文件
xionggeshop/addons/shopro_huifu/library/HuifuClient.php
可乐加冰 e946a357c1 ```
feat(shopro_huifu): 添加分账配置提交功能和接收方绑定机制

- 实现 shouldDeferWalletUntilGatewaySplitResolves 方法,确保分销/区域结算事件早于
  split_timing 时等待分账结束再入钱包
- 新增 SyncSplitReceivers 命令用于提交商户分账配置并绑定已审核通过的汇付号为分账接收方
- 扩展 HuifuClient 添加 submitMerchantSplitConfig 接口实现 v2/merchant/split/config 调用
- 创建 HuifuMerchantSplitService 处理主商户分账配置提交和进件子户绑定为分账接收方逻辑
- 在进件成功后自动调用 split/config 接口绑定分账接收方
- 添加数据库表结构升级脚本,增加分账绑定状态相关字段
- 注册新的命令行工具到应用配置中
```
2026-10-13 10:04:10 +08:00

874 行
20 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 绑定';
}
if ($respCode === '22000002') {
$parts[] = '排查: 1) 控台主商户已开通延时分账 2) 子商户 huifu_id 已绑为分账接收方 3) acct_infos 勿含收款主商户(剩余款留主商户)4) platform_huifu_id=主商户时仅分出推广员等子户 5) receiver 为进件 huifu_id';
}
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));
}
/**
* 商户分账配置查询 v2/merchant/split/query(含 rule_origin、div_flag、apply_ratio)
*/
public function queryMerchantSplitConfig($huifuId = '')
{
$huifuId = trim((string) $huifuId);
if ($huifuId === '') {
$huifuId = trim((string) $this->config->get('huifu_id'));
}
if ($huifuId === '') {
return ['ok' => false, 'message' => '缺少 huifu_id', 'data' => null];
}
SdkLoader::requireRequest('V2MerchantSplitQueryRequest.php');
$request = new \BsPaySdk\request\V2MerchantSplitQueryRequest();
$request->setHuifuId($huifuId);
$request->setReqDate(date('Ymd'));
$request->setReqSeqId('SPLQ' . date('YmdHis') . mt_rand(1000, 9999));
$productId = trim((string) $this->config->get('product_id'));
if ($productId !== '') {
$request->setExtendInfo(['product_id' => $productId]);
}
return $this->safeApiResult($this->postRequest($request));
}
/**
* 商户分账配置提交 v2/merchant/split/config
*
* @param array<string,mixed> $extend rule_origin、div_flag、apply_ratio、acct_split_bunch_list 等
*/
public function submitMerchantSplitConfig($huifuId, array $extend)
{
$huifuId = trim((string) $huifuId);
if ($huifuId === '') {
return ['ok' => false, 'message' => '缺少 huifu_id', 'data' => null];
}
SdkLoader::requireRequest('V2MerchantSplitConfigRequest.php');
$request = new \BsPaySdk\request\V2MerchantSplitConfigRequest();
$request->setHuifuId($huifuId);
$request->setReqDate(date('Ymd'));
$request->setReqSeqId('SPLCFG' . date('YmdHis') . mt_rand(1000, 9999));
$productId = trim((string) ($extend['product_id'] ?? $this->config->get('product_id')));
if ($productId !== '' && !isset($extend['product_id'])) {
$extend['product_id'] = $productId;
}
$request->setExtendInfo($extend);
\think\Log::write('[shopro_huifu] split/config request huifu_id=' . $huifuId
. ' extend=' . json_encode($extend, JSON_UNESCAPED_UNICODE), 'info');
return $this->safeApiResult($this->postRequest($request));
}
/**
* 商户详细信息查询(传 product_id 时可含 delay_flag 等业务开通字段)
*/
public function queryMerchantBasicdata($huifuId = '', $withProductId = true)
{
$huifuId = trim((string) $huifuId);
if ($huifuId === '') {
$huifuId = trim((string) $this->config->get('huifu_id'));
}
if ($huifuId === '') {
return ['ok' => false, 'message' => '缺少 huifu_id', 'data' => null];
}
SdkLoader::requireRequest('V2MerchantBasicdataQueryRequest.php');
$request = new \BsPaySdk\request\V2MerchantBasicdataQueryRequest();
$request->setHuifuId($huifuId);
$request->setReqDate(date('Ymd'));
$request->setReqSeqId('MBDQ' . date('YmdHis') . mt_rand(1000, 9999));
if ($withProductId) {
$productId = trim((string) $this->config->get('product_id'));
if ($productId !== '') {
$request->setExtendInfo(['product_id' => $productId]);
}
}
return $this->safeApiResult($this->postRequest($request));
}
/**
* 在汇付应答中递归查找指定键(用于 split 配置可能在嵌套 JSON 字符串中)
*
* @return array<string, mixed>
*/
public static function collectNestedKeys($payload, array $keys)
{
$found = [];
self::walkNestedPayload($payload, $keys, $found);
return $found;
}
protected static function walkNestedPayload($node, array $keys, array &$found)
{
if (is_string($node)) {
$trim = trim($node);
if ($trim !== '' && ($trim[0] === '{' || $trim[0] === '[')) {
$decoded = json_decode($trim, true);
if (is_array($decoded)) {
self::walkNestedPayload($decoded, $keys, $found);
}
}
return;
}
if (!is_array($node)) {
return;
}
foreach ($node as $k => $v) {
if (in_array($k, $keys, true) && $v !== '' && $v !== null && !isset($found[$k])) {
$found[$k] = $v;
}
self::walkNestedPayload($v, $keys, $found);
}
}
/**
* 解析 split_bill_result(可能是 array 或 JSON 字符串)
*
* @return array<int, array<string, mixed>>
*/
public static function parseSplitBillResult($raw)
{
if ($raw === null || $raw === '') {
return [];
}
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [];
}
$raw = $decoded;
}
if (!is_array($raw)) {
return [];
}
if (isset($raw['rule_origin']) || isset($raw['div_flag'])) {
return [$raw];
}
$list = [];
foreach ($raw as $item) {
if (is_array($item)) {
$list[] = $item;
}
}
return $list;
}
public static function describeRuleOrigin($code)
{
$code = trim((string) $code);
if ($code === '01') {
return '01 接口发起(交易确认可传 acct_split_bunch)';
}
if ($code === '02') {
return '02 控台配置(接口带分账串易 22000002,以控台规则为准)';
}
return $code !== '' ? $code : '(未返回)';
}
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;
}
}