feat:添加下单日志

This commit is contained in:
dzc committed 2026-10-23 15:53:43 +08:00
1 parent e75b3bd58e
commit 531815517a
35 files changed
+2039 -220

No files matched your search

+1 -1
View File
@@ -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为版本号(内部比较)
```
@@ -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<String, Object> status = FlutterTool.getFaceStatus(context, appId, activeKey, sdkKey);
MAIN_HANDLER.post(() -> result.success(status));
@@ -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<String, Object> getFaceStatus(Context context, String appId, String activeKey, String sdkKey) {
long startedAt = SystemClock.elapsedRealtime();
updateConfig(appId, activeKey, sdkKey);
Map<String, Object> 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<FaceEntity> 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;
}
@@ -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<Integer> 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);
@@ -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<Long> 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;
}
}
}
@@ -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;
/**
* 产生特征提取失败示语的特征提取次数(小于该值不提示)
@@ -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() {
@@ -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<String> 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<String> 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<String> 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<String> 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);
}
}
@@ -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<String, Object> 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<String, Object> 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. */
+58
View File
@@ -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`。
尚未把本次改动安装到现场设备,未声称真机支付耗时或真人活体识别已验证。
+64
View File
@@ -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
中关闭,以覆盖优惠查询或本地准备步骤意外抛错的分支。
@@ -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,
@@ -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: '人脸识别服务连接异常,请重试',
+104 -2
View File
@@ -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<FacePaymentReadiness>();
// 仅在内存中比较配置,不输出激活参数,也不把就绪结果持久化到下一次启动。
static (bool, String, String, String)? _preparedConfiguration;
/// 正常支付复用 Load 或最近一次人脸变更后的完整核对结果。
/// 设置页诊断仍使用实时 [getStatus],异常和占线结果不会缓存。
static Future<FacePaymentReadiness> 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<AppControl>().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<T> runLibraryMutation<T>(Future<T> Function() operation) async {
_paymentReadiness.beginMutation();
try {
return await operation();
} finally {
_paymentReadiness.endMutation();
if (_paymentReadiness.mutationDepth == 0 &&
!AppRuntimeTool.useManualHardwareInput &&
Get.isRegistered<AppControl>()) {
try {
await getPaymentReadiness();
} catch (error) {
// 核对失败不覆盖原操作的返回值或异常;不缓存,支付仍可显式重试。
logger.w('|$_tag|人脸变更后准备状态刷新失败:${error.runtimeType}|');
}
}
}
}
/// 打开原生人脸识别页面;手动硬件模式或插件拒绝打开时返回 false。
static Future<bool> jumpFacePage() async {
if (AppRuntimeTool.useManualHardwareInput) return false;
@@ -43,6 +119,24 @@ abstract class AppFaceTool {
///
/// 任意插件、权限或样本异常都转换为警告结果,调用方可以继续完成终端启动。
static Future<FaceStartupPreparationResult> 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<FaceStartupPreparationResult> _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,
@@ -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(
@@ -0,0 +1,84 @@
import 'dart:async';
/// 缓存当前进程已确认的人脸就绪状态,并隔离库变更前后的异步查询。
///
/// 变更开始和结束都会使已有结果失效;变更期间允许查询,但不会缓存查询结果。
/// 调用方负责在修改人脸库、激活参数等就绪条件时通知本实例。
class FaceReadinessCache<T extends Object> {
T? _value;
int _generation = 0;
int _mutationDepth = 0;
Future<T>? _inFlight;
int? _inFlightGeneration;
T? get value => _value;
int get mutationDepth => _mutationDepth;
/// 复用已确认状态,或合并同一代次的并发查询。
///
/// 失效前发起的查询仍会向原调用方返回结果,但不能覆盖新代次的缓存或查询。
Future<T> read(
Future<T> Function() load, {
required bool Function(T) canCache,
}) {
final cached = _value;
if (cached != null) return Future<T>.value(cached);
final inFlight = _inFlight;
if (inFlight != null && _inFlightGeneration == _generation) {
return inFlight;
}
final completer = Completer<T>();
final generation = _generation;
// 先登记再调用 load,确保同步进入的其他调用也能复用本次查询。
_inFlight = completer.future;
_inFlightGeneration = generation;
unawaited(_resolve(load, canCache, generation, completer));
return completer.future;
}
Future<void> _resolve(
Future<T> Function() load,
bool Function(T) canCache,
int generation,
Completer<T> 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();
}
}
+116
View File
@@ -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<bool> _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;
}
}
@@ -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<T> run<T>(Future<T> 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<T> measureCurrent<T>(
String stage,
Future<T> Function() callback,
) =>
current?.measure(stage, callback) ?? callback();
/// 同步阶段同样只观察耗时和异常类型,不记录返回值。
static T measureSyncCurrent<T>(String stage, T Function() callback) {
final trace = current;
return trace == null ? callback() : trace.measureSync(stage, callback);
}
/// 慢阶段等待满阈值时只提醒一次;完成时仅慢阶段和失败阶段追加日志。
Future<T> measure<T>(String stage, Future<T> 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<T>(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 = <String, int>{};
final pending = <String>{};
final errorTypes = <String>{};
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;
}
+94 -89
View File
@@ -221,43 +221,45 @@ abstract class FaceDiscernPlugs {
required String appId,
required String sdkKey,
required String activeKey,
}) async {
try {
final map = await _channel.invokeMethod<dynamic>('activateFaceSdk', {
'appId': appId,
'sdkKey': sdkKey,
'activeKey': activeKey,
}) =>
AppFaceTool.runLibraryMutation(() async {
try {
final map = await _channel.invokeMethod<dynamic>('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<FaceRegistrationResult> rebuildFaceLibrary({
String? userId,
}) async {
final map = await _channel.invokeMethod<dynamic>('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<dynamic>('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<FaceFeatureExtractionResult> extractFaceFeature({
@@ -300,21 +302,22 @@ abstract class FaceDiscernPlugs {
String? userId,
String? username,
List<int>? featureData,
}) async {
final map = await _channel.invokeMethod<dynamic>('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<dynamic>('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<FaceBulkRemoveResult> removeFaces({
required List<String> sampleIds,
}) async {
final failed = <String>[];
final nativeIds = <int>[];
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<dynamic>('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 = <String>[];
final nativeIds = <int>[];
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<dynamic>('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<bool> removeFace({
required String sampleId,
}) async {
final sampleIdInt = int.tryParse(sampleId);
final pa = {
"sampleId": sampleIdInt,
};
final res = await _channel.invokeMethod<bool?>('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<bool?>('removeFace', pa);
if (res == null) return false;
return res;
});
/// 姓名或人员绑定变化时只刷新内存档案,保留已注册的特征。
static Future<bool> updateFaceMetadata(List<FaceEntity> faces) async {
+17 -7
View File
@@ -57,7 +57,10 @@ abstract class SyncFaceTool {
/// 比对,会让收银弹框反复判定库损坏并进入就地注册引导。整库装载已先
/// 尝试从本地图片补回特征,这里只清理最终仍无特征的行。
/// 删除顺序仍先处理虹软内存库;单条失败时保留记录供后续权威快照重试。
static Future<int> delEmptyFace() async {
static Future<int> delEmptyFace() =>
AppFaceTool.runLibraryMutation(_delEmptyFace);
static Future<int> _delEmptyFace() async {
final faceDao = Get.find<AppControl>().dataBase.getFaceDao;
// 只回传残缺行;正常启动无需把数千条完整档案再传回 Dart 逐条筛选。
final profiles = await faceDao.getEmptyFaceList() ?? const <FaceEntity>[];
@@ -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<bool> deleteLocalFace(FaceEntity face) =>
_deleteLocalFace(face, Get.find<AppControl>().dataBase.getFaceDao);
AppFaceTool.runLibraryMutation(
() =>
_deleteLocalFace(face, Get.find<AppControl>().dataBase.getFaceDao),
);
/// 按批量管线清理一组本地人脸(不触碰服务端数据),返回成功删除数。
///
@@ -847,10 +855,12 @@ abstract class SyncFaceTool {
List<FaceEntity> faces, {
void Function(int processed, int? total)? onProgress,
}) async {
final result = await _deleteFacesInBulk(
faces,
Get.find<AppControl>().dataBase.getFaceDao,
onProgress: onProgress,
final result = await AppFaceTool.runLibraryMutation(
() => _deleteFacesInBulk(
faces,
Get.find<AppControl>().dataBase.getFaceDao,
onProgress: onProgress,
),
);
return result.deleted;
}
@@ -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<FaceStream> 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<FaceRecognitionPanel> {
}
}
/// 等待服务层的幂等结果,冻结三秒后结束原生会话并通知宿主。
/// 收到幂等结果后立即结束原生会话并通知宿主,不额外等待成功画面展示。
Future<void> _waitForResult() async {
try {
final result = await widget.handle.result;
@@ -193,12 +186,10 @@ class _FaceRecognitionPanelState extends State<FaceRecognitionPanel> {
if (_event?.status != FaceRecognitionSessionStatus.success) {
setState(() => _failure = null);
}
await Future<void>.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) {
@@ -65,7 +65,7 @@ class _FaceRecognitionPreviewDialogState
if (mounted) Navigator.of(context).pop();
}
/// 返回唯一识别结果;面板已经完成成功冻结和会话释放。
/// 返回唯一识别结果;面板已经完成会话释放。
void _recognized(FaceStream result) {
if (_closing || !mounted) return;
_closing = true;
+62 -35
View File
@@ -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<T?> _createApiOrder<T>() 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<Map<String, dynamic>> shopList = [];
@@ -358,12 +362,17 @@ mixin OrderMixin {
var evidenceMetadataList = const <Map<String, dynamic>>[];
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: "结算订单失败");
@@ -754,7 +754,7 @@ class SelectPayTypeDialogController extends GetxController {
return requestId == _faceDispatchRequestId && isRecognition.value;
}
/// 检查数据库与 SDK 状态后创建唯一人脸会话;准备期间同样锁定重复点击和关闭流程。
/// 复用 Load/人脸变更后核对结果创建会话;准备期间锁定重复点击和关闭流程。
Future<void> _dispatchFace() async {
if (isRecognition.value) {
Toast.error(msg: "正在识别中,请勿重复点击");
@@ -769,11 +769,11 @@ class SelectPayTypeDialogController extends GetxController {
isRecognition.value = true;
try {
final service = Get.find<FaceRecognitionSessionService>();
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<void>.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('无法读取人脸状态,请稍后重试');
+39 -13
View File
@@ -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)) {
@@ -426,7 +426,10 @@ class FaceManageController extends GetxController {
}
/// 按服务端 500 条上限分批删除,并仅清理服务端已确认的本地样本。
Future<void> _deleteServerBackedFaces(List<FaceEntity> faces) async {
Future<void> _deleteServerBackedFaces(List<FaceEntity> faces) =>
AppFaceTool.runLibraryMutation(() => _deleteServerBackedFacesInBatch(faces));
Future<void> _deleteServerBackedFacesInBatch(List<FaceEntity> 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) {
@@ -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;
@@ -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,
+1 -1
View File
@@ -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
+171
View File
@@ -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<String>();
var calls = 0;
Future<String> 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<String>();
final source = Completer<String>();
var calls = 0;
Future<String> 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<List<int>>();
var sampleIds = <int>[1, 2];
var calls = 0;
Future<List<int>> 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<String>();
final oldSource = Completer<String>();
final newSource = Completer<String>();
var calls = 0;
Future<String> 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<String>();
final oldSource = Completer<String>();
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<String>();
var calls = 0;
Future<String> 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<String>();
cache.beginMutation();
final source = Completer<String>();
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<String>();
var calls = 0;
Future<String> 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<bool>();
var calls = 0;
Future<bool> 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);
});
}
@@ -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 <num>[1, 5.5, 100]) {
test('接受已保存占比、百分比边界和小数,异常读取值回退 3% 避免阻断设置页', () {
for (final percent in <num>[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');
}
});
}
+258
View File
@@ -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<void>? finishCompletion;
final result = Completer<FaceStream>();
bool _ownsSession = true;
int finishCalls = 0;
int cancelCalls = 0;
FaceRecognitionSessionHandle get handle => FaceRecognitionSessionHandle(
sessionId: _sessionId,
scene: FaceRecognitionScene.cashier,
events: const Stream<FaceRecognitionSessionEvent>.empty(),
result: result.future,
);
@override
bool owns(String? sessionId) => _ownsSession && sessionId == _sessionId;
@override
Future<void> finish() async {
finishCalls++;
await finishCompletion?.future;
_ownsSession = false;
}
@override
Future<void> cancel({String message = '已取消人脸识别'}) async {
cancelCalls++;
_ownsSession = false;
}
}
Widget _harness({
required FakeFaceRecognitionSessionService service,
required ValueChanged<FaceStream> 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 = <FaceStream>[];
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<void>();
final service = FakeFaceRecognitionSessionService(
finishCompletion: finishCompletion,
);
final recognized = <FaceStream>[];
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<void>();
final service = FakeFaceRecognitionSessionService(
finishCompletion: finishCompletion,
);
final recognized = <FaceStream>[];
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 = <FaceStream>[];
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<void>();
final service = FakeFaceRecognitionSessionService(
finishCompletion: finishCompletion,
);
final controller = FaceRecognitionPanelController();
final recognized = <FaceStream>[];
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);
});
}
+70 -4
View File
@@ -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<AppControl>(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<String, dynamic>.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))));
+232
View File
@@ -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<dio.ResponseBody> Function(dio.RequestOptions) respond;
final requests = <dio.RequestOptions>[];
@override
Future<dio.ResponseBody> fetch(
dio.RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? 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 = <String>[];
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<AppControl>(_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<AppControl>(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<void>.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<dynamic>(
'/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': <Object>[],
}));
HttpManage().dio.httpClientAdapter = adapter;
final response = await HttpManage().dio.get<dynamic>(
'/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<dynamic>(
'/wallet/wallet-setting/ad/get-discounts-list',
options: dio.Options(extra: {HttpManage.silentRequestKey: true}),
),
throwsA(isA<dio.DioException>()
.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<dynamic>(
path,
options: dio.Options(extra: {HttpManage.silentRequestKey: true}),
),
throwsA(isA<dio.DioException>().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')));
});
}
+222
View File
@@ -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 = <String>[];
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 = <String>[];
final secondLogs = <String>[];
final first = PaymentTimingTrace(log: firstLogs.add, id: 'first');
final second = PaymentTimingTrace(log: secondLogs.add, id: 'second');
final firstReady = Completer<void>();
final secondReady = Completer<void>();
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 = <String>[];
var elapsed = Duration.zero;
final trace = PaymentTimingTrace(log: logs.add, elapsed: () => elapsed);
final source = Completer<int>();
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 = <String>[];
var elapsed = Duration.zero;
final trace = PaymentTimingTrace(log: logs.add, elapsed: () => elapsed);
final source = Completer<void>();
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 = <String>[];
var elapsed = Duration.zero;
final trace = PaymentTimingTrace(log: logs.add, elapsed: () => elapsed);
final source = Completer<void>();
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 = <String>[];
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 = <String>[];
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<void>.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 = <String>[];
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 = <String>[];
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));
});
}