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 @@
+
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)) {