feat:消费机更新
feat:人员调用钱包部门不调动的bug
This commit is contained in:
1 parent
842402e364
commit
60d9380a65
144 files changed
+27832
-1666
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,857 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 订单识别证据本地队列入口。
|
||||
///
|
||||
/// 本工具只保存短期待办和失败元数据,长期证据由服务端 `hard_order_evidence_image` 保存。
|
||||
/// 所有批处理均串行执行,避免与收银、人脸识别及其他重型任务争用磁盘和上行带宽。
|
||||
abstract final class OrderEvidenceUploadTool {
|
||||
/// 单轮自动或手动补传最多处理的任务数量。
|
||||
static const int batchLimit = 20;
|
||||
|
||||
/// 尚未发起订单的识别临时任务最长保留时间。
|
||||
static const Duration waitingOrderRetention = Duration(hours: 24);
|
||||
|
||||
/// 已绑定订单但未成功的现场图片最长保留时间。
|
||||
static const Duration failedImageRetention = Duration(days: 30);
|
||||
|
||||
/// 成功任务元数据保留时间,超过后只删除本地队列记录。
|
||||
static const Duration successfulMetadataRetention = Duration(days: 30);
|
||||
|
||||
/// 服务端允许继续补传的已支付、退款及已核销订单状态编码。
|
||||
static const Set<String> uploadableOrderStatuses = {'5', '10', '20'};
|
||||
|
||||
/// 退避阶梯,依次为 1、5、15、60、120、240、360 分钟,之后固定最长 6 小时。
|
||||
static const List<Duration> retryDelays = [
|
||||
Duration(minutes: 1),
|
||||
Duration(minutes: 5),
|
||||
Duration(minutes: 15),
|
||||
Duration(minutes: 60),
|
||||
Duration(minutes: 120),
|
||||
Duration(minutes: 240),
|
||||
Duration(minutes: 360),
|
||||
];
|
||||
|
||||
/// 当前应用数据库中的订单证据任务 DAO;调度启动前数据库必须已经由 AppControl 打开。
|
||||
static OrderEvidenceUploadTaskDao get _dao =>
|
||||
Get.find<AppControl>().dataBase.getOrderEvidenceUploadTaskDao;
|
||||
|
||||
/// 当前正在执行的补传批次;预约核销即时触发与五分钟定时任务共用,防止并发处理同一现场文件。
|
||||
static Future<OrderEvidenceUploadSummary>? _processingBatch;
|
||||
|
||||
/// 保存一次收银场景人脸成功结果对应的参考图和现场图待办。
|
||||
///
|
||||
/// 同一识别会话重复回调时直接复用已有记录。现场文件生成失败仍会建立缺失任务,待订单
|
||||
/// 成功后向服务端明确报告 `UNAVAILABLE`,避免 PC 端把异常误判为没有使用人脸识别。
|
||||
static Future<void> createFaceRecognitionTasks({
|
||||
required FaceStream recognition,
|
||||
required FaceEntity face,
|
||||
}) async {
|
||||
final sessionId = recognition.sessionId?.trim();
|
||||
if (sessionId == null || sessionId.isEmpty) return;
|
||||
await _cleanupOrphanEvidenceFiles(DateTime.now());
|
||||
if ((await _dao.getByRecognitionSession(sessionId)).isNotEmpty) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
final capturedAt = recognition.capturedAt == null
|
||||
? now
|
||||
: DateTime.fromMillisecondsSinceEpoch(recognition.capturedAt!);
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
final appVersion = '${package.version}+${package.buildNumber}';
|
||||
final expiresAt = now.add(waitingOrderRetention);
|
||||
final common = (
|
||||
sessionId: sessionId,
|
||||
userId: face.userId,
|
||||
sampleId: face.sampleId,
|
||||
similarity: recognition.similar,
|
||||
threshold: recognition.similarityThreshold,
|
||||
capturedAt: capturedAt,
|
||||
appVersion: appVersion,
|
||||
now: now,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
|
||||
await _dao.save(OrderEvidenceUploadTaskEntity(
|
||||
recognitionSessionId: common.sessionId,
|
||||
evidenceType: OrderEvidenceType.faceRecognition.code,
|
||||
evidenceRole: OrderEvidenceRole.faceReference.code,
|
||||
sequenceNo: 0,
|
||||
userId: common.userId,
|
||||
faceSampleId: common.sampleId,
|
||||
referenceImageUrl: face.imagePath,
|
||||
referenceImageVersion: face.updateTime?.toIso8601String(),
|
||||
similarity: common.similarity,
|
||||
similarityThreshold: common.threshold,
|
||||
capturedAt: common.capturedAt,
|
||||
appVersion: common.appVersion,
|
||||
status: OrderEvidenceTaskStatus.waitingOrder.code,
|
||||
createTime: common.now,
|
||||
updateTime: common.now,
|
||||
expiresAt: common.expiresAt,
|
||||
));
|
||||
await _dao.save(OrderEvidenceUploadTaskEntity(
|
||||
recognitionSessionId: common.sessionId,
|
||||
evidenceType: OrderEvidenceType.faceRecognition.code,
|
||||
evidenceRole: OrderEvidenceRole.faceLive.code,
|
||||
sequenceNo: 0,
|
||||
userId: common.userId,
|
||||
faceSampleId: common.sampleId,
|
||||
localFilePath: recognition.evidenceFilePath,
|
||||
imageHash: recognition.evidenceHash,
|
||||
imageSize: recognition.evidenceSize,
|
||||
imageWidth: recognition.evidenceWidth,
|
||||
imageHeight: recognition.evidenceHeight,
|
||||
similarity: common.similarity,
|
||||
similarityThreshold: common.threshold,
|
||||
capturedAt: common.capturedAt,
|
||||
appVersion: common.appVersion,
|
||||
status: OrderEvidenceTaskStatus.waitingOrder.code,
|
||||
lastError: recognition.evidenceFilePath == null
|
||||
? _safeFailure(recognition.evidenceError, fallback: '现场人脸图片生成失败')
|
||||
: null,
|
||||
createTime: common.now,
|
||||
updateTime: common.now,
|
||||
expiresAt: common.expiresAt,
|
||||
));
|
||||
}
|
||||
|
||||
/// 将识别会话绑定到即将提交的终端订单号,并返回下单接口使用的元数据清单。
|
||||
///
|
||||
/// 绑定只表示订单请求已经发起,不表示服务端已落单或支付成功;服务端 ID 必须在响应成功或
|
||||
/// 后台对账后再写入。若同一订单位置已有任务,保留先到记录并取消重复现场文件。
|
||||
static Future<List<Map<String, dynamic>>> bindSessionToOrder({
|
||||
required String recognitionSessionId,
|
||||
required String androidOrderCode,
|
||||
}) async {
|
||||
final tasks = await _dao.getByRecognitionSession(recognitionSessionId);
|
||||
if (tasks.isEmpty) return const [];
|
||||
final now = DateTime.now();
|
||||
final bound = <OrderEvidenceUploadTaskEntity>[];
|
||||
for (final task in tasks) {
|
||||
if (_isTerminal(OrderEvidenceTaskStatus.fromCode(task.status))) continue;
|
||||
final existing = await _dao.getByPosition(
|
||||
androidOrderCode,
|
||||
task.evidenceType,
|
||||
task.evidenceRole,
|
||||
task.sequenceNo,
|
||||
);
|
||||
if (existing != null && existing.id != task.id) {
|
||||
await _deleteLocalFile(task.localFilePath);
|
||||
await _dao.edit(task.copyWith(
|
||||
status: OrderEvidenceTaskStatus.cancelled.code,
|
||||
clearLocalFilePath: true,
|
||||
lastError: '同一订单证据位置已有待办,本记录已取消',
|
||||
updateTime: now,
|
||||
));
|
||||
bound.add(existing);
|
||||
continue;
|
||||
}
|
||||
final next = task.copyWith(
|
||||
androidOrderCode: androidOrderCode,
|
||||
updateTime: now,
|
||||
expiresAt: now.add(failedImageRetention),
|
||||
);
|
||||
await _dao.edit(next);
|
||||
bound.add(next);
|
||||
}
|
||||
return bound.map(_toCreateOrderMetadata).toList(growable: false);
|
||||
}
|
||||
|
||||
/// 在预约核销请求发出前,把识别会话预关联到已经存在的服务端订单。
|
||||
///
|
||||
/// 预关联仍保持 `WAITING_ORDER`,只有核销成功后才切换为可上传状态;即使核销响应丢失,
|
||||
/// 后台任务也能等订单归属变更到当前设备后继续对账。重复选择同一订单时复用原任务。
|
||||
static Future<void> prepareSessionForExistingOrder({
|
||||
required String recognitionSessionId,
|
||||
required String serverOrderId,
|
||||
}) async {
|
||||
final tasks = await _dao.getByRecognitionSession(recognitionSessionId);
|
||||
if (tasks.isEmpty) throw StateError('未找到本次人脸识别图片,请重新识别');
|
||||
final now = DateTime.now();
|
||||
for (final task in tasks) {
|
||||
if (_isTerminal(OrderEvidenceTaskStatus.fromCode(task.status))) continue;
|
||||
final existing = await _dao.getByServerOrderPosition(
|
||||
serverOrderId,
|
||||
task.evidenceType,
|
||||
task.evidenceRole,
|
||||
task.sequenceNo,
|
||||
);
|
||||
if (existing != null && existing.id != task.id) {
|
||||
await _deleteLocalFile(task.localFilePath);
|
||||
await _dao.edit(task.copyWith(
|
||||
status: OrderEvidenceTaskStatus.cancelled.code,
|
||||
clearLocalFilePath: true,
|
||||
lastError: '同一预约订单证据位置已有待办,本记录已取消',
|
||||
updateTime: now,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
await _dao.edit(task.copyWith(
|
||||
serverOrderId: serverOrderId,
|
||||
updateTime: now,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回识别会话产生任务中的应用版本,供下单 DTO 顶层 `appVersion` 使用。
|
||||
static Future<String?> getSessionAppVersion(
|
||||
String recognitionSessionId) async {
|
||||
final tasks = await _dao.getByRecognitionSession(recognitionSessionId);
|
||||
return tasks.isEmpty ? null : tasks.first.appVersion;
|
||||
}
|
||||
|
||||
/// 服务端明确返回订单成功后写入订单 ID,并把活动任务切换为可补传状态。
|
||||
static Future<void> markOrderReady({
|
||||
required String recognitionSessionId,
|
||||
required String serverOrderId,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
for (final task
|
||||
in await _dao.getByRecognitionSession(recognitionSessionId)) {
|
||||
final status = OrderEvidenceTaskStatus.fromCode(task.status);
|
||||
if (_isTerminal(status)) continue;
|
||||
await _dao.edit(task.copyWith(
|
||||
serverOrderId: serverOrderId,
|
||||
status: OrderEvidenceTaskStatus.ready.code,
|
||||
clearNextRetryAt: true,
|
||||
clearLastError: true,
|
||||
updateTime: now,
|
||||
expiresAt: now.add(failedImageRetention),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单明确失败或用户在下单前取消时终止识别会话任务,并删除现场临时图片。
|
||||
static Future<void> cancelSession(
|
||||
String recognitionSessionId, {
|
||||
String reason = '订单未提交,证据任务已取消',
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
for (final task
|
||||
in await _dao.getByRecognitionSession(recognitionSessionId)) {
|
||||
if (_isTerminal(OrderEvidenceTaskStatus.fromCode(task.status))) continue;
|
||||
await _deleteLocalFile(task.localFilePath);
|
||||
await _dao.edit(task.copyWith(
|
||||
status: OrderEvidenceTaskStatus.cancelled.code,
|
||||
clearLocalFilePath: true,
|
||||
lastError: _safeFailure(reason),
|
||||
clearNextRetryAt: true,
|
||||
updateTime: now,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 串行处理一批到期任务,并在业务处理前完成 24 小时、30 天和成功元数据清理。
|
||||
static Future<OrderEvidenceUploadSummary> processPending() async {
|
||||
final running = _processingBatch;
|
||||
if (running != null) return running;
|
||||
final batch = _processPendingBatch();
|
||||
_processingBatch = batch;
|
||||
try {
|
||||
return await batch;
|
||||
} finally {
|
||||
if (identical(_processingBatch, batch)) _processingBatch = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行单个真实补传批次;调用方必须通过 [processPending] 取得进程内串行保护。
|
||||
static Future<OrderEvidenceUploadSummary> _processPendingBatch() async {
|
||||
final now = DateTime.now();
|
||||
await _cleanupExpired(now);
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
final hardId = setting.serviceId;
|
||||
if (hardId == null || hardId.isEmpty) {
|
||||
throw const ScheduledTaskDependencyException('终端尚未绑定,无法补传订单证据');
|
||||
}
|
||||
var scanned = 0;
|
||||
var skipped = 0;
|
||||
var succeeded = 0;
|
||||
var failed = 0;
|
||||
|
||||
final due = await _dao.getDueTasks(now, batchLimit);
|
||||
for (final task in due) {
|
||||
scanned++;
|
||||
final result = await _processTask(hardId, task);
|
||||
switch (result) {
|
||||
case _TaskProcessResult.skipped:
|
||||
skipped++;
|
||||
break;
|
||||
case _TaskProcessResult.succeeded:
|
||||
succeeded++;
|
||||
break;
|
||||
case _TaskProcessResult.failed:
|
||||
failed++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final remaining = (batchLimit - scanned).clamp(0, batchLimit);
|
||||
if (remaining > 0) {
|
||||
final reports = await _dao.getUnavailableReportTasks(now, remaining);
|
||||
for (final task in reports) {
|
||||
scanned++;
|
||||
final reported = await _reportUnavailable(hardId, task);
|
||||
if (reported) {
|
||||
succeeded++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return OrderEvidenceUploadSummary(
|
||||
scanned: scanned,
|
||||
skipped: skipped,
|
||||
succeeded: succeeded,
|
||||
failed: failed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 返回指定尝试次数对应的退避时长;超出阶梯后固定为 6 小时。
|
||||
static Duration retryDelayForAttempt(int attemptCount) {
|
||||
final index = (attemptCount - 1).clamp(0, retryDelays.length - 1);
|
||||
return retryDelays[index];
|
||||
}
|
||||
|
||||
/// 判断下单失败是否仍可能已经在服务端落单,需要保留证据并按终端订单号对账。
|
||||
///
|
||||
/// 请求超时、断连、取消以及 5xx 都可能发生在服务端提交事务之后;明确的 4xx/业务拒绝和
|
||||
/// 证书失败则没有继续对账的必要。非 Dio 异常无法证明服务端未落单,按保守策略保留。
|
||||
static bool shouldReconcileOrderFailure(Object error) {
|
||||
if (error is! DioException) return true;
|
||||
return switch (error.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout ||
|
||||
DioExceptionType.connectionError ||
|
||||
DioExceptionType.cancel ||
|
||||
DioExceptionType.unknown =>
|
||||
true,
|
||||
DioExceptionType.badCertificate => false,
|
||||
DioExceptionType.badResponse =>
|
||||
(error.response?.statusCode ?? 500) >= 500,
|
||||
};
|
||||
}
|
||||
|
||||
/// 处理单条活动任务,必要时先按终端订单号对账再提交参考图或上传现场图。
|
||||
static Future<_TaskProcessResult> _processTask(
|
||||
String hardId,
|
||||
OrderEvidenceUploadTaskEntity source,
|
||||
) async {
|
||||
var task = source;
|
||||
if (!_hasOrderReference(task)) {
|
||||
return _TaskProcessResult.skipped;
|
||||
}
|
||||
try {
|
||||
final resolved = await HardApi.resolveOrderEvidence(
|
||||
hardId: hardId,
|
||||
androidOrderCode: task.androidOrderCode,
|
||||
orderId: task.serverOrderId,
|
||||
);
|
||||
if (resolved == null) throw StateError('订单对账结果为空');
|
||||
if (!uploadableOrderStatuses.contains(resolved.orderStatus)) {
|
||||
await _scheduleRetry(task, '订单尚未确认支付');
|
||||
return _TaskProcessResult.skipped;
|
||||
}
|
||||
final serverEvidence = _findServerEvidence(resolved, task);
|
||||
if (serverEvidence?.status == 'AVAILABLE') {
|
||||
if (task.imageHash != null &&
|
||||
serverEvidence?.imageHash != null &&
|
||||
task.imageHash!.toLowerCase() !=
|
||||
serverEvidence!.imageHash!.toLowerCase()) {
|
||||
await _markPermanent(
|
||||
task,
|
||||
'服务端相同位置已存在其他图片哈希',
|
||||
serverOrderId: resolved.orderId,
|
||||
evidenceId: serverEvidence.id,
|
||||
// AVAILABLE 记录不允许被 UNAVAILABLE 覆盖;此标记表示本地无需再发缺失报告。
|
||||
unavailableReported: true,
|
||||
);
|
||||
return _TaskProcessResult.failed;
|
||||
}
|
||||
await _markSuccess(
|
||||
task,
|
||||
serverOrderId: resolved.orderId,
|
||||
evidenceId: serverEvidence!.id,
|
||||
imageUrl: serverEvidence.imageUrl,
|
||||
);
|
||||
return _TaskProcessResult.succeeded;
|
||||
}
|
||||
if (serverEvidence?.status == 'UNAVAILABLE') {
|
||||
await _markPermanent(
|
||||
task,
|
||||
serverEvidence?.failureReason ?? '服务端已记录证据永久缺失',
|
||||
serverOrderId: resolved.orderId,
|
||||
evidenceId: serverEvidence?.id,
|
||||
unavailableReported: true,
|
||||
);
|
||||
return _TaskProcessResult.failed;
|
||||
}
|
||||
task = task.copyWith(
|
||||
serverOrderId: resolved.orderId,
|
||||
status: OrderEvidenceTaskStatus.ready.code,
|
||||
clearNextRetryAt: true,
|
||||
updateTime: DateTime.now(),
|
||||
expiresAt: DateTime.now().add(failedImageRetention),
|
||||
);
|
||||
await _dao.edit(task);
|
||||
|
||||
if (task.evidenceRole == OrderEvidenceRole.faceLive.code) {
|
||||
final path = task.localFilePath;
|
||||
if (path == null || !await File(path).exists()) {
|
||||
task = await _markPermanent(
|
||||
task,
|
||||
task.lastError ?? '终端现场图片文件已丢失',
|
||||
serverOrderId: resolved.orderId,
|
||||
);
|
||||
return await _reportUnavailable(hardId, task)
|
||||
? _TaskProcessResult.succeeded
|
||||
: _TaskProcessResult.failed;
|
||||
}
|
||||
await _dao.edit(task.copyWith(
|
||||
status: OrderEvidenceTaskStatus.uploading.code,
|
||||
updateTime: DateTime.now(),
|
||||
));
|
||||
final result = await HardApi.uploadOrderEvidence(
|
||||
hardId: hardId,
|
||||
request: _toRequest(task),
|
||||
filePath: path,
|
||||
);
|
||||
return await _applyServiceResult(task, result);
|
||||
}
|
||||
|
||||
final result = await HardApi.submitOrderEvidence(
|
||||
hardId: hardId,
|
||||
request: _toRequest(task, status: 'PENDING'),
|
||||
);
|
||||
return await _applyServiceResult(task, result);
|
||||
} catch (error) {
|
||||
// 预约核销请求发出前已预关联服务端订单,但订单只有核销提交后才归属当前设备。
|
||||
// 这里把核销前的归属拒绝视为可重试,避免请求响应丢失后永久丢掉已经采集的现场图片。
|
||||
if (_isPreparedReservationEvidence(task)) {
|
||||
await _schedulePreparedReservationRetry(
|
||||
task,
|
||||
_safeFailureForError(error),
|
||||
);
|
||||
} else if (_isPermanentError(error)) {
|
||||
await _markPermanent(task, _safeFailureForError(error));
|
||||
} else {
|
||||
await _scheduleRetry(task, _safeFailureForError(error));
|
||||
}
|
||||
return _TaskProcessResult.failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// 把服务端幂等结果转换为本地终态或下一轮重试状态。
|
||||
static Future<_TaskProcessResult> _applyServiceResult(
|
||||
OrderEvidenceUploadTaskEntity task,
|
||||
ServiceOrderEvidenceUploadResult? result,
|
||||
) async {
|
||||
if (result == null) {
|
||||
await _scheduleRetry(task, '服务端证据处理结果为空');
|
||||
return _TaskProcessResult.failed;
|
||||
}
|
||||
if (result.status == 'AVAILABLE') {
|
||||
await _markSuccess(
|
||||
task,
|
||||
serverOrderId: result.orderId,
|
||||
evidenceId: result.evidenceId,
|
||||
imageUrl: result.imageUrl,
|
||||
);
|
||||
return _TaskProcessResult.succeeded;
|
||||
}
|
||||
if (result.status == 'UNAVAILABLE') {
|
||||
await _markPermanent(
|
||||
task,
|
||||
result.message,
|
||||
serverOrderId: result.orderId,
|
||||
evidenceId: result.evidenceId,
|
||||
unavailableReported: true,
|
||||
);
|
||||
return _TaskProcessResult.failed;
|
||||
}
|
||||
await _scheduleRetry(task, result.message);
|
||||
return _TaskProcessResult.failed;
|
||||
}
|
||||
|
||||
/// 向服务端报告现场图片已经永久缺失;尚未取得订单 ID 时先执行一次订单对账。
|
||||
static Future<bool> _reportUnavailable(
|
||||
String hardId,
|
||||
OrderEvidenceUploadTaskEntity source,
|
||||
) async {
|
||||
var task = source;
|
||||
try {
|
||||
if (task.serverOrderId == null) {
|
||||
final resolved = await HardApi.resolveOrderEvidence(
|
||||
hardId: hardId,
|
||||
androidOrderCode: task.androidOrderCode,
|
||||
orderId: task.serverOrderId,
|
||||
);
|
||||
if (resolved == null ||
|
||||
!uploadableOrderStatuses.contains(resolved.orderStatus)) {
|
||||
await _scheduleUnavailableRetry(task, '永久缺失上报等待订单支付确认');
|
||||
return false;
|
||||
}
|
||||
task = task.copyWith(
|
||||
serverOrderId: resolved.orderId,
|
||||
updateTime: DateTime.now(),
|
||||
);
|
||||
await _dao.edit(task);
|
||||
}
|
||||
final result = await HardApi.submitOrderEvidence(
|
||||
hardId: hardId,
|
||||
request: _toRequest(
|
||||
task,
|
||||
status: 'UNAVAILABLE',
|
||||
failureReason: task.lastError ?? '终端证据图片永久缺失',
|
||||
),
|
||||
);
|
||||
if (result?.status == 'AVAILABLE') {
|
||||
await _markSuccess(
|
||||
task,
|
||||
serverOrderId: result!.orderId,
|
||||
evidenceId: result.evidenceId,
|
||||
imageUrl: result.imageUrl,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (result == null || result.status != 'UNAVAILABLE') {
|
||||
await _scheduleUnavailableRetry(
|
||||
task,
|
||||
result?.message ?? '永久缺失上报结果为空',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await _dao.edit(task.copyWith(
|
||||
serverOrderId: result.orderId,
|
||||
serverEvidenceId: result.evidenceId,
|
||||
status: OrderEvidenceTaskStatus.fromCode(task.status) ==
|
||||
OrderEvidenceTaskStatus.expired
|
||||
? OrderEvidenceTaskStatus.expired.code
|
||||
: OrderEvidenceTaskStatus.permanentFailed.code,
|
||||
unavailableReportedAt: DateTime.now(),
|
||||
clearNextRetryAt: true,
|
||||
updateTime: DateTime.now(),
|
||||
));
|
||||
return true;
|
||||
} catch (error) {
|
||||
await _scheduleUnavailableRetry(task, _safeFailureForError(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理未绑定和图片到期任务,并删除超过保留期的成功元数据。
|
||||
static Future<void> _cleanupExpired(DateTime now) async {
|
||||
for (final task in await _dao.getExpiredWaitingTasks(now)) {
|
||||
await _deleteLocalFile(task.localFilePath);
|
||||
await _dao.edit(task.copyWith(
|
||||
status: OrderEvidenceTaskStatus.expired.code,
|
||||
clearLocalFilePath: true,
|
||||
lastError: task.androidOrderCode == null
|
||||
? '识别后 24 小时内未发起有效订单'
|
||||
: '订单证据超过本地保留期限仍未完成',
|
||||
clearNextRetryAt: true,
|
||||
updateTime: now,
|
||||
));
|
||||
}
|
||||
for (final task in await _dao.getExpiredImageTasks(now)) {
|
||||
await _deleteLocalFile(task.localFilePath);
|
||||
await _dao.edit(task.copyWith(
|
||||
status: OrderEvidenceTaskStatus.expired.code,
|
||||
clearLocalFilePath: true,
|
||||
lastError: '现场图片保留 30 天后仍未补传成功',
|
||||
clearNextRetryAt: true,
|
||||
updateTime: now,
|
||||
));
|
||||
}
|
||||
await _dao
|
||||
.deleteSuccessfulBefore(now.subtract(successfulMetadataRetention));
|
||||
await _cleanupOrphanEvidenceFiles(now);
|
||||
}
|
||||
|
||||
/// 删除原生写图成功但尚未来得及落 Floor 队列就崩溃留下的无主文件。
|
||||
///
|
||||
/// 原生层只会删除超过 24 小时且不在任一本地任务 `localFilePath` 中的 JPEG;仍在等待订单、
|
||||
/// 重试或上传中的文件会被保护,并继续遵循绑定后最多 30 天的保留策略。
|
||||
static Future<void> _cleanupOrphanEvidenceFiles(DateTime now) async {
|
||||
try {
|
||||
final referencedPaths = (await _dao.getAll())
|
||||
.map((task) => task.localFilePath)
|
||||
.whereType<String>()
|
||||
.where((path) => path.isNotEmpty)
|
||||
.toSet();
|
||||
await FaceDiscernPlugs.cleanupOrderEvidenceFiles(
|
||||
referencedPaths: referencedPaths,
|
||||
olderThan: now.subtract(waitingOrderRetention),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.w(
|
||||
'|订单证据补传|无主现场图片清理失败|errorType:${error.runtimeType}|',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 将成功任务切换为终态并立即删除现场图片,文件删除失败也不反向改变服务端成功结果。
|
||||
static Future<void> _markSuccess(
|
||||
OrderEvidenceUploadTaskEntity task, {
|
||||
required String serverOrderId,
|
||||
required String evidenceId,
|
||||
String? imageUrl,
|
||||
}) async {
|
||||
await _deleteLocalFile(task.localFilePath);
|
||||
final now = DateTime.now();
|
||||
await _dao.edit(task.copyWith(
|
||||
serverOrderId: serverOrderId,
|
||||
status: OrderEvidenceTaskStatus.success.code,
|
||||
serverEvidenceId: evidenceId,
|
||||
serverImageUrl: imageUrl,
|
||||
clearLocalFilePath: true,
|
||||
clearNextRetryAt: true,
|
||||
clearLastError: true,
|
||||
completedAt: now,
|
||||
updateTime: now,
|
||||
));
|
||||
}
|
||||
|
||||
/// 将确定无法恢复的任务保存为永久失败,可选标记服务端已经收到永久缺失状态。
|
||||
static Future<OrderEvidenceUploadTaskEntity> _markPermanent(
|
||||
OrderEvidenceUploadTaskEntity task,
|
||||
String reason, {
|
||||
String? serverOrderId,
|
||||
String? evidenceId,
|
||||
bool unavailableReported = false,
|
||||
}) async {
|
||||
final next = task.copyWith(
|
||||
serverOrderId: serverOrderId,
|
||||
serverEvidenceId: evidenceId,
|
||||
status: OrderEvidenceTaskStatus.permanentFailed.code,
|
||||
lastError: _safeFailure(reason),
|
||||
unavailableReportedAt: unavailableReported ? DateTime.now() : null,
|
||||
clearNextRetryAt: true,
|
||||
updateTime: DateTime.now(),
|
||||
);
|
||||
await _dao.edit(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/// 保存一次失败及下一次允许执行时间,不抛出原始异常或记录终端完整文件路径。
|
||||
static Future<void> _scheduleRetry(
|
||||
OrderEvidenceUploadTaskEntity task,
|
||||
String reason,
|
||||
) async {
|
||||
final attempts = task.attemptCount + 1;
|
||||
final now = DateTime.now();
|
||||
await _dao.edit(task.copyWith(
|
||||
status: OrderEvidenceTaskStatus.retryWait.code,
|
||||
attemptCount: attempts,
|
||||
nextRetryAt: now.add(retryDelayForAttempt(attempts)),
|
||||
lastError: _safeFailure(reason),
|
||||
updateTime: now,
|
||||
));
|
||||
}
|
||||
|
||||
/// 预约核销尚未确认时保留等待状态并设置退避,避免设备归属尚未更新被误判为永久失败。
|
||||
static Future<void> _schedulePreparedReservationRetry(
|
||||
OrderEvidenceUploadTaskEntity task,
|
||||
String reason,
|
||||
) async {
|
||||
final attempts = task.attemptCount + 1;
|
||||
final now = DateTime.now();
|
||||
await _dao.edit(task.copyWith(
|
||||
status: OrderEvidenceTaskStatus.waitingOrder.code,
|
||||
attemptCount: attempts,
|
||||
nextRetryAt: now.add(retryDelayForAttempt(attempts)),
|
||||
lastError: _safeFailure(reason),
|
||||
updateTime: now,
|
||||
));
|
||||
}
|
||||
|
||||
/// 永久缺失上报失败时只更新退避时间,保持 EXPIRED 或 PERMANENT_FAILED 终态不再进入上传队列。
|
||||
static Future<void> _scheduleUnavailableRetry(
|
||||
OrderEvidenceUploadTaskEntity task,
|
||||
String reason,
|
||||
) async {
|
||||
final attempts = task.attemptCount + 1;
|
||||
final now = DateTime.now();
|
||||
final current = OrderEvidenceTaskStatus.fromCode(task.status);
|
||||
await _dao.edit(task.copyWith(
|
||||
status: current == OrderEvidenceTaskStatus.expired
|
||||
? OrderEvidenceTaskStatus.expired.code
|
||||
: OrderEvidenceTaskStatus.permanentFailed.code,
|
||||
attemptCount: attempts,
|
||||
nextRetryAt: now.add(retryDelayForAttempt(attempts)),
|
||||
lastError: _safeFailure(reason),
|
||||
updateTime: now,
|
||||
));
|
||||
}
|
||||
|
||||
/// 查找服务端同一类型、角色和序号的证据位置。
|
||||
static ServiceOrderEvidenceImage? _findServerEvidence(
|
||||
ServiceOrderEvidenceResolve resolved,
|
||||
OrderEvidenceUploadTaskEntity task,
|
||||
) {
|
||||
for (final evidence in resolved.evidenceImages) {
|
||||
if (evidence.evidenceType == task.evidenceType &&
|
||||
evidence.evidenceRole == task.evidenceRole &&
|
||||
evidence.sequenceNo == task.sequenceNo) {
|
||||
return evidence;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 构造提交或上传接口共用请求,终端本地路径永远不会进入请求字段。
|
||||
static OrderEvidenceRequest _toRequest(
|
||||
OrderEvidenceUploadTaskEntity task, {
|
||||
String? status,
|
||||
String? failureReason,
|
||||
}) {
|
||||
return OrderEvidenceRequest(
|
||||
androidOrderCode: task.androidOrderCode,
|
||||
orderId: task.serverOrderId,
|
||||
evidenceType: task.evidenceType,
|
||||
evidenceRole: task.evidenceRole,
|
||||
sequenceNo: task.sequenceNo,
|
||||
userId: task.userId,
|
||||
faceSampleId: task.faceSampleId,
|
||||
referenceImageUrl: task.referenceImageUrl,
|
||||
referenceImageVersion:
|
||||
DateTime.tryParse(task.referenceImageVersion ?? ''),
|
||||
similarity: task.similarity,
|
||||
similarityThreshold: task.similarityThreshold,
|
||||
capturedAt: task.capturedAt,
|
||||
imageHash: task.imageHash,
|
||||
imageSize: task.imageSize,
|
||||
imageWidth: task.imageWidth,
|
||||
imageHeight: task.imageHeight,
|
||||
status: status,
|
||||
failureReason: failureReason,
|
||||
appVersion: task.appVersion,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构造下单 DTO 的可选证据占位元数据,不包含本地文件路径和参考图版本内部字段。
|
||||
static Map<String, dynamic> _toCreateOrderMetadata(
|
||||
OrderEvidenceUploadTaskEntity task,
|
||||
) {
|
||||
return <String, dynamic>{
|
||||
'evidenceType': task.evidenceType,
|
||||
'evidenceRole': task.evidenceRole,
|
||||
'sequenceNo': task.sequenceNo,
|
||||
'userId': task.userId,
|
||||
if (task.faceSampleId != null) 'faceSampleId': task.faceSampleId,
|
||||
if (task.referenceImageUrl != null)
|
||||
'referenceImageUrl': task.referenceImageUrl,
|
||||
if (task.referenceImageVersion != null)
|
||||
'referenceImageVersion': task.referenceImageVersion,
|
||||
if (task.similarity != null) 'similarity': task.similarity,
|
||||
if (task.similarityThreshold != null)
|
||||
'similarityThreshold': task.similarityThreshold,
|
||||
'capturedAt': task.capturedAt.toIso8601String(),
|
||||
if (task.imageHash != null) 'imageHash': task.imageHash,
|
||||
if (task.imageSize != null) 'imageSize': task.imageSize,
|
||||
if (task.imageWidth != null) 'imageWidth': task.imageWidth,
|
||||
if (task.imageHeight != null) 'imageHeight': task.imageHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/// 删除终端私有目录中的现场图;不存在或删除失败时只保留元数据,不泄露完整路径到日志。
|
||||
static Future<void> _deleteLocalFile(String? path) async {
|
||||
if (path == null || path.isEmpty) return;
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (_) {
|
||||
logger.w('|订单证据补传|现场临时图片删除失败|');
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断本地任务状态是否已经结束,不允许取消或订单绑定重新激活终态任务。
|
||||
static bool _isTerminal(OrderEvidenceTaskStatus? status) =>
|
||||
status == OrderEvidenceTaskStatus.success ||
|
||||
status == OrderEvidenceTaskStatus.permanentFailed ||
|
||||
status == OrderEvidenceTaskStatus.expired ||
|
||||
status == OrderEvidenceTaskStatus.cancelled;
|
||||
|
||||
/// 判断任务是否已经具备任一种服务端订单定位信息。
|
||||
static bool _hasOrderReference(OrderEvidenceUploadTaskEntity task) =>
|
||||
(task.androidOrderCode?.isNotEmpty ?? false) ||
|
||||
(task.serverOrderId?.isNotEmpty ?? false);
|
||||
|
||||
/// 判断任务是否来自预约核销的服务端订单预关联,用于在核销提交前保留可恢复重试。
|
||||
static bool _isPreparedReservationEvidence(
|
||||
OrderEvidenceUploadTaskEntity task,
|
||||
) =>
|
||||
OrderEvidenceTaskStatus.fromCode(task.status) ==
|
||||
OrderEvidenceTaskStatus.waitingOrder &&
|
||||
(task.serverOrderId?.isNotEmpty ?? false) &&
|
||||
!(task.androidOrderCode?.isNotEmpty ?? false);
|
||||
|
||||
/// 判断服务端拒绝是否属于哈希、格式或归属等确定不可恢复的错误。
|
||||
static bool _isPermanentError(Object error) {
|
||||
if (error is! DioException) return false;
|
||||
final message = '${error.error ?? ''} ${error.message ?? ''}';
|
||||
return const [
|
||||
'禁止覆盖',
|
||||
'哈希不一致',
|
||||
'不受信任',
|
||||
'地址不合法',
|
||||
'不属于当前设备',
|
||||
'用户不一致',
|
||||
'样本不一致',
|
||||
'图片大小与终端上报不一致',
|
||||
'图片宽度与终端上报不一致',
|
||||
'图片高度与终端上报不一致',
|
||||
].any(message.contains);
|
||||
}
|
||||
|
||||
/// 将异常映射为不包含 Token、URL、完整路径和图片内容的稳定诊断摘要。
|
||||
static String _safeFailureForError(Object error) {
|
||||
if (error is DioException) {
|
||||
return switch (error.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout =>
|
||||
'订单证据请求超时',
|
||||
DioExceptionType.connectionError => '订单证据服务网络不可达',
|
||||
DioExceptionType.badCertificate => '订单证据服务证书校验失败',
|
||||
DioExceptionType.cancel => '订单证据请求已取消',
|
||||
DioExceptionType.badResponse =>
|
||||
_isPermanentError(error) ? '服务端拒绝不可恢复的证据请求' : '订单证据服务暂未接受请求',
|
||||
DioExceptionType.unknown => '订单证据请求发生未知网络错误',
|
||||
};
|
||||
}
|
||||
return '订单证据处理异常(${error.runtimeType})';
|
||||
}
|
||||
|
||||
/// 把失败说明限制为单行 300 字符;空说明使用调用点给出的安全默认值。
|
||||
static String _safeFailure(String? source, {String fallback = '证据处理失败'}) {
|
||||
var normalized =
|
||||
(source == null || source.trim().isEmpty ? fallback : source)
|
||||
.replaceAll('\n', ' ')
|
||||
.replaceAll('\r', ' ')
|
||||
.trim();
|
||||
if (normalized.contains('/') || normalized.contains('\\')) {
|
||||
normalized = fallback;
|
||||
}
|
||||
return normalized.length <= 300
|
||||
? normalized
|
||||
: '${normalized.substring(0, 300)}...';
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条任务在本轮批处理中的结果分类,仅用于汇总计数。
|
||||
enum _TaskProcessResult {
|
||||
/// 当前订单尚未绑定或尚未确认支付,本轮没有写入证据。
|
||||
skipped,
|
||||
|
||||
/// 服务端已经确认图片可用或永久缺失上报成功。
|
||||
succeeded,
|
||||
|
||||
/// 本轮进入退避等待或确定失败。
|
||||
failed,
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 定时任务结构化存储入口,隔离调度器与 Floor DAO 的实体转换细节。
|
||||
abstract final class ScheduledTaskStorageTool {
|
||||
/// 最早版本商品同步任务的默认 Cron,仅用于识别尚未升级的关闭配置。
|
||||
static const String _legacyDisabledProductSyncCron = '0 25 0 * * *';
|
||||
|
||||
/// 上一版本商品同步任务的默认 Cron,仅用于把未自定义配置升级为每 3 小时。
|
||||
static const String _legacyFiveMinuteProductSyncCron = '0 */5 * * * *';
|
||||
|
||||
/// 当前应用数据库中的任务配置 DAO;数据库由 AppControl 在调度器启动前完成初始化。
|
||||
static ScheduledTaskDao get _taskDao =>
|
||||
Get.find<AppControl>().dataBase.getScheduledTaskDao;
|
||||
|
||||
/// 当前应用数据库中的任务执行日志 DAO。
|
||||
static ScheduledTaskLogDao get _logDao =>
|
||||
Get.find<AppControl>().dataBase.getScheduledTaskLogDao;
|
||||
|
||||
/// 首次升级时把旧 sysJson 导入表中,并补齐新增任务及升级仍沿用旧默认值的商品同步任务。
|
||||
///
|
||||
/// 商品同步只迁移完整匹配旧默认值的记录;用户调整过 Cron 或空闲策略时保留现场配置。
|
||||
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 writes = <ScheduledTaskEntity>[];
|
||||
for (final defaultConfig in defaults) {
|
||||
final current = existing[defaultConfig.type.name];
|
||||
if (current != null) {
|
||||
if (_usesLegacyProductSyncDefault(
|
||||
type: defaultConfig.type,
|
||||
enabled: current.enabled,
|
||||
cron: current.cronExpression,
|
||||
idleOnly: current.idleOnly,
|
||||
)) {
|
||||
writes.add(
|
||||
current.copyWith(
|
||||
enabled: defaultConfig.enabled,
|
||||
cronExpression: defaultConfig.cron,
|
||||
idleOnly: defaultConfig.idleOnly,
|
||||
updateTime: now,
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final legacyConfig = legacy[defaultConfig.type] ?? defaultConfig;
|
||||
final config = _usesLegacyProductSyncDefault(
|
||||
type: legacyConfig.type,
|
||||
enabled: legacyConfig.enabled,
|
||||
cron: legacyConfig.cron,
|
||||
idleOnly: legacyConfig.idleOnly,
|
||||
)
|
||||
? defaultConfig
|
||||
: legacyConfig;
|
||||
final record = legacyRecords[defaultConfig.type];
|
||||
writes.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 (writes.isNotEmpty) await _taskDao.batchSave(writes);
|
||||
}
|
||||
|
||||
/// 判断配置是否仍为旧版商品同步默认值,用于升级默认策略且避免覆盖其他任务或自定义规则。
|
||||
static bool _usesLegacyProductSyncDefault({
|
||||
required ScheduledTaskType type,
|
||||
required bool enabled,
|
||||
required String cron,
|
||||
required bool idleOnly,
|
||||
}) =>
|
||||
type == ScheduledTaskType.productSync &&
|
||||
idleOnly &&
|
||||
((!enabled && cron == _legacyDisabledProductSyncCron) ||
|
||||
(enabled && cron == _legacyFiveMinuteProductSyncCron));
|
||||
|
||||
/// 从 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
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;
|
||||
}
|
||||
@@ -1,132 +1,290 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 业务终端的 EY_PRINT 代理队列,负责双通道入队、版本幂等、串行 USB 重试和结果补报。
|
||||
abstract class SyncPrintTool {
|
||||
static const _tag = "sync打印工具";
|
||||
/// 打印队列诊断日志使用的稳定模块标签。
|
||||
static const _tag = 'sync打印工具';
|
||||
|
||||
/// 是否已有串行打印循环正在执行,防止两个任务同时占用 USB 通道。
|
||||
static bool _isPrint = false;
|
||||
|
||||
/// 是否已有打印结果补报正在执行,防止网络恢复和定时任务重复提交。
|
||||
static bool _isSync = false;
|
||||
|
||||
/// 是否已有 HTTP 待办补拉正在执行,防止 MQTT 重连和页面恢复重复拉取。
|
||||
static bool _isPulling = false;
|
||||
|
||||
/// 将 MQTT 与 HTTP 的并发入队串行化,避免唯一索引插入竞争。
|
||||
static Future<void> _enqueueTail = Future<void>.value();
|
||||
|
||||
/// 三次 USB 尝试前的等待时间:首次立即执行,后两次失败后分别等待 5 秒和 30 秒。
|
||||
///
|
||||
/// 方案同时限定“最多三次 USB 尝试”,因此不会再安排会形成第 4 次出纸的 120 秒重试。
|
||||
static const attemptDelays = <Duration>[
|
||||
Duration.zero,
|
||||
Duration(seconds: 5),
|
||||
Duration(seconds: 30),
|
||||
];
|
||||
|
||||
/// 将 MQTT 或 HTTP 待办按服务端任务 ID 转换为唯一的本地任务。
|
||||
///
|
||||
/// [orderId] 是服务端订单 ID,[androidOrderId] 允许线上预约订单为空;[historyId]
|
||||
/// 是本地唯一去重键,[retryVersion] 只有高于本地失败版本时才允许再次进入打印队列。
|
||||
static Future<void> addTask({
|
||||
required String orderId,
|
||||
required String androidOrderId,
|
||||
String? androidOrderId,
|
||||
required String historyId,
|
||||
required String pic,
|
||||
String printType = '1',
|
||||
int printNo = 1,
|
||||
int retryVersion = 0,
|
||||
}) async {
|
||||
final appC = Get.find<AppControl>();
|
||||
final printHistoryDao = appC.dataBase.getPrintHistoryDao;
|
||||
final saveObj = PrintHistoryEntity(
|
||||
orderId: androidOrderId,
|
||||
serviceOrderId: orderId,
|
||||
servicePrintId: historyId,
|
||||
invoiceUrl: pic,
|
||||
status: PrintStatus.NONE,
|
||||
createTime: DateTime.now(),
|
||||
final operation = _enqueueTail.then((_) => _addTask(
|
||||
orderId: orderId,
|
||||
androidOrderId: androidOrderId,
|
||||
historyId: historyId,
|
||||
pic: pic,
|
||||
printType: printType,
|
||||
printNo: printNo,
|
||||
retryVersion: retryVersion,
|
||||
));
|
||||
// 队尾吞掉上一项异常以保证后续任务仍可入队,当前调用者仍等待原 operation 获得真实结果。
|
||||
_enqueueTail = operation.then<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace __) {},
|
||||
);
|
||||
await printHistoryDao.save(saveObj);
|
||||
logger.i("|$_tag|添加到打印记录到本地数据库,json:|${saveObj.toJson()}|");
|
||||
await SyncPrintTool.doCheckPrint();
|
||||
await operation;
|
||||
}
|
||||
|
||||
// 检查打印
|
||||
static Future<void> doCheckPrint() async {
|
||||
if (_isPrint) {
|
||||
logger.i("|$_tag|检查打印,正在打印中");
|
||||
/// 在串行入队区内执行任务新增或失败版本更新。
|
||||
static Future<void> _addTask({
|
||||
required String orderId,
|
||||
String? androidOrderId,
|
||||
required String historyId,
|
||||
required String pic,
|
||||
required String printType,
|
||||
required int printNo,
|
||||
required int retryVersion,
|
||||
}) async {
|
||||
final dao = Get.find<AppControl>().dataBase.getPrintHistoryDao;
|
||||
final existing = await dao.findByServicePrintId(historyId);
|
||||
if (existing != null) {
|
||||
if (!canApplyTaskVersion(existing, retryVersion)) {
|
||||
if (retryVersion > existing.retryVersion &&
|
||||
existing.status != PrintStatus.ERROR) {
|
||||
logger.w(
|
||||
'|$_tag|忽略非失败任务的超前版本|task:$historyId|status:${existing.status.name}|');
|
||||
return;
|
||||
}
|
||||
logger.i('|$_tag|忽略重复打印任务|task:$historyId|version:$retryVersion|');
|
||||
return;
|
||||
}
|
||||
existing
|
||||
..orderId = androidOrderId?.trim().isNotEmpty == true
|
||||
? androidOrderId!
|
||||
: orderId
|
||||
..serviceOrderId = orderId
|
||||
..invoiceUrl = pic
|
||||
..printType = printType
|
||||
..printNo = printNo
|
||||
..retryVersion = retryVersion
|
||||
..attemptCount = 0
|
||||
..lastError = null
|
||||
..resultPending = false
|
||||
..status = PrintStatus.NONE
|
||||
..printTime = null
|
||||
..syncTime = null;
|
||||
await dao.edit(existing);
|
||||
} else {
|
||||
await dao.save(PrintHistoryEntity(
|
||||
orderId: androidOrderId?.trim().isNotEmpty == true
|
||||
? androidOrderId!
|
||||
: orderId,
|
||||
serviceOrderId: orderId,
|
||||
servicePrintId: historyId,
|
||||
invoiceUrl: pic,
|
||||
printType: printType,
|
||||
printNo: printNo,
|
||||
retryVersion: retryVersion,
|
||||
status: PrintStatus.NONE,
|
||||
createTime: DateTime.now(),
|
||||
));
|
||||
}
|
||||
logger.i('|$_tag|打印任务已入队|task:$historyId|version:$retryVersion|');
|
||||
unawaited(doCheckPrint());
|
||||
}
|
||||
|
||||
/// 只有本地任务已失败且服务端版本更高时,才允许同一任务重新出纸。
|
||||
static bool canApplyTaskVersion(
|
||||
PrintHistoryEntity existing, int incomingVersion) =>
|
||||
existing.status == PrintStatus.ERROR &&
|
||||
incomingVersion > existing.retryVersion;
|
||||
|
||||
/// 在登录、MQTT 重连、应用恢复和周期检查时补拉当前业务终端代理的待办。
|
||||
static Future<void> pullPendingTasks() async {
|
||||
if (_isPulling || !AppNetworkTool.isAutoNetwork()) {
|
||||
return;
|
||||
}
|
||||
final appC = Get.find<AppControl>();
|
||||
final printHistoryDao = appC.dataBase.getPrintHistoryDao;
|
||||
final printFacility = appC.printManage;
|
||||
var setting = await AppSettingTool.getSysSetting();
|
||||
if (setting.serviceId == null) return;
|
||||
_isPulling = true;
|
||||
try {
|
||||
// 检查有没有需要打印的
|
||||
final list = await printHistoryDao.getAll(PrintStatus.NONE.name);
|
||||
if (ObjectUtil.isEmptyList(list)) {
|
||||
_isPrint = false;
|
||||
logger.i("|$_tag|检查打印,没有需要打印的发票");
|
||||
return;
|
||||
setting = await AppSettingTool.updatePrinterBinding(
|
||||
await HardApi.getPrinterBinding(),
|
||||
);
|
||||
if (setting.printerHardId == null) return;
|
||||
final tasks = await HardApi.getPrinterTasks();
|
||||
for (final task in tasks) {
|
||||
if (task.historyId == null ||
|
||||
task.orderId == null ||
|
||||
task.pic == null) {
|
||||
continue;
|
||||
}
|
||||
await addTask(
|
||||
orderId: task.orderId!,
|
||||
androidOrderId: task.androidOrderCode,
|
||||
historyId: task.historyId!,
|
||||
pic: task.pic!,
|
||||
printType: task.printType ?? '1',
|
||||
printNo: task.printNo ?? 1,
|
||||
retryVersion: task.retryVersion ?? 0,
|
||||
);
|
||||
}
|
||||
final isOpen = await printFacility.open();
|
||||
if (!isOpen) {
|
||||
_isPrint = false;
|
||||
logger.e("|$_tag|检查打印,打开打印机失败");
|
||||
return;
|
||||
} catch (error, stack) {
|
||||
logger.w('|$_tag|补拉打印待办失败|error:$error|stack:$stack|');
|
||||
} finally {
|
||||
_isPulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 串行处理全部待打印任务,同一时间只允许一个 USB 写入流程。
|
||||
static Future<void> doCheckPrint() async {
|
||||
if (_isPrint) {
|
||||
return;
|
||||
}
|
||||
_isPrint = true;
|
||||
final dao = Get.find<AppControl>().dataBase.getPrintHistoryDao;
|
||||
try {
|
||||
// 循环查库可接住打印期间新入队的 MQTT/HTTP 任务,不必等待下一次十分钟补偿扫描。
|
||||
while (true) {
|
||||
final list = await dao.getAll(PrintStatus.NONE.name);
|
||||
if (list.isEmpty) {
|
||||
break;
|
||||
}
|
||||
for (final task in list) {
|
||||
await _print(task);
|
||||
}
|
||||
}
|
||||
for (final v in list) {
|
||||
await doPrint(v);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.e("|$_tag|检查打印,打印错误,error:|$e|");
|
||||
} catch (error, stack) {
|
||||
logger.e('|$_tag|打印队列执行异常|error:$error|stack:$stack|');
|
||||
} finally {
|
||||
_isPrint = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查同步
|
||||
/// 补报本地已经完成的成功或失败结果,断网不会导致再次出纸。
|
||||
static Future<void> doCheckSync() async {
|
||||
if (_isSync) {
|
||||
logger.i("|$_tag|检查打印,正在打印中");
|
||||
if (_isSync || !AppNetworkTool.isAutoNetwork()) {
|
||||
return;
|
||||
}
|
||||
final app = Get.find<AppControl>();
|
||||
final printHistoryDao = app.dataBase.getPrintHistoryDao;
|
||||
final list = await printHistoryDao.getAll(PrintStatus.SUCCESS.name);
|
||||
if (ObjectUtil.isEmptyList(list)) {
|
||||
_isSync = false;
|
||||
return;
|
||||
}
|
||||
for (final v in list) {
|
||||
final printPa = {
|
||||
"orderId": v.serviceOrderId,
|
||||
"historyId": v.servicePrintId,
|
||||
};
|
||||
try {
|
||||
final flag = await HardApi.printInvoice(printPa);
|
||||
if (!flag) Exception("|$_tag|同步失败api错误params|${v.toJson()}|");
|
||||
v.status = PrintStatus.SYNC;
|
||||
v.syncTime = DateTime.now();
|
||||
await printHistoryDao.edit(v);
|
||||
} catch (e) {
|
||||
logger.e("|$_tag|检查同步,打印错误,error:|$e|");
|
||||
v.status = PrintStatus.ERROR;
|
||||
await printHistoryDao.edit(v);
|
||||
} finally {
|
||||
_isSync = false;
|
||||
_isSync = true;
|
||||
final dao = Get.find<AppControl>().dataBase.getPrintHistoryDao;
|
||||
try {
|
||||
for (final task in await dao.getPendingResults()) {
|
||||
await _report(task);
|
||||
}
|
||||
} finally {
|
||||
_isSync = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行打印
|
||||
static Future<void> doPrint(PrintHistoryEntity v) async {
|
||||
/// 串行执行单个任务的最多三次 USB 尝试,并在最终状态落库后补报服务端。
|
||||
static Future<void> _print(PrintHistoryEntity task) async {
|
||||
final app = Get.find<AppControl>();
|
||||
final printHistoryDao = app.dataBase.getPrintHistoryDao;
|
||||
final printFacility = app.printManage;
|
||||
try {
|
||||
final isPrint = await printFacility.printImg(v.invoiceUrl);
|
||||
if (!isPrint) {
|
||||
v.status = PrintStatus.ERROR;
|
||||
await printHistoryDao.edit(v);
|
||||
throw Exception("|$_tag|打印失败params|${v.toJson()}|");
|
||||
final dao = app.dataBase.getPrintHistoryDao;
|
||||
for (var index = 0; index < attemptDelays.length; index++) {
|
||||
final delay = attemptDelays[index];
|
||||
if (delay > Duration.zero) {
|
||||
await Future<void>.delayed(delay);
|
||||
}
|
||||
// 更新本地数据库
|
||||
v.status = PrintStatus.SUCCESS;
|
||||
v.printTime = DateUtil.formatDate(DateTime.now());
|
||||
await printHistoryDao.edit(v);
|
||||
// 同步到服务端
|
||||
final printPa = {
|
||||
"orderId": v.serviceOrderId,
|
||||
"historyId": v.servicePrintId,
|
||||
};
|
||||
await HardApi.printInvoice(printPa);
|
||||
v.status = PrintStatus.SYNC;
|
||||
v.syncTime = DateTime.now();
|
||||
await printHistoryDao.edit(v);
|
||||
} catch (e) {
|
||||
logger.i("|$_tag|执行打印,打印错误,error:|$e|");
|
||||
task.attemptCount = index + 1;
|
||||
await dao.edit(task);
|
||||
var opened = false;
|
||||
try {
|
||||
opened = await app.printManage.open();
|
||||
if (!opened) {
|
||||
throw StateError(app.printManage.lastError ?? 'USB 打印机连接失败');
|
||||
}
|
||||
final accepted = await app.printManage.printImg(task.invoiceUrl);
|
||||
if (!accepted) {
|
||||
throw StateError(app.printManage.lastError ?? 'USB 未完整接收打印数据');
|
||||
}
|
||||
task
|
||||
..status = PrintStatus.SUCCESS
|
||||
..printTime = DateUtil.formatDate(DateTime.now())
|
||||
..lastError = null
|
||||
..resultPending = true;
|
||||
await dao.edit(task);
|
||||
await _report(task);
|
||||
return;
|
||||
} catch (error, stack) {
|
||||
task.lastError = _errorText(error);
|
||||
await dao.edit(task);
|
||||
logger.w(
|
||||
'|$_tag|USB 打印尝试失败|task:${task.servicePrintId}|attempt:${task.attemptCount}|error:$error|stack:$stack|');
|
||||
} finally {
|
||||
if (opened) {
|
||||
await app.printManage.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
task
|
||||
..status = PrintStatus.ERROR
|
||||
..resultPending = true;
|
||||
await dao.edit(task);
|
||||
await _report(task);
|
||||
}
|
||||
|
||||
/// 向服务端提交当前重试版本的最终结果;失败时保留 resultPending 等待网络恢复补报。
|
||||
static Future<void> _report(PrintHistoryEntity task) async {
|
||||
if (!AppNetworkTool.isAutoNetwork()) {
|
||||
return;
|
||||
}
|
||||
final success =
|
||||
task.status == PrintStatus.SUCCESS || task.status == PrintStatus.SYNC;
|
||||
try {
|
||||
final accepted = await HardApi.reportPrinterResult(
|
||||
taskId: task.servicePrintId,
|
||||
orderId: task.serviceOrderId,
|
||||
retryVersion: task.retryVersion,
|
||||
success: success,
|
||||
attemptCount: task.attemptCount,
|
||||
errorMessage: success ? null : task.lastError,
|
||||
);
|
||||
if (!accepted) {
|
||||
throw StateError('服务端拒绝打印结果');
|
||||
}
|
||||
task
|
||||
..resultPending = false
|
||||
..syncTime = DateTime.now();
|
||||
if (success) {
|
||||
task.status = PrintStatus.SYNC;
|
||||
}
|
||||
await Get.find<AppControl>().dataBase.getPrintHistoryDao.edit(task);
|
||||
} catch (error, stack) {
|
||||
logger.w(
|
||||
'|$_tag|打印结果等待补报|task:${task.servicePrintId}|error:$error|stack:$stack|');
|
||||
}
|
||||
}
|
||||
|
||||
/// 将本地异常压缩成最多 500 个字符的单行可上报文本,避免持久化堆栈或敏感上下文。
|
||||
static String _errorText(Object error) {
|
||||
final text = error.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return text.length <= 500 ? text : text.substring(0, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 自动更新检查的业务结果,未执行安装的正常条件以跳过状态返回。
|
||||
class TerminalAutoUpdateResult {
|
||||
/// 创建一次自动更新检查结果。
|
||||
const TerminalAutoUpdateResult({
|
||||
required this.skipped,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
/// 是否因无更新、未绑定或平台/网络条件不满足而安全跳过。
|
||||
final bool skipped;
|
||||
|
||||
/// 写入任务最近结果和日志的中文摘要。
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// 自动更新候选版本评估结果,区分可安装目标和不满足发布策略的安全跳过原因。
|
||||
class TerminalAutoUpdateDecision {
|
||||
/// 创建一份包含最高优先级强制更新版本的评估结果。
|
||||
const TerminalAutoUpdateDecision.install(ServiceAppVersion this.target)
|
||||
: skippedMessage = '';
|
||||
|
||||
/// 创建一份不执行安装并保留明确业务原因的评估结果。
|
||||
const TerminalAutoUpdateDecision.skip(this.skippedMessage) : target = null;
|
||||
|
||||
/// 同平台、已发布、强制更新且版本码高于本机的最高版本;不满足时为空。
|
||||
final ServiceAppVersion? target;
|
||||
|
||||
/// 没有安装目标时写入任务记录的原因;存在目标时为空字符串。
|
||||
final String skippedMessage;
|
||||
|
||||
/// 是否已经选出允许自动安装的目标版本。
|
||||
bool get hasTarget => target != null;
|
||||
}
|
||||
|
||||
/// Android Root APK 全自动更新工具。
|
||||
abstract final class TerminalAutoUpdateTool {
|
||||
/// 自动更新诊断日志使用的稳定模块标签。
|
||||
static const String _tag = '终端自动更新';
|
||||
|
||||
/// 应用私有支持目录下的固定子目录,更新确认或失败后删除 APK 文件。
|
||||
static const String _downloadDirectory = 'apk_updates';
|
||||
|
||||
/// 查询并安装最高的已发布新版本;安全前置条件不满足时不产生任何系统变更。
|
||||
static Future<TerminalAutoUpdateResult> run() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return const TerminalAutoUpdateResult(
|
||||
skipped: true,
|
||||
message: '非 Android 平台,本次跳过',
|
||||
);
|
||||
}
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
if (setting.serviceId == null || setting.serviceId!.isEmpty) {
|
||||
return const TerminalAutoUpdateResult(
|
||||
skipped: true,
|
||||
message: '终端尚未绑定,本次跳过',
|
||||
);
|
||||
}
|
||||
if (!AppNetworkTool.isAutoNetwork()) {
|
||||
return const TerminalAutoUpdateResult(
|
||||
skipped: true,
|
||||
message: '网络不可用,本次跳过',
|
||||
);
|
||||
}
|
||||
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
final currentVersionCode = int.tryParse(package.buildNumber) ?? 0;
|
||||
final platform = AppPlatformEnum.terminalCode(setting.sysType);
|
||||
final page = await AppApi.openCardList(
|
||||
{
|
||||
'appUniqueCode': Constants.deviceUniqueCode,
|
||||
'platform': platform,
|
||||
'current': 1,
|
||||
'size': 8,
|
||||
},
|
||||
silent: true,
|
||||
);
|
||||
final decision = evaluateForcedUpdate(
|
||||
versions: page?.records ?? const <ServiceAppVersion>[],
|
||||
currentVersionCode: currentVersionCode,
|
||||
appUniqueCode: Constants.deviceUniqueCode,
|
||||
platform: platform,
|
||||
);
|
||||
if (!decision.hasTarget) {
|
||||
return TerminalAutoUpdateResult(
|
||||
skipped: true,
|
||||
message: decision.skippedMessage,
|
||||
);
|
||||
}
|
||||
final target = decision.target!;
|
||||
final targetVersionCode = target.versionCode!;
|
||||
final downloadUrl = _resolveDownloadUrl(target.downloadUrl);
|
||||
if (downloadUrl == null) {
|
||||
throw StateError('最新版本缺少 APK 下载地址');
|
||||
}
|
||||
|
||||
// 只有已经选出符合发布策略的新版本才检查 Root,避免无更新时产生无意义的授权动作。
|
||||
final root = await AppRuntimeTool.checkRootAccess();
|
||||
await AppSettingTool.setRootAccessResult(root);
|
||||
if (!root.available) {
|
||||
throw ScheduledTaskDependencyException('Root 不可用:${root.diagnostic}');
|
||||
}
|
||||
|
||||
final task = DownloadTask(
|
||||
url: downloadUrl,
|
||||
filename: 'pj_xfj_build_$targetVersionCode.apk',
|
||||
directory: _downloadDirectory,
|
||||
baseDirectory: BaseDirectory.applicationSupport,
|
||||
updates: Updates.statusAndProgress,
|
||||
retries: 2,
|
||||
);
|
||||
final apkPath = await task.filePath();
|
||||
final apkFile = File(apkPath);
|
||||
if (await apkFile.exists()) await apkFile.delete();
|
||||
try {
|
||||
// 进度回调频率由下载插件控制,按 10% 节点限流后写日志,避免大 APK 刷屏。
|
||||
var nextProgressToLog = 0;
|
||||
final download = await FileDownloader().download(
|
||||
task,
|
||||
onProgress: (progress) {
|
||||
if (!progress.isFinite || progress < 0 || progress > 1) return;
|
||||
final percentage = (progress * 100).floor().clamp(0, 100).toInt();
|
||||
while (nextProgressToLog <= percentage && nextProgressToLog <= 100) {
|
||||
logger.i(
|
||||
'|$_tag|APK下载进度target:$targetVersionCode|progress:$nextProgressToLog%|',
|
||||
);
|
||||
nextProgressToLog += 10;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (download.status != TaskStatus.complete || !await apkFile.exists()) {
|
||||
throw StateError('APK 下载失败(${download.status.name})');
|
||||
}
|
||||
final validation = await AppRuntimeTool.validateApk(
|
||||
path: apkPath,
|
||||
expectedVersionCode: targetVersionCode,
|
||||
);
|
||||
if (!validation.success) {
|
||||
throw StateError('${validation.code}:${validation.message}');
|
||||
}
|
||||
|
||||
// 必须先让服务端保留计划更新原因;请求失败时不进入可能导致进程退出的安装步骤。
|
||||
await TerminalHeartbeatTool.report(
|
||||
scene: TerminalHeartbeatScene.autoUpdate,
|
||||
throwOnError: true,
|
||||
);
|
||||
final install = await AppRuntimeTool.installApkWithRoot(
|
||||
path: apkPath,
|
||||
expectedVersionCode: targetVersionCode,
|
||||
);
|
||||
if (!install.success) {
|
||||
throw StateError('${install.code}:${install.message}');
|
||||
}
|
||||
return TerminalAutoUpdateResult(
|
||||
skipped: false,
|
||||
message: 'Build $targetVersionCode 安装命令已提交,等待新版本启动确认',
|
||||
);
|
||||
} catch (error, stack) {
|
||||
if (await apkFile.exists()) await apkFile.delete();
|
||||
logger
|
||||
.e('|$_tag|更新失败target:$targetVersionCode|error:$error|stack:$stack|');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// 按自动任务协议选择版本码最高的强制更新版本。
|
||||
///
|
||||
/// 版本码只负责判断升级顺序;`status=1` 和 `isForce=1` 分别控制是否已经发布、是否允许
|
||||
/// 无人工确认自动安装。存在更高版本但策略不满足时返回具体原因,不能误报“已是最新”。
|
||||
static TerminalAutoUpdateDecision evaluateForcedUpdate({
|
||||
required Iterable<ServiceAppVersion> versions,
|
||||
required int currentVersionCode,
|
||||
required String appUniqueCode,
|
||||
required String platform,
|
||||
}) {
|
||||
final higherVersions = versions
|
||||
.where(
|
||||
(item) =>
|
||||
item.appUniqueCode == appUniqueCode &&
|
||||
item.platform == platform &&
|
||||
(item.versionCode ?? 0) > currentVersionCode,
|
||||
)
|
||||
.toList()
|
||||
..sort(_compareVersionCodeDescending);
|
||||
if (higherVersions.isEmpty) {
|
||||
return const TerminalAutoUpdateDecision.skip('当前已是最新版本');
|
||||
}
|
||||
|
||||
final publishedVersions = higherVersions
|
||||
.where(
|
||||
(item) => item.status == AppVersionStatusEnum.published.code,
|
||||
)
|
||||
.toList();
|
||||
if (publishedVersions.isEmpty) {
|
||||
return TerminalAutoUpdateDecision.skip(
|
||||
'发现更高版本 ${_versionLabel(higherVersions.first)},但未处于已发布状态,本次跳过',
|
||||
);
|
||||
}
|
||||
|
||||
final forcedVersions = publishedVersions
|
||||
.where((item) => item.isForce == true)
|
||||
.toList()
|
||||
..sort(_compareVersionCodeDescending);
|
||||
if (forcedVersions.isEmpty) {
|
||||
return TerminalAutoUpdateDecision.skip(
|
||||
'发现已发布的新版本 ${_versionLabel(publishedVersions.first)},但未标记为强制更新,本次跳过',
|
||||
);
|
||||
}
|
||||
return TerminalAutoUpdateDecision.install(forcedVersions.first);
|
||||
}
|
||||
|
||||
/// 按内部版本码倒序排列候选项,缺失版本码的异常数据按 0 处理。
|
||||
static int _compareVersionCodeDescending(
|
||||
ServiceAppVersion left,
|
||||
ServiceAppVersion right,
|
||||
) =>
|
||||
(right.versionCode ?? 0).compareTo(left.versionCode ?? 0);
|
||||
|
||||
/// 组合任务日志使用的展示版本和内部 Build,缺失展示版本时仍保留可诊断的版本码。
|
||||
static String _versionLabel(ServiceAppVersion version) {
|
||||
final versionName = version.versionName?.trim();
|
||||
final build = version.versionCode ?? 0;
|
||||
return versionName == null || versionName.isEmpty
|
||||
? 'Build $build'
|
||||
: '$versionName(Build $build)';
|
||||
}
|
||||
|
||||
/// 启动时确认原生待更新记录,完成任务快照及被包替换中断的 running 日志。
|
||||
static Future<void> recoverPendingUpdate() async {
|
||||
final recovery = await AppRuntimeTool.consumePendingUpdate();
|
||||
if (!recovery.pending) return;
|
||||
final type = ScheduledTaskType.autoUpdate;
|
||||
final now = DateTime.now();
|
||||
final record = ScheduledTaskRecord(
|
||||
lastRunAt: SyncCheckTool.instance.records[type]?.lastRunAt ?? now,
|
||||
lastSuccessAt: recovery.success
|
||||
? now
|
||||
: SyncCheckTool.instance.records[type]?.lastSuccessAt,
|
||||
success: recovery.success,
|
||||
message: recovery.message,
|
||||
);
|
||||
SyncCheckTool.instance.records[type] = record;
|
||||
await ScheduledTaskStorageTool.saveRecord(
|
||||
type,
|
||||
record,
|
||||
recovery.success
|
||||
? ScheduledTaskRunStatus.success
|
||||
: ScheduledTaskRunStatus.failed,
|
||||
);
|
||||
final latest = await ScheduledTaskStorageTool.getLatestLog(type);
|
||||
if (latest?.status == ScheduledTaskRunStatus.running.name) {
|
||||
await ScheduledTaskStorageTool.finishLog(
|
||||
latest!.finish(
|
||||
nextStatus: recovery.success
|
||||
? ScheduledTaskRunStatus.success.name
|
||||
: ScheduledTaskRunStatus.failed.name,
|
||||
nextMessage: recovery.message,
|
||||
at: now,
|
||||
nextErrorType: recovery.success ? null : 'UpdateRecoveryFailed',
|
||||
nextErrorMessage: recovery.success ? null : recovery.message,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (recovery.success) await AppRuntimeTool.refreshKioskStatus();
|
||||
}
|
||||
|
||||
/// 将后台文件键转换为实际下载地址,绝对 HTTP 地址保持不变。
|
||||
static String? _resolveDownloadUrl(String? source) {
|
||||
final value = source?.trim();
|
||||
if (value == null || value.isEmpty) return null;
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||
return value;
|
||||
}
|
||||
return '${Constants.ossUrl}/api/admin/sys-file/oss/file?fileName=$value';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 终端心跳场景编码,与后端 TerminalHeartbeatSceneEnum 名称保持一致。
|
||||
enum TerminalHeartbeatScene {
|
||||
/// 普通在线心跳,服务端收到后清除计划更新断开原因。
|
||||
online('ONLINE'),
|
||||
|
||||
/// 自动更新安装前心跳,服务端在重启期间保留“自动更新重启”原因。
|
||||
autoUpdate('AUTO_UPDATE');
|
||||
|
||||
/// 写入心跳接口的固定协议编码。
|
||||
final String code;
|
||||
|
||||
/// 创建一项跨端固定心跳场景。
|
||||
const TerminalHeartbeatScene(this.code);
|
||||
}
|
||||
|
||||
/// 自动更新前心跳失败的可展示异常,隐藏 Dio 协议细节并保留明确恢复动作。
|
||||
class TerminalHeartbeatException implements Exception {
|
||||
/// 使用已经脱敏和限长的终端操作提示创建异常。
|
||||
const TerminalHeartbeatException(this.message);
|
||||
|
||||
/// 可写入任务记录并直接展示给终端操作人员的短中文说明。
|
||||
final String message;
|
||||
|
||||
/// 返回不含请求地址、堆栈和 Dio 实现细节的安全摘要。
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// 食堂、超市 Android 终端心跳管理器。
|
||||
abstract final class TerminalHeartbeatTool {
|
||||
/// 心跳诊断日志使用的稳定模块标签。
|
||||
static const String _tag = '终端心跳';
|
||||
|
||||
/// 服务端业务提示的最大展示长度,避免异常响应再次撑满 Toast 和任务列表。
|
||||
static const int _maxServiceMessageLength = 120;
|
||||
|
||||
/// 终端在线阈值为 180 秒,客户端每 60 秒上报一次以容忍两次短暂失败。
|
||||
static const Duration _interval = Duration(seconds: 60);
|
||||
|
||||
/// 当前进程唯一心跳计时器,应用控制器关闭时释放。
|
||||
static Timer? _timer;
|
||||
|
||||
/// 启动独立心跳并立即上报一次;重复调用只会重建同一个计时器。
|
||||
static void start() {
|
||||
_timer?.cancel();
|
||||
unawaited(report());
|
||||
_timer = Timer.periodic(_interval, (_) => unawaited(report()));
|
||||
}
|
||||
|
||||
/// 停止进程内心跳计时器。
|
||||
static void stop() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
///
|
||||
/// 上报当前 APK、运行环境、Kiosk、Root 与外围硬件最新快照。
|
||||
///
|
||||
/// [throwOnError] 只供自动更新前的强一致场景使用;普通后台心跳失败仅写日志,
|
||||
/// 不弹 Toast,也不影响收银流程。
|
||||
static Future<bool> report({
|
||||
TerminalHeartbeatScene scene = TerminalHeartbeatScene.online,
|
||||
bool throwOnError = false,
|
||||
}) async {
|
||||
try {
|
||||
if (!AppNetworkTool.isAutoNetwork()) return false;
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
final hardId = setting.serviceId;
|
||||
if (hardId == null || hardId.isEmpty) return false;
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
final network = await AppRuntimeTool.getNetworkSnapshot();
|
||||
final performance = await AppRuntimeTool.getPerformanceSnapshot();
|
||||
final kiosk = await AppRuntimeTool.refreshKioskStatus();
|
||||
final devices = AppDeviceStatusTool.status.value;
|
||||
final versionCode = int.tryParse(package.buildNumber);
|
||||
await HardApi.terminalHeartbeat(
|
||||
hardId: hardId,
|
||||
data: {
|
||||
'apkVersionName': package.version,
|
||||
'apkVersionCode': versionCode,
|
||||
'scene': scene.code,
|
||||
'systemUptimeSeconds': performance.systemUptimeSeconds,
|
||||
'appUptimeSeconds': performance.appUptimeSeconds,
|
||||
'networkType': network.type,
|
||||
'availableStorageBytes': performance.storageAvailableBytes,
|
||||
'rootStatus': setting.rootAccessStatus.name,
|
||||
'deviceOwner': kiosk.deviceOwner,
|
||||
'kioskActive': kiosk.active,
|
||||
'deviceStatus': {
|
||||
'printer': devices.printerAvailable,
|
||||
'steelyard': devices.steelyardAvailable,
|
||||
'cardReader': devices.cardReaderAvailable,
|
||||
'subScreen': devices.subScreenAvailable,
|
||||
},
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error, stack) {
|
||||
logger.w('|$_tag|上报失败scene:${scene.code}|error:$error|stack:$stack|');
|
||||
if (throwOnError) {
|
||||
throw TerminalHeartbeatException(_toUserMessage(error));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 将心跳异常转换为包含原因和恢复动作的短提示,原始异常仅保留在诊断日志中。
|
||||
static String _toUserMessage(Object error) {
|
||||
final serviceMessage = _extractServiceMessage(error);
|
||||
if (serviceMessage != null &&
|
||||
(serviceMessage.contains('设备不存在') ||
|
||||
serviceMessage.contains('不属于当前租户'))) {
|
||||
return '更新状态上报失败:设备不存在或不属于当前租户,请在“设置 > 基本信息”重新绑定设备';
|
||||
}
|
||||
if (serviceMessage != null) {
|
||||
return '更新状态上报失败:$serviceMessage';
|
||||
}
|
||||
if (error is DioException) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return '更新状态上报超时,请检查网络后重试';
|
||||
case DioExceptionType.connectionError:
|
||||
case DioExceptionType.badCertificate:
|
||||
return '更新状态上报失败:无法连接服务器,请检查网络后重试';
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
return statusCode == null
|
||||
? '更新状态上报失败:服务端返回异常,请稍后重试'
|
||||
: '更新状态上报失败:服务端返回 HTTP $statusCode,请稍后重试';
|
||||
case DioExceptionType.cancel:
|
||||
return '更新状态上报已取消,请重新执行任务';
|
||||
case DioExceptionType.unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return '更新状态上报失败,请稍后重试';
|
||||
}
|
||||
|
||||
/// 从统一响应或静默请求拒绝原因中提取服务端业务提示,并压缩空白与限制长度。
|
||||
static String? _extractServiceMessage(Object error) {
|
||||
if (error is! DioException) return null;
|
||||
final data = error.response?.data;
|
||||
Object? rawMessage;
|
||||
if (data is Map) rawMessage = data['msg'];
|
||||
if (rawMessage == null &&
|
||||
error.type == DioExceptionType.badResponse &&
|
||||
error.error is String) {
|
||||
rawMessage = error.error;
|
||||
}
|
||||
final message = rawMessage?.toString().trim().replaceAll(
|
||||
RegExp(r'\s+'),
|
||||
' ',
|
||||
);
|
||||
if (message == null || message.isEmpty) return null;
|
||||
return message.length <= _maxServiceMessageLength
|
||||
? message
|
||||
: '${message.substring(0, _maxServiceMessageLength)}...';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
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();
|
||||
String? serverFileId;
|
||||
if (sanitizedSize == 0) {
|
||||
// 空日志没有可供后台查看的内容,本机仍保存成功快照,避免定时任务反复处理同一空文件。
|
||||
skipped++;
|
||||
logger.i('|终端日志上传|空日志无需上传path:${item.relativePath}|');
|
||||
} else {
|
||||
final needUpload = await _preflight(
|
||||
hardId: hardId,
|
||||
relativePath: item.relativePath,
|
||||
fileSize: sanitizedSize,
|
||||
fileHash: sanitizedHash,
|
||||
);
|
||||
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