Files
links/application/common/service/SplitTicketSyncService.php
T

881 lines
33 KiB
PHP
Raw Normal View History

2026-06-09 03:36:30 +08:00
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Ticket;
use app\common\library\scrm\AntiBotConfigBuilder;
2026-06-09 03:36:30 +08:00
use app\common\library\scrm\UnifiedScrmData;
use think\Db;
use think\Exception;
/**
* 分流工单云控数据同步服务
*/
class SplitTicketSyncService
{
private SplitTicketNumberSyncService $numberSync;
private SplitTicketRuleService $ruleService;
private SplitTicketSyncLockService $lockService;
public function __construct()
{
$this->numberSync = new SplitTicketNumberSyncService();
$this->ruleService = new SplitTicketRuleService();
$this->lockService = new SplitTicketSyncLockService();
}
/**
* 同步单条工单
*
* @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入)
* @param string $syncMode auto=定时自动 manual=手动触发
2026-06-09 03:36:30 +08:00
* @return array{success:bool,message:string,skipped?:bool}
*/
public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false, string $syncMode = 'manual'): array
2026-06-09 03:36:30 +08:00
{
$ticket = Ticket::get($ticketId);
if (!$ticket) {
SplitTicketSyncLogger::log('sync', 'ticket not found', ['ticketId' => $ticketId]);
2026-06-09 03:36:30 +08:00
return ['success' => false, 'message' => '工单不存在'];
}
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
SplitTicketSyncLogger::log('sync', 'syncOne start', [
'force' => $force,
'syncMode' => $syncMode,
'status' => (string) $ticket['status'],
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
'pageUrl' => (string) $ticket['ticket_url'],
'nodeHost' => SplitSyncConfigService::getNodeHost(),
]);
2026-06-20 04:47:34 +08:00
if (!$force && !$dueValidated) {
2026-06-09 03:36:30 +08:00
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => $skip]);
SplitTicketSyncLogger::clearTicketContext();
2026-06-09 03:36:30 +08:00
return ['success' => false, 'message' => $skip, 'skipped' => true];
}
}
if (!$this->lockService->acquire($ticketId)) {
SplitTicketSyncLogger::log('sync', 'lock busy', ['ticketId' => $ticketId]);
SplitTicketSyncLogger::clearTicketContext();
2026-06-09 03:36:30 +08:00
return ['success' => false, 'message' => '工单正在同步中', 'skipped' => true];
}
try {
$result = $this->doSync($ticket, $syncMode);
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
return $result;
2026-06-09 03:36:30 +08:00
} finally {
$this->lockService->release($ticketId);
SplitTicketSyncLogger::clearTicketContext();
2026-06-09 03:36:30 +08:00
}
}
/**
* 扫描到期工单并同步(直连 PHP 与 Node 双通道)
2026-06-09 03:36:30 +08:00
*/
public function syncDueTickets(): int
{
2026-06-20 04:47:34 +08:00
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
if ($autoSyncTypes === []) {
SplitTicketSyncLogger::logCron('scan skip', ['reason' => 'no auto-sync ticket types']);
2026-06-20 04:47:34 +08:00
return 0;
}
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
$directTypes = array_values(array_intersect(
$autoSyncTypes,
SplitScrmSpiderFactory::listDirectSyncTypes()
));
$nodeTypes = array_values(array_intersect(
$autoSyncTypes,
SplitScrmSpiderFactory::listNodeSyncTypes()
));
2026-06-20 04:47:34 +08:00
SplitTicketSyncLogger::logCron('scan start', [
'autoSyncTypes' => $autoSyncTypes,
'directTypes' => $directTypes,
'nodeTypes' => $nodeTypes,
'failThreshold' => $failThreshold,
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
]);
$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['success']);
}));
$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 通道:按周期同步,不占 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);
SplitTicketSyncLogger::logCron('direct channel start', [
'types' => $directTypes,
'candidateCount' => count($list),
]);
/** @var list<array<string, mixed>> $processed */
$processed = [];
/** @var list<array<string, mixed>> $skipped */
$skipped = [];
$count = 0;
foreach ($list as $ticket) {
$ticketId = (int) $ticket['id'];
$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;
}
$result = $this->syncOne($ticketId, false, true, 'auto');
if (!empty($result['skipped'])) {
$skipReason = (string) ($result['message'] ?? '已跳过');
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason, 'direct');
continue;
}
$processed[] = [
'ticketId' => $ticketId,
'ticketType' => $ticketType,
'ticketUrl' => $ticketUrl,
'channel' => 'direct',
'success' => !empty($result['success']),
'message' => (string) ($result['message'] ?? ''),
];
$count++;
}
return [
'processedCount' => $count,
'processed' => $processed,
'skipped' => $skipped,
'candidateIds' => $candidateIds,
'summary' => [
'channel' => 'direct',
'processedCount' => $count,
'skippedCount' => count($skipped),
],
'stoppedByNodeBusy' => false,
];
}
/**
* Node 通道:限流 + 类型公平轮转
*
* @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,
2026-06-20 04:47:34 +08:00
'maxPerRun' => $maxPerRun,
'poolCount' => count($pool),
'dueCount' => count($duePool),
'pickedCount' => count($picked),
2026-06-20 04:47:34 +08:00
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
]);
2026-06-09 03:36:30 +08:00
if (!SplitNodeHealthService::canAcceptWork()) {
SplitTicketSyncLogger::logCron('node channel skip', [
'reason' => 'node queue busy',
'queue' => SplitNodeHealthService::getQueueSnapshot(),
]);
$skipped = [];
foreach ($picked as $ticket) {
$skipped[] = $this->buildCronTicketEntry(
(int) $ticket['id'],
(string) $ticket['ticket_type'],
(string) $ticket['ticket_url'],
'Node 队列繁忙,本轮未执行'
);
}
foreach ($pool as $ticket) {
$ticketId = (int) $ticket['id'];
if (isset($pickedIdMap[$ticketId])) {
continue;
}
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
continue;
}
$skipped[] = $this->buildCronTicketEntry(
$ticketId,
(string) $ticket['ticket_type'],
(string) $ticket['ticket_url'],
sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit)
);
}
return [
'processedCount' => 0,
'processed' => [],
'skipped' => $skipped,
'candidateIds' => $candidateIds,
'summary' => [
'channel' => 'node',
'processedCount' => 0,
'skippedCount' => count($skipped),
'reason' => 'node queue busy',
],
'stoppedByNodeBusy' => true,
];
}
/** @var list<array<string, mixed>> $processed */
$processed = [];
/** @var list<array<string, mixed>> $skipped */
$skipped = [];
2026-06-20 04:47:34 +08:00
$count = 0;
$stoppedByNodeBusy = false;
foreach ($pool as $ticket) {
$ticketId = (int) $ticket['id'];
$ticketType = (string) $ticket['ticket_type'];
$ticketUrl = (string) $ticket['ticket_url'];
if (!isset($pickedIdMap[$ticketId])) {
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
continue;
}
$skipped[] = $this->buildCronTicketEntry(
$ticketId,
$ticketType,
$ticketUrl,
sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit)
);
2026-06-09 03:36:30 +08:00
continue;
}
2026-06-20 04:47:34 +08:00
if (!SplitNodeHealthService::canAcceptWork()) {
$stoppedByNodeBusy = true;
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, 'Node 队列繁忙,停止本轮后续同步');
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, 'Node 队列繁忙,停止本轮后续同步', 'node');
continue;
2026-06-20 04:47:34 +08:00
}
$sessionKey = AntiBotConfigBuilder::resolveSessionKey($ticketType, $ticketUrl);
$result = $this->syncOne($ticketId, false, true, 'auto');
2026-06-09 03:36:30 +08:00
if (!empty($result['skipped'])) {
$skipReason = (string) ($result['message'] ?? '已跳过');
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason, 'node');
2026-06-09 03:36:30 +08:00
continue;
}
$processed[] = [
'ticketId' => $ticketId,
'ticketType' => $ticketType,
'ticketUrl' => $ticketUrl,
'sessionKey' => $sessionKey,
'channel' => 'node',
'success' => !empty($result['success']),
'message' => (string) ($result['message'] ?? ''),
];
2026-06-09 03:36:30 +08:00
$count++;
}
return [
'processedCount' => $count,
'processed' => $processed,
'skipped' => $skipped,
'candidateIds' => $candidateIds,
'summary' => [
'channel' => 'node',
'processedCount' => $count,
'skippedCount' => count($skipped),
'maxPerRun' => $maxPerRun,
],
'stoppedByNodeBusy' => $stoppedByNodeBusy,
];
}
/**
* @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')->whereIn('ticket_type', $types);
if ($failThreshold > 0) {
$query->where('sync_fail_count', '<', $failThreshold);
}
$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,
];
2026-06-09 03:36:30 +08:00
}
2026-06-20 04:47:34 +08:00
/**
* 已配置自动同步周期且已实现蜘蛛的工单类型
*
* @return list<string>
*/
private function resolveAutoSyncTicketTypes(): array
{
$types = [];
foreach (SplitScrmSpiderFactory::listSupportedTypes() as $ticketType) {
if (SplitSyncConfigService::getIntervalMinutes($ticketType) > 0) {
$types[] = $ticketType;
}
}
return $types;
}
2026-06-09 03:36:30 +08:00
/**
* @return array{success:bool,message:string}
*/
private function doSync(Ticket $ticket, string $syncMode): array
2026-06-09 03:36:30 +08:00
{
$ticketType = (string) $ticket['ticket_type'];
$pageUrl = trim((string) $ticket['ticket_url']);
if ($pageUrl === '') {
SplitTicketSyncLogger::log('sync', 'empty pageUrl');
2026-06-09 03:36:30 +08:00
$this->markFailure($ticket, '工单链接为空');
return ['success' => false, 'message' => '工单链接为空'];
}
if (!SplitScrmSpiderFactory::isSupported($ticketType)) {
SplitTicketSyncLogger::log('sync', 'spider not supported', ['ticketType' => $ticketType]);
2026-06-09 03:36:30 +08:00
$this->markFailure($ticket, '工单类型尚未实现蜘蛛');
return ['success' => false, 'message' => '工单类型尚未实现蜘蛛'];
}
SplitTicketSyncLogger::log('sync', 'create spider', [
'ticketType' => $ticketType,
'hasAccount' => trim((string) ($ticket['account'] ?? '')) !== '',
]);
2026-06-09 03:36:30 +08:00
$spider = SplitScrmSpiderFactory::create(
$ticketType,
$pageUrl,
(string) ($ticket['account'] ?? ''),
(string) ($ticket['password'] ?? '')
);
if ($spider === null) {
$this->markFailure($ticket, '无法创建蜘蛛实例');
return ['success' => false, 'message' => '无法创建蜘蛛实例'];
}
Db::startTrans();
try {
SplitTicketSyncLogger::log('sync', 'spider run begin');
2026-06-09 03:36:30 +08:00
$finalData = $spider->run();
if (!$finalData instanceof UnifiedScrmData) {
throw new Exception('蜘蛛返回数据无效');
}
$this->numberSync->syncFromUnifiedData($ticket, $finalData);
$completeCount = max(0, $finalData->todayNewCount);
$this->ruleService->applyTicketStatusRules($ticket, $completeCount);
$freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket;
if ((string) $freshTicket['status'] === 'hidden') {
2026-06-09 03:36:30 +08:00
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
} else {
$this->ruleService->applyNumberRules($freshTicket);
2026-06-09 03:36:30 +08:00
}
$ticket = $freshTicket;
2026-06-09 03:36:30 +08:00
$inboundCount = $this->numberSync->sumInboundForTicket($ticket);
$speed = $this->calcSpeedPerHour($ticket, $completeCount);
$payload = [
'complete_count' => $completeCount,
'inbound_count' => $inboundCount,
'speed_per_hour' => $speed['speed'],
'number_count' => max(0, $finalData->total),
'number_offline_count' => max(0, $finalData->totalOffline),
'number_banned_count' => 0,
'online_count' => max(0, $finalData->totalOnline),
'sync_fail_count' => 0,
'speed_snapshot_count' => $speed['snapshot_count'],
'speed_snapshot_time' => $speed['snapshot_time'],
];
$this->applySyncResult($ticket, $payload, true, '', $syncMode);
2026-06-09 03:36:30 +08:00
Db::commit();
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
2026-06-09 03:36:30 +08:00
return ['success' => true, 'message' => '同步成功'];
} catch (\Throwable $e) {
Db::rollback();
$msg = $e->getMessage();
SplitTicketSyncLogger::log('sync', 'exception', [
'type' => get_class($e),
'message' => $msg,
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
2026-06-09 03:36:30 +08:00
$this->markFailure($ticket, $msg);
return ['success' => false, 'message' => $msg];
}
}
private function shouldSkip(Ticket $ticket): ?string
{
if ((string) $ticket['status'] === 'hidden') {
return '工单已关闭';
}
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
2026-06-09 03:36:30 +08:00
}
if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) {
return '工单类型尚未实现';
}
$interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']);
if ($interval <= 0) {
return '该类型未配置自动同步周期';
}
$lastSync = (int) ($ticket['sync_time'] ?? 0);
$elapsed = $lastSync > 0 ? (time() - $lastSync) : null;
if ($lastSync > 0 && $elapsed !== null && $elapsed < ($interval * 60)) {
SplitTicketSyncLogger::log('sync', 'interval not reached', [
'intervalMinutes' => $interval,
'elapsedSeconds' => $elapsed,
'needSeconds' => $interval * 60,
]);
2026-06-09 03:36:30 +08:00
return '未到同步周期';
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = '', string $syncMode = 'manual'): void
2026-06-09 03:36:30 +08:00
{
$now = time();
2026-06-09 03:36:30 +08:00
$data = [
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)),
'speed_per_hour' => max(0, (float) ($payload['speed_per_hour'] ?? 0)),
'number_count' => max(0, (int) ($payload['number_count'] ?? 0)),
'number_offline_count' => max(0, (int) ($payload['number_offline_count'] ?? 0)),
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
'sync_status' => $success ? 'success' : 'error',
'sync_time' => $now,
'sync_message' => $success ? '' : $message,
2026-06-09 03:36:30 +08:00
'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_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';
}
2026-06-09 03:36:30 +08:00
if (!$ticket->allowField(array_keys($data))->save($data)) {
throw new Exception('工单同步结果保存失败');
}
}
private function markFailure(Ticket $ticket, string $message): void
{
$failCount = (int) ($ticket['sync_fail_count'] ?? 0) + 1;
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
$previousSyncStatus = (string) ($ticket['sync_status'] ?? 'pending');
$neverSyncedSuccessfully = $previousSyncStatus === 'pending' && (int) ($ticket['sync_time'] ?? 0) <= 0;
$update = [
2026-06-09 03:36:30 +08:00
'sync_status' => 'error',
'sync_time' => time(),
'sync_message' => $message,
2026-06-09 03:36:30 +08:00
'sync_fail_count' => $failCount,
];
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
$update['status'] = 'hidden';
}
$ticket->save($update);
if (isset($update['status']) && $update['status'] === 'hidden') {
$fresh = Ticket::get((int) $ticket['id']);
if ($fresh) {
$this->ruleService->cascadeTicketClosedToNumbers($fresh);
}
}
2026-06-09 03:36:30 +08:00
}
/**
* @return array{speed:float,snapshot_count:int,snapshot_time:int}
*/
private function calcSpeedPerHour(Ticket $ticket, int $currentComplete): array
{
$now = time();
$snapshotTime = (int) ($ticket['speed_snapshot_time'] ?? 0);
$snapshotCount = (int) ($ticket['speed_snapshot_count'] ?? 0);
if ($snapshotTime <= 0) {
return [
'speed' => 0.0,
'snapshot_count' => $currentComplete,
'snapshot_time' => $now,
];
}
$elapsed = $now - $snapshotTime;
if ($elapsed >= 3600) {
return [
'speed' => 0.0,
'snapshot_count' => $currentComplete,
'snapshot_time' => $now,
];
}
$hours = $elapsed > 0 ? ($elapsed / 3600) : 0;
$delta = $currentComplete - $snapshotCount;
$speed = ($delta < 0 || $hours <= 0) ? 0.0 : round($delta / $hours, 2);
return [
'speed' => $speed,
'snapshot_count' => $snapshotCount,
'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')
->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 ($failThreshold > 0 && $failCount >= $failThreshold) {
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 ?? '未知原因,未进入执行队列';
}
/**
* 按 sessionKeyticketType: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;
}
2026-06-09 03:36:30 +08:00
}