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
|
public class WeChatRouteIntegrationTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WeChatLegacyVerification_ShouldRegisterGetOnly()
|
public async Task WeChatLegacyVerification_ShouldRegisterGetAndPostMethods()
|
||||||
{
|
{
|
||||||
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: ConfigureWeChatFallback);
|
await using var host = await SimpleAdminApiTestHost.CreateAsync(configureServices: ConfigureWeChatFallback);
|
||||||
|
|
||||||
var methods = host.GetHttpMethods("apiwechat/verification");
|
var methods = host.GetHttpMethods("apiwechat/verification");
|
||||||
methods.Should().BeEquivalentTo(["GET"]);
|
methods.Should().BeEquivalentTo(["GET", "POST"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@ -84,12 +84,28 @@ public partial class AuthService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("/wechat/mp/{appId}")]
|
[HttpPost("/wechat/mp/{appId}")]
|
||||||
[Consumes("text/xml", "application/xml", "text/plain")]
|
[Consumes("text/xml", "application/xml", "application/json", "text/plain")]
|
||||||
[Produces("application/xml")]
|
[Produces("text/plain", "application/xml")]
|
||||||
[ApiExplorerSettings(GroupName = "WeChat")]
|
[ApiExplorerSettings(GroupName = "WeChat")]
|
||||||
public async Task<ContentResult> WeChatMessageAsync(
|
public async Task<ContentResult> WeChatMessageAsync(
|
||||||
[FromRoute] string appId,
|
[FromRoute] string appId,
|
||||||
@ -101,8 +117,19 @@ public partial class AuthService
|
|||||||
{
|
{
|
||||||
var options = await WeChatMpConfigProvider.GetAsync(cancellationToken);
|
var options = await WeChatMpConfigProvider.GetAsync(cancellationToken);
|
||||||
WeChatMpCallbackSupport.EnsureAppId(options, appId);
|
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
|
var request = httpContextAccessor.HttpContext?.Request
|
||||||
?? throw new BusinessException("无法读取微信回调请求");
|
?? throw new BusinessException("无法读取微信回调请求");
|
||||||
|
var options = await WeChatMpConfigProvider.GetAsync(cancellationToken);
|
||||||
return await WeChatMpCallbackSupport.HandleMessageAsync(
|
return await WeChatMpCallbackSupport.HandleMessageAsync(
|
||||||
options,
|
options,
|
||||||
WeChatEventHandler,
|
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 sealed class WeChatIncomingMessage
|
||||||
{
|
{
|
||||||
public string? ToUserName { get; init; }
|
public string? ToUserName { get; init; }
|
||||||
|
|||||||
@ -54,6 +54,13 @@ internal static class WeChatMpCallbackSupport
|
|||||||
string? encryptType,
|
string? encryptType,
|
||||||
CancellationToken cancellationToken)
|
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))
|
if (!WeChatSignatureHelper.Validate(options.Token, timestamp, nonce, signature))
|
||||||
{
|
{
|
||||||
logger.LogWarning("微信消息验签失败");
|
logger.LogWarning("微信消息验签失败");
|
||||||
@ -64,26 +71,28 @@ internal static class WeChatMpCallbackSupport
|
|||||||
var requestBody = await reader.ReadToEndAsync(cancellationToken);
|
var requestBody = await reader.ReadToEndAsync(cancellationToken);
|
||||||
if (string.IsNullOrWhiteSpace(requestBody))
|
if (string.IsNullOrWhiteSpace(requestBody))
|
||||||
{
|
{
|
||||||
return Xml(string.Empty);
|
return AckSuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 明文模式:无 encrypt_type 或 encrypt_type=raw;安全模式 AES 暂不支持
|
||||||
if (!string.IsNullOrWhiteSpace(encryptType)
|
if (!string.IsNullOrWhiteSpace(encryptType)
|
||||||
&& !string.Equals(encryptType, "raw", StringComparison.OrdinalIgnoreCase))
|
&& !string.Equals(encryptType, "raw", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"收到加密微信消息({EncryptType}),当前仅支持明文模式,请在公众平台切换为明文或后续启用 AES。",
|
"收到加密微信消息({EncryptType}),当前仅支持明文模式,请在公众平台切换为明文或后续启用 AES。",
|
||||||
encryptType);
|
encryptType);
|
||||||
return Xml(string.Empty);
|
return AckSuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
var message = WeChatXmlMessageParser.Parse(requestBody);
|
var message = WeChatIncomingMessageParser.Parse(requestBody, request.ContentType);
|
||||||
if (message == null)
|
if (message == null)
|
||||||
{
|
{
|
||||||
return Xml(string.Empty);
|
logger.LogWarning("无法解析微信消息体(支持明文 XML / JSON)");
|
||||||
|
return AckSuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
var reply = await eventHandler.HandleAsync(message, cancellationToken);
|
var reply = await eventHandler.HandleAsync(message, cancellationToken);
|
||||||
return Xml(reply ?? string.Empty);
|
return BuildMessageReply(reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void EnsureAppId(WeChatMpOptions options, string appId)
|
public static void EnsureAppId(WeChatMpOptions options, string appId)
|
||||||
@ -103,6 +112,18 @@ internal static class WeChatMpCallbackSupport
|
|||||||
private static ContentResult EchoStr(string echostr) =>
|
private static ContentResult EchoStr(string echostr) =>
|
||||||
new() { Content = echostr, ContentType = "text/plain", StatusCode = StatusCodes.Status200OK };
|
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) =>
|
private static ContentResult PlainText(string content) =>
|
||||||
new() { Content = content, ContentType = "text/plain; charset=utf-8", StatusCode = StatusCodes.Status200OK };
|
new() { Content = content, ContentType = "text/plain; charset=utf-8", StatusCode = StatusCodes.Status200OK };
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user