From 7c20242ad75962c741f386f449360645a291762c Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 22 Oct 2026 16:33:20 +0800 Subject: [PATCH] perf: optimize face library startup and differential sync Stream and validate native feature loading with fast decoding and bounded SDK batches. Reconcile authoritative department snapshots with rollback-safe metadata and file updates. Bound downloads, isolate duplicate identifiers, and defer unchanged invalid photo versions. Remove unused legacy face APIs and document device measurements. Validation: 42 Flutter tests in an isolated source snapshot; native unit and device checks; all 5,537 registered features verified byte-for-byte, plus real SDK photo searches and metadata checks. --- android/app/build.gradle | 2 + .../database/FaceLoadReaderChecks.java | 70 ++ .../facediscern/flutter/FaceSearchChecks.java | 123 +++ .../flutter/FaceStartupInstrumentation.java | 184 ++++ .../facediscern/database/DataBaseUtils.java | 241 ++--- .../database/FaceFeatureCodec.java | 103 +++ .../flutter/FaceLibraryLoader.java | 252 ++++++ .../flutter/FlutterFaceDiscernPlugin.java | 90 +- .../facediscern/flutter/FlutterTool.java | 211 +---- .../facediscern/flutter/config/Constants.java | 4 - .../tool/face/servcie/FaceServer.java | 61 +- .../database/FaceFeatureCodecTest.java | 46 + docs/face-startup-performance.md | 79 ++ docs/face-sync-reliability.md | 47 + lib/common/api/user_api.dart | 49 +- .../models/manage/face_sync_models.dart | 2 +- .../models/plugs/face_discern_models.dart | 59 +- .../models/plugs/face_discern_models.g.dart | 29 - .../services/database/dao/face_dao.dart | 75 +- lib/common/services/database/database.dart | 1 + lib/common/services/database/database.g.dart | 202 +++-- .../services/database/entity/face_entity.dart | 5 + .../face_timestamp_converter.dart | 13 + lib/common/utils/app/app_face_tool.dart | 16 +- lib/common/utils/app/app_network_tool.dart | 31 +- lib/common/utils/app/control/app_control.dart | 2 +- lib/common/utils/manage/http_manage.dart | 19 +- .../utils/plugs/face_discern_plugs.dart | 76 +- .../utils/sync/face_image_download_queue.dart | 63 ++ .../utils/sync/face_sync_failure_store.dart | 80 ++ lib/common/utils/sync/face_sync_plan.dart | 83 ++ lib/common/utils/sync/sync_face_tool.dart | 854 ++++++++++-------- .../widgets/order/select_pay_type_dialog.dart | 6 + lib/pages/load/controller.dart | 10 +- .../setting/face_setting_controller.dart | 2 +- .../widgets/face_sync_result_dialog.dart | 2 +- .../setting/widgets/setting_face_plugin.dart | 5 + pubspec.lock | 2 +- pubspec.yaml | 1 + test/face_sync_failure_store_test.dart | 52 ++ test/face_sync_pipeline_test.dart | 480 ++++++++++ test/face_sync_plan_test.dart | 136 +++ 42 files changed, 2889 insertions(+), 979 deletions(-) create mode 100644 android/app/src/androidTest/java/io/flutter/plugins/facediscern/database/FaceLoadReaderChecks.java create mode 100644 android/app/src/androidTest/java/io/flutter/plugins/facediscern/flutter/FaceSearchChecks.java create mode 100644 android/app/src/androidTest/java/io/flutter/plugins/facediscern/flutter/FaceStartupInstrumentation.java create mode 100644 android/app/src/main/java/io/flutter/plugins/facediscern/database/FaceFeatureCodec.java create mode 100644 android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FaceLibraryLoader.java create mode 100644 android/app/src/test/java/io/flutter/plugins/facediscern/database/FaceFeatureCodecTest.java create mode 100644 docs/face-startup-performance.md create mode 100644 docs/face-sync-reliability.md create mode 100644 lib/common/services/database/type_converter/face_timestamp_converter.dart create mode 100644 lib/common/utils/sync/face_image_download_queue.dart create mode 100644 lib/common/utils/sync/face_sync_failure_store.dart create mode 100644 lib/common/utils/sync/face_sync_plan.dart create mode 100644 test/face_sync_failure_store_test.dart create mode 100644 test/face_sync_pipeline_test.dart create mode 100644 test/face_sync_plan_test.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 13f204e..14e0e03 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,6 +33,7 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + testInstrumentationRunner "io.flutter.plugins.facediscern.flutter.FaceStartupInstrumentation" ndk { abiFilters 'arm64-v8a' } @@ -60,6 +61,7 @@ flutter { } dependencies { + testImplementation 'junit:junit:4.13.2' implementation fileTree(dir: 'libs', include: ['*.jar']) // rxjava diff --git a/android/app/src/androidTest/java/io/flutter/plugins/facediscern/database/FaceLoadReaderChecks.java b/android/app/src/androidTest/java/io/flutter/plugins/facediscern/database/FaceLoadReaderChecks.java new file mode 100644 index 0000000..9925b8a --- /dev/null +++ b/android/app/src/androidTest/java/io/flutter/plugins/facediscern/database/FaceLoadReaderChecks.java @@ -0,0 +1,70 @@ +package io.flutter.plugins.facediscern.database; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import io.flutter.plugins.facediscern.database.DataBaseUtils.FaceLoadReader; +import io.flutter.plugins.facediscern.database.DataBaseUtils.FaceLoadRow; + +/** 在独立内存 SQLite 库执行,不向设备业务库注入测试样本。 */ +public final class FaceLoadReaderChecks { + public static int run() { + int assertions = 0; + try (SQLiteDatabase db = SQLiteDatabase.create(null)) { + db.execSQL("CREATE TABLE face (id INTEGER PRIMARY KEY, userId TEXT, sampleId TEXT, userName TEXT, path TEXT, faceModel TEXT)"); + db.execSQL("INSERT INTO face VALUES (1,'a','11','','','[0,128,255]'), (2,'a','12','','','[broken]')," + + " (3,'a','13','','','[-128,-1]'), (4,'b','2147483648','','','[1]'), (5,'b','15','','',NULL)"); + List all = new ArrayList<>(); + try (FaceLoadReader reader = new FaceLoadReader(db, null, null)) { + check(reader.total == 5, "snapshot total"); assertions++; + for (;;) { + List page = reader.readPage(2); + if (page.isEmpty()) break; + check(page.size() <= 2, "bounded page"); assertions++; + all.addAll(page); + } + check(reader.readCount == 5 && reader.decodeFailureCount == 1, "bad row does not truncate query"); assertions++; + check(Arrays.equals(all.get(0).face.getFaceModel(), new byte[]{0,-128,-1}) + && Arrays.equals(all.get(2).face.getFaceModel(), new byte[]{-128,-1}), "byte preservation after bad row"); assertions++; + check(all.get(3).face == null, "overflow ID must not alias another sample"); assertions++; + all.get(1).face.setFaceModel(new byte[]{1,2,3}); + reader.persistRepairedModels(all.subList(1,2)); + try (Cursor c = db.rawQuery("SELECT faceModel FROM face WHERE id=2", null)) { + c.moveToFirst(); + check(Arrays.equals(FaceFeatureCodec.decode(c.getString(0)), new byte[]{1,2,3}), "repair persisted"); assertions++; + } + } + try (FaceLoadReader reader = new FaceLoadReader(db, null, "a")) { + check(reader.total == 3 && reader.readPage(10).size() == 3, "user scope"); assertions++; + } + try (FaceLoadReader reader = new FaceLoadReader(db, null, null)) { + reader.readPage(2); + db.execSQL("INSERT INTO face VALUES (6,'b','16','','','[1]')"); + int count = 2; + for (;;) { + List page = reader.readPage(2); + if (page.isEmpty()) break; + count += page.size(); + } + check(count == 5, "new rows cannot extend snapshot indefinitely"); assertions++; + } + try (FaceLoadReader reader = new FaceLoadReader(db, null, null)) { + reader.readPage(2); + db.execSQL("DELETE FROM face WHERE id=6"); + boolean failed = false; + try { while (!reader.readPage(2).isEmpty()) { } } + catch (IllegalStateException expected) { failed = true; } + check(failed, "deleted rows cannot report complete snapshot"); assertions++; + } + } + return assertions; + } + + private static void check(boolean value, String name) { + if (!value) throw new AssertionError(name); + } +} diff --git a/android/app/src/androidTest/java/io/flutter/plugins/facediscern/flutter/FaceSearchChecks.java b/android/app/src/androidTest/java/io/flutter/plugins/facediscern/flutter/FaceSearchChecks.java new file mode 100644 index 0000000..7acff4e --- /dev/null +++ b/android/app/src/androidTest/java/io/flutter/plugins/facediscern/flutter/FaceSearchChecks.java @@ -0,0 +1,123 @@ +package io.flutter.plugins.facediscern.flutter; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import com.arcsoft.face.ErrorInfo; +import com.arcsoft.face.FaceEngine; +import com.arcsoft.face.FaceFeature; +import com.arcsoft.face.FaceInfo; +import com.arcsoft.face.MaskInfo; +import com.arcsoft.face.enums.DetectFaceOrientPriority; +import com.arcsoft.face.enums.DetectMode; +import com.arcsoft.face.enums.ExtractType; +import com.arcsoft.imageutil.ArcSoftImageFormat; +import com.arcsoft.imageutil.ArcSoftImageUtil; +import com.arcsoft.imageutil.ArcSoftImageUtilError; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Arrays; +import java.util.Collections; + +import io.flutter.plugins.facediscern.tool.face.model.CompareResult; +import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; +import io.flutter.plugins.facediscern.tool.face.servcie.FaceServer; + +/** SDK 明确区分 REGISTER/RECOGNIZE 特征,检索回归必须从本地照片生成真实查询特征。 */ +final class FaceSearchChecks { + static final class Probe { + final String userId; + final FaceFeature feature; + Probe(String userId, FaceFeature feature) { this.userId = userId; this.feature = feature; } + } + + static List prepare(Context context, List reference) { + List probes = new ArrayList<>(); + if (reference.isEmpty()) return probes; + FaceEngine engine = new FaceEngine(); + int init = engine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, + DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, + FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_FACE_RECOGNITION); + if (init != ErrorInfo.MOK) throw new AssertionError("Probe engine initialization failed"); + try { + for (int index : new int[]{0, reference.size() / 3, reference.size() * 2 / 3, reference.size() - 1}) { + FaceEntity source = reference.get(index); + Bitmap bitmap = BitmapFactory.decodeFile(source.getFaceFilePath()); + if (bitmap == null) throw new AssertionError("Probe source image unavailable"); + Bitmap aligned = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); + try { + if (aligned == null) throw new AssertionError("Probe image alignment failed"); + int width = aligned.getWidth(), height = aligned.getHeight(); + byte[] image = ArcSoftImageUtil.createImageData(width, height, ArcSoftImageFormat.BGR24); + if (ArcSoftImageUtil.bitmapToImageData(aligned, image, ArcSoftImageFormat.BGR24) + != ArcSoftImageUtilError.CODE_SUCCESS) throw new AssertionError("Probe image conversion failed"); + List faces = new ArrayList<>(); + if (engine.detectFaces(image, width, height, FaceEngine.CP_PAF_BGR24, faces) != ErrorInfo.MOK || faces.isEmpty()) { + throw new AssertionError("Probe source face unavailable"); + } + FaceFeature feature = new FaceFeature(); + int code = engine.extractFaceFeature(image, width, height, FaceEngine.CP_PAF_BGR24, + faces.get(0), ExtractType.RECOGNIZE, MaskInfo.NOT_WORN, feature); + if (code != ErrorInfo.MOK) throw new AssertionError("Probe feature extraction failed"); + probes.add(new Probe(source.getUserId(), feature)); + } finally { + if (aligned != null && aligned != bitmap && !aligned.isRecycled()) aligned.recycle(); + if (!bitmap.isRecycled()) bitmap.recycle(); + } + } + } finally { + engine.unInit(); + } + return probes; + } + + static int verify(FaceServer server, List probes) { + for (Probe probe : probes) { + CompareResult match = server.searchFaceFeature(probe.feature); + if (match == null || match.getSimilar() <= 0 + || !Objects.equals(probe.userId, match.getFaceEntity().getUserId())) { + throw new AssertionError("SDK face search returned a different user"); + } + } + return probes.size(); + } + + /** 在真实 SDK 内存库验证档案更新与整批拒绝,不改数据库、不写任何照片。 */ + static int verifyMetadata(FaceServer server, List probes) { + if (probes.isEmpty()) return 0; + Probe probe = probes.get(0); + FaceEntity matched = server.searchFaceFeature(probe.feature).getFaceEntity(); + FaceEntity original = new FaceEntity(); + original.setFaceId(matched.getFaceId()); + original.setUserId(matched.getUserId()); + original.setUserName(matched.getUserName()); + byte[] bytes = server.getFaceFeature(matched.getFaceId()).getFeatureData().clone(); + int count = server.getTotalFaceCount(); + FaceEntity update = new FaceEntity(); + update.setFaceId(matched.getFaceId()); + update.setUserId("__metadata_test__"); + update.setUserName("__metadata_test__"); + FaceEntity missing = new FaceEntity(); + missing.setFaceId(-1); + try { + if (server.updateFaceMetadata(Arrays.asList(update, missing)) == ErrorInfo.MOK + || !Objects.equals(original.getUserId(), server.searchFaceFeature(probe.feature).getFaceEntity().getUserId())) { + throw new AssertionError("Metadata batch rejection must not partially apply"); + } + if (server.updateFaceMetadata(Collections.singletonList(update)) != ErrorInfo.MOK + || !"__metadata_test__".equals(server.searchFaceFeature(probe.feature).getFaceEntity().getUserId()) + || count != server.getTotalFaceCount() + || !Arrays.equals(bytes, server.getFaceFeature(matched.getFaceId()).getFeatureData())) { + throw new AssertionError("Metadata update must preserve registered features"); + } + return 2; + } finally { + if (server.updateFaceMetadata(Collections.singletonList(original)) != ErrorInfo.MOK) { + throw new AssertionError("Metadata test restoration failed"); + } + } + } +} diff --git a/android/app/src/androidTest/java/io/flutter/plugins/facediscern/flutter/FaceStartupInstrumentation.java b/android/app/src/androidTest/java/io/flutter/plugins/facediscern/flutter/FaceStartupInstrumentation.java new file mode 100644 index 0000000..96daabb --- /dev/null +++ b/android/app/src/androidTest/java/io/flutter/plugins/facediscern/flutter/FaceStartupInstrumentation.java @@ -0,0 +1,184 @@ +package io.flutter.plugins.facediscern.flutter; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.os.SystemClock; + +import com.arcsoft.face.ActiveFileInfo; +import com.arcsoft.face.ErrorInfo; +import com.arcsoft.face.FaceEngine; +import com.google.gson.Gson; + +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.flutter.plugins.facediscern.database.DatabaseHelper; +import io.flutter.plugins.facediscern.database.FaceLoadReaderChecks; +import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; +import io.flutter.plugins.facediscern.tool.face.servcie.FaceServer; + +/** 手动真机基准入口:不启动收银业务,不做支付/远端同步;实际库仅在 benchmark=true 时读取。 */ +public final class FaceStartupInstrumentation extends Instrumentation { + private Bundle arguments; + + @Override public void onCreate(Bundle arguments) { + this.arguments = arguments == null ? new Bundle() : arguments; + start(); + } + + @Override public void onStart() { + Bundle output = new Bundle(); + try { + int assertions = FaceLoadReaderChecks.run(); + output.putInt("sqliteAssertions", assertions); + output.putInt("batchIsolationAssertions", checkBatchIsolation()); + if ("true".equals(arguments.getString("benchmark"))) { + Context context = getTargetContext(); + if (FaceEngine.getActiveFileInfo(context, new ActiveFileInfo()) != ErrorInfo.MOK) { + throw new AssertionError("Device SDK must already be activated"); + } + List reference = legacyRead(context); + for (FaceEntity face : reference) { + if (face.getFaceModel() == null || face.getFaceModel().length == 0) { + throw new AssertionError("Benchmark requires complete features; never repair business data in tests"); + } + } + List probes = FaceSearchChecks.prepare(context, reference); + boolean legacy = "legacy".equals(arguments.getString("mode")); + int batchSize = Integer.parseInt(arguments.getString("batch", "1000")); + int rounds = Math.min(5, Integer.parseInt(arguments.getString("rounds", "1"))); + for (int i = 0; i < rounds; i++) { + FaceServer.getInstance().release(); + Map result = legacy ? legacyLoad(context) : + new FaceLibraryLoader(batchSize, null).load(context, null); + if (!Boolean.TRUE.equals(result.get("success"))) throw new AssertionError("Registration failed"); + FaceServer server = FaceServer.getInstance(); + Set expected = new HashSet<>(); + for (FaceEntity face : reference) { + expected.add(face.getFaceId()); + if (!Arrays.equals(face.getFaceModel(), server.getFaceFeature(face.getFaceId()).getFeatureData())) { + throw new AssertionError("Registered feature byte mismatch"); + } + } + if (!expected.equals(server.getRegisteredFaceIds()) || server.getTotalFaceCount() != reference.size()) { + throw new AssertionError("Registered sample set mismatch"); + } + int searchChecks = FaceSearchChecks.verify(server, probes); + result.put("metadataChecks", FaceSearchChecks.verifyMetadata(server, probes)); + FaceSearchChecks.verify(server, probes); + result.put("verifiedEveryFeature", true); + result.put("searchChecks", searchChecks); + Bundle progress = new Bundle(); + progress.putString("result", new JSONObject(result).toString()); + sendStatus(0, progress); + } + } + output.putString("stream", "Face startup checks passed\n"); + finish(Activity.RESULT_OK, output); + } catch (Throwable error) { + output.putString("stream", "Face startup checks failed: " + error.getClass().getSimpleName() + + ": " + error.getMessage() + "\n"); + finish(Activity.RESULT_CANCELED, output); + } finally { + FaceServer.getInstance().release(); + } + } + + /** 模拟 SDK 部分写入后报错;仅操作内存替身,不向真实 SDK 写入坏特征。 */ + private static int checkBatchIsolation() { + PartialFailureServer server = new PartialFailureServer(); + List faces = new ArrayList<>(); + for (int id = 1; id <= 1024; id++) { + FaceEntity face = new FaceEntity(id, new byte[]{1}); + face.setSampleId(id); + faces.add(face); + } + new FaceLibraryLoader(1024, null).registerBatch(server, faces); + Set expected = new HashSet<>(); + for (int id = 1; id <= 1024; id++) if (id != 513) expected.add(id); + if (!expected.equals(server.ids) || server.calls > 25) { + throw new AssertionError("Partial SDK failure must isolate only the bad sample"); + } + PartialFailureServer failedCleanup = new PartialFailureServer(); + failedCleanup.rejectCleanup = true; + boolean aborted = false; + try { new FaceLibraryLoader(1024, null).registerBatch(failedCleanup, faces); } + catch (IllegalStateException expectedFailure) { aborted = true; } + if (!aborted || failedCleanup.calls != 1) throw new AssertionError("Cannot retry an unclean SDK batch"); + return 2; + } + + private static final class PartialFailureServer extends FaceServer { + final Set ids = new HashSet<>(); + int calls; + boolean rejectCleanup; + @Override public int registerFaceFeatureInfoBatchFromDb(List batch) { + calls++; + for (FaceEntity face : batch) { + if (face.getFaceId() == 513) return ErrorInfo.MERR_INVALID_PARAM; + if (!ids.add(face.getFaceId())) throw new IllegalArgumentException("Duplicate partial write"); + } + return ErrorInfo.MOK; + } + @Override public int removeFace(int searchId) { + if (rejectCleanup) return ErrorInfo.MERR_INVALID_PARAM; + ids.remove(searchId); + return ErrorInfo.MOK; + } + } + + /** 保留优化前 SELECT * + 每行 Gson 的基准实现,仅存在于测试 APK。 */ + private static List legacyRead(Context context) { + List faces = new ArrayList<>(); + try (DatabaseHelper helper = new DatabaseHelper(context); + SQLiteDatabase db = helper.getWritableDatabase(); + Cursor cursor = db.rawQuery("SELECT * FROM face", null)) { + cursor.getCount(); + while (cursor.moveToNext()) { + FaceEntity face = new FaceEntity(); + face.setFaceId(cursor.getInt(cursor.getColumnIndexOrThrow("sampleId"))); + face.setSampleId(face.getFaceId()); + face.setUserId(cursor.getString(cursor.getColumnIndexOrThrow("userId"))); + face.setUserName(cursor.getString(cursor.getColumnIndexOrThrow("userName"))); + face.setFaceFilePath(cursor.getString(cursor.getColumnIndexOrThrow("path"))); + face.setFaceModel(new Gson().fromJson(cursor.getString(cursor.getColumnIndexOrThrow("faceModel")), byte[].class)); + faces.add(face); + } + } + return faces; + } + + private static Map legacyLoad(Context context) { + long started = SystemClock.elapsedRealtime(); + List faces = legacyRead(context); + long readMs = SystemClock.elapsedRealtime() - started; + FaceServer server = FaceServer.getInstance(); + server.init(context); + server.removeFace(-1); + long registerStarted = SystemClock.elapsedRealtime(); + for (int i = 0; i < faces.size(); i += 100) { + if (server.registerFaceFeatureInfoBatchFromDb(faces.subList(i, Math.min(i + 100, faces.size()))) != ErrorInfo.MOK) { + throw new AssertionError("Legacy batch registration failed"); + } + } + Map result = new HashMap<>(); + result.put("success", true); + result.put("mode", "legacy100"); + result.put("total", faces.size()); + result.put("readAndDecodeMs", readMs); + result.put("registerMs", SystemClock.elapsedRealtime() - registerStarted); + result.put("durationMs", SystemClock.elapsedRealtime() - started); + return result; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java index 2b7dc36..69a6d65 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java @@ -4,6 +4,7 @@ import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteStatement; +import android.os.SystemClock; import com.google.gson.Gson; @@ -17,73 +18,130 @@ import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; public class DataBaseUtils { private static final String TAG = "android数据库"; + private static final Gson GSON = new Gson(); - static public List getUserFace(Context context, String queryUserId) { - List result = new ArrayList<>(); - int rowCount = 0; - DatabaseHelper databaseHelper = null; - SQLiteDatabase database = null; - Cursor cursor = null; + /** 一次装库复用连接;查询/读取错误必须向上抛出,禁止把已读前缀当成完整库。 */ + public static FaceLoadReader openFaceLoadReader(Context context, String userId) { + DatabaseHelper helper = new DatabaseHelper(context); try { - databaseHelper = new DatabaseHelper(context); - database = databaseHelper.getWritableDatabase(); - String sql = "SELECT * FROM face"; - if (Objects.nonNull(queryUserId) && !Objects.equals(queryUserId, "")) { - sql = sql.concat(" WHERE userId = ?"); - cursor = database.rawQuery(sql, new String[]{queryUserId}); - } else { - cursor = database.rawQuery(sql, null); + return new FaceLoadReader(helper.getWritableDatabase(), helper, userId); + } catch (RuntimeException exception) { + helper.close(); + throw exception; + } + } + + /** 行号仅用于历史特征修复,避免按非唯一 sampleId 更新到其他记录。 */ + public static final class FaceLoadRow { + public final long rowId; + public final FaceEntity face; + + private FaceLoadRow(long rowId, FaceEntity face) { + this.rowId = rowId; + this.face = face; + } + } + + public static final class FaceLoadReader implements AutoCloseable { + private final SQLiteDatabase database; + private final DatabaseHelper helper; + private final String userId; + private final long maximumId; + private long afterId; + public final int total; + public int readCount; + public int decodeFailureCount; + public long readNanos; + public long decodeNanos; + + // 包内构造器允许真机测试使用独立内存库,不修改设备业务库。 + FaceLoadReader(SQLiteDatabase database, DatabaseHelper helper, String userId) { + this.database = database; + this.helper = helper; + this.userId = userId == null || userId.trim().isEmpty() ? null : userId; + long started = SystemClock.elapsedRealtimeNanos(); + String where = this.userId == null ? "" : " WHERE userId = ?"; + try (Cursor cursor = database.rawQuery("SELECT COUNT(*), COALESCE(MAX(id), 0) FROM face" + where, + this.userId == null ? null : new String[]{this.userId})) { + cursor.moveToFirst(); + total = cursor.getInt(0); + maximumId = cursor.getLong(1); } - Log.i(TAG, "getUserFace,sql:" + sql + ";queryUserId:" + queryUserId + ";"); - rowCount = cursor.getCount(); - while (cursor.moveToNext()) { - int userIdIndex = cursor.getColumnIndex("userId"); - int sampleIdIndex = cursor.getColumnIndex("sampleId"); - int userNameIndex = cursor.getColumnIndex("userName"); - int pathIndex = cursor.getColumnIndex("path"); - int faceModelIndex = cursor.getColumnIndex("faceModel"); - FaceEntity item = new FaceEntity(); - if (userIdIndex != -1) { - String userId = cursor.getString(userIdIndex); - item.setUserId(userId); + readNanos += SystemClock.elapsedRealtimeNanos() - started; + } + + /** 主键键集分页,每次特征数据限制在较小窗口,避免大游标反复扫描和整库物化。 */ + public List readPage(int limit) { + if (limit <= 0) throw new IllegalArgumentException("Invalid face page size"); + long started = SystemClock.elapsedRealtimeNanos(); + long decodeBefore = decodeNanos; + List rows = new ArrayList<>(limit); + String sql = "SELECT id, userId, sampleId, userName, path, faceModel FROM face" + + " WHERE id > ? AND id <= ?" + (userId == null ? "" : " AND userId = ?") + + " ORDER BY id ASC LIMIT ?"; + String[] args = userId == null + ? new String[]{String.valueOf(afterId), String.valueOf(maximumId), String.valueOf(limit)} + : new String[]{String.valueOf(afterId), String.valueOf(maximumId), userId, String.valueOf(limit)}; + try (Cursor cursor = database.rawQuery(sql, args)) { + while (cursor.moveToNext()) { + afterId = cursor.getLong(0); + readCount++; + FaceEntity face = new FaceEntity(); + try { + int sampleId = Integer.parseInt(cursor.getString(2)); + if (sampleId < 0) throw new NumberFormatException(); + face.setFaceId(sampleId); + face.setSampleId(sampleId); + } catch (NumberFormatException exception) { + // 非法/溢出的搜索标识计为失败行,不能截断为另一人的标识。 + rows.add(new FaceLoadRow(afterId, null)); + continue; + } + face.setUserId(cursor.getString(1)); + face.setUserName(cursor.getString(3)); + face.setFaceFilePath(cursor.getString(4)); + String json = cursor.getString(5); + long decodeStarted = SystemClock.elapsedRealtimeNanos(); + try { + face.setFaceModel(FaceFeatureCodec.decode(json)); + } catch (IllegalArgumentException exception) { + // 单条坏 JSON 不阻断后续行,交给装库流程尝试从原图片恢复。 + decodeFailureCount++; + } finally { + decodeNanos += SystemClock.elapsedRealtimeNanos() - decodeStarted; + } + rows.add(new FaceLoadRow(afterId, face)); } - if (sampleIdIndex != -1) { - int sampleId = cursor.getInt(sampleIdIndex); - item.setSampleId(sampleId); - item.setFaceId(sampleId); + if (rows.isEmpty() && readCount != total) { + throw new IllegalStateException("Face database changed during registration"); } - if (userNameIndex != -1) { - String userName = cursor.getString(userNameIndex); - item.setUserName(userName); - } - if (pathIndex != -1) { - String faceFilePath = cursor.getString(pathIndex); - item.setFaceFilePath(faceFilePath); - } - if (faceModelIndex != -1) { - String faceModel = cursor.getString(faceModelIndex); - if (faceModel != null && !Objects.equals(faceModel, "")) { - item.setFaceModel(toByteArray(faceModel)); + return rows; + } finally { + readNanos += SystemClock.elapsedRealtimeNanos() - started - (decodeNanos - decodeBefore); + } + } + + /** 修复特征按页提交事务,复用连接和预编译语句,避免每张照片开库、提交。 */ + public void persistRepairedModels(List rows) { + if (rows.isEmpty()) return; + database.beginTransaction(); + try (SQLiteStatement statement = database.compileStatement("UPDATE face SET faceModel = ? WHERE id = ?")) { + for (FaceLoadRow row : rows) { + statement.bindString(1, fromByteArray(row.face.getFaceModel())); + statement.bindLong(2, row.rowId); + if (statement.executeUpdateDelete() != 1) { + throw new IllegalStateException("Face repair row disappeared"); } } - result.add(item); - } - Log.i(TAG, "查询人脸数据库成功人脸总数:" + rowCount + "list长度:" + result.size() + ";"); - return result; - } catch (Exception e) { - Log.e(TAG, "查询数据库人脸错误error:" + e.getMessage(), e); - return result; - } finally { - if (cursor != null) { - cursor.close(); - } - if (database != null) { - database.close(); - } - if (databaseHelper != null) { - databaseHelper.close(); + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); } } + + @Override public void close() { + if (helper != null) helper.close(); + } } // 状态检查专用轻量查询:只读取数量与 sampleId 集合所需列,不加载 faceModel 特征大字段。 @@ -99,8 +157,8 @@ public class DataBaseUtils { try { databaseHelper = new DatabaseHelper(context); database = databaseHelper.getWritableDatabase(); - String sql = "SELECT userId, sampleId, userName, path FROM face" - + " WHERE faceModel IS NOT NULL AND faceModel != '' AND faceModel != '[]'"; + String sql = "SELECT sampleId FROM face" + + " WHERE TRIM(COALESCE(faceModel, '')) NOT IN ('', '[]', 'null')"; if (Objects.nonNull(queryUserId) && !Objects.equals(queryUserId, "")) { sql = sql.concat(" AND userId = ?"); cursor = database.rawQuery(sql, new String[]{queryUserId}); @@ -109,32 +167,18 @@ public class DataBaseUtils { } Log.i(TAG, "getFaceProfiles,sql:" + sql + ";queryUserId:" + queryUserId + ";"); while (cursor.moveToNext()) { - int userIdIndex = cursor.getColumnIndex("userId"); - int sampleIdIndex = cursor.getColumnIndex("sampleId"); - int userNameIndex = cursor.getColumnIndex("userName"); - int pathIndex = cursor.getColumnIndex("path"); FaceEntity item = new FaceEntity(); - if (userIdIndex != -1) { - item.setUserId(cursor.getString(userIdIndex)); - } - if (sampleIdIndex != -1) { - int sampleId = cursor.getInt(sampleIdIndex); - item.setSampleId(sampleId); - item.setFaceId(sampleId); - } - if (userNameIndex != -1) { - item.setUserName(cursor.getString(userNameIndex)); - } - if (pathIndex != -1) { - item.setFaceFilePath(cursor.getString(pathIndex)); - } + int sampleId = Integer.parseInt(cursor.getString(0)); + if (sampleId < 0) throw new IllegalStateException("Invalid face search ID"); + item.setSampleId(sampleId); + item.setFaceId(sampleId); result.add(item); } Log.i(TAG, "查询人脸概况成功总数:" + result.size() + ";"); return result; } catch (Exception e) { - Log.e(TAG, "查询人脸概况错误error:" + e.getMessage(), e); - return result; + Log.e(TAG, "查询人脸概况失败type:" + e.getClass().getSimpleName()); + throw new IllegalStateException("Cannot read complete face profiles"); } finally { if (cursor != null) { cursor.close(); @@ -148,43 +192,8 @@ public class DataBaseUtils { } } - static public Boolean updateFaceModel(Context context, FaceEntity faceEntity) { - DatabaseHelper databaseHelper = null; - SQLiteDatabase database = null; - SQLiteStatement statement = null; - try { - databaseHelper = new DatabaseHelper(context); - database = databaseHelper.getWritableDatabase(); - String sqlUpdate = "UPDATE face SET faceModel = ? WHERE sampleId = ?"; - statement = database.compileStatement(sqlUpdate); - statement.bindString(1, fromByteArray(faceEntity.getFaceModel())); - statement.bindLong(2, faceEntity.getSampleId()); - int rowsAffected = statement.executeUpdateDelete(); - Log.i(TAG, "getUserFaceupdateFaceModel,sql:" + sqlUpdate + ";sampleId:" + faceEntity.getSampleId() + ";faceModel长度:" + faceEntity.getFaceModel().length + ";res:" + rowsAffected + ";"); - return rowsAffected > 0; - } catch (Exception e) { - Log.e(TAG, "更新人脸模型失败username:" + faceEntity.getUserName() + ";id:" + faceEntity.getFaceId() + ";"); - Log.e(TAG, "更新人脸模型失败error:" + e.getMessage(), e); - return false; - } finally { - if (statement != null) { - statement.close(); - } - if (database != null) { - database.close(); - } - if (databaseHelper != null) { - databaseHelper.close(); - } - } - } - - public static byte[] toByteArray(String json) { - return new Gson().fromJson(json, byte[].class); - } - public static String fromByteArray(byte[] byteArray) { - return new Gson().toJson(byteArray); + return GSON.toJson(byteArray); } } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/FaceFeatureCodec.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/FaceFeatureCodec.java new file mode 100644 index 0000000..63a9a79 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/FaceFeatureCodec.java @@ -0,0 +1,103 @@ +package io.flutter.plugins.facediscern.database; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Arrays; + +/** 兼容 Flutter 无符号字节及历史 Java 有符号字节,不构造装箱数字列表。 */ +public final class FaceFeatureCodec { + private FaceFeatureCodec() { } + + public static byte[] decode(String json) { + if (json == null || json.trim().isEmpty()) return null; + byte[] integers = decodeIntegerArray(json); + if (integers != null) return integers; + return decodeCompatibleArray(json); + } + + /** 常规库由 jsonEncode/Gson 写出纯整数数组,直接扫描避免逐字节 JsonReader 调用。 */ + private static byte[] decodeIntegerArray(String json) { + int length = json.length(); + int position = skipSpace(json, 0); + if (position == length || json.charAt(position++) != '[') return null; + position = skipSpace(json, position); + if (position < length && json.charAt(position) == ']') { + return skipSpace(json, position + 1) == length ? new byte[0] : null; + } + // 整数数组中逗号数精确给出长度;只分配最终 byte[],无需装箱、扩容和裁剪。 + int count = 1; + for (int i = position; i < length; i++) if (json.charAt(i) == ',') count++; + byte[] bytes = new byte[count]; + int size = 0; + while (position < length) { + boolean negative = json.charAt(position) == '-'; + if (negative) position++; + int numberStart = position; + int value = 0; + while (position < length) { + char digit = json.charAt(position); + if (digit < '0' || digit > '9') break; + // 饱和到越界值,防止恶意长数字整数溢出后被当成有效字节。 + value = value > 255 ? 256 : value * 10 + digit - '0'; + position++; + } + if (position == numberStart || (position - numberStart > 1 && json.charAt(numberStart) == '0')) return null; + position = skipSpace(json, position); + if (position == length) return null; + char delimiter = json.charAt(position++); + if (delimiter != ',' && delimiter != ']') return null; + if (value > (negative ? 128 : 255)) throw new IllegalArgumentException("Invalid face feature byte"); + if (size == bytes.length) return null; + bytes[size++] = (byte) (negative ? -value : value); + if (delimiter == ']') return size == bytes.length && skipSpace(json, position) == length ? bytes : null; + position = skipSpace(json, position); + } + return null; + } + + private static int skipSpace(String json, int position) { + while (position < json.length()) { + char value = json.charAt(position); + if (value != ' ' && value != '\n' && value != '\r' && value != '\t') break; + position++; + } + return position; + } + + /** 兼容历史合法 JSON 的整值小数、指数及字符串数字,异常仍由样本级边界隔离。 */ + private static byte[] decodeCompatibleArray(String json) { + try (JsonReader reader = new JsonReader(new StringReader(json))) { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + requireEnd(reader); + return null; + } + byte[] bytes = new byte[1024]; + int size = 0; + reader.beginArray(); + while (reader.hasNext()) { + int value = reader.nextInt(); + if (value < -128 || value > 255) { + throw new IllegalArgumentException("Invalid face feature byte"); + } + if (size == bytes.length) bytes = Arrays.copyOf(bytes, bytes.length * 2); + bytes[size++] = (byte) value; + } + reader.endArray(); + requireEnd(reader); + return size == bytes.length ? bytes : Arrays.copyOf(bytes, size); + } catch (IOException | IllegalStateException exception) { + // 不附带原始 JSON 或底层异常文案,避免将生物特征写入日志。 + throw new IllegalArgumentException("Invalid face feature JSON"); + } + } + + private static void requireEnd(JsonReader reader) throws IOException { + if (reader.peek() != JsonToken.END_DOCUMENT) { + throw new IllegalArgumentException("Trailing face feature data"); + } + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FaceLibraryLoader.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FaceLibraryLoader.java new file mode 100644 index 0000000..722f797 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FaceLibraryLoader.java @@ -0,0 +1,252 @@ +package io.flutter.plugins.facediscern.flutter; + +import android.content.Context; +import android.os.SystemClock; +import android.util.Log; + +import com.arcsoft.face.ErrorInfo; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.flutter.plugins.facediscern.database.DataBaseUtils; +import io.flutter.plugins.facediscern.database.DataBaseUtils.FaceLoadReader; +import io.flutter.plugins.facediscern.database.DataBaseUtils.FaceLoadRow; +import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; +import io.flutter.plugins.facediscern.tool.face.servcie.FaceServer; + +/** 在原有人脸互斥队列内执行有界读取、修复和批量装载,不改变特征持久化格式。 */ +final class FaceLibraryLoader { + static final int READ_PAGE_SIZE = 128; + private static final long PROGRESS_INTERVAL_MS = 150; + private final int batchSize; + private final FlutterTool.RegistrationProgressListener listener; + private final Set seenIds = new HashSet<>(); + private final Set failedIds = new LinkedHashSet<>(); + private int total; + private int success; + private int failed; + private int readCount; + private int decodeFailures; + private int missingFeatures; + private int repaired; + private int sdkCalls; + private int fallbackBatches; + private int progressEvents; + private int engineCode = -99; + private int clearCode; + private long readNanos; + private long decodeNanos; + private long repairNanos; + private long initNanos; + private long registerNanos; + private long lastProgressAt = -PROGRESS_INTERVAL_MS; + private String lastPhase = ""; + + FaceLibraryLoader(int batchSize, FlutterTool.RegistrationProgressListener listener) { + if (batchSize <= 0) throw new IllegalArgumentException("Invalid registration batch size"); + this.batchSize = batchSize; + this.listener = listener; + } + + Map load(Context context, String userId) { + long started = SystemClock.elapsedRealtime(); + String message = "人脸库注册完成"; + boolean complete = false; + publish("reading", "正在读取本地人脸库", 0, true); + try (FaceLoadReader reader = DataBaseUtils.openFaceLoadReader(context, userId)) { + total = reader.total; + try { + FaceServer server = FaceServer.getInstance(); + publish("initializing", "正在初始化人脸引擎", 0, true); + long initStarted = SystemClock.elapsedRealtimeNanos(); + server.init(context); + engineCode = server.getEngineInitCode(); + initNanos += SystemClock.elapsedRealtimeNanos() - initStarted; + if (!server.isEngineReady()) throw new IllegalStateException("Face engine unavailable"); + + boolean fullRebuild = userId == null || userId.trim().isEmpty(); + if (fullRebuild) { + clearCode = server.removeFace(-1); + if (clearCode != ErrorInfo.MOK) { + throw new IllegalStateException("Cannot clear face library"); + } + } else { + server.removeFacesByUserId(userId); + } + // 用户范围重装时,禁止坏数据覆盖其他用户仍在库的相同搜索标识。 + seenIds.addAll(server.getRegisteredFaceIds()); + int previousCount = seenIds.size(); + List batch = new ArrayList<>(batchSize); + for (;;) { + List page = reader.readPage(READ_PAGE_SIZE); + readCount = reader.readCount; + if (page.isEmpty()) break; + List eligible = new ArrayList<>(page.size()); + for (FaceLoadRow row : page) { + if (row.face == null || !seenIds.add(row.face.getFaceId())) { + fail(row.face); + } else { + eligible.add(row); + } + } + repairPage(context, reader, eligible); + for (FaceLoadRow row : eligible) { + FaceEntity face = row.face; + if (face.getFaceModel() == null || face.getFaceModel().length == 0) { + fail(face); + continue; + } + batch.add(face); + if (batch.size() == batchSize) { + registerBatch(server, batch); + batch.clear(); + } + } + publish("loading", "读取并装载本地人脸", readCount, true); + } + if (!batch.isEmpty()) registerBatch(server, batch); + publish("verifying", "正在核对内存人脸库", readCount, true); + int expected = previousCount + success; + complete = readCount == total && success + failed == total + && server.getTotalFaceCount() == expected + && server.getRegisteredFaceIds().size() == expected; + if (!complete) message = "人脸库数量核对失败,请重新注册"; + else if (total == 0) message = "本地数据库暂无人脸样本"; + else if (failed > 0) message = "人脸库注册完成,部分样本失败,请前往人脸管理处理"; + } finally { + readNanos = reader.readNanos; + decodeNanos = reader.decodeNanos; + decodeFailures = reader.decodeFailureCount; + readCount = reader.readCount; + } + } catch (Exception exception) { + complete = false; + // 不记录底层 SQL/JSON 异常原文,避免特征或业务数据出现在诊断日志。 + Log.e("FaceStartupPerf", "load_failed phase=" + lastPhase + " type=" + exception.getClass().getSimpleName() + + " engineCode=" + engineCode + " clearCode=" + clearCode); + message = "本地人脸库装载中断,请重新注册"; + failed = Math.max(failed, total - success); + } + boolean ok = complete && failed == 0; + publish(ok ? "completed" : "completedWithErrors", message, readCount, false); + Map metrics = new HashMap<>(); + metrics.put("readMs", readNanos / 1000000); + metrics.put("decodeMs", decodeNanos / 1000000); + metrics.put("repairMs", repairNanos / 1000000); + metrics.put("engineInitMs", initNanos / 1000000); + metrics.put("engineCode", engineCode); + metrics.put("clearCode", clearCode); + metrics.put("registerMs", registerNanos / 1000000); + metrics.put("readCount", readCount); + metrics.put("decodeFailures", decodeFailures); + metrics.put("missingFeatures", missingFeatures); + metrics.put("repairedCount", repaired); + metrics.put("sdkCalls", sdkCalls); + metrics.put("fallbackBatches", fallbackBatches); + metrics.put("progressEvents", progressEvents); + metrics.put("readPageSize", READ_PAGE_SIZE); + metrics.put("registerBatchSize", batchSize); + metrics.put("loadCompleted", complete ? 1 : 0); + Map result = new HashMap<>(); + result.put("success", ok); + result.put("message", message); + result.put("total", total); + result.put("successCount", success); + result.put("failedCount", failed); + result.put("failedSampleIds", new ArrayList<>(failedIds)); + result.put("durationMs", SystemClock.elapsedRealtime() - started); + result.put("metrics", metrics); + Log.i("FaceStartupPerf", "load total=" + total + " success=" + success + " failed=" + failed + + " durationMs=" + result.get("durationMs") + " metrics=" + metrics); + return result; + } + + private void repairPage(Context context, FaceLoadReader reader, List rows) { + List repairs = new ArrayList<>(); + long started = SystemClock.elapsedRealtimeNanos(); + for (FaceLoadRow row : rows) { + if (row.face.getFaceModel() != null && row.face.getFaceModel().length > 0) continue; + missingFeatures++; + publish("repairing", "正在修复缺失的人脸特征", readCount, true); + try { + byte[] feature = FlutterTool.extractFaceModelFromFile(context, row.face.getFaceFilePath()); + if (feature != null && feature.length > 0) { + row.face.setFaceModel(feature); + repairs.add(row); + } + } catch (Exception exception) { + // 单张坏图保留为失败行,不影响下一张图片或后续页面。 + } + } + try { + reader.persistRepairedModels(repairs); + repaired += repairs.size(); + } catch (Exception exception) { + for (FaceLoadRow row : repairs) row.face.setFaceModel(null); + Log.w("FaceStartupPerf", "repair_commit_failed count=" + repairs.size()); + } + repairNanos += SystemClock.elapsedRealtimeNanos() - started; + } + + /** 批量失败时先清理可能部分写入的目标,再二分隔离,避免一张坏特征拖慢整批。 */ + void registerBatch(FaceServer server, List batch) { + long started = SystemClock.elapsedRealtimeNanos(); + int code; + try { + sdkCalls++; + code = server.registerFaceFeatureInfoBatchFromDb(batch); + } catch (Exception exception) { + code = -99; + } + registerNanos += SystemClock.elapsedRealtimeNanos() - started; + if (code == ErrorInfo.MOK) { + success += batch.size(); + publish("loading", "读取并装载本地人脸", readCount, true); + return; + } + fallbackBatches++; + started = SystemClock.elapsedRealtimeNanos(); + for (FaceEntity face : batch) { + if (server.removeFace(face.getFaceId()) != ErrorInfo.MOK) { + throw new IllegalStateException("Cannot isolate failed face batch"); + } + } + registerNanos += SystemClock.elapsedRealtimeNanos() - started; + if (batch.size() == 1) { + fail(batch.get(0)); + } else { + int middle = batch.size() / 2; + registerBatch(server, batch.subList(0, middle)); + registerBatch(server, batch.subList(middle, batch.size())); + } + } + + private void fail(FaceEntity face) { + failed++; + if (face != null) failedIds.add(face.getSampleId()); + } + + private void publish(String phase, String message, int processed, boolean inProgress) { + long now = SystemClock.elapsedRealtime(); + if (inProgress && phase.equals(lastPhase) && now - lastProgressAt < PROGRESS_INTERVAL_MS) return; + lastPhase = phase; + lastProgressAt = now; + progressEvents++; + Map progress = new HashMap<>(); + progress.put("phase", phase); + progress.put("message", message); + progress.put("processed", processed); + progress.put("total", total); + progress.put("successCount", success); + progress.put("failedCount", failed); + progress.put("currentSampleId", null); + progress.put("inProgress", inProgress); + if (listener != null) listener.onProgress(progress); + } +} 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 0f709ea..5aaa86c 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 @@ -27,6 +27,11 @@ import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager; import io.flutter.plugins.facediscern.tool.face.constants.FaceBrodConstant; +import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; +import io.flutter.plugins.facediscern.tool.face.servcie.FaceServer; +import com.arcsoft.face.ErrorInfo; +import com.arcsoft.face.ActiveFileInfo; +import com.arcsoft.face.FaceEngine; import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration; import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfigurationStore; import io.flutter.plugins.facediscern.ui.FaceDiscern; @@ -317,23 +322,20 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me result.error("RESET_RECOGNIZE_CONFIGURATION_FAILED", e.getMessage(), null); } break; - case "active": - runSdkActivation(call, result, false); - break; - case "getConfig": - result.success(FlutterTool.getConfig(context)); - break; case "getFaceStatus": runFaceStatus(call, result); break; - case "activateFaceSdk": - runSdkActivation(call, result, true); + case "isFaceSdkActivated": + FACE_EXECUTOR.execute(() -> { + boolean activated = FaceEngine.getActiveFileInfo(context, new ActiveFileInfo()) == ErrorInfo.MOK; + MAIN_HANDLER.post(() -> result.success(activated)); + }); break; - case "registerFaceByDatabase": - startRegistrationTask(call.argument("userId"), result, true); + case "activateFaceSdk": + runSdkActivation(call, result); break; case "rebuildFaceLibrary": - startRegistrationTask(call.argument("userId"), result, false); + startRegistrationTask(call.argument("userId"), result); break; case "extractFaceFeature": startFaceFeatureExtraction(stringArgument(call, "filePath"), result); @@ -344,14 +346,12 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me case "applyFaceDelta": startFaceDelta(call, result); break; + case "updateFaceMetadata": + startFaceMetadataUpdate(call, result); + break; case "removeFaces": startFaceBulkRemove(call, result); break; - case "getFace": - String sampleId = Objects.requireNonNull(call.argument("sampleId")).toString(); - Map face = FlutterTool.getFace(context, Integer.parseInt(sampleId)); - result.success(face); - break; case "removeFace": String removeSampleId = Objects.requireNonNull(call.argument("sampleId")).toString(); int removeSampleIdValue = Integer.parseInt(removeSampleId); @@ -407,19 +407,14 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me * @param result Flutter MethodChannel 异步结果 * @param fullResult true 返回 activateFaceSdk 的完整结果 Map,false 只返回布尔值 */ - private void runSdkActivation(MethodCall call, MethodChannel.Result result, boolean fullResult) { + private void runSdkActivation(MethodCall call, MethodChannel.Result result) { String appId = stringArgument(call, "appId"); String activeKey = stringArgument(call, "activeKey"); String sdkKey = stringArgument(call, "sdkKey"); FACE_EXECUTOR.execute(() -> { try { - if (fullResult) { - Map activation = FlutterTool.activateFaceSdk(context, appId, activeKey, sdkKey); - MAIN_HANDLER.post(() -> result.success(activation)); - } else { - Boolean activated = FlutterTool.active(context, appId, activeKey, sdkKey); - MAIN_HANDLER.post(() -> result.success(activated)); - } + Map activation = FlutterTool.activateFaceSdk(context, appId, activeKey, sdkKey); + MAIN_HANDLER.post(() -> result.success(activation)); } catch (Exception e) { Log.e(TAG, "激活人脸SDK失败:" + e.getMessage(), e); MAIN_HANDLER.post(() -> result.error("FACE_SDK_ACTIVATE_FAILED", "激活人脸SDK失败", e.getMessage())); @@ -427,7 +422,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me }); } - private void startRegistrationTask(String userId, MethodChannel.Result result, boolean legacyResult) { + private void startRegistrationTask(String userId, MethodChannel.Result result) { if (!FlutterTool.tryStartRegistration("rebuildFaceLibrary")) { result.error("REGISTRATION_IN_PROGRESS", "人脸库正在注册,请稍候", null); return; @@ -435,13 +430,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me FACE_EXECUTOR.execute(() -> { try { Map registrationResult = FlutterTool.rebuildFaceLibrary(context, userId, this::emitRegistrationProgress); - MAIN_HANDLER.post(() -> { - if (legacyResult) { - result.success(registrationResult.get("failedSampleIds")); - } else { - result.success(registrationResult); - } - }); + MAIN_HANDLER.post(() -> result.success(registrationResult)); } catch (Exception e) { Log.e(TAG, "人脸库注册失败:" + e.getMessage(), e); MAIN_HANDLER.post(() -> result.error("REGISTRATION_FAILED", "人脸库注册失败,请稍后重试", null)); @@ -539,6 +528,43 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me }); } + private void startFaceMetadataUpdate(MethodCall call, MethodChannel.Result result) { + Object rawItems = call.argument("items"); + if (!(rawItems instanceof List)) { + result.error("INVALID_FACE_METADATA", "人脸档案参数无效", null); + return; + } + if (!FlutterTool.tryStartRegistration("updateFaceMetadata")) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null); + return; + } + FACE_EXECUTOR.execute(() -> { + try { + List updates = new ArrayList<>(); + for (Object raw : (List) rawItems) { + if (!(raw instanceof Map)) throw new IllegalArgumentException(); + Map item = (Map) raw; + Object id = item.get("sampleId"); + if (!(id instanceof Number) || ((Number) id).longValue() <= 0 + || ((Number) id).longValue() > Integer.MAX_VALUE + || !(item.get("userId") instanceof String) + || !(item.get("username") instanceof String)) throw new IllegalArgumentException(); + FaceEntity update = new FaceEntity(); + update.setFaceId(((Number) id).intValue()); + update.setUserId((String) item.get("userId")); + update.setUserName((String) item.get("username")); + updates.add(update); + } + boolean success = FaceServer.getInstance().updateFaceMetadata(updates) == ErrorInfo.MOK; + MAIN_HANDLER.post(() -> result.success(success)); + } catch (Exception exception) { + MAIN_HANDLER.post(() -> result.error("UPDATE_FACE_METADATA_FAILED", "内存人脸档案更新失败", null)); + } finally { + FlutterTool.finishRegistration(); + } + }); + } + /** * 在统一人脸操作互斥锁内批量移除内存库样本;一次通道调用摊平逐条往返与 * 互斥收发开销,供同步孤儿删除和调试清理入口使用。 diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java index a96330d..ce2d4f6 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java @@ -4,8 +4,6 @@ import static com.arcsoft.face.FaceEngine.activeOnline; import static com.arcsoft.face.FaceEngine.getActiveFileInfo; import static io.flutter.plugins.facediscern.database.DataBaseUtils.getFaceProfiles; -import static io.flutter.plugins.facediscern.database.DataBaseUtils.getUserFace; -import static io.flutter.plugins.facediscern.database.DataBaseUtils.updateFaceModel; import android.content.Context; import android.graphics.Bitmap; @@ -15,18 +13,15 @@ import io.flutter.Log; import com.arcsoft.face.ActiveFileInfo; import com.arcsoft.face.ErrorInfo; -import com.arcsoft.face.FaceFeatureInfo; import com.arcsoft.imageutil.ArcSoftImageFormat; import com.arcsoft.imageutil.ArcSoftImageUtil; import com.arcsoft.imageutil.ArcSoftImageUtilError; -import com.google.gson.Gson; import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -46,7 +41,7 @@ public class FlutterTool { static private final String FACE_JSON_FILE_NAME = "face.json"; - private static final int REGISTER_BATCH_SIZE = 100; + private static final int REGISTER_BATCH_SIZE = 1000; private static final AtomicBoolean REGISTRATION_IN_PROGRESS = new AtomicBoolean(false); private static volatile Map latestRegistrationProgress = idleRegistrationProgress(); private static volatile Map latestRegistrationResult; @@ -69,11 +64,6 @@ public class FlutterTool { } // 在线激活 - static public Boolean active(Context context, String appId, String activeKey, String sdkKey) { - Map result = activateFaceSdk(context, appId, activeKey, sdkKey); - return Boolean.TRUE.equals(result.get("success")); - } - static public Map activateFaceSdk(Context context, String appId, String activeKey, String sdkKey) { updateConfig(appId, activeKey, sdkKey); Map result = new HashMap<>(); @@ -87,7 +77,6 @@ public class FlutterTool { } int code = activeOnline(context, Constants.ACTIVE_KEY, Constants.APP_ID, Constants.SDK_KEY); boolean flag = Objects.equals(code, ErrorInfo.MOK) || Objects.equals(code, ErrorInfo.MERR_ASF_ALREADY_ACTIVATED); - Constants.IS_ACTIVE = flag; result.put("success", flag); result.put("code", code); result.put("message", flag ? "人脸插件激活成功" : activationErrorMessage(code)); @@ -98,23 +87,12 @@ public class FlutterTool { return result; } - // 获取配置 - static Map getConfig(Context context) { - HashMap config = new HashMap<>(); - config.put("appId", Constants.APP_ID); - config.put("sdkKey", Constants.SDK_KEY); - config.put("activeKey", Constants.ACTIVE_KEY); - config.put("isActive", isActivated(context)); - return config; - } - static Map getFaceStatus(Context context, String appId, String activeKey, String sdkKey) { updateConfig(appId, activeKey, sdkKey); Map status = new HashMap<>(); ActiveFileInfo activeFileInfo = new ActiveFileInfo(); int activeCode = getActiveFileInfo(context, activeFileInfo); boolean activated = activeCode == ErrorInfo.MOK; - Constants.IS_ACTIVE = activated; FaceServer faceServer = FaceServer.getInstance(); if (activated) faceServer.init(context); @@ -217,7 +195,6 @@ public class FlutterTool { ActiveFileInfo activeFileInfo = new ActiveFileInfo(); int activeCode = getActiveFileInfo(context, activeFileInfo); boolean activated = activeCode == ErrorInfo.MOK; - Constants.IS_ACTIVE = activated; Map progress = new HashMap<>(latestRegistrationProgress); int total = progress.get("total") instanceof Number ? ((Number) progress.get("total")).intValue() : -1; @@ -261,19 +238,6 @@ public class FlutterTool { // 获取人脸 - static public Map getFace(Context context, int sampleId) { - FaceServer faceServer = FaceServer.getInstance(); - faceServer.init(context); - FaceFeatureInfo faceFeature = faceServer.getFaceFeature(sampleId); - if (Objects.isNull(faceFeature)) return null; - Map result = new HashMap<>(); - result.put("searchId", faceFeature.getSearchId()); - result.put("faceTag", faceFeature.getFaceTag()); - result.put("featureData", faceFeature.getFeatureData()); - return result; - } - - // 删除人脸 -1 删除全部 static public Boolean removeFace(Context context, int sampleId) { FaceServer faceServer = FaceServer.getInstance(); faceServer.init(context); @@ -300,6 +264,7 @@ public class FlutterTool { result.put("qualityStatus", nativeResult.qualityStatus); result.put("qualityScore", nativeResult.qualityScore); result.put("qualityErrorCode", nativeResult.qualityErrorCode); + result.put("cacheableFailure", !success && nativeResult.cacheableFeatureFailure); return result; } catch (Exception exception) { Log.e(TAG, "提取人脸特征异常error:" + exception.getMessage(), exception); @@ -418,118 +383,13 @@ public class FlutterTool { } - // 旧通道保留同步包装;新页面通过 rebuildFaceLibrary 获取完整结果和进度。 - static public List registerFaceByDatabase(Context context, String userId) { - Map result = rebuildFaceLibrary(context, userId, null); - Object failed = result.get("failedSampleIds"); - if (failed instanceof List) { - //noinspection unchecked - return (List) failed; - } - return new ArrayList<>(); - } - static public Map rebuildFaceLibrary(Context context, String userId, RegistrationProgressListener listener) { - long startTime = System.currentTimeMillis(); - List faceList = getUserFace(context, userId); - Set failedSampleIds = new LinkedHashSet<>(); - List validFaces = new ArrayList<>(); - int total = faceList.size(); - int processed = 0; - - publishProgress(listener, "preparing", "准备人脸特征", processed, total, 0, 0, null, true); - for (FaceEntity face : faceList) { - boolean valid = hasFaceModel(face); - if (!valid) { - valid = extractAndPersistFaceModel(context, face); - } - processed++; - if (valid) { - validFaces.add(face); - } else if (face.getSampleId() != null) { - failedSampleIds.add(face.getSampleId()); - } - publishProgress(listener, "preparing", "生成缺失的人脸特征", processed, total, validFaces.size(), failedSampleIds.size(), face.getSampleId(), true); - } - - FaceServer faceServer = FaceServer.getInstance(); - faceServer.init(context); - if (!faceServer.isEngineReady()) { - for (FaceEntity face : validFaces) { - if (face.getSampleId() != null) failedSampleIds.add(face.getSampleId()); - } - return finishRegistrationResult(listener, total, 0, failedSampleIds, "人脸引擎初始化失败,请重新激活后再试", startTime); - } - - boolean fullRebuild = userId == null || userId.trim().isEmpty(); - if (fullRebuild) { - faceServer.removeFace(-1); - } else { - faceServer.removeFacesByUserId(userId); - } - - int preparationFailureCount = failedSampleIds.size(); - processed = 0; - int success = 0; - for (int start = 0; start < validFaces.size(); start += REGISTER_BATCH_SIZE) { - int end = Math.min(start + REGISTER_BATCH_SIZE, validFaces.size()); - List batch = new ArrayList<>(validFaces.subList(start, end)); - int code; - try { - code = faceServer.registerFaceFeatureInfoBatchFromDb(batch); - } catch (Exception exception) { - code = -99; - Log.e(TAG, "批量注册抛出异常,改为逐条处理error:" + exception.getMessage(), exception); - } - if (code == ErrorInfo.MOK) { - success += batch.size(); - processed += batch.size(); - Integer currentSampleId = batch.get(batch.size() - 1).getSampleId(); - publishProgress(listener, "registering", "装载人脸到内存库", preparationFailureCount + processed, total, success, failedSampleIds.size(), currentSampleId, true); - continue; - } - - Log.w(TAG, "批量注册失败code:" + code + ";改为逐条定位失败样本"); - for (FaceEntity face : batch) { - int singleCode; - try { - singleCode = faceServer.registerFaceFeatureInfoFromDb(face); - } catch (Exception exception) { - singleCode = -99; - Log.e(TAG, "单条注册异常sampleId:" + maskSampleId(face.getSampleId()) + ";error:" + exception.getMessage(), exception); - } - if (singleCode == ErrorInfo.MOK) { - success++; - } else if (face.getSampleId() != null) { - failedSampleIds.add(face.getSampleId()); - } - processed++; - publishProgress(listener, "registering", "逐条定位异常样本", preparationFailureCount + processed, total, success, failedSampleIds.size(), face.getSampleId(), true); - } - } - - String message; - if (total == 0) { - message = "本地数据库暂无人脸样本"; - } else if (failedSampleIds.isEmpty()) { - message = "人脸库注册完成"; - } else { - message = "人脸库注册完成,部分样本失败,请前往人脸管理处理"; - } - return finishRegistrationResult(listener, total, success, failedSampleIds, message, startTime); - } - - private static boolean extractAndPersistFaceModel(Context context, FaceEntity face) { - if (face == null || face.getFaceFilePath() == null) return false; - try { - byte[] faceBytes = extractFaceModelFromFile(context, face.getFaceFilePath()); - if (faceBytes == null || faceBytes.length == 0) return false; - face.setFaceModel(faceBytes); - return updateFaceModel(context, face); - } catch (Exception e) { - Log.e(TAG, "生成人脸特征失败sampleId:" + maskSampleId(face.getSampleId()) + ";error:" + e.getMessage()); - return false; - } + Map result = new FaceLibraryLoader(REGISTER_BATCH_SIZE, progress -> { + latestRegistrationProgress = progress; + if (listener != null) listener.onProgress(new HashMap<>(progress)); + }).load(context, userId); + latestRegistrationResult = result; + return result; } /** @@ -539,7 +399,7 @@ public class FlutterTool { * @param filePath 待校验图片绝对路径 * @return 虹软特征字节,图片无效或未检测到合格人脸时返回空 */ - private static byte[] extractFaceModelFromFile(Context context, String filePath) { + static byte[] extractFaceModelFromFile(Context context, String filePath) { return extractFaceResultFromFile(context, filePath).featureData; } @@ -552,7 +412,10 @@ public class FlutterTool { byte[] bytes = FileUtil.fileToData(new File(filePath)); if (bytes == null || bytes.length == 0) return failed; Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); - if (bitmap == null) return failed; + if (bitmap == null) { + failed.cacheableFeatureFailure = true; + return failed; + } Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); if (alignedBitmap == null) { bitmap.recycle(); @@ -644,21 +507,10 @@ public class FlutterTool { return sampleId.substring(0, 1) + "***" + sampleId.substring(sampleId.length() - 3); } - private static boolean hasFaceModel(FaceEntity face) { - return face != null && face.getFaceModel() != null && face.getFaceModel().length > 0; - } - private static boolean hasCompleteConfig() { return !Constants.APP_ID.isEmpty() && !Constants.SDK_KEY.isEmpty() && !Constants.ACTIVE_KEY.isEmpty(); } - private static boolean isActivated(Context context) { - ActiveFileInfo activeFileInfo = new ActiveFileInfo(); - boolean activated = getActiveFileInfo(context, activeFileInfo) == ErrorInfo.MOK; - Constants.IS_ACTIVE = activated; - return activated; - } - private static Map idleRegistrationProgress() { Map progress = new HashMap<>(); progress.put("phase", "idle"); @@ -672,43 +524,6 @@ public class FlutterTool { return progress; } - private static void publishProgress(RegistrationProgressListener listener, String phase, String message, int processed, int total, int success, int failed, Integer currentSampleId, boolean inProgress) { - Map progress = new HashMap<>(); - progress.put("phase", phase); - progress.put("message", message); - progress.put("processed", processed); - progress.put("total", total); - progress.put("successCount", success); - progress.put("failedCount", failed); - progress.put("currentSampleId", currentSampleId); - progress.put("inProgress", inProgress); - latestRegistrationProgress = progress; - if (listener != null) listener.onProgress(new HashMap<>(progress)); - } - - private static Map finishRegistrationResult(RegistrationProgressListener listener, int total, int success, Set failedSampleIds, String message, long startTime) { - List failed = new ArrayList<>(failedSampleIds); - Map result = new HashMap<>(); - result.put("success", failed.isEmpty() && success == total); - result.put("message", message); - result.put("total", total); - result.put("successCount", success); - result.put("failedCount", failed.size()); - result.put("failedSampleIds", failed); - result.put("durationMs", System.currentTimeMillis() - startTime); - latestRegistrationResult = result; - publishProgress(listener, failed.isEmpty() ? "completed" : "completedWithErrors", message, total, total, success, failed.size(), null, false); - Log.i(TAG, "人脸库注册结束total:" + total + ";success:" + success + ";failed:" + failed.size()); - return result; - } - - private static String maskSampleId(Integer sampleId) { - if (sampleId == null) return "-"; - String value = String.valueOf(sampleId); - if (value.length() <= 4) return "***" + value; - return value.substring(0, 1) + "***" + value.substring(value.length() - 3); - } - private static String activationErrorMessage(int code) { if (code == ErrorInfo.MERR_ASF_READ_PHONE_STATE_DENIED) return "缺少电话状态权限,请授权后重试"; if (code == ErrorInfo.MERR_ASF_INTERNET_DENIED || code == ErrorInfo.MERR_ASF_NETWORK_COULDNT_RESOLVE_HOST || code == ErrorInfo.MERR_ASF_NETWORK_COULDNT_CONNECT_SERVER || code == ErrorInfo.MERR_ASF_NETWORK_CONNECT_TIMEOUT || code == ErrorInfo.MERR_ASF_NETWORK_UNKNOWN_ERROR) return "无法连接虹软激活服务,请检查网络后重试"; diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java index 2a305b8..adac630 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java @@ -6,8 +6,4 @@ public class Constants { public static String SDK_KEY = ""; public static String ACTIVE_KEY = ""; - /** - * 仅保留给旧版 getConfig 通道兼容,真实授权状态必须通过 ActiveFileInfo 查询。 - */ - public static boolean IS_ACTIVE = false; } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java index ada3c2c..54dd400 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java @@ -136,22 +136,12 @@ public class FaceServer { faceServer = null; } - /** - * 全量重建使用:先清空旧内存库,再按调用方传入的数据重新装载。 - */ - public synchronized int registerFaceFeatureInfoListFromDb(List faceEntityList) { - if (faceEngine == null) return -99; - faceEngine.removeFaceFeature(-1); - faceRegisterInfoMap.clear(); - return registerFaceFeatureInfoBatchFromDb(faceEntityList); - } - /** * 只追加当前批次,不清空其他人脸;用于分批全量装载和单用户增量替换。 */ public synchronized int registerFaceFeatureInfoBatchFromDb(List faceEntityList) { if (faceEngine == null || faceEntityList == null || faceEntityList.isEmpty()) return -99; - List faceFeatureInfoList = new ArrayList<>(); + List faceFeatureInfoList = new ArrayList<>(faceEntityList.size()); for (FaceEntity faceEntity : faceEntityList) { FaceFeatureInfo faceFeatureInfo = new FaceFeatureInfo((int) faceEntity.getFaceId(), faceEntity.getFaceModel()); faceFeatureInfoList.add(faceFeatureInfo); @@ -211,6 +201,22 @@ public class FaceServer { return new HashSet<>(faceRegisterInfoMap.keySet()); } + /** 原子更新业务档案,不删除、不重新注册任何特征。先校验整批再修改映射。 */ + public synchronized int updateFaceMetadata(List updates) { + Set seen = new HashSet<>(); + for (FaceEntity update : updates) { + Integer id = update.getFaceId(); + if (id == null || !seen.add(id) || !faceRegisterInfoMap.containsKey(id) + || getFaceFeatureQuiet(id) == null) return ErrorInfo.MERR_INVALID_PARAM; + } + for (FaceEntity update : updates) { + FaceEntity current = faceRegisterInfoMap.get(update.getFaceId()); + current.setUserId(update.getUserId()); + current.setUserName(update.getUserName()); + } + return ErrorInfo.MOK; + } + /** * 恢复单样本替换前的人脸特征;没有旧特征时保持目标搜索标识为空。 * @@ -251,12 +257,6 @@ public class FaceServer { return copy; } - // 注册单个人脸数据 - public synchronized int registerFaceFeatureInfoFromDb(FaceEntity faceEntity) { - // 历史单条入口也复用带旧特征回滚的替换逻辑,避免 SDK 异常后丢失原可用样本。 - return replaceFaceFeature(faceEntity); - } - // 从人脸数据库中搜索人脸 public synchronized FaceFeatureInfo getFaceFeature(int searchId) { FaceFeatureInfo faceFeatureInfo = null; @@ -293,6 +293,11 @@ public class FaceServer { // 删除人脸-1删除全部 public synchronized int removeFace(int searchId) { if (faceEngine == null) return -99; + // 虹软在空库上 removeFaceFeature(-1) 可返回非 MOK;目标已达成时按幂等成功处理。 + if (searchId == -1 && faceEngine.getFaceCount() == 0) { + faceRegisterInfoMap.clear(); + return ErrorInfo.MOK; + } // MQ 和每日快照都可能重复删除同一样本;内存库已不存在时直接按幂等成功处理。 if (searchId != -1 && getFaceFeatureQuiet(searchId) == null) { faceRegisterInfoMap.remove(searchId); @@ -341,7 +346,7 @@ public class FaceServer { if (entity == null) { entity = new FaceEntity(faceFeatureInfo.getSearchId(), faceFeatureInfo.getFeatureData()); } - Log.i(TAG, "查询人脸总耗时:" + (System.currentTimeMillis() - searchStart) + "ms;username:" + entity.getUserName() + ";faceId:" + entity.getFaceId() + ";"); + Log.i(TAG, "FaceSearchPerf durationMs=" + searchCost); return new CompareResult(entity, searchResult.getMaxSimilar(), ErrorInfo.MOK, searchCost); } } catch (IllegalArgumentException exception) { @@ -351,11 +356,6 @@ public class FaceServer { } - /** 保持旧调用方返回特征字节的兼容入口。 */ - public synchronized byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) { - return registerBgr24WithQuality(context, bgr24, width, height, name).featureData; - } - /** * 在统一解码结果上同时执行图像质量检测和虹软特征提取。 * @@ -417,10 +417,12 @@ public class FaceServer { * 注册照要求不戴口罩 */ Log.e(TAG, "registerBgr24: maskInfo is worn"); + qualityResult.cacheableFeatureFailure = true; return qualityResult; } if (isMask == MaskInfo.UNKNOWN) { Log.e(TAG, "registerBgr24: maskInfo is unknown"); + qualityResult.cacheableFeatureFailure = true; return qualityResult; } } @@ -472,6 +474,7 @@ public class FaceServer { return qualityResult; } else { Log.e(TAG, "registerBgr24: extract face feature failed, code is " + code); + qualityResult.cacheableFeatureFailure = isImageFeatureError(code); return qualityResult; } } else { @@ -480,10 +483,21 @@ public class FaceServer { qualityResult.qualityErrorCode = code == ErrorInfo.MOK ? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE : code; + qualityResult.cacheableFeatureFailure = code == ErrorInfo.MOK || isImageFeatureError(code); return qualityResult; } } + private static boolean isImageFeatureError(int code) { + return code == ErrorInfo.MERR_FSDK_FACEFEATURE_UNKNOWN + || code == ErrorInfo.MERR_FSDK_FACEFEATURE_INVALID_FORMAT + || code == ErrorInfo.MERR_FSDK_FACEFEATURE_INVALID_PARAM + || code == ErrorInfo.MERR_FSDK_FACEFEATURE_LOW_CONFIDENCE_LEVEL + || code == ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE + || code == ErrorInfo.MERR_ASF_EX_INVALID_IMAGE_INFO + || code == ErrorInfo.MERR_ASF_EX_INVALID_FACE_INFO; + } + /** * 仅检测BGR24在库照片的质量,不提取或写入人脸特征。 * @@ -535,6 +549,7 @@ public class FaceServer { public Double qualityScore; /** 检测失败时的错误码。 */ public Integer qualityErrorCode; + public boolean cacheableFeatureFailure; } private String getImagePath(String name, Context context) { diff --git a/android/app/src/test/java/io/flutter/plugins/facediscern/database/FaceFeatureCodecTest.java b/android/app/src/test/java/io/flutter/plugins/facediscern/database/FaceFeatureCodecTest.java new file mode 100644 index 0000000..43c3924 --- /dev/null +++ b/android/app/src/test/java/io/flutter/plugins/facediscern/database/FaceFeatureCodecTest.java @@ -0,0 +1,46 @@ +package io.flutter.plugins.facediscern.database; + +import com.google.gson.Gson; + +import org.junit.Test; + +import java.util.Random; + +import static org.junit.Assert.*; + +public class FaceFeatureCodecTest { + @Test public void signedAndUnsignedFeaturesKeepEveryByte() { + Gson gson = new Gson(); + Random random = new Random(20261022); + for (int size : new int[]{0, 1, 1024, 1032, 2056, 8193}) { + byte[] expected = new byte[size]; + random.nextBytes(expected); + int[] unsigned = new int[size]; + for (int i = 0; i < size; i++) unsigned[i] = expected[i] & 255; + assertArrayEquals(expected, FaceFeatureCodec.decode(gson.toJson(expected))); + assertArrayEquals(expected, FaceFeatureCodec.decode(gson.toJson(unsigned))); + } + assertArrayEquals(new byte[]{-128, -1, 0, 127, -128, -1}, + FaceFeatureCodec.decode("[-128,-1,0,127,128,255]")); + assertArrayEquals(new byte[]{1,100,-1}, FaceFeatureCodec.decode("[1.0,1e2,\"255\"]")); + assertArrayEquals(new byte[]{-128,0,127}, FaceFeatureCodec.decode(" \n [ -128,\t0 , 127 ] \r ")); + } + + @Test public void historicalEmptyValuesRemainEmpty() { + assertNull(FaceFeatureCodec.decode(null)); + assertNull(FaceFeatureCodec.decode(" ")); + assertNull(FaceFeatureCodec.decode(" null ")); + assertArrayEquals(new byte[0], FaceFeatureCodec.decode(" [ ] ")); + } + + @Test public void invalidFeaturesCannotBecomeOtherBytesOrValidPrefixes() { + for (String json : new String[]{"[256]", "[-129]", "[1,null]", "[1,2", "{}", "[1] [2]", "[1.5]", "[2147483648]", "null [2]", "[1,]", "[01]", "[-]", "[9999999999999999999999999]"}) { + try { + FaceFeatureCodec.decode(json); + fail("Accepted malformed feature"); + } catch (IllegalArgumentException expected) { + // A failed sample must be isolated rather than silently truncated. + } + } + } +} diff --git a/docs/face-startup-performance.md b/docs/face-startup-performance.md new file mode 100644 index 0000000..07a1ae2 --- /dev/null +++ b/docs/face-startup-performance.md @@ -0,0 +1,79 @@ +# 人脸库启动装载优化与真机验证 + +验证日期:2026-10-22。设备:RK3568,Android 11。实际业务库包含 5,537 条人脸,每条特征 2,056 字节,无缺失或损坏特征。 + +## 改动 + +- 原生读取改为 `id > ? AND id <= ? ORDER BY id LIMIT 128`,只选择装库字段,复用一个数据库连接。记录开始时的数量和最大主键,结束时校验读取数量。 +- 标准整数 JSON 数组直接解码到一次分配的 `byte[]`,兼容 Java 有符号字节、Flutter 无符号字节;合法历史数字形式由 JsonReader 兼容解析。拒绝越界、溢出、损坏和多余尾部数据。 +- 读取和注册交替执行,注册批次采用 1,000 条,避免先在 Java 中物化整库特征再开始注册。 +- 每 150 ms 合并同阶段进度,阶段切换和结束立即发送;load 页显示实际阶段与处理数量。 +- 缺失/损坏特征逐条从原图片修复,按页提交修复事务,不再每张图片打开数据库并单独提交。 +- SDK 批次失败时清理可能的部分写入后进行二分隔离;清理失败立即中止。非法或重复搜索标识计为失败,禁止截断标识或覆盖其他用户的在库标识。 +- 虹软空库清空按幂等成功处理;结束时同时核对读取行数、成功/失败数量、SDK 数量及业务映射数量。数据库错误不返回成功的前缀结果。 +- 启动完成整库读取与修复后,收尾仅查询空特征记录,统一处理 NULL、空串、`[]`、`null`;不再把全部轻量档案传回 Dart 筛选。 +- 增加 `FaceStartupPerf` 脱敏汇总日志和结果 metrics,区分读库、解析、修复、引擎初始化、注册以及 Dart 激活/清理耗时。 + +沿用数据库 v15 和原有 JSON 特征存储格式,无数据库迁移。SDK 激活参数校验及原有人脸互斥队列继续复用。 + +## 受控 A/B 结果 + +同一 Debug APK、同一台设备和同一份数据,每组 3 轮,每轮重新初始化虹软引擎。旧算法仅在测试 APK 中保留用于对照;不会把旧实现带入正式包。 + +| 方案 | 三轮装库耗时 / ms | 中位数 / ms | 注册调用数 | +| --- | --- | --- | --- | +| 旧 SELECT *、每条 Gson、每批 100 | 11996 / 11418 / 11387 | 11418 | 56 | +| 优化读取/解析,每批 100 | 3929 / 3841 / 3871 | 3871 | 56 | +| 优化读取/解析,每批 500 | 3590 / 3516 / 3545 | 3545 | 12 | +| 优化读取/解析,每批 1000 | 3560 / 3467 / 3473 | 3473 | 6 | + +采用 1,000 条批次后,中位数下降约 69.6%,约为旧算法的 3.29 倍速度。主要收益来自读取及解析;批次调整贡献约 0.4 秒。 + +最终方案三轮分段耗时:读库约 1.29–1.32 秒,解析约 1.21–1.22 秒,SDK 注册约 0.68–0.70 秒,引擎初始化约 0.25 秒;每轮只有 22 次进度事件。 + +独立测试不启动 Flutter UI,也不包含旧版逐条进度发送,因此与实际 App 冷启动区分记录。优化前实际 App 日志中,读库/解析约 14.29 秒,从开始查询到装库完成约 17.87 秒。原生 `durationMs` 不包含 Dart 中的 SDK 激活和收尾清理,完整人脸准备耗时由 `startupMs` 记录。 + +## 正确性与回归 + +- 每组每轮均成功注册 5,537 条,失败 0 条;核对全部样本标识集合,并逐条从 SDK 取出特征与优化前 Gson 读取的字节比较。 +- 从本地四张实际照片生成 `ExtractType.RECOGNIZE` 查询特征,每轮检索均匹配到正确用户。SDK 区分注册特征和查询特征,不能直接使用 REGISTER 特征作查询。 +- 原生解析单元测试 3 项通过,包含随机特征及全部字节边界、历史空值、兼容数字形式、畸形/越界/长数字溢出等输入。 +- 真机独立内存 SQLite 11 项断言通过:分页边界、坏 JSON 后续行不丢失、标识溢出、修复落库、用户范围、快照上界、读取期间删除导致的不完整检测。 +- 模拟 SDK 部分写入后的失败隔离及清理失败中止 2 项断言通过。1,024 条中单个坏样本不会退化为整批逐条重试;不向实际业务库或真实 SDK 注入坏特征。 +- 最终提交的独立源码副本共 42 项 Flutter 测试通过(原有 15 项及本次新增 27 项)。 +- Flutter 静态分析无 error。原有 66 条 warning/info 之外,新增两个 Floor `IN` 查询产生 4 条生成代码风格提示,业务源代码没有新增提示。 + +最终 Release 实际 App 原生装库测得 4,315 ms,5,537 条全部成功、失败 0。启动优化阶段首次快照逐行核对已存人脸 id、sampleId、userId 和 faceModel,全部不变;数据库 v15 完整性检查正常,首次快照的订单及挂单数量不变。后续同步测试期间的完整数据核对见 face-sync-reliability.md。Release 不输出 Dart info 级控制台日志,原生 FaceStartupPerf 仍可从 adb 读取。 + +最新原生回归装库 3,584 ms;另有 2 项真实 SDK 元数据更新/整批拒绝断言通过,不重写特征或业务数据库。 + +以上验证覆盖装库、字节一致性和基于本地照片的 SDK 检索;真人摄像头活体识别与实际支付需独立验收。 + +## 复测 + +使用 Flutter 3.27.4、JDK 17。原生测试入口只存在于 androidTest APK,不在发布包中。 + +```sh +# 仓库根目录 +fvm flutter test --no-pub +fvm flutter analyze --no-pub --no-fatal-infos --no-fatal-warnings + +# android 目录 +./gradlew :app:testDebugUnitTest :app:assembleDebug :app:assembleDebugAndroidTest +adb install -r ../build/app/outputs/apk/debug/app-debug.apk +adb install -r ../build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk + +# 仅独立 SQLite 和 SDK 失败隔离检查 +adb shell am instrument -w -r \ + com.example.pj_supermarket_cloud.test/io.flutter.plugins.facediscern.flutter.FaceStartupInstrumentation + +# 真实数据 A/B:要求本机 SDK 已激活,所有样本已有完整特征和对应照片 +adb shell am instrument -w -r -e benchmark true -e mode optimized -e batch 1000 -e rounds 3 \ + com.example.pj_supermarket_cloud.test/io.flutter.plugins.facediscern.flutter.FaceStartupInstrumentation + +# 旧算法对照,或调整 batch 为 100 / 500 +adb shell am instrument -w -r -e benchmark true -e mode legacy -e rounds 3 \ + com.example.pj_supermarket_cloud.test/io.flutter.plugins.facediscern.flutter.FaceStartupInstrumentation +``` + +测试不启动收银页面,不发起支付或远端人脸同步;照片只读,输出仅包含耗时、计数和通过状态。装库前需保留现有 APK 与数据库备份,使用 `adb install -r` 保留应用数据。 diff --git a/docs/face-sync-reliability.md b/docs/face-sync-reliability.md new file mode 100644 index 0000000..0f27813 --- /dev/null +++ b/docs/face-sync-reliability.md @@ -0,0 +1,47 @@ +# 人脸差异同步修复与验证 + +2026-10-22,Flutter 3.27.4 / RK3568 Android 11。数据与日志保留在本机私有临时审计目录,本文不包含人员、图片地址或特征。 + +## 已确认的问题 + +- 本部门只有 846 条远端人脸,本地还有全租户调试导入的其他人脸,共 5,537 条。原同步仍读取、检查全部本地档案和文件。 +- 预下载尚未完成时,主流程会重复下载相同照片;预取窗口无消费背压,中断时仍会下载后续清单。 +- 删除范围由远端“当前有照片的部门”反推。某个子部门完全删空时,其本地残留不会进入孤儿删除范围。 +- 姓名或用户绑定只更新 SQLite,内存识别档案保留旧值。可空部门名与空字符串的比较也会产生重复写入。 +- 质量接口的 `ignoredNotApprovedSampleIds` 未落确认标记,一条历史结果在每次同步末尾反复上报。 +- 全租户清单实际有 5,560 行、5,559 个唯一编号,其中一组编号冲突。旧实现任意选择先读到的一条;另有 22 张照片无法提取注册特征(无可检测人脸、口罩或 SDK 图片特征错误),每次同步都会重新下载和提取。 +- V2 使用 OFFSET 分页,不能直接将并发变化造成的漏页当成本地删除;旧代码还会把任意质量请求失败拆成最多二十次请求重试。 + +## 实现 + +- 部门清单分页提高到服务端上限 500,独立拉取真实部门树,覆盖没有人脸的子部门。只查询范围内档案,并按编号补查新入部门的已有调试档案,防止重复插入。 +- 全租户使用 V2 上限 1,000 条分页并关闭无用总数查询;按物理主键检测重复分页,隔离整组冲突编号并保留本地样本。确实需要删除时,在任何本地变更前再次拉取完整清单并核对指纹,变化则中止。 +- 比对采用独立差集计划。未变化记录没有图片下载、特征提取或原生写入;照片 URL/版本变化时先对比字节摘要,相同内容复用已有特征和质量结果。缺少历史版本时先核对一次照片,避免盲信同一 URL。 +- 元数据最多 100 条一个 SQLite 事务;仅姓名/用户绑定变化时批量修改原生映射,不重新注册特征。原生先校验整批再修改,失败时回滚 SQLite。人脸时间字段无损保存微秒和时区,不影响其他业务实体。 +- 下载窗口限制为 3,等待同一在途任务,复用 HTTP 连接;退出后只等待已开始的请求。下载的完整响应限时 30 秒,正式文件使用唯一版本路径。 +- 对原生明确判定为图片内容错误的提取失败,按照片版本暂缓 15 分钟。记录只存摘要和到期时间,原子替换文件;版本/地址变化立即重试,成功后移除,损坏缓存不会阻断同步。网络、授权和插件异常不进入此记录。 +- 图片存在性改为按目录分块读取;保留小批量和异常回退。中断遗留的 `.pending-delete` 若仍有数据库引用则恢复;SQL 删除失败恢复图片及原生特征并准确统计失败。 +- 质量上报只查询未确认记录,完整处理所有确认类别。整批请求失败留到下轮,停止拆批放大重试。目录清扫按变更或一小时周期执行。 +- 人脸清单日志只输出条数,不在截断前遍历、脱敏和字符串化所有人员档案;进度回调按 150 ms 合并。 + +部门同步继续保留范围外调试档案;全租户同步继续仅在 Debug 环境提供。复用当前服务端已有接口,本次没有修改或部署服务端,也没有修改远端冲突样本或替换无效照片。 + +## 删除的无调用生产代码 + +删除旧 `syncFace` 包装、V1 人脸清单 API、旧文件名/直接下载助手、无调用 DAO、`registerFaceByDatabase` Dart/原生通道及包装、旧 `active/getConfig/getFace` 通道与模型、静态激活标记,以及无调用的旧整库/单条注册包装。旧算法仅保留在实际执行 A/B 的 androidTest 中,不进入业务 APK。 + +## 验证 + +- 最终提交的独立源码副本 42 项 Flutter 测试通过,Debug / Release 均构建成功。 +- 回归使用真实 SQLite/Floor 事务和本地 HTTP 图片服务;模拟原生通道以注入失败,覆盖重复同步、照片变化/相同字节、元数据和事务回滚、空部门删除、范围外转入、SQL 删除失败、分页中断、冲突隔离、V2 删除复核、中断图片恢复、质量确认及网络失败、坏图版本重试和到期。 +- 真机 SDK 校验 5,537 条特征的每个字节和全部编号,四张实际照片生成 RECOGNIZE 特征检索;增加原生档案更新不改变特征和整批拒绝不部分生效的两项检查。 +- 真机本部门 846 条全部跳过、无下载与提取;全量清单 5,559 个唯一编号,5,536 条正常本地人脸直接跳过,一组冲突及 22 张不可注册照片如实报告。 +- 全量首轮建立失败版本记录后,跨进程重启复测得到 downloads=0、extractions=0、nativeUpserts=0、metadataUpdates=0、deferredImages=22,耗时 18,127 ms(含首次目录清扫 2,114 ms);不把异常照片当作同步成功。 +- 随后完整重复全量同步耗时 11,897 ms,同样是零下载、零提取、零特征/档案写入,22 个失败版本仍被暂缓。 +- 最新部门同步耗时 4,112 ms,846 条全部跳过、失败 0;其中清单 2,114 ms、本地档案读取 394 ms、文件核对 1,298 ms。 +- 再次部门同步同样 846 条全部跳过、失败 0;两次任务记录耗时分别为 4,280 / 6,503 ms(包含调度记录开销与网络波动)。 +- 稳定复制真机数据库后与初始备份逐行核对:5,537 条人脸的 id、sampleId、userId、faceModel 完全不变,v15 完整性检查正常。原有 32 条订单逐字段完整保留,挂单仍为 0。设备期间另外产生了 2 条 FACE/PAY 记录(15:17 / 15:22 创建),本任务没有发起人脸支付,也没有删除或改写这些新增记录;不将整个验证期间描述成订单数量不变。 +- 最终版全量首轮 19,059 ms;前一轮中间版本为 38,319 ms。分页、日志及网络条件同时变化,这些同步数字用于现场诊断,不构成受控速度比。 +- 静态分析无 error。Floor 新增两个 `IN` 查询生成四条命名/字符串风格提示,其余为既有提示;没有手工改写生成代码。 + +启动性能 A/B、装库实现与测试命令见 [face-startup-performance.md](face-startup-performance.md)。同步耗时包含网络,单次数字不能作为受控速度比。 diff --git a/lib/common/api/user_api.dart b/lib/common/api/user_api.dart index 12504f1..24b1f8c 100644 --- a/lib/common/api/user_api.dart +++ b/lib/common/api/user_api.dart @@ -47,20 +47,6 @@ abstract class UserApi { return res.data; } - /// 获取人脸 - static Future?> getFaceList( - dynamic formData, - ) async { - final res = await HttpManage().dio.get( - "/admin/user-face/ad/find-face", - queryParameters: formData, - ); - if (res.data == null) return null; - return res.data - ?.map((e) => ServiceAsyncFace.fromJson(e)) - .toList(); - } - /// 获取人脸v2 static Future?> getFaceListV2( dynamic formData, @@ -68,6 +54,10 @@ abstract class UserApi { final res = await HttpManage().dio.get( "/admin/user-face/ad/v2/find-face", queryParameters: formData, + options: dio.Options(extra: { + HttpManage.rejectBusinessErrorKey: true, + HttpManage.responseSummaryKey: true, + }), ); if (res.data == null) return null; return res.data @@ -82,6 +72,10 @@ abstract class UserApi { final res = await HttpManage().dio.post( "/admin/user-face/ad/v3/sync-snapshot", data: requestBody, + options: dio.Options(extra: { + HttpManage.rejectBusinessErrorKey: true, + HttpManage.responseSummaryKey: true, + }), ); if (res.data is! Map) { throw const FormatException('服务端未返回有效的人脸快照'); @@ -91,6 +85,33 @@ abstract class UserApi { ); } + /// 使用服务端真实部门树确定删除范围,包含当前已无人脸的空部门。 + static Future> getFaceDepartmentScope( + String merchantDeptId) async { + final res = await HttpManage().dio.get( + '/admin/dept/getDescendantList/$merchantDeptId', + options: dio.Options(extra: { + HttpManage.rejectBusinessErrorKey: true, + HttpManage.responseSummaryKey: true + }), + ); + if (res.data is! List) throw const FaceSyncSnapshotException('部门范围响应无效'); + final ids = {}; + for (final item in res.data as List) { + if (item is! Map || item['deptId'] == null) { + throw const FaceSyncSnapshotException('部门范围包含无效记录'); + } + final id = item['deptId'].toString(); + if (int.tryParse(id) == null || !ids.add(id)) { + throw const FaceSyncSnapshotException('部门范围包含无效或重复编号'); + } + } + if (!ids.contains(merchantDeptId)) { + throw const FaceSyncSnapshotException('绑定部门不存在,已停止人脸同步'); + } + return ids; + } + /// 由当前设备请求删除服务端部门范围内的人脸,单批最多 500 条。 /// /// 服务端会以 DEVICE-ID 解析真实绑定部门;业务错误必须抛给调用方,确保终端 diff --git a/lib/common/models/manage/face_sync_models.dart b/lib/common/models/manage/face_sync_models.dart index cd57ab4..93bb802 100644 --- a/lib/common/models/manage/face_sync_models.dart +++ b/lib/common/models/manage/face_sync_models.dart @@ -71,7 +71,7 @@ class FaceSyncSummary { final failedSamples = maskedFailedSampleIds.isEmpty ? '' : ',失败样本 ${maskedFailedSampleIds.join(',')}'; - return '扫描 $scanned,新增 $added,更新 $updated,删除 $deleted,失败 $failed$failedSamples$note'; + return '扫描 $scanned,新增 $added,更新 $updated,删除 $deleted,跳过 $skipped,失败 $failed$failedSamples$note'; } } diff --git a/lib/common/models/plugs/face_discern_models.dart b/lib/common/models/plugs/face_discern_models.dart index 32d61ba..ffb45d6 100644 --- a/lib/common/models/plugs/face_discern_models.dart +++ b/lib/common/models/plugs/face_discern_models.dart @@ -73,30 +73,6 @@ class FaceStream { Map toJson() => _$FaceStreamToJson(this); } -@JsonSerializable() -class FaceConfig { - String? appId; - - String? sdkKey; - - String? activeKey; - - // 是否激活 - bool isActive = false; - - FaceConfig({ - this.appId, - this.sdkKey, - this.activeKey, - this.isActive = false, - }); - - factory FaceConfig.fromJson(Map json) => - _$FaceConfigFromJson(json); - - Map toJson() => _$FaceConfigToJson(this); -} - /// 终端本机的人脸识别策略,对应 Android `RecognizeConfiguration` 的可调白名单。 class FaceRecognitionConfiguration { const FaceRecognitionConfiguration({ @@ -295,26 +271,6 @@ class FaceRecognitionConfiguration { ); } -@JsonSerializable() -class FaceRes { - int searchId; - - List featureData; - - String faceTag; - - FaceRes({ - required this.searchId, - required this.featureData, - required this.faceTag, - }); - - factory FaceRes.fromJson(Map json) => - _$FaceResFromJson(json); - - Map toJson() => _$FaceResToJson(this); -} - /// 原生图片特征提取结果;提取阶段不修改 SQLite 或虹软内存库。 class FaceFeatureExtractionResult { /// 创建一次原生特征提取结果。 @@ -325,6 +281,7 @@ class FaceFeatureExtractionResult { this.qualityStatus = FaceQualityStatus.pending, this.qualityScore, this.qualityErrorCode, + this.cacheableFailure = false, }); /// 从 MethodChannel 返回映射创建特征提取结果。 @@ -341,6 +298,7 @@ class FaceFeatureExtractionResult { ), qualityScore: (map['qualityScore'] as num?)?.toDouble(), qualityErrorCode: (map['qualityErrorCode'] as num?)?.toInt(), + cacheableFailure: map['cacheableFailure'] == true, ); } @@ -361,6 +319,9 @@ class FaceFeatureExtractionResult { /// 虹软质量检测错误码;插件内部错误使用负值,成功时为空。 final int? qualityErrorCode; + + /// 原生已确认照片内容不可注册,可按图片版本暂缓重试。 + final bool cacheableFailure; } /// 在库人脸质量状态的跨端持久化协议。 @@ -729,6 +690,7 @@ class FaceRegistrationResult { required this.failedCount, required this.failedSampleIds, required this.durationMs, + this.metrics = const {}, }); final bool success; @@ -739,6 +701,9 @@ class FaceRegistrationResult { final List failedSampleIds; final int durationMs; + /// 原生分段耗时与汇总计数,不含样本标识或生物特征。 + final Map metrics; + factory FaceRegistrationResult.fromMap(Map map) { final failedSampleIds = map['failedSampleIds']; return FaceRegistrationResult( @@ -751,6 +716,12 @@ class FaceRegistrationResult { ? failedSampleIds.map(_asInt).toList(growable: false) : const [], durationMs: _asInt(map['durationMs']), + metrics: map['metrics'] is Map + ? Map.unmodifiable({ + for (final entry in (map['metrics'] as Map).entries) + entry.key.toString(): _asInt(entry.value), + }) + : const {}, ); } } diff --git a/lib/common/models/plugs/face_discern_models.g.dart b/lib/common/models/plugs/face_discern_models.g.dart index 15f91c0..e1c8610 100644 --- a/lib/common/models/plugs/face_discern_models.g.dart +++ b/lib/common/models/plugs/face_discern_models.g.dart @@ -42,32 +42,3 @@ Map _$FaceStreamToJson(FaceStream instance) => 'similarityThreshold': instance.similarityThreshold, 'evidenceError': instance.evidenceError, }; - -FaceConfig _$FaceConfigFromJson(Map json) => FaceConfig( - appId: json['appId'] as String?, - sdkKey: json['sdkKey'] as String?, - activeKey: json['activeKey'] as String?, - isActive: json['isActive'] as bool? ?? false, - ); - -Map _$FaceConfigToJson(FaceConfig instance) => - { - 'appId': instance.appId, - 'sdkKey': instance.sdkKey, - 'activeKey': instance.activeKey, - 'isActive': instance.isActive, - }; - -FaceRes _$FaceResFromJson(Map json) => FaceRes( - searchId: (json['searchId'] as num).toInt(), - featureData: (json['featureData'] as List) - .map((e) => (e as num).toInt()) - .toList(), - faceTag: json['faceTag'] as String, - ); - -Map _$FaceResToJson(FaceRes instance) => { - 'searchId': instance.searchId, - 'featureData': instance.featureData, - 'faceTag': instance.faceTag, - }; diff --git a/lib/common/services/database/dao/face_dao.dart b/lib/common/services/database/dao/face_dao.dart index 66acf11..b471e4e 100644 --- a/lib/common/services/database/dao/face_dao.dart +++ b/lib/common/services/database/dao/face_dao.dart @@ -151,19 +151,32 @@ abstract class FaceDao { /// 单条回读 [findBySampleId]。 @Query(''' SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, - CASE WHEN faceModel IS NULL OR faceModel = '' THEN '[]' ELSE '[1]' END AS faceModel, + CASE WHEN TRIM(COALESCE(faceModel, '')) IN ('', '[]', 'null') THEN '[]' ELSE '[1]' END AS faceModel, createTime, updateTime, qualityScore, qualityStatus, qualityCheckedAt, qualityErrorCode, qualityReportedAt FROM face ORDER BY sampleId ASC ''') Future> getSyncProfiles(); - /// 只更新服务端元数据字段,不触碰 faceModel 大字段和质量检测结果。 - /// - /// Floor 的 @Query 生成器只产出位置参数,参数顺序固定为: - /// sampleId、userId、userName、deptId、deptName、imagePath、updateTime。 - /// 可空字段以空串传入并由 NULLIF 归一为 NULL;[updateTime] 为空串时保留本地 - /// 旧值,与快照合并时"服务端版本缺失则回退本地"一致。 + @Query(''' + SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, + CASE WHEN TRIM(COALESCE(faceModel, '')) IN ('', '[]', 'null') THEN '[]' ELSE '[1]' END AS faceModel, + createTime, updateTime, qualityScore, qualityStatus, qualityCheckedAt, + qualityErrorCode, qualityReportedAt + FROM face WHERE deptId IN (:deptIds) ORDER BY sampleId ASC + ''') + Future> getSyncProfilesInDepartments(List deptIds); + + @Query(''' + SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, + CASE WHEN TRIM(COALESCE(faceModel, '')) IN ('', '[]', 'null') THEN '[]' ELSE '[1]' END AS faceModel, + createTime, updateTime, qualityScore, qualityStatus, qualityCheckedAt, + qualityErrorCode, qualityReportedAt + FROM face WHERE sampleId IN (:sampleIds) ORDER BY sampleId ASC + ''') + Future> getSyncProfilesBySampleIds(List sampleIds); + + /// 只改同步元数据;可空值原样持久化,回滚时也能恢复 NULL 版本。 @Query(''' UPDATE face SET userId = :userId, @@ -171,7 +184,8 @@ abstract class FaceDao { deptId = NULLIF(:deptId, ''), deptName = NULLIF(:deptName, ''), imagePath = :imagePath, - updateTime = COALESCE(NULLIF(:updateTime, ''), updateTime) + updateTime = NULLIF(:updateTime, ''), + qualityReportedAt = NULLIF(:qualityReportedAt, '') WHERE sampleId = :sampleId ''') Future editMetadataBySampleId( @@ -182,8 +196,40 @@ abstract class FaceDao { String deptName, String imagePath, String updateTime, + String qualityReportedAt, ); + @transaction + Future editSyncMetadataBatch(List faces) async { + for (final face in faces) { + await editMetadataBySampleId( + face.sampleId, + face.userId, + face.userName, + face.deptId ?? '', + face.deptName ?? '', + face.imagePath, + face.updateTime?.toIso8601String() ?? '', + face.qualityReportedAt?.toIso8601String() ?? '', + ); + } + } + + /// 只读取待补报结果,正常重复同步不再将几千条已确认的档案搬回 Dart。 + @Query(''' + SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, + '[]' AS faceModel, createTime, updateTime, qualityScore, qualityStatus, + qualityCheckedAt, qualityErrorCode, qualityReportedAt + FROM face WHERE qualityStatus != 'pending' + AND (qualityReportedAt IS NULL OR qualityCheckedAt IS NULL + OR julianday(qualityCheckedAt) > julianday(qualityReportedAt)) + ORDER BY id ASC + ''') + Future> getUnreportedQualityProfiles(); + + @Query("SELECT path FROM face WHERE path IS NOT NULL AND path != ''") + Future> getStoragePaths(); + /// 批量标记质量结果已被服务端确认,未确认样本保持旧标记供下次补报。 @Query(''' UPDATE face SET qualityReportedAt = :reportedAt @@ -191,16 +237,15 @@ abstract class FaceDao { ''') Future markQualityReported( List sampleIds, - DateTime reportedAt, + String reportedAt, ); @Query(''' - SELECT DISTINCT a.deptId FROM face a WHERE a.deptId IS NOT NULL - ''') - Future> findAllUserIds(); - - @Query(''' - SELECT * FROM face WHERE faceModel = '' OR faceModel IS NULL + SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, + '' AS faceModel, createTime, updateTime, qualityScore, qualityStatus, + qualityCheckedAt, qualityErrorCode, qualityReportedAt + FROM face WHERE TRIM(COALESCE(faceModel, '')) IN ('', '[]', 'null') + ORDER BY id ASC ''') Future?> getEmptyFaceList(); } diff --git a/lib/common/services/database/database.dart b/lib/common/services/database/database.dart index e2473b2..d8ede74 100644 --- a/lib/common/services/database/database.dart +++ b/lib/common/services/database/database.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:floor/floor.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/common/services/database/database_migration.dart'; +import 'type_converter/face_timestamp_converter.dart'; import 'package:sqflite/sqflite.dart' as sqflite; diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index 05a9e36..1502bd0 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -1891,15 +1891,15 @@ class _$FaceDao extends FaceDao { 'imagePath': item.imagePath, 'path': item.path, 'faceModel': _listIntConverter.encode(item.faceModel), - 'createTime': _dateTimeEmptyConverter.encode(item.createTime), - 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime), + 'createTime': _faceTimestampConverter.encode(item.createTime), + 'updateTime': _faceTimestampConverter.encode(item.updateTime), 'qualityScore': item.qualityScore, 'qualityStatus': item.qualityStatus, 'qualityCheckedAt': - _dateTimeEmptyConverter.encode(item.qualityCheckedAt), + _faceTimestampConverter.encode(item.qualityCheckedAt), 'qualityErrorCode': item.qualityErrorCode, 'qualityReportedAt': - _dateTimeEmptyConverter.encode(item.qualityReportedAt) + _faceTimestampConverter.encode(item.qualityReportedAt) }), _faceEntityUpdateAdapter = UpdateAdapter( database, @@ -1915,15 +1915,15 @@ class _$FaceDao extends FaceDao { 'imagePath': item.imagePath, 'path': item.path, 'faceModel': _listIntConverter.encode(item.faceModel), - 'createTime': _dateTimeEmptyConverter.encode(item.createTime), - 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime), + 'createTime': _faceTimestampConverter.encode(item.createTime), + 'updateTime': _faceTimestampConverter.encode(item.updateTime), 'qualityScore': item.qualityScore, 'qualityStatus': item.qualityStatus, 'qualityCheckedAt': - _dateTimeEmptyConverter.encode(item.qualityCheckedAt), + _faceTimestampConverter.encode(item.qualityCheckedAt), 'qualityErrorCode': item.qualityErrorCode, 'qualityReportedAt': - _dateTimeEmptyConverter.encode(item.qualityReportedAt) + _faceTimestampConverter.encode(item.qualityReportedAt) }), _faceEntityDeletionAdapter = DeletionAdapter( database, @@ -1939,15 +1939,15 @@ class _$FaceDao extends FaceDao { 'imagePath': item.imagePath, 'path': item.path, 'faceModel': _listIntConverter.encode(item.faceModel), - 'createTime': _dateTimeEmptyConverter.encode(item.createTime), - 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime), + 'createTime': _faceTimestampConverter.encode(item.createTime), + 'updateTime': _faceTimestampConverter.encode(item.updateTime), 'qualityScore': item.qualityScore, 'qualityStatus': item.qualityStatus, 'qualityCheckedAt': - _dateTimeEmptyConverter.encode(item.qualityCheckedAt), + _faceTimestampConverter.encode(item.qualityCheckedAt), 'qualityErrorCode': item.qualityErrorCode, 'qualityReportedAt': - _dateTimeEmptyConverter.encode(item.qualityReportedAt) + _faceTimestampConverter.encode(item.qualityReportedAt) }); final sqflite.DatabaseExecutor database; @@ -2026,7 +2026,7 @@ class _$FaceDao extends FaceDao { ) async { return _queryAdapter.queryList( 'SELECT * FROM face WHERE qualityStatus = \'detected\' AND qualityScore IS NOT NULL AND qualityScore < ?2 AND (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\') ORDER BY id ASC LIMIT ?3 OFFSET ?4', - mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _dateTimeEmptyConverter.decode(row['qualityReportedAt'] as String?)), + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _faceTimestampConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _faceTimestampConverter.decode(row['qualityReportedAt'] as String?)), arguments: [userName, threshold, pageSize, offset]); } @@ -2034,7 +2034,7 @@ class _$FaceDao extends FaceDao { Future> findAllLowQuality(double threshold) async { return _queryAdapter.queryList( 'SELECT * FROM face WHERE qualityStatus = \'detected\' AND qualityScore IS NOT NULL AND qualityScore < ?1 ORDER BY id ASC', - mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _dateTimeEmptyConverter.decode(row['qualityReportedAt'] as String?)), + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _faceTimestampConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _faceTimestampConverter.decode(row['qualityReportedAt'] as String?)), arguments: [threshold]); } @@ -2059,7 +2059,7 @@ class _$FaceDao extends FaceDao { ) async { return _queryAdapter.queryList( 'SELECT * FROM face WHERE qualityStatus IN (\'pending\', \'failed\') AND (?1 = -1 OR id > ?1) ORDER BY id ASC LIMIT ?2', - mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _dateTimeEmptyConverter.decode(row['qualityReportedAt'] as String?)), + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _faceTimestampConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _faceTimestampConverter.decode(row['qualityReportedAt'] as String?)), arguments: [afterId, limit]); } @@ -2077,15 +2077,15 @@ class _$FaceDao extends FaceDao { path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: - _dateTimeEmptyConverter.decode(row['createTime'] as String?), + _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, - qualityCheckedAt: _dateTimeEmptyConverter + qualityCheckedAt: _faceTimestampConverter .decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, - qualityReportedAt: _dateTimeEmptyConverter + qualityReportedAt: _faceTimestampConverter .decode(row['qualityReportedAt'] as String?)), arguments: [id]); } @@ -2104,15 +2104,15 @@ class _$FaceDao extends FaceDao { path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: - _dateTimeEmptyConverter.decode(row['createTime'] as String?), + _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, - qualityCheckedAt: _dateTimeEmptyConverter + qualityCheckedAt: _faceTimestampConverter .decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, - qualityReportedAt: _dateTimeEmptyConverter + qualityReportedAt: _faceTimestampConverter .decode(row['qualityReportedAt'] as String?)), arguments: [sampleId]); } @@ -2131,15 +2131,15 @@ class _$FaceDao extends FaceDao { path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: - _dateTimeEmptyConverter.decode(row['createTime'] as String?), + _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, - qualityCheckedAt: _dateTimeEmptyConverter + qualityCheckedAt: _faceTimestampConverter .decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, - qualityReportedAt: _dateTimeEmptyConverter + qualityReportedAt: _faceTimestampConverter .decode(row['qualityReportedAt'] as String?)), arguments: [userId]); } @@ -2152,14 +2152,14 @@ class _$FaceDao extends FaceDao { ) async { return _queryAdapter.queryList( 'SELECT * FROM face WHERE (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\') LIMIT ?2 OFFSET ?3', - mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _dateTimeEmptyConverter.decode(row['qualityReportedAt'] as String?)), + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _faceTimestampConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _faceTimestampConverter.decode(row['qualityReportedAt'] as String?)), arguments: [userName, pageSize, offset]); } @override Future> getSyncProfiles() async { return _queryAdapter.queryList( - 'SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, CASE WHEN faceModel IS NULL OR faceModel = \'\' THEN \'[]\' ELSE \'[1]\' END AS faceModel, createTime, updateTime, qualityScore, qualityStatus, qualityCheckedAt, qualityErrorCode, qualityReportedAt FROM face ORDER BY sampleId ASC', + 'SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, CASE WHEN TRIM(COALESCE(faceModel, \'\')) IN (\'\', \'[]\', \'null\') THEN \'[]\' ELSE \'[1]\' END AS faceModel, createTime, updateTime, qualityScore, qualityStatus, qualityCheckedAt, qualityErrorCode, qualityReportedAt FROM face ORDER BY sampleId ASC', mapper: (Map row) => FaceEntity( id: row['id'] as int?, userId: row['userId'] as String, @@ -2171,18 +2171,48 @@ class _$FaceDao extends FaceDao { path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: - _dateTimeEmptyConverter.decode(row['createTime'] as String?), + _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, - qualityCheckedAt: _dateTimeEmptyConverter + qualityCheckedAt: _faceTimestampConverter .decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, - qualityReportedAt: _dateTimeEmptyConverter + qualityReportedAt: _faceTimestampConverter .decode(row['qualityReportedAt'] as String?))); } + @override + Future> getSyncProfilesInDepartments( + List deptIds) async { + const offset = 1; + final _sqliteVariablesForDeptIds = + Iterable.generate(deptIds.length, (i) => '?${i + offset}') + .join(','); + return _queryAdapter.queryList( + 'SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, CASE WHEN TRIM(COALESCE(faceModel, \'\')) IN (\'\', \'[]\', \'null\') THEN \'[]\' ELSE \'[1]\' END AS faceModel, createTime, updateTime, qualityScore, qualityStatus, qualityCheckedAt, qualityErrorCode, qualityReportedAt FROM face WHERE deptId IN (' + + _sqliteVariablesForDeptIds + + ') ORDER BY sampleId ASC', + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _faceTimestampConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _faceTimestampConverter.decode(row['qualityReportedAt'] as String?)), + arguments: [...deptIds]); + } + + @override + Future> getSyncProfilesBySampleIds( + List sampleIds) async { + const offset = 1; + final _sqliteVariablesForSampleIds = + Iterable.generate(sampleIds.length, (i) => '?${i + offset}') + .join(','); + return _queryAdapter.queryList( + 'SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, CASE WHEN TRIM(COALESCE(faceModel, \'\')) IN (\'\', \'[]\', \'null\') THEN \'[]\' ELSE \'[1]\' END AS faceModel, createTime, updateTime, qualityScore, qualityStatus, qualityCheckedAt, qualityErrorCode, qualityReportedAt FROM face WHERE sampleId IN (' + + _sqliteVariablesForSampleIds + + ') ORDER BY sampleId ASC', + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _faceTimestampConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _faceTimestampConverter.decode(row['qualityReportedAt'] as String?)), + arguments: [...sampleIds]); + } + @override Future editMetadataBySampleId( String sampleId, @@ -2192,9 +2222,10 @@ class _$FaceDao extends FaceDao { String deptName, String imagePath, String updateTime, + String qualityReportedAt, ) async { await _queryAdapter.queryNoReturn( - 'UPDATE face SET userId = ?2, userName = ?3, deptId = NULLIF(?4, \'\'), deptName = NULLIF(?5, \'\'), imagePath = ?6, updateTime = COALESCE(NULLIF(?7, \'\'), updateTime) WHERE sampleId = ?1', + 'UPDATE face SET userId = ?2, userName = ?3, deptId = NULLIF(?4, \'\'), deptName = NULLIF(?5, \'\'), imagePath = ?6, updateTime = NULLIF(?7, \'\'), qualityReportedAt = NULLIF(?8, \'\') WHERE sampleId = ?1', arguments: [ sampleId, userId, @@ -2202,37 +2233,15 @@ class _$FaceDao extends FaceDao { deptId, deptName, imagePath, - updateTime + updateTime, + qualityReportedAt ]); } @override - Future markQualityReported( - List sampleIds, - DateTime reportedAt, - ) async { - const offset = 2; - final _sqliteVariablesForSampleIds = - Iterable.generate(sampleIds.length, (i) => '?${i + offset}') - .join(','); - await _queryAdapter.queryNoReturn( - 'UPDATE face SET qualityReportedAt = ?1 WHERE sampleId IN (' + - _sqliteVariablesForSampleIds + - ')', - arguments: [_dateTimeConverter.encode(reportedAt), ...sampleIds]); - } - - @override - Future> findAllUserIds() async { + Future> getUnreportedQualityProfiles() async { return _queryAdapter.queryList( - 'SELECT DISTINCT a.deptId FROM face a WHERE a.deptId IS NOT NULL', - mapper: (Map row) => row.values.first as String); - } - - @override - Future?> getEmptyFaceList() async { - return _queryAdapter.queryList( - 'SELECT * FROM face WHERE faceModel = \'\' OR faceModel IS NULL', + 'SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, \'[]\' AS faceModel, createTime, updateTime, qualityScore, qualityStatus, qualityCheckedAt, qualityErrorCode, qualityReportedAt FROM face WHERE qualityStatus != \'pending\' AND (qualityReportedAt IS NULL OR qualityCheckedAt IS NULL OR julianday(qualityCheckedAt) > julianday(qualityReportedAt)) ORDER BY id ASC', mapper: (Map row) => FaceEntity( id: row['id'] as int?, userId: row['userId'] as String, @@ -2244,15 +2253,65 @@ class _$FaceDao extends FaceDao { path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: - _dateTimeEmptyConverter.decode(row['createTime'] as String?), + _faceTimestampConverter.decode(row['createTime'] as String?), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + _faceTimestampConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, - qualityCheckedAt: _dateTimeEmptyConverter + qualityCheckedAt: _faceTimestampConverter .decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, - qualityReportedAt: _dateTimeEmptyConverter + qualityReportedAt: _faceTimestampConverter + .decode(row['qualityReportedAt'] as String?))); + } + + @override + Future> getStoragePaths() async { + return _queryAdapter.queryList( + 'SELECT path FROM face WHERE path IS NOT NULL AND path != \'\'', + mapper: (Map row) => row.values.first as String); + } + + @override + Future markQualityReported( + List sampleIds, + String reportedAt, + ) async { + const offset = 2; + final _sqliteVariablesForSampleIds = + Iterable.generate(sampleIds.length, (i) => '?${i + offset}') + .join(','); + await _queryAdapter.queryNoReturn( + 'UPDATE face SET qualityReportedAt = ?1 WHERE sampleId IN (' + + _sqliteVariablesForSampleIds + + ')', + arguments: [reportedAt, ...sampleIds]); + } + + @override + Future?> getEmptyFaceList() async { + return _queryAdapter.queryList( + 'SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, \'\' AS faceModel, createTime, updateTime, qualityScore, qualityStatus, qualityCheckedAt, qualityErrorCode, qualityReportedAt FROM face WHERE TRIM(COALESCE(faceModel, \'\')) IN (\'\', \'[]\', \'null\') ORDER BY id ASC', + mapper: (Map row) => FaceEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + sampleId: row['sampleId'] as String, + userName: row['userName'] as String, + deptId: row['deptId'] as String?, + deptName: row['deptName'] as String?, + imagePath: row['imagePath'] as String, + path: row['path'] as String?, + faceModel: _listIntConverter.decode(row['faceModel'] as String), + createTime: + _faceTimestampConverter.decode(row['createTime'] as String?), + updateTime: + _faceTimestampConverter.decode(row['updateTime'] as String?), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _faceTimestampConverter + .decode(row['qualityCheckedAt'] as String?), + qualityErrorCode: row['qualityErrorCode'] as int?, + qualityReportedAt: _faceTimestampConverter .decode(row['qualityReportedAt'] as String?))); } @@ -2288,6 +2347,20 @@ class _$FaceDao extends FaceDao { Future batchDel(List entitys) async { await _faceEntityDeletionAdapter.deleteList(entitys); } + + @override + Future editSyncMetadataBatch(List faces) async { + if (database is sqflite.Transaction) { + await super.editSyncMetadataBatch(faces); + } else { + await (database as sqflite.Database) + .transaction((transaction) async { + final transactionDatabase = _$AppDatabase(changeListener) + ..database = transaction; + await transactionDatabase.getFaceDao.editSyncMetadataBatch(faces); + }); + } + } } class _$SettingDao extends SettingDao { @@ -2964,3 +3037,4 @@ final _discountsTypeConverter = DiscountsTypeConverter(); final _listDiscountsConverter = ListDiscountsConverter(); final _listIntConverter = ListIntConverter(); final _boolConverter = BoolConverter(); +final _faceTimestampConverter = FaceTimestampConverter(); diff --git a/lib/common/services/database/entity/face_entity.dart b/lib/common/services/database/entity/face_entity.dart index 3a0f7c6..504504f 100644 --- a/lib/common/services/database/entity/face_entity.dart +++ b/lib/common/services/database/entity/face_entity.dart @@ -1,6 +1,7 @@ import 'package:floor/floor.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../type_converter/face_timestamp_converter.dart'; part 'face_entity.g.dart'; @@ -43,9 +44,11 @@ class FaceEntity extends Equatable { List? faceModel; /// 创建时间 + @TypeConverters([FaceTimestampConverter]) DateTime? createTime; /// 同步时间 + @TypeConverters([FaceTimestampConverter]) DateTime? updateTime; /// 最近一次在库图片质量分,范围为0到1;未检测或检测失败时为空。 @@ -55,12 +58,14 @@ class FaceEntity extends Equatable { String qualityStatus; /// 最近一次质量检测完成时间;从未检测时为空。 + @TypeConverters([FaceTimestampConverter]) DateTime? qualityCheckedAt; /// 最近一次质量检测的虹软或插件错误码;成功时为空。 int? qualityErrorCode; /// 质量结果最近一次被服务端确认的时间;为空或早于检测时间时需要补报。 + @TypeConverters([FaceTimestampConverter]) DateTime? qualityReportedAt; FaceEntity({ diff --git a/lib/common/services/database/type_converter/face_timestamp_converter.dart b/lib/common/services/database/type_converter/face_timestamp_converter.dart new file mode 100644 index 0000000..7e129ee --- /dev/null +++ b/lib/common/services/database/type_converter/face_timestamp_converter.dart @@ -0,0 +1,13 @@ +import 'package:floor/floor.dart'; + +/// 人脸版本和质量确认时间必须无损保存;旧版秒级时间仍可直接读取。 +class FaceTimestampConverter extends TypeConverter { + @override + DateTime? decode(String? databaseValue) => + databaseValue == null || databaseValue.isEmpty + ? null + : DateTime.parse(databaseValue); + + @override + String? encode(DateTime? value) => value?.toIso8601String(); +} diff --git a/lib/common/utils/app/app_face_tool.dart b/lib/common/utils/app/app_face_tool.dart index 087c2dd..b5d430b 100644 --- a/lib/common/utils/app/app_face_tool.dart +++ b/lib/common/utils/app/app_face_tool.dart @@ -43,6 +43,7 @@ abstract class AppFaceTool { /// /// 任意插件、权限或样本异常都转换为警告结果,调用方可以继续完成终端启动。 static Future prepareForStartup() async { + final startupWatch = Stopwatch()..start(); if (AppRuntimeTool.useManualHardwareInput) { return const FaceStartupPreparationResult( status: FaceStartupPreparationStatus.skipped, @@ -70,8 +71,11 @@ abstract class AppFaceTool { } FaceActivationResult activation; + final activationWatch = Stopwatch()..start(); try { activation = await activateSdk(); + logger.i( + '|FaceStartupPerf|activationMs:${activationWatch.elapsedMilliseconds}|success:${activation.success}|'); } catch (error, stack) { logger.w('|$_tag|启动阶段激活人脸插件异常error:|$error|stack:|$stack|'); return const FaceStartupPreparationResult( @@ -102,11 +106,16 @@ abstract class AppFaceTool { // 装载阶段已尝试从本地图片补回缺失特征;最终仍无特征的行在此清理, // 否则它们进不了内存库,却会让一致性比对永久失败,收银弹框就会 // 反复进入就地注册引导。清库只发生在启动装载和同步删除两个入口。 + final cleanupWatch = Stopwatch()..start(); try { - await SyncFaceTool.delEmptyFace(); + if (registration.metrics['loadCompleted'] == 1) { + await SyncFaceTool.delEmptyFace(); + } } catch (error, stack) { logger.w('|$_tag|启动清理残缺人脸失败error:|$error|stack:|$stack|'); } + logger.i( + '|FaceStartupPerf|startupMs:${startupWatch.elapsedMilliseconds}|registrationMs:${registration.durationMs}|cleanupMs:${cleanupWatch.elapsedMilliseconds}|total:${registration.total}|success:${registration.successCount}|failed:${registration.failedCount}|'); final status = registration.success ? FaceStartupPreparationStatus.success : FaceStartupPreparationStatus.warning; @@ -229,8 +238,9 @@ abstract class AppFaceTool { /// 查询当前设备的人脸 SDK 是否已经激活并可继续初始化。 static Future isActiveSdk() async { if (AppRuntimeTool.useManualHardwareInput) return true; - final status = await getStatus(); - return status?.activated == true; + final setting = await AppSettingTool.getSysSetting(); + if (!validateSysFaceParams(setting)) return false; + return FaceDiscernPlugs.isSdkActivated(); } /// 获取激活、引擎和人脸库一致性状态。 diff --git a/lib/common/utils/app/app_network_tool.dart b/lib/common/utils/app/app_network_tool.dart index 8c721c5..f26cb06 100644 --- a/lib/common/utils/app/app_network_tool.dart +++ b/lib/common/utils/app/app_network_tool.dart @@ -142,27 +142,26 @@ abstract class AppNetworkTool { } // 从网络中下载图片 - static Future downloadImage(String url) async { + static Future downloadImage(String url, + {http.Client? client}) async { final networkImage = serverImageUrl(url); + final downloadClient = client ?? http.Client(); try { - final client = http.Client(); - try { - final request = http.Request('GET', Uri.parse(networkImage)); - final streamedResponse = await client.send(request).timeout( - const Duration(seconds: 30), - ); - if (streamedResponse.statusCode != 200) return null; - final List bytes = []; - await for (final chunk in streamedResponse.stream) { - bytes.addAll(chunk); + final request = http.Request('GET', Uri.parse(networkImage)); + return await (() async { + final response = await downloadClient.send(request); + if (response.statusCode != 200) { + await response.stream.drain(); + return null; } - return Uint8List.fromList(bytes); - } finally { - client.close(); - } + return response.stream.toBytes(); + })() + .timeout(const Duration(seconds: 30)); } catch (e) { - logger.e('|$_tag|从网络中下载图片错误:|$e|'); + logger.w('|$_tag|图片下载失败|error:${e.runtimeType}|'); return null; + } finally { + if (client == null) downloadClient.close(); } } diff --git a/lib/common/utils/app/control/app_control.dart b/lib/common/utils/app/control/app_control.dart index 5b1f237..a83907a 100644 --- a/lib/common/utils/app/control/app_control.dart +++ b/lib/common/utils/app/control/app_control.dart @@ -226,7 +226,7 @@ class AppControl extends GetxController { _setStartupStep( 3, status: AppStartupStepStatus.running, - detail: '正在激活人脸插件并装载本地人脸库,样本较多时可能需要数十秒', + detail: '正在准备人脸识别并装载本地人脸库', ); final facePreparation = await AppFaceTool.prepareForStartup(); _setStartupStep( diff --git a/lib/common/utils/manage/http_manage.dart b/lib/common/utils/manage/http_manage.dart index 56cdcaa..9bb393e 100644 --- a/lib/common/utils/manage/http_manage.dart +++ b/lib/common/utils/manage/http_manage.dart @@ -20,6 +20,9 @@ class HttpManage { /// 需要把统一业务非零码作为 Dio 异常交给调用方区分明确失败与超时的请求标记。 static const String rejectBusinessErrorKey = 'rejectBusinessError'; + /// 大清单只记录条数,避免在日志截断前遍历、脱敏并字符串化全部人员档案。 + static const String responseSummaryKey = 'responseSummary'; + /// HTTP 管理日志标签。 static const _tag = "http管理"; @@ -292,12 +295,26 @@ class HttpManage { dynamic response, }) { final cost = _calcRequestCost(options); + final logData = options.extra[responseSummaryKey] == true + ? _responseSummary(response) + : response; logger.i( "|$_tag|请求完成 method:${options.method} url:${_formatLogUri(options.uri)} " - "cost:${cost}ms response:${_formatLogData(response)}", + "cost:${cost}ms response:${_formatLogData(logData)}", ); } + Object? _responseSummary(dynamic response) { + if (response is List) return {'count': response.length}; + if (response is Map && response['records'] is List) { + return { + 'count': (response['records'] as List).length, + 'hasMore': response['hasMore'] + }; + } + return response == null ? null : {'type': response.runtimeType.toString()}; + } + /// 记录失败请求的耗时、错误类型和脱敏后的请求/响应摘要。 void _logError(DioException error) { final options = error.requestOptions; diff --git a/lib/common/utils/plugs/face_discern_plugs.dart b/lib/common/utils/plugs/face_discern_plugs.dart index 79f359f..702c05c 100644 --- a/lib/common/utils/plugs/face_discern_plugs.dart +++ b/lib/common/utils/plugs/face_discern_plugs.dart @@ -30,6 +30,16 @@ abstract class FaceDiscernPlugs { static MethodChannel get getChannel => _channel; + /// 只读本机激活文件;按钮前置检查无需再扫描整个人脸数据库。 + static Future isSdkActivated() async { + try { + return await _channel.invokeMethod('isFaceSdkActivated') == true; + } catch (error) { + logger.w('|$_tag|激活状态查询失败|error:${error.runtimeType}|'); + return false; + } + } + static Stream? _stream; static Stream? _sessionStream; static Stream? _registrationStream; @@ -188,38 +198,6 @@ abstract class FaceDiscernPlugs { return FaceRecognitionConfiguration.fromMap(map); } - static Future getConfig() async { - try { - final Map? map = - await _channel.invokeMethod('getConfig'); - if (map == null) return null; - final encode = json.encode(map); - return FaceConfig.fromJson(json.decode(encode)); - } catch (e) { - logger.w("|$_tag| getConfig 错误: $e"); - } - return null; - } - - static Future active({ - required String appId, - required String sdkKey, - required String activeKey, - }) async { - try { - final pa = { - "appId": appId, - "sdkKey": sdkKey, - "activeKey": activeKey, - }; - final isActive = await _channel.invokeMethod('active', pa); - return isActive; - } catch (e) { - logger.w("|$_tag| getConfig 错误: $e"); - } - return false; - } - /// 查询真实授权文件、引擎和内存人脸库状态。 static Future getFaceStatus({ required String appId, @@ -377,18 +355,6 @@ abstract class FaceDiscernPlugs { ); } - static Future getFace({ - required int sampleId, - }) async { - final pa = { - "sampleId": sampleId, - }; - final map = await _channel.invokeMethod('getFace', pa); - if (map == null) return null; - final encode = json.encode(map); - return FaceRes.fromJson(json.decode(encode)); - } - static Future removeFace({ required String sampleId, }) async { @@ -401,15 +367,19 @@ abstract class FaceDiscernPlugs { return res; } - static Future> registerFaceByDatabase({ - String? userId, - }) async { - final pa = { - "userId": userId, - }; - final res = await _channel.invokeMethod('registerFaceByDatabase', pa); - final errorSampleIdList = (res as List).cast(); - return errorSampleIdList; + /// 姓名或人员绑定变化时只刷新内存档案,保留已注册的特征。 + static Future updateFaceMetadata(List faces) async { + final result = await _channel.invokeMethod('updateFaceMetadata', { + 'items': [ + for (final face in faces) + { + 'sampleId': int.parse(face.sampleId), + 'userId': face.userId, + 'username': face.userName + }, + ], + }); + return result == true; } static Stream get faceStream { diff --git a/lib/common/utils/sync/face_image_download_queue.dart b/lib/common/utils/sync/face_image_download_queue.dart new file mode 100644 index 0000000..f9a698f --- /dev/null +++ b/lib/common/utils/sync/face_image_download_queue.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import '../../models/api/user_api_models.dart'; + +/// 有界预取窗口;消费方等待同一在途任务,避免一张照片被重复下载。 +class FaceImageDownloadQueue { + FaceImageDownloadQueue({ + required List records, + required this.download, + required this.discard, + this.concurrency = 3, + }) : _records = records { + if (concurrency < 1) throw ArgumentError.value(concurrency); + _fill(); + } + + final List _records; + final Future Function(ServiceFaceSyncRecord record) download; + final Future Function(File file) discard; + final int concurrency; + final _pending = >{}; + var _next = 0; + var _closed = false; + var downloadCount = 0; + var consumedCount = 0; + + void _fill() { + while ( + !_closed && _pending.length < concurrency && _next < _records.length) { + final record = _records[_next++]; + downloadCount++; + _pending[record.sampleId] = _load(record); + } + } + + Future _load(ServiceFaceSyncRecord record) async { + try { + return await download(record); + } catch (_) { + return null; + } + } + + Future take(String sampleId) async { + final pending = _pending[sampleId]; + if (pending == null) return null; + final file = await pending; + _pending.remove(sampleId); + if (file != null) consumedCount++; + _fill(); + return file; + } + + /// 中止时只等待最多 concurrency 条在途请求,不再下载余下清单。 + Future dispose() async { + _closed = true; + final files = await Future.wait(_pending.values); + _pending.clear(); + for (final file in files.whereType()) { + await discard(file); + } + } +} diff --git a/lib/common/utils/sync/face_sync_failure_store.dart b/lib/common/utils/sync/face_sync_failure_store.dart new file mode 100644 index 0000000..c4fcf28 --- /dev/null +++ b/lib/common/utils/sync/face_sync_failure_store.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +import '../../models/api/user_api_models.dart'; + +/// 仅记录已确认的照片提取失败;同一版本暂缓 15 分钟,网络/授权异常不进入这里。 +/// 文件只含版本摘要和重试时间,不包含姓名、图片地址或特征。 +class FaceSyncFailureStore { + FaceSyncFailureStore._(this._file, this._now); + static const retryDelay = Duration(minutes: 15); + final File _file; + final DateTime Function() _now; + final _entries = {}; + var _dirty = false; + + static Future open(String storagePath, + {DateTime Function()? now}) async { + final store = FaceSyncFailureStore._( + File('$storagePath/.face-sync-failures.json'), now ?? DateTime.now); + try { + if (await store._file.exists() && + await store._file.length() <= 2 * 1024 * 1024) { + final data = jsonDecode(await store._file.readAsString()); + if (data is Map && data['version'] == 1 && data['entries'] is Map) { + for (final entry in (data['entries'] as Map).entries) { + if (entry.key is String && + (entry.key as String).length == 64 && + entry.value is int && + entry.value > store._now().millisecondsSinceEpoch) { + store._entries[entry.key as String] = entry.value as int; + } + } + } + } + } catch (_) { + // 缓存损坏只会多重试一次照片,不能阻断权威同步。 + } + return store; + } + + String _key(ServiceFaceSyncRecord face) => sha256 + .convert(utf8.encode(jsonEncode([ + face.sampleId, + face.faceImg, + face.sourceUpdatedAt?.toIso8601String(), + ]))) + .toString(); + + bool shouldDefer(ServiceFaceSyncRecord face) => + (_entries[_key(face)] ?? 0) > _now().millisecondsSinceEpoch; + + void remember(ServiceFaceSyncRecord face) { + _entries[_key(face)] = _now().add(retryDelay).millisecondsSinceEpoch; + _dirty = true; + } + + void forget(ServiceFaceSyncRecord face) { + if (_entries.remove(_key(face)) != null) _dirty = true; + } + + Future flush() async { + if (!_dirty) return; + _entries + .removeWhere((_, expiry) => expiry <= _now().millisecondsSinceEpoch); + if (_entries.length > 10000) { + final ordered = _entries.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + _entries + ..clear() + ..addEntries(ordered.take(10000)); + } + final pending = File('${_file.path}.tmp'); + await pending.writeAsString(jsonEncode({'version': 1, 'entries': _entries}), + flush: true); + await pending.rename(_file.path); + _dirty = false; + } +} diff --git a/lib/common/utils/sync/face_sync_plan.dart b/lib/common/utils/sync/face_sync_plan.dart new file mode 100644 index 0000000..a026843 --- /dev/null +++ b/lib/common/utils/sync/face_sync_plan.dart @@ -0,0 +1,83 @@ +import '../../models/api/user_api_models.dart'; +import '../../models/manage/face_sync_models.dart'; +import '../../services/database/entity/face_entity.dart'; + +enum FaceSyncChangeKind { image, metadata, unchanged } + +/// 只计算差集,不读写文件、数据库或 SDK;完整清单校验成功后才执行计划。 +class FaceSyncChange { + const FaceSyncChange(this.remote, this.local, this.kind); + + final ServiceFaceSyncRecord remote; + final FaceEntity? local; + final FaceSyncChangeKind kind; + + static bool sameVersion(DateTime? left, DateTime? right) => left == null + ? right == null + : right != null && left.isAtSameMomentAs(right); + + static bool metadataChanged(FaceEntity local, ServiceFaceSyncRecord remote) => + local.userId != remote.userId || + local.userName != (remote.username ?? '') || + (local.deptId ?? '') != remote.deptId || + (local.deptName ?? '') != (remote.deptName ?? '') || + local.imagePath != remote.faceImg || + (remote.sourceUpdatedAt != null && + !sameVersion(local.updateTime, remote.sourceUpdatedAt)); +} + +class FaceSyncPlan { + const FaceSyncPlan(this.changes, this.orphans); + + final List changes; + final List orphans; + + factory FaceSyncPlan.build({ + required List remote, + required List local, + required Set existingPaths, + Set? scopeDeptIds, + Set protectedSampleIds = const {}, + }) { + final remaining = {}; + for (final face in local) { + if (remaining.containsKey(face.sampleId)) { + throw const FaceSyncSnapshotException('本地存在重复人脸编号,请先修复数据'); + } + remaining[face.sampleId] = face; + } + final seen = {}; + final changes = []; + for (final record in remote) { + if (!seen.add(record.sampleId)) { + throw const FaceSyncSnapshotException('远端清单包含重复人脸编号'); + } + final face = remaining.remove(record.sampleId); + final imageChanged = face == null || + face.imagePath != record.faceImg || + (record.sourceUpdatedAt != null && + !FaceSyncChange.sameVersion( + face.updateTime, record.sourceUpdatedAt)) || + (face.faceModel ?? const []).isEmpty || + face.path == null || + !existingPaths.contains(face.path); + changes.add(FaceSyncChange( + record, + face, + imageChanged + ? FaceSyncChangeKind.image + : FaceSyncChange.metadataChanged(face, record) + ? FaceSyncChangeKind.metadata + : FaceSyncChangeKind.unchanged, + )); + } + return FaceSyncPlan( + changes, + remaining.values + .where((face) => + !protectedSampleIds.contains(face.sampleId) && + (scopeDeptIds == null || scopeDeptIds.contains(face.deptId))) + .toList(), + ); + } +} diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart index 80b5d8f..28f7d71 100644 --- a/lib/common/utils/sync/sync_face_tool.dart +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -1,10 +1,14 @@ import 'dart:io'; +import 'dart:convert'; -import 'package:common_utils/common_utils.dart'; -import 'package:flutter/material.dart'; +import 'package:crypto/crypto.dart'; import 'package:get/get.dart'; +import 'package:http/http.dart' as http; import 'package:pj_supermarket_cloud/common/index.dart'; +import 'face_sync_plan.dart'; +import 'face_image_download_queue.dart'; +import 'face_sync_failure_store.dart'; /// 应用级人脸差异同步服务,供页面、Cron、MQ 和识别异常恢复共同复用。 abstract class SyncFaceTool { @@ -12,10 +16,10 @@ abstract class SyncFaceTool { static const String _tag = '人脸差异同步'; /// V3 游标快照页面大小。 - static const int _snapshotPageSize = 200; + static const int _snapshotPageSize = 500; /// V2 调试全租户同步页面大小,仅非生产入口使用。 - static const int _debugPageSize = 100; + static const int _debugPageSize = 1000; /// 单次日志最多保留的脱敏失败样本数量,避免异常数据导致日志膨胀。 static const int _maxFailureSamples = 20; @@ -26,9 +30,6 @@ abstract class SyncFaceTool { /// 服务端单次允许接收的人脸质量结果上限。 static const int _qualityReportBatchSize = 500; - /// 整批上报被服务端拒绝后的降级小批大小,避免个别样本拖住整批。 - static const int _qualityRetryBatchSize = 25; - /// 同步图片预取的临时下载目录名;每次同步开始时整体清理中断遗留。 static const String _syncTempDirectoryName = '.face-sync-temp'; @@ -47,6 +48,9 @@ abstract class SyncFaceTool { /// SQLite 批量删除的单语句样本上限。 static const int _sqlDeleteBatchSize = 500; + static bool _syncRunning = false; + static DateTime? _lastSweepAt; + /// 删除数据库中缺少人脸特征的历史残缺行;启动整库装载完成后调用。 /// /// 空特征行永远注册不进虹软内存库,却参与"内存库与本地库一致"的集合 @@ -55,12 +59,13 @@ abstract class SyncFaceTool { /// 删除顺序仍先处理虹软内存库;单条失败时保留记录供后续权威快照重试。 static Future delEmptyFace() async { final faceDao = Get.find().dataBase.getFaceDao; - // 轻量档案以哨兵区分特征有无,避免为找出残缺行反序列化全表特征。 - final profiles = await faceDao.getSyncProfiles(); + // 只回传残缺行;正常启动无需把数千条完整档案再传回 Dart 逐条筛选。 + final profiles = await faceDao.getEmptyFaceList() ?? const []; var deleted = 0; for (final profile in profiles) { - if ((profile.faceModel ?? const []).isNotEmpty) continue; - if (await _deleteLocalFace(profile, faceDao)) deleted++; + if (await _deleteLocalFace(profile, faceDao, completeRecord: true)) { + deleted++; + } } if (deleted > 0) { logger.i('|$_tag|启动清理无可注册特征的残缺人脸|count:$deleted|'); @@ -68,21 +73,6 @@ abstract class SyncFaceTool { return deleted; } - /// 兼容既有页面与识别恢复调用的统一入口。 - /// - /// [isAll] 只允许非生产调试使用;生产调用即使误传也会收敛为本部门快照。 - static Future syncFace({ - bool isAll = false, - VoidCallback? onSyncGroup, - }) async { - final mode = isAll && !AppSysTool.isProd() - ? FaceSyncMode.debugAllSnapshot - : FaceSyncMode.departmentSnapshot; - final summary = await sync(mode: mode); - onSyncGroup?.call(); - return summary; - } - /// 执行部门权威快照或一组用户增量;用户集合按稳定顺序串行处理。 /// /// 完整快照会一次性拉入内存并完成唯一性、游标和页面连续性校验, @@ -96,6 +86,26 @@ abstract class SyncFaceTool { Set? userIds, void Function(int processed, int? total, String? note)? onProgress, }) async { + if (_syncRunning) throw const FaceSyncSnapshotException('人脸同步正在进行中'); + _syncRunning = true; + try { + return await _sync(mode: mode, userIds: userIds, onProgress: onProgress); + } finally { + _syncRunning = false; + } + } + + static Future _sync({ + required FaceSyncMode mode, + Set? userIds, + void Function(int processed, int? total, String? note)? onProgress, + }) async { + final watch = Stopwatch()..start(); + final metrics = { + 'extractions': 0, + 'nativeUpserts': 0, + 'metadataUpdates': 0 + }; if (!AppNetworkTool.isAutoNetwork()) { throw const FaceSyncSnapshotException('网络不可用,无法获取人脸权威快照'); } @@ -150,44 +160,118 @@ abstract class SyncFaceTool { ); } } else { - final remote = mode == FaceSyncMode.debugAllSnapshot - ? await _loadDebugAllSnapshot(onProgress: onProgress) - : await _loadV3Snapshot( - merchantDeptId: merchantDeptId, - onProgress: onProgress, - ); + final snapshotWatch = Stopwatch()..start(); + final Set? departmentScope; + final _DebugFaceSnapshot? debugSnapshot; + final List remote; + if (mode == FaceSyncMode.debugAllSnapshot) { + departmentScope = null; + debugSnapshot = await _loadDebugAllSnapshot(onProgress: onProgress); + remote = debugSnapshot.records; + for (final id in debugSnapshot.conflictingSampleIds) { + accumulator.scanned++; + accumulator.fail(id, '服务端人脸编号冲突,已保留本地样本'); + } + } else { + debugSnapshot = null; + // 真正的部门树与照片清单并行拉取;两者都成功后才能开始本地变更。 + final scopeFuture = UserApi.getFaceDepartmentScope(merchantDeptId); + final remoteFuture = _loadV3Snapshot( + merchantDeptId: merchantDeptId, + onProgress: onProgress, + ); + final values = await Future.wait([scopeFuture, remoteFuture]); + departmentScope = values[0] as Set; + remote = values[1] as List; + if (remote.any((face) => !departmentScope!.contains(face.deptId))) { + throw const FaceSyncSnapshotException('人脸清单与部门范围不一致,请重试'); + } + } + metrics['snapshotMs'] = snapshotWatch.elapsedMilliseconds; // 轻量档案不反序列化特征大字段;需要完整行时在单条处理时按 sampleId 回读。 - final local = await Get.find() - .dataBase - .getFaceDao - .getSyncProfiles(); + final localWatch = Stopwatch()..start(); + final faceDao = Get.find().dataBase.getFaceDao; + final local = departmentScope == null + ? await faceDao.getSyncProfiles() + : await _loadDepartmentProfiles(faceDao, departmentScope, remote); + metrics['localReadMs'] = localWatch.elapsedMilliseconds; await _reconcile( remote: remote, local: local, storagePath: storagePath, accumulator: accumulator, - orphanScopeDeptIds: mode == FaceSyncMode.departmentSnapshot - ? {for (final record in remote) record.deptId} - : null, + orphanScopeDeptIds: departmentScope, + protectedSampleIds: debugSnapshot?.conflictingSampleIds ?? const {}, + beforeDeletePlan: debugSnapshot == null + ? null + : () async { + // V2 没有固定上界。只有确实要删本地数据时再次核对完整清单, + // 并发新增、删除导致的 OFFSET 漏页不能被当成本地孤儿。 + onProgress?.call(0, null, '正在复核全量清单...'); + final verified = await _loadDebugAllSnapshot(); + if (verified.fingerprint != debugSnapshot!.fingerprint) { + throw const FaceSyncSnapshotException('服务端人脸在分页期间发生变化,请重试同步'); + } + }, onProgress: onProgress, + metrics: metrics, ); } + final qualityWatch = Stopwatch()..start(); final qualityReported = await _reportPersistedFaceQualitySafely( onProgress: onProgress, ); - onProgress?.call(0, null, '正在清理目录文件...'); - await _sweepFaceDirectory(storagePath); + metrics['qualityMs'] = qualityWatch.elapsedMilliseconds; + final sweepWatch = Stopwatch()..start(); + if (accumulator.added + accumulator.updated + accumulator.deleted > 0 || + _lastSweepAt == null || + DateTime.now().difference(_lastSweepAt!) >= const Duration(hours: 1)) { + onProgress?.call(0, null, '正在清理目录文件...'); + await _sweepFaceDirectory(storagePath); + _lastSweepAt = DateTime.now(); + } + metrics['sweepMs'] = sweepWatch.elapsedMilliseconds; + metrics['totalMs'] = watch.elapsedMilliseconds; final summary = accumulator.toSummary( qualityReportNote: qualityReported ? null : '质量上报未完成,将在下次同步重试', ); logger.i('|$_tag|同步完成|${summary.message}|'); + logger.i('|FaceSyncPerf|mode:${mode.name}|metrics:$metrics|'); return summary; } - /// 拉取本部门 V3 快照清单并返回快照覆盖的部门编号集合。 - /// - /// 供调试清理入口判断"范围外样本";只拉清单不做图片处理。空集合表示部门 - /// 当前没有任何在册人脸,调用方必须中止清理,避免把本地全部人脸误判为范围外。 + /// 部门同步只查询部门内记录;新入部门的样本再按编号补查,避免重复插入调试库中已有的行。 + static Future> _loadDepartmentProfiles( + FaceDao faceDao, + Set deptIds, + List remote, + ) async { + final byRowId = {}; + final departments = deptIds.toList(); + for (var start = 0; start < departments.length; start += 500) { + final rows = await faceDao.getSyncProfilesInDepartments( + departments.skip(start).take(500).toList(), + ); + for (final face in rows) { + byRowId[face.id] = face; + } + } + final knownIds = byRowId.values.map((face) => face.sampleId).toSet(); + final missing = remote + .map((face) => face.sampleId) + .where((id) => !knownIds.contains(id)) + .toList(); + for (var start = 0; start < missing.length; start += 500) { + for (final face in await faceDao.getSyncProfilesBySampleIds( + missing.skip(start).take(500).toList(), + )) { + byRowId[face.id] = face; + } + } + return byRowId.values.toList(); + } + + /// 供调试清理入口读取完整部门树,空部门也属于设备同步范围。 static Future> getDepartmentScopeDeptIds() async { if (!AppNetworkTool.isAutoNetwork()) { throw const FaceSyncSnapshotException('网络不可用,无法获取部门范围'); @@ -197,8 +281,7 @@ abstract class SyncFaceTool { if (merchantDeptId == null || merchantDeptId.isEmpty) { throw const FaceSyncSnapshotException('终端尚未绑定商户部门'); } - final remote = await _loadV3Snapshot(merchantDeptId: merchantDeptId); - return {for (final record in remote) record.deptId}; + return UserApi.getFaceDepartmentScope(merchantDeptId); } /// 拉取 V3 固定上界游标快照并校验页面连续性、记录唯一性和字段有效性。 @@ -226,14 +309,25 @@ abstract class SyncFaceTool { if (snapshot.snapshotMaxId != snapshotMaxId) { throw const FaceSyncSnapshotException('人脸快照上界在分页期间发生变化'); } + final maximumId = int.tryParse(snapshotMaxId); + if (maximumId == null || + maximumId < 0 || + snapshot.records.length > _snapshotPageSize) { + throw const FaceSyncSnapshotException('人脸快照上界或分页大小无效'); + } var previousId = int.tryParse(afterId) ?? 0; for (final record in snapshot.records) { final currentId = int.tryParse(record.id); final sampleId = int.tryParse(record.sampleId); - if (currentId == null || currentId <= previousId) { + if (currentId == null || + currentId <= previousId || + currentId > maximumId) { throw const FaceSyncSnapshotException('人脸快照游标不连续或发生倒退'); } - if (sampleId == null || sampleId <= 0 || sampleId > 2147483647) { + if (sampleId == null || + sampleId <= 0 || + sampleId > 2147483647 || + sampleId.toString() != record.sampleId) { throw FaceSyncSnapshotException( '存在无效人脸编号 ${_maskSampleId(record.sampleId)}', ); @@ -269,43 +363,46 @@ abstract class SyncFaceTool { throw const FaceSyncSnapshotException('人脸快照分页超过安全上限'); } - /// 使用保留的 V2 接口拉取非生产调试全租户快照。 - /// - /// 生产入口不会调用本方法;V2 以服务端 updateTime(缺失时回退 createTime)作为 - /// 图片版本,保证调试同步写入的人脸同样可以参与质量上报和版本比对。 - /// 服务端历史脏数据(空编号或重复编号)只跳过并记录计数,不阻断调试同步。 - static Future> _loadDebugAllSnapshot({ + /// V2 全租户清单:隔离冲突编号,禁止任意选择一个人的照片覆盖另一个人。 + static Future<_DebugFaceSnapshot> _loadDebugAllSnapshot({ void Function(int processed, int? total, String? note)? onProgress, }) async { final records = []; final sampleIds = {}; - var invalidCount = 0; - var duplicateCount = 0; + final databaseIds = {}; + final conflicts = {}; for (var page = 1; page <= _maxSnapshotPages; page++) { final data = await UserApi.getFaceListV2({ 'current': page, 'size': _debugPageSize, + 'searchCount': false, 'merchantDeptId': '', }); if (data == null) { throw const FaceSyncSnapshotException('调试全租户人脸接口未返回有效数据'); } for (final face in data) { - final sampleId = face.autoCode?.trim(); + final parsedSampleId = int.tryParse(face.autoCode?.trim() ?? ''); final userId = face.userId?.trim(); - if (sampleId == null || + if (parsedSampleId == null || + parsedSampleId <= 0 || + parsedSampleId > 2147483647 || userId == null || - sampleId.isEmpty || - userId.isEmpty) { - invalidCount++; - continue; + userId.isEmpty || + face.id == null || + face.faceImg == null || + face.faceImg!.trim().isEmpty) { + throw const FaceSyncSnapshotException('全量清单包含无效人脸,请先修复服务端数据'); } + if (!databaseIds.add(face.id!)) { + throw const FaceSyncSnapshotException('全量清单分页重复,请重试同步'); + } + final sampleId = parsedSampleId.toString(); if (!sampleIds.add(sampleId)) { - duplicateCount++; - continue; + conflicts.add(sampleId); } records.add(ServiceFaceSyncRecord( - id: face.id ?? sampleId, + id: face.id!, sampleId: sampleId, userId: userId, username: face.username, @@ -316,186 +413,228 @@ abstract class SyncFaceTool { )); } if (data.length < _debugPageSize) { - if (invalidCount > 0 || duplicateCount > 0) { + if (conflicts.isNotEmpty) { logger.w( - '|$_tag|调试快照跳过异常编号|invalid:$invalidCount|duplicate:$duplicateCount|', + '|$_tag|全量清单隔离冲突编号|count:${conflicts.length}|', ); } - return records; + final fingerprint = sha256 + .convert(utf8.encode(jsonEncode([ + for (final record in records) record.toJson(), + ]))) + .toString(); + return _DebugFaceSnapshot( + records.where((face) => !conflicts.contains(face.sampleId)).toList(), + conflicts, + fingerprint, + ); } onProgress?.call(records.length, null, null); } throw const FaceSyncSnapshotException('调试全租户快照分页超过安全上限'); } - /// 对完整服务端集合与指定范围本地集合做差异,只处理新增、变化和孤儿记录。 - /// - /// [orphanScopeDeptIds] 非空时只把部门编号在集合内的本地记录视为孤儿候选, - /// 供部门快照模式保留 V2 调试同步灌入的范围外样本;userDelta 的本地集合已按 - /// 用户限定、debugAllSnapshot 是全租户权威集合,两者传空保持全量删除语义。 + /// 完整清单校验后计算差集;图片、元数据和孤儿删除分别走有界管线。 static Future _reconcile({ required List remote, required List local, required String storagePath, required _FaceSyncAccumulator accumulator, Set? orphanScopeDeptIds, + Set protectedSampleIds = const {}, + Future Function()? beforeDeletePlan, void Function(int processed, int? total, String? note)? onProgress, + Map? metrics, }) async { + final watch = Stopwatch()..start(); final faceDao = Get.find().dataBase.getFaceDao; - final localBySampleId = {}; - for (final face in local) { - if (localBySampleId.containsKey(face.sampleId)) { - throw FaceSyncSnapshotException( - '本地存在重复人脸编号 ${_maskSampleId(face.sampleId)}', - ); - } - localBySampleId[face.sampleId] = face; + onProgress?.call(0, null, '正在核对本地照片...'); + final remoteIds = remote.map((face) => face.sampleId).toSet(); + final existingPaths = await _collectExistingFilePaths( + local.where((face) => remoteIds.contains(face.sampleId)).toList(), + ); + metrics?['fileCheckMs'] = watch.elapsedMilliseconds; + final plan = FaceSyncPlan.build( + remote: remote, + local: local, + existingPaths: existingPaths, + scopeDeptIds: orphanScopeDeptIds, + protectedSampleIds: protectedSampleIds, + ); + if (plan.orphans.isNotEmpty) await beforeDeletePlan?.call(); + final failureStore = await FaceSyncFailureStore.open(storagePath); + final imageRecords = [ + for (final change in plan.changes) + if (change.kind == FaceSyncChangeKind.image && + !failureStore.shouldDefer(change.remote)) + change.remote, + ]; + final imageClient = http.Client(); + final downloads = FaceImageDownloadQueue( + records: imageRecords, + download: (record) => + _downloadTemporaryFace(record, storagePath, imageClient), + discard: (file) => delFaceByStorage(filePath: file.path), + concurrency: _downloadPrefetchConcurrency, + ); + final metadata = []; + var lastProgressMs = -150; + var processed = 0; + void progress({bool force = false, String? note}) { + if (!force && watch.elapsedMilliseconds - lastProgressMs < 150) return; + lastProgressMs = watch.elapsedMilliseconds; + onProgress?.call(processed, remote.length, note); } - // 文件存在性分批并行预检,避免大库逐条串行 stat 拖慢比对阶段。 - final existingFilePaths = await _collectExistingFilePaths(local); - // 需要重下图片的记录先并行预取,下载与主流程的提取、注册流水线并行。 - final prefetchRecords = [ - for (final record in remote) - if (_needsImageWork( - localBySampleId[record.sampleId], - record, - existingFilePaths, - )) - record, - ]; - final prefetcher = _FaceImagePrefetcher.start( - prefetchRecords, - storagePath, - ); try { - for (final record in remote) { + progress(force: true, note: '正在处理变化人脸...'); + for (final change in plan.changes) { + final record = change.remote; accumulator.scanned++; - final existing = localBySampleId.remove(record.sampleId); - final imageChanged = _imageChanged(existing, record, existingFilePaths); - if (existing == null || imageChanged) { - final failureReason = await _upsertImageFace( - record: record, - existing: existing, - storagePath: storagePath, - faceDao: faceDao, - prefetcher: prefetcher, - ); - if (failureReason == null) { - existing == null ? accumulator.added++ : accumulator.updated++; - } else { - accumulator.fail(record.sampleId, failureReason); - } - } else if (_metadataChanged(existing, record)) { - // 元数据变化只改对应列,不回写特征大字段和质量检测结果。 - // 可空字段统一传空串,由 SQL 的 NULLIF/COALESCE 还原空值或保留旧版本。 - await faceDao.editMetadataBySampleId( - record.sampleId, - record.userId, - record.username ?? '', - record.deptId, - record.deptName ?? '', - record.faceImg ?? '', - record.sourceUpdatedAt == null - ? '' - : DateUtil.formatDate(record.sourceUpdatedAt!), - ); - accumulator.updated++; - } else { - accumulator.skipped++; + processed++; + switch (change.kind) { + case FaceSyncChangeKind.image: + if (failureStore.shouldDefer(record)) { + accumulator.fail(record.sampleId, '照片版本未变,已暂缓重复重试(最长15分钟)'); + metrics?['deferredImages'] = (metrics['deferredImages'] ?? 0) + 1; + break; + } + final failureReason = await _upsertImageFace( + record: record, + existing: change.local, + storagePath: storagePath, + faceDao: faceDao, + downloads: downloads, + metrics: metrics, + failureStore: failureStore, + ); + if (failureReason == null) { + failureStore.forget(record); + change.local == null + ? accumulator.added++ + : accumulator.updated++; + } else { + accumulator.fail(record.sampleId, failureReason); + } + case FaceSyncChangeKind.metadata: + metadata.add(change); + case FaceSyncChangeKind.unchanged: + failureStore.forget(record); + accumulator.skipped++; } - onProgress?.call(accumulator.scanned, remote.length, null); + progress(); } - - // 只有远端完整集合已经成功拉取和校验后才会执行此处的集合差删除。 - // 部门快照只删除快照覆盖部门内的孤儿,V2 调试灌入的范围外样本交回 - // 灌入它的调试同步管理,避免两种口径互相删除导致反复全量重建。 - final scopedOrphans = orphanScopeDeptIds == null - ? localBySampleId.values.toList(growable: false) - : localBySampleId.values - .where( - (face) => - face.deptId != null && - face.deptId!.isNotEmpty && - orphanScopeDeptIds.contains(face.deptId), - ) - .toList(growable: false); - final foreignRetained = localBySampleId.length - scopedOrphans.length; - if (foreignRetained > 0) { - // 已知边界:某子部门的人脸在服务端被整体删除时,该子部门的本地残留 - // 会保留到该部门再次出现数据或下一次全租户调试同步时清理。 - logger.i('|$_tag|范围外样本保留|count:$foreignRetained|'); + for (var start = 0; start < metadata.length; start += 100) { + final chunk = metadata.skip(start).take(100).toList(); + try { + await _updateMetadataBatch( + chunk + .map((change) => _mergeMetadata(change.local!, change.remote)) + .toList(), + chunk.map((change) => change.local!).toList(), + faceDao, + ); + accumulator.updated += chunk.length; + metrics?['metadataUpdates'] = + (metrics['metadataUpdates'] ?? 0) + chunk.length; + } catch (_) { + for (final change in chunk) { + accumulator.fail(change.remote.sampleId, '人脸档案更新失败,已保留旧数据'); + } + } + progress(note: '正在更新人员档案...'); } + progress(force: true); final bulkResult = await _deleteFacesInBulk( - scopedOrphans, + plan.orphans, faceDao, onProgress: onProgress == null ? null : (processed, total) => onProgress( - remote.length + processed, - remote.length + scopedOrphans.length, - null, - ), + remote.length + processed, + remote.length + (total ?? plan.orphans.length), + '正在清理已删除人脸...', + ), ); accumulator.deleted += bulkResult.deleted; for (final face in bulkResult.failedFaces) { accumulator.fail(face.sampleId, '本地样本清理失败'); } } finally { - await prefetcher?.dispose(); + try { + await downloads.dispose(); + } finally { + imageClient.close(); + } + try { + await failureStore.flush(); + } catch (error) { + logger.w('|$_tag|提取失败记录保存失败|error:${error.runtimeType}|'); + } + metrics?['downloads'] = + downloads.downloadCount + (metrics['downloadRetries'] ?? 0); + metrics?['imageWork'] = imageRecords.length; + metrics?['reconcileMs'] = watch.elapsedMilliseconds; } - logger.i( - '|$_tag|图片预取统计|计划:${prefetchRecords.length}|命中:${prefetcher?.hitCount ?? 0}|', - ); } - /// 判断快照记录是否需要走下载与特征重提取流程。 - static bool _needsImageWork( - FaceEntity? existing, - ServiceFaceSyncRecord record, - Set existingFilePaths, - ) => - existing == null || _imageChanged(existing, record, existingFilePaths); + static FaceEntity _mergeMetadata( + FaceEntity old, ServiceFaceSyncRecord record) => + FaceEntity( + id: old.id, + sampleId: old.sampleId, + userId: record.userId, + userName: record.username ?? '', + deptId: record.deptId, + deptName: record.deptName, + imagePath: record.faceImg!, + path: old.path, + faceModel: old.faceModel, + createTime: old.createTime, + updateTime: record.sourceUpdatedAt ?? old.updateTime, + qualityScore: old.qualityScore, + qualityStatus: old.qualityStatus, + qualityCheckedAt: old.qualityCheckedAt, + qualityErrorCode: old.qualityErrorCode, + qualityReportedAt: record.sourceUpdatedAt != null && + !FaceSyncChange.sameVersion( + old.updateTime, record.sourceUpdatedAt) + ? null + : old.qualityReportedAt, + ); - /// 判断图片版本、特征或正式文件是否变化;完全一致时不下载也不写虹软。 - /// - /// 历史本地记录可能缺少图片版本时间(早期 V2 调试同步写入的 updateTime 为空); - /// 此时只要图片地址一致且本地特征与文件完好,就不再强制重新下载,由 - /// [_metadataChanged] 的版本回填补齐,避免服务端图片损坏时记录永远无法修复。 - /// - /// [existingFilePaths] 为预检得到的仍存在的本地文件路径集合,见 - /// [_collectExistingFilePaths]。 - static bool _imageChanged( - FaceEntity? existing, - ServiceFaceSyncRecord record, - Set existingFilePaths, - ) { - if (existing == null) return true; - if (existing.imagePath != record.faceImg) return true; - if (record.sourceUpdatedAt != null && - existing.updateTime != null && - existing.updateTime != record.sourceUpdatedAt) { - return true; + /// SQLite 批量提交后刷新内存档案;原生整批校验失败时恢复数据库原值。 + static Future _updateMetadataBatch( + List updates, + List originals, + FaceDao faceDao, + ) async { + await faceDao.editSyncMetadataBatch(updates); + final changedBindings = []; + final oldBindings = []; + for (var index = 0; index < updates.length; index++) { + if (updates[index].userId != originals[index].userId || + updates[index].userName != originals[index].userName) { + changedBindings.add(updates[index]); + oldBindings.add(originals[index]); + } + } + try { + if (changedBindings.isNotEmpty && + !AppRuntimeTool.useManualHardwareInput && + !await FaceDiscernPlugs.updateFaceMetadata(changedBindings)) { + throw const FaceSyncSnapshotException('内存人脸档案更新失败'); + } + } catch (_) { + await faceDao.editSyncMetadataBatch(originals); + // 通道结果丢失时也恢复内存档案;特征始终保持不变。 + if (oldBindings.isNotEmpty && !AppRuntimeTool.useManualHardwareInput) { + await FaceDiscernPlugs.updateFaceMetadata(oldBindings); + } + rethrow; } - if ((existing.faceModel ?? const []).isEmpty) return true; - final path = existing.path; - return path == null || path.isEmpty || !existingFilePaths.contains(path); } - /// 判断姓名、部门元数据或图片版本时间是否变化;此类变化不需要重新提取图片特征。 - /// - /// 版本时间以服务端快照为准回填,用于修复缺少上报版本的本地历史记录。 - static bool _metadataChanged( - FaceEntity existing, - ServiceFaceSyncRecord record, - ) => - existing.userId != record.userId || - existing.userName != (record.username ?? '') || - existing.deptId != record.deptId || - existing.deptName != (record.deptName ?? '') || - (record.sourceUpdatedAt != null && - existing.updateTime != record.sourceUpdatedAt); - /// 下载临时图片、提取特征并提交文件、SQLite 和虹软单样本增量。 /// /// 虹软替换失败时原生保留旧特征,本方法随后回滚 SQLite 和新文件;新样本失败则不留正式记录。 @@ -505,18 +644,39 @@ abstract class SyncFaceTool { required FaceEntity? existing, required String storagePath, required FaceDao faceDao, - _FaceImagePrefetcher? prefetcher, + required FaceImageDownloadQueue downloads, + required FaceSyncFailureStore failureStore, + Map? metrics, }) async { File? temporaryFile; File? finalFile; + var filePromoted = false; var databaseChanged = false; // 比对用轻量档案不含特征;单条回读完整行,保证替换与回滚都写回真实特征。 final existingRow = existing == null ? null : await faceDao.findBySampleId(existing.sampleId) ?? existing; try { - temporaryFile = prefetcher?.take(record.sampleId) ?? - await _downloadTemporaryFace(record, storagePath); + temporaryFile = await downloads.take(record.sampleId); + if (temporaryFile == null) { + metrics?['downloadRetries'] = (metrics['downloadRetries'] ?? 0) + 1; + temporaryFile = await downloads.download(record); + } + // URL 或记录时间变化不一定意味着照片字节变化;相同图片复用原特征和质量结果。 + if (existingRow != null && + (existingRow.faceModel ?? const []).isNotEmpty && + existingRow.path != null && + await File(existingRow.path!).exists() && + await _sameImageBytes(temporaryFile, File(existingRow.path!))) { + await _updateMetadataBatch( + [_mergeMetadata(existingRow, record)], + [existingRow], + faceDao, + ); + metrics?['identicalImages'] = (metrics['identicalImages'] ?? 0) + 1; + return null; + } + metrics?['extractions'] = (metrics['extractions'] ?? 0) + 1; final extraction = AppRuntimeTool.useManualHardwareInput ? null : await FaceDiscernPlugs.extractFaceFeature( @@ -524,12 +684,16 @@ abstract class SyncFaceTool { ); final featureData = extraction?.featureData ?? []; if (!AppRuntimeTool.useManualHardwareInput && featureData.isEmpty) { + if (extraction?.cacheableFailure == true) failureStore.remember(record); throw const FaceSyncSnapshotException('图片未提取到可注册人脸'); } finalFile = File(_officialFilePath(record, storagePath)); - if (await finalFile.exists()) await finalFile.delete(); + if (await finalFile.exists()) { + throw const FaceSyncSnapshotException('人脸文件路径冲突,请重试'); + } await temporaryFile.rename(finalFile.path); + filePromoted = true; temporaryFile = null; final entity = _toEntity( record, @@ -549,6 +713,7 @@ abstract class SyncFaceTool { databaseChanged = true; if (!AppRuntimeTool.useManualHardwareInput) { + metrics?['nativeUpserts'] = (metrics['nativeUpserts'] ?? 0) + 1; final applied = await FaceDiscernPlugs.applyFaceDelta( operation: 'upsert', sampleId: record.sampleId, @@ -577,9 +742,26 @@ abstract class SyncFaceTool { logger.e( '|$_tag|回滚本地人脸失败sampleId:${_maskSampleId(record.sampleId)}|error:$rollbackError|stack:$rollbackStack|', ); + // 新文件可能仍被数据库引用,不能删除;中止本轮以免继续扩大不一致。 + throw const FaceSyncSnapshotException('本地人脸回滚失败,请重新同步修复'); + } + if (!AppRuntimeTool.useManualHardwareInput) { + if (existingRow == null) { + final removed = await FaceDiscernPlugs.applyFaceDelta( + operation: 'delete', + sampleId: record.sampleId, + ); + if (!removed.success) { + throw const FaceSyncSnapshotException('新增人脸回滚失败,请重新同步修复'); + } + } else { + await _restoreNativeFace(existingRow); + } } } - if (finalFile != null) await delFaceByStorage(filePath: finalFile.path); + if (filePromoted && finalFile != null) { + await delFaceByStorage(filePath: finalFile.path); + } // 快照异常携带面向终端人员的安全摘要;其余异常只归类不透出细节。 return error is FaceSyncSnapshotException ? error.message : '样本处理异常'; } finally { @@ -589,13 +771,25 @@ abstract class SyncFaceTool { } } + static Future _sameImageBytes(File left, File right) async { + if (await left.length() != await right.length()) return false; + final hashes = await Future.wait([ + sha256.bind(left.openRead()).first, + sha256.bind(right.openRead()).first, + ]); + return hashes[0] == hashes[1]; + } + /// 删除本地孤儿人脸;文件、虹软或 SQLite 失败时恢复文件和旧特征。 static Future _deleteLocalFace( FaceEntity face, - FaceDao faceDao, - ) async { + FaceDao faceDao, { + bool completeRecord = false, + }) async { // 比对用轻量档案不含特征,回滚与原生恢复需要完整行;单行回读代价可忽略。 - face = await faceDao.findBySampleId(face.sampleId) ?? face; + if (!completeRecord) { + face = await faceDao.findBySampleId(face.sampleId) ?? face; + } File? originalFile; File? pendingFile; try { @@ -681,10 +875,8 @@ abstract class SyncFaceTool { final candidates = []; final failedFaces = []; for (var start = 0; start < faces.length; start += _deleteFileBatchSize) { - final chunk = faces - .skip(start) - .take(_deleteFileBatchSize) - .toList(growable: false); + final chunk = + faces.skip(start).take(_deleteFileBatchSize).toList(growable: false); await Future.wait( chunk.map((face) async { final path = face.path; @@ -777,7 +969,9 @@ abstract class SyncFaceTool { // 阶段四:成功样本删除待删文件;失败样本回滚改名,SQL 失败样本恢复原生特征。 final fileStart = DateTime.now(); final rollbackFaces = [...failedFaces, ...sqlFailed]; - for (var start = 0; start < sqlDeleted.length; start += _deleteFileBatchSize) { + for (var start = 0; + start < sqlDeleted.length; + start += _deleteFileBatchSize) { final chunk = sqlDeleted .skip(start) .take(_deleteFileBatchSize) @@ -831,7 +1025,7 @@ abstract class SyncFaceTool { '|fileMs:${DateTime.now().difference(fileStart).inMilliseconds}|', ); onProgress?.call(deleted, faces.length); - return (deleted: deleted, failedFaces: failedFaces); + return (deleted: deleted, failedFaces: rollbackFaces); } /// 手动检测存量在库照片质量并将结果批量回写 SQLite。 @@ -899,8 +1093,7 @@ abstract class SyncFaceTool { .toList(growable: false), ); } catch (error, stack) { - logger.w( - '|$_tag|原生质量检测通道失败,将样本记录为检测失败|error:$error|stack:$stack|'); + logger.w('|$_tag|原生质量检测通道失败,将样本记录为检测失败|error:$error|stack:$stack|'); nativeResult = FaceQualityAuditResult( results: [ for (final face in validFaces) @@ -957,9 +1150,8 @@ abstract class SyncFaceTool { final reported = await _reportPersistedFaceQualitySafely(); return FaceQualityAuditResult( results: allResults, - message: reported - ? lastNativeMessage - : '$lastNativeMessage;服务端上报失败,将在下次重试', + message: + reported ? lastNativeMessage : '$lastNativeMessage;服务端上报失败,将在下次重试', ); } @@ -978,7 +1170,7 @@ abstract class SyncFaceTool { try { final faceDao = Get.find().dataBase.getFaceDao; // 上报只需要质量与版本列,走轻量档案避免全表特征反序列化。 - final faces = await faceDao.getSyncProfiles(); + final faces = await faceDao.getUnreportedQualityProfiles(); final items = []; var invalidCount = 0; var alreadyReportedCount = 0; @@ -1047,6 +1239,7 @@ abstract class SyncFaceTool { ...result.updatedSampleIds, ...result.ignoredStaleSampleIds, ...result.alreadyAbsentSampleIds, + ...result.ignoredNotApprovedSampleIds, }.where(batchSampleIds.contains).toList(growable: false); if (confirmedSampleIds.length != batch.length) allSucceeded = false; try { @@ -1054,10 +1247,11 @@ abstract class SyncFaceTool { if (confirmedSampleIds.isNotEmpty) { await faceDao.markQualityReported( confirmedSampleIds, - DateTime.now(), + DateTime.now().toIso8601String(), ); } } catch (markError, markStack) { + allSucceeded = false; logger.w( '|$_tag|质量上报标记失败|error:${markError.runtimeType}|stack:$markStack|', ); @@ -1080,24 +1274,12 @@ abstract class SyncFaceTool { final confirmed = await reportBatch(batch); if (confirmed != null) { confirmedCount += confirmed.length; - } else if (batch.length > _qualityRetryBatchSize) { - // 服务端对整批做一票否决校验(未审核/重复编号会拒绝整批): - // 降级为小子批重试,个别问题样本只拖住自己的小批,其余照常确认。 - for (var sub = 0; sub < batch.length; sub += _qualityRetryBatchSize) { - final subEnd = - (sub + _qualityRetryBatchSize).clamp(0, batch.length).toInt(); - final subConfirmed = await reportBatch(batch.sublist(sub, subEnd)); - if (subConfirmed == null) { - failedBatchCount++; - allSucceeded = false; - } else { - confirmedCount += subConfirmed.length; - } - onProgress?.call(confirmedCount, items.length, '正在上报质量结果'); - } } else { failedBatchCount++; allSucceeded = false; + // 服务端已逐条确认未审核/冲突样本;整批网络或业务失败留到下轮, + // 禁止一次超时再拆成二十次请求,阻塞整个人脸同步。 + break; } onProgress?.call(confirmedCount, items.length, '正在上报质量结果'); } @@ -1118,25 +1300,31 @@ abstract class SyncFaceTool { .map((item) => (item as num).toInt()) .toList(); if (featureData.isNotEmpty) { - await FaceDiscernPlugs.applyFaceDelta( + final restored = await FaceDiscernPlugs.applyFaceDelta( operation: 'upsert', sampleId: face.sampleId, userId: face.userId, username: face.userName, featureData: featureData, ); + if (!restored.success) { + throw const FaceSyncSnapshotException('旧人脸特征恢复失败'); + } return; } - await FaceDiscernPlugs.rebuildFaceLibrary(userId: face.userId); + final restored = + await FaceDiscernPlugs.rebuildFaceLibrary(userId: face.userId); + if (!restored.success) throw const FaceSyncSnapshotException('旧人脸库恢复失败'); } /// 下载远端图片到专用临时目录;下载失败不会覆盖任何正式文件。 static Future _downloadTemporaryFace( ServiceFaceSyncRecord record, String storagePath, + http.Client client, ) async { final imageUrl = record.faceImg!; - final bytes = await AppNetworkTool.downloadImage(imageUrl); + final bytes = await AppNetworkTool.downloadImage(imageUrl, client: client); if (bytes == null || bytes.isEmpty) { throw const FaceSyncSnapshotException('人脸图片下载失败'); } @@ -1178,13 +1366,9 @@ abstract class SyncFaceTool { try { final directory = Directory(storagePath); if (!await directory.exists()) return 0; - final referenced = { - for (final profile in await Get.find() - .dataBase - .getFaceDao - .getSyncProfiles()) - if (profile.path != null && profile.path!.isNotEmpty) profile.path!, - }; + final referenced = + (await Get.find().dataBase.getFaceDao.getStoragePaths()) + .toSet(); final cutoff = DateTime.now().subtract(const Duration(hours: 1)); var deletedCount = 0; var freedBytes = 0; @@ -1192,6 +1376,14 @@ abstract class SyncFaceTool { if (entity is! File) continue; final name = entity.uri.pathSegments.last; final isPending = name.endsWith('.pending-delete'); + if (isPending) { + final original = entity.path + .substring(0, entity.path.length - '.pending-delete'.length); + if (referenced.contains(original) && !await File(original).exists()) { + await entity.rename(original); + continue; + } + } if (!isPending) { if (referenced.contains(entity.path)) continue; if (!_isImageFileName(name)) continue; @@ -1242,17 +1434,49 @@ abstract class SyncFaceTool { .whereType() .where((path) => path.isNotEmpty) .toSet(); + final groups = >{}; + for (final path in paths) { + groups.putIfAbsent(File(path).parent.path, () => {}).add(path); + } final existing = {}; - for (var start = 0; start < paths.length; start += _fileCheckBatchSize) { - final chunk = paths - .skip(start) - .take(_fileCheckBatchSize) - .toList(growable: false); + final fallback = []; + for (final group in groups.entries) { + if (group.value.length < 32) { + fallback.addAll(group.value); + continue; + } + try { + final pending = []; + // 目录读取以块返回;几千张照片无需进行几千次异步 stat 往返。 + await for (final entry + in Directory(group.key).list(followLinks: false)) { + if (entry is! File) continue; + if (group.value.contains(entry.path)) existing.add(entry.path); + if (entry.path.endsWith('.pending-delete')) pending.add(entry); + } + for (final file in pending) { + final original = file.path + .substring(0, file.path.length - '.pending-delete'.length); + if (group.value.contains(original) && !existing.contains(original)) { + await file.rename(original); + existing.add(original); + } + } + } catch (_) { + fallback.addAll(group.value.where((path) => !existing.contains(path))); + } + } + for (var start = 0; start < fallback.length; start += _fileCheckBatchSize) { final checked = await Future.wait( - chunk.map( - (path) async => await File(path).exists() ? path : null, - ), - ); + fallback.skip(start).take(_fileCheckBatchSize).map((path) async { + if (await File(path).exists()) return path; + final pending = File('$path.pending-delete'); + if (await pending.exists()) { + await pending.rename(path); + return path; + } + return null; + })); existing.addAll(checked.whereType()); } return existing; @@ -1263,7 +1487,13 @@ abstract class SyncFaceTool { ServiceFaceSyncRecord record, String storagePath, ) { - final extension = AppFileTool.getFileExtension(record.faceImg!); + final source = Uri.tryParse(record.faceImg!); + var extension = AppFileTool.getFileExtension( + source?.queryParameters['fileName'] ?? source?.path ?? '', + ).toLowerCase(); + if (!{'.jpg', '.jpeg', '.png', '.webp', '.bmp'}.contains(extension)) { + extension = '.jpg'; + } final sourceVersion = record.sourceUpdatedAt?.millisecondsSinceEpoch ?? 0; final writeVersion = DateTime.now().microsecondsSinceEpoch; return '$storagePath/${record.userId}_${record.sampleId}_${sourceVersion}_$writeVersion$extension'; @@ -1325,31 +1555,6 @@ abstract class SyncFaceTool { } } - /// 为兼容其他历史调用保留的文件名生成方法;新差异同步使用带版本戳的内部路径。 - static String? generateFaceFileName({ - required String userId, - required String sampleId, - required String username, - required String facePath, - }) { - if (facePath.isEmpty) return null; - return '${userId}_${sampleId}_$username${AppFileTool.getFileExtension(facePath)}'; - } - - /// 为兼容其他历史调用保留的下载方法;新差异同步使用临时文件校验后再提升为正式文件。 - static Future writeFaceByStorage({ - required String storagePath, - required String? fileName, - required String facePath, - }) async { - if (fileName == null || fileName.isEmpty) return null; - final bytes = await AppNetworkTool.downloadImage(facePath); - if (bytes == null || bytes.isEmpty) return null; - final file = File('$storagePath/$fileName'); - await file.writeAsBytes(bytes, flush: true); - return await file.exists() ? file.path : null; - } - /// 对虹软搜索标识进行脱敏,日志不输出完整样本编号。 static String _maskSampleId(String value) { if (value.length <= 4) return '***$value'; @@ -1414,78 +1619,11 @@ class _FaceSyncAccumulator { ); } -/// 图片预取器:在主流程逐条提取、注册期间按固定并发提前下载后续样本图片。 -/// -/// 下载失败的样本不缓存,由消费方按原有同步下载逻辑重试;预取文件命中后被 -/// 消费方接管改名,未消费的文件由 [dispose] 统一清理。 -class _FaceImagePrefetcher { - _FaceImagePrefetcher._(this._storagePath); - - /// 为需要重下图片的记录启动预取;集合为空时返回空避免空转。 - static _FaceImagePrefetcher? start( - List records, - String storagePath, - ) { - if (records.isEmpty) return null; - return _FaceImagePrefetcher._(storagePath).._download(records); - } - - /// 预取图片写入的同步临时目录根路径。 - final String _storagePath; - - /// sampleId 到已下载临时文件的缓存;取走即移除。 - final _cache = {}; - - /// 在途下载任务,[dispose] 等待其结束后再清理。 - Future? _running; - - /// 预取文件被主流程命中的次数,仅用于同步日志统计。 - int _hitCount = 0; - - /// 命中次数,供日志观察预取收益。 - int get hitCount => _hitCount; - - /// 以固定并发依次下载全部预取记录。 - void _download(List records) { - var next = 0; - Future worker() async { - while (true) { - final index = next++; - if (index >= records.length) return; - final record = records[index]; - try { - _cache[record.sampleId] = await SyncFaceTool._downloadTemporaryFace( - record, - _storagePath, - ); - } catch (_) { - // 预取失败不缓存,消费方按原逻辑同步下载重试。 - } - } - } - - _running = Future.wait( - List.generate( - SyncFaceTool._downloadPrefetchConcurrency, - (_) => worker(), - ), - ); - } - - /// 取走一条已预取的临时文件;未就绪或预取失败时返回空。 - File? take(String sampleId) { - final file = _cache.remove(sampleId); - if (file != null) _hitCount++; - return file; - } - - /// 等待在途下载结束并清理未消费的预取文件。 - Future dispose() async { - await _running; - final files = _cache.values.toList(growable: false); - _cache.clear(); - for (final file in files) { - await SyncFaceTool.delFaceByStorage(filePath: file.path); - } - } +/// V2 没有游标快照,保留完整清单指纹供执行删除前复核。 +class _DebugFaceSnapshot { + const _DebugFaceSnapshot( + this.records, this.conflictingSampleIds, this.fingerprint); + final List records; + final Set conflictingSampleIds; + final String fingerprint; } diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index f224c85..fbd1016 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -983,6 +983,12 @@ class SelectPayTypeDialogController extends GetxController { } } if (!_ownsFaceSetup(requestId)) return; + // 数量核对或数据库读取中断可能没有可列举的失败样本,也必须保留失败状态。 + if (result != null && !result.success && result.failedCount == 0) { + faceSetupPhase.value = _FaceSetupPhase.failed; + faceSetupMessage.value = result.message; + return; + } if (result != null && result.total == 0) { faceSetupPhase.value = _FaceSetupPhase.failed; faceSetupMessage.value = "本机暂无人脸数据,请先前往'设置'->'人脸管理'同步人脸"; diff --git a/lib/pages/load/controller.dart b/lib/pages/load/controller.dart index 0c72de4..e3ed15a 100644 --- a/lib/pages/load/controller.dart +++ b/lib/pages/load/controller.dart @@ -23,10 +23,12 @@ class LoadController extends GetxController { // 启动装载人脸库时给收银员实时反馈,避免最长的步骤只有静态“进行中”。 _registrationSubscription = FaceDiscernPlugs.registrationStream.listen( (progress) { - faceRegistrationMessage.value = - progress.inProgress && progress.message.trim().isNotEmpty - ? progress.message.trim() - : ''; + final message = progress.message.trim(); + faceRegistrationMessage.value = !progress.inProgress || message.isEmpty + ? '' + : progress.total > 0 + ? '$message · ${progress.processed}/${progress.total}' + : message; }, onError: (Object error, StackTrace stack) { logger.w('|$_tag|注册进度监听失败error:$error|stack:$stack|'); diff --git a/lib/pages/setting/face_setting_controller.dart b/lib/pages/setting/face_setting_controller.dart index 9ee6d82..b23995e 100644 --- a/lib/pages/setting/face_setting_controller.dart +++ b/lib/pages/setting/face_setting_controller.dart @@ -225,7 +225,7 @@ class FaceSettingController extends GetxController { try { final result = await FaceDiscernPlugs.rebuildFaceLibrary(); lastResult.value = result; - if (result.failedCount > 0) { + if (!result.success) { errorMessage.value = result.message; Toast.error(msg: result.message); } else { diff --git a/lib/pages/setting/widgets/face_sync_result_dialog.dart b/lib/pages/setting/widgets/face_sync_result_dialog.dart index 5569c34..e2a0f06 100644 --- a/lib/pages/setting/widgets/face_sync_result_dialog.dart +++ b/lib/pages/setting/widgets/face_sync_result_dialog.dart @@ -167,7 +167,7 @@ class _SyncResultBody extends StatelessWidget { ), SizedBox(width: 8.w), Text( - '失败明细(下次同步自动重试)', + '失败明细', style: theme.typography.base.copyWith( color: XfjDesignTokens.danger, fontWeight: FontWeight.w600, diff --git a/lib/pages/setting/widgets/setting_face_plugin.dart b/lib/pages/setting/widgets/setting_face_plugin.dart index 8dc191d..95af954 100644 --- a/lib/pages/setting/widgets/setting_face_plugin.dart +++ b/lib/pages/setting/widgets/setting_face_plugin.dart @@ -936,6 +936,11 @@ String _libraryConsistency(FacePluginStatus? status) { String _phaseTitle(String? phase) { return switch (phase) { + 'reading' => '读取本地人脸库', + 'initializing' => '初始化人脸引擎', + 'repairing' => '修复缺失人脸特征', + 'loading' => '读取并装载人脸库', + 'verifying' => '核对内存人脸库', 'preparing' => '准备人脸特征', 'registering' => '装载内存人脸库', 'completed' => '注册完成', diff --git a/pubspec.lock b/pubspec.lock index 7e408a2..8cdf27f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1215,7 +1215,7 @@ packages: source: hosted version: "2.5.4+6" sqflite_common_ffi: - dependency: transitive + dependency: "direct dev" description: name: sqflite_common_ffi sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36" diff --git a/pubspec.yaml b/pubspec.yaml index 123883a..bbe3544 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,6 +86,7 @@ dependencies: # 版本管理更新内容 HTML 渲染 flutter_html: 3.0.0 dev_dependencies: + sqflite_common_ffi: 2.3.4+4 flutter_test: sdk: flutter flutter_lints: ^5.0.0 diff --git a/test/face_sync_failure_store_test.dart b/test/face_sync_failure_store_test.dart new file mode 100644 index 0000000..1af2db3 --- /dev/null +++ b/test/face_sync_failure_store_test.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/common/models/api/user_api_models.dart'; +import 'package:pj_supermarket_cloud/common/utils/sync/face_sync_failure_store.dart'; + +void main() { + test('失败记录跨进程持久化、版本变化失效、15 分钟到期,并且不保存图片地址', () async { + final directory = + await Directory.systemTemp.createTemp('pj-face-failure-test-'); + addTearDown(() => directory.delete(recursive: true)); + var now = DateTime(2026, 10, 22); + ServiceFaceSyncRecord face({DateTime? version}) => ServiceFaceSyncRecord( + id: '1', + sampleId: '1', + userId: '2', + deptId: '3', + faceImg: 'https://private-photo.example/test.jpg', + sourceUpdatedAt: version ?? DateTime(2026), + ); + final first = + await FaceSyncFailureStore.open(directory.path, now: () => now); + first.remember(face()); + await first.flush(); + final reloaded = + await FaceSyncFailureStore.open(directory.path, now: () => now); + expect(reloaded.shouldDefer(face()), isTrue); + expect(reloaded.shouldDefer(face(version: DateTime(2027))), isFalse); + final content = + await File('${directory.path}/.face-sync-failures.json').readAsString(); + expect(content.contains('private-photo'), isFalse); + now = now.add(const Duration(minutes: 15)); + expect(reloaded.shouldDefer(face()), isFalse); + }); + + test('损坏缓存不阻断同步,成功后可移除失败记录', () async { + final directory = + await Directory.systemTemp.createTemp('pj-face-failure-test-'); + addTearDown(() => directory.delete(recursive: true)); + await File('${directory.path}/.face-sync-failures.json') + .writeAsString('{broken'); + final store = await FaceSyncFailureStore.open(directory.path); + const face = ServiceFaceSyncRecord( + id: '1', sampleId: '1', userId: '2', deptId: '3', faceImg: 'photo'); + expect(store.shouldDefer(face), isFalse); + store.remember(face); + store.forget(face); + await store.flush(); + expect((await FaceSyncFailureStore.open(directory.path)).shouldDefer(face), + isFalse); + }); +} diff --git a/test/face_sync_pipeline_test.dart b/test/face_sync_pipeline_test.dart new file mode 100644 index 0000000..aa963fc --- /dev/null +++ b/test/face_sync_pipeline_test.dart @@ -0,0 +1,480 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart' as dio; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:sqflite/sqflite.dart' as sqlite; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _TestApp extends AppControl { + @override + void onClose() {} +} + +class _Adapter implements dio.HttpClientAdapter { + _Adapter(this.respond); + final Object Function(dio.RequestOptions) respond; + @override + Future fetch(dio.RequestOptions options, + Stream? requestStream, Future? cancelFuture) async => + dio.ResponseBody.fromString( + jsonEncode({'code': 0, 'data': respond(options)}), 200, + headers: { + 'content-type': ['application/json'] + }); + @override + void close({bool force = false}) {} +} + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + late Directory temp; + late HttpServer server; + late AppDatabase database; + late String facePath; + late String imageUrl; + var imageRequests = 0; + var extractionCalls = 0; + var upserts = 0; + var metadataCalls = 0; + var qualityReports = 0; + var v2Requests = 0; + var failUpsertOnce = false; + var failMetadataOnce = false; + var invalidSnapshot = false; + var changingAllSnapshot = false; + var rejectQualityReports = false; + var rejectFacePhoto = false; + final memory = >{}; + final remote = >[]; + final allRemote = >[]; + var photoBytes = [10, 20, 30]; + final version = DateTime.parse('2026-10-22T01:02:03.123456'); + + Map record(String sample, + {String? user, DateTime? updated}) => + { + 'id': sample, + 'sampleId': sample, + 'userId': user ?? '20', + 'username': 'Test', + 'deptId': '10', + 'deptName': null, + 'faceImg': imageUrl, + 'sourceUpdatedAt': (updated ?? version).toIso8601String(), + }; + + Future seed(String sample, + {String dept = '10', bool quality = false}) async { + final file = File('$facePath/$sample.jpg'); + await file.writeAsBytes([1, 2, 3]); + final face = FaceEntity( + sampleId: sample, + userId: '20', + userName: 'Test', + deptId: dept, + imagePath: imageUrl, + path: file.path, + faceModel: [1, 2, 3], + updateTime: version, + qualityStatus: quality ? 'detected' : 'pending', + qualityCheckedAt: quality ? version : null, + qualityScore: quality ? 0.8 : null); + face.id = await database.getFaceDao.save(face); + memory[sample] = { + 'userId': '20', + 'username': 'Test', + 'feature': [1, 2, 3] + }; + return face; + } + + Future sync({bool all = false}) => SyncFaceTool.sync( + mode: all + ? FaceSyncMode.debugAllSnapshot + : FaceSyncMode.departmentSnapshot); + + setUpAll(() async { + HttpOverrides.global = null; + temp = await Directory.systemTemp.createTemp('pj-face-sync-test-'); + for (final channel in [ + 'plugins.flutter.io/path_provider', + 'external_path' + ]) { + binding.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(channel), (_) async => temp.path); + } + sqfliteFfiInit(); + sqlite.databaseFactory = databaseFactoryFfiNoIsolate; + database = await $FloorAppDatabase.inMemoryDatabaseBuilder().build(); + final app = _TestApp()..dataBase = database; + app.isNetwork.value = true; + Get.put(app); + await database.getSettingDao.save(SettingEntity( + sysJson: jsonEncode( + (SysSetting(sysType: SysType.CANTEEN, serviceId: '900') + ..merchantDeptId = '10') + .toJson()), + userJson: jsonEncode(UserSetting(username: '', password: '').toJson()), + )); + facePath = (await AppFileTool.getFacePath())!; + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + imageUrl = 'http://127.0.0.1:${server.port}/photo.jpg'; + server.listen((request) async { + imageRequests++; + request.response.add(photoBytes); + await request.response.close(); + }); + HttpManage().dio.httpClientAdapter = _Adapter((options) { + if (options.path.contains('getDescendantList')) { + return [ + {'deptId': '10'}, + {'deptId': '11'} + ]; + } + if (options.path.endsWith('sync-snapshot')) { + return { + 'records': remote, + 'nextAfterId': remote.isEmpty ? '0' : remote.last['id'], + 'hasMore': invalidSnapshot, + 'snapshotMaxId': '999' + }; + } + if (options.path.contains('v2/find-face')) { + v2Requests++; + return changingAllSnapshot && v2Requests > 1 ? [] : allRemote; + } + if (options.path.contains('lease')) { + return {'granted': true, 'remainingSeconds': 0}; + } + if (options.path.contains('quality')) { + qualityReports++; + if (rejectQualityReports) throw StateError('Injected network failure'); + return { + 'updatedSampleIds': [], + 'ignoredStaleSampleIds': [], + 'alreadyAbsentSampleIds': [], + 'ignoredNotApprovedSampleIds': ['1'] + }; + } + throw StateError('Unexpected API in test: ${options.path}'); + }); + binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('local/face_discern'), + (call) async { + final args = Map.from(call.arguments as Map); + switch (call.method) { + case 'extractFaceFeature': + extractionCalls++; + if (rejectFacePhoto) { + return { + 'featureData': [], + 'qualityStatus': 'failed', + 'cacheableFailure': true + }; + } + return { + 'featureData': [4, 5, 6], + 'qualityStatus': 'pending' + }; + case 'applyFaceDelta': + final id = args['sampleId'].toString(); + if (args['operation'] == 'delete') { + memory.remove(id); + } else { + upserts++; + memory[id] = { + 'userId': args['userId'], + 'username': args['username'], + 'feature': List.from(args['featureData'] as List) + }; + if (failUpsertOnce) { + failUpsertOnce = false; + return { + 'success': false, + 'code': -99, + 'message': 'Injected failure' + }; + } + } + return {'success': true, 'code': 0}; + case 'updateFaceMetadata': + metadataCalls++; + if (failMetadataOnce) { + failMetadataOnce = false; + return false; + } + for (final item in args['items'] as List) { + memory[item['sampleId'].toString()]! + ..['userId'] = item['userId'] + ..['username'] = item['username']; + } + return true; + case 'removeFaces': + for (final id in args['sampleIds'] as List) { + memory.remove(id.toString()); + } + return {'success': true, 'failedSampleIds': []}; + } + throw StateError('Unexpected native method: ${call.method}'); + }, + ); + }); + + setUp(() async { + await database.database + .execute('DROP TRIGGER IF EXISTS reject_face_delete'); + await database.database + .execute('DROP TRIGGER IF EXISTS reject_face_metadata'); + await database.getFaceDao.delAll(); + memory.clear(); + remote.clear(); + allRemote.clear(); + imageRequests = extractionCalls = + upserts = metadataCalls = qualityReports = v2Requests = 0; + failUpsertOnce = + failMetadataOnce = invalidSnapshot = changingAllSnapshot = false; + rejectQualityReports = false; + rejectFacePhoto = false; + photoBytes = [10, 20, 30]; + for (final file in await Directory(facePath).list().toList()) { + await file.delete(recursive: true); + } + }); + + tearDownAll(() async { + await AppSettingTool.removeSettingCache(); + await server.close(force: true); + await database.close(); + Get.delete(force: true); + await temp.delete(recursive: true); + }); + + test('连续两次同步不下载、不提取、不写 SDK,特征和精确时间保持不变', () async { + await seed('1'); + remote.add(record('1')); + expect((await sync()).skipped, 1); + expect((await sync()).skipped, 1); + expect( + [imageRequests, extractionCalls, upserts, metadataCalls], [0, 0, 0, 0]); + final row = await database.getFaceDao.findBySampleId('1'); + expect(row!.faceModel, [1, 2, 3]); + expect(row.updateTime, version); + }); + + test('照片变化只下载提取一次;下一次同步直接跳过', () async { + final old = await seed('1'); + remote.add(record('1', updated: version.add(const Duration(seconds: 1)))); + expect((await sync()).updated, 1); + expect((await sync()).skipped, 1); + expect([imageRequests, extractionCalls, upserts], [1, 1, 1]); + expect(await File(old.path!).exists(), isFalse); + expect( + (await database.getFaceDao.findBySampleId('1'))!.faceModel, [4, 5, 6]); + }); + + test('相同照片字节只更新版本,复用已有特征、质量和正式文件', () async { + final old = await seed('1'); + photoBytes = [1, 2, 3]; + remote.add( + record('1', updated: version.add(const Duration(microseconds: 1)))); + expect((await sync()).updated, 1); + expect((await sync()).skipped, 1); + expect([imageRequests, extractionCalls, upserts], [1, 0, 0]); + expect((await database.getFaceDao.findBySampleId('1'))!.path, old.path); + }); + + test('人员绑定变化同时更新数据库和内存,不重新提取特征', () async { + await seed('1'); + remote.add(record('1', user: '21')); + expect((await sync()).updated, 1); + expect((await sync()).skipped, 1); + expect((await database.getFaceDao.findBySampleId('1'))!.userId, '21'); + expect(memory['1']!['userId'], '21'); + expect(memory['1']!['feature'], [1, 2, 3]); + expect( + [imageRequests, extractionCalls, upserts, metadataCalls], [0, 0, 0, 1]); + }); + + test('内存档案提交失败时还原数据库,重试能够成功', () async { + await seed('1'); + remote.add(record('1', user: '21')); + failMetadataOnce = true; + expect((await sync()).failed, 1); + expect((await database.getFaceDao.findBySampleId('1'))!.userId, '20'); + expect(memory['1']!['userId'], '20'); + expect((await sync()).updated, 1); + }); + + test('部门变更能命中调试导入的范围外旧行,不重复新增', () async { + final old = await seed('1', dept: '99'); + remote.add(record('1')); + final result = await sync(); + expect(result.updated, 1); + expect(result.added, 0); + final row = (await database.getFaceDao.findBySampleId('1'))!; + expect(row.id, old.id); + expect(row.deptId, '10'); + expect(imageRequests, 0); + }); + + test('批量档案更新中第二条 SQL 失败,第一条也会回滚', () async { + await seed('1'); + await seed('2'); + remote.addAll([record('1', user: '21'), record('2', user: '21')]); + await database.database.execute( + "CREATE TRIGGER reject_face_metadata BEFORE UPDATE ON face WHEN NEW.sampleId = '2' AND NEW.userId = '21' BEGIN SELECT RAISE(ABORT, 'injected'); END"); + expect((await sync()).failed, 2); + expect((await database.getFaceDao.findBySampleId('1'))!.userId, '20'); + expect((await database.getFaceDao.findBySampleId('2'))!.userId, '20'); + expect(metadataCalls, 0); + }); + + test('大目录分块核对能恢复中断图片,所有未变化样本都跳过', () async { + for (var i = 1; i <= 40; i++) { + final row = await seed('$i'); + remote.add(record('$i')); + if (i == 1) { + await File(row.path!).rename('${row.path}.pending-delete'); + } + } + expect((await sync()).skipped, 40); + expect([imageRequests, extractionCalls, upserts], [0, 0, 0]); + expect(await File('$facePath/1.jpg').exists(), isTrue); + }); + + test('同一版本坏图不重复下载提取,版本更新后立即重试', () async { + await seed('1'); + remote.add(record('1', updated: DateTime(2027))); + rejectFacePhoto = true; + expect((await sync()).failed, 1); + expect((await sync()).failed, 1); + expect([imageRequests, extractionCalls, upserts], [1, 1, 0]); + expect( + (await database.getFaceDao.findBySampleId('1'))!.faceModel, [1, 2, 3]); + rejectFacePhoto = false; + remote[0] = record('1', updated: DateTime(2028)); + expect((await sync()).updated, 1); + expect([imageRequests, extractionCalls, upserts], [2, 2, 1]); + }); + + test('特征提交失败时恢复旧照片、特征和数据库', () async { + final old = await seed('1'); + remote.add(record('1', updated: DateTime(2027))); + failUpsertOnce = true; + expect((await sync()).failed, 1); + final row = (await database.getFaceDao.findBySampleId('1'))!; + expect(row.path, old.path); + expect(row.faceModel, [1, 2, 3]); + expect(await File(old.path!).readAsBytes(), [1, 2, 3]); + expect(memory['1']!['feature'], [1, 2, 3]); + expect((await sync()).updated, 1); + }); + + test('空部门清单删除最后一张照片,外部门照片保留', () async { + final old = await seed('1', dept: '11'); + await seed('2', dept: '99'); + expect((await sync()).deleted, 1); + expect(await database.getFaceDao.findBySampleId('1'), isNull); + expect(await database.getFaceDao.findBySampleId('2'), isNotNull); + expect(await File(old.path!).exists(), isFalse); + expect(memory.containsKey('1'), isFalse); + }); + + test('SQLite 删除失败会恢复原生特征和文件,并准确计入失败数', () async { + final old = await seed('1'); + await database.database.execute( + "CREATE TRIGGER reject_face_delete BEFORE DELETE ON face BEGIN SELECT RAISE(ABORT, 'injected'); END"); + final result = await sync(); + expect(result.deleted, 0); + expect(result.failed, 1); + expect(await database.getFaceDao.findBySampleId('1'), isNotNull); + expect(await File(old.path!).exists(), isTrue); + expect(memory['1']!['feature'], [1, 2, 3]); + }); + + test('分页中断时不执行任何人脸更新或孤儿删除', () async { + final old = await seed('1'); + invalidSnapshot = true; + await expectLater(sync(), throwsA(isA())); + expect(await database.getFaceDao.findBySampleId('1'), isNotNull); + expect(await File(old.path!).exists(), isTrue); + expect(imageRequests, 0); + }); + + test('全量冲突编号隔离并保留本地绑定,正常编号继续处理', () async { + await seed('1'); + allRemote.addAll([ + { + 'id': '101', + 'autoCode': '1', + 'userId': '20', + 'faceImg': imageUrl, + 'deptId': '10' + }, + { + 'id': '102', + 'autoCode': '01', + 'userId': '21', + 'faceImg': imageUrl, + 'deptId': '10' + }, + ]); + final result = await sync(all: true); + expect(result.failed, 1); + expect(result.deleted, 0); + expect((await database.getFaceDao.findBySampleId('1'))!.userId, '20'); + expect([imageRequests, upserts], [0, 0]); + }); + + test('V2 删档前复核发现清单变化,整轮中止且不做本地变更', () async { + final old = await seed('1'); + allRemote.add({ + 'id': '102', + 'autoCode': '2', + 'userId': '20', + 'faceImg': imageUrl, + 'deptId': '10' + }); + changingAllSnapshot = true; + await expectLater( + sync(all: true), throwsA(isA())); + expect(await File(old.path!).exists(), isTrue); + expect(await database.getFaceDao.findBySampleId('1'), isNotNull); + expect([imageRequests, upserts], [0, 0]); + }); + + test('上次中断遗留的待删图片恢复后直接跳过,无需下载', () async { + final old = await seed('1'); + await File(old.path!).rename('${old.path}.pending-delete'); + remote.add(record('1')); + expect((await sync()).skipped, 1); + expect(await File(old.path!).exists(), isTrue); + expect(imageRequests, 0); + }); + + test('质量批次请求失败留待下次,不拆成多批请求拖慢本次同步', () async { + for (var i = 1; i <= 40; i++) { + await seed('$i', quality: true); + remote.add(record('$i')); + } + rejectQualityReports = true; + expect((await sync()).qualityReportNote, isNotNull); + expect(qualityReports, 1); + expect( + (await database.getFaceDao.getUnreportedQualityProfiles()).length, 40); + }); + + test('服务端明确拒收的历史质量记录落确认标记,不在下轮重复上报', () async { + await seed('1', quality: true); + remote.add(record('1')); + expect((await sync()).qualityReportNote, isNull); + expect((await sync()).qualityReportNote, isNull); + expect(qualityReports, 1); + expect(await database.getFaceDao.getUnreportedQualityProfiles(), isEmpty); + }); +} diff --git a/test/face_sync_plan_test.dart b/test/face_sync_plan_test.dart new file mode 100644 index 0000000..858f15b --- /dev/null +++ b/test/face_sync_plan_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/common/utils/sync/face_sync_plan.dart'; +import 'package:pj_supermarket_cloud/common/utils/sync/face_image_download_queue.dart'; +import 'package:pj_supermarket_cloud/common/services/database/type_converter/face_timestamp_converter.dart'; + +ServiceFaceSyncRecord record(String id, {DateTime? version, String? name}) => + ServiceFaceSyncRecord( + id: id, + sampleId: id, + userId: '20', + deptId: '10', + faceImg: 'photo', + sourceUpdatedAt: version, + username: name); + +FaceEntity local(String id, {DateTime? version, String? deptName}) => + FaceEntity( + sampleId: id, + userId: '20', + userName: '', + deptId: '10', + deptName: deptName, + imagePath: 'photo', + path: '/$id.jpg', + faceModel: [1], + updateTime: version); + +void main() { + test('重复同步与空部门名直接跳过,缺失版本只需校验一次照片', () { + final remote = [record('1'), record('2', version: DateTime(2026))]; + final first = FaceSyncPlan.build( + remote: remote, + local: [local('1'), local('2')], + existingPaths: {'/1.jpg', '/2.jpg'}); + expect(first.changes.map((x) => x.kind), + [FaceSyncChangeKind.unchanged, FaceSyncChangeKind.image]); + final second = FaceSyncPlan.build( + remote: remote, + local: [local('1'), local('2', version: DateTime(2026))], + existingPaths: {'/1.jpg', '/2.jpg'}); + expect(second.changes.every((x) => x.kind == FaceSyncChangeKind.unchanged), + isTrue); + }); + + test('新增、照片版本变化和文件缺失只处理对应样本', () { + final plan = FaceSyncPlan.build( + remote: [record('1'), record('2', version: DateTime(2026)), record('3')], + local: [local('1'), local('2', version: DateTime(2025))], + existingPaths: {'/2.jpg'}, + ); + expect( + plan.changes.every((x) => x.kind == FaceSyncChangeKind.image), isTrue); + }); + + test('空部门权威清单可删除最后一张照片,并保留调试导入的外部门样本', () { + final foreign = local('2')..deptId = '99'; + final plan = FaceSyncPlan.build( + remote: [], + local: [local('1'), foreign], + existingPaths: {}, + scopeDeptIds: {'10', '11'}); + expect(plan.orphans.map((x) => x.sampleId), ['1']); + }); + + test('冲突编号被保护,本地和远端重复清单会拒绝执行', () { + final plan = FaceSyncPlan.build( + remote: [], + local: [local('1'), local('2')], + existingPaths: {}, + protectedSampleIds: {'1'}); + expect(plan.orphans.map((x) => x.sampleId), ['2']); + expect( + () => FaceSyncPlan.build( + remote: [record('1'), record('1')], local: [], existingPaths: {}), + throwsA(isA())); + expect( + () => FaceSyncPlan.build( + remote: [], local: [local('1'), local('1')], existingPaths: {}), + throwsA(isA())); + }); + + test('时间版本保留微秒和时区,旧秒级格式仍可读取', () { + final converter = FaceTimestampConverter(); + final version = DateTime.parse('2026-10-22T08:00:01.123456Z'); + expect(converter.decode(converter.encode(version)), version); + expect(converter.decode('2026-10-22 08:00:01'), + DateTime(2026, 10, 22, 8, 0, 1)); + final plan = FaceSyncPlan.build( + remote: [record('1', version: version)], + local: [local('1', version: version.toLocal())], + existingPaths: {'/1.jpg'}); + expect(plan.changes.single.kind, FaceSyncChangeKind.unchanged); + }); + + test('消费未完成的预下载会等待同一任务,预取窗口始终有界', () async { + final tasks = >{}; + final calls = []; + final discarded = []; + final queue = FaceImageDownloadQueue( + records: List.generate(30, (i) => record('${i + 1}')), + download: (face) { + calls.add(face.sampleId); + return (tasks[face.sampleId] = Completer()).future; + }, + discard: (file) async => discarded.add(file.path), + ); + expect(calls, ['1', '2', '3']); + final take = queue.take('1'); + expect(calls.length, 3); + tasks['1']!.complete(File('/1')); + expect((await take)!.path, '/1'); + expect(calls, ['1', '2', '3', '4']); + final closing = queue.dispose(); + for (final id in ['2', '3', '4']) { + tasks[id]!.complete(File('/$id')); + } + await closing; + expect(calls.length, 4); + expect(discarded.toSet(), {'/2', '/3', '/4'}); + expect(queue.consumedCount, 1); + }); + + test('下载异常被消费方接管,不产生未处理异步异常', () async { + final queue = FaceImageDownloadQueue( + records: [record('1')], + download: (_) async => throw const SocketException('offline'), + discard: (_) async {}); + expect(await queue.take('1'), isNull); + await queue.dispose(); + expect(queue.downloadCount, 1); + }); +}