From 01213b21e1c117faaa0e40a9d7e7bc30245baca2 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 25 Jan 2026 20:28:11 +0800 Subject: [PATCH] 321 --- .kiro/specs/wechat-pay-v3-upgrade/tasks.md | 56 +- .../Models/Config/ConfigModels.cs | 40 +- .../Interfaces/IPaymentNotifyService.cs | 20 +- .../Interfaces/IWechatPayV3Service.cs | 181 +++ .../Services/PaymentNotifyService.cs | 233 +++- .../Services/WechatPayConfigService.cs | 9 +- .../Services/WechatPayV3Service.cs | 922 +++++++++++++++ .../Modules/ServiceModule.cs | 4 +- .../Models/Payment/PaymentModels.cs | 41 +- .../Models/Payment/WechatPayV3Models.cs | 1050 +++++++++++++++++ .../PaymentNotifyServiceIntegrationTests.cs | 8 + .../WechatPayV3ConfigPropertyTests.cs | 228 ++++ .../WechatPayV3DecryptionPropertyTests.cs | 420 +++++++ .../WechatPayV3NotifyFormatPropertyTests.cs | 410 +++++++ .../WechatPayV3RequestPropertyTests.cs | 367 ++++++ .../WechatPayV3SignaturePropertyTests.cs | 352 ++++++ 16 files changed, 4301 insertions(+), 40 deletions(-) create mode 100644 server/HoneyBox/src/HoneyBox.Core/Interfaces/IWechatPayV3Service.cs create mode 100644 server/HoneyBox/src/HoneyBox.Core/Services/WechatPayV3Service.cs create mode 100644 server/HoneyBox/src/HoneyBox.Model/Models/Payment/WechatPayV3Models.cs create mode 100644 server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3ConfigPropertyTests.cs create mode 100644 server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3DecryptionPropertyTests.cs create mode 100644 server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3NotifyFormatPropertyTests.cs create mode 100644 server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3RequestPropertyTests.cs create mode 100644 server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3SignaturePropertyTests.cs diff --git a/.kiro/specs/wechat-pay-v3-upgrade/tasks.md b/.kiro/specs/wechat-pay-v3-upgrade/tasks.md index 5c675747..53deaaa9 100644 --- a/.kiro/specs/wechat-pay-v3-upgrade/tasks.md +++ b/.kiro/specs/wechat-pay-v3-upgrade/tasks.md @@ -6,92 +6,92 @@ ## Tasks -- [ ] 1. 扩展配置模型支持 V3 字段 - - [ ] 1.1 扩展 WeixinPayMerchant 模型添加 V3 字段 +- [x] 1. 扩展配置模型支持 V3 字段 + - [x] 1.1 扩展 WeixinPayMerchant 模型添加 V3 字段 - 在 `HoneyBox.Admin.Business/Models/Config/ConfigModels.cs` 中添加 V3 字段 - 字段:PayVersion、ApiV3Key、CertSerialNo、PrivateKeyPath、WechatPublicKeyId、WechatPublicKeyPath - _Requirements: 1.1, 1.2, 1.3_ - - [ ] 1.2 扩展 WechatPayMerchantConfig 模型添加 V3 字段 + - [x] 1.2 扩展 WechatPayMerchantConfig 模型添加 V3 字段 - 在 `HoneyBox.Model/Models/Payment/PaymentModels.cs` 中添加对应字段 - _Requirements: 1.1, 1.2_ - - [ ] 1.3 更新 WechatPayConfigService 支持 V3 配置映射 + - [x] 1.3 更新 WechatPayConfigService 支持 V3 配置映射 - 在配置加载时映射 V3 字段 - _Requirements: 1.4, 1.5_ - - [ ] 1.4 编写配置序列化 round-trip 属性测试 + - [x] 1.4 编写配置序列化 round-trip 属性测试 - **Property 1: 配置序列化 Round-Trip** - **Validates: Requirements 1.4, 1.5** -- [ ] 2. 创建 V3 支付数据模型 - - [ ] 2.1 创建 WechatPayV3Models.cs 文件 +- [x] 2. 创建 V3 支付数据模型 + - [x] 2.1 创建 WechatPayV3Models.cs 文件 - 在 `HoneyBox.Model/Models/Payment/` 目录下创建 - 包含:WechatPayV3JsapiRequest、WechatPayV3Amount、WechatPayV3Payer、WechatPayV3JsapiResponse - _Requirements: 3.2_ - - [ ] 2.2 创建 V3 回调通知模型 + - [x] 2.2 创建 V3 回调通知模型 - 包含:WechatPayV3Notification、WechatPayV3Resource、WechatPayV3PaymentResult - _Requirements: 4.3, 4.4_ - - [ ] 2.3 创建 V3 查询、关闭、退款结果模型 + - [x] 2.3 创建 V3 查询、关闭、退款结果模型 - 包含:WechatPayV3QueryResult、WechatPayV3CloseResult、WechatPayV3RefundResult、WechatPayV3RefundRequest - _Requirements: 5.3, 6.3, 7.3_ -- [ ] 3. 实现 V3 支付服务核心功能 - - [ ] 3.1 创建 IWechatPayV3Service 接口 +- [x] 3. 实现 V3 支付服务核心功能 + - [x] 3.1 创建 IWechatPayV3Service 接口 - 在 `HoneyBox.Core/Interfaces/` 目录下创建 - 定义:CreateJsapiOrderAsync、QueryOrderAsync、CloseOrderAsync、RefundAsync - _Requirements: 3.1, 5.1, 6.1, 7.1_ - - [ ] 3.2 实现 V3 签名生成方法 + - [x] 3.2 实现 V3 签名生成方法 - 实现 RSA-SHA256 签名算法 - 签名字符串格式:HTTP方法\nURL\n时间戳\n随机串\n请求体\n - _Requirements: 3.3_ - - [ ] 3.3 编写签名确定性属性测试 + - [x] 3.3 编写签名确定性属性测试 - **Property 5: V3 请求签名正确性** - **Validates: Requirements 3.3** - - [ ] 3.4 实现 CreateJsapiOrderAsync 方法 + - [x] 3.4 实现 CreateJsapiOrderAsync 方法 - 构建 V3 JSAPI 下单请求 - 调用微信 V3 API - 返回小程序支付参数 - _Requirements: 3.2, 3.4_ - - [ ] 3.5 编写请求字段完整性属性测试 + - [x] 3.5 编写请求字段完整性属性测试 - **Property 6: V3 请求字段完整性** - **Validates: Requirements 3.2** -- [ ] 4. Checkpoint - 确保 V3 下单功能测试通过 +- [x] 4. Checkpoint - 确保 V3 下单功能测试通过 - 运行所有测试,确保通过 - 如有问题请询问用户 -- [ ] 5. 实现 V3 回调处理 - - [ ] 5.1 实现回调签名验证方法 +- [-] 5. 实现 V3 回调处理 + - [x] 5.1 实现回调签名验证方法 - 使用微信支付公钥验证 Wechatpay-Signature 头 - _Requirements: 4.2_ - - [ ] 5.2 实现 AES-256-GCM 解密方法 + - [x] 5.2 实现 AES-256-GCM 解密方法 - 解密 resource.ciphertext 字段 - 使用 APIv3 密钥作为解密密钥 - _Requirements: 4.3_ - - [ ] 5.3 编写解密 round-trip 属性测试 + - [x] 5.3 编写解密 round-trip 属性测试 - **Property 9: V3 回调解密 Round-Trip** - **Validates: Requirements 4.3** - - [ ] 5.4 实现回调格式自动识别 + - [x] 5.4 实现回调格式自动识别 - JSON 格式且包含 resource 字段 → V3 流程 - XML 格式 → V2 流程 - _Requirements: 4.1, 4.5_ - - [ ] 5.5 编写回调格式识别属性测试 + - [x] 5.5 编写回调格式识别属性测试 - **Property 8: 回调格式识别正确性** - **Validates: Requirements 4.1, 4.5** - - [ ] 5.6 更新 PaymentNotifyService 支持 V3 回调 + - [x] 5.6 更新 PaymentNotifyService 支持 V3 回调 - 在现有回调处理中添加 V3 分支 - _Requirements: 4.4, 4.6_ -- [ ] 6. 实现 V3 订单查询和关闭 - - [ ] 6.1 实现 QueryOrderAsync 方法 +- [x] 6. 实现 V3 订单查询和关闭 + - [x] 6.1 实现 QueryOrderAsync 方法 - 调用 V3 订单查询接口 - 解析订单状态 - _Requirements: 5.1, 5.2, 5.3_ - - [ ] 6.2 实现 CloseOrderAsync 方法 + - [x] 6.2 实现 CloseOrderAsync 方法 - 调用 V3 订单关闭接口 - 处理 HTTP 204 响应 - _Requirements: 6.1, 6.2, 6.3_ -- [ ] 7. 实现 V3 退款接口 - - [ ] 7.1 实现 RefundAsync 方法 +- [-] 7. 实现 V3 退款接口 + - [-] 7.1 实现 RefundAsync 方法 - 调用 V3 退款接口 - 支持部分退款 - _Requirements: 7.1, 7.2, 7.3, 7.4_ diff --git a/server/HoneyBox/src/HoneyBox.Admin.Business/Models/Config/ConfigModels.cs b/server/HoneyBox/src/HoneyBox.Admin.Business/Models/Config/ConfigModels.cs index 08978646..52bcea77 100644 --- a/server/HoneyBox/src/HoneyBox.Admin.Business/Models/Config/ConfigModels.cs +++ b/server/HoneyBox/src/HoneyBox.Admin.Business/Models/Config/ConfigModels.cs @@ -65,7 +65,7 @@ public class WeixinPayMerchant public string OrderPrefix { get; set; } = string.Empty; /// - /// API密钥 + /// API密钥(V2版本使用) /// [JsonPropertyName("api_key")] public string? ApiKey { get; set; } @@ -81,6 +81,44 @@ public class WeixinPayMerchant /// [JsonPropertyName("is_enabled")] public string? IsEnabled { get; set; } + + // ===== V3 新增字段 ===== + + /// + /// 支付版本: "V2" 或 "V3",默认 "V2" + /// + [JsonPropertyName("pay_version")] + public string PayVersion { get; set; } = "V2"; + + /// + /// APIv3 密钥(32位字符串,V3版本使用) + /// + [JsonPropertyName("api_v3_key")] + public string? ApiV3Key { get; set; } + + /// + /// 商户API证书序列号(V3版本使用) + /// + [JsonPropertyName("cert_serial_no")] + public string? CertSerialNo { get; set; } + + /// + /// 商户私钥文件路径(V3版本使用) + /// + [JsonPropertyName("private_key_path")] + public string? PrivateKeyPath { get; set; } + + /// + /// 微信支付公钥ID(V3版本使用) + /// + [JsonPropertyName("wechat_public_key_id")] + public string? WechatPublicKeyId { get; set; } + + /// + /// 微信支付公钥文件路径(V3版本使用) + /// + [JsonPropertyName("wechat_public_key_path")] + public string? WechatPublicKeyPath { get; set; } } diff --git a/server/HoneyBox/src/HoneyBox.Core/Interfaces/IPaymentNotifyService.cs b/server/HoneyBox/src/HoneyBox.Core/Interfaces/IPaymentNotifyService.cs index e9bbdde3..f0c8a9b4 100644 --- a/server/HoneyBox/src/HoneyBox.Core/Interfaces/IPaymentNotifyService.cs +++ b/server/HoneyBox/src/HoneyBox.Core/Interfaces/IPaymentNotifyService.cs @@ -8,11 +8,27 @@ namespace HoneyBox.Core.Interfaces; public interface IPaymentNotifyService { /// - /// 处理微信支付回调 + /// 处理微信支付回调(自动识别 V2/V3 格式) + /// + /// 回调请求体 + /// 回调请求头(V3 需要用于签名验证) + /// 回调处理结果 + Task HandleWechatNotifyAsync(string notifyBody, WechatPayNotifyHeaders? headers = null); + + /// + /// 处理微信支付 V2 回调(XML 格式) /// /// 微信回调XML数据 /// 回调处理结果 - Task HandleWechatNotifyAsync(string xmlData); + Task HandleWechatV2NotifyAsync(string xmlData); + + /// + /// 处理微信支付 V3 回调(JSON 格式) + /// + /// 微信回调JSON数据 + /// 回调请求头 + /// 回调处理结果 + Task HandleWechatV3NotifyAsync(string jsonData, WechatPayNotifyHeaders headers); /// /// 处理一番赏订单支付成功 diff --git a/server/HoneyBox/src/HoneyBox.Core/Interfaces/IWechatPayV3Service.cs b/server/HoneyBox/src/HoneyBox.Core/Interfaces/IWechatPayV3Service.cs new file mode 100644 index 00000000..aaa57450 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Core/Interfaces/IWechatPayV3Service.cs @@ -0,0 +1,181 @@ +using HoneyBox.Model.Models.Payment; + +namespace HoneyBox.Core.Interfaces; + +/// +/// 微信支付 V3 服务接口 +/// 提供基于 RSA-SHA256 签名和 AES-256-GCM 加密的 V3 版本支付功能 +/// +public interface IWechatPayV3Service +{ + #region 下单接口 + + /// + /// 创建 JSAPI 下单(小程序/公众号支付) + /// + /// 支付请求 + /// 支付结果,包含调起支付所需参数 + Task CreateJsapiOrderAsync(WechatPayRequest request); + + #endregion + + #region 订单管理接口 + + /// + /// 查询订单状态 + /// + /// 商户订单号 + /// 订单查询结果 + Task QueryOrderAsync(string orderNo); + + /// + /// 关闭订单 + /// + /// 商户订单号 + /// 关闭结果 + Task CloseOrderAsync(string orderNo); + + #endregion + + #region 退款接口 + + /// + /// 申请退款 + /// + /// 退款请求 + /// 退款结果 + Task RefundAsync(WechatPayV3RefundRequest request); + + #endregion + + #region 签名与验签 + + /// + /// 生成 V3 请求签名 + /// + /// HTTP 方法(GET、POST 等) + /// 请求 URL(不含域名,如 /v3/pay/transactions/jsapi) + /// 时间戳(秒) + /// 随机字符串 + /// 请求体(GET 请求为空字符串) + /// 商户私钥(PEM 格式内容) + /// Base64 编码的签名字符串 + string GenerateSignature(string method, string url, string timestamp, string nonce, string body, string privateKey); + + /// + /// 验证回调签名 + /// + /// 微信回调头中的时间戳(Wechatpay-Timestamp) + /// 微信回调头中的随机串(Wechatpay-Nonce) + /// 回调请求体 + /// 微信回调头中的签名(Wechatpay-Signature) + /// 微信回调头中的证书序列号(Wechatpay-Serial) + /// 签名是否有效 + bool VerifyNotifySignature(string timestamp, string nonce, string body, string signature, string serialNo); + + /// + /// 使用指定的公钥验证回调签名 + /// + /// 时间戳 + /// 随机串 + /// 请求体 + /// 签名 + /// 公钥 PEM 内容 + /// 签名是否有效 + bool VerifyNotifySignatureWithPublicKey(string timestamp, string nonce, string body, string signature, string publicKey); + + #endregion + + #region 加解密 + + /// + /// 解密回调数据 + /// + /// 密文(Base64 编码) + /// 随机串 + /// 附加数据 + /// APIv3 密钥 + /// 解密后的明文 JSON 字符串 + string DecryptNotifyResource(string ciphertext, string nonce, string associatedData, string apiV3Key); + + /// + /// 使用 AES-256-GCM 加密数据(用于测试) + /// + /// 明文 + /// 随机串(12 字节) + /// 附加数据 + /// APIv3 密钥(32 字节) + /// Base64 编码的密文(包含认证标签) + string EncryptNotifyResource(string plaintext, string nonce, string associatedData, string apiV3Key); + + #endregion + + #region 回调格式识别 + + /// + /// 检测回调数据是否为 V3 格式 + /// V3 格式特征:JSON 格式且包含 resource 字段 + /// + /// 回调请求体 + /// 是否为 V3 格式 + bool IsV3NotifyFormat(string notifyBody); + + /// + /// 检测回调数据是否为 V2 格式 + /// V2 格式特征:XML 格式,以 <xml> 开头 + /// + /// 回调请求体 + /// 是否为 V2 格式 + bool IsV2NotifyFormat(string notifyBody); + + /// + /// 检测回调格式并返回版本 + /// + /// 回调请求体 + /// 回调版本:V3、V2 或 Unknown + NotifyVersion DetectNotifyVersion(string notifyBody); + + #endregion + + #region 辅助方法 + + /// + /// 生成小程序调起支付所需的签名 + /// + /// 小程序 AppId + /// 时间戳 + /// 随机字符串 + /// 预支付交易会话标识 + /// 商户私钥 + /// 支付签名 + string GeneratePaySign(string appId, string timestamp, string nonceStr, string prepayId, string privateKey); + + /// + /// 生成随机字符串 + /// + /// 长度(默认 32) + /// 随机字符串 + string GenerateNonceStr(int length = 32); + + /// + /// 获取当前时间戳(秒) + /// + /// Unix 时间戳字符串 + string GetTimestamp(); + + /// + /// 读取私钥文件内容 + /// + /// 私钥文件路径 + /// 私钥 PEM 内容 + string ReadPrivateKey(string privateKeyPath); + + /// + /// 读取公钥文件内容 + /// + /// 公钥文件路径 + /// 公钥 PEM 内容 + string ReadPublicKey(string publicKeyPath); + + #endregion +} diff --git a/server/HoneyBox/src/HoneyBox.Core/Services/PaymentNotifyService.cs b/server/HoneyBox/src/HoneyBox.Core/Services/PaymentNotifyService.cs index 73d42037..cace7fc3 100644 --- a/server/HoneyBox/src/HoneyBox.Core/Services/PaymentNotifyService.cs +++ b/server/HoneyBox/src/HoneyBox.Core/Services/PaymentNotifyService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HoneyBox.Core.Interfaces; using HoneyBox.Model.Data; using HoneyBox.Model.Entities; @@ -15,6 +16,8 @@ public class PaymentNotifyService : IPaymentNotifyService { private readonly HoneyBoxDbContext _dbContext; private readonly IWechatPayService _wechatPayService; + private readonly IWechatPayV3Service _wechatPayV3Service; + private readonly IWechatPayConfigService _wechatPayConfigService; private readonly IPaymentService _paymentService; private readonly ILotteryEngine _lotteryEngine; private readonly ILogger _logger; @@ -43,19 +46,50 @@ public class PaymentNotifyService : IPaymentNotifyService public PaymentNotifyService( HoneyBoxDbContext dbContext, IWechatPayService wechatPayService, + IWechatPayV3Service wechatPayV3Service, + IWechatPayConfigService wechatPayConfigService, IPaymentService paymentService, ILotteryEngine lotteryEngine, ILogger logger) { _dbContext = dbContext; _wechatPayService = wechatPayService; + _wechatPayV3Service = wechatPayV3Service; + _wechatPayConfigService = wechatPayConfigService; _paymentService = paymentService; _lotteryEngine = lotteryEngine; _logger = logger; } /// - public async Task HandleWechatNotifyAsync(string xmlData) + public async Task HandleWechatNotifyAsync(string notifyBody, WechatPayNotifyHeaders? headers = null) + { + // 自动识别回调格式 + var version = _wechatPayV3Service.DetectNotifyVersion(notifyBody); + + _logger.LogInformation("检测到微信支付回调版本: {Version}", version); + + return version switch + { + NotifyVersion.V3 when headers != null => await HandleWechatV3NotifyAsync(notifyBody, headers), + NotifyVersion.V3 => new NotifyResult + { + Success = false, + Message = "V3 回调缺少请求头", + JsonResponse = JsonSerializer.Serialize(new WechatPayV3NotifyResponse { Code = "FAIL", Message = "缺少请求头" }) + }, + NotifyVersion.V2 => await HandleWechatV2NotifyAsync(notifyBody), + _ => new NotifyResult + { + Success = false, + Message = "无法识别的回调格式", + XmlResponse = _wechatPayService.GenerateNotifyResponseXml("FAIL", "无法识别的回调格式") + } + }; + } + + /// + public async Task HandleWechatV2NotifyAsync(string xmlData) { var successResponse = _wechatPayService.GenerateNotifyResponseXml("SUCCESS", "OK"); var failResponse = _wechatPayService.GenerateNotifyResponseXml("FAIL", "处理失败"); @@ -65,7 +99,7 @@ public class PaymentNotifyService : IPaymentNotifyService // 1. 检查XML数据是否为空 if (string.IsNullOrEmpty(xmlData)) { - _logger.LogWarning("微信支付回调数据为空"); + _logger.LogWarning("微信支付 V2 回调数据为空"); return new NotifyResult { Success = false, @@ -78,7 +112,7 @@ public class PaymentNotifyService : IPaymentNotifyService var notifyData = _wechatPayService.ParseNotifyXml(xmlData); if (notifyData == null || string.IsNullOrEmpty(notifyData.OutTradeNo)) { - _logger.LogWarning("解析微信支付回调XML失败"); + _logger.LogWarning("解析微信支付 V2 回调XML失败"); return new NotifyResult { Success = false, @@ -90,13 +124,13 @@ public class PaymentNotifyService : IPaymentNotifyService var orderNo = notifyData.OutTradeNo; var attach = notifyData.Attach; - _logger.LogInformation("收到微信支付回调: OrderNo={OrderNo}, Attach={Attach}, TotalFee={TotalFee}", + _logger.LogInformation("收到微信支付 V2 回调: OrderNo={OrderNo}, Attach={Attach}, TotalFee={TotalFee}", orderNo, attach, notifyData.TotalFee); // 3. 验证签名 if (!_wechatPayService.VerifyNotifySign(notifyData)) { - _logger.LogWarning("微信支付回调签名验证失败: OrderNo={OrderNo}", orderNo); + _logger.LogWarning("微信支付 V2 回调签名验证失败: OrderNo={OrderNo}", orderNo); return new NotifyResult { Success = false, @@ -172,6 +206,195 @@ public class PaymentNotifyService : IPaymentNotifyService } } + /// + public async Task HandleWechatV3NotifyAsync(string jsonData, WechatPayNotifyHeaders headers) + { + var successResponse = JsonSerializer.Serialize(new WechatPayV3NotifyResponse { Code = "SUCCESS", Message = "成功" }); + var failResponse = JsonSerializer.Serialize(new WechatPayV3NotifyResponse { Code = "FAIL", Message = "处理失败" }); + + try + { + // 1. 检查数据是否为空 + if (string.IsNullOrEmpty(jsonData)) + { + _logger.LogWarning("微信支付 V3 回调数据为空"); + return new NotifyResult + { + Success = false, + Message = "回调数据为空", + JsonResponse = failResponse + }; + } + + // 2. 验证签名 + if (!_wechatPayV3Service.VerifyNotifySignature( + headers.Timestamp, + headers.Nonce, + jsonData, + headers.Signature, + headers.Serial)) + { + _logger.LogWarning("微信支付 V3 回调签名验证失败"); + return new NotifyResult + { + Success = false, + Message = "签名验证失败", + JsonResponse = failResponse + }; + } + + // 3. 解析回调通知 + var notification = JsonSerializer.Deserialize(jsonData); + if (notification == null || notification.Resource == null) + { + _logger.LogWarning("解析微信支付 V3 回调数据失败"); + return new NotifyResult + { + Success = false, + Message = "解析回调数据失败", + JsonResponse = failResponse + }; + } + + _logger.LogInformation("收到微信支付 V3 回调: Id={Id}, EventType={EventType}", + notification.Id, notification.EventType); + + // 4. 获取商户配置并解密数据 + var merchantConfig = _wechatPayConfigService.GetDefaultConfig(); + if (string.IsNullOrEmpty(merchantConfig.ApiV3Key)) + { + _logger.LogError("APIv3 密钥未配置"); + return new NotifyResult + { + Success = false, + Message = "APIv3 密钥未配置", + JsonResponse = failResponse + }; + } + + var decryptedJson = _wechatPayV3Service.DecryptNotifyResource( + notification.Resource.Ciphertext, + notification.Resource.Nonce, + notification.Resource.AssociatedData, + merchantConfig.ApiV3Key); + + _logger.LogDebug("V3 回调解密成功: {DecryptedJson}", decryptedJson); + + // 5. 解析支付结果 + var paymentResult = JsonSerializer.Deserialize(decryptedJson); + if (paymentResult == null || string.IsNullOrEmpty(paymentResult.OutTradeNo)) + { + _logger.LogWarning("解析 V3 支付结果失败"); + return new NotifyResult + { + Success = false, + Message = "解析支付结果失败", + JsonResponse = failResponse + }; + } + + var orderNo = paymentResult.OutTradeNo; + var attach = paymentResult.Attach; + + _logger.LogInformation("V3 支付结果: OrderNo={OrderNo}, TradeState={TradeState}, Attach={Attach}", + orderNo, paymentResult.TradeState, attach); + + // 6. 检查支付状态 + if (paymentResult.TradeState != WechatPayV3TradeState.Success) + { + _logger.LogWarning("V3 支付状态非成功: OrderNo={OrderNo}, TradeState={TradeState}", + orderNo, paymentResult.TradeState); + // 即使支付失败,也返回成功响应,避免微信重复通知 + return new NotifyResult + { + Success = true, + Message = "支付未成功", + JsonResponse = successResponse + }; + } + + // 7. 幂等性检查 - 检查订单是否已处理 + if (await IsOrderProcessedAsync(orderNo)) + { + _logger.LogInformation("订单已处理,跳过重复回调: OrderNo={OrderNo}", orderNo); + return new NotifyResult + { + Success = true, + Message = "订单已处理", + JsonResponse = successResponse + }; + } + + // 8. 记录回调通知(转换为 V2 格式以复用现有逻辑) + var notifyData = ConvertV3ToV2NotifyData(paymentResult); + await RecordNotifyAsync(orderNo, notifyData); + + // 9. 根据订单类型路由处理 + var processResult = await RouteOrderProcessingAsync(orderNo, attach, notifyData); + + if (processResult) + { + _logger.LogInformation("微信支付 V3 回调处理成功: OrderNo={OrderNo}", orderNo); + return new NotifyResult + { + Success = true, + Message = "处理成功", + JsonResponse = successResponse + }; + } + else + { + _logger.LogWarning("微信支付 V3 回调处理失败: OrderNo={OrderNo}", orderNo); + // 处理失败也返回成功,避免微信重复通知 + return new NotifyResult + { + Success = false, + Message = "处理失败", + JsonResponse = successResponse + }; + } + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "V3 回调解密失败"); + return new NotifyResult + { + Success = false, + Message = $"解密失败: {ex.Message}", + JsonResponse = failResponse + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "处理微信支付 V3 回调异常"); + // 异常情况也返回成功,避免微信重复通知 + return new NotifyResult + { + Success = false, + Message = $"处理异常: {ex.Message}", + JsonResponse = successResponse + }; + } + } + + /// + /// 将 V3 支付结果转换为 V2 格式(复用现有处理逻辑) + /// + private static WechatNotifyData ConvertV3ToV2NotifyData(WechatPayV3PaymentResult v3Result) + { + return new WechatNotifyData + { + ReturnCode = "SUCCESS", + ResultCode = v3Result.TradeState == WechatPayV3TradeState.Success ? "SUCCESS" : "FAIL", + OutTradeNo = v3Result.OutTradeNo, + TransactionId = v3Result.TransactionId, + TotalFee = v3Result.Amount.Total, + OpenId = v3Result.Payer.OpenId, + Attach = v3Result.Attach, + NonceStr = Guid.NewGuid().ToString("N")[..32] + }; + } + /// /// 根据订单类型路由到对应的处理方法 /// diff --git a/server/HoneyBox/src/HoneyBox.Core/Services/WechatPayConfigService.cs b/server/HoneyBox/src/HoneyBox.Core/Services/WechatPayConfigService.cs index c43b29dc..49e913dd 100644 --- a/server/HoneyBox/src/HoneyBox.Core/Services/WechatPayConfigService.cs +++ b/server/HoneyBox/src/HoneyBox.Core/Services/WechatPayConfigService.cs @@ -122,7 +122,14 @@ public class WechatPayConfigService : IWechatPayConfigService Key = merchant.Key, OrderPrefix = merchant.OrderPrefix, Weight = merchant.Weight, - NotifyUrl = merchant.NotifyUrl + NotifyUrl = merchant.NotifyUrl, + // V3 字段映射 + PayVersion = merchant.PayVersion, + ApiV3Key = merchant.ApiV3Key, + CertSerialNo = merchant.CertSerialNo, + PrivateKeyPath = merchant.PrivateKeyPath, + WechatPublicKeyId = merchant.WechatPublicKeyId, + WechatPublicKeyPath = merchant.WechatPublicKeyPath }; } diff --git a/server/HoneyBox/src/HoneyBox.Core/Services/WechatPayV3Service.cs b/server/HoneyBox/src/HoneyBox.Core/Services/WechatPayV3Service.cs new file mode 100644 index 00000000..287dfc41 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Core/Services/WechatPayV3Service.cs @@ -0,0 +1,922 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using HoneyBox.Core.Interfaces; +using HoneyBox.Model.Data; +using HoneyBox.Model.Entities; +using HoneyBox.Model.Models.Payment; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace HoneyBox.Core.Services; + +/// +/// 微信支付 V3 服务实现 +/// 提供基于 RSA-SHA256 签名和 AES-256-GCM 加密的 V3 版本支付功能 +/// +public class WechatPayV3Service : IWechatPayV3Service +{ + private readonly HoneyBoxDbContext _dbContext; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly IWechatPayConfigService _configService; + + /// + /// V3 JSAPI 下单 API 地址 + /// + private const string V3_JSAPI_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"; + + /// + /// V3 订单查询 API 地址(商户订单号) + /// + private const string V3_QUERY_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{0}"; + + /// + /// V3 关闭订单 API 地址 + /// + private const string V3_CLOSE_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{0}/close"; + + /// + /// V3 退款 API 地址 + /// + private const string V3_REFUND_URL = "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds"; + + /// + /// 随机字符串字符集 + /// + private const string NONCE_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + public WechatPayV3Service( + HoneyBoxDbContext dbContext, + HttpClient httpClient, + ILogger logger, + IWechatPayConfigService configService) + { + _dbContext = dbContext; + _httpClient = httpClient; + _logger = logger; + _configService = configService; + } + + #region 下单接口 + + /// + public async Task CreateJsapiOrderAsync(WechatPayRequest request) + { + try + { + _logger.LogInformation("开始创建 V3 JSAPI 支付订单: OrderNo={OrderNo}, UserId={UserId}, Amount={Amount}", + request.OrderNo, request.UserId, request.Amount); + + // 1. 获取用户信息和 OpenId + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == request.UserId); + if (user == null) + { + _logger.LogWarning("用户不存在: UserId={UserId}", request.UserId); + return new WechatPayResult { Status = 0, Msg = "用户不存在" }; + } + + var openId = string.IsNullOrEmpty(request.OpenId) ? user.OpenId : request.OpenId; + if (string.IsNullOrEmpty(openId)) + { + _logger.LogWarning("用户 OpenId 为空: UserId={UserId}", request.UserId); + return new WechatPayResult { Status = 0, Msg = "用户 OpenId 不存在" }; + } + + // 2. 获取商户配置 + var merchantConfig = _configService.GetMerchantByOrderNo(request.OrderNo); + + // 验证 V3 配置 + if (string.IsNullOrEmpty(merchantConfig.ApiV3Key) || + string.IsNullOrEmpty(merchantConfig.CertSerialNo) || + string.IsNullOrEmpty(merchantConfig.PrivateKeyPath)) + { + _logger.LogError("V3 配置不完整: MchId={MchId}", merchantConfig.MchId); + return new WechatPayResult { Status = 0, Msg = "V3 支付配置不完整" }; + } + + _logger.LogDebug("使用 V3 商户配置: MchId={MchId}, AppId={AppId}", merchantConfig.MchId, merchantConfig.AppId); + + // 3. 读取私钥 + var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath); + if (string.IsNullOrEmpty(privateKey)) + { + _logger.LogError("读取私钥失败: Path={Path}", merchantConfig.PrivateKeyPath); + return new WechatPayResult { Status = 0, Msg = "读取商户私钥失败" }; + } + + // 4. 构建 V3 请求 + var totalFee = (int)Math.Round(request.Amount * 100); // 转换为分 + var v3Request = new WechatPayV3JsapiRequest + { + AppId = merchantConfig.AppId, + MchId = merchantConfig.MchId, + Description = TruncateDescription(request.Body, 127), + OutTradeNo = request.OrderNo, + NotifyUrl = merchantConfig.NotifyUrl, + Amount = new WechatPayV3Amount { Total = totalFee, Currency = "CNY" }, + Payer = new WechatPayV3Payer { OpenId = openId }, + Attach = request.Attach + }; + + var requestBody = JsonSerializer.Serialize(v3Request, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + // 5. 生成签名并发送请求 + var timestamp = GetTimestamp(); + var nonceStr = GenerateNonceStr(); + var url = "/v3/pay/transactions/jsapi"; + var signature = GenerateSignature("POST", url, timestamp, nonceStr, requestBody, privateKey); + + // 6. 构建 Authorization 头 + var authorization = $"WECHATPAY2-SHA256-RSA2048 mchid=\"{merchantConfig.MchId}\",nonce_str=\"{nonceStr}\",timestamp=\"{timestamp}\",serial_no=\"{merchantConfig.CertSerialNo}\",signature=\"{signature}\""; + + // 7. 发送请求 + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, V3_JSAPI_URL); + httpRequest.Headers.Add("Authorization", authorization); + httpRequest.Headers.Add("Accept", "application/json"); + httpRequest.Headers.Add("User-Agent", "HoneyBox/1.0"); + httpRequest.Content = new StringContent(requestBody, Encoding.UTF8, "application/json"); + + _logger.LogDebug("V3 下单请求: URL={Url}, Body={Body}", V3_JSAPI_URL, requestBody); + + var response = await _httpClient.SendAsync(httpRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + _logger.LogDebug("V3 下单响应: StatusCode={StatusCode}, Body={Body}", response.StatusCode, responseContent); + + // 8. 处理响应 + if (!response.IsSuccessStatusCode) + { + var errorResponse = JsonSerializer.Deserialize(responseContent); + var errorMsg = GetV3ErrorMessage(errorResponse?.Code ?? "UNKNOWN", errorResponse?.Message ?? "未知错误"); + _logger.LogWarning("V3 下单失败: Code={Code}, Message={Message}", errorResponse?.Code, errorResponse?.Message); + return new WechatPayResult { Status = 0, Msg = $"支付失败({errorMsg})" }; + } + + var v3Response = JsonSerializer.Deserialize(responseContent); + if (v3Response == null || string.IsNullOrEmpty(v3Response.PrepayId)) + { + _logger.LogError("V3 下单成功但 prepay_id 为空"); + return new WechatPayResult { Status = 0, Msg = "网络故障,请稍后重试(prepay_id为空)" }; + } + + // 9. 保存订单通知记录 + await SaveOrderNotifyAsync(request.OrderNo, merchantConfig.NotifyUrl, nonceStr, request.Amount, request.Attach, openId); + + // 10. 构建返回给前端的支付参数(V3 使用 RSA 签名) + var payTimestamp = GetTimestamp(); + var payNonceStr = GenerateNonceStr(); + var packageStr = $"prepay_id={v3Response.PrepayId}"; + var paySign = GeneratePaySign(merchantConfig.AppId, payTimestamp, payNonceStr, v3Response.PrepayId, privateKey); + + _logger.LogInformation("V3 支付订单创建成功: OrderNo={OrderNo}, PrepayId={PrepayId}", request.OrderNo, v3Response.PrepayId); + + return new WechatPayResult + { + Status = 1, + Msg = "success", + Data = new WechatPayData + { + AppId = merchantConfig.AppId, + TimeStamp = payTimestamp, + NonceStr = payNonceStr, + Package = packageStr, + SignType = "RSA", + PaySign = paySign, + IsWeixin = 1 + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "创建 V3 支付订单异常: OrderNo={OrderNo}", request.OrderNo); + return new WechatPayResult { Status = 0, Msg = "系统错误,请稍后重试" }; + } + } + + #endregion + + #region 订单管理接口 + + /// + public async Task QueryOrderAsync(string orderNo) + { + try + { + _logger.LogInformation("开始查询 V3 订单: OrderNo={OrderNo}", orderNo); + + var merchantConfig = _configService.GetMerchantByOrderNo(orderNo); + var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath!); + + var url = $"/v3/pay/transactions/out-trade-no/{orderNo}?mchid={merchantConfig.MchId}"; + var fullUrl = string.Format(V3_QUERY_URL, orderNo) + $"?mchid={merchantConfig.MchId}"; + + var timestamp = GetTimestamp(); + var nonceStr = GenerateNonceStr(); + var signature = GenerateSignature("GET", url, timestamp, nonceStr, "", privateKey); + + var authorization = $"WECHATPAY2-SHA256-RSA2048 mchid=\"{merchantConfig.MchId}\",nonce_str=\"{nonceStr}\",timestamp=\"{timestamp}\",serial_no=\"{merchantConfig.CertSerialNo}\",signature=\"{signature}\""; + + using var httpRequest = new HttpRequestMessage(HttpMethod.Get, fullUrl); + httpRequest.Headers.Add("Authorization", authorization); + httpRequest.Headers.Add("Accept", "application/json"); + + var response = await _httpClient.SendAsync(httpRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + _logger.LogDebug("V3 订单查询响应: StatusCode={StatusCode}, Body={Body}", response.StatusCode, responseContent); + + if (!response.IsSuccessStatusCode) + { + var errorResponse = JsonSerializer.Deserialize(responseContent); + return new WechatPayV3QueryResult + { + Success = false, + ErrorCode = errorResponse?.Code, + ErrorMessage = errorResponse?.Message + }; + } + + var queryResponse = JsonSerializer.Deserialize(responseContent); + return new WechatPayV3QueryResult + { + Success = true, + TradeState = queryResponse?.TradeState ?? "", + TradeStateDesc = queryResponse?.TradeStateDesc ?? "", + TransactionId = queryResponse?.TransactionId, + OutTradeNo = queryResponse?.OutTradeNo, + TotalAmount = queryResponse?.Amount?.Total, + SuccessTime = queryResponse?.SuccessTime + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "查询 V3 订单异常: OrderNo={OrderNo}", orderNo); + return new WechatPayV3QueryResult + { + Success = false, + ErrorCode = "SYSTEM_ERROR", + ErrorMessage = ex.Message + }; + } + } + + /// + public async Task CloseOrderAsync(string orderNo) + { + try + { + _logger.LogInformation("开始关闭 V3 订单: OrderNo={OrderNo}", orderNo); + + var merchantConfig = _configService.GetMerchantByOrderNo(orderNo); + var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath!); + + var url = $"/v3/pay/transactions/out-trade-no/{orderNo}/close"; + var fullUrl = string.Format(V3_CLOSE_URL, orderNo); + + var requestBody = JsonSerializer.Serialize(new WechatPayV3CloseRequest { MchId = merchantConfig.MchId }); + + var timestamp = GetTimestamp(); + var nonceStr = GenerateNonceStr(); + var signature = GenerateSignature("POST", url, timestamp, nonceStr, requestBody, privateKey); + + var authorization = $"WECHATPAY2-SHA256-RSA2048 mchid=\"{merchantConfig.MchId}\",nonce_str=\"{nonceStr}\",timestamp=\"{timestamp}\",serial_no=\"{merchantConfig.CertSerialNo}\",signature=\"{signature}\""; + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, fullUrl); + httpRequest.Headers.Add("Authorization", authorization); + httpRequest.Headers.Add("Accept", "application/json"); + httpRequest.Content = new StringContent(requestBody, Encoding.UTF8, "application/json"); + + var response = await _httpClient.SendAsync(httpRequest); + + _logger.LogDebug("V3 关闭订单响应: StatusCode={StatusCode}", response.StatusCode); + + // HTTP 204 表示成功 + if (response.StatusCode == System.Net.HttpStatusCode.NoContent) + { + _logger.LogInformation("V3 订单关闭成功: OrderNo={OrderNo}", orderNo); + return new WechatPayV3CloseResult { Success = true }; + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var errorResponse = JsonSerializer.Deserialize(responseContent); + return new WechatPayV3CloseResult + { + Success = false, + ErrorCode = errorResponse?.Code, + ErrorMessage = errorResponse?.Message + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "关闭 V3 订单异常: OrderNo={OrderNo}", orderNo); + return new WechatPayV3CloseResult + { + Success = false, + ErrorCode = "SYSTEM_ERROR", + ErrorMessage = ex.Message + }; + } + } + + #endregion + + #region 退款接口 + + /// + public async Task RefundAsync(WechatPayV3RefundRequest request) + { + try + { + _logger.LogInformation("开始 V3 退款: OrderNo={OrderNo}, RefundNo={RefundNo}, RefundAmount={RefundAmount}", + request.OrderNo, request.RefundNo, request.RefundAmount); + + var merchantConfig = _configService.GetMerchantByOrderNo(request.OrderNo); + var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath!); + + var apiRequest = new WechatPayV3RefundApiRequest + { + OutTradeNo = request.OrderNo, + TransactionId = request.TransactionId, + OutRefundNo = request.RefundNo, + Reason = request.Reason, + NotifyUrl = request.NotifyUrl, + Amount = new WechatPayV3RefundAmount + { + Refund = request.RefundAmount, + Total = request.TotalAmount, + Currency = "CNY" + } + }; + + var requestBody = JsonSerializer.Serialize(apiRequest, new JsonSerializerOptions + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + var url = "/v3/refund/domestic/refunds"; + var timestamp = GetTimestamp(); + var nonceStr = GenerateNonceStr(); + var signature = GenerateSignature("POST", url, timestamp, nonceStr, requestBody, privateKey); + + var authorization = $"WECHATPAY2-SHA256-RSA2048 mchid=\"{merchantConfig.MchId}\",nonce_str=\"{nonceStr}\",timestamp=\"{timestamp}\",serial_no=\"{merchantConfig.CertSerialNo}\",signature=\"{signature}\""; + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, V3_REFUND_URL); + httpRequest.Headers.Add("Authorization", authorization); + httpRequest.Headers.Add("Accept", "application/json"); + httpRequest.Content = new StringContent(requestBody, Encoding.UTF8, "application/json"); + + var response = await _httpClient.SendAsync(httpRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + _logger.LogDebug("V3 退款响应: StatusCode={StatusCode}, Body={Body}", response.StatusCode, responseContent); + + if (!response.IsSuccessStatusCode) + { + var errorResponse = JsonSerializer.Deserialize(responseContent); + return new WechatPayV3RefundResult + { + Success = false, + ErrorCode = errorResponse?.Code, + ErrorMessage = errorResponse?.Message + }; + } + + var refundResponse = JsonSerializer.Deserialize(responseContent); + return new WechatPayV3RefundResult + { + Success = true, + RefundId = refundResponse?.RefundId, + OutRefundNo = refundResponse?.OutRefundNo, + Status = refundResponse?.Status, + RefundAmount = refundResponse?.Amount?.Refund + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "V3 退款异常: OrderNo={OrderNo}", request.OrderNo); + return new WechatPayV3RefundResult + { + Success = false, + ErrorCode = "SYSTEM_ERROR", + ErrorMessage = ex.Message + }; + } + } + + #endregion + + #region 签名与验签 + + /// + public string GenerateSignature(string method, string url, string timestamp, string nonce, string body, string privateKey) + { + // 构建签名字符串 + // 格式:HTTP方法\nURL\n时间戳\n随机串\n请求体\n + var signatureString = $"{method}\n{url}\n{timestamp}\n{nonce}\n{body}\n"; + + // 使用 RSA-SHA256 签名 + using var rsa = RSA.Create(); + rsa.ImportFromPem(privateKey); + + var signatureBytes = rsa.SignData( + Encoding.UTF8.GetBytes(signatureString), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return Convert.ToBase64String(signatureBytes); + } + + /// + public bool VerifyNotifySignature(string timestamp, string nonce, string body, string signature, string serialNo) + { + try + { + _logger.LogDebug("开始验证 V3 回调签名: Timestamp={Timestamp}, Nonce={Nonce}, SerialNo={SerialNo}", + timestamp, nonce, serialNo); + + // 参数验证 + if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(nonce) || + string.IsNullOrEmpty(body) || string.IsNullOrEmpty(signature)) + { + _logger.LogWarning("V3 回调签名验证参数不完整"); + return false; + } + + // 获取商户配置 + var merchantConfig = _configService.GetDefaultConfig(); + + if (string.IsNullOrEmpty(merchantConfig.WechatPublicKeyPath)) + { + _logger.LogError("微信支付公钥路径未配置"); + return false; + } + + // 验证公钥ID是否匹配(如果配置了) + if (!string.IsNullOrEmpty(merchantConfig.WechatPublicKeyId) && + !string.IsNullOrEmpty(serialNo) && + merchantConfig.WechatPublicKeyId != serialNo) + { + _logger.LogWarning("微信支付公钥ID不匹配: Expected={Expected}, Actual={Actual}", + merchantConfig.WechatPublicKeyId, serialNo); + // 继续验证,因为可能是微信更换了公钥 + } + + var publicKey = ReadPublicKey(merchantConfig.WechatPublicKeyPath); + if (string.IsNullOrEmpty(publicKey)) + { + _logger.LogError("读取微信支付公钥失败: Path={Path}", merchantConfig.WechatPublicKeyPath); + return false; + } + + // 构建验签字符串 + // 格式:时间戳\n随机串\n请求体\n + var verifyString = $"{timestamp}\n{nonce}\n{body}\n"; + + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKey); + + var signatureBytes = Convert.FromBase64String(signature); + var isValid = rsa.VerifyData( + Encoding.UTF8.GetBytes(verifyString), + signatureBytes, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + if (isValid) + { + _logger.LogDebug("V3 回调签名验证成功"); + } + else + { + _logger.LogWarning("V3 回调签名验证失败"); + } + + return isValid; + } + catch (FormatException ex) + { + _logger.LogError(ex, "V3 回调签名 Base64 解码失败"); + return false; + } + catch (CryptographicException ex) + { + _logger.LogError(ex, "V3 回调签名验证加密异常"); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "验证回调签名异常"); + return false; + } + } + + /// + /// 使用指定的公钥验证回调签名 + /// + /// 时间戳 + /// 随机串 + /// 请求体 + /// 签名 + /// 公钥 PEM 内容 + /// 签名是否有效 + public bool VerifyNotifySignatureWithPublicKey(string timestamp, string nonce, string body, string signature, string publicKey) + { + try + { + if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(nonce) || + string.IsNullOrEmpty(body) || string.IsNullOrEmpty(signature) || + string.IsNullOrEmpty(publicKey)) + { + return false; + } + + // 构建验签字符串 + var verifyString = $"{timestamp}\n{nonce}\n{body}\n"; + + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKey); + + var signatureBytes = Convert.FromBase64String(signature); + return rsa.VerifyData( + Encoding.UTF8.GetBytes(verifyString), + signatureBytes, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + } + catch (Exception ex) + { + _logger.LogError(ex, "使用指定公钥验证签名异常"); + return false; + } + } + + #endregion + + #region 加解密 + + /// + public string DecryptNotifyResource(string ciphertext, string nonce, string associatedData, string apiV3Key) + { + try + { + _logger.LogDebug("开始解密 V3 回调数据: Nonce={Nonce}, AssociatedData={AssociatedData}", + nonce, associatedData); + + // 参数验证 + if (string.IsNullOrEmpty(ciphertext)) + { + throw new ArgumentException("密文不能为空", nameof(ciphertext)); + } + if (string.IsNullOrEmpty(nonce)) + { + throw new ArgumentException("随机串不能为空", nameof(nonce)); + } + if (string.IsNullOrEmpty(apiV3Key)) + { + throw new ArgumentException("APIv3 密钥不能为空", nameof(apiV3Key)); + } + if (apiV3Key.Length != 32) + { + throw new ArgumentException("APIv3 密钥长度必须为 32 字节", nameof(apiV3Key)); + } + + var ciphertextBytes = Convert.FromBase64String(ciphertext); + var nonceBytes = Encoding.UTF8.GetBytes(nonce); + var associatedDataBytes = string.IsNullOrEmpty(associatedData) + ? Array.Empty() + : Encoding.UTF8.GetBytes(associatedData); + var keyBytes = Encoding.UTF8.GetBytes(apiV3Key); + + // AES-256-GCM 解密 + // 密文最后 16 字节是 authentication tag + const int tagLength = 16; + + if (ciphertextBytes.Length < tagLength) + { + throw new ArgumentException("密文长度不足,无法提取认证标签", nameof(ciphertext)); + } + + var actualCiphertext = ciphertextBytes[..^tagLength]; + var tag = ciphertextBytes[^tagLength..]; + + using var aesGcm = new AesGcm(keyBytes, tagLength); + var plaintext = new byte[actualCiphertext.Length]; + aesGcm.Decrypt(nonceBytes, actualCiphertext, tag, plaintext, associatedDataBytes); + + var result = Encoding.UTF8.GetString(plaintext); + _logger.LogDebug("V3 回调数据解密成功"); + return result; + } + catch (FormatException ex) + { + _logger.LogError(ex, "V3 回调数据 Base64 解码失败"); + throw new InvalidOperationException("密文 Base64 解码失败", ex); + } + catch (CryptographicException ex) + { + _logger.LogError(ex, "V3 回调数据解密失败(可能是密钥错误或数据被篡改)"); + throw new InvalidOperationException("解密失败,可能是密钥错误或数据被篡改", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "解密回调数据失败"); + throw; + } + } + + /// + /// 使用 AES-256-GCM 加密数据(用于测试) + /// + /// 明文 + /// 随机串(12 字节) + /// 附加数据 + /// APIv3 密钥(32 字节) + /// Base64 编码的密文(包含认证标签) + public string EncryptNotifyResource(string plaintext, string nonce, string associatedData, string apiV3Key) + { + if (string.IsNullOrEmpty(plaintext)) + { + throw new ArgumentException("明文不能为空", nameof(plaintext)); + } + if (string.IsNullOrEmpty(nonce)) + { + throw new ArgumentException("随机串不能为空", nameof(nonce)); + } + if (string.IsNullOrEmpty(apiV3Key) || apiV3Key.Length != 32) + { + throw new ArgumentException("APIv3 密钥长度必须为 32 字节", nameof(apiV3Key)); + } + + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var nonceBytes = Encoding.UTF8.GetBytes(nonce); + var associatedDataBytes = string.IsNullOrEmpty(associatedData) + ? Array.Empty() + : Encoding.UTF8.GetBytes(associatedData); + var keyBytes = Encoding.UTF8.GetBytes(apiV3Key); + + const int tagLength = 16; + var ciphertext = new byte[plaintextBytes.Length]; + var tag = new byte[tagLength]; + + using var aesGcm = new AesGcm(keyBytes, tagLength); + aesGcm.Encrypt(nonceBytes, plaintextBytes, ciphertext, tag, associatedDataBytes); + + // 将密文和标签合并 + var result = new byte[ciphertext.Length + tag.Length]; + ciphertext.CopyTo(result, 0); + tag.CopyTo(result, ciphertext.Length); + + return Convert.ToBase64String(result); + } + + #endregion + + #region 回调格式识别 + + /// + /// 检测回调数据是否为 V3 格式 + /// V3 格式特征:JSON 格式且包含 resource 字段 + /// + /// 回调请求体 + /// 是否为 V3 格式 + public bool IsV3NotifyFormat(string notifyBody) + { + if (string.IsNullOrWhiteSpace(notifyBody)) + { + return false; + } + + var trimmedBody = notifyBody.TrimStart(); + + // V3 格式是 JSON,以 { 开头 + if (!trimmedBody.StartsWith('{')) + { + return false; + } + + try + { + // 尝试解析为 JSON 并检查是否包含 resource 字段 + using var doc = JsonDocument.Parse(notifyBody); + var root = doc.RootElement; + + // V3 回调必须包含 resource 字段 + return root.TryGetProperty("resource", out _); + } + catch (JsonException) + { + // JSON 解析失败,不是 V3 格式 + return false; + } + } + + /// + /// 检测回调数据是否为 V2 格式 + /// V2 格式特征:XML 格式,以 <xml> 开头 + /// + /// 回调请求体 + /// 是否为 V2 格式 + public bool IsV2NotifyFormat(string notifyBody) + { + if (string.IsNullOrWhiteSpace(notifyBody)) + { + return false; + } + + var trimmedBody = notifyBody.TrimStart(); + + // V2 格式是 XML,以 < 开头 + if (!trimmedBody.StartsWith('<')) + { + return false; + } + + // 检查是否包含 标签(不区分大小写) + return trimmedBody.Contains("", StringComparison.OrdinalIgnoreCase) || + trimmedBody.Contains(" + /// 检测回调格式并返回版本 + /// + /// 回调请求体 + /// 回调版本:V3、V2 或 Unknown + public NotifyVersion DetectNotifyVersion(string notifyBody) + { + if (IsV3NotifyFormat(notifyBody)) + { + return NotifyVersion.V3; + } + + if (IsV2NotifyFormat(notifyBody)) + { + return NotifyVersion.V2; + } + + return NotifyVersion.Unknown; + } + + #endregion + + #region 辅助方法 + + /// + public string GeneratePaySign(string appId, string timestamp, string nonceStr, string prepayId, string privateKey) + { + // 小程序调起支付签名字符串 + // 格式:appId\n时间戳\n随机串\nprepay_id=xxx\n + var signatureString = $"{appId}\n{timestamp}\n{nonceStr}\nprepay_id={prepayId}\n"; + + using var rsa = RSA.Create(); + rsa.ImportFromPem(privateKey); + + var signatureBytes = rsa.SignData( + Encoding.UTF8.GetBytes(signatureString), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return Convert.ToBase64String(signatureBytes); + } + + /// + public string GenerateNonceStr(int length = 32) + { + var random = new Random(); + var result = new char[length]; + + for (int i = 0; i < length; i++) + { + result[i] = NONCE_CHARS[random.Next(NONCE_CHARS.Length)]; + } + + return new string(result); + } + + /// + public string GetTimestamp() + { + return DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + } + + /// + public string ReadPrivateKey(string privateKeyPath) + { + try + { + // 支持相对路径和绝对路径 + var fullPath = Path.IsPathRooted(privateKeyPath) + ? privateKeyPath + : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, privateKeyPath); + + if (!File.Exists(fullPath)) + { + _logger.LogError("私钥文件不存在: {Path}", fullPath); + return string.Empty; + } + + return File.ReadAllText(fullPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "读取私钥文件失败: {Path}", privateKeyPath); + return string.Empty; + } + } + + /// + public string ReadPublicKey(string publicKeyPath) + { + try + { + var fullPath = Path.IsPathRooted(publicKeyPath) + ? publicKeyPath + : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, publicKeyPath); + + if (!File.Exists(fullPath)) + { + _logger.LogError("公钥文件不存在: {Path}", fullPath); + return string.Empty; + } + + return File.ReadAllText(fullPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "读取公钥文件失败: {Path}", publicKeyPath); + return string.Empty; + } + } + + /// + /// 截断商品描述(V3 限制最大 127 字符) + /// + private static string TruncateDescription(string description, int maxLength) + { + if (string.IsNullOrEmpty(description)) + { + return "商品购买"; + } + + return description.Length <= maxLength ? description : description[..maxLength]; + } + + /// + /// 保存订单通知记录 + /// + private async Task SaveOrderNotifyAsync(string orderNo, string notifyUrl, string nonceStr, decimal amount, string attach, string openId) + { + var orderNotify = new OrderNotify + { + OrderNo = orderNo, + NotifyUrl = notifyUrl, + NonceStr = nonceStr, + PayTime = DateTime.Now, + PayAmount = amount, + Status = 0, + RetryCount = 0, + Attach = attach, + OpenId = openId, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + }; + + _dbContext.OrderNotifies.Add(orderNotify); + await _dbContext.SaveChangesAsync(); + + _logger.LogDebug("保存订单通知记录: OrderNo={OrderNo}, NotifyUrl={NotifyUrl}", orderNo, notifyUrl); + } + + /// + /// 获取 V3 错误消息 + /// + private static string GetV3ErrorMessage(string code, string message) + { + var errorMessages = new Dictionary + { + { "PARAM_ERROR", "参数错误" }, + { "OUT_TRADE_NO_USED", "订单号已使用" }, + { "ORDER_NOT_EXIST", "订单不存在" }, + { "ORDER_CLOSED", "订单已关闭" }, + { "SIGN_ERROR", "签名错误" }, + { "MCH_NOT_EXISTS", "商户号不存在" }, + { "APPID_MCHID_NOT_MATCH", "AppID和商户号不匹配" }, + { "FREQUENCY_LIMITED", "请求频率超限" }, + { "SYSTEM_ERROR", "系统错误" }, + { "INVALID_REQUEST", "请求参数无效" }, + { "OPENID_MISMATCH", "OpenID不匹配" }, + { "NOAUTH", "商户未开通此接口权限" }, + { "NOT_ENOUGH", "用户账户余额不足" }, + { "TRADE_ERROR", "交易错误" } + }; + + return errorMessages.TryGetValue(code, out var msg) ? msg : message; + } + + #endregion +} diff --git a/server/HoneyBox/src/HoneyBox.Infrastructure/Modules/ServiceModule.cs b/server/HoneyBox/src/HoneyBox.Infrastructure/Modules/ServiceModule.cs index 5f943851..5737e08a 100644 --- a/server/HoneyBox/src/HoneyBox.Infrastructure/Modules/ServiceModule.cs +++ b/server/HoneyBox/src/HoneyBox.Infrastructure/Modules/ServiceModule.cs @@ -264,10 +264,12 @@ public class ServiceModule : Module { var dbContext = c.Resolve(); var wechatPayService = c.Resolve(); + var wechatPayV3Service = c.Resolve(); + var wechatPayConfigService = c.Resolve(); var paymentService = c.Resolve(); var lotteryEngine = c.Resolve(); var logger = c.Resolve>(); - return new PaymentNotifyService(dbContext, wechatPayService, paymentService, lotteryEngine, logger); + return new PaymentNotifyService(dbContext, wechatPayService, wechatPayV3Service, wechatPayConfigService, paymentService, lotteryEngine, logger); }).As().InstancePerLifetimeScope(); // 注册充值服务 diff --git a/server/HoneyBox/src/HoneyBox.Model/Models/Payment/PaymentModels.cs b/server/HoneyBox/src/HoneyBox.Model/Models/Payment/PaymentModels.cs index 5864167a..71eaaaaf 100644 --- a/server/HoneyBox/src/HoneyBox.Model/Models/Payment/PaymentModels.cs +++ b/server/HoneyBox/src/HoneyBox.Model/Models/Payment/PaymentModels.cs @@ -138,9 +138,14 @@ public class NotifyResult public string Message { get; set; } = string.Empty; /// - /// XML响应内容(返回给微信) + /// XML响应内容(返回给微信 V2) /// public string XmlResponse { get; set; } = string.Empty; + + /// + /// JSON响应内容(返回给微信 V3) + /// + public string JsonResponse { get; set; } = string.Empty; } /// @@ -415,7 +420,7 @@ public class WechatPayMerchantConfig public string AppId { get; set; } = string.Empty; /// - /// 商户密钥 + /// 商户密钥(V2版本使用) /// public string Key { get; set; } = string.Empty; @@ -433,6 +438,38 @@ public class WechatPayMerchantConfig /// 回调通知URL /// public string NotifyUrl { get; set; } = string.Empty; + + // ===== V3 新增字段 ===== + + /// + /// 支付版本: "V2" 或 "V3",默认 "V2" + /// + public string PayVersion { get; set; } = "V2"; + + /// + /// APIv3 密钥(32位字符串,V3版本使用) + /// + public string? ApiV3Key { get; set; } + + /// + /// 商户API证书序列号(V3版本使用) + /// + public string? CertSerialNo { get; set; } + + /// + /// 商户私钥文件路径(V3版本使用) + /// + public string? PrivateKeyPath { get; set; } + + /// + /// 微信支付公钥ID(V3版本使用) + /// + public string? WechatPublicKeyId { get; set; } + + /// + /// 微信支付公钥文件路径(V3版本使用) + /// + public string? WechatPublicKeyPath { get; set; } } /// diff --git a/server/HoneyBox/src/HoneyBox.Model/Models/Payment/WechatPayV3Models.cs b/server/HoneyBox/src/HoneyBox.Model/Models/Payment/WechatPayV3Models.cs new file mode 100644 index 00000000..63a34e93 --- /dev/null +++ b/server/HoneyBox/src/HoneyBox.Model/Models/Payment/WechatPayV3Models.cs @@ -0,0 +1,1050 @@ +using System.Text.Json.Serialization; + +namespace HoneyBox.Model.Models.Payment; + +#region V3 JSAPI 下单请求/响应模型 + +/// +/// V3 JSAPI 下单请求 +/// +public class WechatPayV3JsapiRequest +{ + /// + /// 应用ID + /// + [JsonPropertyName("appid")] + public string AppId { get; set; } = string.Empty; + + /// + /// 商户号 + /// + [JsonPropertyName("mchid")] + public string MchId { get; set; } = string.Empty; + + /// + /// 商品描述 + /// + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// + /// 商户订单号 + /// + [JsonPropertyName("out_trade_no")] + public string OutTradeNo { get; set; } = string.Empty; + + /// + /// 通知地址 + /// + [JsonPropertyName("notify_url")] + public string NotifyUrl { get; set; } = string.Empty; + + /// + /// 订单金额信息 + /// + [JsonPropertyName("amount")] + public WechatPayV3Amount Amount { get; set; } = new(); + + /// + /// 支付者信息 + /// + [JsonPropertyName("payer")] + public WechatPayV3Payer Payer { get; set; } = new(); + + /// + /// 附加数据(可选) + /// + [JsonPropertyName("attach")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Attach { get; set; } + + /// + /// 交易结束时间(可选,RFC3339格式) + /// + [JsonPropertyName("time_expire")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TimeExpire { get; set; } +} + +/// +/// V3 金额信息 +/// +public class WechatPayV3Amount +{ + /// + /// 总金额(单位:分) + /// + [JsonPropertyName("total")] + public int Total { get; set; } + + /// + /// 货币类型(默认:CNY) + /// + [JsonPropertyName("currency")] + public string Currency { get; set; } = "CNY"; +} + +/// +/// V3 支付者信息 +/// +public class WechatPayV3Payer +{ + /// + /// 用户标识(OpenId) + /// + [JsonPropertyName("openid")] + public string OpenId { get; set; } = string.Empty; +} + +/// +/// V3 JSAPI 下单响应 +/// +public class WechatPayV3JsapiResponse +{ + /// + /// 预支付交易会话标识 + /// + [JsonPropertyName("prepay_id")] + public string PrepayId { get; set; } = string.Empty; +} + +#endregion + + +#region V3 回调通知模型 + +/// +/// V3 回调通知 +/// +public class WechatPayV3Notification +{ + /// + /// 通知ID + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// 通知创建时间(RFC3339格式) + /// + [JsonPropertyName("create_time")] + public string CreateTime { get; set; } = string.Empty; + + /// + /// 通知类型(如:TRANSACTION.SUCCESS) + /// + [JsonPropertyName("event_type")] + public string EventType { get; set; } = string.Empty; + + /// + /// 通知数据类型 + /// + [JsonPropertyName("resource_type")] + public string ResourceType { get; set; } = string.Empty; + + /// + /// 通知资源数据(加密数据) + /// + [JsonPropertyName("resource")] + public WechatPayV3Resource Resource { get; set; } = new(); + + /// + /// 回调摘要 + /// + [JsonPropertyName("summary")] + public string Summary { get; set; } = string.Empty; +} + +/// +/// V3 回调资源(加密数据) +/// +public class WechatPayV3Resource +{ + /// + /// 加密算法类型(AEAD_AES_256_GCM) + /// + [JsonPropertyName("algorithm")] + public string Algorithm { get; set; } = string.Empty; + + /// + /// 数据密文(Base64编码) + /// + [JsonPropertyName("ciphertext")] + public string Ciphertext { get; set; } = string.Empty; + + /// + /// 随机串 + /// + [JsonPropertyName("nonce")] + public string Nonce { get; set; } = string.Empty; + + /// + /// 附加数据 + /// + [JsonPropertyName("associated_data")] + public string AssociatedData { get; set; } = string.Empty; + + /// + /// 原始类型 + /// + [JsonPropertyName("original_type")] + public string OriginalType { get; set; } = string.Empty; +} + +/// +/// V3 解密后的支付结果 +/// +public class WechatPayV3PaymentResult +{ + /// + /// 应用ID + /// + [JsonPropertyName("appid")] + public string AppId { get; set; } = string.Empty; + + /// + /// 商户号 + /// + [JsonPropertyName("mchid")] + public string MchId { get; set; } = string.Empty; + + /// + /// 商户订单号 + /// + [JsonPropertyName("out_trade_no")] + public string OutTradeNo { get; set; } = string.Empty; + + /// + /// 微信支付订单号 + /// + [JsonPropertyName("transaction_id")] + public string TransactionId { get; set; } = string.Empty; + + /// + /// 交易类型(JSAPI、NATIVE、APP等) + /// + [JsonPropertyName("trade_type")] + public string TradeType { get; set; } = string.Empty; + + /// + /// 交易状态(SUCCESS、NOTPAY、CLOSED等) + /// + [JsonPropertyName("trade_state")] + public string TradeState { get; set; } = string.Empty; + + /// + /// 交易状态描述 + /// + [JsonPropertyName("trade_state_desc")] + public string TradeStateDesc { get; set; } = string.Empty; + + /// + /// 付款银行 + /// + [JsonPropertyName("bank_type")] + public string BankType { get; set; } = string.Empty; + + /// + /// 支付完成时间(RFC3339格式) + /// + [JsonPropertyName("success_time")] + public string SuccessTime { get; set; } = string.Empty; + + /// + /// 支付者信息 + /// + [JsonPropertyName("payer")] + public WechatPayV3Payer Payer { get; set; } = new(); + + /// + /// 订单金额信息 + /// + [JsonPropertyName("amount")] + public WechatPayV3PaymentAmount Amount { get; set; } = new(); + + /// + /// 附加数据 + /// + [JsonPropertyName("attach")] + public string Attach { get; set; } = string.Empty; +} + +/// +/// V3 支付金额(回调) +/// +public class WechatPayV3PaymentAmount +{ + /// + /// 订单总金额(单位:分) + /// + [JsonPropertyName("total")] + public int Total { get; set; } + + /// + /// 用户支付金额(单位:分) + /// + [JsonPropertyName("payer_total")] + public int PayerTotal { get; set; } + + /// + /// 货币类型 + /// + [JsonPropertyName("currency")] + public string Currency { get; set; } = "CNY"; + + /// + /// 用户支付币种 + /// + [JsonPropertyName("payer_currency")] + public string PayerCurrency { get; set; } = "CNY"; +} + +/// +/// V3 回调响应(成功) +/// +public class WechatPayV3NotifyResponse +{ + /// + /// 返回状态码(SUCCESS/FAIL) + /// + [JsonPropertyName("code")] + public string Code { get; set; } = "SUCCESS"; + + /// + /// 返回信息 + /// + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} + +#endregion + + +#region V3 订单查询模型 + +/// +/// V3 订单查询响应(微信API原始响应) +/// +public class WechatPayV3QueryResponse +{ + /// + /// 应用ID + /// + [JsonPropertyName("appid")] + public string AppId { get; set; } = string.Empty; + + /// + /// 商户号 + /// + [JsonPropertyName("mchid")] + public string MchId { get; set; } = string.Empty; + + /// + /// 商户订单号 + /// + [JsonPropertyName("out_trade_no")] + public string OutTradeNo { get; set; } = string.Empty; + + /// + /// 微信支付订单号 + /// + [JsonPropertyName("transaction_id")] + public string TransactionId { get; set; } = string.Empty; + + /// + /// 交易类型 + /// + [JsonPropertyName("trade_type")] + public string TradeType { get; set; } = string.Empty; + + /// + /// 交易状态 + /// + [JsonPropertyName("trade_state")] + public string TradeState { get; set; } = string.Empty; + + /// + /// 交易状态描述 + /// + [JsonPropertyName("trade_state_desc")] + public string TradeStateDesc { get; set; } = string.Empty; + + /// + /// 付款银行 + /// + [JsonPropertyName("bank_type")] + public string BankType { get; set; } = string.Empty; + + /// + /// 支付完成时间 + /// + [JsonPropertyName("success_time")] + public string SuccessTime { get; set; } = string.Empty; + + /// + /// 支付者信息 + /// + [JsonPropertyName("payer")] + public WechatPayV3Payer Payer { get; set; } = new(); + + /// + /// 订单金额信息 + /// + [JsonPropertyName("amount")] + public WechatPayV3PaymentAmount Amount { get; set; } = new(); + + /// + /// 附加数据 + /// + [JsonPropertyName("attach")] + public string Attach { get; set; } = string.Empty; +} + +/// +/// V3 订单查询结果(业务封装) +/// +public class WechatPayV3QueryResult +{ + /// + /// 是否成功 + /// + public bool Success { get; set; } + + /// + /// 交易状态(SUCCESS、NOTPAY、CLOSED、USERPAYING、PAYERROR等) + /// + public string TradeState { get; set; } = string.Empty; + + /// + /// 交易状态描述 + /// + public string TradeStateDesc { get; set; } = string.Empty; + + /// + /// 微信支付订单号 + /// + public string? TransactionId { get; set; } + + /// + /// 商户订单号 + /// + public string? OutTradeNo { get; set; } + + /// + /// 订单金额(单位:分) + /// + public int? TotalAmount { get; set; } + + /// + /// 支付完成时间 + /// + public string? SuccessTime { get; set; } + + /// + /// 错误码 + /// + public string? ErrorCode { get; set; } + + /// + /// 错误消息 + /// + public string? ErrorMessage { get; set; } +} + +#endregion + +#region V3 订单关闭模型 + +/// +/// V3 订单关闭请求 +/// +public class WechatPayV3CloseRequest +{ + /// + /// 商户号 + /// + [JsonPropertyName("mchid")] + public string MchId { get; set; } = string.Empty; +} + +/// +/// V3 订单关闭结果(业务封装) +/// +public class WechatPayV3CloseResult +{ + /// + /// 是否成功(HTTP 204 表示成功) + /// + public bool Success { get; set; } + + /// + /// 错误码 + /// + public string? ErrorCode { get; set; } + + /// + /// 错误消息 + /// + public string? ErrorMessage { get; set; } +} + +#endregion + +#region V3 退款模型 + +/// +/// V3 退款请求(业务封装) +/// +public class WechatPayV3RefundRequest +{ + /// + /// 商户订单号(与微信支付订单号二选一) + /// + public string OrderNo { get; set; } = string.Empty; + + /// + /// 微信支付订单号(与商户订单号二选一) + /// + public string? TransactionId { get; set; } + + /// + /// 商户退款单号 + /// + public string RefundNo { get; set; } = string.Empty; + + /// + /// 退款原因(可选) + /// + public string? Reason { get; set; } + + /// + /// 退款结果回调URL(可选) + /// + public string? NotifyUrl { get; set; } + + /// + /// 订单总金额(单位:分) + /// + public int TotalAmount { get; set; } + + /// + /// 退款金额(单位:分) + /// + public int RefundAmount { get; set; } +} + +/// +/// V3 退款API请求(微信API格式) +/// +public class WechatPayV3RefundApiRequest +{ + /// + /// 商户订单号 + /// + [JsonPropertyName("out_trade_no")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OutTradeNo { get; set; } + + /// + /// 微信支付订单号 + /// + [JsonPropertyName("transaction_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TransactionId { get; set; } + + /// + /// 商户退款单号 + /// + [JsonPropertyName("out_refund_no")] + public string OutRefundNo { get; set; } = string.Empty; + + /// + /// 退款原因 + /// + [JsonPropertyName("reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Reason { get; set; } + + /// + /// 退款结果回调URL + /// + [JsonPropertyName("notify_url")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? NotifyUrl { get; set; } + + /// + /// 金额信息 + /// + [JsonPropertyName("amount")] + public WechatPayV3RefundAmount Amount { get; set; } = new(); +} + +/// +/// V3 退款金额信息 +/// +public class WechatPayV3RefundAmount +{ + /// + /// 退款金额(单位:分) + /// + [JsonPropertyName("refund")] + public int Refund { get; set; } + + /// + /// 原订单金额(单位:分) + /// + [JsonPropertyName("total")] + public int Total { get; set; } + + /// + /// 货币类型 + /// + [JsonPropertyName("currency")] + public string Currency { get; set; } = "CNY"; +} + +/// +/// V3 退款API响应(微信API格式) +/// +public class WechatPayV3RefundApiResponse +{ + /// + /// 微信支付退款单号 + /// + [JsonPropertyName("refund_id")] + public string RefundId { get; set; } = string.Empty; + + /// + /// 商户退款单号 + /// + [JsonPropertyName("out_refund_no")] + public string OutRefundNo { get; set; } = string.Empty; + + /// + /// 微信支付订单号 + /// + [JsonPropertyName("transaction_id")] + public string TransactionId { get; set; } = string.Empty; + + /// + /// 商户订单号 + /// + [JsonPropertyName("out_trade_no")] + public string OutTradeNo { get; set; } = string.Empty; + + /// + /// 退款渠道 + /// + [JsonPropertyName("channel")] + public string Channel { get; set; } = string.Empty; + + /// + /// 退款入账账户 + /// + [JsonPropertyName("user_received_account")] + public string UserReceivedAccount { get; set; } = string.Empty; + + /// + /// 退款创建时间 + /// + [JsonPropertyName("create_time")] + public string CreateTime { get; set; } = string.Empty; + + /// + /// 退款状态(SUCCESS、CLOSED、PROCESSING、ABNORMAL) + /// + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + /// + /// 金额信息 + /// + [JsonPropertyName("amount")] + public WechatPayV3RefundResponseAmount Amount { get; set; } = new(); +} + +/// +/// V3 退款响应金额信息 +/// +public class WechatPayV3RefundResponseAmount +{ + /// + /// 订单金额(单位:分) + /// + [JsonPropertyName("total")] + public int Total { get; set; } + + /// + /// 退款金额(单位:分) + /// + [JsonPropertyName("refund")] + public int Refund { get; set; } + + /// + /// 用户支付金额(单位:分) + /// + [JsonPropertyName("payer_total")] + public int PayerTotal { get; set; } + + /// + /// 用户退款金额(单位:分) + /// + [JsonPropertyName("payer_refund")] + public int PayerRefund { get; set; } + + /// + /// 货币类型 + /// + [JsonPropertyName("currency")] + public string Currency { get; set; } = "CNY"; +} + +/// +/// V3 退款结果(业务封装) +/// +public class WechatPayV3RefundResult +{ + /// + /// 是否成功 + /// + public bool Success { get; set; } + + /// + /// 微信支付退款单号 + /// + public string? RefundId { get; set; } + + /// + /// 商户退款单号 + /// + public string? OutRefundNo { get; set; } + + /// + /// 退款状态(SUCCESS、CLOSED、PROCESSING、ABNORMAL) + /// + public string? Status { get; set; } + + /// + /// 退款金额(单位:分) + /// + public int? RefundAmount { get; set; } + + /// + /// 错误码 + /// + public string? ErrorCode { get; set; } + + /// + /// 错误消息 + /// + public string? ErrorMessage { get; set; } +} + +/// +/// V3 退款回调解密后的结果 +/// +public class WechatPayV3RefundNotifyResult +{ + /// + /// 商户号 + /// + [JsonPropertyName("mchid")] + public string MchId { get; set; } = string.Empty; + + /// + /// 商户订单号 + /// + [JsonPropertyName("out_trade_no")] + public string OutTradeNo { get; set; } = string.Empty; + + /// + /// 微信支付订单号 + /// + [JsonPropertyName("transaction_id")] + public string TransactionId { get; set; } = string.Empty; + + /// + /// 商户退款单号 + /// + [JsonPropertyName("out_refund_no")] + public string OutRefundNo { get; set; } = string.Empty; + + /// + /// 微信支付退款单号 + /// + [JsonPropertyName("refund_id")] + public string RefundId { get; set; } = string.Empty; + + /// + /// 退款状态 + /// + [JsonPropertyName("refund_status")] + public string RefundStatus { get; set; } = string.Empty; + + /// + /// 退款成功时间 + /// + [JsonPropertyName("success_time")] + public string SuccessTime { get; set; } = string.Empty; + + /// + /// 退款入账账户 + /// + [JsonPropertyName("user_received_account")] + public string UserReceivedAccount { get; set; } = string.Empty; + + /// + /// 金额信息 + /// + [JsonPropertyName("amount")] + public WechatPayV3RefundNotifyAmount Amount { get; set; } = new(); +} + +/// +/// V3 退款回调金额信息 +/// +public class WechatPayV3RefundNotifyAmount +{ + /// + /// 订单金额(单位:分) + /// + [JsonPropertyName("total")] + public int Total { get; set; } + + /// + /// 退款金额(单位:分) + /// + [JsonPropertyName("refund")] + public int Refund { get; set; } + + /// + /// 用户支付金额(单位:分) + /// + [JsonPropertyName("payer_total")] + public int PayerTotal { get; set; } + + /// + /// 用户退款金额(单位:分) + /// + [JsonPropertyName("payer_refund")] + public int PayerRefund { get; set; } +} + +#endregion + +#region V3 错误响应模型 + +/// +/// V3 API 错误响应 +/// +public class WechatPayV3ErrorResponse +{ + /// + /// 错误码 + /// + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + /// + /// 错误信息 + /// + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + /// + /// 详细错误信息 + /// + [JsonPropertyName("detail")] + public WechatPayV3ErrorDetail? Detail { get; set; } +} + +/// +/// V3 错误详情 +/// +public class WechatPayV3ErrorDetail +{ + /// + /// 字段名 + /// + [JsonPropertyName("field")] + public string Field { get; set; } = string.Empty; + + /// + /// 字段值 + /// + [JsonPropertyName("value")] + public string Value { get; set; } = string.Empty; + + /// + /// 问题描述 + /// + [JsonPropertyName("issue")] + public string Issue { get; set; } = string.Empty; + + /// + /// 位置 + /// + [JsonPropertyName("location")] + public string Location { get; set; } = string.Empty; +} + +#endregion + +#region V3 交易状态常量 + +/// +/// V3 交易状态常量 +/// +public static class WechatPayV3TradeState +{ + /// + /// 支付成功 + /// + public const string Success = "SUCCESS"; + + /// + /// 转入退款 + /// + public const string Refund = "REFUND"; + + /// + /// 未支付 + /// + public const string NotPay = "NOTPAY"; + + /// + /// 已关闭 + /// + public const string Closed = "CLOSED"; + + /// + /// 已撤销(仅付款码支付) + /// + public const string Revoked = "REVOKED"; + + /// + /// 用户支付中(仅付款码支付) + /// + public const string UserPaying = "USERPAYING"; + + /// + /// 支付失败(仅付款码支付) + /// + public const string PayError = "PAYERROR"; +} + +/// +/// V3 退款状态常量 +/// +public static class WechatPayV3RefundStatus +{ + /// + /// 退款成功 + /// + public const string Success = "SUCCESS"; + + /// + /// 退款关闭 + /// + public const string Closed = "CLOSED"; + + /// + /// 退款处理中 + /// + public const string Processing = "PROCESSING"; + + /// + /// 退款异常 + /// + public const string Abnormal = "ABNORMAL"; +} + +/// +/// V3 回调事件类型常量 +/// +public static class WechatPayV3EventType +{ + /// + /// 支付成功 + /// + public const string TransactionSuccess = "TRANSACTION.SUCCESS"; + + /// + /// 退款成功 + /// + public const string RefundSuccess = "REFUND.SUCCESS"; + + /// + /// 退款异常 + /// + public const string RefundAbnormal = "REFUND.ABNORMAL"; + + /// + /// 退款关闭 + /// + public const string RefundClosed = "REFUND.CLOSED"; +} + +#endregion + + +#region 回调版本枚举 + +/// +/// 微信支付回调版本 +/// +public enum NotifyVersion +{ + /// + /// 未知版本 + /// + Unknown = 0, + + /// + /// V2 版本(XML 格式) + /// + V2 = 2, + + /// + /// V3 版本(JSON 格式) + /// + V3 = 3 +} + +#endregion + + +#region V3 回调请求头 + +/// +/// 微信支付回调请求头 +/// +public class WechatPayNotifyHeaders +{ + /// + /// 时间戳(Wechatpay-Timestamp) + /// + public string Timestamp { get; set; } = string.Empty; + + /// + /// 随机串(Wechatpay-Nonce) + /// + public string Nonce { get; set; } = string.Empty; + + /// + /// 签名(Wechatpay-Signature) + /// + public string Signature { get; set; } = string.Empty; + + /// + /// 证书序列号(Wechatpay-Serial) + /// + public string Serial { get; set; } = string.Empty; + + /// + /// 签名类型(Wechatpay-Signature-Type,默认 WECHATPAY2-SHA256-RSA2048) + /// + public string SignatureType { get; set; } = "WECHATPAY2-SHA256-RSA2048"; +} + +#endregion diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Integration/PaymentNotifyServiceIntegrationTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Integration/PaymentNotifyServiceIntegrationTests.cs index e6934c0c..dc6998da 100644 --- a/server/HoneyBox/tests/HoneyBox.Tests/Integration/PaymentNotifyServiceIntegrationTests.cs +++ b/server/HoneyBox/tests/HoneyBox.Tests/Integration/PaymentNotifyServiceIntegrationTests.cs @@ -81,9 +81,17 @@ public class PaymentNotifyServiceIntegrationTests var paymentService = new PaymentService(dbContext, _mockPaymentLogger.Object); var mockLotteryEngine = new Mock(); + var mockWechatPayV3Service = new Mock(); + + // Setup V3 service to detect V2 format for existing tests + mockWechatPayV3Service.Setup(x => x.DetectNotifyVersion(It.IsAny())) + .Returns(NotifyVersion.V2); + var notifyService = new PaymentNotifyService( dbContext, wechatPayService, + mockWechatPayV3Service.Object, + _mockConfigService.Object, paymentService, mockLotteryEngine.Object, _mockNotifyLogger.Object); diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3ConfigPropertyTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3ConfigPropertyTests.cs new file mode 100644 index 00000000..160db060 --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3ConfigPropertyTests.cs @@ -0,0 +1,228 @@ +using System.Text.Json; +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Admin.Business.Models.Config; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// 微信支付 V3 配置属性测试 +/// **Feature: wechat-pay-v3-upgrade** +/// +public class WechatPayV3ConfigPropertyTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = null, // 使用原始属性名 + WriteIndented = false + }; + + #region Property 1: 配置序列化 Round-Trip + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 1: 配置序列化 Round-Trip** + /// *For any* 有效的 WeixinPayMerchant 配置对象(包含 V2 或 V3 字段), + /// 序列化为 JSON 后再反序列化,应该得到与原始对象等价的配置。 + /// **Validates: Requirements 1.4, 1.5** + /// + [Property(MaxTest = 100)] + public bool WeixinPayMerchant_V3Config_RoundTrip_ShouldPreserveAllFields( + NonEmptyString name, + NonEmptyString mchId, + NonEmptyString orderPrefix, + NonEmptyString apiKey, + NonEmptyString apiV3Key, + NonEmptyString certSerialNo, + NonEmptyString privateKeyPath, + NonEmptyString wechatPublicKeyId, + NonEmptyString wechatPublicKeyPath, + bool isV3) + { + // 创建包含 V3 字段的配置 + var original = new WeixinPayMerchant + { + Name = name.Get, + MchId = mchId.Get, + OrderPrefix = orderPrefix.Get.Length >= 3 ? orderPrefix.Get.Substring(0, 3) : "ABC", + ApiKey = apiKey.Get, + IsEnabled = "1", + PayVersion = isV3 ? "V3" : "V2", + ApiV3Key = isV3 ? apiV3Key.Get : null, + CertSerialNo = isV3 ? certSerialNo.Get : null, + PrivateKeyPath = isV3 ? privateKeyPath.Get : null, + WechatPublicKeyId = isV3 ? wechatPublicKeyId.Get : null, + WechatPublicKeyPath = isV3 ? wechatPublicKeyPath.Get : null + }; + + // 序列化 + var json = JsonSerializer.Serialize(original, JsonOptions); + + // 反序列化 + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + if (deserialized == null) return false; + + // 验证所有字段 + return original.Name == deserialized.Name && + original.MchId == deserialized.MchId && + original.OrderPrefix == deserialized.OrderPrefix && + original.ApiKey == deserialized.ApiKey && + original.IsEnabled == deserialized.IsEnabled && + original.PayVersion == deserialized.PayVersion && + original.ApiV3Key == deserialized.ApiV3Key && + original.CertSerialNo == deserialized.CertSerialNo && + original.PrivateKeyPath == deserialized.PrivateKeyPath && + original.WechatPublicKeyId == deserialized.WechatPublicKeyId && + original.WechatPublicKeyPath == deserialized.WechatPublicKeyPath; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 1: 配置序列化 Round-Trip** + /// *For any* 有效的 WeixinPaySetting 配置对象(包含多个商户), + /// 序列化为 JSON 后再反序列化,应该得到与原始对象等价的配置。 + /// **Validates: Requirements 1.4, 1.5** + /// + [Property(MaxTest = 100)] + public bool WeixinPaySetting_RoundTrip_ShouldPreserveAllMerchants(PositiveInt seed) + { + // 创建包含多个商户的配置 + var merchantCount = (seed.Get % 3) + 1; // 1-3 个商户 + var merchants = new List(); + + for (int i = 0; i < merchantCount; i++) + { + var isV3 = i % 2 == 0; // 交替 V2/V3 + merchants.Add(new WeixinPayMerchant + { + Name = $"商户{i + 1}", + MchId = $"mch{seed.Get + i}", + OrderPrefix = $"M{i:D2}", + ApiKey = $"key{seed.Get + i}", + IsEnabled = "1", + PayVersion = isV3 ? "V3" : "V2", + ApiV3Key = isV3 ? $"v3key{seed.Get + i}" : null, + CertSerialNo = isV3 ? $"serial{seed.Get + i}" : null, + PrivateKeyPath = isV3 ? $"certs/{seed.Get + i}/key.pem" : null, + WechatPublicKeyId = isV3 ? $"pubkeyid{seed.Get + i}" : null, + WechatPublicKeyPath = isV3 ? $"certs/{seed.Get + i}/pub.pem" : null + }); + } + + var original = new WeixinPaySetting { Merchants = merchants }; + + // 序列化 + var json = JsonSerializer.Serialize(original, JsonOptions); + + // 反序列化 + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + if (deserialized == null || deserialized.Merchants == null) return false; + if (original.Merchants.Count != deserialized.Merchants.Count) return false; + + // 验证每个商户 + for (int i = 0; i < original.Merchants.Count; i++) + { + var orig = original.Merchants[i]; + var deser = deserialized.Merchants[i]; + + if (orig.Name != deser.Name || + orig.MchId != deser.MchId || + orig.OrderPrefix != deser.OrderPrefix || + orig.ApiKey != deser.ApiKey || + orig.IsEnabled != deser.IsEnabled || + orig.PayVersion != deser.PayVersion || + orig.ApiV3Key != deser.ApiV3Key || + orig.CertSerialNo != deser.CertSerialNo || + orig.PrivateKeyPath != deser.PrivateKeyPath || + orig.WechatPublicKeyId != deser.WechatPublicKeyId || + orig.WechatPublicKeyPath != deser.WechatPublicKeyPath) + { + return false; + } + } + + return true; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 1: 配置序列化 Round-Trip** + /// *For any* V2 配置,PayVersion 默认值应该是 "V2"。 + /// **Validates: Requirements 1.3** + /// + [Fact] + public void WeixinPayMerchant_DefaultPayVersion_ShouldBeV2() + { + var merchant = new WeixinPayMerchant(); + Assert.Equal("V2", merchant.PayVersion); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 1: 配置序列化 Round-Trip** + /// *For any* V3 配置 JSON,反序列化后应该正确读取所有 V3 字段。 + /// **Validates: Requirements 1.1, 1.2** + /// + [Fact] + public void WeixinPayMerchant_V3JsonDeserialization_ShouldReadAllV3Fields() + { + var json = @"{ + ""name"": ""测试商户"", + ""mch_id"": ""1738725801"", + ""order_prefix"": ""MYH"", + ""api_key"": ""v2key"", + ""is_enabled"": ""1"", + ""pay_version"": ""V3"", + ""api_v3_key"": ""d1cxc0vXCUH2984901DxddPJMYqcwcnd"", + ""cert_serial_no"": ""SERIAL123456"", + ""private_key_path"": ""certs/1738725801/apiclient_key.pem"", + ""wechat_public_key_id"": ""PUB_KEY_ID_0117387258012026012500291641000801"", + ""wechat_public_key_path"": ""certs/1738725801/pub_key.pem"" + }"; + + var merchant = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(merchant); + Assert.Equal("测试商户", merchant.Name); + Assert.Equal("1738725801", merchant.MchId); + Assert.Equal("MYH", merchant.OrderPrefix); + Assert.Equal("v2key", merchant.ApiKey); + Assert.Equal("1", merchant.IsEnabled); + Assert.Equal("V3", merchant.PayVersion); + Assert.Equal("d1cxc0vXCUH2984901DxddPJMYqcwcnd", merchant.ApiV3Key); + Assert.Equal("SERIAL123456", merchant.CertSerialNo); + Assert.Equal("certs/1738725801/apiclient_key.pem", merchant.PrivateKeyPath); + Assert.Equal("PUB_KEY_ID_0117387258012026012500291641000801", merchant.WechatPublicKeyId); + Assert.Equal("certs/1738725801/pub_key.pem", merchant.WechatPublicKeyPath); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 1: 配置序列化 Round-Trip** + /// *For any* V2 配置 JSON(不包含 V3 字段),反序列化后 V3 字段应该为 null。 + /// **Validates: Requirements 1.3** + /// + [Fact] + public void WeixinPayMerchant_V2JsonDeserialization_ShouldHaveNullV3Fields() + { + var json = @"{ + ""name"": ""V2商户"", + ""mch_id"": ""1234567890"", + ""order_prefix"": ""ABC"", + ""api_key"": ""v2key"", + ""is_enabled"": ""1"" + }"; + + var merchant = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(merchant); + Assert.Equal("V2商户", merchant.Name); + Assert.Equal("V2", merchant.PayVersion); // 默认值 + Assert.Null(merchant.ApiV3Key); + Assert.Null(merchant.CertSerialNo); + Assert.Null(merchant.PrivateKeyPath); + Assert.Null(merchant.WechatPublicKeyId); + Assert.Null(merchant.WechatPublicKeyPath); + } + + #endregion +} diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3DecryptionPropertyTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3DecryptionPropertyTests.cs new file mode 100644 index 00000000..afc64068 --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3DecryptionPropertyTests.cs @@ -0,0 +1,420 @@ +using System.Security.Cryptography; +using System.Text; +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Core.Interfaces; +using HoneyBox.Core.Services; +using HoneyBox.Model.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// 微信支付 V3 解密属性测试 +/// **Feature: wechat-pay-v3-upgrade** +/// +public class WechatPayV3DecryptionPropertyTests +{ + /// + /// 生成有效的 32 字节 APIv3 密钥 + /// + private static string GenerateApiV3Key() + { + const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var random = new Random(); + var result = new char[32]; + for (int i = 0; i < 32; i++) + { + result[i] = chars[random.Next(chars.Length)]; + } + return new string(result); + } + + /// + /// 生成有效的 12 字节 nonce + /// + private static string GenerateNonce() + { + const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var random = new Random(); + var result = new char[12]; + for (int i = 0; i < 12; i++) + { + result[i] = chars[random.Next(chars.Length)]; + } + return new string(result); + } + + private IWechatPayV3Service CreateService() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + var dbContext = new HoneyBoxDbContext(options); + var httpClient = new HttpClient(); + var logger = Mock.Of>(); + var configService = Mock.Of(); + + return new WechatPayV3Service(dbContext, httpClient, logger, configService); + } + + #region Property 9: V3 回调解密 Round-Trip + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip** + /// *For any* 有效的支付结果数据,使用 AES-256-GCM 加密后再解密, + /// 应该得到与原始数据等价的结果。 + /// **Validates: Requirements 4.3** + /// + [Property(MaxTest = 100)] + public bool DecryptRoundTrip_ShouldReturnOriginalData(NonEmptyString plaintext, PositiveInt seed) + { + var service = CreateService(); + + // 生成固定的密钥和 nonce(基于 seed 确保可重复) + var random = new Random(seed.Get); + var apiV3Key = GenerateApiV3KeyWithSeed(random); + var nonce = GenerateNonceWithSeed(random); + var associatedData = "transaction"; + + // 清理输入(移除可能导致问题的字符) + var cleanPlaintext = plaintext.Get.Replace("\0", ""); + if (string.IsNullOrEmpty(cleanPlaintext)) + { + return true; // 跳过空字符串 + } + + try + { + // 加密 + var ciphertext = service.EncryptNotifyResource(cleanPlaintext, nonce, associatedData, apiV3Key); + + // 解密 + var decrypted = service.DecryptNotifyResource(ciphertext, nonce, associatedData, apiV3Key); + + // 验证 round-trip + return cleanPlaintext == decrypted; + } + catch + { + return false; + } + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip** + /// *For any* JSON 格式的支付结果数据,加密后再解密应该保持 JSON 结构不变。 + /// **Validates: Requirements 4.3** + /// + [Property(MaxTest = 100)] + public bool DecryptRoundTrip_JsonData_ShouldPreserveStructure( + NonEmptyString orderNo, + NonEmptyString transactionId, + PositiveInt amount, + PositiveInt seed) + { + var service = CreateService(); + + var random = new Random(seed.Get); + var apiV3Key = GenerateApiV3KeyWithSeed(random); + var nonce = GenerateNonceWithSeed(random); + var associatedData = "transaction"; + + // 构建类似微信支付回调的 JSON 数据 + var cleanOrderNo = orderNo.Get.Replace("\"", "").Replace("\\", "").Replace("\n", "").Replace("\r", ""); + var cleanTransactionId = transactionId.Get.Replace("\"", "").Replace("\\", "").Replace("\n", "").Replace("\r", ""); + + var jsonData = $"{{\"out_trade_no\":\"{cleanOrderNo}\",\"transaction_id\":\"{cleanTransactionId}\",\"trade_state\":\"SUCCESS\",\"amount\":{{\"total\":{amount.Get}}}}}"; + + try + { + // 加密 + var ciphertext = service.EncryptNotifyResource(jsonData, nonce, associatedData, apiV3Key); + + // 解密 + var decrypted = service.DecryptNotifyResource(ciphertext, nonce, associatedData, apiV3Key); + + // 验证 round-trip + return jsonData == decrypted; + } + catch + { + return false; + } + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip** + /// *For any* 有效数据,使用不同的密钥解密应该失败。 + /// **Validates: Requirements 4.3** + /// + [Property(MaxTest = 100)] + public bool Decrypt_WithWrongKey_ShouldFail(NonEmptyString plaintext, PositiveInt seed) + { + var service = CreateService(); + + var random = new Random(seed.Get); + var apiV3Key1 = GenerateApiV3KeyWithSeed(random); + var apiV3Key2 = GenerateApiV3KeyWithSeed(new Random(seed.Get + 1)); // 不同的密钥 + var nonce = GenerateNonceWithSeed(random); + var associatedData = "transaction"; + + // 确保两个密钥不同 + if (apiV3Key1 == apiV3Key2) + { + return true; // 跳过相同密钥的情况 + } + + var cleanPlaintext = plaintext.Get.Replace("\0", ""); + if (string.IsNullOrEmpty(cleanPlaintext)) + { + return true; + } + + try + { + // 使用密钥1加密 + var ciphertext = service.EncryptNotifyResource(cleanPlaintext, nonce, associatedData, apiV3Key1); + + // 使用密钥2解密应该失败 + try + { + service.DecryptNotifyResource(ciphertext, nonce, associatedData, apiV3Key2); + return false; // 如果没有抛出异常,测试失败 + } + catch (InvalidOperationException) + { + return true; // 预期的异常 + } + catch (CryptographicException) + { + return true; // 预期的异常 + } + } + catch + { + return false; + } + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip** + /// *For any* 有效数据,使用不同的 nonce 解密应该失败。 + /// **Validates: Requirements 4.3** + /// + [Property(MaxTest = 100)] + public bool Decrypt_WithWrongNonce_ShouldFail(NonEmptyString plaintext, PositiveInt seed) + { + var service = CreateService(); + + var random = new Random(seed.Get); + var apiV3Key = GenerateApiV3KeyWithSeed(random); + var nonce1 = GenerateNonceWithSeed(random); + var nonce2 = GenerateNonceWithSeed(new Random(seed.Get + 1)); // 不同的 nonce + var associatedData = "transaction"; + + // 确保两个 nonce 不同 + if (nonce1 == nonce2) + { + return true; + } + + var cleanPlaintext = plaintext.Get.Replace("\0", ""); + if (string.IsNullOrEmpty(cleanPlaintext)) + { + return true; + } + + try + { + // 使用 nonce1 加密 + var ciphertext = service.EncryptNotifyResource(cleanPlaintext, nonce1, associatedData, apiV3Key); + + // 使用 nonce2 解密应该失败 + try + { + service.DecryptNotifyResource(ciphertext, nonce2, associatedData, apiV3Key); + return false; + } + catch (InvalidOperationException) + { + return true; + } + catch (CryptographicException) + { + return true; + } + } + catch + { + return false; + } + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip** + /// *For any* 有效数据,篡改密文后解密应该失败。 + /// **Validates: Requirements 4.3** + /// + [Property(MaxTest = 100)] + public bool Decrypt_WithTamperedCiphertext_ShouldFail(NonEmptyString plaintext, PositiveInt seed) + { + var service = CreateService(); + + var random = new Random(seed.Get); + var apiV3Key = GenerateApiV3KeyWithSeed(random); + var nonce = GenerateNonceWithSeed(random); + var associatedData = "transaction"; + + var cleanPlaintext = plaintext.Get.Replace("\0", ""); + if (string.IsNullOrEmpty(cleanPlaintext)) + { + return true; + } + + try + { + // 加密 + var ciphertext = service.EncryptNotifyResource(cleanPlaintext, nonce, associatedData, apiV3Key); + + // 篡改密文(修改一个字符) + var ciphertextBytes = Convert.FromBase64String(ciphertext); + if (ciphertextBytes.Length > 0) + { + ciphertextBytes[0] = (byte)(ciphertextBytes[0] ^ 0xFF); + } + var tamperedCiphertext = Convert.ToBase64String(ciphertextBytes); + + // 解密篡改后的密文应该失败 + try + { + service.DecryptNotifyResource(tamperedCiphertext, nonce, associatedData, apiV3Key); + return false; + } + catch (InvalidOperationException) + { + return true; + } + catch (CryptographicException) + { + return true; + } + } + catch + { + return false; + } + } + + #endregion + + #region 辅助方法 + + private static string GenerateApiV3KeyWithSeed(Random random) + { + const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var result = new char[32]; + for (int i = 0; i < 32; i++) + { + result[i] = chars[random.Next(chars.Length)]; + } + return new string(result); + } + + private static string GenerateNonceWithSeed(Random random) + { + const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var result = new char[12]; + for (int i = 0; i < 12; i++) + { + result[i] = chars[random.Next(chars.Length)]; + } + return new string(result); + } + + #endregion + + #region 边界情况测试 + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip** + /// 空的 associated data 应该正常工作。 + /// **Validates: Requirements 4.3** + /// + [Fact] + public void DecryptRoundTrip_EmptyAssociatedData_ShouldWork() + { + var service = CreateService(); + var apiV3Key = "d1cxc0vXCUH2984901DxddPJMYqcwcnd"; + var nonce = "abcdefghijkl"; + var plaintext = "{\"out_trade_no\":\"TEST123\",\"trade_state\":\"SUCCESS\"}"; + + // 加密(空 associated data) + var ciphertext = service.EncryptNotifyResource(plaintext, nonce, "", apiV3Key); + + // 解密 + var decrypted = service.DecryptNotifyResource(ciphertext, nonce, "", apiV3Key); + + Assert.Equal(plaintext, decrypted); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip** + /// 中文内容应该正常加解密。 + /// **Validates: Requirements 4.3** + /// + [Fact] + public void DecryptRoundTrip_ChineseContent_ShouldWork() + { + var service = CreateService(); + var apiV3Key = "d1cxc0vXCUH2984901DxddPJMYqcwcnd"; + var nonce = "abcdefghijkl"; + var plaintext = "{\"description\":\"商品购买-测试商品\",\"trade_state_desc\":\"支付成功\"}"; + + // 加密 + var ciphertext = service.EncryptNotifyResource(plaintext, nonce, "transaction", apiV3Key); + + // 解密 + var decrypted = service.DecryptNotifyResource(ciphertext, nonce, "transaction", apiV3Key); + + Assert.Equal(plaintext, decrypted); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip** + /// 无效的 APIv3 密钥长度应该抛出异常。 + /// **Validates: Requirements 4.3** + /// + [Fact] + public void Decrypt_InvalidKeyLength_ShouldThrow() + { + var service = CreateService(); + var invalidKey = "shortkey"; // 不是 32 字节 + var nonce = "abcdefghijkl"; + var ciphertext = "dGVzdA=="; // 随便一个 base64 + + Assert.Throws(() => + service.DecryptNotifyResource(ciphertext, nonce, "transaction", invalidKey)); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip** + /// 空密文应该抛出异常。 + /// **Validates: Requirements 4.3** + /// + [Fact] + public void Decrypt_EmptyCiphertext_ShouldThrow() + { + var service = CreateService(); + var apiV3Key = "d1cxc0vXCUH2984901DxddPJMYqcwcnd"; + var nonce = "abcdefghijkl"; + + Assert.Throws(() => + service.DecryptNotifyResource("", nonce, "transaction", apiV3Key)); + } + + #endregion +} diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3NotifyFormatPropertyTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3NotifyFormatPropertyTests.cs new file mode 100644 index 00000000..b6bb666c --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3NotifyFormatPropertyTests.cs @@ -0,0 +1,410 @@ +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Core.Interfaces; +using HoneyBox.Core.Services; +using HoneyBox.Model.Data; +using HoneyBox.Model.Models.Payment; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// 微信支付 V3 回调格式识别属性测试 +/// **Feature: wechat-pay-v3-upgrade** +/// +public class WechatPayV3NotifyFormatPropertyTests +{ + private IWechatPayV3Service CreateService() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + var dbContext = new HoneyBoxDbContext(options); + var httpClient = new HttpClient(); + var logger = Mock.Of>(); + var configService = Mock.Of(); + + return new WechatPayV3Service(dbContext, httpClient, logger, configService); + } + + #region Property 8: 回调格式识别正确性 + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// *For any* JSON 格式且包含 resource 字段的回调数据,应该被识别为 V3 格式。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Property(MaxTest = 100)] + public bool V3Format_JsonWithResource_ShouldBeDetectedAsV3( + NonEmptyString notifyId, + NonEmptyString eventType, + NonEmptyString ciphertext, + PositiveInt seed) + { + var service = CreateService(); + + // 清理输入(移除控制字符和特殊字符) + var cleanNotifyId = CleanJsonString(RemoveControlChars(notifyId.Get)); + var cleanEventType = CleanJsonString(RemoveControlChars(eventType.Get)); + var cleanCiphertext = CleanJsonString(RemoveControlChars(ciphertext.Get)); + + // 如果清理后为空,跳过测试 + if (string.IsNullOrEmpty(cleanNotifyId) || + string.IsNullOrEmpty(cleanEventType) || + string.IsNullOrEmpty(cleanCiphertext)) + { + return true; + } + + // 构建 V3 格式的回调数据(JSON 格式且包含 resource 字段) + var v3NotifyBody = $@"{{ + ""id"": ""{cleanNotifyId}"", + ""create_time"": ""2024-01-01T12:00:00+08:00"", + ""event_type"": ""{cleanEventType}"", + ""resource_type"": ""encrypt-resource"", + ""resource"": {{ + ""algorithm"": ""AEAD_AES_256_GCM"", + ""ciphertext"": ""{cleanCiphertext}"", + ""nonce"": ""abcdefghijkl"", + ""associated_data"": ""transaction"" + }} + }}"; + + var isV3 = service.IsV3NotifyFormat(v3NotifyBody); + var isV2 = service.IsV2NotifyFormat(v3NotifyBody); + var version = service.DetectNotifyVersion(v3NotifyBody); + + // V3 格式应该被正确识别 + return isV3 && !isV2 && version == NotifyVersion.V3; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// *For any* XML 格式的回调数据,应该被识别为 V2 格式。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Property(MaxTest = 100)] + public bool V2Format_Xml_ShouldBeDetectedAsV2( + NonEmptyString orderNo, + NonEmptyString transactionId, + PositiveInt totalFee, + PositiveInt seed) + { + var service = CreateService(); + + // 清理输入(移除控制字符) + var cleanOrderNo = CleanXmlString(RemoveControlChars(orderNo.Get)); + var cleanTransactionId = CleanXmlString(RemoveControlChars(transactionId.Get)); + + // 如果清理后为空,跳过测试 + if (string.IsNullOrEmpty(cleanOrderNo) || string.IsNullOrEmpty(cleanTransactionId)) + { + return true; + } + + // 构建 V2 格式的回调数据(XML 格式) + var v2NotifyBody = $@" + + + + + {totalFee.Get} + "; + + var isV3 = service.IsV3NotifyFormat(v2NotifyBody); + var isV2 = service.IsV2NotifyFormat(v2NotifyBody); + var version = service.DetectNotifyVersion(v2NotifyBody); + + // V2 格式应该被正确识别 + return !isV3 && isV2 && version == NotifyVersion.V2; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// *For any* JSON 格式但不包含 resource 字段的数据,不应该被识别为 V3 格式。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Property(MaxTest = 100)] + public bool JsonWithoutResource_ShouldNotBeV3( + NonEmptyString key, + NonEmptyString value, + PositiveInt seed) + { + var service = CreateService(); + + var cleanKey = CleanJsonString(RemoveControlChars(key.Get)); + var cleanValue = CleanJsonString(RemoveControlChars(value.Get)); + + // 如果清理后为空,跳过测试 + if (string.IsNullOrEmpty(cleanKey) || string.IsNullOrEmpty(cleanValue)) + { + return true; + } + + // 确保 key 不是 "resource" + if (cleanKey.Equals("resource", StringComparison.OrdinalIgnoreCase)) + { + cleanKey = "other_key"; + } + + // 构建不包含 resource 字段的 JSON + var jsonBody = $@"{{ + ""{cleanKey}"": ""{cleanValue}"", + ""other_field"": ""some_value"" + }}"; + + var isV3 = service.IsV3NotifyFormat(jsonBody); + + // 不包含 resource 字段的 JSON 不应该被识别为 V3 + return !isV3; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// *For any* 空字符串或空白字符串,应该被识别为 Unknown 格式。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Property(MaxTest = 100)] + public bool EmptyOrWhitespace_ShouldBeUnknown(PositiveInt whitespaceCount) + { + var service = CreateService(); + + // 生成空白字符串 + var whitespace = new string(' ', whitespaceCount.Get % 100); + + var isV3Empty = service.IsV3NotifyFormat(""); + var isV2Empty = service.IsV2NotifyFormat(""); + var versionEmpty = service.DetectNotifyVersion(""); + + var isV3Whitespace = service.IsV3NotifyFormat(whitespace); + var isV2Whitespace = service.IsV2NotifyFormat(whitespace); + var versionWhitespace = service.DetectNotifyVersion(whitespace); + + // 空字符串和空白字符串都应该被识别为 Unknown + return !isV3Empty && !isV2Empty && versionEmpty == NotifyVersion.Unknown && + !isV3Whitespace && !isV2Whitespace && versionWhitespace == NotifyVersion.Unknown; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// *For any* 非 JSON 非 XML 的数据,应该被识别为 Unknown 格式。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Property(MaxTest = 100)] + public bool InvalidFormat_ShouldBeUnknown(NonEmptyString randomData, PositiveInt seed) + { + var service = CreateService(); + + // 确保数据不是以 { 或 < 开头 + var data = randomData.Get.TrimStart(); + if (data.StartsWith('{') || data.StartsWith('<')) + { + data = "INVALID_" + data; + } + + var isV3 = service.IsV3NotifyFormat(data); + var isV2 = service.IsV2NotifyFormat(data); + var version = service.DetectNotifyVersion(data); + + // 非 JSON 非 XML 的数据应该被识别为 Unknown + return !isV3 && !isV2 && version == NotifyVersion.Unknown; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// V3 和 V2 格式应该是互斥的。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Property(MaxTest = 100)] + public bool V3AndV2_ShouldBeMutuallyExclusive(NonEmptyString data, PositiveInt seed) + { + var service = CreateService(); + + var isV3 = service.IsV3NotifyFormat(data.Get); + var isV2 = service.IsV2NotifyFormat(data.Get); + + // V3 和 V2 不能同时为 true + return !(isV3 && isV2); + } + + #endregion + + #region 边界情况测试 + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// 真实的 V3 支付成功回调应该被正确识别。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Fact] + public void RealV3PaymentSuccessNotify_ShouldBeDetectedAsV3() + { + var service = CreateService(); + + var v3NotifyBody = @"{ + ""id"": ""EV-2024010112345678901234567890"", + ""create_time"": ""2024-01-01T12:00:00+08:00"", + ""event_type"": ""TRANSACTION.SUCCESS"", + ""resource_type"": ""encrypt-resource"", + ""resource"": { + ""algorithm"": ""AEAD_AES_256_GCM"", + ""ciphertext"": ""base64encodedciphertext"", + ""nonce"": ""abcdefghijkl"", + ""associated_data"": ""transaction"", + ""original_type"": ""transaction"" + }, + ""summary"": ""支付成功"" + }"; + + Assert.True(service.IsV3NotifyFormat(v3NotifyBody)); + Assert.False(service.IsV2NotifyFormat(v3NotifyBody)); + Assert.Equal(NotifyVersion.V3, service.DetectNotifyVersion(v3NotifyBody)); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// 真实的 V2 支付成功回调应该被正确识别。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Fact] + public void RealV2PaymentSuccessNotify_ShouldBeDetectedAsV2() + { + var service = CreateService(); + + var v2NotifyBody = @" + + + + + + + + + + + 100 + + + + + + "; + + Assert.False(service.IsV3NotifyFormat(v2NotifyBody)); + Assert.True(service.IsV2NotifyFormat(v2NotifyBody)); + Assert.Equal(NotifyVersion.V2, service.DetectNotifyVersion(v2NotifyBody)); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// 带有前导空白的 V3 回调应该被正确识别。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Fact] + public void V3NotifyWithLeadingWhitespace_ShouldBeDetectedAsV3() + { + var service = CreateService(); + + var v3NotifyBody = @" + { + ""id"": ""test"", + ""resource"": { + ""ciphertext"": ""test"" + } + }"; + + Assert.True(service.IsV3NotifyFormat(v3NotifyBody)); + Assert.Equal(NotifyVersion.V3, service.DetectNotifyVersion(v3NotifyBody)); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// 带有前导空白的 V2 回调应该被正确识别。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Fact] + public void V2NotifyWithLeadingWhitespace_ShouldBeDetectedAsV2() + { + var service = CreateService(); + + var v2NotifyBody = @" + + SUCCESS + "; + + Assert.True(service.IsV2NotifyFormat(v2NotifyBody)); + Assert.Equal(NotifyVersion.V2, service.DetectNotifyVersion(v2NotifyBody)); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性** + /// 无效的 JSON 不应该被识别为 V3。 + /// **Validates: Requirements 4.1, 4.5** + /// + [Fact] + public void InvalidJson_ShouldNotBeV3() + { + var service = CreateService(); + + var invalidJson = @"{ ""resource"": ""missing closing brace"""; + + Assert.False(service.IsV3NotifyFormat(invalidJson)); + } + + #endregion + + #region 辅助方法 + + /// + /// 移除控制字符 + /// + private static string RemoveControlChars(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + return new string(input.Where(c => !char.IsControl(c)).ToArray()); + } + + /// + /// 清理字符串以用于 JSON + /// + private static string CleanJsonString(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + return input + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t"); + } + + /// + /// 清理字符串以用于 XML + /// + private static string CleanXmlString(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + return input + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'") + .Replace("\n", " ") + .Replace("\r", " "); + } + + #endregion +} diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3RequestPropertyTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3RequestPropertyTests.cs new file mode 100644 index 00000000..3674ea13 --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3RequestPropertyTests.cs @@ -0,0 +1,367 @@ +using System.Text.Json; +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Model.Models.Payment; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// 微信支付 V3 请求字段完整性属性测试 +/// **Feature: wechat-pay-v3-upgrade** +/// +public class WechatPayV3RequestPropertyTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + #region Property 6: V3 请求字段完整性 + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性** + /// *For any* V3 JSAPI 下单请求,构建的请求体应该包含所有必要字段: + /// appid、mchid、description、out_trade_no、notify_url、amount、payer。 + /// **Validates: Requirements 3.2** + /// + [Property(MaxTest = 100)] + public bool V3JsapiRequest_ShouldContainAllRequiredFields( + NonEmptyString appId, + NonEmptyString mchId, + NonEmptyString description, + NonEmptyString outTradeNo, + NonEmptyString notifyUrl, + PositiveInt totalAmount, + NonEmptyString openId) + { + // 创建 V3 JSAPI 请求 + var request = new WechatPayV3JsapiRequest + { + AppId = appId.Get, + MchId = mchId.Get, + Description = description.Get, + OutTradeNo = outTradeNo.Get, + NotifyUrl = notifyUrl.Get, + Amount = new WechatPayV3Amount + { + Total = totalAmount.Get, + Currency = "CNY" + }, + Payer = new WechatPayV3Payer + { + OpenId = openId.Get + } + }; + + // 序列化为 JSON + var json = JsonSerializer.Serialize(request, JsonOptions); + + // 验证所有必要字段都存在 + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // 检查所有必要字段 + var hasAppId = root.TryGetProperty("appid", out var appIdProp) && !string.IsNullOrEmpty(appIdProp.GetString()); + var hasMchId = root.TryGetProperty("mchid", out var mchIdProp) && !string.IsNullOrEmpty(mchIdProp.GetString()); + var hasDescription = root.TryGetProperty("description", out var descProp) && !string.IsNullOrEmpty(descProp.GetString()); + var hasOutTradeNo = root.TryGetProperty("out_trade_no", out var tradeProp) && !string.IsNullOrEmpty(tradeProp.GetString()); + var hasNotifyUrl = root.TryGetProperty("notify_url", out var notifyProp) && !string.IsNullOrEmpty(notifyProp.GetString()); + var hasAmount = root.TryGetProperty("amount", out var amountProp) && amountProp.ValueKind == JsonValueKind.Object; + var hasPayer = root.TryGetProperty("payer", out var payerProp) && payerProp.ValueKind == JsonValueKind.Object; + + // 检查 amount 子字段 + var hasTotal = hasAmount && amountProp.TryGetProperty("total", out var totalProp) && totalProp.ValueKind == JsonValueKind.Number; + var hasCurrency = hasAmount && amountProp.TryGetProperty("currency", out var currencyProp) && !string.IsNullOrEmpty(currencyProp.GetString()); + + // 检查 payer 子字段 + var hasOpenId = hasPayer && payerProp.TryGetProperty("openid", out var openIdProp) && !string.IsNullOrEmpty(openIdProp.GetString()); + + return hasAppId && hasMchId && hasDescription && hasOutTradeNo && hasNotifyUrl && + hasAmount && hasTotal && hasCurrency && hasPayer && hasOpenId; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性** + /// *For any* V3 JSAPI 请求,金额字段应该是正整数(单位:分)。 + /// **Validates: Requirements 3.2** + /// + [Property(MaxTest = 100)] + public bool V3JsapiRequest_AmountShouldBePositiveInteger(PositiveInt totalAmount) + { + var request = new WechatPayV3JsapiRequest + { + AppId = "wx1234567890", + MchId = "1234567890", + Description = "测试商品", + OutTradeNo = "ORDER123456", + NotifyUrl = "https://example.com/notify", + Amount = new WechatPayV3Amount + { + Total = totalAmount.Get, + Currency = "CNY" + }, + Payer = new WechatPayV3Payer + { + OpenId = "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o" + } + }; + + var json = JsonSerializer.Serialize(request, JsonOptions); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var amount = root.GetProperty("amount"); + var total = amount.GetProperty("total").GetInt32(); + + return total > 0 && total == totalAmount.Get; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性** + /// *For any* V3 JSAPI 请求,货币类型默认应该是 CNY。 + /// **Validates: Requirements 3.2** + /// + [Property(MaxTest = 100)] + public bool V3JsapiRequest_CurrencyShouldDefaultToCNY(PositiveInt totalAmount) + { + var request = new WechatPayV3JsapiRequest + { + AppId = "wx1234567890", + MchId = "1234567890", + Description = "测试商品", + OutTradeNo = "ORDER123456", + NotifyUrl = "https://example.com/notify", + Amount = new WechatPayV3Amount + { + Total = totalAmount.Get + // Currency 使用默认值 + }, + Payer = new WechatPayV3Payer + { + OpenId = "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o" + } + }; + + var json = JsonSerializer.Serialize(request, JsonOptions); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var amount = root.GetProperty("amount"); + var currency = amount.GetProperty("currency").GetString(); + + return currency == "CNY"; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性** + /// *For any* V3 JSAPI 请求,可选字段 attach 为 null 时不应该出现在 JSON 中。 + /// **Validates: Requirements 3.2** + /// + [Fact] + public void V3JsapiRequest_NullAttach_ShouldNotAppearInJson() + { + var request = new WechatPayV3JsapiRequest + { + AppId = "wx1234567890", + MchId = "1234567890", + Description = "测试商品", + OutTradeNo = "ORDER123456", + NotifyUrl = "https://example.com/notify", + Amount = new WechatPayV3Amount { Total = 100, Currency = "CNY" }, + Payer = new WechatPayV3Payer { OpenId = "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o" }, + Attach = null // 可选字段为 null + }; + + var json = JsonSerializer.Serialize(request, JsonOptions); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // attach 为 null 时不应该出现在 JSON 中 + Assert.False(root.TryGetProperty("attach", out _)); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性** + /// *For any* V3 JSAPI 请求,可选字段 attach 有值时应该出现在 JSON 中。 + /// **Validates: Requirements 3.2** + /// + [Property(MaxTest = 100)] + public bool V3JsapiRequest_NonNullAttach_ShouldAppearInJson(NonEmptyString attach) + { + var request = new WechatPayV3JsapiRequest + { + AppId = "wx1234567890", + MchId = "1234567890", + Description = "测试商品", + OutTradeNo = "ORDER123456", + NotifyUrl = "https://example.com/notify", + Amount = new WechatPayV3Amount { Total = 100, Currency = "CNY" }, + Payer = new WechatPayV3Payer { OpenId = "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o" }, + Attach = attach.Get + }; + + var json = JsonSerializer.Serialize(request, JsonOptions); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // attach 有值时应该出现在 JSON 中 + return root.TryGetProperty("attach", out var attachProp) && + attachProp.GetString() == attach.Get; + } + + #endregion + + #region Property 7: V3 支付参数完整性 + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 7: V3 支付参数完整性** + /// *For any* 成功的 V3 下单响应,返回给前端的支付参数应该包含: + /// timeStamp、nonceStr、package、signType(RSA)、paySign。 + /// **Validates: Requirements 3.4** + /// + [Property(MaxTest = 100)] + public bool V3PayData_ShouldContainAllRequiredFields( + NonEmptyString appId, + NonEmptyString timeStamp, + NonEmptyString nonceStr, + NonEmptyString prepayId, + NonEmptyString paySign) + { + // 模拟 V3 支付数据 + var payData = new WechatPayData + { + AppId = appId.Get, + TimeStamp = timeStamp.Get, + NonceStr = nonceStr.Get, + Package = $"prepay_id={prepayId.Get}", + SignType = "RSA", + PaySign = paySign.Get, + IsWeixin = 1 + }; + + // 验证所有必要字段 + return !string.IsNullOrEmpty(payData.AppId) && + !string.IsNullOrEmpty(payData.TimeStamp) && + !string.IsNullOrEmpty(payData.NonceStr) && + !string.IsNullOrEmpty(payData.Package) && + payData.Package.StartsWith("prepay_id=") && + payData.SignType == "RSA" && + !string.IsNullOrEmpty(payData.PaySign); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 7: V3 支付参数完整性** + /// V3 支付参数的 signType 应该是 RSA(而不是 V2 的 MD5)。 + /// **Validates: Requirements 3.4** + /// + [Fact] + public void V3PayData_SignType_ShouldBeRSA() + { + var payData = new WechatPayData + { + AppId = "wx1234567890", + TimeStamp = "1609459200", + NonceStr = "5K8264ILTKCH16CQ2502SI8ZNMTM67VS", + Package = "prepay_id=wx201410272009395522657a690389285100", + SignType = "RSA", // V3 使用 RSA + PaySign = "base64signature", + IsWeixin = 1 + }; + + Assert.Equal("RSA", payData.SignType); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 7: V3 支付参数完整性** + /// V3 支付参数的 package 格式应该是 prepay_id=xxx。 + /// **Validates: Requirements 3.4** + /// + [Property(MaxTest = 100)] + public bool V3PayData_Package_ShouldHaveCorrectFormat(NonEmptyString prepayId) + { + var package = $"prepay_id={prepayId.Get}"; + + return package.StartsWith("prepay_id=") && + package.Length > "prepay_id=".Length; + } + + #endregion + + #region 请求序列化测试 + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性** + /// V3 请求序列化后的 JSON 字段名应该使用 snake_case 格式。 + /// **Validates: Requirements 3.2** + /// + [Fact] + public void V3JsapiRequest_Serialization_ShouldUseSnakeCase() + { + var request = new WechatPayV3JsapiRequest + { + AppId = "wx1234567890", + MchId = "1234567890", + Description = "测试商品", + OutTradeNo = "ORDER123456", + NotifyUrl = "https://example.com/notify", + Amount = new WechatPayV3Amount { Total = 100, Currency = "CNY" }, + Payer = new WechatPayV3Payer { OpenId = "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o" } + }; + + var json = JsonSerializer.Serialize(request, JsonOptions); + + // 验证使用 snake_case + Assert.Contains("\"appid\"", json); + Assert.Contains("\"mchid\"", json); + Assert.Contains("\"description\"", json); + Assert.Contains("\"out_trade_no\"", json); + Assert.Contains("\"notify_url\"", json); + Assert.Contains("\"amount\"", json); + Assert.Contains("\"payer\"", json); + Assert.Contains("\"total\"", json); + Assert.Contains("\"currency\"", json); + Assert.Contains("\"openid\"", json); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性** + /// V3 请求序列化后应该是有效的 JSON。 + /// **Validates: Requirements 3.2** + /// + [Property(MaxTest = 100)] + public bool V3JsapiRequest_Serialization_ShouldProduceValidJson( + NonEmptyString appId, + NonEmptyString mchId, + NonEmptyString description, + NonEmptyString outTradeNo, + NonEmptyString notifyUrl, + PositiveInt totalAmount, + NonEmptyString openId) + { + var request = new WechatPayV3JsapiRequest + { + AppId = appId.Get, + MchId = mchId.Get, + Description = description.Get, + OutTradeNo = outTradeNo.Get, + NotifyUrl = notifyUrl.Get, + Amount = new WechatPayV3Amount { Total = totalAmount.Get, Currency = "CNY" }, + Payer = new WechatPayV3Payer { OpenId = openId.Get } + }; + + try + { + var json = JsonSerializer.Serialize(request, JsonOptions); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.ValueKind == JsonValueKind.Object; + } + catch + { + return false; + } + } + + #endregion +} diff --git a/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3SignaturePropertyTests.cs b/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3SignaturePropertyTests.cs new file mode 100644 index 00000000..af295de7 --- /dev/null +++ b/server/HoneyBox/tests/HoneyBox.Tests/Services/WechatPayV3SignaturePropertyTests.cs @@ -0,0 +1,352 @@ +using System.Security.Cryptography; +using System.Text; +using FsCheck; +using FsCheck.Xunit; +using HoneyBox.Core.Interfaces; +using HoneyBox.Core.Services; +using HoneyBox.Model.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace HoneyBox.Tests.Services; + +/// +/// 微信支付 V3 签名属性测试 +/// **Feature: wechat-pay-v3-upgrade** +/// +public class WechatPayV3SignaturePropertyTests +{ + // 测试用 RSA 密钥对(仅用于测试) + private static readonly string TestPrivateKey; + private static readonly string TestPublicKey; + + static WechatPayV3SignaturePropertyTests() + { + // 生成测试用 RSA 密钥对 + using var rsa = RSA.Create(2048); + TestPrivateKey = ExportPrivateKeyPem(rsa); + TestPublicKey = ExportPublicKeyPem(rsa); + } + + private static string ExportPrivateKeyPem(RSA rsa) + { + var privateKeyBytes = rsa.ExportRSAPrivateKey(); + var base64 = Convert.ToBase64String(privateKeyBytes); + var sb = new StringBuilder(); + sb.AppendLine("-----BEGIN RSA PRIVATE KEY-----"); + for (int i = 0; i < base64.Length; i += 64) + { + sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i))); + } + sb.AppendLine("-----END RSA PRIVATE KEY-----"); + return sb.ToString(); + } + + private static string ExportPublicKeyPem(RSA rsa) + { + var publicKeyBytes = rsa.ExportRSAPublicKey(); + var base64 = Convert.ToBase64String(publicKeyBytes); + var sb = new StringBuilder(); + sb.AppendLine("-----BEGIN RSA PUBLIC KEY-----"); + for (int i = 0; i < base64.Length; i += 64) + { + sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i))); + } + sb.AppendLine("-----END RSA PUBLIC KEY-----"); + return sb.ToString(); + } + + private IWechatPayV3Service CreateService() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + var dbContext = new HoneyBoxDbContext(options); + var httpClient = new HttpClient(); + var logger = Mock.Of>(); + var configService = Mock.Of(); + + return new WechatPayV3Service(dbContext, httpClient, logger, configService); + } + + #region Property 5: V3 请求签名正确性 + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性** + /// *For any* V3 请求数据,使用相同的私钥和参数生成的签名应该是确定性的 + /// (相同输入产生相同输出)。 + /// **Validates: Requirements 3.3** + /// + [Property(MaxTest = 100)] + public bool V3Signature_SameInputs_ShouldProduceSameOutput( + NonEmptyString method, + NonEmptyString url, + NonEmptyString body, + PositiveInt seed) + { + var service = CreateService(); + + // 使用固定的时间戳和随机串以确保确定性 + var timestamp = seed.Get.ToString(); + var nonce = $"nonce{seed.Get}"; + + // 清理输入(移除可能导致问题的字符) + var cleanMethod = method.Get.Replace("\n", "").Replace("\r", "").ToUpper(); + var cleanUrl = "/" + url.Get.Replace("\n", "").Replace("\r", "").TrimStart('/'); + var cleanBody = body.Get.Replace("\n", " ").Replace("\r", ""); + + // 生成两次签名 + var signature1 = service.GenerateSignature(cleanMethod, cleanUrl, timestamp, nonce, cleanBody, TestPrivateKey); + var signature2 = service.GenerateSignature(cleanMethod, cleanUrl, timestamp, nonce, cleanBody, TestPrivateKey); + + // 相同输入应该产生相同输出 + return signature1 == signature2; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性** + /// *For any* V3 请求数据,生成的签名应该是有效的 Base64 字符串。 + /// **Validates: Requirements 3.3** + /// + [Property(MaxTest = 100)] + public bool V3Signature_ShouldBeValidBase64( + NonEmptyString method, + NonEmptyString url, + NonEmptyString body, + PositiveInt seed) + { + var service = CreateService(); + + var timestamp = seed.Get.ToString(); + var nonce = $"nonce{seed.Get}"; + + var cleanMethod = method.Get.Replace("\n", "").Replace("\r", "").ToUpper(); + var cleanUrl = "/" + url.Get.Replace("\n", "").Replace("\r", "").TrimStart('/'); + var cleanBody = body.Get.Replace("\n", " ").Replace("\r", ""); + + var signature = service.GenerateSignature(cleanMethod, cleanUrl, timestamp, nonce, cleanBody, TestPrivateKey); + + // 验证是有效的 Base64 字符串 + try + { + var bytes = Convert.FromBase64String(signature); + return bytes.Length > 0; + } + catch + { + return false; + } + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性** + /// *For any* V3 请求数据,不同的输入应该产生不同的签名。 + /// **Validates: Requirements 3.3** + /// + [Property(MaxTest = 100)] + public bool V3Signature_DifferentInputs_ShouldProduceDifferentOutput( + NonEmptyString body1, + NonEmptyString body2, + PositiveInt seed) + { + // 如果两个 body 相同,跳过测试 + if (body1.Get == body2.Get) return true; + + var service = CreateService(); + + var timestamp = seed.Get.ToString(); + var nonce = $"nonce{seed.Get}"; + var method = "POST"; + var url = "/v3/pay/transactions/jsapi"; + + var cleanBody1 = body1.Get.Replace("\n", " ").Replace("\r", ""); + var cleanBody2 = body2.Get.Replace("\n", " ").Replace("\r", ""); + + var signature1 = service.GenerateSignature(method, url, timestamp, nonce, cleanBody1, TestPrivateKey); + var signature2 = service.GenerateSignature(method, url, timestamp, nonce, cleanBody2, TestPrivateKey); + + // 不同输入应该产生不同输出 + return signature1 != signature2; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性** + /// *For any* V3 请求数据,签名应该可以使用对应的公钥验证。 + /// **Validates: Requirements 3.3** + /// + [Property(MaxTest = 100)] + public bool V3Signature_ShouldBeVerifiableWithPublicKey( + NonEmptyString method, + NonEmptyString url, + NonEmptyString body, + PositiveInt seed) + { + var service = CreateService(); + + var timestamp = seed.Get.ToString(); + var nonce = $"nonce{seed.Get}"; + + var cleanMethod = method.Get.Replace("\n", "").Replace("\r", "").ToUpper(); + var cleanUrl = "/" + url.Get.Replace("\n", "").Replace("\r", "").TrimStart('/'); + var cleanBody = body.Get.Replace("\n", " ").Replace("\r", ""); + + // 生成签名 + var signature = service.GenerateSignature(cleanMethod, cleanUrl, timestamp, nonce, cleanBody, TestPrivateKey); + + // 构建签名字符串(与 GenerateSignature 方法中的格式一致) + var signatureString = $"{cleanMethod}\n{cleanUrl}\n{timestamp}\n{nonce}\n{cleanBody}\n"; + + // 使用公钥验证签名 + try + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(TestPublicKey); + + var signatureBytes = Convert.FromBase64String(signature); + return rsa.VerifyData( + Encoding.UTF8.GetBytes(signatureString), + signatureBytes, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + } + catch + { + return false; + } + } + + #endregion + + #region 辅助方法测试 + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性** + /// GenerateNonceStr 应该生成指定长度的随机字符串。 + /// **Validates: Requirements 3.3** + /// + [Property(MaxTest = 100)] + public bool GenerateNonceStr_ShouldHaveCorrectLength(PositiveInt length) + { + var service = CreateService(); + var actualLength = Math.Min(Math.Max(length.Get % 64, 1), 64); // 限制在 1-64 之间 + + var nonceStr = service.GenerateNonceStr(actualLength); + + return nonceStr.Length == actualLength; + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性** + /// GenerateNonceStr 应该只包含字母和数字。 + /// **Validates: Requirements 3.3** + /// + [Property(MaxTest = 100)] + public bool GenerateNonceStr_ShouldContainOnlyAlphanumeric(PositiveInt seed) + { + var service = CreateService(); + var nonceStr = service.GenerateNonceStr(32); + + return nonceStr.All(c => char.IsLetterOrDigit(c)); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性** + /// GetTimestamp 应该返回有效的 Unix 时间戳。 + /// **Validates: Requirements 3.3** + /// + [Fact] + public void GetTimestamp_ShouldReturnValidUnixTimestamp() + { + var service = CreateService(); + var timestamp = service.GetTimestamp(); + + // 应该是数字字符串 + Assert.True(long.TryParse(timestamp, out var timestampValue)); + + // 应该是合理的时间戳(2020年之后,2100年之前) + var minTimestamp = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeSeconds(); + var maxTimestamp = new DateTimeOffset(2100, 1, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeSeconds(); + + Assert.True(timestampValue >= minTimestamp && timestampValue <= maxTimestamp); + } + + #endregion + + #region 签名格式验证 + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性** + /// 签名字符串格式应该符合微信 V3 规范:HTTP方法\nURL\n时间戳\n随机串\n请求体\n + /// **Validates: Requirements 3.3** + /// + [Fact] + public void V3Signature_Format_ShouldMatchWechatSpec() + { + var service = CreateService(); + + var method = "POST"; + var url = "/v3/pay/transactions/jsapi"; + var timestamp = "1609459200"; + var nonce = "5K8264ILTKCH16CQ2502SI8ZNMTM67VS"; + var body = "{\"appid\":\"wx1234567890\",\"mchid\":\"1234567890\"}"; + + // 生成签名 + var signature = service.GenerateSignature(method, url, timestamp, nonce, body, TestPrivateKey); + + // 验证签名不为空 + Assert.False(string.IsNullOrEmpty(signature)); + + // 验证是有效的 Base64 + var signatureBytes = Convert.FromBase64String(signature); + Assert.True(signatureBytes.Length > 0); + + // 验证签名长度(RSA-2048 签名应该是 256 字节) + Assert.Equal(256, signatureBytes.Length); + } + + /// + /// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性** + /// 小程序支付签名格式应该符合微信规范:appId\n时间戳\n随机串\nprepay_id=xxx\n + /// **Validates: Requirements 3.4** + /// + [Fact] + public void GeneratePaySign_Format_ShouldMatchWechatSpec() + { + var service = CreateService(); + + var appId = "wx1234567890"; + var timestamp = "1609459200"; + var nonceStr = "5K8264ILTKCH16CQ2502SI8ZNMTM67VS"; + var prepayId = "wx201410272009395522657a690389285100"; + + // 生成支付签名 + var paySign = service.GeneratePaySign(appId, timestamp, nonceStr, prepayId, TestPrivateKey); + + // 验证签名不为空 + Assert.False(string.IsNullOrEmpty(paySign)); + + // 验证是有效的 Base64 + var signatureBytes = Convert.FromBase64String(paySign); + Assert.True(signatureBytes.Length > 0); + + // 验证签名长度(RSA-2048 签名应该是 256 字节) + Assert.Equal(256, signatureBytes.Length); + + // 验证签名可以用公钥验证 + var signatureString = $"{appId}\n{timestamp}\n{nonceStr}\nprepay_id={prepayId}\n"; + using var rsa = RSA.Create(); + rsa.ImportFromPem(TestPublicKey); + + var isValid = rsa.VerifyData( + Encoding.UTF8.GetBytes(signatureString), + signatureBytes, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + Assert.True(isValid); + } + + #endregion +}