From 9b57b0c400db1bcbb633dd8a80a4eb82b662bcf7 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 4 Jul 2026 11:46:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8B=E5=8F=B7=E6=AF=94=E7=8E=87=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=20+=20=E8=A7=A6=E7=BA=BF=E9=99=8D=E6=9D=83=20+=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E8=A3=81=E5=86=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Install/split_number_ratio_deferred.sql | 6 ++ .../service/SplitNumberVisitRuleService.php | 66 +++++++++++++++++++ .../service/SplitNumberWeighService.php | 26 +++++++- .../common/service/SplitRedirectService.php | 50 +++++++++++--- .../common/service/SplitRoundRobinStore.php | 23 +++++-- .../service/SplitTicketNumberSyncService.php | 25 ++----- .../common/service/SplitTicketRuleService.php | 25 ++++--- .../common/service/SplitTicketSyncService.php | 2 + .../Install/split_number_ratio_deferred.sql | 6 ++ .../service/SplitNumberVisitRuleService.php | 66 +++++++++++++++++++ .../service/SplitNumberWeighService.php | 26 +++++++- .../common/service/SplitRedirectService.php | 50 +++++++++++--- .../common/service/SplitRoundRobinStore.php | 23 +++++-- .../service/SplitTicketNumberSyncService.php | 25 ++----- .../common/service/SplitTicketRuleService.php | 25 ++++--- .../common/service/SplitTicketSyncService.php | 2 + 16 files changed, 358 insertions(+), 88 deletions(-) create mode 100644 application/admin/command/Install/split_number_ratio_deferred.sql create mode 100644 application/common/service/SplitNumberVisitRuleService.php create mode 100644 patches/application/admin/command/Install/split_number_ratio_deferred.sql create mode 100644 patches/application/common/service/SplitNumberVisitRuleService.php diff --git a/application/admin/command/Install/split_number_ratio_deferred.sql b/application/admin/command/Install/split_number_ratio_deferred.sql new file mode 100644 index 0000000..abdf077 --- /dev/null +++ b/application/admin/command/Install/split_number_ratio_deferred.sql @@ -0,0 +1,6 @@ +-- 下号比率触线降权:访问侧标记 ratio_deferred,同步侧裁决 status +SET NAMES utf8mb4; + +ALTER TABLE `fa_split_number` + ADD COLUMN `ratio_deferred` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '下号比率触线降权:0=否,1=是(仍status=normal,选号时后置)' AFTER `no_inbound_click_streak`, + ADD COLUMN `ratio_deferred_at` bigint(16) DEFAULT NULL COMMENT '触线降权标记时间' AFTER `ratio_deferred`; diff --git a/application/common/service/SplitNumberVisitRuleService.php b/application/common/service/SplitNumberVisitRuleService.php new file mode 100644 index 0000000..3f38dd4 --- /dev/null +++ b/application/common/service/SplitNumberVisitRuleService.php @@ -0,0 +1,66 @@ +where('split_link_id', (int) $number['split_link_id']) + ->where('ticket_name', (string) $number['ticket_name']) + ->find(); + if ($ticket === null) { + return; + } + + $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); + + // 自上次同步检查点以来进线未增长,则本次访问计入 streak + if ($inboundCount <= $lastInbound) { + $streak++; + } else { + $streak = 0; + } + + $update = [ + 'no_inbound_click_streak' => $streak, + 'updatetime' => time(), + ]; + + // 非手动号码且达到下号比率:触线降权,不关 status,等待同步确认 + if ((int) $number['manual_manage'] === 0 && $assignRatio > 0 && $streak >= $assignRatio) { + $update['ratio_deferred'] = 1; + $update['ratio_deferred_at'] = time(); + } + + Number::where('id', $numberId)->update($update); + } +} diff --git a/application/common/service/SplitNumberWeighService.php b/application/common/service/SplitNumberWeighService.php index 7a00c32..1b46633 100755 --- a/application/common/service/SplitNumberWeighService.php +++ b/application/common/service/SplitNumberWeighService.php @@ -32,11 +32,13 @@ class SplitNumberWeighService * @param int $linkId 分流链接 ID * @param int $numberCount 可用号码数量 * @param SplitRoundRobinStore $roundRobinStore 关闭随机打乱时的轮转计数器 + * @param string $poolKey 选号池标识(preferred / deferred 等) */ public static function resolvePickIndex( int $linkId, int $numberCount, - SplitRoundRobinStore $roundRobinStore + SplitRoundRobinStore $roundRobinStore, + string $poolKey = 'main' ): int { if ($numberCount <= 0) { return 0; @@ -49,6 +51,26 @@ class SplitNumberWeighService return random_int(0, $numberCount - 1); } - return $roundRobinStore->nextIndex($linkId, $numberCount); + return $roundRobinStore->nextIndex($linkId, $numberCount, $poolKey); + } + + /** + * 从号码行列表中按链接配置选一条(严格轮转或随机) + * + * @param array|Number> $rows + * @return array|Number|null + */ + public static function pickRowFromList( + int $linkId, + array $rows, + SplitRoundRobinStore $roundRobinStore, + string $poolKey = 'main' + ) { + $count = count($rows); + if ($count === 0) { + return null; + } + $index = self::resolvePickIndex($linkId, $count, $roundRobinStore, $poolKey); + return $rows[$index] ?? $rows[0]; } } diff --git a/application/common/service/SplitRedirectService.php b/application/common/service/SplitRedirectService.php index a92bae5..bd6b76d 100755 --- a/application/common/service/SplitRedirectService.php +++ b/application/common/service/SplitRedirectService.php @@ -12,15 +12,21 @@ use think\Collection; * 分流链接落地页:查链、跨工单合并选号、拼接跳转 URL、访问计数 * * 号码池为同一 split_link_id 下全部 status=normal 的号码; + * 优先选用 ratio_deferred=0 的号码,全部触线降权时回退至降权池仍可轮转。 * random_shuffle 关闭时严格轮转,开启时每次访问随机选号。 */ class SplitRedirectService { private SplitRoundRobinStore $roundRobinStore; - public function __construct(?SplitRoundRobinStore $roundRobinStore = null) - { + private SplitNumberVisitRuleService $visitRuleService; + + public function __construct( + ?SplitRoundRobinStore $roundRobinStore = null, + ?SplitNumberVisitRuleService $visitRuleService = null + ) { $this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore(); + $this->visitRuleService = $visitRuleService ?? new SplitNumberVisitRuleService(); } /** @@ -53,18 +59,16 @@ class SplitRedirectService $numbers = Number::where('split_link_id', (int) $link['id']) ->where('status', 'normal') ->order('id', 'asc') - ->field('id,number,number_type,number_type_custom') + ->field('id,number,number_type,number_type_custom,ratio_deferred') ->select(); - $count = is_countable($numbers) ? count($numbers) : 0; - if ($count === 0) { + $list = $numbers instanceof Collection ? $numbers->all() : (array) $numbers; + if ($list === []) { return null; } $linkId = (int) $link->getAttr('id'); - $index = SplitNumberWeighService::resolvePickIndex($linkId, $count, $this->roundRobinStore); - $list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers; - $picked = $list[$index] ?? $list[0] ?? null; + $picked = $this->pickNumberRow($linkId, $list); if ($picked === null) { return null; } @@ -93,8 +97,38 @@ class SplitRedirectService $numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id'); if ($numberId > 0) { Number::where('id', $numberId)->setInc('visit_count'); + $this->visitRuleService->recordVisitAfterRedirect($numberId); } return $redirectUrl; } + + /** + * 双层选号:优先 ratio_deferred=0,否则回退至降权池 + * + * @param array|Number> $rows + * @return array|Number|null + */ + private function pickNumberRow(int $linkId, array $rows) + { + $preferred = []; + $deferred = []; + foreach ($rows as $row) { + $deferredFlag = is_array($row) + ? (int) ($row['ratio_deferred'] ?? 0) + : (int) $row->getAttr('ratio_deferred'); + if ($deferredFlag === 1) { + $deferred[] = $row; + } else { + $preferred[] = $row; + } + } + + $picked = SplitNumberWeighService::pickRowFromList($linkId, $preferred, $this->roundRobinStore, 'preferred'); + if ($picked !== null) { + return $picked; + } + + return SplitNumberWeighService::pickRowFromList($linkId, $deferred, $this->roundRobinStore, 'deferred'); + } } diff --git a/application/common/service/SplitRoundRobinStore.php b/application/common/service/SplitRoundRobinStore.php index 2012988..e7f8cac 100755 --- a/application/common/service/SplitRoundRobinStore.php +++ b/application/common/service/SplitRoundRobinStore.php @@ -22,15 +22,19 @@ class SplitRoundRobinStore /** * 获取本次访问应使用的号码下标(0 .. count-1) + * + * @param string $poolKey 选号池标识(如 preferred / deferred),用于双层选号独立轮转 */ - public function nextIndex(int $splitLinkId, int $numberCount): int + public function nextIndex(int $splitLinkId, int $numberCount, string $poolKey = 'main'): int { if ($splitLinkId <= 0 || $numberCount <= 0) { return 0; } + $poolKey = $this->sanitizePoolKey($poolKey); + if ($this->ensureRedis()) { - $key = self::KEY_PREFIX . $splitLinkId; + $key = self::KEY_PREFIX . $splitLinkId . ':' . $poolKey; $seq = (int) $this->redis->incr($key); if ($seq <= 0) { $seq = 1; @@ -39,7 +43,16 @@ class SplitRoundRobinStore return ($seq - 1) % $numberCount; } - return $this->nextIndexFallback($splitLinkId, $numberCount); + return $this->nextIndexFallback($splitLinkId, $numberCount, $poolKey); + } + + /** + * 选号池 key 仅允许字母数字与下划线,避免 Redis key 注入 + */ + private function sanitizePoolKey(string $poolKey): string + { + $poolKey = preg_replace('/[^a-zA-Z0-9_]/', '', $poolKey) ?? ''; + return $poolKey !== '' ? $poolKey : 'main'; } /** @@ -102,14 +115,14 @@ class SplitRoundRobinStore /** * 无 Redis 时使用 runtime 文件锁递增(开发/单机可用;生产请启用 Redis) */ - private function nextIndexFallback(int $splitLinkId, int $numberCount): int + private function nextIndexFallback(int $splitLinkId, int $numberCount, string $poolKey = 'main'): int { $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); $dir = $runtime . 'split_rr/'; if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { return 0; } - $file = $dir . $splitLinkId . '.cnt'; + $file = $dir . $splitLinkId . '_' . $poolKey . '.cnt'; $fp = @fopen($file, 'c+'); if ($fp === false) { return 0; diff --git a/application/common/service/SplitTicketNumberSyncService.php b/application/common/service/SplitTicketNumberSyncService.php index 33622b6..c9d4693 100755 --- a/application/common/service/SplitTicketNumberSyncService.php +++ b/application/common/service/SplitTicketNumberSyncService.php @@ -13,7 +13,7 @@ use think\Db; * 工单同步结果写入号码表 * * 同步策略(全量镜像): - * - 爬虫返回的号码:写入 platform_status / inbound_count,开关 status 与云控在线状态一致 + * - 爬虫返回的号码:写入 platform_status / inbound_count;status 由后续 applyNumberRules 裁决 * - 爬虫未返回且 manual_manage=0:物理删除 * - manual_manage=1:不删除,仅当仍在爬虫结果中时更新 platform_status */ @@ -108,27 +108,11 @@ class SplitTicketNumberSyncService } /** - * 工单手动开启等非同步场景:按已存的 platform_status 对齐开关(不跑单号上限/下号比率) + * 工单手动开启等非同步场景:按业务规则对齐号码开关(平台 + 单号上限 + 下号比率) */ public function applyStatusFromPlatformSnapshot(Ticket $ticket): void { - if ((string) ($ticket['status'] ?? 'hidden') !== 'normal') { - return; - } - - $numbers = Number::where('admin_id', (int) $ticket['admin_id']) - ->where('split_link_id', (int) $ticket['split_link_id']) - ->where('ticket_name', (string) $ticket['ticket_name']) - ->where('manual_manage', 0) - ->select(); - - foreach ($numbers as $number) { - $platformStatus = (string) ($number['platform_status'] ?? 'offline'); - Number::where('id', (int) $number['id'])->update([ - 'status' => self::resolveSwitchStatus($ticket, $platformStatus), - 'updatetime' => time(), - ]); - } + (new SplitTicketRuleService())->applyNumberRules($ticket); } /** @@ -163,7 +147,6 @@ class SplitTicketNumberSyncService } $update['inbound_count'] = max(0, $newFollowers); - $update['status'] = self::resolveSwitchStatus($ticket, $platformStatus); Number::where('id', (int) $row['id'])->update($update); } @@ -185,7 +168,7 @@ class SplitTicketNumberSyncService 'inbound_count' => max(0, $newFollowers), 'manual_manage' => 0, 'platform_status' => $platformStatus, - 'status' => self::resolveSwitchStatus($ticket, $platformStatus), + 'ratio_deferred' => 0, 'createtime' => $now, 'updatetime' => $now, ]; diff --git a/application/common/service/SplitTicketRuleService.php b/application/common/service/SplitTicketRuleService.php index d4b5202..d2514ab 100755 --- a/application/common/service/SplitTicketRuleService.php +++ b/application/common/service/SplitTicketRuleService.php @@ -13,7 +13,7 @@ use app\admin\model\split\Ticket; class SplitTicketRuleService { /** - * 同步后应用工单开关规则;号码开关以爬虫/platform 快照为准(不再跑单号上限/下号比率) + * 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率) */ public function applyAfterSync(Ticket $ticket, int $completeCount): void { @@ -23,7 +23,7 @@ class SplitTicketRuleService if ((string) $fresh['status'] === 'hidden') { $this->cascadeTicketClosedToNumbers($fresh); } else { - (new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($fresh); + $this->applyNumberRules($fresh); } } } @@ -70,7 +70,7 @@ class SplitTicketRuleService } /** - * 手动切换工单状态时,非手动号码按 platform_status 对齐开关 + * 手动切换工单状态时,非手动号码按业务规则对齐开关 */ public function syncNumbersWithTicketStatus(Ticket $ticket): void { @@ -79,21 +79,21 @@ class SplitTicketRuleService $this->cascadeTicketClosedToNumbers($ticket); return; } - (new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($ticket); + $this->applyNumberRules($ticket); } /** * 工单处于开启状态时,将云控在线的非手动号码设为开启 * - * @deprecated 请使用 applyStatusFromPlatformSnapshot(以 platform_status 为准) + * @deprecated 请使用 applyNumberRules */ public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void { - (new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($ticket); + $this->applyNumberRules($ticket); } /** - * 单号上限、下号比率、云控在线状态、工单开关综合决定号码状态 + * 单号上限、下号比率、云控在线状态、工单开关综合决定号码 status;同步时清除或确认 ratio_deferred */ public function applyNumberRules(Ticket $ticket): void { @@ -125,12 +125,21 @@ class SplitTicketRuleService 'updatetime' => time(), ]; - // 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态 + // 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态与降权标记 if ((int) $number['manual_manage'] === 1) { 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; + } + $update['status'] = $this->resolveAutomatedStatus( $ticket, (string) ($number['platform_status'] ?? 'unknown'), diff --git a/application/common/service/SplitTicketSyncService.php b/application/common/service/SplitTicketSyncService.php index 03cca46..ff1e05d 100755 --- a/application/common/service/SplitTicketSyncService.php +++ b/application/common/service/SplitTicketSyncService.php @@ -555,6 +555,8 @@ class SplitTicketSyncService $freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket; if ((string) $freshTicket['status'] === 'hidden') { $this->ruleService->cascadeTicketClosedToNumbers($freshTicket); + } else { + $this->ruleService->applyNumberRules($freshTicket); } $ticket = $freshTicket; diff --git a/patches/application/admin/command/Install/split_number_ratio_deferred.sql b/patches/application/admin/command/Install/split_number_ratio_deferred.sql new file mode 100644 index 0000000..abdf077 --- /dev/null +++ b/patches/application/admin/command/Install/split_number_ratio_deferred.sql @@ -0,0 +1,6 @@ +-- 下号比率触线降权:访问侧标记 ratio_deferred,同步侧裁决 status +SET NAMES utf8mb4; + +ALTER TABLE `fa_split_number` + ADD COLUMN `ratio_deferred` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '下号比率触线降权:0=否,1=是(仍status=normal,选号时后置)' AFTER `no_inbound_click_streak`, + ADD COLUMN `ratio_deferred_at` bigint(16) DEFAULT NULL COMMENT '触线降权标记时间' AFTER `ratio_deferred`; diff --git a/patches/application/common/service/SplitNumberVisitRuleService.php b/patches/application/common/service/SplitNumberVisitRuleService.php new file mode 100644 index 0000000..3f38dd4 --- /dev/null +++ b/patches/application/common/service/SplitNumberVisitRuleService.php @@ -0,0 +1,66 @@ +where('split_link_id', (int) $number['split_link_id']) + ->where('ticket_name', (string) $number['ticket_name']) + ->find(); + if ($ticket === null) { + return; + } + + $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); + + // 自上次同步检查点以来进线未增长,则本次访问计入 streak + if ($inboundCount <= $lastInbound) { + $streak++; + } else { + $streak = 0; + } + + $update = [ + 'no_inbound_click_streak' => $streak, + 'updatetime' => time(), + ]; + + // 非手动号码且达到下号比率:触线降权,不关 status,等待同步确认 + if ((int) $number['manual_manage'] === 0 && $assignRatio > 0 && $streak >= $assignRatio) { + $update['ratio_deferred'] = 1; + $update['ratio_deferred_at'] = time(); + } + + Number::where('id', $numberId)->update($update); + } +} diff --git a/patches/application/common/service/SplitNumberWeighService.php b/patches/application/common/service/SplitNumberWeighService.php index 7a00c32..1b46633 100755 --- a/patches/application/common/service/SplitNumberWeighService.php +++ b/patches/application/common/service/SplitNumberWeighService.php @@ -32,11 +32,13 @@ class SplitNumberWeighService * @param int $linkId 分流链接 ID * @param int $numberCount 可用号码数量 * @param SplitRoundRobinStore $roundRobinStore 关闭随机打乱时的轮转计数器 + * @param string $poolKey 选号池标识(preferred / deferred 等) */ public static function resolvePickIndex( int $linkId, int $numberCount, - SplitRoundRobinStore $roundRobinStore + SplitRoundRobinStore $roundRobinStore, + string $poolKey = 'main' ): int { if ($numberCount <= 0) { return 0; @@ -49,6 +51,26 @@ class SplitNumberWeighService return random_int(0, $numberCount - 1); } - return $roundRobinStore->nextIndex($linkId, $numberCount); + return $roundRobinStore->nextIndex($linkId, $numberCount, $poolKey); + } + + /** + * 从号码行列表中按链接配置选一条(严格轮转或随机) + * + * @param array|Number> $rows + * @return array|Number|null + */ + public static function pickRowFromList( + int $linkId, + array $rows, + SplitRoundRobinStore $roundRobinStore, + string $poolKey = 'main' + ) { + $count = count($rows); + if ($count === 0) { + return null; + } + $index = self::resolvePickIndex($linkId, $count, $roundRobinStore, $poolKey); + return $rows[$index] ?? $rows[0]; } } diff --git a/patches/application/common/service/SplitRedirectService.php b/patches/application/common/service/SplitRedirectService.php index a92bae5..bd6b76d 100755 --- a/patches/application/common/service/SplitRedirectService.php +++ b/patches/application/common/service/SplitRedirectService.php @@ -12,15 +12,21 @@ use think\Collection; * 分流链接落地页:查链、跨工单合并选号、拼接跳转 URL、访问计数 * * 号码池为同一 split_link_id 下全部 status=normal 的号码; + * 优先选用 ratio_deferred=0 的号码,全部触线降权时回退至降权池仍可轮转。 * random_shuffle 关闭时严格轮转,开启时每次访问随机选号。 */ class SplitRedirectService { private SplitRoundRobinStore $roundRobinStore; - public function __construct(?SplitRoundRobinStore $roundRobinStore = null) - { + private SplitNumberVisitRuleService $visitRuleService; + + public function __construct( + ?SplitRoundRobinStore $roundRobinStore = null, + ?SplitNumberVisitRuleService $visitRuleService = null + ) { $this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore(); + $this->visitRuleService = $visitRuleService ?? new SplitNumberVisitRuleService(); } /** @@ -53,18 +59,16 @@ class SplitRedirectService $numbers = Number::where('split_link_id', (int) $link['id']) ->where('status', 'normal') ->order('id', 'asc') - ->field('id,number,number_type,number_type_custom') + ->field('id,number,number_type,number_type_custom,ratio_deferred') ->select(); - $count = is_countable($numbers) ? count($numbers) : 0; - if ($count === 0) { + $list = $numbers instanceof Collection ? $numbers->all() : (array) $numbers; + if ($list === []) { return null; } $linkId = (int) $link->getAttr('id'); - $index = SplitNumberWeighService::resolvePickIndex($linkId, $count, $this->roundRobinStore); - $list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers; - $picked = $list[$index] ?? $list[0] ?? null; + $picked = $this->pickNumberRow($linkId, $list); if ($picked === null) { return null; } @@ -93,8 +97,38 @@ class SplitRedirectService $numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id'); if ($numberId > 0) { Number::where('id', $numberId)->setInc('visit_count'); + $this->visitRuleService->recordVisitAfterRedirect($numberId); } return $redirectUrl; } + + /** + * 双层选号:优先 ratio_deferred=0,否则回退至降权池 + * + * @param array|Number> $rows + * @return array|Number|null + */ + private function pickNumberRow(int $linkId, array $rows) + { + $preferred = []; + $deferred = []; + foreach ($rows as $row) { + $deferredFlag = is_array($row) + ? (int) ($row['ratio_deferred'] ?? 0) + : (int) $row->getAttr('ratio_deferred'); + if ($deferredFlag === 1) { + $deferred[] = $row; + } else { + $preferred[] = $row; + } + } + + $picked = SplitNumberWeighService::pickRowFromList($linkId, $preferred, $this->roundRobinStore, 'preferred'); + if ($picked !== null) { + return $picked; + } + + return SplitNumberWeighService::pickRowFromList($linkId, $deferred, $this->roundRobinStore, 'deferred'); + } } diff --git a/patches/application/common/service/SplitRoundRobinStore.php b/patches/application/common/service/SplitRoundRobinStore.php index 2012988..e7f8cac 100755 --- a/patches/application/common/service/SplitRoundRobinStore.php +++ b/patches/application/common/service/SplitRoundRobinStore.php @@ -22,15 +22,19 @@ class SplitRoundRobinStore /** * 获取本次访问应使用的号码下标(0 .. count-1) + * + * @param string $poolKey 选号池标识(如 preferred / deferred),用于双层选号独立轮转 */ - public function nextIndex(int $splitLinkId, int $numberCount): int + public function nextIndex(int $splitLinkId, int $numberCount, string $poolKey = 'main'): int { if ($splitLinkId <= 0 || $numberCount <= 0) { return 0; } + $poolKey = $this->sanitizePoolKey($poolKey); + if ($this->ensureRedis()) { - $key = self::KEY_PREFIX . $splitLinkId; + $key = self::KEY_PREFIX . $splitLinkId . ':' . $poolKey; $seq = (int) $this->redis->incr($key); if ($seq <= 0) { $seq = 1; @@ -39,7 +43,16 @@ class SplitRoundRobinStore return ($seq - 1) % $numberCount; } - return $this->nextIndexFallback($splitLinkId, $numberCount); + return $this->nextIndexFallback($splitLinkId, $numberCount, $poolKey); + } + + /** + * 选号池 key 仅允许字母数字与下划线,避免 Redis key 注入 + */ + private function sanitizePoolKey(string $poolKey): string + { + $poolKey = preg_replace('/[^a-zA-Z0-9_]/', '', $poolKey) ?? ''; + return $poolKey !== '' ? $poolKey : 'main'; } /** @@ -102,14 +115,14 @@ class SplitRoundRobinStore /** * 无 Redis 时使用 runtime 文件锁递增(开发/单机可用;生产请启用 Redis) */ - private function nextIndexFallback(int $splitLinkId, int $numberCount): int + private function nextIndexFallback(int $splitLinkId, int $numberCount, string $poolKey = 'main'): int { $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); $dir = $runtime . 'split_rr/'; if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { return 0; } - $file = $dir . $splitLinkId . '.cnt'; + $file = $dir . $splitLinkId . '_' . $poolKey . '.cnt'; $fp = @fopen($file, 'c+'); if ($fp === false) { return 0; diff --git a/patches/application/common/service/SplitTicketNumberSyncService.php b/patches/application/common/service/SplitTicketNumberSyncService.php index 33622b6..c9d4693 100755 --- a/patches/application/common/service/SplitTicketNumberSyncService.php +++ b/patches/application/common/service/SplitTicketNumberSyncService.php @@ -13,7 +13,7 @@ use think\Db; * 工单同步结果写入号码表 * * 同步策略(全量镜像): - * - 爬虫返回的号码:写入 platform_status / inbound_count,开关 status 与云控在线状态一致 + * - 爬虫返回的号码:写入 platform_status / inbound_count;status 由后续 applyNumberRules 裁决 * - 爬虫未返回且 manual_manage=0:物理删除 * - manual_manage=1:不删除,仅当仍在爬虫结果中时更新 platform_status */ @@ -108,27 +108,11 @@ class SplitTicketNumberSyncService } /** - * 工单手动开启等非同步场景:按已存的 platform_status 对齐开关(不跑单号上限/下号比率) + * 工单手动开启等非同步场景:按业务规则对齐号码开关(平台 + 单号上限 + 下号比率) */ public function applyStatusFromPlatformSnapshot(Ticket $ticket): void { - if ((string) ($ticket['status'] ?? 'hidden') !== 'normal') { - return; - } - - $numbers = Number::where('admin_id', (int) $ticket['admin_id']) - ->where('split_link_id', (int) $ticket['split_link_id']) - ->where('ticket_name', (string) $ticket['ticket_name']) - ->where('manual_manage', 0) - ->select(); - - foreach ($numbers as $number) { - $platformStatus = (string) ($number['platform_status'] ?? 'offline'); - Number::where('id', (int) $number['id'])->update([ - 'status' => self::resolveSwitchStatus($ticket, $platformStatus), - 'updatetime' => time(), - ]); - } + (new SplitTicketRuleService())->applyNumberRules($ticket); } /** @@ -163,7 +147,6 @@ class SplitTicketNumberSyncService } $update['inbound_count'] = max(0, $newFollowers); - $update['status'] = self::resolveSwitchStatus($ticket, $platformStatus); Number::where('id', (int) $row['id'])->update($update); } @@ -185,7 +168,7 @@ class SplitTicketNumberSyncService 'inbound_count' => max(0, $newFollowers), 'manual_manage' => 0, 'platform_status' => $platformStatus, - 'status' => self::resolveSwitchStatus($ticket, $platformStatus), + 'ratio_deferred' => 0, 'createtime' => $now, 'updatetime' => $now, ]; diff --git a/patches/application/common/service/SplitTicketRuleService.php b/patches/application/common/service/SplitTicketRuleService.php index d4b5202..d2514ab 100755 --- a/patches/application/common/service/SplitTicketRuleService.php +++ b/patches/application/common/service/SplitTicketRuleService.php @@ -13,7 +13,7 @@ use app\admin\model\split\Ticket; class SplitTicketRuleService { /** - * 同步后应用工单开关规则;号码开关以爬虫/platform 快照为准(不再跑单号上限/下号比率) + * 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率) */ public function applyAfterSync(Ticket $ticket, int $completeCount): void { @@ -23,7 +23,7 @@ class SplitTicketRuleService if ((string) $fresh['status'] === 'hidden') { $this->cascadeTicketClosedToNumbers($fresh); } else { - (new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($fresh); + $this->applyNumberRules($fresh); } } } @@ -70,7 +70,7 @@ class SplitTicketRuleService } /** - * 手动切换工单状态时,非手动号码按 platform_status 对齐开关 + * 手动切换工单状态时,非手动号码按业务规则对齐开关 */ public function syncNumbersWithTicketStatus(Ticket $ticket): void { @@ -79,21 +79,21 @@ class SplitTicketRuleService $this->cascadeTicketClosedToNumbers($ticket); return; } - (new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($ticket); + $this->applyNumberRules($ticket); } /** * 工单处于开启状态时,将云控在线的非手动号码设为开启 * - * @deprecated 请使用 applyStatusFromPlatformSnapshot(以 platform_status 为准) + * @deprecated 请使用 applyNumberRules */ public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void { - (new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($ticket); + $this->applyNumberRules($ticket); } /** - * 单号上限、下号比率、云控在线状态、工单开关综合决定号码状态 + * 单号上限、下号比率、云控在线状态、工单开关综合决定号码 status;同步时清除或确认 ratio_deferred */ public function applyNumberRules(Ticket $ticket): void { @@ -125,12 +125,21 @@ class SplitTicketRuleService 'updatetime' => time(), ]; - // 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态 + // 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态与降权标记 if ((int) $number['manual_manage'] === 1) { 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; + } + $update['status'] = $this->resolveAutomatedStatus( $ticket, (string) ($number['platform_status'] ?? 'unknown'), diff --git a/patches/application/common/service/SplitTicketSyncService.php b/patches/application/common/service/SplitTicketSyncService.php index 03cca46..ff1e05d 100755 --- a/patches/application/common/service/SplitTicketSyncService.php +++ b/patches/application/common/service/SplitTicketSyncService.php @@ -555,6 +555,8 @@ class SplitTicketSyncService $freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket; if ((string) $freshTicket['status'] === 'hidden') { $this->ruleService->cascadeTicketClosedToNumbers($freshTicket); + } else { + $this->ruleService->applyNumberRules($freshTicket); } $ticket = $freshTicket;