文件
xionggeshop/addons/shopro_huifu/library/split/HuifuMerchantSplitService.php
可乐加冰 b66b74b4f2 ```
feat(shopro_huifu): 支持汇付分账配置rule_origin双模式

支持汇付分账配置的两种模式:
- rule_origin=01:接口发起模式,不带接收方列表,接收方在confirm时指定
- rule_origin=02:控台配置模式,需带acct_split_bunch_list固定接收方

更新SyncSplitReceivers命令支持--rule-origin选项,调整分账配置提交逻辑,
新增配置项split_config_rule_origin默认为01模式,并完善相关文档说明

BREAKING CHANGE: 分账配置流程调整,需重新配置相关参数
```
2026-10-13 10:35:02 +08:00

469 行
18 KiB
PHP

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

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

<?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)
*
* rule_origin=01(接口发起):不得传 acct_split_bunch_list,接收方在 delaytrans/confirm 的 acct_split_bunch 指定
* rule_origin=02(控台配置):须传 acct_split_bunch_list 固定接收方与比例
*/
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 = $this->resolveRuleOrigin($options);
$receiverHuifuIds = $this->normalizeReceiverIds($receiverHuifuIds, $mainHuifuId);
if ($ruleOrigin === '01' && $receiverHuifuIds !== [] && empty($options['force_receiver_list'])) {
Log::write('[shopro_huifu] split/config rule_origin=01 忽略 acct_split_bunch_list('
. count($receiverHuifuIds) . ' 个接收方仅在 confirm 时指定)', 'info');
$receiverHuifuIds = [];
}
$scene = trim((string) ($options['scene'] ?? $this->resolveSplitConfigScene()));
if ($scene === '') {
throw new \InvalidArgumentException('分账模式 scene 不能为空,请在插件配置 split_config_scene 或传 --scene');
}
$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'),
'scene' => $scene,
];
$splitExtInfo = $this->buildSplitExtInfoJson($options, floatval($applyRatio), $scene);
if ($splitExtInfo !== null && $splitExtInfo !== '') {
$extend['split_ext_info'] = $splitExtInfo;
}
$asyncUrl = trim((string) ($options['async_return_url'] ?? $this->config->get('split_config_async_url', '')));
if ($asyncUrl !== '') {
$extend['async_return_url'] = $asyncUrl;
}
if ($ruleOrigin === '02') {
if ($receiverHuifuIds === []) {
throw new \InvalidArgumentException('rule_origin=02 时必须提供分账接收方列表');
}
$extend['acct_split_bunch_list'] = $this->encodeAcctSplitBunchList($receiverHuifuIds, $applyRatio);
}
if ($dryRun) {
return [
'ok' => true,
'message' => 'dry-run',
'dry_run' => true,
'receivers' => $receiverHuifuIds,
'rule_origin' => $ruleOrigin,
'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;
}
/**
* 提交分账配置:01 仅开通开关;02 时合并远端+本地进件后带 acct_split_bunch_list
*
* @return array{ok:bool,message:string,submitted:int,receivers:string[],remote:int,local:int,rule_origin?:string}
*/
public function syncAllApprovedReceivers(array $options = [])
{
if (!$this->isEnabled()) {
throw new \RuntimeException('汇付插件未启用');
}
$ruleOrigin = $this->resolveRuleOrigin($options);
$main = $this->resolveMainHuifuId();
$local = $this->collectApprovedReceiverHuifuIds();
$remote = $this->fetchRemoteReceiverHuifuIds($main);
$merged = $this->normalizeReceiverIds(array_merge($remote, $local), $main);
$toSubmit = $ruleOrigin === '02' ? $merged : [];
$submit = $this->submitSplitConfig($toSubmit, $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'])) {
if ($ruleOrigin === '02') {
$this->markOnboardBindByHuifuIds($merged, 'ok', $submit['message'] ?? 'submitted');
} else {
$this->markOnboardBindByHuifuIds($local, 'ok', 'rule_origin=01 分账配置已提交,接收方在 confirm 指定');
}
} 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' => $ruleOrigin === '02' ? count($merged) : 0,
'receivers' => $merged,
'remote' => count($remote),
'local' => count($local),
'resp_code' => $respCode,
'rule_origin' => $ruleOrigin,
'dry_run' => !empty($submit['dry_run']),
'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];
}
$ruleOrigin = $this->resolveRuleOrigin($options);
if ($ruleOrigin === '01' && $this->isSplitConfigEffective($main)) {
$this->updateOnboardBindFields($row, 'skip', 'rule_origin=01,分账配置已生效');
return ['ok' => true, 'message' => 'split config already effective', 'skipped' => true];
}
$remote = $this->fetchRemoteReceiverHuifuIds($main);
$local = $this->collectApprovedReceiverHuifuIds();
$merged = $this->normalizeReceiverIds(array_merge($remote, $local, [$hid]), $main);
$toSubmit = $ruleOrigin === '02' ? $merged : [];
try {
$submit = $this->submitSplitConfig($toSubmit, $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';
}
/**
* 汇付 split/config 必填「分账模式」:A5=线上推广方佣金/抽成,02=线上其他,00=其他(>30% 常需 split_ext_info)
*/
public function resolveSplitConfigScene()
{
$scene = trim((string) $this->config->get('split_config_scene', 'A5'));
return $scene !== '' ? $scene : 'A5';
}
public function resolveRuleOrigin(array $options = [])
{
$ruleOrigin = trim((string) ($options['rule_origin'] ?? $this->config->get('split_config_rule_origin', '01')));
if (!in_array($ruleOrigin, ['01', '02'], true)) {
$ruleOrigin = '01';
}
return $ruleOrigin;
}
public function isSplitConfigEffective($mainHuifuId = null)
{
$mainHuifuId = $mainHuifuId ?: $this->resolveMainHuifuId();
$result = $this->client->queryMerchantSplitConfig($mainHuifuId);
if (!$result['ok']) {
return false;
}
$items = HuifuClient::parseSplitBillResult($result['data']['split_bill_result'] ?? null);
return $items !== [];
}
/**
* @return string|null JSON 字符串,供 extend.split_ext_info
*/
protected function buildSplitExtInfoJson(array $options, $applyRatio, $scene)
{
if (isset($options['split_ext_info'])) {
$raw = $options['split_ext_info'];
if (is_array($raw)) {
return json_encode($raw, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$raw = trim((string) $raw);
if ($raw !== '') {
return $raw;
}
}
$cfgJson = trim((string) $this->config->get('split_config_ext_info', ''));
if ($cfgJson !== '') {
return $cfgJson;
}
$busi = trim((string) $this->config->get('split_config_busi_instruction', ''));
$capital = trim((string) $this->config->get('split_config_capital_instruction', ''));
$function = trim((string) $this->config->get('split_config_function_instruction', ''));
if ($busi !== '' || $capital !== '' || $function !== '') {
return json_encode([
'busi_instruction' => $busi,
'capital_instruction' => $capital,
'function_instruction' => $function,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$needExt = $applyRatio > 30.0 || $scene === '00';
if (!$needExt) {
return null;
}
return json_encode($this->defaultSplitExtInfoPayload(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* 最大分账比例>30% 时的兜底说明(与 docs/汇付分账40%申请说明 一致,可在后台配置覆盖)
*
* @return array<string,string>
*/
protected function defaultSplitExtInfoPayload()
{
return [
'busi_instruction' => '平台运营微信小程序酒类零售,采用自营零售与推广分销、区域代理模式;收款商户负责货源、发货与售后,推广员与区域代理按平台制度获得服务费,单笔订单分账总额不超过实付金额40%,确认收货后延时分账。',
'capital_instruction' => '消费者微信支付进入平台汇付主商户,开启延时分账暂存;确认收货后由系统接口向各分账接收方子商户划转推广/区域服务费,余额留主商户;退款按汇付与微信规则原路退回,资金均在汇付体系内可追溯。',
'function_instruction' => '开通延时分账并将最大比例设为40%,用于向已进件推广员、区域代理自动结算服务费,避免人工转账;实际单笔平均分账约15%-25%,基于真实交易及合作协议,不用于违规资金归集。',
];
}
/**
* @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) {
// 列不存在时不阻断
}
}
}