2025-10-22 20:32:53 +08:00

479 lines
19 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. 查询订单
$order = OrderModel::where('id', $orderId)
->where('user_id', $this->auth->id)
->find();
if (!$order) {
$this->error('订单不存在');
}
// 3. 检查订单状态
if ($order['status'] != 'unpaid') {
$this->error('订单已支付或已关闭');
}
// 4. ✅ 生成支付流水号(统一标识,用于订单推送和支付串生成)
// 格式: PAY + 年月日时分秒(14位) + 随机数(6位) = 23位
$payFlowId = 'PAY' . date('YmdHis') . mt_rand(100000, 999999);
Log::info('[建行支付] 生成支付流水号 pay_flow_id:' . $payFlowId . ' order_id:' . $orderId);
// 5. ✅ 先推送订单到建行生活步骤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());
}
// 6. 生成支付串步骤3调用收银台
// ⚠️ 注意: generatePaymentString()内部已经完成了以下操作:
// - 更新订单的ccb_pay_flow_id字段
// - 记录支付日志到ccb_payment_log表
// 控制器不应该重复操作,否则会导致数据重复写入!
$result = $this->paymentService->generatePaymentString($orderId, $payFlowId);
if (!$result['status']) {
$this->error('支付串生成失败: ' . $result['message']);
}
// 7. 返回支付串给前端调用收银台
$this->success('支付串生成成功', $result['data']);
} catch (Exception $e) {
Log::error('[建行支付] 生成支付串失败 order_id:' . ($orderId ?? 0) . ' error:' . $e->getMessage());
$this->error('生成支付串失败: ' . $e->getMessage());
}
}
/**
* 查询订单支付状态 (前端轮询用)
*
* ⚠️ 重要说明:
* 本接口只查询订单状态,不执行任何业务逻辑!
* 订单状态由建行异步通知(notify)接口更新,前端只负责轮询查询。
*
* 修改原因:
* 原callback()方法存在严重安全漏洞:
* 1. 前端可伪造支付成功请求
* 2. 与notify()形成双通道,存在竞态条件
* 3. 违反建行标准支付流程
*
* 正确流程:
* 前端调起支付 → 建行处理 → 建行异步通知notify() → 前端轮询本接口查询状态
*
* @return void
*/
public function queryPaymentStatus()
{
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')
->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,
]);
} 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');
}
}
/**
* 建行生活退款操作通知接口
*
* 📋 接口说明文档7.2
* - 建行生活主动推送退款操作消息
* - 仅推送少量信息(商户号、订单号、退款时间)
* - ⚠️ 不能用于退款结果判断,需通过 A3341TP03 查询接口获取详细信息
* - SIGN 字段使用商户私钥签名,需用建行公钥验签
*
* @return void
*/
public function refundNotify()
{
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['REFUND_DTM'])) {
Log::error('[建行退款通知] 缺少REFUND_DTM退款时间');
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. ⚠️ 重要提示:退款通知仅包含少量信息,不能用于退款结果判断
// 需要通过 A3341TP03 订单查询接口获取详细的退款信息
Log::info('[建行退款通知] 退款操作通知 ORDERID:' . $params['ORDERID'] . ' REFUND_DTM:' . $params['REFUND_DTM']);
// 8. 调用订单查询接口获取详细退款信息
try {
$orderSn = $params['ORDERID'];
$refundTime = $params['REFUND_DTM'];
// 计算查询时间范围退款时间前后1天
$refundTimestamp = strtotime($refundTime);
$startTime = date('YmdHis', $refundTimestamp - 86400); // 前1天
$endTime = date('YmdHis', $refundTimestamp + 86400); // 后1天
// 查询退款交易详情
$queryResult = $this->orderService->queryOrder(
$orderSn,
$startTime,
$endTime,
1,
'1', // 交易类型1-退款交易
'00' // 交易状态00-成功
);
if ($queryResult['status']) {
Log::info('[建行退款通知] 查询退款详情成功: ' . json_encode($queryResult['data'], JSON_UNESCAPED_UNICODE));
// 9. 根据查询结果处理本地订单退款状态
// 查找本地订单
$order = Db::name('shopro_order')
->where('pay_flow_id', $orderSn)
->find();
if ($order) {
// 更新订单退款状态到建行
$updateResult = $this->orderService->updateOrderStatus($order['id'], null, '2'); // 2-已退款
if ($updateResult['status']) {
Log::info('[建行退款通知] 订单退款状态更新成功 order_id:' . $order['id']);
} else {
Log::warning('[建行退款通知] 订单退款状态更新失败 order_id:' . $order['id'] . ' error:' . $updateResult['message']);
}
} else {
Log::warning('[建行退款通知] 未找到本地订单 pay_flow_id:' . $orderSn);
}
} else {
Log::error('[建行退款通知] 查询退款详情失败: ' . $queryResult['message']);
}
} catch (\Exception $e) {
Log::error('[建行退款通知] 查询退款详情异常: ' . $e->getMessage());
// ⚠️ 查询失败不影响通知接收仍然返回SUCCESS
}
// 10. 返回处理结果
// ⚠️ 重要: 必须使用exit直接退出,防止ThinkPHP框架追加额外内容
Log::info('[建行退款通知] 处理完成,返回: SUCCESS');
// 直接退出,确保只输出SUCCESS
exit('SUCCESS');
} catch (Exception $e) {
Log::error('[建行退款通知] 处理失败 error:' . $e->getMessage());
// 异常情况也要直接退出
exit('FAIL');
}
}
}