同步失败原因,最近同步成功的时间记录
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
-- 工单同步日志增强:最近成功时间/方式、完整失败原因
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
MODIFY COLUMN `sync_message` text COMMENT '同步失败完整原因',
|
||||||
|
ADD COLUMN `sync_success_time` bigint(16) DEFAULT NULL COMMENT '最近一次同步成功时间' AFTER `sync_time`,
|
||||||
|
ADD COLUMN `sync_success_mode` enum('auto','manual') DEFAULT NULL COMMENT '最近成功同步方式:auto=自动,manual=手动' AFTER `sync_success_time`;
|
||||||
|
|
||||||
|
-- 历史成功记录回填最近成功时间(方式未知留空)
|
||||||
|
UPDATE `fa_split_ticket`
|
||||||
|
SET `sync_success_time` = `sync_time`
|
||||||
|
WHERE `sync_status` = 'success'
|
||||||
|
AND `sync_success_time` IS NULL
|
||||||
|
AND `sync_time` IS NOT NULL
|
||||||
|
AND `sync_time` > 0;
|
||||||
@@ -62,12 +62,13 @@ class SplitSyncTickets extends Command
|
|||||||
try {
|
try {
|
||||||
$count = $service->syncDueTickets();
|
$count = $service->syncDueTickets();
|
||||||
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
||||||
|
$output->writeln('<comment>汇总日志已写入 runtime/log/split_sync.log</comment>');
|
||||||
} finally {
|
} finally {
|
||||||
$cronLock->release();
|
$cronLock->release();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SplitTicketSyncLogger::isEnabled()) {
|
if (SplitTicketSyncLogger::isEnabled()) {
|
||||||
$output->writeln('<comment>调试日志已写入 runtime/log/split_sync.log</comment>');
|
$output->writeln('<comment>详细调试日志已写入 runtime/log/split_sync.log</comment>');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ class Ticket extends Backend
|
|||||||
$params['sync_status'],
|
$params['sync_status'],
|
||||||
$params['sync_time'],
|
$params['sync_time'],
|
||||||
$params['sync_message'],
|
$params['sync_message'],
|
||||||
|
$params['sync_success_time'],
|
||||||
|
$params['sync_success_mode'],
|
||||||
$params['sync_fail_count'],
|
$params['sync_fail_count'],
|
||||||
$params['speed_snapshot_count'],
|
$params['speed_snapshot_count'],
|
||||||
$params['speed_snapshot_time'],
|
$params['speed_snapshot_time'],
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ return [
|
|||||||
'Sync display success' => '同步成功 / 在线 %s',
|
'Sync display success' => '同步成功 / 在线 %s',
|
||||||
'Sync display pending' => '待同步',
|
'Sync display pending' => '待同步',
|
||||||
'Sync display error' => '同步异常',
|
'Sync display error' => '同步异常',
|
||||||
|
'Sync display auto paused' => '自动同步已暂停',
|
||||||
|
'Sync success time' => '最近同步成功',
|
||||||
|
'Sync mode auto' => '自动',
|
||||||
|
'Sync mode manual' => '手动',
|
||||||
|
'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停',
|
||||||
|
'Sync tooltip error prefix' => '异常原因:',
|
||||||
|
'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log',
|
||||||
'Createtime' => '创建时间',
|
'Createtime' => '创建时间',
|
||||||
'Copy' => '拷贝',
|
'Copy' => '拷贝',
|
||||||
'Summary row' => '汇总',
|
'Summary row' => '汇总',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\admin\model\split;
|
namespace app\admin\model\split;
|
||||||
|
|
||||||
|
use app\common\service\SplitSyncConfigService;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Model;
|
use think\Model;
|
||||||
|
|
||||||
@@ -31,6 +32,9 @@ class Ticket extends Model
|
|||||||
'ticket_progress_text',
|
'ticket_progress_text',
|
||||||
'inbound_ratio_text',
|
'inbound_ratio_text',
|
||||||
'sync_display_text',
|
'sync_display_text',
|
||||||
|
'sync_success_text',
|
||||||
|
'sync_tooltip_text',
|
||||||
|
'sync_auto_paused',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,13 +156,35 @@ class Ticket extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步状态展示文案
|
* 是否因连续失败暂停自动同步(工单仍为开启状态)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function isAutoSyncPaused(array $data): bool
|
||||||
|
{
|
||||||
|
if ((string) ($data['status'] ?? '') !== 'normal') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
if ($threshold <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (int) ($data['sync_fail_count'] ?? 0) >= $threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态展示文案(列表列默认显示,异常详情见 tooltip)
|
||||||
*
|
*
|
||||||
* @param mixed $value
|
* @param mixed $value
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
public function getSyncDisplayTextAttr($value, $data): string
|
public function getSyncDisplayTextAttr($value, $data): string
|
||||||
{
|
{
|
||||||
|
if (self::isAutoSyncPaused($data)) {
|
||||||
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
return sprintf((string) __('Sync display auto paused'), $threshold);
|
||||||
|
}
|
||||||
|
|
||||||
$status = (string) ($data['sync_status'] ?? 'pending');
|
$status = (string) ($data['sync_status'] ?? 'pending');
|
||||||
$online = (int) ($data['online_count'] ?? 0);
|
$online = (int) ($data['online_count'] ?? 0);
|
||||||
if ($status === 'success') {
|
if ($status === 'success') {
|
||||||
@@ -170,6 +196,66 @@ class Ticket extends Model
|
|||||||
return (string) __('Sync display error');
|
return (string) __('Sync display error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态列 tooltip 完整说明
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncTooltipTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
if (self::isAutoSyncPaused($data)) {
|
||||||
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
$parts[] = sprintf(
|
||||||
|
(string) __('Sync tooltip auto paused'),
|
||||||
|
(int) ($data['sync_fail_count'] ?? 0),
|
||||||
|
$threshold
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = trim((string) ($data['sync_message'] ?? ''));
|
||||||
|
if ($message !== '') {
|
||||||
|
$parts[] = (string) __('Sync tooltip error prefix') . $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否暂停自动同步(供前端样式判断)
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncAutoPausedAttr($value, $data): bool
|
||||||
|
{
|
||||||
|
return self::isAutoSyncPaused($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次同步成功时间及方式
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncSuccessTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$ts = (int) ($data['sync_success_time'] ?? 0);
|
||||||
|
if ($ts <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$timeText = date('Y-m-d H:i:s', $ts);
|
||||||
|
$mode = (string) ($data['sync_success_mode'] ?? '');
|
||||||
|
if ($mode === 'auto') {
|
||||||
|
return $timeText . ' (' . (string) __('Sync mode auto') . ')';
|
||||||
|
}
|
||||||
|
if ($mode === 'manual') {
|
||||||
|
return $timeText . ' (' . (string) __('Sync mode manual') . ')';
|
||||||
|
}
|
||||||
|
return $timeText;
|
||||||
|
}
|
||||||
|
|
||||||
public function setStartTimeAttr($value): ?int
|
public function setStartTimeAttr($value): ?int
|
||||||
{
|
{
|
||||||
return self::parseTimeValue($value);
|
return self::parseTimeValue($value);
|
||||||
|
|||||||
@@ -67,6 +67,24 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
.split-sync-success-normal {
|
||||||
|
color: #3c763d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.split-sync-success-stopped {
|
||||||
|
color: #a94442;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.split-ticket-sync-status-paused {
|
||||||
|
color: #8a6d3b;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted #8a6d3b;
|
||||||
|
}
|
||||||
|
.split-ticket-sync-status-error {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted #a94442;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="panel panel-default panel-intro">
|
<div class="panel panel-default panel-intro">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ namespace app\common\service;
|
|||||||
use think\Config;
|
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
|
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
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public static function log(string $stage, string $message, array $context = []): void
|
public static function log(string $stage, string $message, array $context = []): void
|
||||||
@@ -43,7 +57,14 @@ class SplitTicketSyncLogger
|
|||||||
if (!self::isEnabled()) {
|
if (!self::isEnabled()) {
|
||||||
return;
|
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);
|
$context = self::sanitize($context);
|
||||||
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
||||||
$line = sprintf(
|
$line = sprintf(
|
||||||
@@ -83,8 +104,8 @@ class SplitTicketSyncLogger
|
|||||||
$out[$key] = self::sanitize($value);
|
$out[$key] = self::sanitize($value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (is_string($value) && mb_strlen($value) > 800) {
|
if (is_string($value) && mb_strlen($value) > 2000) {
|
||||||
$out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)';
|
$out[$key] = mb_substr($value, 0, 2000, 'UTF-8') . '...(truncated)';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$out[$key] = $value;
|
$out[$key] = $value;
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ 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}
|
* @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);
|
$ticket = Ticket::get($ticketId);
|
||||||
if (!$ticket) {
|
if (!$ticket) {
|
||||||
@@ -44,6 +45,7 @@ class SplitTicketSyncService
|
|||||||
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
|
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
|
||||||
SplitTicketSyncLogger::log('sync', 'syncOne start', [
|
SplitTicketSyncLogger::log('sync', 'syncOne start', [
|
||||||
'force' => $force,
|
'force' => $force,
|
||||||
|
'syncMode' => $syncMode,
|
||||||
'status' => (string) $ticket['status'],
|
'status' => (string) $ticket['status'],
|
||||||
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
|
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
|
||||||
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
|
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
|
||||||
@@ -67,7 +69,7 @@ class SplitTicketSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->doSync($ticket);
|
$result = $this->doSync($ticket, $syncMode);
|
||||||
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
|
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
|
||||||
return $result;
|
return $result;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -83,65 +85,143 @@ class SplitTicketSyncService
|
|||||||
{
|
{
|
||||||
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
|
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
|
||||||
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
|
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
|
||||||
|
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
|
||||||
if ($autoSyncTypes === []) {
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SplitNodeHealthService::canAcceptWork()) {
|
if (!SplitNodeHealthService::canAcceptWork()) {
|
||||||
SplitTicketSyncLogger::log('cron', 'scan skip', [
|
SplitTicketSyncLogger::logCron('scan skip', [
|
||||||
'reason' => 'node queue busy',
|
'reason' => 'node queue busy',
|
||||||
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
]);
|
]);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
|
||||||
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
|
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
|
||||||
if ($failThreshold > 0) {
|
if ($failThreshold > 0) {
|
||||||
$query->where('sync_fail_count', '<', $failThreshold);
|
$query->where('sync_fail_count', '<', $failThreshold);
|
||||||
}
|
}
|
||||||
// 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单
|
|
||||||
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
|
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
|
||||||
->limit($maxPerRun * 5)
|
->limit($maxPerRun * 5)
|
||||||
->select();
|
->select();
|
||||||
|
|
||||||
|
$candidateCount = count($list);
|
||||||
|
SplitTicketSyncLogger::logCron('scan start', [
|
||||||
|
'candidateCount' => $candidateCount,
|
||||||
|
'maxPerRun' => $maxPerRun,
|
||||||
|
'autoSyncTypes' => $autoSyncTypes,
|
||||||
|
'failThreshold' => $failThreshold,
|
||||||
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
|
]);
|
||||||
SplitTicketSyncLogger::log('cron', 'scan start', [
|
SplitTicketSyncLogger::log('cron', 'scan start', [
|
||||||
'candidateCount' => count($list),
|
'candidateCount' => $candidateCount,
|
||||||
'maxPerRun' => $maxPerRun,
|
'maxPerRun' => $maxPerRun,
|
||||||
'autoSyncTypes' => $autoSyncTypes,
|
'autoSyncTypes' => $autoSyncTypes,
|
||||||
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** @var list<array<string, mixed>> $processed */
|
||||||
|
$processed = [];
|
||||||
|
/** @var list<array<string, mixed>> $skipped */
|
||||||
|
$skipped = [];
|
||||||
$count = 0;
|
$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) {
|
if ($count >= $maxPerRun) {
|
||||||
break;
|
$reason = '本轮处理上限已满';
|
||||||
}
|
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason);
|
||||||
$skip = $this->shouldSkip($ticket);
|
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason);
|
||||||
if ($skip !== null) {
|
|
||||||
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
|
|
||||||
'ticketId' => (int) $ticket['id'],
|
|
||||||
'ticketType' => (string) $ticket['ticket_type'],
|
|
||||||
'reason' => $skip,
|
|
||||||
]);
|
|
||||||
continue;
|
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()) {
|
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', [
|
SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [
|
||||||
'processed' => $count,
|
'processed' => $count,
|
||||||
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
$result = $this->syncOne((int) $ticket['id'], false, true);
|
|
||||||
|
$result = $this->syncOne($ticketId, false, true, 'auto');
|
||||||
if (!empty($result['skipped'])) {
|
if (!empty($result['skipped'])) {
|
||||||
|
$skipReason = (string) ($result['message'] ?? '已跳过');
|
||||||
|
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason);
|
||||||
|
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$processed[] = [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'ticketType' => $ticketType,
|
||||||
|
'ticketUrl' => $ticketUrl,
|
||||||
|
'success' => !empty($result['success']),
|
||||||
|
'message' => (string) ($result['message'] ?? ''),
|
||||||
|
];
|
||||||
$count++;
|
$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;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +244,7 @@ class SplitTicketSyncService
|
|||||||
/**
|
/**
|
||||||
* @return array{success:bool,message:string}
|
* @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'];
|
$ticketType = (string) $ticket['ticket_type'];
|
||||||
$pageUrl = trim((string) $ticket['ticket_url']);
|
$pageUrl = trim((string) $ticket['ticket_url']);
|
||||||
@@ -212,7 +292,6 @@ class SplitTicketSyncService
|
|||||||
if ((string) $freshTicket['status'] === 'hidden') {
|
if ((string) $freshTicket['status'] === 'hidden') {
|
||||||
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
||||||
}
|
}
|
||||||
// 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线)
|
|
||||||
$this->ruleService->applyNumberRules($freshTicket);
|
$this->ruleService->applyNumberRules($freshTicket);
|
||||||
$ticket = $freshTicket;
|
$ticket = $freshTicket;
|
||||||
|
|
||||||
@@ -232,13 +311,13 @@ class SplitTicketSyncService
|
|||||||
'speed_snapshot_time' => $speed['snapshot_time'],
|
'speed_snapshot_time' => $speed['snapshot_time'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->applySyncResult($ticket, $payload, true, '');
|
$this->applySyncResult($ticket, $payload, true, '', $syncMode);
|
||||||
Db::commit();
|
Db::commit();
|
||||||
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
|
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
|
||||||
return ['success' => true, 'message' => '同步成功'];
|
return ['success' => true, 'message' => '同步成功'];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
$msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8');
|
$msg = $e->getMessage();
|
||||||
SplitTicketSyncLogger::log('sync', 'exception', [
|
SplitTicketSyncLogger::log('sync', 'exception', [
|
||||||
'type' => get_class($e),
|
'type' => get_class($e),
|
||||||
'message' => $msg,
|
'message' => $msg,
|
||||||
@@ -282,8 +361,9 @@ class SplitTicketSyncService
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $payload
|
* @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 = [
|
$data = [
|
||||||
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
||||||
'inbound_count' => max(0, (int) ($payload['inbound_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)),
|
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
|
||||||
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
||||||
'sync_status' => $success ? 'success' : 'error',
|
'sync_status' => $success ? 'success' : 'error',
|
||||||
'sync_time' => time(),
|
'sync_time' => $now,
|
||||||
'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'),
|
'sync_message' => $success ? '' : $message,
|
||||||
'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1),
|
'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_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0),
|
||||||
'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 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)) {
|
if (!$ticket->allowField(array_keys($data))->save($data)) {
|
||||||
throw new Exception('工单同步结果保存失败');
|
throw new Exception('工单同步结果保存失败');
|
||||||
}
|
}
|
||||||
@@ -313,10 +397,9 @@ class SplitTicketSyncService
|
|||||||
$update = [
|
$update = [
|
||||||
'sync_status' => 'error',
|
'sync_status' => 'error',
|
||||||
'sync_time' => time(),
|
'sync_time' => time(),
|
||||||
'sync_message' => mb_substr($message, 0, 255, 'UTF-8'),
|
'sync_message' => $message,
|
||||||
'sync_fail_count' => $failCount,
|
'sync_fail_count' => $failCount,
|
||||||
];
|
];
|
||||||
// 新建工单首次同步失败:立即关闭;已同步过的工单仍按连续失败阈值关闭
|
|
||||||
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
|
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
|
||||||
$update['status'] = 'hidden';
|
$update['status'] = 'hidden';
|
||||||
}
|
}
|
||||||
@@ -365,4 +448,129 @@ class SplitTicketSyncService
|
|||||||
'snapshot_time' => $snapshotTime,
|
'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 ?? '未知原因,未进入执行队列';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,14 @@
|
|||||||
|
-- 工单同步日志增强:最近成功时间/方式、完整失败原因
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `fa_split_ticket`
|
||||||
|
MODIFY COLUMN `sync_message` text COMMENT '同步失败完整原因',
|
||||||
|
ADD COLUMN `sync_success_time` bigint(16) DEFAULT NULL COMMENT '最近一次同步成功时间' AFTER `sync_time`,
|
||||||
|
ADD COLUMN `sync_success_mode` enum('auto','manual') DEFAULT NULL COMMENT '最近成功同步方式:auto=自动,manual=手动' AFTER `sync_success_time`;
|
||||||
|
|
||||||
|
UPDATE `fa_split_ticket`
|
||||||
|
SET `sync_success_time` = `sync_time`
|
||||||
|
WHERE `sync_status` = 'success'
|
||||||
|
AND `sync_success_time` IS NULL
|
||||||
|
AND `sync_time` IS NOT NULL
|
||||||
|
AND `sync_time` > 0;
|
||||||
@@ -62,12 +62,13 @@ class SplitSyncTickets extends Command
|
|||||||
try {
|
try {
|
||||||
$count = $service->syncDueTickets();
|
$count = $service->syncDueTickets();
|
||||||
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
||||||
|
$output->writeln('<comment>汇总日志已写入 runtime/log/split_sync.log</comment>');
|
||||||
} finally {
|
} finally {
|
||||||
$cronLock->release();
|
$cronLock->release();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SplitTicketSyncLogger::isEnabled()) {
|
if (SplitTicketSyncLogger::isEnabled()) {
|
||||||
$output->writeln('<comment>调试日志已写入 runtime/log/split_sync.log</comment>');
|
$output->writeln('<comment>详细调试日志已写入 runtime/log/split_sync.log</comment>');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ class Ticket extends Backend
|
|||||||
$params['sync_status'],
|
$params['sync_status'],
|
||||||
$params['sync_time'],
|
$params['sync_time'],
|
||||||
$params['sync_message'],
|
$params['sync_message'],
|
||||||
|
$params['sync_success_time'],
|
||||||
|
$params['sync_success_mode'],
|
||||||
$params['sync_fail_count'],
|
$params['sync_fail_count'],
|
||||||
$params['speed_snapshot_count'],
|
$params['speed_snapshot_count'],
|
||||||
$params['speed_snapshot_time'],
|
$params['speed_snapshot_time'],
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ return [
|
|||||||
'Sync display success' => '同步成功 / 在线 %s',
|
'Sync display success' => '同步成功 / 在线 %s',
|
||||||
'Sync display pending' => '待同步',
|
'Sync display pending' => '待同步',
|
||||||
'Sync display error' => '同步异常',
|
'Sync display error' => '同步异常',
|
||||||
|
'Sync display auto paused' => '自动同步已暂停',
|
||||||
|
'Sync success time' => '最近同步成功',
|
||||||
|
'Sync mode auto' => '自动',
|
||||||
|
'Sync mode manual' => '手动',
|
||||||
|
'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停',
|
||||||
|
'Sync tooltip error prefix' => '异常原因:',
|
||||||
|
'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log',
|
||||||
'Createtime' => '创建时间',
|
'Createtime' => '创建时间',
|
||||||
'Copy' => '拷贝',
|
'Copy' => '拷贝',
|
||||||
'Summary row' => '汇总',
|
'Summary row' => '汇总',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\admin\model\split;
|
namespace app\admin\model\split;
|
||||||
|
|
||||||
|
use app\common\service\SplitSyncConfigService;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Model;
|
use think\Model;
|
||||||
|
|
||||||
@@ -31,6 +32,9 @@ class Ticket extends Model
|
|||||||
'ticket_progress_text',
|
'ticket_progress_text',
|
||||||
'inbound_ratio_text',
|
'inbound_ratio_text',
|
||||||
'sync_display_text',
|
'sync_display_text',
|
||||||
|
'sync_success_text',
|
||||||
|
'sync_tooltip_text',
|
||||||
|
'sync_auto_paused',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,13 +156,35 @@ class Ticket extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步状态展示文案
|
* 是否因连续失败暂停自动同步(工单仍为开启状态)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function isAutoSyncPaused(array $data): bool
|
||||||
|
{
|
||||||
|
if ((string) ($data['status'] ?? '') !== 'normal') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
if ($threshold <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (int) ($data['sync_fail_count'] ?? 0) >= $threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态展示文案(列表列默认显示,异常详情见 tooltip)
|
||||||
*
|
*
|
||||||
* @param mixed $value
|
* @param mixed $value
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
public function getSyncDisplayTextAttr($value, $data): string
|
public function getSyncDisplayTextAttr($value, $data): string
|
||||||
{
|
{
|
||||||
|
if (self::isAutoSyncPaused($data)) {
|
||||||
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
return sprintf((string) __('Sync display auto paused'), $threshold);
|
||||||
|
}
|
||||||
|
|
||||||
$status = (string) ($data['sync_status'] ?? 'pending');
|
$status = (string) ($data['sync_status'] ?? 'pending');
|
||||||
$online = (int) ($data['online_count'] ?? 0);
|
$online = (int) ($data['online_count'] ?? 0);
|
||||||
if ($status === 'success') {
|
if ($status === 'success') {
|
||||||
@@ -170,6 +196,66 @@ class Ticket extends Model
|
|||||||
return (string) __('Sync display error');
|
return (string) __('Sync display error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步状态列 tooltip 完整说明
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncTooltipTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
if (self::isAutoSyncPaused($data)) {
|
||||||
|
$threshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
$parts[] = sprintf(
|
||||||
|
(string) __('Sync tooltip auto paused'),
|
||||||
|
(int) ($data['sync_fail_count'] ?? 0),
|
||||||
|
$threshold
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = trim((string) ($data['sync_message'] ?? ''));
|
||||||
|
if ($message !== '') {
|
||||||
|
$parts[] = (string) __('Sync tooltip error prefix') . $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否暂停自动同步(供前端样式判断)
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncAutoPausedAttr($value, $data): bool
|
||||||
|
{
|
||||||
|
return self::isAutoSyncPaused($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次同步成功时间及方式
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function getSyncSuccessTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$ts = (int) ($data['sync_success_time'] ?? 0);
|
||||||
|
if ($ts <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$timeText = date('Y-m-d H:i:s', $ts);
|
||||||
|
$mode = (string) ($data['sync_success_mode'] ?? '');
|
||||||
|
if ($mode === 'auto') {
|
||||||
|
return $timeText . ' (' . (string) __('Sync mode auto') . ')';
|
||||||
|
}
|
||||||
|
if ($mode === 'manual') {
|
||||||
|
return $timeText . ' (' . (string) __('Sync mode manual') . ')';
|
||||||
|
}
|
||||||
|
return $timeText;
|
||||||
|
}
|
||||||
|
|
||||||
public function setStartTimeAttr($value): ?int
|
public function setStartTimeAttr($value): ?int
|
||||||
{
|
{
|
||||||
return self::parseTimeValue($value);
|
return self::parseTimeValue($value);
|
||||||
|
|||||||
@@ -67,6 +67,24 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
.split-sync-success-normal {
|
||||||
|
color: #3c763d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.split-sync-success-stopped {
|
||||||
|
color: #a94442;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.split-ticket-sync-status-paused {
|
||||||
|
color: #8a6d3b;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted #8a6d3b;
|
||||||
|
}
|
||||||
|
.split-ticket-sync-status-error {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted #a94442;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="panel panel-default panel-intro">
|
<div class="panel panel-default panel-intro">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ namespace app\common\service;
|
|||||||
use think\Config;
|
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
|
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
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
public static function log(string $stage, string $message, array $context = []): void
|
public static function log(string $stage, string $message, array $context = []): void
|
||||||
@@ -43,7 +57,14 @@ class SplitTicketSyncLogger
|
|||||||
if (!self::isEnabled()) {
|
if (!self::isEnabled()) {
|
||||||
return;
|
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);
|
$context = self::sanitize($context);
|
||||||
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
||||||
$line = sprintf(
|
$line = sprintf(
|
||||||
@@ -83,8 +104,8 @@ class SplitTicketSyncLogger
|
|||||||
$out[$key] = self::sanitize($value);
|
$out[$key] = self::sanitize($value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (is_string($value) && mb_strlen($value) > 800) {
|
if (is_string($value) && mb_strlen($value) > 2000) {
|
||||||
$out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)';
|
$out[$key] = mb_substr($value, 0, 2000, 'UTF-8') . '...(truncated)';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$out[$key] = $value;
|
$out[$key] = $value;
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ 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}
|
* @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);
|
$ticket = Ticket::get($ticketId);
|
||||||
if (!$ticket) {
|
if (!$ticket) {
|
||||||
@@ -44,6 +45,7 @@ class SplitTicketSyncService
|
|||||||
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
|
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
|
||||||
SplitTicketSyncLogger::log('sync', 'syncOne start', [
|
SplitTicketSyncLogger::log('sync', 'syncOne start', [
|
||||||
'force' => $force,
|
'force' => $force,
|
||||||
|
'syncMode' => $syncMode,
|
||||||
'status' => (string) $ticket['status'],
|
'status' => (string) $ticket['status'],
|
||||||
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
|
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
|
||||||
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
|
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
|
||||||
@@ -67,7 +69,7 @@ class SplitTicketSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->doSync($ticket);
|
$result = $this->doSync($ticket, $syncMode);
|
||||||
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
|
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
|
||||||
return $result;
|
return $result;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -83,65 +85,143 @@ class SplitTicketSyncService
|
|||||||
{
|
{
|
||||||
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
|
$maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun();
|
||||||
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
|
$autoSyncTypes = $this->resolveAutoSyncTicketTypes();
|
||||||
|
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||||
|
|
||||||
if ($autoSyncTypes === []) {
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SplitNodeHealthService::canAcceptWork()) {
|
if (!SplitNodeHealthService::canAcceptWork()) {
|
||||||
SplitTicketSyncLogger::log('cron', 'scan skip', [
|
SplitTicketSyncLogger::logCron('scan skip', [
|
||||||
'reason' => 'node queue busy',
|
'reason' => 'node queue busy',
|
||||||
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
]);
|
]);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
|
||||||
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
|
$query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes);
|
||||||
if ($failThreshold > 0) {
|
if ($failThreshold > 0) {
|
||||||
$query->where('sync_fail_count', '<', $failThreshold);
|
$query->where('sync_fail_count', '<', $failThreshold);
|
||||||
}
|
}
|
||||||
// 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单
|
|
||||||
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
|
$list = $query->order('sync_time', 'asc')->order('id', 'asc')
|
||||||
->limit($maxPerRun * 5)
|
->limit($maxPerRun * 5)
|
||||||
->select();
|
->select();
|
||||||
|
|
||||||
|
$candidateCount = count($list);
|
||||||
|
SplitTicketSyncLogger::logCron('scan start', [
|
||||||
|
'candidateCount' => $candidateCount,
|
||||||
|
'maxPerRun' => $maxPerRun,
|
||||||
|
'autoSyncTypes' => $autoSyncTypes,
|
||||||
|
'failThreshold' => $failThreshold,
|
||||||
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
|
]);
|
||||||
SplitTicketSyncLogger::log('cron', 'scan start', [
|
SplitTicketSyncLogger::log('cron', 'scan start', [
|
||||||
'candidateCount' => count($list),
|
'candidateCount' => $candidateCount,
|
||||||
'maxPerRun' => $maxPerRun,
|
'maxPerRun' => $maxPerRun,
|
||||||
'autoSyncTypes' => $autoSyncTypes,
|
'autoSyncTypes' => $autoSyncTypes,
|
||||||
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** @var list<array<string, mixed>> $processed */
|
||||||
|
$processed = [];
|
||||||
|
/** @var list<array<string, mixed>> $skipped */
|
||||||
|
$skipped = [];
|
||||||
$count = 0;
|
$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) {
|
if ($count >= $maxPerRun) {
|
||||||
break;
|
$reason = '本轮处理上限已满';
|
||||||
}
|
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason);
|
||||||
$skip = $this->shouldSkip($ticket);
|
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason);
|
||||||
if ($skip !== null) {
|
|
||||||
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
|
|
||||||
'ticketId' => (int) $ticket['id'],
|
|
||||||
'ticketType' => (string) $ticket['ticket_type'],
|
|
||||||
'reason' => $skip,
|
|
||||||
]);
|
|
||||||
continue;
|
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()) {
|
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', [
|
SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [
|
||||||
'processed' => $count,
|
'processed' => $count,
|
||||||
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
$result = $this->syncOne((int) $ticket['id'], false, true);
|
|
||||||
|
$result = $this->syncOne($ticketId, false, true, 'auto');
|
||||||
if (!empty($result['skipped'])) {
|
if (!empty($result['skipped'])) {
|
||||||
|
$skipReason = (string) ($result['message'] ?? '已跳过');
|
||||||
|
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason);
|
||||||
|
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$processed[] = [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'ticketType' => $ticketType,
|
||||||
|
'ticketUrl' => $ticketUrl,
|
||||||
|
'success' => !empty($result['success']),
|
||||||
|
'message' => (string) ($result['message'] ?? ''),
|
||||||
|
];
|
||||||
$count++;
|
$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;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +244,7 @@ class SplitTicketSyncService
|
|||||||
/**
|
/**
|
||||||
* @return array{success:bool,message:string}
|
* @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'];
|
$ticketType = (string) $ticket['ticket_type'];
|
||||||
$pageUrl = trim((string) $ticket['ticket_url']);
|
$pageUrl = trim((string) $ticket['ticket_url']);
|
||||||
@@ -212,7 +292,6 @@ class SplitTicketSyncService
|
|||||||
if ((string) $freshTicket['status'] === 'hidden') {
|
if ((string) $freshTicket['status'] === 'hidden') {
|
||||||
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
||||||
}
|
}
|
||||||
// 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线)
|
|
||||||
$this->ruleService->applyNumberRules($freshTicket);
|
$this->ruleService->applyNumberRules($freshTicket);
|
||||||
$ticket = $freshTicket;
|
$ticket = $freshTicket;
|
||||||
|
|
||||||
@@ -232,13 +311,13 @@ class SplitTicketSyncService
|
|||||||
'speed_snapshot_time' => $speed['snapshot_time'],
|
'speed_snapshot_time' => $speed['snapshot_time'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->applySyncResult($ticket, $payload, true, '');
|
$this->applySyncResult($ticket, $payload, true, '', $syncMode);
|
||||||
Db::commit();
|
Db::commit();
|
||||||
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
|
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
|
||||||
return ['success' => true, 'message' => '同步成功'];
|
return ['success' => true, 'message' => '同步成功'];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
$msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8');
|
$msg = $e->getMessage();
|
||||||
SplitTicketSyncLogger::log('sync', 'exception', [
|
SplitTicketSyncLogger::log('sync', 'exception', [
|
||||||
'type' => get_class($e),
|
'type' => get_class($e),
|
||||||
'message' => $msg,
|
'message' => $msg,
|
||||||
@@ -282,8 +361,9 @@ class SplitTicketSyncService
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $payload
|
* @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 = [
|
$data = [
|
||||||
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
||||||
'inbound_count' => max(0, (int) ($payload['inbound_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)),
|
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
|
||||||
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
||||||
'sync_status' => $success ? 'success' : 'error',
|
'sync_status' => $success ? 'success' : 'error',
|
||||||
'sync_time' => time(),
|
'sync_time' => $now,
|
||||||
'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'),
|
'sync_message' => $success ? '' : $message,
|
||||||
'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1),
|
'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_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0),
|
||||||
'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 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)) {
|
if (!$ticket->allowField(array_keys($data))->save($data)) {
|
||||||
throw new Exception('工单同步结果保存失败');
|
throw new Exception('工单同步结果保存失败');
|
||||||
}
|
}
|
||||||
@@ -313,10 +397,9 @@ class SplitTicketSyncService
|
|||||||
$update = [
|
$update = [
|
||||||
'sync_status' => 'error',
|
'sync_status' => 'error',
|
||||||
'sync_time' => time(),
|
'sync_time' => time(),
|
||||||
'sync_message' => mb_substr($message, 0, 255, 'UTF-8'),
|
'sync_message' => $message,
|
||||||
'sync_fail_count' => $failCount,
|
'sync_fail_count' => $failCount,
|
||||||
];
|
];
|
||||||
// 新建工单首次同步失败:立即关闭;已同步过的工单仍按连续失败阈值关闭
|
|
||||||
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
|
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
|
||||||
$update['status'] = 'hidden';
|
$update['status'] = 'hidden';
|
||||||
}
|
}
|
||||||
@@ -365,4 +448,129 @@ class SplitTicketSyncService
|
|||||||
'snapshot_time' => $snapshotTime,
|
'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 ?? '未知原因,未进入执行队列';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
sortOrder: 'desc',
|
sortOrder: 'desc',
|
||||||
fixedColumns: true,
|
fixedColumns: true,
|
||||||
fixedRightNumber: 1,
|
fixedRightNumber: 1,
|
||||||
|
searchFormVisible: true,
|
||||||
columns: [
|
columns: [
|
||||||
[
|
[
|
||||||
{checkbox: true},
|
{checkbox: true},
|
||||||
@@ -98,6 +99,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
operate: false,
|
operate: false,
|
||||||
formatter: Controller.api.formatter.syncDisplay
|
formatter: Controller.api.formatter.syncDisplay
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'sync_success_text',
|
||||||
|
title: __('Sync success time'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.syncSuccessDisplay
|
||||||
|
},
|
||||||
$.extend(
|
$.extend(
|
||||||
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
||||||
searchSelectMeta(false)
|
searchSelectMeta(false)
|
||||||
@@ -136,6 +143,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
|
|
||||||
table.on('load-success.bs.table', function (e, res) {
|
table.on('load-success.bs.table', function (e, res) {
|
||||||
Controller.api.renderSummaryRow(res);
|
Controller.api.renderSummaryRow(res);
|
||||||
|
Controller.api.initSyncTooltips(table);
|
||||||
var pendingIds = window.__splitTicketPendingPostAddSyncIds;
|
var pendingIds = window.__splitTicketPendingPostAddSyncIds;
|
||||||
if (!pendingIds || !pendingIds.length) {
|
if (!pendingIds || !pendingIds.length) {
|
||||||
return;
|
return;
|
||||||
@@ -309,11 +317,26 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
*/
|
*/
|
||||||
finishTicketsSync: function (table) {
|
finishTicketsSync: function (table) {
|
||||||
Controller.api.syncingTicketIds = [];
|
Controller.api.syncingTicketIds = [];
|
||||||
table.bootstrapTable('refresh');
|
table.bootstrapTable('refresh', {
|
||||||
|
silent: true
|
||||||
|
});
|
||||||
|
Controller.api.initSyncTooltips(table);
|
||||||
var ids = Table.api.selectedids(table);
|
var ids = Table.api.selectedids(table);
|
||||||
$('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0);
|
$('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化同步状态列 tooltip(完整异常原因)
|
||||||
|
*/
|
||||||
|
initSyncTooltips: function (table) {
|
||||||
|
var $container = table.closest('.bootstrap-table');
|
||||||
|
$container.find('[data-sync-tooltip="1"]').tooltip({
|
||||||
|
container: 'body',
|
||||||
|
placement: 'auto top',
|
||||||
|
html: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
formatter: {
|
formatter: {
|
||||||
/**
|
/**
|
||||||
* 操作列:编辑 → 拷贝 → 删除
|
* 操作列:编辑 → 拷贝 → 删除
|
||||||
@@ -407,13 +430,38 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
return Controller.api.formatter.syncingDisplayHtml();
|
return Controller.api.formatter.syncingDisplayHtml();
|
||||||
}
|
}
|
||||||
var text = value || '';
|
var text = value || '';
|
||||||
|
var tooltip = row.sync_tooltip_text ? String(row.sync_tooltip_text).trim() : '';
|
||||||
|
if (tooltip !== '') {
|
||||||
|
tooltip = tooltip.replace(/\r?\n/g, ';');
|
||||||
|
}
|
||||||
|
var hasTooltip = tooltip !== '';
|
||||||
var color = 'danger';
|
var color = 'danger';
|
||||||
if (row.sync_status === 'success') {
|
var extraClass = '';
|
||||||
|
if (row.sync_auto_paused) {
|
||||||
|
color = 'warning';
|
||||||
|
extraClass = ' split-ticket-sync-status-paused';
|
||||||
|
} else if (row.sync_status === 'success') {
|
||||||
color = 'success';
|
color = 'success';
|
||||||
} else if (row.sync_status === 'pending') {
|
} else if (row.sync_status === 'pending') {
|
||||||
color = 'muted';
|
color = 'muted';
|
||||||
|
} else if (row.sync_status === 'error') {
|
||||||
|
extraClass = ' split-ticket-sync-status-error';
|
||||||
}
|
}
|
||||||
return '<span class="text-' + color + '">' + Fast.api.escape(text) + '</span>';
|
var attrs = '';
|
||||||
|
if (hasTooltip) {
|
||||||
|
attrs = ' data-sync-tooltip="1" data-toggle="tooltip" title="' + Fast.api.escape(tooltip) + '"';
|
||||||
|
}
|
||||||
|
return '<span class="text-' + color + extraClass + '"' + attrs + '>' + Fast.api.escape(text) + '</span>';
|
||||||
|
},
|
||||||
|
syncSuccessDisplay: function (value, row) {
|
||||||
|
var text = value == null ? '' : String(value).trim();
|
||||||
|
if (text === '') {
|
||||||
|
return '<span class="text-muted">-</span>';
|
||||||
|
}
|
||||||
|
var colorClass = row.status === 'normal'
|
||||||
|
? 'split-sync-success-normal'
|
||||||
|
: 'split-sync-success-stopped';
|
||||||
|
return '<span class="' + colorClass + '">' + Fast.api.escape(text) + '</span>';
|
||||||
},
|
},
|
||||||
syncingDisplayHtml: function () {
|
syncingDisplayHtml: function () {
|
||||||
var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg)
|
var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg)
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
operate: false,
|
operate: false,
|
||||||
formatter: Controller.api.formatter.syncDisplay
|
formatter: Controller.api.formatter.syncDisplay
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'sync_success_text',
|
||||||
|
title: __('Sync success time'),
|
||||||
|
operate: false,
|
||||||
|
formatter: Controller.api.formatter.syncSuccessDisplay
|
||||||
|
},
|
||||||
$.extend(
|
$.extend(
|
||||||
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
||||||
searchSelectMeta(false)
|
searchSelectMeta(false)
|
||||||
@@ -137,6 +143,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
|
|
||||||
table.on('load-success.bs.table', function (e, res) {
|
table.on('load-success.bs.table', function (e, res) {
|
||||||
Controller.api.renderSummaryRow(res);
|
Controller.api.renderSummaryRow(res);
|
||||||
|
Controller.api.initSyncTooltips(table);
|
||||||
var pendingIds = window.__splitTicketPendingPostAddSyncIds;
|
var pendingIds = window.__splitTicketPendingPostAddSyncIds;
|
||||||
if (!pendingIds || !pendingIds.length) {
|
if (!pendingIds || !pendingIds.length) {
|
||||||
return;
|
return;
|
||||||
@@ -310,11 +317,26 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
*/
|
*/
|
||||||
finishTicketsSync: function (table) {
|
finishTicketsSync: function (table) {
|
||||||
Controller.api.syncingTicketIds = [];
|
Controller.api.syncingTicketIds = [];
|
||||||
table.bootstrapTable('refresh');
|
table.bootstrapTable('refresh', {
|
||||||
|
silent: true
|
||||||
|
});
|
||||||
|
Controller.api.initSyncTooltips(table);
|
||||||
var ids = Table.api.selectedids(table);
|
var ids = Table.api.selectedids(table);
|
||||||
$('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0);
|
$('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化同步状态列 tooltip(完整异常原因)
|
||||||
|
*/
|
||||||
|
initSyncTooltips: function (table) {
|
||||||
|
var $container = table.closest('.bootstrap-table');
|
||||||
|
$container.find('[data-sync-tooltip="1"]').tooltip({
|
||||||
|
container: 'body',
|
||||||
|
placement: 'auto top',
|
||||||
|
html: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
formatter: {
|
formatter: {
|
||||||
/**
|
/**
|
||||||
* 操作列:编辑 → 拷贝 → 删除
|
* 操作列:编辑 → 拷贝 → 删除
|
||||||
@@ -408,13 +430,38 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
return Controller.api.formatter.syncingDisplayHtml();
|
return Controller.api.formatter.syncingDisplayHtml();
|
||||||
}
|
}
|
||||||
var text = value || '';
|
var text = value || '';
|
||||||
|
var tooltip = row.sync_tooltip_text ? String(row.sync_tooltip_text).trim() : '';
|
||||||
|
if (tooltip !== '') {
|
||||||
|
tooltip = tooltip.replace(/\r?\n/g, ';');
|
||||||
|
}
|
||||||
|
var hasTooltip = tooltip !== '';
|
||||||
var color = 'danger';
|
var color = 'danger';
|
||||||
if (row.sync_status === 'success') {
|
var extraClass = '';
|
||||||
|
if (row.sync_auto_paused) {
|
||||||
|
color = 'warning';
|
||||||
|
extraClass = ' split-ticket-sync-status-paused';
|
||||||
|
} else if (row.sync_status === 'success') {
|
||||||
color = 'success';
|
color = 'success';
|
||||||
} else if (row.sync_status === 'pending') {
|
} else if (row.sync_status === 'pending') {
|
||||||
color = 'muted';
|
color = 'muted';
|
||||||
|
} else if (row.sync_status === 'error') {
|
||||||
|
extraClass = ' split-ticket-sync-status-error';
|
||||||
}
|
}
|
||||||
return '<span class="text-' + color + '">' + Fast.api.escape(text) + '</span>';
|
var attrs = '';
|
||||||
|
if (hasTooltip) {
|
||||||
|
attrs = ' data-sync-tooltip="1" data-toggle="tooltip" title="' + Fast.api.escape(tooltip) + '"';
|
||||||
|
}
|
||||||
|
return '<span class="text-' + color + extraClass + '"' + attrs + '>' + Fast.api.escape(text) + '</span>';
|
||||||
|
},
|
||||||
|
syncSuccessDisplay: function (value, row) {
|
||||||
|
var text = value == null ? '' : String(value).trim();
|
||||||
|
if (text === '') {
|
||||||
|
return '<span class="text-muted">-</span>';
|
||||||
|
}
|
||||||
|
var colorClass = row.status === 'normal'
|
||||||
|
? 'split-sync-success-normal'
|
||||||
|
: 'split-sync-success-stopped';
|
||||||
|
return '<span class="' + colorClass + '">' + Fast.api.escape(text) + '</span>';
|
||||||
},
|
},
|
||||||
syncingDisplayHtml: function () {
|
syncingDisplayHtml: function () {
|
||||||
var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg)
|
var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg)
|
||||||
|
|||||||
Reference in New Issue
Block a user