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
+}