diff --git a/_php_code/A2c.php b/_php_code/A2c.php new file mode 100644 index 0000000..a58b775 --- /dev/null +++ b/_php_code/A2c.php @@ -0,0 +1,105 @@ +account = $account; + $this->password = $password; + $this->pageUrl = $pageUrl; + + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig() + { + return [ + 'pageUrl' => $this->pageUrl, + 'apiUrls' => [self::API_LIST, self::API_DETAILS], + + // 明确指派角色 + 'listApi' => self::API_LIST, // 必须 + 'detailApi' => self::API_DETAILS, // 选填 + + 'listMethod' => 'POST', + + 'paginationMode' => self::MODE_UI, + + 'authActions' => [ + // ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password], + // ['type' => 'type', 'selector' => '#username_input', 'value' => $this->account], + // ['type' => 'press', 'key' => 'Enter'], + + ['type' => 'wait', 'ms' => 2000] + ] + ]; + } + + // 只负责解析 List 的总页数 + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $default_per_page_count = self::DEFAULT_PER_PAGE_COUNT; + $this->unifiedData->total = $listFirstPageData['data']['total']; + + if($listFirstPageData['data']['total'] <= $default_per_page_count) { + return 1; + } + + return ceil($listFirstPageData['data']['total']/$default_per_page_count); + } + + // 只负责组装 List 的翻页参数 + protected function buildListPageParams($page) + { + return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT]; + } + + // 提供 List 的下一页按钮信息 + protected function getUiPaginationConfig() + { + return [ + 'nextBtnSelector' => '.btn-next', + 'waitMs' => 2000 + ]; + } + + // 清爽至极的数据清洗:详情是详情,列表是列表 + protected function parseToUnifiedData($detailData, $allListPagesData) + { + $unifiedData = $this->unifiedData; + + // 1. 如果捕获到了详情数据,提取今日新增 + if ($detailData) { + $unifiedData->todayNewCount = (int)($detailData['data']['newFollowersToday'] ?? 0); + } + + // 2. 循环合并了所有页数的 List 数组 + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['rows'] ?? []; + foreach ($records as $item) { + if(!empty($item['account'])) { + $number = $item['account'] ?? null; + $isOnline = (isset($item['numberStatus']) && $item['numberStatus'] == 1); + + $unifiedData->addNumber($number, $isOnline, $item['newFollowersToday']); + } + } + } + + return $unifiedData; + } +} \ No newline at end of file diff --git a/_php_code/AbstractScrmSpider.class.php b/_php_code/AbstractScrmSpider.class.php new file mode 100644 index 0000000..58976e4 --- /dev/null +++ b/_php_code/AbstractScrmSpider.class.php @@ -0,0 +1,246 @@ +"; + + foreach ($vars as $var) { + echo "
";
+            
+            // 针对布尔值和 null 的特殊处理,因为 print_r 打印这些不直观
+            if (is_bool($var)) {
+                echo "bool(" . ($var ? 'true' : 'false') . ")";
+            } elseif (is_null($var)) {
+                echo "null";
+            } else {
+                // 使用 print_r 获取格式化字符串,并用 htmlspecialchars 防止 HTML 标签被浏览器解析
+                $output = htmlspecialchars(print_r($var, true));
+                
+                // 简单的高亮:给数组的键名加上颜色
+                $output = preg_replace('/\[(.*?)\]/', '[$1]', $output);
+                
+                echo $output;
+            }
+            
+            echo "
"; + + // 如果有多个参数,用分割线隔开 + if (count($vars) > 1 && $var !== end($vars)) { + echo "
"; + } + } + + echo ""; + } +} + +/** + * 美化打印变量并终止程序 (Dump and Die) + */ +if (!function_exists('dd')) { + function dd(...$vars) { + dump(...$vars); + die(1); // 终止程序执行 + } +} + +// 文件名: AbstractScrmSpider.php + +require_once __DIR__ . '/UnifiedScrmData.class.php'; + +abstract class AbstractScrmSpider +{ + const MODE_FETCH = 'fetch'; // 极速并发拉取 (推荐默认) + const MODE_UI = 'ui_click'; // 强制 UI 点击下一页 + + protected $nodeHost; + + public function __construct($nodeHost = 'http://127.0.0.1:3001') + { + $this->nodeHost = $nodeHost; + } + + /** ================= 子类必须实现的契约 ================= **/ + abstract protected function getSpiderConfig(); + + // 以下方法的关注点,全部仅限于 List 接口! + abstract protected function extractListTotalPages($listFirstPageData, $countData = null); + abstract protected function buildListPageParams($page); + abstract protected function getUiPaginationConfig(); + + // 清洗方法直接接收两个明确的变量,告别繁杂的数组解析 + abstract protected function parseToUnifiedData($detailData, $allListPagesData); + + /** ================= 核心工作流 ================= **/ + public function run() + { + $config = $this->getSpiderConfig(); + + $listApi = $config['listApi']; + $detailApi = $config['detailApi'] ?? null; // detail 接口是可选的 + $countApi = $config['countApi'] ?? null; // 有些在线客服的总数通过单独调用接口获取 + + // 组装需要 Node 拦截的目标数组 + $apiUrlsToIntercept = [$listApi]; + if ($detailApi) { $apiUrlsToIntercept[] = $detailApi; } + if ($countApi) { $apiUrlsToIntercept[] = $countApi; } +// var_dump($apiUrlsToIntercept); + // 【阶段一】:初始化并首屏拦截 + $initResult = $this->requestNode('/api/auth-and-intercept', [ + 'pageUrl' => $config['pageUrl'], + 'apiUrls' => $apiUrlsToIntercept, + 'authActions' => $config['authActions'] + ]); + + if (empty($initResult['success'])) { + throw new Exception("初始化失败: " . ($initResult['error'] ?? '未知')); + } + + $interceptedApis = $initResult['interceptedApis']; + $cookies = $initResult['cookies']; +// dd($interceptedApis); + // 必须拦截到 List 接口,否则无法继续 + if (!isset($interceptedApis[$listApi])) { + throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]"); + } + + // 分离 Detail 和 List 的首屏数据 + $detailData = $detailApi && isset($interceptedApis[$detailApi]) ? $interceptedApis[$detailApi]['data'] : null; + $countData = $countApi && isset($interceptedApis[$countApi]) ? $interceptedApis[$countApi]['data'] : null; +// dd($detailData); +// dd($countData); + $listApiNode = $interceptedApis[$listApi]; + $allListPagesData = [$listApiNode['data']]; // 初始化列表容器,装入第一页数据 + + $totalPages = $this->extractListTotalPages($listApiNode['data'], $countData); + + $mode = $config['paginationMode'] ?? self::MODE_FETCH; + + // 如果总页数 > 1,触发【阶段二】 + if ($totalPages > 1 || $totalPages === null) { + // 策略 1:极速 Fetch + if ($mode === self::MODE_FETCH) { + $paramList = []; + for ($page = 2; $page <= $totalPages; $page++) { + $paramList[] = $this->buildListPageParams($page); + } + + $fetchResult = $this->requestNode('/api/batch-fetch', [ + 'tasks' => [[ + 'apiPath' => $listApi, + 'fullUrl' => $listApiNode['url'], + 'headers' => $listApiNode['headers'] ?? "", + 'paramList' => $paramList, + 'method' => $config['listMethod'] ?? 'GET' + ]], + 'cookies' => $cookies + ], 120); + + if (!empty($fetchResult['success'])) { + foreach ($fetchResult['results'][$listApi] as $pResult) { + if ($pResult['success']) $allListPagesData[] = $pResult['data']; + } + } + } + // 策略 2:强制 UI 点击 + elseif ($mode === self::MODE_UI) { + $uiConfig = $this->getUiPaginationConfig(); + + // 🔥 核心修改:直接将第一页的数组传给 Node,让 Node 引擎自己去剔除时间戳并生成统一哈希 + $firstPageData = $listApiNode['data']; + // 🔥 核心重构 2:如果是 null 盲点模式,下发 9999 次点击任务;否则按实际计算次数 + $clicksToPerform = ($totalPages === null) ? 9999 : ($totalPages - 1); + + $uiResult = $this->requestNode('/api/ui-pagination', [ + 'apiUrl' => $listApi, + 'pageUrl' => $config['pageUrl'], + 'nextBtnSelector' => $uiConfig['nextBtnSelector'], + 'waitMs' => $uiConfig['waitMs'] ?? 2000, + 'clicksToPerform' => $clicksToPerform, + 'cookies' => $cookies, + 'firstPageData' => $firstPageData, // 传递原始数据源 + // 🔥 核心补充:把登录剧本原封不动地传给翻页引擎! + 'authActions' => $config['authActions'] + ], 1200); // 增加 PHP 端的 cURL 超时时间到 400 秒,以包容多次重试产生的耗时 + + if (!empty($uiResult['success']) && !empty($uiResult['data'])) { + foreach ($uiResult['data'] as $pageData) { + $allListPagesData[] = $pageData; + } + } + } + } + // 注:如果 $totalPages <= 1,直接自然跳过上述区块,进入第三阶段。 + + // 【阶段三】:移交数据进行最终清洗 + // return $this->parseToUnifiedData($detailData, $allListPagesData); + + // 【阶段三】:解析并打包数据 + $unifiedData = $this->parseToUnifiedData($detailData, $allListPagesData); + + // 🔥 核心修改:引入业务对账雷达 + // if (!$this->validateDataIntegrity($unifiedData)) { + // 方案 A:直接返回 false(符合你的特殊要求) + // return false; + + // 方案 B(工业级推荐):抛出自定义异常。 + // 因为返回 false 会让你在外部不知道到底是“网络超时”失败的,还是“数据对账没对上”失败的。 + // throw new Exception("抓取校验异常:数据未成功获取,或客服数量业务对账失败!"); + // } + + return $unifiedData; + } + + private function requestNode($endpoint, $payload, $timeout = 60) + { + $ch = curl_init($this->nodeHost . $endpoint); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + $response = curl_exec($ch); + if (curl_errno($ch)) throw new Exception(curl_error($ch)); + curl_close($ch); + return json_decode($response, true); + } + + /** + * 🔥 新增:数据完整性与业务对账校验 + * @param array $unifiedData 已经解析好的统一格式数据 + * @return bool 校验成功返回 true,失败返回 false + */ + protected function validateDataIntegrity($unifiedData) + { + // 1. 底线校验:如果最核心的列表数据或详情数据根本就是空的,直接判定失败 + if (empty($unifiedData) || !is_array($unifiedData)) { + return false; + } + + // 2. 这里可以放一些全系统通用的基础校验(如果有的话) + + return true; + } +} \ No newline at end of file diff --git a/_php_code/Haiwang.php b/_php_code/Haiwang.php new file mode 100644 index 0000000..c146cde --- /dev/null +++ b/_php_code/Haiwang.php @@ -0,0 +1,98 @@ +account = $account; + $this->password = $password; + $this->pageUrl = $pageUrl; + + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig() + { + return [ + 'pageUrl' => $this->pageUrl, + 'apiUrls' => [self::API_LIST, self::API_DETAILS], + + // 明确指派角色 + 'listApi' => self::API_LIST, // 必须 + 'detailApi' => self::API_DETAILS, // 选填 + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password], + // ['type' => 'type', 'selector' => '#username_input', 'value' => $this->account], + ['type' => 'press', 'key' => 'Enter'], + + ['type' => 'wait', 'ms' => 2000] + ] + ]; + } + + // 只负责解析 List 的总页数 + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $default_per_page_count = self::DEFAULT_PER_PAGE_COUNT; + $this->unifiedData->total = $listFirstPageData['data']['total']; + + if($this->unifiedData->total <= $default_per_page_count) { + return 1; + } + + return ceil($this->unifiedData->total/$default_per_page_count); + } + + // 只负责组装 List 的翻页参数 + protected function buildListPageParams($page) + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + // 提供 List 的下一页按钮信息 + protected function getUiPaginationConfig() + { + return [ + 'nextBtnSelector' => '.btn-next', + 'waitMs' => 2000 + ]; + } + + // 清爽至极的数据清洗:详情是详情,列表是列表 + protected function parseToUnifiedData($detailData, $allListPagesData) + { + $unifiedData = $this->unifiedData; + + // 循环合并了所有页数的 List 数组 + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['items'] ?? []; + foreach ($records as $item) { + if(!empty($item['acclist_account'])) { + $number = $item['acclist_account'] ?? null; + $isOnline = (isset($item['acclist_status']) && $item['acclist_status'] == 2); + + $unifiedData->addNumber($number, $isOnline, $item['account_statistics_today_effective']); + } + } + } + $unifiedData->todayNewCount = $allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective']; + + return $unifiedData; + } +} \ No newline at end of file diff --git a/_php_code/Huojian.php b/_php_code/Huojian.php new file mode 100644 index 0000000..c235e34 --- /dev/null +++ b/_php_code/Huojian.php @@ -0,0 +1,102 @@ +account = $account; + $this->password = $password; + $this->pageUrl = $pageUrl; + + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig() + { + return [ + 'pageUrl' => $this->pageUrl, + 'apiUrls' => [self::API_LIST], + + // 明确指派角色 + 'listApi' => self::API_LIST, // 必须 + + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + + 'authActions' => [ + // 1. 填入密码:寻找 class 包含 el-message-box__input 下面的任意 input + // Node.js 会自动扫描所有匹配项,并只把密码强行注入到那个“肉眼可见”的框里 + ['type' => 'vue_fill', 'selector' => '.el-message-box__input input', 'value' => $this->password], + + // 2. 停顿 500ms,让 Vue 绑定的 v-model 彻底反应过来 + ['type' => 'wait', 'ms' => 500], + + // 3. 点击确认:寻找 MessageBox 底部的蓝色 primary 确认按钮 + // 同样利用 vue_click 的可见性过滤,无视隐藏的旧弹窗按钮 + ['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'], + + // 4. 等待弹窗淡出,接口开始请求 + ['type' => 'wait', 'ms' => 2000] + ] + ]; + } + + // 只负责解析 List 的总页数 + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + // 只有一页 + return 1; + } + + // 没有分页返回空数组 + protected function buildListPageParams($page) + { + return []; + } + + // 提供 List 的下一页按钮信息 + protected function getUiPaginationConfig() + { + return []; + } + + // 清爽至极的数据清洗:详情是详情,列表是列表 + protected function parseToUnifiedData($detailData, $allListPagesData) + { + $unifiedData = $this->unifiedData; + + $unifiedData->todayNewCount = $allListPagesData[0]['data']['counterWorker']['newTodayFriend']; + + // 2. 循环合并了所有页数的 List 数组 + $count = 0; + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['counterCsAccountVo'] ?? []; + foreach ($records as $item) { + if(!empty($item['accountLogin'])) { + $number = $item['accountLogin'] ?? null; + $isOnline = (isset($item['accountStatus']) && $item['accountStatus'] == 1); + $count++; + $unifiedData->addNumber($number, $isOnline, $item['newTodayFriend']); + } + } + } + + $unifiedData->total = $count; + + return $unifiedData; + } +} \ No newline at end of file diff --git a/_php_code/SsCustomer.php b/_php_code/SsCustomer.php new file mode 100644 index 0000000..ae07f3f --- /dev/null +++ b/_php_code/SsCustomer.php @@ -0,0 +1,131 @@ +account = $account; + $this->password = $password; + $this->pageUrl = $pageUrl; + + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig() + { + return [ + 'pageUrl' => $this->pageUrl, + 'apiUrls' => [self::API_LIST, self::API_DETAILS, self::API_COUNT], + + // 明确指派角色 + 'listApi' => self::API_LIST, // 必须 + 'detailApi' => self::API_DETAILS, // 选填 + 'countApi' => self::API_COUNT, + + 'listMethod' => 'POST', + + 'paginationMode' => self::MODE_UI, + + 'authActions' => [ + ['type' => 'wait', 'ms' => 2000], + + ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password], + + ['type' => 'press', 'key' => 'Enter'], + + ['type' => 'wait', 'ms' => 3000], + + [ + 'type' => 'vue_click', + 'selector' => 'button[class*="reports-customers__dimension"]', + 'text' => 'social media accounts' + ], + + ['type' => 'wait', 'ms' => 3000], + ] + ]; + } + + // 只负责解析 List 的总页数 + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + return null; + + // $default_per_page_count = self::DEFAULT_PER_PAGE_COUNT; + + // // 场景 A:如果单独的 Count 接口有数据返回 + // if ($countData) { + // // 假设 count 接口返回的是 {"data": {"total": 312}} + // $totalRecords = $countData['data']['total']; + // } else { + // // 场景 B + // $totalRecords = $listFirstPageData['data']['total']; + // } + + // $this->unifiedData->total = $totalRecords; + // if($totalRecords <= $default_per_page_count) { + // return 1; + // } + + // return ceil($listFirstPageData['data']['total']/$default_per_page_count); + } + + // 只负责组装 List 的翻页参数 + protected function buildListPageParams($page) + { + return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT]; + } + + // 提供 List 的下一页按钮信息 + protected function getUiPaginationConfig() + { + return [ + 'nextBtnSelector' => '.arco-pagination-item-next', + 'waitMs' => 2000 + ]; + } + + // 清爽至极的数据清洗:详情是详情,列表是列表 + protected function parseToUnifiedData($detailData, $allListPagesData) + { + $unifiedData = $this->unifiedData; + + // 1. 如果捕获到了详情数据,提取今日新增 + if ($detailData) { + $unifiedData->todayNewCount = (int)($detailData['data']['distinct_contacts_total'] ?? 0); + } + + // 2. 循环合并了所有页数的 List 数组 + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['list'] ?? []; + foreach ($records as $item) { + if(!empty($item['channel_tag'])) { + $number = $item['channel_tag'] ?? null; + $isOnline = true; // 离线状态的账号同步不到 + + $unifiedData->addNumber($number, $isOnline, $item['distinct_contacts_total']); + } + } + } + + // 🚀 3. 终极统计:所有翻页数据均已入库,此时再统计真实的 Total 总数 + $unifiedData->total = count($unifiedData->numbers); + + return $unifiedData; + } +} \ No newline at end of file diff --git a/_php_code/UnifiedScrmData.class.php b/_php_code/UnifiedScrmData.class.php new file mode 100644 index 0000000..878b494 --- /dev/null +++ b/_php_code/UnifiedScrmData.class.php @@ -0,0 +1,26 @@ +numbers[] = [ + 'number' => $number, + 'status' => $isOnline ? 'online' : 'offline', + 'newFollowersToday' => $newFollowersToday + ]; + + if ($isOnline) { + $this->totalOnline++; + } else { + $this->totalOffline++; + } + } +} \ No newline at end of file diff --git a/_php_code/Xinghe.php b/_php_code/Xinghe.php new file mode 100644 index 0000000..70c2a2b --- /dev/null +++ b/_php_code/Xinghe.php @@ -0,0 +1,100 @@ +account = $account; + $this->password = $password; + $this->pageUrl = $pageUrl; + + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig() + { + return [ + 'pageUrl' => $this->pageUrl, + 'apiUrls' => [self::API_LIST . "?page=1&limit=10"], + 'listApi' => self::API_LIST, // 必须,第一页的列表数据 + // 'detailApi' => self::API_DETAILS, // 选填 + // 'countApi' => self::API_COUNT, + + 'listMethod' => 'GET', + + 'paginationMode' => self::MODE_FETCH, + + 'authActions' => [ + // ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password], + // ['type' => 'type', 'selector' => '#username_input', 'value' => $this->account], + // ['type' => 'press', 'key' => 'Enter'], + + ['type' => 'wait', 'ms' => 2000] + ] + ]; + } + + // 只负责解析 List 的总页数 + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $default_per_page_count = self::DEFAULT_PER_PAGE_COUNT; + $this->unifiedData->total = $listFirstPageData['count']; + $this->unifiedData->todayNewCount = $listFirstPageData['totalRow']['day_sum']; + + if($listFirstPageData['count'] <= $default_per_page_count) { + return 1; + } + + return ceil($listFirstPageData['count']/$default_per_page_count); + } + + // 只负责组装 List 的翻页参数 + protected function buildListPageParams($page) + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + // 提供 List 的下一页按钮信息 + protected function getUiPaginationConfig() + { + return [ + 'nextBtnSelector' => '.layui-laypage-next', + 'waitMs' => 2000 + ]; + } + + // 清爽至极的数据清洗:详情是详情,列表是列表 + protected function parseToUnifiedData($detailData, $allListPagesData) + { + $unifiedData = $this->unifiedData; + + // 循环合并了所有页数的 List 数组 + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data'] ?? []; + foreach ($records as $item) { + if(!empty($item['user'])) { + $number = $item['user'] ?? null; + $isOnline = (isset($item['online']) && $item['online'] == 1); + + $unifiedData->addNumber($number, $isOnline, $item['day_sum']); + } + } + } + + return $unifiedData; + } +} \ No newline at end of file diff --git a/_php_code/index.php b/_php_code/index.php new file mode 100644 index 0000000..db78969 --- /dev/null +++ b/_php_code/index.php @@ -0,0 +1,140 @@ +抓取任务 (多引擎智能调度)...\n\r"; +// $pageUrl = 'https://user.a2c.chat/visitors/counter/share?id=33e449dc83c24ee59275bf03a2d82234'; // 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://103.251.112.35:10158/share/share/index.html?token=pds65jl202t2kjis5firb8epu4d8a83ptfhc63d89l3mv11nwa'; // 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/ygn9zjr8'; // PageUrl 入口授权页 +// $username = ""; // 登录账号 +// $password = "123456"; // 登录密码 +// $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 "🚀 开始执行抓取任务 (多引擎智能调度)...\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 "🚀 开始执行抓取任务 (多引擎智能调度)...\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"; +// } \ No newline at end of file diff --git a/application/admin/command/SplitSyncTickets.php b/application/admin/command/SplitSyncTickets.php new file mode 100644 index 0000000..9913c92 --- /dev/null +++ b/application/admin/command/SplitSyncTickets.php @@ -0,0 +1,52 @@ +setName('split:sync-tickets') + ->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '') + ->setDescription('同步分流工单云控数据'); + } + + protected function execute(Input $input, Output $output): void + { + set_time_limit(0); + $service = new SplitTicketSyncService(); + $ticketId = trim((string) $input->getOption('ticket')); + + if ($ticketId !== '' && ctype_digit($ticketId)) { + $result = $service->syncOne((int) $ticketId, true); + if (!empty($result['skipped'])) { + $output->writeln('跳过: ' . ($result['message'] ?? '') . ''); + return; + } + if ($result['success']) { + $output->writeln('工单 #' . $ticketId . ' 同步成功'); + } else { + $output->writeln('工单 #' . $ticketId . ' 同步失败: ' . ($result['message'] ?? '') . ''); + } + return; + } + + $count = $service->syncDueTickets(); + $output->writeln('本次处理工单数: ' . $count . ''); + } +} diff --git a/application/admin/controller/split/Number.php b/application/admin/controller/split/Number.php index a3cd1b8..1dfc617 100644 --- a/application/admin/controller/split/Number.php +++ b/application/admin/controller/split/Number.php @@ -58,6 +58,7 @@ class Number extends Backend $this->assignconfig('numberTypeList', $this->model->getNumberTypeList()); $this->assignconfig('statusList', $this->model->getStatusList()); $this->assignconfig('manualManageList', $this->model->getManualManageList()); + $this->assignconfig('platformStatusList', $this->model->getPlatformStatusList()); $this->setupPatchFrontend(); } diff --git a/application/admin/controller/split/Ticket.php b/application/admin/controller/split/Ticket.php index 96435cb..30dd32c 100644 --- a/application/admin/controller/split/Ticket.php +++ b/application/admin/controller/split/Ticket.php @@ -7,7 +7,11 @@ namespace app\admin\controller\split; use app\admin\model\split\Link as LinkModel; use app\admin\model\split\Ticket as TicketModel; use app\common\controller\Backend; +use app\common\service\SplitTicketRuleService; +use app\common\service\SplitTicketSyncService; use think\Db; +use think\Lang; +use think\Loader; use think\Exception; use think\exception\DbException; use think\exception\PDOException; @@ -45,11 +49,6 @@ class Ticket extends Backend $lang = $this->request->langset(); $lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn'; - $langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/ticket.php'; - if (is_file($langFile)) { - \think\Lang::load($langFile); - } - $this->model = new \app\admin\model\split\Ticket(); $this->view->assign('ticketTypeList', $this->model->getTicketTypeList()); $this->view->assign('numberTypeList', $this->model->getNumberTypeList()); @@ -62,6 +61,32 @@ class Ticket extends Backend $this->setupPatchFrontend(); } + /** + * 加载工单语言包(优先 patches) + */ + protected function loadlang($name): void + { + $lang = $this->request->langset(); + $lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn'; + $name = Loader::parseName($name); + $name = preg_match('/^([a-zA-Z0-9_\.\/]+)$/i', $name) ? $name : 'index'; + + $files = [ + APP_PATH . 'admin/lang/' . $lang . '/' . str_replace('.', '/', $name) . '.php', + ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/ticket.php', + ]; + $loaded = false; + foreach ($files as $file) { + if (is_file($file)) { + Lang::load($file); + $loaded = true; + } + } + if (!$loaded) { + parent::loadlang($name); + } + } + /** * 未部署 JS 时指向 script 接口 */ @@ -146,10 +171,9 @@ class Ticket extends Backend [$where, $sort, $order, $offset, $limit] = $this->buildparams(); $ticketTable = $this->model->getTable(); $clickSub = TicketModel::buildClickCountSubQuery(); + // 勿 with(splitLink):eagerlyType=0 为 JOIN 预载入,会重写 field 导致子查询 SQL 语法错误 $list = $this->model - ->field($ticketTable . '.*') - ->fieldRaw($clickSub) - ->with(['splitLink']) + ->field($ticketTable . '.*,' . $clickSub) ->where($where) ->order($sort, $order) ->paginate($limit); @@ -177,11 +201,95 @@ class Ticket extends Backend $params['sync_status'], $params['sync_time'], $params['sync_message'], + $params['sync_fail_count'], + $params['speed_snapshot_count'], + $params['speed_snapshot_time'], $params['click_count'] ); return $params; } + /** + * 手动同步选中工单 + */ + public function sync(): void + { + if (!$this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $this->request->post('ids', ''); + if ($ids === '') { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $adminIds = $this->getDataLimitAdminIds(); + $pk = $this->model->getPk(); + $list = $this->model->where($pk, 'in', $ids)->select(); + if (!$list || count($list) === 0) { + $this->error(__('No Results were found')); + } + + $service = new SplitTicketSyncService(); + $ok = 0; + $fail = 0; + $messages = []; + + foreach ($list as $row) { + if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) { + $fail++; + $messages[] = '#' . $row['id'] . ': 无权限'; + continue; + } + $result = $service->syncOne((int) $row['id'], true); + if ($result['success']) { + $ok++; + } else { + $fail++; + $messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败'); + } + } + + $summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail); + if ($fail > 0) { + $summary .= ';' . implode(';', array_slice($messages, 0, 5)); + } + $this->success($summary); + } + + /** + * 批量更新:工单状态变更时联动号码 + * + * @param string $ids + */ + public function multi($ids = '') + { + if (!$this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids', ''); + if ($ids === '') { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + + $params = $this->request->post('params', ''); + parse_str((string) $params, $values); + $ruleService = new SplitTicketRuleService(); + + if (isset($values['status'])) { + $pk = $this->model->getPk(); + $rows = $this->model->where($pk, 'in', $ids)->select(); + parent::multi($ids); + foreach ($rows as $row) { + $fresh = $this->model->get($row['id']); + if ($fresh) { + $ruleService->syncNumbersWithTicketStatus($fresh); + } + } + return; + } + + parent::multi($ids); + } + /** * @return string * @throws DbException diff --git a/application/admin/lang/zh-cn/split/number.php b/application/admin/lang/zh-cn/split/number.php index 1e9a53f..bc90983 100644 --- a/application/admin/lang/zh-cn/split/number.php +++ b/application/admin/lang/zh-cn/split/number.php @@ -2,7 +2,7 @@ return [ 'Id' => '号码ID', - 'Link_url' => '链接URL', + 'Link_url' => '分流链接', 'Ticket_name' => '工单名称', 'Number' => '号码', 'Numbers' => '号码', @@ -13,6 +13,10 @@ return [ 'Inbound_count' => '进线人数', 'Status' => '状态', 'Manual_manage' => '手动管理', + 'Platform_status' => '平台状态', + 'Platform status online' => '在线', + 'Platform status offline' => '离线', + 'Platform status unknown' => '未知', 'Createtime' => '创建时间', 'Section basic' => '基础信息', 'Status normal' => '正常', diff --git a/application/admin/lang/zh-cn/split/ticket.php b/application/admin/lang/zh-cn/split/ticket.php index 1962e87..3fcbf1e 100644 --- a/application/admin/lang/zh-cn/split/ticket.php +++ b/application/admin/lang/zh-cn/split/ticket.php @@ -24,6 +24,9 @@ return [ 'Number_count' => '号码数量', 'Number_count_detail' => '离线 %s / 封号 %s', 'Sync_status' => '同步状态', + 'Sync_status_btn' => '同步状态', + 'Sync running' => '正在同步,请稍候…', + 'Sync done' => '同步完成', 'Sync status success' => '同步成功', 'Sync status error' => '同步异常', 'Sync status pending' => '待同步', diff --git a/application/admin/model/split/Number.php b/application/admin/model/split/Number.php index ae21727..c82e70a 100644 --- a/application/admin/model/split/Number.php +++ b/application/admin/model/split/Number.php @@ -26,6 +26,7 @@ class Number extends Model 'link_url_text', 'status_text', 'manual_manage_text', + 'platform_status_text', ]; /** @@ -65,6 +66,18 @@ class Number extends Model ]; } + /** + * @return array + */ + public function getPlatformStatusList(): array + { + return [ + 'online' => __('Platform status online'), + 'offline' => __('Platform status offline'), + 'unknown' => __('Platform status unknown'), + ]; + } + /** * 关联分流链接 */ @@ -94,17 +107,19 @@ class Number extends Model return $list[$type] ?? $type; } + /** + * 列表展示用:仅显示分流链接码(非完整 URL) + */ public function getLinkUrlTextAttr($value, $data): string { - if ($value !== '' && $value !== null) { - return (string) $value; + if (isset($data['split_link']) && is_array($data['split_link'])) { + return (string) ($data['split_link']['link_code'] ?? ''); } $linkId = (int) ($data['split_link_id'] ?? 0); if ($linkId <= 0) { return ''; } - $code = Link::where('id', $linkId)->value('link_code'); - return self::buildLinkUrl((string) $code); + return (string) Link::where('id', $linkId)->value('link_code'); } public function getStatusTextAttr($value, $data): string @@ -121,6 +136,13 @@ class Number extends Model return $list[$key] ?? $key; } + public function getPlatformStatusTextAttr($value, $data): string + { + $key = (string) ($data['platform_status'] ?? 'unknown'); + $list = $this->getPlatformStatusList(); + return $list[$key] ?? $key; + } + /** * 根据链接码生成完整分流 URL */ diff --git a/application/admin/model/split/Ticket.php b/application/admin/model/split/Ticket.php index bc76ce3..e19eb19 100644 --- a/application/admin/model/split/Ticket.php +++ b/application/admin/model/split/Ticket.php @@ -98,7 +98,8 @@ class Ticket extends Model */ public function splitLink() { - return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(0); + // IN 预载入:避免 eagerlyType=0(JOIN) 与列表 field 子查询冲突导致 SQL 1064 + return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(1); } /** @@ -126,7 +127,7 @@ class Ticket extends Model $total = (int) ($data['ticket_total'] ?? 0); $complete = (int) ($data['complete_count'] ?? 0); if ($total <= 0) { - return '—'; + return '0%'; } $percent = round($complete / $total * 100, 2); return $percent . '%'; diff --git a/application/admin/view/split/ticket/index.html b/application/admin/view/split/ticket/index.html index 06106e7..ec5325e 100644 --- a/application/admin/view/split/ticket/index.html +++ b/application/admin/view/split/ticket/index.html @@ -11,6 +11,7 @@ {:__('Add')} {:__('Edit')} {:__('Delete')} + {:__('Sync_status_btn')} nodeHost = rtrim($nodeHost, '/'); + } + + /** @return array */ + abstract protected function getSpiderConfig(): array; + + /** + * @param array|null $countData + */ + abstract protected function extractListTotalPages($listFirstPageData, $countData = null); + + /** + * @return array + */ + abstract protected function buildListPageParams(int $page): array; + + /** @return array */ + abstract protected function getUiPaginationConfig(): array; + + /** + * @param mixed $detailData + * @param array $allListPagesData + */ + abstract protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData; + + public function run(): UnifiedScrmData + { + $config = $this->getSpiderConfig(); + + $listApi = (string) ($config['listApi'] ?? ''); + $detailApi = $config['detailApi'] ?? null; + $countApi = $config['countApi'] ?? null; + + $apiUrlsToIntercept = [$listApi]; + if ($detailApi) { + $apiUrlsToIntercept[] = $detailApi; + } + if ($countApi) { + $apiUrlsToIntercept[] = $countApi; + } + + $initResult = $this->requestNode('/api/auth-and-intercept', [ + 'pageUrl' => $config['pageUrl'], + 'apiUrls' => $apiUrlsToIntercept, + 'authActions' => $config['authActions'] ?? [], + ]); + + if (empty($initResult['success'])) { + throw new Exception('初始化失败: ' . ($initResult['error'] ?? '未知')); + } + + $interceptedApis = $initResult['interceptedApis']; + $cookies = $initResult['cookies']; + + if (!isset($interceptedApis[$listApi])) { + throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]"); + } + + $detailData = $detailApi && isset($interceptedApis[$detailApi]) + ? $interceptedApis[$detailApi]['data'] : null; + $countData = $countApi && isset($interceptedApis[$countApi]) + ? $interceptedApis[$countApi]['data'] : null; + + $listApiNode = $interceptedApis[$listApi]; + $allListPagesData = [$listApiNode['data']]; + + $totalPages = $this->extractListTotalPages($listApiNode['data'], $countData); + $mode = $config['paginationMode'] ?? self::MODE_FETCH; + + if ($totalPages > 1 || $totalPages === null) { + if ($mode === self::MODE_FETCH && $totalPages !== null) { + $paramList = []; + for ($page = 2; $page <= $totalPages; $page++) { + $paramList[] = $this->buildListPageParams($page); + } + + $fetchResult = $this->requestNode('/api/batch-fetch', [ + 'tasks' => [[ + 'apiPath' => $listApi, + 'fullUrl' => $listApiNode['url'], + 'headers' => $listApiNode['headers'] ?? '', + 'paramList' => $paramList, + 'method' => $config['listMethod'] ?? 'GET', + ]], + 'cookies' => $cookies, + ], 120); + + if (!empty($fetchResult['success'])) { + foreach ($fetchResult['results'][$listApi] as $pResult) { + if (!empty($pResult['success'])) { + $allListPagesData[] = $pResult['data']; + } + } + } + } elseif ($mode === self::MODE_UI) { + $uiConfig = $this->getUiPaginationConfig(); + $firstPageData = $listApiNode['data']; + $clicksToPerform = ($totalPages === null) ? 9999 : ($totalPages - 1); + + $uiResult = $this->requestNode('/api/ui-pagination', [ + 'apiUrl' => $listApi, + 'pageUrl' => $config['pageUrl'], + 'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '', + 'waitMs' => $uiConfig['waitMs'] ?? 2000, + 'clicksToPerform' => $clicksToPerform, + 'cookies' => $cookies, + 'firstPageData' => $firstPageData, + 'authActions' => $config['authActions'] ?? [], + ], 1200); + + if (!empty($uiResult['success']) && !empty($uiResult['data'])) { + foreach ($uiResult['data'] as $pageData) { + $allListPagesData[] = $pageData; + } + } + } + } + + return $this->parseToUnifiedData($detailData, $allListPagesData); + } + + /** + * @param array $payload + * @return array + */ + protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array + { + $ch = curl_init($this->nodeHost . $endpoint); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + $response = curl_exec($ch); + if (curl_errno($ch)) { + $err = curl_error($ch); + curl_close($ch); + throw new Exception($err); + } + curl_close($ch); + $decoded = json_decode((string) $response, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/application/common/library/scrm/ScrmSpiderInterface.php b/application/common/library/scrm/ScrmSpiderInterface.php new file mode 100644 index 0000000..9a6a7d3 --- /dev/null +++ b/application/common/library/scrm/ScrmSpiderInterface.php @@ -0,0 +1,18 @@ + */ + public array $numbers = []; + + /** @var int 号码总数 */ + public int $total = 0; + + /** + * @param string $number 号码 + * @param bool $isOnline 是否在线 + * @param int $newFollowersToday 今日进线 + */ + public function addNumber(string $number, bool $isOnline, int $newFollowersToday = 0): void + { + $this->numbers[] = [ + 'number' => $number, + 'status' => $isOnline ? 'online' : 'offline', + 'newFollowersToday' => max(0, $newFollowersToday), + ]; + + if ($isOnline) { + $this->totalOnline++; + } else { + $this->totalOffline++; + } + } +} diff --git a/application/common/library/scrm/spider/A2cSpider.php b/application/common/library/scrm/spider/A2cSpider.php new file mode 100644 index 0000000..3ecafb8 --- /dev/null +++ b/application/common/library/scrm/spider/A2cSpider.php @@ -0,0 +1,98 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'detailApi' => self::API_DETAILS, + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'wait', 'ms' => 2000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $total = (int) ($listFirstPageData['data']['total'] ?? 0); + $this->unifiedData->total = $total; + if ($total <= self::DEFAULT_PER_PAGE_COUNT) { + return 1; + } + return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); + } + + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT]; + } + + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.btn-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + if ($detailData) { + $unifiedData->todayNewCount = (int) ($detailData['data']['newFollowersToday'] ?? 0); + } + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['rows'] ?? []; + foreach ($records as $item) { + if (empty($item['account'])) { + continue; + } + $number = (string) $item['account']; + $isOnline = isset($item['numberStatus']) && (int) $item['numberStatus'] === 1; + $unifiedData->addNumber($number, $isOnline, (int) ($item['newFollowersToday'] ?? 0)); + } + } + return $unifiedData; + } +} diff --git a/application/common/library/scrm/spider/HaiwangSpider.php b/application/common/library/scrm/spider/HaiwangSpider.php new file mode 100644 index 0000000..f87ef35 --- /dev/null +++ b/application/common/library/scrm/spider/HaiwangSpider.php @@ -0,0 +1,101 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password], + ['type' => 'press', 'key' => 'Enter'], + ['type' => 'wait', 'ms' => 2000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $total = (int) ($listFirstPageData['data']['total'] ?? 0); + $this->unifiedData->total = $total; + if ($total <= self::DEFAULT_PER_PAGE_COUNT) { + return 1; + } + return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); + } + + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.btn-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['items'] ?? []; + foreach ($records as $item) { + if (empty($item['acclist_account'])) { + continue; + } + $number = (string) $item['acclist_account']; + $isOnline = isset($item['acclist_status']) && (int) $item['acclist_status'] === 2; + $unifiedData->addNumber( + $number, + $isOnline, + (int) ($item['account_statistics_today_effective'] ?? 0) + ); + } + } + if (!empty($allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective'])) { + $unifiedData->todayNewCount = (int) $allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective']; + } + return $unifiedData; + } +} diff --git a/application/common/library/scrm/spider/HuojianSpider.php b/application/common/library/scrm/spider/HuojianSpider.php new file mode 100644 index 0000000..815bec4 --- /dev/null +++ b/application/common/library/scrm/spider/HuojianSpider.php @@ -0,0 +1,89 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'vue_fill', 'selector' => '.el-message-box__input input', 'value' => $this->password], + ['type' => 'wait', 'ms' => 500], + ['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'], + ['type' => 'wait', 'ms' => 2000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + return 1; + } + + protected function buildListPageParams(int $page): array + { + return []; + } + + protected function getUiPaginationConfig(): array + { + return []; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + $unifiedData->todayNewCount = (int) ($allListPagesData[0]['data']['counterWorker']['newTodayFriend'] ?? 0); + $count = 0; + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['counterCsAccountVo'] ?? []; + foreach ($records as $item) { + if (empty($item['accountLogin'])) { + continue; + } + $number = (string) $item['accountLogin']; + $isOnline = isset($item['accountStatus']) && (int) $item['accountStatus'] === 1; + $count++; + $unifiedData->addNumber($number, $isOnline, (int) ($item['newTodayFriend'] ?? 0)); + } + } + $unifiedData->total = $count; + return $unifiedData; + } +} diff --git a/application/common/library/scrm/spider/SsCustomerSpider.php b/application/common/library/scrm/spider/SsCustomerSpider.php new file mode 100644 index 0000000..834b3e4 --- /dev/null +++ b/application/common/library/scrm/spider/SsCustomerSpider.php @@ -0,0 +1,102 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'detailApi' => self::API_DETAILS, + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'wait', 'ms' => 2000], + ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password], + ['type' => 'press', 'key' => 'Enter'], + ['type' => 'wait', 'ms' => 3000], + [ + 'type' => 'vue_click', + 'selector' => 'button[class*="reports-customers__dimension"]', + 'text' => 'social media accounts', + ], + ['type' => 'wait', 'ms' => 3000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + return null; + } + + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT]; + } + + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.arco-pagination-item-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + if ($detailData) { + $unifiedData->todayNewCount = (int) ($detailData['data']['distinct_contacts_total'] ?? 0); + } + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['list'] ?? []; + foreach ($records as $item) { + if (empty($item['channel_tag'])) { + continue; + } + $number = (string) $item['channel_tag']; + $unifiedData->addNumber($number, true, (int) ($item['distinct_contacts_total'] ?? 0)); + } + } + $unifiedData->total = count($unifiedData->numbers); + return $unifiedData; + } +} diff --git a/application/common/library/scrm/spider/XingheSpider.php b/application/common/library/scrm/spider/XingheSpider.php new file mode 100644 index 0000000..27fb2a6 --- /dev/null +++ b/application/common/library/scrm/spider/XingheSpider.php @@ -0,0 +1,93 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'listMethod' => 'GET', + 'paginationMode' => self::MODE_FETCH, + 'authActions' => [ + ['type' => 'wait', 'ms' => 2000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $total = (int) ($listFirstPageData['count'] ?? 0); + $this->unifiedData->total = $total; + $this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0); + if ($total <= self::DEFAULT_PER_PAGE_COUNT) { + return 1; + } + return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); + } + + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.layui-laypage-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data'] ?? []; + foreach ($records as $item) { + if (empty($item['user'])) { + continue; + } + $number = (string) $item['user']; + $isOnline = isset($item['online']) && (int) $item['online'] === 1; + $unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0)); + } + } + return $unifiedData; + } +} diff --git a/application/common/service/SplitScrmSpiderFactory.php b/application/common/service/SplitScrmSpiderFactory.php new file mode 100644 index 0000000..9ab061a --- /dev/null +++ b/application/common/service/SplitScrmSpiderFactory.php @@ -0,0 +1,65 @@ + 云控蜘蛛工厂 + * + * 新增云控类型:在 spider/ 下新增类并在此注册 ticket_type => Class + */ +class SplitScrmSpiderFactory +{ + /** @var array> */ + private const MAP = [ + 'a2c' => A2cSpider::class, + 'haiwang' => HaiwangSpider::class, + 'huojian' => HuojianSpider::class, + 'xinghe' => XingheSpider::class, + 'ss_customer' => SsCustomerSpider::class, + // ceo_scrm 等未实现类型:新增 spider 类后在此注册 + ]; + + /** + * @return class-string|null + */ + public static function resolveClass(string $ticketType): ?string + { + $ticketType = trim($ticketType); + return self::MAP[$ticketType] ?? null; + } + + /** + * 是否已实现蜘蛛 + */ + public static function isSupported(string $ticketType): bool + { + return self::resolveClass($ticketType) !== null; + } + + /** + * @return ScrmSpiderInterface|null + */ + public static function create( + string $ticketType, + string $pageUrl, + string $account = '', + string $password = '', + string $nodeHost = '' + ): ?ScrmSpiderInterface { + $class = self::resolveClass($ticketType); + if ($class === null) { + return null; + } + $host = $nodeHost !== '' ? $nodeHost : SplitSyncConfigService::getNodeHost(); + return new $class($pageUrl, $account, $password, $host); + } +} diff --git a/application/common/service/SplitSyncConfigService.php b/application/common/service/SplitSyncConfigService.php new file mode 100644 index 0000000..222bb65 --- /dev/null +++ b/application/common/service/SplitSyncConfigService.php @@ -0,0 +1,50 @@ +where('name', $name)->value('value'); + return $db !== null ? (string) $db : ''; + } +} diff --git a/application/common/service/SplitTicketNumberSyncService.php b/application/common/service/SplitTicketNumberSyncService.php new file mode 100644 index 0000000..f99a4b4 --- /dev/null +++ b/application/common/service/SplitTicketNumberSyncService.php @@ -0,0 +1,141 @@ +numbers as $row) { + $number = trim((string) ($row['number'] ?? '')); + if ($number === '') { + continue; + } + $syncedNumbers[$number] = $row; + } + + $existingList = Number::where('admin_id', $adminId) + ->where('split_link_id', $linkId) + ->where('ticket_name', $ticketName) + ->select(); + + $existingMap = []; + foreach ($existingList as $item) { + $existingMap[(string) $item['number']] = $item; + } + + foreach ($syncedNumbers as $number => $row) { + $platformStatus = ($row['status'] ?? '') === 'online' ? 'online' : 'offline'; + $newFollowers = (int) ($row['newFollowersToday'] ?? 0); + + if (isset($existingMap[$number])) { + $this->updateExistingNumber($existingMap[$number], $platformStatus, $newFollowers); + continue; + } + + $this->insertNumber($ticket, $number, $platformStatus, $newFollowers); + } + + foreach ($existingMap as $number => $item) { + if (isset($syncedNumbers[$number])) { + continue; + } + if ((int) $item['manual_manage'] === 1) { + continue; + } + Number::where('id', (int) $item['id'])->update([ + 'status' => 'hidden', + 'updatetime' => time(), + ]); + } + } + + /** + * @param Number $row + */ + private function updateExistingNumber($row, string $platformStatus, int $newFollowers): void + { + $update = [ + 'platform_status' => $platformStatus, + 'updatetime' => time(), + ]; + + if ((int) $row['manual_manage'] === 1) { + Number::where('id', (int) $row['id'])->update($update); + return; + } + + $update['status'] = $platformStatus === 'online' ? 'normal' : 'hidden'; + if ($update['status'] === 'normal') { + $update['inbound_count'] = max(0, $newFollowers); + } + + Number::where('id', (int) $row['id'])->update($update); + } + + private function insertNumber(Ticket $ticket, string $number, string $platformStatus, int $newFollowers): void + { + $status = $platformStatus === 'online' ? 'normal' : 'hidden'; + $now = time(); + $data = [ + 'admin_id' => (int) $ticket['admin_id'], + 'split_link_id' => (int) $ticket['split_link_id'], + 'ticket_name' => (string) $ticket['ticket_name'], + 'number' => $number, + 'number_type' => (string) $ticket['number_type'], + 'number_type_custom' => (string) ($ticket['number_type_custom'] ?? ''), + 'visit_count' => 0, + 'inbound_count' => $status === 'normal' ? max(0, $newFollowers) : 0, + 'manual_manage' => 0, + 'platform_status' => $platformStatus, + 'status' => $status, + 'createtime' => $now, + 'updatetime' => $now, + ]; + + try { + Db::name('split_number')->insert($data); + } catch (\Throwable $e) { + $exists = Number::where('split_link_id', (int) $ticket['split_link_id']) + ->where('number', $number) + ->find(); + if ($exists) { + $this->updateExistingNumber($exists, $platformStatus, $newFollowers); + } + } + } + + /** + * 汇总工单进线人数(仅开启状态的号码) + */ + public function sumInboundForTicket(Ticket $ticket): int + { + $sum = 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('status', 'normal') + ->sum('inbound_count'); + return (int) $sum; + } +} diff --git a/application/common/service/SplitTicketRuleService.php b/application/common/service/SplitTicketRuleService.php new file mode 100644 index 0000000..d3ff0f3 --- /dev/null +++ b/application/common/service/SplitTicketRuleService.php @@ -0,0 +1,149 @@ +applyNumberRules($ticket); + $this->applyTicketStatusRules($ticket, $completeCount); + $this->cascadeTicketClosedToNumbers($ticket); + } + + /** + * 同步流程:工单因时间/完成量等原因关闭时,联动关闭非手动号码 + */ + public function cascadeTicketClosedToNumbers(Ticket $ticket): void + { + if ((string) ($ticket['status'] ?? 'hidden') !== 'hidden') { + return; + } + 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) + ->update([ + 'status' => 'hidden', + 'updatetime' => time(), + ]); + } + + /** + * 手动切换工单状态时,联动非手动管理的号码(开→全开,关→全关) + */ + public function syncNumbersWithTicketStatus(Ticket $ticket): void + { + $ticketStatus = (string) ($ticket['status'] ?? 'hidden'); + 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) + ->update([ + 'status' => $ticketStatus, + 'updatetime' => time(), + ]); + } + + /** + * 单号上限、下号比率 + */ + public function applyNumberRules(Ticket $ticket): void + { + $orderLimit = (int) ($ticket['order_limit'] ?? 0); + $assignRatio = (int) ($ticket['assign_ratio'] ?? 0); + + $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) { + $visitCount = (int) $number['visit_count']; + $inboundCount = (int) $number['inbound_count']; + $lastVisit = (int) ($number['last_sync_visit_count'] ?? 0); + $lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0); + $streak = (int) ($number['no_inbound_click_streak'] ?? 0); + + if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) { + $streak += ($visitCount - $lastVisit); + } elseif ($inboundCount > $lastInbound) { + $streak = 0; + } + + $status = (string) $number['status']; + if ($orderLimit > 0 && $inboundCount > $orderLimit) { + $status = 'hidden'; + } + if ($assignRatio > 0 && $streak >= $assignRatio) { + $status = 'hidden'; + } + + Number::where('id', (int) $number['id'])->update([ + 'no_inbound_click_streak' => $streak, + 'last_sync_visit_count' => $visitCount, + 'last_sync_inbound_count' => $inboundCount, + 'status' => $status, + 'updatetime' => time(), + ]); + } + } + + /** + * 完成量、时间窗口决定工单开关 + */ + public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void + { + $status = $this->resolveTicketStatus($ticket, $completeCount); + if ($status !== (string) $ticket['status']) { + Ticket::where('id', (int) $ticket['id'])->update([ + 'status' => $status, + 'updatetime' => time(), + ]); + $ticket['status'] = $status; + } + } + + /** + * 是否处于允许同步/开启的时间窗口 + */ + public function isWithinTimeWindow(Ticket $ticket, ?int $now = null): bool + { + $now = $now ?? time(); + $start = $ticket['start_time'] ?? null; + $end = $ticket['end_time'] ?? null; + if ($start !== null && $start !== '' && (int) $start > 0 && $now < (int) $start) { + return false; + } + if ($end !== null && $end !== '' && (int) $end > 0 && $now > (int) $end) { + return false; + } + return true; + } + + private function resolveTicketStatus(Ticket $ticket, int $completeCount): string + { + if (!$this->isWithinTimeWindow($ticket)) { + return 'hidden'; + } + + $ticketTotal = (int) ($ticket['ticket_total'] ?? 0); + if ($ticketTotal > 0) { + return $completeCount >= $ticketTotal ? 'hidden' : 'normal'; + } + + return 'normal'; + } +} diff --git a/application/common/service/SplitTicketSyncLockService.php b/application/common/service/SplitTicketSyncLockService.php new file mode 100644 index 0000000..cef85d5 --- /dev/null +++ b/application/common/service/SplitTicketSyncLockService.php @@ -0,0 +1,64 @@ +lockPath($ticketId); + if ($this->isStaleLock($path)) { + @unlink($path); + } + if (is_file($path)) { + return false; + } + $payload = json_encode([ + 'ticket_id' => $ticketId, + 'pid' => getmypid(), + 'time' => time(), + ], JSON_UNESCAPED_UNICODE); + $written = @file_put_contents($path, $payload, LOCK_EX); + return $written !== false; + } + + /** + * 释放锁 + */ + public function release(int $ticketId): void + { + $path = $this->lockPath($ticketId); + if (is_file($path)) { + @unlink($path); + } + } + + private function lockPath(int $ticketId): string + { + $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); + $dir = $runtime . 'split_ticket_sync/'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + return $dir . $ticketId . '.lock'; + } + + private function isStaleLock(string $path): bool + { + if (!is_file($path)) { + return false; + } + $mtime = (int) @filemtime($path); + return $mtime > 0 && (time() - $mtime) > self::LOCK_TTL; + } +} diff --git a/application/common/service/SplitTicketSyncService.php b/application/common/service/SplitTicketSyncService.php new file mode 100644 index 0000000..610c677 --- /dev/null +++ b/application/common/service/SplitTicketSyncService.php @@ -0,0 +1,255 @@ +numberSync = new SplitTicketNumberSyncService(); + $this->ruleService = new SplitTicketRuleService(); + $this->lockService = new SplitTicketSyncLockService(); + } + + /** + * 同步单条工单 + * + * @return array{success:bool,message:string,skipped?:bool} + */ + public function syncOne(int $ticketId, bool $force = false): array + { + $ticket = Ticket::get($ticketId); + if (!$ticket) { + return ['success' => false, 'message' => '工单不存在']; + } + + if (!$force) { + $skip = $this->shouldSkip($ticket); + if ($skip !== null) { + return ['success' => false, 'message' => $skip, 'skipped' => true]; + } + } + + if (!$this->lockService->acquire($ticketId)) { + return ['success' => false, 'message' => '工单正在同步中', 'skipped' => true]; + } + + try { + return $this->doSync($ticket); + } finally { + $this->lockService->release($ticketId); + } + } + + /** + * 扫描到期工单并同步 + */ + public function syncDueTickets(): int + { + $count = 0; + $list = Ticket::where('status', 'normal') + ->where('sync_fail_count', '<', self::FAIL_PAUSE_THRESHOLD) + ->select(); + + foreach ($list as $ticket) { + $skip = $this->shouldSkip($ticket); + if ($skip !== null) { + continue; + } + $result = $this->syncOne((int) $ticket['id'], false); + if (!empty($result['skipped'])) { + continue; + } + $count++; + } + + return $count; + } + + /** + * @return array{success:bool,message:string} + */ + private function doSync(Ticket $ticket): array + { + $ticketType = (string) $ticket['ticket_type']; + $pageUrl = trim((string) $ticket['ticket_url']); + if ($pageUrl === '') { + $this->markFailure($ticket, '工单链接为空'); + return ['success' => false, 'message' => '工单链接为空']; + } + + if (!SplitScrmSpiderFactory::isSupported($ticketType)) { + $this->markFailure($ticket, '工单类型尚未实现蜘蛛'); + return ['success' => false, 'message' => '工单类型尚未实现蜘蛛']; + } + + $spider = SplitScrmSpiderFactory::create( + $ticketType, + $pageUrl, + (string) ($ticket['account'] ?? ''), + (string) ($ticket['password'] ?? '') + ); + if ($spider === null) { + $this->markFailure($ticket, '无法创建蜘蛛实例'); + return ['success' => false, 'message' => '无法创建蜘蛛实例']; + } + + Db::startTrans(); + try { + $finalData = $spider->run(); + if (!$finalData instanceof UnifiedScrmData) { + throw new Exception('蜘蛛返回数据无效'); + } + + $this->numberSync->syncFromUnifiedData($ticket, $finalData); + $this->ruleService->applyNumberRules($ticket); + + $completeCount = max(0, $finalData->todayNewCount); + $this->ruleService->applyTicketStatusRules($ticket, $completeCount); + + $freshTicket = Ticket::get((int) $ticket['id']); + if ($freshTicket) { + $this->ruleService->cascadeTicketClosedToNumbers($freshTicket); + $ticket = $freshTicket; + } + + $inboundCount = $this->numberSync->sumInboundForTicket($ticket); + $speed = $this->calcSpeedPerHour($ticket, $completeCount); + + $payload = [ + 'complete_count' => $completeCount, + 'inbound_count' => $inboundCount, + 'speed_per_hour' => $speed['speed'], + 'number_count' => max(0, $finalData->total), + 'number_offline_count' => max(0, $finalData->totalOffline), + 'number_banned_count' => 0, + 'online_count' => max(0, $finalData->totalOnline), + 'sync_fail_count' => 0, + 'speed_snapshot_count' => $speed['snapshot_count'], + 'speed_snapshot_time' => $speed['snapshot_time'], + ]; + + $this->applySyncResult($ticket, $payload, true, ''); + Db::commit(); + return ['success' => true, 'message' => '同步成功']; + } catch (\Throwable $e) { + Db::rollback(); + $msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8'); + $this->markFailure($ticket, $msg); + return ['success' => false, 'message' => $msg]; + } + } + + private function shouldSkip(Ticket $ticket): ?string + { + if ((string) $ticket['status'] === 'hidden') { + return '工单已关闭'; + } + if ((int) ($ticket['sync_fail_count'] ?? 0) >= self::FAIL_PAUSE_THRESHOLD) { + return '连续同步失败超过5次已暂停'; + } + if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) { + return '工单类型尚未实现'; + } + $interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']); + if ($interval <= 0) { + return '该类型未配置自动同步周期'; + } + $lastSync = (int) ($ticket['sync_time'] ?? 0); + if ($lastSync > 0 && (time() - $lastSync) < ($interval * 60)) { + return '未到同步周期'; + } + return null; + } + + /** + * @param array $payload + */ + public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void + { + $data = [ + 'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)), + 'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)), + 'speed_per_hour' => max(0, (float) ($payload['speed_per_hour'] ?? 0)), + 'number_count' => max(0, (int) ($payload['number_count'] ?? 0)), + 'number_offline_count' => max(0, (int) ($payload['number_offline_count'] ?? 0)), + 'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)), + 'online_count' => max(0, (int) ($payload['online_count'] ?? 0)), + 'sync_status' => $success ? 'success' : 'error', + 'sync_time' => time(), + 'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'), + 'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1), + 'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0), + 'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0), + ]; + if (!$ticket->allowField(array_keys($data))->save($data)) { + throw new Exception('工单同步结果保存失败'); + } + } + + private function markFailure(Ticket $ticket, string $message): void + { + $failCount = (int) ($ticket['sync_fail_count'] ?? 0) + 1; + $ticket->save([ + 'sync_status' => 'error', + 'sync_time' => time(), + 'sync_message' => mb_substr($message, 0, 255, 'UTF-8'), + 'sync_fail_count' => $failCount, + ]); + } + + /** + * @return array{speed:float,snapshot_count:int,snapshot_time:int} + */ + private function calcSpeedPerHour(Ticket $ticket, int $currentComplete): array + { + $now = time(); + $snapshotTime = (int) ($ticket['speed_snapshot_time'] ?? 0); + $snapshotCount = (int) ($ticket['speed_snapshot_count'] ?? 0); + + if ($snapshotTime <= 0) { + return [ + 'speed' => 0.0, + 'snapshot_count' => $currentComplete, + 'snapshot_time' => $now, + ]; + } + + $elapsed = $now - $snapshotTime; + if ($elapsed >= 3600) { + return [ + 'speed' => 0.0, + 'snapshot_count' => $currentComplete, + 'snapshot_time' => $now, + ]; + } + + $hours = $elapsed > 0 ? ($elapsed / 3600) : 0; + $delta = $currentComplete - $snapshotCount; + $speed = ($delta < 0 || $hours <= 0) ? 0.0 : round($delta / $hours, 2); + + return [ + 'speed' => $speed, + 'snapshot_count' => $snapshotCount, + 'snapshot_time' => $snapshotTime, + ]; + } +} diff --git a/application/extra/site.php b/application/extra/site.php index 6612b0e..daf37c5 100755 --- a/application/extra/site.php +++ b/application/extra/site.php @@ -42,8 +42,17 @@ return array ( 'category2' => 'Category2', 'custom' => 'Custom', ), - 'split_platform_domain' => 'link1.com -link2.com -link3.com -link4.com', + 'split_platform_domain' => 'links.test', + '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_custome' => '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' => '0', + 'split_sync_interval_sihai' => '0', ); diff --git a/links.test_wzXtw.zip b/links.test_wzXtw.zip new file mode 100755 index 0000000..be1a117 Binary files /dev/null and b/links.test_wzXtw.zip differ diff --git a/patches/application/admin/command/Install/split_config_sync.sql b/patches/application/admin/command/Install/split_config_sync.sql new file mode 100644 index 0000000..f7c6837 --- /dev/null +++ b/patches/application/admin/command/Install/split_config_sync.sql @@ -0,0 +1,54 @@ +-- 工单云控同步:Node 服务地址与各类型同步周期(分钟,0=不自动同步) +SET NAMES utf8mb4; + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_scrm_node_host', 'split', '云控 Node 服务地址', '所有云控蜘蛛共用的 Headless 服务根地址', 'string', '', 'http://127.0.0.1:3001', '', '', '', NULL +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`) +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); + +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 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_a2c' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_haiwang', 'split', '海王同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_haiwang' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_huojian', 'split', '火箭云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_huojian' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_xinghe', 'split', '星河云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_xinghe' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_ss_customer', 'split', 'SS云控(Customer)同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_ceo_scrm', 'split', 'CEO SCRM同步周期(分钟)', '0 表示不自动同步(蜘蛛未实现)', 'number', '', '0', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ceo_scrm' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_taiji', 'split', '太极云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_taiji' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_ss_channel', 'split', 'SS云控(Channel)同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_channel' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_yifafa', 'split', '译发发云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_yifafa' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_whatshub', 'split', 'Whatshub云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_whatshub' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_sihai', 'split', '四海云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_sihai' LIMIT 1); diff --git a/patches/application/admin/command/Install/split_number.sql b/patches/application/admin/command/Install/split_number.sql index 214683b..939b10f 100644 --- a/patches/application/admin/command/Install/split_number.sql +++ b/patches/application/admin/command/Install/split_number.sql @@ -15,6 +15,7 @@ CREATE TABLE `fa_split_number` ( `inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数', `manual_manage` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '手动管理:0=否,1=是', `status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用', + `weigh` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '排序权重(越大越靠前)', `createtime` bigint(16) DEFAULT NULL COMMENT '创建时间', `updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), @@ -22,6 +23,7 @@ CREATE TABLE `fa_split_number` ( KEY `admin_id` (`admin_id`), KEY `split_link_id` (`split_link_id`), KEY `status` (`status`), + KEY `weigh` (`weigh`), KEY `number` (`number`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='分流号码表'; diff --git a/patches/application/admin/command/Install/split_number_weigh.sql b/patches/application/admin/command/Install/split_number_weigh.sql new file mode 100644 index 0000000..fa3d60d --- /dev/null +++ b/patches/application/admin/command/Install/split_number_weigh.sql @@ -0,0 +1,7 @@ +-- 号码表:排序权重(可选,跳转轮转按 id 顺序;随机打乱通过新号码随机插入顺序实现) +SET NAMES utf8mb4; + +ALTER TABLE `fa_split_number` + ADD COLUMN `weigh` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '排序权重(越大越靠前)' AFTER `status`; + +UPDATE `fa_split_number` SET `weigh` = `id` WHERE `weigh` = 0; diff --git a/patches/application/admin/command/Install/split_ticket_sync.sql b/patches/application/admin/command/Install/split_ticket_sync.sql new file mode 100644 index 0000000..66b99fb --- /dev/null +++ b/patches/application/admin/command/Install/split_ticket_sync.sql @@ -0,0 +1,20 @@ +-- 工单云控定时同步:扩展字段 + 手动同步权限节点 +SET NAMES utf8mb4; + +ALTER TABLE `fa_split_ticket` + ADD COLUMN `sync_fail_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '连续同步失败次数' AFTER `sync_message`, + ADD COLUMN `speed_snapshot_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '速度计算基准完成数' AFTER `sync_fail_count`, + ADD COLUMN `speed_snapshot_time` bigint(16) DEFAULT NULL COMMENT '速度计算基准时间' AFTER `speed_snapshot_count`; + +ALTER TABLE `fa_split_number` + ADD COLUMN `platform_status` enum('online','offline','unknown') NOT NULL DEFAULT 'unknown' COMMENT '云控平台在线状态' AFTER `manual_manage`, + ADD COLUMN `last_sync_visit_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '上次同步时访问次数' AFTER `platform_status`, + ADD COLUMN `last_sync_inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '上次同步时进线人数' AFTER `last_sync_visit_count`, + ADD COLUMN `no_inbound_click_streak` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线无增长期间连续点击增量' AFTER `last_sync_inbound_count`; + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', m.id, 'split.ticket/sync', '同步状态', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` m +WHERE m.name = 'split.ticket' AND m.ismenu = 1 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/sync' LIMIT 1) +LIMIT 1; diff --git a/patches/application/admin/command/SplitSyncTickets.php b/patches/application/admin/command/SplitSyncTickets.php new file mode 100644 index 0000000..f6f317c --- /dev/null +++ b/patches/application/admin/command/SplitSyncTickets.php @@ -0,0 +1,60 @@ +setName('split:sync-tickets') + ->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '') + ->setDescription('同步分流工单云控数据'); + } + + protected function execute(Input $input, Output $output): void + { + set_time_limit(0); + SplitTicketSyncLogger::log('cli', 'command start', [ + 'ticketOption' => trim((string) $input->getOption('ticket')), + 'appDebug' => SplitTicketSyncLogger::isEnabled(), + ]); + $service = new SplitTicketSyncService(); + $ticketId = trim((string) $input->getOption('ticket')); + + if ($ticketId !== '' && ctype_digit($ticketId)) { + $result = $service->syncOne((int) $ticketId, true); + if (!empty($result['skipped'])) { + $output->writeln('跳过: ' . ($result['message'] ?? '') . ''); + return; + } + if ($result['success']) { + $output->writeln('工单 #' . $ticketId . ' 同步成功'); + } else { + $output->writeln('工单 #' . $ticketId . ' 同步失败: ' . ($result['message'] ?? '') . ''); + } + return; + } + + $count = $service->syncDueTickets(); + $output->writeln('本次处理工单数: ' . $count . ''); + if (SplitTicketSyncLogger::isEnabled()) { + $output->writeln('调试日志已写入 runtime/log/split_sync.log'); + } + } +} diff --git a/patches/application/admin/controller/split/Number.php b/patches/application/admin/controller/split/Number.php index a3cd1b8..bb63358 100644 --- a/patches/application/admin/controller/split/Number.php +++ b/patches/application/admin/controller/split/Number.php @@ -33,6 +33,9 @@ class Number extends Backend protected $modelSceneValidate = true; + /** @var string */ + protected $multiFields = 'status,manual_manage'; + /** @var string[] */ protected $noNeedRight = ['script']; @@ -58,6 +61,7 @@ class Number extends Backend $this->assignconfig('numberTypeList', $this->model->getNumberTypeList()); $this->assignconfig('statusList', $this->model->getStatusList()); $this->assignconfig('manualManageList', $this->model->getManualManageList()); + $this->assignconfig('platformStatusList', $this->model->getPlatformStatusList()); $this->setupPatchFrontend(); } @@ -126,6 +130,59 @@ class Number extends Backend return (string) $this->view->fetch($file); } + /** + * 列表状态开关:手动关闭时标记 manual_manage=1,防止同步自动恢复开启 + * + * @param string $ids + */ + public function multi($ids = '') + { + if (!$this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids', ''); + if ($ids === '') { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + + $params = $this->request->post('params', ''); + parse_str((string) $params, $values); + if (!isset($values['status'])) { + parent::multi($ids); + return; + } + + if ((string) $values['status'] === 'hidden') { + $values['manual_manage'] = 1; + } elseif ((string) $values['status'] === 'normal') { + $values['manual_manage'] = 0; + } + + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $this->model->where($this->dataLimitField, 'in', $adminIds); + } + $count = 0; + Db::startTrans(); + try { + $list = $this->model->where($this->model->getPk(), 'in', $ids)->select(); + foreach ($list as $item) { + $count += $item->allowField(true)->isUpdate(true)->save($values); + } + Db::commit(); + } catch (ValidateException $e) { + Db::rollback(); + $this->error($e->getMessage()); + } catch (PDOException|Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + if ($count > 0) { + $this->success(); + } + $this->error(__('No rows were updated')); + } + /** * @return string|Json * @throws DbException diff --git a/patches/application/admin/controller/split/Ticket.php b/patches/application/admin/controller/split/Ticket.php index 3d01b03..5d64e8b 100644 --- a/patches/application/admin/controller/split/Ticket.php +++ b/patches/application/admin/controller/split/Ticket.php @@ -7,6 +7,9 @@ namespace app\admin\controller\split; use app\admin\model\split\Link as LinkModel; use app\admin\model\split\Ticket as TicketModel; use app\common\controller\Backend; +use app\common\service\SplitTicketRuleService; +use app\common\service\SplitTicketSyncLogger; +use app\common\service\SplitTicketSyncService; use think\Db; use think\Lang; use think\Loader; @@ -55,6 +58,12 @@ class Ticket extends Backend $this->assignconfig('ticketTypeList', $this->model->getTicketTypeList()); $this->assignconfig('numberTypeList', $this->model->getNumberTypeList()); $this->assignconfig('statusList', $this->model->getStatusList()); + $this->assignconfig([ + 'syncConfirmMsg' => __('Sync confirm'), + 'syncBackgroundStartedMsg' => __('Sync background started'), + 'syncInProgressMsg' => __('Sync in progress'), + 'syncTicketStartedMsg' => __('Sync ticket started'), + ]); $this->setupPatchFrontend(); } @@ -199,11 +208,127 @@ class Ticket extends Backend $params['sync_status'], $params['sync_time'], $params['sync_message'], + $params['sync_fail_count'], + $params['speed_snapshot_count'], + $params['speed_snapshot_time'], $params['click_count'] ); return $params; } + /** + * 手动同步选中工单 + */ + public function sync(): void + { + if (!$this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $this->request->post('ids', ''); + if ($ids === '') { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $adminIds = $this->getDataLimitAdminIds(); + $pk = $this->model->getPk(); + $list = $this->model->where($pk, 'in', $ids)->select(); + if (!$list || count($list) === 0) { + $this->error(__('No Results were found')); + } + + SplitTicketSyncLogger::log('web', 'manual sync request', [ + 'ids' => $ids, + 'appDebug' => SplitTicketSyncLogger::isEnabled(), + ]); + + $service = new SplitTicketSyncService(); + $ok = 0; + $fail = 0; + $messages = []; + + foreach ($list as $row) { + if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) { + $fail++; + $messages[] = '#' . $row['id'] . ': 无权限'; + continue; + } + $result = $service->syncOne((int) $row['id'], true); + if ($result['success']) { + $ok++; + } else { + $fail++; + $messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败'); + } + } + + $summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail); + if ($fail > 0) { + $summary .= ';' . implode(';', array_slice($messages, 0, 5)); + } + $this->success($summary); + } + + /** + * 批量更新:工单状态变更时联动号码 + * + * @param string $ids + */ + public function multi($ids = '') + { + if (!$this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids', ''); + if ($ids === '') { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + + $params = $this->request->post('params', ''); + parse_str((string) $params, $values); + $ruleService = new SplitTicketRuleService(); + + if (isset($values['status'])) { + $pk = $this->model->getPk(); + $adminIds = $this->getDataLimitAdminIds(); + $query = $this->model->where($pk, 'in', $ids); + if (is_array($adminIds)) { + $query->where($this->dataLimitField, 'in', $adminIds); + } + $rows = $query->select(); + if (!$rows || count($rows) === 0) { + $this->error(__('No Results were found')); + } + + $count = 0; + Db::startTrans(); + try { + foreach ($rows as $item) { + $count += $item->allowField(true)->isUpdate(true)->save($values); + } + Db::commit(); + } catch (ValidateException $e) { + Db::rollback(); + $this->error($e->getMessage()); + } catch (PDOException|Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + + foreach ($rows as $row) { + $fresh = $this->model->get($row['id']); + if ($fresh) { + $ruleService->syncNumbersWithTicketStatus($fresh); + } + } + + if ($count > 0) { + $this->success(); + } + $this->error(__('No rows were updated')); + } + + parent::multi($ids); + } + /** * @return string * @throws DbException @@ -244,7 +369,7 @@ class Ticket extends Backend if ($result === false) { $this->error(__('No rows were inserted')); } - $this->success(); + $this->success('', null, ['id' => (int) $this->model->id]); } /** @@ -277,6 +402,7 @@ class Ticket extends Backend if (($params['number_type'] ?? '') !== 'custom') { $params['number_type_custom'] = ''; } + $oldStatus = (string) ($row['status'] ?? 'hidden'); $result = false; Db::startTrans(); try { @@ -297,6 +423,12 @@ class Ticket extends Backend if ($result === false) { $this->error(__('No rows were updated')); } + if (isset($params['status']) && (string) $params['status'] !== $oldStatus) { + $fresh = $this->model->get($row['id']); + if ($fresh) { + (new SplitTicketRuleService())->syncNumbersWithTicketStatus($fresh); + } + } $this->success(); } diff --git a/patches/application/admin/lang/zh-cn/split/number.php b/patches/application/admin/lang/zh-cn/split/number.php index 7e90daf..bc90983 100644 --- a/patches/application/admin/lang/zh-cn/split/number.php +++ b/patches/application/admin/lang/zh-cn/split/number.php @@ -13,6 +13,10 @@ return [ 'Inbound_count' => '进线人数', 'Status' => '状态', 'Manual_manage' => '手动管理', + 'Platform_status' => '平台状态', + 'Platform status online' => '在线', + 'Platform status offline' => '离线', + 'Platform status unknown' => '未知', 'Createtime' => '创建时间', 'Section basic' => '基础信息', 'Status normal' => '正常', diff --git a/patches/application/admin/lang/zh-cn/split/ticket.php b/patches/application/admin/lang/zh-cn/split/ticket.php index 1962e87..78d7f21 100644 --- a/patches/application/admin/lang/zh-cn/split/ticket.php +++ b/patches/application/admin/lang/zh-cn/split/ticket.php @@ -24,10 +24,18 @@ return [ 'Number_count' => '号码数量', 'Number_count_detail' => '离线 %s / 封号 %s', 'Sync_status' => '同步状态', + 'Sync_status_btn' => '同步状态', + 'Sync running' => '正在同步,请稍候…', + 'Sync confirm' => '确定同步所选工单吗?任务将在后台执行,完成后自动提示结果。', + 'Sync background started' => '同步任务已在后台执行,请稍候…', + 'Sync in progress' => '同步中', + 'Sync ticket started' => '正在同步工单', + 'Sync done' => '同步完成', 'Sync status success' => '同步成功', 'Sync status error' => '同步异常', 'Sync status pending' => '待同步', - 'Sync display success' => '同步成功 / 在线人数 %s', + 'Sync display success' => '同步成功 / 在线 %s', + 'Sync display pending' => '待同步', 'Sync display error' => '同步异常', 'Createtime' => '创建时间', 'Section basic' => '基础信息', diff --git a/patches/application/admin/model/split/Number.php b/patches/application/admin/model/split/Number.php index c0faad5..2085f63 100644 --- a/patches/application/admin/model/split/Number.php +++ b/patches/application/admin/model/split/Number.php @@ -15,6 +15,21 @@ class Number extends Model { protected $name = 'split_number'; + protected static function init(): void + { + self::beforeInsert(function (self $row): void { + if ((int) ($row['weigh'] ?? 0) > 0) { + return; + } + $linkId = (int) ($row['split_link_id'] ?? 0); + if ($linkId <= 0) { + return; + } + $maxWeigh = (int) self::where('split_link_id', $linkId)->max('weigh'); + $row->setAttr('weigh', $maxWeigh + 1); + }); + } + protected $autoWriteTimestamp = 'integer'; protected $createTime = 'createtime'; @@ -26,6 +41,7 @@ class Number extends Model 'link_url_text', 'status_text', 'manual_manage_text', + 'platform_status_text', ]; /** @@ -65,6 +81,18 @@ class Number extends Model ]; } + /** + * @return array + */ + public function getPlatformStatusList(): array + { + return [ + 'online' => __('Platform status online'), + 'offline' => __('Platform status offline'), + 'unknown' => __('Platform status unknown'), + ]; + } + /** * 关联分流链接 */ @@ -123,6 +151,13 @@ class Number extends Model return $list[$key] ?? $key; } + public function getPlatformStatusTextAttr($value, $data): string + { + $key = (string) ($data['platform_status'] ?? 'unknown'); + $list = $this->getPlatformStatusList(); + return $list[$key] ?? $key; + } + /** * 根据链接码生成完整分流 URL */ diff --git a/patches/application/admin/model/split/Ticket.php b/patches/application/admin/model/split/Ticket.php index d5ba7a6..ae3a552 100644 --- a/patches/application/admin/model/split/Ticket.php +++ b/patches/application/admin/model/split/Ticket.php @@ -127,7 +127,7 @@ class Ticket extends Model $total = (int) ($data['ticket_total'] ?? 0); $complete = (int) ($data['complete_count'] ?? 0); if ($total <= 0) { - return '—'; + return '0%'; } $percent = round($complete / $total * 100, 2); return $percent . '%'; @@ -163,6 +163,9 @@ class Ticket extends Model if ($status === 'success') { return sprintf((string) __('Sync display success'), $online); } + if ($status === 'pending') { + return (string) __('Sync display pending'); + } return (string) __('Sync display error'); } diff --git a/patches/application/admin/view/split/ticket/index.html b/patches/application/admin/view/split/ticket/index.html index 06106e7..71dfa41 100644 --- a/patches/application/admin/view/split/ticket/index.html +++ b/patches/application/admin/view/split/ticket/index.html @@ -1,3 +1,29 @@ +
{:build_heading(null,FALSE)} @@ -11,6 +37,7 @@ {:__('Add')} {:__('Edit')} {:__('Delete')} + {:__('Sync_status_btn')}
nodeHost = rtrim($nodeHost, '/'); + } + + /** @return array */ + abstract protected function getSpiderConfig(): array; + + /** + * @param array|null $countData + */ + abstract protected function extractListTotalPages($listFirstPageData, $countData = null); + + /** + * @return array + */ + abstract protected function buildListPageParams(int $page): array; + + /** @return array */ + abstract protected function getUiPaginationConfig(): array; + + /** + * @param mixed $detailData + * @param array $allListPagesData + */ + abstract protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData; + + public function run(): UnifiedScrmData + { + $config = $this->getSpiderConfig(); + SplitTicketSyncLogger::log('spider', 'run start', [ + 'nodeHost' => $this->nodeHost, + 'pageUrl' => $config['pageUrl'] ?? '', + 'listApi' => $config['listApi'] ?? '', + 'paginationMode' => $config['paginationMode'] ?? self::MODE_FETCH, + ]); + + $listApi = (string) ($config['listApi'] ?? ''); + $detailApi = $config['detailApi'] ?? null; + $countApi = $config['countApi'] ?? null; + + $apiUrlsToIntercept = [$listApi]; + if ($detailApi) { + $apiUrlsToIntercept[] = $detailApi; + } + if ($countApi) { + $apiUrlsToIntercept[] = $countApi; + } + + $initResult = $this->requestNode('/api/auth-and-intercept', [ + 'pageUrl' => $config['pageUrl'], + 'apiUrls' => $apiUrlsToIntercept, + 'authActions' => $config['authActions'] ?? [], + ]); + + if (empty($initResult['success'])) { + SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [ + 'error' => $initResult['error'] ?? '未知', + ]); + throw new Exception('初始化失败: ' . ($initResult['error'] ?? '未知')); + } + + $interceptedApis = $initResult['interceptedApis']; + SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [ + 'intercepted' => array_keys($interceptedApis), + ]); + $cookies = $initResult['cookies']; + + if (!isset($interceptedApis[$listApi])) { + throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]"); + } + + $detailData = $detailApi && isset($interceptedApis[$detailApi]) + ? $interceptedApis[$detailApi]['data'] : null; + $countData = $countApi && isset($interceptedApis[$countApi]) + ? $interceptedApis[$countApi]['data'] : null; + + $listApiNode = $interceptedApis[$listApi]; + $allListPagesData = [$listApiNode['data']]; + + $totalPages = $this->extractListTotalPages($listApiNode['data'], $countData); + $mode = $config['paginationMode'] ?? self::MODE_FETCH; + SplitTicketSyncLogger::log('spider', 'pagination plan', [ + 'totalPages' => $totalPages, + 'mode' => $mode, + ]); + + if ($totalPages > 1 || $totalPages === null) { + if ($mode === self::MODE_FETCH && $totalPages !== null) { + $paramList = []; + for ($page = 2; $page <= $totalPages; $page++) { + $paramList[] = $this->buildListPageParams($page); + } + + $fetchResult = $this->requestNode('/api/batch-fetch', [ + 'tasks' => [[ + 'apiPath' => $listApi, + 'fullUrl' => $listApiNode['url'], + 'headers' => $listApiNode['headers'] ?? '', + 'paramList' => $paramList, + 'method' => $config['listMethod'] ?? 'GET', + ]], + 'cookies' => $cookies, + ], 120); + + if (!empty($fetchResult['success'])) { + foreach ($fetchResult['results'][$listApi] as $pResult) { + if (!empty($pResult['success'])) { + $allListPagesData[] = $pResult['data']; + } + } + } + } elseif ($mode === self::MODE_UI) { + $uiConfig = $this->getUiPaginationConfig(); + $firstPageData = $listApiNode['data']; + $clicksToPerform = ($totalPages === null) ? 9999 : ($totalPages - 1); + + $uiResult = $this->requestNode('/api/ui-pagination', [ + 'apiUrl' => $listApi, + 'pageUrl' => $config['pageUrl'], + 'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '', + 'waitMs' => $uiConfig['waitMs'] ?? 2000, + 'clicksToPerform' => $clicksToPerform, + 'cookies' => $cookies, + 'firstPageData' => $firstPageData, + 'authActions' => $config['authActions'] ?? [], + ], 1200); + + if (!empty($uiResult['success']) && !empty($uiResult['data'])) { + foreach ($uiResult['data'] as $pageData) { + $allListPagesData[] = $pageData; + } + } + } + } + + $result = $this->parseToUnifiedData($detailData, $allListPagesData); + SplitTicketSyncLogger::log('spider', 'run done', [ + 'todayNewCount' => $result->todayNewCount, + 'totalOnline' => $result->totalOnline, + 'totalOffline' => $result->totalOffline, + 'total' => $result->total, + 'numberCount' => count($result->numbers), + ]); + return $result; + } + + /** + * @param array $payload + * @return array + */ + protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array + { + $url = $this->nodeHost . $endpoint; + $started = microtime(true); + SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [ + 'url' => $url, + 'timeout' => $timeout, + 'payload' => $payload, + ]); + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + $response = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $elapsedMs = (int) round((microtime(true) - $started) * 1000); + if (curl_errno($ch)) { + $err = curl_error($ch); + curl_close($ch); + SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [ + 'httpCode' => $httpCode, + 'elapsedMs' => $elapsedMs, + 'error' => $err, + ]); + throw new Exception($err); + } + curl_close($ch); + $decoded = json_decode((string) $response, true); + $summary = is_array($decoded) ? self::summarizeNodeResponse($decoded) : ['raw' => mb_substr((string) $response, 0, 300, 'UTF-8')]; + SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([ + 'httpCode' => $httpCode, + 'elapsedMs' => $elapsedMs, + 'responseSize' => strlen((string) $response), + ], $summary)); + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $decoded + * @return array + */ + private static function summarizeNodeResponse(array $decoded): array + { + $summary = [ + 'success' => $decoded['success'] ?? null, + 'error' => $decoded['error'] ?? null, + ]; + if (isset($decoded['interceptedApis']) && is_array($decoded['interceptedApis'])) { + $summary['interceptedApis'] = array_keys($decoded['interceptedApis']); + } + if (isset($decoded['results']) && is_array($decoded['results'])) { + $summary['resultApis'] = array_keys($decoded['results']); + } + if (isset($decoded['data']) && is_array($decoded['data'])) { + $summary['dataPages'] = count($decoded['data']); + } + return $summary; + } +} diff --git a/patches/application/common/library/scrm/ScrmSpiderInterface.php b/patches/application/common/library/scrm/ScrmSpiderInterface.php new file mode 100644 index 0000000..9a6a7d3 --- /dev/null +++ b/patches/application/common/library/scrm/ScrmSpiderInterface.php @@ -0,0 +1,18 @@ + */ + public array $numbers = []; + + /** @var int 号码总数 */ + public int $total = 0; + + /** + * @param string $number 号码 + * @param bool $isOnline 是否在线 + * @param int $newFollowersToday 今日进线 + */ + public function addNumber(string $number, bool $isOnline, int $newFollowersToday = 0): void + { + $number = trim($number); + if ($number === '') { + return; + } + $this->numbers[] = [ + 'number' => $number, + 'status' => $isOnline ? 'online' : 'offline', + 'newFollowersToday' => max(0, $newFollowersToday), + ]; + + if ($isOnline) { + $this->totalOnline++; + } else { + $this->totalOffline++; + } + } +} diff --git a/patches/application/common/library/scrm/spider/A2cSpider.php b/patches/application/common/library/scrm/spider/A2cSpider.php new file mode 100644 index 0000000..3ecafb8 --- /dev/null +++ b/patches/application/common/library/scrm/spider/A2cSpider.php @@ -0,0 +1,98 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'detailApi' => self::API_DETAILS, + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'wait', 'ms' => 2000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $total = (int) ($listFirstPageData['data']['total'] ?? 0); + $this->unifiedData->total = $total; + if ($total <= self::DEFAULT_PER_PAGE_COUNT) { + return 1; + } + return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); + } + + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT]; + } + + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.btn-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + if ($detailData) { + $unifiedData->todayNewCount = (int) ($detailData['data']['newFollowersToday'] ?? 0); + } + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['rows'] ?? []; + foreach ($records as $item) { + if (empty($item['account'])) { + continue; + } + $number = (string) $item['account']; + $isOnline = isset($item['numberStatus']) && (int) $item['numberStatus'] === 1; + $unifiedData->addNumber($number, $isOnline, (int) ($item['newFollowersToday'] ?? 0)); + } + } + return $unifiedData; + } +} diff --git a/patches/application/common/library/scrm/spider/HaiwangSpider.php b/patches/application/common/library/scrm/spider/HaiwangSpider.php new file mode 100644 index 0000000..f87ef35 --- /dev/null +++ b/patches/application/common/library/scrm/spider/HaiwangSpider.php @@ -0,0 +1,101 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password], + ['type' => 'press', 'key' => 'Enter'], + ['type' => 'wait', 'ms' => 2000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $total = (int) ($listFirstPageData['data']['total'] ?? 0); + $this->unifiedData->total = $total; + if ($total <= self::DEFAULT_PER_PAGE_COUNT) { + return 1; + } + return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); + } + + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.btn-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['items'] ?? []; + foreach ($records as $item) { + if (empty($item['acclist_account'])) { + continue; + } + $number = (string) $item['acclist_account']; + $isOnline = isset($item['acclist_status']) && (int) $item['acclist_status'] === 2; + $unifiedData->addNumber( + $number, + $isOnline, + (int) ($item['account_statistics_today_effective'] ?? 0) + ); + } + } + if (!empty($allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective'])) { + $unifiedData->todayNewCount = (int) $allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective']; + } + return $unifiedData; + } +} diff --git a/patches/application/common/library/scrm/spider/HuojianSpider.php b/patches/application/common/library/scrm/spider/HuojianSpider.php new file mode 100644 index 0000000..815bec4 --- /dev/null +++ b/patches/application/common/library/scrm/spider/HuojianSpider.php @@ -0,0 +1,89 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'vue_fill', 'selector' => '.el-message-box__input input', 'value' => $this->password], + ['type' => 'wait', 'ms' => 500], + ['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'], + ['type' => 'wait', 'ms' => 2000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + return 1; + } + + protected function buildListPageParams(int $page): array + { + return []; + } + + protected function getUiPaginationConfig(): array + { + return []; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + $unifiedData->todayNewCount = (int) ($allListPagesData[0]['data']['counterWorker']['newTodayFriend'] ?? 0); + $count = 0; + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['counterCsAccountVo'] ?? []; + foreach ($records as $item) { + if (empty($item['accountLogin'])) { + continue; + } + $number = (string) $item['accountLogin']; + $isOnline = isset($item['accountStatus']) && (int) $item['accountStatus'] === 1; + $count++; + $unifiedData->addNumber($number, $isOnline, (int) ($item['newTodayFriend'] ?? 0)); + } + } + $unifiedData->total = $count; + return $unifiedData; + } +} diff --git a/patches/application/common/library/scrm/spider/SsCustomerSpider.php b/patches/application/common/library/scrm/spider/SsCustomerSpider.php new file mode 100644 index 0000000..834b3e4 --- /dev/null +++ b/patches/application/common/library/scrm/spider/SsCustomerSpider.php @@ -0,0 +1,102 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'detailApi' => self::API_DETAILS, + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'wait', 'ms' => 2000], + ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password], + ['type' => 'press', 'key' => 'Enter'], + ['type' => 'wait', 'ms' => 3000], + [ + 'type' => 'vue_click', + 'selector' => 'button[class*="reports-customers__dimension"]', + 'text' => 'social media accounts', + ], + ['type' => 'wait', 'ms' => 3000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + return null; + } + + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT]; + } + + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.arco-pagination-item-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + if ($detailData) { + $unifiedData->todayNewCount = (int) ($detailData['data']['distinct_contacts_total'] ?? 0); + } + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['list'] ?? []; + foreach ($records as $item) { + if (empty($item['channel_tag'])) { + continue; + } + $number = (string) $item['channel_tag']; + $unifiedData->addNumber($number, true, (int) ($item['distinct_contacts_total'] ?? 0)); + } + } + $unifiedData->total = count($unifiedData->numbers); + return $unifiedData; + } +} diff --git a/patches/application/common/library/scrm/spider/XingheSpider.php b/patches/application/common/library/scrm/spider/XingheSpider.php new file mode 100644 index 0000000..27fb2a6 --- /dev/null +++ b/patches/application/common/library/scrm/spider/XingheSpider.php @@ -0,0 +1,93 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'listMethod' => 'GET', + 'paginationMode' => self::MODE_FETCH, + 'authActions' => [ + ['type' => 'wait', 'ms' => 2000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $total = (int) ($listFirstPageData['count'] ?? 0); + $this->unifiedData->total = $total; + $this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0); + if ($total <= self::DEFAULT_PER_PAGE_COUNT) { + return 1; + } + return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); + } + + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.layui-laypage-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data'] ?? []; + foreach ($records as $item) { + if (empty($item['user'])) { + continue; + } + $number = (string) $item['user']; + $isOnline = isset($item['online']) && (int) $item['online'] === 1; + $unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0)); + } + } + return $unifiedData; + } +} diff --git a/patches/application/common/service/SplitAutoReplyService.php b/patches/application/common/service/SplitAutoReplyService.php index b057801..05ac2ad 100644 --- a/patches/application/common/service/SplitAutoReplyService.php +++ b/patches/application/common/service/SplitAutoReplyService.php @@ -90,4 +90,16 @@ class SplitAutoReplyService $lines = self::parseLines($stored); return implode("\n", $lines); } + + /** + * 从多行回复语中随机抽取一条(无配置时返回空字符串) + */ + public static function pickRandomLine(string $raw): string + { + $lines = self::parseLines($raw); + if ($lines === []) { + return ''; + } + return $lines[array_rand($lines)]; + } } diff --git a/patches/application/common/service/SplitFriendUrlBuilder.php b/patches/application/common/service/SplitFriendUrlBuilder.php index ceb427f..bf5c2a4 100644 --- a/patches/application/common/service/SplitFriendUrlBuilder.php +++ b/patches/application/common/service/SplitFriendUrlBuilder.php @@ -11,9 +11,15 @@ class SplitFriendUrlBuilder { /** * 构建跳转 URL;无法构建时返回空字符串 + * + * @param string $whatsAppReplyText 仅 WhatsApp 类型使用,预填消息文案(urlencode 在内部处理) */ - public static function build(string $numberType, string $number, string $numberTypeCustom = ''): string - { + public static function build( + string $numberType, + string $number, + string $numberTypeCustom = '', + string $whatsAppReplyText = '' + ): string { $number = trim($number); if ($number === '') { return ''; @@ -21,7 +27,7 @@ class SplitFriendUrlBuilder switch ($numberType) { case 'whatsapp': - return self::buildWhatsApp($number); + return self::buildWhatsApp($number, $whatsAppReplyText); case 'telegram': return self::buildTelegram($number); case 'line': @@ -34,16 +40,22 @@ class SplitFriendUrlBuilder } /** - * WhatsApp:https://api.whatsapp.com/send?phone= 仅数字 + * WhatsApp:https://api.whatsapp.com/send?phone= 仅数字,可选 &text= 预填消息 */ - private static function buildWhatsApp(string $number): string + private static function buildWhatsApp(string $number, string $replyText = ''): string { $digits = preg_replace('/\D+/', '', $number) ?? ''; if ($digits === '') { return ''; } - return 'https://api.whatsapp.com/send?phone=' . $digits; + $url = 'https://api.whatsapp.com/send?phone=' . $digits; + $replyText = trim($replyText); + if ($replyText !== '') { + $url .= '&text=' . rawurlencode($replyText); + } + + return $url; } /** diff --git a/patches/application/common/service/SplitNumberWeighService.php b/patches/application/common/service/SplitNumberWeighService.php new file mode 100644 index 0000000..d6191ea --- /dev/null +++ b/patches/application/common/service/SplitNumberWeighService.php @@ -0,0 +1,24 @@ +value('random_shuffle') === 1; + } +} diff --git a/patches/application/common/service/SplitRedirectService.php b/patches/application/common/service/SplitRedirectService.php index 1086b80..a7a68f4 100644 --- a/patches/application/common/service/SplitRedirectService.php +++ b/patches/application/common/service/SplitRedirectService.php @@ -72,7 +72,17 @@ class SplitRedirectService ? (string) ($picked['number_type_custom'] ?? '') : (string) $picked->getAttr('number_type_custom'); - $redirectUrl = SplitFriendUrlBuilder::build($numberType, $numberValue, $numberCustom); + $whatsAppReplyText = ''; + if ($numberType === 'whatsapp') { + $whatsAppReplyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply')); + } + + $redirectUrl = SplitFriendUrlBuilder::build( + $numberType, + $numberValue, + $numberCustom, + $whatsAppReplyText + ); if ($redirectUrl === '') { return null; } diff --git a/patches/application/common/service/SplitScrmSpiderFactory.php b/patches/application/common/service/SplitScrmSpiderFactory.php new file mode 100644 index 0000000..9ab061a --- /dev/null +++ b/patches/application/common/service/SplitScrmSpiderFactory.php @@ -0,0 +1,65 @@ + 云控蜘蛛工厂 + * + * 新增云控类型:在 spider/ 下新增类并在此注册 ticket_type => Class + */ +class SplitScrmSpiderFactory +{ + /** @var array> */ + private const MAP = [ + 'a2c' => A2cSpider::class, + 'haiwang' => HaiwangSpider::class, + 'huojian' => HuojianSpider::class, + 'xinghe' => XingheSpider::class, + 'ss_customer' => SsCustomerSpider::class, + // ceo_scrm 等未实现类型:新增 spider 类后在此注册 + ]; + + /** + * @return class-string|null + */ + public static function resolveClass(string $ticketType): ?string + { + $ticketType = trim($ticketType); + return self::MAP[$ticketType] ?? null; + } + + /** + * 是否已实现蜘蛛 + */ + public static function isSupported(string $ticketType): bool + { + return self::resolveClass($ticketType) !== null; + } + + /** + * @return ScrmSpiderInterface|null + */ + public static function create( + string $ticketType, + string $pageUrl, + string $account = '', + string $password = '', + string $nodeHost = '' + ): ?ScrmSpiderInterface { + $class = self::resolveClass($ticketType); + if ($class === null) { + return null; + } + $host = $nodeHost !== '' ? $nodeHost : SplitSyncConfigService::getNodeHost(); + return new $class($pageUrl, $account, $password, $host); + } +} diff --git a/patches/application/common/service/SplitSyncConfigService.php b/patches/application/common/service/SplitSyncConfigService.php new file mode 100644 index 0000000..de0cb3d --- /dev/null +++ b/patches/application/common/service/SplitSyncConfigService.php @@ -0,0 +1,62 @@ +where('name', $name)->value('value'); + return $db !== null ? (string) $db : ''; + } +} diff --git a/patches/application/common/service/SplitTicketNumberSyncService.php b/patches/application/common/service/SplitTicketNumberSyncService.php new file mode 100644 index 0000000..dccaab1 --- /dev/null +++ b/patches/application/common/service/SplitTicketNumberSyncService.php @@ -0,0 +1,182 @@ +numbers as $row) { + $number = self::normalizeNumber($row['number'] ?? ''); + if ($number === '') { + continue; + } + $syncedNumbers[] = [ + 'number' => $number, + 'row' => $row, + ]; + } + + $existingList = Number::where('admin_id', $adminId) + ->where('split_link_id', $linkId) + ->where('ticket_name', $ticketName) + ->select(); + + $existingMap = []; + foreach ($existingList as $item) { + $existingMap[(string) $item['number']] = $item; + } + + $syncedNumberSet = []; + $pendingInserts = []; + foreach ($syncedNumbers as $entry) { + $number = $entry['number']; + $row = $entry['row']; + $syncedNumberSet[$number] = true; + $platformStatus = ($row['status'] ?? '') === 'online' ? 'online' : 'offline'; + $newFollowers = (int) ($row['newFollowersToday'] ?? 0); + + if (isset($existingMap[$number])) { + $this->updateExistingNumber($existingMap[$number], $platformStatus, $newFollowers); + continue; + } + + $pendingInserts[] = [ + 'number' => $number, + 'platform_status' => $platformStatus, + 'new_followers' => $newFollowers, + ]; + } + + if ($pendingInserts !== []) { + // 随机打乱:打乱待插入批次顺序,按随机顺序逐条 insert 以获得乱序自增 id + if ($randomShuffle && count($pendingInserts) > 1) { + shuffle($pendingInserts); + } + foreach ($pendingInserts as $item) { + $this->insertNumber( + $ticket, + $item['number'], + $item['platform_status'], + $item['new_followers'] + ); + } + } + + foreach ($existingMap as $number => $item) { + if (isset($syncedNumberSet[$number])) { + continue; + } + if ((int) $item['manual_manage'] === 1) { + continue; + } + Number::where('id', (int) $item['id'])->update([ + 'status' => 'hidden', + 'updatetime' => time(), + ]); + } + } + + /** + * @param Number $row + */ + private function updateExistingNumber($row, string $platformStatus, int $newFollowers): void + { + $update = [ + 'platform_status' => $platformStatus, + 'updatetime' => time(), + ]; + + if ((int) $row['manual_manage'] === 1) { + Number::where('id', (int) $row['id'])->update($update); + return; + } + + // 进线人数由同步写入,最终开关由 applyNumberRules 统一判定(单号上限/下号比率等) + $update['inbound_count'] = max(0, $newFollowers); + Number::where('id', (int) $row['id'])->update($update); + } + + private function insertNumber( + Ticket $ticket, + string $number, + string $platformStatus, + int $newFollowers + ): void { + $now = time(); + $data = [ + 'admin_id' => (int) $ticket['admin_id'], + 'split_link_id' => (int) $ticket['split_link_id'], + 'ticket_name' => (string) $ticket['ticket_name'], + 'number' => $number, + 'number_type' => (string) $ticket['number_type'], + 'number_type_custom' => (string) ($ticket['number_type_custom'] ?? ''), + 'visit_count' => 0, + 'inbound_count' => max(0, $newFollowers), + 'manual_manage' => 0, + 'platform_status' => $platformStatus, + 'status' => 'hidden', + 'createtime' => $now, + 'updatetime' => $now, + ]; + + try { + Db::name('split_number')->insert($data); + } catch (\Throwable $e) { + $exists = Number::where('split_link_id', (int) $ticket['split_link_id']) + ->where('number', $number) + ->find(); + if ($exists) { + $this->updateExistingNumber($exists, $platformStatus, $newFollowers); + } + } + } + + /** + * 统一号码为字符串(云控 API 可能返回 int) + */ + private static function normalizeNumber($value): string + { + if ($value === null || $value === '') { + return ''; + } + return trim((string) $value); + } + + /** + * 汇总工单进线人数(仅开启状态的号码) + */ + public function sumInboundForTicket(Ticket $ticket): int + { + $sum = 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('status', 'normal') + ->sum('inbound_count'); + return (int) $sum; + } +} diff --git a/patches/application/common/service/SplitTicketRuleService.php b/patches/application/common/service/SplitTicketRuleService.php new file mode 100644 index 0000000..a4f65bd --- /dev/null +++ b/patches/application/common/service/SplitTicketRuleService.php @@ -0,0 +1,195 @@ +applyTicketStatusRules($ticket, $completeCount); + $fresh = Ticket::get((int) $ticket['id']); + if ($fresh) { + if ((string) $fresh['status'] === 'hidden') { + $this->cascadeTicketClosedToNumbers($fresh); + } + $this->applyNumberRules($fresh); + } + } + + /** + * 同步流程:工单因时间/完成量等原因关闭时,联动关闭非手动号码 + */ + public function cascadeTicketClosedToNumbers(Ticket $ticket): void + { + if ((string) ($ticket['status'] ?? 'hidden') !== 'hidden') { + return; + } + 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) + ->update([ + 'status' => 'hidden', + 'updatetime' => time(), + ]); + } + + /** + * 手动切换工单状态时,联动非手动管理的号码 + */ + public function syncNumbersWithTicketStatus(Ticket $ticket): void + { + $ticketStatus = (string) ($ticket['status'] ?? 'hidden'); + if ($ticketStatus === 'hidden') { + $this->cascadeTicketClosedToNumbers($ticket); + return; + } + $this->applyNumberRules($ticket); + } + + /** + * 工单处于开启状态时,将云控在线的非手动号码设为开启 + * + * @deprecated 请使用 applyNumberRules(会校验单号上限/下号比率) + */ + public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void + { + $this->applyNumberRules($ticket); + } + + /** + * 单号上限、下号比率、云控在线状态、工单开关综合决定号码状态 + */ + public function applyNumberRules(Ticket $ticket): void + { + $orderLimit = (int) ($ticket['order_limit'] ?? 0); + $assignRatio = (int) ($ticket['assign_ratio'] ?? 0); + + $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']) + ->select(); + + foreach ($numbers as $number) { + $visitCount = (int) $number['visit_count']; + $inboundCount = (int) $number['inbound_count']; + $lastVisit = (int) ($number['last_sync_visit_count'] ?? 0); + $lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0); + $streak = (int) ($number['no_inbound_click_streak'] ?? 0); + + if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) { + $streak += ($visitCount - $lastVisit); + } elseif ($inboundCount > $lastInbound) { + $streak = 0; + } + + $update = [ + 'no_inbound_click_streak' => $streak, + 'last_sync_visit_count' => $visitCount, + 'last_sync_inbound_count' => $inboundCount, + 'updatetime' => time(), + ]; + + // 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态 + if ((int) $number['manual_manage'] === 1) { + Number::where('id', (int) $number['id'])->update($update); + continue; + } + + $update['status'] = $this->resolveAutomatedStatus( + $ticket, + (string) ($number['platform_status'] ?? 'unknown'), + $inboundCount, + $streak, + $orderLimit, + $assignRatio + ); + + Number::where('id', (int) $number['id'])->update($update); + } + } + + /** + * 非手动管理号码的自动开关判定 + */ + private function resolveAutomatedStatus( + Ticket $ticket, + string $platformStatus, + int $inboundCount, + int $streak, + int $orderLimit, + int $assignRatio + ): string { + if ((string) ($ticket['status'] ?? 'hidden') !== 'normal') { + return 'hidden'; + } + if ($platformStatus !== 'online') { + return 'hidden'; + } + if ($orderLimit > 0 && $inboundCount >= $orderLimit) { + return 'hidden'; + } + if ($assignRatio > 0 && $streak >= $assignRatio) { + return 'hidden'; + } + + return 'normal'; + } + + /** + * 完成量、时间窗口决定工单开关(定时与手动同步均适用) + */ + public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void + { + $status = $this->resolveTicketStatus($ticket, $completeCount); + if ($status !== (string) ($ticket['status'] ?? 'hidden')) { + Ticket::where('id', (int) $ticket['id'])->update([ + 'status' => $status, + 'updatetime' => time(), + ]); + $ticket['status'] = $status; + } + } + + /** + * 是否处于允许同步/开启的时间窗口 + */ + public function isWithinTimeWindow(Ticket $ticket, ?int $now = null): bool + { + $now = $now ?? time(); + $start = $ticket['start_time'] ?? null; + $end = $ticket['end_time'] ?? null; + if ($start !== null && $start !== '' && (int) $start > 0 && $now < (int) $start) { + return false; + } + if ($end !== null && $end !== '' && (int) $end > 0 && $now > (int) $end) { + return false; + } + return true; + } + + private function resolveTicketStatus(Ticket $ticket, int $completeCount): string + { + if (!$this->isWithinTimeWindow($ticket)) { + return 'hidden'; + } + + $ticketTotal = (int) ($ticket['ticket_total'] ?? 0); + if ($ticketTotal > 0) { + return $completeCount >= $ticketTotal ? 'hidden' : 'normal'; + } + + return 'normal'; + } +} diff --git a/patches/application/common/service/SplitTicketSyncLockService.php b/patches/application/common/service/SplitTicketSyncLockService.php new file mode 100644 index 0000000..cef85d5 --- /dev/null +++ b/patches/application/common/service/SplitTicketSyncLockService.php @@ -0,0 +1,64 @@ +lockPath($ticketId); + if ($this->isStaleLock($path)) { + @unlink($path); + } + if (is_file($path)) { + return false; + } + $payload = json_encode([ + 'ticket_id' => $ticketId, + 'pid' => getmypid(), + 'time' => time(), + ], JSON_UNESCAPED_UNICODE); + $written = @file_put_contents($path, $payload, LOCK_EX); + return $written !== false; + } + + /** + * 释放锁 + */ + public function release(int $ticketId): void + { + $path = $this->lockPath($ticketId); + if (is_file($path)) { + @unlink($path); + } + } + + private function lockPath(int $ticketId): string + { + $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); + $dir = $runtime . 'split_ticket_sync/'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + return $dir . $ticketId . '.lock'; + } + + private function isStaleLock(string $path): bool + { + if (!is_file($path)) { + return false; + } + $mtime = (int) @filemtime($path); + return $mtime > 0 && (time() - $mtime) > self::LOCK_TTL; + } +} diff --git a/patches/application/common/service/SplitTicketSyncLogger.php b/patches/application/common/service/SplitTicketSyncLogger.php new file mode 100644 index 0000000..fecd42b --- /dev/null +++ b/patches/application/common/service/SplitTicketSyncLogger.php @@ -0,0 +1,114 @@ + $context + */ + public static function log(string $stage, string $message, array $context = []): void + { + if (!self::isEnabled()) { + return; + } + + $context = self::sanitize($context); + $ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : ''; + $line = sprintf( + "[%s] %s [%s] %s%s\n", + date('Y-m-d H:i:s'), + self::$ticketTag ?? '[global]', + $stage, + $message, + $ctxJson + ); + + $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (ROOT_PATH . 'runtime/'); + $dir = $runtime . 'log/'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + @file_put_contents($dir . self::LOG_FILE, $line, FILE_APPEND | LOCK_EX); + } + + /** + * @param array $context + * @return array + */ + private static function sanitize(array $context): array + { + $out = []; + foreach ($context as $key => $value) { + $lower = strtolower((string) $key); + if (in_array($lower, ['password', 'passwd', 'pwd', 'token', 'secret'], true)) { + continue; + } + if ($key === 'authActions' && is_array($value)) { + $out[$key] = self::sanitizeAuthActions($value); + continue; + } + if (is_array($value)) { + $out[$key] = self::sanitize($value); + continue; + } + if (is_string($value) && mb_strlen($value) > 800) { + $out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)'; + continue; + } + $out[$key] = $value; + } + return $out; + } + + /** + * @param array $actions + * @return array + */ + private static function sanitizeAuthActions(array $actions): array + { + $sanitized = []; + foreach ($actions as $action) { + if (!is_array($action)) { + $sanitized[] = $action; + continue; + } + if (array_key_exists('value', $action)) { + $action['value'] = '***'; + } + $sanitized[] = $action; + } + return $sanitized; + } +} diff --git a/patches/application/common/service/SplitTicketSyncService.php b/patches/application/common/service/SplitTicketSyncService.php index 8036b41..71d5ed1 100644 --- a/patches/application/common/service/SplitTicketSyncService.php +++ b/patches/application/common/service/SplitTicketSyncService.php @@ -5,38 +5,233 @@ declare(strict_types=1); namespace app\common\service; use app\admin\model\split\Ticket; +use app\common\library\scrm\UnifiedScrmData; +use think\Db; use think\Exception; /** - * 分流工单数据同步服务(骨架,后续按 ticket_type 对接各云控 API) - * - * 期望第三方接口 payload 字段映射: - * - complete_count int 完成数量 - * - inbound_count int 进线人数 - * - speed_per_hour float 每小时进线人数 - * - number_count int 号码总数(含离线+封号) - * - number_offline_count int 可选 离线数 - * - number_banned_count int 可选 封号数 - * - online_count int 在线人数 + * 分流工单云控数据同步服务 */ class SplitTicketSyncService { - /** - * 同步单条工单(后续实现:按 ticket_type 选择适配器并请求 API) - */ - public function syncOne(int $ticketId): bool + private SplitTicketNumberSyncService $numberSync; + + private SplitTicketRuleService $ruleService; + + private SplitTicketSyncLockService $lockService; + + public function __construct() { - $ticket = Ticket::get($ticketId); - if (!$ticket) { - return false; - } - // TODO: 调用具体云控适配器获取 $payload - return false; + $this->numberSync = new SplitTicketNumberSyncService(); + $this->ruleService = new SplitTicketRuleService(); + $this->lockService = new SplitTicketSyncLockService(); } /** - * 将同步结果写入工单表 + * 同步单条工单 * + * @return array{success:bool,message:string,skipped?:bool} + */ + public function syncOne(int $ticketId, bool $force = false): array + { + $ticket = Ticket::get($ticketId); + if (!$ticket) { + SplitTicketSyncLogger::log('sync', 'ticket not found', ['ticketId' => $ticketId]); + return ['success' => false, 'message' => '工单不存在']; + } + + SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']); + SplitTicketSyncLogger::log('sync', 'syncOne start', [ + 'force' => $force, + 'status' => (string) $ticket['status'], + 'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0), + 'syncTime' => (int) ($ticket['sync_time'] ?? 0), + 'pageUrl' => (string) $ticket['ticket_url'], + 'nodeHost' => SplitSyncConfigService::getNodeHost(), + ]); + + if (!$force) { + $skip = $this->shouldSkip($ticket); + if ($skip !== null) { + SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => $skip]); + SplitTicketSyncLogger::clearTicketContext(); + return ['success' => false, 'message' => $skip, 'skipped' => true]; + } + } + + if (!$this->lockService->acquire($ticketId)) { + SplitTicketSyncLogger::log('sync', 'lock busy', ['ticketId' => $ticketId]); + SplitTicketSyncLogger::clearTicketContext(); + return ['success' => false, 'message' => '工单正在同步中', 'skipped' => true]; + } + + try { + $result = $this->doSync($ticket); + SplitTicketSyncLogger::log('sync', 'syncOne end', $result); + return $result; + } finally { + $this->lockService->release($ticketId); + SplitTicketSyncLogger::clearTicketContext(); + } + } + + /** + * 扫描到期工单并同步 + */ + public function syncDueTickets(): int + { + $count = 0; + $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); + $query = Ticket::where('status', 'normal'); + if ($failThreshold > 0) { + $query->where('sync_fail_count', '<', $failThreshold); + } + $list = $query->select(); + + SplitTicketSyncLogger::log('cron', 'scan start', [ + 'candidateCount' => count($list), + ]); + + foreach ($list as $ticket) { + $skip = $this->shouldSkip($ticket); + if ($skip !== null) { + SplitTicketSyncLogger::log('cron', 'candidate skipped', [ + 'ticketId' => (int) $ticket['id'], + 'ticketType' => (string) $ticket['ticket_type'], + 'reason' => $skip, + ]); + continue; + } + $result = $this->syncOne((int) $ticket['id'], false); + if (!empty($result['skipped'])) { + continue; + } + $count++; + } + + SplitTicketSyncLogger::log('cron', 'scan end', ['processedCount' => $count]); + return $count; + } + + /** + * @return array{success:bool,message:string} + */ + private function doSync(Ticket $ticket): array + { + $ticketType = (string) $ticket['ticket_type']; + $pageUrl = trim((string) $ticket['ticket_url']); + if ($pageUrl === '') { + SplitTicketSyncLogger::log('sync', 'empty pageUrl'); + $this->markFailure($ticket, '工单链接为空'); + return ['success' => false, 'message' => '工单链接为空']; + } + + if (!SplitScrmSpiderFactory::isSupported($ticketType)) { + SplitTicketSyncLogger::log('sync', 'spider not supported', ['ticketType' => $ticketType]); + $this->markFailure($ticket, '工单类型尚未实现蜘蛛'); + return ['success' => false, 'message' => '工单类型尚未实现蜘蛛']; + } + + SplitTicketSyncLogger::log('sync', 'create spider', [ + 'ticketType' => $ticketType, + 'hasAccount' => trim((string) ($ticket['account'] ?? '')) !== '', + ]); + $spider = SplitScrmSpiderFactory::create( + $ticketType, + $pageUrl, + (string) ($ticket['account'] ?? ''), + (string) ($ticket['password'] ?? '') + ); + if ($spider === null) { + $this->markFailure($ticket, '无法创建蜘蛛实例'); + return ['success' => false, 'message' => '无法创建蜘蛛实例']; + } + + Db::startTrans(); + try { + SplitTicketSyncLogger::log('sync', 'spider run begin'); + $finalData = $spider->run(); + if (!$finalData instanceof UnifiedScrmData) { + throw new Exception('蜘蛛返回数据无效'); + } + + $this->numberSync->syncFromUnifiedData($ticket, $finalData); + + $completeCount = max(0, $finalData->todayNewCount); + $this->ruleService->applyTicketStatusRules($ticket, $completeCount); + + $freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket; + if ((string) $freshTicket['status'] === 'hidden') { + $this->ruleService->cascadeTicketClosedToNumbers($freshTicket); + } + // 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线) + $this->ruleService->applyNumberRules($freshTicket); + $ticket = $freshTicket; + + $inboundCount = $this->numberSync->sumInboundForTicket($ticket); + $speed = $this->calcSpeedPerHour($ticket, $completeCount); + + $payload = [ + 'complete_count' => $completeCount, + 'inbound_count' => $inboundCount, + 'speed_per_hour' => $speed['speed'], + 'number_count' => max(0, $finalData->total), + 'number_offline_count' => max(0, $finalData->totalOffline), + 'number_banned_count' => 0, + 'online_count' => max(0, $finalData->totalOnline), + 'sync_fail_count' => 0, + 'speed_snapshot_count' => $speed['snapshot_count'], + 'speed_snapshot_time' => $speed['snapshot_time'], + ]; + + $this->applySyncResult($ticket, $payload, true, ''); + Db::commit(); + SplitTicketSyncLogger::log('sync', 'db commit ok', $payload); + return ['success' => true, 'message' => '同步成功']; + } catch (\Throwable $e) { + Db::rollback(); + $msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8'); + SplitTicketSyncLogger::log('sync', 'exception', [ + 'type' => get_class($e), + 'message' => $msg, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + $this->markFailure($ticket, $msg); + return ['success' => false, 'message' => $msg]; + } + } + + private function shouldSkip(Ticket $ticket): ?string + { + if ((string) $ticket['status'] === 'hidden') { + return '工单已关闭'; + } + $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); + if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) { + return sprintf('连续同步失败超过%d次已暂停', $failThreshold); + } + if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) { + return '工单类型尚未实现'; + } + $interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']); + if ($interval <= 0) { + return '该类型未配置自动同步周期'; + } + $lastSync = (int) ($ticket['sync_time'] ?? 0); + $elapsed = $lastSync > 0 ? (time() - $lastSync) : null; + if ($lastSync > 0 && $elapsed !== null && $elapsed < ($interval * 60)) { + SplitTicketSyncLogger::log('sync', 'interval not reached', [ + 'intervalMinutes' => $interval, + 'elapsedSeconds' => $elapsed, + 'needSeconds' => $interval * 60, + ]); + return '未到同步周期'; + } + return null; + } + + /** * @param array $payload */ public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void @@ -52,9 +247,74 @@ class SplitTicketSyncService 'sync_status' => $success ? 'success' : 'error', 'sync_time' => time(), 'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'), + 'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1), + 'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0), + 'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0), ]; if (!$ticket->allowField(array_keys($data))->save($data)) { throw new Exception('工单同步结果保存失败'); } } + + private function markFailure(Ticket $ticket, string $message): void + { + $failCount = (int) ($ticket['sync_fail_count'] ?? 0) + 1; + $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); + $previousSyncStatus = (string) ($ticket['sync_status'] ?? 'pending'); + $neverSyncedSuccessfully = $previousSyncStatus === 'pending' && (int) ($ticket['sync_time'] ?? 0) <= 0; + $update = [ + 'sync_status' => 'error', + 'sync_time' => time(), + 'sync_message' => mb_substr($message, 0, 255, 'UTF-8'), + 'sync_fail_count' => $failCount, + ]; + // 新建工单首次同步失败:立即关闭;已同步过的工单仍按连续失败阈值关闭 + if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) { + $update['status'] = 'hidden'; + } + $ticket->save($update); + if (isset($update['status']) && $update['status'] === 'hidden') { + $fresh = Ticket::get((int) $ticket['id']); + if ($fresh) { + $this->ruleService->cascadeTicketClosedToNumbers($fresh); + } + } + } + + /** + * @return array{speed:float,snapshot_count:int,snapshot_time:int} + */ + private function calcSpeedPerHour(Ticket $ticket, int $currentComplete): array + { + $now = time(); + $snapshotTime = (int) ($ticket['speed_snapshot_time'] ?? 0); + $snapshotCount = (int) ($ticket['speed_snapshot_count'] ?? 0); + + if ($snapshotTime <= 0) { + return [ + 'speed' => 0.0, + 'snapshot_count' => $currentComplete, + 'snapshot_time' => $now, + ]; + } + + $elapsed = $now - $snapshotTime; + if ($elapsed >= 3600) { + return [ + 'speed' => 0.0, + 'snapshot_count' => $currentComplete, + 'snapshot_time' => $now, + ]; + } + + $hours = $elapsed > 0 ? ($elapsed / 3600) : 0; + $delta = $currentComplete - $snapshotCount; + $speed = ($delta < 0 || $hours <= 0) ? 0.0 : round($delta / $hours, 2); + + return [ + 'speed' => $speed, + 'snapshot_count' => $snapshotCount, + 'snapshot_time' => $snapshotTime, + ]; + } } diff --git a/patches/public/assets/js/backend/split/number.js b/patches/public/assets/js/backend/split/number.js index f60605b..55a942e 100644 --- a/patches/public/assets/js/backend/split/number.js +++ b/patches/public/assets/js/backend/split/number.js @@ -42,6 +42,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }, {field: 'visit_count', title: __('Visit_count'), operate: false, sortable: true}, {field: 'inbound_count', title: __('Inbound_count'), operate: false, sortable: true}, + { + field: 'platform_status', + title: __('Platform_status'), + searchList: Config.platformStatusList, + formatter: Controller.api.formatter.platformStatus + }, { field: 'status', title: __('Status'), @@ -98,6 +104,15 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindevent(); }, api: { + formatter: { + platformStatus: function (value, row) { + var text = row.platform_status_text != null && row.platform_status_text !== '' + ? String(row.platform_status_text) + : (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value); + var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning'); + return '' + Fast.api.escape(text || '') + ''; + } + }, bindevent: function () { Form.api.bindevent($('form[role=form]')); Controller.api.fixSelectPlaceholder(); diff --git a/patches/public/assets/js/backend/split/ticket.js b/patches/public/assets/js/backend/split/ticket.js index a4f5653..01bb5fa 100644 --- a/patches/public/assets/js/backend/split/ticket.js +++ b/patches/public/assets/js/backend/split/ticket.js @@ -57,6 +57,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }, {field: 'order_limit', title: __('Order_limit'), operate: false}, {field: 'assign_ratio', title: __('Assign_ratio'), operate: false}, + {field: 'ticket_total', title: __('Ticket_total'), operate: false, sortable: true}, {field: 'complete_count', title: __('Complete_count'), operate: false, sortable: true}, { field: 'ticket_progress_text', @@ -117,14 +118,130 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); Table.api.bindevent(table); + Controller.api.syncingTicketIds = []; + window.__splitTicketPendingPostAddSyncIds = window.__splitTicketPendingPostAddSyncIds || []; + + table.on('load-success.bs.table', function () { + var pendingIds = window.__splitTicketPendingPostAddSyncIds; + if (!pendingIds || !pendingIds.length) { + return; + } + window.__splitTicketPendingPostAddSyncIds = []; + Controller.api.startBackgroundSync(table, pendingIds, false); + }); + + table.on('check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table', function () { + var ids = Table.api.selectedids(table); + $('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0); + }); + + $('.btn-sync').on('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + var ids = Table.api.selectedids(table); + if (!ids.length) { + Toastr.error(__('Please select at least one record')); + return false; + } + var syncConfirmMsg = (typeof Config.syncConfirmMsg !== 'undefined' && Config.syncConfirmMsg) + ? Config.syncConfirmMsg + : '确定同步所选工单吗?任务将在后台执行,完成后自动提示结果。'; + var syncBackgroundMsg = (typeof Config.syncBackgroundStartedMsg !== 'undefined' && Config.syncBackgroundStartedMsg) + ? Config.syncBackgroundStartedMsg + : '同步任务已在后台执行,请稍候…'; + Layer.confirm(syncConfirmMsg, {icon: 3, title: __('Sync_status_btn')}, function (index) { + Layer.close(index); + Toastr.info(syncBackgroundMsg); + Controller.api.startBackgroundSync(table, ids, true); + }); + return false; + }); }, add: function () { - Controller.api.bindevent(); + Form.api.bindevent($('form[role=form]'), function (data, ret) { + var ticketId = ret.data && ret.data.id ? parseInt(ret.data.id, 10) : 0; + var syncTicketMsg = (typeof Config.syncTicketStartedMsg !== 'undefined' && Config.syncTicketStartedMsg) + ? Config.syncTicketStartedMsg + : '正在同步工单'; + if (ticketId > 0) { + parent.__splitTicketPendingPostAddSyncIds = parent.__splitTicketPendingPostAddSyncIds || []; + parent.__splitTicketPendingPostAddSyncIds.push(ticketId); + } + if (parent && parent.Toastr) { + parent.Toastr.info(syncTicketMsg); + } + parent.$('.btn-refresh').trigger('click'); + if (window.name) { + var layerIndex = parent.Layer.getFrameIndex(window.name); + parent.Layer.close(layerIndex); + } + return false; + }); + Controller.api.fixSelectPlaceholder(); + Controller.api.bindNumberTypeToggle(); + Controller.api.bindEndTimeCheck(); }, edit: function () { Controller.api.bindevent(); }, api: { + /** @type {number[]} 正在手动同步的工单 ID */ + syncingTicketIds: [], + + /** + * 后台同步:标记「同步中」并请求 sync 接口 + * + * @param {object} table bootstrapTable 实例 + * @param {number[]} ids 工单 ID + * @param {boolean} disableSyncBtn 是否禁用工具栏同步按钮 + */ + startBackgroundSync: function (table, ids, disableSyncBtn) { + ids = (ids || []).map(function (id) { + return parseInt(id, 10); + }).filter(function (id) { + return !isNaN(id) && id > 0; + }); + if (!ids.length) { + return; + } + Controller.api.markTicketsSyncing(table, ids); + if (disableSyncBtn) { + $('.btn-sync').addClass('btn-disabled disabled'); + } + Fast.api.ajax({ + url: 'split.ticket/sync', + data: {ids: ids.join(',')}, + loading: false + }, function () { + Controller.api.finishTicketsSync(table); + }, function () { + Controller.api.finishTicketsSync(table); + }); + }, + + /** + * 将选中工单标记为「同步中」并刷新列表展示(不请求后端) + */ + markTicketsSyncing: function (table, ids) { + Controller.api.syncingTicketIds = (ids || []).map(function (id) { + return parseInt(id, 10); + }).filter(function (id) { + return !isNaN(id) && id > 0; + }); + var data = table.bootstrapTable('getData'); + table.bootstrapTable('load', data); + }, + + /** + * 同步结束:清除标记并刷新列表数据 + */ + finishTicketsSync: function (table) { + Controller.api.syncingTicketIds = []; + table.bootstrapTable('refresh'); + var ids = Table.api.selectedids(table); + $('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0); + }, + formatter: { /** * 工单类型:纯文本展示,无链接/标签样式 @@ -168,9 +285,27 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin return String(total); }, syncDisplay: function (value, row) { + var rowId = parseInt(row.id, 10); + if (Controller.api.syncingTicketIds.indexOf(rowId) !== -1) { + return Controller.api.formatter.syncingDisplayHtml(); + } var text = value || ''; - var color = row.sync_status === 'success' ? 'success' : 'danger'; + var color = 'danger'; + if (row.sync_status === 'success') { + color = 'success'; + } else if (row.sync_status === 'pending') { + color = 'muted'; + } return '' + Fast.api.escape(text) + ''; + }, + syncingDisplayHtml: function () { + var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg) + ? Config.syncInProgressMsg + : '同步中'; + return '' + + '' + + '' + Fast.api.escape(label) + '' + + ''; } }, bindevent: function () { diff --git a/patches/runtime/split_rr/999001.cnt b/patches/runtime/split_rr/999001.cnt index bf0d87a..301160a 100644 --- a/patches/runtime/split_rr/999001.cnt +++ b/patches/runtime/split_rr/999001.cnt @@ -1 +1 @@ -4 \ No newline at end of file +8 \ No newline at end of file diff --git a/public/assets/js/backend/split/number.js b/public/assets/js/backend/split/number.js index 33e1d99..55a942e 100644 --- a/public/assets/js/backend/split/number.js +++ b/public/assets/js/backend/split/number.js @@ -8,6 +8,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin add_url: 'split.number/add', edit_url: 'split.number/edit', del_url: 'split.number/del', + multi_url: 'split.number/multi', table: 'split_number', } }); @@ -41,11 +42,19 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }, {field: 'visit_count', title: __('Visit_count'), operate: false, sortable: true}, {field: 'inbound_count', title: __('Inbound_count'), operate: false, sortable: true}, + { + field: 'platform_status', + title: __('Platform_status'), + searchList: Config.platformStatusList, + formatter: Controller.api.formatter.platformStatus + }, { field: 'status', title: __('Status'), searchList: Config.statusList, - formatter: Table.api.formatter.status + formatter: Table.api.formatter.toggle, + yes: 'normal', + no: 'hidden' }, { field: 'createtime', @@ -95,6 +104,15 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindevent(); }, api: { + formatter: { + platformStatus: function (value, row) { + var text = row.platform_status_text != null && row.platform_status_text !== '' + ? String(row.platform_status_text) + : (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value); + var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning'); + return '' + Fast.api.escape(text || '') + ''; + } + }, bindevent: function () { Form.api.bindevent($('form[role=form]')); Controller.api.fixSelectPlaceholder(); diff --git a/public/assets/js/backend/split/ticket.js b/public/assets/js/backend/split/ticket.js index 750daaf..10685b9 100644 --- a/public/assets/js/backend/split/ticket.js +++ b/public/assets/js/backend/split/ticket.js @@ -29,14 +29,15 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin field: 'ticket_type', title: __('Ticket_type'), searchList: Config.ticketTypeList, - formatter: Table.api.formatter.normal + operate: false, + formatter: Controller.api.formatter.ticketTypePlain }, {field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'}, { field: 'link_code_text', title: __('Split_link_id'), operate: false, - formatter: Table.api.formatter.content + formatter: Controller.api.formatter.splitLinkCode }, { field: 'start_time_text', @@ -116,6 +117,36 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); Table.api.bindevent(table); + + table.on('check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table', function () { + var ids = Table.api.selectedids(table); + $('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0); + }); + + $('.btn-sync').on('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + var ids = Table.api.selectedids(table); + if (!ids.length) { + Toastr.error(__('Please select at least one record')); + return false; + } + Layer.confirm(__('Sync running'), {icon: 3, title: __('Sync_status_btn')}, function (index) { + Layer.close(index); + var loadIdx = Layer.load(1, {shade: [0.3, '#000']}); + Fast.api.ajax({ + url: 'split.ticket/sync', + data: {ids: ids.join(',')} + }, function (data, ret) { + Layer.close(loadIdx); + table.bootstrapTable('refresh'); + return false; + }, function () { + Layer.close(loadIdx); + }); + }); + return false; + }); }, add: function () { Controller.api.bindevent(); @@ -125,6 +156,30 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }, api: { formatter: { + /** + * 工单类型:纯文本展示,无链接/标签样式 + */ + ticketTypePlain: function (value, row) { + var text = row.ticket_type_text != null && row.ticket_type_text !== '' + ? String(row.ticket_type_text) + : (value != null ? String(value) : ''); + if (text === '') { + return '-'; + } + return '' + Fast.api.escape(text) + ''; + }, + /** + * 分流链接:纯文本 + 边框背景标记,不可点击 + */ + splitLinkCode: function (value) { + value = value == null ? '' : String(value); + if ($.trim(value) === '') { + return '-'; + } + var safe = Fast.api.escape(value); + return '' + + safe + ''; + }, speedPerHour: function (value) { var num = parseFloat(value); if (isNaN(num)) {