添加whatshub工单
This commit is contained in:
Regular → Executable
Regular → Executable
+10
-3
@@ -107,6 +107,11 @@ abstract class AbstractScrmSpider
|
|||||||
if ($detailApi) { $apiUrlsToIntercept[] = $detailApi; }
|
if ($detailApi) { $apiUrlsToIntercept[] = $detailApi; }
|
||||||
if ($countApi) { $apiUrlsToIntercept[] = $countApi; }
|
if ($countApi) { $apiUrlsToIntercept[] = $countApi; }
|
||||||
// var_dump($apiUrlsToIntercept);
|
// var_dump($apiUrlsToIntercept);
|
||||||
|
// dump([
|
||||||
|
// 'pageUrl' => $config['pageUrl'],
|
||||||
|
// 'apiUrls' => $apiUrlsToIntercept,
|
||||||
|
// 'authActions' => $config['authActions']
|
||||||
|
// ]);
|
||||||
// 【阶段一】:初始化并首屏拦截
|
// 【阶段一】:初始化并首屏拦截
|
||||||
$initResult = $this->requestNode('/api/auth-and-intercept', [
|
$initResult = $this->requestNode('/api/auth-and-intercept', [
|
||||||
'pageUrl' => $config['pageUrl'],
|
'pageUrl' => $config['pageUrl'],
|
||||||
@@ -120,7 +125,8 @@ abstract class AbstractScrmSpider
|
|||||||
|
|
||||||
$interceptedApis = $initResult['interceptedApis'];
|
$interceptedApis = $initResult['interceptedApis'];
|
||||||
$cookies = $initResult['cookies'];
|
$cookies = $initResult['cookies'];
|
||||||
// dd($interceptedApis);
|
$finalPageUrl = $initResult['finalPageUrl'] ?? $config['pageUrl'];
|
||||||
|
|
||||||
// 必须拦截到 List 接口,否则无法继续
|
// 必须拦截到 List 接口,否则无法继续
|
||||||
if (!isset($interceptedApis[$listApi])) {
|
if (!isset($interceptedApis[$listApi])) {
|
||||||
throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]");
|
throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]");
|
||||||
@@ -133,7 +139,7 @@ abstract class AbstractScrmSpider
|
|||||||
// dd($countData);
|
// dd($countData);
|
||||||
$listApiNode = $interceptedApis[$listApi];
|
$listApiNode = $interceptedApis[$listApi];
|
||||||
$allListPagesData = [$listApiNode['data']]; // 初始化列表容器,装入第一页数据
|
$allListPagesData = [$listApiNode['data']]; // 初始化列表容器,装入第一页数据
|
||||||
|
// dd($allListPagesData);exit;
|
||||||
$totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
|
$totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
|
||||||
|
|
||||||
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
|
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
|
||||||
@@ -163,7 +169,7 @@ abstract class AbstractScrmSpider
|
|||||||
if ($pResult['success']) $allListPagesData[] = $pResult['data'];
|
if ($pResult['success']) $allListPagesData[] = $pResult['data'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 策略 2:强制 UI 点击
|
// 策略 2:强制 UI 点击
|
||||||
elseif ($mode === self::MODE_UI) {
|
elseif ($mode === self::MODE_UI) {
|
||||||
$uiConfig = $this->getUiPaginationConfig();
|
$uiConfig = $this->getUiPaginationConfig();
|
||||||
@@ -176,6 +182,7 @@ abstract class AbstractScrmSpider
|
|||||||
$uiResult = $this->requestNode('/api/ui-pagination', [
|
$uiResult = $this->requestNode('/api/ui-pagination', [
|
||||||
'apiUrl' => $listApi,
|
'apiUrl' => $listApi,
|
||||||
'pageUrl' => $config['pageUrl'],
|
'pageUrl' => $config['pageUrl'],
|
||||||
|
'finalPageUrl' => $finalPageUrl,
|
||||||
'nextBtnSelector' => $uiConfig['nextBtnSelector'],
|
'nextBtnSelector' => $uiConfig['nextBtnSelector'],
|
||||||
'waitMs' => $uiConfig['waitMs'] ?? 2000,
|
'waitMs' => $uiConfig['waitMs'] ?? 2000,
|
||||||
'clicksToPerform' => $clicksToPerform,
|
'clicksToPerform' => $clicksToPerform,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
// Chatknow SCRM工单
|
||||||
|
require_once __DIR__ . '/AbstractScrmSpider.class.php';
|
||||||
|
|
||||||
|
class Chatknow extends AbstractScrmSpider
|
||||||
|
{
|
||||||
|
const API_LIST = '/user/user/UserInfoChildChannel/list'; // 列表API地址
|
||||||
|
const API_DETAILS = ''; // 详情API地址
|
||||||
|
const API_COUNT = ''; // 总数API地址
|
||||||
|
const DEFAULT_PER_PAGE_COUNT = 10; // List默认每页显示的数量
|
||||||
|
private $pageUrl;
|
||||||
|
private $account;
|
||||||
|
private $password;
|
||||||
|
private $unifiedData;
|
||||||
|
|
||||||
|
// 实例化时动态传入账号和密码
|
||||||
|
public function __construct($pageUrl, $account, $password, $nodeHost = 'http://127.0.0.1:3001')
|
||||||
|
{
|
||||||
|
parent::__construct($nodeHost);
|
||||||
|
$this->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, // 必须,第一页的列表数据
|
||||||
|
// 'detailApi' => self::API_DETAILS, // 选填
|
||||||
|
// 'countApi' => self::API_COUNT,
|
||||||
|
|
||||||
|
'listMethod' => 'GET',
|
||||||
|
|
||||||
|
'paginationMode' => self::MODE_UI,
|
||||||
|
|
||||||
|
'authActions' => [
|
||||||
|
// ['type' => 'vue_select', 'selector' => 'input[type="password"]', 'value' => $this->password],
|
||||||
|
// ['type' => 'type', 'selector' => '#username_input', 'value' => $this->account],
|
||||||
|
// ['type' => 'vue_click', 'selector' => '.layui-btn', 'text' => '搜索'],
|
||||||
|
|
||||||
|
['type' => 'wait', 'ms' => 4000]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只负责解析 List 的总页数
|
||||||
|
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||||
|
{
|
||||||
|
$default_per_page_count = self::DEFAULT_PER_PAGE_COUNT;
|
||||||
|
$this->unifiedData->total = $listFirstPageData['total'];
|
||||||
|
$this->unifiedData->todayNewCount = $listFirstPageData['data']['today_num'];
|
||||||
|
|
||||||
|
if($listFirstPageData['total'] <= $default_per_page_count) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ceil($listFirstPageData['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' => '.tabs-content .arco-pagination-item-next',
|
||||||
|
'waitMs' => 2000
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清爽至极的数据清洗:详情是详情,列表是列表
|
||||||
|
protected function parseToUnifiedData($detailData, $allListPagesData)
|
||||||
|
{
|
||||||
|
$unifiedData = $this->unifiedData;
|
||||||
|
|
||||||
|
// 循环合并了所有页数的 List 数组
|
||||||
|
foreach ($allListPagesData as $pageRaw) {
|
||||||
|
$records = $pageRaw['rows'] ?? [];
|
||||||
|
foreach ($records as $item) {
|
||||||
|
if(!empty($item['username'])) {
|
||||||
|
$number = $item['username'] ?? null;
|
||||||
|
$isOnline = (isset($item['state']) && $item['state'] == 1);
|
||||||
|
|
||||||
|
$unifiedData->addNumber($number, $isOnline, $item['today_num']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $unifiedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
// Whatshub 工单平台
|
||||||
|
|
||||||
|
require_once __DIR__ . '/AbstractScrmSpider.class.php';
|
||||||
|
|
||||||
|
class Whatshub extends AbstractScrmSpider
|
||||||
|
{
|
||||||
|
const API_LIST = '/api/whatshub-counter/workShare/open/detail'; // 列表API地址
|
||||||
|
const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics'; // 详情API地址
|
||||||
|
const API_COUNT = ''; // 总数API地址
|
||||||
|
const DEFAULT_PER_PAGE_COUNT = 20; // List默认每页显示的数量
|
||||||
|
private $pageUrl;
|
||||||
|
private $account;
|
||||||
|
private $password;
|
||||||
|
private $unifiedData;
|
||||||
|
|
||||||
|
// 实例化时动态传入账号和密码
|
||||||
|
public function __construct($pageUrl, $account, $password, $nodeHost = 'http://127.0.0.1:3001')
|
||||||
|
{
|
||||||
|
parent::__construct($nodeHost);
|
||||||
|
$this->account = $account;
|
||||||
|
$this->password = $password;
|
||||||
|
$this->pageUrl = $pageUrl;
|
||||||
|
|
||||||
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSpiderConfig()
|
||||||
|
{
|
||||||
|
$host = (string) parse_url($this->pageUrl, PHP_URL_HOST);
|
||||||
|
return [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'apiUrls' => [self::API_LIST, self::API_DETAILS],
|
||||||
|
|
||||||
|
// 明确指派角色
|
||||||
|
'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-input__inner', 'value' => $this->password],
|
||||||
|
|
||||||
|
// 2. 停顿 500ms,让 Vue 绑定的 v-model 彻底反应过来
|
||||||
|
['type' => 'wait', 'ms' => 500],
|
||||||
|
|
||||||
|
// 3. 点击确认:寻找 MessageBox 底部的蓝色 primary 确认按钮
|
||||||
|
// 同样利用 vue_click 的可见性过滤,无视隐藏的旧弹窗按钮
|
||||||
|
['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'],
|
||||||
|
|
||||||
|
// 4. 等待弹窗淡出,接口开始请求
|
||||||
|
['type' => 'wait', 'ms' => 2200]
|
||||||
|
],
|
||||||
|
// Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用
|
||||||
|
'antiBot' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'profile' => 'real',
|
||||||
|
'turnstile' => true,
|
||||||
|
'solverFallback' => true,
|
||||||
|
'sessionKey' => 'whatshub:' . $host,
|
||||||
|
'challengeTimeoutMs' => 60000,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只负责解析 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有分页返回空数组
|
||||||
|
protected function buildListPageParams($page)
|
||||||
|
{
|
||||||
|
return ['pageNum' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供 List 的下一页按钮信息
|
||||||
|
protected function getUiPaginationConfig()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nextBtnSelector' => '.vxe-pager--next-btn',
|
||||||
|
'waitMs' => 1500
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清爽至极的数据清洗:详情是详情,列表是列表
|
||||||
|
protected function parseToUnifiedData($detailData, $allListPagesData)
|
||||||
|
{
|
||||||
|
$unifiedData = $this->unifiedData;
|
||||||
|
|
||||||
|
// 1. 如果捕获到了详情数据,提取今日新增
|
||||||
|
if ($detailData) {
|
||||||
|
$unifiedData->todayNewCount = (int)($detailData['data']['dayNewFans'] ?? 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['isOnline']) && $item['isOnline'] == 1);
|
||||||
|
|
||||||
|
$unifiedData->addNumber($number, $isOnline, $item['dayNewFans']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 3. 终极统计:所有翻页数据均已入库,此时再统计真实的 Total 总数
|
||||||
|
$unifiedData->total = count($unifiedData->numbers);
|
||||||
|
|
||||||
|
return $unifiedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+9
-9
@@ -5,10 +5,10 @@ require_once __DIR__ . '/AbstractScrmSpider.class.php';
|
|||||||
|
|
||||||
class Xinghe extends AbstractScrmSpider
|
class Xinghe extends AbstractScrmSpider
|
||||||
{
|
{
|
||||||
const API_LIST = '/share/share/api_yinliu_count.html'; // 列表API地址
|
const API_LIST = '/share/share/api_yinliu_count.html'; // 列表API地址
|
||||||
const API_DETAILS = ''; // 详情API地址
|
const API_DETAILS = ''; // 详情API地址
|
||||||
const API_COUNT = ''; // 总数API地址
|
const API_COUNT = ''; // 总数API地址
|
||||||
const DEFAULT_PER_PAGE_COUNT = 10; // List默认每页显示的数量
|
const DEFAULT_PER_PAGE_COUNT = 10; // List默认每页显示的数量
|
||||||
private $pageUrl;
|
private $pageUrl;
|
||||||
private $account;
|
private $account;
|
||||||
private $password;
|
private $password;
|
||||||
@@ -29,7 +29,7 @@ class Xinghe extends AbstractScrmSpider
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'pageUrl' => $this->pageUrl,
|
'pageUrl' => $this->pageUrl,
|
||||||
'apiUrls' => [self::API_LIST . "?page=1&limit=10"],
|
'apiUrls' => [self::API_LIST . "?page=1&limit=10&is_repet=1"],
|
||||||
'listApi' => self::API_LIST, // 必须,第一页的列表数据
|
'listApi' => self::API_LIST, // 必须,第一页的列表数据
|
||||||
// 'detailApi' => self::API_DETAILS, // 选填
|
// 'detailApi' => self::API_DETAILS, // 选填
|
||||||
// 'countApi' => self::API_COUNT,
|
// 'countApi' => self::API_COUNT,
|
||||||
@@ -39,9 +39,9 @@ class Xinghe extends AbstractScrmSpider
|
|||||||
'paginationMode' => self::MODE_FETCH,
|
'paginationMode' => self::MODE_FETCH,
|
||||||
|
|
||||||
'authActions' => [
|
'authActions' => [
|
||||||
// ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password],
|
// ['type' => 'vue_select', 'selector' => 'input[type="password"]', 'value' => $this->password],
|
||||||
// ['type' => 'type', 'selector' => '#username_input', 'value' => $this->account],
|
// ['type' => 'type', 'selector' => '#username_input', 'value' => $this->account],
|
||||||
// ['type' => 'press', 'key' => 'Enter'],
|
// ['type' => 'vue_click', 'selector' => '.layui-btn', 'text' => '搜索'],
|
||||||
|
|
||||||
['type' => 'wait', 'ms' => 2000]
|
['type' => 'wait', 'ms' => 2000]
|
||||||
]
|
]
|
||||||
@@ -53,7 +53,7 @@ class Xinghe extends AbstractScrmSpider
|
|||||||
{
|
{
|
||||||
$default_per_page_count = self::DEFAULT_PER_PAGE_COUNT;
|
$default_per_page_count = self::DEFAULT_PER_PAGE_COUNT;
|
||||||
$this->unifiedData->total = $listFirstPageData['count'];
|
$this->unifiedData->total = $listFirstPageData['count'];
|
||||||
$this->unifiedData->todayNewCount = $listFirstPageData['totalRow']['day_sum'];
|
// $this->unifiedData->todayNewCount = $listFirstPageData['totalRow']['day_sum'];
|
||||||
|
|
||||||
if($listFirstPageData['count'] <= $default_per_page_count) {
|
if($listFirstPageData['count'] <= $default_per_page_count) {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -84,7 +84,7 @@ class Xinghe extends AbstractScrmSpider
|
|||||||
|
|
||||||
// 循环合并了所有页数的 List 数组
|
// 循环合并了所有页数的 List 数组
|
||||||
foreach ($allListPagesData as $pageRaw) {
|
foreach ($allListPagesData as $pageRaw) {
|
||||||
$records = $pageRaw['data'] ?? [];
|
$records = $pageRaw['data'] ?? [];
|
||||||
foreach ($records as $item) {
|
foreach ($records as $item) {
|
||||||
if(!empty($item['user'])) {
|
if(!empty($item['user'])) {
|
||||||
$number = $item['user'] ?? null;
|
$number = $item['user'] ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/UnifiedScrmData.class.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据爬虫类
|
||||||
|
* 用于处理需要先获取 Token 授权,再读取接口数据的场景
|
||||||
|
*/
|
||||||
|
class Xinghe
|
||||||
|
{
|
||||||
|
private $cookieFile;
|
||||||
|
private $ch; // cURL 句柄
|
||||||
|
private $pageUrl;
|
||||||
|
private $account;
|
||||||
|
private $password;
|
||||||
|
private $unifiedData;
|
||||||
|
private $baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数:初始化 cURL 和 Cookie 文件
|
||||||
|
*/
|
||||||
|
public function __construct($pageUrl, $username, $password)
|
||||||
|
{
|
||||||
|
$this->pageUrl = $pageUrl;
|
||||||
|
$this->username = $username;
|
||||||
|
$this->password = $password;
|
||||||
|
|
||||||
|
$parsedUrl = parse_url($pageUrl);
|
||||||
|
$scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : '';
|
||||||
|
$host = isset($parsedUrl['host']) ? $parsedUrl['host'] : '';
|
||||||
|
$baseUrl = $scheme . $host;
|
||||||
|
$this->baseUrl = $baseUrl;
|
||||||
|
|
||||||
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run()
|
||||||
|
{
|
||||||
|
// 1. 创建临时 Cookie 文件
|
||||||
|
$this->cookieFile = tempnam(sys_get_temp_dir(), 'spider_cookie_');
|
||||||
|
if ($this->cookieFile === false) {
|
||||||
|
throw new \RuntimeException("无法在系统临时目录创建 Cookie 文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 初始化 cURL 并设置全局参数
|
||||||
|
$this->ch = curl_init();
|
||||||
|
|
||||||
|
$apiUrl = '/share/share/api_yinliu_count.html?page=1&limit=10&id=&class_id=&is_repet=1&start_time=&end_time=';
|
||||||
|
|
||||||
|
$defaultOptions = [
|
||||||
|
CURLOPT_RETURNTRANSFER => true, // 将结果返回为字符串
|
||||||
|
CURLOPT_HEADER => false, // 不输出响应头
|
||||||
|
CURLOPT_TIMEOUT => 15, // 设置超时时间(秒)
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
|
||||||
|
// 核心优化:同时指定读写 Cookie 为同一个文件,cURL 会自动维护会话
|
||||||
|
CURLOPT_COOKIEJAR => $this->cookieFile,
|
||||||
|
CURLOPT_COOKIEFILE => $this->cookieFile,
|
||||||
|
];
|
||||||
|
|
||||||
|
curl_setopt_array($this->ch, $defaultOptions);
|
||||||
|
|
||||||
|
$this->authenticate();
|
||||||
|
$first_page_data = $this->fetchApiData($apiUrl); // 首页数据
|
||||||
|
$this->unifiedData->total = $first_page_data['count']; // 总在线人数
|
||||||
|
$this->unifiedData->todayNewCount = $first_page_data['totalRow']['day_sum']; // 今日新增在线人数
|
||||||
|
|
||||||
|
$total_pages = ceil($this->unifiedData->total/10); // 总页数
|
||||||
|
$allListPagesData = [$first_page_data['data']]; // 初始化列表容器,装入第一页数据
|
||||||
|
for ($page = 2; $page <= $total_pages; $page++) {
|
||||||
|
$apiUrl = '/share/share/api_yinliu_count.html?page=' . $page . '&limit=10&id=&class_id=&is_repet=1&start_time=&end_time=';
|
||||||
|
|
||||||
|
$page_data = $this->fetchApiData($apiUrl);
|
||||||
|
|
||||||
|
$allListPagesData[] = $page_data['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parseToUnifiedData($first_page_data, $allListPagesData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清爽至极的数据清洗:详情是详情,列表是列表
|
||||||
|
protected function parseToUnifiedData($detailData, $allListPagesData)
|
||||||
|
{
|
||||||
|
$unifiedData = $this->unifiedData;
|
||||||
|
|
||||||
|
// 循环合并了所有页数的 List 数组
|
||||||
|
foreach ($allListPagesData as $records) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 析构函数:释放资源,清理垃圾
|
||||||
|
*/
|
||||||
|
public function __destruct()
|
||||||
|
{
|
||||||
|
// 关闭 cURL 会话
|
||||||
|
if (is_resource($this->ch) || $this->ch instanceof \CurlHandle) {
|
||||||
|
curl_close($this->ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除临时 Cookie 文件
|
||||||
|
if (file_exists($this->cookieFile)) {
|
||||||
|
unlink($this->cookieFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第一步:访问授权页面,建立会话
|
||||||
|
*
|
||||||
|
* @param string $authUrl 包含 token 的授权地址
|
||||||
|
* @return bool 授权请求是否成功
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function authenticate()
|
||||||
|
{
|
||||||
|
$this->sendRequest($this->pageUrl);
|
||||||
|
return true; // 如果没有抛出异常,则认为请求成功
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第二步:请求 API 接口获取数据
|
||||||
|
*
|
||||||
|
* @param string $apiUrl 接口数据地址
|
||||||
|
* @return array|null 解析后的数组数据,如果解析失败返回 null
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function fetchApiData($apiUrl)
|
||||||
|
{
|
||||||
|
$response = $this->sendRequest($this->baseUrl . $apiUrl);
|
||||||
|
|
||||||
|
// 尝试解析 JSON 数据
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw new \RuntimeException("JSON 解析失败: " . json_last_error_msg() . "。原始响应: " . $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 cURL 请求的底层私有方法
|
||||||
|
*
|
||||||
|
* @param string $url 目标地址
|
||||||
|
* @return string 服务器响应内容
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
private function sendRequest($url)
|
||||||
|
{
|
||||||
|
curl_setopt($this->ch, CURLOPT_URL, $url);
|
||||||
|
|
||||||
|
$response = curl_exec($this->ch);
|
||||||
|
|
||||||
|
// 检查是否有网络或 cURL 底层错误
|
||||||
|
if ($response === false) {
|
||||||
|
$error = curl_error($this->ch);
|
||||||
|
throw new \RuntimeException("请求失败 [{$url}]: {$error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 HTTP 状态码
|
||||||
|
$httpCode = curl_getinfo($this->ch, CURLINFO_HTTP_CODE);
|
||||||
|
if ($httpCode >= 400) {
|
||||||
|
throw new \RuntimeException("HTTP 请求异常 [{$url}],状态码: {$httpCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string)$response;
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+98
-28
@@ -1,15 +1,86 @@
|
|||||||
<?php
|
<?php
|
||||||
// 文件名: run.php
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 'ON');
|
||||||
|
|
||||||
require_once __DIR__ . '/A2c.php'; // A2c云控
|
// 文件名: run.php
|
||||||
require_once __DIR__ . '/Xinghe.php'; // 星河云控
|
require_once __DIR__ . '/Chatknow.php'; // ChatKnow SCRM
|
||||||
|
require_once __DIR__ . '/A2c.php'; // A2c云控
|
||||||
|
require_once __DIR__ . '/Xinghe2.php'; // 星河云控
|
||||||
require_once __DIR__ . '/Huojian.php'; // 火箭
|
require_once __DIR__ . '/Huojian.php'; // 火箭
|
||||||
require_once __DIR__ . '/SsCustomer.php'; // SS云控(Customer)
|
require_once __DIR__ . '/SsCustomer.php'; // SS云控(Customer)
|
||||||
require_once __DIR__ . '/Haiwang.php'; // 海王
|
require_once __DIR__ . '/Haiwang.php'; // 海王
|
||||||
|
require_once __DIR__ . '/Whatshub.php'; // Whatshub 工单平台
|
||||||
|
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
命令行可以测试Cloudflare防火墙
|
||||||
|
curl -s -X POST http://127.0.0.1:3001/api/auth-and-intercept \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"pageUrl": "https://web.whatshub.cc/m/iTYsWKQH5030/1",
|
||||||
|
"apiUrls": ["/api/whatshub-counter/workShare/open/detail"],
|
||||||
|
"authActions": [
|
||||||
|
{"type": "vue_fill", "selector": ".el-input__inner", "value": "745030"},
|
||||||
|
{"type": "wait", "ms": 500},
|
||||||
|
{"type": "vue_click", "selector": ".vxe-button-group .theme--primary"},
|
||||||
|
{"type": "wait", "ms": 2200}
|
||||||
|
],
|
||||||
|
"antiBot": {
|
||||||
|
"enabled": true,
|
||||||
|
"profile": "real",
|
||||||
|
"turnstile": true,
|
||||||
|
"solverFallback": true,
|
||||||
|
"sessionKey": "whatshub:t.flowerbells.top",
|
||||||
|
"challengeTimeoutMs": 60000
|
||||||
|
}
|
||||||
|
}' | jq .
|
||||||
|
*/
|
||||||
|
|
||||||
|
echo "🚀 开始执行<Whatshub 工单平台>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
$pageUrl = 'https://web.whatshub.cc/m/iTYsWKQH5030/1'; // PageUrl 入口授权页
|
||||||
|
$username = ""; // 登录账号
|
||||||
|
$password = "745030"; // 登录密码
|
||||||
|
$spider = new Whatshub($pageUrl, $username, $password);
|
||||||
|
$finalData = $spider->run();
|
||||||
|
|
||||||
|
echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
echo "----------------------------------------\n\r";
|
||||||
|
echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
|
echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
|
echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
|
echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
|
echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
|
echo "号码列表:\n\r";
|
||||||
|
echo dd($finalData->numbers);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// echo "🚀 开始执行<ChatKnow SCRM>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
// $pageUrl = 'https://user.chatknow.com/child/workorder-share?shareKey=jf5t6MNrJ2mC76N'; // PageUrl 入口授权页
|
||||||
|
// $username = ""; // 登录账号
|
||||||
|
// $password = ""; // 登录密码
|
||||||
|
// $spider = new Chatknow($pageUrl, $username, $password);
|
||||||
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
// echo "----------------------------------------\n\r";
|
||||||
|
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
|
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
|
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
|
// echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
|
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
|
// echo "号码列表:\n\r";
|
||||||
|
// echo dd($finalData->numbers);
|
||||||
|
// } catch (Exception $e) {
|
||||||
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
|
// }
|
||||||
|
|
||||||
// try {
|
// try {
|
||||||
// echo "🚀 开始执行<A2c云控>抓取任务 (多引擎智能调度)...\n\r";
|
// echo "🚀 开始执行<A2c云控>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
// $pageUrl = 'https://user.a2c.chat/visitors/counter/share?id=33e449dc83c24ee59275bf03a2d82234'; // PageUrl 入口授权页
|
// $pageUrl = 'https://yyk.ink/8415O53'; // PageUrl 入口授权页
|
||||||
// $username = ""; // 登录账号
|
// $username = ""; // 登录账号
|
||||||
// $password = ""; // 登录密码
|
// $password = ""; // 登录密码
|
||||||
// $spider = new A2c($pageUrl, $username, $password);
|
// $spider = new A2c($pageUrl, $username, $password);
|
||||||
@@ -31,7 +102,7 @@ require_once __DIR__ . '/Haiwang.php'; // 海王
|
|||||||
|
|
||||||
// try {
|
// try {
|
||||||
// echo "🚀 开始执行<星河云控>抓取任务 (多引擎智能调度)...\n\r";
|
// echo "🚀 开始执行<星河云控>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
// $pageUrl = 'http://103.251.112.35:10158/share/share/index.html?token=pds65jl202t2kjis5firb8epu4d8a83ptfhc63d89l3mv11nwa'; // PageUrl 入口授权页
|
// $pageUrl = 'http://8.218.14.51/share/share/index.html?token=hn6z3egq4nnnebkv4063pcdouzr4ug5js902mlqo2n3yp9gzhe'; // PageUrl 入口授权页
|
||||||
// $username = ""; // 登录账号
|
// $username = ""; // 登录账号
|
||||||
// $password = ""; // 登录密码
|
// $password = ""; // 登录密码
|
||||||
// $spider = new Xinghe($pageUrl, $username, $password);
|
// $spider = new Xinghe($pageUrl, $username, $password);
|
||||||
@@ -46,16 +117,15 @@ require_once __DIR__ . '/Haiwang.php'; // 海王
|
|||||||
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
// echo "号码列表:\n\r";
|
// echo "号码列表:\n\r";
|
||||||
// echo dd($finalData->numbers);
|
// echo dd($finalData->numbers);
|
||||||
|
|
||||||
// } catch (Exception $e) {
|
// } catch (Exception $e) {
|
||||||
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// try {
|
// try {
|
||||||
// echo "🚀 开始执行<火箭工单>抓取任务 (多引擎智能调度)...\n\r";
|
// echo "🚀 开始执行<火箭工单>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
// $pageUrl = 'https://s.url99.me/ygn9zjr8'; // PageUrl 入口授权页
|
// $pageUrl = 'https://s.url99.me/68vfje8m'; // PageUrl 入口授权页
|
||||||
// $username = ""; // 登录账号
|
// $username = ""; // 登录账号
|
||||||
// $password = "123456"; // 登录密码
|
// $password = "542187"; // 登录密码
|
||||||
// $spider = new Huojian($pageUrl, $username, $password);
|
// $spider = new Huojian($pageUrl, $username, $password);
|
||||||
// $finalData = $spider->run();
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
@@ -96,27 +166,27 @@ require_once __DIR__ . '/Haiwang.php'; // 海王
|
|||||||
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
// }
|
// }
|
||||||
|
|
||||||
try {
|
// try {
|
||||||
echo "🚀 开始执行<海王>抓取任务 (多引擎智能调度)...\n\r";
|
// echo "🚀 开始执行<海王>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
$pageUrl = 'https://admin.haiwangweb.com/web#/accountshow/pZsEulYrb'; // PageUrl 入口授权页
|
// $pageUrl = 'https://admin.haiwangweb.com/web#/accountshow/pZsEulYrb'; // PageUrl 入口授权页
|
||||||
$username = ""; // 登录账号
|
// $username = ""; // 登录账号
|
||||||
$password = "9999"; // 登录密码
|
// $password = "9999"; // 登录密码
|
||||||
$spider = new Haiwang($pageUrl, $username, $password);
|
// $spider = new Haiwang($pageUrl, $username, $password);
|
||||||
$finalData = $spider->run();
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
echo "✅ 任务完成!统一数据如下:\n\r";
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
echo "----------------------------------------\n\r";
|
// echo "----------------------------------------\n\r";
|
||||||
echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
echo "Total:" . $finalData->total . " 个号码\n\r";
|
// echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
echo "号码列表:\n\r";
|
// echo "号码列表:\n\r";
|
||||||
echo dd($finalData->numbers);
|
// echo dd($finalData->numbers);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
// } catch (Exception $e) {
|
||||||
echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
}
|
// }
|
||||||
|
|
||||||
// try {
|
// try {
|
||||||
// echo "🚀 开始执行<CEO SCRM>抓取任务 (多引擎智能调度)...\n\r";
|
// echo "🚀 开始执行<CEO SCRM>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
|||||||
Regular → Executable
@@ -0,0 +1,6 @@
|
|||||||
|
-- Chatknow SCRM 工单云控自动同步周期(分钟,0=不自动同步)
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_interval_chatknow', 'split', 'Chatknow SCRM同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_chatknow' LIMIT 1);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- 修复 SS 云控同步周期配置键名拼写错误:ss_custome -> ss_customer
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
UPDATE `fa_config`
|
||||||
|
SET `name` = 'split_sync_interval_ss_customer'
|
||||||
|
WHERE `name` = 'split_sync_interval_ss_custome'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer') AS `t`);
|
||||||
|
|
||||||
|
UPDATE `fa_config` AS `correct`
|
||||||
|
INNER JOIN `fa_config` AS `wrong` ON `wrong`.`name` = 'split_sync_interval_ss_custome'
|
||||||
|
SET `correct`.`value` = `wrong`.`value`
|
||||||
|
WHERE `correct`.`name` = 'split_sync_interval_ss_customer';
|
||||||
|
|
||||||
|
DELETE FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_custome';
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+7
@@ -71,6 +71,13 @@ WHERE m.name = 'split.number' AND m.ismenu = 1
|
|||||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchupdate' LIMIT 1)
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchupdate' LIMIT 1)
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/batchoperate', '批量操作号码', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchoperate' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
SELECT 'file', m.id, 'split.number/multi', '批量更新', 'fa fa-circle-o', '', '列表状态开关', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
SELECT 'file', m.id, 'split.number/multi', '批量更新', 'fa fa-circle-o', '', '列表状态开关', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
FROM `fa_auth_rule` m
|
FROM `fa_auth_rule` m
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
+15
-2
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\admin\command;
|
namespace app\admin\command;
|
||||||
|
|
||||||
|
use app\common\service\SplitCronLockService;
|
||||||
use app\common\service\SplitTicketSyncLogger;
|
use app\common\service\SplitTicketSyncLogger;
|
||||||
use app\common\service\SplitTicketSyncService;
|
use app\common\service\SplitTicketSyncService;
|
||||||
use think\console\Command;
|
use think\console\Command;
|
||||||
@@ -51,8 +52,20 @@ class SplitSyncTickets extends Command
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$count = $service->syncDueTickets();
|
$cronLock = new SplitCronLockService();
|
||||||
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
if (!$cronLock->acquire()) {
|
||||||
|
SplitTicketSyncLogger::log('cli', 'cron lock busy, skip');
|
||||||
|
$output->writeln('<comment>上一轮同步仍在执行,跳过本次</comment>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$count = $service->syncDueTickets();
|
||||||
|
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
||||||
|
} finally {
|
||||||
|
$cronLock->release();
|
||||||
|
}
|
||||||
|
|
||||||
if (SplitTicketSyncLogger::isEnabled()) {
|
if (SplitTicketSyncLogger::isEnabled()) {
|
||||||
$output->writeln('<comment>调试日志已写入 runtime/log/split_sync.log</comment>');
|
$output->writeln('<comment>调试日志已写入 runtime/log/split_sync.log</comment>');
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+25
@@ -64,10 +64,35 @@ class Link extends Backend
|
|||||||
$this->assignconfig('ipProtectList', $this->model->getIpProtectList());
|
$this->assignconfig('ipProtectList', $this->model->getIpProtectList());
|
||||||
$this->assignconfig('randomShuffleList', $this->model->getRandomShuffleList());
|
$this->assignconfig('randomShuffleList', $this->model->getRandomShuffleList());
|
||||||
$this->assignconfig('statusList', $this->model->getStatusList());
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
|
$this->assignconfig('linkFilterList', $this->buildLinkFilterList());
|
||||||
|
|
||||||
$this->setupPatchFrontend();
|
$this->setupPatchFrontend();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分流链接列表筛选下拉(id => 链接码 - 描述)
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function buildLinkFilterList(): array
|
||||||
|
{
|
||||||
|
$query = $this->model->order('id', 'desc');
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$list = [];
|
||||||
|
foreach ($query->field('id,link_code,description')->select() as $row) {
|
||||||
|
$code = (string) $row['link_code'];
|
||||||
|
$desc = (string) $row['description'];
|
||||||
|
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
|
||||||
|
$list[(string) $row['id']] = $label;
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 未部署 JS 到 public 时,或 patches 版本较新时,jsname 指向 script 接口
|
* 未部署 JS 到 public 时,或 patches 版本较新时,jsname 指向 script 接口
|
||||||
*/
|
*/
|
||||||
|
|||||||
Regular → Executable
+130
@@ -29,6 +29,9 @@ class Number extends Backend
|
|||||||
|
|
||||||
protected $dataLimit = 'personal';
|
protected $dataLimit = 'personal';
|
||||||
|
|
||||||
|
/** 关联 splitLink 预载入时需为筛选/排序字段加表别名,避免 status 等列歧义 */
|
||||||
|
protected $relationSearch = true;
|
||||||
|
|
||||||
protected $modelValidate = true;
|
protected $modelValidate = true;
|
||||||
|
|
||||||
protected $modelSceneValidate = true;
|
protected $modelSceneValidate = true;
|
||||||
@@ -62,6 +65,8 @@ class Number extends Backend
|
|||||||
$this->assignconfig('statusList', $this->model->getStatusList());
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
||||||
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
||||||
|
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
|
||||||
|
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
|
||||||
|
|
||||||
$this->setupPatchFrontend();
|
$this->setupPatchFrontend();
|
||||||
}
|
}
|
||||||
@@ -111,6 +116,52 @@ class Number extends Backend
|
|||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已有号码关联的分流链接(供列表筛选下拉)
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function buildNumberSplitLinkFilterList(): array
|
||||||
|
{
|
||||||
|
$numberTable = $this->model->getTable();
|
||||||
|
$linkTable = (new LinkModel())->getTable();
|
||||||
|
$query = Db::table($numberTable)
|
||||||
|
->alias('n')
|
||||||
|
->join($linkTable . ' l', 'n.split_link_id = l.id')
|
||||||
|
->where('n.split_link_id', '>', 0)
|
||||||
|
->group('n.split_link_id')
|
||||||
|
->field('l.id,l.link_code,l.description')
|
||||||
|
->order('l.id', 'desc');
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$query->where('n.admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$list = [];
|
||||||
|
foreach ($query->select() as $row) {
|
||||||
|
$code = (string) $row['link_code'];
|
||||||
|
$desc = (string) $row['description'];
|
||||||
|
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
|
||||||
|
$list[(string) $row['id']] = $label;
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部分流链接(供批量操作弹窗可搜索下拉)
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function buildSplitLinkSelectConfig(): array
|
||||||
|
{
|
||||||
|
$list = [];
|
||||||
|
foreach ($this->buildSplitLinkList() as $row) {
|
||||||
|
$list[(string) $row['id']] = (string) $row['label'];
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
private function fetchPatch(string $template): string
|
private function fetchPatch(string $template): string
|
||||||
{
|
{
|
||||||
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
||||||
@@ -395,6 +446,85 @@ class Number extends Backend
|
|||||||
$this->success(__('Batch update success'));
|
$this->success(__('Batch update success'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按分流链接 + 号码文本批量开启/关闭/删除
|
||||||
|
*/
|
||||||
|
public function batchoperate(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$splitLinkId = (int) $this->request->post('split_link_id', 0);
|
||||||
|
$numbersText = (string) $this->request->post('numbers', '');
|
||||||
|
$action = (string) $this->request->post('action', '');
|
||||||
|
|
||||||
|
if ($splitLinkId <= 0) {
|
||||||
|
$this->error(__('Please select split link'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$numberList = NumberModel::parseNumbersText($numbersText);
|
||||||
|
if ($numberList === []) {
|
||||||
|
$this->error(__('Please fill at least one number'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedActions = ['enable', 'disable', 'delete'];
|
||||||
|
if (!in_array($action, $allowedActions, true)) {
|
||||||
|
$this->error(__('Invalid batch operate action'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkQuery = (new LinkModel())->where('id', $splitLinkId)->where('status', 'normal');
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$linkQuery->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$linkQuery->find()) {
|
||||||
|
$this->error(__('No Results were found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->model
|
||||||
|
->where('split_link_id', $splitLinkId)
|
||||||
|
->where('number', 'in', $numberList);
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$rows = $query->select();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($row->delete()) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$status = $action === 'enable' ? 'normal' : 'hidden';
|
||||||
|
$manualManage = $action === 'enable' ? 0 : 1;
|
||||||
|
$count = (int) $query->update([
|
||||||
|
'status' => $status,
|
||||||
|
'manual_manage' => $manualManage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($count <= 0) {
|
||||||
|
$this->error(__('No matching numbers found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success(sprintf(__('Batch operate success'), $count));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 排除不可由表单提交的字段
|
* 排除不可由表单提交的字段
|
||||||
*
|
*
|
||||||
|
|||||||
Regular → Executable
+129
-1
@@ -58,6 +58,7 @@ class Ticket extends Backend
|
|||||||
$this->assignconfig('ticketTypeList', $this->model->getTicketTypeList());
|
$this->assignconfig('ticketTypeList', $this->model->getTicketTypeList());
|
||||||
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
|
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
|
||||||
$this->assignconfig('statusList', $this->model->getStatusList());
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
|
$this->assignconfig('splitLinkFilterList', $this->buildTicketSplitLinkFilterList());
|
||||||
$this->assignconfig([
|
$this->assignconfig([
|
||||||
'syncConfirmMsg' => __('Sync confirm'),
|
'syncConfirmMsg' => __('Sync confirm'),
|
||||||
'syncBackgroundStartedMsg' => __('Sync background started'),
|
'syncBackgroundStartedMsg' => __('Sync background started'),
|
||||||
@@ -142,6 +143,71 @@ class Ticket extends Backend
|
|||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已有工单关联的分流链接(供列表筛选下拉)
|
||||||
|
*
|
||||||
|
* @return array<string, string> id => label
|
||||||
|
*/
|
||||||
|
private function buildTicketSplitLinkFilterList(): array
|
||||||
|
{
|
||||||
|
$ticketTable = $this->model->getTable();
|
||||||
|
$linkTable = (new LinkModel())->getTable();
|
||||||
|
$query = Db::table($ticketTable)
|
||||||
|
->alias('t')
|
||||||
|
->join($linkTable . ' l', 't.split_link_id = l.id')
|
||||||
|
->where('t.split_link_id', '>', 0)
|
||||||
|
->group('t.split_link_id')
|
||||||
|
->field('l.id,l.link_code,l.description')
|
||||||
|
->order('l.id', 'desc');
|
||||||
|
if ($this->dataLimit) {
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$query->where('t.admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$list = [];
|
||||||
|
foreach ($query->select() as $row) {
|
||||||
|
$code = (string) $row['link_code'];
|
||||||
|
$desc = (string) $row['description'];
|
||||||
|
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
|
||||||
|
$list[(string) $row['id']] = $label;
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按当前筛选条件汇总列表统计列
|
||||||
|
*
|
||||||
|
* @param callable|array<mixed> $where buildparams() 返回的闭包或条件数组
|
||||||
|
* @return array<string, int|float|string>
|
||||||
|
*/
|
||||||
|
private function buildListSummary($where): array
|
||||||
|
{
|
||||||
|
$stats = $this->model
|
||||||
|
->where($where)
|
||||||
|
->fieldRaw(
|
||||||
|
'COALESCE(SUM(ticket_total), 0) AS sum_ticket_total,'
|
||||||
|
. ' COALESCE(SUM(complete_count), 0) AS sum_complete_count,'
|
||||||
|
. ' COALESCE(SUM(speed_per_hour), 0) AS sum_speed_per_hour,'
|
||||||
|
. ' COALESCE(AVG(CASE WHEN ticket_total > 0 THEN complete_count * 100.0 / ticket_total ELSE 0 END), 0) AS avg_ticket_progress'
|
||||||
|
)
|
||||||
|
->find();
|
||||||
|
if (!$stats) {
|
||||||
|
return [
|
||||||
|
'ticket_total' => 0,
|
||||||
|
'complete_count' => 0,
|
||||||
|
'speed_per_hour' => '0.00',
|
||||||
|
'ticket_progress_text' => '0.00%',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'ticket_total' => (int) ($stats['sum_ticket_total'] ?? 0),
|
||||||
|
'complete_count' => (int) ($stats['sum_complete_count'] ?? 0),
|
||||||
|
'speed_per_hour' => number_format((float) ($stats['sum_speed_per_hour'] ?? 0), 2, '.', ''),
|
||||||
|
'ticket_progress_text' => number_format((float) ($stats['avg_ticket_progress'] ?? 0), 2, '.', '') . '%',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function fetchPatch(string $template): string
|
private function fetchPatch(string $template): string
|
||||||
{
|
{
|
||||||
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
||||||
@@ -179,7 +245,11 @@ class Ticket extends Backend
|
|||||||
->where($where)
|
->where($where)
|
||||||
->order($sort, $order)
|
->order($sort, $order)
|
||||||
->paginate($limit);
|
->paginate($limit);
|
||||||
$result = ['total' => $list->total(), 'rows' => $list->items()];
|
$result = [
|
||||||
|
'total' => $list->total(),
|
||||||
|
'rows' => $list->items(),
|
||||||
|
'summary' => $this->buildListSummary($where),
|
||||||
|
];
|
||||||
return json($result);
|
return json($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +401,10 @@ class Ticket extends Backend
|
|||||||
public function add()
|
public function add()
|
||||||
{
|
{
|
||||||
if (false === $this->request->isPost()) {
|
if (false === $this->request->isPost()) {
|
||||||
|
$copyRow = $this->buildCopyRowData();
|
||||||
|
if ($copyRow !== null) {
|
||||||
|
$this->view->assign('row', $copyRow);
|
||||||
|
}
|
||||||
return $this->fetchPatch('add');
|
return $this->fetchPatch('add');
|
||||||
}
|
}
|
||||||
$params = $this->request->post('row/a', []);
|
$params = $this->request->post('row/a', []);
|
||||||
@@ -367,6 +441,60 @@ class Ticket extends Backend
|
|||||||
$this->success('', null, ['id' => (int) $this->model->id]);
|
$this->success('', null, ['id' => (int) $this->model->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 copy_id 构建添加工单表单的预填数据(仅复制业务字段,不含同步统计)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function buildCopyRowData(): ?array
|
||||||
|
{
|
||||||
|
$copyId = (int) $this->request->get('copy_id/d', 0);
|
||||||
|
if ($copyId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->model->get($copyId);
|
||||||
|
if (!$row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $row->toArray();
|
||||||
|
$copyFields = [
|
||||||
|
'ticket_type',
|
||||||
|
'ticket_name',
|
||||||
|
'ticket_url',
|
||||||
|
'ticket_total',
|
||||||
|
'split_link_id',
|
||||||
|
'number_type',
|
||||||
|
'number_type_custom',
|
||||||
|
'order_limit',
|
||||||
|
'assign_ratio',
|
||||||
|
'account',
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
$copyRow = [];
|
||||||
|
foreach ($copyFields as $field) {
|
||||||
|
if (array_key_exists($field, $data)) {
|
||||||
|
$copyRow[$field] = $data[$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$copyRow['start_time'] = $data['start_time_text'] ?? '';
|
||||||
|
$copyRow['end_time'] = $data['end_time_text'] ?? '';
|
||||||
|
|
||||||
|
$name = trim((string) ($copyRow['ticket_name'] ?? ''));
|
||||||
|
if ($name !== '' && mb_strpos($name, ' (副本)') === false) {
|
||||||
|
$copyRow['ticket_name'] = $name . ' (副本)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $copyRow;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string|null $ids
|
* @param string|null $ids
|
||||||
* @return string
|
* @return string
|
||||||
|
|||||||
Regular → Executable
+1
-1
@@ -37,7 +37,7 @@ return [
|
|||||||
'System config' => '系统配置',
|
'System config' => '系统配置',
|
||||||
'Auto reply' => '自动回复',
|
'Auto reply' => '自动回复',
|
||||||
'Reply statements' => '回复语句',
|
'Reply statements' => '回复语句',
|
||||||
'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联',
|
'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联;跳转 WhatsApp / Telegram 时随机附加为预填消息',
|
||||||
'Auto reply saved' => '自动回复已保存',
|
'Auto reply saved' => '自动回复已保存',
|
||||||
'Reply statements column' => '回复语',
|
'Reply statements column' => '回复语',
|
||||||
'NS' => 'NS',
|
'NS' => 'NS',
|
||||||
|
|||||||
Regular → Executable
+12
@@ -30,6 +30,18 @@ return [
|
|||||||
'Batch selected count' => '已选号码',
|
'Batch selected count' => '已选号码',
|
||||||
'Batch update status' => '更新状态',
|
'Batch update status' => '更新状态',
|
||||||
'Batch update btn' => '更新状态',
|
'Batch update btn' => '更新状态',
|
||||||
|
'Batch operate btn' => '批量操作',
|
||||||
|
'Batch operate title' => '批量操作',
|
||||||
|
'Batch operate action' => '操作',
|
||||||
|
'Batch operate enable' => '开启状态',
|
||||||
|
'Batch operate disable' => '关闭状态',
|
||||||
|
'Batch operate delete' => '删除号码',
|
||||||
|
'Batch operate success' => '批量操作成功,共处理 %d 条',
|
||||||
|
'Invalid batch operate action' => '操作类型无效',
|
||||||
|
'No matching numbers found' => '未找到匹配的号码',
|
||||||
|
'Please select split link' => '请选择分流链接',
|
||||||
|
'Numbers one per line' => '一行一个号码',
|
||||||
|
'Batch operate delete confirm' => '确定要删除这些号码吗?',
|
||||||
'Please fill at least one number' => '请至少填写一个有效号码',
|
'Please fill at least one number' => '请至少填写一个有效号码',
|
||||||
'All numbers already exist for this link' => '该链接下号码均已存在,未新增任何记录',
|
'All numbers already exist for this link' => '该链接下号码均已存在,未新增任何记录',
|
||||||
'Number already exists for this link' => '该链接下已存在相同号码',
|
'Number already exists for this link' => '该链接下已存在相同号码',
|
||||||
|
|||||||
Regular → Executable
+3
@@ -38,6 +38,8 @@ return [
|
|||||||
'Sync display pending' => '待同步',
|
'Sync display pending' => '待同步',
|
||||||
'Sync display error' => '同步异常',
|
'Sync display error' => '同步异常',
|
||||||
'Createtime' => '创建时间',
|
'Createtime' => '创建时间',
|
||||||
|
'Copy' => '拷贝',
|
||||||
|
'Summary row' => '汇总',
|
||||||
'Section basic' => '基础信息',
|
'Section basic' => '基础信息',
|
||||||
'Section time rule' => '时间与规则',
|
'Section time rule' => '时间与规则',
|
||||||
'Section account' => '账号信息',
|
'Section account' => '账号信息',
|
||||||
@@ -51,6 +53,7 @@ return [
|
|||||||
'Ticket type yifafa' => '译发发云控',
|
'Ticket type yifafa' => '译发发云控',
|
||||||
'Ticket type a2c' => 'A2C云控',
|
'Ticket type a2c' => 'A2C云控',
|
||||||
'Ticket type ceo_scrm' => 'CEO SCRM',
|
'Ticket type ceo_scrm' => 'CEO SCRM',
|
||||||
|
'Ticket type chatknow' => 'Chatknow SCRM',
|
||||||
'Ticket type whatshub' => 'Whatshub云控',
|
'Ticket type whatshub' => 'Whatshub云控',
|
||||||
'Ticket type sihai' => '四海云控',
|
'Ticket type sihai' => '四海云控',
|
||||||
'End time must after start' => '到期时间必须晚于开始时间',
|
'End time must after start' => '到期时间必须晚于开始时间',
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
+1
-1
@@ -98,7 +98,7 @@ class Number extends Model
|
|||||||
*/
|
*/
|
||||||
public function splitLink()
|
public function splitLink()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(0);
|
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setNumberTypeCustomAttr($value): string
|
public function setNumberTypeCustomAttr($value): string
|
||||||
|
|||||||
Regular → Executable
+1
@@ -50,6 +50,7 @@ class Ticket extends Model
|
|||||||
'yifafa' => __('Ticket type yifafa'),
|
'yifafa' => __('Ticket type yifafa'),
|
||||||
'a2c' => __('Ticket type a2c'),
|
'a2c' => __('Ticket type a2c'),
|
||||||
'ceo_scrm' => __('Ticket type ceo_scrm'),
|
'ceo_scrm' => __('Ticket type ceo_scrm'),
|
||||||
|
'chatknow' => __('Ticket type chatknow'),
|
||||||
'whatshub' => __('Ticket type whatshub'),
|
'whatshub' => __('Ticket type whatshub'),
|
||||||
'sihai' => __('Ticket type sihai'),
|
'sihai' => __('Ticket type sihai'),
|
||||||
];
|
];
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
@@ -18,6 +18,7 @@
|
|||||||
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||||
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||||
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
|
||||||
</div>
|
</div>
|
||||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
data-operate-edit="{:$auth->check('split.number/edit')}"
|
data-operate-edit="{:$auth->check('split.number/edit')}"
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+51
@@ -17,12 +17,56 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
animation: split-ticket-sync-dots 1.4s steps(4, end) infinite;
|
animation: split-ticket-sync-dots 1.4s steps(4, end) infinite;
|
||||||
}
|
}
|
||||||
|
.split-ticket-url-group {
|
||||||
|
width: 200px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.split-ticket-url-group .form-control {
|
||||||
|
cursor: default;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #fff;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
.split-ticket-url-group .btn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
@keyframes split-ticket-sync-dots {
|
@keyframes split-ticket-sync-dots {
|
||||||
0%, 20% { content: ''; }
|
0%, 20% { content: ''; }
|
||||||
40% { content: '.'; }
|
40% { content: '.'; }
|
||||||
60% { content: '..'; }
|
60% { content: '..'; }
|
||||||
80%, 100% { content: '...'; }
|
80%, 100% { content: '...'; }
|
||||||
}
|
}
|
||||||
|
.split-ticket-summary-bar {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
|
||||||
|
border: 1px solid #93c5fd;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.split-ticket-summary-bar .split-ticket-summary-label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e40af;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.split-ticket-summary-bar .split-ticket-summary-item em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #475569;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.split-ticket-summary-bar .split-ticket-summary-item b {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="panel panel-default panel-intro">
|
<div class="panel panel-default panel-intro">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
@@ -39,6 +83,13 @@
|
|||||||
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.ticket/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.ticket/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||||
<a href="javascript:;" class="btn btn-info btn-sync btn-disabled disabled {:$auth->check('split.ticket/sync')?'':'hide'}" title="{:__('Sync_status_btn')}"><i class="fa fa-refresh"></i> {:__('Sync_status_btn')}</a>
|
<a href="javascript:;" class="btn btn-info btn-sync btn-disabled disabled {:$auth->check('split.ticket/sync')?'':'hide'}" title="{:__('Sync_status_btn')}"><i class="fa fa-refresh"></i> {:__('Sync_status_btn')}</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="split-ticket-summary" class="split-ticket-summary-bar hide">
|
||||||
|
<span class="split-ticket-summary-label">{:__('Summary row')}</span>
|
||||||
|
<span class="split-ticket-summary-item"><em>{:__('Ticket_total')}</em><b data-sum="ticket_total">0</b></span>
|
||||||
|
<span class="split-ticket-summary-item"><em>{:__('Complete_count')}</em><b data-sum="complete_count">0</b></span>
|
||||||
|
<span class="split-ticket-summary-item"><em>{:__('Ticket_progress')}</em><b data-sum="ticket_progress_text">0.00%</b></span>
|
||||||
|
<span class="split-ticket-summary-item"><em>{:__('Speed_per_hour')}</em><b data-sum="speed_per_hour">0.00</b></span>
|
||||||
|
</div>
|
||||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
data-operate-edit="{:$auth->check('split.ticket/edit')}"
|
data-operate-edit="{:$auth->check('split.ticket/edit')}"
|
||||||
data-operate-del="{:$auth->check('split.ticket/del')}"
|
data-operate-del="{:$auth->check('split.ticket/del')}"
|
||||||
|
|||||||
Regular → Executable
+244
-52
@@ -15,63 +15,255 @@ class CountryIso
|
|||||||
* @var array<string, string>
|
* @var array<string, string>
|
||||||
*/
|
*/
|
||||||
private const COUNTRIES = [
|
private const COUNTRIES = [
|
||||||
'CN' => '中国',
|
'AD' => '安道尔',
|
||||||
'HK' => '中国香港',
|
'AE' => '阿拉伯联合酋长国',
|
||||||
'MO' => '中国澳门',
|
'AF' => '阿富汗',
|
||||||
'TW' => '中国台湾',
|
'AG' => '安提瓜和巴布达',
|
||||||
'US' => '美国',
|
'AI' => '安圭拉',
|
||||||
'CA' => '加拿大',
|
'AL' => '阿尔巴尼亚',
|
||||||
'GB' => '英国',
|
'AM' => '亚美尼亚',
|
||||||
'DE' => '德国',
|
'AO' => '安哥拉',
|
||||||
'FR' => '法国',
|
'AQ' => '南极洲',
|
||||||
'IT' => '意大利',
|
|
||||||
'ES' => '西班牙',
|
|
||||||
'NL' => '荷兰',
|
|
||||||
'BE' => '比利时',
|
|
||||||
'CH' => '瑞士',
|
|
||||||
'AT' => '奥地利',
|
|
||||||
'SE' => '瑞典',
|
|
||||||
'NO' => '挪威',
|
|
||||||
'DK' => '丹麦',
|
|
||||||
'FI' => '芬兰',
|
|
||||||
'IE' => '爱尔兰',
|
|
||||||
'PT' => '葡萄牙',
|
|
||||||
'PL' => '波兰',
|
|
||||||
'CZ' => '捷克',
|
|
||||||
'HU' => '匈牙利',
|
|
||||||
'RO' => '罗马尼亚',
|
|
||||||
'GR' => '希腊',
|
|
||||||
'RU' => '俄罗斯',
|
|
||||||
'UA' => '乌克兰',
|
|
||||||
'TR' => '土耳其',
|
|
||||||
'IL' => '以色列',
|
|
||||||
'SA' => '沙特阿拉伯',
|
|
||||||
'AE' => '阿联酋',
|
|
||||||
'QA' => '卡塔尔',
|
|
||||||
'KW' => '科威特',
|
|
||||||
'IN' => '印度',
|
|
||||||
'PK' => '巴基斯坦',
|
|
||||||
'BD' => '孟加拉国',
|
|
||||||
'TH' => '泰国',
|
|
||||||
'VN' => '越南',
|
|
||||||
'MY' => '马来西亚',
|
|
||||||
'SG' => '新加坡',
|
|
||||||
'ID' => '印度尼西亚',
|
|
||||||
'PH' => '菲律宾',
|
|
||||||
'JP' => '日本',
|
|
||||||
'KR' => '韩国',
|
|
||||||
'AU' => '澳大利亚',
|
|
||||||
'NZ' => '新西兰',
|
|
||||||
'BR' => '巴西',
|
|
||||||
'MX' => '墨西哥',
|
|
||||||
'AR' => '阿根廷',
|
'AR' => '阿根廷',
|
||||||
|
'AS' => '美属萨摩亚',
|
||||||
|
'AT' => '奥地利',
|
||||||
|
'AU' => '澳大利亚',
|
||||||
|
'AW' => '阿鲁巴',
|
||||||
|
'AX' => '奥兰群岛',
|
||||||
|
'AZ' => '阿塞拜疆',
|
||||||
|
'BA' => '波斯尼亚和黑塞哥维那',
|
||||||
|
'BB' => '巴巴多斯',
|
||||||
|
'BD' => '孟加拉国',
|
||||||
|
'BE' => '比利时',
|
||||||
|
'BF' => '布基纳法索',
|
||||||
|
'BG' => '保加利亚',
|
||||||
|
'BH' => '巴林',
|
||||||
|
'BI' => '布隆迪',
|
||||||
|
'BJ' => '贝宁',
|
||||||
|
'BL' => '圣巴泰勒米',
|
||||||
|
'BM' => '百慕大',
|
||||||
|
'BN' => '文莱',
|
||||||
|
'BO' => '玻利维亚',
|
||||||
|
'BQ' => '荷兰加勒比区',
|
||||||
|
'BR' => '巴西',
|
||||||
|
'BS' => '巴哈马',
|
||||||
|
'BT' => '不丹',
|
||||||
|
'BV' => '布韦岛',
|
||||||
|
'BW' => '博茨瓦纳',
|
||||||
|
'BY' => '白俄罗斯',
|
||||||
|
'BZ' => '伯利兹',
|
||||||
|
'CA' => '加拿大',
|
||||||
|
'CC' => '科科斯(基林)群岛',
|
||||||
|
'CD' => '刚果(金)',
|
||||||
|
'CF' => '中非共和国',
|
||||||
|
'CG' => '刚果(布)',
|
||||||
|
'CH' => '瑞士',
|
||||||
|
'CI' => '科特迪瓦',
|
||||||
|
'CK' => '库克群岛',
|
||||||
'CL' => '智利',
|
'CL' => '智利',
|
||||||
|
'CM' => '喀麦隆',
|
||||||
|
'CN' => '中国',
|
||||||
'CO' => '哥伦比亚',
|
'CO' => '哥伦比亚',
|
||||||
'PE' => '秘鲁',
|
'CR' => '哥斯达黎加',
|
||||||
'ZA' => '南非',
|
'CU' => '古巴',
|
||||||
|
'CV' => '佛得角',
|
||||||
|
'CW' => '库拉索',
|
||||||
|
'CX' => '圣诞岛',
|
||||||
|
'CY' => '塞浦路斯',
|
||||||
|
'CZ' => '捷克',
|
||||||
|
'DE' => '德国',
|
||||||
|
'DJ' => '吉布提',
|
||||||
|
'DK' => '丹麦',
|
||||||
|
'DM' => '多米尼克',
|
||||||
|
'DO' => '多米尼加共和国',
|
||||||
|
'DZ' => '阿尔及利亚',
|
||||||
|
'EC' => '厄瓜多尔',
|
||||||
|
'EE' => '爱沙尼亚',
|
||||||
'EG' => '埃及',
|
'EG' => '埃及',
|
||||||
'NG' => '尼日利亚',
|
'EH' => '西撒哈拉',
|
||||||
|
'ER' => '厄立特里亚',
|
||||||
|
'ES' => '西班牙',
|
||||||
|
'ET' => '埃塞俄比亚',
|
||||||
|
'FI' => '芬兰',
|
||||||
|
'FJ' => '斐济',
|
||||||
|
'FK' => '福克兰群岛(马尔维纳斯)',
|
||||||
|
'FM' => '密克罗尼西亚联邦',
|
||||||
|
'FO' => '法罗群岛',
|
||||||
|
'FR' => '法国',
|
||||||
|
'GA' => '加蓬',
|
||||||
|
'GB' => '英国',
|
||||||
|
'GD' => '格林纳达',
|
||||||
|
'GE' => '格鲁吉亚',
|
||||||
|
'GF' => '法属圭亚那',
|
||||||
|
'GG' => '根西岛',
|
||||||
|
'GH' => '加纳',
|
||||||
|
'GI' => '直布罗陀',
|
||||||
|
'GL' => '格陵兰',
|
||||||
|
'GM' => '冈比亚',
|
||||||
|
'GN' => '几内亚',
|
||||||
|
'GP' => '瓜德罗普',
|
||||||
|
'GQ' => '赤道几内亚',
|
||||||
|
'GR' => '希腊',
|
||||||
|
'GS' => '南乔治亚和南桑威奇群岛',
|
||||||
|
'GT' => '危地马拉',
|
||||||
|
'GU' => '关岛',
|
||||||
|
'GW' => '几内亚比绍',
|
||||||
|
'GY' => '圭亚那',
|
||||||
|
'HK' => '香港',
|
||||||
|
'HM' => '赫德岛和麦克唐纳群岛',
|
||||||
|
'HN' => '洪都拉斯',
|
||||||
|
'HR' => '克罗地亚',
|
||||||
|
'HT' => '海地',
|
||||||
|
'HU' => '匈牙利',
|
||||||
|
'ID' => '印度尼西亚',
|
||||||
|
'IE' => '爱尔兰',
|
||||||
|
'IL' => '以色列',
|
||||||
|
'IM' => '马恩岛',
|
||||||
|
'IN' => '印度',
|
||||||
|
'IO' => '英属印度洋领地',
|
||||||
|
'IQ' => '伊拉克',
|
||||||
|
'IR' => '伊朗',
|
||||||
|
'IS' => '冰岛',
|
||||||
|
'IT' => '意大利',
|
||||||
|
'JE' => '泽西岛',
|
||||||
|
'JM' => '牙买加',
|
||||||
|
'JO' => '约旦',
|
||||||
|
'JP' => '日本',
|
||||||
'KE' => '肯尼亚',
|
'KE' => '肯尼亚',
|
||||||
|
'KG' => '吉尔吉斯斯坦',
|
||||||
|
'KH' => '柬埔寨',
|
||||||
|
'KI' => '基里巴斯',
|
||||||
|
'KM' => '科摩罗',
|
||||||
|
'KN' => '圣基茨和尼维斯',
|
||||||
|
'KP' => '朝鲜',
|
||||||
|
'KR' => '韩国',
|
||||||
|
'KW' => '科威特',
|
||||||
|
'KY' => '开曼群岛',
|
||||||
|
'KZ' => '哈萨克斯坦',
|
||||||
|
'LA' => '老挝',
|
||||||
|
'LB' => '黎巴嫩',
|
||||||
|
'LC' => '圣卢西亚',
|
||||||
|
'LI' => '列支敦士登',
|
||||||
|
'LK' => '斯里兰卡',
|
||||||
|
'LR' => '利比里亚',
|
||||||
|
'LS' => '莱索托',
|
||||||
|
'LT' => '立陶宛',
|
||||||
|
'LU' => '卢森堡',
|
||||||
|
'LV' => '拉脱维亚',
|
||||||
|
'LY' => '利比亚',
|
||||||
|
'MA' => '摩洛哥',
|
||||||
|
'MC' => '摩纳哥',
|
||||||
|
'MD' => '摩尔多瓦',
|
||||||
|
'ME' => '黑山',
|
||||||
|
'MF' => '法属圣马丁',
|
||||||
|
'MG' => '马达加斯加',
|
||||||
|
'MH' => '马绍尔群岛',
|
||||||
|
'MK' => '北马其顿',
|
||||||
|
'ML' => '马里',
|
||||||
|
'MM' => '缅甸',
|
||||||
|
'MN' => '蒙古',
|
||||||
|
'MO' => '澳门',
|
||||||
|
'MP' => '北马里亚纳群岛',
|
||||||
|
'MQ' => '马提尼克',
|
||||||
|
'MR' => '毛里塔尼亚',
|
||||||
|
'MS' => '蒙特塞拉特',
|
||||||
|
'MT' => '马耳他',
|
||||||
|
'MU' => '毛里求斯',
|
||||||
|
'MV' => '马尔代夫',
|
||||||
|
'MW' => '马拉维',
|
||||||
|
'MX' => '墨西哥',
|
||||||
|
'MY' => '马来西亚',
|
||||||
|
'MZ' => '莫桑比克',
|
||||||
|
'NA' => '纳米比亚',
|
||||||
|
'NC' => '新喀里多尼亚',
|
||||||
|
'NE' => '尼日尔',
|
||||||
|
'NF' => '诺福克岛',
|
||||||
|
'NG' => '尼日利亚',
|
||||||
|
'NI' => '尼加拉瓜',
|
||||||
|
'NL' => '荷兰',
|
||||||
|
'NO' => '挪威',
|
||||||
|
'NP' => '尼泊尔',
|
||||||
|
'NR' => '瑙鲁',
|
||||||
|
'NU' => '纽埃',
|
||||||
|
'NZ' => '新西兰',
|
||||||
|
'OM' => '阿曼',
|
||||||
|
'PA' => '巴拿马',
|
||||||
|
'PE' => '秘鲁',
|
||||||
|
'PF' => '法属波利尼西亚',
|
||||||
|
'PG' => '巴布亚新几内亚',
|
||||||
|
'PH' => '菲律宾',
|
||||||
|
'PK' => '巴基斯坦',
|
||||||
|
'PL' => '波兰',
|
||||||
|
'PM' => '圣皮埃尔和密克隆',
|
||||||
|
'PN' => '皮特凯恩群岛',
|
||||||
|
'PR' => '波多黎各',
|
||||||
|
'PS' => '巴勒斯坦',
|
||||||
|
'PT' => '葡萄牙',
|
||||||
|
'PW' => '帕劳',
|
||||||
|
'PY' => '巴拉圭',
|
||||||
|
'QA' => '卡塔尔',
|
||||||
|
'RE' => '留尼汪',
|
||||||
|
'RO' => '罗马尼亚',
|
||||||
|
'RS' => '塞尔维亚',
|
||||||
|
'RU' => '俄罗斯',
|
||||||
|
'RW' => '卢旺达',
|
||||||
|
'SA' => '沙特阿拉伯',
|
||||||
|
'SB' => '所罗门群岛',
|
||||||
|
'SC' => '塞舌尔',
|
||||||
|
'SD' => '苏丹',
|
||||||
|
'SE' => '瑞典',
|
||||||
|
'SG' => '新加坡',
|
||||||
|
'SH' => '圣赫勒拿、阿森松和特里斯坦-达库尼亚',
|
||||||
|
'SI' => '斯洛文尼亚',
|
||||||
|
'SJ' => '斯瓦尔巴和扬马延',
|
||||||
|
'SK' => '斯洛伐克',
|
||||||
|
'SL' => '塞拉利昂',
|
||||||
|
'SM' => '圣马力诺',
|
||||||
|
'SN' => '塞内加尔',
|
||||||
|
'SO' => '索马里',
|
||||||
|
'SR' => '苏里南',
|
||||||
|
'SS' => '南苏丹',
|
||||||
|
'ST' => '圣多美和普林西比',
|
||||||
|
'SV' => '萨尔瓦多',
|
||||||
|
'SX' => '荷属圣马丁',
|
||||||
|
'SY' => '叙利亚',
|
||||||
|
'SZ' => '斯威士兰(斯瓦帝尼)',
|
||||||
|
'TC' => '特克斯和凯科斯群岛',
|
||||||
|
'TD' => '乍得',
|
||||||
|
'TF' => '法属南部领地',
|
||||||
|
'TG' => '多哥',
|
||||||
|
'TH' => '泰国',
|
||||||
|
'TJ' => '塔吉克斯坦',
|
||||||
|
'TK' => '托克劳',
|
||||||
|
'TL' => '东帝汶',
|
||||||
|
'TM' => '土库曼斯坦',
|
||||||
|
'TN' => '突尼斯',
|
||||||
|
'TO' => '汤加',
|
||||||
|
'TR' => '土耳其',
|
||||||
|
'TT' => '特立尼达和多巴哥',
|
||||||
|
'TV' => '图瓦卢',
|
||||||
|
'TW' => '台湾',
|
||||||
|
'TZ' => '坦桑尼亚',
|
||||||
|
'UA' => '乌克兰',
|
||||||
|
'UG' => '乌干达',
|
||||||
|
'UM' => '美国本土外小岛屿',
|
||||||
|
'US' => '美国',
|
||||||
|
'UY' => '乌拉圭',
|
||||||
|
'UZ' => '乌兹别克斯坦',
|
||||||
|
'VA' => '梵蒂冈',
|
||||||
|
'VC' => '圣文森特和格林纳丁斯',
|
||||||
|
'VE' => '委内瑞拉',
|
||||||
|
'VG' => '英属维尔京群岛',
|
||||||
|
'VI' => '美属维尔京群岛',
|
||||||
|
'VN' => '越南',
|
||||||
|
'VU' => '瓦努阿图',
|
||||||
|
'WF' => '瓦利斯和富图纳',
|
||||||
|
'WS' => '萨摩亚',
|
||||||
|
'YE' => '也门',
|
||||||
|
'YT' => '马约特',
|
||||||
|
'ZA' => '南非',
|
||||||
|
'ZM' => '赞比亚',
|
||||||
|
'ZW' => '津巴布韦'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Regular → Executable
+107
-5
@@ -67,22 +67,36 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
$apiUrlsToIntercept[] = $countApi;
|
$apiUrlsToIntercept[] = $countApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$antiBot = $config['antiBot'] ?? null;
|
||||||
|
|
||||||
$initResult = $this->requestNode('/api/auth-and-intercept', [
|
$initResult = $this->requestNode('/api/auth-and-intercept', [
|
||||||
'pageUrl' => $config['pageUrl'],
|
'pageUrl' => $config['pageUrl'],
|
||||||
'apiUrls' => $apiUrlsToIntercept,
|
'apiUrls' => $apiUrlsToIntercept,
|
||||||
'authActions' => $config['authActions'] ?? [],
|
'authActions' => $config['authActions'] ?? [],
|
||||||
|
'antiBot' => $antiBot,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (empty($initResult['success'])) {
|
if (empty($initResult['success'])) {
|
||||||
|
$cfCode = (string) ($initResult['code'] ?? '');
|
||||||
SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [
|
SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [
|
||||||
'error' => $initResult['error'] ?? '未知',
|
'error' => $initResult['error'] ?? '未知',
|
||||||
|
'code' => $cfCode !== '' ? $cfCode : null,
|
||||||
|
'challengeType' => $initResult['challengeType'] ?? null,
|
||||||
|
'stage' => $initResult['stage'] ?? null,
|
||||||
|
'cf' => $initResult['cf'] ?? null,
|
||||||
]);
|
]);
|
||||||
throw new Exception('初始化失败: ' . ($initResult['error'] ?? '未知'));
|
$errMsg = (string) ($initResult['error'] ?? '未知');
|
||||||
|
if ($cfCode === 'CF_TURNSTILE_FAILED') {
|
||||||
|
throw new Exception('Cloudflare Turnstile 验证失败: ' . ($initResult['stage'] ?? 'timeout'));
|
||||||
|
}
|
||||||
|
throw new Exception('初始化失败: ' . $errMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
$interceptedApis = $initResult['interceptedApis'];
|
$interceptedApis = $initResult['interceptedApis'];
|
||||||
|
$finalPageUrl = (string) ($initResult['finalPageUrl'] ?? ($config['pageUrl'] ?? ''));
|
||||||
SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [
|
SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [
|
||||||
'intercepted' => array_keys($interceptedApis),
|
'intercepted' => array_keys($interceptedApis),
|
||||||
|
'finalPageUrl' => $finalPageUrl,
|
||||||
]);
|
]);
|
||||||
$cookies = $initResult['cookies'];
|
$cookies = $initResult['cookies'];
|
||||||
|
|
||||||
@@ -121,6 +135,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
'method' => $config['listMethod'] ?? 'GET',
|
'method' => $config['listMethod'] ?? 'GET',
|
||||||
]],
|
]],
|
||||||
'cookies' => $cookies,
|
'cookies' => $cookies,
|
||||||
|
'antiBot' => $antiBot,
|
||||||
], 120);
|
], 120);
|
||||||
|
|
||||||
if (!empty($fetchResult['success'])) {
|
if (!empty($fetchResult['success'])) {
|
||||||
@@ -138,12 +153,14 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
$uiResult = $this->requestNode('/api/ui-pagination', [
|
$uiResult = $this->requestNode('/api/ui-pagination', [
|
||||||
'apiUrl' => $listApi,
|
'apiUrl' => $listApi,
|
||||||
'pageUrl' => $config['pageUrl'],
|
'pageUrl' => $config['pageUrl'],
|
||||||
|
'finalPageUrl' => $finalPageUrl,
|
||||||
'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '',
|
'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '',
|
||||||
'waitMs' => $uiConfig['waitMs'] ?? 2000,
|
'waitMs' => $uiConfig['waitMs'] ?? 2000,
|
||||||
'clicksToPerform' => $clicksToPerform,
|
'clicksToPerform' => $clicksToPerform,
|
||||||
'cookies' => $cookies,
|
'cookies' => $cookies,
|
||||||
'firstPageData' => $firstPageData,
|
'firstPageData' => $firstPageData,
|
||||||
'authActions' => $config['authActions'] ?? [],
|
'authActions' => $config['authActions'] ?? [],
|
||||||
|
'antiBot' => $antiBot,
|
||||||
], 1200);
|
], 1200);
|
||||||
|
|
||||||
if (!empty($uiResult['success']) && !empty($uiResult['data'])) {
|
if (!empty($uiResult['success']) && !empty($uiResult['data'])) {
|
||||||
@@ -170,13 +187,43 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array
|
protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array
|
||||||
|
{
|
||||||
|
$maxAttempts = 3;
|
||||||
|
$lastDecoded = [];
|
||||||
|
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||||
|
$lastDecoded = $this->doRequestNode($endpoint, $payload, $timeout, $attempt);
|
||||||
|
if (!empty($lastDecoded['success'])) {
|
||||||
|
return $lastDecoded;
|
||||||
|
}
|
||||||
|
$error = (string) ($lastDecoded['error'] ?? '');
|
||||||
|
if (!$this->shouldRetryNodeResponse($lastDecoded, $error) || $attempt >= $maxAttempts) {
|
||||||
|
return $lastDecoded;
|
||||||
|
}
|
||||||
|
$delaySeconds = $attempt;
|
||||||
|
SplitTicketSyncLogger::log('node_retry', 'retry ' . $endpoint, [
|
||||||
|
'attempt' => $attempt + 1,
|
||||||
|
'error' => $error,
|
||||||
|
'delay' => $delaySeconds,
|
||||||
|
]);
|
||||||
|
sleep($delaySeconds);
|
||||||
|
}
|
||||||
|
return $lastDecoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function doRequestNode(string $endpoint, array $payload, int $timeout, int $attempt): array
|
||||||
{
|
{
|
||||||
$url = $this->nodeHost . $endpoint;
|
$url = $this->nodeHost . $endpoint;
|
||||||
$started = microtime(true);
|
$started = microtime(true);
|
||||||
|
$logPayload = SplitTicketSyncLogger::isEnabled() ? $payload : self::summarizeNodePayload($payload);
|
||||||
SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [
|
SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [
|
||||||
'url' => $url,
|
'url' => $url,
|
||||||
'timeout' => $timeout,
|
'timeout' => $timeout,
|
||||||
'payload' => $payload,
|
'attempt' => $attempt,
|
||||||
|
'payload' => $logPayload,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
@@ -184,6 +231,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
curl_setopt($ch, CURLOPT_POST, true);
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||||
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
@@ -194,6 +242,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [
|
SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [
|
||||||
'httpCode' => $httpCode,
|
'httpCode' => $httpCode,
|
||||||
'elapsedMs' => $elapsedMs,
|
'elapsedMs' => $elapsedMs,
|
||||||
|
'attempt' => $attempt,
|
||||||
'error' => $err,
|
'error' => $err,
|
||||||
]);
|
]);
|
||||||
throw new Exception($err);
|
throw new Exception($err);
|
||||||
@@ -204,9 +253,62 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([
|
SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([
|
||||||
'httpCode' => $httpCode,
|
'httpCode' => $httpCode,
|
||||||
'elapsedMs' => $elapsedMs,
|
'elapsedMs' => $elapsedMs,
|
||||||
|
'attempt' => $attempt,
|
||||||
'responseSize' => strlen((string) $response),
|
'responseSize' => strlen((string) $response),
|
||||||
], $summary));
|
], $summary));
|
||||||
return is_array($decoded) ? $decoded : [];
|
if (!is_array($decoded)) {
|
||||||
|
return ['success' => false, 'error' => 'Node 返回非 JSON', 'httpCode' => $httpCode];
|
||||||
|
}
|
||||||
|
if ($httpCode === 503) {
|
||||||
|
$decoded['success'] = false;
|
||||||
|
$decoded['error'] = (string) ($decoded['error'] ?? '服务繁忙');
|
||||||
|
$decoded['httpCode'] = 503;
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $decoded
|
||||||
|
*/
|
||||||
|
private function shouldRetryNodeResponse(array $decoded, string $error): bool
|
||||||
|
{
|
||||||
|
if ((int) ($decoded['httpCode'] ?? 0) === 503) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$lower = mb_strtolower($error, 'UTF-8');
|
||||||
|
foreach (['排队超时', 'queue_timeout', 'timeout', 'net::', 'navigation', 'connection'] as $needle) {
|
||||||
|
if ($needle !== '' && mb_strpos($lower, mb_strtolower($needle, 'UTF-8')) !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function summarizeNodePayload(array $payload): array
|
||||||
|
{
|
||||||
|
$summary = $payload;
|
||||||
|
if (isset($summary['tasks']) && is_array($summary['tasks'])) {
|
||||||
|
$summary['tasks'] = array_map(static function ($task) {
|
||||||
|
if (!is_array($task)) {
|
||||||
|
return $task;
|
||||||
|
}
|
||||||
|
$copy = $task;
|
||||||
|
if (isset($copy['paramList']) && is_array($copy['paramList'])) {
|
||||||
|
$copy['paramList_count'] = count($copy['paramList']);
|
||||||
|
unset($copy['paramList']);
|
||||||
|
}
|
||||||
|
return $copy;
|
||||||
|
}, $summary['tasks']);
|
||||||
|
}
|
||||||
|
if (isset($summary['cookies']) && is_array($summary['cookies'])) {
|
||||||
|
$summary['cookies_count'] = count($summary['cookies']);
|
||||||
|
unset($summary['cookies']);
|
||||||
|
}
|
||||||
|
return $summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library\scrm\spider;
|
||||||
|
|
||||||
|
use app\common\library\scrm\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chatknow SCRM 云控蜘蛛(UI 翻页 + 列表 API 拦截)
|
||||||
|
*/
|
||||||
|
class ChatknowSpider extends AbstractScrmSpider
|
||||||
|
{
|
||||||
|
private const API_LIST = '/user/user/UserInfoChildChannel/list';
|
||||||
|
|
||||||
|
private const DEFAULT_PER_PAGE_COUNT = 10;
|
||||||
|
|
||||||
|
private string $pageUrl;
|
||||||
|
|
||||||
|
private string $account;
|
||||||
|
|
||||||
|
private string $password;
|
||||||
|
|
||||||
|
private UnifiedScrmData $unifiedData;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $pageUrl,
|
||||||
|
string $account = '',
|
||||||
|
string $password = '',
|
||||||
|
string $nodeHost = 'http://127.0.0.1:3001'
|
||||||
|
) {
|
||||||
|
parent::__construct($nodeHost);
|
||||||
|
$this->pageUrl = $pageUrl;
|
||||||
|
$this->account = $account;
|
||||||
|
$this->password = $password;
|
||||||
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
protected function getSpiderConfig(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'listApi' => self::API_LIST,
|
||||||
|
'listMethod' => 'GET',
|
||||||
|
'paginationMode' => self::MODE_UI,
|
||||||
|
'authActions' => [
|
||||||
|
['type' => 'wait', 'ms' => 4000],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $listFirstPageData
|
||||||
|
* @param array<string, mixed>|null $countData
|
||||||
|
*/
|
||||||
|
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||||
|
{
|
||||||
|
$total = (int) ($listFirstPageData['total'] ?? 0);
|
||||||
|
$this->unifiedData->total = $total;
|
||||||
|
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['data']['today_num'] ?? 0);
|
||||||
|
|
||||||
|
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
protected function buildListPageParams(int $page): array
|
||||||
|
{
|
||||||
|
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
protected function getUiPaginationConfig(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nextBtnSelector' => '.tabs-content .arco-pagination-item-next',
|
||||||
|
'waitMs' => 2000,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||||
|
{
|
||||||
|
$unifiedData = $this->unifiedData;
|
||||||
|
|
||||||
|
foreach ($allListPagesData as $pageRaw) {
|
||||||
|
if (!is_array($pageRaw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$records = $pageRaw['rows'] ?? [];
|
||||||
|
if (!is_array($records)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($records as $item) {
|
||||||
|
if (!is_array($item) || empty($item['username'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$number = (string) $item['username'];
|
||||||
|
$isOnline = isset($item['state']) && (int) $item['state'] === 1;
|
||||||
|
$unifiedData->addNumber($number, $isOnline, (int) ($item['today_num'] ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $unifiedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library\scrm\spider;
|
||||||
|
|
||||||
|
use app\common\library\scrm\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whatshub 云控蜘蛛(Real Browser + Turnstile 过盾 + UI 翻页)
|
||||||
|
*
|
||||||
|
* 时序:Cloudflare Turnstile → 密码弹窗 authActions → API 拦截 → UI 翻页
|
||||||
|
*/
|
||||||
|
class WhatshubSpider extends AbstractScrmSpider
|
||||||
|
{
|
||||||
|
/** 列表 API 路径 */
|
||||||
|
private const API_LIST = '/api/whatshub-counter/workShare/open/detail';
|
||||||
|
|
||||||
|
/** 详情/统计 API 路径 */
|
||||||
|
private const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics';
|
||||||
|
|
||||||
|
/** 默认每页条数 */
|
||||||
|
private const DEFAULT_PER_PAGE_COUNT = 20;
|
||||||
|
|
||||||
|
private string $pageUrl;
|
||||||
|
|
||||||
|
private string $account;
|
||||||
|
|
||||||
|
private string $password;
|
||||||
|
|
||||||
|
private UnifiedScrmData $unifiedData;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $pageUrl,
|
||||||
|
string $account = '',
|
||||||
|
string $password = '',
|
||||||
|
string $nodeHost = 'http://127.0.0.1:3001'
|
||||||
|
) {
|
||||||
|
parent::__construct($nodeHost);
|
||||||
|
$this->pageUrl = $pageUrl;
|
||||||
|
$this->account = $account;
|
||||||
|
$this->password = $password;
|
||||||
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
protected function getSpiderConfig(): array
|
||||||
|
{
|
||||||
|
$host = (string) parse_url($this->pageUrl, PHP_URL_HOST);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'listApi' => self::API_LIST,
|
||||||
|
'detailApi' => self::API_DETAILS,
|
||||||
|
'listMethod' => 'POST',
|
||||||
|
'paginationMode' => self::MODE_UI,
|
||||||
|
'authActions' => [
|
||||||
|
// Element Plus 密码弹窗:仅注入可见 input
|
||||||
|
['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password],
|
||||||
|
['type' => 'wait', 'ms' => 500],
|
||||||
|
['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'],
|
||||||
|
['type' => 'wait', 'ms' => 2200],
|
||||||
|
],
|
||||||
|
// Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用
|
||||||
|
'antiBot' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'profile' => 'real',
|
||||||
|
'turnstile' => true,
|
||||||
|
'solverFallback' => true,
|
||||||
|
'sessionKey' => 'whatshub:' . $host,
|
||||||
|
'challengeTimeoutMs' => 60000,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $listFirstPageData
|
||||||
|
* @param array<string, mixed>|null $countData
|
||||||
|
*/
|
||||||
|
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||||
|
{
|
||||||
|
$defaultPerPage = self::DEFAULT_PER_PAGE_COUNT;
|
||||||
|
$this->unifiedData->total = (int) ($listFirstPageData['data']['total'] ?? 0);
|
||||||
|
|
||||||
|
if ($this->unifiedData->total <= $defaultPerPage) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ceil($this->unifiedData->total / $defaultPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
protected function buildListPageParams(int $page): array
|
||||||
|
{
|
||||||
|
return ['pageNum' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
protected function getUiPaginationConfig(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nextBtnSelector' => '.vxe-pager--next-btn',
|
||||||
|
'waitMs' => 1500,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||||
|
{
|
||||||
|
$unifiedData = $this->unifiedData;
|
||||||
|
|
||||||
|
if (is_array($detailData)) {
|
||||||
|
$unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($allListPagesData as $pageRaw) {
|
||||||
|
if (!is_array($pageRaw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$records = $pageRaw['data']['rows'] ?? [];
|
||||||
|
if (!is_array($records)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($records as $item) {
|
||||||
|
if (!is_array($item) || empty($item['account'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$number = (string) $item['account'];
|
||||||
|
$isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1;
|
||||||
|
$unifiedData->addNumber($number, $isOnline, (int) ($item['dayNewFans'] ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$unifiedData->total = count($unifiedData->numbers);
|
||||||
|
|
||||||
|
return $unifiedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+188
-37
@@ -4,83 +4,152 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\common\library\scrm\spider;
|
namespace app\common\library\scrm\spider;
|
||||||
|
|
||||||
use app\common\library\scrm\AbstractScrmSpider;
|
use app\common\library\scrm\ScrmSpiderInterface;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 星河云控蜘蛛
|
* 星河云控蜘蛛(纯 PHP cURL 抓取,不依赖 Node Puppeteer)
|
||||||
|
*
|
||||||
|
* 流程:访问带 token 的授权页建立 Cookie 会话 → 分页请求列表 API → 清洗为 UnifiedScrmData
|
||||||
*/
|
*/
|
||||||
class XingheSpider extends AbstractScrmSpider
|
class XingheSpider implements ScrmSpiderInterface
|
||||||
{
|
{
|
||||||
private const API_LIST = '/share/share/api_yinliu_count.html';
|
private const API_PATH = '/share/share/api_yinliu_count.html';
|
||||||
|
|
||||||
private const DEFAULT_PER_PAGE_COUNT = 10;
|
private const DEFAULT_PER_PAGE_COUNT = 10;
|
||||||
|
|
||||||
|
private const CURL_TIMEOUT_SECONDS = 15;
|
||||||
|
|
||||||
|
/** @var resource|\CurlHandle|null */
|
||||||
|
private $ch = null;
|
||||||
|
|
||||||
|
/** @var string|null 临时 Cookie 文件路径,仅在 run() 成功后赋值 */
|
||||||
|
private ?string $cookieFile = null;
|
||||||
|
|
||||||
private string $pageUrl;
|
private string $pageUrl;
|
||||||
|
|
||||||
private string $account;
|
private string $account;
|
||||||
|
|
||||||
private string $password;
|
private string $password;
|
||||||
|
|
||||||
|
private string $baseUrl;
|
||||||
|
|
||||||
private UnifiedScrmData $unifiedData;
|
private UnifiedScrmData $unifiedData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $pageUrl 带 token 的授权页地址
|
||||||
|
* @param string $account 账号(星河云控当前未使用,保留与工厂签名一致)
|
||||||
|
* @param string $password 密码(星河云控当前未使用,保留与工厂签名一致)
|
||||||
|
* @param string $nodeHost Node 地址(星河云控不使用,忽略)
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $pageUrl,
|
string $pageUrl,
|
||||||
string $account = '',
|
string $account = '',
|
||||||
string $password = '',
|
string $password = '',
|
||||||
string $nodeHost = 'http://127.0.0.1:3001'
|
string $nodeHost = ''
|
||||||
) {
|
) {
|
||||||
parent::__construct($nodeHost);
|
unset($nodeHost);
|
||||||
|
|
||||||
$this->pageUrl = $pageUrl;
|
$this->pageUrl = $pageUrl;
|
||||||
$this->account = $account;
|
$this->account = $account;
|
||||||
$this->password = $password;
|
$this->password = $password;
|
||||||
|
$this->baseUrl = $this->resolveBaseUrl($pageUrl);
|
||||||
$this->unifiedData = new UnifiedScrmData();
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getSpiderConfig(): array
|
/**
|
||||||
|
* 执行抓取并返回统一数据
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
public function run(): UnifiedScrmData
|
||||||
{
|
{
|
||||||
return [
|
$cookieFile = tempnam(sys_get_temp_dir(), 'xinghe_spider_cookie_');
|
||||||
'pageUrl' => $this->pageUrl,
|
if ($cookieFile === false) {
|
||||||
'listApi' => self::API_LIST,
|
throw new RuntimeException('无法在系统临时目录创建 Cookie 文件');
|
||||||
'listMethod' => 'GET',
|
}
|
||||||
'paginationMode' => self::MODE_FETCH,
|
|
||||||
'authActions' => [
|
$this->cookieFile = $cookieFile;
|
||||||
['type' => 'wait', 'ms' => 2000],
|
$this->ch = curl_init();
|
||||||
],
|
if ($this->ch === false) {
|
||||||
];
|
throw new RuntimeException('cURL 初始化失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
try {
|
||||||
{
|
curl_setopt_array($this->ch, [
|
||||||
$total = (int) ($listFirstPageData['count'] ?? 0);
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
$this->unifiedData->total = $total;
|
CURLOPT_HEADER => false,
|
||||||
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0);
|
CURLOPT_TIMEOUT => self::CURL_TIMEOUT_SECONDS,
|
||||||
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
|
||||||
return 1;
|
CURLOPT_COOKIEJAR => $this->cookieFile,
|
||||||
|
CURLOPT_COOKIEFILE => $this->cookieFile,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->authenticate();
|
||||||
|
|
||||||
|
$firstPageData = $this->fetchApiData($this->buildApiUrl(1));
|
||||||
|
$this->unifiedData->total = (int) ($firstPageData['count'] ?? 0);
|
||||||
|
$this->unifiedData->todayNewCount = (int) ($firstPageData['totalRow']['day_sum'] ?? 0);
|
||||||
|
|
||||||
|
$totalPages = (int) ceil($this->unifiedData->total / self::DEFAULT_PER_PAGE_COUNT);
|
||||||
|
$allListPagesData = [$firstPageData['data'] ?? []];
|
||||||
|
|
||||||
|
for ($page = 2; $page <= $totalPages; $page++) {
|
||||||
|
$pageData = $this->fetchApiData($this->buildApiUrl($page));
|
||||||
|
$allListPagesData[] = $pageData['data'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parseToUnifiedData($allListPagesData);
|
||||||
|
} finally {
|
||||||
|
$this->cleanup();
|
||||||
}
|
}
|
||||||
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function buildListPageParams(int $page): array
|
/**
|
||||||
|
* 访问授权页面,建立会话 Cookie
|
||||||
|
*/
|
||||||
|
private function authenticate(): void
|
||||||
{
|
{
|
||||||
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
|
$this->sendRequest($this->pageUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getUiPaginationConfig(): array
|
/**
|
||||||
|
* 请求 API 并解析 JSON
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function fetchApiData(string $apiPath): array
|
||||||
{
|
{
|
||||||
return [
|
$response = $this->sendRequest($this->baseUrl . $apiPath);
|
||||||
'nextBtnSelector' => '.layui-laypage-next',
|
$data = json_decode($response, true);
|
||||||
'waitMs' => 2000,
|
|
||||||
];
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'JSON 解析失败: ' . json_last_error_msg() . '。原始响应: ' . mb_substr($response, 0, 500, 'UTF-8')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
throw new RuntimeException('API 返回数据格式无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
/**
|
||||||
|
* @param list<mixed> $allListPagesData
|
||||||
|
*/
|
||||||
|
private function parseToUnifiedData(array $allListPagesData): UnifiedScrmData
|
||||||
{
|
{
|
||||||
$unifiedData = $this->unifiedData;
|
$unifiedData = $this->unifiedData;
|
||||||
foreach ($allListPagesData as $pageRaw) {
|
|
||||||
$records = $pageRaw['data'] ?? [];
|
foreach ($allListPagesData as $records) {
|
||||||
|
if (!is_array($records)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
foreach ($records as $item) {
|
foreach ($records as $item) {
|
||||||
if (empty($item['user'])) {
|
if (!is_array($item) || empty($item['user'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$number = (string) $item['user'];
|
$number = (string) $item['user'];
|
||||||
@@ -88,6 +157,88 @@ class XingheSpider extends AbstractScrmSpider
|
|||||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0));
|
$unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $unifiedData;
|
return $unifiedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 cURL 请求
|
||||||
|
*/
|
||||||
|
private function sendRequest(string $url): string
|
||||||
|
{
|
||||||
|
if ($this->ch === null) {
|
||||||
|
throw new RuntimeException('cURL 句柄未初始化');
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt($this->ch, CURLOPT_URL, $url);
|
||||||
|
$response = curl_exec($this->ch);
|
||||||
|
|
||||||
|
if ($response === false) {
|
||||||
|
throw new RuntimeException(sprintf('请求失败 [%s]: %s', $url, curl_error($this->ch)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$httpCode = (int) curl_getinfo($this->ch, CURLINFO_HTTP_CODE);
|
||||||
|
if ($httpCode >= 400) {
|
||||||
|
throw new RuntimeException(sprintf('HTTP 请求异常 [%s],状态码: %d', $url, $httpCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建分页 API 路径(相对 baseUrl)
|
||||||
|
*/
|
||||||
|
private function buildApiUrl(int $page): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'%s?page=%d&limit=%d&id=&class_id=&is_repet=1&start_time=&end_time=',
|
||||||
|
self::API_PATH,
|
||||||
|
$page,
|
||||||
|
self::DEFAULT_PER_PAGE_COUNT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从授权页 URL 解析站点根地址
|
||||||
|
*/
|
||||||
|
private function resolveBaseUrl(string $pageUrl): string
|
||||||
|
{
|
||||||
|
$parsedUrl = parse_url($pageUrl);
|
||||||
|
if ($parsedUrl === false || empty($parsedUrl['host'])) {
|
||||||
|
throw new RuntimeException('工单链接格式无效,无法解析域名');
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : 'http://';
|
||||||
|
$port = isset($parsedUrl['port']) ? $parsedUrl['port'] : '';
|
||||||
|
if($port !== '' && $port !== 80 && $port !== 443) {
|
||||||
|
$port = ':' . $port;
|
||||||
|
}
|
||||||
|
return $scheme . $parsedUrl['host'] . $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放 cURL 资源并删除临时 Cookie 文件
|
||||||
|
*/
|
||||||
|
private function cleanup(): void
|
||||||
|
{
|
||||||
|
if ($this->ch !== null) {
|
||||||
|
if (is_resource($this->ch) || $this->ch instanceof \CurlHandle) {
|
||||||
|
curl_close($this->ch);
|
||||||
|
}
|
||||||
|
$this->ch = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->cookieFile !== null && $this->cookieFile !== '' && is_file($this->cookieFile)) {
|
||||||
|
@unlink($this->cookieFile);
|
||||||
|
}
|
||||||
|
$this->cookieFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兜底清理:防止 run() 异常退出时资源泄漏
|
||||||
|
*/
|
||||||
|
public function __destruct()
|
||||||
|
{
|
||||||
|
$this->cleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library\scrm\spider;
|
||||||
|
|
||||||
|
use app\common\library\scrm\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 星河云控蜘蛛
|
||||||
|
*/
|
||||||
|
class XingheSpider extends AbstractScrmSpider
|
||||||
|
{
|
||||||
|
private const API_LIST = '/share/share/api_yinliu_count.html';
|
||||||
|
|
||||||
|
private const DEFAULT_PER_PAGE_COUNT = 10;
|
||||||
|
|
||||||
|
private string $pageUrl;
|
||||||
|
|
||||||
|
private string $account;
|
||||||
|
|
||||||
|
private string $password;
|
||||||
|
|
||||||
|
private UnifiedScrmData $unifiedData;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $pageUrl,
|
||||||
|
string $account = '',
|
||||||
|
string $password = '',
|
||||||
|
string $nodeHost = 'http://127.0.0.1:3001'
|
||||||
|
) {
|
||||||
|
parent::__construct($nodeHost);
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时同步全局互斥锁,防止每分钟 cron 重叠执行打满 Node 队列
|
||||||
|
*/
|
||||||
|
class SplitCronLockService
|
||||||
|
{
|
||||||
|
private const LOCK_FILE = 'cron.lock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试获取全局 cron 锁
|
||||||
|
*/
|
||||||
|
public function acquire(): bool
|
||||||
|
{
|
||||||
|
$path = $this->lockPath();
|
||||||
|
if ($this->isStaleLock($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
if (is_file($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$payload = json_encode([
|
||||||
|
'pid' => getmypid(),
|
||||||
|
'time' => time(),
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
$written = @file_put_contents($path, $payload, LOCK_EX);
|
||||||
|
return $written !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放全局 cron 锁
|
||||||
|
*/
|
||||||
|
public function release(): void
|
||||||
|
{
|
||||||
|
$path = $this->lockPath();
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lockPath(): 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 . self::LOCK_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isStaleLock(string $path): bool
|
||||||
|
{
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$mtime = (int) @filemtime($path);
|
||||||
|
$ttl = SplitSyncConfigService::getCronLockTtlSeconds();
|
||||||
|
return $mtime > 0 && (time() - $mtime) > $ttl;
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+13
-7
@@ -12,13 +12,13 @@ class SplitFriendUrlBuilder
|
|||||||
/**
|
/**
|
||||||
* 构建跳转 URL;无法构建时返回空字符串
|
* 构建跳转 URL;无法构建时返回空字符串
|
||||||
*
|
*
|
||||||
* @param string $whatsAppReplyText 仅 WhatsApp 类型使用,预填消息文案(urlencode 在内部处理)
|
* @param string $replyText WhatsApp / Telegram 预填消息文案(内部 rawurlencode)
|
||||||
*/
|
*/
|
||||||
public static function build(
|
public static function build(
|
||||||
string $numberType,
|
string $numberType,
|
||||||
string $number,
|
string $number,
|
||||||
string $numberTypeCustom = '',
|
string $numberTypeCustom = '',
|
||||||
string $whatsAppReplyText = ''
|
string $replyText = ''
|
||||||
): string {
|
): string {
|
||||||
$number = trim($number);
|
$number = trim($number);
|
||||||
if ($number === '') {
|
if ($number === '') {
|
||||||
@@ -27,9 +27,9 @@ class SplitFriendUrlBuilder
|
|||||||
|
|
||||||
switch ($numberType) {
|
switch ($numberType) {
|
||||||
case 'whatsapp':
|
case 'whatsapp':
|
||||||
return self::buildWhatsApp($number, $whatsAppReplyText);
|
return self::buildWhatsApp($number, $replyText);
|
||||||
case 'telegram':
|
case 'telegram':
|
||||||
return self::buildTelegram($number);
|
return self::buildTelegram($number, $replyText);
|
||||||
case 'line':
|
case 'line':
|
||||||
return self::buildLine($number);
|
return self::buildLine($number);
|
||||||
case 'custom':
|
case 'custom':
|
||||||
@@ -59,16 +59,22 @@ class SplitFriendUrlBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Telegram:https://t.me/+ 号码(去掉前导 +)
|
* Telegram:https://t.me/+ 号码(去掉前导 +),可选 ?text= 预填消息(须 URL 编码)
|
||||||
*/
|
*/
|
||||||
private static function buildTelegram(string $number): string
|
private static function buildTelegram(string $number, string $replyText = ''): string
|
||||||
{
|
{
|
||||||
$id = ltrim($number, '+');
|
$id = ltrim($number, '+');
|
||||||
if ($id === '') {
|
if ($id === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'https://t.me/+' . $id;
|
$url = 'https://t.me/+' . $id;
|
||||||
|
$replyText = trim($replyText);
|
||||||
|
if ($replyText !== '') {
|
||||||
|
$url .= '?text=' . rawurlencode($replyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node Puppeteer 服务健康与队列负载探测
|
||||||
|
*/
|
||||||
|
class SplitNodeHealthService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 拉取 /api/stats,失败返回 null(不阻断同步,由后续请求快速失败)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public static function fetchStats(): ?array
|
||||||
|
{
|
||||||
|
$url = SplitSyncConfigService::getNodeHost() . '/api/stats';
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
if (curl_errno($ch)) {
|
||||||
|
curl_close($ch);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$decoded = json_decode((string) $response, true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前是否适合向 Node 提交新 Browser 任务
|
||||||
|
*/
|
||||||
|
public static function canAcceptWork(): bool
|
||||||
|
{
|
||||||
|
$stats = self::fetchStats();
|
||||||
|
if ($stats === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!empty($stats['shuttingDown'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
|
||||||
|
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
||||||
|
$queued = (int) ($queue['queued'] ?? 0);
|
||||||
|
$active = (int) ($queue['active'] ?? 0);
|
||||||
|
$max = max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4)));
|
||||||
|
$threshold = SplitSyncConfigService::getNodeQueueSkipThreshold();
|
||||||
|
if ($queued >= $threshold) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $active < $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{queued:int,active:int,max:int}
|
||||||
|
*/
|
||||||
|
public static function getQueueSnapshot(): array
|
||||||
|
{
|
||||||
|
$stats = self::fetchStats();
|
||||||
|
if ($stats === null) {
|
||||||
|
return ['queued' => 0, 'active' => 0, 'max' => 0];
|
||||||
|
}
|
||||||
|
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
|
||||||
|
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
||||||
|
return [
|
||||||
|
'queued' => (int) ($queue['queued'] ?? 0),
|
||||||
|
'active' => (int) ($queue['active'] ?? 0),
|
||||||
|
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 侧 Real Browser / Captcha 是否就绪(供同步失败日志可读性)
|
||||||
|
*
|
||||||
|
* @return array{realBrowserReady:bool,captchaConfigured:bool}
|
||||||
|
*/
|
||||||
|
public static function getAntiBotSnapshot(): array
|
||||||
|
{
|
||||||
|
$stats = self::fetchStats();
|
||||||
|
if ($stats === null) {
|
||||||
|
return ['realBrowserReady' => false, 'captchaConfigured' => false];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'realBrowserReady' => !empty($stats['realBrowserReady']),
|
||||||
|
'captchaConfigured' => !empty($stats['captchaConfigured']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+4
-4
@@ -72,16 +72,16 @@ class SplitRedirectService
|
|||||||
? (string) ($picked['number_type_custom'] ?? '')
|
? (string) ($picked['number_type_custom'] ?? '')
|
||||||
: (string) $picked->getAttr('number_type_custom');
|
: (string) $picked->getAttr('number_type_custom');
|
||||||
|
|
||||||
$whatsAppReplyText = '';
|
$replyText = '';
|
||||||
if ($numberType === 'whatsapp') {
|
if (in_array($numberType, ['whatsapp', 'telegram'], true)) {
|
||||||
$whatsAppReplyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply'));
|
$replyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$redirectUrl = SplitFriendUrlBuilder::build(
|
$redirectUrl = SplitFriendUrlBuilder::build(
|
||||||
$numberType,
|
$numberType,
|
||||||
$numberValue,
|
$numberValue,
|
||||||
$numberCustom,
|
$numberCustom,
|
||||||
$whatsAppReplyText
|
$replyText
|
||||||
);
|
);
|
||||||
if ($redirectUrl === '') {
|
if ($redirectUrl === '') {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
+14
@@ -6,9 +6,11 @@ namespace app\common\service;
|
|||||||
|
|
||||||
use app\common\library\scrm\ScrmSpiderInterface;
|
use app\common\library\scrm\ScrmSpiderInterface;
|
||||||
use app\common\library\scrm\spider\A2cSpider;
|
use app\common\library\scrm\spider\A2cSpider;
|
||||||
|
use app\common\library\scrm\spider\ChatknowSpider;
|
||||||
use app\common\library\scrm\spider\HaiwangSpider;
|
use app\common\library\scrm\spider\HaiwangSpider;
|
||||||
use app\common\library\scrm\spider\HuojianSpider;
|
use app\common\library\scrm\spider\HuojianSpider;
|
||||||
use app\common\library\scrm\spider\SsCustomerSpider;
|
use app\common\library\scrm\spider\SsCustomerSpider;
|
||||||
|
use app\common\library\scrm\spider\WhatshubSpider;
|
||||||
use app\common\library\scrm\spider\XingheSpider;
|
use app\common\library\scrm\spider\XingheSpider;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +27,8 @@ class SplitScrmSpiderFactory
|
|||||||
'huojian' => HuojianSpider::class,
|
'huojian' => HuojianSpider::class,
|
||||||
'xinghe' => XingheSpider::class,
|
'xinghe' => XingheSpider::class,
|
||||||
'ss_customer' => SsCustomerSpider::class,
|
'ss_customer' => SsCustomerSpider::class,
|
||||||
|
'chatknow' => ChatknowSpider::class,
|
||||||
|
'whatshub' => WhatshubSpider::class,
|
||||||
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -45,6 +49,16 @@ class SplitScrmSpiderFactory
|
|||||||
return self::resolveClass($ticketType) !== null;
|
return self::resolveClass($ticketType) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已注册且已实现蜘蛛的类型列表
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function listSupportedTypes(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::MAP);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return ScrmSpiderInterface|null
|
* @return ScrmSpiderInterface|null
|
||||||
*/
|
*/
|
||||||
|
|||||||
Regular → Executable
+36
@@ -50,6 +50,42 @@ class SplitSyncConfigService
|
|||||||
return max(0, (int) $value);
|
return max(0, (int) $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次 cron 最多处理几条到期工单,避免一分钟内打满 Node Browser 槽位
|
||||||
|
*/
|
||||||
|
public static function getMaxTicketsPerCronRun(): int
|
||||||
|
{
|
||||||
|
$value = self::getConfigValue('split_sync_max_per_cron');
|
||||||
|
if ($value === '') {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return max(1, min(20, (int) $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局 cron 锁过期秒数,防止异常退出后永久占锁
|
||||||
|
*/
|
||||||
|
public static function getCronLockTtlSeconds(): int
|
||||||
|
{
|
||||||
|
$value = self::getConfigValue('split_sync_cron_lock_ttl');
|
||||||
|
if ($value === '') {
|
||||||
|
return 1800;
|
||||||
|
}
|
||||||
|
return max(300, (int) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 排队数达到该阈值时,本轮 cron 不再提交新任务
|
||||||
|
*/
|
||||||
|
public static function getNodeQueueSkipThreshold(): int
|
||||||
|
{
|
||||||
|
$value = self::getConfigValue('split_sync_node_queue_threshold');
|
||||||
|
if ($value === '') {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return max(0, (int) $value);
|
||||||
|
}
|
||||||
|
|
||||||
private static function getConfigValue(string $name): string
|
private static function getConfigValue(string $name): string
|
||||||
{
|
{
|
||||||
$site = Config::get('site.' . $name);
|
$site = Config::get('site.' . $name);
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+55
-7
@@ -30,9 +30,10 @@ class SplitTicketSyncService
|
|||||||
/**
|
/**
|
||||||
* 同步单条工单
|
* 同步单条工单
|
||||||
*
|
*
|
||||||
|
* @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入)
|
||||||
* @return array{success:bool,message:string,skipped?:bool}
|
* @return array{success:bool,message:string,skipped?:bool}
|
||||||
*/
|
*/
|
||||||
public function syncOne(int $ticketId, bool $force = false): array
|
public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false): array
|
||||||
{
|
{
|
||||||
$ticket = Ticket::get($ticketId);
|
$ticket = Ticket::get($ticketId);
|
||||||
if (!$ticket) {
|
if (!$ticket) {
|
||||||
@@ -50,7 +51,7 @@ class SplitTicketSyncService
|
|||||||
'nodeHost' => SplitSyncConfigService::getNodeHost(),
|
'nodeHost' => SplitSyncConfigService::getNodeHost(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$force) {
|
if (!$force && !$dueValidated) {
|
||||||
$skip = $this->shouldSkip($ticket);
|
$skip = $this->shouldSkip($ticket);
|
||||||
if ($skip !== null) {
|
if ($skip !== null) {
|
||||||
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => $skip]);
|
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => $skip]);
|
||||||
@@ -76,23 +77,47 @@ class SplitTicketSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描到期工单并同步
|
* 扫描到期工单并同步(限流 + Node 队列感知)
|
||||||
*/
|
*/
|
||||||
public function syncDueTickets(): int
|
public function syncDueTickets(): int
|
||||||
{
|
{
|
||||||
$count = 0;
|
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
|
||||||
|
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
|
||||||
|
if ($autoSyncTypes === []) {
|
||||||
|
SplitTicketSyncLogger::log('cron', 'scan skip', ['reason' => 'no auto-sync ticket types']);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SplitNodeHealthService::canAcceptWork()) {
|
||||||
|
SplitTicketSyncLogger::log('cron', 'scan skip', [
|
||||||
|
'reason' => 'node queue busy',
|
||||||
|
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
|
]);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
$query = Ticket::where('status', 'normal');
|
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
|
||||||
if ($failThreshold > 0) {
|
if ($failThreshold > 0) {
|
||||||
$query->where('sync_fail_count', '<', $failThreshold);
|
$query->where('sync_fail_count', '<', $failThreshold);
|
||||||
}
|
}
|
||||||
$list = $query->select();
|
// 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单
|
||||||
|
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
|
||||||
|
->limit($maxPerRun * 5)
|
||||||
|
->select();
|
||||||
|
|
||||||
SplitTicketSyncLogger::log('cron', 'scan start', [
|
SplitTicketSyncLogger::log('cron', 'scan start', [
|
||||||
'candidateCount' => count($list),
|
'candidateCount' => count($list),
|
||||||
|
'maxPerRun' => $maxPerRun,
|
||||||
|
'autoSyncTypes' => $autoSyncTypes,
|
||||||
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
foreach ($list as $ticket) {
|
foreach ($list as $ticket) {
|
||||||
|
if ($count >= $maxPerRun) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
$skip = $this->shouldSkip($ticket);
|
$skip = $this->shouldSkip($ticket);
|
||||||
if ($skip !== null) {
|
if ($skip !== null) {
|
||||||
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
|
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
|
||||||
@@ -102,7 +127,14 @@ class SplitTicketSyncService
|
|||||||
]);
|
]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$result = $this->syncOne((int) $ticket['id'], false);
|
if (!SplitNodeHealthService::canAcceptWork()) {
|
||||||
|
SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [
|
||||||
|
'processed' => $count,
|
||||||
|
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$result = $this->syncOne((int) $ticket['id'], false, true);
|
||||||
if (!empty($result['skipped'])) {
|
if (!empty($result['skipped'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -113,6 +145,22 @@ class SplitTicketSyncService
|
|||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已配置自动同步周期且已实现蜘蛛的工单类型
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function resolveAutoSyncTicketTypes(): array
|
||||||
|
{
|
||||||
|
$types = [];
|
||||||
|
foreach (SplitScrmSpiderFactory::listSupportedTypes() as $ticketType) {
|
||||||
|
if (SplitSyncConfigService::getIntervalMinutes($ticketType) > 0) {
|
||||||
|
$types[] = $ticketType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $types;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{success:bool,message:string}
|
* @return array{success:bool,message:string}
|
||||||
*/
|
*/
|
||||||
|
|||||||
Regular → Executable
@@ -279,17 +279,17 @@ return [
|
|||||||
//FastAdmin配置
|
//FastAdmin配置
|
||||||
'fastadmin' => [
|
'fastadmin' => [
|
||||||
//是否开启前台会员中心
|
//是否开启前台会员中心
|
||||||
'usercenter' => true,
|
'usercenter' => false,
|
||||||
//会员注册验证码类型email/mobile/wechat/text/false
|
//会员注册验证码类型email/mobile/wechat/text/false
|
||||||
'user_register_captcha' => 'text',
|
'user_register_captcha' => 'text',
|
||||||
//登录验证码
|
//登录验证码
|
||||||
'login_captcha' => true,
|
'login_captcha' => false,
|
||||||
//登录失败超过10次则1天后重试
|
//登录失败超过10次则1天后重试
|
||||||
'login_failure_retry' => true,
|
'login_failure_retry' => true,
|
||||||
//是否同一账号同一时间只能在一个地方登录
|
//是否同一账号同一时间只能在一个地方登录
|
||||||
'login_unique' => false,
|
'login_unique' => false,
|
||||||
//是否开启IP变动检测
|
//是否开启IP变动检测
|
||||||
'loginip_check' => true,
|
'loginip_check' => false,
|
||||||
//登录页默认背景图
|
//登录页默认背景图
|
||||||
'login_background' => "",
|
'login_background' => "",
|
||||||
//是否启用多级菜单导航
|
//是否启用多级菜单导航
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
return array (
|
return array (
|
||||||
'name' => 'LINK-S 多功能打粉',
|
'name' => 'link管理系统',
|
||||||
'beian' => '',
|
'beian' => '',
|
||||||
'cdnurl' => '',
|
'cdnurl' => '',
|
||||||
'version' => '1.0.1',
|
'version' => '1.0.14',
|
||||||
'timezone' => 'Asia/Shanghai',
|
'timezone' => 'Asia/Shanghai',
|
||||||
'forbiddenip' => '',
|
'forbiddenip' => '',
|
||||||
'languages' =>
|
'languages' =>
|
||||||
@@ -42,18 +42,19 @@ return array (
|
|||||||
'category2' => 'Category2',
|
'category2' => 'Category2',
|
||||||
'custom' => 'Custom',
|
'custom' => 'Custom',
|
||||||
),
|
),
|
||||||
'split_platform_domain' => 'links.test',
|
'split_platform_domain' => 'flowerbells.top',
|
||||||
'split_scrm_node_host' => 'http://127.0.0.1:3001',
|
'split_scrm_node_host' => 'http://127.0.0.1:3001',
|
||||||
'split_sync_interval_a2c' => '3',
|
'split_sync_interval_a2c' => '3',
|
||||||
'split_sync_interval_haiwang' => '3',
|
'split_sync_interval_haiwang' => '3',
|
||||||
'split_sync_interval_huojian' => '3',
|
'split_sync_interval_huojian' => '3',
|
||||||
'split_sync_interval_xinghe' => '3',
|
'split_sync_interval_xinghe' => '3',
|
||||||
'split_sync_interval_ss_custome' => '3',
|
'split_sync_interval_ss_customer' => '3',
|
||||||
'split_sync_interval_ceo_scrm' => '0',
|
'split_sync_interval_ceo_scrm' => '0',
|
||||||
'split_sync_interval_taiji' => '0',
|
'split_sync_interval_taiji' => '0',
|
||||||
'split_sync_interval_ss_channel' => '0',
|
'split_sync_interval_ss_channel' => '0',
|
||||||
'split_sync_interval_yifafa' => '0',
|
'split_sync_interval_yifafa' => '0',
|
||||||
'split_sync_interval_whatshub' => '0',
|
'split_sync_interval_whatshub' => '5',
|
||||||
'split_sync_interval_sihai' => '0',
|
'split_sync_interval_sihai' => '0',
|
||||||
'split_sync_fail_pause_threshold' => '5',
|
'split_sync_fail_pause_threshold' => '6',
|
||||||
|
'split_sync_interval_chatknow' => '5',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class Index extends Frontend
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
return $this->view->fetch();
|
return 404;
|
||||||
|
// return $this->view->fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -0,0 +1,6 @@
|
|||||||
|
-- Chatknow SCRM 工单云控自动同步周期(分钟,0=不自动同步)
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_interval_chatknow', 'split', 'Chatknow SCRM同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_chatknow' LIMIT 1);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- 修复 SS 云控同步周期配置键名拼写错误:ss_custome -> ss_customer
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
UPDATE `fa_config`
|
||||||
|
SET `name` = 'split_sync_interval_ss_customer'
|
||||||
|
WHERE `name` = 'split_sync_interval_ss_custome'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer') AS `t`);
|
||||||
|
|
||||||
|
UPDATE `fa_config` AS `correct`
|
||||||
|
INNER JOIN `fa_config` AS `wrong` ON `wrong`.`name` = 'split_sync_interval_ss_custome'
|
||||||
|
SET `correct`.`value` = `wrong`.`value`
|
||||||
|
WHERE `correct`.`name` = 'split_sync_interval_ss_customer';
|
||||||
|
|
||||||
|
DELETE FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_custome';
|
||||||
Regular → Executable
+29
@@ -29,6 +29,10 @@ INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `va
|
|||||||
SELECT 'split_sync_interval_ss_customer', 'split', 'SS云控(Customer)同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
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);
|
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_chatknow', 'split', 'Chatknow SCRM同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_chatknow' LIMIT 1);
|
||||||
|
|
||||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
SELECT 'split_sync_interval_ceo_scrm', 'split', 'CEO SCRM同步周期(分钟)', '0 表示不自动同步(蜘蛛未实现)', 'number', '', '0', '', '', '', NULL
|
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);
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ceo_scrm' LIMIT 1);
|
||||||
@@ -52,3 +56,28 @@ FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync
|
|||||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
SELECT 'split_sync_interval_sihai', 'split', '四海云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL
|
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);
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_sihai' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_max_per_cron', 'split', '单次定时同步上限', '每分钟 cron 最多处理几条到期工单,防止打满 Node Browser 槽位', 'number', '', '2', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_cron_lock_ttl', 'split', '定时同步全局锁TTL(秒)', '异常退出后自动释放全局锁,避免后续 cron 永久跳过', 'number', '', '1800', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_cron_lock_ttl' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_node_queue_threshold', 'split', 'Node排队跳过阈值', '排队数达到该值时本轮 cron 不再提交新任务', 'number', '', '2', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_node_queue_threshold' LIMIT 1);
|
||||||
|
|
||||||
|
-- 修复历史拼写错误:split_sync_interval_ss_custome -> split_sync_interval_ss_customer
|
||||||
|
UPDATE `fa_config`
|
||||||
|
SET `name` = 'split_sync_interval_ss_customer'
|
||||||
|
WHERE `name` = 'split_sync_interval_ss_custome'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer') AS `t`);
|
||||||
|
|
||||||
|
UPDATE `fa_config` AS `correct`
|
||||||
|
INNER JOIN `fa_config` AS `wrong` ON `wrong`.`name` = 'split_sync_interval_ss_custome'
|
||||||
|
SET `correct`.`value` = `wrong`.`value`
|
||||||
|
WHERE `correct`.`name` = 'split_sync_interval_ss_customer';
|
||||||
|
|
||||||
|
DELETE FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_custome';
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+7
@@ -71,6 +71,13 @@ WHERE m.name = 'split.number' AND m.ismenu = 1
|
|||||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchupdate' LIMIT 1)
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchupdate' LIMIT 1)
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/batchoperate', '批量操作号码', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchoperate' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
SELECT 'file', m.id, 'split.number/multi', '批量更新', 'fa fa-circle-o', '', '列表状态开关', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
SELECT 'file', m.id, 'split.number/multi', '批量更新', 'fa fa-circle-o', '', '列表状态开关', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
FROM `fa_auth_rule` m
|
FROM `fa_auth_rule` m
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user