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._(); /// 当前已加载的任务配置,以稳定任务类型为键并向设置页提供响应式更新。 final configs = {}.obs; /// 每个任务最近一次执行结果快照,以稳定任务类型为键。 final records = {}.obs; /// 正在实际执行业务动作的任务集合,用于禁止同任务并发并驱动运行状态 UI。 final runningTasks = {}.obs; /// 正在等待安全窗口或等待前一次同类任务结束的任务集合。 final pendingTasks = {}.obs; /// 最近一次终端日志上传统计,供设置页展示扫描、跳过、成功和失败数量。 final terminalLogSummary = const TerminalLogUploadSummary().obs; /// 当前进程内 Cron 调度器;任务全部停止后置空并关闭其内部计时资源。 Cron? _cron; /// 已注册到 Cron 的任务句柄,以任务类型为键支持单任务重建和取消。 final Map _scheduledTasks = {}; /// 同任务合并后的待执行请求,记录触发来源和需要满足的空闲级别。 final Map _pendingRequests = {}; /// 每 5 秒检查待执行任务安全窗口的轮询计时器;队列清空后自动释放。 Timer? _idleTimer; /// 正在进行的释放操作,避免生命周期重复回调并发关闭同一个 Cron 实例。 Future? _disposeFuture; /// 是否已经完成本轮启动,防止 AppControl 重复注册相同 Cron 任务。 bool _started = false; /// 当前进程最近一次补排部门人脸同步的本地日期,保证同一天最多自动补排一次。 DateTime? _lastDepartmentFaceCatchUpDate; /// 常用规则均使用 cron 0.6.2 可解析的标准字段、范围和步长,不包含 Quartz 专属的 L/W/#。 static const List 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 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 */5 * * * *', 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 startAll() async { if (_started) return; await _disposeFuture; _started = true; _cron ??= Cron(); await _loadState(); for (final config in configs.values) { await _rebuildTask(config); } queueMissedDepartmentFaceSync(); } /// 在每天 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 _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 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 _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 trigger( ScheduledTaskType type, { bool currentLogOnly = false, bool scheduled = false, bool catchUp = false, Set? 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 _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 _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 _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.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: 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()) { Get.find().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()) { final setting = await AppSettingTool.getSysSetting(); // 定时任务完成后通知仍存活的收银页重读本地快照,库存才能在不切页的情况下刷新。 Get.find().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> loadLogs( ScheduledTaskType type, { ScheduledTaskSourceType? sourceType, }) => ScheduledTaskStorageTool.getLogs(type, sourceType: sourceType); /// 从 SQLite 分页查询指定任务日志,并返回筛选后的总数与校正页码。 Future< ({ List 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 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 _dispose() async { _started = false; _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? faceUserIds; /// 当前请求是否为部门或调试完整快照,用于合并时让全量覆盖用户增量。 final bool fullFaceSnapshot; /// 是否执行仅非生产可触发的全租户调试快照。 final bool debugAllFaces; /// 全量日志请求优先于“仅当前文件”;任一手动请求到达后只需等待安全窗口。 _PendingTaskRequest merge(_PendingTaskRequest other) { final mergedFullFaceSnapshot = fullFaceSnapshot || other.fullFaceSnapshot; final mergedUserIds = mergedFullFaceSnapshot ? null : {...?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; }