feat(shopro_huifu): 添加分账配置提交功能和接收方绑定机制

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

查看文件

@@ -22,20 +22,52 @@ class SplitWalletSettlement
{
public static function shouldDeferCommissionWallet($order, $event)
{
if (!self::shouldDeferForGatewaySplit($order, $event)) {
$commissionEvent = (new CommissionConfig())->getCommissionEvent();
if ($commissionEvent !== $event && $event !== 'admin') {
return false;
}
$commissionEvent = (new CommissionConfig())->getCommissionEvent();
return $commissionEvent === $event;
return self::shouldDeferWalletUntilGatewaySplitResolves($order);
}
public static function shouldDeferRegionalWallet($order, $event)
{
if (!self::shouldDeferForGatewaySplit($order, $event)) {
$regionalEvent = (new RegionalConfig())->getCommissionEvent();
if ($regionalEvent !== $event && $event !== 'admin') {
return false;
}
$regionalEvent = (new RegionalConfig())->getCommissionEvent();
return $regionalEvent === $event;
return self::shouldDeferWalletUntilGatewaySplitResolves($order);
}
/**
* 分销/区域结算事件(如 payed)早于 split_timing(如 confirm)时,仍应等分账结束再入钱包
*/
protected static function shouldDeferWalletUntilGatewaySplitResolves($order)
{
if (is_numeric($order)) {
$order = Order::get($order);
}
if (!$order) {
return false;
}
$config = new Config();
if (!$config->isEnable() || !$config->shouldSkipWallet()) {
return false;
}
if (self::isSplitSuccessful($order['id'])) {
return false;
}
if (class_exists('\\addons\\shopro_huifu\\library\\Bridge')
&& \addons\shopro_huifu\library\Bridge::shouldHandleSplit($order)) {
return true;
}
if (in_array($config->getMode(), ['wechat', 'both'], true) && $order['pay_type'] === 'wechat') {
return true;
}
return false;
}
/**

查看文件

@@ -0,0 +1,84 @@
<?php
namespace addons\shopro_huifu\command;
use addons\shopro_huifu\library\Bridge;
use addons\shopro_huifu\library\HuifuClient;
use addons\shopro_huifu\library\split\HuifuMerchantSplitService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
class SyncSplitReceivers extends Command
{
protected function configure()
{
$this->setName('shopro_huifu:sync-split-receivers')
->addOption('dry-run', null, Option::VALUE_NONE, '仅预览将提交的接收方与参数,不调汇付')
->addOption('init-only', null, Option::VALUE_NONE, '仅提交主商户分账开关(rule_origin=01),不带接收方列表')
->addOption('huifu-id', null, Option::VALUE_OPTIONAL, '主商户汇付号,默认 platform_huifu_id 或插件 huifu_id')
->setDescription('Submit v2/merchant/split/config and bind approved onboard huifu_ids as split receivers');
}
protected function execute(Input $input, Output $output)
{
if (!Bridge::isEnabled()) {
$output->writeln('[FAIL] 汇付插件未启用');
return 1;
}
$service = new HuifuMerchantSplitService();
$main = trim((string) $input->getOption('huifu-id'));
if ($main === '') {
$main = $service->resolveMainHuifuId();
}
$output->writeln('主商户 huifu_id: ' . $main);
$output->writeln('apply_ratio(最大分账比例%): ' . $service->resolveApplyRatio());
$dryRun = (bool) $input->getOption('dry-run');
$initOnly = (bool) $input->getOption('init-only');
$options = [
'dry_run' => $dryRun,
'main_huifu_id' => $main,
];
if ($initOnly) {
$output->writeln('模式: init-only(不带 acct_split_bunch_list)');
$result = $service->submitSplitConfig([], $options);
} else {
$output->writeln('模式: 全量(远端已绑 + 本地已进件 approved)');
$result = $service->syncAllApprovedReceivers($options);
}
if (!empty($result['dry_run'])) {
$output->writeln('[DRY-RUN] 将提交接收方 ' . count($result['receivers'] ?? []) . ' 个');
$output->writeln(json_encode($result['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
return 0;
}
$output->writeln('resp: ' . ($result['message'] ?? ''));
if (isset($result['resp_code'])) {
$output->writeln('resp_code: ' . $result['resp_code']);
}
if (isset($result['submitted'])) {
$output->writeln('提交接收方数量: ' . $result['submitted']
. '(远端已有 ' . ($result['remote'] ?? 0) . ',本地进件 ' . ($result['local'] ?? 0) . ')');
}
if (!empty($result['receivers'])) {
foreach ($result['receivers'] as $hid) {
$output->writeln(' - ' . $hid);
}
}
if (empty($result['ok'])) {
$output->writeln('[FAIL] 提交未成功,请查看日志 [shopro_huifu] split/config');
return 1;
}
$output->writeln('');
$output->writeln('下一步: php think shopro_huifu:query-split-config --huifu-id=' . $main);
$output->writeln('(配置若走异步审核,需等待生效后再测 delaytrans/confirm)');
return 0;
}
}

查看文件

@@ -587,6 +587,36 @@ class HuifuClient
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));
}
/**

查看文件

@@ -27,6 +27,7 @@ class Installer
self::importSqlFile($addonDir . 'upgrade_huifu_split.sql', $output, true);
self::importSqlFile($addonDir . 'upgrade_huifu_onboard.sql', $output, true);
self::importSqlFile($addonDir . 'upgrade_huifu_onboard_account.sql', $output, true);
self::importSqlFile($addonDir . 'upgrade_huifu_split_bind.sql', $output, true);
self::upgradePayTypeColumn($output);
self::log($output, '注册汇付/分账菜单 ...');
@@ -170,6 +171,7 @@ class Installer
self::importSqlFile($addonDir . 'upgrade_huifu_split.sql', $output, true);
self::importSqlFile($addonDir . 'upgrade_huifu_onboard.sql', $output, true);
self::importSqlFile($addonDir . 'upgrade_huifu_onboard_account.sql', $output, true);
self::importSqlFile($addonDir . 'upgrade_huifu_split_bind.sql', $output, true);
self::upgradePayTypeColumn($output);
}

查看文件

@@ -42,10 +42,11 @@ class HuifuOnboardReceiverSync
$existing->status = 'normal';
$existing->updatetime = $now;
$existing->save();
self::bindHuifuSplitReceiver($row);
return $existing;
}
return Receiver::create([
$created = Receiver::create([
'role_type' => $row['role_type'],
'role_id' => $roleId,
'channel' => 'huifu',
@@ -58,6 +59,23 @@ class HuifuOnboardReceiverSync
'createtime' => $now,
'updatetime' => $now,
]);
self::bindHuifuSplitReceiver($row);
return $created;
}
/**
* 进件通过后向汇付主商户提交 split/config,绑定子户为分账接收方
*/
protected static function bindHuifuSplitReceiver(Onboard $row)
{
if (!class_exists('\\addons\\shopro_huifu\\library\\split\\HuifuMerchantSplitService')) {
return;
}
try {
(new \addons\shopro_huifu\library\split\HuifuMerchantSplitService())->bindOnboardReceiver($row);
} catch (\Throwable $e) {
\think\Log::error('[shopro_huifu] onboard split bind: ' . $e->getMessage());
}
}
public static function bindOnboardId($roleType, $roleRefId, $onboardId)

查看文件

@@ -0,0 +1,50 @@
<?php
namespace BsPaySdk\request;
use BsPaySdk\enums\FunctionCodeEnum;
/**
* 商户分账配置 v2/merchant/split/config
*/
class V2MerchantSplitConfigRequest extends BaseRequest
{
private $reqSeqId;
private $reqDate;
private $huifuId;
public function getFunctionCode()
{
return FunctionCodeEnum::$V2_MERCHANT_SPLIT_CONFIG;
}
public function getReqSeqId()
{
return $this->reqSeqId;
}
public function setReqSeqId($reqSeqId)
{
$this->reqSeqId = $reqSeqId;
}
public function getReqDate()
{
return $this->reqDate;
}
public function setReqDate($reqDate)
{
$this->reqDate = $reqDate;
}
public function getHuifuId()
{
return $this->huifuId;
}
public function setHuifuId($huifuId)
{
$this->huifuId = $huifuId;
}
}

查看文件

@@ -0,0 +1,351 @@
<?php
namespace addons\shopro_huifu\library\split;
use addons\shopro_huifu\library\Bridge;
use addons\shopro_huifu\library\HuifuClient;
use addons\shopro_huifu\library\HuifuConfig;
use addons\shopro_huifu\model\Onboard;
use think\Db;
use think\Log;
/**
* 主商户分账配置提交 + 进件子户绑定为分账接收方(v2/merchant/split/config)
*/
class HuifuMerchantSplitService
{
/** @var HuifuClient */
protected $client;
/** @var HuifuConfig */
protected $config;
public function __construct(HuifuClient $client = null)
{
$this->client = $client ?: new HuifuClient();
$this->config = HuifuConfig::getInstance();
}
public function isEnabled()
{
return Bridge::isEnabled();
}
public function resolveMainHuifuId()
{
try {
$platform = trim((string) Db::name('shopro_split_config')->where('name', 'platform_huifu_id')->value('value'));
if ($platform !== '') {
return $platform;
}
} catch (\Exception $e) {
// ignore
}
return trim((string) $this->config->get('huifu_id'));
}
public function resolveApplyRatio()
{
$rate = 30.0;
try {
$rate = floatval(Db::name('shopro_split_config')->where('name', 'split_max_rate')->value('value'));
} catch (\Exception $e) {
// ignore
}
if ($rate <= 0 || $rate > 100) {
$rate = 30.0;
}
return number_format($rate, 2, '.', '');
}
/**
* @return string[]
*/
public function collectApprovedReceiverHuifuIds()
{
$main = $this->resolveMainHuifuId();
$rows = Onboard::where('onboard_status', Onboard::STATUS_APPROVED)
->where('huifu_id', '<>', '')
->whereNotNull('huifu_id')
->field('huifu_id')
->select();
$ids = [];
foreach ($rows as $row) {
$hid = trim((string) $row['huifu_id']);
if ($hid === '' || $hid === $main) {
continue;
}
$ids[$hid] = $hid;
}
return array_values($ids);
}
/**
* @return string[]
*/
public function fetchRemoteReceiverHuifuIds($mainHuifuId = null)
{
$mainHuifuId = $mainHuifuId ?: $this->resolveMainHuifuId();
$result = $this->client->queryMerchantSplitConfig($mainHuifuId);
if (!$result['ok']) {
return [];
}
$items = HuifuClient::parseSplitBillResult($result['data']['split_bill_result'] ?? null);
$ids = [];
foreach ($items as $item) {
$receivers = $this->parseAcctSplitBunchList($item['acct_split_bunch_list'] ?? null);
foreach ($receivers as $r) {
$hid = trim((string) ($r['huifu_id'] ?? ''));
if ($hid !== '') {
$ids[$hid] = $hid;
}
}
}
return array_values($ids);
}
/**
* @param string[] $receiverHuifuIds
* @return array{ok:bool,message:string,data:?array,dry_run?:bool,receivers?:string[]}
*/
public function submitSplitConfig(array $receiverHuifuIds, array $options = [])
{
$dryRun = !empty($options['dry_run']);
$mainHuifuId = trim((string) ($options['main_huifu_id'] ?? $this->resolveMainHuifuId()));
if ($mainHuifuId === '') {
throw new \InvalidArgumentException('缺少主商户 huifu_id');
}
$applyRatio = (string) ($options['apply_ratio'] ?? $this->resolveApplyRatio());
$ruleOrigin = trim((string) ($options['rule_origin'] ?? '01'));
if (!in_array($ruleOrigin, ['01', '02'], true)) {
$ruleOrigin = '01';
}
$receiverHuifuIds = $this->normalizeReceiverIds($receiverHuifuIds, $mainHuifuId);
$extend = [
'product_id' => trim((string) $this->config->get('product_id')),
'rule_origin' => $ruleOrigin,
'repeal_flag' => (string) ($options['repeal_flag'] ?? 'Y'),
'refund_flag' => (string) ($options['refund_flag'] ?? 'Y'),
'div_flag' => (string) ($options['div_flag'] ?? 'Y'),
'apply_ratio' => $applyRatio,
'start_type' => (string) ($options['start_type'] ?? '0'),
];
$asyncUrl = trim((string) ($options['async_return_url'] ?? $this->config->get('split_config_async_url', '')));
if ($asyncUrl !== '') {
$extend['async_return_url'] = $asyncUrl;
}
if ($receiverHuifuIds !== []) {
$extend['acct_split_bunch_list'] = $this->encodeAcctSplitBunchList($receiverHuifuIds, $applyRatio);
} elseif ($ruleOrigin === '02') {
throw new \InvalidArgumentException('rule_origin=02 时必须提供分账接收方列表');
}
if ($dryRun) {
return [
'ok' => true,
'message' => 'dry-run',
'dry_run' => true,
'receivers' => $receiverHuifuIds,
'data' => ['extend' => $extend, 'huifu_id' => $mainHuifuId],
];
}
$result = $this->client->submitMerchantSplitConfig($mainHuifuId, $extend);
Log::write('[shopro_huifu] split/config submit main=' . $mainHuifuId
. ' receivers=' . count($receiverHuifuIds)
. ' ok=' . ($result['ok'] ? '1' : '0')
. ' msg=' . ($result['message'] ?? ''), 'info');
return $result;
}
/**
* 全量同步:合并远端 + 全部已进件子户,一次提交 split/config
*
* @return array{ok:bool,message:string,submitted:int,receivers:string[],remote:int,local:int}
*/
public function syncAllApprovedReceivers(array $options = [])
{
if (!$this->isEnabled()) {
throw new \RuntimeException('汇付插件未启用');
}
$main = $this->resolveMainHuifuId();
$local = $this->collectApprovedReceiverHuifuIds();
$remote = $this->fetchRemoteReceiverHuifuIds($main);
$merged = $this->normalizeReceiverIds(array_merge($remote, $local), $main);
$submit = $this->submitSplitConfig($merged, $options);
$ok = !empty($submit['ok']) && empty($submit['dry_run']);
$respCode = is_array($submit['data'] ?? null) ? (string) ($submit['data']['resp_code'] ?? '') : '';
$businessOk = $respCode === '' || $respCode === '00000000';
if ($ok && $businessOk && empty($options['dry_run'])) {
$this->markOnboardBindByHuifuIds($merged, 'ok', $submit['message'] ?? 'submitted');
} elseif (!$ok && empty($options['dry_run'])) {
$this->markOnboardBindByHuifuIds($local, 'fail', $submit['message'] ?? 'submit failed');
}
return [
'ok' => !empty($submit['ok']) && ($businessOk || !empty($submit['dry_run'])),
'message' => (string) ($submit['message'] ?? ''),
'submitted' => count($merged),
'receivers' => $merged,
'remote' => count($remote),
'local' => count($local),
'resp_code' => $respCode,
'data' => $submit['data'] ?? null,
];
}
/**
* 单条进件通过后:合并远端列表 + 该子户,提交 split/config
*/
public function bindOnboardReceiver(Onboard $row, array $options = [])
{
if (!$this->isEnabled()) {
return ['ok' => false, 'message' => '汇付未启用', 'skipped' => true];
}
if ($row['onboard_status'] !== Onboard::STATUS_APPROVED) {
return ['ok' => false, 'message' => '进件未通过', 'skipped' => true];
}
$hid = trim((string) ($row['huifu_id'] ?? ''));
if ($hid === '') {
return ['ok' => false, 'message' => '无 huifu_id', 'skipped' => true];
}
$main = $this->resolveMainHuifuId();
if ($hid === $main) {
$this->updateOnboardBindFields($row, 'skip', '与主商户相同,不作为分账接收方');
return ['ok' => true, 'message' => 'skip main merchant', 'skipped' => true];
}
if (!$this->shouldAutoBindOnboard()) {
return ['ok' => false, 'message' => 'split_auto_bind_receiver=0', 'skipped' => true];
}
$remote = $this->fetchRemoteReceiverHuifuIds($main);
$local = $this->collectApprovedReceiverHuifuIds();
$merged = $this->normalizeReceiverIds(array_merge($remote, $local, [$hid]), $main);
try {
$submit = $this->submitSplitConfig($merged, $options);
} catch (\Throwable $e) {
$this->updateOnboardBindFields($row, 'fail', $e->getMessage());
Log::error('[shopro_huifu] bind onboard split fail id=' . $row['id'] . ' ' . $e->getMessage());
return ['ok' => false, 'message' => $e->getMessage()];
}
$respCode = is_array($submit['data'] ?? null) ? (string) ($submit['data']['resp_code'] ?? '') : '';
$businessOk = !empty($submit['ok']) && ($respCode === '' || $respCode === '00000000');
if ($businessOk) {
$this->updateOnboardBindFields($row, 'ok', $submit['message'] ?? 'ok');
} else {
$this->updateOnboardBindFields($row, 'fail', $submit['message'] ?? 'resp_code=' . $respCode);
}
return [
'ok' => $businessOk,
'message' => (string) ($submit['message'] ?? ''),
'receivers' => $merged,
'resp_code' => $respCode,
];
}
public function shouldAutoBindOnboard()
{
$v = $this->config->get('split_auto_bind_receiver', '1');
return (string) $v !== '0';
}
/**
* @param string[] $huifuIds
*/
protected function encodeAcctSplitBunchList(array $huifuIds, $feeRate)
{
$list = [];
foreach ($huifuIds as $hid) {
$list[] = [
'huifu_id' => $hid,
'fee_rate' => $feeRate,
];
}
return json_encode($list, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* @param string[] $ids
* @return string[]
*/
protected function normalizeReceiverIds(array $ids, $mainHuifuId)
{
$mainHuifuId = trim((string) $mainHuifuId);
$out = [];
foreach ($ids as $id) {
$hid = trim((string) $id);
if ($hid === '' || $hid === $mainHuifuId) {
continue;
}
$out[$hid] = $hid;
}
ksort($out);
return array_values($out);
}
protected function parseAcctSplitBunchList($raw)
{
if ($raw === null || $raw === '') {
return [];
}
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [];
}
return $decoded;
}
return is_array($raw) ? $raw : [];
}
/**
* @param string[] $huifuIds
*/
protected function markOnboardBindByHuifuIds(array $huifuIds, $status, $message)
{
if ($huifuIds === []) {
return;
}
$now = time();
$msg = mb_substr((string) $message, 0, 480);
try {
Db::name('shopro_huifu_onboard')
->where('huifu_id', 'in', $huifuIds)
->where('onboard_status', Onboard::STATUS_APPROVED)
->update([
'split_bind_status' => $status,
'split_bind_message' => $msg,
'split_bind_time' => $now,
]);
} catch (\Exception $e) {
// 未执行 upgrade 列时忽略
}
}
protected function updateOnboardBindFields(Onboard $row, $status, $message)
{
try {
$row->split_bind_status = $status;
$row->split_bind_message = mb_substr((string) $message, 0, 480);
$row->split_bind_time = time();
$row->save();
} catch (\Exception $e) {
// 列不存在时不阻断
}
}
}

查看文件

@@ -0,0 +1,9 @@
-- 进件汇付分账接收方绑定状态(split/config 提交结果)
ALTER TABLE `__PREFIX__shopro_huifu_onboard`
ADD COLUMN `split_bind_status` varchar(20) DEFAULT NULL COMMENT 'ok|fail|skip|pending' AFTER `resp_snapshot`,
ADD COLUMN `split_bind_message` varchar(500) DEFAULT NULL COMMENT 'split/config 回执摘要' AFTER `split_bind_status`,
ADD COLUMN `split_bind_time` int(10) DEFAULT NULL COMMENT '最近绑定尝试时间' AFTER `split_bind_message`;
INSERT IGNORE INTO `__PREFIX__shopro_huifu_config` (`name`, `value`, `group`, `description`) VALUES
('split_auto_bind_receiver', '1', 'split', '进件通过后自动调 v2/merchant/split/config 绑定分账接收方(0关1开)'),
('split_config_async_url', '', 'split', '分账配置异步通知 virgo:// 地址,可选');

查看文件

@@ -25,6 +25,7 @@ return [
'addons\shopro_huifu\command\TestPayConnection',
'addons\shopro_huifu\command\ProbeWechatOnboarding',
'addons\shopro_huifu\command\QuerySplitConfig',
'addons\shopro_huifu\command\SyncSplitReceivers',
'addons\shopro_huifu\command\DiagnoseFreezeFlow',
'addons\shopro_huifu\command\OnboardPoll',
'addons\shopro_huifu\command\OnboardAutoSubmit',