同步失败原因,最近同步成功的时间记录

This commit is contained in:
root
2026-06-20 05:07:11 +08:00
parent d5a0ffa6db
commit e95f4a227c
19 changed files with 884 additions and 74 deletions
@@ -7,7 +7,9 @@ namespace app\common\service;
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
{
@@ -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
*/
public static function log(string $stage, string $message, array $context = []): void
@@ -43,7 +57,14 @@ class SplitTicketSyncLogger
if (!self::isEnabled()) {
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);
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
$line = sprintf(
@@ -83,8 +104,8 @@ class SplitTicketSyncLogger
$out[$key] = self::sanitize($value);
continue;
}
if (is_string($value) && mb_strlen($value) > 800) {
$out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)';
if (is_string($value) && mb_strlen($value) > 2000) {
$out[$key] = mb_substr($value, 0, 2000, 'UTF-8') . '...(truncated)';
continue;
}
$out[$key] = $value;
@@ -30,10 +30,11 @@ class SplitTicketSyncService
/**
* 同步单条工单
*
* @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}
*/
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);
if (!$ticket) {
@@ -44,6 +45,7 @@ class SplitTicketSyncService
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),
@@ -67,7 +69,7 @@ class SplitTicketSyncService
}
try {
$result = $this->doSync($ticket);
$result = $this->doSync($ticket, $syncMode);
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
return $result;
} finally {
@@ -83,65 +85,143 @@ class SplitTicketSyncService
{
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
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', [
SplitTicketSyncLogger::logCron('scan skip', [
'reason' => 'node queue busy',
'queue' => SplitNodeHealthService::getQueueSnapshot(),
]);
return 0;
}
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
if ($failThreshold > 0) {
$query->where('sync_fail_count', '<', $failThreshold);
}
// 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
->limit($maxPerRun * 5)
->select();
$candidateCount = count($list);
SplitTicketSyncLogger::logCron('scan start', [
'candidateCount' => $candidateCount,
'maxPerRun' => $maxPerRun,
'autoSyncTypes' => $autoSyncTypes,
'failThreshold' => $failThreshold,
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
]);
SplitTicketSyncLogger::log('cron', 'scan start', [
'candidateCount' => count($list),
'candidateCount' => $candidateCount,
'maxPerRun' => $maxPerRun,
'autoSyncTypes' => $autoSyncTypes,
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
]);
/** @var list<array<string, mixed>> $processed */
$processed = [];
/** @var list<array<string, mixed>> $skipped */
$skipped = [];
$count = 0;
foreach ($list as $ticket) {
$stoppedByNodeBusy = false;
foreach ($list as $index => $ticket) {
$ticketId = (int) $ticket['id'];
$ticketUrl = (string) $ticket['ticket_url'];
$ticketType = (string) $ticket['ticket_type'];
if ($count >= $maxPerRun) {
break;
}
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
'ticketId' => (int) $ticket['id'],
'ticketType' => (string) $ticket['ticket_type'],
'reason' => $skip,
]);
$reason = '本轮处理上限已满';
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason);
continue;
}
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skip);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip);
continue;
}
if (!SplitNodeHealthService::canAcceptWork()) {
$reason = 'Node 队列繁忙,停止本轮后续同步';
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason);
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason);
$stoppedByNodeBusy = true;
for ($i = $index + 1; $i < $candidateCount; $i++) {
$remain = $list[$i];
$remainReason = 'Node 队列繁忙,本轮未执行';
$skipped[] = $this->buildCronTicketEntry(
(int) $remain['id'],
(string) $remain['ticket_type'],
(string) $remain['ticket_url'],
$remainReason
);
}
SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [
'processed' => $count,
'queue' => SplitNodeHealthService::getQueueSnapshot(),
]);
break;
}
$result = $this->syncOne((int) $ticket['id'], false, true);
$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);
continue;
}
$processed[] = [
'ticketId' => $ticketId,
'ticketType' => $ticketType,
'ticketUrl' => $ticketUrl,
'success' => !empty($result['success']),
'message' => (string) ($result['message'] ?? ''),
];
$count++;
}
SplitTicketSyncLogger::log('cron', 'scan end', ['processedCount' => $count]);
$candidateIds = array_map(static function ($row) {
return (int) $row['id'];
}, $list instanceof \think\Collection ? $list->all() : (array) $list);
$openNotSynced = $this->buildOpenNotSyncedAudit(
$autoSyncTypes,
$failThreshold,
$maxPerRun,
$candidateIds,
$processed,
$skipped,
$stoppedByNodeBusy
);
$successCount = count(array_filter($processed, static function (array $row): bool {
return !empty($row['success']);
}));
$failedCount = count($processed) - $successCount;
SplitTicketSyncLogger::logCron('scan end', [
'processedCount' => $count,
'successCount' => $successCount,
'failedCount' => $failedCount,
'skippedCount' => count($skipped),
'processed' => $processed,
'skipped' => $skipped,
'openNotSynced' => $openNotSynced,
]);
SplitTicketSyncLogger::log('cron', 'scan end', [
'processedCount' => $count,
'successCount' => $successCount,
'failedCount' => $failedCount,
]);
return $count;
}
@@ -164,7 +244,7 @@ class SplitTicketSyncService
/**
* @return array{success:bool,message:string}
*/
private function doSync(Ticket $ticket): array
private function doSync(Ticket $ticket, string $syncMode): array
{
$ticketType = (string) $ticket['ticket_type'];
$pageUrl = trim((string) $ticket['ticket_url']);
@@ -212,7 +292,6 @@ class SplitTicketSyncService
if ((string) $freshTicket['status'] === 'hidden') {
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
}
// 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线)
$this->ruleService->applyNumberRules($freshTicket);
$ticket = $freshTicket;
@@ -232,13 +311,13 @@ class SplitTicketSyncService
'speed_snapshot_time' => $speed['snapshot_time'],
];
$this->applySyncResult($ticket, $payload, true, '');
$this->applySyncResult($ticket, $payload, true, '', $syncMode);
Db::commit();
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
return ['success' => true, 'message' => '同步成功'];
} catch (\Throwable $e) {
Db::rollback();
$msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8');
$msg = $e->getMessage();
SplitTicketSyncLogger::log('sync', 'exception', [
'type' => get_class($e),
'message' => $msg,
@@ -282,8 +361,9 @@ class SplitTicketSyncService
/**
* @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 = [
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)),
@@ -293,12 +373,16 @@ class SplitTicketSyncService
'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' => time(),
'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'),
'sync_time' => $now,
'sync_message' => $success ? '' : $message,
'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';
}
if (!$ticket->allowField(array_keys($data))->save($data)) {
throw new Exception('工单同步结果保存失败');
}
@@ -313,10 +397,9 @@ class SplitTicketSyncService
$update = [
'sync_status' => 'error',
'sync_time' => time(),
'sync_message' => mb_substr($message, 0, 255, 'UTF-8'),
'sync_message' => $message,
'sync_fail_count' => $failCount,
];
// 新建工单首次同步失败:立即关闭;已同步过的工单仍按连续失败阈值关闭
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
$update['status'] = 'hidden';
}
@@ -365,4 +448,129 @@ class SplitTicketSyncService
'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): void
{
$ctx = [
'ticketId' => $ticketId,
'ticketType' => $ticketType,
'ticketUrl' => $ticketUrl,
'reason' => $reason,
];
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 $ticket) {
$ticketId = (int) $ticket['id'];
if (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)])) {
return sprintf('未进入本轮候选队列(仅扫描最久未同步的前%d条)', $maxPerRun * 5);
}
if ($stoppedByNodeBusy) {
return 'Node 队列繁忙,本轮未执行';
}
$skip = $this->shouldSkip(Ticket::get((int) $ticket['id']) ?: new Ticket($ticket));
return $skip ?? '未知原因,未进入执行队列';
}
}