This commit is contained in:
龚浩星 2025-10-22 23:06:52 +08:00
commit f29cfc81a0
5 changed files with 746 additions and 302 deletions

View File

@ -27,7 +27,7 @@ class Ccbpayment extends Common
* 不需要登录的方法 (支付回调不需要登录) * 不需要登录的方法 (支付回调不需要登录)
* @var array * @var array
*/ */
protected $noNeedLogin = ['notify']; protected $noNeedLogin = ['notify', 'refundNotify'];
/** /**
* 不需要权限的方法 * 不需要权限的方法
@ -73,9 +73,10 @@ class Ccbpayment extends Common
$this->error('订单ID不能为空'); $this->error('订单ID不能为空');
} }
// 2. 查询订单 // 2. 查询订单包含ccb_pay_flow_id字段用于判断是否已推送
$order = OrderModel::where('id', $orderId) $order = OrderModel::where('id', $orderId)
->where('user_id', $this->auth->id) ->where('user_id', $this->auth->id)
->field('id, order_sn, status, pay_fee, ccb_pay_flow_id')
->find(); ->find();
if (!$order) { if (!$order) {
@ -87,12 +88,17 @@ class Ccbpayment extends Common
$this->error('订单已支付或已关闭'); $this->error('订单已支付或已关闭');
} }
// 4. ✅ 生成支付流水号(统一标识,用于订单推送和支付串生成) // 4. ✅ 判断是否已推送过订单根据ccb_pay_flow_id判断
$payFlowId = $order['ccb_pay_flow_id'];
$needPushOrder = empty($payFlowId);
if ($needPushOrder) {
// 4.1 生成新的支付流水号(统一标识,用于订单推送和支付串生成)
// 格式: PAY + 年月日时分秒(14位) + 随机数(6位) = 23位 // 格式: PAY + 年月日时分秒(14位) + 随机数(6位) = 23位
$payFlowId = 'PAY' . date('YmdHis') . mt_rand(100000, 999999); $payFlowId = 'PAY' . date('YmdHis') . mt_rand(100000, 999999);
Log::info('[建行支付] 生成支付流水号 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId); Log::info('[建行支付] 首次支付,生成支付流水号 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId);
// 5. ✅ 先推送订单到建行生活步骤2调用A3341TP01订单推送接口 // 4.2 推送订单到建行生活步骤2调用A3341TP01订单推送接口
// ⚠️ 重要:必须先推送订单,收银台才能校验订单信息 // ⚠️ 重要:必须先推送订单,收银台才能校验订单信息
// 根据《5.6.2 业务流程说明》步骤2由服务方调用订单推送接口向建行生活推送订单信息 // 根据《5.6.2 业务流程说明》步骤2由服务方调用订单推送接口向建行生活推送订单信息
try { try {
@ -111,10 +117,14 @@ class Ccbpayment extends Common
Log::error('[建行支付] 订单推送异常(阻塞支付) order_id:' . $orderId . ' error:' . $e->getMessage()); Log::error('[建行支付] 订单推送异常(阻塞支付) order_id:' . $orderId . ' error:' . $e->getMessage());
$this->error('订单推送异常,无法生成支付串: ' . $e->getMessage()); $this->error('订单推送异常,无法生成支付串: ' . $e->getMessage());
} }
} else {
// 4.3 已推送过,复用现有支付流水号
Log::info('[建行支付] 订单已推送过,复用支付流水号 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId);
}
// 6. 生成支付串步骤3调用收银台 // 5. 生成支付串步骤3调用收银台
// ⚠️ 注意: generatePaymentString()内部已经完成了以下操作: // ⚠️ 注意: generatePaymentString()内部已经完成了以下操作:
// - 更新订单的ccb_pay_flow_id字段 // - 更新订单的ccb_pay_flow_id字段(幂等操作,重复更新不影响)
// - 记录支付日志到ccb_payment_log表 // - 记录支付日志到ccb_payment_log表
// 控制器不应该重复操作,否则会导致数据重复写入! // 控制器不应该重复操作,否则会导致数据重复写入!
$result = $this->paymentService->generatePaymentString($orderId, $payFlowId); $result = $this->paymentService->generatePaymentString($orderId, $payFlowId);
@ -123,7 +133,7 @@ class Ccbpayment extends Common
$this->error('支付串生成失败: ' . $result['message']); $this->error('支付串生成失败: ' . $result['message']);
} }
// 7. 返回支付串给前端调用收银台 // 6. 返回支付串给前端调用收银台
$this->success('支付串生成成功', $result['data']); $this->success('支付串生成成功', $result['data']);
} catch (Exception $e) { } catch (Exception $e) {
@ -137,22 +147,21 @@ class Ccbpayment extends Common
* 查询订单支付状态 (前端轮询用) * 查询订单支付状态 (前端轮询用)
* *
* ⚠️ 重要说明: * ⚠️ 重要说明:
* 本接口只查询订单状态,不执行任何业务逻辑! * 1. 本接口首先查询本地订单状态(已支付直接返回)
* 订单状态由建行异步通知(notify)接口更新,前端只负责轮询查询。 * 2. 若本地未支付则调用建行API查询实际支付状态补偿机制
* 3. 若建行返回已支付,更新本地订单并同步到建行外联系统
* 4. 使用事务+行锁保证幂等性避免与notify()冲突
* *
* 修改原因: * 流程:
* 原callback()方法存在严重安全漏洞: * 前端调起支付 建行处理 建行异步通知notify() (主流程)
* 1. 前端可伪造支付成功请求 * 前端轮询本接口 (补偿机制)
* 2. 与notify()形成双通道,存在竞态条件
* 3. 违反建行标准支付流程
*
* 正确流程:
* 前端调起支付 建行处理 建行异步通知notify() 前端轮询本接口查询状态
* *
* @return void * @return void
*/ */
public function queryPaymentStatus() public function queryPaymentStatus()
{ {
$orderId = null;
try { try {
// 1. 获取订单ID // 1. 获取订单ID
$orderId = $this->request->get('order_id', 0); $orderId = $this->request->get('order_id', 0);
@ -161,28 +170,192 @@ class Ccbpayment extends Common
$this->error('订单ID不能为空'); $this->error('订单ID不能为空');
} }
// 2. 查询订单(只查询,不更新!) // 2. 查询本地订单状态(不加锁,快速返回)
$order = OrderModel::where('id', $orderId) $order = OrderModel::where('id', $orderId)
->where('user_id', $this->auth->id) ->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(); ->find();
if (!$order) { if (!$order) {
$this->error('订单不存在'); $this->error('订单不存在');
} }
// 3. 返回订单状态(只读操作,绝不修改数据!) // 3. 如果订单已支付直接返回幂等性避免重复查询建行API
if (in_array($order->status, ['paid', 'completed', 'success'])) {
Log::info('[建行支付查询] 订单已支付,直接返回 order_id:' . $orderId . ' order_sn:' . $order->order_sn);
$this->success('查询成功', [ $this->success('查询成功', [
'order_id' => $order->id, 'order_id' => $order->id,
'order_sn' => $order->order_sn, 'order_sn' => $order->order_sn,
'status' => $order->status, 'status' => $order->status,
'is_paid' => in_array($order->status, ['paid', 'completed', 'success']), 'is_paid' => true,
'paid_time' => $order->paid_time, 'paid_time' => $order->paid_time,
'pay_flow_id' => $order->ccb_pay_flow_id, '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) { } catch (Exception $e) {
Log::error('[建行支付] 查询订单状态失败 order_id:' . ($orderId ?? 0) . ' error:' . $e->getMessage()); // 回滚事务
Db::rollback();
throw $e;
}
} catch (Exception $e) {
Log::error('[建行支付查询] 查询订单状态失败 order_id:' . ($orderId ?? 0) . ' error:' . $e->getMessage());
$this->error('查询失败: ' . $e->getMessage()); $this->error('查询失败: ' . $e->getMessage());
} }
@ -206,12 +379,23 @@ class Ccbpayment extends Common
* *
* @return void * @return void
*/ */
/**
* 建行生活支付通知接口
*
* 📋 接口说明文档7.1
* - 建行生活主动推送支付结果
* - 不会附带服务方编号
* - 通过 REMARK2 字段识别服务方
* - SIGN 字段使用商户私钥签名,需用建行公钥验签
*
* @return void
*/
public function notify() public function notify()
{ {
try { try {
// 1. 获取原始请求数据 // 1. 获取原始请求数据
$rawData = file_get_contents('php://input'); $rawData = file_get_contents('php://input');
Log::info('[建行通知] 收到异步通知: ' . $rawData); Log::info('[建行支付通知] 收到异步通知: ' . $rawData);
// 2. 解析POST参数 // 2. 解析POST参数
$params = $this->request->post(); $params = $this->request->post();
@ -222,51 +406,98 @@ class Ccbpayment extends Common
} }
// 4. 记录参数 // 4. 记录参数
Log::info('[建行通知] 解析参数: ' . json_encode($params, JSON_UNESCAPED_UNICODE)); Log::info('[建行支付通知] 解析参数: ' . json_encode($params, JSON_UNESCAPED_UNICODE));
// 5. 验证必需参数 // 5. 验证必需参数
if (empty($params['ORDERID'])) { if (empty($params['ORDERID'])) {
Log::error('[建行通知] 缺少ORDERID参数'); Log::error('[建行支付通知] 缺少ORDERID参数');
exit('FAIL'); 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); $result = $this->paymentService->handleNotify($params);
// 7. ✅ 处理成功后更新订单状态到建行(步骤13:调用订单更新接口更新订单状态) // 9. ✅ 处理成功后更新订单状态到建行(步骤13:调用订单更新接口更新订单状态)
if ($result['status'] === 'success' && !empty($result['order_id'])) { if ($result['status'] === 'success' && !empty($result['order_id'])) {
// ⚠️ 只有新支付才更新,已支付的订单跳过更新 // ⚠️ 只有新支付才更新,已支付的订单跳过更新
if ($result['already_paid'] === false) { if ($result['already_paid'] === false) {
try { try {
// 调用订单更新接口,将订单状态从未支付更新为已支付 // 调用订单更新接口,将订单状态从未支付更新为已支付
$updateResult = $this->orderService->updateOrderStatus($result['order_id']); $updateResult = $this->orderService->updateOrderStatus($result['order_id'], '1'); // 1-支付成功
if ($updateResult['status']) { if ($updateResult['status']) {
Log::info('[建行通知] 订单状态更新成功 order_id:' . $result['order_id']); Log::info('[建行支付通知] 订单状态更新成功 order_id:' . $result['order_id']);
} else { } else {
// ⚠️ 更新失败不影响本地支付状态,记录日志后续补推 // ⚠️ 更新失败不影响本地支付状态,记录日志后续补推
Log::warning('[建行通知] 订单状态更新失败(本地已支付) order_id:' . $result['order_id'] . ' error:' . $updateResult['message']); Log::warning('[建行支付通知] 订单状态更新失败(本地已支付) order_id:' . $result['order_id'] . ' error:' . $updateResult['message']);
} }
} catch (Exception $e) { } catch (Exception $e) {
// ⚠️ 更新异常不影响支付成功,记录日志后续补推 // ⚠️ 更新异常不影响支付成功,记录日志后续补推
Log::error('[建行通知] 订单状态更新异常(本地已支付) order_id:' . $result['order_id'] . ' error:' . $e->getMessage()); Log::error('[建行支付通知] 订单状态更新异常(本地已支付) order_id:' . $result['order_id'] . ' error:' . $e->getMessage());
} }
} else { } else {
Log::info('[建行通知] 订单已支付且已更新,跳过更新 order_id:' . $result['order_id']); Log::info('[建行支付通知] 订单已支付且已更新,跳过更新 order_id:' . $result['order_id']);
} }
} }
// 8. 返回处理结果 // 10. 返回处理结果
// ⚠️ 重要: 必须使用exit直接退出,防止ThinkPHP框架追加额外内容 // ⚠️ 重要: 必须使用exit直接退出,防止ThinkPHP框架追加额外内容
// 建行要求返回纯文本 'SUCCESS' 或 'FAIL',任何额外字符都会导致建行认为通知失败 // 建行要求返回纯文本 'SUCCESS' 或 'FAIL',任何额外字符都会导致建行认为通知失败
$response = ($result['status'] === 'success') ? 'SUCCESS' : 'FAIL'; $response = ($result['status'] === 'success') ? 'SUCCESS' : 'FAIL';
Log::info('[建行通知] 处理完成,返回: ' . $response); Log::info('[建行支付通知] 处理完成,返回: ' . $response);
// 直接退出,确保只输出SUCCESS/FAIL // 直接退出,确保只输出SUCCESS/FAIL
exit($response); exit($response);
} catch (Exception $e) { } catch (Exception $e) {
Log::error('[建行通知] 处理失败 error:' . $e->getMessage()); Log::error('[建行支付通知] 处理失败 error:' . $e->getMessage());
// 异常情况也要直接退出 // 异常情况也要直接退出
exit('FAIL'); exit('FAIL');

View File

@ -321,85 +321,147 @@ class CcbHttpClient
/** /**
* 订单状态更新A3341TP02 * 订单状态更新A3341TP02
* *
* @param string $userId 用户ID * @param string $orderId 订单编号用户订单号对应收银台USER_ORDERID字段
* @param string $orderId 订单ID * @param string $informId 通知类型0-支付状态修改 1-退款状态修改)
* @param string $orderStatus 订单状态 * @param string $payFlowId 支付流水号对应收银台ORDERID字段
* @param string $refundStatus 退款状态 * @param string $payMrchId 支付商户号
* @param array $additionalParams 额外参数PAY_STATUS、REFUND_STATUS、PAY_AMT、DISCOUNT_AMT、CUS_ORDER_URL等
* @return array 响应数据 * @return array 响应数据
* @throws \Exception * @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 = [ $body = [
'USER_ID' => $userId,
'ORDER_ID' => $orderId, 'ORDER_ID' => $orderId,
'ORDER_STATUS' => $orderStatus, 'INFORM_ID' => $informId,
'REFUND_STATUS' => $refundStatus '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); return $this->request($this->config['tx_codes']['order_update'], $body);
} }
/** /**
* 订单查询A3341TP03 * 订单查询A3341TP03
* *
* @param string $onlnPyTxnOrdrId 支付订单ID * @param string $onlnPyTxnOrdrId 订单编号调用收银台时支付流水号对应字段ORDERID
* @param string $txnStatus 交易状态 * @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 响应数据 * @return array 响应数据
* @throws \Exception * @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 = [ $body = [
'TX_TYPE' => $txType,
'TXN_PRD_TPCD' => '99', // 99-自定义时间段查询(文档要求)
'STDT_TM' => $startTime,
'EDDT_TM' => $endTime,
'ONLN_PY_TXN_ORDR_ID' => $onlnPyTxnOrdrId, 'ONLN_PY_TXN_ORDR_ID' => $onlnPyTxnOrdrId,
'PAGE' => '1',
'TXN_PRD_TPCD' => '06',
'TXN_STATUS' => $txnStatus, '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); return $this->request($this->config['tx_codes']['order_query'], $body);
} }
/** /**
* 退款接口A3341TP04 * 退款接口A3341TP04
* *
* @param string $orderId 订单ID * @param string $orderId 订单号调用收银台时支付流水号对应字段ORDERID
* @param string $refundAmount 退款金额 * @param float|string $refundAmount 退款金额(单位:元)
* @param string $refundReason 退款原因 * @param string|int $payTime 支付时间(时间戳或日期时间字符串)
* @param string|null $refundCode 退款流水号(可选,建议填写用于查询退款结果)
* @param array $additionalParams 额外参数PLAT_MCT_ID、CUSTOMERID、BRANCHID等
* @return array 响应数据 * @return array 响应数据
* @throws \Exception * @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 = [ $body = [
'ORDER_ID' => $orderId, 'ORDER' => $orderId, // 注意:字段名是 ORDER不是 ORDER_ID
'REFUND_AMOUNT' => $refundAmount, 'MONEY' => $refundAmount, // 注意:字段名是 MONEY不是 REFUND_AMOUNT
'REFUND_REASON' => $refundReason, 'STDT_TM' => $stat_tm,
'REFUND_TIME' => date('YmdHis') '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 $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;
}
}
} }

View File

@ -98,8 +98,8 @@ class CcbOrderService
$costTime = round((microtime(true) - $startTime) * 1000, 2); $costTime = round((microtime(true) - $startTime) * 1000, 2);
$this->recordSyncLog($orderId, 'A3341TP01', $txSeq, $response, 'response', true, $costTime); $this->recordSyncLog($orderId, 'A3341TP01', $txSeq, $response, 'response', true, $costTime);
// 更新订单同步状态 // 更新订单同步状态为成功(清空错误信息)
$this->updateOrderSyncStatus($orderId, 1); $this->updateOrderSyncStatus($orderId, 1, '');
return [ return [
'status' => true, 'status' => true,
@ -112,8 +112,8 @@ class CcbOrderService
$costTime = round((microtime(true) - $startTime) * 1000, 2); $costTime = round((microtime(true) - $startTime) * 1000, 2);
$this->recordSyncLog($orderId, 'A3341TP01', '', null, 'error', false, $costTime, $e->getMessage()); $this->recordSyncLog($orderId, 'A3341TP01', '', null, 'error', false, $costTime, $e->getMessage());
// 更新同步状态为失败 // 更新同步状态为失败,并保存错误信息
$this->updateOrderSyncStatus($orderId, 2); $this->updateOrderSyncStatus($orderId, 2, $e->getMessage());
Log::error('建行订单推送失败: ' . $e->getMessage()); Log::error('建行订单推送失败: ' . $e->getMessage());
return [ return [
@ -128,8 +128,8 @@ class CcbOrderService
* 更新订单状态到建行生活 * 更新订单状态到建行生活
* *
* @param int $orderId 订单ID * @param int $orderId 订单ID
* @param string $status 订单状态 * @param string|null $status 支付状态0-待支付 1-支付成功 2-已过期 3-支付失败 4-取消)
* @param string $refundStatus 退款状态 * @param string|null $refundStatus 退款状态0-无退款 1-退款申请 2-已退款 3-部分退款)
* @return array * @return array
*/ */
public function updateOrderStatus($orderId, $status = null, $refundStatus = null) public function updateOrderStatus($orderId, $status = null, $refundStatus = null)
@ -138,7 +138,7 @@ class CcbOrderService
$txSeq = CcbMD5::generateTransactionSeq(); $txSeq = CcbMD5::generateTransactionSeq();
try { try {
// 获取订单信息 // 获取订单信息(包含支付流水号)
$order = Db::name('shopro_order') $order = Db::name('shopro_order')
->alias('o') ->alias('o')
->join('user u', 'o.user_id = u.id', 'LEFT') ->join('user u', 'o.user_id = u.id', 'LEFT')
@ -150,31 +150,60 @@ class CcbOrderService
throw new \Exception('订单不存在'); throw new \Exception('订单不存在');
} }
// 获取建行用户ID // 获取支付流水号
$ccbUserId = $order['ccb_user_id']; $payFlowId = $order['pay_flow_id'] ?? null;
if (!$ccbUserId) { if (empty($payFlowId)) {
throw new \Exception('用户未绑定建行生活账号'); throw new \Exception('订单缺少支付流水号,无法更新状态到建行');
}
// 获取支付商户号
$payMrchId = $this->config['merchant_id'] ?? null;
if (empty($payMrchId)) {
throw new \Exception('配置中缺少支付商户号merchant_id');
} }
// 映射订单状态 // 映射订单状态
$orderStatus = $status ?: $this->mapOrderStatus($order['status']); $payStatus = $status ?: $this->mapOrderStatus($order['status']);
$refundStatus = $refundStatus ?: $this->mapRefundStatus($order['refund_status'] ?? 0); $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 = [ $requestData = [
'ccb_user_id' => $ccbUserId, 'order_id' => $order['order_sn'],
'order_sn' => $order['order_sn'], 'inform_id' => $informId,
'order_status' => $orderStatus, 'pay_flow_id' => $payFlowId,
'refund_status' => $refundStatus 'pay_mrch_id' => $payMrchId,
'additional_params' => $additionalParams
]; ];
$this->recordSyncLog($orderId, 'A3341TP02', $txSeq, $requestData, 'request'); $this->recordSyncLog($orderId, 'A3341TP02', $txSeq, $requestData, 'request');
// 调用建行API更新状态 // 调用建行API更新状态(使用新接口)
$response = $this->httpClient->updateOrderStatus( $response = $this->httpClient->updateOrderStatus(
$ccbUserId, $order['order_sn'], // 订单编号
$order['order_sn'], $informId, // 通知类型
$orderStatus, $payFlowId, // 支付流水号
$refundStatus $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 * @return array
*/ */
public function queryOrder($orderSn) public function queryOrder($orderSn, $startTime = null, $endTime = null, $page = 1, $txType = '0', $txnStatus = '00')
{ {
try { try {
// 调用建行API查询订单 // 调用建行API查询订单使用新接口
$response = $this->httpClient->queryOrder($orderSn); $response = $this->httpClient->queryOrder(
$orderSn,
$startTime,
$endTime,
$page,
$txType,
$txnStatus
);
return [ return [
'status' => true, 'status' => true,
@ -232,14 +273,14 @@ class CcbOrderService
* 处理订单退款 * 处理订单退款
* *
* @param int $orderId 订单ID * @param int $orderId 订单ID
* @param float $refundAmount 退款金额 * @param float $refundAmount 退款金额(单位:元)
* @param string $refundReason 退款原因 * @param string|null $refundCode 退款流水号(可选,建议填写用于查询退款结果)
* @return array * @return array
*/ */
public function refundOrder($orderId, $refundAmount, $refundReason = '') public function refundOrder($orderId, $refundAmount, $refundCode = null)
{ {
try { try {
// 获取订单信息 // 获取订单信息(需要支付流水号和支付时间)
$order = Db::name('shopro_order')->where('id', $orderId)->find(); $order = Db::name('shopro_order')->where('id', $orderId)->find();
if (!$order) { if (!$order) {
throw new \Exception('订单不存在'); throw new \Exception('订单不存在');
@ -250,11 +291,24 @@ class CcbOrderService
throw new \Exception('退款金额不能超过订单总额'); 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( $response = $this->httpClient->refund(
$order['order_sn'], $payFlowId, // 支付流水号对应收银台ORDERID
number_format($refundAmount, 2, '.', ''), $refundAmount, // 退款金额
$refundReason $payTime, // 支付时间(用于计算查询时间范围)
$refundCode // 退款流水号(可选)
); );
// 更新订单退款状态 // 更新订单退款状态
@ -515,14 +569,26 @@ class CcbOrderService
* *
* @param int $orderId 订单ID * @param int $orderId 订单ID
* @param int $status 同步状态0-未同步 1-已同步 2-同步失败 * @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_status' => $status,
'ccb_sync_time' => time(), 'ccb_sync_time' => time(),
'updatetime' => 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'; // 已退款 if ($refundStatus == 2) return '2'; // 已退款
return '0'; 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
];
}
}
} }

View File

@ -82,139 +82,178 @@ class CcbPaymentService
throw new \Exception('支付流水号不能为空'); throw new \Exception('支付流水号不能为空');
} }
// ✅ 构建完整的48个支付参数按照建行文档5.4完整参数定义) // ⚠️ 关键建行要求参数按照文档表格定义的顺序拼接不是ASCII排序
// 基础商户参数(必须二选一:建行商户号组合 或 外部平台商户号) // 根据建行文档4.1和4.2,必须严格按照参数表顺序构建签名字符串
$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', // 币种T01=人民币)
'TXCODE' => '520100', // 交易码T520100=即时支付)
'REMARK1' => '', // 备注1T
'REMARK2' => $this->config['service_id'], // 备注2T服务方编号
'TYPE' => '1', // 接口类型T1=防钓鱼)
'GATEWAY' => '0', // 网关类型T
'CLIENTIP' => $this->getClientIp(), // 客户端IPT
'REGINFO' => '', // 客户注册信息T中文需escape编码
'PROINFO' => $this->buildProductInfo($order), // 商品信息T中文已escape编码
'REFERER' => '', // 商户URLT
'THIRDAPPINFO' => 'comccbpay1234567890cloudmerchant', // 客户端标识T固定值
'TIMEOUT' => date('YmdHis', strtotime('+30 minutes')), // 超时时间F格式YYYYMMDDHHmmss
];
// ✅ 可选参数(根据实际场景添加) // 1. 定义参与MAC签名的参数数组按文档表格顺序
// 外部平台商户号(与建行商户号二选一) $macParams = [];
if (!empty($this->config['plat_mct_id'])) {
$paymentParams['PLATMCTID'] = $this->config['plat_mct_id']; // 1.1 商户信息(必填,二选一:建行商户号组合 或 外部平台商户号)
// 使用外部商户号时,删除建行商户号 $usePlatMctId = !empty($this->config['plat_mct_id']);
unset($paymentParams['MERCHANTID'], $paymentParams['POSID'], $paymentParams['BRANCHID']); 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位终端号 // 1.2 订单信息(必填)
if (!empty($this->config['pos_id_19'])) { $macParams['ORDERID'] = $payFlowId; // 支付流水号
$paymentParams['POSID19'] = $this->config['pos_id_19']; $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'; // 客户端标识(固定值)
// 支付位图(控制支付方式:生活钱包/龙支付/微信/数币/信用付/快贷) // 1.3 可选参数按文档表格顺序有值才参与MAC
if (!empty($this->config['pay_bitmap'])) { // ⚠️ 注意根据文档4.2橙色字段有值时才参与MAC空值不参与
$paymentParams['PAYBITMAP'] = $this->config['pay_bitmap'];
}
// 账户位图(控制支付账户:建行借记卡/贷记卡/他行借记卡/贷记卡/建行钱包) // 分期期数在THIRDAPPINFO之后
if (!empty($this->config['account_bitmap'])) {
$paymentParams['ACCOUNTBITMAP'] = $this->config['account_bitmap'];
}
// 分期期数
if (!empty($this->config['install_num'])) { 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'])) { 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'])) { 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') { if ($this->config['dcep_mct_type'] == '2') {
// 非融合商户需要填写数币商户号 // 非融合商户需要填写数币商户号
$paymentParams['DCEP_MERCHANTID'] = $this->config['dcep_merchant_id'] ?? ''; if (!empty($this->config['dcep_merchant_id'])) {
$paymentParams['DCEP_POSID'] = $this->config['dcep_pos_id'] ?? ''; $macParams['DCEP_MERCHANTID'] = $this->config['dcep_merchant_id'];
$paymentParams['DCEP_BRANCHID'] = $this->config['dcep_branch_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'])) { 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'])) { 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'])) { 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'])) { 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校验 // 场景编号埋点使用不参与MAC校验
if (!empty($this->config['scn_id'])) { 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'])) { 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排序 // 6. 生成ENCPUB商户公钥密文不参与MAC校验
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 处理密钥格式化
$encpub = $this->encryptPublicKeyLast30(); $encpub = $this->encryptPublicKeyLast30();
// 组装最终支付串 // 7. 组装最终支付串
$finalPaymentString = $signString . '&MAC=' . $mac . '&PLATFORMID=' . $this->config['service_id'] . '&ENCPUB=' . urlencode($encpub); // 格式参与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([ Order::where('id', $orderId)->update([
@ -225,7 +264,7 @@ class CcbPaymentService
// 记录支付请求 // 记录支付请求
$this->recordPaymentRequest($orderId, [ $this->recordPaymentRequest($orderId, [
'payment_string' => $finalPaymentString, 'payment_string' => $finalPaymentString,
'params' => $paymentParams, 'params' => $macParams,
'mac' => $mac, 'mac' => $mac,
'pay_flow_id' => $payFlowId 'pay_flow_id' => $payFlowId
]); ]);
@ -705,6 +744,11 @@ class CcbPaymentService
/** /**
* 记录支付请求 * 记录支付请求
* *
* ⚠️ 幂等性说明:
* 1. 当用户多次点击支付按钮时会复用同一个pay_flow_id
* 2. 本方法会先检查是否已存在记录,如果存在则更新,否则插入
* 3. 这样可以避免唯一键冲突,并记录最新的支付串生成时间
*
* @param int $orderId 订单ID * @param int $orderId 订单ID
* @param array $paymentData 支付数据 * @param array $paymentData 支付数据
*/ */
@ -714,18 +758,43 @@ class CcbPaymentService
$order = Order::find($orderId); $order = Order::find($orderId);
$user = Db::name('user')->where('id', $order['user_id'])->field('ccb_user_id')->find(); $user = Db::name('user')->where('id', $order['user_id'])->field('ccb_user_id')->find();
// 记录到建行支付日志表 $payFlowId = $paymentData['pay_flow_id'] ?? '';
Db::name('ccb_payment_log')->insert([
// 检查是否已存在记录根据pay_flow_id唯一键
$existingLog = Db::name('ccb_payment_log')
->where('pay_flow_id', $payFlowId)
->find();
$logData = [
'order_id' => $orderId, 'order_id' => $orderId,
'order_sn' => $order['order_sn'], 'order_sn' => $order['order_sn'],
'pay_flow_id' => $paymentData['pay_flow_id'] ?? '', // ✅ 使用真实的支付流水号
'payment_string' => $paymentData['payment_string'] ?? '', 'payment_string' => $paymentData['payment_string'] ?? '',
'user_id' => $order['user_id'], 'user_id' => $order['user_id'],
'ccb_user_id' => $user['ccb_user_id'] ?? '', 'ccb_user_id' => $user['ccb_user_id'] ?? '',
'amount' => $order['pay_fee'], // 使用Shopro的pay_fee字段 'amount' => $order['pay_fee'], // 使用Shopro的pay_fee字段
'status' => 0, // 待支付 '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);
}
} }
/** /**

View File

@ -239,4 +239,78 @@ class CcbRSA
'private_key' => $privateKey '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);
}
} }