diff --git a/CHANGELOG.md b/CHANGELOG.md
index 468027d24..53a7e6c4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@
## [Unreleased]
### Fixed
+* HTTP 请求管线正确性与响应一致性(C458,GFX-1180,GitHub #926):ProtoBuf 成功响应写出前更正 Content-Type 为 `application/x-protobuf`(原恒为 JSON,按媒体类型分派的客户端/网关解析二进制体失败);参数字典改 OrdinalIgnoreCase 并同步把 Query 提取改 TryAdd——大小写差异同名键(Query↔Body、Query↔Query)一律显式 `ParamErr` 拒绝,消除「被校验/被签名的 Query 值被 Body 静默覆盖」窗口;非 JSON 对象 Body(null 字面量/数组/标量/空体)以参数错误拒绝(新键 `Network.Http.RequestBodyNotJsonObject`),不再落入顶层 catch 记服务器错误日志;非 GET 缺 Content-Type 改统一 JSON envelope;DataAnnotations 校验失败业务码由字面量 400 统一为 `GameHttpStatusCode.ParamErr`;Swagger 路由匹配改末段精确比较(互为后缀命令不再错配文档,驼峰路由可匹配)且 GET 参数必填性跟随 `RequiredAttribute`;AOP 拒绝且未自行写响应时管线回写校验失败 envelope(契约写入 `IHttpAopHandler.Run`);`CheckSign` 缺 sign/timestamp 改本地化消息。对外破坏性:依赖 `code == 400` 判定校验失败的客户端改判 `code == 13`;`HttpStatusMessage.Success`/`NotFoundCommand`/`CheckFailedCommand` 及孤儿键 `Network.Http.NotFoundCommand`/`CheckFailedCommand` 删除(公共 API,下游如有引用需迁移)。
* Utility 安全修复:配置凭据脱敏、解压炸弹上限、冲突异常 Secret 脱敏与压缩失败显式抛出(C433,GFX-1173,GitHub #919):①`AppSetting.ToString/ToFormatString` 经浅拷贝掩码后序列化——`DataBasePassword`/`HttpSignSecret`/`InterNodeAuthSecret`/`HttpsCertPassword` 非空值输出 `***`(null/空串原样保留区分「未配置」),四个凭据不再随启动/停止日志明文外发;②`CompressionHelper.Decompress` 新增 `maxOutputLength` 上限重载(旧签名转发 const 默认 64MB),膨胀过程内超限即抛 `InvalidDataException`——Network `DefaultMessageDecompressHandler` 增构造参数上限、RemoteMessaging `IMessageCompressionAlgorithm.Decompress` 接口加参(const 默认值,源码兼容;`DefaultMessageCodec` 显式传 `_maxDecompressedSize`),事后长度检查降级为兜底防线,1MB 高压缩比帧不再可膨胀至 GB 级托管内存;③`SettingFieldConflict` 敏感字段判定扩展 Secret/Token 子串——多 Role 配置冲突异常不再明文携带 `HttpSignSecret`/`InterNodeAuthSecret`;④`RandomHelper` 类文档声明双随机源分级(基础接口 `Random.Shared` 非加密 PRNG 禁用于令牌/验证码/防作弊场景,安全场景用权重系列 CSPRNG 或 `RandomNumberGenerator`);⑤落地已归档 C349 既定方案:`Compress` 异常原样抛出、`Decompress` 对非法/损坏/截断数据统一抛 `InvalidDataException`(含 SharpZipLib 截断流死循环主动判定),删除「失败静默返回原始字节」回退与 `CompressFailed` 本地化键;⑥`FP.Sqrt/Acos` 越界异常构造器实参换序(本地化消息进 `Message`、`"x"` 进 `ParamName`,修复前 `ParamName == null`)。公共 API 行为变更经本条披露;解压炸弹/脱敏/截断/参数名均有回归用例锁定。
* Core 回收链路数据与稳定性修复(C436,GFX-1176,GitHub #922):`Actor.OnRecycleAsync` 在回收回调后删除 `ScheduleIdSet` 中剩余的 Quartz 任务并清空集合,三条回收路径(空闲回收 / `RemoveAsync` / `RemoveAllAsync`)均覆盖;定时任务触发改为 `isNew: false` 反查,目标 Actor 不在内存时不再重建 Actor(消除周期任务复活已回收 Actor 的循环),记 Warning 并删除该任务。`BaseComponent.InactiveAsync` 以 try/finally 保证 `IsActive` 落为 false,`Actor.InactiveAsync` 逐组件隔离异常(Fatal 日志后继续),单个组件反激活失败不再让 Actor 永驻内存,也不再打断 `RemoveAllAsync` 关服流程。显式移除前对有未落库修改的 Actor 强制回存(失败记 Fatal 不阻断移除)。`StateComponent.TimerSaveAsync` 单个状态类型回存失败不再中断整轮,日志级别 Info → Error。`ActorManager.RemoveAsync` 返回真实完成任务(await 返回即回存 / 反激活 / 回收回调均已执行,隔离 Actor 返回异常任务)并同步删除 `ActiveTimeDic` 条目。行为变化:依赖「定时器唤醒离线 Actor」的业务需在触发前自行加载 Actor;组件反激活异常不再向 `RemoveAllAsync` 调用方抛出。回归 11 例(A/B:还原生产改动后 11/11 必败)。
diff --git a/GameFrameX.Localization/Keys.NetworkHttp.cs b/GameFrameX.Localization/Keys.NetworkHttp.cs
index 6f254805c..68698ca6c 100644
--- a/GameFrameX.Localization/Keys.NetworkHttp.cs
+++ b/GameFrameX.Localization/Keys.NetworkHttp.cs
@@ -196,6 +196,15 @@ public static class NetworkHttp
///
public const string DataVerificationFailed = "Network.Http.DataVerificationFailed";
+ ///
+ /// 请求体必须是 JSON 对象
+ ///
+ ///
+ /// 键名: Network.Http.RequestBodyNotJsonObject
+ /// 用途: 当JSON请求体不是对象(null字面量/数组/标量/空体)时返回参数错误
+ ///
+ public const string RequestBodyNotJsonObject = "Network.Http.RequestBodyNotJsonObject";
+
///
/// http命令未包含验证参数{0} 和 {1}
///
@@ -215,24 +224,6 @@ public static class NetworkHttp
///
public const string UndefinedCommand = "Network.Http.UndefinedCommand";
- ///
- /// not found command
- ///
- ///
- /// 键名: Network.Http.NotFoundCommand
- /// 用途: 命令未找到时的错误消息
- ///
- public const string NotFoundCommand = "Network.Http.NotFoundCommand";
-
- ///
- /// check failed command
- ///
- ///
- /// 键名: Network.Http.CheckFailedCommand
- /// 用途: 命令验证失败时的错误消息
- ///
- public const string CheckFailedCommand = "Network.Http.CheckFailedCommand";
-
///
/// server error
///
diff --git a/GameFrameX.Localization/Localization/Messages/Resources.resx b/GameFrameX.Localization/Localization/Messages/Resources.resx
index 24252a40a..f833d4134 100644
--- a/GameFrameX.Localization/Localization/Messages/Resources.resx
+++ b/GameFrameX.Localization/Localization/Messages/Resources.resx
@@ -199,18 +199,15 @@
data verification failed
+
+
+ request body must be a JSON object
http command missing validation parameters {0} and {1}
undefined command
-
-
- not found command
-
-
- check failed command
server error
diff --git a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx
index 5033e34bb..9164c7917 100644
--- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx
+++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx
@@ -217,18 +217,15 @@
数据验证失败
+
+
+ 请求体必须是 JSON 对象
http命令未包含验证参数{0} 和 {1}
未定义的命令
-
-
- 未找到的命令
-
-
- 验证失败的命令
服务器错误
diff --git a/GameFrameX.Network.HTTP/BaseHttpHandler.cs b/GameFrameX.Network.HTTP/BaseHttpHandler.cs
index 935791415..e411fb213 100644
--- a/GameFrameX.Network.HTTP/BaseHttpHandler.cs
+++ b/GameFrameX.Network.HTTP/BaseHttpHandler.cs
@@ -233,7 +233,7 @@ public bool CheckSign(string command, Dictionary paramMap, out s
// 内部验证
if (!paramMap.ContainsKey(GlobalConst.HttpSignKey) || !paramMap.ContainsKey(GlobalConst.HttpTimestampKey))
{
- error = HttpJsonResultData.ValidationErrorString();
+ error = HttpJsonResultData.ValidationErrorString(HttpStatusMessage.ParamErrorMessage);
return false;
}
diff --git a/GameFrameX.Network.HTTP/HttpHandler.cs b/GameFrameX.Network.HTTP/HttpHandler.cs
index f48fd7448..78893498c 100644
--- a/GameFrameX.Network.HTTP/HttpHandler.cs
+++ b/GameFrameX.Network.HTTP/HttpHandler.cs
@@ -92,10 +92,18 @@ public static async Task HandleRequestAsync(HttpContext context, Func();
- var queryStringParamCount = ExtractQueryParameters(context, paramMap);
+ // 参数字典不区分大小写:与 JsonHelper 绑定(PropertyNameCaseInsensitive)口径一致,
+ // Query 与 Body 的大小写差异同名键一律视为重复参数显式拒绝,杜绝 Body 静默覆盖 Query
+ var paramMap = new Dictionary(StringComparer.OrdinalIgnoreCase);
context.Response.Headers.ContentType = JsonContentType;
+ var queryExtraction = await ExtractQueryParametersAsync(context, paramMap);
+ if (queryExtraction.ShouldAbort)
+ {
+ return;
+ }
+
+ var queryStringParamCount = queryExtraction.Count;
var isGetRequest = HttpMethods.IsGet(context.Request.Method);
var parseResult = await TryParseRequestBodyAsync(context, paramMap, isGetRequest);
@@ -154,17 +162,24 @@ public static async Task HandleRequestAsync(HttpContext context, Func paramMap)
+ // 从查询字符串中提取参数并返回参数个数;键不区分大小写重复(如 ?a=1&A=2)时写出参数重复错误响应并返回 ShouldAbort=true。
+ // Extract parameters from the query string and return the count; a case-insensitive duplicate key (e.g. ?a=1&A=2) writes the duplicate-parameter error response and returns ShouldAbort=true.
+ private static async Task<(bool ShouldAbort, int Count)> ExtractQueryParametersAsync(HttpContext context, Dictionary paramMap)
{
var queryStringParamCount = 0;
foreach (var keyValuePair in context.Request.Query)
{
- paramMap.Add(keyValuePair.Key, keyValuePair.Value.ToString());
+ if (!paramMap.TryAdd(keyValuePair.Key, keyValuePair.Value.ToString()))
+ {
+ // 参数Key发生重复(不区分大小写)
+ await context.Response.WriteAsync(HttpJsonResultData.ErrorString(GameHttpStatusCode.ParamErr, LocalizationService.GetString(Localization.Keys.NetworkHttp.ParameterDuplicate, keyValuePair.Key)));
+ return (true, queryStringParamCount);
+ }
+
queryStringParamCount++;
}
- return queryStringParamCount;
+ return (false, queryStringParamCount);
}
// 解析请求体:GET 跳过 Body;ProtoBuf 反序列化;JSON 合并参数。返回解析结果,ShouldAbort=true 表示已写出错误响应、调用方应直接 return。
@@ -176,7 +191,7 @@ private static int ExtractQueryParameters(HttpContext context, Dictionary.ErrorString(GameHttpStatusCode.ParamErr, LocalizationService.GetString(Localization.Keys.NetworkHttp.HttpHeaderContentTypeNull)));
return (true, null, null, false);
}
@@ -220,7 +235,31 @@ private static int ExtractQueryParameters(HttpContext context, Dictionary>(jsonBody);
+ Dictionary jsonKv;
+ try
+ {
+ jsonKv = JsonHelper.Deserialize>(jsonBody);
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ // 数组/标量/畸形 JSON:客户端格式错误按参数错误拒绝,不落入顶层 catch 记服务器错误日志
+ await WriteRequestBodyNotJsonObjectAsync(context);
+ return (true, null, null, false);
+ }
+ catch (ArgumentException)
+ {
+ // 空/空白请求体:JsonHelper 对空串先抛 ArgumentException
+ await WriteRequestBodyNotJsonObjectAsync(context);
+ return (true, null, null, false);
+ }
+
+ if (jsonKv == null)
+ {
+ // body 为 null 字面量时反序列化返回 null
+ await WriteRequestBodyNotJsonObjectAsync(context);
+ return (true, null, null, false);
+ }
+
foreach (var keyValuePair in jsonKv)
{
if (!paramMap.TryAdd(keyValuePair.Key, keyValuePair.Value))
@@ -273,6 +312,12 @@ private static bool RunAopHandlers(HttpContext httpContext, HttpActionContext co
// 执行AOP处理器
if (!RunAopHandlers(httpContext, context, aopHandlerTypes))
{
+ // AOP 拒绝且未自行写出响应时,回写统一校验失败信封,避免客户端收到空 200
+ if (!httpContext.Response.HasStarted)
+ {
+ await httpContext.Response.WriteAsync(HttpJsonResultData.ValidationErrorString());
+ }
+
return (false, null);
}
@@ -326,6 +371,8 @@ private static async Task ExecuteProtoBufAsync(HttpContext httpContext, BaseHttp
ReadOnlyMemory body = ProtoBufSerializerHelper.Serialize(result);
var messageHttpObject = new MessageHttpObject { Id = MessageProtoHelper.GetMessageIdByType(result), UniqueId = context.MessageObject.UniqueId, Body = body.ToArray(), };
var resultResponse = ProtoBufSerializerHelper.Serialize(messageHttpObject);
+ // ProtoBuf 成功响应是二进制负载,写出前更正 Content-Type(入口处统一预设的是 JSON)
+ httpContext.Response.ContentType = ProtoBufContentType;
httpContext.Response.ContentLength = resultResponse.Length;
await httpContext.Response.BodyWriter.WriteAsync(resultResponse);
}
@@ -380,11 +427,11 @@ private static async Task ExecuteJsonMessageRequestAsync(HttpContext httpContext
{
if (validationResults.Count > 0)
{
- await httpContext.Response.WriteAsync(HttpJsonResultData.ErrorString(400, validationResults[0].ErrorMessage));
+ await httpContext.Response.WriteAsync(HttpJsonResultData.ErrorString(GameHttpStatusCode.ParamErr, validationResults[0].ErrorMessage));
}
else
{
- await httpContext.Response.WriteAsync(HttpJsonResultData.ErrorString(400, LocalizationService.GetString(Localization.Keys.NetworkHttp.DataVerificationFailed)));
+ await httpContext.Response.WriteAsync(HttpJsonResultData.ErrorString(GameHttpStatusCode.ParamErr, LocalizationService.GetString(Localization.Keys.NetworkHttp.DataVerificationFailed)));
}
}
}
@@ -460,4 +507,11 @@ private static Task WriteRequestBodyTooLargeAsync(HttpContext context, long maxB
context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
return context.Response.WriteAsync(HttpJsonResultData.ErrorString(StatusCodes.Status413PayloadTooLarge, $"HTTP request body is too large. Max allowed bytes: {maxBodyBytes}."));
}
+
+ // 写出"请求体必须是 JSON 对象"的参数错误响应 / Writes the "request body must be a JSON object" parameter-error response.
+ private static Task WriteRequestBodyNotJsonObjectAsync(HttpContext context)
+ {
+ // Localization: Network.Http.RequestBodyNotJsonObject - 请求体必须是 JSON 对象
+ return context.Response.WriteAsync(HttpJsonResultData.ErrorString(GameHttpStatusCode.ParamErr, LocalizationService.GetString(Localization.Keys.NetworkHttp.RequestBodyNotJsonObject)));
+ }
}
diff --git a/GameFrameX.Network.HTTP/HttpStatusMessage.cs b/GameFrameX.Network.HTTP/HttpStatusMessage.cs
index 1f4df0105..48b1af273 100644
--- a/GameFrameX.Network.HTTP/HttpStatusMessage.cs
+++ b/GameFrameX.Network.HTTP/HttpStatusMessage.cs
@@ -40,14 +40,6 @@ namespace GameFrameX.Network.HTTP;
///
public static class HttpStatusMessage
{
- ///
- /// 成功状态消息常量。
- ///
- ///
- /// Success status message constant.
- ///
- /// 成功消息字符串 / Success message string
- public const string Success = "ok";
///
/// 获取未定义命令的错误消息。
@@ -61,30 +53,6 @@ public static string UndefinedCommand
get { return LocalizationService.GetString(Localization.Keys.NetworkHttp.UndefinedCommand); }
}
- ///
- /// 获取未找到命令的错误消息。
- ///
- ///
- /// Gets the error message for not found commands.
- ///
- /// 未找到命令的本地化错误消息 / Localized error message for not found commands
- public static string NotFoundCommand
- {
- get { return LocalizationService.GetString(Localization.Keys.NetworkHttp.NotFoundCommand); }
- }
-
- ///
- /// 获取验证失败的错误消息。
- ///
- ///
- /// Gets the error message for validation failures.
- ///
- /// 验证失败的本地化错误消息 / Localized error message for validation failures
- public static string CheckFailedCommand
- {
- get { return LocalizationService.GetString(Localization.Keys.NetworkHttp.CheckFailedCommand); }
- }
-
///
/// 获取服务器错误的错误消息。
///
diff --git a/GameFrameX.Network.HTTP/IHttpAopHandler.cs b/GameFrameX.Network.HTTP/IHttpAopHandler.cs
index 4160c64f1..e5d9faf1d 100644
--- a/GameFrameX.Network.HTTP/IHttpAopHandler.cs
+++ b/GameFrameX.Network.HTTP/IHttpAopHandler.cs
@@ -53,6 +53,8 @@ public interface IHttpAopHandler
///
///
/// Executes the interception processing logic.
+ /// 契约:返回 false 表示拒绝该请求并终止后续处理;若实现未自行写出响应,管线将回写统一校验失败响应(HttpJsonResultData 校验失败信封),客户端不会收到空响应。
+ /// Contract: returning false rejects the request and stops further processing; if the implementation has not written a response itself, the pipeline writes the unified validation-failure response (the HttpJsonResultData validation-failure envelope), so the client never receives an empty response.
///
/// HTTP 上下文 / HTTP context
/// HTTP 处理管线统一请求上下文 / Unified request context for the HTTP handling pipeline
diff --git a/GameFrameX.Network.HTTP/SwaggerOperationFilter.cs b/GameFrameX.Network.HTTP/SwaggerOperationFilter.cs
index bc007bf01..f3a160448 100644
--- a/GameFrameX.Network.HTTP/SwaggerOperationFilter.cs
+++ b/GameFrameX.Network.HTTP/SwaggerOperationFilter.cs
@@ -30,6 +30,7 @@
using System.Collections.Concurrent;
using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
using System.Reflection;
using System.Text.Json.Nodes;
using Microsoft.OpenApi;
@@ -131,17 +132,19 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context)
/// 根据路由模板查找匹配的 HTTP 处理器。
///
///
- /// Finds the HTTP handler whose cached standard command matches the end of the route template.
+ /// Finds the HTTP handler whose cached command exactly matches the last segment of the route template (original or snake-case form, case-insensitive). Suffix containment (e.g. login vs req_login) must not match.
///
/// 路由模板 / Route template
/// 匹配的处理器,未找到时返回 null / The matching handler, or null when none matches
private BaseHttpHandler FindHandler(string routeTemplate)
{
+ // 取路由最后一段命令精确匹配;EndsWith 会把互为后缀的命令(如 login/req_login)错配到错误处理器
+ var command = routeTemplate.Substring(routeTemplate.LastIndexOf('/') + 1);
return _handlers.FirstOrDefault(h =>
{
var handlerType = h.GetType();
var mappingAttr = MappingAttributeCache.GetOrAdd(handlerType, t => t.GetCustomAttribute());
- return routeTemplate.EndsWith(mappingAttr?.StandardCmd ?? "", StringComparison.OrdinalIgnoreCase);
+ return string.Equals(command, mappingAttr?.OriginalCmd, StringComparison.OrdinalIgnoreCase) || string.Equals(command, mappingAttr?.StandardCmd, StringComparison.OrdinalIgnoreCase);
});
}
@@ -198,7 +201,7 @@ private static void AddQueryParameters(OpenApiOperation operation, Type messageT
{
Name = property.Name,
In = ParameterLocation.Query,
- Required = false, // 可根据 RequiredAttribute 判断
+ Required = property.GetCustomAttribute() != null, // 必填性与 DataAnnotations 校验行为一致
Description = propDescriptionAttr?.Description ?? property.Name,
Schema = GetSchemaForType(property.PropertyType),
});
diff --git a/Tests/GameFrameX.Tests/Network/Http/HttpHandlerPipelineConsistencyTests.cs b/Tests/GameFrameX.Tests/Network/Http/HttpHandlerPipelineConsistencyTests.cs
new file mode 100644
index 000000000..7b85969c4
--- /dev/null
+++ b/Tests/GameFrameX.Tests/Network/Http/HttpHandlerPipelineConsistencyTests.cs
@@ -0,0 +1,455 @@
+// ==========================================================================================
+// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利
+// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights
+// 均受中华人民共和国及相关国际法律法规保护。
+// are protected by the laws of the People's Republic of China and applicable international regulations.
+// 使用本项目须严格遵守相应法律法规与开源许可证之规定。
+// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses.
+// 本项目采用 Apache License 2.0 单协议分发,
+// This project is licensed solely under the Apache License 2.0,
+// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。
+// please refer to the LICENSE file in the root directory of the source code for the full license text.
+// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、
+// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order,
+// 侵犯他人合法权益等法律法规所禁止的行为!
+// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations!
+// 因基于本项目二次开发所产生的一切法律纠纷与责任,
+// Any legal disputes or liabilities arising from secondary development based on this project
+// 本项目组织与贡献者概不承担。
+// shall be borne solely by the developer; the project organization and contributors assume no responsibility.
+// GitHub 仓库:https://github.com/GameFrameX
+// GitHub Repository: https://github.com/GameFrameX
+// Gitee 仓库:https://gitee.com/GameFrameX
+// Gitee Repository: https://gitee.com/GameFrameX
+// CNB 仓库:https://cnb.cool/GameFrameX
+// CNB Repository: https://cnb.cool/GameFrameX
+// 官方文档:https://gameframex.doc.alianblank.com/
+// Official Documentation: https://gameframex.doc.alianblank.com/
+// ==========================================================================================
+
+using System.ComponentModel.DataAnnotations;
+using System.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Http.Features;
+using GameFrameX.Foundation.Http.Normalization;
+using GameFrameX.Network.Abstractions;
+using GameFrameX.Network.HTTP;
+using GameFrameX.Network.Messages;
+using GameFrameX.ProtoBuf.Net;
+using GameFrameX.Proto.Proto;
+using GameFrameX.Tests.Utility;
+using GameFrameX.Utility.Runtime;
+using GameFrameX.Utility.Setting;
+using Microsoft.AspNetCore.Http;
+
+namespace GameFrameX.Tests.Network.Http;
+
+///
+/// HTTP 请求管线正确性与响应一致性回归(GFX-1180 / C458):
+/// ProtoBuf Content-Type、大小写参数重复拒绝、非对象 JSON Body 参数错误、统一 JSON envelope、
+/// DataAnnotations 业务码、AOP 拒绝响应契约、CheckSign 缺参本地化消息。
+///
+[Collection(GameAppRuntimeCollection.Name)]
+public class HttpHandlerPipelineConsistencyTests
+{
+ private static readonly object SettingsLock = new();
+
+ [Fact]
+ public async Task HandleRequest_ShouldSetProtoContentType_ForProtoBufSuccess()
+ {
+ EnsureSettings();
+ EnsureProtoMessageRegistry();
+ var handlerCalled = false;
+ var body = BuildProtoBufRequestBody(new ReqHeartBeat { Timestamp = 12345, });
+ var context = CreateContext("application/x-protobuf", new MemoryStream(body));
+
+ await HttpHandler.HandleRequestAsync(context, _ => new ProtoBufEchoHttpHandler(() => handlerCalled = true));
+
+ Assert.True(handlerCalled);
+ Assert.NotNull(context.Response.ContentType);
+ Assert.StartsWith("application/x-protobuf", context.Response.ContentType, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task HandleRequest_ShouldKeepJsonContentType_ForJsonErrorPath()
+ {
+ EnsureSettings();
+ var handlerCalled = false;
+ var context = CreateContext("text/plain", new MemoryStream(Encoding.UTF8.GetBytes("plain")));
+
+ await HttpHandler.HandleRequestAsync(context, _ =>
+ {
+ handlerCalled = true;
+ return null;
+ });
+
+ Assert.False(handlerCalled);
+ Assert.StartsWith("application/json", context.Response.ContentType, StringComparison.OrdinalIgnoreCase);
+ var envelope = await ReadEnvelopeAsync(context);
+ Assert.Equal(GameHttpStatusCode.ParamErr, envelope.GetProperty("code").GetInt32());
+ }
+
+ [Fact]
+ public async Task HandleRequest_ShouldRejectParameter_WhenBodyKeyDiffersOnlyByCasing()
+ {
+ EnsureSettings();
+ var handlerCalled = false;
+ var context = CreateContext("application/json", new MemoryStream(Encoding.UTF8.GetBytes("{\"userId\":2}")));
+ context.Request.QueryString = new QueryString("?UserId=1");
+
+ await HttpHandler.HandleRequestAsync(context, _ => new PlainJsonProbeHttpHandler(() => handlerCalled = true));
+
+ Assert.False(handlerCalled);
+ var envelope = await ReadEnvelopeAsync(context);
+ Assert.Equal(GameHttpStatusCode.ParamErr, envelope.GetProperty("code").GetInt32());
+ Assert.Contains("userId", envelope.GetProperty("message").GetString(), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task HandleRequest_ShouldRejectParameter_WhenExactDuplicateKeyExists()
+ {
+ EnsureSettings();
+ var handlerCalled = false;
+ var context = CreateContext("application/json", new MemoryStream(Encoding.UTF8.GetBytes("{\"userId\":2}")));
+ context.Request.QueryString = new QueryString("?userId=1");
+
+ await HttpHandler.HandleRequestAsync(context, _ => new PlainJsonProbeHttpHandler(() => handlerCalled = true));
+
+ Assert.False(handlerCalled);
+ var envelope = await ReadEnvelopeAsync(context);
+ Assert.Equal(GameHttpStatusCode.ParamErr, envelope.GetProperty("code").GetInt32());
+ }
+
+ [Fact]
+ public async Task HandleRequest_ShouldExecuteHandler_WhenBodyOnlyParameterProvided()
+ {
+ EnsureSettings();
+ var handlerCalled = false;
+ var context = CreateContext("application/json", new MemoryStream(Encoding.UTF8.GetBytes("{\"userId\":2}")));
+
+ await HttpHandler.HandleRequestAsync(context, _ => new PlainJsonProbeHttpHandler(() => handlerCalled = true));
+
+ Assert.True(handlerCalled);
+ }
+
+ [Theory]
+ [InlineData("null")]
+ [InlineData("[1,2]")]
+ [InlineData("123")]
+ [InlineData("\"text\"")]
+ [InlineData("")]
+ public async Task HandleRequest_ShouldReturnParamError_WhenJsonBodyIsNotAnObject(string requestBody)
+ {
+ EnsureSettings();
+ var handlerCalled = false;
+ var context = CreateContext("application/json", new MemoryStream(Encoding.UTF8.GetBytes(requestBody)));
+
+ await HttpHandler.HandleRequestAsync(context, _ => new PlainJsonProbeHttpHandler(() => handlerCalled = true));
+
+ Assert.False(handlerCalled);
+ var envelope = await ReadEnvelopeAsync(context);
+ Assert.Equal(GameHttpStatusCode.ParamErr, envelope.GetProperty("code").GetInt32());
+ Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+ }
+
+ [Fact]
+ public async Task HandleRequest_ShouldReturnJsonEnvelope_WhenContentTypeMissingOnPost()
+ {
+ EnsureSettings();
+ var handlerCalled = false;
+ var context = CreateContext(null, Stream.Null);
+
+ await HttpHandler.HandleRequestAsync(context, _ =>
+ {
+ handlerCalled = true;
+ return null;
+ });
+
+ Assert.False(handlerCalled);
+ Assert.StartsWith("application/json", context.Response.ContentType, StringComparison.OrdinalIgnoreCase);
+ var envelope = await ReadEnvelopeAsync(context);
+ Assert.Equal(GameHttpStatusCode.ParamErr, envelope.GetProperty("code").GetInt32());
+ }
+
+ [Fact]
+ public async Task HandleRequest_ShouldReturnParamErrCode_WhenDataAnnotationsValidationFails()
+ {
+ EnsureSettings();
+ var handlerCalled = false;
+ var context = CreateContext("application/json", new MemoryStream(Encoding.UTF8.GetBytes("{\"optionalName\":\"x\"}")));
+
+ await HttpHandler.HandleRequestAsync(context, _ => new DataAnnotationsProbeHttpHandler(() => handlerCalled = true));
+
+ Assert.False(handlerCalled);
+ var envelope = await ReadEnvelopeAsync(context);
+ Assert.Equal(GameHttpStatusCode.ParamErr, envelope.GetProperty("code").GetInt32());
+ }
+
+ [Fact]
+ public async Task HandleRequest_ShouldWriteValidationError_WhenAopRejectsWithoutResponse()
+ {
+ EnsureSettings();
+ var handlerCalled = false;
+ var context = CreateContext("application/json", new MemoryStream(Encoding.UTF8.GetBytes("{}")));
+
+ await HttpHandler.HandleRequestAsync(context, _ => new PlainJsonProbeHttpHandler(() => handlerCalled = true), new List { new RejectingAopHandler(), });
+
+ Assert.False(handlerCalled);
+ var envelope = await ReadEnvelopeAsync(context);
+ Assert.Equal(400, envelope.GetProperty("code").GetInt32());
+ Assert.False(string.IsNullOrEmpty(envelope.GetProperty("message").GetString()));
+ }
+
+ [Fact]
+ public async Task HandleRequest_ShouldNotAppendResponse_WhenAopWritesOwnResponse()
+ {
+ EnsureSettings();
+ var handlerCalled = false;
+ var context = CreateContext("application/json", new MemoryStream(Encoding.UTF8.GetBytes("{}")));
+ // DefaultHttpContext 不跟踪 HasStarted(WriteAsync/StartAsync 均不置位,真实服务器由宿主 feature 置位),
+ // 以包装 feature 模拟真实服务器上“AOP 已写出响应”的状态
+ context.Features.Set(new StartedResponseFeature(context.Features.Get()));
+
+ await HttpHandler.HandleRequestAsync(context, _ => new PlainJsonProbeHttpHandler(() => handlerCalled = true), new List { new RespondingRejectAopHandler(), });
+
+ Assert.False(handlerCalled);
+ context.Response.Body.Seek(0, SeekOrigin.Begin);
+ using (var reader = new StreamReader(context.Response.Body, Encoding.UTF8))
+ {
+ var responseBody = await reader.ReadToEndAsync();
+ Assert.Equal("custom-response", responseBody);
+ }
+ }
+
+ [Fact]
+ public void CheckSign_ShouldReturnLocalizedMessage_WhenSignParametersMissing()
+ {
+ EnsureSettings();
+ var setting = GlobalSettings.CurrentSetting;
+ var originalRequireSign = setting.HttpRequireSign;
+ var originalSecret = setting.HttpSignSecret;
+ try
+ {
+ setting.HttpRequireSign = true;
+ setting.HttpSignSecret = "unit-test-secret";
+ var handler = new PlainJsonProbeHttpHandler(() => { });
+
+ var isValid = handler.CheckSign(new Dictionary(), out var error);
+
+ Assert.False(isValid);
+ Assert.Contains(GlobalConst.HttpSignKey, error, StringComparison.Ordinal);
+ Assert.Contains(GlobalConst.HttpTimestampKey, error, StringComparison.Ordinal);
+ }
+ finally
+ {
+ setting.HttpRequireSign = originalRequireSign;
+ setting.HttpSignSecret = originalSecret;
+ }
+ }
+
+ private static DefaultHttpContext CreateContext(string contentType, Stream body)
+ {
+ var context = new DefaultHttpContext();
+ context.Request.Method = HttpMethods.Post;
+ context.Request.Path = "/game/api/consistencyProbe";
+ context.Request.ContentType = contentType;
+ context.Request.Body = body;
+ context.Response.Body = new MemoryStream();
+ return context;
+ }
+
+ private static async Task ReadEnvelopeAsync(DefaultHttpContext context)
+ {
+ context.Response.Body.Seek(0, SeekOrigin.Begin);
+ using (var reader = new StreamReader(context.Response.Body, Encoding.UTF8))
+ {
+ var responseBody = await reader.ReadToEndAsync();
+ return JsonDocument.Parse(responseBody).RootElement;
+ }
+ }
+
+ private static byte[] BuildProtoBufRequestBody(MessageObject request)
+ {
+ var messageHttpObject = new MessageHttpObject
+ {
+ Id = MessageProtoHelper.GetMessageIdByType(request.GetType()),
+ UniqueId = 7,
+ Body = ProtoBufSerializerHelper.Serialize(request).ToArray(),
+ };
+ return ProtoBufSerializerHelper.Serialize(messageHttpObject).ToArray();
+ }
+
+ private static void EnsureProtoMessageRegistry()
+ {
+ // 惰性初始化消息注册表(对齐 SessionAuthenticationMiddlewareTests 模式),避免清空其他夹具的注册
+ if (MessageProtoHelper.GetMessageTypeById(((10) << 16) + 10) == null)
+ {
+ MessageProtoHelper.Init(typeof(ReqHeartBeat).Assembly);
+ }
+ }
+
+ private static void EnsureSettings()
+ {
+ if (GlobalSettings.CurrentSetting != null)
+ {
+ GameAppRuntime.MarkStarted(DateTime.UtcNow);
+ return;
+ }
+
+ lock (SettingsLock)
+ {
+ if (GlobalSettings.CurrentSetting == null)
+ {
+ GlobalSettings.SetCurrentSetting(new AppSetting { ServerId = 1, });
+ }
+ }
+
+ GameAppRuntime.MarkStarted(DateTime.UtcNow);
+ }
+
+ ///
+ /// ProtoBuf 回显处理器:原样返回请求消息,用于断言 ProtoBuf 成功路径的响应头。
+ ///
+ private sealed class ProtoBufEchoHttpHandler : BaseHttpHandler
+ {
+ private readonly Action _probe;
+
+ public ProtoBufEchoHttpHandler(Action probe)
+ {
+ _probe = probe;
+ }
+
+ public override Task ActionMessageObjectAsync(HttpActionContext context)
+ {
+ _probe();
+ return Task.FromResult(context.MessageObject);
+ }
+ }
+
+ ///
+ /// Plain JSON 探针处理器。
+ ///
+ private sealed class PlainJsonProbeHttpHandler : BaseHttpHandler
+ {
+ private readonly Action _probe;
+
+ public PlainJsonProbeHttpHandler(Action probe)
+ {
+ _probe = probe;
+ }
+
+ public override Task ActionAsync(HttpActionContext context)
+ {
+ _probe();
+ return Task.FromResult(HttpJsonResultData.SuccessString());
+ }
+ }
+
+ ///
+ /// 带 [Required] 强类型请求的处理器:用于 DataAnnotations 校验失败业务码回归。
+ ///
+ [HttpMessageMapping(typeof(DataAnnotationsProbeHttpHandler), typeof(ValidationProbeRequest))]
+ private sealed class DataAnnotationsProbeHttpHandler : BaseHttpHandler
+ {
+ private readonly Action _probe;
+
+ public DataAnnotationsProbeHttpHandler(Action probe)
+ {
+ _probe = probe;
+ }
+
+ public override Task ActionAsync(HttpActionContext context)
+ {
+ _probe();
+ return Task.FromResult(HttpJsonResultData.SuccessString());
+ }
+ }
+
+ private sealed class ValidationProbeRequest : HttpMessageRequestBase
+ {
+ [Required]
+ public string RequiredName { get; set; }
+
+ public string OptionalName { get; set; }
+ }
+
+ private sealed class RejectingAopHandler : IHttpAopHandler
+ {
+ public int Priority
+ {
+ get { return 0; }
+ }
+
+ public bool Run(HttpContext httpContext, HttpActionContext context)
+ {
+ return false;
+ }
+ }
+
+ private sealed class RespondingRejectAopHandler : IHttpAopHandler
+ {
+ public int Priority
+ {
+ get { return 0; }
+ }
+
+ public bool Run(HttpContext httpContext, HttpActionContext context)
+ {
+ httpContext.Response.WriteAsync("custom-response").GetAwaiter().GetResult();
+ return false;
+ }
+ }
+
+ ///
+ /// 恒置位 HasStarted 的响应 feature 包装:DefaultHttpContext 不跟踪响应启动状态,
+ /// 用于在内存测试中模拟真实服务器“响应已写出”的语义(见探测实验)。
+ ///
+ private sealed class StartedResponseFeature : IHttpResponseFeature
+ {
+ private readonly IHttpResponseFeature _inner;
+
+ public StartedResponseFeature(IHttpResponseFeature inner)
+ {
+ _inner = inner;
+ }
+
+ public Stream Body
+ {
+ get { return _inner.Body; }
+ set { _inner.Body = value; }
+ }
+
+ public void OnCompleted(Func