From 639732e6d1947c585ffabc30f58f77cd9f1c1903 Mon Sep 17 00:00:00 2001 From: Billy <641833868@qq.com> Date: Mon, 20 Oct 2025 17:10:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9E=B6=E6=9E=84=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/shopro/controller/Ccbpayment.php | 156 ++++------------ doc/建行支付架构修复报告.md | 229 +++++++++++++----------- 2 files changed, 166 insertions(+), 219 deletions(-) diff --git a/addons/shopro/controller/Ccbpayment.php b/addons/shopro/controller/Ccbpayment.php index b97ce47..42ded4d 100644 --- a/addons/shopro/controller/Ccbpayment.php +++ b/addons/shopro/controller/Ccbpayment.php @@ -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()'); } /** diff --git a/doc/建行支付架构修复报告.md b/doc/建行支付架构修复报告.md index 9cfb2f2..36e8cca 100644 --- a/doc/建行支付架构修复报告.md +++ b/doc/建行支付架构修复报告.md @@ -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 | ---