321
This commit is contained in:
parent
ad3bd91ec3
commit
01213b21e1
|
|
@ -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_
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ public class WeixinPayMerchant
|
|||
public string OrderPrefix { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// API密钥
|
||||
/// API密钥(V2版本使用)
|
||||
/// </summary>
|
||||
[JsonPropertyName("api_key")]
|
||||
public string? ApiKey { get; set; }
|
||||
|
|
@ -81,6 +81,44 @@ public class WeixinPayMerchant
|
|||
/// </summary>
|
||||
[JsonPropertyName("is_enabled")]
|
||||
public string? IsEnabled { get; set; }
|
||||
|
||||
// ===== V3 新增字段 =====
|
||||
|
||||
/// <summary>
|
||||
/// 支付版本: "V2" 或 "V3",默认 "V2"
|
||||
/// </summary>
|
||||
[JsonPropertyName("pay_version")]
|
||||
public string PayVersion { get; set; } = "V2";
|
||||
|
||||
/// <summary>
|
||||
/// APIv3 密钥(32位字符串,V3版本使用)
|
||||
/// </summary>
|
||||
[JsonPropertyName("api_v3_key")]
|
||||
public string? ApiV3Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户API证书序列号(V3版本使用)
|
||||
/// </summary>
|
||||
[JsonPropertyName("cert_serial_no")]
|
||||
public string? CertSerialNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户私钥文件路径(V3版本使用)
|
||||
/// </summary>
|
||||
[JsonPropertyName("private_key_path")]
|
||||
public string? PrivateKeyPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付公钥ID(V3版本使用)
|
||||
/// </summary>
|
||||
[JsonPropertyName("wechat_public_key_id")]
|
||||
public string? WechatPublicKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付公钥文件路径(V3版本使用)
|
||||
/// </summary>
|
||||
[JsonPropertyName("wechat_public_key_path")]
|
||||
public string? WechatPublicKeyPath { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,27 @@ namespace HoneyBox.Core.Interfaces;
|
|||
public interface IPaymentNotifyService
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理微信支付回调
|
||||
/// 处理微信支付回调(自动识别 V2/V3 格式)
|
||||
/// </summary>
|
||||
/// <param name="notifyBody">回调请求体</param>
|
||||
/// <param name="headers">回调请求头(V3 需要用于签名验证)</param>
|
||||
/// <returns>回调处理结果</returns>
|
||||
Task<NotifyResult> HandleWechatNotifyAsync(string notifyBody, WechatPayNotifyHeaders? headers = null);
|
||||
|
||||
/// <summary>
|
||||
/// 处理微信支付 V2 回调(XML 格式)
|
||||
/// </summary>
|
||||
/// <param name="xmlData">微信回调XML数据</param>
|
||||
/// <returns>回调处理结果</returns>
|
||||
Task<NotifyResult> HandleWechatNotifyAsync(string xmlData);
|
||||
Task<NotifyResult> HandleWechatV2NotifyAsync(string xmlData);
|
||||
|
||||
/// <summary>
|
||||
/// 处理微信支付 V3 回调(JSON 格式)
|
||||
/// </summary>
|
||||
/// <param name="jsonData">微信回调JSON数据</param>
|
||||
/// <param name="headers">回调请求头</param>
|
||||
/// <returns>回调处理结果</returns>
|
||||
Task<NotifyResult> HandleWechatV3NotifyAsync(string jsonData, WechatPayNotifyHeaders headers);
|
||||
|
||||
/// <summary>
|
||||
/// 处理一番赏订单支付成功
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
using HoneyBox.Model.Models.Payment;
|
||||
|
||||
namespace HoneyBox.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付 V3 服务接口
|
||||
/// 提供基于 RSA-SHA256 签名和 AES-256-GCM 加密的 V3 版本支付功能
|
||||
/// </summary>
|
||||
public interface IWechatPayV3Service
|
||||
{
|
||||
#region 下单接口
|
||||
|
||||
/// <summary>
|
||||
/// 创建 JSAPI 下单(小程序/公众号支付)
|
||||
/// </summary>
|
||||
/// <param name="request">支付请求</param>
|
||||
/// <returns>支付结果,包含调起支付所需参数</returns>
|
||||
Task<WechatPayResult> CreateJsapiOrderAsync(WechatPayRequest request);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 订单管理接口
|
||||
|
||||
/// <summary>
|
||||
/// 查询订单状态
|
||||
/// </summary>
|
||||
/// <param name="orderNo">商户订单号</param>
|
||||
/// <returns>订单查询结果</returns>
|
||||
Task<WechatPayV3QueryResult> QueryOrderAsync(string orderNo);
|
||||
|
||||
/// <summary>
|
||||
/// 关闭订单
|
||||
/// </summary>
|
||||
/// <param name="orderNo">商户订单号</param>
|
||||
/// <returns>关闭结果</returns>
|
||||
Task<WechatPayV3CloseResult> CloseOrderAsync(string orderNo);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 退款接口
|
||||
|
||||
/// <summary>
|
||||
/// 申请退款
|
||||
/// </summary>
|
||||
/// <param name="request">退款请求</param>
|
||||
/// <returns>退款结果</returns>
|
||||
Task<WechatPayV3RefundResult> RefundAsync(WechatPayV3RefundRequest request);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 签名与验签
|
||||
|
||||
/// <summary>
|
||||
/// 生成 V3 请求签名
|
||||
/// </summary>
|
||||
/// <param name="method">HTTP 方法(GET、POST 等)</param>
|
||||
/// <param name="url">请求 URL(不含域名,如 /v3/pay/transactions/jsapi)</param>
|
||||
/// <param name="timestamp">时间戳(秒)</param>
|
||||
/// <param name="nonce">随机字符串</param>
|
||||
/// <param name="body">请求体(GET 请求为空字符串)</param>
|
||||
/// <param name="privateKey">商户私钥(PEM 格式内容)</param>
|
||||
/// <returns>Base64 编码的签名字符串</returns>
|
||||
string GenerateSignature(string method, string url, string timestamp, string nonce, string body, string privateKey);
|
||||
|
||||
/// <summary>
|
||||
/// 验证回调签名
|
||||
/// </summary>
|
||||
/// <param name="timestamp">微信回调头中的时间戳(Wechatpay-Timestamp)</param>
|
||||
/// <param name="nonce">微信回调头中的随机串(Wechatpay-Nonce)</param>
|
||||
/// <param name="body">回调请求体</param>
|
||||
/// <param name="signature">微信回调头中的签名(Wechatpay-Signature)</param>
|
||||
/// <param name="serialNo">微信回调头中的证书序列号(Wechatpay-Serial)</param>
|
||||
/// <returns>签名是否有效</returns>
|
||||
bool VerifyNotifySignature(string timestamp, string nonce, string body, string signature, string serialNo);
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定的公钥验证回调签名
|
||||
/// </summary>
|
||||
/// <param name="timestamp">时间戳</param>
|
||||
/// <param name="nonce">随机串</param>
|
||||
/// <param name="body">请求体</param>
|
||||
/// <param name="signature">签名</param>
|
||||
/// <param name="publicKey">公钥 PEM 内容</param>
|
||||
/// <returns>签名是否有效</returns>
|
||||
bool VerifyNotifySignatureWithPublicKey(string timestamp, string nonce, string body, string signature, string publicKey);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 加解密
|
||||
|
||||
/// <summary>
|
||||
/// 解密回调数据
|
||||
/// </summary>
|
||||
/// <param name="ciphertext">密文(Base64 编码)</param>
|
||||
/// <param name="nonce">随机串</param>
|
||||
/// <param name="associatedData">附加数据</param>
|
||||
/// <param name="apiV3Key">APIv3 密钥</param>
|
||||
/// <returns>解密后的明文 JSON 字符串</returns>
|
||||
string DecryptNotifyResource(string ciphertext, string nonce, string associatedData, string apiV3Key);
|
||||
|
||||
/// <summary>
|
||||
/// 使用 AES-256-GCM 加密数据(用于测试)
|
||||
/// </summary>
|
||||
/// <param name="plaintext">明文</param>
|
||||
/// <param name="nonce">随机串(12 字节)</param>
|
||||
/// <param name="associatedData">附加数据</param>
|
||||
/// <param name="apiV3Key">APIv3 密钥(32 字节)</param>
|
||||
/// <returns>Base64 编码的密文(包含认证标签)</returns>
|
||||
string EncryptNotifyResource(string plaintext, string nonce, string associatedData, string apiV3Key);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 回调格式识别
|
||||
|
||||
/// <summary>
|
||||
/// 检测回调数据是否为 V3 格式
|
||||
/// V3 格式特征:JSON 格式且包含 resource 字段
|
||||
/// </summary>
|
||||
/// <param name="notifyBody">回调请求体</param>
|
||||
/// <returns>是否为 V3 格式</returns>
|
||||
bool IsV3NotifyFormat(string notifyBody);
|
||||
|
||||
/// <summary>
|
||||
/// 检测回调数据是否为 V2 格式
|
||||
/// V2 格式特征:XML 格式,以 <xml> 开头
|
||||
/// </summary>
|
||||
/// <param name="notifyBody">回调请求体</param>
|
||||
/// <returns>是否为 V2 格式</returns>
|
||||
bool IsV2NotifyFormat(string notifyBody);
|
||||
|
||||
/// <summary>
|
||||
/// 检测回调格式并返回版本
|
||||
/// </summary>
|
||||
/// <param name="notifyBody">回调请求体</param>
|
||||
/// <returns>回调版本:V3、V2 或 Unknown</returns>
|
||||
NotifyVersion DetectNotifyVersion(string notifyBody);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 辅助方法
|
||||
|
||||
/// <summary>
|
||||
/// 生成小程序调起支付所需的签名
|
||||
/// </summary>
|
||||
/// <param name="appId">小程序 AppId</param>
|
||||
/// <param name="timestamp">时间戳</param>
|
||||
/// <param name="nonceStr">随机字符串</param>
|
||||
/// <param name="prepayId">预支付交易会话标识</param>
|
||||
/// <param name="privateKey">商户私钥</param>
|
||||
/// <returns>支付签名</returns>
|
||||
string GeneratePaySign(string appId, string timestamp, string nonceStr, string prepayId, string privateKey);
|
||||
|
||||
/// <summary>
|
||||
/// 生成随机字符串
|
||||
/// </summary>
|
||||
/// <param name="length">长度(默认 32)</param>
|
||||
/// <returns>随机字符串</returns>
|
||||
string GenerateNonceStr(int length = 32);
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前时间戳(秒)
|
||||
/// </summary>
|
||||
/// <returns>Unix 时间戳字符串</returns>
|
||||
string GetTimestamp();
|
||||
|
||||
/// <summary>
|
||||
/// 读取私钥文件内容
|
||||
/// </summary>
|
||||
/// <param name="privateKeyPath">私钥文件路径</param>
|
||||
/// <returns>私钥 PEM 内容</returns>
|
||||
string ReadPrivateKey(string privateKeyPath);
|
||||
|
||||
/// <summary>
|
||||
/// 读取公钥文件内容
|
||||
/// </summary>
|
||||
/// <param name="publicKeyPath">公钥文件路径</param>
|
||||
/// <returns>公钥 PEM 内容</returns>
|
||||
string ReadPublicKey(string publicKeyPath);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -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<PaymentNotifyService> _logger;
|
||||
|
|
@ -43,19 +46,50 @@ public class PaymentNotifyService : IPaymentNotifyService
|
|||
public PaymentNotifyService(
|
||||
HoneyBoxDbContext dbContext,
|
||||
IWechatPayService wechatPayService,
|
||||
IWechatPayV3Service wechatPayV3Service,
|
||||
IWechatPayConfigService wechatPayConfigService,
|
||||
IPaymentService paymentService,
|
||||
ILotteryEngine lotteryEngine,
|
||||
ILogger<PaymentNotifyService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_wechatPayService = wechatPayService;
|
||||
_wechatPayV3Service = wechatPayV3Service;
|
||||
_wechatPayConfigService = wechatPayConfigService;
|
||||
_paymentService = paymentService;
|
||||
_lotteryEngine = lotteryEngine;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotifyResult> HandleWechatNotifyAsync(string xmlData)
|
||||
public async Task<NotifyResult> 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", "无法识别的回调格式")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotifyResult> 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
|
|||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotifyResult> 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<WechatPayV3Notification>(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<WechatPayV3PaymentResult>(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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 V3 支付结果转换为 V2 格式(复用现有处理逻辑)
|
||||
/// </summary>
|
||||
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]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据订单类型路由到对应的处理方法
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
922
server/HoneyBox/src/HoneyBox.Core/Services/WechatPayV3Service.cs
Normal file
922
server/HoneyBox/src/HoneyBox.Core/Services/WechatPayV3Service.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付 V3 服务实现
|
||||
/// 提供基于 RSA-SHA256 签名和 AES-256-GCM 加密的 V3 版本支付功能
|
||||
/// </summary>
|
||||
public class WechatPayV3Service : IWechatPayV3Service
|
||||
{
|
||||
private readonly HoneyBoxDbContext _dbContext;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<WechatPayV3Service> _logger;
|
||||
private readonly IWechatPayConfigService _configService;
|
||||
|
||||
/// <summary>
|
||||
/// V3 JSAPI 下单 API 地址
|
||||
/// </summary>
|
||||
private const string V3_JSAPI_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi";
|
||||
|
||||
/// <summary>
|
||||
/// V3 订单查询 API 地址(商户订单号)
|
||||
/// </summary>
|
||||
private const string V3_QUERY_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{0}";
|
||||
|
||||
/// <summary>
|
||||
/// V3 关闭订单 API 地址
|
||||
/// </summary>
|
||||
private const string V3_CLOSE_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{0}/close";
|
||||
|
||||
/// <summary>
|
||||
/// V3 退款 API 地址
|
||||
/// </summary>
|
||||
private const string V3_REFUND_URL = "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds";
|
||||
|
||||
/// <summary>
|
||||
/// 随机字符串字符集
|
||||
/// </summary>
|
||||
private const string NONCE_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
public WechatPayV3Service(
|
||||
HoneyBoxDbContext dbContext,
|
||||
HttpClient httpClient,
|
||||
ILogger<WechatPayV3Service> logger,
|
||||
IWechatPayConfigService configService)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
#region 下单接口
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WechatPayResult> 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<WechatPayV3ErrorResponse>(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<WechatPayV3JsapiResponse>(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 订单管理接口
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WechatPayV3QueryResult> 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<WechatPayV3ErrorResponse>(responseContent);
|
||||
return new WechatPayV3QueryResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorCode = errorResponse?.Code,
|
||||
ErrorMessage = errorResponse?.Message
|
||||
};
|
||||
}
|
||||
|
||||
var queryResponse = JsonSerializer.Deserialize<WechatPayV3QueryResponse>(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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WechatPayV3CloseResult> 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<WechatPayV3ErrorResponse>(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 退款接口
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WechatPayV3RefundResult> 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<WechatPayV3ErrorResponse>(responseContent);
|
||||
return new WechatPayV3RefundResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorCode = errorResponse?.Code,
|
||||
ErrorMessage = errorResponse?.Message
|
||||
};
|
||||
}
|
||||
|
||||
var refundResponse = JsonSerializer.Deserialize<WechatPayV3RefundApiResponse>(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 签名与验签
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定的公钥验证回调签名
|
||||
/// </summary>
|
||||
/// <param name="timestamp">时间戳</param>
|
||||
/// <param name="nonce">随机串</param>
|
||||
/// <param name="body">请求体</param>
|
||||
/// <param name="signature">签名</param>
|
||||
/// <param name="publicKey">公钥 PEM 内容</param>
|
||||
/// <returns>签名是否有效</returns>
|
||||
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 加解密
|
||||
|
||||
/// <inheritdoc />
|
||||
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<byte>()
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 AES-256-GCM 加密数据(用于测试)
|
||||
/// </summary>
|
||||
/// <param name="plaintext">明文</param>
|
||||
/// <param name="nonce">随机串(12 字节)</param>
|
||||
/// <param name="associatedData">附加数据</param>
|
||||
/// <param name="apiV3Key">APIv3 密钥(32 字节)</param>
|
||||
/// <returns>Base64 编码的密文(包含认证标签)</returns>
|
||||
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<byte>()
|
||||
: 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 回调格式识别
|
||||
|
||||
/// <summary>
|
||||
/// 检测回调数据是否为 V3 格式
|
||||
/// V3 格式特征:JSON 格式且包含 resource 字段
|
||||
/// </summary>
|
||||
/// <param name="notifyBody">回调请求体</param>
|
||||
/// <returns>是否为 V3 格式</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检测回调数据是否为 V2 格式
|
||||
/// V2 格式特征:XML 格式,以 <xml> 开头
|
||||
/// </summary>
|
||||
/// <param name="notifyBody">回调请求体</param>
|
||||
/// <returns>是否为 V2 格式</returns>
|
||||
public bool IsV2NotifyFormat(string notifyBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(notifyBody))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmedBody = notifyBody.TrimStart();
|
||||
|
||||
// V2 格式是 XML,以 < 开头
|
||||
if (!trimmedBody.StartsWith('<'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否包含 <xml> 标签(不区分大小写)
|
||||
return trimmedBody.Contains("<xml>", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmedBody.Contains("<xml ", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检测回调格式并返回版本
|
||||
/// </summary>
|
||||
/// <param name="notifyBody">回调请求体</param>
|
||||
/// <returns>回调版本:V3、V2 或 Unknown</returns>
|
||||
public NotifyVersion DetectNotifyVersion(string notifyBody)
|
||||
{
|
||||
if (IsV3NotifyFormat(notifyBody))
|
||||
{
|
||||
return NotifyVersion.V3;
|
||||
}
|
||||
|
||||
if (IsV2NotifyFormat(notifyBody))
|
||||
{
|
||||
return NotifyVersion.V2;
|
||||
}
|
||||
|
||||
return NotifyVersion.Unknown;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 辅助方法
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetTimestamp()
|
||||
{
|
||||
return DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 截断商品描述(V3 限制最大 127 字符)
|
||||
/// </summary>
|
||||
private static string TruncateDescription(string description, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(description))
|
||||
{
|
||||
return "商品购买";
|
||||
}
|
||||
|
||||
return description.Length <= maxLength ? description : description[..maxLength];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存订单通知记录
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取 V3 错误消息
|
||||
/// </summary>
|
||||
private static string GetV3ErrorMessage(string code, string message)
|
||||
{
|
||||
var errorMessages = new Dictionary<string, string>
|
||||
{
|
||||
{ "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
|
||||
}
|
||||
|
|
@ -264,10 +264,12 @@ public class ServiceModule : Module
|
|||
{
|
||||
var dbContext = c.Resolve<HoneyBoxDbContext>();
|
||||
var wechatPayService = c.Resolve<IWechatPayService>();
|
||||
var wechatPayV3Service = c.Resolve<IWechatPayV3Service>();
|
||||
var wechatPayConfigService = c.Resolve<IWechatPayConfigService>();
|
||||
var paymentService = c.Resolve<IPaymentService>();
|
||||
var lotteryEngine = c.Resolve<ILotteryEngine>();
|
||||
var logger = c.Resolve<ILogger<PaymentNotifyService>>();
|
||||
return new PaymentNotifyService(dbContext, wechatPayService, paymentService, lotteryEngine, logger);
|
||||
return new PaymentNotifyService(dbContext, wechatPayService, wechatPayV3Service, wechatPayConfigService, paymentService, lotteryEngine, logger);
|
||||
}).As<IPaymentNotifyService>().InstancePerLifetimeScope();
|
||||
|
||||
// 注册充值服务
|
||||
|
|
|
|||
|
|
@ -138,9 +138,14 @@ public class NotifyResult
|
|||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// XML响应内容(返回给微信)
|
||||
/// XML响应内容(返回给微信 V2)
|
||||
/// </summary>
|
||||
public string XmlResponse { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// JSON响应内容(返回给微信 V3)
|
||||
/// </summary>
|
||||
public string JsonResponse { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -415,7 +420,7 @@ public class WechatPayMerchantConfig
|
|||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商户密钥
|
||||
/// 商户密钥(V2版本使用)
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
|
|
@ -433,6 +438,38 @@ public class WechatPayMerchantConfig
|
|||
/// 回调通知URL
|
||||
/// </summary>
|
||||
public string NotifyUrl { get; set; } = string.Empty;
|
||||
|
||||
// ===== V3 新增字段 =====
|
||||
|
||||
/// <summary>
|
||||
/// 支付版本: "V2" 或 "V3",默认 "V2"
|
||||
/// </summary>
|
||||
public string PayVersion { get; set; } = "V2";
|
||||
|
||||
/// <summary>
|
||||
/// APIv3 密钥(32位字符串,V3版本使用)
|
||||
/// </summary>
|
||||
public string? ApiV3Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户API证书序列号(V3版本使用)
|
||||
/// </summary>
|
||||
public string? CertSerialNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户私钥文件路径(V3版本使用)
|
||||
/// </summary>
|
||||
public string? PrivateKeyPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付公钥ID(V3版本使用)
|
||||
/// </summary>
|
||||
public string? WechatPublicKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付公钥文件路径(V3版本使用)
|
||||
/// </summary>
|
||||
public string? WechatPublicKeyPath { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -81,9 +81,17 @@ public class PaymentNotifyServiceIntegrationTests
|
|||
var paymentService = new PaymentService(dbContext, _mockPaymentLogger.Object);
|
||||
|
||||
var mockLotteryEngine = new Mock<ILotteryEngine>();
|
||||
var mockWechatPayV3Service = new Mock<IWechatPayV3Service>();
|
||||
|
||||
// Setup V3 service to detect V2 format for existing tests
|
||||
mockWechatPayV3Service.Setup(x => x.DetectNotifyVersion(It.IsAny<string>()))
|
||||
.Returns(NotifyVersion.V2);
|
||||
|
||||
var notifyService = new PaymentNotifyService(
|
||||
dbContext,
|
||||
wechatPayService,
|
||||
mockWechatPayV3Service.Object,
|
||||
_mockConfigService.Object,
|
||||
paymentService,
|
||||
mockLotteryEngine.Object,
|
||||
_mockNotifyLogger.Object);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付 V3 配置属性测试
|
||||
/// **Feature: wechat-pay-v3-upgrade**
|
||||
/// </summary>
|
||||
public class WechatPayV3ConfigPropertyTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = null, // 使用原始属性名
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
#region Property 1: 配置序列化 Round-Trip
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 1: 配置序列化 Round-Trip**
|
||||
/// *For any* 有效的 WeixinPayMerchant 配置对象(包含 V2 或 V3 字段),
|
||||
/// 序列化为 JSON 后再反序列化,应该得到与原始对象等价的配置。
|
||||
/// **Validates: Requirements 1.4, 1.5**
|
||||
/// </summary>
|
||||
[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<WeixinPayMerchant>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 1: 配置序列化 Round-Trip**
|
||||
/// *For any* 有效的 WeixinPaySetting 配置对象(包含多个商户),
|
||||
/// 序列化为 JSON 后再反序列化,应该得到与原始对象等价的配置。
|
||||
/// **Validates: Requirements 1.4, 1.5**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool WeixinPaySetting_RoundTrip_ShouldPreserveAllMerchants(PositiveInt seed)
|
||||
{
|
||||
// 创建包含多个商户的配置
|
||||
var merchantCount = (seed.Get % 3) + 1; // 1-3 个商户
|
||||
var merchants = new List<WeixinPayMerchant>();
|
||||
|
||||
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<WeixinPaySetting>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 1: 配置序列化 Round-Trip**
|
||||
/// *For any* V2 配置,PayVersion 默认值应该是 "V2"。
|
||||
/// **Validates: Requirements 1.3**
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WeixinPayMerchant_DefaultPayVersion_ShouldBeV2()
|
||||
{
|
||||
var merchant = new WeixinPayMerchant();
|
||||
Assert.Equal("V2", merchant.PayVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 1: 配置序列化 Round-Trip**
|
||||
/// *For any* V3 配置 JSON,反序列化后应该正确读取所有 V3 字段。
|
||||
/// **Validates: Requirements 1.1, 1.2**
|
||||
/// </summary>
|
||||
[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<WeixinPayMerchant>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 1: 配置序列化 Round-Trip**
|
||||
/// *For any* V2 配置 JSON(不包含 V3 字段),反序列化后 V3 字段应该为 null。
|
||||
/// **Validates: Requirements 1.3**
|
||||
/// </summary>
|
||||
[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<WeixinPayMerchant>(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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付 V3 解密属性测试
|
||||
/// **Feature: wechat-pay-v3-upgrade**
|
||||
/// </summary>
|
||||
public class WechatPayV3DecryptionPropertyTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 生成有效的 32 字节 APIv3 密钥
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成有效的 12 字节 nonce
|
||||
/// </summary>
|
||||
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<HoneyBoxDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var dbContext = new HoneyBoxDbContext(options);
|
||||
var httpClient = new HttpClient();
|
||||
var logger = Mock.Of<ILogger<WechatPayV3Service>>();
|
||||
var configService = Mock.Of<IWechatPayConfigService>();
|
||||
|
||||
return new WechatPayV3Service(dbContext, httpClient, logger, configService);
|
||||
}
|
||||
|
||||
#region Property 9: V3 回调解密 Round-Trip
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip**
|
||||
/// *For any* 有效的支付结果数据,使用 AES-256-GCM 加密后再解密,
|
||||
/// 应该得到与原始数据等价的结果。
|
||||
/// **Validates: Requirements 4.3**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip**
|
||||
/// *For any* JSON 格式的支付结果数据,加密后再解密应该保持 JSON 结构不变。
|
||||
/// **Validates: Requirements 4.3**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip**
|
||||
/// *For any* 有效数据,使用不同的密钥解密应该失败。
|
||||
/// **Validates: Requirements 4.3**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip**
|
||||
/// *For any* 有效数据,使用不同的 nonce 解密应该失败。
|
||||
/// **Validates: Requirements 4.3**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip**
|
||||
/// *For any* 有效数据,篡改密文后解密应该失败。
|
||||
/// **Validates: Requirements 4.3**
|
||||
/// </summary>
|
||||
[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 边界情况测试
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip**
|
||||
/// 空的 associated data 应该正常工作。
|
||||
/// **Validates: Requirements 4.3**
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip**
|
||||
/// 中文内容应该正常加解密。
|
||||
/// **Validates: Requirements 4.3**
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip**
|
||||
/// 无效的 APIv3 密钥长度应该抛出异常。
|
||||
/// **Validates: Requirements 4.3**
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decrypt_InvalidKeyLength_ShouldThrow()
|
||||
{
|
||||
var service = CreateService();
|
||||
var invalidKey = "shortkey"; // 不是 32 字节
|
||||
var nonce = "abcdefghijkl";
|
||||
var ciphertext = "dGVzdA=="; // 随便一个 base64
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
service.DecryptNotifyResource(ciphertext, nonce, "transaction", invalidKey));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 9: V3 回调解密 Round-Trip**
|
||||
/// 空密文应该抛出异常。
|
||||
/// **Validates: Requirements 4.3**
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decrypt_EmptyCiphertext_ShouldThrow()
|
||||
{
|
||||
var service = CreateService();
|
||||
var apiV3Key = "d1cxc0vXCUH2984901DxddPJMYqcwcnd";
|
||||
var nonce = "abcdefghijkl";
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
service.DecryptNotifyResource("", nonce, "transaction", apiV3Key));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付 V3 回调格式识别属性测试
|
||||
/// **Feature: wechat-pay-v3-upgrade**
|
||||
/// </summary>
|
||||
public class WechatPayV3NotifyFormatPropertyTests
|
||||
{
|
||||
private IWechatPayV3Service CreateService()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<HoneyBoxDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var dbContext = new HoneyBoxDbContext(options);
|
||||
var httpClient = new HttpClient();
|
||||
var logger = Mock.Of<ILogger<WechatPayV3Service>>();
|
||||
var configService = Mock.Of<IWechatPayConfigService>();
|
||||
|
||||
return new WechatPayV3Service(dbContext, httpClient, logger, configService);
|
||||
}
|
||||
|
||||
#region Property 8: 回调格式识别正确性
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// *For any* JSON 格式且包含 resource 字段的回调数据,应该被识别为 V3 格式。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// *For any* XML 格式的回调数据,应该被识别为 V2 格式。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[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 = $@"<xml>
|
||||
<return_code><![CDATA[SUCCESS]]></return_code>
|
||||
<result_code><![CDATA[SUCCESS]]></result_code>
|
||||
<out_trade_no><![CDATA[{cleanOrderNo}]]></out_trade_no>
|
||||
<transaction_id><![CDATA[{cleanTransactionId}]]></transaction_id>
|
||||
<total_fee>{totalFee.Get}</total_fee>
|
||||
</xml>";
|
||||
|
||||
var isV3 = service.IsV3NotifyFormat(v2NotifyBody);
|
||||
var isV2 = service.IsV2NotifyFormat(v2NotifyBody);
|
||||
var version = service.DetectNotifyVersion(v2NotifyBody);
|
||||
|
||||
// V2 格式应该被正确识别
|
||||
return !isV3 && isV2 && version == NotifyVersion.V2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// *For any* JSON 格式但不包含 resource 字段的数据,不应该被识别为 V3 格式。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// *For any* 空字符串或空白字符串,应该被识别为 Unknown 格式。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// *For any* 非 JSON 非 XML 的数据,应该被识别为 Unknown 格式。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// V3 和 V2 格式应该是互斥的。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[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 边界情况测试
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// 真实的 V3 支付成功回调应该被正确识别。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// 真实的 V2 支付成功回调应该被正确识别。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RealV2PaymentSuccessNotify_ShouldBeDetectedAsV2()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var v2NotifyBody = @"<xml>
|
||||
<return_code><![CDATA[SUCCESS]]></return_code>
|
||||
<return_msg><![CDATA[OK]]></return_msg>
|
||||
<appid><![CDATA[wx1234567890abcdef]]></appid>
|
||||
<mch_id><![CDATA[1234567890]]></mch_id>
|
||||
<nonce_str><![CDATA[5K8264ILTKCH16CQ2502SI8ZNMTM67VS]]></nonce_str>
|
||||
<sign><![CDATA[C380BEC2BFD727A4B6845133519F3AD6]]></sign>
|
||||
<result_code><![CDATA[SUCCESS]]></result_code>
|
||||
<openid><![CDATA[oUpF8uMuAJO_M2pxb1Q9zNjWeS6o]]></openid>
|
||||
<trade_type><![CDATA[JSAPI]]></trade_type>
|
||||
<bank_type><![CDATA[CMC]]></bank_type>
|
||||
<total_fee>100</total_fee>
|
||||
<fee_type><![CDATA[CNY]]></fee_type>
|
||||
<transaction_id><![CDATA[1217752501201407033233368018]]></transaction_id>
|
||||
<out_trade_no><![CDATA[MYH20240101120000001]]></out_trade_no>
|
||||
<attach><![CDATA[order_yfs]]></attach>
|
||||
<time_end><![CDATA[20240101120000]]></time_end>
|
||||
</xml>";
|
||||
|
||||
Assert.False(service.IsV3NotifyFormat(v2NotifyBody));
|
||||
Assert.True(service.IsV2NotifyFormat(v2NotifyBody));
|
||||
Assert.Equal(NotifyVersion.V2, service.DetectNotifyVersion(v2NotifyBody));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// 带有前导空白的 V3 回调应该被正确识别。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// 带有前导空白的 V2 回调应该被正确识别。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2NotifyWithLeadingWhitespace_ShouldBeDetectedAsV2()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var v2NotifyBody = @"
|
||||
<xml>
|
||||
<return_code>SUCCESS</return_code>
|
||||
</xml>";
|
||||
|
||||
Assert.True(service.IsV2NotifyFormat(v2NotifyBody));
|
||||
Assert.Equal(NotifyVersion.V2, service.DetectNotifyVersion(v2NotifyBody));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 8: 回调格式识别正确性**
|
||||
/// 无效的 JSON 不应该被识别为 V3。
|
||||
/// **Validates: Requirements 4.1, 4.5**
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InvalidJson_ShouldNotBeV3()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var invalidJson = @"{ ""resource"": ""missing closing brace""";
|
||||
|
||||
Assert.False(service.IsV3NotifyFormat(invalidJson));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 辅助方法
|
||||
|
||||
/// <summary>
|
||||
/// 移除控制字符
|
||||
/// </summary>
|
||||
private static string RemoveControlChars(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
return new string(input.Where(c => !char.IsControl(c)).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理字符串以用于 JSON
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理字符串以用于 XML
|
||||
/// </summary>
|
||||
private static string CleanXmlString(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
return input
|
||||
.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'")
|
||||
.Replace("\n", " ")
|
||||
.Replace("\r", " ");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
using System.Text.Json;
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using HoneyBox.Model.Models.Payment;
|
||||
using Xunit;
|
||||
|
||||
namespace HoneyBox.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付 V3 请求字段完整性属性测试
|
||||
/// **Feature: wechat-pay-v3-upgrade**
|
||||
/// </summary>
|
||||
public class WechatPayV3RequestPropertyTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
#region Property 6: V3 请求字段完整性
|
||||
|
||||
/// <summary>
|
||||
/// **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**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性**
|
||||
/// *For any* V3 JSAPI 请求,金额字段应该是正整数(单位:分)。
|
||||
/// **Validates: Requirements 3.2**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性**
|
||||
/// *For any* V3 JSAPI 请求,货币类型默认应该是 CNY。
|
||||
/// **Validates: Requirements 3.2**
|
||||
/// </summary>
|
||||
[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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性**
|
||||
/// *For any* V3 JSAPI 请求,可选字段 attach 为 null 时不应该出现在 JSON 中。
|
||||
/// **Validates: Requirements 3.2**
|
||||
/// </summary>
|
||||
[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 _));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性**
|
||||
/// *For any* V3 JSAPI 请求,可选字段 attach 有值时应该出现在 JSON 中。
|
||||
/// **Validates: Requirements 3.2**
|
||||
/// </summary>
|
||||
[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 支付参数完整性
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 7: V3 支付参数完整性**
|
||||
/// *For any* 成功的 V3 下单响应,返回给前端的支付参数应该包含:
|
||||
/// timeStamp、nonceStr、package、signType(RSA)、paySign。
|
||||
/// **Validates: Requirements 3.4**
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 7: V3 支付参数完整性**
|
||||
/// V3 支付参数的 signType 应该是 RSA(而不是 V2 的 MD5)。
|
||||
/// **Validates: Requirements 3.4**
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 7: V3 支付参数完整性**
|
||||
/// V3 支付参数的 package 格式应该是 prepay_id=xxx。
|
||||
/// **Validates: Requirements 3.4**
|
||||
/// </summary>
|
||||
[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 请求序列化测试
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性**
|
||||
/// V3 请求序列化后的 JSON 字段名应该使用 snake_case 格式。
|
||||
/// **Validates: Requirements 3.2**
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 6: V3 请求字段完整性**
|
||||
/// V3 请求序列化后应该是有效的 JSON。
|
||||
/// **Validates: Requirements 3.2**
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 微信支付 V3 签名属性测试
|
||||
/// **Feature: wechat-pay-v3-upgrade**
|
||||
/// </summary>
|
||||
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<HoneyBoxDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var dbContext = new HoneyBoxDbContext(options);
|
||||
var httpClient = new HttpClient();
|
||||
var logger = Mock.Of<ILogger<WechatPayV3Service>>();
|
||||
var configService = Mock.Of<IWechatPayConfigService>();
|
||||
|
||||
return new WechatPayV3Service(dbContext, httpClient, logger, configService);
|
||||
}
|
||||
|
||||
#region Property 5: V3 请求签名正确性
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||||
/// *For any* V3 请求数据,使用相同的私钥和参数生成的签名应该是确定性的
|
||||
/// (相同输入产生相同输出)。
|
||||
/// **Validates: Requirements 3.3**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||||
/// *For any* V3 请求数据,生成的签名应该是有效的 Base64 字符串。
|
||||
/// **Validates: Requirements 3.3**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||||
/// *For any* V3 请求数据,不同的输入应该产生不同的签名。
|
||||
/// **Validates: Requirements 3.3**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||||
/// *For any* V3 请求数据,签名应该可以使用对应的公钥验证。
|
||||
/// **Validates: Requirements 3.3**
|
||||
/// </summary>
|
||||
[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 辅助方法测试
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||||
/// GenerateNonceStr 应该生成指定长度的随机字符串。
|
||||
/// **Validates: Requirements 3.3**
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||||
/// GenerateNonceStr 应该只包含字母和数字。
|
||||
/// **Validates: Requirements 3.3**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool GenerateNonceStr_ShouldContainOnlyAlphanumeric(PositiveInt seed)
|
||||
{
|
||||
var service = CreateService();
|
||||
var nonceStr = service.GenerateNonceStr(32);
|
||||
|
||||
return nonceStr.All(c => char.IsLetterOrDigit(c));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||||
/// GetTimestamp 应该返回有效的 Unix 时间戳。
|
||||
/// **Validates: Requirements 3.3**
|
||||
/// </summary>
|
||||
[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 签名格式验证
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||||
/// 签名字符串格式应该符合微信 V3 规范:HTTP方法\nURL\n时间戳\n随机串\n请求体\n
|
||||
/// **Validates: Requirements 3.3**
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||||
/// 小程序支付签名格式应该符合微信规范:appId\n时间戳\n随机串\nprepay_id=xxx\n
|
||||
/// **Validates: Requirements 3.4**
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user