This commit is contained in:
Billy 2025-10-21 13:45:10 +08:00
parent 461083cb79
commit b4e76a58ab
4 changed files with 0 additions and 2006 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,44 +0,0 @@
package com.example.filedemo.util.fuwufang;
import com.example.filedemo.util.RSAUtil;
import sun.misc.BASE64Decoder;
import sun.misc.BASE64Encoder;
public class UrlMain {
public static void main(String[] args) throws Exception {
String msg = "BGCOLOR=&userid=YSM202111170063936&mobile=18242028306&cityid=330100&userCityId=330100&orderid=&PLATFLOWNO=0000A2UNK1639016304462982&openid=&lgt=113.3295774824442&ltt=23.12339638654285&Usr_Name=&USERID=YSM202111170063936&MOBILE=18242028306&CITYID=330100&USERCITYID=330100&ORDERID=&OPENID=&LGT=113.3295774824442&LTT=23.12339638654285";
//String enc_msg = "SDB0dllqYmxFS2xHRmlqa1ZaOFk0OHBXY0I5TitoREdJaVB3K1pjM2M3dy9jek4zN016ZUoxZENTNTVLWVFFV3VSYzlYOVlXRkpBcQpWRUgwaDJUMG04V2lmNHJyS3krdG5QUDJHalhEQlNma21oR3JrV0lsbFRibC9vbWJONGxqeVk1TXZQWjVWc2t5N2ZVRlZTYlNlYjIzCnJ5cFN4dTRNSDUrTjFRTU5NVFE9Cg%3D%3D";
// 公钥
String publicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQClMNB2rs4PMyxHdV+HeISWBbe55WQkmSYQQvFq8M4MMczhYihhp1Z9p723wD8cv9m/PQQcQZuNIehGGIIbZnMZFkqwDYUODH0DF8N5o7BiUhw/XUr3nl49/hsjlE6L7k/7jYzxZ+r3CXhz7qVXZNW6tD2RM+AI4qomQr0p1VNxhQIDAQAB";
// 私钥
String privateKey = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKUw0Hauzg8zLEd1X4d4hJYFt7nlZCSZJhBC8WrwzgwxzOFiKGGnVn2nvbfAPxy/2b89BBxBm40h6EYYghtmcxkWSrANhQ4MfQMXw3mjsGJSHD9dSveeXj3+GyOUTovuT/uNjPFn6vcJeHPupVdk1bq0PZEz4AjiqiZCvSnVU3GFAgMBAAECgYAyTZQdoAulu0qPlCF8CmotmR4ioMUHFA/wQcJsc1n7gqrGM3LikeeXqh3ut79ATPfM8ZKv3Ba3Oo0V017DY0ZG7j2stXxFhm2ln/q6nfaDsfx5ae22kIdNFCrDfwYByBiVsZPNCrj+8qDb/DPiVveEpsj7hn6thZY8QnjwEi0O3QJBAOia3cqup/rLMTYwtl43OREyMDt3qWS+aRQz1jQJlQSONV76qsZpZZUVxQEglvf6+afRCyn1mAqNa2dek6gbHTMCQQC1zijBYb6b4kghbKg/ZC37A79kBuRKtl/yIMYtFLWrtIntv047HavVPHZLEl++44Hk+9rfzNw1J12uXigGVoZnAkBGh6745jzJLxOc+uhRaS1EqZM2dPJIOfRiy9UHsmAdIYHNavSddRf4PMGfteIRD2jkGd7oui+AA6Gtll/veUlBAkAwybEwK/3NsUywA4um70hTiy7qNds/nW9j952W7W7PNDSrY2IoBQ9eusn33WdqP31VKK0Uz9HsRbMjHstY4BFTAkEAisda+CJkO/Epdj693ewIr4GbGORGSVB2pCjLGPqhuvu37d/T9+9T85BoeaMwm31aVNGOPIUCSPOMelKRUoj3Gw==";
// 公钥加密得到密文并使用base64处理
String enc_msg = RSAUtil.encrypt(msg, publicKey);
//enc_msg = "";
//enc_msg = "TVpoZ040QTdVcTZMem11cGdTVjdWeFEvWU5kZWZqcFVBK3JSS0hGK0NsSFo0Ly82RnV3blVvT1hrMXNlZ25odXlkVytvRkdGT0xoYwpRSlhjYWI4eWJkUWh3UTgzUU1MQzBzYWtManZCOFNhR29VWDhiMHZoRXhWdkVIQ3BTcnlVRUQwUU9zQzVodXoxQTk3djRIeXNlMFR5CjFnWm1zaTRLZm9sWHM1TndJcXMvQ3lVdE9MTjNqZGZoajNHTXQvSHN1c01PVzFBekwxbTFTWVk2VGhuL3lvODA4NGRRaGN2aC9KVU0KdUdWZUNtdEJJbytHVVJaOXNCZW5BaUZVTEFWTHJINHhRK0pWUnpRRUZvRlhhNVVaRVQ0cDJTME1TWXBTZ1R4eStOeFloTER1S280OQpsV3RONFVKVkJiTVY1dUJrdEthMEF6MEROczA1bndDTnJtL3FReFdQMmFtV3pRT24ybHl6MVRCMXhjZGpaVVpDc3prOVZoM3FtS05SCmNVTnV2M0x6cDB2SzIzOVFyM205R0taWGl6TFdKcktQcE9UaDkrODQyY0s3L1ZmTDh6S2NBZG1QN21QMXhKZE5NVlEzZFZnaW9XWlQKd2xWWVc4N2FLZ0xLNlZIaEhJNTVGV3RNaFYrTzdNbUZlSzBWckhaS3NHSDJIUGg5ZUQyVkFVWVNWaU9HdisyUDRFczJ5V0lCNjkxNgpjMStRWjc5RXdwRmwrQjBJUG0yLy9rbDVXSXVYaDdXY1FPUzBBMm4yMnYxWnlMK29vdVNUTWNGMm9TNWx1RjRzRVc5VDkrb0tjRjU5CjBVOGVvY1E0R2Q5dkxwTnVpSG1CNUpvWFF6NG1EZGFBdG13eS9zMHFXVTgyQjlWZEVETlNPZGx0NnNoQXBTZ01ObGtGS0hyQllHMD0K";
BASE64Encoder encoder = new BASE64Encoder();
enc_msg = encoder.encode(enc_msg.getBytes("UTF-8"));
enc_msg = enc_msg.replaceAll("\r\n", "").replaceAll("\r", "").replaceAll("\n", "");
System.out.println("公钥加密得到密文并使用base64处理:");
//enc_msg = "TVpoZ040QTdVcTZMem11cGdTVjdWeFEvWU5kZWZqcFVBK3JSS0hGK0NsSFo0Ly82RnV3blVvT1hrMXNlZ25odXlkVytvRkdGT0xoYwpRSlhjYWI4eWJkUWh3UTgzUU1MQzBzYWtManZCOFNhR29VWDhiMHZoRXhWdkVIQ3BTcnlVRUQwUU9zQzVodXoxQTk3djRIeXNlMFR5CjFnWm1zaTRLZm9sWHM1TndJcXMvQ3lVdE9MTjNqZGZoajNHTXQvSHN1c01PVzFBekwxbTFTWVk2VGhuL3lvODA4NGRRaGN2aC9KVU0KdUdWZUNtdEJJbytHVVJaOXNCZW5BaUZVTEFWTHJINHhRK0pWUnpRRUZvRlhhNVVaRVQ0cDJTME1TWXBTZ1R4eStOeFloTER1S280OQpsV3RONFVKVkJiTVY1dUJrdEthMEF6MEROczA1bndDTnJtL3FReFdQMmFtV3pRT24ybHl6MVRCMXhjZGpaVVpDc3prOVZoM3FtS05SCmNVTnV2M0x6cDB2SzIzOVFyM205R0taWGl6TFdKcktQcE9UaDkrODQyY0s3L1ZmTDh6S2NBZG1QN21QMXhKZE5NVlEzZFZnaW9XWlQKd2xWWVc4N2FLZ0xLNlZIaEhJNTVGV3RNaFYrTzdNbUZlSzBWckhaS3NHSDJIUGg5ZUQyVkFVWVNWaU9HdisyUDRFczJ5V0lCNjkxNgpjMStRWjc5RXdwRmwrQjBJUG0yLy9rbDVXSXVYaDdXY1FPUzBBMm4yMnYxWnlMK29vdVNUTWNGMm9TNWx1RjRzRVc5VDkrb0tjRjU5CjBVOGVvY1E0R2Q5dkxwTnVpSG1CNUpvWFF6NG1EZGFBdG13eS9zMHFXVTgyQjlWZEVETlNPZGx0NnNoQXBTZ01ObGtGS0hyQllHMD0K";
System.out.println(enc_msg);
//enc_msg = "UVRreXJPVm1GRlgrYldkWFNjd1pwM0dTMWxuNkJYMThUZEs1U1dLQWU2cFdkV0JoUXBFeU1nci90L1J1YWpTSks0RXo2a250cXJGK0hoclRXQ3I5Nk8vUEw4aWFKS3J5SllpUm9jTE1NMVdEcWsyakIvMWxkWXE1WGx4Qk9lenR3aTI0alV4MVV4dTBZY0ZWaUIvdGFRd0xIaWdzdE1nT1pEYnlqcnhKdUdGdkpJVG9hNkJDbGM4RXpnRWN3bzZiQnBDR3BXSCtkQk5LZE5yN0dDYnAzRTltUGRxOEh4Y01NNFBiNXdyeFBrZlpCMkl2NXpGRnNmOStUTHEzajVQT2JTa2t6dXR2VGhVS2VKN1dYZ01Vdzhvbk9rYzE2S3Q3VTg3dEFJVlpJYTY2RDdTMGd5ZWNrN01oVE5KM2tkYXNhUmVtbEQ2cys0WkNtb2NqYWVWbVpuT09yNGtsS3Z6U2VZVE5sOWNpMXRCblFBV0M0VzE5dVN0RXp6OGxFY21idHBqZHVSbUxGODNyNm83YWZ6N1dDbGMwUDEyakMxODRxZDNNUUpRQ0l0OE1OZThzNTZsNVJ1blJnRmNGNGJEb2UyTU94QUUzb05Rd3JCMldRemcxNE9mRFp2UVdlMW9JVUNMU0cyZGc1OUNUN09KdG5lZndDaEJQUGNmc2tBVE8%3D";
//enc_msg = "T2tpcTVMeC9uVCt6VXNSRWxaT09VVGh1YlZtMkpXQXhzcWErZkxsQ0pUWktMQVZNdTFTYm1VZnN6aVVGaTNnbUE0VEx6LzJLL3pkVmpzY0pHSDlzUmJ6MFRWTUM3QkZ4ZXV5bVJZMW43bGVNOG1wRVhheGNpTEIyVzNMV0lmakh0d0o0QTRUNWtwMnhUOVprRXFhZ1RKUEZ3RUgwSmdqem9CRHpjMzZNWkxlRS9DUzBCR0RoQzdTODFweXBMaktuUWdhK0RJNUFOQUdrQnhjeHcrQWFGeUdNRmRVMWVaMU9GWUtYVjRzeUJVZnZ6dk1UN2ZmODIvLzZBa1VRMFN3a2p5TmliRjg4VkJCODJGckRCOC9TRW1CWVJnWWtRVklhWEFPZXo1aXlSR0laam1KN3Z6bXFKTDZSVzVGWTFPYms2YWJaU1FnVnZwNXoxbStHdG1KdkRYczJxeE01Unk2N0RtNlhpOGRyRERvVW83YUdzbW5Tamp5VzNUSVE0WS9iSzVyMEo5UndwcjUvTTFYMGg1T3d4MWJoRWVVTUJVZlMzV1BZTVNwMVR4WFVsRkFjTk8yQk9wZ3lvcWJYcmRFV0c2RmFIUXNxYS82ay80SmpseVpCbDd6cUUwYU9SM3lZMXY0ZG9iVHlDb0JENkNhcFp1SWs0NFlibDRaMFdTRmFlRE1lcndiZmdUcU1nWmFNL0RjWmVWN2V1akVGNytaWjNLTExZdmU2VlV3bVlJbmM2bHg2N2FwdG5UM0hic3BWei8rTnlHek1FRWRmQlpGTVFnbDhSeTBoeTlDcGRxRng2dUhrdm5wRHJrenZkUVAzWm55bkRzZHgwdlBoUW9XeEQyQWRDVi9UdVdIOTIxeG52b0NVa3U2UCtkSFJyUm9kd1BVSjBWOURiYnc9";
// base64逆处理并用私钥解密
BASE64Decoder decoder = new BASE64Decoder();
enc_msg = new String(decoder.decodeBuffer(enc_msg),"UTF-8");
String dec_msg = RSAUtil.decrypt(enc_msg, privateKey);
System.out.println("base64逆处理并用私钥解密:");
System.out.println(dec_msg);
}
}

