This commit is contained in:
zpc 2026-01-25 19:52:40 +08:00
parent 434fe8f833
commit ad3bd91ec3
13 changed files with 1037 additions and 79 deletions

View File

@ -46,7 +46,7 @@
"mysql": {
"command": "node",
"args": [
"D:/CodeManage/HaniBlindBox/server/scripts/mysql-mcp-server/index.js"
"D:/outsource/HaniBlindBox/server/scripts/mysql-mcp-server/index.js"
],
"env": {
"MYSQL_HOST": "192.168.195.16",
@ -62,7 +62,7 @@
"sqlserver": {
"command": "node",
"args": [
"D:/CodeManage/HaniBlindBox/server/scripts/mssql-mcp-server/index.js"
"D:/outsource/HaniBlindBox/server/scripts/mssql-mcp-server/index.js"
],
"env": {
"MSSQL_SERVER": "192.168.195.15",
@ -78,7 +78,7 @@
"admin-sqlserver": {
"command": "node",
"args": [
"D:/CodeManage/HaniBlindBox/server/scripts/mssql-mcp-server/index.js"
"D:/outsource/HaniBlindBox/server/scripts/mssql-mcp-server/index.js"
],
"env": {
"MSSQL_SERVER": "192.168.195.15",

View File

@ -0,0 +1,630 @@
# Design Document: 微信支付 V3 升级
## Overview
本设计文档描述将微信支付从 V2 版本升级到 V3 版本的技术方案。V3 版本使用更安全的 RSA-SHA256 签名算法和 AES-256-GCM 加密,替代 V2 的 MD5 签名和 XML 格式。
### 核心变更
| 特性 | V2 版本 | V3 版本 |
|------|---------|---------|
| 数据格式 | XML | JSON |
| 签名算法 | MD5 | RSA-SHA256 |
| 认证方式 | API密钥 | 商户API证书 + 微信支付公钥 |
| 回调解密 | 无加密 | AES-256-GCM |
## Architecture
### 整体架构
```
┌─────────────────────────────────────────────────────────────────┐
│ 小程序前端 │
│ (honey_box/common/server/pay.js) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ API 层 │
│ (HoneyBox.Api) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PayController │ │
│ │ - CreatePayment() → 根据配置选择 V2/V3 │ │
│ │ - NotifyCallback() → 自动识别 V2/V3 格式 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Core 层 │
│ (HoneyBox.Core) │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ IWechatPayService │ │ IWechatPayV3Service │ (新增) │
│ │ (V2 实现) │ │ (V3 实现) │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │ │ │
│ └────────────┬───────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ IWechatPayConfigService │ │
│ │ - GetMerchantByOrderNo() → 返回包含 PayVersion 的配置 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 微信支付平台 │
│ V2: https://api.mch.weixin.qq.com/pay/unifiedorder │
│ V3: https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi │
└─────────────────────────────────────────────────────────────────┘
```
### 版本路由策略
```
支付请求 → 获取商户配置 → 检查 PayVersion
┌───────────────┴───────────────┐
▼ ▼
PayVersion = "V2" PayVersion = "V3"
│ │
▼ ▼
WechatPayService.CreatePaymentAsync() WechatPayV3Service.CreateJsapiOrderAsync()
│ │
▼ ▼
XML + MD5签名 JSON + RSA签名
```
## Components and Interfaces
### 1. 配置模型扩展
#### WeixinPayMerchant (扩展)
```csharp
// 文件: HoneyBox.Admin.Business/Models/Config/ConfigModels.cs
public class WeixinPayMerchant
{
// 现有字段保持不变
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("mch_id")]
public string MchId { get; set; } = string.Empty;
[JsonPropertyName("order_prefix")]
public string OrderPrefix { get; set; } = string.Empty;
[JsonPropertyName("api_key")]
public string? ApiKey { get; set; }
[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位字符串
/// </summary>
[JsonPropertyName("api_v3_key")]
public string? ApiV3Key { get; set; }
/// <summary>
/// 商户API证书序列号
/// </summary>
[JsonPropertyName("cert_serial_no")]
public string? CertSerialNo { get; set; }
/// <summary>
/// 商户私钥文件路径
/// </summary>
[JsonPropertyName("private_key_path")]
public string? PrivateKeyPath { get; set; }
/// <summary>
/// 微信支付公钥ID
/// </summary>
[JsonPropertyName("wechat_public_key_id")]
public string? WechatPublicKeyId { get; set; }
/// <summary>
/// 微信支付公钥文件路径
/// </summary>
[JsonPropertyName("wechat_public_key_path")]
public string? WechatPublicKeyPath { get; set; }
}
```
#### WechatPayMerchantConfig (扩展)
```csharp
// 文件: HoneyBox.Model/Models/Payment/PaymentModels.cs
public class WechatPayMerchantConfig
{
// 现有字段保持不变
public string Name { get; set; } = string.Empty;
public string MchId { get; set; } = string.Empty;
public string AppId { get; set; } = string.Empty;
public string Key { get; set; } = string.Empty;
public string OrderPrefix { get; set; } = string.Empty;
public int Weight { get; set; } = 1;
public string NotifyUrl { get; set; } = string.Empty;
// ===== V3 新增字段 =====
public string PayVersion { get; set; } = "V2";
public string? ApiV3Key { get; set; }
public string? CertSerialNo { get; set; }
public string? PrivateKeyPath { get; set; }
public string? WechatPublicKeyId { get; set; }
public string? WechatPublicKeyPath { get; set; }
}
```
### 2. V3 支付服务接口
```csharp
// 文件: HoneyBox.Core/Interfaces/IWechatPayV3Service.cs (新建)
public interface IWechatPayV3Service
{
/// <summary>
/// 创建 JSAPI 下单(小程序支付)
/// </summary>
Task<WechatPayResult> CreateJsapiOrderAsync(WechatPayRequest request);
/// <summary>
/// 查询订单状态
/// </summary>
Task<WechatPayV3QueryResult> QueryOrderAsync(string orderNo);
/// <summary>
/// 关闭订单
/// </summary>
Task<WechatPayV3CloseResult> CloseOrderAsync(string orderNo);
/// <summary>
/// 申请退款
/// </summary>
Task<WechatPayV3RefundResult> RefundAsync(WechatPayV3RefundRequest request);
/// <summary>
/// 验证回调签名
/// </summary>
bool VerifyNotifySignature(string timestamp, string nonce, string body, string signature, string serialNo);
/// <summary>
/// 解密回调数据
/// </summary>
string DecryptNotifyResource(string ciphertext, string nonce, string associatedData, string apiV3Key);
/// <summary>
/// 生成 V3 请求签名
/// </summary>
string GenerateSignature(string method, string url, string timestamp, string nonce, string body, string privateKey);
}
```
### 3. V3 请求/响应模型
```csharp
// 文件: HoneyBox.Model/Models/Payment/WechatPayV3Models.cs (新建)
/// <summary>
/// V3 JSAPI 下单请求
/// </summary>
public class WechatPayV3JsapiRequest
{
[JsonPropertyName("appid")]
public string AppId { get; set; } = string.Empty;
[JsonPropertyName("mchid")]
public string MchId { get; set; } = string.Empty;
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
[JsonPropertyName("out_trade_no")]
public string OutTradeNo { get; set; } = string.Empty;
[JsonPropertyName("notify_url")]
public string NotifyUrl { get; set; } = string.Empty;
[JsonPropertyName("amount")]
public WechatPayV3Amount Amount { get; set; } = new();
[JsonPropertyName("payer")]
public WechatPayV3Payer Payer { get; set; } = new();
[JsonPropertyName("attach")]
public string? Attach { get; set; }
}
/// <summary>
/// V3 金额信息
/// </summary>
public class WechatPayV3Amount
{
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("currency")]
public string Currency { get; set; } = "CNY";
}
/// <summary>
/// V3 支付者信息
/// </summary>
public class WechatPayV3Payer
{
[JsonPropertyName("openid")]
public string OpenId { get; set; } = string.Empty;
}
/// <summary>
/// V3 下单响应
/// </summary>
public class WechatPayV3JsapiResponse
{
[JsonPropertyName("prepay_id")]
public string PrepayId { get; set; } = string.Empty;
}
/// <summary>
/// V3 回调通知
/// </summary>
public class WechatPayV3Notification
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("create_time")]
public string CreateTime { get; set; } = string.Empty;
[JsonPropertyName("event_type")]
public string EventType { get; set; } = string.Empty;
[JsonPropertyName("resource_type")]
public string ResourceType { get; set; } = string.Empty;
[JsonPropertyName("resource")]
public WechatPayV3Resource Resource { get; set; } = new();
}
/// <summary>
/// V3 回调资源(加密数据)
/// </summary>
public class WechatPayV3Resource
{
[JsonPropertyName("algorithm")]
public string Algorithm { get; set; } = string.Empty;
[JsonPropertyName("ciphertext")]
public string Ciphertext { get; set; } = string.Empty;
[JsonPropertyName("nonce")]
public string Nonce { get; set; } = string.Empty;
[JsonPropertyName("associated_data")]
public string AssociatedData { get; set; } = string.Empty;
}
/// <summary>
/// V3 解密后的支付结果
/// </summary>
public class WechatPayV3PaymentResult
{
[JsonPropertyName("appid")]
public string AppId { get; set; } = string.Empty;
[JsonPropertyName("mchid")]
public string MchId { get; set; } = string.Empty;
[JsonPropertyName("out_trade_no")]
public string OutTradeNo { get; set; } = string.Empty;
[JsonPropertyName("transaction_id")]
public string TransactionId { get; set; } = string.Empty;
[JsonPropertyName("trade_state")]
public string TradeState { get; set; } = string.Empty;
[JsonPropertyName("trade_state_desc")]
public string TradeStateDesc { get; set; } = string.Empty;
[JsonPropertyName("success_time")]
public string SuccessTime { get; set; } = string.Empty;
[JsonPropertyName("payer")]
public WechatPayV3Payer Payer { get; set; } = new();
[JsonPropertyName("amount")]
public WechatPayV3PaymentAmount Amount { get; set; } = new();
}
/// <summary>
/// V3 支付金额(回调)
/// </summary>
public class WechatPayV3PaymentAmount
{
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("payer_total")]
public int PayerTotal { get; set; }
[JsonPropertyName("currency")]
public string Currency { get; set; } = "CNY";
}
/// <summary>
/// V3 退款请求
/// </summary>
public class WechatPayV3RefundRequest
{
public string OrderNo { get; set; } = string.Empty;
public string RefundNo { get; set; } = string.Empty;
public string? Reason { get; set; }
public int TotalAmount { get; set; }
public int RefundAmount { get; set; }
}
/// <summary>
/// V3 查询结果
/// </summary>
public class WechatPayV3QueryResult
{
public bool Success { get; set; }
public string TradeState { get; set; } = string.Empty;
public string TradeStateDesc { get; set; } = string.Empty;
public string? TransactionId { get; set; }
public string? ErrorCode { get; set; }
public string? ErrorMessage { get; set; }
}
/// <summary>
/// V3 关闭结果
/// </summary>
public class WechatPayV3CloseResult
{
public bool Success { get; set; }
public string? ErrorCode { get; set; }
public string? ErrorMessage { get; set; }
}
/// <summary>
/// V3 退款结果
/// </summary>
public class WechatPayV3RefundResult
{
public bool Success { get; set; }
public string? RefundId { get; set; }
public string? Status { get; set; }
public string? ErrorCode { get; set; }
public string? ErrorMessage { get; set; }
}
```
## Data Models
### 数据库配置存储
配置存储在 `config` 表中key 为 `weixinpay_setting`value 为 JSON 格式:
```json
{
"merchants": [
{
"name": "商户名称",
"mch_id": "1738725801",
"order_prefix": "MYH",
"is_enabled": "1",
"api_key": "V2密钥(兼容)",
"pay_version": "V3",
"api_v3_key": "d1cxc0vXCUH2984901DxddPJMYqcwcnd",
"cert_serial_no": "证书序列号",
"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"
}
]
}
```
### 证书文件存储
证书文件存储在服务器的 `certs` 目录下:
```
server/HoneyBox/certs/
└── 1738725801/ # 商户号目录
├── apiclient_key.pem # 商户私钥
└── pub_key.pem # 微信支付公钥
```
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: 配置序列化 Round-Trip
*For any* 有效的 `WeixinPayMerchant` 配置对象(包含 V2 或 V3 字段),序列化为 JSON 后再反序列化,应该得到与原始对象等价的配置。
**Validates: Requirements 1.4, 1.5**
### Property 2: V3 配置字段完整性
*For any* PayVersion 为 "V3" 的商户配置当配置加载成功时ApiV3Key、CertSerialNo、PrivateKeyPath、WechatPublicKeyId、WechatPublicKeyPath 字段应该都能正确读取(非空或有默认值)。
**Validates: Requirements 1.1, 1.2**
### Property 3: V2 向后兼容性
*For any* PayVersion 为 "V2" 的商户配置ApiKey 字段应该能正确读取,且 V3 字段不影响 V2 功能。
**Validates: Requirements 1.3**
### Property 4: 版本路由正确性
*For any* 支付请求,当商户配置的 PayVersion 为 "V3" 时,应该调用 V3 接口;当 PayVersion 为 "V2" 时,应该调用 V2 接口。
**Validates: Requirements 3.1, 3.5**
### Property 5: V3 请求签名正确性
*For any* V3 请求数据,使用相同的私钥和参数生成的签名应该是确定性的(相同输入产生相同输出),且签名格式符合 RSA-SHA256 规范。
**Validates: Requirements 3.3**
### Property 6: V3 请求字段完整性
*For any* V3 JSAPI 下单请求构建的请求体应该包含所有必要字段appid、mchid、description、out_trade_no、notify_url、amount、payer。
**Validates: Requirements 3.2**
### Property 7: V3 支付参数完整性
*For any* 成功的 V3 下单响应返回给前端的支付参数应该包含timeStamp、nonceStr、package、signType(RSA)、paySign。
**Validates: Requirements 3.4**
### Property 8: 回调格式识别正确性
*For any* 支付回调请求,当请求体为 JSON 格式且包含 resource 字段时,应该使用 V3 解密流程;当请求体为 XML 格式时,应该使用 V2 解密流程。
**Validates: Requirements 4.1, 4.5**
### Property 9: V3 回调解密 Round-Trip
*For any* 有效的支付结果数据,使用 AES-256-GCM 加密后再解密,应该得到与原始数据等价的结果。
**Validates: Requirements 4.3**
### Property 10: V3 回调签名验证
*For any* V3 回调请求,使用正确的微信支付公钥验证签名应该返回 true使用错误的公钥或篡改的数据应该返回 false。
**Validates: Requirements 4.2**
### Property 11: 回调响应格式正确性
*For any* 回调处理结果V3 回调应该返回 JSON 格式响应V2 回调应该返回 XML 格式响应。
**Validates: Requirements 4.6**
### Property 12: 部分退款金额正确性
*For any* 部分退款请求,退款金额应该小于等于订单总金额,且退款请求中的金额字段应该正确设置。
**Validates: Requirements 7.4**
## Error Handling
### 配置错误处理
| 错误场景 | 处理方式 |
|---------|---------|
| V3 配置缺少必要字段 | 记录警告日志,回退到 V2 |
| 私钥文件不存在 | 抛出配置异常,阻止启动 |
| 公钥文件不存在 | 抛出配置异常,阻止启动 |
| APIv3 密钥格式错误 | 记录错误日志,返回配置错误 |
### 支付错误处理
| 错误场景 | 处理方式 |
|---------|---------|
| V3 签名生成失败 | 记录错误日志,返回系统错误 |
| V3 下单请求失败 | 解析错误码,返回友好提示 |
| V3 回调验签失败 | 记录警告日志,返回失败响应 |
| V3 回调解密失败 | 记录错误日志,返回失败响应 |
### 错误码映射
```csharp
private static readonly Dictionary<string, string> V3ErrorMessages = new()
{
{ "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", "系统错误" }
};
```
## Testing Strategy
### 单元测试
1. **配置模型测试**
- 测试 V3 字段序列化/反序列化
- 测试 V2 向后兼容性
- 测试配置验证逻辑
2. **签名算法测试**
- 测试 RSA-SHA256 签名生成
- 测试签名验证
- 测试签名字符串构建
3. **加解密测试**
- 测试 AES-256-GCM 加密
- 测试 AES-256-GCM 解密
- 测试解密失败场景
### 属性测试
使用 FsCheck 进行属性测试:
```csharp
// 配置 round-trip 测试
[Property]
public Property ConfigRoundTrip()
{
return Prop.ForAll(
Arb.From<WeixinPayMerchant>(),
config =>
{
var json = JsonSerializer.Serialize(config);
var deserialized = JsonSerializer.Deserialize<WeixinPayMerchant>(json);
return config.Equals(deserialized);
});
}
// 签名确定性测试
[Property]
public Property SignatureDeterministic()
{
return Prop.ForAll(
Arb.From<string>(), // method
Arb.From<string>(), // url
Arb.From<string>(), // body
(method, url, body) =>
{
var sign1 = service.GenerateSignature(method, url, timestamp, nonce, body, privateKey);
var sign2 = service.GenerateSignature(method, url, timestamp, nonce, body, privateKey);
return sign1 == sign2;
});
}
```
### 集成测试
1. **支付流程测试**
- 测试 V3 下单流程(使用 Mock
- 测试 V3 回调处理
- 测试版本路由逻辑
2. **后台配置测试**
- 测试配置保存和加载
- 测试 V2/V3 切换

View File

@ -0,0 +1,101 @@
# Requirements Document
## Introduction
将现有的微信支付从 V2 版本升级到 V3 版本,以支持更安全的 RSA-SHA256 签名和 AES-GCM 加密。当前系统使用 V2 版本XML格式、MD5签名需要升级到 V3 版本JSON格式、RSA-SHA256签名以满足微信支付的最新安全要求。
## Glossary
- **WechatPay_V2**: 微信支付 V2 版本,使用 XML 数据格式和 MD5 签名算法
- **WechatPay_V3**: 微信支付 V3 版本,使用 JSON 数据格式和 RSA-SHA256 签名算法
- **APIv3_Key**: 微信支付 V3 版本的 API 密钥,用于 AES-GCM 解密回调通知
- **Merchant_Private_Key**: 商户 API 私钥,用于请求签名
- **Wechat_Public_Key**: 微信支付平台公钥,用于验证回调签名
- **JSAPI_Payment**: 小程序/公众号内支付方式
- **Payment_Callback**: 微信支付结果异步通知
- **Config_System**: 后台配置管理系统,存储支付参数到数据库
## Requirements
### Requirement 1: V3 配置模型扩展
**User Story:** As a 开发者, I want 后端配置模型支持 V3 字段, so that 系统能够存储和读取 V3 支付配置。
#### Acceptance Criteria
1. WHEN 系统加载微信支付配置 THEN THE Config_System SHALL 支持读取 PayVersion 字段(值为 "V2" 或 "V3"
2. WHEN PayVersion 为 "V3" THEN THE Config_System SHALL 读取以下字段ApiV3Key、CertSerialNo、PrivateKeyPath、WechatPublicKeyId、WechatPublicKeyPath
3. WHEN PayVersion 为 "V2" THEN THE Config_System SHALL 保持原有字段兼容ApiKey
4. WHEN 配置保存时 THEN THE Config_System SHALL 正确序列化 V3 字段到 JSON 格式
5. WHEN 配置加载时 THEN THE Config_System SHALL 正确反序列化 V3 字段
### Requirement 2: 后台管理页面配置
**User Story:** As a 系统管理员, I want 在后台管理页面配置微信支付 V3 参数, so that 系统能够使用 V3 接口进行支付。
#### Acceptance Criteria
1. WHEN 管理员打开微信支付配置页面 THEN THE Admin_System SHALL 显示「支付版本」选择项V2/V3
2. WHEN 管理员选择 V3 版本 THEN THE Admin_System SHALL 显示 V3 专属配置项APIv3密钥、证书序列号、商户私钥路径、微信支付公钥ID、微信支付公钥路径
3. WHEN 管理员选择 V2 版本 THEN THE Admin_System SHALL 隐藏 V3 配置项并显示 V2 配置项
4. WHEN 管理员保存配置 THEN THE Admin_System SHALL 验证必填字段并保存到数据库
5. WHEN 页面加载时 THEN THE Admin_System SHALL 正确回显已保存的配置值
### Requirement 3: V3 JSAPI 下单接口
**User Story:** As a 小程序用户, I want 使用 V3 接口发起支付, so that 完成商品购买。
#### Acceptance Criteria
1. WHEN 用户发起支付请求且商户配置为 V3 THEN THE WechatPay_V3 SHALL 调用 V3 JSAPI 下单接口
2. WHEN 构建 V3 请求 THEN THE WechatPay_V3 SHALL 使用 JSON 格式并包含appid、mchid、description、out_trade_no、notify_url、amount、payer
3. WHEN 签名 V3 请求 THEN THE WechatPay_V3 SHALL 使用 RSA-SHA256 算法对请求进行签名
4. WHEN 下单成功 THEN THE WechatPay_V3 SHALL 返回小程序调起支付所需参数timeStamp、nonceStr、package、signType(RSA)、paySign
5. WHEN 商户配置为 V2 THEN THE WechatPay_V3 SHALL 回退到 V2 接口处理
### Requirement 4: V3 支付回调处理
**User Story:** As a 系统, I want 正确解密 V3 支付回调通知, so that 更新订单支付状态。
#### Acceptance Criteria
1. WHEN 收到 V3 格式回调JSON格式且包含 resource 字段THEN THE Payment_Callback SHALL 使用 V3 解密流程
2. WHEN 验证 V3 回调签名 THEN THE Payment_Callback SHALL 使用微信支付公钥验证 Wechatpay-Signature 头
3. WHEN 解密 V3 回调数据 THEN THE Payment_Callback SHALL 使用 AES-256-GCM 算法和 APIv3 密钥解密 resource.ciphertext
4. WHEN 解密成功 THEN THE Payment_Callback SHALL 提取订单号和支付状态并更新订单
5. WHEN 收到 V2 格式回调XML格式THEN THE Payment_Callback SHALL 使用 V2 解密流程
6. WHEN 回调处理成功 THEN THE Payment_Callback SHALL 返回正确的响应格式V3: JSON, V2: XML
### Requirement 5: V3 订单查询
**User Story:** As a 系统, I want 使用 V3 接口查询订单状态, so that 处理支付超时和异常情况。
#### Acceptance Criteria
1. WHEN 查询订单且商户配置为 V3 THEN THE WechatPay_V3 SHALL 调用 V3 订单查询接口
2. WHEN 构建查询请求 THEN THE WechatPay_V3 SHALL 使用商户订单号作为路径参数
3. WHEN 查询成功 THEN THE WechatPay_V3 SHALL 解析返回的订单状态SUCCESS、NOTPAY、CLOSED 等)
4. IF 查询失败 THEN THE WechatPay_V3 SHALL 返回错误信息并记录日志
### Requirement 6: V3 订单关闭
**User Story:** As a 系统, I want 使用 V3 接口关闭未支付订单, so that 释放库存和资源。
#### Acceptance Criteria
1. WHEN 关闭订单且商户配置为 V3 THEN THE WechatPay_V3 SHALL 调用 V3 订单关闭接口
2. WHEN 构建关闭请求 THEN THE WechatPay_V3 SHALL 使用商户订单号作为路径参数并包含 mchid
3. WHEN 关闭成功 THEN THE WechatPay_V3 SHALL 返回成功状态HTTP 204
4. IF 关闭失败 THEN THE WechatPay_V3 SHALL 返回错误信息并记录日志
### Requirement 7: V3 退款接口
**User Story:** As a 系统管理员, I want 使用 V3 接口发起退款, so that 处理用户退款请求。
#### Acceptance Criteria
1. WHEN 发起退款且商户配置为 V3 THEN THE WechatPay_V3 SHALL 调用 V3 退款接口
2. WHEN 构建退款请求 THEN THE WechatPay_V3 SHALL 包含out_trade_no、out_refund_no、reason、notify_url、amount
3. WHEN 退款成功 THEN THE WechatPay_V3 SHALL 返回退款单号和状态
4. THE WechatPay_V3 SHALL 支持部分退款(退款金额小于订单金额)
5. IF 退款失败 THEN THE WechatPay_V3 SHALL 返回错误信息并记录日志

View File

@ -0,0 +1,154 @@
# Implementation Plan: 微信支付 V3 升级
## Overview
本实现计划将微信支付从 V2 升级到 V3采用增量开发方式确保 V2 功能不受影响。实现顺序:配置模型 → V3 服务 → 回调处理 → 后台管理页面。
## Tasks
- [ ] 1. 扩展配置模型支持 V3 字段
- [ ] 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 字段
- 在 `HoneyBox.Model/Models/Payment/PaymentModels.cs` 中添加对应字段
- _Requirements: 1.1, 1.2_
- [ ] 1.3 更新 WechatPayConfigService 支持 V3 配置映射
- 在配置加载时映射 V3 字段
- _Requirements: 1.4, 1.5_
- [ ] 1.4 编写配置序列化 round-trip 属性测试
- **Property 1: 配置序列化 Round-Trip**
- **Validates: Requirements 1.4, 1.5**
- [ ] 2. 创建 V3 支付数据模型
- [ ] 2.1 创建 WechatPayV3Models.cs 文件
- 在 `HoneyBox.Model/Models/Payment/` 目录下创建
- 包含WechatPayV3JsapiRequest、WechatPayV3Amount、WechatPayV3Payer、WechatPayV3JsapiResponse
- _Requirements: 3.2_
- [ ] 2.2 创建 V3 回调通知模型
- 包含WechatPayV3Notification、WechatPayV3Resource、WechatPayV3PaymentResult
- _Requirements: 4.3, 4.4_
- [ ] 2.3 创建 V3 查询、关闭、退款结果模型
- 包含WechatPayV3QueryResult、WechatPayV3CloseResult、WechatPayV3RefundResult、WechatPayV3RefundRequest
- _Requirements: 5.3, 6.3, 7.3_
- [ ] 3. 实现 V3 支付服务核心功能
- [ ] 3.1 创建 IWechatPayV3Service 接口
- 在 `HoneyBox.Core/Interfaces/` 目录下创建
- 定义CreateJsapiOrderAsync、QueryOrderAsync、CloseOrderAsync、RefundAsync
- _Requirements: 3.1, 5.1, 6.1, 7.1_
- [ ] 3.2 实现 V3 签名生成方法
- 实现 RSA-SHA256 签名算法
- 签名字符串格式HTTP方法\nURL\n时间戳\n随机串\n请求体\n
- _Requirements: 3.3_
- [ ] 3.3 编写签名确定性属性测试
- **Property 5: V3 请求签名正确性**
- **Validates: Requirements 3.3**
- [ ] 3.4 实现 CreateJsapiOrderAsync 方法
- 构建 V3 JSAPI 下单请求
- 调用微信 V3 API
- 返回小程序支付参数
- _Requirements: 3.2, 3.4_
- [ ] 3.5 编写请求字段完整性属性测试
- **Property 6: V3 请求字段完整性**
- **Validates: Requirements 3.2**
- [ ] 4. Checkpoint - 确保 V3 下单功能测试通过
- 运行所有测试,确保通过
- 如有问题请询问用户
- [ ] 5. 实现 V3 回调处理
- [ ] 5.1 实现回调签名验证方法
- 使用微信支付公钥验证 Wechatpay-Signature 头
- _Requirements: 4.2_
- [ ] 5.2 实现 AES-256-GCM 解密方法
- 解密 resource.ciphertext 字段
- 使用 APIv3 密钥作为解密密钥
- _Requirements: 4.3_
- [ ] 5.3 编写解密 round-trip 属性测试
- **Property 9: V3 回调解密 Round-Trip**
- **Validates: Requirements 4.3**
- [ ] 5.4 实现回调格式自动识别
- JSON 格式且包含 resource 字段 → V3 流程
- XML 格式 → V2 流程
- _Requirements: 4.1, 4.5_
- [ ] 5.5 编写回调格式识别属性测试
- **Property 8: 回调格式识别正确性**
- **Validates: Requirements 4.1, 4.5**
- [ ] 5.6 更新 PaymentNotifyService 支持 V3 回调
- 在现有回调处理中添加 V3 分支
- _Requirements: 4.4, 4.6_
- [ ] 6. 实现 V3 订单查询和关闭
- [ ] 6.1 实现 QueryOrderAsync 方法
- 调用 V3 订单查询接口
- 解析订单状态
- _Requirements: 5.1, 5.2, 5.3_
- [ ] 6.2 实现 CloseOrderAsync 方法
- 调用 V3 订单关闭接口
- 处理 HTTP 204 响应
- _Requirements: 6.1, 6.2, 6.3_
- [ ] 7. 实现 V3 退款接口
- [ ] 7.1 实现 RefundAsync 方法
- 调用 V3 退款接口
- 支持部分退款
- _Requirements: 7.1, 7.2, 7.3, 7.4_
- [ ] 7.2 编写部分退款金额属性测试
- **Property 12: 部分退款金额正确性**
- **Validates: Requirements 7.4**
- [ ] 8. 实现版本路由逻辑
- [ ] 8.1 更新 WechatPayService 支持版本路由
- 根据商户配置的 PayVersion 选择 V2 或 V3 服务
- _Requirements: 3.1, 3.5_
- [ ] 8.2 编写版本路由属性测试
- **Property 4: 版本路由正确性**
- **Validates: Requirements 3.1, 3.5**
- [ ] 8.3 注册 V3 服务到依赖注入容器
- 在 ServiceModule.cs 中注册 IWechatPayV3Service
- _Requirements: 3.1_
- [ ] 9. Checkpoint - 确保后端 V3 功能完整
- 运行所有测试,确保通过
- 如有问题请询问用户
- [ ] 10. 更新后台管理页面
- [ ] 10.1 更新前端配置接口类型定义
- 在 `admin-web/src/api/business/config.ts` 中添加 V3 字段
- _Requirements: 2.1_
- [ ] 10.2 更新微信商户配置表单组件
- 在 `admin-web/src/views/business/config/components/WeixinMerchantForm.vue` 中添加 V3 配置项
- 添加支付版本选择V2/V3
- 根据版本显示/隐藏对应配置项
- _Requirements: 2.1, 2.2, 2.3_
- [ ] 10.3 实现配置验证逻辑
- V3 版本时验证必填字段
- _Requirements: 2.4_
- [ ] 10.4 测试配置保存和回显
- 确保配置正确保存到数据库
- 确保页面加载时正确回显
- _Requirements: 2.4, 2.5_
- [ ] 11. 准备证书文件
- [ ] 11.1 创建证书目录结构
- 创建 `server/HoneyBox/certs/1738725801/` 目录
- _Requirements: 1.2_
- [ ] 11.2 解压并放置证书文件
- 从 `微信支付商户号/商户API证书/` 解压获取 apiclient_key.pem
- 从 `微信支付商户号/微信支付公钥/` 复制 pub_key.pem
- _Requirements: 1.2_
- [ ] 12. Final Checkpoint - 完整功能验证
- 运行所有测试,确保通过
- 验证 V2 功能不受影响
- 如有问题请询问用户
## Notes
- 所有任务均为必需,包括属性测试任务
- 每个任务都引用了具体的需求条款以便追溯
- Checkpoint 任务用于阶段性验证
- 属性测试验证通用正确性属性
- 单元测试验证具体示例和边界情况

View File

@ -11,8 +11,8 @@
// 测试环境配置 - .NET 10 后端
const testing = {
// baseUrl: 'https://app.zpc-xy.com/honey/api',
baseUrl: 'http://192.168.1.24:5238',
baseUrl: 'https://app.zpc-xy.com/honey/api',
// baseUrl: 'http://192.168.1.24:5238',
imageUrl: 'https://youdas-1308826010.cos.ap-shanghai.myqcloud.com',
loginPage: '',
wxAppId: ''

View File

@ -212,6 +212,32 @@ class BasePlatform {
* @param {Object} item 菜单项
*/
navigateToPath(item) {
// 需要登录才能访问的页面路径
const needLoginPaths = [
'/pages/other/order_list', // 消费记录
'/package/mine/collect', // 我的收藏
'/pages/user/coupon', // 优惠券
'/pages/user/tui-guang', // 邀请好友
'/pages/user/cancel-account-page', // 注销账号
];
// 检查是否需要登录
const needLogin = needLoginPaths.some(path => item.path.startsWith(path));
if (needLogin) {
const token = uni.getStorageSync('token');
if (!token) {
uni.setStorageSync('redirect', item.path);
uni.showToast({
title: '请先登录',
icon: 'none'
});
setTimeout(() => {
navigateTo('/pages/user/login');
}, 100);
return;
}
}
navigateTo(item.path);
}

View File

@ -400,19 +400,32 @@ class RequestManager {
// 处理 HTTP 401 未授权Token 过期或无效)
if (res.statusCode === 401) {
console.log('Token过期或无效,尝试自动刷新');
console.log('Token过期或无效');
// 白名单接口不进行自动刷新,直接拒绝
// 白名单接口直接返回错误,不处理
if (RequestManager.isUrlInWhitelist(requestUrl)) {
reject({ status: -1, msg: '登录已过期' });
return;
}
// 检查是否有 token用户是否曾经登录过
const currentToken = uni.getStorageSync('token');
if (!currentToken) {
// 用户从未登录过,直接返回错误,不跳转
console.log('用户未登录,返回错误');
reject({ status: -1, msg: '请先登录' });
return;
}
// 检查是否有 refreshToken
const refreshToken = uni.getStorageSync('refreshToken');
if (!refreshToken) {
console.log('没有 refreshToken直接跳转登录页');
RequestManager.clearTokensAndRedirect();
console.log('没有 refreshToken清除token并返回错误');
// 清除过期的token但不跳转登录页
uni.removeStorageSync('token');
uni.removeStorageSync('accessToken');
uni.removeStorageSync('tokenExpireTime');
uni.removeStorageSync('userinfo');
reject({ status: -1, msg: '登录已过期' });
return;
}
@ -443,9 +456,13 @@ class RequestManager {
// 处理队列中的其他请求
RequestManager.processRefreshQueue(true);
} else {
console.log('Token 刷新失败,跳转登录页');
// 刷新失败,清除 Token 并跳转登录页
RequestManager.clearTokensAndRedirect();
console.log('Token 刷新失败');
// 刷新失败,清除 Token但不跳转登录页
uni.removeStorageSync('token');
uni.removeStorageSync('accessToken');
uni.removeStorageSync('refreshToken');
uni.removeStorageSync('tokenExpireTime');
uni.removeStorageSync('userinfo');
reject({ status: -1, msg: '登录已过期' });
// 拒绝队列中的所有请求
@ -455,7 +472,11 @@ class RequestManager {
.catch(error => {
console.error('Token 刷新异常:', error);
RequestManager.isRefreshing = false;
RequestManager.clearTokensAndRedirect();
uni.removeStorageSync('token');
uni.removeStorageSync('accessToken');
uni.removeStorageSync('refreshToken');
uni.removeStorageSync('tokenExpireTime');
uni.removeStorageSync('userinfo');
reject({ status: -1, msg: '登录已过期' });
RequestManager.processRefreshQueue(false);
});
@ -516,55 +537,22 @@ class RequestManager {
}
}
// 获取当前页面路径和参数
var currentPage = pages[pages.length - 1];
if (currentPage) {
var currentRoute = currentPage.route;
var currentParams = currentPage.options || {};
// 只有非登录页面才保存重定向信息
if (currentRoute && currentRoute !== 'pages/user/login') {
// 构建完整的重定向URL
var redirectPath = '/' + currentRoute;
// 如果有参数,拼接参数
if (Object.keys(currentParams).length > 0) {
var paramString = Object.keys(currentParams)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(currentParams[key])}`)
.join('&');
redirectPath += '?' + paramString;
}
// 保存重定向URL到缓存
console.log('保存重定向URL:', redirectPath);
uni.setStorageSync('redirect', redirectPath);
}
}
// 白名单接口直接返回错误,不跳转登录页
console.log(requestUrl);
if (RequestManager.isUrlInWhitelist(requestUrl)) {
reject(res.data)
return;
}
setTimeout(() => {
uni.showToast({
title: '请先登录',
icon: 'none'
})
}, 100)
// 清除所有Token相关存储
uni.removeStorageSync('token');
uni.removeStorageSync('accessToken');
uni.removeStorageSync('refreshToken');
uni.removeStorageSync('tokenExpireTime');
uni.removeStorageSync('userinfo');
// 使用新的路由守卫方法进行跳转
RouterManager.navigateTo('/pages/user/login', {}, 'navigateTo')
.catch(err => {
console.error('登录页面跳转失败:', err);
});
// 不再自动跳转登录页,只返回错误让调用方处理
// 调用方可以根据业务需要决定是否跳转登录页
reject(res.data)
} else {
reject(res.data)

View File

@ -98,29 +98,9 @@ export function routerTo(options) {
url += (url.indexOf('?') === -1 ? '?' : '&') + queryString;
}
// 检查是否需要登录
const needLogin = !isInWhiteList(url);
if (needLogin && !isLogin()) {
// 需要登录但未登录,跳转到登录页面
// 先保存当前URL用于登录后重定向
uni.setStorageSync('redirect', url);
// 使用navigateTo而非redirectTo保留页面栈
uni.navigateTo({
url: '/pages/user/login',
success: () => {
console.log('跳转到登录页面成功,保存的重定向地址:', url);
},
fail: (err) => {
console.error('跳转到登录页面失败:', err);
reject(err);
}
});
// 拒绝当前的导航请求
return reject(new Error('需要登录'));
}
// 移除路由层面的登录拦截
// 允许用户浏览所有页面,只在需要执行特定操作时才要求登录
// 登录检查应该在具体的业务操作中进行(如抽奖、下单等)
// 根据type选择跳转方式
if (type) {
@ -251,5 +231,53 @@ export default {
navigateBack,
collectWhitePath,
getCollectedWhitePaths,
clearCollectedWhitePaths
};
clearCollectedWhitePaths,
requireLogin,
isLogin
};
/**
* 检查登录状态未登录则跳转登录页
* 用于需要登录才能执行的操作如抽奖下单等
* @param {String} message 提示消息可选
* @returns {Boolean} true表示已登录false表示未登录会跳转登录页
*/
export function requireLogin(message = '请先登录') {
if (isLogin()) {
return true;
}
// 保存当前页面用于登录后跳转
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
if (currentPage) {
const currentRoute = currentPage.route;
const currentParams = currentPage.options || {};
if (currentRoute && currentRoute !== 'pages/user/login') {
let redirectPath = '/' + currentRoute;
if (Object.keys(currentParams).length > 0) {
const paramString = Object.keys(currentParams)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(currentParams[key])}`)
.join('&');
redirectPath += '?' + paramString;
}
uni.setStorageSync('redirect', redirectPath);
}
}
// 显示提示
uni.showToast({
title: message,
icon: 'none'
});
// 跳转登录页
setTimeout(() => {
uni.navigateTo({
url: '/pages/user/login'
});
}, 100);
return false;
}

View File

@ -51,25 +51,25 @@
<view v-if="getIsCheck('user_money')" class="money-card">
<view class="other-num">
<view class="other-item" @click="$c.to({ url: '/pages/user/bi_jl' })">
<view class="other-item" @click="toFinancePage('/pages/user/bi_jl')">
<view class="item-content">
<view class="title">{{ $config.getAppSetting('currency1_name') }}</view>
<view class="num">{{ formatNumber(userinfo.integral) }}</view>
</view>
</view>
<view class="divider"></view>
<view class="other-item" @click="$c.to({ url: '/pages/user/jf_jl' })">
<view class="other-item" @click="toFinancePage('/pages/user/jf_jl')">
<view class="item-content">
<view class="title">{{ $config.getAppSetting('currency2_name') }}</view>
<view class="num">{{ formatNumber(userinfo.money2) }}</view>
</view>
</view>
<view v-if="zuanshi" class="divider"></view>
<view v-if="zuanshi" class="other-item" @click="$c.to({ url: '/pages/user/yetx' })">
<view v-if="zuanshi" class="other-item" @click="toFinancePage('/pages/user/yetx')">
<view class="item-content">
<view class="title-wrapper">
<view class="title">{{ $config.getAppSetting('balance_name') }}</view>
<view class="recharge-tag" @click.stop="$c.to({ url: '/pages/user/recharge-page' })">充值
<view class="recharge-tag" @click.stop="toFinancePage('/pages/user/recharge-page')">充值
</view>
</view>
<view class="num">{{ formatNumber(userinfo.money) }}</view>
@ -367,6 +367,25 @@
})
}
},
/**
* 跳转到财务相关页面需要登录
* @param {String} url - 页面路径
*/
toFinancePage(url) {
const token = uni.getStorageSync('token');
if (!token) {
uni.setStorageSync('redirect', '/pages/user/index');
uni.showToast({
title: '请先登录',
icon: 'none'
});
setTimeout(() => {
this.$c.nav('/pages/user/login');
}, 100);
return;
}
this.$c.to({ url });
},
/**
* 格式化数字如果小数部分是.00则只显示整数部分
* @param {Number|String} num - 要格式化的数字

View File

@ -0,0 +1 @@
商户号1738725801

View File

@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0KeKMd6Yxovf4kPI0c1Q
Islyq9fi/Wg60dodzPNkRRoraqmqbbW7uQcKHkHvIZi5Z9fK8SGkezyhcjiR3o8z
uwnH5QiFuMw6P+1XB1koFfbxxCc6Eh0iuRI5BqNfyRwXwn9wIEUNwfF/SAPJGTkk
hCzViil3tOmnJDMxQUJitt4RsnL6BvQ3afWcm7oqt7MLlcIhIW8jAsSFeWPuZcW5
Hj+o2udrTUaTRkw7AEsHr9xyePhsqYjGxbi9fTlghkUYnRUNikSydtQoHbGHP70Q
tz4HbPqH4gpsCqabPVuANFGH5a8uidOH3XKq2iPLggbPci1nFI8xMmHMaT88u/o5
GQIDAQAB
-----END PUBLIC KEY-----

View File

@ -0,0 +1 @@
PUB_KEY_ID_0117387258012026012500291641000801

View File

@ -0,0 +1 @@
d1cxc0vXCUH2984901DxddPJMYqcwcnd