diff --git a/addons/shopro/controller/Ccbpayment.php b/addons/shopro/controller/Ccbpayment.php index 26149bd..3516043 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 = ['notify']; + protected $noNeedLogin = ['notify', 'refundNotify']; /** * 不需要权限的方法 @@ -73,9 +73,10 @@ class Ccbpayment extends Common $this->error('订单ID不能为空'); } - // 2. 查询订单 + // 2. 查询订单(包含ccb_pay_flow_id字段,用于判断是否已推送) $order = OrderModel::where('id', $orderId) ->where('user_id', $this->auth->id) + ->field('id, order_sn, status, pay_fee, ccb_pay_flow_id') ->find(); if (!$order) { @@ -87,34 +88,43 @@ class Ccbpayment extends Common $this->error('订单已支付或已关闭'); } - // 4. ✅ 生成支付流水号(统一标识,用于订单推送和支付串生成) - // 格式: PAY + 年月日时分秒(14位) + 随机数(6位) = 23位 - $payFlowId = 'PAY' . date('YmdHis') . mt_rand(100000, 999999); - Log::info('[建行支付] 生成支付流水号 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId); + // 4. ✅ 判断是否已推送过订单(根据ccb_pay_flow_id判断) + $payFlowId = $order['ccb_pay_flow_id']; + $needPushOrder = empty($payFlowId); - // 5. ✅ 先推送订单到建行生活(步骤2:调用A3341TP01订单推送接口) - // ⚠️ 重要:必须先推送订单,收银台才能校验订单信息 - // 根据《5.6.2 业务流程说明》步骤2:由服务方调用订单推送接口向建行生活推送订单信息 - try { - $pushResult = $this->orderService->pushOrder($orderId, $payFlowId); + if ($needPushOrder) { + // 4.1 生成新的支付流水号(统一标识,用于订单推送和支付串生成) + // 格式: PAY + 年月日时分秒(14位) + 随机数(6位) = 23位 + $payFlowId = 'PAY' . date('YmdHis') . mt_rand(100000, 999999); + Log::info('[建行支付] 首次支付,生成支付流水号 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId); - if (!$pushResult['status']) { - // ⚠️ 推送失败必须阻塞支付流程!收银台会找不到订单 - Log::error('[建行支付] 订单推送失败(阻塞支付) order_id:' . $orderId . ' error:' . $pushResult['message']); - $this->error('订单推送失败,无法生成支付串: ' . $pushResult['message']); + // 4.2 推送订单到建行生活(步骤2:调用A3341TP01订单推送接口) + // ⚠️ 重要:必须先推送订单,收银台才能校验订单信息 + // 根据《5.6.2 业务流程说明》步骤2:由服务方调用订单推送接口向建行生活推送订单信息 + try { + $pushResult = $this->orderService->pushOrder($orderId, $payFlowId); + + if (!$pushResult['status']) { + // ⚠️ 推送失败必须阻塞支付流程!收银台会找不到订单 + Log::error('[建行支付] 订单推送失败(阻塞支付) order_id:' . $orderId . ' error:' . $pushResult['message']); + $this->error('订单推送失败,无法生成支付串: ' . $pushResult['message']); + } + + Log::info('[建行支付] 订单推送成功 order_id:' . $orderId); + + } catch (Exception $e) { + // ⚠️ 推送异常必须阻塞支付流程 + Log::error('[建行支付] 订单推送异常(阻塞支付) order_id:' . $orderId . ' error:' . $e->getMessage()); + $this->error('订单推送异常,无法生成支付串: ' . $e->getMessage()); } - - Log::info('[建行支付] 订单推送成功 order_id:' . $orderId); - - } catch (Exception $e) { - // ⚠️ 推送异常必须阻塞支付流程 - Log::error('[建行支付] 订单推送异常(阻塞支付) order_id:' . $orderId . ' error:' . $e->getMessage()); - $this->error('订单推送异常,无法生成支付串: ' . $e->getMessage()); + } else { + // 4.3 已推送过,复用现有支付流水号 + Log::info('[建行支付] 订单已推送过,复用支付流水号 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId); } - // 6. 生成支付串(步骤3:调用收银台) + // 5. 生成支付串(步骤3:调用收银台) // ⚠️ 注意: generatePaymentString()内部已经完成了以下操作: - // - 更新订单的ccb_pay_flow_id字段 + // - 更新订单的ccb_pay_flow_id字段(幂等操作,重复更新不影响) // - 记录支付日志到ccb_payment_log表 // 控制器不应该重复操作,否则会导致数据重复写入! $result = $this->paymentService->generatePaymentString($orderId, $payFlowId); @@ -123,7 +133,7 @@ class Ccbpayment extends Common $this->error('支付串生成失败: ' . $result['message']); } - // 7. 返回支付串给前端调用收银台 + // 6. 返回支付串给前端调用收银台 $this->success('支付串生成成功', $result['data']); } catch (Exception $e) { @@ -137,22 +147,21 @@ class Ccbpayment extends Common * 查询订单支付状态 (前端轮询用) * * ⚠️ 重要说明: - * 本接口只查询订单状态,不执行任何业务逻辑! - * 订单状态由建行异步通知(notify)接口更新,前端只负责轮询查询。 + * 1. 本接口首先查询本地订单状态(已支付直接返回) + * 2. 若本地未支付,则调用建行API查询实际支付状态(补偿机制) + * 3. 若建行返回已支付,更新本地订单并同步到建行外联系统 + * 4. 使用事务+行锁保证幂等性,避免与notify()冲突 * - * 修改原因: - * 原callback()方法存在严重安全漏洞: - * 1. 前端可伪造支付成功请求 - * 2. 与notify()形成双通道,存在竞态条件 - * 3. 违反建行标准支付流程 - * - * 正确流程: - * 前端调起支付 → 建行处理 → 建行异步通知notify() → 前端轮询本接口查询状态 + * 流程: + * 前端调起支付 → 建行处理 → 建行异步通知notify() (主流程) + * → 前端轮询本接口 (补偿机制) * * @return void */ public function queryPaymentStatus() { + $orderId = null; + try { // 1. 获取订单ID $orderId = $this->request->get('order_id', 0); @@ -161,28 +170,192 @@ class Ccbpayment extends Common $this->error('订单ID不能为空'); } - // 2. 查询订单(只查询,不更新!) + // 2. 查询本地订单状态(不加锁,快速返回) $order = OrderModel::where('id', $orderId) ->where('user_id', $this->auth->id) - ->field('id, order_sn, status, paid_time, ccb_pay_flow_id') + ->field('id, order_sn, status, paid_time, ccb_pay_flow_id, pay_fee') ->find(); if (!$order) { $this->error('订单不存在'); } - // 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, - ]); + // 3. 如果订单已支付,直接返回(幂等性:避免重复查询建行API) + if (in_array($order->status, ['paid', 'completed', 'success'])) { + Log::info('[建行支付查询] 订单已支付,直接返回 order_id:' . $orderId . ' order_sn:' . $order->order_sn); + + $this->success('查询成功', [ + 'order_id' => $order->id, + 'order_sn' => $order->order_sn, + 'status' => $order->status, + 'is_paid' => true, + 'paid_time' => $order->paid_time, + 'pay_flow_id' => $order->ccb_pay_flow_id, + 'query_source' => 'local' // 标记数据来源 + ]); + } + + // 4. 订单未支付,调用建行API查询实际支付状态(补偿机制) + if (empty($order->ccb_pay_flow_id)) { + // 没有支付流水号,说明用户还未调起支付,直接返回未支付 + Log::info('[建行支付查询] 订单无支付流水号,未发起支付 order_id:' . $orderId); + + $this->success('查询成功', [ + 'order_id' => $order->id, + 'order_sn' => $order->order_sn, + 'status' => $order->status, + 'is_paid' => false, + 'paid_time' => null, + 'pay_flow_id' => null, + 'query_source' => 'local' + ]); + } + + Log::info('[建行支付查询] 调用建行API查询支付状态 order_id:' . $orderId . ' order_sn:' . $order->order_sn . ' pay_flow_id:' . $order->ccb_pay_flow_id); + + // 5. 调用建行订单查询接口 + // 参数说明:orderSn=支付流水号, startTime=7天前, endTime=当前, page=1, txType=0(支付交易), txnStatus=00(成功) + $queryResult = $this->orderService->queryOrder( + $order->ccb_pay_flow_id, // 使用支付流水号查询 + date('YmdHis', strtotime('-7 days')), // 开始时间 + date('YmdHis'), // 结束时间 + 1, // 页码 + '0', // 交易类型:0-支付交易 + '00' // 交易状态:00-成功 + ); + + // 6. 处理建行查询结果 + if (!$queryResult['status']) { + // 建行API调用失败,返回本地状态 + Log::warning('[建行支付查询] 建行API调用失败,返回本地状态 order_id:' . $orderId . ' error:' . $queryResult['message']); + + $this->success('查询成功', [ + 'order_id' => $order->id, + 'order_sn' => $order->order_sn, + 'status' => $order->status, + 'is_paid' => false, + 'paid_time' => null, + 'pay_flow_id' => $order->ccb_pay_flow_id, + 'query_source' => 'local', + 'ccb_error' => $queryResult['message'] + ]); + } + + // 7. 解析建行返回数据 + $ccbData = $queryResult['data']['CLD_BODY'] ?? []; + $txnList = $ccbData['TXN_LST'] ?? []; + + // 8. 检查是否有支付成功记录 + $isPaidInCcb = false; + $ccbPayTime = null; + $ccbTransId = null; + + if (!empty($txnList)) { + foreach ($txnList as $txn) { + // TXN_STATUS=00 表示交易成功 + if (isset($txn['TXN_STATUS']) && $txn['TXN_STATUS'] == '00') { + $isPaidInCcb = true; + $ccbPayTime = $txn['TXN_TIME'] ?? null; // 交易时间 YYYYMMDDHHmmss + $ccbTransId = $txn['TXN_SEQ'] ?? null; // 交易流水号 + break; + } + } + } + + // 9. 如果建行返回未支付,直接返回 + if (!$isPaidInCcb) { + Log::info('[建行支付查询] 建行返回未支付 order_id:' . $orderId . ' order_sn:' . $order->order_sn); + + $this->success('查询成功', [ + 'order_id' => $order->id, + 'order_sn' => $order->order_sn, + 'status' => $order->status, + 'is_paid' => false, + 'paid_time' => null, + 'pay_flow_id' => $order->ccb_pay_flow_id, + 'query_source' => 'ccb' + ]); + } + + // 10. ✅ 建行返回已支付,启动事务更新本地订单 + Log::info('[建行支付查询] 建行返回已支付,开始更新本地订单 order_id:' . $orderId . ' order_sn:' . $order->order_sn); + + Db::startTrans(); + try { + // 10.1 使用行锁重新查询订单状态(避免并发冲突) + $lockedOrder = OrderModel::where('id', $orderId) + ->where('user_id', $this->auth->id) + ->lock(true) // 悲观锁:FOR UPDATE + ->find(); + + if (!$lockedOrder) { + throw new Exception('订单不存在或无权限'); + } + + // 10.2 再次检查订单状态(可能已被notify()更新) + if (in_array($lockedOrder->status, ['paid', 'completed', 'success'])) { + Db::commit(); + Log::info('[建行支付查询] 订单已被其他流程更新为已支付,跳过更新 order_id:' . $orderId); + + $this->success('查询成功', [ + 'order_id' => $lockedOrder->id, + 'order_sn' => $lockedOrder->order_sn, + 'status' => $lockedOrder->status, + 'is_paid' => true, + 'paid_time' => $lockedOrder->paid_time, + 'pay_flow_id' => $lockedOrder->ccb_pay_flow_id, + 'query_source' => 'local_concurrent' + ]); + } + + // 10.3 更新订单状态为已支付 + $paidTime = time() * 1000; // Shopro使用毫秒时间戳 + + OrderModel::where('id', $orderId)->update([ + 'status' => 'paid', + 'pay_type' => 'offline', // 建行支付归类为线下银行支付 + 'paid_time' => $paidTime, + 'transaction_id' => $ccbTransId ?: $order->ccb_pay_flow_id, // 建行交易流水号 + 'updatetime' => time() + ]); + + Log::info('[建行支付查询] 本地订单状态更新成功 order_id:' . $orderId . ' status:paid'); + + // 10.4 提交事务 + Db::commit(); + + // 11. ✅ 更新订单状态到建行外联系统(异步,失败不影响本地) + try { + $updateResult = $this->orderService->updateOrderStatus($orderId, '1'); // 1-支付成功 + + if ($updateResult['status']) { + Log::info('[建行支付查询] 订单状态同步到建行成功 order_id:' . $orderId); + } else { + Log::warning('[建行支付查询] 订单状态同步到建行失败(本地已更新) order_id:' . $orderId . ' error:' . $updateResult['message']); + } + } catch (Exception $e) { + Log::error('[建行支付查询] 订单状态同步到建行异常(本地已更新) order_id:' . $orderId . ' error:' . $e->getMessage()); + } + + // 12. 返回成功结果 + $this->success('查询成功', [ + 'order_id' => $order->id, + 'order_sn' => $order->order_sn, + 'status' => 'paid', + 'is_paid' => true, + 'paid_time' => $paidTime, + 'pay_flow_id' => $order->ccb_pay_flow_id, + 'query_source' => 'ccb_updated' // 标记:从建行查询并更新 + ]); + + } catch (Exception $e) { + // 回滚事务 + Db::rollback(); + throw $e; + } } 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()); } @@ -206,12 +379,23 @@ class Ccbpayment extends Common * * @return void */ + /** + * 建行生活支付通知接口 + * + * 📋 接口说明(文档7.1): + * - 建行生活主动推送支付结果 + * - 不会附带服务方编号 + * - 通过 REMARK2 字段识别服务方 + * - SIGN 字段使用商户私钥签名,需用建行公钥验签 + * + * @return void + */ public function notify() { try { // 1. 获取原始请求数据 $rawData = file_get_contents('php://input'); - Log::info('[建行通知] 收到异步通知: ' . $rawData); + Log::info('[建行支付通知] 收到异步通知: ' . $rawData); // 2. 解析POST参数 $params = $this->request->post(); @@ -222,51 +406,98 @@ class Ccbpayment extends Common } // 4. 记录参数 - Log::info('[建行通知] 解析参数: ' . json_encode($params, JSON_UNESCAPED_UNICODE)); + Log::info('[建行支付通知] 解析参数: ' . json_encode($params, JSON_UNESCAPED_UNICODE)); // 5. 验证必需参数 if (empty($params['ORDERID'])) { - Log::error('[建行通知] 缺少ORDERID参数'); + Log::error('[建行支付通知] 缺少ORDERID参数'); exit('FAIL'); } - // 6. 调用支付服务处理通知(返回订单ID) + if (empty($params['SIGN'])) { + Log::error('[建行支付通知] 缺少SIGN签名'); + exit('FAIL'); + } + + if (empty($params['SUCCESS'])) { + Log::error('[建行支付通知] 缺少SUCCESS字段'); + exit('FAIL'); + } + + // 6. ✅ 验证签名(使用建行公钥验签) + try { + $signature = $params['SIGN']; + $verifyParams = $params; // 复制参数用于验签 + + // 加载建行公钥配置 + $configFile = __DIR__ . '/../config/ccblife.php'; + if (!file_exists($configFile)) { + throw new \Exception('建行生活配置文件不存在'); + } + $config = include $configFile; + + // 验签 + $verifyResult = \addons\shopro\library\ccblife\CcbRSA::verifyNotify( + $verifyParams, + $signature, + $config['ccb_public_key'] // 建行公钥 + ); + + if (!$verifyResult) { + Log::error('[建行支付通知] 签名验证失败 ORDERID:' . $params['ORDERID']); + exit('FAIL'); + } + + Log::info('[建行支付通知] 签名验证成功 ORDERID:' . $params['ORDERID']); + + } catch (\Exception $e) { + Log::error('[建行支付通知] 签名验证异常: ' . $e->getMessage()); + exit('FAIL'); + } + + // 7. 检查支付状态 + if ($params['SUCCESS'] !== 'Y') { + Log::warning('[建行支付通知] 支付未成功 ORDERID:' . $params['ORDERID'] . ' SUCCESS:' . $params['SUCCESS']); + exit('SUCCESS'); // ⚠️ 仍然返回SUCCESS,表示通知已接收 + } + + // 8. 调用支付服务处理通知(返回订单ID) $result = $this->paymentService->handleNotify($params); - // 7. ✅ 处理成功后更新订单状态到建行(步骤13:调用订单更新接口更新订单状态) + // 9. ✅ 处理成功后更新订单状态到建行(步骤13:调用订单更新接口更新订单状态) if ($result['status'] === 'success' && !empty($result['order_id'])) { // ⚠️ 只有新支付才更新,已支付的订单跳过更新 if ($result['already_paid'] === false) { try { // 调用订单更新接口,将订单状态从未支付更新为已支付 - $updateResult = $this->orderService->updateOrderStatus($result['order_id']); + $updateResult = $this->orderService->updateOrderStatus($result['order_id'], '1'); // 1-支付成功 if ($updateResult['status']) { - Log::info('[建行通知] 订单状态更新成功 order_id:' . $result['order_id']); + Log::info('[建行支付通知] 订单状态更新成功 order_id:' . $result['order_id']); } else { // ⚠️ 更新失败不影响本地支付状态,记录日志后续补推 - Log::warning('[建行通知] 订单状态更新失败(本地已支付) order_id:' . $result['order_id'] . ' error:' . $updateResult['message']); + 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']); } } - // 8. 返回处理结果 + // 10. 返回处理结果 // ⚠️ 重要: 必须使用exit直接退出,防止ThinkPHP框架追加额外内容 // 建行要求返回纯文本 'SUCCESS' 或 'FAIL',任何额外字符都会导致建行认为通知失败 $response = ($result['status'] === 'success') ? 'SUCCESS' : 'FAIL'; - Log::info('[建行通知] 处理完成,返回: ' . $response); + Log::info('[建行支付通知] 处理完成,返回: ' . $response); // 直接退出,确保只输出SUCCESS/FAIL exit($response); } catch (Exception $e) { - Log::error('[建行通知] 处理失败 error:' . $e->getMessage()); + Log::error('[建行支付通知] 处理失败 error:' . $e->getMessage()); // 异常情况也要直接退出 exit('FAIL'); diff --git a/addons/shopro/library/ccblife/CcbHttpClient.php b/addons/shopro/library/ccblife/CcbHttpClient.php index 412f660..4295d8e 100644 --- a/addons/shopro/library/ccblife/CcbHttpClient.php +++ b/addons/shopro/library/ccblife/CcbHttpClient.php @@ -321,85 +321,147 @@ class CcbHttpClient /** * 订单状态更新(A3341TP02) * - * @param string $userId 用户ID - * @param string $orderId 订单ID - * @param string $orderStatus 订单状态 - * @param string $refundStatus 退款状态 + * @param string $orderId 订单编号(用户订单号,对应收银台USER_ORDERID字段) + * @param string $informId 通知类型(0-支付状态修改 1-退款状态修改) + * @param string $payFlowId 支付流水号(对应收银台ORDERID字段) + * @param string $payMrchId 支付商户号 + * @param array $additionalParams 额外参数(PAY_STATUS、REFUND_STATUS、PAY_AMT、DISCOUNT_AMT、CUS_ORDER_URL等) * @return array 响应数据 * @throws \Exception */ - public function updateOrderStatus($userId, $orderId, $orderStatus, $refundStatus = '0') + public function updateOrderStatus($orderId, $informId, $payFlowId, $payMrchId, $additionalParams = []) { + // 验证通知类型 + if (!in_array($informId, ['0', '1'])) { + throw new \Exception('通知类型INFORM_ID必须为0(支付状态修改)或1(退款状态修改)'); + } + + // 验证互斥规则 + if ($informId == '0') { + // 支付状态修改时,PAY_STATUS必填,REFUND_STATUS为空 + if (empty($additionalParams['PAY_STATUS'])) { + throw new \Exception('支付状态修改时PAY_STATUS不能为空'); + } + $additionalParams['REFUND_STATUS'] = null; + } elseif ($informId == '1') { + // 退款状态修改时,REFUND_STATUS必填,PAY_STATUS为空 + if (empty($additionalParams['REFUND_STATUS'])) { + throw new \Exception('退款状态修改时REFUND_STATUS不能为空'); + } + $additionalParams['PAY_STATUS'] = null; + } + + // 构建请求体(必填字段) $body = [ - 'USER_ID' => $userId, 'ORDER_ID' => $orderId, - 'ORDER_STATUS' => $orderStatus, - 'REFUND_STATUS' => $refundStatus + 'INFORM_ID' => $informId, + 'PAY_FLOW_ID' => $payFlowId, + 'PAY_MRCH_ID' => $payMrchId ]; + // 合并额外参数 + $body = array_merge($body, $additionalParams); + + // 移除空值字段 + $body = array_filter($body, function($value) { + return $value !== null && $value !== ''; + }); + return $this->request($this->config['tx_codes']['order_update'], $body); } /** * 订单查询(A3341TP03) * - * @param string $onlnPyTxnOrdrId 支付订单ID - * @param string $txnStatus 交易状态 + * @param string $onlnPyTxnOrdrId 订单编号(调用收银台时支付流水号,对应字段ORDERID) + * @param string|null $startTime 开始日期时间(格式yyyyMMddHHmmss,默认为7天前) + * @param string|null $endTime 结束日期时间(格式yyyyMMddHHmmss,默认为当前时间) + * @param int $page 当前页次(默认1) + * @param string $txType 交易类型(0-支付交易 1-退款交易 a-查询可退款的订单) + * @param string $txnStatus 交易状态(00-交易成功 01-交易失败 02-不确定) + * @param array $additionalParams 额外参数(PLAT_MCT_ID、CUSTOMERID、BRANCHID、SCN_IDR等) * @return array 响应数据 * @throws \Exception */ - public function queryOrder($onlnPyTxnOrdrId, $txnStatus = '00') + public function queryOrder($onlnPyTxnOrdrId, $startTime = null, $endTime = null, $page = 1, $txType = '0', $txnStatus = '00', $additionalParams = []) { + // 默认查询最近7天 + if (empty($startTime)) { + $startTime = date('YmdHis', strtotime('-7 days')); + } + if (empty($endTime)) { + $endTime = date('YmdHis'); + } + + // 构建请求体(必填字段) $body = [ + 'TX_TYPE' => $txType, + 'TXN_PRD_TPCD' => '99', // 99-自定义时间段查询(文档要求) + 'STDT_TM' => $startTime, + 'EDDT_TM' => $endTime, 'ONLN_PY_TXN_ORDR_ID' => $onlnPyTxnOrdrId, - 'PAGE' => '1', - 'TXN_PRD_TPCD' => '06', 'TXN_STATUS' => $txnStatus, - 'TX_TYPE' => '0' + 'PAGE' => (string)$page ]; + // 合并额外参数(商户信息等) + $body = array_merge($body, $additionalParams); + + // 移除空值字段 + $body = array_filter($body, function($value) { + return $value !== null && $value !== ''; + }); + return $this->request($this->config['tx_codes']['order_query'], $body); } /** * 退款接口(A3341TP04) * - * @param string $orderId 订单ID - * @param string $refundAmount 退款金额 - * @param string $refundReason 退款原因 + * @param string $orderId 订单号(调用收银台时支付流水号,对应字段ORDERID) + * @param float|string $refundAmount 退款金额(单位:元) + * @param string|int $payTime 支付时间(时间戳或日期时间字符串) + * @param string|null $refundCode 退款流水号(可选,建议填写用于查询退款结果) + * @param array $additionalParams 额外参数(PLAT_MCT_ID、CUSTOMERID、BRANCHID等) * @return array 响应数据 * @throws \Exception */ - public function refund($orderId, $refundAmount, $refundReason = '') + public function refund($orderId, $refundAmount, $payTime, $refundCode = null, $additionalParams = []) { + // 计算时间范围(支付时间前后4小时) + $payTimestamp = is_numeric($payTime) ? $payTime : strtotime($payTime); + if (!$payTimestamp) { + throw new \Exception('支付时间格式错误,请传入时间戳或有效的日期时间字符串'); + } + + $stat_tm = date('YmdHis', $payTimestamp - 4*3600); // 支付时间往前4小时 + $edit_tm = date('YmdHis', min($payTimestamp + 4*3600, time())); // 支付时间往后4小时,但不超过当前时间 + + // 生成退款流水号(如果未提供) + if (empty($refundCode)) { + $refundCode = 'RF' . date('YmdHis') . str_pad(mt_rand(0, 9999), 4, '0', STR_PAD_LEFT); + } + + // 格式化退款金额(保留2位小数) + $refundAmount = number_format((float)$refundAmount, 2, '.', ''); + + // 构建请求体(必填字段) $body = [ - 'ORDER_ID' => $orderId, - 'REFUND_AMOUNT' => $refundAmount, - 'REFUND_REASON' => $refundReason, - 'REFUND_TIME' => date('YmdHis') + 'ORDER' => $orderId, // 注意:字段名是 ORDER,不是 ORDER_ID + 'MONEY' => $refundAmount, // 注意:字段名是 MONEY,不是 REFUND_AMOUNT + 'STDT_TM' => $stat_tm, + 'EDDT_TM' => $edit_tm, + 'REFUND_CODE' => $refundCode ]; + // 合并额外参数(商户信息等) + $body = array_merge($body, $additionalParams); + + // 移除空值字段 + $body = array_filter($body, function($value) { + return $value !== null && $value !== ''; + }); + return $this->request($this->config['tx_codes']['order_refund'], $body); } - - /** - * 测试连接 - * 使用查询接口测试连接是否正常 - * - * @return bool 是否连接成功 - */ - public function testConnection() - { - try { - // 使用一个不存在的订单号进行查询测试 - $this->queryOrder('TEST' . time()); - return true; - } catch (\Exception $e) { - // 如果是业务错误(订单不存在),说明连接正常 - if (strpos($e->getMessage(), '业务处理失败') !== false) { - return true; - } - return false; - } - } } diff --git a/addons/shopro/library/ccblife/CcbOrderService.php b/addons/shopro/library/ccblife/CcbOrderService.php index 2f6e0fc..58dcde4 100644 --- a/addons/shopro/library/ccblife/CcbOrderService.php +++ b/addons/shopro/library/ccblife/CcbOrderService.php @@ -98,8 +98,8 @@ class CcbOrderService $costTime = round((microtime(true) - $startTime) * 1000, 2); $this->recordSyncLog($orderId, 'A3341TP01', $txSeq, $response, 'response', true, $costTime); - // 更新订单同步状态 - $this->updateOrderSyncStatus($orderId, 1); + // 更新订单同步状态为成功(清空错误信息) + $this->updateOrderSyncStatus($orderId, 1, ''); return [ 'status' => true, @@ -112,8 +112,8 @@ class CcbOrderService $costTime = round((microtime(true) - $startTime) * 1000, 2); $this->recordSyncLog($orderId, 'A3341TP01', '', null, 'error', false, $costTime, $e->getMessage()); - // 更新同步状态为失败 - $this->updateOrderSyncStatus($orderId, 2); + // 更新同步状态为失败,并保存错误信息 + $this->updateOrderSyncStatus($orderId, 2, $e->getMessage()); Log::error('建行订单推送失败: ' . $e->getMessage()); return [ @@ -128,8 +128,8 @@ class CcbOrderService * 更新订单状态到建行生活 * * @param int $orderId 订单ID - * @param string $status 订单状态 - * @param string $refundStatus 退款状态 + * @param string|null $status 支付状态(0-待支付 1-支付成功 2-已过期 3-支付失败 4-取消) + * @param string|null $refundStatus 退款状态(0-无退款 1-退款申请 2-已退款 3-部分退款) * @return array */ public function updateOrderStatus($orderId, $status = null, $refundStatus = null) @@ -138,7 +138,7 @@ class CcbOrderService $txSeq = CcbMD5::generateTransactionSeq(); try { - // 获取订单信息 + // 获取订单信息(包含支付流水号) $order = Db::name('shopro_order') ->alias('o') ->join('user u', 'o.user_id = u.id', 'LEFT') @@ -150,31 +150,60 @@ class CcbOrderService throw new \Exception('订单不存在'); } - // 获取建行用户ID - $ccbUserId = $order['ccb_user_id']; - if (!$ccbUserId) { - throw new \Exception('用户未绑定建行生活账号'); + // 获取支付流水号 + $payFlowId = $order['pay_flow_id'] ?? null; + if (empty($payFlowId)) { + throw new \Exception('订单缺少支付流水号,无法更新状态到建行'); + } + + // 获取支付商户号 + $payMrchId = $this->config['merchant_id'] ?? null; + if (empty($payMrchId)) { + throw new \Exception('配置中缺少支付商户号(merchant_id)'); } // 映射订单状态 - $orderStatus = $status ?: $this->mapOrderStatus($order['status']); - $refundStatus = $refundStatus ?: $this->mapRefundStatus($order['refund_status'] ?? 0); + $payStatus = $status ?: $this->mapOrderStatus($order['status']); + $mappedRefundStatus = $refundStatus ?: $this->mapRefundStatus($order['refund_status'] ?? 0); + + // 确定通知类型(0-支付状态修改 1-退款状态修改) + $informId = !empty($refundStatus) && $refundStatus != '0' ? '1' : '0'; + + // 构建额外参数 + $additionalParams = []; + if ($informId == '0') { + // 支付状态修改 + $additionalParams['PAY_STATUS'] = $payStatus; + $additionalParams['PAY_AMT'] = number_format($order['pay_fee'] ?? 0, 2, '.', ''); + } else { + // 退款状态修改 + $additionalParams['REFUND_STATUS'] = $mappedRefundStatus; + $additionalParams['TOTAL_REFUND_AMT'] = number_format($order['refund_fee'] ?? 0, 2, '.', ''); + } + + // 添加其他可选参数 + $additionalParams['DISCOUNT_AMT'] = number_format($order['total_discount_fee'] ?? 0, 2, '.', ''); + if (!empty($order['goods_name'])) { + $additionalParams['GOODS_NM'] = mb_substr($order['goods_name'], 0, 200); + } // 记录请求 $requestData = [ - 'ccb_user_id' => $ccbUserId, - 'order_sn' => $order['order_sn'], - 'order_status' => $orderStatus, - 'refund_status' => $refundStatus + 'order_id' => $order['order_sn'], + 'inform_id' => $informId, + 'pay_flow_id' => $payFlowId, + 'pay_mrch_id' => $payMrchId, + 'additional_params' => $additionalParams ]; $this->recordSyncLog($orderId, 'A3341TP02', $txSeq, $requestData, 'request'); - // 调用建行API更新状态 + // 调用建行API更新状态(使用新接口) $response = $this->httpClient->updateOrderStatus( - $ccbUserId, - $order['order_sn'], - $orderStatus, - $refundStatus + $order['order_sn'], // 订单编号 + $informId, // 通知类型 + $payFlowId, // 支付流水号 + $payMrchId, // 支付商户号 + $additionalParams // 额外参数 ); // 记录响应 @@ -203,14 +232,26 @@ class CcbOrderService /** * 查询建行订单信息 * - * @param string $orderSn 订单号 + * @param string $orderSn 订单号(支付流水号,对应收银台ORDERID字段) + * @param string|null $startTime 开始时间(格式yyyyMMddHHmmss,默认7天前) + * @param string|null $endTime 结束时间(格式yyyyMMddHHmmss,默认当前时间) + * @param int $page 页码(默认1) + * @param string $txType 交易类型(0-支付交易 1-退款交易 a-查询可退款订单) + * @param string $txnStatus 交易状态(00-成功 01-失败 02-不确定) * @return array */ - public function queryOrder($orderSn) + public function queryOrder($orderSn, $startTime = null, $endTime = null, $page = 1, $txType = '0', $txnStatus = '00') { try { - // 调用建行API查询订单 - $response = $this->httpClient->queryOrder($orderSn); + // 调用建行API查询订单(使用新接口) + $response = $this->httpClient->queryOrder( + $orderSn, + $startTime, + $endTime, + $page, + $txType, + $txnStatus + ); return [ 'status' => true, @@ -232,14 +273,14 @@ class CcbOrderService * 处理订单退款 * * @param int $orderId 订单ID - * @param float $refundAmount 退款金额 - * @param string $refundReason 退款原因 + * @param float $refundAmount 退款金额(单位:元) + * @param string|null $refundCode 退款流水号(可选,建议填写用于查询退款结果) * @return array */ - public function refundOrder($orderId, $refundAmount, $refundReason = '') + public function refundOrder($orderId, $refundAmount, $refundCode = null) { try { - // 获取订单信息 + // 获取订单信息(需要支付流水号和支付时间) $order = Db::name('shopro_order')->where('id', $orderId)->find(); if (!$order) { throw new \Exception('订单不存在'); @@ -250,11 +291,24 @@ class CcbOrderService throw new \Exception('退款金额不能超过订单总额'); } - // 调用建行API发起退款 + // 获取支付流水号(必须) + $payFlowId = $order['pay_flow_id'] ?? null; + if (empty($payFlowId)) { + throw new \Exception('订单缺少支付流水号,无法执行退款'); + } + + // 获取支付时间(用于计算查询时间范围) + $payTime = $order['pay_time'] ?? $order['createtime']; + if (empty($payTime)) { + throw new \Exception('订单缺少支付时间,无法执行退款'); + } + + // 调用建行API发起退款(使用新接口) $response = $this->httpClient->refund( - $order['order_sn'], - number_format($refundAmount, 2, '.', ''), - $refundReason + $payFlowId, // 支付流水号(对应收银台ORDERID) + $refundAmount, // 退款金额 + $payTime, // 支付时间(用于计算查询时间范围) + $refundCode // 退款流水号(可选) ); // 更新订单退款状态 @@ -515,14 +569,26 @@ class CcbOrderService * * @param int $orderId 订单ID * @param int $status 同步状态:0-未同步 1-已同步 2-同步失败 + * @param string $errorMsg 错误信息(失败时填写,成功时传空字符串清空) */ - private function updateOrderSyncStatus($orderId, $status) + private function updateOrderSyncStatus($orderId, $status, $errorMsg = '') { - Db::name('shopro_order')->where('id', $orderId)->update([ + $updateData = [ 'ccb_sync_status' => $status, 'ccb_sync_time' => time(), 'updatetime' => time() - ]); + ]; + + // 根据状态处理错误信息 + if ($status == 1) { + // 同步成功,清空错误信息 + $updateData['ccb_sync_error'] = ''; + } elseif ($status == 2 && !empty($errorMsg)) { + // 同步失败,保存错误信息(限制长度255字符) + $updateData['ccb_sync_error'] = mb_substr($errorMsg, 0, 255, 'UTF-8'); + } + + Db::name('shopro_order')->where('id', $orderId)->update($updateData); } /** @@ -559,62 +625,4 @@ class CcbOrderService if ($refundStatus == 2) return '2'; // 已退款 return '0'; } - - /** - * 批量同步订单 - * 用于初始化或定时同步 - * - * @param array $conditions 查询条件 - * @param int $limit 批量数量 - * @return array - */ - public function batchSync($conditions = [], $limit = 100) - { - $successCount = 0; - $failCount = 0; - $errors = []; - - try { - // 构建查询 - $query = Db::name('shopro_order'); - - if (!empty($conditions)) { - $query->where($conditions); - } - - // 查询需要同步的订单 - $orders = $query->where('status', '<>', 'cancelled') - ->where('ccb_sync_status', 'in', [0, 2]) // 未同步或同步失败的 - ->limit($limit) - ->select(); - - foreach ($orders as $order) { - $result = $this->pushOrder($order['id']); - if ($result['status']) { - $successCount++; - } else { - $failCount++; - $errors[] = "订单{$order['order_sn']}: {$result['message']}"; - } - } - - return [ - 'status' => true, - 'message' => "批量同步完成", - 'data' => [ - 'total' => count($orders), - 'success' => $successCount, - 'fail' => $failCount, - 'errors' => $errors - ] - ]; - - } catch (\Exception $e) { - return [ - 'status' => false, - 'message' => '批量同步失败: ' . $e->getMessage(), - 'data' => null - ]; - } - } } diff --git a/addons/shopro/library/ccblife/CcbPaymentService.php b/addons/shopro/library/ccblife/CcbPaymentService.php index 4b22e81..bf7df9c 100644 --- a/addons/shopro/library/ccblife/CcbPaymentService.php +++ b/addons/shopro/library/ccblife/CcbPaymentService.php @@ -82,139 +82,178 @@ class CcbPaymentService throw new \Exception('支付流水号不能为空'); } - // ✅ 构建完整的48个支付参数(按照建行文档5.4完整参数定义) - // 基础商户参数(必须二选一:建行商户号组合 或 外部平台商户号) - $paymentParams = [ - 'MERCHANTID' => $this->config['merchant_id'], // 商户代码(F=可选,但不用外部商户号时必填) - 'POSID' => $this->config['pos_id'], // 柜台代码(F) - 'BRANCHID' => $this->config['branch_id'], // 分行代码(F) - 'ORDERID' => $payFlowId, // 支付流水号(T=必送) - 'USER_ORDERID' => $order['order_sn'], // 用户订单号(T) - 'PAYMENT' => number_format($order['pay_fee'], 2, '.', ''), // 支付金额(T) - 'CURCODE' => '01', // 币种(T,01=人民币) - 'TXCODE' => '520100', // 交易码(T,520100=即时支付) - 'REMARK1' => '', // 备注1(T) - 'REMARK2' => $this->config['service_id'], // 备注2(T,服务方编号) - 'TYPE' => '1', // 接口类型(T,1=防钓鱼) - 'GATEWAY' => '0', // 网关类型(T) - 'CLIENTIP' => $this->getClientIp(), // 客户端IP(T) - 'REGINFO' => '', // 客户注册信息(T,中文需escape编码) - 'PROINFO' => $this->buildProductInfo($order), // 商品信息(T,中文已escape编码) - 'REFERER' => '', // 商户URL(T) - 'THIRDAPPINFO' => 'comccbpay1234567890cloudmerchant', // 客户端标识(T,固定值) - 'TIMEOUT' => date('YmdHis', strtotime('+30 minutes')), // 超时时间(F,格式YYYYMMDDHHmmss) - ]; + // ⚠️ 关键:建行要求参数按照文档表格定义的顺序拼接,不是ASCII排序! + // 根据建行文档4.1和4.2,必须严格按照参数表顺序构建签名字符串 - // ✅ 可选参数(根据实际场景添加) - // 外部平台商户号(与建行商户号二选一) - if (!empty($this->config['plat_mct_id'])) { - $paymentParams['PLATMCTID'] = $this->config['plat_mct_id']; - // 使用外部商户号时,删除建行商户号 - unset($paymentParams['MERCHANTID'], $paymentParams['POSID'], $paymentParams['BRANCHID']); + // 1. 定义参与MAC签名的参数数组(按文档表格顺序) + $macParams = []; + + // 1.1 商户信息(必填,二选一:建行商户号组合 或 外部平台商户号) + $usePlatMctId = !empty($this->config['plat_mct_id']); + if ($usePlatMctId) { + // 使用外部平台商户号 + $macParams['PLATMCTID'] = $this->config['plat_mct_id']; + } else { + // 使用建行商户号组合 + $macParams['MERCHANTID'] = $this->config['merchant_id']; + $macParams['POSID'] = $this->config['pos_id']; + $macParams['BRANCHID'] = $this->config['branch_id']; } - // 微信支付19位终端号 - if (!empty($this->config['pos_id_19'])) { - $paymentParams['POSID19'] = $this->config['pos_id_19']; - } - - // 支付位图(控制支付方式:生活钱包/龙支付/微信/数币/信用付/快贷) - if (!empty($this->config['pay_bitmap'])) { - $paymentParams['PAYBITMAP'] = $this->config['pay_bitmap']; - } + // 1.2 订单信息(必填) + $macParams['ORDERID'] = $payFlowId; // 支付流水号 + $macParams['USER_ORDERID'] = $order['order_sn']; // 用户订单号 + $macParams['PAYMENT'] = number_format($order['pay_fee'], 2, '.', ''); // 支付金额 + $macParams['CURCODE'] = '01'; // 币种(01=人民币) + $macParams['TXCODE'] = '520100'; // 交易码 + $macParams['REMARK1'] = ''; // 备注1(空字符串也要传) + $macParams['REMARK2'] = $this->config['service_id']; // 备注2(服务方编号) + $macParams['TYPE'] = '1'; // 接口类型(1=防钓鱼) + $macParams['GATEWAY'] = '0'; // 网关类型 + $macParams['CLIENTIP'] = ''; // 客户端IP(建行生活环境送空) + $macParams['REGINFO'] = ''; // 客户注册信息(空字符串) + $macParams['PROINFO'] = $this->buildProductInfo($order); // 商品信息(escape编码) + $macParams['REFERER'] = ''; // 商户URL(空字符串) + $macParams['THIRDAPPINFO'] = 'comccbpay1234567890cloudmerchant'; // 客户端标识(固定值) - // 账户位图(控制支付账户:建行借记卡/贷记卡/他行借记卡/贷记卡/建行钱包) - if (!empty($this->config['account_bitmap'])) { - $paymentParams['ACCOUNTBITMAP'] = $this->config['account_bitmap']; - } + // 1.3 可选参数(按文档表格顺序,有值才参与MAC) + // ⚠️ 注意:根据文档4.2,橙色字段有值时才参与MAC,空值不参与 - // 分期期数 + // 分期期数(在THIRDAPPINFO之后) if (!empty($this->config['install_num'])) { - $paymentParams['INSTALLNUM'] = $this->config['install_num']; + $macParams['INSTALLNUM'] = $this->config['install_num']; } - // 积分二级活动编号 + // 超时时间 + if (!empty($this->config['timeout'])) { + $macParams['TIMEOUT'] = $this->config['timeout']; + } else { + // 默认30分钟超时 + $macParams['TIMEOUT'] = date('YmdHis', strtotime('+30 minutes')); + } + + // 中国建设银行App环境参数 + if (!empty($this->config['user_id'])) { + $macParams['USERID'] = $this->config['user_id']; + } + if (!empty($this->config['token'])) { + $macParams['TOKEN'] = $this->config['token']; + } + if (!empty($this->config['pay_success_url'])) { + $macParams['PAYSUCCESSURL'] = urlencode($this->config['pay_success_url']); + } + + // 支付位图和账户位图 + if (!empty($this->config['pay_bitmap'])) { + $macParams['PAYBITMAP'] = $this->config['pay_bitmap']; + } + if (!empty($this->config['account_bitmap'])) { + $macParams['ACCOUNTBITMAP'] = $this->config['account_bitmap']; + } + + // 积分相关 if (!empty($this->config['point_avy_id'])) { - $paymentParams['POINTAVYID'] = $this->config['point_avy_id']; + $macParams['POINTAVYID'] = $this->config['point_avy_id']; + } + if (!empty($this->config['fixed_point_val'])) { + $macParams['FIXEDPOINTVAL'] = $this->config['fixed_point_val']; + } + if (!empty($this->config['min_point_limit'])) { + $macParams['MINPOINTLIMIT'] = $this->config['min_point_limit']; + } + + // 有价券相关 + if (!empty($this->config['coupon_avy_id'])) { + $macParams['COUPONAVYID'] = $this->config['coupon_avy_id']; + } + if (!empty($this->config['only_credit_pay_flag'])) { + $macParams['ONLY_CREDIT_PAY_FLAG'] = $this->config['only_credit_pay_flag']; } // 数字人民币参数 if (!empty($this->config['dcep_mct_type'])) { - $paymentParams['DCEP_MCT_TYPE'] = $this->config['dcep_mct_type']; + $macParams['DCEP_MCT_TYPE'] = $this->config['dcep_mct_type']; if ($this->config['dcep_mct_type'] == '2') { // 非融合商户需要填写数币商户号 - $paymentParams['DCEP_MERCHANTID'] = $this->config['dcep_merchant_id'] ?? ''; - $paymentParams['DCEP_POSID'] = $this->config['dcep_pos_id'] ?? ''; - $paymentParams['DCEP_BRANCHID'] = $this->config['dcep_branch_id'] ?? ''; + if (!empty($this->config['dcep_merchant_id'])) { + $macParams['DCEP_MERCHANTID'] = $this->config['dcep_merchant_id']; + } + if (!empty($this->config['dcep_pos_id'])) { + $macParams['DCEP_POSID'] = $this->config['dcep_pos_id']; + } + if (!empty($this->config['dcep_branch_id'])) { + $macParams['DCEP_BRANCHID'] = $this->config['dcep_branch_id']; + } } if (!empty($this->config['dcep_dep_acc_no'])) { - $paymentParams['DCEPDEPACCNO'] = $this->config['dcep_dep_acc_no']; + $macParams['DCEPDEPACCNO'] = $this->config['dcep_dep_acc_no']; } } - // 有价券活动编号 - if (!empty($this->config['coupon_avy_id'])) { - $paymentParams['COUPONAVYID'] = $this->config['coupon_avy_id']; - } - - // 限制信用卡支付标志 - if (!empty($this->config['only_credit_pay_flag'])) { - $paymentParams['ONLY_CREDIT_PAY_FLAG'] = $this->config['only_credit_pay_flag']; - } - - // 固定抵扣积分值 - if (!empty($this->config['fixed_point_val'])) { - $paymentParams['FIXEDPOINTVAL'] = $this->config['fixed_point_val']; - } - - // 最小使用积分抵扣限制 - if (!empty($this->config['min_point_limit'])) { - $paymentParams['MINPOINTLIMIT'] = $this->config['min_point_limit']; - } - - // 扩展域(JSON格式,需encodeURI) - if (!empty($this->config['extend_params'])) { - $paymentParams['EXTENDPARAMS'] = urlencode($this->config['extend_params']); - } - - // 二级商户参数(平台类服务方使用) + // 二级商户参数 if (!empty($this->config['sub_mct_id'])) { - $paymentParams['SUB_MCT_ID'] = $this->config['sub_mct_id']; + $macParams['SUB_MCT_ID'] = $this->config['sub_mct_id']; } if (!empty($this->config['sub_mct_name'])) { - $paymentParams['SUB_MCT_NAME'] = $this->config['sub_mct_name']; + $macParams['SUB_MCT_NAME'] = $this->config['sub_mct_name']; } if (!empty($this->config['sub_mct_mcc'])) { - $paymentParams['SUB_MCT_MCC'] = $this->config['sub_mct_mcc']; + $macParams['SUB_MCT_MCC'] = $this->config['sub_mct_mcc']; + } + + // 扩展域 + if (!empty($this->config['extend_params'])) { + $macParams['EXTENDPARAMS'] = urlencode($this->config['extend_params']); + } + + // 特殊字段(中石化专用) + if (!empty($this->config['identity_code'])) { + $macParams['IDENTITYCODE'] = $this->config['identity_code']; + } + if (!empty($this->config['notify_url'])) { + $macParams['NOTIFY_URL'] = urlencode($this->config['notify_url']); + } + + // 2. 构建签名字符串(按照定义顺序拼接,不排序!) + $signString = http_build_query($macParams); + + // 3. 添加PLATFORMPUB参与MD5签名(但不作为HTTP参数传递) + $platformPubKey = $this->config['public_key']; // 服务方公钥 + $macSignString = $signString . '&PLATFORMPUB=' . $platformPubKey; + + // 4. 生成MAC签名(32位小写MD5) + $mac = strtolower(md5($macSignString)); + + // 5. 构建不参与MAC的参数 + $nonMacParams = []; + + // 微信支付19位终端号(不参与MAC校验) + if (!empty($this->config['pos_id_19'])) { + $nonMacParams['POSID19'] = $this->config['pos_id_19']; } // 场景编号(埋点使用,不参与MAC校验) if (!empty($this->config['scn_id'])) { - $paymentParams['SCNID'] = $this->config['scn_id']; + $nonMacParams['SCNID'] = $this->config['scn_id']; } if (!empty($this->config['scn_pltfrm_id'])) { - $paymentParams['SCN_PLTFRM_ID'] = $this->config['scn_pltfrm_id']; + $nonMacParams['SCN_PLTFRM_ID'] = $this->config['scn_pltfrm_id']; } - // 按ASCII排序 - ksort($paymentParams); - - // 生成签名字符串 - $signString = http_build_query($paymentParams); - - // ⚠️ 建行支付串签名规则(v2.2版本): - // 1. PLATFORMPUB字段仅参与MD5计算,不作为HTTP参数传递 - // 2. 签名 = MD5(参数字符串 + &PLATFORMPUB= + 服务方公钥内容) - // 3. 生成32位小写MD5字符串(根据建行文档5.4.1要求) - $platformPubKey = $this->config['public_key']; // 服务方公钥 - $mac = strtolower(md5($signString . '&PLATFORMPUB=' . $platformPubKey)); - - // ✅ 修复:使用 CcbRSA 加密商户公钥后30位(用于ENCPUB字段) - // 删除 CcbEncryption 类,统一使用 CcbRSA 处理密钥格式化 + // 6. 生成ENCPUB(商户公钥密文,不参与MAC校验) $encpub = $this->encryptPublicKeyLast30(); - // 组装最终支付串 - $finalPaymentString = $signString . '&MAC=' . $mac . '&PLATFORMID=' . $this->config['service_id'] . '&ENCPUB=' . urlencode($encpub); + // 7. 组装最终支付串 + // 格式:参与MAC的参数 + 不参与MAC的参数 + MAC + PLATFORMID + ENCPUB + $finalPaymentString = $signString; + + // 添加不参与MAC的参数 + if (!empty($nonMacParams)) { + $finalPaymentString .= '&' . http_build_query($nonMacParams); + } + + // 添加MAC、PLATFORMID、ENCPUB + $finalPaymentString .= '&MAC=' . $mac . '&PLATFORMID=' . $this->config['service_id'] . '&ENCPUB=' . urlencode($encpub); // 保存支付流水号到订单 Order::where('id', $orderId)->update([ @@ -225,7 +264,7 @@ class CcbPaymentService // 记录支付请求 $this->recordPaymentRequest($orderId, [ 'payment_string' => $finalPaymentString, - 'params' => $paymentParams, + 'params' => $macParams, 'mac' => $mac, 'pay_flow_id' => $payFlowId ]); @@ -705,6 +744,11 @@ class CcbPaymentService /** * 记录支付请求 * + * ⚠️ 幂等性说明: + * 1. 当用户多次点击支付按钮时,会复用同一个pay_flow_id + * 2. 本方法会先检查是否已存在记录,如果存在则更新,否则插入 + * 3. 这样可以避免唯一键冲突,并记录最新的支付串生成时间 + * * @param int $orderId 订单ID * @param array $paymentData 支付数据 */ @@ -714,18 +758,43 @@ class CcbPaymentService $order = Order::find($orderId); $user = Db::name('user')->where('id', $order['user_id'])->field('ccb_user_id')->find(); - // 记录到建行支付日志表 - Db::name('ccb_payment_log')->insert([ + $payFlowId = $paymentData['pay_flow_id'] ?? ''; + + // 检查是否已存在记录(根据pay_flow_id唯一键) + $existingLog = Db::name('ccb_payment_log') + ->where('pay_flow_id', $payFlowId) + ->find(); + + $logData = [ 'order_id' => $orderId, 'order_sn' => $order['order_sn'], - 'pay_flow_id' => $paymentData['pay_flow_id'] ?? '', // ✅ 使用真实的支付流水号 'payment_string' => $paymentData['payment_string'] ?? '', 'user_id' => $order['user_id'], 'ccb_user_id' => $user['ccb_user_id'] ?? '', 'amount' => $order['pay_fee'], // 使用Shopro的pay_fee字段 'status' => 0, // 待支付 - 'create_time' => time() - ]); + ]; + + if ($existingLog) { + // 已存在记录,更新支付串(保留原create_time,重置status为待支付) + // 注意:用户重复点击支付时,会生成新的支付串,需要更新 + Db::name('ccb_payment_log') + ->where('pay_flow_id', $payFlowId) + ->update([ + 'payment_string' => $logData['payment_string'], + 'status' => 0, // 重置为待支付(因为是新的支付串) + 'amount' => $logData['amount'], // 更新金额(订单金额可能变化) + ]); + + Log::info('[建行支付] 更新支付日志 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId); + } else { + // 不存在记录,插入新记录 + $logData['pay_flow_id'] = $payFlowId; + $logData['create_time'] = time(); + Db::name('ccb_payment_log')->insert($logData); + + Log::info('[建行支付] 插入支付日志 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId); + } } /** diff --git a/addons/shopro/library/ccblife/CcbRSA.php b/addons/shopro/library/ccblife/CcbRSA.php index a3ee74b..a6c24a4 100644 --- a/addons/shopro/library/ccblife/CcbRSA.php +++ b/addons/shopro/library/ccblife/CcbRSA.php @@ -239,4 +239,78 @@ class CcbRSA 'private_key' => $privateKey ]; } + + /** + * RSA公钥验签(用于建行回调通知) + * + * 建行回调通知中的 SIGN 字段是使用商户私钥签名的, + * 服务方需要使用建行公钥进行验签 + * + * @param string $data 待验签的原始数据 + * @param string $signature 签名字符串(十六进制) + * @param string $publicKey 建行公钥(BASE64编码) + * @return bool 验签是否成功 + * @throws \Exception + */ + public static function verify($data, $signature, $publicKey) + { + // 格式化公钥 + $publicKey = self::formatPublicKey($publicKey); + + // 加载公钥资源 + $pubKey = openssl_pkey_get_public($publicKey); + if (!$pubKey) { + throw new \Exception('公钥格式错误: ' . openssl_error_string()); + } + + // 将十六进制签名转换为二进制 + $signatureBinary = hex2bin($signature); + if ($signatureBinary === false) { + throw new \Exception('签名格式错误:无法从十六进制转换'); + } + + // 使用公钥验签(SHA256算法) + $result = openssl_verify($data, $signatureBinary, $pubKey, OPENSSL_ALGO_SHA256); + + openssl_free_key($pubKey); + + if ($result === 1) { + return true; // 验签成功 + } elseif ($result === 0) { + return false; // 验签失败 + } else { + throw new \Exception('验签过程出错: ' . openssl_error_string()); + } + } + + /** + * 建行通知验签(针对回调通知) + * + * 用于验证建行支付通知和退款通知的签名 + * + * @param array $params 通知参数(不包含SIGN字段) + * @param string $signature SIGN字段的值 + * @param string $ccbPublicKey 建行公钥 + * @return bool 验签是否成功 + * @throws \Exception + */ + public static function verifyNotify($params, $signature, $ccbPublicKey) + { + // 移除 SIGN 字段(如果存在) + unset($params['SIGN']); + + // 按照建行规范拼接验签字符串 + // 格式:将参数按字典序排列后拼接(key=value&key=value) + ksort($params); + $signStr = ''; + foreach ($params as $key => $value) { + if ($value !== '' && $value !== null) { + $signStr .= $key . '=' . $value . '&'; + } + } + $signStr = rtrim($signStr, '&'); + + // 调用验签方法 + return self::verify($signStr, $signature, $ccbPublicKey); + } } \ No newline at end of file