feat(shopro_huifu): 添加分账媒体文件上传命令并优化分账配置流程

- 新增 UploadSplitMedia 命令用于上传小程序运营媒介截图到汇付
- 在 SyncSplitReceivers 中添加异常处理和更清晰的日志输出
- 优化 HuifuMerchantSplitService 中的分账配置逻辑
- 当分账比例>20%时自动验证并上传运营媒介材料
- 添加相关配置项到数据库升级脚本
- 注册新命令到应用命令列表
```
这个提交包含在:
可乐加冰
2026-10-13 10:50:30 +08:00
父节点 b66b74b4f2
当前提交 ae5aac26ac
共有 5 个文件被更改,包括 255 次插入 和 9 次删除

查看文件

@@ -54,16 +54,21 @@ class SyncSplitReceivers extends Command
'rule_origin' => $ruleOrigin,
];
if ($initOnly || $ruleOrigin === '01') {
$output->writeln('模式: 提交分账配置(rule_origin=01 不传 acct_split_bunch_list)');
if ($ruleOrigin === '01') {
$result = $service->syncAllApprovedReceivers($options);
try {
if ($initOnly || $ruleOrigin === '01') {
$output->writeln('模式: 提交分账配置(rule_origin=01 不传 acct_split_bunch_list)');
if ($ruleOrigin === '01') {
$result = $service->syncAllApprovedReceivers($options);
} else {
$result = $service->submitSplitConfig([], $options);
}
} else {
$result = $service->submitSplitConfig([], $options);
$output->writeln('模式: rule_origin=02,合并远端+本地进件后提交接收方列表');
$result = $service->syncAllApprovedReceivers($options);
}
} else {
$output->writeln('模式: rule_origin=02,合并远端+本地进件后提交接收方列表');
$result = $service->syncAllApprovedReceivers($options);
} catch (\InvalidArgumentException $e) {
$output->writeln('[FAIL] ' . $e->getMessage());
return 1;
}
if (!empty($result['dry_run'])) {

查看文件

@@ -0,0 +1,120 @@
<?php
namespace addons\shopro_huifu\command;
use addons\shopro_huifu\library\Bridge;
use addons\shopro_huifu\library\HuifuConfig;
use addons\shopro_huifu\library\onboard\HuifuOnboardPicture;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\Db;
/**
* 上传小程序运营媒介截图到汇付,生成 split/config 所需的 online_media_info_list JSON
*/
class UploadSplitMedia extends Command
{
protected function configure()
{
$this->setName('shopro_huifu:upload-split-media')
->addOption('home-url', null, Option::VALUE_REQUIRED, '首页截图 HTTPS 地址')
->addOption('goods-url', null, Option::VALUE_REQUIRED, '商品/服务明细页截图')
->addOption('order-url', null, Option::VALUE_REQUIRED, '下单场景页截图')
->addOption('pay-url', null, Option::VALUE_REQUIRED, '支付页截图')
->addOption('media-name', null, Option::VALUE_OPTIONAL, '小程序名称,默认 雄戈小酒')
->addOption('media-type', null, Option::VALUE_OPTIONAL, '运营媒介类型,小程序默认 S3')
->addOption('file-type', null, Option::VALUE_OPTIONAL, '汇付图片上传 file_type,默认 F24')
->addOption('save-config', null, Option::VALUE_NONE, '写入 shopro_huifu_config.split_config_online_media_json')
->setDescription('Upload miniprogram media screenshots for split/config (apply_ratio>20%)');
}
protected function execute(Input $input, Output $output)
{
if (!Bridge::isEnabled()) {
$output->writeln('[FAIL] 汇付插件未启用');
return 1;
}
$urls = [
'media_front_page' => trim((string) $input->getOption('home-url')),
'media_service_page' => trim((string) $input->getOption('goods-url')),
'media_order_page' => trim((string) $input->getOption('order-url')),
'media_pay_page' => trim((string) $input->getOption('pay-url')),
];
$labels = [
'media_front_page' => 'home-url',
'media_service_page' => 'goods-url',
'media_order_page' => 'order-url',
'media_pay_page' => 'pay-url',
];
foreach ($urls as $key => $url) {
if ($url === '') {
$output->writeln('[FAIL] 缺少 --' . $labels[$key]);
return 1;
}
}
$fileType = trim((string) $input->getOption('file-type'));
if ($fileType === '') {
$fileType = 'F24';
}
$mediaName = trim((string) $input->getOption('media-name'));
if ($mediaName === '') {
$mediaName = '雄戈小酒';
}
$mediaType = trim((string) $input->getOption('media-type'));
if ($mediaType === '') {
$mediaType = 'S3';
}
$picture = new HuifuOnboardPicture();
$fileIds = [];
foreach ($urls as $field => $url) {
$output->writeln('上传 ' . $field . ' ...');
$fileIds[$field] = $picture->uploadByUrl($fileType, $url);
$output->writeln(' file_id: ' . $fileIds[$field]);
}
$item = [
'media_type' => $mediaType,
'media_name' => $mediaName,
'media_mer_common_flag' => 'Y',
'media_front_page' => $fileIds['media_front_page'],
'media_service_page' => $fileIds['media_service_page'],
'media_order_page' => $fileIds['media_order_page'],
'media_pay_page' => $fileIds['media_pay_page'],
];
$appId = trim((string) Bridge::getMiniProgramAppId());
if ($appId !== '') {
$item['app_id'] = $appId;
}
$json = json_encode([$item], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$output->writeln('');
$output->writeln('split_config_online_media_json(可复制到 fa_shopro_huifu_config):');
$output->writeln($json);
if ($input->getOption('save-config')) {
$row = Db::name('shopro_huifu_config')->where('name', 'split_config_online_media_json')->find();
if ($row) {
Db::name('shopro_huifu_config')->where('name', 'split_config_online_media_json')->update(['value' => $json]);
} else {
Db::name('shopro_huifu_config')->insert([
'name' => 'split_config_online_media_json',
'value' => $json,
'group' => 'split',
'description' => 'split/config online_media_info_list JSON',
]);
}
HuifuConfig::reload();
$output->writeln('[OK] 已写入 split_config_online_media_json');
}
$output->writeln('');
$output->writeln('下一步: php think shopro_huifu:sync-split-receivers');
return 0;
}
}

查看文件

@@ -150,6 +150,17 @@ class HuifuMerchantSplitService
$extend['split_ext_info'] = $splitExtInfo;
}
$ratioFloat = floatval($applyRatio);
$mediaList = $this->buildOnlineMediaInfoListJson($options, $ratioFloat);
if ($mediaList !== null && $mediaList !== '') {
$extend['online_media_info_list'] = $mediaList;
} elseif ($this->needsOnlineMediaInfo($ratioFloat) && empty($options['skip_media_check'])) {
throw new \InvalidArgumentException(
'分账比例>20%须提交运营媒介 online_media_info_list(首页/商品/下单/支付截图的汇付 file_id)。'
. '请执行 php think shopro_huifu:upload-split-media 或配置 split_config_online_media_json,见 docs/汇付延时分账测试指南.md'
);
}
$asyncUrl = trim((string) ($options['async_return_url'] ?? $this->config->get('split_config_async_url', '')));
if ($asyncUrl !== '') {
$extend['async_return_url'] = $asyncUrl;
@@ -381,6 +392,105 @@ class HuifuMerchantSplitService
];
}
/**
* 企业商户分账比例 >20% 时汇付要求运营媒介材料(与业务开通一致)
*/
public function needsOnlineMediaInfo($applyRatio)
{
return floatval($applyRatio) > 20.0;
}
/**
* @return string|null JSON 字符串(数组),供 extend.online_media_info_list
*/
public function buildOnlineMediaInfoListJson(array $options = [], $applyRatio = 0.0)
{
if (isset($options['online_media_info_list'])) {
$raw = $options['online_media_info_list'];
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_online_media_json', ''));
if ($cfgJson !== '') {
$decoded = json_decode($cfgJson, true);
if (!is_array($decoded)) {
throw new \InvalidArgumentException('split_config_online_media_json 不是合法 JSON 数组');
}
return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$item = $this->buildOnlineMediaItemFromConfig();
if ($item !== null) {
return json_encode([$item], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
if ($this->needsOnlineMediaInfo($applyRatio)) {
return null;
}
return null;
}
/**
* @return array<string,string>|null
*/
public function buildOnlineMediaItemFromConfig()
{
$front = trim((string) $this->config->get('split_config_media_front_page', ''));
$service = trim((string) $this->config->get('split_config_media_service_page', ''));
$order = trim((string) $this->config->get('split_config_media_order_page', ''));
$pay = trim((string) $this->config->get('split_config_media_pay_page', ''));
if ($front === '' || $service === '' || $order === '' || $pay === '') {
return null;
}
$mediaType = trim((string) $this->config->get('split_config_media_type', 'S3'));
if ($mediaType === '') {
$mediaType = 'S3';
}
$mediaName = trim((string) $this->config->get('split_config_media_name', ''));
if ($mediaName === '') {
$mediaName = '雄戈小酒';
}
$common = trim((string) $this->config->get('split_config_media_mer_common_flag', 'Y'));
if ($common === '') {
$common = 'Y';
}
$item = [
'media_type' => $mediaType,
'media_name' => $mediaName,
'media_mer_common_flag' => $common,
'media_front_page' => $front,
'media_service_page' => $service,
'media_order_page' => $order,
'media_pay_page' => $pay,
];
$appId = trim((string) $this->config->get('split_config_media_app_id', ''));
if ($appId === '' && class_exists(Bridge::class)) {
$appId = trim((string) Bridge::getMiniProgramAppId());
}
if ($appId !== '' && in_array($mediaType, ['S2', 'S3', 'S4'], true)) {
$item['app_id'] = $appId;
}
if ($common === 'N') {
$auth = trim((string) $this->config->get('split_config_media_authorize_materials', ''));
if ($auth !== '') {
$item['authorize_materials'] = $auth;
}
}
return $item;
}
/**
* @param string[] $huifuIds
*/

查看文件

@@ -12,4 +12,14 @@ INSERT IGNORE INTO `__PREFIX__shopro_huifu_config` (`name`, `value`, `group`, `d
('split_config_ext_info', '', 'split', 'split_ext_info 整段 JSON,留空则>30%时用内置说明或下方三字段'),
('split_config_busi_instruction', '', 'split', '业务模式说明 busi_instruction'),
('split_config_capital_instruction', '', 'split', '资金流向说明 capital_instruction'),
('split_config_function_instruction', '', 'split', '功能开通用途 function_instruction');
('split_config_function_instruction', '', 'split', '功能开通用途 function_instruction'),
('split_config_online_media_json', '', 'split', 'online_media_info_list 整段 JSON 数组(40%分账必填)'),
('split_config_media_type', 'S3', 'split', '运营媒介类型 S3=小程序'),
('split_config_media_name', '雄戈小酒', 'split', '媒介名称(小程序名)'),
('split_config_media_mer_common_flag', 'Y', 'split', '媒介主体与商户主体是否一致 Y/N'),
('split_config_media_app_id', '', 'split', '小程序 app_id,留空则读商城配置'),
('split_config_media_front_page', '', 'split', '首页截图汇付 file_id'),
('split_config_media_service_page', '', 'split', '商品明细页 file_id'),
('split_config_media_order_page', '', 'split', '下单页 file_id'),
('split_config_media_pay_page', '', 'split', '支付页 file_id'),
('split_config_media_authorize_materials', '', 'split', '主体不一致时的授权材料 file_id');