连续同步失败后触发重试时间点

This commit is contained in:
root
2026-07-13 23:06:53 +08:00
parent 76692e0021
commit 98fc6d9e3d
17 changed files with 759 additions and 13 deletions
@@ -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`;
@@ -278,6 +278,9 @@ class Ticket extends Backend
$params['sync_success_time'], $params['sync_success_time'],
$params['sync_success_mode'], $params['sync_success_mode'],
$params['sync_fail_count'], $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_count'],
$params['speed_snapshot_time'], $params['speed_snapshot_time'],
$params['click_count'], $params['click_count'],
@@ -44,10 +44,14 @@ return [
'Sync display pending' => '待同步', 'Sync display pending' => '待同步',
'Sync display error' => '同步异常', 'Sync display error' => '同步异常',
'Sync display auto paused' => '自动同步已暂停', 'Sync display auto paused' => '自动同步已暂停',
'Sync display auto sync stopped' => '自动同步已终止(重试已用尽)',
'Sync display retry waiting' => '自动同步已暂停,将于 %s 第 %d 次重试',
'Sync success time' => '最近同步成功', 'Sync success time' => '最近同步成功',
'Sync mode auto' => '自动', 'Sync mode auto' => '自动',
'Sync mode manual' => '手动', 'Sync mode manual' => '手动',
'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停', 'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停',
'Sync tooltip auto sync stopped' => '分阶段重试已全部失败,自动同步已永久终止;手动同步成功后可恢复',
'Sync tooltip retry waiting' => '连续失败 %s 次,将于 %s 进行第 %d 次自动重试',
'Sync tooltip error prefix' => '异常原因:', 'Sync tooltip error prefix' => '异常原因:',
'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log', 'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log',
'Createtime' => '创建时间', 'Createtime' => '创建时间',
+49 -3
View File
@@ -6,6 +6,7 @@ namespace app\admin\model\split;
use app\common\service\SplitSyncConfigService; use app\common\service\SplitSyncConfigService;
use app\common\service\SplitTicketRuleService; use app\common\service\SplitTicketRuleService;
use app\common\service\SplitTicketSyncFailRetryService;
use app\common\service\SplitTicketSyncLockService; use app\common\service\SplitTicketSyncLockService;
use think\Db; use think\Db;
use think\Model; use think\Model;
@@ -176,10 +177,23 @@ class Ticket extends Model
if ((string) ($data['status'] ?? '') !== 'normal') { if ((string) ($data['status'] ?? '') !== 'normal') {
return false; return false;
} }
$retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
return true;
}
if ($retryService->isInRetryMode($data)) {
return true;
}
$threshold = SplitSyncConfigService::getFailPauseThreshold(); $threshold = SplitSyncConfigService::getFailPauseThreshold();
if ($threshold <= 0) { if ($threshold <= 0) {
return false; return false;
} }
if (SplitSyncConfigService::hasFailRetrySchedule()) {
return false;
}
return (int) ($data['sync_fail_count'] ?? 0) >= $threshold; return (int) ($data['sync_fail_count'] ?? 0) >= $threshold;
} }
@@ -191,9 +205,26 @@ class Ticket extends Model
*/ */
public function getSyncDisplayTextAttr($value, $data): string 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)) { if (self::isAutoSyncPaused($data)) {
$threshold = SplitSyncConfigService::getFailPauseThreshold(); return (string) __('Sync display auto paused');
return sprintf((string) __('Sync display auto paused'), $threshold);
} }
$status = (string) ($data['sync_status'] ?? 'pending'); $status = (string) ($data['sync_status'] ?? 'pending');
@@ -216,7 +247,22 @@ class Ticket extends Model
public function getSyncTooltipTextAttr($value, $data): string public function getSyncTooltipTextAttr($value, $data): string
{ {
$parts = []; $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(); $threshold = SplitSyncConfigService::getFailPauseThreshold();
$parts[] = sprintf( $parts[] = sprintf(
(string) __('Sync tooltip auto paused'), (string) __('Sync tooltip auto paused'),
@@ -24,6 +24,12 @@ class SplitSyncConfigService
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST; return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
} }
/** 失败重试时间点最多配置条数 */
private const FAIL_RETRY_SCHEDULE_MAX = 10;
/** 单条重试时间点上限(分钟) */
private const FAIL_RETRY_MINUTE_MAX = 1440;
/** /**
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停) * 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
*/ */
@@ -36,6 +42,50 @@ class SplitSyncConfigService
return max(0, (int) $value); return max(0, (int) $value);
} }
/**
* 达失败暂停阈值后的重试时间点(分钟,相对锚点的绝对偏移,升序去重)
*
* @return list<int>
*/
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 表示不自动同步 * 指定工单类型的自动同步周期(分钟),0 表示不自动同步
*/ */
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Ticket;
/**
* 工单同步失败分阶段重试状态机
*
* 达连续失败阈值后按配置时间点(相对锚点的绝对分钟偏移)自动重试;
* 任一次成功则清零恢复常态,全部失败则永久终止自动同步。
*/
class SplitTicketSyncFailRetryService
{
/**
* 是否已永久终止自动同步(重试时间点已全部用尽)
*
* @param Ticket|array<string, mixed> $ticket
*/
public function isPermanentlyStopped($ticket): bool
{
return (int) ($this->readField($ticket, 'sync_auto_sync_stopped') ?? 0) === 1;
}
/**
* 是否处于失败重试等待/重试周期内(未永久终止)
*
* @param Ticket|array<string, mixed> $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<string, mixed> 需合并进 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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $ticket
*/
private function readField($ticket, string $field)
{
if ($ticket instanceof Ticket) {
return $ticket->getData($field) ?? ($ticket[$field] ?? null);
}
return $ticket[$field] ?? null;
}
}
@@ -23,12 +23,15 @@ class SplitTicketSyncService
private SplitTicketSyncDispatchService $dispatchService; private SplitTicketSyncDispatchService $dispatchService;
private SplitTicketSyncFailRetryService $failRetryService;
public function __construct() public function __construct()
{ {
$this->numberSync = new SplitTicketNumberSyncService(); $this->numberSync = new SplitTicketNumberSyncService();
$this->ruleService = new SplitTicketRuleService(); $this->ruleService = new SplitTicketRuleService();
$this->lockService = new SplitTicketSyncLockService(); $this->lockService = new SplitTicketSyncLockService();
$this->dispatchService = new SplitTicketSyncDispatchService(); $this->dispatchService = new SplitTicketSyncDispatchService();
$this->failRetryService = new SplitTicketSyncFailRetryService();
} }
/** /**
@@ -400,7 +403,13 @@ class SplitTicketSyncService
->where('manual_manage', 0) ->where('manual_manage', 0)
->whereIn('ticket_type', $types); ->whereIn('ticket_type', $types);
if ($failThreshold > 0) { 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'); $query->order('sync_time', 'asc')->order('id', 'asc');
if ($limit > 0) { if ($limit > 0) {
@@ -613,6 +622,27 @@ class SplitTicketSyncService
if ((string) $ticket['status'] === 'hidden') { if ((string) $ticket['status'] === 'hidden') {
return '工单已关闭'; 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(); $failThreshold = SplitSyncConfigService::getFailPauseThreshold();
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) { if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
return sprintf('连续同步失败超过%d次已暂停', $failThreshold); return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
@@ -661,6 +691,7 @@ class SplitTicketSyncService
if ($success) { if ($success) {
$data['sync_success_time'] = $now; $data['sync_success_time'] = $now;
$data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual'; $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)) { if (!$ticket->allowField(array_keys($data))->save($data)) {
throw new Exception('工单同步结果保存失败'); throw new Exception('工单同步结果保存失败');
@@ -679,6 +710,21 @@ class SplitTicketSyncService
'sync_message' => $message, 'sync_message' => $message,
'sync_fail_count' => $failCount, '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)) { if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
$update['status'] = 'hidden'; $update['status'] = 'hidden';
} }
@@ -787,7 +833,7 @@ class SplitTicketSyncService
$openList = Ticket::where('status', 'normal') $openList = Ticket::where('status', 'normal')
->whereIn('ticket_type', $supportedTypes) ->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(); ->select();
$result = []; $result = [];
@@ -838,7 +884,16 @@ class SplitTicketSyncService
$ticketType = (string) ($ticket['ticket_type'] ?? ''); $ticketType = (string) ($ticket['ticket_type'] ?? '');
$failCount = (int) ($ticket['sync_fail_count'] ?? 0); $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); return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold);
} }
if (!in_array($ticketType, $autoSyncTypes, true)) { if (!in_array($ticketType, $autoSyncTypes, true)) {
+1
View File
@@ -56,6 +56,7 @@ return array (
'split_sync_interval_whatshub' => '3', 'split_sync_interval_whatshub' => '3',
'split_sync_interval_sihai' => '0', 'split_sync_interval_sihai' => '0',
'split_sync_fail_pause_threshold' => '6', 'split_sync_fail_pause_threshold' => '6',
'split_sync_fail_retry_minutes' => '',
'split_sync_interval_chatknow' => '5', 'split_sync_interval_chatknow' => '5',
'split_sync_max_per_cron' => '27', 'split_sync_max_per_cron' => '27',
'split_sync_node_candidate_multiplier' => '13', 'split_sync_node_candidate_multiplier' => '13',
@@ -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); 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`) 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); 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`) 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 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); FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_a2c' LIMIT 1);
@@ -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`;
@@ -278,6 +278,9 @@ class Ticket extends Backend
$params['sync_success_time'], $params['sync_success_time'],
$params['sync_success_mode'], $params['sync_success_mode'],
$params['sync_fail_count'], $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_count'],
$params['speed_snapshot_time'], $params['speed_snapshot_time'],
$params['click_count'], $params['click_count'],
@@ -44,10 +44,14 @@ return [
'Sync display pending' => '待同步', 'Sync display pending' => '待同步',
'Sync display error' => '同步异常', 'Sync display error' => '同步异常',
'Sync display auto paused' => '自动同步已暂停', 'Sync display auto paused' => '自动同步已暂停',
'Sync display auto sync stopped' => '自动同步已终止(重试已用尽)',
'Sync display retry waiting' => '自动同步已暂停,将于 %s 第 %d 次重试',
'Sync success time' => '最近同步成功', 'Sync success time' => '最近同步成功',
'Sync mode auto' => '自动', 'Sync mode auto' => '自动',
'Sync mode manual' => '手动', 'Sync mode manual' => '手动',
'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停', 'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停',
'Sync tooltip auto sync stopped' => '分阶段重试已全部失败,自动同步已永久终止;手动同步成功后可恢复',
'Sync tooltip retry waiting' => '连续失败 %s 次,将于 %s 进行第 %d 次自动重试',
'Sync tooltip error prefix' => '异常原因:', 'Sync tooltip error prefix' => '异常原因:',
'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log', 'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log',
'Createtime' => '创建时间', 'Createtime' => '创建时间',
@@ -6,6 +6,7 @@ namespace app\admin\model\split;
use app\common\service\SplitSyncConfigService; use app\common\service\SplitSyncConfigService;
use app\common\service\SplitTicketRuleService; use app\common\service\SplitTicketRuleService;
use app\common\service\SplitTicketSyncFailRetryService;
use app\common\service\SplitTicketSyncLockService; use app\common\service\SplitTicketSyncLockService;
use think\Db; use think\Db;
use think\Model; use think\Model;
@@ -176,10 +177,23 @@ class Ticket extends Model
if ((string) ($data['status'] ?? '') !== 'normal') { if ((string) ($data['status'] ?? '') !== 'normal') {
return false; return false;
} }
$retryService = new SplitTicketSyncFailRetryService();
if ($retryService->isPermanentlyStopped($data)) {
return true;
}
if ($retryService->isInRetryMode($data)) {
return true;
}
$threshold = SplitSyncConfigService::getFailPauseThreshold(); $threshold = SplitSyncConfigService::getFailPauseThreshold();
if ($threshold <= 0) { if ($threshold <= 0) {
return false; return false;
} }
if (SplitSyncConfigService::hasFailRetrySchedule()) {
return false;
}
return (int) ($data['sync_fail_count'] ?? 0) >= $threshold; return (int) ($data['sync_fail_count'] ?? 0) >= $threshold;
} }
@@ -191,9 +205,26 @@ class Ticket extends Model
*/ */
public function getSyncDisplayTextAttr($value, $data): string 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)) { if (self::isAutoSyncPaused($data)) {
$threshold = SplitSyncConfigService::getFailPauseThreshold(); return (string) __('Sync display auto paused');
return sprintf((string) __('Sync display auto paused'), $threshold);
} }
$status = (string) ($data['sync_status'] ?? 'pending'); $status = (string) ($data['sync_status'] ?? 'pending');
@@ -216,7 +247,22 @@ class Ticket extends Model
public function getSyncTooltipTextAttr($value, $data): string public function getSyncTooltipTextAttr($value, $data): string
{ {
$parts = []; $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(); $threshold = SplitSyncConfigService::getFailPauseThreshold();
$parts[] = sprintf( $parts[] = sprintf(
(string) __('Sync tooltip auto paused'), (string) __('Sync tooltip auto paused'),
@@ -24,6 +24,12 @@ class SplitSyncConfigService
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST; return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
} }
/** 失败重试时间点最多配置条数 */
private const FAIL_RETRY_SCHEDULE_MAX = 10;
/** 单条重试时间点上限(分钟) */
private const FAIL_RETRY_MINUTE_MAX = 1440;
/** /**
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停) * 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
*/ */
@@ -36,6 +42,50 @@ class SplitSyncConfigService
return max(0, (int) $value); return max(0, (int) $value);
} }
/**
* 达失败暂停阈值后的重试时间点(分钟,相对锚点的绝对偏移,升序去重)
*
* @return list<int>
*/
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 表示不自动同步 * 指定工单类型的自动同步周期(分钟),0 表示不自动同步
*/ */
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Ticket;
/**
* 工单同步失败分阶段重试状态机
*
* 达连续失败阈值后按配置时间点(相对锚点的绝对分钟偏移)自动重试;
* 任一次成功则清零恢复常态,全部失败则永久终止自动同步。
*/
class SplitTicketSyncFailRetryService
{
/**
* 是否已永久终止自动同步(重试时间点已全部用尽)
*
* @param Ticket|array<string, mixed> $ticket
*/
public function isPermanentlyStopped($ticket): bool
{
return (int) ($this->readField($ticket, 'sync_auto_sync_stopped') ?? 0) === 1;
}
/**
* 是否处于失败重试等待/重试周期内(未永久终止)
*
* @param Ticket|array<string, mixed> $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<string, mixed> 需合并进 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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $ticket
*/
private function readField($ticket, string $field)
{
if ($ticket instanceof Ticket) {
return $ticket->getData($field) ?? ($ticket[$field] ?? null);
}
return $ticket[$field] ?? null;
}
}
@@ -23,12 +23,15 @@ class SplitTicketSyncService
private SplitTicketSyncDispatchService $dispatchService; private SplitTicketSyncDispatchService $dispatchService;
private SplitTicketSyncFailRetryService $failRetryService;
public function __construct() public function __construct()
{ {
$this->numberSync = new SplitTicketNumberSyncService(); $this->numberSync = new SplitTicketNumberSyncService();
$this->ruleService = new SplitTicketRuleService(); $this->ruleService = new SplitTicketRuleService();
$this->lockService = new SplitTicketSyncLockService(); $this->lockService = new SplitTicketSyncLockService();
$this->dispatchService = new SplitTicketSyncDispatchService(); $this->dispatchService = new SplitTicketSyncDispatchService();
$this->failRetryService = new SplitTicketSyncFailRetryService();
} }
/** /**
@@ -400,7 +403,13 @@ class SplitTicketSyncService
->where('manual_manage', 0) ->where('manual_manage', 0)
->whereIn('ticket_type', $types); ->whereIn('ticket_type', $types);
if ($failThreshold > 0) { 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'); $query->order('sync_time', 'asc')->order('id', 'asc');
if ($limit > 0) { if ($limit > 0) {
@@ -613,6 +622,27 @@ class SplitTicketSyncService
if ((string) $ticket['status'] === 'hidden') { if ((string) $ticket['status'] === 'hidden') {
return '工单已关闭'; 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(); $failThreshold = SplitSyncConfigService::getFailPauseThreshold();
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) { if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
return sprintf('连续同步失败超过%d次已暂停', $failThreshold); return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
@@ -661,6 +691,7 @@ class SplitTicketSyncService
if ($success) { if ($success) {
$data['sync_success_time'] = $now; $data['sync_success_time'] = $now;
$data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual'; $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)) { if (!$ticket->allowField(array_keys($data))->save($data)) {
throw new Exception('工单同步结果保存失败'); throw new Exception('工单同步结果保存失败');
@@ -679,6 +710,21 @@ class SplitTicketSyncService
'sync_message' => $message, 'sync_message' => $message,
'sync_fail_count' => $failCount, '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)) { if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
$update['status'] = 'hidden'; $update['status'] = 'hidden';
} }
@@ -787,7 +833,7 @@ class SplitTicketSyncService
$openList = Ticket::where('status', 'normal') $openList = Ticket::where('status', 'normal')
->whereIn('ticket_type', $supportedTypes) ->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(); ->select();
$result = []; $result = [];
@@ -838,7 +884,16 @@ class SplitTicketSyncService
$ticketType = (string) ($ticket['ticket_type'] ?? ''); $ticketType = (string) ($ticket['ticket_type'] ?? '');
$failCount = (int) ($ticket['sync_fail_count'] ?? 0); $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); return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold);
} }
if (!in_array($ticketType, $autoSyncTypes, true)) { if (!in_array($ticketType, $autoSyncTypes, true)) {
+67
View File
@@ -0,0 +1,67 @@
<?php
return array (
'name' => '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',
);