From dc5ef2bc9eb1bc1c6fc42d35cd4ceec302e22a79 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 10 Sep 2026 17:49:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BA=BA=E8=84=B8=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E3=80=81=E4=BA=BA=E8=84=B8=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E5=87=AD=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/common/models/api/user_api_models.dart | 8 + lib/common/models/api/user_api_models.g.dart | 8 + .../models/manage/face_sync_models.dart | 9 +- .../sync/order_evidence_upload_tool.dart | 5 +- lib/common/utils/sync/sync_face_tool.dart | 48 ++- .../widgets/order/mixin/order_mixin.dart | 65 +++- .../widgets/order/select_pay_type_dialog.dart | 290 +++++++++++------- lib/pages/canteen_product/controller.dart | 19 +- lib/pages/supermarket_order/controller.dart | 19 +- 9 files changed, 341 insertions(+), 130 deletions(-) diff --git a/lib/common/models/api/user_api_models.dart b/lib/common/models/api/user_api_models.dart index afd4f1f..1960337 100644 --- a/lib/common/models/api/user_api_models.dart +++ b/lib/common/models/api/user_api_models.dart @@ -114,6 +114,12 @@ class ServiceAsyncFace { /// 部门名 final String? deptName; + /// 服务端记录更新时间;V2 调试同步将其作为图片版本,缺失时回退创建时间。 + final DateTime? updateTime; + + /// 服务端记录创建时间;V2 调试同步在更新时间缺失时的图片版本回退值。 + final DateTime? createTime; + /// 删除标志 @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) final bool? delFlag; @@ -127,6 +133,8 @@ class ServiceAsyncFace { this.userAvatar, this.deptId, this.deptName, + this.updateTime, + this.createTime, this.delFlag, }); diff --git a/lib/common/models/api/user_api_models.g.dart b/lib/common/models/api/user_api_models.g.dart index 989b351..c82fb92 100644 --- a/lib/common/models/api/user_api_models.g.dart +++ b/lib/common/models/api/user_api_models.g.dart @@ -61,6 +61,12 @@ ServiceAsyncFace _$ServiceAsyncFaceFromJson(Map json) => userAvatar: json['userAvatar'] as String?, deptId: json['deptId'] as String?, deptName: json['deptName'] as String?, + updateTime: json['updateTime'] == null + ? null + : DateTime.parse(json['updateTime'] as String), + createTime: json['createTime'] == null + ? null + : DateTime.parse(json['createTime'] as String), delFlag: JsonTool.stringToBoolean(json['delFlag'] as String?), ); @@ -74,6 +80,8 @@ Map _$ServiceAsyncFaceToJson(ServiceAsyncFace instance) => 'userAvatar': instance.userAvatar, 'deptId': instance.deptId, 'deptName': instance.deptName, + 'updateTime': instance.updateTime?.toIso8601String(), + 'createTime': instance.createTime?.toIso8601String(), 'delFlag': JsonTool.booleanToString(instance.delFlag), }; diff --git a/lib/common/models/manage/face_sync_models.dart b/lib/common/models/manage/face_sync_models.dart index 28b52a0..84e071a 100644 --- a/lib/common/models/manage/face_sync_models.dart +++ b/lib/common/models/manage/face_sync_models.dart @@ -22,6 +22,7 @@ class FaceSyncSummary { required this.skipped, required this.failed, required this.maskedFailedSampleIds, + this.qualityReportNote, }); /// 本次同步模式。 @@ -48,6 +49,9 @@ class FaceSyncSummary { /// 失败样本的脱敏编号,最多由同步服务保留有限数量用于现场定位。 final List maskedFailedSampleIds; + /// 同步末尾的质量上报未完成时的提示;上报成功或无需上报时为空。 + final String? qualityReportNote; + /// 本次成功新增、更新和删除的变更总数。 int get changed => added + updated + deleted; @@ -56,13 +60,14 @@ class FaceSyncSummary { /// 面向任务日志和设置页的简短结果描述。 String get message { + final note = qualityReportNote == null ? '' : ';$qualityReportNote'; if (!hasFailures) { - return '扫描 $scanned,新增 $added,更新 $updated,删除 $deleted,跳过 $skipped'; + return '扫描 $scanned,新增 $added,更新 $updated,删除 $deleted,跳过 $skipped$note'; } final failedSamples = maskedFailedSampleIds.isEmpty ? '' : ',失败样本 ${maskedFailedSampleIds.join(',')}'; - return '扫描 $scanned,新增 $added,更新 $updated,删除 $deleted,失败 $failed$failedSamples'; + return '扫描 $scanned,新增 $added,更新 $updated,删除 $deleted,失败 $failed$failedSamples$note'; } } diff --git a/lib/common/utils/sync/order_evidence_upload_tool.dart b/lib/common/utils/sync/order_evidence_upload_tool.dart index 38afb9b..11b6b16 100644 --- a/lib/common/utils/sync/order_evidence_upload_tool.dart +++ b/lib/common/utils/sync/order_evidence_upload_tool.dart @@ -792,6 +792,10 @@ abstract final class OrderEvidenceUploadTool { !(task.androidOrderCode?.isNotEmpty ?? false); /// 判断服务端拒绝是否属于哈希、格式或归属等确定不可恢复的错误。 + /// + /// 注意“订单不存在或不属于当前设备”不能列为永久失败:pj 离线人脸订单在下单时只 + /// 绑定终端订单号,服务端订单要等联网同步后才存在,该拒绝必须按退避重试,由 + /// 30 天本地保留期限兜底收敛。 static bool _isPermanentError(Object error) { if (error is! DioException) return false; final message = '${error.error ?? ''} ${error.message ?? ''}'; @@ -800,7 +804,6 @@ abstract final class OrderEvidenceUploadTool { '哈希不一致', '不受信任', '地址不合法', - '不属于当前设备', '用户不一致', '样本不一致', '图片大小与终端上报不一致', diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart index 743dd8e..9c41e60 100644 --- a/lib/common/utils/sync/sync_face_tool.dart +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -131,8 +131,10 @@ abstract class SyncFaceTool { onProgress: onProgress, ); } - await _reportPersistedFaceQualitySafely(); - final summary = accumulator.toSummary(); + final qualityReported = await _reportPersistedFaceQualitySafely(); + final summary = accumulator.toSummary( + qualityReportNote: qualityReported ? null : '质量上报未完成,将在下次同步重试', + ); logger.i('|$_tag|同步完成|${summary.message}|'); return summary; } @@ -207,12 +209,16 @@ abstract class SyncFaceTool { /// 使用保留的 V2 接口拉取非生产调试全租户快照。 /// - /// 生产入口不会调用本方法;V2 不提供源更新时间,因此仅以图片地址判断图片版本。 + /// 生产入口不会调用本方法;V2 以服务端 updateTime(缺失时回退 createTime)作为 + /// 图片版本,保证调试同步写入的人脸同样可以参与质量上报和版本比对。 + /// 服务端历史脏数据(空编号或重复编号)只跳过并记录计数,不阻断调试同步。 static Future> _loadDebugAllSnapshot({ void Function(int processed, int? total)? onProgress, }) async { final records = []; final sampleIds = {}; + var invalidCount = 0; + var duplicateCount = 0; for (var page = 1; page <= _maxSnapshotPages; page++) { final data = await UserApi.getFaceListV2({ 'current': page, @@ -228,9 +234,13 @@ abstract class SyncFaceTool { if (sampleId == null || userId == null || sampleId.isEmpty || - userId.isEmpty || - !sampleIds.add(sampleId)) { - throw const FaceSyncSnapshotException('调试全租户快照包含无效或重复人脸编号'); + userId.isEmpty) { + invalidCount++; + continue; + } + if (!sampleIds.add(sampleId)) { + duplicateCount++; + continue; } records.add(ServiceFaceSyncRecord( id: face.id ?? sampleId, @@ -240,9 +250,17 @@ abstract class SyncFaceTool { deptId: face.deptId ?? '', deptName: face.deptName, faceImg: face.faceImg, + sourceUpdatedAt: face.updateTime ?? face.createTime, )); } - if (data.length < _debugPageSize) return records; + if (data.length < _debugPageSize) { + if (invalidCount > 0 || duplicateCount > 0) { + logger.w( + '|$_tag|调试快照跳过异常编号|invalid:$invalidCount|duplicate:$duplicateCount|', + ); + } + return records; + } onProgress?.call(records.length, null); } throw const FaceSyncSnapshotException('调试全租户快照分页超过安全上限'); @@ -306,6 +324,10 @@ abstract class SyncFaceTool { } /// 判断图片版本、特征或正式文件是否变化;完全一致时不下载也不写虹软。 + /// + /// 历史本地记录可能缺少图片版本时间(早期 V2 调试同步写入的 updateTime 为空); + /// 此时只要图片地址一致且本地特征与文件完好,就不再强制重新下载,由 + /// [_metadataChanged] 的版本回填补齐,避免服务端图片损坏时记录永远无法修复。 static Future _imageChanged( FaceEntity? existing, ServiceFaceSyncRecord record, @@ -313,6 +335,7 @@ abstract class SyncFaceTool { if (existing == null) return true; if (existing.imagePath != record.faceImg) return true; if (record.sourceUpdatedAt != null && + existing.updateTime != null && existing.updateTime != record.sourceUpdatedAt) { return true; } @@ -321,7 +344,9 @@ abstract class SyncFaceTool { return path == null || path.isEmpty || !await File(path).exists(); } - /// 判断姓名或部门元数据是否变化;此类变化不需要重新提取图片特征。 + /// 判断姓名、部门元数据或图片版本时间是否变化;此类变化不需要重新提取图片特征。 + /// + /// 版本时间以服务端快照为准回填,用于修复缺少上报版本的本地历史记录。 static bool _metadataChanged( FaceEntity existing, ServiceFaceSyncRecord record, @@ -329,7 +354,9 @@ abstract class SyncFaceTool { existing.userId != record.userId || existing.userName != (record.username ?? '') || existing.deptId != record.deptId || - existing.deptName != (record.deptName ?? ''); + existing.deptName != (record.deptName ?? '') || + (record.sourceUpdatedAt != null && + existing.updateTime != record.sourceUpdatedAt); /// 下载临时图片、提取特征并提交文件、SQLite 和虹软单样本增量。 /// @@ -852,7 +879,7 @@ class _FaceSyncAccumulator { } /// 生成可公开给任务日志和页面的不可变汇总。 - FaceSyncSummary toSummary() => FaceSyncSummary( + FaceSyncSummary toSummary({String? qualityReportNote}) => FaceSyncSummary( mode: mode, scanned: scanned, added: added, @@ -861,5 +888,6 @@ class _FaceSyncAccumulator { skipped: skipped, failed: failed, maskedFailedSampleIds: List.unmodifiable(maskedFailedSampleIds), + qualityReportNote: qualityReportNote, ); } diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index 3412196..0a7e153 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -15,6 +15,9 @@ mixin OrderMixin { /// 下单参数日志保留的最大字符数,与 HttpManage 请求日志的截断口径一致。 static const _maxOrderParamLogLength = 2000; + /// 当前收银弹框成功的人脸证据结果,只有人脸支付下单时才会消费并绑定订单号。 + Rxn pendingFaceRecognition = Rxn(); + final appC = Get.find(); /// **************** start 需要重制 start **************** @@ -325,6 +328,21 @@ mixin OrderMixin { }).toList(); } final isNetwork = AppNetworkTool.isAutoNetwork(); + // 人脸支付订单在发起请求前把识别会话绑定到终端订单号;离线单同样绑定, + // 联网同步并由服务端确认支付后,后台补传任务按订单号对账上传证据。 + final isFacePayment = orderPayType.value == OrderPayType.FACE; + final faceSessionId = pendingFaceRecognition.value?.sessionId?.trim(); + var evidenceMetadataList = const >[]; + String? evidenceAppVersion; + if (isFacePayment && faceSessionId != null && faceSessionId.isNotEmpty) { + evidenceMetadataList = await OrderEvidenceUploadTool.bindSessionToOrder( + recognitionSessionId: faceSessionId, + androidOrderCode: sqlOrderDetail.uniqueCode, + ); + evidenceAppVersion = + await OrderEvidenceUploadTool.getSessionAppVersion(faceSessionId); + pendingFaceRecognition.value = null; + } var pa = { "androidOrderId": sqlOrderId, "androidOrderCode": sqlOrderDetail.uniqueCode, @@ -338,6 +356,9 @@ mixin OrderMixin { "orderPayType": orderPayType.value?.name, "list": shopList, "extraPrice": amountMealPrice.value, + if (evidenceMetadataList.isNotEmpty) + "evidenceMetadataList": evidenceMetadataList, + if (evidenceAppVersion != null) "appVersion": evidenceAppVersion, }; if (orderPayType.value != null && appSetting.sysType == SysType.CANTEEN && @@ -349,7 +370,10 @@ mixin OrderMixin { pa["walletId"] = payUser.value?.walletId; } } - final orderParamsJson = json.encode(pa); + // 证据元数据不进入日志,避免哈希和样本编号长期留存在终端日志文件中。 + final safeLogParameters = Map.from(pa) + ..remove('evidenceMetadataList'); + final orderParamsJson = json.encode(safeLogParameters); logger.i( "|$_tag|创建api订单,下单参数:|${orderParamsJson.length > _maxOrderParamLogLength ? "${orderParamsJson.substring(0, _maxOrderParamLogLength)}...(已截断,原长度:${orderParamsJson.length})" : orderParamsJson}|;", ); @@ -381,6 +405,36 @@ mixin OrderMixin { sqlOrderDetail.payPrice = payPrice; sqlOrderDetail.payTime = DateTime.now(); await orderDao.edit(sqlOrderDetail); + if (isFacePayment && + faceSessionId != null && + faceSessionId.isNotEmpty && + result is String && + result.isNotEmpty) { + try { + await OrderEvidenceUploadTool.markOrderReady( + recognitionSessionId: faceSessionId, + serverOrderId: result, + ); + // 调度器仍会检查支付、人脸和业务弹框安全窗口,当前收银收尾不会被补传占用。 + unawaited( + SyncCheckTool.instance + .trigger( + ScheduledTaskType.orderEvidenceUpload, + triggerType: ScheduledTaskTriggerType.system, + ) + .catchError((error) { + logger.w( + '|$_tag|订单已成功但证据补传触发失败|errorType:${error.runtimeType}|', + ); + }), + ); + } catch (error) { + // 任务已经绑定 androidOrderCode,后续可重新对账;本地证据状态失败不能反向否定已成功订单。 + logger.w( + '|$_tag|订单已成功但证据任务状态更新失败|errorType:${error.runtimeType}|', + ); + } + } if (orderPayType.value != OrderPayType.WALLET_QR_CODE_SCAN) { // 支付结果已落库,副屏通知只能尽力发送,不能等待其回执阻塞主屏订单收尾。 unawaited(sendSubScreenPaySuccess()); @@ -392,6 +446,15 @@ mixin OrderMixin { } return result; } catch (e, stack) { + if (isFacePayment && + faceSessionId != null && + faceSessionId.isNotEmpty && + !OrderEvidenceUploadTool.shouldReconcileOrderFailure(e)) { + await OrderEvidenceUploadTool.cancelSession( + faceSessionId, + reason: '服务端明确拒绝订单,证据任务已取消', + ); + } Toast.error(msg: "结算订单失败"); logger.e("|$_tag|创建api订单,下单失败error:|$e|stack:|$stack|"); } diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index e1e309c..e2ab0ba 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -6,6 +6,7 @@ import 'package:common_utils/common_utils.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; import 'package:forui/forui.dart'; @@ -13,33 +14,33 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -/// 收银弹框内人脸库就地同步的阶段性状态。 +/// 收银弹框内人脸库就地注册的阶段性状态。 enum _FaceSetupPhase { - /// 当前没有需要展示的同步引导。 + /// 当前没有需要展示的注册引导。 idle, - /// 本机人脸库为空,等待收银员确认就地同步。 + /// 本机人脸数据尚未注册进识别引擎,等待收银员就地注册。 empty, - /// 就地同步执行中,展示下载与注册进度。 + /// 就地注册执行中,展示装载进度。 running, - /// 同步完成并已具备可识别人脸。 + /// 注册完成并已具备可识别人脸。 success, - /// 同步失败或服务端没有可用数据,可重试或改用其他支付方式。 + /// 注册失败或本机缺少人脸数据,可重试或改用其他支付方式。 failed, } -/// 人脸库就地同步的一次进度快照;清单仍在分页获取时总数为空。 +/// 人脸库就地注册的一次进度快照;原生尚未上报总数时为空。 class _FaceSetupProgress { /// 创建进度快照。 const _FaceSetupProgress({required this.processed, this.total}); - /// 已完成处理的记录数。 + /// 已完成处理的样本数。 final int processed; - /// 本次需要处理的总记录数;为空表示总数尚未确定。 + /// 本次需要注册的总样本数;为空表示总数尚未确定。 final int? total; } @@ -120,20 +121,20 @@ class SelectPayTypeDialogController extends GetxController { /// 人脸启动、识别或人员查询失败时在支付工作区展示的可恢复说明。 final faceWorkspaceMessage = ''.obs; - /// 人脸库就地同步当前阶段;仅人脸支付方式下出现,切换或关闭时复位。 + /// 人脸库就地注册当前阶段;仅人脸支付方式下出现,切换或关闭时复位。 final faceSetupPhase = Rx<_FaceSetupPhase>(_FaceSetupPhase.idle); - /// 人脸库就地同步的进度快照;运行前和复位后为空。 + /// 人脸库就地注册的进度快照;运行前和复位后为空。 final faceSetupProgress = Rxn<_FaceSetupProgress>(); - /// 人脸库就地同步的结果说明;失败原因或完成统计。 + /// 人脸库就地注册的结果说明;失败原因或完成统计。 final faceSetupMessage = ''.obs; - /// 人脸库就地同步请求序号;关闭、切换支付方式或停止识别后递增,使迟到的 + /// 人脸库就地注册请求序号;关闭、切换支付方式或停止识别后递增,使迟到的 /// 完成回调不再自动续接识别或改写界面状态。 int _faceSetupDispatchId = 0; - /// 就地同步是否仍在执行;识别发起前据此避让,防止与后台增量同时占用引擎。 + /// 就地注册是否仍在执行;识别发起前据此避让,防止与引擎装载争抢原生互斥。 bool _faceSetupRunning = false; /// 人脸结果已返回、正在查询人员和钱包时为 true。 @@ -725,8 +726,8 @@ class SelectPayTypeDialogController extends GetxController { return; } if (_faceSetupRunning) { - // 就地同步仍在后台写入人脸特征,此时开启摄像头会与增量注册争抢引擎。 - faceWorkspaceMessage.value = '人脸数据正在同步,请稍候'; + // 就地注册仍在装载人脸特征,此时开启摄像头会与原生注册争抢引擎。 + faceWorkspaceMessage.value = '人脸库正在注册,请稍候'; return; } final requestId = ++_faceDispatchRequestId; @@ -738,8 +739,8 @@ class SelectPayTypeDialogController extends GetxController { final databaseFaceCount = await faceDao.findCount("-1"); if (!_ownsFaceDispatch(requestId)) return; if (databaseFaceCount == null || databaseFaceCount == 0) { - // 空库不再把收银员引去设置页:切换为就地同步引导,由其当场拉取人脸 - // 后自动续接识别,顾客无需离开结算弹框。 + // 空库不再把收银员引去设置页:切换为就地注册引导,把本机人脸装载进 + // 识别引擎后自动续接识别,顾客无需离开结算弹框。 isRecognition.value = false; isReidentifying.value = false; faceWorkspaceMessage.value = ''; @@ -758,7 +759,7 @@ class SelectPayTypeDialogController extends GetxController { var waitRound = 0; while (status?.registrationInProgress == true && waitRound < 8) { faceWorkspaceMessage.value = - waitRound == 0 ? '人脸数据正在同步,请稍候' : '人脸数据正在同步,请稍候(${waitRound * 2}秒)'; + waitRound == 0 ? '人脸库正在注册,请稍候' : '人脸库正在注册,请稍候(${waitRound * 2}秒)'; await Future.delayed(const Duration(seconds: 2)); if (!_ownsFaceDispatch(requestId)) return; status = await AppFaceTool.getStatus(); @@ -774,8 +775,16 @@ class SelectPayTypeDialogController extends GetxController { throw Exception(msg); } if (status!.memoryCount != databaseFaceCount || !status.engineReady) { - String msg = "人脸库尚未就绪,请前往'设置'->'人脸设置'重新注册"; - throw Exception(msg); + // 引擎与本地库不一致时注册正好可以就地修复,同样进入注册引导而不是 + // 引导收银员离开弹框去设置页。 + isRecognition.value = false; + isReidentifying.value = false; + faceWorkspaceMessage.value = ''; + if (faceSetupPhase.value != _FaceSetupPhase.running) { + faceSetupPhase.value = _FaceSetupPhase.empty; + faceSetupMessage.value = ''; + } + return; } final handle = await service.start( scene: FaceRecognitionScene.cashier, @@ -806,106 +815,101 @@ class SelectPayTypeDialogController extends GetxController { } } - /// 检查异步人脸同步结果是否仍属于未被关闭或切换的本轮就地同步请求。 + /// 检查异步人脸注册结果是否仍属于未被关闭或切换的本轮就地注册请求。 bool _ownsFaceSetup(int requestId) { return requestId == _faceSetupDispatchId; } - /// 在收银弹框内直接同步本部门人脸,完成后自动续接人脸识别。 + /// 在收银弹框内直接把本机人脸数据注册进识别引擎,完成后自动续接人脸识别。 /// - /// 弹框打开期间调度器的安全窗口永远不满足,因此这里直接执行同步并手动 - /// 持有重型任务互斥;识别锁复用 [isRecognition],使底部提交与支付方式 - /// 切换沿既有取消确认流程工作。关闭弹框或切换支付方式后同步会在后台 - /// 安全完成,但不再自动开启摄像头。 - Future startFaceLibrarySetup() async { + /// 收银弹框只负责注册:人脸数据本身的拉取仍由设置页、启动装载和每日任务 + /// 完成,这里不发起任何网络请求,注册只读取本地库并批量装载虹软内存特征。 + /// 识别锁复用 [isRecognition],使底部提交与支付方式切换沿既有取消确认流程 + /// 工作。关闭弹框或切换支付方式后注册会在后台安全完成,但不再自动开启 + /// 摄像头。 + Future startFaceLibraryRegistration() async { if (faceSetupPhase.value == _FaceSetupPhase.running || isSubmitting.value) { return; } - if (!AppNetworkTool.isAutoNetwork()) { - faceSetupPhase.value = _FaceSetupPhase.failed; - faceSetupMessage.value = '网络不可用,无法同步人脸,请恢复网络后重试'; - await AppRuntimeTool.vibrate(durationMs: 60); - return; - } - final setting = await AppSettingTool.getSysSetting(); - if (ObjectUtil.isEmpty(setting.merchantDeptId)) { - faceSetupPhase.value = _FaceSetupPhase.failed; - faceSetupMessage.value = '终端尚未绑定商户部门,请先在设置中绑定设备'; - return; - } - // 弹框打开前已启动的后台人脸同步仍在占用引擎时,等待其结束后再重试。 - if (SyncCheckTool.instance.runningTasks - .contains(ScheduledTaskType.departmentFaceSync)) { - faceSetupPhase.value = _FaceSetupPhase.failed; - faceSetupMessage.value = '人脸数据正在后台同步,请稍后点击重试'; - return; - } final requestId = ++_faceSetupDispatchId; + final status = await AppFaceTool.getStatus(); + if (!_ownsFaceSetup(requestId)) return; + if (status?.activated != true) { + faceSetupPhase.value = _FaceSetupPhase.failed; + faceSetupMessage.value = "人脸插件未激活,请前往'设置'->'人脸设置'处理"; + return; + } _faceSetupRunning = true; faceSetupPhase.value = _FaceSetupPhase.running; faceSetupProgress.value = null; faceSetupMessage.value = ''; isRecognition.value = true; isReidentifying.value = false; - TtsManage.speak('开始同步人脸'); + TtsManage.speak('开始注册人脸'); SyncIdleTool.instance.beginHeavyTask(); + StreamSubscription? progressListen; try { - final summary = await SyncFaceTool.sync( - mode: FaceSyncMode.departmentSnapshot, - onProgress: (processed, total) { + progressListen = FaceDiscernPlugs.registrationStream.listen( + (event) { if (!_ownsFaceSetup(requestId)) return; faceSetupProgress.value = _FaceSetupProgress( - processed: processed, - total: total, + processed: event.processed, + total: event.total > 0 ? event.total : null, ); }, + onError: (Object error, StackTrace stack) { + logger.w('|$_tag|弹框内注册进度监听失败error:$error|stack:$stack|'); + }, ); - if (summary.hasFailures) { - logger.w('|$_tag|弹框内人脸同步部分失败|${summary.message}|'); + FaceRegistrationResult? result; + try { + result = await FaceDiscernPlugs.rebuildFaceLibrary(); + } on PlatformException catch (error) { + if (error.code != 'REGISTRATION_IN_PROGRESS') rethrow; + // 弹框打开前已有注册在装载(多为启动步骤):跟随原生进度等待结束。 + logger.i('|$_tag|弹框内注册避让进行中的原生注册|code:${error.code}|'); + final finished = await _awaitRegistrationIdle(requestId); + if (!_ownsFaceSetup(requestId)) return; + if (!finished) { + faceSetupPhase.value = _FaceSetupPhase.failed; + faceSetupMessage.value = '人脸库正在注册,请稍后点击重试'; + return; + } } if (!_ownsFaceSetup(requestId)) return; - final usableCount = summary.added + summary.updated; - if (summary.scanned == 0) { + if (result != null && result.total == 0) { faceSetupPhase.value = _FaceSetupPhase.failed; - faceSetupMessage.value = '服务端暂无可同步的人脸,请先在管理端录入人脸'; + faceSetupMessage.value = "本机暂无人脸数据,请先前往'设置'->'人脸管理'同步人脸"; return; } - if (usableCount == 0 && summary.failed > 0) { + if (result != null && + result.successCount == 0 && + result.failedCount > 0) { faceSetupPhase.value = _FaceSetupPhase.failed; - faceSetupMessage.value = - summary.hasFailures ? summary.message : '本次没有同步到新的人脸,请稍后重试'; + faceSetupMessage.value = result.message; return; } faceSetupPhase.value = _FaceSetupPhase.success; - faceSetupMessage.value = summary.message; - if (summary.hasFailures) { - Toast.error(msg: '部分人脸同步失败 ${summary.failed} 条,可稍后在设置中重新同步'); + faceSetupMessage.value = result?.message ?? '本机人脸库已装载完成'; + if (result != null && result.failedCount > 0) { + Toast.error(msg: '部分人脸注册失败 ${result.failedCount} 条,可稍后在设置中重新注册'); } else { - TtsManage.speak('人脸同步完成'); - } - // 特征写入已经结束,先解除同步占位与识别锁再续接,避免被防重入拦截。 - _faceSetupRunning = false; - isRecognition.value = false; - if (_ownsFaceSetup(requestId) && - orderPayDisplayType.value == OrderPayDisplayType.face && - payUser.value == null) { - await _dispatchFace(); - if (!_ownsFaceSetup(requestId)) return; - if (faceSessionHandle.value == null && payUser.value == null) { - // 续接失败的原因已由 _dispatchFace 写入 faceWorkspaceMessage。 - faceSetupPhase.value = _FaceSetupPhase.failed; - } + TtsManage.speak('人脸注册完成'); } + await _continueRecognitionAfterSetup(requestId); } catch (error, stack) { - logger.e('|$_tag|弹框内人脸同步失败error:|$error|stack:|$stack|'); + logger.e('|$_tag|弹框内人脸注册失败error:|$error|stack:|$stack|'); if (!_ownsFaceSetup(requestId)) return; faceSetupPhase.value = _FaceSetupPhase.failed; faceSetupMessage.value = - error is FaceSyncSnapshotException ? error.message : '人脸同步失败,请稍后重试'; + error is PlatformException && error.message != null + ? error.message! + : '人脸注册失败,请稍后重试'; await AppRuntimeTool.vibrate(durationMs: 60); } finally { _faceSetupRunning = false; SyncIdleTool.instance.endHeavyTask(); + await progressListen?.cancel(); if (_ownsFaceSetup(requestId) && faceSessionHandle.value == null) { isRecognition.value = false; isReidentifying.value = false; @@ -913,6 +917,56 @@ class SelectPayTypeDialogController extends GetxController { } } + /// 跟随原生注册进度直到结束;超时或流异常返回 false 交由调用方提示重试。 + Future _awaitRegistrationIdle( + int requestId, { + Duration timeout = const Duration(minutes: 3), + }) async { + final completer = Completer(); + StreamSubscription? listen; + listen = FaceDiscernPlugs.registrationStream.listen( + (event) { + if (_ownsFaceSetup(requestId)) { + faceSetupProgress.value = _FaceSetupProgress( + processed: event.processed, + total: event.total > 0 ? event.total : null, + ); + } + if (!event.inProgress && !completer.isCompleted) { + completer.complete(true); + } + }, + onError: (Object error) { + if (!completer.isCompleted) completer.complete(false); + }, + ); + try { + return await completer.future.timeout(timeout, onTimeout: () => false); + } finally { + await listen.cancel(); + } + } + + /// 注册成功后自动续接人脸识别;续接失败的原因已写入 [faceWorkspaceMessage]。 + Future _continueRecognitionAfterSetup(int requestId) async { + // 特征装载已经结束,先解除注册占位与识别锁再续接,避免被防重入拦截。 + _faceSetupRunning = false; + isRecognition.value = false; + if (!_ownsFaceSetup(requestId) || + orderPayDisplayType.value != OrderPayDisplayType.face || + payUser.value != null) { + return; + } + await _dispatchFace(); + if (!_ownsFaceSetup(requestId)) return; + if (faceSessionHandle.value == null && payUser.value == null) { + faceSetupPhase.value = _FaceSetupPhase.failed; + if (faceWorkspaceMessage.value.isEmpty) { + faceSetupMessage.value = '人脸库装载后仍未就绪,请重试或前往设置检查'; + } + } + } + /// 处理嵌入式面板返回的唯一人脸结果,并在原位查询人员与钱包。 Future handleFaceRecognized( FaceStream event, @@ -2373,21 +2427,30 @@ class _PaymentWorkspace extends StatelessWidget { ), ); } - // 人脸库为空或就地同步进行中时,用可操作的同步引导替代离开弹框的指引。 + // 人脸库未就绪或就地注册进行中时,用可操作的注册引导占满支付工作区 + // 剩余区域,替代离开弹框去设置页的指引。 if (type == OrderPayDisplayType.face && controller.faceSetupPhase.value != _FaceSetupPhase.idle) { - return SingleChildScrollView( - child: [ - _FaceLibrarySetupPanel(controller: controller), - if (!paymentEnabled) ...[ - SizedBox(height: 12.h), - _PaymentWorkspaceMessage( - message: disabledMessage ?? '请先添加商品后再继续支付', - warning: true, - ), - ], - ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), - ); + return [ + LayoutBuilder( + builder: (context, constraints) { + // minHeight 让注册卡撑满工作区剩余高度;内容超高时仍可滚动。 + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: _FaceLibrarySetupPanel(controller: controller), + ), + ); + }, + ).expanded(), + if (!paymentEnabled) ...[ + SizedBox(height: 12.h), + _PaymentWorkspaceMessage( + message: disabledMessage ?? '请先添加商品后再继续支付', + warning: true, + ), + ], + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); } return SingleChildScrollView( child: [ @@ -2456,10 +2519,10 @@ class _FaceLibrarySetupPanel extends StatelessWidget { SizedBox(height: 16.h), Text( switch (phase) { - _FaceSetupPhase.running => '正在同步人脸', - _FaceSetupPhase.success => '人脸同步完成', - _FaceSetupPhase.failed => '人脸同步失败', - _ => '人脸库为空', + _FaceSetupPhase.running => '正在注册人脸', + _FaceSetupPhase.success => '人脸注册完成', + _FaceSetupPhase.failed => '人脸注册失败', + _ => '人脸库未就绪', }, textAlign: TextAlign.center, style: theme.typography.lg.copyWith( @@ -2470,14 +2533,13 @@ class _FaceLibrarySetupPanel extends StatelessWidget { SizedBox(height: 8.h), Text( switch (phase) { - _FaceSetupPhase.running => determinate - ? '正在下载照片并注册人脸特征,请保持弹框开启;人数较多时可能需要几分钟。' - : '正在获取服务端人脸清单,已获取 $processed 条,请稍候。', + _FaceSetupPhase.running => + determinate ? '正在把本机人脸特征装载进识别引擎,请保持弹框开启。' : '正在准备本机人脸特征,请稍候。', _FaceSetupPhase.success => setupMessage.isEmpty ? '本机人脸已就绪' : setupMessage, _FaceSetupPhase.failed => - setupMessage.isEmpty ? '同步未完成,请重试或改用其他支付方式' : setupMessage, - _ => '本机还没有可识别人脸,可直接在此同步本部门人脸,完成后自动开始识别支付。', + setupMessage.isEmpty ? '注册未完成,请重试或改用其他支付方式' : setupMessage, + _ => '点击注册将把本机已同步的人脸装载进识别引擎,完成后自动开始人脸识别。', }, textAlign: TextAlign.center, style: theme.typography.sm.copyWith( @@ -2489,8 +2551,8 @@ class _FaceLibrarySetupPanel extends StatelessWidget { SizedBox(height: 18.h), Semantics( label: determinate - ? '人脸同步进度,已处理 $processed 条,共 $total 条' - : '正在获取服务端人脸清单,已获取 $processed 条', + ? '人脸注册进度,已注册 $processed 条,共 $total 条' + : '正在准备本机人脸特征,已处理 $processed 条', child: [ ClipRRect( borderRadius: BorderRadius.circular(8.w), @@ -2508,7 +2570,7 @@ class _FaceLibrarySetupPanel extends StatelessWidget { SizedBox(height: 8.h), [ Text( - determinate ? '已处理 $processed / $total' : '已获取 $processed 条', + determinate ? '已注册 $processed / $total' : '已处理 $processed 条', style: theme.typography.sm.copyWith( color: XfjDesignTokens.muted, fontFeatures: const [FontFeature.tabularFigures()], @@ -2530,13 +2592,13 @@ class _FaceLibrarySetupPanel extends StatelessWidget { if (phase == _FaceSetupPhase.empty) ...[ SizedBox(height: 18.h), _PaymentDialogButton( - key: const ValueKey('face-setup-sync-button'), - label: '同步人脸并继续识别', - onTap: controller.startFaceLibrarySetup, + key: const ValueKey('face-setup-register-button'), + label: '注册人脸', + onTap: controller.startFaceLibraryRegistration, ), SizedBox(height: 12.h), Text( - '同步也可稍后在 设置 → 人脸管理 中进行', + '本机人脸数据的同步由 设置 → 人脸管理 或每日任务完成', textAlign: TextAlign.center, style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), ), @@ -2549,8 +2611,8 @@ class _FaceLibrarySetupPanel extends StatelessWidget { SizedBox(height: 18.h), _PaymentDialogButton( key: const ValueKey('face-setup-retry-button'), - label: '重试同步', - onTap: controller.startFaceLibrarySetup, + label: '重试注册', + onTap: controller.startFaceLibraryRegistration, ), SizedBox(height: 12.h), Text( @@ -2573,7 +2635,11 @@ class _FaceLibrarySetupPanel extends StatelessWidget { ), ], ] - .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + .toColumn( + // 卡片由外层约束撑满工作区剩余高度,内容在整卡内垂直居中。 + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + ) .padding(all: 22.w) .decorated( key: const ValueKey('face-setup-panel'), diff --git a/lib/pages/canteen_product/controller.dart b/lib/pages/canteen_product/controller.dart index 53c1c2f..56bba2e 100644 --- a/lib/pages/canteen_product/controller.dart +++ b/lib/pages/canteen_product/controller.dart @@ -253,11 +253,26 @@ class CanteenProductController extends GetxController with OrderMixin { this.orderPayType.value = orderPayType; this.orderPayDisplayType.value = orderPayDisplayType; this.globalQrCode = qrCode; + pendingFaceRecognition.value = faceRecognition; payUser.value = user; final validPayUser = _validatePayUser(); - if (!validPayUser) return false; + if (!validPayUser) { + final sessionId = faceRecognition?.sessionId; + if (sessionId != null) { + await OrderEvidenceUploadTool.cancelSession(sessionId); + } + pendingFaceRecognition.value = null; + return false; + } final validShopCar = _validateShopCar(); - if (!validShopCar) return false; + if (!validShopCar) { + final sessionId = faceRecognition?.sessionId; + if (sessionId != null) { + await OrderEvidenceUploadTool.cancelSession(sessionId); + } + pendingFaceRecognition.value = null; + return false; + } EasyLoading.show( status: '支付中...', maskType: EasyLoadingMaskType.black, diff --git a/lib/pages/supermarket_order/controller.dart b/lib/pages/supermarket_order/controller.dart index 23882dc..7b9005b 100644 --- a/lib/pages/supermarket_order/controller.dart +++ b/lib/pages/supermarket_order/controller.dart @@ -354,11 +354,26 @@ class SupermarketOrderController extends GetxController with OrderMixin { this.orderPayType.value = orderPayType; this.orderPayDisplayType.value = orderPayDisplayType; this.globalQrCode = qrCode; + pendingFaceRecognition.value = faceRecognition; payUser.value = user; final validPayUser = _validatePayUser(); - if (!validPayUser) return false; + if (!validPayUser) { + final sessionId = faceRecognition?.sessionId; + if (sessionId != null) { + await OrderEvidenceUploadTool.cancelSession(sessionId); + } + pendingFaceRecognition.value = null; + return false; + } final validShopCar = _validateShopCar(); - if (!validShopCar) return false; + if (!validShopCar) { + final sessionId = faceRecognition?.sessionId; + if (sessionId != null) { + await OrderEvidenceUploadTool.cancelSession(sessionId); + } + pendingFaceRecognition.value = null; + return false; + } EasyLoading.show( status: '支付中...', maskType: EasyLoadingMaskType.black,