From 531815517acccd3c61db5f833631c16a2eefd642 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Fri, 23 Oct 2026 15:53:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E4=B8=8B=E5=8D=95?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../flutter/FlutterFaceDiscernPlugin.java | 3 + .../facediscern/flutter/FlutterTool.java | 9 + .../tool/face/help/FaceHelper.java | 38 +-- .../tool/face/help/FaceNoticeDispatcher.java | 41 +++ .../face/model/RecognizeConfiguration.java | 2 +- .../tool/face/filter/FaceAreaRatioTest.java | 11 +- .../face/help/FaceNoticeDispatcherTest.java | 79 ++++++ .../RecognizeConfigurationStoreTest.java | 14 +- docs/face-payment-readiness.md | 58 ++++ docs/payment-latency-diagnostics.md | 64 +++++ .../models/plugs/face_discern_models.dart | 4 +- .../face_recognition_session_service.dart | 13 +- lib/common/utils/app/app_face_tool.dart | 106 ++++++- lib/common/utils/app/control/app_control.dart | 4 +- .../utils/app/face_readiness_cache.dart | 84 ++++++ lib/common/utils/manage/http_manage.dart | 116 ++++++++ .../utils/order/payment_timing_trace.dart | 233 ++++++++++++++++ .../utils/plugs/face_discern_plugs.dart | 183 +++++++------ lib/common/utils/sync/sync_face_tool.dart | 24 +- .../widgets/face/face_recognition_panel.dart | 15 +- .../face/face_recognition_preview_dialog.dart | 2 +- .../widgets/order/mixin/order_mixin.dart | 97 ++++--- .../widgets/order/select_pay_type_dialog.dart | 17 +- lib/pages/canteen_product/controller.dart | 52 +++- lib/pages/setting/face_manage_controller.dart | 13 +- .../setting/face_setting_controller.dart | 2 +- .../setting/widgets/setting_face_plugin.dart | 2 +- pubspec.yaml | 2 +- test/face_readiness_cache_test.dart | 171 ++++++++++++ test/face_recognition_configuration_test.dart | 12 +- test/face_recognition_panel_test.dart | 258 ++++++++++++++++++ test/face_sync_pipeline_test.dart | 74 ++++- test/payment_http_timing_test.dart | 232 ++++++++++++++++ test/payment_timing_trace_test.dart | 222 +++++++++++++++ 35 files changed, 2039 insertions(+), 220 deletions(-) create mode 100644 android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceNoticeDispatcher.java create mode 100644 android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/help/FaceNoticeDispatcherTest.java create mode 100644 docs/face-payment-readiness.md create mode 100644 docs/payment-latency-diagnostics.md create mode 100644 lib/common/utils/app/face_readiness_cache.dart create mode 100644 lib/common/utils/order/payment_timing_trace.dart create mode 100644 test/face_readiness_cache_test.dart create mode 100644 test/face_recognition_panel_test.dart create mode 100644 test/payment_http_timing_test.dart create mode 100644 test/payment_timing_trace_test.dart diff --git a/README.md b/README.md index 607b83f..1543ad6 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 2.1.3 --build-number 213 +flutter build apk --build-name 2.1.5 --build-number 215 --build-name为版本号(展示) --build-number为版本号(内部比较) ``` diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java index 5aaa86c..3058811 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java @@ -7,6 +7,7 @@ import android.content.IntentFilter; import android.os.Build; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import androidx.annotation.NonNull; @@ -388,7 +389,9 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me result.success(busyStatus); return; } + long enqueuedAt = SystemClock.elapsedRealtime(); FACE_EXECUTOR.execute(() -> { + Log.i("FaceStatusPerf", "queueMs=" + (SystemClock.elapsedRealtime() - enqueuedAt)); try { Map status = FlutterTool.getFaceStatus(context, appId, activeKey, sdkKey); MAIN_HANDLER.post(() -> result.success(status)); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java index ce2d4f6..5dccc4f 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java @@ -8,6 +8,7 @@ import static io.flutter.plugins.facediscern.database.DataBaseUtils.getFaceProfi import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.os.SystemClock; import io.flutter.Log; @@ -88,6 +89,7 @@ public class FlutterTool { } static Map getFaceStatus(Context context, String appId, String activeKey, String sdkKey) { + long startedAt = SystemClock.elapsedRealtime(); updateConfig(appId, activeKey, sdkKey); Map status = new HashMap<>(); ActiveFileInfo activeFileInfo = new ActiveFileInfo(); @@ -96,10 +98,12 @@ public class FlutterTool { FaceServer faceServer = FaceServer.getInstance(); if (activated) faceServer.init(context); + long authInitCompletedAt = SystemClock.elapsedRealtime(); // 状态检查只需要数量与 sampleId 集合,走不含 faceModel 大字段的轻量查询, // 避免整库特征 JSON 反序列化把调用线程(主线程)阻塞数秒。 // 查询已过滤空特征行:databaseCount 与一致性集合都只统计可注册样本。 List databaseFaces = getFaceProfiles(context, null); + long sqliteScanCompletedAt = SystemClock.elapsedRealtime(); int databaseCount = databaseFaces.size(); int memoryCount = activated && faceServer.isEngineReady() ? Math.max(faceServer.getTotalFaceCount(), 0) : 0; boolean registrationInProgress = REGISTRATION_IN_PROGRESS.get(); @@ -122,8 +126,13 @@ public class FlutterTool { status.put("memoryCount", memoryCount); status.put("registrationInProgress", registrationInProgress); status.put("libraryConsistent", !registrationInProgress && databaseSampleIds.equals(memorySampleIds)); + long sdkCompareCompletedAt = SystemClock.elapsedRealtime(); status.put("registrationProgress", new HashMap<>(latestRegistrationProgress)); status.put("lastRegistrationResult", latestRegistrationResult == null ? null : new HashMap<>(latestRegistrationResult)); + Log.i("FaceStatusPerf", "authInitMs=" + (authInitCompletedAt - startedAt) + + ";sqliteScanMs=" + (sqliteScanCompletedAt - authInitCompletedAt) + + ";sdkCompareMs=" + (sdkCompareCompletedAt - sqliteScanCompletedAt) + + ";totalMs=" + (SystemClock.elapsedRealtime() - startedAt)); return status; } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java index 9f7c361..83c10a2 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java @@ -61,6 +61,7 @@ import io.reactivex.Observer; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; /** @@ -73,7 +74,7 @@ public class FaceHelper implements FaceListener { /** * 识别结果的回调 */ - private RecognizeCallback recognizeCallback; + private volatile RecognizeCallback recognizeCallback; private volatile long sessionGeneration; @@ -179,7 +180,8 @@ public class FaceHelper implements FaceListener { /** 最近一帧达到相机面积占比的人脸;异步结果也必须仍在此集合内。 */ private volatile Set areaEligibleTrackIds = Collections.emptySet(); - private Disposable timerDisposable; + private final FaceNoticeDispatcher noticeDispatcher = new FaceNoticeDispatcher( + Schedulers.computation(), AndroidSchedulers.mainThread()); private FaceHelper(Builder builder) { onlyDetectLiveness = builder.onlyDetectLiveness; @@ -309,6 +311,7 @@ public class FaceHelper implements FaceListener { public synchronized void stop() { sessionGeneration++; + noticeDispatcher.cancel(); clearTrackingState(); } @@ -343,7 +346,7 @@ public class FaceHelper implements FaceListener { recognizeCallback = callback; if (getFeatureDelayedDisposables != null) getFeatureDelayedDisposables.clear(); if (delayFaceTaskCompositeDisposable != null) delayFaceTaskCompositeDisposable.clear(); - if (timerDisposable != null && !timerDisposable.isDisposed()) timerDisposable.dispose(); + noticeDispatcher.cancel(); clearTrackingState(); } @@ -799,19 +802,19 @@ public class FaceHelper implements FaceListener { } private void noticeCurrentStatus(String notice) { - if (recognizeCallback != null) { - recognizeCallback.onNoticeChanged(notice); - } - if (timerDisposable != null && !timerDisposable.isDisposed()) { - timerDisposable.dispose(); - } - timerDisposable = Observable.timer(1500, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(aLong -> { - if (recognizeCallback != null) { - recognizeCallback.onNoticeChanged(""); - } - }); + noticeCurrentStatus(notice, false); + } + + private void noticeCurrentStatus(String notice, boolean success) { + final RecognizeCallback callback = recognizeCallback; + final long generation = sessionGeneration; + if (callback != null) callback.onNoticeChanged(notice); + noticeDispatcher.schedule(success, () -> { + // 主线程只消费原代次、原回调的结果;不持有 FaceHelper 锁再进入会话管理器。 + if (generation == sessionGeneration && callback != null && callback == recognizeCallback) { + callback.onNoticeChanged(""); + } + }); } private void searchFace(final FaceFeature faceFeature, final Integer trackId, @@ -842,8 +845,9 @@ public class FaceHelper implements FaceListener { } } setName(trackId, msg); - noticeCurrentStatus(msg); changeRecognizeStatus(trackId, RequestFeatureStatus.SUCCEED); + // 先写成功状态,再零延迟投递主线程,保留旧页面与会话的统一结果通知链。 + noticeCurrentStatus(msg, true); } else { noticeCurrentStatus("未通过:NOT_REGISTERED"); retryRecognizeDelayed(trackId); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceNoticeDispatcher.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceNoticeDispatcher.java new file mode 100644 index 0000000..50fccc7 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceNoticeDispatcher.java @@ -0,0 +1,41 @@ +package io.flutter.plugins.facediscern.tool.face.help; + +import java.util.concurrent.TimeUnit; + +import io.reactivex.Observable; +import io.reactivex.Scheduler; +import io.reactivex.disposables.Disposable; + +/** 只调度提示清空:成功立即投递,失败提示仍保留 1.5 秒。 */ +final class FaceNoticeDispatcher { + private final Scheduler timerScheduler; + private final Scheduler callbackScheduler; + private Disposable pending; + private volatile long revision; + + FaceNoticeDispatcher(Scheduler timerScheduler, Scheduler callbackScheduler) { + this.timerScheduler = timerScheduler; + this.callbackScheduler = callbackScheduler; + } + + synchronized void schedule(boolean success, Runnable clearNotice) { + cancel(); + long expectedRevision = revision; + Observable clearSignal = success + ? Observable.just(0L) + : Observable.timer(1500L, TimeUnit.MILLISECONDS, timerScheduler); + pending = clearSignal + .observeOn(callbackScheduler) + .subscribe(ignored -> { + if (revision == expectedRevision) clearNotice.run(); + }); + } + + synchronized void cancel() { + revision++; + if (pending != null) { + pending.dispose(); + pending = null; + } + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java index 91c9f5e..e75f80d 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java @@ -21,7 +21,7 @@ public class RecognizeConfiguration { public static final boolean DEFAULT_ENABLE_IMAGE_QUALITY = true; public static final boolean DEFAULT_KEEP_MAX_FACE = true; /** 原始 RGB 相机画面中允许开始识别的最小人脸面积百分比。 */ - public static final double DEFAULT_MIN_FACE_AREA_PERCENT = 5.0; + public static final double DEFAULT_MIN_FACE_AREA_PERCENT = 3.0; /** * 产生特征提取失败示语的特征提取次数(小于该值不提示) diff --git a/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/filter/FaceAreaRatioTest.java b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/filter/FaceAreaRatioTest.java index 3c46518..aa4901e 100644 --- a/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/filter/FaceAreaRatioTest.java +++ b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/filter/FaceAreaRatioTest.java @@ -1,15 +1,18 @@ package io.flutter.plugins.facediscern.tool.face.filter; +import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration; + import org.junit.Test; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class FaceAreaRatioTest { - @Test public void defaultFivePercentIncludesBoundaryButRejectsSmallerFaces() { - assertFalse(FaceAreaRatio.meetsMinimum(0, 0, 199, 250, 1000, 1000, 5.0)); - assertTrue(FaceAreaRatio.meetsMinimum(0, 0, 200, 250, 1000, 1000, 5.0)); - assertTrue(FaceAreaRatio.meetsMinimum(0, 0, 201, 250, 1000, 1000, 5.0)); + @Test public void defaultThreePercentIncludesBoundaryButRejectsSmallerFaces() { + double defaultPercent = RecognizeConfiguration.DEFAULT_MIN_FACE_AREA_PERCENT; + assertFalse(FaceAreaRatio.meetsMinimum(0, 0, 119, 250, 1000, 1000, defaultPercent)); + assertTrue(FaceAreaRatio.meetsMinimum(0, 0, 120, 250, 1000, 1000, defaultPercent)); + assertTrue(FaceAreaRatio.meetsMinimum(0, 0, 121, 250, 1000, 1000, defaultPercent)); } @Test public void usesAreaOfFullRawFrameAcrossAspectRatiosAndRotations() { diff --git a/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/help/FaceNoticeDispatcherTest.java b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/help/FaceNoticeDispatcherTest.java new file mode 100644 index 0000000..3f0979f --- /dev/null +++ b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/help/FaceNoticeDispatcherTest.java @@ -0,0 +1,79 @@ +package io.flutter.plugins.facediscern.tool.face.help; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import io.reactivex.schedulers.TestScheduler; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class FaceNoticeDispatcherTest { + @Test public void successNeedsNoTimeAdvanceButStillUsesCallbackScheduler() { + TestScheduler timer = new TestScheduler(); + TestScheduler callback = new TestScheduler(); + FaceNoticeDispatcher dispatcher = new FaceNoticeDispatcher(timer, callback); + List delivered = new ArrayList<>(); + + dispatcher.schedule(true, () -> delivered.add("success")); + assertTrue(delivered.isEmpty()); + callback.triggerActions(); + assertEquals(Arrays.asList("success"), delivered); + assertEquals(0L, timer.now(TimeUnit.MILLISECONDS)); + } + + @Test public void failureNoticeRetainsItsFullDisplayInterval() { + TestScheduler timer = new TestScheduler(); + TestScheduler callback = new TestScheduler(); + FaceNoticeDispatcher dispatcher = new FaceNoticeDispatcher(timer, callback); + List delivered = new ArrayList<>(); + + dispatcher.schedule(false, () -> delivered.add("clear failure")); + timer.advanceTimeBy(1499, TimeUnit.MILLISECONDS); + callback.triggerActions(); + assertTrue(delivered.isEmpty()); + timer.advanceTimeBy(1, TimeUnit.MILLISECONDS); + assertTrue(delivered.isEmpty()); + callback.triggerActions(); + assertEquals(Arrays.asList("clear failure"), delivered); + } + + @Test public void successReplacesAnOlderClearAlreadyQueuedForCallback() { + TestScheduler timer = new TestScheduler(); + TestScheduler callback = new TestScheduler(); + FaceNoticeDispatcher dispatcher = new FaceNoticeDispatcher(timer, callback); + List delivered = new ArrayList<>(); + + dispatcher.schedule(false, () -> delivered.add("old failure")); + timer.advanceTimeBy(1500, TimeUnit.MILLISECONDS); + dispatcher.schedule(true, () -> delivered.add("success")); + timer.triggerActions(); + callback.triggerActions(); + assertEquals(Arrays.asList("success"), delivered); + timer.advanceTimeBy(10, TimeUnit.SECONDS); + callback.triggerActions(); + assertEquals(Arrays.asList("success"), delivered); + } + + @Test public void stoppingOrChangingSessionDiscardsAPendingSuccessCallback() { + TestScheduler timer = new TestScheduler(); + TestScheduler callback = new TestScheduler(); + FaceNoticeDispatcher dispatcher = new FaceNoticeDispatcher(timer, callback); + List delivered = new ArrayList<>(); + + dispatcher.schedule(true, () -> delivered.add("old session")); + timer.triggerActions(); + dispatcher.cancel(); + callback.triggerActions(); + assertTrue(delivered.isEmpty()); + + dispatcher.schedule(true, () -> delivered.add("new session")); + timer.triggerActions(); + callback.triggerActions(); + assertEquals(Arrays.asList("new session"), delivered); + } +} diff --git a/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStoreTest.java b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStoreTest.java index 6763b79..03a6f3c 100644 --- a/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStoreTest.java +++ b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStoreTest.java @@ -14,15 +14,15 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.fail; public class RecognizeConfigurationStoreTest { - @Test public void existingSettingsWithoutNewKeyUseFivePercent() { + @Test public void existingSettingsWithoutNewKeyUseThreePercent() { Map values = new HashMap<>(); values.put("keepMaxFace", false); values.put("similarThreshold", 0.91F); RecognizeConfiguration configuration = RecognizeConfigurationStore.load(preferences(values)); - assertEquals(5.0, configuration.getMinFaceAreaPercent(), 0.0); + assertEquals(3.0, configuration.getMinFaceAreaPercent(), 0.0); assertFalse(configuration.isKeepMaxFace()); assertEquals(0.91F, configuration.getSimilarThreshold(), 0.0F); - assertEquals(5.0, (Double) RecognizeConfigurationStore.toMap(configuration).get("minFaceAreaPercent"), 0.0); + assertEquals(3.0, (Double) RecognizeConfigurationStore.toMap(configuration).get("minFaceAreaPercent"), 0.0); } @Test public void saveLoadPartialUpdateAndResetPreserveContract() { @@ -36,7 +36,7 @@ public class RecognizeConfigurationStoreTest { } assertEquals(100.0, RecognizeConfigurationStore.save(preferences, Collections.singletonMap("enableLiveness", true)).getMinFaceAreaPercent(), 0.0); - assertEquals(5.0, RecognizeConfigurationStore.reset(preferences).getMinFaceAreaPercent(), 0.0); + assertEquals(3.0, RecognizeConfigurationStore.reset(preferences).getMinFaceAreaPercent(), 0.0); assertEquals(0, values.size()); } @@ -55,11 +55,11 @@ public class RecognizeConfigurationStoreTest { } } - @Test public void malformedPersistedValueFallsBackToFivePercent() { + @Test public void malformedPersistedValueFallsBackToThreePercent() { for (Object invalid : new Object[]{Float.NaN, Float.POSITIVE_INFINITY, -1F, 0F, 101F, "5", false}) { Map values = new HashMap<>(); values.put("minFaceAreaPercent", invalid); - assertEquals(5.0, RecognizeConfigurationStore.load(preferences(values)).getMinFaceAreaPercent(), 0.0); + assertEquals(3.0, RecognizeConfigurationStore.load(preferences(values)).getMinFaceAreaPercent(), 0.0); } } @@ -67,7 +67,7 @@ public class RecognizeConfigurationStoreTest { RecognizeConfiguration original = new RecognizeConfiguration.Builder().minFaceAreaPercent(17).build(); assertEquals(17.0, original.withImageQualityEnabled(false).getMinFaceAreaPercent(), 0.0); assertFalse(original.withImageQualityEnabled(false).isEnableImageQuality()); - assertEquals(5.0, new RecognizeConfiguration.Builder().build().getMinFaceAreaPercent(), 0.0); + assertEquals(3.0, new RecognizeConfiguration.Builder().build().getMinFaceAreaPercent(), 0.0); } /** Small in-memory implementation of the Android interface, without an Android runtime. */ diff --git a/docs/face-payment-readiness.md b/docs/face-payment-readiness.md new file mode 100644 index 0000000..711315a --- /dev/null +++ b/docs/face-payment-readiness.md @@ -0,0 +1,58 @@ +# 人脸支付前置检查与启动准备 + +## 原先的提示在做什么 + +收银弹框 `_dispatchFace` 显示“正在检查人脸库与摄像头状态”后,依次执行: + +1. Floor 查询本地人脸表总行数,空库进入就地注册引导。 +2. 读取本地设备人脸参数和 SDK 授权文件,必要时初始化 FaceServer。 +3. 原生 SQLite 扫描所有有效样本编号,再获取 SDK 内存库中的样本编号,比较两个集合是否一致。 +4. 申请相机权限,再创建业务识别会话。 + +这段检查不下载人脸,也不在线重新激活 SDK。提示期间尚未实际打开摄像头;真正的识别引擎和 Camera 启动由后续原生会话执行。RGB/IR 色度探测只在设置页枚举摄像头时运行。 + +虽然原生集合查询只返回 sampleId,SQL 中判断有效特征的 `TRIM(COALESCE(faceModel, ''))` 仍需访问所有记录的特征 JSON。因此磁盘读取、页缓存和同时运行的人脸任务会影响耗时。状态查询还与其他人脸操作共用单线程执行器,可能等待队列。现场 1 秒和 3–4 秒的具体占比需要分段日志确认。 + +## 当前流程 + +- Load 完成激活、整库装载和残缺行清理后,进行一次完整核对并保存当前进程的健康准备结果,同时提前申请相机权限并检查是否枚举到摄像头。 +- 正常支付复用准备结果,不再重复执行 Floor 计数和原生整库集合比较。 +- 同步、删除、重建、SDK 激活会使结果失效。操作覆盖数据库、SDK 和失败补偿的整个边界,最外层收尾后统一核对;批量删除也只在整批结束时核对。 +- 配置变化和原生启动异常使结果失效。占线、异常或不健康结果不会长期缓存;旧查询不能覆盖新一代结果。 +- 设置页诊断仍然实时读取完整状态。 +- 每次会话继续读取相机权限和处理真实开相机失败;硬件状态不永久缓存。 + +Load 的摄像头检查是权限与设备枚举,不预开 Camera。原生原有的 Camera/识别引擎 20 分钟热保持继续使用;首次识别、长时间空闲释放、相机配置切换等情况仍需冷启动。 + +## 识别成功后立即继续 + +原先虹软比对通过后,要等 Java 的 1.5 秒提示清空定时器才通知会话;Flutter 收到结果后又固定展示成功画面 1 秒,合计约 2.5 秒。 + +现在成功结果直接投递主线程,不再等待提示展示。原生仍先保存成功帧现场图片,再向 Flutter 回传结果;Flutter 收到结果后立即结束当前原生会话,完成后再通知收银业务读取人员与钱包信息。 + +结果锁、会话代次校验、过期回调拦截和关闭界面后的回调保护均保留。失败提示保留时间与失败重试间隔不变。正常成功流程不再使用固定展示延迟,但现场图片处理、会话收尾及后续业务接口仍有实际耗时。 + +## 排查日志 + +`FacePaymentPerf`: + +- `readinessMs`、`cacheHit`:支付准备耗时和是否复用结果。 +- `cameraPermissionMs`:当前会话读取/申请相机权限的耗时。 + +`FaceStatusPerf`: + +- `queueMs`:完整核对在原生执行器中的排队时间。 +- `authInitMs`:授权文件读取和 FaceServer 初始化。 +- `sqliteScanMs`:有效样本编号扫描。 +- `sdkCompareMs`:SDK 数量、内存编号集合读取和集合比较。 +- `totalMs`:完整原生核对耗时,不包含队列等待。 + +`FaceSessionManager` 原有日志继续记录识别引擎初始化及 Camera.open 耗时。上述新增日志只包含计时、计数或状态,不记录激活密钥和人脸特征。 + +## 验证边界 + +2026-10-23:69 项 Flutter 测试通过,包含缓存并发/失效、数量不变的样本替换、同步失败回滚,以及成功后不推进时间即可继续、必须等待会话结束、取消/销毁保护和结果幂等。17 项 Android JVM 单元测试通过,覆盖成功立即调度、失败提示保持 1.5 秒、旧通知替换与切换会话后的通知取消。静态分析没有 error,仍有项目已有的 warning/info;本次修改涉及的 Dart 文件单独分析无问题。 + +包含本次成功延迟移除的 Debug 和 Release 均编译通过,最新 Release 安装包为 `build/app/outputs/flutter-apk/app-release.apk`。 + +尚未把本次改动安装到现场设备,未声称真机支付耗时或真人活体识别已验证。 diff --git a/docs/payment-latency-diagnostics.md b/docs/payment-latency-diagnostics.md new file mode 100644 index 0000000..82f34f7 --- /dev/null +++ b/docs/payment-latency-diagnostics.md @@ -0,0 +1,64 @@ +# 消费机“支付中”耗时诊断 + +食堂购买页面从发起“支付中”到请求关闭遮罩,为每次付款保存一条 +`|支付耗时|` warning 摘要,其中 `event:summary` 表示本次付款汇总。 +无需开启全量 INFO 日志,也不需要网关访问日志。安装包含本次改动的版本后才会产生这些记录。 + +## 现场取日志 + +1. 正常使用消费机;出现等待时记下大概时间。 +2. 在消费机设置中导出当天日志,保留完整的付款前后记录。 +3. 按 `trace` 聚合同一笔付款;摘要中出现 `androidOrderCode` 后,可用该号匹配 hard 模块订单 SQL。 + +诊断日志只增加关联编号、阶段、耗时、状态码和异常类型,不增加人员资料、令牌、付款码或图片内容。 + +## 付款阶段 + +| 字段 | 覆盖范围 | +| --- | --- | +| `discounts` | 付款时重算优惠,包括优惠 HTTP、本地读取及计算 | +| `local_order_update` | 本地订单、购物车更新 | +| `create_order` | 下单整体,包括下面的准备、请求和收尾阶段 | +| `order_prepare` | 本地配置、订单校验及读取 | +| `evidence_bind` / `evidence_version` | 本地人脸证据任务绑定和版本读取 | +| `order_http` | 下单 API 完整 Future 的等待 | +| `order_reconcile` | 请求异常后的只读订单对账 | +| `local_payment_commit` | 支付结果写回本地订单 | +| `evidence_ready` / `evidence_cancel` | 本地证据任务状态更新 | + +`stagesInclusiveMs` 的单位是毫秒。父阶段包含子阶段,不能直接相加。 +`totalMs` 从开始付款追踪到调用关闭遮罩并结束追踪,不包含遮罩动画和最终帧绘制完成时间。 + +阶段等待满 1 秒会输出一次 `event:pending`;慢阶段结束输出 `event:stage`。 +每笔付款结束均输出一条摘要,异常只记录类型。 + +`eventLoopLagMs` 是付款期间每 250ms 定时器的最大超期调度时间。 +它很大时说明 Dart 事件循环未及时获得调度,可能是同步计算或进程/设备调度停顿; +它很小时不能证明 Android 主线程、GPU 或所有原生插件都正常。 + +## HTTP 阶段 + +用户查询、优惠查询、下单及对账接口超过 1 秒,或返回失败时,额外输出 +`|支付请求耗时|` warning,使用同一 `trace`(付款范围外的用户查询显示 `-`)。 + +| 字段 | 含义 | +| --- | --- | +| `settingsMs` | 拦截器读取本地鉴权配置的耗时 | +| `prepareMs` | 拦截器开始至交给 HTTP 适配器前的耗时,包含 settingsMs | +| `waitMs` | 发出请求后至响应/错误拦截器进入,包含连接、传输、网关、服务、解码及 Dart 调度 | +| `handlerMs` | 响应/错误拦截器内处理耗时,包括发生时的自动登录 | +| `totalMs` | 从请求拦截器开始到统一响应处理结束 | + +HTTP 摘要只记录路径,不记录查询参数、请求体、响应正文、令牌或完整 URL。 +`waitMs` 不能解释为纯后端耗时,也不能独立拆开网络和服务端。 + +## 根据下一次日志判断 + +- 本地阶段数秒、HTTP 很快:优先排查本地数据库、证据任务及其排队。 +- HTTP 等待数秒、`eventLoopLagMs` 很小:重点对齐同一订单的服务器记录,排查完整远程请求链路。 +- `settingsMs` 或 `prepareMs` 数秒:检查消费机本地设置读取及发出前处理。 +- `handlerMs` 数秒:检查响应处理、登录恢复等收尾工作。 +- `eventLoopLagMs` 数秒:重点排查 Dart 同步阻塞和设备/进程调度,同时结合系统日志确认原因。 + +本次诊断不调整扣款、重试、超时、优惠计算或证据上传顺序。支付遮罩改为在 finally +中关闭,以覆盖优惠查询或本地准备步骤意外抛错的分支。 diff --git a/lib/common/models/plugs/face_discern_models.dart b/lib/common/models/plugs/face_discern_models.dart index e2045c6..0bbf85a 100644 --- a/lib/common/models/plugs/face_discern_models.dart +++ b/lib/common/models/plugs/face_discern_models.dart @@ -90,7 +90,7 @@ class FaceRecognitionConfiguration { required this.recognizeFailedRetryInterval, required this.livenessFailedRetryInterval, required this.livenessType, - this.minFaceAreaPercent = 5.0, + this.minFaceAreaPercent = 3.0, }); const FaceRecognitionConfiguration.defaults() @@ -103,7 +103,7 @@ class FaceRecognitionConfiguration { libraryFaceQualityThreshold = 0.60, keepMaxFace = true, maxDetectFaces = 1, - minFaceAreaPercent = 5.0, + minFaceAreaPercent = 3.0, extractRetryCount = 3, livenessRetryCount = 3, recognizeFailedRetryInterval = 0, diff --git a/lib/common/services/face_recognition_session_service.dart b/lib/common/services/face_recognition_session_service.dart index 05f8c5f..9df534c 100644 --- a/lib/common/services/face_recognition_session_service.dart +++ b/lib/common/services/face_recognition_session_service.dart @@ -90,7 +90,15 @@ class FaceRecognitionSessionService extends GetxService { message: '已有进行中的人脸识别,请勿重复操作', ); } - final cameraPermission = await Permission.camera.request(); + final permissionWatch = Stopwatch()..start(); + final currentPermission = await Permission.camera.status; + final cameraPermission = currentPermission.isGranted + ? currentPermission + : await Permission.camera.request(); + logger.i( + '|FacePaymentPerf|cameraPermissionMs:${permissionWatch.elapsedMilliseconds}|' + 'granted:${cameraPermission.isGranted}|', + ); if (!cameraPermission.isGranted) { throw FaceRecognitionFailure( code: 'CAMERA_PERMISSION_DENIED', @@ -175,6 +183,7 @@ class FaceRecognitionSessionService extends GetxService { } } on PlatformException catch (error) { if (_activeSessionId != sessionId) return; + AppFaceTool.invalidatePaymentReadiness(); _nativeStarted = true; _startFailureEmitted = true; logger.e( @@ -191,6 +200,7 @@ class FaceRecognitionSessionService extends GetxService { )); } catch (error, stack) { if (_activeSessionId != sessionId) return; + AppFaceTool.invalidatePaymentReadiness(); _nativeStarted = true; _startFailureEmitted = true; logger.e( @@ -334,6 +344,7 @@ class FaceRecognitionSessionService extends GetxService { } void _handleNativeError(Object error) { + AppFaceTool.invalidatePaymentReadiness(); const failure = FaceRecognitionFailure( code: 'SESSION_STREAM_ERROR', message: '人脸识别服务连接异常,请重试', diff --git a/lib/common/utils/app/app_face_tool.dart b/lib/common/utils/app/app_face_tool.dart index b5d430b..098adc1 100644 --- a/lib/common/utils/app/app_face_tool.dart +++ b/lib/common/utils/app/app_face_tool.dart @@ -3,6 +3,18 @@ import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +import 'face_readiness_cache.dart'; + +/// 当前进程中已核对完成的人脸支付准备结果,不缓存相机权限或硬件连接。 +class FacePaymentReadiness { + const FacePaymentReadiness({ + required this.databaseFaceCount, + required this.status, + }); + + final int databaseFaceCount; + final FacePluginStatus? status; +} /// 统一封装人脸 SDK 激活、状态查询、本地人脸库注册和删除操作。 /// @@ -11,6 +23,70 @@ abstract class AppFaceTool { /// 人脸工具日志标签,不记录激活参数和人脸特征等敏感内容。 static const String _tag = "app 人脸工具"; + static final _paymentReadiness = FaceReadinessCache(); + // 仅在内存中比较配置,不输出激活参数,也不把就绪结果持久化到下一次启动。 + static (bool, String, String, String)? _preparedConfiguration; + + /// 正常支付复用 Load 或最近一次人脸变更后的完整核对结果。 + /// 设置页诊断仍使用实时 [getStatus],异常和占线结果不会缓存。 + static Future getPaymentReadiness({ + bool forceRefresh = false, + }) async { + final watch = Stopwatch()..start(); + final setting = await AppSettingTool.getSysSetting(); + final configuration = ( + AppRuntimeTool.useManualHardwareInput, + (setting.faceAppId ?? '').trim(), + (setting.faceSdkKey ?? '').trim(), + (setting.faceActiveKey ?? '').trim(), + ); + if (_preparedConfiguration != configuration || forceRefresh) { + _preparedConfiguration = configuration; + _paymentReadiness.invalidate(); + } + final cacheHit = _paymentReadiness.value != null; + final result = await _paymentReadiness.read( + () async => FacePaymentReadiness( + databaseFaceCount: + await Get.find().dataBase.getFaceDao.findCount('-1') ?? + 0, + status: await getStatus(), + ), + canCache: (result) => + result.status?.ready == true && + (result.databaseFaceCount == 0 || result.status!.memoryCount > 0), + ); + logger.i( + '|FacePaymentPerf|readinessMs:${watch.elapsedMilliseconds}|' + 'cacheHit:$cacheHit|ready:${result.status?.ready == true}|', + ); + return result; + } + + /// 原生异常时不再信任上一份准备结果,下一次请求重新核对。 + static void invalidatePaymentReadiness() => _paymentReadiness.invalidate(); + + /// 覆盖 SQLite、SDK 和补偿回滚的整个变更边界;嵌套操作只在最外层收尾核对。 + /// 无论操作成功还是失败,都不能把两库更新到一半的状态留给下一位顾客。 + static Future runLibraryMutation(Future Function() operation) async { + _paymentReadiness.beginMutation(); + try { + return await operation(); + } finally { + _paymentReadiness.endMutation(); + if (_paymentReadiness.mutationDepth == 0 && + !AppRuntimeTool.useManualHardwareInput && + Get.isRegistered()) { + try { + await getPaymentReadiness(); + } catch (error) { + // 核对失败不覆盖原操作的返回值或异常;不缓存,支付仍可显式重试。 + logger.w('|$_tag|人脸变更后准备状态刷新失败:${error.runtimeType}|'); + } + } + } + } + /// 打开原生人脸识别页面;手动硬件模式或插件拒绝打开时返回 false。 static Future jumpFacePage() async { if (AppRuntimeTool.useManualHardwareInput) return false; @@ -43,6 +119,24 @@ abstract class AppFaceTool { /// /// 任意插件、权限或样本异常都转换为警告结果,调用方可以继续完成终端启动。 static Future prepareForStartup() async { + final result = await runLibraryMutation(_prepareForStartup); + if (result.status != FaceStartupPreparationStatus.success || + _paymentReadiness.value?.status?.ready == true) { + return result; + } + return FaceStartupPreparationResult( + status: FaceStartupPreparationStatus.warning, + message: '${result.message};人脸库状态尚未就绪,可在设置中检查并重新注册', + total: result.total, + successCount: result.successCount, + failedCount: result.failedCount, + failedSampleIds: result.failedSampleIds, + missingParameters: result.missingParameters, + activationCode: result.activationCode, + ); + } + + static Future _prepareForStartup() async { final startupWatch = Stopwatch()..start(); if (AppRuntimeTool.useManualHardwareInput) { return const FaceStartupPreparationResult( @@ -116,12 +210,20 @@ abstract class AppFaceTool { } logger.i( '|FaceStartupPerf|startupMs:${startupWatch.elapsedMilliseconds}|registrationMs:${registration.durationMs}|cleanupMs:${cleanupWatch.elapsedMilliseconds}|total:${registration.total}|success:${registration.successCount}|failed:${registration.failedCount}|'); - final status = registration.success + // 权限申请和摄像头枚举在 Load 提前完成;真实开机与断线检查仍由会话负责。 + final cameraPermission = await Permission.camera.request(); + final cameraAvailable = + cameraPermission.isGranted && await FaceDiscernPlugs.hasCamera(); + final status = registration.success && cameraAvailable ? FaceStartupPreparationStatus.success : FaceStartupPreparationStatus.warning; return FaceStartupPreparationResult( status: status, - message: registration.message, + message: !cameraPermission.isGranted + ? '${registration.message};相机权限未开启,请授权后使用人脸支付' + : !cameraAvailable + ? '${registration.message};未发现可用摄像头,请检查设备连接' + : registration.message, total: registration.total, successCount: registration.successCount, failedCount: registration.failedCount, diff --git a/lib/common/utils/app/control/app_control.dart b/lib/common/utils/app/control/app_control.dart index a83907a..3441d7f 100644 --- a/lib/common/utils/app/control/app_control.dart +++ b/lib/common/utils/app/control/app_control.dart @@ -96,7 +96,7 @@ class AppControl extends GetxController { ('运行环境与权限', '等待申请存储权限并读取终端配置'), ('本地数据库', '等待打开本地数据库并完成备份'), ('收银外设', '等待初始化打印机、读卡器、电子秤与语音'), - ('人脸识别库', '等待激活人脸插件并装载本地人脸库'), + ('人脸识别库', '等待装载、核对人脸库并检查相机权限'), ('后台服务', '等待启动同步调度与终端心跳'), ]; @@ -226,7 +226,7 @@ class AppControl extends GetxController { _setStartupStep( 3, status: AppStartupStepStatus.running, - detail: '正在准备人脸识别并装载本地人脸库', + detail: '正在装载、核对人脸库并检查相机权限与连接', ); final facePreparation = await AppFaceTool.prepareForStartup(); _setStartupStep( diff --git a/lib/common/utils/app/face_readiness_cache.dart b/lib/common/utils/app/face_readiness_cache.dart new file mode 100644 index 0000000..6401ac1 --- /dev/null +++ b/lib/common/utils/app/face_readiness_cache.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +/// 缓存当前进程已确认的人脸就绪状态,并隔离库变更前后的异步查询。 +/// +/// 变更开始和结束都会使已有结果失效;变更期间允许查询,但不会缓存查询结果。 +/// 调用方负责在修改人脸库、激活参数等就绪条件时通知本实例。 +class FaceReadinessCache { + T? _value; + int _generation = 0; + int _mutationDepth = 0; + Future? _inFlight; + int? _inFlightGeneration; + + T? get value => _value; + + int get mutationDepth => _mutationDepth; + + /// 复用已确认状态,或合并同一代次的并发查询。 + /// + /// 失效前发起的查询仍会向原调用方返回结果,但不能覆盖新代次的缓存或查询。 + Future read( + Future Function() load, { + required bool Function(T) canCache, + }) { + final cached = _value; + if (cached != null) return Future.value(cached); + + final inFlight = _inFlight; + if (inFlight != null && _inFlightGeneration == _generation) { + return inFlight; + } + + final completer = Completer(); + final generation = _generation; + // 先登记再调用 load,确保同步进入的其他调用也能复用本次查询。 + _inFlight = completer.future; + _inFlightGeneration = generation; + unawaited(_resolve(load, canCache, generation, completer)); + return completer.future; + } + + Future _resolve( + Future Function() load, + bool Function(T) canCache, + int generation, + Completer completer, + ) async { + try { + final result = await load(); + if (generation == _generation && + _mutationDepth == 0 && + canCache(result)) { + _value = result; + } + completer.complete(result); + } catch (error, stack) { + completer.completeError(error, stack); + } finally { + if (identical(_inFlight, completer.future)) { + _inFlight = null; + _inFlightGeneration = null; + } + } + } + + /// 无论样本数量是否变化,都使已有就绪状态和查询代次失效。 + void invalidate() { + _generation++; + _value = null; + } + + void beginMutation() { + _mutationDepth++; + invalidate(); + } + + void endMutation() { + if (_mutationDepth == 0) { + throw StateError('No face readiness mutation is active'); + } + _mutationDepth--; + invalidate(); + } +} diff --git a/lib/common/utils/manage/http_manage.dart b/lib/common/utils/manage/http_manage.dart index 9bb393e..58912ea 100644 --- a/lib/common/utils/manage/http_manage.dart +++ b/lib/common/utils/manage/http_manage.dart @@ -8,6 +8,7 @@ import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:get/get.dart' hide FormData; import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/common/utils/order/payment_timing_trace.dart'; /// 无需向操作员重复提示的服务端业务消息。 const ignoreMsg = ["账号未登录"]; @@ -29,6 +30,17 @@ class HttpManage { /// 请求开始时间在 Dio extra 中使用的内部键,用于计算请求耗时。 static const _requestStartAtKey = "_requestStartAt"; + /// 支付相关请求的单调时钟记录,仅在慢请求或失败时输出安全摘要。 + static const _paymentTimingKey = '_paymentHttpTiming'; + + static const _paymentTimingPaths = { + '/hard/hard-api/ad/get-user', + '/hard/hard-api/ad/get-user-by-card', + '/wallet/wallet-setting/ad/get-discounts-list', + '/hard/hard-api/ad/create-order', + '/hard/hard-api/ad/get-order-by-android-code', + }; + /// 单段请求或响应日志允许保留的最大字符数,超出部分只记录原长度。 static const _maxLogLength = 2000; @@ -88,8 +100,10 @@ class HttpManage { InterceptorsWrapper( onRequest: (RequestOptions options, handler) async { options.extra[_requestStartAtKey] = DateTime.now(); + _startPaymentTiming(options); options.headers["CLIENT-TOC"] = "Y"; final userInfo = await AppSettingTool.getUserSetting(); + _paymentTiming(options)?.markSettingsReady(); if (!options.path.contains(Constants.oauth2AppApiUrl)) { if (ObjectUtil.isNotEmpty(userInfo.accessToken)) { options.headers['Authorization'] = @@ -106,9 +120,11 @@ class HttpManage { "query:${_formatLogData(options.queryParameters)} " "body:${_formatLogData(options.data)}", ); + _paymentTiming(options)?.markPrepared(); return handler.next(options); }, onResponse: (e, handler) async { + _paymentTiming(e.requestOptions)?.markArrival(); final silent = e.requestOptions.extra[silentRequestKey] == true; // 先判断是不是登录接口 late ServiceBase serviceData; @@ -122,6 +138,12 @@ class HttpManage { Toast.error(msg: "服务器返回格式异常,请稍后重试"); EasyLoading.dismiss(); } + _finishPaymentTiming( + e.requestOptions, + result: 'invalidResponse', + statusCode: e.statusCode, + failed: true, + ); handler.reject( DioException( requestOptions: e.requestOptions, @@ -156,6 +178,13 @@ class HttpManage { options: e.requestOptions, response: serviceData.data, ); + _finishPaymentTiming( + e.requestOptions, + result: serviceData.code == 0 ? 'success' : 'businessError', + statusCode: e.statusCode, + businessCode: serviceData.code, + failed: serviceData.code != 0, + ); if (serviceData.code != 0 && (silent || e.requestOptions.extra[rejectBusinessErrorKey] == true)) { @@ -173,6 +202,7 @@ class HttpManage { handler.next(e); }, onError: (e, handler) async { + _paymentTiming(e.requestOptions)?.markArrival(); final silent = e.requestOptions.extra[silentRequestKey] == true; _logError(e); final timedOut = e.type == DioExceptionType.connectionTimeout || @@ -205,6 +235,13 @@ class HttpManage { } EasyLoading.dismiss(); } + _finishPaymentTiming( + e.requestOptions, + result: 'error', + statusCode: statusCode, + errorType: e.type.name, + failed: true, + ); return handler.next(e); }, ), @@ -220,6 +257,64 @@ class HttpManage { ); } + /// 路径按配置的网关前缀归一化;不读取或输出查询参数、请求体与响应内容。 + void _startPaymentTiming(RequestOptions options) { + options.extra.remove(_paymentTimingKey); + var path = options.uri.path; + if (!_paymentTimingPaths.contains(path)) { + final basePath = + Uri.tryParse(options.baseUrl)?.path.replaceFirst(RegExp(r'/$'), '') ?? + ''; + if (basePath.isEmpty || !path.startsWith('$basePath/')) return; + path = path.substring(basePath.length); + if (!_paymentTimingPaths.contains(path)) return; + } + options.extra[_paymentTimingKey] = _PaymentHttpTiming( + path: path, + traceId: PaymentTimingTrace.current?.id, + ); + } + + _PaymentHttpTiming? _paymentTiming(RequestOptions options) { + final timing = options.extra[_paymentTimingKey]; + return timing is _PaymentHttpTiming ? timing : null; + } + + /// waitMs 包含连接、传输、网关、服务端和响应解码/调度,不能作为纯后端耗时。 + /// handlerMs 单独覆盖响应到达后的业务处理,包括自动登录。 + void _finishPaymentTiming( + RequestOptions options, { + required String result, + required bool failed, + int? statusCode, + int? businessCode, + String? errorType, + }) { + final timing = _paymentTiming(options); + if (timing == null || timing.finished) return; + timing.finished = true; + timing.watch.stop(); + final totalMs = timing.watch.elapsedMilliseconds; + if (!failed && totalMs < 1000) return; + final arrivalMs = timing.arrivalAtMs; + final preparedMs = timing.preparedAtMs; + final waitMs = + arrivalMs != null && preparedMs != null ? arrivalMs - preparedMs : null; + final handlerMs = arrivalMs != null ? totalMs - arrivalMs : null; + try { + logger.w( + '|支付请求耗时|trace:${timing.traceId ?? "-"}|method:${options.method}|' + 'path:${timing.path}|result:$result|totalMs:$totalMs|' + 'settingsMs:${timing.settingsAtMs ?? "-"}|prepareMs:${preparedMs ?? "-"}|' + 'waitMs:${waitMs ?? "-"}|handlerMs:${handlerMs ?? "-"}|' + 'http:${statusCode ?? "-"}|code:${businessCode ?? "-"}|' + 'errorType:${errorType ?? "-"}|', + ); + } catch (_) { + // 诊断输出失败不能打断响应解包、对账或正常支付结果返回。 + } + } + /// 使用本地保存的账号密码重新获取令牌;失败时清理失效登录信息并返回 false。 Future _autoLogin() async { final userSetting = await AppSettingTool.getUserSetting(); @@ -429,3 +524,24 @@ class HttpManage { return "${text.substring(0, _maxLogLength)}...(已截断,原长度:${text.length})"; } } + +/// 每个请求各自保存阶段与结算标记,避免响应拒绝转入错误拦截器时重复记录。 +class _PaymentHttpTiming { + _PaymentHttpTiming({required this.path, required this.traceId}); + + final String path; + final String? traceId; + final Stopwatch watch = Stopwatch()..start(); + int? settingsAtMs; + int? preparedAtMs; + int? arrivalAtMs; + bool finished = false; + + void markSettingsReady() => settingsAtMs = watch.elapsedMilliseconds; + + void markPrepared() => preparedAtMs = watch.elapsedMilliseconds; + + void markArrival() { + arrivalAtMs ??= watch.elapsedMilliseconds; + } +} diff --git a/lib/common/utils/order/payment_timing_trace.dart b/lib/common/utils/order/payment_timing_trace.dart new file mode 100644 index 0000000..2827d39 --- /dev/null +++ b/lib/common/utils/order/payment_timing_trace.dart @@ -0,0 +1,233 @@ +import 'dart:async'; + +/// 一次支付遮罩内的阶段计时;仅记录定位所需的编号、耗时和异常类型。 +/// +/// 默认使用单调时钟,避免终端校时改变耗时。阶段允许嵌套,汇总中的阶段耗时包含 +/// 子阶段,不能把各阶段直接相加当作总耗时。调用方负责在 finally 中结束追踪。 +class PaymentTimingTrace { + PaymentTimingTrace({ + required void Function(String message) log, + String? id, + Duration slowThreshold = const Duration(seconds: 1), + Duration Function()? elapsed, + }) : id = _safeIdentifier(id, fallback: _nextId())!, + _log = log, + _slowThreshold = slowThreshold > Duration.zero + ? slowThreshold + : const Duration(seconds: 1), + _elapsed = elapsed { + _stopwatch.start(); + _lastProbeAt = _now(); + _eventLoopProbe = Timer.periodic(_probeInterval, (_) { + if (!_finished) _recordEventLoopLag(_now()); + }); + } + + static final Object _zoneKey = Object(); + static const Duration _probeInterval = Duration(milliseconds: 250); + static int _sequence = 0; + + /// 本次支付追踪编号,可用于关联 HTTP 日志。 + final String id; + final void Function(String message) _log; + final Duration _slowThreshold; + final Duration Function()? _elapsed; + final Stopwatch _stopwatch = Stopwatch(); + final List<_PaymentTimingStage> _stages = []; + Duration _lastElapsed = Duration.zero; + Duration _lastProbeAt = Duration.zero; + Duration _maxEventLoopLag = Duration.zero; + Timer? _eventLoopProbe; + String? _orderCode; + bool _finished = false; + + /// 并发支付或嵌套异步任务通过 Zone 取得各自追踪,避免全局变量串单。 + static PaymentTimingTrace? get current => + Zone.current[_zoneKey] as PaymentTimingTrace?; + + /// 在本次支付的异步上下文中执行原业务,不捕获或改写业务异常。 + Future run(Future Function() callback) => + runZoned(callback, zoneValues: {_zoneKey: this}); + + /// 下单参数准备完成后补上终端订单号,不接受其他业务字段或任意日志文本。 + static void setCurrentOrderCode(String code) { + final trace = current; + if (trace == null || trace._finished) return; + trace._orderCode = _safeIdentifier(code, maxLength: 128); + } + + /// 未开启支付追踪时直接执行回调,兼容其他共用下单入口。 + static Future measureCurrent( + String stage, + Future Function() callback, + ) => + current?.measure(stage, callback) ?? callback(); + + /// 同步阶段同样只观察耗时和异常类型,不记录返回值。 + static T measureSyncCurrent(String stage, T Function() callback) { + final trace = current; + return trace == null ? callback() : trace.measureSync(stage, callback); + } + + /// 慢阶段等待满阈值时只提醒一次;完成时仅慢阶段和失败阶段追加日志。 + Future measure(String stage, Future Function() callback) async { + if (_finished) return callback(); + final timing = _startStage(stage); + Object? failure; + try { + return await callback(); + } catch (error) { + failure = error; + rethrow; + } finally { + _finishStage(timing, failure); + } + } + + /// 同步计算不能由 Timer 中途打断,结束后仍会记录实际慢阶段。 + T measureSync(String stage, T Function() callback) { + if (_finished) return callback(); + final timing = _startStage(stage); + Object? failure; + try { + return callback(); + } catch (error) { + failure = error; + rethrow; + } finally { + _finishStage(timing, failure); + } + } + + /// 每次支付只输出一条汇总;即使回调尚未结束,也先取消全部看门狗。 + void finish({required String outcome}) { + if (_finished) return; + _finished = true; + final total = _now(); + // 同步阻塞结束时可能尚未来得及派发 Timer,在关闭前也采样最后一段间隔。 + _recordEventLoopLag(total); + _eventLoopProbe?.cancel(); + _stopwatch.stop(); + final durations = {}; + final pending = {}; + final errorTypes = {}; + for (final stage in _stages) { + stage.watchdog?.cancel(); + final cost = stage.cost ?? _difference(total, stage.startedAt); + durations.update( + stage.name, + (previous) => previous + cost.inMilliseconds, + ifAbsent: () => cost.inMilliseconds, + ); + if (stage.cost == null) pending.add(stage.name); + if (stage.errorType != null) errorTypes.add(stage.errorType!); + } + final safeOutcome = const { + 'success', + 'failed', + 'exception', + 'offline_accepted', + }.contains(outcome) + ? outcome + : 'unknown'; + _write( + 'event:summary|outcome:$safeOutcome|totalMs:${total.inMilliseconds}' + '|stagesInclusiveMs:${durations.entries.map((e) => '${e.key}=${e.value}').join(',')}' + '|eventLoopLagMs:${_maxEventLoopLag.inMilliseconds}' + '|pending:${pending.isEmpty ? '-' : pending.join(',')}' + '|errorTypes:${errorTypes.isEmpty ? '-' : errorTypes.join(',')}|', + ); + } + + /// 调度延迟只表示 Dart 事件循环未及时执行,不据此推断网络或服务端故障。 + void _recordEventLoopLag(Duration now) { + final interval = _difference(now, _lastProbeAt); + final lag = _difference(interval, _probeInterval); + if (lag > _maxEventLoopLag) _maxEventLoopLag = lag; + _lastProbeAt = now; + } + + _PaymentTimingStage _startStage(String name) { + final stage = _PaymentTimingStage( + _safeIdentifier(name, fallback: 'unknown_stage', maxLength: 64)!, + _now(), + ); + _stages.add(stage); + stage.watchdog = Timer(_slowThreshold, () { + if (_finished || stage.cost != null) return; + _write( + 'event:pending|stage:${stage.name}' + '|elapsedMs:${_difference(_now(), stage.startedAt).inMilliseconds}|', + ); + }); + return stage; + } + + void _finishStage(_PaymentTimingStage stage, Object? failure) { + stage.watchdog?.cancel(); + if (_finished) return; + stage.cost = _difference(_now(), stage.startedAt); + stage.errorType = failure == null + ? null + : _safeIdentifier(failure.runtimeType.toString(), fallback: 'Object'); + if (stage.cost! < _slowThreshold && failure == null) return; + _write( + 'event:stage|stage:${stage.name}|costMs:${stage.cost!.inMilliseconds}' + '|outcome:${failure == null ? 'success' : 'exception'}' + '${stage.errorType == null ? '' : '|errorType:${stage.errorType}'}|', + ); + } + + Duration _now() { + var value = _stopwatch.elapsed; + try { + value = _elapsed?.call() ?? value; + } catch (_) { + // 测量来源异常也必须保留原支付流程。 + } + if (value < _lastElapsed) return _lastElapsed; + _lastElapsed = value; + return value; + } + + void _write(String fields) { + try { + _log( + '|支付耗时|trace:$id' + '${_orderCode == null ? '' : '|androidOrderCode:$_orderCode'}|$fields', + ); + } catch (_) { + // 日志故障不能改变支付结果,也不能向收银异步上下文抛出异常。 + } + } + + static Duration _difference(Duration end, Duration start) => + end >= start ? end - start : Duration.zero; + + static String _nextId() => + 'pay_${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}_${++_sequence}'; + + static String? _safeIdentifier( + String? value, { + String? fallback, + int maxLength = 64, + }) { + if (value == null || + value.isEmpty || + value.length > maxLength || + !RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value)) { + return fallback; + } + return value; + } +} + +class _PaymentTimingStage { + _PaymentTimingStage(this.name, this.startedAt); + + final String name; + final Duration startedAt; + Timer? watchdog; + Duration? cost; + String? errorType; +} diff --git a/lib/common/utils/plugs/face_discern_plugs.dart b/lib/common/utils/plugs/face_discern_plugs.dart index 702c05c..bde82ad 100644 --- a/lib/common/utils/plugs/face_discern_plugs.dart +++ b/lib/common/utils/plugs/face_discern_plugs.dart @@ -221,43 +221,45 @@ abstract class FaceDiscernPlugs { required String appId, required String sdkKey, required String activeKey, - }) async { - try { - final map = await _channel.invokeMethod('activateFaceSdk', { - 'appId': appId, - 'sdkKey': sdkKey, - 'activeKey': activeKey, + }) => + AppFaceTool.runLibraryMutation(() async { + try { + final map = await _channel.invokeMethod('activateFaceSdk', { + 'appId': appId, + 'sdkKey': sdkKey, + 'activeKey': activeKey, + }); + if (map is Map) return FaceActivationResult.fromMap(map); + } catch (e) { + logger.w('|$_tag| activateFaceSdk 错误: $e'); + } + return const FaceActivationResult( + success: false, + code: -1, + message: '人脸插件激活失败,请稍后重试', + requiresPermission: false, + canOpenSettings: false, + ); }); - if (map is Map) return FaceActivationResult.fromMap(map); - } catch (e) { - logger.w('|$_tag| activateFaceSdk 错误: $e'); - } - return const FaceActivationResult( - success: false, - code: -1, - message: '人脸插件激活失败,请稍后重试', - requiresPermission: false, - canOpenSettings: false, - ); - } static Future rebuildFaceLibrary({ String? userId, - }) async { - final map = await _channel.invokeMethod('rebuildFaceLibrary', { - 'userId': userId, - }); - if (map is Map) return FaceRegistrationResult.fromMap(map); - return const FaceRegistrationResult( - success: false, - message: '人脸库注册失败,请稍后重试', - total: 0, - successCount: 0, - failedCount: 0, - failedSampleIds: [], - durationMs: 0, - ); - } + }) => + AppFaceTool.runLibraryMutation(() async { + final map = await _channel.invokeMethod('rebuildFaceLibrary', { + 'userId': userId, + }); + if (map is Map) return FaceRegistrationResult.fromMap(map); + return const FaceRegistrationResult( + success: false, + message: '人脸库注册失败,请稍后重试', + total: 0, + successCount: 0, + failedCount: 0, + failedSampleIds: [], + durationMs: 0, + ); + }); /// 从临时图片提取注册特征,不修改虹软内存库和本地数据库。 static Future extractFaceFeature({ @@ -300,21 +302,22 @@ abstract class FaceDiscernPlugs { String? userId, String? username, List? featureData, - }) async { - final map = await _channel.invokeMethod('applyFaceDelta', { - 'operation': operation, - 'sampleId': int.tryParse(sampleId), - 'userId': userId, - 'username': username, - 'featureData': featureData, - }); - if (map is Map) return FaceDeltaApplyResult.fromMap(map); - return const FaceDeltaApplyResult( - success: false, - message: '原生未返回有效的人脸增量结果', - code: -99, - ); - } + }) => + AppFaceTool.runLibraryMutation(() async { + final map = await _channel.invokeMethod('applyFaceDelta', { + 'operation': operation, + 'sampleId': int.tryParse(sampleId), + 'userId': userId, + 'username': username, + 'featureData': featureData, + }); + if (map is Map) return FaceDeltaApplyResult.fromMap(map); + return const FaceDeltaApplyResult( + success: false, + message: '原生未返回有效的人脸增量结果', + code: -99, + ); + }); /// 在虹软内存库中批量删除样本,一次通道调用摊平逐条往返与互斥收发开销。 /// @@ -322,50 +325,52 @@ abstract class FaceDiscernPlugs { /// 也计入失败清单由调用方保留记录。 static Future removeFaces({ required List sampleIds, - }) async { - final failed = []; - final nativeIds = []; - for (final sampleId in sampleIds) { - final id = int.tryParse(sampleId); - if (id == null) { - failed.add(sampleId); - } else { - nativeIds.add(id); - } - } - if (nativeIds.isEmpty) { - return FaceBulkRemoveResult( - failedSampleIds: failed, - message: '没有可删除的原生样本', - ); - } - final map = await _channel.invokeMethod('removeFaces', { - 'sampleIds': nativeIds, - }); - if (map is Map) { - final result = FaceBulkRemoveResult.fromMap(map); - return FaceBulkRemoveResult( - failedSampleIds: [...failed, ...result.failedSampleIds], - message: result.message, - ); - } - return FaceBulkRemoveResult( - failedSampleIds: sampleIds, - message: '原生未返回有效的批量删除结果', - ); - } + }) => + AppFaceTool.runLibraryMutation(() async { + final failed = []; + final nativeIds = []; + for (final sampleId in sampleIds) { + final id = int.tryParse(sampleId); + if (id == null) { + failed.add(sampleId); + } else { + nativeIds.add(id); + } + } + if (nativeIds.isEmpty) { + return FaceBulkRemoveResult( + failedSampleIds: failed, + message: '没有可删除的原生样本', + ); + } + final map = await _channel.invokeMethod('removeFaces', { + 'sampleIds': nativeIds, + }); + if (map is Map) { + final result = FaceBulkRemoveResult.fromMap(map); + return FaceBulkRemoveResult( + failedSampleIds: [...failed, ...result.failedSampleIds], + message: result.message, + ); + } + return FaceBulkRemoveResult( + failedSampleIds: sampleIds, + message: '原生未返回有效的批量删除结果', + ); + }); static Future removeFace({ required String sampleId, - }) async { - final sampleIdInt = int.tryParse(sampleId); - final pa = { - "sampleId": sampleIdInt, - }; - final res = await _channel.invokeMethod('removeFace', pa); - if (res == null) return false; - return res; - } + }) => + AppFaceTool.runLibraryMutation(() async { + final sampleIdInt = int.tryParse(sampleId); + final pa = { + "sampleId": sampleIdInt, + }; + final res = await _channel.invokeMethod('removeFace', pa); + if (res == null) return false; + return res; + }); /// 姓名或人员绑定变化时只刷新内存档案,保留已注册的特征。 static Future updateFaceMetadata(List faces) async { diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart index 28f7d71..3fc79d7 100644 --- a/lib/common/utils/sync/sync_face_tool.dart +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -57,7 +57,10 @@ abstract class SyncFaceTool { /// 比对,会让收银弹框反复判定库损坏并进入就地注册引导。整库装载已先 /// 尝试从本地图片补回特征,这里只清理最终仍无特征的行。 /// 删除顺序仍先处理虹软内存库;单条失败时保留记录供后续权威快照重试。 - static Future delEmptyFace() async { + static Future delEmptyFace() => + AppFaceTool.runLibraryMutation(_delEmptyFace); + + static Future _delEmptyFace() async { final faceDao = Get.find().dataBase.getFaceDao; // 只回传残缺行;正常启动无需把数千条完整档案再传回 Dart 逐条筛选。 final profiles = await faceDao.getEmptyFaceList() ?? const []; @@ -89,7 +92,9 @@ abstract class SyncFaceTool { if (_syncRunning) throw const FaceSyncSnapshotException('人脸同步正在进行中'); _syncRunning = true; try { - return await _sync(mode: mode, userIds: userIds, onProgress: onProgress); + return await AppFaceTool.runLibraryMutation( + () => _sync(mode: mode, userIds: userIds, onProgress: onProgress), + ); } finally { _syncRunning = false; } @@ -838,7 +843,10 @@ abstract class SyncFaceTool { /// 调用方必须先完成服务端删除或取得“服务端已不存在”确认;失败时本方法恢复 /// 图片、SQLite 和虹软内存特征,并返回 false 供页面保留重试入口。 static Future deleteLocalFace(FaceEntity face) => - _deleteLocalFace(face, Get.find().dataBase.getFaceDao); + AppFaceTool.runLibraryMutation( + () => + _deleteLocalFace(face, Get.find().dataBase.getFaceDao), + ); /// 按批量管线清理一组本地人脸(不触碰服务端数据),返回成功删除数。 /// @@ -847,10 +855,12 @@ abstract class SyncFaceTool { List faces, { void Function(int processed, int? total)? onProgress, }) async { - final result = await _deleteFacesInBulk( - faces, - Get.find().dataBase.getFaceDao, - onProgress: onProgress, + final result = await AppFaceTool.runLibraryMutation( + () => _deleteFacesInBulk( + faces, + Get.find().dataBase.getFaceDao, + onProgress: onProgress, + ), ); return result.deleted; } diff --git a/lib/common/widgets/face/face_recognition_panel.dart b/lib/common/widgets/face/face_recognition_panel.dart index 2f848f4..5ebb9c8 100644 --- a/lib/common/widgets/face/face_recognition_panel.dart +++ b/lib/common/widgets/face/face_recognition_panel.dart @@ -14,9 +14,6 @@ import 'package:pj_supermarket_cloud/common/widgets/face/face_camera_preview.dar /// 人脸识别面板的默认无人结果超时,超时后保留当前会话以允许原位重试。 const Duration faceRecognitionPanelDefaultTimeout = Duration(seconds: 45); -/// 人脸识别成功画面的默认冻结时长,沿用原业务成功展示语义。 -const Duration faceRecognitionPanelSuccessHold = Duration(seconds: 1); - /// 宿主控制人脸面板取消动作的轻量句柄,不直接暴露 State 或原生插件。 class FaceRecognitionPanelController { /// 当前挂载面板提供的取消实现;面板销毁后会清空。 @@ -53,7 +50,6 @@ class FaceRecognitionPanel extends StatefulWidget { this.onFailure, this.onCancelled, this.timeout = faceRecognitionPanelDefaultTimeout, - this.successHoldDuration = faceRecognitionPanelSuccessHold, this.showCancelAction = true, this.compact = false, }); @@ -64,7 +60,7 @@ class FaceRecognitionPanel extends StatefulWidget { /// 当前面板独占处理的会话句柄。 final FaceRecognitionSessionHandle handle; - /// 成功冻结结束后的结果回调,每个面板生命周期最多调用一次。 + /// 会话安全结束后的结果回调,每个面板生命周期最多调用一次。 final ValueChanged onRecognized; /// 供宿主主动取消当前面板的可选控制器。 @@ -79,9 +75,6 @@ class FaceRecognitionPanel extends StatefulWidget { /// 单轮识别超时时长;到期后显示可恢复提示但不自动关闭弹框。 final Duration timeout; - /// 成功结果在主屏保持冻结的时长。 - final Duration successHoldDuration; - /// 是否在面板底部展示取消识别按钮。 final bool showCancelAction; @@ -183,7 +176,7 @@ class _FaceRecognitionPanelState extends State { } } - /// 等待服务层的幂等结果,冻结三秒后结束原生会话并通知宿主。 + /// 收到幂等结果后立即结束原生会话并通知宿主,不额外等待成功画面展示。 Future _waitForResult() async { try { final result = await widget.handle.result; @@ -193,12 +186,10 @@ class _FaceRecognitionPanelState extends State { if (_event?.status != FaceRecognitionSessionStatus.success) { setState(() => _failure = null); } - await Future.delayed(widget.successHoldDuration); - if (!mounted || _cancelling) return; if (widget.service.owns(widget.handle.sessionId)) { await widget.service.finish(); } - if (mounted) widget.onRecognized(result); + if (mounted && !_cancelling) widget.onRecognized(result); } on FaceRecognitionFailure catch (failure) { if (!mounted || _cancelling) return; if (failure.cancelled) { diff --git a/lib/common/widgets/face/face_recognition_preview_dialog.dart b/lib/common/widgets/face/face_recognition_preview_dialog.dart index 66c6287..04df5af 100644 --- a/lib/common/widgets/face/face_recognition_preview_dialog.dart +++ b/lib/common/widgets/face/face_recognition_preview_dialog.dart @@ -65,7 +65,7 @@ class _FaceRecognitionPreviewDialogState if (mounted) Navigator.of(context).pop(); } - /// 返回唯一识别结果;面板已经完成成功冻结和会话释放。 + /// 返回唯一识别结果;面板已经完成会话释放。 void _recognized(FaceStream result) { if (_closing || !mounted) return; _closing = true; diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index 29d39ec..b1e2cb8 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -9,6 +9,7 @@ import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/common/utils/order/payment_timing_trace.dart'; import 'package:pj_supermarket_cloud/common/utils/order/terminal_order_recovery.dart'; mixin OrderMixin { @@ -213,8 +214,7 @@ mixin OrderMixin { logger.e("|$_tag|创建本地购物车,失败本地订单id不存在"); return false; } - if (ObjectUtil.isEmpty(sqlOrderDetail) || - sqlOrderDetail.id != sqlOrderId) { + if (ObjectUtil.isEmpty(sqlOrderDetail) || sqlOrderDetail.id != sqlOrderId) { logger.e("|$_tag|创建本地购物车,失败本地订单不存在,sqlOrderDetail:|$sqlOrderDetail|;"); return false; } @@ -248,8 +248,7 @@ mixin OrderMixin { // 更新本地订单;payUser 可能为空(线下匿名单),与上方用户名容错保持一致。 final payUserId = payUser.value?.userId; sqlOrderDetail!.username = payUser.value?.username ?? "未知"; - sqlOrderDetail.userCode = - payUserId != null ? payUserId.toString() : "未知"; + sqlOrderDetail.userCode = payUserId != null ? payUserId.toString() : "未知"; sqlOrderDetail.productDesc = getProductDesc(); sqlOrderDetail.count = getProductCount(); sqlOrderDetail.weight = getProductWeight(); @@ -312,10 +311,14 @@ mixin OrderMixin { /// 准备并提交原协议订单;网络响应丢失时先只读对账,确认后统一完成本地状态。 Future _createApiOrder() async { - final prepare = await createApiOrderValidate(); + final prepare = await PaymentTimingTrace.measureCurrent( + 'order_prepare', + createApiOrderValidate, + ); if (prepare == null) return null; final sqlOrderDetail = prepare.order; + PaymentTimingTrace.setCurrentOrderCode(sqlOrderDetail.uniqueCode); final appSetting = prepare.setting; final serviceId = appSetting.serviceId; final serviceMerchantId = appSetting.serviceMerchantId; @@ -325,7 +328,8 @@ mixin OrderMixin { // 固定本次请求的支付方式,异步网络处理期间不读取可能被硬件回调更新的选择状态。 final submittedPayType = orderPayType.value!; final submittedDisplayType = orderPayDisplayType.value!; - final anonymousPayment = OrderPayDisplayType.isOffline(submittedDisplayType); + final anonymousPayment = + OrderPayDisplayType.isOffline(submittedDisplayType); // 获取下单商品 List> shopList = []; @@ -358,12 +362,17 @@ mixin OrderMixin { var evidenceMetadataList = const >[]; String? evidenceAppVersion; if (isFacePayment && faceSessionId != null && faceSessionId.isNotEmpty) { - evidenceMetadataList = await OrderEvidenceUploadTool.bindSessionToOrder( - recognitionSessionId: faceSessionId, - androidOrderCode: sqlOrderDetail.uniqueCode, + evidenceMetadataList = await PaymentTimingTrace.measureCurrent( + 'evidence_bind', + () => OrderEvidenceUploadTool.bindSessionToOrder( + recognitionSessionId: faceSessionId, + androidOrderCode: sqlOrderDetail.uniqueCode, + ), + ); + evidenceAppVersion = await PaymentTimingTrace.measureCurrent( + 'evidence_version', + () => OrderEvidenceUploadTool.getSessionAppVersion(faceSessionId), ); - evidenceAppVersion = - await OrderEvidenceUploadTool.getSessionAppVersion(faceSessionId); pendingFaceRecognition.value = null; } var pa = { @@ -403,7 +412,10 @@ mixin OrderMixin { try { if (isNetwork) { if (submittedPayType == OrderPayType.WALLET_QR_CODE_SCAN) { - final data = await HardApi.createHardQrCodeScanOrder(pa); + final data = await PaymentTimingTrace.measureCurrent( + 'order_http', + () => HardApi.createHardQrCodeScanOrder(pa), + ); if (data == null) throw Error(); if (ObjectUtil.isEmpty(data)) throw Error(); logger.i("|$_tag||有网络|创建api订单,下单成功res:|$data|"); @@ -413,22 +425,28 @@ mixin OrderMixin { } else { String? data; try { - data = await HardApi.createHardwareOrder(pa); + data = await PaymentTimingTrace.measureCurrent( + 'order_http', + () => HardApi.createHardwareOrder(pa), + ); } on DioException catch (error) { if (!TerminalOrderRecovery.shouldQuery(error)) rethrow; - data = await TerminalOrderRecovery.recover( - lookup: () => HardApi.getOrderByAndroidCode( - hardId: serviceId!, - androidOrderCode: sqlOrderDetail.uniqueCode, + data = await PaymentTimingTrace.measureCurrent( + 'order_reconcile', + () => TerminalOrderRecovery.recover( + lookup: () => HardApi.getOrderByAndroidCode( + hardId: serviceId!, + androidOrderCode: sqlOrderDetail.uniqueCode, + ), + // 旧 Flutter 枚举的消费卡 code 与人民医院重复;线上请求按名称编码, + // 对账使用后端既有消费卡编码 14,不改动本地历史枚举和存储协议。 + payType: submittedPayType == OrderPayType.CSXFK + ? '14' + : submittedPayType.code, + userId: anonymousPayment ? null : pa['userId']?.toString(), + totalPrice: sqlOrderDetail.price, + extraPrice: sqlOrderDetail.extraPrice, ), - // 旧 Flutter 枚举的消费卡 code 与人民医院重复;线上请求按名称编码, - // 对账使用后端既有消费卡编码 14,不改动本地历史枚举和存储协议。 - payType: submittedPayType == OrderPayType.CSXFK - ? '14' - : submittedPayType.code, - userId: anonymousPayment ? null : pa['userId']?.toString(), - totalPrice: sqlOrderDetail.price, - extraPrice: sqlOrderDetail.extraPrice, ); if (data == null) rethrow; logger.i('|$_tag|超时对账确认订单已支付|orderId:$data|'); @@ -447,24 +465,30 @@ mixin OrderMixin { final payPrice = sqlOrderDetail.price + sqlOrderDetail.extraPrice; sqlOrderDetail.payPrice = payPrice; sqlOrderDetail.payTime = DateTime.now(); - await orderDao.edit(sqlOrderDetail); + await PaymentTimingTrace.measureCurrent( + 'local_payment_commit', + () => orderDao.edit(sqlOrderDetail), + ); if (isFacePayment && faceSessionId != null && faceSessionId.isNotEmpty && result is String && result.isNotEmpty) { try { - await OrderEvidenceUploadTool.markOrderReady( - recognitionSessionId: faceSessionId, - serverOrderId: result, + await PaymentTimingTrace.measureCurrent( + 'evidence_ready', + () => OrderEvidenceUploadTool.markOrderReady( + recognitionSessionId: faceSessionId, + serverOrderId: result as String, + ), ); // 调度器仍会检查支付、人脸和业务弹框安全窗口,当前收银收尾不会被补传占用。 unawaited( SyncCheckTool.instance .trigger( - ScheduledTaskType.orderEvidenceUpload, - triggerType: ScheduledTaskTriggerType.system, - ) + ScheduledTaskType.orderEvidenceUpload, + triggerType: ScheduledTaskTriggerType.system, + ) .catchError((error) { logger.w( '|$_tag|订单已成功但证据补传触发失败|errorType:${error.runtimeType}|', @@ -493,9 +517,12 @@ mixin OrderMixin { faceSessionId != null && faceSessionId.isNotEmpty && !OrderEvidenceUploadTool.shouldReconcileOrderFailure(e)) { - await OrderEvidenceUploadTool.cancelSession( - faceSessionId, - reason: '服务端明确拒绝订单,证据任务已取消', + await PaymentTimingTrace.measureCurrent( + 'evidence_cancel', + () => OrderEvidenceUploadTool.cancelSession( + faceSessionId, + reason: '服务端明确拒绝订单,证据任务已取消', + ), ); } Toast.error(msg: "结算订单失败"); diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index fbd1016..59b6a13 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -754,7 +754,7 @@ class SelectPayTypeDialogController extends GetxController { return requestId == _faceDispatchRequestId && isRecognition.value; } - /// 检查数据库与 SDK 状态后创建唯一人脸会话;准备期间同样锁定重复点击和关闭流程。 + /// 复用 Load/人脸变更后核对结果创建会话;准备期间锁定重复点击和关闭流程。 Future _dispatchFace() async { if (isRecognition.value) { Toast.error(msg: "正在识别中,请勿重复点击"); @@ -769,11 +769,11 @@ class SelectPayTypeDialogController extends GetxController { isRecognition.value = true; try { final service = Get.find(); - faceWorkspaceMessage.value = '正在检查人脸库与摄像头状态'; - final faceDao = app.dataBase.getFaceDao; - final databaseFaceCount = await faceDao.findCount("-1"); + faceWorkspaceMessage.value = '正在启动人脸识别'; + final readiness = await AppFaceTool.getPaymentReadiness(); + final databaseFaceCount = readiness.databaseFaceCount; if (!_ownsFaceDispatch(requestId)) return; - if (databaseFaceCount == null || databaseFaceCount == 0) { + if (databaseFaceCount == 0) { // 空库不再把收银员引去设置页:切换为就地注册引导,把本机人脸装载进 // 识别引擎后自动续接识别,顾客无需离开结算弹框。 isRecognition.value = false; @@ -786,8 +786,7 @@ class SelectPayTypeDialogController extends GetxController { return; } faceSetupPhase.value = _FaceSetupPhase.idle; - var status = await AppFaceTool.getStatus(); - if (!_ownsFaceDispatch(requestId)) return; + var status = readiness.status; if (status?.registrationInProgress == true && status!.rebuildingLibrary) { // 整库重建会先清空内存库再分批重装,期间识别确实不可用;营业期间 // 只可能来自设置页手动重建(弹框内就地注册已被 _faceSetupRunning @@ -801,7 +800,7 @@ class SelectPayTypeDialogController extends GetxController { : '人脸库正在重建,请稍候(${waitRound * 2}秒)'; await Future.delayed(const Duration(seconds: 2)); if (!_ownsFaceDispatch(requestId)) return; - status = await AppFaceTool.getStatus(); + status = (await AppFaceTool.getPaymentReadiness()).status; if (!_ownsFaceDispatch(requestId)) return; waitRound++; } @@ -844,7 +843,7 @@ class SelectPayTypeDialogController extends GetxController { if (!settled) { throw Exception("人脸数据同步中,请稍后再发起人脸支付"); } - status = await AppFaceTool.getStatus(); + status = (await AppFaceTool.getPaymentReadiness()).status; if (!_ownsFaceDispatch(requestId)) return; if (status == null) { throw Exception('无法读取人脸状态,请稍后重试'); diff --git a/lib/pages/canteen_product/controller.dart b/lib/pages/canteen_product/controller.dart index 1d36435..29b2d8b 100644 --- a/lib/pages/canteen_product/controller.dart +++ b/lib/pages/canteen_product/controller.dart @@ -8,6 +8,7 @@ import 'package:get/get.dart'; import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/common/utils/order/payment_timing_trace.dart'; class CanteenProductController extends GetxController with OrderMixin { CanteenProductController(); @@ -275,18 +276,44 @@ class CanteenProductController extends GetxController with OrderMixin { pendingFaceRecognition.value = null; return false; } - EasyLoading.show( - status: '支付中...', - maskType: EasyLoadingMaskType.black, + // 普通 INFO 不落生产日志,单独保存不含人员/支付凭证的付款耗时摘要。 + final timing = PaymentTimingTrace( + id: 'canteen_${orderPayType.name}_${DateTime.now().microsecondsSinceEpoch}', + log: (message) => logger.w(message), ); - // 最后计算一次额外的价格 - await _computedAmountMealPrice(); - await updateSqlOrder(); - final isNetwork = AppNetworkTool.isAutoNetwork(); - final orderResult = await createApiOrder(); - EasyLoading.dismiss(); - // 在线下单失败时保持弹框打开允许重试;离线订单入本地队列视为已受理。 - if (isNetwork && orderResult == null) return false; + var outcome = 'exception'; + final accepted = await timing.run(() async { + try { + EasyLoading.show( + status: '支付中...', + maskType: EasyLoadingMaskType.black, + ); + // 最后计算一次额外的价格,计时包含优惠 HTTP 及其前后的本地工作。 + await timing.measure('discounts', _computedAmountMealPrice); + await timing.measure('local_order_update', updateSqlOrder); + final isNetwork = AppNetworkTool.isAutoNetwork(); + final orderResult = await timing.measure( + 'create_order', + () => createApiOrder(), + ); + // 在线下单失败时保持弹框打开允许重试;离线订单入本地队列视为已受理。 + final accepted = !isNetwork || orderResult != null; + outcome = !isNetwork + ? 'offline_accepted' + : accepted + ? 'success' + : 'failed'; + return accepted; + } finally { + // 优惠查询、本地写库等前置步骤抛错时也必须撤销支付遮罩。 + try { + EasyLoading.dismiss(); + } finally { + timing.finish(outcome: outcome); + } + } + }); + if (!accepted) return false; putOffSendSubScreen(); _clearShopCard(); return true; @@ -330,8 +357,7 @@ class CanteenProductController extends GetxController with OrderMixin { var extraPrice = 0; final currentUser = payUser.value; // 外部匿名人员没有餐次优惠,避免带着上一名识别人员的额外支付结算。 - final isRealUser = - currentUser != null && + final isRealUser = currentUser != null && !AppUserTool.isGenerateExternalUser(currentUser) && ObjectUtil.isNotEmpty(currentUser.userId); if (isRealUser && ObjectUtil.isNotEmpty(hourType.value)) { diff --git a/lib/pages/setting/face_manage_controller.dart b/lib/pages/setting/face_manage_controller.dart index 8c72920..5eb30b8 100644 --- a/lib/pages/setting/face_manage_controller.dart +++ b/lib/pages/setting/face_manage_controller.dart @@ -426,7 +426,10 @@ class FaceManageController extends GetxController { } /// 按服务端 500 条上限分批删除,并仅清理服务端已确认的本地样本。 - Future _deleteServerBackedFaces(List faces) async { + Future _deleteServerBackedFaces(List faces) => + AppFaceTool.runLibraryMutation(() => _deleteServerBackedFacesInBatch(faces)); + + Future _deleteServerBackedFacesInBatch(List faces) async { isDeleting.value = true; qualityOperationMessage.value = ''; var localDeleted = 0; @@ -655,9 +658,11 @@ class FaceManageController extends GetxController { maskType: EasyLoadingMaskType.black, ); try { - final faceDao = appC.dataBase.getFaceDao; - await faceDao.delAll(); - await AppFaceTool.removeFace(sampleId: '-1'); + await AppFaceTool.runLibraryMutation(() async { + final faceDao = appC.dataBase.getFaceDao; + await faceDao.delAll(); + await AppFaceTool.removeFace(sampleId: '-1'); + }); await getFaceList(); Toast.success(msg: "删除成功"); } catch (e, stack) { diff --git a/lib/pages/setting/face_setting_controller.dart b/lib/pages/setting/face_setting_controller.dart index 4ef09d4..9e90a6c 100644 --- a/lib/pages/setting/face_setting_controller.dart +++ b/lib/pages/setting/face_setting_controller.dart @@ -125,7 +125,7 @@ class FaceSettingController extends GetxController { if (isSavingRecognitionConfiguration.value) return; final confirmed = await appMessageDialog( Get.context!, - '是否还原为默认人脸识别策略?活体检测将关闭,相似度阈值将还原为 80%,最小人脸占画面比例将还原为 5%。', + '是否还原为默认人脸识别策略?活体检测将关闭,相似度阈值将还原为 80%,最小人脸占画面比例将还原为 3%。', ); if (!confirmed) return; isSavingRecognitionConfiguration.value = true; diff --git a/lib/pages/setting/widgets/setting_face_plugin.dart b/lib/pages/setting/widgets/setting_face_plugin.dart index 12092d5..d352d03 100644 --- a/lib/pages/setting/widgets/setting_face_plugin.dart +++ b/lib/pages/setting/widgets/setting_face_plugin.dart @@ -95,7 +95,7 @@ class _RecognitionConfigurationGroup extends StatelessWidget { _RecognitionValueRow( icon: FAssets.icons.scanFace, label: '最小人脸占画面比例', - subtitle: '人脸框面积达到相机完整画面的设定比例才开始识别;默认 5%,越大需站得越近', + subtitle: '人脸框面积达到相机完整画面的设定比例才开始识别;默认 3%,越大需站得越近', value: configuration.minFaceAreaPercent, min: 1, max: 100, diff --git a/pubspec.yaml b/pubspec.yaml index bbe3544..190c3a8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pj_supermarket_cloud description: "智慧后勤-测试" publish_to: "none" -version: 2.1.3+213 +version: 2.1.5+215 environment: sdk: ^3.6.0 diff --git a/test/face_readiness_cache_test.dart b/test/face_readiness_cache_test.dart new file mode 100644 index 0000000..848a6ad --- /dev/null +++ b/test/face_readiness_cache_test.dart @@ -0,0 +1,171 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/face_readiness_cache.dart'; + +void main() { + test('已就绪的重复读取直接使用缓存,不再次加载', () async { + final cache = FaceReadinessCache(); + var calls = 0; + Future load() async { + calls++; + return 'ready'; + } + + expect(await cache.read(load, canCache: (_) => true), 'ready'); + expect(await cache.read(load, canCache: (_) => true), 'ready'); + expect(cache.value, 'ready'); + expect(calls, 1); + }); + + test('同一代次的并发读取只发起一次加载', () async { + final cache = FaceReadinessCache(); + final source = Completer(); + var calls = 0; + Future load() { + calls++; + return source.future; + } + + final first = cache.read(load, canCache: (_) => true); + final second = cache.read(load, canCache: (_) => true); + expect(identical(first, second), isTrue); + expect(calls, 1); + source.complete('ready'); + expect(await Future.wait([first, second]), ['ready', 'ready']); + expect(cache.value, 'ready'); + }); + + test('相同数量的样本替换仍会在失效后重新检查', () async { + final cache = FaceReadinessCache>(); + var sampleIds = [1, 2]; + var calls = 0; + Future> load() async { + calls++; + return List.unmodifiable(sampleIds); + } + + expect(await cache.read(load, canCache: (_) => true), [1, 2]); + sampleIds = [1, 3]; + cache.invalidate(); + expect(cache.value, isNull); + expect(await cache.read(load, canCache: (_) => true), [1, 3]); + expect(calls, 2); + }); + + test('查询中失效会创建新查询,旧查询完成不能清除新查询', () async { + final cache = FaceReadinessCache(); + final oldSource = Completer(); + final newSource = Completer(); + var calls = 0; + Future load() { + calls++; + return calls == 1 ? oldSource.future : newSource.future; + } + + final oldRead = cache.read(load, canCache: (_) => true); + cache.invalidate(); + final newRead = cache.read(load, canCache: (_) => true); + expect(identical(oldRead, newRead), isFalse); + expect(calls, 2); + + oldSource.complete('old'); + expect(await oldRead, 'old'); + expect(cache.value, isNull); + final joined = cache.read(load, canCache: (_) => true); + expect(identical(joined, newRead), isTrue); + expect(calls, 2); + + newSource.complete('new'); + expect(await newRead, 'new'); + expect(await joined, 'new'); + expect(cache.value, 'new'); + }); + + test('旧查询晚于新查询完成时不能覆盖新缓存', () async { + final cache = FaceReadinessCache(); + final oldSource = Completer(); + final oldRead = cache.read(() => oldSource.future, canCache: (_) => true); + cache.invalidate(); + + expect(await cache.read(() async => 'new', canCache: (_) => true), 'new'); + oldSource.complete('old'); + expect(await oldRead, 'old'); + expect(cache.value, 'new'); + }); + + test('嵌套变更全部结束后才允许缓存,中途读到就绪也不缓存', () async { + final cache = FaceReadinessCache(); + var calls = 0; + Future load() async { + calls++; + return 'ready'; + } + + await cache.read(load, canCache: (_) => true); + cache.beginMutation(); + cache.beginMutation(); + expect(cache.mutationDepth, 2); + expect(cache.value, isNull); + expect(await cache.read(load, canCache: (_) => true), 'ready'); + expect(cache.value, isNull); + + cache.endMutation(); + expect(cache.mutationDepth, 1); + expect(await cache.read(load, canCache: (_) => true), 'ready'); + expect(cache.value, isNull); + + cache.endMutation(); + expect(cache.mutationDepth, 0); + expect(await cache.read(load, canCache: (_) => true), 'ready'); + expect(cache.value, 'ready'); + expect(calls, 4); + expect(cache.endMutation, throwsStateError); + expect(cache.mutationDepth, 0); + }); + + test('变更期间发起的查询在变更结束后完成也不能缓存', () async { + final cache = FaceReadinessCache(); + cache.beginMutation(); + final source = Completer(); + final duringMutation = + cache.read(() => source.future, canCache: (_) => true); + cache.endMutation(); + source.complete('ready'); + + expect(await duringMutation, 'ready'); + expect(cache.value, isNull); + expect(await cache.read(() async => 'checked', canCache: (_) => true), + 'checked'); + expect(cache.value, 'checked'); + }); + + test('加载失败不缓存失败结果,下一次读取可以重试', () async { + final cache = FaceReadinessCache(); + var calls = 0; + Future load() async { + calls++; + if (calls == 1) throw StateError('sdk unavailable'); + return 'ready'; + } + + await expectLater( + cache.read(load, canCache: (_) => true), throwsStateError); + expect(cache.value, isNull); + expect(await cache.read(load, canCache: (_) => true), 'ready'); + expect(calls, 2); + }); + + test('未就绪的正常结果不缓存,后续就绪结果才会缓存', () async { + final cache = FaceReadinessCache(); + var calls = 0; + Future load() async => ++calls > 1; + + expect(await cache.read(load, canCache: (ready) => ready), isFalse); + expect(cache.value, isNull); + expect(await cache.read(load, canCache: (ready) => ready), isTrue); + expect(await cache.read(load, canCache: (ready) => ready), isTrue); + expect(cache.value, isTrue); + expect(calls, 2); + }); +} diff --git a/test/face_recognition_configuration_test.dart b/test/face_recognition_configuration_test.dart index 031096f..6eca82f 100644 --- a/test/face_recognition_configuration_test.dart +++ b/test/face_recognition_configuration_test.dart @@ -4,8 +4,8 @@ import 'package:pj_supermarket_cloud/common/models/plugs/face_discern_models.dar void main() { const defaults = FaceRecognitionConfiguration.defaults(); - test('默认策略和旧版缺少占比字段的策略均使用 5%', () { - expect(defaults.minFaceAreaPercent, 5); + test('默认策略和旧版缺少占比字段的策略均使用 3%', () { + expect(defaults.minFaceAreaPercent, 3); final legacyMap = defaults.toMap()..remove('minFaceAreaPercent'); expect(FaceRecognitionConfiguration.fromMap(legacyMap), defaults); }); @@ -18,11 +18,11 @@ void main() { expect(changed.toMap()['minFaceAreaPercent'], 12); expect(FaceRecognitionConfiguration.fromMap(changed.toMap()), changed); expect(changed.copyWith(enableLiveness: true).minFaceAreaPercent, 12); - expect(changed.copyWith(minFaceAreaPercent: 5).usesDefaults, isTrue); + expect(changed.copyWith(minFaceAreaPercent: 3).usesDefaults, isTrue); }); - test('接受百分比边界和小数,异常读取值回退 5% 避免阻断设置页', () { - for (final percent in [1, 5.5, 100]) { + test('接受已保存占比、百分比边界和小数,异常读取值回退 3% 避免阻断设置页', () { + for (final percent in [1, 3, 5, 5.5, 100]) { final decoded = FaceRecognitionConfiguration.fromMap({ 'minFaceAreaPercent': percent, }); @@ -42,7 +42,7 @@ void main() { final decoded = FaceRecognitionConfiguration.fromMap({ 'minFaceAreaPercent': invalid, }); - expect(decoded.minFaceAreaPercent, 5, reason: '异常值 $invalid'); + expect(decoded.minFaceAreaPercent, 3, reason: '异常值 $invalid'); } }); } diff --git a/test/face_recognition_panel_test.dart b/test/face_recognition_panel_test.dart new file mode 100644 index 0000000..5bee85b --- /dev/null +++ b/test/face_recognition_panel_test.dart @@ -0,0 +1,258 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/models/plugs/face_discern_models.dart'; +import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart'; +import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart'; +import 'package:pj_supermarket_cloud/common/widgets/face/face_recognition_panel.dart'; + +const _sessionId = 'panel-test-session'; +const _faceChannel = MethodChannel('local/face_discern'); + +class FakeFaceRecognitionSessionService extends FaceRecognitionSessionService { + FakeFaceRecognitionSessionService({this.finishCompletion}); + + final Completer? finishCompletion; + final result = Completer(); + bool _ownsSession = true; + int finishCalls = 0; + int cancelCalls = 0; + + FaceRecognitionSessionHandle get handle => FaceRecognitionSessionHandle( + sessionId: _sessionId, + scene: FaceRecognitionScene.cashier, + events: const Stream.empty(), + result: result.future, + ); + + @override + bool owns(String? sessionId) => _ownsSession && sessionId == _sessionId; + + @override + Future finish() async { + finishCalls++; + await finishCompletion?.future; + _ownsSession = false; + } + + @override + Future cancel({String message = '已取消人脸识别'}) async { + cancelCalls++; + _ownsSession = false; + } +} + +Widget _harness({ + required FakeFaceRecognitionSessionService service, + required ValueChanged onRecognized, + FaceRecognitionPanelController? controller, + VoidCallback? onCancelled, +}) { + final base = FThemes.zinc.light; + return ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + builder: (_, __) => FTheme( + data: FThemeData.inherit( + colorScheme: base.colorScheme, + typography: base.typography, + style: base.style, + ), + child: MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 600, + child: FaceRecognitionPanel( + service: service, + handle: service.handle, + controller: controller, + onRecognized: onRecognized, + onCancelled: onCancelled, + ), + ), + ), + ), + ), + ), + ); +} + +FaceStream _recognitionResult() => FaceStream( + similar: 0.96, + cost: 42, + sampleId: 123, + sessionId: _sessionId, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (_) async => null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_faceChannel, (call) async { + switch (call.method) { + case 'createPreviewTexture': + return { + 'textureId': 7, + 'width': 1280, + 'height': 720, + 'rotation': 0, + 'mirror': false, + }; + case 'releasePreviewTexture': + return null; + default: + throw PlatformException(code: 'UNEXPECTED_METHOD'); + } + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_faceChannel, null); + }); + + testWidgets('成功结果无需推进一秒即可结束会话并通知业务', (tester) async { + final service = FakeFaceRecognitionSessionService(); + final recognized = []; + await tester.pumpWidget( + _harness(service: service, onRecognized: recognized.add), + ); + await tester.pump(); + + final beforeResult = tester.binding.clock.now(); + final result = _recognitionResult(); + service.result.complete(result); + await tester.pump(); + + expect(tester.binding.clock.now(), beforeResult); + expect(service.finishCalls, 1); + expect(recognized, [same(result)]); + expect(tester.takeException(), isNull); + }); + + testWidgets('会话结束尚未完成时不提前通知业务', (tester) async { + final finishCompletion = Completer(); + final service = FakeFaceRecognitionSessionService( + finishCompletion: finishCompletion, + ); + final recognized = []; + await tester.pumpWidget( + _harness(service: service, onRecognized: recognized.add), + ); + await tester.pump(); + + final result = _recognitionResult(); + service.result.complete(result); + await tester.pump(); + expect(service.finishCalls, 1); + expect(recognized, isEmpty); + + finishCompletion.complete(); + await tester.pump(); + expect(recognized, [same(result)]); + await tester.pump(); + expect(recognized, hasLength(1)); + expect(tester.takeException(), isNull); + }); + + testWidgets('结束会话期间销毁面板会忽略迟到成功结果', (tester) async { + final finishCompletion = Completer(); + final service = FakeFaceRecognitionSessionService( + finishCompletion: finishCompletion, + ); + final recognized = []; + await tester.pumpWidget( + _harness(service: service, onRecognized: recognized.add), + ); + await tester.pump(); + + service.result.complete(_recognitionResult()); + await tester.pump(); + expect(service.finishCalls, 1); + await tester.pumpWidget(const SizedBox.shrink()); + finishCompletion.complete(); + await tester.pump(); + + expect(recognized, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('成功结果到达前取消面板会忽略迟到结果', (tester) async { + final service = FakeFaceRecognitionSessionService(); + final controller = FaceRecognitionPanelController(); + final recognized = []; + var cancelled = 0; + await tester.pumpWidget( + _harness( + service: service, + onRecognized: recognized.add, + controller: controller, + onCancelled: () => cancelled++, + ), + ); + await tester.pump(); + + final cancel = controller.cancel(); + await tester.pump(); + await cancel; + expect(service.cancelCalls, 1); + expect(cancelled, 1); + + service.result.complete(_recognitionResult()); + await tester.pump(); + expect(service.finishCalls, 0); + expect(recognized, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('成功结果锁定后重复取消不会打断结束会话或重复通知业务', (tester) async { + final finishCompletion = Completer(); + final service = FakeFaceRecognitionSessionService( + finishCompletion: finishCompletion, + ); + final controller = FaceRecognitionPanelController(); + final recognized = []; + var cancelled = 0; + await tester.pumpWidget( + _harness( + service: service, + onRecognized: recognized.add, + controller: controller, + onCancelled: () => cancelled++, + ), + ); + await tester.pump(); + + service.result.complete(_recognitionResult()); + await tester.pump(); + expect(service.finishCalls, 1); + await controller.cancel(); + await controller.cancel(); + await tester.pump(); + expect(service.cancelCalls, 0); + expect(cancelled, 0); + expect(recognized, isEmpty); + + finishCompletion.complete(); + await tester.pump(); + expect(recognized, hasLength(1)); + await controller.cancel(); + await tester.pump(); + expect(recognized, hasLength(1)); + expect(service.finishCalls, 1); + expect(service.cancelCalls, 0); + expect(cancelled, 0); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/face_sync_pipeline_test.dart b/test/face_sync_pipeline_test.dart index aa963fc..5fc3d2d 100644 --- a/test/face_sync_pipeline_test.dart +++ b/test/face_sync_pipeline_test.dart @@ -41,6 +41,7 @@ void main() { var extractionCalls = 0; var upserts = 0; var metadataCalls = 0; + var statusCalls = 0; var qualityReports = 0; var v2Requests = 0; var failUpsertOnce = false; @@ -115,9 +116,12 @@ void main() { app.isNetwork.value = true; Get.put(app); await database.getSettingDao.save(SettingEntity( - sysJson: jsonEncode( - (SysSetting(sysType: SysType.CANTEEN, serviceId: '900') - ..merchantDeptId = '10') + sysJson: + jsonEncode((SysSetting(sysType: SysType.CANTEEN, serviceId: '900') + ..merchantDeptId = '10' + ..faceAppId = 'test-app' + ..faceSdkKey = 'test-sdk' + ..faceActiveKey = 'test-active') .toJson()), userJson: jsonEncode(UserSetting(username: '', password: '').toJson()), )); @@ -168,6 +172,20 @@ void main() { (call) async { final args = Map.from(call.arguments as Map); switch (call.method) { + case 'getFaceStatus': + statusCalls++; + final faces = await database.getFaceDao.findAll('-1', 1000, 0); + final ids = faces.map((face) => face.sampleId).toSet(); + return { + 'activated': true, + 'parametersComplete': true, + 'engineReady': true, + 'databaseCount': ids.length, + 'memoryCount': memory.length, + 'registrationInProgress': false, + 'libraryConsistent': + ids.length == memory.length && ids.containsAll(memory.keys), + }; case 'extractFaceFeature': extractionCalls++; if (rejectFacePhoto) { @@ -226,6 +244,7 @@ void main() { }); setUp(() async { + AppFaceTool.invalidatePaymentReadiness(); await database.database .execute('DROP TRIGGER IF EXISTS reject_face_delete'); await database.database @@ -235,7 +254,7 @@ void main() { remote.clear(); allRemote.clear(); imageRequests = extractionCalls = - upserts = metadataCalls = qualityReports = v2Requests = 0; + upserts = metadataCalls = statusCalls = qualityReports = v2Requests = 0; failUpsertOnce = failMetadataOnce = invalidSnapshot = changingAllSnapshot = false; rejectQualityReports = false; @@ -266,6 +285,53 @@ void main() { expect(row.updateTime, version); }); + test('准备阶段完成一次核对,连续支付直接复用且不再扫描原生库', () async { + await AppFaceTool.runLibraryMutation(() => seed('1')); + expect(statusCalls, 1); + for (var i = 0; i < 3; i++) { + final readiness = await AppFaceTool.getPaymentReadiness(); + expect(readiness.databaseFaceCount, 1); + expect(readiness.status!.ready, isTrue); + } + expect(statusCalls, 1); + }); + + test('同步成对增删保持数量不变时仍刷新,后续支付复用新结果', () async { + await seed('1'); + expect((await AppFaceTool.getPaymentReadiness()).status!.ready, isTrue); + remote.add(record('2')); + await sync(); + expect(memory.keys, ['2']); + expect(statusCalls, 2); + final readiness = await AppFaceTool.getPaymentReadiness(); + expect(readiness.status!.ready, isTrue); + expect(readiness.databaseFaceCount, 1); + expect(statusCalls, 2); + }); + + test('同步失败完成回滚后才缓存,后续支付不使用提交一半的状态', () async { + await seed('1'); + await AppFaceTool.getPaymentReadiness(); + remote.add(record('1', updated: version.add(const Duration(seconds: 1)))); + failUpsertOnce = true; + expect((await sync()).failed, 1); + expect(statusCalls, 2); + expect((await AppFaceTool.getPaymentReadiness()).status!.ready, isTrue); + expect(statusCalls, 2); + expect(memory['1']!['feature'], [1, 2, 3]); + }); + + test('激活配置发生变化时只重新核对一次', () async { + await seed('1'); + await AppFaceTool.getPaymentReadiness(); + final setting = await AppSettingTool.getSysSetting(); + setting.faceAppId = 'test-app-updated'; + await AppSettingTool.setSysSetting(setting); + await AppFaceTool.getPaymentReadiness(); + await AppFaceTool.getPaymentReadiness(); + expect(statusCalls, 2); + }); + test('照片变化只下载提取一次;下一次同步直接跳过', () async { final old = await seed('1'); remote.add(record('1', updated: version.add(const Duration(seconds: 1)))); diff --git a/test/payment_http_timing_test.dart b/test/payment_http_timing_test.dart new file mode 100644 index 0000000..9f30899 --- /dev/null +++ b/test/payment_http_timing_test.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart' as dio; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:logger/logger.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/common/utils/order/payment_timing_trace.dart'; +import 'package:sqflite/sqflite.dart' as sqlite; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _PaymentHttpTestApp extends AppControl { + @override + void onClose() {} +} + +/// All requests end here; the tests never open a network connection. +class _PaymentHttpAdapter implements dio.HttpClientAdapter { + _PaymentHttpAdapter(this.respond); + + final Future Function(dio.RequestOptions) respond; + final requests = []; + + @override + Future fetch( + dio.RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) { + requests.add(options); + return respond(options); + } + + @override + void close({bool force = false}) {} +} + +dio.ResponseBody _jsonResponse(Object data) => dio.ResponseBody.fromString( + jsonEncode(data), + 200, + headers: { + 'content-type': ['application/json'], + }, + ); + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + late Directory temporaryDirectory; + late AppDatabase database; + late dio.HttpClientAdapter originalAdapter; + late String originalBaseUrl; + final warningMessages = []; + void capture(LogEvent event) { + if (event.level == Level.warning && + event.message.toString().contains('|支付请求耗时|')) { + warningMessages.add(event.message.toString()); + } + } + + setUpAll(() async { + temporaryDirectory = + await Directory.systemTemp.createTemp('pj-payment-http-timing-'); + for (final channel in [ + 'plugins.flutter.io/path_provider', + 'external_path' + ]) { + binding.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(channel), + (_) async => temporaryDirectory.path, + ); + } + sqfliteFfiInit(); + sqlite.databaseFactory = databaseFactoryFfiNoIsolate; + database = await $FloorAppDatabase.inMemoryDatabaseBuilder().build(); + Get.put(_PaymentHttpTestApp()..dataBase = database); + await database.getSettingDao.save(SettingEntity( + sysJson: jsonEncode(SysSetting(sysType: SysType.CANTEEN).toJson()), + userJson: jsonEncode((UserSetting(username: '', password: '') + ..accessToken = 'test-auth-secret' + ..tenantId = '12') + .toJson()), + )); + await logger.init; + final client = HttpManage().dio; + originalAdapter = client.httpClientAdapter; + originalBaseUrl = client.options.baseUrl; + // Keep the configured gateway prefix, while using a deliberately fake host. + client.options.baseUrl = 'https://payment-test.invalid/api'; + Logger.addLogListener(capture); + }); + + setUp(warningMessages.clear); + + tearDownAll(() async { + Logger.removeLogListener(capture); + HttpManage().dio + ..httpClientAdapter = originalAdapter + ..options.baseUrl = originalBaseUrl; + await logger.close(); + await AppSettingTool.removeSettingCache(); + await database.close(); + Get.delete(force: true); + await temporaryDirectory.delete(recursive: true); + }); + + test('slow request keeps its result and reports only path and timing', + () async { + final adapter = _PaymentHttpAdapter((_) async { + await Future.delayed(const Duration(milliseconds: 1050)); + return _jsonResponse({ + 'code': 0, + 'data': {'message': 'response-secret'}, + }); + }); + HttpManage().dio.httpClientAdapter = adapter; + final trace = PaymentTimingTrace(id: 'test_payment', log: (_) {}); + addTearDown(() => trace.finish(outcome: 'failed')); + final response = await trace.run(() => HttpManage().dio.post( + '/hard/hard-api/ad/create-order', + queryParameters: {'qrcode': 'query-secret'}, + data: {'cardNo': 'body-secret'}, + )); + trace.finish(outcome: 'success'); + + expect(response.data, {'message': 'response-secret'}); + expect(adapter.requests, hasLength(1)); + expect(adapter.requests.single.headers['Authorization'], + 'Bearer test-auth-secret'); + expect(adapter.requests.single.headers['TENANT-ID'], '12'); + expect(warningMessages, hasLength(1)); + final message = warningMessages.single; + expect(message, contains('|trace:test_payment|')); + expect(message, contains('|path:/hard/hard-api/ad/create-order|')); + expect(message, contains('|result:success|')); + expect(message, contains('|settingsMs:')); + expect(message, contains('|prepareMs:')); + expect(message, contains('|waitMs:')); + expect(message, contains('|handlerMs:')); + final waitMs = + int.parse(RegExp(r'\|waitMs:(\d+)\|').firstMatch(message)!.group(1)!); + expect(waitMs, greaterThanOrEqualTo(1000)); + for (final secret in [ + 'test-auth-secret', + 'query-secret', + 'body-secret', + 'response-secret', + 'payment-test.invalid', + ]) { + expect(message, isNot(contains(secret))); + } + }); + + test('fast successful payment request does not add an HTTP warning', + () async { + final adapter = _PaymentHttpAdapter((_) async => _jsonResponse({ + 'code': 0, + 'data': [], + })); + HttpManage().dio.httpClientAdapter = adapter; + + final response = await HttpManage().dio.get( + '/wallet/wallet-setting/ad/get-discounts-list', + ); + + expect(response.data, isEmpty); + expect(adapter.requests, hasLength(1)); + expect(warningMessages, isEmpty); + }); + + test('business rejection is unchanged and emits one diagnostic', () async { + final adapter = _PaymentHttpAdapter((_) async => _jsonResponse({ + 'code': 912, + 'msg': 'private business rejection', + 'data': null, + })); + HttpManage().dio.httpClientAdapter = adapter; + + await expectLater( + HttpManage().dio.get( + '/wallet/wallet-setting/ad/get-discounts-list', + options: dio.Options(extra: {HttpManage.silentRequestKey: true}), + ), + throwsA(isA() + .having( + (error) => error.type, 'type', dio.DioExceptionType.badResponse) + .having((error) => error.error, 'error', 'private business rejection') + .having((error) => error.response?.statusCode, 'status', 200)), + ); + expect(adapter.requests, hasLength(1)); + expect(warningMessages, hasLength(1)); + expect(warningMessages.single, contains('|result:businessError|')); + expect(warningMessages.single, contains('|code:912|')); + expect( + warningMessages.single, isNot(contains('private business rejection'))); + }); + + test('transport error propagates once and unrelated routes are excluded', + () async { + final adapter = _PaymentHttpAdapter((options) async { + throw dio.DioException( + requestOptions: options, + type: dio.DioExceptionType.receiveTimeout, + message: 'private transport details', + ); + }); + HttpManage().dio.httpClientAdapter = adapter; + for (final path in [ + '/hard/hard-api/ad/get-user', + '/hard/hard-api/ad/unrelated-test', + ]) { + await expectLater( + HttpManage().dio.get( + path, + options: dio.Options(extra: {HttpManage.silentRequestKey: true}), + ), + throwsA(isA().having( + (error) => error.type, + 'type', + dio.DioExceptionType.receiveTimeout, + )), + ); + } + expect(adapter.requests, hasLength(2)); + expect(warningMessages, hasLength(1)); + expect(warningMessages.single, contains('|errorType:receiveTimeout|')); + expect( + warningMessages.single, isNot(contains('private transport details'))); + }); +} diff --git a/test/payment_timing_trace_test.dart b/test/payment_timing_trace_test.dart new file mode 100644 index 0000000..2541190 --- /dev/null +++ b/test/payment_timing_trace_test.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/common/utils/order/payment_timing_trace.dart'; + +void main() { + test('快速支付只输出一条包含全部阶段的汇总,重复结束不追加日志', () async { + final logs = []; + var elapsed = Duration.zero; + final trace = PaymentTimingTrace( + log: logs.add, + id: 'checkout-a', + elapsed: () => elapsed, + ); + final result = await trace.run(() async { + PaymentTimingTrace.setCurrentOrderCode('android_123_456'); + return trace.measure('discounts', () async { + elapsed = const Duration(milliseconds: 75); + return PaymentTimingTrace.measureCurrent('local_sql', () async { + elapsed = const Duration(milliseconds: 125); + return 'paid'; + }); + }); + }); + trace.finish(outcome: 'success'); + trace.finish(outcome: 'failed'); + + expect(result, 'paid'); + expect(logs, hasLength(1)); + expect(logs.single, contains('trace:checkout-a')); + expect(logs.single, contains('androidOrderCode:android_123_456')); + expect(logs.single, contains('outcome:success|totalMs:125')); + expect( + logs.single, contains('stagesInclusiveMs:discounts=125,local_sql=50')); + expect(logs.single, contains('pending:-|errorTypes:-')); + }); + + test('并发异步支付和嵌套追踪各自使用所在 Zone,不会串单', () async { + final firstLogs = []; + final secondLogs = []; + final first = PaymentTimingTrace(log: firstLogs.add, id: 'first'); + final second = PaymentTimingTrace(log: secondLogs.add, id: 'second'); + final firstReady = Completer(); + final secondReady = Completer(); + + await Future.wait([ + first.run(() async { + PaymentTimingTrace.setCurrentOrderCode('order_first'); + firstReady.complete(); + await secondReady.future; + expect(PaymentTimingTrace.current, same(first)); + await second.run(() async { + expect(PaymentTimingTrace.current, same(second)); + }); + expect(PaymentTimingTrace.current, same(first)); + await PaymentTimingTrace.measureCurrent('first_stage', () async {}); + }), + second.run(() async { + PaymentTimingTrace.setCurrentOrderCode('order_second'); + secondReady.complete(); + await firstReady.future; + expect(PaymentTimingTrace.current, same(second)); + await PaymentTimingTrace.measureCurrent('second_stage', () async {}); + }), + ]); + first.finish(outcome: 'success'); + second.finish(outcome: 'success'); + + expect(PaymentTimingTrace.current, isNull); + expect(firstLogs.single, contains('androidOrderCode:order_first')); + expect(firstLogs.single, contains('first_stage=')); + expect(firstLogs.single, isNot(contains('second_stage'))); + expect(secondLogs.single, contains('androidOrderCode:order_second')); + expect(secondLogs.single, contains('second_stage=')); + }); + + testWidgets('看门狗只报告一次,慢阶段结束后停止等待并输出实际耗时', (tester) async { + final logs = []; + var elapsed = Duration.zero; + final trace = PaymentTimingTrace(log: logs.add, elapsed: () => elapsed); + final source = Completer(); + final work = trace.measure('http_order', () => source.future); + + elapsed = const Duration(milliseconds: 1000); + await tester.pump(const Duration(seconds: 1)); + expect(logs, hasLength(1)); + expect( + logs.single, contains('event:pending|stage:http_order|elapsedMs:1000')); + elapsed = const Duration(milliseconds: 4500); + await tester.pump(const Duration(milliseconds: 3500)); + expect(logs, hasLength(1)); + source.complete(7); + expect(await work, 7); + trace.finish(outcome: 'success'); + await tester.pump(const Duration(seconds: 5)); + + expect(logs, hasLength(3)); + expect(logs[1], contains('event:stage|stage:http_order|costMs:4500')); + expect(logs[2], contains('totalMs:4500|stagesInclusiveMs:http_order=4500')); + }); + + testWidgets('结束追踪取消尚未完成阶段的看门狗,后续完成不再写日志', (tester) async { + final logs = []; + var elapsed = Duration.zero; + final trace = PaymentTimingTrace(log: logs.add, elapsed: () => elapsed); + final source = Completer(); + final work = trace.measure('pending_sql', () => source.future); + elapsed = const Duration(milliseconds: 150); + trace.finish(outcome: 'failed'); + await tester.pump(const Duration(seconds: 5)); + source.complete(); + await work; + + expect(logs, hasLength(1)); + expect(logs.single, contains('outcome:failed|totalMs:150')); + expect(logs.single, contains('pending:pending_sql')); + }); + + testWidgets('等待网络四点五秒但事件循环正常时,调度延迟保持零', (tester) async { + final logs = []; + var elapsed = Duration.zero; + final trace = PaymentTimingTrace(log: logs.add, elapsed: () => elapsed); + final source = Completer(); + final work = trace.measure('http_order', () => source.future); + for (var tick = 1; tick <= 18; tick++) { + elapsed = Duration(milliseconds: tick * 250); + await tester.pump(const Duration(milliseconds: 250)); + } + source.complete(); + await work; + trace.finish(outcome: 'offline_accepted'); + + expect(logs.last, contains('outcome:offline_accepted|totalMs:4500')); + expect(logs.last, contains('eventLoopLagMs:0')); + expect(logs.where((line) => line.contains('event:pending')), hasLength(1)); + }); + + testWidgets('同步阻塞结束后尚未派发探针时,结束追踪仍捕获最后一段调度延迟', (tester) async { + final logs = []; + var elapsed = Duration.zero; + final trace = PaymentTimingTrace(log: logs.add, elapsed: () => elapsed); + expect( + trace.measureSync('json_prepare', () { + elapsed = const Duration(milliseconds: 4500); + return 'paid'; + }), + 'paid', + ); + trace.finish(outcome: 'success'); + await tester.pump(const Duration(seconds: 5)); + + expect(logs, hasLength(2)); + expect(logs.last, contains('eventLoopLagMs:4250')); + expect(logs.where((line) => line.contains('event:pending')), isEmpty); + }); + + testWidgets('失败保持原异常对象和堆栈,不输出异常内容并取消看门狗', (tester) async { + final logs = []; + final trace = PaymentTimingTrace(log: logs.add); + final failure = StateError('secret-wallet-token'); + final originalStack = StackTrace.fromString('original-payment-stack'); + Object? caught; + StackTrace? caughtStack; + try { + await trace.measure( + 'http_order', () => Future.error(failure, originalStack)); + } catch (error, stack) { + caught = error; + caughtStack = stack; + } finally { + trace.finish(outcome: 'exception'); + } + await tester.pump(const Duration(seconds: 5)); + + expect(caught, same(failure)); + expect(caughtStack.toString(), originalStack.toString()); + expect(logs, hasLength(2)); + expect(logs.first, contains('errorType:StateError')); + expect(logs.last, contains('errorTypes:StateError')); + expect(logs.join(), isNot(contains('secret-wallet-token'))); + expect(logs.join(), isNot(contains('original-payment-stack'))); + }); + + test('日志故障、回退时钟和不安全元数据不能改变原业务返回值', () async { + var elapsed = const Duration(milliseconds: 200); + final logs = []; + final trace = PaymentTimingTrace( + log: (message) { + logs.add(message); + throw StateError('logger offline'); + }, + elapsed: () => elapsed, + id: 'invalid\nsecret', + ); + final result = await trace.run(() async { + PaymentTimingTrace.setCurrentOrderCode('invalid|token=secret'); + return trace.measure('first', () async { + elapsed = const Duration(milliseconds: 50); + return trace.measureSync('second', () => 42); + }); + }); + trace.finish(outcome: 'invalid\nsecret'); + + expect(result, 42); + expect(logs, hasLength(1)); + expect(logs.single, contains('totalMs:200')); + expect(logs.single, contains('first=0,second=0')); + expect(logs.single, contains('outcome:unknown')); + expect(logs.single, isNot(contains('secret'))); + }); + + test('没有追踪或追踪已经结束时按原样执行共享下单回调', () async { + expect(await PaymentTimingTrace.measureCurrent('unused', () async => 6), 6); + expect(PaymentTimingTrace.measureSyncCurrent('unused', () => 7), 7); + final logs = []; + final trace = PaymentTimingTrace(log: logs.add); + trace.finish(outcome: 'success'); + expect(await trace.measure('unused', () async => 8), 8); + expect(trace.measureSync('unused', () => 9), 9); + expect(logs, hasLength(1)); + }); +}