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 };