feat(微信): 消息推送支持明文 XML/JSON 验签与回包

- POST /apiwechat/verification 恢复消息回调,支持 JSON/XML 明文包体

- SHA1 验签通过后 GET 原样返回 echostr,POST 无回复时返回 success

- 补充官方验签示例与 JSON/XML 推送集成测试

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-04 12:52:43 +08:00
parent 6c2e095196
commit da1227d8aa
5 changed files with 264 additions and 10 deletions

View File

@ -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}&timestamp={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&timestamp=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 = """
<xml>
<ToUserName><![CDATA[gh_test]]></ToUserName>
<FromUserName><![CDATA[o-test]]></FromUserName>
<CreateTime>1400000000</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[CLICK]]></Event>
</xml>
""";
using var content = new StringContent(xml, Encoding.UTF8, "text/xml");
var response = await host.Client.PostAsync(
$"/wechat/mp/wx-test?signature={signature}&timestamp={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<WeChatMpOptions>(options =>
{
options.Token = token;
options.AppId = "wx-test";
});
var configRepository = new Mock<ISystemOtherConfigRepository>();
configRepository
.Setup(x => x.GetByConfigCodeAsync(SystemOtherConfigCodes.WeChatMp, It.IsAny<CancellationToken>()))
.ReturnsAsync((SystemOtherConfig?)null);
services.RemoveAll<ISystemOtherConfigRepository>();
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();
}
}

View File

@ -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]

View File

@ -84,12 +84,28 @@ public partial class AuthService
}
/// <summary>
/// 微信消息/事件回调。
/// 微信公众号消息/事件推送(明文,XML/JSON)。
/// </summary>
[AllowAnonymous]
[HttpPost("/apiwechat/verification")]
[Consumes("text/xml", "application/xml", "application/json", "text/plain")]
[Produces("text/plain", "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) =>
WeChatMessageCoreAsync(signature, timestamp, nonce, encryptType, cancellationToken);
/// <summary>
/// 微信消息/事件推送(明文,XML/JSON)。
/// </summary>
[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<ContentResult> 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<ContentResult> 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,

View File

@ -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<char> body) =>
contentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true
|| body.StartsWith("{");
}
public sealed class WeChatIncomingMessage
{
public string? ToUserName { get; init; }

View File

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