From 98fc6d9e3d38cc717729b625760f1629f7a38014 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 13 Jul 2026 23:06:53 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=9E=E7=BB=AD=E5=90=8C=E6=AD=A5=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=90=8E=E8=A7=A6=E5=8F=91=E9=87=8D=E8=AF=95=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/Install/split_sync_fail_retry.sql | 11 ++ application/admin/controller/split/Ticket.php | 3 + application/admin/lang/zh-cn/split/ticket.php | 4 + application/admin/model/split/Ticket.php | 52 +++++- .../common/service/SplitSyncConfigService.php | 50 ++++++ .../SplitTicketSyncFailRetryService.php | 168 ++++++++++++++++++ .../common/service/SplitTicketSyncService.php | 61 ++++++- application/extra/site.php | 1 + .../command/Install/split_config_sync.sql | 6 +- .../command/Install/split_sync_fail_retry.sql | 11 ++ .../admin/controller/split/Ticket.php | 3 + .../admin/lang/zh-cn/split/ticket.php | 4 + .../application/admin/model/split/Ticket.php | 52 +++++- .../common/service/SplitSyncConfigService.php | 50 ++++++ .../SplitTicketSyncFailRetryService.php | 168 ++++++++++++++++++ .../common/service/SplitTicketSyncService.php | 61 ++++++- patches/application/extra/site.php | 67 +++++++ 17 files changed, 759 insertions(+), 13 deletions(-) create mode 100644 application/admin/command/Install/split_sync_fail_retry.sql create mode 100644 application/common/service/SplitTicketSyncFailRetryService.php create mode 100644 patches/application/admin/command/Install/split_sync_fail_retry.sql create mode 100644 patches/application/common/service/SplitTicketSyncFailRetryService.php create mode 100755 patches/application/extra/site.php diff --git a/application/admin/command/Install/split_sync_fail_retry.sql b/application/admin/command/Install/split_sync_fail_retry.sql new file mode 100644 index 0000000..d75851c --- /dev/null +++ b/application/admin/command/Install/split_sync_fail_retry.sql @@ -0,0 +1,11 @@ +-- 同步失败分阶段重试:系统配置 + 工单状态字段 +SET NAMES utf8mb4; + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_fail_retry_minutes', 'split', '失败重试时间点(分钟)', '逗号分隔、升序,相对首次达失败暂停阈值后的绝对分钟数,如 10,20,30,50;留空则达阈值后永久暂停自动同步', 'string', '', '', '', '', ' placeholder="10,20,30,50"', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_retry_minutes' LIMIT 1); + +ALTER TABLE `fa_split_ticket` + ADD COLUMN `sync_fail_pause_at` bigint(16) DEFAULT NULL COMMENT '达阈值进入重试的锚点时间' AFTER `sync_fail_count`, + ADD COLUMN `sync_fail_retry_index` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '已完成的重试次数(0=尚未重试)' AFTER `sync_fail_pause_at`, + ADD COLUMN `sync_auto_sync_stopped` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '1=全部重试失败,永久终止自动同步' AFTER `sync_fail_retry_index`; diff --git a/application/admin/controller/split/Ticket.php b/application/admin/controller/split/Ticket.php index 2babeff..0dcb0dd 100755 --- a/application/admin/controller/split/Ticket.php +++ b/application/admin/controller/split/Ticket.php @@ -278,6 +278,9 @@ class Ticket extends Backend $params['sync_success_time'], $params['sync_success_mode'], $params['sync_fail_count'], + $params['sync_fail_pause_at'], + $params['sync_fail_retry_index'], + $params['sync_auto_sync_stopped'], $params['speed_snapshot_count'], $params['speed_snapshot_time'], $params['click_count'], diff --git a/application/admin/lang/zh-cn/split/ticket.php b/application/admin/lang/zh-cn/split/ticket.php index b0a30e6..f1c2520 100755 --- a/application/admin/lang/zh-cn/split/ticket.php +++ b/application/admin/lang/zh-cn/split/ticket.php @@ -44,10 +44,14 @@ return [ 'Sync display pending' => '待同步', 'Sync display error' => '同步异常', 'Sync display auto paused' => '自动同步已暂停', + 'Sync display auto sync stopped' => '自动同步已终止(重试已用尽)', + 'Sync display retry waiting' => '自动同步已暂停,将于 %s 第 %d 次重试', 'Sync success time' => '最近同步成功', 'Sync mode auto' => '自动', 'Sync mode manual' => '手动', 'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停', + 'Sync tooltip auto sync stopped' => '分阶段重试已全部失败,自动同步已永久终止;手动同步成功后可恢复', + 'Sync tooltip retry waiting' => '连续失败 %s 次,将于 %s 进行第 %d 次自动重试', 'Sync tooltip error prefix' => '异常原因:', 'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log', 'Createtime' => '创建时间', diff --git a/application/admin/model/split/Ticket.php b/application/admin/model/split/Ticket.php index a7782f3..a73360a 100755 --- a/application/admin/model/split/Ticket.php +++ b/application/admin/model/split/Ticket.php @@ -6,6 +6,7 @@ namespace app\admin\model\split; use app\common\service\SplitSyncConfigService; use app\common\service\SplitTicketRuleService; +use app\common\service\SplitTicketSyncFailRetryService; use app\common\service\SplitTicketSyncLockService; use think\Db; use think\Model; @@ -176,10 +177,23 @@ class Ticket extends Model if ((string) ($data['status'] ?? '') !== 'normal') { return false; } + + $retryService = new SplitTicketSyncFailRetryService(); + if ($retryService->isPermanentlyStopped($data)) { + return true; + } + if ($retryService->isInRetryMode($data)) { + return true; + } + $threshold = SplitSyncConfigService::getFailPauseThreshold(); if ($threshold <= 0) { return false; } + if (SplitSyncConfigService::hasFailRetrySchedule()) { + return false; + } + return (int) ($data['sync_fail_count'] ?? 0) >= $threshold; } @@ -191,9 +205,26 @@ class Ticket extends Model */ public function getSyncDisplayTextAttr($value, $data): string { + $retryService = new SplitTicketSyncFailRetryService(); + + if ($retryService->isPermanentlyStopped($data)) { + return (string) __('Sync display auto sync stopped'); + } + + if ($retryService->isInRetryMode($data)) { + $nextAt = $retryService->getNextRetryAt($data); + $attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1; + if ($nextAt !== null) { + return sprintf( + (string) __('Sync display retry waiting'), + date('H:i', $nextAt), + $attempt + ); + } + } + if (self::isAutoSyncPaused($data)) { - $threshold = SplitSyncConfigService::getFailPauseThreshold(); - return sprintf((string) __('Sync display auto paused'), $threshold); + return (string) __('Sync display auto paused'); } $status = (string) ($data['sync_status'] ?? 'pending'); @@ -216,7 +247,22 @@ class Ticket extends Model public function getSyncTooltipTextAttr($value, $data): string { $parts = []; - if (self::isAutoSyncPaused($data)) { + $retryService = new SplitTicketSyncFailRetryService(); + + if ($retryService->isPermanentlyStopped($data)) { + $parts[] = (string) __('Sync tooltip auto sync stopped'); + } elseif ($retryService->isInRetryMode($data)) { + $nextAt = $retryService->getNextRetryAt($data); + $attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1; + if ($nextAt !== null) { + $parts[] = sprintf( + (string) __('Sync tooltip retry waiting'), + (int) ($data['sync_fail_count'] ?? 0), + date('Y-m-d H:i:s', $nextAt), + $attempt + ); + } + } elseif (self::isAutoSyncPaused($data)) { $threshold = SplitSyncConfigService::getFailPauseThreshold(); $parts[] = sprintf( (string) __('Sync tooltip auto paused'), diff --git a/application/common/service/SplitSyncConfigService.php b/application/common/service/SplitSyncConfigService.php index c667c3c..33f7d63 100755 --- a/application/common/service/SplitSyncConfigService.php +++ b/application/common/service/SplitSyncConfigService.php @@ -24,6 +24,12 @@ class SplitSyncConfigService return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST; } + /** 失败重试时间点最多配置条数 */ + private const FAIL_RETRY_SCHEDULE_MAX = 10; + + /** 单条重试时间点上限(分钟) */ + private const FAIL_RETRY_MINUTE_MAX = 1440; + /** * 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停) */ @@ -36,6 +42,50 @@ class SplitSyncConfigService return max(0, (int) $value); } + /** + * 达失败暂停阈值后的重试时间点(分钟,相对锚点的绝对偏移,升序去重) + * + * @return list + */ + public static function getFailRetryMinutes(): array + { + $raw = trim(self::getConfigValue('split_sync_fail_retry_minutes')); + if ($raw === '') { + return []; + } + + $parts = preg_split('/[,,\s]+/', $raw) ?: []; + $minutes = []; + foreach ($parts as $part) { + $part = trim((string) $part); + if ($part === '' || !ctype_digit($part)) { + continue; + } + $minute = (int) $part; + if ($minute < 1 || $minute > self::FAIL_RETRY_MINUTE_MAX) { + continue; + } + $minutes[$minute] = $minute; + } + + if ($minutes === []) { + return []; + } + + $list = array_values($minutes); + sort($list, SORT_NUMERIC); + + return array_slice($list, 0, self::FAIL_RETRY_SCHEDULE_MAX); + } + + /** + * 是否配置了失败分阶段重试时间点 + */ + public static function hasFailRetrySchedule(): bool + { + return self::getFailRetryMinutes() !== []; + } + /** * 指定工单类型的自动同步周期(分钟),0 表示不自动同步 */ diff --git a/application/common/service/SplitTicketSyncFailRetryService.php b/application/common/service/SplitTicketSyncFailRetryService.php new file mode 100644 index 0000000..d1c7599 --- /dev/null +++ b/application/common/service/SplitTicketSyncFailRetryService.php @@ -0,0 +1,168 @@ + $ticket + */ + public function isPermanentlyStopped($ticket): bool + { + return (int) ($this->readField($ticket, 'sync_auto_sync_stopped') ?? 0) === 1; + } + + /** + * 是否处于失败重试等待/重试周期内(未永久终止) + * + * @param Ticket|array $ticket + */ + public function isInRetryMode($ticket): bool + { + if ($this->isPermanentlyStopped($ticket)) { + return false; + } + + return (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0) > 0; + } + + /** + * 首次达失败阈值时进入重试态(工单保持 normal,不关单) + * + * @return array 需合并进 save 的字段;无变更时返回空数组 + */ + public function enterRetryIfNeeded(Ticket $ticket, int $failCount, int $threshold): array + { + if ($failCount < $threshold || !SplitSyncConfigService::hasFailRetrySchedule()) { + return []; + } + if ($this->isInRetryMode($ticket)) { + return []; + } + + return [ + 'sync_fail_pause_at' => time(), + 'sync_fail_retry_index' => 0, + 'sync_auto_sync_stopped' => 0, + ]; + } + + /** + * 计划内重试失败后递进索引;用尽全部时间点后标记永久终止 + * + * @return array + */ + public function onRetryFailure(Ticket $ticket): array + { + $schedule = SplitSyncConfigService::getFailRetryMinutes(); + $index = (int) ($ticket['sync_fail_retry_index'] ?? 0) + 1; + $update = ['sync_fail_retry_index' => $index]; + + if ($index >= count($schedule)) { + $update['sync_auto_sync_stopped'] = 1; + } + + return $update; + } + + /** + * 同步成功时清零失败计数与重试相关字段 + * + * @return array + */ + public function clearOnSuccessFields(): array + { + return [ + 'sync_fail_count' => 0, + 'sync_fail_pause_at' => null, + 'sync_fail_retry_index' => 0, + 'sync_auto_sync_stopped' => 0, + ]; + } + + /** + * Cron 自动重试是否已到点;未到点返回 skip 原因,到点返回 null(允许执行) + * + * @param Ticket|array $ticket + */ + public function shouldAllowAutoRetry($ticket, ?int $now = null): ?string + { + if (!$this->isInRetryMode($ticket)) { + return null; + } + if ($this->isPermanentlyStopped($ticket)) { + return '自动同步已终止(重试已用尽)'; + } + + $nextAt = $this->getNextRetryAt($ticket); + if ($nextAt === null) { + return '自动同步已终止(重试已用尽)'; + } + + $now = $now ?? time(); + if ($now < $nextAt) { + $attempt = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0) + 1; + + return sprintf( + '失败重试等待中,将于 %s 进行第 %d 次重试', + date('Y-m-d H:i:s', $nextAt), + $attempt + ); + } + + return null; + } + + /** + * 下一次计划重试的 Unix 时间戳;无待重试时返回 null + * + * @param Ticket|array $ticket + */ + public function getNextRetryAt($ticket): ?int + { + if (!$this->isInRetryMode($ticket) || $this->isPermanentlyStopped($ticket)) { + return null; + } + + $schedule = SplitSyncConfigService::getFailRetryMinutes(); + if ($schedule === []) { + return null; + } + + $index = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0); + if ($index >= count($schedule)) { + return null; + } + + $pauseAt = (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0); + if ($pauseAt <= 0) { + return null; + } + + return $pauseAt + $schedule[$index] * 60; + } + + /** + * @param Ticket|array $ticket + */ + private function readField($ticket, string $field) + { + if ($ticket instanceof Ticket) { + return $ticket->getData($field) ?? ($ticket[$field] ?? null); + } + + return $ticket[$field] ?? null; + } +} diff --git a/application/common/service/SplitTicketSyncService.php b/application/common/service/SplitTicketSyncService.php index 87ae830..2e30af2 100755 --- a/application/common/service/SplitTicketSyncService.php +++ b/application/common/service/SplitTicketSyncService.php @@ -23,12 +23,15 @@ class SplitTicketSyncService private SplitTicketSyncDispatchService $dispatchService; + private SplitTicketSyncFailRetryService $failRetryService; + public function __construct() { $this->numberSync = new SplitTicketNumberSyncService(); $this->ruleService = new SplitTicketRuleService(); $this->lockService = new SplitTicketSyncLockService(); $this->dispatchService = new SplitTicketSyncDispatchService(); + $this->failRetryService = new SplitTicketSyncFailRetryService(); } /** @@ -400,7 +403,13 @@ class SplitTicketSyncService ->where('manual_manage', 0) ->whereIn('ticket_type', $types); if ($failThreshold > 0) { - $query->where('sync_fail_count', '<', $failThreshold); + $query->where(function ($query) use ($failThreshold): void { + $query->where('sync_fail_count', '<', $failThreshold) + ->whereOr(function ($query): void { + $query->where('sync_fail_pause_at', '>', 0) + ->where('sync_auto_sync_stopped', 0); + }); + }); } $query->order('sync_time', 'asc')->order('id', 'asc'); if ($limit > 0) { @@ -613,6 +622,27 @@ class SplitTicketSyncService if ((string) $ticket['status'] === 'hidden') { return '工单已关闭'; } + + if ($this->failRetryService->isPermanentlyStopped($ticket)) { + return '自动同步已终止(重试已用尽)'; + } + + if ($this->failRetryService->isInRetryMode($ticket)) { + $retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket); + if ($retrySkip !== null) { + return $retrySkip; + } + if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) { + return '工单类型尚未实现'; + } + $interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']); + if ($interval <= 0) { + return '该类型未配置自动同步周期'; + } + + return null; + } + $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) { return sprintf('连续同步失败超过%d次已暂停', $failThreshold); @@ -661,6 +691,7 @@ class SplitTicketSyncService if ($success) { $data['sync_success_time'] = $now; $data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual'; + $data = array_merge($data, $this->failRetryService->clearOnSuccessFields()); } if (!$ticket->allowField(array_keys($data))->save($data)) { throw new Exception('工单同步结果保存失败'); @@ -679,6 +710,21 @@ class SplitTicketSyncService 'sync_message' => $message, 'sync_fail_count' => $failCount, ]; + + if ($this->failRetryService->isInRetryMode($ticket)) { + $update = array_merge($update, $this->failRetryService->onRetryFailure($ticket)); + $ticket->save($update); + + return; + } + + if ($failThreshold > 0 && $failCount >= $failThreshold && SplitSyncConfigService::hasFailRetrySchedule()) { + $update = array_merge($update, $this->failRetryService->enterRetryIfNeeded($ticket, $failCount, $failThreshold)); + $ticket->save($update); + + return; + } + if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) { $update['status'] = 'hidden'; } @@ -787,7 +833,7 @@ class SplitTicketSyncService $openList = Ticket::where('status', 'normal') ->whereIn('ticket_type', $supportedTypes) - ->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status') + ->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status,sync_fail_pause_at,sync_fail_retry_index,sync_auto_sync_stopped,status,manual_manage') ->select(); $result = []; @@ -838,7 +884,16 @@ class SplitTicketSyncService $ticketType = (string) ($ticket['ticket_type'] ?? ''); $failCount = (int) ($ticket['sync_fail_count'] ?? 0); - if ($failThreshold > 0 && $failCount >= $failThreshold) { + if ($this->failRetryService->isPermanentlyStopped($ticket)) { + return '自动同步已终止(重试已用尽)'; + } + if ($this->failRetryService->isInRetryMode($ticket)) { + $retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket); + if ($retrySkip !== null) { + return $retrySkip; + } + } + if ($failThreshold > 0 && $failCount >= $failThreshold && !SplitSyncConfigService::hasFailRetrySchedule()) { return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold); } if (!in_array($ticketType, $autoSyncTypes, true)) { diff --git a/application/extra/site.php b/application/extra/site.php index dd4ca89..0c13209 100755 --- a/application/extra/site.php +++ b/application/extra/site.php @@ -56,6 +56,7 @@ return array ( 'split_sync_interval_whatshub' => '3', 'split_sync_interval_sihai' => '0', 'split_sync_fail_pause_threshold' => '6', + 'split_sync_fail_retry_minutes' => '', 'split_sync_interval_chatknow' => '5', 'split_sync_max_per_cron' => '27', 'split_sync_node_candidate_multiplier' => '13', diff --git a/patches/application/admin/command/Install/split_config_sync.sql b/patches/application/admin/command/Install/split_config_sync.sql index ee93eeb..21542b8 100755 --- a/patches/application/admin/command/Install/split_config_sync.sql +++ b/patches/application/admin/command/Install/split_config_sync.sql @@ -14,9 +14,13 @@ SELECT 'split_scrm_session_ttl_minutes', 'split', '云控 antiBot 会话 TTL(分 FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_scrm_session_ttl_minutes' LIMIT 1); INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) -SELECT 'split_sync_fail_pause_threshold', 'split', '连续同步失败暂停阈值', '连续同步失败达到该次数后自动关闭工单并暂停定时同步;同步成功后将清零重新计数;0 表示不因失败自动暂停', 'number', '', '5', '', '', '', NULL +SELECT 'split_sync_fail_pause_threshold', 'split', '连续同步失败暂停阈值', '连续同步失败达到该次数后暂停定时同步;同步成功后将清零重新计数;0 表示不因失败自动暂停', 'number', '', '5', '', '', '', NULL FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_pause_threshold' LIMIT 1); +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_fail_retry_minutes', 'split', '失败重试时间点(分钟)', '逗号分隔、升序,相对首次达失败暂停阈值后的绝对分钟数,如 10,20,30,50;留空则达阈值后永久暂停自动同步', 'string', '', '', '', '', ' placeholder="10,20,30,50"', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_retry_minutes' LIMIT 1); + INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) SELECT 'split_sync_interval_a2c', 'split', 'A2C云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_a2c' LIMIT 1); diff --git a/patches/application/admin/command/Install/split_sync_fail_retry.sql b/patches/application/admin/command/Install/split_sync_fail_retry.sql new file mode 100644 index 0000000..d75851c --- /dev/null +++ b/patches/application/admin/command/Install/split_sync_fail_retry.sql @@ -0,0 +1,11 @@ +-- 同步失败分阶段重试:系统配置 + 工单状态字段 +SET NAMES utf8mb4; + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_fail_retry_minutes', 'split', '失败重试时间点(分钟)', '逗号分隔、升序,相对首次达失败暂停阈值后的绝对分钟数,如 10,20,30,50;留空则达阈值后永久暂停自动同步', 'string', '', '', '', '', ' placeholder="10,20,30,50"', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_retry_minutes' LIMIT 1); + +ALTER TABLE `fa_split_ticket` + ADD COLUMN `sync_fail_pause_at` bigint(16) DEFAULT NULL COMMENT '达阈值进入重试的锚点时间' AFTER `sync_fail_count`, + ADD COLUMN `sync_fail_retry_index` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '已完成的重试次数(0=尚未重试)' AFTER `sync_fail_pause_at`, + ADD COLUMN `sync_auto_sync_stopped` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '1=全部重试失败,永久终止自动同步' AFTER `sync_fail_retry_index`; diff --git a/patches/application/admin/controller/split/Ticket.php b/patches/application/admin/controller/split/Ticket.php index 2babeff..0dcb0dd 100755 --- a/patches/application/admin/controller/split/Ticket.php +++ b/patches/application/admin/controller/split/Ticket.php @@ -278,6 +278,9 @@ class Ticket extends Backend $params['sync_success_time'], $params['sync_success_mode'], $params['sync_fail_count'], + $params['sync_fail_pause_at'], + $params['sync_fail_retry_index'], + $params['sync_auto_sync_stopped'], $params['speed_snapshot_count'], $params['speed_snapshot_time'], $params['click_count'], diff --git a/patches/application/admin/lang/zh-cn/split/ticket.php b/patches/application/admin/lang/zh-cn/split/ticket.php index b0a30e6..f1c2520 100755 --- a/patches/application/admin/lang/zh-cn/split/ticket.php +++ b/patches/application/admin/lang/zh-cn/split/ticket.php @@ -44,10 +44,14 @@ return [ 'Sync display pending' => '待同步', 'Sync display error' => '同步异常', 'Sync display auto paused' => '自动同步已暂停', + 'Sync display auto sync stopped' => '自动同步已终止(重试已用尽)', + 'Sync display retry waiting' => '自动同步已暂停,将于 %s 第 %d 次重试', 'Sync success time' => '最近同步成功', 'Sync mode auto' => '自动', 'Sync mode manual' => '手动', 'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停', + 'Sync tooltip auto sync stopped' => '分阶段重试已全部失败,自动同步已永久终止;手动同步成功后可恢复', + 'Sync tooltip retry waiting' => '连续失败 %s 次,将于 %s 进行第 %d 次自动重试', 'Sync tooltip error prefix' => '异常原因:', 'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log', 'Createtime' => '创建时间', diff --git a/patches/application/admin/model/split/Ticket.php b/patches/application/admin/model/split/Ticket.php index a7782f3..a73360a 100755 --- a/patches/application/admin/model/split/Ticket.php +++ b/patches/application/admin/model/split/Ticket.php @@ -6,6 +6,7 @@ namespace app\admin\model\split; use app\common\service\SplitSyncConfigService; use app\common\service\SplitTicketRuleService; +use app\common\service\SplitTicketSyncFailRetryService; use app\common\service\SplitTicketSyncLockService; use think\Db; use think\Model; @@ -176,10 +177,23 @@ class Ticket extends Model if ((string) ($data['status'] ?? '') !== 'normal') { return false; } + + $retryService = new SplitTicketSyncFailRetryService(); + if ($retryService->isPermanentlyStopped($data)) { + return true; + } + if ($retryService->isInRetryMode($data)) { + return true; + } + $threshold = SplitSyncConfigService::getFailPauseThreshold(); if ($threshold <= 0) { return false; } + if (SplitSyncConfigService::hasFailRetrySchedule()) { + return false; + } + return (int) ($data['sync_fail_count'] ?? 0) >= $threshold; } @@ -191,9 +205,26 @@ class Ticket extends Model */ public function getSyncDisplayTextAttr($value, $data): string { + $retryService = new SplitTicketSyncFailRetryService(); + + if ($retryService->isPermanentlyStopped($data)) { + return (string) __('Sync display auto sync stopped'); + } + + if ($retryService->isInRetryMode($data)) { + $nextAt = $retryService->getNextRetryAt($data); + $attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1; + if ($nextAt !== null) { + return sprintf( + (string) __('Sync display retry waiting'), + date('H:i', $nextAt), + $attempt + ); + } + } + if (self::isAutoSyncPaused($data)) { - $threshold = SplitSyncConfigService::getFailPauseThreshold(); - return sprintf((string) __('Sync display auto paused'), $threshold); + return (string) __('Sync display auto paused'); } $status = (string) ($data['sync_status'] ?? 'pending'); @@ -216,7 +247,22 @@ class Ticket extends Model public function getSyncTooltipTextAttr($value, $data): string { $parts = []; - if (self::isAutoSyncPaused($data)) { + $retryService = new SplitTicketSyncFailRetryService(); + + if ($retryService->isPermanentlyStopped($data)) { + $parts[] = (string) __('Sync tooltip auto sync stopped'); + } elseif ($retryService->isInRetryMode($data)) { + $nextAt = $retryService->getNextRetryAt($data); + $attempt = (int) ($data['sync_fail_retry_index'] ?? 0) + 1; + if ($nextAt !== null) { + $parts[] = sprintf( + (string) __('Sync tooltip retry waiting'), + (int) ($data['sync_fail_count'] ?? 0), + date('Y-m-d H:i:s', $nextAt), + $attempt + ); + } + } elseif (self::isAutoSyncPaused($data)) { $threshold = SplitSyncConfigService::getFailPauseThreshold(); $parts[] = sprintf( (string) __('Sync tooltip auto paused'), diff --git a/patches/application/common/service/SplitSyncConfigService.php b/patches/application/common/service/SplitSyncConfigService.php index c667c3c..33f7d63 100755 --- a/patches/application/common/service/SplitSyncConfigService.php +++ b/patches/application/common/service/SplitSyncConfigService.php @@ -24,6 +24,12 @@ class SplitSyncConfigService return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST; } + /** 失败重试时间点最多配置条数 */ + private const FAIL_RETRY_SCHEDULE_MAX = 10; + + /** 单条重试时间点上限(分钟) */ + private const FAIL_RETRY_MINUTE_MAX = 1440; + /** * 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停) */ @@ -36,6 +42,50 @@ class SplitSyncConfigService return max(0, (int) $value); } + /** + * 达失败暂停阈值后的重试时间点(分钟,相对锚点的绝对偏移,升序去重) + * + * @return list + */ + public static function getFailRetryMinutes(): array + { + $raw = trim(self::getConfigValue('split_sync_fail_retry_minutes')); + if ($raw === '') { + return []; + } + + $parts = preg_split('/[,,\s]+/', $raw) ?: []; + $minutes = []; + foreach ($parts as $part) { + $part = trim((string) $part); + if ($part === '' || !ctype_digit($part)) { + continue; + } + $minute = (int) $part; + if ($minute < 1 || $minute > self::FAIL_RETRY_MINUTE_MAX) { + continue; + } + $minutes[$minute] = $minute; + } + + if ($minutes === []) { + return []; + } + + $list = array_values($minutes); + sort($list, SORT_NUMERIC); + + return array_slice($list, 0, self::FAIL_RETRY_SCHEDULE_MAX); + } + + /** + * 是否配置了失败分阶段重试时间点 + */ + public static function hasFailRetrySchedule(): bool + { + return self::getFailRetryMinutes() !== []; + } + /** * 指定工单类型的自动同步周期(分钟),0 表示不自动同步 */ diff --git a/patches/application/common/service/SplitTicketSyncFailRetryService.php b/patches/application/common/service/SplitTicketSyncFailRetryService.php new file mode 100644 index 0000000..d1c7599 --- /dev/null +++ b/patches/application/common/service/SplitTicketSyncFailRetryService.php @@ -0,0 +1,168 @@ + $ticket + */ + public function isPermanentlyStopped($ticket): bool + { + return (int) ($this->readField($ticket, 'sync_auto_sync_stopped') ?? 0) === 1; + } + + /** + * 是否处于失败重试等待/重试周期内(未永久终止) + * + * @param Ticket|array $ticket + */ + public function isInRetryMode($ticket): bool + { + if ($this->isPermanentlyStopped($ticket)) { + return false; + } + + return (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0) > 0; + } + + /** + * 首次达失败阈值时进入重试态(工单保持 normal,不关单) + * + * @return array 需合并进 save 的字段;无变更时返回空数组 + */ + public function enterRetryIfNeeded(Ticket $ticket, int $failCount, int $threshold): array + { + if ($failCount < $threshold || !SplitSyncConfigService::hasFailRetrySchedule()) { + return []; + } + if ($this->isInRetryMode($ticket)) { + return []; + } + + return [ + 'sync_fail_pause_at' => time(), + 'sync_fail_retry_index' => 0, + 'sync_auto_sync_stopped' => 0, + ]; + } + + /** + * 计划内重试失败后递进索引;用尽全部时间点后标记永久终止 + * + * @return array + */ + public function onRetryFailure(Ticket $ticket): array + { + $schedule = SplitSyncConfigService::getFailRetryMinutes(); + $index = (int) ($ticket['sync_fail_retry_index'] ?? 0) + 1; + $update = ['sync_fail_retry_index' => $index]; + + if ($index >= count($schedule)) { + $update['sync_auto_sync_stopped'] = 1; + } + + return $update; + } + + /** + * 同步成功时清零失败计数与重试相关字段 + * + * @return array + */ + public function clearOnSuccessFields(): array + { + return [ + 'sync_fail_count' => 0, + 'sync_fail_pause_at' => null, + 'sync_fail_retry_index' => 0, + 'sync_auto_sync_stopped' => 0, + ]; + } + + /** + * Cron 自动重试是否已到点;未到点返回 skip 原因,到点返回 null(允许执行) + * + * @param Ticket|array $ticket + */ + public function shouldAllowAutoRetry($ticket, ?int $now = null): ?string + { + if (!$this->isInRetryMode($ticket)) { + return null; + } + if ($this->isPermanentlyStopped($ticket)) { + return '自动同步已终止(重试已用尽)'; + } + + $nextAt = $this->getNextRetryAt($ticket); + if ($nextAt === null) { + return '自动同步已终止(重试已用尽)'; + } + + $now = $now ?? time(); + if ($now < $nextAt) { + $attempt = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0) + 1; + + return sprintf( + '失败重试等待中,将于 %s 进行第 %d 次重试', + date('Y-m-d H:i:s', $nextAt), + $attempt + ); + } + + return null; + } + + /** + * 下一次计划重试的 Unix 时间戳;无待重试时返回 null + * + * @param Ticket|array $ticket + */ + public function getNextRetryAt($ticket): ?int + { + if (!$this->isInRetryMode($ticket) || $this->isPermanentlyStopped($ticket)) { + return null; + } + + $schedule = SplitSyncConfigService::getFailRetryMinutes(); + if ($schedule === []) { + return null; + } + + $index = (int) ($this->readField($ticket, 'sync_fail_retry_index') ?? 0); + if ($index >= count($schedule)) { + return null; + } + + $pauseAt = (int) ($this->readField($ticket, 'sync_fail_pause_at') ?? 0); + if ($pauseAt <= 0) { + return null; + } + + return $pauseAt + $schedule[$index] * 60; + } + + /** + * @param Ticket|array $ticket + */ + private function readField($ticket, string $field) + { + if ($ticket instanceof Ticket) { + return $ticket->getData($field) ?? ($ticket[$field] ?? null); + } + + return $ticket[$field] ?? null; + } +} diff --git a/patches/application/common/service/SplitTicketSyncService.php b/patches/application/common/service/SplitTicketSyncService.php index 87ae830..2e30af2 100755 --- a/patches/application/common/service/SplitTicketSyncService.php +++ b/patches/application/common/service/SplitTicketSyncService.php @@ -23,12 +23,15 @@ class SplitTicketSyncService private SplitTicketSyncDispatchService $dispatchService; + private SplitTicketSyncFailRetryService $failRetryService; + public function __construct() { $this->numberSync = new SplitTicketNumberSyncService(); $this->ruleService = new SplitTicketRuleService(); $this->lockService = new SplitTicketSyncLockService(); $this->dispatchService = new SplitTicketSyncDispatchService(); + $this->failRetryService = new SplitTicketSyncFailRetryService(); } /** @@ -400,7 +403,13 @@ class SplitTicketSyncService ->where('manual_manage', 0) ->whereIn('ticket_type', $types); if ($failThreshold > 0) { - $query->where('sync_fail_count', '<', $failThreshold); + $query->where(function ($query) use ($failThreshold): void { + $query->where('sync_fail_count', '<', $failThreshold) + ->whereOr(function ($query): void { + $query->where('sync_fail_pause_at', '>', 0) + ->where('sync_auto_sync_stopped', 0); + }); + }); } $query->order('sync_time', 'asc')->order('id', 'asc'); if ($limit > 0) { @@ -613,6 +622,27 @@ class SplitTicketSyncService if ((string) $ticket['status'] === 'hidden') { return '工单已关闭'; } + + if ($this->failRetryService->isPermanentlyStopped($ticket)) { + return '自动同步已终止(重试已用尽)'; + } + + if ($this->failRetryService->isInRetryMode($ticket)) { + $retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket); + if ($retrySkip !== null) { + return $retrySkip; + } + if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) { + return '工单类型尚未实现'; + } + $interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']); + if ($interval <= 0) { + return '该类型未配置自动同步周期'; + } + + return null; + } + $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) { return sprintf('连续同步失败超过%d次已暂停', $failThreshold); @@ -661,6 +691,7 @@ class SplitTicketSyncService if ($success) { $data['sync_success_time'] = $now; $data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual'; + $data = array_merge($data, $this->failRetryService->clearOnSuccessFields()); } if (!$ticket->allowField(array_keys($data))->save($data)) { throw new Exception('工单同步结果保存失败'); @@ -679,6 +710,21 @@ class SplitTicketSyncService 'sync_message' => $message, 'sync_fail_count' => $failCount, ]; + + if ($this->failRetryService->isInRetryMode($ticket)) { + $update = array_merge($update, $this->failRetryService->onRetryFailure($ticket)); + $ticket->save($update); + + return; + } + + if ($failThreshold > 0 && $failCount >= $failThreshold && SplitSyncConfigService::hasFailRetrySchedule()) { + $update = array_merge($update, $this->failRetryService->enterRetryIfNeeded($ticket, $failCount, $failThreshold)); + $ticket->save($update); + + return; + } + if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) { $update['status'] = 'hidden'; } @@ -787,7 +833,7 @@ class SplitTicketSyncService $openList = Ticket::where('status', 'normal') ->whereIn('ticket_type', $supportedTypes) - ->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status') + ->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status,sync_fail_pause_at,sync_fail_retry_index,sync_auto_sync_stopped,status,manual_manage') ->select(); $result = []; @@ -838,7 +884,16 @@ class SplitTicketSyncService $ticketType = (string) ($ticket['ticket_type'] ?? ''); $failCount = (int) ($ticket['sync_fail_count'] ?? 0); - if ($failThreshold > 0 && $failCount >= $failThreshold) { + if ($this->failRetryService->isPermanentlyStopped($ticket)) { + return '自动同步已终止(重试已用尽)'; + } + if ($this->failRetryService->isInRetryMode($ticket)) { + $retrySkip = $this->failRetryService->shouldAllowAutoRetry($ticket); + if ($retrySkip !== null) { + return $retrySkip; + } + } + if ($failThreshold > 0 && $failCount >= $failThreshold && !SplitSyncConfigService::hasFailRetrySchedule()) { return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold); } if (!in_array($ticketType, $autoSyncTypes, true)) { diff --git a/patches/application/extra/site.php b/patches/application/extra/site.php new file mode 100755 index 0000000..0c13209 --- /dev/null +++ b/patches/application/extra/site.php @@ -0,0 +1,67 @@ + 'link管理系统', + 'beian' => '', + 'cdnurl' => '', + 'version' => '1.0.10', + 'timezone' => 'Asia/Shanghai', + 'forbiddenip' => '', + 'languages' => + array ( + 'backend' => 'zh-cn', + 'frontend' => 'zh-cn', + ), + 'fixedpage' => 'dashboard', + 'categorytype' => + array ( + 'default' => 'Default', + 'page' => 'Page', + 'article' => 'Article', + 'test' => 'Test', + ), + 'configgroup' => + array ( + 'basic' => 'Basic', + 'email' => 'Email', + 'dictionary' => 'Dictionary', + 'user' => 'User', + 'example' => 'Example', + 'split' => '分流设置', + ), + 'mail_type' => '1', + 'mail_smtp_host' => 'smtp.qq.com', + 'mail_smtp_port' => '465', + 'mail_smtp_user' => '', + 'mail_smtp_pass' => '', + 'mail_verify_type' => '2', + 'mail_from' => '', + 'attachmentcategory' => + array ( + 'category1' => 'Category1', + 'category2' => 'Category2', + 'custom' => 'Custom', + ), + 'split_platform_domain' => 'flowerbells.top', + 'split_scrm_node_host' => 'http://127.0.0.1:3001', + 'split_sync_interval_a2c' => '3', + 'split_sync_interval_haiwang' => '3', + 'split_sync_interval_huojian' => '3', + 'split_sync_interval_xinghe' => '3', + 'split_sync_interval_ss_customer' => '3', + 'split_sync_interval_ceo_scrm' => '0', + 'split_sync_interval_taiji' => '0', + 'split_sync_interval_ss_channel' => '0', + 'split_sync_interval_yifafa' => '0', + 'split_sync_interval_whatshub' => '3', + 'split_sync_interval_sihai' => '0', + 'split_sync_fail_pause_threshold' => '6', + 'split_sync_fail_retry_minutes' => '', + 'split_sync_interval_chatknow' => '5', + 'split_sync_max_per_cron' => '27', + 'split_sync_node_candidate_multiplier' => '13', + 'split_sync_cron_lock_ttl' => '680', + 'split_sync_node_queue_threshold' => '8', + 'split_scrm_antibot_types' => 'whatshub,chatknow,a2c,huojian', + 'split_scrm_session_ttl_minutes' => '120', +);