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:
dzc committed 2026-10-22 16:35:52 +08:00
1 parent 6f0e3ea37e
commit 7c20242ad7
42 files changed
+2889 -979

No files matched your search

+2
View File
@@ -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
@@ -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);
}
}
@@ -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");
}
}
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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();
}
});
}
/**
* 在统一人脸操作互斥锁内批量移除内存库样本;一次通道调用摊平逐条往返与
* 互斥收发开销,供同步孤儿删除和调试清理入口使用。
@@ -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;
}
@@ -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) {
@@ -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.
}
}
}
}
+79
View File
@@ -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` 保留应用数据。
+47
View File
@@ -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)。同步耗时包含网络,单次数字不能作为受控速度比。
+35 -14
View File
@@ -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,
};
+60 -15
View File
@@ -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;
+138 -64
View File
@@ -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();
}
+13 -3
View File
@@ -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();
}
/// 获取激活、引擎和人脸库一致性状态。
+15 -16
View File
@@ -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(
+18 -1
View File
@@ -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;
+23 -53
View File
@@ -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;
}
}
+83
View File
@@ -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 = "本机暂无人脸数据,请先前往'设置'->'人脸管理'同步人脸";
+6 -4
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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
+52
View File
@@ -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);
});
}
+480
View File
@@ -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);
});
}
+136
View File
@@ -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);
});
}