feat:购买失败修改
fix:购买副屏展示用户余额
This commit is contained in:
1 parent
3c11805958
commit
c6f9ffb998
20 files changed
+1357
-289
No files matched your search
@@ -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为版本号(内部比较)
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
+104
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,8 +48,8 @@ const typeConverters = <Type>[
|
||||
// 版本必须与原生 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) {
|
||||
|
||||
@@ -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<UserEntity> _userEntityDeletionAdapter;
|
||||
|
||||
@override
|
||||
Future<void> 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<UserEntity?> findByUserId(String userId) async {
|
||||
return _queryAdapter.query(
|
||||
@@ -1820,17 +1835,6 @@ class _$UserDao extends UserDao {
|
||||
await _queryAdapter.queryNoReturn('DELETE FROM user_record');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<int?> findCount(String keyword) async {
|
||||
return _queryAdapter.query(
|
||||
|
||||
@@ -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<void> repairSchema(sqflite.Database database) async {
|
||||
if (!await _needsSchemaRepair(database)) return;
|
||||
await database.transaction((transaction) => _repairSchema(transaction));
|
||||
}
|
||||
|
||||
static Future<bool> _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<bool> _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<void> _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<void> _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<void> _createHotPathIndexes(sqflite.Database database) async {
|
||||
static Future<void> _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<void> _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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -276,19 +276,6 @@ abstract class SyncFaceTool {
|
||||
return byRowId.values.toList();
|
||||
}
|
||||
|
||||
/// 供调试清理入口读取完整部门树,空部门也属于设备同步范围。
|
||||
static Future<Set<String>> 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<AppControl>().dataBase.getFaceDao),
|
||||
);
|
||||
|
||||
/// 按批量管线清理一组本地人脸(不触碰服务端数据),返回成功删除数。
|
||||
///
|
||||
/// 供人脸管理页的调试清理入口使用;同步孤儿删除走同一条 [_deleteFacesInBulk] 管线。
|
||||
static Future<int> deleteLocalFaces(
|
||||
List<FaceEntity> faces, {
|
||||
void Function(int processed, int? total)? onProgress,
|
||||
}) async {
|
||||
final result = await AppFaceTool.runLibraryMutation(
|
||||
() => _deleteFacesInBulk(
|
||||
faces,
|
||||
Get.find<AppControl>().dataBase.getFaceDao,
|
||||
onProgress: onProgress,
|
||||
),
|
||||
);
|
||||
return result.deleted;
|
||||
}
|
||||
|
||||
/// 批量删除本地人脸的统一管线:并行改名 → 原生批量移除 → SQL 批量删除 → 并行清理文件。
|
||||
///
|
||||
/// 逐条删除实测约 664ms/条(每条包含独立的原生通道往返、SQLite 提交和多次
|
||||
@@ -1366,12 +1336,6 @@ abstract class SyncFaceTool {
|
||||
/// 只处理目录顶层文件:修改时间超过一小时、不属于任何本地记录且扩展名为
|
||||
/// 图片的文件视为历史孤儿删除;`.pending-delete` 残留同样清理。跳过全部
|
||||
/// 子目录(同步临时目录与原生头像目录都不在顶层规则覆盖范围内)。
|
||||
static Future<int> sweepFaceStorage() async {
|
||||
final storagePath = await AppFileTool.getFacePath();
|
||||
if (storagePath == null || storagePath.isEmpty) return 0;
|
||||
return _sweepFaceDirectory(storagePath);
|
||||
}
|
||||
|
||||
static Future<int> _sweepFaceDirectory(String storagePath) async {
|
||||
try {
|
||||
final directory = Directory(storagePath);
|
||||
|
||||
@@ -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<void> storageUser({required ServiceUser user}) async {
|
||||
try {
|
||||
final app = Get.find<AppControl>();
|
||||
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<AppControl>().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 = <String, Object?>{
|
||||
'walletCardNumber': user.walletCardNumber,
|
||||
'walletId': user.walletId,
|
||||
'walletTypeId': user.walletTypeId,
|
||||
'walletBalance': user.walletBalance,
|
||||
'canteenId': user.canteenId,
|
||||
};
|
||||
final identity = <String, Object?>{
|
||||
'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 = <String, Object?>{
|
||||
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<OfflineUserSyncSummary> syncOfflineUsers({bool isAll = false}) async {
|
||||
static Future<OfflineUserSyncSummary> syncOfflineUsers(
|
||||
{bool isAll = false}) async {
|
||||
if (!AppNetworkTool.isAutoNetwork()) {
|
||||
throw StateError('网络不可用,无法同步离线人员');
|
||||
}
|
||||
final app = Get.find<AppControl>();
|
||||
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;
|
||||
|
||||
@@ -469,84 +469,6 @@ class FaceManageController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理调试全租户同步灌入的非本部门人脸;仅删除本机数据,不触碰服务端。
|
||||
///
|
||||
/// 先拉一次本部门快照清单确定范围(部门无在册人脸时中止,避免误删全库),
|
||||
/// 确认数量后走批量删除管线,收尾顺带清理目录垃圾文件。
|
||||
Future<void> 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<void> _refreshQualityCounts(FaceDao faceDao) async {
|
||||
lowQualityCount.value = await faceDao.findLowQualityCount(
|
||||
|
||||
@@ -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: '删除全部人脸',
|
||||
|
||||
@@ -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<CustomerDisplayProduct> 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -84,6 +84,19 @@ class SubScreenHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusWidgets = <Widget>[
|
||||
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,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<SubAppPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 已识别到的顾客在顶栏展示脱敏姓名和手机号,副屏永不显示完整个人信息。
|
||||
/// 已识别顾客的脱敏姓名、手机号和所选钱包余额随购物车快照一同更新。
|
||||
List<Widget> _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<SubAppPage> {
|
||||
color: XfjDesignTokens.primary,
|
||||
scale: scale,
|
||||
),
|
||||
SubScreenHeaderPill(
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
label: '$balanceTitle:$balanceText',
|
||||
color: XfjDesignTokens.primary,
|
||||
scale: scale,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
@@ -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<String, dynamic> _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<String, dynamic>).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);
|
||||
});
|
||||
}
|
||||
@@ -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<String, Object?> _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<void> _sendAction(
|
||||
WidgetTester tester,
|
||||
String action, [
|
||||
Object? arguments,
|
||||
]) async {
|
||||
final delivered = Completer<void>();
|
||||
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<Text>(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());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<Object> Function(dio.RequestOptions) respond;
|
||||
final requests = <dio.RequestOptions>[];
|
||||
|
||||
@override
|
||||
Future<dio.ResponseBody> fetch(dio.RequestOptions options,
|
||||
Stream<Uint8List>? requestStream, Future<void>? 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<String, dynamic> _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<int> 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<AppControl>(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<AppControl>(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<void>();
|
||||
final response = Completer<Object>();
|
||||
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<void> 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<sqlite.DatabaseException>()),
|
||||
);
|
||||
} 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user