fix: 微信回调路由挂到 AuthService 以修复 404

Fake 框架未注册 SimpleAdmin.Api.WeChat 下的 ApplicationService,
将 /apiwechat/verification 等公众号验签与消息接口并入 AuthService。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-29 20:46:49 +08:00
parent 26ccc69dc6
commit 42b41e68c7
8 changed files with 308 additions and 194 deletions

View File

@ -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<WeChatMpOptions>(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<WeChatMpOptions>(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}&timestamp={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();
}
}

View File

@ -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> WeChatMpOptions =>
LazyServiceProvider.GetRequiredService<IOptions<WeChatMpOptions>>();
private IWeChatMpApiClient WeChatMpApiClient =>
LazyServiceProvider.GetRequiredService<IWeChatMpApiClient>();
private IWeChatEventHandler WeChatEventHandler =>
LazyServiceProvider.GetRequiredService<IWeChatEventHandler>();
private ILogger<AuthService> WeChatLogger =>
LazyServiceProvider.GetRequiredService<ILogger<AuthService>>();
/// <summary>
/// 微信服务器接入验签(兼容 WMS:/api/apiwechat/verification)。
/// </summary>
[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);
/// <summary>
/// 微信服务器接入验签。
/// </summary>
[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);
}
/// <summary>
/// 微信消息/事件回调(兼容 WMS:/api/apiwechat/verification)。
/// </summary>
[AllowAnonymous]
[HttpPost("/apiwechat/verification")]
[Consumes("text/xml", "application/xml", "text/plain")]
[Produces("application/xml")]
[ApiExplorerSettings(GroupName = "WeChat")]
public Task<ContentResult> 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);
}
/// <summary>
/// 微信消息/事件回调。
/// </summary>
[AllowAnonymous]
[HttpPost("/wechat/mp/{appId}")]
[Consumes("text/xml", "application/xml", "text/plain")]
[Produces("application/xml")]
[ApiExplorerSettings(GroupName = "WeChat")]
public async Task<ContentResult> 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);
}
/// <summary>
/// 获取 PC 扫码登录二维码。
/// </summary>
[AllowAnonymous]
[HttpGet("/wechat/mp/login-qrcode")]
[ApiExplorerSettings(GroupName = "WeChat")]
public async Task<WxLoginQrcodeOutput> 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
};
}
}

View File

@ -29,7 +29,7 @@ namespace SimpleAdmin.Api.Auth;
/// </summary> /// </summary>
[Authorize] [Authorize]
[ApiExplorerSettings(GroupName = "RBAC")] [ApiExplorerSettings(GroupName = "RBAC")]
public class AuthService( public partial class AuthService(
AccountManager accountManager, AccountManager accountManager,
UserService userService, UserService userService,
MenuService menuService, MenuService menuService,

View File

@ -50,10 +50,6 @@ public class SimpleAdminApiModule : FakeModule
settings.RootPath = "rbac"; settings.RootPath = "rbac";
settings.TypePredicate = type => type == typeof(AuthService); settings.TypePredicate = type => type == typeof(AuthService);
}); });
options.ApplicationServices2Controller<SimpleAdminApiModule>(settings =>
{
settings.TypePredicate = type => type.Namespace == "SimpleAdmin.Api.WeChat";
});
options.ApplicationServices2Controller<FakeRbacApplicationModule>(settings => options.ApplicationServices2Controller<FakeRbacApplicationModule>(settings =>
{ {
settings.RootPath = "rbac"; settings.RootPath = "rbac";

View File

@ -5,6 +5,14 @@ namespace SimpleAdmin.Api.WeChat.Services;
internal static class WeChatSignatureHelper 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) public static bool Validate(string token, string timestamp, string nonce, string signature)
{ {
if (string.IsNullOrWhiteSpace(token) if (string.IsNullOrWhiteSpace(token)

View File

@ -21,7 +21,7 @@ namespace SimpleAdmin.Api.WeChat;
/// </summary> /// </summary>
[AllowAnonymous] [AllowAnonymous]
[ApiExplorerSettings(GroupName = "WeChat")] [ApiExplorerSettings(GroupName = "WeChat")]
public class WeChatCustomerAppService( public class WeChatCustomerService(
IWeChatMpApiClient weChatMpApiClient, IWeChatMpApiClient weChatMpApiClient,
ICustomerRepository customerRepository, ICustomerRepository customerRepository,
UserService userService, UserService userService,

View File

@ -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;
/// <summary>
/// 微信公众号服务器回调与登录二维码。
/// </summary>
[AllowAnonymous]
[ApiExplorerSettings(GroupName = "WeChat")]
public class WeChatMpAppService(
IOptions<WeChatMpOptions> options,
IWeChatMpApiClient weChatMpApiClient,
IWeChatEventHandler eventHandler,
IHttpContextAccessor httpContextAccessor,
ILogger<WeChatMpAppService> logger) : ApplicationService
{
/// <summary>
/// 微信服务器接入验签(兼容 WMS 公众号菜单配置:/api/apiwechat/verification)。
/// </summary>
[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);
/// <summary>
/// 微信服务器接入验签。
/// </summary>
[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);
}
/// <summary>
/// 微信消息/事件回调(兼容 WMS:/api/apiwechat/verification)。
/// </summary>
[HttpPost("/apiwechat/verification")]
[Consumes("text/xml", "application/xml", "text/plain")]
[Produces("application/xml")]
public Task<ContentResult> 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);
/// <summary>
/// 微信消息/事件回调。
/// </summary>
[HttpPost("/wechat/mp/{appId}")]
[Consumes("text/xml", "application/xml", "text/plain")]
[Produces("application/xml")]
public async Task<ContentResult> 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<ContentResult> 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" };
}
/// <summary>
/// 获取 PC 扫码登录二维码。
/// </summary>
[HttpGet("/wechat/mp/login-qrcode")]
public async Task<WxLoginQrcodeOutput> 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 不匹配");
}
}
}

View File

@ -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<WeChatMpOptions> 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<ContentResult> HandleMessageAsync(
IOptions<WeChatMpOptions> 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<WeChatMpOptions> 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" };
}