- 从 Query 读取 signature/timestamp/nonce/echostr 并记录失败原因 - 验签通过后 WriteAsync 写回 echostr,避免 JSON 包装 - 补充生产 Token 验签集成测试 Co-authored-by: Cursor <cursoragent@cursor.com>
166 lines
5.8 KiB
C#
166 lines
5.8 KiB
C#
using Fake.Application;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using SimpleAdmin.Api.WeChat.Options;
|
||
using SimpleAdmin.Api.WeChat.Services;
|
||
|
||
namespace SimpleAdmin.Api.WeChat;
|
||
|
||
internal static class WeChatMpCallbackSupport
|
||
{
|
||
/// <summary>
|
||
/// 微信 GET 接入验签:从 Query 读取参数,验签通过后原样输出 echostr。
|
||
/// </summary>
|
||
public static bool TryValidateGetRequest(
|
||
WeChatMpOptions options,
|
||
HttpRequest request,
|
||
ILogger? logger,
|
||
out string echostr)
|
||
{
|
||
echostr = string.Empty;
|
||
|
||
if (string.IsNullOrWhiteSpace(options.Token))
|
||
{
|
||
logger?.LogWarning("微信 GET 验签失败:未配置 Token");
|
||
return false;
|
||
}
|
||
|
||
var signature = request.Query["signature"].ToString();
|
||
var timestamp = request.Query["timestamp"].ToString();
|
||
var nonce = request.Query["nonce"].ToString();
|
||
echostr = request.Query["echostr"].ToString();
|
||
|
||
if (string.IsNullOrWhiteSpace(signature)
|
||
|| string.IsNullOrWhiteSpace(timestamp)
|
||
|| string.IsNullOrWhiteSpace(nonce)
|
||
|| string.IsNullOrEmpty(echostr))
|
||
{
|
||
logger?.LogWarning(
|
||
"微信 GET 验签失败:缺少必要参数 signature={HasSig} timestamp={HasTs} nonce={HasNonce} echostr={HasEcho}",
|
||
!string.IsNullOrWhiteSpace(signature),
|
||
!string.IsNullOrWhiteSpace(timestamp),
|
||
!string.IsNullOrWhiteSpace(nonce),
|
||
!string.IsNullOrEmpty(echostr));
|
||
return false;
|
||
}
|
||
|
||
if (!WeChatSignatureHelper.Validate(options.Token, timestamp, nonce, signature))
|
||
{
|
||
logger?.LogWarning(
|
||
"微信 GET 验签失败:signature 不匹配 timestamp={Timestamp} nonce={Nonce}",
|
||
timestamp,
|
||
nonce);
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
public static ContentResult Validate(
|
||
WeChatMpOptions options,
|
||
HttpRequest request,
|
||
string? signature,
|
||
string? timestamp,
|
||
string? nonce)
|
||
{
|
||
if (!TryValidateGetRequest(options, request, null, out var echostr))
|
||
{
|
||
return PlainError();
|
||
}
|
||
|
||
return EchoStr(echostr);
|
||
}
|
||
|
||
/// <summary>微信或探活可能发 HEAD,须返回 200(WMS/Spring 默认支持)。</summary>
|
||
public static ContentResult HeadOk() =>
|
||
new() { Content = string.Empty, ContentType = "text/plain; charset=utf-8", StatusCode = StatusCodes.Status200OK };
|
||
|
||
public static ContentResult PlainError() => PlainText("error");
|
||
|
||
public static async Task<ContentResult> HandleMessageAsync(
|
||
WeChatMpOptions options,
|
||
IWeChatEventHandler eventHandler,
|
||
HttpRequest request,
|
||
ILogger logger,
|
||
string signature,
|
||
string timestamp,
|
||
string nonce,
|
||
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("微信消息验签失败");
|
||
return PlainText("error");
|
||
}
|
||
|
||
using var reader = new StreamReader(request.Body);
|
||
var requestBody = await reader.ReadToEndAsync(cancellationToken);
|
||
if (string.IsNullOrWhiteSpace(requestBody))
|
||
{
|
||
return AckSuccess();
|
||
}
|
||
|
||
// 明文模式:无 encrypt_type 或 encrypt_type=raw;安全模式 AES 暂不支持
|
||
if (!string.IsNullOrWhiteSpace(encryptType)
|
||
&& !string.Equals(encryptType, "raw", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
logger.LogWarning(
|
||
"收到加密微信消息({EncryptType}),当前仅支持明文模式,请在公众平台切换为明文或后续启用 AES。",
|
||
encryptType);
|
||
return AckSuccess();
|
||
}
|
||
|
||
var message = WeChatIncomingMessageParser.Parse(requestBody, request.ContentType);
|
||
if (message == null)
|
||
{
|
||
logger.LogWarning("无法解析微信消息体(支持明文 XML / JSON)");
|
||
return AckSuccess();
|
||
}
|
||
|
||
var reply = await eventHandler.HandleAsync(message, cancellationToken);
|
||
return BuildMessageReply(reply);
|
||
}
|
||
|
||
public static void EnsureAppId(WeChatMpOptions options, string appId)
|
||
{
|
||
var configured = options.AppId;
|
||
if (string.IsNullOrWhiteSpace(configured))
|
||
{
|
||
throw new BusinessException("未配置微信公众号 AppId");
|
||
}
|
||
|
||
if (!string.Equals(configured, appId, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
throw new BusinessException("公众号 AppId 不匹配");
|
||
}
|
||
}
|
||
|
||
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 };
|
||
|
||
private static ContentResult Xml(string content) =>
|
||
new() { Content = content, ContentType = "application/xml; charset=utf-8", StatusCode = StatusCodes.Status200OK };
|
||
}
|