diff --git a/addons/shopro/controller/Ccbpayment.php b/addons/shopro/controller/Ccbpayment.php index cc47a1a..b97ce47 100644 --- a/addons/shopro/controller/Ccbpayment.php +++ b/addons/shopro/controller/Ccbpayment.php @@ -109,114 +109,77 @@ class Ccbpayment extends Common } /** - * 支付回调 (前端调用) + * 查询订单支付状态 (前端轮询用) * - * 说明: - * 前端调起支付后,建行App会跳转回H5页面 - * H5页面需要调用此接口通知后端支付成功 + * ⚠️ 重要说明: + * 本接口只查询订单状态,不执行任何业务逻辑! + * 订单状态由建行异步通知(notify)接口更新,前端只负责轮询查询。 + * + * 修改原因: + * 原callback()方法存在严重安全漏洞: + * 1. 前端可伪造支付成功请求 + * 2. 与notify()形成双通道,存在竞态条件 + * 3. 违反建行标准支付流程 + * + * 正确流程: + * 前端调起支付 → 建行处理 → 建行异步通知notify() → 前端轮询本接口查询状态 * * @return void */ - public function callback() + public function queryPaymentStatus() { try { - // 1. 获取参数 - $orderId = $this->request->post('order_id', 0); - $transId = $this->request->post('trans_id', ''); - $payTime = $this->request->post('pay_time', ''); + // 1. 获取订单ID + $orderId = $this->request->get('order_id', 0); if (empty($orderId)) { $this->error('订单ID不能为空'); } - // 2. 查询订单 - $order = OrderModel::where('id', $orderId)->find(); + // 2. 查询订单(只查询,不更新!) + $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('订单不存在'); } - // 3. 检查订单状态 - if ($order['status'] == 'paid' || $order['status'] == 'completed') { - $this->success('订单已支付', [ - 'order_id' => $order->id, - 'order_sn' => $order->order_sn, - 'status' => $order['status'], - ]); - return; - } - - // 4. 验证支付结果 (调用建行查询接口) - $verifyResult = $this->paymentService->verifyPayment($order->order_sn); - - if (!$verifyResult) { - $this->error('支付验证失败,请稍后再试'); - } - - // 5. 更新订单状态(防止重复支付) - Db::startTrans(); - try { - // ⚠️ 使用原子性更新,防止并发重复支付 - $affectedRows = Db::name('shopro_order') - ->where('id', $order->id) - ->where('status', 'unpaid') // 只更新未支付的订单 - ->update([ - 'status' => 'paid', - 'paid_time' => time() * 1000, // Shopro使用毫秒时间戳 - 'updatetime' => time() - ]); - - if ($affectedRows === 0) { - // 订单状态不正确或已支付,回滚事务 - Db::rollback(); - - // 检查订单当前状态 - $order->refresh(); - if ($order->status === 'paid') { - // 订单已支付,直接返回成功 - $this->success('订单已支付', [ - 'order_id' => $order->id, - 'order_sn' => $order->order_sn, - 'status' => 'paid', - ]); - return; - } else { - throw new Exception('订单状态异常,无法更新为已支付'); - } - } - - // 6. 推送订单状态到建行 - $this->pushOrderToCcb($order); - - // 7. 更新支付日志 - $this->updatePaymentLog($order->ccb_pay_flow_id, [ - 'status' => 1, - 'pay_time' => time(), - 'trans_id' => $transId, - ]); - - Db::commit(); - - Log::info('[建行支付] 支付成功 order_id:' . $order->id . ' order_sn:' . $order->order_sn . ' trans_id:' . $transId); - - $this->success('支付成功', [ - 'order_id' => $order->id, - 'order_sn' => $order->order_sn, - 'status' => 'paid', - ]); - - } catch (Exception $e) { - Db::rollback(); - throw $e; - } + // 3. 返回订单状态(只读操作,绝不修改数据!) + $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('[建行支付] 支付回调失败 order_id:' . ($orderId ?? 0) . ' error:' . $e->getMessage()); + Log::error('[建行支付] 查询订单状态失败 order_id:' . ($orderId ?? 0) . ' error:' . $e->getMessage()); - $this->error('支付处理失败: ' . $e->getMessage()); + $this->error('查询失败: ' . $e->getMessage()); } } + /** + * ⚠️ 已废弃: 支付回调 (前端调用) + * + * @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(); + } + /** * 建行支付通知 (建行服务器回调) * @@ -226,6 +189,12 @@ class Ccbpayment extends Common * * ⚠️ 重要:此接口为建行服务器异步回调,必须返回纯文本 'SUCCESS' 或 'FAIL' * + * ✅ 正确流程: + * 1. 验证签名 + * 2. 更新订单状态(由handleNotify()完成) + * 3. 推送订单到建行外联系统(本方法完成) + * 4. 返回SUCCESS给建行 + * * @return void */ public function notify() @@ -249,20 +218,36 @@ class Ccbpayment extends Common // 5. 验证必需参数 if (empty($params['ORDERID'])) { Log::error('[建行通知] 缺少ORDERID参数'); - echo 'FAIL'; - return; + exit('FAIL'); } - // 6. 调用支付服务处理通知 + // 6. 调用支付服务处理通知(返回订单ID) $result = $this->paymentService->handleNotify($params); - // 7. 返回处理结果 + // 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('[建行通知] 订单已支付且已推送,跳过推送 order_id:' . $result['order_id']); + } + } + + // 8. 返回处理结果 // ⚠️ 重要: 必须使用exit直接退出,防止ThinkPHP框架追加额外内容 // 建行要求返回纯文本 'SUCCESS' 或 'FAIL',任何额外字符都会导致建行认为通知失败 - Log::info('[建行通知] 处理完成,返回: ' . strtoupper($result)); + $response = ($result['status'] === 'success') ? 'SUCCESS' : 'FAIL'; + Log::info('[建行通知] 处理完成,返回: ' . $response); // 直接退出,确保只输出SUCCESS/FAIL - exit(strtoupper($result)); + exit($response); } catch (Exception $e) { Log::error('[建行通知] 处理失败 error:' . $e->getMessage()); @@ -273,62 +258,103 @@ class Ccbpayment extends Common } /** - * 推送订单到建行 + * 推送订单到建行外联系统 * - * @param object $order 订单对象 + * ⚠️ 重要: 只在notify()支付成功后调用! + * ✅ 幂等性: 支持重复调用,已推送的订单会跳过 + * + * @param int $orderId 订单ID * @return void + * @throws Exception 推送失败时抛出异常 */ - private function pushOrderToCcb($order) + private function pushOrderToCcb($orderId) { - // 获取订单商品列表 - $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(); + try { + // ✅ 重新查询订单,确保状态已更新 + $order = OrderModel::find($orderId); + if (!$order) { + throw new Exception('订单不存在'); + } - $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 + // ✅ 验证订单状态 + 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, ]; - } - // 获取用户的建行用户ID - $user = Db::name('user')->where('id', $order->user_id)->field('ccb_user_id')->find(); + // 推送到建行 + $result = $this->orderService->pushOrder($order->id); - // 构造订单数据 (使用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, - ]; + if (!$result['status']) { + // ✅ 推送失败: 更新状态为失败,记录错误原因 + OrderModel::where('id', $orderId)->update([ + 'ccb_sync_status' => 2, // 2=失败 + 'ccb_sync_error' => $result['message'] ?? '未知错误', + 'ccb_sync_time' => time(), + ]); - // 推送到建行 - $result = $this->orderService->pushOrder($order->id); + throw new Exception($result['message'] ?? '推送失败'); + } - if (!$result['status']) { - Log::warning('[建行推送] 订单推送失败 order_id:' . $order->id . ' error:' . ($result['message'] ?? '')); - } else { - // 更新同步状态 - $order->ccb_sync_status = 1; - $order->ccb_sync_time = time(); - $order->save(); + // ✅ 推送成功: 更新同步状态 + 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; // 向上抛出异常 } } diff --git a/addons/shopro/library/ccblife/CcbPaymentService.php b/addons/shopro/library/ccblife/CcbPaymentService.php index 80b6066..c0cc2a9 100644 --- a/addons/shopro/library/ccblife/CcbPaymentService.php +++ b/addons/shopro/library/ccblife/CcbPaymentService.php @@ -340,8 +340,11 @@ class CcbPaymentService * 处理异步通知 * 建行支付异步通知处理 * + * ⚠️ 注意:这是唯一可信的支付确认来源! + * 返回订单ID供控制器调用pushOrderToCcb()推送到外联系统 + * * @param array $params 通知参数 - * @return string 'success' 或 'fail' + * @return array ['status' => 'success'|'fail', 'order_id' => int, 'order_sn' => string] */ public function handleNotify($params) { @@ -366,22 +369,36 @@ class CcbPaymentService throw new \Exception('订单不存在'); } - // 如果订单已支付,直接返回成功 + // 如果订单已支付,直接返回成功(幂等性) if ($order['status'] == 'paid') { - return 'success'; + Log::info('[建行通知] 订单已支付,跳过处理 order_id:' . $order->id); + return [ + 'status' => 'success', + 'order_id' => $order->id, + 'order_sn' => $order->order_sn, + 'already_paid' => true, // 标记为已支付 + ]; } // 更新订单状态 $this->updateOrderPaymentStatus($order, $params); - // 同步到建行 - $this->orderService->pushOrder($order['id']); + Log::info('[建行通知] 订单状态更新成功 order_id:' . $order->id . ' order_sn:' . $order->order_sn); - return 'success'; + // ✅ 返回订单ID供控制器推送到外联系统 + return [ + 'status' => 'success', + 'order_id' => $order->id, + 'order_sn' => $order->order_sn, + 'already_paid' => false, // 新支付 + ]; } catch (\Exception $e) { - Log::error('建行支付异步通知处理失败: ' . $e->getMessage()); - return 'fail'; + Log::error('[建行通知] 处理失败: ' . $e->getMessage()); + return [ + 'status' => 'fail', + 'message' => $e->getMessage(), + ]; } } diff --git a/doc/建行支付架构修复报告.md b/doc/建行支付架构修复报告.md new file mode 100644 index 0000000..9cfb2f2 --- /dev/null +++ b/doc/建行支付架构修复报告.md @@ -0,0 +1,796 @@ +# 建行支付架构修复报告 + +**项目**: 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 | 初始版本,完成严重安全漏洞和架构偏离修复 | + +--- + +**修复完成,已做好生产环境部署准备!** ✅ diff --git a/frontend/sheep/platform/pay.js b/frontend/sheep/platform/pay.js index 00ceb1d..d186e13 100644 --- a/frontend/sheep/platform/pay.js +++ b/frontend/sheep/platform/pay.js @@ -304,8 +304,10 @@ export default class SheepPay { return; } + const orderId = orderInfo.data.id; + // 调用后端生成支付串 - const paymentResult = await ccbApi.createPayment(orderInfo.data.id); + const paymentResult = await ccbApi.createPayment(orderId); if (paymentResult.code !== 1) { sheep.$helper.toast(paymentResult.msg || '生成支付串失败'); @@ -316,24 +318,73 @@ export default class SheepPay { try { const result = await CcbLifePlatform.payment({ payment_string: paymentResult.data.payment_string, - order_id: orderInfo.data.id, + order_id: orderId, order_sn: this.orderSN }); 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() + // ✅ 支付调起成功,开始轮询查询订单状态 + console.log('[建行支付] 支付调起成功,开始轮询查询订单状态'); + + // 显示加载提示 + uni.showLoading({ + title: '支付确认中...', + mask: true }); - if (callbackResult.code === 1) { - that.payResult('success'); - } else { - sheep.$helper.toast('支付确认失败,请联系客服'); - that.payResult('fail'); - } + // 轮询查询订单状态(最多30次,每次间隔2秒,总共60秒) + let pollCount = 0; + const MAX_POLL_COUNT = 30; + const POLL_INTERVAL = 2000; // 2秒 + + const pollPaymentStatus = async () => { + pollCount++; + + try { + const statusResult = await ccbApi.queryPaymentStatus(orderId); + + if (statusResult.code === 1 && statusResult.data.is_paid) { + // ✅ 支付成功 + uni.hideLoading(); + console.log('[建行支付] 订单已支付 order_id:' + orderId); + 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); + } else { // 支付失败或取消 if (result.msg && result.msg.includes('取消')) { diff --git a/frontend/sheep/platform/provider/ccblife/api.js b/frontend/sheep/platform/provider/ccblife/api.js index d89c170..a6cb5f1 100644 --- a/frontend/sheep/platform/provider/ccblife/api.js +++ b/frontend/sheep/platform/provider/ccblife/api.js @@ -59,10 +59,33 @@ const ccbApi = { }, /** - * 支付回调 - * 支付完成后通知后端 + * 查询订单支付状态 (轮询用) + * + * ⚠️ 重要: 本接口只查询状态,不更新订单! + * 订单状态由建行异步通知(notify)接口更新。 + * + * @deprecated paymentCallback已废弃,改用本方法 + */ + queryPaymentStatus: (orderId) => { + return request({ + url: '/ccbpayment/queryPaymentStatus', + method: 'GET', + params: { + order_id: orderId + }, + custom: { + showLoading: false, + auth: true + } + }); + }, + + /** + * @deprecated 2025-01-20 该方法存在安全漏洞,已废弃 + * @see queryPaymentStatus */ paymentCallback: (data) => { + // 向后兼容:调用查询接口 return request({ url: '/ccbpayment/callback', method: 'POST',