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: 分账配置流程调整,需重新配置相关参数 ```
469 行
18 KiB
PHP
469 行
18 KiB
PHP
<?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) {
|
||
// 列不存在时不阻断
|
||
}
|
||
}
|
||
}
|