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); $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 ($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'; } /** * 汇付 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'; } /** * @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%,基于真实交易及合作协议,不用于违规资金归集。', ]; } /** * @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) { // 列不存在时不阻断 } } }