diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java index 678a997..121ca12 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java @@ -831,8 +831,8 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba rawResult.put("similarityThreshold", resultThreshold); recognitionExecutor.execute(() -> { FaceEvidenceImageWriter.Result evidence = null; - // 只有收银支付场景才保存长期证据候选;预约查询和身份核验不得创建现场证据。 - if ("cashier".equals(resultScene)) { + // 收银支付和预约菜单核销都需要保留识别成功帧;普通身份核验等其他场景不写入长期证据候选。 + if ("cashier".equals(resultScene) || "reservation".equals(resultScene)) { evidence = FaceEvidenceImageWriter.write( context.getFilesDir(), frameSnapshot, resultRotation, resultMirror, frameSnapshot == null || frameSnapshot.getFaceInfo() == null diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart index 6a7dfe1..821b505 100644 --- a/lib/common/api/hard_api.dart +++ b/lib/common/api/hard_api.dart @@ -5,14 +5,18 @@ import 'package:dio/dio.dart' as dio; import 'package:pj_supermarket_cloud/common/index.dart'; abstract class HardApi { - /// 按终端订单号对账服务端订单和识别证据状态;后台任务失败不弹业务 Toast。 + /// 按终端订单号或服务端订单 ID 对账识别证据状态;后台任务失败不弹业务 Toast。 static Future resolveOrderEvidence({ required String hardId, - required String androidOrderCode, + String? androidOrderCode, + String? orderId, }) async { final res = await HttpManage().dio.get( '/hard/hard-order-evidence/ad/resolve', - queryParameters: {'androidOrderCode': androidOrderCode}, + queryParameters: { + if (androidOrderCode != null) 'androidOrderCode': androidOrderCode, + if (orderId != null) 'orderId': orderId, + }, options: dio.Options( headers: {'DEVICE-ID': hardId}, extra: {HttpManage.silentRequestKey: true}, diff --git a/lib/common/models/api/hard_api_models.dart b/lib/common/models/api/hard_api_models.dart index 549c70e..5325a33 100644 --- a/lib/common/models/api/hard_api_models.dart +++ b/lib/common/models/api/hard_api_models.dart @@ -9,7 +9,8 @@ part 'hard_api_models.g.dart'; class OrderEvidenceRequest { /// 创建一条与服务端证据位置协议一致的请求。 const OrderEvidenceRequest({ - required this.androidOrderCode, + this.androidOrderCode, + this.orderId, required this.evidenceType, required this.evidenceRole, required this.sequenceNo, @@ -29,8 +30,11 @@ class OrderEvidenceRequest { required this.appVersion, }); - /// 终端稳定订单号,用于服务端按设备对账订单。 - final String androidOrderCode; + /// 终端稳定订单号,用于服务端按设备对账现场创建的订单;预约核销证据允许为空。 + final String? androidOrderCode; + + /// 已由当前设备核销的服务端预约订单 ID;现场创建的订单证据允许为空。 + final String? orderId; /// 证据业务类型,使用 `OrderEvidenceType.code`。 final String evidenceType; diff --git a/lib/common/models/api/hard_api_models.g.dart b/lib/common/models/api/hard_api_models.g.dart index 46abd09..fe5436c 100644 --- a/lib/common/models/api/hard_api_models.g.dart +++ b/lib/common/models/api/hard_api_models.g.dart @@ -9,7 +9,8 @@ part of 'hard_api_models.dart'; OrderEvidenceRequest _$OrderEvidenceRequestFromJson( Map json) => OrderEvidenceRequest( - androidOrderCode: json['androidOrderCode'] as String, + androidOrderCode: json['androidOrderCode'] as String?, + orderId: json['orderId'] as String?, evidenceType: json['evidenceType'] as String, evidenceRole: json['evidenceRole'] as String, sequenceNo: (json['sequenceNo'] as num).toInt(), @@ -33,13 +34,7 @@ OrderEvidenceRequest _$OrderEvidenceRequestFromJson( Map _$OrderEvidenceRequestToJson( OrderEvidenceRequest instance) { - final val = { - 'androidOrderCode': instance.androidOrderCode, - 'evidenceType': instance.evidenceType, - 'evidenceRole': instance.evidenceRole, - 'sequenceNo': instance.sequenceNo, - 'userId': instance.userId, - }; + final val = {}; void writeNotNull(String key, dynamic value) { if (value != null) { @@ -47,6 +42,12 @@ Map _$OrderEvidenceRequestToJson( } } + writeNotNull('androidOrderCode', instance.androidOrderCode); + writeNotNull('orderId', instance.orderId); + val['evidenceType'] = instance.evidenceType; + val['evidenceRole'] = instance.evidenceRole; + val['sequenceNo'] = instance.sequenceNo; + val['userId'] = instance.userId; writeNotNull('faceSampleId', instance.faceSampleId); writeNotNull('referenceImageUrl', instance.referenceImageUrl); writeNotNull('referenceImageVersion', diff --git a/lib/common/services/database/dao/order_evidence_upload_task_dao.dart b/lib/common/services/database/dao/order_evidence_upload_task_dao.dart index 5df07b9..219ec2a 100644 --- a/lib/common/services/database/dao/order_evidence_upload_task_dao.dart +++ b/lib/common/services/database/dao/order_evidence_upload_task_dao.dart @@ -29,6 +29,22 @@ abstract class OrderEvidenceUploadTaskDao { int sequenceNo, ); + /// 按服务端订单 ID 和证据位置查询预约核销任务;不存在时返回空。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE serverOrderId = :serverOrderId + AND evidenceType = :evidenceType + AND evidenceRole = :evidenceRole + AND sequenceNo = :sequenceNo + LIMIT 1 + ''') + Future getByServerOrderPosition( + String serverOrderId, + String evidenceType, + String evidenceRole, + int sequenceNo, + ); + /// 查询同一终端订单的全部任务,按类型、角色和序号稳定排序。 @Query(''' SELECT * FROM order_evidence_upload_task @@ -53,7 +69,7 @@ abstract class OrderEvidenceUploadTaskDao { @Query(''' SELECT * FROM order_evidence_upload_task WHERE status IN ('WAITING_ORDER', 'READY', 'UPLOADING', 'RETRY_WAIT') - AND androidOrderCode IS NOT NULL + AND (androidOrderCode IS NOT NULL OR serverOrderId IS NOT NULL) AND (nextRetryAt IS NULL OR nextRetryAt <= :now) ORDER BY createTime ASC, id ASC LIMIT :limit @@ -67,7 +83,7 @@ abstract class OrderEvidenceUploadTaskDao { @Query(''' SELECT * FROM order_evidence_upload_task WHERE status IN ('PERMANENT_FAILED', 'EXPIRED') - AND androidOrderCode IS NOT NULL + AND (androidOrderCode IS NOT NULL OR serverOrderId IS NOT NULL) AND unavailableReportedAt IS NULL AND (nextRetryAt IS NULL OR nextRetryAt <= :now) ORDER BY updateTime ASC, id ASC diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index 31662ef..9f1a7b1 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -2591,6 +2591,19 @@ class _$OrderEvidenceUploadTaskDao extends OrderEvidenceUploadTaskDao { arguments: [androidOrderCode, evidenceType, evidenceRole, sequenceNo]); } + @override + Future getByServerOrderPosition( + String serverOrderId, + String evidenceType, + String evidenceRole, + int sequenceNo, + ) async { + return _queryAdapter.query( + 'SELECT * FROM order_evidence_upload_task WHERE serverOrderId = ?1 AND evidenceType = ?2 AND evidenceRole = ?3 AND sequenceNo = ?4 LIMIT 1', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [serverOrderId, evidenceType, evidenceRole, sequenceNo]); + } + @override Future> getByOrderCode( String androidOrderCode) async { @@ -2615,7 +2628,7 @@ class _$OrderEvidenceUploadTaskDao extends OrderEvidenceUploadTaskDao { int limit, ) async { return _queryAdapter.queryList( - 'SELECT * FROM order_evidence_upload_task WHERE status IN (\'WAITING_ORDER\', \'READY\', \'UPLOADING\', \'RETRY_WAIT\') AND androidOrderCode IS NOT NULL AND (nextRetryAt IS NULL OR nextRetryAt <= ?1) ORDER BY createTime ASC, id ASC LIMIT ?2', + 'SELECT * FROM order_evidence_upload_task WHERE status IN (\'WAITING_ORDER\', \'READY\', \'UPLOADING\', \'RETRY_WAIT\') AND (androidOrderCode IS NOT NULL OR serverOrderId IS NOT NULL) AND (nextRetryAt IS NULL OR nextRetryAt <= ?1) ORDER BY createTime ASC, id ASC LIMIT ?2', mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), arguments: [_dateTimeConverter.encode(now), limit]); } @@ -2626,7 +2639,7 @@ class _$OrderEvidenceUploadTaskDao extends OrderEvidenceUploadTaskDao { int limit, ) async { return _queryAdapter.queryList( - 'SELECT * FROM order_evidence_upload_task WHERE status IN (\'PERMANENT_FAILED\', \'EXPIRED\') AND androidOrderCode IS NOT NULL AND unavailableReportedAt IS NULL AND (nextRetryAt IS NULL OR nextRetryAt <= ?1) ORDER BY updateTime ASC, id ASC LIMIT ?2', + 'SELECT * FROM order_evidence_upload_task WHERE status IN (\'PERMANENT_FAILED\', \'EXPIRED\') AND (androidOrderCode IS NOT NULL OR serverOrderId IS NOT NULL) AND unavailableReportedAt IS NULL AND (nextRetryAt IS NULL OR nextRetryAt <= ?1) ORDER BY updateTime ASC, id ASC LIMIT ?2', mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), arguments: [_dateTimeConverter.encode(now), limit]); } diff --git a/lib/common/utils/sync/order_evidence_upload_tool.dart b/lib/common/utils/sync/order_evidence_upload_tool.dart index c0030ea..38afb9b 100644 --- a/lib/common/utils/sync/order_evidence_upload_tool.dart +++ b/lib/common/utils/sync/order_evidence_upload_tool.dart @@ -41,6 +41,9 @@ abstract final class OrderEvidenceUploadTool { static OrderEvidenceUploadTaskDao get _dao => Get.find().dataBase.getOrderEvidenceUploadTaskDao; + /// 当前正在执行的补传批次;预约核销即时触发与五分钟定时任务共用,防止并发处理同一现场文件。 + static Future? _processingBatch; + /// 保存一次收银场景人脸成功结果对应的参考图和现场图待办。 /// /// 同一识别会话重复回调时直接复用已有记录。现场文件生成失败仍会建立缺失任务,待订单 @@ -159,6 +162,42 @@ abstract final class OrderEvidenceUploadTool { return bound.map(_toCreateOrderMetadata).toList(growable: false); } + /// 在预约核销请求发出前,把识别会话预关联到已经存在的服务端订单。 + /// + /// 预关联仍保持 `WAITING_ORDER`,只有核销成功后才切换为可上传状态;即使核销响应丢失, + /// 后台任务也能等订单归属变更到当前设备后继续对账。重复选择同一订单时复用原任务。 + static Future 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 getSessionAppVersion( String recognitionSessionId) async { @@ -182,6 +221,7 @@ abstract final class OrderEvidenceUploadTool { clearNextRetryAt: true, clearLastError: true, updateTime: now, + expiresAt: now.add(failedImageRetention), )); } } @@ -208,6 +248,19 @@ abstract final class OrderEvidenceUploadTool { /// 串行处理一批到期任务,并在业务处理前完成 24 小时、30 天和成功元数据清理。 static Future 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 _processPendingBatch() async { final now = DateTime.now(); await _cleanupExpired(now); final setting = await AppSettingTool.getSysSetting(); @@ -290,13 +343,14 @@ abstract final class OrderEvidenceUploadTool { OrderEvidenceUploadTaskEntity source, ) async { var task = source; - if (task.androidOrderCode == null || task.androidOrderCode!.isEmpty) { + if (!_hasOrderReference(task)) { return _TaskProcessResult.skipped; } try { final resolved = await HardApi.resolveOrderEvidence( hardId: hardId, - androidOrderCode: task.androidOrderCode!, + androidOrderCode: task.androidOrderCode, + orderId: task.serverOrderId, ); if (resolved == null) throw StateError('订单对账结果为空'); if (!uploadableOrderStatuses.contains(resolved.orderStatus)) { @@ -342,6 +396,7 @@ abstract final class OrderEvidenceUploadTool { status: OrderEvidenceTaskStatus.ready.code, clearNextRetryAt: true, updateTime: DateTime.now(), + expiresAt: DateTime.now().add(failedImageRetention), ); await _dao.edit(task); @@ -375,7 +430,14 @@ abstract final class OrderEvidenceUploadTool { ); return await _applyServiceResult(task, result); } catch (error) { - if (_isPermanentError(error)) { + // 预约核销请求发出前已预关联服务端订单,但订单只有核销提交后才归属当前设备。 + // 这里把核销前的归属拒绝视为可重试,避免请求响应丢失后永久丢掉已经采集的现场图片。 + if (_isPreparedReservationEvidence(task)) { + await _schedulePreparedReservationRetry( + task, + _safeFailureForError(error), + ); + } else if (_isPermanentError(error)) { await _markPermanent(task, _safeFailureForError(error)); } else { await _scheduleRetry(task, _safeFailureForError(error)); @@ -426,7 +488,8 @@ abstract final class OrderEvidenceUploadTool { if (task.serverOrderId == null) { final resolved = await HardApi.resolveOrderEvidence( hardId: hardId, - androidOrderCode: task.androidOrderCode!, + androidOrderCode: task.androidOrderCode, + orderId: task.serverOrderId, ); if (resolved == null || !uploadableOrderStatuses.contains(resolved.orderStatus)) { @@ -591,6 +654,22 @@ abstract final class OrderEvidenceUploadTool { )); } + /// 预约核销尚未确认时保留等待状态并设置退避,避免设备归属尚未更新被误判为永久失败。 + static Future _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 _scheduleUnavailableRetry( OrderEvidenceUploadTaskEntity task, @@ -632,7 +711,8 @@ abstract final class OrderEvidenceUploadTool { String? failureReason, }) { return OrderEvidenceRequest( - androidOrderCode: task.androidOrderCode!, + androidOrderCode: task.androidOrderCode, + orderId: task.serverOrderId, evidenceType: task.evidenceType, evidenceRole: task.evidenceRole, sequenceNo: task.sequenceNo, @@ -697,6 +777,20 @@ abstract final class OrderEvidenceUploadTool { 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; diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index d6b8f11..775d2ce 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -13,8 +13,8 @@ class Constants { static const pwdEncKey = 'pigxpigxpigxpigx'; /// 服务 url 线上 - // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; - static const apiUrl = 'https://www.zianxin.com/api'; + static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + // static const apiUrl = 'https://www.zianxin.com/api'; // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; diff --git a/lib/pages/terminal/menu_query/controller.dart b/lib/pages/terminal/menu_query/controller.dart index d0721fd..cda6a2d 100644 --- a/lib/pages/terminal/menu_query/controller.dart +++ b/lib/pages/terminal/menu_query/controller.dart @@ -168,6 +168,9 @@ class ReservationMenuQueryController extends GetxController { String? _recognizedIdentityValue; + /// 当前真实人脸查询产生的本地证据会话;核销成功后移交上传队列,其他清屏路径必须删除。 + String? _recognizedFaceEvidenceSessionId; + /// 页面层订阅的一次性快速核销请求;使用广播流避免测试或页面尚未挂载时缓存过期弹框。 final StreamController _quickVerificationRequestController = @@ -279,6 +282,7 @@ class ReservationMenuQueryController extends GetxController { _cancelRecognitionRestart(); final generation = ++_requestGeneration; _stopPrivacyTimer(); + await _discardCurrentFaceEvidence('切换预约查询识别方式'); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; @@ -309,6 +313,7 @@ class ReservationMenuQueryController extends GetxController { _recognitionLocked = false; if (!preserveCardResult) { _stopPrivacyTimer(); + await _discardCurrentFaceEvidence('开始新的预约查询识别'); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; @@ -326,11 +331,11 @@ class ReservationMenuQueryController extends GetxController { if (_closed || generation != _requestGeneration) return; } - Future recognized(String value) async { + Future recognized(ReservationRecognizedIdentity identity) async { if (_closed || generation != _requestGeneration || _recognitionLocked) { return; } - final identityValue = value.trim(); + final identityValue = identity.identityValue.trim(); if (identityValue.isEmpty) { _recognitionLocked = true; if (preserveCardResult) { @@ -369,10 +374,12 @@ class ReservationMenuQueryController extends GetxController { _stopPrivacyTimer(); await _recognitionGateway.stopAll(); if (_closed || generation != _requestGeneration) return; + await _replaceFaceEvidenceSession(identity.faceEvidenceSessionId); await _queryIdentity( identityValue, generation, preserveCurrentResultUntilSuccess: preserveCardResult, + faceEvidenceSessionId: identity.faceEvidenceSessionId, ); } @@ -505,11 +512,16 @@ class ReservationMenuQueryController extends GetxController { String identityValue, int generation, { bool preserveCurrentResultUntilSuccess = false, + String? faceEvidenceSessionId, }) async { final preserveCurrentResult = preserveCurrentResultUntilSuccess && result.value != null && status.value.hasIdentity; if (!_networkAvailable()) { + await _discardFaceEvidenceSession( + faceEvidenceSessionId, + reason: '预约查询断网,未取得可核销订单', + ); if (preserveCurrentResult) { await _retainCurrentCardResultAfterQueryFailure( generation, @@ -538,6 +550,7 @@ class ReservationMenuQueryController extends GetxController { // 换卡请求期间继续展示上一位结果,并同步刷新副屏到期时间,成功后再原子替换。 _refreshRetainedCardResultDisplay(); } + var keepFaceEvidence = false; try { logger.i( '|预约查询|开始请求预约菜单|method:${method.value.name}|' @@ -570,6 +583,7 @@ class ReservationMenuQueryController extends GetxController { } result.value = ReservationMenuDisplayData.fromService(response); _recognizedIdentityValue = identityValue; + keepFaceEvidence = true; currentPage.value = 1; errorMessage.value = ''; status.value = response.days.isEmpty @@ -661,6 +675,54 @@ class ReservationMenuQueryController extends GetxController { errorMessage.value = '暂时无法查询预约,请稍后重试'; status.value = ReservationMenuQueryStatus.networkError; await _syncSubScreen('error'); + } finally { + if (!keepFaceEvidence) { + await _discardFaceEvidenceSession( + faceEvidenceSessionId, + reason: '预约查询未取得有效结果', + ); + } + } + } + + /// 用新识别结果替换当前人脸证据会话;旧会话尚未核销时立即清理本地图片。 + Future _replaceFaceEvidenceSession(String? nextSessionId) async { + final previousSessionId = _recognizedFaceEvidenceSessionId; + if (previousSessionId != null && previousSessionId != nextSessionId) { + await _discardFaceEvidenceSession( + previousSessionId, + reason: '新的预约查询身份已替换旧人脸结果', + ); + } + _recognizedFaceEvidenceSessionId = nextSessionId; + } + + /// 清理当前页面持有但尚未绑定核销订单的人脸证据会话。 + Future _discardCurrentFaceEvidence(String reason) async { + await _discardFaceEvidenceSession( + _recognizedFaceEvidenceSessionId, + reason: reason, + ); + } + + /// 按会话精确回收未使用图片;清理失败只记录类型,过期维护仍会继续收敛文件。 + Future _discardFaceEvidenceSession( + String? sessionId, { + required String reason, + }) async { + if (sessionId == null || sessionId.isEmpty) return; + if (_recognizedFaceEvidenceSessionId == sessionId) { + _recognizedFaceEvidenceSessionId = null; + } + try { + await _verificationGateway.discardFaceEvidence( + recognitionSessionId: sessionId, + reason: reason, + ); + } catch (error) { + logger.w( + '|预约查询|未使用人脸证据清理失败|errorType:${error.runtimeType}|', + ); } } @@ -819,6 +881,7 @@ class ReservationMenuQueryController extends GetxController { _cancelRecognitionRestart(); final generation = ++_requestGeneration; _stopPrivacyTimer(); + await _discardCurrentFaceEvidence('预约结果弹框关闭后开始下一轮识别'); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; @@ -891,6 +954,7 @@ class ReservationMenuQueryController extends GetxController { return false; } final recognitionMethod = method.value; + final faceEvidenceSessionId = _recognizedFaceEvidenceSessionId; _verificationSubmitting = true; _stopPrivacyTimer(); @@ -898,8 +962,13 @@ class ReservationMenuQueryController extends GetxController { // selectedOrderId 只能来自当前点击餐段的订单选择器,避免误取其他餐段的 ID。 final canceled = await _verificationGateway.cancel( orderId: selectedOrderId, + faceEvidenceSessionId: faceEvidenceSessionId, ); if (!canceled) throw StateError('订单 $selectedOrderId 核销接口未返回成功'); + if (_recognizedFaceEvidenceSessionId == faceEvidenceSessionId) { + // 核销网关已把会话移交给持久化补传队列,后续页面清屏不得再取消这些任务。 + _recognizedFaceEvidenceSessionId = null; + } unawaited(_announceVerificationSuccess()); try { @@ -1065,6 +1134,7 @@ class ReservationMenuQueryController extends GetxController { _stopPrivacyTimer(); await _recognitionGateway.stopAll(); if (_closed || generation != _requestGeneration) return; + await _discardCurrentFaceEvidence('预约查询隐私清屏'); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; @@ -1135,6 +1205,7 @@ class ReservationMenuQueryController extends GetxController { _cancelRecognitionRestart(); _stopPrivacyTimer(); unawaited(_recognitionGateway.stopAll()); + unawaited(_discardCurrentFaceEvidence('退出预约查询页面')); unawaited(_clearSubScreen()); unawaited(_quickVerificationRequestController.close()); mealSlotC.dispose(); diff --git a/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart b/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart index a615a87..ad9a691 100644 --- a/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart +++ b/lib/pages/terminal/menu_query/reservation_recognition_gateway.dart @@ -31,17 +31,34 @@ class ReservationRecognitionFailure { final String message; } +/// 查询菜单识别成功后的身份与可选人脸证据会话。 +class ReservationRecognizedIdentity { + /// 创建一次已完成硬件校验的身份结果;饭卡和调试输入没有人脸证据会话。 + const ReservationRecognizedIdentity({ + required this.identityValue, + this.faceEvidenceSessionId, + }); + + /// 交给预约查询接口的饭卡号或用户 ID。 + final String identityValue; + + /// 已保存参考照和现场照任务的人脸会话 ID;非真实人脸识别时为空。 + final String? faceEvidenceSessionId; +} + /// 查询菜单使用的硬件识别边界,Controller 不直接依赖串口或原生人脸插件。 abstract interface class ReservationRecognitionGateway { /// 启动单轮饭卡识别,并通过回调返回身份或分类失败。 Future startCard({ - required Future Function(String cardNumber) onRecognized, + required Future Function(ReservationRecognizedIdentity identity) + onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, }); /// 启动单轮人脸识别,并通过回调返回用户编号或分类失败。 Future startFace({ - required Future Function(String userId) onRecognized, + required Future Function(ReservationRecognizedIdentity identity) + onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, bool embeddedPreview = false, }); @@ -72,7 +89,8 @@ class TerminalReservationRecognitionGateway /// 启动饭卡读取;设备在线但未放卡的超时会单独分类,供持续模式安全续轮。 @override Future startCard({ - required Future Function(String cardNumber) onRecognized, + required Future Function(ReservationRecognizedIdentity identity) + onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, }) async { await stopFace(); @@ -100,7 +118,9 @@ class TerminalReservationRecognitionGateway )); return; } - await onRecognized(_sanitizeCardNumber(cardNumber)); + await onRecognized(ReservationRecognizedIdentity( + identityValue: _sanitizeCardNumber(cardNumber), + )); return; } @@ -125,7 +145,9 @@ class TerminalReservationRecognitionGateway )); return; } - unawaited(onRecognized(cardNumber)); + unawaited(onRecognized(ReservationRecognizedIdentity( + identityValue: cardNumber, + ))); break; case SerialCardResStatus.warning: break; @@ -146,7 +168,8 @@ class TerminalReservationRecognitionGateway /// 启动人脸查询会话;当前实现会持续等待,若原生未来上报超时则转换为可续轮分类。 @override Future startFace({ - required Future Function(String userId) onRecognized, + required Future Function(ReservationRecognizedIdentity identity) + onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, bool embeddedPreview = false, }) async { @@ -175,7 +198,9 @@ class TerminalReservationRecognitionGateway )); return; } - await onRecognized(userId.trim()); + await onRecognized(ReservationRecognizedIdentity( + identityValue: userId.trim(), + )); return; } @@ -209,6 +234,7 @@ class TerminalReservationRecognitionGateway final service = Get.find(); FaceRecognitionSessionHandle? handle; var recognitionCompleted = false; + String? createdEvidenceSessionId; try { handle = await service.start(scene: FaceRecognitionScene.reservation); _faceSessionId = handle.sessionId; @@ -248,7 +274,29 @@ class TerminalReservationRecognitionGateway )); return; } - await onRecognized(userId); + final sessionId = result.sessionId?.trim(); + if (sessionId == null || sessionId.isEmpty) { + throw StateError('识别结果缺少会话编号,请重新识别人脸'); + } + final sampleId = result.sampleId?.toString(); + if (sampleId == null || sampleId.isEmpty) { + throw StateError('识别结果缺少人脸样本编号,请重新同步人脸'); + } + final face = await _app.dataBase.getFaceDao.findBySampleId(sampleId); + if (face == null || face.userId != userId) { + throw StateError('本地人脸样本与识别人员不一致,请重新同步人脸'); + } + // 查询菜单同样先持久化现场照,后续网络核销成功后再绑定订单,避免会话结束时丢失图片。 + createdEvidenceSessionId = sessionId; + await OrderEvidenceUploadTool.createFaceRecognitionTasks( + recognition: result, + face: face, + ); + await onRecognized(ReservationRecognizedIdentity( + identityValue: userId, + faceEvidenceSessionId: sessionId, + )); + createdEvidenceSessionId = null; } on FaceRecognitionFailure catch (error) { onFailure(ReservationRecognitionFailure( error.cancelled @@ -259,6 +307,18 @@ class TerminalReservationRecognitionGateway error.message, )); } catch (error, stack) { + if (createdEvidenceSessionId != null) { + try { + await OrderEvidenceUploadTool.cancelSession( + createdEvidenceSessionId, + reason: '预约查询的人脸身份或证据校验未完成', + ); + } catch (cleanupError) { + logger.w( + '|预约查询|人脸证据任务回收失败|errorType:${cleanupError.runtimeType}|', + ); + } + } logger.e('|预约查询|人脸识别失败error:|$error|stack:|$stack|'); onFailure(ReservationRecognitionFailure( ReservationRecognitionFailureType.device, diff --git a/lib/pages/terminal/menu_query/reservation_verification_gateway.dart b/lib/pages/terminal/menu_query/reservation_verification_gateway.dart index c818047..8d08408 100644 --- a/lib/pages/terminal/menu_query/reservation_verification_gateway.dart +++ b/lib/pages/terminal/menu_query/reservation_verification_gateway.dart @@ -1,8 +1,18 @@ +import 'dart:async'; + import 'package:pj_supermarket_cloud/common/index.dart'; abstract interface class ReservationVerificationGateway { + /// 核销当前查询结果中的预约订单,并在真实人脸识别时移交对应证据会话。 Future cancel({ required int orderId, + String? faceEvidenceSessionId, + }); + + /// 清理尚未绑定核销订单的人脸图片任务,页面清屏或放弃当前身份时调用。 + Future discardFaceEvidence({ + required String recognitionSessionId, + required String reason, }); } @@ -12,7 +22,52 @@ class TerminalReservationVerificationGateway @override Future cancel({ required int orderId, + String? faceEvidenceSessionId, + }) async { + if (faceEvidenceSessionId != null) { + // 先落服务端订单 ID,确保核销响应丢失或进程退出后仍能恢复这次现场图片。 + await OrderEvidenceUploadTool.prepareSessionForExistingOrder( + recognitionSessionId: faceEvidenceSessionId, + serverOrderId: '$orderId', + ); + } + final canceled = await HardApi.cancelReservationOrder(orderId: orderId); + if (!canceled || faceEvidenceSessionId == null) return canceled; + try { + await OrderEvidenceUploadTool.markOrderReady( + recognitionSessionId: faceEvidenceSessionId, + serverOrderId: '$orderId', + ); + } catch (error) { + // 核销已经由服务端确认,不能因本地队列状态更新失败向操作员误报核销失败。 + logger.w( + '|预约查询|核销成功后更新人脸证据任务失败|errorType:${error.runtimeType}|', + ); + } + unawaited(_uploadPendingEvidence()); + return true; + } + + /// 删除当前身份尚未绑定订单的现场图,避免仅查询菜单时保留无业务用途的人脸图片。 + @override + Future discardFaceEvidence({ + required String recognitionSessionId, + required String reason, }) { - return HardApi.cancelReservationOrder(orderId: orderId); + return OrderEvidenceUploadTool.cancelSession( + recognitionSessionId, + reason: reason, + ); + } + + /// 立即尝试补传本次核销证据;失败由本地队列按既有退避策略继续处理。 + Future _uploadPendingEvidence() async { + try { + await OrderEvidenceUploadTool.processPending(); + } catch (error) { + logger.w( + '|预约查询|核销成功后首次上传人脸证据失败|errorType:${error.runtimeType}|', + ); + } } } diff --git a/test/common/order_evidence_upload_tool_test.dart b/test/common/order_evidence_upload_tool_test.dart index 9283c7f..6758d31 100644 --- a/test/common/order_evidence_upload_tool_test.dart +++ b/test/common/order_evidence_upload_tool_test.dart @@ -104,4 +104,21 @@ void main() { request.toJson()['referenceImageVersion'], version.toIso8601String()); expect(request.toJson(), isNot(contains('imageHash'))); }); + + test('预约核销证据只使用服务端订单 ID 且不伪造终端订单号', () { + final capturedAt = DateTime(2026, 8, 23, 12, 30); + final request = OrderEvidenceRequest( + orderId: '2081674128087121921', + evidenceType: OrderEvidenceType.faceRecognition.code, + evidenceRole: OrderEvidenceRole.faceLive.code, + sequenceNo: 0, + userId: '1001', + faceSampleId: '2001', + capturedAt: capturedAt, + appVersion: '1.4.0+140', + ); + + expect(request.toJson()['orderId'], '2081674128087121921'); + expect(request.toJson(), isNot(contains('androidOrderCode'))); + }); } diff --git a/test/pages/terminal/menu_query_controller_test.dart b/test/pages/terminal/menu_query_controller_test.dart index 4874573..250f796 100644 --- a/test/pages/terminal/menu_query_controller_test.dart +++ b/test/pages/terminal/menu_query_controller_test.dart @@ -1038,6 +1038,87 @@ void main() { await controller.clearForPrivacy(); }); + testWidgets('人脸核销把识别证据会话绑定到所选订单且成功后不再清理', (tester) async { + final repository = _SequenceRepository([ + _resultWithOrderIds('张*明', const [1002]), + _result('张*明', 0), + ]); + final recognition = _FakeRecognitionGateway(); + final verification = _FakeVerificationGateway(); + final controller = ReservationMenuQueryController( + repository: repository, + recognitionGateway: recognition, + subScreenGateway: _FakeSubScreenGateway(), + verificationGateway: verification, + networkAvailable: () => true, + speaker: (_) async {}, + successNotifier: (_) {}, + errorNotifier: (_) {}, + preferencesLoader: () async => const ReservationQueryPagePreferences( + methodOrder: ReservationRecognitionMethod.values, + defaultMethod: ReservationRecognitionMethod.face, + verificationEnabled: true, + verificationDateScope: ReservationVerificationDateScope.all, + ), + now: () => DateTime(2026, 7, 24, 8), + ); + addTearDown(controller.onClose); + + controller.onInit(); + await tester.pump(); + await controller.startRecognition(); + await recognition.emitFaceAt( + 0, + '1001', + evidenceSessionId: 'face-session-1', + ); + final day = controller.result.value!.days.single; + final meal = day.meals.single; + + expect(await controller.confirmVerification(day, meal, 1002), isTrue); + expect( + verification.calls, + const [ + _VerificationCall( + orderId: 1002, + faceEvidenceSessionId: 'face-session-1', + ), + ], + ); + + await controller.clearForPrivacy(); + expect(verification.discardedSessions, isEmpty); + }); + + testWidgets('人脸仅查询未核销时在隐私清屏中删除现场图片任务', (tester) async { + final recognition = _FakeRecognitionGateway(); + final verification = _FakeVerificationGateway(); + final controller = ReservationMenuQueryController( + repository: _ImmediateRepository(_result('张*明', 1)), + recognitionGateway: recognition, + subScreenGateway: _FakeSubScreenGateway(), + verificationGateway: verification, + networkAvailable: () => true, + preferencesLoader: () async => const ReservationQueryPagePreferences( + methodOrder: ReservationRecognitionMethod.values, + defaultMethod: ReservationRecognitionMethod.face, + ), + ); + addTearDown(controller.onClose); + + controller.onInit(); + await tester.pump(); + await controller.startRecognition(); + await recognition.emitFaceAt( + 0, + '1001', + evidenceSessionId: 'face-session-unused', + ); + + await controller.clearForPrivacy(); + expect(verification.discardedSessions, ['face-session-unused']); + }); + testWidgets('点击午餐时传入早餐订单 ID 必须拒绝且不能调用核销接口', (tester) async { final recognition = _FakeRecognitionGateway(); final verification = _FakeVerificationGateway(); @@ -1378,39 +1459,56 @@ class _QueryCall { class _FakeVerificationGateway implements ReservationVerificationGateway { final calls = <_VerificationCall>[]; + final discardedSessions = []; @override Future cancel({ required int orderId, + String? faceEvidenceSessionId, }) async { calls.add( _VerificationCall( orderId: orderId, + faceEvidenceSessionId: faceEvidenceSessionId, ), ); return true; } + + @override + Future discardFaceEvidence({ + required String recognitionSessionId, + required String reason, + }) async { + discardedSessions.add(recognitionSessionId); + } } class _VerificationCall { const _VerificationCall({ required this.orderId, + this.faceEvidenceSessionId, }); final int orderId; + final String? faceEvidenceSessionId; @override bool operator ==(Object other) => - other is _VerificationCall && other.orderId == orderId; + other is _VerificationCall && + other.orderId == orderId && + other.faceEvidenceSessionId == faceEvidenceSessionId; @override - int get hashCode => orderId.hashCode; + int get hashCode => Object.hash(orderId, faceEvidenceSessionId); } class _FakeRecognitionGateway implements ReservationRecognitionGateway { - final cardCallbacks = Function(String)>[]; + final cardCallbacks = + Function(ReservationRecognizedIdentity)>[]; final cardFailures = []; - final faceCallbacks = Function(String)>[]; + final faceCallbacks = + Function(ReservationRecognizedIdentity)>[]; final faceFailures = []; /// 每次人脸启动是否使用页面内嵌预览,用于校验持续模式不会再弹独立识别框。 @@ -1418,16 +1516,26 @@ class _FakeRecognitionGateway implements ReservationRecognitionGateway { int stopAllCalls = 0; Future emitCardAt(int index, String value) { - return cardCallbacks[index](value); + return cardCallbacks[index](ReservationRecognizedIdentity( + identityValue: value, + )); } - Future emitFaceAt(int index, String value) { - return faceCallbacks[index](value); + Future emitFaceAt( + int index, + String value, { + String? evidenceSessionId, + }) { + return faceCallbacks[index](ReservationRecognizedIdentity( + identityValue: value, + faceEvidenceSessionId: evidenceSessionId, + )); } @override Future startCard({ - required Future Function(String cardNumber) onRecognized, + required Future Function(ReservationRecognizedIdentity identity) + onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, }) async { cardCallbacks.add(onRecognized); @@ -1436,7 +1544,8 @@ class _FakeRecognitionGateway implements ReservationRecognitionGateway { @override Future startFace({ - required Future Function(String userId) onRecognized, + required Future Function(ReservationRecognizedIdentity identity) + onRecognized, required void Function(ReservationRecognitionFailure failure) onFailure, bool embeddedPreview = false, }) async {