修复随机号码前先按权重随机工单,并修复降权号码筛选与清理

This commit is contained in:
root
2026-07-04 22:09:39 +08:00
parent 9b57b0c400
commit 6b72cd7b67
37 changed files with 2201 additions and 473 deletions
@@ -4,15 +4,19 @@ declare(strict_types=1);
namespace app\common\service;
use app\common\library\scrm\AntiBotConfigBuilder;
use think\Db;
/**
* 工单手工同步 CLI 后台投递
* 工单同步 CLI 后台投递
*
* Web 请求仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
* 避免长时间占用 PHP-FPM 与 Session 锁导致后台其它页面无法打开
* Web / Cron 仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
* 避免长时间占用 PHP-FPM、Cron 全局锁或 Session 锁
*
* Node 类型(A2C / Whatshub 等)在同一 nohup 子 shell 内串行执行,避免并行 Chrome 压垮温池。
* 并行策略:
* - API 优先类型(Whatshub 等):独立并行投递,不占 Node Real Browser 槽位
* - 直连 PHP 类型:独立并行投递
* - Node 类型:不同 sessionKey(域名)并行;同一 sessionKey 内串行以命中 Browser 温池
*/
class SplitTicketSyncDispatchService
{
@@ -24,75 +28,91 @@ class SplitTicketSyncDispatchService
*/
public function dispatchManual(array $ticketIds): array
{
$queued = [];
$lockService = new SplitTicketSyncLockService();
$metaList = $this->loadTicketMetaList($ticketIds);
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
$toDispatch = [];
$skipped = [];
$failed = [];
$php = $this->resolvePhpBinary();
$think = $this->resolveThinkScript();
$root = $this->resolveRootPath();
$lockService = new SplitTicketSyncLockService();
$nodeTypeMap = array_flip(SplitScrmSpiderFactory::listNodeSyncTypes());
$typeMap = $this->loadTicketTypeMap($ticketIds);
/** @var int[] $nodeTicketIds */
$nodeTicketIds = [];
/** @var int[] $directTicketIds */
$directTicketIds = [];
foreach ($ticketIds as $rawId) {
$ticketId = (int) $rawId;
if ($ticketId <= 0) {
continue;
}
foreach ($metaList as $meta) {
$ticketId = (int) $meta['id'];
if ($lockService->isLocked($ticketId)) {
$skipped[] = $ticketId;
continue;
}
$ticketType = (string) ($typeMap[$ticketId] ?? '');
if ($ticketType !== '' && isset($nodeTypeMap[$ticketType])) {
$nodeTicketIds[] = $ticketId;
} else {
$directTicketIds[] = $ticketId;
}
$toDispatch[] = $meta;
}
foreach ($directTicketIds as $ticketId) {
if ($this->spawnCli($php, $think, $root, $ticketId)) {
$queued[] = $ticketId;
} else {
$failed[] = $ticketId;
}
}
if ($nodeTicketIds !== []) {
$spawned = $this->spawnCliSequential($php, $think, $root, $nodeTicketIds);
if ($spawned) {
foreach ($nodeTicketIds as $ticketId) {
$queued[] = $ticketId;
}
} else {
foreach ($nodeTicketIds as $ticketId) {
$failed[] = $ticketId;
}
}
}
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'manual');
SplitTicketSyncLogger::log('web', 'manual sync dispatched', [
'queued' => $queued,
'skipped' => $skipped,
'failed' => $failed,
'nodeSerial' => $nodeTicketIds,
'directParallel' => $directTicketIds,
'phpCli' => $php,
'queued' => $dispatchResult['queued'],
'skipped' => $skipped,
'failed' => $dispatchResult['failed'],
'phpCli' => $this->resolvePhpBinary(),
]);
return [
'queued' => $queued,
'queued' => $dispatchResult['queued'],
'skipped' => $skipped,
'failed' => $failed,
'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'],
];
}
@@ -117,10 +137,181 @@ class SplitTicketSyncDispatchService
}
/**
* @param int[] $ticketIds
* @return array<int, string> ticketId => ticket_type
* @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 loadTicketTypeMap(array $ticketIds): array
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) {
@@ -135,42 +326,72 @@ class SplitTicketSyncDispatchService
$rows = Db::name('split_ticket')
->where('id', 'in', $ids)
->field('id,ticket_type')
->field('id,ticket_type,ticket_url')
->select();
$map = [];
$list = [];
foreach ($rows as $row) {
$map[(int) $row['id']] = (string) ($row['ticket_type'] ?? '');
$list[] = [
'id' => (int) $row['id'],
'ticket_type' => (string) ($row['ticket_type'] ?? ''),
'ticket_url' => (string) ($row['ticket_url'] ?? ''),
];
}
return $map;
return $list;
}
/**
* 后台启动 split:sync-tickets CLI(单工单,并行投递)
* @param array<string,mixed>|object $ticket
* @return array{id:int,ticket_type:string,ticket_url:string}|null
*/
private function spawnCli(string $php, string $think, string $root, int $ticketId): bool
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 >> %s 2>&1',
'%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, 'parallel');
return $this->runBackgroundCommand($root, $inner, $ticketId, $source . '-parallel');
}
/**
* Node 类型工单:单条 nohup 内串行执行多个 CLI,避免并行 Chrome
* 同一 sessionKey 内串行执行多个 CLI(单 nohup 子 shell
*
* @param int[] $ticketIds
*/
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds): bool
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds, string $source): bool
{
if ($ticketIds === []) {
return false;
@@ -184,11 +405,13 @@ class SplitTicketSyncDispatchService
continue;
}
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
$syncMode = $this->normalizeSyncModeForCli($source);
$parts[] = sprintf(
'%s %s split:sync-tickets --ticket=%d >> %s 2>&1',
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
escapeshellarg($php),
escapeshellarg($think),
$ticketId,
$syncMode,
escapeshellarg($logFile)
);
}
@@ -198,7 +421,7 @@ class SplitTicketSyncDispatchService
$inner = implode('; ', $parts);
return $this->runBackgroundCommand($root, $inner, $ticketIds, 'node-serial');
return $this->runBackgroundCommand($root, $inner, $ticketIds, $source . '-node-serial');
}
/**
@@ -214,7 +437,7 @@ class SplitTicketSyncDispatchService
if ($this->canUseExec()) {
@exec($command, $output, $exitCode);
SplitTicketSyncLogger::log('web', 'cli spawn exec', [
SplitTicketSyncLogger::log('dispatch', 'cli spawn exec', [
'ticketRef' => $ticketRef,
'mode' => $mode,
'command' => $command,
@@ -225,7 +448,7 @@ class SplitTicketSyncDispatchService
if ($this->canUseShellExec()) {
@shell_exec($command);
SplitTicketSyncLogger::log('web', 'cli spawn shell_exec', [
SplitTicketSyncLogger::log('dispatch', 'cli spawn shell_exec', [
'ticketRef' => $ticketRef,
'mode' => $mode,
'command' => $command,
@@ -233,7 +456,7 @@ class SplitTicketSyncDispatchService
return true;
}
SplitTicketSyncLogger::log('web', 'cli spawn failed: exec disabled', [
SplitTicketSyncLogger::log('dispatch', 'cli spawn failed: exec disabled', [
'ticketRef' => $ticketRef,
'mode' => $mode,
]);
@@ -333,4 +556,12 @@ class SplitTicketSyncDispatchService
{
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';
}
}