Compare commits
22 Commits
d5a0ffa6db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 964f960264 | |||
| 98fc6d9e3d | |||
| 76692e0021 | |||
| 92b4faf61f | |||
| 2e621bf970 | |||
| ef4c8153c2 | |||
| 744d8b26a4 | |||
| a8a2551868 | |||
| f1acab12a7 | |||
| 6b72cd7b67 | |||
| 9b57b0c400 | |||
| 455b6669e8 | |||
| 8508d51b66 | |||
| 0f3eb53afe | |||
| c3223f026e | |||
| 638b3c4032 | |||
| 439f79a5ae | |||
| 54c13c54ef | |||
| 9f2e904fab | |||
| 6130125427 | |||
| 09bfa9ae01 | |||
| e95f4a227c |
@@ -0,0 +1 @@
|
|||||||
|
47demv6l5lvam1gzqdkzrih64pb0tzqd.gddJCefh0gXT5ZBsRHlHGniOhraU34wwA4o-jjmdTFw
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
1213
|
||||||
+116
-33
@@ -1,55 +1,144 @@
|
|||||||
<?php
|
<?php
|
||||||
// A2c云控
|
// A2c云控 — 测试脚本(短链/长链 + Real Browser 拦截 API,对齐生产 A2cSpider)
|
||||||
|
|
||||||
require_once __DIR__ . '/AbstractScrmSpider.class.php';
|
require_once __DIR__ . '/AbstractScrmSpider.class.php';
|
||||||
|
require_once __DIR__ . '/SplitPageUrlResolver.php';
|
||||||
|
|
||||||
class A2c extends AbstractScrmSpider
|
class A2c extends AbstractScrmSpider
|
||||||
{
|
{
|
||||||
const API_LIST = '/api/talk/counter/share/record/list'; // 列表API地址
|
const API_LIST = '/api/talk/counter/share/record/list';
|
||||||
const API_DETAILS = '/api/talk/counter/share/detail'; // 详情API地址
|
|
||||||
const API_COUNT = ''; // 总数API地址
|
const API_DETAILS = '/api/talk/counter/share/detail';
|
||||||
const DEFAULT_PER_PAGE_COUNT = 20; // List默认每页显示的数量
|
|
||||||
|
const API_COUNT = '';
|
||||||
|
|
||||||
|
const DEFAULT_PER_PAGE_COUNT = 20;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
private $pageUrl;
|
private $pageUrl;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
private $account;
|
private $account;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
private $password;
|
private $password;
|
||||||
|
|
||||||
|
/** @var UnifiedScrmData */
|
||||||
private $unifiedData;
|
private $unifiedData;
|
||||||
|
|
||||||
// 实例化时动态传入账号和密码
|
/** @var string HTTP 预解析后的落地 URL */
|
||||||
|
private $landingUrl = '';
|
||||||
|
|
||||||
public function __construct($pageUrl, $account, $password, $nodeHost = 'http://127.0.0.1:3001')
|
public function __construct($pageUrl, $account, $password, $nodeHost = 'http://127.0.0.1:3001')
|
||||||
{
|
{
|
||||||
parent::__construct($nodeHost);
|
parent::__construct($nodeHost);
|
||||||
$this->account = $account;
|
$this->account = $account;
|
||||||
$this->password = $password;
|
$this->password = $password;
|
||||||
$this->pageUrl = $pageUrl;
|
$this->pageUrl = $pageUrl;
|
||||||
|
$this->landingUrl = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
$this->unifiedData = new UnifiedScrmData();
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getSpiderConfig()
|
protected function getSpiderConfig()
|
||||||
{
|
{
|
||||||
|
$antiBot = $this->buildAntiBotConfig();
|
||||||
|
|
||||||
|
$this->logDebug('a2c spider config', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'landingUrl' => $this->landingUrl,
|
||||||
|
'sessionKey' => $antiBot['sessionKey'] ?? '',
|
||||||
|
'cfClearanceRequired' => $antiBot['cfClearanceRequired'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pageUrl' => $this->pageUrl,
|
'pageUrl' => $this->pageUrl,
|
||||||
'apiUrls' => [self::API_LIST, self::API_DETAILS],
|
'listApi' => self::API_LIST,
|
||||||
|
'detailApi' => self::API_DETAILS,
|
||||||
// 明确指派角色
|
|
||||||
'listApi' => self::API_LIST, // 必须
|
|
||||||
'detailApi' => self::API_DETAILS, // 选填
|
|
||||||
|
|
||||||
'listMethod' => 'POST',
|
'listMethod' => 'POST',
|
||||||
|
|
||||||
'paginationMode' => self::MODE_UI,
|
'paginationMode' => self::MODE_UI,
|
||||||
|
|
||||||
'authActions' => [
|
'authActions' => [
|
||||||
// ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password],
|
['type' => 'wait', 'ms' => 2000],
|
||||||
// ['type' => 'type', 'selector' => '#username_input', 'value' => $this->account],
|
],
|
||||||
// ['type' => 'press', 'key' => 'Enter'],
|
'antiBot' => $antiBot,
|
||||||
|
|
||||||
['type' => 'wait', 'ms' => 2000]
|
|
||||||
]
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只负责解析 List 的总页数
|
/**
|
||||||
|
* A2C:业务域 + 分享 id 会话;短链仍作 pageUrl,landingUrlHint 供 Node 直达长链
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildAntiBotConfig()
|
||||||
|
{
|
||||||
|
$sessionScope = $this->resolveA2cSessionScope();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => true,
|
||||||
|
'profile' => 'real',
|
||||||
|
'turnstile' => true,
|
||||||
|
'solverFallback' => true,
|
||||||
|
'cfClearanceRequired' => false,
|
||||||
|
'sessionKey' => 'a2c:' . $sessionScope,
|
||||||
|
'challengeTimeoutMs' => 60000,
|
||||||
|
'landingUrlHint' => $this->landingUrl,
|
||||||
|
'cfCloudflareSolver' => true,
|
||||||
|
'cfChallengeMode' => 'managed',
|
||||||
|
'businessHostHint' => $this->resolveA2cSessionHost(),
|
||||||
|
'postCfReadyUrlContains' => '/visitors/counter/share',
|
||||||
|
'postCfReadySelector' => '.el-table',
|
||||||
|
'postCfReadyApiUrl' => '/api/talk/counter/share/record/list',
|
||||||
|
'postCfReadyTimeoutMs' => 60000,
|
||||||
|
'postCfSettleMs' => 500,
|
||||||
|
'postCfSpaWaitMs' => 8000,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2C 业务域 host
|
||||||
|
*/
|
||||||
|
private function resolveA2cSessionHost()
|
||||||
|
{
|
||||||
|
$candidates = [];
|
||||||
|
if ($this->landingUrl !== '') {
|
||||||
|
$candidates[] = (string) parse_url($this->landingUrl, PHP_URL_HOST);
|
||||||
|
}
|
||||||
|
$candidates[] = (string) parse_url($this->pageUrl, PHP_URL_HOST);
|
||||||
|
|
||||||
|
foreach ($candidates as $host) {
|
||||||
|
$host = strtolower(trim($host));
|
||||||
|
if ($host !== '' && strpos($host, 'a2c.chat') !== false) {
|
||||||
|
return $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'user.a2c.chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2C 会话 scope:host + share id
|
||||||
|
*/
|
||||||
|
private function resolveA2cSessionScope()
|
||||||
|
{
|
||||||
|
$host = $this->resolveA2cSessionHost();
|
||||||
|
$shareId = $this->extractA2cShareId($this->pageUrl);
|
||||||
|
if ($shareId === '' && $this->landingUrl !== '') {
|
||||||
|
$shareId = $this->extractA2cShareId($this->landingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $shareId !== '' ? $host . ':' . $shareId : $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractA2cShareId($url)
|
||||||
|
{
|
||||||
|
$query = (string) parse_url($url, PHP_URL_QUERY);
|
||||||
|
if ($query === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
parse_str($query, $params);
|
||||||
|
|
||||||
|
return isset($params['id']) ? trim((string) $params['id']) : '';
|
||||||
|
}
|
||||||
|
|
||||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||||
{
|
{
|
||||||
$default_per_page_count = self::DEFAULT_PER_PAGE_COUNT;
|
$default_per_page_count = self::DEFAULT_PER_PAGE_COUNT;
|
||||||
@@ -59,43 +148,37 @@ class A2c extends AbstractScrmSpider
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ceil($listFirstPageData['data']['total']/$default_per_page_count);
|
return (int) ceil($listFirstPageData['data']['total'] / $default_per_page_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只负责组装 List 的翻页参数
|
|
||||||
protected function buildListPageParams($page)
|
protected function buildListPageParams($page)
|
||||||
{
|
{
|
||||||
return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
|
return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提供 List 的下一页按钮信息
|
|
||||||
protected function getUiPaginationConfig()
|
protected function getUiPaginationConfig()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'nextBtnSelector' => '.btn-next',
|
'nextBtnSelector' => '.btn-next',
|
||||||
'waitMs' => 2000
|
'waitMs' => 2000,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清爽至极的数据清洗:详情是详情,列表是列表
|
|
||||||
protected function parseToUnifiedData($detailData, $allListPagesData)
|
protected function parseToUnifiedData($detailData, $allListPagesData)
|
||||||
{
|
{
|
||||||
$unifiedData = $this->unifiedData;
|
$unifiedData = $this->unifiedData;
|
||||||
|
|
||||||
// 1. 如果捕获到了详情数据,提取今日新增
|
|
||||||
if ($detailData) {
|
if ($detailData) {
|
||||||
$unifiedData->todayNewCount = (int) ($detailData['data']['newFollowersToday'] ?? 0);
|
$unifiedData->todayNewCount = (int) ($detailData['data']['newFollowersToday'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 循环合并了所有页数的 List 数组
|
|
||||||
foreach ($allListPagesData as $pageRaw) {
|
foreach ($allListPagesData as $pageRaw) {
|
||||||
$records = $pageRaw['data']['rows'] ?? [];
|
$records = $pageRaw['data']['rows'] ?? [];
|
||||||
foreach ($records as $item) {
|
foreach ($records as $item) {
|
||||||
if (!empty($item['account'])) {
|
if (!empty($item['account'])) {
|
||||||
$number = $item['account'] ?? null;
|
$number = $item['account'];
|
||||||
$isOnline = (isset($item['numberStatus']) && $item['numberStatus'] == 1);
|
$isOnline = (isset($item['numberStatus']) && (int) $item['numberStatus'] === 1);
|
||||||
|
$unifiedData->addNumber($number, $isOnline, (int) ($item['newFollowersToday'] ?? 0));
|
||||||
$unifiedData->addNumber($number, $isOnline, $item['newFollowersToday']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,11 +75,23 @@ abstract class AbstractScrmSpider
|
|||||||
const MODE_FETCH = 'fetch'; // 极速并发拉取 (推荐默认)
|
const MODE_FETCH = 'fetch'; // 极速并发拉取 (推荐默认)
|
||||||
const MODE_UI = 'ui_click'; // 强制 UI 点击下一页
|
const MODE_UI = 'ui_click'; // 强制 UI 点击下一页
|
||||||
|
|
||||||
|
/** CLI 测试时输出 Node 请求/失败详情 */
|
||||||
|
protected $verbose = false;
|
||||||
|
|
||||||
protected $nodeHost;
|
protected $nodeHost;
|
||||||
|
|
||||||
public function __construct($nodeHost = 'http://127.0.0.1:3001')
|
public function __construct($nodeHost = 'http://127.0.0.1:3001')
|
||||||
{
|
{
|
||||||
$this->nodeHost = $nodeHost;
|
$this->nodeHost = rtrim($nodeHost, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开启调试输出(测试脚本可调用)
|
||||||
|
*/
|
||||||
|
public function setVerbose($verbose = true)
|
||||||
|
{
|
||||||
|
$this->verbose = (bool) $verbose;
|
||||||
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ================= 子类必须实现的契约 ================= **/
|
/** ================= 子类必须实现的契约 ================= **/
|
||||||
@@ -106,27 +118,38 @@ abstract class AbstractScrmSpider
|
|||||||
$apiUrlsToIntercept = [$listApi];
|
$apiUrlsToIntercept = [$listApi];
|
||||||
if ($detailApi) { $apiUrlsToIntercept[] = $detailApi; }
|
if ($detailApi) { $apiUrlsToIntercept[] = $detailApi; }
|
||||||
if ($countApi) { $apiUrlsToIntercept[] = $countApi; }
|
if ($countApi) { $apiUrlsToIntercept[] = $countApi; }
|
||||||
// var_dump($apiUrlsToIntercept);
|
|
||||||
// dump([
|
$antiBot = $config['antiBot'] ?? null;
|
||||||
// 'pageUrl' => $config['pageUrl'],
|
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
|
||||||
// 'apiUrls' => $apiUrlsToIntercept,
|
|
||||||
// 'authActions' => $config['authActions']
|
// antiBot + UI 翻页:单 Browser 合并路径(与生产 SplitTicket 同步一致)
|
||||||
// ]);
|
if ($this->shouldUseUnifiedBrowserPath($antiBot, $mode)) {
|
||||||
|
return $this->runUnifiedUiPath($config, $antiBot, $apiUrlsToIntercept, $listApi, $detailApi, $countApi);
|
||||||
|
}
|
||||||
|
|
||||||
// 【阶段一】:初始化并首屏拦截
|
// 【阶段一】:初始化并首屏拦截
|
||||||
$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,
|
||||||
|
], $this->resolveAuthInterceptTimeout($antiBot));
|
||||||
|
|
||||||
if (empty($initResult['success'])) {
|
if (empty($initResult['success'])) {
|
||||||
throw new Exception("初始化失败: " . ($initResult['error'] ?? '未知'));
|
throw new Exception($this->formatNodeFailure('初始化失败', $initResult));
|
||||||
}
|
}
|
||||||
|
|
||||||
$interceptedApis = $initResult['interceptedApis'];
|
$interceptedApis = $initResult['interceptedApis'];
|
||||||
$cookies = $initResult['cookies'];
|
$cookies = $initResult['cookies'];
|
||||||
$finalPageUrl = $initResult['finalPageUrl'] ?? $config['pageUrl'];
|
$finalPageUrl = $initResult['finalPageUrl'] ?? $config['pageUrl'];
|
||||||
|
|
||||||
|
$this->logDebug('auth-and-intercept ok', [
|
||||||
|
'finalPageUrl' => $finalPageUrl,
|
||||||
|
'intercepted' => array_keys($interceptedApis),
|
||||||
|
'cf' => $initResult['cf'] ?? null,
|
||||||
|
'browserReused' => $initResult['browserReused'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
// 必须拦截到 List 接口,否则无法继续
|
// 必须拦截到 List 接口,否则无法继续
|
||||||
if (!isset($interceptedApis[$listApi])) {
|
if (!isset($interceptedApis[$listApi])) {
|
||||||
throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]");
|
throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]");
|
||||||
@@ -161,7 +184,8 @@ abstract class AbstractScrmSpider
|
|||||||
'paramList' => $paramList,
|
'paramList' => $paramList,
|
||||||
'method' => $config['listMethod'] ?? 'GET'
|
'method' => $config['listMethod'] ?? 'GET'
|
||||||
]],
|
]],
|
||||||
'cookies' => $cookies
|
'cookies' => $cookies,
|
||||||
|
'antiBot' => $antiBot,
|
||||||
], 120);
|
], 120);
|
||||||
|
|
||||||
if (!empty($fetchResult['success'])) {
|
if (!empty($fetchResult['success'])) {
|
||||||
@@ -189,7 +213,8 @@ abstract class AbstractScrmSpider
|
|||||||
'cookies' => $cookies,
|
'cookies' => $cookies,
|
||||||
'firstPageData' => $firstPageData, // 传递原始数据源
|
'firstPageData' => $firstPageData, // 传递原始数据源
|
||||||
// 🔥 核心补充:把登录剧本原封不动地传给翻页引擎!
|
// 🔥 核心补充:把登录剧本原封不动地传给翻页引擎!
|
||||||
'authActions' => $config['authActions']
|
'authActions' => $config['authActions'],
|
||||||
|
'antiBot' => $antiBot,
|
||||||
], 1200); // 增加 PHP 端的 cURL 超时时间到 400 秒,以包容多次重试产生的耗时
|
], 1200); // 增加 PHP 端的 cURL 超时时间到 400 秒,以包容多次重试产生的耗时
|
||||||
|
|
||||||
if (!empty($uiResult['success']) && !empty($uiResult['data'])) {
|
if (!empty($uiResult['success']) && !empty($uiResult['data'])) {
|
||||||
@@ -220,18 +245,225 @@ abstract class AbstractScrmSpider
|
|||||||
return $unifiedData;
|
return $unifiedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单 Browser 路径:auth + 拦截 + UI 翻页(需 antiBot.sessionKey)
|
||||||
|
*/
|
||||||
|
protected function runUnifiedUiPath($config, $antiBot, $apiUrlsToIntercept, $listApi, $detailApi, $countApi)
|
||||||
|
{
|
||||||
|
$uiConfig = $this->getUiPaginationConfig();
|
||||||
|
$sessionKey = is_array($antiBot) ? (string) ($antiBot['sessionKey'] ?? '') : '';
|
||||||
|
|
||||||
|
$this->logDebug('unified browser path', [
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
'listApi' => $listApi,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mergedResult = $this->requestNode('/api/auth-intercept-and-paginate', [
|
||||||
|
'pageUrl' => $config['pageUrl'],
|
||||||
|
'apiUrls' => $apiUrlsToIntercept,
|
||||||
|
'authActions' => $config['authActions'] ?? [],
|
||||||
|
'antiBot' => $antiBot,
|
||||||
|
'pagination' => [
|
||||||
|
'apiUrl' => $listApi,
|
||||||
|
'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '',
|
||||||
|
'waitMs' => $uiConfig['waitMs'] ?? 2000,
|
||||||
|
'clicksToPerform' => 9999,
|
||||||
|
],
|
||||||
|
], 1200);
|
||||||
|
|
||||||
|
if (empty($mergedResult['success'])) {
|
||||||
|
throw new Exception($this->formatNodeFailure('合并同步失败', $mergedResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logDebug('auth-intercept-and-paginate ok', [
|
||||||
|
'intercepted' => array_keys($mergedResult['interceptedApis'] ?? []),
|
||||||
|
'extraPages' => count($mergedResult['extraPages'] ?? []),
|
||||||
|
'cf' => $mergedResult['cf'] ?? null,
|
||||||
|
'browserReused' => $mergedResult['browserReused'] ?? null,
|
||||||
|
'finalPageUrl' => $mergedResult['finalPageUrl'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$interceptedApis = $mergedResult['interceptedApis'] ?? [];
|
||||||
|
if (!isset($interceptedApis[$listApi])) {
|
||||||
|
throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
$detailData = $detailApi && isset($interceptedApis[$detailApi])
|
||||||
|
? $interceptedApis[$detailApi]['data'] : null;
|
||||||
|
$countData = $countApi && isset($interceptedApis[$countApi])
|
||||||
|
? $interceptedApis[$countApi]['data'] : null;
|
||||||
|
|
||||||
|
$listApiNode = $interceptedApis[$listApi];
|
||||||
|
$allListPagesData = [$listApiNode['data']];
|
||||||
|
if (!empty($mergedResult['extraPages']) && is_array($mergedResult['extraPages'])) {
|
||||||
|
foreach ($mergedResult['extraPages'] as $pageData) {
|
||||||
|
$allListPagesData[] = $pageData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->extractListTotalPages($listApiNode['data'], $countData);
|
||||||
|
|
||||||
|
return $this->parseToUnifiedData($detailData, $allListPagesData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* antiBot 启用且配置了 sessionKey 时,UI 翻页走合并 Browser 路径
|
||||||
|
*/
|
||||||
|
protected function shouldUseUnifiedBrowserPath($antiBot, $mode)
|
||||||
|
{
|
||||||
|
if ($mode !== self::MODE_UI) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!is_array($antiBot) || empty($antiBot['enabled'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !empty($antiBot['sessionKey']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化 Node 失败信息,附带 CF / 落地页等诊断字段
|
||||||
|
*/
|
||||||
|
protected function formatNodeFailure($prefix, $result)
|
||||||
|
{
|
||||||
|
$cfCode = (string) ($result['code'] ?? '');
|
||||||
|
if ($cfCode === 'CF_TURNSTILE_FAILED') {
|
||||||
|
return 'Cloudflare Turnstile 验证失败: ' . ($result['stage'] ?? 'timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [$prefix . ': ' . ($result['error'] ?? '未知')];
|
||||||
|
|
||||||
|
if (!empty($result['finalPageUrl'])) {
|
||||||
|
$parts[] = 'finalPageUrl=' . $result['finalPageUrl'];
|
||||||
|
}
|
||||||
|
if (!empty($result['page']) && is_array($result['page'])) {
|
||||||
|
$page = $result['page'];
|
||||||
|
if (!empty($page['url'])) {
|
||||||
|
$parts[] = 'pageUrl=' . $page['url'];
|
||||||
|
}
|
||||||
|
if (isset($page['elInputInner'])) {
|
||||||
|
$parts[] = 'elInputInner=' . $page['elInputInner'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($result['cf']) && is_array($result['cf'])) {
|
||||||
|
$cf = $result['cf'];
|
||||||
|
$cfBits = [];
|
||||||
|
if (isset($cf['cfDetected'])) {
|
||||||
|
$cfBits[] = 'cfDetected=' . ($cf['cfDetected'] ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (!empty($cf['turnstileStage'])) {
|
||||||
|
$cfBits[] = 'turnstileStage=' . $cf['turnstileStage'];
|
||||||
|
}
|
||||||
|
if (isset($cf['solverUsed'])) {
|
||||||
|
$cfBits[] = 'solverUsed=' . ($cf['solverUsed'] ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (isset($cf['elapsedMs'])) {
|
||||||
|
$cfBits[] = 'cfElapsedMs=' . $cf['elapsedMs'];
|
||||||
|
}
|
||||||
|
if (!empty($cfBits)) {
|
||||||
|
$parts[] = implode(', ', $cfBits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' | ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function logDebug($message, $context = [])
|
||||||
|
{
|
||||||
|
if (!$this->verbose) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$line = '[Spider] ' . $message;
|
||||||
|
if (!empty($context)) {
|
||||||
|
$line .= ' ' . json_encode($context, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
echo $line . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real Browser + Turnstile 首屏耗时较长,单独放宽 auth-and-intercept 超时
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|null $antiBot
|
||||||
|
*/
|
||||||
|
private function resolveAuthInterceptTimeout($antiBot)
|
||||||
|
{
|
||||||
|
if (!is_array($antiBot) || empty($antiBot['enabled'])) {
|
||||||
|
return 120;
|
||||||
|
}
|
||||||
|
return 180;
|
||||||
|
}
|
||||||
|
|
||||||
private function requestNode($endpoint, $payload, $timeout = 60)
|
private function requestNode($endpoint, $payload, $timeout = 60)
|
||||||
{
|
{
|
||||||
$ch = curl_init($this->nodeHost . $endpoint);
|
$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;
|
||||||
|
}
|
||||||
|
$this->logDebug('node retry', [
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'attempt' => $attempt + 1,
|
||||||
|
'error' => $error,
|
||||||
|
]);
|
||||||
|
sleep($attempt);
|
||||||
|
}
|
||||||
|
return $lastDecoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function doRequestNode($endpoint, $payload, $timeout, $attempt)
|
||||||
|
{
|
||||||
|
$url = $this->nodeHost . $endpoint;
|
||||||
|
$this->logDebug('node request', [
|
||||||
|
'url' => $url,
|
||||||
|
'timeout' => $timeout,
|
||||||
|
'attempt' => $attempt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_POST, true);
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
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);
|
||||||
if (curl_errno($ch)) throw new Exception(curl_error($ch));
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
if (curl_errno($ch)) {
|
||||||
|
$err = curl_error($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
return json_decode($response, true);
|
throw new Exception($err);
|
||||||
|
}
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$decoded = json_decode((string) $response, true);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldRetryNodeResponse($decoded, $error)
|
||||||
|
{
|
||||||
|
if ((int) ($decoded['httpCode'] ?? 0) === 503) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$lower = strtolower($error);
|
||||||
|
foreach (['排队超时', 'queue_timeout', 'timeout', 'net::', 'navigation', 'connection'] as $needle) {
|
||||||
|
if ($needle !== '' && strpos($lower, $needle) !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Regular → Executable
+12
-1
@@ -26,6 +26,8 @@ class Chatknow extends AbstractScrmSpider
|
|||||||
|
|
||||||
protected function getSpiderConfig()
|
protected function getSpiderConfig()
|
||||||
{
|
{
|
||||||
|
$host = (string) parse_url($this->pageUrl, PHP_URL_HOST);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pageUrl' => $this->pageUrl,
|
'pageUrl' => $this->pageUrl,
|
||||||
'apiUrls' => [self::API_LIST],
|
'apiUrls' => [self::API_LIST],
|
||||||
@@ -43,7 +45,16 @@ class Chatknow extends AbstractScrmSpider
|
|||||||
// ['type' => 'vue_click', 'selector' => '.layui-btn', 'text' => '搜索'],
|
// ['type' => 'vue_click', 'selector' => '.layui-btn', 'text' => '搜索'],
|
||||||
|
|
||||||
['type' => 'wait', 'ms' => 4000]
|
['type' => 'wait', 'ms' => 4000]
|
||||||
]
|
],
|
||||||
|
// ChatKnow 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用
|
||||||
|
'antiBot' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'profile' => 'real',
|
||||||
|
'turnstile' => true,
|
||||||
|
'solverFallback' => true,
|
||||||
|
'sessionKey' => 'chatknow:' . $host,
|
||||||
|
'challengeTimeoutMs' => 60000,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+126
-34
@@ -1,96 +1,188 @@
|
|||||||
<?php
|
<?php
|
||||||
// 火箭工单
|
// 火箭工单 — 测试脚本(短链/长链 + Real Browser + CF + 密码弹窗)
|
||||||
|
|
||||||
require_once __DIR__ . '/AbstractScrmSpider.class.php';
|
require_once __DIR__ . '/AbstractScrmSpider.class.php';
|
||||||
|
require_once __DIR__ . '/SplitPageUrlResolver.php';
|
||||||
|
|
||||||
class Huojian extends AbstractScrmSpider
|
class Huojian extends AbstractScrmSpider
|
||||||
{
|
{
|
||||||
const API_LIST = '/prod-api1/biz/counter/link/share/'; // 列表API地址
|
const API_LIST = '/prod-api1/biz/counter/link/share/';
|
||||||
const API_DETAILS = ''; // 详情API地址
|
|
||||||
const API_COUNT = ''; // 总数API地址
|
const API_DETAILS = '';
|
||||||
const DEFAULT_PER_PAGE_COUNT = 20; // List默认每页显示的数量
|
|
||||||
|
const API_COUNT = '';
|
||||||
|
|
||||||
|
const DEFAULT_PER_PAGE_COUNT = 20;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
private $pageUrl;
|
private $pageUrl;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
private $account;
|
private $account;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
private $password;
|
private $password;
|
||||||
|
|
||||||
|
/** @var UnifiedScrmData */
|
||||||
private $unifiedData;
|
private $unifiedData;
|
||||||
|
|
||||||
// 实例化时动态传入账号和密码
|
/** @var string */
|
||||||
|
private $landingUrlHint = '';
|
||||||
|
|
||||||
public function __construct($pageUrl, $account, $password, $nodeHost = 'http://127.0.0.1:3001')
|
public function __construct($pageUrl, $account, $password, $nodeHost = 'http://127.0.0.1:3001')
|
||||||
{
|
{
|
||||||
parent::__construct($nodeHost);
|
parent::__construct($nodeHost);
|
||||||
$this->account = $account;
|
$this->account = $account;
|
||||||
$this->password = $password;
|
$this->password = $password;
|
||||||
$this->pageUrl = $pageUrl;
|
$this->pageUrl = $pageUrl;
|
||||||
|
$this->landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
$this->unifiedData = new UnifiedScrmData();
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getSpiderConfig()
|
protected function getSpiderConfig()
|
||||||
{
|
{
|
||||||
|
$antiBot = $this->buildAntiBotConfig();
|
||||||
|
|
||||||
|
$this->logDebug('huojian spider config', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'landingUrlHint' => $this->landingUrlHint,
|
||||||
|
'sessionKey' => $antiBot['sessionKey'] ?? '',
|
||||||
|
'businessHostHint' => $antiBot['businessHostHint'] ?? '',
|
||||||
|
'cfClearanceRequired' => $antiBot['cfClearanceRequired'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pageUrl' => $this->pageUrl,
|
'pageUrl' => $this->pageUrl,
|
||||||
'apiUrls' => [self::API_LIST],
|
'listApi' => self::API_LIST,
|
||||||
|
|
||||||
// 明确指派角色
|
|
||||||
'listApi' => self::API_LIST, // 必须
|
|
||||||
|
|
||||||
'listMethod' => 'POST',
|
'listMethod' => 'POST',
|
||||||
'paginationMode' => self::MODE_UI,
|
'paginationMode' => self::MODE_UI,
|
||||||
|
|
||||||
'authActions' => [
|
'authActions' => [
|
||||||
// 1. 填入密码:寻找 class 包含 el-message-box__input 下面的任意 input
|
|
||||||
// Node.js 会自动扫描所有匹配项,并只把密码强行注入到那个“肉眼可见”的框里
|
|
||||||
['type' => 'vue_fill', 'selector' => '.el-message-box__input input', 'value' => $this->password],
|
['type' => 'vue_fill', 'selector' => '.el-message-box__input input', 'value' => $this->password],
|
||||||
|
|
||||||
// 2. 停顿 500ms,让 Vue 绑定的 v-model 彻底反应过来
|
|
||||||
['type' => 'wait', 'ms' => 500],
|
['type' => 'wait', 'ms' => 500],
|
||||||
|
|
||||||
// 3. 点击确认:寻找 MessageBox 底部的蓝色 primary 确认按钮
|
|
||||||
// 同样利用 vue_click 的可见性过滤,无视隐藏的旧弹窗按钮
|
|
||||||
['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'],
|
['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'],
|
||||||
|
['type' => 'wait', 'ms' => 2000],
|
||||||
// 4. 等待弹窗淡出,接口开始请求
|
],
|
||||||
['type' => 'wait', 'ms' => 2000]
|
'antiBot' => $antiBot,
|
||||||
]
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只负责解析 List 的总页数
|
/**
|
||||||
|
* 对齐生产 AntiBotConfigBuilder 火箭策略(长链 host 动态解析)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildAntiBotConfig()
|
||||||
|
{
|
||||||
|
$sessionScope = $this->resolveHuojianSessionScope();
|
||||||
|
$businessHostHint = $this->resolveHuojianBusinessHostHint();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => true,
|
||||||
|
'profile' => 'real',
|
||||||
|
'turnstile' => true,
|
||||||
|
'solverFallback' => true,
|
||||||
|
'cfClearanceRequired' => true,
|
||||||
|
'sessionKey' => 'huojian:' . $sessionScope,
|
||||||
|
'challengeTimeoutMs' => 60000,
|
||||||
|
'landingUrlHint' => $this->landingUrlHint,
|
||||||
|
'businessHostHint' => $businessHostHint,
|
||||||
|
'postCfReadyUrlContains' => '/gds',
|
||||||
|
'postCfReadySelector' => '.el-message-box__input',
|
||||||
|
'postCfReadyTimeoutMs' => 45000,
|
||||||
|
'postCfSettleMs' => 500,
|
||||||
|
'postCfSpaWaitMs' => 8000,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isHuojianLongLinkUrl($pageUrl)
|
||||||
|
{
|
||||||
|
$path = (string) parse_url($pageUrl, PHP_URL_PATH);
|
||||||
|
if ($path === '' || strpos($path, '/gds') === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$query = [];
|
||||||
|
parse_str((string) parse_url($pageUrl, PHP_URL_QUERY), $query);
|
||||||
|
|
||||||
|
return trim((string) ($query['link'] ?? '')) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractHuojianShareToken($pageUrl)
|
||||||
|
{
|
||||||
|
if ($this->isHuojianLongLinkUrl($pageUrl)) {
|
||||||
|
$query = [];
|
||||||
|
parse_str((string) parse_url($pageUrl, PHP_URL_QUERY), $query);
|
||||||
|
|
||||||
|
return trim((string) ($query['link'] ?? ''));
|
||||||
|
}
|
||||||
|
$path = trim((string) parse_url($pageUrl, PHP_URL_PATH), '/');
|
||||||
|
if ($path === '' || strpos($path, '/') !== false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (preg_match('/^[a-zA-Z0-9]+$/', $path) !== 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveHuojianSessionScope()
|
||||||
|
{
|
||||||
|
if ($this->isHuojianLongLinkUrl($this->pageUrl)) {
|
||||||
|
return strtolower((string) parse_url($this->pageUrl, PHP_URL_HOST));
|
||||||
|
}
|
||||||
|
$token = $this->extractHuojianShareToken($this->pageUrl);
|
||||||
|
if ($token !== '') {
|
||||||
|
return 'share.' . $token;
|
||||||
|
}
|
||||||
|
if ($this->landingUrlHint !== '' && $this->isHuojianLongLinkUrl($this->landingUrlHint)) {
|
||||||
|
return strtolower((string) parse_url($this->landingUrlHint, PHP_URL_HOST));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveHuojianBusinessHostHint()
|
||||||
|
{
|
||||||
|
if ($this->isHuojianLongLinkUrl($this->pageUrl)) {
|
||||||
|
return strtolower((string) parse_url($this->pageUrl, PHP_URL_HOST));
|
||||||
|
}
|
||||||
|
if ($this->landingUrlHint !== '' && $this->isHuojianLongLinkUrl($this->landingUrlHint)) {
|
||||||
|
return strtolower((string) parse_url($this->landingUrlHint, PHP_URL_HOST));
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||||
{
|
{
|
||||||
// 只有一页
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 没有分页返回空数组
|
|
||||||
protected function buildListPageParams($page)
|
protected function buildListPageParams($page)
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提供 List 的下一页按钮信息
|
|
||||||
protected function getUiPaginationConfig()
|
protected function getUiPaginationConfig()
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清爽至极的数据清洗:详情是详情,列表是列表
|
|
||||||
protected function parseToUnifiedData($detailData, $allListPagesData)
|
protected function parseToUnifiedData($detailData, $allListPagesData)
|
||||||
{
|
{
|
||||||
$unifiedData = $this->unifiedData;
|
$unifiedData = $this->unifiedData;
|
||||||
|
|
||||||
$unifiedData->todayNewCount = $allListPagesData[0]['data']['counterWorker']['newTodayFriend'];
|
$unifiedData->todayNewCount = (int) ($allListPagesData[0]['data']['counterWorker']['newTodayFriend'] ?? 0) + (int) ($allListPagesData[0]['data']['counterWorker']['newRemovedTodayFriend'] ?? 0);
|
||||||
|
|
||||||
// 2. 循环合并了所有页数的 List 数组
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($allListPagesData as $pageRaw) {
|
foreach ($allListPagesData as $pageRaw) {
|
||||||
$records = $pageRaw['data']['counterCsAccountVo'] ?? [];
|
$records = $pageRaw['data']['counterCsAccountVo'] ?? [];
|
||||||
foreach ($records as $item) {
|
foreach ($records as $item) {
|
||||||
if (!empty($item['accountLogin'])) {
|
if (!empty($item['accountLogin'])) {
|
||||||
$number = $item['accountLogin'] ?? null;
|
$number = $item['accountLogin'];
|
||||||
$isOnline = (isset($item['accountStatus']) && $item['accountStatus'] == 1);
|
$isOnline = (isset($item['accountStatus']) && (int) $item['accountStatus'] === 1);
|
||||||
$count++;
|
$count++;
|
||||||
$unifiedData->addNumber($number, $isOnline, $item['newTodayFriend']);
|
$unifiedData->addNumber($number, $isOnline, (int) ($item['newTodayFriend'] ?? 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+131
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短链 HTTP 重定向预解析(与 puppeteer-api/url-resolve.js 行为对齐)
|
||||||
|
*
|
||||||
|
* 用于 antiBot.sessionKey 按落地业务域生成,工单 URL 仍可为 yyk.ink 等短链。
|
||||||
|
*/
|
||||||
|
final class SplitPageUrlResolver
|
||||||
|
{
|
||||||
|
private const REDIRECT_CODES = [301, 302, 303, 307, 308];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟随 HTTP 重定向,返回最终 URL;失败时返回原始 URL
|
||||||
|
*/
|
||||||
|
public static function resolveFinalUrl(string $pageUrl, int $maxRedirects = 10, int $timeoutSec = 12): string
|
||||||
|
{
|
||||||
|
$current = trim($pageUrl);
|
||||||
|
if ($current === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($i = 0; $i < $maxRedirects; $i++) {
|
||||||
|
$response = self::fetchHeadOrGet($current, $timeoutSec);
|
||||||
|
if ($response === null) {
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $response['code'];
|
||||||
|
$location = $response['location'];
|
||||||
|
if (!in_array($code, self::REDIRECT_CODES, true) || $location === '') {
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = self::resolveRelativeUrl($current, $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{code: int, location: string}|null
|
||||||
|
*/
|
||||||
|
private static function fetchHeadOrGet(string $url, int $timeoutSec): ?array
|
||||||
|
{
|
||||||
|
$result = self::curlOnce($url, true, $timeoutSec);
|
||||||
|
if ($result !== null) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
return self::curlOnce($url, false, $timeoutSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{code: int, location: string}|null
|
||||||
|
*/
|
||||||
|
private static function curlOnce(string $url, bool $headOnly, int $timeoutSec): ?array
|
||||||
|
{
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_NOBODY => $headOnly,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
CURLOPT_TIMEOUT => $timeoutSec,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; SplitPageUrlResolver/1.0)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
if ($body === false) {
|
||||||
|
curl_close($ch);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$redirectUrl = (string) curl_getinfo($ch, CURLINFO_REDIRECT_URL);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$location = $redirectUrl;
|
||||||
|
if ($location === '' && is_string($body)) {
|
||||||
|
$location = self::parseLocationHeader($body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['code' => $code, 'location' => trim($location)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parseLocationHeader(string $rawHeaders): string
|
||||||
|
{
|
||||||
|
foreach (preg_split('/\r\n|\n|\r/', $rawHeaders) as $line) {
|
||||||
|
if (stripos($line, 'Location:') === 0) {
|
||||||
|
return trim(substr($line, 9));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveRelativeUrl(string $baseUrl, string $location): string
|
||||||
|
{
|
||||||
|
if (preg_match('#^https?://#i', $location)) {
|
||||||
|
return $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = parse_url($baseUrl);
|
||||||
|
if (!is_array($base)) {
|
||||||
|
return $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = (string) ($base['scheme'] ?? 'https');
|
||||||
|
$host = (string) ($base['host'] ?? '');
|
||||||
|
if ($host === '') {
|
||||||
|
return $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($location, '/') === 0) {
|
||||||
|
return $scheme . '://' . $host . $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = (string) ($base['path'] ?? '/');
|
||||||
|
$dir = rtrim(str_replace(basename($path), '', $path), '/');
|
||||||
|
|
||||||
|
return $scheme . '://' . $host . ($dir !== '' ? $dir . '/' : '/') . $location;
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+178
-10
@@ -7,8 +7,12 @@ class Whatshub extends AbstractScrmSpider
|
|||||||
{
|
{
|
||||||
const API_LIST = '/api/whatshub-counter/workShare/open/detail'; // 列表API地址
|
const API_LIST = '/api/whatshub-counter/workShare/open/detail'; // 列表API地址
|
||||||
const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics'; // 详情API地址
|
const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics'; // 详情API地址
|
||||||
|
/** shareCode + password 直连接口(一次返回全量号码,无需翻页) */
|
||||||
|
const API_DETAIL_BY_SHARE_CODE = '/api/whatshub-counter/workShare/open/detailByShareCode';
|
||||||
const API_COUNT = ''; // 总数API地址
|
const API_COUNT = ''; // 总数API地址
|
||||||
const DEFAULT_PER_PAGE_COUNT = 20; // List默认每页显示的数量
|
const DEFAULT_PER_PAGE_COUNT = 20; // List默认每页显示的数量
|
||||||
|
const API_FETCH_TIMEOUT_SEC = 20;
|
||||||
|
|
||||||
private $pageUrl;
|
private $pageUrl;
|
||||||
private $account;
|
private $account;
|
||||||
private $password;
|
private $password;
|
||||||
@@ -25,6 +29,165 @@ class Whatshub extends AbstractScrmSpider
|
|||||||
$this->unifiedData = new UnifiedScrmData();
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 优先:detailByShareCode 直抓;失败则回退 Node 爬虫
|
||||||
|
*/
|
||||||
|
public function run()
|
||||||
|
{
|
||||||
|
$apiResult = $this->tryFetchViaShareCodeApi();
|
||||||
|
if ($apiResult instanceof UnifiedScrmData) {
|
||||||
|
$this->logDebug('whatshub api path ok', ['count' => count($apiResult->numbers)]);
|
||||||
|
return $apiResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logDebug('whatshub api skipped or failed, fallback node', []);
|
||||||
|
return parent::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从短链 pageUrl 解析 shareCode,例如 /m/KMYBBInp2066/1 → KMYBBInp2066
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private function extractShareCodeFromPageUrl()
|
||||||
|
{
|
||||||
|
$path = (string) parse_url($this->pageUrl, PHP_URL_PATH);
|
||||||
|
if ($path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (preg_match('#/m/([^/]+)/#', $path, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接 detailByShareCode 完整 GET URL
|
||||||
|
*
|
||||||
|
* @param string $shareCode
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private function buildShareCodeApiUrl($shareCode)
|
||||||
|
{
|
||||||
|
$parsed = parse_url($this->pageUrl);
|
||||||
|
$scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
|
||||||
|
$host = isset($parsed['host']) ? $parsed['host'] : '';
|
||||||
|
if ($host === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = http_build_query([
|
||||||
|
'shareCode' => $shareCode,
|
||||||
|
'password' => $this->password,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $scheme . '://' . $host . self::API_DETAIL_BY_SHARE_CODE . '?' . $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试通过 shareCode API 抓取;成功返回 UnifiedScrmData,否则返回 null
|
||||||
|
*
|
||||||
|
* @return UnifiedScrmData|null
|
||||||
|
*/
|
||||||
|
private function tryFetchViaShareCodeApi()
|
||||||
|
{
|
||||||
|
if ($this->password === '' || $this->password === null) {
|
||||||
|
$this->logDebug('whatshub api skip: empty password', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shareCode = $this->extractShareCodeFromPageUrl();
|
||||||
|
if ($shareCode === null || $shareCode === '') {
|
||||||
|
$this->logDebug('whatshub api skip: cannot parse shareCode', ['pageUrl' => $this->pageUrl]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiUrl = $this->buildShareCodeApiUrl($shareCode);
|
||||||
|
if ($apiUrl === null) {
|
||||||
|
$this->logDebug('whatshub api skip: invalid pageUrl host', ['pageUrl' => $this->pageUrl]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logDebug('whatshub api request', ['url' => preg_replace('/password=[^&]+/', 'password=***', $apiUrl)]);
|
||||||
|
|
||||||
|
$ch = curl_init($apiUrl);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_TIMEOUT => self::API_FETCH_TIMEOUT_SEC,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Accept: application/json',
|
||||||
|
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlErr = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false || $curlErr !== '') {
|
||||||
|
$this->logDebug('whatshub api curl failed', ['error' => $curlErr]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
$this->logDebug('whatshub api http error', ['httpCode' => $httpCode]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($response, true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
$this->logDebug('whatshub api invalid json', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($payload['code'] ?? 0) !== 200) {
|
||||||
|
$this->logDebug('whatshub api business error', [
|
||||||
|
'code' => $payload['code'] ?? null,
|
||||||
|
'msg' => $payload['msg'] ?? '',
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($payload['data']) || !is_array($payload['data'])) {
|
||||||
|
$this->logDebug('whatshub api invalid data field', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parseShareCodeApiResponse($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 detailByShareCode 响应映射为 UnifiedScrmData
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return UnifiedScrmData
|
||||||
|
*/
|
||||||
|
private function parseShareCodeApiResponse(array $payload)
|
||||||
|
{
|
||||||
|
$unifiedData = new UnifiedScrmData();
|
||||||
|
$todayNewSum = 0;
|
||||||
|
|
||||||
|
foreach ($payload['data'] as $item) {
|
||||||
|
if (!is_array($item) || empty($item['account'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$number = (string) $item['account'];
|
||||||
|
$isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1;
|
||||||
|
$dayNewFans = (int) ($item['dayNewFans'] ?? 0);
|
||||||
|
|
||||||
|
$unifiedData->addNumber($number, $isOnline, $dayNewFans);
|
||||||
|
$todayNewSum += $dayNewFans;
|
||||||
|
}
|
||||||
|
|
||||||
|
$unifiedData->todayNewCount = $todayNewSum;
|
||||||
|
$unifiedData->total = count($unifiedData->numbers);
|
||||||
|
|
||||||
|
return $unifiedData;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getSpiderConfig()
|
protected function getSpiderConfig()
|
||||||
{
|
{
|
||||||
$host = (string) parse_url($this->pageUrl, PHP_URL_HOST);
|
$host = (string) parse_url($this->pageUrl, PHP_URL_HOST);
|
||||||
@@ -33,14 +196,14 @@ class Whatshub extends AbstractScrmSpider
|
|||||||
'apiUrls' => [self::API_LIST, self::API_DETAILS],
|
'apiUrls' => [self::API_LIST, self::API_DETAILS],
|
||||||
|
|
||||||
// 明确指派角色
|
// 明确指派角色
|
||||||
'listApi' => self::API_LIST, // 必须
|
'listApi' => self::API_LIST,
|
||||||
|
'detailApi' => self::API_DETAILS,
|
||||||
|
|
||||||
'listMethod' => 'POST',
|
'listMethod' => 'POST',
|
||||||
'paginationMode' => self::MODE_UI,
|
'paginationMode' => self::MODE_UI,
|
||||||
|
|
||||||
'authActions' => [
|
'authActions' => [
|
||||||
// 1. 填入密码:寻找 class 包含 el-message-box__input 下面的任意 input
|
// Node antiBot.postCfReady* 会在 CF 成功后等待 work-order-sharing + .el-input__inner
|
||||||
// Node.js 会自动扫描所有匹配项,并只把密码强行注入到那个“肉眼可见”的框里
|
|
||||||
['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password],
|
['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password],
|
||||||
|
|
||||||
// 2. 停顿 500ms,让 Vue 绑定的 v-model 彻底反应过来
|
// 2. 停顿 500ms,让 Vue 绑定的 v-model 彻底反应过来
|
||||||
@@ -51,16 +214,21 @@ class Whatshub extends AbstractScrmSpider
|
|||||||
['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'],
|
['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'],
|
||||||
|
|
||||||
// 4. 等待弹窗淡出,接口开始请求
|
// 4. 等待弹窗淡出,接口开始请求
|
||||||
['type' => 'wait', 'ms' => 2200]
|
['type' => 'wait', 'ms' => 2200],
|
||||||
],
|
],
|
||||||
// Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用
|
// Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用
|
||||||
|
// sessionKey 必须与 pageUrl 的 host 一致,例如 web.whatshub.cc
|
||||||
'antiBot' => [
|
'antiBot' => [
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
'profile' => 'real',
|
'profile' => 'real',
|
||||||
'turnstile' => true,
|
'turnstile' => true,
|
||||||
'solverFallback' => true,
|
'solverFallback' => true,
|
||||||
'sessionKey' => 'whatshub:' . $host,
|
'challengeTimeoutMs' => 40000,
|
||||||
'challengeTimeoutMs' => 60000,
|
'postCfReadyUrlContains' => 'work-order-sharing',
|
||||||
|
'postCfReadySelector' => '.el-input__inner',
|
||||||
|
'postCfReadyTimeoutMs' => 30000,
|
||||||
|
'postCfSettleMs' => 500,
|
||||||
|
'postCfSpaWaitMs' => 3000,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -69,7 +237,7 @@ class Whatshub extends AbstractScrmSpider
|
|||||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||||
{
|
{
|
||||||
$default_per_page_count = self::DEFAULT_PER_PAGE_COUNT;
|
$default_per_page_count = self::DEFAULT_PER_PAGE_COUNT;
|
||||||
$this->unifiedData->total = $listFirstPageData['data']['total'];
|
$this->unifiedData->total = $listFirstPageData['total'];
|
||||||
|
|
||||||
if ($this->unifiedData->total <= $default_per_page_count) {
|
if ($this->unifiedData->total <= $default_per_page_count) {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -89,7 +257,7 @@ class Whatshub extends AbstractScrmSpider
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'nextBtnSelector' => '.vxe-pager--next-btn',
|
'nextBtnSelector' => '.vxe-pager--next-btn',
|
||||||
'waitMs' => 1500
|
'waitMs' => 1500,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +273,7 @@ class Whatshub extends AbstractScrmSpider
|
|||||||
|
|
||||||
// 2. 循环合并了所有页数的 List 数组
|
// 2. 循环合并了所有页数的 List 数组
|
||||||
foreach ($allListPagesData as $pageRaw) {
|
foreach ($allListPagesData as $pageRaw) {
|
||||||
$records = $pageRaw['data']['rows'] ?? [];
|
$records = $pageRaw['rows'] ?? [];
|
||||||
foreach ($records as $item) {
|
foreach ($records as $item) {
|
||||||
if (!empty($item['account'])) {
|
if (!empty($item['account'])) {
|
||||||
$number = $item['account'] ?? null;
|
$number = $item['account'] ?? null;
|
||||||
@@ -116,7 +284,7 @@ class Whatshub extends AbstractScrmSpider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🚀 3. 终极统计:所有翻页数据均已入库,此时再统计真实的 Total 总数
|
// 3. 终极统计:所有翻页数据均已入库,此时再统计真实的 Total 总数
|
||||||
$unifiedData->total = count($unifiedData->numbers);
|
$unifiedData->total = count($unifiedData->numbers);
|
||||||
|
|
||||||
return $unifiedData;
|
return $unifiedData;
|
||||||
|
|||||||
Regular → Executable
+73
-65
@@ -11,50 +11,52 @@ require_once __DIR__ . '/SsCustomer.php'; // SS云控(Customer)
|
|||||||
require_once __DIR__ . '/Haiwang.php'; // 海王
|
require_once __DIR__ . '/Haiwang.php'; // 海王
|
||||||
require_once __DIR__ . '/Whatshub.php'; // Whatshub 工单平台
|
require_once __DIR__ . '/Whatshub.php'; // Whatshub 工单平台
|
||||||
|
|
||||||
try {
|
// 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";
|
// 命令行可以测试Cloudflare防火墙
|
||||||
$pageUrl = 'https://web.whatshub.cc/m/iTYsWKQH5030/1'; // PageUrl 入口授权页
|
// curl -s -X POST http://127.0.0.1:3001/api/auth-and-intercept \
|
||||||
$username = ""; // 登录账号
|
// -H 'Content-Type: application/json' \
|
||||||
$password = "745030"; // 登录密码
|
// -d '{
|
||||||
$spider = new Whatshub($pageUrl, $username, $password);
|
// "pageUrl": "https://web.whatshub.cc/m/KMYBBInp2066/1",
|
||||||
$finalData = $spider->run();
|
// "apiUrls": ["/api/whatshub-counter/workShare/open/detail"],
|
||||||
|
// "authActions": [
|
||||||
|
// {"type": "wait", "ms": 1000},
|
||||||
|
// {"type": "vue_fill", "selector": ".el-input__wrapper > .el-input__inner", "value": "602066"},
|
||||||
|
// {"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:web.whatshub.cc",
|
||||||
|
// "challengeTimeoutMs": 60000
|
||||||
|
// }
|
||||||
|
// }' | jq .
|
||||||
|
// */
|
||||||
|
|
||||||
echo "✅ 任务完成!统一数据如下:\n\r";
|
// echo "🚀 开始执行<Whatshub 工单平台>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
echo "----------------------------------------\n\r";
|
// $pageUrl = 'https://web.whatshub.cc/m/KMYBBInp2066/1'; // PageUrl 入口授权页
|
||||||
echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
// $username = ""; // 登录账号
|
||||||
echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
// $password = "602066"; // 登录密码
|
||||||
echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
// $spider = new Whatshub($pageUrl, $username, $password);
|
||||||
echo "Total:" . $finalData->total . " 个号码\n\r";
|
// $finalData = $spider->run();
|
||||||
echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
|
||||||
echo "号码列表:\n\r";
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
echo dd($finalData->numbers);
|
// echo "----------------------------------------\n\r";
|
||||||
} catch (Exception $e) {
|
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
echo "🚨 抓取异常:" . $e->getMessage() . "\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 {
|
||||||
@@ -78,27 +80,30 @@ try {
|
|||||||
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// try {
|
try {
|
||||||
// echo "🚀 开始执行<A2c云控>抓取任务 (多引擎智能调度)...\n\r";
|
echo "🚀 开始执行<A2c云控>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
// $pageUrl = 'https://yyk.ink/8415O53'; // PageUrl 入口授权页
|
// 长链:快速验证业务页拦截(推荐先测通)
|
||||||
// $username = ""; // 登录账号
|
// $pageUrl = 'https://user.a2c.chat/visitors/counter/share?id=1b2cf9a91e2647c185b252e723c871de';
|
||||||
// $password = ""; // 登录密码
|
// 短链:与客户后台一致,上线前必须回归
|
||||||
// $spider = new A2c($pageUrl, $username, $password);
|
$pageUrl = 'https://yyk.ink/5H3O8b';
|
||||||
// $finalData = $spider->run();
|
$username = "";
|
||||||
|
$password = "";
|
||||||
|
$spider = (new A2c($pageUrl, $username, $password))->setVerbose(true);
|
||||||
|
$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 "🚀 开始执行<星河云控>抓取任务 (多引擎智能调度)...\n\r";
|
// echo "🚀 开始执行<星河云控>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
@@ -123,10 +128,13 @@ try {
|
|||||||
|
|
||||||
// try {
|
// try {
|
||||||
// echo "🚀 开始执行<火箭工单>抓取任务 (多引擎智能调度)...\n\r";
|
// echo "🚀 开始执行<火箭工单>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
// $pageUrl = 'https://s.url99.me/68vfje8m'; // PageUrl 入口授权页
|
// // 长链(业务域动态,如 v3.url66.me)
|
||||||
// $username = ""; // 登录账号
|
// // $pageUrl = 'https://v3.url66.me/gds?link=6a387e66e4b09e9d162cd647';
|
||||||
// $password = "542187"; // 登录密码
|
// // 短链(与客户后台一致)
|
||||||
// $spider = new Huojian($pageUrl, $username, $password);
|
// $pageUrl = 'https://s.url99.me/50q3622j';
|
||||||
|
// $username = "";
|
||||||
|
// $password = "你的分享密码";
|
||||||
|
// $spider = (new Huojian($pageUrl, $username, $password))->setVerbose(true);
|
||||||
// $finalData = $spider->run();
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
// echo "✅ 任务完成!统一数据如下:\n\r";
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
|||||||
Executable
+210
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 'ON');
|
||||||
|
echo 1111;exit;
|
||||||
|
// 文件名: run.php
|
||||||
|
// require_once __DIR__ . '/Chatknow.php'; // ChatKnow SCRM
|
||||||
|
require_once __DIR__ . '/A2c.php'; // A2c云控
|
||||||
|
// require_once __DIR__ . '/Xinghe2.php'; // 星河云控
|
||||||
|
require_once __DIR__ . '/Huojian.php'; // 火箭
|
||||||
|
// require_once __DIR__ . '/SsCustomer.php'; // SS云控(Customer)
|
||||||
|
// require_once __DIR__ . '/Haiwang.php'; // 海王
|
||||||
|
// require_once __DIR__ . '/Whatshub.php'; // Whatshub 工单平台
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// /*
|
||||||
|
// 命令行可以测试Cloudflare防火墙
|
||||||
|
// curl -s -X POST http://127.0.0.1:3001/api/auth-and-intercept \
|
||||||
|
// -H 'Content-Type: application/json' \
|
||||||
|
// -d '{
|
||||||
|
// "pageUrl": "https://web.whatshub.cc/m/iTYsWKQH5030/1",
|
||||||
|
// "apiUrls": ["/api/whatshub-counter/workShare/open/detail"],
|
||||||
|
// "authActions": [
|
||||||
|
// {"type": "vue_fill", "selector": ".el-input__inner", "value": "745030"},
|
||||||
|
// {"type": "wait", "ms": 500},
|
||||||
|
// {"type": "vue_click", "selector": ".vxe-button-group .theme--primary"},
|
||||||
|
// {"type": "wait", "ms": 2200}
|
||||||
|
// ],
|
||||||
|
// "antiBot": {
|
||||||
|
// "enabled": true,
|
||||||
|
// "profile": "real",
|
||||||
|
// "turnstile": true,
|
||||||
|
// "solverFallback": true,
|
||||||
|
// "sessionKey": "whatshub:t.flowerbells.top",
|
||||||
|
// "challengeTimeoutMs": 60000
|
||||||
|
// }
|
||||||
|
// }' | jq .
|
||||||
|
// */
|
||||||
|
|
||||||
|
// echo "🚀 开始执行<Whatshub 工单平台>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
// $pageUrl = 'https://web.whatshub.cc/m/iTYsWKQH5030/1'; // PageUrl 入口授权页
|
||||||
|
// $username = ""; // 登录账号
|
||||||
|
// $password = "745030"; // 登录密码
|
||||||
|
// $spider = new Whatshub($pageUrl, $username, $password);
|
||||||
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
// echo "----------------------------------------\n\r";
|
||||||
|
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
|
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
|
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
|
// echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
|
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
|
// echo "号码列表:\n\r";
|
||||||
|
// echo dd($finalData->numbers);
|
||||||
|
// } catch (Exception $e) {
|
||||||
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// echo "🚀 开始执行<ChatKnow SCRM>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
// $pageUrl = 'https://user.chatknow.com/child/workorder-share?shareKey=jf5t6MNrJ2mC76N'; // PageUrl 入口授权页
|
||||||
|
// $username = ""; // 登录账号
|
||||||
|
// $password = ""; // 登录密码
|
||||||
|
// $spider = new Chatknow($pageUrl, $username, $password);
|
||||||
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
// echo "----------------------------------------\n\r";
|
||||||
|
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
|
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
|
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
|
// echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
|
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
|
// echo "号码列表:\n\r";
|
||||||
|
// echo dd($finalData->numbers);
|
||||||
|
// } catch (Exception $e) {
|
||||||
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
|
// }
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// echo "🚀 开始执行<A2c云控>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
// $pageUrl = 'https://yyk.ink/52oXZRG'; // PageUrl 入口授权页
|
||||||
|
// $username = ""; // 登录账号
|
||||||
|
// $password = ""; // 登录密码
|
||||||
|
// $spider = new A2c($pageUrl, $username, $password);
|
||||||
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
// echo "----------------------------------------\n\r";
|
||||||
|
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
|
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
|
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
|
// echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
|
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
|
// echo "号码列表:\n\r";
|
||||||
|
// echo dd($finalData->numbers);
|
||||||
|
|
||||||
|
// } catch (Exception $e) {
|
||||||
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
|
// }
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// echo "🚀 开始执行<星河云控>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
// $pageUrl = 'http://8.218.14.51/share/share/index.html?token=hn6z3egq4nnnebkv4063pcdouzr4ug5js902mlqo2n3yp9gzhe'; // PageUrl 入口授权页
|
||||||
|
// $username = ""; // 登录账号
|
||||||
|
// $password = ""; // 登录密码
|
||||||
|
// $spider = new Xinghe($pageUrl, $username, $password);
|
||||||
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
// echo "----------------------------------------\n\r";
|
||||||
|
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
|
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
|
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
|
// echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
|
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
|
// echo "号码列表:\n\r";
|
||||||
|
// echo dd($finalData->numbers);
|
||||||
|
// } catch (Exception $e) {
|
||||||
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
|
// }
|
||||||
|
|
||||||
|
try {
|
||||||
|
echo "🚀 开始执行<火箭工单>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
$pageUrl = 'https://s.url99.me/50q3622j'; // PageUrl 入口授权页
|
||||||
|
$username = ""; // 登录账号
|
||||||
|
$password = "666666"; // 登录密码
|
||||||
|
$spider = new Huojian($pageUrl, $username, $password);
|
||||||
|
$finalData = $spider->run();
|
||||||
|
|
||||||
|
echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
echo "----------------------------------------\n\r";
|
||||||
|
echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
|
echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
|
echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
|
echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
|
echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
|
echo "号码列表:\n\r";
|
||||||
|
echo dd($finalData->numbers);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
|
}
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// echo "🚀 开始执行<SS云控(Customer)>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
// $pageUrl = 'https://app.salesmartly.vip/next/share/reports/verify/customer/fwt7cg'; // PageUrl 入口授权页
|
||||||
|
// $username = ""; // 登录账号
|
||||||
|
// $password = "123456"; // 登录密码
|
||||||
|
// $spider = new SsCustomer($pageUrl, $username, $password);
|
||||||
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
// echo "----------------------------------------\n\r";
|
||||||
|
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
|
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
|
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
|
// echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
|
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
|
// echo "号码列表:\n\r";
|
||||||
|
// echo dd($finalData->numbers);
|
||||||
|
|
||||||
|
|
||||||
|
// } catch (Exception $e) {
|
||||||
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
|
// }
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// echo "🚀 开始执行<海王>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
// $pageUrl = 'https://admin.haiwangweb.com/web#/accountshow/pZsEulYrb'; // PageUrl 入口授权页
|
||||||
|
// $username = ""; // 登录账号
|
||||||
|
// $password = "9999"; // 登录密码
|
||||||
|
// $spider = new Haiwang($pageUrl, $username, $password);
|
||||||
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
// echo "----------------------------------------\n\r";
|
||||||
|
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
|
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
|
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
|
// echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
|
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
|
// echo "号码列表:\n\r";
|
||||||
|
// echo dd($finalData->numbers);
|
||||||
|
|
||||||
|
// } catch (Exception $e) {
|
||||||
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
|
// }
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// echo "🚀 开始执行<CEO SCRM>抓取任务 (多引擎智能调度)...\n\r";
|
||||||
|
// $pageUrl = 'https://admin.scrmceo.com/#/workShareDetail?code=XgGTK5yN'; // PageUrl 入口授权页
|
||||||
|
// $username = ""; // 登录账号
|
||||||
|
// $password = "a2222"; // 登录密码
|
||||||
|
// $spider = new SsCustomer($pageUrl, $username, $password);
|
||||||
|
// $finalData = $spider->run();
|
||||||
|
|
||||||
|
// echo "✅ 任务完成!统一数据如下:\n\r";
|
||||||
|
// echo "----------------------------------------\n\r";
|
||||||
|
// echo "当日新增:{$finalData->todayNewCount} 人\n\r";
|
||||||
|
// echo "在线号码:{$finalData->totalOnline} 个\n\r";
|
||||||
|
// echo "离线号码:{$finalData->totalOffline} 个\n\r";
|
||||||
|
// echo "Total:" . $finalData->total . " 个号码\n\r";
|
||||||
|
// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r";
|
||||||
|
// echo "号码列表:\n\r";
|
||||||
|
// echo dd($finalData->numbers);
|
||||||
|
// } catch (Exception $e) {
|
||||||
|
// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r";
|
||||||
|
// }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 双通道定时同步:Node 通道上限与候选池配置(已有环境增量执行)
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_max_per_cron', 'split', 'Node通道单次同步上限', '每分钟 cron 在 Node 通道最多处理几条到期工单(仅影响需调用 Node 的类型);纯 PHP 直连类型不受此限制', 'number', '', '5', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_node_candidate_multiplier', 'split', 'Node通道候选池倍数', 'Node 候选工单数 = 单次上限 × 本倍数,用于公平轮转', 'number', '', '10', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_node_candidate_multiplier' LIMIT 1);
|
||||||
|
|
||||||
|
UPDATE `fa_config`
|
||||||
|
SET `title` = 'Node通道单次同步上限',
|
||||||
|
`tip` = '每分钟 cron 在 Node 通道最多处理几条到期工单(仅影响需调用 Node 的类型);纯 PHP 直连类型不受此限制'
|
||||||
|
WHERE `name` = 'split_sync_max_per_cron';
|
||||||
|
|
||||||
|
UPDATE `fa_config`
|
||||||
|
SET `value` = '5'
|
||||||
|
WHERE `name` = 'split_sync_max_per_cron' AND (`value` = '' OR `value` = '2');
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 号码管理:清理无效降权池权限节点
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/cleanupdeferred', '清理降权池', 'fa fa-circle-o', '', '清除无落地页访问基线的无效 ratio_deferred 标记', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferred' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- 可选:一次性修复历史脏数据(与后台「清理降权池」逻辑一致)
|
||||||
|
-- UPDATE `fa_split_number`
|
||||||
|
-- SET `ratio_deferred` = 0, `ratio_deferred_at` = NULL,
|
||||||
|
-- `no_inbound_click_streak` = IF(`visit_count` <= 0, 0, `no_inbound_click_streak`)
|
||||||
|
-- WHERE `ratio_deferred` = 1
|
||||||
|
-- AND (`visit_count` <= 0 OR `visit_count` <= `last_sync_visit_count`);
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/cleanupdeferredpreview', '清理降权池预览', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferredpreview' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 下号比率触线降权:访问侧标记 ratio_deferred,同步侧裁决 status
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_number`
|
||||||
|
ADD COLUMN `ratio_deferred` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '下号比率触线降权:0=否,1=是(仍status=normal,选号时后置)' AFTER `no_inbound_click_streak`,
|
||||||
|
ADD COLUMN `ratio_deferred_at` bigint(16) DEFAULT NULL COMMENT '触线降权标记时间' AFTER `ratio_deferred`;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- 同步失败分阶段重试:系统配置 + 工单状态字段
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_fail_retry_minutes', 'split', '失败重试时间点(分钟)', '逗号分隔、升序,相对首次达失败暂停阈值后的绝对分钟数,如 10,20,30,50;留空则达阈值后永久暂停自动同步', 'string', '', '', '', '', ' placeholder="10,20,30,50"', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_retry_minutes' LIMIT 1);
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD COLUMN `sync_fail_pause_at` bigint(16) DEFAULT NULL COMMENT '达阈值进入重试的锚点时间' AFTER `sync_fail_count`,
|
||||||
|
ADD COLUMN `sync_fail_retry_index` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '已完成的重试次数(0=尚未重试)' AFTER `sync_fail_pause_at`,
|
||||||
|
ADD COLUMN `sync_auto_sync_stopped` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '1=全部重试失败,永久终止自动同步' AFTER `sync_fail_retry_index`;
|
||||||
@@ -20,12 +20,14 @@ CREATE TABLE `fa_split_ticket` (
|
|||||||
`account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号',
|
`account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号',
|
||||||
`password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码',
|
`password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码',
|
||||||
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
|
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
|
||||||
|
`manual_manage` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '手动管理:0=否,1=是(手动关闭后暂停同步且禁止自动开启)',
|
||||||
`complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)',
|
`complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)',
|
||||||
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)',
|
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)',
|
||||||
`speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)',
|
`speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)',
|
||||||
`number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)',
|
`number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)',
|
||||||
`number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)',
|
`number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)',
|
||||||
`number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)',
|
`number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)',
|
||||||
|
`active_number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '本地开启号码数(status=normal)',
|
||||||
`online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)',
|
`online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)',
|
||||||
`sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态',
|
`sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态',
|
||||||
`sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间',
|
`sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间',
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 工单表:本地开启号码数(status=normal),列表直接读字段,避免子查询
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD COLUMN `active_number_count` int(10) unsigned NOT NULL DEFAULT 0
|
||||||
|
COMMENT '本地开启号码数(status=normal)' AFTER `number_banned_count`;
|
||||||
|
|
||||||
|
-- 历史数据回填
|
||||||
|
UPDATE `fa_split_ticket` t
|
||||||
|
SET t.`active_number_count` = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM `fa_split_number` n
|
||||||
|
WHERE n.`admin_id` = t.`admin_id`
|
||||||
|
AND n.`split_link_id` = t.`split_link_id`
|
||||||
|
AND n.`ticket_name` = t.`ticket_name`
|
||||||
|
AND n.`status` = 'normal'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 加速按工单统计 status=normal 数量
|
||||||
|
ALTER TABLE `fa_split_number`
|
||||||
|
ADD KEY `idx_ticket_active` (`admin_id`, `split_link_id`, `ticket_name`(50), `status`);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 工单手动管理:手动关闭后禁止自动同步与自动开启
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD COLUMN `manual_manage` tinyint(1) unsigned NOT NULL DEFAULT 0
|
||||||
|
COMMENT '手动管理:0=否,1=是(手动关闭后暂停同步且禁止自动开启)' AFTER `status`;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 工单同步日志增强:最近成功时间/方式、完整失败原因
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
MODIFY COLUMN `sync_message` text COMMENT '同步失败完整原因',
|
||||||
|
ADD COLUMN `sync_success_time` bigint(16) DEFAULT NULL COMMENT '最近一次同步成功时间' AFTER `sync_time`,
|
||||||
|
ADD COLUMN `sync_success_mode` enum('auto','manual') DEFAULT NULL COMMENT '最近成功同步方式:auto=自动,manual=手动' AFTER `sync_success_time`;
|
||||||
|
|
||||||
|
-- 历史成功记录回填最近成功时间(方式未知留空)
|
||||||
|
UPDATE `fa_split_ticket`
|
||||||
|
SET `sync_success_time` = `sync_time`
|
||||||
|
WHERE `sync_status` = 'success'
|
||||||
|
AND `sync_success_time` IS NULL
|
||||||
|
AND `sync_time` IS NOT NULL
|
||||||
|
AND `sync_time` > 0;
|
||||||
@@ -18,6 +18,7 @@ use think\console\Output;
|
|||||||
* 用法:
|
* 用法:
|
||||||
* php think split:sync-tickets
|
* php think split:sync-tickets
|
||||||
* php think split:sync-tickets --ticket=12
|
* php think split:sync-tickets --ticket=12
|
||||||
|
* php think split:sync-tickets --ticket=12 --mode=auto
|
||||||
*/
|
*/
|
||||||
class SplitSyncTickets extends Command
|
class SplitSyncTickets extends Command
|
||||||
{
|
{
|
||||||
@@ -25,21 +26,24 @@ class SplitSyncTickets extends Command
|
|||||||
{
|
{
|
||||||
$this->setName('split:sync-tickets')
|
$this->setName('split:sync-tickets')
|
||||||
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
|
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
|
||||||
|
->addOption('mode', 'm', Option::VALUE_OPTIONAL, '同步方式: auto=定时自动, manual=手动触发', 'manual')
|
||||||
->setDescription('同步分流工单云控数据');
|
->setDescription('同步分流工单云控数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(Input $input, Output $output): void
|
protected function execute(Input $input, Output $output): void
|
||||||
{
|
{
|
||||||
set_time_limit(0);
|
set_time_limit(0);
|
||||||
|
$syncMode = $this->resolveSyncMode($input);
|
||||||
SplitTicketSyncLogger::log('cli', 'command start', [
|
SplitTicketSyncLogger::log('cli', 'command start', [
|
||||||
'ticketOption' => trim((string) $input->getOption('ticket')),
|
'ticketOption' => trim((string) $input->getOption('ticket')),
|
||||||
|
'syncMode' => $syncMode,
|
||||||
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
||||||
]);
|
]);
|
||||||
$service = new SplitTicketSyncService();
|
$service = new SplitTicketSyncService();
|
||||||
$ticketId = trim((string) $input->getOption('ticket'));
|
$ticketId = trim((string) $input->getOption('ticket'));
|
||||||
|
|
||||||
if ($ticketId !== '' && ctype_digit($ticketId)) {
|
if ($ticketId !== '' && ctype_digit($ticketId)) {
|
||||||
$result = $service->syncOne((int) $ticketId, true);
|
$result = $service->syncOne((int) $ticketId, true, false, $syncMode);
|
||||||
if (!empty($result['skipped'])) {
|
if (!empty($result['skipped'])) {
|
||||||
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
|
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
|
||||||
return;
|
return;
|
||||||
@@ -61,13 +65,24 @@ class SplitSyncTickets extends Command
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$count = $service->syncDueTickets();
|
$count = $service->syncDueTickets();
|
||||||
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
$output->writeln('<info>本次投递工单数: ' . $count . '</info>');
|
||||||
|
$output->writeln('<comment>同步在后台 CLI 进程中执行,汇总日志见 runtime/log/split_sync.log</comment>');
|
||||||
} finally {
|
} finally {
|
||||||
$cronLock->release();
|
$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>');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CLI 同步方式,供 sync_success_mode 落库
|
||||||
|
*/
|
||||||
|
private function resolveSyncMode(Input $input): string
|
||||||
|
{
|
||||||
|
$mode = strtolower(trim((string) $input->getOption('mode')));
|
||||||
|
|
||||||
|
return in_array($mode, ['auto', 'manual'], true) ? $mode : 'manual';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace app\admin\controller\split;
|
|||||||
use app\admin\model\split\Link as LinkModel;
|
use app\admin\model\split\Link as LinkModel;
|
||||||
use app\admin\model\split\Number as NumberModel;
|
use app\admin\model\split\Number as NumberModel;
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
|
use app\common\service\SplitTicketActiveNumberCountService;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Exception;
|
use think\Exception;
|
||||||
use think\exception\DbException;
|
use think\exception\DbException;
|
||||||
@@ -65,6 +66,7 @@ class Number extends Backend
|
|||||||
$this->assignconfig('statusList', $this->model->getStatusList());
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
||||||
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
||||||
|
$this->assignconfig('ratioDeferredList', $this->model->getRatioDeferredList());
|
||||||
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
|
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
|
||||||
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
|
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
|
||||||
|
|
||||||
@@ -224,6 +226,7 @@ class Number extends Backend
|
|||||||
$this->error($e->getMessage());
|
$this->error($e->getMessage());
|
||||||
}
|
}
|
||||||
if ($count > 0) {
|
if ($count > 0) {
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshForNumberIds(explode(',', (string) $ids));
|
||||||
$this->success();
|
$this->success();
|
||||||
}
|
}
|
||||||
$this->error(__('No rows were updated'));
|
$this->error(__('No rows were updated'));
|
||||||
@@ -330,6 +333,12 @@ class Number extends Backend
|
|||||||
$this->error($e->getMessage());
|
$this->error($e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshForTicketKeys(
|
||||||
|
(int) ($baseRow['admin_id'] ?? $adminId),
|
||||||
|
$splitLinkId,
|
||||||
|
(string) ($baseRow['ticket_name'] ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
$msg = __('Inserted %d number(s)', $inserted);
|
$msg = __('Inserted %d number(s)', $inserted);
|
||||||
if ($skipped > 0) {
|
if ($skipped > 0) {
|
||||||
$msg .= ',' . __('Skipped %d duplicate(s)', $skipped);
|
$msg .= ',' . __('Skipped %d duplicate(s)', $skipped);
|
||||||
@@ -377,6 +386,7 @@ class Number extends Backend
|
|||||||
}
|
}
|
||||||
|
|
||||||
$result = false;
|
$result = false;
|
||||||
|
$before = $row->getData();
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
if ($this->modelValidate) {
|
if ($this->modelValidate) {
|
||||||
@@ -396,9 +406,50 @@ class Number extends Backend
|
|||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
$this->error(__('No rows were updated'));
|
$this->error(__('No rows were updated'));
|
||||||
}
|
}
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshAfterNumberEdit($before, $row->getData());
|
||||||
$this->success();
|
$this->success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除号码后回写工单本地开启号码数
|
||||||
|
*
|
||||||
|
* @param string|null $ids
|
||||||
|
*/
|
||||||
|
public function del($ids = null)
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
$ids = $ids ?: $this->request->post('ids');
|
||||||
|
if (empty($ids)) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||||
|
}
|
||||||
|
$pk = $this->model->getPk();
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$this->model->where($this->dataLimitField, 'in', $adminIds);
|
||||||
|
}
|
||||||
|
$list = $this->model->where($pk, 'in', $ids)->select();
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$countService = new SplitTicketActiveNumberCountService();
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
foreach ($list as $item) {
|
||||||
|
$count += $item->delete();
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
if ($count) {
|
||||||
|
$countService->refreshFromNumberRows($list);
|
||||||
|
$this->success();
|
||||||
|
}
|
||||||
|
$this->error(__('No rows were deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量更新号码状态与手动管理
|
* 批量更新号码状态与手动管理
|
||||||
*/
|
*/
|
||||||
@@ -443,6 +494,7 @@ class Number extends Backend
|
|||||||
if ($count === false || $count === 0) {
|
if ($count === false || $count === 0) {
|
||||||
$this->error(__('No rows were updated'));
|
$this->error(__('No rows were updated'));
|
||||||
}
|
}
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshForNumberIds($ids);
|
||||||
$this->success(__('Batch update success'));
|
$this->success(__('Batch update success'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +547,8 @@ class Number extends Backend
|
|||||||
}
|
}
|
||||||
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
|
$countService = new SplitTicketActiveNumberCountService();
|
||||||
|
$keyRows = (clone $query)->field('admin_id,split_link_id,ticket_name')->select();
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
if ($action === 'delete') {
|
if ($action === 'delete') {
|
||||||
@@ -522,9 +576,51 @@ class Number extends Backend
|
|||||||
$this->error(__('No matching numbers found'));
|
$this->error(__('No matching numbers found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$countService->refreshFromNumberRows($keyRows);
|
||||||
$this->success(sprintf(__('Batch operate success'), $count));
|
$this->success(sprintf(__('Batch operate success'), $count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览待清理的无效降权号码数量
|
||||||
|
*/
|
||||||
|
public function cleanupdeferredpreview(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isAjax()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new \app\common\service\SplitNumberRatioDeferredService();
|
||||||
|
$adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null;
|
||||||
|
$count = $service->countInvalidDeferred(is_array($adminIds) ? $adminIds : null);
|
||||||
|
|
||||||
|
$this->success('', null, ['count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理无效降权号码池(无落地页访问基线却标记 ratio_deferred=1 的记录)
|
||||||
|
*/
|
||||||
|
public function cleanupdeferred(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new \app\common\service\SplitNumberRatioDeferredService();
|
||||||
|
$adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$count = $service->cleanupInvalidDeferred(is_array($adminIds) ? $adminIds : null);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($count <= 0) {
|
||||||
|
$this->success(__('Ratio deferred cleanup none'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success(sprintf(__('Ratio deferred cleanup success'), $count));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 排除不可由表单提交的字段
|
* 排除不可由表单提交的字段
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use app\admin\model\split\Link as LinkModel;
|
|||||||
use app\admin\model\split\Ticket as TicketModel;
|
use app\admin\model\split\Ticket as TicketModel;
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
use app\common\service\SplitTicketRuleService;
|
use app\common\service\SplitTicketRuleService;
|
||||||
|
use app\common\service\SplitTicketSyncDispatchService;
|
||||||
use app\common\service\SplitTicketSyncLogger;
|
use app\common\service\SplitTicketSyncLogger;
|
||||||
use app\common\service\SplitTicketSyncService;
|
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Lang;
|
use think\Lang;
|
||||||
use think\Loader;
|
use think\Loader;
|
||||||
@@ -39,7 +39,7 @@ class Ticket extends Backend
|
|||||||
protected $modelSceneValidate = true;
|
protected $modelSceneValidate = true;
|
||||||
|
|
||||||
/** @var string[] 无需鉴权的方法 */
|
/** @var string[] 无需鉴权的方法 */
|
||||||
protected $noNeedRight = ['script'];
|
protected $noNeedRight = ['script', 'syncpolling'];
|
||||||
|
|
||||||
/** @var string patches 视图目录 */
|
/** @var string patches 视图目录 */
|
||||||
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/';
|
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/';
|
||||||
@@ -64,6 +64,7 @@ class Ticket extends Backend
|
|||||||
'syncBackgroundStartedMsg' => __('Sync background started'),
|
'syncBackgroundStartedMsg' => __('Sync background started'),
|
||||||
'syncInProgressMsg' => __('Sync in progress'),
|
'syncInProgressMsg' => __('Sync in progress'),
|
||||||
'syncTicketStartedMsg' => __('Sync ticket started'),
|
'syncTicketStartedMsg' => __('Sync ticket started'),
|
||||||
|
'syncDoneMsg' => __('Sync done'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->setupPatchFrontend();
|
$this->setupPatchFrontend();
|
||||||
@@ -267,22 +268,39 @@ class Ticket extends Backend
|
|||||||
$params['inbound_count'],
|
$params['inbound_count'],
|
||||||
$params['speed_per_hour'],
|
$params['speed_per_hour'],
|
||||||
$params['number_count'],
|
$params['number_count'],
|
||||||
|
$params['active_number_count'],
|
||||||
$params['number_offline_count'],
|
$params['number_offline_count'],
|
||||||
$params['number_banned_count'],
|
$params['number_banned_count'],
|
||||||
$params['online_count'],
|
$params['online_count'],
|
||||||
$params['sync_status'],
|
$params['sync_status'],
|
||||||
$params['sync_time'],
|
$params['sync_time'],
|
||||||
$params['sync_message'],
|
$params['sync_message'],
|
||||||
|
$params['sync_success_time'],
|
||||||
|
$params['sync_success_mode'],
|
||||||
$params['sync_fail_count'],
|
$params['sync_fail_count'],
|
||||||
|
$params['sync_fail_pause_at'],
|
||||||
|
$params['sync_fail_retry_index'],
|
||||||
|
$params['sync_auto_sync_stopped'],
|
||||||
$params['speed_snapshot_count'],
|
$params['speed_snapshot_count'],
|
||||||
$params['speed_snapshot_time'],
|
$params['speed_snapshot_time'],
|
||||||
$params['click_count']
|
$params['click_count'],
|
||||||
|
$params['manual_manage']
|
||||||
);
|
);
|
||||||
return $params;
|
return $params;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动同步选中工单
|
* 用户切换工单状态时写入 manual_manage,防止同步自动恢复开启
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $values
|
||||||
|
*/
|
||||||
|
private function applyManualManageForStatusChange(array &$values): void
|
||||||
|
{
|
||||||
|
(new SplitTicketRuleService())->applyManualManageForStatusChange($values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动同步选中工单(投递 CLI 后台执行,Web 请求立即返回)
|
||||||
*/
|
*/
|
||||||
public function sync(): void
|
public function sync(): void
|
||||||
{
|
{
|
||||||
@@ -305,31 +323,84 @@ class Ticket extends Backend
|
|||||||
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = new SplitTicketSyncService();
|
/** @var int[] $allowedIds */
|
||||||
$ok = 0;
|
$allowedIds = [];
|
||||||
$fail = 0;
|
$denied = 0;
|
||||||
$messages = [];
|
|
||||||
|
|
||||||
foreach ($list as $row) {
|
foreach ($list as $row) {
|
||||||
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||||
$fail++;
|
$denied++;
|
||||||
$messages[] = '#' . $row['id'] . ': 无权限';
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$result = $service->syncOne((int) $row['id'], true);
|
$allowedIds[] = (int) $row['id'];
|
||||||
if ($result['success']) {
|
}
|
||||||
$ok++;
|
|
||||||
} else {
|
if ($allowedIds === []) {
|
||||||
$fail++;
|
$this->error(__('You have no permission'));
|
||||||
$messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败');
|
}
|
||||||
|
|
||||||
|
// 尽早释放 Session 锁,避免投递等待期间阻塞同账号其它后台请求(如编辑/新增弹窗)
|
||||||
|
if (function_exists('session_write_close')) {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatch = new SplitTicketSyncDispatchService();
|
||||||
|
$result = $dispatch->dispatchManual($allowedIds);
|
||||||
|
|
||||||
|
$queuedCount = count($result['queued']);
|
||||||
|
$skippedCount = count($result['skipped']);
|
||||||
|
$failedCount = count($result['failed']);
|
||||||
|
|
||||||
|
if ($queuedCount === 0) {
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$this->error(__('Sync all skipped busy'));
|
||||||
|
}
|
||||||
|
$this->error(__('Sync dispatch failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = sprintf(__('Sync dispatch queued'), $queuedCount);
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$summary .= sprintf(__('Sync dispatch skipped suffix'), $skippedCount);
|
||||||
|
}
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$summary .= sprintf(__('Sync dispatch failed suffix'), $failedCount);
|
||||||
|
}
|
||||||
|
if ($denied > 0) {
|
||||||
|
$summary .= sprintf(__('Sync dispatch denied suffix'), $denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success($summary, null, [
|
||||||
|
'queued' => $result['queued'],
|
||||||
|
'syncing' => $result['queued'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询手工同步进度(依据 runtime 文件锁)
|
||||||
|
*/
|
||||||
|
public function syncpolling(): void
|
||||||
|
{
|
||||||
|
$idsRaw = trim((string) $this->request->request('ids', ''));
|
||||||
|
if ($idsRaw === '') {
|
||||||
|
$this->success('', null, ['syncing' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var int[] $ticketIds */
|
||||||
|
$ticketIds = [];
|
||||||
|
foreach (explode(',', $idsRaw) as $part) {
|
||||||
|
$part = trim($part);
|
||||||
|
if ($part !== '' && ctype_digit($part)) {
|
||||||
|
$ticketIds[] = (int) $part;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail);
|
if ($ticketIds === []) {
|
||||||
if ($fail > 0) {
|
$this->success('', null, ['syncing' => []]);
|
||||||
$summary .= ';' . implode(';', array_slice($messages, 0, 5));
|
|
||||||
}
|
}
|
||||||
$this->success($summary);
|
|
||||||
|
$dispatch = new SplitTicketSyncDispatchService();
|
||||||
|
$syncing = $dispatch->filterSyncingIds($ticketIds);
|
||||||
|
|
||||||
|
$this->success('', null, ['syncing' => $syncing]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -352,6 +423,7 @@ class Ticket extends Backend
|
|||||||
$ruleService = new SplitTicketRuleService();
|
$ruleService = new SplitTicketRuleService();
|
||||||
|
|
||||||
if (isset($values['status'])) {
|
if (isset($values['status'])) {
|
||||||
|
$this->applyManualManageForStatusChange($values);
|
||||||
$pk = $this->model->getPk();
|
$pk = $this->model->getPk();
|
||||||
$adminIds = $this->getDataLimitAdminIds();
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
$query = $this->model->where($pk, 'in', $ids);
|
$query = $this->model->where($pk, 'in', $ids);
|
||||||
@@ -526,6 +598,9 @@ class Ticket extends Backend
|
|||||||
$params['number_type_custom'] = '';
|
$params['number_type_custom'] = '';
|
||||||
}
|
}
|
||||||
$oldStatus = (string) ($row['status'] ?? 'hidden');
|
$oldStatus = (string) ($row['status'] ?? 'hidden');
|
||||||
|
if (isset($params['status']) && (string) $params['status'] !== $oldStatus) {
|
||||||
|
$this->applyManualManageForStatusChange($params);
|
||||||
|
}
|
||||||
$result = false;
|
$result = false;
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -51,4 +51,20 @@ return [
|
|||||||
'Invalid manual manage' => '手动管理选项无效',
|
'Invalid manual manage' => '手动管理选项无效',
|
||||||
'Batch update success' => '批量更新成功',
|
'Batch update success' => '批量更新成功',
|
||||||
'Unit count' => '个',
|
'Unit count' => '个',
|
||||||
|
'Ratio_deferred' => '选号降权',
|
||||||
|
'Ratio deferred badge' => '降权中',
|
||||||
|
'Ratio deferred no' => '正常选号',
|
||||||
|
'Ratio deferred yes' => '降权中',
|
||||||
|
'Ratio deferred filter' => '仅显示降权中号码',
|
||||||
|
'Ratio deferred tooltip'=> '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决',
|
||||||
|
'Ratio deferred edit notice' => '该号码处于下号比率触线降权状态,同步后将根据进线情况恢复或关闭',
|
||||||
|
'No inbound click streak' => '无进线连续点击',
|
||||||
|
'Ratio deferred cleanup btn' => '清理降权池',
|
||||||
|
'Ratio deferred cleanup confirm' => '将清除 %d 条无效降权记录(无落地页访问或访问未超过同步基线)。确定继续?',
|
||||||
|
'Ratio deferred cleanup success' => '已清理 %d 条无效降权记录',
|
||||||
|
'Ratio deferred cleanup none' => '当前没有需要清理的无效降权记录',
|
||||||
|
'Stream loading' => '加载中...',
|
||||||
|
'Stream loaded' => '已加载 %loaded% / %total%,继续滚动加载更多',
|
||||||
|
'Stream loading bottom' => '加载中...',
|
||||||
|
'Stream loading pending' => '即将加载更多,可切换显示条数取消...',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -31,12 +31,29 @@ return [
|
|||||||
'Sync in progress' => '同步中',
|
'Sync in progress' => '同步中',
|
||||||
'Sync ticket started' => '正在同步工单',
|
'Sync ticket started' => '正在同步工单',
|
||||||
'Sync done' => '同步完成',
|
'Sync done' => '同步完成',
|
||||||
|
'Sync dispatch queued' => '已提交 %d 条同步任务到后台执行',
|
||||||
|
'Sync dispatch skipped suffix' => ',%d 条已在同步中已跳过',
|
||||||
|
'Sync dispatch failed suffix' => ',%d 条启动失败',
|
||||||
|
'Sync dispatch denied suffix' => ',%d 条无权限未提交',
|
||||||
|
'Sync all skipped busy' => '所选工单均在同步中,请稍后再试',
|
||||||
|
'Sync dispatch failed' => '未能启动同步任务,请检查服务器是否允许后台执行 CLI(exec/shell_exec)',
|
||||||
'Sync status success' => '同步成功',
|
'Sync status success' => '同步成功',
|
||||||
'Sync status error' => '同步异常',
|
'Sync status error' => '同步异常',
|
||||||
'Sync status pending' => '待同步',
|
'Sync status pending' => '待同步',
|
||||||
'Sync display success' => '同步成功 / 在线 %s',
|
'Sync display success' => '同步成功 / 在线 %s',
|
||||||
'Sync display pending' => '待同步',
|
'Sync display pending' => '待同步',
|
||||||
'Sync display error' => '同步异常',
|
'Sync display error' => '同步异常',
|
||||||
|
'Sync display auto paused' => '自动同步已暂停',
|
||||||
|
'Sync display auto sync stopped' => '自动同步已终止(重试已用尽)',
|
||||||
|
'Sync display retry waiting' => '自动同步已暂停,将于 %s 第 %d 次重试',
|
||||||
|
'Sync success time' => '最近同步成功',
|
||||||
|
'Sync mode auto' => '自动',
|
||||||
|
'Sync mode manual' => '手动',
|
||||||
|
'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停',
|
||||||
|
'Sync tooltip auto sync stopped' => '分阶段重试已全部失败,自动同步已永久终止;手动同步成功后可恢复',
|
||||||
|
'Sync tooltip retry waiting' => '连续失败 %s 次,将于 %s 进行第 %d 次自动重试',
|
||||||
|
'Sync tooltip error prefix' => '异常原因:',
|
||||||
|
'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log',
|
||||||
'Createtime' => '创建时间',
|
'Createtime' => '创建时间',
|
||||||
'Copy' => '拷贝',
|
'Copy' => '拷贝',
|
||||||
'Summary row' => '汇总',
|
'Summary row' => '汇总',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class Number extends Model
|
|||||||
'status_text',
|
'status_text',
|
||||||
'manual_manage_text',
|
'manual_manage_text',
|
||||||
'platform_status_text',
|
'platform_status_text',
|
||||||
|
'ratio_deferred_text',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +82,17 @@ class Number extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getRatioDeferredList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'0' => __('Ratio deferred no'),
|
||||||
|
'1' => __('Ratio deferred yes'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -158,6 +170,13 @@ class Number extends Model
|
|||||||
return $list[$key] ?? $key;
|
return $list[$key] ?? $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRatioDeferredTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ((int) ($data['ratio_deferred'] ?? 0));
|
||||||
|
$list = $this->getRatioDeferredList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据链接码生成完整分流 URL
|
* 根据链接码生成完整分流 URL
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\admin\model\split;
|
namespace app\admin\model\split;
|
||||||
|
|
||||||
|
use app\common\service\SplitSyncConfigService;
|
||||||
|
use app\common\service\SplitTicketRuleService;
|
||||||
|
use app\common\service\SplitTicketSyncFailRetryService;
|
||||||
|
use app\common\service\SplitTicketSyncLockService;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Model;
|
use think\Model;
|
||||||
|
|
||||||
@@ -14,6 +18,15 @@ class Ticket extends Model
|
|||||||
{
|
{
|
||||||
protected $name = 'split_ticket';
|
protected $name = 'split_ticket';
|
||||||
|
|
||||||
|
protected static function init(): void
|
||||||
|
{
|
||||||
|
// 删除工单时:释放同步锁,并物理删除该工单关联的全部号码
|
||||||
|
self::beforeDelete(function (self $ticket): void {
|
||||||
|
(new SplitTicketSyncLockService())->release((int) $ticket['id']);
|
||||||
|
(new SplitTicketRuleService())->deleteSyncedNumbersForTicket($ticket);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected $autoWriteTimestamp = 'integer';
|
protected $autoWriteTimestamp = 'integer';
|
||||||
|
|
||||||
protected $createTime = 'createtime';
|
protected $createTime = 'createtime';
|
||||||
@@ -31,6 +44,9 @@ class Ticket extends Model
|
|||||||
'ticket_progress_text',
|
'ticket_progress_text',
|
||||||
'inbound_ratio_text',
|
'inbound_ratio_text',
|
||||||
'sync_display_text',
|
'sync_display_text',
|
||||||
|
'sync_success_text',
|
||||||
|
'sync_tooltip_text',
|
||||||
|
'sync_auto_paused',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,13 +168,65 @@ class Ticket extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步状态展示文案
|
* 是否因连续失败暂停自动同步(工单仍为开启状态)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function isAutoSyncPaused(array $data): bool
|
||||||
|
{
|
||||||
|
if ((string) ($data['status'] ?? '') !== 'normal') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$retryService = new SplitTicketSyncFailRetryService();
|
||||||
|
if ($retryService->isPermanentlyStopped($data)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($retryService->isInRetryMode($data)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
if ($threshold <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (SplitSyncConfigService::hasFailRetrySchedule()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($data['sync_fail_count'] ?? 0) >= $threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态展示文案(列表列默认显示,异常详情见 tooltip)
|
||||||
*
|
*
|
||||||
* @param mixed $value
|
* @param mixed $value
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
public function getSyncDisplayTextAttr($value, $data): string
|
public function getSyncDisplayTextAttr($value, $data): string
|
||||||
{
|
{
|
||||||
|
$retryService = new SplitTicketSyncFailRetryService();
|
||||||
|
|
||||||
|
if ($retryService->isPermanentlyStopped($data)) {
|
||||||
|
return (string) __('Sync display auto sync stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($retryService->isInRetryMode($data)) {
|
||||||
|
$nextAt = $retryService->getNextRetryAt($data);
|
||||||
|
$attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1;
|
||||||
|
if ($nextAt !== null) {
|
||||||
|
return sprintf(
|
||||||
|
(string) __('Sync display retry waiting'),
|
||||||
|
date('H:i', $nextAt),
|
||||||
|
$attempt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isAutoSyncPaused($data)) {
|
||||||
|
return (string) __('Sync display auto paused');
|
||||||
|
}
|
||||||
|
|
||||||
$status = (string) ($data['sync_status'] ?? 'pending');
|
$status = (string) ($data['sync_status'] ?? 'pending');
|
||||||
$online = (int) ($data['online_count'] ?? 0);
|
$online = (int) ($data['online_count'] ?? 0);
|
||||||
if ($status === 'success') {
|
if ($status === 'success') {
|
||||||
@@ -170,6 +238,81 @@ class Ticket extends Model
|
|||||||
return (string) __('Sync display error');
|
return (string) __('Sync display error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态列 tooltip 完整说明
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncTooltipTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
$retryService = new SplitTicketSyncFailRetryService();
|
||||||
|
|
||||||
|
if ($retryService->isPermanentlyStopped($data)) {
|
||||||
|
$parts[] = (string) __('Sync tooltip auto sync stopped');
|
||||||
|
} elseif ($retryService->isInRetryMode($data)) {
|
||||||
|
$nextAt = $retryService->getNextRetryAt($data);
|
||||||
|
$attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1;
|
||||||
|
if ($nextAt !== null) {
|
||||||
|
$parts[] = sprintf(
|
||||||
|
(string) __('Sync tooltip retry waiting'),
|
||||||
|
(int) ($data['sync_fail_count'] ?? 0),
|
||||||
|
date('Y-m-d H:i:s', $nextAt),
|
||||||
|
$attempt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} elseif (self::isAutoSyncPaused($data)) {
|
||||||
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
$parts[] = sprintf(
|
||||||
|
(string) __('Sync tooltip auto paused'),
|
||||||
|
(int) ($data['sync_fail_count'] ?? 0),
|
||||||
|
$threshold
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = trim((string) ($data['sync_message'] ?? ''));
|
||||||
|
if ($message !== '') {
|
||||||
|
$parts[] = (string) __('Sync tooltip error prefix') . $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否暂停自动同步(供前端样式判断)
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncAutoPausedAttr($value, $data): bool
|
||||||
|
{
|
||||||
|
return self::isAutoSyncPaused($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次同步成功时间及方式
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncSuccessTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$ts = (int) ($data['sync_success_time'] ?? 0);
|
||||||
|
if ($ts <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$timeText = date('Y-m-d H:i:s', $ts);
|
||||||
|
$mode = (string) ($data['sync_success_mode'] ?? '');
|
||||||
|
if ($mode === 'auto') {
|
||||||
|
return $timeText . ' (' . (string) __('Sync mode auto') . ')';
|
||||||
|
}
|
||||||
|
if ($mode === 'manual') {
|
||||||
|
return $timeText . ' (' . (string) __('Sync mode manual') . ')';
|
||||||
|
}
|
||||||
|
return $timeText;
|
||||||
|
}
|
||||||
|
|
||||||
public function setStartTimeAttr($value): ?int
|
public function setStartTimeAttr($value): ?int
|
||||||
{
|
{
|
||||||
return self::parseTimeValue($value);
|
return self::parseTimeValue($value);
|
||||||
|
|||||||
@@ -123,6 +123,13 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
{if isset($row)}
|
{if isset($row)}
|
||||||
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
||||||
|
{if isset($row.ratio_deferred) && $row.ratio_deferred==1 && isset($row.status) && $row.status=='normal'}
|
||||||
|
<div class="alert alert-warning split-ratio-deferred-notice" style="margin-bottom:10px;padding:8px 12px;font-size:12px;">
|
||||||
|
<i class="fa fa-level-down"></i>
|
||||||
|
{:__('Ratio deferred edit notice')}
|
||||||
|
({:__('No inbound click streak')}:{$row.no_inbound_click_streak|default=0})
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
||||||
{else}
|
{else}
|
||||||
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||||
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||||
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-default btn-filter-ratio-deferred" title="{:__('Ratio deferred filter')}"><i class="fa fa-level-down text-warning"></i> {:__('Ratio deferred badge')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-warning btn-cleanup-ratio-deferred {:$auth->check('split.number/cleanupdeferred')?'':'hide'}" title="{:__('Ratio deferred cleanup btn')}"><i class="fa fa-eraser"></i> {:__('Ratio deferred cleanup btn')}</a>
|
||||||
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
|
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
|
||||||
</div>
|
</div>
|
||||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
|
|||||||
@@ -67,6 +67,24 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
.split-sync-success-normal {
|
||||||
|
color: #3c763d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.split-sync-success-stopped {
|
||||||
|
color: #a94442;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.split-ticket-sync-status-paused {
|
||||||
|
color: #8a6d3b;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted #8a6d3b;
|
||||||
|
}
|
||||||
|
.split-ticket-sync-status-error {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted #a94442;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="panel panel-default panel-intro">
|
<div class="panel panel-default panel-intro">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
|
|||||||
@@ -68,13 +68,18 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$antiBot = $config['antiBot'] ?? null;
|
$antiBot = $config['antiBot'] ?? null;
|
||||||
|
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
|
||||||
|
|
||||||
|
if ($this->shouldUseUnifiedBrowserPath($antiBot, $mode)) {
|
||||||
|
return $this->runUnifiedUiPath($config, $antiBot, $apiUrlsToIntercept, $listApi, $detailApi, $countApi);
|
||||||
|
}
|
||||||
|
|
||||||
$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,
|
'antiBot' => $antiBot,
|
||||||
]);
|
], $this->resolveAuthInterceptTimeout($antiBot));
|
||||||
|
|
||||||
if (empty($initResult['success'])) {
|
if (empty($initResult['success'])) {
|
||||||
$cfCode = (string) ($initResult['code'] ?? '');
|
$cfCode = (string) ($initResult['code'] ?? '');
|
||||||
@@ -97,6 +102,8 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [
|
SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [
|
||||||
'intercepted' => array_keys($interceptedApis),
|
'intercepted' => array_keys($interceptedApis),
|
||||||
'finalPageUrl' => $finalPageUrl,
|
'finalPageUrl' => $finalPageUrl,
|
||||||
|
'cfStage' => $initResult['cf']['turnstileStage'] ?? ($initResult['cf']['stage'] ?? null),
|
||||||
|
'browserReused' => $initResult['browserReused'] ?? null,
|
||||||
]);
|
]);
|
||||||
$cookies = $initResult['cookies'];
|
$cookies = $initResult['cookies'];
|
||||||
|
|
||||||
@@ -113,7 +120,6 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
$allListPagesData = [$listApiNode['data']];
|
$allListPagesData = [$listApiNode['data']];
|
||||||
|
|
||||||
$totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
|
$totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
|
||||||
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
|
|
||||||
SplitTicketSyncLogger::log('spider', 'pagination plan', [
|
SplitTicketSyncLogger::log('spider', 'pagination plan', [
|
||||||
'totalPages' => $totalPages,
|
'totalPages' => $totalPages,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
@@ -182,6 +188,124 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单 Browser 路径:auth + 拦截 + UI 翻页(需 antiBot.sessionKey)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $config
|
||||||
|
* @param array<string, mixed>|null $antiBot
|
||||||
|
* @param list<string> $apiUrlsToIntercept
|
||||||
|
*/
|
||||||
|
private function runUnifiedUiPath(
|
||||||
|
array $config,
|
||||||
|
?array $antiBot,
|
||||||
|
array $apiUrlsToIntercept,
|
||||||
|
string $listApi,
|
||||||
|
?string $detailApi,
|
||||||
|
?string $countApi
|
||||||
|
): UnifiedScrmData {
|
||||||
|
$uiConfig = $this->getUiPaginationConfig();
|
||||||
|
$sessionKey = is_array($antiBot) ? (string) ($antiBot['sessionKey'] ?? '') : '';
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'unified browser path', [
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
'listApi' => $listApi,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mergedResult = $this->requestNode('/api/auth-intercept-and-paginate', [
|
||||||
|
'pageUrl' => $config['pageUrl'],
|
||||||
|
'apiUrls' => $apiUrlsToIntercept,
|
||||||
|
'authActions' => $config['authActions'] ?? [],
|
||||||
|
'antiBot' => $antiBot,
|
||||||
|
'pagination' => [
|
||||||
|
'apiUrl' => $listApi,
|
||||||
|
'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '',
|
||||||
|
'waitMs' => $uiConfig['waitMs'] ?? 2000,
|
||||||
|
'clicksToPerform' => 9999,
|
||||||
|
],
|
||||||
|
], 1200);
|
||||||
|
|
||||||
|
if (empty($mergedResult['success'])) {
|
||||||
|
$cfCode = (string) ($mergedResult['code'] ?? '');
|
||||||
|
SplitTicketSyncLogger::log('spider', 'auth-intercept-and-paginate failed', [
|
||||||
|
'error' => $mergedResult['error'] ?? '未知',
|
||||||
|
'code' => $cfCode !== '' ? $cfCode : null,
|
||||||
|
'stage' => $mergedResult['stage'] ?? null,
|
||||||
|
'cf' => $mergedResult['cf'] ?? null,
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
]);
|
||||||
|
if ($cfCode === 'CF_TURNSTILE_FAILED') {
|
||||||
|
throw new Exception('Cloudflare Turnstile 验证失败: ' . ($mergedResult['stage'] ?? 'timeout'));
|
||||||
|
}
|
||||||
|
throw new Exception('合并同步失败: ' . (string) ($mergedResult['error'] ?? '未知'));
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'auth-intercept-and-paginate ok', [
|
||||||
|
'intercepted' => array_keys($mergedResult['interceptedApis'] ?? []),
|
||||||
|
'extraPages' => count($mergedResult['extraPages'] ?? []),
|
||||||
|
'cfStage' => $mergedResult['cf']['turnstileStage'] ?? ($mergedResult['cf']['stage'] ?? null),
|
||||||
|
'browserReused' => $mergedResult['browserReused'] ?? null,
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$interceptedApis = $mergedResult['interceptedApis'] ?? [];
|
||||||
|
if (!isset($interceptedApis[$listApi])) {
|
||||||
|
throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
$detailData = $detailApi && isset($interceptedApis[$detailApi])
|
||||||
|
? $interceptedApis[$detailApi]['data'] : null;
|
||||||
|
$countData = $countApi && isset($interceptedApis[$countApi])
|
||||||
|
? $interceptedApis[$countApi]['data'] : null;
|
||||||
|
|
||||||
|
$listApiNode = $interceptedApis[$listApi];
|
||||||
|
$allListPagesData = [$listApiNode['data']];
|
||||||
|
if (!empty($mergedResult['extraPages']) && is_array($mergedResult['extraPages'])) {
|
||||||
|
foreach ($mergedResult['extraPages'] as $pageData) {
|
||||||
|
$allListPagesData[] = $pageData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->extractListTotalPages($listApiNode['data'], $countData);
|
||||||
|
|
||||||
|
$result = $this->parseToUnifiedData($detailData, $allListPagesData);
|
||||||
|
SplitTicketSyncLogger::log('spider', 'run done', [
|
||||||
|
'todayNewCount' => $result->todayNewCount,
|
||||||
|
'totalOnline' => $result->totalOnline,
|
||||||
|
'totalOffline' => $result->totalOffline,
|
||||||
|
'total' => $result->total,
|
||||||
|
'numberCount' => count($result->numbers),
|
||||||
|
'unifiedPath' => true,
|
||||||
|
]);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $antiBot
|
||||||
|
*/
|
||||||
|
protected function shouldUseUnifiedBrowserPath(?array $antiBot, string $mode): bool
|
||||||
|
{
|
||||||
|
if ($mode !== self::MODE_UI) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!is_array($antiBot) || empty($antiBot['enabled'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !empty($antiBot['sessionKey']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real Browser + Turnstile 首屏耗时较长,单独放宽 auth-and-intercept 超时
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|null $antiBot
|
||||||
|
*/
|
||||||
|
protected function resolveAuthInterceptTimeout(?array $antiBot): int
|
||||||
|
{
|
||||||
|
if (!is_array($antiBot) || empty($antiBot['enabled'])) {
|
||||||
|
return 120;
|
||||||
|
}
|
||||||
|
return 180;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $payload
|
* @param array<string, mixed> $payload
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
|
|||||||
+402
@@ -0,0 +1,402 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library\scrm;
|
||||||
|
|
||||||
|
use app\common\service\SplitPageUrlResolver;
|
||||||
|
use think\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 云控蜘蛛 antiBot 配置构建器
|
||||||
|
*
|
||||||
|
* 约定:sessionKey = "{ticketType}:{host}";profile=real 当 ticket_type 在 site 配置列表中
|
||||||
|
*/
|
||||||
|
class AntiBotConfigBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 为指定工单类型与落地页 URL 构建 Node antiBot 配置;未启用类型返回 null
|
||||||
|
*
|
||||||
|
* @param string $landingUrlHint HTTP 预解析或历史上次落地的长链(可选)
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public static function build(string $ticketType, string $pageUrl, string $landingUrlHint = ''): ?array
|
||||||
|
{
|
||||||
|
$enabledTypes = self::getEnabledTypes();
|
||||||
|
if (!in_array($ticketType, $enabledTypes, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'a2c' && $landingUrlHint === '') {
|
||||||
|
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'whatshub' && $landingUrlHint === '') {
|
||||||
|
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionScope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint);
|
||||||
|
if ($sessionScope === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionKey = $ticketType . ':' . $sessionScope;
|
||||||
|
$ttlMinutes = self::getSessionTtlMinutes();
|
||||||
|
|
||||||
|
$businessHostHint = self::resolveBusinessHostHint($ticketType, $pageUrl, $landingUrlHint);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => true,
|
||||||
|
'profile' => 'real',
|
||||||
|
'turnstile' => self::defaultTurnstile($ticketType),
|
||||||
|
'solverFallback' => self::defaultSolverFallback($ticketType),
|
||||||
|
'cfClearanceRequired' => self::defaultCfClearanceRequired($ticketType),
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
'challengeTimeoutMs' => 60000,
|
||||||
|
'sessionTtlMinutes' => $ttlMinutes,
|
||||||
|
'businessHostHint' => $businessHostHint,
|
||||||
|
'landingUrlHint' => $landingUrlHint !== '' ? $landingUrlHint : '',
|
||||||
|
'cfCloudflareSolver' => self::defaultCfCloudflareSolver($ticketType),
|
||||||
|
'cfChallengeMode' => self::defaultCfChallengeMode($ticketType),
|
||||||
|
] + self::postCfReadyExtras($ticketType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话 scope:A2C 业务域+分享 id;Whatshub host+shareCode/workId;火箭长链用动态 host
|
||||||
|
*/
|
||||||
|
public static function resolveSessionScope(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
if ($ticketType === 'a2c') {
|
||||||
|
return self::resolveA2cSessionScope($pageUrl, $landingUrlHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'whatshub') {
|
||||||
|
return self::resolveWhatshubSessionScope($pageUrl, $landingUrlHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'huojian') {
|
||||||
|
return self::resolveHuojianSessionScope($pageUrl, $landingUrlHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = strtolower(trim((string) parse_url($pageUrl, PHP_URL_HOST)));
|
||||||
|
|
||||||
|
return $host !== '' ? $host : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 兼容旧调用,等价 resolveSessionScope
|
||||||
|
*/
|
||||||
|
public static function resolveSessionHost(string $ticketType, string $pageUrl): string
|
||||||
|
{
|
||||||
|
return self::resolveSessionScope($ticketType, $pageUrl, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 sessionKey(供 cron 分组调度使用)
|
||||||
|
*/
|
||||||
|
public static function resolveSessionKey(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
if ($ticketType === 'a2c' && $landingUrlHint === '') {
|
||||||
|
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'whatshub' && $landingUrlHint === '') {
|
||||||
|
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint);
|
||||||
|
|
||||||
|
return $ticketType . ':' . ($scope !== '' ? $scope : 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否对该工单类型启用 antiBot
|
||||||
|
*/
|
||||||
|
public static function isEnabledForType(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return in_array($ticketType, self::getEnabledTypes(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function getEnabledTypes(): array
|
||||||
|
{
|
||||||
|
$raw = (string) Config::get('site.split_scrm_antibot_types');
|
||||||
|
if ($raw === '') {
|
||||||
|
return ['whatshub', 'chatknow'];
|
||||||
|
}
|
||||||
|
$parts = array_map('trim', explode(',', $raw));
|
||||||
|
return array_values(array_filter($parts, static function (string $v): bool {
|
||||||
|
return $v !== '';
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSessionTtlMinutes(): int
|
||||||
|
{
|
||||||
|
$minutes = (int) Config::get('site.split_scrm_session_ttl_minutes');
|
||||||
|
return $minutes > 0 ? $minutes : 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按工单类型附加 CF 过盾后的业务页就绪等待
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function postCfReadyExtras(string $ticketType): array
|
||||||
|
{
|
||||||
|
if ($ticketType === 'whatshub') {
|
||||||
|
return [
|
||||||
|
'postCfReadyUrlContains' => 'work-order-sharing',
|
||||||
|
// 业务页表格就绪即可;密码框为 vxe-form,勿用泛化 .el-input__inner
|
||||||
|
'postCfReadySelector' => '.vxe-table, .vxe-grid',
|
||||||
|
'postCfReadySelectorOptional' => true,
|
||||||
|
// statistics 仅在密码验证后触发,禁止 pre-auth 等待(见 Node skipPostCfReadyApi)
|
||||||
|
'postCfReadyTimeoutMs' => 60000,
|
||||||
|
'postCfSettleMs' => 500,
|
||||||
|
'postCfSpaWaitMs' => 4000,
|
||||||
|
'skipShortLinkCfPurgeOnReuse' => true,
|
||||||
|
'authSkipIfNoPasswordDialog' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'a2c') {
|
||||||
|
return [
|
||||||
|
'postCfReadyUrlContains' => '/visitors/counter/share',
|
||||||
|
'postCfReadySelector' => '.el-table',
|
||||||
|
'postCfReadyApiUrl' => '/api/talk/counter/share/record/list',
|
||||||
|
'postCfReadyTimeoutMs' => 60000,
|
||||||
|
'postCfSettleMs' => 500,
|
||||||
|
'postCfSpaWaitMs' => 8000,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'huojian') {
|
||||||
|
return [
|
||||||
|
'postCfReadyUrlContains' => HuojianUrlHelper::BUSINESS_PATH_NEEDLE,
|
||||||
|
'postCfReadySelector' => '.el-message-box__input',
|
||||||
|
'postCfReadyTimeoutMs' => 45000,
|
||||||
|
'postCfSettleMs' => 500,
|
||||||
|
'postCfSpaWaitMs' => 8000,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultTurnstile(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultSolverFallback(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultCfClearanceRequired(string $ticketType): bool
|
||||||
|
{
|
||||||
|
if ($ticketType === 'a2c' || $ticketType === 'whatshub') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2C / Whatshub 等为 CF Managed Challenge(5s 盾),非独立 Turnstile 挂件
|
||||||
|
*/
|
||||||
|
private static function defaultCfChallengeMode(string $ticketType): string
|
||||||
|
{
|
||||||
|
if ($ticketType === 'a2c' || $ticketType === 'whatshub') {
|
||||||
|
return 'managed';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sitekey 缺失时是否允许 CapSolver AntiCloudflareTask(需 Node 侧 CAPTCHA_PROXY)
|
||||||
|
*/
|
||||||
|
private static function defaultCfCloudflareSolver(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return $ticketType === 'a2c' || $ticketType === 'whatshub';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 火箭/A2C 业务域 hint:优先长链动态 host,其次 landing 预解析
|
||||||
|
*/
|
||||||
|
private static function resolveBusinessHostHint(string $ticketType, string $pageUrl, string $landingUrlHint): string
|
||||||
|
{
|
||||||
|
if ($ticketType === 'a2c') {
|
||||||
|
return self::resolveA2cSessionHost($pageUrl, $landingUrlHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'huojian') {
|
||||||
|
$host = HuojianUrlHelper::extractBusinessHost($pageUrl);
|
||||||
|
if ($host !== '') {
|
||||||
|
return $host;
|
||||||
|
}
|
||||||
|
if ($landingUrlHint !== '') {
|
||||||
|
$hintHost = HuojianUrlHelper::extractBusinessHost($landingUrlHint);
|
||||||
|
if ($hintHost !== '') {
|
||||||
|
return $hintHost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whatshub 会话 scope:host + shareCode 或 workId,避免多工单共用温池 Browser
|
||||||
|
*/
|
||||||
|
private static function resolveWhatshubSessionScope(string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
$host = strtolower(trim((string) parse_url($pageUrl, PHP_URL_HOST)));
|
||||||
|
if ($host === '' && $landingUrlHint !== '') {
|
||||||
|
$host = strtolower(trim((string) parse_url($landingUrlHint, PHP_URL_HOST)));
|
||||||
|
}
|
||||||
|
if ($host === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$shareCode = self::extractWhatshubShareCode($pageUrl);
|
||||||
|
if ($shareCode === '' && $landingUrlHint !== '') {
|
||||||
|
$shareCode = self::extractWhatshubShareCode($landingUrlHint);
|
||||||
|
}
|
||||||
|
if ($shareCode !== '') {
|
||||||
|
return $host . ':' . $shareCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workId = self::extractWhatshubWorkId($pageUrl);
|
||||||
|
if ($workId === '' && $landingUrlHint !== '') {
|
||||||
|
$workId = self::extractWhatshubWorkId($landingUrlHint);
|
||||||
|
}
|
||||||
|
if ($workId !== '') {
|
||||||
|
return $host . ':' . $workId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Whatshub 短链 /m/{shareCode}/ 提取 shareCode
|
||||||
|
*/
|
||||||
|
private static function extractWhatshubShareCode(string $url): string
|
||||||
|
{
|
||||||
|
$path = (string) parse_url($url, PHP_URL_PATH);
|
||||||
|
if ($path === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (preg_match('#/m/([^/]+)/#', $path, $matches)) {
|
||||||
|
return trim($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Whatshub 长链 work-order-sharing?workId= 提取 workId
|
||||||
|
*/
|
||||||
|
private static function extractWhatshubWorkId(string $url): string
|
||||||
|
{
|
||||||
|
$query = (string) parse_url($url, PHP_URL_QUERY);
|
||||||
|
if ($query === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
parse_str($query, $params);
|
||||||
|
$workId = isset($params['workId']) ? trim((string) $params['workId']) : '';
|
||||||
|
|
||||||
|
return $workId !== '' ? $workId : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveA2cSessionHost(string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
$candidates = [];
|
||||||
|
if ($landingUrlHint !== '') {
|
||||||
|
$candidates[] = (string) parse_url($landingUrlHint, PHP_URL_HOST);
|
||||||
|
}
|
||||||
|
$candidates[] = (string) parse_url($pageUrl, PHP_URL_HOST);
|
||||||
|
|
||||||
|
foreach ($candidates as $host) {
|
||||||
|
$host = strtolower(trim($host));
|
||||||
|
if ($host !== '' && strpos($host, 'a2c.chat') !== false) {
|
||||||
|
return $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'user.a2c.chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2C 会话 scope:业务域 + 分享 id(避免多工单共用温池 Browser 互相干扰)
|
||||||
|
*
|
||||||
|
* 短链先 HTTP 预解析再提取 ?id=,确保不同分享页不会落到同一 sessionKey。
|
||||||
|
*/
|
||||||
|
private static function resolveA2cSessionScope(string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
$resolvedUrl = self::resolveA2cResolvedUrl($pageUrl, $landingUrlHint);
|
||||||
|
$host = self::resolveA2cSessionHost($pageUrl, $resolvedUrl);
|
||||||
|
$shareId = self::extractA2cShareId($pageUrl);
|
||||||
|
if ($shareId === '') {
|
||||||
|
$shareId = self::extractA2cShareId($resolvedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $shareId !== '' ? $host . ':' . $shareId : $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2C 短链 HTTP 预解析:优先已有 landingUrlHint,否则跟随重定向得到长链
|
||||||
|
*/
|
||||||
|
private static function resolveA2cResolvedUrl(string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
if ($landingUrlHint !== '' && self::extractA2cShareId($landingUrlHint) !== '') {
|
||||||
|
return $landingUrlHint;
|
||||||
|
}
|
||||||
|
if (self::extractA2cShareId($pageUrl) !== '') {
|
||||||
|
return $pageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
|
if ($resolved !== '') {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $landingUrlHint !== '' ? $landingUrlHint : $pageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 A2C 分享 URL 提取 id 查询参数
|
||||||
|
*/
|
||||||
|
private static function extractA2cShareId(string $url): string
|
||||||
|
{
|
||||||
|
$query = (string) parse_url($url, PHP_URL_QUERY);
|
||||||
|
if ($query === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
parse_str($query, $params);
|
||||||
|
$id = isset($params['id']) ? trim((string) $params['id']) : '';
|
||||||
|
|
||||||
|
return $id !== '' ? $id : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveHuojianSessionScope(string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
$scope = HuojianUrlHelper::resolveSessionScope($pageUrl);
|
||||||
|
if ($scope !== '') {
|
||||||
|
return $scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($landingUrlHint !== '') {
|
||||||
|
$fromLanding = HuojianUrlHelper::resolveSessionScope($landingUrlHint);
|
||||||
|
if ($fromLanding !== '') {
|
||||||
|
return $fromLanding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library\scrm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 火箭云控 URL 解析:短链(s.url99.me/code)与长链(*.url66.me/gds?link=)动态识别
|
||||||
|
*
|
||||||
|
* 长链域名可能为 v3.url66.me、v4.url66.me 等,禁止写死单一 host。
|
||||||
|
*/
|
||||||
|
final class HuojianUrlHelper
|
||||||
|
{
|
||||||
|
/** 火箭业务页路径特征 */
|
||||||
|
public const BUSINESS_PATH_NEEDLE = '/gds';
|
||||||
|
|
||||||
|
/** 长链 query 中的分享 token 参数名 */
|
||||||
|
public const LINK_QUERY_PARAM = 'link';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为火箭长链业务页(含 /gds 且带 link=)
|
||||||
|
*/
|
||||||
|
public static function isLongLinkUrl(string $pageUrl): bool
|
||||||
|
{
|
||||||
|
$path = (string) parse_url($pageUrl, PHP_URL_PATH);
|
||||||
|
if ($path === '' || strpos($path, self::BUSINESS_PATH_NEEDLE) === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = [];
|
||||||
|
parse_str((string) parse_url($pageUrl, PHP_URL_QUERY), $query);
|
||||||
|
|
||||||
|
return trim((string) ($query[self::LINK_QUERY_PARAM] ?? '')) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从长链 URL 动态解析业务域(如 v3.url66.me)
|
||||||
|
*/
|
||||||
|
public static function extractBusinessHost(string $pageUrl): string
|
||||||
|
{
|
||||||
|
if (!self::isLongLinkUrl($pageUrl)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = strtolower(trim((string) parse_url($pageUrl, PHP_URL_HOST)));
|
||||||
|
|
||||||
|
return $host !== '' ? $host : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取分享稳定标识:长链 link= 或短链 path 段(如 50q3622j)
|
||||||
|
*/
|
||||||
|
public static function extractShareToken(string $pageUrl): string
|
||||||
|
{
|
||||||
|
if (self::isLongLinkUrl($pageUrl)) {
|
||||||
|
$query = [];
|
||||||
|
parse_str((string) parse_url($pageUrl, PHP_URL_QUERY), $query);
|
||||||
|
|
||||||
|
return trim((string) ($query[self::LINK_QUERY_PARAM] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = trim((string) parse_url($pageUrl, PHP_URL_PATH), '/');
|
||||||
|
if ($path === '' || strpos($path, '/') !== false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^[a-zA-Z0-9]+$/', $path) !== 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为火箭短链入口(单段 path,非 /gds 长链)
|
||||||
|
*/
|
||||||
|
public static function isShortEntryUrl(string $pageUrl): bool
|
||||||
|
{
|
||||||
|
if (self::isLongLinkUrl($pageUrl)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::extractShareToken($pageUrl) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话 scope:长链用动态业务域;短链用 share.{token}(跨长链域名复用 cookie jar)
|
||||||
|
*/
|
||||||
|
public static function resolveSessionScope(string $pageUrl): string
|
||||||
|
{
|
||||||
|
$businessHost = self::extractBusinessHost($pageUrl);
|
||||||
|
if ($businessHost !== '') {
|
||||||
|
return $businessHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = self::extractShareToken($pageUrl);
|
||||||
|
if ($token !== '') {
|
||||||
|
return 'share.' . $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@ 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\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
use app\common\service\SplitPageUrlResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A2C 云控蜘蛛
|
* A2C 云控蜘蛛(Real Browser 打开分享页 + 拦截加密 API + UI 翻页)
|
||||||
|
*
|
||||||
|
* 业务域 user.a2c.chat 在 CF 挑战页启用 CapSolver 兜底;短链 yyk.ink 预解析为长链后直达业务页。
|
||||||
*/
|
*/
|
||||||
class A2cSpider extends AbstractScrmSpider
|
class A2cSpider extends AbstractScrmSpider
|
||||||
{
|
{
|
||||||
@@ -24,6 +28,9 @@ class A2cSpider extends AbstractScrmSpider
|
|||||||
|
|
||||||
private string $password;
|
private string $password;
|
||||||
|
|
||||||
|
/** @var string HTTP 预解析落地 URL(供 antiBot landingUrlHint) */
|
||||||
|
private string $landingUrlHint = '';
|
||||||
|
|
||||||
private UnifiedScrmData $unifiedData;
|
private UnifiedScrmData $unifiedData;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -36,6 +43,7 @@ class A2cSpider extends AbstractScrmSpider
|
|||||||
$this->pageUrl = $pageUrl;
|
$this->pageUrl = $pageUrl;
|
||||||
$this->account = $account;
|
$this->account = $account;
|
||||||
$this->password = $password;
|
$this->password = $password;
|
||||||
|
$this->landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
$this->unifiedData = new UnifiedScrmData();
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +58,7 @@ class A2cSpider extends AbstractScrmSpider
|
|||||||
'authActions' => [
|
'authActions' => [
|
||||||
['type' => 'wait', 'ms' => 2000],
|
['type' => 'wait', 'ms' => 2000],
|
||||||
],
|
],
|
||||||
|
'antiBot' => AntiBotConfigBuilder::build('a2c', $this->pageUrl, $this->landingUrlHint),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +49,7 @@ class ChatknowSpider extends AbstractScrmSpider
|
|||||||
'authActions' => [
|
'authActions' => [
|
||||||
['type' => 'wait', 'ms' => 4000],
|
['type' => 'wait', 'ms' => 4000],
|
||||||
],
|
],
|
||||||
|
'antiBot' => AntiBotConfigBuilder::build('chatknow', $this->pageUrl),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ 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\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
use app\common\service\SplitPageUrlResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 火箭云控蜘蛛
|
* 火箭云控蜘蛛(Real Browser + CF 过盾 + 密码弹窗 + API 拦截)
|
||||||
|
*
|
||||||
|
* 短链 s.url99.me → 长链 *.url66.me/gds?link=(长链域名动态,不写死)
|
||||||
*/
|
*/
|
||||||
class HuojianSpider extends AbstractScrmSpider
|
class HuojianSpider extends AbstractScrmSpider
|
||||||
{
|
{
|
||||||
@@ -22,6 +26,9 @@ class HuojianSpider extends AbstractScrmSpider
|
|||||||
|
|
||||||
private UnifiedScrmData $unifiedData;
|
private UnifiedScrmData $unifiedData;
|
||||||
|
|
||||||
|
/** HTTP 预解析落地 URL(短链 CF 时可能失败,仅作 hint) */
|
||||||
|
private string $landingUrlHint = '';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $pageUrl,
|
string $pageUrl,
|
||||||
string $account = '',
|
string $account = '',
|
||||||
@@ -32,11 +39,14 @@ class HuojianSpider extends AbstractScrmSpider
|
|||||||
$this->pageUrl = $pageUrl;
|
$this->pageUrl = $pageUrl;
|
||||||
$this->account = $account;
|
$this->account = $account;
|
||||||
$this->password = $password;
|
$this->password = $password;
|
||||||
|
$this->landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
$this->unifiedData = new UnifiedScrmData();
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getSpiderConfig(): array
|
protected function getSpiderConfig(): array
|
||||||
{
|
{
|
||||||
|
$antiBot = AntiBotConfigBuilder::build('huojian', $this->pageUrl, $this->landingUrlHint);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pageUrl' => $this->pageUrl,
|
'pageUrl' => $this->pageUrl,
|
||||||
'listApi' => self::API_LIST,
|
'listApi' => self::API_LIST,
|
||||||
@@ -48,6 +58,7 @@ class HuojianSpider extends AbstractScrmSpider
|
|||||||
['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'],
|
['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'],
|
||||||
['type' => 'wait', 'ms' => 2000],
|
['type' => 'wait', 'ms' => 2000],
|
||||||
],
|
],
|
||||||
|
'antiBot' => $antiBot,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +95,7 @@ class HuojianSpider extends AbstractScrmSpider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$unifiedData->total = $count;
|
$unifiedData->total = $count;
|
||||||
|
|
||||||
return $unifiedData;
|
return $unifiedData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ 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\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
use app\common\service\SplitTicketSyncLogger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whatshub 云控蜘蛛(Real Browser + Turnstile 过盾 + UI 翻页)
|
* Whatshub 云控蜘蛛
|
||||||
*
|
*
|
||||||
* 时序:Cloudflare Turnstile → 密码弹窗 authActions → API 拦截 → UI 翻页
|
* 时序:cURL detailByShareCode 拉号码 → Node 监听 statistics 算完成量 → 失败则完整 Node 爬虫
|
||||||
*/
|
*/
|
||||||
class WhatshubSpider extends AbstractScrmSpider
|
class WhatshubSpider extends AbstractScrmSpider
|
||||||
{
|
{
|
||||||
@@ -20,9 +22,14 @@ class WhatshubSpider extends AbstractScrmSpider
|
|||||||
/** 详情/统计 API 路径 */
|
/** 详情/统计 API 路径 */
|
||||||
private const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics';
|
private const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics';
|
||||||
|
|
||||||
|
/** shareCode + password 直连接口(一次返回全量号码,无需翻页) */
|
||||||
|
private const API_DETAIL_BY_SHARE_CODE = '/api/whatshub-counter/workShare/open/detailByShareCode';
|
||||||
|
|
||||||
/** 默认每页条数 */
|
/** 默认每页条数 */
|
||||||
private const DEFAULT_PER_PAGE_COUNT = 20;
|
private const DEFAULT_PER_PAGE_COUNT = 20;
|
||||||
|
|
||||||
|
private const API_FETCH_TIMEOUT_SEC = 20;
|
||||||
|
|
||||||
private string $pageUrl;
|
private string $pageUrl;
|
||||||
|
|
||||||
private string $account;
|
private string $account;
|
||||||
@@ -44,11 +51,294 @@ class WhatshubSpider extends AbstractScrmSpider
|
|||||||
$this->unifiedData = new UnifiedScrmData();
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 混合同步:cURL 拉号码 + Node 监听 statistics 算完成量
|
||||||
|
* statistics 失败时清 session 重试;仍失败则 fallback 完整 Node 仅取完成量
|
||||||
|
*/
|
||||||
|
public function run(): UnifiedScrmData
|
||||||
|
{
|
||||||
|
$apiResult = $this->tryFetchViaShareCodeApi();
|
||||||
|
$statsCount = $this->tryFetchStatisticsViaNode(false);
|
||||||
|
|
||||||
|
if ($apiResult instanceof UnifiedScrmData && $statsCount !== null) {
|
||||||
|
$apiResult->todayNewCount = $statsCount;
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok', [
|
||||||
|
'numberCount' => count($apiResult->numbers),
|
||||||
|
'todayNewCount' => $statsCount,
|
||||||
|
]);
|
||||||
|
return $apiResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cURL 已成功:清 session 再试 statistics;仍失败则完整 Node 只取完成量
|
||||||
|
if ($apiResult instanceof UnifiedScrmData) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats retry after purge session', [
|
||||||
|
'numberCount' => count($apiResult->numbers),
|
||||||
|
]);
|
||||||
|
$statsCount = $this->tryFetchStatisticsViaNode(true);
|
||||||
|
if ($statsCount !== null) {
|
||||||
|
$apiResult->todayNewCount = $statsCount;
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok (retry)', [
|
||||||
|
'numberCount' => count($apiResult->numbers),
|
||||||
|
'todayNewCount' => $statsCount,
|
||||||
|
]);
|
||||||
|
return $apiResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats fallback full node for complete count', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'numberCount' => count($apiResult->numbers),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
$fullResult = parent::run();
|
||||||
|
$apiResult->todayNewCount = $fullResult->todayNewCount;
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok (full node stats)', [
|
||||||
|
'numberCount' => count($apiResult->numbers),
|
||||||
|
'todayNewCount' => $apiResult->todayNewCount,
|
||||||
|
]);
|
||||||
|
return $apiResult;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats fallback full node failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('Whatshub statistics 同步失败(号码已通过 API 获取,无法更新完成量)');
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub hybrid incomplete, fallback full node', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'apiOk' => false,
|
||||||
|
'statisticsOk' => $statsCount !== null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return parent::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whatshub vxe 密码表单:placeholder 含「密码」+ 确认按钮,与业务页搜索框区分 */
|
||||||
|
private const AUTH_PASSWORD_INPUT = '.vxe-form .el-input__inner[placeholder*="密码"]';
|
||||||
|
|
||||||
|
private const AUTH_CONFIRM_BUTTON = '.vxe-form .vxe-button-group button.theme--primary[type="submit"]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 监听 statistics API,返回 dayNewFans + reDayNewFans 总和;失败返回 null
|
||||||
|
*
|
||||||
|
* @param bool $isRetry 是否为清 session 后的二次尝试
|
||||||
|
*/
|
||||||
|
private function tryFetchStatisticsViaNode(bool $isRetry = false): ?int
|
||||||
|
{
|
||||||
|
$config = $this->getSpiderConfig();
|
||||||
|
$antiBot = $config['antiBot'] ?? null;
|
||||||
|
if (is_array($antiBot)) {
|
||||||
|
// statistics-only:早挂拦截器、识别 vxe 密码框、session 有效时 skip auth
|
||||||
|
$antiBot = array_merge($antiBot, [
|
||||||
|
'skipPostCfReadyApi' => true,
|
||||||
|
'authSkipIfNoPasswordDialog' => true,
|
||||||
|
'authSelectorTimeoutMs' => 30000,
|
||||||
|
'earlyApiIntercept' => true,
|
||||||
|
]);
|
||||||
|
if ($isRetry) {
|
||||||
|
$antiBot['purgeSession'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats node request', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'api' => self::API_DETAILS,
|
||||||
|
'isRetry' => $isRetry,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->requestNode('/api/auth-and-intercept', [
|
||||||
|
'pageUrl' => $config['pageUrl'],
|
||||||
|
'apiUrls' => [self::API_DETAILS],
|
||||||
|
'authActions' => $config['authActions'] ?? [],
|
||||||
|
'antiBot' => $antiBot,
|
||||||
|
], $this->resolveAuthInterceptTimeout(is_array($antiBot) ? $antiBot : null));
|
||||||
|
|
||||||
|
if (empty($result['success'])) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats node failed', [
|
||||||
|
'error' => $result['error'] ?? '未知',
|
||||||
|
'code' => $result['code'] ?? null,
|
||||||
|
'page' => $result['page'] ?? null,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$intercepted = $result['interceptedApis'] ?? [];
|
||||||
|
if (!isset($intercepted[self::API_DETAILS]['data']) || !is_array($intercepted[self::API_DETAILS]['data'])) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats node missing payload', [
|
||||||
|
'intercepted' => array_keys(is_array($intercepted) ? $intercepted : []),
|
||||||
|
'page' => $result['page'] ?? null,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$detailData = $intercepted[self::API_DETAILS]['data'];
|
||||||
|
$dayNewFans = (int) ($detailData['data']['dayNewFans'] ?? 0);
|
||||||
|
$reDayNewFans = (int) ($detailData['data']['reDayNewFans'] ?? 0);
|
||||||
|
$total = $dayNewFans + $reDayNewFans;
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats node ok', [
|
||||||
|
'dayNewFans' => $dayNewFans,
|
||||||
|
'reDayNewFans' => $reDayNewFans,
|
||||||
|
'total' => $total,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从短链 pageUrl 解析 shareCode,例如 /m/KMYBBInp2066/1 → KMYBBInp2066
|
||||||
|
*/
|
||||||
|
private function extractShareCodeFromPageUrl(): ?string
|
||||||
|
{
|
||||||
|
$path = (string) parse_url($this->pageUrl, PHP_URL_PATH);
|
||||||
|
if ($path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (preg_match('#/m/([^/]+)/#', $path, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接 detailByShareCode 完整 GET URL
|
||||||
|
*/
|
||||||
|
private function buildShareCodeApiUrl(string $shareCode): ?string
|
||||||
|
{
|
||||||
|
$parsed = parse_url($this->pageUrl);
|
||||||
|
$scheme = isset($parsed['scheme']) ? (string) $parsed['scheme'] : 'https';
|
||||||
|
$host = isset($parsed['host']) ? (string) $parsed['host'] : '';
|
||||||
|
if ($host === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = http_build_query([
|
||||||
|
'shareCode' => $shareCode,
|
||||||
|
'password' => $this->password,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $scheme . '://' . $host . self::API_DETAIL_BY_SHARE_CODE . '?' . $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试通过 shareCode API 抓取;成功返回 UnifiedScrmData,否则返回 null
|
||||||
|
*/
|
||||||
|
private function tryFetchViaShareCodeApi(): ?UnifiedScrmData
|
||||||
|
{
|
||||||
|
if ($this->password === '') {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api skip: empty password', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shareCode = $this->extractShareCodeFromPageUrl();
|
||||||
|
if ($shareCode === null || $shareCode === '') {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api skip: cannot parse shareCode', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiUrl = $this->buildShareCodeApiUrl($shareCode);
|
||||||
|
if ($apiUrl === null) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api skip: invalid pageUrl host', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api request', [
|
||||||
|
'url' => preg_replace('/password=[^&]+/', 'password=***', $apiUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init($apiUrl);
|
||||||
|
if ($ch === false) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api curl init failed', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_TIMEOUT => self::API_FETCH_TIMEOUT_SEC,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Accept: application/json',
|
||||||
|
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlErr = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false || $curlErr !== '') {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api curl failed', [
|
||||||
|
'error' => $curlErr,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api http error', [
|
||||||
|
'httpCode' => $httpCode,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode((string) $response, true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api invalid json', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($payload['code'] ?? 0) !== 200) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api business error', [
|
||||||
|
'code' => $payload['code'] ?? null,
|
||||||
|
'msg' => $payload['msg'] ?? '',
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($payload['data']) || !is_array($payload['data'])) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api invalid data field', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parseShareCodeApiResponse($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 detailByShareCode 响应映射为 UnifiedScrmData
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function parseShareCodeApiResponse(array $payload): UnifiedScrmData
|
||||||
|
{
|
||||||
|
$unifiedData = new UnifiedScrmData();
|
||||||
|
|
||||||
|
foreach ($payload['data'] as $item) {
|
||||||
|
if (!is_array($item) || empty($item['account'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$number = (string) $item['account'];
|
||||||
|
$isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1;
|
||||||
|
$dayNewFans = (int) ($item['dayNewFans'] ?? 0);
|
||||||
|
|
||||||
|
// 号码同步仅使用 dayNewFans,不含 reDayNewFans;完成量由 Node statistics 提供
|
||||||
|
$unifiedData->addNumber($number, $isOnline, $dayNewFans);
|
||||||
|
}
|
||||||
|
|
||||||
|
$unifiedData->total = count($unifiedData->numbers);
|
||||||
|
|
||||||
|
return $unifiedData;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
protected function getSpiderConfig(): array
|
protected function getSpiderConfig(): array
|
||||||
{
|
{
|
||||||
$host = (string) parse_url($this->pageUrl, PHP_URL_HOST);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pageUrl' => $this->pageUrl,
|
'pageUrl' => $this->pageUrl,
|
||||||
'listApi' => self::API_LIST,
|
'listApi' => self::API_LIST,
|
||||||
@@ -56,21 +346,13 @@ class WhatshubSpider extends AbstractScrmSpider
|
|||||||
'listMethod' => 'POST',
|
'listMethod' => 'POST',
|
||||||
'paginationMode' => self::MODE_UI,
|
'paginationMode' => self::MODE_UI,
|
||||||
'authActions' => [
|
'authActions' => [
|
||||||
// Element Plus 密码弹窗:仅注入可见 input
|
// Whatshub 密码 UI 为 vxe-form + Element input,限定在表单内避免误填搜索框
|
||||||
['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password],
|
['type' => 'vue_fill', 'selector' => self::AUTH_PASSWORD_INPUT, 'value' => $this->password],
|
||||||
['type' => 'wait', 'ms' => 500],
|
['type' => 'wait', 'ms' => 500],
|
||||||
['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'],
|
['type' => 'vue_click', 'selector' => self::AUTH_CONFIRM_BUTTON],
|
||||||
['type' => 'wait', 'ms' => 2200],
|
['type' => 'wait', 'ms' => 2200],
|
||||||
],
|
],
|
||||||
// Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用
|
'antiBot' => AntiBotConfigBuilder::build('whatshub', $this->pageUrl),
|
||||||
'antiBot' => [
|
|
||||||
'enabled' => true,
|
|
||||||
'profile' => 'real',
|
|
||||||
'turnstile' => true,
|
|
||||||
'solverFallback' => true,
|
|
||||||
'sessionKey' => 'whatshub:' . $host,
|
|
||||||
'challengeTimeoutMs' => 60000,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +392,7 @@ class WhatshubSpider extends AbstractScrmSpider
|
|||||||
$unifiedData = $this->unifiedData;
|
$unifiedData = $this->unifiedData;
|
||||||
|
|
||||||
if (is_array($detailData)) {
|
if (is_array($detailData)) {
|
||||||
$unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0);
|
$unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0) + (int) ($detailData['data']['reDayNewFans'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($allListPagesData as $pageRaw) {
|
foreach ($allListPagesData as $pageRaw) {
|
||||||
|
|||||||
@@ -37,9 +37,25 @@ class SplitNodeHealthService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前是否适合向 Node 提交新 Browser 任务
|
* 当前是否适合向 Node 提交新 Browser 任务
|
||||||
|
*
|
||||||
|
* A2C 等 antiBot 任务走 real profile,应参考 queueReal 而非 standard 队列。
|
||||||
*/
|
*/
|
||||||
public static function canAcceptWork(): bool
|
public static function canAcceptWork(): bool
|
||||||
{
|
{
|
||||||
|
return self::canAcceptWorkForTicketType('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指定工单类型是否可向 Node 提交新任务
|
||||||
|
*
|
||||||
|
* API 优先类型(如 Whatshub)常态不占 Real Browser,调度层应跳过队列门禁。
|
||||||
|
*/
|
||||||
|
public static function canAcceptWorkForTicketType(string $ticketType): bool
|
||||||
|
{
|
||||||
|
if ($ticketType !== '' && SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$stats = self::fetchStats();
|
$stats = self::fetchStats();
|
||||||
if ($stats === null) {
|
if ($stats === null) {
|
||||||
return true;
|
return true;
|
||||||
@@ -47,33 +63,61 @@ class SplitNodeHealthService
|
|||||||
if (!empty($stats['shuttingDown'])) {
|
if (!empty($stats['shuttingDown'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
|
|
||||||
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
$snapshot = self::resolveQueueSnapshot($stats);
|
||||||
$queued = (int) ($queue['queued'] ?? 0);
|
|
||||||
$active = (int) ($queue['active'] ?? 0);
|
|
||||||
$max = max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4)));
|
|
||||||
$threshold = SplitSyncConfigService::getNodeQueueSkipThreshold();
|
$threshold = SplitSyncConfigService::getNodeQueueSkipThreshold();
|
||||||
if ($queued >= $threshold) {
|
if ($snapshot['queued'] >= $threshold) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return $active < $max;
|
|
||||||
|
return $snapshot['active'] < $snapshot['max'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{queued:int,active:int,max:int}
|
* @return array{queued:int,active:int,max:int,profile:string}
|
||||||
*/
|
*/
|
||||||
public static function getQueueSnapshot(): array
|
public static function getQueueSnapshot(): array
|
||||||
{
|
{
|
||||||
$stats = self::fetchStats();
|
$stats = self::fetchStats();
|
||||||
if ($stats === null) {
|
if ($stats === null) {
|
||||||
return ['queued' => 0, 'active' => 0, 'max' => 0];
|
return ['queued' => 0, 'active' => 0, 'max' => 0, 'profile' => 'real'];
|
||||||
}
|
}
|
||||||
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
|
|
||||||
|
$snapshot = self::resolveQueueSnapshot($stats);
|
||||||
|
return [
|
||||||
|
'queued' => $snapshot['queued'],
|
||||||
|
'active' => $snapshot['active'],
|
||||||
|
'max' => $snapshot['max'],
|
||||||
|
'profile' => $snapshot['profile'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优先使用 real 队列(antiBot / A2C),无数据时回退 standard
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $stats
|
||||||
|
* @return array{queued:int,active:int,max:int,profile:string}
|
||||||
|
*/
|
||||||
|
private static function resolveQueueSnapshot(array $stats): array
|
||||||
|
{
|
||||||
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
||||||
|
$queueReal = is_array($stats['queueReal'] ?? null) ? $stats['queueReal'] : [];
|
||||||
|
if ($queueReal !== []) {
|
||||||
|
return [
|
||||||
|
'queued' => (int) ($queueReal['queued'] ?? 0),
|
||||||
|
'active' => (int) ($queueReal['active'] ?? 0),
|
||||||
|
'max' => max(1, (int) ($config['maxConcurrentBrowsersReal'] ?? ($queueReal['max'] ?? 2))),
|
||||||
|
'profile' => 'real',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'queued' => (int) ($queue['queued'] ?? 0),
|
'queued' => (int) ($queue['queued'] ?? 0),
|
||||||
'active' => (int) ($queue['active'] ?? 0),
|
'active' => (int) ($queue['active'] ?? 0),
|
||||||
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))),
|
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))),
|
||||||
|
'profile' => 'standard',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Number;
|
||||||
|
use think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下号比率触线降权(ratio_deferred)规则与降权池清理
|
||||||
|
*
|
||||||
|
* 降权前提:已有落地页访问(visit_count > last_sync_visit_count),
|
||||||
|
* 且自上次同步以来进线未增长时累计 streak,触线才标记降权。
|
||||||
|
*/
|
||||||
|
class SplitNumberRatioDeferredService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 是否具备计入 streak 的落地页访问基数
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function hasLandingVisitBaseline($number): bool
|
||||||
|
{
|
||||||
|
$visitCount = $this->readInt($number, 'visit_count');
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastVisit = $this->readInt($number, 'last_sync_visit_count');
|
||||||
|
|
||||||
|
return $visitCount > $lastVisit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据访问与进线快照计算新的 streak
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function computeStreakForVisit($number, int $currentStreak): int
|
||||||
|
{
|
||||||
|
if (!$this->hasLandingVisitBaseline($number)) {
|
||||||
|
return $currentStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inboundCount = $this->readInt($number, 'inbound_count');
|
||||||
|
$lastInbound = $this->readInt($number, 'last_sync_inbound_count');
|
||||||
|
|
||||||
|
if ($inboundCount <= $lastInbound) {
|
||||||
|
return $currentStreak + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步侧:根据 visit / inbound 与上次同步检查点重算 streak
|
||||||
|
*
|
||||||
|
* 访问侧(recordVisitAfterRedirect)已按次 +1 累计 streak,此处不得再叠加 delta,否则首次同步会双倍计次导致提前关号。
|
||||||
|
* 取 max(访问侧 streak, 自上次同步以来的点击增量) 作为裁决值,并兜底访问钩子未触发的场景。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function computeStreakForSync($number, int $currentStreak): int
|
||||||
|
{
|
||||||
|
$visitCount = $this->readInt($number, 'visit_count');
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastVisit = $this->readInt($number, 'last_sync_visit_count');
|
||||||
|
$inboundCount = $this->readInt($number, 'inbound_count');
|
||||||
|
$lastInbound = $this->readInt($number, 'last_sync_inbound_count');
|
||||||
|
|
||||||
|
if ($inboundCount > $lastInbound) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visitCount > $lastVisit) {
|
||||||
|
$deltaSinceSync = $visitCount - $lastVisit;
|
||||||
|
|
||||||
|
return max($currentStreak, $deltaSinceSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $currentStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否应标记 ratio_deferred(访问侧触线降权,不关 status)
|
||||||
|
*/
|
||||||
|
public function shouldMarkDeferred(int $streak, int $assignRatio, bool $hasLandingBaseline): bool
|
||||||
|
{
|
||||||
|
return $hasLandingBaseline && $assignRatio > 0 && $streak >= $assignRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为应清理的无效降权记录
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function isInvalidDeferredRecord($number): bool
|
||||||
|
{
|
||||||
|
if ($this->readInt($number, 'ratio_deferred') !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !$this->hasLandingVisitBaseline($number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理无效降权标记(ratio_deferred=1 但无有效落地页访问基线)
|
||||||
|
*
|
||||||
|
* @param int[]|null $adminIds 数据权限管理员 ID 列表,null 表示不限制
|
||||||
|
* @return int 清理条数
|
||||||
|
*/
|
||||||
|
public function cleanupInvalidDeferred(?array $adminIds = null): int
|
||||||
|
{
|
||||||
|
$query = Number::where('ratio_deferred', 1);
|
||||||
|
if ($adminIds !== null && $adminIds !== []) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $query->field('id,visit_count,last_sync_visit_count,ratio_deferred,no_inbound_click_streak')->select();
|
||||||
|
if ($rows === null || $rows === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$count = 0;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!$this->isInvalidDeferredRecord($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$update = [
|
||||||
|
'ratio_deferred' => 0,
|
||||||
|
'ratio_deferred_at' => null,
|
||||||
|
'updatetime' => $now,
|
||||||
|
];
|
||||||
|
if ($this->readInt($row, 'visit_count') <= 0) {
|
||||||
|
$update['no_inbound_click_streak'] = 0;
|
||||||
|
}
|
||||||
|
Number::where('id', (int) $row['id'])->update($update);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计当前权限范围内无效降权条数(清理前预览)
|
||||||
|
*
|
||||||
|
* @param int[]|null $adminIds
|
||||||
|
*/
|
||||||
|
public function countInvalidDeferred(?array $adminIds = null): int
|
||||||
|
{
|
||||||
|
$query = Number::where('ratio_deferred', 1);
|
||||||
|
if ($adminIds !== null && $adminIds !== []) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
foreach ($query->field('id,visit_count,last_sync_visit_count,ratio_deferred')->select() as $row) {
|
||||||
|
if ($this->isInvalidDeferredRecord($row)) {
|
||||||
|
$total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
private function readInt($number, string $field): int
|
||||||
|
{
|
||||||
|
if (is_array($number)) {
|
||||||
|
return (int) ($number[$field] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $number->getAttr($field);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Number;
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落地页访问侧下号比率规则:更新连续无进线点击 streak,触线时仅降权(ratio_deferred),不关闭 status
|
||||||
|
*
|
||||||
|
* 最终关号由同步后 SplitTicketRuleService::applyNumberRules 裁决。
|
||||||
|
*/
|
||||||
|
class SplitNumberVisitRuleService
|
||||||
|
{
|
||||||
|
private SplitNumberRatioDeferredService $ratioDeferredService;
|
||||||
|
|
||||||
|
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
|
||||||
|
{
|
||||||
|
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 访问计数已递增后调用:累加 streak,达到 assign_ratio 时标记 ratio_deferred=1
|
||||||
|
*/
|
||||||
|
public function recordVisitAfterRedirect(int $numberId): void
|
||||||
|
{
|
||||||
|
if ($numberId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Number|null $number */
|
||||||
|
$number = Number::get($numberId);
|
||||||
|
if ($number === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Ticket|null $ticket */
|
||||||
|
$ticket = Ticket::where('admin_id', (int) $number['admin_id'])
|
||||||
|
->where('split_link_id', (int) $number['split_link_id'])
|
||||||
|
->where('ticket_name', (string) $number['ticket_name'])
|
||||||
|
->find();
|
||||||
|
if ($ticket === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignRatio = (int) ($ticket['assign_ratio'] ?? 0);
|
||||||
|
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
||||||
|
$hasBaseline = $this->ratioDeferredService->hasLandingVisitBaseline($number);
|
||||||
|
|
||||||
|
if ($hasBaseline) {
|
||||||
|
$streak = $this->ratioDeferredService->computeStreakForVisit($number, $streak);
|
||||||
|
}
|
||||||
|
|
||||||
|
$update = [
|
||||||
|
'no_inbound_click_streak' => $streak,
|
||||||
|
'updatetime' => time(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ((int) $number['manual_manage'] === 0
|
||||||
|
&& $this->ratioDeferredService->shouldMarkDeferred($streak, $assignRatio, $hasBaseline)
|
||||||
|
) {
|
||||||
|
$update['ratio_deferred'] = 1;
|
||||||
|
$update['ratio_deferred_at'] = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
Number::where('id', $numberId)->update($update);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,16 @@ namespace app\common\service;
|
|||||||
use app\admin\model\split\Link;
|
use app\admin\model\split\Link;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分流链接随机打乱配置读取
|
* 分流链接随机打乱配置与落地页选号
|
||||||
|
*
|
||||||
|
* - random_shuffle=0:跨工单合并号码池,按 id 顺序全局严格轮转
|
||||||
|
* - random_shuffle=1:先按各工单开启号码数比例随机选工单,再在该工单内随机选号
|
||||||
|
* - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布)
|
||||||
*/
|
*/
|
||||||
class SplitNumberWeighService
|
class SplitNumberWeighService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 链接是否开启随机打乱(新号码按随机插入顺序写入,跳转按 id 顺序轮转)
|
* 链接是否开启随机打乱
|
||||||
*/
|
*/
|
||||||
public static function isRandomShuffleEnabled(int $linkId): bool
|
public static function isRandomShuffleEnabled(int $linkId): bool
|
||||||
{
|
{
|
||||||
@@ -21,4 +25,52 @@ class SplitNumberWeighService
|
|||||||
}
|
}
|
||||||
return (int) Link::where('id', $linkId)->value('random_shuffle') === 1;
|
return (int) Link::where('id', $linkId)->value('random_shuffle') === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析本次访问应使用的号码下标(0 .. numberCount-1)
|
||||||
|
*
|
||||||
|
* @param int $linkId 分流链接 ID
|
||||||
|
* @param int $numberCount 可用号码数量
|
||||||
|
* @param SplitRoundRobinStore $roundRobinStore 关闭随机打乱时的轮转计数器
|
||||||
|
* @param string $poolKey 选号池标识(preferred / deferred 等)
|
||||||
|
*/
|
||||||
|
public static function resolvePickIndex(
|
||||||
|
int $linkId,
|
||||||
|
int $numberCount,
|
||||||
|
SplitRoundRobinStore $roundRobinStore,
|
||||||
|
string $poolKey = 'main'
|
||||||
|
): int {
|
||||||
|
if ($numberCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ($numberCount === 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isRandomShuffleEnabled($linkId)) {
|
||||||
|
return random_int(0, $numberCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $roundRobinStore->nextIndex($linkId, $numberCount, $poolKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从号码行列表中按链接配置选一条(严格轮转或随机)
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>|Number> $rows
|
||||||
|
* @return array<string, mixed>|Number|null
|
||||||
|
*/
|
||||||
|
public static function pickRowFromList(
|
||||||
|
int $linkId,
|
||||||
|
array $rows,
|
||||||
|
SplitRoundRobinStore $roundRobinStore,
|
||||||
|
string $poolKey = 'main'
|
||||||
|
) {
|
||||||
|
$count = count($rows);
|
||||||
|
if ($count === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$index = self::resolvePickIndex($linkId, $count, $roundRobinStore, $poolKey);
|
||||||
|
return $rows[$index] ?? $rows[0];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短链 HTTP 重定向预解析(与 puppeteer-api/url-resolve.js 行为对齐)
|
||||||
|
*
|
||||||
|
* 用于 antiBot landingUrlHint;CF 拦截时可能失败,浏览器仍会 follow。
|
||||||
|
*/
|
||||||
|
final class SplitPageUrlResolver
|
||||||
|
{
|
||||||
|
private const REDIRECT_CODES = [301, 302, 303, 307, 308];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟随 HTTP 重定向,返回最终 URL;失败时返回原始 URL
|
||||||
|
*/
|
||||||
|
public static function resolveFinalUrl(string $pageUrl, int $maxRedirects = 10, int $timeoutSec = 12): string
|
||||||
|
{
|
||||||
|
$current = trim($pageUrl);
|
||||||
|
if ($current === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($i = 0; $i < $maxRedirects; $i++) {
|
||||||
|
$response = self::fetchHeadOrGet($current, $timeoutSec);
|
||||||
|
if ($response === null) {
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $response['code'];
|
||||||
|
$location = $response['location'];
|
||||||
|
if (!in_array($code, self::REDIRECT_CODES, true) || $location === '') {
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = self::resolveRelativeUrl($current, $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{code: int, location: string}|null
|
||||||
|
*/
|
||||||
|
private static function fetchHeadOrGet(string $url, int $timeoutSec): ?array
|
||||||
|
{
|
||||||
|
$result = self::curlOnce($url, true, $timeoutSec);
|
||||||
|
if ($result !== null) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::curlOnce($url, false, $timeoutSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{code: int, location: string}|null
|
||||||
|
*/
|
||||||
|
private static function curlOnce(string $url, bool $headOnly, int $timeoutSec): ?array
|
||||||
|
{
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_NOBODY => $headOnly,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
CURLOPT_TIMEOUT => $timeoutSec,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; SplitPageUrlResolver/1.0)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
if ($body === false) {
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$redirectUrl = (string) curl_getinfo($ch, CURLINFO_REDIRECT_URL);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$location = $redirectUrl;
|
||||||
|
if ($location === '' && is_string($body)) {
|
||||||
|
$location = self::parseLocationHeader($body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['code' => $code, 'location' => trim($location)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parseLocationHeader(string $rawHeaders): string
|
||||||
|
{
|
||||||
|
foreach (preg_split('/\r\n|\n|\r/', $rawHeaders) as $line) {
|
||||||
|
if (stripos($line, 'Location:') === 0) {
|
||||||
|
return trim(substr($line, 9));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveRelativeUrl(string $baseUrl, string $location): string
|
||||||
|
{
|
||||||
|
if (preg_match('#^https?://#i', $location)) {
|
||||||
|
return $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = parse_url($baseUrl);
|
||||||
|
if (!is_array($base)) {
|
||||||
|
return $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = (string) ($base['scheme'] ?? 'https');
|
||||||
|
$host = (string) ($base['host'] ?? '');
|
||||||
|
if ($host === '') {
|
||||||
|
return $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($location, '/') === 0) {
|
||||||
|
return $scheme . '://' . $host . $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = (string) ($base['path'] ?? '/');
|
||||||
|
$dir = rtrim(str_replace(basename($path), '', $path), '/');
|
||||||
|
|
||||||
|
return $scheme . '://' . $host . ($dir !== '' ? $dir . '/' : '/') . $location;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,18 +6,30 @@ namespace app\common\service;
|
|||||||
|
|
||||||
use app\admin\model\split\Link;
|
use app\admin\model\split\Link;
|
||||||
use app\admin\model\split\Number;
|
use app\admin\model\split\Number;
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
use think\Collection;
|
use think\Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分流链接落地页:查链、轮转选号、拼接跳转 URL、访问计数
|
* 分流链接落地页:查链、选号、拼接跳转 URL、访问计数
|
||||||
|
*
|
||||||
|
* 号码池:同一 split_link_id 下 status=normal,且所属工单未关闭(ticket.status≠hidden)的号码;
|
||||||
|
* 优先选用 ratio_deferred=0,全部触线降权时回退至降权池。
|
||||||
|
*
|
||||||
|
* random_shuffle=0:跨工单合并池,全局严格轮转;
|
||||||
|
* random_shuffle=1:先按各工单开启号码数量比例随机选工单,再在该工单内随机选号。
|
||||||
*/
|
*/
|
||||||
class SplitRedirectService
|
class SplitRedirectService
|
||||||
{
|
{
|
||||||
private SplitRoundRobinStore $roundRobinStore;
|
private SplitRoundRobinStore $roundRobinStore;
|
||||||
|
|
||||||
public function __construct(?SplitRoundRobinStore $roundRobinStore = null)
|
private SplitNumberVisitRuleService $visitRuleService;
|
||||||
{
|
|
||||||
|
public function __construct(
|
||||||
|
?SplitRoundRobinStore $roundRobinStore = null,
|
||||||
|
?SplitNumberVisitRuleService $visitRuleService = null
|
||||||
|
) {
|
||||||
$this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
|
$this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
|
||||||
|
$this->visitRuleService = $visitRuleService ?? new SplitNumberVisitRuleService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,22 +58,17 @@ class SplitRedirectService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection<int, Number>|array<int, Number> $numbers */
|
$linkId = (int) $link->getAttr('id');
|
||||||
$numbers = Number::where('split_link_id', (int) $link['id'])
|
$list = $this->loadEligibleNumbers($linkId);
|
||||||
->where('status', 'normal')
|
if ($list === []) {
|
||||||
->order('id', 'asc')
|
|
||||||
->field('id,number,number_type,number_type_custom')
|
|
||||||
->select();
|
|
||||||
|
|
||||||
$count = is_countable($numbers) ? count($numbers) : 0;
|
|
||||||
if ($count === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$linkId = (int) $link->getAttr('id');
|
if (SplitNumberWeighService::isRandomShuffleEnabled($linkId)) {
|
||||||
$index = $this->roundRobinStore->nextIndex($linkId, $count);
|
$picked = $this->pickNumberRowRandomByTicketWeight($linkId, $list);
|
||||||
$list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers;
|
} else {
|
||||||
$picked = $list[$index] ?? $list[0] ?? null;
|
$picked = $this->pickNumberRow($linkId, $list);
|
||||||
|
}
|
||||||
if ($picked === null) {
|
if ($picked === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -90,8 +97,146 @@ class SplitRedirectService
|
|||||||
$numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id');
|
$numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id');
|
||||||
if ($numberId > 0) {
|
if ($numberId > 0) {
|
||||||
Number::where('id', $numberId)->setInc('visit_count');
|
Number::where('id', $numberId)->setInc('visit_count');
|
||||||
|
$this->visitRuleService->recordVisitAfterRedirect($numberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $redirectUrl;
|
return $redirectUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可参与选号的号码:status=normal,且 ticket_name 不属于已关闭工单
|
||||||
|
*
|
||||||
|
* @return list<Number|array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function loadEligibleNumbers(int $linkId): array
|
||||||
|
{
|
||||||
|
if ($linkId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<string> $hiddenTicketNames */
|
||||||
|
$hiddenTicketNames = Ticket::where('split_link_id', $linkId)
|
||||||
|
->where('status', 'hidden')
|
||||||
|
->column('ticket_name');
|
||||||
|
$hiddenTicketNames = array_values(array_unique(array_filter(array_map(
|
||||||
|
static function ($name): string {
|
||||||
|
return trim((string) $name);
|
||||||
|
},
|
||||||
|
$hiddenTicketNames
|
||||||
|
))));
|
||||||
|
|
||||||
|
$query = Number::where('split_link_id', $linkId)
|
||||||
|
->where('status', 'normal')
|
||||||
|
->order('id', 'asc')
|
||||||
|
->field('id,number,number_type,number_type_custom,ratio_deferred,ticket_name');
|
||||||
|
|
||||||
|
if ($hiddenTicketNames !== []) {
|
||||||
|
$query->where('ticket_name', 'not in', $hiddenTicketNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
$numbers = $query->select();
|
||||||
|
return $numbers instanceof Collection ? $numbers->all() : (array) $numbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* random_shuffle=1:先按工单开启号码数加权随机选工单,再在该工单内选号
|
||||||
|
*
|
||||||
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
|
* @return array<string, mixed>|Number|null
|
||||||
|
*/
|
||||||
|
private function pickNumberRowRandomByTicketWeight(int $linkId, array $rows)
|
||||||
|
{
|
||||||
|
$groups = $this->groupNumbersByTicket($rows);
|
||||||
|
if ($groups === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketKey = $this->pickTicketGroupKeyWeighted($groups);
|
||||||
|
if ($ticketKey === null || !isset($groups[$ticketKey])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pickNumberRow($linkId, $groups[$ticketKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
|
* @return array<string, list<Number|array<string, mixed>>>
|
||||||
|
*/
|
||||||
|
private function groupNumbersByTicket(array $rows): array
|
||||||
|
{
|
||||||
|
/** @var array<string, list<Number|array<string, mixed>>> $groups */
|
||||||
|
$groups = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = is_array($row)
|
||||||
|
? trim((string) ($row['ticket_name'] ?? ''))
|
||||||
|
: trim((string) $row->getAttr('ticket_name'));
|
||||||
|
$key = $name !== '' ? $name : '__manual__';
|
||||||
|
$groups[$key][] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按各分组号码数量加权随机选一个工单(分组 key)
|
||||||
|
*
|
||||||
|
* @param array<string, list<Number|array<string, mixed>>> $groups
|
||||||
|
*/
|
||||||
|
private function pickTicketGroupKeyWeighted(array $groups): ?string
|
||||||
|
{
|
||||||
|
$total = 0;
|
||||||
|
foreach ($groups as $rows) {
|
||||||
|
$total += count($rows);
|
||||||
|
}
|
||||||
|
if ($total <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = array_keys($groups);
|
||||||
|
if (count($keys) === 1) {
|
||||||
|
// PHP 会把纯数字字符串键转成 int,需强转以匹配 ?string 返回类型
|
||||||
|
return (string) $keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = random_int(1, $total);
|
||||||
|
$acc = 0;
|
||||||
|
foreach ($groups as $key => $rows) {
|
||||||
|
$acc += count($rows);
|
||||||
|
if ($r <= $acc) {
|
||||||
|
return (string) $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双层选号:优先 ratio_deferred=0,否则回退至降权池
|
||||||
|
*
|
||||||
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
|
* @return array<string, mixed>|Number|null
|
||||||
|
*/
|
||||||
|
private function pickNumberRow(int $linkId, array $rows)
|
||||||
|
{
|
||||||
|
$preferred = [];
|
||||||
|
$deferred = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$deferredFlag = is_array($row)
|
||||||
|
? (int) ($row['ratio_deferred'] ?? 0)
|
||||||
|
: (int) $row->getAttr('ratio_deferred');
|
||||||
|
if ($deferredFlag === 1) {
|
||||||
|
$deferred[] = $row;
|
||||||
|
} else {
|
||||||
|
$preferred[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$picked = SplitNumberWeighService::pickRowFromList($linkId, $preferred, $this->roundRobinStore, 'preferred');
|
||||||
|
if ($picked !== null) {
|
||||||
|
return $picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SplitNumberWeighService::pickRowFromList($linkId, $deferred, $this->roundRobinStore, 'deferred');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,19 @@ class SplitRoundRobinStore
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取本次访问应使用的号码下标(0 .. count-1)
|
* 获取本次访问应使用的号码下标(0 .. count-1)
|
||||||
|
*
|
||||||
|
* @param string $poolKey 选号池标识(如 preferred / deferred),用于双层选号独立轮转
|
||||||
*/
|
*/
|
||||||
public function nextIndex(int $splitLinkId, int $numberCount): int
|
public function nextIndex(int $splitLinkId, int $numberCount, string $poolKey = 'main'): int
|
||||||
{
|
{
|
||||||
if ($splitLinkId <= 0 || $numberCount <= 0) {
|
if ($splitLinkId <= 0 || $numberCount <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$poolKey = $this->sanitizePoolKey($poolKey);
|
||||||
|
|
||||||
if ($this->ensureRedis()) {
|
if ($this->ensureRedis()) {
|
||||||
$key = self::KEY_PREFIX . $splitLinkId;
|
$key = self::KEY_PREFIX . $splitLinkId . ':' . $poolKey;
|
||||||
$seq = (int) $this->redis->incr($key);
|
$seq = (int) $this->redis->incr($key);
|
||||||
if ($seq <= 0) {
|
if ($seq <= 0) {
|
||||||
$seq = 1;
|
$seq = 1;
|
||||||
@@ -39,7 +43,16 @@ class SplitRoundRobinStore
|
|||||||
return ($seq - 1) % $numberCount;
|
return ($seq - 1) % $numberCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->nextIndexFallback($splitLinkId, $numberCount);
|
return $this->nextIndexFallback($splitLinkId, $numberCount, $poolKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选号池 key 仅允许字母数字与下划线,避免 Redis key 注入
|
||||||
|
*/
|
||||||
|
private function sanitizePoolKey(string $poolKey): string
|
||||||
|
{
|
||||||
|
$poolKey = preg_replace('/[^a-zA-Z0-9_]/', '', $poolKey) ?? '';
|
||||||
|
return $poolKey !== '' ? $poolKey : 'main';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,14 +115,14 @@ class SplitRoundRobinStore
|
|||||||
/**
|
/**
|
||||||
* 无 Redis 时使用 runtime 文件锁递增(开发/单机可用;生产请启用 Redis)
|
* 无 Redis 时使用 runtime 文件锁递增(开发/单机可用;生产请启用 Redis)
|
||||||
*/
|
*/
|
||||||
private function nextIndexFallback(int $splitLinkId, int $numberCount): int
|
private function nextIndexFallback(int $splitLinkId, int $numberCount, string $poolKey = 'main'): int
|
||||||
{
|
{
|
||||||
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
||||||
$dir = $runtime . 'split_rr/';
|
$dir = $runtime . 'split_rr/';
|
||||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
$file = $dir . $splitLinkId . '.cnt';
|
$file = $dir . $splitLinkId . '_' . $poolKey . '.cnt';
|
||||||
$fp = @fopen($file, 'c+');
|
$fp = @fopen($file, 'c+');
|
||||||
if ($fp === false) {
|
if ($fp === false) {
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\common\service;
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\common\library\scrm\AbstractScrmSpider;
|
||||||
use app\common\library\scrm\ScrmSpiderInterface;
|
use app\common\library\scrm\ScrmSpiderInterface;
|
||||||
use app\common\library\scrm\spider\A2cSpider;
|
use app\common\library\scrm\spider\A2cSpider;
|
||||||
use app\common\library\scrm\spider\ChatknowSpider;
|
use app\common\library\scrm\spider\ChatknowSpider;
|
||||||
@@ -32,6 +33,16 @@ class SplitScrmSpiderFactory
|
|||||||
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 优先同步类型:常态走 PHP cURL,不占 Node Real Browser 槽位
|
||||||
|
*
|
||||||
|
* Whatshub 已改为混合同步(cURL 号码 + Node statistics),需走 Node 队列门禁。
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const API_FIRST_SYNC_TYPES = [
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return class-string<ScrmSpiderInterface>|null
|
* @return class-string<ScrmSpiderInterface>|null
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +70,65 @@ class SplitScrmSpiderFactory
|
|||||||
return array_keys(self::MAP);
|
return array_keys(self::MAP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否 API 优先类型(调度层可并行投递且不占 Node 队列)
|
||||||
|
*/
|
||||||
|
public static function isApiFirstSyncType(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return in_array(trim($ticketType), self::API_FIRST_SYNC_TYPES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function listApiFirstSyncTypes(): array
|
||||||
|
{
|
||||||
|
return self::API_FIRST_SYNC_TYPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步是否依赖 Node Headless(纯 PHP cURL 等为 false)
|
||||||
|
*/
|
||||||
|
public static function requiresNode(string $ticketType): bool
|
||||||
|
{
|
||||||
|
$class = self::resolveClass($ticketType);
|
||||||
|
if ($class === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_subclass_of($class, AbstractScrmSpider::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function listDirectSyncTypes(): array
|
||||||
|
{
|
||||||
|
$types = [];
|
||||||
|
foreach (self::listSupportedTypes() as $ticketType) {
|
||||||
|
if (!self::requiresNode($ticketType)) {
|
||||||
|
$types[] = $ticketType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $types;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function listNodeSyncTypes(): array
|
||||||
|
{
|
||||||
|
$types = [];
|
||||||
|
foreach (self::listSupportedTypes() as $ticketType) {
|
||||||
|
if (self::requiresNode($ticketType)) {
|
||||||
|
$types[] = $ticketType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $types;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return ScrmSpiderInterface|null
|
* @return ScrmSpiderInterface|null
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ class SplitSyncConfigService
|
|||||||
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
|
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 失败重试时间点最多配置条数 */
|
||||||
|
private const FAIL_RETRY_SCHEDULE_MAX = 10;
|
||||||
|
|
||||||
|
/** 单条重试时间点上限(分钟) */
|
||||||
|
private const FAIL_RETRY_MINUTE_MAX = 1440;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
|
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
|
||||||
*/
|
*/
|
||||||
@@ -36,6 +42,50 @@ class SplitSyncConfigService
|
|||||||
return max(0, (int) $value);
|
return max(0, (int) $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 达失败暂停阈值后的重试时间点(分钟,相对锚点的绝对偏移,升序去重)
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public static function getFailRetryMinutes(): array
|
||||||
|
{
|
||||||
|
$raw = trim(self::getConfigValue('split_sync_fail_retry_minutes'));
|
||||||
|
if ($raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = preg_split('/[,,\s]+/', $raw) ?: [];
|
||||||
|
$minutes = [];
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$part = trim((string) $part);
|
||||||
|
if ($part === '' || !ctype_digit($part)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$minute = (int) $part;
|
||||||
|
if ($minute < 1 || $minute > self::FAIL_RETRY_MINUTE_MAX) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$minutes[$minute] = $minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($minutes === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = array_values($minutes);
|
||||||
|
sort($list, SORT_NUMERIC);
|
||||||
|
|
||||||
|
return array_slice($list, 0, self::FAIL_RETRY_SCHEDULE_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否配置了失败分阶段重试时间点
|
||||||
|
*/
|
||||||
|
public static function hasFailRetrySchedule(): bool
|
||||||
|
{
|
||||||
|
return self::getFailRetryMinutes() !== [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 指定工单类型的自动同步周期(分钟),0 表示不自动同步
|
* 指定工单类型的自动同步周期(分钟),0 表示不自动同步
|
||||||
*/
|
*/
|
||||||
@@ -57,9 +107,21 @@ class SplitSyncConfigService
|
|||||||
{
|
{
|
||||||
$value = self::getConfigValue('split_sync_max_per_cron');
|
$value = self::getConfigValue('split_sync_max_per_cron');
|
||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
return 2;
|
return 5;
|
||||||
}
|
}
|
||||||
return max(1, min(20, (int) $value));
|
return max(1, min(50, (int) $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 通道候选池倍数(候选数 = maxPerRun × 本值)
|
||||||
|
*/
|
||||||
|
public static function getNodeCandidatePoolMultiplier(): int
|
||||||
|
{
|
||||||
|
$value = self::getConfigValue('split_sync_node_candidate_multiplier');
|
||||||
|
if ($value === '') {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
return max(3, min(50, (int) $value));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +148,53 @@ class SplitSyncConfigService
|
|||||||
return max(0, (int) $value);
|
return max(0, (int) $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台投递 / Cron 使用的 PHP CLI 可执行文件路径
|
||||||
|
*
|
||||||
|
* Web(FPM) 环境下 PHP_BINARY 指向 php-fpm,不能用于 think 命令。
|
||||||
|
*/
|
||||||
|
public static function getCliPhpBinary(): string
|
||||||
|
{
|
||||||
|
$configured = trim(self::getConfigValue('split_sync_cli_php'));
|
||||||
|
if ($configured !== '' && self::isUsableCliPhp($configured)) {
|
||||||
|
return $configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defined('PHP_BINDIR') && PHP_BINDIR !== '') {
|
||||||
|
$candidate = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php';
|
||||||
|
if (self::isUsableCliPhp($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PHP_SAPI === 'cli' && defined('PHP_BINARY') && PHP_BINARY !== '' && self::isUsableCliPhp(PHP_BINARY)) {
|
||||||
|
return PHP_BINARY;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['/usr/bin/php', '/usr/local/bin/php'] as $candidate) {
|
||||||
|
if (self::isUsableCliPhp($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断路径是否为可用的 PHP CLI(排除 php-fpm)
|
||||||
|
*/
|
||||||
|
private static function isUsableCliPhp(string $path): bool
|
||||||
|
{
|
||||||
|
if (stripos($path, 'fpm') !== false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($path === 'php') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_file($path) && is_executable($path);
|
||||||
|
}
|
||||||
|
|
||||||
private static function getConfigValue(string $name): string
|
private static function getConfigValue(string $name): string
|
||||||
{
|
{
|
||||||
$site = Config::get('site.' . $name);
|
$site = Config::get('site.' . $name);
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Number;
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单本地开启号码数(active_number_count)物化字段维护
|
||||||
|
*
|
||||||
|
* 统计口径:fa_split_number 中 admin_id + split_link_id + ticket_name 匹配且 status=normal
|
||||||
|
*/
|
||||||
|
class SplitTicketActiveNumberCountService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 按工单关联键统计并回写 active_number_count
|
||||||
|
*/
|
||||||
|
public function refreshForTicketKeys(int $adminId, int $linkId, string $ticketName): void
|
||||||
|
{
|
||||||
|
$ticketName = trim($ticketName);
|
||||||
|
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = (int) Number::where('admin_id', $adminId)
|
||||||
|
->where('split_link_id', $linkId)
|
||||||
|
->where('ticket_name', $ticketName)
|
||||||
|
->where('status', 'normal')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
Ticket::where('admin_id', $adminId)
|
||||||
|
->where('split_link_id', $linkId)
|
||||||
|
->where('ticket_name', $ticketName)
|
||||||
|
->update([
|
||||||
|
'active_number_count' => $count,
|
||||||
|
'updatetime' => time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按工单模型回写
|
||||||
|
*/
|
||||||
|
public function refreshForTicket(Ticket $ticket): void
|
||||||
|
{
|
||||||
|
$this->refreshForTicketKeys(
|
||||||
|
(int) $ticket['admin_id'],
|
||||||
|
(int) $ticket['split_link_id'],
|
||||||
|
(string) $ticket['ticket_name']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量号码 ID:去重后刷新涉及工单
|
||||||
|
*
|
||||||
|
* @param array<int|string> $ids
|
||||||
|
*/
|
||||||
|
public function refreshForNumberIds(array $ids): void
|
||||||
|
{
|
||||||
|
$ids = array_values(array_filter(array_map('intval', $ids)));
|
||||||
|
if ($ids === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = Number::where('id', 'in', $ids)
|
||||||
|
->field('admin_id,split_link_id,ticket_name')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$this->refreshFromNumberRows($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑单条号码:关联键变更时刷新旧工单与新工单
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $before
|
||||||
|
* @param array<string, mixed> $after
|
||||||
|
*/
|
||||||
|
public function refreshAfterNumberEdit(array $before, array $after): void
|
||||||
|
{
|
||||||
|
$keys = [];
|
||||||
|
$beforeKey = $this->buildTicketKeyFromRow($before);
|
||||||
|
if ($beforeKey !== null) {
|
||||||
|
$keys[] = $beforeKey;
|
||||||
|
}
|
||||||
|
$afterKey = $this->buildTicketKeyFromRow($after);
|
||||||
|
if ($afterKey !== null) {
|
||||||
|
$keys[] = $afterKey;
|
||||||
|
}
|
||||||
|
$this->refreshForTicketKeyList($keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量工单关联键去重后刷新
|
||||||
|
*
|
||||||
|
* @param array<int, array{0:int,1:int,2:string}> $keys
|
||||||
|
*/
|
||||||
|
public function refreshForTicketKeyList(array $keys): void
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (!is_array($key) || count($key) < 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$adminId = (int) $key[0];
|
||||||
|
$linkId = (int) $key[1];
|
||||||
|
$ticketName = trim((string) $key[2]);
|
||||||
|
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hash = $adminId . ':' . $linkId . ':' . $ticketName;
|
||||||
|
if (isset($seen[$hash])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$hash] = true;
|
||||||
|
$this->refreshForTicketKeys($adminId, $linkId, $ticketName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<int, array<string, mixed>|Number> $rows
|
||||||
|
*/
|
||||||
|
public function refreshFromNumberRows(iterable $rows): void
|
||||||
|
{
|
||||||
|
$keys = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$data = $row instanceof Number ? $row->getData() : (array) $row;
|
||||||
|
$key = $this->buildTicketKeyFromRow($data);
|
||||||
|
if ($key !== null) {
|
||||||
|
$keys[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->refreshForTicketKeyList($keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array{0:int,1:int,2:string}|null
|
||||||
|
*/
|
||||||
|
private function buildTicketKeyFromRow(array $row): ?array
|
||||||
|
{
|
||||||
|
$adminId = (int) ($row['admin_id'] ?? 0);
|
||||||
|
$linkId = (int) ($row['split_link_id'] ?? 0);
|
||||||
|
$ticketName = trim((string) ($row['ticket_name'] ?? ''));
|
||||||
|
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$adminId, $linkId, $ticketName];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,16 @@ use think\Db;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 工单同步结果写入号码表
|
* 工单同步结果写入号码表
|
||||||
|
*
|
||||||
|
* 同步策略(全量镜像):
|
||||||
|
* - 爬虫返回的号码:写入 platform_status / inbound_count;status 由后续 applyNumberRules 裁决
|
||||||
|
* - 爬虫未返回且 manual_manage=0:物理删除
|
||||||
|
* - manual_manage=1:不删除,仅当仍在爬虫结果中时更新 platform_status
|
||||||
*/
|
*/
|
||||||
class SplitTicketNumberSyncService
|
class SplitTicketNumberSyncService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 将蜘蛛返回的号码列表同步到号码管理
|
* 将蜘蛛返回的号码列表同步到号码管理(全量镜像,以爬虫为准)
|
||||||
*/
|
*/
|
||||||
public function syncFromUnifiedData(Ticket $ticket, UnifiedScrmData $data): void
|
public function syncFromUnifiedData(Ticket $ticket, UnifiedScrmData $data): void
|
||||||
{
|
{
|
||||||
@@ -61,7 +66,7 @@ class SplitTicketNumberSyncService
|
|||||||
$newFollowers = (int) ($row['newFollowersToday'] ?? 0);
|
$newFollowers = (int) ($row['newFollowersToday'] ?? 0);
|
||||||
|
|
||||||
if (isset($existingMap[$number])) {
|
if (isset($existingMap[$number])) {
|
||||||
$this->updateExistingNumber($existingMap[$number], $platformStatus, $newFollowers);
|
$this->updateExistingNumber($ticket, $existingMap[$number], $platformStatus, $newFollowers);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +92,7 @@ class SplitTicketNumberSyncService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$deleteIds = [];
|
||||||
foreach ($existingMap as $number => $item) {
|
foreach ($existingMap as $number => $item) {
|
||||||
if (isset($syncedNumberSet[$number])) {
|
if (isset($syncedNumberSet[$number])) {
|
||||||
continue;
|
continue;
|
||||||
@@ -94,18 +100,42 @@ class SplitTicketNumberSyncService
|
|||||||
if ((int) $item['manual_manage'] === 1) {
|
if ((int) $item['manual_manage'] === 1) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Number::where('id', (int) $item['id'])->update([
|
$deleteIds[] = (int) $item['id'];
|
||||||
'status' => 'hidden',
|
|
||||||
'updatetime' => time(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
if ($deleteIds !== []) {
|
||||||
|
Number::where('id', 'in', $deleteIds)->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单手动开启等非同步场景:按业务规则对齐号码开关(平台 + 单号上限 + 下号比率)
|
||||||
|
*/
|
||||||
|
public function applyStatusFromPlatformSnapshot(Ticket $ticket): void
|
||||||
|
{
|
||||||
|
(new SplitTicketRuleService())->applyNumberRules($ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 云控在线 → 开启;离线 → 关闭;工单关闭时一律关闭
|
||||||
|
*/
|
||||||
|
public static function resolveSwitchStatus(Ticket $ticket, string $platformStatus): string
|
||||||
|
{
|
||||||
|
if ((string) ($ticket['status'] ?? 'hidden') !== 'normal') {
|
||||||
|
return 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $platformStatus === 'online' ? 'normal' : 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Number $row
|
* @param Number $row
|
||||||
*/
|
*/
|
||||||
private function updateExistingNumber($row, string $platformStatus, int $newFollowers): void
|
private function updateExistingNumber(
|
||||||
{
|
Ticket $ticket,
|
||||||
|
$row,
|
||||||
|
string $platformStatus,
|
||||||
|
int $newFollowers
|
||||||
|
): void {
|
||||||
$update = [
|
$update = [
|
||||||
'platform_status' => $platformStatus,
|
'platform_status' => $platformStatus,
|
||||||
'updatetime' => time(),
|
'updatetime' => time(),
|
||||||
@@ -116,7 +146,6 @@ class SplitTicketNumberSyncService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进线人数由同步写入,最终开关由 applyNumberRules 统一判定(单号上限/下号比率等)
|
|
||||||
$update['inbound_count'] = max(0, $newFollowers);
|
$update['inbound_count'] = max(0, $newFollowers);
|
||||||
Number::where('id', (int) $row['id'])->update($update);
|
Number::where('id', (int) $row['id'])->update($update);
|
||||||
}
|
}
|
||||||
@@ -139,7 +168,7 @@ class SplitTicketNumberSyncService
|
|||||||
'inbound_count' => max(0, $newFollowers),
|
'inbound_count' => max(0, $newFollowers),
|
||||||
'manual_manage' => 0,
|
'manual_manage' => 0,
|
||||||
'platform_status' => $platformStatus,
|
'platform_status' => $platformStatus,
|
||||||
'status' => 'hidden',
|
'ratio_deferred' => 0,
|
||||||
'createtime' => $now,
|
'createtime' => $now,
|
||||||
'updatetime' => $now,
|
'updatetime' => $now,
|
||||||
];
|
];
|
||||||
@@ -148,10 +177,11 @@ class SplitTicketNumberSyncService
|
|||||||
Db::name('split_number')->insert($data);
|
Db::name('split_number')->insert($data);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$exists = Number::where('split_link_id', (int) $ticket['split_link_id'])
|
$exists = Number::where('split_link_id', (int) $ticket['split_link_id'])
|
||||||
|
->where('ticket_name', (string) $ticket['ticket_name'])
|
||||||
->where('number', $number)
|
->where('number', $number)
|
||||||
->find();
|
->find();
|
||||||
if ($exists) {
|
if ($exists) {
|
||||||
$this->updateExistingNumber($exists, $platformStatus, $newFollowers);
|
$this->updateExistingNumber($ticket, $exists, $platformStatus, $newFollowers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,40 @@ use app\admin\model\split\Ticket;
|
|||||||
*/
|
*/
|
||||||
class SplitTicketRuleService
|
class SplitTicketRuleService
|
||||||
{
|
{
|
||||||
|
private SplitNumberRatioDeferredService $ratioDeferredService;
|
||||||
|
|
||||||
|
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
|
||||||
|
{
|
||||||
|
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步后应用全部规则并写回工单/号码
|
* 手动关闭的工单(manual_manage=1):禁止自动同步改写 status,须用户手动开启
|
||||||
|
*/
|
||||||
|
public function isManuallyClosed(Ticket $ticket): bool
|
||||||
|
{
|
||||||
|
return (int) ($ticket['manual_manage'] ?? 0) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表/编辑切换状态时同步 manual_manage 标记
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $values
|
||||||
|
*/
|
||||||
|
public function applyManualManageForStatusChange(array &$values): void
|
||||||
|
{
|
||||||
|
if (!isset($values['status'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((string) $values['status'] === 'hidden') {
|
||||||
|
$values['manual_manage'] = 1;
|
||||||
|
} elseif ((string) $values['status'] === 'normal') {
|
||||||
|
$values['manual_manage'] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率)
|
||||||
*/
|
*/
|
||||||
public function applyAfterSync(Ticket $ticket, int $completeCount): void
|
public function applyAfterSync(Ticket $ticket, int $completeCount): void
|
||||||
{
|
{
|
||||||
@@ -22,10 +54,11 @@ class SplitTicketRuleService
|
|||||||
if ($fresh) {
|
if ($fresh) {
|
||||||
if ((string) $fresh['status'] === 'hidden') {
|
if ((string) $fresh['status'] === 'hidden') {
|
||||||
$this->cascadeTicketClosedToNumbers($fresh);
|
$this->cascadeTicketClosedToNumbers($fresh);
|
||||||
}
|
} else {
|
||||||
$this->applyNumberRules($fresh);
|
$this->applyNumberRules($fresh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步流程:工单因时间/完成量等原因关闭时,联动关闭非手动号码
|
* 同步流程:工单因时间/完成量等原因关闭时,联动关闭非手动号码
|
||||||
@@ -43,10 +76,33 @@ class SplitTicketRuleService
|
|||||||
'status' => 'hidden',
|
'status' => 'hidden',
|
||||||
'updatetime' => time(),
|
'updatetime' => time(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshForTicket($ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动切换工单状态时,联动非手动管理的号码
|
* 工单物理删除时,移除该工单关联的全部号码(含 manual_manage=1)
|
||||||
|
*
|
||||||
|
* 关联键与 SplitTicketNumberSyncService 一致:admin_id + split_link_id + ticket_name
|
||||||
|
*
|
||||||
|
* @return int 删除行数
|
||||||
|
*/
|
||||||
|
public function deleteSyncedNumbersForTicket(Ticket $ticket): int
|
||||||
|
{
|
||||||
|
$ticketName = trim((string) ($ticket['ticket_name'] ?? ''));
|
||||||
|
$linkId = (int) ($ticket['split_link_id'] ?? 0);
|
||||||
|
if ($ticketName === '' || $linkId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number::where('admin_id', (int) $ticket['admin_id'])
|
||||||
|
->where('split_link_id', $linkId)
|
||||||
|
->where('ticket_name', $ticketName)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动切换工单状态时,非手动号码按业务规则对齐开关
|
||||||
*/
|
*/
|
||||||
public function syncNumbersWithTicketStatus(Ticket $ticket): void
|
public function syncNumbersWithTicketStatus(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
@@ -61,7 +117,7 @@ class SplitTicketRuleService
|
|||||||
/**
|
/**
|
||||||
* 工单处于开启状态时,将云控在线的非手动号码设为开启
|
* 工单处于开启状态时,将云控在线的非手动号码设为开启
|
||||||
*
|
*
|
||||||
* @deprecated 请使用 applyNumberRules(会校验单号上限/下号比率)
|
* @deprecated 请使用 applyNumberRules
|
||||||
*/
|
*/
|
||||||
public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void
|
public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
@@ -69,7 +125,7 @@ class SplitTicketRuleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单号上限、下号比率、云控在线状态、工单开关综合决定号码状态
|
* 单号上限、下号比率、云控在线状态、工单开关综合决定号码 status;同步时清除或确认 ratio_deferred
|
||||||
*/
|
*/
|
||||||
public function applyNumberRules(Ticket $ticket): void
|
public function applyNumberRules(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
@@ -84,14 +140,13 @@ class SplitTicketRuleService
|
|||||||
foreach ($numbers as $number) {
|
foreach ($numbers as $number) {
|
||||||
$visitCount = (int) $number['visit_count'];
|
$visitCount = (int) $number['visit_count'];
|
||||||
$inboundCount = (int) $number['inbound_count'];
|
$inboundCount = (int) $number['inbound_count'];
|
||||||
$lastVisit = (int) ($number['last_sync_visit_count'] ?? 0);
|
|
||||||
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
||||||
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
||||||
|
|
||||||
if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) {
|
if ($visitCount <= 0) {
|
||||||
$streak += ($visitCount - $lastVisit);
|
|
||||||
} elseif ($inboundCount > $lastInbound) {
|
|
||||||
$streak = 0;
|
$streak = 0;
|
||||||
|
} else {
|
||||||
|
$streak = $this->ratioDeferredService->computeStreakForSync($number, $streak);
|
||||||
}
|
}
|
||||||
|
|
||||||
$update = [
|
$update = [
|
||||||
@@ -101,12 +156,26 @@ class SplitTicketRuleService
|
|||||||
'updatetime' => time(),
|
'updatetime' => time(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态
|
$clearDeferred = [
|
||||||
|
'ratio_deferred' => 0,
|
||||||
|
'ratio_deferred_at' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 手动管理:更新统计;无效降权(无落地页访问)一并清除
|
||||||
if ((int) $number['manual_manage'] === 1) {
|
if ((int) $number['manual_manage'] === 1) {
|
||||||
|
if ($this->ratioDeferredService->isInvalidDeferredRecord($number) || $visitCount <= 0) {
|
||||||
|
$update = array_merge($update, $clearDeferred);
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
$update['no_inbound_click_streak'] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Number::where('id', (int) $number['id'])->update($update);
|
Number::where('id', (int) $number['id'])->update($update);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步裁决 ratio_deferred:同步时清除访问侧降权,由 streak 决定是否关号
|
||||||
|
$update = array_merge($update, $clearDeferred);
|
||||||
|
|
||||||
$update['status'] = $this->resolveAutomatedStatus(
|
$update['status'] = $this->resolveAutomatedStatus(
|
||||||
$ticket,
|
$ticket,
|
||||||
(string) ($number['platform_status'] ?? 'unknown'),
|
(string) ($number['platform_status'] ?? 'unknown'),
|
||||||
@@ -118,6 +187,8 @@ class SplitTicketRuleService
|
|||||||
|
|
||||||
Number::where('id', (int) $number['id'])->update($update);
|
Number::where('id', (int) $number['id'])->update($update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshForTicket($ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,10 +219,13 @@ class SplitTicketRuleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 完成量、时间窗口决定工单开关(定时与手动同步均适用)
|
* 完成量、时间窗口决定工单开关(定时与手动同步均适用;手动关闭工单除外)
|
||||||
*/
|
*/
|
||||||
public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void
|
public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void
|
||||||
{
|
{
|
||||||
|
if ($this->isManuallyClosed($ticket)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$status = $this->resolveTicketStatus($ticket, $completeCount);
|
$status = $this->resolveTicketStatus($ticket, $completeCount);
|
||||||
if ($status !== (string) ($ticket['status'] ?? 'hidden')) {
|
if ($status !== (string) ($ticket['status'] ?? 'hidden')) {
|
||||||
Ticket::where('id', (int) $ticket['id'])->update([
|
Ticket::where('id', (int) $ticket['id'])->update([
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
|
use think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单同步 CLI 后台投递
|
||||||
|
*
|
||||||
|
* Web / Cron 仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
|
||||||
|
* 避免长时间占用 PHP-FPM、Cron 全局锁或 Session 锁。
|
||||||
|
*
|
||||||
|
* 并行策略:
|
||||||
|
* - API 优先类型(Whatshub 等):独立并行投递,不占 Node Real Browser 槽位
|
||||||
|
* - 直连 PHP 类型:独立并行投递
|
||||||
|
* - Node 类型:不同 sessionKey(域名)并行;同一 sessionKey 内串行以命中 Browser 温池
|
||||||
|
*/
|
||||||
|
class SplitTicketSyncDispatchService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 投递手工同步任务到后台 CLI
|
||||||
|
*
|
||||||
|
* @param int[] $ticketIds 已通过权限校验的工单 ID
|
||||||
|
* @return array{queued:int[],skipped:int[],failed:int[]}
|
||||||
|
*/
|
||||||
|
public function dispatchManual(array $ticketIds): array
|
||||||
|
{
|
||||||
|
$lockService = new SplitTicketSyncLockService();
|
||||||
|
$metaList = $this->loadTicketMetaList($ticketIds);
|
||||||
|
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
|
||||||
|
$toDispatch = [];
|
||||||
|
$skipped = [];
|
||||||
|
$failed = [];
|
||||||
|
|
||||||
|
foreach ($metaList as $meta) {
|
||||||
|
$ticketId = (int) $meta['id'];
|
||||||
|
if ((int) ($meta['manual_manage'] ?? 0) === 1) {
|
||||||
|
$skipped[] = $ticketId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($lockService->isLocked($ticketId)) {
|
||||||
|
$skipped[] = $ticketId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$toDispatch[] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'manual');
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('web', 'manual sync dispatched', [
|
||||||
|
'queued' => $dispatchResult['queued'],
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'failed' => $dispatchResult['failed'],
|
||||||
|
'phpCli' => $this->resolvePhpBinary(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queued' => $dispatchResult['queued'],
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'failed' => $dispatchResult['failed'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron 自动同步:将挑选后的到期工单投递到后台 CLI(Cron 进程立即返回)
|
||||||
|
*
|
||||||
|
* @param list<array{id:int,ticket_type:string,ticket_url:string}|Ticket|\think\Model> $tickets
|
||||||
|
* @return array{
|
||||||
|
* queued:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* failed:list<array<string,mixed>>,
|
||||||
|
* stoppedByNodeBusy:bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function dispatchAuto(array $tickets): array
|
||||||
|
{
|
||||||
|
$lockService = new SplitTicketSyncLockService();
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
|
||||||
|
$toDispatch = [];
|
||||||
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
|
$skipped = [];
|
||||||
|
|
||||||
|
foreach ($tickets as $ticket) {
|
||||||
|
$meta = $this->normalizeTicketMeta($ticket);
|
||||||
|
if ($meta === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ticketId = (int) $meta['id'];
|
||||||
|
if ($lockService->isLocked($ticketId)) {
|
||||||
|
$skipped[] = $this->buildDispatchEntry(
|
||||||
|
$ticketId,
|
||||||
|
(string) $meta['ticket_type'],
|
||||||
|
(string) $meta['ticket_url'],
|
||||||
|
'工单正在同步中'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$toDispatch[] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'auto');
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('cron', 'auto sync dispatched', [
|
||||||
|
'queuedCount' => count($dispatchResult['queued']),
|
||||||
|
'skippedCount' => count($skipped) + count($dispatchResult['skipped']),
|
||||||
|
'failedCount' => count($dispatchResult['failed']),
|
||||||
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
||||||
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queued' => $dispatchResult['queued'],
|
||||||
|
'skipped' => array_merge($skipped, $dispatchResult['skipped']),
|
||||||
|
'failed' => $dispatchResult['failed'],
|
||||||
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询仍在同步中的工单 ID(依据 runtime 文件锁)
|
||||||
|
*
|
||||||
|
* @param int[] $ticketIds
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
public function filterSyncingIds(array $ticketIds): array
|
||||||
|
{
|
||||||
|
$lockService = new SplitTicketSyncLockService();
|
||||||
|
$syncing = [];
|
||||||
|
foreach ($ticketIds as $rawId) {
|
||||||
|
$ticketId = (int) $rawId;
|
||||||
|
if ($ticketId > 0 && $lockService->isLocked($ticketId)) {
|
||||||
|
$syncing[] = $ticketId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $syncing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{id:int,ticket_type:string,ticket_url:string}> $metaList
|
||||||
|
* @return array{
|
||||||
|
* queued:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* failed:list<array<string,mixed>>,
|
||||||
|
* stoppedByNodeBusy:bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function dispatchTicketMetaList(array $metaList, string $source): array
|
||||||
|
{
|
||||||
|
$php = $this->resolvePhpBinary();
|
||||||
|
$think = $this->resolveThinkScript();
|
||||||
|
$root = $this->resolveRootPath();
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $queued */
|
||||||
|
$queued = [];
|
||||||
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
|
$skipped = [];
|
||||||
|
/** @var list<array<string,mixed>> $failed */
|
||||||
|
$failed = [];
|
||||||
|
$stoppedByNodeBusy = false;
|
||||||
|
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $apiFirstList */
|
||||||
|
$apiFirstList = [];
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $directList */
|
||||||
|
$directList = [];
|
||||||
|
/** @var array<string, list<array{id:int,ticket_type:string,ticket_url:string}>> $nodeGroups */
|
||||||
|
$nodeGroups = [];
|
||||||
|
|
||||||
|
foreach ($metaList as $meta) {
|
||||||
|
$ticketType = (string) $meta['ticket_type'];
|
||||||
|
if (SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) {
|
||||||
|
$apiFirstList[] = $meta;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!SplitScrmSpiderFactory::requiresNode($ticketType)) {
|
||||||
|
$directList[] = $meta;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sessionKey = AntiBotConfigBuilder::resolveSessionKey(
|
||||||
|
$ticketType,
|
||||||
|
(string) $meta['ticket_url']
|
||||||
|
);
|
||||||
|
$nodeGroups[$sessionKey][] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($apiFirstList as $meta) {
|
||||||
|
$this->spawnSingleMeta($php, $think, $root, $meta, $source, $queued, $failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($directList as $meta) {
|
||||||
|
$this->spawnSingleMeta($php, $think, $root, $meta, $source, $queued, $failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($nodeGroups as $sessionKey => $group) {
|
||||||
|
if ($group === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $nodeReady */
|
||||||
|
$nodeReady = [];
|
||||||
|
foreach ($group as $meta) {
|
||||||
|
$ticketType = (string) $meta['ticket_type'];
|
||||||
|
if (!SplitNodeHealthService::canAcceptWorkForTicketType($ticketType)) {
|
||||||
|
$stoppedByNodeBusy = true;
|
||||||
|
$skipped[] = $this->buildDispatchEntry(
|
||||||
|
(int) $meta['id'],
|
||||||
|
$ticketType,
|
||||||
|
(string) $meta['ticket_url'],
|
||||||
|
'Node 队列繁忙,本轮未投递'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$nodeReady[] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($nodeReady === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($nodeReady) === 1) {
|
||||||
|
$this->spawnSingleMeta($php, $think, $root, $nodeReady[0], $source, $queued, $failed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_map(static function (array $row): int {
|
||||||
|
return (int) $row['id'];
|
||||||
|
}, $nodeReady);
|
||||||
|
|
||||||
|
if ($this->spawnCliSequential($php, $think, $root, $ids, $source)) {
|
||||||
|
foreach ($nodeReady as $meta) {
|
||||||
|
$queued[] = $this->buildQueuedEntry($meta, $sessionKey, 'node-group-serial');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($nodeReady as $meta) {
|
||||||
|
$failed[] = $this->buildDispatchEntry(
|
||||||
|
(int) $meta['id'],
|
||||||
|
(string) $meta['ticket_type'],
|
||||||
|
(string) $meta['ticket_url'],
|
||||||
|
'CLI 投递失败'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queued' => $queued,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'failed' => $failed,
|
||||||
|
'stoppedByNodeBusy' => $stoppedByNodeBusy,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $queued
|
||||||
|
* @param list<array<string,mixed>> $failed
|
||||||
|
* @param array{id:int,ticket_type:string,ticket_url:string} $meta
|
||||||
|
*/
|
||||||
|
private function spawnSingleMeta(
|
||||||
|
string $php,
|
||||||
|
string $think,
|
||||||
|
string $root,
|
||||||
|
array $meta,
|
||||||
|
string $source,
|
||||||
|
array &$queued,
|
||||||
|
array &$failed
|
||||||
|
): void {
|
||||||
|
$ticketId = (int) $meta['id'];
|
||||||
|
if ($this->spawnCli($php, $think, $root, $ticketId, $source)) {
|
||||||
|
$queued[] = $this->buildQueuedEntry($meta, '', 'parallel');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed[] = $this->buildDispatchEntry(
|
||||||
|
$ticketId,
|
||||||
|
(string) $meta['ticket_type'],
|
||||||
|
(string) $meta['ticket_url'],
|
||||||
|
'CLI 投递失败'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id:int,ticket_type:string,ticket_url:string} $meta
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function buildQueuedEntry(array $meta, string $sessionKey, string $mode): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticketId' => (int) $meta['id'],
|
||||||
|
'ticketType' => (string) $meta['ticket_type'],
|
||||||
|
'ticketUrl' => (string) $meta['ticket_url'],
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
'mode' => $mode,
|
||||||
|
'dispatched' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}
|
||||||
|
*/
|
||||||
|
private function buildDispatchEntry(int $ticketId, string $ticketType, string $ticketUrl, string $reason): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'ticketType' => $ticketType,
|
||||||
|
'ticketUrl' => $ticketUrl,
|
||||||
|
'reason' => $reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $ticketIds
|
||||||
|
* @return list<array{id:int,ticket_type:string,ticket_url:string}>
|
||||||
|
*/
|
||||||
|
private function loadTicketMetaList(array $ticketIds): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
foreach ($ticketIds as $rawId) {
|
||||||
|
$ticketId = (int) $rawId;
|
||||||
|
if ($ticketId > 0) {
|
||||||
|
$ids[] = $ticketId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($ids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = Db::name('split_ticket')
|
||||||
|
->where('id', 'in', $ids)
|
||||||
|
->field('id,ticket_type,ticket_url,manual_manage')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$list[] = [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'ticket_type' => (string) ($row['ticket_type'] ?? ''),
|
||||||
|
'ticket_url' => (string) ($row['ticket_url'] ?? ''),
|
||||||
|
'manual_manage' => (int) ($row['manual_manage'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed>|object $ticket
|
||||||
|
* @return array{id:int,ticket_type:string,ticket_url:string}|null
|
||||||
|
*/
|
||||||
|
private function normalizeTicketMeta($ticket): ?array
|
||||||
|
{
|
||||||
|
if (is_object($ticket) && method_exists($ticket, 'toArray')) {
|
||||||
|
$ticket = $ticket->toArray();
|
||||||
|
}
|
||||||
|
if (!is_array($ticket)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$ticketId = (int) ($ticket['id'] ?? 0);
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $ticketId,
|
||||||
|
'ticket_type' => (string) ($ticket['ticket_type'] ?? ''),
|
||||||
|
'ticket_url' => (string) ($ticket['ticket_url'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台启动 split:sync-tickets CLI(单工单,独立进程)
|
||||||
|
*/
|
||||||
|
private function spawnCli(string $php, string $think, string $root, int $ticketId, string $source): bool
|
||||||
|
{
|
||||||
|
$logDir = $this->resolveLogDir();
|
||||||
|
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
||||||
|
|
||||||
|
$syncMode = $this->normalizeSyncModeForCli($source);
|
||||||
|
$inner = sprintf(
|
||||||
|
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
|
||||||
|
escapeshellarg($php),
|
||||||
|
escapeshellarg($think),
|
||||||
|
$ticketId,
|
||||||
|
$syncMode,
|
||||||
|
escapeshellarg($logFile)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->runBackgroundCommand($root, $inner, $ticketId, $source . '-parallel');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一 sessionKey 内串行执行多个 CLI(单 nohup 子 shell)
|
||||||
|
*
|
||||||
|
* @param int[] $ticketIds
|
||||||
|
*/
|
||||||
|
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds, string $source): bool
|
||||||
|
{
|
||||||
|
if ($ticketIds === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logDir = $this->resolveLogDir();
|
||||||
|
$parts = [];
|
||||||
|
foreach ($ticketIds as $ticketId) {
|
||||||
|
$ticketId = (int) $ticketId;
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
||||||
|
$syncMode = $this->normalizeSyncModeForCli($source);
|
||||||
|
$parts[] = sprintf(
|
||||||
|
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
|
||||||
|
escapeshellarg($php),
|
||||||
|
escapeshellarg($think),
|
||||||
|
$ticketId,
|
||||||
|
$syncMode,
|
||||||
|
escapeshellarg($logFile)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($parts === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inner = implode('; ', $parts);
|
||||||
|
|
||||||
|
return $this->runBackgroundCommand($root, $inner, $ticketIds, $source . '-node-serial');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int|int[] $ticketRef 日志用 ticketId 或 ID 列表
|
||||||
|
*/
|
||||||
|
private function runBackgroundCommand(string $root, string $inner, $ticketRef, string $mode): bool
|
||||||
|
{
|
||||||
|
if ($this->isWindows()) {
|
||||||
|
$command = sprintf('start /B cmd /C %s', $inner);
|
||||||
|
} else {
|
||||||
|
$command = sprintf('cd %s && nohup bash -c %s &', escapeshellarg($root), escapeshellarg($inner));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->canUseExec()) {
|
||||||
|
@exec($command, $output, $exitCode);
|
||||||
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn exec', [
|
||||||
|
'ticketRef' => $ticketRef,
|
||||||
|
'mode' => $mode,
|
||||||
|
'command' => $command,
|
||||||
|
'exitCode' => $exitCode,
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->canUseShellExec()) {
|
||||||
|
@shell_exec($command);
|
||||||
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn shell_exec', [
|
||||||
|
'ticketRef' => $ticketRef,
|
||||||
|
'mode' => $mode,
|
||||||
|
'command' => $command,
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn failed: exec disabled', [
|
||||||
|
'ticketRef' => $ticketRef,
|
||||||
|
'mode' => $mode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePhpBinary(): string
|
||||||
|
{
|
||||||
|
if (method_exists(SplitSyncConfigService::class, 'getCliPhpBinary')) {
|
||||||
|
return SplitSyncConfigService::getCliPhpBinary();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolvePhpBinaryFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当 SplitSyncConfigService 未部署 getCliPhpBinary 时的兜底解析
|
||||||
|
*/
|
||||||
|
private function resolvePhpBinaryFallback(): string
|
||||||
|
{
|
||||||
|
if (defined('PHP_BINDIR') && PHP_BINDIR !== '') {
|
||||||
|
$candidate = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php';
|
||||||
|
if ($this->isUsableCliPhp($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['/usr/bin/php', '/usr/local/bin/php'] as $candidate) {
|
||||||
|
if ($this->isUsableCliPhp($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'php';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isUsableCliPhp(string $path): bool
|
||||||
|
{
|
||||||
|
if (stripos($path, 'fpm') !== false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($path === 'php') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return is_file($path) && is_executable($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveThinkScript(): string
|
||||||
|
{
|
||||||
|
$root = $this->resolveRootPath();
|
||||||
|
return rtrim($root, '/\\') . DIRECTORY_SEPARATOR . 'think';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRootPath(): string
|
||||||
|
{
|
||||||
|
if (defined('ROOT_PATH') && ROOT_PATH !== '') {
|
||||||
|
return rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(dirname(__DIR__, 3), '/\\') . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLogDir(): string
|
||||||
|
{
|
||||||
|
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : ($this->resolveRootPath() . 'runtime' . DIRECTORY_SEPARATOR);
|
||||||
|
$dir = rtrim($runtime, '/\\') . DIRECTORY_SEPARATOR . 'split_ticket_sync' . DIRECTORY_SEPARATOR;
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isWindows(): bool
|
||||||
|
{
|
||||||
|
return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function disabledFunctions(): array
|
||||||
|
{
|
||||||
|
$raw = (string) ini_get('disable_functions');
|
||||||
|
if ($raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter(array_map('trim', explode(',', $raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canUseExec(): bool
|
||||||
|
{
|
||||||
|
return function_exists('exec') && !in_array('exec', $this->disabledFunctions(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canUseShellExec(): bool
|
||||||
|
{
|
||||||
|
return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投递 CLI 时携带 --mode,供 sync_success_mode 区分自动/手动
|
||||||
|
*/
|
||||||
|
private function normalizeSyncModeForCli(string $source): string
|
||||||
|
{
|
||||||
|
return in_array($source, ['auto', 'manual'], true) ? $source : 'manual';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单同步失败分阶段重试状态机
|
||||||
|
*
|
||||||
|
* 达连续失败阈值后按配置时间点(相对锚点的绝对分钟偏移)自动重试;
|
||||||
|
* 任一次成功则清零恢复常态,全部失败则永久终止自动同步。
|
||||||
|
*/
|
||||||
|
class SplitTicketSyncFailRetryService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 是否已永久终止自动同步(重试时间点已全部用尽)
|
||||||
|
*
|
||||||
|
* @param Ticket|array<string, mixed> $ticket
|
||||||
|
*/
|
||||||
|
public function isPermanentlyStopped($ticket): bool
|
||||||
|
{
|
||||||
|
return (int) ($this->readField($ticket, 'sync_auto_sync_stopped') ?? 0) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否处于失败重试等待/重试周期内(未永久终止)
|
||||||
|
*
|
||||||
|
* @param Ticket|array<string, mixed> $ticket
|
||||||
|
*/
|
||||||
|
public function isInRetryMode($ticket): bool
|
||||||
|
{
|
||||||
|
if ($this->isPermanentlyStopped($ticket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首次达失败阈值时进入重试态(工单保持 normal,不关单)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> 需合并进 save 的字段;无变更时返回空数组
|
||||||
|
*/
|
||||||
|
public function enterRetryIfNeeded(Ticket $ticket, int $failCount, int $threshold): array
|
||||||
|
{
|
||||||
|
if ($failCount < $threshold || !SplitSyncConfigService::hasFailRetrySchedule()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if ($this->isInRetryMode($ticket)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sync_fail_pause_at' => time(),
|
||||||
|
'sync_fail_retry_index' => 0,
|
||||||
|
'sync_auto_sync_stopped' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计划内重试失败后递进索引;用尽全部时间点后标记永久终止
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function onRetryFailure(Ticket $ticket): array
|
||||||
|
{
|
||||||
|
$schedule = SplitSyncConfigService::getFailRetryMinutes();
|
||||||
|
$index = (int) ($ticket['sync_fail_retry_index'] ?? 0) + 1;
|
||||||
|
$update = ['sync_fail_retry_index' => $index];
|
||||||
|
|
||||||
|
if ($index >= count($schedule)) {
|
||||||
|
$update['sync_auto_sync_stopped'] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $update;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步成功时清零失败计数与重试相关字段
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function clearOnSuccessFields(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sync_fail_count' => 0,
|
||||||
|
'sync_fail_pause_at' => null,
|
||||||
|
'sync_fail_retry_index' => 0,
|
||||||
|
'sync_auto_sync_stopped' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron 自动重试是否已到点;未到点返回 skip 原因,到点返回 null(允许执行)
|
||||||
|
*
|
||||||
|
* @param Ticket|array<string, mixed> $ticket
|
||||||
|
*/
|
||||||
|
public function shouldAllowAutoRetry($ticket, ?int $now = null): ?string
|
||||||
|
{
|
||||||
|
if (!$this->isInRetryMode($ticket)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($this->isPermanentlyStopped($ticket)) {
|
||||||
|
return '自动同步已终止(重试已用尽)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextAt = $this->getNextRetryAt($ticket);
|
||||||
|
if ($nextAt === null) {
|
||||||
|
return '自动同步已终止(重试已用尽)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = $now ?? time();
|
||||||
|
if ($now < $nextAt) {
|
||||||
|
$attempt = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0) + 1;
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'失败重试等待中,将于 %s 进行第 %d 次重试',
|
||||||
|
date('Y-m-d H:i:s', $nextAt),
|
||||||
|
$attempt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下一次计划重试的 Unix 时间戳;无待重试时返回 null
|
||||||
|
*
|
||||||
|
* @param Ticket|array<string, mixed> $ticket
|
||||||
|
*/
|
||||||
|
public function getNextRetryAt($ticket): ?int
|
||||||
|
{
|
||||||
|
if (!$this->isInRetryMode($ticket) || $this->isPermanentlyStopped($ticket)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schedule = SplitSyncConfigService::getFailRetryMinutes();
|
||||||
|
if ($schedule === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$index = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0);
|
||||||
|
if ($index >= count($schedule)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pauseAt = (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0);
|
||||||
|
if ($pauseAt <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pauseAt + $schedule[$index] * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Ticket|array<string, mixed> $ticket
|
||||||
|
*/
|
||||||
|
private function readField($ticket, string $field)
|
||||||
|
{
|
||||||
|
if ($ticket instanceof Ticket) {
|
||||||
|
return $ticket->getData($field) ?? ($ticket[$field] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ticket[$field] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,20 @@ class SplitTicketSyncLockService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单是否处于同步中(锁文件存在且未过期)
|
||||||
|
*/
|
||||||
|
public function isLocked(int $ticketId): bool
|
||||||
|
{
|
||||||
|
$path = $this->lockPath($ticketId);
|
||||||
|
if ($this->isStaleLock($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_file($path);
|
||||||
|
}
|
||||||
|
|
||||||
private function lockPath(int $ticketId): string
|
private function lockPath(int $ticketId): string
|
||||||
{
|
{
|
||||||
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ namespace app\common\service;
|
|||||||
use think\Config;
|
use think\Config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工单云控同步调试日志(仅 app_debug=true 时写入 runtime/log/split_sync.log)
|
* 工单云控同步日志
|
||||||
|
* - logCron:定时任务汇总,app_debug 关闭时也写入 runtime/log/split_sync.log
|
||||||
|
* - log:详细调试日志,仅 app_debug=true 时写入
|
||||||
*/
|
*/
|
||||||
class SplitTicketSyncLogger
|
class SplitTicketSyncLogger
|
||||||
{
|
{
|
||||||
@@ -36,6 +38,18 @@ class SplitTicketSyncLogger
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 定时任务汇总日志(始终写入)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function logCron(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
self::writeLine('cron', $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详细调试日志(仅 app_debug=true)
|
||||||
|
*
|
||||||
* @param array<string, mixed> $context
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public static function log(string $stage, string $message, array $context = []): void
|
public static function log(string $stage, string $message, array $context = []): void
|
||||||
@@ -43,7 +57,14 @@ class SplitTicketSyncLogger
|
|||||||
if (!self::isEnabled()) {
|
if (!self::isEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self::writeLine($stage, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private static function writeLine(string $stage, string $message, array $context = []): void
|
||||||
|
{
|
||||||
$context = self::sanitize($context);
|
$context = self::sanitize($context);
|
||||||
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
||||||
$line = sprintf(
|
$line = sprintf(
|
||||||
@@ -83,8 +104,8 @@ class SplitTicketSyncLogger
|
|||||||
$out[$key] = self::sanitize($value);
|
$out[$key] = self::sanitize($value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (is_string($value) && mb_strlen($value) > 800) {
|
if (is_string($value) && mb_strlen($value) > 2000) {
|
||||||
$out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)';
|
$out[$key] = mb_substr($value, 0, 2000, 'UTF-8') . '...(truncated)';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$out[$key] = $value;
|
$out[$key] = $value;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace app\common\service;
|
namespace app\common\service;
|
||||||
|
|
||||||
use app\admin\model\split\Ticket;
|
use app\admin\model\split\Ticket;
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Exception;
|
use think\Exception;
|
||||||
@@ -20,20 +21,27 @@ class SplitTicketSyncService
|
|||||||
|
|
||||||
private SplitTicketSyncLockService $lockService;
|
private SplitTicketSyncLockService $lockService;
|
||||||
|
|
||||||
|
private SplitTicketSyncDispatchService $dispatchService;
|
||||||
|
|
||||||
|
private SplitTicketSyncFailRetryService $failRetryService;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->numberSync = new SplitTicketNumberSyncService();
|
$this->numberSync = new SplitTicketNumberSyncService();
|
||||||
$this->ruleService = new SplitTicketRuleService();
|
$this->ruleService = new SplitTicketRuleService();
|
||||||
$this->lockService = new SplitTicketSyncLockService();
|
$this->lockService = new SplitTicketSyncLockService();
|
||||||
|
$this->dispatchService = new SplitTicketSyncDispatchService();
|
||||||
|
$this->failRetryService = new SplitTicketSyncFailRetryService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步单条工单
|
* 同步单条工单
|
||||||
*
|
*
|
||||||
* @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入)
|
* @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入)
|
||||||
|
* @param string $syncMode auto=定时自动 manual=手动触发
|
||||||
* @return array{success:bool,message:string,skipped?:bool}
|
* @return array{success:bool,message:string,skipped?:bool}
|
||||||
*/
|
*/
|
||||||
public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false): array
|
public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false, string $syncMode = 'manual'): array
|
||||||
{
|
{
|
||||||
$ticket = Ticket::get($ticketId);
|
$ticket = Ticket::get($ticketId);
|
||||||
if (!$ticket) {
|
if (!$ticket) {
|
||||||
@@ -44,13 +52,21 @@ class SplitTicketSyncService
|
|||||||
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
|
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
|
||||||
SplitTicketSyncLogger::log('sync', 'syncOne start', [
|
SplitTicketSyncLogger::log('sync', 'syncOne start', [
|
||||||
'force' => $force,
|
'force' => $force,
|
||||||
|
'syncMode' => $syncMode,
|
||||||
'status' => (string) $ticket['status'],
|
'status' => (string) $ticket['status'],
|
||||||
|
'manualManage' => (int) ($ticket['manual_manage'] ?? 0),
|
||||||
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
|
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
|
||||||
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
|
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
|
||||||
'pageUrl' => (string) $ticket['ticket_url'],
|
'pageUrl' => (string) $ticket['ticket_url'],
|
||||||
'nodeHost' => SplitSyncConfigService::getNodeHost(),
|
'nodeHost' => SplitSyncConfigService::getNodeHost(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($this->ruleService->isManuallyClosed($ticket)) {
|
||||||
|
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => '工单已手动关闭']);
|
||||||
|
SplitTicketSyncLogger::clearTicketContext();
|
||||||
|
return ['success' => false, 'message' => '工单已手动关闭,暂停同步', 'skipped' => true];
|
||||||
|
}
|
||||||
|
|
||||||
if (!$force && !$dueValidated) {
|
if (!$force && !$dueValidated) {
|
||||||
$skip = $this->shouldSkip($ticket);
|
$skip = $this->shouldSkip($ticket);
|
||||||
if ($skip !== null) {
|
if ($skip !== null) {
|
||||||
@@ -67,7 +83,7 @@ class SplitTicketSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->doSync($ticket);
|
$result = $this->doSync($ticket, $syncMode);
|
||||||
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
|
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
|
||||||
return $result;
|
return $result;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,72 +93,411 @@ class SplitTicketSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描到期工单并同步(限流 + Node 队列感知)
|
* 扫描到期工单并同步(直连 PHP 与 Node 双通道)
|
||||||
*/
|
*/
|
||||||
public function syncDueTickets(): int
|
public function syncDueTickets(): int
|
||||||
{
|
{
|
||||||
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
|
|
||||||
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
|
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
|
||||||
if ($autoSyncTypes === []) {
|
if ($autoSyncTypes === []) {
|
||||||
SplitTicketSyncLogger::log('cron', 'scan skip', ['reason' => 'no auto-sync ticket types']);
|
SplitTicketSyncLogger::logCron('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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
|
$directTypes = array_values(array_intersect(
|
||||||
if ($failThreshold > 0) {
|
$autoSyncTypes,
|
||||||
$query->where('sync_fail_count', '<', $failThreshold);
|
SplitScrmSpiderFactory::listDirectSyncTypes()
|
||||||
}
|
));
|
||||||
// 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单
|
$nodeTypes = array_values(array_intersect(
|
||||||
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
|
$autoSyncTypes,
|
||||||
->limit($maxPerRun * 5)
|
SplitScrmSpiderFactory::listNodeSyncTypes()
|
||||||
->select();
|
));
|
||||||
|
|
||||||
SplitTicketSyncLogger::log('cron', 'scan start', [
|
SplitTicketSyncLogger::logCron('scan start', [
|
||||||
'candidateCount' => count($list),
|
|
||||||
'maxPerRun' => $maxPerRun,
|
|
||||||
'autoSyncTypes' => $autoSyncTypes,
|
'autoSyncTypes' => $autoSyncTypes,
|
||||||
|
'directTypes' => $directTypes,
|
||||||
|
'nodeTypes' => $nodeTypes,
|
||||||
|
'failThreshold' => $failThreshold,
|
||||||
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$count = 0;
|
$directResult = $this->runDirectCronChannel($directTypes, $failThreshold);
|
||||||
|
$nodeResult = $this->runNodeCronChannel($nodeTypes, $failThreshold);
|
||||||
|
|
||||||
|
$processedCount = $directResult['processedCount'] + $nodeResult['processedCount'];
|
||||||
|
$processed = array_merge($directResult['processed'], $nodeResult['processed']);
|
||||||
|
$skipped = array_merge($directResult['skipped'], $nodeResult['skipped']);
|
||||||
|
$candidateIds = array_merge($directResult['candidateIds'], $nodeResult['candidateIds']);
|
||||||
|
$openNotSynced = $this->buildOpenNotSyncedAudit(
|
||||||
|
$autoSyncTypes,
|
||||||
|
$failThreshold,
|
||||||
|
SplitSyncConfigService::getMaxTicketsPerCronRun(),
|
||||||
|
$candidateIds,
|
||||||
|
$processed,
|
||||||
|
$skipped,
|
||||||
|
$nodeResult['stoppedByNodeBusy']
|
||||||
|
);
|
||||||
|
|
||||||
|
$successCount = count(array_filter($processed, static function (array $row): bool {
|
||||||
|
return !empty($row['dispatched']);
|
||||||
|
}));
|
||||||
|
$failedCount = count($processed) - $successCount;
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::logCron('scan end', [
|
||||||
|
'processedCount' => $processedCount,
|
||||||
|
'successCount' => $successCount,
|
||||||
|
'failedCount' => $failedCount,
|
||||||
|
'skippedCount' => count($skipped),
|
||||||
|
'directChannel' => $directResult['summary'],
|
||||||
|
'nodeChannel' => $nodeResult['summary'],
|
||||||
|
'processed' => $processed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'openNotSynced' => $openNotSynced,
|
||||||
|
]);
|
||||||
|
SplitTicketSyncLogger::log('cron', 'scan end', [
|
||||||
|
'processedCount' => $processedCount,
|
||||||
|
'successCount' => $successCount,
|
||||||
|
'failedCount' => $failedCount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $processedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直连 PHP 通道:挑选到期工单并投递后台 CLI,不占 Node 名额
|
||||||
|
*
|
||||||
|
* @param list<string> $directTypes
|
||||||
|
* @return array{
|
||||||
|
* processedCount:int,
|
||||||
|
* processed:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* candidateIds:list<int>,
|
||||||
|
* summary:array<string,mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function runDirectCronChannel(array $directTypes, int $failThreshold): array
|
||||||
|
{
|
||||||
|
if ($directTypes === []) {
|
||||||
|
return $this->emptyCronChannelResult('direct');
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $this->loadOpenTicketsForTypes($directTypes, $failThreshold, 0);
|
||||||
|
$candidateIds = $this->extractTicketIds($list);
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $dueList */
|
||||||
|
$dueList = [];
|
||||||
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
|
$skipped = [];
|
||||||
|
|
||||||
foreach ($list as $ticket) {
|
foreach ($list as $ticket) {
|
||||||
if ($count >= $maxPerRun) {
|
$ticketId = (int) $ticket['id'];
|
||||||
break;
|
$ticketType = (string) $ticket['ticket_type'];
|
||||||
|
$ticketUrl = (string) $ticket['ticket_url'];
|
||||||
|
|
||||||
|
$skip = $this->shouldSkip($ticket);
|
||||||
|
if ($skip !== null) {
|
||||||
|
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skip);
|
||||||
|
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip, 'direct');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$dueList[] = $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::logCron('direct channel start', [
|
||||||
|
'types' => $directTypes,
|
||||||
|
'candidateCount' => count($list),
|
||||||
|
'dueCount' => count($dueList),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatchResult = $this->dispatchService->dispatchAuto($dueList);
|
||||||
|
$skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped']));
|
||||||
|
$failed = $this->mapDispatchFailedToCron($dispatchResult['failed']);
|
||||||
|
|
||||||
|
foreach ($failed as $row) {
|
||||||
|
$skipped[] = $row;
|
||||||
|
$this->logCronCandidateSkipped(
|
||||||
|
(int) $row['ticketId'],
|
||||||
|
(string) $row['ticketType'],
|
||||||
|
(string) $row['ticketUrl'],
|
||||||
|
(string) $row['reason'],
|
||||||
|
'direct'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $processed */
|
||||||
|
$processed = [];
|
||||||
|
foreach ($dispatchResult['queued'] as $row) {
|
||||||
|
$processed[] = [
|
||||||
|
'ticketId' => (int) ($row['ticketId'] ?? 0),
|
||||||
|
'ticketType' => (string) ($row['ticketType'] ?? ''),
|
||||||
|
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
|
||||||
|
'channel' => 'direct',
|
||||||
|
'dispatched' => true,
|
||||||
|
'message' => '已投递后台同步',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'processedCount' => count($processed),
|
||||||
|
'processed' => $processed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'candidateIds' => $candidateIds,
|
||||||
|
'summary' => [
|
||||||
|
'channel' => 'direct',
|
||||||
|
'processedCount' => count($processed),
|
||||||
|
'skippedCount' => count($skipped),
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
|
],
|
||||||
|
'stoppedByNodeBusy' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 通道:限流 + 类型公平轮转 + 异步 CLI 投递
|
||||||
|
*
|
||||||
|
* @param list<string> $nodeTypes
|
||||||
|
* @return array{
|
||||||
|
* processedCount:int,
|
||||||
|
* processed:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* candidateIds:list<int>,
|
||||||
|
* summary:array<string,mixed>,
|
||||||
|
* stoppedByNodeBusy:bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function runNodeCronChannel(array $nodeTypes, int $failThreshold): array
|
||||||
|
{
|
||||||
|
if ($nodeTypes === []) {
|
||||||
|
return $this->emptyCronChannelResult('node');
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
|
||||||
|
$poolMultiplier = SplitSyncConfigService::getNodeCandidatePoolMultiplier();
|
||||||
|
$candidateLimit = $maxPerRun * $poolMultiplier;
|
||||||
|
|
||||||
|
$pool = $this->loadOpenTicketsForTypes($nodeTypes, $failThreshold, $candidateLimit);
|
||||||
|
$candidateIds = $this->extractTicketIds($pool);
|
||||||
|
$duePool = [];
|
||||||
|
foreach ($pool as $ticket) {
|
||||||
|
if ($this->shouldSkip($ticket) === null) {
|
||||||
|
$duePool[] = $ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$picked = $this->pickFairNodeTickets($duePool, $maxPerRun);
|
||||||
|
$picked = $this->sortTicketsBySessionKey($picked);
|
||||||
|
$pickedIdMap = array_fill_keys($this->extractTicketIds($picked), true);
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::logCron('node channel start', [
|
||||||
|
'types' => $nodeTypes,
|
||||||
|
'maxPerRun' => $maxPerRun,
|
||||||
|
'poolCount' => count($pool),
|
||||||
|
'dueCount' => count($duePool),
|
||||||
|
'pickedCount' => count($picked),
|
||||||
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
|
$skipped = [];
|
||||||
|
foreach ($pool as $ticket) {
|
||||||
|
$ticketId = (int) $ticket['id'];
|
||||||
|
if (isset($pickedIdMap[$ticketId])) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
$skip = $this->shouldSkip($ticket);
|
$skip = $this->shouldSkip($ticket);
|
||||||
if ($skip !== null) {
|
if ($skip !== null) {
|
||||||
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
|
|
||||||
'ticketId' => (int) $ticket['id'],
|
|
||||||
'ticketType' => (string) $ticket['ticket_type'],
|
|
||||||
'reason' => $skip,
|
|
||||||
]);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!SplitNodeHealthService::canAcceptWork()) {
|
$skipped[] = $this->buildCronTicketEntry(
|
||||||
SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [
|
$ticketId,
|
||||||
'processed' => $count,
|
(string) $ticket['ticket_type'],
|
||||||
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
(string) $ticket['ticket_url'],
|
||||||
]);
|
sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit)
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
$result = $this->syncOne((int) $ticket['id'], false, true);
|
|
||||||
if (!empty($result['skipped'])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$count++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SplitTicketSyncLogger::log('cron', 'scan end', ['processedCount' => $count]);
|
$dispatchResult = $this->dispatchService->dispatchAuto($picked);
|
||||||
return $count;
|
$skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped']));
|
||||||
|
$failed = $this->mapDispatchFailedToCron($dispatchResult['failed']);
|
||||||
|
|
||||||
|
foreach ($failed as $row) {
|
||||||
|
$skipped[] = $row;
|
||||||
|
$this->logCronCandidateSkipped(
|
||||||
|
(int) $row['ticketId'],
|
||||||
|
(string) $row['ticketType'],
|
||||||
|
(string) $row['ticketUrl'],
|
||||||
|
(string) $row['reason'],
|
||||||
|
'node'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $processed */
|
||||||
|
$processed = [];
|
||||||
|
foreach ($dispatchResult['queued'] as $row) {
|
||||||
|
$processed[] = [
|
||||||
|
'ticketId' => (int) ($row['ticketId'] ?? 0),
|
||||||
|
'ticketType' => (string) ($row['ticketType'] ?? ''),
|
||||||
|
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
|
||||||
|
'sessionKey' => (string) ($row['sessionKey'] ?? ''),
|
||||||
|
'channel' => 'node',
|
||||||
|
'dispatched' => true,
|
||||||
|
'message' => '已投递后台同步',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'processedCount' => count($processed),
|
||||||
|
'processed' => $processed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'candidateIds' => $candidateIds,
|
||||||
|
'summary' => [
|
||||||
|
'channel' => 'node',
|
||||||
|
'processedCount' => count($processed),
|
||||||
|
'skippedCount' => count($skipped),
|
||||||
|
'maxPerRun' => $maxPerRun,
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
|
],
|
||||||
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $rows
|
||||||
|
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
|
||||||
|
*/
|
||||||
|
private function mapDispatchSkippedToCron(array $rows): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$result[] = $this->buildCronTicketEntry(
|
||||||
|
(int) ($row['ticketId'] ?? 0),
|
||||||
|
(string) ($row['ticketType'] ?? ''),
|
||||||
|
(string) ($row['ticketUrl'] ?? ''),
|
||||||
|
(string) ($row['reason'] ?? '已跳过')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $rows
|
||||||
|
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
|
||||||
|
*/
|
||||||
|
private function mapDispatchFailedToCron(array $rows): array
|
||||||
|
{
|
||||||
|
return $this->mapDispatchSkippedToCron($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $types
|
||||||
|
* @return list<Ticket>
|
||||||
|
*/
|
||||||
|
private function loadOpenTicketsForTypes(array $types, int $failThreshold, int $limit): array
|
||||||
|
{
|
||||||
|
if ($types === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = Ticket::where('status', 'normal')
|
||||||
|
->where('manual_manage', 0)
|
||||||
|
->whereIn('ticket_type', $types);
|
||||||
|
if ($failThreshold > 0) {
|
||||||
|
$query->where(function ($query) use ($failThreshold): void {
|
||||||
|
$query->where('sync_fail_count', '<', $failThreshold)
|
||||||
|
->whereOr(function ($query): void {
|
||||||
|
$query->where('sync_fail_pause_at', '>', 0)
|
||||||
|
->where('sync_auto_sync_stopped', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$query->order('sync_time', 'asc')->order('id', 'asc');
|
||||||
|
if ($limit > 0) {
|
||||||
|
$query->limit($limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $query->select();
|
||||||
|
return $list instanceof \think\Collection ? $list->all() : (array) $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 通道:按工单类型公平轮转选取(每轮每类型优先取 1 条)
|
||||||
|
*
|
||||||
|
* @param list<Ticket> $dueTickets
|
||||||
|
* @return list<Ticket>
|
||||||
|
*/
|
||||||
|
private function pickFairNodeTickets(array $dueTickets, int $maxPerRun): array
|
||||||
|
{
|
||||||
|
if ($dueTickets === [] || $maxPerRun <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, list<Ticket>> $byType */
|
||||||
|
$byType = [];
|
||||||
|
foreach ($dueTickets as $ticket) {
|
||||||
|
$type = (string) $ticket['ticket_type'];
|
||||||
|
$byType[$type][] = $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeKeys = array_keys($byType);
|
||||||
|
sort($typeKeys, SORT_STRING);
|
||||||
|
|
||||||
|
/** @var list<Ticket> $picked */
|
||||||
|
$picked = [];
|
||||||
|
while (count($picked) < $maxPerRun) {
|
||||||
|
$added = false;
|
||||||
|
foreach ($typeKeys as $type) {
|
||||||
|
if (count($picked) >= $maxPerRun) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ($byType[$type] === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$picked[] = array_shift($byType[$type]);
|
||||||
|
$added = true;
|
||||||
|
}
|
||||||
|
if (!$added) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Ticket|array<string,mixed>> $list
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function extractTicketIds(array $list): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
foreach ($list as $row) {
|
||||||
|
$ids[] = (int) (is_array($row) ? ($row['id'] ?? 0) : ($row['id'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* processedCount:int,
|
||||||
|
* processed:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* candidateIds:list<int>,
|
||||||
|
* summary:array<string,mixed>,
|
||||||
|
* stoppedByNodeBusy:bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function emptyCronChannelResult(string $channel): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'processedCount' => 0,
|
||||||
|
'processed' => [],
|
||||||
|
'skipped' => [],
|
||||||
|
'candidateIds' => [],
|
||||||
|
'summary' => ['channel' => $channel, 'processedCount' => 0, 'skippedCount' => 0],
|
||||||
|
'stoppedByNodeBusy' => false,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,8 +519,17 @@ class SplitTicketSyncService
|
|||||||
/**
|
/**
|
||||||
* @return array{success:bool,message:string}
|
* @return array{success:bool,message:string}
|
||||||
*/
|
*/
|
||||||
private function doSync(Ticket $ticket): array
|
private function doSync(Ticket $ticket, string $syncMode): array
|
||||||
{
|
{
|
||||||
|
$freshTicket = Ticket::get((int) $ticket['id']);
|
||||||
|
if (!$freshTicket) {
|
||||||
|
return ['success' => false, 'message' => '工单不存在', 'skipped' => true];
|
||||||
|
}
|
||||||
|
if ($this->ruleService->isManuallyClosed($freshTicket)) {
|
||||||
|
return ['success' => false, 'message' => '工单已手动关闭,暂停同步', 'skipped' => true];
|
||||||
|
}
|
||||||
|
$ticket = $freshTicket;
|
||||||
|
|
||||||
$ticketType = (string) $ticket['ticket_type'];
|
$ticketType = (string) $ticket['ticket_type'];
|
||||||
$pageUrl = trim((string) $ticket['ticket_url']);
|
$pageUrl = trim((string) $ticket['ticket_url']);
|
||||||
if ($pageUrl === '') {
|
if ($pageUrl === '') {
|
||||||
@@ -211,9 +575,9 @@ class SplitTicketSyncService
|
|||||||
$freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket;
|
$freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket;
|
||||||
if ((string) $freshTicket['status'] === 'hidden') {
|
if ((string) $freshTicket['status'] === 'hidden') {
|
||||||
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
||||||
}
|
} else {
|
||||||
// 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线)
|
|
||||||
$this->ruleService->applyNumberRules($freshTicket);
|
$this->ruleService->applyNumberRules($freshTicket);
|
||||||
|
}
|
||||||
$ticket = $freshTicket;
|
$ticket = $freshTicket;
|
||||||
|
|
||||||
$inboundCount = $this->numberSync->sumInboundForTicket($ticket);
|
$inboundCount = $this->numberSync->sumInboundForTicket($ticket);
|
||||||
@@ -232,13 +596,13 @@ class SplitTicketSyncService
|
|||||||
'speed_snapshot_time' => $speed['snapshot_time'],
|
'speed_snapshot_time' => $speed['snapshot_time'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->applySyncResult($ticket, $payload, true, '');
|
$this->applySyncResult($ticket, $payload, true, '', $syncMode);
|
||||||
Db::commit();
|
Db::commit();
|
||||||
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
|
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
|
||||||
return ['success' => true, 'message' => '同步成功'];
|
return ['success' => true, 'message' => '同步成功'];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
$msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8');
|
$msg = $e->getMessage();
|
||||||
SplitTicketSyncLogger::log('sync', 'exception', [
|
SplitTicketSyncLogger::log('sync', 'exception', [
|
||||||
'type' => get_class($e),
|
'type' => get_class($e),
|
||||||
'message' => $msg,
|
'message' => $msg,
|
||||||
@@ -252,9 +616,33 @@ class SplitTicketSyncService
|
|||||||
|
|
||||||
private function shouldSkip(Ticket $ticket): ?string
|
private function shouldSkip(Ticket $ticket): ?string
|
||||||
{
|
{
|
||||||
|
if ($this->ruleService->isManuallyClosed($ticket)) {
|
||||||
|
return '工单已手动关闭,暂停同步';
|
||||||
|
}
|
||||||
if ((string) $ticket['status'] === 'hidden') {
|
if ((string) $ticket['status'] === 'hidden') {
|
||||||
return '工单已关闭';
|
return '工单已关闭';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->failRetryService->isPermanentlyStopped($ticket)) {
|
||||||
|
return '自动同步已终止(重试已用尽)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->failRetryService->isInRetryMode($ticket)) {
|
||||||
|
$retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket);
|
||||||
|
if ($retrySkip !== null) {
|
||||||
|
return $retrySkip;
|
||||||
|
}
|
||||||
|
if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) {
|
||||||
|
return '工单类型尚未实现';
|
||||||
|
}
|
||||||
|
$interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']);
|
||||||
|
if ($interval <= 0) {
|
||||||
|
return '该类型未配置自动同步周期';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
|
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
|
||||||
return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
|
return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
|
||||||
@@ -282,8 +670,9 @@ class SplitTicketSyncService
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $payload
|
* @param array<string, mixed> $payload
|
||||||
*/
|
*/
|
||||||
public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void
|
public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = '', string $syncMode = 'manual'): void
|
||||||
{
|
{
|
||||||
|
$now = time();
|
||||||
$data = [
|
$data = [
|
||||||
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
||||||
'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)),
|
'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)),
|
||||||
@@ -293,12 +682,17 @@ class SplitTicketSyncService
|
|||||||
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
|
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
|
||||||
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
||||||
'sync_status' => $success ? 'success' : 'error',
|
'sync_status' => $success ? 'success' : 'error',
|
||||||
'sync_time' => time(),
|
'sync_time' => $now,
|
||||||
'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'),
|
'sync_message' => $success ? '' : $message,
|
||||||
'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1),
|
'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1),
|
||||||
'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0),
|
'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0),
|
||||||
'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0),
|
'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0),
|
||||||
];
|
];
|
||||||
|
if ($success) {
|
||||||
|
$data['sync_success_time'] = $now;
|
||||||
|
$data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual';
|
||||||
|
$data = array_merge($data, $this->failRetryService->clearOnSuccessFields());
|
||||||
|
}
|
||||||
if (!$ticket->allowField(array_keys($data))->save($data)) {
|
if (!$ticket->allowField(array_keys($data))->save($data)) {
|
||||||
throw new Exception('工单同步结果保存失败');
|
throw new Exception('工单同步结果保存失败');
|
||||||
}
|
}
|
||||||
@@ -313,10 +707,24 @@ class SplitTicketSyncService
|
|||||||
$update = [
|
$update = [
|
||||||
'sync_status' => 'error',
|
'sync_status' => 'error',
|
||||||
'sync_time' => time(),
|
'sync_time' => time(),
|
||||||
'sync_message' => mb_substr($message, 0, 255, 'UTF-8'),
|
'sync_message' => $message,
|
||||||
'sync_fail_count' => $failCount,
|
'sync_fail_count' => $failCount,
|
||||||
];
|
];
|
||||||
// 新建工单首次同步失败:立即关闭;已同步过的工单仍按连续失败阈值关闭
|
|
||||||
|
if ($this->failRetryService->isInRetryMode($ticket)) {
|
||||||
|
$update = array_merge($update, $this->failRetryService->onRetryFailure($ticket));
|
||||||
|
$ticket->save($update);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failThreshold > 0 && $failCount >= $failThreshold && SplitSyncConfigService::hasFailRetrySchedule()) {
|
||||||
|
$update = array_merge($update, $this->failRetryService->enterRetryIfNeeded($ticket, $failCount, $failThreshold));
|
||||||
|
$ticket->save($update);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
|
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
|
||||||
$update['status'] = 'hidden';
|
$update['status'] = 'hidden';
|
||||||
}
|
}
|
||||||
@@ -365,4 +773,177 @@ class SplitTicketSyncService
|
|||||||
'snapshot_time' => $snapshotTime,
|
'snapshot_time' => $snapshotTime,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}
|
||||||
|
*/
|
||||||
|
private function buildCronTicketEntry(int $ticketId, string $ticketType, string $ticketUrl, string $reason): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'ticketType' => $ticketType,
|
||||||
|
'ticketUrl' => $ticketUrl,
|
||||||
|
'reason' => $reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logCronCandidateSkipped(int $ticketId, string $ticketType, string $ticketUrl, string $reason, string $channel = 'legacy'): void
|
||||||
|
{
|
||||||
|
$ctx = [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'ticketType' => $ticketType,
|
||||||
|
'ticketUrl' => $ticketUrl,
|
||||||
|
'reason' => $reason,
|
||||||
|
'channel' => $channel,
|
||||||
|
];
|
||||||
|
SplitTicketSyncLogger::logCron('candidate skipped', $ctx);
|
||||||
|
SplitTicketSyncLogger::log('cron', 'candidate skipped', $ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开启但未在本轮同步的工单及原因(供 cron 汇总)
|
||||||
|
*
|
||||||
|
* @param list<int> $candidateIds
|
||||||
|
* @param list<array<string, mixed>> $processed
|
||||||
|
* @param list<array<string, mixed>> $skipped
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function buildOpenNotSyncedAudit(
|
||||||
|
array $autoSyncTypes,
|
||||||
|
int $failThreshold,
|
||||||
|
int $maxPerRun,
|
||||||
|
array $candidateIds,
|
||||||
|
array $processed,
|
||||||
|
array $skipped,
|
||||||
|
bool $stoppedByNodeBusy
|
||||||
|
): array {
|
||||||
|
$handledIds = [];
|
||||||
|
foreach ($processed as $row) {
|
||||||
|
$handledIds[(int) ($row['ticketId'] ?? 0)] = true;
|
||||||
|
}
|
||||||
|
foreach ($skipped as $row) {
|
||||||
|
$handledIds[(int) ($row['ticketId'] ?? 0)] = true;
|
||||||
|
}
|
||||||
|
$candidateIdMap = array_fill_keys($candidateIds, true);
|
||||||
|
|
||||||
|
$supportedTypes = SplitScrmSpiderFactory::listSupportedTypes();
|
||||||
|
if ($supportedTypes === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$openList = Ticket::where('status', 'normal')
|
||||||
|
->whereIn('ticket_type', $supportedTypes)
|
||||||
|
->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status,sync_fail_pause_at,sync_fail_retry_index,sync_auto_sync_stopped,status,manual_manage')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($openList as $ticketRow) {
|
||||||
|
$ticket = $ticketRow instanceof Ticket ? $ticketRow->toArray() : (array) $ticketRow;
|
||||||
|
$ticketId = (int) ($ticket['id'] ?? 0);
|
||||||
|
if ($ticketId <= 0 || isset($handledIds[$ticketId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketType = (string) ($ticket['ticket_type'] ?? '');
|
||||||
|
$ticketUrl = (string) ($ticket['ticket_url'] ?? '');
|
||||||
|
$reason = $this->resolveOpenNotSyncedReason(
|
||||||
|
$ticket,
|
||||||
|
$autoSyncTypes,
|
||||||
|
$failThreshold,
|
||||||
|
$maxPerRun,
|
||||||
|
$candidateIdMap,
|
||||||
|
$stoppedByNodeBusy
|
||||||
|
);
|
||||||
|
if ($reason === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'ticketType' => $ticketType,
|
||||||
|
'ticketUrl' => $ticketUrl,
|
||||||
|
'reason' => $reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $ticket
|
||||||
|
* @param array<string, bool> $candidateIdMap
|
||||||
|
*/
|
||||||
|
private function resolveOpenNotSyncedReason(
|
||||||
|
array $ticket,
|
||||||
|
array $autoSyncTypes,
|
||||||
|
int $failThreshold,
|
||||||
|
int $maxPerRun,
|
||||||
|
array $candidateIdMap,
|
||||||
|
bool $stoppedByNodeBusy
|
||||||
|
): ?string {
|
||||||
|
$ticketType = (string) ($ticket['ticket_type'] ?? '');
|
||||||
|
$failCount = (int) ($ticket['sync_fail_count'] ?? 0);
|
||||||
|
|
||||||
|
if ($this->failRetryService->isPermanentlyStopped($ticket)) {
|
||||||
|
return '自动同步已终止(重试已用尽)';
|
||||||
|
}
|
||||||
|
if ($this->failRetryService->isInRetryMode($ticket)) {
|
||||||
|
$retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket);
|
||||||
|
if ($retrySkip !== null) {
|
||||||
|
return $retrySkip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($failThreshold > 0 && $failCount >= $failThreshold && !SplitSyncConfigService::hasFailRetrySchedule()) {
|
||||||
|
return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold);
|
||||||
|
}
|
||||||
|
if (!in_array($ticketType, $autoSyncTypes, true)) {
|
||||||
|
return '该类型未配置自动同步周期';
|
||||||
|
}
|
||||||
|
if (!isset($candidateIdMap[(int) ($ticket['id'] ?? 0)])) {
|
||||||
|
if (SplitScrmSpiderFactory::requiresNode($ticketType)) {
|
||||||
|
$poolSize = SplitSyncConfigService::getMaxTicketsPerCronRun()
|
||||||
|
* SplitSyncConfigService::getNodeCandidatePoolMultiplier();
|
||||||
|
return sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $poolSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '直连通道:未到同步周期或本轮已处理';
|
||||||
|
}
|
||||||
|
if ($stoppedByNodeBusy) {
|
||||||
|
return 'Node 队列繁忙,本轮未执行';
|
||||||
|
}
|
||||||
|
|
||||||
|
$skip = $this->shouldSkip(Ticket::get((int) $ticket['id']) ?: new Ticket($ticket));
|
||||||
|
return $skip ?? '未知原因,未进入执行队列';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 sessionKey(ticketType:host)分组排序,使同域名工单连续执行以命中 Browser 温池
|
||||||
|
*
|
||||||
|
* @param \think\Collection|array<int, Ticket|array<string, mixed>> $list
|
||||||
|
* @return list<Ticket|array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sortTicketsBySessionKey($list): array
|
||||||
|
{
|
||||||
|
$rows = $list instanceof \think\Collection ? $list->all() : (array) $list;
|
||||||
|
usort($rows, static function ($a, $b): int {
|
||||||
|
$typeA = (string) (is_array($a) ? ($a['ticket_type'] ?? '') : ($a['ticket_type'] ?? ''));
|
||||||
|
$urlA = (string) (is_array($a) ? ($a['ticket_url'] ?? '') : ($a['ticket_url'] ?? ''));
|
||||||
|
$typeB = (string) (is_array($b) ? ($b['ticket_type'] ?? '') : ($b['ticket_type'] ?? ''));
|
||||||
|
$urlB = (string) (is_array($b) ? ($b['ticket_url'] ?? '') : ($b['ticket_url'] ?? ''));
|
||||||
|
$keyA = AntiBotConfigBuilder::resolveSessionKey($typeA, $urlA);
|
||||||
|
$keyB = AntiBotConfigBuilder::resolveSessionKey($typeB, $urlB);
|
||||||
|
if ($keyA === $keyB) {
|
||||||
|
$timeA = (int) (is_array($a) ? ($a['sync_time'] ?? 0) : ($a['sync_time'] ?? 0));
|
||||||
|
$timeB = (int) (is_array($b) ? ($b['sync_time'] ?? 0) : ($b['sync_time'] ?? 0));
|
||||||
|
if ($timeA === $timeB) {
|
||||||
|
$idA = (int) (is_array($a) ? ($a['id'] ?? 0) : ($a['id'] ?? 0));
|
||||||
|
$idB = (int) (is_array($b) ? ($b['id'] ?? 0) : ($b['id'] ?? 0));
|
||||||
|
return $idA <=> $idB;
|
||||||
|
}
|
||||||
|
return $timeA <=> $timeB;
|
||||||
|
}
|
||||||
|
return strcmp($keyA, $keyB);
|
||||||
|
});
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ return array (
|
|||||||
'name' => 'link管理系统',
|
'name' => 'link管理系统',
|
||||||
'beian' => '',
|
'beian' => '',
|
||||||
'cdnurl' => '',
|
'cdnurl' => '',
|
||||||
'version' => '1.0.14',
|
'version' => '1.0.10',
|
||||||
'timezone' => 'Asia/Shanghai',
|
'timezone' => 'Asia/Shanghai',
|
||||||
'forbiddenip' => '',
|
'forbiddenip' => '',
|
||||||
'languages' =>
|
'languages' =>
|
||||||
@@ -53,8 +53,15 @@ return array (
|
|||||||
'split_sync_interval_taiji' => '0',
|
'split_sync_interval_taiji' => '0',
|
||||||
'split_sync_interval_ss_channel' => '0',
|
'split_sync_interval_ss_channel' => '0',
|
||||||
'split_sync_interval_yifafa' => '0',
|
'split_sync_interval_yifafa' => '0',
|
||||||
'split_sync_interval_whatshub' => '5',
|
'split_sync_interval_whatshub' => '3',
|
||||||
'split_sync_interval_sihai' => '0',
|
'split_sync_interval_sihai' => '0',
|
||||||
'split_sync_fail_pause_threshold' => '6',
|
'split_sync_fail_pause_threshold' => '6',
|
||||||
|
'split_sync_fail_retry_minutes' => '',
|
||||||
'split_sync_interval_chatknow' => '5',
|
'split_sync_interval_chatknow' => '5',
|
||||||
|
'split_sync_max_per_cron' => '27',
|
||||||
|
'split_sync_node_candidate_multiplier' => '13',
|
||||||
|
'split_sync_cron_lock_ttl' => '680',
|
||||||
|
'split_sync_node_queue_threshold' => '8',
|
||||||
|
'split_scrm_antibot_types' => 'whatshub,chatknow,a2c,huojian',
|
||||||
|
'split_scrm_session_ttl_minutes' => '120',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 双通道定时同步:Node 通道上限与候选池配置(已有环境增量执行)
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_max_per_cron', 'split', 'Node通道单次同步上限', '每分钟 cron 在 Node 通道最多处理几条到期工单(仅影响需调用 Node 的类型);纯 PHP 直连类型不受此限制', 'number', '', '5', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_node_candidate_multiplier', 'split', 'Node通道候选池倍数', 'Node 候选工单数 = 单次上限 × 本倍数,用于公平轮转', 'number', '', '10', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_node_candidate_multiplier' LIMIT 1);
|
||||||
|
|
||||||
|
UPDATE `fa_config`
|
||||||
|
SET `title` = 'Node通道单次同步上限',
|
||||||
|
`tip` = '每分钟 cron 在 Node 通道最多处理几条到期工单(仅影响需调用 Node 的类型);纯 PHP 直连类型不受此限制'
|
||||||
|
WHERE `name` = 'split_sync_max_per_cron';
|
||||||
|
|
||||||
|
UPDATE `fa_config`
|
||||||
|
SET `value` = '5'
|
||||||
|
WHERE `name` = 'split_sync_max_per_cron' AND (`value` = '' OR `value` = '2');
|
||||||
@@ -6,9 +6,21 @@ SELECT 'split_scrm_node_host', 'split', '云控 Node 服务地址', '所有云
|
|||||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_scrm_node_host' LIMIT 1);
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_scrm_node_host' LIMIT 1);
|
||||||
|
|
||||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
SELECT 'split_sync_fail_pause_threshold', 'split', '连续同步失败暂停阈值', '连续同步失败达到该次数后自动关闭工单并暂停定时同步;同步成功后将清零重新计数;0 表示不因失败自动暂停', 'number', '', '5', '', '', '', NULL
|
SELECT 'split_scrm_antibot_types', 'split', '云控 antiBot 启用类型', '逗号分隔的工单类型标识;启用后走 Node unified 路径(CF 过盾 + real profile)。当前支持:whatshub, chatknow, a2c, huojian', 'string', '', 'whatshub,chatknow,a2c,huojian', '', '', ' placeholder="whatshub,chatknow,a2c,huojian"', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_scrm_antibot_types' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_scrm_session_ttl_minutes', 'split', '云控 antiBot 会话 TTL(分钟)', 'CF 过盾后 sessionKey 缓存有效期;默认 120', 'number', '', '120', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_scrm_session_ttl_minutes' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_fail_pause_threshold', 'split', '连续同步失败暂停阈值', '连续同步失败达到该次数后暂停定时同步;同步成功后将清零重新计数;0 表示不因失败自动暂停', 'number', '', '5', '', '', '', NULL
|
||||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_pause_threshold' LIMIT 1);
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_pause_threshold' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_fail_retry_minutes', 'split', '失败重试时间点(分钟)', '逗号分隔、升序,相对首次达失败暂停阈值后的绝对分钟数,如 10,20,30,50;留空则达阈值后永久暂停自动同步', 'string', '', '', '', '', ' placeholder="10,20,30,50"', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_retry_minutes' LIMIT 1);
|
||||||
|
|
||||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
SELECT 'split_sync_interval_a2c', 'split', 'A2C云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
SELECT 'split_sync_interval_a2c', 'split', 'A2C云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
||||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_a2c' LIMIT 1);
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_a2c' LIMIT 1);
|
||||||
@@ -58,9 +70,13 @@ SELECT 'split_sync_interval_sihai', 'split', '四海云控同步周期(分钟)',
|
|||||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_sihai' LIMIT 1);
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_sihai' LIMIT 1);
|
||||||
|
|
||||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
SELECT 'split_sync_max_per_cron', 'split', '单次定时同步上限', '每分钟 cron 最多处理几条到期工单,防止打满 Node Browser 槽位', 'number', '', '2', '', '', '', NULL
|
SELECT 'split_sync_max_per_cron', 'split', 'Node通道单次同步上限', '每分钟 cron 在 Node 通道最多处理几条到期工单(仅影响需调用 Node 的类型);纯 PHP 直连类型不受此限制', 'number', '', '5', '', '', '', NULL
|
||||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1);
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_node_candidate_multiplier', 'split', 'Node通道候选池倍数', 'Node 候选工单数 = 单次上限 × 本倍数,用于公平轮转', 'number', '', '10', '', '', '', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_node_candidate_multiplier' LIMIT 1);
|
||||||
|
|
||||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
SELECT 'split_sync_cron_lock_ttl', 'split', '定时同步全局锁TTL(秒)', '异常退出后自动释放全局锁,避免后续 cron 永久跳过', 'number', '', '1800', '', '', '', NULL
|
SELECT 'split_sync_cron_lock_ttl', 'split', '定时同步全局锁TTL(秒)', '异常退出后自动释放全局锁,避免后续 cron 永久跳过', 'number', '', '1800', '', '', '', NULL
|
||||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_cron_lock_ttl' LIMIT 1);
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_cron_lock_ttl' LIMIT 1);
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
-- 号码管理:清理无效降权池权限节点
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/cleanupdeferred', '清理降权池', 'fa fa-circle-o', '', '清除无落地页访问基线的无效 ratio_deferred 标记', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferred' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- 可选:一次性修复历史脏数据(与后台「清理降权池」逻辑一致)
|
||||||
|
-- UPDATE `fa_split_number`
|
||||||
|
-- SET `ratio_deferred` = 0, `ratio_deferred_at` = NULL,
|
||||||
|
-- `no_inbound_click_streak` = IF(`visit_count` <= 0, 0, `no_inbound_click_streak`)
|
||||||
|
-- WHERE `ratio_deferred` = 1
|
||||||
|
-- AND (`visit_count` <= 0 OR `visit_count` <= `last_sync_visit_count`);
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/cleanupdeferredpreview', '清理降权池预览', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferredpreview' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 下号比率触线降权:访问侧标记 ratio_deferred,同步侧裁决 status
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_number`
|
||||||
|
ADD COLUMN `ratio_deferred` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '下号比率触线降权:0=否,1=是(仍status=normal,选号时后置)' AFTER `no_inbound_click_streak`,
|
||||||
|
ADD COLUMN `ratio_deferred_at` bigint(16) DEFAULT NULL COMMENT '触线降权标记时间' AFTER `ratio_deferred`;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- 同步失败分阶段重试:系统配置 + 工单状态字段
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||||
|
SELECT 'split_sync_fail_retry_minutes', 'split', '失败重试时间点(分钟)', '逗号分隔、升序,相对首次达失败暂停阈值后的绝对分钟数,如 10,20,30,50;留空则达阈值后永久暂停自动同步', 'string', '', '', '', '', ' placeholder="10,20,30,50"', NULL
|
||||||
|
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_retry_minutes' LIMIT 1);
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD COLUMN `sync_fail_pause_at` bigint(16) DEFAULT NULL COMMENT '达阈值进入重试的锚点时间' AFTER `sync_fail_count`,
|
||||||
|
ADD COLUMN `sync_fail_retry_index` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '已完成的重试次数(0=尚未重试)' AFTER `sync_fail_pause_at`,
|
||||||
|
ADD COLUMN `sync_auto_sync_stopped` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '1=全部重试失败,永久终止自动同步' AFTER `sync_fail_retry_index`;
|
||||||
@@ -20,12 +20,14 @@ CREATE TABLE `fa_split_ticket` (
|
|||||||
`account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号',
|
`account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号',
|
||||||
`password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码',
|
`password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码',
|
||||||
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
|
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
|
||||||
|
`manual_manage` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '手动管理:0=否,1=是(手动关闭后暂停同步且禁止自动开启)',
|
||||||
`complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)',
|
`complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)',
|
||||||
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)',
|
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)',
|
||||||
`speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)',
|
`speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)',
|
||||||
`number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)',
|
`number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)',
|
||||||
`number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)',
|
`number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)',
|
||||||
`number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)',
|
`number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)',
|
||||||
|
`active_number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '本地开启号码数(status=normal)',
|
||||||
`online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)',
|
`online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)',
|
||||||
`sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态',
|
`sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态',
|
||||||
`sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间',
|
`sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间',
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 工单表:本地开启号码数(status=normal),列表直接读字段,避免子查询
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD COLUMN `active_number_count` int(10) unsigned NOT NULL DEFAULT 0
|
||||||
|
COMMENT '本地开启号码数(status=normal)' AFTER `number_banned_count`;
|
||||||
|
|
||||||
|
-- 历史数据回填
|
||||||
|
UPDATE `fa_split_ticket` t
|
||||||
|
SET t.`active_number_count` = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM `fa_split_number` n
|
||||||
|
WHERE n.`admin_id` = t.`admin_id`
|
||||||
|
AND n.`split_link_id` = t.`split_link_id`
|
||||||
|
AND n.`ticket_name` = t.`ticket_name`
|
||||||
|
AND n.`status` = 'normal'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 加速按工单统计 status=normal 数量
|
||||||
|
ALTER TABLE `fa_split_number`
|
||||||
|
ADD KEY `idx_ticket_active` (`admin_id`, `split_link_id`, `ticket_name`(50), `status`);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 工单手动管理:手动关闭后禁止自动同步与自动开启
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
ADD COLUMN `manual_manage` tinyint(1) unsigned NOT NULL DEFAULT 0
|
||||||
|
COMMENT '手动管理:0=否,1=是(手动关闭后暂停同步且禁止自动开启)' AFTER `status`;
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
-- 工单同步日志增强:最近成功时间/方式、完整失败原因
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
MODIFY COLUMN `sync_message` text COMMENT '同步失败完整原因',
|
||||||
|
ADD COLUMN `sync_success_time` bigint(16) DEFAULT NULL COMMENT '最近一次同步成功时间' AFTER `sync_time`,
|
||||||
|
ADD COLUMN `sync_success_mode` enum('auto','manual') DEFAULT NULL COMMENT '最近成功同步方式:auto=自动,manual=手动' AFTER `sync_success_time`;
|
||||||
|
|
||||||
|
UPDATE `fa_split_ticket`
|
||||||
|
SET `sync_success_time` = `sync_time`
|
||||||
|
WHERE `sync_status` = 'success'
|
||||||
|
AND `sync_success_time` IS NULL
|
||||||
|
AND `sync_time` IS NOT NULL
|
||||||
|
AND `sync_time` > 0;
|
||||||
@@ -18,6 +18,7 @@ use think\console\Output;
|
|||||||
* 用法:
|
* 用法:
|
||||||
* php think split:sync-tickets
|
* php think split:sync-tickets
|
||||||
* php think split:sync-tickets --ticket=12
|
* php think split:sync-tickets --ticket=12
|
||||||
|
* php think split:sync-tickets --ticket=12 --mode=auto
|
||||||
*/
|
*/
|
||||||
class SplitSyncTickets extends Command
|
class SplitSyncTickets extends Command
|
||||||
{
|
{
|
||||||
@@ -25,21 +26,24 @@ class SplitSyncTickets extends Command
|
|||||||
{
|
{
|
||||||
$this->setName('split:sync-tickets')
|
$this->setName('split:sync-tickets')
|
||||||
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
|
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
|
||||||
|
->addOption('mode', 'm', Option::VALUE_OPTIONAL, '同步方式: auto=定时自动, manual=手动触发', 'manual')
|
||||||
->setDescription('同步分流工单云控数据');
|
->setDescription('同步分流工单云控数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(Input $input, Output $output): void
|
protected function execute(Input $input, Output $output): void
|
||||||
{
|
{
|
||||||
set_time_limit(0);
|
set_time_limit(0);
|
||||||
|
$syncMode = $this->resolveSyncMode($input);
|
||||||
SplitTicketSyncLogger::log('cli', 'command start', [
|
SplitTicketSyncLogger::log('cli', 'command start', [
|
||||||
'ticketOption' => trim((string) $input->getOption('ticket')),
|
'ticketOption' => trim((string) $input->getOption('ticket')),
|
||||||
|
'syncMode' => $syncMode,
|
||||||
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
||||||
]);
|
]);
|
||||||
$service = new SplitTicketSyncService();
|
$service = new SplitTicketSyncService();
|
||||||
$ticketId = trim((string) $input->getOption('ticket'));
|
$ticketId = trim((string) $input->getOption('ticket'));
|
||||||
|
|
||||||
if ($ticketId !== '' && ctype_digit($ticketId)) {
|
if ($ticketId !== '' && ctype_digit($ticketId)) {
|
||||||
$result = $service->syncOne((int) $ticketId, true);
|
$result = $service->syncOne((int) $ticketId, true, false, $syncMode);
|
||||||
if (!empty($result['skipped'])) {
|
if (!empty($result['skipped'])) {
|
||||||
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
|
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
|
||||||
return;
|
return;
|
||||||
@@ -61,13 +65,24 @@ class SplitSyncTickets extends Command
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$count = $service->syncDueTickets();
|
$count = $service->syncDueTickets();
|
||||||
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
$output->writeln('<info>本次投递工单数: ' . $count . '</info>');
|
||||||
|
$output->writeln('<comment>同步在后台 CLI 进程中执行,汇总日志见 runtime/log/split_sync.log</comment>');
|
||||||
} finally {
|
} finally {
|
||||||
$cronLock->release();
|
$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>');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CLI 同步方式,供 sync_success_mode 落库
|
||||||
|
*/
|
||||||
|
private function resolveSyncMode(Input $input): string
|
||||||
|
{
|
||||||
|
$mode = strtolower(trim((string) $input->getOption('mode')));
|
||||||
|
|
||||||
|
return in_array($mode, ['auto', 'manual'], true) ? $mode : 'manual';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace app\admin\controller\split;
|
|||||||
use app\admin\model\split\Link as LinkModel;
|
use app\admin\model\split\Link as LinkModel;
|
||||||
use app\admin\model\split\Number as NumberModel;
|
use app\admin\model\split\Number as NumberModel;
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
|
use app\common\service\SplitTicketActiveNumberCountService;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Exception;
|
use think\Exception;
|
||||||
use think\exception\DbException;
|
use think\exception\DbException;
|
||||||
@@ -65,6 +66,7 @@ class Number extends Backend
|
|||||||
$this->assignconfig('statusList', $this->model->getStatusList());
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
||||||
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
||||||
|
$this->assignconfig('ratioDeferredList', $this->model->getRatioDeferredList());
|
||||||
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
|
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
|
||||||
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
|
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
|
||||||
|
|
||||||
@@ -224,6 +226,7 @@ class Number extends Backend
|
|||||||
$this->error($e->getMessage());
|
$this->error($e->getMessage());
|
||||||
}
|
}
|
||||||
if ($count > 0) {
|
if ($count > 0) {
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshForNumberIds(explode(',', (string) $ids));
|
||||||
$this->success();
|
$this->success();
|
||||||
}
|
}
|
||||||
$this->error(__('No rows were updated'));
|
$this->error(__('No rows were updated'));
|
||||||
@@ -330,6 +333,12 @@ class Number extends Backend
|
|||||||
$this->error($e->getMessage());
|
$this->error($e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshForTicketKeys(
|
||||||
|
(int) ($baseRow['admin_id'] ?? $adminId),
|
||||||
|
$splitLinkId,
|
||||||
|
(string) ($baseRow['ticket_name'] ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
$msg = __('Inserted %d number(s)', $inserted);
|
$msg = __('Inserted %d number(s)', $inserted);
|
||||||
if ($skipped > 0) {
|
if ($skipped > 0) {
|
||||||
$msg .= ',' . __('Skipped %d duplicate(s)', $skipped);
|
$msg .= ',' . __('Skipped %d duplicate(s)', $skipped);
|
||||||
@@ -377,6 +386,7 @@ class Number extends Backend
|
|||||||
}
|
}
|
||||||
|
|
||||||
$result = false;
|
$result = false;
|
||||||
|
$before = $row->getData();
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
if ($this->modelValidate) {
|
if ($this->modelValidate) {
|
||||||
@@ -396,9 +406,50 @@ class Number extends Backend
|
|||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
$this->error(__('No rows were updated'));
|
$this->error(__('No rows were updated'));
|
||||||
}
|
}
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshAfterNumberEdit($before, $row->getData());
|
||||||
$this->success();
|
$this->success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除号码后回写工单本地开启号码数
|
||||||
|
*
|
||||||
|
* @param string|null $ids
|
||||||
|
*/
|
||||||
|
public function del($ids = null)
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
$ids = $ids ?: $this->request->post('ids');
|
||||||
|
if (empty($ids)) {
|
||||||
|
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||||
|
}
|
||||||
|
$pk = $this->model->getPk();
|
||||||
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$this->model->where($this->dataLimitField, 'in', $adminIds);
|
||||||
|
}
|
||||||
|
$list = $this->model->where($pk, 'in', $ids)->select();
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$countService = new SplitTicketActiveNumberCountService();
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
foreach ($list as $item) {
|
||||||
|
$count += $item->delete();
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (PDOException|Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
if ($count) {
|
||||||
|
$countService->refreshFromNumberRows($list);
|
||||||
|
$this->success();
|
||||||
|
}
|
||||||
|
$this->error(__('No rows were deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量更新号码状态与手动管理
|
* 批量更新号码状态与手动管理
|
||||||
*/
|
*/
|
||||||
@@ -443,6 +494,7 @@ class Number extends Backend
|
|||||||
if ($count === false || $count === 0) {
|
if ($count === false || $count === 0) {
|
||||||
$this->error(__('No rows were updated'));
|
$this->error(__('No rows were updated'));
|
||||||
}
|
}
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshForNumberIds($ids);
|
||||||
$this->success(__('Batch update success'));
|
$this->success(__('Batch update success'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +547,8 @@ class Number extends Backend
|
|||||||
}
|
}
|
||||||
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
|
$countService = new SplitTicketActiveNumberCountService();
|
||||||
|
$keyRows = (clone $query)->field('admin_id,split_link_id,ticket_name')->select();
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
if ($action === 'delete') {
|
if ($action === 'delete') {
|
||||||
@@ -522,9 +576,51 @@ class Number extends Backend
|
|||||||
$this->error(__('No matching numbers found'));
|
$this->error(__('No matching numbers found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$countService->refreshFromNumberRows($keyRows);
|
||||||
$this->success(sprintf(__('Batch operate success'), $count));
|
$this->success(sprintf(__('Batch operate success'), $count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览待清理的无效降权号码数量
|
||||||
|
*/
|
||||||
|
public function cleanupdeferredpreview(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isAjax()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new \app\common\service\SplitNumberRatioDeferredService();
|
||||||
|
$adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null;
|
||||||
|
$count = $service->countInvalidDeferred(is_array($adminIds) ? $adminIds : null);
|
||||||
|
|
||||||
|
$this->success('', null, ['count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理无效降权号码池(无落地页访问基线却标记 ratio_deferred=1 的记录)
|
||||||
|
*/
|
||||||
|
public function cleanupdeferred(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new \app\common\service\SplitNumberRatioDeferredService();
|
||||||
|
$adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$count = $service->cleanupInvalidDeferred(is_array($adminIds) ? $adminIds : null);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($count <= 0) {
|
||||||
|
$this->success(__('Ratio deferred cleanup none'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success(sprintf(__('Ratio deferred cleanup success'), $count));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 排除不可由表单提交的字段
|
* 排除不可由表单提交的字段
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use app\admin\model\split\Link as LinkModel;
|
|||||||
use app\admin\model\split\Ticket as TicketModel;
|
use app\admin\model\split\Ticket as TicketModel;
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
use app\common\service\SplitTicketRuleService;
|
use app\common\service\SplitTicketRuleService;
|
||||||
|
use app\common\service\SplitTicketSyncDispatchService;
|
||||||
use app\common\service\SplitTicketSyncLogger;
|
use app\common\service\SplitTicketSyncLogger;
|
||||||
use app\common\service\SplitTicketSyncService;
|
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Lang;
|
use think\Lang;
|
||||||
use think\Loader;
|
use think\Loader;
|
||||||
@@ -39,7 +39,7 @@ class Ticket extends Backend
|
|||||||
protected $modelSceneValidate = true;
|
protected $modelSceneValidate = true;
|
||||||
|
|
||||||
/** @var string[] 无需鉴权的方法 */
|
/** @var string[] 无需鉴权的方法 */
|
||||||
protected $noNeedRight = ['script'];
|
protected $noNeedRight = ['script', 'syncpolling'];
|
||||||
|
|
||||||
/** @var string patches 视图目录 */
|
/** @var string patches 视图目录 */
|
||||||
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/';
|
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/';
|
||||||
@@ -64,6 +64,7 @@ class Ticket extends Backend
|
|||||||
'syncBackgroundStartedMsg' => __('Sync background started'),
|
'syncBackgroundStartedMsg' => __('Sync background started'),
|
||||||
'syncInProgressMsg' => __('Sync in progress'),
|
'syncInProgressMsg' => __('Sync in progress'),
|
||||||
'syncTicketStartedMsg' => __('Sync ticket started'),
|
'syncTicketStartedMsg' => __('Sync ticket started'),
|
||||||
|
'syncDoneMsg' => __('Sync done'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->setupPatchFrontend();
|
$this->setupPatchFrontend();
|
||||||
@@ -267,22 +268,39 @@ class Ticket extends Backend
|
|||||||
$params['inbound_count'],
|
$params['inbound_count'],
|
||||||
$params['speed_per_hour'],
|
$params['speed_per_hour'],
|
||||||
$params['number_count'],
|
$params['number_count'],
|
||||||
|
$params['active_number_count'],
|
||||||
$params['number_offline_count'],
|
$params['number_offline_count'],
|
||||||
$params['number_banned_count'],
|
$params['number_banned_count'],
|
||||||
$params['online_count'],
|
$params['online_count'],
|
||||||
$params['sync_status'],
|
$params['sync_status'],
|
||||||
$params['sync_time'],
|
$params['sync_time'],
|
||||||
$params['sync_message'],
|
$params['sync_message'],
|
||||||
|
$params['sync_success_time'],
|
||||||
|
$params['sync_success_mode'],
|
||||||
$params['sync_fail_count'],
|
$params['sync_fail_count'],
|
||||||
|
$params['sync_fail_pause_at'],
|
||||||
|
$params['sync_fail_retry_index'],
|
||||||
|
$params['sync_auto_sync_stopped'],
|
||||||
$params['speed_snapshot_count'],
|
$params['speed_snapshot_count'],
|
||||||
$params['speed_snapshot_time'],
|
$params['speed_snapshot_time'],
|
||||||
$params['click_count']
|
$params['click_count'],
|
||||||
|
$params['manual_manage']
|
||||||
);
|
);
|
||||||
return $params;
|
return $params;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动同步选中工单
|
* 用户切换工单状态时写入 manual_manage,防止同步自动恢复开启
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $values
|
||||||
|
*/
|
||||||
|
private function applyManualManageForStatusChange(array &$values): void
|
||||||
|
{
|
||||||
|
(new SplitTicketRuleService())->applyManualManageForStatusChange($values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动同步选中工单(投递 CLI 后台执行,Web 请求立即返回)
|
||||||
*/
|
*/
|
||||||
public function sync(): void
|
public function sync(): void
|
||||||
{
|
{
|
||||||
@@ -305,31 +323,84 @@ class Ticket extends Backend
|
|||||||
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = new SplitTicketSyncService();
|
/** @var int[] $allowedIds */
|
||||||
$ok = 0;
|
$allowedIds = [];
|
||||||
$fail = 0;
|
$denied = 0;
|
||||||
$messages = [];
|
|
||||||
|
|
||||||
foreach ($list as $row) {
|
foreach ($list as $row) {
|
||||||
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||||
$fail++;
|
$denied++;
|
||||||
$messages[] = '#' . $row['id'] . ': 无权限';
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$result = $service->syncOne((int) $row['id'], true);
|
$allowedIds[] = (int) $row['id'];
|
||||||
if ($result['success']) {
|
}
|
||||||
$ok++;
|
|
||||||
} else {
|
if ($allowedIds === []) {
|
||||||
$fail++;
|
$this->error(__('You have no permission'));
|
||||||
$messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败');
|
}
|
||||||
|
|
||||||
|
// 尽早释放 Session 锁,避免投递等待期间阻塞同账号其它后台请求(如编辑/新增弹窗)
|
||||||
|
if (function_exists('session_write_close')) {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatch = new SplitTicketSyncDispatchService();
|
||||||
|
$result = $dispatch->dispatchManual($allowedIds);
|
||||||
|
|
||||||
|
$queuedCount = count($result['queued']);
|
||||||
|
$skippedCount = count($result['skipped']);
|
||||||
|
$failedCount = count($result['failed']);
|
||||||
|
|
||||||
|
if ($queuedCount === 0) {
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$this->error(__('Sync all skipped busy'));
|
||||||
|
}
|
||||||
|
$this->error(__('Sync dispatch failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = sprintf(__('Sync dispatch queued'), $queuedCount);
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$summary .= sprintf(__('Sync dispatch skipped suffix'), $skippedCount);
|
||||||
|
}
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$summary .= sprintf(__('Sync dispatch failed suffix'), $failedCount);
|
||||||
|
}
|
||||||
|
if ($denied > 0) {
|
||||||
|
$summary .= sprintf(__('Sync dispatch denied suffix'), $denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success($summary, null, [
|
||||||
|
'queued' => $result['queued'],
|
||||||
|
'syncing' => $result['queued'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询手工同步进度(依据 runtime 文件锁)
|
||||||
|
*/
|
||||||
|
public function syncpolling(): void
|
||||||
|
{
|
||||||
|
$idsRaw = trim((string) $this->request->request('ids', ''));
|
||||||
|
if ($idsRaw === '') {
|
||||||
|
$this->success('', null, ['syncing' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var int[] $ticketIds */
|
||||||
|
$ticketIds = [];
|
||||||
|
foreach (explode(',', $idsRaw) as $part) {
|
||||||
|
$part = trim($part);
|
||||||
|
if ($part !== '' && ctype_digit($part)) {
|
||||||
|
$ticketIds[] = (int) $part;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail);
|
if ($ticketIds === []) {
|
||||||
if ($fail > 0) {
|
$this->success('', null, ['syncing' => []]);
|
||||||
$summary .= ';' . implode(';', array_slice($messages, 0, 5));
|
|
||||||
}
|
}
|
||||||
$this->success($summary);
|
|
||||||
|
$dispatch = new SplitTicketSyncDispatchService();
|
||||||
|
$syncing = $dispatch->filterSyncingIds($ticketIds);
|
||||||
|
|
||||||
|
$this->success('', null, ['syncing' => $syncing]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -352,6 +423,7 @@ class Ticket extends Backend
|
|||||||
$ruleService = new SplitTicketRuleService();
|
$ruleService = new SplitTicketRuleService();
|
||||||
|
|
||||||
if (isset($values['status'])) {
|
if (isset($values['status'])) {
|
||||||
|
$this->applyManualManageForStatusChange($values);
|
||||||
$pk = $this->model->getPk();
|
$pk = $this->model->getPk();
|
||||||
$adminIds = $this->getDataLimitAdminIds();
|
$adminIds = $this->getDataLimitAdminIds();
|
||||||
$query = $this->model->where($pk, 'in', $ids);
|
$query = $this->model->where($pk, 'in', $ids);
|
||||||
@@ -526,6 +598,9 @@ class Ticket extends Backend
|
|||||||
$params['number_type_custom'] = '';
|
$params['number_type_custom'] = '';
|
||||||
}
|
}
|
||||||
$oldStatus = (string) ($row['status'] ?? 'hidden');
|
$oldStatus = (string) ($row['status'] ?? 'hidden');
|
||||||
|
if (isset($params['status']) && (string) $params['status'] !== $oldStatus) {
|
||||||
|
$this->applyManualManageForStatusChange($params);
|
||||||
|
}
|
||||||
$result = false;
|
$result = false;
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -51,4 +51,20 @@ return [
|
|||||||
'Invalid manual manage' => '手动管理选项无效',
|
'Invalid manual manage' => '手动管理选项无效',
|
||||||
'Batch update success' => '批量更新成功',
|
'Batch update success' => '批量更新成功',
|
||||||
'Unit count' => '个',
|
'Unit count' => '个',
|
||||||
|
'Ratio_deferred' => '选号降权',
|
||||||
|
'Ratio deferred badge' => '降权中',
|
||||||
|
'Ratio deferred no' => '正常选号',
|
||||||
|
'Ratio deferred yes' => '降权中',
|
||||||
|
'Ratio deferred filter' => '仅显示降权中号码',
|
||||||
|
'Ratio deferred tooltip'=> '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决',
|
||||||
|
'Ratio deferred edit notice' => '该号码处于下号比率触线降权状态,同步后将根据进线情况恢复或关闭',
|
||||||
|
'No inbound click streak' => '无进线连续点击',
|
||||||
|
'Ratio deferred cleanup btn' => '清理降权池',
|
||||||
|
'Ratio deferred cleanup confirm' => '将清除 %d 条无效降权记录(无落地页访问或访问未超过同步基线)。确定继续?',
|
||||||
|
'Ratio deferred cleanup success' => '已清理 %d 条无效降权记录',
|
||||||
|
'Ratio deferred cleanup none' => '当前没有需要清理的无效降权记录',
|
||||||
|
'Stream loading' => '加载中...',
|
||||||
|
'Stream loaded' => '已加载 %loaded% / %total%,继续滚动加载更多',
|
||||||
|
'Stream loading bottom' => '加载中...',
|
||||||
|
'Stream loading pending' => '即将加载更多,可切换显示条数取消...',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -31,12 +31,29 @@ return [
|
|||||||
'Sync in progress' => '同步中',
|
'Sync in progress' => '同步中',
|
||||||
'Sync ticket started' => '正在同步工单',
|
'Sync ticket started' => '正在同步工单',
|
||||||
'Sync done' => '同步完成',
|
'Sync done' => '同步完成',
|
||||||
|
'Sync dispatch queued' => '已提交 %d 条同步任务到后台执行',
|
||||||
|
'Sync dispatch skipped suffix' => ',%d 条已在同步中已跳过',
|
||||||
|
'Sync dispatch failed suffix' => ',%d 条启动失败',
|
||||||
|
'Sync dispatch denied suffix' => ',%d 条无权限未提交',
|
||||||
|
'Sync all skipped busy' => '所选工单均在同步中,请稍后再试',
|
||||||
|
'Sync dispatch failed' => '未能启动同步任务,请检查服务器是否允许后台执行 CLI(exec/shell_exec)',
|
||||||
'Sync status success' => '同步成功',
|
'Sync status success' => '同步成功',
|
||||||
'Sync status error' => '同步异常',
|
'Sync status error' => '同步异常',
|
||||||
'Sync status pending' => '待同步',
|
'Sync status pending' => '待同步',
|
||||||
'Sync display success' => '同步成功 / 在线 %s',
|
'Sync display success' => '同步成功 / 在线 %s',
|
||||||
'Sync display pending' => '待同步',
|
'Sync display pending' => '待同步',
|
||||||
'Sync display error' => '同步异常',
|
'Sync display error' => '同步异常',
|
||||||
|
'Sync display auto paused' => '自动同步已暂停',
|
||||||
|
'Sync display auto sync stopped' => '自动同步已终止(重试已用尽)',
|
||||||
|
'Sync display retry waiting' => '自动同步已暂停,将于 %s 第 %d 次重试',
|
||||||
|
'Sync success time' => '最近同步成功',
|
||||||
|
'Sync mode auto' => '自动',
|
||||||
|
'Sync mode manual' => '手动',
|
||||||
|
'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停',
|
||||||
|
'Sync tooltip auto sync stopped' => '分阶段重试已全部失败,自动同步已永久终止;手动同步成功后可恢复',
|
||||||
|
'Sync tooltip retry waiting' => '连续失败 %s 次,将于 %s 进行第 %d 次自动重试',
|
||||||
|
'Sync tooltip error prefix' => '异常原因:',
|
||||||
|
'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log',
|
||||||
'Createtime' => '创建时间',
|
'Createtime' => '创建时间',
|
||||||
'Copy' => '拷贝',
|
'Copy' => '拷贝',
|
||||||
'Summary row' => '汇总',
|
'Summary row' => '汇总',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class Number extends Model
|
|||||||
'status_text',
|
'status_text',
|
||||||
'manual_manage_text',
|
'manual_manage_text',
|
||||||
'platform_status_text',
|
'platform_status_text',
|
||||||
|
'ratio_deferred_text',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +82,17 @@ class Number extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getRatioDeferredList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'0' => __('Ratio deferred no'),
|
||||||
|
'1' => __('Ratio deferred yes'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -158,6 +170,13 @@ class Number extends Model
|
|||||||
return $list[$key] ?? $key;
|
return $list[$key] ?? $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRatioDeferredTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ((int) ($data['ratio_deferred'] ?? 0));
|
||||||
|
$list = $this->getRatioDeferredList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据链接码生成完整分流 URL
|
* 根据链接码生成完整分流 URL
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\admin\model\split;
|
namespace app\admin\model\split;
|
||||||
|
|
||||||
|
use app\common\service\SplitSyncConfigService;
|
||||||
|
use app\common\service\SplitTicketRuleService;
|
||||||
|
use app\common\service\SplitTicketSyncFailRetryService;
|
||||||
|
use app\common\service\SplitTicketSyncLockService;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Model;
|
use think\Model;
|
||||||
|
|
||||||
@@ -14,6 +18,15 @@ class Ticket extends Model
|
|||||||
{
|
{
|
||||||
protected $name = 'split_ticket';
|
protected $name = 'split_ticket';
|
||||||
|
|
||||||
|
protected static function init(): void
|
||||||
|
{
|
||||||
|
// 删除工单时:释放同步锁,并物理删除该工单关联的全部号码
|
||||||
|
self::beforeDelete(function (self $ticket): void {
|
||||||
|
(new SplitTicketSyncLockService())->release((int) $ticket['id']);
|
||||||
|
(new SplitTicketRuleService())->deleteSyncedNumbersForTicket($ticket);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected $autoWriteTimestamp = 'integer';
|
protected $autoWriteTimestamp = 'integer';
|
||||||
|
|
||||||
protected $createTime = 'createtime';
|
protected $createTime = 'createtime';
|
||||||
@@ -31,6 +44,9 @@ class Ticket extends Model
|
|||||||
'ticket_progress_text',
|
'ticket_progress_text',
|
||||||
'inbound_ratio_text',
|
'inbound_ratio_text',
|
||||||
'sync_display_text',
|
'sync_display_text',
|
||||||
|
'sync_success_text',
|
||||||
|
'sync_tooltip_text',
|
||||||
|
'sync_auto_paused',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,13 +168,65 @@ class Ticket extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步状态展示文案
|
* 是否因连续失败暂停自动同步(工单仍为开启状态)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function isAutoSyncPaused(array $data): bool
|
||||||
|
{
|
||||||
|
if ((string) ($data['status'] ?? '') !== 'normal') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$retryService = new SplitTicketSyncFailRetryService();
|
||||||
|
if ($retryService->isPermanentlyStopped($data)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($retryService->isInRetryMode($data)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
if ($threshold <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (SplitSyncConfigService::hasFailRetrySchedule()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($data['sync_fail_count'] ?? 0) >= $threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态展示文案(列表列默认显示,异常详情见 tooltip)
|
||||||
*
|
*
|
||||||
* @param mixed $value
|
* @param mixed $value
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
public function getSyncDisplayTextAttr($value, $data): string
|
public function getSyncDisplayTextAttr($value, $data): string
|
||||||
{
|
{
|
||||||
|
$retryService = new SplitTicketSyncFailRetryService();
|
||||||
|
|
||||||
|
if ($retryService->isPermanentlyStopped($data)) {
|
||||||
|
return (string) __('Sync display auto sync stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($retryService->isInRetryMode($data)) {
|
||||||
|
$nextAt = $retryService->getNextRetryAt($data);
|
||||||
|
$attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1;
|
||||||
|
if ($nextAt !== null) {
|
||||||
|
return sprintf(
|
||||||
|
(string) __('Sync display retry waiting'),
|
||||||
|
date('H:i', $nextAt),
|
||||||
|
$attempt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isAutoSyncPaused($data)) {
|
||||||
|
return (string) __('Sync display auto paused');
|
||||||
|
}
|
||||||
|
|
||||||
$status = (string) ($data['sync_status'] ?? 'pending');
|
$status = (string) ($data['sync_status'] ?? 'pending');
|
||||||
$online = (int) ($data['online_count'] ?? 0);
|
$online = (int) ($data['online_count'] ?? 0);
|
||||||
if ($status === 'success') {
|
if ($status === 'success') {
|
||||||
@@ -170,6 +238,81 @@ class Ticket extends Model
|
|||||||
return (string) __('Sync display error');
|
return (string) __('Sync display error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态列 tooltip 完整说明
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncTooltipTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
$retryService = new SplitTicketSyncFailRetryService();
|
||||||
|
|
||||||
|
if ($retryService->isPermanentlyStopped($data)) {
|
||||||
|
$parts[] = (string) __('Sync tooltip auto sync stopped');
|
||||||
|
} elseif ($retryService->isInRetryMode($data)) {
|
||||||
|
$nextAt = $retryService->getNextRetryAt($data);
|
||||||
|
$attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1;
|
||||||
|
if ($nextAt !== null) {
|
||||||
|
$parts[] = sprintf(
|
||||||
|
(string) __('Sync tooltip retry waiting'),
|
||||||
|
(int) ($data['sync_fail_count'] ?? 0),
|
||||||
|
date('Y-m-d H:i:s', $nextAt),
|
||||||
|
$attempt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} elseif (self::isAutoSyncPaused($data)) {
|
||||||
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
$parts[] = sprintf(
|
||||||
|
(string) __('Sync tooltip auto paused'),
|
||||||
|
(int) ($data['sync_fail_count'] ?? 0),
|
||||||
|
$threshold
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = trim((string) ($data['sync_message'] ?? ''));
|
||||||
|
if ($message !== '') {
|
||||||
|
$parts[] = (string) __('Sync tooltip error prefix') . $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否暂停自动同步(供前端样式判断)
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncAutoPausedAttr($value, $data): bool
|
||||||
|
{
|
||||||
|
return self::isAutoSyncPaused($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次同步成功时间及方式
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncSuccessTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$ts = (int) ($data['sync_success_time'] ?? 0);
|
||||||
|
if ($ts <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$timeText = date('Y-m-d H:i:s', $ts);
|
||||||
|
$mode = (string) ($data['sync_success_mode'] ?? '');
|
||||||
|
if ($mode === 'auto') {
|
||||||
|
return $timeText . ' (' . (string) __('Sync mode auto') . ')';
|
||||||
|
}
|
||||||
|
if ($mode === 'manual') {
|
||||||
|
return $timeText . ' (' . (string) __('Sync mode manual') . ')';
|
||||||
|
}
|
||||||
|
return $timeText;
|
||||||
|
}
|
||||||
|
|
||||||
public function setStartTimeAttr($value): ?int
|
public function setStartTimeAttr($value): ?int
|
||||||
{
|
{
|
||||||
return self::parseTimeValue($value);
|
return self::parseTimeValue($value);
|
||||||
|
|||||||
@@ -123,6 +123,13 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
{if isset($row)}
|
{if isset($row)}
|
||||||
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
||||||
|
{if isset($row.ratio_deferred) && $row.ratio_deferred==1 && isset($row.status) && $row.status=='normal'}
|
||||||
|
<div class="alert alert-warning split-ratio-deferred-notice" style="margin-bottom:10px;padding:8px 12px;font-size:12px;">
|
||||||
|
<i class="fa fa-level-down"></i>
|
||||||
|
{:__('Ratio deferred edit notice')}
|
||||||
|
({:__('No inbound click streak')}:{$row.no_inbound_click_streak|default=0})
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
||||||
{else}
|
{else}
|
||||||
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||||
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||||
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-default btn-filter-ratio-deferred" title="{:__('Ratio deferred filter')}"><i class="fa fa-level-down text-warning"></i> {:__('Ratio deferred badge')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-warning btn-cleanup-ratio-deferred {:$auth->check('split.number/cleanupdeferred')?'':'hide'}" title="{:__('Ratio deferred cleanup btn')}"><i class="fa fa-eraser"></i> {:__('Ratio deferred cleanup btn')}</a>
|
||||||
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
|
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
|
||||||
</div>
|
</div>
|
||||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
|
|||||||
@@ -67,6 +67,24 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
.split-sync-success-normal {
|
||||||
|
color: #3c763d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.split-sync-success-stopped {
|
||||||
|
color: #a94442;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.split-ticket-sync-status-paused {
|
||||||
|
color: #8a6d3b;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted #8a6d3b;
|
||||||
|
}
|
||||||
|
.split-ticket-sync-status-error {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted #a94442;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="panel panel-default panel-intro">
|
<div class="panel panel-default panel-intro">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
|
|||||||
@@ -68,13 +68,18 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$antiBot = $config['antiBot'] ?? null;
|
$antiBot = $config['antiBot'] ?? null;
|
||||||
|
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
|
||||||
|
|
||||||
|
if ($this->shouldUseUnifiedBrowserPath($antiBot, $mode)) {
|
||||||
|
return $this->runUnifiedUiPath($config, $antiBot, $apiUrlsToIntercept, $listApi, $detailApi, $countApi);
|
||||||
|
}
|
||||||
|
|
||||||
$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,
|
'antiBot' => $antiBot,
|
||||||
]);
|
], $this->resolveAuthInterceptTimeout($antiBot));
|
||||||
|
|
||||||
if (empty($initResult['success'])) {
|
if (empty($initResult['success'])) {
|
||||||
$cfCode = (string) ($initResult['code'] ?? '');
|
$cfCode = (string) ($initResult['code'] ?? '');
|
||||||
@@ -97,6 +102,8 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [
|
SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [
|
||||||
'intercepted' => array_keys($interceptedApis),
|
'intercepted' => array_keys($interceptedApis),
|
||||||
'finalPageUrl' => $finalPageUrl,
|
'finalPageUrl' => $finalPageUrl,
|
||||||
|
'cfStage' => $initResult['cf']['turnstileStage'] ?? ($initResult['cf']['stage'] ?? null),
|
||||||
|
'browserReused' => $initResult['browserReused'] ?? null,
|
||||||
]);
|
]);
|
||||||
$cookies = $initResult['cookies'];
|
$cookies = $initResult['cookies'];
|
||||||
|
|
||||||
@@ -113,7 +120,6 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
$allListPagesData = [$listApiNode['data']];
|
$allListPagesData = [$listApiNode['data']];
|
||||||
|
|
||||||
$totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
|
$totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
|
||||||
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
|
|
||||||
SplitTicketSyncLogger::log('spider', 'pagination plan', [
|
SplitTicketSyncLogger::log('spider', 'pagination plan', [
|
||||||
'totalPages' => $totalPages,
|
'totalPages' => $totalPages,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
@@ -182,6 +188,124 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单 Browser 路径:auth + 拦截 + UI 翻页(需 antiBot.sessionKey)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $config
|
||||||
|
* @param array<string, mixed>|null $antiBot
|
||||||
|
* @param list<string> $apiUrlsToIntercept
|
||||||
|
*/
|
||||||
|
private function runUnifiedUiPath(
|
||||||
|
array $config,
|
||||||
|
?array $antiBot,
|
||||||
|
array $apiUrlsToIntercept,
|
||||||
|
string $listApi,
|
||||||
|
?string $detailApi,
|
||||||
|
?string $countApi
|
||||||
|
): UnifiedScrmData {
|
||||||
|
$uiConfig = $this->getUiPaginationConfig();
|
||||||
|
$sessionKey = is_array($antiBot) ? (string) ($antiBot['sessionKey'] ?? '') : '';
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'unified browser path', [
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
'listApi' => $listApi,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mergedResult = $this->requestNode('/api/auth-intercept-and-paginate', [
|
||||||
|
'pageUrl' => $config['pageUrl'],
|
||||||
|
'apiUrls' => $apiUrlsToIntercept,
|
||||||
|
'authActions' => $config['authActions'] ?? [],
|
||||||
|
'antiBot' => $antiBot,
|
||||||
|
'pagination' => [
|
||||||
|
'apiUrl' => $listApi,
|
||||||
|
'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '',
|
||||||
|
'waitMs' => $uiConfig['waitMs'] ?? 2000,
|
||||||
|
'clicksToPerform' => 9999,
|
||||||
|
],
|
||||||
|
], 1200);
|
||||||
|
|
||||||
|
if (empty($mergedResult['success'])) {
|
||||||
|
$cfCode = (string) ($mergedResult['code'] ?? '');
|
||||||
|
SplitTicketSyncLogger::log('spider', 'auth-intercept-and-paginate failed', [
|
||||||
|
'error' => $mergedResult['error'] ?? '未知',
|
||||||
|
'code' => $cfCode !== '' ? $cfCode : null,
|
||||||
|
'stage' => $mergedResult['stage'] ?? null,
|
||||||
|
'cf' => $mergedResult['cf'] ?? null,
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
]);
|
||||||
|
if ($cfCode === 'CF_TURNSTILE_FAILED') {
|
||||||
|
throw new Exception('Cloudflare Turnstile 验证失败: ' . ($mergedResult['stage'] ?? 'timeout'));
|
||||||
|
}
|
||||||
|
throw new Exception('合并同步失败: ' . (string) ($mergedResult['error'] ?? '未知'));
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'auth-intercept-and-paginate ok', [
|
||||||
|
'intercepted' => array_keys($mergedResult['interceptedApis'] ?? []),
|
||||||
|
'extraPages' => count($mergedResult['extraPages'] ?? []),
|
||||||
|
'cfStage' => $mergedResult['cf']['turnstileStage'] ?? ($mergedResult['cf']['stage'] ?? null),
|
||||||
|
'browserReused' => $mergedResult['browserReused'] ?? null,
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$interceptedApis = $mergedResult['interceptedApis'] ?? [];
|
||||||
|
if (!isset($interceptedApis[$listApi])) {
|
||||||
|
throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
$detailData = $detailApi && isset($interceptedApis[$detailApi])
|
||||||
|
? $interceptedApis[$detailApi]['data'] : null;
|
||||||
|
$countData = $countApi && isset($interceptedApis[$countApi])
|
||||||
|
? $interceptedApis[$countApi]['data'] : null;
|
||||||
|
|
||||||
|
$listApiNode = $interceptedApis[$listApi];
|
||||||
|
$allListPagesData = [$listApiNode['data']];
|
||||||
|
if (!empty($mergedResult['extraPages']) && is_array($mergedResult['extraPages'])) {
|
||||||
|
foreach ($mergedResult['extraPages'] as $pageData) {
|
||||||
|
$allListPagesData[] = $pageData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->extractListTotalPages($listApiNode['data'], $countData);
|
||||||
|
|
||||||
|
$result = $this->parseToUnifiedData($detailData, $allListPagesData);
|
||||||
|
SplitTicketSyncLogger::log('spider', 'run done', [
|
||||||
|
'todayNewCount' => $result->todayNewCount,
|
||||||
|
'totalOnline' => $result->totalOnline,
|
||||||
|
'totalOffline' => $result->totalOffline,
|
||||||
|
'total' => $result->total,
|
||||||
|
'numberCount' => count($result->numbers),
|
||||||
|
'unifiedPath' => true,
|
||||||
|
]);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $antiBot
|
||||||
|
*/
|
||||||
|
protected function shouldUseUnifiedBrowserPath(?array $antiBot, string $mode): bool
|
||||||
|
{
|
||||||
|
if ($mode !== self::MODE_UI) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!is_array($antiBot) || empty($antiBot['enabled'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !empty($antiBot['sessionKey']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real Browser + Turnstile 首屏耗时较长,单独放宽 auth-and-intercept 超时
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|null $antiBot
|
||||||
|
*/
|
||||||
|
protected function resolveAuthInterceptTimeout(?array $antiBot): int
|
||||||
|
{
|
||||||
|
if (!is_array($antiBot) || empty($antiBot['enabled'])) {
|
||||||
|
return 120;
|
||||||
|
}
|
||||||
|
return 180;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $payload
|
* @param array<string, mixed> $payload
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library\scrm;
|
||||||
|
|
||||||
|
use app\common\service\SplitPageUrlResolver;
|
||||||
|
use think\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 云控蜘蛛 antiBot 配置构建器
|
||||||
|
*
|
||||||
|
* 约定:sessionKey = "{ticketType}:{host}";profile=real 当 ticket_type 在 site 配置列表中
|
||||||
|
*/
|
||||||
|
class AntiBotConfigBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 为指定工单类型与落地页 URL 构建 Node antiBot 配置;未启用类型返回 null
|
||||||
|
*
|
||||||
|
* @param string $landingUrlHint HTTP 预解析或历史上次落地的长链(可选)
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public static function build(string $ticketType, string $pageUrl, string $landingUrlHint = ''): ?array
|
||||||
|
{
|
||||||
|
$enabledTypes = self::getEnabledTypes();
|
||||||
|
if (!in_array($ticketType, $enabledTypes, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'a2c' && $landingUrlHint === '') {
|
||||||
|
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'whatshub' && $landingUrlHint === '') {
|
||||||
|
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionScope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint);
|
||||||
|
if ($sessionScope === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionKey = $ticketType . ':' . $sessionScope;
|
||||||
|
$ttlMinutes = self::getSessionTtlMinutes();
|
||||||
|
|
||||||
|
$businessHostHint = self::resolveBusinessHostHint($ticketType, $pageUrl, $landingUrlHint);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => true,
|
||||||
|
'profile' => 'real',
|
||||||
|
'turnstile' => self::defaultTurnstile($ticketType),
|
||||||
|
'solverFallback' => self::defaultSolverFallback($ticketType),
|
||||||
|
'cfClearanceRequired' => self::defaultCfClearanceRequired($ticketType),
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
'challengeTimeoutMs' => 60000,
|
||||||
|
'sessionTtlMinutes' => $ttlMinutes,
|
||||||
|
'businessHostHint' => $businessHostHint,
|
||||||
|
'landingUrlHint' => $landingUrlHint !== '' ? $landingUrlHint : '',
|
||||||
|
'cfCloudflareSolver' => self::defaultCfCloudflareSolver($ticketType),
|
||||||
|
'cfChallengeMode' => self::defaultCfChallengeMode($ticketType),
|
||||||
|
] + self::postCfReadyExtras($ticketType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话 scope:A2C 业务域+分享 id;Whatshub host+shareCode/workId;火箭长链用动态 host
|
||||||
|
*/
|
||||||
|
public static function resolveSessionScope(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
if ($ticketType === 'a2c') {
|
||||||
|
return self::resolveA2cSessionScope($pageUrl, $landingUrlHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'whatshub') {
|
||||||
|
return self::resolveWhatshubSessionScope($pageUrl, $landingUrlHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'huojian') {
|
||||||
|
return self::resolveHuojianSessionScope($pageUrl, $landingUrlHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = strtolower(trim((string) parse_url($pageUrl, PHP_URL_HOST)));
|
||||||
|
|
||||||
|
return $host !== '' ? $host : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 兼容旧调用,等价 resolveSessionScope
|
||||||
|
*/
|
||||||
|
public static function resolveSessionHost(string $ticketType, string $pageUrl): string
|
||||||
|
{
|
||||||
|
return self::resolveSessionScope($ticketType, $pageUrl, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 sessionKey(供 cron 分组调度使用)
|
||||||
|
*/
|
||||||
|
public static function resolveSessionKey(string $ticketType, string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
if ($ticketType === 'a2c' && $landingUrlHint === '') {
|
||||||
|
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'whatshub' && $landingUrlHint === '') {
|
||||||
|
$landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = self::resolveSessionScope($ticketType, $pageUrl, $landingUrlHint);
|
||||||
|
|
||||||
|
return $ticketType . ':' . ($scope !== '' ? $scope : 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否对该工单类型启用 antiBot
|
||||||
|
*/
|
||||||
|
public static function isEnabledForType(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return in_array($ticketType, self::getEnabledTypes(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function getEnabledTypes(): array
|
||||||
|
{
|
||||||
|
$raw = (string) Config::get('site.split_scrm_antibot_types');
|
||||||
|
if ($raw === '') {
|
||||||
|
return ['whatshub', 'chatknow'];
|
||||||
|
}
|
||||||
|
$parts = array_map('trim', explode(',', $raw));
|
||||||
|
return array_values(array_filter($parts, static function (string $v): bool {
|
||||||
|
return $v !== '';
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSessionTtlMinutes(): int
|
||||||
|
{
|
||||||
|
$minutes = (int) Config::get('site.split_scrm_session_ttl_minutes');
|
||||||
|
return $minutes > 0 ? $minutes : 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按工单类型附加 CF 过盾后的业务页就绪等待
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function postCfReadyExtras(string $ticketType): array
|
||||||
|
{
|
||||||
|
if ($ticketType === 'whatshub') {
|
||||||
|
return [
|
||||||
|
'postCfReadyUrlContains' => 'work-order-sharing',
|
||||||
|
// 业务页表格就绪即可;密码框为 vxe-form,勿用泛化 .el-input__inner
|
||||||
|
'postCfReadySelector' => '.vxe-table, .vxe-grid',
|
||||||
|
'postCfReadySelectorOptional' => true,
|
||||||
|
// statistics 仅在密码验证后触发,禁止 pre-auth 等待(见 Node skipPostCfReadyApi)
|
||||||
|
'postCfReadyTimeoutMs' => 60000,
|
||||||
|
'postCfSettleMs' => 500,
|
||||||
|
'postCfSpaWaitMs' => 4000,
|
||||||
|
'skipShortLinkCfPurgeOnReuse' => true,
|
||||||
|
'authSkipIfNoPasswordDialog' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'a2c') {
|
||||||
|
return [
|
||||||
|
'postCfReadyUrlContains' => '/visitors/counter/share',
|
||||||
|
'postCfReadySelector' => '.el-table',
|
||||||
|
'postCfReadyApiUrl' => '/api/talk/counter/share/record/list',
|
||||||
|
'postCfReadyTimeoutMs' => 60000,
|
||||||
|
'postCfSettleMs' => 500,
|
||||||
|
'postCfSpaWaitMs' => 8000,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'huojian') {
|
||||||
|
return [
|
||||||
|
'postCfReadyUrlContains' => HuojianUrlHelper::BUSINESS_PATH_NEEDLE,
|
||||||
|
'postCfReadySelector' => '.el-message-box__input',
|
||||||
|
'postCfReadyTimeoutMs' => 45000,
|
||||||
|
'postCfSettleMs' => 500,
|
||||||
|
'postCfSpaWaitMs' => 8000,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultTurnstile(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultSolverFallback(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultCfClearanceRequired(string $ticketType): bool
|
||||||
|
{
|
||||||
|
if ($ticketType === 'a2c' || $ticketType === 'whatshub') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2C / Whatshub 等为 CF Managed Challenge(5s 盾),非独立 Turnstile 挂件
|
||||||
|
*/
|
||||||
|
private static function defaultCfChallengeMode(string $ticketType): string
|
||||||
|
{
|
||||||
|
if ($ticketType === 'a2c' || $ticketType === 'whatshub') {
|
||||||
|
return 'managed';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sitekey 缺失时是否允许 CapSolver AntiCloudflareTask(需 Node 侧 CAPTCHA_PROXY)
|
||||||
|
*/
|
||||||
|
private static function defaultCfCloudflareSolver(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return $ticketType === 'a2c' || $ticketType === 'whatshub';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 火箭/A2C 业务域 hint:优先长链动态 host,其次 landing 预解析
|
||||||
|
*/
|
||||||
|
private static function resolveBusinessHostHint(string $ticketType, string $pageUrl, string $landingUrlHint): string
|
||||||
|
{
|
||||||
|
if ($ticketType === 'a2c') {
|
||||||
|
return self::resolveA2cSessionHost($pageUrl, $landingUrlHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketType === 'huojian') {
|
||||||
|
$host = HuojianUrlHelper::extractBusinessHost($pageUrl);
|
||||||
|
if ($host !== '') {
|
||||||
|
return $host;
|
||||||
|
}
|
||||||
|
if ($landingUrlHint !== '') {
|
||||||
|
$hintHost = HuojianUrlHelper::extractBusinessHost($landingUrlHint);
|
||||||
|
if ($hintHost !== '') {
|
||||||
|
return $hintHost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whatshub 会话 scope:host + shareCode 或 workId,避免多工单共用温池 Browser
|
||||||
|
*/
|
||||||
|
private static function resolveWhatshubSessionScope(string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
$host = strtolower(trim((string) parse_url($pageUrl, PHP_URL_HOST)));
|
||||||
|
if ($host === '' && $landingUrlHint !== '') {
|
||||||
|
$host = strtolower(trim((string) parse_url($landingUrlHint, PHP_URL_HOST)));
|
||||||
|
}
|
||||||
|
if ($host === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$shareCode = self::extractWhatshubShareCode($pageUrl);
|
||||||
|
if ($shareCode === '' && $landingUrlHint !== '') {
|
||||||
|
$shareCode = self::extractWhatshubShareCode($landingUrlHint);
|
||||||
|
}
|
||||||
|
if ($shareCode !== '') {
|
||||||
|
return $host . ':' . $shareCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workId = self::extractWhatshubWorkId($pageUrl);
|
||||||
|
if ($workId === '' && $landingUrlHint !== '') {
|
||||||
|
$workId = self::extractWhatshubWorkId($landingUrlHint);
|
||||||
|
}
|
||||||
|
if ($workId !== '') {
|
||||||
|
return $host . ':' . $workId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Whatshub 短链 /m/{shareCode}/ 提取 shareCode
|
||||||
|
*/
|
||||||
|
private static function extractWhatshubShareCode(string $url): string
|
||||||
|
{
|
||||||
|
$path = (string) parse_url($url, PHP_URL_PATH);
|
||||||
|
if ($path === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (preg_match('#/m/([^/]+)/#', $path, $matches)) {
|
||||||
|
return trim($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Whatshub 长链 work-order-sharing?workId= 提取 workId
|
||||||
|
*/
|
||||||
|
private static function extractWhatshubWorkId(string $url): string
|
||||||
|
{
|
||||||
|
$query = (string) parse_url($url, PHP_URL_QUERY);
|
||||||
|
if ($query === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
parse_str($query, $params);
|
||||||
|
$workId = isset($params['workId']) ? trim((string) $params['workId']) : '';
|
||||||
|
|
||||||
|
return $workId !== '' ? $workId : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveA2cSessionHost(string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
$candidates = [];
|
||||||
|
if ($landingUrlHint !== '') {
|
||||||
|
$candidates[] = (string) parse_url($landingUrlHint, PHP_URL_HOST);
|
||||||
|
}
|
||||||
|
$candidates[] = (string) parse_url($pageUrl, PHP_URL_HOST);
|
||||||
|
|
||||||
|
foreach ($candidates as $host) {
|
||||||
|
$host = strtolower(trim($host));
|
||||||
|
if ($host !== '' && strpos($host, 'a2c.chat') !== false) {
|
||||||
|
return $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'user.a2c.chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2C 会话 scope:业务域 + 分享 id(避免多工单共用温池 Browser 互相干扰)
|
||||||
|
*
|
||||||
|
* 短链先 HTTP 预解析再提取 ?id=,确保不同分享页不会落到同一 sessionKey。
|
||||||
|
*/
|
||||||
|
private static function resolveA2cSessionScope(string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
$resolvedUrl = self::resolveA2cResolvedUrl($pageUrl, $landingUrlHint);
|
||||||
|
$host = self::resolveA2cSessionHost($pageUrl, $resolvedUrl);
|
||||||
|
$shareId = self::extractA2cShareId($pageUrl);
|
||||||
|
if ($shareId === '') {
|
||||||
|
$shareId = self::extractA2cShareId($resolvedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $shareId !== '' ? $host . ':' . $shareId : $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2C 短链 HTTP 预解析:优先已有 landingUrlHint,否则跟随重定向得到长链
|
||||||
|
*/
|
||||||
|
private static function resolveA2cResolvedUrl(string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
if ($landingUrlHint !== '' && self::extractA2cShareId($landingUrlHint) !== '') {
|
||||||
|
return $landingUrlHint;
|
||||||
|
}
|
||||||
|
if (self::extractA2cShareId($pageUrl) !== '') {
|
||||||
|
return $pageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
|
if ($resolved !== '') {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $landingUrlHint !== '' ? $landingUrlHint : $pageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 A2C 分享 URL 提取 id 查询参数
|
||||||
|
*/
|
||||||
|
private static function extractA2cShareId(string $url): string
|
||||||
|
{
|
||||||
|
$query = (string) parse_url($url, PHP_URL_QUERY);
|
||||||
|
if ($query === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
parse_str($query, $params);
|
||||||
|
$id = isset($params['id']) ? trim((string) $params['id']) : '';
|
||||||
|
|
||||||
|
return $id !== '' ? $id : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveHuojianSessionScope(string $pageUrl, string $landingUrlHint = ''): string
|
||||||
|
{
|
||||||
|
$scope = HuojianUrlHelper::resolveSessionScope($pageUrl);
|
||||||
|
if ($scope !== '') {
|
||||||
|
return $scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($landingUrlHint !== '') {
|
||||||
|
$fromLanding = HuojianUrlHelper::resolveSessionScope($landingUrlHint);
|
||||||
|
if ($fromLanding !== '') {
|
||||||
|
return $fromLanding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library\scrm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 火箭云控 URL 解析:短链(s.url99.me/code)与长链(*.url66.me/gds?link=)动态识别
|
||||||
|
*
|
||||||
|
* 长链域名可能为 v3.url66.me、v4.url66.me 等,禁止写死单一 host。
|
||||||
|
*/
|
||||||
|
final class HuojianUrlHelper
|
||||||
|
{
|
||||||
|
/** 火箭业务页路径特征 */
|
||||||
|
public const BUSINESS_PATH_NEEDLE = '/gds';
|
||||||
|
|
||||||
|
/** 长链 query 中的分享 token 参数名 */
|
||||||
|
public const LINK_QUERY_PARAM = 'link';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为火箭长链业务页(含 /gds 且带 link=)
|
||||||
|
*/
|
||||||
|
public static function isLongLinkUrl(string $pageUrl): bool
|
||||||
|
{
|
||||||
|
$path = (string) parse_url($pageUrl, PHP_URL_PATH);
|
||||||
|
if ($path === '' || strpos($path, self::BUSINESS_PATH_NEEDLE) === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = [];
|
||||||
|
parse_str((string) parse_url($pageUrl, PHP_URL_QUERY), $query);
|
||||||
|
|
||||||
|
return trim((string) ($query[self::LINK_QUERY_PARAM] ?? '')) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从长链 URL 动态解析业务域(如 v3.url66.me)
|
||||||
|
*/
|
||||||
|
public static function extractBusinessHost(string $pageUrl): string
|
||||||
|
{
|
||||||
|
if (!self::isLongLinkUrl($pageUrl)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = strtolower(trim((string) parse_url($pageUrl, PHP_URL_HOST)));
|
||||||
|
|
||||||
|
return $host !== '' ? $host : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取分享稳定标识:长链 link= 或短链 path 段(如 50q3622j)
|
||||||
|
*/
|
||||||
|
public static function extractShareToken(string $pageUrl): string
|
||||||
|
{
|
||||||
|
if (self::isLongLinkUrl($pageUrl)) {
|
||||||
|
$query = [];
|
||||||
|
parse_str((string) parse_url($pageUrl, PHP_URL_QUERY), $query);
|
||||||
|
|
||||||
|
return trim((string) ($query[self::LINK_QUERY_PARAM] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = trim((string) parse_url($pageUrl, PHP_URL_PATH), '/');
|
||||||
|
if ($path === '' || strpos($path, '/') !== false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^[a-zA-Z0-9]+$/', $path) !== 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为火箭短链入口(单段 path,非 /gds 长链)
|
||||||
|
*/
|
||||||
|
public static function isShortEntryUrl(string $pageUrl): bool
|
||||||
|
{
|
||||||
|
if (self::isLongLinkUrl($pageUrl)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::extractShareToken($pageUrl) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话 scope:长链用动态业务域;短链用 share.{token}(跨长链域名复用 cookie jar)
|
||||||
|
*/
|
||||||
|
public static function resolveSessionScope(string $pageUrl): string
|
||||||
|
{
|
||||||
|
$businessHost = self::extractBusinessHost($pageUrl);
|
||||||
|
if ($businessHost !== '') {
|
||||||
|
return $businessHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = self::extractShareToken($pageUrl);
|
||||||
|
if ($token !== '') {
|
||||||
|
return 'share.' . $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@ 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\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
use app\common\service\SplitPageUrlResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A2C 云控蜘蛛
|
* A2C 云控蜘蛛(Real Browser 打开分享页 + 拦截加密 API + UI 翻页)
|
||||||
|
*
|
||||||
|
* 业务域 user.a2c.chat 在 CF 挑战页启用 CapSolver 兜底;短链 yyk.ink 预解析为长链后直达业务页。
|
||||||
*/
|
*/
|
||||||
class A2cSpider extends AbstractScrmSpider
|
class A2cSpider extends AbstractScrmSpider
|
||||||
{
|
{
|
||||||
@@ -24,6 +28,9 @@ class A2cSpider extends AbstractScrmSpider
|
|||||||
|
|
||||||
private string $password;
|
private string $password;
|
||||||
|
|
||||||
|
/** @var string HTTP 预解析落地 URL(供 antiBot landingUrlHint) */
|
||||||
|
private string $landingUrlHint = '';
|
||||||
|
|
||||||
private UnifiedScrmData $unifiedData;
|
private UnifiedScrmData $unifiedData;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -36,6 +43,7 @@ class A2cSpider extends AbstractScrmSpider
|
|||||||
$this->pageUrl = $pageUrl;
|
$this->pageUrl = $pageUrl;
|
||||||
$this->account = $account;
|
$this->account = $account;
|
||||||
$this->password = $password;
|
$this->password = $password;
|
||||||
|
$this->landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
$this->unifiedData = new UnifiedScrmData();
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +58,7 @@ class A2cSpider extends AbstractScrmSpider
|
|||||||
'authActions' => [
|
'authActions' => [
|
||||||
['type' => 'wait', 'ms' => 2000],
|
['type' => 'wait', 'ms' => 2000],
|
||||||
],
|
],
|
||||||
|
'antiBot' => AntiBotConfigBuilder::build('a2c', $this->pageUrl, $this->landingUrlHint),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +49,7 @@ class ChatknowSpider extends AbstractScrmSpider
|
|||||||
'authActions' => [
|
'authActions' => [
|
||||||
['type' => 'wait', 'ms' => 4000],
|
['type' => 'wait', 'ms' => 4000],
|
||||||
],
|
],
|
||||||
|
'antiBot' => AntiBotConfigBuilder::build('chatknow', $this->pageUrl),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ 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\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
use app\common\service\SplitPageUrlResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 火箭云控蜘蛛
|
* 火箭云控蜘蛛(Real Browser + CF 过盾 + 密码弹窗 + API 拦截)
|
||||||
|
*
|
||||||
|
* 短链 s.url99.me → 长链 *.url66.me/gds?link=(长链域名动态,不写死)
|
||||||
*/
|
*/
|
||||||
class HuojianSpider extends AbstractScrmSpider
|
class HuojianSpider extends AbstractScrmSpider
|
||||||
{
|
{
|
||||||
@@ -22,6 +26,9 @@ class HuojianSpider extends AbstractScrmSpider
|
|||||||
|
|
||||||
private UnifiedScrmData $unifiedData;
|
private UnifiedScrmData $unifiedData;
|
||||||
|
|
||||||
|
/** HTTP 预解析落地 URL(短链 CF 时可能失败,仅作 hint) */
|
||||||
|
private string $landingUrlHint = '';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $pageUrl,
|
string $pageUrl,
|
||||||
string $account = '',
|
string $account = '',
|
||||||
@@ -32,11 +39,14 @@ class HuojianSpider extends AbstractScrmSpider
|
|||||||
$this->pageUrl = $pageUrl;
|
$this->pageUrl = $pageUrl;
|
||||||
$this->account = $account;
|
$this->account = $account;
|
||||||
$this->password = $password;
|
$this->password = $password;
|
||||||
|
$this->landingUrlHint = SplitPageUrlResolver::resolveFinalUrl($pageUrl);
|
||||||
$this->unifiedData = new UnifiedScrmData();
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getSpiderConfig(): array
|
protected function getSpiderConfig(): array
|
||||||
{
|
{
|
||||||
|
$antiBot = AntiBotConfigBuilder::build('huojian', $this->pageUrl, $this->landingUrlHint);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pageUrl' => $this->pageUrl,
|
'pageUrl' => $this->pageUrl,
|
||||||
'listApi' => self::API_LIST,
|
'listApi' => self::API_LIST,
|
||||||
@@ -48,6 +58,7 @@ class HuojianSpider extends AbstractScrmSpider
|
|||||||
['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'],
|
['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'],
|
||||||
['type' => 'wait', 'ms' => 2000],
|
['type' => 'wait', 'ms' => 2000],
|
||||||
],
|
],
|
||||||
|
'antiBot' => $antiBot,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +95,7 @@ class HuojianSpider extends AbstractScrmSpider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$unifiedData->total = $count;
|
$unifiedData->total = $count;
|
||||||
|
|
||||||
return $unifiedData;
|
return $unifiedData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ 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\AbstractScrmSpider;
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
|
use app\common\service\SplitTicketSyncLogger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whatshub 云控蜘蛛(Real Browser + Turnstile 过盾 + UI 翻页)
|
* Whatshub 云控蜘蛛
|
||||||
*
|
*
|
||||||
* 时序:Cloudflare Turnstile → 密码弹窗 authActions → API 拦截 → UI 翻页
|
* 时序:cURL detailByShareCode 拉号码 → Node 监听 statistics 算完成量 → 失败则完整 Node 爬虫
|
||||||
*/
|
*/
|
||||||
class WhatshubSpider extends AbstractScrmSpider
|
class WhatshubSpider extends AbstractScrmSpider
|
||||||
{
|
{
|
||||||
@@ -20,9 +22,14 @@ class WhatshubSpider extends AbstractScrmSpider
|
|||||||
/** 详情/统计 API 路径 */
|
/** 详情/统计 API 路径 */
|
||||||
private const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics';
|
private const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics';
|
||||||
|
|
||||||
|
/** shareCode + password 直连接口(一次返回全量号码,无需翻页) */
|
||||||
|
private const API_DETAIL_BY_SHARE_CODE = '/api/whatshub-counter/workShare/open/detailByShareCode';
|
||||||
|
|
||||||
/** 默认每页条数 */
|
/** 默认每页条数 */
|
||||||
private const DEFAULT_PER_PAGE_COUNT = 20;
|
private const DEFAULT_PER_PAGE_COUNT = 20;
|
||||||
|
|
||||||
|
private const API_FETCH_TIMEOUT_SEC = 20;
|
||||||
|
|
||||||
private string $pageUrl;
|
private string $pageUrl;
|
||||||
|
|
||||||
private string $account;
|
private string $account;
|
||||||
@@ -44,11 +51,294 @@ class WhatshubSpider extends AbstractScrmSpider
|
|||||||
$this->unifiedData = new UnifiedScrmData();
|
$this->unifiedData = new UnifiedScrmData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 混合同步:cURL 拉号码 + Node 监听 statistics 算完成量
|
||||||
|
* statistics 失败时清 session 重试;仍失败则 fallback 完整 Node 仅取完成量
|
||||||
|
*/
|
||||||
|
public function run(): UnifiedScrmData
|
||||||
|
{
|
||||||
|
$apiResult = $this->tryFetchViaShareCodeApi();
|
||||||
|
$statsCount = $this->tryFetchStatisticsViaNode(false);
|
||||||
|
|
||||||
|
if ($apiResult instanceof UnifiedScrmData && $statsCount !== null) {
|
||||||
|
$apiResult->todayNewCount = $statsCount;
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok', [
|
||||||
|
'numberCount' => count($apiResult->numbers),
|
||||||
|
'todayNewCount' => $statsCount,
|
||||||
|
]);
|
||||||
|
return $apiResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cURL 已成功:清 session 再试 statistics;仍失败则完整 Node 只取完成量
|
||||||
|
if ($apiResult instanceof UnifiedScrmData) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats retry after purge session', [
|
||||||
|
'numberCount' => count($apiResult->numbers),
|
||||||
|
]);
|
||||||
|
$statsCount = $this->tryFetchStatisticsViaNode(true);
|
||||||
|
if ($statsCount !== null) {
|
||||||
|
$apiResult->todayNewCount = $statsCount;
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok (retry)', [
|
||||||
|
'numberCount' => count($apiResult->numbers),
|
||||||
|
'todayNewCount' => $statsCount,
|
||||||
|
]);
|
||||||
|
return $apiResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats fallback full node for complete count', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'numberCount' => count($apiResult->numbers),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
$fullResult = parent::run();
|
||||||
|
$apiResult->todayNewCount = $fullResult->todayNewCount;
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub hybrid path ok (full node stats)', [
|
||||||
|
'numberCount' => count($apiResult->numbers),
|
||||||
|
'todayNewCount' => $apiResult->todayNewCount,
|
||||||
|
]);
|
||||||
|
return $apiResult;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats fallback full node failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('Whatshub statistics 同步失败(号码已通过 API 获取,无法更新完成量)');
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub hybrid incomplete, fallback full node', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'apiOk' => false,
|
||||||
|
'statisticsOk' => $statsCount !== null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return parent::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whatshub vxe 密码表单内 input / 确认按钮(避免误填业务页搜索框) */
|
||||||
|
private const AUTH_PASSWORD_INPUT = '.vxe-form .el-input__inner';
|
||||||
|
|
||||||
|
private const AUTH_CONFIRM_BUTTON = '.vxe-form button.theme--primary[type="submit"]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 监听 statistics API,返回 dayNewFans + reDayNewFans 总和;失败返回 null
|
||||||
|
*
|
||||||
|
* @param bool $isRetry 是否为清 session 后的二次尝试
|
||||||
|
*/
|
||||||
|
private function tryFetchStatisticsViaNode(bool $isRetry = false): ?int
|
||||||
|
{
|
||||||
|
$config = $this->getSpiderConfig();
|
||||||
|
$antiBot = $config['antiBot'] ?? null;
|
||||||
|
if (is_array($antiBot)) {
|
||||||
|
// statistics-only:早挂拦截器、识别 vxe 密码框、session 有效时 skip auth
|
||||||
|
$antiBot = array_merge($antiBot, [
|
||||||
|
'skipPostCfReadyApi' => true,
|
||||||
|
'authSkipIfNoPasswordDialog' => true,
|
||||||
|
'authSelectorTimeoutMs' => 30000,
|
||||||
|
'earlyApiIntercept' => true,
|
||||||
|
]);
|
||||||
|
if ($isRetry) {
|
||||||
|
$antiBot['purgeSession'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats node request', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'api' => self::API_DETAILS,
|
||||||
|
'isRetry' => $isRetry,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->requestNode('/api/auth-and-intercept', [
|
||||||
|
'pageUrl' => $config['pageUrl'],
|
||||||
|
'apiUrls' => [self::API_DETAILS],
|
||||||
|
'authActions' => $config['authActions'] ?? [],
|
||||||
|
'antiBot' => $antiBot,
|
||||||
|
], $this->resolveAuthInterceptTimeout(is_array($antiBot) ? $antiBot : null));
|
||||||
|
|
||||||
|
if (empty($result['success'])) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats node failed', [
|
||||||
|
'error' => $result['error'] ?? '未知',
|
||||||
|
'code' => $result['code'] ?? null,
|
||||||
|
'page' => $result['page'] ?? null,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$intercepted = $result['interceptedApis'] ?? [];
|
||||||
|
if (!isset($intercepted[self::API_DETAILS]['data']) || !is_array($intercepted[self::API_DETAILS]['data'])) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats node missing payload', [
|
||||||
|
'intercepted' => array_keys(is_array($intercepted) ? $intercepted : []),
|
||||||
|
'page' => $result['page'] ?? null,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$detailData = $intercepted[self::API_DETAILS]['data'];
|
||||||
|
$dayNewFans = (int) ($detailData['data']['dayNewFans'] ?? 0);
|
||||||
|
$reDayNewFans = (int) ($detailData['data']['reDayNewFans'] ?? 0);
|
||||||
|
$total = $dayNewFans + $reDayNewFans;
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub stats node ok', [
|
||||||
|
'dayNewFans' => $dayNewFans,
|
||||||
|
'reDayNewFans' => $reDayNewFans,
|
||||||
|
'total' => $total,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从短链 pageUrl 解析 shareCode,例如 /m/KMYBBInp2066/1 → KMYBBInp2066
|
||||||
|
*/
|
||||||
|
private function extractShareCodeFromPageUrl(): ?string
|
||||||
|
{
|
||||||
|
$path = (string) parse_url($this->pageUrl, PHP_URL_PATH);
|
||||||
|
if ($path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (preg_match('#/m/([^/]+)/#', $path, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接 detailByShareCode 完整 GET URL
|
||||||
|
*/
|
||||||
|
private function buildShareCodeApiUrl(string $shareCode): ?string
|
||||||
|
{
|
||||||
|
$parsed = parse_url($this->pageUrl);
|
||||||
|
$scheme = isset($parsed['scheme']) ? (string) $parsed['scheme'] : 'https';
|
||||||
|
$host = isset($parsed['host']) ? (string) $parsed['host'] : '';
|
||||||
|
if ($host === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = http_build_query([
|
||||||
|
'shareCode' => $shareCode,
|
||||||
|
'password' => $this->password,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $scheme . '://' . $host . self::API_DETAIL_BY_SHARE_CODE . '?' . $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试通过 shareCode API 抓取;成功返回 UnifiedScrmData,否则返回 null
|
||||||
|
*/
|
||||||
|
private function tryFetchViaShareCodeApi(): ?UnifiedScrmData
|
||||||
|
{
|
||||||
|
if ($this->password === '') {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api skip: empty password', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shareCode = $this->extractShareCodeFromPageUrl();
|
||||||
|
if ($shareCode === null || $shareCode === '') {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api skip: cannot parse shareCode', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiUrl = $this->buildShareCodeApiUrl($shareCode);
|
||||||
|
if ($apiUrl === null) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api skip: invalid pageUrl host', [
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api request', [
|
||||||
|
'url' => preg_replace('/password=[^&]+/', 'password=***', $apiUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init($apiUrl);
|
||||||
|
if ($ch === false) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api curl init failed', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_TIMEOUT => self::API_FETCH_TIMEOUT_SEC,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Accept: application/json',
|
||||||
|
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlErr = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false || $curlErr !== '') {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api curl failed', [
|
||||||
|
'error' => $curlErr,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api http error', [
|
||||||
|
'httpCode' => $httpCode,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode((string) $response, true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api invalid json', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($payload['code'] ?? 0) !== 200) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api business error', [
|
||||||
|
'code' => $payload['code'] ?? null,
|
||||||
|
'msg' => $payload['msg'] ?? '',
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($payload['data']) || !is_array($payload['data'])) {
|
||||||
|
SplitTicketSyncLogger::log('spider', 'whatshub api invalid data field', []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parseShareCodeApiResponse($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 detailByShareCode 响应映射为 UnifiedScrmData
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function parseShareCodeApiResponse(array $payload): UnifiedScrmData
|
||||||
|
{
|
||||||
|
$unifiedData = new UnifiedScrmData();
|
||||||
|
|
||||||
|
foreach ($payload['data'] as $item) {
|
||||||
|
if (!is_array($item) || empty($item['account'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$number = (string) $item['account'];
|
||||||
|
$isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1;
|
||||||
|
$dayNewFans = (int) ($item['dayNewFans'] ?? 0);
|
||||||
|
|
||||||
|
// 号码同步仅使用 dayNewFans,不含 reDayNewFans;完成量由 Node statistics 提供
|
||||||
|
$unifiedData->addNumber($number, $isOnline, $dayNewFans);
|
||||||
|
}
|
||||||
|
|
||||||
|
$unifiedData->total = count($unifiedData->numbers);
|
||||||
|
|
||||||
|
return $unifiedData;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
protected function getSpiderConfig(): array
|
protected function getSpiderConfig(): array
|
||||||
{
|
{
|
||||||
$host = (string) parse_url($this->pageUrl, PHP_URL_HOST);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pageUrl' => $this->pageUrl,
|
'pageUrl' => $this->pageUrl,
|
||||||
'listApi' => self::API_LIST,
|
'listApi' => self::API_LIST,
|
||||||
@@ -56,21 +346,13 @@ class WhatshubSpider extends AbstractScrmSpider
|
|||||||
'listMethod' => 'POST',
|
'listMethod' => 'POST',
|
||||||
'paginationMode' => self::MODE_UI,
|
'paginationMode' => self::MODE_UI,
|
||||||
'authActions' => [
|
'authActions' => [
|
||||||
// Element Plus 密码弹窗:仅注入可见 input
|
// Whatshub 密码 UI 为 vxe-form + Element input,限定在表单内避免误填搜索框
|
||||||
['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password],
|
['type' => 'vue_fill', 'selector' => self::AUTH_PASSWORD_INPUT, 'value' => $this->password],
|
||||||
['type' => 'wait', 'ms' => 500],
|
['type' => 'wait', 'ms' => 500],
|
||||||
['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'],
|
['type' => 'vue_click', 'selector' => self::AUTH_CONFIRM_BUTTON],
|
||||||
['type' => 'wait', 'ms' => 2200],
|
['type' => 'wait', 'ms' => 2200],
|
||||||
],
|
],
|
||||||
// Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用
|
'antiBot' => AntiBotConfigBuilder::build('whatshub', $this->pageUrl),
|
||||||
'antiBot' => [
|
|
||||||
'enabled' => true,
|
|
||||||
'profile' => 'real',
|
|
||||||
'turnstile' => true,
|
|
||||||
'solverFallback' => true,
|
|
||||||
'sessionKey' => 'whatshub:' . $host,
|
|
||||||
'challengeTimeoutMs' => 60000,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +392,7 @@ class WhatshubSpider extends AbstractScrmSpider
|
|||||||
$unifiedData = $this->unifiedData;
|
$unifiedData = $this->unifiedData;
|
||||||
|
|
||||||
if (is_array($detailData)) {
|
if (is_array($detailData)) {
|
||||||
$unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0);
|
$unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0) + (int) ($detailData['data']['reDayNewFans'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($allListPagesData as $pageRaw) {
|
foreach ($allListPagesData as $pageRaw) {
|
||||||
|
|||||||
@@ -37,9 +37,25 @@ class SplitNodeHealthService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前是否适合向 Node 提交新 Browser 任务
|
* 当前是否适合向 Node 提交新 Browser 任务
|
||||||
|
*
|
||||||
|
* A2C 等 antiBot 任务走 real profile,应参考 queueReal 而非 standard 队列。
|
||||||
*/
|
*/
|
||||||
public static function canAcceptWork(): bool
|
public static function canAcceptWork(): bool
|
||||||
{
|
{
|
||||||
|
return self::canAcceptWorkForTicketType('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指定工单类型是否可向 Node 提交新任务
|
||||||
|
*
|
||||||
|
* API 优先类型(如 Whatshub)常态不占 Real Browser,调度层应跳过队列门禁。
|
||||||
|
*/
|
||||||
|
public static function canAcceptWorkForTicketType(string $ticketType): bool
|
||||||
|
{
|
||||||
|
if ($ticketType !== '' && SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$stats = self::fetchStats();
|
$stats = self::fetchStats();
|
||||||
if ($stats === null) {
|
if ($stats === null) {
|
||||||
return true;
|
return true;
|
||||||
@@ -47,33 +63,61 @@ class SplitNodeHealthService
|
|||||||
if (!empty($stats['shuttingDown'])) {
|
if (!empty($stats['shuttingDown'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
|
|
||||||
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
$snapshot = self::resolveQueueSnapshot($stats);
|
||||||
$queued = (int) ($queue['queued'] ?? 0);
|
|
||||||
$active = (int) ($queue['active'] ?? 0);
|
|
||||||
$max = max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4)));
|
|
||||||
$threshold = SplitSyncConfigService::getNodeQueueSkipThreshold();
|
$threshold = SplitSyncConfigService::getNodeQueueSkipThreshold();
|
||||||
if ($queued >= $threshold) {
|
if ($snapshot['queued'] >= $threshold) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return $active < $max;
|
|
||||||
|
return $snapshot['active'] < $snapshot['max'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{queued:int,active:int,max:int}
|
* @return array{queued:int,active:int,max:int,profile:string}
|
||||||
*/
|
*/
|
||||||
public static function getQueueSnapshot(): array
|
public static function getQueueSnapshot(): array
|
||||||
{
|
{
|
||||||
$stats = self::fetchStats();
|
$stats = self::fetchStats();
|
||||||
if ($stats === null) {
|
if ($stats === null) {
|
||||||
return ['queued' => 0, 'active' => 0, 'max' => 0];
|
return ['queued' => 0, 'active' => 0, 'max' => 0, 'profile' => 'real'];
|
||||||
}
|
}
|
||||||
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
|
|
||||||
|
$snapshot = self::resolveQueueSnapshot($stats);
|
||||||
|
return [
|
||||||
|
'queued' => $snapshot['queued'],
|
||||||
|
'active' => $snapshot['active'],
|
||||||
|
'max' => $snapshot['max'],
|
||||||
|
'profile' => $snapshot['profile'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优先使用 real 队列(antiBot / A2C),无数据时回退 standard
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $stats
|
||||||
|
* @return array{queued:int,active:int,max:int,profile:string}
|
||||||
|
*/
|
||||||
|
private static function resolveQueueSnapshot(array $stats): array
|
||||||
|
{
|
||||||
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
||||||
|
$queueReal = is_array($stats['queueReal'] ?? null) ? $stats['queueReal'] : [];
|
||||||
|
if ($queueReal !== []) {
|
||||||
|
return [
|
||||||
|
'queued' => (int) ($queueReal['queued'] ?? 0),
|
||||||
|
'active' => (int) ($queueReal['active'] ?? 0),
|
||||||
|
'max' => max(1, (int) ($config['maxConcurrentBrowsersReal'] ?? ($queueReal['max'] ?? 2))),
|
||||||
|
'profile' => 'real',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'queued' => (int) ($queue['queued'] ?? 0),
|
'queued' => (int) ($queue['queued'] ?? 0),
|
||||||
'active' => (int) ($queue['active'] ?? 0),
|
'active' => (int) ($queue['active'] ?? 0),
|
||||||
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))),
|
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))),
|
||||||
|
'profile' => 'standard',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Number;
|
||||||
|
use think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下号比率触线降权(ratio_deferred)规则与降权池清理
|
||||||
|
*
|
||||||
|
* 降权前提:已有落地页访问(visit_count > last_sync_visit_count),
|
||||||
|
* 且自上次同步以来进线未增长时累计 streak,触线才标记降权。
|
||||||
|
*/
|
||||||
|
class SplitNumberRatioDeferredService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 是否具备计入 streak 的落地页访问基数
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function hasLandingVisitBaseline($number): bool
|
||||||
|
{
|
||||||
|
$visitCount = $this->readInt($number, 'visit_count');
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastVisit = $this->readInt($number, 'last_sync_visit_count');
|
||||||
|
|
||||||
|
return $visitCount > $lastVisit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据访问与进线快照计算新的 streak
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function computeStreakForVisit($number, int $currentStreak): int
|
||||||
|
{
|
||||||
|
if (!$this->hasLandingVisitBaseline($number)) {
|
||||||
|
return $currentStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inboundCount = $this->readInt($number, 'inbound_count');
|
||||||
|
$lastInbound = $this->readInt($number, 'last_sync_inbound_count');
|
||||||
|
|
||||||
|
if ($inboundCount <= $lastInbound) {
|
||||||
|
return $currentStreak + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步侧:根据 visit / inbound 与上次同步检查点重算 streak
|
||||||
|
*
|
||||||
|
* 访问侧(recordVisitAfterRedirect)已按次 +1 累计 streak,此处不得再叠加 delta,否则首次同步会双倍计次导致提前关号。
|
||||||
|
* 取 max(访问侧 streak, 自上次同步以来的点击增量) 作为裁决值,并兜底访问钩子未触发的场景。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function computeStreakForSync($number, int $currentStreak): int
|
||||||
|
{
|
||||||
|
$visitCount = $this->readInt($number, 'visit_count');
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastVisit = $this->readInt($number, 'last_sync_visit_count');
|
||||||
|
$inboundCount = $this->readInt($number, 'inbound_count');
|
||||||
|
$lastInbound = $this->readInt($number, 'last_sync_inbound_count');
|
||||||
|
|
||||||
|
if ($inboundCount > $lastInbound) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visitCount > $lastVisit) {
|
||||||
|
$deltaSinceSync = $visitCount - $lastVisit;
|
||||||
|
|
||||||
|
return max($currentStreak, $deltaSinceSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $currentStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否应标记 ratio_deferred(访问侧触线降权,不关 status)
|
||||||
|
*/
|
||||||
|
public function shouldMarkDeferred(int $streak, int $assignRatio, bool $hasLandingBaseline): bool
|
||||||
|
{
|
||||||
|
return $hasLandingBaseline && $assignRatio > 0 && $streak >= $assignRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为应清理的无效降权记录
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function isInvalidDeferredRecord($number): bool
|
||||||
|
{
|
||||||
|
if ($this->readInt($number, 'ratio_deferred') !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !$this->hasLandingVisitBaseline($number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理无效降权标记(ratio_deferred=1 但无有效落地页访问基线)
|
||||||
|
*
|
||||||
|
* @param int[]|null $adminIds 数据权限管理员 ID 列表,null 表示不限制
|
||||||
|
* @return int 清理条数
|
||||||
|
*/
|
||||||
|
public function cleanupInvalidDeferred(?array $adminIds = null): int
|
||||||
|
{
|
||||||
|
$query = Number::where('ratio_deferred', 1);
|
||||||
|
if ($adminIds !== null && $adminIds !== []) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $query->field('id,visit_count,last_sync_visit_count,ratio_deferred,no_inbound_click_streak')->select();
|
||||||
|
if ($rows === null || $rows === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$count = 0;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!$this->isInvalidDeferredRecord($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$update = [
|
||||||
|
'ratio_deferred' => 0,
|
||||||
|
'ratio_deferred_at' => null,
|
||||||
|
'updatetime' => $now,
|
||||||
|
];
|
||||||
|
if ($this->readInt($row, 'visit_count') <= 0) {
|
||||||
|
$update['no_inbound_click_streak'] = 0;
|
||||||
|
}
|
||||||
|
Number::where('id', (int) $row['id'])->update($update);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计当前权限范围内无效降权条数(清理前预览)
|
||||||
|
*
|
||||||
|
* @param int[]|null $adminIds
|
||||||
|
*/
|
||||||
|
public function countInvalidDeferred(?array $adminIds = null): int
|
||||||
|
{
|
||||||
|
$query = Number::where('ratio_deferred', 1);
|
||||||
|
if ($adminIds !== null && $adminIds !== []) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
foreach ($query->field('id,visit_count,last_sync_visit_count,ratio_deferred')->select() as $row) {
|
||||||
|
if ($this->isInvalidDeferredRecord($row)) {
|
||||||
|
$total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
private function readInt($number, string $field): int
|
||||||
|
{
|
||||||
|
if (is_array($number)) {
|
||||||
|
return (int) ($number[$field] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $number->getAttr($field);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Number;
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落地页访问侧下号比率规则:更新连续无进线点击 streak,触线时仅降权(ratio_deferred),不关闭 status
|
||||||
|
*
|
||||||
|
* 最终关号由同步后 SplitTicketRuleService::applyNumberRules 裁决。
|
||||||
|
*/
|
||||||
|
class SplitNumberVisitRuleService
|
||||||
|
{
|
||||||
|
private SplitNumberRatioDeferredService $ratioDeferredService;
|
||||||
|
|
||||||
|
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
|
||||||
|
{
|
||||||
|
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 访问计数已递增后调用:累加 streak,达到 assign_ratio 时标记 ratio_deferred=1
|
||||||
|
*/
|
||||||
|
public function recordVisitAfterRedirect(int $numberId): void
|
||||||
|
{
|
||||||
|
if ($numberId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Number|null $number */
|
||||||
|
$number = Number::get($numberId);
|
||||||
|
if ($number === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Ticket|null $ticket */
|
||||||
|
$ticket = Ticket::where('admin_id', (int) $number['admin_id'])
|
||||||
|
->where('split_link_id', (int) $number['split_link_id'])
|
||||||
|
->where('ticket_name', (string) $number['ticket_name'])
|
||||||
|
->find();
|
||||||
|
if ($ticket === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignRatio = (int) ($ticket['assign_ratio'] ?? 0);
|
||||||
|
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
||||||
|
$hasBaseline = $this->ratioDeferredService->hasLandingVisitBaseline($number);
|
||||||
|
|
||||||
|
if ($hasBaseline) {
|
||||||
|
$streak = $this->ratioDeferredService->computeStreakForVisit($number, $streak);
|
||||||
|
}
|
||||||
|
|
||||||
|
$update = [
|
||||||
|
'no_inbound_click_streak' => $streak,
|
||||||
|
'updatetime' => time(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ((int) $number['manual_manage'] === 0
|
||||||
|
&& $this->ratioDeferredService->shouldMarkDeferred($streak, $assignRatio, $hasBaseline)
|
||||||
|
) {
|
||||||
|
$update['ratio_deferred'] = 1;
|
||||||
|
$update['ratio_deferred_at'] = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
Number::where('id', $numberId)->update($update);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,16 @@ namespace app\common\service;
|
|||||||
use app\admin\model\split\Link;
|
use app\admin\model\split\Link;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分流链接随机打乱配置读取
|
* 分流链接随机打乱配置与落地页选号
|
||||||
|
*
|
||||||
|
* - random_shuffle=0:跨工单合并号码池,按 id 顺序全局严格轮转
|
||||||
|
* - random_shuffle=1:先按各工单开启号码数比例随机选工单,再在该工单内随机选号
|
||||||
|
* - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布)
|
||||||
*/
|
*/
|
||||||
class SplitNumberWeighService
|
class SplitNumberWeighService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 链接是否开启随机打乱(新号码按随机插入顺序写入,跳转按 id 顺序轮转)
|
* 链接是否开启随机打乱
|
||||||
*/
|
*/
|
||||||
public static function isRandomShuffleEnabled(int $linkId): bool
|
public static function isRandomShuffleEnabled(int $linkId): bool
|
||||||
{
|
{
|
||||||
@@ -21,4 +25,52 @@ class SplitNumberWeighService
|
|||||||
}
|
}
|
||||||
return (int) Link::where('id', $linkId)->value('random_shuffle') === 1;
|
return (int) Link::where('id', $linkId)->value('random_shuffle') === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析本次访问应使用的号码下标(0 .. numberCount-1)
|
||||||
|
*
|
||||||
|
* @param int $linkId 分流链接 ID
|
||||||
|
* @param int $numberCount 可用号码数量
|
||||||
|
* @param SplitRoundRobinStore $roundRobinStore 关闭随机打乱时的轮转计数器
|
||||||
|
* @param string $poolKey 选号池标识(preferred / deferred 等)
|
||||||
|
*/
|
||||||
|
public static function resolvePickIndex(
|
||||||
|
int $linkId,
|
||||||
|
int $numberCount,
|
||||||
|
SplitRoundRobinStore $roundRobinStore,
|
||||||
|
string $poolKey = 'main'
|
||||||
|
): int {
|
||||||
|
if ($numberCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ($numberCount === 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isRandomShuffleEnabled($linkId)) {
|
||||||
|
return random_int(0, $numberCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $roundRobinStore->nextIndex($linkId, $numberCount, $poolKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从号码行列表中按链接配置选一条(严格轮转或随机)
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>|Number> $rows
|
||||||
|
* @return array<string, mixed>|Number|null
|
||||||
|
*/
|
||||||
|
public static function pickRowFromList(
|
||||||
|
int $linkId,
|
||||||
|
array $rows,
|
||||||
|
SplitRoundRobinStore $roundRobinStore,
|
||||||
|
string $poolKey = 'main'
|
||||||
|
) {
|
||||||
|
$count = count($rows);
|
||||||
|
if ($count === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$index = self::resolvePickIndex($linkId, $count, $roundRobinStore, $poolKey);
|
||||||
|
return $rows[$index] ?? $rows[0];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短链 HTTP 重定向预解析(与 puppeteer-api/url-resolve.js 行为对齐)
|
||||||
|
*
|
||||||
|
* 用于 antiBot landingUrlHint;CF 拦截时可能失败,浏览器仍会 follow。
|
||||||
|
*/
|
||||||
|
final class SplitPageUrlResolver
|
||||||
|
{
|
||||||
|
private const REDIRECT_CODES = [301, 302, 303, 307, 308];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟随 HTTP 重定向,返回最终 URL;失败时返回原始 URL
|
||||||
|
*/
|
||||||
|
public static function resolveFinalUrl(string $pageUrl, int $maxRedirects = 10, int $timeoutSec = 12): string
|
||||||
|
{
|
||||||
|
$current = trim($pageUrl);
|
||||||
|
if ($current === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($i = 0; $i < $maxRedirects; $i++) {
|
||||||
|
$response = self::fetchHeadOrGet($current, $timeoutSec);
|
||||||
|
if ($response === null) {
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $response['code'];
|
||||||
|
$location = $response['location'];
|
||||||
|
if (!in_array($code, self::REDIRECT_CODES, true) || $location === '') {
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = self::resolveRelativeUrl($current, $location);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{code: int, location: string}|null
|
||||||
|
*/
|
||||||
|
private static function fetchHeadOrGet(string $url, int $timeoutSec): ?array
|
||||||
|
{
|
||||||
|
$result = self::curlOnce($url, true, $timeoutSec);
|
||||||
|
if ($result !== null) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::curlOnce($url, false, $timeoutSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{code: int, location: string}|null
|
||||||
|
*/
|
||||||
|
private static function curlOnce(string $url, bool $headOnly, int $timeoutSec): ?array
|
||||||
|
{
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
if ($ch === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_NOBODY => $headOnly,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
|
CURLOPT_TIMEOUT => $timeoutSec,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; SplitPageUrlResolver/1.0)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
if ($body === false) {
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$redirectUrl = (string) curl_getinfo($ch, CURLINFO_REDIRECT_URL);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$location = $redirectUrl;
|
||||||
|
if ($location === '' && is_string($body)) {
|
||||||
|
$location = self::parseLocationHeader($body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['code' => $code, 'location' => trim($location)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parseLocationHeader(string $rawHeaders): string
|
||||||
|
{
|
||||||
|
foreach (preg_split('/\r\n|\n|\r/', $rawHeaders) as $line) {
|
||||||
|
if (stripos($line, 'Location:') === 0) {
|
||||||
|
return trim(substr($line, 9));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveRelativeUrl(string $baseUrl, string $location): string
|
||||||
|
{
|
||||||
|
if (preg_match('#^https?://#i', $location)) {
|
||||||
|
return $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = parse_url($baseUrl);
|
||||||
|
if (!is_array($base)) {
|
||||||
|
return $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = (string) ($base['scheme'] ?? 'https');
|
||||||
|
$host = (string) ($base['host'] ?? '');
|
||||||
|
if ($host === '') {
|
||||||
|
return $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($location, '/') === 0) {
|
||||||
|
return $scheme . '://' . $host . $location;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = (string) ($base['path'] ?? '/');
|
||||||
|
$dir = rtrim(str_replace(basename($path), '', $path), '/');
|
||||||
|
|
||||||
|
return $scheme . '://' . $host . ($dir !== '' ? $dir . '/' : '/') . $location;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,18 +6,30 @@ namespace app\common\service;
|
|||||||
|
|
||||||
use app\admin\model\split\Link;
|
use app\admin\model\split\Link;
|
||||||
use app\admin\model\split\Number;
|
use app\admin\model\split\Number;
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
use think\Collection;
|
use think\Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分流链接落地页:查链、轮转选号、拼接跳转 URL、访问计数
|
* 分流链接落地页:查链、选号、拼接跳转 URL、访问计数
|
||||||
|
*
|
||||||
|
* 号码池:同一 split_link_id 下 status=normal,且所属工单未关闭(ticket.status≠hidden)的号码;
|
||||||
|
* 优先选用 ratio_deferred=0,全部触线降权时回退至降权池。
|
||||||
|
*
|
||||||
|
* random_shuffle=0:跨工单合并池,全局严格轮转;
|
||||||
|
* random_shuffle=1:先按各工单开启号码数量比例随机选工单,再在该工单内随机选号。
|
||||||
*/
|
*/
|
||||||
class SplitRedirectService
|
class SplitRedirectService
|
||||||
{
|
{
|
||||||
private SplitRoundRobinStore $roundRobinStore;
|
private SplitRoundRobinStore $roundRobinStore;
|
||||||
|
|
||||||
public function __construct(?SplitRoundRobinStore $roundRobinStore = null)
|
private SplitNumberVisitRuleService $visitRuleService;
|
||||||
{
|
|
||||||
|
public function __construct(
|
||||||
|
?SplitRoundRobinStore $roundRobinStore = null,
|
||||||
|
?SplitNumberVisitRuleService $visitRuleService = null
|
||||||
|
) {
|
||||||
$this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
|
$this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
|
||||||
|
$this->visitRuleService = $visitRuleService ?? new SplitNumberVisitRuleService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,22 +58,17 @@ class SplitRedirectService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection<int, Number>|array<int, Number> $numbers */
|
$linkId = (int) $link->getAttr('id');
|
||||||
$numbers = Number::where('split_link_id', (int) $link['id'])
|
$list = $this->loadEligibleNumbers($linkId);
|
||||||
->where('status', 'normal')
|
if ($list === []) {
|
||||||
->order('id', 'asc')
|
|
||||||
->field('id,number,number_type,number_type_custom')
|
|
||||||
->select();
|
|
||||||
|
|
||||||
$count = is_countable($numbers) ? count($numbers) : 0;
|
|
||||||
if ($count === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$linkId = (int) $link->getAttr('id');
|
if (SplitNumberWeighService::isRandomShuffleEnabled($linkId)) {
|
||||||
$index = $this->roundRobinStore->nextIndex($linkId, $count);
|
$picked = $this->pickNumberRowRandomByTicketWeight($linkId, $list);
|
||||||
$list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers;
|
} else {
|
||||||
$picked = $list[$index] ?? $list[0] ?? null;
|
$picked = $this->pickNumberRow($linkId, $list);
|
||||||
|
}
|
||||||
if ($picked === null) {
|
if ($picked === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -90,8 +97,146 @@ class SplitRedirectService
|
|||||||
$numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id');
|
$numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id');
|
||||||
if ($numberId > 0) {
|
if ($numberId > 0) {
|
||||||
Number::where('id', $numberId)->setInc('visit_count');
|
Number::where('id', $numberId)->setInc('visit_count');
|
||||||
|
$this->visitRuleService->recordVisitAfterRedirect($numberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $redirectUrl;
|
return $redirectUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可参与选号的号码:status=normal,且 ticket_name 不属于已关闭工单
|
||||||
|
*
|
||||||
|
* @return list<Number|array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function loadEligibleNumbers(int $linkId): array
|
||||||
|
{
|
||||||
|
if ($linkId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<string> $hiddenTicketNames */
|
||||||
|
$hiddenTicketNames = Ticket::where('split_link_id', $linkId)
|
||||||
|
->where('status', 'hidden')
|
||||||
|
->column('ticket_name');
|
||||||
|
$hiddenTicketNames = array_values(array_unique(array_filter(array_map(
|
||||||
|
static function ($name): string {
|
||||||
|
return trim((string) $name);
|
||||||
|
},
|
||||||
|
$hiddenTicketNames
|
||||||
|
))));
|
||||||
|
|
||||||
|
$query = Number::where('split_link_id', $linkId)
|
||||||
|
->where('status', 'normal')
|
||||||
|
->order('id', 'asc')
|
||||||
|
->field('id,number,number_type,number_type_custom,ratio_deferred,ticket_name');
|
||||||
|
|
||||||
|
if ($hiddenTicketNames !== []) {
|
||||||
|
$query->where('ticket_name', 'not in', $hiddenTicketNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
$numbers = $query->select();
|
||||||
|
return $numbers instanceof Collection ? $numbers->all() : (array) $numbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* random_shuffle=1:先按工单开启号码数加权随机选工单,再在该工单内选号
|
||||||
|
*
|
||||||
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
|
* @return array<string, mixed>|Number|null
|
||||||
|
*/
|
||||||
|
private function pickNumberRowRandomByTicketWeight(int $linkId, array $rows)
|
||||||
|
{
|
||||||
|
$groups = $this->groupNumbersByTicket($rows);
|
||||||
|
if ($groups === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketKey = $this->pickTicketGroupKeyWeighted($groups);
|
||||||
|
if ($ticketKey === null || !isset($groups[$ticketKey])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pickNumberRow($linkId, $groups[$ticketKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
|
* @return array<string, list<Number|array<string, mixed>>>
|
||||||
|
*/
|
||||||
|
private function groupNumbersByTicket(array $rows): array
|
||||||
|
{
|
||||||
|
/** @var array<string, list<Number|array<string, mixed>>> $groups */
|
||||||
|
$groups = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = is_array($row)
|
||||||
|
? trim((string) ($row['ticket_name'] ?? ''))
|
||||||
|
: trim((string) $row->getAttr('ticket_name'));
|
||||||
|
$key = $name !== '' ? $name : '__manual__';
|
||||||
|
$groups[$key][] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按各分组号码数量加权随机选一个工单(分组 key)
|
||||||
|
*
|
||||||
|
* @param array<string, list<Number|array<string, mixed>>> $groups
|
||||||
|
*/
|
||||||
|
private function pickTicketGroupKeyWeighted(array $groups): ?string
|
||||||
|
{
|
||||||
|
$total = 0;
|
||||||
|
foreach ($groups as $rows) {
|
||||||
|
$total += count($rows);
|
||||||
|
}
|
||||||
|
if ($total <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = array_keys($groups);
|
||||||
|
if (count($keys) === 1) {
|
||||||
|
// PHP 会把纯数字字符串键转成 int,需强转以匹配 ?string 返回类型
|
||||||
|
return (string) $keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = random_int(1, $total);
|
||||||
|
$acc = 0;
|
||||||
|
foreach ($groups as $key => $rows) {
|
||||||
|
$acc += count($rows);
|
||||||
|
if ($r <= $acc) {
|
||||||
|
return (string) $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双层选号:优先 ratio_deferred=0,否则回退至降权池
|
||||||
|
*
|
||||||
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
|
* @return array<string, mixed>|Number|null
|
||||||
|
*/
|
||||||
|
private function pickNumberRow(int $linkId, array $rows)
|
||||||
|
{
|
||||||
|
$preferred = [];
|
||||||
|
$deferred = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$deferredFlag = is_array($row)
|
||||||
|
? (int) ($row['ratio_deferred'] ?? 0)
|
||||||
|
: (int) $row->getAttr('ratio_deferred');
|
||||||
|
if ($deferredFlag === 1) {
|
||||||
|
$deferred[] = $row;
|
||||||
|
} else {
|
||||||
|
$preferred[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$picked = SplitNumberWeighService::pickRowFromList($linkId, $preferred, $this->roundRobinStore, 'preferred');
|
||||||
|
if ($picked !== null) {
|
||||||
|
return $picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SplitNumberWeighService::pickRowFromList($linkId, $deferred, $this->roundRobinStore, 'deferred');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,19 @@ class SplitRoundRobinStore
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取本次访问应使用的号码下标(0 .. count-1)
|
* 获取本次访问应使用的号码下标(0 .. count-1)
|
||||||
|
*
|
||||||
|
* @param string $poolKey 选号池标识(如 preferred / deferred),用于双层选号独立轮转
|
||||||
*/
|
*/
|
||||||
public function nextIndex(int $splitLinkId, int $numberCount): int
|
public function nextIndex(int $splitLinkId, int $numberCount, string $poolKey = 'main'): int
|
||||||
{
|
{
|
||||||
if ($splitLinkId <= 0 || $numberCount <= 0) {
|
if ($splitLinkId <= 0 || $numberCount <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$poolKey = $this->sanitizePoolKey($poolKey);
|
||||||
|
|
||||||
if ($this->ensureRedis()) {
|
if ($this->ensureRedis()) {
|
||||||
$key = self::KEY_PREFIX . $splitLinkId;
|
$key = self::KEY_PREFIX . $splitLinkId . ':' . $poolKey;
|
||||||
$seq = (int) $this->redis->incr($key);
|
$seq = (int) $this->redis->incr($key);
|
||||||
if ($seq <= 0) {
|
if ($seq <= 0) {
|
||||||
$seq = 1;
|
$seq = 1;
|
||||||
@@ -39,7 +43,16 @@ class SplitRoundRobinStore
|
|||||||
return ($seq - 1) % $numberCount;
|
return ($seq - 1) % $numberCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->nextIndexFallback($splitLinkId, $numberCount);
|
return $this->nextIndexFallback($splitLinkId, $numberCount, $poolKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选号池 key 仅允许字母数字与下划线,避免 Redis key 注入
|
||||||
|
*/
|
||||||
|
private function sanitizePoolKey(string $poolKey): string
|
||||||
|
{
|
||||||
|
$poolKey = preg_replace('/[^a-zA-Z0-9_]/', '', $poolKey) ?? '';
|
||||||
|
return $poolKey !== '' ? $poolKey : 'main';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,14 +115,14 @@ class SplitRoundRobinStore
|
|||||||
/**
|
/**
|
||||||
* 无 Redis 时使用 runtime 文件锁递增(开发/单机可用;生产请启用 Redis)
|
* 无 Redis 时使用 runtime 文件锁递增(开发/单机可用;生产请启用 Redis)
|
||||||
*/
|
*/
|
||||||
private function nextIndexFallback(int $splitLinkId, int $numberCount): int
|
private function nextIndexFallback(int $splitLinkId, int $numberCount, string $poolKey = 'main'): int
|
||||||
{
|
{
|
||||||
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
||||||
$dir = $runtime . 'split_rr/';
|
$dir = $runtime . 'split_rr/';
|
||||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
$file = $dir . $splitLinkId . '.cnt';
|
$file = $dir . $splitLinkId . '_' . $poolKey . '.cnt';
|
||||||
$fp = @fopen($file, 'c+');
|
$fp = @fopen($file, 'c+');
|
||||||
if ($fp === false) {
|
if ($fp === false) {
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\common\service;
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\common\library\scrm\AbstractScrmSpider;
|
||||||
use app\common\library\scrm\ScrmSpiderInterface;
|
use app\common\library\scrm\ScrmSpiderInterface;
|
||||||
use app\common\library\scrm\spider\A2cSpider;
|
use app\common\library\scrm\spider\A2cSpider;
|
||||||
use app\common\library\scrm\spider\ChatknowSpider;
|
use app\common\library\scrm\spider\ChatknowSpider;
|
||||||
@@ -32,6 +33,16 @@ class SplitScrmSpiderFactory
|
|||||||
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 优先同步类型:常态走 PHP cURL,不占 Node Real Browser 槽位
|
||||||
|
*
|
||||||
|
* Whatshub 已改为混合同步(cURL 号码 + Node statistics),需走 Node 队列门禁。
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const API_FIRST_SYNC_TYPES = [
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return class-string<ScrmSpiderInterface>|null
|
* @return class-string<ScrmSpiderInterface>|null
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +70,65 @@ class SplitScrmSpiderFactory
|
|||||||
return array_keys(self::MAP);
|
return array_keys(self::MAP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否 API 优先类型(调度层可并行投递且不占 Node 队列)
|
||||||
|
*/
|
||||||
|
public static function isApiFirstSyncType(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return in_array(trim($ticketType), self::API_FIRST_SYNC_TYPES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function listApiFirstSyncTypes(): array
|
||||||
|
{
|
||||||
|
return self::API_FIRST_SYNC_TYPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步是否依赖 Node Headless(纯 PHP cURL 等为 false)
|
||||||
|
*/
|
||||||
|
public static function requiresNode(string $ticketType): bool
|
||||||
|
{
|
||||||
|
$class = self::resolveClass($ticketType);
|
||||||
|
if ($class === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_subclass_of($class, AbstractScrmSpider::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function listDirectSyncTypes(): array
|
||||||
|
{
|
||||||
|
$types = [];
|
||||||
|
foreach (self::listSupportedTypes() as $ticketType) {
|
||||||
|
if (!self::requiresNode($ticketType)) {
|
||||||
|
$types[] = $ticketType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $types;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function listNodeSyncTypes(): array
|
||||||
|
{
|
||||||
|
$types = [];
|
||||||
|
foreach (self::listSupportedTypes() as $ticketType) {
|
||||||
|
if (self::requiresNode($ticketType)) {
|
||||||
|
$types[] = $ticketType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $types;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return ScrmSpiderInterface|null
|
* @return ScrmSpiderInterface|null
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ class SplitSyncConfigService
|
|||||||
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
|
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 失败重试时间点最多配置条数 */
|
||||||
|
private const FAIL_RETRY_SCHEDULE_MAX = 10;
|
||||||
|
|
||||||
|
/** 单条重试时间点上限(分钟) */
|
||||||
|
private const FAIL_RETRY_MINUTE_MAX = 1440;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
|
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
|
||||||
*/
|
*/
|
||||||
@@ -36,6 +42,50 @@ class SplitSyncConfigService
|
|||||||
return max(0, (int) $value);
|
return max(0, (int) $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 达失败暂停阈值后的重试时间点(分钟,相对锚点的绝对偏移,升序去重)
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public static function getFailRetryMinutes(): array
|
||||||
|
{
|
||||||
|
$raw = trim(self::getConfigValue('split_sync_fail_retry_minutes'));
|
||||||
|
if ($raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = preg_split('/[,,\s]+/', $raw) ?: [];
|
||||||
|
$minutes = [];
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$part = trim((string) $part);
|
||||||
|
if ($part === '' || !ctype_digit($part)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$minute = (int) $part;
|
||||||
|
if ($minute < 1 || $minute > self::FAIL_RETRY_MINUTE_MAX) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$minutes[$minute] = $minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($minutes === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = array_values($minutes);
|
||||||
|
sort($list, SORT_NUMERIC);
|
||||||
|
|
||||||
|
return array_slice($list, 0, self::FAIL_RETRY_SCHEDULE_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否配置了失败分阶段重试时间点
|
||||||
|
*/
|
||||||
|
public static function hasFailRetrySchedule(): bool
|
||||||
|
{
|
||||||
|
return self::getFailRetryMinutes() !== [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 指定工单类型的自动同步周期(分钟),0 表示不自动同步
|
* 指定工单类型的自动同步周期(分钟),0 表示不自动同步
|
||||||
*/
|
*/
|
||||||
@@ -57,9 +107,21 @@ class SplitSyncConfigService
|
|||||||
{
|
{
|
||||||
$value = self::getConfigValue('split_sync_max_per_cron');
|
$value = self::getConfigValue('split_sync_max_per_cron');
|
||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
return 2;
|
return 5;
|
||||||
}
|
}
|
||||||
return max(1, min(20, (int) $value));
|
return max(1, min(50, (int) $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 通道候选池倍数(候选数 = maxPerRun × 本值)
|
||||||
|
*/
|
||||||
|
public static function getNodeCandidatePoolMultiplier(): int
|
||||||
|
{
|
||||||
|
$value = self::getConfigValue('split_sync_node_candidate_multiplier');
|
||||||
|
if ($value === '') {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
return max(3, min(50, (int) $value));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +148,53 @@ class SplitSyncConfigService
|
|||||||
return max(0, (int) $value);
|
return max(0, (int) $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台投递 / Cron 使用的 PHP CLI 可执行文件路径
|
||||||
|
*
|
||||||
|
* Web(FPM) 环境下 PHP_BINARY 指向 php-fpm,不能用于 think 命令。
|
||||||
|
*/
|
||||||
|
public static function getCliPhpBinary(): string
|
||||||
|
{
|
||||||
|
$configured = trim(self::getConfigValue('split_sync_cli_php'));
|
||||||
|
if ($configured !== '' && self::isUsableCliPhp($configured)) {
|
||||||
|
return $configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defined('PHP_BINDIR') && PHP_BINDIR !== '') {
|
||||||
|
$candidate = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php';
|
||||||
|
if (self::isUsableCliPhp($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PHP_SAPI === 'cli' && defined('PHP_BINARY') && PHP_BINARY !== '' && self::isUsableCliPhp(PHP_BINARY)) {
|
||||||
|
return PHP_BINARY;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['/usr/bin/php', '/usr/local/bin/php'] as $candidate) {
|
||||||
|
if (self::isUsableCliPhp($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断路径是否为可用的 PHP CLI(排除 php-fpm)
|
||||||
|
*/
|
||||||
|
private static function isUsableCliPhp(string $path): bool
|
||||||
|
{
|
||||||
|
if (stripos($path, 'fpm') !== false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($path === 'php') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_file($path) && is_executable($path);
|
||||||
|
}
|
||||||
|
|
||||||
private static function getConfigValue(string $name): string
|
private static function getConfigValue(string $name): string
|
||||||
{
|
{
|
||||||
$site = Config::get('site.' . $name);
|
$site = Config::get('site.' . $name);
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Number;
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单本地开启号码数(active_number_count)物化字段维护
|
||||||
|
*
|
||||||
|
* 统计口径:fa_split_number 中 admin_id + split_link_id + ticket_name 匹配且 status=normal
|
||||||
|
*/
|
||||||
|
class SplitTicketActiveNumberCountService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 按工单关联键统计并回写 active_number_count
|
||||||
|
*/
|
||||||
|
public function refreshForTicketKeys(int $adminId, int $linkId, string $ticketName): void
|
||||||
|
{
|
||||||
|
$ticketName = trim($ticketName);
|
||||||
|
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = (int) Number::where('admin_id', $adminId)
|
||||||
|
->where('split_link_id', $linkId)
|
||||||
|
->where('ticket_name', $ticketName)
|
||||||
|
->where('status', 'normal')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
Ticket::where('admin_id', $adminId)
|
||||||
|
->where('split_link_id', $linkId)
|
||||||
|
->where('ticket_name', $ticketName)
|
||||||
|
->update([
|
||||||
|
'active_number_count' => $count,
|
||||||
|
'updatetime' => time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按工单模型回写
|
||||||
|
*/
|
||||||
|
public function refreshForTicket(Ticket $ticket): void
|
||||||
|
{
|
||||||
|
$this->refreshForTicketKeys(
|
||||||
|
(int) $ticket['admin_id'],
|
||||||
|
(int) $ticket['split_link_id'],
|
||||||
|
(string) $ticket['ticket_name']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量号码 ID:去重后刷新涉及工单
|
||||||
|
*
|
||||||
|
* @param array<int|string> $ids
|
||||||
|
*/
|
||||||
|
public function refreshForNumberIds(array $ids): void
|
||||||
|
{
|
||||||
|
$ids = array_values(array_filter(array_map('intval', $ids)));
|
||||||
|
if ($ids === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = Number::where('id', 'in', $ids)
|
||||||
|
->field('admin_id,split_link_id,ticket_name')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$this->refreshFromNumberRows($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑单条号码:关联键变更时刷新旧工单与新工单
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $before
|
||||||
|
* @param array<string, mixed> $after
|
||||||
|
*/
|
||||||
|
public function refreshAfterNumberEdit(array $before, array $after): void
|
||||||
|
{
|
||||||
|
$keys = [];
|
||||||
|
$beforeKey = $this->buildTicketKeyFromRow($before);
|
||||||
|
if ($beforeKey !== null) {
|
||||||
|
$keys[] = $beforeKey;
|
||||||
|
}
|
||||||
|
$afterKey = $this->buildTicketKeyFromRow($after);
|
||||||
|
if ($afterKey !== null) {
|
||||||
|
$keys[] = $afterKey;
|
||||||
|
}
|
||||||
|
$this->refreshForTicketKeyList($keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量工单关联键去重后刷新
|
||||||
|
*
|
||||||
|
* @param array<int, array{0:int,1:int,2:string}> $keys
|
||||||
|
*/
|
||||||
|
public function refreshForTicketKeyList(array $keys): void
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (!is_array($key) || count($key) < 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$adminId = (int) $key[0];
|
||||||
|
$linkId = (int) $key[1];
|
||||||
|
$ticketName = trim((string) $key[2]);
|
||||||
|
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hash = $adminId . ':' . $linkId . ':' . $ticketName;
|
||||||
|
if (isset($seen[$hash])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$hash] = true;
|
||||||
|
$this->refreshForTicketKeys($adminId, $linkId, $ticketName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<int, array<string, mixed>|Number> $rows
|
||||||
|
*/
|
||||||
|
public function refreshFromNumberRows(iterable $rows): void
|
||||||
|
{
|
||||||
|
$keys = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$data = $row instanceof Number ? $row->getData() : (array) $row;
|
||||||
|
$key = $this->buildTicketKeyFromRow($data);
|
||||||
|
if ($key !== null) {
|
||||||
|
$keys[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->refreshForTicketKeyList($keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array{0:int,1:int,2:string}|null
|
||||||
|
*/
|
||||||
|
private function buildTicketKeyFromRow(array $row): ?array
|
||||||
|
{
|
||||||
|
$adminId = (int) ($row['admin_id'] ?? 0);
|
||||||
|
$linkId = (int) ($row['split_link_id'] ?? 0);
|
||||||
|
$ticketName = trim((string) ($row['ticket_name'] ?? ''));
|
||||||
|
if ($adminId <= 0 || $linkId <= 0 || $ticketName === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$adminId, $linkId, $ticketName];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,16 @@ use think\Db;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 工单同步结果写入号码表
|
* 工单同步结果写入号码表
|
||||||
|
*
|
||||||
|
* 同步策略(全量镜像):
|
||||||
|
* - 爬虫返回的号码:写入 platform_status / inbound_count;status 由后续 applyNumberRules 裁决
|
||||||
|
* - 爬虫未返回且 manual_manage=0:物理删除
|
||||||
|
* - manual_manage=1:不删除,仅当仍在爬虫结果中时更新 platform_status
|
||||||
*/
|
*/
|
||||||
class SplitTicketNumberSyncService
|
class SplitTicketNumberSyncService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 将蜘蛛返回的号码列表同步到号码管理
|
* 将蜘蛛返回的号码列表同步到号码管理(全量镜像,以爬虫为准)
|
||||||
*/
|
*/
|
||||||
public function syncFromUnifiedData(Ticket $ticket, UnifiedScrmData $data): void
|
public function syncFromUnifiedData(Ticket $ticket, UnifiedScrmData $data): void
|
||||||
{
|
{
|
||||||
@@ -61,7 +66,7 @@ class SplitTicketNumberSyncService
|
|||||||
$newFollowers = (int) ($row['newFollowersToday'] ?? 0);
|
$newFollowers = (int) ($row['newFollowersToday'] ?? 0);
|
||||||
|
|
||||||
if (isset($existingMap[$number])) {
|
if (isset($existingMap[$number])) {
|
||||||
$this->updateExistingNumber($existingMap[$number], $platformStatus, $newFollowers);
|
$this->updateExistingNumber($ticket, $existingMap[$number], $platformStatus, $newFollowers);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +92,7 @@ class SplitTicketNumberSyncService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$deleteIds = [];
|
||||||
foreach ($existingMap as $number => $item) {
|
foreach ($existingMap as $number => $item) {
|
||||||
if (isset($syncedNumberSet[$number])) {
|
if (isset($syncedNumberSet[$number])) {
|
||||||
continue;
|
continue;
|
||||||
@@ -94,18 +100,42 @@ class SplitTicketNumberSyncService
|
|||||||
if ((int) $item['manual_manage'] === 1) {
|
if ((int) $item['manual_manage'] === 1) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Number::where('id', (int) $item['id'])->update([
|
$deleteIds[] = (int) $item['id'];
|
||||||
'status' => 'hidden',
|
|
||||||
'updatetime' => time(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
if ($deleteIds !== []) {
|
||||||
|
Number::where('id', 'in', $deleteIds)->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单手动开启等非同步场景:按业务规则对齐号码开关(平台 + 单号上限 + 下号比率)
|
||||||
|
*/
|
||||||
|
public function applyStatusFromPlatformSnapshot(Ticket $ticket): void
|
||||||
|
{
|
||||||
|
(new SplitTicketRuleService())->applyNumberRules($ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 云控在线 → 开启;离线 → 关闭;工单关闭时一律关闭
|
||||||
|
*/
|
||||||
|
public static function resolveSwitchStatus(Ticket $ticket, string $platformStatus): string
|
||||||
|
{
|
||||||
|
if ((string) ($ticket['status'] ?? 'hidden') !== 'normal') {
|
||||||
|
return 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $platformStatus === 'online' ? 'normal' : 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Number $row
|
* @param Number $row
|
||||||
*/
|
*/
|
||||||
private function updateExistingNumber($row, string $platformStatus, int $newFollowers): void
|
private function updateExistingNumber(
|
||||||
{
|
Ticket $ticket,
|
||||||
|
$row,
|
||||||
|
string $platformStatus,
|
||||||
|
int $newFollowers
|
||||||
|
): void {
|
||||||
$update = [
|
$update = [
|
||||||
'platform_status' => $platformStatus,
|
'platform_status' => $platformStatus,
|
||||||
'updatetime' => time(),
|
'updatetime' => time(),
|
||||||
@@ -116,7 +146,6 @@ class SplitTicketNumberSyncService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进线人数由同步写入,最终开关由 applyNumberRules 统一判定(单号上限/下号比率等)
|
|
||||||
$update['inbound_count'] = max(0, $newFollowers);
|
$update['inbound_count'] = max(0, $newFollowers);
|
||||||
Number::where('id', (int) $row['id'])->update($update);
|
Number::where('id', (int) $row['id'])->update($update);
|
||||||
}
|
}
|
||||||
@@ -139,7 +168,7 @@ class SplitTicketNumberSyncService
|
|||||||
'inbound_count' => max(0, $newFollowers),
|
'inbound_count' => max(0, $newFollowers),
|
||||||
'manual_manage' => 0,
|
'manual_manage' => 0,
|
||||||
'platform_status' => $platformStatus,
|
'platform_status' => $platformStatus,
|
||||||
'status' => 'hidden',
|
'ratio_deferred' => 0,
|
||||||
'createtime' => $now,
|
'createtime' => $now,
|
||||||
'updatetime' => $now,
|
'updatetime' => $now,
|
||||||
];
|
];
|
||||||
@@ -148,10 +177,11 @@ class SplitTicketNumberSyncService
|
|||||||
Db::name('split_number')->insert($data);
|
Db::name('split_number')->insert($data);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$exists = Number::where('split_link_id', (int) $ticket['split_link_id'])
|
$exists = Number::where('split_link_id', (int) $ticket['split_link_id'])
|
||||||
|
->where('ticket_name', (string) $ticket['ticket_name'])
|
||||||
->where('number', $number)
|
->where('number', $number)
|
||||||
->find();
|
->find();
|
||||||
if ($exists) {
|
if ($exists) {
|
||||||
$this->updateExistingNumber($exists, $platformStatus, $newFollowers);
|
$this->updateExistingNumber($ticket, $exists, $platformStatus, $newFollowers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,40 @@ use app\admin\model\split\Ticket;
|
|||||||
*/
|
*/
|
||||||
class SplitTicketRuleService
|
class SplitTicketRuleService
|
||||||
{
|
{
|
||||||
|
private SplitNumberRatioDeferredService $ratioDeferredService;
|
||||||
|
|
||||||
|
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
|
||||||
|
{
|
||||||
|
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步后应用全部规则并写回工单/号码
|
* 手动关闭的工单(manual_manage=1):禁止自动同步改写 status,须用户手动开启
|
||||||
|
*/
|
||||||
|
public function isManuallyClosed(Ticket $ticket): bool
|
||||||
|
{
|
||||||
|
return (int) ($ticket['manual_manage'] ?? 0) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表/编辑切换状态时同步 manual_manage 标记
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $values
|
||||||
|
*/
|
||||||
|
public function applyManualManageForStatusChange(array &$values): void
|
||||||
|
{
|
||||||
|
if (!isset($values['status'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((string) $values['status'] === 'hidden') {
|
||||||
|
$values['manual_manage'] = 1;
|
||||||
|
} elseif ((string) $values['status'] === 'normal') {
|
||||||
|
$values['manual_manage'] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率)
|
||||||
*/
|
*/
|
||||||
public function applyAfterSync(Ticket $ticket, int $completeCount): void
|
public function applyAfterSync(Ticket $ticket, int $completeCount): void
|
||||||
{
|
{
|
||||||
@@ -22,10 +54,11 @@ class SplitTicketRuleService
|
|||||||
if ($fresh) {
|
if ($fresh) {
|
||||||
if ((string) $fresh['status'] === 'hidden') {
|
if ((string) $fresh['status'] === 'hidden') {
|
||||||
$this->cascadeTicketClosedToNumbers($fresh);
|
$this->cascadeTicketClosedToNumbers($fresh);
|
||||||
}
|
} else {
|
||||||
$this->applyNumberRules($fresh);
|
$this->applyNumberRules($fresh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步流程:工单因时间/完成量等原因关闭时,联动关闭非手动号码
|
* 同步流程:工单因时间/完成量等原因关闭时,联动关闭非手动号码
|
||||||
@@ -43,10 +76,33 @@ class SplitTicketRuleService
|
|||||||
'status' => 'hidden',
|
'status' => 'hidden',
|
||||||
'updatetime' => time(),
|
'updatetime' => time(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshForTicket($ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动切换工单状态时,联动非手动管理的号码
|
* 工单物理删除时,移除该工单关联的全部号码(含 manual_manage=1)
|
||||||
|
*
|
||||||
|
* 关联键与 SplitTicketNumberSyncService 一致:admin_id + split_link_id + ticket_name
|
||||||
|
*
|
||||||
|
* @return int 删除行数
|
||||||
|
*/
|
||||||
|
public function deleteSyncedNumbersForTicket(Ticket $ticket): int
|
||||||
|
{
|
||||||
|
$ticketName = trim((string) ($ticket['ticket_name'] ?? ''));
|
||||||
|
$linkId = (int) ($ticket['split_link_id'] ?? 0);
|
||||||
|
if ($ticketName === '' || $linkId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number::where('admin_id', (int) $ticket['admin_id'])
|
||||||
|
->where('split_link_id', $linkId)
|
||||||
|
->where('ticket_name', $ticketName)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动切换工单状态时,非手动号码按业务规则对齐开关
|
||||||
*/
|
*/
|
||||||
public function syncNumbersWithTicketStatus(Ticket $ticket): void
|
public function syncNumbersWithTicketStatus(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
@@ -61,7 +117,7 @@ class SplitTicketRuleService
|
|||||||
/**
|
/**
|
||||||
* 工单处于开启状态时,将云控在线的非手动号码设为开启
|
* 工单处于开启状态时,将云控在线的非手动号码设为开启
|
||||||
*
|
*
|
||||||
* @deprecated 请使用 applyNumberRules(会校验单号上限/下号比率)
|
* @deprecated 请使用 applyNumberRules
|
||||||
*/
|
*/
|
||||||
public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void
|
public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
@@ -69,7 +125,7 @@ class SplitTicketRuleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单号上限、下号比率、云控在线状态、工单开关综合决定号码状态
|
* 单号上限、下号比率、云控在线状态、工单开关综合决定号码 status;同步时清除或确认 ratio_deferred
|
||||||
*/
|
*/
|
||||||
public function applyNumberRules(Ticket $ticket): void
|
public function applyNumberRules(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
@@ -84,14 +140,13 @@ class SplitTicketRuleService
|
|||||||
foreach ($numbers as $number) {
|
foreach ($numbers as $number) {
|
||||||
$visitCount = (int) $number['visit_count'];
|
$visitCount = (int) $number['visit_count'];
|
||||||
$inboundCount = (int) $number['inbound_count'];
|
$inboundCount = (int) $number['inbound_count'];
|
||||||
$lastVisit = (int) ($number['last_sync_visit_count'] ?? 0);
|
|
||||||
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
||||||
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
||||||
|
|
||||||
if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) {
|
if ($visitCount <= 0) {
|
||||||
$streak += ($visitCount - $lastVisit);
|
|
||||||
} elseif ($inboundCount > $lastInbound) {
|
|
||||||
$streak = 0;
|
$streak = 0;
|
||||||
|
} else {
|
||||||
|
$streak = $this->ratioDeferredService->computeStreakForSync($number, $streak);
|
||||||
}
|
}
|
||||||
|
|
||||||
$update = [
|
$update = [
|
||||||
@@ -101,12 +156,26 @@ class SplitTicketRuleService
|
|||||||
'updatetime' => time(),
|
'updatetime' => time(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态
|
$clearDeferred = [
|
||||||
|
'ratio_deferred' => 0,
|
||||||
|
'ratio_deferred_at' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 手动管理:更新统计;无效降权(无落地页访问)一并清除
|
||||||
if ((int) $number['manual_manage'] === 1) {
|
if ((int) $number['manual_manage'] === 1) {
|
||||||
|
if ($this->ratioDeferredService->isInvalidDeferredRecord($number) || $visitCount <= 0) {
|
||||||
|
$update = array_merge($update, $clearDeferred);
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
$update['no_inbound_click_streak'] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Number::where('id', (int) $number['id'])->update($update);
|
Number::where('id', (int) $number['id'])->update($update);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步裁决 ratio_deferred:同步时清除访问侧降权,由 streak 决定是否关号
|
||||||
|
$update = array_merge($update, $clearDeferred);
|
||||||
|
|
||||||
$update['status'] = $this->resolveAutomatedStatus(
|
$update['status'] = $this->resolveAutomatedStatus(
|
||||||
$ticket,
|
$ticket,
|
||||||
(string) ($number['platform_status'] ?? 'unknown'),
|
(string) ($number['platform_status'] ?? 'unknown'),
|
||||||
@@ -118,6 +187,8 @@ class SplitTicketRuleService
|
|||||||
|
|
||||||
Number::where('id', (int) $number['id'])->update($update);
|
Number::where('id', (int) $number['id'])->update($update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(new SplitTicketActiveNumberCountService())->refreshForTicket($ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,10 +219,13 @@ class SplitTicketRuleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 完成量、时间窗口决定工单开关(定时与手动同步均适用)
|
* 完成量、时间窗口决定工单开关(定时与手动同步均适用;手动关闭工单除外)
|
||||||
*/
|
*/
|
||||||
public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void
|
public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void
|
||||||
{
|
{
|
||||||
|
if ($this->isManuallyClosed($ticket)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$status = $this->resolveTicketStatus($ticket, $completeCount);
|
$status = $this->resolveTicketStatus($ticket, $completeCount);
|
||||||
if ($status !== (string) ($ticket['status'] ?? 'hidden')) {
|
if ($status !== (string) ($ticket['status'] ?? 'hidden')) {
|
||||||
Ticket::where('id', (int) $ticket['id'])->update([
|
Ticket::where('id', (int) $ticket['id'])->update([
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
|
use think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单同步 CLI 后台投递
|
||||||
|
*
|
||||||
|
* Web / Cron 仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
|
||||||
|
* 避免长时间占用 PHP-FPM、Cron 全局锁或 Session 锁。
|
||||||
|
*
|
||||||
|
* 并行策略:
|
||||||
|
* - API 优先类型(Whatshub 等):独立并行投递,不占 Node Real Browser 槽位
|
||||||
|
* - 直连 PHP 类型:独立并行投递
|
||||||
|
* - Node 类型:不同 sessionKey(域名)并行;同一 sessionKey 内串行以命中 Browser 温池
|
||||||
|
*/
|
||||||
|
class SplitTicketSyncDispatchService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 投递手工同步任务到后台 CLI
|
||||||
|
*
|
||||||
|
* @param int[] $ticketIds 已通过权限校验的工单 ID
|
||||||
|
* @return array{queued:int[],skipped:int[],failed:int[]}
|
||||||
|
*/
|
||||||
|
public function dispatchManual(array $ticketIds): array
|
||||||
|
{
|
||||||
|
$lockService = new SplitTicketSyncLockService();
|
||||||
|
$metaList = $this->loadTicketMetaList($ticketIds);
|
||||||
|
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
|
||||||
|
$toDispatch = [];
|
||||||
|
$skipped = [];
|
||||||
|
$failed = [];
|
||||||
|
|
||||||
|
foreach ($metaList as $meta) {
|
||||||
|
$ticketId = (int) $meta['id'];
|
||||||
|
if ((int) ($meta['manual_manage'] ?? 0) === 1) {
|
||||||
|
$skipped[] = $ticketId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($lockService->isLocked($ticketId)) {
|
||||||
|
$skipped[] = $ticketId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$toDispatch[] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'manual');
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('web', 'manual sync dispatched', [
|
||||||
|
'queued' => $dispatchResult['queued'],
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'failed' => $dispatchResult['failed'],
|
||||||
|
'phpCli' => $this->resolvePhpBinary(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queued' => $dispatchResult['queued'],
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'failed' => $dispatchResult['failed'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron 自动同步:将挑选后的到期工单投递到后台 CLI(Cron 进程立即返回)
|
||||||
|
*
|
||||||
|
* @param list<array{id:int,ticket_type:string,ticket_url:string}|Ticket|\think\Model> $tickets
|
||||||
|
* @return array{
|
||||||
|
* queued:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* failed:list<array<string,mixed>>,
|
||||||
|
* stoppedByNodeBusy:bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function dispatchAuto(array $tickets): array
|
||||||
|
{
|
||||||
|
$lockService = new SplitTicketSyncLockService();
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
|
||||||
|
$toDispatch = [];
|
||||||
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
|
$skipped = [];
|
||||||
|
|
||||||
|
foreach ($tickets as $ticket) {
|
||||||
|
$meta = $this->normalizeTicketMeta($ticket);
|
||||||
|
if ($meta === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ticketId = (int) $meta['id'];
|
||||||
|
if ($lockService->isLocked($ticketId)) {
|
||||||
|
$skipped[] = $this->buildDispatchEntry(
|
||||||
|
$ticketId,
|
||||||
|
(string) $meta['ticket_type'],
|
||||||
|
(string) $meta['ticket_url'],
|
||||||
|
'工单正在同步中'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$toDispatch[] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'auto');
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('cron', 'auto sync dispatched', [
|
||||||
|
'queuedCount' => count($dispatchResult['queued']),
|
||||||
|
'skippedCount' => count($skipped) + count($dispatchResult['skipped']),
|
||||||
|
'failedCount' => count($dispatchResult['failed']),
|
||||||
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
||||||
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queued' => $dispatchResult['queued'],
|
||||||
|
'skipped' => array_merge($skipped, $dispatchResult['skipped']),
|
||||||
|
'failed' => $dispatchResult['failed'],
|
||||||
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询仍在同步中的工单 ID(依据 runtime 文件锁)
|
||||||
|
*
|
||||||
|
* @param int[] $ticketIds
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
public function filterSyncingIds(array $ticketIds): array
|
||||||
|
{
|
||||||
|
$lockService = new SplitTicketSyncLockService();
|
||||||
|
$syncing = [];
|
||||||
|
foreach ($ticketIds as $rawId) {
|
||||||
|
$ticketId = (int) $rawId;
|
||||||
|
if ($ticketId > 0 && $lockService->isLocked($ticketId)) {
|
||||||
|
$syncing[] = $ticketId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $syncing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{id:int,ticket_type:string,ticket_url:string}> $metaList
|
||||||
|
* @return array{
|
||||||
|
* queued:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* failed:list<array<string,mixed>>,
|
||||||
|
* stoppedByNodeBusy:bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function dispatchTicketMetaList(array $metaList, string $source): array
|
||||||
|
{
|
||||||
|
$php = $this->resolvePhpBinary();
|
||||||
|
$think = $this->resolveThinkScript();
|
||||||
|
$root = $this->resolveRootPath();
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $queued */
|
||||||
|
$queued = [];
|
||||||
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
|
$skipped = [];
|
||||||
|
/** @var list<array<string,mixed>> $failed */
|
||||||
|
$failed = [];
|
||||||
|
$stoppedByNodeBusy = false;
|
||||||
|
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $apiFirstList */
|
||||||
|
$apiFirstList = [];
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $directList */
|
||||||
|
$directList = [];
|
||||||
|
/** @var array<string, list<array{id:int,ticket_type:string,ticket_url:string}>> $nodeGroups */
|
||||||
|
$nodeGroups = [];
|
||||||
|
|
||||||
|
foreach ($metaList as $meta) {
|
||||||
|
$ticketType = (string) $meta['ticket_type'];
|
||||||
|
if (SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) {
|
||||||
|
$apiFirstList[] = $meta;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!SplitScrmSpiderFactory::requiresNode($ticketType)) {
|
||||||
|
$directList[] = $meta;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sessionKey = AntiBotConfigBuilder::resolveSessionKey(
|
||||||
|
$ticketType,
|
||||||
|
(string) $meta['ticket_url']
|
||||||
|
);
|
||||||
|
$nodeGroups[$sessionKey][] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($apiFirstList as $meta) {
|
||||||
|
$this->spawnSingleMeta($php, $think, $root, $meta, $source, $queued, $failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($directList as $meta) {
|
||||||
|
$this->spawnSingleMeta($php, $think, $root, $meta, $source, $queued, $failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($nodeGroups as $sessionKey => $group) {
|
||||||
|
if ($group === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $nodeReady */
|
||||||
|
$nodeReady = [];
|
||||||
|
foreach ($group as $meta) {
|
||||||
|
$ticketType = (string) $meta['ticket_type'];
|
||||||
|
if (!SplitNodeHealthService::canAcceptWorkForTicketType($ticketType)) {
|
||||||
|
$stoppedByNodeBusy = true;
|
||||||
|
$skipped[] = $this->buildDispatchEntry(
|
||||||
|
(int) $meta['id'],
|
||||||
|
$ticketType,
|
||||||
|
(string) $meta['ticket_url'],
|
||||||
|
'Node 队列繁忙,本轮未投递'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$nodeReady[] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($nodeReady === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($nodeReady) === 1) {
|
||||||
|
$this->spawnSingleMeta($php, $think, $root, $nodeReady[0], $source, $queued, $failed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_map(static function (array $row): int {
|
||||||
|
return (int) $row['id'];
|
||||||
|
}, $nodeReady);
|
||||||
|
|
||||||
|
if ($this->spawnCliSequential($php, $think, $root, $ids, $source)) {
|
||||||
|
foreach ($nodeReady as $meta) {
|
||||||
|
$queued[] = $this->buildQueuedEntry($meta, $sessionKey, 'node-group-serial');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($nodeReady as $meta) {
|
||||||
|
$failed[] = $this->buildDispatchEntry(
|
||||||
|
(int) $meta['id'],
|
||||||
|
(string) $meta['ticket_type'],
|
||||||
|
(string) $meta['ticket_url'],
|
||||||
|
'CLI 投递失败'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queued' => $queued,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'failed' => $failed,
|
||||||
|
'stoppedByNodeBusy' => $stoppedByNodeBusy,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $queued
|
||||||
|
* @param list<array<string,mixed>> $failed
|
||||||
|
* @param array{id:int,ticket_type:string,ticket_url:string} $meta
|
||||||
|
*/
|
||||||
|
private function spawnSingleMeta(
|
||||||
|
string $php,
|
||||||
|
string $think,
|
||||||
|
string $root,
|
||||||
|
array $meta,
|
||||||
|
string $source,
|
||||||
|
array &$queued,
|
||||||
|
array &$failed
|
||||||
|
): void {
|
||||||
|
$ticketId = (int) $meta['id'];
|
||||||
|
if ($this->spawnCli($php, $think, $root, $ticketId, $source)) {
|
||||||
|
$queued[] = $this->buildQueuedEntry($meta, '', 'parallel');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed[] = $this->buildDispatchEntry(
|
||||||
|
$ticketId,
|
||||||
|
(string) $meta['ticket_type'],
|
||||||
|
(string) $meta['ticket_url'],
|
||||||
|
'CLI 投递失败'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id:int,ticket_type:string,ticket_url:string} $meta
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function buildQueuedEntry(array $meta, string $sessionKey, string $mode): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticketId' => (int) $meta['id'],
|
||||||
|
'ticketType' => (string) $meta['ticket_type'],
|
||||||
|
'ticketUrl' => (string) $meta['ticket_url'],
|
||||||
|
'sessionKey' => $sessionKey,
|
||||||
|
'mode' => $mode,
|
||||||
|
'dispatched' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}
|
||||||
|
*/
|
||||||
|
private function buildDispatchEntry(int $ticketId, string $ticketType, string $ticketUrl, string $reason): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'ticketType' => $ticketType,
|
||||||
|
'ticketUrl' => $ticketUrl,
|
||||||
|
'reason' => $reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $ticketIds
|
||||||
|
* @return list<array{id:int,ticket_type:string,ticket_url:string}>
|
||||||
|
*/
|
||||||
|
private function loadTicketMetaList(array $ticketIds): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
foreach ($ticketIds as $rawId) {
|
||||||
|
$ticketId = (int) $rawId;
|
||||||
|
if ($ticketId > 0) {
|
||||||
|
$ids[] = $ticketId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($ids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = Db::name('split_ticket')
|
||||||
|
->where('id', 'in', $ids)
|
||||||
|
->field('id,ticket_type,ticket_url,manual_manage')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$list[] = [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'ticket_type' => (string) ($row['ticket_type'] ?? ''),
|
||||||
|
'ticket_url' => (string) ($row['ticket_url'] ?? ''),
|
||||||
|
'manual_manage' => (int) ($row['manual_manage'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed>|object $ticket
|
||||||
|
* @return array{id:int,ticket_type:string,ticket_url:string}|null
|
||||||
|
*/
|
||||||
|
private function normalizeTicketMeta($ticket): ?array
|
||||||
|
{
|
||||||
|
if (is_object($ticket) && method_exists($ticket, 'toArray')) {
|
||||||
|
$ticket = $ticket->toArray();
|
||||||
|
}
|
||||||
|
if (!is_array($ticket)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$ticketId = (int) ($ticket['id'] ?? 0);
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $ticketId,
|
||||||
|
'ticket_type' => (string) ($ticket['ticket_type'] ?? ''),
|
||||||
|
'ticket_url' => (string) ($ticket['ticket_url'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台启动 split:sync-tickets CLI(单工单,独立进程)
|
||||||
|
*/
|
||||||
|
private function spawnCli(string $php, string $think, string $root, int $ticketId, string $source): bool
|
||||||
|
{
|
||||||
|
$logDir = $this->resolveLogDir();
|
||||||
|
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
||||||
|
|
||||||
|
$syncMode = $this->normalizeSyncModeForCli($source);
|
||||||
|
$inner = sprintf(
|
||||||
|
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
|
||||||
|
escapeshellarg($php),
|
||||||
|
escapeshellarg($think),
|
||||||
|
$ticketId,
|
||||||
|
$syncMode,
|
||||||
|
escapeshellarg($logFile)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->runBackgroundCommand($root, $inner, $ticketId, $source . '-parallel');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一 sessionKey 内串行执行多个 CLI(单 nohup 子 shell)
|
||||||
|
*
|
||||||
|
* @param int[] $ticketIds
|
||||||
|
*/
|
||||||
|
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds, string $source): bool
|
||||||
|
{
|
||||||
|
if ($ticketIds === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logDir = $this->resolveLogDir();
|
||||||
|
$parts = [];
|
||||||
|
foreach ($ticketIds as $ticketId) {
|
||||||
|
$ticketId = (int) $ticketId;
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
||||||
|
$syncMode = $this->normalizeSyncModeForCli($source);
|
||||||
|
$parts[] = sprintf(
|
||||||
|
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
|
||||||
|
escapeshellarg($php),
|
||||||
|
escapeshellarg($think),
|
||||||
|
$ticketId,
|
||||||
|
$syncMode,
|
||||||
|
escapeshellarg($logFile)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($parts === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inner = implode('; ', $parts);
|
||||||
|
|
||||||
|
return $this->runBackgroundCommand($root, $inner, $ticketIds, $source . '-node-serial');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int|int[] $ticketRef 日志用 ticketId 或 ID 列表
|
||||||
|
*/
|
||||||
|
private function runBackgroundCommand(string $root, string $inner, $ticketRef, string $mode): bool
|
||||||
|
{
|
||||||
|
if ($this->isWindows()) {
|
||||||
|
$command = sprintf('start /B cmd /C %s', $inner);
|
||||||
|
} else {
|
||||||
|
$command = sprintf('cd %s && nohup bash -c %s &', escapeshellarg($root), escapeshellarg($inner));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->canUseExec()) {
|
||||||
|
@exec($command, $output, $exitCode);
|
||||||
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn exec', [
|
||||||
|
'ticketRef' => $ticketRef,
|
||||||
|
'mode' => $mode,
|
||||||
|
'command' => $command,
|
||||||
|
'exitCode' => $exitCode,
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->canUseShellExec()) {
|
||||||
|
@shell_exec($command);
|
||||||
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn shell_exec', [
|
||||||
|
'ticketRef' => $ticketRef,
|
||||||
|
'mode' => $mode,
|
||||||
|
'command' => $command,
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn failed: exec disabled', [
|
||||||
|
'ticketRef' => $ticketRef,
|
||||||
|
'mode' => $mode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePhpBinary(): string
|
||||||
|
{
|
||||||
|
if (method_exists(SplitSyncConfigService::class, 'getCliPhpBinary')) {
|
||||||
|
return SplitSyncConfigService::getCliPhpBinary();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolvePhpBinaryFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当 SplitSyncConfigService 未部署 getCliPhpBinary 时的兜底解析
|
||||||
|
*/
|
||||||
|
private function resolvePhpBinaryFallback(): string
|
||||||
|
{
|
||||||
|
if (defined('PHP_BINDIR') && PHP_BINDIR !== '') {
|
||||||
|
$candidate = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php';
|
||||||
|
if ($this->isUsableCliPhp($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['/usr/bin/php', '/usr/local/bin/php'] as $candidate) {
|
||||||
|
if ($this->isUsableCliPhp($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'php';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isUsableCliPhp(string $path): bool
|
||||||
|
{
|
||||||
|
if (stripos($path, 'fpm') !== false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($path === 'php') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return is_file($path) && is_executable($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveThinkScript(): string
|
||||||
|
{
|
||||||
|
$root = $this->resolveRootPath();
|
||||||
|
return rtrim($root, '/\\') . DIRECTORY_SEPARATOR . 'think';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRootPath(): string
|
||||||
|
{
|
||||||
|
if (defined('ROOT_PATH') && ROOT_PATH !== '') {
|
||||||
|
return rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(dirname(__DIR__, 3), '/\\') . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLogDir(): string
|
||||||
|
{
|
||||||
|
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : ($this->resolveRootPath() . 'runtime' . DIRECTORY_SEPARATOR);
|
||||||
|
$dir = rtrim($runtime, '/\\') . DIRECTORY_SEPARATOR . 'split_ticket_sync' . DIRECTORY_SEPARATOR;
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isWindows(): bool
|
||||||
|
{
|
||||||
|
return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function disabledFunctions(): array
|
||||||
|
{
|
||||||
|
$raw = (string) ini_get('disable_functions');
|
||||||
|
if ($raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter(array_map('trim', explode(',', $raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canUseExec(): bool
|
||||||
|
{
|
||||||
|
return function_exists('exec') && !in_array('exec', $this->disabledFunctions(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canUseShellExec(): bool
|
||||||
|
{
|
||||||
|
return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投递 CLI 时携带 --mode,供 sync_success_mode 区分自动/手动
|
||||||
|
*/
|
||||||
|
private function normalizeSyncModeForCli(string $source): string
|
||||||
|
{
|
||||||
|
return in_array($source, ['auto', 'manual'], true) ? $source : 'manual';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单同步失败分阶段重试状态机
|
||||||
|
*
|
||||||
|
* 达连续失败阈值后按配置时间点(相对锚点的绝对分钟偏移)自动重试;
|
||||||
|
* 任一次成功则清零恢复常态,全部失败则永久终止自动同步。
|
||||||
|
*/
|
||||||
|
class SplitTicketSyncFailRetryService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 是否已永久终止自动同步(重试时间点已全部用尽)
|
||||||
|
*
|
||||||
|
* @param Ticket|array<string, mixed> $ticket
|
||||||
|
*/
|
||||||
|
public function isPermanentlyStopped($ticket): bool
|
||||||
|
{
|
||||||
|
return (int) ($this->readField($ticket, 'sync_auto_sync_stopped') ?? 0) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否处于失败重试等待/重试周期内(未永久终止)
|
||||||
|
*
|
||||||
|
* @param Ticket|array<string, mixed> $ticket
|
||||||
|
*/
|
||||||
|
public function isInRetryMode($ticket): bool
|
||||||
|
{
|
||||||
|
if ($this->isPermanentlyStopped($ticket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首次达失败阈值时进入重试态(工单保持 normal,不关单)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> 需合并进 save 的字段;无变更时返回空数组
|
||||||
|
*/
|
||||||
|
public function enterRetryIfNeeded(Ticket $ticket, int $failCount, int $threshold): array
|
||||||
|
{
|
||||||
|
if ($failCount < $threshold || !SplitSyncConfigService::hasFailRetrySchedule()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if ($this->isInRetryMode($ticket)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sync_fail_pause_at' => time(),
|
||||||
|
'sync_fail_retry_index' => 0,
|
||||||
|
'sync_auto_sync_stopped' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计划内重试失败后递进索引;用尽全部时间点后标记永久终止
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function onRetryFailure(Ticket $ticket): array
|
||||||
|
{
|
||||||
|
$schedule = SplitSyncConfigService::getFailRetryMinutes();
|
||||||
|
$index = (int) ($ticket['sync_fail_retry_index'] ?? 0) + 1;
|
||||||
|
$update = ['sync_fail_retry_index' => $index];
|
||||||
|
|
||||||
|
if ($index >= count($schedule)) {
|
||||||
|
$update['sync_auto_sync_stopped'] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $update;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步成功时清零失败计数与重试相关字段
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function clearOnSuccessFields(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sync_fail_count' => 0,
|
||||||
|
'sync_fail_pause_at' => null,
|
||||||
|
'sync_fail_retry_index' => 0,
|
||||||
|
'sync_auto_sync_stopped' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron 自动重试是否已到点;未到点返回 skip 原因,到点返回 null(允许执行)
|
||||||
|
*
|
||||||
|
* @param Ticket|array<string, mixed> $ticket
|
||||||
|
*/
|
||||||
|
public function shouldAllowAutoRetry($ticket, ?int $now = null): ?string
|
||||||
|
{
|
||||||
|
if (!$this->isInRetryMode($ticket)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($this->isPermanentlyStopped($ticket)) {
|
||||||
|
return '自动同步已终止(重试已用尽)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextAt = $this->getNextRetryAt($ticket);
|
||||||
|
if ($nextAt === null) {
|
||||||
|
return '自动同步已终止(重试已用尽)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = $now ?? time();
|
||||||
|
if ($now < $nextAt) {
|
||||||
|
$attempt = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0) + 1;
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'失败重试等待中,将于 %s 进行第 %d 次重试',
|
||||||
|
date('Y-m-d H:i:s', $nextAt),
|
||||||
|
$attempt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下一次计划重试的 Unix 时间戳;无待重试时返回 null
|
||||||
|
*
|
||||||
|
* @param Ticket|array<string, mixed> $ticket
|
||||||
|
*/
|
||||||
|
public function getNextRetryAt($ticket): ?int
|
||||||
|
{
|
||||||
|
if (!$this->isInRetryMode($ticket) || $this->isPermanentlyStopped($ticket)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schedule = SplitSyncConfigService::getFailRetryMinutes();
|
||||||
|
if ($schedule === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$index = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0);
|
||||||
|
if ($index >= count($schedule)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pauseAt = (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0);
|
||||||
|
if ($pauseAt <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pauseAt + $schedule[$index] * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Ticket|array<string, mixed> $ticket
|
||||||
|
*/
|
||||||
|
private function readField($ticket, string $field)
|
||||||
|
{
|
||||||
|
if ($ticket instanceof Ticket) {
|
||||||
|
return $ticket->getData($field) ?? ($ticket[$field] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ticket[$field] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,20 @@ class SplitTicketSyncLockService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工单是否处于同步中(锁文件存在且未过期)
|
||||||
|
*/
|
||||||
|
public function isLocked(int $ticketId): bool
|
||||||
|
{
|
||||||
|
$path = $this->lockPath($ticketId);
|
||||||
|
if ($this->isStaleLock($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_file($path);
|
||||||
|
}
|
||||||
|
|
||||||
private function lockPath(int $ticketId): string
|
private function lockPath(int $ticketId): string
|
||||||
{
|
{
|
||||||
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ namespace app\common\service;
|
|||||||
use think\Config;
|
use think\Config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工单云控同步调试日志(仅 app_debug=true 时写入 runtime/log/split_sync.log)
|
* 工单云控同步日志
|
||||||
|
* - logCron:定时任务汇总,app_debug 关闭时也写入 runtime/log/split_sync.log
|
||||||
|
* - log:详细调试日志,仅 app_debug=true 时写入
|
||||||
*/
|
*/
|
||||||
class SplitTicketSyncLogger
|
class SplitTicketSyncLogger
|
||||||
{
|
{
|
||||||
@@ -36,6 +38,18 @@ class SplitTicketSyncLogger
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 定时任务汇总日志(始终写入)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public static function logCron(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
self::writeLine('cron', $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详细调试日志(仅 app_debug=true)
|
||||||
|
*
|
||||||
* @param array<string, mixed> $context
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public static function log(string $stage, string $message, array $context = []): void
|
public static function log(string $stage, string $message, array $context = []): void
|
||||||
@@ -43,7 +57,14 @@ class SplitTicketSyncLogger
|
|||||||
if (!self::isEnabled()) {
|
if (!self::isEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
self::writeLine($stage, $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private static function writeLine(string $stage, string $message, array $context = []): void
|
||||||
|
{
|
||||||
$context = self::sanitize($context);
|
$context = self::sanitize($context);
|
||||||
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
||||||
$line = sprintf(
|
$line = sprintf(
|
||||||
@@ -83,8 +104,8 @@ class SplitTicketSyncLogger
|
|||||||
$out[$key] = self::sanitize($value);
|
$out[$key] = self::sanitize($value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (is_string($value) && mb_strlen($value) > 800) {
|
if (is_string($value) && mb_strlen($value) > 2000) {
|
||||||
$out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)';
|
$out[$key] = mb_substr($value, 0, 2000, 'UTF-8') . '...(truncated)';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$out[$key] = $value;
|
$out[$key] = $value;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace app\common\service;
|
namespace app\common\service;
|
||||||
|
|
||||||
use app\admin\model\split\Ticket;
|
use app\admin\model\split\Ticket;
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use app\common\library\scrm\UnifiedScrmData;
|
use app\common\library\scrm\UnifiedScrmData;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Exception;
|
use think\Exception;
|
||||||
@@ -20,20 +21,27 @@ class SplitTicketSyncService
|
|||||||
|
|
||||||
private SplitTicketSyncLockService $lockService;
|
private SplitTicketSyncLockService $lockService;
|
||||||
|
|
||||||
|
private SplitTicketSyncDispatchService $dispatchService;
|
||||||
|
|
||||||
|
private SplitTicketSyncFailRetryService $failRetryService;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->numberSync = new SplitTicketNumberSyncService();
|
$this->numberSync = new SplitTicketNumberSyncService();
|
||||||
$this->ruleService = new SplitTicketRuleService();
|
$this->ruleService = new SplitTicketRuleService();
|
||||||
$this->lockService = new SplitTicketSyncLockService();
|
$this->lockService = new SplitTicketSyncLockService();
|
||||||
|
$this->dispatchService = new SplitTicketSyncDispatchService();
|
||||||
|
$this->failRetryService = new SplitTicketSyncFailRetryService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步单条工单
|
* 同步单条工单
|
||||||
*
|
*
|
||||||
* @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入)
|
* @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入)
|
||||||
|
* @param string $syncMode auto=定时自动 manual=手动触发
|
||||||
* @return array{success:bool,message:string,skipped?:bool}
|
* @return array{success:bool,message:string,skipped?:bool}
|
||||||
*/
|
*/
|
||||||
public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false): array
|
public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false, string $syncMode = 'manual'): array
|
||||||
{
|
{
|
||||||
$ticket = Ticket::get($ticketId);
|
$ticket = Ticket::get($ticketId);
|
||||||
if (!$ticket) {
|
if (!$ticket) {
|
||||||
@@ -44,13 +52,21 @@ class SplitTicketSyncService
|
|||||||
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
|
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
|
||||||
SplitTicketSyncLogger::log('sync', 'syncOne start', [
|
SplitTicketSyncLogger::log('sync', 'syncOne start', [
|
||||||
'force' => $force,
|
'force' => $force,
|
||||||
|
'syncMode' => $syncMode,
|
||||||
'status' => (string) $ticket['status'],
|
'status' => (string) $ticket['status'],
|
||||||
|
'manualManage' => (int) ($ticket['manual_manage'] ?? 0),
|
||||||
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
|
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
|
||||||
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
|
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
|
||||||
'pageUrl' => (string) $ticket['ticket_url'],
|
'pageUrl' => (string) $ticket['ticket_url'],
|
||||||
'nodeHost' => SplitSyncConfigService::getNodeHost(),
|
'nodeHost' => SplitSyncConfigService::getNodeHost(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($this->ruleService->isManuallyClosed($ticket)) {
|
||||||
|
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => '工单已手动关闭']);
|
||||||
|
SplitTicketSyncLogger::clearTicketContext();
|
||||||
|
return ['success' => false, 'message' => '工单已手动关闭,暂停同步', 'skipped' => true];
|
||||||
|
}
|
||||||
|
|
||||||
if (!$force && !$dueValidated) {
|
if (!$force && !$dueValidated) {
|
||||||
$skip = $this->shouldSkip($ticket);
|
$skip = $this->shouldSkip($ticket);
|
||||||
if ($skip !== null) {
|
if ($skip !== null) {
|
||||||
@@ -67,7 +83,7 @@ class SplitTicketSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->doSync($ticket);
|
$result = $this->doSync($ticket, $syncMode);
|
||||||
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
|
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
|
||||||
return $result;
|
return $result;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,72 +93,411 @@ class SplitTicketSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描到期工单并同步(限流 + Node 队列感知)
|
* 扫描到期工单并同步(直连 PHP 与 Node 双通道)
|
||||||
*/
|
*/
|
||||||
public function syncDueTickets(): int
|
public function syncDueTickets(): int
|
||||||
{
|
{
|
||||||
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
|
|
||||||
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
|
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
|
||||||
if ($autoSyncTypes === []) {
|
if ($autoSyncTypes === []) {
|
||||||
SplitTicketSyncLogger::log('cron', 'scan skip', ['reason' => 'no auto-sync ticket types']);
|
SplitTicketSyncLogger::logCron('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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
|
$directTypes = array_values(array_intersect(
|
||||||
if ($failThreshold > 0) {
|
$autoSyncTypes,
|
||||||
$query->where('sync_fail_count', '<', $failThreshold);
|
SplitScrmSpiderFactory::listDirectSyncTypes()
|
||||||
}
|
));
|
||||||
// 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单
|
$nodeTypes = array_values(array_intersect(
|
||||||
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
|
$autoSyncTypes,
|
||||||
->limit($maxPerRun * 5)
|
SplitScrmSpiderFactory::listNodeSyncTypes()
|
||||||
->select();
|
));
|
||||||
|
|
||||||
SplitTicketSyncLogger::log('cron', 'scan start', [
|
SplitTicketSyncLogger::logCron('scan start', [
|
||||||
'candidateCount' => count($list),
|
|
||||||
'maxPerRun' => $maxPerRun,
|
|
||||||
'autoSyncTypes' => $autoSyncTypes,
|
'autoSyncTypes' => $autoSyncTypes,
|
||||||
|
'directTypes' => $directTypes,
|
||||||
|
'nodeTypes' => $nodeTypes,
|
||||||
|
'failThreshold' => $failThreshold,
|
||||||
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$count = 0;
|
$directResult = $this->runDirectCronChannel($directTypes, $failThreshold);
|
||||||
|
$nodeResult = $this->runNodeCronChannel($nodeTypes, $failThreshold);
|
||||||
|
|
||||||
|
$processedCount = $directResult['processedCount'] + $nodeResult['processedCount'];
|
||||||
|
$processed = array_merge($directResult['processed'], $nodeResult['processed']);
|
||||||
|
$skipped = array_merge($directResult['skipped'], $nodeResult['skipped']);
|
||||||
|
$candidateIds = array_merge($directResult['candidateIds'], $nodeResult['candidateIds']);
|
||||||
|
$openNotSynced = $this->buildOpenNotSyncedAudit(
|
||||||
|
$autoSyncTypes,
|
||||||
|
$failThreshold,
|
||||||
|
SplitSyncConfigService::getMaxTicketsPerCronRun(),
|
||||||
|
$candidateIds,
|
||||||
|
$processed,
|
||||||
|
$skipped,
|
||||||
|
$nodeResult['stoppedByNodeBusy']
|
||||||
|
);
|
||||||
|
|
||||||
|
$successCount = count(array_filter($processed, static function (array $row): bool {
|
||||||
|
return !empty($row['dispatched']);
|
||||||
|
}));
|
||||||
|
$failedCount = count($processed) - $successCount;
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::logCron('scan end', [
|
||||||
|
'processedCount' => $processedCount,
|
||||||
|
'successCount' => $successCount,
|
||||||
|
'failedCount' => $failedCount,
|
||||||
|
'skippedCount' => count($skipped),
|
||||||
|
'directChannel' => $directResult['summary'],
|
||||||
|
'nodeChannel' => $nodeResult['summary'],
|
||||||
|
'processed' => $processed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'openNotSynced' => $openNotSynced,
|
||||||
|
]);
|
||||||
|
SplitTicketSyncLogger::log('cron', 'scan end', [
|
||||||
|
'processedCount' => $processedCount,
|
||||||
|
'successCount' => $successCount,
|
||||||
|
'failedCount' => $failedCount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $processedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直连 PHP 通道:挑选到期工单并投递后台 CLI,不占 Node 名额
|
||||||
|
*
|
||||||
|
* @param list<string> $directTypes
|
||||||
|
* @return array{
|
||||||
|
* processedCount:int,
|
||||||
|
* processed:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* candidateIds:list<int>,
|
||||||
|
* summary:array<string,mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function runDirectCronChannel(array $directTypes, int $failThreshold): array
|
||||||
|
{
|
||||||
|
if ($directTypes === []) {
|
||||||
|
return $this->emptyCronChannelResult('direct');
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $this->loadOpenTicketsForTypes($directTypes, $failThreshold, 0);
|
||||||
|
$candidateIds = $this->extractTicketIds($list);
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $dueList */
|
||||||
|
$dueList = [];
|
||||||
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
|
$skipped = [];
|
||||||
|
|
||||||
foreach ($list as $ticket) {
|
foreach ($list as $ticket) {
|
||||||
if ($count >= $maxPerRun) {
|
$ticketId = (int) $ticket['id'];
|
||||||
break;
|
$ticketType = (string) $ticket['ticket_type'];
|
||||||
|
$ticketUrl = (string) $ticket['ticket_url'];
|
||||||
|
|
||||||
|
$skip = $this->shouldSkip($ticket);
|
||||||
|
if ($skip !== null) {
|
||||||
|
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skip);
|
||||||
|
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip, 'direct');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$dueList[] = $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::logCron('direct channel start', [
|
||||||
|
'types' => $directTypes,
|
||||||
|
'candidateCount' => count($list),
|
||||||
|
'dueCount' => count($dueList),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatchResult = $this->dispatchService->dispatchAuto($dueList);
|
||||||
|
$skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped']));
|
||||||
|
$failed = $this->mapDispatchFailedToCron($dispatchResult['failed']);
|
||||||
|
|
||||||
|
foreach ($failed as $row) {
|
||||||
|
$skipped[] = $row;
|
||||||
|
$this->logCronCandidateSkipped(
|
||||||
|
(int) $row['ticketId'],
|
||||||
|
(string) $row['ticketType'],
|
||||||
|
(string) $row['ticketUrl'],
|
||||||
|
(string) $row['reason'],
|
||||||
|
'direct'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $processed */
|
||||||
|
$processed = [];
|
||||||
|
foreach ($dispatchResult['queued'] as $row) {
|
||||||
|
$processed[] = [
|
||||||
|
'ticketId' => (int) ($row['ticketId'] ?? 0),
|
||||||
|
'ticketType' => (string) ($row['ticketType'] ?? ''),
|
||||||
|
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
|
||||||
|
'channel' => 'direct',
|
||||||
|
'dispatched' => true,
|
||||||
|
'message' => '已投递后台同步',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'processedCount' => count($processed),
|
||||||
|
'processed' => $processed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'candidateIds' => $candidateIds,
|
||||||
|
'summary' => [
|
||||||
|
'channel' => 'direct',
|
||||||
|
'processedCount' => count($processed),
|
||||||
|
'skippedCount' => count($skipped),
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
|
],
|
||||||
|
'stoppedByNodeBusy' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 通道:限流 + 类型公平轮转 + 异步 CLI 投递
|
||||||
|
*
|
||||||
|
* @param list<string> $nodeTypes
|
||||||
|
* @return array{
|
||||||
|
* processedCount:int,
|
||||||
|
* processed:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* candidateIds:list<int>,
|
||||||
|
* summary:array<string,mixed>,
|
||||||
|
* stoppedByNodeBusy:bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function runNodeCronChannel(array $nodeTypes, int $failThreshold): array
|
||||||
|
{
|
||||||
|
if ($nodeTypes === []) {
|
||||||
|
return $this->emptyCronChannelResult('node');
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
|
||||||
|
$poolMultiplier = SplitSyncConfigService::getNodeCandidatePoolMultiplier();
|
||||||
|
$candidateLimit = $maxPerRun * $poolMultiplier;
|
||||||
|
|
||||||
|
$pool = $this->loadOpenTicketsForTypes($nodeTypes, $failThreshold, $candidateLimit);
|
||||||
|
$candidateIds = $this->extractTicketIds($pool);
|
||||||
|
$duePool = [];
|
||||||
|
foreach ($pool as $ticket) {
|
||||||
|
if ($this->shouldSkip($ticket) === null) {
|
||||||
|
$duePool[] = $ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$picked = $this->pickFairNodeTickets($duePool, $maxPerRun);
|
||||||
|
$picked = $this->sortTicketsBySessionKey($picked);
|
||||||
|
$pickedIdMap = array_fill_keys($this->extractTicketIds($picked), true);
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::logCron('node channel start', [
|
||||||
|
'types' => $nodeTypes,
|
||||||
|
'maxPerRun' => $maxPerRun,
|
||||||
|
'poolCount' => count($pool),
|
||||||
|
'dueCount' => count($duePool),
|
||||||
|
'pickedCount' => count($picked),
|
||||||
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
|
$skipped = [];
|
||||||
|
foreach ($pool as $ticket) {
|
||||||
|
$ticketId = (int) $ticket['id'];
|
||||||
|
if (isset($pickedIdMap[$ticketId])) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
$skip = $this->shouldSkip($ticket);
|
$skip = $this->shouldSkip($ticket);
|
||||||
if ($skip !== null) {
|
if ($skip !== null) {
|
||||||
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
|
|
||||||
'ticketId' => (int) $ticket['id'],
|
|
||||||
'ticketType' => (string) $ticket['ticket_type'],
|
|
||||||
'reason' => $skip,
|
|
||||||
]);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!SplitNodeHealthService::canAcceptWork()) {
|
$skipped[] = $this->buildCronTicketEntry(
|
||||||
SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [
|
$ticketId,
|
||||||
'processed' => $count,
|
(string) $ticket['ticket_type'],
|
||||||
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
(string) $ticket['ticket_url'],
|
||||||
]);
|
sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit)
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
$result = $this->syncOne((int) $ticket['id'], false, true);
|
|
||||||
if (!empty($result['skipped'])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$count++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SplitTicketSyncLogger::log('cron', 'scan end', ['processedCount' => $count]);
|
$dispatchResult = $this->dispatchService->dispatchAuto($picked);
|
||||||
return $count;
|
$skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped']));
|
||||||
|
$failed = $this->mapDispatchFailedToCron($dispatchResult['failed']);
|
||||||
|
|
||||||
|
foreach ($failed as $row) {
|
||||||
|
$skipped[] = $row;
|
||||||
|
$this->logCronCandidateSkipped(
|
||||||
|
(int) $row['ticketId'],
|
||||||
|
(string) $row['ticketType'],
|
||||||
|
(string) $row['ticketUrl'],
|
||||||
|
(string) $row['reason'],
|
||||||
|
'node'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $processed */
|
||||||
|
$processed = [];
|
||||||
|
foreach ($dispatchResult['queued'] as $row) {
|
||||||
|
$processed[] = [
|
||||||
|
'ticketId' => (int) ($row['ticketId'] ?? 0),
|
||||||
|
'ticketType' => (string) ($row['ticketType'] ?? ''),
|
||||||
|
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
|
||||||
|
'sessionKey' => (string) ($row['sessionKey'] ?? ''),
|
||||||
|
'channel' => 'node',
|
||||||
|
'dispatched' => true,
|
||||||
|
'message' => '已投递后台同步',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'processedCount' => count($processed),
|
||||||
|
'processed' => $processed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'candidateIds' => $candidateIds,
|
||||||
|
'summary' => [
|
||||||
|
'channel' => 'node',
|
||||||
|
'processedCount' => count($processed),
|
||||||
|
'skippedCount' => count($skipped),
|
||||||
|
'maxPerRun' => $maxPerRun,
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
|
],
|
||||||
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $rows
|
||||||
|
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
|
||||||
|
*/
|
||||||
|
private function mapDispatchSkippedToCron(array $rows): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$result[] = $this->buildCronTicketEntry(
|
||||||
|
(int) ($row['ticketId'] ?? 0),
|
||||||
|
(string) ($row['ticketType'] ?? ''),
|
||||||
|
(string) ($row['ticketUrl'] ?? ''),
|
||||||
|
(string) ($row['reason'] ?? '已跳过')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $rows
|
||||||
|
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
|
||||||
|
*/
|
||||||
|
private function mapDispatchFailedToCron(array $rows): array
|
||||||
|
{
|
||||||
|
return $this->mapDispatchSkippedToCron($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $types
|
||||||
|
* @return list<Ticket>
|
||||||
|
*/
|
||||||
|
private function loadOpenTicketsForTypes(array $types, int $failThreshold, int $limit): array
|
||||||
|
{
|
||||||
|
if ($types === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = Ticket::where('status', 'normal')
|
||||||
|
->where('manual_manage', 0)
|
||||||
|
->whereIn('ticket_type', $types);
|
||||||
|
if ($failThreshold > 0) {
|
||||||
|
$query->where(function ($query) use ($failThreshold): void {
|
||||||
|
$query->where('sync_fail_count', '<', $failThreshold)
|
||||||
|
->whereOr(function ($query): void {
|
||||||
|
$query->where('sync_fail_pause_at', '>', 0)
|
||||||
|
->where('sync_auto_sync_stopped', 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$query->order('sync_time', 'asc')->order('id', 'asc');
|
||||||
|
if ($limit > 0) {
|
||||||
|
$query->limit($limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $query->select();
|
||||||
|
return $list instanceof \think\Collection ? $list->all() : (array) $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 通道:按工单类型公平轮转选取(每轮每类型优先取 1 条)
|
||||||
|
*
|
||||||
|
* @param list<Ticket> $dueTickets
|
||||||
|
* @return list<Ticket>
|
||||||
|
*/
|
||||||
|
private function pickFairNodeTickets(array $dueTickets, int $maxPerRun): array
|
||||||
|
{
|
||||||
|
if ($dueTickets === [] || $maxPerRun <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, list<Ticket>> $byType */
|
||||||
|
$byType = [];
|
||||||
|
foreach ($dueTickets as $ticket) {
|
||||||
|
$type = (string) $ticket['ticket_type'];
|
||||||
|
$byType[$type][] = $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeKeys = array_keys($byType);
|
||||||
|
sort($typeKeys, SORT_STRING);
|
||||||
|
|
||||||
|
/** @var list<Ticket> $picked */
|
||||||
|
$picked = [];
|
||||||
|
while (count($picked) < $maxPerRun) {
|
||||||
|
$added = false;
|
||||||
|
foreach ($typeKeys as $type) {
|
||||||
|
if (count($picked) >= $maxPerRun) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ($byType[$type] === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$picked[] = array_shift($byType[$type]);
|
||||||
|
$added = true;
|
||||||
|
}
|
||||||
|
if (!$added) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Ticket|array<string,mixed>> $list
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function extractTicketIds(array $list): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
foreach ($list as $row) {
|
||||||
|
$ids[] = (int) (is_array($row) ? ($row['id'] ?? 0) : ($row['id'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* processedCount:int,
|
||||||
|
* processed:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* candidateIds:list<int>,
|
||||||
|
* summary:array<string,mixed>,
|
||||||
|
* stoppedByNodeBusy:bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function emptyCronChannelResult(string $channel): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'processedCount' => 0,
|
||||||
|
'processed' => [],
|
||||||
|
'skipped' => [],
|
||||||
|
'candidateIds' => [],
|
||||||
|
'summary' => ['channel' => $channel, 'processedCount' => 0, 'skippedCount' => 0],
|
||||||
|
'stoppedByNodeBusy' => false,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,8 +519,17 @@ class SplitTicketSyncService
|
|||||||
/**
|
/**
|
||||||
* @return array{success:bool,message:string}
|
* @return array{success:bool,message:string}
|
||||||
*/
|
*/
|
||||||
private function doSync(Ticket $ticket): array
|
private function doSync(Ticket $ticket, string $syncMode): array
|
||||||
{
|
{
|
||||||
|
$freshTicket = Ticket::get((int) $ticket['id']);
|
||||||
|
if (!$freshTicket) {
|
||||||
|
return ['success' => false, 'message' => '工单不存在', 'skipped' => true];
|
||||||
|
}
|
||||||
|
if ($this->ruleService->isManuallyClosed($freshTicket)) {
|
||||||
|
return ['success' => false, 'message' => '工单已手动关闭,暂停同步', 'skipped' => true];
|
||||||
|
}
|
||||||
|
$ticket = $freshTicket;
|
||||||
|
|
||||||
$ticketType = (string) $ticket['ticket_type'];
|
$ticketType = (string) $ticket['ticket_type'];
|
||||||
$pageUrl = trim((string) $ticket['ticket_url']);
|
$pageUrl = trim((string) $ticket['ticket_url']);
|
||||||
if ($pageUrl === '') {
|
if ($pageUrl === '') {
|
||||||
@@ -211,9 +575,9 @@ class SplitTicketSyncService
|
|||||||
$freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket;
|
$freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket;
|
||||||
if ((string) $freshTicket['status'] === 'hidden') {
|
if ((string) $freshTicket['status'] === 'hidden') {
|
||||||
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
||||||
}
|
} else {
|
||||||
// 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线)
|
|
||||||
$this->ruleService->applyNumberRules($freshTicket);
|
$this->ruleService->applyNumberRules($freshTicket);
|
||||||
|
}
|
||||||
$ticket = $freshTicket;
|
$ticket = $freshTicket;
|
||||||
|
|
||||||
$inboundCount = $this->numberSync->sumInboundForTicket($ticket);
|
$inboundCount = $this->numberSync->sumInboundForTicket($ticket);
|
||||||
@@ -232,13 +596,13 @@ class SplitTicketSyncService
|
|||||||
'speed_snapshot_time' => $speed['snapshot_time'],
|
'speed_snapshot_time' => $speed['snapshot_time'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->applySyncResult($ticket, $payload, true, '');
|
$this->applySyncResult($ticket, $payload, true, '', $syncMode);
|
||||||
Db::commit();
|
Db::commit();
|
||||||
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
|
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
|
||||||
return ['success' => true, 'message' => '同步成功'];
|
return ['success' => true, 'message' => '同步成功'];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
$msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8');
|
$msg = $e->getMessage();
|
||||||
SplitTicketSyncLogger::log('sync', 'exception', [
|
SplitTicketSyncLogger::log('sync', 'exception', [
|
||||||
'type' => get_class($e),
|
'type' => get_class($e),
|
||||||
'message' => $msg,
|
'message' => $msg,
|
||||||
@@ -252,9 +616,33 @@ class SplitTicketSyncService
|
|||||||
|
|
||||||
private function shouldSkip(Ticket $ticket): ?string
|
private function shouldSkip(Ticket $ticket): ?string
|
||||||
{
|
{
|
||||||
|
if ($this->ruleService->isManuallyClosed($ticket)) {
|
||||||
|
return '工单已手动关闭,暂停同步';
|
||||||
|
}
|
||||||
if ((string) $ticket['status'] === 'hidden') {
|
if ((string) $ticket['status'] === 'hidden') {
|
||||||
return '工单已关闭';
|
return '工单已关闭';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->failRetryService->isPermanentlyStopped($ticket)) {
|
||||||
|
return '自动同步已终止(重试已用尽)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->failRetryService->isInRetryMode($ticket)) {
|
||||||
|
$retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket);
|
||||||
|
if ($retrySkip !== null) {
|
||||||
|
return $retrySkip;
|
||||||
|
}
|
||||||
|
if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) {
|
||||||
|
return '工单类型尚未实现';
|
||||||
|
}
|
||||||
|
$interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']);
|
||||||
|
if ($interval <= 0) {
|
||||||
|
return '该类型未配置自动同步周期';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
|
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
|
||||||
return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
|
return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
|
||||||
@@ -282,8 +670,9 @@ class SplitTicketSyncService
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $payload
|
* @param array<string, mixed> $payload
|
||||||
*/
|
*/
|
||||||
public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void
|
public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = '', string $syncMode = 'manual'): void
|
||||||
{
|
{
|
||||||
|
$now = time();
|
||||||
$data = [
|
$data = [
|
||||||
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
||||||
'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)),
|
'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)),
|
||||||
@@ -293,12 +682,17 @@ class SplitTicketSyncService
|
|||||||
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
|
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
|
||||||
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
||||||
'sync_status' => $success ? 'success' : 'error',
|
'sync_status' => $success ? 'success' : 'error',
|
||||||
'sync_time' => time(),
|
'sync_time' => $now,
|
||||||
'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'),
|
'sync_message' => $success ? '' : $message,
|
||||||
'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1),
|
'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1),
|
||||||
'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0),
|
'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0),
|
||||||
'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0),
|
'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0),
|
||||||
];
|
];
|
||||||
|
if ($success) {
|
||||||
|
$data['sync_success_time'] = $now;
|
||||||
|
$data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual';
|
||||||
|
$data = array_merge($data, $this->failRetryService->clearOnSuccessFields());
|
||||||
|
}
|
||||||
if (!$ticket->allowField(array_keys($data))->save($data)) {
|
if (!$ticket->allowField(array_keys($data))->save($data)) {
|
||||||
throw new Exception('工单同步结果保存失败');
|
throw new Exception('工单同步结果保存失败');
|
||||||
}
|
}
|
||||||
@@ -313,10 +707,24 @@ class SplitTicketSyncService
|
|||||||
$update = [
|
$update = [
|
||||||
'sync_status' => 'error',
|
'sync_status' => 'error',
|
||||||
'sync_time' => time(),
|
'sync_time' => time(),
|
||||||
'sync_message' => mb_substr($message, 0, 255, 'UTF-8'),
|
'sync_message' => $message,
|
||||||
'sync_fail_count' => $failCount,
|
'sync_fail_count' => $failCount,
|
||||||
];
|
];
|
||||||
// 新建工单首次同步失败:立即关闭;已同步过的工单仍按连续失败阈值关闭
|
|
||||||
|
if ($this->failRetryService->isInRetryMode($ticket)) {
|
||||||
|
$update = array_merge($update, $this->failRetryService->onRetryFailure($ticket));
|
||||||
|
$ticket->save($update);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failThreshold > 0 && $failCount >= $failThreshold && SplitSyncConfigService::hasFailRetrySchedule()) {
|
||||||
|
$update = array_merge($update, $this->failRetryService->enterRetryIfNeeded($ticket, $failCount, $failThreshold));
|
||||||
|
$ticket->save($update);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
|
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
|
||||||
$update['status'] = 'hidden';
|
$update['status'] = 'hidden';
|
||||||
}
|
}
|
||||||
@@ -365,4 +773,177 @@ class SplitTicketSyncService
|
|||||||
'snapshot_time' => $snapshotTime,
|
'snapshot_time' => $snapshotTime,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}
|
||||||
|
*/
|
||||||
|
private function buildCronTicketEntry(int $ticketId, string $ticketType, string $ticketUrl, string $reason): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'ticketType' => $ticketType,
|
||||||
|
'ticketUrl' => $ticketUrl,
|
||||||
|
'reason' => $reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logCronCandidateSkipped(int $ticketId, string $ticketType, string $ticketUrl, string $reason, string $channel = 'legacy'): void
|
||||||
|
{
|
||||||
|
$ctx = [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'ticketType' => $ticketType,
|
||||||
|
'ticketUrl' => $ticketUrl,
|
||||||
|
'reason' => $reason,
|
||||||
|
'channel' => $channel,
|
||||||
|
];
|
||||||
|
SplitTicketSyncLogger::logCron('candidate skipped', $ctx);
|
||||||
|
SplitTicketSyncLogger::log('cron', 'candidate skipped', $ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开启但未在本轮同步的工单及原因(供 cron 汇总)
|
||||||
|
*
|
||||||
|
* @param list<int> $candidateIds
|
||||||
|
* @param list<array<string, mixed>> $processed
|
||||||
|
* @param list<array<string, mixed>> $skipped
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function buildOpenNotSyncedAudit(
|
||||||
|
array $autoSyncTypes,
|
||||||
|
int $failThreshold,
|
||||||
|
int $maxPerRun,
|
||||||
|
array $candidateIds,
|
||||||
|
array $processed,
|
||||||
|
array $skipped,
|
||||||
|
bool $stoppedByNodeBusy
|
||||||
|
): array {
|
||||||
|
$handledIds = [];
|
||||||
|
foreach ($processed as $row) {
|
||||||
|
$handledIds[(int) ($row['ticketId'] ?? 0)] = true;
|
||||||
|
}
|
||||||
|
foreach ($skipped as $row) {
|
||||||
|
$handledIds[(int) ($row['ticketId'] ?? 0)] = true;
|
||||||
|
}
|
||||||
|
$candidateIdMap = array_fill_keys($candidateIds, true);
|
||||||
|
|
||||||
|
$supportedTypes = SplitScrmSpiderFactory::listSupportedTypes();
|
||||||
|
if ($supportedTypes === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$openList = Ticket::where('status', 'normal')
|
||||||
|
->whereIn('ticket_type', $supportedTypes)
|
||||||
|
->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status,sync_fail_pause_at,sync_fail_retry_index,sync_auto_sync_stopped,status,manual_manage')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($openList as $ticketRow) {
|
||||||
|
$ticket = $ticketRow instanceof Ticket ? $ticketRow->toArray() : (array) $ticketRow;
|
||||||
|
$ticketId = (int) ($ticket['id'] ?? 0);
|
||||||
|
if ($ticketId <= 0 || isset($handledIds[$ticketId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketType = (string) ($ticket['ticket_type'] ?? '');
|
||||||
|
$ticketUrl = (string) ($ticket['ticket_url'] ?? '');
|
||||||
|
$reason = $this->resolveOpenNotSyncedReason(
|
||||||
|
$ticket,
|
||||||
|
$autoSyncTypes,
|
||||||
|
$failThreshold,
|
||||||
|
$maxPerRun,
|
||||||
|
$candidateIdMap,
|
||||||
|
$stoppedByNodeBusy
|
||||||
|
);
|
||||||
|
if ($reason === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'ticketType' => $ticketType,
|
||||||
|
'ticketUrl' => $ticketUrl,
|
||||||
|
'reason' => $reason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $ticket
|
||||||
|
* @param array<string, bool> $candidateIdMap
|
||||||
|
*/
|
||||||
|
private function resolveOpenNotSyncedReason(
|
||||||
|
array $ticket,
|
||||||
|
array $autoSyncTypes,
|
||||||
|
int $failThreshold,
|
||||||
|
int $maxPerRun,
|
||||||
|
array $candidateIdMap,
|
||||||
|
bool $stoppedByNodeBusy
|
||||||
|
): ?string {
|
||||||
|
$ticketType = (string) ($ticket['ticket_type'] ?? '');
|
||||||
|
$failCount = (int) ($ticket['sync_fail_count'] ?? 0);
|
||||||
|
|
||||||
|
if ($this->failRetryService->isPermanentlyStopped($ticket)) {
|
||||||
|
return '自动同步已终止(重试已用尽)';
|
||||||
|
}
|
||||||
|
if ($this->failRetryService->isInRetryMode($ticket)) {
|
||||||
|
$retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket);
|
||||||
|
if ($retrySkip !== null) {
|
||||||
|
return $retrySkip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($failThreshold > 0 && $failCount >= $failThreshold && !SplitSyncConfigService::hasFailRetrySchedule()) {
|
||||||
|
return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold);
|
||||||
|
}
|
||||||
|
if (!in_array($ticketType, $autoSyncTypes, true)) {
|
||||||
|
return '该类型未配置自动同步周期';
|
||||||
|
}
|
||||||
|
if (!isset($candidateIdMap[(int) ($ticket['id'] ?? 0)])) {
|
||||||
|
if (SplitScrmSpiderFactory::requiresNode($ticketType)) {
|
||||||
|
$poolSize = SplitSyncConfigService::getMaxTicketsPerCronRun()
|
||||||
|
* SplitSyncConfigService::getNodeCandidatePoolMultiplier();
|
||||||
|
return sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $poolSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '直连通道:未到同步周期或本轮已处理';
|
||||||
|
}
|
||||||
|
if ($stoppedByNodeBusy) {
|
||||||
|
return 'Node 队列繁忙,本轮未执行';
|
||||||
|
}
|
||||||
|
|
||||||
|
$skip = $this->shouldSkip(Ticket::get((int) $ticket['id']) ?: new Ticket($ticket));
|
||||||
|
return $skip ?? '未知原因,未进入执行队列';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 sessionKey(ticketType:host)分组排序,使同域名工单连续执行以命中 Browser 温池
|
||||||
|
*
|
||||||
|
* @param \think\Collection|array<int, Ticket|array<string, mixed>> $list
|
||||||
|
* @return list<Ticket|array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sortTicketsBySessionKey($list): array
|
||||||
|
{
|
||||||
|
$rows = $list instanceof \think\Collection ? $list->all() : (array) $list;
|
||||||
|
usort($rows, static function ($a, $b): int {
|
||||||
|
$typeA = (string) (is_array($a) ? ($a['ticket_type'] ?? '') : ($a['ticket_type'] ?? ''));
|
||||||
|
$urlA = (string) (is_array($a) ? ($a['ticket_url'] ?? '') : ($a['ticket_url'] ?? ''));
|
||||||
|
$typeB = (string) (is_array($b) ? ($b['ticket_type'] ?? '') : ($b['ticket_type'] ?? ''));
|
||||||
|
$urlB = (string) (is_array($b) ? ($b['ticket_url'] ?? '') : ($b['ticket_url'] ?? ''));
|
||||||
|
$keyA = AntiBotConfigBuilder::resolveSessionKey($typeA, $urlA);
|
||||||
|
$keyB = AntiBotConfigBuilder::resolveSessionKey($typeB, $urlB);
|
||||||
|
if ($keyA === $keyB) {
|
||||||
|
$timeA = (int) (is_array($a) ? ($a['sync_time'] ?? 0) : ($a['sync_time'] ?? 0));
|
||||||
|
$timeB = (int) (is_array($b) ? ($b['sync_time'] ?? 0) : ($b['sync_time'] ?? 0));
|
||||||
|
if ($timeA === $timeB) {
|
||||||
|
$idA = (int) (is_array($a) ? ($a['id'] ?? 0) : ($a['id'] ?? 0));
|
||||||
|
$idB = (int) (is_array($b) ? ($b['id'] ?? 0) : ($b['id'] ?? 0));
|
||||||
|
return $idA <=> $idB;
|
||||||
|
}
|
||||||
|
return $timeA <=> $timeB;
|
||||||
|
}
|
||||||
|
return strcmp($keyA, $keyB);
|
||||||
|
});
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return array (
|
||||||
|
'name' => 'link管理系统',
|
||||||
|
'beian' => '',
|
||||||
|
'cdnurl' => '',
|
||||||
|
'version' => '1.0.10',
|
||||||
|
'timezone' => 'Asia/Shanghai',
|
||||||
|
'forbiddenip' => '',
|
||||||
|
'languages' =>
|
||||||
|
array (
|
||||||
|
'backend' => 'zh-cn',
|
||||||
|
'frontend' => 'zh-cn',
|
||||||
|
),
|
||||||
|
'fixedpage' => 'dashboard',
|
||||||
|
'categorytype' =>
|
||||||
|
array (
|
||||||
|
'default' => 'Default',
|
||||||
|
'page' => 'Page',
|
||||||
|
'article' => 'Article',
|
||||||
|
'test' => 'Test',
|
||||||
|
),
|
||||||
|
'configgroup' =>
|
||||||
|
array (
|
||||||
|
'basic' => 'Basic',
|
||||||
|
'email' => 'Email',
|
||||||
|
'dictionary' => 'Dictionary',
|
||||||
|
'user' => 'User',
|
||||||
|
'example' => 'Example',
|
||||||
|
'split' => '分流设置',
|
||||||
|
),
|
||||||
|
'mail_type' => '1',
|
||||||
|
'mail_smtp_host' => 'smtp.qq.com',
|
||||||
|
'mail_smtp_port' => '465',
|
||||||
|
'mail_smtp_user' => '',
|
||||||
|
'mail_smtp_pass' => '',
|
||||||
|
'mail_verify_type' => '2',
|
||||||
|
'mail_from' => '',
|
||||||
|
'attachmentcategory' =>
|
||||||
|
array (
|
||||||
|
'category1' => 'Category1',
|
||||||
|
'category2' => 'Category2',
|
||||||
|
'custom' => 'Custom',
|
||||||
|
),
|
||||||
|
'split_platform_domain' => 'flowerbells.top',
|
||||||
|
'split_scrm_node_host' => 'http://127.0.0.1:3001',
|
||||||
|
'split_sync_interval_a2c' => '3',
|
||||||
|
'split_sync_interval_haiwang' => '3',
|
||||||
|
'split_sync_interval_huojian' => '3',
|
||||||
|
'split_sync_interval_xinghe' => '3',
|
||||||
|
'split_sync_interval_ss_customer' => '3',
|
||||||
|
'split_sync_interval_ceo_scrm' => '0',
|
||||||
|
'split_sync_interval_taiji' => '0',
|
||||||
|
'split_sync_interval_ss_channel' => '0',
|
||||||
|
'split_sync_interval_yifafa' => '0',
|
||||||
|
'split_sync_interval_whatshub' => '3',
|
||||||
|
'split_sync_interval_sihai' => '0',
|
||||||
|
'split_sync_fail_pause_threshold' => '6',
|
||||||
|
'split_sync_fail_retry_minutes' => '',
|
||||||
|
'split_sync_interval_chatknow' => '5',
|
||||||
|
'split_sync_max_per_cron' => '27',
|
||||||
|
'split_sync_node_candidate_multiplier' => '13',
|
||||||
|
'split_sync_cron_lock_ttl' => '680',
|
||||||
|
'split_sync_node_queue_threshold' => '8',
|
||||||
|
'split_scrm_antibot_types' => 'whatshub,chatknow,a2c,huojian',
|
||||||
|
'split_scrm_session_ttl_minutes' => '120',
|
||||||
|
);
|
||||||
@@ -25,6 +25,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
fixedColumns: true,
|
fixedColumns: true,
|
||||||
fixedRightNumber: 1,
|
fixedRightNumber: 1,
|
||||||
searchFormVisible: true,
|
searchFormVisible: true,
|
||||||
|
pageSize: 10,
|
||||||
|
pageList: Controller.api.streamLoad.PAGE_LIST,
|
||||||
|
queryParams: function (params) {
|
||||||
|
return Controller.api.streamLoad.wrapQueryParams(table, params);
|
||||||
|
},
|
||||||
columns: [
|
columns: [
|
||||||
[
|
[
|
||||||
{checkbox: true},
|
{checkbox: true},
|
||||||
@@ -37,7 +42,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
formatter: Table.api.formatter.content
|
formatter: Table.api.formatter.content
|
||||||
},
|
},
|
||||||
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
||||||
{field: 'number', title: __('Number'), operate: 'LIKE'},
|
{
|
||||||
|
field: 'number',
|
||||||
|
title: __('Number'),
|
||||||
|
operate: 'LIKE',
|
||||||
|
formatter: Controller.api.formatter.numberWithDeferred
|
||||||
|
},
|
||||||
$.extend(
|
$.extend(
|
||||||
{field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal},
|
{field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal},
|
||||||
searchSelectMeta(false)
|
searchSelectMeta(false)
|
||||||
@@ -52,6 +62,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
||||||
searchSelectMeta(false)
|
searchSelectMeta(false)
|
||||||
),
|
),
|
||||||
|
$.extend(
|
||||||
|
{
|
||||||
|
field: 'ratio_deferred',
|
||||||
|
title: __('Ratio_deferred'),
|
||||||
|
visible: false,
|
||||||
|
searchable: true,
|
||||||
|
operate: '=',
|
||||||
|
searchList: Config.ratioDeferredList || {}
|
||||||
|
},
|
||||||
|
searchSelectMeta(false)
|
||||||
|
),
|
||||||
{
|
{
|
||||||
field: 'createtime',
|
field: 'createtime',
|
||||||
title: __('Createtime'),
|
title: __('Createtime'),
|
||||||
@@ -97,11 +118,18 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
Controller.api.openBatchOperateModal(table);
|
Controller.api.openBatchOperateModal(table);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
table.on('post-body.bs.table', function () {
|
||||||
|
table.find('[data-toggle="tooltip"]').tooltip({container: 'body'});
|
||||||
|
});
|
||||||
|
|
||||||
table.on('post-common-search.bs.table', function (e, tbl) {
|
table.on('post-common-search.bs.table', function (e, tbl) {
|
||||||
Controller.api.initCommonSearchSelectpicker(tbl);
|
Controller.api.initCommonSearchSelectpicker(tbl);
|
||||||
});
|
});
|
||||||
|
|
||||||
Table.api.bindevent(table);
|
Table.api.bindevent(table);
|
||||||
|
Controller.api.streamLoad.bind(table);
|
||||||
|
Controller.api.bindDeferredFilter(table);
|
||||||
|
Controller.api.bindDeferredCleanup(table);
|
||||||
},
|
},
|
||||||
add: function () {
|
add: function () {
|
||||||
Controller.api.bindevent();
|
Controller.api.bindevent();
|
||||||
@@ -145,6 +173,504 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页选「全部」时改为分块滚动加载,避免一次渲染过多 DOM 导致卡顿
|
||||||
|
*/
|
||||||
|
streamLoad: {
|
||||||
|
CHUNK: 50,
|
||||||
|
SCROLL_THRESHOLD: 80,
|
||||||
|
LOAD_DELAY: 1500,
|
||||||
|
PAGE_LIST: [10, 30, 50, 100, 200, 300, 500, 'All'],
|
||||||
|
|
||||||
|
createState: function () {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
loading: false,
|
||||||
|
pendingLoad: false,
|
||||||
|
loadTimer: null,
|
||||||
|
loaded: 0,
|
||||||
|
total: 0,
|
||||||
|
chunk: 50,
|
||||||
|
pendingReset: false,
|
||||||
|
pendingAllSelect: false,
|
||||||
|
appendQuery: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getState: function (table) {
|
||||||
|
var state = table.data('splitStreamLoad');
|
||||||
|
if (!state) {
|
||||||
|
state = Controller.api.streamLoad.createState();
|
||||||
|
table.data('splitStreamLoad', state);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllLabel: function () {
|
||||||
|
var localeKey = (typeof Config !== 'undefined' && Config.language === 'zh-cn') ? 'zh-CN' : 'en-US';
|
||||||
|
var locale = $.fn.bootstrapTable.locales && $.fn.bootstrapTable.locales[localeKey];
|
||||||
|
if (locale && typeof locale.formatAllRows === 'function') {
|
||||||
|
return locale.formatAllRows();
|
||||||
|
}
|
||||||
|
if ($.fn.bootstrapTable.defaults && typeof $.fn.bootstrapTable.defaults.formatAllRows === 'function') {
|
||||||
|
return $.fn.bootstrapTable.defaults.formatAllRows();
|
||||||
|
}
|
||||||
|
return 'All';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* initServer 首次请求时实例尚未写入 data('bootstrap.table'),getOptions 会退回 jQuery 对象
|
||||||
|
*/
|
||||||
|
safeGetOptions: function (table) {
|
||||||
|
if (!table || !table.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var opts = table.bootstrapTable('getOptions');
|
||||||
|
if (opts && typeof opts.formatAllRows === 'function') {
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
var instance = table.data('bootstrap.table');
|
||||||
|
if (instance && instance.options && typeof instance.options.formatAllRows === 'function') {
|
||||||
|
return instance.options;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefaultPageList: function () {
|
||||||
|
return Controller.api.streamLoad.PAGE_LIST;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMaxNormalPageSize: function (pageList) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var maxNormal = 0;
|
||||||
|
$.each(pageList || api.getDefaultPageList(), function (i, value) {
|
||||||
|
if (api.isAllPageSize(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var num = parseInt(value, 10);
|
||||||
|
if (!isNaN(num) && num > maxNormal) {
|
||||||
|
maxNormal = num;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return maxNormal;
|
||||||
|
},
|
||||||
|
|
||||||
|
isAllPageSize: function (pageSize) {
|
||||||
|
if (pageSize === undefined || pageSize === null || pageSize === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return String(pageSize).toUpperCase() === String(Controller.api.streamLoad.getAllLabel()).toUpperCase();
|
||||||
|
},
|
||||||
|
|
||||||
|
isLargeStoredPageSize: function (table, params) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var options = api.safeGetOptions(table);
|
||||||
|
var pageSize = options ? options.pageSize : null;
|
||||||
|
var limit = params ? (parseInt(params.limit, 10) || 0) : 0;
|
||||||
|
|
||||||
|
if (pageSize !== null && api.isAllPageSize(pageSize)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var maxNormal = api.getMaxNormalPageSize(options ? options.pageList : null);
|
||||||
|
if (typeof pageSize === 'number' && pageSize > maxNormal) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!options && limit > maxNormal) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
shouldEnable: function (table, params) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var state = api.getState(table);
|
||||||
|
if (state.pendingAllSelect || state.enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var options = api.safeGetOptions(table);
|
||||||
|
var limit = parseInt(params.limit, 10) || 0;
|
||||||
|
|
||||||
|
if (options) {
|
||||||
|
var totalRows = parseInt(options.totalRows, 10) || 0;
|
||||||
|
if (api.isAllPageSize(options.pageSize)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (api.isLargeStoredPageSize(table, params)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return totalRows > 0 && limit >= totalRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格构造阶段:All 模式 totalRows 尚未就绪时 limit 可能为 0
|
||||||
|
if (limit === 0) {
|
||||||
|
var stored = localStorage.getItem('pagesize');
|
||||||
|
if (stored && api.isAllPageSize(stored)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return api.isLargeStoredPageSize(table, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
enable: function (table) {
|
||||||
|
var state = Controller.api.streamLoad.getState(table);
|
||||||
|
state.enabled = true;
|
||||||
|
state.loaded = 0;
|
||||||
|
state.loading = false;
|
||||||
|
state.pendingReset = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
disable: function (table) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var state = api.getState(table);
|
||||||
|
api.cancelPendingLoad(table);
|
||||||
|
state.enabled = false;
|
||||||
|
state.loaded = 0;
|
||||||
|
state.total = 0;
|
||||||
|
state.loading = false;
|
||||||
|
state.pendingReset = false;
|
||||||
|
state.pendingAllSelect = false;
|
||||||
|
api.updateHint(table);
|
||||||
|
api.togglePaginationNav(table, false);
|
||||||
|
api.showBottomLoading(table, false);
|
||||||
|
},
|
||||||
|
|
||||||
|
wrapQueryParams: function (table, params) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var state = api.getState(table);
|
||||||
|
|
||||||
|
if (api.shouldEnable(table, params)) {
|
||||||
|
state.enabled = true;
|
||||||
|
state.pendingAllSelect = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.enabled) {
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.limit = state.chunk;
|
||||||
|
if (state.appendQuery) {
|
||||||
|
params.offset = parseInt(params.offset, 10) || 0;
|
||||||
|
} else {
|
||||||
|
params.offset = 0;
|
||||||
|
state.pendingReset = true;
|
||||||
|
}
|
||||||
|
state.appendQuery = false;
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
|
||||||
|
bind: function (table) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var $wrapper = table.closest('.bootstrap-table');
|
||||||
|
|
||||||
|
$wrapper.on('click.splitStream', '.page-list .dropdown-menu li a', function () {
|
||||||
|
api.cancelPendingLoad(table);
|
||||||
|
var label = $.trim($(this).text());
|
||||||
|
if (api.isAllPageSize(label)) {
|
||||||
|
api.getState(table).pendingAllSelect = true;
|
||||||
|
api.enable(table);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('page-change.bs.table', function (e, pageNumber, pageSize) {
|
||||||
|
var options = api.safeGetOptions(table);
|
||||||
|
if (!options) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (api.isAllPageSize(pageSize)
|
||||||
|
|| (typeof pageSize === 'number' && pageSize === options.totalRows && options.totalRows > 0)) {
|
||||||
|
api.enable(table);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof pageSize === 'number' && pageSize < options.totalRows) {
|
||||||
|
api.disable(table);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('refresh.bs.table search.bs.table common-search.bs.table', function () {
|
||||||
|
var state = api.getState(table);
|
||||||
|
api.cancelPendingLoad(table);
|
||||||
|
if (!state.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.loaded = 0;
|
||||||
|
state.pendingReset = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('sort.bs.table', function () {
|
||||||
|
var state = api.getState(table);
|
||||||
|
api.cancelPendingLoad(table);
|
||||||
|
if (!state.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.loaded = 0;
|
||||||
|
state.pendingReset = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('load-success.bs.table', function (e, data) {
|
||||||
|
var state = api.getState(table);
|
||||||
|
if (!state.enabled || !data || typeof data.total === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.total = parseInt(data.total, 10) || 0;
|
||||||
|
if (state.pendingReset) {
|
||||||
|
state.loaded = $.isArray(data.rows) ? data.rows.length : 0;
|
||||||
|
state.pendingReset = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.updateHint(table);
|
||||||
|
api.togglePaginationNav(table, true);
|
||||||
|
api.bindScrollContainer(table);
|
||||||
|
api.autoFillViewport(table);
|
||||||
|
|
||||||
|
var tableOptions = api.safeGetOptions(table);
|
||||||
|
if (tableOptions && tableOptions.fixedColumns) {
|
||||||
|
table.bootstrapTable('resetView');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('post-body.bs.table', function () {
|
||||||
|
if (!api.getState(table).enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.updateHint(table);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
bindScrollContainer: function (table) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var $body = table.closest('.bootstrap-table').find('.fixed-table-body');
|
||||||
|
if (!$body.length || $body.data('splitStreamBound')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$body.data('splitStreamBound', true);
|
||||||
|
$body.on('scroll.splitStream', function () {
|
||||||
|
api.onScroll(table, this);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onScroll: function (table, el) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var state = api.getState(table);
|
||||||
|
if (!state.enabled || state.loading || state.loaded >= state.total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - api.SCROLL_THRESHOLD;
|
||||||
|
if (!atBottom) {
|
||||||
|
api.cancelPendingLoad(table);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.pendingLoad) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.scheduleLoadMore(table);
|
||||||
|
},
|
||||||
|
|
||||||
|
isNearBottom: function (table) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var $body = table.closest('.bootstrap-table').find('.fixed-table-body');
|
||||||
|
if (!$body.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var el = $body[0];
|
||||||
|
return el.scrollTop + el.clientHeight >= el.scrollHeight - api.SCROLL_THRESHOLD;
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleLoadMore: function (table) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var state = api.getState(table);
|
||||||
|
if (state.loading || state.pendingLoad || !state.enabled || state.loaded >= state.total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.pendingLoad = true;
|
||||||
|
api.showBottomLoading(table, true);
|
||||||
|
api.updateHint(table, false, true);
|
||||||
|
|
||||||
|
state.loadTimer = setTimeout(function () {
|
||||||
|
state.pendingLoad = false;
|
||||||
|
state.loadTimer = null;
|
||||||
|
if (!state.enabled) {
|
||||||
|
api.showBottomLoading(table, false);
|
||||||
|
api.updateHint(table);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!api.isNearBottom(table)) {
|
||||||
|
api.showBottomLoading(table, false);
|
||||||
|
api.updateHint(table);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.loadMore(table);
|
||||||
|
}, api.LOAD_DELAY);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelPendingLoad: function (table) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var state = api.getState(table);
|
||||||
|
if (state.loadTimer) {
|
||||||
|
clearTimeout(state.loadTimer);
|
||||||
|
state.loadTimer = null;
|
||||||
|
}
|
||||||
|
state.pendingLoad = false;
|
||||||
|
if (!state.loading) {
|
||||||
|
api.showBottomLoading(table, false);
|
||||||
|
api.updateHint(table);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showBottomLoading: function (table, show) {
|
||||||
|
var $body = table.closest('.bootstrap-table').find('.fixed-table-body');
|
||||||
|
if (!$body.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var $bar = $body.children('.split-stream-loading-bar');
|
||||||
|
if (show) {
|
||||||
|
if (!$bar.length) {
|
||||||
|
var loadingText = __('Stream loading bottom');
|
||||||
|
if (!loadingText || loadingText === 'Stream loading bottom') {
|
||||||
|
loadingText = '加载中...';
|
||||||
|
}
|
||||||
|
$bar = $('<div class="split-stream-loading-bar text-center" style="padding:14px 0;color:#999;border-top:1px solid #f0f0f0;background:#fafafa;">'
|
||||||
|
+ '<i class="fa fa-spinner fa-spin"></i> ' + Fast.api.escape(loadingText)
|
||||||
|
+ '</div>');
|
||||||
|
$body.append($bar);
|
||||||
|
}
|
||||||
|
$bar.show();
|
||||||
|
} else if ($bar.length) {
|
||||||
|
$bar.hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
buildRequestParams: function (table, offset, limit) {
|
||||||
|
var options = Controller.api.streamLoad.safeGetOptions(table);
|
||||||
|
if (!options) {
|
||||||
|
return {offset: offset, limit: limit};
|
||||||
|
}
|
||||||
|
var state = Controller.api.streamLoad.getState(table);
|
||||||
|
state.appendQuery = true;
|
||||||
|
return options.queryParams({
|
||||||
|
search: options.searchText,
|
||||||
|
sort: options.sortName,
|
||||||
|
order: options.sortOrder,
|
||||||
|
offset: offset,
|
||||||
|
limit: limit
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMore: function (table) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var state = api.getState(table);
|
||||||
|
if (state.loading || !state.enabled || state.loaded >= state.total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loading = true;
|
||||||
|
api.updateHint(table, true);
|
||||||
|
|
||||||
|
var options = api.safeGetOptions(table);
|
||||||
|
if (!options) {
|
||||||
|
state.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$.ajax({
|
||||||
|
url: options.url,
|
||||||
|
type: options.method,
|
||||||
|
data: api.buildRequestParams(table, state.loaded, state.chunk),
|
||||||
|
dataType: 'json'
|
||||||
|
}).done(function (res) {
|
||||||
|
state.loading = false;
|
||||||
|
api.showBottomLoading(table, false);
|
||||||
|
if (!res || typeof res.rows === 'undefined') {
|
||||||
|
Toastr.error(__('Unknown data format'));
|
||||||
|
api.updateHint(table);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = res.rows || [];
|
||||||
|
if (rows.length === 0) {
|
||||||
|
state.loaded = state.total;
|
||||||
|
api.updateHint(table);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.bootstrapTable('append', rows);
|
||||||
|
state.loaded += rows.length;
|
||||||
|
api.updateHint(table);
|
||||||
|
api.autoFillViewport(table);
|
||||||
|
|
||||||
|
if (options.fixedColumns) {
|
||||||
|
table.bootstrapTable('resetView');
|
||||||
|
}
|
||||||
|
}).fail(function () {
|
||||||
|
state.loading = false;
|
||||||
|
api.showBottomLoading(table, false);
|
||||||
|
api.updateHint(table);
|
||||||
|
Toastr.error(__('Unknown data format'));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
autoFillViewport: function (table) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var state = api.getState(table);
|
||||||
|
if (!state.enabled || state.loading || state.pendingLoad || state.loaded >= state.total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var $body = table.closest('.bootstrap-table').find('.fixed-table-body');
|
||||||
|
if (!$body.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var el = $body[0];
|
||||||
|
if (el.scrollHeight <= el.clientHeight + api.SCROLL_THRESHOLD) {
|
||||||
|
api.scheduleLoadMore(table);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePaginationNav: function (table, hide) {
|
||||||
|
table.closest('.bootstrap-table').find('.fixed-table-pagination .pagination').toggle(!hide);
|
||||||
|
},
|
||||||
|
|
||||||
|
getHintText: function (loaded, total, loading, pending) {
|
||||||
|
if (pending) {
|
||||||
|
var pendingText = __('Stream loading pending');
|
||||||
|
if (!pendingText || pendingText === 'Stream loading pending') {
|
||||||
|
pendingText = '即将加载更多,可切换显示条数取消...';
|
||||||
|
}
|
||||||
|
return pendingText;
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
var loadingText = __('Stream loading');
|
||||||
|
return loadingText && loadingText !== 'Stream loading' ? loadingText : '加载中...';
|
||||||
|
}
|
||||||
|
var template = __('Stream loaded');
|
||||||
|
if (!template || template === 'Stream loaded') {
|
||||||
|
template = '已加载 %loaded% / %total%,继续滚动加载更多';
|
||||||
|
}
|
||||||
|
return template
|
||||||
|
.replace('%loaded%', String(loaded))
|
||||||
|
.replace('%total%', String(total));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateHint: function (table, loading, pending) {
|
||||||
|
var api = Controller.api.streamLoad;
|
||||||
|
var state = api.getState(table);
|
||||||
|
var $wrapper = table.closest('.bootstrap-table');
|
||||||
|
var $detail = $wrapper.find('.pagination-detail');
|
||||||
|
|
||||||
|
if (!state.enabled) {
|
||||||
|
$wrapper.find('.split-stream-hint').remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var $hint = $wrapper.find('.split-stream-hint');
|
||||||
|
if (!$hint.length) {
|
||||||
|
$hint = $('<span class="split-stream-hint text-muted" style="margin-left:10px;font-size:12px;"></span>');
|
||||||
|
$detail.append($hint);
|
||||||
|
}
|
||||||
|
var isPending = pending !== undefined ? pending : state.pendingLoad;
|
||||||
|
var isLoading = loading !== undefined ? loading : state.loading;
|
||||||
|
$hint.text(api.getHintText(state.loaded, state.total, isLoading, isPending));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
formatter: {
|
formatter: {
|
||||||
platformStatus: function (value, row) {
|
platformStatus: function (value, row) {
|
||||||
var text = row.platform_status_text != null && row.platform_status_text !== ''
|
var text = row.platform_status_text != null && row.platform_status_text !== ''
|
||||||
@@ -152,7 +678,151 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
: (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value);
|
: (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value);
|
||||||
var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning');
|
var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning');
|
||||||
return '<span class="text-' + cls + '">' + Fast.api.escape(text || '') + '</span>';
|
return '<span class="text-' + cls + '">' + Fast.api.escape(text || '') + '</span>';
|
||||||
|
},
|
||||||
|
isRatioDeferred: function (row) {
|
||||||
|
return parseInt(row.ratio_deferred, 10) === 1 && String(row.status || '') === 'normal';
|
||||||
|
},
|
||||||
|
ratioDeferredTooltip: function (row) {
|
||||||
|
var streak = parseInt(row.no_inbound_click_streak, 10) || 0;
|
||||||
|
var template = __('Ratio deferred tooltip');
|
||||||
|
if (!template || template === 'Ratio deferred tooltip') {
|
||||||
|
template = '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决';
|
||||||
}
|
}
|
||||||
|
return template.indexOf('%s') >= 0 ? template.replace('%s', String(streak)) : template;
|
||||||
|
},
|
||||||
|
ratioDeferredBadgeHtml: function (tooltip) {
|
||||||
|
var label = __('Ratio deferred badge');
|
||||||
|
if (!label || label === 'Ratio deferred badge') {
|
||||||
|
label = '降权中';
|
||||||
|
}
|
||||||
|
return '<span class="label label-warning split-ratio-deferred-badge" style="margin-left:6px;font-size:11px;font-weight:500;vertical-align:middle;"'
|
||||||
|
+ ' data-toggle="tooltip" data-placement="top" title="' + Fast.api.escape(tooltip) + '">'
|
||||||
|
+ '<i class="fa fa-level-down"></i> ' + Fast.api.escape(label)
|
||||||
|
+ '</span>';
|
||||||
|
},
|
||||||
|
numberWithDeferred: function (value, row) {
|
||||||
|
var html = Fast.api.escape(String(value || ''));
|
||||||
|
if (Controller.api.formatter.isRatioDeferred(row)) {
|
||||||
|
html += Controller.api.formatter.ratioDeferredBadgeHtml(
|
||||||
|
Controller.api.formatter.ratioDeferredTooltip(row)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refreshSearchSelect: function ($field) {
|
||||||
|
if ($field.length && $field.hasClass('selectpicker') && $field.data('selectpicker')) {
|
||||||
|
$field.selectpicker('refresh');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
syncStatusTabs: function (table, statusValue) {
|
||||||
|
var $tabs = table.closest('.panel-intro').find('.panel-heading [data-field="status"]');
|
||||||
|
if (!$tabs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tabs.find('li').removeClass('active');
|
||||||
|
$tabs.find('li a[data-value="' + (statusValue === null || statusValue === undefined ? '' : statusValue) + '"]')
|
||||||
|
.parent()
|
||||||
|
.addClass('active');
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 工具栏「降权中」快捷筛选:与列表徽章一致(ratio_deferred=1 且 status=normal)
|
||||||
|
*/
|
||||||
|
bindDeferredFilter: function (table) {
|
||||||
|
var active = false;
|
||||||
|
var savedStatus = '';
|
||||||
|
var $btn = $('.btn-filter-ratio-deferred');
|
||||||
|
if (!$btn.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var applyFilter = function () {
|
||||||
|
var $form = table.closest('.bootstrap-table').find('form.form-commonsearch');
|
||||||
|
if (!$form.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var $ratioField = $form.find('[name="ratio_deferred"]');
|
||||||
|
var $statusField = $form.find('[name="status"]');
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
savedStatus = $statusField.length ? String($statusField.val() || '') : '';
|
||||||
|
if ($ratioField.length) {
|
||||||
|
$ratioField.val('1');
|
||||||
|
Controller.api.refreshSearchSelect($ratioField);
|
||||||
|
}
|
||||||
|
if ($statusField.length) {
|
||||||
|
$statusField.val('normal');
|
||||||
|
Controller.api.refreshSearchSelect($statusField);
|
||||||
|
}
|
||||||
|
Controller.api.syncStatusTabs(table, 'normal');
|
||||||
|
} else {
|
||||||
|
if ($ratioField.length) {
|
||||||
|
$ratioField.val('');
|
||||||
|
Controller.api.refreshSearchSelect($ratioField);
|
||||||
|
}
|
||||||
|
if ($statusField.length) {
|
||||||
|
$statusField.val(savedStatus);
|
||||||
|
Controller.api.refreshSearchSelect($statusField);
|
||||||
|
}
|
||||||
|
Controller.api.syncStatusTabs(table, savedStatus);
|
||||||
|
savedStatus = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
table.trigger('uncheckbox');
|
||||||
|
$form.trigger('submit');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
$btn.on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
active = !active;
|
||||||
|
$btn.toggleClass('btn-warning', active).toggleClass('btn-default', !active);
|
||||||
|
if (!applyFilter()) {
|
||||||
|
active = !active;
|
||||||
|
$btn.toggleClass('btn-warning', active).toggleClass('btn-default', !active);
|
||||||
|
Toastr.warning('筛选表单未就绪,请刷新页面后重试');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 清理无效降权池:无落地页访问基线却标记 ratio_deferred=1 的号码
|
||||||
|
*/
|
||||||
|
bindDeferredCleanup: function (table) {
|
||||||
|
var $btn = $('.btn-cleanup-ratio-deferred');
|
||||||
|
if (!$btn.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$btn.on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'split.number/cleanupdeferredpreview',
|
||||||
|
type: 'get'
|
||||||
|
}, function (data) {
|
||||||
|
var count = parseInt(data.count, 10) || 0;
|
||||||
|
if (count <= 0) {
|
||||||
|
Toastr.info(__('Ratio deferred cleanup none'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var template = __('Ratio deferred cleanup confirm');
|
||||||
|
if (!template || template === 'Ratio deferred cleanup confirm') {
|
||||||
|
template = '将清除 %d 条无效降权记录。确定继续?';
|
||||||
|
}
|
||||||
|
var message = template.indexOf('%d') >= 0
|
||||||
|
? template.replace('%d', String(count))
|
||||||
|
: template;
|
||||||
|
Layer.confirm(message, {icon: 3, title: __('Warning')}, function (index) {
|
||||||
|
Layer.close(index);
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'split.number/cleanupdeferred',
|
||||||
|
type: 'post'
|
||||||
|
}, function (data, ret) {
|
||||||
|
table.bootstrapTable('refresh');
|
||||||
|
Toastr.success(ret.msg || __('Ratio deferred cleanup success'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
bindevent: function () {
|
bindevent: function () {
|
||||||
Form.api.bindevent($('form[role=form]'));
|
Form.api.bindevent($('form[role=form]'));
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
sortOrder: 'desc',
|
sortOrder: 'desc',
|
||||||
fixedColumns: true,
|
fixedColumns: true,
|
||||||
fixedRightNumber: 1,
|
fixedRightNumber: 1,
|
||||||
|
searchFormVisible: true,
|
||||||
columns: [
|
columns: [
|
||||||
[
|
[
|
||||||
{checkbox: true},
|
{checkbox: true},
|
||||||
@@ -87,10 +88,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
formatter: Controller.api.formatter.speedPerHour
|
formatter: Controller.api.formatter.speedPerHour
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'number_count',
|
field: 'active_number_count',
|
||||||
title: __('Number_count'),
|
title: __('Number_count'),
|
||||||
operate: false,
|
operate: false
|
||||||
formatter: Controller.api.formatter.numberCount
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'sync_display_text',
|
field: 'sync_display_text',
|
||||||
@@ -98,6 +98,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
operate: false,
|
operate: false,
|
||||||
formatter: Controller.api.formatter.syncDisplay
|
formatter: Controller.api.formatter.syncDisplay
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'sync_success_text',
|
||||||
|
title: __('Sync success time'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.syncSuccessDisplay
|
||||||
|
},
|
||||||
$.extend(
|
$.extend(
|
||||||
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
||||||
searchSelectMeta(false)
|
searchSelectMeta(false)
|
||||||
@@ -136,6 +142,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
|
|
||||||
table.on('load-success.bs.table', function (e, res) {
|
table.on('load-success.bs.table', function (e, res) {
|
||||||
Controller.api.renderSummaryRow(res);
|
Controller.api.renderSummaryRow(res);
|
||||||
|
Controller.api.initSyncTooltips(table);
|
||||||
var pendingIds = window.__splitTicketPendingPostAddSyncIds;
|
var pendingIds = window.__splitTicketPendingPostAddSyncIds;
|
||||||
if (!pendingIds || !pendingIds.length) {
|
if (!pendingIds || !pendingIds.length) {
|
||||||
return;
|
return;
|
||||||
@@ -236,6 +243,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
|
|
||||||
/** @type {number[]} 正在手动同步的工单 ID */
|
/** @type {number[]} 正在手动同步的工单 ID */
|
||||||
syncingTicketIds: [],
|
syncingTicketIds: [],
|
||||||
|
syncPollTimer: null,
|
||||||
|
syncPollStartedAt: 0,
|
||||||
|
syncPollGraceMs: 8000,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染筛选结果汇总行(全量筛选数据,非当前页)
|
* 渲染筛选结果汇总行(全量筛选数据,非当前页)
|
||||||
@@ -261,7 +271,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台同步:标记「同步中」并请求 sync 接口
|
* 后台同步:标记「同步中」、投递 CLI,并轮询直至锁释放
|
||||||
*
|
*
|
||||||
* @param {object} table bootstrapTable 实例
|
* @param {object} table bootstrapTable 实例
|
||||||
* @param {number[]} ids 工单 ID
|
* @param {number[]} ids 工单 ID
|
||||||
@@ -284,13 +294,81 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
url: 'split.ticket/sync',
|
url: 'split.ticket/sync',
|
||||||
data: {ids: ids.join(',')},
|
data: {ids: ids.join(',')},
|
||||||
loading: false
|
loading: false
|
||||||
}, function () {
|
}, function (data, ret) {
|
||||||
|
var queued = (ret.data && ret.data.queued) ? ret.data.queued : ids;
|
||||||
|
Controller.api.syncingTicketIds = (queued || []).map(function (id) {
|
||||||
|
return parseInt(id, 10);
|
||||||
|
}).filter(function (id) {
|
||||||
|
return !isNaN(id) && id > 0;
|
||||||
|
});
|
||||||
|
if (Controller.api.syncingTicketIds.length) {
|
||||||
|
Controller.api.startSyncPolling(table);
|
||||||
|
} else {
|
||||||
Controller.api.finishTicketsSync(table);
|
Controller.api.finishTicketsSync(table);
|
||||||
|
}
|
||||||
}, function () {
|
}, function () {
|
||||||
Controller.api.finishTicketsSync(table);
|
Controller.api.finishTicketsSync(table);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询同步锁状态,全部完成后刷新列表
|
||||||
|
*/
|
||||||
|
startSyncPolling: function (table) {
|
||||||
|
Controller.api.stopSyncPolling();
|
||||||
|
if (!Controller.api.syncingTicketIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Controller.api.syncPollStartedAt = Date.now();
|
||||||
|
Controller.api.syncPollTimer = setInterval(function () {
|
||||||
|
if (!Controller.api.syncingTicketIds.length) {
|
||||||
|
Controller.api.stopSyncPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'split.ticket/syncpolling',
|
||||||
|
data: {ids: Controller.api.syncingTicketIds.join(',')},
|
||||||
|
loading: false
|
||||||
|
}, function (data, ret) {
|
||||||
|
var still = (ret.data && ret.data.syncing) ? ret.data.syncing : [];
|
||||||
|
still = (still || []).map(function (id) {
|
||||||
|
return parseInt(id, 10);
|
||||||
|
}).filter(function (id) {
|
||||||
|
return !isNaN(id) && id > 0;
|
||||||
|
});
|
||||||
|
var elapsed = Date.now() - (Controller.api.syncPollStartedAt || 0);
|
||||||
|
var graceMs = Controller.api.syncPollGraceMs || 8000;
|
||||||
|
if (still.length === 0 && elapsed < graceMs) {
|
||||||
|
table.bootstrapTable('refresh', {silent: true});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (still.length === 0) {
|
||||||
|
Controller.api.stopSyncPolling();
|
||||||
|
var doneMsg = (typeof Config.syncDoneMsg !== 'undefined' && Config.syncDoneMsg)
|
||||||
|
? Config.syncDoneMsg
|
||||||
|
: '同步完成';
|
||||||
|
Toastr.success(doneMsg);
|
||||||
|
Controller.api.finishTicketsSync(table);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (still.join(',') !== Controller.api.syncingTicketIds.join(',')) {
|
||||||
|
Controller.api.syncingTicketIds = still;
|
||||||
|
var rowData = table.bootstrapTable('getData');
|
||||||
|
table.bootstrapTable('load', rowData);
|
||||||
|
}
|
||||||
|
table.bootstrapTable('refresh', {silent: true});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopSyncPolling: function () {
|
||||||
|
if (Controller.api.syncPollTimer) {
|
||||||
|
clearInterval(Controller.api.syncPollTimer);
|
||||||
|
Controller.api.syncPollTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将选中工单标记为「同步中」并刷新列表展示(不请求后端)
|
* 将选中工单标记为「同步中」并刷新列表展示(不请求后端)
|
||||||
*/
|
*/
|
||||||
@@ -308,12 +386,28 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
* 同步结束:清除标记并刷新列表数据
|
* 同步结束:清除标记并刷新列表数据
|
||||||
*/
|
*/
|
||||||
finishTicketsSync: function (table) {
|
finishTicketsSync: function (table) {
|
||||||
|
Controller.api.stopSyncPolling();
|
||||||
Controller.api.syncingTicketIds = [];
|
Controller.api.syncingTicketIds = [];
|
||||||
table.bootstrapTable('refresh');
|
table.bootstrapTable('refresh', {
|
||||||
|
silent: true
|
||||||
|
});
|
||||||
|
Controller.api.initSyncTooltips(table);
|
||||||
var ids = Table.api.selectedids(table);
|
var ids = Table.api.selectedids(table);
|
||||||
$('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0);
|
$('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化同步状态列 tooltip(完整异常原因)
|
||||||
|
*/
|
||||||
|
initSyncTooltips: function (table) {
|
||||||
|
var $container = table.closest('.bootstrap-table');
|
||||||
|
$container.find('[data-sync-tooltip="1"]').tooltip({
|
||||||
|
container: 'body',
|
||||||
|
placement: 'auto top',
|
||||||
|
html: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
formatter: {
|
formatter: {
|
||||||
/**
|
/**
|
||||||
* 操作列:编辑 → 拷贝 → 删除
|
* 操作列:编辑 → 拷贝 → 删除
|
||||||
@@ -391,29 +485,44 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
}
|
}
|
||||||
return num.toFixed(2);
|
return num.toFixed(2);
|
||||||
},
|
},
|
||||||
numberCount: function (value, row) {
|
|
||||||
var total = parseInt(value, 10) || 0;
|
|
||||||
var offline = parseInt(row.number_offline_count, 10) || 0;
|
|
||||||
var banned = parseInt(row.number_banned_count, 10) || 0;
|
|
||||||
var tip = __('Number_count_detail').replace('%s', offline).replace('%s', banned);
|
|
||||||
if (offline > 0 || banned > 0) {
|
|
||||||
return '<span data-toggle="tooltip" title="' + Fast.api.escape(tip) + '">' + total + '</span>';
|
|
||||||
}
|
|
||||||
return String(total);
|
|
||||||
},
|
|
||||||
syncDisplay: function (value, row) {
|
syncDisplay: function (value, row) {
|
||||||
var rowId = parseInt(row.id, 10);
|
var rowId = parseInt(row.id, 10);
|
||||||
if (Controller.api.syncingTicketIds.indexOf(rowId) !== -1) {
|
if (Controller.api.syncingTicketIds.indexOf(rowId) !== -1) {
|
||||||
return Controller.api.formatter.syncingDisplayHtml();
|
return Controller.api.formatter.syncingDisplayHtml();
|
||||||
}
|
}
|
||||||
var text = value || '';
|
var text = value || '';
|
||||||
|
var tooltip = row.sync_tooltip_text ? String(row.sync_tooltip_text).trim() : '';
|
||||||
|
if (tooltip !== '') {
|
||||||
|
tooltip = tooltip.replace(/\r?\n/g, ';');
|
||||||
|
}
|
||||||
|
var hasTooltip = tooltip !== '';
|
||||||
var color = 'danger';
|
var color = 'danger';
|
||||||
if (row.sync_status === 'success') {
|
var extraClass = '';
|
||||||
|
if (row.sync_auto_paused) {
|
||||||
|
color = 'warning';
|
||||||
|
extraClass = ' split-ticket-sync-status-paused';
|
||||||
|
} else if (row.sync_status === 'success') {
|
||||||
color = 'success';
|
color = 'success';
|
||||||
} else if (row.sync_status === 'pending') {
|
} else if (row.sync_status === 'pending') {
|
||||||
color = 'muted';
|
color = 'muted';
|
||||||
|
} else if (row.sync_status === 'error') {
|
||||||
|
extraClass = ' split-ticket-sync-status-error';
|
||||||
}
|
}
|
||||||
return '<span class="text-' + color + '">' + Fast.api.escape(text) + '</span>';
|
var attrs = '';
|
||||||
|
if (hasTooltip) {
|
||||||
|
attrs = ' data-sync-tooltip="1" data-toggle="tooltip" title="' + Fast.api.escape(tooltip) + '"';
|
||||||
|
}
|
||||||
|
return '<span class="text-' + color + extraClass + '"' + attrs + '>' + Fast.api.escape(text) + '</span>';
|
||||||
|
},
|
||||||
|
syncSuccessDisplay: function (value, row) {
|
||||||
|
var text = value == null ? '' : String(value).trim();
|
||||||
|
if (text === '') {
|
||||||
|
return '<span class="text-muted">-</span>';
|
||||||
|
}
|
||||||
|
var colorClass = row.status === 'normal'
|
||||||
|
? 'split-sync-success-normal'
|
||||||
|
: 'split-sync-success-stopped';
|
||||||
|
return '<span class="' + colorClass + '">' + Fast.api.escape(text) + '</span>';
|
||||||
},
|
},
|
||||||
syncingDisplayHtml: function () {
|
syncingDisplayHtml: function () {
|
||||||
var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg)
|
var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user