文件
xionggeshop/addons/shopro_huifu/library/HuifuClient.php

693 行
14 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?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;
}
}