diff --git a/app/Controller/v3/CCBNotifyController.php b/app/Controller/v3/CCBNotifyController.php new file mode 100644 index 0000000..a1c9350 --- /dev/null +++ b/app/Controller/v3/CCBNotifyController.php @@ -0,0 +1,34 @@ +clientFactory = $clientFactory; + $this->logger = $loggerFactory->get('ccb'); + + $this->isDebug = config('ccb.debug'); + $this->mktId = config('ccb.mkt_id'); + + $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); + $result[] = ['', $value]; + } + } else { + $value = trim($item); + if ($value !== '') { + $result[] = [$key, $value]; + } + } + } + + $str = ''; + + foreach ($result as [$key, $value]) { + if ($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]; + + if (env('CCB_HTTP_PROXY')) { + $options['proxy'] = env('CCB_HTTP_PROXY'); + } + + try { + $response = $this->clientFactory->create(['timeout' => 120])->post($uri, $options); + } catch (\Exception $e) { + $this->saveApiLog($uri, $params, $e->getMessage()); + throw new BusinessException(500, '请求异常'); + } + + $content = $response->getBody()->getContents(); + $result = json_decode($content, true); + + if (!isset($result['Svc_Rsp_St']) || $result['Svc_Rsp_St'] != '00') { + $this->saveApiLog($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($uri, $params, $content); + throw new BusinessException(500, ($result['Rsp_Inf'] ?? 'CCB验签失败')); + } + + if ($this->isDebug) { + $this->saveApiLog($uri, $params, $content); + } + + return $result; + } + + /** + * 保存API请求日志 + * @param $uri + * @param $params + * @param $content + * @return void + */ + private function saveApiLog($uri, $params, $content) + { + $this->logger->info( + sprintf( + "%s\nUrl:%s\nParams:\n%s\nContent:\n%s\n", + 'CCB API Log', + $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); + } + + /** + * 3.1 生成支付订单接口 + * @param string $Main_Ordr_No 主订单编号 + * @param string $Ordr_Tamt 订单总金额 + * @param string $Txn_Tamt 交易总金额(实付) + * @param string $Pymd_Cd 支付方式:03为H5,05为小程序 + * @param string $Sub_Appid 当前调起支付的小程序appid + * @param string $Sub_Openid 用户在小程序下的openid + * @param array $Orderlist 子订单列表 + * @return array + */ + public function gatherPlaceorder(string $Main_Ordr_No, string $Ordr_Tamt, string $Txn_Tamt, string $Pymd_Cd, 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' => $Pymd_Cd, + 'Py_Ordr_Tpcd' => '04', + 'Py_Rslt_Ntc_Sn' => '1', + 'Ccy' => '156', + 'Ordr_Tamt' => $Ordr_Tamt, + 'Txn_Tamt' => $Txn_Tamt, + 'Sub_Appid' => $Sub_Appid, + 'Sub_Openid' => $Sub_Openid, + 'Order_Time_Out' => '1800', + 'Orderlist' => $Orderlist, + 'Vno' => '4', + ]; + + return $this->apiRequest($uri, $params); + } + + /** + * 下单子订单格式化 + * @param string $Mkt_Mrch_Id 商家编号 + * @param string $Cmdty_Ordr_No 子订单编号 + * @param string $Ordr_Amt 订单金额 + * @param string $Txnamt 实付金额 + * @param string $Clrg_Rule_Id 分账规则编号 + * @param array $Parlist 分账参与方列表 + * @return string[] + */ + public function subOrderListItem(string $Mkt_Mrch_Id, string $Cmdty_Ordr_No, string $Ordr_Amt, string $Txnamt, string $Clrg_Rule_Id, array $Parlist) + { + return compact('Mkt_Mrch_Id', 'Cmdty_Ordr_No', 'Ordr_Amt', 'Txnamt', 'Clrg_Rule_Id', 'Parlist'); + } + + /** + * 分账参与方 + * @param int $Seq_No 顺序号 + * @param string $Mkt_Mrch_Id 商家编号 + * @return array + */ + public function parListItem(int $Seq_No, string $Mkt_Mrch_Id) + { + return compact('Seq_No', 'Mkt_Mrch_Id'); + } + + /** + * 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', + ]; + + return $this->apiRequest($uri, $params); + } + + /** + * 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); + } + +} \ No newline at end of file diff --git a/config/config.php b/config/config.php index c6209b4..3a92371 100644 --- a/config/config.php +++ b/config/config.php @@ -64,4 +64,10 @@ return [ 'distance' => [ 'delivery_distance' => env('DELIVERY_DISTANCE', '7000') ], + 'ccb' => [ + 'debug' => env('CCB_DEBUG', false), + 'mkt_id' => env('CCB_MKT_ID', ''), + 'self_private_key' => env('CCB_SELF_PRIVATE_KEY', ''), + 'bank_public_key' => env('CCB_BANK_PUBLIC_KEY', ''), + ], ]; diff --git a/config/routes.php b/config/routes.php index a5f6040..6dbeb1b 100644 --- a/config/routes.php +++ b/config/routes.php @@ -186,4 +186,14 @@ Router::addGroup('/v3/wechat/',function () { Router::post('notify/online', 'App\Controller\v3\NotifyController@wxminiOnline'); Router::post('notify/offline', 'App\Controller\v3\NotifyController@wxminiOffline'); Router::post('notify/refund', 'App\Controller\v3\NotifyController@wxminiRefund'); +}); + +// 建行支付回调 +Router::addGroup('/v3/ccb/',function () { + Router::post('notify/pay', 'App\Controller\v3\CCBNotifyController@pay'); + Router::post('notify/refund', 'App\Controller\v3\NotifyController@refund'); + Router::post('notify/merchant', 'App\Controller\v3\NotifyController@merchant'); + Router::post('notify/accounting', 'App\Controller\v3\NotifyController@accounting'); + Router::post('notify/bill', 'App\Controller\v3\NotifyController@bill'); + Router::post('notify/platform', 'App\Controller\v3\NotifyController@platform'); }); \ No newline at end of file diff --git a/test/Cases/CCBTest.php b/test/Cases/CCBTest.php new file mode 100644 index 0000000..8e35422 --- /dev/null +++ b/test/Cases/CCBTest.php @@ -0,0 +1,88 @@ +get(CCBPayment::class); + + $data = "Write Once, Run Anywhere"; + + $result = $ccb->sign($data); + + $s = 'odUeD1V6obC/j8lUvmFwn6LSQ2DrvaDEin5DKs0FiB/HttboPJncmLisH22Y1grPezE0a+Ij6cdd5Taof8e4A76pKdXA+hDGz0nPMlCRgSF5tHQ6uVXktL/3lqpVTX6ECjRoHmzmo6cAMqVXYQKEl56r4gJcBPW4X4ghRtTgw9AK8+8b8O5EAqxuPxMyhSOYrCsUYRXapezV3uioEReYjvhg/u+kRf662P2nL0ab3szGMXMAoE+JjgMAHR9WCL3Can/5ADgoFff/7kMSxrA7/r94EfkDN50IvRVAx9WBLx3+WWcRRfM6JcqZ91B+g00wYN/OSZHTJDPgV6Ofd5cBpA=='; + + $this->assertTrue($result == $s); + } + + public function testCreateSign() + { + $ccb = ApplicationContext::getContainer()->get(CCBPayment::class); + + $params = json_decode('{"Blank3":" ","Blank2":"","Sign_Inf":"signInf","Rsp_Inf":"rspInf","Svc_Rsp_St":"svcRspSt","Svc_Rsp_Cd":"svcRspCd","Amt":"amt","Pymd_Cd":"pymdCd","Parlist":[{"Seq_No":"seqNo","Mkt_Mrch_Id":"mktMrchIdFj"},{"Seq_No":"seqNo","Xbb":[{"sdc":"1131","xyz":"xxxx"}],"Mkt_Mrch_Id":"mktMrchIdFj2"},{"Seq_No":"seqNo3","Mkt_Mrch_Id":"mktMrchIdFj3"}]}', true); + + $str = $ccb->createSign($params); + + $s = 'Amt=amt&Mkt_Mrch_Id=mktMrchIdFj&Seq_No=seqNo&Mkt_Mrch_Id=mktMrchIdFj2&Seq_No=seqNo&sdc=1131&xyz=xxxx&Mkt_Mrch_Id=mktMrchIdFj3&Seq_No=seqNo3&Pymd_Cd=pymdCd'; + + $this->assertTrue($str == $s); + } + + public function testOrder() + { + $ccb = ApplicationContext::getContainer()->get(CCBPayment::class); + + $parList = []; + $parList[] = $ccb->parListItem(1, '41060860800469000000'); + $parList[] = $ccb->parListItem(2, '41060860800469061877'); + + $subOrderList[] = $ccb->subOrderListItem( + '41060860800469061877', + '151152', + '1.23', + '1.23', + 'F410608608004691879', + $parList + ); + + $parList = []; + $parList[] = $ccb->parListItem(1, '41060860800469000000'); + $parList[] = $ccb->parListItem(2, '41060860800469061878'); + + $subOrderList[] = $ccb->subOrderListItem( + '41060860800469061878', + '151153', + '1.00', + '1.00', + 'F410608608004691879', + $parList + ); + + $result = $ccb->gatherPlaceorder( + 'c2020060915410278957', + '2.23', + '2.23', + '03', + '', + '', + $subOrderList + ); + + var_export($result); + } + + public function testQuery() + { + $ccb = ApplicationContext::getContainer()->get(CCBPayment::class); + + $result = $ccb->gatherEnquireOrder('c2020060915410278956'); + + var_dump($result); + } +} \ No newline at end of file