clientFactory = $clientFactory; $this->logger = $loggerFactory->get('ccb'); $this->isDebug = config('ccb.debug'); $this->mktId = config('ccb.mkt_id'); $this->merchantId = config('ccb.merchant_id'); $this->pymdCd = config('ccb.pymd_cd'); $selfPrivateKey = config('ccb.self_private_key'); $selfPrivateKey = "-----BEGIN RSA PRIVATE KEY-----\n".chunk_split($selfPrivateKey, 64, "\n")."-----END RSA PRIVATE KEY-----\n"; $this->selfPrivateKey = openssl_get_privatekey($selfPrivateKey); $bankPublicKey = config('ccb.bank_public_key'); $bankPublicKey = "-----BEGIN PUBLIC KEY-----\n".chunk_split($bankPublicKey, 64, "\n")."-----END PUBLIC KEY-----\n"; $this->bankPublicKey = openssl_get_publickey($bankPublicKey); } /** * 使用我方私钥加密 * @param string $data * @return string */ public function encrypt(string $data) { $str = ''; foreach (str_split($data, 117) as $chunk) { openssl_private_encrypt($chunk, $crypted, $this->selfPrivateKey); $str .= $crypted; } return base64_encode($str); } /** * 使用银行公钥解密 * @param string $data * @return string */ public function decrypt(string $data) { $raw = base64_decode($data); $str = ''; foreach (str_split($raw, 256) as $chunk) { openssl_public_decrypt($chunk, $decrypted, $this->bankPublicKey); $str .= $decrypted; } return $str; } /** * 使用我方私钥生成签名 * @param string $data * @return string */ public function sign(string $data) { openssl_sign($data, $signature, $this->selfPrivateKey, OPENSSL_ALGO_SHA256); return base64_encode($signature); } /** * 使用银行公钥验证签名 * @param string $data * @param string $signature * @return bool */ public function verifySign(string $data, string $signature) { $signature = base64_decode($signature); $result = openssl_verify($data, $signature, $this->bankPublicKey, OPENSSL_ALGO_SHA256); return $result == 1; } /** * 计算待签名字符串 * @param array $params * @return string */ public function createSign(array $params) { // 不参与签名的字符串 $unsignKeys = ['Sign_Inf', 'Svc_Rsp_St', 'Svc_Rsp_Cd', 'Rsp_Inf']; $result = []; ksort($params); foreach ($params as $key => $item) { if (in_array($key, $unsignKeys)) { continue; } if (is_array($item)) { foreach ($item as $child) { $value = $this->createSign($child); if ($value !== '') { $result[] = ['', $value]; } } } else { $value = trim($item); if ($value !== '') { $result[] = [$key, $value]; } } } $str = ''; foreach ($result as [$key, $value]) { $str .= $key ? "$key=$value&" : "$value&"; } return rtrim($str, '&'); } /** * 发送API请求 * @param string $uri * @param array $params * @return array */ private function apiRequest(string $uri, array $params = []): array { $signData = $this->createSign($params); $params['Sign_Inf'] = $this->sign($signData); if ($this->isDebug) { $uri = $this->devBaseUri.$uri; } else { $uri = $this->prodBaseUri.$uri; } $options = ['json' => $params]; $startTime = microtime(true); try { $response = $this->clientFactory->create(['timeout' => 60])->post($uri, $options); } catch (\Exception $e) { $this->saveApiLog(0, $uri, $params, $e->getMessage()); throw new BusinessException(500, '请求异常'); } $useTime = round((microtime(true) - $startTime) * 1000, 2); $content = $response->getBody()->getContents(); $result = json_decode($content, true); if (!isset($result['Svc_Rsp_St']) || $result['Svc_Rsp_St'] != '00') { $this->saveApiLog($useTime, $uri, $params, $content); throw new BusinessException(500, (($result['Rsp_Inf'] ?? '') ?: 'CCB请求失败')); } if (!isset($result['Sign_Inf']) || !$this->verifySign($this->createSign($result), $result['Sign_Inf'])) { $this->saveApiLog($useTime, $uri, $params, $content); throw new BusinessException(500, (($result['Rsp_Inf'] ?? '') ?: 'CCB验签失败')); } if ($this->isDebug) { $this->saveApiLog($useTime, $uri, $params, $content); } return $result; } /** * 保存API请求日志 * @param $useTime * @param $uri * @param $params * @param $content * @return void */ private function saveApiLog($useTime, $uri, $params, $content) { $this->logger->info( sprintf( "%s\nTime: %.2f ms\nUrl: %s\nParams:\n%s\nContent:\n%s\n", 'CCB API Log', $useTime, $uri, json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), $content ) ); } private function getTimestamp() { return (new \DateTime())->format('YmdHisv'); } private function genSerialNumber() { return date('YmdHis').mt_rand(10000, 99999).mt_rand(10000, 99999); } private function genMainOrderNo() { return time().mt_rand(10000, 99999).mt_rand(10000, 99999); } /** * 3.1 生成支付订单接口 * @param string $Main_Ordr_No 主订单编号 * @param string $Ordr_Tamt 订单总金额 * @param string $Txn_Tamt 交易总金额(实付) * @param string|null $Sub_Appid 当前调起支付的小程序appid * @param string|null $Sub_Openid 用户在小程序下的openid * @param array $Orderlist 子订单列表 * @return array */ public function gatherPlaceorder(string $Main_Ordr_No, string $Ordr_Tamt, string $Txn_Tamt, ?string $Sub_Appid, ?string $Sub_Openid, array $Orderlist) { $uri = '/online/direct/gatherPlaceorder'; $params = [ 'Ittparty_Stm_Id' => $this->ittpartyStmId, 'Py_Chnl_Cd' => $this->pyChnlCd, 'Ittparty_Tms' => $this->getTimestamp(), 'Ittparty_Jrnl_No' => $this->genSerialNumber(), 'Mkt_Id' => $this->mktId, 'Main_Ordr_No' => $Main_Ordr_No, 'Pymd_Cd' => $this->pymdCd, 'Py_Ordr_Tpcd' => '04', 'Py_Rslt_Ntc_Sn' => '1', 'Ccy' => '156', 'Ordr_Tamt' => $Ordr_Tamt, 'Txn_Tamt' => $Txn_Tamt, 'Order_Time_Out' => '1800', 'Orderlist' => $Orderlist, 'Vno' => '4', ]; if ($this->pymdCd == '05') { $params['Sub_Appid'] = $Sub_Appid; $params['Sub_Openid'] = $Sub_Openid; } return $this->apiRequest($uri, $params); } /** * 下单子订单格式化 * @param string $Mkt_Mrch_Id 商家编号 * @param string $Cmdty_Ordr_No 子订单编号 * @param string $Ordr_Amt 订单金额 * @param string $Txnamt 实付金额 * @param string $Cmdty_Dsc 商品描述 * @return string[] */ public function subOrderListItem(string $Mkt_Mrch_Id, string $Cmdty_Ordr_No, string $Ordr_Amt, string $Txnamt, string $Cmdty_Dsc) { return compact('Mkt_Mrch_Id', 'Cmdty_Ordr_No', 'Ordr_Amt', 'Txnamt', 'Cmdty_Dsc'); } /** * 3.4 查询支付结果接口,同一笔订单不支持并发查询 * @param string $Main_Ordr_No 主订单号,主订单号与支付流水号必输其一 * @param string $Py_Trn_No 银行支付流水号 * @return array */ public function gatherEnquireOrder(string $Main_Ordr_No, string $Py_Trn_No = '') { $uri = '/online/direct/gatherEnquireOrder'; $params = [ 'Ittparty_Stm_Id' => $this->ittpartyStmId, 'Py_Chnl_Cd' => $this->pyChnlCd, 'Ittparty_Tms' => $this->getTimestamp(), 'Ittparty_Jrnl_No' => $this->genSerialNumber(), 'Mkt_Id' => $this->mktId, 'Main_Ordr_No' => $Main_Ordr_No, 'Py_Trn_No' => $Py_Trn_No, 'Vno' => '4', ]; $ssdb = ApplicationContext::getContainer()->get(SSDBTask::class); $key = 'ccb_'.__FUNCTION__.'_'.$Main_Ordr_No.$Py_Trn_No; if ($ssdb->exec('setnx',$key, 1)) { try { return $this->apiRequest($uri, $params); } finally { $ssdb->exec('expire', $key, 2); } } else { sleep(1); return $this->gatherEnquireOrder($Main_Ordr_No, $Py_Trn_No); } } /** * 4.1 订单退款接口 * @param string $Cust_Rfnd_Trcno 退款流水号,对于同一笔退款唯一 * @param string $Py_Trn_No 银行支付流水号 * @param string $Rfnd_Amt 退款金额,全额退款时可不传 * @param array $Sub_Ordr_List 子订单列表,全额退款时不需要传该域 * @return array */ public function refundOrder(string $Cust_Rfnd_Trcno, string $Py_Trn_No, string $Rfnd_Amt = '', array $Sub_Ordr_List = []) { $uri = '/online/direct/refundOrder'; $params = [ 'Ittparty_Stm_Id' => $this->ittpartyStmId, 'Py_Chnl_Cd' => $this->pyChnlCd, 'Ittparty_Tms' => $this->getTimestamp(), 'Ittparty_Jrnl_No' => $this->genSerialNumber(), 'Mkt_Id' => $this->mktId, 'Cust_Rfnd_Trcno' => $Cust_Rfnd_Trcno, 'Py_Trn_No' => $Py_Trn_No, 'Rfnd_Amt' => $Rfnd_Amt, 'Sub_Ordr_List' => $Sub_Ordr_List, 'Vno' => '3', ]; if ($Sub_Ordr_List) { $params['Sub_Ordr_List'] = $Sub_Ordr_List; } return $this->apiRequest($uri, $params); } /** * 退款子订单格式化 * @param string $Sub_Ordr_Id 银行子订单编号 * @param string $Rfnd_Amt 退款金额 * @return string[] */ public function refundSubOrderListItem(string $Sub_Ordr_Id, string $Rfnd_Amt) { return compact('Sub_Ordr_Id', 'Rfnd_Amt'); } /** * 4.3 查询退款结果接口,同一笔退款不支持并发查询 * @param string $Cust_Rfnd_Trcno 我方退款流水号,与银行退款流水号必输其一 * @param string $Rfnd_Trcno 银行退款流水号 * @return array */ public function enquireRefundOrder(string $Cust_Rfnd_Trcno, string $Rfnd_Trcno = '') { $uri = '/online/direct/enquireRefundOrder'; $params = [ 'Ittparty_Stm_Id' => $this->ittpartyStmId, 'Py_Chnl_Cd' => $this->pyChnlCd, 'Ittparty_Tms' => $this->getTimestamp(), 'Ittparty_Jrnl_No' => $this->genSerialNumber(), 'Mkt_Id' => $this->mktId, 'Cust_Rfnd_Trcno' => $Cust_Rfnd_Trcno, 'Rfnd_Trcno' => $Rfnd_Trcno, 'Vno' => '4', ]; return $this->apiRequest($uri, $params); } /** * 5.3 确认收货接口 * @param string $Prim_Ordr_No 银行主订单编号 * @return array */ public function mergeNoticeArrival(string $Prim_Ordr_No) { $uri = '/online/direct/enquireRefundOrder'; $params = [ 'Ittparty_Stm_Id' => $this->ittpartyStmId, 'Py_Chnl_Cd' => $this->pyChnlCd, 'Ittparty_Tms' => $this->getTimestamp(), 'Ittparty_Jrnl_No' => $this->genSerialNumber(), 'Mkt_Id' => $this->mktId, 'Prim_Ordr_No' => $Prim_Ordr_No, 'Vno' => '4', ]; return $this->apiRequest($uri, $params); } /** * 10.1 订单信息查询 * @param string $Main_Ordr_No 主订单编号 * @param string $Py_Trn_No 支付流水号,主订单号与支付流水号必输其一 * @return array */ public function orderInfQuery(string $Main_Ordr_No, string $Py_Trn_No = '') { $uri = '/online/direct/OrderInfQuery'; $params = [ 'Ittparty_Stm_Id' => $this->ittpartyStmId, 'Py_Chnl_Cd' => $this->pyChnlCd, 'Ittparty_Tms' => $this->getTimestamp(), 'Ittparty_Jrnl_No' => $this->genSerialNumber(), 'Mkt_Id' => $this->mktId, 'Main_Ordr_No' => $Main_Ordr_No, 'Py_Trn_No' => $Py_Trn_No, 'Vno' => '4', ]; return $this->apiRequest($uri, $params); } /** * 创建支付订单 * @param $globalOrderId * @param $userId * @return array * @throws \GuzzleHttp\Exception\GuzzleException */ public function createCcbPayment($globalOrderId, $userId) { try { // 待支付的,未超时(15min,900sec)的订单 $orderMain = OrderMain::query() ->where(['state' => OrderState::UNPAID, 'global_order_id' => $globalOrderId, 'user_id' => $userId]) ->where('created_at', '>=', (time() - 900)) ->first(); if (empty($orderMain)) { throw new BusinessException(ErrorCode::ORDER_NOT_AVAILABLE, '[订单号无效]'.$globalOrderId); } $user = User::select('openid')->find($userId); $model = new CcbPayment(); $model->order_main_id = $orderMain->global_order_id; $model->main_ordr_no = $this->genMainOrderNo(); $model->pymd_cd = $this->pymdCd; if ($this->pymdCd == '05') { $model->sub_appid = config('wechat.applet.app_id'); $model->sub_openid = $user->openid; } $model->py_ordr_tpcd = '04'; $model->ordr_tamt = $orderMain->money; $model->txn_tamt = $orderMain->money; $subOrderList = []; // 运费判断 if (bccomp($orderMain->delivery_money, '0', 2) == 1) { $subOrderList[] = $this->subOrderListItem( $this->merchantId, $model->main_ordr_no.'D', $orderMain->delivery_money, $orderMain->delivery_money, '配送费' ); } // 我方分润比例% $selfProfitRatio = config('ccb.self_profit_ratio'); if (bccomp($selfProfitRatio, '0', 3) == 0) { throw new BusinessException(500, '[未配置分润比例]'); } foreach ($orderMain->orders as $order) { if (empty($order->store->ccb_merchant_id)) { throw new BusinessException(500, '[店铺未配置商家编号]'.$order->store_id); } foreach ($order->orderGoods as $orderGoods) { $goodsMoney = bcmul($orderGoods->price, (string)$orderGoods->number, 2); $selfProfitMoney = bcmul($goodsMoney, $selfProfitRatio, 2); $merchantProfitMoney = bcsub($goodsMoney, $selfProfitMoney, 2); // 平台抽佣后最小金额不能为0 if (bccomp($merchantProfitMoney, '0', 2) == 0) { $selfProfitMoney = '0.00'; $merchantProfitMoney = $goodsMoney; } $subOrderList[] = $this->subOrderListItem( $order->store->ccb_merchant_id, $model->main_ordr_no.'G'.$orderGoods->id.'N01', $merchantProfitMoney, $merchantProfitMoney, $orderGoods->name.'(分佣到账)' ); if (bccomp($selfProfitMoney, '0', 2) == 1) { $subOrderList[] = $this->subOrderListItem( $this->merchantId, $model->main_ordr_no.'G'.$orderGoods->id.'N02', $selfProfitMoney, $selfProfitMoney, $orderGoods->name.'(平台抽佣)' ); } } } $model->orderlist = json_encode($subOrderList); $model->save(); $result = $this->gatherPlaceorder( $model->main_ordr_no, $model->ordr_tamt, $model->txn_tamt, $model->sub_appid, $model->sub_openid, $subOrderList ); $model->py_trn_no = $result['Py_Trn_No']; $model->prim_ordr_no = $result['Prim_Ordr_No']; $model->ordr_gen_tm = $result['Ordr_Gen_Tm']; if (isset($result['Cshdk_Url'])) { $model->cshdk_url = $result['Cshdk_Url']; } if ($this->pymdCd == '05') { $model->rtn_par_data = json_encode($result['Rtn_Par_Data']); } $model->ordr_stcd = $result['Ordr_Stcd']; $model->rtn_orderlist = json_encode($result['Orderlist']); $model->save(); if ($this->pymdCd == '05') { // 返回支付参数给前端 $parameters = [ 'appId' => $result['Rtn_Par_Data']['appId'], 'timeStamp' => $result['Rtn_Par_Data']['timeStamp'], 'nonceStr' => $result['Rtn_Par_Data']['nonceStr'], 'package' => $result['Rtn_Par_Data']['package'], 'signType' => $result['Rtn_Par_Data']['signType'], 'paySign' => $result['Rtn_Par_Data']['paySign'], ]; } else { $parameters = [ 'Cshdk_Url' => $model->cshdk_url, ]; } $parameters['order_main_id'] = $orderMain->global_order_id; return $parameters; } catch (\Exception $e) { $this->log->event(LogLabel::ORDER_PAYMENT_LOG, ['payment_do_exception_msg' => $e->getMessage()]); $message = $e instanceof BusinessException ? $e->getMessage() : '[稍后重试]'; throw new ErrorCodeException(ErrorCode::PAYMENT_FAIL, $message); } } }