feat:消费机更新

feat:人员调用钱包部门不调动的bug
This commit is contained in:
dzc committed 2026-09-09 11:56:19 +08:00
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();
}
+277 -54
View File
@@ -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);
+97 -25
View File
@@ -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);
}
}
+602
View File
@@ -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;
}
}
+686 -26
View File
@@ -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();
}
}
+11
View File
@@ -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';
+51
View File
@@ -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>?;
+337 -1
View File
@@ -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;
}
+207 -29
View File
@@ -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})";
}
+3
View File
@@ -18,6 +18,9 @@ class MqManager {
static MqttServerClient? mqttClient;
static MqttConnectionState state = MqttConnectionState.disconnected;
/// 最近一次连接失败原因;连接成功或主动关闭后清空,供定时任务诊断展示。
static String? connectionFailureReason;
// 当前设备的 topic
static late String mainTopic;
+15
View File
@@ -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
+117
View File
@@ -0,0 +1,117 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart';
/// 同步调度与顶栏共用的空闲状态快照,避免界面自行拼装支付、弹窗和重型任务规则。
class SyncIdleSnapshot {
/// 创建一次不可变的终端空闲判断快照。
const SyncIdleSnapshot({
required this.foreground,
required this.safeWindow,
required this.idle,
required this.inactiveFor,
required this.reason,
});
/// 应用当前是否处于 resumed 前台状态。
final bool foreground;
/// 当前是否避开了支付遮罩、人脸会话、业务弹窗和其他重型任务。
final bool safeWindow;
/// 当前是否同时满足安全窗口和连续 60 秒无人操作。
final bool idle;
/// 距离最近一次指针操作或应用恢复前台经过的时长。
final Duration inactiveFor;
/// 当前未达到空闲状态的首要中文原因;空闲时为空字符串。
final String reason;
/// 返回顶栏使用的简短空闲状态文案。
String get label => idle ? '空闲' : '忙碌';
}
/// 统一记录终端用户活动和重型后台任务,用于选择不会干扰支付、人脸和弹框的安全执行窗口。
class SyncIdleTool {
/// 隐藏构造器,空闲判断必须通过全局单例维护同一份活动时间和任务计数。
SyncIdleTool._();
/// 应用级空闲检测单例。
static final SyncIdleTool instance = SyncIdleTool._();
/// 自动空闲任务要求的最短连续无人操作时间。
static const Duration idleDuration = Duration(seconds: 60);
/// 最近一次用户指针操作或恢复前台的本地时间。
DateTime _lastActivityAt = DateTime.now();
/// 应用是否处于可安全执行终端任务的前台生命周期。
bool _foreground = true;
/// 当前并行执行的重型同步任务数量,用计数支持嵌套开始和结束。
int _heavyTaskCount = 0;
/// 记录一次终端触控活动并重新开始 60 秒空闲计时。
void recordUserActivity() {
_lastActivityAt = DateTime.now();
}
/// 根据应用生命周期更新前台状态,恢复前台时视为一次用户活动。
void updateLifecycle(AppLifecycleState state) {
_foreground = state == AppLifecycleState.resumed;
if (_foreground) recordUserActivity();
}
/// 标记一个重型任务开始,阻止其他需要安全窗口的任务并发执行。
void beginHeavyTask() => _heavyTaskCount++;
/// 标记一个重型任务结束;计数最低保持为零以容忍重复清理。
void endHeavyTask() {
if (_heavyTaskCount > 0) _heavyTaskCount--;
}
/// 手动任务和后台拉取只等待业务安全窗口,不额外要求 60 秒无人操作。
SyncIdleSnapshot get snapshot {
final faceActive = Get.isRegistered<FaceRecognitionSessionService>() &&
Get.find<FaceRecognitionSessionService>().isActive;
final route = Get.context == null ? null : ModalRoute.of(Get.context!);
final dialogOpen = Get.isDialogOpen == true ||
Get.isBottomSheetOpen == true ||
route?.isCurrent == false;
final inactiveFor = DateTime.now().difference(_lastActivityAt);
final reason = !_foreground
? '应用不在前台'
: EasyLoading.isShow
? '前台操作进行中'
: faceActive
? '人脸识别进行中'
: dialogOpen
? '业务弹窗处理中'
: _heavyTaskCount > 0
? '后台同步进行中'
: inactiveFor < idleDuration
? '最近有终端操作'
: '';
final safeWindow = _foreground &&
!EasyLoading.isShow &&
!faceActive &&
!dialogOpen &&
_heavyTaskCount == 0;
return SyncIdleSnapshot(
foreground: _foreground,
safeWindow: safeWindow,
idle: safeWindow && inactiveFor >= idleDuration,
inactiveFor: inactiveFor,
reason: reason,
);
}
/// 当前是否满足手动执行和 MQTT 拉取所需的业务安全窗口。
bool get isSafeWindow => snapshot.safeWindow;
/// 当前是否满足自动空闲任务的完整空闲条件。
bool get isIdle => snapshot.idle;
}
+248 -90
View File
@@ -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;
}
}