架构修复

This commit is contained in:
Billy 2025-10-20 17:10:16 +08:00
parent 4b729e6e21
commit 639732e6d1
2 changed files with 166 additions and 219 deletions

View File

@ -27,7 +27,7 @@ class Ccbpayment extends Common
* 不需要登录的方法 (支付回调不需要登录)
* @var array
*/
protected $noNeedLogin = ['callback', 'notify'];
protected $noNeedLogin = ['notify'];
/**
* 不需要权限的方法
@ -98,7 +98,23 @@ class Ccbpayment extends Common
$this->error('支付串生成失败: ' . $result['message']);
}
// 5. 直接返回支付串(不再重复保存数据库!)
// 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']);
} catch (Exception $e) {
@ -163,22 +179,6 @@ class Ccbpayment extends Common
}
}
/**
* ⚠️ 已废弃: 支付回调 (前端调用)
*
* @deprecated 2025-01-20 该方法存在严重安全漏洞,已被queryPaymentStatus()替代
* @see queryPaymentStatus()
*/
public function callback()
{
// 向后兼容:直接调用查询接口
Log::warning('[建行支付] callback()已废弃,请前端改用queryPaymentStatus()接口');
// 将POST的order_id转为GET参数
$_GET['order_id'] = $this->request->post('order_id', 0);
return $this->queryPaymentStatus();
}
/**
* 建行支付通知 (建行服务器回调)
@ -224,19 +224,26 @@ class Ccbpayment extends Common
// 6. 调用支付服务处理通知(返回订单ID)
$result = $this->paymentService->handleNotify($params);
// 7. 处理成功后推送订单到建行外联系统
// 7. ✅ 处理成功后更新订单状态到建行(步骤13:调用订单更新接口更新订单状态)
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']);
// 调用订单更新接口,将订单状态从未支付更新为已支付
$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());
// ⚠️ 更新异常不影响支付成功,记录日志后续补推
Log::error('[建行通知] 订单状态更新异常(本地已支付) order_id:' . $result['order_id'] . ' error:' . $e->getMessage());
}
} else {
Log::info('[建行通知] 订单已支付且已推送,跳过推送 order_id:' . $result['order_id']);
Log::info('[建行通知] 订单已支付且已更新,跳过更新 order_id:' . $result['order_id']);
}
}
@ -258,104 +265,15 @@ class Ccbpayment extends Common
}
/**
* 推送订单到建行外联系统
* ⚠️ 已废弃: 推送订单到建行外联系统
*
* ⚠️ 重要: 只在notify()支付成功后调用!
* 幂等性: 支持重复调用,已推送的订单会跳过
*
* @param int $orderId 订单ID
* @return void
* @throws Exception 推送失败时抛出异常
* @deprecated 2025-01-20 根据建行流程图,订单推送应在createPayment()时完成,此方法已废弃
* @see createPayment() 步骤5: 推送未支付订单
* @see notify() 步骤7: 更新订单为已支付
*/
private function pushOrderToCcb($orderId)
{
try {
// ✅ 重新查询订单,确保状态已更新
$order = OrderModel::find($orderId);
if (!$order) {
throw new Exception('订单不存在');
}
// ✅ 验证订单状态
if ($order->status !== 'paid') {
throw new Exception('订单状态不正确(status=' . $order->status . '),无法推送');
}
// ✅ 幂等性检查: 如果已推送成功,跳过
if ($order->ccb_sync_status == 1) {
Log::info('[建行推送] 订单已推送,跳过 order_id:' . $orderId);
return;
}
// 获取订单商品列表
$orderItems = Db::name('shopro_order_item')
->where('order_id', $order->id)
->field('goods_id, goods_sku_text, goods_title, goods_price, goods_num, discount_fee')
->select();
$goodsList = [];
foreach ($orderItems as $item) {
$goodsList[] = [
'goods_id' => $item['goods_id'],
'goods_name' => $item['goods_title'],
'goods_sku' => $item['goods_sku_text'],
'goods_price' => $item['goods_price'],
'goods_num' => $item['goods_num'],
'discount_amount' => $item['discount_fee'] ?? 0
];
}
// 获取用户的建行用户ID
$user = Db::name('user')->where('id', $order->user_id)->field('ccb_user_id')->find();
// 构造订单数据 (使用Shopro实际字段名)
$orderData = [
'id' => $order->id,
'order_sn' => $order->order_sn,
'ccb_user_id' => $user['ccb_user_id'] ?? '',
'total_amount' => $order->total_amount, // 订单总金额
'pay_amount' => $order->total_fee, // 实际支付金额
'discount_amount' => $order->discount_fee, // 优惠金额
'status' => $order->status, // Shopro使用status枚举
'refund_status' => $order->aftersale_status ?? 0, // 售后状态
'create_time' => $order->createtime, // Shopro使用秒级时间戳
'paid_time' => $order->paid_time, // 支付时间
'ccb_pay_flow_id' => $order->ccb_pay_flow_id,
'goods_list' => $goodsList,
];
// 推送到建行
$result = $this->orderService->pushOrder($order->id);
if (!$result['status']) {
// ✅ 推送失败: 更新状态为失败,记录错误原因
OrderModel::where('id', $orderId)->update([
'ccb_sync_status' => 2, // 2=失败
'ccb_sync_error' => $result['message'] ?? '未知错误',
'ccb_sync_time' => time(),
]);
throw new Exception($result['message'] ?? '推送失败');
}
// ✅ 推送成功: 更新同步状态
OrderModel::where('id', $orderId)->update([
'ccb_sync_status' => 1, // 1=成功
'ccb_sync_time' => time(),
'ccb_sync_error' => '', // 清空错误信息
]);
Log::info('[建行推送] 订单推送成功 order_id:' . $order->id . ' order_sn:' . $order->order_sn);
} catch (Exception $e) {
// ✅ 记录失败原因,供后续补推
OrderModel::where('id', $orderId)->update([
'ccb_sync_status' => 2, // 失败状态
'ccb_sync_error' => $e->getMessage(),
]);
throw $e; // 向上抛出异常
}
Log::warning('[建行支付] pushOrderToCcb()已废弃,请使用orderService->pushOrder()或updateOrderStatus()');
}
/**

View File

@ -2,9 +2,10 @@
**项目**: Shopro商城建行支付集成
**修复时间**: 2025-01-20
**文档版本**: v1.0
**修复类型**: 🔴 严重安全漏洞 + 架构偏离
**文档版本**: v2.0 (根据官方流程图修正)
**修复类型**: 🔴 严重安全漏洞 + 架构偏离 + 订单同步时机错误
**建行接口版本**: v2.20 (2025-07-25)
**参考文档**: 建行生活服务方接入流程图
---
@ -17,14 +18,15 @@
1. **前端callback机制违反建行标准流程** 🔴 致命
2. **前端可伪造支付成功请求** 🔴 安全漏洞
3. **双通道更新订单状态导致竞态条件** 🔴 致命
4. **订单推送时机错误** 🟡 严重
5. **缺少轮询查询机制** 🟡 严重
4. **订单推送时机错误** 🔴 致命 - 应该在创建订单时推送,而不是支付成功后
5. **订单更新时机错误** 🔴 致命 - 支付成功后应调用更新接口,而不是推送接口
6. **缺少轮询查询机制** 🟡 严重
---
## 🔴 建行标准支付流程 vs 错误实现
### 建行标准流程(官方文档)
### 建行标准流程(官方文档 - 根据流程图修正)
```mermaid
sequenceDiagram
@ -32,25 +34,39 @@ sequenceDiagram
participant Backend as 服务方后台
participant CCBApp as 建行APP
participant CCBBackend as 建行后端
participant Merchant as 商户管理(外联)
Note over H5,Backend: 步骤1: 生成支付串
H5->>Backend: POST /createPayment
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: 步骤2: 调起建行收银台
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: 步骤3: 建行异步通知 ✅ 唯一可信的支付确认!
CCBBackend->>Backend: POST notify(ORDERID, SIGN等)
Note over CCBBackend,Backend: 步骤10-12: 建行异步通知
CCBBackend->>Merchant: 返回支付成功通知
Merchant->>Backend: 推送服务器通知(notify)
Backend->>Backend: 验证SIGN签名
Backend->>Backend: 原子更新订单状态为paid
Backend->>Backend: 推送订单到外联系统
Backend-->>CCBBackend: 返回SUCCESS
Backend->>Backend: 原子更新本地订单状态为paid
deactivate CCBBackend
Note over H5,Backend: 步骤4: 前端轮询查询状态 ✅ 只查询,不更新!
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'}
@ -60,6 +76,11 @@ sequenceDiagram
end
```
**关键流程说明**:
1. **步骤2**: 生成支付串后**立即推送未支付订单**到建行外联系统(A3341TP01)
2. **步骤13**: 收到支付成功通知后**更新订单状态为已支付**(A3341TP02)
3. **步骤15**: 前端轮询查询订单状态(用于未收到通知的降级方案)
### 修复前的错误实现
```mermaid
@ -327,7 +348,76 @@ public function notify()
---
### 修复3: handleNotify()返回订单ID
### 修复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`):
@ -404,84 +494,21 @@ public function handleNotify($params)
---
### 修复4: pushOrderToCcb()增加幂等性检查
### ~~修复6: pushOrderToCcb()增加幂等性检查~~
**修复后** (`addons/shopro/controller/Ccbpayment.php:270-359`):
**⚠️ 已废弃**: 根据建行流程图,`pushOrderToCcb()`方法已废弃。
```php
/**
* 推送订单到建行外联系统
*
* ⚠️ 重要: 只在notify()支付成功后调用!
* ✅ 幂等性: 支持重复调用,已推送的订单会跳过
*
* @param int $orderId 订单ID
* @throws Exception 推送失败时抛出异常
*/
private function pushOrderToCcb($orderId)
{
try {
// ✅ 重新查询订单,确保状态已更新
$order = OrderModel::find($orderId);
if (!$order) {
throw new Exception('订单不存在');
}
// ✅ 验证订单状态
if ($order->status !== 'paid') {
throw new Exception('订单状态不正确,无法推送');
}
// ✅ 幂等性检查: 如果已推送成功,跳过
if ($order->ccb_sync_status == 1) {
Log::info('[建行推送] 订单已推送,跳过 order_id:' . $orderId);
return;
}
// 推送到建行
$result = $this->orderService->pushOrder($order->id);
if (!$result['status']) {
// ✅ 推送失败: 更新状态为失败,记录错误原因
OrderModel::where('id', $orderId)->update([
'ccb_sync_status' => 2, // 2=失败
'ccb_sync_error' => $result['message'] ?? '未知错误',
'ccb_sync_time' => time(),
]);
throw new Exception($result['message'] ?? '推送失败');
}
// ✅ 推送成功: 更新同步状态
OrderModel::where('id', $orderId)->update([
'ccb_sync_status' => 1, // 1=成功
'ccb_sync_time' => time(),
'ccb_sync_error' => '',
]);
Log::info('[建行推送] 订单推送成功 order_id:' . $orderId);
} catch (Exception $e) {
// ✅ 记录失败原因,供后续补推
OrderModel::where('id', $orderId)->update([
'ccb_sync_status' => 2,
'ccb_sync_error' => $e->getMessage(),
]);
throw $e;
}
}
```
**修复要点**:
- ✅ 幂等性检查(ccb_sync_status == 1时跳过)
- ✅ 状态验证(只推送已支付的订单)
- ✅ 失败原因记录(ccb_sync_error字段)
- ✅ 异常向上抛出(但不影响notify返回SUCCESS)
**正确流程**:
1. `createPayment()` → 调用`orderService->pushOrder()` → 推送未支付订单(A3341TP01)
2. `notify()` → 调用`orderService->updateOrderStatus()` → 更新为已支付(A3341TP02)
---
### 修复5: 前端改为轮询查询
**⚠️ 已废弃**: 根据建行流程图,订单推送和更新的幂等性由`CcbOrderService`内部保证。
---
### 修复6: 前端改为轮询查询
**修复后** (`frontend/sheep/platform/pay.js:325-386`):
@ -576,11 +603,13 @@ async ccbPay() {
| **支付确认来源** | 前端callback + 建行notify (双通道) ❌ | 只依赖建行notify (单通道) ✅ |
| **前端职责** | 调用callback通知后端支付成功 ❌ | 轮询查询订单状态 ✅ |
| **安全性** | 可伪造前端请求触发支付成功 🔴 | 只信任建行签名验证 ✅ |
| **订单推送时机** | callback()中推送 ❌ | notify()成功后推送 ✅ |
| **订单推送时机** | 支付成功后推送 ❌ | **创建订单时推送未支付状态** ✅ |
| **订单更新时机** | 未更新到建行 ❌ | **支付成功后更新为已支付** ✅ |
| **竞态风险** | callback和notify可能同时执行 🔴 | 只有notify会更新订单 ✅ |
| **幂等性** | 无幂等保护 ❌ | 支持重复调用,已推送的跳过 ✅ |
| **符合建行规范** | 否 ❌ | ✅ |
| **幂等性** | 无幂等保护 ❌ | 支持重复调用,已推送/已更新的跳过 ✅ |
| **符合建行规范** | 否 ❌ | **完全符合流程图** ✅ |
| **订单状态一致性** | 可能重复更新或状态异常 🔴 | 原子更新,状态一致 ✅ |
| **建行订单同步** | 不同步或错误时机同步 ❌ | **按流程图正确同步** ✅ |
---
@ -652,25 +681,24 @@ curl -X POST "http://your-domain/addons/shopro/ccbpayment/notify" \
### 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
# [建行通知] 订单推送成功 order_id:123
# [建行通知] 订单状态更新成功 order_id:123 ← 步骤13: 更新为已支付
# [建行通知] 处理完成,返回: SUCCESS
# 查看推送日志
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行推送'
# ✅ 正常流程应该看到:
# [建行推送] 订单推送成功 order_id:123 order_sn:202501200001
# 查看幂等性日志(重复通知时)
# [建行通知] 订单已支付,跳过处理 order_id:123
# [建行推送] 订单已推送,跳过 order_id:123
# [建行通知] 订单已支付且已更新,跳过更新 order_id:123
```
---
@ -790,6 +818,7 @@ php think clear
| 版本 | 日期 | 修改内容 |
|-----|------|---------|
| v1.0 | 2025-01-20 | 初始版本,完成严重安全漏洞和架构偏离修复 |
| v2.0 | 2025-01-20 | **重大修正**: 根据建行流程图修正订单同步时机 - 推送未支付订单在createPayment,更新已支付订单在notify |
---