2025-10-27 20:28:01 +08:00

505 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace addons\shopro\controller;
use addons\shopro\controller\Common;
use addons\shopro\library\ccblife\CcbPaymentService;
use addons\shopro\library\ccblife\CcbOrderService;
use app\admin\model\shopro\order\Order as OrderModel;
use think\Db;
use think\Exception;
use think\Log;
/**
* 建行支付控制器
*
* 功能:
* - 生成支付串
* - 处理支付回调
* - 验证支付结果
*
* @author Billy
* @date 2025-01-16
*/
class Ccbpayment extends Common
{
/**
* 不需要登录的方法 (支付回调不需要登录)
* @var array
*/
protected $noNeedLogin = ['notify', 'refundNotify'];
/**
* 不需要权限的方法
* @var array
*/
protected $noNeedRight = ['*'];
/**
* 支付服务
* @var CcbPaymentService
*/
private $paymentService;
/**
* 订单服务
* @var CcbOrderService
*/
private $orderService;
/**
* 初始化
*/
public function _initialize()
{
parent::_initialize();
$this->paymentService = new CcbPaymentService();
$this->orderService = new CcbOrderService();
}
/**
* 生成支付串
*
* @return void
*/
public function createPayment()
{
try {
// 1. 获取订单ID
$orderId = $this->request->post('order_id', 0);
if (empty($orderId)) {
$this->error('订单ID不能为空');
}
// 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) {
$this->error('订单不存在');
}
// 3. 检查订单状态
if ($order['status'] != 'unpaid') {
$this->error('订单已支付或已关闭');
}
// 4. ✅ 判断是否已推送过订单根据ccb_pay_flow_id判断
$payFlowId = $order['ccb_pay_flow_id'];
$needPushOrder = empty($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);
// 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());
}
} else {
// 4.3 已推送过,复用现有支付流水号
Log::info('[建行支付] 订单已推送过,复用支付流水号 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId);
}
// 5. 生成支付串步骤3调用收银台
$result = $this->paymentService->generatePaymentString($orderId, $payFlowId);
if (!$result['status']) {
$this->error('支付串生成失败: ' . $result['message']);
}
// 6. 返回支付串给前端调用收银台
$this->success('支付串生成成功', $result['data']);
} catch (Exception $e) {
Log::error('[建行支付] 生成支付串失败 order_id:' . ($orderId ?? 0) . ' error:' . $e->getMessage());
$this->error('生成支付串失败: ' . $e->getMessage());
}
}
/**
* 查询订单支付状态 (前端轮询用)
*
* ⚠️ 重要说明:
* 1. 本接口首先查询本地订单状态(已支付直接返回)
* 2. 若本地未支付则调用建行API查询实际支付状态补偿机制
* 3. 若建行返回已支付,更新本地订单并同步到建行外联系统
* 4. 使用事务+行锁保证幂等性避免与notify()冲突
*
* 流程:
* 前端调起支付 → 建行处理 → 建行异步通知notify() (主流程)
* → 前端轮询本接口 (补偿机制)
*
* @return void
*/
public function queryPaymentStatus()
{
$orderId = null;
try {
// 1. 获取订单ID
$orderId = $this->request->get('order_id', 0);
if (empty($orderId)) {
$this->error('订单ID不能为空');
}
// 2. 查询本地订单状态(不加锁,快速返回)
$order = OrderModel::where('id', $orderId)
->where('user_id', $this->auth->id)
->field('id, order_sn, status, paid_time, ccb_pay_flow_id, pay_fee')
->find();
if (!$order) {
$this->error('订单不存在');
}
// 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'] ?? [];
// ✅ 修复:建行实际返回字段名是 LIST不是 TXN_LST
$txnList = $ccbData['LIST'] ?? [];
// 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;
// ✅ 修复:建行实际返回的交易时间字段是 CLRG_STM_DT_TM清算时间
$ccbPayTime = $txn['CLRG_STM_DT_TM'] ?? null; // 交易时间 YYYYMMDDHHmmss
// ✅ 修复:建行实际返回的交易流水号是 EBNK_VCHR_NO电子银行凭证号
// 备选字段ATM_TXN_SNATM交易流水号
$ccbTransId = $txn['EBNK_VCHR_NO'] ?? $txn['ATM_TXN_SN'] ?? 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',
'paid_time' => $paidTime,
'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());
$this->error('查询失败: ' . $e->getMessage());
}
}
/**
* 建行支付通知 (建行服务器回调)
*
* 说明:
* 建行支付成功后,会向notify_url发送支付通知
* 这是服务器到服务器的回调,需要验签
*
* ⚠️ 重要:此接口为建行服务器异步回调,必须返回纯文本 'SUCCESS' 或 'FAIL'
*
* ✅ 正确流程:
* 1. 验证签名
* 2. 更新订单状态(由handleNotify()完成)
* 3. 推送订单到建行外联系统(本方法完成)
* 4. 返回SUCCESS给建行
*
* @return void
*/
/**
* 建行生活支付通知接口
*
* 📋 接口说明文档7.1
* - 建行生活主动推送支付结果
* - 不会附带服务方编号
* - 通过 REMARK2 字段识别服务方
* - SIGN 字段使用商户私钥签名,需用建行公钥验签
*
* @return void
*/
public function notify()
{
try {
// 1. 获取原始请求数据
$rawData = file_get_contents('php://input');
Log::info('[建行支付通知] 收到异步通知: ' . $rawData);
// 2. 解析POST参数
$params = $this->request->post();
// 3. 如果POST为空尝试解析原始数据
if (empty($params) && $rawData) {
parse_str($rawData, $params);
}
// 4. 记录参数
Log::info('[建行支付通知] 解析参数: ' . json_encode($params, JSON_UNESCAPED_UNICODE));
// 5. 验证必需参数
if (empty($params['ORDERID'])) {
Log::error('[建行支付通知] 缺少ORDERID参数');
exit('FAIL');
}
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);
// 9. ✅ 处理成功后更新订单状态到建行(步骤13:调用订单更新接口更新订单状态)
if ($result['status'] === 'success' && !empty($result['order_id'])) {
// ⚠️ 只有新支付才更新,已支付的订单跳过更新
if ($result['already_paid'] === false) {
try {
// 调用订单更新接口,将订单状态从未支付更新为已支付
$updateResult = $this->orderService->updateOrderStatus($result['order_id'], '1'); // 1-支付成功
if ($updateResult['status']) {
Log::info('[建行支付通知] 订单状态更新成功 order_id:' . $result['order_id']);
} else {
// ⚠️ 更新失败不影响本地支付状态,记录日志后续补推
Log::warning('[建行支付通知] 订单状态更新失败(本地已支付) order_id:' . $result['order_id'] . ' error:' . $updateResult['message']);
}
} catch (Exception $e) {
// ⚠️ 更新异常不影响支付成功,记录日志后续补推
Log::error('[建行支付通知] 订单状态更新异常(本地已支付) order_id:' . $result['order_id'] . ' error:' . $e->getMessage());
}
} else {
Log::info('[建行支付通知] 订单已支付且已更新,跳过更新 order_id:' . $result['order_id']);
}
}
// 10. 返回处理结果
// ⚠️ 重要: 必须使用exit直接退出,防止ThinkPHP框架追加额外内容
// 建行要求返回纯文本 'SUCCESS' 或 'FAIL',任何额外字符都会导致建行认为通知失败
$response = ($result['status'] === 'success') ? 'SUCCESS' : 'FAIL';
Log::info('[建行支付通知] 处理完成,返回: ' . $response);
// 直接退出,确保只输出SUCCESS/FAIL
exit($response);
} catch (Exception $e) {
Log::error('[建行支付通知] 处理失败 error:' . $e->getMessage());
// 异常情况也要直接退出
exit('FAIL');
}
}
}