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; } $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' ); } $onlineBusiType = $this->resolveOnlineBusiType($options, $mainHuifuId); if ($onlineBusiType !== '') { $extend['online_busi_type'] = $onlineBusiType; } elseif ($this->needsOnlineBusiType($ratioFloat) && empty($options['skip_online_busi_check'])) { throw new \InvalidArgumentException( '分账比例>30%须填写 online_busi_type(线上业务类型编码,8位如 H7999AL)。' . '请配置 split_config_online_busi_type、传 --online-busi-type=,或确认主商户进件资料已含该字段;' . '编码表见汇付《线上业务类型编码及补充材料说明》或问渠道/实施(酒类小程序须选与营业执照一致项)' ); } $extendedMaterial = $this->buildExtendedMaterialListJson($options); if ($extendedMaterial !== null && $extendedMaterial !== '') { $extend['extended_material_list'] = $extendedMaterial; } $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 */ protected function defaultSplitExtInfoPayload() { return [ 'busi_instruction' => '平台运营微信小程序酒类零售,采用自营零售与推广分销、区域代理模式;收款商户负责货源、发货与售后,推广员与区域代理按平台制度获得服务费,单笔订单分账总额不超过实付金额40%,确认收货后延时分账。', 'capital_instruction' => '消费者微信支付进入平台汇付主商户,开启延时分账暂存;确认收货后由系统接口向各分账接收方子商户划转推广/区域服务费,余额留主商户;退款按汇付与微信规则原路退回,资金均在汇付体系内可追溯。', 'function_instruction' => '开通延时分账并将最大比例设为40%,用于向已进件推广员、区域代理自动结算服务费,避免人工转账;实际单笔平均分账约15%-25%,基于真实交易及合作协议,不用于违规资金归集。', ]; } /** * 企业商户分账比例 >20% 时汇付要求运营媒介材料(与业务开通一致) */ public function needsOnlineMediaInfo($applyRatio) { return floatval($applyRatio) > 20.0; } public function needsOnlineBusiType($applyRatio) { return floatval($applyRatio) > 30.0; } public function hasOnlineMediaForSplit($applyRatio, array $options = []) { try { $json = $this->buildOnlineMediaInfoListJson($options, floatval($applyRatio)); return $json !== null && $json !== ''; } catch (\Throwable $e) { return false; } } public function resolveOnlineBusiType(array $options = [], $mainHuifuId = null) { $fromOpt = trim((string) ($options['online_busi_type'] ?? '')); if ($fromOpt !== '') { return $fromOpt; } $fromCfg = trim((string) $this->config->get('split_config_online_busi_type', '')); if ($fromCfg !== '') { return $fromCfg; } if (!empty($options['skip_merchant_online_busi_lookup'])) { return ''; } $mainHuifuId = $mainHuifuId ?: $this->resolveMainHuifuId(); try { $basic = $this->client->queryMerchantBasicdata($mainHuifuId); if (!empty($basic['ok']) && is_array($basic['data'] ?? null)) { $found = HuifuClient::collectNestedKeys($basic['data'], ['online_busi_type', 'mcc']); $v = trim((string) ($found['online_busi_type'] ?? '')); if ($v === '') { $mcc = trim((string) ($found['mcc'] ?? '')); if ($mcc !== '' && preg_match('/^H[A-Z0-9]{4,}$/i', $mcc)) { $v = strtoupper($mcc); } } if ($v !== '') { return $v; } } } catch (\Throwable $e) { Log::write('[shopro_huifu] split/config online_busi_type lookup fail: ' . $e->getMessage(), 'info'); } return ''; } /** * @return string|null JSON 数组字符串 */ protected function buildExtendedMaterialListJson(array $options = []) { if (isset($options['extended_material_list'])) { $raw = $options['extended_material_list']; if (is_array($raw)) { return json_encode($raw, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $raw = trim((string) $raw); if ($raw !== '') { return $raw; } } $cfg = trim((string) $this->config->get('split_config_extended_material_json', '')); if ($cfg === '') { return null; } $decoded = json_decode($cfg, true); if (!is_array($decoded)) { throw new \InvalidArgumentException('split_config_extended_material_json 不是合法 JSON 数组'); } return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } /** * @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|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 */ 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) { // 列不存在时不阻断 } } }