693 行
14 KiB
PHP
693 行
14 KiB
PHP
<?php
|
||
|
||
|
||
|
||
namespace addons\shopro_huifu\library;
|
||
|
||
|
||
|
||
use BsPaySdk\core\BsPayTools;
|
||
|
||
use think\Log;
|
||
|
||
|
||
|
||
class HuifuClient
|
||
|
||
{
|
||
|
||
protected $config;
|
||
|
||
|
||
|
||
public function __construct()
|
||
|
||
{
|
||
|
||
$this->config = HuifuConfig::getInstance();
|
||
|
||
SdkLoader::boot();
|
||
|
||
}
|
||
|
||
|
||
|
||
public function payment()
|
||
|
||
{
|
||
|
||
return new \BsPaySdk\core\Payment();
|
||
|
||
}
|
||
|
||
|
||
|
||
public function postRequest($request, $tag = false)
|
||
|
||
{
|
||
|
||
$client = new \BsPaySdk\core\BsPayClient();
|
||
|
||
try {
|
||
$result = $client->postRequest($request, $tag);
|
||
} catch (\Throwable $e) {
|
||
Log::error('[shopro_huifu] sdk postRequest exception: ' . $e->getMessage());
|
||
throw new \RuntimeException('汇付 SDK 请求异常:' . $e->getMessage(), 0, $e);
|
||
}
|
||
|
||
if (!$result) {
|
||
Log::error('[shopro_huifu] sdk postRequest returned null');
|
||
}
|
||
|
||
return $result;
|
||
|
||
}
|
||
|
||
|
||
|
||
public function verifyNotify($respData, $sign)
|
||
|
||
{
|
||
|
||
if (!$respData || !$sign) {
|
||
|
||
return false;
|
||
|
||
}
|
||
|
||
$sign = HuifuNotifyParser::normalizeSign($sign);
|
||
|
||
return BsPayTools::verifySign($sign, $respData, $this->config->getPublicKey()) === 1;
|
||
|
||
}
|
||
|
||
|
||
|
||
public function parseResponse($result)
|
||
|
||
{
|
||
|
||
return $this->extractResponseData($result, true);
|
||
|
||
}
|
||
|
||
|
||
|
||
public function extractResponseData($result, $assertSuccess = false)
|
||
|
||
{
|
||
|
||
if (!$result || $result->isError()) {
|
||
|
||
$message = self::formatSdkError($result);
|
||
|
||
Log::error('[shopro_huifu] sdk-error: ' . $message);
|
||
|
||
throw new \RuntimeException($message);
|
||
|
||
}
|
||
|
||
$datas = $result->getRspDatas();
|
||
|
||
$data = $this->normalizeBusinessData($datas['data'] ?? $datas);
|
||
|
||
if ($assertSuccess) {
|
||
|
||
$this->assertBusinessSuccess($data);
|
||
|
||
}
|
||
|
||
return $data;
|
||
|
||
}
|
||
|
||
|
||
|
||
public function safeApiResult($result)
|
||
|
||
{
|
||
|
||
if (!$result || $result->isError()) {
|
||
|
||
return [
|
||
|
||
'ok' => false,
|
||
|
||
'message' => self::formatSdkError($result),
|
||
|
||
'data' => null,
|
||
|
||
];
|
||
|
||
}
|
||
|
||
$data = $this->extractResponseData($result, false);
|
||
|
||
return [
|
||
|
||
'ok' => true,
|
||
|
||
'message' => (string) ($data['resp_desc'] ?? 'OK'),
|
||
|
||
'data' => $data,
|
||
|
||
];
|
||
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
|
||
* 将 SDK 错误转为可读诊断信息($huifu-pay-integration / copilot-troubleshooting-playbooks)
|
||
|
||
*/
|
||
|
||
public static function formatSdkError($result)
|
||
|
||
{
|
||
|
||
if (!$result) {
|
||
|
||
return '汇付 SDK 无响应(请检查 huifu_id、skill_source 及图片上传参数)';
|
||
|
||
}
|
||
|
||
|
||
|
||
$error = $result->getErrorInfo();
|
||
|
||
$code = is_array($error) ? (string) ($error['code'] ?? '') : '';
|
||
|
||
$msg = is_array($error) ? (string) ($error['msg'] ?? '汇付 SDK 错误') : (string) $error;
|
||
|
||
$http = method_exists($result, 'getHttpStateCode') ? (int) $result->getHttpStateCode() : 0;
|
||
|
||
|
||
|
||
$parts = [$msg];
|
||
|
||
if ($code !== '') {
|
||
|
||
$parts[] = 'code=' . $code;
|
||
|
||
}
|
||
|
||
if ($http > 0) {
|
||
|
||
$parts[] = 'http=' . $http;
|
||
|
||
}
|
||
|
||
|
||
|
||
$rsp = method_exists($result, 'getRspDatas') ? $result->getRspDatas() : null;
|
||
|
||
if (is_array($rsp)) {
|
||
|
||
if (empty($rsp['sign'])) {
|
||
|
||
$parts[] = '响应缺少 sign 字段';
|
||
|
||
}
|
||
|
||
$inner = $rsp['data'] ?? null;
|
||
|
||
if (is_string($inner) && $inner !== '') {
|
||
|
||
$decoded = json_decode($inner, true);
|
||
|
||
if (is_array($decoded)) {
|
||
|
||
if (!empty($decoded['resp_desc'])) {
|
||
|
||
$parts[] = '汇付提示=' . $decoded['resp_desc'];
|
||
|
||
}
|
||
|
||
if (!empty($decoded['resp_code'])) {
|
||
|
||
$parts[] = 'resp_code=' . $decoded['resp_code'];
|
||
|
||
}
|
||
|
||
} else {
|
||
|
||
$parts[] = 'data片段=' . self::previewText($inner);
|
||
|
||
}
|
||
|
||
} elseif (is_array($inner) && !empty($inner['resp_desc'])) {
|
||
|
||
$parts[] = '汇付提示=' . $inner['resp_desc'];
|
||
|
||
}
|
||
|
||
} elseif (is_string($rsp) && $rsp !== '') {
|
||
|
||
$parts[] = 'body片段=' . self::previewText($rsp);
|
||
|
||
}
|
||
|
||
|
||
|
||
if ($code === 'RESP_SIGN_VERIFY_UNDO' || $http === 401) {
|
||
|
||
$parts[] = '排查: 1) 后台 env 与 sys_id/product_id/商户私钥必须同一环境(测试/生产不能混用)'
|
||
|
||
. ' 2) 确认 SdkLoader 已按 env 设置 PROD_MODE'
|
||
|
||
. ' 3) 商户私钥须为汇付控台商户RSA私钥'
|
||
|
||
. ' 4) 汇付RSA公钥须为汇付平台公钥(不是商户公钥)';
|
||
|
||
}
|
||
|
||
if ($code === 'RESP_SIGN_VERIFY_FAILED') {
|
||
|
||
$parts[] = '排查: 汇付RSA公钥配置错误,请粘贴汇付控台「汇付公钥」而非商户公钥';
|
||
|
||
}
|
||
|
||
|
||
|
||
return implode(';', $parts);
|
||
|
||
}
|
||
|
||
|
||
|
||
protected static function previewText($text)
|
||
|
||
{
|
||
|
||
$text = preg_replace('/\s+/', ' ', (string) $text);
|
||
|
||
if (function_exists('mb_substr')) {
|
||
|
||
return mb_substr($text, 0, 120);
|
||
|
||
}
|
||
|
||
return substr($text, 0, 120);
|
||
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
|
||
* 汇付同步应答的 data 可能是 array,也可能是 JSON 字符串。
|
||
|
||
*/
|
||
|
||
public function normalizeBusinessData($data)
|
||
|
||
{
|
||
|
||
if (is_string($data)) {
|
||
|
||
$decoded = json_decode($data, true);
|
||
|
||
if (is_array($decoded)) {
|
||
|
||
$data = $decoded;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
if (!is_array($data)) {
|
||
|
||
throw new \RuntimeException('汇付返回 data 格式异常');
|
||
|
||
}
|
||
|
||
return $data;
|
||
|
||
}
|
||
|
||
|
||
|
||
public function assertBusinessSuccess(array $data)
|
||
|
||
{
|
||
|
||
$respCode = (string) ($data['resp_code'] ?? '');
|
||
|
||
$transStat = (string) ($data['trans_stat'] ?? '');
|
||
|
||
// $huifu-pay-integration: 00000100=下单受理/处理中,T_MINIAPP 同步常见,不等于失败
|
||
|
||
$acceptedCodes = ['00000000', '00000100'];
|
||
|
||
if ($respCode !== '' && !in_array($respCode, $acceptedCodes, true)) {
|
||
|
||
throw new \RuntimeException($this->formatBusinessError($data));
|
||
|
||
}
|
||
|
||
|
||
|
||
if ($transStat === 'F') {
|
||
|
||
throw new \RuntimeException($this->formatBusinessError($data, '交易失败'));
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
protected function formatBusinessError(array $data, $prefix = '汇付业务失败')
|
||
|
||
{
|
||
|
||
$parts = [$prefix];
|
||
|
||
if (!empty($data['resp_desc'])) {
|
||
|
||
$parts[] = (string) $data['resp_desc'];
|
||
|
||
}
|
||
|
||
if (!empty($data['bank_message'])) {
|
||
|
||
$parts[] = (string) $data['bank_message'];
|
||
|
||
}
|
||
|
||
if (!empty($data['resp_code'])) {
|
||
|
||
$parts[] = 'resp_code=' . $data['resp_code'];
|
||
|
||
}
|
||
|
||
if (!empty($data['trans_stat'])) {
|
||
|
||
$parts[] = 'trans_stat=' . $data['trans_stat'];
|
||
|
||
}
|
||
|
||
$respCode = (string) ($data['resp_code'] ?? '');
|
||
|
||
if ($respCode === '22000005' && strpos((string) ($data['resp_desc'] ?? ''), '入驻') !== false) {
|
||
|
||
$parts[] = '排查: 1) 插件 huifu_id 须与控台该微信入驻商户一致 2) 渠道模式 sys_id=渠道商号、huifu_id=子商户号、私钥用渠道商 3) pay_scene:01=线上/02=线下或留空 4) 跑 php think shopro_huifu:probe-wechat 查汇付侧 AppID 绑定';
|
||
|
||
}
|
||
|
||
return implode(',', $parts);
|
||
|
||
}
|
||
|
||
|
||
|
||
public function logBusinessData(array $data, $tag = 'huifu-response')
|
||
|
||
{
|
||
|
||
$safe = [
|
||
|
||
'resp_code' => $data['resp_code'] ?? '',
|
||
|
||
'resp_desc' => $data['resp_desc'] ?? '',
|
||
|
||
'trans_stat' => $data['trans_stat'] ?? '',
|
||
|
||
'bank_message' => $data['bank_message'] ?? '',
|
||
|
||
'hf_seq_id' => $data['hf_seq_id'] ?? '',
|
||
|
||
'has_pay_info' => !empty($data['pay_info']),
|
||
|
||
'keys' => array_keys($data),
|
||
|
||
];
|
||
|
||
Log::error('[shopro_huifu] ' . $tag . ': ' . json_encode($safe, JSON_UNESCAPED_UNICODE));
|
||
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
|
||
* 用一笔不存在的订单做查询,验证「请求签名 + 应答验签 + 网关环境」是否打通。
|
||
|
||
*/
|
||
|
||
public function probeSignedApi()
|
||
|
||
{
|
||
|
||
SdkLoader::requireRequest('v4/payment/TradePaymentScanpayQueryRequest.php');
|
||
|
||
$request = new \BsPaySdk\request\v4\payment\TradePaymentScanpayQueryRequest();
|
||
|
||
$request->setHuifuId($this->config->get('huifu_id'));
|
||
|
||
$request->setReqDate(date('Ymd'));
|
||
|
||
$request->setReqSeqId('PROBE' . date('His') . mt_rand(1000, 9999));
|
||
|
||
|
||
|
||
$result = $this->payment()->query($request);
|
||
|
||
if (!$result || $result->isError()) {
|
||
|
||
return [
|
||
|
||
'ok' => false,
|
||
|
||
'message' => self::formatSdkError($result),
|
||
|
||
];
|
||
|
||
}
|
||
|
||
|
||
|
||
$data = $this->extractResponseData($result, false);
|
||
|
||
$respCode = (string) ($data['resp_code'] ?? '');
|
||
|
||
$respDesc = (string) ($data['resp_desc'] ?? '');
|
||
|
||
// 探针查单:订单不存在 = 签名/验签/网关已打通
|
||
|
||
if ($respCode === '23000001' || strpos($respDesc, '交易不存在') !== false || strpos($respDesc, '订单不存在') !== false) {
|
||
|
||
return [
|
||
|
||
'ok' => true,
|
||
|
||
'message' => '应答验签通过(探针订单不存在属正常),resp_code=' . $respCode,
|
||
|
||
'data' => $data,
|
||
|
||
];
|
||
|
||
}
|
||
|
||
return [
|
||
|
||
'ok' => true,
|
||
|
||
'message' => '应答验签通过,resp_code=' . $respCode . ',' . $respDesc,
|
||
|
||
'data' => $data,
|
||
|
||
];
|
||
|
||
}
|
||
|
||
|
||
|
||
public function queryWechatConfig($bankChannelNo = '')
|
||
|
||
{
|
||
|
||
SdkLoader::requireRequest('V2MerchantBusiConfigQueryRequest.php');
|
||
|
||
$request = new \BsPaySdk\request\V2MerchantBusiConfigQueryRequest();
|
||
|
||
$request->setHuifuId($this->config->get('huifu_id'));
|
||
|
||
$request->setReqDate(date('Ymd'));
|
||
|
||
$request->setReqSeqId('WXCFG' . date('YmdHis') . mt_rand(1000, 9999));
|
||
|
||
if ($bankChannelNo !== '') {
|
||
|
||
$request->setExtendInfo(['bank_channel_no' => $bankChannelNo]);
|
||
|
||
}
|
||
|
||
return $this->safeApiResult($this->postRequest($request));
|
||
|
||
}
|
||
|
||
|
||
|
||
public function queryWechatMercode($feeType = '01')
|
||
|
||
{
|
||
|
||
SdkLoader::requireRequest('V2MerchantBusiMercodeQueryRequest.php');
|
||
|
||
$request = new \BsPaySdk\request\V2MerchantBusiMercodeQueryRequest();
|
||
|
||
$request->setHuifuId($this->config->get('huifu_id'));
|
||
|
||
$request->setReqDate(date('Ymd'));
|
||
|
||
$request->setReqSeqId('WXMID' . date('YmdHis') . mt_rand(1000, 9999));
|
||
|
||
$request->setPayWay('W');
|
||
|
||
$request->setExtendInfo(['fee_type' => $feeType]);
|
||
|
||
return $this->safeApiResult($this->postRequest($request));
|
||
|
||
}
|
||
|
||
|
||
|
||
public function probeMiniAppCreate($subOpenid, $subAppid = '', $amount = '0.01')
|
||
|
||
{
|
||
|
||
SdkLoader::requireRequest('v4/payment/TradePaymentCreateRequest.php');
|
||
|
||
SdkLoader::requireRequest('v4/WxData.php');
|
||
|
||
$subOpenid = trim((string) $subOpenid);
|
||
|
||
$subAppid = trim((string) $subAppid);
|
||
|
||
if ($subAppid === '') {
|
||
|
||
$subAppid = Bridge::getMiniProgramAppId();
|
||
|
||
}
|
||
|
||
if ($subOpenid === '' || $subAppid === '') {
|
||
|
||
return ['ok' => false, 'message' => '缺少 sub_openid 或 sub_appid', 'data' => null];
|
||
|
||
}
|
||
|
||
$request = new \BsPaySdk\request\v4\payment\TradePaymentCreateRequest();
|
||
|
||
$request->setReqDate(date('Ymd'));
|
||
|
||
$request->setReqSeqId('PROBE' . date('YmdHis') . mt_rand(1000, 9999));
|
||
|
||
$request->setHuifuId($this->config->get('huifu_id'));
|
||
|
||
$request->setTradeType('T_MINIAPP');
|
||
|
||
$request->setTransAmt(number_format((float) $amount, 2, '.', ''));
|
||
|
||
$request->setGoodsDesc('汇付探针订单');
|
||
|
||
$wxData = new \BsPaySdk\request\v4\WxData();
|
||
|
||
$wxData->setSubAppid($subAppid);
|
||
|
||
$wxData->setSubOpenid($subOpenid);
|
||
|
||
$wxData->setBody('汇付探针订单');
|
||
|
||
$request->setMethodExpand(\BsPaySdk\core\Payment::arrayToJsonString($wxData));
|
||
|
||
$payScene = trim((string) $this->config->get('pay_scene'));
|
||
|
||
$channelNo = trim((string) $this->config->get('channel_no'));
|
||
|
||
if ($payScene !== '') {
|
||
|
||
$request->setPayScene($payScene);
|
||
|
||
}
|
||
|
||
if ($channelNo !== '') {
|
||
|
||
$request->setChannelNo($channelNo);
|
||
|
||
}
|
||
|
||
$feeSign = trim((string) $this->config->get('fee_sign'));
|
||
|
||
if ($feeSign !== '') {
|
||
|
||
$request->setFeeFlag($feeSign);
|
||
|
||
}
|
||
|
||
try {
|
||
|
||
$result = $this->payment()->create($request);
|
||
|
||
$data = $this->extractResponseData($result, true);
|
||
|
||
return ['ok' => true, 'message' => '下单成功', 'data' => $data];
|
||
|
||
} catch (\Exception $e) {
|
||
|
||
return ['ok' => false, 'message' => $e->getMessage(), 'data' => null];
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
public static function analyzeCredentials(HuifuConfig $config)
|
||
|
||
{
|
||
|
||
$sysId = trim((string) $config->get('sys_id'));
|
||
|
||
$huifuId = trim((string) $config->get('huifu_id'));
|
||
|
||
$upper = trim((string) $config->get('upper_huifu_id'));
|
||
|
||
$lines = [];
|
||
|
||
if ($sysId === '' || $huifuId === '') {
|
||
|
||
$lines[] = '[FAIL] sys_id 或 huifu_id 为空';
|
||
|
||
return $lines;
|
||
|
||
}
|
||
|
||
if ($sysId === $huifuId) {
|
||
|
||
$lines[] = '[OK] 直连模式:sys_id 与 huifu_id 相同,私钥应为该商户密钥';
|
||
|
||
} else {
|
||
|
||
$lines[] = '[INFO] 渠道模式:sys_id(' . $sysId . ') ≠ huifu_id(' . $huifuId . ')';
|
||
|
||
$lines[] = ' 私钥通常填渠道商(sys_id)密钥;huifu_id 填实际收款子商户';
|
||
|
||
if ($upper === '') {
|
||
|
||
$lines[] = '[WARN] upper_huifu_id 为空,若报入驻/权限错误请补渠道商号';
|
||
|
||
}
|
||
|
||
}
|
||
|
||
return $lines;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|