This commit is contained in:
zpc 2026-01-25 20:28:11 +08:00
parent ad3bd91ec3
commit 01213b21e1
16 changed files with 4301 additions and 40 deletions

View File

@ -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_

View File

@ -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>
/// 微信支付公钥IDV3版本使用
/// </summary>
[JsonPropertyName("wechat_public_key_id")]
public string? WechatPublicKeyId { get; set; }
/// <summary>
/// 微信支付公钥文件路径V3版本使用
/// </summary>
[JsonPropertyName("wechat_public_key_path")]
public string? WechatPublicKeyPath { get; set; }
}

View File

@ -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>
/// 处理一番赏订单支付成功

View File

@ -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 格式,以 &lt;xml&gt; 开头
/// </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
}

View File

@ -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>

View File

@ -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
};
}

View 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 格式,以 &lt;xml&gt; 开头
/// </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
}

View File

@ -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();
// 注册充值服务

View File

@ -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>
/// 微信支付公钥IDV3版本使用
/// </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

View File

@ -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);

View File

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

View File

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

View File

@ -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("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&apos;")
.Replace("\n", " ")
.Replace("\r", " ");
}
#endregion
}

View File

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

View File

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