From 42b41e68c7b1d93d646e34a5ef3266ac6cc2fb8e Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 29 Jun 2026 20:46:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BE=AE=E4=BF=A1=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E6=8C=82=E5=88=B0=20AuthService=20=E4=BB=A5?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fake 框架未注册 SimpleAdmin.Api.WeChat 下的 ApplicationService, 将 /apiwechat/verification 等公众号验签与消息接口并入 AuthService。 Co-authored-by: Cursor --- .../WeChatRouteIntegrationTests.cs | 56 ++++++ .../Auth/AuthService.WeChatMp.cs | 145 ++++++++++++++ src/SimpleAdmin.Api/Auth/AuthService.cs | 2 +- src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 4 - .../WeChat/Services/WeChatSignatureHelper.cs | 8 + ...AppService.cs => WeChatCustomerService.cs} | 2 +- .../WeChat/WeChatMpAppService.cs | 188 ------------------ .../WeChat/WeChatMpCallbackSupport.cs | 97 +++++++++ 8 files changed, 308 insertions(+), 194 deletions(-) create mode 100644 src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs create mode 100644 src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs rename src/SimpleAdmin.Api/WeChat/{WeChatCustomerAppService.cs => WeChatCustomerService.cs} (99%) delete mode 100644 src/SimpleAdmin.Api/WeChat/WeChatMpAppService.cs create mode 100644 src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs diff --git a/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs b/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs new file mode 100644 index 0000000..bb07084 --- /dev/null +++ b/src/SimpleAdmin.Api.IntegrationTests/WeChatRouteIntegrationTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SimpleAdmin.Api.IntegrationTests.Support; +using SimpleAdmin.Api.WeChat.Options; + +namespace SimpleAdmin.Api.IntegrationTests; + +public class WeChatRouteIntegrationTests +{ + [Fact] + public async Task WeChatRoutes_ShouldBeRegisteredOnAuthService() + { + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.PostConfigure(options => + { + options.Token = "test-token"; + options.AppId = "wx-test"; + }); + }); + + host.RoutePatterns.Should().Contain("apiwechat/verification"); + host.RoutePatterns.Should().Contain("wechat/mp/{appId}"); + host.RoutePatterns.Should().Contain("wechat/mp/login-qrcode"); + } + + [Fact] + public async Task WeChatValidateLegacy_ShouldReturnEchostrWhenSignatureValid() + { + await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: services => + { + services.PostConfigure(options => options.Token = "test-token"); + }); + + const string timestamp = "1400000000"; + const string nonce = "nonce"; + const string echostr = "hello-wechat"; + var signature = SignWeChat("test-token", timestamp, nonce); + + var response = await host.Client.GetAsync( + $"/apiwechat/verification?signature={signature}×tamp={timestamp}&nonce={nonce}&echostr={echostr}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Be(echostr); + } + + 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/Auth/AuthService.WeChatMp.cs b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs new file mode 100644 index 0000000..2265077 --- /dev/null +++ b/src/SimpleAdmin.Api/Auth/AuthService.WeChatMp.cs @@ -0,0 +1,145 @@ +using Fake.Application; +using Fake.Domain.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using SimpleAdmin.Api.WeChat; +using SimpleAdmin.Api.WeChat.Dtos; +using SimpleAdmin.Api.WeChat.Options; +using SimpleAdmin.Api.WeChat.Services; + +namespace SimpleAdmin.Api.Auth; + +public partial class AuthService +{ + private IOptions WeChatMpOptions => + LazyServiceProvider.GetRequiredService>(); + + private IWeChatMpApiClient WeChatMpApiClient => + LazyServiceProvider.GetRequiredService(); + + private IWeChatEventHandler WeChatEventHandler => + LazyServiceProvider.GetRequiredService(); + + private ILogger WeChatLogger => + LazyServiceProvider.GetRequiredService>(); + + /// + /// 微信服务器接入验签(兼容 WMS:/api/apiwechat/verification)。 + /// + [AllowAnonymous] + [HttpGet("/apiwechat/verification")] + [Produces("text/plain")] + [ApiExplorerSettings(GroupName = "WeChat")] + public ContentResult WeChatValidateLegacyAsync( + [FromQuery] string? signature, + [FromQuery] string? timestamp, + [FromQuery] string? nonce, + [FromQuery] string? echostr) + => WeChatMpCallbackSupport.Validate(WeChatMpOptions, signature, timestamp, nonce, echostr); + + /// + /// 微信服务器接入验签。 + /// + [AllowAnonymous] + [HttpGet("/wechat/mp/{appId}")] + [Produces("text/plain")] + [ApiExplorerSettings(GroupName = "WeChat")] + public ContentResult WeChatValidateAsync( + [FromRoute] string appId, + [FromQuery] string? signature, + [FromQuery] string? timestamp, + [FromQuery] string? nonce, + [FromQuery] string? echostr) + { + WeChatMpCallbackSupport.EnsureAppId(WeChatMpOptions, appId); + return WeChatMpCallbackSupport.Validate(WeChatMpOptions, signature, timestamp, nonce, echostr); + } + + /// + /// 微信消息/事件回调(兼容 WMS:/api/apiwechat/verification)。 + /// + [AllowAnonymous] + [HttpPost("/apiwechat/verification")] + [Consumes("text/xml", "application/xml", "text/plain")] + [Produces("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) + { + var request = httpContextAccessor.HttpContext?.Request + ?? throw new BusinessException("无法读取微信回调请求"); + return WeChatMpCallbackSupport.HandleMessageAsync( + WeChatMpOptions, + WeChatEventHandler, + request, + WeChatLogger, + signature, + timestamp, + nonce, + encryptType, + cancellationToken); + } + + /// + /// 微信消息/事件回调。 + /// + [AllowAnonymous] + [HttpPost("/wechat/mp/{appId}")] + [Consumes("text/xml", "application/xml", "text/plain")] + [Produces("application/xml")] + [ApiExplorerSettings(GroupName = "WeChat")] + public async Task WeChatMessageAsync( + [FromRoute] string appId, + [FromQuery] string signature, + [FromQuery] string timestamp, + [FromQuery] string nonce, + [FromQuery(Name = "encrypt_type")] string? encryptType, + CancellationToken cancellationToken = default) + { + WeChatMpCallbackSupport.EnsureAppId(WeChatMpOptions, appId); + var request = httpContextAccessor.HttpContext?.Request + ?? throw new BusinessException("无法读取微信回调请求"); + return await WeChatMpCallbackSupport.HandleMessageAsync( + WeChatMpOptions, + WeChatEventHandler, + request, + WeChatLogger, + signature, + timestamp, + nonce, + encryptType, + cancellationToken); + } + + /// + /// 获取 PC 扫码登录二维码。 + /// + [AllowAnonymous] + [HttpGet("/wechat/mp/login-qrcode")] + [ApiExplorerSettings(GroupName = "WeChat")] + public async Task GetWeChatLoginQrcodeAsync( + [FromQuery] string loginUuid, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(loginUuid)) + { + throw new BusinessException("缺少 loginUuid"); + } + + const int expireSeconds = 600; + var qrCodeUrl = await WeChatMpApiClient.CreateLoginQrCodeUrlAsync( + loginUuid.Trim(), + expireSeconds, + cancellationToken); + return new WxLoginQrcodeOutput + { + QrCodeUrl = qrCodeUrl, + ExpiresIn = expireSeconds + }; + } +} diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 48b08aa..1c67557 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -29,7 +29,7 @@ namespace SimpleAdmin.Api.Auth; /// [Authorize] [ApiExplorerSettings(GroupName = "RBAC")] -public class AuthService( +public partial class AuthService( AccountManager accountManager, UserService userService, MenuService menuService, diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index 38716f4..3bff776 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -50,10 +50,6 @@ public class SimpleAdminApiModule : FakeModule settings.RootPath = "rbac"; settings.TypePredicate = type => type == typeof(AuthService); }); - options.ApplicationServices2Controller(settings => - { - settings.TypePredicate = type => type.Namespace == "SimpleAdmin.Api.WeChat"; - }); options.ApplicationServices2Controller(settings => { settings.RootPath = "rbac"; diff --git a/src/SimpleAdmin.Api/WeChat/Services/WeChatSignatureHelper.cs b/src/SimpleAdmin.Api/WeChat/Services/WeChatSignatureHelper.cs index e87e808..98f940b 100644 --- a/src/SimpleAdmin.Api/WeChat/Services/WeChatSignatureHelper.cs +++ b/src/SimpleAdmin.Api/WeChat/Services/WeChatSignatureHelper.cs @@ -5,6 +5,14 @@ namespace SimpleAdmin.Api.WeChat.Services; internal static class WeChatSignatureHelper { + public static string Sign(string token, string timestamp, string nonce) + { + var parts = new[] { token, timestamp, nonce }.OrderBy(x => x, StringComparer.Ordinal).ToArray(); + var raw = string.Concat(parts); + var hash = SHA1.HashData(Encoding.UTF8.GetBytes(raw)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + public static bool Validate(string token, string timestamp, string nonce, string signature) { if (string.IsNullOrWhiteSpace(token) diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs similarity index 99% rename from src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs rename to src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs index de993f9..7eaf2b7 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerAppService.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs @@ -21,7 +21,7 @@ namespace SimpleAdmin.Api.WeChat; /// [AllowAnonymous] [ApiExplorerSettings(GroupName = "WeChat")] -public class WeChatCustomerAppService( +public class WeChatCustomerService( IWeChatMpApiClient weChatMpApiClient, ICustomerRepository customerRepository, UserService userService, diff --git a/src/SimpleAdmin.Api/WeChat/WeChatMpAppService.cs b/src/SimpleAdmin.Api/WeChat/WeChatMpAppService.cs deleted file mode 100644 index 7c1d8fc..0000000 --- a/src/SimpleAdmin.Api/WeChat/WeChatMpAppService.cs +++ /dev/null @@ -1,188 +0,0 @@ -using Fake.Application; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using SimpleAdmin.Api.WeChat.Dtos; -using SimpleAdmin.Api.WeChat.Options; -using SimpleAdmin.Api.WeChat.Services; - -namespace SimpleAdmin.Api.WeChat; - -/// -/// 微信公众号服务器回调与登录二维码。 -/// -[AllowAnonymous] -[ApiExplorerSettings(GroupName = "WeChat")] -public class WeChatMpAppService( - IOptions options, - IWeChatMpApiClient weChatMpApiClient, - IWeChatEventHandler eventHandler, - IHttpContextAccessor httpContextAccessor, - ILogger logger) : ApplicationService -{ - /// - /// 微信服务器接入验签(兼容 WMS 公众号菜单配置:/api/apiwechat/verification)。 - /// - [HttpGet("/apiwechat/verification")] - [Produces("text/plain")] - public ContentResult ValidateLegacyAsync( - [FromQuery] string? signature, - [FromQuery] string? timestamp, - [FromQuery] string? nonce, - [FromQuery] string? echostr) - => ValidateCore(signature, timestamp, nonce, echostr); - - /// - /// 微信服务器接入验签。 - /// - [HttpGet("/wechat/mp/{appId}")] - [Produces("text/plain")] - public ContentResult ValidateAsync( - [FromRoute] string appId, - [FromQuery] string? signature, - [FromQuery] string? timestamp, - [FromQuery] string? nonce, - [FromQuery] string? echostr) - { - EnsureAppId(appId); - return ValidateCore(signature, timestamp, nonce, echostr); - } - - /// - /// 微信消息/事件回调(兼容 WMS:/api/apiwechat/verification)。 - /// - [HttpPost("/apiwechat/verification")] - [Consumes("text/xml", "application/xml", "text/plain")] - [Produces("application/xml")] - public Task MessageLegacyAsync( - [FromQuery] string signature, - [FromQuery] string timestamp, - [FromQuery] string nonce, - [FromQuery] string? openid, - [FromQuery(Name = "encrypt_type")] string? encryptType, - [FromQuery(Name = "msg_signature")] string? msgSignature, - CancellationToken cancellationToken = default) - => MessageCoreAsync(signature, timestamp, nonce, encryptType, cancellationToken); - - /// - /// 微信消息/事件回调。 - /// - [HttpPost("/wechat/mp/{appId}")] - [Consumes("text/xml", "application/xml", "text/plain")] - [Produces("application/xml")] - public async Task MessageAsync( - [FromRoute] string appId, - [FromQuery] string signature, - [FromQuery] string timestamp, - [FromQuery] string nonce, - [FromQuery] string? openid, - [FromQuery(Name = "encrypt_type")] string? encryptType, - [FromQuery(Name = "msg_signature")] string? msgSignature, - CancellationToken cancellationToken = default) - { - EnsureAppId(appId); - return await MessageCoreAsync(signature, timestamp, nonce, encryptType, cancellationToken); - } - - private ContentResult ValidateCore( - string? signature, - string? timestamp, - string? nonce, - string? echostr) - { - if (string.IsNullOrWhiteSpace(options.Value.Token)) - { - throw new BusinessException("未配置微信公众号 Token"); - } - - if (string.IsNullOrWhiteSpace(signature) - || string.IsNullOrWhiteSpace(timestamp) - || string.IsNullOrWhiteSpace(nonce) - || string.IsNullOrWhiteSpace(echostr)) - { - return new ContentResult { Content = "error", ContentType = "text/plain; charset=utf-8" }; - } - - var valid = WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature); - return new ContentResult - { - Content = valid ? echostr : "error", - ContentType = "text/plain; charset=utf-8" - }; - } - - private async Task MessageCoreAsync( - string signature, - string timestamp, - string nonce, - string? encryptType, - CancellationToken cancellationToken) - { - if (!WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature)) - { - logger.LogWarning("微信消息验签失败"); - return new ContentResult { Content = "error", ContentType = "text/plain; charset=utf-8" }; - } - - var request = httpContextAccessor.HttpContext?.Request - ?? throw new BusinessException("无法读取微信回调请求"); - using var reader = new StreamReader(request.Body); - var requestBody = await reader.ReadToEndAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(requestBody)) - { - return new ContentResult { Content = string.Empty, ContentType = "application/xml; charset=utf-8" }; - } - - if (!string.IsNullOrWhiteSpace(encryptType) - && !string.Equals(encryptType, "raw", StringComparison.OrdinalIgnoreCase)) - { - logger.LogWarning("收到加密微信消息({EncryptType}),当前仅支持明文模式,请在公众平台切换为明文或后续启用 AES。", encryptType); - return new ContentResult { Content = string.Empty, ContentType = "application/xml; charset=utf-8" }; - } - - var message = WeChatXmlMessageParser.Parse(requestBody); - if (message == null) - { - return new ContentResult { Content = string.Empty, ContentType = "application/xml; charset=utf-8" }; - } - - var reply = await eventHandler.HandleAsync(message, cancellationToken); - return new ContentResult { Content = reply ?? string.Empty, ContentType = "application/xml; charset=utf-8" }; - } - - /// - /// 获取 PC 扫码登录二维码。 - /// - [HttpGet("/wechat/mp/login-qrcode")] - public async Task GetLoginQrcodeAsync( - [FromQuery] string loginUuid, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(loginUuid)) - { - throw new BusinessException("缺少 loginUuid"); - } - - const int expireSeconds = 600; - var qrCodeUrl = await weChatMpApiClient.CreateLoginQrCodeUrlAsync(loginUuid.Trim(), expireSeconds, cancellationToken); - return new WxLoginQrcodeOutput - { - QrCodeUrl = qrCodeUrl, - ExpiresIn = expireSeconds - }; - } - - private void EnsureAppId(string appId) - { - var configured = options.Value.AppId; - if (string.IsNullOrWhiteSpace(configured)) - { - throw new BusinessException("未配置微信公众号 AppId"); - } - - if (!string.Equals(configured, appId, StringComparison.OrdinalIgnoreCase)) - { - throw new BusinessException("公众号 AppId 不匹配"); - } - } -} diff --git a/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs b/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs new file mode 100644 index 0000000..eb80f40 --- /dev/null +++ b/src/SimpleAdmin.Api/WeChat/WeChatMpCallbackSupport.cs @@ -0,0 +1,97 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using SimpleAdmin.Api.WeChat.Options; +using SimpleAdmin.Api.WeChat.Services; + +namespace SimpleAdmin.Api.WeChat; + +internal static class WeChatMpCallbackSupport +{ + public static ContentResult Validate( + IOptions options, + string? signature, + string? timestamp, + string? nonce, + string? echostr) + { + if (string.IsNullOrWhiteSpace(options.Value.Token)) + { + throw new BusinessException("未配置微信公众号 Token"); + } + + if (string.IsNullOrWhiteSpace(signature) + || string.IsNullOrWhiteSpace(timestamp) + || string.IsNullOrWhiteSpace(nonce) + || string.IsNullOrWhiteSpace(echostr)) + { + return PlainText("error"); + } + + var valid = WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature); + return PlainText(valid ? echostr : "error"); + } + + public static async Task HandleMessageAsync( + IOptions options, + IWeChatEventHandler eventHandler, + HttpRequest request, + ILogger logger, + string signature, + string timestamp, + string nonce, + string? encryptType, + CancellationToken cancellationToken) + { + if (!WeChatSignatureHelper.Validate(options.Value.Token, timestamp, nonce, signature)) + { + logger.LogWarning("微信消息验签失败"); + return PlainText("error"); + } + + using var reader = new StreamReader(request.Body); + var requestBody = await reader.ReadToEndAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(requestBody)) + { + return Xml(string.Empty); + } + + if (!string.IsNullOrWhiteSpace(encryptType) + && !string.Equals(encryptType, "raw", StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning( + "收到加密微信消息({EncryptType}),当前仅支持明文模式,请在公众平台切换为明文或后续启用 AES。", + encryptType); + return Xml(string.Empty); + } + + var message = WeChatXmlMessageParser.Parse(requestBody); + if (message == null) + { + return Xml(string.Empty); + } + + var reply = await eventHandler.HandleAsync(message, cancellationToken); + return Xml(reply ?? string.Empty); + } + + public static void EnsureAppId(IOptions options, string appId) + { + var configured = options.Value.AppId; + if (string.IsNullOrWhiteSpace(configured)) + { + throw new BusinessException("未配置微信公众号 AppId"); + } + + if (!string.Equals(configured, appId, StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("公众号 AppId 不匹配"); + } + } + + private static ContentResult PlainText(string content) => + new() { Content = content, ContentType = "text/plain; charset=utf-8" }; + + private static ContentResult Xml(string content) => + new() { Content = content, ContentType = "application/xml; charset=utf-8" }; +}