diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java index 9fe7997..5f537ae 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java @@ -16,7 +16,7 @@ public class DatabaseHelper extends SQLiteOpenHelper { private static final String DATABASE_PATH = "pj_cashier/database"; private static final String DATABASE_NAME = "cash_register.db"; /** 与 Flutter Floor 的 @Database 版本保持一致,避免原生层提前推进 user_version。 */ - private static final int DATABASE_VERSION = 9; + private static final int DATABASE_VERSION = 10; public DatabaseHelper(Context context) { super(context, getCustomDatabasePath(context), null, DATABASE_VERSION); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java index de52c6e..152e28a 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java @@ -12,8 +12,10 @@ import androidx.annotation.NonNull; import java.util.List; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -225,6 +227,20 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me case "hasCamera": result.success(sessionManager.hasCamera()); break; + case "cleanupOrderEvidenceFiles": + List rawReferencedPaths = call.argument("referencedPaths"); + Number olderThan = call.argument("olderThan"); + Set referencedPaths = new HashSet<>(); + if (rawReferencedPaths != null) { + for (Object path : rawReferencedPaths) { + if (path instanceof String && !((String) path).trim().isEmpty()) { + referencedPaths.add((String) path); + } + } + } + result.success(sessionManager.cleanupOrderEvidenceFiles( + referencedPaths, olderThan == null ? 0L : olderThan.longValue())); + break; case "getRecognizeConfiguration": result.success(RecognizeConfigurationStore.toMap(RecognizeConfigurationStore.load(context))); break; diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceEvidenceImageWriter.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceEvidenceImageWriter.java new file mode 100644 index 0000000..41331ad --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceEvidenceImageWriter.java @@ -0,0 +1,251 @@ +package io.flutter.plugins.facediscern.session; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.YuvImage; +import android.graphics.ImageFormat; + +import com.arcsoft.face.FaceInfo; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; + +/** + * 将人脸比对成功帧裁剪为终端私有 JPEG 证据。 + * + *

裁剪使用虹软实际参与比对的人脸框并外扩约 20%,随后补偿相机方向和镜像,最长边 + * 限制为 640 像素,质量逐级降低到约 100KB。文件写入使用临时文件改名,避免任务队列 + * 看到半个 JPEG。

+ */ +public final class FaceEvidenceImageWriter { + /** 本地现场图最长边上限,单位为像素。 */ + private static final int MAX_EDGE = 640; + + /** 本地现场图目标大小,单位为字节。 */ + private static final int TARGET_BYTES = 100 * 1024; + + /** 人脸框外扩比例。 */ + private static final float PADDING_RATIO = 0.20f; + + /** 创建人脸证据写入器。 */ + private FaceEvidenceImageWriter() { + } + + /** + * 写入一张成功匹配目标的人脸现场图。 + * + * @param contextFilesDir 应用私有 files 目录 + * @param snapshot 实际提取特征所用的帧和目标框 + * @param rotation 相机预览方向,取 0/90/180/270 + * @param mirror 是否需要水平镜像 + * @param trackId 虹软跟踪编号,仅用于生成不含业务信息的文件名 + * @return 图片写入结果;失败时包含安全错误摘要且路径为空 + */ + public static Result write(File contextFilesDir, RecognitionFrameSnapshot snapshot, int rotation, + boolean mirror, int trackId) { + try { + if (snapshot == null || snapshot.getNv21() == null || snapshot.getFaceInfo() == null) { + return Result.failure(snapshot == null ? 0L : snapshot.getCapturedAt(), "成功帧快照为空"); + } + byte[] nv21 = snapshot.getNv21(); + FaceInfo faceInfo = snapshot.getFaceInfo(); + Rect crop = expandAndClamp(faceInfo.getRect(), snapshot.getWidth(), snapshot.getHeight()); + if (crop.width() < 2 || crop.height() < 2) return Result.failure(snapshot.getCapturedAt(), "人脸框无效"); + ByteArrayOutputStream rawJpeg = new ByteArrayOutputStream(); + YuvImage yuv = new YuvImage(nv21, ImageFormat.NV21, snapshot.getWidth(), snapshot.getHeight(), null); + if (!yuv.compressToJpeg(crop, 92, rawJpeg)) return Result.failure(snapshot.getCapturedAt(), "NV21 转图片失败"); + Bitmap bitmap = BitmapFactory.decodeByteArray(rawJpeg.toByteArray(), 0, rawJpeg.size()); + if (bitmap == null) return Result.failure(snapshot.getCapturedAt(), "图片解码失败"); + Bitmap oriented = orient(bitmap, rotation, mirror); + Bitmap scaled = scale(oriented, MAX_EDGE); + if (oriented != bitmap) bitmap.recycle(); + if (scaled != oriented) oriented.recycle(); + + byte[] encoded = compress(scaled); + scaled.recycle(); + if (encoded == null) return Result.failure(snapshot.getCapturedAt(), "JPEG 压缩失败"); + File directory = new File(contextFilesDir, "order_evidence"); + if (!directory.exists() && !directory.mkdirs()) return Result.failure(snapshot.getCapturedAt(), "证据目录不可用"); + File target = new File(directory, UUID.randomUUID().toString() + "_" + trackId + ".jpg"); + File temporary = new File(directory, "." + target.getName() + ".tmp"); + try (FileOutputStream output = new FileOutputStream(temporary)) { + output.write(encoded); + output.getFD().sync(); + } + if (!temporary.renameTo(target)) { + // Android 部分文件系统不支持跨目录原子改名,当前目录内失败时直接返回并清理。 + temporary.delete(); + return Result.failure(snapshot.getCapturedAt(), "图片文件写入失败"); + } + return Result.success(target.getAbsolutePath(), sha256(encoded), encoded.length, + scaledWidth(encoded), scaledHeight(encoded), snapshot.getCapturedAt()); + } catch (Exception error) { + return Result.failure(snapshot == null ? 0L : snapshot.getCapturedAt(), "现场图片生成失败"); + } + } + + /** + * 清理原生写图后未能写入 Floor 队列的超时文件。 + * + * @param contextFilesDir 应用私有 files 目录 + * @param referencedPaths Floor 队列仍引用的绝对路径,这些文件不受 24 小时无主清理影响 + * @param olderThanMillis 仅清理最后修改时间早于该 Unix 毫秒时间点的文件 + * @return 实际成功删除的文件数量 + */ + public static int cleanupOrphans(File contextFilesDir, Set referencedPaths, long olderThanMillis) { + File directory = new File(contextFilesDir, "order_evidence"); + File[] files = directory.listFiles(); + if (files == null || files.length == 0) return 0; + Set protectedPaths = canonicalPaths(referencedPaths); + int deleted = 0; + for (File file : files) { + if (!file.isFile() || file.lastModified() <= 0L || file.lastModified() > olderThanMillis) continue; + String name = file.getName().toLowerCase(Locale.ROOT); + boolean temporary = name.startsWith(".") && name.endsWith(".tmp"); + boolean jpeg = name.endsWith(".jpg") || name.endsWith(".jpeg"); + if (!temporary && !jpeg) continue; + if (jpeg && protectedPaths.contains(canonicalPath(file))) continue; + if (file.delete()) deleted++; + } + return deleted; + } + + /** 将 Floor 传入的路径统一为规范路径,避免路径表示差异误删仍在补传的文件。 */ + private static Set canonicalPaths(Set paths) { + Set result = new HashSet<>(); + if (paths == null) return result; + for (String path : paths) { + if (path != null && !path.trim().isEmpty()) result.add(canonicalPath(new File(path))); + } + return result; + } + + /** 返回文件规范路径;系统拒绝解析时退回绝对路径用于保守比对。 */ + private static String canonicalPath(File file) { + try { + return file.getCanonicalPath(); + } catch (Exception ignored) { + return file.getAbsolutePath(); + } + } + + /** 外扩人脸框并限制在 NV21 有效区域内,坐标调整为偶数以适配 UV 平面。 */ + private static Rect expandAndClamp(Rect source, int width, int height) { + int paddingX = Math.round(source.width() * PADDING_RATIO); + int paddingY = Math.round(source.height() * PADDING_RATIO); + int left = Math.max(0, source.left - paddingX); + int top = Math.max(0, source.top - paddingY); + int right = Math.min(width, source.right + paddingX); + int bottom = Math.min(height, source.bottom + paddingY); + left &= ~1; + top &= ~1; + right = Math.min(width, right & ~1); + bottom = Math.min(height, bottom & ~1); + return new Rect(left, top, right, bottom); + } + + /** 应用方向和前置镜像补偿。 */ + private static Bitmap orient(Bitmap source, int rotation, boolean mirror) { + int normalizedRotation = ((rotation % 360) + 360) % 360; + if (normalizedRotation == 0 && !mirror) return source; + Matrix matrix = new Matrix(); + if (normalizedRotation != 0) matrix.postRotate(normalizedRotation); + if (mirror) matrix.postScale(-1f, 1f); + return Bitmap.createBitmap(source, 0, 0, source.getWidth(), source.getHeight(), matrix, true); + } + + /** 按最长边等比缩小,避免上传大图。 */ + private static Bitmap scale(Bitmap source, int maxEdge) { + int edge = Math.max(source.getWidth(), source.getHeight()); + if (edge <= maxEdge) return source; + float ratio = maxEdge / (float) edge; + return Bitmap.createScaledBitmap(source, Math.round(source.getWidth() * ratio), + Math.round(source.getHeight() * ratio), true); + } + + /** 逐级压缩到约 100KB;缩放后仍过大时保留最低质量结果供服务端校验。 */ + private static byte[] compress(Bitmap bitmap) { + for (int quality : new int[]{88, 78, 68, 58, 48, 40}) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, output); + if (output.size() <= TARGET_BYTES || quality == 40) return output.toByteArray(); + } + return null; + } + + /** 读取压缩图片宽度;失败时返回 0,避免再次持有 Bitmap。 */ + private static int scaledWidth(byte[] jpeg) { + Bitmap bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length); + if (bitmap == null) return 0; + int width = bitmap.getWidth(); + bitmap.recycle(); + return width; + } + + /** 读取压缩图片高度;失败时返回 0,避免再次持有 Bitmap。 */ + private static int scaledHeight(byte[] jpeg) { + Bitmap bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length); + if (bitmap == null) return 0; + int height = bitmap.getHeight(); + bitmap.recycle(); + return height; + } + + /** 计算现场 JPEG 的 SHA-256。 */ + private static String sha256(byte[] content) throws NoSuchAlgorithmException { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(content); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte value : digest) result.append(String.format(Locale.ROOT, "%02x", value)); + return result.toString(); + } + + /** 单张现场图写入结果。 */ + public static final class Result { + /** 私有文件路径,失败时为空。 */ + public final String filePath; + /** 文件 SHA-256,失败时为空。 */ + public final String hash; + /** 文件大小,单位字节。 */ + public final int size; + /** 图片宽度,单位像素。 */ + public final int width; + /** 图片高度,单位像素。 */ + public final int height; + /** 原始帧采集时间,单位 Unix 毫秒。 */ + public final long capturedAt; + /** 安全失败摘要,成功时为空。 */ + public final String error; + + /** 创建成功或失败结果;成功时文件字段完整,失败时仅保留采集时间和安全摘要。 */ + private Result(String filePath, String hash, int size, int width, int height, long capturedAt, String error) { + this.filePath = filePath; + this.hash = hash; + this.size = size; + this.width = width; + this.height = height; + this.capturedAt = capturedAt; + this.error = error; + } + + /** 创建成功结果。 */ + public static Result success(String filePath, String hash, int size, int width, int height, long capturedAt) { + return new Result(filePath, hash, size, width, height, capturedAt, null); + } + + /** 创建失败结果。 */ + public static Result failure(long capturedAt, String error) { + return new Result(null, null, 0, 0, 0, capturedAt, error); + } + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java index 92ad0f1..678a997 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java @@ -24,6 +24,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -31,6 +32,7 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicBoolean; import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; +import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; import io.flutter.plugins.facediscern.tool.face.constants.RequestFeatureStatus; import io.flutter.plugins.facediscern.tool.face.constants.RequestLivenessStatus; import io.flutter.plugins.facediscern.tool.camera.config.PreviewConfig; @@ -165,6 +167,17 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba } } + /** + * 清理超过保留截止时间且没有被 Flutter 本地队列引用的现场图。 + * + * @param referencedPaths Floor 任务仍持有的现场图绝对路径 + * @param olderThanMillis 无主文件清理截止时间,单位 Unix 毫秒 + * @return 成功删除数量 + */ + public int cleanupOrderEvidenceFiles(Set referencedPaths, long olderThanMillis) { + return FaceEvidenceImageWriter.cleanupOrphans(context.getFilesDir(), referencedPaths, olderThanMillis); + } + public Map createPreviewTexture(TextureRegistry textureRegistry) throws Exception { TextureRegistry.SurfaceTextureEntry entry = textureRegistry.createSurfaceTexture(); FacePreviewRenderer renderer; @@ -779,7 +792,21 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba return "unknown"; } - private void onRecognized(HashMap rawResult, long callbackGeneration) { + /** + * 冻结当前成功会话并在单线程识别执行器中生成现场证据,完成后才向会话所有者回传结果。 + * + * @param rawResult 虹软返回的命中用户和相似度字段,不含原始帧 + * @param callbackGeneration 回调所属会话代次,用于拒绝迟到结果 + * @param frameSnapshot 实际命中目标的 NV21 与人脸框,非收银兼容链路允许为空 + */ + private void onRecognized(HashMap rawResult, long callbackGeneration, + RecognitionFrameSnapshot frameSnapshot) { + final String resultSessionId; + final String resultScene; + final double resultThreshold; + final int resultRotation; + final boolean resultMirror; + final Listener resultOwner; synchronized (sessionLock) { if (callbackGeneration != sessionGeneration || state != State.DETECTING || activeSessionId == null) return; state = State.SUCCESS; @@ -793,10 +820,44 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba // 只冻结预览输出,不释放 Camera 热资源;成功框和相似度保留至少 3 秒供顾客确认。 if (previewRenderer != null) previewRenderer.setFrozen(true); emitState("识别成功,正在读取信息", successBoxes, null); - String json = new Gson().toJson(rawResult); - Listener owner = activeOwner; - if (owner != null) owner.onRecognitionResult(json); + resultSessionId = activeSessionId; + resultScene = activeScene; + resultThreshold = processor == null ? 0.8d : (double) processor.getSimilarityThreshold(); + resultRotation = displayOrientation; + resultMirror = mirror; + resultOwner = activeOwner; } + rawResult.put("sessionId", resultSessionId); + rawResult.put("similarityThreshold", resultThreshold); + recognitionExecutor.execute(() -> { + FaceEvidenceImageWriter.Result evidence = null; + // 只有收银支付场景才保存长期证据候选;预约查询和身份核验不得创建现场证据。 + if ("cashier".equals(resultScene)) { + evidence = FaceEvidenceImageWriter.write( + context.getFilesDir(), frameSnapshot, resultRotation, resultMirror, + frameSnapshot == null || frameSnapshot.getFaceInfo() == null + ? 0 : frameSnapshot.getFaceInfo().getFaceId()); + rawResult.put("capturedAt", evidence.capturedAt); + if (evidence.filePath != null) { + rawResult.put("evidenceFilePath", evidence.filePath); + rawResult.put("evidenceHash", evidence.hash); + rawResult.put("evidenceSize", evidence.size); + rawResult.put("evidenceWidth", evidence.width); + rawResult.put("evidenceHeight", evidence.height); + } else { + rawResult.put("evidenceError", evidence.error == null ? "现场图片生成失败" : evidence.error); + } + } + synchronized (sessionLock) { + if (callbackGeneration != sessionGeneration + || !Objects.equals(resultSessionId, activeSessionId) + || state != State.SUCCESS) { + if (evidence != null && evidence.filePath != null) new java.io.File(evidence.filePath).delete(); + return; + } + } + if (resultOwner != null) resultOwner.onRecognitionResult(new Gson().toJson(rawResult)); + }); } private void handleFatalFailure(String message, String errorCode, long expectedGeneration) { diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java index dd422d4..f90b3ba 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java @@ -33,6 +33,7 @@ import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration; import io.flutter.plugins.facediscern.tool.face.model.RecognizeInfo; +import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -77,6 +78,9 @@ public class FaceHelper implements FaceListener { */ private ConcurrentHashMap recognizeInfoMap = new ConcurrentHashMap<>(); + /** 每个跟踪目标最近一次特征提取所用的帧快照,仅在当前会话内短暂持有。 */ + private ConcurrentHashMap recognitionFrameMap = new ConcurrentHashMap<>(); + private CompositeDisposable getFeatureDelayedDisposables = new CompositeDisposable(); private CompositeDisposable delayFaceTaskCompositeDisposable = new CompositeDisposable(); /** @@ -311,6 +315,9 @@ public class FaceHelper implements FaceListener { if (recognizeInfoMap != null) { recognizeInfoMap.clear(); } + if (recognitionFrameMap != null) { + recognitionFrameMap.clear(); + } if (facePreviewInfoList != null) { facePreviewInfoList.clear(); } @@ -433,6 +440,7 @@ public class FaceHelper implements FaceListener { } if (!contained) { RecognizeInfo recognizeInfo = recognizeInfoMap.remove(key); + recognitionFrameMap.remove(key); if (recognizeInfo != null) { recognizeCallback.onNoticeChanged(""); // 人脸离开时,通知特征提取线程,避免一直等待活体结果 @@ -770,7 +778,8 @@ public class FaceHelper implements FaceListener { // 相似度属于当前跟踪人脸,先保存再判断阈值,预览框才能展示真实的最近一次比对结果。 setSimilarity(trackId, compareResult.getSimilar()); boolean pass = compareResult.getSimilar() > recognizeConfiguration.getSimilarThreshold(); - recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass); + recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass, + recognitionFrameMap.get(trackId)); if (pass) { String msg = "识别通过"; Log.i(TAG, "用户名字:" + compareResult.getFaceEntity().getUserName() + ";人脸id:" + compareResult.getFaceEntity().getFaceId() + ";"); @@ -827,6 +836,8 @@ public class FaceHelper implements FaceListener { this.format = format; this.trackId = facePreviewInfo.getTrackId(); this.isMask = facePreviewInfo.getMask(); + recognitionFrameMap.put(this.trackId, new RecognitionFrameSnapshot(this.nv21Data, this.faceInfo, + this.width, this.height, System.currentTimeMillis())); } @Override diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java index ebda709..3560be1 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java @@ -2,6 +2,7 @@ package io.flutter.plugins.facediscern.tool.face.help.callback; import io.flutter.plugins.facediscern.tool.face.model.CompareResult; +import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; public interface RecognizeCallback { /** @@ -10,8 +11,10 @@ public interface RecognizeCallback { * @param compareResult 比对结果 * @param liveness 活体值 * @param similarPass 是否通过(依据设置的阈值) + * @param frameSnapshot 实际提取特征的 NV21 和目标人脸框;旧 Activity 链路允许为空 */ - void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass); + void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass, + RecognitionFrameSnapshot frameSnapshot); /** * 提示文字变更的回调 diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognitionFrameSnapshot.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognitionFrameSnapshot.java new file mode 100644 index 0000000..d83d4f1 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognitionFrameSnapshot.java @@ -0,0 +1,62 @@ +package io.flutter.plugins.facediscern.tool.face.model; + +import com.arcsoft.face.FaceInfo; + +import java.util.Arrays; + +/** + * 人脸特征提取使用的可见光帧快照。 + * + *

Camera1 会复用回调缓冲区,因此这里必须复制 NV21 和 FaceInfo,保证异步比对成功后 + * 仍然能裁剪出同一张实际命中目标的人脸图片。

+ */ +public final class RecognitionFrameSnapshot { + /** 原始 NV21 帧的独立副本。 */ + private final byte[] nv21; + + /** 该帧中实际参与特征提取的人脸框。 */ + private final FaceInfo faceInfo; + + /** NV21 原始宽度,单位为像素。 */ + private final int width; + + /** NV21 原始高度,单位为像素。 */ + private final int height; + + /** 相机回调采集时间,单位为 Unix 毫秒。 */ + private final long capturedAt; + + /** 创建一份不可变的人脸成功帧快照。 */ + public RecognitionFrameSnapshot(byte[] nv21, FaceInfo faceInfo, int width, int height, long capturedAt) { + this.nv21 = nv21 == null ? null : Arrays.copyOf(nv21, nv21.length); + this.faceInfo = faceInfo == null ? null : new FaceInfo(faceInfo); + this.width = width; + this.height = height; + this.capturedAt = capturedAt; + } + + /** 返回 NV21 快照副本,避免调用方修改共享内容。 */ + public byte[] getNv21() { + return nv21 == null ? null : Arrays.copyOf(nv21, nv21.length); + } + + /** 返回实际命中人脸框的独立副本。 */ + public FaceInfo getFaceInfo() { + return faceInfo == null ? null : new FaceInfo(faceInfo); + } + + /** 返回 NV21 原始宽度。 */ + public int getWidth() { + return width; + } + + /** 返回 NV21 原始高度。 */ + public int getHeight() { + return height; + } + + /** 返回相机帧采集时间。 */ + public long getCapturedAt() { + return capturedAt; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java index 2face8c..252fcf6 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java @@ -37,6 +37,7 @@ import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration; import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfigurationStore; +import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; import io.flutter.plugins.facediscern.tool.face.widget.FaceRectView; import io.flutter.plugins.facediscern.ui.FaceDiscern; @@ -122,7 +123,14 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback * 旧 Activity 不设置该监听时仍保持原有广播行为,避免破坏历史入口。 */ public interface RecognitionListener { - void onRecognized(HashMap result, long generation); + /** + * 返回当前会话唯一识别结果及实际成功帧,Manager 会在代次校验后生成现场证据。 + * + * @param result 不含原始帧的识别用户与相似度结果 + * @param generation 识别会话代次,旧 Activity 兼容链路使用 0 + * @param frameSnapshot 实际参与成功比对的帧;旧 Activity 兼容链路允许为空 + */ + void onRecognized(HashMap result, long generation, RecognitionFrameSnapshot frameSnapshot); } public void setRecognitionListener(RecognitionListener recognitionListener) { @@ -399,7 +407,8 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback // 识别结果回调 @Override - public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass) { + public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass, + RecognitionFrameSnapshot frameSnapshot) { try { if (!similarPass) return; HashMap json = new HashMap<>(); @@ -431,7 +440,7 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback if (recognitionListener != null) { HashMap result = new HashMap<>(faceDiscern); faceDiscern = null; - recognitionListener.onRecognized(result, 0L); + recognitionListener.onRecognized(result, 0L, null); return; } flutterFaceCallbackIntent.putExtra("json", new Gson().toJson(faceDiscern)); @@ -466,17 +475,23 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback /** 会话专用回调只保存本代识别结果,避免共享字段被旧异步任务覆盖。 */ private final class SessionRecognizeCallback implements RecognizeCallback { + /** 创建该回调时绑定的会话代次,旧代异步结果不得进入新会话。 */ private final long generation; + + /** 当前代已经通过阈值的人脸用户和相似度结果,通知成功后立即清空。 */ private HashMap result; + /** 创建只服务指定会话代次的回调。 */ private SessionRecognizeCallback(long generation) { this.generation = generation; } @Override - public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass) { + public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass, + RecognitionFrameSnapshot frameSnapshot) { if (!similarPass) return; result = buildRecognitionResult(compareResult); + pendingFrameSnapshot = frameSnapshot; } @Override @@ -484,7 +499,11 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback if (!Objects.equals(notice, "") || result == null || recognitionListener == null) return; HashMap current = new HashMap<>(result); result = null; - recognitionListener.onRecognized(current, generation); + recognitionListener.onRecognized(current, generation, pendingFrameSnapshot); + pendingFrameSnapshot = null; } + + /** 与 result 同一次特征提取对应的成功帧,转交 Manager 后立即释放引用。 */ + private RecognitionFrameSnapshot pendingFrameSnapshot; } } diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart index 090af7d..6a7dfe1 100644 --- a/lib/common/api/hard_api.dart +++ b/lib/common/api/hard_api.dart @@ -1,8 +1,69 @@ +import 'dart:io'; + import 'package:dio/dio.dart' as dio; import 'package:pj_supermarket_cloud/common/index.dart'; abstract class HardApi { + /// 按终端订单号对账服务端订单和识别证据状态;后台任务失败不弹业务 Toast。 + static Future resolveOrderEvidence({ + required String hardId, + required String androidOrderCode, + }) async { + final res = await HttpManage().dio.get( + '/hard/hard-order-evidence/ad/resolve', + queryParameters: {'androidOrderCode': androidOrderCode}, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.silentRequestKey: true}, + ), + ); + if (res.data == null) return null; + return ServiceOrderEvidenceResolve.fromJson(res.data); + } + + /// 提交识别元数据、触发参考图复制或报告本地图片永久缺失。 + static Future submitOrderEvidence({ + required String hardId, + required OrderEvidenceRequest request, + }) async { + final res = await HttpManage().dio.post( + '/hard/hard-order-evidence/ad/submit', + data: request.toJson(), + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.silentRequestKey: true}, + ), + ); + if (res.data == null) return null; + return ServiceOrderEvidenceUploadResult.fromJson(res.data); + } + + /// 使用统一 Dio 鉴权和租户头上传现场图片,multipart 文件名不包含终端完整路径。 + static Future uploadOrderEvidence({ + required String hardId, + required OrderEvidenceRequest request, + required String filePath, + }) async { + final file = File(filePath); + final fileName = file.uri.pathSegments.last; + final form = dio.FormData.fromMap({ + ...request.toJson(), + 'file': await dio.MultipartFile.fromFile(filePath, filename: fileName), + }); + final res = await HttpManage().dio.post( + '/hard/hard-order-evidence/ad/upload', + data: form, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + contentType: 'multipart/form-data', + extra: {HttpManage.silentRequestKey: true}, + ), + ); + if (res.data == null) return null; + return ServiceOrderEvidenceUploadResult.fromJson(res.data); + } + /// 静默上报食堂、超市 Android 终端最新运行状态;后台失败不得弹出业务 Toast。 static Future terminalHeartbeat({ required String hardId, @@ -112,6 +173,9 @@ abstract class HardApi { final res = await HttpManage().dio.post( "/hard/hard-api/ad/create-order", data: formData, + options: dio.Options( + extra: {HttpManage.rejectBusinessErrorKey: true}, + ), ); if (res.data == null) return null; return res.data; diff --git a/lib/common/models/api/hard_api_models.dart b/lib/common/models/api/hard_api_models.dart index 4be817c..549c70e 100644 --- a/lib/common/models/api/hard_api_models.dart +++ b/lib/common/models/api/hard_api_models.dart @@ -4,6 +4,213 @@ import 'package:pj_supermarket_cloud/common/index.dart'; part 'hard_api_models.g.dart'; +/// 终端提交订单识别证据元数据和现场图片时共用的强类型请求。 +@JsonSerializable(includeIfNull: false) +class OrderEvidenceRequest { + /// 创建一条与服务端证据位置协议一致的请求。 + const OrderEvidenceRequest({ + required this.androidOrderCode, + required this.evidenceType, + required this.evidenceRole, + required this.sequenceNo, + required this.userId, + this.faceSampleId, + this.referenceImageUrl, + this.referenceImageVersion, + this.similarity, + this.similarityThreshold, + required this.capturedAt, + this.imageHash, + this.imageSize, + this.imageWidth, + this.imageHeight, + this.status, + this.failureReason, + required this.appVersion, + }); + + /// 终端稳定订单号,用于服务端按设备对账订单。 + final String androidOrderCode; + + /// 证据业务类型,使用 `OrderEvidenceType.code`。 + final String evidenceType; + + /// 图片角色,使用 `OrderEvidenceRole.code`。 + final String evidenceRole; + + /// 同一订单、类型和角色下从 0 开始的序号。 + final int sequenceNo; + + /// 识别用户主键,服务端会再次校验与订单用户一致。 + final String userId; + + /// 虹软样本编号;人脸证据必填,其他类型允许为空。 + final String? faceSampleId; + + /// 识别时记录的后台参考人脸地址,仅参考图角色提交。 + final String? referenceImageUrl; + + /// 识别时本地人脸快照对应的后台更新时间,用于识别相同地址上的内容替换。 + final DateTime? referenceImageVersion; + + /// 人脸比对相似度快照,范围 0 到 1。 + final double? similarity; + + /// 人脸比对成功阈值快照,范围 0 到 1。 + final double? similarityThreshold; + + /// 真正比对成功帧的终端采集时间。 + final DateTime capturedAt; + + /// 图片 SHA-256;现场图片必填,参考图内容未知时允许为空。 + final String? imageHash; + + /// 图片大小,单位字节。 + final int? imageSize; + + /// 图片宽度,单位像素。 + final int? imageWidth; + + /// 图片高度,单位像素。 + final int? imageHeight; + + /// 元数据提交目标状态,仅允许服务端协议的 PENDING 或 UNAVAILABLE。 + final String? status; + + /// 永久缺失的单行摘要,不得包含终端完整文件路径。 + final String? failureReason; + + /// 产生证据的终端应用版本快照。 + final String appVersion; + + /// 从 JSON 响应或测试夹具恢复请求对象。 + factory OrderEvidenceRequest.fromJson(Map json) => + _$OrderEvidenceRequestFromJson(json); + + /// 转换为统一 Dio 可提交的 JSON 或 multipart 字段 Map。 + Map toJson() => _$OrderEvidenceRequestToJson(this); +} + +/// 服务端单条订单识别证据状态,用于终端对账和幂等恢复。 +@JsonSerializable() +class ServiceOrderEvidenceImage { + /// 创建一条服务端证据状态快照。 + const ServiceOrderEvidenceImage({ + required this.id, + required this.evidenceType, + required this.evidenceRole, + required this.sequenceNo, + required this.status, + this.imageHash, + this.imageUrl, + this.failureReason, + }); + + /// 服务端证据记录主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String id; + + /// 服务端稳定证据类型编码。 + final String evidenceType; + + /// 服务端稳定图片角色编码。 + final String evidenceRole; + + /// 同一角色下的图片序号。 + final int sequenceNo; + + /// 服务端证据状态,取 PENDING、AVAILABLE 或 UNAVAILABLE。 + final String status; + + /// 服务端已登记的图片 SHA-256。 + final String? imageHash; + + /// PC 可访问的服务端证据图片地址。 + final String? imageUrl; + + /// 永久缺失或复制失败摘要。 + final String? failureReason; + + /// 从服务端 JSON 解析证据状态。 + factory ServiceOrderEvidenceImage.fromJson(Map json) => + _$ServiceOrderEvidenceImageFromJson(json); + + /// 转换为测试或诊断使用的 JSON。 + Map toJson() => _$ServiceOrderEvidenceImageToJson(this); +} + +/// 按终端订单号对账返回的服务端订单和全部证据位置。 +@JsonSerializable() +class ServiceOrderEvidenceResolve { + /// 创建一份服务端订单对账结果。 + const ServiceOrderEvidenceResolve({ + required this.orderId, + required this.orderStatus, + this.evidenceImages = const [], + }); + + /// 服务端订单主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String orderId; + + /// 服务端订单状态编码,只有已支付、已核销或退款订单允许继续补传。 + final String orderStatus; + + /// 服务端当前已登记的证据位置及状态。 + final List evidenceImages; + + /// 从统一响应中的 data 对象解析订单对账结果。 + factory ServiceOrderEvidenceResolve.fromJson(Map json) => + _$ServiceOrderEvidenceResolveFromJson(json); + + /// 转换为测试或诊断使用的 JSON。 + Map toJson() => _$ServiceOrderEvidenceResolveToJson(this); +} + +/// 服务端提交元数据或上传现场图片后的幂等结果。 +@JsonSerializable() +class ServiceOrderEvidenceUploadResult { + /// 创建一份服务端证据写入结果。 + const ServiceOrderEvidenceUploadResult({ + required this.evidenceId, + required this.orderId, + required this.status, + required this.uploaded, + required this.message, + this.imageUrl, + }); + + /// 服务端证据记录主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String evidenceId; + + /// 服务端订单主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String orderId; + + /// 服务端证据状态。 + final String status; + + /// 本次请求是否实际新写入文件;幂等重复成功时为 false。 + final bool uploaded; + + /// 面向任务日志的简短服务端处理结果。 + final String message; + + /// AVAILABLE 时的服务端证据图片地址。 + final String? imageUrl; + + /// 从统一响应中的 data 对象解析上传结果。 + factory ServiceOrderEvidenceUploadResult.fromJson( + Map json, + ) => + _$ServiceOrderEvidenceUploadResultFromJson(json); + + /// 转换为测试或诊断使用的 JSON。 + Map toJson() => + _$ServiceOrderEvidenceUploadResultToJson(this); +} + @JsonSerializable() class ServiceHardware extends ServiceBase { /// 编号 diff --git a/lib/common/models/api/hard_api_models.g.dart b/lib/common/models/api/hard_api_models.g.dart index f3eea2e..46abd09 100644 --- a/lib/common/models/api/hard_api_models.g.dart +++ b/lib/common/models/api/hard_api_models.g.dart @@ -6,6 +6,132 @@ part of 'hard_api_models.dart'; // JsonSerializableGenerator // ************************************************************************** +OrderEvidenceRequest _$OrderEvidenceRequestFromJson( + Map json) => + OrderEvidenceRequest( + androidOrderCode: json['androidOrderCode'] as String, + evidenceType: json['evidenceType'] as String, + evidenceRole: json['evidenceRole'] as String, + sequenceNo: (json['sequenceNo'] as num).toInt(), + userId: json['userId'] as String, + faceSampleId: json['faceSampleId'] as String?, + referenceImageUrl: json['referenceImageUrl'] as String?, + referenceImageVersion: json['referenceImageVersion'] == null + ? null + : DateTime.parse(json['referenceImageVersion'] as String), + similarity: (json['similarity'] as num?)?.toDouble(), + similarityThreshold: (json['similarityThreshold'] as num?)?.toDouble(), + capturedAt: DateTime.parse(json['capturedAt'] as String), + imageHash: json['imageHash'] as String?, + imageSize: (json['imageSize'] as num?)?.toInt(), + imageWidth: (json['imageWidth'] as num?)?.toInt(), + imageHeight: (json['imageHeight'] as num?)?.toInt(), + status: json['status'] as String?, + failureReason: json['failureReason'] as String?, + appVersion: json['appVersion'] as String, + ); + +Map _$OrderEvidenceRequestToJson( + OrderEvidenceRequest instance) { + final val = { + 'androidOrderCode': instance.androidOrderCode, + 'evidenceType': instance.evidenceType, + 'evidenceRole': instance.evidenceRole, + 'sequenceNo': instance.sequenceNo, + 'userId': instance.userId, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('faceSampleId', instance.faceSampleId); + writeNotNull('referenceImageUrl', instance.referenceImageUrl); + writeNotNull('referenceImageVersion', + instance.referenceImageVersion?.toIso8601String()); + writeNotNull('similarity', instance.similarity); + writeNotNull('similarityThreshold', instance.similarityThreshold); + val['capturedAt'] = instance.capturedAt.toIso8601String(); + writeNotNull('imageHash', instance.imageHash); + writeNotNull('imageSize', instance.imageSize); + writeNotNull('imageWidth', instance.imageWidth); + writeNotNull('imageHeight', instance.imageHeight); + writeNotNull('status', instance.status); + writeNotNull('failureReason', instance.failureReason); + val['appVersion'] = instance.appVersion; + return val; +} + +ServiceOrderEvidenceImage _$ServiceOrderEvidenceImageFromJson( + Map json) => + ServiceOrderEvidenceImage( + id: JsonTool.anyToRequiredString(json['id']), + evidenceType: json['evidenceType'] as String, + evidenceRole: json['evidenceRole'] as String, + sequenceNo: (json['sequenceNo'] as num).toInt(), + status: json['status'] as String, + imageHash: json['imageHash'] as String?, + imageUrl: json['imageUrl'] as String?, + failureReason: json['failureReason'] as String?, + ); + +Map _$ServiceOrderEvidenceImageToJson( + ServiceOrderEvidenceImage instance) => + { + 'id': instance.id, + 'evidenceType': instance.evidenceType, + 'evidenceRole': instance.evidenceRole, + 'sequenceNo': instance.sequenceNo, + 'status': instance.status, + 'imageHash': instance.imageHash, + 'imageUrl': instance.imageUrl, + 'failureReason': instance.failureReason, + }; + +ServiceOrderEvidenceResolve _$ServiceOrderEvidenceResolveFromJson( + Map json) => + ServiceOrderEvidenceResolve( + orderId: JsonTool.anyToRequiredString(json['orderId']), + orderStatus: json['orderStatus'] as String, + evidenceImages: (json['evidenceImages'] as List?) + ?.map((e) => + ServiceOrderEvidenceImage.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$ServiceOrderEvidenceResolveToJson( + ServiceOrderEvidenceResolve instance) => + { + 'orderId': instance.orderId, + 'orderStatus': instance.orderStatus, + 'evidenceImages': instance.evidenceImages, + }; + +ServiceOrderEvidenceUploadResult _$ServiceOrderEvidenceUploadResultFromJson( + Map json) => + ServiceOrderEvidenceUploadResult( + evidenceId: JsonTool.anyToRequiredString(json['evidenceId']), + orderId: JsonTool.anyToRequiredString(json['orderId']), + status: json['status'] as String, + uploaded: json['uploaded'] as bool, + message: json['message'] as String, + imageUrl: json['imageUrl'] as String?, + ); + +Map _$ServiceOrderEvidenceUploadResultToJson( + ServiceOrderEvidenceUploadResult instance) => + { + 'evidenceId': instance.evidenceId, + 'orderId': instance.orderId, + 'status': instance.status, + 'uploaded': instance.uploaded, + 'message': instance.message, + 'imageUrl': instance.imageUrl, + }; + ServiceHardware _$ServiceHardwareFromJson(Map json) => ServiceHardware( id: json['id'] as String?, diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart index 3231b0f..3efc60b 100644 --- a/lib/common/models/index.dart +++ b/lib/common/models/index.dart @@ -25,6 +25,7 @@ export './manage/usb_facility_enum_manage.dart'; export './manage/usb_facility_manage.dart'; export './manage/terminal_device_status_models.dart'; export './manage/hardware_input_history_models.dart'; +export './manage/order_evidence_models.dart'; export './manage/scheduled_task_models.dart'; export './manage/face_sync_models.dart'; export './manage/root_access_models.dart'; diff --git a/lib/common/models/manage/order_evidence_models.dart b/lib/common/models/manage/order_evidence_models.dart new file mode 100644 index 0000000..ffd798d --- /dev/null +++ b/lib/common/models/manage/order_evidence_models.dart @@ -0,0 +1,100 @@ +/// 订单识别证据业务类型,编码与服务端 `hard_order_evidence_image.evidence_type` 保持一致。 +enum OrderEvidenceType { + /// 人脸比对形成的参考图与现场图证据。 + faceRecognition('FACE_RECOGNITION'), + + /// 预留的菜谱识别现场图证据,本期不执行识别与上传创建逻辑。 + menuRecognition('MENU_RECOGNITION'); + + /// 创建一个跨端协议使用的稳定证据类型。 + const OrderEvidenceType(this.code); + + /// 服务端接口和数据库保存的稳定编码,不能随界面文案变化。 + final String code; +} + +/// 订单识别证据图片角色,编码与服务端 `hard_order_evidence_image.evidence_role` 保持一致。 +enum OrderEvidenceRole { + /// 识别时命中的后台受信人脸图片,由服务端复制,终端不下载后再上传。 + faceReference('FACE_REFERENCE'), + + /// 人脸真正比对成功时从对应 NV21 帧裁剪的现场图片。 + faceLive('FACE_LIVE'), + + /// 预留的菜谱识别现场图片角色,本期不创建该角色任务。 + menuLive('MENU_LIVE'); + + /// 创建一个跨端协议使用的稳定图片角色。 + const OrderEvidenceRole(this.code); + + /// 服务端接口和数据库保存的稳定编码,不能随界面文案变化。 + final String code; +} + +/// 终端订单证据补传任务状态,编码持久化到 `order_evidence_upload_task.status`。 +enum OrderEvidenceTaskStatus { + /// 已保存识别快照,但尚未确认服务端存在已支付订单。 + waitingOrder('WAITING_ORDER'), + + /// 已通过终端订单号对账到服务端已支付订单,可以提交或上传证据。 + ready('READY'), + + /// 当前进程正在提交证据;应用异常退出后会在下一轮恢复为可重试状态。 + uploading('UPLOADING'), + + /// 服务端已确认 AVAILABLE,现场临时图片已经删除。 + success('SUCCESS'), + + /// 本次网络、文件中心或临时依赖失败,等待 `nextRetryAt` 后重试。 + retryWait('RETRY_WAIT'), + + /// 哈希冲突、归属校验失败等确定不可恢复的失败,元数据长期保留。 + permanentFailed('PERMANENT_FAILED'), + + /// 图片或未绑定订单超过保留期,图片已删除且元数据长期保留。 + expired('EXPIRED'), + + /// 用户在下单前取消或切换支付方式,现场图片已删除且不再自动处理。 + cancelled('CANCELLED'); + + /// 创建一个持久化使用的稳定任务状态。 + const OrderEvidenceTaskStatus(this.code); + + /// SQLite 持久化和诊断统计使用的稳定编码。 + final String code; + + /// 按 SQLite 或接口编码解析状态;未知历史编码返回空,避免误执行任务。 + static OrderEvidenceTaskStatus? fromCode(String code) { + for (final value in values) { + if (value.code == code) return value; + } + return null; + } +} + +/// 单轮订单证据补传统计,供统一定时任务写入结构化执行日志。 +class OrderEvidenceUploadSummary { + /// 创建一份不可变的补传结果统计。 + const OrderEvidenceUploadSummary({ + this.scanned = 0, + this.skipped = 0, + this.succeeded = 0, + this.failed = 0, + }); + + /// 本轮从 SQLite 取得并检查的任务数量。 + final int scanned; + + /// 因未到重试时间、订单未落库或已处于终态而跳过的任务数量。 + final int skipped; + + /// 本轮被服务端确认成功或永久缺失已上报成功的任务数量。 + final int succeeded; + + /// 本轮进入重试等待或确定失败的任务数量。 + final int failed; + + /// 设置页和最近执行记录使用的简短中文摘要。 + String get message => + '检查 $scanned 条,成功 $succeeded 条,跳过 $skipped 条,失败 $failed 条'; +} diff --git a/lib/common/models/manage/scheduled_task_models.dart b/lib/common/models/manage/scheduled_task_models.dart index 373b51f..8d81fc6 100644 --- a/lib/common/models/manage/scheduled_task_models.dart +++ b/lib/common/models/manage/scheduled_task_models.dart @@ -34,6 +34,9 @@ enum ScheduledTaskType { /// 本地离线订单补传。 offlineOrderSync, + /// 已支付订单的人脸等识别证据对账、上传和永久缺失上报。 + orderEvidenceUpload, + /// 本机脱敏日志文件上传。 terminalLogUpload, diff --git a/lib/common/models/manage/scheduled_task_models.g.dart b/lib/common/models/manage/scheduled_task_models.g.dart index 1183829..b09cce3 100644 --- a/lib/common/models/manage/scheduled_task_models.g.dart +++ b/lib/common/models/manage/scheduled_task_models.g.dart @@ -34,6 +34,7 @@ const _$ScheduledTaskTypeEnumMap = { ScheduledTaskType.classifySync: 'classifySync', ScheduledTaskType.productSync: 'productSync', ScheduledTaskType.offlineOrderSync: 'offlineOrderSync', + ScheduledTaskType.orderEvidenceUpload: 'orderEvidenceUpload', ScheduledTaskType.terminalLogUpload: 'terminalLogUpload', ScheduledTaskType.expiredLogCleanup: 'expiredLogCleanup', }; diff --git a/lib/common/models/plugs/face_discern_models.dart b/lib/common/models/plugs/face_discern_models.dart index 1af9835..fa2a007 100644 --- a/lib/common/models/plugs/face_discern_models.dart +++ b/lib/common/models/plugs/face_discern_models.dart @@ -4,24 +4,51 @@ part 'face_discern_models.g.dart'; @JsonSerializable() class FaceStream { - // 相似度 + /// 本次比对相似度,范围为 0 到 1。 double similar; - // 识别时间 + /// 虹软完成本次比对耗时,单位为毫秒。 int cost; - // 人脸id + /// 本地虹软人脸库记录编号,旧协议可能为空。 int? faceId; - // 人脸样本id + /// 服务端同步到终端的人脸样本编号。 int? sampleId; - // 样本id + /// 识别成功人员的服务端用户编号。 String? userId; - // 用户名 + /// 识别成功人员的展示姓名。 String? userName; + /// 原生会话编号,用于把一次成功识别绑定到唯一订单;旧原生插件为空。 + String? sessionId; + + /// 实际命中比对的 NV21 帧采集时间,单位为 Unix 毫秒。 + int? capturedAt; + + /// 原生裁剪后的现场 JPEG 私有路径;该路径只在终端本地队列使用。 + String? evidenceFilePath; + + /// 现场 JPEG 的 SHA-256 摘要,文件缺失或裁剪失败时为空。 + String? evidenceHash; + + /// 现场 JPEG 大小,单位为字节。 + int? evidenceSize; + + /// 现场 JPEG 宽度,单位为像素。 + int? evidenceWidth; + + /// 现场 JPEG 高度,单位为像素。 + int? evidenceHeight; + + /// 本次人脸比对采用的阈值快照,范围为 0 到 1。 + double? similarityThreshold; + + /// 现场证据生成失败的安全摘要,不得包含完整路径、原始帧或图片内容。 + String? evidenceError; + FaceStream({ required this.similar, required this.cost, @@ -29,6 +56,15 @@ class FaceStream { this.sampleId, this.userId, this.userName, + this.sessionId, + this.capturedAt, + this.evidenceFilePath, + this.evidenceHash, + this.evidenceSize, + this.evidenceWidth, + this.evidenceHeight, + this.similarityThreshold, + this.evidenceError, }); factory FaceStream.fromJson(Map json) => diff --git a/lib/common/models/plugs/face_discern_models.g.dart b/lib/common/models/plugs/face_discern_models.g.dart index e5f5880..03cbc63 100644 --- a/lib/common/models/plugs/face_discern_models.g.dart +++ b/lib/common/models/plugs/face_discern_models.g.dart @@ -13,6 +13,15 @@ FaceStream _$FaceStreamFromJson(Map json) => FaceStream( sampleId: (json['sampleId'] as num?)?.toInt(), userId: json['userId'] as String?, userName: json['userName'] as String?, + sessionId: json['sessionId'] as String?, + capturedAt: (json['capturedAt'] as num?)?.toInt(), + evidenceFilePath: json['evidenceFilePath'] as String?, + evidenceHash: json['evidenceHash'] as String?, + evidenceSize: (json['evidenceSize'] as num?)?.toInt(), + evidenceWidth: (json['evidenceWidth'] as num?)?.toInt(), + evidenceHeight: (json['evidenceHeight'] as num?)?.toInt(), + similarityThreshold: (json['similarityThreshold'] as num?)?.toDouble(), + evidenceError: json['evidenceError'] as String?, ); Map _$FaceStreamToJson(FaceStream instance) => @@ -23,6 +32,15 @@ Map _$FaceStreamToJson(FaceStream instance) => 'sampleId': instance.sampleId, 'userId': instance.userId, 'userName': instance.userName, + 'sessionId': instance.sessionId, + 'capturedAt': instance.capturedAt, + 'evidenceFilePath': instance.evidenceFilePath, + 'evidenceHash': instance.evidenceHash, + 'evidenceSize': instance.evidenceSize, + 'evidenceWidth': instance.evidenceWidth, + 'evidenceHeight': instance.evidenceHeight, + 'similarityThreshold': instance.similarityThreshold, + 'evidenceError': instance.evidenceError, }; FaceConfig _$FaceConfigFromJson(Map json) => FaceConfig( diff --git a/lib/common/services/database/dao/order_evidence_upload_task_dao.dart b/lib/common/services/database/dao/order_evidence_upload_task_dao.dart new file mode 100644 index 0000000..5df07b9 --- /dev/null +++ b/lib/common/services/database/dao/order_evidence_upload_task_dao.dart @@ -0,0 +1,115 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// `order_evidence_upload_task` 本地补传队列的数据访问接口。 +@dao +abstract class OrderEvidenceUploadTaskDao { + /// 保存任务并返回主键;同一主键冲突时以最新状态整体替换。 + @Insert(onConflict: OnConflictStrategy.replace) + Future save(OrderEvidenceUploadTaskEntity entity); + + /// 按主键更新已经存在的任务,不改变证据位置唯一键。 + @update + Future edit(OrderEvidenceUploadTaskEntity entity); + + /// 按订单号和证据位置查询唯一任务;不存在时返回空。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE androidOrderCode = :androidOrderCode + AND evidenceType = :evidenceType + AND evidenceRole = :evidenceRole + AND sequenceNo = :sequenceNo + LIMIT 1 + ''') + Future getByPosition( + String androidOrderCode, + String evidenceType, + String evidenceRole, + int sequenceNo, + ); + + /// 查询同一终端订单的全部任务,按类型、角色和序号稳定排序。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE androidOrderCode = :androidOrderCode + ORDER BY evidenceType, evidenceRole, sequenceNo + ''') + Future> getByOrderCode( + String androidOrderCode, + ); + + /// 查询同一识别会话创建的参考图与现场图任务,用于支付前绑定订单或取消清理。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE recognitionSessionId = :recognitionSessionId + ORDER BY evidenceRole, sequenceNo + ''') + Future> getByRecognitionSession( + String recognitionSessionId, + ); + + /// 查询到达重试时间的活动任务,每轮最多由调用方传入 20 条并按创建时间串行处理。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE status IN ('WAITING_ORDER', 'READY', 'UPLOADING', 'RETRY_WAIT') + AND androidOrderCode IS NOT NULL + AND (nextRetryAt IS NULL OR nextRetryAt <= :now) + ORDER BY createTime ASC, id ASC + LIMIT :limit + ''') + Future> getDueTasks( + DateTime now, + int limit, + ); + + /// 查询需要向服务端报告永久缺失的已绑定终态任务,已报告记录不会重复返回。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE status IN ('PERMANENT_FAILED', 'EXPIRED') + AND androidOrderCode IS NOT NULL + AND unavailableReportedAt IS NULL + AND (nextRetryAt IS NULL OR nextRetryAt <= :now) + ORDER BY updateTime ASC, id ASC + LIMIT :limit + ''') + Future> getUnavailableReportTasks( + DateTime now, + int limit, + ); + + /// 查询已超过 24 小时绑定期限的等待订单任务,调用方删除图片后转为 EXPIRED。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE status = 'WAITING_ORDER' AND expiresAt <= :now + ORDER BY expiresAt ASC + ''') + Future> getExpiredWaitingTasks( + DateTime now, + ); + + /// 查询已超过 30 天图片保留期且仍持有现场文件的非成功任务。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE localFilePath IS NOT NULL + AND status NOT IN ('SUCCESS', 'CANCELLED') + AND expiresAt <= :now + ORDER BY expiresAt ASC + ''') + Future> getExpiredImageTasks( + DateTime now, + ); + + /// 删除完成时间早于指定时间的成功任务元数据,其他失败终态不自动删除。 + @Query(''' + DELETE FROM order_evidence_upload_task + WHERE status = 'SUCCESS' + AND completedAt IS NOT NULL + AND completedAt < :before + ''') + Future deleteSuccessfulBefore(DateTime before); + + /// 查询全部任务,仅用于迁移验证和针对性测试,不参与生产批量处理。 + @Query('SELECT * FROM order_evidence_upload_task ORDER BY id ASC') + Future> getAll(); +} diff --git a/lib/common/services/database/database.dart b/lib/common/services/database/database.dart index bbb1933..ab71739 100644 --- a/lib/common/services/database/database.dart +++ b/lib/common/services/database/database.dart @@ -28,6 +28,7 @@ const entities = [ ScheduledTaskEntity, ScheduledTaskLogEntity, FileUploadRecordEntity, + OrderEvidenceUploadTaskEntity, ]; /// Floor 在实体字段与 SQLite 基础类型之间使用的全局转换器集合。 @@ -48,7 +49,7 @@ const typeConverters = [ ]; /// 收银终端共享 SQLite 数据库入口,统一暴露各业务 DAO 和带迁移的建库方法。 -@Database(version: 9, entities: entities) +@Database(version: 10, entities: entities) @TypeConverters(typeConverters) abstract class AppDatabase extends FloorDatabase { /// 取餐广播队列 DAO。 @@ -99,6 +100,9 @@ abstract class AppDatabase extends FloorDatabase { /// 文件上传状态与幂等快照 DAO。 FileUploadRecordDao get getFileUploadRecordDao; + /// 订单识别证据待对账、提交、上传和永久缺失上报 DAO。 + OrderEvidenceUploadTaskDao get getOrderEvidenceUploadTaskDao; + /// 使用统一外部存储路径打开数据库并按版本顺序执行全部 Floor Migration。 static Future getDataBase() async { final dbPath = await AppFileTool.getDatabasePath(); diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index a77e21b..31662ef 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -104,13 +104,15 @@ class _$AppDatabase extends AppDatabase { FileUploadRecordDao? _getFileUploadRecordDaoInstance; + OrderEvidenceUploadTaskDao? _getOrderEvidenceUploadTaskDaoInstance; + Future open( String path, List migrations, [ Callback? callback, ]) async { final databaseOptions = sqflite.OpenDatabaseOptions( - version: 9, + version: 10, onConfigure: (database) async { await database.execute('PRAGMA foreign_keys = ON'); await callback?.onConfigure?.call(database); @@ -159,6 +161,8 @@ class _$AppDatabase extends AppDatabase { 'CREATE TABLE IF NOT EXISTS `scheduled_task_log` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `taskCode` TEXT NOT NULL, `taskName` TEXT NOT NULL, `triggerType` TEXT NOT NULL, `sourceType` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `status` TEXT NOT NULL, `message` TEXT NOT NULL, `errorType` TEXT, `errorMessage` TEXT, `errorStack` TEXT, `startedAt` TEXT NOT NULL, `finishedAt` TEXT, `durationMs` INTEGER NOT NULL, `scannedCount` INTEGER, `skippedCount` INTEGER, `successCount` INTEGER, `failureCount` INTEGER)'); await database.execute( 'CREATE TABLE IF NOT EXISTS `file_upload_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `hardId` TEXT NOT NULL, `bizType` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `originalName` TEXT NOT NULL, `sourceSize` INTEGER NOT NULL, `sourceModifiedTime` TEXT NOT NULL, `sanitizedHash` TEXT NOT NULL, `sanitizedSize` INTEGER NOT NULL, `status` TEXT NOT NULL, `serverFileId` TEXT, `attemptCount` INTEGER NOT NULL, `lastAttemptAt` TEXT, `uploadedAt` TEXT, `lastError` TEXT, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `order_evidence_upload_task` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `androidOrderCode` TEXT, `serverOrderId` TEXT, `recognitionSessionId` TEXT NOT NULL, `evidenceType` TEXT NOT NULL, `evidenceRole` TEXT NOT NULL, `sequenceNo` INTEGER NOT NULL, `userId` TEXT NOT NULL, `faceSampleId` TEXT, `referenceImageUrl` TEXT, `referenceImageVersion` TEXT, `localFilePath` TEXT, `imageHash` TEXT, `imageSize` INTEGER, `imageWidth` INTEGER, `imageHeight` INTEGER, `similarity` REAL, `similarityThreshold` REAL, `capturedAt` TEXT NOT NULL, `appVersion` TEXT NOT NULL, `status` TEXT NOT NULL, `attemptCount` INTEGER NOT NULL, `nextRetryAt` TEXT, `serverEvidenceId` TEXT, `serverImageUrl` TEXT, `lastError` TEXT, `unavailableReportedAt` TEXT, `completedAt` TEXT, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, `expiresAt` TEXT NOT NULL)'); await database.execute( 'CREATE INDEX `index_canteen_spu_combo_item_parentServiceUniqueCode_saleDate` ON `canteen_spu_combo_item` (`parentServiceUniqueCode`, `saleDate`)'); await database.execute( @@ -171,6 +175,10 @@ class _$AppDatabase extends AppDatabase { 'CREATE UNIQUE INDEX `index_file_upload_record_hardId_bizType_relativePath` ON `file_upload_record` (`hardId`, `bizType`, `relativePath`)'); await database.execute( 'CREATE INDEX `index_file_upload_record_bizType_status_updateTime` ON `file_upload_record` (`bizType`, `status`, `updateTime`)'); + await database.execute( + 'CREATE UNIQUE INDEX `index_order_evidence_upload_task_androidOrderCode_evidenceType_evidenceRole_sequenceNo` ON `order_evidence_upload_task` (`androidOrderCode`, `evidenceType`, `evidenceRole`, `sequenceNo`)'); + await database.execute( + 'CREATE INDEX `index_order_evidence_upload_task_status_nextRetryAt_createTime` ON `order_evidence_upload_task` (`status`, `nextRetryAt`, `createTime`)'); await callback?.onCreate?.call(database, version); }, @@ -267,6 +275,12 @@ class _$AppDatabase extends AppDatabase { return _getFileUploadRecordDaoInstance ??= _$FileUploadRecordDao(database, changeListener); } + + @override + OrderEvidenceUploadTaskDao get getOrderEvidenceUploadTaskDao { + return _getOrderEvidenceUploadTaskDaoInstance ??= + _$OrderEvidenceUploadTaskDao(database, changeListener); + } } class _$BroadcastDao extends BroadcastDao { @@ -2467,6 +2481,235 @@ class _$FileUploadRecordDao extends FileUploadRecordDao { } } +class _$OrderEvidenceUploadTaskDao extends OrderEvidenceUploadTaskDao { + _$OrderEvidenceUploadTaskDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _orderEvidenceUploadTaskEntityInsertionAdapter = InsertionAdapter( + database, + 'order_evidence_upload_task', + (OrderEvidenceUploadTaskEntity item) => { + 'id': item.id, + 'androidOrderCode': item.androidOrderCode, + 'serverOrderId': item.serverOrderId, + 'recognitionSessionId': item.recognitionSessionId, + 'evidenceType': item.evidenceType, + 'evidenceRole': item.evidenceRole, + 'sequenceNo': item.sequenceNo, + 'userId': item.userId, + 'faceSampleId': item.faceSampleId, + 'referenceImageUrl': item.referenceImageUrl, + 'referenceImageVersion': item.referenceImageVersion, + 'localFilePath': item.localFilePath, + 'imageHash': item.imageHash, + 'imageSize': item.imageSize, + 'imageWidth': item.imageWidth, + 'imageHeight': item.imageHeight, + 'similarity': item.similarity, + 'similarityThreshold': item.similarityThreshold, + 'capturedAt': _dateTimeConverter.encode(item.capturedAt), + 'appVersion': item.appVersion, + 'status': item.status, + 'attemptCount': item.attemptCount, + 'nextRetryAt': + _dateTimeEmptyConverter.encode(item.nextRetryAt), + 'serverEvidenceId': item.serverEvidenceId, + 'serverImageUrl': item.serverImageUrl, + 'lastError': item.lastError, + 'unavailableReportedAt': _dateTimeEmptyConverter + .encode(item.unavailableReportedAt), + 'completedAt': + _dateTimeEmptyConverter.encode(item.completedAt), + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime), + 'expiresAt': _dateTimeConverter.encode(item.expiresAt) + }), + _orderEvidenceUploadTaskEntityUpdateAdapter = UpdateAdapter( + database, + 'order_evidence_upload_task', + ['id'], + (OrderEvidenceUploadTaskEntity item) => { + 'id': item.id, + 'androidOrderCode': item.androidOrderCode, + 'serverOrderId': item.serverOrderId, + 'recognitionSessionId': item.recognitionSessionId, + 'evidenceType': item.evidenceType, + 'evidenceRole': item.evidenceRole, + 'sequenceNo': item.sequenceNo, + 'userId': item.userId, + 'faceSampleId': item.faceSampleId, + 'referenceImageUrl': item.referenceImageUrl, + 'referenceImageVersion': item.referenceImageVersion, + 'localFilePath': item.localFilePath, + 'imageHash': item.imageHash, + 'imageSize': item.imageSize, + 'imageWidth': item.imageWidth, + 'imageHeight': item.imageHeight, + 'similarity': item.similarity, + 'similarityThreshold': item.similarityThreshold, + 'capturedAt': _dateTimeConverter.encode(item.capturedAt), + 'appVersion': item.appVersion, + 'status': item.status, + 'attemptCount': item.attemptCount, + 'nextRetryAt': + _dateTimeEmptyConverter.encode(item.nextRetryAt), + 'serverEvidenceId': item.serverEvidenceId, + 'serverImageUrl': item.serverImageUrl, + 'lastError': item.lastError, + 'unavailableReportedAt': _dateTimeEmptyConverter + .encode(item.unavailableReportedAt), + 'completedAt': + _dateTimeEmptyConverter.encode(item.completedAt), + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime), + 'expiresAt': _dateTimeConverter.encode(item.expiresAt) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _orderEvidenceUploadTaskEntityInsertionAdapter; + + final UpdateAdapter + _orderEvidenceUploadTaskEntityUpdateAdapter; + + @override + Future getByPosition( + String androidOrderCode, + String evidenceType, + String evidenceRole, + int sequenceNo, + ) async { + return _queryAdapter.query( + 'SELECT * FROM order_evidence_upload_task WHERE androidOrderCode = ?1 AND evidenceType = ?2 AND evidenceRole = ?3 AND sequenceNo = ?4 LIMIT 1', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [androidOrderCode, evidenceType, evidenceRole, sequenceNo]); + } + + @override + Future> getByOrderCode( + String androidOrderCode) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE androidOrderCode = ?1 ORDER BY evidenceType, evidenceRole, sequenceNo', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [androidOrderCode]); + } + + @override + Future> getByRecognitionSession( + String recognitionSessionId) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE recognitionSessionId = ?1 ORDER BY evidenceRole, sequenceNo', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [recognitionSessionId]); + } + + @override + Future> getDueTasks( + DateTime now, + int limit, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE status IN (\'WAITING_ORDER\', \'READY\', \'UPLOADING\', \'RETRY_WAIT\') AND androidOrderCode IS NOT NULL AND (nextRetryAt IS NULL OR nextRetryAt <= ?1) ORDER BY createTime ASC, id ASC LIMIT ?2', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [_dateTimeConverter.encode(now), limit]); + } + + @override + Future> getUnavailableReportTasks( + DateTime now, + int limit, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE status IN (\'PERMANENT_FAILED\', \'EXPIRED\') AND androidOrderCode IS NOT NULL AND unavailableReportedAt IS NULL AND (nextRetryAt IS NULL OR nextRetryAt <= ?1) ORDER BY updateTime ASC, id ASC LIMIT ?2', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [_dateTimeConverter.encode(now), limit]); + } + + @override + Future> getExpiredWaitingTasks( + DateTime now) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE status = \'WAITING_ORDER\' AND expiresAt <= ?1 ORDER BY expiresAt ASC', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [_dateTimeConverter.encode(now)]); + } + + @override + Future> getExpiredImageTasks( + DateTime now) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE localFilePath IS NOT NULL AND status NOT IN (\'SUCCESS\', \'CANCELLED\') AND expiresAt <= ?1 ORDER BY expiresAt ASC', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [_dateTimeConverter.encode(now)]); + } + + @override + Future deleteSuccessfulBefore(DateTime before) async { + await _queryAdapter.queryNoReturn( + 'DELETE FROM order_evidence_upload_task WHERE status = \'SUCCESS\' AND completedAt IS NOT NULL AND completedAt < ?1', + arguments: [_dateTimeConverter.encode(before)]); + } + + @override + Future> getAll() async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task ORDER BY id ASC', + mapper: (Map row) => OrderEvidenceUploadTaskEntity( + id: row['id'] as int?, + androidOrderCode: row['androidOrderCode'] as String?, + serverOrderId: row['serverOrderId'] as String?, + recognitionSessionId: row['recognitionSessionId'] as String, + evidenceType: row['evidenceType'] as String, + evidenceRole: row['evidenceRole'] as String, + sequenceNo: row['sequenceNo'] as int, + userId: row['userId'] as String, + faceSampleId: row['faceSampleId'] as String?, + referenceImageUrl: row['referenceImageUrl'] as String?, + referenceImageVersion: row['referenceImageVersion'] as String?, + localFilePath: row['localFilePath'] as String?, + imageHash: row['imageHash'] as String?, + imageSize: row['imageSize'] as int?, + imageWidth: row['imageWidth'] as int?, + imageHeight: row['imageHeight'] as int?, + similarity: row['similarity'] as double?, + similarityThreshold: row['similarityThreshold'] as double?, + capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), + appVersion: row['appVersion'] as String, + status: row['status'] as String, + attemptCount: row['attemptCount'] as int, + nextRetryAt: + _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String), + serverEvidenceId: row['serverEvidenceId'] as String?, + serverImageUrl: row['serverImageUrl'] as String?, + lastError: row['lastError'] as String?, + unavailableReportedAt: _dateTimeEmptyConverter + .decode(row['unavailableReportedAt'] as String), + completedAt: + _dateTimeEmptyConverter.decode(row['completedAt'] as String), + createTime: _dateTimeConverter.decode(row['createTime'] as String), + updateTime: _dateTimeConverter.decode(row['updateTime'] as String), + expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String))); + } + + @override + Future save(OrderEvidenceUploadTaskEntity entity) { + return _orderEvidenceUploadTaskEntityInsertionAdapter.insertAndReturnId( + entity, OnConflictStrategy.replace); + } + + @override + Future edit(OrderEvidenceUploadTaskEntity entity) async { + await _orderEvidenceUploadTaskEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } +} + // ignore_for_file: unused_element final _dateTimeConverter = DateTimeConverter(); final _dateTimeEmptyConverter = DateTimeEmptyConverter(); diff --git a/lib/common/services/database/database_migration.dart b/lib/common/services/database/database_migration.dart index 706aa9a..634eb9a 100644 --- a/lib/common/services/database/database_migration.dart +++ b/lib/common/services/database/database_migration.dart @@ -293,6 +293,53 @@ abstract class DatabaseMigration { ); }); + /// 版本10新增订单识别证据补传队列,长期证据仍由服务端从表保存。 + static Migration migration9To10 = Migration(9, 10, (database) async { + await database.execute(''' + CREATE TABLE IF NOT EXISTS order_evidence_upload_task ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + androidOrderCode TEXT, + serverOrderId TEXT, + recognitionSessionId TEXT NOT NULL, + evidenceType TEXT NOT NULL, + evidenceRole TEXT NOT NULL, + sequenceNo INTEGER NOT NULL, + userId TEXT NOT NULL, + faceSampleId TEXT, + referenceImageUrl TEXT, + referenceImageVersion TEXT, + localFilePath TEXT, + imageHash TEXT, + imageSize INTEGER, + imageWidth INTEGER, + imageHeight INTEGER, + similarity REAL, + similarityThreshold REAL, + capturedAt TEXT NOT NULL, + appVersion TEXT NOT NULL, + status TEXT NOT NULL, + attemptCount INTEGER NOT NULL, + nextRetryAt TEXT, + serverEvidenceId TEXT, + serverImageUrl TEXT, + lastError TEXT, + unavailableReportedAt TEXT, + completedAt TEXT, + createTime TEXT NOT NULL, + updateTime TEXT NOT NULL, + expiresAt TEXT NOT NULL + ) + '''); + await database.execute( + 'CREATE UNIQUE INDEX IF NOT EXISTS index_order_evidence_upload_task_position ' + 'ON order_evidence_upload_task(androidOrderCode, evidenceType, evidenceRole, sequenceNo)', + ); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_order_evidence_upload_task_status_nextRetryAt_createTime ' + 'ON order_evidence_upload_task(status, nextRetryAt, createTime)', + ); + }); + /// 按起始版本升序返回全部迁移,Floor 打开旧数据库时据此连续升级到当前版本。 static List getDatabaseMigrations() { return [ @@ -304,6 +351,7 @@ abstract class DatabaseMigration { migration6To7, migration7To8, migration8To9, + migration9To10, ]; } } diff --git a/lib/common/services/database/entity/order_evidence_upload_task_entity.dart b/lib/common/services/database/entity/order_evidence_upload_task_entity.dart new file mode 100644 index 0000000..772c560 --- /dev/null +++ b/lib/common/services/database/entity/order_evidence_upload_task_entity.dart @@ -0,0 +1,211 @@ +import 'package:floor/floor.dart'; + +/// 终端订单识别证据补传任务。 +/// +/// 本表是断网、崩溃和接口超时后的本地待办队列,不是最终证据库。服务端确认成功后立即删除 +/// [localFilePath] 对应的现场图片;失败图片最多保留 30 天,任务终态元数据按业务规则保留。 +@Entity( + tableName: 'order_evidence_upload_task', + indices: [ + Index( + value: [ + 'androidOrderCode', + 'evidenceType', + 'evidenceRole', + 'sequenceNo', + ], + unique: true, + ), + Index(value: ['status', 'nextRetryAt', 'createTime']), + ], +) +class OrderEvidenceUploadTaskEntity { + /// SQLite 自增主键,首次保存前为空。 + @PrimaryKey(autoGenerate: true) + final int? id; + + /// 终端生成的稳定订单号,用于响应超时后与服务端对账。 + final String? androidOrderCode; + + /// 对账成功后的服务端订单主键;尚未落单或请求结果不确定时为空。 + final String? serverOrderId; + + /// 产生现场图片的人脸识别业务会话编号,用于诊断但不作为服务端幂等键。 + final String recognitionSessionId; + + /// 证据类型,保存 `OrderEvidenceType.code`。 + final String evidenceType; + + /// 图片角色,保存 `OrderEvidenceRole.code`。 + final String evidenceRole; + + /// 同一订单、类型和角色下的稳定序号,从 0 开始。 + final int sequenceNo; + + /// 识别用户主键快照,来源于最终用于下单的人员。 + final String userId; + + /// 虹软样本编号快照;非人脸证据允许为空。 + final String? faceSampleId; + + /// 识别时本地人脸记录保存的后台参考图片地址,仅由服务端校验和复制。 + final String? referenceImageUrl; + + /// 识别时参考图片的版本摘要,当前使用本地人脸记录更新时间 ISO 字符串。 + final String? referenceImageVersion; + + /// 终端私有目录中的现场 JPEG 路径;参考图、已成功或图片到期任务为空。 + final String? localFilePath; + + /// 现场图片或已知参考图内容的 SHA-256;参考图未知内容时允许为空。 + final String? imageHash; + + /// 现场图片大小,单位字节;参考图未下载时允许为空。 + final int? imageSize; + + /// 现场图片宽度,单位像素;参考图未下载时允许为空。 + final int? imageWidth; + + /// 现场图片高度,单位像素;参考图未下载时允许为空。 + final int? imageHeight; + + /// 人脸比对相似度快照,范围 0 到 1;菜谱识别允许为空。 + final double? similarity; + + /// 人脸比对通过时使用的阈值快照,范围 0 到 1;菜谱识别允许为空。 + final double? similarityThreshold; + + /// 真正比对成功帧的终端采集时间。 + final DateTime capturedAt; + + /// 产生证据时的终端应用展示版本与内部版本组合。 + final String appVersion; + + /// 当前任务状态,保存 `OrderEvidenceTaskStatus.code`。 + final String status; + + /// 已发起的服务端对账、提交或上传尝试次数,用于计算退避间隔。 + final int attemptCount; + + /// 下一次允许自动处理的本地时间;立即可处理时为空。 + final DateTime? nextRetryAt; + + /// 服务端证据记录主键;尚未创建或永久缺失上报未返回时为空。 + final String? serverEvidenceId; + + /// 服务端最终证据图片地址;非 AVAILABLE 或参考复制失败时为空。 + final String? serverImageUrl; + + /// 最近一次失败的单行截断摘要,不包含 Token、原始帧、完整路径或图片内容。 + final String? lastError; + + /// 终端已向服务端成功报告 UNAVAILABLE 的时间;未报告或无订单时为空。 + final DateTime? unavailableReportedAt; + + /// 服务端确认 AVAILABLE 的完成时间;其他状态为空。 + final DateTime? completedAt; + + /// 任务首次创建时间,用于 24 小时未绑定订单和终态保留策略。 + final DateTime createTime; + + /// 状态、重试时间或服务端结果最近一次更新时间。 + final DateTime updateTime; + + /// 当前图片或未绑定任务的本地到期时间;绑定订单后延长为现场图片 30 天。 + final DateTime expiresAt; + + /// 创建一条可持久化的订单证据补传任务。 + const OrderEvidenceUploadTaskEntity({ + this.id, + this.androidOrderCode, + this.serverOrderId, + required this.recognitionSessionId, + required this.evidenceType, + required this.evidenceRole, + required this.sequenceNo, + required this.userId, + this.faceSampleId, + this.referenceImageUrl, + this.referenceImageVersion, + this.localFilePath, + this.imageHash, + this.imageSize, + this.imageWidth, + this.imageHeight, + this.similarity, + this.similarityThreshold, + required this.capturedAt, + required this.appVersion, + required this.status, + this.attemptCount = 0, + this.nextRetryAt, + this.serverEvidenceId, + this.serverImageUrl, + this.lastError, + this.unavailableReportedAt, + this.completedAt, + required this.createTime, + required this.updateTime, + required this.expiresAt, + }); + + /// 保留证据位置和识别快照,生成包含指定状态变化的新任务。 + OrderEvidenceUploadTaskEntity copyWith({ + String? androidOrderCode, + String? serverOrderId, + String? localFilePath, + bool clearLocalFilePath = false, + String? imageHash, + int? imageSize, + int? imageWidth, + int? imageHeight, + String? status, + int? attemptCount, + DateTime? nextRetryAt, + bool clearNextRetryAt = false, + String? serverEvidenceId, + String? serverImageUrl, + String? lastError, + bool clearLastError = false, + DateTime? unavailableReportedAt, + DateTime? completedAt, + DateTime? updateTime, + DateTime? expiresAt, + }) { + return OrderEvidenceUploadTaskEntity( + id: id, + androidOrderCode: androidOrderCode ?? this.androidOrderCode, + serverOrderId: serverOrderId ?? this.serverOrderId, + recognitionSessionId: recognitionSessionId, + evidenceType: evidenceType, + evidenceRole: evidenceRole, + sequenceNo: sequenceNo, + userId: userId, + faceSampleId: faceSampleId, + referenceImageUrl: referenceImageUrl, + referenceImageVersion: referenceImageVersion, + localFilePath: + clearLocalFilePath ? null : localFilePath ?? this.localFilePath, + imageHash: imageHash ?? this.imageHash, + imageSize: imageSize ?? this.imageSize, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + similarity: similarity, + similarityThreshold: similarityThreshold, + capturedAt: capturedAt, + appVersion: appVersion, + status: status ?? this.status, + attemptCount: attemptCount ?? this.attemptCount, + nextRetryAt: clearNextRetryAt ? null : nextRetryAt ?? this.nextRetryAt, + serverEvidenceId: serverEvidenceId ?? this.serverEvidenceId, + serverImageUrl: serverImageUrl ?? this.serverImageUrl, + lastError: clearLastError ? null : lastError ?? this.lastError, + unavailableReportedAt: + unavailableReportedAt ?? this.unavailableReportedAt, + completedAt: completedAt ?? this.completedAt, + createTime: createTime, + updateTime: updateTime ?? this.updateTime, + expiresAt: expiresAt ?? this.expiresAt, + ); + } +} diff --git a/lib/common/services/face_recognition_session_service.dart b/lib/common/services/face_recognition_session_service.dart index 0eeaa7c..73bd17e 100644 --- a/lib/common/services/face_recognition_session_service.dart +++ b/lib/common/services/face_recognition_session_service.dart @@ -366,7 +366,10 @@ class FaceRecognitionSessionService extends GetxService { } void _handleFaceResult(FaceStream? event) { - if (event == null || _activeSessionId == null || _resultLocked) return; + final activeSessionId = _activeSessionId; + if (event == null || activeSessionId == null || _resultLocked) return; + // EventChannel 可能在会话切换后仍投递上一帧;空会话或旧会话结果都不能绑定到下一笔订单。 + if (event.sessionId != activeSessionId) return; final userId = event.userId?.trim(); if (userId == null || userId.isEmpty) return; _resultLocked = true; diff --git a/lib/common/services/index.dart b/lib/common/services/index.dart index be0cf72..540e55f 100644 --- a/lib/common/services/index.dart +++ b/lib/common/services/index.dart @@ -10,6 +10,7 @@ export './database/dao/face_dao.dart'; export './database/dao/file_upload_record_dao.dart'; export './face_recognition_session_service.dart'; export './database/dao/order_hold_bill_dao.dart'; +export './database/dao/order_evidence_upload_task_dao.dart'; export './database/dao/order_dao.dart'; export './database/dao/print_history_dao.dart'; export './database/dao/canteen_spu_dao.dart'; @@ -27,6 +28,7 @@ export './database/entity/classify_entity.dart'; export './database/entity/face_entity.dart'; export './database/entity/file_upload_record_entity.dart'; export './database/entity/order_entity.dart'; +export './database/entity/order_evidence_upload_task_entity.dart'; export './database/entity/order_hold_bill_entity.dart'; export './database/entity/print_history_entity.dart'; export './database/entity/canteen_spu_entity.dart'; diff --git a/lib/common/utils/index.dart b/lib/common/utils/index.dart index 89d92c0..b2f7ab8 100644 --- a/lib/common/utils/index.dart +++ b/lib/common/utils/index.dart @@ -49,6 +49,7 @@ export './sync/sync_check_tool.dart'; export './sync/sync_idle_tool.dart'; export './sync/scheduled_task_storage_tool.dart'; export './sync/file_upload_record_tool.dart'; +export './sync/order_evidence_upload_tool.dart'; export './sync/terminal_log_upload_tool.dart'; export './sync/terminal_heartbeat_tool.dart'; export './sync/terminal_auto_update_tool.dart'; diff --git a/lib/common/utils/manage/http_manage.dart b/lib/common/utils/manage/http_manage.dart index 2c944da..ddb78cb 100644 --- a/lib/common/utils/manage/http_manage.dart +++ b/lib/common/utils/manage/http_manage.dart @@ -5,7 +5,7 @@ import 'package:common_utils/common_utils.dart'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; -import 'package:get/get.dart'; +import 'package:get/get.dart' hide FormData; import 'package:pj_supermarket_cloud/common/index.dart'; @@ -17,6 +17,9 @@ class HttpManage { /// 后台心跳、版本检查等旁路请求禁止弹 Toast 时写入 Dio RequestOptions.extra 的稳定键。 static const String silentRequestKey = 'silentRequest'; + /// 需要把统一业务非零码作为 Dio 异常交给调用方区分明确失败与超时的请求标记。 + static const String rejectBusinessErrorKey = 'rejectBusinessError'; + /// HTTP 管理日志标签。 static const _tag = "http管理"; @@ -49,6 +52,10 @@ class HttpManage { 'idcard', 'certno', 'realname', + 'referenceimageurl', + 'imagehash', + 'localfilepath', + 'evidencefilepath', }; /// 进程内唯一的 HTTP 管理器实例,确保拦截器和连接池不被重复创建。 @@ -144,7 +151,9 @@ class HttpManage { options: e.requestOptions, response: serviceData.data, ); - if (silent && serviceData.code != 0) { + if (serviceData.code != 0 && + (silent || + e.requestOptions.extra[rejectBusinessErrorKey] == true)) { handler.reject( DioException( requestOptions: e.requestOptions, @@ -356,6 +365,22 @@ class HttpManage { /// 递归遮蔽 Map/List 中的敏感字段,保留非敏感结构用于问题定位。 dynamic _redactLogData(dynamic data) { + if (data is FormData) { + return { + 'fields': { + for (final field in data.fields) + field.key: _isSensitiveLogKey(field.key) ? '***' : field.value, + }, + 'files': [ + for (final file in data.files) + { + 'field': file.key, + 'filename': file.value.filename ?? '未命名文件', + 'length': file.value.length, + }, + ], + }; + } if (data is Map) { return data.map( (key, value) => MapEntry( diff --git a/lib/common/utils/plugs/face_discern_plugs.dart b/lib/common/utils/plugs/face_discern_plugs.dart index 12e1997..ce2b65c 100644 --- a/lib/common/utils/plugs/face_discern_plugs.dart +++ b/lib/common/utils/plugs/face_discern_plugs.dart @@ -103,6 +103,20 @@ abstract class FaceDiscernPlugs { } } + /// 清理 Android 私有目录中超过截止时间且未被 Floor 任务引用的证据图片。 + /// + /// [referencedPaths] 仅在进程内传给原生层做路径保护,不会写入日志或发送到服务端。 + static Future cleanupOrderEvidenceFiles({ + required Set referencedPaths, + required DateTime olderThan, + }) async { + return await _channel.invokeMethod('cleanupOrderEvidenceFiles', { + 'referencedPaths': referencedPaths.toList(growable: false), + 'olderThan': olderThan.millisecondsSinceEpoch, + }) ?? + 0; + } + static Future getRecognizeConfiguration() { return _invokeRecognizeConfiguration('getRecognizeConfiguration'); } diff --git a/lib/common/utils/sync/order_evidence_upload_tool.dart b/lib/common/utils/sync/order_evidence_upload_tool.dart new file mode 100644 index 0000000..c0030ea --- /dev/null +++ b/lib/common/utils/sync/order_evidence_upload_tool.dart @@ -0,0 +1,763 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 订单识别证据本地队列入口。 +/// +/// 本工具只保存短期待办和失败元数据,长期证据由服务端 `hard_order_evidence_image` 保存。 +/// 所有批处理均串行执行,避免与收银、人脸识别及其他重型任务争用磁盘和上行带宽。 +abstract final class OrderEvidenceUploadTool { + /// 单轮自动或手动补传最多处理的任务数量。 + static const int batchLimit = 20; + + /// 尚未发起订单的识别临时任务最长保留时间。 + static const Duration waitingOrderRetention = Duration(hours: 24); + + /// 已绑定订单但未成功的现场图片最长保留时间。 + static const Duration failedImageRetention = Duration(days: 30); + + /// 成功任务元数据保留时间,超过后只删除本地队列记录。 + static const Duration successfulMetadataRetention = Duration(days: 30); + + /// 服务端允许继续补传的已支付、退款及已核销订单状态编码。 + static const Set uploadableOrderStatuses = {'5', '10', '20'}; + + /// 退避阶梯,依次为 1、5、15、60、120、240、360 分钟,之后固定最长 6 小时。 + static const List retryDelays = [ + Duration(minutes: 1), + Duration(minutes: 5), + Duration(minutes: 15), + Duration(minutes: 60), + Duration(minutes: 120), + Duration(minutes: 240), + Duration(minutes: 360), + ]; + + /// 当前应用数据库中的订单证据任务 DAO;调度启动前数据库必须已经由 AppControl 打开。 + static OrderEvidenceUploadTaskDao get _dao => + Get.find().dataBase.getOrderEvidenceUploadTaskDao; + + /// 保存一次收银场景人脸成功结果对应的参考图和现场图待办。 + /// + /// 同一识别会话重复回调时直接复用已有记录。现场文件生成失败仍会建立缺失任务,待订单 + /// 成功后向服务端明确报告 `UNAVAILABLE`,避免 PC 端把异常误判为没有使用人脸识别。 + static Future createFaceRecognitionTasks({ + required FaceStream recognition, + required FaceEntity face, + }) async { + final sessionId = recognition.sessionId?.trim(); + if (sessionId == null || sessionId.isEmpty) return; + await _cleanupOrphanEvidenceFiles(DateTime.now()); + if ((await _dao.getByRecognitionSession(sessionId)).isNotEmpty) return; + + final now = DateTime.now(); + final capturedAt = recognition.capturedAt == null + ? now + : DateTime.fromMillisecondsSinceEpoch(recognition.capturedAt!); + final package = await PackageInfo.fromPlatform(); + final appVersion = '${package.version}+${package.buildNumber}'; + final expiresAt = now.add(waitingOrderRetention); + final common = ( + sessionId: sessionId, + userId: face.userId, + sampleId: face.sampleId, + similarity: recognition.similar, + threshold: recognition.similarityThreshold, + capturedAt: capturedAt, + appVersion: appVersion, + now: now, + expiresAt: expiresAt, + ); + + await _dao.save(OrderEvidenceUploadTaskEntity( + recognitionSessionId: common.sessionId, + evidenceType: OrderEvidenceType.faceRecognition.code, + evidenceRole: OrderEvidenceRole.faceReference.code, + sequenceNo: 0, + userId: common.userId, + faceSampleId: common.sampleId, + referenceImageUrl: face.imagePath, + referenceImageVersion: face.updateTime?.toIso8601String(), + similarity: common.similarity, + similarityThreshold: common.threshold, + capturedAt: common.capturedAt, + appVersion: common.appVersion, + status: OrderEvidenceTaskStatus.waitingOrder.code, + createTime: common.now, + updateTime: common.now, + expiresAt: common.expiresAt, + )); + await _dao.save(OrderEvidenceUploadTaskEntity( + recognitionSessionId: common.sessionId, + evidenceType: OrderEvidenceType.faceRecognition.code, + evidenceRole: OrderEvidenceRole.faceLive.code, + sequenceNo: 0, + userId: common.userId, + faceSampleId: common.sampleId, + localFilePath: recognition.evidenceFilePath, + imageHash: recognition.evidenceHash, + imageSize: recognition.evidenceSize, + imageWidth: recognition.evidenceWidth, + imageHeight: recognition.evidenceHeight, + similarity: common.similarity, + similarityThreshold: common.threshold, + capturedAt: common.capturedAt, + appVersion: common.appVersion, + status: OrderEvidenceTaskStatus.waitingOrder.code, + lastError: recognition.evidenceFilePath == null + ? _safeFailure(recognition.evidenceError, fallback: '现场人脸图片生成失败') + : null, + createTime: common.now, + updateTime: common.now, + expiresAt: common.expiresAt, + )); + } + + /// 将识别会话绑定到即将提交的终端订单号,并返回下单接口使用的元数据清单。 + /// + /// 绑定只表示订单请求已经发起,不表示服务端已落单或支付成功;服务端 ID 必须在响应成功或 + /// 后台对账后再写入。若同一订单位置已有任务,保留先到记录并取消重复现场文件。 + static Future>> bindSessionToOrder({ + required String recognitionSessionId, + required String androidOrderCode, + }) async { + final tasks = await _dao.getByRecognitionSession(recognitionSessionId); + if (tasks.isEmpty) return const []; + final now = DateTime.now(); + final bound = []; + for (final task in tasks) { + if (_isTerminal(OrderEvidenceTaskStatus.fromCode(task.status))) continue; + final existing = await _dao.getByPosition( + androidOrderCode, + task.evidenceType, + task.evidenceRole, + task.sequenceNo, + ); + if (existing != null && existing.id != task.id) { + await _deleteLocalFile(task.localFilePath); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.cancelled.code, + clearLocalFilePath: true, + lastError: '同一订单证据位置已有待办,本记录已取消', + updateTime: now, + )); + bound.add(existing); + continue; + } + final next = task.copyWith( + androidOrderCode: androidOrderCode, + updateTime: now, + expiresAt: now.add(failedImageRetention), + ); + await _dao.edit(next); + bound.add(next); + } + return bound.map(_toCreateOrderMetadata).toList(growable: false); + } + + /// 返回识别会话产生任务中的应用版本,供下单 DTO 顶层 `appVersion` 使用。 + static Future getSessionAppVersion( + String recognitionSessionId) async { + final tasks = await _dao.getByRecognitionSession(recognitionSessionId); + return tasks.isEmpty ? null : tasks.first.appVersion; + } + + /// 服务端明确返回订单成功后写入订单 ID,并把活动任务切换为可补传状态。 + static Future markOrderReady({ + required String recognitionSessionId, + required String serverOrderId, + }) async { + final now = DateTime.now(); + for (final task + in await _dao.getByRecognitionSession(recognitionSessionId)) { + final status = OrderEvidenceTaskStatus.fromCode(task.status); + if (_isTerminal(status)) continue; + await _dao.edit(task.copyWith( + serverOrderId: serverOrderId, + status: OrderEvidenceTaskStatus.ready.code, + clearNextRetryAt: true, + clearLastError: true, + updateTime: now, + )); + } + } + + /// 订单明确失败或用户在下单前取消时终止识别会话任务,并删除现场临时图片。 + static Future cancelSession( + String recognitionSessionId, { + String reason = '订单未提交,证据任务已取消', + }) async { + final now = DateTime.now(); + for (final task + in await _dao.getByRecognitionSession(recognitionSessionId)) { + if (_isTerminal(OrderEvidenceTaskStatus.fromCode(task.status))) continue; + await _deleteLocalFile(task.localFilePath); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.cancelled.code, + clearLocalFilePath: true, + lastError: _safeFailure(reason), + clearNextRetryAt: true, + updateTime: now, + )); + } + } + + /// 串行处理一批到期任务,并在业务处理前完成 24 小时、30 天和成功元数据清理。 + static Future processPending() async { + final now = DateTime.now(); + await _cleanupExpired(now); + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) { + throw const ScheduledTaskDependencyException('终端尚未绑定,无法补传订单证据'); + } + var scanned = 0; + var skipped = 0; + var succeeded = 0; + var failed = 0; + + final due = await _dao.getDueTasks(now, batchLimit); + for (final task in due) { + scanned++; + final result = await _processTask(hardId, task); + switch (result) { + case _TaskProcessResult.skipped: + skipped++; + break; + case _TaskProcessResult.succeeded: + succeeded++; + break; + case _TaskProcessResult.failed: + failed++; + break; + } + } + + final remaining = (batchLimit - scanned).clamp(0, batchLimit); + if (remaining > 0) { + final reports = await _dao.getUnavailableReportTasks(now, remaining); + for (final task in reports) { + scanned++; + final reported = await _reportUnavailable(hardId, task); + if (reported) { + succeeded++; + } else { + failed++; + } + } + } + return OrderEvidenceUploadSummary( + scanned: scanned, + skipped: skipped, + succeeded: succeeded, + failed: failed, + ); + } + + /// 返回指定尝试次数对应的退避时长;超出阶梯后固定为 6 小时。 + static Duration retryDelayForAttempt(int attemptCount) { + final index = (attemptCount - 1).clamp(0, retryDelays.length - 1); + return retryDelays[index]; + } + + /// 判断下单失败是否仍可能已经在服务端落单,需要保留证据并按终端订单号对账。 + /// + /// 请求超时、断连、取消以及 5xx 都可能发生在服务端提交事务之后;明确的 4xx/业务拒绝和 + /// 证书失败则没有继续对账的必要。非 Dio 异常无法证明服务端未落单,按保守策略保留。 + static bool shouldReconcileOrderFailure(Object error) { + if (error is! DioException) return true; + return switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout || + DioExceptionType.connectionError || + DioExceptionType.cancel || + DioExceptionType.unknown => + true, + DioExceptionType.badCertificate => false, + DioExceptionType.badResponse => + (error.response?.statusCode ?? 500) >= 500, + }; + } + + /// 处理单条活动任务,必要时先按终端订单号对账再提交参考图或上传现场图。 + static Future<_TaskProcessResult> _processTask( + String hardId, + OrderEvidenceUploadTaskEntity source, + ) async { + var task = source; + if (task.androidOrderCode == null || task.androidOrderCode!.isEmpty) { + return _TaskProcessResult.skipped; + } + try { + final resolved = await HardApi.resolveOrderEvidence( + hardId: hardId, + androidOrderCode: task.androidOrderCode!, + ); + if (resolved == null) throw StateError('订单对账结果为空'); + if (!uploadableOrderStatuses.contains(resolved.orderStatus)) { + await _scheduleRetry(task, '订单尚未确认支付'); + return _TaskProcessResult.skipped; + } + final serverEvidence = _findServerEvidence(resolved, task); + if (serverEvidence?.status == 'AVAILABLE') { + if (task.imageHash != null && + serverEvidence?.imageHash != null && + task.imageHash!.toLowerCase() != + serverEvidence!.imageHash!.toLowerCase()) { + await _markPermanent( + task, + '服务端相同位置已存在其他图片哈希', + serverOrderId: resolved.orderId, + evidenceId: serverEvidence.id, + // AVAILABLE 记录不允许被 UNAVAILABLE 覆盖;此标记表示本地无需再发缺失报告。 + unavailableReported: true, + ); + return _TaskProcessResult.failed; + } + await _markSuccess( + task, + serverOrderId: resolved.orderId, + evidenceId: serverEvidence!.id, + imageUrl: serverEvidence.imageUrl, + ); + return _TaskProcessResult.succeeded; + } + if (serverEvidence?.status == 'UNAVAILABLE') { + await _markPermanent( + task, + serverEvidence?.failureReason ?? '服务端已记录证据永久缺失', + serverOrderId: resolved.orderId, + evidenceId: serverEvidence?.id, + unavailableReported: true, + ); + return _TaskProcessResult.failed; + } + task = task.copyWith( + serverOrderId: resolved.orderId, + status: OrderEvidenceTaskStatus.ready.code, + clearNextRetryAt: true, + updateTime: DateTime.now(), + ); + await _dao.edit(task); + + if (task.evidenceRole == OrderEvidenceRole.faceLive.code) { + final path = task.localFilePath; + if (path == null || !await File(path).exists()) { + task = await _markPermanent( + task, + task.lastError ?? '终端现场图片文件已丢失', + serverOrderId: resolved.orderId, + ); + return await _reportUnavailable(hardId, task) + ? _TaskProcessResult.succeeded + : _TaskProcessResult.failed; + } + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.uploading.code, + updateTime: DateTime.now(), + )); + final result = await HardApi.uploadOrderEvidence( + hardId: hardId, + request: _toRequest(task), + filePath: path, + ); + return await _applyServiceResult(task, result); + } + + final result = await HardApi.submitOrderEvidence( + hardId: hardId, + request: _toRequest(task, status: 'PENDING'), + ); + return await _applyServiceResult(task, result); + } catch (error) { + if (_isPermanentError(error)) { + await _markPermanent(task, _safeFailureForError(error)); + } else { + await _scheduleRetry(task, _safeFailureForError(error)); + } + return _TaskProcessResult.failed; + } + } + + /// 把服务端幂等结果转换为本地终态或下一轮重试状态。 + static Future<_TaskProcessResult> _applyServiceResult( + OrderEvidenceUploadTaskEntity task, + ServiceOrderEvidenceUploadResult? result, + ) async { + if (result == null) { + await _scheduleRetry(task, '服务端证据处理结果为空'); + return _TaskProcessResult.failed; + } + if (result.status == 'AVAILABLE') { + await _markSuccess( + task, + serverOrderId: result.orderId, + evidenceId: result.evidenceId, + imageUrl: result.imageUrl, + ); + return _TaskProcessResult.succeeded; + } + if (result.status == 'UNAVAILABLE') { + await _markPermanent( + task, + result.message, + serverOrderId: result.orderId, + evidenceId: result.evidenceId, + unavailableReported: true, + ); + return _TaskProcessResult.failed; + } + await _scheduleRetry(task, result.message); + return _TaskProcessResult.failed; + } + + /// 向服务端报告现场图片已经永久缺失;尚未取得订单 ID 时先执行一次订单对账。 + static Future _reportUnavailable( + String hardId, + OrderEvidenceUploadTaskEntity source, + ) async { + var task = source; + try { + if (task.serverOrderId == null) { + final resolved = await HardApi.resolveOrderEvidence( + hardId: hardId, + androidOrderCode: task.androidOrderCode!, + ); + if (resolved == null || + !uploadableOrderStatuses.contains(resolved.orderStatus)) { + await _scheduleUnavailableRetry(task, '永久缺失上报等待订单支付确认'); + return false; + } + task = task.copyWith( + serverOrderId: resolved.orderId, + updateTime: DateTime.now(), + ); + await _dao.edit(task); + } + final result = await HardApi.submitOrderEvidence( + hardId: hardId, + request: _toRequest( + task, + status: 'UNAVAILABLE', + failureReason: task.lastError ?? '终端证据图片永久缺失', + ), + ); + if (result?.status == 'AVAILABLE') { + await _markSuccess( + task, + serverOrderId: result!.orderId, + evidenceId: result.evidenceId, + imageUrl: result.imageUrl, + ); + return true; + } + if (result == null || result.status != 'UNAVAILABLE') { + await _scheduleUnavailableRetry( + task, + result?.message ?? '永久缺失上报结果为空', + ); + return false; + } + await _dao.edit(task.copyWith( + serverOrderId: result.orderId, + serverEvidenceId: result.evidenceId, + status: OrderEvidenceTaskStatus.fromCode(task.status) == + OrderEvidenceTaskStatus.expired + ? OrderEvidenceTaskStatus.expired.code + : OrderEvidenceTaskStatus.permanentFailed.code, + unavailableReportedAt: DateTime.now(), + clearNextRetryAt: true, + updateTime: DateTime.now(), + )); + return true; + } catch (error) { + await _scheduleUnavailableRetry(task, _safeFailureForError(error)); + return false; + } + } + + /// 清理未绑定和图片到期任务,并删除超过保留期的成功元数据。 + static Future _cleanupExpired(DateTime now) async { + for (final task in await _dao.getExpiredWaitingTasks(now)) { + await _deleteLocalFile(task.localFilePath); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.expired.code, + clearLocalFilePath: true, + lastError: task.androidOrderCode == null + ? '识别后 24 小时内未发起有效订单' + : '订单证据超过本地保留期限仍未完成', + clearNextRetryAt: true, + updateTime: now, + )); + } + for (final task in await _dao.getExpiredImageTasks(now)) { + await _deleteLocalFile(task.localFilePath); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.expired.code, + clearLocalFilePath: true, + lastError: '现场图片保留 30 天后仍未补传成功', + clearNextRetryAt: true, + updateTime: now, + )); + } + await _dao + .deleteSuccessfulBefore(now.subtract(successfulMetadataRetention)); + await _cleanupOrphanEvidenceFiles(now); + } + + /// 删除原生写图成功但尚未来得及落 Floor 队列就崩溃留下的无主文件。 + /// + /// 原生层只会删除超过 24 小时且不在任一本地任务 `localFilePath` 中的 JPEG;仍在等待订单、 + /// 重试或上传中的文件会被保护,并继续遵循绑定后最多 30 天的保留策略。 + static Future _cleanupOrphanEvidenceFiles(DateTime now) async { + try { + final referencedPaths = (await _dao.getAll()) + .map((task) => task.localFilePath) + .whereType() + .where((path) => path.isNotEmpty) + .toSet(); + await FaceDiscernPlugs.cleanupOrderEvidenceFiles( + referencedPaths: referencedPaths, + olderThan: now.subtract(waitingOrderRetention), + ); + } catch (error) { + logger.w( + '|订单证据补传|无主现场图片清理失败|errorType:${error.runtimeType}|', + ); + } + } + + /// 将成功任务切换为终态并立即删除现场图片,文件删除失败也不反向改变服务端成功结果。 + static Future _markSuccess( + OrderEvidenceUploadTaskEntity task, { + required String serverOrderId, + required String evidenceId, + String? imageUrl, + }) async { + await _deleteLocalFile(task.localFilePath); + final now = DateTime.now(); + await _dao.edit(task.copyWith( + serverOrderId: serverOrderId, + status: OrderEvidenceTaskStatus.success.code, + serverEvidenceId: evidenceId, + serverImageUrl: imageUrl, + clearLocalFilePath: true, + clearNextRetryAt: true, + clearLastError: true, + completedAt: now, + updateTime: now, + )); + } + + /// 将确定无法恢复的任务保存为永久失败,可选标记服务端已经收到永久缺失状态。 + static Future _markPermanent( + OrderEvidenceUploadTaskEntity task, + String reason, { + String? serverOrderId, + String? evidenceId, + bool unavailableReported = false, + }) async { + final next = task.copyWith( + serverOrderId: serverOrderId, + serverEvidenceId: evidenceId, + status: OrderEvidenceTaskStatus.permanentFailed.code, + lastError: _safeFailure(reason), + unavailableReportedAt: unavailableReported ? DateTime.now() : null, + clearNextRetryAt: true, + updateTime: DateTime.now(), + ); + await _dao.edit(next); + return next; + } + + /// 保存一次失败及下一次允许执行时间,不抛出原始异常或记录终端完整文件路径。 + static Future _scheduleRetry( + OrderEvidenceUploadTaskEntity task, + String reason, + ) async { + final attempts = task.attemptCount + 1; + final now = DateTime.now(); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.retryWait.code, + attemptCount: attempts, + nextRetryAt: now.add(retryDelayForAttempt(attempts)), + lastError: _safeFailure(reason), + updateTime: now, + )); + } + + /// 永久缺失上报失败时只更新退避时间,保持 EXPIRED 或 PERMANENT_FAILED 终态不再进入上传队列。 + static Future _scheduleUnavailableRetry( + OrderEvidenceUploadTaskEntity task, + String reason, + ) async { + final attempts = task.attemptCount + 1; + final now = DateTime.now(); + final current = OrderEvidenceTaskStatus.fromCode(task.status); + await _dao.edit(task.copyWith( + status: current == OrderEvidenceTaskStatus.expired + ? OrderEvidenceTaskStatus.expired.code + : OrderEvidenceTaskStatus.permanentFailed.code, + attemptCount: attempts, + nextRetryAt: now.add(retryDelayForAttempt(attempts)), + lastError: _safeFailure(reason), + updateTime: now, + )); + } + + /// 查找服务端同一类型、角色和序号的证据位置。 + static ServiceOrderEvidenceImage? _findServerEvidence( + ServiceOrderEvidenceResolve resolved, + OrderEvidenceUploadTaskEntity task, + ) { + for (final evidence in resolved.evidenceImages) { + if (evidence.evidenceType == task.evidenceType && + evidence.evidenceRole == task.evidenceRole && + evidence.sequenceNo == task.sequenceNo) { + return evidence; + } + } + return null; + } + + /// 构造提交或上传接口共用请求,终端本地路径永远不会进入请求字段。 + static OrderEvidenceRequest _toRequest( + OrderEvidenceUploadTaskEntity task, { + String? status, + String? failureReason, + }) { + return OrderEvidenceRequest( + androidOrderCode: task.androidOrderCode!, + evidenceType: task.evidenceType, + evidenceRole: task.evidenceRole, + sequenceNo: task.sequenceNo, + userId: task.userId, + faceSampleId: task.faceSampleId, + referenceImageUrl: task.referenceImageUrl, + referenceImageVersion: + DateTime.tryParse(task.referenceImageVersion ?? ''), + similarity: task.similarity, + similarityThreshold: task.similarityThreshold, + capturedAt: task.capturedAt, + imageHash: task.imageHash, + imageSize: task.imageSize, + imageWidth: task.imageWidth, + imageHeight: task.imageHeight, + status: status, + failureReason: failureReason, + appVersion: task.appVersion, + ); + } + + /// 构造下单 DTO 的可选证据占位元数据,不包含本地文件路径和参考图版本内部字段。 + static Map _toCreateOrderMetadata( + OrderEvidenceUploadTaskEntity task, + ) { + return { + 'evidenceType': task.evidenceType, + 'evidenceRole': task.evidenceRole, + 'sequenceNo': task.sequenceNo, + 'userId': task.userId, + if (task.faceSampleId != null) 'faceSampleId': task.faceSampleId, + if (task.referenceImageUrl != null) + 'referenceImageUrl': task.referenceImageUrl, + if (task.referenceImageVersion != null) + 'referenceImageVersion': task.referenceImageVersion, + if (task.similarity != null) 'similarity': task.similarity, + if (task.similarityThreshold != null) + 'similarityThreshold': task.similarityThreshold, + 'capturedAt': task.capturedAt.toIso8601String(), + if (task.imageHash != null) 'imageHash': task.imageHash, + if (task.imageSize != null) 'imageSize': task.imageSize, + if (task.imageWidth != null) 'imageWidth': task.imageWidth, + if (task.imageHeight != null) 'imageHeight': task.imageHeight, + }; + } + + /// 删除终端私有目录中的现场图;不存在或删除失败时只保留元数据,不泄露完整路径到日志。 + static Future _deleteLocalFile(String? path) async { + if (path == null || path.isEmpty) return; + try { + final file = File(path); + if (await file.exists()) await file.delete(); + } catch (_) { + logger.w('|订单证据补传|现场临时图片删除失败|'); + } + } + + /// 判断本地任务状态是否已经结束,不允许取消或订单绑定重新激活终态任务。 + static bool _isTerminal(OrderEvidenceTaskStatus? status) => + status == OrderEvidenceTaskStatus.success || + status == OrderEvidenceTaskStatus.permanentFailed || + status == OrderEvidenceTaskStatus.expired || + status == OrderEvidenceTaskStatus.cancelled; + + /// 判断服务端拒绝是否属于哈希、格式或归属等确定不可恢复的错误。 + static bool _isPermanentError(Object error) { + if (error is! DioException) return false; + final message = '${error.error ?? ''} ${error.message ?? ''}'; + return const [ + '禁止覆盖', + '哈希不一致', + '不受信任', + '地址不合法', + '不属于当前设备', + '用户不一致', + '样本不一致', + '图片大小与终端上报不一致', + '图片宽度与终端上报不一致', + '图片高度与终端上报不一致', + ].any(message.contains); + } + + /// 将异常映射为不包含 Token、URL、完整路径和图片内容的稳定诊断摘要。 + static String _safeFailureForError(Object error) { + if (error is DioException) { + return switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => + '订单证据请求超时', + DioExceptionType.connectionError => '订单证据服务网络不可达', + DioExceptionType.badCertificate => '订单证据服务证书校验失败', + DioExceptionType.cancel => '订单证据请求已取消', + DioExceptionType.badResponse => + _isPermanentError(error) ? '服务端拒绝不可恢复的证据请求' : '订单证据服务暂未接受请求', + DioExceptionType.unknown => '订单证据请求发生未知网络错误', + }; + } + return '订单证据处理异常(${error.runtimeType})'; + } + + /// 把失败说明限制为单行 300 字符;空说明使用调用点给出的安全默认值。 + static String _safeFailure(String? source, {String fallback = '证据处理失败'}) { + var normalized = + (source == null || source.trim().isEmpty ? fallback : source) + .replaceAll('\n', ' ') + .replaceAll('\r', ' ') + .trim(); + if (normalized.contains('/') || normalized.contains('\\')) { + normalized = fallback; + } + return normalized.length <= 300 + ? normalized + : '${normalized.substring(0, 300)}...'; + } +} + +/// 单条任务在本轮批处理中的结果分类,仅用于汇总计数。 +enum _TaskProcessResult { + /// 当前订单尚未绑定或尚未确认支付,本轮没有写入证据。 + skipped, + + /// 服务端已经确认图片可用或永久缺失上报成功。 + succeeded, + + /// 本轮进入退避等待或确定失败。 + failed, +} diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart index 805f836..a3f432b 100644 --- a/lib/common/utils/sync/sync_check_tool.dart +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -313,6 +313,12 @@ class SyncCheckTool { cron: '0 35 0 * * *', idleOnly: true, ), + ScheduledTaskConfig( + type: ScheduledTaskType.orderEvidenceUpload, + enabled: true, + cron: '0 */5 * * * *', + idleOnly: true, + ), ScheduledTaskConfig( type: ScheduledTaskType.terminalLogUpload, enabled: true, @@ -840,6 +846,20 @@ class SyncCheckTool { case ScheduledTaskType.offlineOrderSync: await SyncOrderTool.syncOfflineOrder(); return const _ScheduledTaskActionResult.success('离线订单同步完成'); + case ScheduledTaskType.orderEvidenceUpload: + if (!AppNetworkTool.isAutoNetwork()) { + return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); + } + final summary = await OrderEvidenceUploadTool.processPending(); + return _ScheduledTaskActionResult.success( + summary.message, + summary: TerminalLogUploadSummary( + scanned: summary.scanned, + skipped: summary.skipped, + succeeded: summary.succeeded, + failed: summary.failed, + ), + ); case ScheduledTaskType.terminalLogUpload: final summary = await TerminalLogUploadTool.upload( currentOnly: request.currentLogOnly, @@ -868,6 +888,7 @@ class SyncCheckTool { ScheduledTaskType.classifySync, ScheduledTaskType.productSync, ScheduledTaskType.offlineOrderSync, + ScheduledTaskType.orderEvidenceUpload, ScheduledTaskType.autoUpdate, ScheduledTaskType.terminalLogUpload, ScheduledTaskType.expiredLogCleanup, @@ -965,6 +986,11 @@ class SyncCheckTool { sourceType: ScheduledTaskSourceType.http, sourceName: 'HTTP 订单接口', ), + ScheduledTaskType.orderEvidenceUpload => const _ScheduledTaskDescriptor( + title: '订单证据补传', + sourceType: ScheduledTaskSourceType.http, + sourceName: '本地证据队列与 HTTP 图片接口', + ), ScheduledTaskType.terminalLogUpload => const _ScheduledTaskDescriptor( title: '终端日志上传', sourceType: ScheduledTaskSourceType.file, diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index b04836c..90b8264 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -62,6 +62,9 @@ mixin OrderMixin { // 购买用户 Rxn payUser = Rxn(null); + /// 当前收银弹框成功的人脸证据结果,只有人脸支付下单时才会消费并绑定订单号。 + Rxn pendingFaceRecognition = Rxn(); + // 二维码识别结果 ServiceQRCode? globalQrCode; @@ -347,6 +350,33 @@ mixin OrderMixin { final serviceMerchantId = appSetting.serviceMerchantId; final orderDao = appC.dataBase.getOrderDao; + final isNetwork = AppNetworkTool.isAutoNetwork(); + final isFacePayment = orderPayType.value == OrderPayType.FACE; + final faceResult = pendingFaceRecognition.value; + final faceSessionId = faceResult?.sessionId?.trim(); + if (isFacePayment && !isNetwork) { + if (faceSessionId != null && faceSessionId.isNotEmpty) { + await OrderEvidenceUploadTool.cancelSession( + faceSessionId, + reason: '人脸支付断网,未发起服务端订单', + ); + } + pendingFaceRecognition.value = null; + Toast.error(msg: '人脸支付需要连接网络'); + return null; + } + var evidenceMetadataList = const >[]; + String? evidenceAppVersion; + if (isFacePayment && faceSessionId != null && faceSessionId.isNotEmpty) { + // 先写入终端订单号再发起远程下单,响应超时也能由后台任务按订单号对账。 + evidenceMetadataList = await OrderEvidenceUploadTool.bindSessionToOrder( + recognitionSessionId: faceSessionId, + androidOrderCode: sqlOrderDetail!.uniqueCode, + ); + evidenceAppVersion = + await OrderEvidenceUploadTool.getSessionAppVersion(faceSessionId); + pendingFaceRecognition.value = null; + } // 获取下单商品 List> shopList = []; @@ -371,7 +401,6 @@ mixin OrderMixin { }; }).toList(); } - final isNetwork = AppNetworkTool.isAutoNetwork(); var pa = { "androidOrderId": sqlOrderId, "androidOrderCode": sqlOrderDetail!.uniqueCode, @@ -385,6 +414,9 @@ mixin OrderMixin { "orderPayType": orderPayType.value?.name, "list": shopList, "extraPrice": amountMealPrice.value, + if (evidenceMetadataList.isNotEmpty) + "evidenceMetadataList": evidenceMetadataList, + if (evidenceAppVersion != null) "appVersion": evidenceAppVersion, }; if (orderPayType.value != null && appSetting.sysType == SysType.CANTEEN && @@ -396,7 +428,9 @@ mixin OrderMixin { pa["walletId"] = payUser.value?.walletId; } } - logger.i("|$_tag|创建api订单,下单参数:|${json.encode(pa)}|"); + final safeLogParameters = Map.from(pa) + ..remove('evidenceMetadataList'); + logger.i("|$_tag|创建api订单,下单参数:|${json.encode(safeLogParameters)}|"); T? result; try { if (isNetwork) { @@ -447,6 +481,36 @@ mixin OrderMixin { sqlOrderDetail.payTime = DateTime.now(); } await orderDao.edit(sqlOrderDetail); + if (isFacePayment && + faceSessionId != null && + faceSessionId.isNotEmpty && + result is String && + result.isNotEmpty) { + try { + await OrderEvidenceUploadTool.markOrderReady( + recognitionSessionId: faceSessionId, + serverOrderId: result, + ); + // 调度器仍会检查支付、人脸和业务弹框安全窗口,当前收银收尾不会被补传占用。 + unawaited( + SyncCheckTool.instance + .trigger( + ScheduledTaskType.orderEvidenceUpload, + triggerType: ScheduledTaskTriggerType.system, + ) + .catchError((error) { + logger.w( + '|$_tag|订单已成功但证据补传触发失败|errorType:${error.runtimeType}|', + ); + }), + ); + } catch (error) { + // 任务已经绑定 androidOrderCode,后续可重新对账;本地证据状态失败不能反向否定已成功订单。 + logger.w( + '|$_tag|订单已成功但证据任务状态更新失败|errorType:${error.runtimeType}|', + ); + } + } if (!isPendingOrderCode) { // 支付结果已经落库,副屏通知只能尽力发送,不能等待其回执阻塞主屏订单收尾。 unawaited(sendSubScreenPaySuccess()); @@ -458,6 +522,15 @@ mixin OrderMixin { } return result; } catch (e, stack) { + if (isFacePayment && + faceSessionId != null && + faceSessionId.isNotEmpty && + !OrderEvidenceUploadTool.shouldReconcileOrderFailure(e)) { + await OrderEvidenceUploadTool.cancelSession( + faceSessionId, + reason: '服务端明确拒绝订单,证据任务已取消', + ); + } Toast.error(msg: "结算订单失败"); logger.e("|$_tag|创建api订单,下单失败error:|$e|stack:|$stack|"); } diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index 1e94b8e..3c0a6f1 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -30,6 +30,7 @@ class SelectPayTypeDialogController extends GetxController { OrderPayType, OrderPayDisplayType, ServiceQRCode?, + FaceStream?, )? onSubmit; /// 聚合订单码支付开始回调,由当前收银业务先创建 Hardware 订单和聚合会话。 @@ -95,6 +96,9 @@ class SelectPayTypeDialogController extends GetxController { /// 当前嵌入收银工作区的人脸会话;为空时不渲染摄像头面板。 final faceSessionHandle = Rxn(); + /// 当前弹框最近一次收银场景人脸成功结果;提交订单时传给订单队列绑定。 + final faceRecognitionResult = Rxn(); + /// 人脸启动、识别或人员查询失败时在支付工作区展示的可恢复说明。 final faceWorkspaceMessage = ''.obs; @@ -168,6 +172,9 @@ class SelectPayTypeDialogController extends GetxController { /// 应用已确认的支付方式,并清理上一方式遗留的局部提示。 void _applyPayDisplayType(OrderPayDisplayType payType) { + if (payType != OrderPayDisplayType.face) { + unawaited(_cancelPendingEvidence()); + } final isOffline = OrderPayDisplayType.isOffline(payType); orderPayType.value = _defaultOrderPayType(payType); globalQrCode = null; @@ -198,6 +205,7 @@ class SelectPayTypeDialogController extends GetxController { /// 释放支付方式选择器与可能仍在运行的人脸会话。 @override void onClose() { + unawaited(_cancelPendingEvidence()); unawaited(_closeFaceSession(cancel: true)); super.onClose(); payTypeRadioC.dispose(); @@ -242,6 +250,7 @@ class SelectPayTypeDialogController extends GetxController { _faceCallbackLock = false; isSubmitting.value = false; faceSessionHandle.value = null; + faceRecognitionResult.value = null; faceWorkspaceMessage.value = ''; faceUserLoading.value = false; closeRecognitionConfirmationVisible.value = false; @@ -301,6 +310,7 @@ class SelectPayTypeDialogController extends GetxController { orderPayType.value!, orderPayDisplayType.value!, globalQrCode, + faceRecognitionResult.value, ); _closeStatus(); if (Get.context != null) Navigator.of(Get.context!).pop(); @@ -320,11 +330,20 @@ class SelectPayTypeDialogController extends GetxController { closeRecognitionConfirmationVisible.value = true; return; } + await _cancelPendingEvidence(); _closeStatus(); onCancel?.call(); Navigator.of(Get.context!).pop(); } + /// 关闭支付弹框前取消尚未绑定订单的本次人脸证据,避免把身份核验图片留在本地队列。 + Future _cancelPendingEvidence() async { + final sessionId = faceRecognitionResult.value?.sessionId; + if (sessionId == null || sessionId.isEmpty) return; + await OrderEvidenceUploadTool.cancelSession(sessionId); + faceRecognitionResult.value = null; + } + /// 取消关闭或切换请求,继续当前识别会话。 void continueCurrentRecognition() { closeRecognitionConfirmationVisible.value = false; @@ -367,6 +386,7 @@ class SelectPayTypeDialogController extends GetxController { } if (activeRecognitionType == OrderPayDisplayType.face) { _faceDispatchRequestId++; + await _cancelPendingEvidence(); await _closeFaceSession(cancel: true); } else if (activeRecognitionType == OrderPayDisplayType.qrCode) { BarcodeScanManage.instance.dispose(); @@ -732,6 +752,7 @@ class SelectPayTypeDialogController extends GetxController { // android人脸识别成功回调 Future _faceDiscernAfter(FaceStream event) async { final reidentifying = isReidentifying.value; + String? createdEvidenceSessionId; try { final isNetwork = AppNetworkTool.isAutoNetwork(); final nextUser = await AppFaceTool.resolveRecognizedUser( @@ -741,6 +762,25 @@ class SelectPayTypeDialogController extends GetxController { if (nextUser == null) { throw StateError('该人脸样本对应人员已失效,请重新同步或重新录入人脸'); } + final sessionId = event.sessionId?.trim(); + if (sessionId == null || sessionId.isEmpty) { + throw StateError('识别结果缺少会话编号,请重新识别人脸'); + } + final sampleId = event.sampleId?.toString(); + if (sampleId == null || sampleId.isEmpty) { + throw StateError('识别结果缺少人脸样本编号,请重新同步人脸'); + } + final face = await app.dataBase.getFaceDao.findBySampleId(sampleId); + if (face == null || face.userId != nextUser.userId) { + throw StateError('本地人脸样本与识别人员不一致,请重新同步人脸'); + } + // 样本归属确认后立即落本地队列,钱包网络请求失败或进程随后退出也不会丢失已采集图片。 + createdEvidenceSessionId = sessionId; + await OrderEvidenceUploadTool.createFaceRecognitionTasks( + recognition: event, + face: face, + ); + var nextWallets = []; if (isNetwork) { try { @@ -761,15 +801,41 @@ class SelectPayTypeDialogController extends GetxController { if (nextWallet != null) { _applyWalletToUser(nextUser, nextWallet); } + final previousSessionId = faceRecognitionResult.value?.sessionId; + if (previousSessionId != null && previousSessionId != sessionId) { + try { + await OrderEvidenceUploadTool.cancelSession( + previousSessionId, + reason: '重新识别人脸后取消旧证据任务', + ); + } catch (error) { + // 旧任务清理失败不应否定本次已经校验成功的人脸,过期维护仍会继续收敛该记录。 + logger.w('|$_tag|旧人脸证据任务取消失败|errorType:${error.runtimeType}|'); + } + } // 人员和钱包使用同一批识别结果一次替换,避免界面短暂混用新旧人员数据。 userWalletList.assignAll(nextWallets); currentWallet.value = nextWallet; payUser.value = nextUser; orderPayType.value = OrderPayType.FACE; orderType = OrderType.BELOW; + faceRecognitionResult.value = event; + createdEvidenceSessionId = null; onDiscernUser?.call(nextUser); faceWorkspaceMessage.value = ''; } catch (e, task) { + if (createdEvidenceSessionId != null) { + try { + await OrderEvidenceUploadTool.cancelSession( + createdEvidenceSessionId, + reason: '人脸识别后的人员或钱包校验未完成', + ); + } catch (cleanupError) { + logger.w( + '|$_tag|本次人脸证据任务回收失败|errorType:${cleanupError.runtimeType}|', + ); + } + } logger.e("|$_tag|人脸识别,意外的错误error:|$e|task:|$task|"); // 重新识别失败时继续保留原人员,避免一次设备或网络异常破坏可支付状态。 if (!reidentifying) payUser.value = null; diff --git a/lib/pages/terminal/canteen_product/controller.dart b/lib/pages/terminal/canteen_product/controller.dart index 6c3dee4..4edf500 100644 --- a/lib/pages/terminal/canteen_product/controller.dart +++ b/lib/pages/terminal/canteen_product/controller.dart @@ -393,6 +393,7 @@ class CanteenProductController extends GetxController with OrderMixin { orderPayType, orderPayDisplayType, qrCode, + faceRecognition, ) async { final log = "|$_tag|回调,识别到用户:|${user.toJson()}|orderType:|$orderType|;orderPayType:|$orderPayType|;orderPayDisplayType|$orderPayDisplayType|;"; @@ -401,11 +402,26 @@ class CanteenProductController extends GetxController with OrderMixin { this.orderPayType.value = orderPayType; this.orderPayDisplayType.value = orderPayDisplayType; globalQrCode = qrCode; + pendingFaceRecognition.value = faceRecognition; payUser.value = user; final validPayUser = _validatePayUser(); - if (!validPayUser) return; + if (!validPayUser) { + final sessionId = faceRecognition?.sessionId; + if (sessionId != null) { + await OrderEvidenceUploadTool.cancelSession(sessionId); + } + pendingFaceRecognition.value = null; + return; + } final validShopCar = _validateShopCar(); - if (!validShopCar) return; + if (!validShopCar) { + final sessionId = faceRecognition?.sessionId; + if (sessionId != null) { + await OrderEvidenceUploadTool.cancelSession(sessionId); + } + pendingFaceRecognition.value = null; + return; + } EasyLoading.show( status: '支付中...', maskType: EasyLoadingMaskType.black, diff --git a/lib/pages/terminal/setting/widgets/setting_sync.dart b/lib/pages/terminal/setting/widgets/setting_sync.dart index eba1ce9..265ea62 100644 --- a/lib/pages/terminal/setting/widgets/setting_sync.dart +++ b/lib/pages/terminal/setting/widgets/setting_sync.dart @@ -483,6 +483,10 @@ Widget _summaryValue( title: '离线订单同步', icon: FAssets.icons.cloudDownload ), + ScheduledTaskType.orderEvidenceUpload => ( + title: '订单证据补传', + icon: FAssets.icons.image + ), ScheduledTaskType.terminalLogUpload => ( title: '终端日志上传', icon: FAssets.icons.logs diff --git a/lib/pages/terminal/supermarket_order/controller.dart b/lib/pages/terminal/supermarket_order/controller.dart index 918d0a4..b1cb8a3 100644 --- a/lib/pages/terminal/supermarket_order/controller.dart +++ b/lib/pages/terminal/supermarket_order/controller.dart @@ -336,6 +336,7 @@ class SupermarketOrderController extends GetxController with OrderMixin { orderPayType, orderPayDisplayType, qrCode, + faceRecognition, ) async { final log = "|$_tag|回调,识别到用户:|${user.toJson()}|orderType:|$orderType|;orderPayType:|$orderPayType|;orderPayDisplayType|$orderPayDisplayType|;"; @@ -344,11 +345,26 @@ class SupermarketOrderController extends GetxController with OrderMixin { this.orderPayType.value = orderPayType; this.orderPayDisplayType.value = orderPayDisplayType; this.globalQrCode = qrCode; + pendingFaceRecognition.value = faceRecognition; payUser.value = user; final validPayUser = _validatePayUser(); - if (!validPayUser) return; + if (!validPayUser) { + final sessionId = faceRecognition?.sessionId; + if (sessionId != null) { + await OrderEvidenceUploadTool.cancelSession(sessionId); + } + pendingFaceRecognition.value = null; + return; + } final validShopCar = _validateShopCar(); - if (!validShopCar) return; + if (!validShopCar) { + final sessionId = faceRecognition?.sessionId; + if (sessionId != null) { + await OrderEvidenceUploadTool.cancelSession(sessionId); + } + pendingFaceRecognition.value = null; + return; + } EasyLoading.show( status: '支付中...', maskType: EasyLoadingMaskType.black, diff --git a/test/common/order_evidence_upload_tool_test.dart b/test/common/order_evidence_upload_tool_test.dart new file mode 100644 index 0000000..9283c7f --- /dev/null +++ b/test/common/order_evidence_upload_tool_test.dart @@ -0,0 +1,107 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 验证订单证据补传的退避、订单对账判定和默认调度配置等纯逻辑。 +void main() { + test('重试间隔按阶梯增长并在六小时封顶', () { + expect(OrderEvidenceUploadTool.retryDelayForAttempt(0), + const Duration(minutes: 1)); + expect(OrderEvidenceUploadTool.retryDelayForAttempt(1), + const Duration(minutes: 1)); + expect(OrderEvidenceUploadTool.retryDelayForAttempt(2), + const Duration(minutes: 5)); + expect(OrderEvidenceUploadTool.retryDelayForAttempt(4), + const Duration(minutes: 60)); + expect(OrderEvidenceUploadTool.retryDelayForAttempt(99), + const Duration(hours: 6)); + }); + + test('超时断连和服务端五百错误保留任务等待订单对账', () { + final request = RequestOptions(path: '/hard/order'); + + expect( + OrderEvidenceUploadTool.shouldReconcileOrderFailure( + DioException( + requestOptions: request, + type: DioExceptionType.receiveTimeout, + ), + ), + isTrue, + ); + expect( + OrderEvidenceUploadTool.shouldReconcileOrderFailure( + DioException( + requestOptions: request, + type: DioExceptionType.connectionError, + ), + ), + isTrue, + ); + expect( + OrderEvidenceUploadTool.shouldReconcileOrderFailure( + DioException.badResponse( + statusCode: 503, + requestOptions: request, + response: Response(requestOptions: request, statusCode: 503), + ), + ), + isTrue, + ); + }); + + test('明确客户端拒绝和证书失败不把无效订单留在对账队列', () { + final request = RequestOptions(path: '/hard/order'); + + expect( + OrderEvidenceUploadTool.shouldReconcileOrderFailure( + DioException.badResponse( + statusCode: 400, + requestOptions: request, + response: Response(requestOptions: request, statusCode: 400), + ), + ), + isFalse, + ); + expect( + OrderEvidenceUploadTool.shouldReconcileOrderFailure( + DioException( + requestOptions: request, + type: DioExceptionType.badCertificate, + ), + ), + isFalse, + ); + }); + + test('订单证据补传默认开启且每五分钟仅空闲执行', () { + final config = SyncCheckTool.defaultConfigs.singleWhere( + (item) => item.type == ScheduledTaskType.orderEvidenceUpload, + ); + + expect(config.enabled, isTrue); + expect(config.cron, '0 */5 * * * *'); + expect(config.idleOnly, isTrue); + }); + + test('参考人脸版本使用 ISO 时间进入创建订单和补传请求', () { + final version = DateTime(2026, 8, 23, 12, 30); + final request = OrderEvidenceRequest( + androidOrderCode: 'terminal-order-1', + evidenceType: OrderEvidenceType.faceRecognition.code, + evidenceRole: OrderEvidenceRole.faceReference.code, + sequenceNo: 0, + userId: '1001', + faceSampleId: '2001', + referenceImageUrl: '/sys-file/oss/file?fileName=face.jpg', + referenceImageVersion: version, + capturedAt: version, + appVersion: '1.4.0+140', + ); + + expect( + request.toJson()['referenceImageVersion'], version.toIso8601String()); + expect(request.toJson(), isNot(contains('imageHash'))); + }); +}