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,407 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 单项终端设备探测函数,返回是否发现可用设备。
|
||||
typedef TerminalDeviceProbe = Future<bool> Function();
|
||||
|
||||
/// 汇总终端硬件状态,并将最近一次检查结果缓存到独立文件中。
|
||||
abstract final class AppDeviceStatusTool {
|
||||
static const String _tag = '终端设备状态';
|
||||
static const String _statusKey = 'terminal_device_status';
|
||||
|
||||
static final CacheManager _statusCache = CacheManager(
|
||||
Config(
|
||||
'terminal_device_status_cache',
|
||||
stalePeriod: const Duration(days: 30),
|
||||
maxNrOfCacheObjects: 2,
|
||||
),
|
||||
);
|
||||
|
||||
static final Rx<TerminalDeviceStatusSnapshot> status =
|
||||
const TerminalDeviceStatusSnapshot.empty().obs;
|
||||
static final Rx<TerminalDeviceCheckProgress> progress =
|
||||
const TerminalDeviceCheckProgress().obs;
|
||||
|
||||
/// 打印机当前是否正在处理小票数据;该瞬时状态不写入缓存。
|
||||
static final RxBool printerWorking = false.obs;
|
||||
|
||||
/// 电子秤当前是否被业务称重流程占用;后台可用性探测不计入工作中。
|
||||
static final RxBool steelyardWorking = false.obs;
|
||||
|
||||
/// 读卡器当前是否正在执行读卡、写卡或诊断扫描;该瞬时状态不写入缓存。
|
||||
static final RxBool cardReaderWorking = false.obs;
|
||||
|
||||
/// 顾客副屏 Presentation 当前是否已请求并保持显示;该瞬时状态不写入缓存。
|
||||
static final RxBool subScreenWorking = false.obs;
|
||||
|
||||
static Future<TerminalDeviceStatusSnapshot>? _refreshFuture;
|
||||
static Future<void> _persistQueue = Future<void>.value();
|
||||
static int _refreshGeneration = 0;
|
||||
|
||||
static bool get isRefreshing => _refreshFuture != null;
|
||||
|
||||
/// 启动时优先恢复缓存,硬件探测随后异步覆盖,避免顶栏长期显示写死状态。
|
||||
static Future<TerminalDeviceStatusSnapshot> loadCachedStatus() async {
|
||||
try {
|
||||
final fileInfo = await _statusCache.getFileFromCache(_statusKey);
|
||||
if (fileInfo == null) return status.value;
|
||||
final cached = TerminalDeviceStatusSnapshot.tryParse(
|
||||
await fileInfo.file.readAsString(),
|
||||
);
|
||||
status.value = cached ?? const TerminalDeviceStatusSnapshot.empty();
|
||||
_resetUnavailableWorkingState(status.value);
|
||||
} catch (e, stack) {
|
||||
status.value = const TerminalDeviceStatusSnapshot.empty();
|
||||
logger.w('|$_tag|读取缓存失败error:|$e|stack:|$stack|');
|
||||
}
|
||||
return status.value;
|
||||
}
|
||||
|
||||
/// 同一时刻只执行一轮完整检查,防止设置页手动刷新与后台任务重复访问硬件。
|
||||
static Future<TerminalDeviceStatusSnapshot> refreshAll({
|
||||
TerminalDeviceProbe? printerProbe,
|
||||
TerminalDeviceProbe? steelyardProbe,
|
||||
TerminalDeviceProbe? cardReaderProbe,
|
||||
TerminalDeviceProbe? subScreenProbe,
|
||||
TerminalDeviceProbe? cameraProbe,
|
||||
bool persist = true,
|
||||
Duration deviceMinimumDuration = const Duration(seconds: 2),
|
||||
Duration deviceTimeout = const Duration(seconds: 10),
|
||||
}) {
|
||||
final running = _refreshFuture;
|
||||
if (running != null) return running;
|
||||
|
||||
late final Future<TerminalDeviceStatusSnapshot> future;
|
||||
final generation = ++_refreshGeneration;
|
||||
future = _performRefresh(
|
||||
printerProbe: printerProbe,
|
||||
steelyardProbe: steelyardProbe,
|
||||
cardReaderProbe: cardReaderProbe,
|
||||
subScreenProbe: subScreenProbe,
|
||||
cameraProbe: cameraProbe,
|
||||
persist: persist,
|
||||
deviceMinimumDuration: deviceMinimumDuration,
|
||||
deviceTimeout: deviceTimeout,
|
||||
generation: generation,
|
||||
).whenComplete(() {
|
||||
if (identical(_refreshFuture, future)) _refreshFuture = null;
|
||||
if (_refreshGeneration == generation) {
|
||||
progress.value = const TerminalDeviceCheckProgress();
|
||||
}
|
||||
});
|
||||
_refreshFuture = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
static Future<TerminalDeviceStatusSnapshot> _performRefresh({
|
||||
TerminalDeviceProbe? printerProbe,
|
||||
TerminalDeviceProbe? steelyardProbe,
|
||||
TerminalDeviceProbe? cardReaderProbe,
|
||||
TerminalDeviceProbe? subScreenProbe,
|
||||
TerminalDeviceProbe? cameraProbe,
|
||||
required bool persist,
|
||||
required Duration deviceMinimumDuration,
|
||||
required Duration deviceTimeout,
|
||||
required int generation,
|
||||
}) async {
|
||||
// Root 不可用是正常设备状态,检查与持久化失败只记录告警,不中断后续外围硬件探测。
|
||||
if (Get.isRegistered<AppControl>()) {
|
||||
try {
|
||||
final rootResult = await AppRuntimeTool.checkRootAccess();
|
||||
await AppSettingTool.setRootAccessResult(rootResult);
|
||||
} catch (e, stack) {
|
||||
logger.w('|$_tag|Root状态检查或持久化失败error:|$e|stack:|$stack|');
|
||||
}
|
||||
}
|
||||
final needsApp = printerProbe == null ||
|
||||
steelyardProbe == null ||
|
||||
cardReaderProbe == null;
|
||||
final app = needsApp ? Get.find<AppControl>() : null;
|
||||
final probes = <(TerminalDeviceType, TerminalDeviceProbe)>[
|
||||
(
|
||||
TerminalDeviceType.printer,
|
||||
printerProbe ?? app!.printManage.checkAvailable,
|
||||
),
|
||||
(
|
||||
TerminalDeviceType.steelyard,
|
||||
steelyardProbe ?? app!.srialSteelyardManage.checkAvailable,
|
||||
),
|
||||
(
|
||||
TerminalDeviceType.cardReader,
|
||||
cardReaderProbe ?? app!.serialCardManage.checkAvailable,
|
||||
),
|
||||
(
|
||||
TerminalDeviceType.subScreen,
|
||||
subScreenProbe ??
|
||||
() => AppSubScreenTool.refreshAvailability(reportStatus: false),
|
||||
),
|
||||
(
|
||||
TerminalDeviceType.camera,
|
||||
cameraProbe ?? FaceDiscernPlugs.hasCamera,
|
||||
),
|
||||
];
|
||||
final results = <bool>[];
|
||||
for (var index = 0; index < probes.length; index++) {
|
||||
if (_refreshGeneration != generation) {
|
||||
logger.i('|$_tag|用户取消设备状态检查');
|
||||
throw const TerminalDeviceCheckCancelled();
|
||||
}
|
||||
final (device, probe) = probes[index];
|
||||
results.add(
|
||||
await _checkDevice(
|
||||
device,
|
||||
probe,
|
||||
minimumDuration: deviceMinimumDuration,
|
||||
timeout: deviceTimeout,
|
||||
completedCount: index,
|
||||
generation: generation,
|
||||
),
|
||||
);
|
||||
}
|
||||
final next = TerminalDeviceStatusSnapshot(
|
||||
printerAvailable: results[0],
|
||||
steelyardAvailable: results[1],
|
||||
cardReaderAvailable: results[2],
|
||||
subScreenAvailable: results[3],
|
||||
cameraAvailable: results[4],
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
status.value = next;
|
||||
_resetUnavailableWorkingState(next);
|
||||
if (persist) await _persist(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
static Future<bool> _checkDevice(
|
||||
TerminalDeviceType device,
|
||||
TerminalDeviceProbe probe, {
|
||||
required Duration minimumDuration,
|
||||
required Duration timeout,
|
||||
required int completedCount,
|
||||
required int generation,
|
||||
}) async {
|
||||
Timer? countdownTimer;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final effectiveMinimumDuration =
|
||||
minimumDuration > timeout ? timeout : minimumDuration;
|
||||
var remainingSeconds = timeout.inSeconds;
|
||||
var result = false;
|
||||
var timedOut = false;
|
||||
Object? probeError;
|
||||
StackTrace? probeStack;
|
||||
void reportProgress() {
|
||||
if (_refreshGeneration != generation) return;
|
||||
progress.value = TerminalDeviceCheckProgress(
|
||||
running: true,
|
||||
currentDevice: device,
|
||||
remainingSeconds: remainingSeconds,
|
||||
completedCount: completedCount,
|
||||
);
|
||||
}
|
||||
|
||||
reportProgress();
|
||||
countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (remainingSeconds > 0) remainingSeconds--;
|
||||
reportProgress();
|
||||
});
|
||||
logger.i(
|
||||
'|$_tag|正在检查${device.label},'
|
||||
'最少${effectiveMinimumDuration.inSeconds}秒,最多${timeout.inSeconds}秒',
|
||||
);
|
||||
try {
|
||||
result = await probe().timeout(timeout);
|
||||
} on TimeoutException {
|
||||
timedOut = true;
|
||||
} catch (e, stack) {
|
||||
probeError = e;
|
||||
probeStack = stack;
|
||||
} finally {
|
||||
// 插件秒回时仍保留最少展示时间,让用户能看清当前检查的设备与日志。
|
||||
final remainingMinimum = effectiveMinimumDuration - stopwatch.elapsed;
|
||||
if (remainingMinimum > Duration.zero) {
|
||||
await Future<void>.delayed(remainingMinimum);
|
||||
}
|
||||
countdownTimer.cancel();
|
||||
stopwatch.stop();
|
||||
}
|
||||
final elapsedMilliseconds = stopwatch.elapsedMilliseconds;
|
||||
if (timedOut) {
|
||||
logger.w(
|
||||
'|$_tag|检查${device.label}超时,'
|
||||
'耗时:$elapsedMilliseconds毫秒,按不可用处理',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (probeError != null) {
|
||||
logger.w(
|
||||
'|$_tag|检查${device.label}失败,'
|
||||
'耗时:$elapsedMilliseconds毫秒,error:|$probeError|stack:|$probeStack|',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
logger.i(
|
||||
'|$_tag|完成检查${device.label},'
|
||||
'耗时:$elapsedMilliseconds毫秒,状态:${result ? '可用' : '不可用'}',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void cancelRefresh() {
|
||||
if (_refreshFuture == null) return;
|
||||
_refreshGeneration++;
|
||||
progress.value = const TerminalDeviceCheckProgress();
|
||||
logger.i('|$_tag|已请求取消本轮设备状态检查');
|
||||
}
|
||||
|
||||
static void updatePrinter(
|
||||
bool available, {
|
||||
DateTime? checkedAt,
|
||||
bool persist = true,
|
||||
}) {
|
||||
if (!available) updatePrinterWorking(false);
|
||||
_update(
|
||||
status.value.copyWith(
|
||||
printerAvailable: available,
|
||||
updatedAt: checkedAt ?? DateTime.now(),
|
||||
),
|
||||
persist: persist,
|
||||
);
|
||||
}
|
||||
|
||||
static void updateSteelyard(
|
||||
bool available, {
|
||||
DateTime? checkedAt,
|
||||
bool persist = true,
|
||||
}) {
|
||||
if (!available) updateSteelyardWorking(false);
|
||||
_update(
|
||||
status.value.copyWith(
|
||||
steelyardAvailable: available,
|
||||
updatedAt: checkedAt ?? DateTime.now(),
|
||||
),
|
||||
persist: persist,
|
||||
);
|
||||
}
|
||||
|
||||
static void updateCardReader(
|
||||
bool available, {
|
||||
DateTime? checkedAt,
|
||||
bool persist = true,
|
||||
}) {
|
||||
if (!available) updateCardReaderWorking(false);
|
||||
_update(
|
||||
status.value.copyWith(
|
||||
cardReaderAvailable: available,
|
||||
updatedAt: checkedAt ?? DateTime.now(),
|
||||
),
|
||||
persist: persist,
|
||||
);
|
||||
}
|
||||
|
||||
static void updateSubScreen(
|
||||
bool available, {
|
||||
DateTime? checkedAt,
|
||||
bool persist = true,
|
||||
}) {
|
||||
if (!available) updateSubScreenWorking(false);
|
||||
_update(
|
||||
status.value.copyWith(
|
||||
subScreenAvailable: available,
|
||||
updatedAt: checkedAt ?? DateTime.now(),
|
||||
),
|
||||
persist: persist,
|
||||
);
|
||||
}
|
||||
|
||||
/// 更新摄像头是否存在;人脸识别是否工作由会话服务的实时状态单独决定。
|
||||
static void updateCamera(
|
||||
bool available, {
|
||||
DateTime? checkedAt,
|
||||
bool persist = true,
|
||||
}) {
|
||||
_update(
|
||||
status.value.copyWith(
|
||||
cameraAvailable: available,
|
||||
updatedAt: checkedAt ?? DateTime.now(),
|
||||
),
|
||||
persist: persist,
|
||||
);
|
||||
}
|
||||
|
||||
/// 更新打印任务占用状态,重复值不触发顶栏重建。
|
||||
static void updatePrinterWorking(bool working) {
|
||||
if (printerWorking.value != working) printerWorking.value = working;
|
||||
}
|
||||
|
||||
/// 更新业务称重占用状态,设备探测使用的临时 Reader 不调用此方法。
|
||||
static void updateSteelyardWorking(bool working) {
|
||||
if (steelyardWorking.value != working) steelyardWorking.value = working;
|
||||
}
|
||||
|
||||
/// 更新读写卡流程占用状态,等待用户放卡期间仍属于工作中。
|
||||
static void updateCardReaderWorking(bool working) {
|
||||
if (cardReaderWorking.value != working) cardReaderWorking.value = working;
|
||||
}
|
||||
|
||||
/// 更新副屏 Presentation 显示状态,设备存在但未显示时保持空闲。
|
||||
static void updateSubScreenWorking(bool working) {
|
||||
if (subScreenWorking.value != working) subScreenWorking.value = working;
|
||||
}
|
||||
|
||||
/// 可用性变为 false 时同步清除瞬时活动,避免顶栏出现“无设备但工作中”的矛盾状态。
|
||||
static void _resetUnavailableWorkingState(
|
||||
TerminalDeviceStatusSnapshot snapshot,
|
||||
) {
|
||||
if (!snapshot.printerAvailable) updatePrinterWorking(false);
|
||||
if (!snapshot.steelyardAvailable) updateSteelyardWorking(false);
|
||||
if (!snapshot.cardReaderAvailable) updateCardReaderWorking(false);
|
||||
if (!snapshot.subScreenAvailable) updateSubScreenWorking(false);
|
||||
}
|
||||
|
||||
static void _update(
|
||||
TerminalDeviceStatusSnapshot next, {
|
||||
required bool persist,
|
||||
}) {
|
||||
status.value = next;
|
||||
if (persist) unawaited(_persist(next));
|
||||
}
|
||||
|
||||
static Future<void> _persist(TerminalDeviceStatusSnapshot snapshot) async {
|
||||
// 实际硬件回调可能连续到达,串行写入可避免较旧快照后完成并覆盖新状态。
|
||||
_persistQueue = _persistQueue.then((_) async {
|
||||
try {
|
||||
await _statusCache.putFile(
|
||||
_statusKey,
|
||||
Uint8List.fromList(utf8.encode(snapshot.toCacheString())),
|
||||
fileExtension: 'json',
|
||||
);
|
||||
} catch (e, stack) {
|
||||
logger.w('|$_tag|写入缓存失败error:|$e|stack:|$stack|');
|
||||
}
|
||||
});
|
||||
await _persistQueue;
|
||||
}
|
||||
|
||||
/// 测试只重置内存状态,不触碰真实设备缓存。
|
||||
static void resetForTest() {
|
||||
_refreshFuture = null;
|
||||
_refreshGeneration = 0;
|
||||
_persistQueue = Future<void>.value();
|
||||
status.value = const TerminalDeviceStatusSnapshot.empty();
|
||||
progress.value = const TerminalDeviceCheckProgress();
|
||||
printerWorking.value = false;
|
||||
steelyardWorking.value = false;
|
||||
cardReaderWorking.value = false;
|
||||
subScreenWorking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
class TerminalDeviceCheckCancelled implements Exception {
|
||||
const TerminalDeviceCheckCancelled();
|
||||
}
|
||||
@@ -1,63 +1,148 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 统一封装人脸 SDK 激活、状态查询、本地人脸库注册和删除操作。
|
||||
///
|
||||
/// 所有激活参数均从终端系统设置读取,调用方不得传入或记录密钥明文。
|
||||
abstract class AppFaceTool {
|
||||
/// 人脸工具日志标签,不记录激活参数和人脸特征等敏感内容。
|
||||
static const String _tag = "app 人脸工具";
|
||||
|
||||
// 跳转到人脸识别页面
|
||||
/// 打开原生人脸识别页面;手动硬件模式或插件拒绝打开时返回 false。
|
||||
static Future<bool> jumpFacePage() async {
|
||||
if (AppRuntimeTool.useManualHardwareInput) return false;
|
||||
final isJump = await FaceDiscernPlugs.jumpFaceDiscernPage();
|
||||
if (!isJump) return false;
|
||||
// 收银机副屏在识别页停留两秒后自动退回商品页。
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
SubScreenPlugin.doubleScreenCancel();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查人脸激活参数
|
||||
static bool _validateSysFaceParams(SysSetting sysSetting) {
|
||||
if (ObjectUtil.isEmpty(sysSetting.faceAppId)) {
|
||||
return false;
|
||||
}
|
||||
if (ObjectUtil.isEmpty(sysSetting.faceSdkKey)) {
|
||||
return false;
|
||||
}
|
||||
if (ObjectUtil.isEmpty(sysSetting.faceActiveKey)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
/// 检查后端下发的人脸激活参数是否全部具有非空有效值。
|
||||
static bool validateSysFaceParams(SysSetting sysSetting) {
|
||||
return missingSysFaceParams(sysSetting).isEmpty;
|
||||
}
|
||||
|
||||
// 批量注册人脸
|
||||
/// 返回缺失的人脸参数字段名,供 Load 页给出可排查但不泄露密钥的提示。
|
||||
static List<String> missingSysFaceParams(SysSetting sysSetting) {
|
||||
final missing = <String>[];
|
||||
if ((sysSetting.faceAppId ?? '').trim().isEmpty) missing.add('APP_ID');
|
||||
if ((sysSetting.faceSdkKey ?? '').trim().isEmpty) missing.add('SDK_KEY');
|
||||
if ((sysSetting.faceActiveKey ?? '').trim().isEmpty) {
|
||||
missing.add('ACTIVE_KEY');
|
||||
}
|
||||
return List.unmodifiable(missing);
|
||||
}
|
||||
|
||||
/// 在 Floor 完成建库后激活虹软 SDK,并将本地数据库人脸完整重建到内存库。
|
||||
///
|
||||
/// 任意插件、权限或样本异常都转换为警告结果,调用方可以继续完成终端启动。
|
||||
static Future<FaceStartupPreparationResult> prepareForStartup() async {
|
||||
if (AppRuntimeTool.useManualHardwareInput) {
|
||||
return const FaceStartupPreparationResult(
|
||||
status: FaceStartupPreparationStatus.skipped,
|
||||
message: '当前为手动硬件输入模式,已跳过真实人脸设备准备',
|
||||
total: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
failedSampleIds: [],
|
||||
missingParameters: [],
|
||||
);
|
||||
}
|
||||
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
final missing = missingSysFaceParams(setting);
|
||||
if (missing.isNotEmpty) {
|
||||
return FaceStartupPreparationResult(
|
||||
status: FaceStartupPreparationStatus.warning,
|
||||
message: '缺少 ${missing.join('、')},请先同步设备配置',
|
||||
total: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
failedSampleIds: const [],
|
||||
missingParameters: missing,
|
||||
);
|
||||
}
|
||||
|
||||
FaceActivationResult activation;
|
||||
try {
|
||||
activation = await activateSdk();
|
||||
} catch (error, stack) {
|
||||
logger.w('|$_tag|启动阶段激活人脸插件异常error:|$error|stack:|$stack|');
|
||||
return const FaceStartupPreparationResult(
|
||||
status: FaceStartupPreparationStatus.warning,
|
||||
message: '人脸插件暂时无法激活,可进入设置后重试',
|
||||
total: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
failedSampleIds: [],
|
||||
missingParameters: [],
|
||||
);
|
||||
}
|
||||
if (!activation.success) {
|
||||
return FaceStartupPreparationResult(
|
||||
status: FaceStartupPreparationStatus.warning,
|
||||
message: activation.message,
|
||||
total: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
failedSampleIds: const [],
|
||||
missingParameters: const [],
|
||||
activationCode: activation.code,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final registration = await FaceDiscernPlugs.rebuildFaceLibrary();
|
||||
final status = registration.success
|
||||
? FaceStartupPreparationStatus.success
|
||||
: FaceStartupPreparationStatus.warning;
|
||||
return FaceStartupPreparationResult(
|
||||
status: status,
|
||||
message: registration.message,
|
||||
total: registration.total,
|
||||
successCount: registration.successCount,
|
||||
failedCount: registration.failedCount,
|
||||
failedSampleIds: List.unmodifiable(registration.failedSampleIds),
|
||||
missingParameters: const [],
|
||||
activationCode: activation.code,
|
||||
);
|
||||
} catch (error, stack) {
|
||||
logger.w('|$_tag|启动阶段重建人脸库异常error:|$error|stack:|$stack|');
|
||||
return FaceStartupPreparationResult(
|
||||
status: FaceStartupPreparationStatus.warning,
|
||||
message: '人脸插件已激活,但本地人脸库注册失败,可在设置中重新注册',
|
||||
total: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
failedSampleIds: const [],
|
||||
missingParameters: const [],
|
||||
activationCode: activation.code,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需将本地人脸记录注册到 SDK 内存库。
|
||||
///
|
||||
/// [userId] 为空时处理完整人脸库,[forceRegister] 为 true 时忽略当前内存库数量并强制重建。
|
||||
/// 返回值表示调用完成后内存库中是否至少存在一条可识别人脸。
|
||||
static Future<bool> faceRegister({
|
||||
String? userId,
|
||||
bool forceRegister = false,
|
||||
}) async {
|
||||
if (AppRuntimeTool.useManualHardwareInput) return true;
|
||||
var faceCount = await FaceDiscernPlugs.getFaceCount();
|
||||
if (faceCount == 0 || forceRegister) {
|
||||
final list = await FaceDiscernPlugs.registerFaceByDatabase(
|
||||
final result = await FaceDiscernPlugs.rebuildFaceLibrary(
|
||||
userId: userId,
|
||||
);
|
||||
if (!ObjectUtil.isEmptyList(list)) {
|
||||
final app = Get.find<AppControl>();
|
||||
final faceDao = app.dataBase.getFaceDao;
|
||||
logger.i("|$_tag|批量注册人脸,错误的人脸:${json.encode(list)}");
|
||||
for (final v in list) {
|
||||
final sampleId = v.toString();
|
||||
final userFace = await faceDao.findBySampleId(sampleId);
|
||||
if (ObjectUtil.isNotEmpty(userFace)) {
|
||||
if (ObjectUtil.isNotEmpty(userFace?.path)) {
|
||||
await SyncFaceTool.delFaceByStorage(filePath: userFace!.path!);
|
||||
}
|
||||
await faceDao.delById(userFace!.id!);
|
||||
await AppFaceTool.removeFace(sampleId: sampleId);
|
||||
}
|
||||
}
|
||||
if (result.failedSampleIds.isNotEmpty) {
|
||||
logger.w('|$_tag|批量注册人脸失败数量:${result.failedSampleIds.length}');
|
||||
}
|
||||
}
|
||||
faceCount = await FaceDiscernPlugs.getFaceCount();
|
||||
@@ -65,33 +150,171 @@ abstract class AppFaceTool {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 是否激活人脸sdk
|
||||
static Future<bool> isActiveSdk() async {
|
||||
final isNetwork = AppNetworkTool.isAutoNetwork();
|
||||
if (!isNetwork) return true;
|
||||
var faceConfig = await FaceDiscernPlugs.getConfig();
|
||||
if (faceConfig == null || faceConfig.isActive == false) {
|
||||
final sysSetting = await AppSettingTool.getSysSetting();
|
||||
final valid = AppFaceTool._validateSysFaceParams(sysSetting);
|
||||
if (!valid) return false;
|
||||
final isActive = await FaceDiscernPlugs.active(
|
||||
appId: sysSetting.faceAppId!,
|
||||
sdkKey: sysSetting.faceSdkKey!,
|
||||
activeKey: sysSetting.faceActiveKey!,
|
||||
);
|
||||
if (isActive) {
|
||||
faceConfig = await FaceDiscernPlugs.getConfig();
|
||||
}
|
||||
/// 将 SDK 识别结果解析为当前后端仍有效的人员和钱包信息。
|
||||
///
|
||||
/// 正常情况直接使用本地样本或 SDK 返回的人员编号查询;编号失效时,仅允许
|
||||
/// 通过现有全量人脸同步刷新同一个 [FaceStream.sampleId] 的归属后重试。
|
||||
/// 禁止按本地饭卡号替换为另一人员,避免旧人脸样本误绑定到卡片的新持有人。
|
||||
static Future<ServiceUser?> resolveRecognizedUser({
|
||||
required FaceStream recognition,
|
||||
required SysType sysType,
|
||||
}) async {
|
||||
final app = Get.find<AppControl>();
|
||||
final faceDao = app.dataBase.getFaceDao;
|
||||
final candidateUserIds = <String>{};
|
||||
final sampleId = recognition.sampleId?.toString();
|
||||
if (sampleId != null && sampleId.isNotEmpty) {
|
||||
final localFace = await faceDao.findBySampleId(sampleId);
|
||||
_addCandidateUserId(candidateUserIds, localFace?.userId);
|
||||
}
|
||||
if (faceConfig == null) {
|
||||
return false;
|
||||
_addCandidateUserId(candidateUserIds, recognition.userId);
|
||||
|
||||
if (!AppNetworkTool.isAutoNetwork()) {
|
||||
return _findCachedRecognizedUser(candidateUserIds);
|
||||
}
|
||||
return faceConfig.isActive;
|
||||
|
||||
final currentUser = await _findRemoteRecognizedUser(
|
||||
candidateUserIds,
|
||||
sysType,
|
||||
);
|
||||
if (currentUser != null || sampleId == null || sampleId.isEmpty) {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
// 识别会话已经由调用方结束,此处才允许进入统一人脸队列,避免并发操作引擎。
|
||||
// 已知人员只回查该用户;没有可靠用户标识时才对账本部门,禁止恢复流程拉取全租户人脸。
|
||||
logger.w('|$_tag|识别样本对应人员已失效,开始回查当前范围人脸并刷新样本归属|');
|
||||
final scheduler = SyncCheckTool.instance;
|
||||
await scheduler.trigger(
|
||||
ScheduledTaskType.departmentFaceSync,
|
||||
faceUserIds: candidateUserIds.isEmpty ? null : candidateUserIds,
|
||||
triggerType: ScheduledTaskTriggerType.system,
|
||||
);
|
||||
if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync) ||
|
||||
scheduler.pendingTasks.contains(ScheduledTaskType.departmentFaceSync)) {
|
||||
// 已有同步占用引擎时不读取旧归属继续支付;队列完成后由下一次识别使用新数据。
|
||||
return null;
|
||||
}
|
||||
final refreshedFace = await faceDao.findBySampleId(sampleId);
|
||||
final refreshedUserId = refreshedFace?.userId.trim();
|
||||
if (refreshedUserId == null ||
|
||||
refreshedUserId.isEmpty ||
|
||||
candidateUserIds.contains(refreshedUserId)) {
|
||||
return null;
|
||||
}
|
||||
candidateUserIds.add(refreshedUserId);
|
||||
return _findRemoteRecognizedUser({refreshedUserId}, sysType);
|
||||
}
|
||||
|
||||
// 删除人脸
|
||||
/// 将非空人员编号加入有序候选集合,保持本地样本优先于原生回包。
|
||||
static void _addCandidateUserId(Set<String> target, String? userId) {
|
||||
final value = userId?.trim();
|
||||
if (value == null || value.isEmpty) return;
|
||||
target.add(value);
|
||||
}
|
||||
|
||||
/// 离线时从本地人员缓存解析识别结果;没有缓存则返回空并交由业务提示同步。
|
||||
static Future<ServiceUser?> _findCachedRecognizedUser(
|
||||
Set<String> candidateUserIds,
|
||||
) async {
|
||||
for (final userId in candidateUserIds) {
|
||||
final user = await SyncUserTool.storageUserByUserId(userId);
|
||||
if (user != null) return SyncUserTool.parsePayUser(user: user);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 依次使用现有人员查询接口校验候选编号,并缓存首个仍有效的人员。
|
||||
static Future<ServiceUser?> _findRemoteRecognizedUser(
|
||||
Set<String> candidateUserIds,
|
||||
SysType sysType,
|
||||
) async {
|
||||
for (final userId in candidateUserIds) {
|
||||
final user = await HardApi.getUserInfoById({
|
||||
'id': userId,
|
||||
'type': sysType.name,
|
||||
});
|
||||
if (user == null || (user.userId ?? '').trim().isEmpty) continue;
|
||||
await SyncUserTool.storageUser(user: user);
|
||||
return user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 查询当前设备的人脸 SDK 是否已经激活并可继续初始化。
|
||||
static Future<bool> isActiveSdk() async {
|
||||
if (AppRuntimeTool.useManualHardwareInput) return true;
|
||||
final status = await getStatus();
|
||||
return status?.activated == true;
|
||||
}
|
||||
|
||||
/// 获取激活、引擎和人脸库一致性状态。
|
||||
///
|
||||
/// 参数不完整时返回未就绪状态;手动硬件输入模式不连接原生通道并返回 null。
|
||||
static Future<FacePluginStatus?> getStatus() async {
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
// 参数完整性来自本地已同步配置,不依赖人脸硬件或原生通道;虚拟机和手动输入模式也应明确展示“等待同步配置”。
|
||||
if (!validateSysFaceParams(setting)) {
|
||||
return const FacePluginStatus(
|
||||
activated: false,
|
||||
activationCode: -1,
|
||||
parametersComplete: false,
|
||||
engineReady: false,
|
||||
engineCode: -1,
|
||||
databaseCount: 0,
|
||||
memoryCount: 0,
|
||||
registrationInProgress: false,
|
||||
libraryConsistent: false,
|
||||
);
|
||||
}
|
||||
if (AppRuntimeTool.useManualHardwareInput) return null;
|
||||
return FaceDiscernPlugs.getFaceStatus(
|
||||
appId: setting.faceAppId ?? '',
|
||||
sdkKey: setting.faceSdkKey ?? '',
|
||||
activeKey: setting.faceActiveKey ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 激活参数只来自后端同步后的系统设置,页面不接收也不展示密钥明文。
|
||||
static Future<FaceActivationResult> activateSdk() async {
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
if (!validateSysFaceParams(setting)) {
|
||||
return const FaceActivationResult(
|
||||
success: false,
|
||||
code: -1,
|
||||
message: '人脸插件参数不完整,请先同步设备配置',
|
||||
requiresPermission: false,
|
||||
canOpenSettings: false,
|
||||
);
|
||||
}
|
||||
|
||||
final permission = await Permission.phone.status;
|
||||
if (!permission.isGranted) {
|
||||
final requested = await Permission.phone.request();
|
||||
if (!requested.isGranted) {
|
||||
return FaceActivationResult(
|
||||
success: false,
|
||||
code: -1,
|
||||
message: requested.isPermanentlyDenied
|
||||
? '电话状态权限已被永久拒绝,请在系统设置中开启'
|
||||
: '激活需要电话状态权限,请授权后重试',
|
||||
requiresPermission: true,
|
||||
canOpenSettings: requested.isPermanentlyDenied,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return FaceDiscernPlugs.activateFaceSdk(
|
||||
appId: setting.faceAppId!.trim(),
|
||||
sdkKey: setting.faceSdkKey!.trim(),
|
||||
activeKey: setting.faceActiveKey!.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 从 SDK 内存库删除指定样本;样本编号为空、SDK 未激活或插件失败时返回 false。
|
||||
static Future<bool> removeFace({String? sampleId}) async {
|
||||
if (sampleId == null) return false;
|
||||
if (AppRuntimeTool.useManualHardwareInput) return true;
|
||||
final isActive = await AppFaceTool.isActiveSdk();
|
||||
if (!isActive) return false;
|
||||
return await FaceDiscernPlugs.removeFace(sampleId: sampleId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -12,17 +13,25 @@ import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
abstract class AppNetworkTool {
|
||||
static const _tag = "网络工具类";
|
||||
|
||||
/// 文件中心对外读取 OSS 对象的统一网关路径。
|
||||
static const _ossFilePath = '/admin/sys-file/oss/file';
|
||||
|
||||
static StreamSubscription<List<ConnectivityResult>>? subscription;
|
||||
|
||||
static bool isNetworkOssImage(String imagePath) {
|
||||
return imagePath.contains("?fileName=");
|
||||
final uri = Uri.tryParse(imagePath.trim());
|
||||
return uri?.queryParameters.containsKey('fileName') == true;
|
||||
}
|
||||
|
||||
// 是否是网络图片
|
||||
static bool isNetworkImage(String imagePath) {
|
||||
if (isNetworkOssImage(imagePath)) return true;
|
||||
final networkImagePattern = RegExp(r'^(http|https)://');
|
||||
return networkImagePattern.hasMatch(imagePath);
|
||||
final value = imagePath.trim();
|
||||
if (value.startsWith('//')) return true;
|
||||
final uri = Uri.tryParse(value);
|
||||
return uri != null &&
|
||||
(uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
uri.host.isNotEmpty;
|
||||
}
|
||||
|
||||
// 手动判断有没有要网络 todo 不建议使用
|
||||
@@ -37,6 +46,22 @@ abstract class AppNetworkTool {
|
||||
return bool;
|
||||
}
|
||||
|
||||
/// 仅用于状态展示的轻量探测,不输出周期性日志。
|
||||
static Future<bool> isConnectedNow() async {
|
||||
final list = await Connectivity().checkConnectivity();
|
||||
return list.any((item) => item != ConnectivityResult.none);
|
||||
}
|
||||
|
||||
/// 原生通道无法识别模拟器网络时,使用 connectivity_plus 补充连接类型。
|
||||
static Future<String> getNetworkTypeNow() async {
|
||||
final list = await Connectivity().checkConnectivity();
|
||||
if (list.contains(ConnectivityResult.wifi)) return 'wifi';
|
||||
if (list.contains(ConnectivityResult.ethernet)) return 'ethernet';
|
||||
if (list.contains(ConnectivityResult.mobile)) return 'mobile';
|
||||
if (list.any((item) => item != ConnectivityResult.none)) return 'unknown';
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
// 自动判断是否有网络
|
||||
static bool isAutoNetwork() {
|
||||
final app = Get.find<AppControl>();
|
||||
@@ -55,33 +80,51 @@ abstract class AppNetworkTool {
|
||||
|
||||
// 自动监听网络变化
|
||||
static void listenNetwork() async {
|
||||
subscription = Connectivity()
|
||||
.onConnectivityChanged
|
||||
.listen((List<ConnectivityResult> list) {
|
||||
final bool = !list.contains(ConnectivityResult.none);
|
||||
final app = Get.find<AppControl>();
|
||||
if (bool != app.isNetwork.value) {
|
||||
app.isNetwork.value = bool;
|
||||
EasyLoading.dismiss();
|
||||
AppMqTool.mqRefresh();
|
||||
logger.w("|$_tag|监听网络变化,当前网络已经${bool ? '连接' : '断开'}");
|
||||
if (bool) SyncOrderTool.syncOfflineOrder();
|
||||
}
|
||||
});
|
||||
final initial = await Connectivity().checkConnectivity();
|
||||
_applyConnectivity(initial);
|
||||
subscription =
|
||||
Connectivity().onConnectivityChanged.listen(_applyConnectivity);
|
||||
}
|
||||
|
||||
static void _applyConnectivity(List<ConnectivityResult> list) {
|
||||
final connected = list.any((item) => item != ConnectivityResult.none);
|
||||
final app = Get.find<AppControl>();
|
||||
if (connected == app.isNetwork.value) return;
|
||||
app.isNetwork.value = connected;
|
||||
EasyLoading.dismiss();
|
||||
logger.w("|$_tag|监听网络变化,当前网络已经${connected ? '连接' : '断开'}");
|
||||
if (connected) unawaited(_restoreNetworkServices());
|
||||
}
|
||||
|
||||
/// 网络恢复后的消息连接和离线订单同步允许失败,不能产生未处理异常中断应用。
|
||||
static Future<void> _restoreNetworkServices() async {
|
||||
final userSetting = await AppSettingTool.getUserSetting();
|
||||
// 登录页只需要网络状态;退出后禁止网络变化重新连接 MQTT 或调用打印待办接口。
|
||||
if (ObjectUtil.isEmpty(userSetting.accessToken)) return;
|
||||
try {
|
||||
await AppMqTool.mqRefresh();
|
||||
await SyncPrintTool.pullPendingTasks();
|
||||
await SyncPrintTool.doCheckSync();
|
||||
} catch (e, stack) {
|
||||
logger.e('|$_tag|网络恢复后连接消息服务失败error:|$e|stack:|$stack|');
|
||||
}
|
||||
try {
|
||||
await SyncOrderTool.syncOfflineOrder();
|
||||
} catch (e, stack) {
|
||||
logger.e('|$_tag|网络恢复后同步离线订单失败error:|$e|stack:|$stack|');
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭监听网络
|
||||
static void closeListenNetwork() {
|
||||
subscription?.cancel();
|
||||
subscription = null;
|
||||
}
|
||||
|
||||
/// 从网络中获取图片
|
||||
static Future<imglib.Image?> loadImageFromNetwork(String url) async {
|
||||
try {
|
||||
String networkImage = url;
|
||||
if (AppNetworkTool.isNetworkOssImage(networkImage)) {
|
||||
networkImage = "${Constants.ossUrl}$networkImage";
|
||||
}
|
||||
final networkImage = serverImageUrl(url);
|
||||
final response = await http.get(Uri.parse(networkImage));
|
||||
if (response.statusCode == 200) {
|
||||
final Uint8List imageData = response.bodyBytes;
|
||||
@@ -100,10 +143,7 @@ abstract class AppNetworkTool {
|
||||
|
||||
// 从网络中下载图片
|
||||
static Future<Uint8List?> downloadImage(String url) async {
|
||||
String networkImage = url;
|
||||
if (AppNetworkTool.isNetworkOssImage(networkImage)) {
|
||||
networkImage = "${Constants.ossUrl}$networkImage";
|
||||
}
|
||||
final networkImage = serverImageUrl(url);
|
||||
try {
|
||||
final client = http.Client();
|
||||
try {
|
||||
@@ -126,7 +166,39 @@ abstract class AppNetworkTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将服务端图片字段统一转换为图片组件可直接加载的绝对地址。
|
||||
///
|
||||
/// 完整 HTTP(S) 地址保持不变;文件中心相对地址拼到 [Constants.ossUrl]
|
||||
/// 配置的部署前缀后;只有 OSS 对象名时,通过标准文件读取接口访问。
|
||||
static String serverImageUrl(String src) {
|
||||
final value = src.trim();
|
||||
if (value.isEmpty) return value;
|
||||
|
||||
final sourceUri = Uri.tryParse(value);
|
||||
if (sourceUri != null &&
|
||||
(sourceUri.scheme == 'http' || sourceUri.scheme == 'https') &&
|
||||
sourceUri.host.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
|
||||
final baseUri = Uri.parse(Constants.ossUrl);
|
||||
final origin = baseUri.origin;
|
||||
final base = Constants.ossUrl.replaceFirst(RegExp(r'/+$'), '');
|
||||
if (value.startsWith('//')) return '${baseUri.scheme}:$value';
|
||||
if (value.startsWith('/api/')) return '$origin$value';
|
||||
if (value.startsWith('api/')) return '$origin/$value';
|
||||
if (value.startsWith('/admin/')) return '$base$value';
|
||||
if (value.startsWith('admin/')) return '$base/$value';
|
||||
|
||||
final endpoint = Uri.parse('$base$_ossFilePath');
|
||||
if (value.startsWith('?')) {
|
||||
return endpoint.replace(query: value.substring(1)).toString();
|
||||
}
|
||||
return endpoint.replace(queryParameters: {'fileName': value}).toString();
|
||||
}
|
||||
|
||||
/// 兼容既有调用名称,所有 OSS 图片地址统一委托给全局解析方法。
|
||||
static String ossImage(String src) {
|
||||
return "${Constants.ossUrl}$src";
|
||||
return serverImageUrl(src);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/models/manage/root_access_models.dart';
|
||||
|
||||
/// 当前 Android 设备的运行环境。
|
||||
class AppRuntimeEnvironment {
|
||||
/// 是否命中 Android 虚拟机特征;通道不可用时按真机降级,避免阻塞应用启动。
|
||||
final bool isEmulator;
|
||||
|
||||
/// Android DisplayManager 当前识别的显示屏数量,最少按单屏处理。
|
||||
final int displayCount;
|
||||
|
||||
/// 当前 Android 系统发行版本,例如 `11`;原生字段不可用时为空字符串。
|
||||
final String androidVersion;
|
||||
|
||||
/// 当前 Android API Level,例如 Android 11 对应 30;原生字段不可用时为 0。
|
||||
final int androidSdkInt;
|
||||
|
||||
/// 当前进程实际安装 APK 的展示版本,不使用后台待更新版本代替。
|
||||
final String apkVersionName;
|
||||
|
||||
/// 当前进程实际安装 APK 的内部版本码;原生字段不可用时为 0。
|
||||
final int apkVersionCode;
|
||||
|
||||
/// 创建一份由 Android 原生运行时采集的不可变环境快照。
|
||||
const AppRuntimeEnvironment({
|
||||
required this.isEmulator,
|
||||
required this.displayCount,
|
||||
required this.androidVersion,
|
||||
required this.androidSdkInt,
|
||||
required this.apkVersionName,
|
||||
required this.apkVersionCode,
|
||||
});
|
||||
|
||||
/// 从 MethodChannel 返回值解析环境快照,缺失版本字段使用可安全展示的空值。
|
||||
factory AppRuntimeEnvironment.fromMap(Map<dynamic, dynamic>? data) {
|
||||
return AppRuntimeEnvironment(
|
||||
isEmulator: data?['isEmulator'] == true,
|
||||
displayCount: (data?['displayCount'] as num?)?.toInt() ?? 1,
|
||||
androidVersion: data?['androidVersion']?.toString() ?? '',
|
||||
androidSdkInt: (data?['androidSdkInt'] as num?)?.toInt() ?? 0,
|
||||
apkVersionName: data?['apkVersionName']?.toString() ?? '',
|
||||
apkVersionCode: (data?['apkVersionCode'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 专用设备模式的实时状态。
|
||||
class AppKioskStatus {
|
||||
final bool deviceAdminActive;
|
||||
final bool deviceOwner;
|
||||
final bool available;
|
||||
final bool active;
|
||||
final bool lockTaskPermitted;
|
||||
final bool autoEnterEnabled;
|
||||
final bool bootAutoStartSupported;
|
||||
|
||||
const AppKioskStatus({
|
||||
required this.deviceAdminActive,
|
||||
required this.deviceOwner,
|
||||
required this.available,
|
||||
required this.active,
|
||||
required this.lockTaskPermitted,
|
||||
required this.autoEnterEnabled,
|
||||
required this.bootAutoStartSupported,
|
||||
});
|
||||
|
||||
const AppKioskStatus.unavailable()
|
||||
: deviceAdminActive = false,
|
||||
deviceOwner = false,
|
||||
available = false,
|
||||
active = false,
|
||||
lockTaskPermitted = false,
|
||||
autoEnterEnabled = false,
|
||||
bootAutoStartSupported = false;
|
||||
|
||||
factory AppKioskStatus.fromMap(Map<dynamic, dynamic>? data) {
|
||||
return AppKioskStatus(
|
||||
deviceAdminActive: data?['deviceAdminActive'] == true,
|
||||
deviceOwner: data?['deviceOwner'] == true,
|
||||
available: data?['available'] == true,
|
||||
active: data?['active'] == true,
|
||||
lockTaskPermitted: data?['lockTaskPermitted'] == true,
|
||||
autoEnterEnabled: data?['autoEnterEnabled'] == true,
|
||||
bootAutoStartSupported: data?['bootAutoStartSupported'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
bool get canExitToAndroidHome => available && active;
|
||||
|
||||
String get statusLabel {
|
||||
if (!available) return '暂不可实现';
|
||||
if (active && !autoEnterEnabled) return 'Kiosk 运行中 · 自动开启已关闭';
|
||||
if (active) return '可以实现 · Kiosk 运行中';
|
||||
if (!autoEnterEnabled) return '可以实现 · 自动开启已关闭';
|
||||
if (lockTaskPermitted) return '可以实现 · 正在进入';
|
||||
return '可以实现 · 等待策略生效';
|
||||
}
|
||||
|
||||
String get description {
|
||||
if (available && active && !autoEnterEnabled) {
|
||||
return '当前仍在 Kiosk;管理员退出后,再次打开或回到 App 将不再自动锁定。';
|
||||
}
|
||||
if (available && !autoEnterEnabled) {
|
||||
return '设备具备 Device Owner 权限;开启右侧配置后,每次打开或回到 App 都会自动进入 Kiosk。';
|
||||
}
|
||||
if (available) {
|
||||
return '已配置 Device Owner;每次打开或回到 App 都会自动进入 Kiosk,并隐藏主页、最近任务和系统栏。';
|
||||
}
|
||||
if (deviceAdminActive) {
|
||||
return '当前仅激活了设备管理员,仍不能可靠禁用主页和最近任务;需要配置为 Device Owner。';
|
||||
}
|
||||
return '未配置 Device Owner,普通全屏不能保证无法退出;需在恢复出厂或未初始化的设备上部署。';
|
||||
}
|
||||
}
|
||||
|
||||
/// Root 执行 DPM 后返回的结构化 Kiosk 启用结果。
|
||||
class AppKioskProvisionResult {
|
||||
/// 创建一份包含稳定错误编码、可操作说明和最新 Kiosk 状态的结果。
|
||||
const AppKioskProvisionResult({
|
||||
required this.success,
|
||||
required this.code,
|
||||
required this.message,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
/// DPM 是否成功配置当前应用为 Device Owner 并进入 Kiosk。
|
||||
final bool success;
|
||||
|
||||
/// 原生层稳定错误编码,用于区分账户、Owner、用户和厂商限制。
|
||||
final String code;
|
||||
|
||||
/// 面向终端管理员展示的中文处理建议。
|
||||
final String message;
|
||||
|
||||
/// 命令执行完成后重新读取的 Kiosk 实时状态。
|
||||
final AppKioskStatus status;
|
||||
|
||||
/// 从原生结构化返回值恢复结果,通道字段缺失时使用安全失败值。
|
||||
factory AppKioskProvisionResult.fromMap(Map<dynamic, dynamic>? data) {
|
||||
return AppKioskProvisionResult(
|
||||
success: data?['success'] == true,
|
||||
code: data?['code']?.toString() ?? 'UNKNOWN',
|
||||
message: data?['message']?.toString() ?? 'Kiosk 启用失败,请检查设备策略',
|
||||
status: AppKioskStatus.fromMap(data?['status'] as Map?),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Android APK 校验或 Root 安装命令的结构化结果。
|
||||
class AppApkOperationResult {
|
||||
/// 创建一份供自动更新流程判断的原生操作结果。
|
||||
const AppApkOperationResult({
|
||||
required this.success,
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.versionCode,
|
||||
});
|
||||
|
||||
/// APK 校验通过或安装命令成功执行时为 true。
|
||||
final bool success;
|
||||
|
||||
/// 原生稳定结果编码,例如 SIGNATURE_MISMATCH 或 INSTALL_STARTED。
|
||||
final String code;
|
||||
|
||||
/// 面向任务日志或人工操作展示的中文摘要。
|
||||
final String message;
|
||||
|
||||
/// 原生 APK 实际版本码;返回数据不含版本时为空。
|
||||
final int? versionCode;
|
||||
|
||||
/// 从 MethodChannel 返回映射创建操作结果。
|
||||
factory AppApkOperationResult.fromMap(Map<dynamic, dynamic>? data) {
|
||||
return AppApkOperationResult(
|
||||
success: data?['valid'] == true,
|
||||
code: data?['code']?.toString() ?? 'UNKNOWN',
|
||||
message: data?['message']?.toString() ?? '原生 APK 操作失败',
|
||||
versionCode: (data?['versionCode'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用启动时消费的待更新恢复结果。
|
||||
class AppUpdateRecoveryResult {
|
||||
/// 创建一份待更新确认结果。
|
||||
const AppUpdateRecoveryResult({
|
||||
required this.pending,
|
||||
required this.success,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
/// 原生层是否存在上次安装前持久化的待更新记录。
|
||||
final bool pending;
|
||||
|
||||
/// 当前安装版本是否达到待更新目标版本码。
|
||||
final bool success;
|
||||
|
||||
/// 启动确认结果摘要,用于完成任务记录。
|
||||
final String message;
|
||||
|
||||
/// 从 MethodChannel 返回映射创建恢复结果。
|
||||
factory AppUpdateRecoveryResult.fromMap(Map<dynamic, dynamic>? data) {
|
||||
return AppUpdateRecoveryResult(
|
||||
pending: data?['pending'] == true,
|
||||
success: data?['success'] == true,
|
||||
message: data?['message']?.toString() ?? '没有待确认的自动更新',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppNetworkSnapshot {
|
||||
final bool connected;
|
||||
final String type;
|
||||
final int receivedBytes;
|
||||
final int transmittedBytes;
|
||||
|
||||
const AppNetworkSnapshot({
|
||||
required this.connected,
|
||||
required this.type,
|
||||
required this.receivedBytes,
|
||||
required this.transmittedBytes,
|
||||
});
|
||||
|
||||
factory AppNetworkSnapshot.fromMap(Map<dynamic, dynamic>? data) {
|
||||
return AppNetworkSnapshot(
|
||||
connected: data?['connected'] == true,
|
||||
type: data?['type']?.toString() ?? 'offline',
|
||||
receivedBytes: (data?['rxBytes'] as num?)?.toInt() ?? 0,
|
||||
transmittedBytes: (data?['txBytes'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 进程与设备资源快照。
|
||||
///
|
||||
/// CPU 使用率需要比较两次 [processCpuTimeMillis],原生层只返回累计值,避免在平台通道内
|
||||
/// 持有采样定时器;内存和存储同时保留总量,便于顶栏给出百分比并在 Tooltip 展示明细。
|
||||
class AppPerformanceSnapshot {
|
||||
/// Android 系统自本次开机后的运行时长,单位为秒。
|
||||
final int systemUptimeSeconds;
|
||||
|
||||
/// 当前应用进程自启动后的运行时长,单位为秒。
|
||||
final int appUptimeSeconds;
|
||||
|
||||
/// 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.systemUptimeSeconds,
|
||||
required this.appUptimeSeconds,
|
||||
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()
|
||||
: systemUptimeSeconds = 0,
|
||||
appUptimeSeconds = 0,
|
||||
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(
|
||||
systemUptimeSeconds: value('systemUptimeSeconds'),
|
||||
appUptimeSeconds: value('appUptimeSeconds'),
|
||||
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(
|
||||
'biz_xfj/runtime_environment',
|
||||
);
|
||||
|
||||
static AppRuntimeEnvironment _environment = const AppRuntimeEnvironment(
|
||||
isEmulator: false,
|
||||
displayCount: 1,
|
||||
androidVersion: '',
|
||||
androidSdkInt: 0,
|
||||
apkVersionName: '',
|
||||
apkVersionCode: 0,
|
||||
);
|
||||
static bool _initialized = false;
|
||||
static bool _manualHardwareInput = false;
|
||||
static AppKioskStatus _kioskStatus = const AppKioskStatus.unavailable();
|
||||
|
||||
/// 请求 Android 终端执行短震动;通道不可用或平台不支持时回退到 Flutter 触觉反馈。
|
||||
static Future<void> vibrate({int durationMs = 60}) async {
|
||||
final safeDuration = durationMs.clamp(20, 200).toInt();
|
||||
try {
|
||||
final handled = await _channel.invokeMethod<bool>(
|
||||
'vibrate',
|
||||
{'durationMs': safeDuration},
|
||||
);
|
||||
if (handled == true) return;
|
||||
} on MissingPluginException {
|
||||
// 非 Android 平台没有运行时通道,继续使用 Flutter 提供的系统触觉能力。
|
||||
} on PlatformException {
|
||||
// 厂商设备禁用震动或原生调用异常时,回退触觉不能影响弹框关闭控制。
|
||||
}
|
||||
await HapticFeedback.mediumImpact();
|
||||
}
|
||||
|
||||
static bool get isEmulator => _environment.isEmulator;
|
||||
|
||||
static int get displayCount => _environment.displayCount;
|
||||
|
||||
/// 返回设置页使用的 Android 发行版本与 API Level,通道不可用时明确显示未知。
|
||||
static String get androidVersionLabel {
|
||||
final version = _environment.androidVersion.trim();
|
||||
final sdk = _environment.androidSdkInt;
|
||||
if (version.isNotEmpty && sdk > 0) return 'Android $version(API $sdk)';
|
||||
if (version.isNotEmpty) return 'Android $version';
|
||||
if (sdk > 0) return 'Android API $sdk';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
/// 返回当前实际安装 APK 的展示版本与 Build,字段缺失时仍保留已知部分。
|
||||
static String get apkVersionLabel {
|
||||
final versionName = _environment.apkVersionName.trim();
|
||||
final versionCode = _environment.apkVersionCode;
|
||||
if (versionName.isNotEmpty && versionCode > 0) {
|
||||
return '$versionName(Build $versionCode)';
|
||||
}
|
||||
if (versionName.isNotEmpty) return versionName;
|
||||
if (versionCode > 0) return 'Build $versionCode';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
/// 虚拟机始终使用手动输入;真机可在设置中主动开启手动输入模式。
|
||||
static bool get useManualHardwareInput => isEmulator || _manualHardwareInput;
|
||||
|
||||
static bool get canUsePhysicalHardware => !useManualHardwareInput;
|
||||
|
||||
static bool get hasMultipleDisplays => displayCount >= 2;
|
||||
|
||||
static AppKioskStatus get kioskStatus => _kioskStatus;
|
||||
|
||||
static Future<void> initializePlatform() async {
|
||||
if (_initialized) return;
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'getRuntimeEnvironment',
|
||||
);
|
||||
_environment = AppRuntimeEnvironment.fromMap(data);
|
||||
_kioskStatus = await refreshKioskStatus();
|
||||
} on PlatformException {
|
||||
// 原生通道异常时按单屏处理,避免错误地启动副屏。
|
||||
_environment = const AppRuntimeEnvironment(
|
||||
isEmulator: false,
|
||||
displayCount: 1,
|
||||
androidVersion: '',
|
||||
androidSdkInt: 0,
|
||||
apkVersionName: '',
|
||||
apkVersionCode: 0,
|
||||
);
|
||||
_kioskStatus = const AppKioskStatus.unavailable();
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<AppKioskStatus> refreshKioskStatus() async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'getKioskStatus',
|
||||
);
|
||||
_kioskStatus = AppKioskStatus.fromMap(data);
|
||||
} on PlatformException {
|
||||
_kioskStatus = const AppKioskStatus.unavailable();
|
||||
}
|
||||
return _kioskStatus;
|
||||
}
|
||||
|
||||
/// 配置每次进入 App 时是否自动恢复 Kiosk,配置由 Android 原生层持久化。
|
||||
static Future<AppKioskStatus> setKioskAutoEnterEnabled(bool enabled) async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'setKioskAutoEnterEnabled',
|
||||
{'enabled': enabled},
|
||||
);
|
||||
_kioskStatus = AppKioskStatus.fromMap(data);
|
||||
} on PlatformException {
|
||||
// 保留原状态,设置页会据此判断本次配置没有生效。
|
||||
}
|
||||
return _kioskStatus;
|
||||
}
|
||||
|
||||
/// 用户确认后通过 Root 请求 Android 配置当前应用为 Device Owner,并立即进入 Kiosk。
|
||||
static Future<AppKioskProvisionResult> enableKioskWithRoot() async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'enableKioskWithRoot',
|
||||
);
|
||||
final provision = AppKioskProvisionResult.fromMap(data);
|
||||
_kioskStatus = provision.status;
|
||||
return provision;
|
||||
} on PlatformException catch (error) {
|
||||
return AppKioskProvisionResult(
|
||||
success: false,
|
||||
code: error.code,
|
||||
message: error.message ?? 'Kiosk 启用失败,请稍后重试',
|
||||
status: _kioskStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行带原生 5 秒超时的 `su -c id`,只有退出码为零且输出确认 uid=0 才返回可用。
|
||||
static Future<RootAccessCheckResult> checkRootAccess() async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'checkRootAccess',
|
||||
);
|
||||
return RootAccessCheckResult.fromMap(data);
|
||||
} on PlatformException catch (error) {
|
||||
return RootAccessCheckResult(
|
||||
status: RootAccessStatus.unavailable,
|
||||
checkedAt: DateTime.now(),
|
||||
diagnostic: error.message ?? 'Root 检查通道不可用',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 由 Android PackageManager 校验 APK 包名、签名和目标版本码。
|
||||
static Future<AppApkOperationResult> validateApk({
|
||||
required String path,
|
||||
required int expectedVersionCode,
|
||||
}) async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'validateApk',
|
||||
{'path': path, 'expectedVersionCode': expectedVersionCode},
|
||||
);
|
||||
return AppApkOperationResult.fromMap(data);
|
||||
} on PlatformException catch (error) {
|
||||
return AppApkOperationResult(
|
||||
success: false,
|
||||
code: error.code,
|
||||
message: error.message ?? 'APK 校验失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装前由原生层再次校验 Root 与 APK,并执行不降级、不卸载数据的 `pm install -r`。
|
||||
static Future<AppApkOperationResult> installApkWithRoot({
|
||||
required String path,
|
||||
required int expectedVersionCode,
|
||||
}) async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'installApkWithRoot',
|
||||
{'path': path, 'expectedVersionCode': expectedVersionCode},
|
||||
);
|
||||
return AppApkOperationResult.fromMap(data);
|
||||
} on PlatformException catch (error) {
|
||||
return AppApkOperationResult(
|
||||
success: false,
|
||||
code: error.code,
|
||||
message: error.message ?? 'APK 安装失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动时消费原生待更新状态,确认版本后原生层同步清理私有 APK。
|
||||
static Future<AppUpdateRecoveryResult> consumePendingUpdate() async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'consumePendingUpdate',
|
||||
);
|
||||
return AppUpdateRecoveryResult.fromMap(data);
|
||||
} on PlatformException catch (error) {
|
||||
return AppUpdateRecoveryResult(
|
||||
pending: false,
|
||||
success: false,
|
||||
message: error.message ?? '自动更新恢复检查失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 只有原生层确认 Device Owner/Kiosk 可用时才允许退出到系统桌面。
|
||||
static Future<bool> exitKioskToAndroidHome() async {
|
||||
if (!_kioskStatus.canExitToAndroidHome) return false;
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('exitKioskToHome') ?? false;
|
||||
} on PlatformException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<AppNetworkSnapshot> getNetworkSnapshot() async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'getNetworkSnapshot',
|
||||
);
|
||||
return AppNetworkSnapshot.fromMap(data);
|
||||
} on PlatformException {
|
||||
return const AppNetworkSnapshot(
|
||||
connected: false,
|
||||
type: 'offline',
|
||||
receivedBytes: 0,
|
||||
transmittedBytes: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取一次 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 {
|
||||
return await _channel.invokeMethod<bool>('isSubScreenShowing') ?? false;
|
||||
} on PlatformException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 单向转发业务状态到副屏 Engine,原生层转发后立即回包。
|
||||
///
|
||||
/// 锁定的双屏插件不会结束普通业务消息的主屏 MethodChannel 请求,因此人脸等
|
||||
/// 硬件主链路必须使用此入口,避免等待副屏回包导致 Camera 初始化被永久阻塞。
|
||||
static Future<bool> sendViceMessage(
|
||||
String method, {
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
return await _channel.invokeMethod<bool>('sendViceMessage', {
|
||||
'method': method,
|
||||
'params': params ?? <String, dynamic>{},
|
||||
}) ??
|
||||
false;
|
||||
}
|
||||
|
||||
static void applyManualHardwareInput(bool enabled) {
|
||||
_manualHardwareInput = enabled;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,99 @@ abstract class AppSettingTool {
|
||||
static const String _tag = "app配置工具";
|
||||
|
||||
static const String _settingKey = "setting";
|
||||
static const String _terminalMenuOrderKey = "terminalMenuOrder";
|
||||
static const String _hiddenTerminalMenusKey = "hiddenTerminalMenus";
|
||||
static const String _payTypeOrderKey = "payTypeOrder";
|
||||
|
||||
/// 收银支付方式允许重新识别人员的编码列表;缺失时使用默认集合,空列表表示全部关闭。
|
||||
static const String _cashierReidentifiablePayTypesKey =
|
||||
"cashierReidentifiablePayTypes";
|
||||
|
||||
/// 收银支付方式的默认重新识别范围;旧配置缺少对应键及用户执行“还原”时均采用该集合。
|
||||
static const Set<OrderPayDisplayType> defaultCashierReidentifiablePayTypes = {
|
||||
// 饭卡支付允许重新刷卡,以便纠正首次识别到的人员。
|
||||
OrderPayDisplayType.walletCard,
|
||||
// 人脸支付允许重新调用摄像头,以便纠正首次识别到的人员。
|
||||
OrderPayDisplayType.face,
|
||||
// 中医院属于线下渠道,默认允许通过饭卡补充人员身份。
|
||||
OrderPayDisplayType.zyy,
|
||||
// 人民医院属于线下渠道,默认允许通过饭卡补充人员身份。
|
||||
OrderPayDisplayType.rmyy,
|
||||
// 超市消费卡属于线下渠道,默认允许通过饭卡补充人员身份。
|
||||
OrderPayDisplayType.csxfk,
|
||||
};
|
||||
static const String _cashierShowHoldActionKey = "cashierShowHoldAction";
|
||||
static const String _cashierShowMoreActionKey = "cashierShowMoreAction";
|
||||
|
||||
/// 收银页是否允许主动修改餐段的稳定存储键;缺失时按允许处理,兼容旧终端。
|
||||
static const String _cashierAllowManualMealPeriodChangeKey =
|
||||
"cashierAllowManualMealPeriodChange";
|
||||
|
||||
/// 顶部隐藏设备编码列表在现有 sysJson 中的稳定存储键;缺失或空列表表示五项全部显示。
|
||||
static const String _hiddenTerminalDeviceStatusesKey =
|
||||
"hiddenTerminalDeviceStatuses";
|
||||
|
||||
/// 旧版顶部设备状态总开关键,仅在保存逐项配置时清理,读取时不再用于隐藏全部设备。
|
||||
static const String _legacyTerminalDeviceStatusVisibleKey =
|
||||
"terminalDeviceStatusVisible";
|
||||
static const String _reservationQueryMethodOrderKey =
|
||||
"reservationQueryMethodOrder";
|
||||
static const String _reservationQueryDefaultMethodKey =
|
||||
"reservationQueryDefaultMethod";
|
||||
|
||||
/// 查询菜单自动识别开关在现有 sysJson 中的稳定存储键,缺失时按关闭处理。
|
||||
static const String _reservationQueryAutoRecognitionEnabledKey =
|
||||
"reservationQueryAutoRecognitionEnabled";
|
||||
|
||||
/// 查询菜单持续自动识别开关在现有 sysJson 中的稳定存储键,缺失时按开启处理。
|
||||
static const String _reservationQueryContinuousRecognitionEnabledKey =
|
||||
"reservationQueryContinuousRecognitionEnabled";
|
||||
static const String _reservationQueryNameMaskEnabledKey =
|
||||
"reservationQueryNameMaskEnabled";
|
||||
static const String _reservationQueryVerificationEnabledKey =
|
||||
"reservationQueryVerificationEnabled";
|
||||
|
||||
/// 查询菜单快速核销开关在现有 sysJson 中的稳定存储键,缺失时按关闭处理。
|
||||
static const String _reservationQueryQuickVerificationEnabledKey =
|
||||
"reservationQueryQuickVerificationEnabled";
|
||||
|
||||
/// 查询菜单是否允许主动修改餐段的稳定存储键;缺失时按允许处理,兼容旧终端。
|
||||
static const String _reservationQueryAllowManualMealPeriodChangeKey =
|
||||
"reservationQueryAllowManualMealPeriodChange";
|
||||
|
||||
/// 查询菜单最近餐段在现有 sysJson 中的稳定存储键,值使用后端餐段编码且不新增数据库字段。
|
||||
static const String _reservationQuerySelectedMealSlotKey =
|
||||
"reservationQuerySelectedMealSlot";
|
||||
static const String _reservationQueryVerificationDateScopeKey =
|
||||
"reservationQueryVerificationDateScope";
|
||||
|
||||
/// 早餐、中餐和晚餐的本地时间范围;值使用现有 ProductHourType 后端编码。
|
||||
static const String _productMealPeriodsKey = "productMealPeriods";
|
||||
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";
|
||||
|
||||
/// Root 状态在 sysJson 中的稳定键,只允许由局部串行更新方法写入。
|
||||
static const String _rootAccessStatusKey = 'rootAccessStatus';
|
||||
|
||||
/// Root 最近检查时间在 sysJson 中的稳定键,使用 ISO-8601 字符串持久化。
|
||||
static const String _rootAccessCheckedAtKey = 'rootAccessCheckedAt';
|
||||
|
||||
/// Root 诊断摘要在 sysJson 中的稳定键,不保存完整 shell 输出。
|
||||
static const String _rootAccessDiagnosticKey = 'rootAccessDiagnostic';
|
||||
|
||||
// 自定义缓存管理器
|
||||
static final CacheManager _settingCache = CacheManager(
|
||||
@@ -21,6 +114,24 @@ abstract class AppSettingTool {
|
||||
),
|
||||
);
|
||||
|
||||
/// setting 单行记录的进程内写入队列,避免 Root 检查、登录刷新和设置页保存相互覆盖。
|
||||
static Future<void> _settingWriteQueue = Future.value();
|
||||
|
||||
///
|
||||
/// 将 setting 的“读取最新值并写回”过程串行执行。
|
||||
///
|
||||
/// setting 表把用户、系统和食堂扩展配置保存在同一行,仅读写前排队才能避免不同字段的
|
||||
/// 并发更新互相覆盖;业务异常仍返回原调用方,后续任务会忽略前一次失败继续执行。
|
||||
static Future<T> _serializeSettingWrite<T>(
|
||||
Future<T> Function() action,
|
||||
) {
|
||||
final operation = _settingWriteQueue.catchError((_) {}).then(
|
||||
(_) => action(),
|
||||
);
|
||||
_settingWriteQueue = operation.then<void>((_) {}).catchError((_) {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
static Future<void> removeSettingCache() async {
|
||||
await _settingCache.emptyCache();
|
||||
}
|
||||
@@ -65,39 +176,73 @@ abstract class AppSettingTool {
|
||||
SysSetting? sysJson,
|
||||
UserSetting? userJson,
|
||||
CanteenExtraPriceSetting? canteenExtraPriceConfig,
|
||||
}) async {
|
||||
if (ObjectUtil.isEmpty(sysJson) &&
|
||||
ObjectUtil.isEmpty(userJson) &&
|
||||
ObjectUtil.isEmpty(canteenExtraPriceConfig)) {
|
||||
return false;
|
||||
}
|
||||
final app = Get.find<AppControl>();
|
||||
final settingDao = app.dataBase.getSettingDao;
|
||||
SettingEntity? setting = await settingDao.get();
|
||||
if (setting == null) {
|
||||
logger.e("|$_tag|_setSetting新增错误settingDao.get()方法返回为null");
|
||||
return false;
|
||||
}
|
||||
if (sysJson != null) {
|
||||
setting.sysJson = json.encode(sysJson.toJson());
|
||||
}
|
||||
if (userJson != null) {
|
||||
setting.userJson = json.encode(userJson.toJson());
|
||||
}
|
||||
if (canteenExtraPriceConfig != null) {
|
||||
setting.canteenExtraPriceConfigJson = json.encode(
|
||||
canteenExtraPriceConfig.toJson(),
|
||||
);
|
||||
}
|
||||
await settingDao.edit(setting);
|
||||
}) =>
|
||||
_serializeSettingWrite(() async {
|
||||
if (ObjectUtil.isEmpty(sysJson) &&
|
||||
ObjectUtil.isEmpty(userJson) &&
|
||||
ObjectUtil.isEmpty(canteenExtraPriceConfig)) {
|
||||
return false;
|
||||
}
|
||||
final app = Get.find<AppControl>();
|
||||
final settingDao = app.dataBase.getSettingDao;
|
||||
final setting = await settingDao.get();
|
||||
if (setting == null) {
|
||||
logger.e("|$_tag|_setSetting新增错误settingDao.get()方法返回为null");
|
||||
return false;
|
||||
}
|
||||
if (sysJson != null) {
|
||||
final current =
|
||||
Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
final updated = sysJson.toJson();
|
||||
// Root 状态只归后台检查写入;普通设置使用调用前快照时不能回写过期的 Root 值。
|
||||
for (final key in const [
|
||||
_rootAccessStatusKey,
|
||||
_rootAccessCheckedAtKey,
|
||||
_rootAccessDiagnosticKey,
|
||||
]) {
|
||||
if (current.containsKey(key)) updated[key] = current[key];
|
||||
}
|
||||
// sysJson 允许保存不涉及数据库结构的终端 UI 扩展配置,更新业务设置时必须保留这些键。
|
||||
setting.sysJson = json.encode({...current, ...updated});
|
||||
}
|
||||
if (userJson != null) {
|
||||
setting.userJson = json.encode(userJson.toJson());
|
||||
}
|
||||
if (canteenExtraPriceConfig != null) {
|
||||
setting.canteenExtraPriceConfigJson = json.encode(
|
||||
canteenExtraPriceConfig.toJson(),
|
||||
);
|
||||
}
|
||||
await settingDao.edit(setting);
|
||||
await _cacheSetting(setting);
|
||||
return true;
|
||||
});
|
||||
|
||||
/// 刷新 setting 单行缓存;调用方必须已经完成数据库写入并持有串行写入队列。
|
||||
static Future<void> _cacheSetting(SettingEntity setting) async {
|
||||
await _settingCache.putFile(
|
||||
_settingKey,
|
||||
Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))),
|
||||
fileExtension: 'json',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 在串行队列内读取并局部修改最新 sysJson,未涉及的业务键与同一行其他列保持不变。
|
||||
static Future<void> _updateSysJson(
|
||||
void Function(Map<String, dynamic> sysJson) update,
|
||||
) =>
|
||||
_serializeSettingWrite(() 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));
|
||||
update(sysJson);
|
||||
setting.sysJson = json.encode(sysJson);
|
||||
await settingDao.edit(setting);
|
||||
await _cacheSetting(setting);
|
||||
});
|
||||
|
||||
static Future<SysSetting> getSysSetting() async {
|
||||
final setting = await _getSetting();
|
||||
return SysSetting.fromJson(json.decode(setting.sysJson));
|
||||
@@ -122,6 +267,123 @@ abstract class AppSettingTool {
|
||||
await _setSetting(sysJson: sysInfo);
|
||||
}
|
||||
|
||||
/// 保存全局餐段选择到 SysSetting;不新增字段,兼容所有已有终端缓存。
|
||||
static Future<void> setSelectedProductHourType(ProductHourType hourType) =>
|
||||
_serializeSettingWrite(() 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['hourType'] = hourType.name;
|
||||
setting.sysJson = json.encode(sysJson);
|
||||
await settingDao.edit(setting);
|
||||
await _cacheSetting(setting);
|
||||
});
|
||||
|
||||
/// 串行更新 Root 专属键,始终在实际写入前重新读取 sysJson,避免后台检查覆盖设置页并发修改。
|
||||
static Future<void> setRootAccessResult(RootAccessCheckResult result) =>
|
||||
_serializeSettingWrite(() 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[_rootAccessStatusKey] = result.status.name;
|
||||
sysJson[_rootAccessCheckedAtKey] = result.checkedAt.toIso8601String();
|
||||
sysJson[_rootAccessDiagnosticKey] = result.diagnostic;
|
||||
setting.sysJson = json.encode(sysJson);
|
||||
await settingDao.edit(setting);
|
||||
await _cacheSetting(setting);
|
||||
});
|
||||
|
||||
/// 读取完整 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,
|
||||
}) =>
|
||||
_serializeSettingWrite(() 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 _cacheSetting(setting);
|
||||
});
|
||||
|
||||
static Future<void> setUserSetting(UserSetting userInfo) async {
|
||||
final globalUserInfo = await getUserSetting();
|
||||
if (globalUserInfo == userInfo) return;
|
||||
@@ -140,6 +402,360 @@ abstract class AppSettingTool {
|
||||
await _setSetting(canteenExtraPriceConfig: canteenExtraPriceConfig);
|
||||
}
|
||||
|
||||
/// 读取左侧菜单的顺序和隐藏状态,未知或缺失项由控制器按默认菜单补齐。
|
||||
static Future<({List<String> order, Set<String> hidden})>
|
||||
getTerminalMenuPreferences() async {
|
||||
final setting = await _getSetting();
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
final order = (sysJson[_terminalMenuOrderKey] as List<dynamic>?)
|
||||
?.whereType<String>()
|
||||
.toList() ??
|
||||
const <String>[];
|
||||
final hidden = (sysJson[_hiddenTerminalMenusKey] as List<dynamic>?)
|
||||
?.whereType<String>()
|
||||
.toSet() ??
|
||||
const <String>{};
|
||||
return (order: order, hidden: hidden);
|
||||
}
|
||||
|
||||
/// 菜单布局属于设备级 UI 设置,直接复用 setting 表中的 sysJson,避免增加数据库字段和迁移。
|
||||
static Future<void> setTerminalMenuPreferences({
|
||||
required List<String> order,
|
||||
required Set<String> hidden,
|
||||
}) =>
|
||||
_updateSysJson((sysJson) {
|
||||
sysJson[_terminalMenuOrderKey] = order;
|
||||
sysJson[_hiddenTerminalMenusKey] = hidden.toList();
|
||||
});
|
||||
|
||||
/// 读取支付方式的完整顺序;显隐仍由 SysSetting.usePayDisplayType 维护。
|
||||
static Future<List<String>> getPayTypeOrder() async {
|
||||
final setting = await _getSetting();
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
return (sysJson[_payTypeOrderKey] as List<dynamic>?)
|
||||
?.whereType<String>()
|
||||
.toList() ??
|
||||
const <String>[];
|
||||
}
|
||||
|
||||
/// 读取允许重新识别人员的支付方式;缺少或非列表配置使用默认值,显式空列表保持全部关闭。
|
||||
static Future<Set<OrderPayDisplayType>>
|
||||
getCashierReidentifiablePayTypes() async {
|
||||
final setting = await _getSetting();
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
final payTypeByCode = {
|
||||
for (final payType in OrderPayDisplayType.values) payType.code: payType,
|
||||
};
|
||||
final source = sysJson[_cashierReidentifiablePayTypesKey];
|
||||
if (source is! List) {
|
||||
return defaultCashierReidentifiablePayTypes.toSet();
|
||||
}
|
||||
return source
|
||||
.whereType<String>()
|
||||
.map((code) => payTypeByCode[code])
|
||||
.whereType<OrderPayDisplayType>()
|
||||
.toSet();
|
||||
}
|
||||
|
||||
/// 支付排序、显隐、默认方式及重新识别能力作为同一份终端支付偏好原子保存。
|
||||
static Future<void> setPayTypePreferences({
|
||||
required SysSetting sysInfo,
|
||||
required List<OrderPayDisplayType> order,
|
||||
required Set<OrderPayDisplayType> reidentifiableTypes,
|
||||
}) {
|
||||
// 在首次异步等待前生成快照,避免连续拖动时后一次修改污染本次落盘内容。
|
||||
final nextSysJson = sysInfo.toJson();
|
||||
final visiblePayTypes = nextSysJson['usePayDisplayType'];
|
||||
final defaultPayType = nextSysJson['defaultPayDisplayType'];
|
||||
final orderCodes = order.map((item) => item.code).toList();
|
||||
final reidentifiableCodes = OrderPayDisplayType.values
|
||||
.where(reidentifiableTypes.contains)
|
||||
.map((item) => item.code)
|
||||
.toList();
|
||||
return _updateSysJson((sysJson) {
|
||||
sysJson['usePayDisplayType'] = visiblePayTypes;
|
||||
sysJson['defaultPayDisplayType'] = defaultPayType;
|
||||
sysJson[_payTypeOrderKey] = orderCodes;
|
||||
sysJson[_cashierReidentifiablePayTypesKey] = reidentifiableCodes;
|
||||
});
|
||||
}
|
||||
|
||||
/// 读取收银页底部快捷操作显隐及餐段修改权限;旧终端没有配置时保持默认允许。
|
||||
static Future<
|
||||
({
|
||||
bool showHoldAction,
|
||||
bool showMoreAction,
|
||||
bool allowManualMealPeriodChange,
|
||||
})> getCashierPagePreferences() async {
|
||||
final setting = await _getSetting();
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
return (
|
||||
showHoldAction: sysJson[_cashierShowHoldActionKey] != false,
|
||||
showMoreAction: sysJson[_cashierShowMoreActionKey] != false,
|
||||
allowManualMealPeriodChange:
|
||||
sysJson[_cashierAllowManualMealPeriodChangeKey] != false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 收银快捷操作与餐段修改权限属于本机页面布局配置,继续复用 sysJson。
|
||||
static Future<void> setCashierPagePreferences({
|
||||
required bool showHoldAction,
|
||||
required bool showMoreAction,
|
||||
required bool allowManualMealPeriodChange,
|
||||
}) =>
|
||||
_updateSysJson((sysJson) {
|
||||
sysJson[_cashierShowHoldActionKey] = showHoldAction;
|
||||
sysJson[_cashierShowMoreActionKey] = showMoreAction;
|
||||
sysJson[_cashierAllowManualMealPeriodChangeKey] =
|
||||
allowManualMealPeriodChange;
|
||||
});
|
||||
|
||||
/// 读取商品餐段时间;旧终端无配置或持久化值损坏时返回产品默认时间。
|
||||
static Future<ProductMealPeriodSchedule>
|
||||
getProductMealPeriodSchedule() async {
|
||||
final setting = await _getSetting();
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
return ProductMealPeriodSchedule.fromJson(sysJson[_productMealPeriodsKey]);
|
||||
}
|
||||
|
||||
/// 保存经过统一校验的基础三餐时间,不增加 setting 表字段和数据库迁移。
|
||||
static Future<void> setProductMealPeriodSchedule(
|
||||
ProductMealPeriodSchedule schedule,
|
||||
) =>
|
||||
_updateSysJson((sysJson) {
|
||||
sysJson[_productMealPeriodsKey] = schedule.toJson();
|
||||
});
|
||||
|
||||
/// 按本机当前时间取得配置餐段;未覆盖时段返回空,调用方可保留人工选择。
|
||||
static Future<ProductHourType?> getCurrentProductHourType({
|
||||
DateTime? now,
|
||||
}) async {
|
||||
final schedule = await getProductMealPeriodSchedule();
|
||||
return schedule.resolve(now ?? DateTime.now());
|
||||
}
|
||||
|
||||
/// 读取顶部需要隐藏的设备类型;缺键、异常值和未知编码均忽略,因此升级后默认五项全部显示。
|
||||
static Future<Set<TerminalDeviceType>>
|
||||
getHiddenTerminalDeviceStatuses() async {
|
||||
final setting = await _getSetting();
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
final source = sysJson[_hiddenTerminalDeviceStatusesKey];
|
||||
if (source is! List) return <TerminalDeviceType>{};
|
||||
final deviceByCode = {
|
||||
for (final device in TerminalDeviceType.values) device.code: device,
|
||||
};
|
||||
return source
|
||||
.whereType<String>()
|
||||
.map((code) => deviceByCode[code])
|
||||
.whereType<TerminalDeviceType>()
|
||||
.toSet();
|
||||
}
|
||||
|
||||
/// 保存逐项隐藏设备编码并移除旧总开关;新设备因不在隐藏列表中会自动显示。
|
||||
static Future<void> setHiddenTerminalDeviceStatuses(
|
||||
Set<TerminalDeviceType> hiddenDevices,
|
||||
) {
|
||||
final hiddenCodes = TerminalDeviceType.values
|
||||
.where(hiddenDevices.contains)
|
||||
.map((device) => device.code)
|
||||
.toList(growable: false);
|
||||
return _updateSysJson((sysJson) {
|
||||
sysJson[_hiddenTerminalDeviceStatusesKey] = hiddenCodes;
|
||||
sysJson.remove(_legacyTerminalDeviceStatusVisibleKey);
|
||||
});
|
||||
}
|
||||
|
||||
/// 读写卡协议配置属于设备本地扩展设置,复用 sysJson 保存且不修改数据库结构。
|
||||
static Future<SerialCardConfig> getSerialCardConfig() async {
|
||||
final setting = await _getSetting();
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
final source = sysJson[_serialCardConfigKey];
|
||||
if (source is! Map) return const SerialCardConfig();
|
||||
try {
|
||||
return SerialCardConfig.fromJson(Map<String, dynamic>.from(source));
|
||||
} on Object {
|
||||
// 旧版本或人工修改的异常配置不能阻断收银读卡,无法解析时回退设备默认参数。
|
||||
return const SerialCardConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/// 密钥只写入本机设置和串口模块,不记录到日志,也不随普通业务接口上传。
|
||||
static Future<void> setSerialCardConfig(SerialCardConfig config) =>
|
||||
_updateSysJson((sysJson) {
|
||||
sysJson[_serialCardConfigKey] = config.toJson();
|
||||
});
|
||||
|
||||
/// 查询方式顺序和默认项属于页面交互配置,继续保存在 sysJson 中,不增加数据库字段。
|
||||
static Future<ReservationQueryPagePreferences>
|
||||
getReservationQueryPagePreferences() async {
|
||||
final setting = await _getSetting();
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
final byCode = {
|
||||
for (final method in ReservationRecognitionMethod.values)
|
||||
method.code: method,
|
||||
};
|
||||
final restored = <ReservationRecognitionMethod>[];
|
||||
final storedOrder =
|
||||
(sysJson[_reservationQueryMethodOrderKey] as List<dynamic>?)
|
||||
?.whereType<String>() ??
|
||||
const <String>[];
|
||||
for (final code in storedOrder) {
|
||||
final method = byCode[code];
|
||||
if (method != null && !restored.contains(method)) restored.add(method);
|
||||
}
|
||||
for (final method in ReservationRecognitionMethod.values) {
|
||||
if (!restored.contains(method)) restored.add(method);
|
||||
}
|
||||
final storedDefault =
|
||||
byCode[sysJson[_reservationQueryDefaultMethodKey] as String?];
|
||||
final defaultMethod =
|
||||
storedDefault != null && restored.contains(storedDefault)
|
||||
? storedDefault
|
||||
: restored.first;
|
||||
final verificationDateScope = ReservationVerificationDateScope.values
|
||||
.where(
|
||||
(scope) =>
|
||||
scope.code ==
|
||||
sysJson[_reservationQueryVerificationDateScopeKey],
|
||||
)
|
||||
.firstOrNull ??
|
||||
ReservationVerificationDateScope.today;
|
||||
final verificationEnabled =
|
||||
sysJson[_reservationQueryVerificationEnabledKey] == true;
|
||||
final selectedMealSlot = ReservationMealSlot.querySelectableValues
|
||||
.where(
|
||||
(slot) =>
|
||||
slot.code == sysJson[_reservationQuerySelectedMealSlotKey],
|
||||
)
|
||||
.firstOrNull ??
|
||||
ReservationMealSlot.morning;
|
||||
return ReservationQueryPagePreferences(
|
||||
methodOrder: List.unmodifiable(restored),
|
||||
defaultMethod: defaultMethod,
|
||||
// 旧设置没有该键时保持手动启动,避免升级后直接拉起读卡器或相机。
|
||||
autoRecognitionEnabled:
|
||||
sysJson[_reservationQueryAutoRecognitionEnabledKey] == true,
|
||||
// 自动识别的设计目标是持续接待,旧设置没有该键时默认续开正常等待超时。
|
||||
continuousRecognitionEnabled:
|
||||
sysJson[_reservationQueryContinuousRecognitionEnabledKey] != false,
|
||||
// 旧设置没有该键时继续脱敏,避免升级后意外展示完整姓名。
|
||||
nameMaskEnabled: sysJson[_reservationQueryNameMaskEnabledKey] != false,
|
||||
verificationEnabled: verificationEnabled,
|
||||
// 快速核销从属于核销入口,旧设置缺键或核销关闭时均不得隐式生效。
|
||||
quickVerificationEnabled: verificationEnabled &&
|
||||
sysJson[_reservationQueryQuickVerificationEnabledKey] == true,
|
||||
// 旧设置没有该键时保持允许手动修改,避免升级后收银流程被意外锁死。
|
||||
allowManualMealPeriodChange:
|
||||
sysJson[_reservationQueryAllowManualMealPeriodChangeKey] != false,
|
||||
selectedMealSlot: selectedMealSlot,
|
||||
verificationDateScope: verificationDateScope,
|
||||
);
|
||||
}
|
||||
|
||||
/// 顺序与默认项一起保存,避免连续操作后出现“默认项不在当前列表中”的矛盾状态。
|
||||
static Future<void> setReservationQueryPagePreferences({
|
||||
required List<ReservationRecognitionMethod> order,
|
||||
required ReservationRecognitionMethod defaultMethod,
|
||||
required bool autoRecognitionEnabled,
|
||||
required bool continuousRecognitionEnabled,
|
||||
required bool nameMaskEnabled,
|
||||
required bool verificationEnabled,
|
||||
required bool quickVerificationEnabled,
|
||||
required bool allowManualMealPeriodChange,
|
||||
required ReservationVerificationDateScope verificationDateScope,
|
||||
}) {
|
||||
final normalized = <ReservationRecognitionMethod>[];
|
||||
for (final method in order) {
|
||||
if (!normalized.contains(method)) normalized.add(method);
|
||||
}
|
||||
for (final method in ReservationRecognitionMethod.values) {
|
||||
if (!normalized.contains(method)) normalized.add(method);
|
||||
}
|
||||
final normalizedDefault =
|
||||
normalized.contains(defaultMethod) ? defaultMethod : normalized.first;
|
||||
return _updateSysJson((sysJson) {
|
||||
sysJson[_reservationQueryMethodOrderKey] =
|
||||
normalized.map((method) => method.code).toList();
|
||||
sysJson[_reservationQueryDefaultMethodKey] = normalizedDefault.code;
|
||||
sysJson[_reservationQueryAutoRecognitionEnabledKey] =
|
||||
autoRecognitionEnabled;
|
||||
sysJson[_reservationQueryContinuousRecognitionEnabledKey] =
|
||||
continuousRecognitionEnabled;
|
||||
sysJson[_reservationQueryNameMaskEnabledKey] = nameMaskEnabled;
|
||||
sysJson[_reservationQueryVerificationEnabledKey] = verificationEnabled;
|
||||
sysJson[_reservationQueryQuickVerificationEnabledKey] =
|
||||
verificationEnabled && quickVerificationEnabled;
|
||||
sysJson[_reservationQueryAllowManualMealPeriodChangeKey] =
|
||||
allowManualMealPeriodChange;
|
||||
sysJson[_reservationQueryVerificationDateScopeKey] =
|
||||
verificationDateScope.code;
|
||||
});
|
||||
}
|
||||
|
||||
/// 保存查询菜单最近选中的基础三餐,并刷新现有 setting 文件缓存供下次进入页面恢复。
|
||||
static Future<void> setReservationQuerySelectedMealSlot(
|
||||
ReservationMealSlot mealSlot,
|
||||
) {
|
||||
if (!ReservationMealSlot.querySelectableValues.contains(mealSlot)) {
|
||||
throw ArgumentError.value(mealSlot, 'mealSlot', '仅允许缓存早餐、午餐或晚餐');
|
||||
}
|
||||
return _updateSysJson((sysJson) {
|
||||
sysJson[_reservationQuerySelectedMealSlotKey] = mealSlot.code;
|
||||
});
|
||||
}
|
||||
|
||||
/// 读取指定“路由 + 功能键”场景最近使用的人工硬件输入,异常旧数据会被忽略。
|
||||
static Future<List<HardwareInputHistoryEntry>> getHardwareInputHistory(
|
||||
String sceneKey,
|
||||
) async {
|
||||
final store = await _getHardwareInputHistoryStore();
|
||||
return List.unmodifiable(store.entriesFor(sceneKey));
|
||||
}
|
||||
|
||||
/// 保存当前场景最近 5 条输入;相同输入只更新备注和时间并置顶,不产生重复记录。
|
||||
static Future<void> saveHardwareInputHistory({
|
||||
required String sceneKey,
|
||||
required String value,
|
||||
String? remark,
|
||||
}) async {
|
||||
final normalizedValue = value.trim();
|
||||
if (normalizedValue.isEmpty) return;
|
||||
await _updateSysJson((sysJson) {
|
||||
final store = _decodeHardwareInputHistoryStore(
|
||||
sysJson[_hardwareInputHistoryKey],
|
||||
);
|
||||
final nextStore = store.save(
|
||||
sceneKey: sceneKey,
|
||||
value: normalizedValue,
|
||||
remark: remark ?? '',
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
sysJson[_hardwareInputHistoryKey] = nextStore.toJson();
|
||||
});
|
||||
}
|
||||
|
||||
/// 从现有 sysJson 扩展配置读取强类型历史模型,单条异常数据不影响人工输入主流程。
|
||||
static Future<HardwareInputHistoryStore>
|
||||
_getHardwareInputHistoryStore() async {
|
||||
final setting = await _getSetting();
|
||||
final sysJson = Map<String, dynamic>.from(json.decode(setting.sysJson));
|
||||
return _decodeHardwareInputHistoryStore(sysJson[_hardwareInputHistoryKey]);
|
||||
}
|
||||
|
||||
/// JSON Map 仅停留在设置存储边界,弹窗与去重逻辑统一操作强类型模型。
|
||||
static HardwareInputHistoryStore _decodeHardwareInputHistoryStore(
|
||||
Object? source,
|
||||
) {
|
||||
if (source is! Map) return HardwareInputHistoryStore.empty;
|
||||
try {
|
||||
return HardwareInputHistoryStore.fromJson(
|
||||
Map<String, dynamic>.from(source),
|
||||
);
|
||||
} on Object {
|
||||
return HardwareInputHistoryStore.empty;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据后台返回的数据更新到缓存
|
||||
static Future<void> updateSysInfoByService(ServiceHardware data) async {
|
||||
if (ObjectUtil.isEmpty(data)) {
|
||||
@@ -223,6 +839,50 @@ abstract class AppSettingTool {
|
||||
await _setSetting(sysJson: sysSetting);
|
||||
}
|
||||
|
||||
/// 将当前业务终端的打印机绑定和打印参数持久化到本机;空响应表示尚未绑定。
|
||||
static Future<SysSetting> updatePrinterBinding(
|
||||
Map<String, dynamic>? binding,
|
||||
) async {
|
||||
final setting = await getSysSetting();
|
||||
if (binding == null) {
|
||||
setting.printerHardId = null;
|
||||
setting.printerHardName = null;
|
||||
setting.printerModel = null;
|
||||
setting.printerUsbVendorId = null;
|
||||
setting.printerUsbProductId = null;
|
||||
setting.printerPaperWidth = 80;
|
||||
setting.printerAutoCut = true;
|
||||
setting.printerImageMode = 'GS_V0';
|
||||
await _setSetting(sysJson: setting);
|
||||
return setting;
|
||||
}
|
||||
final sourceHardId = binding['sourceHardId']?.toString();
|
||||
if (sourceHardId != setting.serviceId) {
|
||||
throw const FormatException('服务端打印机绑定与当前业务终端不匹配');
|
||||
}
|
||||
final sourceType = SysType.values.fromCode(
|
||||
binding['sourceHardType']?.toString() ?? '',
|
||||
);
|
||||
if (sourceType != setting.sysType) {
|
||||
throw const FormatException('服务端打印机绑定的业务类型与当前终端不匹配');
|
||||
}
|
||||
final config = binding['printerConfig'] is Map
|
||||
? Map<String, dynamic>.from(binding['printerConfig'] as Map)
|
||||
: <String, dynamic>{};
|
||||
setting.printerHardId = binding['printerHardId']?.toString();
|
||||
setting.printerHardName = binding['printerHardName']?.toString();
|
||||
setting.printerModel = config['printerModel']?.toString();
|
||||
setting.printerUsbVendorId = int.tryParse('${config['usbVendorId'] ?? ''}');
|
||||
setting.printerUsbProductId =
|
||||
int.tryParse('${config['usbProductId'] ?? ''}');
|
||||
setting.printerPaperWidth =
|
||||
int.tryParse('${config['paperWidth'] ?? ''}') ?? 80;
|
||||
setting.printerAutoCut = '${config['autoCut'] ?? '1'}' != '0';
|
||||
setting.printerImageMode = config['imageMode']?.toString() ?? 'GS_V0';
|
||||
await _setSetting(sysJson: setting);
|
||||
return setting;
|
||||
}
|
||||
|
||||
/// 绑定设备信息
|
||||
static Future<bool> bindSys(int serviceId) async {
|
||||
var androidId = await AppSysTool.getAndroidId();
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 统一维护副屏常驻状态,确保业务切换只发送页面消息,不重复重建 Presentation。
|
||||
abstract class AppSubScreenTool {
|
||||
static bool _available = false;
|
||||
static bool _presentationRequested = false;
|
||||
static Future<bool>? _showFuture;
|
||||
|
||||
static bool get isAvailable => _available;
|
||||
|
||||
static Future<bool> refreshAvailability({bool reportStatus = true}) async {
|
||||
await AppRuntimeTool.initializePlatform();
|
||||
if (AppRuntimeTool.isEmulator || !AppRuntimeTool.hasMultipleDisplays) {
|
||||
_available = false;
|
||||
_presentationRequested = false;
|
||||
AppDeviceStatusTool.updateSubScreenWorking(false);
|
||||
if (reportStatus) AppDeviceStatusTool.updateSubScreen(false);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final hasViceScreen = await SubScreenPlugin.isMultipleScreen;
|
||||
final hasOverlayPermission = await SubScreenPlugin.checkOverlayPermission;
|
||||
_available = hasViceScreen && hasOverlayPermission;
|
||||
if (!_available) {
|
||||
_presentationRequested = false;
|
||||
AppDeviceStatusTool.updateSubScreenWorking(false);
|
||||
}
|
||||
} catch (e) {
|
||||
_available = false;
|
||||
_presentationRequested = false;
|
||||
AppDeviceStatusTool.updateSubScreenWorking(false);
|
||||
logger.w("|副屏工具|检测副屏失败:$e");
|
||||
}
|
||||
if (reportStatus) AppDeviceStatusTool.updateSubScreen(_available);
|
||||
return _available;
|
||||
}
|
||||
|
||||
static Future<bool> show({bool verifyPresentation = false}) async {
|
||||
if (_presentationRequested && !verifyPresentation) {
|
||||
AppDeviceStatusTool.updateSubScreenWorking(true);
|
||||
return true;
|
||||
}
|
||||
final pending = _showFuture;
|
||||
if (pending != null) return pending;
|
||||
final future = _showOnce(verifyPresentation: verifyPresentation);
|
||||
_showFuture = future;
|
||||
try {
|
||||
return await future;
|
||||
} finally {
|
||||
if (identical(_showFuture, future)) _showFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> _showOnce({required bool verifyPresentation}) async {
|
||||
final available = await refreshAvailability();
|
||||
if (!available) return false;
|
||||
if (_presentationRequested && !verifyPresentation) return true;
|
||||
try {
|
||||
if (await AppRuntimeTool.isSubScreenShowing()) {
|
||||
_presentationRequested = true;
|
||||
AppDeviceStatusTool.updateSubScreenWorking(true);
|
||||
return true;
|
||||
}
|
||||
// 插件 showSubDisplay() 会先关闭旧 Presentation;仅首次确认可用时调用,
|
||||
// 后续业务入口只复用常驻副屏,避免顾客屏反复黑屏或闪烁。
|
||||
SubScreenPlugin.doubleScreenShow();
|
||||
_presentationRequested = true;
|
||||
AppDeviceStatusTool.updateSubScreenWorking(true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_presentationRequested = false;
|
||||
_available = false;
|
||||
AppDeviceStatusTool.updateSubScreenWorking(false);
|
||||
AppDeviceStatusTool.updateSubScreen(false);
|
||||
logger.w("|副屏工具|打开副屏失败:$e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容旧调用入口。应用运行期间禁止业务代码关闭 Presentation,
|
||||
/// Android Activity 真正销毁时仍由原生层统一释放副屏窗口和 Engine。
|
||||
static void hide() {
|
||||
if (!_available) return;
|
||||
logger.i("|副屏工具|忽略业务关闭请求,副屏保持常驻");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 应用级餐段状态;收银、查询菜单和其他餐段入口都复用同一个选择器与缓存值。
|
||||
class ProductHourTypeControl extends GetxController {
|
||||
/// 创建默认选中早餐的全局餐段控制器;真正进入业务前由 [initAsync] 恢复本机缓存。
|
||||
ProductHourTypeControl({
|
||||
ProductHourType initial = ProductHourType.MORNING,
|
||||
bool persistSelection = true,
|
||||
}) : _persistSelection = persistSelection,
|
||||
selected = initial.obs,
|
||||
selection = FRadioSelectGroupController<String>(value: initial.code) {
|
||||
selection.addValueListener(_handleSelectionChanged);
|
||||
}
|
||||
|
||||
/// 当前全局餐段,供需要强类型值的业务逻辑使用。
|
||||
final Rx<ProductHourType> selected;
|
||||
|
||||
/// 公共 UI 选择器使用的单选 Controller;页面不得自行创建餐段 Controller。
|
||||
final FRadioSelectGroupController<String> selection;
|
||||
|
||||
/// 首次缓存恢复任务;重复进入页面时复用同一个 Future,避免并发读取与重复迁移。
|
||||
Future<void>? _initializationFuture;
|
||||
|
||||
/// 内部同步强类型值与 Forui 选择器时暂时屏蔽监听器,防止重复持久化。
|
||||
bool _suppressPersistence = false;
|
||||
|
||||
/// 是否把选择写入终端设置;独立单测实例可关闭,应用级实例始终开启。
|
||||
final bool _persistSelection;
|
||||
|
||||
/// 当前餐段缓存恢复完成信号。
|
||||
Future<void> get initialization => _initializationFuture ?? Future.value();
|
||||
|
||||
/// 读取 SysSetting 中缓存的餐段;旧版本没有缓存时尝试迁移查询菜单的历史选择。
|
||||
Future<void> initAsync() {
|
||||
return _initializationFuture ??= _initialize();
|
||||
}
|
||||
|
||||
/// 执行一次缓存恢复,并兼容迁移旧版查询菜单保存的最近餐段。
|
||||
Future<void> _initialize() async {
|
||||
try {
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
var restored = setting.hourType;
|
||||
if (restored == null) {
|
||||
try {
|
||||
final preferences =
|
||||
await AppSettingTool.getReservationQueryPagePreferences();
|
||||
restored = ProductHourType.values.fromCode(
|
||||
preferences.selectedMealSlot.code,
|
||||
);
|
||||
} catch (_) {
|
||||
// 旧配置没有查询菜单缓存时继续使用早餐默认值。
|
||||
}
|
||||
}
|
||||
if (restored != null && restored != ProductHourType.TOTAL) {
|
||||
_setSelection(restored, persist: setting.hourType == null);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
logger.w('|餐段|恢复全局餐段缓存失败error:|$error|stack:|$stack|');
|
||||
}
|
||||
}
|
||||
|
||||
/// 由页面或公共组件请求切换餐段;可在初始化迁移时关闭持久化。
|
||||
void setSelected(ProductHourType next, {bool persist = true}) {
|
||||
if (next == ProductHourType.TOTAL || selected.value == next) return;
|
||||
_setSelection(next, persist: persist);
|
||||
}
|
||||
|
||||
/// 接收公共 Forui 选择器变化,立即更新全局值并异步写入本机缓存。
|
||||
void _handleSelectionChanged(Set<String> values) {
|
||||
if (_suppressPersistence) return;
|
||||
final next = ProductHourType.values.fromCode(values.firstOrNull);
|
||||
if (next == null || next == ProductHourType.TOTAL) return;
|
||||
if (selected.value == next) return;
|
||||
selected.value = next;
|
||||
if (_persistSelection) {
|
||||
unawaited(AppSettingTool.setSelectedProductHourType(next));
|
||||
}
|
||||
}
|
||||
|
||||
/// 原子同步全局强类型值和 UI 选择值,并按调用场景决定是否写入缓存。
|
||||
void _setSelection(ProductHourType next, {required bool persist}) {
|
||||
_suppressPersistence = true;
|
||||
try {
|
||||
selected.value = next;
|
||||
if (selection.value.firstOrNull != next.code) {
|
||||
selection.value = {next.code};
|
||||
}
|
||||
} finally {
|
||||
_suppressPersistence = false;
|
||||
}
|
||||
if (persist && _persistSelection) {
|
||||
unawaited(AppSettingTool.setSelectedProductHourType(next));
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用级控制器被显式销毁时释放选择器监听与 Forui 资源。
|
||||
@override
|
||||
void onClose() {
|
||||
selection.removeValueListener(_handleSelectionChanged);
|
||||
selection.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
library;
|
||||
|
||||
export './app/control/app_control.dart';
|
||||
export './app/control/product_hour_type_control.dart';
|
||||
export './app/control/app_theme_control.dart';
|
||||
|
||||
export './app/app_face_tool.dart';
|
||||
export './app/app_device_status_tool.dart';
|
||||
export './app/app_runtime_tool.dart';
|
||||
export './app/app_sub_screen_tool.dart';
|
||||
export './app/app_file_tool.dart';
|
||||
export './app/app_mq_tool.dart';
|
||||
export './app/app_network_tool.dart';
|
||||
@@ -38,6 +42,13 @@ export './plugs/face_discern_plugs.dart';
|
||||
export './router/router_observer_tool.dart';
|
||||
|
||||
export './sync/sync_check_tool.dart';
|
||||
export './sync/scheduled_task_storage_tool.dart';
|
||||
export './sync/terminal_heartbeat_tool.dart';
|
||||
export './sync/terminal_auto_update_tool.dart';
|
||||
export './sync/terminal_log_upload_tool.dart';
|
||||
export './sync/file_upload_record_tool.dart';
|
||||
export './sync/order_evidence_upload_tool.dart';
|
||||
export './sync/sync_idle_tool.dart';
|
||||
export './sync/sync_config_tool.dart';
|
||||
export './sync/sync_face_tool.dart';
|
||||
export './sync/sync_order_tool.dart';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
abstract class JsonTool {
|
||||
@@ -16,6 +18,28 @@ abstract class JsonTool {
|
||||
return int.tryParse(value);
|
||||
}
|
||||
|
||||
/// 兼容后端 Long 以字符串或数字返回的单值场景。
|
||||
static int anyToInt(Object? value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null) throw const FormatException('数字格式错误');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// 兼容可选 Long 以字符串、数字或空值返回的场景,空值保持为空。
|
||||
static int? anyToNullableInt(Object? value) {
|
||||
if (value == null) return null;
|
||||
return anyToInt(value);
|
||||
}
|
||||
|
||||
/// 兼容后端 BigDecimal 以字符串或数字返回的可选小数。
|
||||
static double? anyToNullableDouble(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) return value.toDouble();
|
||||
return double.tryParse(value.toString());
|
||||
}
|
||||
|
||||
static String? intToString(int? value) {
|
||||
if (value == null) return null;
|
||||
return value.toString();
|
||||
@@ -26,6 +50,33 @@ abstract class JsonTool {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/// 将后端必填 Long 或字符串标识转换为非空字符串;缺失或空值时在模型边界直接拒绝。
|
||||
static String anyToRequiredString(Object? value) {
|
||||
final result = anyToString(value)?.trim();
|
||||
if (result == null || result.isEmpty) {
|
||||
throw const FormatException('必填标识不能为空');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 将 Flutter 时间转换为 Java 后端 LocalDateTime 的统一请求格式,不携带时区和小数秒。
|
||||
static String dateTimeToServiceString(DateTime value) =>
|
||||
DateUtil.formatDate(value, format: DateFormats.full);
|
||||
|
||||
/// 后端为避免 19 位 Long 在部分客户端丢失精度,可能把 ID 序列化为字符串。
|
||||
/// 这里同时兼容字符串和数字,忽略无法安全转换的异常项。
|
||||
static List<int> anyListToIntList(Object? value) {
|
||||
if (value is! List) return const [];
|
||||
return value
|
||||
.map((item) {
|
||||
if (item is int) return item;
|
||||
if (item is num) return item.toInt();
|
||||
return int.tryParse(item.toString());
|
||||
})
|
||||
.whereType<int>()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? anyToMap(dynamic value) {
|
||||
if (value == null) return null;
|
||||
return dynamic as Map<String, dynamic>?;
|
||||
|
||||
@@ -7,10 +7,11 @@ import 'package:logger/logger.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
final _consoleOutput = ConsoleOutput();
|
||||
final logger = Logger(
|
||||
filter: AppLogFilter(),
|
||||
printer: customPrinter,
|
||||
output: ConsoleOutput(),
|
||||
output: _consoleOutput,
|
||||
);
|
||||
final customPrinter = PrettyPrinter(
|
||||
lineLength: 0,
|
||||
@@ -22,6 +23,123 @@ final customPrinter = PrettyPrinter(
|
||||
noBoxingByDefault: true,
|
||||
);
|
||||
|
||||
/// 设置页使用的日志尾部快照,只返回脱敏后的有限内容,避免大文件阻塞收银主线程。
|
||||
Future<AppLogPreview> readAppLogPreview({
|
||||
String? path,
|
||||
int maxBytes = 256 * 1024,
|
||||
int maxLines = 1000,
|
||||
}) async {
|
||||
await logger.init;
|
||||
return _consoleOutput.readPreview(
|
||||
path: path,
|
||||
maxBytes: maxBytes,
|
||||
maxLines: maxLines,
|
||||
);
|
||||
}
|
||||
|
||||
/// 列出日志根目录中的文件,设置页据此构建可切换的日志目录树。
|
||||
Future<AppLogCatalog> listAppLogFiles() async {
|
||||
await logger.init;
|
||||
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,
|
||||
required this.currentPath,
|
||||
required this.files,
|
||||
});
|
||||
|
||||
final String rootPath;
|
||||
final String currentPath;
|
||||
final List<AppLogFile> files;
|
||||
}
|
||||
|
||||
class AppLogFile {
|
||||
const AppLogFile({
|
||||
required this.path,
|
||||
required this.relativePath,
|
||||
required this.name,
|
||||
required this.fileSize,
|
||||
required this.modifiedAt,
|
||||
required this.isCurrent,
|
||||
required this.exists,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String relativePath;
|
||||
final String name;
|
||||
final int fileSize;
|
||||
final DateTime? modifiedAt;
|
||||
final bool isCurrent;
|
||||
final bool exists;
|
||||
}
|
||||
|
||||
class AppLogPreview {
|
||||
const AppLogPreview({
|
||||
required this.path,
|
||||
required this.content,
|
||||
required this.fileSize,
|
||||
required this.modifiedAt,
|
||||
required this.lineCount,
|
||||
required this.truncated,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String content;
|
||||
final int fileSize;
|
||||
final DateTime? modifiedAt;
|
||||
final int lineCount;
|
||||
final bool truncated;
|
||||
}
|
||||
|
||||
class AppLogFilter extends LogFilter {
|
||||
@override
|
||||
bool shouldLog(LogEvent event) {
|
||||
@@ -42,6 +160,154 @@ class ConsoleOutput extends LogOutput {
|
||||
return _queue;
|
||||
}
|
||||
|
||||
/// 排在既有写入队列末尾刷新当前文件,完成后调用方可取得一致的文件大小快照。
|
||||
Future<void> flushCurrentLog() => _enqueue(() async => _sink?.flush());
|
||||
|
||||
/// 读取当前日志文件尾部,并在返回 UI 前移除终端颜色码和常见敏感字段。
|
||||
Future<AppLogPreview> readPreview({
|
||||
String? path,
|
||||
required int maxBytes,
|
||||
required int maxLines,
|
||||
}) async {
|
||||
if (maxBytes <= 0 || maxLines <= 0) {
|
||||
throw ArgumentError('日志预览范围必须大于 0');
|
||||
}
|
||||
final currentPath = await _resolveCurrentLogPath();
|
||||
final resolvedPath = path ?? currentPath;
|
||||
await _validatePreviewPath(resolvedPath, currentPath);
|
||||
if (_samePath(resolvedPath, currentPath)) {
|
||||
await _enqueue(() async => _sink?.flush());
|
||||
}
|
||||
final file = File(resolvedPath);
|
||||
if (!await file.exists()) {
|
||||
return AppLogPreview(
|
||||
path: resolvedPath,
|
||||
content: '',
|
||||
fileSize: 0,
|
||||
modifiedAt: null,
|
||||
lineCount: 0,
|
||||
truncated: false,
|
||||
);
|
||||
}
|
||||
|
||||
final stat = await file.stat();
|
||||
final start = stat.size > maxBytes ? stat.size - maxBytes : 0;
|
||||
final handle = await file.open();
|
||||
late final List<int> bytes;
|
||||
try {
|
||||
await handle.setPosition(start);
|
||||
bytes = await handle.read(stat.size - start);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
var content = utf8.decode(bytes, allowMalformed: true);
|
||||
content = _sanitizePreviewContent(content);
|
||||
if (start > 0) content = _removePartialLeadingEntry(content);
|
||||
|
||||
var lines = const LineSplitter().convert(content);
|
||||
var truncated = start > 0;
|
||||
if (lines.length > maxLines) {
|
||||
lines = lines.sublist(lines.length - maxLines);
|
||||
truncated = true;
|
||||
}
|
||||
return AppLogPreview(
|
||||
path: resolvedPath,
|
||||
content: lines.join('\n').trimRight(),
|
||||
fileSize: stat.size,
|
||||
modifiedAt: stat.modified,
|
||||
lineCount: lines.length,
|
||||
truncated: truncated,
|
||||
);
|
||||
}
|
||||
|
||||
/// 只扫描应用日志根目录中的 `.log` 文件,避免设置页变成任意文件浏览器。
|
||||
Future<AppLogCatalog> listLogFiles() async {
|
||||
final currentPath = await _resolveCurrentLogPath();
|
||||
await _enqueue(() async => _sink?.flush());
|
||||
final root = Directory(currentPath).parent.parent;
|
||||
final files = <AppLogFile>[];
|
||||
if (await root.exists()) {
|
||||
await for (final entity
|
||||
in root.list(recursive: true, followLinks: false)) {
|
||||
if (entity is! File || !entity.path.toLowerCase().endsWith('.log')) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final stat = await entity.stat();
|
||||
files.add(
|
||||
AppLogFile(
|
||||
path: entity.path,
|
||||
relativePath: _relativePath(root.path, entity.path),
|
||||
name: _fileName(entity.path),
|
||||
fileSize: stat.size,
|
||||
modifiedAt: stat.modified,
|
||||
isCurrent: _samePath(entity.path, currentPath),
|
||||
exists: true,
|
||||
),
|
||||
);
|
||||
} on FileSystemException {
|
||||
// 扫描期间文件可能被外部清理,跳过失效项即可由下一次刷新恢复。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!files.any((file) => file.isCurrent)) {
|
||||
files.add(
|
||||
AppLogFile(
|
||||
path: currentPath,
|
||||
relativePath: _relativePath(root.path, currentPath),
|
||||
name: _fileName(currentPath),
|
||||
fileSize: 0,
|
||||
modifiedAt: null,
|
||||
isCurrent: true,
|
||||
exists: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
files.sort((a, b) {
|
||||
if (a.isCurrent != b.isCurrent) return a.isCurrent ? -1 : 1;
|
||||
final timeCompare = (b.modifiedAt ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0))
|
||||
.compareTo(a.modifiedAt ?? DateTime.fromMillisecondsSinceEpoch(0));
|
||||
if (timeCompare != 0) return timeCompare;
|
||||
return b.relativePath.compareTo(a.relativePath);
|
||||
});
|
||||
return AppLogCatalog(
|
||||
rootPath: root.path,
|
||||
currentPath: currentPath,
|
||||
files: List.unmodifiable(files),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _resolveCurrentLogPath() async {
|
||||
final path = _filePath ?? await AppFileTool.getLoggerBackupPath();
|
||||
if (ObjectUtil.isEmpty(path)) {
|
||||
throw const FileSystemException('无法获取日志文件路径');
|
||||
}
|
||||
return path!;
|
||||
}
|
||||
|
||||
Future<void> _validatePreviewPath(
|
||||
String path,
|
||||
String currentPath,
|
||||
) async {
|
||||
if (!path.toLowerCase().endsWith('.log')) {
|
||||
throw const FileSystemException('仅支持预览日志文件');
|
||||
}
|
||||
final root = Directory(currentPath).parent.parent;
|
||||
final candidate = File(path);
|
||||
final rootPath = await root.exists()
|
||||
? await root.resolveSymbolicLinks()
|
||||
: root.absolute.path;
|
||||
final candidatePath = await candidate.exists()
|
||||
? await candidate.resolveSymbolicLinks()
|
||||
: candidate.absolute.path;
|
||||
if (!_isPathInside(rootPath, candidatePath)) {
|
||||
throw const FileSystemException('日志文件不在允许的目录中');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
try {
|
||||
@@ -88,6 +354,7 @@ class ConsoleOutput extends LogOutput {
|
||||
});
|
||||
}
|
||||
|
||||
/// 开发模式保留现有文件记录范围;生产模式仅持久化达到配置阈值的日志。
|
||||
@override
|
||||
void output(OutputEvent event) {
|
||||
try {
|
||||
@@ -98,6 +365,10 @@ class ConsoleOutput extends LogOutput {
|
||||
}
|
||||
unawaited(_enqueue(() async {
|
||||
if (_filePath == null || _sink == null) return;
|
||||
if (AppSysTool.isProd() &&
|
||||
event.level.value < Constants.productionFileLogLevel.value) {
|
||||
return;
|
||||
}
|
||||
if (Level.debug == event.level) return;
|
||||
_sink!.writeAll(event.lines, '\n');
|
||||
_sink!.writeln();
|
||||
@@ -108,3 +379,68 @@ class ConsoleOutput extends LogOutput {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _removePartialLeadingEntry(String content) {
|
||||
final entryEnd = content.indexOf('\n\n');
|
||||
if (entryEnd >= 0) return content.substring(entryEnd + 2);
|
||||
final lineEnd = content.indexOf('\n');
|
||||
return lineEnd >= 0 ? content.substring(lineEnd + 1) : '';
|
||||
}
|
||||
|
||||
bool _samePath(String first, String second) {
|
||||
return File(first).absolute.path == File(second).absolute.path;
|
||||
}
|
||||
|
||||
bool _isPathInside(String rootPath, String candidatePath) {
|
||||
final root = Directory(rootPath).absolute.path;
|
||||
final candidate = File(candidatePath).absolute.path;
|
||||
return candidate == root ||
|
||||
candidate.startsWith('$root${Platform.pathSeparator}');
|
||||
}
|
||||
|
||||
String _relativePath(String rootPath, String filePath) {
|
||||
final root = Directory(rootPath).absolute.path.replaceAll('\\', '/');
|
||||
final file = File(filePath).absolute.path.replaceAll('\\', '/');
|
||||
final prefix = '$root/';
|
||||
return file.startsWith(prefix)
|
||||
? file.substring(prefix.length)
|
||||
: _fileName(file);
|
||||
}
|
||||
|
||||
String _fileName(String path) {
|
||||
return path.replaceAll('\\', '/').split('/').last;
|
||||
}
|
||||
|
||||
String _sanitizePreviewContent(String content) {
|
||||
var value = content.replaceAll(
|
||||
RegExp(r'\x1B\[[0-?]*[ -/]*[@-~]'),
|
||||
'',
|
||||
);
|
||||
value = value.replaceAllMapped(
|
||||
RegExp(
|
||||
r'^.*(?:用户信息|下单参数|存储用户|同步参数|同步设备信息\s+sysInfo|二维码识别.*json|绑定设备信息pa:|添加到.*记录.*json).*$',
|
||||
caseSensitive: false,
|
||||
multiLine: true,
|
||||
),
|
||||
(_) => '[包含敏感业务数据的日志已隐藏]',
|
||||
);
|
||||
value = value.replaceAllMapped(
|
||||
RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false),
|
||||
(_) => 'Bearer ***',
|
||||
);
|
||||
value = value.replaceAllMapped(
|
||||
RegExp(
|
||||
r'((?:"|\b)?(?:token|accessToken|refreshToken|password|passwd|secret|authorization|paymentCode|payCode|authCode|qrCode|serviceCode|shortCode|cardId|cardNo|cardNumber|phone|mobile|idCard|certNo|realName|userName|nickName|address|email|userId|orderId|serviceOrderId)(?:"|\b)?\s*(?:[:=]|\|)\s*)("[^"]*"|[^,\s;|}\]]+)',
|
||||
caseSensitive: false,
|
||||
),
|
||||
(match) {
|
||||
final source = match.group(2)!;
|
||||
return '${match.group(1)}${source.startsWith('"') ? '"***"' : '***'}';
|
||||
},
|
||||
);
|
||||
// 未带字段名的长编号也可能是手机号、饭卡号或付款码,仅保留定位所需的首尾片段。
|
||||
return value.replaceAllMapped(RegExp(r'\d{8,}'), (match) {
|
||||
final source = match.group(0)!;
|
||||
return '${source.substring(0, 3)}****${source.substring(source.length - 4)}';
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,9 @@ class SerialBase {
|
||||
_portName = portName;
|
||||
}
|
||||
|
||||
/// 当前绑定的串口节点名,供设备状态探测读取。
|
||||
String get portName => _portName;
|
||||
|
||||
set baudRate(int baudRate) {
|
||||
_baudRate = baudRate;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,73 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get/get.dart' hide FormData;
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 无需向操作员重复提示的服务端业务消息。
|
||||
const ignoreMsg = ["账号未登录"];
|
||||
|
||||
/// 应用统一 Dio 管理器,负责鉴权头、租户头、响应解包、错误恢复与安全日志。
|
||||
class HttpManage {
|
||||
static const _tag = "http管理";
|
||||
static const _requestStartAtKey = "_requestStartAt";
|
||||
static const _maxLogLength = 1000;
|
||||
/// 后台心跳、版本检查等旁路请求禁止弹 Toast 时写入 Dio RequestOptions.extra 的稳定键。
|
||||
static const String silentRequestKey = 'silentRequest';
|
||||
|
||||
/// 需要把统一业务非零码作为 Dio 异常交给调用方区分明确失败与超时的请求标记。
|
||||
static const String rejectBusinessErrorKey = 'rejectBusinessError';
|
||||
|
||||
/// HTTP 管理日志标签。
|
||||
static const _tag = "http管理";
|
||||
|
||||
/// 请求开始时间在 Dio extra 中使用的内部键,用于计算请求耗时。
|
||||
static const _requestStartAtKey = "_requestStartAt";
|
||||
|
||||
/// 单段请求或响应日志允许保留的最大字符数,超出部分只记录原长度。
|
||||
static const _maxLogLength = 2000;
|
||||
|
||||
/// 日志输出前必须遮蔽的字段名;字段名会先转小写并移除下划线、短横线。
|
||||
static const Set<String> _sensitiveLogKeys = {
|
||||
'password',
|
||||
'passwd',
|
||||
'username',
|
||||
'accesstoken',
|
||||
'refreshtoken',
|
||||
'token',
|
||||
'authorization',
|
||||
'authcode',
|
||||
'paymentcode',
|
||||
'paycode',
|
||||
'qrcode',
|
||||
'servicecode',
|
||||
'shortcode',
|
||||
'cardid',
|
||||
'cardno',
|
||||
'cardnumber',
|
||||
'phone',
|
||||
'mobile',
|
||||
'idcard',
|
||||
'certno',
|
||||
'realname',
|
||||
'referenceimageurl',
|
||||
'imagehash',
|
||||
'localfilepath',
|
||||
'evidencefilepath',
|
||||
};
|
||||
|
||||
/// 进程内唯一的 HTTP 管理器实例,确保拦截器和连接池不被重复创建。
|
||||
static final HttpManage _instance = HttpManage._internal();
|
||||
|
||||
/// 返回进程内共享的 HTTP 管理器实例。
|
||||
factory HttpManage() => _instance;
|
||||
|
||||
/// 统一业务 Dio 实例;所有普通 API 请求必须复用该对象。
|
||||
late Dio dio;
|
||||
|
||||
/// 创建 Dio、注册统一拦截器并配置项目既有的 Android HTTP 客户端策略。
|
||||
HttpManage._internal() {
|
||||
BaseOptions globalOptions = BaseOptions(
|
||||
baseUrl: Constants.apiUrl,
|
||||
@@ -46,32 +93,54 @@ class HttpManage {
|
||||
"Bearer ${userInfo.accessToken}";
|
||||
}
|
||||
}
|
||||
options.headers["TENANT-ID"] = userInfo.tenantId ?? 1;
|
||||
final tenantId = userInfo.tenantId;
|
||||
// 后端会将 TENANT-ID 解析为 Long;旧版本若遗留非数值 code,先回退默认租户,
|
||||
// 登录控制器随后会利用缓存租户表迁移为真实数值 id。
|
||||
options.headers["TENANT-ID"] =
|
||||
tenantId != null && int.tryParse(tenantId) != null ? tenantId : 1;
|
||||
logger.i(
|
||||
"|$_tag|请求开始 method:${options.method} url:${options.uri} "
|
||||
"|$_tag|请求开始 method:${options.method} url:${_formatLogUri(options.uri)} "
|
||||
"query:${_formatLogData(options.queryParameters)} "
|
||||
"body:${_formatLogData(options.data)}",
|
||||
);
|
||||
return handler.next(options);
|
||||
},
|
||||
onResponse: (e, handler) async {
|
||||
final silent = e.requestOptions.extra[silentRequestKey] == true;
|
||||
// 先判断是不是登录接口
|
||||
late ServiceBase serviceData;
|
||||
if (e.requestOptions.path.contains(Constants.oauth2AppApiUrl)) {
|
||||
serviceData = ServiceBase(msg: "", code: 0, data: e.data);
|
||||
} else {
|
||||
serviceData = ServiceBase.fromJson(e.data);
|
||||
final parsed = _tryParseServiceBase(e.data);
|
||||
if (parsed == null) {
|
||||
_logResponse(options: e.requestOptions, response: e.data);
|
||||
if (!silent) {
|
||||
Toast.error(msg: "服务器返回格式异常,请稍后重试");
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: e.requestOptions,
|
||||
response: e,
|
||||
type: DioExceptionType.badResponse,
|
||||
error: '服务器返回格式不是统一 JSON 对象',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
serviceData = parsed;
|
||||
}
|
||||
if (serviceData.code == 401 &&
|
||||
ObjectUtil.isNotEmpty(serviceData.msg)) {
|
||||
if (!ignoreMsg.contains(serviceData.msg)) {
|
||||
if (!silent && !ignoreMsg.contains(serviceData.msg)) {
|
||||
Toast.error(msg: serviceData.msg ?? "服务器未知错误");
|
||||
}
|
||||
final isLogin = await _autoLogin();
|
||||
if (!isLogin) {
|
||||
Get.offAndToNamed(RouteNames.login);
|
||||
}
|
||||
} else if (serviceData.code != 0) {
|
||||
} else if (serviceData.code != 0 && !silent) {
|
||||
Toast.error(msg: serviceData.msg ?? "服务器未知错误");
|
||||
}
|
||||
// 续一下token
|
||||
@@ -82,36 +151,53 @@ class HttpManage {
|
||||
options: e.requestOptions,
|
||||
response: serviceData.data,
|
||||
);
|
||||
if (serviceData.code != 0 &&
|
||||
(silent ||
|
||||
e.requestOptions.extra[rejectBusinessErrorKey] == true)) {
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: e.requestOptions,
|
||||
response: e,
|
||||
type: DioExceptionType.badResponse,
|
||||
error: serviceData.msg ?? '后台请求失败',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
e.data = serviceData.data;
|
||||
handler.next(e);
|
||||
},
|
||||
onError: (e, handler) async {
|
||||
final silent = e.requestOptions.extra[silentRequestKey] == true;
|
||||
_logError(e);
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
final timedOut = e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout) {
|
||||
e.type == DioExceptionType.receiveTimeout;
|
||||
if (!silent && timedOut) {
|
||||
Toast.error(msg: "网络请求超时");
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
final response = e.response;
|
||||
final statusCode = response?.statusCode;
|
||||
if (statusCode != null) {
|
||||
if (statusCode == 424) {
|
||||
final isLogin = await _autoLogin();
|
||||
if (!isLogin) {
|
||||
Get.offAndToNamed(RouteNames.login);
|
||||
}
|
||||
handler.next(e);
|
||||
if (statusCode == 424 &&
|
||||
!e.requestOptions.path.contains(Constants.oauth2AppApiUrl)) {
|
||||
final isLogin = await _autoLogin();
|
||||
if (!isLogin) {
|
||||
Get.offAndToNamed(RouteNames.login);
|
||||
}
|
||||
}
|
||||
if (response != null && response.data != null) {
|
||||
ServiceBase data = ServiceBase.fromJson(response.data);
|
||||
if (data.code != 0) {
|
||||
Toast.error(msg: data.msg ?? "服务器未知错误");
|
||||
EasyLoading.dismiss();
|
||||
handler.next(e);
|
||||
return;
|
||||
|
||||
// 网关、代理或不存在的路由可能返回纯文本/HTML,不能直接强转 Map。
|
||||
final serviceError = _tryParseServiceBase(response?.data);
|
||||
if (!silent && !timedOut && statusCode != null) {
|
||||
final serviceMessage = serviceError?.msg;
|
||||
if (ObjectUtil.isNotEmpty(serviceMessage) &&
|
||||
!ignoreMsg.contains(serviceMessage)) {
|
||||
Toast.error(msg: serviceMessage!);
|
||||
} else {
|
||||
Toast.error(msg: _httpStatusMessage(statusCode));
|
||||
}
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
return handler.next(e);
|
||||
},
|
||||
@@ -128,6 +214,7 @@ class HttpManage {
|
||||
);
|
||||
}
|
||||
|
||||
/// 使用本地保存的账号密码重新获取令牌;失败时清理失效登录信息并返回 false。
|
||||
Future<bool> _autoLogin() async {
|
||||
final userSetting = await AppSettingTool.getUserSetting();
|
||||
final sysSetting = await AppSettingTool.getSysSetting();
|
||||
@@ -145,7 +232,9 @@ class HttpManage {
|
||||
};
|
||||
try {
|
||||
final data = await UserApi.login(pa);
|
||||
if (data != null) throw Error();
|
||||
if (data is! Map) {
|
||||
throw const FormatException('自动登录响应不是 JSON 对象');
|
||||
}
|
||||
userSetting.userId = data['user_id'];
|
||||
userSetting.accessToken = data['access_token'];
|
||||
userSetting.refreshToken = data['refresh_token'];
|
||||
@@ -159,6 +248,8 @@ class HttpManage {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 在令牌接近过期时刷新并持久化新令牌;当前调用点保留但默认未启用。
|
||||
// ignore: unused_element
|
||||
Future<void> _refreshToken() async {
|
||||
final userSetting = await AppSettingTool.getUserSetting();
|
||||
if (ObjectUtil.isEmpty(userSetting.refreshToken) ||
|
||||
@@ -192,22 +283,24 @@ class HttpManage {
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录成功响应的请求耗时和脱敏后的响应摘要。
|
||||
void _logResponse({
|
||||
required RequestOptions options,
|
||||
dynamic response,
|
||||
}) {
|
||||
final cost = _calcRequestCost(options);
|
||||
logger.i(
|
||||
"|$_tag|请求完成 method:${options.method} url:${options.uri} "
|
||||
"|$_tag|请求完成 method:${options.method} url:${_formatLogUri(options.uri)} "
|
||||
"cost:${cost}ms response:${_formatLogData(response)}",
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录失败请求的耗时、错误类型和脱敏后的请求/响应摘要。
|
||||
void _logError(DioException error) {
|
||||
final options = error.requestOptions;
|
||||
final cost = _calcRequestCost(options);
|
||||
logger.e(
|
||||
"|$_tag|请求失败 method:${options.method} url:${options.uri} "
|
||||
"|$_tag|请求失败 method:${options.method} url:${_formatLogUri(options.uri)} "
|
||||
"cost:${cost}ms type:${error.type.name} "
|
||||
"query:${_formatLogData(options.queryParameters)} "
|
||||
"body:${_formatLogData(options.data)} "
|
||||
@@ -216,6 +309,7 @@ class HttpManage {
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据请求开始时间计算本次请求耗时,缺少记录时返回 0 毫秒。
|
||||
int _calcRequestCost(RequestOptions options) {
|
||||
final startAt = options.extra[_requestStartAtKey];
|
||||
if (startAt is DateTime) {
|
||||
@@ -224,9 +318,93 @@ class HttpManage {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 将统一业务响应安全解析为 ServiceBase;纯文本、HTML 或畸形 JSON 返回 null。
|
||||
ServiceBase? _tryParseServiceBase(dynamic value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
dynamic decoded = value;
|
||||
if (value is String) {
|
||||
decoded = jsonDecode(value);
|
||||
}
|
||||
if (decoded is! Map) return null;
|
||||
return ServiceBase.fromJson(
|
||||
decoded.map<String, dynamic>(
|
||||
(key, item) => MapEntry(key.toString(), item),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 将常见 HTTP 状态码转换为可操作的中文提示,不向界面回显服务端 HTML。
|
||||
String _httpStatusMessage(int statusCode) {
|
||||
return switch (statusCode) {
|
||||
400 => '请求参数错误,请检查后重试',
|
||||
401 => '账号、密码或登录信息无效',
|
||||
403 => '当前账号没有访问权限',
|
||||
404 => '请求地址不存在(404),请检查服务配置',
|
||||
409 => '请求与当前数据状态冲突,请刷新后重试',
|
||||
424 => '登录状态已失效,请重新登录',
|
||||
>= 500 => '服务器暂时不可用($statusCode),请稍后重试',
|
||||
_ => '服务器请求失败($statusCode)',
|
||||
};
|
||||
}
|
||||
|
||||
/// 遮蔽 URL 查询参数中的账号、令牌和业务凭证,避免敏感值出现在原始日志文件。
|
||||
String _formatLogUri(Uri uri) {
|
||||
if (!uri.hasQuery) return uri.toString();
|
||||
final queryParameters = uri.queryParameters.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
_isSensitiveLogKey(key) ? '***' : value,
|
||||
),
|
||||
);
|
||||
return uri.replace(queryParameters: queryParameters).toString();
|
||||
}
|
||||
|
||||
/// 递归遮蔽 Map/List 中的敏感字段,保留非敏感结构用于问题定位。
|
||||
dynamic _redactLogData(dynamic data) {
|
||||
if (data is FormData) {
|
||||
return {
|
||||
'fields': {
|
||||
for (final field in data.fields)
|
||||
field.key: _isSensitiveLogKey(field.key) ? '***' : field.value,
|
||||
},
|
||||
'files': [
|
||||
for (final file in data.files)
|
||||
{
|
||||
'field': file.key,
|
||||
'filename': file.value.filename ?? '未命名文件',
|
||||
'length': file.value.length,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (data is Map) {
|
||||
return data.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
_isSensitiveLogKey(key.toString()) ? '***' : _redactLogData(value),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data is Iterable) {
|
||||
return data.map(_redactLogData).toList(growable: false);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// 判断字段名是否属于敏感日志字段,兼容 snake_case、kebab-case 与 camelCase。
|
||||
bool _isSensitiveLogKey(String key) {
|
||||
final normalized = key.toLowerCase().replaceAll(RegExp(r'[_-]'), '');
|
||||
return _sensitiveLogKeys.contains(normalized);
|
||||
}
|
||||
|
||||
/// 将任意请求数据转换为有限长度的脱敏文本,供成功与失败日志统一调用。
|
||||
String _formatLogData(dynamic data) {
|
||||
if (data == null) return "null";
|
||||
final text = data.toString();
|
||||
final text = sanitizeAppLogContent(_redactLogData(data).toString());
|
||||
if (text.length <= _maxLogLength) return text;
|
||||
return "${text.substring(0, _maxLogLength)}...(已截断,原长度:${text.length})";
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ class MqManager {
|
||||
static MqttServerClient? mqttClient;
|
||||
static MqttConnectionState state = MqttConnectionState.disconnected;
|
||||
|
||||
/// 最近一次连接失败原因;连接成功或主动关闭后清空,供定时任务诊断展示。
|
||||
static String? connectionFailureReason;
|
||||
|
||||
// 当前设备的 topic
|
||||
static late String mainTopic;
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
class PrintManage extends BaseFacilityManage {
|
||||
static const String _tag = "打印机管理";
|
||||
|
||||
/// 最近一次打印失败的摘要,供打印任务队列记录并展示。
|
||||
String? lastError;
|
||||
|
||||
USBPrinterManager? manager;
|
||||
CapabilityProfile? profile;
|
||||
int paperSizeWidthMM = PaperSizeWidth.mm80;
|
||||
@@ -21,6 +24,18 @@ class PrintManage extends BaseFacilityManage {
|
||||
@override
|
||||
String setName() => UsbFacilityNames.print.title;
|
||||
|
||||
/// 只发现 USB 设备,不建立打印连接,供设备状态探测任务使用。
|
||||
Future<bool> checkAvailable() async {
|
||||
try {
|
||||
final printers = await USBPrinterManager.discover();
|
||||
return printers.any((v) => _printVendorIds.contains(v.vendorId ?? "-1")) ||
|
||||
printers.length == 1;
|
||||
} catch (e) {
|
||||
logger.e("|$_tag|检查打印机失败error|$e|");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> open() async {
|
||||
final check = await super.open();
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:flutter_libserialport/flutter_libserialport.dart';
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
class SerialCardManage extends SerialBase {
|
||||
@@ -18,6 +19,16 @@ class SerialCardManage extends SerialBase {
|
||||
baudRate = 115200;
|
||||
}
|
||||
|
||||
/// 只检查串口节点是否存在,不打开串口,供设备状态探测任务使用。
|
||||
Future<bool> checkAvailable() async {
|
||||
try {
|
||||
return SerialPort.availablePorts.contains(portName);
|
||||
} catch (e) {
|
||||
logger.e("|读卡器|检查串口失败error|$e|");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void readerClose() {
|
||||
super.readerClose();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter_libserialport/flutter_libserialport.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
class SerialSteelyardManage extends SerialBase {
|
||||
@@ -8,6 +10,16 @@ class SerialSteelyardManage extends SerialBase {
|
||||
readTimeout = 3000;
|
||||
}
|
||||
|
||||
/// 只检查串口节点是否存在,不打开串口,供设备状态探测任务使用。
|
||||
Future<bool> checkAvailable() async {
|
||||
try {
|
||||
return SerialPort.availablePorts.contains(portName);
|
||||
} catch (e) {
|
||||
logger.e("|称|检查串口失败error|$e|");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
double? parseWidget(List<int> bytes) {
|
||||
String result = String.fromCharCodes(bytes);
|
||||
final RegExp regExp = RegExp(r'(\d+(\.\d+)?)');
|
||||
|
||||
@@ -15,9 +15,27 @@ abstract class FaceDiscernPlugs {
|
||||
Constants.facePlugsEventChannel,
|
||||
);
|
||||
|
||||
static const EventChannel _sessionEventChannel = EventChannel(
|
||||
'local/face_discern_session_event',
|
||||
);
|
||||
|
||||
static const EventChannel _registrationEventChannel = EventChannel(
|
||||
Constants.facePlugsRegistrationEventChannel,
|
||||
);
|
||||
|
||||
/// 原生在库图片质量批量检测进度通道。
|
||||
static const EventChannel _qualityAuditEventChannel = EventChannel(
|
||||
Constants.facePlugsQualityAuditEventChannel,
|
||||
);
|
||||
|
||||
static MethodChannel get getChannel => _channel;
|
||||
|
||||
static Stream<FaceStream?>? _stream;
|
||||
static Stream<FaceRecognitionSessionEvent>? _sessionStream;
|
||||
static Stream<FaceRegistrationProgress>? _registrationStream;
|
||||
|
||||
/// 延迟创建并复用的在库图片质量检测进度流。
|
||||
static Stream<FaceQualityAuditProgress>? _qualityAuditStream;
|
||||
|
||||
static Future<bool> jumpFaceDiscernPage() async {
|
||||
try {
|
||||
@@ -29,6 +47,128 @@ abstract class FaceDiscernPlugs {
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionPreviewTexture> createPreviewTexture() async {
|
||||
final map = await _channel.invokeMethod<dynamic>('createPreviewTexture');
|
||||
if (map is! Map) throw StateError('原生未返回有效的人脸预览纹理');
|
||||
return FaceRecognitionPreviewTexture.fromMap(map);
|
||||
}
|
||||
|
||||
static Future<void> releasePreviewTexture(int textureId) async {
|
||||
await _channel.invokeMethod<void>('releasePreviewTexture', {
|
||||
'textureId': textureId,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionSessionEvent> startRecognition({
|
||||
required String sessionId,
|
||||
required FaceRecognitionScene scene,
|
||||
}) {
|
||||
return _invokeSession('startRecognition', {
|
||||
'sessionId': sessionId,
|
||||
'scene': scene.name,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionSessionEvent> retryRecognition(
|
||||
String sessionId,
|
||||
) {
|
||||
return _invokeSession('retryRecognition', {'sessionId': sessionId});
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionSessionEvent> cancelRecognition(
|
||||
String sessionId, {
|
||||
String? message,
|
||||
}) {
|
||||
return _invokeSession('cancelRecognition', {
|
||||
'sessionId': sessionId,
|
||||
'message': message,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionSessionEvent> finishRecognition(
|
||||
String sessionId,
|
||||
) {
|
||||
return _invokeSession('finishRecognition', {'sessionId': sessionId});
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionSessionEvent> suspendRecognition({
|
||||
String? reason,
|
||||
}) {
|
||||
return _invokeSession('suspendRecognition', {'reason': reason});
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionSessionEvent> getRecognitionState() {
|
||||
return _invokeSession('getRecognitionState');
|
||||
}
|
||||
|
||||
/// 通过 Android Camera 枚举检查终端是否存在摄像头,不打开设备也不占用识别资源。
|
||||
static Future<bool> hasCamera() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('hasCamera') ?? false;
|
||||
} catch (e) {
|
||||
logger.w('|$_tag| hasCamera 错误: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理 Android 私有目录中超过截止时间且未被 Floor 任务引用的证据图片。
|
||||
///
|
||||
/// [referencedPaths] 仅在进程内传给原生层做路径保护,不会写入日志或发送到服务端。
|
||||
static Future<int> cleanupOrderEvidenceFiles({
|
||||
required Set<String> referencedPaths,
|
||||
required DateTime olderThan,
|
||||
}) async {
|
||||
return await _channel.invokeMethod<int>('cleanupOrderEvidenceFiles', {
|
||||
'referencedPaths': referencedPaths.toList(growable: false),
|
||||
'olderThan': olderThan.millisecondsSinceEpoch,
|
||||
}) ??
|
||||
0;
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionConfiguration> getRecognizeConfiguration() {
|
||||
return _invokeRecognizeConfiguration('getRecognizeConfiguration');
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionConfiguration> setRecognizeConfiguration(
|
||||
FaceRecognitionConfiguration configuration,
|
||||
) {
|
||||
return _invokeRecognizeConfiguration(
|
||||
'setRecognizeConfiguration',
|
||||
configuration.toMap(),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionConfiguration> resetRecognizeConfiguration() {
|
||||
return _invokeRecognizeConfiguration('resetRecognizeConfiguration');
|
||||
}
|
||||
|
||||
static Stream<FaceRecognitionSessionEvent> get sessionStream {
|
||||
_sessionStream ??= _sessionEventChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) => event is Map)
|
||||
.map((event) => FaceRecognitionSessionEvent.fromMap(event as Map))
|
||||
.asBroadcastStream();
|
||||
return _sessionStream!;
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionSessionEvent> _invokeSession(
|
||||
String method, [
|
||||
Map<String, dynamic>? arguments,
|
||||
]) async {
|
||||
final map = await _channel.invokeMethod<dynamic>(method, arguments);
|
||||
if (map is! Map) throw StateError('原生未返回有效的人脸会话状态');
|
||||
return FaceRecognitionSessionEvent.fromMap(map);
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionConfiguration> _invokeRecognizeConfiguration(
|
||||
String method, [
|
||||
Map<String, dynamic>? arguments,
|
||||
]) async {
|
||||
final map = await _channel.invokeMethod<dynamic>(method, arguments);
|
||||
if (map is! Map) throw StateError('原生未返回有效的人脸识别配置');
|
||||
return FaceRecognitionConfiguration.fromMap(map);
|
||||
}
|
||||
|
||||
static Future<FaceConfig?> getConfig() async {
|
||||
try {
|
||||
final Map<Object?, Object?>? map =
|
||||
@@ -61,6 +201,124 @@ abstract class FaceDiscernPlugs {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 查询真实授权文件、引擎和内存人脸库状态。
|
||||
static Future<FacePluginStatus?> getFaceStatus({
|
||||
required String appId,
|
||||
required String sdkKey,
|
||||
required String activeKey,
|
||||
}) async {
|
||||
try {
|
||||
final map = await _channel.invokeMethod<dynamic>('getFaceStatus', {
|
||||
'appId': appId,
|
||||
'sdkKey': sdkKey,
|
||||
'activeKey': activeKey,
|
||||
});
|
||||
return map is Map ? FacePluginStatus.fromMap(map) : null;
|
||||
} catch (e) {
|
||||
logger.w('|$_tag| getFaceStatus 错误: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<FaceActivationResult> activateFaceSdk({
|
||||
required String appId,
|
||||
required String sdkKey,
|
||||
required String activeKey,
|
||||
}) async {
|
||||
try {
|
||||
final map = await _channel.invokeMethod<dynamic>('activateFaceSdk', {
|
||||
'appId': appId,
|
||||
'sdkKey': sdkKey,
|
||||
'activeKey': activeKey,
|
||||
});
|
||||
if (map is Map) return FaceActivationResult.fromMap(map);
|
||||
} catch (e) {
|
||||
logger.w('|$_tag| activateFaceSdk 错误: $e');
|
||||
}
|
||||
return const FaceActivationResult(
|
||||
success: false,
|
||||
code: -1,
|
||||
message: '人脸插件激活失败,请稍后重试',
|
||||
requiresPermission: false,
|
||||
canOpenSettings: false,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<FaceRegistrationResult> rebuildFaceLibrary({
|
||||
String? userId,
|
||||
}) async {
|
||||
final map = await _channel.invokeMethod<dynamic>('rebuildFaceLibrary', {
|
||||
'userId': userId,
|
||||
});
|
||||
if (map is Map) return FaceRegistrationResult.fromMap(map);
|
||||
return const FaceRegistrationResult(
|
||||
success: false,
|
||||
message: '人脸库注册失败,请稍后重试',
|
||||
total: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
failedSampleIds: [],
|
||||
durationMs: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 从临时图片提取注册特征,不修改虹软内存库和本地数据库。
|
||||
static Future<FaceFeatureExtractionResult> extractFaceFeature({
|
||||
required String filePath,
|
||||
}) async {
|
||||
final map = await _channel.invokeMethod<dynamic>('extractFaceFeature', {
|
||||
'filePath': filePath,
|
||||
});
|
||||
if (map is Map) return FaceFeatureExtractionResult.fromMap(map);
|
||||
return const FaceFeatureExtractionResult(
|
||||
success: false,
|
||||
message: '原生未返回有效的人脸特征结果',
|
||||
featureData: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// 在原生人脸操作互斥边界内批量检测本地在库照片质量。
|
||||
///
|
||||
/// 文件路径仅传入本机 Android 插件;原生逐图解码并通过 [qualityAuditStream]
|
||||
/// 报告进度,返回结果不会自动修改 Floor 数据库。
|
||||
static Future<FaceQualityAuditResult> auditFaceQuality({
|
||||
required List<FaceQualityAuditItem> items,
|
||||
}) async {
|
||||
final map = await _channel.invokeMethod<dynamic>('auditFaceQuality', {
|
||||
'items': items.map((item) => item.toMap()).toList(growable: false),
|
||||
});
|
||||
if (map is Map) return FaceQualityAuditResult.fromMap(map);
|
||||
return const FaceQualityAuditResult(
|
||||
results: [],
|
||||
message: '原生未返回有效的质量检测结果',
|
||||
);
|
||||
}
|
||||
|
||||
/// 在虹软内存库中原子应用单样本新增、替换或删除。
|
||||
///
|
||||
/// 替换失败时原生层恢复旧特征;[operation] 仅允许 upsert 或 delete。
|
||||
static Future<FaceDeltaApplyResult> applyFaceDelta({
|
||||
required String operation,
|
||||
required String sampleId,
|
||||
String? userId,
|
||||
String? username,
|
||||
List<int>? featureData,
|
||||
}) async {
|
||||
final map = await _channel.invokeMethod<dynamic>('applyFaceDelta', {
|
||||
'operation': operation,
|
||||
'sampleId': int.tryParse(sampleId),
|
||||
'userId': userId,
|
||||
'username': username,
|
||||
'featureData': featureData,
|
||||
});
|
||||
if (map is Map) return FaceDeltaApplyResult.fromMap(map);
|
||||
return const FaceDeltaApplyResult(
|
||||
success: false,
|
||||
message: '原生未返回有效的人脸增量结果',
|
||||
code: -99,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<RegisterAllStorageFaceRes?> registerAllStorageFace({
|
||||
required String filePath,
|
||||
String? fileName,
|
||||
@@ -126,4 +384,22 @@ abstract class FaceDiscernPlugs {
|
||||
});
|
||||
return _stream!;
|
||||
}
|
||||
|
||||
static Stream<FaceRegistrationProgress> get registrationStream {
|
||||
_registrationStream ??= _registrationEventChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) => event is Map)
|
||||
.map((event) => FaceRegistrationProgress.fromMap(event as Map));
|
||||
return _registrationStream!;
|
||||
}
|
||||
|
||||
/// 在库人脸质量检测进度广播流;订阅时会先收到原生最新状态。
|
||||
static Stream<FaceQualityAuditProgress> get qualityAuditStream {
|
||||
_qualityAuditStream ??= _qualityAuditEventChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) => event is Map)
|
||||
.map((event) => FaceQualityAuditProgress.fromMap(event as Map))
|
||||
.asBroadcastStream();
|
||||
return _qualityAuditStream!;
|
||||
}
|
||||
}
|
||||
@@ -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