feat:购买失败修改

fix:购买副屏展示用户余额
This commit is contained in:
dzc committed 2026-10-26 17:46:54 +08:00
1 parent 3c11805958
commit c6f9ffb998
20 files changed
+1357 -289

No files matched your search

+1 -1
View File
@@ -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);
@@ -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);
}
}
/**
+3 -2
View File
@@ -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) {
+16 -12
View File
@@ -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)
-36
View File
@@ -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);
+100 -95
View File
@@ -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,
],
),
);
+14 -2
View File
@@ -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
View File
@@ -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
+171
View File
@@ -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());
}
});
}
+519
View File
@@ -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();
}
});
}