支付串生成

This commit is contained in:
Billy 2025-10-21 14:33:20 +08:00
parent b4e76a58ab
commit 70b1d415de
4 changed files with 347 additions and 60 deletions

View File

@ -92,6 +92,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,

View File

@ -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) {

View File

@ -46,14 +46,20 @@ class CcbOrderService
* 当用户下单后调用此方法同步订单信息
*
* @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')
@ -78,7 +84,8 @@ class CcbOrderService
->select();
// 构建订单数据符合A3341TP01接口规范
$orderData = $this->buildOrderData($order, $orderItems, $ccbUserId);
// ✅ 传入统一的支付流水号
$orderData = $this->buildOrderData($order, $orderItems, $ccbUserId, $payFlowId);
// 记录请求数据(同步日志)
$txSeq = CcbMD5::generateTransactionSeq();
@ -310,12 +317,11 @@ class CcbOrderService
* @param string $ccbUserId 建行用户ID
* @return array
*/
private function buildOrderData($order, $orderItems, $ccbUserId)
private function buildOrderData($order, $orderItems, $ccbUserId, $payFlowId)
{
// ⚠️ 验证必填字段PAY_FLOW_ID支付流水号
// 这个字段在 createPayment 时设置,推送订单前必须存在
if (empty($order['ccb_pay_flow_id'])) {
throw new \Exception('订单支付流水号(ccb_pay_flow_id)不存在请先调用createPayment生成支付串');
// ✅ 使用控制器传入的统一支付流水号(确保与支付串生成使用同一流水号)
if (empty($payFlowId)) {
throw new \Exception('支付流水号不能为空');
}
// 构建SKU商品列表JSON字符串格式
@ -378,7 +384,7 @@ class CcbOrderService
'ORDER_STATUS' => $this->mapOrderStatus($order['status']), // 订单状态
'REFUND_STATUS' => $this->mapRefundStatus($order['refund_status'] ?? 0), // 退款状态
'MCT_NM' => $this->config['merchant']['name'] ?? '商户名称', // 商户名称
'PAY_FLOW_ID' => $order['ccb_pay_flow_id'], // 支付流水号(必填!已在上方验证
'PAY_FLOW_ID' => $payFlowId, // ✅ 支付流水号(使用控制器传入的统一流水号
'PAY_MRCH_ID' => $this->config['merchant_id'], // 支付商户号(必填!)
'SKU_LIST' => $skuList, // 商品信息JSON字符串必填

View File

@ -49,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();
@ -68,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', // 币种T01=人民币
'TXCODE' => '520100', // 交易码T520100=即时支付)
'REMARK1' => '', // 备注1T
'REMARK2' => $this->config['service_id'], // 备注2T服务方编号
'TYPE' => '1', // 接口类型T1=防钓鱼)
'GATEWAY' => '0', // 网关类型T
'CLIENTIP' => $this->getClientIp(), // 客户端IPT
'REGINFO' => '', // 客户注册信息T中文需escape编码
'PROINFO' => $this->buildProductInfo($order), // 商品信息T中文已escape编码
'REFERER' => '', // 商户URLT
'THIRDAPPINFO' => 'comccbpay1234567890cloudmerchant', // 客户端标识T固定值
'TIMEOUT' => date('YmdHis', strtotime('+30 minutes')), // 超时时间F格式YYYYMMDDHHmmss
];
// ✅ 可选参数(根据实际场景添加)
// 外部平台商户号(与建行商户号二选一)
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);
@ -108,9 +205,9 @@ 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));
// ✅ 修复:使用 CcbRSA 加密商户公钥后30位用于ENCPUB字段
// 删除 CcbEncryption 类,统一使用 CcbRSA 处理密钥格式化
@ -182,11 +279,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)
{
@ -197,11 +330,14 @@ 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);
}
/**
@ -292,8 +428,8 @@ class CcbPaymentService
// 支付成功,更新订单状态
$this->updateOrderPaymentStatus($order, $params);
// 同步订单到建行
$this->orderService->pushOrder($order['id']);
// ✅ 更新订单状态到建行订单已在createPayment时推送这里只需更新状态
$this->orderService->updateOrderStatus($order['id']);
return [
'status' => true,