diff --git a/application/admin/command/Install/split_number_cleanup_deferred.sql b/application/admin/command/Install/split_number_cleanup_deferred.sql new file mode 100644 index 0000000..3fed801 --- /dev/null +++ b/application/admin/command/Install/split_number_cleanup_deferred.sql @@ -0,0 +1,21 @@ +-- 号码管理:清理无效降权池权限节点 +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', m.id, 'split.number/cleanupdeferred', '清理降权池', 'fa fa-circle-o', '', '清除无落地页访问基线的无效 ratio_deferred 标记', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` m +WHERE m.name = 'split.number' AND m.ismenu = 1 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferred' LIMIT 1) +LIMIT 1; + +-- 可选:一次性修复历史脏数据(与后台「清理降权池」逻辑一致) +-- UPDATE `fa_split_number` +-- SET `ratio_deferred` = 0, `ratio_deferred_at` = NULL, +-- `no_inbound_click_streak` = IF(`visit_count` <= 0, 0, `no_inbound_click_streak`) +-- WHERE `ratio_deferred` = 1 +-- AND (`visit_count` <= 0 OR `visit_count` <= `last_sync_visit_count`); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', m.id, 'split.number/cleanupdeferredpreview', '清理降权池预览', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` m +WHERE m.name = 'split.number' AND m.ismenu = 1 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferredpreview' LIMIT 1) +LIMIT 1; diff --git a/application/admin/command/SplitSyncTickets.php b/application/admin/command/SplitSyncTickets.php index 62bd888..63ea1fe 100755 --- a/application/admin/command/SplitSyncTickets.php +++ b/application/admin/command/SplitSyncTickets.php @@ -18,6 +18,7 @@ use think\console\Output; * 用法: * php think split:sync-tickets * php think split:sync-tickets --ticket=12 + * php think split:sync-tickets --ticket=12 --mode=auto */ class SplitSyncTickets extends Command { @@ -25,21 +26,24 @@ class SplitSyncTickets extends Command { $this->setName('split:sync-tickets') ->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '') + ->addOption('mode', 'm', Option::VALUE_OPTIONAL, '同步方式: auto=定时自动, manual=手动触发', 'manual') ->setDescription('同步分流工单云控数据'); } protected function execute(Input $input, Output $output): void { set_time_limit(0); + $syncMode = $this->resolveSyncMode($input); SplitTicketSyncLogger::log('cli', 'command start', [ 'ticketOption' => trim((string) $input->getOption('ticket')), + 'syncMode' => $syncMode, 'appDebug' => SplitTicketSyncLogger::isEnabled(), ]); $service = new SplitTicketSyncService(); $ticketId = trim((string) $input->getOption('ticket')); if ($ticketId !== '' && ctype_digit($ticketId)) { - $result = $service->syncOne((int) $ticketId, true); + $result = $service->syncOne((int) $ticketId, true, false, $syncMode); if (!empty($result['skipped'])) { $output->writeln('跳过: ' . ($result['message'] ?? '') . ''); return; @@ -61,8 +65,8 @@ class SplitSyncTickets extends Command try { $count = $service->syncDueTickets(); - $output->writeln('本次处理工单数: ' . $count . ''); - $output->writeln('汇总日志已写入 runtime/log/split_sync.log'); + $output->writeln('本次投递工单数: ' . $count . ''); + $output->writeln('同步在后台 CLI 进程中执行,汇总日志见 runtime/log/split_sync.log'); } finally { $cronLock->release(); } @@ -71,4 +75,14 @@ class SplitSyncTickets extends Command $output->writeln('详细调试日志已写入 runtime/log/split_sync.log'); } } + + /** + * 解析 CLI 同步方式,供 sync_success_mode 落库 + */ + private function resolveSyncMode(Input $input): string + { + $mode = strtolower(trim((string) $input->getOption('mode'))); + + return in_array($mode, ['auto', 'manual'], true) ? $mode : 'manual'; + } } diff --git a/application/admin/controller/split/Number.php b/application/admin/controller/split/Number.php index d73b491..07fc38d 100755 --- a/application/admin/controller/split/Number.php +++ b/application/admin/controller/split/Number.php @@ -65,6 +65,7 @@ class Number extends Backend $this->assignconfig('statusList', $this->model->getStatusList()); $this->assignconfig('manualManageList', $this->model->getManualManageList()); $this->assignconfig('platformStatusList', $this->model->getPlatformStatusList()); + $this->assignconfig('ratioDeferredList', $this->model->getRatioDeferredList()); $this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList()); $this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig()); @@ -525,6 +526,47 @@ class Number extends Backend $this->success(sprintf(__('Batch operate success'), $count)); } + /** + * 预览待清理的无效降权号码数量 + */ + public function cleanupdeferredpreview(): void + { + if (false === $this->request->isAjax()) { + $this->error(__('Invalid parameters')); + } + + $service = new \app\common\service\SplitNumberRatioDeferredService(); + $adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null; + $count = $service->countInvalidDeferred(is_array($adminIds) ? $adminIds : null); + + $this->success('', null, ['count' => $count]); + } + + /** + * 清理无效降权号码池(无落地页访问基线却标记 ratio_deferred=1 的记录) + */ + public function cleanupdeferred(): void + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + + $service = new \app\common\service\SplitNumberRatioDeferredService(); + $adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null; + + try { + $count = $service->cleanupInvalidDeferred(is_array($adminIds) ? $adminIds : null); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + } + + if ($count <= 0) { + $this->success(__('Ratio deferred cleanup none')); + } + + $this->success(sprintf(__('Ratio deferred cleanup success'), $count)); + } + /** * 排除不可由表单提交的字段 * diff --git a/application/admin/lang/zh-cn/split/number.php b/application/admin/lang/zh-cn/split/number.php index bec8fa0..22c3bef 100755 --- a/application/admin/lang/zh-cn/split/number.php +++ b/application/admin/lang/zh-cn/split/number.php @@ -51,4 +51,16 @@ return [ 'Invalid manual manage' => '手动管理选项无效', 'Batch update success' => '批量更新成功', 'Unit count' => '个', + 'Ratio_deferred' => '选号降权', + 'Ratio deferred badge' => '降权中', + 'Ratio deferred no' => '正常选号', + 'Ratio deferred yes' => '降权中', + 'Ratio deferred filter' => '仅显示降权中号码', + 'Ratio deferred tooltip'=> '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决', + 'Ratio deferred edit notice' => '该号码处于下号比率触线降权状态,同步后将根据进线情况恢复或关闭', + 'No inbound click streak' => '无进线连续点击', + 'Ratio deferred cleanup btn' => '清理降权池', + 'Ratio deferred cleanup confirm' => '将清除 %d 条无效降权记录(无落地页访问或访问未超过同步基线)。确定继续?', + 'Ratio deferred cleanup success' => '已清理 %d 条无效降权记录', + 'Ratio deferred cleanup none' => '当前没有需要清理的无效降权记录', ]; diff --git a/application/admin/model/split/Number.php b/application/admin/model/split/Number.php index 0231f91..abd0ea8 100755 --- a/application/admin/model/split/Number.php +++ b/application/admin/model/split/Number.php @@ -42,6 +42,7 @@ class Number extends Model 'status_text', 'manual_manage_text', 'platform_status_text', + 'ratio_deferred_text', ]; /** @@ -81,6 +82,17 @@ class Number extends Model ]; } + /** + * @return array + */ + public function getRatioDeferredList(): array + { + return [ + '0' => __('Ratio deferred no'), + '1' => __('Ratio deferred yes'), + ]; + } + /** * @return array */ @@ -158,6 +170,13 @@ class Number extends Model return $list[$key] ?? $key; } + public function getRatioDeferredTextAttr($value, $data): string + { + $key = (string) ((int) ($data['ratio_deferred'] ?? 0)); + $list = $this->getRatioDeferredList(); + return $list[$key] ?? $key; + } + /** * 根据链接码生成完整分流 URL */ diff --git a/application/admin/view/split/number/form_body.html b/application/admin/view/split/number/form_body.html index c29ee48..21e3c38 100755 --- a/application/admin/view/split/number/form_body.html +++ b/application/admin/view/split/number/form_body.html @@ -123,6 +123,13 @@
{if isset($row)} + {if isset($row.ratio_deferred) && $row.ratio_deferred==1 && isset($row.status) && $row.status=='normal'} +
+ + {:__('Ratio deferred edit notice')} + ({:__('No inbound click streak')}:{$row.no_inbound_click_streak|default=0}) +
+ {/if} {else} diff --git a/application/admin/view/split/number/index.html b/application/admin/view/split/number/index.html index 106b43b..00b4e8e 100755 --- a/application/admin/view/split/number/index.html +++ b/application/admin/view/split/number/index.html @@ -18,6 +18,8 @@ {:__('Edit')} {:__('Delete')} {:__('Batch update btn')} + {:__('Ratio deferred badge')} + {:__('Ratio deferred cleanup btn')} {:__('Batch operate btn')}
last_sync_visit_count), + * 且自上次同步以来进线未增长时累计 streak,触线才标记降权。 + */ +class SplitNumberRatioDeferredService +{ + /** + * 是否具备计入 streak 的落地页访问基数 + * + * @param array|Number $number + */ + public function hasLandingVisitBaseline($number): bool + { + $visitCount = $this->readInt($number, 'visit_count'); + if ($visitCount <= 0) { + return false; + } + + $lastVisit = $this->readInt($number, 'last_sync_visit_count'); + + return $visitCount > $lastVisit; + } + + /** + * 根据访问与进线快照计算新的 streak + * + * @param array|Number $number + */ + public function computeStreakForVisit($number, int $currentStreak): int + { + if (!$this->hasLandingVisitBaseline($number)) { + return $currentStreak; + } + + $inboundCount = $this->readInt($number, 'inbound_count'); + $lastInbound = $this->readInt($number, 'last_sync_inbound_count'); + + if ($inboundCount <= $lastInbound) { + return $currentStreak + 1; + } + + return 0; + } + + /** + * 同步侧:根据 visit / inbound 与上次同步检查点重算 streak + * + * @param array|Number $number + */ + public function computeStreakForSync($number, int $currentStreak): int + { + $visitCount = $this->readInt($number, 'visit_count'); + if ($visitCount <= 0) { + return 0; + } + + $lastVisit = $this->readInt($number, 'last_sync_visit_count'); + $inboundCount = $this->readInt($number, 'inbound_count'); + $lastInbound = $this->readInt($number, 'last_sync_inbound_count'); + + if ($inboundCount > $lastInbound) { + return 0; + } + + if ($visitCount > $lastVisit) { + return $currentStreak + ($visitCount - $lastVisit); + } + + return $currentStreak; + } + + /** + * 是否应标记 ratio_deferred(访问侧触线降权,不关 status) + */ + public function shouldMarkDeferred(int $streak, int $assignRatio, bool $hasLandingBaseline): bool + { + return $hasLandingBaseline && $assignRatio > 0 && $streak >= $assignRatio; + } + + /** + * 是否为应清理的无效降权记录 + * + * @param array|Number $number + */ + public function isInvalidDeferredRecord($number): bool + { + if ($this->readInt($number, 'ratio_deferred') !== 1) { + return false; + } + + return !$this->hasLandingVisitBaseline($number); + } + + /** + * 清理无效降权标记(ratio_deferred=1 但无有效落地页访问基线) + * + * @param int[]|null $adminIds 数据权限管理员 ID 列表,null 表示不限制 + * @return int 清理条数 + */ + public function cleanupInvalidDeferred(?array $adminIds = null): int + { + $query = Number::where('ratio_deferred', 1); + if ($adminIds !== null && $adminIds !== []) { + $query->where('admin_id', 'in', $adminIds); + } + + $rows = $query->field('id,visit_count,last_sync_visit_count,ratio_deferred,no_inbound_click_streak')->select(); + if ($rows === null || $rows === []) { + return 0; + } + + $now = time(); + $count = 0; + Db::startTrans(); + try { + foreach ($rows as $row) { + if (!$this->isInvalidDeferredRecord($row)) { + continue; + } + $update = [ + 'ratio_deferred' => 0, + 'ratio_deferred_at' => null, + 'updatetime' => $now, + ]; + if ($this->readInt($row, 'visit_count') <= 0) { + $update['no_inbound_click_streak'] = 0; + } + Number::where('id', (int) $row['id'])->update($update); + $count++; + } + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + + return $count; + } + + /** + * 统计当前权限范围内无效降权条数(清理前预览) + * + * @param int[]|null $adminIds + */ + public function countInvalidDeferred(?array $adminIds = null): int + { + $query = Number::where('ratio_deferred', 1); + if ($adminIds !== null && $adminIds !== []) { + $query->where('admin_id', 'in', $adminIds); + } + + $total = 0; + foreach ($query->field('id,visit_count,last_sync_visit_count,ratio_deferred')->select() as $row) { + if ($this->isInvalidDeferredRecord($row)) { + $total++; + } + } + + return $total; + } + + /** + * @param array|Number $number + */ + private function readInt($number, string $field): int + { + if (is_array($number)) { + return (int) ($number[$field] ?? 0); + } + + return (int) $number->getAttr($field); + } +} diff --git a/application/common/service/SplitNumberVisitRuleService.php b/application/common/service/SplitNumberVisitRuleService.php index 3f38dd4..e3e4a94 100644 --- a/application/common/service/SplitNumberVisitRuleService.php +++ b/application/common/service/SplitNumberVisitRuleService.php @@ -14,6 +14,13 @@ use app\admin\model\split\Ticket; */ class SplitNumberVisitRuleService { + private SplitNumberRatioDeferredService $ratioDeferredService; + + public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null) + { + $this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService(); + } + /** * 访问计数已递增后调用:累加 streak,达到 assign_ratio 时标记 ratio_deferred=1 */ @@ -39,15 +46,11 @@ class SplitNumberVisitRuleService } $assignRatio = (int) ($ticket['assign_ratio'] ?? 0); - $inboundCount = (int) $number['inbound_count']; - $lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0); $streak = (int) ($number['no_inbound_click_streak'] ?? 0); + $hasBaseline = $this->ratioDeferredService->hasLandingVisitBaseline($number); - // 自上次同步检查点以来进线未增长,则本次访问计入 streak - if ($inboundCount <= $lastInbound) { - $streak++; - } else { - $streak = 0; + if ($hasBaseline) { + $streak = $this->ratioDeferredService->computeStreakForVisit($number, $streak); } $update = [ @@ -55,8 +58,9 @@ class SplitNumberVisitRuleService 'updatetime' => time(), ]; - // 非手动号码且达到下号比率:触线降权,不关 status,等待同步确认 - if ((int) $number['manual_manage'] === 0 && $assignRatio > 0 && $streak >= $assignRatio) { + if ((int) $number['manual_manage'] === 0 + && $this->ratioDeferredService->shouldMarkDeferred($streak, $assignRatio, $hasBaseline) + ) { $update['ratio_deferred'] = 1; $update['ratio_deferred_at'] = time(); } diff --git a/application/common/service/SplitNumberWeighService.php b/application/common/service/SplitNumberWeighService.php index 1b46633..1caf292 100755 --- a/application/common/service/SplitNumberWeighService.php +++ b/application/common/service/SplitNumberWeighService.php @@ -9,8 +9,8 @@ use app\admin\model\split\Link; /** * 分流链接随机打乱配置与落地页选号 * - * - random_shuffle=0:跨工单合并号码池,按 id 顺序严格轮转 - * - random_shuffle=1:跨工单合并号码池,每次访问完全随机选号 + * - random_shuffle=0:跨工单合并号码池,按 id 顺序全局严格轮转 + * - random_shuffle=1:先按各工单开启号码数比例随机选工单,再在该工单内随机选号 * - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布) */ class SplitNumberWeighService diff --git a/application/common/service/SplitRedirectService.php b/application/common/service/SplitRedirectService.php index bd6b76d..86c3af3 100755 --- a/application/common/service/SplitRedirectService.php +++ b/application/common/service/SplitRedirectService.php @@ -6,14 +6,17 @@ namespace app\common\service; use app\admin\model\split\Link; use app\admin\model\split\Number; +use app\admin\model\split\Ticket; use think\Collection; /** - * 分流链接落地页:查链、跨工单合并选号、拼接跳转 URL、访问计数 + * 分流链接落地页:查链、选号、拼接跳转 URL、访问计数 * - * 号码池为同一 split_link_id 下全部 status=normal 的号码; - * 优先选用 ratio_deferred=0 的号码,全部触线降权时回退至降权池仍可轮转。 - * random_shuffle 关闭时严格轮转,开启时每次访问随机选号。 + * 号码池:同一 split_link_id 下 status=normal,且所属工单未关闭(ticket.status≠hidden)的号码; + * 优先选用 ratio_deferred=0,全部触线降权时回退至降权池。 + * + * random_shuffle=0:跨工单合并池,全局严格轮转; + * random_shuffle=1:先按各工单开启号码数量比例随机选工单,再在该工单内随机选号。 */ class SplitRedirectService { @@ -55,20 +58,17 @@ class SplitRedirectService return null; } - /** @var Collection|array $numbers */ - $numbers = Number::where('split_link_id', (int) $link['id']) - ->where('status', 'normal') - ->order('id', 'asc') - ->field('id,number,number_type,number_type_custom,ratio_deferred') - ->select(); - - $list = $numbers instanceof Collection ? $numbers->all() : (array) $numbers; + $linkId = (int) $link->getAttr('id'); + $list = $this->loadEligibleNumbers($linkId); if ($list === []) { return null; } - $linkId = (int) $link->getAttr('id'); - $picked = $this->pickNumberRow($linkId, $list); + if (SplitNumberWeighService::isRandomShuffleEnabled($linkId)) { + $picked = $this->pickNumberRowRandomByTicketWeight($linkId, $list); + } else { + $picked = $this->pickNumberRow($linkId, $list); + } if ($picked === null) { return null; } @@ -103,10 +103,117 @@ class SplitRedirectService return $redirectUrl; } + /** + * 可参与选号的号码:status=normal,且 ticket_name 不属于已关闭工单 + * + * @return list> + */ + private function loadEligibleNumbers(int $linkId): array + { + if ($linkId <= 0) { + return []; + } + + /** @var list $hiddenTicketNames */ + $hiddenTicketNames = Ticket::where('split_link_id', $linkId) + ->where('status', 'hidden') + ->column('ticket_name'); + $hiddenTicketNames = array_values(array_unique(array_filter(array_map( + static function ($name): string { + return trim((string) $name); + }, + $hiddenTicketNames + )))); + + $query = Number::where('split_link_id', $linkId) + ->where('status', 'normal') + ->order('id', 'asc') + ->field('id,number,number_type,number_type_custom,ratio_deferred,ticket_name'); + + if ($hiddenTicketNames !== []) { + $query->where('ticket_name', 'not in', $hiddenTicketNames); + } + + $numbers = $query->select(); + return $numbers instanceof Collection ? $numbers->all() : (array) $numbers; + } + + /** + * random_shuffle=1:先按工单开启号码数加权随机选工单,再在该工单内选号 + * + * @param list> $rows + * @return array|Number|null + */ + private function pickNumberRowRandomByTicketWeight(int $linkId, array $rows) + { + $groups = $this->groupNumbersByTicket($rows); + if ($groups === []) { + return null; + } + + $ticketKey = $this->pickTicketGroupKeyWeighted($groups); + if ($ticketKey === null || !isset($groups[$ticketKey])) { + return null; + } + + return $this->pickNumberRow($linkId, $groups[$ticketKey]); + } + + /** + * @param list> $rows + * @return array>> + */ + private function groupNumbersByTicket(array $rows): array + { + /** @var array>> $groups */ + $groups = []; + foreach ($rows as $row) { + $name = is_array($row) + ? trim((string) ($row['ticket_name'] ?? '')) + : trim((string) $row->getAttr('ticket_name')); + $key = $name !== '' ? $name : '__manual__'; + $groups[$key][] = $row; + } + + return $groups; + } + + /** + * 按各分组号码数量加权随机选一个工单(分组 key) + * + * @param array>> $groups + */ + private function pickTicketGroupKeyWeighted(array $groups): ?string + { + $total = 0; + foreach ($groups as $rows) { + $total += count($rows); + } + if ($total <= 0) { + return null; + } + + $keys = array_keys($groups); + if (count($keys) === 1) { + return $keys[0]; + } + + $r = random_int(1, $total); + $acc = 0; + foreach ($groups as $key => $rows) { + $acc += count($rows); + if ($r <= $acc) { + return $key; + } + } + + return $keys[0]; + } + /** * 双层选号:优先 ratio_deferred=0,否则回退至降权池 * - * @param array|Number> $rows + * @param list> $rows * @return array|Number|null */ private function pickNumberRow(int $linkId, array $rows) diff --git a/application/common/service/SplitScrmSpiderFactory.php b/application/common/service/SplitScrmSpiderFactory.php index e96981b..90a5f0c 100755 --- a/application/common/service/SplitScrmSpiderFactory.php +++ b/application/common/service/SplitScrmSpiderFactory.php @@ -33,6 +33,17 @@ class SplitScrmSpiderFactory // ceo_scrm 等未实现类型:新增 spider 类后在此注册 ]; + /** + * API 优先同步类型:常态走 PHP cURL,不占 Node Real Browser 槽位 + * + * 调度层据此跳过 Node 队列门禁并允许并行投递;蜘蛛实现本身不在此修改。 + * + * @var list + */ + private const API_FIRST_SYNC_TYPES = [ + 'whatshub', + ]; + /** * @return class-string|null */ @@ -60,6 +71,22 @@ class SplitScrmSpiderFactory return array_keys(self::MAP); } + /** + * 是否 API 优先类型(调度层可并行投递且不占 Node 队列) + */ + public static function isApiFirstSyncType(string $ticketType): bool + { + return in_array(trim($ticketType), self::API_FIRST_SYNC_TYPES, true); + } + + /** + * @return list + */ + public static function listApiFirstSyncTypes(): array + { + return self::API_FIRST_SYNC_TYPES; + } + /** * 同步是否依赖 Node Headless(纯 PHP cURL 等为 false) */ diff --git a/application/common/service/SplitSyncConfigService.php b/application/common/service/SplitSyncConfigService.php index 91b74c8..c667c3c 100755 --- a/application/common/service/SplitSyncConfigService.php +++ b/application/common/service/SplitSyncConfigService.php @@ -59,7 +59,7 @@ class SplitSyncConfigService if ($value === '') { return 5; } - return max(1, min(20, (int) $value)); + return max(1, min(50, (int) $value)); } /** diff --git a/application/common/service/SplitTicketRuleService.php b/application/common/service/SplitTicketRuleService.php index d2514ab..9c458e0 100755 --- a/application/common/service/SplitTicketRuleService.php +++ b/application/common/service/SplitTicketRuleService.php @@ -12,6 +12,13 @@ use app\admin\model\split\Ticket; */ class SplitTicketRuleService { + private SplitNumberRatioDeferredService $ratioDeferredService; + + public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null) + { + $this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService(); + } + /** * 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率) */ @@ -108,14 +115,13 @@ class SplitTicketRuleService foreach ($numbers as $number) { $visitCount = (int) $number['visit_count']; $inboundCount = (int) $number['inbound_count']; - $lastVisit = (int) ($number['last_sync_visit_count'] ?? 0); $lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0); $streak = (int) ($number['no_inbound_click_streak'] ?? 0); - if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) { - $streak += ($visitCount - $lastVisit); - } elseif ($inboundCount > $lastInbound) { + if ($visitCount <= 0) { $streak = 0; + } else { + $streak = $this->ratioDeferredService->computeStreakForSync($number, $streak); } $update = [ @@ -125,20 +131,25 @@ class SplitTicketRuleService 'updatetime' => time(), ]; - // 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态与降权标记 + $clearDeferred = [ + 'ratio_deferred' => 0, + 'ratio_deferred_at' => null, + ]; + + // 手动管理:更新统计;无效降权(无落地页访问)一并清除 if ((int) $number['manual_manage'] === 1) { + if ($this->ratioDeferredService->isInvalidDeferredRecord($number) || $visitCount <= 0) { + $update = array_merge($update, $clearDeferred); + if ($visitCount <= 0) { + $update['no_inbound_click_streak'] = 0; + } + } Number::where('id', (int) $number['id'])->update($update); continue; } - // 同步裁决 ratio_deferred:进线增长则解除降权;触线且无进线增长则关号并清除降权 - if ($inboundCount > $lastInbound) { - $update['ratio_deferred'] = 0; - $update['ratio_deferred_at'] = null; - } else { - $update['ratio_deferred'] = 0; - $update['ratio_deferred_at'] = null; - } + // 同步裁决 ratio_deferred:同步时清除访问侧降权,由 streak 决定是否关号 + $update = array_merge($update, $clearDeferred); $update['status'] = $this->resolveAutomatedStatus( $ticket, diff --git a/application/common/service/SplitTicketSyncDispatchService.php b/application/common/service/SplitTicketSyncDispatchService.php index 6a06409..71faa83 100755 --- a/application/common/service/SplitTicketSyncDispatchService.php +++ b/application/common/service/SplitTicketSyncDispatchService.php @@ -4,15 +4,19 @@ declare(strict_types=1); namespace app\common\service; +use app\common\library\scrm\AntiBotConfigBuilder; use think\Db; /** - * 工单手工同步 CLI 后台投递 + * 工单同步 CLI 后台投递 * - * Web 请求仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行, - * 避免长时间占用 PHP-FPM 与 Session 锁导致后台其它页面无法打开。 + * Web / Cron 仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行, + * 避免长时间占用 PHP-FPM、Cron 全局锁或 Session 锁。 * - * Node 类型(A2C / Whatshub 等)在同一 nohup 子 shell 内串行执行,避免并行 Chrome 压垮温池。 + * 并行策略: + * - API 优先类型(Whatshub 等):独立并行投递,不占 Node Real Browser 槽位 + * - 直连 PHP 类型:独立并行投递 + * - Node 类型:不同 sessionKey(域名)并行;同一 sessionKey 内串行以命中 Browser 温池 */ class SplitTicketSyncDispatchService { @@ -24,75 +28,91 @@ class SplitTicketSyncDispatchService */ public function dispatchManual(array $ticketIds): array { - $queued = []; + $lockService = new SplitTicketSyncLockService(); + $metaList = $this->loadTicketMetaList($ticketIds); + + /** @var list $toDispatch */ + $toDispatch = []; $skipped = []; $failed = []; - $php = $this->resolvePhpBinary(); - $think = $this->resolveThinkScript(); - $root = $this->resolveRootPath(); - $lockService = new SplitTicketSyncLockService(); - $nodeTypeMap = array_flip(SplitScrmSpiderFactory::listNodeSyncTypes()); - $typeMap = $this->loadTicketTypeMap($ticketIds); - - /** @var int[] $nodeTicketIds */ - $nodeTicketIds = []; - /** @var int[] $directTicketIds */ - $directTicketIds = []; - - foreach ($ticketIds as $rawId) { - $ticketId = (int) $rawId; - if ($ticketId <= 0) { - continue; - } - + foreach ($metaList as $meta) { + $ticketId = (int) $meta['id']; if ($lockService->isLocked($ticketId)) { $skipped[] = $ticketId; continue; } - - $ticketType = (string) ($typeMap[$ticketId] ?? ''); - if ($ticketType !== '' && isset($nodeTypeMap[$ticketType])) { - $nodeTicketIds[] = $ticketId; - } else { - $directTicketIds[] = $ticketId; - } + $toDispatch[] = $meta; } - foreach ($directTicketIds as $ticketId) { - if ($this->spawnCli($php, $think, $root, $ticketId)) { - $queued[] = $ticketId; - } else { - $failed[] = $ticketId; - } - } - - if ($nodeTicketIds !== []) { - $spawned = $this->spawnCliSequential($php, $think, $root, $nodeTicketIds); - if ($spawned) { - foreach ($nodeTicketIds as $ticketId) { - $queued[] = $ticketId; - } - } else { - foreach ($nodeTicketIds as $ticketId) { - $failed[] = $ticketId; - } - } - } + $dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'manual'); SplitTicketSyncLogger::log('web', 'manual sync dispatched', [ - 'queued' => $queued, - 'skipped' => $skipped, - 'failed' => $failed, - 'nodeSerial' => $nodeTicketIds, - 'directParallel' => $directTicketIds, - 'phpCli' => $php, + 'queued' => $dispatchResult['queued'], + 'skipped' => $skipped, + 'failed' => $dispatchResult['failed'], + 'phpCli' => $this->resolvePhpBinary(), ]); return [ - 'queued' => $queued, + 'queued' => $dispatchResult['queued'], 'skipped' => $skipped, - 'failed' => $failed, + 'failed' => $dispatchResult['failed'], + ]; + } + + /** + * Cron 自动同步:将挑选后的到期工单投递到后台 CLI(Cron 进程立即返回) + * + * @param list $tickets + * @return array{ + * queued:list>, + * skipped:list>, + * failed:list>, + * stoppedByNodeBusy:bool + * } + */ + public function dispatchAuto(array $tickets): array + { + $lockService = new SplitTicketSyncLockService(); + /** @var list $toDispatch */ + $toDispatch = []; + /** @var list> $skipped */ + $skipped = []; + + foreach ($tickets as $ticket) { + $meta = $this->normalizeTicketMeta($ticket); + if ($meta === null) { + continue; + } + $ticketId = (int) $meta['id']; + if ($lockService->isLocked($ticketId)) { + $skipped[] = $this->buildDispatchEntry( + $ticketId, + (string) $meta['ticket_type'], + (string) $meta['ticket_url'], + '工单正在同步中' + ); + continue; + } + $toDispatch[] = $meta; + } + + $dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'auto'); + + SplitTicketSyncLogger::log('cron', 'auto sync dispatched', [ + 'queuedCount' => count($dispatchResult['queued']), + 'skippedCount' => count($skipped) + count($dispatchResult['skipped']), + 'failedCount' => count($dispatchResult['failed']), + 'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'], + 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), + ]); + + return [ + 'queued' => $dispatchResult['queued'], + 'skipped' => array_merge($skipped, $dispatchResult['skipped']), + 'failed' => $dispatchResult['failed'], + 'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'], ]; } @@ -117,10 +137,181 @@ class SplitTicketSyncDispatchService } /** - * @param int[] $ticketIds - * @return array ticketId => ticket_type + * @param list $metaList + * @return array{ + * queued:list>, + * skipped:list>, + * failed:list>, + * stoppedByNodeBusy:bool + * } */ - private function loadTicketTypeMap(array $ticketIds): array + private function dispatchTicketMetaList(array $metaList, string $source): array + { + $php = $this->resolvePhpBinary(); + $think = $this->resolveThinkScript(); + $root = $this->resolveRootPath(); + + /** @var list> $queued */ + $queued = []; + /** @var list> $skipped */ + $skipped = []; + /** @var list> $failed */ + $failed = []; + $stoppedByNodeBusy = false; + + /** @var list $apiFirstList */ + $apiFirstList = []; + /** @var list $directList */ + $directList = []; + /** @var array> $nodeGroups */ + $nodeGroups = []; + + foreach ($metaList as $meta) { + $ticketType = (string) $meta['ticket_type']; + if (SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) { + $apiFirstList[] = $meta; + continue; + } + if (!SplitScrmSpiderFactory::requiresNode($ticketType)) { + $directList[] = $meta; + continue; + } + $sessionKey = AntiBotConfigBuilder::resolveSessionKey( + $ticketType, + (string) $meta['ticket_url'] + ); + $nodeGroups[$sessionKey][] = $meta; + } + + foreach ($apiFirstList as $meta) { + $this->spawnSingleMeta($php, $think, $root, $meta, $source, $queued, $failed); + } + + foreach ($directList as $meta) { + $this->spawnSingleMeta($php, $think, $root, $meta, $source, $queued, $failed); + } + + foreach ($nodeGroups as $sessionKey => $group) { + if ($group === []) { + continue; + } + + /** @var list $nodeReady */ + $nodeReady = []; + foreach ($group as $meta) { + $ticketType = (string) $meta['ticket_type']; + if (!SplitNodeHealthService::canAcceptWorkForTicketType($ticketType)) { + $stoppedByNodeBusy = true; + $skipped[] = $this->buildDispatchEntry( + (int) $meta['id'], + $ticketType, + (string) $meta['ticket_url'], + 'Node 队列繁忙,本轮未投递' + ); + continue; + } + $nodeReady[] = $meta; + } + + if ($nodeReady === []) { + continue; + } + + if (count($nodeReady) === 1) { + $this->spawnSingleMeta($php, $think, $root, $nodeReady[0], $source, $queued, $failed); + continue; + } + + $ids = array_map(static function (array $row): int { + return (int) $row['id']; + }, $nodeReady); + + if ($this->spawnCliSequential($php, $think, $root, $ids, $source)) { + foreach ($nodeReady as $meta) { + $queued[] = $this->buildQueuedEntry($meta, $sessionKey, 'node-group-serial'); + } + } else { + foreach ($nodeReady as $meta) { + $failed[] = $this->buildDispatchEntry( + (int) $meta['id'], + (string) $meta['ticket_type'], + (string) $meta['ticket_url'], + 'CLI 投递失败' + ); + } + } + } + + return [ + 'queued' => $queued, + 'skipped' => $skipped, + 'failed' => $failed, + 'stoppedByNodeBusy' => $stoppedByNodeBusy, + ]; + } + + /** + * @param list> $queued + * @param list> $failed + * @param array{id:int,ticket_type:string,ticket_url:string} $meta + */ + private function spawnSingleMeta( + string $php, + string $think, + string $root, + array $meta, + string $source, + array &$queued, + array &$failed + ): void { + $ticketId = (int) $meta['id']; + if ($this->spawnCli($php, $think, $root, $ticketId, $source)) { + $queued[] = $this->buildQueuedEntry($meta, '', 'parallel'); + return; + } + + $failed[] = $this->buildDispatchEntry( + $ticketId, + (string) $meta['ticket_type'], + (string) $meta['ticket_url'], + 'CLI 投递失败' + ); + } + + /** + * @param array{id:int,ticket_type:string,ticket_url:string} $meta + * @return array + */ + private function buildQueuedEntry(array $meta, string $sessionKey, string $mode): array + { + return [ + 'ticketId' => (int) $meta['id'], + 'ticketType' => (string) $meta['ticket_type'], + 'ticketUrl' => (string) $meta['ticket_url'], + 'sessionKey' => $sessionKey, + 'mode' => $mode, + 'dispatched' => true, + ]; + } + + /** + * @return array{ticketId:int,ticketType:string,ticketUrl:string,reason:string} + */ + private function buildDispatchEntry(int $ticketId, string $ticketType, string $ticketUrl, string $reason): array + { + return [ + 'ticketId' => $ticketId, + 'ticketType' => $ticketType, + 'ticketUrl' => $ticketUrl, + 'reason' => $reason, + ]; + } + + /** + * @param int[] $ticketIds + * @return list + */ + private function loadTicketMetaList(array $ticketIds): array { $ids = []; foreach ($ticketIds as $rawId) { @@ -135,42 +326,72 @@ class SplitTicketSyncDispatchService $rows = Db::name('split_ticket') ->where('id', 'in', $ids) - ->field('id,ticket_type') + ->field('id,ticket_type,ticket_url') ->select(); - $map = []; + $list = []; foreach ($rows as $row) { - $map[(int) $row['id']] = (string) ($row['ticket_type'] ?? ''); + $list[] = [ + 'id' => (int) $row['id'], + 'ticket_type' => (string) ($row['ticket_type'] ?? ''), + 'ticket_url' => (string) ($row['ticket_url'] ?? ''), + ]; } - return $map; + return $list; } /** - * 后台启动 split:sync-tickets CLI(单工单,并行投递) + * @param array|object $ticket + * @return array{id:int,ticket_type:string,ticket_url:string}|null */ - private function spawnCli(string $php, string $think, string $root, int $ticketId): bool + private function normalizeTicketMeta($ticket): ?array + { + if (is_object($ticket) && method_exists($ticket, 'toArray')) { + $ticket = $ticket->toArray(); + } + if (!is_array($ticket)) { + return null; + } + $ticketId = (int) ($ticket['id'] ?? 0); + if ($ticketId <= 0) { + return null; + } + + return [ + 'id' => $ticketId, + 'ticket_type' => (string) ($ticket['ticket_type'] ?? ''), + 'ticket_url' => (string) ($ticket['ticket_url'] ?? ''), + ]; + } + + /** + * 后台启动 split:sync-tickets CLI(单工单,独立进程) + */ + private function spawnCli(string $php, string $think, string $root, int $ticketId, string $source): bool { $logDir = $this->resolveLogDir(); $logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log'; + $syncMode = $this->normalizeSyncModeForCli($source); $inner = sprintf( - '%s %s split:sync-tickets --ticket=%d >> %s 2>&1', + '%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1', escapeshellarg($php), escapeshellarg($think), $ticketId, + $syncMode, escapeshellarg($logFile) ); - return $this->runBackgroundCommand($root, $inner, $ticketId, 'parallel'); + return $this->runBackgroundCommand($root, $inner, $ticketId, $source . '-parallel'); } /** - * Node 类型工单:单条 nohup 内串行执行多个 CLI,避免并行 Chrome + * 同一 sessionKey 内串行执行多个 CLI(单 nohup 子 shell) * * @param int[] $ticketIds */ - private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds): bool + private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds, string $source): bool { if ($ticketIds === []) { return false; @@ -184,11 +405,13 @@ class SplitTicketSyncDispatchService continue; } $logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log'; + $syncMode = $this->normalizeSyncModeForCli($source); $parts[] = sprintf( - '%s %s split:sync-tickets --ticket=%d >> %s 2>&1', + '%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1', escapeshellarg($php), escapeshellarg($think), $ticketId, + $syncMode, escapeshellarg($logFile) ); } @@ -198,7 +421,7 @@ class SplitTicketSyncDispatchService $inner = implode('; ', $parts); - return $this->runBackgroundCommand($root, $inner, $ticketIds, 'node-serial'); + return $this->runBackgroundCommand($root, $inner, $ticketIds, $source . '-node-serial'); } /** @@ -214,7 +437,7 @@ class SplitTicketSyncDispatchService if ($this->canUseExec()) { @exec($command, $output, $exitCode); - SplitTicketSyncLogger::log('web', 'cli spawn exec', [ + SplitTicketSyncLogger::log('dispatch', 'cli spawn exec', [ 'ticketRef' => $ticketRef, 'mode' => $mode, 'command' => $command, @@ -225,7 +448,7 @@ class SplitTicketSyncDispatchService if ($this->canUseShellExec()) { @shell_exec($command); - SplitTicketSyncLogger::log('web', 'cli spawn shell_exec', [ + SplitTicketSyncLogger::log('dispatch', 'cli spawn shell_exec', [ 'ticketRef' => $ticketRef, 'mode' => $mode, 'command' => $command, @@ -233,7 +456,7 @@ class SplitTicketSyncDispatchService return true; } - SplitTicketSyncLogger::log('web', 'cli spawn failed: exec disabled', [ + SplitTicketSyncLogger::log('dispatch', 'cli spawn failed: exec disabled', [ 'ticketRef' => $ticketRef, 'mode' => $mode, ]); @@ -333,4 +556,12 @@ class SplitTicketSyncDispatchService { return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true); } + + /** + * 投递 CLI 时携带 --mode,供 sync_success_mode 区分自动/手动 + */ + private function normalizeSyncModeForCli(string $source): string + { + return in_array($source, ['auto', 'manual'], true) ? $source : 'manual'; + } } diff --git a/application/common/service/SplitTicketSyncService.php b/application/common/service/SplitTicketSyncService.php index ff1e05d..d37c257 100755 --- a/application/common/service/SplitTicketSyncService.php +++ b/application/common/service/SplitTicketSyncService.php @@ -21,11 +21,14 @@ class SplitTicketSyncService private SplitTicketSyncLockService $lockService; + private SplitTicketSyncDispatchService $dispatchService; + public function __construct() { $this->numberSync = new SplitTicketNumberSyncService(); $this->ruleService = new SplitTicketRuleService(); $this->lockService = new SplitTicketSyncLockService(); + $this->dispatchService = new SplitTicketSyncDispatchService(); } /** @@ -126,7 +129,7 @@ class SplitTicketSyncService ); $successCount = count(array_filter($processed, static function (array $row): bool { - return !empty($row['success']); + return !empty($row['dispatched']); })); $failedCount = count($processed) - $successCount; @@ -151,7 +154,7 @@ class SplitTicketSyncService } /** - * 直连 PHP 通道:按周期同步,不占 Node 名额 + * 直连 PHP 通道:挑选到期工单并投递后台 CLI,不占 Node 名额 * * @param list $directTypes * @return array{ @@ -171,16 +174,10 @@ class SplitTicketSyncService $list = $this->loadOpenTicketsForTypes($directTypes, $failThreshold, 0); $candidateIds = $this->extractTicketIds($list); - SplitTicketSyncLogger::logCron('direct channel start', [ - 'types' => $directTypes, - 'candidateCount' => count($list), - ]); - - /** @var list> $processed */ - $processed = []; - /** @var list> $skipped */ + /** @var list> $dueList */ + $dueList = []; + /** @var list> $skipped */ $skipped = []; - $count = 0; foreach ($list as $ticket) { $ticketId = (int) $ticket['id']; @@ -193,42 +190,60 @@ class SplitTicketSyncService $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip, 'direct'); continue; } + $dueList[] = $ticket; + } - $result = $this->syncOne($ticketId, false, true, 'auto'); - if (!empty($result['skipped'])) { - $skipReason = (string) ($result['message'] ?? '已跳过'); - $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason); - $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason, 'direct'); - continue; - } + SplitTicketSyncLogger::logCron('direct channel start', [ + 'types' => $directTypes, + 'candidateCount' => count($list), + 'dueCount' => count($dueList), + ]); + $dispatchResult = $this->dispatchService->dispatchAuto($dueList); + $skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped'])); + $failed = $this->mapDispatchFailedToCron($dispatchResult['failed']); + + foreach ($failed as $row) { + $skipped[] = $row; + $this->logCronCandidateSkipped( + (int) $row['ticketId'], + (string) $row['ticketType'], + (string) $row['ticketUrl'], + (string) $row['reason'], + 'direct' + ); + } + + /** @var list> $processed */ + $processed = []; + foreach ($dispatchResult['queued'] as $row) { $processed[] = [ - 'ticketId' => $ticketId, - 'ticketType' => $ticketType, - 'ticketUrl' => $ticketUrl, + 'ticketId' => (int) ($row['ticketId'] ?? 0), + 'ticketType' => (string) ($row['ticketType'] ?? ''), + 'ticketUrl' => (string) ($row['ticketUrl'] ?? ''), 'channel' => 'direct', - 'success' => !empty($result['success']), - 'message' => (string) ($result['message'] ?? ''), + 'dispatched' => true, + 'message' => '已投递后台同步', ]; - $count++; } return [ - 'processedCount' => $count, + 'processedCount' => count($processed), 'processed' => $processed, 'skipped' => $skipped, 'candidateIds' => $candidateIds, 'summary' => [ 'channel' => 'direct', - 'processedCount' => $count, + 'processedCount' => count($processed), 'skippedCount' => count($skipped), + 'dispatchMode' => 'async-cli', ], 'stoppedByNodeBusy' => false, ]; } /** - * Node 通道:限流 + 类型公平轮转 + * Node 通道:限流 + 类型公平轮转 + 异步 CLI 投递 * * @param list $nodeTypes * @return array{ @@ -269,123 +284,101 @@ class SplitTicketSyncService 'dueCount' => count($duePool), 'pickedCount' => count($picked), 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), + 'dispatchMode' => 'async-cli', ]); - if (!SplitNodeHealthService::canAcceptWork()) { - SplitTicketSyncLogger::logCron('node channel skip', [ - 'reason' => 'node queue busy', - 'queue' => SplitNodeHealthService::getQueueSnapshot(), - ]); - $skipped = []; - foreach ($picked as $ticket) { - $skipped[] = $this->buildCronTicketEntry( - (int) $ticket['id'], - (string) $ticket['ticket_type'], - (string) $ticket['ticket_url'], - 'Node 队列繁忙,本轮未执行' - ); - } - foreach ($pool as $ticket) { - $ticketId = (int) $ticket['id']; - if (isset($pickedIdMap[$ticketId])) { - continue; - } - $skip = $this->shouldSkip($ticket); - if ($skip !== null) { - continue; - } - $skipped[] = $this->buildCronTicketEntry( - $ticketId, - (string) $ticket['ticket_type'], - (string) $ticket['ticket_url'], - sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit) - ); - } - - return [ - 'processedCount' => 0, - 'processed' => [], - 'skipped' => $skipped, - 'candidateIds' => $candidateIds, - 'summary' => [ - 'channel' => 'node', - 'processedCount' => 0, - 'skippedCount' => count($skipped), - 'reason' => 'node queue busy', - ], - 'stoppedByNodeBusy' => true, - ]; - } - - /** @var list> $processed */ - $processed = []; - /** @var list> $skipped */ + /** @var list> $skipped */ $skipped = []; - $count = 0; - $stoppedByNodeBusy = false; - foreach ($pool as $ticket) { $ticketId = (int) $ticket['id']; - $ticketType = (string) $ticket['ticket_type']; - $ticketUrl = (string) $ticket['ticket_url']; - - if (!isset($pickedIdMap[$ticketId])) { - $skip = $this->shouldSkip($ticket); - if ($skip !== null) { - continue; - } - $skipped[] = $this->buildCronTicketEntry( - $ticketId, - $ticketType, - $ticketUrl, - sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit) - ); + if (isset($pickedIdMap[$ticketId])) { continue; } - - if (!SplitNodeHealthService::canAcceptWork()) { - $stoppedByNodeBusy = true; - $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, 'Node 队列繁忙,停止本轮后续同步'); - $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, 'Node 队列繁忙,停止本轮后续同步', 'node'); + $skip = $this->shouldSkip($ticket); + if ($skip !== null) { continue; } + $skipped[] = $this->buildCronTicketEntry( + $ticketId, + (string) $ticket['ticket_type'], + (string) $ticket['ticket_url'], + sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit) + ); + } - $sessionKey = AntiBotConfigBuilder::resolveSessionKey($ticketType, $ticketUrl); - $result = $this->syncOne($ticketId, false, true, 'auto'); - if (!empty($result['skipped'])) { - $skipReason = (string) ($result['message'] ?? '已跳过'); - $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason); - $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason, 'node'); - continue; - } + $dispatchResult = $this->dispatchService->dispatchAuto($picked); + $skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped'])); + $failed = $this->mapDispatchFailedToCron($dispatchResult['failed']); + foreach ($failed as $row) { + $skipped[] = $row; + $this->logCronCandidateSkipped( + (int) $row['ticketId'], + (string) $row['ticketType'], + (string) $row['ticketUrl'], + (string) $row['reason'], + 'node' + ); + } + + /** @var list> $processed */ + $processed = []; + foreach ($dispatchResult['queued'] as $row) { $processed[] = [ - 'ticketId' => $ticketId, - 'ticketType' => $ticketType, - 'ticketUrl' => $ticketUrl, - 'sessionKey' => $sessionKey, + 'ticketId' => (int) ($row['ticketId'] ?? 0), + 'ticketType' => (string) ($row['ticketType'] ?? ''), + 'ticketUrl' => (string) ($row['ticketUrl'] ?? ''), + 'sessionKey' => (string) ($row['sessionKey'] ?? ''), 'channel' => 'node', - 'success' => !empty($result['success']), - 'message' => (string) ($result['message'] ?? ''), + 'dispatched' => true, + 'message' => '已投递后台同步', ]; - $count++; } return [ - 'processedCount' => $count, + 'processedCount' => count($processed), 'processed' => $processed, 'skipped' => $skipped, 'candidateIds' => $candidateIds, 'summary' => [ 'channel' => 'node', - 'processedCount' => $count, + 'processedCount' => count($processed), 'skippedCount' => count($skipped), 'maxPerRun' => $maxPerRun, + 'dispatchMode' => 'async-cli', ], - 'stoppedByNodeBusy' => $stoppedByNodeBusy, + 'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'], ]; } + /** + * @param list> $rows + * @return list + */ + private function mapDispatchSkippedToCron(array $rows): array + { + $result = []; + foreach ($rows as $row) { + $result[] = $this->buildCronTicketEntry( + (int) ($row['ticketId'] ?? 0), + (string) ($row['ticketType'] ?? ''), + (string) ($row['ticketUrl'] ?? ''), + (string) ($row['reason'] ?? '已跳过') + ); + } + + return $result; + } + + /** + * @param list> $rows + * @return list + */ + private function mapDispatchFailedToCron(array $rows): array + { + return $this->mapDispatchSkippedToCron($rows); + } + /** * @param list $types * @return list diff --git a/patches/application/admin/command/Install/split_number_cleanup_deferred.sql b/patches/application/admin/command/Install/split_number_cleanup_deferred.sql new file mode 100644 index 0000000..3fed801 --- /dev/null +++ b/patches/application/admin/command/Install/split_number_cleanup_deferred.sql @@ -0,0 +1,21 @@ +-- 号码管理:清理无效降权池权限节点 +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', m.id, 'split.number/cleanupdeferred', '清理降权池', 'fa fa-circle-o', '', '清除无落地页访问基线的无效 ratio_deferred 标记', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` m +WHERE m.name = 'split.number' AND m.ismenu = 1 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferred' LIMIT 1) +LIMIT 1; + +-- 可选:一次性修复历史脏数据(与后台「清理降权池」逻辑一致) +-- UPDATE `fa_split_number` +-- SET `ratio_deferred` = 0, `ratio_deferred_at` = NULL, +-- `no_inbound_click_streak` = IF(`visit_count` <= 0, 0, `no_inbound_click_streak`) +-- WHERE `ratio_deferred` = 1 +-- AND (`visit_count` <= 0 OR `visit_count` <= `last_sync_visit_count`); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', m.id, 'split.number/cleanupdeferredpreview', '清理降权池预览', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` m +WHERE m.name = 'split.number' AND m.ismenu = 1 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferredpreview' LIMIT 1) +LIMIT 1; diff --git a/patches/application/admin/command/SplitSyncTickets.php b/patches/application/admin/command/SplitSyncTickets.php index 62bd888..63ea1fe 100755 --- a/patches/application/admin/command/SplitSyncTickets.php +++ b/patches/application/admin/command/SplitSyncTickets.php @@ -18,6 +18,7 @@ use think\console\Output; * 用法: * php think split:sync-tickets * php think split:sync-tickets --ticket=12 + * php think split:sync-tickets --ticket=12 --mode=auto */ class SplitSyncTickets extends Command { @@ -25,21 +26,24 @@ class SplitSyncTickets extends Command { $this->setName('split:sync-tickets') ->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '') + ->addOption('mode', 'm', Option::VALUE_OPTIONAL, '同步方式: auto=定时自动, manual=手动触发', 'manual') ->setDescription('同步分流工单云控数据'); } protected function execute(Input $input, Output $output): void { set_time_limit(0); + $syncMode = $this->resolveSyncMode($input); SplitTicketSyncLogger::log('cli', 'command start', [ 'ticketOption' => trim((string) $input->getOption('ticket')), + 'syncMode' => $syncMode, 'appDebug' => SplitTicketSyncLogger::isEnabled(), ]); $service = new SplitTicketSyncService(); $ticketId = trim((string) $input->getOption('ticket')); if ($ticketId !== '' && ctype_digit($ticketId)) { - $result = $service->syncOne((int) $ticketId, true); + $result = $service->syncOne((int) $ticketId, true, false, $syncMode); if (!empty($result['skipped'])) { $output->writeln('跳过: ' . ($result['message'] ?? '') . ''); return; @@ -61,8 +65,8 @@ class SplitSyncTickets extends Command try { $count = $service->syncDueTickets(); - $output->writeln('本次处理工单数: ' . $count . ''); - $output->writeln('汇总日志已写入 runtime/log/split_sync.log'); + $output->writeln('本次投递工单数: ' . $count . ''); + $output->writeln('同步在后台 CLI 进程中执行,汇总日志见 runtime/log/split_sync.log'); } finally { $cronLock->release(); } @@ -71,4 +75,14 @@ class SplitSyncTickets extends Command $output->writeln('详细调试日志已写入 runtime/log/split_sync.log'); } } + + /** + * 解析 CLI 同步方式,供 sync_success_mode 落库 + */ + private function resolveSyncMode(Input $input): string + { + $mode = strtolower(trim((string) $input->getOption('mode'))); + + return in_array($mode, ['auto', 'manual'], true) ? $mode : 'manual'; + } } diff --git a/patches/application/admin/controller/split/Number.php b/patches/application/admin/controller/split/Number.php index d73b491..07fc38d 100755 --- a/patches/application/admin/controller/split/Number.php +++ b/patches/application/admin/controller/split/Number.php @@ -65,6 +65,7 @@ class Number extends Backend $this->assignconfig('statusList', $this->model->getStatusList()); $this->assignconfig('manualManageList', $this->model->getManualManageList()); $this->assignconfig('platformStatusList', $this->model->getPlatformStatusList()); + $this->assignconfig('ratioDeferredList', $this->model->getRatioDeferredList()); $this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList()); $this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig()); @@ -525,6 +526,47 @@ class Number extends Backend $this->success(sprintf(__('Batch operate success'), $count)); } + /** + * 预览待清理的无效降权号码数量 + */ + public function cleanupdeferredpreview(): void + { + if (false === $this->request->isAjax()) { + $this->error(__('Invalid parameters')); + } + + $service = new \app\common\service\SplitNumberRatioDeferredService(); + $adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null; + $count = $service->countInvalidDeferred(is_array($adminIds) ? $adminIds : null); + + $this->success('', null, ['count' => $count]); + } + + /** + * 清理无效降权号码池(无落地页访问基线却标记 ratio_deferred=1 的记录) + */ + public function cleanupdeferred(): void + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + + $service = new \app\common\service\SplitNumberRatioDeferredService(); + $adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null; + + try { + $count = $service->cleanupInvalidDeferred(is_array($adminIds) ? $adminIds : null); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + } + + if ($count <= 0) { + $this->success(__('Ratio deferred cleanup none')); + } + + $this->success(sprintf(__('Ratio deferred cleanup success'), $count)); + } + /** * 排除不可由表单提交的字段 * diff --git a/patches/application/admin/lang/zh-cn/split/number.php b/patches/application/admin/lang/zh-cn/split/number.php index bec8fa0..22c3bef 100755 --- a/patches/application/admin/lang/zh-cn/split/number.php +++ b/patches/application/admin/lang/zh-cn/split/number.php @@ -51,4 +51,16 @@ return [ 'Invalid manual manage' => '手动管理选项无效', 'Batch update success' => '批量更新成功', 'Unit count' => '个', + 'Ratio_deferred' => '选号降权', + 'Ratio deferred badge' => '降权中', + 'Ratio deferred no' => '正常选号', + 'Ratio deferred yes' => '降权中', + 'Ratio deferred filter' => '仅显示降权中号码', + 'Ratio deferred tooltip'=> '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决', + 'Ratio deferred edit notice' => '该号码处于下号比率触线降权状态,同步后将根据进线情况恢复或关闭', + 'No inbound click streak' => '无进线连续点击', + 'Ratio deferred cleanup btn' => '清理降权池', + 'Ratio deferred cleanup confirm' => '将清除 %d 条无效降权记录(无落地页访问或访问未超过同步基线)。确定继续?', + 'Ratio deferred cleanup success' => '已清理 %d 条无效降权记录', + 'Ratio deferred cleanup none' => '当前没有需要清理的无效降权记录', ]; diff --git a/patches/application/admin/model/split/Number.php b/patches/application/admin/model/split/Number.php index 0231f91..abd0ea8 100755 --- a/patches/application/admin/model/split/Number.php +++ b/patches/application/admin/model/split/Number.php @@ -42,6 +42,7 @@ class Number extends Model 'status_text', 'manual_manage_text', 'platform_status_text', + 'ratio_deferred_text', ]; /** @@ -81,6 +82,17 @@ class Number extends Model ]; } + /** + * @return array + */ + public function getRatioDeferredList(): array + { + return [ + '0' => __('Ratio deferred no'), + '1' => __('Ratio deferred yes'), + ]; + } + /** * @return array */ @@ -158,6 +170,13 @@ class Number extends Model return $list[$key] ?? $key; } + public function getRatioDeferredTextAttr($value, $data): string + { + $key = (string) ((int) ($data['ratio_deferred'] ?? 0)); + $list = $this->getRatioDeferredList(); + return $list[$key] ?? $key; + } + /** * 根据链接码生成完整分流 URL */ diff --git a/patches/application/admin/view/split/number/form_body.html b/patches/application/admin/view/split/number/form_body.html index c29ee48..21e3c38 100755 --- a/patches/application/admin/view/split/number/form_body.html +++ b/patches/application/admin/view/split/number/form_body.html @@ -123,6 +123,13 @@
{if isset($row)} + {if isset($row.ratio_deferred) && $row.ratio_deferred==1 && isset($row.status) && $row.status=='normal'} +
+ + {:__('Ratio deferred edit notice')} + ({:__('No inbound click streak')}:{$row.no_inbound_click_streak|default=0}) +
+ {/if} {else} diff --git a/patches/application/admin/view/split/number/index.html b/patches/application/admin/view/split/number/index.html index 106b43b..00b4e8e 100755 --- a/patches/application/admin/view/split/number/index.html +++ b/patches/application/admin/view/split/number/index.html @@ -18,6 +18,8 @@ {:__('Edit')} {:__('Delete')} {:__('Batch update btn')} + {:__('Ratio deferred badge')} + {:__('Ratio deferred cleanup btn')} {:__('Batch operate btn')}
last_sync_visit_count), + * 且自上次同步以来进线未增长时累计 streak,触线才标记降权。 + */ +class SplitNumberRatioDeferredService +{ + /** + * 是否具备计入 streak 的落地页访问基数 + * + * @param array|Number $number + */ + public function hasLandingVisitBaseline($number): bool + { + $visitCount = $this->readInt($number, 'visit_count'); + if ($visitCount <= 0) { + return false; + } + + $lastVisit = $this->readInt($number, 'last_sync_visit_count'); + + return $visitCount > $lastVisit; + } + + /** + * 根据访问与进线快照计算新的 streak + * + * @param array|Number $number + */ + public function computeStreakForVisit($number, int $currentStreak): int + { + if (!$this->hasLandingVisitBaseline($number)) { + return $currentStreak; + } + + $inboundCount = $this->readInt($number, 'inbound_count'); + $lastInbound = $this->readInt($number, 'last_sync_inbound_count'); + + if ($inboundCount <= $lastInbound) { + return $currentStreak + 1; + } + + return 0; + } + + /** + * 同步侧:根据 visit / inbound 与上次同步检查点重算 streak + * + * @param array|Number $number + */ + public function computeStreakForSync($number, int $currentStreak): int + { + $visitCount = $this->readInt($number, 'visit_count'); + if ($visitCount <= 0) { + return 0; + } + + $lastVisit = $this->readInt($number, 'last_sync_visit_count'); + $inboundCount = $this->readInt($number, 'inbound_count'); + $lastInbound = $this->readInt($number, 'last_sync_inbound_count'); + + if ($inboundCount > $lastInbound) { + return 0; + } + + if ($visitCount > $lastVisit) { + return $currentStreak + ($visitCount - $lastVisit); + } + + return $currentStreak; + } + + /** + * 是否应标记 ratio_deferred(访问侧触线降权,不关 status) + */ + public function shouldMarkDeferred(int $streak, int $assignRatio, bool $hasLandingBaseline): bool + { + return $hasLandingBaseline && $assignRatio > 0 && $streak >= $assignRatio; + } + + /** + * 是否为应清理的无效降权记录 + * + * @param array|Number $number + */ + public function isInvalidDeferredRecord($number): bool + { + if ($this->readInt($number, 'ratio_deferred') !== 1) { + return false; + } + + return !$this->hasLandingVisitBaseline($number); + } + + /** + * 清理无效降权标记(ratio_deferred=1 但无有效落地页访问基线) + * + * @param int[]|null $adminIds 数据权限管理员 ID 列表,null 表示不限制 + * @return int 清理条数 + */ + public function cleanupInvalidDeferred(?array $adminIds = null): int + { + $query = Number::where('ratio_deferred', 1); + if ($adminIds !== null && $adminIds !== []) { + $query->where('admin_id', 'in', $adminIds); + } + + $rows = $query->field('id,visit_count,last_sync_visit_count,ratio_deferred,no_inbound_click_streak')->select(); + if ($rows === null || $rows === []) { + return 0; + } + + $now = time(); + $count = 0; + Db::startTrans(); + try { + foreach ($rows as $row) { + if (!$this->isInvalidDeferredRecord($row)) { + continue; + } + $update = [ + 'ratio_deferred' => 0, + 'ratio_deferred_at' => null, + 'updatetime' => $now, + ]; + if ($this->readInt($row, 'visit_count') <= 0) { + $update['no_inbound_click_streak'] = 0; + } + Number::where('id', (int) $row['id'])->update($update); + $count++; + } + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + + return $count; + } + + /** + * 统计当前权限范围内无效降权条数(清理前预览) + * + * @param int[]|null $adminIds + */ + public function countInvalidDeferred(?array $adminIds = null): int + { + $query = Number::where('ratio_deferred', 1); + if ($adminIds !== null && $adminIds !== []) { + $query->where('admin_id', 'in', $adminIds); + } + + $total = 0; + foreach ($query->field('id,visit_count,last_sync_visit_count,ratio_deferred')->select() as $row) { + if ($this->isInvalidDeferredRecord($row)) { + $total++; + } + } + + return $total; + } + + /** + * @param array|Number $number + */ + private function readInt($number, string $field): int + { + if (is_array($number)) { + return (int) ($number[$field] ?? 0); + } + + return (int) $number->getAttr($field); + } +} diff --git a/patches/application/common/service/SplitNumberVisitRuleService.php b/patches/application/common/service/SplitNumberVisitRuleService.php index 3f38dd4..e3e4a94 100644 --- a/patches/application/common/service/SplitNumberVisitRuleService.php +++ b/patches/application/common/service/SplitNumberVisitRuleService.php @@ -14,6 +14,13 @@ use app\admin\model\split\Ticket; */ class SplitNumberVisitRuleService { + private SplitNumberRatioDeferredService $ratioDeferredService; + + public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null) + { + $this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService(); + } + /** * 访问计数已递增后调用:累加 streak,达到 assign_ratio 时标记 ratio_deferred=1 */ @@ -39,15 +46,11 @@ class SplitNumberVisitRuleService } $assignRatio = (int) ($ticket['assign_ratio'] ?? 0); - $inboundCount = (int) $number['inbound_count']; - $lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0); $streak = (int) ($number['no_inbound_click_streak'] ?? 0); + $hasBaseline = $this->ratioDeferredService->hasLandingVisitBaseline($number); - // 自上次同步检查点以来进线未增长,则本次访问计入 streak - if ($inboundCount <= $lastInbound) { - $streak++; - } else { - $streak = 0; + if ($hasBaseline) { + $streak = $this->ratioDeferredService->computeStreakForVisit($number, $streak); } $update = [ @@ -55,8 +58,9 @@ class SplitNumberVisitRuleService 'updatetime' => time(), ]; - // 非手动号码且达到下号比率:触线降权,不关 status,等待同步确认 - if ((int) $number['manual_manage'] === 0 && $assignRatio > 0 && $streak >= $assignRatio) { + if ((int) $number['manual_manage'] === 0 + && $this->ratioDeferredService->shouldMarkDeferred($streak, $assignRatio, $hasBaseline) + ) { $update['ratio_deferred'] = 1; $update['ratio_deferred_at'] = time(); } diff --git a/patches/application/common/service/SplitNumberWeighService.php b/patches/application/common/service/SplitNumberWeighService.php index 1b46633..1caf292 100755 --- a/patches/application/common/service/SplitNumberWeighService.php +++ b/patches/application/common/service/SplitNumberWeighService.php @@ -9,8 +9,8 @@ use app\admin\model\split\Link; /** * 分流链接随机打乱配置与落地页选号 * - * - random_shuffle=0:跨工单合并号码池,按 id 顺序严格轮转 - * - random_shuffle=1:跨工单合并号码池,每次访问完全随机选号 + * - random_shuffle=0:跨工单合并号码池,按 id 顺序全局严格轮转 + * - random_shuffle=1:先按各工单开启号码数比例随机选工单,再在该工单内随机选号 * - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布) */ class SplitNumberWeighService diff --git a/patches/application/common/service/SplitRedirectService.php b/patches/application/common/service/SplitRedirectService.php index bd6b76d..86c3af3 100755 --- a/patches/application/common/service/SplitRedirectService.php +++ b/patches/application/common/service/SplitRedirectService.php @@ -6,14 +6,17 @@ namespace app\common\service; use app\admin\model\split\Link; use app\admin\model\split\Number; +use app\admin\model\split\Ticket; use think\Collection; /** - * 分流链接落地页:查链、跨工单合并选号、拼接跳转 URL、访问计数 + * 分流链接落地页:查链、选号、拼接跳转 URL、访问计数 * - * 号码池为同一 split_link_id 下全部 status=normal 的号码; - * 优先选用 ratio_deferred=0 的号码,全部触线降权时回退至降权池仍可轮转。 - * random_shuffle 关闭时严格轮转,开启时每次访问随机选号。 + * 号码池:同一 split_link_id 下 status=normal,且所属工单未关闭(ticket.status≠hidden)的号码; + * 优先选用 ratio_deferred=0,全部触线降权时回退至降权池。 + * + * random_shuffle=0:跨工单合并池,全局严格轮转; + * random_shuffle=1:先按各工单开启号码数量比例随机选工单,再在该工单内随机选号。 */ class SplitRedirectService { @@ -55,20 +58,17 @@ class SplitRedirectService return null; } - /** @var Collection|array $numbers */ - $numbers = Number::where('split_link_id', (int) $link['id']) - ->where('status', 'normal') - ->order('id', 'asc') - ->field('id,number,number_type,number_type_custom,ratio_deferred') - ->select(); - - $list = $numbers instanceof Collection ? $numbers->all() : (array) $numbers; + $linkId = (int) $link->getAttr('id'); + $list = $this->loadEligibleNumbers($linkId); if ($list === []) { return null; } - $linkId = (int) $link->getAttr('id'); - $picked = $this->pickNumberRow($linkId, $list); + if (SplitNumberWeighService::isRandomShuffleEnabled($linkId)) { + $picked = $this->pickNumberRowRandomByTicketWeight($linkId, $list); + } else { + $picked = $this->pickNumberRow($linkId, $list); + } if ($picked === null) { return null; } @@ -103,10 +103,117 @@ class SplitRedirectService return $redirectUrl; } + /** + * 可参与选号的号码:status=normal,且 ticket_name 不属于已关闭工单 + * + * @return list> + */ + private function loadEligibleNumbers(int $linkId): array + { + if ($linkId <= 0) { + return []; + } + + /** @var list $hiddenTicketNames */ + $hiddenTicketNames = Ticket::where('split_link_id', $linkId) + ->where('status', 'hidden') + ->column('ticket_name'); + $hiddenTicketNames = array_values(array_unique(array_filter(array_map( + static function ($name): string { + return trim((string) $name); + }, + $hiddenTicketNames + )))); + + $query = Number::where('split_link_id', $linkId) + ->where('status', 'normal') + ->order('id', 'asc') + ->field('id,number,number_type,number_type_custom,ratio_deferred,ticket_name'); + + if ($hiddenTicketNames !== []) { + $query->where('ticket_name', 'not in', $hiddenTicketNames); + } + + $numbers = $query->select(); + return $numbers instanceof Collection ? $numbers->all() : (array) $numbers; + } + + /** + * random_shuffle=1:先按工单开启号码数加权随机选工单,再在该工单内选号 + * + * @param list> $rows + * @return array|Number|null + */ + private function pickNumberRowRandomByTicketWeight(int $linkId, array $rows) + { + $groups = $this->groupNumbersByTicket($rows); + if ($groups === []) { + return null; + } + + $ticketKey = $this->pickTicketGroupKeyWeighted($groups); + if ($ticketKey === null || !isset($groups[$ticketKey])) { + return null; + } + + return $this->pickNumberRow($linkId, $groups[$ticketKey]); + } + + /** + * @param list> $rows + * @return array>> + */ + private function groupNumbersByTicket(array $rows): array + { + /** @var array>> $groups */ + $groups = []; + foreach ($rows as $row) { + $name = is_array($row) + ? trim((string) ($row['ticket_name'] ?? '')) + : trim((string) $row->getAttr('ticket_name')); + $key = $name !== '' ? $name : '__manual__'; + $groups[$key][] = $row; + } + + return $groups; + } + + /** + * 按各分组号码数量加权随机选一个工单(分组 key) + * + * @param array>> $groups + */ + private function pickTicketGroupKeyWeighted(array $groups): ?string + { + $total = 0; + foreach ($groups as $rows) { + $total += count($rows); + } + if ($total <= 0) { + return null; + } + + $keys = array_keys($groups); + if (count($keys) === 1) { + return $keys[0]; + } + + $r = random_int(1, $total); + $acc = 0; + foreach ($groups as $key => $rows) { + $acc += count($rows); + if ($r <= $acc) { + return $key; + } + } + + return $keys[0]; + } + /** * 双层选号:优先 ratio_deferred=0,否则回退至降权池 * - * @param array|Number> $rows + * @param list> $rows * @return array|Number|null */ private function pickNumberRow(int $linkId, array $rows) diff --git a/patches/application/common/service/SplitScrmSpiderFactory.php b/patches/application/common/service/SplitScrmSpiderFactory.php index e96981b..90a5f0c 100755 --- a/patches/application/common/service/SplitScrmSpiderFactory.php +++ b/patches/application/common/service/SplitScrmSpiderFactory.php @@ -33,6 +33,17 @@ class SplitScrmSpiderFactory // ceo_scrm 等未实现类型:新增 spider 类后在此注册 ]; + /** + * API 优先同步类型:常态走 PHP cURL,不占 Node Real Browser 槽位 + * + * 调度层据此跳过 Node 队列门禁并允许并行投递;蜘蛛实现本身不在此修改。 + * + * @var list + */ + private const API_FIRST_SYNC_TYPES = [ + 'whatshub', + ]; + /** * @return class-string|null */ @@ -60,6 +71,22 @@ class SplitScrmSpiderFactory return array_keys(self::MAP); } + /** + * 是否 API 优先类型(调度层可并行投递且不占 Node 队列) + */ + public static function isApiFirstSyncType(string $ticketType): bool + { + return in_array(trim($ticketType), self::API_FIRST_SYNC_TYPES, true); + } + + /** + * @return list + */ + public static function listApiFirstSyncTypes(): array + { + return self::API_FIRST_SYNC_TYPES; + } + /** * 同步是否依赖 Node Headless(纯 PHP cURL 等为 false) */ diff --git a/patches/application/common/service/SplitSyncConfigService.php b/patches/application/common/service/SplitSyncConfigService.php index 91b74c8..c667c3c 100755 --- a/patches/application/common/service/SplitSyncConfigService.php +++ b/patches/application/common/service/SplitSyncConfigService.php @@ -59,7 +59,7 @@ class SplitSyncConfigService if ($value === '') { return 5; } - return max(1, min(20, (int) $value)); + return max(1, min(50, (int) $value)); } /** diff --git a/patches/application/common/service/SplitTicketRuleService.php b/patches/application/common/service/SplitTicketRuleService.php index d2514ab..9c458e0 100755 --- a/patches/application/common/service/SplitTicketRuleService.php +++ b/patches/application/common/service/SplitTicketRuleService.php @@ -12,6 +12,13 @@ use app\admin\model\split\Ticket; */ class SplitTicketRuleService { + private SplitNumberRatioDeferredService $ratioDeferredService; + + public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null) + { + $this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService(); + } + /** * 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率) */ @@ -108,14 +115,13 @@ class SplitTicketRuleService foreach ($numbers as $number) { $visitCount = (int) $number['visit_count']; $inboundCount = (int) $number['inbound_count']; - $lastVisit = (int) ($number['last_sync_visit_count'] ?? 0); $lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0); $streak = (int) ($number['no_inbound_click_streak'] ?? 0); - if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) { - $streak += ($visitCount - $lastVisit); - } elseif ($inboundCount > $lastInbound) { + if ($visitCount <= 0) { $streak = 0; + } else { + $streak = $this->ratioDeferredService->computeStreakForSync($number, $streak); } $update = [ @@ -125,20 +131,25 @@ class SplitTicketRuleService 'updatetime' => time(), ]; - // 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态与降权标记 + $clearDeferred = [ + 'ratio_deferred' => 0, + 'ratio_deferred_at' => null, + ]; + + // 手动管理:更新统计;无效降权(无落地页访问)一并清除 if ((int) $number['manual_manage'] === 1) { + if ($this->ratioDeferredService->isInvalidDeferredRecord($number) || $visitCount <= 0) { + $update = array_merge($update, $clearDeferred); + if ($visitCount <= 0) { + $update['no_inbound_click_streak'] = 0; + } + } Number::where('id', (int) $number['id'])->update($update); continue; } - // 同步裁决 ratio_deferred:进线增长则解除降权;触线且无进线增长则关号并清除降权 - if ($inboundCount > $lastInbound) { - $update['ratio_deferred'] = 0; - $update['ratio_deferred_at'] = null; - } else { - $update['ratio_deferred'] = 0; - $update['ratio_deferred_at'] = null; - } + // 同步裁决 ratio_deferred:同步时清除访问侧降权,由 streak 决定是否关号 + $update = array_merge($update, $clearDeferred); $update['status'] = $this->resolveAutomatedStatus( $ticket, diff --git a/patches/application/common/service/SplitTicketSyncDispatchService.php b/patches/application/common/service/SplitTicketSyncDispatchService.php index 6a06409..71faa83 100644 --- a/patches/application/common/service/SplitTicketSyncDispatchService.php +++ b/patches/application/common/service/SplitTicketSyncDispatchService.php @@ -4,15 +4,19 @@ declare(strict_types=1); namespace app\common\service; +use app\common\library\scrm\AntiBotConfigBuilder; use think\Db; /** - * 工单手工同步 CLI 后台投递 + * 工单同步 CLI 后台投递 * - * Web 请求仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行, - * 避免长时间占用 PHP-FPM 与 Session 锁导致后台其它页面无法打开。 + * Web / Cron 仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行, + * 避免长时间占用 PHP-FPM、Cron 全局锁或 Session 锁。 * - * Node 类型(A2C / Whatshub 等)在同一 nohup 子 shell 内串行执行,避免并行 Chrome 压垮温池。 + * 并行策略: + * - API 优先类型(Whatshub 等):独立并行投递,不占 Node Real Browser 槽位 + * - 直连 PHP 类型:独立并行投递 + * - Node 类型:不同 sessionKey(域名)并行;同一 sessionKey 内串行以命中 Browser 温池 */ class SplitTicketSyncDispatchService { @@ -24,75 +28,91 @@ class SplitTicketSyncDispatchService */ public function dispatchManual(array $ticketIds): array { - $queued = []; + $lockService = new SplitTicketSyncLockService(); + $metaList = $this->loadTicketMetaList($ticketIds); + + /** @var list $toDispatch */ + $toDispatch = []; $skipped = []; $failed = []; - $php = $this->resolvePhpBinary(); - $think = $this->resolveThinkScript(); - $root = $this->resolveRootPath(); - $lockService = new SplitTicketSyncLockService(); - $nodeTypeMap = array_flip(SplitScrmSpiderFactory::listNodeSyncTypes()); - $typeMap = $this->loadTicketTypeMap($ticketIds); - - /** @var int[] $nodeTicketIds */ - $nodeTicketIds = []; - /** @var int[] $directTicketIds */ - $directTicketIds = []; - - foreach ($ticketIds as $rawId) { - $ticketId = (int) $rawId; - if ($ticketId <= 0) { - continue; - } - + foreach ($metaList as $meta) { + $ticketId = (int) $meta['id']; if ($lockService->isLocked($ticketId)) { $skipped[] = $ticketId; continue; } - - $ticketType = (string) ($typeMap[$ticketId] ?? ''); - if ($ticketType !== '' && isset($nodeTypeMap[$ticketType])) { - $nodeTicketIds[] = $ticketId; - } else { - $directTicketIds[] = $ticketId; - } + $toDispatch[] = $meta; } - foreach ($directTicketIds as $ticketId) { - if ($this->spawnCli($php, $think, $root, $ticketId)) { - $queued[] = $ticketId; - } else { - $failed[] = $ticketId; - } - } - - if ($nodeTicketIds !== []) { - $spawned = $this->spawnCliSequential($php, $think, $root, $nodeTicketIds); - if ($spawned) { - foreach ($nodeTicketIds as $ticketId) { - $queued[] = $ticketId; - } - } else { - foreach ($nodeTicketIds as $ticketId) { - $failed[] = $ticketId; - } - } - } + $dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'manual'); SplitTicketSyncLogger::log('web', 'manual sync dispatched', [ - 'queued' => $queued, - 'skipped' => $skipped, - 'failed' => $failed, - 'nodeSerial' => $nodeTicketIds, - 'directParallel' => $directTicketIds, - 'phpCli' => $php, + 'queued' => $dispatchResult['queued'], + 'skipped' => $skipped, + 'failed' => $dispatchResult['failed'], + 'phpCli' => $this->resolvePhpBinary(), ]); return [ - 'queued' => $queued, + 'queued' => $dispatchResult['queued'], 'skipped' => $skipped, - 'failed' => $failed, + 'failed' => $dispatchResult['failed'], + ]; + } + + /** + * Cron 自动同步:将挑选后的到期工单投递到后台 CLI(Cron 进程立即返回) + * + * @param list $tickets + * @return array{ + * queued:list>, + * skipped:list>, + * failed:list>, + * stoppedByNodeBusy:bool + * } + */ + public function dispatchAuto(array $tickets): array + { + $lockService = new SplitTicketSyncLockService(); + /** @var list $toDispatch */ + $toDispatch = []; + /** @var list> $skipped */ + $skipped = []; + + foreach ($tickets as $ticket) { + $meta = $this->normalizeTicketMeta($ticket); + if ($meta === null) { + continue; + } + $ticketId = (int) $meta['id']; + if ($lockService->isLocked($ticketId)) { + $skipped[] = $this->buildDispatchEntry( + $ticketId, + (string) $meta['ticket_type'], + (string) $meta['ticket_url'], + '工单正在同步中' + ); + continue; + } + $toDispatch[] = $meta; + } + + $dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'auto'); + + SplitTicketSyncLogger::log('cron', 'auto sync dispatched', [ + 'queuedCount' => count($dispatchResult['queued']), + 'skippedCount' => count($skipped) + count($dispatchResult['skipped']), + 'failedCount' => count($dispatchResult['failed']), + 'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'], + 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), + ]); + + return [ + 'queued' => $dispatchResult['queued'], + 'skipped' => array_merge($skipped, $dispatchResult['skipped']), + 'failed' => $dispatchResult['failed'], + 'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'], ]; } @@ -117,10 +137,181 @@ class SplitTicketSyncDispatchService } /** - * @param int[] $ticketIds - * @return array ticketId => ticket_type + * @param list $metaList + * @return array{ + * queued:list>, + * skipped:list>, + * failed:list>, + * stoppedByNodeBusy:bool + * } */ - private function loadTicketTypeMap(array $ticketIds): array + private function dispatchTicketMetaList(array $metaList, string $source): array + { + $php = $this->resolvePhpBinary(); + $think = $this->resolveThinkScript(); + $root = $this->resolveRootPath(); + + /** @var list> $queued */ + $queued = []; + /** @var list> $skipped */ + $skipped = []; + /** @var list> $failed */ + $failed = []; + $stoppedByNodeBusy = false; + + /** @var list $apiFirstList */ + $apiFirstList = []; + /** @var list $directList */ + $directList = []; + /** @var array> $nodeGroups */ + $nodeGroups = []; + + foreach ($metaList as $meta) { + $ticketType = (string) $meta['ticket_type']; + if (SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) { + $apiFirstList[] = $meta; + continue; + } + if (!SplitScrmSpiderFactory::requiresNode($ticketType)) { + $directList[] = $meta; + continue; + } + $sessionKey = AntiBotConfigBuilder::resolveSessionKey( + $ticketType, + (string) $meta['ticket_url'] + ); + $nodeGroups[$sessionKey][] = $meta; + } + + foreach ($apiFirstList as $meta) { + $this->spawnSingleMeta($php, $think, $root, $meta, $source, $queued, $failed); + } + + foreach ($directList as $meta) { + $this->spawnSingleMeta($php, $think, $root, $meta, $source, $queued, $failed); + } + + foreach ($nodeGroups as $sessionKey => $group) { + if ($group === []) { + continue; + } + + /** @var list $nodeReady */ + $nodeReady = []; + foreach ($group as $meta) { + $ticketType = (string) $meta['ticket_type']; + if (!SplitNodeHealthService::canAcceptWorkForTicketType($ticketType)) { + $stoppedByNodeBusy = true; + $skipped[] = $this->buildDispatchEntry( + (int) $meta['id'], + $ticketType, + (string) $meta['ticket_url'], + 'Node 队列繁忙,本轮未投递' + ); + continue; + } + $nodeReady[] = $meta; + } + + if ($nodeReady === []) { + continue; + } + + if (count($nodeReady) === 1) { + $this->spawnSingleMeta($php, $think, $root, $nodeReady[0], $source, $queued, $failed); + continue; + } + + $ids = array_map(static function (array $row): int { + return (int) $row['id']; + }, $nodeReady); + + if ($this->spawnCliSequential($php, $think, $root, $ids, $source)) { + foreach ($nodeReady as $meta) { + $queued[] = $this->buildQueuedEntry($meta, $sessionKey, 'node-group-serial'); + } + } else { + foreach ($nodeReady as $meta) { + $failed[] = $this->buildDispatchEntry( + (int) $meta['id'], + (string) $meta['ticket_type'], + (string) $meta['ticket_url'], + 'CLI 投递失败' + ); + } + } + } + + return [ + 'queued' => $queued, + 'skipped' => $skipped, + 'failed' => $failed, + 'stoppedByNodeBusy' => $stoppedByNodeBusy, + ]; + } + + /** + * @param list> $queued + * @param list> $failed + * @param array{id:int,ticket_type:string,ticket_url:string} $meta + */ + private function spawnSingleMeta( + string $php, + string $think, + string $root, + array $meta, + string $source, + array &$queued, + array &$failed + ): void { + $ticketId = (int) $meta['id']; + if ($this->spawnCli($php, $think, $root, $ticketId, $source)) { + $queued[] = $this->buildQueuedEntry($meta, '', 'parallel'); + return; + } + + $failed[] = $this->buildDispatchEntry( + $ticketId, + (string) $meta['ticket_type'], + (string) $meta['ticket_url'], + 'CLI 投递失败' + ); + } + + /** + * @param array{id:int,ticket_type:string,ticket_url:string} $meta + * @return array + */ + private function buildQueuedEntry(array $meta, string $sessionKey, string $mode): array + { + return [ + 'ticketId' => (int) $meta['id'], + 'ticketType' => (string) $meta['ticket_type'], + 'ticketUrl' => (string) $meta['ticket_url'], + 'sessionKey' => $sessionKey, + 'mode' => $mode, + 'dispatched' => true, + ]; + } + + /** + * @return array{ticketId:int,ticketType:string,ticketUrl:string,reason:string} + */ + private function buildDispatchEntry(int $ticketId, string $ticketType, string $ticketUrl, string $reason): array + { + return [ + 'ticketId' => $ticketId, + 'ticketType' => $ticketType, + 'ticketUrl' => $ticketUrl, + 'reason' => $reason, + ]; + } + + /** + * @param int[] $ticketIds + * @return list + */ + private function loadTicketMetaList(array $ticketIds): array { $ids = []; foreach ($ticketIds as $rawId) { @@ -135,42 +326,72 @@ class SplitTicketSyncDispatchService $rows = Db::name('split_ticket') ->where('id', 'in', $ids) - ->field('id,ticket_type') + ->field('id,ticket_type,ticket_url') ->select(); - $map = []; + $list = []; foreach ($rows as $row) { - $map[(int) $row['id']] = (string) ($row['ticket_type'] ?? ''); + $list[] = [ + 'id' => (int) $row['id'], + 'ticket_type' => (string) ($row['ticket_type'] ?? ''), + 'ticket_url' => (string) ($row['ticket_url'] ?? ''), + ]; } - return $map; + return $list; } /** - * 后台启动 split:sync-tickets CLI(单工单,并行投递) + * @param array|object $ticket + * @return array{id:int,ticket_type:string,ticket_url:string}|null */ - private function spawnCli(string $php, string $think, string $root, int $ticketId): bool + private function normalizeTicketMeta($ticket): ?array + { + if (is_object($ticket) && method_exists($ticket, 'toArray')) { + $ticket = $ticket->toArray(); + } + if (!is_array($ticket)) { + return null; + } + $ticketId = (int) ($ticket['id'] ?? 0); + if ($ticketId <= 0) { + return null; + } + + return [ + 'id' => $ticketId, + 'ticket_type' => (string) ($ticket['ticket_type'] ?? ''), + 'ticket_url' => (string) ($ticket['ticket_url'] ?? ''), + ]; + } + + /** + * 后台启动 split:sync-tickets CLI(单工单,独立进程) + */ + private function spawnCli(string $php, string $think, string $root, int $ticketId, string $source): bool { $logDir = $this->resolveLogDir(); $logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log'; + $syncMode = $this->normalizeSyncModeForCli($source); $inner = sprintf( - '%s %s split:sync-tickets --ticket=%d >> %s 2>&1', + '%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1', escapeshellarg($php), escapeshellarg($think), $ticketId, + $syncMode, escapeshellarg($logFile) ); - return $this->runBackgroundCommand($root, $inner, $ticketId, 'parallel'); + return $this->runBackgroundCommand($root, $inner, $ticketId, $source . '-parallel'); } /** - * Node 类型工单:单条 nohup 内串行执行多个 CLI,避免并行 Chrome + * 同一 sessionKey 内串行执行多个 CLI(单 nohup 子 shell) * * @param int[] $ticketIds */ - private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds): bool + private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds, string $source): bool { if ($ticketIds === []) { return false; @@ -184,11 +405,13 @@ class SplitTicketSyncDispatchService continue; } $logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log'; + $syncMode = $this->normalizeSyncModeForCli($source); $parts[] = sprintf( - '%s %s split:sync-tickets --ticket=%d >> %s 2>&1', + '%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1', escapeshellarg($php), escapeshellarg($think), $ticketId, + $syncMode, escapeshellarg($logFile) ); } @@ -198,7 +421,7 @@ class SplitTicketSyncDispatchService $inner = implode('; ', $parts); - return $this->runBackgroundCommand($root, $inner, $ticketIds, 'node-serial'); + return $this->runBackgroundCommand($root, $inner, $ticketIds, $source . '-node-serial'); } /** @@ -214,7 +437,7 @@ class SplitTicketSyncDispatchService if ($this->canUseExec()) { @exec($command, $output, $exitCode); - SplitTicketSyncLogger::log('web', 'cli spawn exec', [ + SplitTicketSyncLogger::log('dispatch', 'cli spawn exec', [ 'ticketRef' => $ticketRef, 'mode' => $mode, 'command' => $command, @@ -225,7 +448,7 @@ class SplitTicketSyncDispatchService if ($this->canUseShellExec()) { @shell_exec($command); - SplitTicketSyncLogger::log('web', 'cli spawn shell_exec', [ + SplitTicketSyncLogger::log('dispatch', 'cli spawn shell_exec', [ 'ticketRef' => $ticketRef, 'mode' => $mode, 'command' => $command, @@ -233,7 +456,7 @@ class SplitTicketSyncDispatchService return true; } - SplitTicketSyncLogger::log('web', 'cli spawn failed: exec disabled', [ + SplitTicketSyncLogger::log('dispatch', 'cli spawn failed: exec disabled', [ 'ticketRef' => $ticketRef, 'mode' => $mode, ]); @@ -333,4 +556,12 @@ class SplitTicketSyncDispatchService { return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true); } + + /** + * 投递 CLI 时携带 --mode,供 sync_success_mode 区分自动/手动 + */ + private function normalizeSyncModeForCli(string $source): string + { + return in_array($source, ['auto', 'manual'], true) ? $source : 'manual'; + } } diff --git a/patches/application/common/service/SplitTicketSyncService.php b/patches/application/common/service/SplitTicketSyncService.php index ff1e05d..d37c257 100755 --- a/patches/application/common/service/SplitTicketSyncService.php +++ b/patches/application/common/service/SplitTicketSyncService.php @@ -21,11 +21,14 @@ class SplitTicketSyncService private SplitTicketSyncLockService $lockService; + private SplitTicketSyncDispatchService $dispatchService; + public function __construct() { $this->numberSync = new SplitTicketNumberSyncService(); $this->ruleService = new SplitTicketRuleService(); $this->lockService = new SplitTicketSyncLockService(); + $this->dispatchService = new SplitTicketSyncDispatchService(); } /** @@ -126,7 +129,7 @@ class SplitTicketSyncService ); $successCount = count(array_filter($processed, static function (array $row): bool { - return !empty($row['success']); + return !empty($row['dispatched']); })); $failedCount = count($processed) - $successCount; @@ -151,7 +154,7 @@ class SplitTicketSyncService } /** - * 直连 PHP 通道:按周期同步,不占 Node 名额 + * 直连 PHP 通道:挑选到期工单并投递后台 CLI,不占 Node 名额 * * @param list $directTypes * @return array{ @@ -171,16 +174,10 @@ class SplitTicketSyncService $list = $this->loadOpenTicketsForTypes($directTypes, $failThreshold, 0); $candidateIds = $this->extractTicketIds($list); - SplitTicketSyncLogger::logCron('direct channel start', [ - 'types' => $directTypes, - 'candidateCount' => count($list), - ]); - - /** @var list> $processed */ - $processed = []; - /** @var list> $skipped */ + /** @var list> $dueList */ + $dueList = []; + /** @var list> $skipped */ $skipped = []; - $count = 0; foreach ($list as $ticket) { $ticketId = (int) $ticket['id']; @@ -193,42 +190,60 @@ class SplitTicketSyncService $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip, 'direct'); continue; } + $dueList[] = $ticket; + } - $result = $this->syncOne($ticketId, false, true, 'auto'); - if (!empty($result['skipped'])) { - $skipReason = (string) ($result['message'] ?? '已跳过'); - $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason); - $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason, 'direct'); - continue; - } + SplitTicketSyncLogger::logCron('direct channel start', [ + 'types' => $directTypes, + 'candidateCount' => count($list), + 'dueCount' => count($dueList), + ]); + $dispatchResult = $this->dispatchService->dispatchAuto($dueList); + $skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped'])); + $failed = $this->mapDispatchFailedToCron($dispatchResult['failed']); + + foreach ($failed as $row) { + $skipped[] = $row; + $this->logCronCandidateSkipped( + (int) $row['ticketId'], + (string) $row['ticketType'], + (string) $row['ticketUrl'], + (string) $row['reason'], + 'direct' + ); + } + + /** @var list> $processed */ + $processed = []; + foreach ($dispatchResult['queued'] as $row) { $processed[] = [ - 'ticketId' => $ticketId, - 'ticketType' => $ticketType, - 'ticketUrl' => $ticketUrl, + 'ticketId' => (int) ($row['ticketId'] ?? 0), + 'ticketType' => (string) ($row['ticketType'] ?? ''), + 'ticketUrl' => (string) ($row['ticketUrl'] ?? ''), 'channel' => 'direct', - 'success' => !empty($result['success']), - 'message' => (string) ($result['message'] ?? ''), + 'dispatched' => true, + 'message' => '已投递后台同步', ]; - $count++; } return [ - 'processedCount' => $count, + 'processedCount' => count($processed), 'processed' => $processed, 'skipped' => $skipped, 'candidateIds' => $candidateIds, 'summary' => [ 'channel' => 'direct', - 'processedCount' => $count, + 'processedCount' => count($processed), 'skippedCount' => count($skipped), + 'dispatchMode' => 'async-cli', ], 'stoppedByNodeBusy' => false, ]; } /** - * Node 通道:限流 + 类型公平轮转 + * Node 通道:限流 + 类型公平轮转 + 异步 CLI 投递 * * @param list $nodeTypes * @return array{ @@ -269,123 +284,101 @@ class SplitTicketSyncService 'dueCount' => count($duePool), 'pickedCount' => count($picked), 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), + 'dispatchMode' => 'async-cli', ]); - if (!SplitNodeHealthService::canAcceptWork()) { - SplitTicketSyncLogger::logCron('node channel skip', [ - 'reason' => 'node queue busy', - 'queue' => SplitNodeHealthService::getQueueSnapshot(), - ]); - $skipped = []; - foreach ($picked as $ticket) { - $skipped[] = $this->buildCronTicketEntry( - (int) $ticket['id'], - (string) $ticket['ticket_type'], - (string) $ticket['ticket_url'], - 'Node 队列繁忙,本轮未执行' - ); - } - foreach ($pool as $ticket) { - $ticketId = (int) $ticket['id']; - if (isset($pickedIdMap[$ticketId])) { - continue; - } - $skip = $this->shouldSkip($ticket); - if ($skip !== null) { - continue; - } - $skipped[] = $this->buildCronTicketEntry( - $ticketId, - (string) $ticket['ticket_type'], - (string) $ticket['ticket_url'], - sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit) - ); - } - - return [ - 'processedCount' => 0, - 'processed' => [], - 'skipped' => $skipped, - 'candidateIds' => $candidateIds, - 'summary' => [ - 'channel' => 'node', - 'processedCount' => 0, - 'skippedCount' => count($skipped), - 'reason' => 'node queue busy', - ], - 'stoppedByNodeBusy' => true, - ]; - } - - /** @var list> $processed */ - $processed = []; - /** @var list> $skipped */ + /** @var list> $skipped */ $skipped = []; - $count = 0; - $stoppedByNodeBusy = false; - foreach ($pool as $ticket) { $ticketId = (int) $ticket['id']; - $ticketType = (string) $ticket['ticket_type']; - $ticketUrl = (string) $ticket['ticket_url']; - - if (!isset($pickedIdMap[$ticketId])) { - $skip = $this->shouldSkip($ticket); - if ($skip !== null) { - continue; - } - $skipped[] = $this->buildCronTicketEntry( - $ticketId, - $ticketType, - $ticketUrl, - sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit) - ); + if (isset($pickedIdMap[$ticketId])) { continue; } - - if (!SplitNodeHealthService::canAcceptWork()) { - $stoppedByNodeBusy = true; - $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, 'Node 队列繁忙,停止本轮后续同步'); - $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, 'Node 队列繁忙,停止本轮后续同步', 'node'); + $skip = $this->shouldSkip($ticket); + if ($skip !== null) { continue; } + $skipped[] = $this->buildCronTicketEntry( + $ticketId, + (string) $ticket['ticket_type'], + (string) $ticket['ticket_url'], + sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit) + ); + } - $sessionKey = AntiBotConfigBuilder::resolveSessionKey($ticketType, $ticketUrl); - $result = $this->syncOne($ticketId, false, true, 'auto'); - if (!empty($result['skipped'])) { - $skipReason = (string) ($result['message'] ?? '已跳过'); - $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason); - $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason, 'node'); - continue; - } + $dispatchResult = $this->dispatchService->dispatchAuto($picked); + $skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped'])); + $failed = $this->mapDispatchFailedToCron($dispatchResult['failed']); + foreach ($failed as $row) { + $skipped[] = $row; + $this->logCronCandidateSkipped( + (int) $row['ticketId'], + (string) $row['ticketType'], + (string) $row['ticketUrl'], + (string) $row['reason'], + 'node' + ); + } + + /** @var list> $processed */ + $processed = []; + foreach ($dispatchResult['queued'] as $row) { $processed[] = [ - 'ticketId' => $ticketId, - 'ticketType' => $ticketType, - 'ticketUrl' => $ticketUrl, - 'sessionKey' => $sessionKey, + 'ticketId' => (int) ($row['ticketId'] ?? 0), + 'ticketType' => (string) ($row['ticketType'] ?? ''), + 'ticketUrl' => (string) ($row['ticketUrl'] ?? ''), + 'sessionKey' => (string) ($row['sessionKey'] ?? ''), 'channel' => 'node', - 'success' => !empty($result['success']), - 'message' => (string) ($result['message'] ?? ''), + 'dispatched' => true, + 'message' => '已投递后台同步', ]; - $count++; } return [ - 'processedCount' => $count, + 'processedCount' => count($processed), 'processed' => $processed, 'skipped' => $skipped, 'candidateIds' => $candidateIds, 'summary' => [ 'channel' => 'node', - 'processedCount' => $count, + 'processedCount' => count($processed), 'skippedCount' => count($skipped), 'maxPerRun' => $maxPerRun, + 'dispatchMode' => 'async-cli', ], - 'stoppedByNodeBusy' => $stoppedByNodeBusy, + 'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'], ]; } + /** + * @param list> $rows + * @return list + */ + private function mapDispatchSkippedToCron(array $rows): array + { + $result = []; + foreach ($rows as $row) { + $result[] = $this->buildCronTicketEntry( + (int) ($row['ticketId'] ?? 0), + (string) ($row['ticketType'] ?? ''), + (string) ($row['ticketUrl'] ?? ''), + (string) ($row['reason'] ?? '已跳过') + ); + } + + return $result; + } + + /** + * @param list> $rows + * @return list + */ + private function mapDispatchFailedToCron(array $rows): array + { + return $this->mapDispatchSkippedToCron($rows); + } + /** * @param list $types * @return list diff --git a/patches/public/assets/js/backend/split/number.js b/patches/public/assets/js/backend/split/number.js index e6d771b..07ca9ed 100755 --- a/patches/public/assets/js/backend/split/number.js +++ b/patches/public/assets/js/backend/split/number.js @@ -37,7 +37,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin formatter: Table.api.formatter.content }, {field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'}, - {field: 'number', title: __('Number'), operate: 'LIKE'}, + { + field: 'number', + title: __('Number'), + operate: 'LIKE', + formatter: Controller.api.formatter.numberWithDeferred + }, $.extend( {field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal}, searchSelectMeta(false) @@ -52,6 +57,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'}, searchSelectMeta(false) ), + $.extend( + { + field: 'ratio_deferred', + title: __('Ratio_deferred'), + visible: false, + searchable: true, + operate: '=', + searchList: Controller.api.buildRatioDeferredSearchList() + }, + searchSelectMeta(false) + ), { field: 'createtime', title: __('Createtime'), @@ -97,11 +113,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.openBatchOperateModal(table); }); + table.on('post-body.bs.table', function () { + table.find('[data-toggle="tooltip"]').tooltip({container: 'body'}); + }); + table.on('post-common-search.bs.table', function (e, tbl) { Controller.api.initCommonSearchSelectpicker(tbl); }); Table.api.bindevent(table); + Controller.api.bindDeferredFilter(table); + Controller.api.bindDeferredCleanup(table); }, add: function () { Controller.api.bindevent(); @@ -138,6 +160,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin } return ext; }, + /** + * PHP json_encode 会把 ['0'=>..,'1'=>..] 压成数组,FastAdmin 通用搜索会误用文案作 option value + */ + buildRatioDeferredSearchList: function () { + var list = Config.ratioDeferredList; + if ($.isArray(list)) { + return {0: list[0], 1: list[1]}; + } + return list || {}; + }, initCommonSearchSelectpicker: function (tbl) { var $form = $('form.form-commonsearch', tbl.$container); if ($form.length) { @@ -152,8 +184,152 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin : (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value); var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning'); return '' + Fast.api.escape(text || '') + ''; + }, + isRatioDeferred: function (row) { + return parseInt(row.ratio_deferred, 10) === 1 && String(row.status || '') === 'normal'; + }, + ratioDeferredTooltip: function (row) { + var streak = parseInt(row.no_inbound_click_streak, 10) || 0; + var template = __('Ratio deferred tooltip'); + if (!template || template === 'Ratio deferred tooltip') { + template = '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决'; + } + return template.indexOf('%s') >= 0 ? template.replace('%s', String(streak)) : template; + }, + ratioDeferredBadgeHtml: function (tooltip) { + var label = __('Ratio deferred badge'); + if (!label || label === 'Ratio deferred badge') { + label = '降权中'; + } + return '' + + ' ' + Fast.api.escape(label) + + ''; + }, + numberWithDeferred: function (value, row) { + var html = Fast.api.escape(String(value || '')); + if (Controller.api.formatter.isRatioDeferred(row)) { + html += Controller.api.formatter.ratioDeferredBadgeHtml( + Controller.api.formatter.ratioDeferredTooltip(row) + ); + } + return html; } }, + refreshSearchSelect: function ($field) { + if ($field.length && $field.hasClass('selectpicker') && $field.data('selectpicker')) { + $field.selectpicker('refresh'); + } + }, + syncStatusTabs: function (table, statusValue) { + var $tabs = table.closest('.panel-intro').find('.panel-heading [data-field="status"]'); + if (!$tabs.length) { + return; + } + $tabs.find('li').removeClass('active'); + $tabs.find('li a[data-value="' + (statusValue === null || statusValue === undefined ? '' : statusValue) + '"]') + .parent() + .addClass('active'); + }, + /** + * 工具栏「降权中」快捷筛选:与列表徽章一致(ratio_deferred=1 且 status=normal) + */ + bindDeferredFilter: function (table) { + var active = false; + var savedStatus = ''; + var $btn = $('.btn-filter-ratio-deferred'); + if (!$btn.length) { + return; + } + + var applyFilter = function () { + var $form = table.closest('.bootstrap-table').find('form.form-commonsearch'); + if (!$form.length) { + return false; + } + var $ratioField = $form.find('[name="ratio_deferred"]'); + var $statusField = $form.find('[name="status"]'); + + if (active) { + savedStatus = $statusField.length ? String($statusField.val() || '') : ''; + if ($ratioField.length) { + $ratioField.val('1'); + Controller.api.refreshSearchSelect($ratioField); + } + if ($statusField.length) { + $statusField.val('normal'); + Controller.api.refreshSearchSelect($statusField); + } + Controller.api.syncStatusTabs(table, 'normal'); + } else { + if ($ratioField.length) { + $ratioField.val(''); + Controller.api.refreshSearchSelect($ratioField); + } + if ($statusField.length) { + $statusField.val(savedStatus); + Controller.api.refreshSearchSelect($statusField); + } + Controller.api.syncStatusTabs(table, savedStatus); + savedStatus = ''; + } + + table.trigger('uncheckbox'); + $form.trigger('submit'); + return true; + }; + + $btn.on('click', function (e) { + e.preventDefault(); + active = !active; + $btn.toggleClass('btn-warning', active).toggleClass('btn-default', !active); + if (!applyFilter()) { + active = !active; + $btn.toggleClass('btn-warning', active).toggleClass('btn-default', !active); + Toastr.warning('筛选表单未就绪,请刷新页面后重试'); + } + }); + }, + /** + * 清理无效降权池:无落地页访问基线却标记 ratio_deferred=1 的号码 + */ + bindDeferredCleanup: function (table) { + var $btn = $('.btn-cleanup-ratio-deferred'); + if (!$btn.length) { + return; + } + $btn.on('click', function (e) { + e.preventDefault(); + Fast.api.ajax({ + url: 'split.number/cleanupdeferredpreview', + type: 'get' + }, function (data) { + var count = parseInt(data.count, 10) || 0; + if (count <= 0) { + Toastr.info(__('Ratio deferred cleanup none')); + return false; + } + var template = __('Ratio deferred cleanup confirm'); + if (!template || template === 'Ratio deferred cleanup confirm') { + template = '将清除 %d 条无效降权记录。确定继续?'; + } + var message = template.indexOf('%d') >= 0 + ? template.replace('%d', String(count)) + : template; + Layer.confirm(message, {icon: 3, title: __('Warning')}, function (index) { + Layer.close(index); + Fast.api.ajax({ + url: 'split.number/cleanupdeferred', + type: 'post' + }, function (data, ret) { + table.bootstrapTable('refresh'); + Toastr.success(ret.msg || __('Ratio deferred cleanup success')); + }); + }); + return false; + }); + }); + }, bindevent: function () { Form.api.bindevent($('form[role=form]')); Controller.api.fixSelectPlaceholder(); diff --git a/public/assets/js/backend/split/number.js b/public/assets/js/backend/split/number.js index e6d771b..07ca9ed 100755 --- a/public/assets/js/backend/split/number.js +++ b/public/assets/js/backend/split/number.js @@ -37,7 +37,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin formatter: Table.api.formatter.content }, {field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'}, - {field: 'number', title: __('Number'), operate: 'LIKE'}, + { + field: 'number', + title: __('Number'), + operate: 'LIKE', + formatter: Controller.api.formatter.numberWithDeferred + }, $.extend( {field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal}, searchSelectMeta(false) @@ -52,6 +57,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'}, searchSelectMeta(false) ), + $.extend( + { + field: 'ratio_deferred', + title: __('Ratio_deferred'), + visible: false, + searchable: true, + operate: '=', + searchList: Controller.api.buildRatioDeferredSearchList() + }, + searchSelectMeta(false) + ), { field: 'createtime', title: __('Createtime'), @@ -97,11 +113,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.openBatchOperateModal(table); }); + table.on('post-body.bs.table', function () { + table.find('[data-toggle="tooltip"]').tooltip({container: 'body'}); + }); + table.on('post-common-search.bs.table', function (e, tbl) { Controller.api.initCommonSearchSelectpicker(tbl); }); Table.api.bindevent(table); + Controller.api.bindDeferredFilter(table); + Controller.api.bindDeferredCleanup(table); }, add: function () { Controller.api.bindevent(); @@ -138,6 +160,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin } return ext; }, + /** + * PHP json_encode 会把 ['0'=>..,'1'=>..] 压成数组,FastAdmin 通用搜索会误用文案作 option value + */ + buildRatioDeferredSearchList: function () { + var list = Config.ratioDeferredList; + if ($.isArray(list)) { + return {0: list[0], 1: list[1]}; + } + return list || {}; + }, initCommonSearchSelectpicker: function (tbl) { var $form = $('form.form-commonsearch', tbl.$container); if ($form.length) { @@ -152,8 +184,152 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin : (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value); var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning'); return '' + Fast.api.escape(text || '') + ''; + }, + isRatioDeferred: function (row) { + return parseInt(row.ratio_deferred, 10) === 1 && String(row.status || '') === 'normal'; + }, + ratioDeferredTooltip: function (row) { + var streak = parseInt(row.no_inbound_click_streak, 10) || 0; + var template = __('Ratio deferred tooltip'); + if (!template || template === 'Ratio deferred tooltip') { + template = '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决'; + } + return template.indexOf('%s') >= 0 ? template.replace('%s', String(streak)) : template; + }, + ratioDeferredBadgeHtml: function (tooltip) { + var label = __('Ratio deferred badge'); + if (!label || label === 'Ratio deferred badge') { + label = '降权中'; + } + return '' + + ' ' + Fast.api.escape(label) + + ''; + }, + numberWithDeferred: function (value, row) { + var html = Fast.api.escape(String(value || '')); + if (Controller.api.formatter.isRatioDeferred(row)) { + html += Controller.api.formatter.ratioDeferredBadgeHtml( + Controller.api.formatter.ratioDeferredTooltip(row) + ); + } + return html; } }, + refreshSearchSelect: function ($field) { + if ($field.length && $field.hasClass('selectpicker') && $field.data('selectpicker')) { + $field.selectpicker('refresh'); + } + }, + syncStatusTabs: function (table, statusValue) { + var $tabs = table.closest('.panel-intro').find('.panel-heading [data-field="status"]'); + if (!$tabs.length) { + return; + } + $tabs.find('li').removeClass('active'); + $tabs.find('li a[data-value="' + (statusValue === null || statusValue === undefined ? '' : statusValue) + '"]') + .parent() + .addClass('active'); + }, + /** + * 工具栏「降权中」快捷筛选:与列表徽章一致(ratio_deferred=1 且 status=normal) + */ + bindDeferredFilter: function (table) { + var active = false; + var savedStatus = ''; + var $btn = $('.btn-filter-ratio-deferred'); + if (!$btn.length) { + return; + } + + var applyFilter = function () { + var $form = table.closest('.bootstrap-table').find('form.form-commonsearch'); + if (!$form.length) { + return false; + } + var $ratioField = $form.find('[name="ratio_deferred"]'); + var $statusField = $form.find('[name="status"]'); + + if (active) { + savedStatus = $statusField.length ? String($statusField.val() || '') : ''; + if ($ratioField.length) { + $ratioField.val('1'); + Controller.api.refreshSearchSelect($ratioField); + } + if ($statusField.length) { + $statusField.val('normal'); + Controller.api.refreshSearchSelect($statusField); + } + Controller.api.syncStatusTabs(table, 'normal'); + } else { + if ($ratioField.length) { + $ratioField.val(''); + Controller.api.refreshSearchSelect($ratioField); + } + if ($statusField.length) { + $statusField.val(savedStatus); + Controller.api.refreshSearchSelect($statusField); + } + Controller.api.syncStatusTabs(table, savedStatus); + savedStatus = ''; + } + + table.trigger('uncheckbox'); + $form.trigger('submit'); + return true; + }; + + $btn.on('click', function (e) { + e.preventDefault(); + active = !active; + $btn.toggleClass('btn-warning', active).toggleClass('btn-default', !active); + if (!applyFilter()) { + active = !active; + $btn.toggleClass('btn-warning', active).toggleClass('btn-default', !active); + Toastr.warning('筛选表单未就绪,请刷新页面后重试'); + } + }); + }, + /** + * 清理无效降权池:无落地页访问基线却标记 ratio_deferred=1 的号码 + */ + bindDeferredCleanup: function (table) { + var $btn = $('.btn-cleanup-ratio-deferred'); + if (!$btn.length) { + return; + } + $btn.on('click', function (e) { + e.preventDefault(); + Fast.api.ajax({ + url: 'split.number/cleanupdeferredpreview', + type: 'get' + }, function (data) { + var count = parseInt(data.count, 10) || 0; + if (count <= 0) { + Toastr.info(__('Ratio deferred cleanup none')); + return false; + } + var template = __('Ratio deferred cleanup confirm'); + if (!template || template === 'Ratio deferred cleanup confirm') { + template = '将清除 %d 条无效降权记录。确定继续?'; + } + var message = template.indexOf('%d') >= 0 + ? template.replace('%d', String(count)) + : template; + Layer.confirm(message, {icon: 3, title: __('Warning')}, function (index) { + Layer.close(index); + Fast.api.ajax({ + url: 'split.number/cleanupdeferred', + type: 'post' + }, function (data, ret) { + table.bootstrapTable('refresh'); + Toastr.success(ret.msg || __('Ratio deferred cleanup success')); + }); + }); + return false; + }); + }); + }, bindevent: function () { Form.api.bindevent($('form[role=form]')); Controller.api.fixSelectPlaceholder();