From c6f9ffb9982e33a87b81ff438f89c55418c67549 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 26 Oct 2026 17:46:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=B4=AD=E4=B9=B0=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=20fix:=E8=B4=AD=E4=B9=B0=E5=89=AF=E5=B1=8F?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E7=94=A8=E6=88=B7=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../main/assets/database/user_cache_v16.sql | 136 +++++ .../facediscern/database/DatabaseHelper.java | 109 +++- lib/common/services/database/database.dart | 5 +- lib/common/services/database/database.g.dart | 28 +- .../services/database/database_migration.dart | 166 ++++-- .../services/database/entity/face_entity.dart | 2 + .../services/database/entity/user_entity.dart | 7 +- lib/common/utils/sync/sync_face_tool.dart | 36 -- lib/common/utils/sync/sync_user_tool.dart | 195 +++---- lib/pages/setting/face_manage_controller.dart | 78 --- lib/pages/setting/widgets/setting_face.dart | 8 - .../models/customer_display_models.dart | 9 + .../utils/customer_display_coordinator.dart | 9 + .../common/widgets/sub_screen_header.dart | 39 +- lib/sub_screen/page/sub_app.dart | 16 +- pubspec.yaml | 3 +- test/customer_display_balance_test.dart | 171 ++++++ .../customer_display_balance_widget_test.dart | 108 ++++ test/user_cache_consistency_test.dart | 519 ++++++++++++++++++ 20 files changed, 1357 insertions(+), 289 deletions(-) create mode 100644 android/app/src/main/assets/database/user_cache_v16.sql create mode 100644 test/customer_display_balance_test.dart create mode 100644 test/customer_display_balance_widget_test.dart create mode 100644 test/user_cache_consistency_test.dart diff --git a/README.md b/README.md index 4ab120d..ae1a8fc 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~ ## flutter打包 ``` -flutter build apk --build-name 2.1.8 --build-number 218 +flutter build apk --build-name 2.1.9 --build-number 219 --build-name为版本号(展示) --build-number为版本号(内部比较) ``` diff --git a/android/app/src/main/assets/database/user_cache_v16.sql b/android/app/src/main/assets/database/user_cache_v16.sql new file mode 100644 index 0000000..f0950a2 --- /dev/null +++ b/android/app/src/main/assets/database/user_cache_v16.sql @@ -0,0 +1,136 @@ +-- Flutter and Android execute these statements inside the caller's transaction. +-- Keep this asset free of semicolons within comments and string literals. +DROP TABLE IF EXISTS temp.user_cache_v16_duplicates; +DROP TABLE IF EXISTS temp.user_cache_v16_winners; + +CREATE TEMP TABLE user_cache_v16_duplicates AS +SELECT * FROM user_record +WHERE userId IN ( + SELECT userId FROM user_record GROUP BY userId HAVING COUNT(*) > 1 +); + +CREATE INDEX temp.index_user_cache_v16_duplicates_userId +ON user_cache_v16_duplicates(userId); + +CREATE TEMP TABLE user_cache_v16_winners AS +SELECT current.* FROM user_cache_v16_duplicates AS current +WHERE current.id = ( + SELECT candidate.id FROM user_cache_v16_duplicates AS candidate + WHERE candidate.userId = current.userId + ORDER BY COALESCE( + julianday(NULLIF(candidate.updateTime, '')), + julianday(NULLIF(candidate.createTime, '')), + 0 + ) DESC, candidate.id DESC + LIMIT 1 +); + +CREATE UNIQUE INDEX temp.index_user_cache_v16_winners_userId +ON user_cache_v16_winners(userId); + +CREATE UNIQUE INDEX temp.index_user_cache_v16_winners_id +ON user_cache_v16_winners(id); + +UPDATE user_cache_v16_winners +SET nickname = COALESCE(( + SELECT candidate.nickname FROM user_cache_v16_duplicates AS candidate + WHERE candidate.userId = user_cache_v16_winners.userId + AND TRIM(candidate.nickname) != '' + AND TRIM(candidate.nickname) != '未知' + AND INSTR(candidate.nickname, '*') = 0 + ORDER BY COALESCE( + julianday(NULLIF(candidate.updateTime, '')), + julianday(NULLIF(candidate.createTime, '')), + 0 + ) DESC, candidate.id DESC + LIMIT 1 + ), nickname), + mobile = COALESCE(( + SELECT candidate.mobile FROM user_cache_v16_duplicates AS candidate + WHERE candidate.userId = user_cache_v16_winners.userId + AND TRIM(COALESCE(candidate.mobile, '')) != '' + ORDER BY COALESCE( + julianday(NULLIF(candidate.updateTime, '')), + julianday(NULLIF(candidate.createTime, '')), + 0 + ) DESC, candidate.id DESC + LIMIT 1 + ), mobile), + avatar = COALESCE(( + SELECT candidate.avatar FROM user_cache_v16_duplicates AS candidate + WHERE candidate.userId = user_cache_v16_winners.userId + AND TRIM(COALESCE(candidate.avatar, '')) != '' + ORDER BY COALESCE( + julianday(NULLIF(candidate.updateTime, '')), + julianday(NULLIF(candidate.createTime, '')), + 0 + ) DESC, candidate.id DESC + LIMIT 1 + ), avatar), + createTime = COALESCE(( + SELECT candidate.createTime FROM user_cache_v16_duplicates AS candidate + WHERE candidate.userId = user_cache_v16_winners.userId + AND julianday(NULLIF(candidate.createTime, '')) IS NOT NULL + ORDER BY julianday(candidate.createTime) ASC, candidate.id ASC + LIMIT 1 + ), createTime); + +-- Legacy ListDiscountsConverter encodes an accepted empty rule list as NULL. +-- A valid rule timestamp therefore remains authoritative even for NULL or empty rules. +UPDATE user_cache_v16_winners +SET discountsList = ( + SELECT candidate.discountsList FROM user_cache_v16_duplicates AS candidate + WHERE candidate.userId = user_cache_v16_winners.userId + AND julianday(NULLIF(candidate.discountsUpdateTime, '')) IS NOT NULL + ORDER BY julianday(candidate.discountsUpdateTime) DESC, candidate.id DESC + LIMIT 1 + ), + discountsUpdateTime = ( + SELECT candidate.discountsUpdateTime + FROM user_cache_v16_duplicates AS candidate + WHERE candidate.userId = user_cache_v16_winners.userId + AND julianday(NULLIF(candidate.discountsUpdateTime, '')) IS NOT NULL + ORDER BY julianday(candidate.discountsUpdateTime) DESC, candidate.id DESC + LIMIT 1 + ) +WHERE EXISTS ( + SELECT 1 FROM user_cache_v16_duplicates AS candidate + WHERE candidate.userId = user_cache_v16_winners.userId + AND julianday(NULLIF(candidate.discountsUpdateTime, '')) IS NOT NULL +); + +UPDATE user_record +SET nickname = ( + SELECT merged.nickname FROM user_cache_v16_winners AS merged + WHERE merged.id = user_record.id + ), + mobile = ( + SELECT merged.mobile FROM user_cache_v16_winners AS merged + WHERE merged.id = user_record.id + ), + avatar = ( + SELECT merged.avatar FROM user_cache_v16_winners AS merged + WHERE merged.id = user_record.id + ), + discountsList = ( + SELECT merged.discountsList FROM user_cache_v16_winners AS merged + WHERE merged.id = user_record.id + ), + discountsUpdateTime = ( + SELECT merged.discountsUpdateTime FROM user_cache_v16_winners AS merged + WHERE merged.id = user_record.id + ), + createTime = ( + SELECT merged.createTime FROM user_cache_v16_winners AS merged + WHERE merged.id = user_record.id + ) +WHERE id IN (SELECT id FROM user_cache_v16_winners); + +DELETE FROM user_record +WHERE id IN (SELECT id FROM user_cache_v16_duplicates) + AND id NOT IN (SELECT id FROM user_cache_v16_winners); + +DROP TABLE user_cache_v16_winners; +DROP TABLE user_cache_v16_duplicates; + +CREATE UNIQUE INDEX IF NOT EXISTS index_user_record_userId ON user_record(userId); 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 c59041b..88230a3 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 @@ -7,7 +7,11 @@ import android.database.sqlite.SQLiteOpenHelper; import android.os.Environment; import android.util.Log; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; public class DatabaseHelper extends SQLiteOpenHelper { @@ -15,17 +19,22 @@ public class DatabaseHelper extends SQLiteOpenHelper { private static final String DATABASE_PATH = "pj_cashier/database"; private static final String DATABASE_NAME = "cash_register.db"; + private static final String USER_CACHE_MIGRATION = "database/user_cache_v16.sql"; + private static final String USER_ID_INDEX = "index_user_record_userId"; + private final Context appContext; /** * 必须与 Flutter Floor 的 @Database 版本保持同步 * (lib/common/services/database/database.dart)。 * 历史上此值曾误用兄弟项目编号 13,把 user_version 提前盖章到 13 导致 * Floor 跳过 11-13 区间迁移;双方现已一致跳到 14,后续升级必须同时修改。 * 15:与 Floor migration14To15 一致,为收银热表补建二级索引。 + * 16:共用 SQL 合并重复人员缓存并约束 userId 唯一,补齐已盖章但遗漏的结构。 */ - private static final int DATABASE_VERSION = 15; + private static final int DATABASE_VERSION = 16; public DatabaseHelper(Context context) { super(context, getCustomDatabasePath(context), null, DATABASE_VERSION); + appContext = context.getApplicationContext(); } private static String getCustomDatabasePath(Context context) { @@ -38,10 +47,11 @@ public class DatabaseHelper extends SQLiteOpenHelper { @Override public void onCreate(SQLiteDatabase db) { Log.i(TAG, "onCreate"); - Cursor cursor = db.query("face", null, null, null, null, null, null); - while (cursor.moveToNext()) { - int id = cursor.getColumnIndex("id"); - Log.i(TAG, "查询到人脸数据库的数据id:" + id); + try (Cursor cursor = db.query("face", null, null, null, null, null, null)) { + while (cursor.moveToNext()) { + int id = cursor.getColumnIndex("id"); + Log.i(TAG, "查询到人脸数据库的数据id:" + id); + } } } @@ -56,6 +66,95 @@ public class DatabaseHelper extends SQLiteOpenHelper { if (oldVersion < 15) { createHotPathIndexes(db); } + if (oldVersion < 16) { + // SQLiteOpenHelper 已包围升级事务,失败会回滚,禁止先盖章再由 Flutter 补救。 + repairCurrentSchema(db); + } + } + + @Override + public void onOpen(SQLiteDatabase db) { + super.onOpen(db); + if (db.isReadOnly() || !needsSchemaRepair(db)) return; + // 兼容版本号已到 16、实际字段或索引仍缺失的历史库。 + db.beginTransaction(); + try { + repairCurrentSchema(db); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private boolean needsSchemaRepair(SQLiteDatabase db) { + for (String column : new String[]{"qualityScore", "qualityStatus", "qualityCheckedAt", + "qualityErrorCode", "qualityReportedAt"}) { + if (!hasColumn(db, "face", column)) return true; + } + for (String index : new String[]{"index_face_qualityStatus_qualityScore", + "index_supermarket_sku_barCode", "index_supermarket_sku_spuId", + "index_supermarket_spu_classifyServiceUniqueCode_delFlag", + "index_supermarket_spu_serviceUniqueCode", "index_face_sampleId", + "index_face_userId"}) { + if (!hasIndex(db, index)) return true; + } + return !hasUniqueUserIdIndex(db); + } + + private void repairCurrentSchema(SQLiteDatabase db) { + migrateFaceQuality(db); + migrateFaceQualityReport(db); + createHotPathIndexes(db); + if (!hasUniqueUserIdIndex(db)) { + // IF NOT EXISTS 不能修复同名但非唯一或列错误的索引。 + db.execSQL("DROP INDEX IF EXISTS " + USER_ID_INDEX); + executeUserCacheMigration(db); + } + } + + private boolean hasIndex(SQLiteDatabase db, String name) { + try (Cursor cursor = db.rawQuery( + "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?", new String[]{name})) { + return cursor.moveToFirst(); + } + } + + private boolean hasUniqueUserIdIndex(SQLiteDatabase db) { + boolean unique = false; + try (Cursor cursor = db.rawQuery("PRAGMA index_list(user_record)", null)) { + int name = cursor.getColumnIndexOrThrow("name"); + int uniqueColumn = cursor.getColumnIndexOrThrow("unique"); + int partial = cursor.getColumnIndex("partial"); + while (cursor.moveToNext()) { + if (USER_ID_INDEX.equals(cursor.getString(name))) { + unique = cursor.getInt(uniqueColumn) == 1 + && (partial < 0 || cursor.getInt(partial) == 0); + break; + } + } + } + if (!unique) return false; + try (Cursor cursor = db.rawQuery("PRAGMA index_info(" + USER_ID_INDEX + ")", null)) { + int name = cursor.getColumnIndexOrThrow("name"); + return cursor.moveToFirst() && "userId".equals(cursor.getString(name)) + && !cursor.moveToNext(); + } + } + + private void executeUserCacheMigration(SQLiteDatabase db) { + // 与 Flutter rootBundle 读取同一个资产,避免两个开库入口的数据合并规则分叉。 + try (InputStream input = appContext.getAssets().open(USER_CACHE_MIGRATION); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[4096]; + int length; + while ((length = input.read(buffer)) != -1) output.write(buffer, 0, length); + String script = new String(output.toByteArray(), StandardCharsets.UTF_8); + for (String statement : script.split(";")) { + if (!statement.trim().isEmpty()) db.execSQL(statement); + } + } catch (IOException error) { + throw new IllegalStateException("无法读取人员缓存升级 SQL,数据库升级已中止", error); + } } /** diff --git a/lib/common/services/database/database.dart b/lib/common/services/database/database.dart index d8ede74..2d13e9a 100644 --- a/lib/common/services/database/database.dart +++ b/lib/common/services/database/database.dart @@ -48,8 +48,8 @@ const typeConverters = [ // 版本必须与原生 DatabaseHelper.DATABASE_VERSION 同步修改 // (android/.../facediscern/database/DatabaseHelper.java)。 // 11-13 区间曾被原生历史版本以 13 提前盖章 user_version 占用,Flutter 侧从 10 直接跳到 14。 -// 15:为收银热表补建二级索引(sku/spu/face),迁移 DDL 与实体 @Index 声明同名。 -@Database(version: 15, entities: entities) +// 16:合并重复人员缓存并约束 userId 唯一,同时修复历史版本号与实际结构不一致的库。 +@Database(version: 16, entities: entities) @TypeConverters(typeConverters) abstract class AppDatabase extends FloorDatabase { BroadcastDao get getBroadcastDao; @@ -81,6 +81,7 @@ abstract class AppDatabase extends FloorDatabase { logger.i('数据库已创建,版本: $version'); }, onOpen: (database) async { + await DatabaseMigration.repairSchema(database); logger.i('数据库已打开'); }, onUpgrade: (database, startVersion, endVersion) { diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index eb4c81a..ac7e7eb 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: 15, + version: 16, onConfigure: (database) async { await database.execute('PRAGMA foreign_keys = ON'); await callback?.onConfigure?.call(database); @@ -171,10 +171,14 @@ class _$AppDatabase extends AppDatabase { 'CREATE INDEX `index_supermarket_spu_serviceUniqueCode` ON `supermarket_spu` (`serviceUniqueCode`)'); await database.execute( 'CREATE UNIQUE INDEX `index_print_history_servicePrintId` ON `print_history` (`servicePrintId`)'); + await database.execute( + 'CREATE UNIQUE INDEX `index_user_record_userId` ON `user_record` (`userId`)'); await database.execute( 'CREATE INDEX `index_face_sampleId` ON `face` (`sampleId`)'); await database .execute('CREATE INDEX `index_face_userId` ON `face` (`userId`)'); + await database.execute( + 'CREATE INDEX `index_face_qualityStatus_qualityScore` ON `face` (`qualityStatus`, `qualityScore`)'); await database.execute( 'CREATE INDEX `index_scheduled_task_log_taskCode_startedAt` ON `scheduled_task_log` (`taskCode`, `startedAt`)'); await database.execute( @@ -1757,6 +1761,17 @@ class _$UserDao extends UserDao { final DeletionAdapter _userEntityDeletionAdapter; + @override + Future updateDiscounts( + String userId, + String rulesJson, + DateTime updatedAt, + ) async { + await _queryAdapter.queryNoReturn( + 'UPDATE user_record SET discountsList = ?2, discountsUpdateTime = ?3 WHERE userId = ?1', + arguments: [userId, rulesJson, _dateTimeConverter.encode(updatedAt)]); + } + @override Future findByUserId(String userId) async { return _queryAdapter.query( @@ -1820,17 +1835,6 @@ class _$UserDao extends UserDao { await _queryAdapter.queryNoReturn('DELETE FROM user_record'); } - @override - Future updateDiscounts( - String userId, - String rulesJson, - DateTime updatedAt, - ) async { - await _queryAdapter.queryNoReturn( - 'UPDATE user_record SET discountsList = ?2, discountsUpdateTime = ?3 WHERE userId = ?1', - arguments: [userId, rulesJson, _dateTimeConverter.encode(updatedAt)]); - } - @override Future findCount(String keyword) async { return _queryAdapter.query( diff --git a/lib/common/services/database/database_migration.dart b/lib/common/services/database/database_migration.dart index 22afecf..4ac0a63 100644 --- a/lib/common/services/database/database_migration.dart +++ b/lib/common/services/database/database_migration.dart @@ -1,7 +1,26 @@ import 'package:floor/floor.dart'; +import 'package:flutter/services.dart'; import 'package:sqflite/sqflite.dart' as sqflite; abstract class DatabaseMigration { + static const _userIdIndex = 'index_user_record_userId'; + static const _requiredFaceQualityColumns = { + 'qualityScore', + 'qualityStatus', + 'qualityCheckedAt', + 'qualityErrorCode', + 'qualityReportedAt', + }; + static const _requiredHotIndexes = { + 'index_face_qualityStatus_qualityScore', + 'index_supermarket_sku_barCode', + 'index_supermarket_sku_spuId', + 'index_supermarket_spu_classifyServiceUniqueCode_delFlag', + 'index_supermarket_spu_serviceUniqueCode', + 'index_face_sampleId', + 'index_face_userId', + }; + /// 从版本1到版本2的迁移:添加productNamePy字段 static Migration migration1To2 = Migration(1, 2, (database) async { // 先检查列是否存在,不存在才添加 @@ -235,7 +254,6 @@ abstract class DatabaseMigration { '''); }); - /// 从版本9到版本10的迁移(迁移自 biz_xfj_cloud):新增定时任务/任务日志/文件上传 /// 记录/订单证据上传队列表,并为 face 表补充在库质量检测列。 static Migration migration9To10 = Migration(9, 10, (database) async { @@ -357,41 +375,13 @@ abstract class DatabaseMigration { ); // face 表补充在库质量检测列(迁移自 biz_xfj_cloud 版本11到12迁移)。 - final columns = await database.rawQuery('PRAGMA table_info(face)'); - final names = columns.map((column) => column['name']).toSet(); - if (!names.contains('qualityScore')) { - await database.execute('ALTER TABLE face ADD COLUMN qualityScore REAL'); - } - if (!names.contains('qualityStatus')) { - await database.execute( - "ALTER TABLE face ADD COLUMN qualityStatus TEXT NOT NULL DEFAULT 'pending'", - ); - } - if (!names.contains('qualityCheckedAt')) { - await database.execute( - 'ALTER TABLE face ADD COLUMN qualityCheckedAt TEXT', - ); - } - if (!names.contains('qualityErrorCode')) { - await database.execute( - 'ALTER TABLE face ADD COLUMN qualityErrorCode INTEGER', - ); - } - await database.execute( - "UPDATE face SET qualityStatus = 'pending' WHERE qualityStatus IS NULL OR qualityStatus NOT IN ('pending', 'detected', 'failed')", - ); - // 项目现有 DateTimeEmptyConverter 以空字符串表示业务空值;迁移旧记录时同步规范化, - // 避免 Floor 生成代码把 SQLite NULL 强转为 String 后读取失败。 - await database.execute( - "UPDATE face SET qualityCheckedAt = '' WHERE qualityCheckedAt IS NULL", - ); - await database.execute( - 'CREATE INDEX IF NOT EXISTS index_face_qualityStatus_qualityScore ON face(qualityStatus, qualityScore)', - ); + await _upgradeFaceQuality(database); // print_history 升级为幂等打印任务队列(迁移自 biz_xfj_cloud 版本12到13迁移)。 - final printColumns = await database.rawQuery('PRAGMA table_info(print_history)'); - final printColumnNames = printColumns.map((column) => column['name']).toSet(); + final printColumns = + await database.rawQuery('PRAGMA table_info(print_history)'); + final printColumnNames = + printColumns.map((column) => column['name']).toSet(); if (!printColumnNames.contains('printType')) { await database.execute( "ALTER TABLE print_history ADD COLUMN printType TEXT NOT NULL DEFAULT '1'"); @@ -409,7 +399,8 @@ abstract class DatabaseMigration { 'ALTER TABLE print_history ADD COLUMN attemptCount INTEGER NOT NULL DEFAULT 0'); } if (!printColumnNames.contains('lastError')) { - await database.execute('ALTER TABLE print_history ADD COLUMN lastError TEXT'); + await database + .execute('ALTER TABLE print_history ADD COLUMN lastError TEXT'); } if (!printColumnNames.contains('resultPending')) { await database.execute( @@ -446,9 +437,109 @@ abstract class DatabaseMigration { await _createHotPathIndexes(database); }); + /// 修复被提前标记为版本15的旧库,并将一人多条的人员缓存合并为一条。 + /// Floor 的升级回调已位于事务中,不能在这里再调用 Database.transaction。 + static Migration migration15To16 = Migration(15, 16, (database) async { + await _repairSchema(database); + }); + + /// 每次打开都核验实际结构,避免版本号已到16时永久跳过历史缺失项。 + /// 正常结构只读 schema,不扫描人员缓存、人脸特征或重复执行 DDL。 + static Future repairSchema(sqflite.Database database) async { + if (!await _needsSchemaRepair(database)) return; + await database.transaction((transaction) => _repairSchema(transaction)); + } + + static Future _needsSchemaRepair( + sqflite.DatabaseExecutor database, + ) async { + final columns = await database.rawQuery('PRAGMA table_info(face)'); + final columnNames = columns.map((column) => column['name']).toSet(); + if (!_requiredFaceQualityColumns.every(columnNames.contains)) return true; + + final indexes = await database.rawQuery( + "SELECT name FROM sqlite_master WHERE type = 'index'", + ); + final indexNames = indexes.map((index) => index['name']).toSet(); + if (!_requiredHotIndexes.every(indexNames.contains)) return true; + return !await _hasUniqueUserIdIndex(database); + } + + static Future _hasUniqueUserIdIndex( + sqflite.DatabaseExecutor database, + ) async { + final indexes = await database.rawQuery('PRAGMA index_list(user_record)'); + for (final index in indexes) { + if (index['name'] == _userIdIndex && + index['unique'] == 1 && + (index['partial'] ?? 0) == 0) { + final columns = + await database.rawQuery('PRAGMA index_info($_userIdIndex)'); + return columns.length == 1 && columns.single['name'] == 'userId'; + } + } + return false; + } + + static Future _repairSchema(sqflite.DatabaseExecutor database) async { + await _upgradeFaceQuality(database); + await _upgradeFaceQualityReport(database); + await _createHotPathIndexes(database); + if (await _hasUniqueUserIdIndex(database)) return; + + // 同名但非唯一、部分索引或索引列错误,不能被 IF NOT EXISTS 静默跳过。 + await database.execute('DROP INDEX IF EXISTS $_userIdIndex'); + + // 与原生 AssetManager 共享同一份SQL,合并规则和钱包快照选择不能分叉。 + final sql = await rootBundle.loadString( + 'android/app/src/main/assets/database/user_cache_v16.sql', + ); + for (final statement in sql.split(';')) { + if (statement.trim().isNotEmpty) await database.execute(statement); + } + } + + /// 单独补齐基础质量字段,不重复执行旧迁移中的其他表和订单清理操作。 + static Future _upgradeFaceQuality( + sqflite.DatabaseExecutor database, + ) async { + final columns = await database.rawQuery('PRAGMA table_info(face)'); + final names = columns.map((column) => column['name']).toSet(); + if (!names.contains('qualityScore')) { + await database.execute('ALTER TABLE face ADD COLUMN qualityScore REAL'); + } + if (!names.contains('qualityStatus')) { + await database.execute( + "ALTER TABLE face ADD COLUMN qualityStatus TEXT NOT NULL DEFAULT 'pending'", + ); + } + if (!names.contains('qualityCheckedAt')) { + await database.execute( + 'ALTER TABLE face ADD COLUMN qualityCheckedAt TEXT', + ); + } + if (!names.contains('qualityErrorCode')) { + await database.execute( + 'ALTER TABLE face ADD COLUMN qualityErrorCode INTEGER', + ); + } + await database.execute( + "UPDATE face SET qualityStatus = 'pending' WHERE qualityStatus IS NULL OR qualityStatus NOT IN ('pending', 'detected', 'failed')", + ); + // 与原生兜底迁移保留相同的历史空时间存储语义。 + await database.execute( + "UPDATE face SET qualityCheckedAt = '' WHERE qualityCheckedAt IS NULL", + ); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_face_qualityStatus_qualityScore ON face(qualityStatus, qualityScore)', + ); + } + /// 收银热路径二级索引:扫码枪按条码/自编码查询、商品分页按分类过滤、 /// SKU JOIN spu、人脸识别按样本回查与按用户删档。 - static Future _createHotPathIndexes(sqflite.Database database) async { + static Future _createHotPathIndexes( + sqflite.DatabaseExecutor database, + ) async { await database.execute( 'CREATE INDEX IF NOT EXISTS index_supermarket_sku_barCode ' 'ON supermarket_sku(barCode)', @@ -476,7 +567,7 @@ abstract class DatabaseMigration { /// 补齐质量上报确认列并规范化 NULL 特征;全部操作幂等,可被多条迁移重复执行。 static Future _upgradeFaceQualityReport( - sqflite.Database database, + sqflite.DatabaseExecutor database, ) async { final columns = await database.rawQuery('PRAGMA table_info(face)'); final names = columns.map((column) => column['name']).toSet(); @@ -505,6 +596,7 @@ abstract class DatabaseMigration { migration10To14, migration13To14, migration14To15, + migration15To16, ]; } } diff --git a/lib/common/services/database/entity/face_entity.dart b/lib/common/services/database/entity/face_entity.dart index 504504f..6ed14da 100644 --- a/lib/common/services/database/entity/face_entity.dart +++ b/lib/common/services/database/entity/face_entity.dart @@ -12,6 +12,8 @@ part 'face_entity.g.dart'; // 人脸识别 resolve 按样本回查、同步按用户删档是高频路径,避免 SCAN face。 Index(value: ["sampleId"]), Index(value: ["userId"]), + // 与历史迁移保持一致,新建数据库也直接具备质量检测索引。 + Index(value: ["qualityStatus", "qualityScore"]), ], ) class FaceEntity extends Equatable { diff --git a/lib/common/services/database/entity/user_entity.dart b/lib/common/services/database/entity/user_entity.dart index 01e2171..0f73494 100644 --- a/lib/common/services/database/entity/user_entity.dart +++ b/lib/common/services/database/entity/user_entity.dart @@ -65,7 +65,12 @@ class DiscountsItem { } @JsonSerializable() -@Entity(tableName: "user_record") +@Entity( + tableName: "user_record", + indices: [ + Index(name: 'index_user_record_userId', value: ["userId"], unique: true), + ], +) class UserEntity extends Equatable { /// 自增ID @PrimaryKey(autoGenerate: true) diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart index 12822d2..41076d4 100644 --- a/lib/common/utils/sync/sync_face_tool.dart +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -276,19 +276,6 @@ abstract class SyncFaceTool { return byRowId.values.toList(); } - /// 供调试清理入口读取完整部门树,空部门也属于设备同步范围。 - static Future> getDepartmentScopeDeptIds() async { - if (!AppNetworkTool.isAutoNetwork()) { - throw const FaceSyncSnapshotException('网络不可用,无法获取部门范围'); - } - final setting = await AppSettingTool.getSysSetting(); - final merchantDeptId = setting.merchantDeptId?.trim(); - if (merchantDeptId == null || merchantDeptId.isEmpty) { - throw const FaceSyncSnapshotException('终端尚未绑定商户部门'); - } - return UserApi.getFaceDepartmentScope(merchantDeptId); - } - /// 拉取 V3 固定上界游标快照并校验页面连续性、记录唯一性和字段有效性。 /// /// [onProgress] 在每页合并后上报当前已获取条数;分页总数未知,回调总数为空。 @@ -848,23 +835,6 @@ abstract class SyncFaceTool { _deleteLocalFace(face, Get.find().dataBase.getFaceDao), ); - /// 按批量管线清理一组本地人脸(不触碰服务端数据),返回成功删除数。 - /// - /// 供人脸管理页的调试清理入口使用;同步孤儿删除走同一条 [_deleteFacesInBulk] 管线。 - static Future deleteLocalFaces( - List faces, { - void Function(int processed, int? total)? onProgress, - }) async { - final result = await AppFaceTool.runLibraryMutation( - () => _deleteFacesInBulk( - faces, - Get.find().dataBase.getFaceDao, - onProgress: onProgress, - ), - ); - return result.deleted; - } - /// 批量删除本地人脸的统一管线:并行改名 → 原生批量移除 → SQL 批量删除 → 并行清理文件。 /// /// 逐条删除实测约 664ms/条(每条包含独立的原生通道往返、SQLite 提交和多次 @@ -1366,12 +1336,6 @@ abstract class SyncFaceTool { /// 只处理目录顶层文件:修改时间超过一小时、不属于任何本地记录且扩展名为 /// 图片的文件视为历史孤儿删除;`.pending-delete` 残留同样清理。跳过全部 /// 子目录(同步临时目录与原生头像目录都不在顶层规则覆盖范围内)。 - static Future sweepFaceStorage() async { - final storagePath = await AppFileTool.getFacePath(); - if (storagePath == null || storagePath.isEmpty) return 0; - return _sweepFaceDirectory(storagePath); - } - static Future _sweepFaceDirectory(String storagePath) async { try { final directory = Directory(storagePath); diff --git a/lib/common/utils/sync/sync_user_tool.dart b/lib/common/utils/sync/sync_user_tool.dart index e58bbdb..3164958 100644 --- a/lib/common/utils/sync/sync_user_tool.dart +++ b/lib/common/utils/sync/sync_user_tool.dart @@ -1,5 +1,8 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:sqflite/sqflite.dart' as sqflite; + +enum _UserCacheWrite { added, updated, unchanged } /// 一次离线人员同步的不可变结果汇总。 class OfflineUserSyncSummary { @@ -46,35 +49,23 @@ abstract class SyncUserTool { /// 存储用户 static Future storageUser({required ServiceUser user}) async { try { - final app = Get.find(); - final userDao = app.dataBase.getUserDao; - final detail = await userDao.findByUserId(user.userId!); - if (detail == null) { - final obj = _parseStorageUser(user: user, createDate: true); - await userDao.save(obj); - logger.i("|$_tag|,存储用户,新增成功|${obj.toJson()}|"); - } else { - final obj = _parseStorageUser(user: user, updateDate: true); - obj.id = detail.id; - if (obj == detail) return; - // 规则查询可能与人员刷新并发,不能用旧快照整行回写覆盖新规则。 - await app.dataBase.database.update( - 'user_record', - { - 'nickname': obj.nickname, - 'mobile': obj.mobile, - 'avatar': obj.avatar, - 'walletCardNumber': obj.walletCardNumber, - 'walletId': obj.walletId, - 'walletTypeId': obj.walletTypeId, - 'walletBalance': obj.walletBalance, - 'canteenId': obj.canteenId, - 'updateTime': DateTimeConverter().encode(obj.updateTime!), - }, - where: 'id = ?', - whereArgs: [detail.id], - ); - logger.i("|$_tag|,存储用户,修改成功|${obj.toJson()}|"); + final userId = user.userId?.trim(); + if (userId == null || userId.isEmpty) { + throw ArgumentError('不能缓存缺少用户编号的人员'); + } + final result = await _storeUserSnapshot(UserEntity( + userId: userId, + nickname: user.username ?? '未知', + mobile: user.userMobile, + avatar: user.userAvatar, + walletCardNumber: user.walletCardNumber, + walletId: user.walletId, + walletTypeId: user.walletTypeId, + walletBalance: user.walletBalance, + canteenId: user.canteenId, + )); + if (result != _UserCacheWrite.unchanged) { + logger.i('|$_tag|存储用户成功|userId:$userId|${result.name}|'); } } catch (e, stack) { logger.e("|$_tag|,存储用户失败意外的错误error:|$e|stack:|$stack|"); @@ -99,29 +90,67 @@ abstract class SyncUserTool { ); } - static UserEntity _parseStorageUser({ - required ServiceUser user, - bool createDate = false, - bool updateDate = false, + /// 所有人员缓存入口共用同一事务,首次缓存的查询与插入不可交错。 + /// 数据库唯一索引兜底限制一人一条;更新仅触碰当前快照拥有的字段, + /// 不回写优惠列,也不让脱敏同步覆盖消费时取得的完整身份信息。 + static Future<_UserCacheWrite> _storeUserSnapshot( + UserEntity user, { + bool preserveIdentity = false, }) { - final userEntity = UserEntity( - userId: user.userId ?? "-1", - nickname: user.username ?? "未知", - mobile: user.userMobile, - avatar: user.userAvatar, - walletCardNumber: user.walletCardNumber, - walletId: user.walletId, - walletTypeId: user.walletTypeId, - walletBalance: user.walletBalance, - canteenId: user.canteenId, - ); - if (createDate) { - userEntity.createTime = DateTime.now(); - } - if (updateDate) { - userEntity.updateTime = DateTime.now(); - } - return userEntity; + final database = + Get.find().dataBase.database as sqflite.Database; + return database.transaction((transaction) async { + final rows = await transaction.query( + 'user_record', + where: 'userId = ?', + whereArgs: [user.userId], + ); + if (rows.length > 1) { + throw StateError('人员缓存存在重复,请重启应用完成数据库修复'); + } + final now = DateTimeConverter().encode(DateTime.now()); + final wallet = { + 'walletCardNumber': user.walletCardNumber, + 'walletId': user.walletId, + 'walletTypeId': user.walletTypeId, + 'walletBalance': user.walletBalance, + 'canteenId': user.canteenId, + }; + final identity = { + 'nickname': user.nickname, + 'mobile': user.mobile, + 'avatar': user.avatar, + }; + if (rows.isEmpty) { + await transaction.insert('user_record', { + 'userId': user.userId, + ...identity, + ...wallet, + 'createTime': now, + 'updateTime': now, + }); + return _UserCacheWrite.added; + } + final existing = rows.single; + final changes = { + if (!preserveIdentity) ...identity, + ...wallet, + }; + if (preserveIdentity && user.canteenId == null) { + changes.remove('canteenId'); + } + if (changes.entries + .every((entry) => existing[entry.key] == entry.value)) { + return _UserCacheWrite.unchanged; + } + await transaction.update( + 'user_record', + {...changes, 'updateTime': now}, + where: 'userId = ?', + whereArgs: [user.userId], + ); + return _UserCacheWrite.updated; + }); } /// 按设备绑定部门树(或全租户)同步脱敏后的离线人员到本机缓存。 @@ -130,12 +159,11 @@ abstract class SyncUserTool { /// 服务端返回的记录姓名已脱敏且不含手机号头像;本地已有的完整消费缓存 /// (真实姓名、手机号)优先保留,同步只补齐缺失人员和刷新钱包快照字段。 /// 分页期间快照上界变化会立即中断,已写入的本地数据保持有效。 - static Future syncOfflineUsers({bool isAll = false}) async { + static Future syncOfflineUsers( + {bool isAll = false}) async { if (!AppNetworkTool.isAutoNetwork()) { throw StateError('网络不可用,无法同步离线人员'); } - final app = Get.find(); - final userDao = app.dataBase.getUserDao; var afterId = '0'; String? snapshotMaxId; var added = 0; @@ -153,48 +181,25 @@ abstract class SyncUserTool { throw StateError('离线人员快照上界在分页期间发生变化'); } for (final record in snapshot.records) { - final existing = await userDao.findByUserId(record.userId); - if (existing == null) { - final now = DateTime.now(); - await userDao.save( - UserEntity( - userId: record.userId, - nickname: record.username, - walletCardNumber: record.walletCardNumber, - walletId: record.walletId, - walletTypeId: record.walletTypeId, - walletBalance: record.walletBalance, - canteenId: record.canteenId?.toString(), - createTime: now, - updateTime: now, - ), - ); - added++; - continue; - } - // 姓名、手机号、头像和优惠保留本地消费缓存的真实数据,仅刷新钱包快照。 - final merged = UserEntity( - id: existing.id, - userId: existing.userId, - nickname: existing.nickname, - mobile: existing.mobile, - avatar: existing.avatar, - walletCardNumber: record.walletCardNumber, - walletId: record.walletId, - walletTypeId: record.walletTypeId, - walletBalance: record.walletBalance, - canteenId: record.canteenId?.toString() ?? existing.canteenId, - discountsList: existing.discountsList, - discountsUpdateTime: existing.discountsUpdateTime, - createTime: existing.createTime, - updateTime: DateTime.now(), + final result = await _storeUserSnapshot( + UserEntity( + userId: record.userId, + nickname: record.username, + walletCardNumber: record.walletCardNumber, + walletId: record.walletId, + walletTypeId: record.walletTypeId, + walletBalance: record.walletBalance, + canteenId: record.canteenId?.toString(), + ), + preserveIdentity: true, ); - // UserEntity 等值比较不含时间戳;完全一致时避免无谓写库。 - if (merged == existing) { - unchanged++; - } else { - await userDao.edit(merged); - updated++; + switch (result) { + case _UserCacheWrite.added: + added++; + case _UserCacheWrite.updated: + updated++; + case _UserCacheWrite.unchanged: + unchanged++; } } if (!snapshot.hasMore) break; diff --git a/lib/pages/setting/face_manage_controller.dart b/lib/pages/setting/face_manage_controller.dart index e69ab63..5b7487d 100644 --- a/lib/pages/setting/face_manage_controller.dart +++ b/lib/pages/setting/face_manage_controller.dart @@ -469,84 +469,6 @@ class FaceManageController extends GetxController { } } - /// 清理调试全租户同步灌入的非本部门人脸;仅删除本机数据,不触碰服务端。 - /// - /// 先拉一次本部门快照清单确定范围(部门无在册人脸时中止,避免误删全库), - /// 确认数量后走批量删除管线,收尾顺带清理目录垃圾文件。 - Future cleanForeignFaces() async { - if (operationInProgress) return; - if (AppSysTool.isProd()) { - Toast.error(msg: '生产环境不支持清理非本部门人脸'); - return; - } - if (!AppNetworkTool.isAutoNetworkMsg()) return; - isDeleting.value = true; - qualityOperationMessage.value = ''; - try { - EasyLoading.show( - status: '正在获取部门范围...', - maskType: EasyLoadingMaskType.black, - ); - final scopeDeptIds = await SyncFaceTool.getDepartmentScopeDeptIds(); - if (scopeDeptIds.isEmpty) { - Toast.error(msg: '本部门当前没有在册人脸,已取消清理'); - return; - } - final profiles = await appC.dataBase.getFaceDao.getSyncProfiles(); - final foreign = profiles - .where( - (face) => - face.deptId != null && - face.deptId!.isNotEmpty && - !scopeDeptIds.contains(face.deptId), - ) - .toList(growable: false); - if (foreign.isEmpty) { - Toast.success(msg: '没有非本部门人脸需要清理'); - return; - } - EasyLoading.dismiss(); - final isSure = await appMessageDialog( - Get.context!, - '检测到 ${foreign.length} 条非本部门人脸(来自调试全租户同步)。' - '是否清理?仅删除本机数据,不影响服务端。', - ); - if (!isSure) return; - var lastPaintAt = DateTime.now(); - final deleted = await SyncFaceTool.deleteLocalFaces( - foreign, - onProgress: (processed, total) { - if (DateTime.now().difference(lastPaintAt) < - const Duration(milliseconds: 100)) { - return; - } - lastPaintAt = DateTime.now(); - EasyLoading.showProgress( - total == null || total <= 0 ? 0 : processed / total, - status: '清理中 $processed/$total', - maskType: EasyLoadingMaskType.black, - ); - }, - ); - await SyncFaceTool.sweepFaceStorage(); - if (deleted == foreign.length) { - Toast.success(msg: '已清理 $deleted 条非本部门人脸'); - } else { - qualityOperationMessage.value = - '已清理 $deleted 条,失败 ${foreign.length - deleted} 条,可再次清理重试'; - Toast.error(msg: qualityOperationMessage.value); - } - } catch (e, stack) { - Toast.error(msg: '清理非本部门人脸失败'); - logger.e('|人脸管理|清理非本部门人脸失败error:$e|stack:$stack|'); - } finally { - isDeleting.value = false; - currentPage.value = 1; - await getFaceList(); - EasyLoading.dismiss(); - } - } - /// 刷新不受当前搜索条件影响的质量摘要。 Future _refreshQualityCounts(FaceDao faceDao) async { lowQualityCount.value = await faceDao.findLowQualityCount( diff --git a/lib/pages/setting/widgets/setting_face.dart b/lib/pages/setting/widgets/setting_face.dart index dab7c54..68d4451 100644 --- a/lib/pages/setting/widgets/setting_face.dart +++ b/lib/pages/setting/widgets/setting_face.dart @@ -201,14 +201,6 @@ Widget buildFaceSearch(BuildContext _) { minWidth: 176.w, onTap: busy ? null : controller.asyncFace, ), - if (!AppSysTool.isProd()) - TerminalToolbarButton( - label: '清理非本部门人脸', - icon: FAssets.icons.userX, - style: TerminalToolbarButtonStyle.secondary, - minWidth: 176.w, - onTap: busy ? null : controller.cleanForeignFaces, - ), const Spacer(), TerminalToolbarButton( label: '删除全部人脸', diff --git a/lib/sub_screen/common/models/customer_display_models.dart b/lib/sub_screen/common/models/customer_display_models.dart index b97d194..f065e34 100644 --- a/lib/sub_screen/common/models/customer_display_models.dart +++ b/lib/sub_screen/common/models/customer_display_models.dart @@ -136,6 +136,7 @@ class CustomerDisplayState { required this.amountMealPrice, this.customerName, this.customerPhone, + this.customerBalance, this.qrCodeData, this.countdownSeconds = 0, this.connected = true, @@ -159,6 +160,7 @@ class CustomerDisplayState { businessTitle: '智慧超市', customerName: '张小明', customerPhone: '13812345678', + customerBalance: 12580, products: const [ CustomerDisplayProduct( name: '农夫山泉 550ml', @@ -202,6 +204,10 @@ class CustomerDisplayState { final String businessTitle; final String? customerName; final String? customerPhone; + + /// 主屏同步的当前所选钱包余额,单位为分;为空时余额未知。 + /// 支付成功消息不含新余额,因此成功页保留的是支付前快照。 + final int? customerBalance; final List products; final int totalPrice; final int amountMealPrice; @@ -242,6 +248,7 @@ class CustomerDisplayState { businessTitle: businessTitle, customerName: customerName, customerPhone: customerPhone, + customerBalance: customerBalance, products: products, totalPrice: totalPrice, amountMealPrice: amountMealPrice, @@ -258,6 +265,7 @@ class CustomerDisplayState { other.businessTitle == businessTitle && other.customerName == customerName && other.customerPhone == customerPhone && + other.customerBalance == customerBalance && listEquals(other.products, products) && other.totalPrice == totalPrice && other.amountMealPrice == amountMealPrice && @@ -272,6 +280,7 @@ class CustomerDisplayState { businessTitle, customerName, customerPhone, + customerBalance, Object.hashAll(products), totalPrice, amountMealPrice, diff --git a/lib/sub_screen/common/utils/customer_display_coordinator.dart b/lib/sub_screen/common/utils/customer_display_coordinator.dart index 5306e39..dfda505 100644 --- a/lib/sub_screen/common/utils/customer_display_coordinator.dart +++ b/lib/sub_screen/common/utils/customer_display_coordinator.dart @@ -198,6 +198,7 @@ class CustomerDisplayCoordinator extends ChangeNotifier { String? customerName; String? customerPhone; + int? customerBalance; final rawPayUser = data['payUser']; if (rawPayUser != null) { final payUser = _normalizeMap(rawPayUser); @@ -209,6 +210,13 @@ class CustomerDisplayCoordinator extends ChangeNotifier { final userMobile = payUser['userMobile']; if (userMobile != null && userMobile is! String) return null; customerPhone = userMobile?.trim(); + // 钱包可能存在负余额;缺失或异常值只降级余额展示,不丢弃有效购物车。 + final walletBalance = payUser['walletBalance']; + if (walletBalance is num && + walletBalance.isFinite && + walletBalance == walletBalance.truncateToDouble()) { + customerBalance = walletBalance.toInt(); + } } return CustomerDisplayState( @@ -216,6 +224,7 @@ class CustomerDisplayCoordinator extends ChangeNotifier { businessTitle: businessTitle, customerName: customerName, customerPhone: customerPhone, + customerBalance: customerBalance, products: List.unmodifiable(products), totalPrice: totalPrice, amountMealPrice: amountMealPrice, diff --git a/lib/sub_screen/common/widgets/sub_screen_header.dart b/lib/sub_screen/common/widgets/sub_screen_header.dart index 1464449..5e465d1 100644 --- a/lib/sub_screen/common/widgets/sub_screen_header.dart +++ b/lib/sub_screen/common/widgets/sub_screen_header.dart @@ -84,6 +84,19 @@ class SubScreenHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final statusWidgets = [ + for (final widget in trailing) ...[ + widget, + SizedBox(width: 12 * scale), + ], + Icon( + Icons.schedule_rounded, + color: XfjDesignTokens.muted, + size: 24 * subScreenTextScale(scale), + ), + SizedBox(width: 8 * scale), + SubScreenHeaderClock(scale: scale), + ]; return Container( // 高度给保底值:小尺寸副屏文字有 0.9 字号下限,纯按 scale 收缩会裁切文字。 height: subtitle == null @@ -146,17 +159,21 @@ class SubScreenHeader extends StatelessWidget { ], ), ), - for (final widget in trailing) ...[ - widget, - SizedBox(width: 12 * scale), - ], - Icon( - Icons.schedule_rounded, - color: XfjDesignTokens.muted, - size: 24 * subScreenTextScale(scale), - ), - SizedBox(width: 8 * scale), - SubScreenHeaderClock(scale: scale), + // 姓名、手机号和余额同屏时,长金额在可用宽度内整体缩放,保留完整数值。 + if (trailing.length >= 3) + Expanded( + flex: 4, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: statusWidgets, + ), + ), + ) + else + ...statusWidgets, ], ), ); diff --git a/lib/sub_screen/page/sub_app.dart b/lib/sub_screen/page/sub_app.dart index dc2d6fa..9624004 100644 --- a/lib/sub_screen/page/sub_app.dart +++ b/lib/sub_screen/page/sub_app.dart @@ -5,6 +5,7 @@ import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/models/face_recognition_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/utils/sub_display_coordinator.dart'; @@ -119,12 +120,17 @@ class _SubAppPageState extends State { } } - /// 已识别到的顾客在顶栏展示脱敏姓名和手机号,副屏永不显示完整个人信息。 + /// 已识别顾客的脱敏姓名、手机号和所选钱包余额随购物车快照一同更新。 List _customerTrailing(double scale) { final state = _coordinator.customerCoordinator.state; final name = state.maskedCustomerName; final phone = state.maskedCustomerPhone; - if (name == null && phone == null) return const []; + final balance = state.customerBalance; + if (name == null && phone == null && balance == null) return const []; + final balanceTitle = + state.scenario == CustomerDisplayScenario.success ? '支付前余额' : '余额'; + final balanceText = + balance == null ? '--' : '¥ ${(balance / 100).toStringAsFixed(2)}'; return [ if (name != null) SubScreenHeaderPill( @@ -140,6 +146,12 @@ class _SubAppPageState extends State { color: XfjDesignTokens.primary, scale: scale, ), + SubScreenHeaderPill( + icon: Icons.account_balance_wallet_outlined, + label: '$balanceTitle:$balanceText', + color: XfjDesignTokens.primary, + scale: scale, + ), ]; } diff --git a/pubspec.yaml b/pubspec.yaml index e0fda00..0d040f6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pj_supermarket_cloud description: "智慧后勤-测试" publish_to: "none" -version: 2.1.8+218 +version: 2.1.9+219 environment: sdk: ^3.6.0 @@ -115,3 +115,4 @@ flutter: - assets/images/ - assets/images/customer_welcome/ - assets/crt/ + - android/app/src/main/assets/database/user_cache_v16.sql diff --git a/test/customer_display_balance_test.dart b/test/customer_display_balance_test.dart new file mode 100644 index 0000000..b5ccf71 --- /dev/null +++ b/test/customer_display_balance_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_models.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/utils/customer_display_coordinator.dart'; + +Map _cart({ + Object? balance = 12580, + String name = '张小明', + bool hasUser = true, + bool empty = false, +}) => + { + 'sysType': 'CANTEEN', + 'shopList': empty + ? [] + : [ + { + 'productName': '午餐', + 'productPrice': 600, + 'totalPrice': 600, + 'nature': 'DEFAULT', + 'productNumber': 1, + }, + ], + 'totalPrice': empty ? 0 : 600, + 'amountMealPrice': 0, + 'payUser': hasUser + ? { + 'username': name, + 'userMobile': '13812345678', + 'walletBalance': balance, + } + : null, + }; + +void main() { + test('主屏余额按整数分保留,包括零余额及负余额', () { + final coordinator = CustomerDisplayCoordinator(); + addTearDown(coordinator.dispose); + + for (final balance in [12580, 0, -50]) { + expect(coordinator.handleAction('updateCard', _cart(balance: balance)), + isTrue); + expect(coordinator.state.customerBalance, balance); + expect(coordinator.state.totalPrice, 600); + expect(coordinator.state.businessTitle, '智慧食堂'); + } + + expect(coordinator.handleAction('updateCard', _cart(balance: 12580.0)), + isTrue); + expect(coordinator.state.customerBalance, 12580); + }); + + test('缺失或异常余额不显示旧余额,仍接受有效人员和商品快照', () { + final coordinator = CustomerDisplayCoordinator(); + addTearDown(coordinator.dispose); + + final missingBalance = _cart(); + (missingBalance['payUser'] as Map).remove('walletBalance'); + final invalidSnapshots = [ + missingBalance, + for (final balance in [ + null, + '12580', + 12580.5, + double.nan, + double.infinity + ]) + _cart(balance: balance), + ]; + for (final snapshot in invalidSnapshots) { + coordinator.handleAction('updateCard', _cart()); + expect(coordinator.handleAction('updateCard', snapshot), isTrue); + expect(coordinator.state.customerBalance, isNull); + expect(coordinator.state.customerName, '张小明'); + expect(coordinator.state.products.single.name, '午餐'); + expect(coordinator.state.scenario, CustomerDisplayScenario.review); + } + }); + + test('仅余额变化也刷新副屏,同余额重复快照不重复通知', () { + final coordinator = CustomerDisplayCoordinator(); + addTearDown(coordinator.dispose); + var notifications = 0; + coordinator.addListener(() => notifications++); + + expect(coordinator.handleAction('updateCard', _cart()), isTrue); + expect(notifications, 1); + expect(coordinator.handleAction('updateCard', _cart()), isFalse); + expect(notifications, 1); + expect(coordinator.handleAction('updateCard', _cart(balance: 600)), isTrue); + expect(notifications, 2); + expect(coordinator.state.customerBalance, 600); + }); + + test('切换人员、匿名快照及空购物车不会残留上一位顾客余额', () { + final coordinator = CustomerDisplayCoordinator(); + addTearDown(coordinator.dispose); + coordinator.handleAction('updateCard', _cart()); + + coordinator.handleAction('updateCard', _cart(name: '李小红', balance: 800)); + expect(coordinator.state.customerName, '李小红'); + expect(coordinator.state.customerBalance, 800); + + coordinator.handleAction('updateCard', _cart(name: '王小军', balance: null)); + expect(coordinator.state.customerName, '王小军'); + expect(coordinator.state.customerBalance, isNull); + + coordinator.handleAction('updateCard', _cart()); + coordinator.handleAction('updateCard', _cart(hasUser: false)); + expect(coordinator.state.customerName, isNull); + expect(coordinator.state.customerPhone, isNull); + expect(coordinator.state.customerBalance, isNull); + + coordinator.handleAction('updateCard', _cart()); + coordinator.handleAction('updateCard', _cart(empty: true)); + expect(coordinator.state, CustomerDisplayState.idle()); + expect(coordinator.state.customerBalance, isNull); + }); + + test('退出购买页面会清除余额及顾客信息', () { + final coordinator = CustomerDisplayCoordinator(); + addTearDown(coordinator.dispose); + coordinator.handleAction('updateCard', _cart()); + + expect(coordinator.handleAction('jumpSubBack'), isTrue); + expect(coordinator.state, CustomerDisplayState.idle()); + expect(coordinator.state.customerBalance, isNull); + }); + + testWidgets('支付中、取消及超时均保留同一笔购买的余额快照', (tester) async { + final coordinator = CustomerDisplayCoordinator(qrCountdownSeconds: 2); + addTearDown(coordinator.dispose); + coordinator.handleAction('updateCard', _cart()); + + coordinator.handleAction('qrCodeScan', {'qrCodeData': 'wallet-pay-code'}); + expect(coordinator.state.scenario, CustomerDisplayScenario.paying); + expect(coordinator.state.customerBalance, 12580); + coordinator.handleAction('qrCodeScanCancel'); + expect(coordinator.state.scenario, CustomerDisplayScenario.cancelled); + expect(coordinator.state.customerBalance, 12580); + + coordinator.handleAction('qrCodeScan', {'qrCodeData': 'next-wallet-code'}); + await tester.pump(const Duration(seconds: 2)); + expect(coordinator.state.scenario, CustomerDisplayScenario.timeout); + expect(coordinator.state.customerBalance, 12580); + }); + + testWidgets('支付成功保留支付前余额,成功倒计时结束后清除顾客信息', (tester) async { + final coordinator = CustomerDisplayCoordinator(successCountdownSeconds: 2); + addTearDown(coordinator.dispose); + coordinator.handleAction('updateCard', _cart()); + + expect(coordinator.handleAction('orderPaySuccess'), isTrue); + expect(coordinator.state.scenario, CustomerDisplayScenario.success); + expect(coordinator.state.customerBalance, 12580); + expect(coordinator.handleAction('updateCard', _cart(empty: true)), isFalse); + expect(coordinator.handleAction('jumpSubBack'), isFalse); + + await tester.pump(const Duration(seconds: 1)); + expect(coordinator.state.scenario, CustomerDisplayScenario.success); + expect(coordinator.state.customerBalance, 12580); + expect(coordinator.state.countdownSeconds, 1); + + await tester.pump(const Duration(seconds: 1)); + expect(coordinator.state, CustomerDisplayState.idle()); + expect(coordinator.state.customerBalance, isNull); + expect(coordinator.state.customerName, isNull); + expect(coordinator.state.customerPhone, isNull); + }); +} diff --git a/test/customer_display_balance_widget_test.dart b/test/customer_display_balance_widget_test.dart new file mode 100644 index 0000000..85192d9 --- /dev/null +++ b/test/customer_display_balance_widget_test.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/sub_screen/common/widgets/sub_screen_header.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/sub_app.dart'; + +Map _cart({int? balance = 12345, bool hasUser = true}) => { + 'sysType': 'CANTEEN', + 'totalPrice': 600, + 'amountMealPrice': 0, + 'shopList': [ + { + 'productName': '午餐', + 'productPrice': 600, + 'totalPrice': 600, + 'nature': 'DEFAULT', + 'productNumber': 1, + }, + ], + 'payUser': hasUser + ? { + 'username': '张小明', + 'userMobile': '13812345678', + 'walletBalance': balance, + } + : null, + }; + +/// 通过真实副屏插件入口投递主屏消息,覆盖顶层模式切换和顶栏刷新。 +Future _sendAction( + WidgetTester tester, + String action, [ + Object? arguments, +]) async { + final delivered = Completer(); + tester.binding.channelBuffers.push( + 'screen_plugin_sub_channel', + const StandardMethodCodec().encodeMethodCall(MethodCall(action, arguments)), + (_) => delivered.complete(), + ); + await delivered.future; + await tester.pump(); +} + +Finder _headerText(String text) => find.descendant( + of: find.byType(SubScreenHeader), + matching: find.text(text), + ); + +void main() { + testWidgets('食堂副屏余额随购买状态更新,常见横屏分辨率下顶栏无溢出', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(const SubAppPage()); + await _sendAction(tester, 'updateCard', _cart()); + + expect(_headerText('张*明,您好'), findsOneWidget); + expect(_headerText('138****5678'), findsOneWidget); + expect(_headerText('余额:¥ 123.45'), findsOneWidget); + expect(tester.takeException(), isNull); + + await _sendAction(tester, 'updateCard', _cart(balance: 0)); + expect(_headerText('余额:¥ 0.00'), findsOneWidget); + expect(_headerText('余额:¥ 123.45'), findsNothing); + + await _sendAction(tester, 'updateCard', _cart(balance: null)); + expect(_headerText('余额:--'), findsOneWidget, + reason: tester + .widgetList(find.descendant( + of: find.byType(SubScreenHeader), matching: find.byType(Text))) + .map((text) => text.data) + .join(', ')); + + await _sendAction(tester, 'updateCard', _cart(hasUser: false)); + expect(find.byType(SubScreenHeaderPill), findsNothing); + + await _sendAction(tester, 'updateCard', _cart(balance: 12345)); + await _sendAction(tester, 'orderPaySuccess'); + expect(_headerText('支付前余额:¥ 123.45'), findsOneWidget); + expect(_headerText('余额:¥ 123.45'), findsNothing); + expect(tester.takeException(), isNull); + + await tester.pump(const Duration(seconds: 10)); + expect(find.byType(SubScreenHeaderPill), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + // 插件在 isolate 中只注册一次通道,在同一个测试中遍历不同的真实副屏尺寸。 + for (final size in [ + const Size(1024, 768), + const Size(1184, 666), + const Size(1280, 720), + const Size(1920, 1080), + ]) { + await tester.binding.setSurfaceSize(size); + await tester.pumpWidget(const SubAppPage()); + await _sendAction(tester, 'updateCard', _cart(balance: 123456789)); + expect(_headerText('余额:¥ 1234567.89'), findsOneWidget); + expect(tester.takeException(), isNull, reason: '$size 商品核对页顶栏溢出'); + + await _sendAction(tester, 'orderPaySuccess'); + expect(_headerText('支付前余额:¥ 1234567.89'), findsOneWidget); + expect(tester.takeException(), isNull, reason: '$size 支付成功页顶栏溢出'); + await tester.pumpWidget(const SizedBox.shrink()); + } + }); +} diff --git a/test/user_cache_consistency_test.dart b/test/user_cache_consistency_test.dart new file mode 100644 index 0000000..10f940d --- /dev/null +++ b/test/user_cache_consistency_test.dart @@ -0,0 +1,519 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart' as dio; +import 'package:floor/floor.dart' show Callback; +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:pj_supermarket_cloud/common/services/database/database_migration.dart'; +import 'package:sqflite/sqflite.dart' as sqlite; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _CacheTestApp extends AppControl { + @override + void onClose() {} +} + +/// Every request terminates here; these tests never contact a backend or device. +class _CacheAdapter implements dio.HttpClientAdapter { + _CacheAdapter(this.respond); + + final Future Function(dio.RequestOptions) respond; + final requests = []; + + @override + Future fetch(dio.RequestOptions options, + Stream? requestStream, Future? cancelFuture) async { + requests.add(options); + return dio.ResponseBody.fromString( + jsonEncode({'code': 0, 'data': await respond(options)}), + 200, + headers: { + 'content-type': ['application/json'] + }, + ); + } + + @override + void close({bool force = false}) {} +} + +DiscountsItem _rule(double rate) => DiscountsItem( + name: 'cache consistency rule', + hourType: ProductHourType.MORNING, + type: null, + basePrice: 0, + specialDishEnable: true, + extraPer: rate, + ); + +ServiceUser _onlineUser({int balance = 700, String canteen = '10'}) => + ServiceUser( + userId: '100', + username: '张三', + userMobile: 'synthetic-mobile', + userAvatar: 'synthetic-avatar', + walletId: '200', + walletTypeId: '300', + walletCardNumber: 'synthetic-card', + walletBalance: balance, + canteenId: canteen, + ); + +Map _snapshot({int balance = 900, int canteen = 10}) => { + 'records': [ + { + 'userId': '100', + 'username': '张*', + 'walletCardNumber': 'synthetic-card', + 'walletId': '200', + 'walletTypeId': '300', + 'walletBalance': balance, + 'canteenId': canteen, + } + ], + 'nextAfterId': '100', + 'hasMore': false, + 'snapshotMaxId': '100', + }; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + late Directory directory; + late AppDatabase database; + late _CacheTestApp app; + late dio.HttpClientAdapter originalAdapter; + late String originalBaseUrl; + late _CacheAdapter adapter; + + Future cachedUserCount() async { + final rows = await database.database.rawQuery( + 'SELECT COUNT(*) AS count FROM user_record WHERE userId = ?', + ['100'], + ); + return rows.single['count'] as int; + } + + void mockSnapshot({int balance = 900, int canteen = 10}) { + adapter = _CacheAdapter((options) async { + expect(options.path, endsWith('/offline-user/sync-snapshot')); + return _snapshot(balance: balance, canteen: canteen); + }); + HttpManage().dio.httpClientAdapter = adapter; + } + + setUpAll(() async { + directory = await Directory.systemTemp.createTemp('pj-user-cache-test-'); + for (final channel in [ + 'plugins.flutter.io/path_provider', + 'external_path' + ]) { + binding.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(channel), + (_) async => directory.path, + ); + } + sqfliteFfiInit(); + // Floor selects this factory on desktop; share it with the settings cache. + sqlite.databaseFactory = databaseFactoryFfi; + database = await $FloorAppDatabase.inMemoryDatabaseBuilder().build(); + app = _CacheTestApp()..dataBase = database; + Get.put(app); + await database.getSettingDao.save(SettingEntity( + sysJson: jsonEncode( + SysSetting(sysType: SysType.CANTEEN, serviceId: '10').toJson()), + userJson: jsonEncode( + (UserSetting(username: '', password: '')..tenantId = '1').toJson()), + )); + await logger.init; + final client = HttpManage().dio; + originalAdapter = client.httpClientAdapter; + originalBaseUrl = client.options.baseUrl; + client.options.baseUrl = 'https://user-cache-test.invalid/api'; + }); + + setUp(() async { + app.isNetwork.value = true; + await database.getUserDao.delAll(); + await database.getFaceDao.delAll(); + mockSnapshot(); + }); + + tearDownAll(() async { + final client = HttpManage().dio; + client.httpClientAdapter = originalAdapter; + client.options.baseUrl = originalBaseUrl; + await logger.close(); + await AppSettingTool.removeSettingCache(); + await database.close(); + Get.delete(force: true); + await directory.delete(recursive: true); + }); + + test('并发首次缓存同一人员只生成一行,之后仍可单行读取', () async { + await Future.wait(List.generate( + 8, + (i) => SyncUserTool.storageUser(user: _onlineUser(balance: 700 + i)), + )); + expect(await cachedUserCount(), 1); + final stored = (await database.getUserDao.findByUserId('100'))!; + expect(stored.nickname, '张三'); + expect(stored.walletBalance, inInclusiveRange(700, 707)); + expect(stored.createTime, isNotNull); + expect(stored.updateTime, isNotNull); + expect(adapter.requests, isEmpty); + }); + + test('在线缓存与离线人员同步交错时仍只有一行并保留完整身份', () async { + final requested = Completer(); + final response = Completer(); + adapter = _CacheAdapter((options) { + expect(options.path, endsWith('/offline-user/sync-snapshot')); + requested.complete(); + return response.future; + }); + HttpManage().dio.httpClientAdapter = adapter; + final offline = SyncUserTool.syncOfflineUsers(); + await requested.future; + final online = SyncUserTool.storageUser(user: _onlineUser()); + response.complete(_snapshot()); + await Future.wait([offline, online]); + + expect(await cachedUserCount(), 1); + final stored = (await database.getUserDao.findByUserId('100'))!; + expect(stored.nickname, '张三'); + expect(stored.mobile, 'synthetic-mobile'); + expect(stored.avatar, 'synthetic-avatar'); + expect(stored.walletBalance, anyOf(700, 900)); + expect(adapter.requests, hasLength(1)); + }); + + test('并发优惠更新不会被在线人员刷新覆盖', () async { + await SyncUserTool.storageUser(user: _onlineUser(balance: 1000)); + final updatedAt = DateTime(2026, 10, 26, 12); + await Future.wait([ + SyncUserTool.storageUser(user: _onlineUser(balance: 600)), + database.getUserDao.updateDiscounts( + '100', + jsonEncode([_rule(2).toJson()]), + updatedAt, + ), + ]); + final stored = (await database.getUserDao.findByUserId('100'))!; + expect(stored.walletBalance, 600); + expect(stored.discountsList!.single.extraPer, 2); + expect(stored.discountsUpdateTime, updatedAt); + }); + + test('离线人员同步与优惠清空并发时保留最新空规则和身份字段', () async { + await SyncUserTool.storageUser(user: _onlineUser(balance: 1000)); + await database.getUserDao.updateDiscounts( + '100', + jsonEncode([_rule(2).toJson()]), + DateTime(2026, 10, 25), + ); + final updatedAt = DateTime(2026, 10, 26, 12); + await Future.wait([ + SyncUserTool.syncOfflineUsers(), + database.getUserDao.updateDiscounts('100', '[]', updatedAt), + ]); + final stored = (await database.getUserDao.findByUserId('100'))!; + expect(stored.walletBalance, 900); + expect(stored.nickname, '张三'); + expect(stored.mobile, 'synthetic-mobile'); + expect(stored.avatar, 'synthetic-avatar'); + expect(stored.discountsList, isEmpty); + expect(stored.discountsUpdateTime, updatedAt); + }); + + test('仅商户归属或头像变化也会更新缓存,离线快照保留真实身份', () async { + await SyncUserTool.storageUser(user: _onlineUser()); + await SyncUserTool.storageUser( + user: _onlineUser()..userAvatar = 'new-synthetic-avatar', + ); + expect((await database.getUserDao.findByUserId('100'))!.avatar, + 'new-synthetic-avatar'); + + mockSnapshot(balance: 700, canteen: 20); + final summary = await SyncUserTool.syncOfflineUsers(); + expect(summary.updated, 1); + final stored = (await database.getUserDao.findByUserId('100'))!; + expect(stored.canteenId, '20'); + expect(stored.nickname, '张三'); + expect(stored.avatar, 'new-synthetic-avatar'); + }); + + test('同一用户多个样本人脸都能离线解析到唯一人员', () async { + await SyncUserTool.storageUser(user: _onlineUser()); + for (final sampleId in ['101', '102', '103']) { + await database.getFaceDao.save(FaceEntity( + userId: '100', + sampleId: sampleId, + userName: '张三', + imagePath: 'synthetic-$sampleId', + faceModel: [1, 2, 3], + )); + } + app.isNetwork.value = false; + for (final sampleId in [101, 102, 103]) { + final recognized = await AppFaceTool.resolveRecognizedUser( + recognition: FaceStream( + similar: 0.95, cost: 1, sampleId: sampleId, userId: '100'), + sysType: SysType.CANTEEN, + ); + expect(recognized!.userId, '100'); + expect(recognized.walletBalance, 700); + } + expect(await cachedUserCount(), 1); + expect(await database.getFaceDao.findByUserId('100'), hasLength(3)); + expect(adapter.requests, isEmpty); + }); + + test('空用户编号不能生成无法归属的离线缓存', () async { + await SyncUserTool.storageUser(user: _onlineUser()..userId = ' '); + await SyncUserTool.storageUser(user: _onlineUser()..userId = null); + expect(await database.getUserDao.findCount('-1'), 0); + }); + + test('15升级16合并重复人员且保留人脸、订单,修复旧版缺列和索引', () async { + final path = '${directory.path}/legacy-user-cache.db'; + var legacy = await $FloorAppDatabase.databaseBuilder(path).build(); + await legacy.database + .execute('DROP INDEX IF EXISTS index_user_record_userId'); + await legacy.database + .execute('ALTER TABLE face DROP COLUMN qualityReportedAt'); + const hotIndexes = [ + 'index_supermarket_sku_barCode', + 'index_supermarket_sku_spuId', + 'index_supermarket_spu_classifyServiceUniqueCode_delFlag', + 'index_supermarket_spu_serviceUniqueCode', + 'index_face_sampleId', + 'index_face_userId', + ]; + for (final name in hotIndexes) { + await legacy.database.execute('DROP INDEX IF EXISTS $name'); + } + final oldRules = jsonEncode([_rule(1.5).toJson()]); + final newRules = jsonEncode([_rule(2).toJson()]); + Future seed(int id, String userId, + {required String updated, + required int balance, + String name = '张*', + String? mobile, + String? avatar, + String? rules, + String? rulesUpdated, + String created = '2026-10-19 08:00:00'}) async { + await legacy.database.insert('user_record', { + 'id': id, + 'userId': userId, + 'nickname': name, + 'mobile': mobile, + 'avatar': avatar, + 'walletId': 'wallet-$id', + 'walletTypeId': 'type-$id', + 'walletCardNumber': 'card-$id', + 'walletBalance': balance, + 'canteenId': 'canteen-$id', + 'createTime': created, + 'updateTime': updated, + 'discountsList': rules, + 'discountsUpdateTime': rulesUpdated, + }); + } + + await seed(10, '100', + updated: '2026-10-20 08:00:00', + balance: 1300, + name: '张三', + mobile: 'synthetic-mobile', + avatar: 'synthetic-avatar', + created: '2026-10-10 08:00:00', + rules: oldRules, + rulesUpdated: '2026-10-21 08:00:00'); + await seed(20, '100', + updated: '2026-10-26 08:00:00', + balance: 700, + mobile: '', + avatar: '', + rules: oldRules, + rulesUpdated: '2026-10-22 08:00:00'); + await seed(30, '100', + updated: '2026-10-24 08:00:00', + balance: 900, + name: '未知', + rules: '[]', + rulesUpdated: '2026-10-25 08:00:00'); + await seed(40, '200', + updated: '2026-10-23 08:00:00', + balance: 1100, + name: '李四', + rules: newRules, + rulesUpdated: '2026-10-26 08:00:00'); + await seed(50, '200', + updated: '2026-10-25 08:00:00', + balance: 500, + name: '李*', + rules: oldRules, + rulesUpdated: '2026-10-24 08:00:00'); + await seed(60, '300', + updated: '2026-10-25 08:00:00', balance: 600, name: '王五'); + await seed(70, '400', + updated: '2026-10-26 08:00:00', + balance: 420, + name: '赵六', + rules: oldRules, + rulesUpdated: '2026-10-24 08:00:00'); + // The historical converter encodes an empty rule list as SQL NULL. + // Its newer timestamp must clear old rules independently of the wallet row. + await seed(80, '400', + updated: '2026-10-23 08:00:00', + balance: 1000, + name: '赵*', + rules: ListDiscountsConverter().encode([]), + rulesUpdated: '2026-10-26 09:00:00'); + await legacy.database.insert('face', { + 'userId': '100', + 'sampleId': '101', + 'userName': '张三', + 'imagePath': 'synthetic-face', + 'faceModel': null, + 'qualityStatus': 'pending', + }); + await legacy.getOrderDao.save(OrderEntity( + uniqueCode: 'synthetic-offline-order', + serviceUniqueCode: '', + sysType: SysType.CANTEEN, + userCode: '100', + price: 400, + extraPrice: 0, + payPrice: 400, + type: OrderType.BELOW, + status: OrderStatus.OFFLINE_PAY, + payType: OrderPayType.FACE, + count: 2, + weight: 0, + createTime: DateTime(2026, 10, 25), + )); + final faceBefore = await legacy.database.query('face'); + final ordersBefore = await legacy.database.query('order_record'); + final otherBefore = await legacy.database + .query('user_record', where: 'userId = ?', whereArgs: ['300']); + await legacy.database.execute('PRAGMA user_version = 15'); + await legacy.close(); + + legacy = await $FloorAppDatabase + .databaseBuilder(path) + .addMigrations(DatabaseMigration.getDatabaseMigrations()) + .build(); + try { + expect(await legacy.database.getVersion(), 16); + expect(await legacy.getUserDao.findCount('-1'), 4); + final merged = (await legacy.getUserDao.findByUserId('100'))!; + expect(merged.id, 20); + expect(merged.walletBalance, 700); + expect(merged.walletId, 'wallet-20'); + expect(merged.walletCardNumber, 'card-20'); + expect(merged.walletTypeId, 'type-20'); + expect(merged.canteenId, 'canteen-20'); + expect(merged.nickname, '张三'); + expect(merged.mobile, 'synthetic-mobile'); + expect(merged.avatar, 'synthetic-avatar'); + expect(merged.createTime, DateTime(2026, 10, 10, 8)); + expect(merged.discountsList, isEmpty); + expect(merged.discountsUpdateTime, DateTime(2026, 10, 25, 8)); + final second = (await legacy.getUserDao.findByUserId('200'))!; + expect(second.id, 50); + expect(second.walletBalance, 500); + expect(second.nickname, '李四'); + expect(second.discountsList!.single.extraPer, 2); + expect(second.discountsUpdateTime, DateTime(2026, 10, 26, 8)); + final cleared = (await legacy.getUserDao.findByUserId('400'))!; + expect(cleared.id, 70); + expect(cleared.walletBalance, 420); + expect(cleared.walletId, 'wallet-70'); + expect(cleared.discountsList, isEmpty); + expect(cleared.discountsUpdateTime, DateTime(2026, 10, 26, 9)); + final clearedRow = await legacy.database + .query('user_record', where: 'userId = ?', whereArgs: ['400']); + expect(clearedRow.single['discountsList'], isNull); + expect( + await legacy.database + .query('user_record', where: 'userId = ?', whereArgs: ['300']), + otherBefore); + expect(await legacy.database.query('order_record'), ordersBefore); + final faceAfter = await legacy.database.query('face'); + expect(faceAfter, [ + { + ...faceBefore.single, + 'faceModel': '', + 'qualityCheckedAt': '', + 'qualityReportedAt': null, + } + ]); + final columns = await legacy.database.rawQuery('PRAGMA table_info(face)'); + expect(columns.map((column) => column['name']), + contains('qualityReportedAt')); + final indexes = await legacy.database + .rawQuery("SELECT name FROM sqlite_master WHERE type = 'index'"); + expect(indexes.map((index) => index['name']), containsAll(hotIndexes)); + final userIndexes = + await legacy.database.rawQuery('PRAGMA index_list(user_record)'); + expect(userIndexes, + contains(containsPair('name', 'index_user_record_userId'))); + expect( + userIndexes.singleWhere( + (index) => index['name'] == 'index_user_record_userId')['unique'], + 1); + await expectLater( + legacy.getUserDao + .save(UserEntity(userId: '100', nickname: 'duplicate')), + throwsA(isA()), + ); + } finally { + await legacy.close(); + } + }); + + test('已盖章16但同名索引非唯一且列错误时,打开仍能无损修复', () async { + final path = '${directory.path}/stamped-user-cache.db'; + var stamped = await $FloorAppDatabase.databaseBuilder(path).build(); + await stamped.database + .execute('DROP INDEX IF EXISTS index_user_record_userId'); + await stamped.database.execute( + 'CREATE INDEX index_user_record_userId ON user_record(nickname)'); + await stamped.getUserDao.batchSave([ + UserEntity(userId: '100', nickname: '张三', walletBalance: 1000), + UserEntity(userId: '100', nickname: '张三', walletBalance: 700), + ]); + await stamped.close(); + stamped = await $FloorAppDatabase + .databaseBuilder(path) + .addCallback(Callback(onOpen: DatabaseMigration.repairSchema)) + .build(); + try { + expect(await stamped.database.getVersion(), 16); + expect(await stamped.getUserDao.findCount('-1'), 1); + expect( + (await stamped.getUserDao.findByUserId('100'))!.walletBalance, 700); + final indexes = + await stamped.database.rawQuery('PRAGMA index_list(user_record)'); + expect( + indexes.singleWhere( + (index) => index['name'] == 'index_user_record_userId')['unique'], + 1); + expect( + (await stamped.database + .rawQuery('PRAGMA index_info(index_user_record_userId)')) + .map((column) => column['name']), + ['userId']); + await DatabaseMigration.repairSchema(stamped.database as sqlite.Database); + expect(await stamped.getUserDao.findCount('-1'), 1); + } finally { + await stamped.close(); + } + }); +}