feat(shopro_huifu): 添加分账配置提交功能和接收方绑定机制 - 实现 shouldDeferWalletUntilGatewaySplitResolves 方法,确保分销/区域结算事件早于 split_timing 时等待分账结束再入钱包 - 新增 SyncSplitReceivers 命令用于提交商户分账配置并绑定已审核通过的汇付号为分账接收方 - 扩展 HuifuClient 添加 submitMerchantSplitConfig 接口实现 v2/merchant/split/config 调用 - 创建 HuifuMerchantSplitService 处理主商户分账配置提交和进件子户绑定为分账接收方逻辑 - 在进件成功后自动调用 split/config 接口绑定分账接收方 - 添加数据库表结构升级脚本,增加分账绑定状态相关字段 - 注册新的命令行工具到应用配置中 ```
352 行
12 KiB
PHP
352 行
12 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)
|
||
*/
|
||
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) {
|
||
// 列不存在时不阻断
|
||
}
|
||
}
|
||
}
|