diff --git a/src/SimpleAdmin.Api.IntegrationTests/WeChatMessagePushIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/WeChatMessagePushIntegrationTests.cs new file mode 100644 index 0000000..1e523ce --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/WeChatMessagePushIntegrationTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; +using Oms.Domain.SystemAggregate.Consts; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using SimpleAdmin.Api.IntegrationTests.Support; +using SimpleAdmin.Api.WeChat.Options; + +namespace SimpleAdmin.Api.IntegrationTests; + +public class WeChatMessagePushIntegrationTests +{ + [Fact] + public void WeChatSignature_ShouldMatchOfficialExample() + { + const string token = "AAAAA"; + const string timestamp = "1714037059"; + const string nonce = "486452656"; + const string expected = "899cf89e464efb63f54ddac96b0a0a235f53aa78"; + + SignWeChat(token, timestamp, nonce).Should().Be(expected); + } + + [Fact] + public async Task JsonPush_WithValidSignature_ShouldReturnSuccess() + { + const string token = "AAAAA"; + const string timestamp = "1714037059"; + const string nonce = "486452656"; + const string signature = "899cf89e464efb63f54ddac96b0a0a235f53aa78"; + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + ConfigureWeChatFallback(services, token); + }); + + const string json = """ + { + "ToUserName": "gh_97417a04a28d", + "FromUserName": "o9AgO5Kd5ggOC-bXrbNODIiE3bGY", + "CreateTime": 1714037059, + "MsgType": "event", + "Event": "debug_demo", + "debug_str": "hello world" + } + """; + + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await host.Client.PostAsync( + $"/apiwechat/verification?signature={signature}×tamp={timestamp}&nonce={nonce}", + content); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Be("success"); + } + + [Fact] + public async Task JsonPush_WithInvalidSignature_ShouldReturnError() + { + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + ConfigureWeChatFallback(services, "AAAAA"); + }); + + const string json = """{"MsgType":"event","Event":"debug_demo"}"""; + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await host.Client.PostAsync( + "/apiwechat/verification?signature=invalid×tamp=1714037059&nonce=486452656", + content); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Be("error"); + } + + [Fact] + public async Task XmlPush_WithValidSignature_AndUnhandledEvent_ShouldReturnSuccess() + { + const string token = "test-token"; + const string timestamp = "1400000000"; + const string nonce = "nonce"; + var signature = SignWeChat(token, timestamp, nonce); + + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + ConfigureWeChatFallback(services, token); + }); + + const string xml = """ + + + + 1400000000 + + + + """; + + using var content = new StringContent(xml, Encoding.UTF8, "text/xml"); + var response = await host.Client.PostAsync( + $"/wechat/mp/wx-test?signature={signature}×tamp={timestamp}&nonce={nonce}", + content); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Be("success"); + } + + private static void ConfigureWeChatFallback(IServiceCollection services, string token) + { + services.PostConfigure(options => + { + options.Token = token; + options.AppId = "wx-test"; + }); + + var configRepository = new Mock(); + configRepository + .Setup(x => x.GetByConfigCodeAsync(SystemOtherConfigCodes.WeChatMp, It.IsAny())) + .ReturnsAsync((SystemOtherConfig?)null); + services.RemoveAll(); + services.AddSingleton(configRepository.Object); + } + + private static string SignWeChat(string token, string timestamp, string nonce) + { + var parts = new[] { token, timestamp, nonce }.OrderBy(x => x, StringComparer.Ordinal).ToArray(); + var hash = SHA1.HashData(Encoding.UTF8.GetBytes(string.Concat(parts))); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs index 2198035..9f3a290 100644 --- a/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs +++ b/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs @@ -16,12 +16,12 @@ namespace SimpleAdmin.Api.IntegrationTests; public class WeChatRouteIntegrationTests { [Fact] - public async Task WeChatLegacyVerification_ShouldRegisterGetOnly() + public async Task WeChatLegacyVerification_ShouldRegisterGetAndPostMethods() { await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: ConfigureWeChatFallback); var methods = host.GetHttpMethods("apiwechat/verification"); - methods.Should().BeEquivalentTo(["GET"]); + methods.Should().BeEquivalentTo(["GET", "POST"]); } [Fact] diff --git a/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs index f805902..df386f8 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs @@ -84,12 +84,28 @@ public partial class AuthService } /// - /// 微信消息/事件回调。 + /// 微信公众号消息/事件推送(明文,XML/JSON)。 + /// + [AllowAnonymous] + [HttpPost("/apiwechat/verification")] + [Consumes("text/xml", "application/xml", "application/json", "text/plain")] + [Produces("text/plain", "application/xml")] + [ApiExplorerSettings(GroupName = "WeChat")] + public Task WeChatMessageLegacyAsync( + [FromQuery] string signature, + [FromQuery] string timestamp, + [FromQuery] string nonce, + [FromQuery(Name = "encrypt_type")] string? encryptType, + CancellationToken cancellationToken = default) => + WeChatMessageCoreAsync(signature, timestamp, nonce, encryptType, cancellationToken); + + /// + /// 微信消息/事件推送(明文,XML/JSON)。 /// [AllowAnonymous] [HttpPost("/wechat/mp/{appId}")] - [Consumes("text/xml", "application/xml", "text/plain")] - [Produces("application/xml")] + [Consumes("text/xml", "application/xml", "application/json", "text/plain")] + [Produces("text/plain", "application/xml")] [ApiExplorerSettings(GroupName = "WeChat")] public async Task WeChatMessageAsync( [FromRoute] string appId, @@ -101,8 +117,19 @@ public partial class AuthService { var options = await WeChatMpConfigProvider.GetAsync(cancellationToken); WeChatMpCallbackSupport.EnsureAppId(options, appId); + return await WeChatMessageCoreAsync(signature, timestamp, nonce, encryptType, cancellationToken); + } + + private async Task WeChatMessageCoreAsync( + string signature, + string timestamp, + string nonce, + string? encryptType, + CancellationToken cancellationToken) + { var request = httpContextAccessor.HttpContext?.Request ?? throw new BusinessException("无法读取微信回调请求"); + var options = await WeChatMpConfigProvider.GetAsync(cancellationToken); return await WeChatMpCallbackSupport.HandleMessageAsync( options, WeChatEventHandler, diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs index a669c72..b54c44b 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatMpApiClient.cs @@ -359,6 +359,78 @@ internal static class WeChatXmlMessageParser } } +internal static class WeChatJsonMessageParser +{ + public static WeChatIncomingMessage? Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + string? GetString(string name) + { + if (!root.TryGetProperty(name, out var prop)) + { + return null; + } + + return prop.ValueKind switch + { + JsonValueKind.String => prop.GetString(), + JsonValueKind.Number => prop.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => prop.ToString() + }; + } + + return new WeChatIncomingMessage + { + ToUserName = GetString("ToUserName"), + FromUserName = GetString("FromUserName"), + CreateTime = GetString("CreateTime"), + MsgType = GetString("MsgType"), + Event = GetString("Event"), + EventKey = GetString("EventKey"), + Content = GetString("Content") + }; + } + catch (JsonException) + { + return null; + } + } +} + +internal static class WeChatIncomingMessageParser +{ + public static WeChatIncomingMessage? Parse(string body, string? contentType) + { + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + + var trimmed = body.TrimStart(); + if (IsJson(contentType, trimmed)) + { + return WeChatJsonMessageParser.Parse(body); + } + + return WeChatXmlMessageParser.Parse(body); + } + + private static bool IsJson(string? contentType, ReadOnlySpan body) => + contentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true + || body.StartsWith("{"); +} + public sealed class WeChatIncomingMessage { public string? ToUserName { get; init; } diff --git a/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs b/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs index f3c8912..da76038 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs @@ -54,6 +54,13 @@ internal static class WeChatMpCallbackSupport string? encryptType, CancellationToken cancellationToken) { + if (string.IsNullOrWhiteSpace(options.Token)) + { + logger.LogWarning("微信消息回调未配置 Token"); + return PlainText("error"); + } + + // token、timestamp、nonce 字典序排序后拼接,SHA1 与 signature 对比 if (!WeChatSignatureHelper.Validate(options.Token, timestamp, nonce, signature)) { logger.LogWarning("微信消息验签失败"); @@ -64,26 +71,28 @@ internal static class WeChatMpCallbackSupport var requestBody = await reader.ReadToEndAsync(cancellationToken); if (string.IsNullOrWhiteSpace(requestBody)) { - return Xml(string.Empty); + return AckSuccess(); } + // 明文模式:无 encrypt_type 或 encrypt_type=raw;安全模式 AES 暂不支持 if (!string.IsNullOrWhiteSpace(encryptType) && !string.Equals(encryptType, "raw", StringComparison.OrdinalIgnoreCase)) { logger.LogWarning( "收到加密微信消息({EncryptType}),当前仅支持明文模式,请在公众平台切换为明文或后续启用 AES。", encryptType); - return Xml(string.Empty); + return AckSuccess(); } - var message = WeChatXmlMessageParser.Parse(requestBody); + var message = WeChatIncomingMessageParser.Parse(requestBody, request.ContentType); if (message == null) { - return Xml(string.Empty); + logger.LogWarning("无法解析微信消息体(支持明文 XML / JSON)"); + return AckSuccess(); } var reply = await eventHandler.HandleAsync(message, cancellationToken); - return Xml(reply ?? string.Empty); + return BuildMessageReply(reply); } public static void EnsureAppId(WeChatMpOptions options, string appId) @@ -103,6 +112,18 @@ internal static class WeChatMpCallbackSupport private static ContentResult EchoStr(string echostr) => new() { Content = echostr, ContentType = "text/plain", StatusCode = StatusCodes.Status200OK }; + private static ContentResult AckSuccess() => PlainText("success"); + + private static ContentResult BuildMessageReply(string? reply) + { + if (string.IsNullOrEmpty(reply)) + { + return AckSuccess(); + } + + return reply.TrimStart().StartsWith('<') ? Xml(reply) : PlainText(reply); + } + private static ContentResult PlainText(string content) => new() { Content = content, ContentType = "text/plain; charset=utf-8", StatusCode = StatusCodes.Status200OK };