diff --git a/addons/shopro/config/ccblife.php b/addons/shopro/config/ccblife.php index 82d9e03..d5f4e24 100644 --- a/addons/shopro/config/ccblife.php +++ b/addons/shopro/config/ccblife.php @@ -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, diff --git a/addons/shopro/controller/Ccbpayment.php b/addons/shopro/controller/Ccbpayment.php index 42ded4d..3d500ec 100644 --- a/addons/shopro/controller/Ccbpayment.php +++ b/addons/shopro/controller/Ccbpayment.php @@ -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) { diff --git a/addons/shopro/library/ccblife/CcbOrderService.php b/addons/shopro/library/ccblife/CcbOrderService.php index 86fe33b..83ee6c1 100644 --- a/addons/shopro/library/ccblife/CcbOrderService.php +++ b/addons/shopro/library/ccblife/CcbOrderService.php @@ -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字符串(必填!) diff --git a/addons/shopro/library/ccblife/CcbPaymentService.php b/addons/shopro/library/ccblife/CcbPaymentService.php index 92b6b7f..8783e98 100644 --- a/addons/shopro/library/ccblife/CcbPaymentService.php +++ b/addons/shopro/library/ccblife/CcbPaymentService.php @@ -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', // 币种(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); @@ -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,