# 建行支付架构修复报告 **项目**: Shopro商城建行支付集成 **修复时间**: 2025-01-20 **文档版本**: v1.0 **修复类型**: 🔴 严重安全漏洞 + 架构偏离 **建行接口版本**: v2.20 (2025-07-25) --- ## 📋 问题概述 通过对比建行官方支付流程图和当前实现,发现**严重的架构偏离和安全漏洞**: ### 核心问题 1. **前端callback机制违反建行标准流程** 🔴 致命 2. **前端可伪造支付成功请求** 🔴 安全漏洞 3. **双通道更新订单状态导致竞态条件** 🔴 致命 4. **订单推送时机错误** 🟡 严重 5. **缺少轮询查询机制** 🟡 严重 --- ## 🔴 建行标准支付流程 vs 错误实现 ### 建行标准流程(官方文档) ```mermaid sequenceDiagram participant H5 as 前端H5页面 participant Backend as 服务方后台 participant CCBApp as 建行APP participant CCBBackend as 建行后端 Note over H5,Backend: 步骤1: 生成支付串 H5->>Backend: POST /createPayment Backend-->>H5: {payment_string} Note over H5,CCBApp: 步骤2: 调起建行收银台 H5->>CCBApp: JSBridge.ccbpay(支付串) activate CCBBackend Note right of CCBBackend: 用户在建行APP中完成支付 Note over CCBBackend,Backend: 步骤3: 建行异步通知 ✅ 唯一可信的支付确认! CCBBackend->>Backend: POST notify(ORDERID, SIGN等) Backend->>Backend: 验证SIGN签名 Backend->>Backend: 原子更新订单状态为paid Backend->>Backend: 推送订单到外联系统 Backend-->>CCBBackend: 返回SUCCESS deactivate CCBBackend Note over H5,Backend: 步骤4: 前端轮询查询状态 ✅ 只查询,不更新! loop 每2秒轮询(最多60秒) H5->>Backend: GET /queryPaymentStatus Backend-->>H5: {status: 'paid'或'unpaid'} alt status == 'paid' H5->>H5: 跳转到支付成功页 end end ``` ### 修复前的错误实现 ```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触发更新
路径B: 建行notify触发更新
可能导致重复处理或竞态条件! 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: 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标志防止重复推送 - ✅ 幂等性保护(已支付的订单直接返回成功) --- ### 修复4: pushOrderToCcb()增加幂等性检查 **修复后** (`addons/shopro/controller/Ccbpayment.php:270-359`): ```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) --- ### 修复5: 前端改为轮询查询 **修复后** (`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()成功后推送 ✅ | | **竞态风险** | 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 # 查看notify日志 tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行通知' # ✅ 正常流程应该看到: # [建行通知] 收到异步通知: ORDERID=... # [建行通知] 解析参数: {...} # [建行通知] 订单状态更新成功 order_id:123 # [建行通知] 订单推送成功 order_id:123 # [建行通知] 处理完成,返回: SUCCESS # 查看推送日志 tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行推送' # ✅ 正常流程应该看到: # [建行推送] 订单推送成功 order_id:123 order_sn:202501200001 # 查看幂等性日志(重复通知时) # [建行通知] 订单已支付,跳过处理 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 | 初始版本,完成严重安全漏洞和架构偏离修复 | --- **修复完成,已做好生产环境部署准备!** ✅