Files
biz_xfj_cloud/lib/common/utils/sync/sync_check_tool.dart
T
2026-09-04 13:15:55 +08:00

1214 lines
45 KiB
Dart

import 'dart:async';
import 'package:cron/cron.dart';
import 'package:get/get.dart';
import 'package:mqtt_client/mqtt_client.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 应用进程内任务调度入口。
///
/// 收银终端常驻时 Cron 可提供秒级规则;Android 杀死进程后不会继续执行,启动后由本类重新注册。
class SyncCheckTool {
/// 调度器写入本地诊断日志时使用的稳定模块标签。
static const _tag = '终端任务调度';
/// 禁止外部创建第二套调度状态,确保 Cron、运行集合和待执行队列全局唯一。
SyncCheckTool._();
/// 应用级任务调度单例,由 AppControl 初始化并由设置页读取响应式状态。
static final SyncCheckTool instance = SyncCheckTool._();
/// 打印角色允许注册的后台任务;其余商品、人脸、订单和取餐任务属于收银角色。
static const Set<ScheduledTaskType> _printerTaskTypes = {
ScheduledTaskType.mqReconnect,
ScheduledTaskType.printCheck,
ScheduledTaskType.deviceStatus,
ScheduledTaskType.autoUpdate,
ScheduledTaskType.terminalLogUpload,
ScheduledTaskType.expiredLogCleanup,
};
/// 当前已加载的任务配置,以稳定任务类型为键并向设置页提供响应式更新。
final configs = <ScheduledTaskType, ScheduledTaskConfig>{}.obs;
/// 每个任务最近一次执行结果快照,以稳定任务类型为键。
final records = <ScheduledTaskType, ScheduledTaskRecord>{}.obs;
/// 正在实际执行业务动作的任务集合,用于禁止同任务并发并驱动运行状态 UI。
final runningTasks = <ScheduledTaskType>{}.obs;
/// 正在等待安全窗口或等待前一次同类任务结束的任务集合。
final pendingTasks = <ScheduledTaskType>{}.obs;
/// 最近一次终端日志上传统计,供设置页展示扫描、跳过、成功和失败数量。
final terminalLogSummary = const TerminalLogUploadSummary().obs;
/// 当前进程内 Cron 调度器;任务全部停止后置空并关闭其内部计时资源。
Cron? _cron;
/// 已注册到 Cron 的任务句柄,以任务类型为键支持单任务重建和取消。
final Map<ScheduledTaskType, ScheduledTask> _scheduledTasks = {};
/// 同任务合并后的待执行请求,记录触发来源和需要满足的空闲级别。
final Map<ScheduledTaskType, _PendingTaskRequest> _pendingRequests = {};
/// 每 5 秒检查待执行任务安全窗口的轮询计时器;队列清空后自动释放。
Timer? _idleTimer;
/// 正在进行的释放操作,避免生命周期重复回调并发关闭同一个 Cron 实例。
Future<void>? _disposeFuture;
/// 是否已经完成本轮启动,防止 AppControl 重复注册相同 Cron 任务。
bool _started = false;
/// 当前调度任务对应的终端角色;设备首次绑定改变角色时据此重建任务集合。
TerminalRole? _activeRole;
/// 当前已经注册的终端角色,供状态诊断和测试确认角色切换结果。
TerminalRole? get activeRole => _activeRole;
/// 当前注册到 Cron 的任务类型快照。
Set<ScheduledTaskType> get scheduledTaskTypes =>
Set.unmodifiable(_scheduledTasks.keys);
/// 当前进程最近一次补排部门人脸同步的本地日期,保证同一天最多自动补排一次。
DateTime? _lastDepartmentFaceCatchUpDate;
/// 常用规则均使用 cron 0.6.2 可解析的标准字段、范围和步长,不包含 Quartz 专属的 L/W/#。
static const List<ScheduledCronPreset> cronPresets = [
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 30 秒',
expression: '*/30 * * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 1 分钟',
expression: '0 * * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 2 分钟',
expression: '0 */2 * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 3 分钟',
expression: '0 */3 * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 5 分钟',
expression: '0 */5 * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 10 分钟',
expression: '0 */10 * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 15 分钟',
expression: '0 */15 * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 20 分钟',
expression: '0 */20 * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 30 分钟',
expression: '0 */30 * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 45 分钟',
expression: '0 */45 * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每小时',
expression: '0 0 * * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 2 小时',
expression: '0 0 */2 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 3 小时',
expression: '0 0 */3 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 4 小时',
expression: '0 0 */4 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 6 小时',
expression: '0 0 */6 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 8 小时',
expression: '0 0 */8 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.interval,
label: '每 12 小时',
expression: '0 0 */12 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 00:00',
expression: '0 0 0 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 00:10',
expression: '0 10 0 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 00:30',
expression: '0 30 0 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 00:40',
expression: '0 40 0 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 01:00',
expression: '0 0 1 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 02:00',
expression: '0 0 2 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 03:00',
expression: '0 0 3 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 04:00',
expression: '0 0 4 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 06:00',
expression: '0 0 6 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 08:00',
expression: '0 0 8 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 09:00',
expression: '0 0 9 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 12:00',
expression: '0 0 12 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 18:00',
expression: '0 0 18 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 21:00',
expression: '0 0 21 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 22:00',
expression: '0 0 22 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.daily,
label: '每天 23:00',
expression: '0 0 23 * * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.weekly,
label: '工作日 08:00',
expression: '0 0 8 * * 1-5'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.weekly,
label: '工作日 18:00',
expression: '0 0 18 * * 1-5'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.weekly,
label: '每周一 00:00',
expression: '0 0 0 * * 1'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.weekly,
label: '每周一 08:00',
expression: '0 0 8 * * 1'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.weekly,
label: '每周三 00:00',
expression: '0 0 0 * * 3'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.weekly,
label: '每周五 18:00',
expression: '0 0 18 * * 5'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.weekly,
label: '每周六 00:00',
expression: '0 0 0 * * 6'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.weekly,
label: '每周日 00:00',
expression: '0 0 0 * * 7'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.monthly,
label: '每月 1 日 00:00',
expression: '0 0 0 1 * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.monthly,
label: '每月 1 日 03:00',
expression: '0 0 3 1 * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.monthly,
label: '每月 5 日 00:00',
expression: '0 0 0 5 * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.monthly,
label: '每月 10 日 00:00',
expression: '0 0 0 10 * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.monthly,
label: '每月 15 日 00:00',
expression: '0 0 0 15 * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.monthly,
label: '每月 20 日 00:00',
expression: '0 0 0 20 * *'),
ScheduledCronPreset(
group: ScheduledCronPresetGroup.monthly,
label: '每月 25 日 00:00',
expression: '0 0 0 25 * *'),
];
/// 首次建表或后续新增任务时写入的默认配置,不覆盖设备已经调整过的规则。
static const List<ScheduledTaskConfig> defaultConfigs = [
ScheduledTaskConfig(
type: ScheduledTaskType.mqReconnect,
enabled: true,
cron: '0 * * * * *',
idleOnly: false,
),
ScheduledTaskConfig(
type: ScheduledTaskType.printCheck,
enabled: true,
cron: '0 */10 * * * *',
idleOnly: false,
),
ScheduledTaskConfig(
type: ScheduledTaskType.broadcastCheck,
enabled: true,
cron: '0 */2 * * * *',
idleOnly: false,
),
ScheduledTaskConfig(
type: ScheduledTaskType.deviceStatus,
enabled: true,
cron: '0 * * * * *',
idleOnly: false,
),
ScheduledTaskConfig(
type: ScheduledTaskType.autoUpdate,
enabled: true,
cron: '0 30 0 * * *',
idleOnly: true,
),
ScheduledTaskConfig(
type: ScheduledTaskType.configSync,
enabled: false,
cron: '0 5 0 * * *',
idleOnly: true,
),
ScheduledTaskConfig(
type: ScheduledTaskType.departmentFaceSync,
enabled: true,
cron: '0 40 0 * * *',
idleOnly: true,
),
ScheduledTaskConfig(
type: ScheduledTaskType.classifySync,
enabled: false,
cron: '0 15 0 * * *',
idleOnly: true,
),
ScheduledTaskConfig(
type: ScheduledTaskType.productSync,
enabled: true,
cron: '0 0 */3 * * *',
idleOnly: true,
),
ScheduledTaskConfig(
type: ScheduledTaskType.offlineOrderSync,
enabled: false,
cron: '0 35 0 * * *',
idleOnly: true,
),
ScheduledTaskConfig(
type: ScheduledTaskType.orderEvidenceUpload,
enabled: true,
cron: '0 */5 * * * *',
idleOnly: true,
),
ScheduledTaskConfig(
type: ScheduledTaskType.terminalLogUpload,
enabled: true,
cron: '0 0 0 * * *',
idleOnly: true,
),
ScheduledTaskConfig(
type: ScheduledTaskType.expiredLogCleanup,
enabled: true,
cron: '0 10 0 * * *',
idleOnly: true,
),
];
/// 初始化结构化配置并注册全部已启用任务;角色改变时先释放旧角色任务再重新注册。
Future<void> startAll(
{TerminalRole terminalRole = TerminalRole.cashier}) async {
await _disposeFuture;
if (_started && _activeRole == terminalRole) return;
if (_started) await dispose();
_started = true;
_activeRole = terminalRole;
_cron ??= Cron();
await _loadState();
for (final config in configs.values) {
if (!supportsTask(terminalRole, config.type)) {
continue;
}
await _rebuildTask(config);
}
if (terminalRole == TerminalRole.cashier) {
queueMissedDepartmentFaceSync();
}
}
/// 判断指定角色是否允许执行某类后台任务,打印角色只保留通信、打印和运维任务。
static bool supportsTask(
TerminalRole terminalRole, ScheduledTaskType taskType) =>
terminalRole == TerminalRole.cashier ||
_printerTaskTypes.contains(taskType);
/// 在每天 00:40 之后检查当天是否成功完成部门人脸同步,并在首次启动或恢复时补排一次。
///
/// 补排只加入同一人脸任务队列并要求完整空闲,不会阻塞启动或打断支付、识别和业务弹框。
void queueMissedDepartmentFaceSync() {
if (!_started) return;
final config = configs[ScheduledTaskType.departmentFaceSync];
if (config == null || !config.enabled) return;
final now = DateTime.now();
final scheduledAt = DateTime(now.year, now.month, now.day, 0, 40);
if (now.isBefore(scheduledAt)) return;
final lastSuccess =
records[ScheduledTaskType.departmentFaceSync]?.lastSuccessAt;
if (_isSameLocalDate(lastSuccess, now) ||
_isSameLocalDate(_lastDepartmentFaceCatchUpDate, now)) {
return;
}
_lastDepartmentFaceCatchUpDate = now;
unawaited(trigger(
ScheduledTaskType.departmentFaceSync,
catchUp: true,
triggerType: ScheduledTaskTriggerType.system,
));
}
/// 判断两个可空时间是否属于终端本地时区的同一自然日。
static bool _isSameLocalDate(DateTime? left, DateTime right) =>
left != null &&
left.year == right.year &&
left.month == right.month &&
left.day == right.day;
/// 完成旧 sysJson 一次性迁移,再从 SQLite 加载任务配置、最近结果和上传统计。
Future<void> _loadState() async {
final legacyConfigs = (await AppSettingTool.getScheduledTaskConfigs())
.where((item) => isValidCron(item.cron))
.toList(growable: false);
final legacyRecords = await AppSettingTool.getScheduledTaskRecords();
final legacyUploadStates =
await AppSettingTool.getTerminalLogUploadStates();
await ScheduledTaskStorageTool.initialize(
defaults: defaultConfigs,
legacyConfigs: legacyConfigs,
legacyRecords: legacyRecords,
);
final setting = await AppSettingTool.getSysSetting();
final hardId = setting.serviceId;
final canImportUploadState = hardId != null && hardId.isNotEmpty;
if (canImportUploadState && legacyUploadStates.isNotEmpty) {
await FileUploadRecordTool.importLegacy(hardId, legacyUploadStates);
}
await AppSettingTool.clearLegacyScheduledTaskState(
includeUploadState: canImportUploadState,
);
final state = await ScheduledTaskStorageTool.loadState();
configs.assignAll(state.configs);
records.assignAll(state.records);
final latestUploadLog = await ScheduledTaskStorageTool.getLatestLog(
ScheduledTaskType.terminalLogUpload,
);
if (latestUploadLog != null) {
terminalLogSummary.value = TerminalLogUploadSummary(
scanned: latestUploadLog.scannedCount ?? 0,
skipped: latestUploadLog.skippedCount ?? 0,
succeeded: latestUploadLog.successCount ?? 0,
failed: latestUploadLog.failureCount ?? 0,
lastSuccessAt:
latestUploadLog.status == ScheduledTaskRunStatus.success.name
? latestUploadLog.finishedAt
: records[ScheduledTaskType.terminalLogUpload]?.lastSuccessAt,
);
}
}
/// 校验 cron 0.6.2 使用的六段表达式及每段数值范围,返回是否可以安全注册。
static bool isValidCron(String expression) {
final fields = expression.trim().split(RegExp(r'\s+'));
if (fields.length != 6) return false;
final ranges = const [
(min: 0, max: 59),
(min: 0, max: 59),
(min: 0, max: 23),
(min: 1, max: 31),
(min: 1, max: 12),
(min: 0, max: 7),
];
for (var index = 0; index < fields.length; index++) {
if (!_isValidCronField(
fields[index],
min: ranges[index].min,
max: ranges[index].max,
)) {
return false;
}
}
try {
Schedule.parse(expression);
return true;
} catch (_) {
return false;
}
}
/// cron 0.6.2 会静默过滤越界数字,因此保存前额外校验每一段,避免 `99 * * * * *`
/// 这类永远不会触发的表达式被误判为有效。
static bool _isValidCronField(
String field, {
required int min,
required int max,
}) {
if (field.isEmpty) return false;
for (final segment in field.split(',')) {
final intervalParts = segment.split('/');
if (intervalParts.length > 2) return false;
final base = intervalParts.first;
if (intervalParts.length == 2) {
final interval = int.tryParse(intervalParts.last);
if (interval == null || interval <= 0) return false;
if (base != '*' && !base.contains('-')) return false;
}
if (base == '*') continue;
if (base.contains('-')) {
final range = base.split('-');
if (range.length != 2) return false;
final lower = int.tryParse(range.first);
final upper = int.tryParse(range.last);
if (lower == null ||
upper == null ||
lower < min ||
upper > max ||
lower > upper) {
return false;
}
continue;
}
final value = int.tryParse(base);
if (value == null || value < min || value > max) return false;
}
return true;
}
/// 保存单个任务配置并仅重建该任务;任一步失败时恢复旧配置和旧调度句柄。
Future<void> updateConfig(ScheduledTaskConfig config) async {
if (!isValidCron(config.cron)) {
throw const FormatException('请输入有效的 6 段 Cron 表达式');
}
final previous = configs[config.type];
configs[config.type] = config;
try {
await _rebuildTask(config);
await ScheduledTaskStorageTool.saveConfig(config);
} catch (error, stack) {
if (previous == null) {
configs.remove(config.type);
} else {
configs[config.type] = previous;
}
// 调度注册和本地配置必须一起回滚,避免界面显示旧值但后台仍按新规则运行。
try {
if (previous == null) {
await _scheduledTasks.remove(config.type)?.cancel();
} else {
await _rebuildTask(previous);
}
if (previous != null) {
await ScheduledTaskStorageTool.saveConfig(previous);
}
} catch (rollbackError, rollbackStack) {
logger.e(
'|$_tag|任务配置回滚失败type:${config.type.name}|error:$rollbackError|stack:$rollbackStack|',
);
}
logger.e(
'|$_tag|任务配置保存失败type:${config.type.name}|error:$error|stack:$stack|',
);
rethrow;
}
}
/// 取消任务旧句柄,并按当前开关和 Cron 表达式重新注册对应任务。
Future<void> _rebuildTask(ScheduledTaskConfig config) async {
await _scheduledTasks.remove(config.type)?.cancel();
if (!_started || !config.enabled) return;
_scheduledTasks[config.type] = _cron!.schedule(
Schedule.parse(config.cron),
() => trigger(config.type, scheduled: true),
);
}
/// 手动和 MQTT 触发不检查时间规则;同任务重复触发只保留一个合并后的待执行请求。
Future<void> trigger(
ScheduledTaskType type, {
bool currentLogOnly = false,
bool scheduled = false,
bool catchUp = false,
Set<String>? faceUserIds,
bool debugAllFaces = false,
ScheduledTaskTriggerType triggerType = ScheduledTaskTriggerType.manual,
}) async {
final config =
configs[type] ?? defaultConfigs.firstWhere((item) => item.type == type);
final request = _PendingTaskRequest(
currentLogOnly: currentLogOnly,
requireSafeWindow: !scheduled || config.idleOnly,
requireFullIdle: (scheduled || catchUp) && config.idleOnly,
triggerType: scheduled ? ScheduledTaskTriggerType.cron : triggerType,
faceUserIds: faceUserIds == null ? null : Set.unmodifiable(faceUserIds),
fullFaceSnapshot:
type == ScheduledTaskType.departmentFaceSync && faceUserIds == null,
debugAllFaces: debugAllFaces,
);
if (runningTasks.contains(type)) {
_enqueue(type, request);
return;
}
if (!_isReady(request)) {
_enqueue(type, request);
_ensureIdleTimer();
return;
}
await _execute(type, request: request);
}
/// 将任务加入等待集合;同任务已有请求时按更完整的处理范围合并。
void _enqueue(ScheduledTaskType type, _PendingTaskRequest request) {
final existing = _pendingRequests[type];
_pendingRequests[type] = existing?.merge(request) ?? request;
pendingTasks.add(type);
}
/// 判断请求当前是否满足无需等待、业务安全窗口或完整空闲窗口中的对应条件。
bool _isReady(_PendingTaskRequest request) {
if (!request.requireSafeWindow) return true;
return request.requireFullIdle
? SyncIdleTool.instance.isIdle
: SyncIdleTool.instance.isSafeWindow;
}
/// 从可观察集合和内部请求映射同时取出一个待执行任务,避免 UI 与队列状态分离。
_PendingTaskRequest? _takePending(ScheduledTaskType type) {
pendingTasks.remove(type);
return _pendingRequests.remove(type);
}
/// 在存在待执行任务时启动唯一轮询器,安全窗口满足后按队列快照依次执行。
void _ensureIdleTimer() {
_idleTimer ??= Timer.periodic(const Duration(seconds: 5), (_) async {
if (pendingTasks.isEmpty) {
_idleTimer?.cancel();
_idleTimer = null;
return;
}
final tasks = pendingTasks.toList(growable: false);
for (final type in tasks) {
if (runningTasks.contains(type)) continue;
final request = _pendingRequests[type];
if (request == null || !_isReady(request)) continue;
_takePending(type);
await _execute(type, request: request);
}
});
}
/// 执行一次任务并同步运行集合、最近结果及结构化日志,结束后处理合并请求。
Future<void> _execute(
ScheduledTaskType type, {
required _PendingTaskRequest request,
}) async {
runningTasks.add(type);
final startedAt = DateTime.now();
final descriptor = _taskDescriptor(type);
ScheduledTaskLogEntity? executionLog;
try {
executionLog = await ScheduledTaskStorageTool.startLog(
type: type,
triggerType: request.triggerType,
sourceType: descriptor.sourceType,
taskName: descriptor.title,
sourceName: descriptor.sourceName,
startedAt: startedAt,
);
} catch (error, stack) {
// 诊断日志写入失败不能阻断收银终端本身的同步任务。
logger.w('|$_tag|创建任务日志失败type:${type.name}|error:$error|stack:$stack|');
}
final heavy = _isHeavy(type);
if (heavy) SyncIdleTool.instance.beginHeavyTask();
try {
final result = await _runAction(
type,
request: request,
);
records[type] = ScheduledTaskRecord(
lastRunAt: startedAt,
lastSuccessAt: result.status == ScheduledTaskRunStatus.success
? DateTime.now()
: records[type]?.lastSuccessAt,
success: true,
message: result.message,
);
await _safeSaveRecord(
type,
records[type]!,
result.status,
);
if (executionLog != null) {
await _safeFinishLog(
executionLog.finish(
nextStatus: result.status.name,
nextMessage: result.message,
at: DateTime.now(),
scanned: result.summary?.scanned,
skipped: result.summary?.skipped,
succeeded: result.summary?.succeeded,
failed: result.summary?.failed,
),
);
}
} catch (error, stack) {
if (error is TerminalLogUploadException) {
terminalLogSummary.value = error.summary;
}
final message = switch (error) {
TerminalLogUploadException() => error.summary.label,
FaceSyncPartialFailureException() => error.summary.message,
_ => error.toString(),
};
records[type] = ScheduledTaskRecord(
lastRunAt: startedAt,
lastSuccessAt: records[type]?.lastSuccessAt,
success: false,
message: message,
);
await _safeSaveRecord(
type,
records[type]!,
ScheduledTaskRunStatus.failed,
);
if (executionLog != null) {
await _safeFinishLog(
executionLog.finish(
nextStatus: ScheduledTaskRunStatus.failed.name,
nextMessage: message,
at: DateTime.now(),
nextErrorType: error.runtimeType.toString(),
nextErrorMessage: _safeError(error),
nextErrorStack: _safeStack(stack),
scanned: switch (error) {
TerminalLogUploadException() => error.summary.scanned,
FaceSyncPartialFailureException() => error.summary.scanned,
_ => null,
},
skipped: switch (error) {
TerminalLogUploadException() => error.summary.skipped,
FaceSyncPartialFailureException() => error.summary.skipped,
_ => null,
},
succeeded: switch (error) {
TerminalLogUploadException() => error.summary.succeeded,
FaceSyncPartialFailureException() => error.summary.changed,
_ => null,
},
failed: switch (error) {
TerminalLogUploadException() => error.summary.failed,
FaceSyncPartialFailureException() => error.summary.failed,
_ => null,
},
),
);
}
if (error is ScheduledTaskDependencyException) {
logger.w('|$_tag|任务依赖暂不可用type:${type.name}|error:${error.message}|');
} else {
logger.e(
'|$_tag|任务执行失败type:${type.name}|error:$error|stack:$stack|',
);
}
} finally {
if (heavy) SyncIdleTool.instance.endHeavyTask();
runningTasks.remove(type);
final nextRequest = _pendingRequests[type];
if (nextRequest != null) {
if (_isReady(nextRequest)) {
_takePending(type);
unawaited(_execute(type, request: nextRequest));
} else {
_ensureIdleTimer();
}
}
}
}
/// 结构化诊断数据属于旁路能力,SQLite 暂时不可写时不能把已经成功的业务任务改判为失败。
Future<void> _safeSaveRecord(
ScheduledTaskType type,
ScheduledTaskRecord record,
ScheduledTaskRunStatus status,
) async {
try {
await ScheduledTaskStorageTool.saveRecord(type, record, status);
} catch (error, stack) {
logger.w(
'|$_tag|保存任务状态失败type:${type.name}|error:$error|stack:$stack|',
);
}
}
/// 完成日志和历史裁剪失败只写本地告警,原始 HTTP、MQTT 或插件结果保持不变。
Future<void> _safeFinishLog(ScheduledTaskLogEntity log) async {
try {
await ScheduledTaskStorageTool.finishLog(log);
} catch (error, stack) {
logger.w(
'|$_tag|完成任务日志失败type:${log.taskCode}|error:$error|stack:$stack|',
);
}
}
/// 将稳定任务类型分派到既有 MQTT、HTTP、插件或文件业务入口并返回统一结果。
Future<_ScheduledTaskActionResult> _runAction(
ScheduledTaskType type, {
required _PendingTaskRequest request,
}) async {
switch (type) {
case ScheduledTaskType.mqReconnect:
if (!AppNetworkTool.isAutoNetwork()) {
return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过');
}
if (MqManager.state == MqttConnectionState.connected) {
return const _ScheduledTaskActionResult.success('消息服务连接正常');
}
await AppMqTool.mqRefresh();
if (MqManager.state != MqttConnectionState.connected) {
throw ScheduledTaskDependencyException(
MqManager.connectionFailureReason ?? '消息服务暂未连接',
);
}
return const _ScheduledTaskActionResult.success('消息服务重连成功');
case ScheduledTaskType.printCheck:
if (!AppNetworkTool.isAutoNetwork()) {
return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过');
}
await SyncPrintTool.pullPendingTasks();
await SyncPrintTool.doCheckPrint();
await SyncPrintTool.doCheckSync();
return const _ScheduledTaskActionResult.success('打印任务检查完成');
case ScheduledTaskType.broadcastCheck:
if (!AppNetworkTool.isAutoNetwork()) {
return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过');
}
final setting = await AppSettingTool.getSysSetting();
final configuredCurrentHourType =
await AppSettingTool.getCurrentProductHourType();
await SyncTakeFoodBroadcastTool.doCheckBroadcast(
configuredCurrentHourType ??
setting.hourType ??
ProductHourType.TOTAL,
);
return const _ScheduledTaskActionResult.success('取餐广播检查完成');
case ScheduledTaskType.deviceStatus:
final setting = await AppSettingTool.getSysSetting();
if (setting.terminalRole == TerminalRole.printer) {
final available =
await Get.find<AppControl>().printManage.checkAvailable();
AppDeviceStatusTool.updatePrinter(available);
} else {
await AppDeviceStatusTool.refreshAll();
}
return const _ScheduledTaskActionResult.success('设备状态检查完成');
case ScheduledTaskType.autoUpdate:
final update = await TerminalAutoUpdateTool.run();
return update.skipped
? _ScheduledTaskActionResult.skipped(update.message)
: _ScheduledTaskActionResult.success(update.message);
case ScheduledTaskType.configSync:
await SyncConfigTool.autoSyncSysInfo();
return const _ScheduledTaskActionResult.success('设备配置同步完成');
case ScheduledTaskType.departmentFaceSync:
final mode = request.debugAllFaces
? FaceSyncMode.debugAllSnapshot
: request.faceUserIds == null
? FaceSyncMode.departmentSnapshot
: FaceSyncMode.userDelta;
final summary = await SyncFaceTool.sync(
mode: mode,
userIds: request.faceUserIds,
);
if (summary.changed > 0 &&
request.triggerType == ScheduledTaskTriggerType.mqtt &&
Get.isRegistered<AppControl>()) {
Get.find<AppControl>().mqUpdateEventBus.fire(MqtActionEnum.FACE);
}
if (summary.hasFailures) {
throw FaceSyncPartialFailureException(summary);
}
return _ScheduledTaskActionResult.success(
summary.message,
summary: TerminalLogUploadSummary(
scanned: summary.scanned,
skipped: summary.skipped,
succeeded: summary.changed,
failed: summary.failed,
),
);
case ScheduledTaskType.classifySync:
await SyncProductTool.syncClassify();
return const _ScheduledTaskActionResult.success('分类同步完成');
case ScheduledTaskType.productSync:
await SyncProductTool.syncProduct();
if (Get.isRegistered<AppControl>()) {
final setting = await AppSettingTool.getSysSetting();
// 定时任务完成后通知仍存活的收银页重读本地快照,库存才能在不切页的情况下刷新。
Get.find<AppControl>().mqUpdateEventBus.fire(
setting.sysType == SysType.CANTEEN
? MqtActionEnum.CANTEEN_PRODUCT
: MqtActionEnum.SUPERMARKET_PRODUCT,
);
}
return const _ScheduledTaskActionResult.success('商品同步完成');
case ScheduledTaskType.offlineOrderSync:
await SyncOrderTool.syncOfflineOrder();
return const _ScheduledTaskActionResult.success('离线订单同步完成');
case ScheduledTaskType.orderEvidenceUpload:
if (!AppNetworkTool.isAutoNetwork()) {
return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过');
}
final summary = await OrderEvidenceUploadTool.processPending();
return _ScheduledTaskActionResult.success(
summary.message,
summary: TerminalLogUploadSummary(
scanned: summary.scanned,
skipped: summary.skipped,
succeeded: summary.succeeded,
failed: summary.failed,
),
);
case ScheduledTaskType.terminalLogUpload:
final summary = await TerminalLogUploadTool.upload(
currentOnly: request.currentLogOnly,
);
terminalLogSummary.value = summary;
return _ScheduledTaskActionResult.success(
summary.label,
summary: summary,
);
case ScheduledTaskType.expiredLogCleanup:
final uploadSummary = await TerminalLogUploadTool.upload();
terminalLogSummary.value = uploadSummary;
final deleted = await TerminalLogUploadTool.cleanupExpiredLogs();
return _ScheduledTaskActionResult.success(
'上传检查完成,清理 $deleted 个过期日志',
summary: uploadSummary,
);
}
}
/// 返回任务是否会占用较多网络、磁盘或数据处理资源,需要进入重型任务互斥区。
bool _isHeavy(ScheduledTaskType type) {
return const {
ScheduledTaskType.configSync,
ScheduledTaskType.departmentFaceSync,
ScheduledTaskType.classifySync,
ScheduledTaskType.productSync,
ScheduledTaskType.offlineOrderSync,
ScheduledTaskType.orderEvidenceUpload,
ScheduledTaskType.autoUpdate,
ScheduledTaskType.terminalLogUpload,
ScheduledTaskType.expiredLogCleanup,
}.contains(type);
}
/// 返回任务设置页和执行日志共用的中文名称。
static String taskTitle(ScheduledTaskType type) =>
_taskDescriptor(type).title;
/// 返回任务执行日志使用的主要依赖类型。
static ScheduledTaskSourceType taskSourceType(ScheduledTaskType type) =>
_taskDescriptor(type).sourceType;
/// 返回任务执行日志使用的具体依赖名称。
static String taskSourceName(ScheduledTaskType type) =>
_taskDescriptor(type).sourceName;
/// 从 SQLite 查询指定任务最近日志,并可按依赖类型过滤。
Future<List<ScheduledTaskLogEntity>> loadLogs(
ScheduledTaskType type, {
ScheduledTaskSourceType? sourceType,
}) =>
ScheduledTaskStorageTool.getLogs(type, sourceType: sourceType);
/// 从 SQLite 分页查询指定任务日志,并返回筛选后的总数与校正页码。
Future<
({
List<ScheduledTaskLogEntity> records,
int total,
int currentPage,
})> loadLogPage(
ScheduledTaskType type, {
ScheduledTaskSourceType? sourceType,
required int currentPage,
required int pageSize,
}) =>
ScheduledTaskStorageTool.getLogPage(
type,
sourceType: sourceType,
currentPage: currentPage,
pageSize: pageSize,
);
/// 将任务类型映射为稳定展示名称和依赖分类,不把 UI 文案写入持久化主键。
static _ScheduledTaskDescriptor _taskDescriptor(ScheduledTaskType type) =>
switch (type) {
ScheduledTaskType.mqReconnect => const _ScheduledTaskDescriptor(
title: 'MQTT 重连',
sourceType: ScheduledTaskSourceType.mqtt,
sourceName: 'MQTT 消息服务',
),
ScheduledTaskType.printCheck => const _ScheduledTaskDescriptor(
title: '打印任务检查',
sourceType: ScheduledTaskSourceType.http,
sourceName: 'HTTP 接口与打印插件',
),
ScheduledTaskType.broadcastCheck => const _ScheduledTaskDescriptor(
title: '取餐广播检查',
sourceType: ScheduledTaskSourceType.local,
sourceName: '本地广播队列',
),
ScheduledTaskType.deviceStatus => const _ScheduledTaskDescriptor(
title: '设备状态检查',
sourceType: ScheduledTaskSourceType.plugin,
sourceName: '终端硬件插件',
),
ScheduledTaskType.autoUpdate => const _ScheduledTaskDescriptor(
title: 'APK 自动更新',
sourceType: ScheduledTaskSourceType.http,
sourceName: '版本接口、私有下载目录与 Root 安装服务',
),
ScheduledTaskType.configSync => const _ScheduledTaskDescriptor(
title: '设备配置同步',
sourceType: ScheduledTaskSourceType.http,
sourceName: 'HTTP 配置接口',
),
ScheduledTaskType.departmentFaceSync => const _ScheduledTaskDescriptor(
title: '本部门人脸同步',
sourceType: ScheduledTaskSourceType.http,
sourceName: 'HTTP 人脸快照与虹软插件',
),
ScheduledTaskType.classifySync => const _ScheduledTaskDescriptor(
title: '分类同步',
sourceType: ScheduledTaskSourceType.http,
sourceName: 'HTTP 分类接口',
),
ScheduledTaskType.productSync => const _ScheduledTaskDescriptor(
title: '商品同步',
sourceType: ScheduledTaskSourceType.http,
sourceName: 'HTTP 商品接口',
),
ScheduledTaskType.offlineOrderSync => const _ScheduledTaskDescriptor(
title: '离线订单同步',
sourceType: ScheduledTaskSourceType.http,
sourceName: 'HTTP 订单接口',
),
ScheduledTaskType.orderEvidenceUpload => const _ScheduledTaskDescriptor(
title: '订单证据补传',
sourceType: ScheduledTaskSourceType.http,
sourceName: '本地证据队列与 HTTP 图片接口',
),
ScheduledTaskType.terminalLogUpload => const _ScheduledTaskDescriptor(
title: '终端日志上传',
sourceType: ScheduledTaskSourceType.file,
sourceName: '本地文件与 HTTP 上传接口',
),
ScheduledTaskType.expiredLogCleanup => const _ScheduledTaskDescriptor(
title: '过期日志清理',
sourceType: ScheduledTaskSourceType.file,
sourceName: '本地日志文件',
),
};
/// 将异常摘要截断到 1000 个字符,避免单条任务日志异常膨胀。
static String _safeError(Object error) {
final text = error.toString();
return text.length <= 1000 ? text : '${text.substring(0, 1000)}...';
}
/// 将异常堆栈截断到 4000 个字符,在保留诊断价值的同时限制 SQLite 记录大小。
static String _safeStack(StackTrace stack) {
final text = stack.toString();
return text.length <= 4000 ? text : '${text.substring(0, 4000)}...';
}
/// 幂等释放调度资源;并发调用会等待同一个正在执行的释放操作。
Future<void> dispose() async {
final disposing = _disposeFuture;
if (disposing != null) return disposing;
final operation = _dispose();
_disposeFuture = operation;
try {
await operation;
} finally {
if (identical(_disposeFuture, operation)) _disposeFuture = null;
}
}
/// 取消轮询和全部 Cron 句柄、清空待执行状态并关闭 Cron 实例。
Future<void> _dispose() async {
_started = false;
_activeRole = null;
_idleTimer?.cancel();
_idleTimer = null;
for (final task in _scheduledTasks.values) {
await task.cancel();
}
_scheduledTasks.clear();
_pendingRequests.clear();
pendingTasks.clear();
final cron = _cron;
_cron = null;
await cron?.close();
}
}
/// 尚未执行或同任务运行中再次触发时保存的合并请求。
class _PendingTaskRequest {
/// 创建一次待执行请求,明确日志范围、空闲要求和触发来源。
const _PendingTaskRequest({
required this.currentLogOnly,
required this.requireSafeWindow,
required this.requireFullIdle,
required this.triggerType,
required this.faceUserIds,
required this.fullFaceSnapshot,
required this.debugAllFaces,
});
/// 是否只上传 Logger 当前写入的日志文件;为假时扫描全部日志。
final bool currentLogOnly;
/// 是否必须等待支付、人脸、遮罩、弹窗和其他重型任务全部结束。
final bool requireSafeWindow;
/// 是否还需在安全窗口基础上满足连续 60 秒无人操作。
final bool requireFullIdle;
/// 最终写入任务执行日志的触发来源。
final ScheduledTaskTriggerType triggerType;
/// FACE 用户增量回查集合;部门完整快照时为空。
final Set<String>? faceUserIds;
/// 当前请求是否为部门或调试完整快照,用于合并时让全量覆盖用户增量。
final bool fullFaceSnapshot;
/// 是否执行仅非生产可触发的全租户调试快照。
final bool debugAllFaces;
/// 全量日志请求优先于“仅当前文件”;任一手动请求到达后只需等待安全窗口。
_PendingTaskRequest merge(_PendingTaskRequest other) {
final mergedFullFaceSnapshot = fullFaceSnapshot || other.fullFaceSnapshot;
final mergedUserIds = mergedFullFaceSnapshot
? null
: <String>{...?faceUserIds, ...?other.faceUserIds};
return _PendingTaskRequest(
currentLogOnly: currentLogOnly && other.currentLogOnly,
requireSafeWindow: requireSafeWindow || other.requireSafeWindow,
// Cron/补排全量一旦进入队列,不因随后到达的 MQ 增量而降低 60 秒空闲要求。
requireFullIdle: mergedFullFaceSnapshot
? requireFullIdle || other.requireFullIdle
: requireFullIdle && other.requireFullIdle,
triggerType: triggerType == ScheduledTaskTriggerType.cron
? other.triggerType
: triggerType,
faceUserIds:
mergedUserIds == null ? null : Set.unmodifiable(mergedUserIds),
fullFaceSnapshot: mergedFullFaceSnapshot,
debugAllFaces: debugAllFaces || other.debugAllFaces,
);
}
}
/// MQTT 等外部依赖暂不可用时使用的可诊断任务异常。
class ScheduledTaskDependencyException implements Exception {
/// 使用面向终端人员的安全摘要创建依赖异常。
const ScheduledTaskDependencyException(this.message);
/// 不包含地址、凭证或堆栈的依赖失败摘要。
final String message;
/// 返回可直接写入任务最近结果的依赖失败摘要。
@override
String toString() => message;
}
/// 单个业务动作完成后返回给统一调度收尾流程的结果。
class _ScheduledTaskActionResult {
/// 创建成功结果,可附带文件任务的上传统计。
const _ScheduledTaskActionResult.success(this.message, {this.summary})
: status = ScheduledTaskRunStatus.success;
/// 创建无需继续处理的跳过结果,不携带文件上传统计。
const _ScheduledTaskActionResult.skipped(this.message)
: status = ScheduledTaskRunStatus.skipped,
summary = null;
/// 设置页和任务日志使用的简短中文结果摘要。
final String message;
/// 本次动作的最终运行状态,仅包含成功或跳过。
final ScheduledTaskRunStatus status;
/// 日志上传或清理任务的文件统计,其他任务为空。
final TerminalLogUploadSummary? summary;
}
/// 任务展示名称及主要依赖来源的稳定描述。
class _ScheduledTaskDescriptor {
/// 创建一项任务元数据,仅用于展示和结构化日志分类。
const _ScheduledTaskDescriptor({
required this.title,
required this.sourceType,
required this.sourceName,
});
/// 面向终端人员的中文任务名称。
final String title;
/// MQTT、HTTP、插件、文件或本地逻辑中的主要依赖分类。
final ScheduledTaskSourceType sourceType;
/// 执行日志展示的具体依赖名称快照。
final String sourceName;
}