添加whatshub工单
This commit is contained in:
Regular → Executable
Regular → Executable
+107
-5
@@ -67,22 +67,36 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
$apiUrlsToIntercept[] = $countApi;
|
||||
}
|
||||
|
||||
$antiBot = $config['antiBot'] ?? null;
|
||||
|
||||
$initResult = $this->requestNode('/api/auth-and-intercept', [
|
||||
'pageUrl' => $config['pageUrl'],
|
||||
'apiUrls' => $apiUrlsToIntercept,
|
||||
'authActions' => $config['authActions'] ?? [],
|
||||
'antiBot' => $antiBot,
|
||||
]);
|
||||
|
||||
if (empty($initResult['success'])) {
|
||||
$cfCode = (string) ($initResult['code'] ?? '');
|
||||
SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [
|
||||
'error' => $initResult['error'] ?? '未知',
|
||||
'error' => $initResult['error'] ?? '未知',
|
||||
'code' => $cfCode !== '' ? $cfCode : null,
|
||||
'challengeType' => $initResult['challengeType'] ?? null,
|
||||
'stage' => $initResult['stage'] ?? null,
|
||||
'cf' => $initResult['cf'] ?? null,
|
||||
]);
|
||||
throw new Exception('初始化失败: ' . ($initResult['error'] ?? '未知'));
|
||||
$errMsg = (string) ($initResult['error'] ?? '未知');
|
||||
if ($cfCode === 'CF_TURNSTILE_FAILED') {
|
||||
throw new Exception('Cloudflare Turnstile 验证失败: ' . ($initResult['stage'] ?? 'timeout'));
|
||||
}
|
||||
throw new Exception('初始化失败: ' . $errMsg);
|
||||
}
|
||||
|
||||
$interceptedApis = $initResult['interceptedApis'];
|
||||
$finalPageUrl = (string) ($initResult['finalPageUrl'] ?? ($config['pageUrl'] ?? ''));
|
||||
SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [
|
||||
'intercepted' => array_keys($interceptedApis),
|
||||
'intercepted' => array_keys($interceptedApis),
|
||||
'finalPageUrl' => $finalPageUrl,
|
||||
]);
|
||||
$cookies = $initResult['cookies'];
|
||||
|
||||
@@ -121,6 +135,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
'method' => $config['listMethod'] ?? 'GET',
|
||||
]],
|
||||
'cookies' => $cookies,
|
||||
'antiBot' => $antiBot,
|
||||
], 120);
|
||||
|
||||
if (!empty($fetchResult['success'])) {
|
||||
@@ -138,12 +153,14 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
$uiResult = $this->requestNode('/api/ui-pagination', [
|
||||
'apiUrl' => $listApi,
|
||||
'pageUrl' => $config['pageUrl'],
|
||||
'finalPageUrl' => $finalPageUrl,
|
||||
'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '',
|
||||
'waitMs' => $uiConfig['waitMs'] ?? 2000,
|
||||
'clicksToPerform' => $clicksToPerform,
|
||||
'cookies' => $cookies,
|
||||
'firstPageData' => $firstPageData,
|
||||
'authActions' => $config['authActions'] ?? [],
|
||||
'antiBot' => $antiBot,
|
||||
], 1200);
|
||||
|
||||
if (!empty($uiResult['success']) && !empty($uiResult['data'])) {
|
||||
@@ -170,13 +187,43 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array
|
||||
{
|
||||
$maxAttempts = 3;
|
||||
$lastDecoded = [];
|
||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||
$lastDecoded = $this->doRequestNode($endpoint, $payload, $timeout, $attempt);
|
||||
if (!empty($lastDecoded['success'])) {
|
||||
return $lastDecoded;
|
||||
}
|
||||
$error = (string) ($lastDecoded['error'] ?? '');
|
||||
if (!$this->shouldRetryNodeResponse($lastDecoded, $error) || $attempt >= $maxAttempts) {
|
||||
return $lastDecoded;
|
||||
}
|
||||
$delaySeconds = $attempt;
|
||||
SplitTicketSyncLogger::log('node_retry', 'retry ' . $endpoint, [
|
||||
'attempt' => $attempt + 1,
|
||||
'error' => $error,
|
||||
'delay' => $delaySeconds,
|
||||
]);
|
||||
sleep($delaySeconds);
|
||||
}
|
||||
return $lastDecoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function doRequestNode(string $endpoint, array $payload, int $timeout, int $attempt): array
|
||||
{
|
||||
$url = $this->nodeHost . $endpoint;
|
||||
$started = microtime(true);
|
||||
$logPayload = SplitTicketSyncLogger::isEnabled() ? $payload : self::summarizeNodePayload($payload);
|
||||
SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [
|
||||
'url' => $url,
|
||||
'timeout' => $timeout,
|
||||
'payload' => $payload,
|
||||
'attempt' => $attempt,
|
||||
'payload' => $logPayload,
|
||||
]);
|
||||
|
||||
$ch = curl_init($url);
|
||||
@@ -184,6 +231,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
@@ -194,6 +242,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [
|
||||
'httpCode' => $httpCode,
|
||||
'elapsedMs' => $elapsedMs,
|
||||
'attempt' => $attempt,
|
||||
'error' => $err,
|
||||
]);
|
||||
throw new Exception($err);
|
||||
@@ -204,9 +253,62 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([
|
||||
'httpCode' => $httpCode,
|
||||
'elapsedMs' => $elapsedMs,
|
||||
'attempt' => $attempt,
|
||||
'responseSize' => strlen((string) $response),
|
||||
], $summary));
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
if (!is_array($decoded)) {
|
||||
return ['success' => false, 'error' => 'Node 返回非 JSON', 'httpCode' => $httpCode];
|
||||
}
|
||||
if ($httpCode === 503) {
|
||||
$decoded['success'] = false;
|
||||
$decoded['error'] = (string) ($decoded['error'] ?? '服务繁忙');
|
||||
$decoded['httpCode'] = 503;
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $decoded
|
||||
*/
|
||||
private function shouldRetryNodeResponse(array $decoded, string $error): bool
|
||||
{
|
||||
if ((int) ($decoded['httpCode'] ?? 0) === 503) {
|
||||
return true;
|
||||
}
|
||||
$lower = mb_strtolower($error, 'UTF-8');
|
||||
foreach (['排队超时', 'queue_timeout', 'timeout', 'net::', 'navigation', 'connection'] as $needle) {
|
||||
if ($needle !== '' && mb_strpos($lower, mb_strtolower($needle, 'UTF-8')) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function summarizeNodePayload(array $payload): array
|
||||
{
|
||||
$summary = $payload;
|
||||
if (isset($summary['tasks']) && is_array($summary['tasks'])) {
|
||||
$summary['tasks'] = array_map(static function ($task) {
|
||||
if (!is_array($task)) {
|
||||
return $task;
|
||||
}
|
||||
$copy = $task;
|
||||
if (isset($copy['paramList']) && is_array($copy['paramList'])) {
|
||||
$copy['paramList_count'] = count($copy['paramList']);
|
||||
unset($copy['paramList']);
|
||||
}
|
||||
return $copy;
|
||||
}, $summary['tasks']);
|
||||
}
|
||||
if (isset($summary['cookies']) && is_array($summary['cookies'])) {
|
||||
$summary['cookies_count'] = count($summary['cookies']);
|
||||
unset($summary['cookies']);
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* Chatknow SCRM 云控蜘蛛(UI 翻页 + 列表 API 拦截)
|
||||
*/
|
||||
class ChatknowSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/user/user/UserInfoChildChannel/list';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 10;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'listMethod' => 'GET',
|
||||
'paginationMode' => self::MODE_UI,
|
||||
'authActions' => [
|
||||
['type' => 'wait', 'ms' => 4000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $listFirstPageData
|
||||
* @param array<string, mixed>|null $countData
|
||||
*/
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
$total = (int) ($listFirstPageData['total'] ?? 0);
|
||||
$this->unifiedData->total = $total;
|
||||
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['data']['today_num'] ?? 0);
|
||||
|
||||
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.tabs-content .arco-pagination-item-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
if (!is_array($pageRaw)) {
|
||||
continue;
|
||||
}
|
||||
$records = $pageRaw['rows'] ?? [];
|
||||
if (!is_array($records)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($records as $item) {
|
||||
if (!is_array($item) || empty($item['username'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['username'];
|
||||
$isOnline = isset($item['state']) && (int) $item['state'] === 1;
|
||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['today_num'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* Whatshub 云控蜘蛛(Real Browser + Turnstile 过盾 + UI 翻页)
|
||||
*
|
||||
* 时序:Cloudflare Turnstile → 密码弹窗 authActions → API 拦截 → UI 翻页
|
||||
*/
|
||||
class WhatshubSpider extends AbstractScrmSpider
|
||||
{
|
||||
/** 列表 API 路径 */
|
||||
private const API_LIST = '/api/whatshub-counter/workShare/open/detail';
|
||||
|
||||
/** 详情/统计 API 路径 */
|
||||
private const API_DETAILS = '/api/whatshub-counter/workShare/open/statistics';
|
||||
|
||||
/** 默认每页条数 */
|
||||
private const DEFAULT_PER_PAGE_COUNT = 20;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
$host = (string) parse_url($this->pageUrl, PHP_URL_HOST);
|
||||
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'detailApi' => self::API_DETAILS,
|
||||
'listMethod' => 'POST',
|
||||
'paginationMode' => self::MODE_UI,
|
||||
'authActions' => [
|
||||
// Element Plus 密码弹窗:仅注入可见 input
|
||||
['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password],
|
||||
['type' => 'wait', 'ms' => 500],
|
||||
['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'],
|
||||
['type' => 'wait', 'ms' => 2200],
|
||||
],
|
||||
// Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用
|
||||
'antiBot' => [
|
||||
'enabled' => true,
|
||||
'profile' => 'real',
|
||||
'turnstile' => true,
|
||||
'solverFallback' => true,
|
||||
'sessionKey' => 'whatshub:' . $host,
|
||||
'challengeTimeoutMs' => 60000,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $listFirstPageData
|
||||
* @param array<string, mixed>|null $countData
|
||||
*/
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
$defaultPerPage = self::DEFAULT_PER_PAGE_COUNT;
|
||||
$this->unifiedData->total = (int) ($listFirstPageData['data']['total'] ?? 0);
|
||||
|
||||
if ($this->unifiedData->total <= $defaultPerPage) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int) ceil($this->unifiedData->total / $defaultPerPage);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['pageNum' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.vxe-pager--next-btn',
|
||||
'waitMs' => 1500,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
|
||||
if (is_array($detailData)) {
|
||||
$unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0);
|
||||
}
|
||||
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
if (!is_array($pageRaw)) {
|
||||
continue;
|
||||
}
|
||||
$records = $pageRaw['data']['rows'] ?? [];
|
||||
if (!is_array($records)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($records as $item) {
|
||||
if (!is_array($item) || empty($item['account'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['account'];
|
||||
$isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1;
|
||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['dayNewFans'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
$unifiedData->total = count($unifiedData->numbers);
|
||||
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
Regular → Executable
+188
-37
@@ -4,83 +4,152 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\ScrmSpiderInterface;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 星河云控蜘蛛
|
||||
* 星河云控蜘蛛(纯 PHP cURL 抓取,不依赖 Node Puppeteer)
|
||||
*
|
||||
* 流程:访问带 token 的授权页建立 Cookie 会话 → 分页请求列表 API → 清洗为 UnifiedScrmData
|
||||
*/
|
||||
class XingheSpider extends AbstractScrmSpider
|
||||
class XingheSpider implements ScrmSpiderInterface
|
||||
{
|
||||
private const API_LIST = '/share/share/api_yinliu_count.html';
|
||||
private const API_PATH = '/share/share/api_yinliu_count.html';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 10;
|
||||
|
||||
private const CURL_TIMEOUT_SECONDS = 15;
|
||||
|
||||
/** @var resource|\CurlHandle|null */
|
||||
private $ch = null;
|
||||
|
||||
/** @var string|null 临时 Cookie 文件路径,仅在 run() 成功后赋值 */
|
||||
private ?string $cookieFile = null;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private string $baseUrl;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
/**
|
||||
* @param string $pageUrl 带 token 的授权页地址
|
||||
* @param string $account 账号(星河云控当前未使用,保留与工厂签名一致)
|
||||
* @param string $password 密码(星河云控当前未使用,保留与工厂签名一致)
|
||||
* @param string $nodeHost Node 地址(星河云控不使用,忽略)
|
||||
*/
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
string $nodeHost = ''
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
unset($nodeHost);
|
||||
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->baseUrl = $this->resolveBaseUrl($pageUrl);
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
/**
|
||||
* 执行抓取并返回统一数据
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function run(): UnifiedScrmData
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'listMethod' => 'GET',
|
||||
'paginationMode' => self::MODE_FETCH,
|
||||
'authActions' => [
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
$total = (int) ($listFirstPageData['count'] ?? 0);
|
||||
$this->unifiedData->total = $total;
|
||||
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0);
|
||||
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
||||
return 1;
|
||||
$cookieFile = tempnam(sys_get_temp_dir(), 'xinghe_spider_cookie_');
|
||||
if ($cookieFile === false) {
|
||||
throw new RuntimeException('无法在系统临时目录创建 Cookie 文件');
|
||||
}
|
||||
|
||||
$this->cookieFile = $cookieFile;
|
||||
$this->ch = curl_init();
|
||||
if ($this->ch === false) {
|
||||
throw new RuntimeException('cURL 初始化失败');
|
||||
}
|
||||
|
||||
try {
|
||||
curl_setopt_array($this->ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HEADER => false,
|
||||
CURLOPT_TIMEOUT => self::CURL_TIMEOUT_SECONDS,
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
|
||||
CURLOPT_COOKIEJAR => $this->cookieFile,
|
||||
CURLOPT_COOKIEFILE => $this->cookieFile,
|
||||
]);
|
||||
|
||||
$this->authenticate();
|
||||
|
||||
$firstPageData = $this->fetchApiData($this->buildApiUrl(1));
|
||||
$this->unifiedData->total = (int) ($firstPageData['count'] ?? 0);
|
||||
$this->unifiedData->todayNewCount = (int) ($firstPageData['totalRow']['day_sum'] ?? 0);
|
||||
|
||||
$totalPages = (int) ceil($this->unifiedData->total / self::DEFAULT_PER_PAGE_COUNT);
|
||||
$allListPagesData = [$firstPageData['data'] ?? []];
|
||||
|
||||
for ($page = 2; $page <= $totalPages; $page++) {
|
||||
$pageData = $this->fetchApiData($this->buildApiUrl($page));
|
||||
$allListPagesData[] = $pageData['data'] ?? [];
|
||||
}
|
||||
|
||||
return $this->parseToUnifiedData($allListPagesData);
|
||||
} finally {
|
||||
$this->cleanup();
|
||||
}
|
||||
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
/**
|
||||
* 访问授权页面,建立会话 Cookie
|
||||
*/
|
||||
private function authenticate(): void
|
||||
{
|
||||
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
$this->sendRequest($this->pageUrl);
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
/**
|
||||
* 请求 API 并解析 JSON
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fetchApiData(string $apiPath): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.layui-laypage-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
$response = $this->sendRequest($this->baseUrl . $apiPath);
|
||||
$data = json_decode($response, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new RuntimeException(
|
||||
'JSON 解析失败: ' . json_last_error_msg() . '。原始响应: ' . mb_substr($response, 0, 500, 'UTF-8')
|
||||
);
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('API 返回数据格式无效');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
/**
|
||||
* @param list<mixed> $allListPagesData
|
||||
*/
|
||||
private function parseToUnifiedData(array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data'] ?? [];
|
||||
|
||||
foreach ($allListPagesData as $records) {
|
||||
if (!is_array($records)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['user'])) {
|
||||
if (!is_array($item) || empty($item['user'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['user'];
|
||||
@@ -88,6 +157,88 @@ class XingheSpider extends AbstractScrmSpider
|
||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
return $unifiedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 cURL 请求
|
||||
*/
|
||||
private function sendRequest(string $url): string
|
||||
{
|
||||
if ($this->ch === null) {
|
||||
throw new RuntimeException('cURL 句柄未初始化');
|
||||
}
|
||||
|
||||
curl_setopt($this->ch, CURLOPT_URL, $url);
|
||||
$response = curl_exec($this->ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new RuntimeException(sprintf('请求失败 [%s]: %s', $url, curl_error($this->ch)));
|
||||
}
|
||||
|
||||
$httpCode = (int) curl_getinfo($this->ch, CURLINFO_HTTP_CODE);
|
||||
if ($httpCode >= 400) {
|
||||
throw new RuntimeException(sprintf('HTTP 请求异常 [%s],状态码: %d', $url, $httpCode));
|
||||
}
|
||||
|
||||
return (string) $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建分页 API 路径(相对 baseUrl)
|
||||
*/
|
||||
private function buildApiUrl(int $page): string
|
||||
{
|
||||
return sprintf(
|
||||
'%s?page=%d&limit=%d&id=&class_id=&is_repet=1&start_time=&end_time=',
|
||||
self::API_PATH,
|
||||
$page,
|
||||
self::DEFAULT_PER_PAGE_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从授权页 URL 解析站点根地址
|
||||
*/
|
||||
private function resolveBaseUrl(string $pageUrl): string
|
||||
{
|
||||
$parsedUrl = parse_url($pageUrl);
|
||||
if ($parsedUrl === false || empty($parsedUrl['host'])) {
|
||||
throw new RuntimeException('工单链接格式无效,无法解析域名');
|
||||
}
|
||||
|
||||
$scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : 'http://';
|
||||
$port = isset($parsedUrl['port']) ? $parsedUrl['port'] : '';
|
||||
if($port !== '' && $port !== 80 && $port !== 443) {
|
||||
$port = ':' . $port;
|
||||
}
|
||||
return $scheme . $parsedUrl['host'] . $port;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放 cURL 资源并删除临时 Cookie 文件
|
||||
*/
|
||||
private function cleanup(): void
|
||||
{
|
||||
if ($this->ch !== null) {
|
||||
if (is_resource($this->ch) || $this->ch instanceof \CurlHandle) {
|
||||
curl_close($this->ch);
|
||||
}
|
||||
$this->ch = null;
|
||||
}
|
||||
|
||||
if ($this->cookieFile !== null && $this->cookieFile !== '' && is_file($this->cookieFile)) {
|
||||
@unlink($this->cookieFile);
|
||||
}
|
||||
$this->cookieFile = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底清理:防止 run() 异常退出时资源泄漏
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
$this->cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* 星河云控蜘蛛
|
||||
*/
|
||||
class XingheSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/share/share/api_yinliu_count.html';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 10;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'listMethod' => 'GET',
|
||||
'paginationMode' => self::MODE_FETCH,
|
||||
'authActions' => [
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
$total = (int) ($listFirstPageData['count'] ?? 0);
|
||||
$this->unifiedData->total = $total;
|
||||
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0);
|
||||
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
||||
return 1;
|
||||
}
|
||||
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.layui-laypage-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['user'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['user'];
|
||||
$isOnline = isset($item['online']) && (int) $item['online'] === 1;
|
||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0));
|
||||
}
|
||||
}
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 定时同步全局互斥锁,防止每分钟 cron 重叠执行打满 Node 队列
|
||||
*/
|
||||
class SplitCronLockService
|
||||
{
|
||||
private const LOCK_FILE = 'cron.lock';
|
||||
|
||||
/**
|
||||
* 尝试获取全局 cron 锁
|
||||
*/
|
||||
public function acquire(): bool
|
||||
{
|
||||
$path = $this->lockPath();
|
||||
if ($this->isStaleLock($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
if (is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
$payload = json_encode([
|
||||
'pid' => getmypid(),
|
||||
'time' => time(),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
$written = @file_put_contents($path, $payload, LOCK_EX);
|
||||
return $written !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放全局 cron 锁
|
||||
*/
|
||||
public function release(): void
|
||||
{
|
||||
$path = $this->lockPath();
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function lockPath(): string
|
||||
{
|
||||
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
||||
$dir = $runtime . 'split_ticket_sync/';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
return $dir . self::LOCK_FILE;
|
||||
}
|
||||
|
||||
private function isStaleLock(string $path): bool
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
$mtime = (int) @filemtime($path);
|
||||
$ttl = SplitSyncConfigService::getCronLockTtlSeconds();
|
||||
return $mtime > 0 && (time() - $mtime) > $ttl;
|
||||
}
|
||||
}
|
||||
Regular → Executable
+13
-7
@@ -12,13 +12,13 @@ class SplitFriendUrlBuilder
|
||||
/**
|
||||
* 构建跳转 URL;无法构建时返回空字符串
|
||||
*
|
||||
* @param string $whatsAppReplyText 仅 WhatsApp 类型使用,预填消息文案(urlencode 在内部处理)
|
||||
* @param string $replyText WhatsApp / Telegram 预填消息文案(内部 rawurlencode)
|
||||
*/
|
||||
public static function build(
|
||||
string $numberType,
|
||||
string $number,
|
||||
string $numberTypeCustom = '',
|
||||
string $whatsAppReplyText = ''
|
||||
string $replyText = ''
|
||||
): string {
|
||||
$number = trim($number);
|
||||
if ($number === '') {
|
||||
@@ -27,9 +27,9 @@ class SplitFriendUrlBuilder
|
||||
|
||||
switch ($numberType) {
|
||||
case 'whatsapp':
|
||||
return self::buildWhatsApp($number, $whatsAppReplyText);
|
||||
return self::buildWhatsApp($number, $replyText);
|
||||
case 'telegram':
|
||||
return self::buildTelegram($number);
|
||||
return self::buildTelegram($number, $replyText);
|
||||
case 'line':
|
||||
return self::buildLine($number);
|
||||
case 'custom':
|
||||
@@ -59,16 +59,22 @@ class SplitFriendUrlBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram:https://t.me/+ 号码(去掉前导 +)
|
||||
* Telegram:https://t.me/+ 号码(去掉前导 +),可选 ?text= 预填消息(须 URL 编码)
|
||||
*/
|
||||
private static function buildTelegram(string $number): string
|
||||
private static function buildTelegram(string $number, string $replyText = ''): string
|
||||
{
|
||||
$id = ltrim($number, '+');
|
||||
if ($id === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'https://t.me/+' . $id;
|
||||
$url = 'https://t.me/+' . $id;
|
||||
$replyText = trim($replyText);
|
||||
if ($replyText !== '') {
|
||||
$url .= '?text=' . rawurlencode($replyText);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* Node Puppeteer 服务健康与队列负载探测
|
||||
*/
|
||||
class SplitNodeHealthService
|
||||
{
|
||||
/**
|
||||
* 拉取 /api/stats,失败返回 null(不阻断同步,由后续请求快速失败)
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function fetchStats(): ?array
|
||||
{
|
||||
$url = SplitSyncConfigService::getNodeHost() . '/api/stats';
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
|
||||
$response = curl_exec($ch);
|
||||
if (curl_errno($ch)) {
|
||||
curl_close($ch);
|
||||
return null;
|
||||
}
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($httpCode !== 200) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode((string) $response, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前是否适合向 Node 提交新 Browser 任务
|
||||
*/
|
||||
public static function canAcceptWork(): bool
|
||||
{
|
||||
$stats = self::fetchStats();
|
||||
if ($stats === null) {
|
||||
return true;
|
||||
}
|
||||
if (!empty($stats['shuttingDown'])) {
|
||||
return false;
|
||||
}
|
||||
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
|
||||
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
||||
$queued = (int) ($queue['queued'] ?? 0);
|
||||
$active = (int) ($queue['active'] ?? 0);
|
||||
$max = max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4)));
|
||||
$threshold = SplitSyncConfigService::getNodeQueueSkipThreshold();
|
||||
if ($queued >= $threshold) {
|
||||
return false;
|
||||
}
|
||||
return $active < $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{queued:int,active:int,max:int}
|
||||
*/
|
||||
public static function getQueueSnapshot(): array
|
||||
{
|
||||
$stats = self::fetchStats();
|
||||
if ($stats === null) {
|
||||
return ['queued' => 0, 'active' => 0, 'max' => 0];
|
||||
}
|
||||
$queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : [];
|
||||
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
||||
return [
|
||||
'queued' => (int) ($queue['queued'] ?? 0),
|
||||
'active' => (int) ($queue['active'] ?? 0),
|
||||
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Node 侧 Real Browser / Captcha 是否就绪(供同步失败日志可读性)
|
||||
*
|
||||
* @return array{realBrowserReady:bool,captchaConfigured:bool}
|
||||
*/
|
||||
public static function getAntiBotSnapshot(): array
|
||||
{
|
||||
$stats = self::fetchStats();
|
||||
if ($stats === null) {
|
||||
return ['realBrowserReady' => false, 'captchaConfigured' => false];
|
||||
}
|
||||
return [
|
||||
'realBrowserReady' => !empty($stats['realBrowserReady']),
|
||||
'captchaConfigured' => !empty($stats['captchaConfigured']),
|
||||
];
|
||||
}
|
||||
}
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+4
-4
@@ -72,16 +72,16 @@ class SplitRedirectService
|
||||
? (string) ($picked['number_type_custom'] ?? '')
|
||||
: (string) $picked->getAttr('number_type_custom');
|
||||
|
||||
$whatsAppReplyText = '';
|
||||
if ($numberType === 'whatsapp') {
|
||||
$whatsAppReplyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply'));
|
||||
$replyText = '';
|
||||
if (in_array($numberType, ['whatsapp', 'telegram'], true)) {
|
||||
$replyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply'));
|
||||
}
|
||||
|
||||
$redirectUrl = SplitFriendUrlBuilder::build(
|
||||
$numberType,
|
||||
$numberValue,
|
||||
$numberCustom,
|
||||
$whatsAppReplyText
|
||||
$replyText
|
||||
);
|
||||
if ($redirectUrl === '') {
|
||||
return null;
|
||||
|
||||
Regular → Executable
Regular → Executable
+14
@@ -6,9 +6,11 @@ namespace app\common\service;
|
||||
|
||||
use app\common\library\scrm\ScrmSpiderInterface;
|
||||
use app\common\library\scrm\spider\A2cSpider;
|
||||
use app\common\library\scrm\spider\ChatknowSpider;
|
||||
use app\common\library\scrm\spider\HaiwangSpider;
|
||||
use app\common\library\scrm\spider\HuojianSpider;
|
||||
use app\common\library\scrm\spider\SsCustomerSpider;
|
||||
use app\common\library\scrm\spider\WhatshubSpider;
|
||||
use app\common\library\scrm\spider\XingheSpider;
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,8 @@ class SplitScrmSpiderFactory
|
||||
'huojian' => HuojianSpider::class,
|
||||
'xinghe' => XingheSpider::class,
|
||||
'ss_customer' => SsCustomerSpider::class,
|
||||
'chatknow' => ChatknowSpider::class,
|
||||
'whatshub' => WhatshubSpider::class,
|
||||
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
||||
];
|
||||
|
||||
@@ -45,6 +49,16 @@ class SplitScrmSpiderFactory
|
||||
return self::resolveClass($ticketType) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已注册且已实现蜘蛛的类型列表
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function listSupportedTypes(): array
|
||||
{
|
||||
return array_keys(self::MAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ScrmSpiderInterface|null
|
||||
*/
|
||||
|
||||
Regular → Executable
+36
@@ -50,6 +50,42 @@ class SplitSyncConfigService
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次 cron 最多处理几条到期工单,避免一分钟内打满 Node Browser 槽位
|
||||
*/
|
||||
public static function getMaxTicketsPerCronRun(): int
|
||||
{
|
||||
$value = self::getConfigValue('split_sync_max_per_cron');
|
||||
if ($value === '') {
|
||||
return 2;
|
||||
}
|
||||
return max(1, min(20, (int) $value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 cron 锁过期秒数,防止异常退出后永久占锁
|
||||
*/
|
||||
public static function getCronLockTtlSeconds(): int
|
||||
{
|
||||
$value = self::getConfigValue('split_sync_cron_lock_ttl');
|
||||
if ($value === '') {
|
||||
return 1800;
|
||||
}
|
||||
return max(300, (int) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Node 排队数达到该阈值时,本轮 cron 不再提交新任务
|
||||
*/
|
||||
public static function getNodeQueueSkipThreshold(): int
|
||||
{
|
||||
$value = self::getConfigValue('split_sync_node_queue_threshold');
|
||||
if ($value === '') {
|
||||
return 2;
|
||||
}
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
private static function getConfigValue(string $name): string
|
||||
{
|
||||
$site = Config::get('site.' . $name);
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+55
-7
@@ -30,9 +30,10 @@ class SplitTicketSyncService
|
||||
/**
|
||||
* 同步单条工单
|
||||
*
|
||||
* @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入)
|
||||
* @return array{success:bool,message:string,skipped?:bool}
|
||||
*/
|
||||
public function syncOne(int $ticketId, bool $force = false): array
|
||||
public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false): array
|
||||
{
|
||||
$ticket = Ticket::get($ticketId);
|
||||
if (!$ticket) {
|
||||
@@ -50,7 +51,7 @@ class SplitTicketSyncService
|
||||
'nodeHost' => SplitSyncConfigService::getNodeHost(),
|
||||
]);
|
||||
|
||||
if (!$force) {
|
||||
if (!$force && !$dueValidated) {
|
||||
$skip = $this->shouldSkip($ticket);
|
||||
if ($skip !== null) {
|
||||
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => $skip]);
|
||||
@@ -76,23 +77,47 @@ class SplitTicketSyncService
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描到期工单并同步
|
||||
* 扫描到期工单并同步(限流 + Node 队列感知)
|
||||
*/
|
||||
public function syncDueTickets(): int
|
||||
{
|
||||
$count = 0;
|
||||
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
|
||||
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
|
||||
if ($autoSyncTypes === []) {
|
||||
SplitTicketSyncLogger::log('cron', 'scan skip', ['reason' => 'no auto-sync ticket types']);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!SplitNodeHealthService::canAcceptWork()) {
|
||||
SplitTicketSyncLogger::log('cron', 'scan skip', [
|
||||
'reason' => 'node queue busy',
|
||||
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||
]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||
$query = Ticket::where('status', 'normal');
|
||||
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
|
||||
if ($failThreshold > 0) {
|
||||
$query->where('sync_fail_count', '<', $failThreshold);
|
||||
}
|
||||
$list = $query->select();
|
||||
// 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单
|
||||
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
|
||||
->limit($maxPerRun * 5)
|
||||
->select();
|
||||
|
||||
SplitTicketSyncLogger::log('cron', 'scan start', [
|
||||
'candidateCount' => count($list),
|
||||
'maxPerRun' => $maxPerRun,
|
||||
'autoSyncTypes' => $autoSyncTypes,
|
||||
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||
]);
|
||||
|
||||
$count = 0;
|
||||
foreach ($list as $ticket) {
|
||||
if ($count >= $maxPerRun) {
|
||||
break;
|
||||
}
|
||||
$skip = $this->shouldSkip($ticket);
|
||||
if ($skip !== null) {
|
||||
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
|
||||
@@ -102,7 +127,14 @@ class SplitTicketSyncService
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$result = $this->syncOne((int) $ticket['id'], false);
|
||||
if (!SplitNodeHealthService::canAcceptWork()) {
|
||||
SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [
|
||||
'processed' => $count,
|
||||
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
$result = $this->syncOne((int) $ticket['id'], false, true);
|
||||
if (!empty($result['skipped'])) {
|
||||
continue;
|
||||
}
|
||||
@@ -113,6 +145,22 @@ class SplitTicketSyncService
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已配置自动同步周期且已实现蜘蛛的工单类型
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveAutoSyncTicketTypes(): array
|
||||
{
|
||||
$types = [];
|
||||
foreach (SplitScrmSpiderFactory::listSupportedTypes() as $ticketType) {
|
||||
if (SplitSyncConfigService::getIntervalMinutes($ticketType) > 0) {
|
||||
$types[] = $ticketType;
|
||||
}
|
||||
}
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success:bool,message:string}
|
||||
*/
|
||||
|
||||
Regular → Executable
Reference in New Issue
Block a user