From be86ae8449fa04cc612ecfbac3433ab8910a35bc Mon Sep 17 00:00:00 2001 From: Blank Date: Sun, 1 Nov 2026 15:24:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(network-http):=20=E4=BF=AE=E5=A4=8DHTTP?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E7=AE=A1=E7=BA=BF=E6=AD=A3=E7=A1=AE=E6=80=A7?= =?UTF-8?q?=E4=B8=8E=E5=93=8D=E5=BA=94=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按全量代码审查 GFX-1180(CR-M01/M02/M03、CR-L01~L05)落地 8 项: ProtoBuf 成功响应写出前更正 Content-Type 为 application/x-protobuf; 参数字典改 OrdinalIgnoreCase 与强类型绑定口径对齐,大小写差异同名键 显式拒绝,消除 Body 静默覆盖 Query 值的校验替换窗口;非对象/空 JSON Body 统一按参数错误拒绝(新增本地化键 Network.Http.RequestBodyNotJsonObject),不再归为服务器错误; 缺 Content-Type 与 DataAnnotations 校验失败统一 JSON envelope 且 业务码由字面量 400 改 GameHttpStatusCode.ParamErr;Swagger 路由 匹配改末段精确比较并修复驼峰路由无法匹配的缺口,GET 参数必填性 跟随 RequiredAttribute;AOP 拒绝且未自写响应时回写校验失败信封并 补 IHttpAopHandler 契约文档;CheckSign 缺参错误改本地化消息; 删除 HttpStatusMessage 无引用成员与孤儿本地化键。 BREAKING CHANGE: DataAnnotations 校验失败业务码 400 改为 13; ProtoBuf 成功响应 Content-Type 由 JSON 改 application/x-protobuf; 参数合并与取键口径改为大小写不敏感;HttpStatusMessage 的 Success/NotFoundCommand/CheckFailedCommand 公共成员删除。 Closes #926 Linear: GFX-1180 --- CHANGELOG.md | 1 + GameFrameX.Localization/Keys.NetworkHttp.cs | 27 +- .../Localization/Messages/Resources.resx | 9 +- .../Messages/Resources.zh-CN.resx | 9 +- GameFrameX.Network.HTTP/BaseHttpHandler.cs | 2 +- GameFrameX.Network.HTTP/HttpHandler.cs | 74 ++- GameFrameX.Network.HTTP/HttpStatusMessage.cs | 32 -- GameFrameX.Network.HTTP/IHttpAopHandler.cs | 2 + .../SwaggerOperationFilter.cs | 9 +- .../HttpHandlerPipelineConsistencyTests.cs | 455 ++++++++++++++++++ .../Http/HttpMessageMappingAttributeTests.cs | 4 +- .../Http/SwaggerOperationFilterMatchTests.cs | 160 ++++++ 12 files changed, 706 insertions(+), 78 deletions(-) create mode 100644 Tests/GameFrameX.Tests/Network/Http/HttpHandlerPipelineConsistencyTests.cs create mode 100644 Tests/GameFrameX.Tests/Network/Http/SwaggerOperationFilterMatchTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3805c7228..40565f88e 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,下游如有引用需迁移)。 * 入队与定时撮合解耦(C406,GFX-1104):删除每次入队的全量撮合及最新票据扫描,复用 `matchmaker-run` 按宿主调度节奏批量处理队列(默认 1000ms)。单轮撮合算法保持不变;桥测试覆盖四名玩家单批次形成两组及批次前取消不参与分配。 * 撮合通知异步执行与停用退订(C407,GFX-1105,GitHub #733):`ICapabilityEventBus.Subscribe` 返回幂等 `IDisposable` 退订句柄(void → IDisposable 为二进制破坏性变更——包族消费方需重新编译;源码侧忽略返回值的既有调用不受影响);online-match 模块撮合分配事件回调改为纯入队(移除同步总线回调内 `ResolveAssignmentAsync` / `session.WriteAsync` 两处 `GetAwaiter().GetResult()` 死锁面),新增 `match-assignment-notification-tick` 每轮有限批次(64)异步解析并推送(条目级失败不阻断同批其余条目,异常交调度器既有日志路径);`DeactivateAsync` 停用收口——关闭入队、退订、清空待处理并等待在途批次完成,热更/停机后旧模块零通知。回归 13 例:总线退订契约 5 + 模块生命周期 8(含对旧同步实现确定性红的发布不同步解析 / 停用退订 / 停用清空 / 容错批次)。 diff --git a/GameFrameX.Localization/Keys.NetworkHttp.cs b/GameFrameX.Localization/Keys.NetworkHttp.cs index 003521c15..0fc5328b1 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 a048fd266..a791725ba 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.resx @@ -193,18 +193,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 f6ec046d9..74549f038 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx @@ -211,18 +211,15 @@ 数据验证失败 + + + 请求体必须是 JSON 对象 http命令未包含验证参数{0} 和 {1} 未定义的命令 - - - 未找到的命令 - - - 验证失败的命令 服务器错误 diff --git a/GameFrameX.Network.HTTP/BaseHttpHandler.cs b/GameFrameX.Network.HTTP/BaseHttpHandler.cs index c0ab480a8..d127941e4 100644 --- a/GameFrameX.Network.HTTP/BaseHttpHandler.cs +++ b/GameFrameX.Network.HTTP/BaseHttpHandler.cs @@ -137,7 +137,7 @@ public bool CheckSign(Dictionary paramMap, out string error) // 内部验证 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 38c61cd1e..ca7d7ff66 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 callback, object state) + { + _inner.OnCompleted(callback, state); + } + + public void OnStarting(Func callback, object state) + { + _inner.OnStarting(callback, state); + } + + public int StatusCode + { + get { return _inner.StatusCode; } + set { _inner.StatusCode = value; } + } + + public string ReasonPhrase + { + get { return _inner.ReasonPhrase; } + set { _inner.ReasonPhrase = value; } + } + + public IHeaderDictionary Headers + { + get { return _inner.Headers; } + set { _inner.Headers = value; } + } + + public bool HasStarted + { + get { return true; } + } + } +} diff --git a/Tests/GameFrameX.Tests/Network/Http/HttpMessageMappingAttributeTests.cs b/Tests/GameFrameX.Tests/Network/Http/HttpMessageMappingAttributeTests.cs index 05133771a..6a04d8d6c 100644 --- a/Tests/GameFrameX.Tests/Network/Http/HttpMessageMappingAttributeTests.cs +++ b/Tests/GameFrameX.Tests/Network/Http/HttpMessageMappingAttributeTests.cs @@ -69,7 +69,7 @@ public async Task HandleRequest_ShouldBindTypedRequestFromJsonBody_WhenRequestTy Assert.Equal(18, request.Age); } - // (a) [Required] 字段缺失 → 校验错误响应(非 code 0),handler 不执行 + // (a) [Required] 字段缺失 → 参数错误响应(C458 起业务码统一为 GameHttpStatusCode.ParamErr,不再用 HTTP 语义字面量 400),handler 不执行 [Fact] public async Task HandleRequest_ShouldReturnValidationError_WhenRequiredFieldIsMissing() { @@ -86,7 +86,7 @@ public async Task HandleRequest_ShouldReturnValidationError_WhenRequiredFieldIsM Assert.False(handlerCalled); var code = GetResponseCode(context); Assert.NotEqual(0, code); - Assert.Equal(400, code); + Assert.Equal(GameHttpStatusCode.ParamErr, code); } // (b) GET + RequestType → Query 参数绑定到强类型请求并执行 diff --git a/Tests/GameFrameX.Tests/Network/Http/SwaggerOperationFilterMatchTests.cs b/Tests/GameFrameX.Tests/Network/Http/SwaggerOperationFilterMatchTests.cs new file mode 100644 index 000000000..2c2b08526 --- /dev/null +++ b/Tests/GameFrameX.Tests/Network/Http/SwaggerOperationFilterMatchTests.cs @@ -0,0 +1,160 @@ +// ========================================================================================== +// 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 GameFrameX.Foundation.Http.Normalization; +using GameFrameX.Network.HTTP; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace GameFrameX.Tests.Network.Http; + +/// +/// Swagger 操作过滤器路由匹配回归(GFX-1180 / C458 CR-L03): +/// 互为后缀的命令(login / req_login)按路由最后一段精确匹配不得错配文档; +/// 驼峰(OriginalCmd)路由同样可匹配;GET Query 参数必填性跟随 RequiredAttribute。 +/// +public class SwaggerOperationFilterMatchTests +{ + [Fact] + public void Apply_ShouldMatchExactCommand_WhenCommandsAreMutualSuffixes() + { + // LoginHttpHandler 排在列表首位:旧 EndsWith 实现会先命中它("game/api/req_login".EndsWith("login")), + // 新实现必须按最后一段精确匹配到 ReqLoginHttpHandler + var filter = CreateFilter(new LoginHttpHandler(), new ReqLoginHttpHandler()); + + var (operation, repository) = Apply(filter, "game/api/req_login", "POST"); + + Assert.NotNull(operation.RequestBody); + Assert.Contains("ReqLoginSwaggerRequest", repository.Schemas.Keys); + Assert.DoesNotContain("LoginSwaggerRequest", repository.Schemas.Keys); + } + + [Fact] + public void Apply_ShouldMatchOriginalCmd_WhenRouteUsesCamelCaseCommand() + { + var filter = CreateFilter(new ReqPlayerListHttpHandler()); + + var (operation, repository) = Apply(filter, "game/api/ReqPlayerList", "POST"); + + Assert.NotNull(operation.RequestBody); + Assert.Contains("ReqPlayerListSwaggerRequest", repository.Schemas.Keys); + } + + [Fact] + public void Apply_ShouldNotMatchHandler_WhenRouteCommandIsNotExact() + { + var filter = CreateFilter(new LoginHttpHandler()); + + var (operation, repository) = Apply(filter, "game/api/req_login", "POST"); + + Assert.DoesNotContain("LoginSwaggerRequest", repository.Schemas.Keys); + } + + [Fact] + public void Apply_ShouldMarkQueryParameterRequired_FromRequiredAttribute() + { + var filter = CreateFilter(new PlayerListQueryHttpHandler()); + + var (operation, repository) = Apply(filter, "game/api/PlayerListQuery", "GET"); + + var requiredParameter = Assert.Single(operation.Parameters, parameter => parameter.Name == "RequiredPlayerId"); + var optionalParameter = Assert.Single(operation.Parameters, parameter => parameter.Name == "OptionalKeyword"); + Assert.True(requiredParameter.Required); + Assert.False(optionalParameter.Required); + } + + private static SwaggerOperationFilter CreateFilter(params BaseHttpHandler[] handlers) + { + return new SwaggerOperationFilter(new List(handlers)); + } + + private static (OpenApiOperation Operation, SchemaRepository Repository) Apply(SwaggerOperationFilter filter, string relativePath, string httpMethod) + { + var schemaRepository = new SchemaRepository(); + var schemaGenerator = new SchemaGenerator(new SchemaGeneratorOptions(), new JsonSerializerDataContractResolver(new System.Text.Json.JsonSerializerOptions())); + var apiDescription = new ApiDescription { RelativePath = relativePath, HttpMethod = httpMethod, }; + var context = new OperationFilterContext(apiDescription, schemaGenerator, schemaRepository, null, null); + var operation = new OpenApiOperation { Parameters = new List(), }; + filter.Apply(operation, context); + return (operation, schemaRepository); + } + + private abstract class ProbeHttpHandlerBase : BaseHttpHandler + { + public override Task ActionAsync(HttpActionContext context) + { + return Task.FromResult(HttpJsonResultData.SuccessString()); + } + } + + [HttpMessageMapping(typeof(LoginHttpHandler), typeof(LoginSwaggerRequest))] + private sealed class LoginHttpHandler : ProbeHttpHandlerBase + { + } + + [HttpMessageMapping(typeof(ReqLoginHttpHandler), typeof(ReqLoginSwaggerRequest))] + private sealed class ReqLoginHttpHandler : ProbeHttpHandlerBase + { + } + + [HttpMessageMapping(typeof(ReqPlayerListHttpHandler), typeof(ReqPlayerListSwaggerRequest))] + private sealed class ReqPlayerListHttpHandler : ProbeHttpHandlerBase + { + } + + [HttpMessageMapping(typeof(PlayerListQueryHttpHandler), typeof(PlayerListQueryRequest), HttpMethod = HttpMethodType.GET)] + private sealed class PlayerListQueryHttpHandler : ProbeHttpHandlerBase + { + } + + private sealed class LoginSwaggerRequest : HttpMessageRequestBase + { + public string LoginName { get; set; } + } + + private sealed class ReqLoginSwaggerRequest : HttpMessageRequestBase + { + public string CredentialCode { get; set; } + } + + private sealed class ReqPlayerListSwaggerRequest : HttpMessageRequestBase + { + public string PlayerListKeyword { get; set; } + } + + private sealed class PlayerListQueryRequest : HttpMessageRequestBase + { + [Required] + public int RequiredPlayerId { get; set; } + + public string OptionalKeyword { get; set; } + } +}