Compare commits

..

16 Commits

Author SHA1 Message Date
root 964f960264 修复工单名称纯数字 2026-07-19 01:00:52 +08:00
root 98fc6d9e3d 连续同步失败后触发重试时间点 2026-07-13 23:06:53 +08:00
root 76692e0021 修复号码数量列的数据统计方式 2026-07-08 01:07:08 +08:00
root 92b4faf61f 手动关闭状态不要自动打开重启同步 2026-07-07 14:56:39 +08:00
root 2e621bf970 修复号码管理显示条数 2026-07-05 23:40:13 +08:00
root ef4c8153c2 修复删除工单所有关联号码 2026-07-05 23:03:50 +08:00
root 744d8b26a4 修复whatshub工单 2026-07-05 22:50:41 +08:00
root a8a2551868 修复whatshub工单数据同步 2026-07-05 11:39:07 +08:00
root f1acab12a7 修改Whatshub工单的完成数量总计的计算方法 2026-07-05 02:13:06 +08:00
root 6b72cd7b67 修复随机号码前先按权重随机工单,并修复降权号码筛选与清理 2026-07-04 22:09:39 +08:00
root 9b57b0c400 下号比率恢复 + 触线降权 + 同步裁决 2026-07-04 11:46:00 +08:00
root 455b6669e8 多个A2C任务时防止同时同步引起错误 2026-07-03 20:51:56 +08:00
root 8508d51b66 修复系统配置 2026-07-03 20:39:50 +08:00
root 0f3eb53afe A2C工单修复 2026-07-03 20:12:20 +08:00
root c3223f026e 修复工单PHP CLI同步问题 系统设置可以设置并发阈值 2026-07-02 19:03:06 +08:00
root 638b3c4032 提交同步状态为异步 2026-07-02 03:39:29 +08:00
108 changed files with 9334 additions and 737 deletions
@@ -0,0 +1 @@
47demv6l5lvam1gzqdkzrih64pb0tzqd.gddJCefh0gXT5ZBsRHlHGniOhraU34wwA4o-jjmdTFw
+1
View File
@@ -0,0 +1 @@
1213
+37 -9
View File
@@ -64,34 +64,37 @@ class A2c extends AbstractScrmSpider
} }
/** /**
* A2C:业务域无需 cf_clearance;短链仍作 pageUrlsessionKey 固定业务域 * A2C:业务域 + 分享 id 会话;短链仍作 pageUrllandingUrlHint 供 Node 直达长链
* *
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function buildAntiBotConfig() private function buildAntiBotConfig()
{ {
$sessionHost = $this->resolveA2cSessionHost(); $sessionScope = $this->resolveA2cSessionScope();
return [ return [
'enabled' => true, 'enabled' => true,
'profile' => 'real', 'profile' => 'real',
'turnstile' => true, 'turnstile' => true,
'solverFallback' => false, 'solverFallback' => true,
'cfClearanceRequired' => false, 'cfClearanceRequired' => false,
'sessionKey' => 'a2c:' . $sessionHost, 'sessionKey' => 'a2c:' . $sessionScope,
'challengeTimeoutMs' => 60000, 'challengeTimeoutMs' => 60000,
'landingUrlHint' => $this->landingUrl, 'landingUrlHint' => $this->landingUrl,
'businessHostHint' => $sessionHost, 'cfCloudflareSolver' => true,
'cfChallengeMode' => 'managed',
'businessHostHint' => $this->resolveA2cSessionHost(),
'postCfReadyUrlContains' => '/visitors/counter/share', 'postCfReadyUrlContains' => '/visitors/counter/share',
'postCfReadySelector' => '.btn-next', 'postCfReadySelector' => '.el-table',
'postCfReadyTimeoutMs' => 30000, 'postCfReadyApiUrl' => '/api/talk/counter/share/record/list',
'postCfReadyTimeoutMs' => 60000,
'postCfSettleMs' => 500, 'postCfSettleMs' => 500,
'postCfSpaWaitMs' => 4000, 'postCfSpaWaitMs' => 8000,
]; ];
} }
/** /**
* A2C 会话域:HTTP 预解析无法穿透短链 CF 时,回退到业务域 user.a2c.chat * A2C 业务域 host
*/ */
private function resolveA2cSessionHost() private function resolveA2cSessionHost()
{ {
@@ -111,6 +114,31 @@ class A2c extends AbstractScrmSpider
return 'user.a2c.chat'; return 'user.a2c.chat';
} }
/**
* A2C 会话 scopehost + share id
*/
private function resolveA2cSessionScope()
{
$host = $this->resolveA2cSessionHost();
$shareId = $this->extractA2cShareId($this->pageUrl);
if ($shareId === '' && $this->landingUrl !== '') {
$shareId = $this->extractA2cShareId($this->landingUrl);
}
return $shareId !== '' ? $host . ':' . $shareId : $host;
}
private function extractA2cShareId($url)
{
$query = (string) parse_url($url, PHP_URL_QUERY);
if ($query === '') {
return '';
}
parse_str($query, $params);
return isset($params['id']) ? trim((string) $params['id']) : '';
}
protected function extractListTotalPages($listFirstPageData, $countData = null) protected function extractListTotalPages($listFirstPageData, $countData = null)
{ {
$default_per_page_count = self::DEFAULT_PER_PAGE_COUNT; $default_per_page_count = self::DEFAULT_PER_PAGE_COUNT;
Regular → Executable
View File
+1 -1
View File
@@ -172,7 +172,7 @@ class Huojian extends AbstractScrmSpider
{ {
$unifiedData = $this->unifiedData; $unifiedData = $this->unifiedData;
$unifiedData->todayNewCount = (int) ($allListPagesData[0]['data']['counterWorker']['newTodayFriend'] ?? 0); $unifiedData->todayNewCount = (int) ($allListPagesData[0]['data']['counterWorker']['newTodayFriend'] ?? 0) + (int) ($allListPagesData[0]['data']['counterWorker']['newRemovedTodayFriend'] ?? 0);
$count = 0; $count = 0;
foreach ($allListPagesData as $pageRaw) { foreach ($allListPagesData as $pageRaw) {
View File
Regular → Executable
View File
Regular → Executable
View File
+1 -1
View File
@@ -85,7 +85,7 @@ try {
// 长链:快速验证业务页拦截(推荐先测通) // 长链:快速验证业务页拦截(推荐先测通)
// $pageUrl = 'https://user.a2c.chat/visitors/counter/share?id=1b2cf9a91e2647c185b252e723c871de'; // $pageUrl = 'https://user.a2c.chat/visitors/counter/share?id=1b2cf9a91e2647c185b252e723c871de';
// 短链:与客户后台一致,上线前必须回归 // 短链:与客户后台一致,上线前必须回归
$pageUrl = 'https://yyk.ink/91SEWrL'; $pageUrl = 'https://yyk.ink/5H3O8b';
$username = ""; $username = "";
$password = ""; $password = "";
$spider = (new A2c($pageUrl, $username, $password))->setVerbose(true); $spider = (new A2c($pageUrl, $username, $password))->setVerbose(true);
+210
View File
@@ -0,0 +1,210 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 'ON');
echo 1111;exit;
// 文件名: run.php
// require_once __DIR__ . '/Chatknow.php'; // ChatKnow SCRM
require_once __DIR__ . '/A2c.php'; // A2c云控
// require_once __DIR__ . '/Xinghe2.php'; // 星河云控
require_once __DIR__ . '/Huojian.php'; // 火箭
// require_once __DIR__ . '/SsCustomer.php'; // SS云控(Customer)
// require_once __DIR__ . '/Haiwang.php'; // 海王
// require_once __DIR__ . '/Whatshub.php'; // Whatshub 工单平台
// try {
// /*
// 命令行可以测试Cloudflare防火墙
// curl -s -X POST http://127.0.0.1:3001/api/auth-and-intercept \
// -H 'Content-Type: application/json' \
// -d '{
// "pageUrl": "https://web.whatshub.cc/m/iTYsWKQH5030/1",
// "apiUrls": ["/api/whatshub-counter/workShare/open/detail"],
// "authActions": [
// {"type": "vue_fill", "selector": ".el-input__inner", "value": "745030"},
// {"type": "wait", "ms": 500},
// {"type": "vue_click", "selector": ".vxe-button-group .theme--primary"},
// {"type": "wait", "ms": 2200}
// ],
// "antiBot": {
// "enabled": true,
// "profile": "real",
// "turnstile": true,
// "solverFallback": true,
// "sessionKey": "whatshub:t.flowerbells.top",
// "challengeTimeoutMs": 60000
// }
// }' | jq .
// */
// echo "🚀 开始执行<Whatshub 工单平台>抓取任务 (多引擎智能调度)...\n\r";
// $pageUrl = 'https://web.whatshub.cc/m/iTYsWKQH5030/1'; // PageUrl 入口授权页
// $username = ""; // 登录账号
// $password = "745030"; // 登录密码
// $spider = new Whatshub($pageUrl, $username, $password);
// $finalData = $spider->run();
// echo "✅ 任务完成!统一数据如下:\n\r";
// echo "----------------------------------------\n\r";
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
// echo "Total" . $finalData->total . " 个号码\n\r";
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
// echo "号码列表:\n\r";
// echo dd($finalData->numbers);
// } catch (Exception $e) {
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
// }
// try {
// echo "🚀 开始执行<ChatKnow SCRM>抓取任务 (多引擎智能调度)...\n\r";
// $pageUrl = 'https://user.chatknow.com/child/workorder-share?shareKey=jf5t6MNrJ2mC76N'; // PageUrl 入口授权页
// $username = ""; // 登录账号
// $password = ""; // 登录密码
// $spider = new Chatknow($pageUrl, $username, $password);
// $finalData = $spider->run();
// echo "✅ 任务完成!统一数据如下:\n\r";
// echo "----------------------------------------\n\r";
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
// echo "Total" . $finalData->total . " 个号码\n\r";
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
// echo "号码列表:\n\r";
// echo dd($finalData->numbers);
// } catch (Exception $e) {
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
// }
// try {
// echo "🚀 开始执行<A2c云控>抓取任务 (多引擎智能调度)...\n\r";
// $pageUrl = 'https://yyk.ink/52oXZRG'; // PageUrl 入口授权页
// $username = ""; // 登录账号
// $password = ""; // 登录密码
// $spider = new A2c($pageUrl, $username, $password);
// $finalData = $spider->run();
// echo "✅ 任务完成!统一数据如下:\n\r";
// echo "----------------------------------------\n\r";
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
// echo "Total" . $finalData->total . " 个号码\n\r";
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
// echo "号码列表:\n\r";
// echo dd($finalData->numbers);
// } catch (Exception $e) {
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
// }
// try {
// echo "🚀 开始执行<星河云控>抓取任务 (多引擎智能调度)...\n\r";
// $pageUrl = 'http://8.218.14.51/share/share/index.html?token=hn6z3egq4nnnebkv4063pcdouzr4ug5js902mlqo2n3yp9gzhe'; // PageUrl 入口授权页
// $username = ""; // 登录账号
// $password = ""; // 登录密码
// $spider = new Xinghe($pageUrl, $username, $password);
// $finalData = $spider->run();
// echo "✅ 任务完成!统一数据如下:\n\r";
// echo "----------------------------------------\n\r";
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
// echo "Total" . $finalData->total . " 个号码\n\r";
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
// echo "号码列表:\n\r";
// echo dd($finalData->numbers);
// } catch (Exception $e) {
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
// }
try {
echo "🚀 开始执行<火箭工单>抓取任务 (多引擎智能调度)...\n\r";
$pageUrl = 'https://s.url99.me/50q3622j'; // PageUrl 入口授权页
$username = ""; // 登录账号
$password = "666666"; // 登录密码
$spider = new Huojian($pageUrl, $username, $password);
$finalData = $spider->run();
echo "✅ 任务完成!统一数据如下:\n\r";
echo "----------------------------------------\n\r";
echo "当日新增:{$finalData->todayNewCount}\n\r";
echo "在线号码:{$finalData->totalOnline}\n\r";
echo "离线号码:{$finalData->totalOffline}\n\r";
echo "Total" . $finalData->total . " 个号码\n\r";
echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
echo "号码列表:\n\r";
echo dd($finalData->numbers);
} catch (Exception $e) {
echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
}
// try {
// echo "🚀 开始执行<SS云控(Customer)>抓取任务 (多引擎智能调度)...\n\r";
// $pageUrl = 'https://app.salesmartly.vip/next/share/reports/verify/customer/fwt7cg'; // PageUrl 入口授权页
// $username = ""; // 登录账号
// $password = "123456"; // 登录密码
// $spider = new SsCustomer($pageUrl, $username, $password);
// $finalData = $spider->run();
// echo "✅ 任务完成!统一数据如下:\n\r";
// echo "----------------------------------------\n\r";
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
// echo "Total" . $finalData->total . " 个号码\n\r";
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
// echo "号码列表:\n\r";
// echo dd($finalData->numbers);
// } catch (Exception $e) {
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
// }
// try {
// echo "🚀 开始执行<海王>抓取任务 (多引擎智能调度)...\n\r";
// $pageUrl = 'https://admin.haiwangweb.com/web#/accountshow/pZsEulYrb'; // PageUrl 入口授权页
// $username = ""; // 登录账号
// $password = "9999"; // 登录密码
// $spider = new Haiwang($pageUrl, $username, $password);
// $finalData = $spider->run();
// echo "✅ 任务完成!统一数据如下:\n\r";
// echo "----------------------------------------\n\r";
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
// echo "Total" . $finalData->total . " 个号码\n\r";
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
// echo "号码列表:\n\r";
// echo dd($finalData->numbers);
// } catch (Exception $e) {
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
// }
// try {
// echo "🚀 开始执行<CEO SCRM>抓取任务 (多引擎智能调度)...\n\r";
// $pageUrl = 'https://admin.scrmceo.com/#/workShareDetail?code=XgGTK5yN'; // PageUrl 入口授权页
// $username = ""; // 登录账号
// $password = "a2222"; // 登录密码
// $spider = new SsCustomer($pageUrl, $username, $password);
// $finalData = $spider->run();
// echo "✅ 任务完成!统一数据如下:\n\r";
// echo "----------------------------------------\n\r";
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
// echo "Total" . $finalData->total . " 个号码\n\r";
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
// echo "号码列表:\n\r";
// echo dd($finalData->numbers);
// } catch (Exception $e) {
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
// }
@@ -0,0 +1,19 @@
-- 双通道定时同步:Node 通道上限与候选池配置(已有环境增量执行)
SET NAMES utf8mb4;
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_max_per_cron', 'split', 'Node通道单次同步上限', '每分钟 cron 在 Node 通道最多处理几条到期工单(仅影响需调用 Node 的类型);纯 PHP 直连类型不受此限制', 'number', '', '5', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_node_candidate_multiplier', 'split', 'Node通道候选池倍数', 'Node 候选工单数 = 单次上限 × 本倍数,用于公平轮转', 'number', '', '10', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_node_candidate_multiplier' LIMIT 1);
UPDATE `fa_config`
SET `title` = 'Node通道单次同步上限',
`tip` = '每分钟 cron 在 Node 通道最多处理几条到期工单(仅影响需调用 Node 的类型);纯 PHP 直连类型不受此限制'
WHERE `name` = 'split_sync_max_per_cron';
UPDATE `fa_config`
SET `value` = '5'
WHERE `name` = 'split_sync_max_per_cron' AND (`value` = '' OR `value` = '2');
@@ -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;
@@ -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`;
@@ -0,0 +1,11 @@
-- 同步失败分阶段重试:系统配置 + 工单状态字段
SET NAMES utf8mb4;
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_fail_retry_minutes', 'split', '失败重试时间点(分钟)', '逗号分隔、升序,相对首次达失败暂停阈值后的绝对分钟数,如 10,20,30,50;留空则达阈值后永久暂停自动同步', 'string', '', '', '', '', ' placeholder="10,20,30,50"', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_retry_minutes' LIMIT 1);
ALTER TABLE `fa_split_ticket`
ADD COLUMN `sync_fail_pause_at` bigint(16) DEFAULT NULL COMMENT '达阈值进入重试的锚点时间' AFTER `sync_fail_count`,
ADD COLUMN `sync_fail_retry_index` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '已完成的重试次数(0=尚未重试)' AFTER `sync_fail_pause_at`,
ADD COLUMN `sync_auto_sync_stopped` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '1=全部重试失败,永久终止自动同步' AFTER `sync_fail_retry_index`;
@@ -20,12 +20,14 @@ CREATE TABLE `fa_split_ticket` (
`account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号', `account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号',
`password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码', `password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码',
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用', `status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
`manual_manage` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '手动管理:0=否,1=是(手动关闭后暂停同步且禁止自动开启)',
`complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)', `complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)',
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)', `inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)',
`speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)', `speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)',
`number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)', `number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)',
`number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)', `number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)',
`number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)', `number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)',
`active_number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '本地开启号码数(status=normal)',
`online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)', `online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)',
`sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态', `sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态',
`sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间', `sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间',
@@ -0,0 +1,21 @@
-- 工单表:本地开启号码数(status=normal),列表直接读字段,避免子查询
SET NAMES utf8mb4;
ALTER TABLE `fa_split_ticket`
ADD COLUMN `active_number_count` int(10) unsigned NOT NULL DEFAULT 0
COMMENT '本地开启号码数(status=normal)' AFTER `number_banned_count`;
-- 历史数据回填
UPDATE `fa_split_ticket` t
SET t.`active_number_count` = (
SELECT COUNT(*)
FROM `fa_split_number` n
WHERE n.`admin_id` = t.`admin_id`
AND n.`split_link_id` = t.`split_link_id`
AND n.`ticket_name` = t.`ticket_name`
AND n.`status` = 'normal'
);
-- 加速按工单统计 status=normal 数量
ALTER TABLE `fa_split_number`
ADD KEY `idx_ticket_active` (`admin_id`, `split_link_id`, `ticket_name`(50), `status`);
@@ -0,0 +1,6 @@
-- 工单手动管理:手动关闭后禁止自动同步与自动开启
SET NAMES utf8mb4;
ALTER TABLE `fa_split_ticket`
ADD COLUMN `manual_manage` tinyint(1) unsigned NOT NULL DEFAULT 0
COMMENT '手动管理:0=否,1=是(手动关闭后暂停同步且禁止自动开启)' AFTER `status`;
View File
+17 -3
View File
@@ -18,6 +18,7 @@ use think\console\Output;
* 用法: * 用法:
* php think split:sync-tickets * php think split:sync-tickets
* php think split:sync-tickets --ticket=12 * php think split:sync-tickets --ticket=12
* php think split:sync-tickets --ticket=12 --mode=auto
*/ */
class SplitSyncTickets extends Command class SplitSyncTickets extends Command
{ {
@@ -25,21 +26,24 @@ class SplitSyncTickets extends Command
{ {
$this->setName('split:sync-tickets') $this->setName('split:sync-tickets')
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '') ->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
->addOption('mode', 'm', Option::VALUE_OPTIONAL, '同步方式: auto=定时自动, manual=手动触发', 'manual')
->setDescription('同步分流工单云控数据'); ->setDescription('同步分流工单云控数据');
} }
protected function execute(Input $input, Output $output): void protected function execute(Input $input, Output $output): void
{ {
set_time_limit(0); set_time_limit(0);
$syncMode = $this->resolveSyncMode($input);
SplitTicketSyncLogger::log('cli', 'command start', [ SplitTicketSyncLogger::log('cli', 'command start', [
'ticketOption' => trim((string) $input->getOption('ticket')), 'ticketOption' => trim((string) $input->getOption('ticket')),
'syncMode' => $syncMode,
'appDebug' => SplitTicketSyncLogger::isEnabled(), 'appDebug' => SplitTicketSyncLogger::isEnabled(),
]); ]);
$service = new SplitTicketSyncService(); $service = new SplitTicketSyncService();
$ticketId = trim((string) $input->getOption('ticket')); $ticketId = trim((string) $input->getOption('ticket'));
if ($ticketId !== '' && ctype_digit($ticketId)) { if ($ticketId !== '' && ctype_digit($ticketId)) {
$result = $service->syncOne((int) $ticketId, true); $result = $service->syncOne((int) $ticketId, true, false, $syncMode);
if (!empty($result['skipped'])) { if (!empty($result['skipped'])) {
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>'); $output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
return; return;
@@ -61,8 +65,8 @@ class SplitSyncTickets extends Command
try { try {
$count = $service->syncDueTickets(); $count = $service->syncDueTickets();
$output->writeln('<info>本次处理工单数: ' . $count . '</info>'); $output->writeln('<info>本次投递工单数: ' . $count . '</info>');
$output->writeln('<comment>汇总日志已写入 runtime/log/split_sync.log</comment>'); $output->writeln('<comment>同步在后台 CLI 进程中执行,汇总日志 runtime/log/split_sync.log</comment>');
} finally { } finally {
$cronLock->release(); $cronLock->release();
} }
@@ -71,4 +75,14 @@ class SplitSyncTickets extends Command
$output->writeln('<comment>详细调试日志已写入 runtime/log/split_sync.log</comment>'); $output->writeln('<comment>详细调试日志已写入 runtime/log/split_sync.log</comment>');
} }
} }
/**
* 解析 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';
}
} }
@@ -7,6 +7,7 @@ namespace app\admin\controller\split;
use app\admin\model\split\Link as LinkModel; use app\admin\model\split\Link as LinkModel;
use app\admin\model\split\Number as NumberModel; use app\admin\model\split\Number as NumberModel;
use app\common\controller\Backend; use app\common\controller\Backend;
use app\common\service\SplitTicketActiveNumberCountService;
use think\Db; use think\Db;
use think\Exception; use think\Exception;
use think\exception\DbException; use think\exception\DbException;
@@ -65,6 +66,7 @@ class Number extends Backend
$this->assignconfig('statusList', $this->model->getStatusList()); $this->assignconfig('statusList', $this->model->getStatusList());
$this->assignconfig('manualManageList', $this->model->getManualManageList()); $this->assignconfig('manualManageList', $this->model->getManualManageList());
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList()); $this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
$this->assignconfig('ratioDeferredList', $this->model->getRatioDeferredList());
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList()); $this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig()); $this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
@@ -224,6 +226,7 @@ class Number extends Backend
$this->error($e->getMessage()); $this->error($e->getMessage());
} }
if ($count > 0) { if ($count > 0) {
(new SplitTicketActiveNumberCountService())->refreshForNumberIds(explode(',', (string) $ids));
$this->success(); $this->success();
} }
$this->error(__('No rows were updated')); $this->error(__('No rows were updated'));
@@ -330,6 +333,12 @@ class Number extends Backend
$this->error($e->getMessage()); $this->error($e->getMessage());
} }
(new SplitTicketActiveNumberCountService())->refreshForTicketKeys(
(int) ($baseRow['admin_id'] ?? $adminId),
$splitLinkId,
(string) ($baseRow['ticket_name'] ?? '')
);
$msg = __('Inserted %d number(s)', $inserted); $msg = __('Inserted %d number(s)', $inserted);
if ($skipped > 0) { if ($skipped > 0) {
$msg .= '' . __('Skipped %d duplicate(s)', $skipped); $msg .= '' . __('Skipped %d duplicate(s)', $skipped);
@@ -377,6 +386,7 @@ class Number extends Backend
} }
$result = false; $result = false;
$before = $row->getData();
Db::startTrans(); Db::startTrans();
try { try {
if ($this->modelValidate) { if ($this->modelValidate) {
@@ -396,9 +406,50 @@ class Number extends Backend
if ($result === false) { if ($result === false) {
$this->error(__('No rows were updated')); $this->error(__('No rows were updated'));
} }
(new SplitTicketActiveNumberCountService())->refreshAfterNumberEdit($before, $row->getData());
$this->success(); $this->success();
} }
/**
* 删除号码后回写工单本地开启号码数
*
* @param string|null $ids
*/
public function del($ids = null)
{
if (false === $this->request->isPost()) {
$this->error(__('Invalid parameters'));
}
$ids = $ids ?: $this->request->post('ids');
if (empty($ids)) {
$this->error(__('Parameter %s can not be empty', 'ids'));
}
$pk = $this->model->getPk();
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$this->model->where($this->dataLimitField, 'in', $adminIds);
}
$list = $this->model->where($pk, 'in', $ids)->select();
$count = 0;
$countService = new SplitTicketActiveNumberCountService();
Db::startTrans();
try {
foreach ($list as $item) {
$count += $item->delete();
}
Db::commit();
} catch (PDOException|Exception $e) {
Db::rollback();
$this->error($e->getMessage());
}
if ($count) {
$countService->refreshFromNumberRows($list);
$this->success();
}
$this->error(__('No rows were deleted'));
}
/** /**
* 批量更新号码状态与手动管理 * 批量更新号码状态与手动管理
*/ */
@@ -443,6 +494,7 @@ class Number extends Backend
if ($count === false || $count === 0) { if ($count === false || $count === 0) {
$this->error(__('No rows were updated')); $this->error(__('No rows were updated'));
} }
(new SplitTicketActiveNumberCountService())->refreshForNumberIds($ids);
$this->success(__('Batch update success')); $this->success(__('Batch update success'));
} }
@@ -495,6 +547,8 @@ class Number extends Backend
} }
$count = 0; $count = 0;
$countService = new SplitTicketActiveNumberCountService();
$keyRows = (clone $query)->field('admin_id,split_link_id,ticket_name')->select();
Db::startTrans(); Db::startTrans();
try { try {
if ($action === 'delete') { if ($action === 'delete') {
@@ -522,9 +576,51 @@ class Number extends Backend
$this->error(__('No matching numbers found')); $this->error(__('No matching numbers found'));
} }
$countService->refreshFromNumberRows($keyRows);
$this->success(sprintf(__('Batch operate success'), $count)); $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));
}
/** /**
* 排除不可由表单提交的字段 * 排除不可由表单提交的字段
* *
+94 -21
View File
@@ -8,8 +8,8 @@ use app\admin\model\split\Link as LinkModel;
use app\admin\model\split\Ticket as TicketModel; use app\admin\model\split\Ticket as TicketModel;
use app\common\controller\Backend; use app\common\controller\Backend;
use app\common\service\SplitTicketRuleService; use app\common\service\SplitTicketRuleService;
use app\common\service\SplitTicketSyncDispatchService;
use app\common\service\SplitTicketSyncLogger; use app\common\service\SplitTicketSyncLogger;
use app\common\service\SplitTicketSyncService;
use think\Db; use think\Db;
use think\Lang; use think\Lang;
use think\Loader; use think\Loader;
@@ -39,7 +39,7 @@ class Ticket extends Backend
protected $modelSceneValidate = true; protected $modelSceneValidate = true;
/** @var string[] 无需鉴权的方法 */ /** @var string[] 无需鉴权的方法 */
protected $noNeedRight = ['script']; protected $noNeedRight = ['script', 'syncpolling'];
/** @var string patches 视图目录 */ /** @var string patches 视图目录 */
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/'; private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/';
@@ -64,6 +64,7 @@ class Ticket extends Backend
'syncBackgroundStartedMsg' => __('Sync background started'), 'syncBackgroundStartedMsg' => __('Sync background started'),
'syncInProgressMsg' => __('Sync in progress'), 'syncInProgressMsg' => __('Sync in progress'),
'syncTicketStartedMsg' => __('Sync ticket started'), 'syncTicketStartedMsg' => __('Sync ticket started'),
'syncDoneMsg' => __('Sync done'),
]); ]);
$this->setupPatchFrontend(); $this->setupPatchFrontend();
@@ -267,6 +268,7 @@ class Ticket extends Backend
$params['inbound_count'], $params['inbound_count'],
$params['speed_per_hour'], $params['speed_per_hour'],
$params['number_count'], $params['number_count'],
$params['active_number_count'],
$params['number_offline_count'], $params['number_offline_count'],
$params['number_banned_count'], $params['number_banned_count'],
$params['online_count'], $params['online_count'],
@@ -276,15 +278,29 @@ class Ticket extends Backend
$params['sync_success_time'], $params['sync_success_time'],
$params['sync_success_mode'], $params['sync_success_mode'],
$params['sync_fail_count'], $params['sync_fail_count'],
$params['sync_fail_pause_at'],
$params['sync_fail_retry_index'],
$params['sync_auto_sync_stopped'],
$params['speed_snapshot_count'], $params['speed_snapshot_count'],
$params['speed_snapshot_time'], $params['speed_snapshot_time'],
$params['click_count'] $params['click_count'],
$params['manual_manage']
); );
return $params; return $params;
} }
/** /**
* 手动同步选中工单 * 用户切换工单状态时写入 manual_manage,防止同步自动恢复开启
*
* @param array<string, mixed> $values
*/
private function applyManualManageForStatusChange(array &$values): void
{
(new SplitTicketRuleService())->applyManualManageForStatusChange($values);
}
/**
* 手动同步选中工单(投递 CLI 后台执行,Web 请求立即返回)
*/ */
public function sync(): void public function sync(): void
{ {
@@ -307,31 +323,84 @@ class Ticket extends Backend
'appDebug' => SplitTicketSyncLogger::isEnabled(), 'appDebug' => SplitTicketSyncLogger::isEnabled(),
]); ]);
$service = new SplitTicketSyncService(); /** @var int[] $allowedIds */
$ok = 0; $allowedIds = [];
$fail = 0; $denied = 0;
$messages = [];
foreach ($list as $row) { foreach ($list as $row) {
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) { if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
$fail++; $denied++;
$messages[] = '#' . $row['id'] . ': 无权限';
continue; continue;
} }
$result = $service->syncOne((int) $row['id'], true); $allowedIds[] = (int) $row['id'];
if ($result['success']) { }
$ok++;
} else { if ($allowedIds === []) {
$fail++; $this->error(__('You have no permission'));
$messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败'); }
// 尽早释放 Session 锁,避免投递等待期间阻塞同账号其它后台请求(如编辑/新增弹窗)
if (function_exists('session_write_close')) {
session_write_close();
}
$dispatch = new SplitTicketSyncDispatchService();
$result = $dispatch->dispatchManual($allowedIds);
$queuedCount = count($result['queued']);
$skippedCount = count($result['skipped']);
$failedCount = count($result['failed']);
if ($queuedCount === 0) {
if ($skippedCount > 0) {
$this->error(__('Sync all skipped busy'));
}
$this->error(__('Sync dispatch failed'));
}
$summary = sprintf(__('Sync dispatch queued'), $queuedCount);
if ($skippedCount > 0) {
$summary .= sprintf(__('Sync dispatch skipped suffix'), $skippedCount);
}
if ($failedCount > 0) {
$summary .= sprintf(__('Sync dispatch failed suffix'), $failedCount);
}
if ($denied > 0) {
$summary .= sprintf(__('Sync dispatch denied suffix'), $denied);
}
$this->success($summary, null, [
'queued' => $result['queued'],
'syncing' => $result['queued'],
]);
}
/**
* 轮询手工同步进度(依据 runtime 文件锁)
*/
public function syncpolling(): void
{
$idsRaw = trim((string) $this->request->request('ids', ''));
if ($idsRaw === '') {
$this->success('', null, ['syncing' => []]);
}
/** @var int[] $ticketIds */
$ticketIds = [];
foreach (explode(',', $idsRaw) as $part) {
$part = trim($part);
if ($part !== '' && ctype_digit($part)) {
$ticketIds[] = (int) $part;
} }
} }
$summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail); if ($ticketIds === []) {
if ($fail > 0) { $this->success('', null, ['syncing' => []]);
$summary .= '' . implode('', array_slice($messages, 0, 5));
} }
$this->success($summary);
$dispatch = new SplitTicketSyncDispatchService();
$syncing = $dispatch->filterSyncingIds($ticketIds);
$this->success('', null, ['syncing' => $syncing]);
} }
/** /**
@@ -354,6 +423,7 @@ class Ticket extends Backend
$ruleService = new SplitTicketRuleService(); $ruleService = new SplitTicketRuleService();
if (isset($values['status'])) { if (isset($values['status'])) {
$this->applyManualManageForStatusChange($values);
$pk = $this->model->getPk(); $pk = $this->model->getPk();
$adminIds = $this->getDataLimitAdminIds(); $adminIds = $this->getDataLimitAdminIds();
$query = $this->model->where($pk, 'in', $ids); $query = $this->model->where($pk, 'in', $ids);
@@ -528,6 +598,9 @@ class Ticket extends Backend
$params['number_type_custom'] = ''; $params['number_type_custom'] = '';
} }
$oldStatus = (string) ($row['status'] ?? 'hidden'); $oldStatus = (string) ($row['status'] ?? 'hidden');
if (isset($params['status']) && (string) $params['status'] !== $oldStatus) {
$this->applyManualManageForStatusChange($params);
}
$result = false; $result = false;
Db::startTrans(); Db::startTrans();
try { try {
@@ -51,4 +51,20 @@ return [
'Invalid manual manage' => '手动管理选项无效', 'Invalid manual manage' => '手动管理选项无效',
'Batch update success' => '批量更新成功', 'Batch update success' => '批量更新成功',
'Unit count' => '个', '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' => '当前没有需要清理的无效降权记录',
'Stream loading' => '加载中...',
'Stream loaded' => '已加载 %loaded% / %total%,继续滚动加载更多',
'Stream loading bottom' => '加载中...',
'Stream loading pending' => '即将加载更多,可切换显示条数取消...',
]; ];
@@ -31,6 +31,12 @@ return [
'Sync in progress' => '同步中', 'Sync in progress' => '同步中',
'Sync ticket started' => '正在同步工单', 'Sync ticket started' => '正在同步工单',
'Sync done' => '同步完成', 'Sync done' => '同步完成',
'Sync dispatch queued' => '已提交 %d 条同步任务到后台执行',
'Sync dispatch skipped suffix' => '%d 条已在同步中已跳过',
'Sync dispatch failed suffix' => '%d 条启动失败',
'Sync dispatch denied suffix' => '%d 条无权限未提交',
'Sync all skipped busy' => '所选工单均在同步中,请稍后再试',
'Sync dispatch failed' => '未能启动同步任务,请检查服务器是否允许后台执行 CLIexec/shell_exec',
'Sync status success' => '同步成功', 'Sync status success' => '同步成功',
'Sync status error' => '同步异常', 'Sync status error' => '同步异常',
'Sync status pending' => '待同步', 'Sync status pending' => '待同步',
@@ -38,10 +44,14 @@ return [
'Sync display pending' => '待同步', 'Sync display pending' => '待同步',
'Sync display error' => '同步异常', 'Sync display error' => '同步异常',
'Sync display auto paused' => '自动同步已暂停', 'Sync display auto paused' => '自动同步已暂停',
'Sync display auto sync stopped' => '自动同步已终止(重试已用尽)',
'Sync display retry waiting' => '自动同步已暂停,将于 %s 第 %d 次重试',
'Sync success time' => '最近同步成功', 'Sync success time' => '最近同步成功',
'Sync mode auto' => '自动', 'Sync mode auto' => '自动',
'Sync mode manual' => '手动', 'Sync mode manual' => '手动',
'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停', 'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停',
'Sync tooltip auto sync stopped' => '分阶段重试已全部失败,自动同步已永久终止;手动同步成功后可恢复',
'Sync tooltip retry waiting' => '连续失败 %s 次,将于 %s 进行第 %d 次自动重试',
'Sync tooltip error prefix' => '异常原因:', 'Sync tooltip error prefix' => '异常原因:',
'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log', 'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log',
'Createtime' => '创建时间', 'Createtime' => '创建时间',
+19
View File
@@ -42,6 +42,7 @@ class Number extends Model
'status_text', 'status_text',
'manual_manage_text', 'manual_manage_text',
'platform_status_text', 'platform_status_text',
'ratio_deferred_text',
]; ];
/** /**
@@ -81,6 +82,17 @@ class Number extends Model
]; ];
} }
/**
* @return array<string, string>
*/
public function getRatioDeferredList(): array
{
return [
'0' => __('Ratio deferred no'),
'1' => __('Ratio deferred yes'),
];
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
@@ -158,6 +170,13 @@ class Number extends Model
return $list[$key] ?? $key; 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 * 根据链接码生成完整分流 URL
*/ */
+50 -4
View File
@@ -6,6 +6,7 @@ namespace app\admin\model\split;
use app\common\service\SplitSyncConfigService; use app\common\service\SplitSyncConfigService;
use app\common\service\SplitTicketRuleService; use app\common\service\SplitTicketRuleService;
use app\common\service\SplitTicketSyncFailRetryService;
use app\common\service\SplitTicketSyncLockService; use app\common\service\SplitTicketSyncLockService;
use think\Db; use think\Db;
use think\Model; use think\Model;
@@ -19,7 +20,7 @@ class Ticket extends Model
protected static function init(): void protected static function init(): void
{ {
// 删除工单时:释放同步锁,并物理删除同步导入的号码(manual_manage=0 // 删除工单时:释放同步锁,并物理删除该工单关联的全部号码
self::beforeDelete(function (self $ticket): void { self::beforeDelete(function (self $ticket): void {
(new SplitTicketSyncLockService())->release((int) $ticket['id']); (new SplitTicketSyncLockService())->release((int) $ticket['id']);
(new SplitTicketRuleService())->deleteSyncedNumbersForTicket($ticket); (new SplitTicketRuleService())->deleteSyncedNumbersForTicket($ticket);
@@ -176,10 +177,23 @@ class Ticket extends Model
if ((string) ($data['status'] ?? '') !== 'normal') { if ((string) ($data['status'] ?? '') !== 'normal') {
return false; return false;
} }
$retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
return true;
}
if ($retryService->isInRetryMode($data)) {
return true;
}
$threshold = SplitSyncConfigService::getFailPauseThreshold(); $threshold = SplitSyncConfigService::getFailPauseThreshold();
if ($threshold <= 0) { if ($threshold <= 0) {
return false; return false;
} }
if (SplitSyncConfigService::hasFailRetrySchedule()) {
return false;
}
return (int) ($data['sync_fail_count'] ?? 0) >= $threshold; return (int) ($data['sync_fail_count'] ?? 0) >= $threshold;
} }
@@ -191,9 +205,26 @@ class Ticket extends Model
*/ */
public function getSyncDisplayTextAttr($value, $data): string public function getSyncDisplayTextAttr($value, $data): string
{ {
$retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
return (string) __('Sync display auto sync stopped');
}
if ($retryService->isInRetryMode($data)) {
$nextAt = $retryService->getNextRetryAt($data);
$attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1;
if ($nextAt !== null) {
return sprintf(
(string) __('Sync display retry waiting'),
date('H:i', $nextAt),
$attempt
);
}
}
if (self::isAutoSyncPaused($data)) { if (self::isAutoSyncPaused($data)) {
$threshold = SplitSyncConfigService::getFailPauseThreshold(); return (string) __('Sync display auto paused');
return sprintf((string) __('Sync display auto paused'), $threshold);
} }
$status = (string) ($data['sync_status'] ?? 'pending'); $status = (string) ($data['sync_status'] ?? 'pending');
@@ -216,7 +247,22 @@ class Ticket extends Model
public function getSyncTooltipTextAttr($value, $data): string public function getSyncTooltipTextAttr($value, $data): string
{ {
$parts = []; $parts = [];
if (self::isAutoSyncPaused($data)) { $retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
$parts[] = (string) __('Sync tooltip auto sync stopped');
} elseif ($retryService->isInRetryMode($data)) {
$nextAt = $retryService->getNextRetryAt($data);
$attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1;
if ($nextAt !== null) {
$parts[] = sprintf(
(string) __('Sync tooltip retry waiting'),
(int) ($data['sync_fail_count'] ?? 0),
date('Y-m-d H:i:s', $nextAt),
$attempt
);
}
} elseif (self::isAutoSyncPaused($data)) {
$threshold = SplitSyncConfigService::getFailPauseThreshold(); $threshold = SplitSyncConfigService::getFailPauseThreshold();
$parts[] = sprintf( $parts[] = sprintf(
(string) __('Sync tooltip auto paused'), (string) __('Sync tooltip auto paused'),
@@ -123,6 +123,13 @@
<div class="form-group"> <div class="form-group">
{if isset($row)} {if isset($row)}
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label> <label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
{if isset($row.ratio_deferred) && $row.ratio_deferred==1 && isset($row.status) && $row.status=='normal'}
<div class="alert alert-warning split-ratio-deferred-notice" style="margin-bottom:10px;padding:8px 12px;font-size:12px;">
<i class="fa fa-level-down"></i>
{:__('Ratio deferred edit notice')}
{:__('No inbound click streak')}{$row.no_inbound_click_streak|default=0}
</div>
{/if}
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50"> <input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
{else} {else}
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label> <label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
@@ -18,6 +18,8 @@
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a> <a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a> <a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a> <a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
<a href="javascript:;" class="btn btn-default btn-filter-ratio-deferred" title="{:__('Ratio deferred filter')}"><i class="fa fa-level-down text-warning"></i> {:__('Ratio deferred badge')}</a>
<a href="javascript:;" class="btn btn-warning btn-cleanup-ratio-deferred {:$auth->check('split.number/cleanupdeferred')?'':'hide'}" title="{:__('Ratio deferred cleanup btn')}"><i class="fa fa-eraser"></i> {:__('Ratio deferred cleanup btn')}</a>
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a> <a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
</div> </div>
<table id="table" class="table table-striped table-bordered table-hover table-nowrap" <table id="table" class="table table-striped table-bordered table-hover table-nowrap"
+175 -15
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\common\library\scrm; namespace app\common\library\scrm;
use app\common\service\SplitPageUrlResolver;
use think\Config; use think\Config;
/** /**
@@ -26,6 +27,14 @@ class AntiBotConfigBuilder
return null; return null;
} }
if ($ticketType === 'a2c' && $landingUrlHint === '') {
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
}
if ($ticketType === 'whatshub' && $landingUrlHint === '') {
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
}
$sessionScope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint); $sessionScope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint);
if ($sessionScope === '') { if ($sessionScope === '') {
return null; return null;
@@ -47,16 +56,22 @@ class AntiBotConfigBuilder
'sessionTtlMinutes' => $ttlMinutes, 'sessionTtlMinutes' => $ttlMinutes,
'businessHostHint' => $businessHostHint, 'businessHostHint' => $businessHostHint,
'landingUrlHint' => $landingUrlHint !== '' ? $landingUrlHint : '', 'landingUrlHint' => $landingUrlHint !== '' ? $landingUrlHint : '',
'cfCloudflareSolver' => self::defaultCfCloudflareSolver($ticketType),
'cfChallengeMode' => self::defaultCfChallengeMode($ticketType),
] + self::postCfReadyExtras($ticketType); ] + self::postCfReadyExtras($ticketType);
} }
/** /**
* 会话 scopeA2C 短链固定业务域;火箭长链用动态 host、短链用 share.{token} * 会话 scopeA2C 业务域+分享 idWhatshub host+shareCode/workId;火箭长链用动态 host
*/ */
public static function resolveSessionScope(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string public static function resolveSessionScope(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string
{ {
if ($ticketType === 'a2c') { if ($ticketType === 'a2c') {
return self::resolveA2cSessionHost($pageUrl, $landingUrlHint); return self::resolveA2cSessionScope($pageUrl, $landingUrlHint);
}
if ($ticketType === 'whatshub') {
return self::resolveWhatshubSessionScope($pageUrl, $landingUrlHint);
} }
if ($ticketType === 'huojian') { if ($ticketType === 'huojian') {
@@ -81,6 +96,14 @@ class AntiBotConfigBuilder
*/ */
public static function resolveSessionKey(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string public static function resolveSessionKey(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string
{ {
if ($ticketType === 'a2c' && $landingUrlHint === '') {
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
}
if ($ticketType === 'whatshub' && $landingUrlHint === '') {
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
}
$scope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint); $scope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint);
return $ticketType . ':' . ($scope !== '' ? $scope : 'unknown'); return $ticketType . ':' . ($scope !== '' ? $scope : 'unknown');
@@ -124,21 +147,27 @@ class AntiBotConfigBuilder
{ {
if ($ticketType === 'whatshub') { if ($ticketType === 'whatshub') {
return [ return [
'postCfReadyUrlContains' => 'work-order-sharing', 'postCfReadyUrlContains' => 'work-order-sharing',
'postCfReadySelector' => '.el-input__inner', // 业务页表格就绪即可;密码框为 vxe-form,勿用泛化 .el-input__inner
'postCfReadyTimeoutMs' => 30000, 'postCfReadySelector' => '.vxe-table, .vxe-grid',
'postCfSettleMs' => 500, 'postCfReadySelectorOptional' => true,
'postCfSpaWaitMs' => 4000, // statistics 仅在密码验证后触发,禁止 pre-auth 等待(见 Node skipPostCfReadyApi
'postCfReadyTimeoutMs' => 60000,
'postCfSettleMs' => 500,
'postCfSpaWaitMs' => 4000,
'skipShortLinkCfPurgeOnReuse' => true,
'authSkipIfNoPasswordDialog' => true,
]; ];
} }
if ($ticketType === 'a2c') { if ($ticketType === 'a2c') {
return [ return [
'postCfReadyUrlContains' => '/visitors/counter/share', 'postCfReadyUrlContains' => '/visitors/counter/share',
'postCfReadySelector' => '.btn-next', 'postCfReadySelector' => '.el-table',
'postCfReadyTimeoutMs' => 30000, 'postCfReadyApiUrl' => '/api/talk/counter/share/record/list',
'postCfReadyTimeoutMs' => 60000,
'postCfSettleMs' => 500, 'postCfSettleMs' => 500,
'postCfSpaWaitMs' => 4000, 'postCfSpaWaitMs' => 8000,
]; ];
} }
@@ -162,20 +191,36 @@ class AntiBotConfigBuilder
private static function defaultSolverFallback(string $ticketType): bool private static function defaultSolverFallback(string $ticketType): bool
{ {
if ($ticketType === 'a2c') { return true;
}
private static function defaultCfClearanceRequired(string $ticketType): bool
{
if ($ticketType === 'a2c' || $ticketType === 'whatshub') {
return false; return false;
} }
return true; return true;
} }
private static function defaultCfClearanceRequired(string $ticketType): bool /**
* A2C / Whatshub 等为 CF Managed Challenge5s 盾),非独立 Turnstile 挂件
*/
private static function defaultCfChallengeMode(string $ticketType): string
{ {
if ($ticketType === 'a2c') { if ($ticketType === 'a2c' || $ticketType === 'whatshub') {
return false; return 'managed';
} }
return true; return '';
}
/**
* sitekey 缺失时是否允许 CapSolver AntiCloudflareTask(需 Node 侧 CAPTCHA_PROXY
*/
private static function defaultCfCloudflareSolver(string $ticketType): bool
{
return $ticketType === 'a2c' || $ticketType === 'whatshub';
} }
/** /**
@@ -205,6 +250,69 @@ class AntiBotConfigBuilder
return ''; return '';
} }
/**
* Whatshub 会话 scopehost + shareCode 或 workId,避免多工单共用温池 Browser
*/
private static function resolveWhatshubSessionScope(string $pageUrl, string $landingUrlHint = ''): string
{
$host = strtolower(trim((string) parse_url($pageUrl, PHP_URL_HOST)));
if ($host === '' && $landingUrlHint !== '') {
$host = strtolower(trim((string) parse_url($landingUrlHint, PHP_URL_HOST)));
}
if ($host === '') {
return '';
}
$shareCode = self::extractWhatshubShareCode($pageUrl);
if ($shareCode === '' && $landingUrlHint !== '') {
$shareCode = self::extractWhatshubShareCode($landingUrlHint);
}
if ($shareCode !== '') {
return $host . ':' . $shareCode;
}
$workId = self::extractWhatshubWorkId($pageUrl);
if ($workId === '' && $landingUrlHint !== '') {
$workId = self::extractWhatshubWorkId($landingUrlHint);
}
if ($workId !== '') {
return $host . ':' . $workId;
}
return $host;
}
/**
* 从 Whatshub 短链 /m/{shareCode}/ 提取 shareCode
*/
private static function extractWhatshubShareCode(string $url): string
{
$path = (string) parse_url($url, PHP_URL_PATH);
if ($path === '') {
return '';
}
if (preg_match('#/m/([^/]+)/#', $path, $matches)) {
return trim($matches[1]);
}
return '';
}
/**
* 从 Whatshub 长链 work-order-sharing?workId= 提取 workId
*/
private static function extractWhatshubWorkId(string $url): string
{
$query = (string) parse_url($url, PHP_URL_QUERY);
if ($query === '') {
return '';
}
parse_str($query, $params);
$workId = isset($params['workId']) ? trim((string) $params['workId']) : '';
return $workId !== '' ? $workId : '';
}
private static function resolveA2cSessionHost(string $pageUrl, string $landingUrlHint = ''): string private static function resolveA2cSessionHost(string $pageUrl, string $landingUrlHint = ''): string
{ {
$candidates = []; $candidates = [];
@@ -223,6 +331,58 @@ class AntiBotConfigBuilder
return 'user.a2c.chat'; return 'user.a2c.chat';
} }
/**
* A2C 会话 scope:业务域 + 分享 id(避免多工单共用温池 Browser 互相干扰)
*
* 短链先 HTTP 预解析再提取 ?id=,确保不同分享页不会落到同一 sessionKey。
*/
private static function resolveA2cSessionScope(string $pageUrl, string $landingUrlHint = ''): string
{
$resolvedUrl = self::resolveA2cResolvedUrl($pageUrl, $landingUrlHint);
$host = self::resolveA2cSessionHost($pageUrl, $resolvedUrl);
$shareId = self::extractA2cShareId($pageUrl);
if ($shareId === '') {
$shareId = self::extractA2cShareId($resolvedUrl);
}
return $shareId !== '' ? $host . ':' . $shareId : $host;
}
/**
* A2C 短链 HTTP 预解析:优先已有 landingUrlHint,否则跟随重定向得到长链
*/
private static function resolveA2cResolvedUrl(string $pageUrl, string $landingUrlHint = ''): string
{
if ($landingUrlHint !== '' && self::extractA2cShareId($landingUrlHint) !== '') {
return $landingUrlHint;
}
if (self::extractA2cShareId($pageUrl) !== '') {
return $pageUrl;
}
$resolved = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
if ($resolved !== '') {
return $resolved;
}
return $landingUrlHint !== '' ? $landingUrlHint : $pageUrl;
}
/**
* 从 A2C 分享 URL 提取 id 查询参数
*/
private static function extractA2cShareId(string $url): string
{
$query = (string) parse_url($url, PHP_URL_QUERY);
if ($query === '') {
return '';
}
parse_str($query, $params);
$id = isset($params['id']) ? trim((string) $params['id']) : '';
return $id !== '' ? $id : '';
}
private static function resolveHuojianSessionScope(string $pageUrl, string $landingUrlHint = ''): string private static function resolveHuojianSessionScope(string $pageUrl, string $landingUrlHint = ''): string
{ {
$scope = HuojianUrlHelper::resolveSessionScope($pageUrl); $scope = HuojianUrlHelper::resolveSessionScope($pageUrl);
View File
@@ -7,11 +7,12 @@ namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider; use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\AntiBotConfigBuilder; use app\common\library\scrm\AntiBotConfigBuilder;
use app\common\library\scrm\UnifiedScrmData; use app\common\library\scrm\UnifiedScrmData;
use app\common\service\SplitPageUrlResolver;
/** /**
* A2C 云控蜘蛛(Real Browser 打开分享页 + 拦截加密 API + UI 翻页) * A2C 云控蜘蛛(Real Browser 打开分享页 + 拦截加密 API + UI 翻页)
* *
* 业务域 user.a2c.chat 无需 cf_clearance;短链 yyk.ink 仍由浏览器 follow 跳转 * 业务域 user.a2c.chat 在 CF 挑战页启用 CapSolver 兜底;短链 yyk.ink 预解析为长链后直达业务页
*/ */
class A2cSpider extends AbstractScrmSpider class A2cSpider extends AbstractScrmSpider
{ {
@@ -27,6 +28,9 @@ class A2cSpider extends AbstractScrmSpider
private string $password; private string $password;
/** @var string HTTP 预解析落地 URL(供 antiBot landingUrlHint */
private string $landingUrlHint = '';
private UnifiedScrmData $unifiedData; private UnifiedScrmData $unifiedData;
public function __construct( public function __construct(
@@ -39,6 +43,7 @@ class A2cSpider extends AbstractScrmSpider
$this->pageUrl = $pageUrl; $this->pageUrl = $pageUrl;
$this->account = $account; $this->account = $account;
$this->password = $password; $this->password = $password;
$this->landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
$this->unifiedData = new UnifiedScrmData(); $this->unifiedData = new UnifiedScrmData();
} }
@@ -53,7 +58,7 @@ class A2cSpider extends AbstractScrmSpider
'authActions' => [ 'authActions' => [
['type' => 'wait', 'ms' => 2000], ['type' => 'wait', 'ms' => 2000],
], ],
'antiBot' => AntiBotConfigBuilder::build('a2c', $this->pageUrl), 'antiBot' => AntiBotConfigBuilder::build('a2c', $this->pageUrl, $this->landingUrlHint),
]; ];
} }
@@ -12,7 +12,7 @@ use app\common\service\SplitTicketSyncLogger;
/** /**
* Whatshub 云控蜘蛛 * Whatshub 云控蜘蛛
* *
* 时序:detailByShareCode API 直抓(优先)→ 失败则 Real Browser + Turnstile + UI 翻页 * 时序:cURL detailByShareCode 拉号码 → Node 监听 statistics 算完成量 → 失败则完整 Node 爬虫
*/ */
class WhatshubSpider extends AbstractScrmSpider class WhatshubSpider extends AbstractScrmSpider
{ {
@@ -52,25 +52,140 @@ class WhatshubSpider extends AbstractScrmSpider
} }
/** /**
* API 优先:detailByShareCode 直抓;失败则回退 Node 爬虫 * 混合同步:cURL 拉号码 + Node 监听 statistics 算完成量
* statistics 失败时清 session 重试;仍失败则 fallback 完整 Node 仅取完成量
*/ */
public function run(): UnifiedScrmData public function run(): UnifiedScrmData
{ {
$apiResult = $this->tryFetchViaShareCodeApi(); $apiResult = $this->tryFetchViaShareCodeApi();
if ($apiResult instanceof UnifiedScrmData) { $statsCount = $this->tryFetchStatisticsViaNode(false);
SplitTicketSyncLogger::log('spider', 'whatshub api path ok', [
'count' => count($apiResult->numbers), if ($apiResult instanceof UnifiedScrmData && $statsCount !== null) {
$apiResult->todayNewCount = $statsCount;
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok', [
'numberCount' => count($apiResult->numbers),
'todayNewCount' => $statsCount,
]); ]);
return $apiResult; return $apiResult;
} }
SplitTicketSyncLogger::log('spider', 'whatshub api skipped or failed, fallback node', [ // cURL 已成功:清 session 再试 statistics;仍失败则完整 Node 只取完成量
'pageUrl' => $this->pageUrl, if ($apiResult instanceof UnifiedScrmData) {
SplitTicketSyncLogger::log('spider', 'whatshub stats retry after purge session', [
'numberCount' => count($apiResult->numbers),
]);
$statsCount = $this->tryFetchStatisticsViaNode(true);
if ($statsCount !== null) {
$apiResult->todayNewCount = $statsCount;
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok (retry)', [
'numberCount' => count($apiResult->numbers),
'todayNewCount' => $statsCount,
]);
return $apiResult;
}
SplitTicketSyncLogger::log('spider', 'whatshub stats fallback full node for complete count', [
'pageUrl' => $this->pageUrl,
'numberCount' => count($apiResult->numbers),
]);
try {
$fullResult = parent::run();
$apiResult->todayNewCount = $fullResult->todayNewCount;
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok (full node stats)', [
'numberCount' => count($apiResult->numbers),
'todayNewCount' => $apiResult->todayNewCount,
]);
return $apiResult;
} catch (\Throwable $e) {
SplitTicketSyncLogger::log('spider', 'whatshub stats fallback full node failed', [
'error' => $e->getMessage(),
]);
}
throw new \RuntimeException('Whatshub statistics 同步失败(号码已通过 API 获取,无法更新完成量)');
}
SplitTicketSyncLogger::log('spider', 'whatshub hybrid incomplete, fallback full node', [
'pageUrl' => $this->pageUrl,
'apiOk' => false,
'statisticsOk' => $statsCount !== null,
]); ]);
return parent::run(); return parent::run();
} }
/** Whatshub vxe 密码表单:placeholder 含「密码」+ 确认按钮,与业务页搜索框区分 */
private const AUTH_PASSWORD_INPUT = '.vxe-form .el-input__inner[placeholder*="密码"]';
private const AUTH_CONFIRM_BUTTON = '.vxe-form .vxe-button-group button.theme--primary[type="submit"]';
/**
* Node 监听 statistics API,返回 dayNewFans + reDayNewFans 总和;失败返回 null
*
* @param bool $isRetry 是否为清 session 后的二次尝试
*/
private function tryFetchStatisticsViaNode(bool $isRetry = false): ?int
{
$config = $this->getSpiderConfig();
$antiBot = $config['antiBot'] ?? null;
if (is_array($antiBot)) {
// statistics-only:早挂拦截器、识别 vxe 密码框、session 有效时 skip auth
$antiBot = array_merge($antiBot, [
'skipPostCfReadyApi' => true,
'authSkipIfNoPasswordDialog' => true,
'authSelectorTimeoutMs' => 30000,
'earlyApiIntercept' => true,
]);
if ($isRetry) {
$antiBot['purgeSession'] = true;
}
}
SplitTicketSyncLogger::log('spider', 'whatshub stats node request', [
'pageUrl' => $this->pageUrl,
'api' => self::API_DETAILS,
'isRetry' => $isRetry,
]);
$result = $this->requestNode('/api/auth-and-intercept', [
'pageUrl' => $config['pageUrl'],
'apiUrls' => [self::API_DETAILS],
'authActions' => $config['authActions'] ?? [],
'antiBot' => $antiBot,
], $this->resolveAuthInterceptTimeout(is_array($antiBot) ? $antiBot : null));
if (empty($result['success'])) {
SplitTicketSyncLogger::log('spider', 'whatshub stats node failed', [
'error' => $result['error'] ?? '未知',
'code' => $result['code'] ?? null,
'page' => $result['page'] ?? null,
]);
return null;
}
$intercepted = $result['interceptedApis'] ?? [];
if (!isset($intercepted[self::API_DETAILS]['data']) || !is_array($intercepted[self::API_DETAILS]['data'])) {
SplitTicketSyncLogger::log('spider', 'whatshub stats node missing payload', [
'intercepted' => array_keys(is_array($intercepted) ? $intercepted : []),
'page' => $result['page'] ?? null,
]);
return null;
}
$detailData = $intercepted[self::API_DETAILS]['data'];
$dayNewFans = (int) ($detailData['data']['dayNewFans'] ?? 0);
$reDayNewFans = (int) ($detailData['data']['reDayNewFans'] ?? 0);
$total = $dayNewFans + $reDayNewFans;
SplitTicketSyncLogger::log('spider', 'whatshub stats node ok', [
'dayNewFans' => $dayNewFans,
'reDayNewFans' => $reDayNewFans,
'total' => $total,
]);
return $total;
}
/** /**
* 从短链 pageUrl 解析 shareCode,例如 /m/KMYBBInp2066/1 → KMYBBInp2066 * 从短链 pageUrl 解析 shareCode,例如 /m/KMYBBInp2066/1 → KMYBBInp2066
*/ */
@@ -202,7 +317,6 @@ class WhatshubSpider extends AbstractScrmSpider
private function parseShareCodeApiResponse(array $payload): UnifiedScrmData private function parseShareCodeApiResponse(array $payload): UnifiedScrmData
{ {
$unifiedData = new UnifiedScrmData(); $unifiedData = new UnifiedScrmData();
$todayNewSum = 0;
foreach ($payload['data'] as $item) { foreach ($payload['data'] as $item) {
if (!is_array($item) || empty($item['account'])) { if (!is_array($item) || empty($item['account'])) {
@@ -213,11 +327,10 @@ class WhatshubSpider extends AbstractScrmSpider
$isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1; $isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1;
$dayNewFans = (int) ($item['dayNewFans'] ?? 0); $dayNewFans = (int) ($item['dayNewFans'] ?? 0);
// 号码同步仅使用 dayNewFans,不含 reDayNewFans;完成量由 Node statistics 提供
$unifiedData->addNumber($number, $isOnline, $dayNewFans); $unifiedData->addNumber($number, $isOnline, $dayNewFans);
$todayNewSum += $dayNewFans;
} }
$unifiedData->todayNewCount = $todayNewSum;
$unifiedData->total = count($unifiedData->numbers); $unifiedData->total = count($unifiedData->numbers);
return $unifiedData; return $unifiedData;
@@ -233,10 +346,10 @@ class WhatshubSpider extends AbstractScrmSpider
'listMethod' => 'POST', 'listMethod' => 'POST',
'paginationMode' => self::MODE_UI, 'paginationMode' => self::MODE_UI,
'authActions' => [ 'authActions' => [
// Element Plus 密码弹窗:仅注入可见 input // Whatshub 密码 UI 为 vxe-form + Element input,限定在表单内避免误填搜索框
['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password], ['type' => 'vue_fill', 'selector' => self::AUTH_PASSWORD_INPUT, 'value' => $this->password],
['type' => 'wait', 'ms' => 500], ['type' => 'wait', 'ms' => 500],
['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'], ['type' => 'vue_click', 'selector' => self::AUTH_CONFIRM_BUTTON],
['type' => 'wait', 'ms' => 2200], ['type' => 'wait', 'ms' => 2200],
], ],
'antiBot' => AntiBotConfigBuilder::build('whatshub', $this->pageUrl), 'antiBot' => AntiBotConfigBuilder::build('whatshub', $this->pageUrl),
@@ -279,7 +392,7 @@ class WhatshubSpider extends AbstractScrmSpider
$unifiedData = $this->unifiedData; $unifiedData = $this->unifiedData;
if (is_array($detailData)) { if (is_array($detailData)) {
$unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0); $unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0) + (int) ($detailData['data']['reDayNewFans'] ?? 0);
} }
foreach ($allListPagesData as $pageRaw) { foreach ($allListPagesData as $pageRaw) {
@@ -37,9 +37,25 @@ class SplitNodeHealthService
/** /**
* 当前是否适合向 Node 提交新 Browser 任务 * 当前是否适合向 Node 提交新 Browser 任务
*
* A2C 等 antiBot 任务走 real profile,应参考 queueReal 而非 standard 队列。
*/ */
public static function canAcceptWork(): bool public static function canAcceptWork(): bool
{ {
return self::canAcceptWorkForTicketType('');
}
/**
* 指定工单类型是否可向 Node 提交新任务
*
* API 优先类型(如 Whatshub)常态不占 Real Browser,调度层应跳过队列门禁。
*/
public static function canAcceptWorkForTicketType(string $ticketType): bool
{
if ($ticketType !== '' && SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) {
return true;
}
$stats = self::fetchStats(); $stats = self::fetchStats();
if ($stats === null) { if ($stats === null) {
return true; return true;
@@ -47,33 +63,61 @@ class SplitNodeHealthService
if (!empty($stats['shuttingDown'])) { if (!empty($stats['shuttingDown'])) {
return false; return false;
} }
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
$config = is_array($stats['config'] ?? null) ? $stats['config'] : []; $snapshot = self::resolveQueueSnapshot($stats);
$queued = (int) ($queue['queued'] ?? 0);
$active = (int) ($queue['active'] ?? 0);
$max = max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4)));
$threshold = SplitSyncConfigService::getNodeQueueSkipThreshold(); $threshold = SplitSyncConfigService::getNodeQueueSkipThreshold();
if ($queued >= $threshold) { if ($snapshot['queued'] >= $threshold) {
return false; return false;
} }
return $active < $max;
return $snapshot['active'] < $snapshot['max'];
} }
/** /**
* @return array{queued:int,active:int,max:int} * @return array{queued:int,active:int,max:int,profile:string}
*/ */
public static function getQueueSnapshot(): array public static function getQueueSnapshot(): array
{ {
$stats = self::fetchStats(); $stats = self::fetchStats();
if ($stats === null) { if ($stats === null) {
return ['queued' => 0, 'active' => 0, 'max' => 0]; return ['queued' => 0, 'active' => 0, 'max' => 0, 'profile' => 'real'];
} }
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
$config = is_array($stats['config'] ?? null) ? $stats['config'] : []; $snapshot = self::resolveQueueSnapshot($stats);
return [ return [
'queued' => (int) ($queue['queued'] ?? 0), 'queued' => $snapshot['queued'],
'active' => (int) ($queue['active'] ?? 0), 'active' => $snapshot['active'],
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))), 'max' => $snapshot['max'],
'profile' => $snapshot['profile'],
];
}
/**
* 优先使用 real 队列(antiBot / A2C),无数据时回退 standard
*
* @param array<string, mixed> $stats
* @return array{queued:int,active:int,max:int,profile:string}
*/
private static function resolveQueueSnapshot(array $stats): array
{
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
$queueReal = is_array($stats['queueReal'] ?? null) ? $stats['queueReal'] : [];
if ($queueReal !== []) {
return [
'queued' => (int) ($queueReal['queued'] ?? 0),
'active' => (int) ($queueReal['active'] ?? 0),
'max' => max(1, (int) ($config['maxConcurrentBrowsersReal'] ?? ($queueReal['max'] ?? 2))),
'profile' => 'real',
];
}
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
return [
'queued' => (int) ($queue['queued'] ?? 0),
'active' => (int) ($queue['active'] ?? 0),
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))),
'profile' => 'standard',
]; ];
} }
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Number;
use think\Db;
/**
* 下号比率触线降权(ratio_deferred)规则与降权池清理
*
* 降权前提:已有落地页访问(visit_count > last_sync_visit_count),
* 且自上次同步以来进线未增长时累计 streak,触线才标记降权。
*/
class SplitNumberRatioDeferredService
{
/**
* 是否具备计入 streak 的落地页访问基数
*
* @param array<string, mixed>|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<string, mixed>|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
*
* 访问侧(recordVisitAfterRedirect)已按次 +1 累计 streak,此处不得再叠加 delta,否则首次同步会双倍计次导致提前关号。
* 取 max(访问侧 streak, 自上次同步以来的点击增量) 作为裁决值,并兜底访问钩子未触发的场景。
*
* @param array<string, mixed>|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) {
$deltaSinceSync = $visitCount - $lastVisit;
return max($currentStreak, $deltaSinceSync);
}
return $currentStreak;
}
/**
* 是否应标记 ratio_deferred(访问侧触线降权,不关 status)
*/
public function shouldMarkDeferred(int $streak, int $assignRatio, bool $hasLandingBaseline): bool
{
return $hasLandingBaseline && $assignRatio > 0 && $streak >= $assignRatio;
}
/**
* 是否为应清理的无效降权记录
*
* @param array<string, mixed>|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<string, mixed>|Number $number
*/
private function readInt($number, string $field): int
{
if (is_array($number)) {
return (int) ($number[$field] ?? 0);
}
return (int) $number->getAttr($field);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Number;
use app\admin\model\split\Ticket;
/**
* 落地页访问侧下号比率规则:更新连续无进线点击 streak,触线时仅降权(ratio_deferred),不关闭 status
*
* 最终关号由同步后 SplitTicketRuleService::applyNumberRules 裁决。
*/
class SplitNumberVisitRuleService
{
private SplitNumberRatioDeferredService $ratioDeferredService;
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
{
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
}
/**
* 访问计数已递增后调用:累加 streak,达到 assign_ratio 时标记 ratio_deferred=1
*/
public function recordVisitAfterRedirect(int $numberId): void
{
if ($numberId <= 0) {
return;
}
/** @var Number|null $number */
$number = Number::get($numberId);
if ($number === null) {
return;
}
/** @var Ticket|null $ticket */
$ticket = Ticket::where('admin_id', (int) $number['admin_id'])
->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);
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
$hasBaseline = $this->ratioDeferredService->hasLandingVisitBaseline($number);
if ($hasBaseline) {
$streak = $this->ratioDeferredService->computeStreakForVisit($number, $streak);
}
$update = [
'no_inbound_click_streak' => $streak,
'updatetime' => time(),
];
if ((int) $number['manual_manage'] === 0
&& $this->ratioDeferredService->shouldMarkDeferred($streak, $assignRatio, $hasBaseline)
) {
$update['ratio_deferred'] = 1;
$update['ratio_deferred_at'] = time();
}
Number::where('id', $numberId)->update($update);
}
}
@@ -9,8 +9,8 @@ use app\admin\model\split\Link;
/** /**
* 分流链接随机打乱配置与落地页选号 * 分流链接随机打乱配置与落地页选号
* *
* - random_shuffle=0:跨工单合并号码池,按 id 顺序严格轮转 * - random_shuffle=0:跨工单合并号码池,按 id 顺序全局严格轮转
* - random_shuffle=1跨工单合并号码池,每次访问完全随机选号 * - random_shuffle=1先按各工单开启号码数比例随机选工单,再在该工单内随机选号
* - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布) * - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布)
*/ */
class SplitNumberWeighService class SplitNumberWeighService
@@ -32,11 +32,13 @@ class SplitNumberWeighService
* @param int $linkId 分流链接 ID * @param int $linkId 分流链接 ID
* @param int $numberCount 可用号码数量 * @param int $numberCount 可用号码数量
* @param SplitRoundRobinStore $roundRobinStore 关闭随机打乱时的轮转计数器 * @param SplitRoundRobinStore $roundRobinStore 关闭随机打乱时的轮转计数器
* @param string $poolKey 选号池标识(preferred / deferred 等)
*/ */
public static function resolvePickIndex( public static function resolvePickIndex(
int $linkId, int $linkId,
int $numberCount, int $numberCount,
SplitRoundRobinStore $roundRobinStore SplitRoundRobinStore $roundRobinStore,
string $poolKey = 'main'
): int { ): int {
if ($numberCount <= 0) { if ($numberCount <= 0) {
return 0; return 0;
@@ -49,6 +51,26 @@ class SplitNumberWeighService
return random_int(0, $numberCount - 1); return random_int(0, $numberCount - 1);
} }
return $roundRobinStore->nextIndex($linkId, $numberCount); return $roundRobinStore->nextIndex($linkId, $numberCount, $poolKey);
}
/**
* 从号码行列表中按链接配置选一条(严格轮转或随机)
*
* @param array<int, array<string, mixed>|Number> $rows
* @return array<string, mixed>|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];
} }
} }
View File
@@ -6,21 +6,30 @@ namespace app\common\service;
use app\admin\model\split\Link; use app\admin\model\split\Link;
use app\admin\model\split\Number; use app\admin\model\split\Number;
use app\admin\model\split\Ticket;
use think\Collection; use think\Collection;
/** /**
* 分流链接落地页:查链、跨工单合并选号、拼接跳转 URL、访问计数 * 分流链接落地页:查链、选号、拼接跳转 URL、访问计数
* *
* 号码池同一 split_link_id 下全部 status=normal 的号码; * 号码池同一 split_link_id 下 status=normal,且所属工单未关闭(ticket.status≠hidden的号码;
* random_shuffle 关闭时严格轮转,开启时每次访问随机选号 * 优先选用 ratio_deferred=0,全部触线降权时回退至降权池
*
* random_shuffle=0:跨工单合并池,全局严格轮转;
* random_shuffle=1:先按各工单开启号码数量比例随机选工单,再在该工单内随机选号。
*/ */
class SplitRedirectService class SplitRedirectService
{ {
private SplitRoundRobinStore $roundRobinStore; 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->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
$this->visitRuleService = $visitRuleService ?? new SplitNumberVisitRuleService();
} }
/** /**
@@ -49,22 +58,17 @@ class SplitRedirectService
return null; return null;
} }
/** @var Collection<int, Number>|array<int, Number> $numbers */ $linkId = (int) $link->getAttr('id');
$numbers = Number::where('split_link_id', (int) $link['id']) $list = $this->loadEligibleNumbers($linkId);
->where('status', 'normal') if ($list === []) {
->order('id', 'asc')
->field('id,number,number_type,number_type_custom')
->select();
$count = is_countable($numbers) ? count($numbers) : 0;
if ($count === 0) {
return null; return null;
} }
$linkId = (int) $link->getAttr('id'); if (SplitNumberWeighService::isRandomShuffleEnabled($linkId)) {
$index = SplitNumberWeighService::resolvePickIndex($linkId, $count, $this->roundRobinStore); $picked = $this->pickNumberRowRandomByTicketWeight($linkId, $list);
$list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers; } else {
$picked = $list[$index] ?? $list[0] ?? null; $picked = $this->pickNumberRow($linkId, $list);
}
if ($picked === null) { if ($picked === null) {
return null; return null;
} }
@@ -93,8 +97,146 @@ class SplitRedirectService
$numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id'); $numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id');
if ($numberId > 0) { if ($numberId > 0) {
Number::where('id', $numberId)->setInc('visit_count'); Number::where('id', $numberId)->setInc('visit_count');
$this->visitRuleService->recordVisitAfterRedirect($numberId);
} }
return $redirectUrl; return $redirectUrl;
} }
/**
* 可参与选号的号码:status=normal,且 ticket_name 不属于已关闭工单
*
* @return list<Number|array<string, mixed>>
*/
private function loadEligibleNumbers(int $linkId): array
{
if ($linkId <= 0) {
return [];
}
/** @var list<string> $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<Number|array<string, mixed>> $rows
* @return array<string, mixed>|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<Number|array<string, mixed>> $rows
* @return array<string, list<Number|array<string, mixed>>>
*/
private function groupNumbersByTicket(array $rows): array
{
/** @var array<string, list<Number|array<string, mixed>>> $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<string, list<Number|array<string, mixed>>> $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) {
// PHP 会把纯数字字符串键转成 int,需强转以匹配 ?string 返回类型
return (string) $keys[0];
}
$r = random_int(1, $total);
$acc = 0;
foreach ($groups as $key => $rows) {
$acc += count($rows);
if ($r <= $acc) {
return (string) $key;
}
}
return (string) $keys[0];
}
/**
* 双层选号:优先 ratio_deferred=0,否则回退至降权池
*
* @param list<Number|array<string, mixed>> $rows
* @return array<string, mixed>|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');
}
} }
@@ -22,15 +22,19 @@ class SplitRoundRobinStore
/** /**
* 获取本次访问应使用的号码下标(0 .. count-1 * 获取本次访问应使用的号码下标(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) { if ($splitLinkId <= 0 || $numberCount <= 0) {
return 0; return 0;
} }
$poolKey = $this->sanitizePoolKey($poolKey);
if ($this->ensureRedis()) { if ($this->ensureRedis()) {
$key = self::KEY_PREFIX . $splitLinkId; $key = self::KEY_PREFIX . $splitLinkId . ':' . $poolKey;
$seq = (int) $this->redis->incr($key); $seq = (int) $this->redis->incr($key);
if ($seq <= 0) { if ($seq <= 0) {
$seq = 1; $seq = 1;
@@ -39,7 +43,16 @@ class SplitRoundRobinStore
return ($seq - 1) % $numberCount; 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) * 无 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/'); $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
$dir = $runtime . 'split_rr/'; $dir = $runtime . 'split_rr/';
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return 0; return 0;
} }
$file = $dir . $splitLinkId . '.cnt'; $file = $dir . $splitLinkId . '_' . $poolKey . '.cnt';
$fp = @fopen($file, 'c+'); $fp = @fopen($file, 'c+');
if ($fp === false) { if ($fp === false) {
return 0; return 0;
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\common\service; namespace app\common\service;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\ScrmSpiderInterface; use app\common\library\scrm\ScrmSpiderInterface;
use app\common\library\scrm\spider\A2cSpider; use app\common\library\scrm\spider\A2cSpider;
use app\common\library\scrm\spider\ChatknowSpider; use app\common\library\scrm\spider\ChatknowSpider;
@@ -32,6 +33,16 @@ class SplitScrmSpiderFactory
// ceo_scrm 等未实现类型:新增 spider 类后在此注册 // ceo_scrm 等未实现类型:新增 spider 类后在此注册
]; ];
/**
* API 优先同步类型:常态走 PHP cURL,不占 Node Real Browser 槽位
*
* Whatshub 已改为混合同步(cURL 号码 + Node statistics),需走 Node 队列门禁。
*
* @var list<string>
*/
private const API_FIRST_SYNC_TYPES = [
];
/** /**
* @return class-string<ScrmSpiderInterface>|null * @return class-string<ScrmSpiderInterface>|null
*/ */
@@ -59,6 +70,65 @@ class SplitScrmSpiderFactory
return array_keys(self::MAP); 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<string>
*/
public static function listApiFirstSyncTypes(): array
{
return self::API_FIRST_SYNC_TYPES;
}
/**
* 同步是否依赖 Node Headless(纯 PHP cURL 等为 false
*/
public static function requiresNode(string $ticketType): bool
{
$class = self::resolveClass($ticketType);
if ($class === null) {
return true;
}
return is_subclass_of($class, AbstractScrmSpider::class);
}
/**
* @return list<string>
*/
public static function listDirectSyncTypes(): array
{
$types = [];
foreach (self::listSupportedTypes() as $ticketType) {
if (!self::requiresNode($ticketType)) {
$types[] = $ticketType;
}
}
return $types;
}
/**
* @return list<string>
*/
public static function listNodeSyncTypes(): array
{
$types = [];
foreach (self::listSupportedTypes() as $ticketType) {
if (self::requiresNode($ticketType)) {
$types[] = $ticketType;
}
}
return $types;
}
/** /**
* @return ScrmSpiderInterface|null * @return ScrmSpiderInterface|null
*/ */
@@ -24,6 +24,12 @@ class SplitSyncConfigService
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST; return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
} }
/** 失败重试时间点最多配置条数 */
private const FAIL_RETRY_SCHEDULE_MAX = 10;
/** 单条重试时间点上限(分钟) */
private const FAIL_RETRY_MINUTE_MAX = 1440;
/** /**
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停) * 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
*/ */
@@ -36,6 +42,50 @@ class SplitSyncConfigService
return max(0, (int) $value); return max(0, (int) $value);
} }
/**
* 达失败暂停阈值后的重试时间点(分钟,相对锚点的绝对偏移,升序去重)
*
* @return list<int>
*/
public static function getFailRetryMinutes(): array
{
$raw = trim(self::getConfigValue('split_sync_fail_retry_minutes'));
if ($raw === '') {
return [];
}
$parts = preg_split('/[,\s]+/', $raw) ?: [];
$minutes = [];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part === '' || !ctype_digit($part)) {
continue;
}
$minute = (int) $part;
if ($minute < 1 || $minute > self::FAIL_RETRY_MINUTE_MAX) {
continue;
}
$minutes[$minute] = $minute;
}
if ($minutes === []) {
return [];
}
$list = array_values($minutes);
sort($list, SORT_NUMERIC);
return array_slice($list, 0, self::FAIL_RETRY_SCHEDULE_MAX);
}
/**
* 是否配置了失败分阶段重试时间点
*/
public static function hasFailRetrySchedule(): bool
{
return self::getFailRetryMinutes() !== [];
}
/** /**
* 指定工单类型的自动同步周期(分钟),0 表示不自动同步 * 指定工单类型的自动同步周期(分钟),0 表示不自动同步
*/ */
@@ -57,9 +107,21 @@ class SplitSyncConfigService
{ {
$value = self::getConfigValue('split_sync_max_per_cron'); $value = self::getConfigValue('split_sync_max_per_cron');
if ($value === '') { if ($value === '') {
return 2; return 5;
} }
return max(1, min(20, (int) $value)); return max(1, min(50, (int) $value));
}
/**
* Node 通道候选池倍数(候选数 = maxPerRun × 本值)
*/
public static function getNodeCandidatePoolMultiplier(): int
{
$value = self::getConfigValue('split_sync_node_candidate_multiplier');
if ($value === '') {
return 10;
}
return max(3, min(50, (int) $value));
} }
/** /**
@@ -86,6 +148,53 @@ class SplitSyncConfigService
return max(0, (int) $value); return max(0, (int) $value);
} }
/**
* 后台投递 / Cron 使用的 PHP CLI 可执行文件路径
*
* Web(FPM) 环境下 PHP_BINARY 指向 php-fpm,不能用于 think 命令。
*/
public static function getCliPhpBinary(): string
{
$configured = trim(self::getConfigValue('split_sync_cli_php'));
if ($configured !== '' && self::isUsableCliPhp($configured)) {
return $configured;
}
if (defined('PHP_BINDIR') && PHP_BINDIR !== '') {
$candidate = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php';
if (self::isUsableCliPhp($candidate)) {
return $candidate;
}
}
if (PHP_SAPI === 'cli' && defined('PHP_BINARY') && PHP_BINARY !== '' && self::isUsableCliPhp(PHP_BINARY)) {
return PHP_BINARY;
}
foreach (['/usr/bin/php', '/usr/local/bin/php'] as $candidate) {
if (self::isUsableCliPhp($candidate)) {
return $candidate;
}
}
return 'php';
}
/**
* 判断路径是否为可用的 PHP CLI(排除 php-fpm
*/
private static function isUsableCliPhp(string $path): bool
{
if (stripos($path, 'fpm') !== false) {
return false;
}
if ($path === 'php') {
return true;
}
return is_file($path) && is_executable($path);
}
private static function getConfigValue(string $name): string private static function getConfigValue(string $name): string
{ {
$site = Config::get('site.' . $name); $site = Config::get('site.' . $name);
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Number;
use app\admin\model\split\Ticket;
/**
* 工单本地开启号码数(active_number_count)物化字段维护
*
* 统计口径:fa_split_number 中 admin_id + split_link_id + ticket_name 匹配且 status=normal
*/
class SplitTicketActiveNumberCountService
{
/**
* 按工单关联键统计并回写 active_number_count
*/
public function refreshForTicketKeys(int $adminId, int $linkId, string $ticketName): void
{
$ticketName = trim($ticketName);
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
return;
}
$count = (int) Number::where('admin_id', $adminId)
->where('split_link_id', $linkId)
->where('ticket_name', $ticketName)
->where('status', 'normal')
->count();
Ticket::where('admin_id', $adminId)
->where('split_link_id', $linkId)
->where('ticket_name', $ticketName)
->update([
'active_number_count' => $count,
'updatetime' => time(),
]);
}
/**
* 按工单模型回写
*/
public function refreshForTicket(Ticket $ticket): void
{
$this->refreshForTicketKeys(
(int) $ticket['admin_id'],
(int) $ticket['split_link_id'],
(string) $ticket['ticket_name']
);
}
/**
* 批量号码 ID:去重后刷新涉及工单
*
* @param array<int|string> $ids
*/
public function refreshForNumberIds(array $ids): void
{
$ids = array_values(array_filter(array_map('intval', $ids)));
if ($ids === []) {
return;
}
$rows = Number::where('id', 'in', $ids)
->field('admin_id,split_link_id,ticket_name')
->select();
$this->refreshFromNumberRows($rows);
}
/**
* 编辑单条号码:关联键变更时刷新旧工单与新工单
*
* @param array<string, mixed> $before
* @param array<string, mixed> $after
*/
public function refreshAfterNumberEdit(array $before, array $after): void
{
$keys = [];
$beforeKey = $this->buildTicketKeyFromRow($before);
if ($beforeKey !== null) {
$keys[] = $beforeKey;
}
$afterKey = $this->buildTicketKeyFromRow($after);
if ($afterKey !== null) {
$keys[] = $afterKey;
}
$this->refreshForTicketKeyList($keys);
}
/**
* 批量工单关联键去重后刷新
*
* @param array<int, array{0:int,1:int,2:string}> $keys
*/
public function refreshForTicketKeyList(array $keys): void
{
$seen = [];
foreach ($keys as $key) {
if (!is_array($key) || count($key) < 3) {
continue;
}
$adminId = (int) $key[0];
$linkId = (int) $key[1];
$ticketName = trim((string) $key[2]);
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
continue;
}
$hash = $adminId . ':' . $linkId . ':' . $ticketName;
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$this->refreshForTicketKeys($adminId, $linkId, $ticketName);
}
}
/**
* @param iterable<int, array<string, mixed>|Number> $rows
*/
public function refreshFromNumberRows(iterable $rows): void
{
$keys = [];
foreach ($rows as $row) {
$data = $row instanceof Number ? $row->getData() : (array) $row;
$key = $this->buildTicketKeyFromRow($data);
if ($key !== null) {
$keys[] = $key;
}
}
$this->refreshForTicketKeyList($keys);
}
/**
* @param array<string, mixed> $row
* @return array{0:int,1:int,2:string}|null
*/
private function buildTicketKeyFromRow(array $row): ?array
{
$adminId = (int) ($row['admin_id'] ?? 0);
$linkId = (int) ($row['split_link_id'] ?? 0);
$ticketName = trim((string) ($row['ticket_name'] ?? ''));
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
return null;
}
return [$adminId, $linkId, $ticketName];
}
}
@@ -13,7 +13,7 @@ use think\Db;
* 工单同步结果写入号码表 * 工单同步结果写入号码表
* *
* 同步策略(全量镜像): * 同步策略(全量镜像):
* - 爬虫返回的号码:写入 platform_status / inbound_count,开关 status 与云控在线状态一致 * - 爬虫返回的号码:写入 platform_status / inbound_countstatus 由后续 applyNumberRules 裁决
* - 爬虫未返回且 manual_manage=0:物理删除 * - 爬虫未返回且 manual_manage=0:物理删除
* - manual_manage=1:不删除,仅当仍在爬虫结果中时更新 platform_status * - manual_manage=1:不删除,仅当仍在爬虫结果中时更新 platform_status
*/ */
@@ -108,27 +108,11 @@ class SplitTicketNumberSyncService
} }
/** /**
* 工单手动开启等非同步场景:按已存的 platform_status 对齐开关(不跑单号上限/下号比率) * 工单手动开启等非同步场景:按业务规则对齐号码开关(平台 + 单号上限 + 下号比率)
*/ */
public function applyStatusFromPlatformSnapshot(Ticket $ticket): void public function applyStatusFromPlatformSnapshot(Ticket $ticket): void
{ {
if ((string) ($ticket['status'] ?? 'hidden') !== 'normal') { (new SplitTicketRuleService())->applyNumberRules($ticket);
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(),
]);
}
} }
/** /**
@@ -163,7 +147,6 @@ class SplitTicketNumberSyncService
} }
$update['inbound_count'] = max(0, $newFollowers); $update['inbound_count'] = max(0, $newFollowers);
$update['status'] = self::resolveSwitchStatus($ticket, $platformStatus);
Number::where('id', (int) $row['id'])->update($update); Number::where('id', (int) $row['id'])->update($update);
} }
@@ -185,7 +168,7 @@ class SplitTicketNumberSyncService
'inbound_count' => max(0, $newFollowers), 'inbound_count' => max(0, $newFollowers),
'manual_manage' => 0, 'manual_manage' => 0,
'platform_status' => $platformStatus, 'platform_status' => $platformStatus,
'status' => self::resolveSwitchStatus($ticket, $platformStatus), 'ratio_deferred' => 0,
'createtime' => $now, 'createtime' => $now,
'updatetime' => $now, 'updatetime' => $now,
]; ];
@@ -12,8 +12,40 @@ use app\admin\model\split\Ticket;
*/ */
class SplitTicketRuleService class SplitTicketRuleService
{ {
private SplitNumberRatioDeferredService $ratioDeferredService;
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
{
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
}
/** /**
* 同步后应用工单开关规则;号码开关以爬虫/platform 快照为准(不再跑单号上限/下号比率) * 手动关闭的工单(manual_manage=1):禁止自动同步改写 status,须用户手动开启
*/
public function isManuallyClosed(Ticket $ticket): bool
{
return (int) ($ticket['manual_manage'] ?? 0) === 1;
}
/**
* 列表/编辑切换状态时同步 manual_manage 标记
*
* @param array<string, mixed> $values
*/
public function applyManualManageForStatusChange(array &$values): void
{
if (!isset($values['status'])) {
return;
}
if ((string) $values['status'] === 'hidden') {
$values['manual_manage'] = 1;
} elseif ((string) $values['status'] === 'normal') {
$values['manual_manage'] = 0;
}
}
/**
* 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率)
*/ */
public function applyAfterSync(Ticket $ticket, int $completeCount): void public function applyAfterSync(Ticket $ticket, int $completeCount): void
{ {
@@ -23,7 +55,7 @@ class SplitTicketRuleService
if ((string) $fresh['status'] === 'hidden') { if ((string) $fresh['status'] === 'hidden') {
$this->cascadeTicketClosedToNumbers($fresh); $this->cascadeTicketClosedToNumbers($fresh);
} else { } else {
(new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($fresh); $this->applyNumberRules($fresh);
} }
} }
} }
@@ -44,13 +76,14 @@ class SplitTicketRuleService
'status' => 'hidden', 'status' => 'hidden',
'updatetime' => time(), 'updatetime' => time(),
]); ]);
(new SplitTicketActiveNumberCountService())->refreshForTicket($ticket);
} }
/** /**
* 工单物理删除时,移除该工单同步导入的号码(manual_manage=0 * 工单物理删除时,移除该工单关联的全部号码(manual_manage=1
* *
* 关联键与 SplitTicketNumberSyncService 一致:admin_id + split_link_id + ticket_name * 关联键与 SplitTicketNumberSyncService 一致:admin_id + split_link_id + ticket_name
* manual_manage=1 的手动管理号码保留
* *
* @return int 删除行数 * @return int 删除行数
*/ */
@@ -65,12 +98,11 @@ class SplitTicketRuleService
return Number::where('admin_id', (int) $ticket['admin_id']) return Number::where('admin_id', (int) $ticket['admin_id'])
->where('split_link_id', $linkId) ->where('split_link_id', $linkId)
->where('ticket_name', $ticketName) ->where('ticket_name', $ticketName)
->where('manual_manage', 0)
->delete(); ->delete();
} }
/** /**
* 手动切换工单状态时,非手动号码按 platform_status 对齐开关 * 手动切换工单状态时,非手动号码按业务规则对齐开关
*/ */
public function syncNumbersWithTicketStatus(Ticket $ticket): void public function syncNumbersWithTicketStatus(Ticket $ticket): void
{ {
@@ -79,21 +111,21 @@ class SplitTicketRuleService
$this->cascadeTicketClosedToNumbers($ticket); $this->cascadeTicketClosedToNumbers($ticket);
return; return;
} }
(new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($ticket); $this->applyNumberRules($ticket);
} }
/** /**
* 工单处于开启状态时,将云控在线的非手动号码设为开启 * 工单处于开启状态时,将云控在线的非手动号码设为开启
* *
* @deprecated 请使用 applyStatusFromPlatformSnapshot(以 platform_status 为准) * @deprecated 请使用 applyNumberRules
*/ */
public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void
{ {
(new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($ticket); $this->applyNumberRules($ticket);
} }
/** /**
* 单号上限、下号比率、云控在线状态、工单开关综合决定号码状态 * 单号上限、下号比率、云控在线状态、工单开关综合决定号码 status;同步时清除或确认 ratio_deferred
*/ */
public function applyNumberRules(Ticket $ticket): void public function applyNumberRules(Ticket $ticket): void
{ {
@@ -108,14 +140,13 @@ class SplitTicketRuleService
foreach ($numbers as $number) { foreach ($numbers as $number) {
$visitCount = (int) $number['visit_count']; $visitCount = (int) $number['visit_count'];
$inboundCount = (int) $number['inbound_count']; $inboundCount = (int) $number['inbound_count'];
$lastVisit = (int) ($number['last_sync_visit_count'] ?? 0);
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0); $lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
$streak = (int) ($number['no_inbound_click_streak'] ?? 0); $streak = (int) ($number['no_inbound_click_streak'] ?? 0);
if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) { if ($visitCount <= 0) {
$streak += ($visitCount - $lastVisit);
} elseif ($inboundCount > $lastInbound) {
$streak = 0; $streak = 0;
} else {
$streak = $this->ratioDeferredService->computeStreakForSync($number, $streak);
} }
$update = [ $update = [
@@ -125,12 +156,26 @@ class SplitTicketRuleService
'updatetime' => time(), 'updatetime' => time(),
]; ];
// 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态 $clearDeferred = [
'ratio_deferred' => 0,
'ratio_deferred_at' => null,
];
// 手动管理:更新统计;无效降权(无落地页访问)一并清除
if ((int) $number['manual_manage'] === 1) { 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); Number::where('id', (int) $number['id'])->update($update);
continue; continue;
} }
// 同步裁决 ratio_deferred:同步时清除访问侧降权,由 streak 决定是否关号
$update = array_merge($update, $clearDeferred);
$update['status'] = $this->resolveAutomatedStatus( $update['status'] = $this->resolveAutomatedStatus(
$ticket, $ticket,
(string) ($number['platform_status'] ?? 'unknown'), (string) ($number['platform_status'] ?? 'unknown'),
@@ -142,6 +187,8 @@ class SplitTicketRuleService
Number::where('id', (int) $number['id'])->update($update); Number::where('id', (int) $number['id'])->update($update);
} }
(new SplitTicketActiveNumberCountService())->refreshForTicket($ticket);
} }
/** /**
@@ -172,10 +219,13 @@ class SplitTicketRuleService
} }
/** /**
* 完成量、时间窗口决定工单开关(定时与手动同步均适用) * 完成量、时间窗口决定工单开关(定时与手动同步均适用;手动关闭工单除外
*/ */
public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void
{ {
if ($this->isManuallyClosed($ticket)) {
return;
}
$status = $this->resolveTicketStatus($ticket, $completeCount); $status = $this->resolveTicketStatus($ticket, $completeCount);
if ($status !== (string) ($ticket['status'] ?? 'hidden')) { if ($status !== (string) ($ticket['status'] ?? 'hidden')) {
Ticket::where('id', (int) $ticket['id'])->update([ Ticket::where('id', (int) $ticket['id'])->update([
@@ -0,0 +1,572 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\library\scrm\AntiBotConfigBuilder;
use think\Db;
/**
* 工单同步 CLI 后台投递
*
* Web / Cron 仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
* 避免长时间占用 PHP-FPM、Cron 全局锁或 Session 锁。
*
* 并行策略:
* - API 优先类型(Whatshub 等):独立并行投递,不占 Node Real Browser 槽位
* - 直连 PHP 类型:独立并行投递
* - Node 类型:不同 sessionKey(域名)并行;同一 sessionKey 内串行以命中 Browser 温池
*/
class SplitTicketSyncDispatchService
{
/**
* 投递手工同步任务到后台 CLI
*
* @param int[] $ticketIds 已通过权限校验的工单 ID
* @return array{queued:int[],skipped:int[],failed:int[]}
*/
public function dispatchManual(array $ticketIds): array
{
$lockService = new SplitTicketSyncLockService();
$metaList = $this->loadTicketMetaList($ticketIds);
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
$toDispatch = [];
$skipped = [];
$failed = [];
foreach ($metaList as $meta) {
$ticketId = (int) $meta['id'];
if ((int) ($meta['manual_manage'] ?? 0) === 1) {
$skipped[] = $ticketId;
continue;
}
if ($lockService->isLocked($ticketId)) {
$skipped[] = $ticketId;
continue;
}
$toDispatch[] = $meta;
}
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'manual');
SplitTicketSyncLogger::log('web', 'manual sync dispatched', [
'queued' => $dispatchResult['queued'],
'skipped' => $skipped,
'failed' => $dispatchResult['failed'],
'phpCli' => $this->resolvePhpBinary(),
]);
return [
'queued' => $dispatchResult['queued'],
'skipped' => $skipped,
'failed' => $dispatchResult['failed'],
];
}
/**
* Cron 自动同步:将挑选后的到期工单投递到后台 CLI(Cron 进程立即返回)
*
* @param list<array{id:int,ticket_type:string,ticket_url:string}|Ticket|\think\Model> $tickets
* @return array{
* queued:list<array<string,mixed>>,
* skipped:list<array<string,mixed>>,
* failed:list<array<string,mixed>>,
* stoppedByNodeBusy:bool
* }
*/
public function dispatchAuto(array $tickets): array
{
$lockService = new SplitTicketSyncLockService();
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
$toDispatch = [];
/** @var list<array<string,mixed>> $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'],
];
}
/**
* 查询仍在同步中的工单 ID(依据 runtime 文件锁)
*
* @param int[] $ticketIds
* @return int[]
*/
public function filterSyncingIds(array $ticketIds): array
{
$lockService = new SplitTicketSyncLockService();
$syncing = [];
foreach ($ticketIds as $rawId) {
$ticketId = (int) $rawId;
if ($ticketId > 0 && $lockService->isLocked($ticketId)) {
$syncing[] = $ticketId;
}
}
return $syncing;
}
/**
* @param list<array{id:int,ticket_type:string,ticket_url:string}> $metaList
* @return array{
* queued:list<array<string,mixed>>,
* skipped:list<array<string,mixed>>,
* failed:list<array<string,mixed>>,
* stoppedByNodeBusy:bool
* }
*/
private function dispatchTicketMetaList(array $metaList, string $source): array
{
$php = $this->resolvePhpBinary();
$think = $this->resolveThinkScript();
$root = $this->resolveRootPath();
/** @var list<array<string,mixed>> $queued */
$queued = [];
/** @var list<array<string,mixed>> $skipped */
$skipped = [];
/** @var list<array<string,mixed>> $failed */
$failed = [];
$stoppedByNodeBusy = false;
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $apiFirstList */
$apiFirstList = [];
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $directList */
$directList = [];
/** @var array<string, list<array{id:int,ticket_type:string,ticket_url:string}>> $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<array{id:int,ticket_type:string,ticket_url:string}> $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<array<string,mixed>> $queued
* @param list<array<string,mixed>> $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<string,mixed>
*/
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<array{id:int,ticket_type:string,ticket_url:string}>
*/
private function loadTicketMetaList(array $ticketIds): array
{
$ids = [];
foreach ($ticketIds as $rawId) {
$ticketId = (int) $rawId;
if ($ticketId > 0) {
$ids[] = $ticketId;
}
}
if ($ids === []) {
return [];
}
$rows = Db::name('split_ticket')
->where('id', 'in', $ids)
->field('id,ticket_type,ticket_url,manual_manage')
->select();
$list = [];
foreach ($rows as $row) {
$list[] = [
'id' => (int) $row['id'],
'ticket_type' => (string) ($row['ticket_type'] ?? ''),
'ticket_url' => (string) ($row['ticket_url'] ?? ''),
'manual_manage' => (int) ($row['manual_manage'] ?? 0),
];
}
return $list;
}
/**
* @param array<string,mixed>|object $ticket
* @return array{id:int,ticket_type:string,ticket_url:string}|null
*/
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 --mode=%s >> %s 2>&1',
escapeshellarg($php),
escapeshellarg($think),
$ticketId,
$syncMode,
escapeshellarg($logFile)
);
return $this->runBackgroundCommand($root, $inner, $ticketId, $source . '-parallel');
}
/**
* 同一 sessionKey 内串行执行多个 CLI(单 nohup 子 shell
*
* @param int[] $ticketIds
*/
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds, string $source): bool
{
if ($ticketIds === []) {
return false;
}
$logDir = $this->resolveLogDir();
$parts = [];
foreach ($ticketIds as $ticketId) {
$ticketId = (int) $ticketId;
if ($ticketId <= 0) {
continue;
}
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
$syncMode = $this->normalizeSyncModeForCli($source);
$parts[] = sprintf(
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
escapeshellarg($php),
escapeshellarg($think),
$ticketId,
$syncMode,
escapeshellarg($logFile)
);
}
if ($parts === []) {
return false;
}
$inner = implode('; ', $parts);
return $this->runBackgroundCommand($root, $inner, $ticketIds, $source . '-node-serial');
}
/**
* @param int|int[] $ticketRef 日志用 ticketId 或 ID 列表
*/
private function runBackgroundCommand(string $root, string $inner, $ticketRef, string $mode): bool
{
if ($this->isWindows()) {
$command = sprintf('start /B cmd /C %s', $inner);
} else {
$command = sprintf('cd %s && nohup bash -c %s &', escapeshellarg($root), escapeshellarg($inner));
}
if ($this->canUseExec()) {
@exec($command, $output, $exitCode);
SplitTicketSyncLogger::log('dispatch', 'cli spawn exec', [
'ticketRef' => $ticketRef,
'mode' => $mode,
'command' => $command,
'exitCode' => $exitCode,
]);
return true;
}
if ($this->canUseShellExec()) {
@shell_exec($command);
SplitTicketSyncLogger::log('dispatch', 'cli spawn shell_exec', [
'ticketRef' => $ticketRef,
'mode' => $mode,
'command' => $command,
]);
return true;
}
SplitTicketSyncLogger::log('dispatch', 'cli spawn failed: exec disabled', [
'ticketRef' => $ticketRef,
'mode' => $mode,
]);
return false;
}
private function resolvePhpBinary(): string
{
if (method_exists(SplitSyncConfigService::class, 'getCliPhpBinary')) {
return SplitSyncConfigService::getCliPhpBinary();
}
return $this->resolvePhpBinaryFallback();
}
/**
* 当 SplitSyncConfigService 未部署 getCliPhpBinary 时的兜底解析
*/
private function resolvePhpBinaryFallback(): string
{
if (defined('PHP_BINDIR') && PHP_BINDIR !== '') {
$candidate = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php';
if ($this->isUsableCliPhp($candidate)) {
return $candidate;
}
}
foreach (['/usr/bin/php', '/usr/local/bin/php'] as $candidate) {
if ($this->isUsableCliPhp($candidate)) {
return $candidate;
}
}
return 'php';
}
private function isUsableCliPhp(string $path): bool
{
if (stripos($path, 'fpm') !== false) {
return false;
}
if ($path === 'php') {
return true;
}
return is_file($path) && is_executable($path);
}
private function resolveThinkScript(): string
{
$root = $this->resolveRootPath();
return rtrim($root, '/\\') . DIRECTORY_SEPARATOR . 'think';
}
private function resolveRootPath(): string
{
if (defined('ROOT_PATH') && ROOT_PATH !== '') {
return rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR;
}
return rtrim(dirname(__DIR__, 3), '/\\') . DIRECTORY_SEPARATOR;
}
private function resolveLogDir(): string
{
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : ($this->resolveRootPath() . 'runtime' . DIRECTORY_SEPARATOR);
$dir = rtrim($runtime, '/\\') . DIRECTORY_SEPARATOR . 'split_ticket_sync' . DIRECTORY_SEPARATOR;
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return $dir;
}
private function isWindows(): bool
{
return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
}
/**
* @return string[]
*/
private function disabledFunctions(): array
{
$raw = (string) ini_get('disable_functions');
if ($raw === '') {
return [];
}
return array_filter(array_map('trim', explode(',', $raw)));
}
private function canUseExec(): bool
{
return function_exists('exec') && !in_array('exec', $this->disabledFunctions(), true);
}
private function canUseShellExec(): bool
{
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';
}
}
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Ticket;
/**
* 工单同步失败分阶段重试状态机
*
* 达连续失败阈值后按配置时间点(相对锚点的绝对分钟偏移)自动重试;
* 任一次成功则清零恢复常态,全部失败则永久终止自动同步。
*/
class SplitTicketSyncFailRetryService
{
/**
* 是否已永久终止自动同步(重试时间点已全部用尽)
*
* @param Ticket|array<string, mixed> $ticket
*/
public function isPermanentlyStopped($ticket): bool
{
return (int) ($this->readField($ticket, 'sync_auto_sync_stopped') ?? 0) === 1;
}
/**
* 是否处于失败重试等待/重试周期内(未永久终止)
*
* @param Ticket|array<string, mixed> $ticket
*/
public function isInRetryMode($ticket): bool
{
if ($this->isPermanentlyStopped($ticket)) {
return false;
}
return (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0) > 0;
}
/**
* 首次达失败阈值时进入重试态(工单保持 normal,不关单)
*
* @return array<string, mixed> 需合并进 save 的字段;无变更时返回空数组
*/
public function enterRetryIfNeeded(Ticket $ticket, int $failCount, int $threshold): array
{
if ($failCount < $threshold || !SplitSyncConfigService::hasFailRetrySchedule()) {
return [];
}
if ($this->isInRetryMode($ticket)) {
return [];
}
return [
'sync_fail_pause_at' => time(),
'sync_fail_retry_index' => 0,
'sync_auto_sync_stopped' => 0,
];
}
/**
* 计划内重试失败后递进索引;用尽全部时间点后标记永久终止
*
* @return array<string, mixed>
*/
public function onRetryFailure(Ticket $ticket): array
{
$schedule = SplitSyncConfigService::getFailRetryMinutes();
$index = (int) ($ticket['sync_fail_retry_index'] ?? 0) + 1;
$update = ['sync_fail_retry_index' => $index];
if ($index >= count($schedule)) {
$update['sync_auto_sync_stopped'] = 1;
}
return $update;
}
/**
* 同步成功时清零失败计数与重试相关字段
*
* @return array<string, mixed>
*/
public function clearOnSuccessFields(): array
{
return [
'sync_fail_count' => 0,
'sync_fail_pause_at' => null,
'sync_fail_retry_index' => 0,
'sync_auto_sync_stopped' => 0,
];
}
/**
* Cron 自动重试是否已到点;未到点返回 skip 原因,到点返回 null(允许执行)
*
* @param Ticket|array<string, mixed> $ticket
*/
public function shouldAllowAutoRetry($ticket, ?int $now = null): ?string
{
if (!$this->isInRetryMode($ticket)) {
return null;
}
if ($this->isPermanentlyStopped($ticket)) {
return '自动同步已终止(重试已用尽)';
}
$nextAt = $this->getNextRetryAt($ticket);
if ($nextAt === null) {
return '自动同步已终止(重试已用尽)';
}
$now = $now ?? time();
if ($now < $nextAt) {
$attempt = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0) + 1;
return sprintf(
'失败重试等待中,将于 %s 进行第 %d 次重试',
date('Y-m-d H:i:s', $nextAt),
$attempt
);
}
return null;
}
/**
* 下一次计划重试的 Unix 时间戳;无待重试时返回 null
*
* @param Ticket|array<string, mixed> $ticket
*/
public function getNextRetryAt($ticket): ?int
{
if (!$this->isInRetryMode($ticket) || $this->isPermanentlyStopped($ticket)) {
return null;
}
$schedule = SplitSyncConfigService::getFailRetryMinutes();
if ($schedule === []) {
return null;
}
$index = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0);
if ($index >= count($schedule)) {
return null;
}
$pauseAt = (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0);
if ($pauseAt <= 0) {
return null;
}
return $pauseAt + $schedule[$index] * 60;
}
/**
* @param Ticket|array<string, mixed> $ticket
*/
private function readField($ticket, string $field)
{
if ($ticket instanceof Ticket) {
return $ticket->getData($field) ?? ($ticket[$field] ?? null);
}
return $ticket[$field] ?? null;
}
}
@@ -43,6 +43,20 @@ class SplitTicketSyncLockService
} }
} }
/**
* 工单是否处于同步中(锁文件存在且未过期)
*/
public function isLocked(int $ticketId): bool
{
$path = $this->lockPath($ticketId);
if ($this->isStaleLock($path)) {
@unlink($path);
return false;
}
return is_file($path);
}
private function lockPath(int $ticketId): string private function lockPath(int $ticketId): string
{ {
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
@@ -21,11 +21,17 @@ class SplitTicketSyncService
private SplitTicketSyncLockService $lockService; private SplitTicketSyncLockService $lockService;
private SplitTicketSyncDispatchService $dispatchService;
private SplitTicketSyncFailRetryService $failRetryService;
public function __construct() public function __construct()
{ {
$this->numberSync = new SplitTicketNumberSyncService(); $this->numberSync = new SplitTicketNumberSyncService();
$this->ruleService = new SplitTicketRuleService(); $this->ruleService = new SplitTicketRuleService();
$this->lockService = new SplitTicketSyncLockService(); $this->lockService = new SplitTicketSyncLockService();
$this->dispatchService = new SplitTicketSyncDispatchService();
$this->failRetryService = new SplitTicketSyncFailRetryService();
} }
/** /**
@@ -48,12 +54,19 @@ class SplitTicketSyncService
'force' => $force, 'force' => $force,
'syncMode' => $syncMode, 'syncMode' => $syncMode,
'status' => (string) $ticket['status'], 'status' => (string) $ticket['status'],
'manualManage' => (int) ($ticket['manual_manage'] ?? 0),
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0), 'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
'syncTime' => (int) ($ticket['sync_time'] ?? 0), 'syncTime' => (int) ($ticket['sync_time'] ?? 0),
'pageUrl' => (string) $ticket['ticket_url'], 'pageUrl' => (string) $ticket['ticket_url'],
'nodeHost' => SplitSyncConfigService::getNodeHost(), 'nodeHost' => SplitSyncConfigService::getNodeHost(),
]); ]);
if ($this->ruleService->isManuallyClosed($ticket)) {
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => '工单已手动关闭']);
SplitTicketSyncLogger::clearTicketContext();
return ['success' => false, 'message' => '工单已手动关闭,暂停同步', 'skipped' => true];
}
if (!$force && !$dueValidated) { if (!$force && !$dueValidated) {
$skip = $this->shouldSkip($ticket); $skip = $this->shouldSkip($ticket);
if ($skip !== null) { if ($skip !== null) {
@@ -80,154 +93,411 @@ class SplitTicketSyncService
} }
/** /**
* 扫描到期工单并同步(限流 + Node 队列感知 * 扫描到期工单并同步(直连 PHP 与 Node 双通道
*/ */
public function syncDueTickets(): int public function syncDueTickets(): int
{ {
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
$autoSyncTypes = $this->resolveAutoSyncTicketTypes(); $autoSyncTypes = $this->resolveAutoSyncTicketTypes();
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
if ($autoSyncTypes === []) { if ($autoSyncTypes === []) {
SplitTicketSyncLogger::logCron('scan skip', ['reason' => 'no auto-sync ticket types']); SplitTicketSyncLogger::logCron('scan skip', ['reason' => 'no auto-sync ticket types']);
return 0; return 0;
} }
if (!SplitNodeHealthService::canAcceptWork()) { $failThreshold = SplitSyncConfigService::getFailPauseThreshold();
SplitTicketSyncLogger::logCron('scan skip', [ $directTypes = array_values(array_intersect(
'reason' => 'node queue busy', $autoSyncTypes,
'queue' => SplitNodeHealthService::getQueueSnapshot(), SplitScrmSpiderFactory::listDirectSyncTypes()
]); ));
return 0; $nodeTypes = array_values(array_intersect(
} $autoSyncTypes,
SplitScrmSpiderFactory::listNodeSyncTypes()
));
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
if ($failThreshold > 0) {
$query->where('sync_fail_count', '<', $failThreshold);
}
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
->limit($maxPerRun * 5)
->select();
$list = $this->sortTicketsBySessionKey($list);
$candidateCount = count($list);
SplitTicketSyncLogger::logCron('scan start', [ SplitTicketSyncLogger::logCron('scan start', [
'candidateCount' => $candidateCount, 'autoSyncTypes' => $autoSyncTypes,
'maxPerRun' => $maxPerRun, 'directTypes' => $directTypes,
'autoSyncTypes' => $autoSyncTypes, 'nodeTypes' => $nodeTypes,
'failThreshold' => $failThreshold, 'failThreshold' => $failThreshold,
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
'groupedBy' => 'sessionKey',
]);
SplitTicketSyncLogger::log('cron', 'scan start', [
'candidateCount' => $candidateCount,
'maxPerRun' => $maxPerRun,
'autoSyncTypes' => $autoSyncTypes,
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
]); ]);
/** @var list<array<string, mixed>> $processed */ $directResult = $this->runDirectCronChannel($directTypes, $failThreshold);
$processed = []; $nodeResult = $this->runNodeCronChannel($nodeTypes, $failThreshold);
/** @var list<array<string, mixed>> $skipped */
$skipped = [];
$count = 0;
$stoppedByNodeBusy = false;
foreach ($list as $index => $ticket) { $processedCount = $directResult['processedCount'] + $nodeResult['processedCount'];
$ticketId = (int) $ticket['id']; $processed = array_merge($directResult['processed'], $nodeResult['processed']);
$ticketUrl = (string) $ticket['ticket_url']; $skipped = array_merge($directResult['skipped'], $nodeResult['skipped']);
$ticketType = (string) $ticket['ticket_type']; $candidateIds = array_merge($directResult['candidateIds'], $nodeResult['candidateIds']);
$sessionKey = AntiBotConfigBuilder::resolveSessionKey($ticketType, $ticketUrl);
if ($count >= $maxPerRun) {
$reason = '本轮处理上限已满';
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason);
continue;
}
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skip);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip);
continue;
}
if (!SplitNodeHealthService::canAcceptWork()) {
$reason = 'Node 队列繁忙,停止本轮后续同步';
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason);
$stoppedByNodeBusy = true;
for ($i = $index + 1; $i < $candidateCount; $i++) {
$remain = $list[$i];
$remainReason = 'Node 队列繁忙,本轮未执行';
$skipped[] = $this->buildCronTicketEntry(
(int) $remain['id'],
(string) $remain['ticket_type'],
(string) $remain['ticket_url'],
$remainReason
);
}
SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [
'processed' => $count,
'queue' => SplitNodeHealthService::getQueueSnapshot(),
]);
break;
}
$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);
continue;
}
$processed[] = [
'ticketId' => $ticketId,
'ticketType' => $ticketType,
'ticketUrl' => $ticketUrl,
'sessionKey' => $sessionKey,
'success' => !empty($result['success']),
'message' => (string) ($result['message'] ?? ''),
];
$count++;
}
$candidateIds = array_map(static function ($row) {
return (int) (is_array($row) ? ($row['id'] ?? 0) : ($row['id'] ?? 0));
}, $list);
$openNotSynced = $this->buildOpenNotSyncedAudit( $openNotSynced = $this->buildOpenNotSyncedAudit(
$autoSyncTypes, $autoSyncTypes,
$failThreshold, $failThreshold,
$maxPerRun, SplitSyncConfigService::getMaxTicketsPerCronRun(),
$candidateIds, $candidateIds,
$processed, $processed,
$skipped, $skipped,
$stoppedByNodeBusy $nodeResult['stoppedByNodeBusy']
); );
$successCount = count(array_filter($processed, static function (array $row): bool { $successCount = count(array_filter($processed, static function (array $row): bool {
return !empty($row['success']); return !empty($row['dispatched']);
})); }));
$failedCount = count($processed) - $successCount; $failedCount = count($processed) - $successCount;
SplitTicketSyncLogger::logCron('scan end', [ SplitTicketSyncLogger::logCron('scan end', [
'processedCount' => $count, 'processedCount' => $processedCount,
'successCount' => $successCount, 'successCount' => $successCount,
'failedCount' => $failedCount, 'failedCount' => $failedCount,
'skippedCount' => count($skipped), 'skippedCount' => count($skipped),
'directChannel' => $directResult['summary'],
'nodeChannel' => $nodeResult['summary'],
'processed' => $processed, 'processed' => $processed,
'skipped' => $skipped, 'skipped' => $skipped,
'openNotSynced' => $openNotSynced, 'openNotSynced' => $openNotSynced,
]); ]);
SplitTicketSyncLogger::log('cron', 'scan end', [ SplitTicketSyncLogger::log('cron', 'scan end', [
'processedCount' => $count, 'processedCount' => $processedCount,
'successCount' => $successCount, 'successCount' => $successCount,
'failedCount' => $failedCount, 'failedCount' => $failedCount,
]); ]);
return $count; return $processedCount;
}
/**
* 直连 PHP 通道:挑选到期工单并投递后台 CLI,不占 Node 名额
*
* @param list<string> $directTypes
* @return array{
* processedCount:int,
* processed:list<array<string,mixed>>,
* skipped:list<array<string,mixed>>,
* candidateIds:list<int>,
* summary:array<string,mixed>
* }
*/
private function runDirectCronChannel(array $directTypes, int $failThreshold): array
{
if ($directTypes === []) {
return $this->emptyCronChannelResult('direct');
}
$list = $this->loadOpenTicketsForTypes($directTypes, $failThreshold, 0);
$candidateIds = $this->extractTicketIds($list);
/** @var list<array<string,mixed>> $dueList */
$dueList = [];
/** @var list<array<string,mixed>> $skipped */
$skipped = [];
foreach ($list as $ticket) {
$ticketId = (int) $ticket['id'];
$ticketType = (string) $ticket['ticket_type'];
$ticketUrl = (string) $ticket['ticket_url'];
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skip);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip, 'direct');
continue;
}
$dueList[] = $ticket;
}
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<array<string,mixed>> $processed */
$processed = [];
foreach ($dispatchResult['queued'] as $row) {
$processed[] = [
'ticketId' => (int) ($row['ticketId'] ?? 0),
'ticketType' => (string) ($row['ticketType'] ?? ''),
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
'channel' => 'direct',
'dispatched' => true,
'message' => '已投递后台同步',
];
}
return [
'processedCount' => count($processed),
'processed' => $processed,
'skipped' => $skipped,
'candidateIds' => $candidateIds,
'summary' => [
'channel' => 'direct',
'processedCount' => count($processed),
'skippedCount' => count($skipped),
'dispatchMode' => 'async-cli',
],
'stoppedByNodeBusy' => false,
];
}
/**
* Node 通道:限流 + 类型公平轮转 + 异步 CLI 投递
*
* @param list<string> $nodeTypes
* @return array{
* processedCount:int,
* processed:list<array<string,mixed>>,
* skipped:list<array<string,mixed>>,
* candidateIds:list<int>,
* summary:array<string,mixed>,
* stoppedByNodeBusy:bool
* }
*/
private function runNodeCronChannel(array $nodeTypes, int $failThreshold): array
{
if ($nodeTypes === []) {
return $this->emptyCronChannelResult('node');
}
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
$poolMultiplier = SplitSyncConfigService::getNodeCandidatePoolMultiplier();
$candidateLimit = $maxPerRun * $poolMultiplier;
$pool = $this->loadOpenTicketsForTypes($nodeTypes, $failThreshold, $candidateLimit);
$candidateIds = $this->extractTicketIds($pool);
$duePool = [];
foreach ($pool as $ticket) {
if ($this->shouldSkip($ticket) === null) {
$duePool[] = $ticket;
}
}
$picked = $this->pickFairNodeTickets($duePool, $maxPerRun);
$picked = $this->sortTicketsBySessionKey($picked);
$pickedIdMap = array_fill_keys($this->extractTicketIds($picked), true);
SplitTicketSyncLogger::logCron('node channel start', [
'types' => $nodeTypes,
'maxPerRun' => $maxPerRun,
'poolCount' => count($pool),
'dueCount' => count($duePool),
'pickedCount' => count($picked),
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
'dispatchMode' => 'async-cli',
]);
/** @var list<array<string,mixed>> $skipped */
$skipped = [];
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)
);
}
$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<array<string,mixed>> $processed */
$processed = [];
foreach ($dispatchResult['queued'] as $row) {
$processed[] = [
'ticketId' => (int) ($row['ticketId'] ?? 0),
'ticketType' => (string) ($row['ticketType'] ?? ''),
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
'sessionKey' => (string) ($row['sessionKey'] ?? ''),
'channel' => 'node',
'dispatched' => true,
'message' => '已投递后台同步',
];
}
return [
'processedCount' => count($processed),
'processed' => $processed,
'skipped' => $skipped,
'candidateIds' => $candidateIds,
'summary' => [
'channel' => 'node',
'processedCount' => count($processed),
'skippedCount' => count($skipped),
'maxPerRun' => $maxPerRun,
'dispatchMode' => 'async-cli',
],
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
];
}
/**
* @param list<array<string,mixed>> $rows
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
*/
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<array<string,mixed>> $rows
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
*/
private function mapDispatchFailedToCron(array $rows): array
{
return $this->mapDispatchSkippedToCron($rows);
}
/**
* @param list<string> $types
* @return list<Ticket>
*/
private function loadOpenTicketsForTypes(array $types, int $failThreshold, int $limit): array
{
if ($types === []) {
return [];
}
$query = Ticket::where('status', 'normal')
->where('manual_manage', 0)
->whereIn('ticket_type', $types);
if ($failThreshold > 0) {
$query->where(function ($query) use ($failThreshold): void {
$query->where('sync_fail_count', '<', $failThreshold)
->whereOr(function ($query): void {
$query->where('sync_fail_pause_at', '>', 0)
->where('sync_auto_sync_stopped', 0);
});
});
}
$query->order('sync_time', 'asc')->order('id', 'asc');
if ($limit > 0) {
$query->limit($limit);
}
$list = $query->select();
return $list instanceof \think\Collection ? $list->all() : (array) $list;
}
/**
* Node 通道:按工单类型公平轮转选取(每轮每类型优先取 1 条)
*
* @param list<Ticket> $dueTickets
* @return list<Ticket>
*/
private function pickFairNodeTickets(array $dueTickets, int $maxPerRun): array
{
if ($dueTickets === [] || $maxPerRun <= 0) {
return [];
}
/** @var array<string, list<Ticket>> $byType */
$byType = [];
foreach ($dueTickets as $ticket) {
$type = (string) $ticket['ticket_type'];
$byType[$type][] = $ticket;
}
$typeKeys = array_keys($byType);
sort($typeKeys, SORT_STRING);
/** @var list<Ticket> $picked */
$picked = [];
while (count($picked) < $maxPerRun) {
$added = false;
foreach ($typeKeys as $type) {
if (count($picked) >= $maxPerRun) {
break;
}
if ($byType[$type] === []) {
continue;
}
$picked[] = array_shift($byType[$type]);
$added = true;
}
if (!$added) {
break;
}
}
return $picked;
}
/**
* @param list<Ticket|array<string,mixed>> $list
* @return list<int>
*/
private function extractTicketIds(array $list): array
{
$ids = [];
foreach ($list as $row) {
$ids[] = (int) (is_array($row) ? ($row['id'] ?? 0) : ($row['id'] ?? 0));
}
return $ids;
}
/**
* @return array{
* processedCount:int,
* processed:list<array<string,mixed>>,
* skipped:list<array<string,mixed>>,
* candidateIds:list<int>,
* summary:array<string,mixed>,
* stoppedByNodeBusy:bool
* }
*/
private function emptyCronChannelResult(string $channel): array
{
return [
'processedCount' => 0,
'processed' => [],
'skipped' => [],
'candidateIds' => [],
'summary' => ['channel' => $channel, 'processedCount' => 0, 'skippedCount' => 0],
'stoppedByNodeBusy' => false,
];
} }
/** /**
@@ -251,6 +521,15 @@ class SplitTicketSyncService
*/ */
private function doSync(Ticket $ticket, string $syncMode): array private function doSync(Ticket $ticket, string $syncMode): array
{ {
$freshTicket = Ticket::get((int) $ticket['id']);
if (!$freshTicket) {
return ['success' => false, 'message' => '工单不存在', 'skipped' => true];
}
if ($this->ruleService->isManuallyClosed($freshTicket)) {
return ['success' => false, 'message' => '工单已手动关闭,暂停同步', 'skipped' => true];
}
$ticket = $freshTicket;
$ticketType = (string) $ticket['ticket_type']; $ticketType = (string) $ticket['ticket_type'];
$pageUrl = trim((string) $ticket['ticket_url']); $pageUrl = trim((string) $ticket['ticket_url']);
if ($pageUrl === '') { if ($pageUrl === '') {
@@ -296,6 +575,8 @@ class SplitTicketSyncService
$freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket; $freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket;
if ((string) $freshTicket['status'] === 'hidden') { if ((string) $freshTicket['status'] === 'hidden') {
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket); $this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
} else {
$this->ruleService->applyNumberRules($freshTicket);
} }
$ticket = $freshTicket; $ticket = $freshTicket;
@@ -335,9 +616,33 @@ class SplitTicketSyncService
private function shouldSkip(Ticket $ticket): ?string private function shouldSkip(Ticket $ticket): ?string
{ {
if ($this->ruleService->isManuallyClosed($ticket)) {
return '工单已手动关闭,暂停同步';
}
if ((string) $ticket['status'] === 'hidden') { if ((string) $ticket['status'] === 'hidden') {
return '工单已关闭'; return '工单已关闭';
} }
if ($this->failRetryService->isPermanentlyStopped($ticket)) {
return '自动同步已终止(重试已用尽)';
}
if ($this->failRetryService->isInRetryMode($ticket)) {
$retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket);
if ($retrySkip !== null) {
return $retrySkip;
}
if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) {
return '工单类型尚未实现';
}
$interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']);
if ($interval <= 0) {
return '该类型未配置自动同步周期';
}
return null;
}
$failThreshold = SplitSyncConfigService::getFailPauseThreshold(); $failThreshold = SplitSyncConfigService::getFailPauseThreshold();
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) { if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
return sprintf('连续同步失败超过%d次已暂停', $failThreshold); return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
@@ -386,6 +691,7 @@ class SplitTicketSyncService
if ($success) { if ($success) {
$data['sync_success_time'] = $now; $data['sync_success_time'] = $now;
$data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual'; $data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual';
$data = array_merge($data, $this->failRetryService->clearOnSuccessFields());
} }
if (!$ticket->allowField(array_keys($data))->save($data)) { if (!$ticket->allowField(array_keys($data))->save($data)) {
throw new Exception('工单同步结果保存失败'); throw new Exception('工单同步结果保存失败');
@@ -404,6 +710,21 @@ class SplitTicketSyncService
'sync_message' => $message, 'sync_message' => $message,
'sync_fail_count' => $failCount, 'sync_fail_count' => $failCount,
]; ];
if ($this->failRetryService->isInRetryMode($ticket)) {
$update = array_merge($update, $this->failRetryService->onRetryFailure($ticket));
$ticket->save($update);
return;
}
if ($failThreshold > 0 && $failCount >= $failThreshold && SplitSyncConfigService::hasFailRetrySchedule()) {
$update = array_merge($update, $this->failRetryService->enterRetryIfNeeded($ticket, $failCount, $failThreshold));
$ticket->save($update);
return;
}
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) { if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
$update['status'] = 'hidden'; $update['status'] = 'hidden';
} }
@@ -466,13 +787,14 @@ class SplitTicketSyncService
]; ];
} }
private function logCronCandidateSkipped(int $ticketId, string $ticketType, string $ticketUrl, string $reason): void private function logCronCandidateSkipped(int $ticketId, string $ticketType, string $ticketUrl, string $reason, string $channel = 'legacy'): void
{ {
$ctx = [ $ctx = [
'ticketId' => $ticketId, 'ticketId' => $ticketId,
'ticketType' => $ticketType, 'ticketType' => $ticketType,
'ticketUrl' => $ticketUrl, 'ticketUrl' => $ticketUrl,
'reason' => $reason, 'reason' => $reason,
'channel' => $channel,
]; ];
SplitTicketSyncLogger::logCron('candidate skipped', $ctx); SplitTicketSyncLogger::logCron('candidate skipped', $ctx);
SplitTicketSyncLogger::log('cron', 'candidate skipped', $ctx); SplitTicketSyncLogger::log('cron', 'candidate skipped', $ctx);
@@ -511,18 +833,19 @@ class SplitTicketSyncService
$openList = Ticket::where('status', 'normal') $openList = Ticket::where('status', 'normal')
->whereIn('ticket_type', $supportedTypes) ->whereIn('ticket_type', $supportedTypes)
->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status') ->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status,sync_fail_pause_at,sync_fail_retry_index,sync_auto_sync_stopped,status,manual_manage')
->select(); ->select();
$result = []; $result = [];
foreach ($openList as $ticket) { foreach ($openList as $ticketRow) {
$ticketId = (int) $ticket['id']; $ticket = $ticketRow instanceof Ticket ? $ticketRow->toArray() : (array) $ticketRow;
if (isset($handledIds[$ticketId])) { $ticketId = (int) ($ticket['id'] ?? 0);
if ($ticketId <= 0 || isset($handledIds[$ticketId])) {
continue; continue;
} }
$ticketType = (string) $ticket['ticket_type']; $ticketType = (string) ($ticket['ticket_type'] ?? '');
$ticketUrl = (string) $ticket['ticket_url']; $ticketUrl = (string) ($ticket['ticket_url'] ?? '');
$reason = $this->resolveOpenNotSyncedReason( $reason = $this->resolveOpenNotSyncedReason(
$ticket, $ticket,
$autoSyncTypes, $autoSyncTypes,
@@ -561,14 +884,29 @@ class SplitTicketSyncService
$ticketType = (string) ($ticket['ticket_type'] ?? ''); $ticketType = (string) ($ticket['ticket_type'] ?? '');
$failCount = (int) ($ticket['sync_fail_count'] ?? 0); $failCount = (int) ($ticket['sync_fail_count'] ?? 0);
if ($failThreshold > 0 && $failCount >= $failThreshold) { if ($this->failRetryService->isPermanentlyStopped($ticket)) {
return '自动同步已终止(重试已用尽)';
}
if ($this->failRetryService->isInRetryMode($ticket)) {
$retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket);
if ($retrySkip !== null) {
return $retrySkip;
}
}
if ($failThreshold > 0 && $failCount >= $failThreshold && !SplitSyncConfigService::hasFailRetrySchedule()) {
return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold); return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold);
} }
if (!in_array($ticketType, $autoSyncTypes, true)) { if (!in_array($ticketType, $autoSyncTypes, true)) {
return '该类型未配置自动同步周期'; return '该类型未配置自动同步周期';
} }
if (!isset($candidateIdMap[(int) ($ticket['id'] ?? 0)])) { if (!isset($candidateIdMap[(int) ($ticket['id'] ?? 0)])) {
return sprintf('未进入本轮候选队列(仅扫描最久未同步的前%d条)', $maxPerRun * 5); if (SplitScrmSpiderFactory::requiresNode($ticketType)) {
$poolSize = SplitSyncConfigService::getMaxTicketsPerCronRun()
* SplitSyncConfigService::getNodeCandidatePoolMultiplier();
return sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $poolSize);
}
return '直连通道:未到同步周期或本轮已处理';
} }
if ($stoppedByNodeBusy) { if ($stoppedByNodeBusy) {
return 'Node 队列繁忙,本轮未执行'; return 'Node 队列繁忙,本轮未执行';
+9 -4
View File
@@ -4,7 +4,7 @@ return array (
'name' => 'link管理系统', 'name' => 'link管理系统',
'beian' => '', 'beian' => '',
'cdnurl' => '', 'cdnurl' => '',
'version' => '1.0.14', 'version' => '1.0.10',
'timezone' => 'Asia/Shanghai', 'timezone' => 'Asia/Shanghai',
'forbiddenip' => '', 'forbiddenip' => '',
'languages' => 'languages' =>
@@ -44,8 +44,6 @@ return array (
), ),
'split_platform_domain' => 'flowerbells.top', 'split_platform_domain' => 'flowerbells.top',
'split_scrm_node_host' => 'http://127.0.0.1:3001', 'split_scrm_node_host' => 'http://127.0.0.1:3001',
'split_scrm_antibot_types' => 'whatshub,chatknow,a2c,huojian',
'split_scrm_session_ttl_minutes' => '120',
'split_sync_interval_a2c' => '3', 'split_sync_interval_a2c' => '3',
'split_sync_interval_haiwang' => '3', 'split_sync_interval_haiwang' => '3',
'split_sync_interval_huojian' => '3', 'split_sync_interval_huojian' => '3',
@@ -55,8 +53,15 @@ return array (
'split_sync_interval_taiji' => '0', 'split_sync_interval_taiji' => '0',
'split_sync_interval_ss_channel' => '0', 'split_sync_interval_ss_channel' => '0',
'split_sync_interval_yifafa' => '0', 'split_sync_interval_yifafa' => '0',
'split_sync_interval_whatshub' => '5', 'split_sync_interval_whatshub' => '3',
'split_sync_interval_sihai' => '0', 'split_sync_interval_sihai' => '0',
'split_sync_fail_pause_threshold' => '6', 'split_sync_fail_pause_threshold' => '6',
'split_sync_fail_retry_minutes' => '',
'split_sync_interval_chatknow' => '5', 'split_sync_interval_chatknow' => '5',
'split_sync_max_per_cron' => '27',
'split_sync_node_candidate_multiplier' => '13',
'split_sync_cron_lock_ttl' => '680',
'split_sync_node_queue_threshold' => '8',
'split_scrm_antibot_types' => 'whatshub,chatknow,a2c,huojian',
'split_scrm_session_ttl_minutes' => '120',
); );
@@ -0,0 +1,19 @@
-- 双通道定时同步:Node 通道上限与候选池配置(已有环境增量执行)
SET NAMES utf8mb4;
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_max_per_cron', 'split', 'Node通道单次同步上限', '每分钟 cron 在 Node 通道最多处理几条到期工单(仅影响需调用 Node 的类型);纯 PHP 直连类型不受此限制', 'number', '', '5', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_node_candidate_multiplier', 'split', 'Node通道候选池倍数', 'Node 候选工单数 = 单次上限 × 本倍数,用于公平轮转', 'number', '', '10', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_node_candidate_multiplier' LIMIT 1);
UPDATE `fa_config`
SET `title` = 'Node通道单次同步上限',
`tip` = '每分钟 cron 在 Node 通道最多处理几条到期工单(仅影响需调用 Node 的类型);纯 PHP 直连类型不受此限制'
WHERE `name` = 'split_sync_max_per_cron';
UPDATE `fa_config`
SET `value` = '5'
WHERE `name` = 'split_sync_max_per_cron' AND (`value` = '' OR `value` = '2');
@@ -6,9 +6,21 @@ SELECT 'split_scrm_node_host', 'split', '云控 Node 服务地址', '所有云
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_scrm_node_host' LIMIT 1); FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_scrm_node_host' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_fail_pause_threshold', 'split', '连续同步失败暂停阈值', '连续同步失败达到该次数后自动关闭工单并暂停定时同步;同步成功后将清零重新计数;0 表示不因失败自动暂停', 'number', '', '5', '', '', '', NULL SELECT 'split_scrm_antibot_types', 'split', '云控 antiBot 启用类型', '逗号分隔的工单类型标识;启用后走 Node unified 路径(CF 过盾 + real profile)。当前支持:whatshub, chatknow, a2c, huojian', 'string', '', 'whatshub,chatknow,a2c,huojian', '', '', ' placeholder="whatshub,chatknow,a2c,huojian"', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_scrm_antibot_types' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_scrm_session_ttl_minutes', 'split', '云控 antiBot 会话 TTL(分钟)', 'CF 过盾后 sessionKey 缓存有效期;默认 120', 'number', '', '120', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_scrm_session_ttl_minutes' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_fail_pause_threshold', 'split', '连续同步失败暂停阈值', '连续同步失败达到该次数后暂停定时同步;同步成功后将清零重新计数;0 表示不因失败自动暂停', 'number', '', '5', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_pause_threshold' LIMIT 1); FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_pause_threshold' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_fail_retry_minutes', 'split', '失败重试时间点(分钟)', '逗号分隔、升序,相对首次达失败暂停阈值后的绝对分钟数,如 10,20,30,50;留空则达阈值后永久暂停自动同步', 'string', '', '', '', '', ' placeholder="10,20,30,50"', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_retry_minutes' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_interval_a2c', 'split', 'A2C云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL SELECT 'split_sync_interval_a2c', 'split', 'A2C云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_a2c' LIMIT 1); FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_a2c' LIMIT 1);
@@ -58,9 +70,13 @@ SELECT 'split_sync_interval_sihai', 'split', '四海云控同步周期(分钟)',
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_sihai' LIMIT 1); FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_sihai' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_max_per_cron', 'split', '单次定时同步上限', '每分钟 cron 最多处理几条到期工单,防止打满 Node Browser 槽位', 'number', '', '2', '', '', '', NULL SELECT 'split_sync_max_per_cron', 'split', 'Node通道单次同步上限', '每分钟 cron 在 Node 通道最多处理几条到期工单(仅影响需调用 Node 的类型);纯 PHP 直连类型不受此限制', 'number', '', '5', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1); FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_node_candidate_multiplier', 'split', 'Node通道候选池倍数', 'Node 候选工单数 = 单次上限 × 本倍数,用于公平轮转', 'number', '', '10', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_node_candidate_multiplier' LIMIT 1);
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_cron_lock_ttl', 'split', '定时同步全局锁TTL(秒)', '异常退出后自动释放全局锁,避免后续 cron 永久跳过', 'number', '', '1800', '', '', '', NULL SELECT 'split_sync_cron_lock_ttl', 'split', '定时同步全局锁TTL(秒)', '异常退出后自动释放全局锁,避免后续 cron 永久跳过', 'number', '', '1800', '', '', '', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_cron_lock_ttl' LIMIT 1); FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_cron_lock_ttl' LIMIT 1);
@@ -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;
@@ -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`;
@@ -0,0 +1,11 @@
-- 同步失败分阶段重试:系统配置 + 工单状态字段
SET NAMES utf8mb4;
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
SELECT 'split_sync_fail_retry_minutes', 'split', '失败重试时间点(分钟)', '逗号分隔、升序,相对首次达失败暂停阈值后的绝对分钟数,如 10,20,30,50;留空则达阈值后永久暂停自动同步', 'string', '', '', '', '', ' placeholder="10,20,30,50"', NULL
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_retry_minutes' LIMIT 1);
ALTER TABLE `fa_split_ticket`
ADD COLUMN `sync_fail_pause_at` bigint(16) DEFAULT NULL COMMENT '达阈值进入重试的锚点时间' AFTER `sync_fail_count`,
ADD COLUMN `sync_fail_retry_index` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '已完成的重试次数(0=尚未重试)' AFTER `sync_fail_pause_at`,
ADD COLUMN `sync_auto_sync_stopped` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '1=全部重试失败,永久终止自动同步' AFTER `sync_fail_retry_index`;
@@ -20,12 +20,14 @@ CREATE TABLE `fa_split_ticket` (
`account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号', `account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号',
`password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码', `password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码',
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用', `status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
`manual_manage` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '手动管理:0=否,1=是(手动关闭后暂停同步且禁止自动开启)',
`complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)', `complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)',
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)', `inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)',
`speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)', `speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)',
`number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)', `number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)',
`number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)', `number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)',
`number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)', `number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)',
`active_number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '本地开启号码数(status=normal)',
`online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)', `online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)',
`sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态', `sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态',
`sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间', `sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间',
@@ -0,0 +1,21 @@
-- 工单表:本地开启号码数(status=normal),列表直接读字段,避免子查询
SET NAMES utf8mb4;
ALTER TABLE `fa_split_ticket`
ADD COLUMN `active_number_count` int(10) unsigned NOT NULL DEFAULT 0
COMMENT '本地开启号码数(status=normal)' AFTER `number_banned_count`;
-- 历史数据回填
UPDATE `fa_split_ticket` t
SET t.`active_number_count` = (
SELECT COUNT(*)
FROM `fa_split_number` n
WHERE n.`admin_id` = t.`admin_id`
AND n.`split_link_id` = t.`split_link_id`
AND n.`ticket_name` = t.`ticket_name`
AND n.`status` = 'normal'
);
-- 加速按工单统计 status=normal 数量
ALTER TABLE `fa_split_number`
ADD KEY `idx_ticket_active` (`admin_id`, `split_link_id`, `ticket_name`(50), `status`);
@@ -0,0 +1,6 @@
-- 工单手动管理:手动关闭后禁止自动同步与自动开启
SET NAMES utf8mb4;
ALTER TABLE `fa_split_ticket`
ADD COLUMN `manual_manage` tinyint(1) unsigned NOT NULL DEFAULT 0
COMMENT '手动管理:0=否,1=是(手动关闭后暂停同步且禁止自动开启)' AFTER `status`;
@@ -18,6 +18,7 @@ use think\console\Output;
* 用法: * 用法:
* php think split:sync-tickets * php think split:sync-tickets
* php think split:sync-tickets --ticket=12 * php think split:sync-tickets --ticket=12
* php think split:sync-tickets --ticket=12 --mode=auto
*/ */
class SplitSyncTickets extends Command class SplitSyncTickets extends Command
{ {
@@ -25,21 +26,24 @@ class SplitSyncTickets extends Command
{ {
$this->setName('split:sync-tickets') $this->setName('split:sync-tickets')
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '') ->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
->addOption('mode', 'm', Option::VALUE_OPTIONAL, '同步方式: auto=定时自动, manual=手动触发', 'manual')
->setDescription('同步分流工单云控数据'); ->setDescription('同步分流工单云控数据');
} }
protected function execute(Input $input, Output $output): void protected function execute(Input $input, Output $output): void
{ {
set_time_limit(0); set_time_limit(0);
$syncMode = $this->resolveSyncMode($input);
SplitTicketSyncLogger::log('cli', 'command start', [ SplitTicketSyncLogger::log('cli', 'command start', [
'ticketOption' => trim((string) $input->getOption('ticket')), 'ticketOption' => trim((string) $input->getOption('ticket')),
'syncMode' => $syncMode,
'appDebug' => SplitTicketSyncLogger::isEnabled(), 'appDebug' => SplitTicketSyncLogger::isEnabled(),
]); ]);
$service = new SplitTicketSyncService(); $service = new SplitTicketSyncService();
$ticketId = trim((string) $input->getOption('ticket')); $ticketId = trim((string) $input->getOption('ticket'));
if ($ticketId !== '' && ctype_digit($ticketId)) { if ($ticketId !== '' && ctype_digit($ticketId)) {
$result = $service->syncOne((int) $ticketId, true); $result = $service->syncOne((int) $ticketId, true, false, $syncMode);
if (!empty($result['skipped'])) { if (!empty($result['skipped'])) {
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>'); $output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
return; return;
@@ -61,8 +65,8 @@ class SplitSyncTickets extends Command
try { try {
$count = $service->syncDueTickets(); $count = $service->syncDueTickets();
$output->writeln('<info>本次处理工单数: ' . $count . '</info>'); $output->writeln('<info>本次投递工单数: ' . $count . '</info>');
$output->writeln('<comment>汇总日志已写入 runtime/log/split_sync.log</comment>'); $output->writeln('<comment>同步在后台 CLI 进程中执行,汇总日志 runtime/log/split_sync.log</comment>');
} finally { } finally {
$cronLock->release(); $cronLock->release();
} }
@@ -71,4 +75,14 @@ class SplitSyncTickets extends Command
$output->writeln('<comment>详细调试日志已写入 runtime/log/split_sync.log</comment>'); $output->writeln('<comment>详细调试日志已写入 runtime/log/split_sync.log</comment>');
} }
} }
/**
* 解析 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';
}
} }
@@ -7,6 +7,7 @@ namespace app\admin\controller\split;
use app\admin\model\split\Link as LinkModel; use app\admin\model\split\Link as LinkModel;
use app\admin\model\split\Number as NumberModel; use app\admin\model\split\Number as NumberModel;
use app\common\controller\Backend; use app\common\controller\Backend;
use app\common\service\SplitTicketActiveNumberCountService;
use think\Db; use think\Db;
use think\Exception; use think\Exception;
use think\exception\DbException; use think\exception\DbException;
@@ -65,6 +66,7 @@ class Number extends Backend
$this->assignconfig('statusList', $this->model->getStatusList()); $this->assignconfig('statusList', $this->model->getStatusList());
$this->assignconfig('manualManageList', $this->model->getManualManageList()); $this->assignconfig('manualManageList', $this->model->getManualManageList());
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList()); $this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
$this->assignconfig('ratioDeferredList', $this->model->getRatioDeferredList());
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList()); $this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig()); $this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
@@ -224,6 +226,7 @@ class Number extends Backend
$this->error($e->getMessage()); $this->error($e->getMessage());
} }
if ($count > 0) { if ($count > 0) {
(new SplitTicketActiveNumberCountService())->refreshForNumberIds(explode(',', (string) $ids));
$this->success(); $this->success();
} }
$this->error(__('No rows were updated')); $this->error(__('No rows were updated'));
@@ -330,6 +333,12 @@ class Number extends Backend
$this->error($e->getMessage()); $this->error($e->getMessage());
} }
(new SplitTicketActiveNumberCountService())->refreshForTicketKeys(
(int) ($baseRow['admin_id'] ?? $adminId),
$splitLinkId,
(string) ($baseRow['ticket_name'] ?? '')
);
$msg = __('Inserted %d number(s)', $inserted); $msg = __('Inserted %d number(s)', $inserted);
if ($skipped > 0) { if ($skipped > 0) {
$msg .= '' . __('Skipped %d duplicate(s)', $skipped); $msg .= '' . __('Skipped %d duplicate(s)', $skipped);
@@ -377,6 +386,7 @@ class Number extends Backend
} }
$result = false; $result = false;
$before = $row->getData();
Db::startTrans(); Db::startTrans();
try { try {
if ($this->modelValidate) { if ($this->modelValidate) {
@@ -396,9 +406,50 @@ class Number extends Backend
if ($result === false) { if ($result === false) {
$this->error(__('No rows were updated')); $this->error(__('No rows were updated'));
} }
(new SplitTicketActiveNumberCountService())->refreshAfterNumberEdit($before, $row->getData());
$this->success(); $this->success();
} }
/**
* 删除号码后回写工单本地开启号码数
*
* @param string|null $ids
*/
public function del($ids = null)
{
if (false === $this->request->isPost()) {
$this->error(__('Invalid parameters'));
}
$ids = $ids ?: $this->request->post('ids');
if (empty($ids)) {
$this->error(__('Parameter %s can not be empty', 'ids'));
}
$pk = $this->model->getPk();
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$this->model->where($this->dataLimitField, 'in', $adminIds);
}
$list = $this->model->where($pk, 'in', $ids)->select();
$count = 0;
$countService = new SplitTicketActiveNumberCountService();
Db::startTrans();
try {
foreach ($list as $item) {
$count += $item->delete();
}
Db::commit();
} catch (PDOException|Exception $e) {
Db::rollback();
$this->error($e->getMessage());
}
if ($count) {
$countService->refreshFromNumberRows($list);
$this->success();
}
$this->error(__('No rows were deleted'));
}
/** /**
* 批量更新号码状态与手动管理 * 批量更新号码状态与手动管理
*/ */
@@ -443,6 +494,7 @@ class Number extends Backend
if ($count === false || $count === 0) { if ($count === false || $count === 0) {
$this->error(__('No rows were updated')); $this->error(__('No rows were updated'));
} }
(new SplitTicketActiveNumberCountService())->refreshForNumberIds($ids);
$this->success(__('Batch update success')); $this->success(__('Batch update success'));
} }
@@ -495,6 +547,8 @@ class Number extends Backend
} }
$count = 0; $count = 0;
$countService = new SplitTicketActiveNumberCountService();
$keyRows = (clone $query)->field('admin_id,split_link_id,ticket_name')->select();
Db::startTrans(); Db::startTrans();
try { try {
if ($action === 'delete') { if ($action === 'delete') {
@@ -522,9 +576,51 @@ class Number extends Backend
$this->error(__('No matching numbers found')); $this->error(__('No matching numbers found'));
} }
$countService->refreshFromNumberRows($keyRows);
$this->success(sprintf(__('Batch operate success'), $count)); $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));
}
/** /**
* 排除不可由表单提交的字段 * 排除不可由表单提交的字段
* *
@@ -8,8 +8,8 @@ use app\admin\model\split\Link as LinkModel;
use app\admin\model\split\Ticket as TicketModel; use app\admin\model\split\Ticket as TicketModel;
use app\common\controller\Backend; use app\common\controller\Backend;
use app\common\service\SplitTicketRuleService; use app\common\service\SplitTicketRuleService;
use app\common\service\SplitTicketSyncDispatchService;
use app\common\service\SplitTicketSyncLogger; use app\common\service\SplitTicketSyncLogger;
use app\common\service\SplitTicketSyncService;
use think\Db; use think\Db;
use think\Lang; use think\Lang;
use think\Loader; use think\Loader;
@@ -39,7 +39,7 @@ class Ticket extends Backend
protected $modelSceneValidate = true; protected $modelSceneValidate = true;
/** @var string[] 无需鉴权的方法 */ /** @var string[] 无需鉴权的方法 */
protected $noNeedRight = ['script']; protected $noNeedRight = ['script', 'syncpolling'];
/** @var string patches 视图目录 */ /** @var string patches 视图目录 */
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/'; private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/';
@@ -64,6 +64,7 @@ class Ticket extends Backend
'syncBackgroundStartedMsg' => __('Sync background started'), 'syncBackgroundStartedMsg' => __('Sync background started'),
'syncInProgressMsg' => __('Sync in progress'), 'syncInProgressMsg' => __('Sync in progress'),
'syncTicketStartedMsg' => __('Sync ticket started'), 'syncTicketStartedMsg' => __('Sync ticket started'),
'syncDoneMsg' => __('Sync done'),
]); ]);
$this->setupPatchFrontend(); $this->setupPatchFrontend();
@@ -267,6 +268,7 @@ class Ticket extends Backend
$params['inbound_count'], $params['inbound_count'],
$params['speed_per_hour'], $params['speed_per_hour'],
$params['number_count'], $params['number_count'],
$params['active_number_count'],
$params['number_offline_count'], $params['number_offline_count'],
$params['number_banned_count'], $params['number_banned_count'],
$params['online_count'], $params['online_count'],
@@ -276,15 +278,29 @@ class Ticket extends Backend
$params['sync_success_time'], $params['sync_success_time'],
$params['sync_success_mode'], $params['sync_success_mode'],
$params['sync_fail_count'], $params['sync_fail_count'],
$params['sync_fail_pause_at'],
$params['sync_fail_retry_index'],
$params['sync_auto_sync_stopped'],
$params['speed_snapshot_count'], $params['speed_snapshot_count'],
$params['speed_snapshot_time'], $params['speed_snapshot_time'],
$params['click_count'] $params['click_count'],
$params['manual_manage']
); );
return $params; return $params;
} }
/** /**
* 手动同步选中工单 * 用户切换工单状态时写入 manual_manage,防止同步自动恢复开启
*
* @param array<string, mixed> $values
*/
private function applyManualManageForStatusChange(array &$values): void
{
(new SplitTicketRuleService())->applyManualManageForStatusChange($values);
}
/**
* 手动同步选中工单(投递 CLI 后台执行,Web 请求立即返回)
*/ */
public function sync(): void public function sync(): void
{ {
@@ -307,31 +323,84 @@ class Ticket extends Backend
'appDebug' => SplitTicketSyncLogger::isEnabled(), 'appDebug' => SplitTicketSyncLogger::isEnabled(),
]); ]);
$service = new SplitTicketSyncService(); /** @var int[] $allowedIds */
$ok = 0; $allowedIds = [];
$fail = 0; $denied = 0;
$messages = [];
foreach ($list as $row) { foreach ($list as $row) {
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) { if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
$fail++; $denied++;
$messages[] = '#' . $row['id'] . ': 无权限';
continue; continue;
} }
$result = $service->syncOne((int) $row['id'], true); $allowedIds[] = (int) $row['id'];
if ($result['success']) { }
$ok++;
} else { if ($allowedIds === []) {
$fail++; $this->error(__('You have no permission'));
$messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败'); }
// 尽早释放 Session 锁,避免投递等待期间阻塞同账号其它后台请求(如编辑/新增弹窗)
if (function_exists('session_write_close')) {
session_write_close();
}
$dispatch = new SplitTicketSyncDispatchService();
$result = $dispatch->dispatchManual($allowedIds);
$queuedCount = count($result['queued']);
$skippedCount = count($result['skipped']);
$failedCount = count($result['failed']);
if ($queuedCount === 0) {
if ($skippedCount > 0) {
$this->error(__('Sync all skipped busy'));
}
$this->error(__('Sync dispatch failed'));
}
$summary = sprintf(__('Sync dispatch queued'), $queuedCount);
if ($skippedCount > 0) {
$summary .= sprintf(__('Sync dispatch skipped suffix'), $skippedCount);
}
if ($failedCount > 0) {
$summary .= sprintf(__('Sync dispatch failed suffix'), $failedCount);
}
if ($denied > 0) {
$summary .= sprintf(__('Sync dispatch denied suffix'), $denied);
}
$this->success($summary, null, [
'queued' => $result['queued'],
'syncing' => $result['queued'],
]);
}
/**
* 轮询手工同步进度(依据 runtime 文件锁)
*/
public function syncpolling(): void
{
$idsRaw = trim((string) $this->request->request('ids', ''));
if ($idsRaw === '') {
$this->success('', null, ['syncing' => []]);
}
/** @var int[] $ticketIds */
$ticketIds = [];
foreach (explode(',', $idsRaw) as $part) {
$part = trim($part);
if ($part !== '' && ctype_digit($part)) {
$ticketIds[] = (int) $part;
} }
} }
$summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail); if ($ticketIds === []) {
if ($fail > 0) { $this->success('', null, ['syncing' => []]);
$summary .= '' . implode('', array_slice($messages, 0, 5));
} }
$this->success($summary);
$dispatch = new SplitTicketSyncDispatchService();
$syncing = $dispatch->filterSyncingIds($ticketIds);
$this->success('', null, ['syncing' => $syncing]);
} }
/** /**
@@ -354,6 +423,7 @@ class Ticket extends Backend
$ruleService = new SplitTicketRuleService(); $ruleService = new SplitTicketRuleService();
if (isset($values['status'])) { if (isset($values['status'])) {
$this->applyManualManageForStatusChange($values);
$pk = $this->model->getPk(); $pk = $this->model->getPk();
$adminIds = $this->getDataLimitAdminIds(); $adminIds = $this->getDataLimitAdminIds();
$query = $this->model->where($pk, 'in', $ids); $query = $this->model->where($pk, 'in', $ids);
@@ -528,6 +598,9 @@ class Ticket extends Backend
$params['number_type_custom'] = ''; $params['number_type_custom'] = '';
} }
$oldStatus = (string) ($row['status'] ?? 'hidden'); $oldStatus = (string) ($row['status'] ?? 'hidden');
if (isset($params['status']) && (string) $params['status'] !== $oldStatus) {
$this->applyManualManageForStatusChange($params);
}
$result = false; $result = false;
Db::startTrans(); Db::startTrans();
try { try {
@@ -51,4 +51,20 @@ return [
'Invalid manual manage' => '手动管理选项无效', 'Invalid manual manage' => '手动管理选项无效',
'Batch update success' => '批量更新成功', 'Batch update success' => '批量更新成功',
'Unit count' => '个', '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' => '当前没有需要清理的无效降权记录',
'Stream loading' => '加载中...',
'Stream loaded' => '已加载 %loaded% / %total%,继续滚动加载更多',
'Stream loading bottom' => '加载中...',
'Stream loading pending' => '即将加载更多,可切换显示条数取消...',
]; ];
@@ -31,6 +31,12 @@ return [
'Sync in progress' => '同步中', 'Sync in progress' => '同步中',
'Sync ticket started' => '正在同步工单', 'Sync ticket started' => '正在同步工单',
'Sync done' => '同步完成', 'Sync done' => '同步完成',
'Sync dispatch queued' => '已提交 %d 条同步任务到后台执行',
'Sync dispatch skipped suffix' => '%d 条已在同步中已跳过',
'Sync dispatch failed suffix' => '%d 条启动失败',
'Sync dispatch denied suffix' => '%d 条无权限未提交',
'Sync all skipped busy' => '所选工单均在同步中,请稍后再试',
'Sync dispatch failed' => '未能启动同步任务,请检查服务器是否允许后台执行 CLIexec/shell_exec',
'Sync status success' => '同步成功', 'Sync status success' => '同步成功',
'Sync status error' => '同步异常', 'Sync status error' => '同步异常',
'Sync status pending' => '待同步', 'Sync status pending' => '待同步',
@@ -38,10 +44,14 @@ return [
'Sync display pending' => '待同步', 'Sync display pending' => '待同步',
'Sync display error' => '同步异常', 'Sync display error' => '同步异常',
'Sync display auto paused' => '自动同步已暂停', 'Sync display auto paused' => '自动同步已暂停',
'Sync display auto sync stopped' => '自动同步已终止(重试已用尽)',
'Sync display retry waiting' => '自动同步已暂停,将于 %s 第 %d 次重试',
'Sync success time' => '最近同步成功', 'Sync success time' => '最近同步成功',
'Sync mode auto' => '自动', 'Sync mode auto' => '自动',
'Sync mode manual' => '手动', 'Sync mode manual' => '手动',
'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停', 'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停',
'Sync tooltip auto sync stopped' => '分阶段重试已全部失败,自动同步已永久终止;手动同步成功后可恢复',
'Sync tooltip retry waiting' => '连续失败 %s 次,将于 %s 进行第 %d 次自动重试',
'Sync tooltip error prefix' => '异常原因:', 'Sync tooltip error prefix' => '异常原因:',
'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log', 'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log',
'Createtime' => '创建时间', 'Createtime' => '创建时间',
@@ -42,6 +42,7 @@ class Number extends Model
'status_text', 'status_text',
'manual_manage_text', 'manual_manage_text',
'platform_status_text', 'platform_status_text',
'ratio_deferred_text',
]; ];
/** /**
@@ -81,6 +82,17 @@ class Number extends Model
]; ];
} }
/**
* @return array<string, string>
*/
public function getRatioDeferredList(): array
{
return [
'0' => __('Ratio deferred no'),
'1' => __('Ratio deferred yes'),
];
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
@@ -158,6 +170,13 @@ class Number extends Model
return $list[$key] ?? $key; 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 * 根据链接码生成完整分流 URL
*/ */
@@ -6,6 +6,7 @@ namespace app\admin\model\split;
use app\common\service\SplitSyncConfigService; use app\common\service\SplitSyncConfigService;
use app\common\service\SplitTicketRuleService; use app\common\service\SplitTicketRuleService;
use app\common\service\SplitTicketSyncFailRetryService;
use app\common\service\SplitTicketSyncLockService; use app\common\service\SplitTicketSyncLockService;
use think\Db; use think\Db;
use think\Model; use think\Model;
@@ -19,7 +20,7 @@ class Ticket extends Model
protected static function init(): void protected static function init(): void
{ {
// 删除工单时:释放同步锁,并物理删除同步导入的号码(manual_manage=0 // 删除工单时:释放同步锁,并物理删除该工单关联的全部号码
self::beforeDelete(function (self $ticket): void { self::beforeDelete(function (self $ticket): void {
(new SplitTicketSyncLockService())->release((int) $ticket['id']); (new SplitTicketSyncLockService())->release((int) $ticket['id']);
(new SplitTicketRuleService())->deleteSyncedNumbersForTicket($ticket); (new SplitTicketRuleService())->deleteSyncedNumbersForTicket($ticket);
@@ -176,10 +177,23 @@ class Ticket extends Model
if ((string) ($data['status'] ?? '') !== 'normal') { if ((string) ($data['status'] ?? '') !== 'normal') {
return false; return false;
} }
$retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
return true;
}
if ($retryService->isInRetryMode($data)) {
return true;
}
$threshold = SplitSyncConfigService::getFailPauseThreshold(); $threshold = SplitSyncConfigService::getFailPauseThreshold();
if ($threshold <= 0) { if ($threshold <= 0) {
return false; return false;
} }
if (SplitSyncConfigService::hasFailRetrySchedule()) {
return false;
}
return (int) ($data['sync_fail_count'] ?? 0) >= $threshold; return (int) ($data['sync_fail_count'] ?? 0) >= $threshold;
} }
@@ -191,9 +205,26 @@ class Ticket extends Model
*/ */
public function getSyncDisplayTextAttr($value, $data): string public function getSyncDisplayTextAttr($value, $data): string
{ {
$retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
return (string) __('Sync display auto sync stopped');
}
if ($retryService->isInRetryMode($data)) {
$nextAt = $retryService->getNextRetryAt($data);
$attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1;
if ($nextAt !== null) {
return sprintf(
(string) __('Sync display retry waiting'),
date('H:i', $nextAt),
$attempt
);
}
}
if (self::isAutoSyncPaused($data)) { if (self::isAutoSyncPaused($data)) {
$threshold = SplitSyncConfigService::getFailPauseThreshold(); return (string) __('Sync display auto paused');
return sprintf((string) __('Sync display auto paused'), $threshold);
} }
$status = (string) ($data['sync_status'] ?? 'pending'); $status = (string) ($data['sync_status'] ?? 'pending');
@@ -216,7 +247,22 @@ class Ticket extends Model
public function getSyncTooltipTextAttr($value, $data): string public function getSyncTooltipTextAttr($value, $data): string
{ {
$parts = []; $parts = [];
if (self::isAutoSyncPaused($data)) { $retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
$parts[] = (string) __('Sync tooltip auto sync stopped');
} elseif ($retryService->isInRetryMode($data)) {
$nextAt = $retryService->getNextRetryAt($data);
$attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1;
if ($nextAt !== null) {
$parts[] = sprintf(
(string) __('Sync tooltip retry waiting'),
(int) ($data['sync_fail_count'] ?? 0),
date('Y-m-d H:i:s', $nextAt),
$attempt
);
}
} elseif (self::isAutoSyncPaused($data)) {
$threshold = SplitSyncConfigService::getFailPauseThreshold(); $threshold = SplitSyncConfigService::getFailPauseThreshold();
$parts[] = sprintf( $parts[] = sprintf(
(string) __('Sync tooltip auto paused'), (string) __('Sync tooltip auto paused'),
@@ -123,6 +123,13 @@
<div class="form-group"> <div class="form-group">
{if isset($row)} {if isset($row)}
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label> <label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
{if isset($row.ratio_deferred) && $row.ratio_deferred==1 && isset($row.status) && $row.status=='normal'}
<div class="alert alert-warning split-ratio-deferred-notice" style="margin-bottom:10px;padding:8px 12px;font-size:12px;">
<i class="fa fa-level-down"></i>
{:__('Ratio deferred edit notice')}
{:__('No inbound click streak')}{$row.no_inbound_click_streak|default=0}
</div>
{/if}
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50"> <input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
{else} {else}
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label> <label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
@@ -18,6 +18,8 @@
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a> <a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a> <a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a> <a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
<a href="javascript:;" class="btn btn-default btn-filter-ratio-deferred" title="{:__('Ratio deferred filter')}"><i class="fa fa-level-down text-warning"></i> {:__('Ratio deferred badge')}</a>
<a href="javascript:;" class="btn btn-warning btn-cleanup-ratio-deferred {:$auth->check('split.number/cleanupdeferred')?'':'hide'}" title="{:__('Ratio deferred cleanup btn')}"><i class="fa fa-eraser"></i> {:__('Ratio deferred cleanup btn')}</a>
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a> <a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
</div> </div>
<table id="table" class="table table-striped table-bordered table-hover table-nowrap" <table id="table" class="table table-striped table-bordered table-hover table-nowrap"
+175 -15
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\common\library\scrm; namespace app\common\library\scrm;
use app\common\service\SplitPageUrlResolver;
use think\Config; use think\Config;
/** /**
@@ -26,6 +27,14 @@ class AntiBotConfigBuilder
return null; return null;
} }
if ($ticketType === 'a2c' && $landingUrlHint === '') {
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
}
if ($ticketType === 'whatshub' && $landingUrlHint === '') {
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
}
$sessionScope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint); $sessionScope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint);
if ($sessionScope === '') { if ($sessionScope === '') {
return null; return null;
@@ -47,16 +56,22 @@ class AntiBotConfigBuilder
'sessionTtlMinutes' => $ttlMinutes, 'sessionTtlMinutes' => $ttlMinutes,
'businessHostHint' => $businessHostHint, 'businessHostHint' => $businessHostHint,
'landingUrlHint' => $landingUrlHint !== '' ? $landingUrlHint : '', 'landingUrlHint' => $landingUrlHint !== '' ? $landingUrlHint : '',
'cfCloudflareSolver' => self::defaultCfCloudflareSolver($ticketType),
'cfChallengeMode' => self::defaultCfChallengeMode($ticketType),
] + self::postCfReadyExtras($ticketType); ] + self::postCfReadyExtras($ticketType);
} }
/** /**
* 会话 scopeA2C 短链固定业务域;火箭长链用动态 host、短链用 share.{token} * 会话 scopeA2C 业务域+分享 idWhatshub host+shareCode/workId;火箭长链用动态 host
*/ */
public static function resolveSessionScope(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string public static function resolveSessionScope(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string
{ {
if ($ticketType === 'a2c') { if ($ticketType === 'a2c') {
return self::resolveA2cSessionHost($pageUrl, $landingUrlHint); return self::resolveA2cSessionScope($pageUrl, $landingUrlHint);
}
if ($ticketType === 'whatshub') {
return self::resolveWhatshubSessionScope($pageUrl, $landingUrlHint);
} }
if ($ticketType === 'huojian') { if ($ticketType === 'huojian') {
@@ -81,6 +96,14 @@ class AntiBotConfigBuilder
*/ */
public static function resolveSessionKey(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string public static function resolveSessionKey(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string
{ {
if ($ticketType === 'a2c' && $landingUrlHint === '') {
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
}
if ($ticketType === 'whatshub' && $landingUrlHint === '') {
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
}
$scope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint); $scope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint);
return $ticketType . ':' . ($scope !== '' ? $scope : 'unknown'); return $ticketType . ':' . ($scope !== '' ? $scope : 'unknown');
@@ -124,21 +147,27 @@ class AntiBotConfigBuilder
{ {
if ($ticketType === 'whatshub') { if ($ticketType === 'whatshub') {
return [ return [
'postCfReadyUrlContains' => 'work-order-sharing', 'postCfReadyUrlContains' => 'work-order-sharing',
'postCfReadySelector' => '.el-input__inner', // 业务页表格就绪即可;密码框为 vxe-form,勿用泛化 .el-input__inner
'postCfReadyTimeoutMs' => 30000, 'postCfReadySelector' => '.vxe-table, .vxe-grid',
'postCfSettleMs' => 500, 'postCfReadySelectorOptional' => true,
'postCfSpaWaitMs' => 4000, // statistics 仅在密码验证后触发,禁止 pre-auth 等待(见 Node skipPostCfReadyApi
'postCfReadyTimeoutMs' => 60000,
'postCfSettleMs' => 500,
'postCfSpaWaitMs' => 4000,
'skipShortLinkCfPurgeOnReuse' => true,
'authSkipIfNoPasswordDialog' => true,
]; ];
} }
if ($ticketType === 'a2c') { if ($ticketType === 'a2c') {
return [ return [
'postCfReadyUrlContains' => '/visitors/counter/share', 'postCfReadyUrlContains' => '/visitors/counter/share',
'postCfReadySelector' => '.btn-next', 'postCfReadySelector' => '.el-table',
'postCfReadyTimeoutMs' => 30000, 'postCfReadyApiUrl' => '/api/talk/counter/share/record/list',
'postCfReadyTimeoutMs' => 60000,
'postCfSettleMs' => 500, 'postCfSettleMs' => 500,
'postCfSpaWaitMs' => 4000, 'postCfSpaWaitMs' => 8000,
]; ];
} }
@@ -162,20 +191,36 @@ class AntiBotConfigBuilder
private static function defaultSolverFallback(string $ticketType): bool private static function defaultSolverFallback(string $ticketType): bool
{ {
if ($ticketType === 'a2c') { return true;
}
private static function defaultCfClearanceRequired(string $ticketType): bool
{
if ($ticketType === 'a2c' || $ticketType === 'whatshub') {
return false; return false;
} }
return true; return true;
} }
private static function defaultCfClearanceRequired(string $ticketType): bool /**
* A2C / Whatshub 等为 CF Managed Challenge5s 盾),非独立 Turnstile 挂件
*/
private static function defaultCfChallengeMode(string $ticketType): string
{ {
if ($ticketType === 'a2c') { if ($ticketType === 'a2c' || $ticketType === 'whatshub') {
return false; return 'managed';
} }
return true; return '';
}
/**
* sitekey 缺失时是否允许 CapSolver AntiCloudflareTask(需 Node 侧 CAPTCHA_PROXY
*/
private static function defaultCfCloudflareSolver(string $ticketType): bool
{
return $ticketType === 'a2c' || $ticketType === 'whatshub';
} }
/** /**
@@ -205,6 +250,69 @@ class AntiBotConfigBuilder
return ''; return '';
} }
/**
* Whatshub 会话 scopehost + shareCode 或 workId,避免多工单共用温池 Browser
*/
private static function resolveWhatshubSessionScope(string $pageUrl, string $landingUrlHint = ''): string
{
$host = strtolower(trim((string) parse_url($pageUrl, PHP_URL_HOST)));
if ($host === '' && $landingUrlHint !== '') {
$host = strtolower(trim((string) parse_url($landingUrlHint, PHP_URL_HOST)));
}
if ($host === '') {
return '';
}
$shareCode = self::extractWhatshubShareCode($pageUrl);
if ($shareCode === '' && $landingUrlHint !== '') {
$shareCode = self::extractWhatshubShareCode($landingUrlHint);
}
if ($shareCode !== '') {
return $host . ':' . $shareCode;
}
$workId = self::extractWhatshubWorkId($pageUrl);
if ($workId === '' && $landingUrlHint !== '') {
$workId = self::extractWhatshubWorkId($landingUrlHint);
}
if ($workId !== '') {
return $host . ':' . $workId;
}
return $host;
}
/**
* 从 Whatshub 短链 /m/{shareCode}/ 提取 shareCode
*/
private static function extractWhatshubShareCode(string $url): string
{
$path = (string) parse_url($url, PHP_URL_PATH);
if ($path === '') {
return '';
}
if (preg_match('#/m/([^/]+)/#', $path, $matches)) {
return trim($matches[1]);
}
return '';
}
/**
* 从 Whatshub 长链 work-order-sharing?workId= 提取 workId
*/
private static function extractWhatshubWorkId(string $url): string
{
$query = (string) parse_url($url, PHP_URL_QUERY);
if ($query === '') {
return '';
}
parse_str($query, $params);
$workId = isset($params['workId']) ? trim((string) $params['workId']) : '';
return $workId !== '' ? $workId : '';
}
private static function resolveA2cSessionHost(string $pageUrl, string $landingUrlHint = ''): string private static function resolveA2cSessionHost(string $pageUrl, string $landingUrlHint = ''): string
{ {
$candidates = []; $candidates = [];
@@ -223,6 +331,58 @@ class AntiBotConfigBuilder
return 'user.a2c.chat'; return 'user.a2c.chat';
} }
/**
* A2C 会话 scope:业务域 + 分享 id(避免多工单共用温池 Browser 互相干扰)
*
* 短链先 HTTP 预解析再提取 ?id=,确保不同分享页不会落到同一 sessionKey。
*/
private static function resolveA2cSessionScope(string $pageUrl, string $landingUrlHint = ''): string
{
$resolvedUrl = self::resolveA2cResolvedUrl($pageUrl, $landingUrlHint);
$host = self::resolveA2cSessionHost($pageUrl, $resolvedUrl);
$shareId = self::extractA2cShareId($pageUrl);
if ($shareId === '') {
$shareId = self::extractA2cShareId($resolvedUrl);
}
return $shareId !== '' ? $host . ':' . $shareId : $host;
}
/**
* A2C 短链 HTTP 预解析:优先已有 landingUrlHint,否则跟随重定向得到长链
*/
private static function resolveA2cResolvedUrl(string $pageUrl, string $landingUrlHint = ''): string
{
if ($landingUrlHint !== '' && self::extractA2cShareId($landingUrlHint) !== '') {
return $landingUrlHint;
}
if (self::extractA2cShareId($pageUrl) !== '') {
return $pageUrl;
}
$resolved = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
if ($resolved !== '') {
return $resolved;
}
return $landingUrlHint !== '' ? $landingUrlHint : $pageUrl;
}
/**
* 从 A2C 分享 URL 提取 id 查询参数
*/
private static function extractA2cShareId(string $url): string
{
$query = (string) parse_url($url, PHP_URL_QUERY);
if ($query === '') {
return '';
}
parse_str($query, $params);
$id = isset($params['id']) ? trim((string) $params['id']) : '';
return $id !== '' ? $id : '';
}
private static function resolveHuojianSessionScope(string $pageUrl, string $landingUrlHint = ''): string private static function resolveHuojianSessionScope(string $pageUrl, string $landingUrlHint = ''): string
{ {
$scope = HuojianUrlHelper::resolveSessionScope($pageUrl); $scope = HuojianUrlHelper::resolveSessionScope($pageUrl);
View File
@@ -7,11 +7,12 @@ namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider; use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\AntiBotConfigBuilder; use app\common\library\scrm\AntiBotConfigBuilder;
use app\common\library\scrm\UnifiedScrmData; use app\common\library\scrm\UnifiedScrmData;
use app\common\service\SplitPageUrlResolver;
/** /**
* A2C 云控蜘蛛(Real Browser 打开分享页 + 拦截加密 API + UI 翻页) * A2C 云控蜘蛛(Real Browser 打开分享页 + 拦截加密 API + UI 翻页)
* *
* 业务域 user.a2c.chat 无需 cf_clearance;短链 yyk.ink 仍由浏览器 follow 跳转 * 业务域 user.a2c.chat 在 CF 挑战页启用 CapSolver 兜底;短链 yyk.ink 预解析为长链后直达业务页
*/ */
class A2cSpider extends AbstractScrmSpider class A2cSpider extends AbstractScrmSpider
{ {
@@ -27,6 +28,9 @@ class A2cSpider extends AbstractScrmSpider
private string $password; private string $password;
/** @var string HTTP 预解析落地 URL(供 antiBot landingUrlHint */
private string $landingUrlHint = '';
private UnifiedScrmData $unifiedData; private UnifiedScrmData $unifiedData;
public function __construct( public function __construct(
@@ -39,6 +43,7 @@ class A2cSpider extends AbstractScrmSpider
$this->pageUrl = $pageUrl; $this->pageUrl = $pageUrl;
$this->account = $account; $this->account = $account;
$this->password = $password; $this->password = $password;
$this->landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
$this->unifiedData = new UnifiedScrmData(); $this->unifiedData = new UnifiedScrmData();
} }
@@ -53,7 +58,7 @@ class A2cSpider extends AbstractScrmSpider
'authActions' => [ 'authActions' => [
['type' => 'wait', 'ms' => 2000], ['type' => 'wait', 'ms' => 2000],
], ],
'antiBot' => AntiBotConfigBuilder::build('a2c', $this->pageUrl), 'antiBot' => AntiBotConfigBuilder::build('a2c', $this->pageUrl, $this->landingUrlHint),
]; ];
} }
@@ -12,7 +12,7 @@ use app\common\service\SplitTicketSyncLogger;
/** /**
* Whatshub 云控蜘蛛 * Whatshub 云控蜘蛛
* *
* 时序:detailByShareCode API 直抓(优先)→ 失败则 Real Browser + Turnstile + UI 翻页 * 时序:cURL detailByShareCode 拉号码 → Node 监听 statistics 算完成量 → 失败则完整 Node 爬虫
*/ */
class WhatshubSpider extends AbstractScrmSpider class WhatshubSpider extends AbstractScrmSpider
{ {
@@ -52,25 +52,140 @@ class WhatshubSpider extends AbstractScrmSpider
} }
/** /**
* API 优先:detailByShareCode 直抓;失败则回退 Node 爬虫 * 混合同步:cURL 拉号码 + Node 监听 statistics 算完成量
* statistics 失败时清 session 重试;仍失败则 fallback 完整 Node 仅取完成量
*/ */
public function run(): UnifiedScrmData public function run(): UnifiedScrmData
{ {
$apiResult = $this->tryFetchViaShareCodeApi(); $apiResult = $this->tryFetchViaShareCodeApi();
if ($apiResult instanceof UnifiedScrmData) { $statsCount = $this->tryFetchStatisticsViaNode(false);
SplitTicketSyncLogger::log('spider', 'whatshub api path ok', [
'count' => count($apiResult->numbers), if ($apiResult instanceof UnifiedScrmData && $statsCount !== null) {
$apiResult->todayNewCount = $statsCount;
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok', [
'numberCount' => count($apiResult->numbers),
'todayNewCount' => $statsCount,
]); ]);
return $apiResult; return $apiResult;
} }
SplitTicketSyncLogger::log('spider', 'whatshub api skipped or failed, fallback node', [ // cURL 已成功:清 session 再试 statistics;仍失败则完整 Node 只取完成量
'pageUrl' => $this->pageUrl, if ($apiResult instanceof UnifiedScrmData) {
SplitTicketSyncLogger::log('spider', 'whatshub stats retry after purge session', [
'numberCount' => count($apiResult->numbers),
]);
$statsCount = $this->tryFetchStatisticsViaNode(true);
if ($statsCount !== null) {
$apiResult->todayNewCount = $statsCount;
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok (retry)', [
'numberCount' => count($apiResult->numbers),
'todayNewCount' => $statsCount,
]);
return $apiResult;
}
SplitTicketSyncLogger::log('spider', 'whatshub stats fallback full node for complete count', [
'pageUrl' => $this->pageUrl,
'numberCount' => count($apiResult->numbers),
]);
try {
$fullResult = parent::run();
$apiResult->todayNewCount = $fullResult->todayNewCount;
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok (full node stats)', [
'numberCount' => count($apiResult->numbers),
'todayNewCount' => $apiResult->todayNewCount,
]);
return $apiResult;
} catch (\Throwable $e) {
SplitTicketSyncLogger::log('spider', 'whatshub stats fallback full node failed', [
'error' => $e->getMessage(),
]);
}
throw new \RuntimeException('Whatshub statistics 同步失败(号码已通过 API 获取,无法更新完成量)');
}
SplitTicketSyncLogger::log('spider', 'whatshub hybrid incomplete, fallback full node', [
'pageUrl' => $this->pageUrl,
'apiOk' => false,
'statisticsOk' => $statsCount !== null,
]); ]);
return parent::run(); return parent::run();
} }
/** Whatshub vxe 密码表单内 input / 确认按钮(避免误填业务页搜索框) */
private const AUTH_PASSWORD_INPUT = '.vxe-form .el-input__inner';
private const AUTH_CONFIRM_BUTTON = '.vxe-form button.theme--primary[type="submit"]';
/**
* Node 监听 statistics API,返回 dayNewFans + reDayNewFans 总和;失败返回 null
*
* @param bool $isRetry 是否为清 session 后的二次尝试
*/
private function tryFetchStatisticsViaNode(bool $isRetry = false): ?int
{
$config = $this->getSpiderConfig();
$antiBot = $config['antiBot'] ?? null;
if (is_array($antiBot)) {
// statistics-only:早挂拦截器、识别 vxe 密码框、session 有效时 skip auth
$antiBot = array_merge($antiBot, [
'skipPostCfReadyApi' => true,
'authSkipIfNoPasswordDialog' => true,
'authSelectorTimeoutMs' => 30000,
'earlyApiIntercept' => true,
]);
if ($isRetry) {
$antiBot['purgeSession'] = true;
}
}
SplitTicketSyncLogger::log('spider', 'whatshub stats node request', [
'pageUrl' => $this->pageUrl,
'api' => self::API_DETAILS,
'isRetry' => $isRetry,
]);
$result = $this->requestNode('/api/auth-and-intercept', [
'pageUrl' => $config['pageUrl'],
'apiUrls' => [self::API_DETAILS],
'authActions' => $config['authActions'] ?? [],
'antiBot' => $antiBot,
], $this->resolveAuthInterceptTimeout(is_array($antiBot) ? $antiBot : null));
if (empty($result['success'])) {
SplitTicketSyncLogger::log('spider', 'whatshub stats node failed', [
'error' => $result['error'] ?? '未知',
'code' => $result['code'] ?? null,
'page' => $result['page'] ?? null,
]);
return null;
}
$intercepted = $result['interceptedApis'] ?? [];
if (!isset($intercepted[self::API_DETAILS]['data']) || !is_array($intercepted[self::API_DETAILS]['data'])) {
SplitTicketSyncLogger::log('spider', 'whatshub stats node missing payload', [
'intercepted' => array_keys(is_array($intercepted) ? $intercepted : []),
'page' => $result['page'] ?? null,
]);
return null;
}
$detailData = $intercepted[self::API_DETAILS]['data'];
$dayNewFans = (int) ($detailData['data']['dayNewFans'] ?? 0);
$reDayNewFans = (int) ($detailData['data']['reDayNewFans'] ?? 0);
$total = $dayNewFans + $reDayNewFans;
SplitTicketSyncLogger::log('spider', 'whatshub stats node ok', [
'dayNewFans' => $dayNewFans,
'reDayNewFans' => $reDayNewFans,
'total' => $total,
]);
return $total;
}
/** /**
* 从短链 pageUrl 解析 shareCode,例如 /m/KMYBBInp2066/1 → KMYBBInp2066 * 从短链 pageUrl 解析 shareCode,例如 /m/KMYBBInp2066/1 → KMYBBInp2066
*/ */
@@ -202,7 +317,6 @@ class WhatshubSpider extends AbstractScrmSpider
private function parseShareCodeApiResponse(array $payload): UnifiedScrmData private function parseShareCodeApiResponse(array $payload): UnifiedScrmData
{ {
$unifiedData = new UnifiedScrmData(); $unifiedData = new UnifiedScrmData();
$todayNewSum = 0;
foreach ($payload['data'] as $item) { foreach ($payload['data'] as $item) {
if (!is_array($item) || empty($item['account'])) { if (!is_array($item) || empty($item['account'])) {
@@ -213,11 +327,10 @@ class WhatshubSpider extends AbstractScrmSpider
$isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1; $isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1;
$dayNewFans = (int) ($item['dayNewFans'] ?? 0); $dayNewFans = (int) ($item['dayNewFans'] ?? 0);
// 号码同步仅使用 dayNewFans,不含 reDayNewFans;完成量由 Node statistics 提供
$unifiedData->addNumber($number, $isOnline, $dayNewFans); $unifiedData->addNumber($number, $isOnline, $dayNewFans);
$todayNewSum += $dayNewFans;
} }
$unifiedData->todayNewCount = $todayNewSum;
$unifiedData->total = count($unifiedData->numbers); $unifiedData->total = count($unifiedData->numbers);
return $unifiedData; return $unifiedData;
@@ -233,10 +346,10 @@ class WhatshubSpider extends AbstractScrmSpider
'listMethod' => 'POST', 'listMethod' => 'POST',
'paginationMode' => self::MODE_UI, 'paginationMode' => self::MODE_UI,
'authActions' => [ 'authActions' => [
// Element Plus 密码弹窗:仅注入可见 input // Whatshub 密码 UI 为 vxe-form + Element input,限定在表单内避免误填搜索框
['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password], ['type' => 'vue_fill', 'selector' => self::AUTH_PASSWORD_INPUT, 'value' => $this->password],
['type' => 'wait', 'ms' => 500], ['type' => 'wait', 'ms' => 500],
['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'], ['type' => 'vue_click', 'selector' => self::AUTH_CONFIRM_BUTTON],
['type' => 'wait', 'ms' => 2200], ['type' => 'wait', 'ms' => 2200],
], ],
'antiBot' => AntiBotConfigBuilder::build('whatshub', $this->pageUrl), 'antiBot' => AntiBotConfigBuilder::build('whatshub', $this->pageUrl),
@@ -279,7 +392,7 @@ class WhatshubSpider extends AbstractScrmSpider
$unifiedData = $this->unifiedData; $unifiedData = $this->unifiedData;
if (is_array($detailData)) { if (is_array($detailData)) {
$unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0); $unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0) + (int) ($detailData['data']['reDayNewFans'] ?? 0);
} }
foreach ($allListPagesData as $pageRaw) { foreach ($allListPagesData as $pageRaw) {
@@ -37,9 +37,25 @@ class SplitNodeHealthService
/** /**
* 当前是否适合向 Node 提交新 Browser 任务 * 当前是否适合向 Node 提交新 Browser 任务
*
* A2C 等 antiBot 任务走 real profile,应参考 queueReal 而非 standard 队列。
*/ */
public static function canAcceptWork(): bool public static function canAcceptWork(): bool
{ {
return self::canAcceptWorkForTicketType('');
}
/**
* 指定工单类型是否可向 Node 提交新任务
*
* API 优先类型(如 Whatshub)常态不占 Real Browser,调度层应跳过队列门禁。
*/
public static function canAcceptWorkForTicketType(string $ticketType): bool
{
if ($ticketType !== '' && SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) {
return true;
}
$stats = self::fetchStats(); $stats = self::fetchStats();
if ($stats === null) { if ($stats === null) {
return true; return true;
@@ -47,33 +63,61 @@ class SplitNodeHealthService
if (!empty($stats['shuttingDown'])) { if (!empty($stats['shuttingDown'])) {
return false; return false;
} }
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
$config = is_array($stats['config'] ?? null) ? $stats['config'] : []; $snapshot = self::resolveQueueSnapshot($stats);
$queued = (int) ($queue['queued'] ?? 0);
$active = (int) ($queue['active'] ?? 0);
$max = max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4)));
$threshold = SplitSyncConfigService::getNodeQueueSkipThreshold(); $threshold = SplitSyncConfigService::getNodeQueueSkipThreshold();
if ($queued >= $threshold) { if ($snapshot['queued'] >= $threshold) {
return false; return false;
} }
return $active < $max;
return $snapshot['active'] < $snapshot['max'];
} }
/** /**
* @return array{queued:int,active:int,max:int} * @return array{queued:int,active:int,max:int,profile:string}
*/ */
public static function getQueueSnapshot(): array public static function getQueueSnapshot(): array
{ {
$stats = self::fetchStats(); $stats = self::fetchStats();
if ($stats === null) { if ($stats === null) {
return ['queued' => 0, 'active' => 0, 'max' => 0]; return ['queued' => 0, 'active' => 0, 'max' => 0, 'profile' => 'real'];
} }
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
$config = is_array($stats['config'] ?? null) ? $stats['config'] : []; $snapshot = self::resolveQueueSnapshot($stats);
return [ return [
'queued' => (int) ($queue['queued'] ?? 0), 'queued' => $snapshot['queued'],
'active' => (int) ($queue['active'] ?? 0), 'active' => $snapshot['active'],
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))), 'max' => $snapshot['max'],
'profile' => $snapshot['profile'],
];
}
/**
* 优先使用 real 队列(antiBot / A2C),无数据时回退 standard
*
* @param array<string, mixed> $stats
* @return array{queued:int,active:int,max:int,profile:string}
*/
private static function resolveQueueSnapshot(array $stats): array
{
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
$queueReal = is_array($stats['queueReal'] ?? null) ? $stats['queueReal'] : [];
if ($queueReal !== []) {
return [
'queued' => (int) ($queueReal['queued'] ?? 0),
'active' => (int) ($queueReal['active'] ?? 0),
'max' => max(1, (int) ($config['maxConcurrentBrowsersReal'] ?? ($queueReal['max'] ?? 2))),
'profile' => 'real',
];
}
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
return [
'queued' => (int) ($queue['queued'] ?? 0),
'active' => (int) ($queue['active'] ?? 0),
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))),
'profile' => 'standard',
]; ];
} }
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Number;
use think\Db;
/**
* 下号比率触线降权(ratio_deferred)规则与降权池清理
*
* 降权前提:已有落地页访问(visit_count > last_sync_visit_count),
* 且自上次同步以来进线未增长时累计 streak,触线才标记降权。
*/
class SplitNumberRatioDeferredService
{
/**
* 是否具备计入 streak 的落地页访问基数
*
* @param array<string, mixed>|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<string, mixed>|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
*
* 访问侧(recordVisitAfterRedirect)已按次 +1 累计 streak,此处不得再叠加 delta,否则首次同步会双倍计次导致提前关号。
* 取 max(访问侧 streak, 自上次同步以来的点击增量) 作为裁决值,并兜底访问钩子未触发的场景。
*
* @param array<string, mixed>|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) {
$deltaSinceSync = $visitCount - $lastVisit;
return max($currentStreak, $deltaSinceSync);
}
return $currentStreak;
}
/**
* 是否应标记 ratio_deferred(访问侧触线降权,不关 status)
*/
public function shouldMarkDeferred(int $streak, int $assignRatio, bool $hasLandingBaseline): bool
{
return $hasLandingBaseline && $assignRatio > 0 && $streak >= $assignRatio;
}
/**
* 是否为应清理的无效降权记录
*
* @param array<string, mixed>|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<string, mixed>|Number $number
*/
private function readInt($number, string $field): int
{
if (is_array($number)) {
return (int) ($number[$field] ?? 0);
}
return (int) $number->getAttr($field);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Number;
use app\admin\model\split\Ticket;
/**
* 落地页访问侧下号比率规则:更新连续无进线点击 streak,触线时仅降权(ratio_deferred),不关闭 status
*
* 最终关号由同步后 SplitTicketRuleService::applyNumberRules 裁决。
*/
class SplitNumberVisitRuleService
{
private SplitNumberRatioDeferredService $ratioDeferredService;
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
{
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
}
/**
* 访问计数已递增后调用:累加 streak,达到 assign_ratio 时标记 ratio_deferred=1
*/
public function recordVisitAfterRedirect(int $numberId): void
{
if ($numberId <= 0) {
return;
}
/** @var Number|null $number */
$number = Number::get($numberId);
if ($number === null) {
return;
}
/** @var Ticket|null $ticket */
$ticket = Ticket::where('admin_id', (int) $number['admin_id'])
->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);
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
$hasBaseline = $this->ratioDeferredService->hasLandingVisitBaseline($number);
if ($hasBaseline) {
$streak = $this->ratioDeferredService->computeStreakForVisit($number, $streak);
}
$update = [
'no_inbound_click_streak' => $streak,
'updatetime' => time(),
];
if ((int) $number['manual_manage'] === 0
&& $this->ratioDeferredService->shouldMarkDeferred($streak, $assignRatio, $hasBaseline)
) {
$update['ratio_deferred'] = 1;
$update['ratio_deferred_at'] = time();
}
Number::where('id', $numberId)->update($update);
}
}
@@ -9,8 +9,8 @@ use app\admin\model\split\Link;
/** /**
* 分流链接随机打乱配置与落地页选号 * 分流链接随机打乱配置与落地页选号
* *
* - random_shuffle=0:跨工单合并号码池,按 id 顺序严格轮转 * - random_shuffle=0:跨工单合并号码池,按 id 顺序全局严格轮转
* - random_shuffle=1跨工单合并号码池,每次访问完全随机选号 * - random_shuffle=1先按各工单开启号码数比例随机选工单,再在该工单内随机选号
* - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布) * - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布)
*/ */
class SplitNumberWeighService class SplitNumberWeighService
@@ -32,11 +32,13 @@ class SplitNumberWeighService
* @param int $linkId 分流链接 ID * @param int $linkId 分流链接 ID
* @param int $numberCount 可用号码数量 * @param int $numberCount 可用号码数量
* @param SplitRoundRobinStore $roundRobinStore 关闭随机打乱时的轮转计数器 * @param SplitRoundRobinStore $roundRobinStore 关闭随机打乱时的轮转计数器
* @param string $poolKey 选号池标识(preferred / deferred 等)
*/ */
public static function resolvePickIndex( public static function resolvePickIndex(
int $linkId, int $linkId,
int $numberCount, int $numberCount,
SplitRoundRobinStore $roundRobinStore SplitRoundRobinStore $roundRobinStore,
string $poolKey = 'main'
): int { ): int {
if ($numberCount <= 0) { if ($numberCount <= 0) {
return 0; return 0;
@@ -49,6 +51,26 @@ class SplitNumberWeighService
return random_int(0, $numberCount - 1); return random_int(0, $numberCount - 1);
} }
return $roundRobinStore->nextIndex($linkId, $numberCount); return $roundRobinStore->nextIndex($linkId, $numberCount, $poolKey);
}
/**
* 从号码行列表中按链接配置选一条(严格轮转或随机)
*
* @param array<int, array<string, mixed>|Number> $rows
* @return array<string, mixed>|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];
} }
} }
View File
@@ -6,21 +6,30 @@ namespace app\common\service;
use app\admin\model\split\Link; use app\admin\model\split\Link;
use app\admin\model\split\Number; use app\admin\model\split\Number;
use app\admin\model\split\Ticket;
use think\Collection; use think\Collection;
/** /**
* 分流链接落地页:查链、跨工单合并选号、拼接跳转 URL、访问计数 * 分流链接落地页:查链、选号、拼接跳转 URL、访问计数
* *
* 号码池同一 split_link_id 下全部 status=normal 的号码; * 号码池同一 split_link_id 下 status=normal,且所属工单未关闭(ticket.status≠hidden的号码;
* random_shuffle 关闭时严格轮转,开启时每次访问随机选号 * 优先选用 ratio_deferred=0,全部触线降权时回退至降权池
*
* random_shuffle=0:跨工单合并池,全局严格轮转;
* random_shuffle=1:先按各工单开启号码数量比例随机选工单,再在该工单内随机选号。
*/ */
class SplitRedirectService class SplitRedirectService
{ {
private SplitRoundRobinStore $roundRobinStore; 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->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
$this->visitRuleService = $visitRuleService ?? new SplitNumberVisitRuleService();
} }
/** /**
@@ -49,22 +58,17 @@ class SplitRedirectService
return null; return null;
} }
/** @var Collection<int, Number>|array<int, Number> $numbers */ $linkId = (int) $link->getAttr('id');
$numbers = Number::where('split_link_id', (int) $link['id']) $list = $this->loadEligibleNumbers($linkId);
->where('status', 'normal') if ($list === []) {
->order('id', 'asc')
->field('id,number,number_type,number_type_custom')
->select();
$count = is_countable($numbers) ? count($numbers) : 0;
if ($count === 0) {
return null; return null;
} }
$linkId = (int) $link->getAttr('id'); if (SplitNumberWeighService::isRandomShuffleEnabled($linkId)) {
$index = SplitNumberWeighService::resolvePickIndex($linkId, $count, $this->roundRobinStore); $picked = $this->pickNumberRowRandomByTicketWeight($linkId, $list);
$list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers; } else {
$picked = $list[$index] ?? $list[0] ?? null; $picked = $this->pickNumberRow($linkId, $list);
}
if ($picked === null) { if ($picked === null) {
return null; return null;
} }
@@ -93,8 +97,146 @@ class SplitRedirectService
$numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id'); $numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id');
if ($numberId > 0) { if ($numberId > 0) {
Number::where('id', $numberId)->setInc('visit_count'); Number::where('id', $numberId)->setInc('visit_count');
$this->visitRuleService->recordVisitAfterRedirect($numberId);
} }
return $redirectUrl; return $redirectUrl;
} }
/**
* 可参与选号的号码:status=normal,且 ticket_name 不属于已关闭工单
*
* @return list<Number|array<string, mixed>>
*/
private function loadEligibleNumbers(int $linkId): array
{
if ($linkId <= 0) {
return [];
}
/** @var list<string> $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<Number|array<string, mixed>> $rows
* @return array<string, mixed>|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<Number|array<string, mixed>> $rows
* @return array<string, list<Number|array<string, mixed>>>
*/
private function groupNumbersByTicket(array $rows): array
{
/** @var array<string, list<Number|array<string, mixed>>> $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<string, list<Number|array<string, mixed>>> $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) {
// PHP 会把纯数字字符串键转成 int,需强转以匹配 ?string 返回类型
return (string) $keys[0];
}
$r = random_int(1, $total);
$acc = 0;
foreach ($groups as $key => $rows) {
$acc += count($rows);
if ($r <= $acc) {
return (string) $key;
}
}
return (string) $keys[0];
}
/**
* 双层选号:优先 ratio_deferred=0,否则回退至降权池
*
* @param list<Number|array<string, mixed>> $rows
* @return array<string, mixed>|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');
}
} }
@@ -22,15 +22,19 @@ class SplitRoundRobinStore
/** /**
* 获取本次访问应使用的号码下标(0 .. count-1 * 获取本次访问应使用的号码下标(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) { if ($splitLinkId <= 0 || $numberCount <= 0) {
return 0; return 0;
} }
$poolKey = $this->sanitizePoolKey($poolKey);
if ($this->ensureRedis()) { if ($this->ensureRedis()) {
$key = self::KEY_PREFIX . $splitLinkId; $key = self::KEY_PREFIX . $splitLinkId . ':' . $poolKey;
$seq = (int) $this->redis->incr($key); $seq = (int) $this->redis->incr($key);
if ($seq <= 0) { if ($seq <= 0) {
$seq = 1; $seq = 1;
@@ -39,7 +43,16 @@ class SplitRoundRobinStore
return ($seq - 1) % $numberCount; 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) * 无 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/'); $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
$dir = $runtime . 'split_rr/'; $dir = $runtime . 'split_rr/';
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return 0; return 0;
} }
$file = $dir . $splitLinkId . '.cnt'; $file = $dir . $splitLinkId . '_' . $poolKey . '.cnt';
$fp = @fopen($file, 'c+'); $fp = @fopen($file, 'c+');
if ($fp === false) { if ($fp === false) {
return 0; return 0;
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\common\service; namespace app\common\service;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\ScrmSpiderInterface; use app\common\library\scrm\ScrmSpiderInterface;
use app\common\library\scrm\spider\A2cSpider; use app\common\library\scrm\spider\A2cSpider;
use app\common\library\scrm\spider\ChatknowSpider; use app\common\library\scrm\spider\ChatknowSpider;
@@ -32,6 +33,16 @@ class SplitScrmSpiderFactory
// ceo_scrm 等未实现类型:新增 spider 类后在此注册 // ceo_scrm 等未实现类型:新增 spider 类后在此注册
]; ];
/**
* API 优先同步类型:常态走 PHP cURL,不占 Node Real Browser 槽位
*
* Whatshub 已改为混合同步(cURL 号码 + Node statistics),需走 Node 队列门禁。
*
* @var list<string>
*/
private const API_FIRST_SYNC_TYPES = [
];
/** /**
* @return class-string<ScrmSpiderInterface>|null * @return class-string<ScrmSpiderInterface>|null
*/ */
@@ -59,6 +70,65 @@ class SplitScrmSpiderFactory
return array_keys(self::MAP); 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<string>
*/
public static function listApiFirstSyncTypes(): array
{
return self::API_FIRST_SYNC_TYPES;
}
/**
* 同步是否依赖 Node Headless(纯 PHP cURL 等为 false
*/
public static function requiresNode(string $ticketType): bool
{
$class = self::resolveClass($ticketType);
if ($class === null) {
return true;
}
return is_subclass_of($class, AbstractScrmSpider::class);
}
/**
* @return list<string>
*/
public static function listDirectSyncTypes(): array
{
$types = [];
foreach (self::listSupportedTypes() as $ticketType) {
if (!self::requiresNode($ticketType)) {
$types[] = $ticketType;
}
}
return $types;
}
/**
* @return list<string>
*/
public static function listNodeSyncTypes(): array
{
$types = [];
foreach (self::listSupportedTypes() as $ticketType) {
if (self::requiresNode($ticketType)) {
$types[] = $ticketType;
}
}
return $types;
}
/** /**
* @return ScrmSpiderInterface|null * @return ScrmSpiderInterface|null
*/ */
@@ -24,6 +24,12 @@ class SplitSyncConfigService
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST; return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
} }
/** 失败重试时间点最多配置条数 */
private const FAIL_RETRY_SCHEDULE_MAX = 10;
/** 单条重试时间点上限(分钟) */
private const FAIL_RETRY_MINUTE_MAX = 1440;
/** /**
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停) * 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
*/ */
@@ -36,6 +42,50 @@ class SplitSyncConfigService
return max(0, (int) $value); return max(0, (int) $value);
} }
/**
* 达失败暂停阈值后的重试时间点(分钟,相对锚点的绝对偏移,升序去重)
*
* @return list<int>
*/
public static function getFailRetryMinutes(): array
{
$raw = trim(self::getConfigValue('split_sync_fail_retry_minutes'));
if ($raw === '') {
return [];
}
$parts = preg_split('/[,\s]+/', $raw) ?: [];
$minutes = [];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part === '' || !ctype_digit($part)) {
continue;
}
$minute = (int) $part;
if ($minute < 1 || $minute > self::FAIL_RETRY_MINUTE_MAX) {
continue;
}
$minutes[$minute] = $minute;
}
if ($minutes === []) {
return [];
}
$list = array_values($minutes);
sort($list, SORT_NUMERIC);
return array_slice($list, 0, self::FAIL_RETRY_SCHEDULE_MAX);
}
/**
* 是否配置了失败分阶段重试时间点
*/
public static function hasFailRetrySchedule(): bool
{
return self::getFailRetryMinutes() !== [];
}
/** /**
* 指定工单类型的自动同步周期(分钟),0 表示不自动同步 * 指定工单类型的自动同步周期(分钟),0 表示不自动同步
*/ */
@@ -57,9 +107,21 @@ class SplitSyncConfigService
{ {
$value = self::getConfigValue('split_sync_max_per_cron'); $value = self::getConfigValue('split_sync_max_per_cron');
if ($value === '') { if ($value === '') {
return 2; return 5;
} }
return max(1, min(20, (int) $value)); return max(1, min(50, (int) $value));
}
/**
* Node 通道候选池倍数(候选数 = maxPerRun × 本值)
*/
public static function getNodeCandidatePoolMultiplier(): int
{
$value = self::getConfigValue('split_sync_node_candidate_multiplier');
if ($value === '') {
return 10;
}
return max(3, min(50, (int) $value));
} }
/** /**
@@ -86,6 +148,53 @@ class SplitSyncConfigService
return max(0, (int) $value); return max(0, (int) $value);
} }
/**
* 后台投递 / Cron 使用的 PHP CLI 可执行文件路径
*
* Web(FPM) 环境下 PHP_BINARY 指向 php-fpm,不能用于 think 命令。
*/
public static function getCliPhpBinary(): string
{
$configured = trim(self::getConfigValue('split_sync_cli_php'));
if ($configured !== '' && self::isUsableCliPhp($configured)) {
return $configured;
}
if (defined('PHP_BINDIR') && PHP_BINDIR !== '') {
$candidate = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php';
if (self::isUsableCliPhp($candidate)) {
return $candidate;
}
}
if (PHP_SAPI === 'cli' && defined('PHP_BINARY') && PHP_BINARY !== '' && self::isUsableCliPhp(PHP_BINARY)) {
return PHP_BINARY;
}
foreach (['/usr/bin/php', '/usr/local/bin/php'] as $candidate) {
if (self::isUsableCliPhp($candidate)) {
return $candidate;
}
}
return 'php';
}
/**
* 判断路径是否为可用的 PHP CLI(排除 php-fpm
*/
private static function isUsableCliPhp(string $path): bool
{
if (stripos($path, 'fpm') !== false) {
return false;
}
if ($path === 'php') {
return true;
}
return is_file($path) && is_executable($path);
}
private static function getConfigValue(string $name): string private static function getConfigValue(string $name): string
{ {
$site = Config::get('site.' . $name); $site = Config::get('site.' . $name);
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Number;
use app\admin\model\split\Ticket;
/**
* 工单本地开启号码数(active_number_count)物化字段维护
*
* 统计口径:fa_split_number 中 admin_id + split_link_id + ticket_name 匹配且 status=normal
*/
class SplitTicketActiveNumberCountService
{
/**
* 按工单关联键统计并回写 active_number_count
*/
public function refreshForTicketKeys(int $adminId, int $linkId, string $ticketName): void
{
$ticketName = trim($ticketName);
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
return;
}
$count = (int) Number::where('admin_id', $adminId)
->where('split_link_id', $linkId)
->where('ticket_name', $ticketName)
->where('status', 'normal')
->count();
Ticket::where('admin_id', $adminId)
->where('split_link_id', $linkId)
->where('ticket_name', $ticketName)
->update([
'active_number_count' => $count,
'updatetime' => time(),
]);
}
/**
* 按工单模型回写
*/
public function refreshForTicket(Ticket $ticket): void
{
$this->refreshForTicketKeys(
(int) $ticket['admin_id'],
(int) $ticket['split_link_id'],
(string) $ticket['ticket_name']
);
}
/**
* 批量号码 ID:去重后刷新涉及工单
*
* @param array<int|string> $ids
*/
public function refreshForNumberIds(array $ids): void
{
$ids = array_values(array_filter(array_map('intval', $ids)));
if ($ids === []) {
return;
}
$rows = Number::where('id', 'in', $ids)
->field('admin_id,split_link_id,ticket_name')
->select();
$this->refreshFromNumberRows($rows);
}
/**
* 编辑单条号码:关联键变更时刷新旧工单与新工单
*
* @param array<string, mixed> $before
* @param array<string, mixed> $after
*/
public function refreshAfterNumberEdit(array $before, array $after): void
{
$keys = [];
$beforeKey = $this->buildTicketKeyFromRow($before);
if ($beforeKey !== null) {
$keys[] = $beforeKey;
}
$afterKey = $this->buildTicketKeyFromRow($after);
if ($afterKey !== null) {
$keys[] = $afterKey;
}
$this->refreshForTicketKeyList($keys);
}
/**
* 批量工单关联键去重后刷新
*
* @param array<int, array{0:int,1:int,2:string}> $keys
*/
public function refreshForTicketKeyList(array $keys): void
{
$seen = [];
foreach ($keys as $key) {
if (!is_array($key) || count($key) < 3) {
continue;
}
$adminId = (int) $key[0];
$linkId = (int) $key[1];
$ticketName = trim((string) $key[2]);
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
continue;
}
$hash = $adminId . ':' . $linkId . ':' . $ticketName;
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$this->refreshForTicketKeys($adminId, $linkId, $ticketName);
}
}
/**
* @param iterable<int, array<string, mixed>|Number> $rows
*/
public function refreshFromNumberRows(iterable $rows): void
{
$keys = [];
foreach ($rows as $row) {
$data = $row instanceof Number ? $row->getData() : (array) $row;
$key = $this->buildTicketKeyFromRow($data);
if ($key !== null) {
$keys[] = $key;
}
}
$this->refreshForTicketKeyList($keys);
}
/**
* @param array<string, mixed> $row
* @return array{0:int,1:int,2:string}|null
*/
private function buildTicketKeyFromRow(array $row): ?array
{
$adminId = (int) ($row['admin_id'] ?? 0);
$linkId = (int) ($row['split_link_id'] ?? 0);
$ticketName = trim((string) ($row['ticket_name'] ?? ''));
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
return null;
}
return [$adminId, $linkId, $ticketName];
}
}
@@ -13,7 +13,7 @@ use think\Db;
* 工单同步结果写入号码表 * 工单同步结果写入号码表
* *
* 同步策略(全量镜像): * 同步策略(全量镜像):
* - 爬虫返回的号码:写入 platform_status / inbound_count,开关 status 与云控在线状态一致 * - 爬虫返回的号码:写入 platform_status / inbound_countstatus 由后续 applyNumberRules 裁决
* - 爬虫未返回且 manual_manage=0:物理删除 * - 爬虫未返回且 manual_manage=0:物理删除
* - manual_manage=1:不删除,仅当仍在爬虫结果中时更新 platform_status * - manual_manage=1:不删除,仅当仍在爬虫结果中时更新 platform_status
*/ */
@@ -108,27 +108,11 @@ class SplitTicketNumberSyncService
} }
/** /**
* 工单手动开启等非同步场景:按已存的 platform_status 对齐开关(不跑单号上限/下号比率) * 工单手动开启等非同步场景:按业务规则对齐号码开关(平台 + 单号上限 + 下号比率)
*/ */
public function applyStatusFromPlatformSnapshot(Ticket $ticket): void public function applyStatusFromPlatformSnapshot(Ticket $ticket): void
{ {
if ((string) ($ticket['status'] ?? 'hidden') !== 'normal') { (new SplitTicketRuleService())->applyNumberRules($ticket);
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(),
]);
}
} }
/** /**
@@ -163,7 +147,6 @@ class SplitTicketNumberSyncService
} }
$update['inbound_count'] = max(0, $newFollowers); $update['inbound_count'] = max(0, $newFollowers);
$update['status'] = self::resolveSwitchStatus($ticket, $platformStatus);
Number::where('id', (int) $row['id'])->update($update); Number::where('id', (int) $row['id'])->update($update);
} }
@@ -185,7 +168,7 @@ class SplitTicketNumberSyncService
'inbound_count' => max(0, $newFollowers), 'inbound_count' => max(0, $newFollowers),
'manual_manage' => 0, 'manual_manage' => 0,
'platform_status' => $platformStatus, 'platform_status' => $platformStatus,
'status' => self::resolveSwitchStatus($ticket, $platformStatus), 'ratio_deferred' => 0,
'createtime' => $now, 'createtime' => $now,
'updatetime' => $now, 'updatetime' => $now,
]; ];
@@ -12,8 +12,40 @@ use app\admin\model\split\Ticket;
*/ */
class SplitTicketRuleService class SplitTicketRuleService
{ {
private SplitNumberRatioDeferredService $ratioDeferredService;
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
{
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
}
/** /**
* 同步后应用工单开关规则;号码开关以爬虫/platform 快照为准(不再跑单号上限/下号比率) * 手动关闭的工单(manual_manage=1):禁止自动同步改写 status,须用户手动开启
*/
public function isManuallyClosed(Ticket $ticket): bool
{
return (int) ($ticket['manual_manage'] ?? 0) === 1;
}
/**
* 列表/编辑切换状态时同步 manual_manage 标记
*
* @param array<string, mixed> $values
*/
public function applyManualManageForStatusChange(array &$values): void
{
if (!isset($values['status'])) {
return;
}
if ((string) $values['status'] === 'hidden') {
$values['manual_manage'] = 1;
} elseif ((string) $values['status'] === 'normal') {
$values['manual_manage'] = 0;
}
}
/**
* 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率)
*/ */
public function applyAfterSync(Ticket $ticket, int $completeCount): void public function applyAfterSync(Ticket $ticket, int $completeCount): void
{ {
@@ -23,7 +55,7 @@ class SplitTicketRuleService
if ((string) $fresh['status'] === 'hidden') { if ((string) $fresh['status'] === 'hidden') {
$this->cascadeTicketClosedToNumbers($fresh); $this->cascadeTicketClosedToNumbers($fresh);
} else { } else {
(new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($fresh); $this->applyNumberRules($fresh);
} }
} }
} }
@@ -44,13 +76,14 @@ class SplitTicketRuleService
'status' => 'hidden', 'status' => 'hidden',
'updatetime' => time(), 'updatetime' => time(),
]); ]);
(new SplitTicketActiveNumberCountService())->refreshForTicket($ticket);
} }
/** /**
* 工单物理删除时,移除该工单同步导入的号码(manual_manage=0 * 工单物理删除时,移除该工单关联的全部号码(manual_manage=1
* *
* 关联键与 SplitTicketNumberSyncService 一致:admin_id + split_link_id + ticket_name * 关联键与 SplitTicketNumberSyncService 一致:admin_id + split_link_id + ticket_name
* manual_manage=1 的手动管理号码保留
* *
* @return int 删除行数 * @return int 删除行数
*/ */
@@ -65,12 +98,11 @@ class SplitTicketRuleService
return Number::where('admin_id', (int) $ticket['admin_id']) return Number::where('admin_id', (int) $ticket['admin_id'])
->where('split_link_id', $linkId) ->where('split_link_id', $linkId)
->where('ticket_name', $ticketName) ->where('ticket_name', $ticketName)
->where('manual_manage', 0)
->delete(); ->delete();
} }
/** /**
* 手动切换工单状态时,非手动号码按 platform_status 对齐开关 * 手动切换工单状态时,非手动号码按业务规则对齐开关
*/ */
public function syncNumbersWithTicketStatus(Ticket $ticket): void public function syncNumbersWithTicketStatus(Ticket $ticket): void
{ {
@@ -79,21 +111,21 @@ class SplitTicketRuleService
$this->cascadeTicketClosedToNumbers($ticket); $this->cascadeTicketClosedToNumbers($ticket);
return; return;
} }
(new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($ticket); $this->applyNumberRules($ticket);
} }
/** /**
* 工单处于开启状态时,将云控在线的非手动号码设为开启 * 工单处于开启状态时,将云控在线的非手动号码设为开启
* *
* @deprecated 请使用 applyStatusFromPlatformSnapshot(以 platform_status 为准) * @deprecated 请使用 applyNumberRules
*/ */
public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void
{ {
(new SplitTicketNumberSyncService())->applyStatusFromPlatformSnapshot($ticket); $this->applyNumberRules($ticket);
} }
/** /**
* 单号上限、下号比率、云控在线状态、工单开关综合决定号码状态 * 单号上限、下号比率、云控在线状态、工单开关综合决定号码 status;同步时清除或确认 ratio_deferred
*/ */
public function applyNumberRules(Ticket $ticket): void public function applyNumberRules(Ticket $ticket): void
{ {
@@ -108,14 +140,13 @@ class SplitTicketRuleService
foreach ($numbers as $number) { foreach ($numbers as $number) {
$visitCount = (int) $number['visit_count']; $visitCount = (int) $number['visit_count'];
$inboundCount = (int) $number['inbound_count']; $inboundCount = (int) $number['inbound_count'];
$lastVisit = (int) ($number['last_sync_visit_count'] ?? 0);
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0); $lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
$streak = (int) ($number['no_inbound_click_streak'] ?? 0); $streak = (int) ($number['no_inbound_click_streak'] ?? 0);
if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) { if ($visitCount <= 0) {
$streak += ($visitCount - $lastVisit);
} elseif ($inboundCount > $lastInbound) {
$streak = 0; $streak = 0;
} else {
$streak = $this->ratioDeferredService->computeStreakForSync($number, $streak);
} }
$update = [ $update = [
@@ -125,12 +156,26 @@ class SplitTicketRuleService
'updatetime' => time(), 'updatetime' => time(),
]; ];
// 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态 $clearDeferred = [
'ratio_deferred' => 0,
'ratio_deferred_at' => null,
];
// 手动管理:更新统计;无效降权(无落地页访问)一并清除
if ((int) $number['manual_manage'] === 1) { 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); Number::where('id', (int) $number['id'])->update($update);
continue; continue;
} }
// 同步裁决 ratio_deferred:同步时清除访问侧降权,由 streak 决定是否关号
$update = array_merge($update, $clearDeferred);
$update['status'] = $this->resolveAutomatedStatus( $update['status'] = $this->resolveAutomatedStatus(
$ticket, $ticket,
(string) ($number['platform_status'] ?? 'unknown'), (string) ($number['platform_status'] ?? 'unknown'),
@@ -142,6 +187,8 @@ class SplitTicketRuleService
Number::where('id', (int) $number['id'])->update($update); Number::where('id', (int) $number['id'])->update($update);
} }
(new SplitTicketActiveNumberCountService())->refreshForTicket($ticket);
} }
/** /**
@@ -172,10 +219,13 @@ class SplitTicketRuleService
} }
/** /**
* 完成量、时间窗口决定工单开关(定时与手动同步均适用) * 完成量、时间窗口决定工单开关(定时与手动同步均适用;手动关闭工单除外
*/ */
public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void
{ {
if ($this->isManuallyClosed($ticket)) {
return;
}
$status = $this->resolveTicketStatus($ticket, $completeCount); $status = $this->resolveTicketStatus($ticket, $completeCount);
if ($status !== (string) ($ticket['status'] ?? 'hidden')) { if ($status !== (string) ($ticket['status'] ?? 'hidden')) {
Ticket::where('id', (int) $ticket['id'])->update([ Ticket::where('id', (int) $ticket['id'])->update([
@@ -0,0 +1,572 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\library\scrm\AntiBotConfigBuilder;
use think\Db;
/**
* 工单同步 CLI 后台投递
*
* Web / Cron 仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
* 避免长时间占用 PHP-FPM、Cron 全局锁或 Session 锁。
*
* 并行策略:
* - API 优先类型(Whatshub 等):独立并行投递,不占 Node Real Browser 槽位
* - 直连 PHP 类型:独立并行投递
* - Node 类型:不同 sessionKey(域名)并行;同一 sessionKey 内串行以命中 Browser 温池
*/
class SplitTicketSyncDispatchService
{
/**
* 投递手工同步任务到后台 CLI
*
* @param int[] $ticketIds 已通过权限校验的工单 ID
* @return array{queued:int[],skipped:int[],failed:int[]}
*/
public function dispatchManual(array $ticketIds): array
{
$lockService = new SplitTicketSyncLockService();
$metaList = $this->loadTicketMetaList($ticketIds);
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
$toDispatch = [];
$skipped = [];
$failed = [];
foreach ($metaList as $meta) {
$ticketId = (int) $meta['id'];
if ((int) ($meta['manual_manage'] ?? 0) === 1) {
$skipped[] = $ticketId;
continue;
}
if ($lockService->isLocked($ticketId)) {
$skipped[] = $ticketId;
continue;
}
$toDispatch[] = $meta;
}
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'manual');
SplitTicketSyncLogger::log('web', 'manual sync dispatched', [
'queued' => $dispatchResult['queued'],
'skipped' => $skipped,
'failed' => $dispatchResult['failed'],
'phpCli' => $this->resolvePhpBinary(),
]);
return [
'queued' => $dispatchResult['queued'],
'skipped' => $skipped,
'failed' => $dispatchResult['failed'],
];
}
/**
* Cron 自动同步:将挑选后的到期工单投递到后台 CLI(Cron 进程立即返回)
*
* @param list<array{id:int,ticket_type:string,ticket_url:string}|Ticket|\think\Model> $tickets
* @return array{
* queued:list<array<string,mixed>>,
* skipped:list<array<string,mixed>>,
* failed:list<array<string,mixed>>,
* stoppedByNodeBusy:bool
* }
*/
public function dispatchAuto(array $tickets): array
{
$lockService = new SplitTicketSyncLockService();
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
$toDispatch = [];
/** @var list<array<string,mixed>> $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'],
];
}
/**
* 查询仍在同步中的工单 ID(依据 runtime 文件锁)
*
* @param int[] $ticketIds
* @return int[]
*/
public function filterSyncingIds(array $ticketIds): array
{
$lockService = new SplitTicketSyncLockService();
$syncing = [];
foreach ($ticketIds as $rawId) {
$ticketId = (int) $rawId;
if ($ticketId > 0 && $lockService->isLocked($ticketId)) {
$syncing[] = $ticketId;
}
}
return $syncing;
}
/**
* @param list<array{id:int,ticket_type:string,ticket_url:string}> $metaList
* @return array{
* queued:list<array<string,mixed>>,
* skipped:list<array<string,mixed>>,
* failed:list<array<string,mixed>>,
* stoppedByNodeBusy:bool
* }
*/
private function dispatchTicketMetaList(array $metaList, string $source): array
{
$php = $this->resolvePhpBinary();
$think = $this->resolveThinkScript();
$root = $this->resolveRootPath();
/** @var list<array<string,mixed>> $queued */
$queued = [];
/** @var list<array<string,mixed>> $skipped */
$skipped = [];
/** @var list<array<string,mixed>> $failed */
$failed = [];
$stoppedByNodeBusy = false;
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $apiFirstList */
$apiFirstList = [];
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $directList */
$directList = [];
/** @var array<string, list<array{id:int,ticket_type:string,ticket_url:string}>> $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<array{id:int,ticket_type:string,ticket_url:string}> $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<array<string,mixed>> $queued
* @param list<array<string,mixed>> $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<string,mixed>
*/
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<array{id:int,ticket_type:string,ticket_url:string}>
*/
private function loadTicketMetaList(array $ticketIds): array
{
$ids = [];
foreach ($ticketIds as $rawId) {
$ticketId = (int) $rawId;
if ($ticketId > 0) {
$ids[] = $ticketId;
}
}
if ($ids === []) {
return [];
}
$rows = Db::name('split_ticket')
->where('id', 'in', $ids)
->field('id,ticket_type,ticket_url,manual_manage')
->select();
$list = [];
foreach ($rows as $row) {
$list[] = [
'id' => (int) $row['id'],
'ticket_type' => (string) ($row['ticket_type'] ?? ''),
'ticket_url' => (string) ($row['ticket_url'] ?? ''),
'manual_manage' => (int) ($row['manual_manage'] ?? 0),
];
}
return $list;
}
/**
* @param array<string,mixed>|object $ticket
* @return array{id:int,ticket_type:string,ticket_url:string}|null
*/
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 --mode=%s >> %s 2>&1',
escapeshellarg($php),
escapeshellarg($think),
$ticketId,
$syncMode,
escapeshellarg($logFile)
);
return $this->runBackgroundCommand($root, $inner, $ticketId, $source . '-parallel');
}
/**
* 同一 sessionKey 内串行执行多个 CLI(单 nohup 子 shell
*
* @param int[] $ticketIds
*/
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds, string $source): bool
{
if ($ticketIds === []) {
return false;
}
$logDir = $this->resolveLogDir();
$parts = [];
foreach ($ticketIds as $ticketId) {
$ticketId = (int) $ticketId;
if ($ticketId <= 0) {
continue;
}
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
$syncMode = $this->normalizeSyncModeForCli($source);
$parts[] = sprintf(
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
escapeshellarg($php),
escapeshellarg($think),
$ticketId,
$syncMode,
escapeshellarg($logFile)
);
}
if ($parts === []) {
return false;
}
$inner = implode('; ', $parts);
return $this->runBackgroundCommand($root, $inner, $ticketIds, $source . '-node-serial');
}
/**
* @param int|int[] $ticketRef 日志用 ticketId 或 ID 列表
*/
private function runBackgroundCommand(string $root, string $inner, $ticketRef, string $mode): bool
{
if ($this->isWindows()) {
$command = sprintf('start /B cmd /C %s', $inner);
} else {
$command = sprintf('cd %s && nohup bash -c %s &', escapeshellarg($root), escapeshellarg($inner));
}
if ($this->canUseExec()) {
@exec($command, $output, $exitCode);
SplitTicketSyncLogger::log('dispatch', 'cli spawn exec', [
'ticketRef' => $ticketRef,
'mode' => $mode,
'command' => $command,
'exitCode' => $exitCode,
]);
return true;
}
if ($this->canUseShellExec()) {
@shell_exec($command);
SplitTicketSyncLogger::log('dispatch', 'cli spawn shell_exec', [
'ticketRef' => $ticketRef,
'mode' => $mode,
'command' => $command,
]);
return true;
}
SplitTicketSyncLogger::log('dispatch', 'cli spawn failed: exec disabled', [
'ticketRef' => $ticketRef,
'mode' => $mode,
]);
return false;
}
private function resolvePhpBinary(): string
{
if (method_exists(SplitSyncConfigService::class, 'getCliPhpBinary')) {
return SplitSyncConfigService::getCliPhpBinary();
}
return $this->resolvePhpBinaryFallback();
}
/**
* 当 SplitSyncConfigService 未部署 getCliPhpBinary 时的兜底解析
*/
private function resolvePhpBinaryFallback(): string
{
if (defined('PHP_BINDIR') && PHP_BINDIR !== '') {
$candidate = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php';
if ($this->isUsableCliPhp($candidate)) {
return $candidate;
}
}
foreach (['/usr/bin/php', '/usr/local/bin/php'] as $candidate) {
if ($this->isUsableCliPhp($candidate)) {
return $candidate;
}
}
return 'php';
}
private function isUsableCliPhp(string $path): bool
{
if (stripos($path, 'fpm') !== false) {
return false;
}
if ($path === 'php') {
return true;
}
return is_file($path) && is_executable($path);
}
private function resolveThinkScript(): string
{
$root = $this->resolveRootPath();
return rtrim($root, '/\\') . DIRECTORY_SEPARATOR . 'think';
}
private function resolveRootPath(): string
{
if (defined('ROOT_PATH') && ROOT_PATH !== '') {
return rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR;
}
return rtrim(dirname(__DIR__, 3), '/\\') . DIRECTORY_SEPARATOR;
}
private function resolveLogDir(): string
{
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : ($this->resolveRootPath() . 'runtime' . DIRECTORY_SEPARATOR);
$dir = rtrim($runtime, '/\\') . DIRECTORY_SEPARATOR . 'split_ticket_sync' . DIRECTORY_SEPARATOR;
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return $dir;
}
private function isWindows(): bool
{
return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
}
/**
* @return string[]
*/
private function disabledFunctions(): array
{
$raw = (string) ini_get('disable_functions');
if ($raw === '') {
return [];
}
return array_filter(array_map('trim', explode(',', $raw)));
}
private function canUseExec(): bool
{
return function_exists('exec') && !in_array('exec', $this->disabledFunctions(), true);
}
private function canUseShellExec(): bool
{
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';
}
}
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Ticket;
/**
* 工单同步失败分阶段重试状态机
*
* 达连续失败阈值后按配置时间点(相对锚点的绝对分钟偏移)自动重试;
* 任一次成功则清零恢复常态,全部失败则永久终止自动同步。
*/
class SplitTicketSyncFailRetryService
{
/**
* 是否已永久终止自动同步(重试时间点已全部用尽)
*
* @param Ticket|array<string, mixed> $ticket
*/
public function isPermanentlyStopped($ticket): bool
{
return (int) ($this->readField($ticket, 'sync_auto_sync_stopped') ?? 0) === 1;
}
/**
* 是否处于失败重试等待/重试周期内(未永久终止)
*
* @param Ticket|array<string, mixed> $ticket
*/
public function isInRetryMode($ticket): bool
{
if ($this->isPermanentlyStopped($ticket)) {
return false;
}
return (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0) > 0;
}
/**
* 首次达失败阈值时进入重试态(工单保持 normal,不关单)
*
* @return array<string, mixed> 需合并进 save 的字段;无变更时返回空数组
*/
public function enterRetryIfNeeded(Ticket $ticket, int $failCount, int $threshold): array
{
if ($failCount < $threshold || !SplitSyncConfigService::hasFailRetrySchedule()) {
return [];
}
if ($this->isInRetryMode($ticket)) {
return [];
}
return [
'sync_fail_pause_at' => time(),
'sync_fail_retry_index' => 0,
'sync_auto_sync_stopped' => 0,
];
}
/**
* 计划内重试失败后递进索引;用尽全部时间点后标记永久终止
*
* @return array<string, mixed>
*/
public function onRetryFailure(Ticket $ticket): array
{
$schedule = SplitSyncConfigService::getFailRetryMinutes();
$index = (int) ($ticket['sync_fail_retry_index'] ?? 0) + 1;
$update = ['sync_fail_retry_index' => $index];
if ($index >= count($schedule)) {
$update['sync_auto_sync_stopped'] = 1;
}
return $update;
}
/**
* 同步成功时清零失败计数与重试相关字段
*
* @return array<string, mixed>
*/
public function clearOnSuccessFields(): array
{
return [
'sync_fail_count' => 0,
'sync_fail_pause_at' => null,
'sync_fail_retry_index' => 0,
'sync_auto_sync_stopped' => 0,
];
}
/**
* Cron 自动重试是否已到点;未到点返回 skip 原因,到点返回 null(允许执行)
*
* @param Ticket|array<string, mixed> $ticket
*/
public function shouldAllowAutoRetry($ticket, ?int $now = null): ?string
{
if (!$this->isInRetryMode($ticket)) {
return null;
}
if ($this->isPermanentlyStopped($ticket)) {
return '自动同步已终止(重试已用尽)';
}
$nextAt = $this->getNextRetryAt($ticket);
if ($nextAt === null) {
return '自动同步已终止(重试已用尽)';
}
$now = $now ?? time();
if ($now < $nextAt) {
$attempt = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0) + 1;
return sprintf(
'失败重试等待中,将于 %s 进行第 %d 次重试',
date('Y-m-d H:i:s', $nextAt),
$attempt
);
}
return null;
}
/**
* 下一次计划重试的 Unix 时间戳;无待重试时返回 null
*
* @param Ticket|array<string, mixed> $ticket
*/
public function getNextRetryAt($ticket): ?int
{
if (!$this->isInRetryMode($ticket) || $this->isPermanentlyStopped($ticket)) {
return null;
}
$schedule = SplitSyncConfigService::getFailRetryMinutes();
if ($schedule === []) {
return null;
}
$index = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0);
if ($index >= count($schedule)) {
return null;
}
$pauseAt = (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0);
if ($pauseAt <= 0) {
return null;
}
return $pauseAt + $schedule[$index] * 60;
}
/**
* @param Ticket|array<string, mixed> $ticket
*/
private function readField($ticket, string $field)
{
if ($ticket instanceof Ticket) {
return $ticket->getData($field) ?? ($ticket[$field] ?? null);
}
return $ticket[$field] ?? null;
}
}
@@ -43,6 +43,20 @@ class SplitTicketSyncLockService
} }
} }
/**
* 工单是否处于同步中(锁文件存在且未过期)
*/
public function isLocked(int $ticketId): bool
{
$path = $this->lockPath($ticketId);
if ($this->isStaleLock($path)) {
@unlink($path);
return false;
}
return is_file($path);
}
private function lockPath(int $ticketId): string private function lockPath(int $ticketId): string
{ {
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
@@ -21,11 +21,17 @@ class SplitTicketSyncService
private SplitTicketSyncLockService $lockService; private SplitTicketSyncLockService $lockService;
private SplitTicketSyncDispatchService $dispatchService;
private SplitTicketSyncFailRetryService $failRetryService;
public function __construct() public function __construct()
{ {
$this->numberSync = new SplitTicketNumberSyncService(); $this->numberSync = new SplitTicketNumberSyncService();
$this->ruleService = new SplitTicketRuleService(); $this->ruleService = new SplitTicketRuleService();
$this->lockService = new SplitTicketSyncLockService(); $this->lockService = new SplitTicketSyncLockService();
$this->dispatchService = new SplitTicketSyncDispatchService();
$this->failRetryService = new SplitTicketSyncFailRetryService();
} }
/** /**
@@ -48,12 +54,19 @@ class SplitTicketSyncService
'force' => $force, 'force' => $force,
'syncMode' => $syncMode, 'syncMode' => $syncMode,
'status' => (string) $ticket['status'], 'status' => (string) $ticket['status'],
'manualManage' => (int) ($ticket['manual_manage'] ?? 0),
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0), 'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
'syncTime' => (int) ($ticket['sync_time'] ?? 0), 'syncTime' => (int) ($ticket['sync_time'] ?? 0),
'pageUrl' => (string) $ticket['ticket_url'], 'pageUrl' => (string) $ticket['ticket_url'],
'nodeHost' => SplitSyncConfigService::getNodeHost(), 'nodeHost' => SplitSyncConfigService::getNodeHost(),
]); ]);
if ($this->ruleService->isManuallyClosed($ticket)) {
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => '工单已手动关闭']);
SplitTicketSyncLogger::clearTicketContext();
return ['success' => false, 'message' => '工单已手动关闭,暂停同步', 'skipped' => true];
}
if (!$force && !$dueValidated) { if (!$force && !$dueValidated) {
$skip = $this->shouldSkip($ticket); $skip = $this->shouldSkip($ticket);
if ($skip !== null) { if ($skip !== null) {
@@ -80,154 +93,411 @@ class SplitTicketSyncService
} }
/** /**
* 扫描到期工单并同步(限流 + Node 队列感知 * 扫描到期工单并同步(直连 PHP 与 Node 双通道
*/ */
public function syncDueTickets(): int public function syncDueTickets(): int
{ {
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
$autoSyncTypes = $this->resolveAutoSyncTicketTypes(); $autoSyncTypes = $this->resolveAutoSyncTicketTypes();
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
if ($autoSyncTypes === []) { if ($autoSyncTypes === []) {
SplitTicketSyncLogger::logCron('scan skip', ['reason' => 'no auto-sync ticket types']); SplitTicketSyncLogger::logCron('scan skip', ['reason' => 'no auto-sync ticket types']);
return 0; return 0;
} }
if (!SplitNodeHealthService::canAcceptWork()) { $failThreshold = SplitSyncConfigService::getFailPauseThreshold();
SplitTicketSyncLogger::logCron('scan skip', [ $directTypes = array_values(array_intersect(
'reason' => 'node queue busy', $autoSyncTypes,
'queue' => SplitNodeHealthService::getQueueSnapshot(), SplitScrmSpiderFactory::listDirectSyncTypes()
]); ));
return 0; $nodeTypes = array_values(array_intersect(
} $autoSyncTypes,
SplitScrmSpiderFactory::listNodeSyncTypes()
));
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
if ($failThreshold > 0) {
$query->where('sync_fail_count', '<', $failThreshold);
}
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
->limit($maxPerRun * 5)
->select();
$list = $this->sortTicketsBySessionKey($list);
$candidateCount = count($list);
SplitTicketSyncLogger::logCron('scan start', [ SplitTicketSyncLogger::logCron('scan start', [
'candidateCount' => $candidateCount, 'autoSyncTypes' => $autoSyncTypes,
'maxPerRun' => $maxPerRun, 'directTypes' => $directTypes,
'autoSyncTypes' => $autoSyncTypes, 'nodeTypes' => $nodeTypes,
'failThreshold' => $failThreshold, 'failThreshold' => $failThreshold,
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
'groupedBy' => 'sessionKey',
]);
SplitTicketSyncLogger::log('cron', 'scan start', [
'candidateCount' => $candidateCount,
'maxPerRun' => $maxPerRun,
'autoSyncTypes' => $autoSyncTypes,
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
]); ]);
/** @var list<array<string, mixed>> $processed */ $directResult = $this->runDirectCronChannel($directTypes, $failThreshold);
$processed = []; $nodeResult = $this->runNodeCronChannel($nodeTypes, $failThreshold);
/** @var list<array<string, mixed>> $skipped */
$skipped = [];
$count = 0;
$stoppedByNodeBusy = false;
foreach ($list as $index => $ticket) { $processedCount = $directResult['processedCount'] + $nodeResult['processedCount'];
$ticketId = (int) $ticket['id']; $processed = array_merge($directResult['processed'], $nodeResult['processed']);
$ticketUrl = (string) $ticket['ticket_url']; $skipped = array_merge($directResult['skipped'], $nodeResult['skipped']);
$ticketType = (string) $ticket['ticket_type']; $candidateIds = array_merge($directResult['candidateIds'], $nodeResult['candidateIds']);
$sessionKey = AntiBotConfigBuilder::resolveSessionKey($ticketType, $ticketUrl);
if ($count >= $maxPerRun) {
$reason = '本轮处理上限已满';
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason);
continue;
}
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skip);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip);
continue;
}
if (!SplitNodeHealthService::canAcceptWork()) {
$reason = 'Node 队列繁忙,停止本轮后续同步';
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason);
$stoppedByNodeBusy = true;
for ($i = $index + 1; $i < $candidateCount; $i++) {
$remain = $list[$i];
$remainReason = 'Node 队列繁忙,本轮未执行';
$skipped[] = $this->buildCronTicketEntry(
(int) $remain['id'],
(string) $remain['ticket_type'],
(string) $remain['ticket_url'],
$remainReason
);
}
SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [
'processed' => $count,
'queue' => SplitNodeHealthService::getQueueSnapshot(),
]);
break;
}
$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);
continue;
}
$processed[] = [
'ticketId' => $ticketId,
'ticketType' => $ticketType,
'ticketUrl' => $ticketUrl,
'sessionKey' => $sessionKey,
'success' => !empty($result['success']),
'message' => (string) ($result['message'] ?? ''),
];
$count++;
}
$candidateIds = array_map(static function ($row) {
return (int) (is_array($row) ? ($row['id'] ?? 0) : ($row['id'] ?? 0));
}, $list);
$openNotSynced = $this->buildOpenNotSyncedAudit( $openNotSynced = $this->buildOpenNotSyncedAudit(
$autoSyncTypes, $autoSyncTypes,
$failThreshold, $failThreshold,
$maxPerRun, SplitSyncConfigService::getMaxTicketsPerCronRun(),
$candidateIds, $candidateIds,
$processed, $processed,
$skipped, $skipped,
$stoppedByNodeBusy $nodeResult['stoppedByNodeBusy']
); );
$successCount = count(array_filter($processed, static function (array $row): bool { $successCount = count(array_filter($processed, static function (array $row): bool {
return !empty($row['success']); return !empty($row['dispatched']);
})); }));
$failedCount = count($processed) - $successCount; $failedCount = count($processed) - $successCount;
SplitTicketSyncLogger::logCron('scan end', [ SplitTicketSyncLogger::logCron('scan end', [
'processedCount' => $count, 'processedCount' => $processedCount,
'successCount' => $successCount, 'successCount' => $successCount,
'failedCount' => $failedCount, 'failedCount' => $failedCount,
'skippedCount' => count($skipped), 'skippedCount' => count($skipped),
'directChannel' => $directResult['summary'],
'nodeChannel' => $nodeResult['summary'],
'processed' => $processed, 'processed' => $processed,
'skipped' => $skipped, 'skipped' => $skipped,
'openNotSynced' => $openNotSynced, 'openNotSynced' => $openNotSynced,
]); ]);
SplitTicketSyncLogger::log('cron', 'scan end', [ SplitTicketSyncLogger::log('cron', 'scan end', [
'processedCount' => $count, 'processedCount' => $processedCount,
'successCount' => $successCount, 'successCount' => $successCount,
'failedCount' => $failedCount, 'failedCount' => $failedCount,
]); ]);
return $count; return $processedCount;
}
/**
* 直连 PHP 通道:挑选到期工单并投递后台 CLI,不占 Node 名额
*
* @param list<string> $directTypes
* @return array{
* processedCount:int,
* processed:list<array<string,mixed>>,
* skipped:list<array<string,mixed>>,
* candidateIds:list<int>,
* summary:array<string,mixed>
* }
*/
private function runDirectCronChannel(array $directTypes, int $failThreshold): array
{
if ($directTypes === []) {
return $this->emptyCronChannelResult('direct');
}
$list = $this->loadOpenTicketsForTypes($directTypes, $failThreshold, 0);
$candidateIds = $this->extractTicketIds($list);
/** @var list<array<string,mixed>> $dueList */
$dueList = [];
/** @var list<array<string,mixed>> $skipped */
$skipped = [];
foreach ($list as $ticket) {
$ticketId = (int) $ticket['id'];
$ticketType = (string) $ticket['ticket_type'];
$ticketUrl = (string) $ticket['ticket_url'];
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skip);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip, 'direct');
continue;
}
$dueList[] = $ticket;
}
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<array<string,mixed>> $processed */
$processed = [];
foreach ($dispatchResult['queued'] as $row) {
$processed[] = [
'ticketId' => (int) ($row['ticketId'] ?? 0),
'ticketType' => (string) ($row['ticketType'] ?? ''),
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
'channel' => 'direct',
'dispatched' => true,
'message' => '已投递后台同步',
];
}
return [
'processedCount' => count($processed),
'processed' => $processed,
'skipped' => $skipped,
'candidateIds' => $candidateIds,
'summary' => [
'channel' => 'direct',
'processedCount' => count($processed),
'skippedCount' => count($skipped),
'dispatchMode' => 'async-cli',
],
'stoppedByNodeBusy' => false,
];
}
/**
* Node 通道:限流 + 类型公平轮转 + 异步 CLI 投递
*
* @param list<string> $nodeTypes
* @return array{
* processedCount:int,
* processed:list<array<string,mixed>>,
* skipped:list<array<string,mixed>>,
* candidateIds:list<int>,
* summary:array<string,mixed>,
* stoppedByNodeBusy:bool
* }
*/
private function runNodeCronChannel(array $nodeTypes, int $failThreshold): array
{
if ($nodeTypes === []) {
return $this->emptyCronChannelResult('node');
}
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
$poolMultiplier = SplitSyncConfigService::getNodeCandidatePoolMultiplier();
$candidateLimit = $maxPerRun * $poolMultiplier;
$pool = $this->loadOpenTicketsForTypes($nodeTypes, $failThreshold, $candidateLimit);
$candidateIds = $this->extractTicketIds($pool);
$duePool = [];
foreach ($pool as $ticket) {
if ($this->shouldSkip($ticket) === null) {
$duePool[] = $ticket;
}
}
$picked = $this->pickFairNodeTickets($duePool, $maxPerRun);
$picked = $this->sortTicketsBySessionKey($picked);
$pickedIdMap = array_fill_keys($this->extractTicketIds($picked), true);
SplitTicketSyncLogger::logCron('node channel start', [
'types' => $nodeTypes,
'maxPerRun' => $maxPerRun,
'poolCount' => count($pool),
'dueCount' => count($duePool),
'pickedCount' => count($picked),
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
'dispatchMode' => 'async-cli',
]);
/** @var list<array<string,mixed>> $skipped */
$skipped = [];
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)
);
}
$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<array<string,mixed>> $processed */
$processed = [];
foreach ($dispatchResult['queued'] as $row) {
$processed[] = [
'ticketId' => (int) ($row['ticketId'] ?? 0),
'ticketType' => (string) ($row['ticketType'] ?? ''),
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
'sessionKey' => (string) ($row['sessionKey'] ?? ''),
'channel' => 'node',
'dispatched' => true,
'message' => '已投递后台同步',
];
}
return [
'processedCount' => count($processed),
'processed' => $processed,
'skipped' => $skipped,
'candidateIds' => $candidateIds,
'summary' => [
'channel' => 'node',
'processedCount' => count($processed),
'skippedCount' => count($skipped),
'maxPerRun' => $maxPerRun,
'dispatchMode' => 'async-cli',
],
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
];
}
/**
* @param list<array<string,mixed>> $rows
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
*/
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<array<string,mixed>> $rows
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
*/
private function mapDispatchFailedToCron(array $rows): array
{
return $this->mapDispatchSkippedToCron($rows);
}
/**
* @param list<string> $types
* @return list<Ticket>
*/
private function loadOpenTicketsForTypes(array $types, int $failThreshold, int $limit): array
{
if ($types === []) {
return [];
}
$query = Ticket::where('status', 'normal')
->where('manual_manage', 0)
->whereIn('ticket_type', $types);
if ($failThreshold > 0) {
$query->where(function ($query) use ($failThreshold): void {
$query->where('sync_fail_count', '<', $failThreshold)
->whereOr(function ($query): void {
$query->where('sync_fail_pause_at', '>', 0)
->where('sync_auto_sync_stopped', 0);
});
});
}
$query->order('sync_time', 'asc')->order('id', 'asc');
if ($limit > 0) {
$query->limit($limit);
}
$list = $query->select();
return $list instanceof \think\Collection ? $list->all() : (array) $list;
}
/**
* Node 通道:按工单类型公平轮转选取(每轮每类型优先取 1 条)
*
* @param list<Ticket> $dueTickets
* @return list<Ticket>
*/
private function pickFairNodeTickets(array $dueTickets, int $maxPerRun): array
{
if ($dueTickets === [] || $maxPerRun <= 0) {
return [];
}
/** @var array<string, list<Ticket>> $byType */
$byType = [];
foreach ($dueTickets as $ticket) {
$type = (string) $ticket['ticket_type'];
$byType[$type][] = $ticket;
}
$typeKeys = array_keys($byType);
sort($typeKeys, SORT_STRING);
/** @var list<Ticket> $picked */
$picked = [];
while (count($picked) < $maxPerRun) {
$added = false;
foreach ($typeKeys as $type) {
if (count($picked) >= $maxPerRun) {
break;
}
if ($byType[$type] === []) {
continue;
}
$picked[] = array_shift($byType[$type]);
$added = true;
}
if (!$added) {
break;
}
}
return $picked;
}
/**
* @param list<Ticket|array<string,mixed>> $list
* @return list<int>
*/
private function extractTicketIds(array $list): array
{
$ids = [];
foreach ($list as $row) {
$ids[] = (int) (is_array($row) ? ($row['id'] ?? 0) : ($row['id'] ?? 0));
}
return $ids;
}
/**
* @return array{
* processedCount:int,
* processed:list<array<string,mixed>>,
* skipped:list<array<string,mixed>>,
* candidateIds:list<int>,
* summary:array<string,mixed>,
* stoppedByNodeBusy:bool
* }
*/
private function emptyCronChannelResult(string $channel): array
{
return [
'processedCount' => 0,
'processed' => [],
'skipped' => [],
'candidateIds' => [],
'summary' => ['channel' => $channel, 'processedCount' => 0, 'skippedCount' => 0],
'stoppedByNodeBusy' => false,
];
} }
/** /**
@@ -251,6 +521,15 @@ class SplitTicketSyncService
*/ */
private function doSync(Ticket $ticket, string $syncMode): array private function doSync(Ticket $ticket, string $syncMode): array
{ {
$freshTicket = Ticket::get((int) $ticket['id']);
if (!$freshTicket) {
return ['success' => false, 'message' => '工单不存在', 'skipped' => true];
}
if ($this->ruleService->isManuallyClosed($freshTicket)) {
return ['success' => false, 'message' => '工单已手动关闭,暂停同步', 'skipped' => true];
}
$ticket = $freshTicket;
$ticketType = (string) $ticket['ticket_type']; $ticketType = (string) $ticket['ticket_type'];
$pageUrl = trim((string) $ticket['ticket_url']); $pageUrl = trim((string) $ticket['ticket_url']);
if ($pageUrl === '') { if ($pageUrl === '') {
@@ -296,6 +575,8 @@ class SplitTicketSyncService
$freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket; $freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket;
if ((string) $freshTicket['status'] === 'hidden') { if ((string) $freshTicket['status'] === 'hidden') {
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket); $this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
} else {
$this->ruleService->applyNumberRules($freshTicket);
} }
$ticket = $freshTicket; $ticket = $freshTicket;
@@ -335,9 +616,33 @@ class SplitTicketSyncService
private function shouldSkip(Ticket $ticket): ?string private function shouldSkip(Ticket $ticket): ?string
{ {
if ($this->ruleService->isManuallyClosed($ticket)) {
return '工单已手动关闭,暂停同步';
}
if ((string) $ticket['status'] === 'hidden') { if ((string) $ticket['status'] === 'hidden') {
return '工单已关闭'; return '工单已关闭';
} }
if ($this->failRetryService->isPermanentlyStopped($ticket)) {
return '自动同步已终止(重试已用尽)';
}
if ($this->failRetryService->isInRetryMode($ticket)) {
$retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket);
if ($retrySkip !== null) {
return $retrySkip;
}
if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) {
return '工单类型尚未实现';
}
$interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']);
if ($interval <= 0) {
return '该类型未配置自动同步周期';
}
return null;
}
$failThreshold = SplitSyncConfigService::getFailPauseThreshold(); $failThreshold = SplitSyncConfigService::getFailPauseThreshold();
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) { if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
return sprintf('连续同步失败超过%d次已暂停', $failThreshold); return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
@@ -386,6 +691,7 @@ class SplitTicketSyncService
if ($success) { if ($success) {
$data['sync_success_time'] = $now; $data['sync_success_time'] = $now;
$data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual'; $data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual';
$data = array_merge($data, $this->failRetryService->clearOnSuccessFields());
} }
if (!$ticket->allowField(array_keys($data))->save($data)) { if (!$ticket->allowField(array_keys($data))->save($data)) {
throw new Exception('工单同步结果保存失败'); throw new Exception('工单同步结果保存失败');
@@ -404,6 +710,21 @@ class SplitTicketSyncService
'sync_message' => $message, 'sync_message' => $message,
'sync_fail_count' => $failCount, 'sync_fail_count' => $failCount,
]; ];
if ($this->failRetryService->isInRetryMode($ticket)) {
$update = array_merge($update, $this->failRetryService->onRetryFailure($ticket));
$ticket->save($update);
return;
}
if ($failThreshold > 0 && $failCount >= $failThreshold && SplitSyncConfigService::hasFailRetrySchedule()) {
$update = array_merge($update, $this->failRetryService->enterRetryIfNeeded($ticket, $failCount, $failThreshold));
$ticket->save($update);
return;
}
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) { if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
$update['status'] = 'hidden'; $update['status'] = 'hidden';
} }
@@ -466,13 +787,14 @@ class SplitTicketSyncService
]; ];
} }
private function logCronCandidateSkipped(int $ticketId, string $ticketType, string $ticketUrl, string $reason): void private function logCronCandidateSkipped(int $ticketId, string $ticketType, string $ticketUrl, string $reason, string $channel = 'legacy'): void
{ {
$ctx = [ $ctx = [
'ticketId' => $ticketId, 'ticketId' => $ticketId,
'ticketType' => $ticketType, 'ticketType' => $ticketType,
'ticketUrl' => $ticketUrl, 'ticketUrl' => $ticketUrl,
'reason' => $reason, 'reason' => $reason,
'channel' => $channel,
]; ];
SplitTicketSyncLogger::logCron('candidate skipped', $ctx); SplitTicketSyncLogger::logCron('candidate skipped', $ctx);
SplitTicketSyncLogger::log('cron', 'candidate skipped', $ctx); SplitTicketSyncLogger::log('cron', 'candidate skipped', $ctx);
@@ -511,18 +833,19 @@ class SplitTicketSyncService
$openList = Ticket::where('status', 'normal') $openList = Ticket::where('status', 'normal')
->whereIn('ticket_type', $supportedTypes) ->whereIn('ticket_type', $supportedTypes)
->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status') ->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status,sync_fail_pause_at,sync_fail_retry_index,sync_auto_sync_stopped,status,manual_manage')
->select(); ->select();
$result = []; $result = [];
foreach ($openList as $ticket) { foreach ($openList as $ticketRow) {
$ticketId = (int) $ticket['id']; $ticket = $ticketRow instanceof Ticket ? $ticketRow->toArray() : (array) $ticketRow;
if (isset($handledIds[$ticketId])) { $ticketId = (int) ($ticket['id'] ?? 0);
if ($ticketId <= 0 || isset($handledIds[$ticketId])) {
continue; continue;
} }
$ticketType = (string) $ticket['ticket_type']; $ticketType = (string) ($ticket['ticket_type'] ?? '');
$ticketUrl = (string) $ticket['ticket_url']; $ticketUrl = (string) ($ticket['ticket_url'] ?? '');
$reason = $this->resolveOpenNotSyncedReason( $reason = $this->resolveOpenNotSyncedReason(
$ticket, $ticket,
$autoSyncTypes, $autoSyncTypes,
@@ -561,14 +884,29 @@ class SplitTicketSyncService
$ticketType = (string) ($ticket['ticket_type'] ?? ''); $ticketType = (string) ($ticket['ticket_type'] ?? '');
$failCount = (int) ($ticket['sync_fail_count'] ?? 0); $failCount = (int) ($ticket['sync_fail_count'] ?? 0);
if ($failThreshold > 0 && $failCount >= $failThreshold) { if ($this->failRetryService->isPermanentlyStopped($ticket)) {
return '自动同步已终止(重试已用尽)';
}
if ($this->failRetryService->isInRetryMode($ticket)) {
$retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket);
if ($retrySkip !== null) {
return $retrySkip;
}
}
if ($failThreshold > 0 && $failCount >= $failThreshold && !SplitSyncConfigService::hasFailRetrySchedule()) {
return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold); return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold);
} }
if (!in_array($ticketType, $autoSyncTypes, true)) { if (!in_array($ticketType, $autoSyncTypes, true)) {
return '该类型未配置自动同步周期'; return '该类型未配置自动同步周期';
} }
if (!isset($candidateIdMap[(int) ($ticket['id'] ?? 0)])) { if (!isset($candidateIdMap[(int) ($ticket['id'] ?? 0)])) {
return sprintf('未进入本轮候选队列(仅扫描最久未同步的前%d条)', $maxPerRun * 5); if (SplitScrmSpiderFactory::requiresNode($ticketType)) {
$poolSize = SplitSyncConfigService::getMaxTicketsPerCronRun()
* SplitSyncConfigService::getNodeCandidatePoolMultiplier();
return sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $poolSize);
}
return '直连通道:未到同步周期或本轮已处理';
} }
if ($stoppedByNodeBusy) { if ($stoppedByNodeBusy) {
return 'Node 队列繁忙,本轮未执行'; return 'Node 队列繁忙,本轮未执行';
+67
View File
@@ -0,0 +1,67 @@
<?php
return array (
'name' => 'link管理系统',
'beian' => '',
'cdnurl' => '',
'version' => '1.0.10',
'timezone' => 'Asia/Shanghai',
'forbiddenip' => '',
'languages' =>
array (
'backend' => 'zh-cn',
'frontend' => 'zh-cn',
),
'fixedpage' => 'dashboard',
'categorytype' =>
array (
'default' => 'Default',
'page' => 'Page',
'article' => 'Article',
'test' => 'Test',
),
'configgroup' =>
array (
'basic' => 'Basic',
'email' => 'Email',
'dictionary' => 'Dictionary',
'user' => 'User',
'example' => 'Example',
'split' => '分流设置',
),
'mail_type' => '1',
'mail_smtp_host' => 'smtp.qq.com',
'mail_smtp_port' => '465',
'mail_smtp_user' => '',
'mail_smtp_pass' => '',
'mail_verify_type' => '2',
'mail_from' => '',
'attachmentcategory' =>
array (
'category1' => 'Category1',
'category2' => 'Category2',
'custom' => 'Custom',
),
'split_platform_domain' => 'flowerbells.top',
'split_scrm_node_host' => 'http://127.0.0.1:3001',
'split_sync_interval_a2c' => '3',
'split_sync_interval_haiwang' => '3',
'split_sync_interval_huojian' => '3',
'split_sync_interval_xinghe' => '3',
'split_sync_interval_ss_customer' => '3',
'split_sync_interval_ceo_scrm' => '0',
'split_sync_interval_taiji' => '0',
'split_sync_interval_ss_channel' => '0',
'split_sync_interval_yifafa' => '0',
'split_sync_interval_whatshub' => '3',
'split_sync_interval_sihai' => '0',
'split_sync_fail_pause_threshold' => '6',
'split_sync_fail_retry_minutes' => '',
'split_sync_interval_chatknow' => '5',
'split_sync_max_per_cron' => '27',
'split_sync_node_candidate_multiplier' => '13',
'split_sync_cron_lock_ttl' => '680',
'split_sync_node_queue_threshold' => '8',
'split_scrm_antibot_types' => 'whatshub,chatknow,a2c,huojian',
'split_scrm_session_ttl_minutes' => '120',
);
@@ -25,6 +25,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
fixedColumns: true, fixedColumns: true,
fixedRightNumber: 1, fixedRightNumber: 1,
searchFormVisible: true, searchFormVisible: true,
pageSize: 10,
pageList: Controller.api.streamLoad.PAGE_LIST,
queryParams: function (params) {
return Controller.api.streamLoad.wrapQueryParams(table, params);
},
columns: [ columns: [
[ [
{checkbox: true}, {checkbox: true},
@@ -37,7 +42,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
formatter: Table.api.formatter.content formatter: Table.api.formatter.content
}, },
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'}, {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( $.extend(
{field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal}, {field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal},
searchSelectMeta(false) searchSelectMeta(false)
@@ -52,6 +62,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'}, {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
searchSelectMeta(false) searchSelectMeta(false)
), ),
$.extend(
{
field: 'ratio_deferred',
title: __('Ratio_deferred'),
visible: false,
searchable: true,
operate: '=',
searchList: Config.ratioDeferredList || {}
},
searchSelectMeta(false)
),
{ {
field: 'createtime', field: 'createtime',
title: __('Createtime'), title: __('Createtime'),
@@ -97,11 +118,18 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
Controller.api.openBatchOperateModal(table); 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) { table.on('post-common-search.bs.table', function (e, tbl) {
Controller.api.initCommonSearchSelectpicker(tbl); Controller.api.initCommonSearchSelectpicker(tbl);
}); });
Table.api.bindevent(table); Table.api.bindevent(table);
Controller.api.streamLoad.bind(table);
Controller.api.bindDeferredFilter(table);
Controller.api.bindDeferredCleanup(table);
}, },
add: function () { add: function () {
Controller.api.bindevent(); Controller.api.bindevent();
@@ -145,6 +173,504 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
} }
}, },
/**
* 分页选「全部」时改为分块滚动加载,避免一次渲染过多 DOM 导致卡顿
*/
streamLoad: {
CHUNK: 50,
SCROLL_THRESHOLD: 80,
LOAD_DELAY: 1500,
PAGE_LIST: [10, 30, 50, 100, 200, 300, 500, 'All'],
createState: function () {
return {
enabled: false,
loading: false,
pendingLoad: false,
loadTimer: null,
loaded: 0,
total: 0,
chunk: 50,
pendingReset: false,
pendingAllSelect: false,
appendQuery: false
};
},
getState: function (table) {
var state = table.data('splitStreamLoad');
if (!state) {
state = Controller.api.streamLoad.createState();
table.data('splitStreamLoad', state);
}
return state;
},
getAllLabel: function () {
var localeKey = (typeof Config !== 'undefined' && Config.language === 'zh-cn') ? 'zh-CN' : 'en-US';
var locale = $.fn.bootstrapTable.locales && $.fn.bootstrapTable.locales[localeKey];
if (locale && typeof locale.formatAllRows === 'function') {
return locale.formatAllRows();
}
if ($.fn.bootstrapTable.defaults && typeof $.fn.bootstrapTable.defaults.formatAllRows === 'function') {
return $.fn.bootstrapTable.defaults.formatAllRows();
}
return 'All';
},
/**
* initServer 首次请求时实例尚未写入 data('bootstrap.table')getOptions 会退回 jQuery 对象
*/
safeGetOptions: function (table) {
if (!table || !table.length) {
return null;
}
var opts = table.bootstrapTable('getOptions');
if (opts && typeof opts.formatAllRows === 'function') {
return opts;
}
var instance = table.data('bootstrap.table');
if (instance && instance.options && typeof instance.options.formatAllRows === 'function') {
return instance.options;
}
return null;
},
getDefaultPageList: function () {
return Controller.api.streamLoad.PAGE_LIST;
},
getMaxNormalPageSize: function (pageList) {
var api = Controller.api.streamLoad;
var maxNormal = 0;
$.each(pageList || api.getDefaultPageList(), function (i, value) {
if (api.isAllPageSize(value)) {
return;
}
var num = parseInt(value, 10);
if (!isNaN(num) && num > maxNormal) {
maxNormal = num;
}
});
return maxNormal;
},
isAllPageSize: function (pageSize) {
if (pageSize === undefined || pageSize === null || pageSize === '') {
return false;
}
return String(pageSize).toUpperCase() === String(Controller.api.streamLoad.getAllLabel()).toUpperCase();
},
isLargeStoredPageSize: function (table, params) {
var api = Controller.api.streamLoad;
var options = api.safeGetOptions(table);
var pageSize = options ? options.pageSize : null;
var limit = params ? (parseInt(params.limit, 10) || 0) : 0;
if (pageSize !== null && api.isAllPageSize(pageSize)) {
return true;
}
var maxNormal = api.getMaxNormalPageSize(options ? options.pageList : null);
if (typeof pageSize === 'number' && pageSize > maxNormal) {
return true;
}
if (!options && limit > maxNormal) {
return true;
}
return false;
},
shouldEnable: function (table, params) {
var api = Controller.api.streamLoad;
var state = api.getState(table);
if (state.pendingAllSelect || state.enabled) {
return true;
}
var options = api.safeGetOptions(table);
var limit = parseInt(params.limit, 10) || 0;
if (options) {
var totalRows = parseInt(options.totalRows, 10) || 0;
if (api.isAllPageSize(options.pageSize)) {
return true;
}
if (api.isLargeStoredPageSize(table, params)) {
return true;
}
return totalRows > 0 && limit >= totalRows;
}
// 表格构造阶段:All 模式 totalRows 尚未就绪时 limit 可能为 0
if (limit === 0) {
var stored = localStorage.getItem('pagesize');
if (stored && api.isAllPageSize(stored)) {
return true;
}
}
return api.isLargeStoredPageSize(table, params);
},
enable: function (table) {
var state = Controller.api.streamLoad.getState(table);
state.enabled = true;
state.loaded = 0;
state.loading = false;
state.pendingReset = true;
},
disable: function (table) {
var api = Controller.api.streamLoad;
var state = api.getState(table);
api.cancelPendingLoad(table);
state.enabled = false;
state.loaded = 0;
state.total = 0;
state.loading = false;
state.pendingReset = false;
state.pendingAllSelect = false;
api.updateHint(table);
api.togglePaginationNav(table, false);
api.showBottomLoading(table, false);
},
wrapQueryParams: function (table, params) {
var api = Controller.api.streamLoad;
var state = api.getState(table);
if (api.shouldEnable(table, params)) {
state.enabled = true;
state.pendingAllSelect = false;
}
if (!state.enabled) {
return params;
}
params.limit = state.chunk;
if (state.appendQuery) {
params.offset = parseInt(params.offset, 10) || 0;
} else {
params.offset = 0;
state.pendingReset = true;
}
state.appendQuery = false;
return params;
},
bind: function (table) {
var api = Controller.api.streamLoad;
var $wrapper = table.closest('.bootstrap-table');
$wrapper.on('click.splitStream', '.page-list .dropdown-menu li a', function () {
api.cancelPendingLoad(table);
var label = $.trim($(this).text());
if (api.isAllPageSize(label)) {
api.getState(table).pendingAllSelect = true;
api.enable(table);
}
});
table.on('page-change.bs.table', function (e, pageNumber, pageSize) {
var options = api.safeGetOptions(table);
if (!options) {
return;
}
if (api.isAllPageSize(pageSize)
|| (typeof pageSize === 'number' && pageSize === options.totalRows && options.totalRows > 0)) {
api.enable(table);
return;
}
if (typeof pageSize === 'number' && pageSize < options.totalRows) {
api.disable(table);
}
});
table.on('refresh.bs.table search.bs.table common-search.bs.table', function () {
var state = api.getState(table);
api.cancelPendingLoad(table);
if (!state.enabled) {
return;
}
state.loaded = 0;
state.pendingReset = true;
});
table.on('sort.bs.table', function () {
var state = api.getState(table);
api.cancelPendingLoad(table);
if (!state.enabled) {
return;
}
state.loaded = 0;
state.pendingReset = true;
});
table.on('load-success.bs.table', function (e, data) {
var state = api.getState(table);
if (!state.enabled || !data || typeof data.total === 'undefined') {
return;
}
state.total = parseInt(data.total, 10) || 0;
if (state.pendingReset) {
state.loaded = $.isArray(data.rows) ? data.rows.length : 0;
state.pendingReset = false;
}
api.updateHint(table);
api.togglePaginationNav(table, true);
api.bindScrollContainer(table);
api.autoFillViewport(table);
var tableOptions = api.safeGetOptions(table);
if (tableOptions && tableOptions.fixedColumns) {
table.bootstrapTable('resetView');
}
});
table.on('post-body.bs.table', function () {
if (!api.getState(table).enabled) {
return;
}
api.updateHint(table);
});
},
bindScrollContainer: function (table) {
var api = Controller.api.streamLoad;
var $body = table.closest('.bootstrap-table').find('.fixed-table-body');
if (!$body.length || $body.data('splitStreamBound')) {
return;
}
$body.data('splitStreamBound', true);
$body.on('scroll.splitStream', function () {
api.onScroll(table, this);
});
},
onScroll: function (table, el) {
var api = Controller.api.streamLoad;
var state = api.getState(table);
if (!state.enabled || state.loading || state.loaded >= state.total) {
return;
}
var atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - api.SCROLL_THRESHOLD;
if (!atBottom) {
api.cancelPendingLoad(table);
return;
}
if (state.pendingLoad) {
return;
}
api.scheduleLoadMore(table);
},
isNearBottom: function (table) {
var api = Controller.api.streamLoad;
var $body = table.closest('.bootstrap-table').find('.fixed-table-body');
if (!$body.length) {
return false;
}
var el = $body[0];
return el.scrollTop + el.clientHeight >= el.scrollHeight - api.SCROLL_THRESHOLD;
},
scheduleLoadMore: function (table) {
var api = Controller.api.streamLoad;
var state = api.getState(table);
if (state.loading || state.pendingLoad || !state.enabled || state.loaded >= state.total) {
return;
}
state.pendingLoad = true;
api.showBottomLoading(table, true);
api.updateHint(table, false, true);
state.loadTimer = setTimeout(function () {
state.pendingLoad = false;
state.loadTimer = null;
if (!state.enabled) {
api.showBottomLoading(table, false);
api.updateHint(table);
return;
}
if (!api.isNearBottom(table)) {
api.showBottomLoading(table, false);
api.updateHint(table);
return;
}
api.loadMore(table);
}, api.LOAD_DELAY);
},
cancelPendingLoad: function (table) {
var api = Controller.api.streamLoad;
var state = api.getState(table);
if (state.loadTimer) {
clearTimeout(state.loadTimer);
state.loadTimer = null;
}
state.pendingLoad = false;
if (!state.loading) {
api.showBottomLoading(table, false);
api.updateHint(table);
}
},
showBottomLoading: function (table, show) {
var $body = table.closest('.bootstrap-table').find('.fixed-table-body');
if (!$body.length) {
return;
}
var $bar = $body.children('.split-stream-loading-bar');
if (show) {
if (!$bar.length) {
var loadingText = __('Stream loading bottom');
if (!loadingText || loadingText === 'Stream loading bottom') {
loadingText = '加载中...';
}
$bar = $('<div class="split-stream-loading-bar text-center" style="padding:14px 0;color:#999;border-top:1px solid #f0f0f0;background:#fafafa;">'
+ '<i class="fa fa-spinner fa-spin"></i> ' + Fast.api.escape(loadingText)
+ '</div>');
$body.append($bar);
}
$bar.show();
} else if ($bar.length) {
$bar.hide();
}
},
buildRequestParams: function (table, offset, limit) {
var options = Controller.api.streamLoad.safeGetOptions(table);
if (!options) {
return {offset: offset, limit: limit};
}
var state = Controller.api.streamLoad.getState(table);
state.appendQuery = true;
return options.queryParams({
search: options.searchText,
sort: options.sortName,
order: options.sortOrder,
offset: offset,
limit: limit
});
},
loadMore: function (table) {
var api = Controller.api.streamLoad;
var state = api.getState(table);
if (state.loading || !state.enabled || state.loaded >= state.total) {
return;
}
state.loading = true;
api.updateHint(table, true);
var options = api.safeGetOptions(table);
if (!options) {
state.loading = false;
return;
}
$.ajax({
url: options.url,
type: options.method,
data: api.buildRequestParams(table, state.loaded, state.chunk),
dataType: 'json'
}).done(function (res) {
state.loading = false;
api.showBottomLoading(table, false);
if (!res || typeof res.rows === 'undefined') {
Toastr.error(__('Unknown data format'));
api.updateHint(table);
return;
}
var rows = res.rows || [];
if (rows.length === 0) {
state.loaded = state.total;
api.updateHint(table);
return;
}
table.bootstrapTable('append', rows);
state.loaded += rows.length;
api.updateHint(table);
api.autoFillViewport(table);
if (options.fixedColumns) {
table.bootstrapTable('resetView');
}
}).fail(function () {
state.loading = false;
api.showBottomLoading(table, false);
api.updateHint(table);
Toastr.error(__('Unknown data format'));
});
},
autoFillViewport: function (table) {
var api = Controller.api.streamLoad;
var state = api.getState(table);
if (!state.enabled || state.loading || state.pendingLoad || state.loaded >= state.total) {
return;
}
var $body = table.closest('.bootstrap-table').find('.fixed-table-body');
if (!$body.length) {
return;
}
var el = $body[0];
if (el.scrollHeight <= el.clientHeight + api.SCROLL_THRESHOLD) {
api.scheduleLoadMore(table);
}
},
togglePaginationNav: function (table, hide) {
table.closest('.bootstrap-table').find('.fixed-table-pagination .pagination').toggle(!hide);
},
getHintText: function (loaded, total, loading, pending) {
if (pending) {
var pendingText = __('Stream loading pending');
if (!pendingText || pendingText === 'Stream loading pending') {
pendingText = '即将加载更多,可切换显示条数取消...';
}
return pendingText;
}
if (loading) {
var loadingText = __('Stream loading');
return loadingText && loadingText !== 'Stream loading' ? loadingText : '加载中...';
}
var template = __('Stream loaded');
if (!template || template === 'Stream loaded') {
template = '已加载 %loaded% / %total%,继续滚动加载更多';
}
return template
.replace('%loaded%', String(loaded))
.replace('%total%', String(total));
},
updateHint: function (table, loading, pending) {
var api = Controller.api.streamLoad;
var state = api.getState(table);
var $wrapper = table.closest('.bootstrap-table');
var $detail = $wrapper.find('.pagination-detail');
if (!state.enabled) {
$wrapper.find('.split-stream-hint').remove();
return;
}
var $hint = $wrapper.find('.split-stream-hint');
if (!$hint.length) {
$hint = $('<span class="split-stream-hint text-muted" style="margin-left:10px;font-size:12px;"></span>');
$detail.append($hint);
}
var isPending = pending !== undefined ? pending : state.pendingLoad;
var isLoading = loading !== undefined ? loading : state.loading;
$hint.text(api.getHintText(state.loaded, state.total, isLoading, isPending));
}
},
formatter: { formatter: {
platformStatus: function (value, row) { platformStatus: function (value, row) {
var text = row.platform_status_text != null && row.platform_status_text !== '' var text = row.platform_status_text != null && row.platform_status_text !== ''
@@ -152,8 +678,152 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
: (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value); : (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value);
var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning'); var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning');
return '<span class="text-' + cls + '">' + Fast.api.escape(text || '') + '</span>'; return '<span class="text-' + cls + '">' + Fast.api.escape(text || '') + '</span>';
},
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 '<span class="label label-warning split-ratio-deferred-badge" style="margin-left:6px;font-size:11px;font-weight:500;vertical-align:middle;"'
+ ' data-toggle="tooltip" data-placement="top" title="' + Fast.api.escape(tooltip) + '">'
+ '<i class="fa fa-level-down"></i> ' + Fast.api.escape(label)
+ '</span>';
},
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 () { bindevent: function () {
Form.api.bindevent($('form[role=form]')); Form.api.bindevent($('form[role=form]'));
Controller.api.fixSelectPlaceholder(); Controller.api.fixSelectPlaceholder();
@@ -88,10 +88,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
formatter: Controller.api.formatter.speedPerHour formatter: Controller.api.formatter.speedPerHour
}, },
{ {
field: 'number_count', field: 'active_number_count',
title: __('Number_count'), title: __('Number_count'),
operate: false, operate: false
formatter: Controller.api.formatter.numberCount
}, },
{ {
field: 'sync_display_text', field: 'sync_display_text',
@@ -244,6 +243,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
/** @type {number[]} 正在手动同步的工单 ID */ /** @type {number[]} 正在手动同步的工单 ID */
syncingTicketIds: [], syncingTicketIds: [],
syncPollTimer: null,
syncPollStartedAt: 0,
syncPollGraceMs: 8000,
/** /**
* 渲染筛选结果汇总行(全量筛选数据,非当前页) * 渲染筛选结果汇总行(全量筛选数据,非当前页)
@@ -269,7 +271,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
}, },
/** /**
* 后台同步:标记「同步中」并请求 sync 接口 * 后台同步:标记「同步中」、投递 CLI,并轮询直至锁释放
* *
* @param {object} table bootstrapTable 实例 * @param {object} table bootstrapTable 实例
* @param {number[]} ids 工单 ID * @param {number[]} ids 工单 ID
@@ -292,13 +294,81 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
url: 'split.ticket/sync', url: 'split.ticket/sync',
data: {ids: ids.join(',')}, data: {ids: ids.join(',')},
loading: false loading: false
}, function () { }, function (data, ret) {
Controller.api.finishTicketsSync(table); var queued = (ret.data && ret.data.queued) ? ret.data.queued : ids;
Controller.api.syncingTicketIds = (queued || []).map(function (id) {
return parseInt(id, 10);
}).filter(function (id) {
return !isNaN(id) && id > 0;
});
if (Controller.api.syncingTicketIds.length) {
Controller.api.startSyncPolling(table);
} else {
Controller.api.finishTicketsSync(table);
}
}, function () { }, function () {
Controller.api.finishTicketsSync(table); Controller.api.finishTicketsSync(table);
}); });
}, },
/**
* 轮询同步锁状态,全部完成后刷新列表
*/
startSyncPolling: function (table) {
Controller.api.stopSyncPolling();
if (!Controller.api.syncingTicketIds.length) {
return;
}
Controller.api.syncPollStartedAt = Date.now();
Controller.api.syncPollTimer = setInterval(function () {
if (!Controller.api.syncingTicketIds.length) {
Controller.api.stopSyncPolling();
return;
}
Fast.api.ajax({
url: 'split.ticket/syncpolling',
data: {ids: Controller.api.syncingTicketIds.join(',')},
loading: false
}, function (data, ret) {
var still = (ret.data && ret.data.syncing) ? ret.data.syncing : [];
still = (still || []).map(function (id) {
return parseInt(id, 10);
}).filter(function (id) {
return !isNaN(id) && id > 0;
});
var elapsed = Date.now() - (Controller.api.syncPollStartedAt || 0);
var graceMs = Controller.api.syncPollGraceMs || 8000;
if (still.length === 0 && elapsed < graceMs) {
table.bootstrapTable('refresh', {silent: true});
return false;
}
if (still.length === 0) {
Controller.api.stopSyncPolling();
var doneMsg = (typeof Config.syncDoneMsg !== 'undefined' && Config.syncDoneMsg)
? Config.syncDoneMsg
: '同步完成';
Toastr.success(doneMsg);
Controller.api.finishTicketsSync(table);
return false;
}
if (still.join(',') !== Controller.api.syncingTicketIds.join(',')) {
Controller.api.syncingTicketIds = still;
var rowData = table.bootstrapTable('getData');
table.bootstrapTable('load', rowData);
}
table.bootstrapTable('refresh', {silent: true});
return false;
});
}, 3000);
},
stopSyncPolling: function () {
if (Controller.api.syncPollTimer) {
clearInterval(Controller.api.syncPollTimer);
Controller.api.syncPollTimer = null;
}
},
/** /**
* 将选中工单标记为「同步中」并刷新列表展示(不请求后端) * 将选中工单标记为「同步中」并刷新列表展示(不请求后端)
*/ */
@@ -316,6 +386,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
* 同步结束:清除标记并刷新列表数据 * 同步结束:清除标记并刷新列表数据
*/ */
finishTicketsSync: function (table) { finishTicketsSync: function (table) {
Controller.api.stopSyncPolling();
Controller.api.syncingTicketIds = []; Controller.api.syncingTicketIds = [];
table.bootstrapTable('refresh', { table.bootstrapTable('refresh', {
silent: true silent: true
@@ -414,16 +485,6 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
} }
return num.toFixed(2); return num.toFixed(2);
}, },
numberCount: function (value, row) {
var total = parseInt(value, 10) || 0;
var offline = parseInt(row.number_offline_count, 10) || 0;
var banned = parseInt(row.number_banned_count, 10) || 0;
var tip = __('Number_count_detail').replace('%s', offline).replace('%s', banned);
if (offline > 0 || banned > 0) {
return '<span data-toggle="tooltip" title="' + Fast.api.escape(tip) + '">' + total + '</span>';
}
return String(total);
},
syncDisplay: function (value, row) { syncDisplay: function (value, row) {
var rowId = parseInt(row.id, 10); var rowId = parseInt(row.id, 10);
if (Controller.api.syncingTicketIds.indexOf(rowId) !== -1) { if (Controller.api.syncingTicketIds.indexOf(rowId) !== -1) {
+38
View File
@@ -0,0 +1,38 @@
# puppeteer-api 环境配置示例
# 复制为 .env 后按实际环境修改;pm2 restart server --update-env
# ========== CapSolver(仅 A2C 等 Managed 过盾时 Node 调用 AntiCloudflareTask==========
CAPTCHA_PROVIDER=capsolver
CAPTCHA_API_KEY=
# 静态住宅代理 ip:port:user:passCapSolver 过盾必填
# Chrome 仅在「检测到 CF 挑战」时才会使用同一代理(见 server.js ensureCaptchaProxyBrowserIfNeeded
CAPTCHA_PROXY=
CAPTCHA_MAX_WAIT_MS=120000
# ========== Standard 槽位(未走 antiBot / profile=standard 的蜘蛛)==========
MAX_CONCURRENT_BROWSERS=4
# ========== Real Browser 槽位(whatshub / chatknow / huojian / a2c 共用)==========
# 20~30 工单后台批量:建议 2~3(非 A2C 不走代理,可与 A2C 并行)
MAX_CONCURRENT_BROWSERS_REAL=2
# ========== 排队(cron 一轮多工单时避免 503==========
QUEUE_TIMEOUT_MS=300000
# ========== 温池(后台批量强烈建议开启,按 sessionKey 复用 Browser==========
POOL_ENABLED=1
MAX_POOLED_BROWSERS=8
POOL_IDLE_MS=300000
# ========== 超时 ==========
NAVIGATION_TIMEOUT_MS=90000
API_INTERCEPT_TIMEOUT_MS=60000
# Real Browser + A2C 过盾(CapSolver + SPA)建议 180s
API_INTERCEPT_TIMEOUT_REAL_MS=180000
# ========== 会话复用(减少 A2C 重复过盾)==========
SESSION_TTL_MS=7200000
PERSIST_BROWSER_PROFILE=1
# ========== 服务端口 ==========
PORT=3001
+10 -8
View File
@@ -59,11 +59,12 @@ async function createBrowserSession(options = {}) {
page, page,
profile: 'real', profile: 'real',
cleanup: async (destroy = false) => { cleanup: async (destroy = false) => {
if (destroy || !sessionKey) { if (!destroy) {
try { return;
await browser.close();
} catch (_) { /* ignore */ }
} }
try {
await browser.close();
} catch (_) { /* ignore */ }
}, },
}; };
} }
@@ -74,11 +75,12 @@ async function createBrowserSession(options = {}) {
page: null, page: null,
profile: 'standard', profile: 'standard',
cleanup: async (destroy = false) => { cleanup: async (destroy = false) => {
if (destroy || !sessionKey) { if (!destroy) {
try { return;
await browser.close();
} catch (_) { /* ignore */ }
} }
try {
await browser.close();
} catch (_) { /* ignore */ }
}, },
}; };
} }
+89 -41
View File
@@ -1,12 +1,16 @@
/** /**
* Browser 温池:按 profile:sessionKey 复用 Browser/Page,降低冷启动与 CF 触发 * Browser 温池:按 profile:sessionKey 复用 Browser,降低冷启动与 CF 触发
*
* 同一 key 的 acquire 在 inUse 时排队等待,禁止销毁正在使用的 Browser(修复 A2C 并发误杀)。
*/ */
const { POOL_IDLE_MS, MAX_POOLED_BROWSERS, POOL_ENABLED } = require('./constants'); const { POOL_IDLE_MS, MAX_POOLED_BROWSERS, POOL_ENABLED, QUEUE_TIMEOUT_MS } = require('./constants');
class BrowserPool { class BrowserPool {
constructor() { constructor() {
/** @type {Map<string, { browser: any, page: any|null, profile: string, sessionKey: string, lastUsedAt: number, inUse: boolean }>} */ /** @type {Map<string, { browser: any, page: any|null, profile: string, sessionKey: string, lastUsedAt: number, inUse: boolean }>} */
this.entries = new Map(); this.entries = new Map();
/** @type {Map<string, Array<{ resolve: () => void, reject: (err: Error) => void, timer: NodeJS.Timeout|null }>>} */
this._waitQueues = new Map();
this.hits = 0; this.hits = 0;
this.misses = 0; this.misses = 0;
this.evictions = 0; this.evictions = 0;
@@ -14,17 +18,10 @@ class BrowserPool {
this._sweepTimer.unref?.(); this._sweepTimer.unref?.();
} }
/**
* @param {string} sessionKey
* @param {'standard'|'real'} profile
*/
buildKey(sessionKey, profile) { buildKey(sessionKey, profile) {
return `${profile}:${sessionKey}`; return `${profile}:${sessionKey}`;
} }
/**
* @param {any} browser
*/
async isBrowserAlive(browser) { async isBrowserAlive(browser) {
if (!browser) return false; if (!browser) return false;
try { try {
@@ -38,12 +35,13 @@ class BrowserPool {
} }
} }
/**
* @param {string} key
*/
async destroyEntry(key) { async destroyEntry(key) {
const entry = this.entries.get(key); const entry = this.entries.get(key);
if (!entry) return; if (!entry) return;
if (entry.inUse) {
console.warn(`[BrowserPool] 跳过销毁 inUse 条目 key=${key}`);
return;
}
this.entries.delete(key); this.entries.delete(key);
try { try {
await entry.browser.close(); await entry.browser.close();
@@ -63,11 +61,43 @@ class BrowserPool {
this.destroyEntry(oldestKey); this.destroyEntry(oldestKey);
} }
/** waitForRelease(key, timeoutMs = QUEUE_TIMEOUT_MS) {
* @param {string} sessionKey const entry = this.entries.get(key);
* @param {'standard'|'real'} profile if (!entry || !entry.inUse) {
* @param {() => Promise<{ browser: any, page: any|null, cleanup: (destroy?: boolean) => Promise<void> }>} launcher return Promise.resolve();
*/ }
return new Promise((resolve, reject) => {
const waiter = {
resolve: () => {
if (waiter.timer) clearTimeout(waiter.timer);
resolve();
},
reject,
timer: timeoutMs > 0
? setTimeout(() => {
const queue = this._waitQueues.get(key) || [];
const idx = queue.indexOf(waiter);
if (idx >= 0) queue.splice(idx, 1);
reject(new Error('POOL_ACQUIRE_TIMEOUT'));
}, timeoutMs)
: null,
};
if (!this._waitQueues.has(key)) {
this._waitQueues.set(key, []);
}
this._waitQueues.get(key).push(waiter);
console.log(`[BrowserPool] key=${key} 正在使用,排队等待 (队列 ${this._waitQueues.get(key).length})`);
});
}
_wakeNextWaiter(key) {
const queue = this._waitQueues.get(key);
if (!queue || queue.length === 0) return;
const next = queue.shift();
if (next?.timer) clearTimeout(next.timer);
next?.resolve();
}
async acquire(sessionKey, profile, launcher) { async acquire(sessionKey, profile, launcher) {
if (!POOL_ENABLED || !sessionKey) { if (!POOL_ENABLED || !sessionKey) {
const launched = await launcher(); const launched = await launcher();
@@ -77,29 +107,38 @@ class BrowserPool {
profile, profile,
pooled: false, pooled: false,
reused: false, reused: false,
release: async (destroy = false) => { release: async () => {
await launched.cleanup(destroy); await launched.cleanup(true);
}, },
}; };
} }
const key = this.buildKey(sessionKey, profile); const key = this.buildKey(sessionKey, profile);
const existing = this.entries.get(key);
if (existing && !existing.inUse && await this.isBrowserAlive(existing.browser)) { while (true) {
existing.inUse = true; const existing = this.entries.get(key);
existing.lastUsedAt = Date.now(); if (!existing) {
this.hits++; break;
return { }
browser: existing.browser, if (existing.inUse) {
page: existing.page, await this.waitForRelease(key);
profile, continue;
pooled: true, }
reused: true, if (await this.isBrowserAlive(existing.browser)) {
release: async (destroy = false) => this.release(key, destroy), existing.inUse = true;
}; existing.lastUsedAt = Date.now();
} this.hits++;
if (existing) { return {
browser: existing.browser,
page: null,
profile,
pooled: true,
reused: true,
release: async (destroy = false) => this.release(key, destroy),
};
}
await this.destroyEntry(key); await this.destroyEntry(key);
break;
} }
this.enforceCapacity(key); this.enforceCapacity(key);
@@ -107,7 +146,7 @@ class BrowserPool {
const launched = await launcher(); const launched = await launcher();
this.entries.set(key, { this.entries.set(key, {
browser: launched.browser, browser: launched.browser,
page: launched.page, page: null,
profile, profile,
sessionKey, sessionKey,
lastUsedAt: Date.now(), lastUsedAt: Date.now(),
@@ -124,18 +163,19 @@ class BrowserPool {
}; };
} }
/**
* @param {string} key
* @param {boolean} destroy
*/
async release(key, destroy = false) { async release(key, destroy = false) {
const entry = this.entries.get(key); const entry = this.entries.get(key);
if (!entry) return; if (!entry) {
this._wakeNextWaiter(key);
return;
}
entry.inUse = false; entry.inUse = false;
entry.lastUsedAt = Date.now(); entry.lastUsedAt = Date.now();
entry.page = null;
if (destroy || !(await this.isBrowserAlive(entry.browser))) { if (destroy || !(await this.isBrowserAlive(entry.browser))) {
await this.destroyEntry(key); await this.destroyEntry(key);
} }
this._wakeNextWaiter(key);
} }
evictIdle() { evictIdle() {
@@ -151,17 +191,24 @@ class BrowserPool {
clearInterval(this._sweepTimer); clearInterval(this._sweepTimer);
const keys = [...this.entries.keys()]; const keys = [...this.entries.keys()];
for (const key of keys) { for (const key of keys) {
await this.destroyEntry(key); const entry = this.entries.get(key);
if (entry && !entry.inUse) {
await this.destroyEntry(key);
}
} }
} }
getStats() { getStats() {
let idle = 0; let idle = 0;
let active = 0; let active = 0;
let waiting = 0;
for (const entry of this.entries.values()) { for (const entry of this.entries.values()) {
if (entry.inUse) active++; if (entry.inUse) active++;
else idle++; else idle++;
} }
for (const queue of this._waitQueues.values()) {
waiting += queue.length;
}
return { return {
enabled: POOL_ENABLED, enabled: POOL_ENABLED,
max: MAX_POOLED_BROWSERS, max: MAX_POOLED_BROWSERS,
@@ -169,6 +216,7 @@ class BrowserPool {
size: this.entries.size, size: this.entries.size,
idle, idle,
active, active,
waiting,
hits: this.hits, hits: this.hits,
misses: this.misses, misses: this.misses,
evictions: this.evictions, evictions: this.evictions,
+118
View File
@@ -0,0 +1,118 @@
/**
* CAPTCHA_PROXY 解析与 Chrome 代理注入(与 CapSolver AntiCloudflareTask 使用同一 IP
*
* 仅在实际检测到 CF 挑战、需要 Managed 过盾时由 server.js 显式启用(useCaptchaProxy=true)。
* 格式:ip:port:username:password
*/
const CAPTCHA_PROXY_RAW = (process.env.CAPTCHA_PROXY || '').trim();
/**
* @returns {{
* host: string,
* port: string,
* username: string,
* password: string,
* chromeProxyServer: string,
* capsolverString: string,
* }|null}
*/
function parseCaptchaProxy(raw = CAPTCHA_PROXY_RAW) {
const value = (raw || '').trim();
if (value === '') {
return null;
}
const parts = value.split(':');
if (parts.length < 4) {
return null;
}
const host = parts[0];
const port = parts[1];
const username = parts[2];
const password = parts.slice(3).join(':');
if (!host || !port || !username || !password) {
return null;
}
return {
host,
port,
username,
password,
chromeProxyServer: `http://${host}:${port}`,
capsolverString: value,
};
}
/** @returns {boolean} */
function hasCaptchaProxy() {
return parseCaptchaProxy() !== null;
}
/**
* 是否属于可能走 CapSolver Managed 的工单(A2C 等)
* 注意:仅表示候选,不代表本次任务一定挂代理
* @param {object|null|undefined} antiBot
*/
function isManagedCfCandidate(antiBot) {
if (!antiBot?.enabled) {
return false;
}
if (antiBot.cfChallengeMode === 'managed') {
return true;
}
if (antiBot.cfCloudflareSolver === true) {
return true;
}
return false;
}
/**
* @deprecated 请使用 isManagedCfCandidate + 显式 useCaptchaProxy 参数
*/
function shouldUseCaptchaProxyForAntiBot(antiBot) {
return false;
}
/**
* Chrome 启动参数 --proxy-server(须 useCaptchaProxy=true 时调用)
* @returns {string[]}
*/
function getCaptchaProxyChromeArgs() {
const parsed = parseCaptchaProxy();
if (!parsed) {
return [];
}
return [
`--proxy-server=${parsed.chromeProxyServer}`,
'--proxy-bypass-list=<-loopback>,localhost,127.0.0.1',
];
}
/**
* 为 Page 设置代理认证(须配合 --proxy-server
* @param {import('puppeteer').Page} page
* @returns {Promise<boolean>}
*/
async function applyCaptchaProxyToPage(page) {
const parsed = parseCaptchaProxy();
if (!parsed || !page) {
return false;
}
await page.authenticate({
username: parsed.username,
password: parsed.password,
});
return true;
}
module.exports = {
parseCaptchaProxy,
hasCaptchaProxy,
isManagedCfCandidate,
shouldUseCaptchaProxyForAntiBot,
getCaptchaProxyChromeArgs,
applyCaptchaProxyToPage,
};
+150 -2
View File
@@ -6,6 +6,10 @@ const {
CAPTCHA_API_KEY, CAPTCHA_API_KEY,
CAPTCHA_MAX_WAIT_MS, CAPTCHA_MAX_WAIT_MS,
} = require('./constants'); } = require('./constants');
const { sanitizePageUrlForCfSolver } = require('./cf-detector');
/** AntiCloudflareTask 所需静态代理(ip:port:user:pass),未配置则不可用 */
const CAPTCHA_PROXY = (process.env.CAPTCHA_PROXY || '').trim();
/** /**
* @param {string} url * @param {string} url
@@ -113,6 +117,147 @@ async function solveVia2Captcha({ pageUrl, sitekey, apiKey }) {
throw new Error('2Captcha 求解超时'); throw new Error('2Captcha 求解超时');
} }
/**
* CapSolver Managed Challenge(返回 cf_clearance cookie,需 CAPTCHA_PROXY
* @param {{ pageUrl: string, apiKey: string, proxy: string, html?: string, userAgent?: string }} params
* @returns {Promise<{ cookies: Record<string, string>, userAgent?: string }>}
*/
async function solveViaCapSolverCloudflare({ pageUrl, apiKey, proxy, html, userAgent }) {
const task = {
type: 'AntiCloudflareTask',
websiteURL: pageUrl,
proxy,
};
if (html) {
task.html = html.slice(0, 500000);
}
if (userAgent) {
task.userAgent = userAgent;
}
const create = await httpJsonPost('https://api.capsolver.com/createTask', {
clientKey: apiKey,
task,
});
if (create.errorId !== 0 || !create.taskId) {
throw new Error(`CapSolver AntiCloudflare createTask 失败: ${create.errorDescription || create.errorCode || 'unknown'}`);
}
const deadline = Date.now() + CAPTCHA_MAX_WAIT_MS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 3000));
const result = await httpJsonPost('https://api.capsolver.com/getTaskResult', {
clientKey: apiKey,
taskId: create.taskId,
});
if (result.status === 'ready' && result.solution) {
const cookies = result.solution.cookies || {};
if (typeof cookies === 'string') {
return { cookies: { cf_clearance: cookies }, userAgent: result.solution.userAgent };
}
if (result.solution.token && !cookies.cf_clearance) {
return {
cookies: { cf_clearance: result.solution.token },
userAgent: result.solution.userAgent,
};
}
return {
cookies,
userAgent: result.solution.userAgent,
};
}
if (result.errorId !== 0) {
throw new Error(`CapSolver AntiCloudflare getTaskResult 失败: ${result.errorDescription || result.errorCode}`);
}
}
throw new Error('CapSolver AntiCloudflare 求解超时');
}
/**
* 统一 Cloudflare Managed Challenge 求解
* @param {{ pageUrl: string, provider?: string, apiKey?: string, proxy?: string, html?: string, userAgent?: string }} params
* @returns {Promise<{ cookies: Record<string, string>, userAgent?: string, provider: string }>}
*/
async function solveCloudflare({ pageUrl, provider, apiKey, proxy, html, userAgent }) {
const resolvedProvider = (provider || CAPTCHA_PROVIDER).toLowerCase();
const resolvedKey = apiKey || CAPTCHA_API_KEY;
const resolvedProxy = (proxy || CAPTCHA_PROXY).trim();
const cleanPageUrl = sanitizePageUrlForCfSolver(pageUrl);
if (!resolvedKey) {
throw new Error('未配置 CAPTCHA_API_KEY,无法使用 AntiCloudflareTask');
}
if (!resolvedProxy) {
throw new Error('AntiCloudflareTask 需要配置 CAPTCHA_PROXY(静态代理 ip:port:user:pass');
}
if (resolvedProvider !== 'capsolver') {
throw new Error('AntiCloudflareTask 当前仅支持 CapSolver');
}
const { cookies, userAgent: solvedUa } = await solveViaCapSolverCloudflare({
pageUrl: cleanPageUrl,
apiKey: resolvedKey,
proxy: resolvedProxy,
html,
userAgent,
});
if (!cookies || !cookies.cf_clearance) {
throw new Error('CapSolver AntiCloudflare 未返回 cf_clearance');
}
return { cookies, userAgent: solvedUa || userAgent, provider: resolvedProvider };
}
/**
* 将 CapSolver AntiCloudflare 返回的 cookie / UA 写入页面并 reload
* @param {import('puppeteer').Page} page
* @param {string} cleanUrl
* @param {{ cookies: Record<string, string>, userAgent?: string }} solution
*/
async function applyCfClearanceSolution(page, cleanUrl, solution) {
if (solution.userAgent) {
await page.setUserAgent(solution.userAgent).catch(() => {});
}
let host = '';
let path = '/';
try {
const parsed = new URL(cleanUrl);
host = parsed.hostname;
path = parsed.pathname || '/';
} catch (_) {
host = '';
}
for (const [name, value] of Object.entries(solution.cookies || {})) {
if (!value) {
continue;
}
const cookieDomain = host.startsWith('.') ? host : `.${host}`;
await page.setCookie({
name,
value: String(value),
domain: cookieDomain,
path: '/',
secure: true,
sameSite: 'None',
}).catch(() => {});
}
console.log(`[CF] cf_clearance 已注入,goto 业务页 url=${cleanUrl}`);
await page.goto(cleanUrl, {
waitUntil: 'domcontentloaded',
timeout: 60000,
}).catch(() => {});
await page.waitForNetworkIdle({ idleTime: 500, timeout: 30000 }).catch(() => {
console.warn('[CF] cf_clearance goto 后 networkidle 超时,继续后续流程');
});
}
/** /**
* 统一 Turnstile 求解入口 * 统一 Turnstile 求解入口
* @param {{ pageUrl: string, sitekey: string, provider?: string, apiKey?: string }} params * @param {{ pageUrl: string, sitekey: string, provider?: string, apiKey?: string }} params
@@ -121,6 +266,7 @@ async function solveVia2Captcha({ pageUrl, sitekey, apiKey }) {
async function solveTurnstile({ pageUrl, sitekey, provider, apiKey }) { async function solveTurnstile({ pageUrl, sitekey, provider, apiKey }) {
const resolvedProvider = (provider || CAPTCHA_PROVIDER).toLowerCase(); const resolvedProvider = (provider || CAPTCHA_PROVIDER).toLowerCase();
const resolvedKey = apiKey || CAPTCHA_API_KEY; const resolvedKey = apiKey || CAPTCHA_API_KEY;
const cleanPageUrl = sanitizePageUrlForCfSolver(pageUrl);
if (!sitekey) { if (!sitekey) {
throw new Error('无法提取 Turnstile sitekey'); throw new Error('无法提取 Turnstile sitekey');
@@ -131,9 +277,9 @@ async function solveTurnstile({ pageUrl, sitekey, provider, apiKey }) {
let token; let token;
if (resolvedProvider === '2captcha') { if (resolvedProvider === '2captcha') {
token = await solveVia2Captcha({ pageUrl, sitekey, apiKey: resolvedKey }); token = await solveVia2Captcha({ pageUrl: cleanPageUrl, sitekey, apiKey: resolvedKey });
} else { } else {
token = await solveViaCapSolver({ pageUrl, sitekey, apiKey: resolvedKey }); token = await solveViaCapSolver({ pageUrl: cleanPageUrl, sitekey, apiKey: resolvedKey });
} }
return { token, provider: resolvedProvider }; return { token, provider: resolvedProvider };
@@ -149,5 +295,7 @@ function isCaptchaConfigured() {
module.exports = { module.exports = {
solveTurnstile, solveTurnstile,
solveCloudflare,
applyCfClearanceSolution,
isCaptchaConfigured, isCaptchaConfigured,
}; };
+173
View File
@@ -0,0 +1,173 @@
/**
* CapSolver / 2Captcha 过盾兜底:Turnstile token 注入 或 AntiCloudflareTask cf_clearance
*
* A2C 等业务域为 CF Managed Challenge(非独立 Turnstile 挂件),须优先 AntiCloudflareTask。
*/
const { sanitizePageUrlForCfSolver } = require('./cf-detector');
const {
injectTurnstileToken,
recoverAfterTokenInject,
extractTurnstileSitekey,
} = require('./turnstile-handler');
const {
solveTurnstile,
solveCloudflare,
applyCfClearanceSolution,
} = require('./captcha-solver');
const { getSitekeyCapture } = require('./sitekey-capture');
/** @returns {boolean} */
function hasCaptchaProxy() {
return (process.env.CAPTCHA_PROXY || '').trim() !== '';
}
/**
* 是否应使用 CapSolver AntiCloudflareTaskManaged Challenge / 5s 盾)
* @param {object|null|undefined} antiBot
*/
function shouldPreferManagedCloudflareSolver(antiBot) {
if (!antiBot || antiBot.cfCloudflareSolver === false) {
return false;
}
if (antiBot.cfChallengeMode === 'managed') {
return hasCaptchaProxy();
}
if (antiBot.cfCloudflareSolver === true && hasCaptchaProxy()) {
return true;
}
return false;
}
/**
* 解析可用于 AntiTurnstileTask 的 sitekey(排除 A2C 配置的 challenge sitekey
* @param {import('puppeteer').Page} page
* @param {object} antiBot
* @param {number} waitMs
* @returns {Promise<string|null>}
*/
async function resolveTurnstileWidgetSitekey(page, antiBot, waitMs) {
if (antiBot.cfChallengeMode === 'managed') {
return null;
}
const capture = getSitekeyCapture(page);
if (capture) {
const fromCapture = await capture.resolveForSolver(page, antiBot, waitMs);
if (fromCapture && antiBot.cfChallengeMode !== 'managed') {
const configured = String(antiBot.turnstileSitekey || '').trim();
if (configured && fromCapture === configured && antiBot.cfCloudflareSolver) {
return null;
}
return fromCapture;
}
}
const dynamic = await extractTurnstileSitekey(page);
if (dynamic) {
return dynamic;
}
const configured = String(antiBot?.turnstileSitekey || '').trim();
if (configured && !antiBot.cfCloudflareSolver) {
return configured;
}
return null;
}
/**
* CapSolver AntiCloudflareTask 求解并注入 cf_clearance
* @param {import('puppeteer').Page} page
* @param {object} params
*/
async function runManagedCloudflareSolver(page, params) {
const { cleanSolverUrl, waitForUrlSettled, timeoutMs } = params;
if (!hasCaptchaProxy()) {
throw new Error('AntiCloudflareTask 需要配置 CAPTCHA_PROXY(静态代理 ip:port:user:pass');
}
console.log('[CF] A2C/Managed Challenge:使用 CapSolver AntiCloudflareTask');
const html = await page.content().catch(() => '');
const userAgent = await page.evaluate(() => navigator.userAgent).catch(() => '');
const { cookies, userAgent: solvedUa, provider } = await solveCloudflare({
pageUrl: cleanSolverUrl,
html,
userAgent,
});
console.log(`[CF] CapSolver AntiCloudflareTask (${provider}) 返回 cf_clearance`);
await applyCfClearanceSolution(page, cleanSolverUrl, { cookies, userAgent: solvedUa || userAgent });
if (typeof waitForUrlSettled === 'function') {
await waitForUrlSettled(page).catch(() => {});
}
return { mode: 'cloudflare', provider };
}
/**
* @param {import('puppeteer').Page} page
* @param {object} options
* @param {object} options.antiBot
* @param {string} [options.navUrl]
* @param {(page: import('puppeteer').Page) => Promise<string>} [options.waitForUrlSettled]
* @param {number} [options.timeoutMs]
* @returns {Promise<{ mode: 'turnstile'|'cloudflare', provider: string }>}
*/
async function runCaptchaApiFallback(page, options) {
const {
antiBot,
navUrl = '',
waitForUrlSettled,
timeoutMs = 60000,
} = options;
const cleanSolverUrl = sanitizePageUrlForCfSolver(navUrl || page.url());
if (shouldPreferManagedCloudflareSolver(antiBot)) {
console.log(`[CF] Captcha API 求解 pageUrl=${cleanSolverUrl} mode=managed_challenge`);
return runManagedCloudflareSolver(page, { cleanSolverUrl, waitForUrlSettled, timeoutMs });
}
const sitekey = await resolveTurnstileWidgetSitekey(
page,
antiBot,
Math.min(timeoutMs, 25000)
);
console.log(`[CF] Captcha API 求解 pageUrl=${cleanSolverUrl} sitekey=${sitekey ? 'ok' : 'missing'}`);
if (sitekey) {
try {
const { token, provider } = await solveTurnstile({ pageUrl: cleanSolverUrl, sitekey });
console.log(`[CF] Captcha API Turnstile (${provider}) 返回 token,注入页面`);
await injectTurnstileToken(page, token);
await recoverAfterTokenInject(
page,
cleanSolverUrl,
waitForUrlSettled,
Math.min(timeoutMs, 45000)
);
return { mode: 'turnstile', provider };
} catch (turnstileErr) {
const msg = (turnstileErr?.message || '').toLowerCase();
const isChallengeMismatch = msg.includes('challenge, not turnstile')
|| msg.includes('not turnstile');
if (isChallengeMismatch && antiBot.cfCloudflareSolver !== false && hasCaptchaProxy()) {
console.warn(`[CF] Turnstile 任务失败(${turnstileErr.message}),降级 AntiCloudflareTask`);
return runManagedCloudflareSolver(page, { cleanSolverUrl, waitForUrlSettled, timeoutMs });
}
throw turnstileErr;
}
}
if (antiBot?.cfCloudflareSolver !== false && hasCaptchaProxy()) {
return runManagedCloudflareSolver(page, { cleanSolverUrl, waitForUrlSettled, timeoutMs });
}
throw new Error('无法过盾:请配置 CAPTCHA_PROXYA2C Managed Challenge)或提供有效 Turnstile sitekey');
}
module.exports = {
runCaptchaApiFallback,
shouldPreferManagedCloudflareSolver,
hasCaptchaProxy,
};
+35 -3
View File
@@ -20,6 +20,34 @@ function urlIndicatesCfChallenge(url) {
|| url.includes('/cdn-cgi/challenge'); || url.includes('/cdn-cgi/challenge');
} }
/** CF 挑战态查询参数(CapSolver websiteURL 需剔除) */
const CF_CHALLENGE_QUERY_KEYS = [
'__cf_chl_rt_tk',
'__cf_chl_tk',
'__cf_chl_f_tk',
'__cf_chl_captcha_tk__',
];
/**
* 去掉 URL 中 CF 挑战临时参数,供 CapSolver / reload 使用
* @param {string} url
* @returns {string}
*/
function sanitizePageUrlForCfSolver(url) {
if (!url || typeof url !== 'string') {
return url || '';
}
try {
const parsed = new URL(url);
for (const key of CF_CHALLENGE_QUERY_KEYS) {
parsed.searchParams.delete(key);
}
return parsed.toString();
} catch (_) {
return url.split('?')[0] || url;
}
}
/** /**
* 是否要求 cf_clearance 才视为过盾完成(A2C user.a2c.chat 为 false * 是否要求 cf_clearance 才视为过盾完成(A2C user.a2c.chat 为 false
* @param {object|null|undefined} antiBot * @param {object|null|undefined} antiBot
@@ -61,7 +89,9 @@ async function detectCloudflare(page, antiBot = null) {
hasTurnstileWidget, hasTurnstileWidget,
hasChallengeRunning, hasChallengeRunning,
titleHasJustAMoment: titleText.includes('Just a moment'), titleHasJustAMoment: titleText.includes('Just a moment'),
bodyHasJustAMoment: bodyText.includes('Just a moment') || bodyText.includes('Checking your browser'), bodyHasJustAMoment: bodyText.includes('Just a moment')
|| bodyText.includes('Checking your browser')
|| bodyText.includes('Performing security verification'),
}; };
}).catch(() => ({ }).catch(() => ({
hasTurnstileInput: false, hasTurnstileInput: false,
@@ -77,7 +107,8 @@ async function detectCloudflare(page, antiBot = null) {
|| domSignals.titleHasJustAMoment || domSignals.titleHasJustAMoment
|| domSignals.bodyHasJustAMoment || domSignals.bodyHasJustAMoment
|| domSignals.hasChallengeRunning || domSignals.hasChallengeRunning
|| title.includes('Just a moment'); || title.includes('Just a moment')
|| title.includes('Performing security verification');
const turnstileSignals = domSignals.hasTurnstileInput || domSignals.hasTurnstileWidget; const turnstileSignals = domSignals.hasTurnstileInput || domSignals.hasTurnstileWidget;
@@ -93,7 +124,7 @@ async function detectCloudflare(page, antiBot = null) {
if (requireClearance) { if (requireClearance) {
blocked = !hasCfClearance && (realChallenge || turnstileSignals); blocked = !hasCfClearance && (realChallenge || turnstileSignals);
} else { } else {
blocked = realChallenge; blocked = realChallenge || (turnstileSignals && urlChallenge);
} }
return { return {
@@ -129,4 +160,5 @@ module.exports = {
isTurnstileSolved, isTurnstileSolved,
urlIndicatesCfChallenge, urlIndicatesCfChallenge,
isCfClearanceRequired, isCfClearanceRequired,
sanitizePageUrlForCfSolver,
}; };
+9 -11
View File
@@ -5,8 +5,9 @@
* 否则清除可能过期的 cf_clearance 并重新触发 TurnstileWhatshub 短链 → work-order-sharing 场景)。 * 否则清除可能过期的 cf_clearance 并重新触发 TurnstileWhatshub 短链 → work-order-sharing 场景)。
*/ */
const { detectCloudflare } = require('./cf-detector'); const { detectCloudflare } = require('./cf-detector');
const { waitForTurnstile, extractTurnstileSitekey, injectTurnstileToken, waitForTurnstileSurface } = require('./turnstile-handler'); const { waitForTurnstile } = require('./turnstile-handler');
const { solveTurnstile, isCaptchaConfigured } = require('./captcha-solver'); const { isCaptchaConfigured } = require('./captcha-solver');
const { runCaptchaApiFallback } = require('./cf-api-fallback');
const { isSessionLikelyValid } = require('./session-store'); const { isSessionLikelyValid } = require('./session-store');
/** /**
@@ -257,15 +258,12 @@ async function handleCloudflareChallenge(page, antiBot, waitForUrlSettled, store
if (waitForUrlSettled) { if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {}); await waitForUrlSettled(page).catch(() => {});
} }
let sitekey = await waitForTurnstileSurface(page, Math.min(timeoutMs, 25000)); await runCaptchaApiFallback(page, {
if (!sitekey) { antiBot,
sitekey = await extractTurnstileSitekey(page); navUrl: page.url(),
} waitForUrlSettled,
const pageUrl = page.url(); timeoutMs,
console.log(`[CF] Captcha API 求解 pageUrl=${pageUrl} sitekey=${sitekey ? 'ok' : 'missing'}`); });
const { token, provider } = await solveTurnstile({ pageUrl, sitekey });
console.log(`[CF] Captcha API (${provider}) 返回 token,注入页面`);
await injectTurnstileToken(page, token);
const apiWaitMs = Math.min(timeoutMs, 30000); const apiWaitMs = Math.min(timeoutMs, 30000);
await waitForTurnstile(page, { timeoutMs: apiWaitMs }); await waitForTurnstile(page, { timeoutMs: apiWaitMs });
+1 -1
View File
@@ -85,7 +85,7 @@ const QUEUE_TIMEOUT_MS = Math.max(5000, parseInt(process.env.QUEUE_TIMEOUT_MS ||
const API_INTERCEPT_TIMEOUT_MS = Math.max(10000, parseInt(process.env.API_INTERCEPT_TIMEOUT_MS || '60000', 10)); const API_INTERCEPT_TIMEOUT_MS = Math.max(10000, parseInt(process.env.API_INTERCEPT_TIMEOUT_MS || '60000', 10));
const API_INTERCEPT_TIMEOUT_REAL_MS = Math.max( const API_INTERCEPT_TIMEOUT_REAL_MS = Math.max(
API_INTERCEPT_TIMEOUT_MS, API_INTERCEPT_TIMEOUT_MS,
parseInt(process.env.API_INTERCEPT_TIMEOUT_REAL_MS || '90000', 10) parseInt(process.env.API_INTERCEPT_TIMEOUT_REAL_MS || '180000', 10)
); );
const NAVIGATION_TIMEOUT_MS = Math.max(15000, parseInt(process.env.NAVIGATION_TIMEOUT_MS || '60000', 10)); const NAVIGATION_TIMEOUT_MS = Math.max(15000, parseInt(process.env.NAVIGATION_TIMEOUT_MS || '60000', 10));
View File
View File

Some files were not shown because too many files have changed in this diff Show More