diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java index bd84331..2b7dc36 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java @@ -89,6 +89,8 @@ public class DataBaseUtils { // 状态检查专用轻量查询:只读取数量与 sampleId 集合所需列,不加载 faceModel 特征大字段。 // face 表把特征以 JSON 文本存在行内(每条约数 KB),SELECT * 全表读取会反复撑爆 2MB // CursorWindow 并把调用线程阻塞数秒;状态一致性比较不需要特征数据。 + // 空特征行(含 NULL、空串和 '[]')永远注册不进虹软内存库,不计入一致性集合, + // 避免它们让 memoryCount 与 databaseCount 永久不等、收银弹框反复触发整库重装。 static public List getFaceProfiles(Context context, String queryUserId) { List result = new ArrayList<>(); DatabaseHelper databaseHelper = null; @@ -97,9 +99,10 @@ public class DataBaseUtils { try { databaseHelper = new DatabaseHelper(context); database = databaseHelper.getWritableDatabase(); - String sql = "SELECT userId, sampleId, userName, path FROM face"; + String sql = "SELECT userId, sampleId, userName, path FROM face" + + " WHERE faceModel IS NOT NULL AND faceModel != '' AND faceModel != '[]'"; if (Objects.nonNull(queryUserId) && !Objects.equals(queryUserId, "")) { - sql = sql.concat(" WHERE userId = ?"); + sql = sql.concat(" AND userId = ?"); cursor = database.rawQuery(sql, new String[]{queryUserId}); } else { cursor = database.rawQuery(sql, null); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java index b485b57..822ee35 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java @@ -15,8 +15,13 @@ public class DatabaseHelper extends SQLiteOpenHelper { private static final String DATABASE_PATH = "pj_cashier/database"; private static final String DATABASE_NAME = "cash_register.db"; - /** 与 Flutter Floor 的 @Database 版本保持一致,避免原生层提前推进 user_version。 */ - private static final int DATABASE_VERSION = 13; + /** + * 必须与 Flutter Floor 的 @Database 版本保持同步 + * (lib/common/services/database/database.dart)。 + * 历史上此值曾误用兄弟项目编号 13,把 user_version 提前盖章到 13 导致 + * Floor 跳过 11-13 区间迁移;双方现已一致跳到 14,后续升级必须同时修改。 + */ + private static final int DATABASE_VERSION = 14; public DatabaseHelper(Context context) { super(context, getCustomDatabasePath(context), null, DATABASE_VERSION); @@ -44,6 +49,21 @@ public class DatabaseHelper extends SQLiteOpenHelper { if (oldVersion < 12) { migrateFaceQuality(db); } + if (oldVersion < 14) { + migrateFaceQualityReport(db); + } + } + + /** + * 从版本13无损补齐质量上报确认列,与 Flutter Floor 的 migration13To14 内容一致。 + * 正常时序下 Floor 总是先打开数据库完成迁移,本方法只是极端时序下的兜底。 + */ + private void migrateFaceQualityReport(SQLiteDatabase db) { + if (!hasColumn(db, "face", "qualityReportedAt")) { + db.execSQL("ALTER TABLE face ADD COLUMN qualityReportedAt TEXT"); + } + // Floor 读取 faceModel 使用非空强转,NULL 历史脏行规范化为空串。 + db.execSQL("UPDATE face SET faceModel = '' WHERE faceModel IS NULL"); } /** diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java index aa892b7..48cb1bc 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java @@ -120,6 +120,7 @@ public class FlutterTool { if (activated) faceServer.init(context); // 状态检查只需要数量与 sampleId 集合,走不含 faceModel 大字段的轻量查询, // 避免整库特征 JSON 反序列化把调用线程(主线程)阻塞数秒。 + // 查询已过滤空特征行:databaseCount 与一致性集合都只统计可注册样本。 List databaseFaces = getFaceProfiles(context, null); int databaseCount = databaseFaces.size(); int memoryCount = activated && faceServer.isEngineReady() ? Math.max(faceServer.getTotalFaceCount(), 0) : 0; diff --git a/lib/common/services/database/dao/face_dao.dart b/lib/common/services/database/dao/face_dao.dart index dfc16d1..60cfc25 100644 --- a/lib/common/services/database/dao/face_dao.dart +++ b/lib/common/services/database/dao/face_dao.dart @@ -135,6 +135,56 @@ abstract class FaceDao { ''') Future> getAll(); + /// 查询同步比对所需的轻量档案,faceModel 以哨兵占位避免全表特征反序列化。 + /// + /// 占位规则:特征非空返回 `[1]`(非空列表),特征为空或 NULL 返回 `[]`。 + /// 返回的实体仅用于内存比对,禁止直接回写数据库;需要完整行时按 sampleId + /// 单条回读 [findBySampleId]。 + @Query(''' + 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 + ''') + Future> getSyncProfiles(); + + /// 只更新服务端元数据字段,不触碰 faceModel 大字段和质量检测结果。 + /// + /// Floor 的 @Query 生成器只产出位置参数,参数顺序固定为: + /// sampleId、userId、userName、deptId、deptName、imagePath、updateTime。 + /// 可空字段以空串传入并由 NULLIF 归一为 NULL;[updateTime] 为空串时保留本地 + /// 旧值,与快照合并时"服务端版本缺失则回退本地"一致。 + @Query(''' + UPDATE face + SET userId = :userId, + userName = :userName, + deptId = NULLIF(:deptId, ''), + deptName = NULLIF(:deptName, ''), + imagePath = :imagePath, + updateTime = COALESCE(NULLIF(:updateTime, ''), updateTime) + WHERE sampleId = :sampleId + ''') + Future editMetadataBySampleId( + String sampleId, + String userId, + String userName, + String deptId, + String deptName, + String imagePath, + String updateTime, + ); + + /// 批量标记质量结果已被服务端确认,未确认样本保持旧标记供下次补报。 + @Query(''' + UPDATE face SET qualityReportedAt = :reportedAt + WHERE sampleId IN (:sampleIds) + ''') + Future markQualityReported( + List sampleIds, + DateTime reportedAt, + ); + @Query(''' SELECT DISTINCT a.deptId FROM face a WHERE a.deptId IS NOT NULL ''') diff --git a/lib/common/services/database/database.dart b/lib/common/services/database/database.dart index 1de26c7..8ea8a4f 100644 --- a/lib/common/services/database/database.dart +++ b/lib/common/services/database/database.dart @@ -44,7 +44,10 @@ const typeConverters = [ BoolConverter, ]; -@Database(version: 10, entities: entities) +// 版本必须与原生 DatabaseHelper.DATABASE_VERSION 同步修改 +// (android/.../facediscern/database/DatabaseHelper.java)。 +// 11-13 区间曾被原生历史版本以 13 提前盖章 user_version 占用,Flutter 侧从 10 直接跳到 14。 +@Database(version: 14, entities: entities) @TypeConverters(typeConverters) abstract class AppDatabase extends FloorDatabase { BroadcastDao get getBroadcastDao; diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index 2f1fbc8..35b4c6a 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -112,7 +112,7 @@ class _$AppDatabase extends AppDatabase { Callback? callback, ]) async { final databaseOptions = sqflite.OpenDatabaseOptions( - version: 10, + version: 14, onConfigure: (database) async { await database.execute('PRAGMA foreign_keys = ON'); await callback?.onConfigure?.call(database); @@ -150,7 +150,7 @@ class _$AppDatabase extends AppDatabase { await database.execute( 'CREATE TABLE IF NOT EXISTS `user_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `nickname` TEXT NOT NULL, `mobile` TEXT, `avatar` TEXT, `walletCardNumber` TEXT, `walletId` TEXT, `walletTypeId` TEXT, `walletBalance` INTEGER, `canteenId` TEXT, `discountsList` TEXT, `discountsUpdateTime` TEXT, `createTime` TEXT, `updateTime` TEXT)'); await database.execute( - 'CREATE TABLE IF NOT EXISTS `face` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `sampleId` TEXT NOT NULL, `userName` TEXT NOT NULL, `deptId` TEXT, `deptName` TEXT, `imagePath` TEXT NOT NULL, `path` TEXT, `faceModel` TEXT, `createTime` TEXT, `updateTime` TEXT, `qualityScore` REAL, `qualityStatus` TEXT NOT NULL, `qualityCheckedAt` TEXT, `qualityErrorCode` INTEGER)'); + 'CREATE TABLE IF NOT EXISTS `face` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `sampleId` TEXT NOT NULL, `userName` TEXT NOT NULL, `deptId` TEXT, `deptName` TEXT, `imagePath` TEXT NOT NULL, `path` TEXT, `faceModel` TEXT, `createTime` TEXT, `updateTime` TEXT, `qualityScore` REAL, `qualityStatus` TEXT NOT NULL, `qualityCheckedAt` TEXT, `qualityErrorCode` INTEGER, `qualityReportedAt` TEXT)'); await database.execute( 'CREATE TABLE IF NOT EXISTS `setting` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userJson` TEXT NOT NULL, `sysJson` TEXT NOT NULL, `canteenExtraPriceConfigJson` TEXT)'); await database.execute( @@ -1885,7 +1885,9 @@ class _$FaceDao extends FaceDao { 'qualityStatus': item.qualityStatus, 'qualityCheckedAt': _dateTimeEmptyConverter.encode(item.qualityCheckedAt), - 'qualityErrorCode': item.qualityErrorCode + 'qualityErrorCode': item.qualityErrorCode, + 'qualityReportedAt': + _dateTimeEmptyConverter.encode(item.qualityReportedAt) }), _faceEntityUpdateAdapter = UpdateAdapter( database, @@ -1907,7 +1909,9 @@ class _$FaceDao extends FaceDao { 'qualityStatus': item.qualityStatus, 'qualityCheckedAt': _dateTimeEmptyConverter.encode(item.qualityCheckedAt), - 'qualityErrorCode': item.qualityErrorCode + 'qualityErrorCode': item.qualityErrorCode, + 'qualityReportedAt': + _dateTimeEmptyConverter.encode(item.qualityReportedAt) }), _faceEntityDeletionAdapter = DeletionAdapter( database, @@ -1929,7 +1933,9 @@ class _$FaceDao extends FaceDao { 'qualityStatus': item.qualityStatus, 'qualityCheckedAt': _dateTimeEmptyConverter.encode(item.qualityCheckedAt), - 'qualityErrorCode': item.qualityErrorCode + 'qualityErrorCode': item.qualityErrorCode, + 'qualityReportedAt': + _dateTimeEmptyConverter.encode(item.qualityReportedAt) }); final sqflite.DatabaseExecutor database; @@ -1995,7 +2001,7 @@ class _$FaceDao extends FaceDao { ) async { return _queryAdapter.queryList( 'SELECT * FROM face WHERE qualityStatus = \'detected\' AND qualityScore IS NOT NULL AND qualityScore < ?2 AND (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\') ORDER BY id ASC LIMIT ?3 OFFSET ?4', - mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?), + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _dateTimeEmptyConverter.decode(row['qualityReportedAt'] as String?)), arguments: [userName, threshold, pageSize, offset]); } @@ -2003,7 +2009,7 @@ class _$FaceDao extends FaceDao { Future> findAllLowQuality(double threshold) async { return _queryAdapter.queryList( 'SELECT * FROM face WHERE qualityStatus = \'detected\' AND qualityScore IS NOT NULL AND qualityScore < ?1 ORDER BY id ASC', - mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?), + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _dateTimeEmptyConverter.decode(row['qualityReportedAt'] as String?)), arguments: [threshold]); } @@ -2043,7 +2049,9 @@ class _$FaceDao extends FaceDao { qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter .decode(row['qualityCheckedAt'] as String?), - qualityErrorCode: row['qualityErrorCode'] as int?)); + qualityErrorCode: row['qualityErrorCode'] as int?, + qualityReportedAt: _dateTimeEmptyConverter + .decode(row['qualityReportedAt'] as String?))); } @override @@ -2067,7 +2075,9 @@ class _$FaceDao extends FaceDao { qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter .decode(row['qualityCheckedAt'] as String?), - qualityErrorCode: row['qualityErrorCode'] as int?), + qualityErrorCode: row['qualityErrorCode'] as int?, + qualityReportedAt: _dateTimeEmptyConverter + .decode(row['qualityReportedAt'] as String?)), arguments: [id]); } @@ -2092,7 +2102,9 @@ class _$FaceDao extends FaceDao { qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter .decode(row['qualityCheckedAt'] as String?), - qualityErrorCode: row['qualityErrorCode'] as int?), + qualityErrorCode: row['qualityErrorCode'] as int?, + qualityReportedAt: _dateTimeEmptyConverter + .decode(row['qualityReportedAt'] as String?)), arguments: [sampleId]); } @@ -2117,7 +2129,9 @@ class _$FaceDao extends FaceDao { qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter .decode(row['qualityCheckedAt'] as String?), - qualityErrorCode: row['qualityErrorCode'] as int?), + qualityErrorCode: row['qualityErrorCode'] as int?, + qualityReportedAt: _dateTimeEmptyConverter + .decode(row['qualityReportedAt'] as String?)), arguments: [userId]); } @@ -2129,7 +2143,7 @@ class _$FaceDao extends FaceDao { ) async { return _queryAdapter.queryList( 'SELECT * FROM face WHERE (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\') LIMIT ?2 OFFSET ?3', - mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?), + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?, qualityReportedAt: _dateTimeEmptyConverter.decode(row['qualityReportedAt'] as String?)), arguments: [userName, pageSize, offset]); } @@ -2154,7 +2168,75 @@ class _$FaceDao extends FaceDao { qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter .decode(row['qualityCheckedAt'] as String?), - qualityErrorCode: row['qualityErrorCode'] as int?)); + qualityErrorCode: row['qualityErrorCode'] as int?, + qualityReportedAt: _dateTimeEmptyConverter + .decode(row['qualityReportedAt'] as String?))); + } + + @override + Future> getSyncProfiles() async { + return _queryAdapter.queryList( + 'SELECT id, userId, sampleId, userName, deptId, deptName, imagePath, path, CASE WHEN faceModel IS NULL OR faceModel = \'\' THEN \'[]\' ELSE \'[1]\' END AS faceModel, createTime, updateTime, qualityScore, qualityStatus, qualityCheckedAt, qualityErrorCode, qualityReportedAt FROM face ORDER BY sampleId ASC', + mapper: (Map row) => FaceEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + sampleId: row['sampleId'] as String, + userName: row['userName'] as String, + deptId: row['deptId'] as String?, + deptName: row['deptName'] as String?, + imagePath: row['imagePath'] as String, + path: row['path'] as String?, + faceModel: _listIntConverter.decode(row['faceModel'] as String), + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String?), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String?), + qualityErrorCode: row['qualityErrorCode'] as int?, + qualityReportedAt: _dateTimeEmptyConverter + .decode(row['qualityReportedAt'] as String?))); + } + + @override + Future editMetadataBySampleId( + String sampleId, + String userId, + String userName, + String deptId, + String deptName, + String imagePath, + String updateTime, + ) 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', + arguments: [ + sampleId, + userId, + userName, + deptId, + deptName, + imagePath, + updateTime + ]); + } + + @override + Future markQualityReported( + List sampleIds, + DateTime reportedAt, + ) async { + const offset = 2; + final _sqliteVariablesForSampleIds = + Iterable.generate(sampleIds.length, (i) => '?${i + offset}') + .join(','); + await _queryAdapter.queryNoReturn( + 'UPDATE face SET qualityReportedAt = ?1 WHERE sampleId IN (' + + _sqliteVariablesForSampleIds + + ')', + arguments: [_dateTimeConverter.encode(reportedAt), ...sampleIds]); } @override @@ -2186,7 +2268,9 @@ class _$FaceDao extends FaceDao { qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter .decode(row['qualityCheckedAt'] as String?), - qualityErrorCode: row['qualityErrorCode'] as int?)); + qualityErrorCode: row['qualityErrorCode'] as int?, + qualityReportedAt: _dateTimeEmptyConverter + .decode(row['qualityReportedAt'] as String?))); } @override diff --git a/lib/common/services/database/database_migration.dart b/lib/common/services/database/database_migration.dart index 24b0d96..3807790 100644 --- a/lib/common/services/database/database_migration.dart +++ b/lib/common/services/database/database_migration.dart @@ -1,4 +1,5 @@ import 'package:floor/floor.dart'; +import 'package:sqflite/sqflite.dart' as sqflite; abstract class DatabaseMigration { /// 从版本1到版本2的迁移:添加productNamePy字段 @@ -423,6 +424,37 @@ abstract class DatabaseMigration { ); }); + /// 从版本10到版本14的迁移(跨过 11-13):face 表补充质量上报确认列。 + /// + /// 11-13 区间曾被原生 DatabaseHelper 以 SQLiteOpenHelper 版本 13 提前盖章 + /// user_version,Floor 对更高版本只会静默跳过升级,因此 Flutter 侧直接跳到 14; + /// 被盖章 13 的设备由 [migration13To14] 修复。 + static Migration migration10To14 = Migration(10, 14, (database) async { + await _upgradeFaceQualityReport(database); + }); + + /// 从版本13到版本14的迁移:修复被原生提前盖章 13 但 schema 仍停留在版本10的设备。 + static Migration migration13To14 = Migration(13, 14, (database) async { + await _upgradeFaceQualityReport(database); + }); + + /// 补齐质量上报确认列并规范化 NULL 特征;全部操作幂等,可被多条迁移重复执行。 + static Future _upgradeFaceQualityReport( + sqflite.Database database, + ) async { + final columns = await database.rawQuery('PRAGMA table_info(face)'); + final names = columns.map((column) => column['name']).toSet(); + if (!names.contains('qualityReportedAt')) { + await database.execute( + 'ALTER TABLE face ADD COLUMN qualityReportedAt TEXT', + ); + } + // 历史脏行可能存在 NULL 特征;Floor 读取 faceModel 使用非空强转,规范化为空串。 + await database.execute( + "UPDATE face SET faceModel = '' WHERE faceModel IS NULL", + ); + } + static List getDatabaseMigrations() { return [ migration1To2, @@ -434,6 +466,8 @@ abstract class DatabaseMigration { migration7To8, migration8To9, migration9To10, + migration10To14, + migration13To14, ]; } } diff --git a/lib/common/services/database/entity/face_entity.dart b/lib/common/services/database/entity/face_entity.dart index 15404fc..937a331 100644 --- a/lib/common/services/database/entity/face_entity.dart +++ b/lib/common/services/database/entity/face_entity.dart @@ -53,6 +53,9 @@ class FaceEntity extends Equatable { /// 最近一次质量检测的虹软或插件错误码;成功时为空。 int? qualityErrorCode; + /// 质量结果最近一次被服务端确认的时间;为空或早于检测时间时需要补报。 + DateTime? qualityReportedAt; + FaceEntity({ this.id, required this.userId, @@ -69,6 +72,7 @@ class FaceEntity extends Equatable { this.qualityStatus = 'pending', this.qualityCheckedAt, this.qualityErrorCode, + this.qualityReportedAt, }); factory FaceEntity.fromJson(Map json) => diff --git a/lib/common/services/database/entity/face_entity.g.dart b/lib/common/services/database/entity/face_entity.g.dart index 1900109..31f3a89 100644 --- a/lib/common/services/database/entity/face_entity.g.dart +++ b/lib/common/services/database/entity/face_entity.g.dart @@ -28,6 +28,9 @@ FaceEntity _$FaceEntityFromJson(Map json) => FaceEntity( ? null : DateTime.parse(json['qualityCheckedAt'] as String), qualityErrorCode: (json['qualityErrorCode'] as num?)?.toInt(), + qualityReportedAt: json['qualityReportedAt'] == null + ? null + : DateTime.parse(json['qualityReportedAt'] as String), ); Map _$FaceEntityToJson(FaceEntity instance) => @@ -47,4 +50,5 @@ Map _$FaceEntityToJson(FaceEntity instance) => 'qualityStatus': instance.qualityStatus, 'qualityCheckedAt': instance.qualityCheckedAt?.toIso8601String(), 'qualityErrorCode': instance.qualityErrorCode, + 'qualityReportedAt': instance.qualityReportedAt?.toIso8601String(), }; diff --git a/lib/common/utils/app/app_face_tool.dart b/lib/common/utils/app/app_face_tool.dart index 1d88a91..0b01991 100644 --- a/lib/common/utils/app/app_face_tool.dart +++ b/lib/common/utils/app/app_face_tool.dart @@ -99,6 +99,14 @@ abstract class AppFaceTool { try { final registration = await FaceDiscernPlugs.rebuildFaceLibrary(); + // 装载阶段已尝试从本地图片补回缺失特征;最终仍无特征的行在此清理, + // 否则它们进不了内存库,却会让一致性比对永久失败,收银弹框就会 + // 反复进入就地注册引导。清库只发生在启动装载和同步删除两个入口。 + try { + await SyncFaceTool.delEmptyFace(); + } catch (error, stack) { + logger.w('|$_tag|启动清理残缺人脸失败error:|$error|stack:|$stack|'); + } final status = registration.success ? FaceStartupPreparationStatus.success : FaceStartupPreparationStatus.warning; diff --git a/lib/common/utils/sync/scheduled_task_storage_tool.dart b/lib/common/utils/sync/scheduled_task_storage_tool.dart index ca1e080..15c4d68 100644 --- a/lib/common/utils/sync/scheduled_task_storage_tool.dart +++ b/lib/common/utils/sync/scheduled_task_storage_tool.dart @@ -10,6 +10,9 @@ abstract final class ScheduledTaskStorageTool { /// 上一版本商品同步任务的默认 Cron,仅用于把未自定义配置升级为每 3 小时。 static const String _legacyFiveMinuteProductSyncCron = '0 */5 * * * *'; + /// 旧版离线订单同步的每日默认 Cron,仅用于把未自定义配置升级为每 10 分钟自动直连。 + static const String _legacyDailyOfflineOrderSyncCron = '0 35 0 * * *'; + /// 当前应用数据库中的任务配置 DAO;数据库由 AppControl 在调度器启动前完成初始化。 static ScheduledTaskDao get _taskDao => Get.find().dataBase.getScheduledTaskDao; @@ -18,9 +21,9 @@ abstract final class ScheduledTaskStorageTool { static ScheduledTaskLogDao get _logDao => Get.find().dataBase.getScheduledTaskLogDao; - /// 首次升级时把旧 sysJson 导入表中,并补齐新增任务及升级仍沿用旧默认值的商品同步任务。 + /// 首次升级时把旧 sysJson 导入表中,并补齐新增任务及升级仍沿用旧默认值的任务。 /// - /// 商品同步只迁移完整匹配旧默认值的记录;用户调整过 Cron 或空闲策略时保留现场配置。 + /// 迁移只处理完整匹配旧默认值的记录;用户调整过 Cron 或空闲策略时保留现场配置。 static Future initialize({ required List defaults, required List legacyConfigs, @@ -35,7 +38,7 @@ abstract final class ScheduledTaskStorageTool { for (final defaultConfig in defaults) { final current = existing[defaultConfig.type.name]; if (current != null) { - if (_usesLegacyProductSyncDefault( + if (_usesLegacyTaskDefault( type: defaultConfig.type, enabled: current.enabled, cron: current.cronExpression, @@ -53,7 +56,7 @@ abstract final class ScheduledTaskStorageTool { continue; } final legacyConfig = legacy[defaultConfig.type] ?? defaultConfig; - final config = _usesLegacyProductSyncDefault( + final config = _usesLegacyTaskDefault( type: legacyConfig.type, enabled: legacyConfig.enabled, cron: legacyConfig.cron, @@ -84,17 +87,23 @@ abstract final class ScheduledTaskStorageTool { if (writes.isNotEmpty) await _taskDao.batchSave(writes); } - /// 判断配置是否仍为旧版商品同步默认值,用于升级默认策略且避免覆盖其他任务或自定义规则。 - static bool _usesLegacyProductSyncDefault({ + /// 判断配置是否仍为旧版任务默认值,用于升级默认策略且避免覆盖用户自定义规则。 + static bool _usesLegacyTaskDefault({ required ScheduledTaskType type, required bool enabled, required String cron, required bool idleOnly, - }) => - type == ScheduledTaskType.productSync && - idleOnly && - ((!enabled && cron == _legacyDisabledProductSyncCron) || - (enabled && cron == _legacyFiveMinuteProductSyncCron)); + }) { + if (type == ScheduledTaskType.productSync) { + return idleOnly && + ((!enabled && cron == _legacyDisabledProductSyncCron) || + (enabled && cron == _legacyFiveMinuteProductSyncCron)); + } + return type == ScheduledTaskType.offlineOrderSync && + !enabled && + idleOnly && + cron == _legacyDailyOfflineOrderSyncCron; + } /// 从 scheduled_task 读取有效任务,并转换为调度器使用的配置和最近执行快照。 /// diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart index 1c48bf7..3b28ac0 100644 --- a/lib/common/utils/sync/sync_check_tool.dart +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -316,9 +316,9 @@ class SyncCheckTool { ), ScheduledTaskConfig( type: ScheduledTaskType.offlineOrderSync, - enabled: false, - cron: '0 35 0 * * *', - idleOnly: true, + enabled: true, + cron: '0 */10 * * * *', + idleOnly: false, ), ScheduledTaskConfig( type: ScheduledTaskType.orderEvidenceUpload, @@ -881,8 +881,18 @@ class SyncCheckTool { } return const _ScheduledTaskActionResult.success('商品同步完成'); case ScheduledTaskType.offlineOrderSync: - await SyncOrderTool.syncOfflineOrder(); - return const _ScheduledTaskActionResult.success('离线订单同步完成'); + final syncedCount = await SyncOrderTool.syncOfflineOrder(); + return switch (syncedCount) { + null => const _ScheduledTaskActionResult.skipped('没有待同步的离线订单'), + // 订单已上传但后台未确认任何一单:后端按用户/钱包校验静默跳过, + // 需要对照后台"订单同步"日志定位,不能与本地无欠账单混为一谈。 + 0 => const _ScheduledTaskActionResult.skipped( + '订单已上传但后台未确认,请检查后台订单同步日志', + ), + int count => _ScheduledTaskActionResult.success( + '离线订单同步完成,共 $count 单', + ), + }; case ScheduledTaskType.orderEvidenceUpload: if (!AppNetworkTool.isAutoNetwork()) { return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart index 9c41e60..5c0c2e5 100644 --- a/lib/common/utils/sync/sync_face_tool.dart +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:common_utils/common_utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -25,15 +26,34 @@ abstract class SyncFaceTool { /// 服务端单次允许接收的人脸质量结果上限。 static const int _qualityReportBatchSize = 500; - /// 删除数据库中缺少本地特征或图片的历史残缺人脸。 + /// 同步图片预取的临时下载目录名;每次同步开始时整体清理中断遗留。 + static const String _syncTempDirectoryName = '.face-sync-temp'; + + /// 并行预检本地人脸文件存在性的批大小。 + static const int _fileCheckBatchSize = 64; + + /// 需要重新下载图片的样本预取并发数。 + static const int _downloadPrefetchConcurrency = 3; + + /// 删除数据库中缺少人脸特征的历史残缺行;启动整库装载完成后调用。 /// + /// 空特征行永远注册不进虹软内存库,却参与"内存库与本地库一致"的集合 + /// 比对,会让收银弹框反复判定库损坏并进入就地注册引导。整库装载已先 + /// 尝试从本地图片补回特征,这里只清理最终仍无特征的行。 /// 删除顺序仍先处理虹软内存库;单条失败时保留记录供后续权威快照重试。 - static Future delEmptyFace() async { + static Future delEmptyFace() async { final faceDao = Get.find().dataBase.getFaceDao; - final faces = await faceDao.getEmptyFaceList() ?? const []; - for (final face in faces) { - await _deleteLocalFace(face, faceDao); + // 轻量档案以哨兵区分特征有无,避免为找出残缺行反序列化全表特征。 + final profiles = await faceDao.getSyncProfiles(); + var deleted = 0; + for (final profile in profiles) { + if ((profile.faceModel ?? const []).isNotEmpty) continue; + if (await _deleteLocalFace(profile, faceDao)) deleted++; } + if (deleted > 0) { + logger.i('|$_tag|启动清理无可注册特征的残缺人脸|count:$deleted|'); + } + return deleted; } /// 兼容既有页面与识别恢复调用的统一入口。 @@ -75,6 +95,7 @@ abstract class SyncFaceTool { if (storagePath == null || storagePath.isEmpty) { throw const FaceSyncSnapshotException('无法创建人脸本地存储目录'); } + await _cleanSyncTempDirectory(storagePath); if (!AppRuntimeTool.useManualHardwareInput && AppSysTool.isProd() && !await AppFaceTool.isActiveSdk()) { @@ -122,7 +143,11 @@ abstract class SyncFaceTool { merchantDeptId: merchantDeptId, onProgress: onProgress, ); - final local = await Get.find().dataBase.getFaceDao.getAll(); + // 轻量档案不反序列化特征大字段;需要完整行时在单条处理时按 sampleId 回读。 + final local = await Get.find() + .dataBase + .getFaceDao + .getSyncProfiles(); await _reconcile( remote: remote, local: local, @@ -285,53 +310,101 @@ abstract class SyncFaceTool { localBySampleId[face.sampleId] = face; } - for (final record in remote) { - accumulator.scanned++; - final existing = localBySampleId.remove(record.sampleId); - final imageChanged = await _imageChanged(existing, record); - if (existing == null || imageChanged) { - final success = await _upsertImageFace( - record: record, - existing: existing, - storagePath: storagePath, - faceDao: faceDao, - ); - if (success) { - existing == null ? accumulator.added++ : accumulator.updated++; + // 文件存在性分批并行预检,避免大库逐条串行 stat 拖慢比对阶段。 + final existingFilePaths = await _collectExistingFilePaths(local); + // 需要重下图片的记录先并行预取,下载与主流程的提取、注册流水线并行。 + final prefetchRecords = [ + for (final record in remote) + if (_needsImageWork( + localBySampleId[record.sampleId], + record, + existingFilePaths, + )) + record, + ]; + final prefetcher = _FaceImagePrefetcher.start( + prefetchRecords, + storagePath, + ); + try { + for (final record in remote) { + accumulator.scanned++; + final existing = localBySampleId.remove(record.sampleId); + final imageChanged = _imageChanged(existing, record, existingFilePaths); + if (existing == null || imageChanged) { + final success = await _upsertImageFace( + record: record, + existing: existing, + storagePath: storagePath, + faceDao: faceDao, + prefetcher: prefetcher, + ); + if (success) { + existing == null ? accumulator.added++ : accumulator.updated++; + } else { + accumulator.fail(record.sampleId); + } + } else if (_metadataChanged(existing, record)) { + // 元数据变化只改对应列,不回写特征大字段和质量检测结果。 + // 可空字段统一传空串,由 SQL 的 NULLIF/COALESCE 还原空值或保留旧版本。 + await faceDao.editMetadataBySampleId( + record.sampleId, + record.userId, + record.username ?? '', + record.deptId, + record.deptName ?? '', + record.faceImg ?? '', + record.sourceUpdatedAt == null + ? '' + : DateUtil.formatDate(record.sourceUpdatedAt!), + ); + accumulator.updated++; } else { - accumulator.fail(record.sampleId); + accumulator.skipped++; } - } else if (_metadataChanged(existing, record)) { - await faceDao.edit(_toEntity(record, existing: existing)); - accumulator.updated++; - } else { - accumulator.skipped++; + onProgress?.call(accumulator.scanned, remote.length); } - onProgress?.call(accumulator.scanned, remote.length); - } - // 只有远端完整集合已经成功拉取和校验后才会执行此处的集合差删除。 - // 孤儿数量已知且通常远小于远端集合,并入总数保持进度单调推进。 - final orphanTotal = remote.length + localBySampleId.length; - for (final orphan in localBySampleId.values) { - if (await _deleteLocalFace(orphan, faceDao)) { - accumulator.deleted++; - } else { - accumulator.fail(orphan.sampleId); + // 只有远端完整集合已经成功拉取和校验后才会执行此处的集合差删除。 + // 孤儿数量已知且通常远小于远端集合,并入总数保持进度单调推进。 + final orphanTotal = remote.length + localBySampleId.length; + for (final orphan in localBySampleId.values) { + if (await _deleteLocalFace(orphan, faceDao)) { + accumulator.deleted++; + } else { + accumulator.fail(orphan.sampleId); + } + onProgress?.call(remote.length + accumulator.deleted, orphanTotal); } - onProgress?.call(remote.length + accumulator.deleted, orphanTotal); + } finally { + await prefetcher?.dispose(); } + logger.i( + '|$_tag|图片预取统计|计划:${prefetchRecords.length}|命中:${prefetcher?.hitCount ?? 0}|', + ); } + /// 判断快照记录是否需要走下载与特征重提取流程。 + static bool _needsImageWork( + FaceEntity? existing, + ServiceFaceSyncRecord record, + Set existingFilePaths, + ) => + existing == null || _imageChanged(existing, record, existingFilePaths); + /// 判断图片版本、特征或正式文件是否变化;完全一致时不下载也不写虹软。 /// /// 历史本地记录可能缺少图片版本时间(早期 V2 调试同步写入的 updateTime 为空); /// 此时只要图片地址一致且本地特征与文件完好,就不再强制重新下载,由 /// [_metadataChanged] 的版本回填补齐,避免服务端图片损坏时记录永远无法修复。 - static Future _imageChanged( + /// + /// [existingFilePaths] 为预检得到的仍存在的本地文件路径集合,见 + /// [_collectExistingFilePaths]。 + static bool _imageChanged( FaceEntity? existing, ServiceFaceSyncRecord record, - ) async { + Set existingFilePaths, + ) { if (existing == null) return true; if (existing.imagePath != record.faceImg) return true; if (record.sourceUpdatedAt != null && @@ -341,7 +414,7 @@ abstract class SyncFaceTool { } if ((existing.faceModel ?? const []).isEmpty) return true; final path = existing.path; - return path == null || path.isEmpty || !await File(path).exists(); + return path == null || path.isEmpty || !existingFilePaths.contains(path); } /// 判断姓名、部门元数据或图片版本时间是否变化;此类变化不需要重新提取图片特征。 @@ -366,12 +439,18 @@ abstract class SyncFaceTool { required FaceEntity? existing, required String storagePath, required FaceDao faceDao, + _FaceImagePrefetcher? prefetcher, }) async { File? temporaryFile; File? finalFile; var databaseChanged = false; + // 比对用轻量档案不含特征;单条回读完整行,保证替换与回滚都写回真实特征。 + final existingRow = existing == null + ? null + : await faceDao.findBySampleId(existing.sampleId) ?? existing; try { - temporaryFile = await _downloadTemporaryFace(record, storagePath); + temporaryFile = prefetcher?.take(record.sampleId) ?? + await _downloadTemporaryFace(record, storagePath); final extraction = AppRuntimeTool.useManualHardwareInput ? null : await FaceDiscernPlugs.extractFaceFeature( @@ -388,13 +467,13 @@ abstract class SyncFaceTool { temporaryFile = null; final entity = _toEntity( record, - existing: existing, + existing: existingRow, path: finalFile.path, faceModel: featureData, extraction: extraction, imageReplaced: true, ); - if (existing == null) { + if (existingRow == null) { final id = await faceDao.save(entity); if (id == null) throw const FaceSyncSnapshotException('本地人脸记录写入失败'); entity.id = id; @@ -413,8 +492,8 @@ abstract class SyncFaceTool { ); if (!applied.success) throw FaceSyncSnapshotException(applied.message); } - if (existing?.path != null && existing!.path != finalFile.path) { - await delFaceByStorage(filePath: existing.path!); + if (existingRow?.path != null && existingRow!.path != finalFile.path) { + await delFaceByStorage(filePath: existingRow.path!); } return true; } catch (error, stack) { @@ -423,10 +502,10 @@ abstract class SyncFaceTool { ); if (databaseChanged) { try { - if (existing == null) { + if (existingRow == null) { await faceDao.delBySampleId(record.sampleId); } else { - await faceDao.edit(existing); + await faceDao.edit(existingRow); } } catch (rollbackError, rollbackStack) { logger.e( @@ -448,6 +527,8 @@ abstract class SyncFaceTool { FaceEntity face, FaceDao faceDao, ) async { + // 比对用轻量档案不含特征,回滚与原生恢复需要完整行;单行回读代价可忽略。 + face = await faceDao.findBySampleId(face.sampleId) ?? face; File? originalFile; File? pendingFile; try { @@ -617,20 +698,23 @@ abstract class SyncFaceTool { ); } - /// 将本机已检测或检测失败的人脸质量按 500 条幂等补报到服务端。 + /// 将本机待补报的人脸质量按 500 条幂等上报到服务端。 /// - /// 第一版不增加本地“已上报”数据库字段,因此每次完整同步或人工质量检测完成后 - /// 都会补报当前持久化结果。服务端按图片版本、成功状态优先级和检测时间保证幂等; - /// 任一网络或业务错误只记录汇总日志并返回 false,绝不回滚 SQLite、特征或虹软内存库。 + /// 上报范围按本地 [FaceEntity.qualityReportedAt] 增量判定:服务端确认过的样本 + /// 在重新检测前不再重复上报;每批确认后立即落标记,标记失败只导致下次重复 + /// 补报,不回滚 SQLite、特征或虹软内存库。 static Future _reportPersistedFaceQualitySafely() async { if (!AppNetworkTool.isAutoNetwork()) { logger.w('|$_tag|质量上报跳过|reason:网络不可用|'); return false; } try { - final faces = await Get.find().dataBase.getFaceDao.getAll(); + final faceDao = Get.find().dataBase.getFaceDao; + // 上报只需要质量与版本列,走轻量档案避免全表特征反序列化。 + final faces = await faceDao.getSyncProfiles(); final items = []; var invalidCount = 0; + var alreadyReportedCount = 0; for (final face in faces) { final status = FaceQualityStatus.fromCode(face.qualityStatus); if (status == FaceQualityStatus.pending) continue; @@ -644,6 +728,11 @@ abstract class SyncFaceTool { invalidCount++; continue; } + final reportedAt = face.qualityReportedAt; + if (reportedAt != null && !checkedAt.isAfter(reportedAt)) { + alreadyReportedCount++; + continue; + } items.add(ServiceFaceQualityReportItem( sampleId: face.sampleId, status: status.code, @@ -659,6 +748,9 @@ abstract class SyncFaceTool { logger.w('|$_tag|质量上报无有效结果|invalid:$invalidCount|'); return false; } + logger.i( + '|$_tag|质量上报跳过|reason:无待上报结果|reported:$alreadyReportedCount|', + ); return true; } @@ -681,11 +773,29 @@ abstract class SyncFaceTool { final batch = items.sublist(offset, end); try { final result = await UserApi.reportTerminalFaceQuality(batch); - final batchConfirmed = result.updatedSampleIds.length + - result.ignoredStaleSampleIds.length + - result.alreadyAbsentSampleIds.length; - confirmedCount += batchConfirmed; - if (batchConfirmed != batch.length) allSucceeded = false; + final batchSampleIds = { + for (final item in batch) item.sampleId, + }; + final confirmedSampleIds = { + ...result.updatedSampleIds, + ...result.ignoredStaleSampleIds, + ...result.alreadyAbsentSampleIds, + }.where(batchSampleIds.contains).toList(growable: false); + confirmedCount += confirmedSampleIds.length; + try { + // 逐批落确认标记,进程中断时已确认批次不会在下次重复上报。 + if (confirmedSampleIds.isNotEmpty) { + await faceDao.markQualityReported( + confirmedSampleIds, + DateTime.now(), + ); + } + } catch (markError, markStack) { + logger.w( + '|$_tag|质量上报标记失败|error:${markError.runtimeType}|stack:$markStack|', + ); + } + if (confirmedSampleIds.length != batch.length) allSucceeded = false; } catch (error, stack) { allSucceeded = false; failedBatchCount++; @@ -695,7 +805,7 @@ abstract class SyncFaceTool { } } logger.i( - '|$_tag|质量上报完成|total:${items.length}|confirmed:$confirmedCount|invalid:$invalidCount|failedBatch:$failedBatchCount|', + '|$_tag|质量上报完成|total:${items.length}|confirmed:$confirmedCount|reported:$alreadyReportedCount|invalid:$invalidCount|failedBatch:$failedBatchCount|', ); return allSucceeded; } catch (error, stack) { @@ -733,7 +843,7 @@ abstract class SyncFaceTool { if (bytes == null || bytes.isEmpty) { throw const FaceSyncSnapshotException('人脸图片下载失败'); } - final directory = Directory('$storagePath/.face-sync-temp'); + final directory = Directory('$storagePath/$_syncTempDirectoryName'); if (!await directory.exists()) await directory.create(recursive: true); final file = File( '${directory.path}/${record.sampleId}_${DateTime.now().microsecondsSinceEpoch}.tmp', @@ -742,6 +852,45 @@ abstract class SyncFaceTool { return file; } + /// 删除同步临时目录中上次中断遗留的文件;失败只记录告警不阻断同步。 + static Future _cleanSyncTempDirectory(String storagePath) async { + try { + final directory = Directory('$storagePath/$_syncTempDirectoryName'); + if (await directory.exists()) { + await directory.delete(recursive: true); + } + } catch (error, stack) { + logger.w( + '|$_tag|清理同步临时目录失败|error:${error.runtimeType}|stack:$stack|', + ); + } + } + + /// 分批并行预检本地人脸文件存在性,返回仍存在的绝对路径集合。 + static Future> _collectExistingFilePaths( + List local, + ) async { + final paths = local + .map((face) => face.path) + .whereType() + .where((path) => path.isNotEmpty) + .toSet(); + final existing = {}; + for (var start = 0; start < paths.length; start += _fileCheckBatchSize) { + final chunk = paths + .skip(start) + .take(_fileCheckBatchSize) + .toList(growable: false); + final checked = await Future.wait( + chunk.map( + (path) async => await File(path).exists() ? path : null, + ), + ); + existing.addAll(checked.whereType()); + } + return existing; + } + /// 生成人脸正式文件路径;版本戳确保新文件写入前旧文件始终可回滚。 static String _officialFilePath( ServiceFaceSyncRecord record, @@ -891,3 +1040,79 @@ class _FaceSyncAccumulator { qualityReportNote: qualityReportNote, ); } + +/// 图片预取器:在主流程逐条提取、注册期间按固定并发提前下载后续样本图片。 +/// +/// 下载失败的样本不缓存,由消费方按原有同步下载逻辑重试;预取文件命中后被 +/// 消费方接管改名,未消费的文件由 [dispose] 统一清理。 +class _FaceImagePrefetcher { + _FaceImagePrefetcher._(this._storagePath); + + /// 为需要重下图片的记录启动预取;集合为空时返回空避免空转。 + static _FaceImagePrefetcher? start( + List records, + String storagePath, + ) { + if (records.isEmpty) return null; + return _FaceImagePrefetcher._(storagePath).._download(records); + } + + /// 预取图片写入的同步临时目录根路径。 + final String _storagePath; + + /// sampleId 到已下载临时文件的缓存;取走即移除。 + final _cache = {}; + + /// 在途下载任务,[dispose] 等待其结束后再清理。 + Future? _running; + + /// 预取文件被主流程命中的次数,仅用于同步日志统计。 + int _hitCount = 0; + + /// 命中次数,供日志观察预取收益。 + int get hitCount => _hitCount; + + /// 以固定并发依次下载全部预取记录。 + void _download(List records) { + var next = 0; + Future worker() async { + while (true) { + final index = next++; + if (index >= records.length) return; + final record = records[index]; + try { + _cache[record.sampleId] = await SyncFaceTool._downloadTemporaryFace( + record, + _storagePath, + ); + } catch (_) { + // 预取失败不缓存,消费方按原逻辑同步下载重试。 + } + } + } + + _running = Future.wait( + List.generate( + SyncFaceTool._downloadPrefetchConcurrency, + (_) => worker(), + ), + ); + } + + /// 取走一条已预取的临时文件;未就绪或预取失败时返回空。 + File? take(String sampleId) { + final file = _cache.remove(sampleId); + if (file != null) _hitCount++; + return file; + } + + /// 等待在途下载结束并清理未消费的预取文件。 + Future dispose() async { + await _running; + final files = _cache.values.toList(growable: false); + _cache.clear(); + for (final file in files) { + await SyncFaceTool.delFaceByStorage(filePath: file.path); + } + } +} diff --git a/lib/common/utils/sync/sync_order_tool.dart b/lib/common/utils/sync/sync_order_tool.dart index 95e7496..f7958de 100644 --- a/lib/common/utils/sync/sync_order_tool.dart +++ b/lib/common/utils/sync/sync_order_tool.dart @@ -6,37 +6,46 @@ import 'package:pj_supermarket_cloud/common/index.dart'; abstract class SyncOrderTool { static const _tag = "sync订单工具"; - // 同步离线订单 - static Future syncOfflineOrder() async { + // 同步离线订单;返回本轮上传成功并回写本地状态的订单数。 + // + // 返回 null 表示本地没有待同步订单;返回 0 表示有待同步订单但后台一单都 + // 未确认(通常被后端用户/钱包校验静默跳过),两者在任务结果中需要区分。 + // + // 不能按设备当前 sysType 过滤:订单的 sysType 来自下单时的收银页模式, + // 而收银页模式与 sysSetting.sysType 可能脱节(如后台设备配置把本地 sysType + // 覆盖成另一模式),按当前模式过滤会让欠账订单永远查不到且任务假成功。 + // 订单 JSON 自带 sysType,后端按订单自身的 sysType 建单,无需设备当前模式。 + static Future syncOfflineOrder() async { final app = Get.find(); final sysSetting = await AppSettingTool.getSysSetting(); - final sysType = sysSetting.sysType.name; final orderDao = app.dataBase.getOrderDao; - // 查询离线订单 + // 查询近 30 天全部模式的离线订单;'-1' 表示不过滤 sysType。 final startDate = DateTime.now().subtract(const Duration(days: 30)); final endDate = DateTime.now().add(const Duration(days: 1)); final list = await orderDao.findOffline( - sysType, + '-1', startDate, endDate, ); if (ObjectUtil.isEmptyList(list)) { - logger.i("|$_tag|错误,没有需要同步的离线订单"); - return; + logger.i("|$_tag|没有需要同步的离线订单"); + return null; } + var syncedCount = 0; final chunkList = chunkArray(list, 5); for (var v in chunkList) { - await _syncOfflineGroupOrder(v); + syncedCount += await _syncOfflineGroupOrder(v); } // 更新同步时间 sysSetting.offlineOrderSyncTime = DateTime.now(); await AppSettingTool.setSysSetting(sysSetting); + return syncedCount; } - // 同步一组离线订单 - static Future _syncOfflineGroupOrder(List list) async { + // 同步一组离线订单;返回本组上传成功并回写本地状态的订单数。 + static Future _syncOfflineGroupOrder(List list) async { final app = Get.find(); final sysSetting = await AppSettingTool.getSysSetting(); @@ -72,12 +81,16 @@ abstract class SyncOrderTool { if (data == null) throw Error(); final batchEditCount = await handEditOfflineStatus(data); logger.i("|$_tag|同步成功的数量为:|$batchEditCount|ids为:|$data|"); + return batchEditCount; } catch (e) { logger.e("|$_tag|同步一组离线订单意外的错误error:$e"); } + return 0; } static Future handEditOfflineStatus(List codes) async { + // 后端可能返回空列表(订单被后端静默跳过),空 IN () 是 SQL 语法错误。 + if (ObjectUtil.isEmptyList(codes)) return 0; final app = Get.find(); final placeholders = List.generate(codes.length, (index) => '?').join(', '); final sql = ''' diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index 3fc418d..ec51800 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -25,9 +25,9 @@ class Constants { static const pwdEncKey = 'pigxpigxpigxpigx'; /// 服务 url 线上 - static const apiUrl = 'https://www.pjzhhq.cn/api'; + // static const apiUrl = 'https://www.pjzhhq.cn/api'; - // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; /// 静态资源 url static const ossUrl = apiUrl; diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index 33ffc2c..65e8aa8 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -774,17 +774,42 @@ class SelectPayTypeDialogController extends GetxController { String msg = "人脸插件未激活,请前往'设置'->'人脸设置'处理"; throw Exception(msg); } - if (status!.memoryCount != databaseFaceCount || !status.engineReady) { - // 引擎与本地库不一致时注册正好可以就地修复,同样进入注册引导而不是 - // 引导收银员离开弹框去设置页。 - isRecognition.value = false; - isReidentifying.value = false; - faceWorkspaceMessage.value = ''; - if (faceSetupPhase.value != _FaceSetupPhase.running) { - faceSetupPhase.value = _FaceSetupPhase.empty; - faceSetupMessage.value = ''; + // 一致性按样本集合判定而不是数量相等:空特征历史行、同步的成对增删 + // 都会自然造成数量偏差,只有引擎真的丢样本或存在脏样本时才需要修复。 + // 内存库为空但表里仍有行时(残缺行占库或引擎异常清零)同样需要修复, + // 否则会拿着空引擎开始一次永远匹配不上的识别。 + if (!_ownsFaceDispatch(requestId)) return; + if (!status!.engineReady || + !status.libraryConsistent || + (status.memoryCount == 0 && databaseFaceCount > 0)) { + // 部门人脸同步正在成对写库时集合会短暂不一致;等同步结束后再判定, + // 避免把瞬态窗口当成库损坏去触发整库清空重装。 + if (_departmentFaceSyncRunning()) { + final settled = await _awaitDepartmentFaceSyncIdle(requestId); + if (!_ownsFaceDispatch(requestId)) return; + if (!settled) { + throw Exception("人脸数据同步中,请稍后再发起人脸支付"); + } + status = await AppFaceTool.getStatus(); + if (!_ownsFaceDispatch(requestId)) return; + if (status == null) { + throw Exception('无法读取人脸状态,请稍后重试'); + } + } + if (!status.engineReady || + !status.libraryConsistent || + (status.memoryCount == 0 && databaseFaceCount > 0)) { + // 引擎与本地库的样本集合不一致时注册正好可以就地修复,同样进入 + // 注册引导而不是引导收银员离开弹框去设置页。 + isRecognition.value = false; + isReidentifying.value = false; + faceWorkspaceMessage.value = ''; + if (faceSetupPhase.value != _FaceSetupPhase.running) { + faceSetupPhase.value = _FaceSetupPhase.empty; + faceSetupMessage.value = ''; + } + return; } - return; } final handle = await service.start( scene: FaceRecognitionScene.cashier, @@ -815,6 +840,32 @@ class SelectPayTypeDialogController extends GetxController { } } + /// 部门人脸同步是否正在执行;同步期间数据库与内存库成对变化,集合一致 + /// 性判定必须等它结束才可靠。只看运行中:排队中的同步在等安全窗口, + /// 而弹框本身开着就会让它一直排队,不能据此阻塞人脸支付。 + bool _departmentFaceSyncRunning() { + return SyncCheckTool.instance.runningTasks.contains( + ScheduledTaskType.departmentFaceSync, + ); + } + + /// 等待部门人脸同步结束;超时或本轮请求失效返回 false 交由调用方提示重试。 + Future _awaitDepartmentFaceSyncIdle( + int requestId, { + int maxWaitSeconds = 120, + }) async { + var waited = 0; + while (_departmentFaceSyncRunning() && waited < maxWaitSeconds) { + faceWorkspaceMessage.value = waited == 0 + ? '人脸数据同步中,请稍候' + : '人脸数据同步中,请稍候($waited秒)'; + await Future.delayed(const Duration(seconds: 2)); + if (!_ownsFaceDispatch(requestId)) return false; + waited += 2; + } + return !_departmentFaceSyncRunning(); + } + /// 检查异步人脸注册结果是否仍属于未被关闭或切换的本轮就地注册请求。 bool _ownsFaceSetup(int requestId) { return requestId == _faceSetupDispatchId; @@ -3874,15 +3925,15 @@ class _PaymentDialogButton extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(12), child: SizedBox( - height: 56.h, + height: 72.h, child: Center( child: Text( label, - style: FTheme.of(context).typography.base.copyWith( + style: FTheme.of(context).typography.lg.copyWith( color: foreground, fontWeight: FontWeight.w700, ), - ), + ), ), ), ),