View File

@ -1,538 +0,0 @@
# 建行支付对接修复报告
**项目**: Shopro商城建行支付集成
**修复时间**: 2025-01-20
**文档版本**: v2.0 (修订版)
**建行接口版本**: v2.20 (2025-07-25)
---
## ⚠️ 重要修订说明
本报告v2.0版本修正了v1.0中关于"建行平台公钥"的**严重错误理解**:
- ❌ **错误**: 文档中不存在"建行平台公钥"这个概念
- ✅ **正确**: 应该是"建行生活支付验签公钥"(需联系建行生活技术支持获取)
---
## 📋 修复概览
本次对建行支付对接代码进行了**5项严重错误修复**和**1项性能优化**,基于建行官方Java示例代码和接口文档v2.20规范。
### 修复文件清单
| 文件路径 | 修复项 | 风险等级 |
|---------|--------|---------|
| `addons/shopro/library/ccblife/CcbPaymentService.php` | MAC签名算法、SIGN验签逻辑 | 🔴 致命 |
| `addons/shopro/library/ccblife/CcbEncryption.php` | ENCPUB生成、RSA分段加密 | 🔴 致命 |
| `addons/shopro/controller/Ccbpayment.php` | 防重复支付、notify返回格式 | 🟡 严重 |
---
## 🔴 致命错误修复
### 1. 支付串MAC签名算法错误
**位置**: `CcbPaymentService.php:148-153`
#### 修复前 ❌
```php
// 错误: 使用私钥签名
$mac = md5($signString . $this->config['private_key']);
```
#### 修复后 ✅
```php
// 正确: 使用服务方公钥参与MD5计算(建行v2.2规范)
$platformPubKey = $this->config['public_key']; // 服务方公钥
$mac = strtoupper(md5($signString . '&PLATFORMPUB=' . $platformPubKey));
```
#### 技术说明
根据建行文档v2.2版本和官方MD5Util.java示例:
- **PLATFORMPUB字段**: 仅参与MD5摘要计算,不作为HTTP参数传递
- **签名格式**: `MD5(参数串 + &PLATFORMPUB= + 服务方公钥内容)`
- **输出格式**: 32位**大写**MD5字符串 (对照MD5Util.java第30行: `toUpperCase()`)
**影响**: 修复前建行会拒绝所有支付请求,因签名验证100%失败。
---
### 2. ENCPUB字段生成逻辑错误
**位置**: `CcbEncryption.php:387-420`
#### 修复前 ❌
```php
// 错误: 加密整个商户公钥
return $this->rsaEncrypt($this->publicKey);
```
#### 修复后 ✅
```php
// 正确: 只加密商户公钥后30位
$publicKeyContent = str_replace([
'-----BEGIN PUBLIC KEY-----',
'-----END PUBLIC KEY-----',
"\r", "\n", " "
], '', $this->publicKey);
$last30Chars = substr($publicKeyContent, -30);
return $this->rsaEncrypt($last30Chars);
```
#### 技术说明
建行文档明确要求:
> "使用服务方公钥对**商户公钥后30位**进行RSA加密并base64后的密文"
**影响**: 修复前ENCPUB字段内容错误,可能导致建行无法验证商户公钥。
---
### 3. 异步通知SIGN验签逻辑优化
**位置**: `CcbPaymentService.php:467-570`
#### 修复前 ❌
```php
// 错误: 使用MD5验签
$expectedSign = md5($signStr . $this->config['private_key']);
return strtolower($signature) === strtolower($expectedSign);
```
#### 修复后 ✅
```php
// 智能验签方案: 如果配置了验签公钥则使用RSA,否则降级为POSID验证
$ccbVerifyPublicKey = $this->config['ccb_payment_verify_public_key'] ?? '';
if (empty($ccbVerifyPublicKey)) {
// 降级方案: POSID验证
return ($params['POSID'] ?? '') === $this->config['pos_id'];
}
// 完整方案: RSA验签(尝试SHA256和SHA1)
$signBinary = hex2bin($params['SIGN']);
$pubKey = openssl_pkey_get_public($ccbVerifyPublicKey);
$result = openssl_verify($signStr, $signBinary, $pubKey, OPENSSL_ALGO_SHA256);
if ($result !== 1) {
$result = openssl_verify($signStr, $signBinary, $pubKey, OPENSSL_ALGO_SHA1);
}
return $result === 1;
```
#### 技术说明
根据建行文档7.2.3章节:
- **SIGN字段**: 256个十六进制字符 (2048位RSA签名)
- **验签密钥**: "建行生活分配的服务商支付验签公钥" (NT_TYPE=YS时)
- **验签算法**: RSA-SHA256或SHA1 (文档未明确,代码会自动尝试)
- **获取方式**: 联系建行生活平台技术支持
#### 降级方案说明
由于建行未提供验签公钥和示例代码,代码实现了两级验证:
1. **优先**: 如果配置了`ccb_payment_verify_public_key`,使用RSA验签
2. **降级**: 如果未配置,验证POSID和订单号是否匹配
**建议**: 尽快联系建行技术支持获取验签公钥,补全配置后获得完整安全保障。
**影响**: 修复后验签逻辑更健壮,未配置公钥时也能正常运行(安全性降低但不会中断业务)。
---
## 🟡 严重问题修复
### 4. 订单状态更新缺少防重复逻辑
**位置**: `Ccbpayment.php:170-197`
#### 修复前 ❌
```php
// 直接更新,没有并发控制
$order->status = 'paid';
$order->save();
```
#### 修复后 ✅
```php
// 使用原子性更新,防止并发重复支付
$affectedRows = Db::name('shopro_order')
->where('id', $order->id)
->where('status', 'unpaid') // 只更新未支付的订单
->update([
'status' => 'paid',
'paid_time' => time() * 1000,
'updatetime' => time()
]);
if ($affectedRows === 0) {
// 订单已支付或状态异常
throw new Exception('订单状态异常,无法更新为已支付');
}
```
**影响**: 修复前在高并发场景下可能出现重复支付或状态覆盖。
---
### 5. notify接口返回格式不规范
**位置**: `Ccbpayment.php:271-283`
#### 修复前 ❌
```php
// ThinkPHP框架会追加额外内容
echo $result; // 'SUCCESS' 或 'FAIL'
```
#### 修复后 ✅
```php
// 直接exit,确保只返回纯文本
exit(strtoupper($result)); // 'SUCCESS' 或 'FAIL'
```
#### 技术说明
建行要求异步通知响应:
- **HTTP 200** 状态码
- **纯文本** 响应体: `SUCCESS``FAIL`
- **不允许**任何额外字符(HTML/JSON等)
**影响**: 修复前ThinkPHP框架可能追加调试信息,导致建行认为通知失败并重复推送。
---
## ⚡ 性能优化
### 6. RSA加密分段大小动态计算
**位置**: `CcbEncryption.php:102-129`
#### 优化前 ⚠️
```php
// 写死1024位RSA的chunk size
$chunkSize = 117; // 1024位RSA密钥,每次最多加密117字节
```
#### 优化后 ✅
```php
// 动态获取RSA密钥大小
$keyDetails = openssl_pkey_get_details($pubKeyId);
$keySize = $keyDetails['bits'] / 8; // 1024位=128字节, 2048位=256字节
$chunkSize = $keySize - 11; // PKCS1填充需要预留11字节
```
**优势**:
- 自动适配1024位/2048位/4096位RSA密钥
- 减少不必要的分段次数,提升加密性能
- 避免密钥升级后的兼容性问题
---
## 🔐 建行接口签名规则总结
### 支付串生成流程
```mermaid
graph LR
A[34个参数] --> B[按ASCII排序ksort]
B --> C[http_build_query拼接]
C --> D[追加&PLATFORMPUB=服务方公钥]
D --> E[MD5签名,32位小写]
E --> F[ENCPUB=RSA加密商户公钥后30位]
F --> G[最终支付串=参数+MAC+PLATFORMID+ENCPUB]
```
### 异步通知验签流程
```mermaid
graph LR
A[接收SIGN字段] --> B[hex2bin转二进制]
B --> C[移除SIGN,剩余参数ksort排序]
C --> D[拼接签名原串]
D --> E[使用建行公钥RSA-SHA256验签]
E --> F{验签结果}
F -->|成功| G[返回SUCCESS]
F -->|失败| H[返回FAIL]
```
---
## ✅ 验证检查清单
修复完成后,请逐项检查以下配置:
### 1. 配置文件检查
**文件**: `addons/shopro/config/ccblife.php`
```php
return [
// 建行商户信息
'merchant_id' => 'YOUR_MERCHANT_ID',
'pos_id' => 'YOUR_POS_ID',
'branch_id' => 'YOUR_BRANCH_ID',
'service_id' => 'YOUR_SERVICE_ID',
// ✅ 服务方公钥(用于MAC签名)
'public_key' => '-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----',
// ✅ 服务方私钥(用于解密)
'private_key' => '-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...
-----END PRIVATE KEY-----',
// ✅ 建行平台公钥(用于SIGN验签) - 新增必填!
'platform_public_key' => '-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----',
// 建行收银台URL
'cashier_url' => 'https://ibsbjstar.ccb.com.cn/CCBIS/ccbMain',
];
```
### 2. 密钥格式验证
运行以下PHP脚本验证密钥格式:
```php
<?php
$config = include 'addons/shopro/config/ccblife.php';
// 验证服务方公钥
$pubKey = openssl_pkey_get_public($config['public_key']);
if ($pubKey) {
$details = openssl_pkey_get_details($pubKey);
echo "✅ 服务方公钥: {$details['bits']}位\n";
} else {
echo "❌ 服务方公钥格式错误\n";
}
// 验证服务方私钥
$privKey = openssl_pkey_get_private($config['private_key']);
if ($privKey) {
$details = openssl_pkey_get_details($privKey);
echo "✅ 服务方私钥: {$details['bits']}位\n";
} else {
echo "❌ 服务方私钥格式错误\n";
}
// 验证建行平台公钥
$ccbPubKey = openssl_pkey_get_public($config['platform_public_key']);
if ($ccbPubKey) {
$details = openssl_pkey_get_details($ccbPubKey);
echo "✅ 建行平台公钥: {$details['bits']}位\n";
} else {
echo "❌ 建行平台公钥格式错误或未配置\n";
}
```
### 3. 数据库字段检查
确保订单表包含建行相关字段:
```sql
ALTER TABLE `fa_shopro_order`
ADD COLUMN `ccb_pay_flow_id` VARCHAR(64) DEFAULT '' COMMENT '建行支付流水号',
ADD COLUMN `ccb_sync_status` TINYINT(1) DEFAULT 0 COMMENT '建行同步状态:0-未同步 1-已同步 2-失败',
ADD COLUMN `ccb_sync_time` INT(10) DEFAULT 0 COMMENT '建行同步时间';
```
### 4. 支付日志表检查
```sql
CREATE TABLE IF NOT EXISTS `fa_ccb_payment_log` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`order_id` INT(11) NOT NULL COMMENT '订单ID',
`order_sn` VARCHAR(50) NOT NULL COMMENT '订单号',
`pay_flow_id` VARCHAR(64) DEFAULT '' COMMENT '支付流水号',
`payment_string` TEXT COMMENT '支付串',
`user_id` INT(11) DEFAULT 0 COMMENT '用户ID',
`ccb_user_id` VARCHAR(50) DEFAULT '' COMMENT '建行用户ID',
`amount` DECIMAL(10,2) DEFAULT 0.00 COMMENT '支付金额',
`status` TINYINT(1) DEFAULT 0 COMMENT '状态:0-待支付 1-已支付',
`pay_time` INT(10) DEFAULT 0 COMMENT '支付时间',
`trans_id` VARCHAR(64) DEFAULT '' COMMENT '建行交易流水号',
`callback_data` TEXT COMMENT '回调数据',
`create_time` INT(10) NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_pay_flow_id` (`pay_flow_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='建行支付日志表';
```
---
## 🧪 测试建议
### 测试环境准备
1. **配置建行测试环境**:
- 使用建行提供的测试商户号
- 配置测试环境的收银台URL
- 确保获取测试环境的平台公钥
2. **测试用例**:
#### TC1: 支付串生成测试
```php
$service = new CcbPaymentService();
$result = $service->generatePaymentString($orderId);
// 验证点:
// 1. MAC长度为32位
// 2. ENCPUB字段存在且不为空
// 3. 支付串包含所有34个必需参数
```
#### TC2: 异步通知验签测试
```php
// 模拟建行回调数据
$params = [
'ORDERID' => 'test123',
'PAYMENT' => '100.00',
'SUCCESS' => 'Y',
'SIGN' => '256字符十六进制字符串...'
];
$result = $service->handleNotify($params);
// 预期: 返回'success'或'fail'
```
#### TC3: 并发支付测试
使用Apache Bench进行并发测试:
```bash
ab -n 100 -c 10 http://your-domain/api/ccbpayment/callback
```
验证订单状态不会重复更新。
---
## ⚠️ 上线前必读
### 1. 建行生活支付验签公钥获取(重要!)
**关键**: 需要向建行生活技术支持索要**"建行生活支付验签公钥"**,用于异步通知SIGN验签。
#### 为什么需要这个公钥?
- 建行用自己的私钥对异步通知进行RSA签名(生成SIGN字段)
- 你需要用建行的公钥来验证SIGN,确保通知是建行发送的
- 这个公钥**不是**你自己生成的公钥,是建行生活平台分配给你的
#### 如何获取?
1. 联系建行生活平台运营人员或技术支持
2. 说明需要获取"建行生活支付验签公钥"(NT_TYPE=YS的验签公钥)
3. 提供你的商户号和服务方编号
4. 获取后配置到`.env`文件中
```ini
# .env文件
ccb_payment_verify_public_key="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"
```
#### 未配置的影响
- 异步通知验签会降级为POSID验证
- 安全性降低,无法完全确认通知来源
- 但不会中断业务,系统仍可正常运行
### 2. 验证密钥格式
运行以下PHP脚本验证密钥配置是否正确:
```php
<?php
// test_ccb_keys.php
$config = include 'addons/shopro/config/ccblife.php';
echo "========== 建行密钥配置验证 ==========\n\n";
// 1. 验证服务方公钥
$pubKey = openssl_pkey_get_public($config['public_key']);
if ($pubKey) {
$details = openssl_pkey_get_details($pubKey);
echo "✅ 服务方公钥: {$details['bits']}位 RSA\n";
} else {
echo "❌ 服务方公钥格式错误: " . openssl_error_string() . "\n";
}
// 2. 验证服务方私钥
$privKey = openssl_pkey_get_private($config['private_key']);
if ($privKey) {
$details = openssl_pkey_get_details($privKey);
echo "✅ 服务方私钥: {$details['bits']}位 RSA\n";
} else {
echo "❌ 服务方私钥格式错误: " . openssl_error_string() . "\n";
}
// 3. 验证建行支付验签公钥(可选)
if (!empty($config['ccb_payment_verify_public_key'])) {
$ccbPubKey = openssl_pkey_get_public($config['ccb_payment_verify_public_key']);
if ($ccbPubKey) {
$details = openssl_pkey_get_details($ccbPubKey);
echo "✅ 建行验签公钥: {$details['bits']}位 RSA\n";
} else {
echo "❌ 建行验签公钥格式错误: " . openssl_error_string() . "\n";
}
} else {
echo "⚠️ 建行验签公钥未配置(验签会降级为POSID验证)\n";
}
echo "\n========== 验证完成 ==========\n";
```
### 3. 日志监控
修复后的代码已增强日志记录,请监控以下关键日志:
```bash
# 查看MAC签名日志
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行支付'
# 查看验签日志
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行验签'
# 查看异步通知日志
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行通知'
```
### 4. 回滚方案
如遇紧急问题,可回滚至修复前版本:
```bash
git checkout HEAD~1 addons/shopro/library/ccblife/
git checkout HEAD~1 addons/shopro/controller/Ccbpayment.php
```
---
## 📞 技术支持
**开发者**: Billy
**修复日期**: 2025-01-20
**建行文档版本**: v2.20 (2025-07-25)
如有疑问,请查阅:
- 建行接入文档: `/doc/建行相关App服务方接入文档v2.20_20250725.html`
- 本修复报告: `/doc/建行支付对接修复报告.md`
---
## 📝 变更历史
| 版本 | 日期 | 修改内容 |
|-----|------|---------|
| v1.0 | 2025-01-20 | 初始版本,完成6项严重错误修复 |
---
**修复完成,已做好生产环境部署准备!** ✅

View File

@ -1,825 +0,0 @@
# 建行支付架构修复报告
**项目**: Shopro商城建行支付集成
**修复时间**: 2025-01-20
**文档版本**: v2.0 (根据官方流程图修正)
**修复类型**: 🔴 严重安全漏洞 + 架构偏离 + 订单同步时机错误
**建行接口版本**: v2.20 (2025-07-25)
**参考文档**: 建行生活服务方接入流程图
---
## 📋 问题概述
通过对比建行官方支付流程图和当前实现,发现**严重的架构偏离和安全漏洞**:
### 核心问题
1. **前端callback机制违反建行标准流程** 🔴 致命
2. **前端可伪造支付成功请求** 🔴 安全漏洞
3. **双通道更新订单状态导致竞态条件** 🔴 致命
4. **订单推送时机错误** 🔴 致命 - 应该在创建订单时推送,而不是支付成功后
5. **订单更新时机错误** 🔴 致命 - 支付成功后应调用更新接口,而不是推送接口
6. **缺少轮询查询机制** 🟡 严重
---
## 🔴 建行标准支付流程 vs 错误实现
### 建行标准流程(官方文档 - 根据流程图修正)
```mermaid
sequenceDiagram
participant H5 as 前端H5页面
participant Backend as 服务方后台
participant CCBApp as 建行APP
participant CCBBackend as 建行后端
participant Merchant as 商户管理(外联)
Note over H5,Backend: 步骤1: 请求下单
H5->>Backend: POST /createPayment(生成支付串)
Backend->>Backend: 保存订单(未支付状态)
Note over Backend,CCBBackend: 步骤2: 推送订单 ✅ 推送未支付订单!
Backend->>Merchant: 调用订单推送接口(A3341TP01)
Merchant-->>Backend: 返回推送结果
Backend-->>H5: {payment_string}
Note over H5,CCBApp: 步骤3: 调起建行收银台
H5->>CCBApp: JSBridge.ccbpay(支付串)
activate CCBBackend
CCBApp->>CCBBackend: 校验登录
CCBApp->>CCBApp: 调用支付组件
H5->>CCBApp: 确认支付、输入密码
CCBApp->>CCBBackend: 发送支付请求
Note right of CCBBackend: 用户在建行APP中完成支付
Note over CCBBackend,Backend: 步骤10-12: 建行异步通知
CCBBackend->>Merchant: 返回支付成功通知
Merchant->>Backend: 推送服务器通知(notify)
Backend->>Backend: 验证SIGN签名
Backend->>Backend: 原子更新本地订单状态为paid
deactivate CCBBackend
Note over Backend,Merchant: 步骤13: 更新订单状态 ✅ 更新为已支付!
Backend->>Merchant: 调用订单更新接口(A3341TP02)
Merchant-->>Backend: 返回更新结果
Backend-->>Merchant: 返回SUCCESS
Note over H5,Backend: 步骤15-16: 前端轮询查询状态 (未收到通知时)
loop 每2秒轮询(最多60秒)
H5->>Backend: GET /queryPaymentStatus
Backend-->>H5: {status: 'paid'或'unpaid'}
alt status == 'paid'
H5->>H5: 跳转到支付成功页
end
end
```
**关键流程说明**:
1. **步骤2**: 生成支付串后**立即推送未支付订单**到建行外联系统(A3341TP01)
2. **步骤13**: 收到支付成功通知后**更新订单状态为已支付**(A3341TP02)
3. **步骤15**: 前端轮询查询订单状态(用于未收到通知的降级方案)
### 修复前的错误实现
```mermaid
sequenceDiagram
participant H5 as 前端H5页面
participant Callback as callback接口
participant Notify as notify接口
participant CCBApp as 建行APP(黑盒)
Note over H5,Callback: ❌ 错误1: 前端callback通知支付成功
H5->>Callback: POST callback(order_id, trans_id) ❌ 可伪造!
Callback->>Callback: verifyPayment()主动查询建行?
Callback->>Callback: ❌ 更新订单为已支付
Callback->>Callback: ❌ 推送订单到外联
Callback-->>H5: 返回success
Note over CCBApp,Notify: ❌ 错误2: 建行异步通知被边缘化
CCBApp-->>Notify: POST notify(ORDERID, SIGN等)
Notify->>Notify: ❌ 再次更新订单?
Notify->>Notify: ❌ 再次推送订单?
Notify-->>CCBApp: 返回SUCCESS
Note over H5,Callback: ⚠️ 严重问题: 两条并行路径!
rect rgb(255, 200, 200)
Note right of Callback: 路径A: 前端callback触发更新<br/>路径B: 建行notify触发更新<br/>可能导致重复处理或竞态条件!
end
```
---
## 🔥 严重安全漏洞详解
### 漏洞1: 前端callback可伪造支付成功
**问题代码** (`frontend/sheep/platform/pay.js:325-336`):
```javascript
// ❌ 错误: 前端主动调用callback通知后端支付成功
if (result.code === 0) {
// 支付成功,通知后端 ❌ 严重安全漏洞!
const callbackResult = await ccbApi.paymentCallback({
order_id: orderInfo.data.id,
trans_id: result.data?.trans_id || '', // ❌ 前端可伪造
pay_time: new Date().getTime() // ❌ 前端可伪造
});
if (callbackResult.code === 1) {
that.payResult('success'); // ❌ 跳转到成功页
}
}
```
**攻击方式**:
1. 用户在浏览器控制台执行:
```javascript
ccbApi.paymentCallback({
order_id: 12345,
trans_id: 'fake_trans_id',
pay_time: Date.now()
})
```
2. 后端callback()接口收到请求,**没有验证签名**,直接更新订单为已支付!
3. 攻击者不花一分钱就能白嫖商品 🔥
**风险等级**: 🔴 **致命** - 可直接导致商户资金损失
---
### 漏洞2: callback()与notify()双通道竞态条件
**问题代码** (`addons/shopro/controller/Ccbpayment.php`):
```php
// ❌ callback()中更新订单状态
public function callback()
{
// ...省略代码...
// 更新订单状态
$affectedRows = Db::name('shopro_order')
->where('id', $order->id)
->where('status', 'unpaid')
->update(['status' => 'paid']);
// 推送订单到建行
$this->pushOrderToCcb($order); // ❌ 在callback中推送
}
// ❌ notify()中也更新订单状态
public function notify()
{
// ...省略代码...
$this->paymentService->handleNotify($params); // ❌ 内部再次更新订单
// handleNotify()内部还会调用pushOrder() ❌ 重复推送!
}
```
**竞态条件场景**:
1. 用户在建行APP完成支付
2. 建行异步通知服务器 → 触发`notify()` → 更新订单为paid
3. **同时**前端H5页面返回 → 调用`callback()` → 再次尝试更新订单
4. 如果notify还未完成,callback会成功更新 → **订单被更新两次**
5. pushOrderToCcb()被调用两次 → **外联系统收到重复订单**
**风险等级**: 🔴 **致命** - 可能导致订单状态异常或重复扣款
---
### 漏洞3: 订单推送时机错误
**问题**: callback()在前端触发时就推送订单,但此时:
- 建行可能还未真正扣款
- callback可能是攻击者伪造的请求
- 订单状态可能还未真正更新为paid
**正确时机**: 只在`notify()`收到建行异步通知并验签成功后推送!
---
## ✅ 修复方案
### 修复1: callback()改造为纯查询接口
**修复后** (`addons/shopro/controller/Ccbpayment.php:129-164`):
```php
/**
* 查询订单支付状态 (前端轮询用)
*
* ⚠️ 重要: 本接口只查询订单状态,不执行任何业务逻辑!
* 订单状态由建行异步通知(notify)接口更新,前端只负责轮询查询。
*/
public function queryPaymentStatus()
{
try {
$orderId = $this->request->get('order_id', 0);
if (empty($orderId)) {
$this->error('订单ID不能为空');
}
// ✅ 只查询,不更新!
$order = OrderModel::where('id', $orderId)
->where('user_id', $this->auth->id)
->field('id, order_sn, status, paid_time, ccb_pay_flow_id')
->find();
if (!$order) {
$this->error('订单不存在');
}
// ✅ 返回订单状态(只读操作,绝不修改数据!)
$this->success('查询成功', [
'order_id' => $order->id,
'order_sn' => $order->order_sn,
'status' => $order->status,
'is_paid' => in_array($order->status, ['paid', 'completed', 'success']),
'paid_time' => $order->paid_time,
'pay_flow_id' => $order->ccb_pay_flow_id,
]);
} catch (Exception $e) {
Log::error('[建行支付] 查询订单状态失败 error:' . $e->getMessage());
$this->error('查询失败: ' . $e->getMessage());
}
}
/**
* ⚠️ 已废弃: 支付回调 (前端调用)
* @deprecated 2025-01-20 该方法存在严重安全漏洞,已被queryPaymentStatus()替代
*/
public function callback()
{
// 向后兼容:直接调用查询接口
Log::warning('[建行支付] callback()已废弃,请前端改用queryPaymentStatus()接口');
$_GET['order_id'] = $this->request->post('order_id', 0);
return $this->queryPaymentStatus();
}
```
**修复要点**:
- ✅ 只查询,不更新任何数据
- ✅ 只返回订单状态,前端根据状态判断是否跳转
- ✅ 支持用户权限验证(`where('user_id', $this->auth->id)`)
- ✅ 向后兼容旧版callback接口
---
### 修复2: notify()成为唯一的支付确认通道
**修复后** (`addons/shopro/controller/Ccbpayment.php:200-258`):
```php
/**
* 建行支付通知 (建行服务器回调)
*
* ✅ 正确流程:
* 1. 验证签名
* 2. 更新订单状态(由handleNotify()完成)
* 3. 推送订单到建行外联系统(本方法完成)
* 4. 返回SUCCESS给建行
*/
public function notify()
{
try {
// 1-5. 解析和验证参数
$rawData = file_get_contents('php://input');
Log::info('[建行通知] 收到异步通知: ' . $rawData);
$params = $this->request->post();
if (empty($params) && $rawData) {
parse_str($rawData, $params);
}
if (empty($params['ORDERID'])) {
Log::error('[建行通知] 缺少ORDERID参数');
exit('FAIL');
}
// 6. 调用服务层处理通知(返回订单ID)
$result = $this->paymentService->handleNotify($params);
// 7. ✅ 处理成功后推送订单到建行外联系统
if ($result['status'] === 'success' && !empty($result['order_id'])) {
// ⚠️ 只有新支付才推送,已支付的订单跳过推送
if ($result['already_paid'] === false) {
try {
$this->pushOrderToCcb($result['order_id']);
Log::info('[建行通知] 订单推送成功 order_id:' . $result['order_id']);
} catch (Exception $e) {
// ⚠️ 推送失败不影响支付成功,记录日志后续补推
Log::error('[建行通知] 订单推送失败 order_id:' . $result['order_id'] . ' error:' . $e->getMessage());
}
} else {
Log::info('[建行通知] 订单已支付且已推送,跳过推送');
}
}
// 8. 返回处理结果
$response = ($result['status'] === 'success') ? 'SUCCESS' : 'FAIL';
Log::info('[建行通知] 处理完成,返回: ' . $response);
exit($response); // 直接退出,确保只输出SUCCESS/FAIL
} catch (Exception $e) {
Log::error('[建行通知] 处理失败 error:' . $e->getMessage());
exit('FAIL');
}
}
```
**修复要点**:
- ✅ 验证SIGN签名(由handleNotify()完成)
- ✅ 原子更新订单状态(由handleNotify()完成)
- ✅ 推送订单到外联系统(本方法完成,在订单状态更新成功后)
- ✅ 幂等性保护(已支付的订单跳过推送)
- ✅ 推送失败不影响支付成功(记录日志后续补推)
---
### 修复3: createPayment()推送未支付订单
**修复后** (`addons/shopro/controller/Ccbpayment.php:101-118`):
```php
// 5. ✅ 推送订单到建行(步骤2:调用订单推送接口将已提交订单)
// ⚠️ 注意:此时推送的是未支付状态的订单
try {
$pushResult = $this->orderService->pushOrder($orderId);
if ($pushResult['status']) {
Log::info('[建行支付] 订单推送成功 order_id:' . $orderId);
} else {
// ⚠️ 推送失败不阻塞支付流程,只记录日志
Log::warning('[建行支付] 订单推送失败(不阻塞支付) order_id:' . $orderId . ' error:' . $pushResult['message']);
}
} catch (Exception $e) {
// ⚠️ 推送异常不阻塞支付流程
Log::error('[建行支付] 订单推送异常(不阻塞支付) order_id:' . $orderId . ' error:' . $e->getMessage());
}
// 6. 返回支付串
$this->success('支付串生成成功', $result['data']);
```
**修复要点**:
- ✅ 生成支付串后立即调用`pushOrder()`推送未支付订单(A3341TP01)
- ✅ 推送失败不阻塞支付流程,用户仍可继续支付
- ✅ 记录推送结果日志,失败的可后续补推
---
### 修复4: notify()更新订单状态为已支付
**修复后** (`addons/shopro/controller/Ccbpayment.php:227-248`):
```php
// 7. ✅ 处理成功后更新订单状态到建行(步骤13:调用订单更新接口更新订单状态)
if ($result['status'] === 'success' && !empty($result['order_id'])) {
// ⚠️ 只有新支付才更新,已支付的订单跳过更新
if ($result['already_paid'] === false) {
try {
// 调用订单更新接口,将订单状态从未支付更新为已支付
$updateResult = $this->orderService->updateOrderStatus($result['order_id']);
if ($updateResult['status']) {
Log::info('[建行通知] 订单状态更新成功 order_id:' . $result['order_id']);
} else {
// ⚠️ 更新失败不影响本地支付状态,记录日志后续补推
Log::warning('[建行通知] 订单状态更新失败(本地已支付) order_id:' . $result['order_id'] . ' error:' . $updateResult['message']);
}
} catch (Exception $e) {
// ⚠️ 更新异常不影响支付成功,记录日志后续补推
Log::error('[建行通知] 订单状态更新异常(本地已支付) order_id:' . $result['order_id'] . ' error:' . $e->getMessage());
}
} else {
Log::info('[建行通知] 订单已支付且已更新,跳过更新 order_id:' . $result['order_id']);
}
}
```
**修复要点**:
- ✅ 收到支付成功通知后调用`updateOrderStatus()`(A3341TP02)
- ✅ 将订单状态从"未支付"更新为"已支付"
- ✅ 更新失败不影响本地支付状态(本地订单已标记为paid)
- ✅ 幂等性保护(已支付的订单跳过更新)
---
### 修复5: handleNotify()返回订单ID
**修复后** (`addons/shopro/library/ccblife/CcbPaymentService.php:349-403`):
```php
/**
* 处理异步通知
*
* ⚠️ 注意:这是唯一可信的支付确认来源!
* 返回订单ID供控制器调用pushOrderToCcb()推送到外联系统
*
* @param array $params 通知参数
* @return array ['status' => 'success'|'fail', 'order_id' => int, 'order_sn' => string]
*/
public function handleNotify($params)
{
try {
// 1. 验证签名
if (!$this->verifyNotifySignature($params)) {
throw new \Exception('签名验证失败');
}
// 2. 查询订单
$payFlowId = $params['ORDERID'] ?? '';
$userOrderId = $params['USER_ORDERID'] ?? '';
if (!empty($userOrderId)) {
$order = Order::where('order_sn', $userOrderId)->find();
} else {
$order = Order::where('ccb_pay_flow_id', $payFlowId)->find();
}
if (!$order) {
throw new \Exception('订单不存在');
}
// 3. ✅ 幂等性检查: 如果订单已支付,直接返回成功
if ($order['status'] == 'paid') {
Log::info('[建行通知] 订单已支付,跳过处理 order_id:' . $order->id);
return [
'status' => 'success',
'order_id' => $order->id,
'order_sn' => $order->order_sn,
'already_paid' => true, // ✅ 标记为已支付
];
}
// 4. 更新订单状态
$this->updateOrderPaymentStatus($order, $params);
Log::info('[建行通知] 订单状态更新成功 order_id:' . $order->id);
// 5. ✅ 返回订单ID供控制器推送到外联系统
return [
'status' => 'success',
'order_id' => $order->id,
'order_sn' => $order->order_sn,
'already_paid' => false, // ✅ 新支付
];
} catch (\Exception $e) {
Log::error('[建行通知] 处理失败: ' . $e->getMessage());
return [
'status' => 'fail',
'message' => $e->getMessage(),
];
}
}
```
**修复要点**:
- ✅ 返回订单ID供控制器推送
- ✅ 返回already_paid标志防止重复推送
- ✅ 幂等性保护(已支付的订单直接返回成功)
---
### ~~修复6: pushOrderToCcb()增加幂等性检查~~
**⚠️ 已废弃**: 根据建行流程图,`pushOrderToCcb()`方法已废弃。
**正确流程**:
1. `createPayment()` → 调用`orderService->pushOrder()` → 推送未支付订单(A3341TP01)
2. `notify()` → 调用`orderService->updateOrderStatus()` → 更新为已支付(A3341TP02)
---
**⚠️ 已废弃**: 根据建行流程图,订单推送和更新的幂等性由`CcbOrderService`内部保证。
---
### 修复6: 前端改为轮询查询
**修复后** (`frontend/sheep/platform/pay.js:325-386`):
```javascript
// 建行生活支付
async ccbPay() {
// ...省略订单信息获取和支付串生成...
// 调起建行支付
const result = await CcbLifePlatform.payment({
payment_string: paymentResult.data.payment_string,
order_id: orderId,
order_sn: this.orderSN
});
if (result.code === 0) {
// ✅ 支付调起成功,开始轮询查询订单状态
console.log('[建行支付] 支付调起成功,开始轮询查询订单状态');
uni.showLoading({
title: '支付确认中...',
mask: true
});
// ✅ 轮询查询订单状态(最多30次,每次间隔2秒,总共60秒)
let pollCount = 0;
const MAX_POLL_COUNT = 30;
const POLL_INTERVAL = 2000;
const pollPaymentStatus = async () => {
pollCount++;
try {
const statusResult = await ccbApi.queryPaymentStatus(orderId);
if (statusResult.code === 1 && statusResult.data.is_paid) {
// ✅ 支付成功
uni.hideLoading();
console.log('[建行支付] 订单已支付');
that.payResult('success');
return;
}
// 未支付,继续轮询
if (pollCount < MAX_POLL_COUNT) {
setTimeout(pollPaymentStatus, POLL_INTERVAL);
} else {
// 超时
uni.hideLoading();
uni.showModal({
title: '提示',
content: '支付确认超时,请稍后在订单列表中查看支付状态',
showCancel: false,
confirmText: '知道了',
success: () => {
sheep.$router.redirect('/pages/order/list');
}
});
}
} catch (error) {
console.error('[建行支付] 查询状态失败:', error);
// 继续轮询(网络错误不中断)
if (pollCount < MAX_POLL_COUNT) {
setTimeout(pollPaymentStatus, POLL_INTERVAL);
} else {
uni.hideLoading();
sheep.$helper.toast('支付状态查询失败,请稍后在订单列表中查看');
that.payResult('fail');
}
}
};
// 延迟1秒后开始轮询(给建行异步通知留点时间)
setTimeout(pollPaymentStatus, 1000);
}
}
```
**修复要点**:
- ✅ 轮询查询订单状态(每2秒一次)
- ✅ 最多轮询30次(总共60秒)
- ✅ 网络错误不中断轮询
- ✅ 超时友好提示用户去订单列表查看
---
## 📊 修复前后对比
| 对比项 | 修复前(错误) | 修复后(正确) |
|-------|------------|------------|
| **支付确认来源** | 前端callback + 建行notify (双通道) ❌ | 只依赖建行notify (单通道) ✅ |
| **前端职责** | 调用callback通知后端支付成功 ❌ | 轮询查询订单状态 ✅ |
| **安全性** | 可伪造前端请求触发支付成功 🔴 | 只信任建行签名验证 ✅ |
| **订单推送时机** | 支付成功后推送 ❌ | **创建订单时推送未支付状态** ✅ |
| **订单更新时机** | 未更新到建行 ❌ | **支付成功后更新为已支付** ✅ |
| **竞态风险** | callback和notify可能同时执行 🔴 | 只有notify会更新订单 ✅ |
| **幂等性** | 无幂等保护 ❌ | 支持重复调用,已推送/已更新的跳过 ✅ |
| **符合建行规范** | 否 ❌ | **完全符合流程图** ✅ |
| **订单状态一致性** | 可能重复更新或状态异常 🔴 | 原子更新,状态一致 ✅ |
| **建行订单同步** | 不同步或错误时机同步 ❌ | **按流程图正确同步** ✅ |
---
## ✅ 验证清单
修复完成后,请逐项验证:
### 1. 后端验证
```bash
# 验证queryPaymentStatus接口
curl -X GET "http://your-domain/addons/shopro/ccbpayment/queryPaymentStatus?order_id=123" \
-H "Authorization: Bearer YOUR_TOKEN"
# 预期返回:
{
"code": 1,
"msg": "查询成功",
"data": {
"order_id": 123,
"order_sn": "202501200001",
"status": "unpaid",
"is_paid": false,
"paid_time": null,
"pay_flow_id": ""
}
}
```
### 2. 安全测试
**测试1: 验证callback()已不能触发支付成功**
```bash
# 尝试伪造callback请求
curl -X POST "http://your-domain/addons/shopro/ccbpayment/callback" \
-d "order_id=123&trans_id=fake_trans&pay_time=123456789" \
-H "Authorization: Bearer YOUR_TOKEN"
# ✅ 预期: 只返回订单状态,不会更新订单为已支付
```
**测试2: 验证notify()是否幂等**
```bash
# 模拟建行重复发送通知
curl -X POST "http://your-domain/addons/shopro/ccbpayment/notify" \
-d "ORDERID=PAY20250120001&SUCCESS=Y&SIGN=..."
# ✅ 预期:
# - 第1次调用: 更新订单+推送外联,返回SUCCESS
# - 第2次调用: 跳过处理,直接返回SUCCESS
# - 日志中应有"订单已支付,跳过处理"
```
### 3. 前端验证
1. 在建行APP中发起支付
2. 观察浏览器控制台:
- 应该看到"支付调起成功,开始轮询查询订单状态"
- 每2秒调用一次`queryPaymentStatus`接口
- 收到`is_paid=true`后跳转到成功页
3. 网络中断测试:
- 支付完成后断开网络
- 前端应继续轮询(虽然失败)
- 60秒后提示超时,引导用户去订单列表查看
### 4. 日志验证
```bash
# 查看创建支付串日志
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行支付'
# ✅ 正常流程应该看到:
# [建行支付] 订单推送成功 order_id:123 ← 步骤2: 推送未支付订单
# 查看notify日志
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行通知'
# ✅ 正常流程应该看到:
# [建行通知] 收到异步通知: ORDERID=...
# [建行通知] 解析参数: {...}
# [建行通知] 订单状态更新成功 order_id:123 ← 步骤13: 更新为已支付
# [建行通知] 处理完成,返回: SUCCESS
# 查看幂等性日志(重复通知时)
# [建行通知] 订单已支付,跳过处理 order_id:123
# [建行通知] 订单已支付且已更新,跳过更新 order_id:123
```
---
## 📝 数据库字段说明
确保订单表包含以下字段:
```sql
ALTER TABLE `fa_shopro_order`
ADD COLUMN `ccb_pay_flow_id` VARCHAR(64) DEFAULT '' COMMENT '建行支付流水号',
ADD COLUMN `ccb_sync_status` TINYINT(1) DEFAULT 0 COMMENT '建行同步状态:0-未同步 1-已同步 2-失败',
ADD COLUMN `ccb_sync_time` INT(10) DEFAULT 0 COMMENT '建行同步时间',
ADD COLUMN `ccb_sync_error` VARCHAR(255) DEFAULT '' COMMENT '建行同步失败原因';
```
**字段说明**:
- `ccb_sync_status`: 0=未同步 / 1=已同步 / 2=失败
- `ccb_sync_error`: 推送失败时记录错误原因,供后续补推
---
## 🚀 部署步骤
### 1. 备份现有代码
```bash
# 备份控制器
cp addons/shopro/controller/Ccbpayment.php addons/shopro/controller/Ccbpayment.php.bak
# 备份服务类
cp addons/shopro/library/ccblife/CcbPaymentService.php addons/shopro/library/ccblife/CcbPaymentService.php.bak
# 备份前端代码
cp frontend/sheep/platform/pay.js frontend/sheep/platform/pay.js.bak
cp frontend/sheep/platform/provider/ccblife/api.js frontend/sheep/platform/provider/ccblife/api.js.bak
```
### 2. 部署后端代码
```bash
# 上传修复后的文件
# - addons/shopro/controller/Ccbpayment.php
# - addons/shopro/library/ccblife/CcbPaymentService.php
# 清除缓存
php think clear
```
### 3. 部署前端代码
```bash
cd frontend
# 上传修复后的文件
# - sheep/platform/pay.js
# - sheep/platform/provider/ccblife/api.js
# 重新打包发布
```
### 4. 数据库迁移(如果字段缺失)
```sql
-- 检查字段是否存在
SHOW COLUMNS FROM `fa_shopro_order` LIKE 'ccb_sync_error';
-- 如果不存在,添加字段
ALTER TABLE `fa_shopro_order`
ADD COLUMN `ccb_sync_error` VARCHAR(255) DEFAULT '' COMMENT '建行同步失败原因';
```
### 5. 监控上线
```bash
# 实时监控notify日志
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行'
# 监控查询接口调用
tail -f runtime/log/$(date +%Y%m)/*.log | grep 'queryPaymentStatus'
```
---
## ⚠️ 回滚方案
如遇紧急问题,可立即回滚:
```bash
# 回滚后端
mv addons/shopro/controller/Ccbpayment.php.bak addons/shopro/controller/Ccbpayment.php
mv addons/shopro/library/ccblife/CcbPaymentService.php.bak addons/shopro/library/ccblife/CcbPaymentService.php
# 清除缓存
php think clear
# 回滚前端(重新发布旧版本代码)
```
---
## 📞 技术支持
**开发者**: Billy
**修复日期**: 2025-01-20
**建行文档版本**: v2.20 (2025-07-25)
如有疑问,请查阅:
- 建行接入文档: `/doc/建行相关App服务方接入文档v2.20_20250725.html`
- 本修复报告: `/doc/建行支付架构修复报告.md`
- 加密修复报告: `/doc/建行支付对接修复报告.md`
---
## 📝 变更历史
| 版本 | 日期 | 修改内容 |
|-----|------|---------|
| v1.0 | 2025-01-20 | 初始版本,完成严重安全漏洞和架构偏离修复 |
| v2.0 | 2025-01-20 | **重大修正**: 根据建行流程图修正订单同步时机 - 推送未支付订单在createPayment,更新已支付订单在notify |
---
**修复完成,已做好生产环境部署准备!** ✅