mirror of
https://gitee.com/liuxioabin/fengketrade.git
synced 2026-04-17 21:03:17 +08:00
'合并'
This commit is contained in:
commit
7d1afd7d80
@ -22,11 +22,7 @@ if (file_exists($envFile)) {
|
||||
|
||||
return [
|
||||
// API基础地址 (生产环境)
|
||||
'api_base_url' => 'https://yunbusiness.ccb.com/tp_service/txCtrl/server',
|
||||
|
||||
// 收银台地址 (生产环境)
|
||||
'cashier_url' => 'https://yunbusiness.ccb.com/clp_service/txCtrl',
|
||||
|
||||
'api_base_url' => 'https://yunbusiness.ccb.com/tp_service/server',
|
||||
// 交易代码映射
|
||||
'tx_codes' => [
|
||||
'order_push' => 'A3341TP01', // 订单推送
|
||||
@ -92,6 +88,142 @@ return [
|
||||
'timeout_minutes' => 30, // 支付超时时间(分钟)
|
||||
],
|
||||
|
||||
// ========== 可选支付参数配置 ==========
|
||||
// 以下参数按需配置,不配置则不会添加到支付串中
|
||||
|
||||
/**
|
||||
* 外部平台商户号 (与建行商户号二选一)
|
||||
* 说明: 使用外部平台商户号时,会自动移除MERCHANTID、POSID、BRANCHID
|
||||
*/
|
||||
'plat_mct_id' => Env::get('ccb.plat_mct_id', ''),
|
||||
|
||||
/**
|
||||
* 微信支付19位终端号
|
||||
* 说明: 微信支付场景下使用
|
||||
*/
|
||||
'pos_id_19' => Env::get('ccb.pos_id_19', ''),
|
||||
|
||||
/**
|
||||
* 支付位图 - 控制支付方式
|
||||
* 格式: 6位字符串,每位对应一个支付方式(1=开启,0=关闭)
|
||||
* 位置: [生活钱包][龙支付][微信][数字人民币][信用付][快贷]
|
||||
* 示例: '111111' = 全部开启, '110000' = 仅生活钱包和龙支付
|
||||
*/
|
||||
'pay_bitmap' => Env::get('ccb.pay_bitmap', '110000'),
|
||||
|
||||
/**
|
||||
* 账户位图 - 控制支付账户类型
|
||||
* 格式: 5位字符串,每位对应一个账户类型(1=开启,0=关闭)
|
||||
* 位置: [建行借记卡][建行贷记卡][他行借记卡][他行贷记卡][建行钱包]
|
||||
* 示例: '11111' = 全部开启, '11000' = 仅建行卡
|
||||
*/
|
||||
'account_bitmap' => Env::get('ccb.account_bitmap', '11000'),
|
||||
|
||||
/**
|
||||
* 分期付款期数
|
||||
* 说明: 分期支付场景下使用
|
||||
*/
|
||||
'install_num' => Env::get('ccb.install_num', ''),
|
||||
|
||||
/**
|
||||
* 积分二级活动编号
|
||||
* 说明: 积分抵扣场景下使用
|
||||
*/
|
||||
'point_avy_id' => Env::get('ccb.point_avy_id', ''),
|
||||
|
||||
/**
|
||||
* 固定抵扣积分值
|
||||
* 说明: 固定积分抵扣场景下使用
|
||||
*/
|
||||
'fixed_point_val' => Env::get('ccb.fixed_point_val', ''),
|
||||
|
||||
/**
|
||||
* 最小使用积分抵扣限制
|
||||
* 说明: 积分抵扣最小值限制
|
||||
*/
|
||||
'min_point_limit' => Env::get('ccb.min_point_limit', ''),
|
||||
|
||||
/**
|
||||
* 有价券活动编号
|
||||
* 说明: 优惠券活动场景下使用
|
||||
*/
|
||||
'coupon_avy_id' => Env::get('ccb.coupon_avy_id', ''),
|
||||
|
||||
/**
|
||||
* 限制信用卡支付标志
|
||||
* 说明: 1=仅限信用卡支付
|
||||
*/
|
||||
'only_credit_pay_flag' => Env::get('ccb.only_credit_pay_flag', ''),
|
||||
|
||||
/**
|
||||
* 扩展域参数
|
||||
* 格式: JSON字符串(配置时无需urlencode,代码会自动处理)
|
||||
* 示例: '{"key1":"value1","key2":"value2"}'
|
||||
*/
|
||||
'extend_params' => Env::get('ccb.extend_params', ''),
|
||||
|
||||
// ========== 数字人民币(DCEP)配置 ==========
|
||||
|
||||
/**
|
||||
* 数字人民币商户类型
|
||||
* 1=融合商户(使用普通商户号)
|
||||
* 2=非融合商户(需单独配置数币商户号)
|
||||
*/
|
||||
'dcep_mct_type' => Env::get('ccb.dcep_mct_type', ''),
|
||||
|
||||
/**
|
||||
* 数字人民币商户号 (dcep_mct_type=2时必填)
|
||||
*/
|
||||
'dcep_merchant_id' => Env::get('ccb.dcep_merchant_id', ''),
|
||||
|
||||
/**
|
||||
* 数字人民币柜台号 (dcep_mct_type=2时必填)
|
||||
*/
|
||||
'dcep_pos_id' => Env::get('ccb.dcep_pos_id', ''),
|
||||
|
||||
/**
|
||||
* 数字人民币分行号 (dcep_mct_type=2时必填)
|
||||
*/
|
||||
'dcep_branch_id' => Env::get('ccb.dcep_branch_id', ''),
|
||||
|
||||
/**
|
||||
* 数字人民币存款账号
|
||||
*/
|
||||
'dcep_dep_acc_no' => Env::get('ccb.dcep_dep_acc_no', ''),
|
||||
|
||||
// ========== 二级商户配置(平台类服务方使用) ==========
|
||||
|
||||
/**
|
||||
* 二级商户编号
|
||||
* 说明: 平台型服务方为下级商户收款时使用
|
||||
*/
|
||||
'sub_mct_id' => Env::get('ccb.sub_mct_id', ''),
|
||||
|
||||
/**
|
||||
* 二级商户名称
|
||||
*/
|
||||
'sub_mct_name' => Env::get('ccb.sub_mct_name', ''),
|
||||
|
||||
/**
|
||||
* 二级商户MCC码
|
||||
* 说明: 商户类别码,标识商户行业类型
|
||||
*/
|
||||
'sub_mct_mcc' => Env::get('ccb.sub_mct_mcc', ''),
|
||||
|
||||
// ========== 场景编号配置(埋点使用,不参与MAC签名) ==========
|
||||
|
||||
/**
|
||||
* 场景编号
|
||||
* 说明: 用于数据埋点分析,不参与MAC校验
|
||||
*/
|
||||
'scn_id' => Env::get('ccb.scn_id', ''),
|
||||
|
||||
/**
|
||||
* 场景平台编号
|
||||
* 说明: 用于数据埋点分析,不参与MAC校验
|
||||
*/
|
||||
'scn_pltfrm_id' => Env::get('ccb.scn_pltfrm_id', ''),
|
||||
|
||||
// 日志配置
|
||||
'log' => [
|
||||
'enabled' => true,
|
||||
@ -108,7 +240,7 @@ return [
|
||||
|
||||
// 商户信息
|
||||
'merchant' => [
|
||||
'name' => Env::get('ccb.merchant_name', '商户名称'),
|
||||
'name' => Env::get('ccb.merchant_name', '丰科贸易(荷西嘉园店)'),
|
||||
'logo_url' => Env::get('ccb.merchant_logo', ''),
|
||||
'order_detail_url' => Env::get('app_url', 'http://fengketrade.test') . '/pages/order/detail?id=',
|
||||
],
|
||||
|
||||
@ -140,55 +140,7 @@ class Ccblife extends Common
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建行用户自动登录(JSBridge方式)
|
||||
* H5在建行App内打开时,通过JSBridge获取用户信息后调用
|
||||
*
|
||||
* POST /addons/shopro/ccblife/autoLogin
|
||||
*/
|
||||
public function autoLogin()
|
||||
{
|
||||
try {
|
||||
// 获取请求参数
|
||||
$ccbUserId = $this->request->post('ccb_user_id', '');
|
||||
$mobile = $this->request->post('mobile', '');
|
||||
$nickname = $this->request->post('nickname', '');
|
||||
$avatar = $this->request->post('avatar', '');
|
||||
|
||||
// 验证必需参数
|
||||
if (empty($ccbUserId)) {
|
||||
$this->error('建行用户ID不能为空');
|
||||
}
|
||||
|
||||
// 处理用户登录/注册
|
||||
$userInfo = $this->processUserLogin($ccbUserId, $mobile, [
|
||||
'nickname' => $nickname,
|
||||
'avatar' => $avatar
|
||||
]);
|
||||
|
||||
// 使用Auth系统登录并生成Token
|
||||
$this->auth->direct($userInfo['user_id']);
|
||||
$token = $this->auth->getToken();
|
||||
|
||||
// 返回结果
|
||||
$this->success('登录成功', [
|
||||
'token' => $token,
|
||||
'user_id' => $userInfo['user_id'],
|
||||
'is_new_user' => $userInfo['is_new'],
|
||||
'userInfo' => $userInfo
|
||||
]);
|
||||
|
||||
} catch (\think\exception\HttpResponseException $e) {
|
||||
// HttpResponseException 是框架正常的响应机制,直接向上抛出
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('建行自动登录失败: ' . $e->getMessage());
|
||||
Log::error('错误堆栈: ' . $e->getTraceAsString());
|
||||
$this->error('登录失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理用户登录/注册
|
||||
*
|
||||
|
||||
@ -87,34 +87,43 @@ class Ccbpayment extends Common
|
||||
$this->error('订单已支付或已关闭');
|
||||
}
|
||||
|
||||
// 4. 生成支付串
|
||||
// 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);
|
||||
$result = $this->paymentService->generatePaymentString($orderId, $payFlowId);
|
||||
|
||||
if (!$result['status']) {
|
||||
$this->error('支付串生成失败: ' . $result['message']);
|
||||
}
|
||||
|
||||
// 5. ✅ 推送订单到建行(步骤2:调用订单推送接口将已提交订单)
|
||||
// ⚠️ 注意:此时推送的是未支付状态的订单
|
||||
try {
|
||||
$pushResult = $this->orderService->pushOrder($orderId);
|
||||
|
||||
if ($pushResult['status']) {
|
||||
Log::info('[建行支付] 订单推送成功 order_id:' . $orderId);
|
||||
} else {
|
||||
// ⚠️ 推送失败不阻塞支付流程,只记录日志
|
||||
Log::warning('[建行支付] 订单推送失败(不阻塞支付) order_id:' . $orderId . ' error:' . $pushResult['message']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// ⚠️ 推送异常不阻塞支付流程
|
||||
Log::error('[建行支付] 订单推送异常(不阻塞支付) order_id:' . $orderId . ' error:' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 6. 返回支付串
|
||||
// 7. 返回支付串给前端调用收银台
|
||||
$this->success('支付串生成成功', $result['data']);
|
||||
|
||||
} catch (Exception $e) {
|
||||
|
||||
@ -1,527 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace addons\shopro\controller;
|
||||
|
||||
use addons\shopro\controller\Common;
|
||||
use addons\shopro\library\ccblife\CcbRSA;
|
||||
use addons\shopro\library\ccblife\CcbMD5;
|
||||
use addons\shopro\library\ccblife\CcbUrlDecrypt;
|
||||
use addons\shopro\library\ccblife\CcbHttpClient;
|
||||
use addons\shopro\library\ccblife\CcbOrderService;
|
||||
use addons\shopro\library\ccblife\CcbPaymentService;
|
||||
use app\admin\model\shopro\order\Order as OrderModel;
|
||||
use think\Db;
|
||||
use think\Exception;
|
||||
|
||||
/**
|
||||
* 建行生活测试控制器
|
||||
*
|
||||
* 用于测试和验证各个功能模块是否正常工作
|
||||
*
|
||||
* @author Billy
|
||||
* @date 2025-01-17
|
||||
*/
|
||||
class Ccbtest extends Common
|
||||
{
|
||||
/**
|
||||
* 不需要登录的方法
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedLogin = ['*'];
|
||||
|
||||
/**
|
||||
* 不需要权限的方法
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedRight = ['*'];
|
||||
|
||||
/**
|
||||
* 测试主页
|
||||
* 展示所有可用的测试接口
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$baseUrl = $this->request->domain() . '/addons/shopro/ccbtest';
|
||||
|
||||
$tests = [
|
||||
'基础功能测试' => [
|
||||
[
|
||||
'name' => '测试RSA加密解密',
|
||||
'url' => $baseUrl . '/testRsa',
|
||||
'method' => 'GET',
|
||||
'desc' => '测试RSA加密和解密功能是否正常'
|
||||
],
|
||||
[
|
||||
'name' => '测试MD5签名',
|
||||
'url' => $baseUrl . '/testMd5',
|
||||
'method' => 'GET',
|
||||
'desc' => '测试MD5签名生成功能'
|
||||
],
|
||||
[
|
||||
'name' => '测试URL参数解密',
|
||||
'url' => $baseUrl . '/testUrlDecrypt',
|
||||
'method' => 'GET',
|
||||
'desc' => '测试建行URL参数解密功能'
|
||||
],
|
||||
],
|
||||
'订单功能测试' => [
|
||||
[
|
||||
'name' => '测试订单推送',
|
||||
'url' => $baseUrl . '/testOrderPush?order_id=1',
|
||||
'method' => 'GET',
|
||||
'desc' => '测试推送订单到建行(需要提供order_id)'
|
||||
],
|
||||
[
|
||||
'name' => '测试订单查询',
|
||||
'url' => $baseUrl . '/testOrderQuery?order_sn=ORDER123',
|
||||
'method' => 'GET',
|
||||
'desc' => '测试查询建行订单状态(需要提供order_sn)'
|
||||
],
|
||||
[
|
||||
'name' => '测试批量同步',
|
||||
'url' => $baseUrl . '/testBatchSync',
|
||||
'method' => 'GET',
|
||||
'desc' => '测试批量同步未同步的订单'
|
||||
],
|
||||
],
|
||||
'支付功能测试' => [
|
||||
[
|
||||
'name' => '测试生成支付串',
|
||||
'url' => $baseUrl . '/testPaymentString?order_id=1',
|
||||
'method' => 'GET',
|
||||
'desc' => '测试生成建行支付串(需要提供order_id)'
|
||||
],
|
||||
[
|
||||
'name' => '测试支付验证',
|
||||
'url' => $baseUrl . '/testVerifyPayment?order_sn=ORDER123',
|
||||
'method' => 'GET',
|
||||
'desc' => '测试验证支付结果(需要提供order_sn)'
|
||||
],
|
||||
],
|
||||
'用户功能测试' => [
|
||||
[
|
||||
'name' => '模拟用户登录',
|
||||
'url' => $baseUrl . '/testUserLogin',
|
||||
'method' => 'POST',
|
||||
'desc' => '模拟建行用户登录流程',
|
||||
'params' => [
|
||||
'ccb_user_id' => 'ccb_test_user_001',
|
||||
'mobile' => '13800138000'
|
||||
]
|
||||
],
|
||||
],
|
||||
'环境检查' => [
|
||||
[
|
||||
'name' => '检查配置',
|
||||
'url' => $baseUrl . '/checkConfig',
|
||||
'method' => 'GET',
|
||||
'desc' => '检查建行配置是否正确'
|
||||
],
|
||||
[
|
||||
'name' => '检查数据库',
|
||||
'url' => $baseUrl . '/checkDatabase',
|
||||
'method' => 'GET',
|
||||
'desc' => '检查数据库表结构是否正确'
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
$this->success('建行生活接口测试', $tests);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试RSA加密解密
|
||||
*/
|
||||
public function testRsa()
|
||||
{
|
||||
try {
|
||||
$config = config('ccblife');
|
||||
|
||||
// 测试数据
|
||||
$testData = '这是一个测试字符串,用于验证RSA加密解密功能是否正常工作。包含中文、English、数字123456和特殊字符!@#$%^&*()';
|
||||
|
||||
// 加密
|
||||
$encrypted = CcbRSA::encrypt($testData, $config['public_key']);
|
||||
|
||||
// 解密
|
||||
$decrypted = CcbRSA::decrypt($encrypted, $config['private_key']);
|
||||
|
||||
// 验证
|
||||
$isSuccess = ($decrypted === $testData);
|
||||
|
||||
$result = [
|
||||
'测试结果' => $isSuccess ? '成功' : '失败',
|
||||
'原始数据' => $testData,
|
||||
'加密后数据(BASE64)' => substr($encrypted, 0, 100) . '...',
|
||||
'加密后长度' => strlen($encrypted),
|
||||
'解密后数据' => $decrypted,
|
||||
'数据一致性' => $isSuccess ? '✓ 一致' : '✗ 不一致'
|
||||
];
|
||||
|
||||
$this->success('RSA加密解密测试完成', $result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('RSA测试失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试MD5签名
|
||||
*/
|
||||
public function testMd5()
|
||||
{
|
||||
try {
|
||||
$config = config('ccblife');
|
||||
|
||||
// 测试API消息签名
|
||||
$apiMessage = '{"TX_CODE":"A3341TP01","USER_ID":"test_user"}';
|
||||
$apiSign = CcbMD5::signApiMessage($apiMessage, $config['private_key']);
|
||||
|
||||
// 测试支付串签名
|
||||
$paymentResult = CcbMD5::generatePaymentSignature(
|
||||
$config['merchant_id'],
|
||||
$config['pos_id'],
|
||||
$config['branch_id'],
|
||||
'TEST_ORDER_' . time(),
|
||||
'100.00',
|
||||
$config['private_key'],
|
||||
'01',
|
||||
'530550'
|
||||
);
|
||||
|
||||
$result = [
|
||||
'API消息签名' => [
|
||||
'原始消息' => $apiMessage,
|
||||
'签名结果' => $apiSign,
|
||||
'签名长度' => strlen($apiSign)
|
||||
],
|
||||
'支付串签名' => [
|
||||
'支付串' => $paymentResult['payment_string'],
|
||||
'MAC签名' => $paymentResult['mac'],
|
||||
'参数列表' => $paymentResult['params']
|
||||
]
|
||||
];
|
||||
|
||||
$this->success('MD5签名测试完成', $result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('MD5测试失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试URL参数解密
|
||||
*/
|
||||
public function testUrlDecrypt()
|
||||
{
|
||||
try {
|
||||
$config = config('ccblife');
|
||||
|
||||
// 模拟建行传递的加密参数
|
||||
// 注意:这里需要真实的ccbParamSJ参数才能测试
|
||||
$ccbParamSJ = $this->request->get('ccbParamSJ', '');
|
||||
|
||||
if (empty($ccbParamSJ)) {
|
||||
// 如果没有提供参数,生成一个测试用的
|
||||
$testParams = [
|
||||
'userid' => 'test_ccb_user_001',
|
||||
'mobile' => '13800138000',
|
||||
'openid' => 'test_openid_001'
|
||||
];
|
||||
|
||||
$result = [
|
||||
'提示' => '未提供ccbParamSJ参数,无法进行实际解密测试',
|
||||
'说明' => '请从建行App跳转时携带ccbParamSJ参数',
|
||||
'服务方编号' => $config['service_id'],
|
||||
'加密方式' => 'RSA(使用服务方私钥解密)',
|
||||
'模拟数据' => $testParams
|
||||
];
|
||||
} else {
|
||||
// 尝试解密(使用服务方私钥)
|
||||
$decryptedParams = CcbUrlDecrypt::decrypt($ccbParamSJ, $config['private_key']);
|
||||
|
||||
$result = [
|
||||
'解密结果' => $decryptedParams ? '成功' : '失败',
|
||||
'原始参数' => $ccbParamSJ,
|
||||
'解密数据' => $decryptedParams,
|
||||
'服务方编号' => $config['service_id'],
|
||||
'加密方式' => 'RSA'
|
||||
];
|
||||
}
|
||||
|
||||
$this->success('URL参数解密测试', $result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('URL解密测试失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试订单推送
|
||||
*/
|
||||
public function testOrderPush()
|
||||
{
|
||||
try {
|
||||
$orderId = $this->request->get('order_id', 1);
|
||||
|
||||
$orderService = new CcbOrderService();
|
||||
$result = $orderService->pushOrder($orderId);
|
||||
|
||||
$this->success('订单推送测试完成', $result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('订单推送失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试订单查询
|
||||
*/
|
||||
public function testOrderQuery()
|
||||
{
|
||||
try {
|
||||
$orderSn = $this->request->get('order_sn', '');
|
||||
|
||||
if (empty($orderSn)) {
|
||||
$this->error('请提供订单号参数 order_sn');
|
||||
}
|
||||
|
||||
$orderService = new CcbOrderService();
|
||||
$result = $orderService->queryOrder($orderSn);
|
||||
|
||||
$this->success('订单查询测试完成', $result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('订单查询失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试批量同步
|
||||
*/
|
||||
public function testBatchSync()
|
||||
{
|
||||
try {
|
||||
$orderService = new CcbOrderService();
|
||||
$result = $orderService->batchSync(5); // 同步5个订单
|
||||
|
||||
$this->success('批量同步测试完成', $result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('批量同步失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试生成支付串
|
||||
*/
|
||||
public function testPaymentString()
|
||||
{
|
||||
try {
|
||||
$orderId = $this->request->get('order_id', 1);
|
||||
|
||||
$paymentService = new CcbPaymentService();
|
||||
$result = $paymentService->generatePaymentString($orderId);
|
||||
|
||||
$this->success('支付串生成测试完成', $result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('支付串生成失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试支付验证
|
||||
*/
|
||||
public function testVerifyPayment()
|
||||
{
|
||||
try {
|
||||
$orderSn = $this->request->get('order_sn', '');
|
||||
|
||||
if (empty($orderSn)) {
|
||||
$this->error('请提供订单号参数 order_sn');
|
||||
}
|
||||
|
||||
$paymentService = new CcbPaymentService();
|
||||
$isSuccess = $paymentService->verifyPayment($orderSn);
|
||||
|
||||
$result = [
|
||||
'订单号' => $orderSn,
|
||||
'支付状态' => $isSuccess ? '已支付' : '未支付',
|
||||
'验证结果' => $isSuccess
|
||||
];
|
||||
|
||||
$this->success('支付验证测试完成', $result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('支付验证失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟用户登录
|
||||
*/
|
||||
public function testUserLogin()
|
||||
{
|
||||
try {
|
||||
$ccbUserId = $this->request->post('ccb_user_id', 'ccb_test_user_001');
|
||||
$mobile = $this->request->post('mobile', '13800138000');
|
||||
|
||||
// 查询或创建用户
|
||||
$user = Db::name('user')->where('ccb_user_id', $ccbUserId)->find();
|
||||
|
||||
if (!$user) {
|
||||
// 创建测试用户
|
||||
$userData = [
|
||||
'ccb_user_id' => $ccbUserId,
|
||||
'username' => 'test_' . substr(md5($ccbUserId), 0, 8),
|
||||
'nickname' => '测试用户' . substr($ccbUserId, -4),
|
||||
'mobile' => $mobile,
|
||||
'avatar' => '/assets/img/avatar.png',
|
||||
'status' => 'normal',
|
||||
'salt' => \fast\Random::alnum(),
|
||||
'password' => '',
|
||||
'joinip' => $this->request->ip(),
|
||||
'jointime' => time(),
|
||||
'logintime' => time(),
|
||||
'loginip' => $this->request->ip(),
|
||||
'createtime' => time(),
|
||||
'updatetime' => time()
|
||||
];
|
||||
|
||||
// 设置随机密码
|
||||
$userData['password'] = md5(md5(\fast\Random::alnum(32)) . $userData['salt']);
|
||||
|
||||
$userId = Db::name('user')->insertGetId($userData);
|
||||
$user = Db::name('user')->where('id', $userId)->find();
|
||||
}
|
||||
|
||||
// 生成Token
|
||||
$this->auth->direct($user['id']);
|
||||
$token = $this->auth->getToken();
|
||||
|
||||
$result = [
|
||||
'用户ID' => $user['id'],
|
||||
'建行用户ID' => $ccbUserId,
|
||||
'手机号' => $mobile,
|
||||
'Token' => $token,
|
||||
'是否新用户' => !isset($userId) ? '否' : '是'
|
||||
];
|
||||
|
||||
$this->success('用户登录测试成功', $result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('用户登录测试失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查配置
|
||||
*/
|
||||
public function checkConfig()
|
||||
{
|
||||
try {
|
||||
$config = config('ccblife');
|
||||
|
||||
$checks = [
|
||||
'基础配置' => [
|
||||
'收银台地址' => $config['cashier_url'] ?? '未配置',
|
||||
'服务方编号' => $config['service_id'] ?? '未配置',
|
||||
],
|
||||
'商户信息' => [
|
||||
'商户号' => $config['merchant_id'] ?? '未配置',
|
||||
'POS号' => $config['pos_id'] ?? '未配置',
|
||||
'分行号' => $config['branch_id'] ?? '未配置',
|
||||
],
|
||||
'密钥配置' => [
|
||||
'私钥长度' => strlen($config['private_key'] ?? '') . ' 字符',
|
||||
'公钥长度' => strlen($config['public_key'] ?? '') . ' 字符',
|
||||
'私钥格式' => $this->validateKey($config['private_key'] ?? ''),
|
||||
'公钥格式' => $this->validateKey($config['public_key'] ?? ''),
|
||||
],
|
||||
'交易代码' => $config['tx_codes'] ?? [],
|
||||
'HTTP配置' => $config['http'] ?? [],
|
||||
'安全配置' => $config['security'] ?? []
|
||||
];
|
||||
|
||||
$this->success('配置检查完成', $checks);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('配置检查失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查数据库
|
||||
*/
|
||||
public function checkDatabase()
|
||||
{
|
||||
try {
|
||||
$checks = [];
|
||||
|
||||
// 检查用户表
|
||||
$userColumns = Db::query("SHOW COLUMNS FROM fa_user LIKE 'ccb_user_id'");
|
||||
$checks['用户表(fa_user)'] = [
|
||||
'ccb_user_id字段' => !empty($userColumns) ? '✓ 存在' : '✗ 不存在'
|
||||
];
|
||||
|
||||
// 检查订单表
|
||||
$orderColumns = Db::query("SHOW COLUMNS FROM fa_shopro_order WHERE Field IN ('ccb_user_id', 'ccb_pay_flow_id', 'ccb_sync_status', 'ccb_sync_time')");
|
||||
$orderFields = array_column($orderColumns, 'Field');
|
||||
$checks['订单表(fa_shopro_order)'] = [
|
||||
'ccb_user_id字段' => in_array('ccb_user_id', $orderFields) ? '✓ 存在' : '✗ 不存在',
|
||||
'ccb_pay_flow_id字段' => in_array('ccb_pay_flow_id', $orderFields) ? '✓ 存在' : '✗ 不存在',
|
||||
'ccb_sync_status字段' => in_array('ccb_sync_status', $orderFields) ? '✓ 存在' : '✗ 不存在',
|
||||
'ccb_sync_time字段' => in_array('ccb_sync_time', $orderFields) ? '✓ 存在' : '✗ 不存在',
|
||||
];
|
||||
|
||||
// 检查支付日志表
|
||||
$paymentLogExists = Db::query("SHOW TABLES LIKE 'fa_ccb_payment_log'");
|
||||
$checks['支付日志表(fa_ccb_payment_log)'] = [
|
||||
'表是否存在' => !empty($paymentLogExists) ? '✓ 存在' : '✗ 不存在'
|
||||
];
|
||||
|
||||
// 检查同步日志表
|
||||
$syncLogExists = Db::query("SHOW TABLES LIKE 'fa_ccb_sync_log'");
|
||||
$checks['同步日志表(fa_ccb_sync_log)'] = [
|
||||
'表是否存在' => !empty($syncLogExists) ? '✓ 存在' : '✗ 不存在'
|
||||
];
|
||||
|
||||
// 统计数据
|
||||
if (!empty($syncLogExists)) {
|
||||
$syncStats = Db::name('ccb_sync_log')
|
||||
->field('sync_status, COUNT(*) as count')
|
||||
->group('sync_status')
|
||||
->select();
|
||||
|
||||
$checks['同步统计'] = [];
|
||||
foreach ($syncStats as $stat) {
|
||||
$status = $stat['sync_status'] == 1 ? '成功' : '失败';
|
||||
$checks['同步统计'][$status] = $stat['count'] . ' 条';
|
||||
}
|
||||
}
|
||||
|
||||
$this->success('数据库检查完成', $checks);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error('数据库检查失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密钥格式
|
||||
*/
|
||||
private function validateKey($key)
|
||||
{
|
||||
if (empty($key)) {
|
||||
return '✗ 未配置';
|
||||
}
|
||||
|
||||
// 检查是否为BASE64格式
|
||||
if (base64_encode(base64_decode($key)) === $key) {
|
||||
return '✓ BASE64格式';
|
||||
}
|
||||
|
||||
return '✗ 格式异常';
|
||||
}
|
||||
}
|
||||
@ -344,7 +344,7 @@ class Pay extends Common
|
||||
*
|
||||
* @param object|string $result
|
||||
* @param string|null $payment
|
||||
* @return void
|
||||
* @return object|string|\think\Response|null
|
||||
*/
|
||||
private function payResponse($result = null, $payment = null)
|
||||
{
|
||||
@ -363,7 +363,7 @@ class Pay extends Common
|
||||
* 根据订单号获取订单实例
|
||||
*
|
||||
* @param [type] $order_sn
|
||||
* @return void
|
||||
* @return array
|
||||
*/
|
||||
private function getOrderInstance($order_sn)
|
||||
{
|
||||
|
||||
@ -77,7 +77,6 @@ class Order extends Common
|
||||
$this->error(__('No Results were found'));
|
||||
}
|
||||
|
||||
$order->pay_types_text = $order->pay_types_text;
|
||||
// 处理未支付订单 item status_code
|
||||
$order = $order->setOrderItemStatusByOrder($order); // 这里订单转 数组了
|
||||
|
||||
|
||||
@ -7,12 +7,19 @@ use think\Exception;
|
||||
/**
|
||||
* 建行生活加密解密核心类
|
||||
*
|
||||
* 功能:
|
||||
* - RSA加密与解密
|
||||
* - MD5签名生成与验证
|
||||
* - 报文构造与解析
|
||||
* - 交易流水号生成
|
||||
* ⚠️ 已废弃:请使用 CcbRSA、CcbMD5、CcbHttpClient 类替代
|
||||
*
|
||||
* 废弃原因:
|
||||
* 1. formatKey() 方法存在密钥格式化错误(PKCS#1 vs PKCS#8 混淆)
|
||||
* 2. chunk_split() 使用不当导致 OpenSSL ASN1 解析错误
|
||||
* 3. 与 CcbRSA 类功能重复,维护成本高
|
||||
*
|
||||
* 迁移指南:
|
||||
* - RSA加密/解密 → 使用 CcbRSA::encrypt() / CcbRSA::decrypt()
|
||||
* - MD5签名 → 使用 CcbMD5::signApiMessage() / CcbMD5::verifyApiSignature()
|
||||
* - 加密商户公钥 → 在 CcbPaymentService 中使用 encryptPublicKeyLast30()
|
||||
*
|
||||
* @deprecated 2025-01-21 统一使用 CcbRSA、CcbMD5 类
|
||||
* @author Billy
|
||||
* @date 2025-01-16
|
||||
*/
|
||||
@ -36,11 +43,6 @@ class CcbEncryption
|
||||
*/
|
||||
private $publicKey;
|
||||
|
||||
/**
|
||||
* 建行平台公钥
|
||||
* @var string
|
||||
*/
|
||||
private $platformPublicKey;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
@ -75,8 +77,8 @@ class CcbEncryption
|
||||
throw new Exception('服务方私钥未配置');
|
||||
}
|
||||
|
||||
if (empty($this->platformPublicKey)) {
|
||||
throw new Exception('建行平台公钥未配置');
|
||||
if (empty($this->publicKey)) {
|
||||
throw new Exception('服务方公钥未配置');
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,7 +92,7 @@ class CcbEncryption
|
||||
public function rsaEncrypt($data)
|
||||
{
|
||||
// 格式化公钥
|
||||
$publicKey = $this->formatKey($this->platformPublicKey, 'PUBLIC');
|
||||
$publicKey = $this->formatKey($this->publicKey);
|
||||
|
||||
// 获取公钥资源
|
||||
$pubKeyId = openssl_pkey_get_public($publicKey);
|
||||
@ -382,7 +384,7 @@ class CcbEncryption
|
||||
$signString = http_build_query($params);
|
||||
|
||||
// 3. 追加平台公钥
|
||||
$signString .= '&PLATFORMPUB=' . $this->platformPublicKey;
|
||||
$signString .= '&PLATFORMPUB=' . $this->publicKey;
|
||||
|
||||
// 4. 生成MD5签名 (转为大写,与Java一致)
|
||||
return strtoupper(md5($signString . $this->privateKey));
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace addons\shopro\library\ccblife;
|
||||
|
||||
use think\Log;
|
||||
|
||||
/**
|
||||
* 建行生活HTTP客户端
|
||||
* 处理与建行API的通信,包括加密、签名、发送请求和解密响应
|
||||
@ -48,6 +50,10 @@ class CcbHttpClient
|
||||
// 构建请求报文
|
||||
$message = $this->buildMessage($txCode, $body, $txSeq);
|
||||
|
||||
// 📝 记录原始请求报文(加密前)
|
||||
Log::info('建行生活API原始请求报文 [txCode=' . $txCode . '] [txSeq=' . $txSeq . ']');
|
||||
Log::info('原始报文内容: ' . $message);
|
||||
|
||||
// 加密报文
|
||||
$encryptedMessage = CcbRSA::encryptForCcb($message, $this->config['public_key']);
|
||||
|
||||
@ -99,6 +105,9 @@ class CcbHttpClient
|
||||
*/
|
||||
private function sendHttpRequest($txCode, $cnt, $mac)
|
||||
{
|
||||
// 记录请求开始时间
|
||||
$startTime = microtime(true);
|
||||
|
||||
// 构建完整的API URL(基础URL + ?txcode=交易代码)
|
||||
$apiUrl = $this->config['api_base_url'] . '?txcode=' . $txCode;
|
||||
|
||||
@ -108,6 +117,11 @@ class CcbHttpClient
|
||||
'mac' => $mac
|
||||
];
|
||||
|
||||
// 📝 记录请求参数(加密后的完整内容)
|
||||
Log::info('建行生活API加密请求参数 [txCode=' . $txCode . '] [url=' . $apiUrl . '] [timeout=' . self::DEFAULT_TIMEOUT . 's]');
|
||||
Log::info('加密参数 cnt: ' . $cnt);
|
||||
Log::info('加密参数 mac: ' . $mac);
|
||||
|
||||
// 初始化CURL
|
||||
$ch = curl_init();
|
||||
|
||||
@ -130,17 +144,32 @@ class CcbHttpClient
|
||||
$response = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlInfo = curl_getinfo($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// 计算请求耗时
|
||||
$costTime = round((microtime(true) - $startTime) * 1000, 2); // 毫秒
|
||||
|
||||
// 检查错误
|
||||
if ($error) {
|
||||
// 📝 记录错误日志
|
||||
Log::error('建行生活API请求失败 [txCode=' . $txCode . '] [url=' . $apiUrl . '] [error=' . $error . '] [cost_time=' . $costTime . 'ms] [total_time=' . ($curlInfo['total_time'] ?? 0) . 's] [connect_time=' . ($curlInfo['connect_time'] ?? 0) . 's]');
|
||||
throw new \Exception('HTTP请求失败: ' . $error);
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
// 📝 记录HTTP状态码异常日志
|
||||
$responsePreview = mb_substr($response, 0, 300);
|
||||
Log::error('建行生活API响应状态码异常 [txCode=' . $txCode . '] [url=' . $apiUrl . '] [http_code=' . $httpCode . '] [cost_time=' . $costTime . 'ms] [response=' . $responsePreview . ']');
|
||||
throw new \Exception('HTTP状态码异常: ' . $httpCode . ', 响应内容: ' . $response);
|
||||
}
|
||||
|
||||
// 📝 记录成功响应日志
|
||||
$totalTime = round(($curlInfo['total_time'] ?? 0) * 1000, 2);
|
||||
$connectTime = round(($curlInfo['connect_time'] ?? 0) * 1000, 2);
|
||||
$responseLength = mb_strlen($response);
|
||||
Log::info('建行生活API请求成功 [txCode=' . $txCode . '] [url=' . $apiUrl . '] [http_code=' . $httpCode . '] [response_length=' . $responseLength . '] [cost_time=' . $costTime . 'ms] [total_time=' . $totalTime . 'ms] [connect_time=' . $connectTime . 'ms]');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
@ -153,6 +182,9 @@ class CcbHttpClient
|
||||
*/
|
||||
private function handleResponse($response)
|
||||
{
|
||||
// 📝 记录原始响应内容
|
||||
Log::info('建行生活API原始响应内容: ' . $response);
|
||||
|
||||
// 解析JSON响应
|
||||
$responseData = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
@ -164,15 +196,25 @@ class CcbHttpClient
|
||||
throw new \Exception('响应格式错误,缺少cnt或mac字段');
|
||||
}
|
||||
|
||||
// 📝 记录加密响应参数
|
||||
Log::info('加密响应参数 cnt: ' . $responseData['cnt']);
|
||||
Log::info('加密响应参数 mac: ' . $responseData['mac']);
|
||||
|
||||
// 解密响应内容
|
||||
$decryptedContent = CcbRSA::decryptFromCcb($responseData['cnt'], $this->config['private_key']);
|
||||
|
||||
// 📝 记录解密后的响应内容
|
||||
Log::info('解密后响应内容: ' . $decryptedContent);
|
||||
|
||||
// 验证签名
|
||||
$isValid = CcbMD5::verifyApiSignature($decryptedContent, $responseData['mac'], $this->config['private_key']);
|
||||
if (!$isValid) {
|
||||
Log::error('响应签名验证失败 [expected_mac=' . $responseData['mac'] . ']');
|
||||
throw new \Exception('响应签名验证失败');
|
||||
}
|
||||
|
||||
Log::info('响应签名验证成功');
|
||||
|
||||
// 解析解密后的内容
|
||||
$decryptedData = json_decode($decryptedContent, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
|
||||
@ -34,63 +34,32 @@ class CcbOrderService
|
||||
throw new \Exception('建行生活配置文件不存在');
|
||||
}
|
||||
|
||||
// 处理BASE64格式的密钥
|
||||
$this->config = $this->processPemKeys($this->config);
|
||||
// ✅ 修复: 删除processPemKeys()调用
|
||||
// 密钥格式化统一由CcbRSA类处理,避免重复格式化导致OpenSSL ASN1解析错误
|
||||
// CcbRSA::formatPublicKey/formatPrivateKey 会在加密/解密时自动处理密钥格式
|
||||
|
||||
$this->httpClient = new CcbHttpClient($this->config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理PEM格式密钥
|
||||
*
|
||||
* @param array $config
|
||||
* @return array
|
||||
*/
|
||||
private function processPemKeys($config)
|
||||
{
|
||||
if (!empty($config['private_key']) && strpos($config['private_key'], '-----BEGIN') === false) {
|
||||
$config['private_key'] = "-----BEGIN PRIVATE KEY-----\n"
|
||||
. chunk_split($config['private_key'], 64, "\n")
|
||||
. "-----END PRIVATE KEY-----";
|
||||
}
|
||||
|
||||
if (!empty($config['public_key']) && strpos($config['public_key'], '-----BEGIN') === false) {
|
||||
$config['public_key'] = "-----BEGIN PUBLIC KEY-----\n"
|
||||
. chunk_split($config['public_key'], 64, "\n")
|
||||
. "-----END PUBLIC KEY-----";
|
||||
}
|
||||
|
||||
if (empty($config['merchant_public_key'])) {
|
||||
$config['merchant_public_key'] = $config['public_key'];
|
||||
}
|
||||
|
||||
// 处理平台公钥
|
||||
if (!empty($config['platform_public_key'])) {
|
||||
if (strpos($config['platform_public_key'], '-----BEGIN') === false) {
|
||||
$config['platform_public_key'] = "-----BEGIN PUBLIC KEY-----\n"
|
||||
. chunk_split($config['platform_public_key'], 64, "\n")
|
||||
. "-----END PUBLIC KEY-----";
|
||||
}
|
||||
} else {
|
||||
$config['platform_public_key'] = $config['public_key'];
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送订单到建行生活平台
|
||||
* 当用户下单后调用此方法同步订单信息
|
||||
*
|
||||
* @param int $orderId Shopro订单ID
|
||||
* @param string $payFlowId 支付流水号(由控制器统一生成)
|
||||
* @return array ['status' => bool, 'message' => string, 'data' => array]
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function pushOrder($orderId)
|
||||
public function pushOrder($orderId, $payFlowId)
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
// ✅ 验证支付流水号
|
||||
if (empty($payFlowId)) {
|
||||
throw new \Exception('支付流水号不能为空');
|
||||
}
|
||||
|
||||
// 获取订单信息
|
||||
$order = Db::name('shopro_order')
|
||||
->alias('o')
|
||||
@ -115,7 +84,8 @@ class CcbOrderService
|
||||
->select();
|
||||
|
||||
// 构建订单数据(符合A3341TP01接口规范)
|
||||
$orderData = $this->buildOrderData($order, $orderItems, $ccbUserId);
|
||||
// ✅ 传入统一的支付流水号
|
||||
$orderData = $this->buildOrderData($order, $orderItems, $ccbUserId, $payFlowId);
|
||||
|
||||
// 记录请求数据(同步日志)
|
||||
$txSeq = CcbMD5::generateTransactionSeq();
|
||||
@ -270,13 +240,13 @@ class CcbOrderService
|
||||
{
|
||||
try {
|
||||
// 获取订单信息
|
||||
$order = Order::find($orderId);
|
||||
$order = Db::name('shopro_order')->where('id', $orderId)->find();
|
||||
if (!$order) {
|
||||
throw new \Exception('订单不存在');
|
||||
}
|
||||
|
||||
// 验证退款金额
|
||||
if ($refundAmount > $order['total_amount']) {
|
||||
if ($refundAmount > $order['order_amount']) {
|
||||
throw new \Exception('退款金额不能超过订单总额');
|
||||
}
|
||||
|
||||
@ -307,146 +277,183 @@ class CcbOrderService
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建符合建行要求的订单数据
|
||||
* 构建符合建行 A3341TP01 接口规范的订单数据
|
||||
*
|
||||
* ⚠️ 注意:Shopro字段说明
|
||||
* - pay_fee: 实际支付金额(Shopro字段)
|
||||
* - order_amount: 订单总金额(Shopro字段)
|
||||
* - total_discount_fee: 优惠总金额(Shopro字段)
|
||||
* - paid_time: 支付时间(毫秒时间戳!需除以1000)
|
||||
* - createtime: 创建时间(毫秒时间戳!需除以1000)
|
||||
* 📋 建行生活订单推送接口规范说明(v1.1.6):
|
||||
*
|
||||
* 必填字段(11个):
|
||||
* - USER_ID: 客户编号(建行用户ID)
|
||||
* - ORDER_ID: 订单号
|
||||
* - ORDER_DT: 订单日期(yyyyMMddHHmmss格式)
|
||||
* - TOTAL_AMT: 订单原金额
|
||||
* - ORDER_STATUS: 订单状态
|
||||
* - REFUND_STATUS: 退款状态
|
||||
* - MCT_NM: 商户名称
|
||||
* - CUS_ORDER_URL: 订单详情链接
|
||||
* - PAY_FLOW_ID: 支付流水号
|
||||
* - PAY_MRCH_ID: 支付商户号
|
||||
* - SKU_LIST: 商品信息JSON字符串
|
||||
*
|
||||
* 重要可选字段(建议必填):
|
||||
* - PAY_AMT: 订单实际支付金额(文档要求:如为空必须在状态变更时推送)
|
||||
* - DISCOUNT_AMT: 第三方平台优惠金额(文档要求:如为空必须在状态变更时推送)
|
||||
* - DISCOUNT_AMT_DESC: 第三方平台优惠说明
|
||||
* - INV_DT: 订单过期日期
|
||||
* - GOODS_NM: 商品名称
|
||||
* - PREFTL_MRCH_ID: 门店商户号
|
||||
* - PLAT_MCT_ID: 服务商门店编号
|
||||
* - PLAT_ORDER_TYPE: 服务方订单类型
|
||||
* - PLATFORM: 下单场景
|
||||
*
|
||||
* ⚠️ 注意:Shopro字段映射
|
||||
* - pay_fee → PAY_AMT(实际支付金额)
|
||||
* - order_amount → TOTAL_AMT(订单总金额)
|
||||
* - total_discount_fee → DISCOUNT_AMT(优惠总金额)
|
||||
* - createtime → ORDER_DT(毫秒时间戳需除以1000)
|
||||
* - expiry_time → INV_DT(过期时间)
|
||||
*
|
||||
* @param array $order 订单数组
|
||||
* @param array $orderItems 订单商品列表
|
||||
* @param string $ccbUserId 建行用户ID
|
||||
* @return array
|
||||
*/
|
||||
private function buildOrderData($order, $orderItems, $ccbUserId)
|
||||
private function buildOrderData($order, $orderItems, $ccbUserId, $payFlowId)
|
||||
{
|
||||
// 构建商品列表
|
||||
$goodsList = $this->buildGoodsList($orderItems);
|
||||
|
||||
// 计算各项金额(Shopro字段:pay_fee=实付金额,order_amount=订单总金额)
|
||||
$totalAmount = number_format($order['order_amount'] ?? 0, 2, '.', '');
|
||||
$payAmount = number_format($order['pay_fee'] ?? 0, 2, '.', '');
|
||||
$discountAmount = number_format($order['total_discount_fee'] ?? 0, 2, '.', '');
|
||||
|
||||
// 处理支付时间(Shopro的paid_time是毫秒时间戳,需要除以1000)
|
||||
$payTime = '';
|
||||
if (!empty($order['paid_time']) && is_numeric($order['paid_time'])) {
|
||||
$payTime = date('YmdHis', intval($order['paid_time'] / 1000));
|
||||
// ✅ 使用控制器传入的统一支付流水号(确保与支付串生成使用同一流水号)
|
||||
if (empty($payFlowId)) {
|
||||
throw new \Exception('支付流水号不能为空');
|
||||
}
|
||||
|
||||
// 处理创建时间(Shopro的createtime是毫秒时间戳,需要除以1000)
|
||||
// 构建SKU商品列表(JSON字符串格式)
|
||||
$skuList = $this->buildSkuList($orderItems);
|
||||
|
||||
// 计算各项金额(保留2位小数)
|
||||
$totalAmount = number_format($order['order_amount'] ?? 0, 2, '.', '');
|
||||
$payAmount = number_format($order['pay_fee'] ?? $order['order_amount'] ?? 0, 2, '.', '');
|
||||
$discountAmount = number_format($order['total_discount_fee'] ?? 0, 2, '.', '');
|
||||
$totalRefundAmount = number_format($order['refund_fee'] ?? 0, 2, '.', '');
|
||||
|
||||
// 处理订单时间(Shopro的createtime是毫秒时间戳,需要除以1000)
|
||||
$createTimeValue = $order['createtime'] ?? null;
|
||||
if (empty($createTimeValue) || !is_numeric($createTimeValue)) {
|
||||
$createTimeValue = time() * 1000; // 当前时间的毫秒时间戳
|
||||
$createTimeValue = time() * 1000;
|
||||
}
|
||||
$createTime = date('YmdHis', intval($createTimeValue / 1000));
|
||||
$orderDt = date('YmdHis', intval($createTimeValue / 1000));
|
||||
|
||||
// 获取订单地址信息(Shopro将地址存储在单独的表中)
|
||||
$orderAddress = Db::name('shopro_order_address')
|
||||
->where('order_id', $order['id'])
|
||||
->find();
|
||||
// 处理订单过期时间
|
||||
$invDt = '';
|
||||
if (!empty($order['expiry_time'])) {
|
||||
// Shopro 的 expiry_time 可能是时间戳或日期字符串
|
||||
if (is_numeric($order['expiry_time'])) {
|
||||
// 如果是毫秒时间戳,需要除以1000
|
||||
$timestamp = intval($order['expiry_time']);
|
||||
if ($timestamp > 9999999999) {
|
||||
$timestamp = intval($timestamp / 1000);
|
||||
}
|
||||
$invDt = date('YmdHis', $timestamp);
|
||||
} else {
|
||||
$invDt = date('YmdHis', strtotime($order['expiry_time']));
|
||||
}
|
||||
}
|
||||
|
||||
// 获取支付方式(Shopro将支付信息存储在单独的表中)
|
||||
$payInfo = Db::name('shopro_pay')
|
||||
->where('order_id', $order['id'])
|
||||
->where('status', 'paid')
|
||||
->find();
|
||||
// 获取商品名称(取第一个商品)
|
||||
$goodsName = '';
|
||||
if (!empty($orderItems)) {
|
||||
$goodsName = $orderItems[0]['goods_title'] ?? '';
|
||||
// 如果有多个商品,可以拼接
|
||||
if (count($orderItems) > 1) {
|
||||
$goodsName .= ' 等' . count($orderItems) . '件商品';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取快递信息(Shopro将快递信息存储在单独的表中)
|
||||
$expressInfo = Db::name('shopro_order_express')
|
||||
->where('order_id', $order['id'])
|
||||
->find();
|
||||
// 构建优惠说明(如果有优惠金额)
|
||||
$discountAmtDesc = '';
|
||||
if ($discountAmount > 0) {
|
||||
// 格式:名称=金额|@|名称=金额
|
||||
// 这里简化处理,实际应该根据具体优惠券信息构建
|
||||
$discountAmtDesc = '平台优惠=' . $discountAmount;
|
||||
}
|
||||
|
||||
// 构建订单数据(34个必填字段)
|
||||
return [
|
||||
'USER_ID' => $ccbUserId, // 建行用户ID
|
||||
// 构建符合A3341TP01接口规范的订单数据
|
||||
$orderData = [
|
||||
// ========== 必填字段 ==========
|
||||
'USER_ID' => $ccbUserId, // 客户编号
|
||||
'ORDER_ID' => $order['order_sn'], // 订单号
|
||||
'ORDER_DT' => $createTime, // 订单时间
|
||||
'ORDER_DT' => $orderDt, // 订单日期(yyyyMMddHHmmss)
|
||||
'TOTAL_AMT' => $totalAmount, // 订单原金额
|
||||
'PAY_AMT' => $payAmount, // 实付金额
|
||||
'DISCOUNT_AMT' => $discountAmount, // 优惠金额
|
||||
'ORDER_STATUS' => $this->mapOrderStatus($order['status']), // 订单状态
|
||||
'REFUND_STATUS' => '0', // 退款状态(默认无退款)
|
||||
'REFUND_STATUS' => $this->mapRefundStatus($order['refund_status'] ?? 0), // 退款状态
|
||||
'MCT_NM' => $this->config['merchant']['name'] ?? '商户名称', // 商户名称
|
||||
'MCT_ORDER_ID' => $order['id'], // 商户订单ID
|
||||
'GOODS_LIST' => json_encode($goodsList, JSON_UNESCAPED_UNICODE), // 商品列表
|
||||
'PAY_TYPE' => $this->mapPayType($payInfo['pay_type'] ?? ''), // 支付方式(从支付表获取)
|
||||
'PAY_TIME' => $payTime, // 支付时间(毫秒转秒)
|
||||
'DELIVERY_TYPE' => '01', // 配送方式(01快递)
|
||||
'DELIVERY_STATUS' => $this->mapDeliveryStatus($order['status']), // 配送状态
|
||||
'DELIVERY_TIME' => !empty($expressInfo['createtime']) ? date('YmdHis', intval($expressInfo['createtime'] / 1000)) : '', // 发货时间
|
||||
'RECEIVE_NAME' => $orderAddress['consignee'] ?? '', // 收货人姓名(从地址表获取)
|
||||
'RECEIVE_PHONE' => $orderAddress['mobile'] ?? '', // 收货人电话(从地址表获取)
|
||||
'RECEIVE_ADDRESS' => $this->buildAddress($order), // 收货地址(从地址表获取)
|
||||
'EXPRESS_COMPANY' => $expressInfo['express_name'] ?? '', // 快递公司(从快递表获取)
|
||||
'EXPRESS_NO' => $expressInfo['express_no'] ?? '', // 快递单号(从快递表获取)
|
||||
'REMARK' => $order['remark'] ?? '', // 备注
|
||||
'ORDER_TYPE' => '01', // 订单类型(01普通订单)
|
||||
'IS_VIRTUAL' => '0', // 是否虚拟商品
|
||||
'ORDER_URL' => $this->config['merchant']['order_detail_url'] . $order['id'], // 订单详情链接
|
||||
'CREATE_TIME' => $createTime, // 创建时间
|
||||
'UPDATE_TIME' => date('YmdHis'), // 更新时间
|
||||
'SHOP_ID' => '1', // 店铺ID
|
||||
'SHOP_NAME' => $this->config['merchant']['name'] ?? '', // 店铺名称
|
||||
'ACTIVITY_ID' => '', // 活动ID
|
||||
'ACTIVITY_NAME' => '', // 活动名称
|
||||
'COUPON_AMT' => number_format($order['coupon_discount_fee'] ?? 0, 2, '.', ''), // 优惠券金额
|
||||
'FREIGHT_AMT' => number_format($order['dispatch_amount'] ?? 0, 2, '.', ''), // 运费(Shopro字段名为dispatch_amount)
|
||||
'PAY_FLOW_ID' => $payFlowId, // ✅ 支付流水号(使用控制器传入的统一流水号)
|
||||
'PAY_MRCH_ID' => $this->config['merchant_id'], // 支付商户号(必填!)
|
||||
'SKU_LIST' => $skuList, // 商品信息JSON字符串(必填!)
|
||||
|
||||
// ========== 重要可选字段(强烈建议填写) ==========
|
||||
'PAY_AMT' => $payAmount, // 订单实际支付金额
|
||||
'DISCOUNT_AMT' => $discountAmount, // 第三方平台优惠金额
|
||||
'PLAT_ORDER_TYPE' => 'T0000', // 服务方订单类型(T0000-普通类型)
|
||||
'PLATFORM' => '99', // 下单场景(99-建行生活APP)
|
||||
];
|
||||
|
||||
// ========== 条件可选字段(有值才添加) ==========
|
||||
|
||||
// 优惠说明
|
||||
if (!empty($discountAmtDesc)) {
|
||||
$orderData['DISCOUNT_AMT_DESC'] = $discountAmtDesc;
|
||||
}
|
||||
|
||||
// 订单过期时间
|
||||
if (!empty($invDt)) {
|
||||
$orderData['INV_DT'] = $invDt;
|
||||
}
|
||||
|
||||
// 商品名称
|
||||
if (!empty($goodsName)) {
|
||||
$orderData['GOODS_NM'] = mb_substr($goodsName, 0, 200); // 限制长度200字符
|
||||
}
|
||||
|
||||
// 累计退款金额(如果有退款)
|
||||
if ($totalRefundAmount > 0) {
|
||||
$orderData['TOTAL_REFUND_AMT'] = $totalRefundAmount;
|
||||
}
|
||||
|
||||
return $orderData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建商品列表
|
||||
* 构建符合建行规范的SKU商品列表(JSON字符串格式)
|
||||
*
|
||||
* @param array $items 订单商品项
|
||||
* @return array
|
||||
* 📋 建行 SKU_LIST 字段规范:
|
||||
*
|
||||
* 必填字段(4个):
|
||||
* - SKU_NAME: 商品名称(必填)
|
||||
* - SKU_REF_PRICE: 商品参考价(必填,支持小数最多2位)
|
||||
* - SKU_NUM: 商品数量(必填,支持小数最多1位)
|
||||
* - SKU_SELL_PRICE: 商品售价(必填,支持小数最多2位)
|
||||
*
|
||||
* ⚠️ 注意:Shopro字段映射
|
||||
* - goods_title → SKU_NAME(商品名称)
|
||||
* - goods_original_price → SKU_REF_PRICE(商品原价作为参考价)
|
||||
* - goods_num → SKU_NUM(购买数量)
|
||||
* - goods_price → SKU_SELL_PRICE(商品实际售价)
|
||||
*
|
||||
* @param array $items 订单商品项数组
|
||||
* @return string JSON字符串格式的SKU列表
|
||||
*/
|
||||
private function buildGoodsList($items)
|
||||
private function buildSkuList($items)
|
||||
{
|
||||
$goodsList = [];
|
||||
$skuList = [];
|
||||
foreach ($items as $item) {
|
||||
$goodsList[] = [
|
||||
'goods_id' => $item['goods_id'],
|
||||
'goods_name' => $item['goods_title'],
|
||||
'goods_price' => number_format($item['goods_price'], 2, '.', ''),
|
||||
'goods_num' => $item['goods_num'],
|
||||
'goods_amount' => number_format($item['goods_amount'], 2, '.', ''),
|
||||
'goods_image' => $item['goods_image'] ?? '',
|
||||
'goods_sku' => $item['goods_sku_text'] ?? ''
|
||||
$skuList[] = [
|
||||
'SKU_NAME' => $item['goods_title'], // 商品名称(必填)
|
||||
'SKU_REF_PRICE' => number_format($item['goods_original_price'] ?? $item['goods_price'], 2, '.', ''), // 商品参考价(必填)
|
||||
'SKU_NUM' => $item['goods_num'], // 商品数量(必填)
|
||||
'SKU_SELL_PRICE' => number_format($item['goods_price'], 2, '.', ''), // 商品售价(必填)
|
||||
];
|
||||
}
|
||||
return $goodsList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建收货地址
|
||||
*
|
||||
* ⚠️ 注意:Shopro的收货地址存储在单独的表 shopro_order_address 中
|
||||
*
|
||||
* @param array $order 订单数组
|
||||
* @return string
|
||||
*/
|
||||
private function buildAddress($order)
|
||||
{
|
||||
// 从订单地址表获取地址信息
|
||||
$orderAddress = Db::name('shopro_order_address')
|
||||
->where('order_id', $order['id'])
|
||||
->find();
|
||||
|
||||
if (!$orderAddress) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$address = '';
|
||||
if (!empty($orderAddress['province_name'])) $address .= $orderAddress['province_name'];
|
||||
if (!empty($orderAddress['city_name'])) $address .= $orderAddress['city_name'];
|
||||
if (!empty($orderAddress['district_name'])) $address .= $orderAddress['district_name'];
|
||||
if (!empty($orderAddress['address'])) $address .= $orderAddress['address'];
|
||||
|
||||
return $address;
|
||||
// 返回JSON字符串(不转义Unicode,保持中文可读)
|
||||
return json_encode($skuList, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -543,38 +550,6 @@ class CcbOrderService
|
||||
return '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射支付方式
|
||||
*
|
||||
* @param string $payType 支付类型
|
||||
* @return string
|
||||
*/
|
||||
private function mapPayType($payType)
|
||||
{
|
||||
$payMap = [
|
||||
'wechat' => '01', // 微信支付
|
||||
'alipay' => '02', // 支付宝
|
||||
'ccb' => '03', // 建行支付
|
||||
'balance' => '04' // 余额支付
|
||||
];
|
||||
|
||||
return $payMap[$payType] ?? '00';
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射配送状态
|
||||
*
|
||||
* @param string $status 订单状态
|
||||
* @return string
|
||||
*/
|
||||
private function mapDeliveryStatus($status)
|
||||
{
|
||||
if (in_array($status, ['unpaid', 'paid'])) return '0'; // 未发货
|
||||
if ($status == 'shipped') return '1'; // 已发货
|
||||
if (in_array($status, ['received', 'completed'])) return '2'; // 已收货
|
||||
return '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步订单
|
||||
* 用于初始化或定时同步
|
||||
|
||||
@ -35,56 +35,13 @@ class CcbPaymentService
|
||||
throw new \Exception('建行生活配置文件不存在');
|
||||
}
|
||||
|
||||
// 处理BASE64格式的密钥,添加PEM包装
|
||||
$this->config = $this->processPemKeys($this->config);
|
||||
// ✅ 修复: 删除processPemKeys()调用
|
||||
// 密钥格式化统一由CcbRSA类处理,避免重复格式化导致OpenSSL ASN1解析错误
|
||||
// CcbRSA::formatPublicKey/formatPrivateKey 会在加密/解密时自动处理密钥格式
|
||||
|
||||
$this->orderService = new CcbOrderService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理PEM格式密钥
|
||||
* 如果密钥是BASE64格式(不含-----BEGIN-----),则添加PEM包装
|
||||
*
|
||||
* @param array $config 配置数组
|
||||
* @return array
|
||||
*/
|
||||
private function processPemKeys($config)
|
||||
{
|
||||
// 处理私钥
|
||||
if (!empty($config['private_key']) && strpos($config['private_key'], '-----BEGIN') === false) {
|
||||
$config['private_key'] = "-----BEGIN PRIVATE KEY-----\n"
|
||||
. chunk_split($config['private_key'], 64, "\n")
|
||||
. "-----END PRIVATE KEY-----";
|
||||
}
|
||||
|
||||
// 处理公钥
|
||||
if (!empty($config['public_key']) && strpos($config['public_key'], '-----BEGIN') === false) {
|
||||
$config['public_key'] = "-----BEGIN PUBLIC KEY-----\n"
|
||||
. chunk_split($config['public_key'], 64, "\n")
|
||||
. "-----END PUBLIC KEY-----";
|
||||
}
|
||||
|
||||
// 兼容merchant_public_key字段
|
||||
if (empty($config['merchant_public_key'])) {
|
||||
$config['merchant_public_key'] = $config['public_key'];
|
||||
}
|
||||
|
||||
// 处理平台公钥
|
||||
if (!empty($config['platform_public_key'])) {
|
||||
// 如果有配置平台公钥且是BASE64格式,添加PEM包装
|
||||
if (strpos($config['platform_public_key'], '-----BEGIN') === false) {
|
||||
$config['platform_public_key'] = "-----BEGIN PUBLIC KEY-----\n"
|
||||
. chunk_split($config['platform_public_key'], 64, "\n")
|
||||
. "-----END PUBLIC KEY-----";
|
||||
}
|
||||
} else {
|
||||
// 如果没有配置平台公钥,使用商户公钥作为默认值
|
||||
$config['platform_public_key'] = $config['public_key'];
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成建行支付串
|
||||
* 用于前端JSBridge调用建行收银台
|
||||
@ -92,9 +49,10 @@ class CcbPaymentService
|
||||
* ⚠️ 注意:必须包含所有必需参数,签名前按ASCII排序
|
||||
*
|
||||
* @param int $orderId Shopro订单ID
|
||||
* @param string $payFlowId 支付流水号(由控制器统一生成)
|
||||
* @return array ['status' => bool, 'message' => string, 'data' => array]
|
||||
*/
|
||||
public function generatePaymentString($orderId)
|
||||
public function generatePaymentString($orderId, $payFlowId)
|
||||
{
|
||||
// ⚠️ 开启事务保护,确保数据一致性
|
||||
Db::startTrans();
|
||||
@ -111,37 +69,133 @@ class CcbPaymentService
|
||||
throw new \Exception('订单状态不正确');
|
||||
}
|
||||
|
||||
// 获取用户建行ID
|
||||
$user = Db::name('user')->where('id', $order['user_id'])->field('ccb_user_id')->find();
|
||||
// 获取用户建行生活ID(用于订单推送)
|
||||
$user = Db::name('user')->where('id', $order['user_id'])
|
||||
->field('ccb_user_id')
|
||||
->find();
|
||||
if (empty($user['ccb_user_id'])) {
|
||||
throw new \Exception('用户未绑定建行账号');
|
||||
throw new \Exception('用户未绑定建行生活账号');
|
||||
}
|
||||
|
||||
// 生成支付流水号(使用订单号作为唯一标识)
|
||||
$payFlowId = 'PAY' . date('YmdHis') . mt_rand(100000, 999999);
|
||||
// ✅ 使用控制器传入的统一支付流水号(确保与订单推送使用同一流水号)
|
||||
if (empty($payFlowId)) {
|
||||
throw new \Exception('支付流水号不能为空');
|
||||
}
|
||||
|
||||
// 构建完整的支付参数(34个参数)
|
||||
// ✅ 构建完整的48个支付参数(按照建行文档5.4完整参数定义)
|
||||
// 基础商户参数(必须二选一:建行商户号组合 或 外部平台商户号)
|
||||
$paymentParams = [
|
||||
'MERCHANTID' => $this->config['merchant_id'], // 商户代码
|
||||
'POSID' => $this->config['pos_id'], // 柜台代码
|
||||
'BRANCHID' => $this->config['branch_id'], // 分行代码
|
||||
'ORDERID' => $payFlowId, // 支付流水号(必须唯一!)
|
||||
'PAYMENT' => number_format($order['pay_fee'], 2, '.', ''), // 支付金额(Shopro使用pay_fee)
|
||||
'CURCODE' => '01', // 币种(01=人民币)
|
||||
'TXCODE' => '520100', // 交易码(520100=即时支付)
|
||||
'REMARK1' => '', // 备注1
|
||||
'REMARK2' => $this->config['service_id'], // 备注2(服务方编号)
|
||||
'TYPE' => '1', // 支付类型(1=个人)
|
||||
'GATEWAY' => '0', // 网关标志
|
||||
'CLIENTIP' => $this->getClientIp(), // 客户端IP
|
||||
'REGINFO' => '', // 注册信息
|
||||
'PROINFO' => $this->buildProductInfo($order), // 商品信息
|
||||
'REFERER' => '', // 来源页面
|
||||
'THIRDAPPINFO' => 'comccbpay1234567890cloudmerchant', // 第三方应用信息(固定值)
|
||||
'USER_ORDERID' => $order['order_sn'], // 商户订单号
|
||||
'TIMEOUT' => date('YmdHis', strtotime('+30 minutes')) // 超时时间
|
||||
'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)
|
||||
];
|
||||
|
||||
// ✅ 可选参数(根据实际场景添加)
|
||||
// 外部平台商户号(与建行商户号二选一)
|
||||
if (!empty($this->config['plat_mct_id'])) {
|
||||
$paymentParams['PLATMCTID'] = $this->config['plat_mct_id'];
|
||||
// 使用外部商户号时,删除建行商户号
|
||||
unset($paymentParams['MERCHANTID'], $paymentParams['POSID'], $paymentParams['BRANCHID']);
|
||||
}
|
||||
|
||||
// 微信支付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'];
|
||||
}
|
||||
|
||||
// 账户位图(控制支付账户:建行借记卡/贷记卡/他行借记卡/贷记卡/建行钱包)
|
||||
if (!empty($this->config['account_bitmap'])) {
|
||||
$paymentParams['ACCOUNTBITMAP'] = $this->config['account_bitmap'];
|
||||
}
|
||||
|
||||
// 分期期数
|
||||
if (!empty($this->config['install_num'])) {
|
||||
$paymentParams['INSTALLNUM'] = $this->config['install_num'];
|
||||
}
|
||||
|
||||
// 积分二级活动编号
|
||||
if (!empty($this->config['point_avy_id'])) {
|
||||
$paymentParams['POINTAVYID'] = $this->config['point_avy_id'];
|
||||
}
|
||||
|
||||
// 数字人民币参数
|
||||
if (!empty($this->config['dcep_mct_type'])) {
|
||||
$paymentParams['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_dep_acc_no'])) {
|
||||
$paymentParams['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'];
|
||||
}
|
||||
if (!empty($this->config['sub_mct_name'])) {
|
||||
$paymentParams['SUB_MCT_NAME'] = $this->config['sub_mct_name'];
|
||||
}
|
||||
if (!empty($this->config['sub_mct_mcc'])) {
|
||||
$paymentParams['SUB_MCT_MCC'] = $this->config['sub_mct_mcc'];
|
||||
}
|
||||
|
||||
// 场景编号(埋点使用,不参与MAC校验)
|
||||
if (!empty($this->config['scn_id'])) {
|
||||
$paymentParams['SCNID'] = $this->config['scn_id'];
|
||||
}
|
||||
if (!empty($this->config['scn_pltfrm_id'])) {
|
||||
$paymentParams['SCN_PLTFRM_ID'] = $this->config['scn_pltfrm_id'];
|
||||
}
|
||||
|
||||
// 按ASCII排序
|
||||
ksort($paymentParams);
|
||||
|
||||
@ -151,13 +205,13 @@ class CcbPaymentService
|
||||
// ⚠️ 建行支付串签名规则(v2.2版本):
|
||||
// 1. PLATFORMPUB字段仅参与MD5计算,不作为HTTP参数传递
|
||||
// 2. 签名 = MD5(参数字符串 + &PLATFORMPUB= + 服务方公钥内容)
|
||||
// 3. 生成32位大写MD5字符串(对照MD5Util.java第30行)
|
||||
// 3. 生成32位小写MD5字符串(根据建行文档5.4.1要求)
|
||||
$platformPubKey = $this->config['public_key']; // 服务方公钥
|
||||
$mac = strtoupper(md5($signString . '&PLATFORMPUB=' . $platformPubKey));
|
||||
$mac = strtolower(md5($signString . '&PLATFORMPUB=' . $platformPubKey));
|
||||
|
||||
// 使用RSA加密商户公钥后30位(用于ENCPUB字段)
|
||||
$encryption = new CcbEncryption($this->config);
|
||||
$encpub = $encryption->encryptMerchantPublicKeyLast30();
|
||||
// ✅ 修复:使用 CcbRSA 加密商户公钥后30位(用于ENCPUB字段)
|
||||
// 删除 CcbEncryption 类,统一使用 CcbRSA 处理密钥格式化
|
||||
$encpub = $this->encryptPublicKeyLast30();
|
||||
|
||||
// 组装最终支付串
|
||||
$finalPaymentString = $signString . '&MAC=' . $mac . '&PLATFORMID=' . $this->config['service_id'] . '&ENCPUB=' . urlencode($encpub);
|
||||
@ -168,9 +222,6 @@ class CcbPaymentService
|
||||
'updatetime' => time()
|
||||
]);
|
||||
|
||||
// 构建完整的支付URL
|
||||
$paymentUrl = $this->config['cashier_url'] . '?' . $finalPaymentString;
|
||||
|
||||
// 记录支付请求
|
||||
$this->recordPaymentRequest($orderId, [
|
||||
'payment_string' => $finalPaymentString,
|
||||
@ -188,7 +239,6 @@ class CcbPaymentService
|
||||
'data' => [
|
||||
'payment_string' => $finalPaymentString,
|
||||
'mac' => $mac,
|
||||
'payment_url' => $paymentUrl,
|
||||
'order_sn' => $order['order_sn'],
|
||||
'pay_flow_id' => $payFlowId,
|
||||
'amount' => number_format($order['pay_fee'], 2, '.', ''),
|
||||
@ -225,11 +275,47 @@ class CcbPaymentService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实现JavaScript的escape()编码
|
||||
* 用于REGINFO和PROINFO字段的中文编码
|
||||
*
|
||||
* 根据建行文档4.3:
|
||||
* "使用js的escape()方法对REGINFO(客户注册信息)和PROINFO(商品信息)进行编码"
|
||||
*
|
||||
* @param string $str 要编码的字符串
|
||||
* @return string 编码后的字符串
|
||||
*/
|
||||
private function jsEscape($str)
|
||||
{
|
||||
if (empty($str)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = '';
|
||||
$length = mb_strlen($str, 'UTF-8');
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$char = mb_substr($str, $i, 1, 'UTF-8');
|
||||
|
||||
// ASCII字符(数字、字母、部分符号)不编码
|
||||
if (preg_match('/^[A-Za-z0-9@*_+\-.\\/]$/', $char)) {
|
||||
$result .= $char;
|
||||
} else {
|
||||
// 非ASCII字符转为 %uXXXX 格式(如:小 -> %u5C0F)
|
||||
$unicode = mb_ord($char, 'UTF-8');
|
||||
$result .= '%u' . strtoupper(str_pad(dechex($unicode), 4, '0', STR_PAD_LEFT));
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建商品信息字符串
|
||||
* 根据建行文档4.3:商品信息中文需使用escape()编码
|
||||
*
|
||||
* @param object $order 订单对象
|
||||
* @return string
|
||||
* @return string escape编码后的商品信息
|
||||
*/
|
||||
private function buildProductInfo($order)
|
||||
{
|
||||
@ -240,11 +326,51 @@ class CcbPaymentService
|
||||
->column('goods_title');
|
||||
|
||||
if (empty($orderItems)) {
|
||||
return '商城订单';
|
||||
$productInfo = '商城订单';
|
||||
} else {
|
||||
// 拼接商品名称,建议不超过50字符(编码前)
|
||||
$productInfo = mb_substr(implode(',', $orderItems), 0, 50, 'UTF-8');
|
||||
}
|
||||
|
||||
// 拼接商品名称
|
||||
return implode(',', $orderItems);
|
||||
// ✅ 使用JavaScript的escape()编码中文
|
||||
return $this->jsEscape($productInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密商户公钥后30位(用于支付串的ENCPUB字段)
|
||||
*
|
||||
* 根据建行文档v2.2规范:
|
||||
* "使用服务方公钥对商户公钥后30位进行RSA加密并base64后的密文"
|
||||
*
|
||||
* @return string BASE64编码的加密密文
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function encryptPublicKeyLast30()
|
||||
{
|
||||
$publicKey = $this->config['public_key'] ?? '';
|
||||
|
||||
if (empty($publicKey)) {
|
||||
throw new \Exception('服务方公钥未配置');
|
||||
}
|
||||
|
||||
// 去除 PEM 格式的头尾和空白字符,获取纯 BASE64 内容
|
||||
$publicKeyContent = str_replace([
|
||||
'-----BEGIN PUBLIC KEY-----',
|
||||
'-----END PUBLIC KEY-----',
|
||||
'-----BEGIN RSA PUBLIC KEY-----',
|
||||
'-----END RSA PUBLIC KEY-----',
|
||||
"\r", "\n", " ", "\t"
|
||||
], '', $publicKey);
|
||||
|
||||
// 取后30位
|
||||
$last30Chars = substr($publicKeyContent, -30);
|
||||
|
||||
if (strlen($last30Chars) < 30) {
|
||||
throw new \Exception('商户公钥长度不足30位');
|
||||
}
|
||||
|
||||
// ✅ 使用 CcbRSA 类进行加密(统一密钥格式化逻辑)
|
||||
return CcbRSA::encrypt($last30Chars, $publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -298,8 +424,8 @@ class CcbPaymentService
|
||||
// 支付成功,更新订单状态
|
||||
$this->updateOrderPaymentStatus($order, $params);
|
||||
|
||||
// 同步订单到建行
|
||||
$this->orderService->pushOrder($order['id']);
|
||||
// ✅ 更新订单状态到建行(订单已在createPayment时推送,这里只需更新状态)
|
||||
$this->orderService->updateOrderStatus($order['id']);
|
||||
|
||||
return [
|
||||
'status' => true,
|
||||
@ -431,26 +557,6 @@ class CcbPaymentService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建支付URL
|
||||
*
|
||||
* @param array $params 支付参数
|
||||
* @param string $mac 签名
|
||||
* @return string
|
||||
*/
|
||||
private function buildPaymentUrl($params, $mac)
|
||||
{
|
||||
// 添加必要参数
|
||||
$params['MAC'] = $mac;
|
||||
$params['REMARK2'] = $this->config['service_id']; // 服务方编号
|
||||
|
||||
// 生成查询字符串
|
||||
$queryString = http_build_query($params);
|
||||
|
||||
// 返回完整URL(实际使用时通过JSBridge调用,不直接访问)
|
||||
return $this->config['cashier_url'] . '?' . $queryString;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新订单支付状态
|
||||
*
|
||||
|
||||
@ -140,9 +140,10 @@ class CcbRSA
|
||||
return $publicKey;
|
||||
}
|
||||
|
||||
// 格式化为PEM格式
|
||||
// ✅ 修复: chunk_split()会在末尾添加换行符,需要用rtrim()去除
|
||||
// 否则会导致PEM格式中密钥内容和尾部之间有多余空行,OpenSSL解析失败
|
||||
$pem = "-----BEGIN PUBLIC KEY-----\n";
|
||||
$pem .= chunk_split($publicKey, 64, "\n");
|
||||
$pem .= rtrim(chunk_split($publicKey, 64, "\n"), "\n") . "\n";
|
||||
$pem .= "-----END PUBLIC KEY-----\n";
|
||||
|
||||
return $pem;
|
||||
@ -166,9 +167,10 @@ class CcbRSA
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
// 格式化为PEM格式
|
||||
// ✅ 修复: chunk_split()会在末尾添加换行符,需要用rtrim()去除
|
||||
// 否则会导致PEM格式中密钥内容和尾部之间有多余空行,OpenSSL解析失败
|
||||
$pem = "-----BEGIN RSA PRIVATE KEY-----\n";
|
||||
$pem .= chunk_split($privateKey, 64, "\n");
|
||||
$pem .= rtrim(chunk_split($privateKey, 64, "\n"), "\n") . "\n";
|
||||
$pem .= "-----END RSA PRIVATE KEY-----\n";
|
||||
|
||||
return $pem;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,44 +0,0 @@
|
||||
package com.example.filedemo.util.fuwufang;
|
||||
|
||||
import com.example.filedemo.util.RSAUtil;
|
||||
import sun.misc.BASE64Decoder;
|
||||
import sun.misc.BASE64Encoder;
|
||||
|
||||
public class UrlMain {
|
||||
public static void main(String[] args) throws Exception {
|
||||
String msg = "BGCOLOR=&userid=YSM202111170063936&mobile=18242028306&cityid=330100&userCityId=330100&orderid=&PLATFLOWNO=0000A2UNK1639016304462982&openid=&lgt=113.3295774824442<t=23.12339638654285&Usr_Name=&USERID=YSM202111170063936&MOBILE=18242028306&CITYID=330100&USERCITYID=330100&ORDERID=&OPENID=&LGT=113.3295774824442<T=23.12339638654285";
|
||||
|
||||
//String enc_msg = "SDB0dllqYmxFS2xHRmlqa1ZaOFk0OHBXY0I5TitoREdJaVB3K1pjM2M3dy9jek4zN016ZUoxZENTNTVLWVFFV3VSYzlYOVlXRkpBcQpWRUgwaDJUMG04V2lmNHJyS3krdG5QUDJHalhEQlNma21oR3JrV0lsbFRibC9vbWJONGxqeVk1TXZQWjVWc2t5N2ZVRlZTYlNlYjIzCnJ5cFN4dTRNSDUrTjFRTU5NVFE9Cg%3D%3D";
|
||||
// 公钥
|
||||
String publicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQClMNB2rs4PMyxHdV+HeISWBbe55WQkmSYQQvFq8M4MMczhYihhp1Z9p723wD8cv9m/PQQcQZuNIehGGIIbZnMZFkqwDYUODH0DF8N5o7BiUhw/XUr3nl49/hsjlE6L7k/7jYzxZ+r3CXhz7qVXZNW6tD2RM+AI4qomQr0p1VNxhQIDAQAB";
|
||||
// 私钥
|
||||
String privateKey = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKUw0Hauzg8zLEd1X4d4hJYFt7nlZCSZJhBC8WrwzgwxzOFiKGGnVn2nvbfAPxy/2b89BBxBm40h6EYYghtmcxkWSrANhQ4MfQMXw3mjsGJSHD9dSveeXj3+GyOUTovuT/uNjPFn6vcJeHPupVdk1bq0PZEz4AjiqiZCvSnVU3GFAgMBAAECgYAyTZQdoAulu0qPlCF8CmotmR4ioMUHFA/wQcJsc1n7gqrGM3LikeeXqh3ut79ATPfM8ZKv3Ba3Oo0V017DY0ZG7j2stXxFhm2ln/q6nfaDsfx5ae22kIdNFCrDfwYByBiVsZPNCrj+8qDb/DPiVveEpsj7hn6thZY8QnjwEi0O3QJBAOia3cqup/rLMTYwtl43OREyMDt3qWS+aRQz1jQJlQSONV76qsZpZZUVxQEglvf6+afRCyn1mAqNa2dek6gbHTMCQQC1zijBYb6b4kghbKg/ZC37A79kBuRKtl/yIMYtFLWrtIntv047HavVPHZLEl++44Hk+9rfzNw1J12uXigGVoZnAkBGh6745jzJLxOc+uhRaS1EqZM2dPJIOfRiy9UHsmAdIYHNavSddRf4PMGfteIRD2jkGd7oui+AA6Gtll/veUlBAkAwybEwK/3NsUywA4um70hTiy7qNds/nW9j952W7W7PNDSrY2IoBQ9eusn33WdqP31VKK0Uz9HsRbMjHstY4BFTAkEAisda+CJkO/Epdj693ewIr4GbGORGSVB2pCjLGPqhuvu37d/T9+9T85BoeaMwm31aVNGOPIUCSPOMelKRUoj3Gw==";
|
||||
|
||||
// 公钥加密得到密文并使用base64处理
|
||||
String enc_msg = RSAUtil.encrypt(msg, publicKey);
|
||||
//enc_msg = "";
|
||||
//enc_msg = "TVpoZ040QTdVcTZMem11cGdTVjdWeFEvWU5kZWZqcFVBK3JSS0hGK0NsSFo0Ly82RnV3blVvT1hrMXNlZ25odXlkVytvRkdGT0xoYwpRSlhjYWI4eWJkUWh3UTgzUU1MQzBzYWtManZCOFNhR29VWDhiMHZoRXhWdkVIQ3BTcnlVRUQwUU9zQzVodXoxQTk3djRIeXNlMFR5CjFnWm1zaTRLZm9sWHM1TndJcXMvQ3lVdE9MTjNqZGZoajNHTXQvSHN1c01PVzFBekwxbTFTWVk2VGhuL3lvODA4NGRRaGN2aC9KVU0KdUdWZUNtdEJJbytHVVJaOXNCZW5BaUZVTEFWTHJINHhRK0pWUnpRRUZvRlhhNVVaRVQ0cDJTME1TWXBTZ1R4eStOeFloTER1S280OQpsV3RONFVKVkJiTVY1dUJrdEthMEF6MEROczA1bndDTnJtL3FReFdQMmFtV3pRT24ybHl6MVRCMXhjZGpaVVpDc3prOVZoM3FtS05SCmNVTnV2M0x6cDB2SzIzOVFyM205R0taWGl6TFdKcktQcE9UaDkrODQyY0s3L1ZmTDh6S2NBZG1QN21QMXhKZE5NVlEzZFZnaW9XWlQKd2xWWVc4N2FLZ0xLNlZIaEhJNTVGV3RNaFYrTzdNbUZlSzBWckhaS3NHSDJIUGg5ZUQyVkFVWVNWaU9HdisyUDRFczJ5V0lCNjkxNgpjMStRWjc5RXdwRmwrQjBJUG0yLy9rbDVXSXVYaDdXY1FPUzBBMm4yMnYxWnlMK29vdVNUTWNGMm9TNWx1RjRzRVc5VDkrb0tjRjU5CjBVOGVvY1E0R2Q5dkxwTnVpSG1CNUpvWFF6NG1EZGFBdG13eS9zMHFXVTgyQjlWZEVETlNPZGx0NnNoQXBTZ01ObGtGS0hyQllHMD0K";
|
||||
|
||||
BASE64Encoder encoder = new BASE64Encoder();
|
||||
enc_msg = encoder.encode(enc_msg.getBytes("UTF-8"));
|
||||
enc_msg = enc_msg.replaceAll("\r\n", "").replaceAll("\r", "").replaceAll("\n", "");
|
||||
|
||||
|
||||
System.out.println("公钥加密得到密文并使用base64处理:");
|
||||
//enc_msg = "TVpoZ040QTdVcTZMem11cGdTVjdWeFEvWU5kZWZqcFVBK3JSS0hGK0NsSFo0Ly82RnV3blVvT1hrMXNlZ25odXlkVytvRkdGT0xoYwpRSlhjYWI4eWJkUWh3UTgzUU1MQzBzYWtManZCOFNhR29VWDhiMHZoRXhWdkVIQ3BTcnlVRUQwUU9zQzVodXoxQTk3djRIeXNlMFR5CjFnWm1zaTRLZm9sWHM1TndJcXMvQ3lVdE9MTjNqZGZoajNHTXQvSHN1c01PVzFBekwxbTFTWVk2VGhuL3lvODA4NGRRaGN2aC9KVU0KdUdWZUNtdEJJbytHVVJaOXNCZW5BaUZVTEFWTHJINHhRK0pWUnpRRUZvRlhhNVVaRVQ0cDJTME1TWXBTZ1R4eStOeFloTER1S280OQpsV3RONFVKVkJiTVY1dUJrdEthMEF6MEROczA1bndDTnJtL3FReFdQMmFtV3pRT24ybHl6MVRCMXhjZGpaVVpDc3prOVZoM3FtS05SCmNVTnV2M0x6cDB2SzIzOVFyM205R0taWGl6TFdKcktQcE9UaDkrODQyY0s3L1ZmTDh6S2NBZG1QN21QMXhKZE5NVlEzZFZnaW9XWlQKd2xWWVc4N2FLZ0xLNlZIaEhJNTVGV3RNaFYrTzdNbUZlSzBWckhaS3NHSDJIUGg5ZUQyVkFVWVNWaU9HdisyUDRFczJ5V0lCNjkxNgpjMStRWjc5RXdwRmwrQjBJUG0yLy9rbDVXSXVYaDdXY1FPUzBBMm4yMnYxWnlMK29vdVNUTWNGMm9TNWx1RjRzRVc5VDkrb0tjRjU5CjBVOGVvY1E0R2Q5dkxwTnVpSG1CNUpvWFF6NG1EZGFBdG13eS9zMHFXVTgyQjlWZEVETlNPZGx0NnNoQXBTZ01ObGtGS0hyQllHMD0K";
|
||||
System.out.println(enc_msg);
|
||||
|
||||
//enc_msg = "UVRreXJPVm1GRlgrYldkWFNjd1pwM0dTMWxuNkJYMThUZEs1U1dLQWU2cFdkV0JoUXBFeU1nci90L1J1YWpTSks0RXo2a250cXJGK0hoclRXQ3I5Nk8vUEw4aWFKS3J5SllpUm9jTE1NMVdEcWsyakIvMWxkWXE1WGx4Qk9lenR3aTI0alV4MVV4dTBZY0ZWaUIvdGFRd0xIaWdzdE1nT1pEYnlqcnhKdUdGdkpJVG9hNkJDbGM4RXpnRWN3bzZiQnBDR3BXSCtkQk5LZE5yN0dDYnAzRTltUGRxOEh4Y01NNFBiNXdyeFBrZlpCMkl2NXpGRnNmOStUTHEzajVQT2JTa2t6dXR2VGhVS2VKN1dYZ01Vdzhvbk9rYzE2S3Q3VTg3dEFJVlpJYTY2RDdTMGd5ZWNrN01oVE5KM2tkYXNhUmVtbEQ2cys0WkNtb2NqYWVWbVpuT09yNGtsS3Z6U2VZVE5sOWNpMXRCblFBV0M0VzE5dVN0RXp6OGxFY21idHBqZHVSbUxGODNyNm83YWZ6N1dDbGMwUDEyakMxODRxZDNNUUpRQ0l0OE1OZThzNTZsNVJ1blJnRmNGNGJEb2UyTU94QUUzb05Rd3JCMldRemcxNE9mRFp2UVdlMW9JVUNMU0cyZGc1OUNUN09KdG5lZndDaEJQUGNmc2tBVE8%3D";
|
||||
//enc_msg = "T2tpcTVMeC9uVCt6VXNSRWxaT09VVGh1YlZtMkpXQXhzcWErZkxsQ0pUWktMQVZNdTFTYm1VZnN6aVVGaTNnbUE0VEx6LzJLL3pkVmpzY0pHSDlzUmJ6MFRWTUM3QkZ4ZXV5bVJZMW43bGVNOG1wRVhheGNpTEIyVzNMV0lmakh0d0o0QTRUNWtwMnhUOVprRXFhZ1RKUEZ3RUgwSmdqem9CRHpjMzZNWkxlRS9DUzBCR0RoQzdTODFweXBMaktuUWdhK0RJNUFOQUdrQnhjeHcrQWFGeUdNRmRVMWVaMU9GWUtYVjRzeUJVZnZ6dk1UN2ZmODIvLzZBa1VRMFN3a2p5TmliRjg4VkJCODJGckRCOC9TRW1CWVJnWWtRVklhWEFPZXo1aXlSR0laam1KN3Z6bXFKTDZSVzVGWTFPYms2YWJaU1FnVnZwNXoxbStHdG1KdkRYczJxeE01Unk2N0RtNlhpOGRyRERvVW83YUdzbW5Tamp5VzNUSVE0WS9iSzVyMEo5UndwcjUvTTFYMGg1T3d4MWJoRWVVTUJVZlMzV1BZTVNwMVR4WFVsRkFjTk8yQk9wZ3lvcWJYcmRFV0c2RmFIUXNxYS82ay80SmpseVpCbDd6cUUwYU9SM3lZMXY0ZG9iVHlDb0JENkNhcFp1SWs0NFlibDRaMFdTRmFlRE1lcndiZmdUcU1nWmFNL0RjWmVWN2V1akVGNytaWjNLTExZdmU2VlV3bVlJbmM2bHg2N2FwdG5UM0hic3BWei8rTnlHek1FRWRmQlpGTVFnbDhSeTBoeTlDcGRxRng2dUhrdm5wRHJrenZkUVAzWm55bkRzZHgwdlBoUW9XeEQyQWRDVi9UdVdIOTIxeG52b0NVa3U2UCtkSFJyUm9kd1BVSjBWOURiYnc9";
|
||||
// base64逆处理并用私钥解密
|
||||
BASE64Decoder decoder = new BASE64Decoder();
|
||||
enc_msg = new String(decoder.decodeBuffer(enc_msg),"UTF-8");
|
||||
String dec_msg = RSAUtil.decrypt(enc_msg, privateKey);
|
||||
|
||||
System.out.println("base64逆处理并用私钥解密:");
|
||||
System.out.println(dec_msg);
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@ -1,538 +0,0 @@
|
||||
# 建行支付对接修复报告
|
||||
|
||||
**项目**: Shopro商城建行支付集成
|
||||
**修复时间**: 2025-01-20
|
||||
**文档版本**: v2.0 (修订版)
|
||||
**建行接口版本**: v2.20 (2025-07-25)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要修订说明
|
||||
|
||||
本报告v2.0版本修正了v1.0中关于"建行平台公钥"的**严重错误理解**:
|
||||
|
||||
- ❌ **错误**: 文档中不存在"建行平台公钥"这个概念
|
||||
- ✅ **正确**: 应该是"建行生活支付验签公钥"(需联系建行生活技术支持获取)
|
||||
|
||||
---
|
||||
|
||||
## 📋 修复概览
|
||||
|
||||
本次对建行支付对接代码进行了**5项严重错误修复**和**1项性能优化**,基于建行官方Java示例代码和接口文档v2.20规范。
|
||||
|
||||
### 修复文件清单
|
||||
|
||||
| 文件路径 | 修复项 | 风险等级 |
|
||||
|---------|--------|---------|
|
||||
| `addons/shopro/library/ccblife/CcbPaymentService.php` | MAC签名算法、SIGN验签逻辑 | 🔴 致命 |
|
||||
| `addons/shopro/library/ccblife/CcbEncryption.php` | ENCPUB生成、RSA分段加密 | 🔴 致命 |
|
||||
| `addons/shopro/controller/Ccbpayment.php` | 防重复支付、notify返回格式 | 🟡 严重 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 致命错误修复
|
||||
|
||||
### 1. 支付串MAC签名算法错误
|
||||
|
||||
**位置**: `CcbPaymentService.php:148-153`
|
||||
|
||||
#### 修复前 ❌
|
||||
```php
|
||||
// 错误: 使用私钥签名
|
||||
$mac = md5($signString . $this->config['private_key']);
|
||||
```
|
||||
|
||||
#### 修复后 ✅
|
||||
```php
|
||||
// 正确: 使用服务方公钥参与MD5计算(建行v2.2规范)
|
||||
$platformPubKey = $this->config['public_key']; // 服务方公钥
|
||||
$mac = strtoupper(md5($signString . '&PLATFORMPUB=' . $platformPubKey));
|
||||
```
|
||||
|
||||
#### 技术说明
|
||||
根据建行文档v2.2版本和官方MD5Util.java示例:
|
||||
- **PLATFORMPUB字段**: 仅参与MD5摘要计算,不作为HTTP参数传递
|
||||
- **签名格式**: `MD5(参数串 + &PLATFORMPUB= + 服务方公钥内容)`
|
||||
- **输出格式**: 32位**大写**MD5字符串 (对照MD5Util.java第30行: `toUpperCase()`)
|
||||
|
||||
**影响**: 修复前建行会拒绝所有支付请求,因签名验证100%失败。
|
||||
|
||||
---
|
||||
|
||||
### 2. ENCPUB字段生成逻辑错误
|
||||
|
||||
**位置**: `CcbEncryption.php:387-420`
|
||||
|
||||
#### 修复前 ❌
|
||||
```php
|
||||
// 错误: 加密整个商户公钥
|
||||
return $this->rsaEncrypt($this->publicKey);
|
||||
```
|
||||
|
||||
#### 修复后 ✅
|
||||
```php
|
||||
// 正确: 只加密商户公钥后30位
|
||||
$publicKeyContent = str_replace([
|
||||
'-----BEGIN PUBLIC KEY-----',
|
||||
'-----END PUBLIC KEY-----',
|
||||
"\r", "\n", " "
|
||||
], '', $this->publicKey);
|
||||
|
||||
$last30Chars = substr($publicKeyContent, -30);
|
||||
return $this->rsaEncrypt($last30Chars);
|
||||
```
|
||||
|
||||
#### 技术说明
|
||||
建行文档明确要求:
|
||||
> "使用服务方公钥对**商户公钥后30位**进行RSA加密并base64后的密文"
|
||||
|
||||
**影响**: 修复前ENCPUB字段内容错误,可能导致建行无法验证商户公钥。
|
||||
|
||||
---
|
||||
|
||||
### 3. 异步通知SIGN验签逻辑优化
|
||||
|
||||
**位置**: `CcbPaymentService.php:467-570`
|
||||
|
||||
#### 修复前 ❌
|
||||
```php
|
||||
// 错误: 使用MD5验签
|
||||
$expectedSign = md5($signStr . $this->config['private_key']);
|
||||
return strtolower($signature) === strtolower($expectedSign);
|
||||
```
|
||||
|
||||
#### 修复后 ✅
|
||||
```php
|
||||
// 智能验签方案: 如果配置了验签公钥则使用RSA,否则降级为POSID验证
|
||||
$ccbVerifyPublicKey = $this->config['ccb_payment_verify_public_key'] ?? '';
|
||||
|
||||
if (empty($ccbVerifyPublicKey)) {
|
||||
// 降级方案: POSID验证
|
||||
return ($params['POSID'] ?? '') === $this->config['pos_id'];
|
||||
}
|
||||
|
||||
// 完整方案: RSA验签(尝试SHA256和SHA1)
|
||||
$signBinary = hex2bin($params['SIGN']);
|
||||
$pubKey = openssl_pkey_get_public($ccbVerifyPublicKey);
|
||||
|
||||
$result = openssl_verify($signStr, $signBinary, $pubKey, OPENSSL_ALGO_SHA256);
|
||||
if ($result !== 1) {
|
||||
$result = openssl_verify($signStr, $signBinary, $pubKey, OPENSSL_ALGO_SHA1);
|
||||
}
|
||||
|
||||
return $result === 1;
|
||||
```
|
||||
|
||||
#### 技术说明
|
||||
根据建行文档7.2.3章节:
|
||||
- **SIGN字段**: 256个十六进制字符 (2048位RSA签名)
|
||||
- **验签密钥**: "建行生活分配的服务商支付验签公钥" (NT_TYPE=YS时)
|
||||
- **验签算法**: RSA-SHA256或SHA1 (文档未明确,代码会自动尝试)
|
||||
- **获取方式**: 联系建行生活平台技术支持
|
||||
|
||||
#### 降级方案说明
|
||||
由于建行未提供验签公钥和示例代码,代码实现了两级验证:
|
||||
|
||||
1. **优先**: 如果配置了`ccb_payment_verify_public_key`,使用RSA验签
|
||||
2. **降级**: 如果未配置,验证POSID和订单号是否匹配
|
||||
|
||||
**建议**: 尽快联系建行技术支持获取验签公钥,补全配置后获得完整安全保障。
|
||||
|
||||
**影响**: 修复后验签逻辑更健壮,未配置公钥时也能正常运行(安全性降低但不会中断业务)。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 严重问题修复
|
||||
|
||||
### 4. 订单状态更新缺少防重复逻辑
|
||||
|
||||
**位置**: `Ccbpayment.php:170-197`
|
||||
|
||||
#### 修复前 ❌
|
||||
```php
|
||||
// 直接更新,没有并发控制
|
||||
$order->status = 'paid';
|
||||
$order->save();
|
||||
```
|
||||
|
||||
#### 修复后 ✅
|
||||
```php
|
||||
// 使用原子性更新,防止并发重复支付
|
||||
$affectedRows = Db::name('shopro_order')
|
||||
->where('id', $order->id)
|
||||
->where('status', 'unpaid') // 只更新未支付的订单
|
||||
->update([
|
||||
'status' => 'paid',
|
||||
'paid_time' => time() * 1000,
|
||||
'updatetime' => time()
|
||||
]);
|
||||
|
||||
if ($affectedRows === 0) {
|
||||
// 订单已支付或状态异常
|
||||
throw new Exception('订单状态异常,无法更新为已支付');
|
||||
}
|
||||
```
|
||||
|
||||
**影响**: 修复前在高并发场景下可能出现重复支付或状态覆盖。
|
||||
|
||||
---
|
||||
|
||||
### 5. notify接口返回格式不规范
|
||||
|
||||
**位置**: `Ccbpayment.php:271-283`
|
||||
|
||||
#### 修复前 ❌
|
||||
```php
|
||||
// ThinkPHP框架会追加额外内容
|
||||
echo $result; // 'SUCCESS' 或 'FAIL'
|
||||
```
|
||||
|
||||
#### 修复后 ✅
|
||||
```php
|
||||
// 直接exit,确保只返回纯文本
|
||||
exit(strtoupper($result)); // 'SUCCESS' 或 'FAIL'
|
||||
```
|
||||
|
||||
#### 技术说明
|
||||
建行要求异步通知响应:
|
||||
- **HTTP 200** 状态码
|
||||
- **纯文本** 响应体: `SUCCESS` 或 `FAIL`
|
||||
- **不允许**任何额外字符(HTML/JSON等)
|
||||
|
||||
**影响**: 修复前ThinkPHP框架可能追加调试信息,导致建行认为通知失败并重复推送。
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 性能优化
|
||||
|
||||
### 6. RSA加密分段大小动态计算
|
||||
|
||||
**位置**: `CcbEncryption.php:102-129`
|
||||
|
||||
#### 优化前 ⚠️
|
||||
```php
|
||||
// 写死1024位RSA的chunk size
|
||||
$chunkSize = 117; // 1024位RSA密钥,每次最多加密117字节
|
||||
```
|
||||
|
||||
#### 优化后 ✅
|
||||
```php
|
||||
// 动态获取RSA密钥大小
|
||||
$keyDetails = openssl_pkey_get_details($pubKeyId);
|
||||
$keySize = $keyDetails['bits'] / 8; // 1024位=128字节, 2048位=256字节
|
||||
$chunkSize = $keySize - 11; // PKCS1填充需要预留11字节
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- 自动适配1024位/2048位/4096位RSA密钥
|
||||
- 减少不必要的分段次数,提升加密性能
|
||||
- 避免密钥升级后的兼容性问题
|
||||
|
||||
---
|
||||
|
||||
## 🔐 建行接口签名规则总结
|
||||
|
||||
### 支付串生成流程
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[34个参数] --> B[按ASCII排序ksort]
|
||||
B --> C[http_build_query拼接]
|
||||
C --> D[追加&PLATFORMPUB=服务方公钥]
|
||||
D --> E[MD5签名,32位小写]
|
||||
E --> F[ENCPUB=RSA加密商户公钥后30位]
|
||||
F --> G[最终支付串=参数+MAC+PLATFORMID+ENCPUB]
|
||||
```
|
||||
|
||||
### 异步通知验签流程
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[接收SIGN字段] --> B[hex2bin转二进制]
|
||||
B --> C[移除SIGN,剩余参数ksort排序]
|
||||
C --> D[拼接签名原串]
|
||||
D --> E[使用建行公钥RSA-SHA256验签]
|
||||
E --> F{验签结果}
|
||||
F -->|成功| G[返回SUCCESS]
|
||||
F -->|失败| H[返回FAIL]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证检查清单
|
||||
|
||||
修复完成后,请逐项检查以下配置:
|
||||
|
||||
### 1. 配置文件检查
|
||||
|
||||
**文件**: `addons/shopro/config/ccblife.php`
|
||||
|
||||
```php
|
||||
return [
|
||||
// 建行商户信息
|
||||
'merchant_id' => 'YOUR_MERCHANT_ID',
|
||||
'pos_id' => 'YOUR_POS_ID',
|
||||
'branch_id' => 'YOUR_BRANCH_ID',
|
||||
'service_id' => 'YOUR_SERVICE_ID',
|
||||
|
||||
// ✅ 服务方公钥(用于MAC签名)
|
||||
'public_key' => '-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
||||
-----END PUBLIC KEY-----',
|
||||
|
||||
// ✅ 服务方私钥(用于解密)
|
||||
'private_key' => '-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...
|
||||
-----END PRIVATE KEY-----',
|
||||
|
||||
// ✅ 建行平台公钥(用于SIGN验签) - 新增必填!
|
||||
'platform_public_key' => '-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
||||
-----END PUBLIC KEY-----',
|
||||
|
||||
// 建行收银台URL
|
||||
'cashier_url' => 'https://ibsbjstar.ccb.com.cn/CCBIS/ccbMain',
|
||||
];
|
||||
```
|
||||
|
||||
### 2. 密钥格式验证
|
||||
|
||||
运行以下PHP脚本验证密钥格式:
|
||||
|
||||
```php
|
||||
<?php
|
||||
$config = include 'addons/shopro/config/ccblife.php';
|
||||
|
||||
// 验证服务方公钥
|
||||
$pubKey = openssl_pkey_get_public($config['public_key']);
|
||||
if ($pubKey) {
|
||||
$details = openssl_pkey_get_details($pubKey);
|
||||
echo "✅ 服务方公钥: {$details['bits']}位\n";
|
||||
} else {
|
||||
echo "❌ 服务方公钥格式错误\n";
|
||||
}
|
||||
|
||||
// 验证服务方私钥
|
||||
$privKey = openssl_pkey_get_private($config['private_key']);
|
||||
if ($privKey) {
|
||||
$details = openssl_pkey_get_details($privKey);
|
||||
echo "✅ 服务方私钥: {$details['bits']}位\n";
|
||||
} else {
|
||||
echo "❌ 服务方私钥格式错误\n";
|
||||
}
|
||||
|
||||
// 验证建行平台公钥
|
||||
$ccbPubKey = openssl_pkey_get_public($config['platform_public_key']);
|
||||
if ($ccbPubKey) {
|
||||
$details = openssl_pkey_get_details($ccbPubKey);
|
||||
echo "✅ 建行平台公钥: {$details['bits']}位\n";
|
||||
} else {
|
||||
echo "❌ 建行平台公钥格式错误或未配置\n";
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 数据库字段检查
|
||||
|
||||
确保订单表包含建行相关字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE `fa_shopro_order`
|
||||
ADD COLUMN `ccb_pay_flow_id` VARCHAR(64) DEFAULT '' COMMENT '建行支付流水号',
|
||||
ADD COLUMN `ccb_sync_status` TINYINT(1) DEFAULT 0 COMMENT '建行同步状态:0-未同步 1-已同步 2-失败',
|
||||
ADD COLUMN `ccb_sync_time` INT(10) DEFAULT 0 COMMENT '建行同步时间';
|
||||
```
|
||||
|
||||
### 4. 支付日志表检查
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `fa_ccb_payment_log` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`order_id` INT(11) NOT NULL COMMENT '订单ID',
|
||||
`order_sn` VARCHAR(50) NOT NULL COMMENT '订单号',
|
||||
`pay_flow_id` VARCHAR(64) DEFAULT '' COMMENT '支付流水号',
|
||||
`payment_string` TEXT COMMENT '支付串',
|
||||
`user_id` INT(11) DEFAULT 0 COMMENT '用户ID',
|
||||
`ccb_user_id` VARCHAR(50) DEFAULT '' COMMENT '建行用户ID',
|
||||
`amount` DECIMAL(10,2) DEFAULT 0.00 COMMENT '支付金额',
|
||||
`status` TINYINT(1) DEFAULT 0 COMMENT '状态:0-待支付 1-已支付',
|
||||
`pay_time` INT(10) DEFAULT 0 COMMENT '支付时间',
|
||||
`trans_id` VARCHAR(64) DEFAULT '' COMMENT '建行交易流水号',
|
||||
`callback_data` TEXT COMMENT '回调数据',
|
||||
`create_time` INT(10) NOT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order_id` (`order_id`),
|
||||
KEY `idx_pay_flow_id` (`pay_flow_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='建行支付日志表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试建议
|
||||
|
||||
### 测试环境准备
|
||||
|
||||
1. **配置建行测试环境**:
|
||||
- 使用建行提供的测试商户号
|
||||
- 配置测试环境的收银台URL
|
||||
- 确保获取测试环境的平台公钥
|
||||
|
||||
2. **测试用例**:
|
||||
|
||||
#### TC1: 支付串生成测试
|
||||
```php
|
||||
$service = new CcbPaymentService();
|
||||
$result = $service->generatePaymentString($orderId);
|
||||
|
||||
// 验证点:
|
||||
// 1. MAC长度为32位
|
||||
// 2. ENCPUB字段存在且不为空
|
||||
// 3. 支付串包含所有34个必需参数
|
||||
```
|
||||
|
||||
#### TC2: 异步通知验签测试
|
||||
```php
|
||||
// 模拟建行回调数据
|
||||
$params = [
|
||||
'ORDERID' => 'test123',
|
||||
'PAYMENT' => '100.00',
|
||||
'SUCCESS' => 'Y',
|
||||
'SIGN' => '256字符十六进制字符串...'
|
||||
];
|
||||
|
||||
$result = $service->handleNotify($params);
|
||||
// 预期: 返回'success'或'fail'
|
||||
```
|
||||
|
||||
#### TC3: 并发支付测试
|
||||
使用Apache Bench进行并发测试:
|
||||
```bash
|
||||
ab -n 100 -c 10 http://your-domain/api/ccbpayment/callback
|
||||
```
|
||||
验证订单状态不会重复更新。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 上线前必读
|
||||
|
||||
### 1. 建行生活支付验签公钥获取(重要!)
|
||||
|
||||
**关键**: 需要向建行生活技术支持索要**"建行生活支付验签公钥"**,用于异步通知SIGN验签。
|
||||
|
||||
#### 为什么需要这个公钥?
|
||||
|
||||
- 建行用自己的私钥对异步通知进行RSA签名(生成SIGN字段)
|
||||
- 你需要用建行的公钥来验证SIGN,确保通知是建行发送的
|
||||
- 这个公钥**不是**你自己生成的公钥,是建行生活平台分配给你的
|
||||
|
||||
#### 如何获取?
|
||||
|
||||
1. 联系建行生活平台运营人员或技术支持
|
||||
2. 说明需要获取"建行生活支付验签公钥"(NT_TYPE=YS的验签公钥)
|
||||
3. 提供你的商户号和服务方编号
|
||||
4. 获取后配置到`.env`文件中
|
||||
|
||||
```ini
|
||||
# .env文件
|
||||
ccb_payment_verify_public_key="-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
||||
-----END PUBLIC KEY-----"
|
||||
```
|
||||
|
||||
#### 未配置的影响
|
||||
|
||||
- 异步通知验签会降级为POSID验证
|
||||
- 安全性降低,无法完全确认通知来源
|
||||
- 但不会中断业务,系统仍可正常运行
|
||||
|
||||
### 2. 验证密钥格式
|
||||
|
||||
运行以下PHP脚本验证密钥配置是否正确:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// test_ccb_keys.php
|
||||
$config = include 'addons/shopro/config/ccblife.php';
|
||||
|
||||
echo "========== 建行密钥配置验证 ==========\n\n";
|
||||
|
||||
// 1. 验证服务方公钥
|
||||
$pubKey = openssl_pkey_get_public($config['public_key']);
|
||||
if ($pubKey) {
|
||||
$details = openssl_pkey_get_details($pubKey);
|
||||
echo "✅ 服务方公钥: {$details['bits']}位 RSA\n";
|
||||
} else {
|
||||
echo "❌ 服务方公钥格式错误: " . openssl_error_string() . "\n";
|
||||
}
|
||||
|
||||
// 2. 验证服务方私钥
|
||||
$privKey = openssl_pkey_get_private($config['private_key']);
|
||||
if ($privKey) {
|
||||
$details = openssl_pkey_get_details($privKey);
|
||||
echo "✅ 服务方私钥: {$details['bits']}位 RSA\n";
|
||||
} else {
|
||||
echo "❌ 服务方私钥格式错误: " . openssl_error_string() . "\n";
|
||||
}
|
||||
|
||||
// 3. 验证建行支付验签公钥(可选)
|
||||
if (!empty($config['ccb_payment_verify_public_key'])) {
|
||||
$ccbPubKey = openssl_pkey_get_public($config['ccb_payment_verify_public_key']);
|
||||
if ($ccbPubKey) {
|
||||
$details = openssl_pkey_get_details($ccbPubKey);
|
||||
echo "✅ 建行验签公钥: {$details['bits']}位 RSA\n";
|
||||
} else {
|
||||
echo "❌ 建行验签公钥格式错误: " . openssl_error_string() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo "⚠️ 建行验签公钥未配置(验签会降级为POSID验证)\n";
|
||||
}
|
||||
|
||||
echo "\n========== 验证完成 ==========\n";
|
||||
```
|
||||
|
||||
### 3. 日志监控
|
||||
|
||||
修复后的代码已增强日志记录,请监控以下关键日志:
|
||||
|
||||
```bash
|
||||
# 查看MAC签名日志
|
||||
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行支付'
|
||||
|
||||
# 查看验签日志
|
||||
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行验签'
|
||||
|
||||
# 查看异步通知日志
|
||||
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行通知'
|
||||
```
|
||||
|
||||
### 4. 回滚方案
|
||||
|
||||
如遇紧急问题,可回滚至修复前版本:
|
||||
|
||||
```bash
|
||||
git checkout HEAD~1 addons/shopro/library/ccblife/
|
||||
git checkout HEAD~1 addons/shopro/controller/Ccbpayment.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
**开发者**: Billy
|
||||
**修复日期**: 2025-01-20
|
||||
**建行文档版本**: v2.20 (2025-07-25)
|
||||
|
||||
如有疑问,请查阅:
|
||||
- 建行接入文档: `/doc/建行相关App服务方接入文档v2.20_20250725.html`
|
||||
- 本修复报告: `/doc/建行支付对接修复报告.md`
|
||||
|
||||
---
|
||||
|
||||
## 📝 变更历史
|
||||
|
||||
| 版本 | 日期 | 修改内容 |
|
||||
|-----|------|---------|
|
||||
| v1.0 | 2025-01-20 | 初始版本,完成6项严重错误修复 |
|
||||
|
||||
---
|
||||
|
||||
**修复完成,已做好生产环境部署准备!** ✅
|
||||
@ -1,825 +0,0 @@
|
||||
# 建行支付架构修复报告
|
||||
|
||||
**项目**: Shopro商城建行支付集成
|
||||
**修复时间**: 2025-01-20
|
||||
**文档版本**: v2.0 (根据官方流程图修正)
|
||||
**修复类型**: 🔴 严重安全漏洞 + 架构偏离 + 订单同步时机错误
|
||||
**建行接口版本**: v2.20 (2025-07-25)
|
||||
**参考文档**: 建行生活服务方接入流程图
|
||||
|
||||
---
|
||||
|
||||
## 📋 问题概述
|
||||
|
||||
通过对比建行官方支付流程图和当前实现,发现**严重的架构偏离和安全漏洞**:
|
||||
|
||||
### 核心问题
|
||||
|
||||
1. **前端callback机制违反建行标准流程** 🔴 致命
|
||||
2. **前端可伪造支付成功请求** 🔴 安全漏洞
|
||||
3. **双通道更新订单状态导致竞态条件** 🔴 致命
|
||||
4. **订单推送时机错误** 🔴 致命 - 应该在创建订单时推送,而不是支付成功后
|
||||
5. **订单更新时机错误** 🔴 致命 - 支付成功后应调用更新接口,而不是推送接口
|
||||
6. **缺少轮询查询机制** 🟡 严重
|
||||
|
||||
---
|
||||
|
||||
## 🔴 建行标准支付流程 vs 错误实现
|
||||
|
||||
### 建行标准流程(官方文档 - 根据流程图修正)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant H5 as 前端H5页面
|
||||
participant Backend as 服务方后台
|
||||
participant CCBApp as 建行APP
|
||||
participant CCBBackend as 建行后端
|
||||
participant Merchant as 商户管理(外联)
|
||||
|
||||
Note over H5,Backend: 步骤1: 请求下单
|
||||
H5->>Backend: POST /createPayment(生成支付串)
|
||||
Backend->>Backend: 保存订单(未支付状态)
|
||||
|
||||
Note over Backend,CCBBackend: 步骤2: 推送订单 ✅ 推送未支付订单!
|
||||
Backend->>Merchant: 调用订单推送接口(A3341TP01)
|
||||
Merchant-->>Backend: 返回推送结果
|
||||
Backend-->>H5: {payment_string}
|
||||
|
||||
Note over H5,CCBApp: 步骤3: 调起建行收银台
|
||||
H5->>CCBApp: JSBridge.ccbpay(支付串)
|
||||
activate CCBBackend
|
||||
CCBApp->>CCBBackend: 校验登录
|
||||
CCBApp->>CCBApp: 调用支付组件
|
||||
H5->>CCBApp: 确认支付、输入密码
|
||||
CCBApp->>CCBBackend: 发送支付请求
|
||||
Note right of CCBBackend: 用户在建行APP中完成支付
|
||||
|
||||
Note over CCBBackend,Backend: 步骤10-12: 建行异步通知
|
||||
CCBBackend->>Merchant: 返回支付成功通知
|
||||
Merchant->>Backend: 推送服务器通知(notify)
|
||||
Backend->>Backend: 验证SIGN签名
|
||||
Backend->>Backend: 原子更新本地订单状态为paid
|
||||
deactivate CCBBackend
|
||||
|
||||
Note over Backend,Merchant: 步骤13: 更新订单状态 ✅ 更新为已支付!
|
||||
Backend->>Merchant: 调用订单更新接口(A3341TP02)
|
||||
Merchant-->>Backend: 返回更新结果
|
||||
Backend-->>Merchant: 返回SUCCESS
|
||||
|
||||
Note over H5,Backend: 步骤15-16: 前端轮询查询状态 (未收到通知时)
|
||||
loop 每2秒轮询(最多60秒)
|
||||
H5->>Backend: GET /queryPaymentStatus
|
||||
Backend-->>H5: {status: 'paid'或'unpaid'}
|
||||
alt status == 'paid'
|
||||
H5->>H5: 跳转到支付成功页
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**关键流程说明**:
|
||||
1. **步骤2**: 生成支付串后**立即推送未支付订单**到建行外联系统(A3341TP01)
|
||||
2. **步骤13**: 收到支付成功通知后**更新订单状态为已支付**(A3341TP02)
|
||||
3. **步骤15**: 前端轮询查询订单状态(用于未收到通知的降级方案)
|
||||
|
||||
### 修复前的错误实现
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant H5 as 前端H5页面
|
||||
participant Callback as callback接口
|
||||
participant Notify as notify接口
|
||||
participant CCBApp as 建行APP(黑盒)
|
||||
|
||||
Note over H5,Callback: ❌ 错误1: 前端callback通知支付成功
|
||||
H5->>Callback: POST callback(order_id, trans_id) ❌ 可伪造!
|
||||
Callback->>Callback: verifyPayment()主动查询建行?
|
||||
Callback->>Callback: ❌ 更新订单为已支付
|
||||
Callback->>Callback: ❌ 推送订单到外联
|
||||
Callback-->>H5: 返回success
|
||||
|
||||
Note over CCBApp,Notify: ❌ 错误2: 建行异步通知被边缘化
|
||||
CCBApp-->>Notify: POST notify(ORDERID, SIGN等)
|
||||
Notify->>Notify: ❌ 再次更新订单?
|
||||
Notify->>Notify: ❌ 再次推送订单?
|
||||
Notify-->>CCBApp: 返回SUCCESS
|
||||
|
||||
Note over H5,Callback: ⚠️ 严重问题: 两条并行路径!
|
||||
rect rgb(255, 200, 200)
|
||||
Note right of Callback: 路径A: 前端callback触发更新<br/>路径B: 建行notify触发更新<br/>可能导致重复处理或竞态条件!
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔥 严重安全漏洞详解
|
||||
|
||||
### 漏洞1: 前端callback可伪造支付成功
|
||||
|
||||
**问题代码** (`frontend/sheep/platform/pay.js:325-336`):
|
||||
|
||||
```javascript
|
||||
// ❌ 错误: 前端主动调用callback通知后端支付成功
|
||||
if (result.code === 0) {
|
||||
// 支付成功,通知后端 ❌ 严重安全漏洞!
|
||||
const callbackResult = await ccbApi.paymentCallback({
|
||||
order_id: orderInfo.data.id,
|
||||
trans_id: result.data?.trans_id || '', // ❌ 前端可伪造
|
||||
pay_time: new Date().getTime() // ❌ 前端可伪造
|
||||
});
|
||||
|
||||
if (callbackResult.code === 1) {
|
||||
that.payResult('success'); // ❌ 跳转到成功页
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**攻击方式**:
|
||||
|
||||
1. 用户在浏览器控制台执行:
|
||||
```javascript
|
||||
ccbApi.paymentCallback({
|
||||
order_id: 12345,
|
||||
trans_id: 'fake_trans_id',
|
||||
pay_time: Date.now()
|
||||
})
|
||||
```
|
||||
|
||||
2. 后端callback()接口收到请求,**没有验证签名**,直接更新订单为已支付!
|
||||
|
||||
3. 攻击者不花一分钱就能白嫖商品 🔥
|
||||
|
||||
**风险等级**: 🔴 **致命** - 可直接导致商户资金损失
|
||||
|
||||
---
|
||||
|
||||
### 漏洞2: callback()与notify()双通道竞态条件
|
||||
|
||||
**问题代码** (`addons/shopro/controller/Ccbpayment.php`):
|
||||
|
||||
```php
|
||||
// ❌ callback()中更新订单状态
|
||||
public function callback()
|
||||
{
|
||||
// ...省略代码...
|
||||
|
||||
// 更新订单状态
|
||||
$affectedRows = Db::name('shopro_order')
|
||||
->where('id', $order->id)
|
||||
->where('status', 'unpaid')
|
||||
->update(['status' => 'paid']);
|
||||
|
||||
// 推送订单到建行
|
||||
$this->pushOrderToCcb($order); // ❌ 在callback中推送
|
||||
}
|
||||
|
||||
// ❌ notify()中也更新订单状态
|
||||
public function notify()
|
||||
{
|
||||
// ...省略代码...
|
||||
|
||||
$this->paymentService->handleNotify($params); // ❌ 内部再次更新订单
|
||||
|
||||
// handleNotify()内部还会调用pushOrder() ❌ 重复推送!
|
||||
}
|
||||
```
|
||||
|
||||
**竞态条件场景**:
|
||||
|
||||
1. 用户在建行APP完成支付
|
||||
2. 建行异步通知服务器 → 触发`notify()` → 更新订单为paid
|
||||
3. **同时**前端H5页面返回 → 调用`callback()` → 再次尝试更新订单
|
||||
4. 如果notify还未完成,callback会成功更新 → **订单被更新两次**
|
||||
5. pushOrderToCcb()被调用两次 → **外联系统收到重复订单**
|
||||
|
||||
**风险等级**: 🔴 **致命** - 可能导致订单状态异常或重复扣款
|
||||
|
||||
---
|
||||
|
||||
### 漏洞3: 订单推送时机错误
|
||||
|
||||
**问题**: callback()在前端触发时就推送订单,但此时:
|
||||
- 建行可能还未真正扣款
|
||||
- callback可能是攻击者伪造的请求
|
||||
- 订单状态可能还未真正更新为paid
|
||||
|
||||
**正确时机**: 只在`notify()`收到建行异步通知并验签成功后推送!
|
||||
|
||||
---
|
||||
|
||||
## ✅ 修复方案
|
||||
|
||||
### 修复1: callback()改造为纯查询接口
|
||||
|
||||
**修复后** (`addons/shopro/controller/Ccbpayment.php:129-164`):
|
||||
|
||||
```php
|
||||
/**
|
||||
* 查询订单支付状态 (前端轮询用)
|
||||
*
|
||||
* ⚠️ 重要: 本接口只查询订单状态,不执行任何业务逻辑!
|
||||
* 订单状态由建行异步通知(notify)接口更新,前端只负责轮询查询。
|
||||
*/
|
||||
public function queryPaymentStatus()
|
||||
{
|
||||
try {
|
||||
$orderId = $this->request->get('order_id', 0);
|
||||
|
||||
if (empty($orderId)) {
|
||||
$this->error('订单ID不能为空');
|
||||
}
|
||||
|
||||
// ✅ 只查询,不更新!
|
||||
$order = OrderModel::where('id', $orderId)
|
||||
->where('user_id', $this->auth->id)
|
||||
->field('id, order_sn, status, paid_time, ccb_pay_flow_id')
|
||||
->find();
|
||||
|
||||
if (!$order) {
|
||||
$this->error('订单不存在');
|
||||
}
|
||||
|
||||
// ✅ 返回订单状态(只读操作,绝不修改数据!)
|
||||
$this->success('查询成功', [
|
||||
'order_id' => $order->id,
|
||||
'order_sn' => $order->order_sn,
|
||||
'status' => $order->status,
|
||||
'is_paid' => in_array($order->status, ['paid', 'completed', 'success']),
|
||||
'paid_time' => $order->paid_time,
|
||||
'pay_flow_id' => $order->ccb_pay_flow_id,
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('[建行支付] 查询订单状态失败 error:' . $e->getMessage());
|
||||
$this->error('查询失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 已废弃: 支付回调 (前端调用)
|
||||
* @deprecated 2025-01-20 该方法存在严重安全漏洞,已被queryPaymentStatus()替代
|
||||
*/
|
||||
public function callback()
|
||||
{
|
||||
// 向后兼容:直接调用查询接口
|
||||
Log::warning('[建行支付] callback()已废弃,请前端改用queryPaymentStatus()接口');
|
||||
|
||||
$_GET['order_id'] = $this->request->post('order_id', 0);
|
||||
return $this->queryPaymentStatus();
|
||||
}
|
||||
```
|
||||
|
||||
**修复要点**:
|
||||
- ✅ 只查询,不更新任何数据
|
||||
- ✅ 只返回订单状态,前端根据状态判断是否跳转
|
||||
- ✅ 支持用户权限验证(`where('user_id', $this->auth->id)`)
|
||||
- ✅ 向后兼容旧版callback接口
|
||||
|
||||
---
|
||||
|
||||
### 修复2: notify()成为唯一的支付确认通道
|
||||
|
||||
**修复后** (`addons/shopro/controller/Ccbpayment.php:200-258`):
|
||||
|
||||
```php
|
||||
/**
|
||||
* 建行支付通知 (建行服务器回调)
|
||||
*
|
||||
* ✅ 正确流程:
|
||||
* 1. 验证签名
|
||||
* 2. 更新订单状态(由handleNotify()完成)
|
||||
* 3. 推送订单到建行外联系统(本方法完成)
|
||||
* 4. 返回SUCCESS给建行
|
||||
*/
|
||||
public function notify()
|
||||
{
|
||||
try {
|
||||
// 1-5. 解析和验证参数
|
||||
$rawData = file_get_contents('php://input');
|
||||
Log::info('[建行通知] 收到异步通知: ' . $rawData);
|
||||
|
||||
$params = $this->request->post();
|
||||
if (empty($params) && $rawData) {
|
||||
parse_str($rawData, $params);
|
||||
}
|
||||
|
||||
if (empty($params['ORDERID'])) {
|
||||
Log::error('[建行通知] 缺少ORDERID参数');
|
||||
exit('FAIL');
|
||||
}
|
||||
|
||||
// 6. 调用服务层处理通知(返回订单ID)
|
||||
$result = $this->paymentService->handleNotify($params);
|
||||
|
||||
// 7. ✅ 处理成功后推送订单到建行外联系统
|
||||
if ($result['status'] === 'success' && !empty($result['order_id'])) {
|
||||
// ⚠️ 只有新支付才推送,已支付的订单跳过推送
|
||||
if ($result['already_paid'] === false) {
|
||||
try {
|
||||
$this->pushOrderToCcb($result['order_id']);
|
||||
Log::info('[建行通知] 订单推送成功 order_id:' . $result['order_id']);
|
||||
} catch (Exception $e) {
|
||||
// ⚠️ 推送失败不影响支付成功,记录日志后续补推
|
||||
Log::error('[建行通知] 订单推送失败 order_id:' . $result['order_id'] . ' error:' . $e->getMessage());
|
||||
}
|
||||
} else {
|
||||
Log::info('[建行通知] 订单已支付且已推送,跳过推送');
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 返回处理结果
|
||||
$response = ($result['status'] === 'success') ? 'SUCCESS' : 'FAIL';
|
||||
Log::info('[建行通知] 处理完成,返回: ' . $response);
|
||||
|
||||
exit($response); // 直接退出,确保只输出SUCCESS/FAIL
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('[建行通知] 处理失败 error:' . $e->getMessage());
|
||||
exit('FAIL');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修复要点**:
|
||||
- ✅ 验证SIGN签名(由handleNotify()完成)
|
||||
- ✅ 原子更新订单状态(由handleNotify()完成)
|
||||
- ✅ 推送订单到外联系统(本方法完成,在订单状态更新成功后)
|
||||
- ✅ 幂等性保护(已支付的订单跳过推送)
|
||||
- ✅ 推送失败不影响支付成功(记录日志后续补推)
|
||||
|
||||
---
|
||||
|
||||
### 修复3: createPayment()推送未支付订单
|
||||
|
||||
**修复后** (`addons/shopro/controller/Ccbpayment.php:101-118`):
|
||||
|
||||
```php
|
||||
// 5. ✅ 推送订单到建行(步骤2:调用订单推送接口将已提交订单)
|
||||
// ⚠️ 注意:此时推送的是未支付状态的订单
|
||||
try {
|
||||
$pushResult = $this->orderService->pushOrder($orderId);
|
||||
|
||||
if ($pushResult['status']) {
|
||||
Log::info('[建行支付] 订单推送成功 order_id:' . $orderId);
|
||||
} else {
|
||||
// ⚠️ 推送失败不阻塞支付流程,只记录日志
|
||||
Log::warning('[建行支付] 订单推送失败(不阻塞支付) order_id:' . $orderId . ' error:' . $pushResult['message']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// ⚠️ 推送异常不阻塞支付流程
|
||||
Log::error('[建行支付] 订单推送异常(不阻塞支付) order_id:' . $orderId . ' error:' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 6. 返回支付串
|
||||
$this->success('支付串生成成功', $result['data']);
|
||||
```
|
||||
|
||||
**修复要点**:
|
||||
- ✅ 生成支付串后立即调用`pushOrder()`推送未支付订单(A3341TP01)
|
||||
- ✅ 推送失败不阻塞支付流程,用户仍可继续支付
|
||||
- ✅ 记录推送结果日志,失败的可后续补推
|
||||
|
||||
---
|
||||
|
||||
### 修复4: notify()更新订单状态为已支付
|
||||
|
||||
**修复后** (`addons/shopro/controller/Ccbpayment.php:227-248`):
|
||||
|
||||
```php
|
||||
// 7. ✅ 处理成功后更新订单状态到建行(步骤13:调用订单更新接口更新订单状态)
|
||||
if ($result['status'] === 'success' && !empty($result['order_id'])) {
|
||||
// ⚠️ 只有新支付才更新,已支付的订单跳过更新
|
||||
if ($result['already_paid'] === false) {
|
||||
try {
|
||||
// 调用订单更新接口,将订单状态从未支付更新为已支付
|
||||
$updateResult = $this->orderService->updateOrderStatus($result['order_id']);
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修复要点**:
|
||||
- ✅ 收到支付成功通知后调用`updateOrderStatus()`(A3341TP02)
|
||||
- ✅ 将订单状态从"未支付"更新为"已支付"
|
||||
- ✅ 更新失败不影响本地支付状态(本地订单已标记为paid)
|
||||
- ✅ 幂等性保护(已支付的订单跳过更新)
|
||||
|
||||
---
|
||||
|
||||
### 修复5: handleNotify()返回订单ID
|
||||
|
||||
**修复后** (`addons/shopro/library/ccblife/CcbPaymentService.php:349-403`):
|
||||
|
||||
```php
|
||||
/**
|
||||
* 处理异步通知
|
||||
*
|
||||
* ⚠️ 注意:这是唯一可信的支付确认来源!
|
||||
* 返回订单ID供控制器调用pushOrderToCcb()推送到外联系统
|
||||
*
|
||||
* @param array $params 通知参数
|
||||
* @return array ['status' => 'success'|'fail', 'order_id' => int, 'order_sn' => string]
|
||||
*/
|
||||
public function handleNotify($params)
|
||||
{
|
||||
try {
|
||||
// 1. 验证签名
|
||||
if (!$this->verifyNotifySignature($params)) {
|
||||
throw new \Exception('签名验证失败');
|
||||
}
|
||||
|
||||
// 2. 查询订单
|
||||
$payFlowId = $params['ORDERID'] ?? '';
|
||||
$userOrderId = $params['USER_ORDERID'] ?? '';
|
||||
|
||||
if (!empty($userOrderId)) {
|
||||
$order = Order::where('order_sn', $userOrderId)->find();
|
||||
} else {
|
||||
$order = Order::where('ccb_pay_flow_id', $payFlowId)->find();
|
||||
}
|
||||
|
||||
if (!$order) {
|
||||
throw new \Exception('订单不存在');
|
||||
}
|
||||
|
||||
// 3. ✅ 幂等性检查: 如果订单已支付,直接返回成功
|
||||
if ($order['status'] == 'paid') {
|
||||
Log::info('[建行通知] 订单已支付,跳过处理 order_id:' . $order->id);
|
||||
return [
|
||||
'status' => 'success',
|
||||
'order_id' => $order->id,
|
||||
'order_sn' => $order->order_sn,
|
||||
'already_paid' => true, // ✅ 标记为已支付
|
||||
];
|
||||
}
|
||||
|
||||
// 4. 更新订单状态
|
||||
$this->updateOrderPaymentStatus($order, $params);
|
||||
|
||||
Log::info('[建行通知] 订单状态更新成功 order_id:' . $order->id);
|
||||
|
||||
// 5. ✅ 返回订单ID供控制器推送到外联系统
|
||||
return [
|
||||
'status' => 'success',
|
||||
'order_id' => $order->id,
|
||||
'order_sn' => $order->order_sn,
|
||||
'already_paid' => false, // ✅ 新支付
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[建行通知] 处理失败: ' . $e->getMessage());
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修复要点**:
|
||||
- ✅ 返回订单ID供控制器推送
|
||||
- ✅ 返回already_paid标志防止重复推送
|
||||
- ✅ 幂等性保护(已支付的订单直接返回成功)
|
||||
|
||||
---
|
||||
|
||||
### ~~修复6: pushOrderToCcb()增加幂等性检查~~
|
||||
|
||||
**⚠️ 已废弃**: 根据建行流程图,`pushOrderToCcb()`方法已废弃。
|
||||
|
||||
**正确流程**:
|
||||
1. `createPayment()` → 调用`orderService->pushOrder()` → 推送未支付订单(A3341TP01)
|
||||
2. `notify()` → 调用`orderService->updateOrderStatus()` → 更新为已支付(A3341TP02)
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 已废弃**: 根据建行流程图,订单推送和更新的幂等性由`CcbOrderService`内部保证。
|
||||
|
||||
---
|
||||
|
||||
### 修复6: 前端改为轮询查询
|
||||
|
||||
**修复后** (`frontend/sheep/platform/pay.js:325-386`):
|
||||
|
||||
```javascript
|
||||
// 建行生活支付
|
||||
async ccbPay() {
|
||||
// ...省略订单信息获取和支付串生成...
|
||||
|
||||
// 调起建行支付
|
||||
const result = await CcbLifePlatform.payment({
|
||||
payment_string: paymentResult.data.payment_string,
|
||||
order_id: orderId,
|
||||
order_sn: this.orderSN
|
||||
});
|
||||
|
||||
if (result.code === 0) {
|
||||
// ✅ 支付调起成功,开始轮询查询订单状态
|
||||
console.log('[建行支付] 支付调起成功,开始轮询查询订单状态');
|
||||
|
||||
uni.showLoading({
|
||||
title: '支付确认中...',
|
||||
mask: true
|
||||
});
|
||||
|
||||
// ✅ 轮询查询订单状态(最多30次,每次间隔2秒,总共60秒)
|
||||
let pollCount = 0;
|
||||
const MAX_POLL_COUNT = 30;
|
||||
const POLL_INTERVAL = 2000;
|
||||
|
||||
const pollPaymentStatus = async () => {
|
||||
pollCount++;
|
||||
|
||||
try {
|
||||
const statusResult = await ccbApi.queryPaymentStatus(orderId);
|
||||
|
||||
if (statusResult.code === 1 && statusResult.data.is_paid) {
|
||||
// ✅ 支付成功
|
||||
uni.hideLoading();
|
||||
console.log('[建行支付] 订单已支付');
|
||||
that.payResult('success');
|
||||
return;
|
||||
}
|
||||
|
||||
// 未支付,继续轮询
|
||||
if (pollCount < MAX_POLL_COUNT) {
|
||||
setTimeout(pollPaymentStatus, POLL_INTERVAL);
|
||||
} else {
|
||||
// 超时
|
||||
uni.hideLoading();
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '支付确认超时,请稍后在订单列表中查看支付状态',
|
||||
showCancel: false,
|
||||
confirmText: '知道了',
|
||||
success: () => {
|
||||
sheep.$router.redirect('/pages/order/list');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[建行支付] 查询状态失败:', error);
|
||||
|
||||
// 继续轮询(网络错误不中断)
|
||||
if (pollCount < MAX_POLL_COUNT) {
|
||||
setTimeout(pollPaymentStatus, POLL_INTERVAL);
|
||||
} else {
|
||||
uni.hideLoading();
|
||||
sheep.$helper.toast('支付状态查询失败,请稍后在订单列表中查看');
|
||||
that.payResult('fail');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟1秒后开始轮询(给建行异步通知留点时间)
|
||||
setTimeout(pollPaymentStatus, 1000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修复要点**:
|
||||
- ✅ 轮询查询订单状态(每2秒一次)
|
||||
- ✅ 最多轮询30次(总共60秒)
|
||||
- ✅ 网络错误不中断轮询
|
||||
- ✅ 超时友好提示用户去订单列表查看
|
||||
|
||||
---
|
||||
|
||||
## 📊 修复前后对比
|
||||
|
||||
| 对比项 | 修复前(错误) | 修复后(正确) |
|
||||
|-------|------------|------------|
|
||||
| **支付确认来源** | 前端callback + 建行notify (双通道) ❌ | 只依赖建行notify (单通道) ✅ |
|
||||
| **前端职责** | 调用callback通知后端支付成功 ❌ | 轮询查询订单状态 ✅ |
|
||||
| **安全性** | 可伪造前端请求触发支付成功 🔴 | 只信任建行签名验证 ✅ |
|
||||
| **订单推送时机** | 支付成功后推送 ❌ | **创建订单时推送未支付状态** ✅ |
|
||||
| **订单更新时机** | 未更新到建行 ❌ | **支付成功后更新为已支付** ✅ |
|
||||
| **竞态风险** | callback和notify可能同时执行 🔴 | 只有notify会更新订单 ✅ |
|
||||
| **幂等性** | 无幂等保护 ❌ | 支持重复调用,已推送/已更新的跳过 ✅ |
|
||||
| **符合建行规范** | 否 ❌ | **完全符合流程图** ✅ |
|
||||
| **订单状态一致性** | 可能重复更新或状态异常 🔴 | 原子更新,状态一致 ✅ |
|
||||
| **建行订单同步** | 不同步或错误时机同步 ❌ | **按流程图正确同步** ✅ |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证清单
|
||||
|
||||
修复完成后,请逐项验证:
|
||||
|
||||
### 1. 后端验证
|
||||
|
||||
```bash
|
||||
# 验证queryPaymentStatus接口
|
||||
curl -X GET "http://your-domain/addons/shopro/ccbpayment/queryPaymentStatus?order_id=123" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
|
||||
# 预期返回:
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "查询成功",
|
||||
"data": {
|
||||
"order_id": 123,
|
||||
"order_sn": "202501200001",
|
||||
"status": "unpaid",
|
||||
"is_paid": false,
|
||||
"paid_time": null,
|
||||
"pay_flow_id": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 安全测试
|
||||
|
||||
**测试1: 验证callback()已不能触发支付成功**
|
||||
|
||||
```bash
|
||||
# 尝试伪造callback请求
|
||||
curl -X POST "http://your-domain/addons/shopro/ccbpayment/callback" \
|
||||
-d "order_id=123&trans_id=fake_trans&pay_time=123456789" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
|
||||
# ✅ 预期: 只返回订单状态,不会更新订单为已支付
|
||||
```
|
||||
|
||||
**测试2: 验证notify()是否幂等**
|
||||
|
||||
```bash
|
||||
# 模拟建行重复发送通知
|
||||
curl -X POST "http://your-domain/addons/shopro/ccbpayment/notify" \
|
||||
-d "ORDERID=PAY20250120001&SUCCESS=Y&SIGN=..."
|
||||
|
||||
# ✅ 预期:
|
||||
# - 第1次调用: 更新订单+推送外联,返回SUCCESS
|
||||
# - 第2次调用: 跳过处理,直接返回SUCCESS
|
||||
# - 日志中应有"订单已支付,跳过处理"
|
||||
```
|
||||
|
||||
### 3. 前端验证
|
||||
|
||||
1. 在建行APP中发起支付
|
||||
2. 观察浏览器控制台:
|
||||
- 应该看到"支付调起成功,开始轮询查询订单状态"
|
||||
- 每2秒调用一次`queryPaymentStatus`接口
|
||||
- 收到`is_paid=true`后跳转到成功页
|
||||
|
||||
3. 网络中断测试:
|
||||
- 支付完成后断开网络
|
||||
- 前端应继续轮询(虽然失败)
|
||||
- 60秒后提示超时,引导用户去订单列表查看
|
||||
|
||||
### 4. 日志验证
|
||||
|
||||
```bash
|
||||
# 查看创建支付串日志
|
||||
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行支付'
|
||||
|
||||
# ✅ 正常流程应该看到:
|
||||
# [建行支付] 订单推送成功 order_id:123 ← 步骤2: 推送未支付订单
|
||||
|
||||
# 查看notify日志
|
||||
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行通知'
|
||||
|
||||
# ✅ 正常流程应该看到:
|
||||
# [建行通知] 收到异步通知: ORDERID=...
|
||||
# [建行通知] 解析参数: {...}
|
||||
# [建行通知] 订单状态更新成功 order_id:123 ← 步骤13: 更新为已支付
|
||||
# [建行通知] 处理完成,返回: SUCCESS
|
||||
|
||||
# 查看幂等性日志(重复通知时)
|
||||
# [建行通知] 订单已支付,跳过处理 order_id:123
|
||||
# [建行通知] 订单已支付且已更新,跳过更新 order_id:123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 数据库字段说明
|
||||
|
||||
确保订单表包含以下字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE `fa_shopro_order`
|
||||
ADD COLUMN `ccb_pay_flow_id` VARCHAR(64) DEFAULT '' COMMENT '建行支付流水号',
|
||||
ADD COLUMN `ccb_sync_status` TINYINT(1) DEFAULT 0 COMMENT '建行同步状态:0-未同步 1-已同步 2-失败',
|
||||
ADD COLUMN `ccb_sync_time` INT(10) DEFAULT 0 COMMENT '建行同步时间',
|
||||
ADD COLUMN `ccb_sync_error` VARCHAR(255) DEFAULT '' COMMENT '建行同步失败原因';
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `ccb_sync_status`: 0=未同步 / 1=已同步 / 2=失败
|
||||
- `ccb_sync_error`: 推送失败时记录错误原因,供后续补推
|
||||
|
||||
---
|
||||
|
||||
## 🚀 部署步骤
|
||||
|
||||
### 1. 备份现有代码
|
||||
|
||||
```bash
|
||||
# 备份控制器
|
||||
cp addons/shopro/controller/Ccbpayment.php addons/shopro/controller/Ccbpayment.php.bak
|
||||
|
||||
# 备份服务类
|
||||
cp addons/shopro/library/ccblife/CcbPaymentService.php addons/shopro/library/ccblife/CcbPaymentService.php.bak
|
||||
|
||||
# 备份前端代码
|
||||
cp frontend/sheep/platform/pay.js frontend/sheep/platform/pay.js.bak
|
||||
cp frontend/sheep/platform/provider/ccblife/api.js frontend/sheep/platform/provider/ccblife/api.js.bak
|
||||
```
|
||||
|
||||
### 2. 部署后端代码
|
||||
|
||||
```bash
|
||||
# 上传修复后的文件
|
||||
# - addons/shopro/controller/Ccbpayment.php
|
||||
# - addons/shopro/library/ccblife/CcbPaymentService.php
|
||||
|
||||
# 清除缓存
|
||||
php think clear
|
||||
```
|
||||
|
||||
### 3. 部署前端代码
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 上传修复后的文件
|
||||
# - sheep/platform/pay.js
|
||||
# - sheep/platform/provider/ccblife/api.js
|
||||
|
||||
# 重新打包发布
|
||||
```
|
||||
|
||||
### 4. 数据库迁移(如果字段缺失)
|
||||
|
||||
```sql
|
||||
-- 检查字段是否存在
|
||||
SHOW COLUMNS FROM `fa_shopro_order` LIKE 'ccb_sync_error';
|
||||
|
||||
-- 如果不存在,添加字段
|
||||
ALTER TABLE `fa_shopro_order`
|
||||
ADD COLUMN `ccb_sync_error` VARCHAR(255) DEFAULT '' COMMENT '建行同步失败原因';
|
||||
```
|
||||
|
||||
### 5. 监控上线
|
||||
|
||||
```bash
|
||||
# 实时监控notify日志
|
||||
tail -f runtime/log/$(date +%Y%m)/*.log | grep '建行'
|
||||
|
||||
# 监控查询接口调用
|
||||
tail -f runtime/log/$(date +%Y%m)/*.log | grep 'queryPaymentStatus'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 回滚方案
|
||||
|
||||
如遇紧急问题,可立即回滚:
|
||||
|
||||
```bash
|
||||
# 回滚后端
|
||||
mv addons/shopro/controller/Ccbpayment.php.bak addons/shopro/controller/Ccbpayment.php
|
||||
mv addons/shopro/library/ccblife/CcbPaymentService.php.bak addons/shopro/library/ccblife/CcbPaymentService.php
|
||||
|
||||
# 清除缓存
|
||||
php think clear
|
||||
|
||||
# 回滚前端(重新发布旧版本代码)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
**开发者**: Billy
|
||||
**修复日期**: 2025-01-20
|
||||
**建行文档版本**: v2.20 (2025-07-25)
|
||||
|
||||
如有疑问,请查阅:
|
||||
- 建行接入文档: `/doc/建行相关App服务方接入文档v2.20_20250725.html`
|
||||
- 本修复报告: `/doc/建行支付架构修复报告.md`
|
||||
- 加密修复报告: `/doc/建行支付对接修复报告.md`
|
||||
|
||||
---
|
||||
|
||||
## 📝 变更历史
|
||||
|
||||
| 版本 | 日期 | 修改内容 |
|
||||
|-----|------|---------|
|
||||
| v1.0 | 2025-01-20 | 初始版本,完成严重安全漏洞和架构偏离修复 |
|
||||
| v2.0 | 2025-01-20 | **重大修正**: 根据建行流程图修正订单同步时机 - 推送未支付订单在createPayment,更新已支付订单在notify |
|
||||
|
||||
---
|
||||
|
||||
**修复完成,已做好生产环境部署准备!** ✅
|
||||
1558
doc/建行生活API接口文档.md
Normal file
1558
doc/建行生活API接口文档.md
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user