feat:设备日志模块
feat:定时任务 feat:设置页面用路由重构
This commit is contained in:
1 parent
1033f403b1
commit
2efa7a3c19
60 files changed
+6560
-643
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -6,9 +7,12 @@ import 'package:mqtt_client/mqtt_client.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 应用级 MQTT 连接初始化、精确 Topic 过滤和业务动作分发工具。
|
||||
class AppMqTool {
|
||||
/// MQTT 连接与消息分发诊断日志使用的稳定模块标签。
|
||||
static const String _tag = "app mq 工具";
|
||||
|
||||
/// 校验网络和设备绑定后重建 MQTT 连接,并注册当前设备的消息监听。
|
||||
static Future<void> mqRefresh() async {
|
||||
final isNetwork = AppNetworkTool.isAutoNetwork();
|
||||
if (!isNetwork) {
|
||||
@@ -74,8 +78,10 @@ class AppMqTool {
|
||||
});
|
||||
}
|
||||
|
||||
// 根据 msg action 来处理 mq 消息
|
||||
static void dispatchMqttMessage(MqBaseMsg msg, String orgStr) async {
|
||||
/// 根据协议动作分发已完成设备 Topic 校验的 MQTT 消息。
|
||||
///
|
||||
/// [orgStr] 仅用于需要专用消息模型的兼容动作;日志拉取采用非阻塞触发,避免占用 MQTT 回调。
|
||||
static Future<void> dispatchMqttMessage(MqBaseMsg msg, String orgStr) async {
|
||||
if (ObjectUtil.isEmpty(msg.action)) return;
|
||||
final app = Get.find<AppControl>();
|
||||
final mqUpdateEventBus = app.mqUpdateEventBus;
|
||||
@@ -128,6 +134,15 @@ class AppMqTool {
|
||||
logger.e("|$_tag|定餐广播执行错误orgStr|$orgStr|error|$e|stack|$stack|");
|
||||
}
|
||||
return;
|
||||
case MqtActionEnum.PULL_TERMINAL_LOG:
|
||||
mqUpdateEventBus.fire(MqtActionEnum.PULL_TERMINAL_LOG);
|
||||
// MQTT 回调不能等待文件扫描和上传,任务会在支付/识别结束后的安全窗口异步执行。
|
||||
unawaited(SyncCheckTool.instance.trigger(
|
||||
ScheduledTaskType.terminalLogUpload,
|
||||
currentLogOnly: true,
|
||||
triggerType: ScheduledTaskTriggerType.mqtt,
|
||||
));
|
||||
return;
|
||||
case MqtActionEnum.FACE:
|
||||
return;
|
||||
// final msg = MqttInvoiceMsg.fromJson(json.decode(orgStr));
|
||||
@@ -151,11 +166,10 @@ class AppMqTool {
|
||||
// );
|
||||
// mqUpdateEventBus.fire(MqtActionEnum.FACE);
|
||||
// break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消当前 MQTT 消息订阅并清空 AppControl 中的监听句柄。
|
||||
static void mqListenClose() {
|
||||
final app = Get.find<AppControl>();
|
||||
app.mqListen?.cancel();
|
||||
|
||||
@@ -110,6 +110,87 @@ class AppNetworkSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 进程与设备资源快照。
|
||||
///
|
||||
/// CPU 使用率需要比较两次 [processCpuTimeMillis],原生层只返回累计值,避免在平台通道内
|
||||
/// 持有采样定时器;内存和存储同时保留总量,便于顶栏给出百分比并在 Tooltip 展示明细。
|
||||
class AppPerformanceSnapshot {
|
||||
/// Android 进程从启动至采样时累计消耗的 CPU 时间,单位为毫秒。
|
||||
final int processCpuTimeMillis;
|
||||
|
||||
/// Android Runtime 报告的逻辑处理器数量,最小按 1 处理。
|
||||
final int logicalProcessorCount;
|
||||
|
||||
/// 系统物理内存总量,单位为字节;平台数据不可用时为 0。
|
||||
final int systemMemoryTotalBytes;
|
||||
|
||||
/// 系统当前可用物理内存,单位为字节;平台数据不可用时为 0。
|
||||
final int systemMemoryAvailableBytes;
|
||||
|
||||
/// 当前应用进程的 PSS 内存占用,单位为字节。
|
||||
final int appMemoryBytes;
|
||||
|
||||
/// 应用文件目录所在存储卷的总容量,单位为字节;平台数据不可用时为 0。
|
||||
final int storageTotalBytes;
|
||||
|
||||
/// 应用文件目录所在存储卷的可用容量,单位为字节;平台数据不可用时为 0。
|
||||
final int storageAvailableBytes;
|
||||
|
||||
/// 创建一份原生平台已经完成单位换算的资源快照。
|
||||
const AppPerformanceSnapshot({
|
||||
required this.processCpuTimeMillis,
|
||||
required this.logicalProcessorCount,
|
||||
required this.systemMemoryTotalBytes,
|
||||
required this.systemMemoryAvailableBytes,
|
||||
required this.appMemoryBytes,
|
||||
required this.storageTotalBytes,
|
||||
required this.storageAvailableBytes,
|
||||
});
|
||||
|
||||
/// 创建平台通道不可用时的空快照,百分比 Getter 将稳定返回 0。
|
||||
const AppPerformanceSnapshot.empty()
|
||||
: processCpuTimeMillis = 0,
|
||||
logicalProcessorCount = 1,
|
||||
systemMemoryTotalBytes = 0,
|
||||
systemMemoryAvailableBytes = 0,
|
||||
appMemoryBytes = 0,
|
||||
storageTotalBytes = 0,
|
||||
storageAvailableBytes = 0;
|
||||
|
||||
/// 从 Android MethodChannel 返回值解析资源快照,缺失或类型异常字段按 0 处理。
|
||||
factory AppPerformanceSnapshot.fromMap(Map<dynamic, dynamic>? data) {
|
||||
int value(String key) => (data?[key] as num?)?.toInt() ?? 0;
|
||||
return AppPerformanceSnapshot(
|
||||
processCpuTimeMillis: value('processCpuTimeMs'),
|
||||
logicalProcessorCount:
|
||||
value('logicalProcessorCount').clamp(1, 256).toInt(),
|
||||
systemMemoryTotalBytes: value('memoryTotalBytes'),
|
||||
systemMemoryAvailableBytes: value('memoryAvailableBytes'),
|
||||
appMemoryBytes: value('appMemoryBytes'),
|
||||
storageTotalBytes: value('storageTotalBytes'),
|
||||
storageAvailableBytes: value('storageAvailableBytes'),
|
||||
);
|
||||
}
|
||||
|
||||
/// 系统内存已使用比例,范围为 0 到 1;总量未知时返回 0。
|
||||
double get memoryUsageRatio => _usedRatio(
|
||||
total: systemMemoryTotalBytes,
|
||||
available: systemMemoryAvailableBytes,
|
||||
);
|
||||
|
||||
/// 应用所在存储卷已使用比例,范围为 0 到 1;总量未知时返回 0。
|
||||
double get storageUsageRatio => _usedRatio(
|
||||
total: storageTotalBytes,
|
||||
available: storageAvailableBytes,
|
||||
);
|
||||
|
||||
/// 根据总量和可用量计算受限于 0 到 1 的占用比例,容忍原生瞬时采样误差。
|
||||
static double _usedRatio({required int total, required int available}) {
|
||||
if (total <= 0) return 0;
|
||||
return ((total - available) / total).clamp(0, 1).toDouble();
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一管理虚拟机识别和硬件手动输入模式,避免各业务页面自行猜测设备能力。
|
||||
abstract class AppRuntimeTool {
|
||||
static const MethodChannel _channel = MethodChannel(
|
||||
@@ -208,6 +289,18 @@ abstract class AppRuntimeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取一次 Android 进程、内存和存储快照;通道失败时返回可安全展示的空快照。
|
||||
static Future<AppPerformanceSnapshot> getPerformanceSnapshot() async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'getPerformanceSnapshot',
|
||||
);
|
||||
return AppPerformanceSnapshot.fromMap(data);
|
||||
} on PlatformException {
|
||||
return const AppPerformanceSnapshot.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询插件当前 Presentation 是否仍在展示,用于避免 showSubDisplay() 先关后开造成闪屏。
|
||||
static Future<bool> isSubScreenShowing() async {
|
||||
try {
|
||||
|
||||
@@ -28,6 +28,18 @@ abstract class AppSettingTool {
|
||||
"reservationQueryVerificationDateScope";
|
||||
static const String _serialCardConfigKey = "serialCardConfig";
|
||||
|
||||
/// v8 之前短暂保存定时任务配置的旧 sysJson 键,仅供一次性迁移读取。
|
||||
static const String _scheduledTaskConfigsKey = "scheduledTaskConfigs";
|
||||
|
||||
/// v8 之前短暂保存任务最近执行结果的旧 sysJson 键,仅供一次性迁移读取。
|
||||
static const String _scheduledTaskRecordsKey = "scheduledTaskRecords";
|
||||
|
||||
/// v8 之前保存日志文件成功上传状态的旧 sysJson 键,仅供一次性迁移读取。
|
||||
static const String _terminalLogUploadStatesKey = "terminalLogUploadStates";
|
||||
|
||||
/// v8 之前保存最近日志上传汇总的旧 sysJson 键,仅供一次性迁移清理。
|
||||
static const String _terminalLogUploadSummaryKey = "terminalLogUploadSummary";
|
||||
|
||||
/// 人工硬件输入历史在现有 sysJson 扩展配置中的存储键。
|
||||
static const String _hardwareInputHistoryKey = "hardwareInputHistory";
|
||||
|
||||
@@ -40,6 +52,9 @@ abstract class AppSettingTool {
|
||||
),
|
||||
);
|
||||
|
||||
/// sysJson 扩展数据的进程内写入队列,避免迁移清理与设置保存相互覆盖。
|
||||
static Future<void> _extensionWriteQueue = Future.value();
|
||||
|
||||
static Future<void> removeSettingCache() async {
|
||||
await _settingCache.emptyCache();
|
||||
}
|
||||
@@ -143,6 +158,100 @@ abstract class AppSettingTool {
|
||||
await _setSetting(sysJson: sysInfo);
|
||||
}
|
||||
|
||||
/// 读取完整 sysJson 为可修改映射,仅供旧结构化数据迁移方法使用。
|
||||
static Future<Map<String, dynamic>> _getSysExtensionJson() async {
|
||||
final setting = await _getSetting();
|
||||
return Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
}
|
||||
|
||||
/// 仅用于把 v8 之前短暂写入 sysJson 的任务配置导入结构化表,不再提供 JSON 写入口。
|
||||
static Future<List<ScheduledTaskConfig>> getScheduledTaskConfigs() async {
|
||||
final sysJson = await _getSysExtensionJson();
|
||||
final raw = sysJson[_scheduledTaskConfigsKey];
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((item) => ScheduledTaskConfig.fromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// 读取 v8 之前的任务执行快照并按当前枚举过滤未知任务,仅用于迁移。
|
||||
static Future<Map<ScheduledTaskType, ScheduledTaskRecord>>
|
||||
getScheduledTaskRecords() async {
|
||||
final sysJson = await _getSysExtensionJson();
|
||||
final raw = sysJson[_scheduledTaskRecordsKey];
|
||||
if (raw is! Map) return const {};
|
||||
final result = <ScheduledTaskType, ScheduledTaskRecord>{};
|
||||
for (final entry in raw.entries) {
|
||||
ScheduledTaskType? type;
|
||||
for (final item in ScheduledTaskType.values) {
|
||||
if (item.name == entry.key.toString()) {
|
||||
type = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (type == null || entry.value is! Map) continue;
|
||||
result[type] = ScheduledTaskRecord.fromJson(
|
||||
Map<String, dynamic>.from(entry.value as Map),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 读取 v8 之前按相对路径保存的成功上传状态,仅用于导入文件上传记录表。
|
||||
static Future<Map<String, TerminalLogUploadState>>
|
||||
getTerminalLogUploadStates() async {
|
||||
final sysJson = await _getSysExtensionJson();
|
||||
final raw = sysJson[_terminalLogUploadStatesKey];
|
||||
if (raw is! Map) return const {};
|
||||
return raw.map(
|
||||
(key, value) => MapEntry(
|
||||
key.toString(),
|
||||
TerminalLogUploadState.fromJson(
|
||||
Map<String, dynamic>.from(value as Map),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 读取 v8 之前的最近上传汇总;仅保留兼容能力,不再作为运行时数据源。
|
||||
static Future<TerminalLogUploadSummary> getTerminalLogUploadSummary() async {
|
||||
final sysJson = await _getSysExtensionJson();
|
||||
final raw = sysJson[_terminalLogUploadSummaryKey];
|
||||
if (raw is! Map) return const TerminalLogUploadSummary();
|
||||
return TerminalLogUploadSummary.fromJson(Map<String, dynamic>.from(raw));
|
||||
}
|
||||
|
||||
/// v8 已将任务与上传状态迁入独立表,导入成功后移除旧 JSON,避免形成双写数据源。
|
||||
static Future<void> clearLegacyScheduledTaskState({
|
||||
required bool includeUploadState,
|
||||
}) async {
|
||||
final operation = _extensionWriteQueue.catchError((_) {}).then((_) async {
|
||||
final app = Get.find<AppControl>();
|
||||
final settingDao = app.dataBase.getSettingDao;
|
||||
final setting = await settingDao.get();
|
||||
if (setting == null) throw StateError('终端设置尚未初始化');
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
sysJson.remove(_scheduledTaskConfigsKey);
|
||||
sysJson.remove(_scheduledTaskRecordsKey);
|
||||
if (includeUploadState) {
|
||||
sysJson.remove(_terminalLogUploadStatesKey);
|
||||
sysJson.remove(_terminalLogUploadSummaryKey);
|
||||
}
|
||||
setting.sysJson = json.encode(sysJson);
|
||||
await settingDao.edit(setting);
|
||||
await _settingCache.putFile(
|
||||
_settingKey,
|
||||
Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))),
|
||||
fileExtension: 'json',
|
||||
);
|
||||
});
|
||||
_extensionWriteQueue = operation;
|
||||
await operation;
|
||||
}
|
||||
|
||||
static Future<void> setUserSetting(UserSetting userInfo) async {
|
||||
final globalUserInfo = await getUserSetting();
|
||||
if (globalUserInfo == userInfo) return;
|
||||
|
||||
@@ -44,6 +44,10 @@ export './plugs/face_discern_plugs.dart';
|
||||
export './router/router_observer_tool.dart';
|
||||
|
||||
export './sync/sync_check_tool.dart';
|
||||
export './sync/sync_idle_tool.dart';
|
||||
export './sync/scheduled_task_storage_tool.dart';
|
||||
export './sync/file_upload_record_tool.dart';
|
||||
export './sync/terminal_log_upload_tool.dart';
|
||||
export './sync/sync_config_tool.dart';
|
||||
export './sync/sync_face_tool.dart';
|
||||
export './sync/sync_order_tool.dart';
|
||||
|
||||
@@ -43,6 +43,53 @@ Future<AppLogCatalog> listAppLogFiles() async {
|
||||
return _consoleOutput.listLogFiles();
|
||||
}
|
||||
|
||||
/// 上传当前日志前刷新文件写入队列,保证快照边界包含调用前已经产生的日志。
|
||||
Future<void> flushCurrentAppLog() async {
|
||||
await logger.init;
|
||||
await _consoleOutput.flushCurrentLog();
|
||||
}
|
||||
|
||||
/// 逐行生成脱敏副本,上传工具不会把原始日志文件直接交给网络层。
|
||||
Future<void> sanitizeAppLogFile({
|
||||
required File source,
|
||||
required File target,
|
||||
int? maxSourceBytes,
|
||||
}) async {
|
||||
final sourceLength = await source.length();
|
||||
final limit = maxSourceBytes == null
|
||||
? sourceLength
|
||||
: maxSourceBytes.clamp(0, sourceLength);
|
||||
final input = await source.open();
|
||||
IOSink? output;
|
||||
try {
|
||||
output = target.openWrite(mode: FileMode.writeOnly);
|
||||
var position = 0;
|
||||
var pending = '';
|
||||
const chunkSize = 64 * 1024;
|
||||
while (position < limit) {
|
||||
final length = (limit - position).clamp(0, chunkSize);
|
||||
final bytes = await input.read(length);
|
||||
if (bytes.isEmpty) break;
|
||||
position += bytes.length;
|
||||
pending += utf8.decode(bytes, allowMalformed: true);
|
||||
final lines = pending.split('\n');
|
||||
pending = lines.removeLast();
|
||||
for (final line in lines) {
|
||||
output.writeln(sanitizeAppLogContent(line));
|
||||
}
|
||||
}
|
||||
if (pending.isNotEmpty) output.write(sanitizeAppLogContent(pending));
|
||||
await output.flush();
|
||||
} finally {
|
||||
await input.close();
|
||||
await output?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志预览和上传共用同一套脱敏规则,避免两个入口出现隐私边界漂移。
|
||||
String sanitizeAppLogContent(String content) =>
|
||||
_sanitizePreviewContent(content);
|
||||
|
||||
class AppLogCatalog {
|
||||
const AppLogCatalog({
|
||||
required this.rootPath,
|
||||
@@ -113,6 +160,9 @@ class ConsoleOutput extends LogOutput {
|
||||
return _queue;
|
||||
}
|
||||
|
||||
/// 排在既有写入队列末尾刷新当前文件,完成后调用方可取得一致的文件大小快照。
|
||||
Future<void> flushCurrentLog() => _enqueue(() async => _sink?.flush());
|
||||
|
||||
/// 读取当前日志文件尾部,并在返回 UI 前移除终端颜色码和常见敏感字段。
|
||||
Future<AppLogPreview> readPreview({
|
||||
String? path,
|
||||
|
||||
@@ -229,6 +229,7 @@ class MqManager {
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/// 将底层连接异常归一化为任务层可展示原因,并抑制可预期网络故障的重复长堆栈。
|
||||
static void _recordConnectionFailure(Object error, StackTrace stack) {
|
||||
state = MqttConnectionState.disconnected;
|
||||
final message = error.toString().toLowerCase();
|
||||
@@ -239,6 +240,12 @@ class MqManager {
|
||||
: error is SocketException
|
||||
? '消息服务地址不可达'
|
||||
: '消息服务连接失败';
|
||||
if (error is TimeoutException || error is SocketException) {
|
||||
// 连接超时和地址不可达属于可预期的外部依赖故障,任务日志会保留结构化结果,
|
||||
// 此处不再每分钟重复输出整段堆栈污染待上传日志。
|
||||
logger.w("|$_tag|mqtt连接失败:${connectionFailureReason ?? error}|");
|
||||
return;
|
||||
}
|
||||
logger.e("|$_tag|mqtt连接失败error:|$error|stack:|$stack|");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user