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:
parent
6c2e095196
commit
da1227d8aa
@ -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 = """
|
||||
<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}×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<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();
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user