From 7857011b92e069f567ba852ee357eb5913a863d3 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Fri, 30 Oct 2026 15:25:58 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E7=BA=BF=E4=B8=8B=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0=E4=B8=8B=E5=8D=95=E9=97=AE=E9=A2=98=20fix=EF=BC=9A?= =?UTF-8?q?=E6=89=93=E5=8D=B0=E6=9C=BA=E7=AA=81=E7=84=B6=E4=B8=8D=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../tool/face/servcie/FaceServer.java | 32 +- lib/common/utils/manage/print_manage.dart | 31 +- lib/common/utils/sync/sync_print_tool.dart | 142 ++++-- .../widgets/cashier/cashier_workspace.dart | 20 +- .../widgets/order/mixin/order_mixin.dart | 20 + .../widgets/order/select_pay_type_dialog.dart | 176 +++++-- .../terminal/canteen_product/controller.dart | 63 +-- lib/pages/terminal/canteen_product/view.dart | 1 + .../supermarket_order/controller.dart | 138 +++--- .../terminal/supermarket_order/view.dart | 2 + .../DragoUsbPrinterPlugin.kt | 120 +++-- pubspec.yaml | 2 +- .../common/cashier_checkout_reentry_test.dart | 430 ++++++++++++++++++ test/common/print_recovery_test.dart | 326 +++++++++++++ 15 files changed, 1285 insertions(+), 220 deletions(-) create mode 100644 test/common/cashier_checkout_reentry_test.dart create mode 100644 test/common/print_recovery_test.dart diff --git a/README.md b/README.md index c1bac2e..0dd945c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~ ## flutter打包 ``` -flutter build apk --build-name 1.5.8 --build-number 158 +flutter build apk --build-name 1.5.9 --build-number 159 --build-name为版本号(展示) --build-number为版本号(内部比较) ``` diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java index 12f6dd9..a00dc7b 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java @@ -181,7 +181,7 @@ public class FaceServer { int searchId = faceEntity.getFaceId(); FaceEntity oldEntity = copyFaceEntity(faceRegisterInfoMap.get(searchId)); try { - FaceFeatureInfo oldFeature = getFaceFeature(searchId); + FaceFeatureInfo oldFeature = getFaceFeature(searchId, true); if (oldEntity == null && oldFeature != null) { oldEntity = new FaceEntity(searchId, oldFeature.getFeatureData()); } @@ -193,6 +193,11 @@ public class FaceServer { faceRegisterInfoMap.put(searchId, faceEntity); return code; } + if (code == ErrorInfo.MOK) { + Log.e(TAG, "人脸特征注册后校验失败searchId:" + searchId); + } else { + Log.e(TAG, "注册人脸特征失败searchId:" + searchId + ";code:" + code); + } restoreFaceFeature(searchId, oldEntity); return code == ErrorInfo.MOK ? -99 : code; } catch (Exception exception) { @@ -257,13 +262,34 @@ public class FaceServer { return replaceFaceFeature(faceEntity); } - // 从人脸数据库中搜索人脸 + /** + * 从虹软内存库读取人脸特征,保留记录不存在及其他查询异常的错误日志。 + * + * @param searchId 虹软 32 位搜索标识,调用前须完成引擎初始化 + * @return 查到的特征;SDK 返回空或抛出参数异常时返回空,供调用方判定失败 + */ public synchronized FaceFeatureInfo getFaceFeature(int searchId) { + return getFaceFeature(searchId, false); + } + + /** + * 按查询用途处理记录不存在的日志,其他异常仍沿用原有日志和返回语义。 + * + * @param searchId 虹软 32 位搜索标识,调用方须持有当前实例锁并保证引擎已初始化 + * @param allowMissing 仅写入前查询可选旧特征时为 true,注册后校验必须为 false + * @return 查到的特征;SDK 返回空或抛出参数异常时返回空 + */ + private FaceFeatureInfo getFaceFeature(int searchId, boolean allowMissing) { FaceFeatureInfo faceFeatureInfo = null; try { faceFeatureInfo = faceEngine.getFaceFeature(searchId); } catch (IllegalArgumentException e) { - Log.e(TAG, "从人脸数据库中搜索人脸error:" + e.getMessage(), e); + String message = e.getMessage(); + // SDK 通过“错误码:”前缀传递原因;限定完整错误码,避免吞掉其他查询异常。 + boolean missing = message != null && message.startsWith(ErrorInfo.MERR_ASF_SEARCH_NO_EXIST + ":"); + if (!allowMissing || !missing) { + Log.e(TAG, "从人脸数据库中搜索人脸error:" + message, e); + } } return faceFeatureInfo; } diff --git a/lib/common/utils/manage/print_manage.dart b/lib/common/utils/manage/print_manage.dart index 48e3b05..4058bb5 100644 --- a/lib/common/utils/manage/print_manage.dart +++ b/lib/common/utils/manage/print_manage.dart @@ -98,6 +98,8 @@ class PrintManage extends BaseFacilityManage { /// 打开目标 USB 打印机并装载 ESC/POS 能力;失败只更新设备状态,不改变订单结果。 @override Future open() async { + // 上一次连接可能在授权、claim 或拔线期间失败;重新发现前先释放残留句柄。 + await close(); final check = await super.open(); if (!check) { AppDeviceStatusTool.updatePrinter(false); @@ -106,11 +108,20 @@ class PrintManage extends BaseFacilityManage { try { lastError = null; final setting = await AppSettingTool.getSysSetting(); - final target = _findTarget(await USBPrinterManager.discover(), setting); + final printers = await USBPrinterManager.discover(); + final target = _findTarget(printers, setting); if (target == null) { - lastError = setting.printerUsbVendorId == null + final reason = setting.printerUsbVendorId == null ? '未发现可用 USB 打印机' : '未发现后台配置 VID/PID 对应的 USB 打印机'; + // 保留配置和实际枚举结果,才能区分设备掉线与后台绑定参数不匹配。 + final discovered = printers + .map((printer) => '${printer.vendorId}/${printer.productId}' + '@${printer.deviceId}') + .join(','); + lastError = '$reason;配置:${setting.printerUsbVendorId}/' + '${setting.printerUsbProductId};已发现:[${discovered.isEmpty ? '无' : discovered}]'; + logger.w('|$_tag|USB 发现失败|$lastError|'); AppDeviceStatusTool.updatePrinter(false); return false; } @@ -123,6 +134,7 @@ class PrintManage extends BaseFacilityManage { lastError = e.toString(); AppDeviceStatusTool.updatePrinter(false); logger.e("|$_tag|打开打印机失败error|$e|"); + await close(); return false; } } @@ -396,15 +408,18 @@ class PrintManage extends BaseFacilityManage { } } - /// 关闭当前 USB 连接并清除“打印中”状态;重复调用不报错。 + /// 释放并清空当前连接;拔线后的释放异常只记日志,不能中断其余打印任务。 @override Future close() async { AppDeviceStatusTool.updatePrinterWorking(false); - if (ObjectUtil.isNotEmpty(manager)) { - if (manager != null) { - await manager?.disconnect(); - } - manager = null; + final current = manager; + manager = null; + if (current == null) return; + try { + await current.disconnect(); + } catch (error, stack) { + // 不覆盖 lastError,原始发现/写入错误才是本次任务失败的依据。 + logger.w('|$_tag|释放 USB 连接失败,后续任务将重新连接|error:$error|stack:$stack|'); } } diff --git a/lib/common/utils/sync/sync_print_tool.dart b/lib/common/utils/sync/sync_print_tool.dart index c52139b..241175d 100644 --- a/lib/common/utils/sync/sync_print_tool.dart +++ b/lib/common/utils/sync/sync_print_tool.dart @@ -19,8 +19,8 @@ abstract class SyncPrintTool { /// 是否已有 HTTP 待办补拉正在执行,防止 MQTT 重连和页面恢复重复拉取。 static bool _isPulling = false; - /// 将 MQTT 与 HTTP 的并发入队串行化,避免唯一索引插入竞争。 - static Future _enqueueTail = Future.value(); + /// MQTT/HTTP 入队与即时/定时回报共用的协调器,生命周期与应用主屏一致。 + static final PrintTaskCoordinator _taskCoordinator = PrintTaskCoordinator(); /// 三次 USB 尝试前的等待时间:首次立即执行,后两次失败后分别等待 5 秒和 30 秒。 /// @@ -46,7 +46,7 @@ abstract class SyncPrintTool { int printNo = 1, int retryVersion = 0, }) async { - final operation = _enqueueTail.then((_) => _addTask( + await _taskCoordinator.mutate(() => _addTask( orderId: orderId, androidOrderId: androidOrderId, historyId: historyId, @@ -56,12 +56,6 @@ abstract class SyncPrintTool { printNo: printNo, retryVersion: retryVersion, )); - // 队尾吞掉上一项异常以保证后续任务仍可入队,当前调用者仍等待原 operation 获得真实结果。 - _enqueueTail = operation.then( - (_) {}, - onError: (Object _, StackTrace __) {}, - ); - await operation; } /// 在串行入队区内执行任务新增或失败版本更新。 @@ -242,36 +236,43 @@ abstract class SyncPrintTool { } task.attemptCount = index + 1; await dao.edit(task); - var opened = false; + var accepted = false; try { - opened = await app.printManage.open(); + final opened = await app.printManage.open(); if (!opened) { throw StateError(app.printManage.lastError ?? 'USB 打印机连接失败'); } // 固定图片栅格打印:指令打印在真机版式与走纸兼容性不达标,产品决定暂不启用; // 任务携带的 printCmd 仅落库留存,不参与出纸。 - final accepted = await app.printManage.printImg(task.invoiceUrl); + accepted = await app.printManage.printImg(task.invoiceUrl); if (!accepted) { throw StateError(app.printManage.lastError ?? 'USB 未完整接收打印数据'); } + } catch (error, stack) { + task.lastError = _errorText(error); + logger.w( + '|$_tag|USB 打印尝试失败|task:${task.servicePrintId}|attempt:${task.attemptCount}|error:$error|stack:$stack|'); + } finally { + // open 返回失败也可能已经创建了部分连接;清理失败不能终止整个打印队列。 + try { + await app.printManage.close(); + } catch (error, stack) { + logger.w( + '|$_tag|USB 连接清理异常|task:${task.servicePrintId}|error:$error|stack:$stack|'); + } + } + if (accepted) { task ..status = PrintStatus.SUCCESS ..printTime = DateUtil.formatDate(DateTime.now()) ..lastError = null ..resultPending = true; + // USB 已接收数据后,持久化或回报异常不能进入 USB 重试分支再次出纸。 await dao.edit(task); await _report(task); return; - } catch (error, stack) { - task.lastError = _errorText(error); - await dao.edit(task); - logger.w( - '|$_tag|USB 打印尝试失败|task:${task.servicePrintId}|attempt:${task.attemptCount}|error:$error|stack:$stack|'); - } finally { - if (opened) { - await app.printManage.close(); - } } + await dao.edit(task); } task ..status = PrintStatus.ERROR @@ -282,33 +283,25 @@ abstract class SyncPrintTool { /// 向服务端提交当前重试版本的最终结果;失败时保留 resultPending 等待网络恢复补报。 static Future _report(PrintHistoryEntity task) async { - if (!AppNetworkTool.isAutoNetwork()) { - return; - } + if (!AppNetworkTool.isAutoNetwork()) return; final success = task.status == PrintStatus.SUCCESS || task.status == PrintStatus.SYNC; try { - final accepted = await HardApi.reportPrinterResult( - taskId: task.servicePrintId, - orderId: task.serviceOrderId, - retryVersion: task.retryVersion, - success: success, - attemptCount: task.attemptCount, - errorMessage: success ? null : task.lastError, + await _taskCoordinator.report( + task, + dao: Get.find().dataBase.getPrintHistoryDao, + sendResult: (current) => HardApi.reportPrinterResult( + taskId: current.servicePrintId, + orderId: current.serviceOrderId, + retryVersion: current.retryVersion, + success: success, + attemptCount: current.attemptCount, + errorMessage: success ? null : current.lastError, + ), ); - if (!accepted) { - throw StateError('服务端拒绝打印结果'); - } - task - ..resultPending = false - ..syncTime = DateTime.now(); - if (success) { - task.status = PrintStatus.SYNC; - } - await Get.find().dataBase.getPrintHistoryDao.edit(task); } catch (error, stack) { logger.w( - '|$_tag|打印结果等待补报|task:${task.servicePrintId}|error:$error|stack:$stack|'); + '|$_tag|打印结果等待补报|task:${task.servicePrintId}|version:${task.retryVersion}|success:$success|attempt:${task.attemptCount}|error:$error|stack:$stack|'); } } @@ -318,3 +311,68 @@ abstract class SyncPrintTool { return text.length <= 500 ? text : text.substring(0, 500); } } + +/// 协调打印任务的本地版本更新和结果回报,使用现有 DAO,不改变数据库与网络协议。 +class PrintTaskCoordinator { + /// 本地读改写队尾;入队与回报确认必须共用,防止版本检查后被其他写入穿插。 + Future _mutationTail = Future.value(); + + /// 正在提交的“任务 ID:重试版本”;完成或异常后移除,允许后续补报。 + final Set _reportsInFlight = {}; + + /// 串行执行本地读改写;网络请求与 USB 等待不得放进此队列。 + Future mutate(Future Function() action) { + final operation = _mutationTail.then((_) => action()); + // 当前调用保留异常,后续入队和回报确认仍可继续执行。 + _mutationTail = operation.then( + (_) {}, + onError: (Object _, StackTrace __) {}, + ); + return operation; + } + + /// 回报尚未确认的当前版本;旧快照与并发重复回报跳过,拒绝或异常保留待补报状态。 + /// + /// [sendResult] 使用统一 API 返回服务端是否接受,不在此方法执行任何 USB 写入。 + Future report( + PrintHistoryEntity task, { + required PrintHistoryDao dao, + required Future Function(PrintHistoryEntity task) sendResult, + }) async { + final reportKey = '${task.servicePrintId}:${task.retryVersion}'; + if (!_reportsInFlight.add(reportKey)) return; + final success = + task.status == PrintStatus.SUCCESS || task.status == PrintStatus.SYNC; + try { + final current = await dao.findByServicePrintId(task.servicePrintId); + // 定时扫描拿到的是快照;等待前面任务回报时,该记录可能已经重试或完成确认。 + if (current == null || + !current.resultPending || + current.retryVersion != task.retryVersion || + current.status != task.status) { + return; + } + final accepted = await sendResult(current); + if (!accepted) { + throw StateError('服务端拒绝打印结果'); + } + await mutate(() async { + final latest = await dao.findByServicePrintId(task.servicePrintId); + // 与新版本入队共用本地写入队列,确保检查版本到落库之间不会被插入一次重试。 + if (latest == null || + !latest.resultPending || + latest.retryVersion != task.retryVersion || + latest.status != task.status) { + return; + } + latest + ..resultPending = false + ..syncTime = DateTime.now(); + if (success) latest.status = PrintStatus.SYNC; + await dao.edit(latest); + }); + } finally { + _reportsInFlight.remove(reportKey); + } + } +} diff --git a/lib/common/widgets/cashier/cashier_workspace.dart b/lib/common/widgets/cashier/cashier_workspace.dart index ab4ee99..0bc23e5 100644 --- a/lib/common/widgets/cashier/cashier_workspace.dart +++ b/lib/common/widgets/cashier/cashier_workspace.dart @@ -97,6 +97,7 @@ class CashierWorkspace extends StatelessWidget { required this.onClearCart, required this.onPay, required this.onCancel, + this.checkoutInProgress = false, this.searchController, this.searchFocusNode, this.searchHint = '搜索商品、自编码或扫码', @@ -135,6 +136,9 @@ class CashierWorkspace extends StatelessWidget { final ValueChanged onRemoveCartItem; final VoidCallback onClearCart; final VoidCallback onPay; + + /// 本次结算准备或弹框仍在进行时禁用主支付入口,由业务 Controller 持有状态。 + final bool checkoutInProgress; final VoidCallback onCancel; final TextEditingController? searchController; final FocusNode? searchFocusNode; @@ -217,6 +221,7 @@ class CashierWorkspace extends StatelessWidget { onWeight: onWeight, onClear: onClearCart, onPay: onPay, + checkoutInProgress: checkoutInProgress, onCancel: onCancel, onHold: onHold, onMore: onMore, @@ -956,7 +961,9 @@ class CashierPagination extends StatelessWidget { } } +/// 收银购物车及支付操作区,结算期间展示业务锁并禁用重复支付。 class CashierCartPanel extends StatelessWidget { + /// 创建购物车展示;结算锁由所属业务页面响应式传入。 const CashierCartPanel({ super.key, required this.title, @@ -970,6 +977,7 @@ class CashierCartPanel extends StatelessWidget { required this.onClear, required this.onPay, required this.onCancel, + this.checkoutInProgress = false, this.onWeight, this.onHold, this.onMore, @@ -988,12 +996,16 @@ class CashierCartPanel extends StatelessWidget { final ValueChanged? onWeight; final VoidCallback onClear; final VoidCallback onPay; + + /// 结算开始即展示进行中状态,防止本地建单尚未完成时按钮仍可重复点击。 + final bool checkoutInProgress; final VoidCallback onCancel; final VoidCallback? onHold; final VoidCallback? onMore; final bool showHoldAction; final bool showMoreAction; + /// 按购物车金额和结算状态构建操作区,空购物车或已有结算时禁止支付。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); @@ -1215,9 +1227,11 @@ class CashierCartPanel extends StatelessWidget { children: [ Expanded( child: _CashierButton( - label: '立即支付 ${_moneyText(payable)}', - // 没有有效数量或重量时使用真实禁用语义,避免零数量条目进入支付流程。 - onTap: hasPayableItem ? onPay : null, + label: checkoutInProgress + ? '正在结算...' + : '立即支付 ${_moneyText(payable)}', + // 状态在所属页面的 Obx 中读取,首次异步准备期间也立即反馈并禁用。 + onTap: hasPayableItem && !checkoutInProgress ? onPay : null, height: 56.h, textStyle: theme.typography.lg.copyWith( color: Colors.white, diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index 23ff3de..321664e 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -10,6 +10,7 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 收银购物车与订单共用流程,统一结算互斥、本地订单及支付后的双屏和打印协调。 mixin OrderMixin { static const _tag = "OrderMixin"; @@ -72,6 +73,9 @@ mixin OrderMixin { // 本地订单id int? sqlOrderId; + /// 当前购物车正在结算;从首次异步准备前锁定,直到弹框关闭或扫码支付收尾才释放。 + final isCheckoutInProgress = false.obs; + /// 最近一次远程下单明确返回的业务错误;网络异常或尚未下单时为空。 String? lastCreateOrderBusinessMessage; @@ -182,6 +186,22 @@ mixin OrderMixin { } } + /// 串行处理同一购物车的结算入口,防止连点或扫码枪并发覆盖当前本地订单号。 + /// + /// 必须在读取快照、生成订单号之前调用;拒绝排队重复操作,失败和取消后允许新交易。 + Future runCheckout(Future Function() checkout) async { + if (isCheckoutInProgress.value) return; + isCheckoutInProgress.value = true; + sqlOrderId = null; + try { + await checkout(); + } finally { + // 只清理内存中的交易引用,历史订单仍保留原状态,禁止下一次失败建单沿用旧 ID。 + sqlOrderId = null; + isCheckoutInProgress.value = false; + } + } + // 创建本地订单 Future createSqlOrder() async { final orderSourceTypeId = getOrderSourceTypeId(); diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index a2d7e19..bd1c42f 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -93,6 +93,12 @@ class SelectPayTypeDialogController extends GetxController { /// 支付提交正在执行时为 true,用于禁用重复提交和弹框关闭。 final isSubmitting = false.obs; + /// 取消或切换正在释放识别资源时锁定操作,避免异步清理期间再次启动或重复关闭路由。 + final isClosing = false.obs; + + /// 当前控制器是否已持有结算弹框路由;路由完全退出前禁止重入初始化。 + bool _dialogOpen = false; + /// 当前嵌入收银工作区的人脸会话;为空时不渲染摄像头面板。 final faceSessionHandle = Rxn(); @@ -129,6 +135,9 @@ class SelectPayTypeDialogController extends GetxController { /// 饭卡读取请求序号;取消、关闭或再次发起后,旧异步回调不得替换当前人员。 int _walletCardDispatchRequestId = 0; + /// 聚合码创建及轮询序号;取消或关闭后,旧请求不得展示二维码或完成新交易。 + int _qrCodeScanDispatchRequestId = 0; + int? _payableAmount; int? _totalAmount; int? _discountAmount; @@ -163,7 +172,11 @@ class SelectPayTypeDialogController extends GetxController { /// 支付卡片由自定义 InkWell 绘制,点击必须直接更新业务 Rx,不能只依赖未挂载的 /// Forui 单选组件刷新,否则 Controller 已收到选择但弹框仍显示旧选中态。 void selectPayDisplayType(OrderPayDisplayType payType) { - if (orderPayDisplayType.value == payType || isSubmitting.value) return; + if (orderPayDisplayType.value == payType || + isSubmitting.value || + isClosing.value) { + return; + } if (isRecognition.value) { pendingPayDisplayType.value = payType; closeRecognitionConfirmationVisible.value = false; @@ -208,6 +221,9 @@ class SelectPayTypeDialogController extends GetxController { /// 释放支付方式选择器与可能仍在运行的人脸会话。 @override void onClose() { + _faceDispatchRequestId++; + _walletCardDispatchRequestId++; + _qrCodeScanDispatchRequestId++; unawaited(_cancelPendingEvidence()); unawaited(_closeFaceSession(cancel: true)); super.onClose(); @@ -253,6 +269,7 @@ class SelectPayTypeDialogController extends GetxController { void _closeStatus() { _faceDispatchRequestId++; _walletCardDispatchRequestId++; + _qrCodeScanDispatchRequestId++; payUser.value = null; userWalletList.value = []; currentWallet.value = null; @@ -280,8 +297,9 @@ class SelectPayTypeDialogController extends GetxController { EasyLoading.dismiss(); } - // |开始识别|点击 + /// 只在空闲状态启动当前识别方式,拦截按钮重建前的连点及支付提交期间的直接调用。 Future doRecognition() async { + if (isRecognition.value || isSubmitting.value || isClosing.value) return; switch (orderPayDisplayType.value) { case OrderPayDisplayType.qrCode: await _dispatchQrCode(); @@ -302,7 +320,12 @@ class SelectPayTypeDialogController extends GetxController { /// 防重复提交当前支付信息,并仅在业务回调确认成功后关闭本次结算弹框。 Future doSubmit() async { - if (payUser.value == null || isSubmitting.value) return; + if (payUser.value == null || + isSubmitting.value || + isRecognition.value || + isClosing.value) { + return; + } if (orderPayDisplayType.value == OrderPayDisplayType.qrCodeScan) { await doRecognition(); return; @@ -334,6 +357,7 @@ class SelectPayTypeDialogController extends GetxController { /// 请求关闭支付弹框;识别进行中时改为展示行内确认区域。 Future doCancel() async { + if (isClosing.value) return; if (isSubmitting.value) { await AppRuntimeTool.vibrate(durationMs: 60); return; @@ -343,10 +367,15 @@ class SelectPayTypeDialogController extends GetxController { closeRecognitionConfirmationVisible.value = true; return; } - await _cancelPendingEvidence(); - _closeStatus(); - onCancel?.call(); - Navigator.of(Get.context!).pop(); + isClosing.value = true; + try { + await _cancelPendingEvidence(); + _closeStatus(); + onCancel?.call(); + Navigator.of(Get.context!).pop(); + } finally { + isClosing.value = false; + } } /// 关闭支付弹框前取消尚未绑定订单的本次人脸证据,避免把身份核验图片留在本地队列。 @@ -354,7 +383,9 @@ class SelectPayTypeDialogController extends GetxController { final sessionId = faceRecognitionResult.value?.sessionId; if (sessionId == null || sessionId.isEmpty) return; await OrderEvidenceUploadTool.cancelSession(sessionId); - faceRecognitionResult.value = null; + if (faceRecognitionResult.value?.sessionId == sessionId) { + faceRecognitionResult.value = null; + } } /// 取消关闭或切换请求,继续当前识别会话。 @@ -365,22 +396,32 @@ class SelectPayTypeDialogController extends GetxController { /// 确认取消当前识别并关闭整个支付弹框。 Future confirmRecognitionAndClose() async { - if (isSubmitting.value) return; - await _stopCurrentRecognition(); - _closeStatus(); - onCancel?.call(); - if (Get.context != null) Navigator.of(Get.context!).pop(); + if (isSubmitting.value || isClosing.value) return; + isClosing.value = true; + try { + await _stopCurrentRecognition(); + _closeStatus(); + onCancel?.call(); + if (Get.context != null) Navigator.of(Get.context!).pop(); + } finally { + isClosing.value = false; + } } /// 确认取消当前识别并切换到用户刚选择的支付方式。 Future confirmRecognitionAndSwitch() async { final pending = pendingPayDisplayType.value; - if (pending == null || isSubmitting.value) return; - await _stopCurrentRecognition(); - closeRecognitionConfirmationVisible.value = false; - pendingPayDisplayType.value = null; - _applyPayDisplayType(pending); - payTypeRadioC.value = {pending}; + if (pending == null || isSubmitting.value || isClosing.value) return; + isClosing.value = true; + try { + await _stopCurrentRecognition(); + closeRecognitionConfirmationVisible.value = false; + pendingPayDisplayType.value = null; + _applyPayDisplayType(pending); + payTypeRadioC.value = {pending}; + } finally { + isClosing.value = false; + } } /// 根据当前支付方式停止硬件、轮询或人脸会话,供切换和关闭流程共用。 @@ -399,11 +440,14 @@ class SelectPayTypeDialogController extends GetxController { } if (activeRecognitionType == OrderPayDisplayType.face) { _faceDispatchRequestId++; + _faceCallbackLock = false; + faceUserLoading.value = false; await _cancelPendingEvidence(); await _closeFaceSession(cancel: true); } else if (activeRecognitionType == OrderPayDisplayType.qrCode) { BarcodeScanManage.instance.dispose(); } else if (activeRecognitionType == OrderPayDisplayType.qrCodeScan) { + _qrCodeScanDispatchRequestId++; SubScreenPlugin.sendMsgToViceScreen( SubScreenAction.aggregateOrderPayCancel.name, ); @@ -506,6 +550,7 @@ class SelectPayTypeDialogController extends GetxController { Future reidentifyCurrentUser() async { if (!canReidentifyCurrentPayType || isSubmitting.value || + isClosing.value || isRecognition.value) { return; } @@ -725,20 +770,28 @@ class SelectPayTypeDialogController extends GetxController { FaceStream event, String sessionId, ) async { - if (_faceCallbackLock || faceSessionHandle.value?.sessionId != sessionId) { + if (_faceCallbackLock || + !isRecognition.value || + isSubmitting.value || + isClosing.value || + faceSessionHandle.value?.sessionId != sessionId) { return; } + final requestId = _faceDispatchRequestId; _faceCallbackLock = true; faceSessionHandle.value = null; faceWorkspaceMessage.value = '识别成功,正在读取人员与钱包信息'; faceUserLoading.value = true; try { - await _faceDiscernAfter(event); + await _faceDiscernAfter(event, requestId); } finally { - faceUserLoading.value = false; - isRecognition.value = false; - isReidentifying.value = false; - _faceCallbackLock = false; + // 旧请求结束不能解开新一轮识别的锁,也不能清掉新弹框的加载状态。 + if (requestId == _faceDispatchRequestId) { + faceUserLoading.value = false; + isRecognition.value = false; + isReidentifying.value = false; + _faceCallbackLock = false; + } } } @@ -762,8 +815,8 @@ class SelectPayTypeDialogController extends GetxController { } } - // android人脸识别成功回调 - Future _faceDiscernAfter(FaceStream event) async { + /// 查询本轮人脸对应人员、证据和钱包;每次异步返回后校验归属,取消后不得写回人员。 + Future _faceDiscernAfter(FaceStream event, int requestId) async { final reidentifying = isReidentifying.value; String? createdEvidenceSessionId; try { @@ -772,6 +825,7 @@ class SelectPayTypeDialogController extends GetxController { recognition: event, sysType: appSetting.sysType, ); + if (!_ownsFaceDispatch(requestId)) return; if (nextUser == null) { throw StateError('该人脸样本对应人员已失效,请重新同步或重新录入人脸'); } @@ -784,6 +838,7 @@ class SelectPayTypeDialogController extends GetxController { throw StateError('识别结果缺少人脸样本编号,请重新同步人脸'); } final face = await app.dataBase.getFaceDao.findBySampleId(sampleId); + if (!_ownsFaceDispatch(requestId)) return; if (face == null || face.userId != nextUser.userId) { throw StateError('本地人脸样本与识别人员不一致,请重新同步人脸'); } @@ -793,6 +848,7 @@ class SelectPayTypeDialogController extends GetxController { recognition: event, face: face, ); + if (!_ownsFaceDispatch(requestId)) return; var nextWallets = []; if (isNetwork) { @@ -810,6 +866,7 @@ class SelectPayTypeDialogController extends GetxController { } else { logger.i("|_tag|人脸识别|无网络|,用户信息:|${nextUser.toJson()}|"); } + if (!_ownsFaceDispatch(requestId)) return; final nextWallet = _defaultWalletForUser(nextUser, nextWallets); if (nextWallet != null) { _applyWalletToUser(nextUser, nextWallet); @@ -826,6 +883,7 @@ class SelectPayTypeDialogController extends GetxController { logger.w('|$_tag|旧人脸证据任务取消失败|errorType:${error.runtimeType}|'); } } + if (!_ownsFaceDispatch(requestId)) return; // 人员和钱包使用同一批识别结果一次替换,避免界面短暂混用新旧人员数据。 userWalletList.assignAll(nextWallets); currentWallet.value = nextWallet; @@ -837,6 +895,15 @@ class SelectPayTypeDialogController extends GetxController { onDiscernUser?.call(nextUser); faceWorkspaceMessage.value = ''; } catch (e, task) { + if (!_ownsFaceDispatch(requestId)) return; + logger.e("|$_tag|人脸识别,意外的错误error:|$e|task:|$task|"); + // 重新识别失败时继续保留原人员,避免一次设备或网络异常破坏可支付状态。 + if (!reidentifying) payUser.value = null; + final message = e.toString().replaceFirst('Bad state: ', '').trim(); + faceWorkspaceMessage.value = + message.isEmpty ? '人员或钱包信息读取失败,请重新识别' : message; + } finally { + // 包含取消后的提前返回;已写入但未被当前有效交易接纳的证据必须回收。 if (createdEvidenceSessionId != null) { try { await OrderEvidenceUploadTool.cancelSession( @@ -849,12 +916,6 @@ class SelectPayTypeDialogController extends GetxController { ); } } - logger.e("|$_tag|人脸识别,意外的错误error:|$e|task:|$task|"); - // 重新识别失败时继续保留原人员,避免一次设备或网络异常破坏可支付状态。 - if (!reidentifying) payUser.value = null; - final message = e.toString().replaceFirst('Bad state: ', '').trim(); - faceWorkspaceMessage.value = - message.isEmpty ? '人员或钱包信息读取失败,请重新识别' : message; } } @@ -885,12 +946,20 @@ class SelectPayTypeDialogController extends GetxController { // ************************************************************************** /// 创建并展示聚合订单码,轮询到服务端终态后再通知收银业务完成本地订单。 Future _dispatchQrCodeScan() async { + if (isRecognition.value) return; + final requestId = ++_qrCodeScanDispatchRequestId; + // 必须在网络建单前上锁,不能等二维码返回后才禁用发起按钮。 + isRecognition.value = true; + // 建单尚未返回时不能取消或改付款方式,避免订单请求与当前购物车状态脱离。 + isSubmitting.value = true; var aggregateQrCodeDisplayed = false; var paymentConfirmed = false; try { // 收银业务先创建 Hardware 订单,Thirdparty 聚合会话必须与该业务订单持久化绑定。 await onQrCodeScanRecognition?.call( (orderRes) async { + if (!_ownsQrCodeScanDispatch(requestId)) return; + isSubmitting.value = false; logger.i( "|$_tag|聚合订单码创建成功,orderId:|${orderRes.orderId}|aggregateNo:|${orderRes.aggregateNo}|", ); @@ -911,9 +980,8 @@ class SelectPayTypeDialogController extends GetxController { params: qrCodeScanEvent.toJson(), ); aggregateQrCodeDisplayed = true; - isRecognition.value = true; - final status = await _queryAggregateOrderPayStatus(orderRes); - if (!isRecognition.value) return; + final status = await _queryAggregateOrderPayStatus(orderRes, requestId); + if (!_ownsQrCodeScanDispatch(requestId)) return; if (status == null || !status.paySuccess) { isRecognition.value = false; SubScreenPlugin.sendMsgToViceScreen( @@ -923,6 +991,8 @@ class SelectPayTypeDialogController extends GetxController { return; } paymentConfirmed = true; + // 已确认扣款后的本地保存与清空购物车不能被取消或切换打断。 + isSubmitting.value = true; SubScreenPlugin.sendMsgToViceScreen( SubScreenAction.aggregateOrderPaySuccess.name, ); @@ -933,6 +1003,7 @@ class SelectPayTypeDialogController extends GetxController { }, ); } catch (error, stack) { + if (requestId != _qrCodeScanDispatchRequestId) return; logger.e("|$_tag|二维码扫一扫,意外的错误error:|$error|task:|$stack|"); isRecognition.value = false; if (paymentConfirmed) { @@ -950,9 +1021,19 @@ class SelectPayTypeDialogController extends GetxController { : '二维码扫一扫错误,请重试', ); } + } finally { + if (requestId == _qrCodeScanDispatchRequestId) { + isRecognition.value = false; + isSubmitting.value = false; + } } } + /// 判断聚合码创建和轮询是否仍归属于当前未取消的交易。 + bool _ownsQrCodeScanDispatch(int requestId) { + return requestId == _qrCodeScanDispatchRequestId && isRecognition.value; + } + /// 校验聚合码只能承载独立 HTTPS 收银路径,禁止混入钱包码或平台原生码内容。 bool _isAggregateCashierUrl(String value) { final uri = Uri.tryParse(value.trim()); @@ -965,23 +1046,26 @@ class SelectPayTypeDialogController extends GetxController { /// 在服务端有效期内轮询聚合支付状态,短暂网络失败不会改变支付结论。 Future _queryAggregateOrderPayStatus( ServiceHardAggregateOrderCodePay order, + int requestId, ) async { final deadline = order.expireTime.add(const Duration(seconds: 10)); var consecutiveErrors = 0; while (!DateTime.now().isAfter(deadline)) { - if (!isRecognition.value) { + if (!_ownsQrCodeScanDispatch(requestId)) { logger.i( "|$_tag|聚合支付查询已由收银员取消,orderId:|${order.orderId}|", ); return null; } await Future.delayed(const Duration(seconds: 2)); + if (!_ownsQrCodeScanDispatch(requestId)) return null; ServiceHardAggregateOrderCodePayStatus? status; try { status = await HardApi.getAggregateOrderCodePayStatus( orderId: order.orderId, aggregateNo: order.aggregateNo, ); + if (!_ownsQrCodeScanDispatch(requestId)) return null; } catch (error) { consecutiveErrors++; logger.w( @@ -1136,13 +1220,15 @@ Future selectPayTypeDialog({ }) async { if (snapshot.isEmpty) return; final payTypeC = Get.find(); - final context = Get.context!; - payTypeC.initPayType(snapshot: snapshot); + if (payTypeC._dialogOpen) return; + payTypeC._dialogOpen = true; final snapshotNotifier = ValueNotifier(snapshot); - final viewport = MediaQuery.sizeOf(context); - final dialogWidth = viewport.width * 0.92; - final dialogHeight = viewport.height * 0.90; try { + final context = Get.context!; + payTypeC.initPayType(snapshot: snapshot); + final viewport = MediaQuery.sizeOf(context); + final dialogWidth = viewport.width * 0.92; + final dialogHeight = viewport.height * 0.90; await showAppDialog( context: context, barrierDismissible: false, @@ -1191,6 +1277,7 @@ Future selectPayTypeDialog({ ); } finally { snapshotNotifier.dispose(); + payTypeC._dialogOpen = false; } } @@ -3176,6 +3263,7 @@ class _CheckoutPaymentActions extends StatelessWidget { return Obx(() { final snapshotRevision = controller.checkoutSnapshotRevision.value; final submitting = controller.isSubmitting.value; + final closing = controller.isClosing.value; final recognizing = controller.isRecognition.value; final hasUser = controller.payUser.value != null; final selectedType = controller.orderPayDisplayType.value; @@ -3194,13 +3282,13 @@ class _CheckoutPaymentActions extends StatelessWidget { child: [ _PaymentDialogButton( label: '取消', - onTap: submitting ? null : controller.doCancel, + onTap: submitting || closing ? null : controller.doCancel, soft: true, ).expanded(), SizedBox(width: 12.w), _PaymentDialogButton( label: label, - onTap: !paymentEnabled || recognizing || submitting + onTap: !paymentEnabled || recognizing || submitting || closing ? null : aggregateOrderPay ? controller.doRecognition diff --git a/lib/pages/terminal/canteen_product/controller.dart b/lib/pages/terminal/canteen_product/controller.dart index 703815d..ac17d0b 100644 --- a/lib/pages/terminal/canteen_product/controller.dart +++ b/lib/pages/terminal/canteen_product/controller.dart @@ -241,21 +241,26 @@ class CanteenProductController extends GetxController with OrderMixin { }); } - // |下单|点击购买 + /// 锁定本次结算后创建本地订单;整个弹框生命周期只使用这一笔订单。 Future doPay() async { - // 上一单的打印角标不能压在新订单上,新结算开始即收起。 - dismissPrintAwait(); - final validShopCar = _validateShopCar(); - if (!validShopCar) return; - final snapshot = await _createCheckoutSnapshot(); - // 创建本地订单 - await createSqlOrder(); - await selectPayTypeDialog( - snapshot: snapshot, - onQuantityChanged: _changeCheckoutQuantity, - ); - // 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。 - unawaited(startPrintAwaitAfterCheckout()); + await runCheckout(() async { + // 锁必须覆盖快照中的数据库查询,不能等弹框显示后才阻止第二次点击。 + dismissPrintAwait(); + if (!_validateShopCar()) return; + final snapshot = await _createCheckoutSnapshot(); + if (snapshot.isEmpty) return; + final orderId = await createSqlOrder(); + if (orderId == null) { + Toast.error(msg: '本地订单创建失败,请重试'); + return; + } + await selectPayTypeDialog( + snapshot: snapshot, + onQuantityChanged: _changeCheckoutQuantity, + ); + // 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。 + unawaited(startPrintAwaitAfterCheckout()); + }); } /// 在结算弹框中按商品编码修改真实购物车数量,并返回包含最新金额与库存的展示快照。 @@ -455,6 +460,7 @@ class CanteenProductController extends GetxController with OrderMixin { sendSubScreen(); } + /// 绑定当前食堂购物车的识别与支付回调,本地订单写入成功后才允许远程下单。 void _listenPayTypeSelectCallback() { final payTypeSelect = Get.find(); // |支付方式|点击|开始识别| @@ -507,18 +513,23 @@ class CanteenProductController extends GetxController with OrderMixin { status: '支付中...', maskType: EasyLoadingMaskType.black, ); - // 最后计算一次额外的价格 - await _computedAmountMealPrice(); - await updateSqlOrder(); - final isNetwork = AppNetworkTool.isAutoNetwork(); - final orderResult = await createApiOrder(); - EasyLoading.dismiss(); - // 在线下单失败(包括服务端库存不足)时保留购物车,收银员可直接调整数量后重试; - // 离线订单没有远程返回值,但本地订单已经进入离线待同步状态,仍需正常收尾。 - if (isNetwork && orderResult == null) return false; - putOffSendSubScreen(); - await _clearShopCard(); - return true; + try { + // 最后计算一次额外的价格;本地更新失败不能继续扣款。 + await _computedAmountMealPrice(); + if (!await updateSqlOrder()) { + Toast.error(msg: '本地订单保存失败,请重试'); + return false; + } + final isNetwork = AppNetworkTool.isAutoNetwork(); + final orderResult = await createApiOrder(); + // 在线失败保留购物车;离线订单由原有本地支付流程收尾。 + if (isNetwork && orderResult == null) return false; + putOffSendSubScreen(); + await _clearShopCard(); + return true; + } finally { + EasyLoading.dismiss(); + } }; // |支付方式|点击|取消| payTypeSelect.onCancel = () {}; diff --git a/lib/pages/terminal/canteen_product/view.dart b/lib/pages/terminal/canteen_product/view.dart index cea6af3..737d3bc 100644 --- a/lib/pages/terminal/canteen_product/view.dart +++ b/lib/pages/terminal/canteen_product/view.dart @@ -86,6 +86,7 @@ class CanteenProductPage extends GetView { }, onClearCart: controller.doCancelPay, onPay: controller.doPay, + checkoutInProgress: controller.isCheckoutInProgress.value, onCancel: controller.doCancelPay, showHoldAction: showHoldAction, showMoreAction: showMoreAction, diff --git a/lib/pages/terminal/supermarket_order/controller.dart b/lib/pages/terminal/supermarket_order/controller.dart index da99d0e..6727440 100644 --- a/lib/pages/terminal/supermarket_order/controller.dart +++ b/lib/pages/terminal/supermarket_order/controller.dart @@ -85,18 +85,22 @@ class SupermarketOrderController extends GetxController with OrderMixin { } } - // |下单|点击购买 + /// 锁定本次结算后创建本地订单,与扫码枪支付共用同一交易锁。 Future doPay() async { - // 上一单的打印角标不能压在新订单上,新结算开始即收起。 - dismissPrintAwait(); - final validShopCar = _validateShopCar(); - if (!validShopCar) return; - final snapshot = _createCheckoutSnapshot(); - // 创建本地订单 - await createSqlOrder(); - await selectPayTypeDialog(snapshot: snapshot); - // 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。 - unawaited(startPrintAwaitAfterCheckout()); + await runCheckout(() async { + dismissPrintAwait(); + if (!_validateShopCar()) return; + final snapshot = _createCheckoutSnapshot(); + if (snapshot.isEmpty) return; + final orderId = await createSqlOrder(); + if (orderId == null) { + Toast.error(msg: '本地订单创建失败,请重试'); + return; + } + await selectPayTypeDialog(snapshot: snapshot); + // 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。 + unawaited(startPrintAwaitAfterCheckout()); + }); } /// 在打开支付弹框前冻结当前商品、数量和整数分金额,避免背景状态影响本次结算展示。 @@ -324,6 +328,7 @@ class SupermarketOrderController extends GetxController with OrderMixin { // 支付的回调 start // ************************************************************************** + /// 绑定超市识别与支付回调,本地订单写入成功后才允许远程下单。 void _listenPayTypeSelectCallback() { final payTypeSelect = Get.find(); // |支付方式|点击|开始识别| @@ -374,14 +379,20 @@ class SupermarketOrderController extends GetxController with OrderMixin { status: '支付中...', maskType: EasyLoadingMaskType.black, ); - await updateSqlOrder(); - final isNetwork = AppNetworkTool.isAutoNetwork(); - final orderResult = await createApiOrder(); - EasyLoading.dismiss(); - if (isNetwork && orderResult == null) return false; - _clearShopCard(); - putOffSendSubScreen(); - return true; + try { + if (!await updateSqlOrder()) { + Toast.error(msg: '本地订单保存失败,请重试'); + return false; + } + final isNetwork = AppNetworkTool.isAutoNetwork(); + final orderResult = await createApiOrder(); + if (isNetwork && orderResult == null) return false; + await _clearShopCard(); + putOffSendSubScreen(); + return true; + } finally { + EasyLoading.dismiss(); + } }; // |支付方式|点击|取消| payTypeSelect.onCancel = () {}; @@ -529,52 +540,51 @@ class SupermarketOrderController extends GetxController with OrderMixin { return false; } - // 处理扫码枪支付 + /// 扫码枪支付与结算弹框互斥,禁止硬件重复上报在同一购物车上另建订单。 Future handlePayQrCode(String event) async { - OrderPayType orderPayType = OrderPayType.WECHANT_SELF_QR_CODE_PAY; - - if (event.length == 18 && RegExp(r'^(2[5-9]|30)').hasMatch(event)) { - // 支付宝支付 - orderPayType = OrderPayType.ALI_SELF_QR_CODE_PAY; - } else if (event.length == 18 && event.startsWith("13")) { - // 微信支付 - orderPayType = OrderPayType.WECHANT_SELF_QR_CODE_PAY; - } else if (event.length == 32) { - // 钱包二维码支付 - orderPayType = OrderPayType.WALLET_QR_CODE; - } - // 创建本地订单 - this.orderType = OrderType.BELOW; - this.orderPayType.value = orderPayType; - this.orderPayDisplayType.value = OrderPayDisplayType.qrCode; - this.globalQrCode = ServiceQRCode( - id: event, - itemId: "-1", - typeId: "-1", - userId: "-1", - orderType: OrderType.BELOW, - payType: orderPayType, - exceedTime: -1, - extra: "-1", - ); - payUser.value = AppUserTool.generateExternalUser(); - final validShopCar = _validateShopCar(); - if (!validShopCar) return; - EasyLoading.show( - status: '支付中...', - maskType: EasyLoadingMaskType.black, - ); - try { - await createSqlOrder(); - await updateSqlOrder(); - await createApiOrder(); - } catch (error, stack) { - logger.e("|$_tag|,处理扫码枪支付失败|$error|stack|$stack|"); - } finally { - EasyLoading.dismiss(); - _clearShopCard(); - putOffSendSubScreen(); - } + await runCheckout(() async { + OrderPayType orderPayType = OrderPayType.WECHANT_SELF_QR_CODE_PAY; + if (event.length == 18 && RegExp(r'^(2[5-9]|30)').hasMatch(event)) { + orderPayType = OrderPayType.ALI_SELF_QR_CODE_PAY; + } else if (event.length == 18 && event.startsWith("13")) { + orderPayType = OrderPayType.WECHANT_SELF_QR_CODE_PAY; + } else if (event.length == 32) { + orderPayType = OrderPayType.WALLET_QR_CODE; + } + orderType = OrderType.BELOW; + this.orderPayType.value = orderPayType; + orderPayDisplayType.value = OrderPayDisplayType.qrCode; + globalQrCode = ServiceQRCode( + id: event, + itemId: "-1", + typeId: "-1", + userId: "-1", + orderType: OrderType.BELOW, + payType: orderPayType, + exceedTime: -1, + extra: "-1", + ); + payUser.value = AppUserTool.generateExternalUser(); + if (!_validateShopCar()) return; + EasyLoading.show( + status: '支付中...', + maskType: EasyLoadingMaskType.black, + ); + try { + if (await createSqlOrder() == null || !await updateSqlOrder()) { + Toast.error(msg: '本地订单保存失败,请重试'); + return; + } + await createApiOrder(); + } catch (error, stack) { + logger.e("|$_tag|,处理扫码枪支付失败|$error|stack|$stack|"); + } finally { + EasyLoading.dismiss(); + // 扫码枪沿用原有结束后清空购物车的语义,交易锁覆盖清理,避免重复硬件事件另建单。 + await _clearShopCard(); + putOffSendSubScreen(); + } + }); } // ************************************************************************** diff --git a/lib/pages/terminal/supermarket_order/view.dart b/lib/pages/terminal/supermarket_order/view.dart index 813ca99..a728da5 100644 --- a/lib/pages/terminal/supermarket_order/view.dart +++ b/lib/pages/terminal/supermarket_order/view.dart @@ -16,6 +16,7 @@ class SupermarketOrderPage extends GetView { final bool showHoldAction; final bool showMoreAction; + /// 构建超市收银工作区,并响应结算锁以即时禁用重复支付入口。 @override Widget build(BuildContext context) { return GetBuilder( @@ -60,6 +61,7 @@ class SupermarketOrderPage extends GetView { ), onClearCart: controller.doCancelPay, onPay: controller.doPay, + checkoutInProgress: controller.isCheckoutInProgress.value, onCancel: controller.doCancelPay, onHold: controller.addHoldBill, onMore: controller.doMore, diff --git a/packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt b/packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt index ee45435..7248476 100644 --- a/packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt +++ b/packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt @@ -12,9 +12,12 @@ import android.hardware.usb.UsbManager import android.os.Build import android.os.Handler import android.os.Looper +import android.util.Log import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors /** 项目内 USB 打印插件;只有 Bulk OUT 接受全部字节后才向 Dart 返回成功。 */ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { @@ -27,9 +30,15 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { /** Android USB Host 系统服务。 */ private lateinit var usbManager: UsbManager - /** 以 VID-PID 为键复用已经 claim 的打印机连接。 */ + /** 以 VID-PID 为键缓存连接;仅 USB 工作线程访问,设备实例变化时必须替换。 */ private val connections = mutableMapOf() + /** 串行执行连接、写入与释放,避免跨线程竞争缓存或在主线程等待 Bulk 写入。 */ + private var usbExecutor: ExecutorService? = null + + /** 原生 USB 生命周期诊断标签,不记录打印内容。 */ + private val logTag = "DragoUsbPrinter" + /** 将后台写入结果切回主线程提交给 Flutter。 */ private val mainHandler = Handler(Looper.getMainLooper()) @@ -37,18 +46,21 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { context = binding.applicationContext usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + usbExecutor = Executors.newSingleThreadExecutor() channel = MethodChannel(binding.binaryMessenger, "drago_usb_printer") channel.setMethodCallHandler(this) } + /** 保持现有四类方法通道契约,将硬件访问统一交给串行工作线程。 */ override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getUSBDeviceList" -> result.success(listPrinterDevices()) - "connect" -> runCatching { connectionFor(call).connect() } - .fold(result::success) { result.error("USB_CONNECT", it.message, null) } - "disconnect" -> { - connections.remove(deviceKey(call))?.close() - result.success(true) + "getUSBDeviceList" -> runUsbOperation(result, "USB_DISCOVERY") { listPrinterDevices() } + "connect" -> runUsbOperation(result, "USB_CONNECT") { + withConnection(call) { it.connect() } + } + "disconnect" -> runUsbOperation(result, "USB_DISCONNECT") { + closeConnection(deviceKey(call)) + true } "write" -> { val data = call.argument("data") @@ -56,22 +68,54 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { result.error("USB_DATA", "Missing USB payload", null) return } - Thread { - runCatching { connectionFor(call).writeFully(data) } - .fold( - { mainHandler.post { result.success(true) } }, - { error -> mainHandler.post { result.error("USB_WRITE", error.message, null) } }, - ) - }.start() + runUsbOperation(result, "USB_WRITE") { + withConnection(call) { it.writeFully(data) } + true + } } else -> result.notImplemented() } } + /** 在单个工作线程完成硬件调用,结果仍回到 Flutter 要求的主线程。 */ + private fun runUsbOperation(result: MethodChannel.Result, errorCode: String, action: () -> Any?) { + val executor = usbExecutor + if (executor == null) { + result.error(errorCode, "USB plugin is detached", null) + return + } + executor.execute { + runCatching(action).fold( + { value -> mainHandler.post { result.success(value) } }, + { error -> mainHandler.post { result.error(errorCode, error.message, null) } }, + ) + } + } + + /** 连接或写入失败立即淘汰句柄;下一次尝试重新 open/claim,不在原生层重发数据。 */ + private fun withConnection(call: MethodCall, action: (UsbPrinterConnection) -> T): T { + try { + return action(connectionFor(call)) + } catch (error: Throwable) { + closeConnection(deviceKey(call)) + throw error + } + } + + /** 先移除缓存再释放资源,释放异常不能留下可再次被取出的失效连接。 */ + private fun closeConnection(key: String) { + val connection = connections.remove(key) ?: return + runCatching { connection.close() } + .onFailure { Log.w(logTag, "USB close failed: $key", it) } + } + /** 返回至少包含一个 Printer Class 接口的 USB 设备摘要。 */ - private fun listPrinterDevices(): List> = usbManager.deviceList.values - .filter(::isPrinterDevice) - .map { device -> + private fun listPrinterDevices(): List> { + val devices = usbManager.deviceList.values + // 后台发现也会经过这里;拔出后即便没有新写入,也不继续持有旧接口。 + connections.filterValues { cached -> devices.none(cached::matchesDevice) } + .keys.toList().forEach(::closeConnection) + return devices.filter(::isPrinterDevice).map { device -> mapOf( "deviceName" to device.deviceName, "manufacturer" to safeValue { device.manufacturerName }, @@ -81,6 +125,7 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { "productId" to device.productId.toString(), ) } + } /** 校验 VID/PID、设备类型和授权后取得可复用连接。 */ private fun connectionFor(call: MethodCall): UsbPrinterConnection { @@ -93,7 +138,13 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { requestPermission(device) error("USB permission requested; retry after permission is granted") } - return connections.getOrPut("$vendorId-$productId") { + val key = "$vendorId-$productId" + val cached = connections[key] + if (cached != null && !cached.matchesDevice(device)) { + Log.i(logTag, "USB device changed; reopening $key at ${device.deviceName}") + closeConnection(key) + } + return connections.getOrPut(key) { UsbPrinterConnection(usbManager, device) } } @@ -123,11 +174,13 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { /** 兼容部分设备描述符读取异常,异常字段按空值返回。 */ private fun safeValue(read: () -> String?): String? = runCatching(read).getOrNull() - /** 释放全部已 claim 的接口和连接,并注销方法通道。 */ + /** 停止接收新调用,在已排队操作结束后释放接口,避免与正在进行的写入并发关闭。 */ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - connections.values.forEach(UsbPrinterConnection::close) - connections.clear() channel.setMethodCallHandler(null) + val executor = usbExecutor + usbExecutor = null + executor?.execute { connections.keys.toList().forEach(::closeConnection) } + executor?.shutdown() } } @@ -148,6 +201,11 @@ private class UsbPrinterConnection( /** 当前打印接口的 Bulk OUT 端点。 */ private var bulkOut: UsbEndpoint? = null + /** VID/PID 相同不代表仍是同次 USB 插入;设备地址变化后旧句柄不能复用。 */ + fun matchesDevice(current: UsbDevice): Boolean = + device.vendorId == current.vendorId && device.productId == current.productId && + device.deviceId == current.deviceId && device.deviceName == current.deviceName + /** 打开设备并 claim Printer Class 接口,缺少 Bulk OUT 时直接失败。 */ @Synchronized fun connect(): Boolean { @@ -163,9 +221,11 @@ private class UsbPrinterConnection( it.direction == UsbConstants.USB_DIR_OUT } ?: error("USB Bulk OUT endpoint is missing") val opened = manager.openDevice(device) ?: error("Unable to open USB printer") - if (!opened.claimInterface(targetInterface, true)) { + try { + check(opened.claimInterface(targetInterface, true)) { "Unable to claim USB printer interface" } + } catch (error: Throwable) { opened.close() - error("Unable to claim USB printer interface") + throw error } connection = opened printerInterface = targetInterface @@ -173,6 +233,7 @@ private class UsbPrinterConnection( return true } + /** 循环处理部分写入;任意一段失败交由调用方废弃连接,禁止静默报告成功。 */ @Synchronized fun writeFully(data: ByteArray): Int { connect() @@ -188,15 +249,18 @@ private class UsbPrinterConnection( return offset } + /** 即使拔线后的 releaseInterface 失败,也必须关闭设备并清空全部状态。 */ @Synchronized fun close() { - printerInterface?.let { connection?.releaseInterface(it) } - connection?.close() + val opened = connection + val claimed = printerInterface connection = null printerInterface = null bulkOut = null + try { + if (claimed != null) opened?.releaseInterface(claimed) + } finally { + opened?.close() + } } } - /** 分发设备发现、连接、关闭和完整写入四类原生调用。 */ - /** 循环处理 bulkTransfer 部分写入,并核对最终累计长度。 */ - /** 释放接口并关闭设备连接,重复调用保持幂等。 */ diff --git a/pubspec.yaml b/pubspec.yaml index 50391d0..cd7d883 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pj_supermarket_cloud description: "资安信智慧后勤-测试" publish_to: "none" -version: 1.5.8+158 +version: 1.5.9+159 environment: sdk: ^3.6.0 diff --git a/test/common/cashier_checkout_reentry_test.dart b/test/common/cashier_checkout_reentry_test.dart new file mode 100644 index 0000000..a8f2ee2 --- /dev/null +++ b/test/common/cashier_checkout_reentry_test.dart @@ -0,0 +1,430 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/canteen_product/controller.dart'; +import 'package:pj_supermarket_cloud/pages/terminal/supermarket_order/controller.dart'; + +/// 用可控制的异步边界验证结算重入,测试不访问真实订单接口、数据库或识别设备。 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + Get.testMode = true; + + setUp(() { + Get.put(AppControl()) + ..serialCardManage = SerialCardManage() + ..isNetwork.value = true; + Get.put(_PaymentController()); + }); + + tearDown(Get.reset); + + testWidgets('食堂立即支付连点只创建一单,弹框关闭前持续锁定且不重置人员', (tester) async { + final controller = _CanteenController(); + await _pumpCheckout(tester, controller); + final button = find.textContaining('立即支付'); + // 故意不在两次点击间 pump,覆盖 UI 尚未刷新但事件已经再次到达的窗口。 + await tester.tap(button); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(controller.createCount, 1); + expect(controller.isCheckoutInProgress.value, isTrue); + expect(find.text('正在结算...'), findsOneWidget); + final payButton = find.ancestor( + of: find.text('正在结算...'), + matching: find.byType(InkWell), + ); + expect(tester.widget(payButton).onTap, isNull); + + controller.pendingOrder.complete(5144); + await tester.pumpAndSettle(); + expect(find.text('收银结算'), findsOneWidget); + expect(controller.sqlOrderId, 5144); + await controller.doPay(); + expect(controller.createCount, 1); + + final payment = Get.find(); + final user = AppUserTool.generateExternalUser(); + payment.payUser.value = user; + await selectPayTypeDialog(snapshot: _snapshot()); + await tester.pumpAndSettle(); + expect(find.text('收银结算'), findsOneWidget); + expect(payment.payUser.value, same(user)); + + await payment.doCancel(); + await tester.pumpAndSettle(); + expect(controller.isCheckoutInProgress.value, isFalse); + expect(controller.sqlOrderId, isNull); + expect(find.textContaining('立即支付'), findsOneWidget); + }); + + testWidgets('本地建单失败不打开弹框、不复用旧订单,重试可正常进入', (tester) async { + final controller = _CanteenController()..sqlOrderId = 5143; + await _pumpCheckout(tester, controller); + final first = controller.doPay(); + await tester.pump(); + expect(controller.sqlOrderId, isNull); + controller.pendingOrder.complete(null); + await first; + await tester.pumpAndSettle(); + expect(find.text('收银结算'), findsNothing); + expect(controller.isCheckoutInProgress.value, isFalse); + expect(controller.shopCardList, hasLength(1)); + + controller.pendingOrder = Completer(); + final second = controller.doPay(); + await tester.pump(); + expect(controller.createCount, 2); + controller.pendingOrder.complete(5145); + await tester.pumpAndSettle(); + expect(find.text('收银结算'), findsOneWidget); + await Get.find().doCancel(); + await tester.pumpAndSettle(); + await second; + // 清除失败提示的定时退出动画,避免跨用例残留。 + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + }); + + test('超市结算期间扫码枪不能替换订单或付款方式,异常后释放结算锁', () async { + final controller = SupermarketOrderController(); + final pending = Completer(); + final checkout = controller.runCheckout(() async { + controller.sqlOrderId = 5144; + await pending.future; + }); + await controller.handlePayQrCode('130000000000000000'); + expect(controller.sqlOrderId, 5144); + expect(controller.orderPayType.value, OrderPayType.FACE); + expect(controller.globalQrCode, isNull); + final failure = expectLater(checkout, throwsStateError); + pending.completeError(StateError('模拟结算异常')); + await failure; + expect(controller.isCheckoutInProgress.value, isFalse); + expect(controller.sqlOrderId, isNull); + controller.selfCodeC.dispose(); + controller.focusNode.dispose(); + }); + + test('确认支付期间拒绝重复提交和识别,业务拒绝后允许同一交易重试', () async { + final controller = Get.find(); + controller.payUser.value = AppUserTool.generateExternalUser(); + var submitted = 0; + var recognized = 0; + var pending = Completer(); + controller.onRecognition = () => recognized++; + controller.onSubmit = (_, __, ___, ____, _____, ______) { + submitted++; + return pending.future; + }; + final first = controller.doSubmit(); + await controller.doSubmit(); + await controller.doRecognition(); + expect(submitted, 1); + expect(recognized, 0); + expect(controller.isSubmitting.value, isTrue); + pending.complete(false); + await first; + expect(controller.isSubmitting.value, isFalse); + pending = Completer(); + final retry = controller.doSubmit(); + expect(submitted, 2); + pending.complete(false); + await retry; + }); + + test('聚合码创建请求未返回时即互斥,结束后可重试', () async { + final controller = Get.find(); + controller.orderPayDisplayType.value = OrderPayDisplayType.qrCodeScan; + final pending = Completer(); + var created = 0; + controller.onQrCodeScanRecognition = (_) async { + created++; + await pending.future; + }; + final first = controller.doRecognition(); + await controller.doRecognition(); + controller.selectPayDisplayType(OrderPayDisplayType.cash); + expect(created, 1); + expect(controller.isRecognition.value, isTrue); + expect(controller.isSubmitting.value, isTrue); + expect( + controller.orderPayDisplayType.value, OrderPayDisplayType.qrCodeScan); + pending.complete(); + await first; + expect(controller.isRecognition.value, isFalse); + expect(controller.isSubmitting.value, isFalse); + await controller.doRecognition(); + expect(created, 2); + }); + + test('开始人脸识别在数据库准备阶段即拒绝连点,准备失败后可重试', () async { + final faces = _PreparingFaceDao(); + Get.find().dataBase = + _LookupDatabase(_DelayedUserDao(), faces: faces); + Get.put(_PreparationFaceService()); + final controller = Get.find(); + controller.orderPayDisplayType.value = OrderPayDisplayType.face; + final first = controller.doRecognition(); + await controller.doRecognition(); + expect(faces.queryCount, 1); + expect(controller.isRecognition.value, isTrue); + faces.pending.complete(0); + await first; + expect(controller.isRecognition.value, isFalse); + expect(controller.faceWorkspaceMessage.value, contains('人脸数据库为空')); + await controller.doRecognition(); + expect(faces.queryCount, 2); + }); + + test('识别进行中不能确认付款,结束识别清理期间也不能启动新识别', () async { + final controller = Get.find(); + controller.payUser.value = AppUserTool.generateExternalUser(); + var submitted = 0; + var recognized = 0; + controller.onSubmit = (_, __, ___, ____, _____, ______) async { + submitted++; + return false; + }; + controller.onRecognition = () => recognized++; + controller.isRecognition.value = true; + await controller.doSubmit(); + expect(submitted, 0); + controller.isRecognition.value = false; + controller.isClosing.value = true; + await controller.doRecognition(); + await controller.doSubmit(); + expect(recognized, 0); + expect(submitted, 0); + }); + + test('旧人脸人员查询返回后不能污染重新打开的结算或释放新识别锁', () async { + final app = Get.find(); + final lookup = _DelayedUserDao(); + app + ..isNetwork.value = false + ..dataBase = _LookupDatabase(lookup); + final controller = Get.find(); + controller.initPayType(snapshot: _snapshot()); + controller.orderPayDisplayType.value = OrderPayDisplayType.face; + controller.isRecognition.value = true; + controller.faceSessionHandle.value = FaceRecognitionSessionHandle( + sessionId: 'old-session', + scene: FaceRecognitionScene.cashier, + events: const Stream.empty(), + result: Completer().future, + ); + final first = controller.handleFaceRecognized( + FaceStream( + similar: 0.95, + cost: 10, + userId: 'old-user', + sessionId: 'old-session'), + 'old-session', + ); + await Future.delayed(Duration.zero); + expect(controller.faceUserLoading.value, isTrue); + // 取消并重开会推进请求序号,模拟下一位顾客已经开始识别。 + controller.initPayType(snapshot: _snapshot()); + final nextUser = AppUserTool.generateExternalUser(); + controller + ..payUser.value = nextUser + ..isRecognition.value = true + ..faceUserLoading.value = true; + lookup.pending.complete(null); + await first; + expect(controller.payUser.value, same(nextUser)); + expect(controller.isRecognition.value, isTrue); + expect(controller.faceUserLoading.value, isTrue); + expect(controller.faceWorkspaceMessage.value, isEmpty); + }); +} + +/// 构造不依赖设备初始化的食堂业务控制器,保留真实 doPay 和快照流程。 +class _CanteenController extends CanteenProductController { + /// 注入两份菜品合计 46 元的购物车和本地商品快照。 + _CanteenController() { + shopCardList.add(_cartItem()); + totalPrice.value = 4600; + final now = DateTime.now(); + canteenSpuList.add(CanteenSpuEntity( + serviceUniqueCode: 'product-1', + classifyServiceUniqueCode: 'class-1', + saleDate: now, + name: '测试套餐', + price: 2300, + isDefault: false, + status: 1, + sort: 1, + isPriceIncreased: false, + createTime: now, + updateTime: now, + )); + } + + /// 测试控制本地建单何时返回,用于模拟 SQLite 延迟和失败。 + Completer pendingOrder = Completer(); + + /// 本地建单入口实际调用次数。 + int createCount = 0; + + /// 只替换持久化边界,成功时与生产方法一样设置本地订单 ID。 + @override + Future createSqlOrder() async { + createCount++; + final id = await pendingOrder.future; + if (id != null) sqlOrderId = id; + return id; + } +} + +/// 固定支付配置以隔离设置存储,保留真实识别与支付入口方法。 +class _PaymentController extends SelectPayTypeDialogController { + /// 不读取设备数据库,测试中的支付方式由用例显式指定。 + @override + // ignore: must_call_super + void onInit() { + appSetting = SysSetting(sysType: SysType.CANTEEN) + ..usePayDisplayType = const [OrderPayDisplayType.cash] + ..defaultPayDisplayType = OrderPayDisplayType.cash; + orderPayDisplayType.value = OrderPayDisplayType.cash; + orderPayType.value = OrderPayType.CASH; + } +} + +/// 延迟返回旧识别人员的本地查询。 +class _DelayedUserDao implements UserDao { + /// 由用例在结算已经切换后结束的查询。 + final pending = Completer(); + + /// 只模拟识别链路需要的人员查询。 + @override + Future findByUserId(String userId) => pending.future; + + /// 测试禁止调用无关数据库写入。 + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +/// 人脸样本为空的用例只需要 DAO 句柄,不允许执行样本读写。 +class _UnusedFaceDao implements FaceDao { + /// 非预期的样本操作直接使测试失败。 + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +/// 延迟人脸库准备查询,覆盖首次 await 期间再次点击的竞争窗口。 +class _PreparingFaceDao extends _UnusedFaceDao { + /// 测试主动结束的本地人脸计数查询。 + final pending = Completer(); + + /// 真实识别入口访问数据库的次数。 + int queryCount = 0; + + /// 返回可控制的准备结果,零条记录会在开启设备前结束识别。 + @override + Future findCount(String userName) { + queryCount++; + return pending.future; + } +} + +/// 准备阶段测试只需要服务句柄,不初始化原生相机监听。 +class _PreparationFaceService extends FaceRecognitionSessionService { + /// 不连接原生事件通道,测试在数据库校验阶段终止。 + @override + // ignore: must_call_super + void onInit() {} + + /// 本用例没有原生会话或订阅,无需向设备发送挂起指令。 + @override + // ignore: must_call_super + void onClose() {} +} + +/// 仅提供延迟人员查询和人脸 DAO 的测试数据库。 +class _LookupDatabase implements AppDatabase { + /// 使用当前用例的可控人员查询。 + _LookupDatabase(this.users, {FaceDao? faces}) + : faces = faces ?? _UnusedFaceDao(); + + /// 本轮识别专属的延迟查询。 + final UserDao users; + + /// 当前用例选择的人脸库查询边界。 + final FaceDao faces; + + /// 供离线识别查询人员缓存。 + @override + UserDao get getUserDao => users; + + /// 无样本编号时不会读取样本,仅满足识别入口获取句柄的约定。 + @override + FaceDao get getFaceDao => faces; + + /// 禁止测试访问其他数据库资源。 + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +/// 创建合计 4600 分的固定购物车条目。 +ShopCardEntity _cartItem() => ShopCardEntity( + orderId: -1, + productServiceUniqueCode: 'product-1', + productName: '测试套餐', + productPic: '', + productPrice: 2300, + nature: ProductNature.DEFAULT, + productNumber: 2, + productWidget: 0, + totalPrice: 4600, + isPriceIncreased: false, + ); + +/// 创建弹框重入和识别生命周期测试使用的非空快照。 +CashierCheckoutSnapshot _snapshot() => CashierCheckoutSnapshot.capture( + cartItems: [_cartItem()], + originalAmount: 4600, + discountAmount: 0, + surchargeAmount: 0, + payableAmount: 4600, + ); + +/// 使用真实购物车按钮和响应式锁构造终端尺寸的测试导航环境。 +Future _pumpCheckout( + WidgetTester tester, _CanteenController controller) async { + addTearDown(controller.searchC.dispose); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1920, 1080); + addTearDown(tester.view.reset); + await tester.pumpWidget(ScreenUtilInit( + designSize: const Size(1920, 1080), + builder: (_, __) => GetMaterialApp( + builder: (_, child) => FTheme(data: FThemes.zinc.light, child: child!), + home: Scaffold( + body: SizedBox( + width: 500, + child: Obx(() => CashierCartPanel( + title: '已点菜品', + items: controller.shopCardList.toList(), + user: null, + totalPrice: 4600, + extraPrice: 0, + productCount: 2, + onQuantityChanged: (_, __) {}, + onRemove: (_) {}, + onClear: () {}, + onPay: controller.doPay, + onCancel: () {}, + checkoutInProgress: controller.isCheckoutInProgress.value, + )), + ), + ), + ), + )); + await tester.pumpAndSettle(); +} diff --git a/test/common/print_recovery_test.dart b/test/common/print_recovery_test.dart new file mode 100644 index 0000000..d9a24bb --- /dev/null +++ b/test/common/print_recovery_test.dart @@ -0,0 +1,326 @@ +// ignore_for_file: depend_on_referenced_packages + +import 'dart:async'; + +import 'package:drago_pos_printer/drago_pos_printer.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 用可控制的回报延迟与打印异常验证恢复流程,不连接服务器或实际 USB 设备。 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + Get.testMode = true; + tearDown(Get.reset); + + test('即时回报与补报同时到达时,同一版本只发送一次', () async { + final coordinator = PrintTaskCoordinator(); + final task = _task(PrintStatus.SUCCESS); + final dao = _PrintDao([task]); + final started = Completer(); + final response = Completer(); + var requests = 0; + Future send(PrintHistoryEntity _) { + requests++; + started.complete(); + return response.future; + } + + final first = coordinator.report(task, dao: dao, sendResult: send); + await started.future; + await coordinator.report(_copy(task), dao: dao, sendResult: send); + expect(requests, 1); + response.complete(true); + await first; + final saved = await dao.findByServicePrintId('9001'); + expect(saved!.status, PrintStatus.SYNC); + expect(saved.resultPending, isFalse); + }); + + test('旧失败回报晚到时不能覆盖刚入队的新版本', () async { + final coordinator = PrintTaskCoordinator(); + final task = _task(PrintStatus.ERROR)..attemptCount = 3; + final dao = _PrintDao([task]); + final started = Completer(); + final response = Completer(); + final reporting = coordinator.report(task, dao: dao, sendResult: (_) { + started.complete(); + return response.future; + }); + await started.future; + await coordinator + .mutate(() => dao.edit(_task(PrintStatus.NONE, version: 1))); + response.complete(true); + await reporting; + + final saved = await dao.findByServicePrintId('9001'); + expect(saved!.retryVersion, 1); + expect(saved.status, PrintStatus.NONE); + expect(saved.attemptCount, 0); + expect(saved.syncTime, isNull); + }); + + test('旧版本与新版本回报乱序返回时保留新版本打印成功', () async { + final coordinator = PrintTaskCoordinator(); + final old = _task(PrintStatus.ERROR); + final dao = _PrintDao([old]); + final started = Completer(); + final response = Completer(); + final reporting = coordinator.report(old, dao: dao, sendResult: (_) { + started.complete(); + return response.future; + }); + await started.future; + final latest = _task(PrintStatus.SUCCESS, version: 1); + await coordinator.mutate(() => dao.edit(latest)); + await coordinator.report(latest, dao: dao, sendResult: (_) async => true); + response.complete(true); + await reporting; + + final saved = await dao.findByServicePrintId('9001'); + expect(saved!.retryVersion, 1); + expect(saved.status, PrintStatus.SYNC); + expect(saved.resultPending, isFalse); + }); + + test('定时扫描留下的旧快照不再回报或清除新版本待补报标记', () async { + final coordinator = PrintTaskCoordinator(); + final dao = _PrintDao([_task(PrintStatus.SUCCESS, version: 1)]); + var requests = 0; + await coordinator.report(_task(PrintStatus.ERROR), dao: dao, + sendResult: (_) async { + requests++; + return true; + }); + expect(requests, 0); + expect((await dao.findByServicePrintId('9001'))!.resultPending, isTrue); + }); + + test('服务端拒绝后保留结果且释放回报锁,后续仍可补报', () async { + final coordinator = PrintTaskCoordinator(); + final task = _task(PrintStatus.SUCCESS); + final dao = _PrintDao([task]); + await expectLater( + coordinator.report(task, dao: dao, sendResult: (_) async => false), + throwsStateError, + ); + expect((await dao.findByServicePrintId('9001'))!.resultPending, isTrue); + await coordinator.report(task, dao: dao, sendResult: (_) async => true); + expect((await dao.findByServicePrintId('9001'))!.resultPending, isFalse); + }); + + test('回报成功但本地确认写入失败时保留补报能力', () async { + final coordinator = PrintTaskCoordinator(); + final task = _task(PrintStatus.SUCCESS); + final dao = _PrintDao([task])..failStatus = PrintStatus.SYNC; + await expectLater( + coordinator.report(task, dao: dao, sendResult: (_) async => true), + throwsStateError, + ); + expect((await dao.findByServicePrintId('9001'))!.resultPending, isTrue); + dao.failStatus = null; + await coordinator.report(task, dao: dao, sendResult: (_) async => true); + expect((await dao.findByServicePrintId('9001'))!.status, PrintStatus.SYNC); + }); + + test('第一单关闭连接抛错后,后续订单仍能打印且每单只写一次', () async { + final dao = _PrintDao([ + _task(PrintStatus.NONE), + _task(PrintStatus.NONE, id: '9002'), + ]); + final printer = _Printer()..closeFailures = 1; + _installApp(dao, printer); + await SyncPrintTool.doCheckPrint(); + expect(printer.writes, 2); + expect(printer.closes, 2); + expect((await dao.getAll(PrintStatus.SUCCESS.name)), hasLength(2)); + }); + + test('打开连接失败后也清理残留,下一次尝试恢复打印', () { + final dao = _PrintDao([_task(PrintStatus.NONE)]); + final printer = _Printer()..openFailures = 1; + _installApp(dao, printer); + fakeAsync((clock) { + var completed = false; + SyncPrintTool.doCheckPrint().then((_) => completed = true); + clock.flushMicrotasks(); + expect(printer.closes, 1); + expect(printer.writes, 0); + clock.elapse(const Duration(seconds: 5)); + clock.flushMicrotasks(); + expect(completed, isTrue); + expect(printer.writes, 1); + expect(printer.closes, 2); + }); + }); + + test('USB 已接收后本地保存失败,不进入本轮 USB 自动重试', () async { + final dao = _PrintDao([_task(PrintStatus.NONE)]) + ..failStatus = PrintStatus.SUCCESS; + final printer = _Printer(); + _installApp(dao, printer); + await SyncPrintTool.doCheckPrint(); + expect(printer.writes, 1); + expect(printer.closes, 1); + }); + + test('设备管理器释放异常后仍清空句柄,重复关闭不再访问旧连接', () async { + final printer = PrintManage(); + final connection = _BrokenUsbManager(); + printer.manager = connection; + _installApp(_PrintDao([]), printer); + await printer.close(); + await printer.close(); + expect(printer.manager, isNull); + expect(connection.closes, 1); + }); +} + +/// 创建独立任务快照,版本与状态由用例指定,订单 ID 仅供本地模拟。 +PrintHistoryEntity _task(PrintStatus status, + {String id = '9001', int version = 0}) => + PrintHistoryEntity( + id: int.parse(id), + orderId: '8001', + serviceOrderId: '8001', + servicePrintId: id, + invoiceUrl: 'https://example.test/voucher.png', + retryVersion: version, + resultPending: status != PrintStatus.NONE, + status: status, + createTime: DateTime(2026, 10, 30), + ); + +/// 模拟 Floor 每次查询返回新对象,避免对象共享掩盖整行覆盖问题。 +PrintHistoryEntity _copy(PrintHistoryEntity task) => + PrintHistoryEntity.fromJson(task.toJson()); + +/// 只提供本轮打印任务所需的 DAO,所有写入均保存副本。 +class _PrintDao implements PrintHistoryDao { + /// 使用给定快照初始化本地打印任务。 + _PrintDao(List initial) + : rows = {for (final task in initial) task.servicePrintId: _copy(task)}; + + /// 模拟 servicePrintId 唯一约束的持久化快照。 + final Map rows; + + /// 模拟目标状态落库失败;为空时全部写入成功。 + PrintStatus? failStatus; + + /// 返回单个任务副本,不与调用方共享可变对象。 + @override + Future findByServicePrintId( + String servicePrintId) async { + final row = rows[servicePrintId]; + return row == null ? null : _copy(row); + } + + /// 模拟整行更新及指定状态写入失败,不吞掉异常。 + @override + Future edit(PrintHistoryEntity entity) async { + if (entity.status == failStatus) throw StateError('模拟持久化失败'); + rows[entity.servicePrintId] = _copy(entity); + } + + /// 返回指定状态的副本,保持真实打印队列每轮查库的行为。 + @override + Future> getAll(String status) async => rows.values + .where((task) => task.status.name == status) + .map(_copy) + .toList(); + + /// 禁止测试调用未建模的数据库能力。 + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +/// 将真实 AppControl 与模拟打印设备、DAO 连接,关闭网络以避免硬件用例调用 HTTP。 +void _installApp(_PrintDao dao, PrintManage printer) { + Get.put(_App()) + ..dataBase = _Database(dao) + ..printManage = printer + ..isNetwork.value = false; +} + +/// 测试不启动应用服务,销毁时也不调用无关原生插件。 +class _App extends AppControl { + /// 设备释放由用例显式触发,避免 tearDown 引入额外关闭次数。 + @override + // ignore: must_call_super + void onClose() {} +} + +/// 只暴露打印 DAO,不创建真实数据库或更改表结构。 +class _Database implements AppDatabase { + /// 保存本用例的任务存储。 + _Database(this.prints); + + /// 本用例的任务存储实例。 + final PrintHistoryDao prints; + + /// 返回打印队列使用的 DAO。 + @override + PrintHistoryDao get getPrintHistoryDao => prints; + + /// 其他数据库能力被调用时立即失败。 + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +/// 记录调用次数并模拟短暂掉线与释放异常,不发送打印数据。 +class _Printer extends PrintManage { + /// 剩余的连接失败次数。 + int openFailures = 0; + + /// 剩余的释放异常次数。 + int closeFailures = 0; + + /// 实际请求写入的次数,用于检查是否多打。 + int writes = 0; + + /// 调用释放的次数,包括打开失败后的清理。 + int closes = 0; + + /// 模拟连接在若干次失败后恢复。 + @override + Future open() async { + if (openFailures <= 0) return true; + openFailures--; + lastError = '模拟 USB 断开'; + return false; + } + + /// 模拟 USB 完整接受一张小票的数据。 + @override + Future printImg(String img) async { + writes++; + return true; + } + + /// 模拟拔线后的连接释放异常。 + @override + Future close() async { + closes++; + if (closeFailures <= 0) return; + closeFailures--; + throw StateError('模拟 USB 释放失败'); + } +} + +/// 不创建上游 FFI 资源,仅模拟 USB 管理器的释放失败。 +class _BrokenUsbManager implements USBPrinterManager { + /// 已尝试释放次数。 + int closes = 0; + + /// 每次释放均抛错,以验证上层仍会清空句柄。 + @override + Future disconnect({Duration? timeout}) async { + closes++; + throw StateError('USB 已断开'); + } + + /// 测试禁止执行真实连接或写入。 + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +}