feat:添加下单日志
This commit is contained in:
1 parent
e75b3bd58e
commit
531815517a
35 files changed
+2039
-220
No files matched your search
@@ -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为版本号(内部比较)
|
||||
```
|
||||
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+21
-17
@@ -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);
|
||||
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
/**
|
||||
* 产生特征提取失败示语的特征提取次数(小于该值不提示)
|
||||
|
||||
+7
-4
@@ -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() {
|
||||
|
||||
+79
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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. */
|
||||
|
||||
@@ -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`。
|
||||
|
||||
尚未把本次改动安装到现场设备,未声称真机支付耗时或真人活体识别已验证。
|
||||
@@ -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: '人脸识别服务连接异常,请重试',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('无法读取人脸状态,请稍后重试');
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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))));
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user