feat:设备日志模块

feat:定时任务
feat:设置页面用路由重构
This commit is contained in:
dzc committed 2026-07-30 13:16:14 +08:00
1 parent 1033f403b1
commit 2efa7a3c19
60 files changed
+6560 -643

No files matched your search

@@ -0,0 +1,191 @@
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 文件上传记录的结构化存储入口。
abstract final class FileUploadRecordTool {
/// 终端日志在通用文件上传记录表中的稳定业务类型。
static const String terminalLogBizType = 'terminal_log';
/// 当前应用数据库中的文件上传记录 DAO。
static FileUploadRecordDao get _dao =>
Get.find<AppControl>().dataBase.getFileUploadRecordDao;
/// 将 v8 之前保存在 sysJson 的成功状态一次性导入 file_upload_record。
///
/// 相同设备、业务类型和相对路径已有记录时保持结构化表数据,不用旧 JSON 覆盖。
static Future<void> importLegacy(
String hardId,
Map<String, TerminalLogUploadState> states,
) async {
final now = DateTime.now();
for (final state in states.values) {
if (await _dao.getByPath(
hardId,
terminalLogBizType,
state.relativePath,
) !=
null) {
continue;
}
await _dao.save(
FileUploadRecordEntity(
hardId: hardId,
bizType: terminalLogBizType,
relativePath: state.relativePath,
originalName: _fileName(state.relativePath),
sourceSize: state.sourceSize,
sourceModifiedTime: state.sourceModifiedTime,
sanitizedHash: state.sanitizedHash,
sanitizedSize: 0,
status: FileUploadStatus.success.name,
uploadedAt: state.uploadedAt,
createTime: state.uploadedAt,
updateTime: now,
),
);
}
}
/// 查询指定设备所有成功的终端日志状态,并以相对路径构建快速匹配映射。
static Future<Map<String, TerminalLogUploadState>> getSuccessfulStates(
String hardId,
) async {
final result = <String, TerminalLogUploadState>{};
for (final record in await _dao.getAll(hardId, terminalLogBizType)) {
if (record.status != FileUploadStatus.success.name ||
record.uploadedAt == null) {
continue;
}
result[record.relativePath] = TerminalLogUploadState(
relativePath: record.relativePath,
sourceSize: record.sourceSize,
sourceModifiedTime: record.sourceModifiedTime,
sanitizedHash: record.sanitizedHash,
uploadedAt: record.uploadedAt!,
);
}
return result;
}
/// 创建或覆盖同一路径的 uploading 状态,并将尝试次数递增后返回带主键实体。
static Future<FileUploadRecordEntity> beginAttempt({
required String hardId,
required String relativePath,
required int sourceSize,
required DateTime sourceModifiedTime,
}) async {
final previous = await _dao.getByPath(
hardId,
terminalLogBizType,
relativePath,
);
final now = DateTime.now();
final next = FileUploadRecordEntity(
id: previous?.id,
hardId: hardId,
bizType: terminalLogBizType,
relativePath: relativePath,
originalName: _fileName(relativePath),
sourceSize: sourceSize,
sourceModifiedTime: sourceModifiedTime,
sanitizedHash: previous?.sanitizedHash ?? '',
sanitizedSize: previous?.sanitizedSize ?? 0,
status: FileUploadStatus.uploading.name,
serverFileId: previous?.serverFileId,
attemptCount: (previous?.attemptCount ?? 0) + 1,
lastAttemptAt: now,
uploadedAt: previous?.uploadedAt,
createTime: previous?.createTime ?? now,
updateTime: now,
);
final id = await _dao.save(next);
return FileUploadRecordEntity(
id: id,
hardId: next.hardId,
bizType: next.bizType,
relativePath: next.relativePath,
originalName: next.originalName,
sourceSize: next.sourceSize,
sourceModifiedTime: next.sourceModifiedTime,
sanitizedHash: next.sanitizedHash,
sanitizedSize: next.sanitizedSize,
status: next.status,
serverFileId: next.serverFileId,
attemptCount: next.attemptCount,
lastAttemptAt: next.lastAttemptAt,
uploadedAt: next.uploadedAt,
createTime: next.createTime,
updateTime: next.updateTime,
);
}
/// 将一次上传尝试标记为成功,并保存脱敏文件哈希、大小和后端记录 ID。
static Future<void> markSuccess({
required FileUploadRecordEntity attempt,
required String sanitizedHash,
required int sanitizedSize,
String? serverFileId,
}) async {
final now = DateTime.now();
await _dao.save(
FileUploadRecordEntity(
id: attempt.id,
hardId: attempt.hardId,
bizType: attempt.bizType,
relativePath: attempt.relativePath,
originalName: attempt.originalName,
sourceSize: attempt.sourceSize,
sourceModifiedTime: attempt.sourceModifiedTime,
sanitizedHash: sanitizedHash,
sanitizedSize: sanitizedSize,
status: FileUploadStatus.success.name,
serverFileId: serverFileId ?? attempt.serverFileId,
attemptCount: attempt.attemptCount,
lastAttemptAt: attempt.lastAttemptAt,
uploadedAt: now,
createTime: attempt.createTime,
updateTime: now,
),
);
}
/// 将一次上传尝试标记为失败,保留上次成功时间和服务端 ID 供后续重试使用。
static Future<void> markFailure({
required FileUploadRecordEntity attempt,
required Object error,
}) async {
final now = DateTime.now();
await _dao.save(
FileUploadRecordEntity(
id: attempt.id,
hardId: attempt.hardId,
bizType: attempt.bizType,
relativePath: attempt.relativePath,
originalName: attempt.originalName,
sourceSize: attempt.sourceSize,
sourceModifiedTime: attempt.sourceModifiedTime,
sanitizedHash: attempt.sanitizedHash,
sanitizedSize: attempt.sanitizedSize,
status: FileUploadStatus.failed.name,
serverFileId: attempt.serverFileId,
attemptCount: attempt.attemptCount,
lastAttemptAt: attempt.lastAttemptAt,
uploadedAt: attempt.uploadedAt,
lastError: _safeError(error),
createTime: attempt.createTime,
updateTime: now,
),
);
}
/// 从跨平台相对路径中提取不含目录的原始文件名。
static String _fileName(String path) =>
path.replaceAll('\\', '/').split('/').last;
/// 将上传异常摘要限制在 500 个字符内,避免单条状态记录无限增长。
static String _safeError(Object error) {
final value = error.toString();
return value.length <= 500 ? value : '${value.substring(0, 500)}...';
}
}
@@ -0,0 +1,222 @@
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 定时任务结构化存储入口,隔离调度器与 Floor DAO 的实体转换细节。
abstract final class ScheduledTaskStorageTool {
/// 当前应用数据库中的任务配置 DAO;数据库由 AppControl 在调度器启动前完成初始化。
static ScheduledTaskDao get _taskDao =>
Get.find<AppControl>().dataBase.getScheduledTaskDao;
/// 当前应用数据库中的任务执行日志 DAO。
static ScheduledTaskLogDao get _logDao =>
Get.find<AppControl>().dataBase.getScheduledTaskLogDao;
/// 首次升级时把旧 sysJson 导入表中;表已有数据时只补齐后续版本新增的默认任务。
static Future<void> initialize({
required List<ScheduledTaskConfig> defaults,
required List<ScheduledTaskConfig> legacyConfigs,
required Map<ScheduledTaskType, ScheduledTaskRecord> legacyRecords,
}) async {
final existing = {
for (final entity in await _taskDao.getAll()) entity.taskCode: entity,
};
final legacy = {for (final config in legacyConfigs) config.type: config};
final now = DateTime.now();
final additions = <ScheduledTaskEntity>[];
for (final defaultConfig in defaults) {
if (existing.containsKey(defaultConfig.type.name)) continue;
final config = legacy[defaultConfig.type] ?? defaultConfig;
final record = legacyRecords[defaultConfig.type];
additions.add(
ScheduledTaskEntity(
taskCode: config.type.name,
enabled: config.enabled,
cronExpression: config.cron,
idleOnly: config.idleOnly,
lastRunAt: record?.lastRunAt,
lastSuccessAt: record?.lastSuccessAt,
lastStatus: record == null
? ScheduledTaskRunStatus.notRun.name
: record.success
? ScheduledTaskRunStatus.success.name
: ScheduledTaskRunStatus.failed.name,
lastMessage: record?.message ?? '尚未执行',
createTime: now,
updateTime: now,
),
);
}
if (additions.isNotEmpty) await _taskDao.batchSave(additions);
}
/// 从 scheduled_task 读取有效任务,并转换为调度器使用的配置和最近执行快照。
///
/// 数据库中未知的 taskCode 会被忽略,保证新旧版本任务集合不一致时仍可启动。
static Future<
({
Map<ScheduledTaskType, ScheduledTaskConfig> configs,
Map<ScheduledTaskType, ScheduledTaskRecord> records,
})> loadState() async {
final configs = <ScheduledTaskType, ScheduledTaskConfig>{};
final records = <ScheduledTaskType, ScheduledTaskRecord>{};
for (final entity in await _taskDao.getAll()) {
final type = _taskType(entity.taskCode);
if (type == null) continue;
configs[type] = ScheduledTaskConfig(
type: type,
enabled: entity.enabled,
cron: entity.cronExpression,
idleOnly: entity.idleOnly,
);
records[type] = ScheduledTaskRecord(
lastRunAt: entity.lastRunAt,
lastSuccessAt: entity.lastSuccessAt,
success: entity.lastStatus == ScheduledTaskRunStatus.success.name ||
entity.lastStatus == ScheduledTaskRunStatus.skipped.name,
message: entity.lastMessage,
);
}
return (configs: configs, records: records);
}
/// 保存任务开关、Cron 和空闲策略;已有记录会保留历史执行时间和创建时间。
static Future<void> saveConfig(ScheduledTaskConfig config) async {
final current = await _taskDao.getByCode(config.type.name);
final now = DateTime.now();
await _taskDao.save(
current == null
? ScheduledTaskEntity(
taskCode: config.type.name,
enabled: config.enabled,
cronExpression: config.cron,
idleOnly: config.idleOnly,
lastStatus: ScheduledTaskRunStatus.notRun.name,
lastMessage: '尚未执行',
createTime: now,
updateTime: now,
)
: current.copyWith(
enabled: config.enabled,
cronExpression: config.cron,
idleOnly: config.idleOnly,
updateTime: now,
),
);
}
/// 将一次任务结果写回 scheduled_task 的最近执行快照。
static Future<void> saveRecord(
ScheduledTaskType type,
ScheduledTaskRecord record,
ScheduledTaskRunStatus status,
) async {
final current = await _taskDao.getByCode(type.name);
if (current == null) return;
await _taskDao.save(
current.copyWith(
lastRunAt: record.lastRunAt,
lastSuccessAt: record.lastSuccessAt,
lastStatus: status.name,
lastMessage: record.message,
updateTime: DateTime.now(),
),
);
}
/// 插入状态为 running 的开始日志,并返回带自增主键的实体供结束时更新。
static Future<ScheduledTaskLogEntity> startLog({
required ScheduledTaskType type,
required ScheduledTaskTriggerType triggerType,
required ScheduledTaskSourceType sourceType,
required String taskName,
required String sourceName,
required DateTime startedAt,
}) async {
final log = ScheduledTaskLogEntity(
taskCode: type.name,
taskName: taskName,
triggerType: triggerType.name,
sourceType: sourceType.name,
sourceName: sourceName,
status: ScheduledTaskRunStatus.running.name,
message: '任务开始执行',
startedAt: startedAt,
);
final id = await _logDao.save(log);
return ScheduledTaskLogEntity(
id: id,
taskCode: log.taskCode,
taskName: log.taskName,
triggerType: log.triggerType,
sourceType: log.sourceType,
sourceName: log.sourceName,
status: log.status,
message: log.message,
startedAt: log.startedAt,
);
}
/// 更新任务日志的最终状态,并将全局历史裁剪到最近 2000 条。
static Future<void> finishLog(ScheduledTaskLogEntity log) async {
await _logDao.edit(log);
await _logDao.trimHistory();
}
/// 查询指定任务最近 100 条日志,可按主要依赖类型进一步筛选。
static Future<List<ScheduledTaskLogEntity>> getLogs(
ScheduledTaskType type, {
ScheduledTaskSourceType? sourceType,
int limit = 100,
}) =>
_logDao.getByTask(type.name, sourceType?.name ?? '', limit);
/// 分页查询指定任务日志,同时返回总数和校正后的当前页。
///
/// 页容量限制在 1 到 100 条;当历史日志裁剪导致请求页超出范围时,自动回退到最后一页,
/// 避免分页组件仍显示有效总数但列表为空。
static Future<
({
List<ScheduledTaskLogEntity> records,
int total,
int currentPage,
})> getLogPage(
ScheduledTaskType type, {
ScheduledTaskSourceType? sourceType,
required int currentPage,
required int pageSize,
}) async {
final sourceCode = sourceType?.name ?? '';
final normalizedPageSize = pageSize.clamp(1, 100).toInt();
final total = await _logDao.countByTask(type.name, sourceCode) ?? 0;
final totalPages = total == 0 ? 1 : (total / normalizedPageSize).ceil();
final normalizedCurrentPage = currentPage.clamp(1, totalPages).toInt();
final records = total == 0
? const <ScheduledTaskLogEntity>[]
: await _logDao.getPageByTask(
type.name,
sourceCode,
normalizedPageSize,
(normalizedCurrentPage - 1) * normalizedPageSize,
);
return (
records: records,
total: total,
currentPage: normalizedCurrentPage,
);
}
/// 查询指定任务最近一条日志;尚未执行时返回空。
static Future<ScheduledTaskLogEntity?> getLatestLog(
ScheduledTaskType type,
) =>
_logDao.getLatest(type.name);
/// 将数据库中的稳定任务编码转换为当前版本枚举,未知编码返回空。
static ScheduledTaskType? _taskType(String code) {
for (final type in ScheduledTaskType.values) {
if (type.name == code) return type;
}
return null;
}
}
File diff suppressed because it is too large. Load diff
+117
View File
@@ -0,0 +1,117 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart';
/// 同步调度与顶栏共用的空闲状态快照,避免界面自行拼装支付、弹窗和重型任务规则。
class SyncIdleSnapshot {
/// 创建一次不可变的终端空闲判断快照。
const SyncIdleSnapshot({
required this.foreground,
required this.safeWindow,
required this.idle,
required this.inactiveFor,
required this.reason,
});
/// 应用当前是否处于 resumed 前台状态。
final bool foreground;
/// 当前是否避开了支付遮罩、人脸会话、业务弹窗和其他重型任务。
final bool safeWindow;
/// 当前是否同时满足安全窗口和连续 60 秒无人操作。
final bool idle;
/// 距离最近一次指针操作或应用恢复前台经过的时长。
final Duration inactiveFor;
/// 当前未达到空闲状态的首要中文原因;空闲时为空字符串。
final String reason;
/// 返回顶栏使用的简短空闲状态文案。
String get label => idle ? '空闲' : '忙碌';
}
/// 统一记录终端用户活动和重型后台任务,用于选择不会干扰支付、人脸和弹框的安全执行窗口。
class SyncIdleTool {
/// 隐藏构造器,空闲判断必须通过全局单例维护同一份活动时间和任务计数。
SyncIdleTool._();
/// 应用级空闲检测单例。
static final SyncIdleTool instance = SyncIdleTool._();
/// 自动空闲任务要求的最短连续无人操作时间。
static const Duration idleDuration = Duration(seconds: 60);
/// 最近一次用户指针操作或恢复前台的本地时间。
DateTime _lastActivityAt = DateTime.now();
/// 应用是否处于可安全执行终端任务的前台生命周期。
bool _foreground = true;
/// 当前并行执行的重型同步任务数量,用计数支持嵌套开始和结束。
int _heavyTaskCount = 0;
/// 记录一次终端触控活动并重新开始 60 秒空闲计时。
void recordUserActivity() {
_lastActivityAt = DateTime.now();
}
/// 根据应用生命周期更新前台状态,恢复前台时视为一次用户活动。
void updateLifecycle(AppLifecycleState state) {
_foreground = state == AppLifecycleState.resumed;
if (_foreground) recordUserActivity();
}
/// 标记一个重型任务开始,阻止其他需要安全窗口的任务并发执行。
void beginHeavyTask() => _heavyTaskCount++;
/// 标记一个重型任务结束;计数最低保持为零以容忍重复清理。
void endHeavyTask() {
if (_heavyTaskCount > 0) _heavyTaskCount--;
}
/// 手动任务和后台拉取只等待业务安全窗口,不额外要求 60 秒无人操作。
SyncIdleSnapshot get snapshot {
final faceActive = Get.isRegistered<FaceRecognitionSessionService>() &&
Get.find<FaceRecognitionSessionService>().isActive;
final route = Get.context == null ? null : ModalRoute.of(Get.context!);
final dialogOpen = Get.isDialogOpen == true ||
Get.isBottomSheetOpen == true ||
route?.isCurrent == false;
final inactiveFor = DateTime.now().difference(_lastActivityAt);
final reason = !_foreground
? '应用不在前台'
: EasyLoading.isShow
? '前台操作进行中'
: faceActive
? '人脸识别进行中'
: dialogOpen
? '业务弹窗处理中'
: _heavyTaskCount > 0
? '后台同步进行中'
: inactiveFor < idleDuration
? '最近有终端操作'
: '';
final safeWindow = _foreground &&
!EasyLoading.isShow &&
!faceActive &&
!dialogOpen &&
_heavyTaskCount == 0;
return SyncIdleSnapshot(
foreground: _foreground,
safeWindow: safeWindow,
idle: safeWindow && inactiveFor >= idleDuration,
inactiveFor: inactiveFor,
reason: reason,
);
}
/// 当前是否满足手动执行和 MQTT 拉取所需的业务安全窗口。
bool get isSafeWindow => snapshot.safeWindow;
/// 当前是否满足自动空闲任务的完整空闲条件。
bool get isIdle => snapshot.idle;
}
@@ -0,0 +1,309 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 单次日志上传存在部分文件失败时抛出的汇总异常。
///
/// 已成功或跳过的文件不会回滚,[summary] 供调度日志和设置页保留本次完整统计。
class TerminalLogUploadException implements Exception {
/// 使用本次上传统计创建异常,调用方应读取 [summary] 而不是重新扫描文件。
const TerminalLogUploadException(this.summary);
/// 包含扫描、跳过、成功、失败数量及最近成功时间的本次任务统计。
final TerminalLogUploadSummary summary;
/// 返回不包含日志正文和文件路径的安全错误摘要。
@override
String toString() => '终端日志上传存在 ${summary.failed} 个失败文件';
}
/// 脱敏日志上传器,所有文件串行处理,避免占满终端磁盘、CPU 和上行带宽。
class TerminalLogUploadTool {
/// 禁止实例化;上传流程只通过静态入口串行处理本次扫描结果。
TerminalLogUploadTool._();
/// 当前日志脱敏规则版本,随 multipart 元数据提交给服务端留存。
static const String sanitizeVersion = 'v1';
/// 单次预检或文件上传请求的发送与响应超时时间。
static const Duration requestTimeout = Duration(minutes: 2);
/// 扫描并上传终端日志,返回本次处理统计。
///
/// [currentOnly] 为真时只处理 Logger 当前写入文件,供 MQTT 实时拉取使用;每个文件都先生成
/// 脱敏副本并通过预检确认是否需要上传,任一文件失败不会阻断后续文件处理。
static Future<TerminalLogUploadSummary> upload({
bool currentOnly = false,
}) async {
if (!AppNetworkTool.isAutoNetwork()) {
throw StateError('当前网络不可用');
}
final setting = await AppSettingTool.getSysSetting();
final hardId = setting.serviceId;
if (hardId == null || hardId.isEmpty) {
throw StateError('终端尚未绑定,无法上传日志');
}
await flushCurrentAppLog();
final catalog = await listAppLogFiles();
final files = currentOnly
? catalog.files.where((item) => item.isCurrent).toList()
: catalog.files.where((item) => item.exists).toList();
final states = await FileUploadRecordTool.getSuccessfulStates(hardId);
final appVersion = (await PackageInfo.fromPlatform()).version;
var skipped = 0;
var succeeded = 0;
var failed = 0;
DateTime? lastSuccessAt;
for (final item in files) {
final source = File(item.path);
if (!await source.exists()) continue;
final stat = await source.stat();
final sourceSize = item.isCurrent ? stat.size : stat.size;
final previous = states[item.relativePath];
if (previous != null &&
previous.sourceSize == sourceSize &&
previous.sourceModifiedTime == stat.modified) {
skipped++;
lastSuccessAt = _latest(lastSuccessAt, previous.uploadedAt);
continue;
}
File? temporaryFile;
FileUploadRecordEntity? uploadAttempt;
try {
uploadAttempt = await FileUploadRecordTool.beginAttempt(
hardId: hardId,
relativePath: item.relativePath,
sourceSize: sourceSize,
sourceModifiedTime: stat.modified,
);
temporaryFile = File(
'${Directory.systemTemp.path}/terminal-log-${DateTime.now().microsecondsSinceEpoch}.log',
);
await sanitizeAppLogFile(
source: source,
target: temporaryFile,
maxSourceBytes: sourceSize,
);
final sanitizedHash =
(await md5.bind(temporaryFile.openRead()).first).toString();
final sanitizedSize = await temporaryFile.length();
final needUpload = await _preflight(
hardId: hardId,
relativePath: item.relativePath,
fileSize: sanitizedSize,
fileHash: sanitizedHash,
);
String? serverFileId;
if (needUpload) {
serverFileId = await _uploadFile(
hardId: hardId,
relativePath: item.relativePath,
modifiedAt: stat.modified,
appVersion: appVersion,
file: temporaryFile,
);
succeeded++;
} else {
skipped++;
}
final uploadedAt = DateTime.now();
lastSuccessAt = _latest(lastSuccessAt, uploadedAt);
await FileUploadRecordTool.markSuccess(
attempt: uploadAttempt,
sanitizedHash: sanitizedHash,
sanitizedSize: sanitizedSize,
serverFileId: serverFileId,
);
} catch (error, stack) {
failed++;
if (uploadAttempt != null) {
try {
await FileUploadRecordTool.markFailure(
attempt: uploadAttempt,
error: error,
);
} catch (recordError, recordStack) {
// 上传异常是主错误,记录表写入失败只能补充告警,不能覆盖真实失败原因。
logger.w(
'|终端日志上传|保存失败记录异常path:${item.relativePath}|'
'error:$recordError|stack:$recordStack|',
);
}
}
logger.e(
'|终端日志上传|文件处理失败path:${item.relativePath}|error:$error|stack:$stack|',
);
} finally {
try {
if (temporaryFile != null && await temporaryFile.exists()) {
await temporaryFile.delete();
}
} catch (error, stack) {
// 临时脱敏副本清理失败不能中断后续文件,但必须留下可诊断告警。
logger.w(
'|终端日志上传|临时文件清理失败path:${temporaryFile?.path}|'
'error:$error|stack:$stack|',
);
}
}
}
final summary = TerminalLogUploadSummary(
scanned: files.length,
skipped: skipped,
succeeded: succeeded,
failed: failed,
lastSuccessAt: lastSuccessAt,
);
if (failed > 0) throw TerminalLogUploadException(summary);
return summary;
}
/// 在可空的当前时间与候选时间中返回较晚值,用于汇总最近成功时间。
static DateTime _latest(DateTime? current, DateTime candidate) =>
current == null || candidate.isAfter(current) ? candidate : current;
/// 向后端提交路径、脱敏文件大小和 MD5,返回是否仍需上传文件内容。
static Future<bool> _preflight({
required String hardId,
required String relativePath,
required int fileSize,
required String fileHash,
}) async {
final response = await _retryRequest(
() => HttpManage().dio.post(
'/hard/hardTerminalLogFile/ad/preflight',
data: {
'relativePath': relativePath,
'fileSize': fileSize,
'fileHash': fileHash,
},
options: Options(
headers: {'DEVICE-ID': hardId},
sendTimeout: requestTimeout,
receiveTimeout: requestTimeout,
),
),
);
final data = response.data;
if (data is! Map || data['needUpload'] is! bool) {
throw StateError('日志上传预检响应无效');
}
return data['needUpload'] as bool;
}
/// 以 multipart 上传单个脱敏日志,并返回后端 `hard_terminal_log_file.id`。
static Future<String> _uploadFile({
required String hardId,
required String relativePath,
required DateTime modifiedAt,
required String appVersion,
required File file,
}) async {
final fileName = relativePath.replaceAll('\\', '/').split('/').last;
final response = await _retryRequest(
() => HttpManage().dio.post(
'/hard/hardTerminalLogFile/ad/upload',
data: FormData.fromMap({
'relativePath': relativePath,
'logDate': _logDate(relativePath, modifiedAt),
'fileModifiedTime': modifiedAt.toIso8601String(),
'appVersion': appVersion,
'sanitizeVersion': sanitizeVersion,
'file': MultipartFile.fromFileSync(file.path, filename: fileName),
}),
options: Options(
headers: {'DEVICE-ID': hardId},
contentType: 'multipart/form-data',
sendTimeout: requestTimeout,
receiveTimeout: requestTimeout,
),
),
);
final data = response.data;
if (data is! Map || data['id'] == null) {
// 统一请求层会展示业务错误但不抛异常,上传器必须自行确认服务端确实返回了成功结果。
throw StateError('日志上传响应无效');
}
return data['id'].toString();
}
/// 对连接、收发超时、网络错误和 5xx 最多重试三次,业务错误直接向上抛出。
static Future<Response<dynamic>> _retryRequest(
Future<Response<dynamic>> Function() action,
) async {
Object? lastError;
for (var attempt = 1; attempt <= 3; attempt++) {
try {
return await action();
} on DioException catch (error) {
lastError = error;
final transient = error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.connectionError ||
(error.response?.statusCode ?? 0) >= 500;
if (!transient || attempt == 3) rethrow;
await Future<void>.delayed(Duration(seconds: attempt));
}
}
throw StateError('日志上传失败:$lastError');
}
/// 从标准日志相对路径推导 `yyyy-MM-dd`,旧路径不匹配时回退到文件修改日期。
static String _logDate(String relativePath, DateTime fallback) {
final match = RegExp(r'(\d{4})-(\d{2})/(\d{2})\.log$')
.firstMatch(relativePath.replaceAll('\\', '/'));
if (match == null) {
return '${fallback.year.toString().padLeft(4, '0')}-${fallback.month.toString().padLeft(2, '0')}-${fallback.day.toString().padLeft(2, '0')}';
}
return '${match.group(1)}-${match.group(2)}-${match.group(3)}';
}
/// 清理前必须确认历史日志已经成功上传,未同步文件只告警、不删除。
static Future<int> cleanupExpiredLogs() async {
final catalog = await listAppLogFiles();
final setting = await AppSettingTool.getSysSetting();
final hardId = setting.serviceId;
if (hardId == null || hardId.isEmpty) return 0;
final states = await FileUploadRecordTool.getSuccessfulStates(hardId);
final expireBefore = DateTime(
DateTime.now().year,
DateTime.now().month - 3,
DateTime.now().day,
);
var deleted = 0;
for (final item in catalog.files) {
if (item.isCurrent ||
item.modifiedAt == null ||
!item.modifiedAt!.isBefore(expireBefore)) {
continue;
}
final state = states[item.relativePath];
final file = File(item.path);
if (state == null) {
logger.w('|终端日志清理|过期日志尚未上传,暂不删除path:${item.relativePath}|');
continue;
}
if (await file.exists()) {
final stat = await file.stat();
// 上传成功后文件可能被继续写入,只有本地快照仍匹配时才允许清理。
if (state.sourceSize != stat.size ||
state.sourceModifiedTime != stat.modified) {
logger.w('|终端日志清理|过期日志上传后已有变化,暂不删除path:${item.relativePath}|');
continue;
}
await file.delete();
deleted++;
}
}
return deleted;
}
}