import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'reservation_menu_repository.dart'; import 'reservation_recognition_gateway.dart'; import 'reservation_sub_screen_gateway.dart'; import 'reservation_verification_gateway.dart'; enum ReservationMenuQueryStatus { idle, recognizing, querying, success, empty, identifyError, networkError, deviceError, expired, } extension ReservationMenuQueryStatusX on ReservationMenuQueryStatus { bool get hasIdentity => this == ReservationMenuQueryStatus.success || this == ReservationMenuQueryStatus.empty; } /// 快速核销在查询成功后交给页面层展示的一次性弹框请求。 class ReservationQuickVerificationRequest { /// 创建已通过当前查询结果校验的快速核销请求,页面仍需复用 Controller 完成最终提交校验。 const ReservationQuickVerificationRequest({ required this.result, required this.day, required this.meal, }); /// 触发请求时的完整预约结果,用于弹框展示脱敏身份与食堂信息。 final ReservationMenuDisplayData result; /// 今天的预约日期,快速核销禁止自动选择未来日期。 final ReservationMenuDayData day; /// 操作员在识别前选中的真实预约餐段,必须包含可核销订单 ID。 final ReservationMenuMealData meal; } /// 本人预约查询状态机,识别、网络、副屏和隐私计时都从这里统一收口。 class ReservationMenuQueryController extends GetxController { /// 创建查询状态机;生产环境使用现有硬件与服务实现,测试可注入网关、时钟和续轮间隔。 ReservationMenuQueryController({ ReservationMenuRepository? repository, ReservationRecognitionGateway? recognitionGateway, ReservationSubScreenGateway? subScreenGateway, ReservationVerificationGateway? verificationGateway, bool Function()? networkAvailable, Future Function()? preferencesLoader, Future Function(ReservationMealSlot)? mealSlotSaver, ProductHourTypeControl? hourTypeControl, Future Function(String)? speaker, void Function(String)? successNotifier, void Function(String)? errorNotifier, this.privacyDuration = const Duration(seconds: 60), this.continuousFaceResultDuration = const Duration(seconds: 10), this.timerTick = const Duration(seconds: 1), this.recognitionRestartDelay = const Duration(milliseconds: 300), this.recognitionFailureRestartDelay = const Duration(seconds: 2), DateTime Function()? now, }) : _repository = repository ?? HardReservationMenuRepository(), _recognitionGateway = recognitionGateway ?? TerminalReservationRecognitionGateway(), _subScreenGateway = subScreenGateway ?? TerminalReservationSubScreenGateway(), _verificationGateway = verificationGateway ?? TerminalReservationVerificationGateway(), _networkAvailable = networkAvailable ?? AppNetworkTool.isAutoNetwork, _preferencesLoader = preferencesLoader ?? AppSettingTool.getReservationQueryPagePreferences, _mealSlotSaver = mealSlotSaver ?? AppSettingTool.setReservationQuerySelectedMealSlot, _speaker = speaker ?? TtsManage.speak, _successNotifier = successNotifier ?? ((message) => Toast.success(msg: message)), _errorNotifier = errorNotifier ?? ((message) => Toast.error(msg: message)), _now = now ?? DateTime.now, _hourTypeControl = hourTypeControl ?? (Get.isRegistered() ? Get.find() : ProductHourTypeControl(persistSelection: false)), _ownsHourTypeControl = hourTypeControl == null && !Get.isRegistered(); final ReservationMenuRepository _repository; final ReservationRecognitionGateway _recognitionGateway; final ReservationSubScreenGateway _subScreenGateway; final ReservationVerificationGateway _verificationGateway; final bool Function() _networkAvailable; final Future Function() _preferencesLoader; /// 最近餐段的本机缓存写入器,测试可注入替身且生产环境复用 AppSettingTool 的串行写队列。 final Future Function(ReservationMealSlot) _mealSlotSaver; final Future Function(String) _speaker; final void Function(String) _successNotifier; final void Function(String) _errorNotifier; /// 查询菜单复用的餐段控制器;生产环境指向应用级实例,测试可注入独立实例。 final ProductHourTypeControl _hourTypeControl; /// 是否由当前页面创建独立餐段控制器;仅测试回退场景需要自行释放。 final bool _ownsHourTypeControl; /// 普通查询结果的隐私展示时长,当前生产默认六十秒。 final Duration privacyDuration; /// 持续人脸模式单个用户菜单的展示时长,到期后自动进入下一轮识别。 final Duration continuousFaceResultDuration; /// 隐私倒计时刷新间隔,测试可注入更短间隔验证状态转换。 final Duration timerTick; /// 正常等待超时后开启下一轮前的缓冲时间,确保旧串口或原生会话已完成资源释放。 final Duration recognitionRestartDelay; /// 单次刷卡或查询失败后的续轮缓冲时间,避免卡片未移开时高频重复识别和请求。 final Duration recognitionFailureRestartDelay; final DateTime Function() _now; final method = ReservationRecognitionMethod.card.obs; final methodOrder = [ ...ReservationRecognitionMethod.values, ].obs; /// 是否自动启动当前查询方式的硬件识别;页面生命周期内使用进入页面时读取的配置快照。 final autoRecognitionEnabled = false.obs; /// 自动识别是否持续接待下一位;饭卡结果展示期保持读卡,正常等待超时也会继续下一轮。 final continuousRecognitionEnabled = true.obs; /// 当前页面查询请求是否要求服务端脱敏用户姓名。 final nameMaskEnabled = true.obs; /// 当前操作员在识别前选择的餐段,页面会在多位用户间保留选择以支持连续核销。 final selectedMealSlot = ReservationMealSlot.morning.obs; /// 是否允许在查询菜单主动修改餐段;关闭后严格按餐段配置时间自动切换。 final allowManualMealPeriodChange = true.obs; /// 查询菜单与收银页面共用餐段组件的单选 Controller,值使用后端稳定餐段编码。 FRadioSelectGroupController get mealSlotC => _hourTypeControl.selection; /// 全局餐段选择监听句柄,页面关闭时用于解除订阅。 late final ValueChanged> _mealSlotListener; /// 识别查询成功后是否直接请求页面打开今天所选餐段的核销弹框。 final quickVerificationEnabled = false.obs; final verificationEnabled = false.obs; final verificationDateScope = ReservationVerificationDateScope.today.obs; final status = ReservationMenuQueryStatus.idle.obs; final result = Rxn(); final currentPage = 1.obs; final remainingSeconds = 0.obs; final errorMessage = ''.obs; Timer? _privacyTimer; /// 正常等待超时或同卡重复读取后的待重启任务;切换方式、清屏或退出页面时必须取消。 Timer? _recognitionRestartTimer; DateTime? _expiresAt; int _requestGeneration = 0; int _revision = 0; String _sessionId = ''; bool _closed = false; bool _recognitionLocked = false; bool _verificationSubmitting = false; /// 恢复旧版页面偏好时临时禁止重复写入最近餐段缓存。 bool _suppressMealSlotPersistence = false; /// 菜单详情或核销弹框是否正在展示;展示期间禁止自动续轮和任何新的硬件识别。 bool _resultDialogPresented = false; String? _recognizedIdentityValue; /// 当前真实人脸查询产生的本地证据会话;核销成功后移交上传队列,其他清屏路径必须删除。 String? _recognizedFaceEvidenceSessionId; /// 页面层订阅的一次性快速核销请求;使用广播流避免测试或页面尚未挂载时缓存过期弹框。 final StreamController _quickVerificationRequestController = StreamController.broadcast(); /// 页面偏好、副屏和识别会话的首次初始化任务。 Future? _initializationFuture; /// 页面偏好与识别会话初始化完成后再执行依赖当前餐段的提示。 Future get initialization => _initializationFuture ?? Future.value(); /// 页面层使用的只读快速核销请求流,Controller 不直接依赖弹框或导航上下文。 Stream get quickVerificationRequests => _quickVerificationRequestController.stream; /// 当前是否为“自动识别 + 持续自动识别 + 人脸”的常驻摄像头模式。 bool get continuousFaceRecognitionActive => autoRecognitionEnabled.value && continuousRecognitionEnabled.value && method.value == ReservationRecognitionMethod.face; /// 当前是否为“自动识别 + 持续自动识别 + 饭卡”的结果期持续读卡模式。 bool get continuousCardRecognitionActive => autoRecognitionEnabled.value && continuousRecognitionEnabled.value && method.value == ReservationRecognitionMethod.card; List get currentDays => result.value?.pageDays(currentPage.value) ?? const []; int get totalPages => result.value?.totalPages ?? 1; /// 注册公共餐段组件的编码监听,并异步恢复查询偏好与初始化识别会话。 @override void onInit() { super.onInit(); _mealSlotListener = _handleMealSlotSelection; mealSlotC.addValueListener(_mealSlotListener); _initializationFuture = _initialize(); } /// 恢复全局餐段与页面偏好,再建立副屏和识别会话。 Future _initialize() async { // 页面单测可注入独立餐段控制器;生产环境使用应用级控制器并等待其缓存恢复。 if (!_ownsHourTypeControl) await _hourTypeControl.initAsync(); await Future.wait([ _loadPagePreferences(), _subScreenGateway.show(), ]); await _beginSession(); } /// 读取当前页面的一次性配置快照;读取失败时保留安全默认值继续提供手动查询。 Future _loadPagePreferences() async { try { final preferences = await _preferencesLoader(); if (_closed) return; methodOrder.assignAll(preferences.methodOrder); method.value = preferences.defaultMethod; autoRecognitionEnabled.value = preferences.autoRecognitionEnabled; continuousRecognitionEnabled.value = preferences.continuousRecognitionEnabled; nameMaskEnabled.value = preferences.nameMaskEnabled; verificationEnabled.value = preferences.verificationEnabled; quickVerificationEnabled.value = preferences.verificationEnabled && preferences.quickVerificationEnabled; if (_ownsHourTypeControl && ReservationMealSlot.querySelectableValues .contains(preferences.selectedMealSlot)) { final restoredType = ProductHourType.values.fromCode( preferences.selectedMealSlot.code, ); if (restoredType != null) { _suppressMealSlotPersistence = true; try { _hourTypeControl.setSelected(restoredType, persist: false); } finally { _suppressMealSlotPersistence = false; } } } selectedMealSlot.value = _mealSlotForProductHourType( _hourTypeControl.selected.value, ); allowManualMealPeriodChange.value = preferences.allowManualMealPeriodChange; verificationDateScope.value = preferences.verificationDateScope; } catch (error, stack) { // 配置损坏或读取失败时继续使用安全默认值,不能让查询入口不可用。 logger.e('|预约查询|加载页面配置失败error:|$error|stack:|$stack|'); } } /// 更新预选餐段;等待硬件识别时仍允许调整,身份已获取并查询服务端后锁定本轮目标。 void selectMealSlot(ReservationMealSlot next, {bool fromGlobal = false}) { if (_closed || !ReservationMealSlot.querySelectableValues.contains(next) || (!fromGlobal && status.value == ReservationMenuQueryStatus.querying) || selectedMealSlot.value == next) { return; } selectedMealSlot.value = next; if (!mealSlotC.contains(next.code)) mealSlotC.value = {next.code}; if (!_suppressMealSlotPersistence) { unawaited(_persistSelectedMealSlot(next)); } } /// 接收公共餐段组件的编码选择,并映射回查询菜单的强类型餐段状态。 void _handleMealSlotSelection(Set selectedCodes) { final selectedCode = selectedCodes.firstOrNull; if (selectedCode == null) return; for (final mealSlot in ReservationMealSlot.querySelectableValues) { if (mealSlot.code == selectedCode) { selectMealSlot(mealSlot, fromGlobal: true); return; } } } /// 把全局商品餐段映射为查询菜单餐段;异常编码回退早餐以保持页面可查询。 ReservationMealSlot _mealSlotForProductHourType(ProductHourType hourType) { return ReservationMealSlot.querySelectableValues.firstWhere( (slot) => slot.code == hourType.code, orElse: () => ReservationMealSlot.morning, ); } /// 异步缓存最近餐段;失败时保留内存中的即时选择,避免本机设置故障阻断识别查询。 Future _persistSelectedMealSlot(ReservationMealSlot mealSlot) async { try { await _mealSlotSaver(mealSlot); } catch (error, stack) { logger.e('|预约查询|缓存最近餐段失败error:|$error|stack:|$stack|'); } } /// 切换本人识别方式,清理旧身份与硬件回调,并按配置决定是否立即启动新方式。 Future selectMethod(ReservationRecognitionMethod next) async { if (_closed || next == method.value) return; _cancelRecognitionRestart(); final generation = ++_requestGeneration; _stopPrivacyTimer(); await _discardCurrentFaceEvidence('切换预约查询识别方式'); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; errorMessage.value = ''; method.value = next; status.value = ReservationMenuQueryStatus.idle; // generation 已先递增,即使旧硬件回调稍晚到达也不会进入查询流程。 // 自动模式直接交给新识别方式更新副屏,避免 idle 消息晚到并覆盖人脸实时画面。 if (!autoRecognitionEnabled.value) unawaited(_syncSubScreen('idle')); await _recognitionGateway.stopAll(); if (_closed || generation != _requestGeneration) return; if (autoRecognitionEnabled.value) await startRecognition(); } /// 主动开始一轮识别;持续饭卡模式会在单次刷卡或查询失败后自动恢复下一轮。 Future startRecognition() => _startRecognition(); /// 建立一次防重复回调的识别代次;持续饭卡结果期可保留当前菜单并只重新布防读卡器。 Future _startRecognition({bool preserveCurrentResult = false}) async { if (_closed || _resultDialogPresented) return; final preserveCardResult = preserveCurrentResult && continuousCardRecognitionActive && result.value != null && status.value.hasIdentity; if (preserveCurrentResult && !preserveCardResult) return; _cancelRecognitionRestart(); final generation = ++_requestGeneration; _recognitionLocked = false; if (!preserveCardResult) { _stopPrivacyTimer(); await _discardCurrentFaceEvidence('开始新的预约查询识别'); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; errorMessage.value = ''; status.value = ReservationMenuQueryStatus.recognizing; } // 人脸方式由统一会话服务先把副屏切到实时识别页;预约 loading 只在识别成功、 // 真正开始查询当前用户时发送,避免副屏在人脸画面前短暂显示查询状态。 if (method.value == ReservationRecognitionMethod.card && !preserveCardResult) { unawaited(_syncSubScreen('loading')); } else if (continuousFaceRecognitionActive) { // 先让副屏进入组合页面再启动人脸服务,避免人脸 start 消息抢先把副屏切到旧的独立页面。 await _syncSubScreen('recognizing'); if (_closed || generation != _requestGeneration) return; } Future recognized(ReservationRecognizedIdentity identity) async { if (_closed || generation != _requestGeneration || _recognitionLocked) { return; } final identityValue = identity.identityValue.trim(); if (identityValue.isEmpty) { _recognitionLocked = true; if (preserveCardResult) { // 持续读卡可能收到“读卡成功但卡号为空”的无效帧;它不代表下一位用户,不能覆盖当前菜单。 errorMessage.value = ''; _refreshRetainedCardResultDisplay(); await _restartRecognitionAfterDelay( generation, preserveCurrentResult: true, ); } else if (continuousCardRecognitionActive) { // 持续模式把空卡视为本轮偶发失败,提示后重新布防,不能切到需要人工恢复的错误页。 await _resumeContinuousCardRecognitionAfterFailure( generation, message: '未读取到有效饭卡,请重新刷卡', ); } else { errorMessage.value = '未读取到有效饭卡,请重新刷卡'; status.value = ReservationMenuQueryStatus.identifyError; await _syncSubScreen('error'); } return; } if (preserveCardResult && identityValue == _recognizedIdentityValue) { // 卡片未移开时硬件可能在重新布防后再次返回同一卡号;不重复查询,短暂延迟后继续等待换卡。 _recognitionLocked = true; await _restartRecognitionAfterDelay( generation, preserveCurrentResult: true, ); return; } // 同一硬件事件可能被串口或原生通道重复投递,只允许首个结果进入查询。 _recognitionLocked = true; _cancelRecognitionRestart(); _stopPrivacyTimer(); await _recognitionGateway.stopAll(); if (_closed || generation != _requestGeneration) return; await _replaceFaceEvidenceSession(identity.faceEvidenceSessionId); await _queryIdentity( identityValue, generation, preserveCurrentResultUntilSuccess: preserveCardResult, faceEvidenceSessionId: identity.faceEvidenceSessionId, ); } void failed(ReservationRecognitionFailure failure) { if (_closed || generation != _requestGeneration || _recognitionLocked) { return; } _recognitionLocked = true; if (method.value == ReservationRecognitionMethod.card && failure.type == ReservationRecognitionFailureType.identify && continuousCardRecognitionActive) { if (preserveCardResult) { // 读卡器空数据属于无效硬件帧,不是新的用户身份;保留当前菜单并继续等待下一张有效卡。 errorMessage.value = ''; _refreshRetainedCardResultDisplay(); unawaited(_restartRecognitionAfterDelay( generation, preserveCurrentResult: true, delay: recognitionFailureRestartDelay, )); } else { unawaited(_resumeContinuousCardRecognitionAfterFailure( generation, message: failure.message, )); } return; } if (failure.type == ReservationRecognitionFailureType.timeout && autoRecognitionEnabled.value && continuousRecognitionEnabled.value) { // 正常无人等待超时不向用户闪现错误页;先完整释放本轮资源,再平滑进入下一轮。 errorMessage.value = ''; if (!preserveCardResult) { status.value = ReservationMenuQueryStatus.recognizing; } unawaited(_restartRecognitionAfterDelay( generation, preserveCurrentResult: preserveCardResult, )); return; } if (preserveCardResult) _stopPrivacyTimer(); if (failure.type == ReservationRecognitionFailureType.cancelled) { if (autoRecognitionEnabled.value) { // 用户主动取消后暂停自动识别并提供恢复入口,禁止立即拉起造成无法退出的弹窗循环。 status.value = ReservationMenuQueryStatus.identifyError; errorMessage.value = failure.message; unawaited(_syncSubScreen('error')); } else { status.value = ReservationMenuQueryStatus.idle; errorMessage.value = ''; unawaited(_syncSubScreen('idle')); } return; } errorMessage.value = failure.message; status.value = failure.type == ReservationRecognitionFailureType.device ? ReservationMenuQueryStatus.deviceError : ReservationMenuQueryStatus.identifyError; unawaited(_syncSubScreen('error')); } try { if (method.value == ReservationRecognitionMethod.card) { await _recognitionGateway.startCard( onRecognized: recognized, onFailure: failed, ); } else { await _recognitionGateway.startFace( onRecognized: recognized, onFailure: failed, embeddedPreview: continuousFaceRecognitionActive, ); } } catch (error, stack) { if (_closed || generation != _requestGeneration) return; logger.e('|预约查询|启动识别失败error:|$error|stack:|$stack|'); if (preserveCardResult) _stopPrivacyTimer(); errorMessage.value = '${method.value.title}设备不可用,请检查设备后重试'; status.value = ReservationMenuQueryStatus.deviceError; await _syncSubScreen('error'); } } /// 释放当前识别会话并延迟续轮;代次变化时旧任务立即失效。 /// /// [preserveCurrentResult] 仅用于持续饭卡结果期,保留当前菜单等待不同卡号。 /// [delay] 允许单次识别或查询失败使用更长间隔,防止卡片未移开时高频重试。 Future _restartRecognitionAfterDelay( int generation, { bool preserveCurrentResult = false, Duration? delay, }) async { await _recognitionGateway.stopAll(); if (_closed || _resultDialogPresented || generation != _requestGeneration || !autoRecognitionEnabled.value || !continuousRecognitionEnabled.value) { return; } _recognitionRestartTimer = Timer(delay ?? recognitionRestartDelay, () { _recognitionRestartTimer = null; if (_closed || _resultDialogPresented || generation != _requestGeneration || !autoRecognitionEnabled.value || !continuousRecognitionEnabled.value) { return; } unawaited(_startRecognition( preserveCurrentResult: preserveCurrentResult, )); }); } /// 取消尚未开始的持续识别续轮,避免方式切换、隐私清屏或销毁后启动旧硬件任务。 void _cancelRecognitionRestart() { _recognitionRestartTimer?.cancel(); _recognitionRestartTimer = null; } /// 查询识别到的身份,并在持续饭卡换卡场景中将新结果作为一次原子提交。 /// /// [preserveCurrentResultUntilSuccess] 为 true 时,网络或业务失败只提示操作员并恢复读卡, /// 当前菜单和对应身份保持不变;服务端成功返回后才一起替换为新用户结果。 Future _queryIdentity( 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, message: '预约查询需要联网,请检查网络后重试', ); return; } if (continuousCardRecognitionActive) { await _resumeContinuousCardRecognitionAfterFailure( generation, message: '预约查询需要联网,请检查网络后重试', ); return; } errorMessage.value = '预约查询需要联网,请检查网络后重试'; status.value = ReservationMenuQueryStatus.networkError; await _syncSubScreen('error'); return; } if (!preserveCurrentResult) { status.value = ReservationMenuQueryStatus.querying; errorMessage.value = ''; // 副屏仅用于展示,插件异常或不回包不能挡在预约接口之前。 unawaited(_syncSubScreen('loading')); } else { // 换卡请求期间继续展示上一位结果,并同步刷新副屏到期时间,成功后再原子替换。 _refreshRetainedCardResultDisplay(); } var keepFaceEvidence = false; try { logger.i( '|预约查询|开始请求预约菜单|method:${method.value.name}|' 'identity:${_maskedIdentity(identityValue)}|', ); final response = await _repository.query( method: method.value, identityValue: identityValue, maskName: nameMaskEnabled.value, ); // 新的刷卡或人脸事件已经开始时,旧请求即使成功也不能覆盖新身份。 if (_closed || generation != _requestGeneration) return; if (response == null) { const message = '未获取到预约查询结果'; if (preserveCurrentResult) { await _retainCurrentCardResultAfterQueryFailure( generation, message: message, ); return; } if (continuousCardRecognitionActive) { await _resumeContinuousCardRecognitionAfterFailure( generation, message: message, ); return; } throw const ReservationMenuRepositoryException('未获取到预约查询结果'); } result.value = ReservationMenuDisplayData.fromService(response); _recognizedIdentityValue = identityValue; keepFaceEvidence = true; currentPage.value = 1; errorMessage.value = ''; status.value = response.days.isEmpty ? ReservationMenuQueryStatus.empty : ReservationMenuQueryStatus.success; logger.i( '|预约查询|预约菜单请求完成|method:${method.value.name}|' 'dayCount:${response.days.length}|', ); _startPrivacyTimer(); await _syncSubScreen( response.days.isEmpty ? 'empty' : 'success', includeResult: true, ); if (_closed || generation != _requestGeneration) return; if (response.days.isEmpty) { _errorNotifier('当前用户暂无有效预约'); } final quickVerificationPending = _publishQuickVerificationRequest(); if (_closed || generation != _requestGeneration) return; if (continuousCardRecognitionActive && !quickVerificationPending && !_resultDialogPresented) { // 菜单继续展示并维持隐私倒计时,读卡器在后台等待下一张不同饭卡直接覆盖当前结果。 await _startRecognition(preserveCurrentResult: true); } } on ReservationMenuIdentityException catch (error) { if (_closed || generation != _requestGeneration) return; if (preserveCurrentResult) { await _retainCurrentCardResultAfterQueryFailure( generation, message: error.message, ); return; } if (continuousCardRecognitionActive) { await _resumeContinuousCardRecognitionAfterFailure( generation, message: error.message, ); return; } errorMessage.value = error.message; status.value = ReservationMenuQueryStatus.identifyError; await _syncSubScreen('error'); } on ReservationMenuRepositoryException catch (error) { if (_closed || generation != _requestGeneration) return; // 终端未绑定等本机配置错误必须保留人工恢复入口,不能被持续模式静默重试。 errorMessage.value = error.message; status.value = ReservationMenuQueryStatus.deviceError; await _syncSubScreen('error'); } on DioException catch (error, stack) { if (_closed || generation != _requestGeneration) return; logger.e('|预约查询|网络请求失败error:|$error|stack:|$stack|'); if (preserveCurrentResult) { await _retainCurrentCardResultAfterQueryFailure( generation, message: '预约查询失败,请检查网络后重试', ); return; } if (continuousCardRecognitionActive) { await _resumeContinuousCardRecognitionAfterFailure( generation, message: '预约查询失败,请检查网络后重试', ); return; } errorMessage.value = '预约查询失败,请检查网络后重试'; status.value = ReservationMenuQueryStatus.networkError; await _syncSubScreen('error'); } catch (error, stack) { if (_closed || generation != _requestGeneration) return; logger.e('|预约查询|查询失败error:|$error|stack:|$stack|'); if (preserveCurrentResult) { await _retainCurrentCardResultAfterQueryFailure( generation, message: '暂时无法查询预约,请稍后重试', ); return; } if (continuousCardRecognitionActive) { await _resumeContinuousCardRecognitionAfterFailure( generation, message: '暂时无法查询预约,请稍后重试', ); return; } 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}|', ); } } /// 首次刷卡或查询失败后清空本轮临时状态,并在持续模式下重新等待下一张饭卡。 /// /// 这里不进入设备故障页,因为读卡器已完成本轮身份上报;失败只影响当前卡片, /// 不能改变操作员在页面设置中开启的持续识别意图。 Future _resumeContinuousCardRecognitionAfterFailure( int generation, { required String message, }) async { if (_closed || generation != _requestGeneration || !continuousCardRecognitionActive || _resultDialogPresented) { return; } _stopPrivacyTimer(); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; errorMessage.value = ''; status.value = ReservationMenuQueryStatus.recognizing; _errorNotifier(message); unawaited(_syncSubScreen('loading')); await _restartRecognitionAfterDelay( generation, delay: recognitionFailureRestartDelay, ); } /// 持续饭卡换卡查询失败时恢复旧结果的隐私计时和读卡,不把失败身份提交为当前用户。 Future _retainCurrentCardResultAfterQueryFailure( int generation, { required String message, }) async { if (_closed || generation != _requestGeneration) return; errorMessage.value = ''; _refreshRetainedCardResultDisplay(); _errorNotifier(message); if (continuousCardRecognitionActive && !_resultDialogPresented) { await _restartRecognitionAfterDelay( generation, preserveCurrentResult: true, delay: recognitionFailureRestartDelay, ); } } /// 刷新持续饭卡保留结果的主副屏展示期限,空卡或换卡查询失败都不得让副屏提前清空。 void _refreshRetainedCardResultDisplay() { if (result.value == null || !status.value.hasIdentity) return; _startPrivacyTimer(); final scenario = status.value == ReservationMenuQueryStatus.empty ? 'empty' : 'success'; unawaited(_syncSubScreen(scenario, includeResult: true)); } /// 在快速核销开启且页面正在监听时发布今天预选餐段,并返回是否需要等待弹框接管识别。 bool _publishQuickVerificationRequest() { if (_closed || !verificationEnabled.value || !quickVerificationEnabled.value || !_quickVerificationRequestController.hasListener) { return false; } final currentResult = result.value; if (currentResult == null || currentResult.days.isEmpty) return false; ReservationMenuDayData? today; for (final day in currentResult.days) { if (day.isToday(_now())) { today = day; break; } } ReservationMenuMealData? targetMeal; if (today != null && canVerifyDay(today)) { for (final meal in today.meals) { if (selectedMealSlot.value.matches(meal) && meal.reserved && meal.orderIds.isNotEmpty) { targetMeal = meal; break; } } } if (today == null || targetMeal == null) { _errorNotifier( '当前用户今天没有可核销的${selectedMealSlot.value.title}预约', ); return false; } _quickVerificationRequestController.add( ReservationQuickVerificationRequest( result: currentResult, day: today, meal: targetMeal, ), ); return true; } String _maskedIdentity(String value) { if (value.length <= 2) return '**'; return '${value.substring(0, 1)}***${value.substring(value.length - 1)}'; } Future changePage(int page) async { if (result.value == null || page < 1 || page > totalPages) return; if (page == currentPage.value) return; currentPage.value = page; // 持续人脸按“识别成功后的固定十秒”接待下一位,操作员翻页不能延长身份展示。 if (!continuousFaceRecognitionActive) _startPrivacyTimer(); await _syncSubScreen('success', includeResult: true); } bool canVerifyDay(ReservationMenuDayData day) { if (!verificationEnabled.value) return false; return verificationDateScope.value == ReservationVerificationDateScope.all || day.isToday(_now()); } /// 在菜单详情或核销弹框展示前暂停识别,并返回当前调用方是否取得唯一展示权。 /// /// 标记必须先于硬件释放,避免快速核销事件和人工点击在 `stopAll` 等待期间叠加弹框; /// 请求代次同步失效可保证已经排队的饭卡、人脸回调不能在弹框背后继续查询。 Future beginResultDialogPresentation() async { if (_closed || _resultDialogPresented) return false; _resultDialogPresented = true; _cancelRecognitionRestart(); _requestGeneration++; _recognitionLocked = true; _stopPrivacyTimer(); try { await _recognitionGateway.stopAll(); } catch (error, stack) { // 弹框暂停边界已经生效,硬件释放异常只记录诊断,不能阻止用户核对或核销当前结果。 logger.w('|预约查询|弹框展示前停止识别失败error:|$error|stack:|$stack|'); } return true; } /// 菜单详情或核销弹框关闭后恢复页面节奏;自动模式立即等待下一位,手动模式保留结果并重新计时。 Future endResultDialogPresentation() async { if (!_resultDialogPresented) return; if (_closed) { _resultDialogPresented = false; return; } if (autoRecognitionEnabled.value) { final keepContinuousFacePreview = continuousFaceRecognitionActive; _cancelRecognitionRestart(); final generation = ++_requestGeneration; _stopPrivacyTimer(); await _discardCurrentFaceEvidence('预约结果弹框关闭后开始下一轮识别'); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; errorMessage.value = ''; status.value = keepContinuousFacePreview ? ReservationMenuQueryStatus.recognizing : ReservationMenuQueryStatus.idle; if (keepContinuousFacePreview) { unawaited(_syncSubScreen('recognizing')); } else { unawaited(_clearSubScreen()); } var shouldResumeRecognition = false; try { await _recognitionGateway.stopAll(); shouldResumeRecognition = !_closed && generation == _requestGeneration && autoRecognitionEnabled.value; } catch (error, stack) { // 关闭弹框后的硬件清理失败不能重新展示旧结果,下一轮启动仍由网关给出可恢复的设备错误。 logger.w('|预约查询|弹框关闭后停止识别失败error:|$error|stack:|$stack|'); shouldResumeRecognition = !_closed && generation == _requestGeneration && autoRecognitionEnabled.value; } finally { // 清屏完成前继续占有弹框边界,避免路由刚关闭时再次点击旧结果叠加第二个弹框。 _resultDialogPresented = false; } if (shouldResumeRecognition) await startRecognition(); return; } _resultDialogPresented = false; if (result.value != null && status.value.hasIdentity) { _startPrivacyTimer(); final scenario = status.value == ReservationMenuQueryStatus.empty ? 'empty' : 'success'; unawaited(_syncSubScreen(scenario, includeResult: true)); } } /// 核销用户在当前餐段弹框中勾选的订单,至少一笔成功后重新查询并替换页面结果。 Future confirmVerification( ReservationMenuDayData day, ReservationMenuMealData meal, List selectedOrderIds, ) async { if (_closed || _verificationSubmitting) return false; final currentMeal = _findCurrentMeal(day, meal); if (currentMeal == null || !currentMeal.reserved || !canVerifyDay(day)) { _errorNotifier('当前餐段预约已失效,请重新查询'); return false; } if (currentMeal.orderIds.isEmpty) { _errorNotifier('未获取到可核销的预约订单,请重新查询'); return false; } final selectedOrderIdSet = selectedOrderIds.toSet(); if (selectedOrderIdSet.isEmpty) { _errorNotifier('请至少选择一笔预约订单'); return false; } if (selectedOrderIdSet.length != selectedOrderIds.length || !selectedOrderIdSet.every(currentMeal.orderIds.contains)) { _errorNotifier('部分所选预约订单已失效,请重新查询'); return false; } // 始终按当前查询结果中的服务端顺序提交,弹框回传列表只能决定选择范围,不能改变核销顺序。 final orderedSelectedOrderIds = currentMeal.orderIds .where(selectedOrderIdSet.contains) .toList(growable: false); if (!_networkAvailable()) { _errorNotifier('核销需要联网,请检查网络后重试'); return false; } final identityValue = _recognizedIdentityValue; if (identityValue == null || identityValue.isEmpty) { _errorNotifier('识别信息已失效,请重新识别后核销'); return false; } final recognitionMethod = method.value; final faceEvidenceSessionId = _recognizedFaceEvidenceSessionId; _verificationSubmitting = true; _stopPrivacyTimer(); try { // 所有订单 ID 均已重新通过当前餐段校验,禁止把日期、餐段或身份信息交给服务端参与订单选择。 final verificationResult = await _verificationGateway.cancel( orderIds: orderedSelectedOrderIds, faceEvidenceSessionId: faceEvidenceSessionId, ); if (!verificationResult.anySucceeded) { throw StateError('所选预约订单核销接口均未返回成功'); } if (faceEvidenceSessionId != null && _recognizedFaceEvidenceSessionId == faceEvidenceSessionId) { // 核销网关已把会话移交给持久化补传队列,后续页面清屏不得再取消这些任务。 _recognizedFaceEvidenceSessionId = null; } unawaited( _announceVerificationSuccess(verificationResult), ); final successMessage = _verificationSuccessMessage(verificationResult); try { await _refreshAfterVerification( identityValue, method: recognitionMethod, ); _successNotifier('$successMessage,页面已刷新'); _startPrivacyTimer(); final scenario = status.value == ReservationMenuQueryStatus.empty ? 'empty' : 'success'; unawaited(_syncSubScreen(scenario, includeResult: true)); } catch (error, stack) { // 核销已由服务端确认成功,后续刷新失败不能反向提示“核销失败”。 // 清掉旧结果可避免用户对同一笔订单再次点击,重新识别即可获取最新数据。 logger.w('|预约查询|核销成功但刷新失败error:|$error|stack:|$stack|'); _successNotifier('$successMessage,请重新识别刷新结果'); _clearStaleResultAfterVerification(); } return true; } on DioException catch (error, stack) { logger.e('|预约查询|核销网络请求失败error:|$error|stack:|$stack|'); _errorNotifier('核销失败,请检查网络后重试'); _startPrivacyTimer(); return false; } catch (error, stack) { logger.e('|预约查询|核销失败error:|$error|stack:|$stack|'); _errorNotifier('核销失败,请重新查询后重试'); _startPrivacyTimer(); return false; } finally { _verificationSubmitting = false; } } /// 根据逐笔结果生成面向操作员的成功摘要,单笔核销继续保留既有简短文案。 String _verificationSuccessMessage(ReservationVerificationResult result) { final successCount = result.succeededOrderIds.length; final failedCount = result.failedOrderIds.length; if (successCount == 1 && failedCount == 0) return '核销成功'; if (failedCount == 0) return '已核销 $successCount 笔预约'; return '已核销 $successCount 笔预约,$failedCount 笔未成功'; } /// 不信任弹框回传的餐段对象,重新从当前查询结果中定位餐段及其订单 ID。 /// 这样即使页面数据已经刷新或调用方传入了其他餐段的 ID,也不会发起错误核销。 ReservationMenuMealData? _findCurrentMeal( ReservationMenuDayData selectedDay, ReservationMenuMealData selectedMeal, ) { final currentResult = result.value; if (currentResult == null) return null; ReservationMealSlot? selectedSlot; for (final slot in ReservationMealSlot.values) { if (slot.matches(selectedMeal)) { selectedSlot = slot; break; } } if (selectedSlot == null) return null; for (final day in currentResult.days) { if (day.date != selectedDay.date) continue; for (final meal in day.meals) { if (selectedSlot.matches(meal)) return meal; } return null; } return null; } /// 播报本次逐笔核销结果;单笔全部成功继续使用操作员熟悉的既有文案。 Future _announceVerificationSuccess( ReservationVerificationResult result, ) async { try { final successCount = result.succeededOrderIds.length; final failedCount = result.failedOrderIds.length; final message = failedCount > 0 ? '$successCount笔预约核销成功,$failedCount笔未成功' : successCount == 1 ? '核销成功' : '$successCount笔预约核销成功'; await _speaker(message); } catch (error, stack) { // 语音属于成功后的辅助反馈,TTS 异常不能改变核销结果。 logger.w('|预约查询|核销成功语音播报失败error:|$error|stack:|$stack|'); } } Future _refreshAfterVerification( String identityValue, { required ReservationRecognitionMethod method, }) async { final response = await _repository.query( method: method, identityValue: identityValue, maskName: nameMaskEnabled.value, ); if (response == null) throw StateError('核销后未获取到最新预约结果'); result.value = ReservationMenuDisplayData.fromService(response); status.value = response.days.isEmpty ? ReservationMenuQueryStatus.empty : ReservationMenuQueryStatus.success; if (currentPage.value > totalPages) currentPage.value = totalPages; } void _clearStaleResultAfterVerification() { _cancelRecognitionRestart(); final generation = ++_requestGeneration; _stopPrivacyTimer(); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; errorMessage.value = ''; status.value = ReservationMenuQueryStatus.idle; unawaited(_resumeAfterStaleResultClear(generation)); } /// 核销后刷新失败时先清理主副屏,再按自动模式重新等待身份,避免页面停在无监听的空闲态。 Future _resumeAfterStaleResultClear(int generation) async { await _recognitionGateway.stopAll(); if (_closed || generation != _requestGeneration) return; if (continuousFaceRecognitionActive) { status.value = ReservationMenuQueryStatus.recognizing; unawaited(_syncSubScreen('recognizing')); } else { unawaited(_clearSubScreen()); } if (!_closed && !_resultDialogPresented && autoRecognitionEnabled.value) { await startRecognition(); } } /// 建立本次页面会话,并按进入页面时加载的开关选择自动识别或手动等待态。 Future _beginSession() async { _sessionId = '${_now().microsecondsSinceEpoch}'; _revision = 0; if (autoRecognitionEnabled.value) { // 配置加载完成后直接复用手动入口,确保饭卡、人脸、防重入和异常处理只有一套状态机。 await startRecognition(); } else { unawaited(_syncSubScreen('idle')); } } /// 从当前时刻重新计算结果隐私展示期限;弹框占用期间不创建后台清屏或自动续轮任务。 void _startPrivacyTimer() { if (_closed || _resultDialogPresented) return; _stopPrivacyTimer(); final displayDuration = continuousFaceRecognitionActive ? continuousFaceResultDuration : privacyDuration; _expiresAt = _now().add(displayDuration); remainingSeconds.value = displayDuration.inSeconds; _privacyTimer = Timer.periodic(timerTick, (_) { if (_closed || _expiresAt == null) return; final milliseconds = _expiresAt!.difference(_now()).inMilliseconds; if (milliseconds <= 0) { unawaited(clearForPrivacy(expired: true)); return; } remainingSeconds.value = (milliseconds / 1000).ceil(); }); } void _stopPrivacyTimer() { _privacyTimer?.cancel(); _privacyTimer = null; _expiresAt = null; remainingSeconds.value = 0; } /// 清除主屏身份与预约结果;自动模式会在清理完成后重新等待下一位用户。 Future clearForPrivacy({bool expired = false}) async { if (_closed || _resultDialogPresented) return; final keepContinuousFacePreview = continuousFaceRecognitionActive; _cancelRecognitionRestart(); final generation = ++_requestGeneration; _stopPrivacyTimer(); await _recognitionGateway.stopAll(); if (_closed || generation != _requestGeneration) return; await _discardCurrentFaceEvidence('预约查询隐私清屏'); result.value = null; _recognizedIdentityValue = null; currentPage.value = 1; errorMessage.value = ''; status.value = keepContinuousFacePreview ? ReservationMenuQueryStatus.recognizing : expired ? ReservationMenuQueryStatus.expired : ReservationMenuQueryStatus.idle; if (keepContinuousFacePreview) { // 只清身份和预约内容,副屏仍保留同一摄像头 Texture,避免每十秒闪回欢迎页。 await _syncSubScreen('recognizing'); } else { unawaited(_clearSubScreen()); } if (_closed || generation != _requestGeneration) return; // 清屏先让结果期读卡回调代次失效,再统一以空白等待态启动下一轮。 if (autoRecognitionEnabled.value) await startRecognition(); } Future _syncSubScreen( String scenario, { bool includeResult = false, }) async { if (_closed || !_subScreenGateway.isAvailable) return; final display = result.value; final event = SubScreenReservationQueryEvent( sessionId: _sessionId, revision: ++_revision, scenario: scenario, maskedName: includeResult ? display?.maskedName : null, deptName: includeResult ? display?.deptName : null, merchantName: includeResult ? display?.merchantName : null, totalDayCount: includeResult ? display?.totalDayCount ?? 0 : 0, totalMealCount: includeResult ? display?.totalMealCount ?? 0 : 0, currentPage: currentPage.value, totalPages: includeResult ? totalPages : 1, days: includeResult ? currentDays : const [], expiresAt: _expiresAt?.toIso8601String(), continuousFaceRecognition: continuousFaceRecognitionActive, ); try { await _subScreenGateway.update(event); } catch (error) { // 单屏或副屏断开不能影响主屏查询。 logger.w('|预约查询|副屏同步失败:$error'); } } Future _clearSubScreen() async { if (!_subScreenGateway.isAvailable) return; try { await _subScreenGateway.clear( sessionId: _sessionId, revision: ++_revision, ); } catch (error) { logger.w('|预约查询|副屏清屏失败:$error'); } } /// 退出页面时终止识别与副屏任务;生产环境仅解除全局餐段监听。 @override void onClose() { _closed = true; _resultDialogPresented = false; _requestGeneration++; _cancelRecognitionRestart(); _stopPrivacyTimer(); unawaited(_recognitionGateway.stopAll()); unawaited(_discardCurrentFaceEvidence('退出预约查询页面')); unawaited(_clearSubScreen()); unawaited(_quickVerificationRequestController.close()); mealSlotC.removeValueListener(_mealSlotListener); if (_ownsHourTypeControl) _hourTypeControl.onClose(); super.onClose(); } }