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.
This commit is contained in:
1 parent
6f0e3ea37e
commit
7c20242ad7
42 files changed
+2889
-979
No files matched your search
@@ -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
|
||||
|
||||
+70
@@ -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<FaceLoadRow> all = new ArrayList<>();
|
||||
try (FaceLoadReader reader = new FaceLoadReader(db, null, null)) {
|
||||
check(reader.total == 5, "snapshot total"); assertions++;
|
||||
for (;;) {
|
||||
List<FaceLoadRow> 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<FaceLoadRow> 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);
|
||||
}
|
||||
}
|
||||
+123
@@ -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<Probe> prepare(Context context, List<FaceEntity> reference) {
|
||||
List<Probe> 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<FaceInfo> 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<Probe> 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<Probe> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -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<FaceEntity> 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<FaceSearchChecks.Probe> 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<String, Object> 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<Integer> 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<FaceEntity> 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<Integer> 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<Integer> ids = new HashSet<>();
|
||||
int calls;
|
||||
boolean rejectCleanup;
|
||||
@Override public int registerFaceFeatureInfoBatchFromDb(List<FaceEntity> 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<FaceEntity> legacyRead(Context context) {
|
||||
List<FaceEntity> 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<String, Object> legacyLoad(Context context) {
|
||||
long started = SystemClock.elapsedRealtime();
|
||||
List<FaceEntity> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+125
-116
@@ -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<FaceEntity> getUserFace(Context context, String queryUserId) {
|
||||
List<FaceEntity> 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<FaceLoadRow> readPage(int limit) {
|
||||
if (limit <= 0) throw new IllegalArgumentException("Invalid face page size");
|
||||
long started = SystemClock.elapsedRealtimeNanos();
|
||||
long decodeBefore = decodeNanos;
|
||||
List<FaceLoadRow> 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<FaceLoadRow> 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);
|
||||
}
|
||||
|
||||
}
|
||||
+103
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+252
@@ -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<Integer> seenIds = new HashSet<>();
|
||||
private final Set<Integer> 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<String, Object> 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<FaceEntity> batch = new ArrayList<>(batchSize);
|
||||
for (;;) {
|
||||
List<FaceLoadRow> page = reader.readPage(READ_PAGE_SIZE);
|
||||
readCount = reader.readCount;
|
||||
if (page.isEmpty()) break;
|
||||
List<FaceLoadRow> 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<String, Object> 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<String, Object> 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<FaceLoadRow> rows) {
|
||||
List<FaceLoadRow> 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<FaceEntity> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
+58
-32
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<FaceEntity> 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在统一人脸操作互斥锁内批量移除内存库样本;一次通道调用摊平逐条往返与
|
||||
* 互斥收发开销,供同步孤儿删除和调试清理入口使用。
|
||||
|
||||
+13
-198
@@ -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<String, Object> latestRegistrationProgress = idleRegistrationProgress();
|
||||
private static volatile Map<String, Object> latestRegistrationResult;
|
||||
@@ -69,11 +64,6 @@ public class FlutterTool {
|
||||
}
|
||||
|
||||
// 在线激活
|
||||
static public Boolean active(Context context, String appId, String activeKey, String sdkKey) {
|
||||
Map<String, Object> result = activateFaceSdk(context, appId, activeKey, sdkKey);
|
||||
return Boolean.TRUE.equals(result.get("success"));
|
||||
}
|
||||
|
||||
static public Map<String, Object> activateFaceSdk(Context context, String appId, String activeKey, String sdkKey) {
|
||||
updateConfig(appId, activeKey, sdkKey);
|
||||
Map<String, Object> 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<String, Object> getConfig(Context context) {
|
||||
HashMap<String, Object> 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<String, Object> getFaceStatus(Context context, String appId, String activeKey, String sdkKey) {
|
||||
updateConfig(appId, activeKey, sdkKey);
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> getFace(Context context, int sampleId) {
|
||||
FaceServer faceServer = FaceServer.getInstance();
|
||||
faceServer.init(context);
|
||||
FaceFeatureInfo faceFeature = faceServer.getFaceFeature(sampleId);
|
||||
if (Objects.isNull(faceFeature)) return null;
|
||||
Map<String, Object> 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<Integer> registerFaceByDatabase(Context context, String userId) {
|
||||
Map<String, Object> result = rebuildFaceLibrary(context, userId, null);
|
||||
Object failed = result.get("failedSampleIds");
|
||||
if (failed instanceof List) {
|
||||
//noinspection unchecked
|
||||
return (List<Integer>) failed;
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
static public Map<String, Object> rebuildFaceLibrary(Context context, String userId, RegistrationProgressListener listener) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
List<FaceEntity> faceList = getUserFace(context, userId);
|
||||
Set<Integer> failedSampleIds = new LinkedHashSet<>();
|
||||
List<FaceEntity> 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<FaceEntity> 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<String, Object> 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<String, Object> idleRegistrationProgress() {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> finishRegistrationResult(RegistrationProgressListener listener, int total, int success, Set<Integer> failedSampleIds, String message, long startTime) {
|
||||
List<Integer> failed = new ArrayList<>(failedSampleIds);
|
||||
Map<String, Object> 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 "无法连接虹软激活服务,请检查网络后重试";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+38
-23
@@ -136,22 +136,12 @@ public class FaceServer {
|
||||
faceServer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量重建使用:先清空旧内存库,再按调用方传入的数据重新装载。
|
||||
*/
|
||||
public synchronized int registerFaceFeatureInfoListFromDb(List<FaceEntity> faceEntityList) {
|
||||
if (faceEngine == null) return -99;
|
||||
faceEngine.removeFaceFeature(-1);
|
||||
faceRegisterInfoMap.clear();
|
||||
return registerFaceFeatureInfoBatchFromDb(faceEntityList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只追加当前批次,不清空其他人脸;用于分批全量装载和单用户增量替换。
|
||||
*/
|
||||
public synchronized int registerFaceFeatureInfoBatchFromDb(List<FaceEntity> faceEntityList) {
|
||||
if (faceEngine == null || faceEntityList == null || faceEntityList.isEmpty()) return -99;
|
||||
List<FaceFeatureInfo> faceFeatureInfoList = new ArrayList<>();
|
||||
List<FaceFeatureInfo> 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<FaceEntity> updates) {
|
||||
Set<Integer> 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) {
|
||||
|
||||
+46
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` 保留应用数据。
|
||||
@@ -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)。同步耗时包含网络,单次数字不能作为受控速度比。
|
||||
@@ -47,20 +47,6 @@ abstract class UserApi {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/// 获取人脸
|
||||
static Future<List<ServiceAsyncFace>?> 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<ServiceAsyncFace>((e) => ServiceAsyncFace.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取人脸v2
|
||||
static Future<List<ServiceAsyncFace>?> 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<Set<String>> 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 = <String>{};
|
||||
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 解析真实绑定部门;业务错误必须抛给调用方,确保终端
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,30 +73,6 @@ class FaceStream {
|
||||
Map<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$FaceConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$FaceConfigToJson(this);
|
||||
}
|
||||
|
||||
/// 终端本机的人脸识别策略,对应 Android `RecognizeConfiguration` 的可调白名单。
|
||||
class FaceRecognitionConfiguration {
|
||||
const FaceRecognitionConfiguration({
|
||||
@@ -295,26 +271,6 @@ class FaceRecognitionConfiguration {
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class FaceRes {
|
||||
int searchId;
|
||||
|
||||
List<int> featureData;
|
||||
|
||||
String faceTag;
|
||||
|
||||
FaceRes({
|
||||
required this.searchId,
|
||||
required this.featureData,
|
||||
required this.faceTag,
|
||||
});
|
||||
|
||||
factory FaceRes.fromJson(Map<String, dynamic> json) =>
|
||||
_$FaceResFromJson(json);
|
||||
|
||||
Map<String, dynamic> 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<int> failedSampleIds;
|
||||
final int durationMs;
|
||||
|
||||
/// 原生分段耗时与汇总计数,不含样本标识或生物特征。
|
||||
final Map<String, int> metrics;
|
||||
|
||||
factory FaceRegistrationResult.fromMap(Map<dynamic, dynamic> 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<String, int>.unmodifiable({
|
||||
for (final entry in (map['metrics'] as Map).entries)
|
||||
entry.key.toString(): _asInt(entry.value),
|
||||
})
|
||||
: const {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,32 +42,3 @@ Map<String, dynamic> _$FaceStreamToJson(FaceStream instance) =>
|
||||
'similarityThreshold': instance.similarityThreshold,
|
||||
'evidenceError': instance.evidenceError,
|
||||
};
|
||||
|
||||
FaceConfig _$FaceConfigFromJson(Map<String, dynamic> json) => FaceConfig(
|
||||
appId: json['appId'] as String?,
|
||||
sdkKey: json['sdkKey'] as String?,
|
||||
activeKey: json['activeKey'] as String?,
|
||||
isActive: json['isActive'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$FaceConfigToJson(FaceConfig instance) =>
|
||||
<String, dynamic>{
|
||||
'appId': instance.appId,
|
||||
'sdkKey': instance.sdkKey,
|
||||
'activeKey': instance.activeKey,
|
||||
'isActive': instance.isActive,
|
||||
};
|
||||
|
||||
FaceRes _$FaceResFromJson(Map<String, dynamic> json) => FaceRes(
|
||||
searchId: (json['searchId'] as num).toInt(),
|
||||
featureData: (json['featureData'] as List<dynamic>)
|
||||
.map((e) => (e as num).toInt())
|
||||
.toList(),
|
||||
faceTag: json['faceTag'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$FaceResToJson(FaceRes instance) => <String, dynamic>{
|
||||
'searchId': instance.searchId,
|
||||
'featureData': instance.featureData,
|
||||
'faceTag': instance.faceTag,
|
||||
};
|
||||
@@ -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<List<FaceEntity>> 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<List<FaceEntity>> getSyncProfilesInDepartments(List<String> 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<List<FaceEntity>> getSyncProfilesBySampleIds(List<String> 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<void> editMetadataBySampleId(
|
||||
@@ -182,8 +196,40 @@ abstract class FaceDao {
|
||||
String deptName,
|
||||
String imagePath,
|
||||
String updateTime,
|
||||
String qualityReportedAt,
|
||||
);
|
||||
|
||||
@transaction
|
||||
Future<void> editSyncMetadataBatch(List<FaceEntity> 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<List<FaceEntity>> getUnreportedQualityProfiles();
|
||||
|
||||
@Query("SELECT path FROM face WHERE path IS NOT NULL AND path != ''")
|
||||
Future<List<String>> getStoragePaths();
|
||||
|
||||
/// 批量标记质量结果已被服务端确认,未确认样本保持旧标记供下次补报。
|
||||
@Query('''
|
||||
UPDATE face SET qualityReportedAt = :reportedAt
|
||||
@@ -191,16 +237,15 @@ abstract class FaceDao {
|
||||
''')
|
||||
Future<void> markQualityReported(
|
||||
List<String> sampleIds,
|
||||
DateTime reportedAt,
|
||||
String reportedAt,
|
||||
);
|
||||
|
||||
@Query('''
|
||||
SELECT DISTINCT a.deptId FROM face a WHERE a.deptId IS NOT NULL
|
||||
''')
|
||||
Future<List<String>> 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<List<FaceEntity>?> getEmptyFaceList();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String, Object?> 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<String, Object?> 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<List<FaceEntity>> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<List<FaceEntity>> 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<String, Object?> 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<List<FaceEntity>> getSyncProfilesInDepartments(
|
||||
List<String> deptIds) async {
|
||||
const offset = 1;
|
||||
final _sqliteVariablesForDeptIds =
|
||||
Iterable<String>.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<String, Object?> 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<List<FaceEntity>> getSyncProfilesBySampleIds(
|
||||
List<String> sampleIds) async {
|
||||
const offset = 1;
|
||||
final _sqliteVariablesForSampleIds =
|
||||
Iterable<String>.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<String, Object?> 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<void> 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<void> markQualityReported(
|
||||
List<String> sampleIds,
|
||||
DateTime reportedAt,
|
||||
) async {
|
||||
const offset = 2;
|
||||
final _sqliteVariablesForSampleIds =
|
||||
Iterable<String>.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<List<String>> findAllUserIds() async {
|
||||
Future<List<FaceEntity>> getUnreportedQualityProfiles() async {
|
||||
return _queryAdapter.queryList(
|
||||
'SELECT DISTINCT a.deptId FROM face a WHERE a.deptId IS NOT NULL',
|
||||
mapper: (Map<String, Object?> row) => row.values.first as String);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FaceEntity>?> 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<String, Object?> 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<List<String>> getStoragePaths() async {
|
||||
return _queryAdapter.queryList(
|
||||
'SELECT path FROM face WHERE path IS NOT NULL AND path != \'\'',
|
||||
mapper: (Map<String, Object?> row) => row.values.first as String);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markQualityReported(
|
||||
List<String> sampleIds,
|
||||
String reportedAt,
|
||||
) async {
|
||||
const offset = 2;
|
||||
final _sqliteVariablesForSampleIds =
|
||||
Iterable<String>.generate(sampleIds.length, (i) => '?${i + offset}')
|
||||
.join(',');
|
||||
await _queryAdapter.queryNoReturn(
|
||||
'UPDATE face SET qualityReportedAt = ?1 WHERE sampleId IN (' +
|
||||
_sqliteVariablesForSampleIds +
|
||||
')',
|
||||
arguments: [reportedAt, ...sampleIds]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FaceEntity>?> 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<String, Object?> 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<void> batchDel(List<FaceEntity> entitys) async {
|
||||
await _faceEntityDeletionAdapter.deleteList(entitys);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> editSyncMetadataBatch(List<FaceEntity> faces) async {
|
||||
if (database is sqflite.Transaction) {
|
||||
await super.editSyncMetadataBatch(faces);
|
||||
} else {
|
||||
await (database as sqflite.Database)
|
||||
.transaction<void>((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();
|
||||
@@ -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<dynamic>? 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({
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:floor/floor.dart';
|
||||
|
||||
/// 人脸版本和质量确认时间必须无损保存;旧版秒级时间仍可直接读取。
|
||||
class FaceTimestampConverter extends TypeConverter<DateTime?, String?> {
|
||||
@override
|
||||
DateTime? decode(String? databaseValue) =>
|
||||
databaseValue == null || databaseValue.isEmpty
|
||||
? null
|
||||
: DateTime.parse(databaseValue);
|
||||
|
||||
@override
|
||||
String? encode(DateTime? value) => value?.toIso8601String();
|
||||
}
|
||||
@@ -43,6 +43,7 @@ abstract class AppFaceTool {
|
||||
///
|
||||
/// 任意插件、权限或样本异常都转换为警告结果,调用方可以继续完成终端启动。
|
||||
static Future<FaceStartupPreparationResult> 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<bool> 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();
|
||||
}
|
||||
|
||||
/// 获取激活、引擎和人脸库一致性状态。
|
||||
|
||||
@@ -142,27 +142,26 @@ abstract class AppNetworkTool {
|
||||
}
|
||||
|
||||
// 从网络中下载图片
|
||||
static Future<Uint8List?> downloadImage(String url) async {
|
||||
static Future<Uint8List?> 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<int> 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<void>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ class AppControl extends GetxController {
|
||||
_setStartupStep(
|
||||
3,
|
||||
status: AppStartupStepStatus.running,
|
||||
detail: '正在激活人脸插件并装载本地人脸库,样本较多时可能需要数十秒',
|
||||
detail: '正在准备人脸识别并装载本地人脸库',
|
||||
);
|
||||
final facePreparation = await AppFaceTool.prepareForStartup();
|
||||
_setStartupStep(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,16 @@ abstract class FaceDiscernPlugs {
|
||||
|
||||
static MethodChannel get getChannel => _channel;
|
||||
|
||||
/// 只读本机激活文件;按钮前置检查无需再扫描整个人脸数据库。
|
||||
static Future<bool> isSdkActivated() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('isFaceSdkActivated') == true;
|
||||
} catch (error) {
|
||||
logger.w('|$_tag|激活状态查询失败|error:${error.runtimeType}|');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Stream<FaceStream?>? _stream;
|
||||
static Stream<FaceRecognitionSessionEvent>? _sessionStream;
|
||||
static Stream<FaceRegistrationProgress>? _registrationStream;
|
||||
@@ -188,38 +198,6 @@ abstract class FaceDiscernPlugs {
|
||||
return FaceRecognitionConfiguration.fromMap(map);
|
||||
}
|
||||
|
||||
static Future<FaceConfig?> getConfig() async {
|
||||
try {
|
||||
final Map<Object?, Object?>? 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<bool> 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<FacePluginStatus?> getFaceStatus({
|
||||
required String appId,
|
||||
@@ -377,18 +355,6 @@ abstract class FaceDiscernPlugs {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<FaceRes?> getFace({
|
||||
required int sampleId,
|
||||
}) async {
|
||||
final pa = {
|
||||
"sampleId": sampleId,
|
||||
};
|
||||
final map = await _channel.invokeMethod<dynamic>('getFace', pa);
|
||||
if (map == null) return null;
|
||||
final encode = json.encode(map);
|
||||
return FaceRes.fromJson(json.decode(encode));
|
||||
}
|
||||
|
||||
static Future<bool> removeFace({
|
||||
required String sampleId,
|
||||
}) async {
|
||||
@@ -401,15 +367,19 @@ abstract class FaceDiscernPlugs {
|
||||
return res;
|
||||
}
|
||||
|
||||
static Future<List<int>> registerFaceByDatabase({
|
||||
String? userId,
|
||||
}) async {
|
||||
final pa = {
|
||||
"userId": userId,
|
||||
};
|
||||
final res = await _channel.invokeMethod('registerFaceByDatabase', pa);
|
||||
final errorSampleIdList = (res as List).cast<int>();
|
||||
return errorSampleIdList;
|
||||
/// 姓名或人员绑定变化时只刷新内存档案,保留已注册的特征。
|
||||
static Future<bool> updateFaceMetadata(List<FaceEntity> faces) async {
|
||||
final result = await _channel.invokeMethod<bool>('updateFaceMetadata', {
|
||||
'items': [
|
||||
for (final face in faces)
|
||||
{
|
||||
'sampleId': int.parse(face.sampleId),
|
||||
'userId': face.userId,
|
||||
'username': face.userName
|
||||
},
|
||||
],
|
||||
});
|
||||
return result == true;
|
||||
}
|
||||
|
||||
static Stream<FaceStream?> get faceStream {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../../models/api/user_api_models.dart';
|
||||
|
||||
/// 有界预取窗口;消费方等待同一在途任务,避免一张照片被重复下载。
|
||||
class FaceImageDownloadQueue {
|
||||
FaceImageDownloadQueue({
|
||||
required List<ServiceFaceSyncRecord> records,
|
||||
required this.download,
|
||||
required this.discard,
|
||||
this.concurrency = 3,
|
||||
}) : _records = records {
|
||||
if (concurrency < 1) throw ArgumentError.value(concurrency);
|
||||
_fill();
|
||||
}
|
||||
|
||||
final List<ServiceFaceSyncRecord> _records;
|
||||
final Future<File> Function(ServiceFaceSyncRecord record) download;
|
||||
final Future<void> Function(File file) discard;
|
||||
final int concurrency;
|
||||
final _pending = <String, Future<File?>>{};
|
||||
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<File?> _load(ServiceFaceSyncRecord record) async {
|
||||
try {
|
||||
return await download(record);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> 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<void> dispose() async {
|
||||
_closed = true;
|
||||
final files = await Future.wait(_pending.values);
|
||||
_pending.clear();
|
||||
for (final file in files.whereType<File>()) {
|
||||
await discard(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = <String, int>{};
|
||||
var _dirty = false;
|
||||
|
||||
static Future<FaceSyncFailureStore> 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<void> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<FaceSyncChange> changes;
|
||||
final List<FaceEntity> orphans;
|
||||
|
||||
factory FaceSyncPlan.build({
|
||||
required List<ServiceFaceSyncRecord> remote,
|
||||
required List<FaceEntity> local,
|
||||
required Set<String> existingPaths,
|
||||
Set<String>? scopeDeptIds,
|
||||
Set<String> protectedSampleIds = const {},
|
||||
}) {
|
||||
final remaining = <String, FaceEntity>{};
|
||||
for (final face in local) {
|
||||
if (remaining.containsKey(face.sampleId)) {
|
||||
throw const FaceSyncSnapshotException('本地存在重复人脸编号,请先修复数据');
|
||||
}
|
||||
remaining[face.sampleId] = face;
|
||||
}
|
||||
final seen = <String>{};
|
||||
final changes = <FaceSyncChange>[];
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 = "本机暂无人脸数据,请先前往'设置'->'人脸管理'同步人脸";
|
||||
|
||||
@@ -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|');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -167,7 +167,7 @@ class _SyncResultBody extends StatelessWidget {
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
'失败明细(下次同步自动重试)',
|
||||
'失败明细',
|
||||
style: theme.typography.base.copyWith(
|
||||
color: XfjDesignTokens.danger,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -936,6 +936,11 @@ String _libraryConsistency(FacePluginStatus? status) {
|
||||
|
||||
String _phaseTitle(String? phase) {
|
||||
return switch (phase) {
|
||||
'reading' => '读取本地人脸库',
|
||||
'initializing' => '初始化人脸引擎',
|
||||
'repairing' => '修复缺失人脸特征',
|
||||
'loading' => '读取并装载人脸库',
|
||||
'verifying' => '核对内存人脸库',
|
||||
'preparing' => '准备人脸特征',
|
||||
'registering' => '装载内存人脸库',
|
||||
'completed' => '注册完成',
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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<dio.ResponseBody> fetch(dio.RequestOptions options,
|
||||
Stream<Uint8List>? requestStream, Future<void>? 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 = <String, Map<String, dynamic>>{};
|
||||
final remote = <Map<String, dynamic>>[];
|
||||
final allRemote = <Map<String, dynamic>>[];
|
||||
var photoBytes = <int>[10, 20, 30];
|
||||
final version = DateTime.parse('2026-10-22T01:02:03.123456');
|
||||
|
||||
Map<String, dynamic> 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<FaceEntity> 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<FaceSyncSummary> 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<AppControl>(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<String, dynamic>.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<int>.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<AppControl>(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<FaceSyncSnapshotException>()));
|
||||
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<FaceSyncSnapshotException>()));
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<FaceSyncSnapshotException>()));
|
||||
expect(
|
||||
() => FaceSyncPlan.build(
|
||||
remote: [], local: [local('1'), local('1')], existingPaths: {}),
|
||||
throwsA(isA<FaceSyncSnapshotException>()));
|
||||
});
|
||||
|
||||
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 = <String, Completer<File>>{};
|
||||
final calls = <String>[];
|
||||
final discarded = <String>[];
|
||||
final queue = FaceImageDownloadQueue(
|
||||
records: List.generate(30, (i) => record('${i + 1}')),
|
||||
download: (face) {
|
||||
calls.add(face.sampleId);
|
||||
return (tasks[face.sampleId] = Completer<File>()).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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user