2026-06-20 04:47:34 +08:00
|
|
|
<?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 任务
|
2026-07-03 20:51:56 +08:00
|
|
|
*
|
|
|
|
|
* A2C 等 antiBot 任务走 real profile,应参考 queueReal 而非 standard 队列。
|
2026-06-20 04:47:34 +08:00
|
|
|
*/
|
|
|
|
|
public static function canAcceptWork(): bool
|
|
|
|
|
{
|
2026-07-04 22:09:39 +08:00
|
|
|
return self::canAcceptWorkForTicketType('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 指定工单类型是否可向 Node 提交新任务
|
|
|
|
|
*
|
|
|
|
|
* API 优先类型(如 Whatshub)常态不占 Real Browser,调度层应跳过队列门禁。
|
|
|
|
|
*/
|
|
|
|
|
public static function canAcceptWorkForTicketType(string $ticketType): bool
|
|
|
|
|
{
|
|
|
|
|
if ($ticketType !== '' && SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
$stats = self::fetchStats();
|
|
|
|
|
if ($stats === null) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (!empty($stats['shuttingDown'])) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-07-03 20:51:56 +08:00
|
|
|
|
|
|
|
|
$snapshot = self::resolveQueueSnapshot($stats);
|
2026-06-20 04:47:34 +08:00
|
|
|
$threshold = SplitSyncConfigService::getNodeQueueSkipThreshold();
|
2026-07-03 20:51:56 +08:00
|
|
|
if ($snapshot['queued'] >= $threshold) {
|
2026-06-20 04:47:34 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-07-03 20:51:56 +08:00
|
|
|
|
|
|
|
|
return $snapshot['active'] < $snapshot['max'];
|
2026-06-20 04:47:34 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-03 20:51:56 +08:00
|
|
|
* @return array{queued:int,active:int,max:int,profile:string}
|
2026-06-20 04:47:34 +08:00
|
|
|
*/
|
|
|
|
|
public static function getQueueSnapshot(): array
|
|
|
|
|
{
|
|
|
|
|
$stats = self::fetchStats();
|
|
|
|
|
if ($stats === null) {
|
2026-07-03 20:51:56 +08:00
|
|
|
return ['queued' => 0, 'active' => 0, 'max' => 0, 'profile' => 'real'];
|
2026-06-20 04:47:34 +08:00
|
|
|
}
|
2026-07-03 20:51:56 +08:00
|
|
|
|
|
|
|
|
$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
|
|
|
|
|
{
|
2026-06-20 04:47:34 +08:00
|
|
|
$config = is_array($stats['config'] ?? null) ? $stats['config'] : [];
|
2026-07-03 20:51:56 +08:00
|
|
|
$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'] : [];
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
return [
|
2026-07-03 20:51:56 +08:00
|
|
|
'queued' => (int) ($queue['queued'] ?? 0),
|
|
|
|
|
'active' => (int) ($queue['active'] ?? 0),
|
|
|
|
|
'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))),
|
|
|
|
|
'profile' => 'standard',
|
2026-06-20 04:47:34 +08:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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']),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|