feat:设备日志模块

feat:定时任务
feat:设置页面用路由重构
This commit is contained in:
dzc committed 2026-07-30 13:16:14 +08:00
1 parent 1033f403b1
commit 2efa7a3c19
60 files changed
+6560 -643

No files matched your search

@@ -0,0 +1,40 @@
import 'package:floor/floor.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// `file_upload_record` 本机文件上传状态的数据访问接口。
@dao
abstract class FileUploadRecordDao {
/// 保存文件状态并返回主键;相同主键时替换原记录以保留同一路径的最新状态。
@Insert(onConflict: OnConflictStrategy.replace)
Future<int> save(FileUploadRecordEntity entity);
/// 按主键更新已经存在的文件上传状态。
@update
Future<void> edit(FileUploadRecordEntity entity);
/// 查询指定设备与业务类型的全部文件状态,并按最近更新时间倒序返回。
@Query('''
SELECT * FROM file_upload_record
WHERE hardId = :hardId AND bizType = :bizType
ORDER BY updateTime DESC
''')
Future<List<FileUploadRecordEntity>> getAll(
String hardId,
String bizType,
);
/// 按设备、业务类型和相对路径查询唯一状态记录;不存在时返回空。
@Query('''
SELECT * FROM file_upload_record
WHERE hardId = :hardId
AND bizType = :bizType
AND relativePath = :relativePath
LIMIT 1
''')
Future<FileUploadRecordEntity?> getByPath(
String hardId,
String bizType,
String relativePath,
);
}
@@ -0,0 +1,27 @@
import 'package:floor/floor.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// `scheduled_task` 配置和最近执行快照的数据访问接口。
@dao
abstract class ScheduledTaskDao {
/// 按 taskCode 保存单条任务;主键冲突时以新配置和执行快照整体替换旧记录。
@Insert(onConflict: OnConflictStrategy.replace)
Future<void> save(ScheduledTaskEntity entity);
/// 批量补齐默认任务;每条记录均按 taskCode 使用替换冲突策略。
@Insert(onConflict: OnConflictStrategy.replace)
Future<void> batchSave(List<ScheduledTaskEntity> entities);
/// 按实体主键更新一条已经存在的任务记录。
@update
Future<void> edit(ScheduledTaskEntity entity);
/// 查询全部任务并按稳定 taskCode 升序返回,便于启动时构建确定性配置映射。
@Query('SELECT * FROM scheduled_task ORDER BY taskCode')
Future<List<ScheduledTaskEntity>> getAll();
/// 按稳定 taskCode 查询单条任务;不存在时返回空。
@Query('SELECT * FROM scheduled_task WHERE taskCode = :taskCode LIMIT 1')
Future<ScheduledTaskEntity?> getByCode(String taskCode);
}
@@ -0,0 +1,70 @@
import 'package:floor/floor.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// `scheduled_task_log` 结构化执行日志的数据访问接口。
@dao
abstract class ScheduledTaskLogDao {
/// 新增任务开始日志并返回 SQLite 自增主键。
@insert
Future<int> save(ScheduledTaskLogEntity entity);
/// 按日志主键写回结束状态、耗时、错误和统计字段。
@update
Future<void> edit(ScheduledTaskLogEntity entity);
/// 按任务编码查询最近日志,可选依赖类型过滤,并按主键倒序限制返回数量。
@Query('''
SELECT * FROM scheduled_task_log
WHERE taskCode = :taskCode
AND (:sourceType = '' OR sourceType = :sourceType)
ORDER BY id DESC
LIMIT :limit
''')
Future<List<ScheduledTaskLogEntity>> getByTask(
String taskCode,
String sourceType,
int limit,
);
/// 按任务编码和可选来源分页查询日志,使用主键倒序保证翻页期间最新记录始终位于前页。
@Query('''
SELECT * FROM scheduled_task_log
WHERE taskCode = :taskCode
AND (:sourceType = '' OR sourceType = :sourceType)
ORDER BY id DESC
LIMIT :pageSize OFFSET :offset
''')
Future<List<ScheduledTaskLogEntity>> getPageByTask(
String taskCode,
String sourceType,
int pageSize,
int offset,
);
/// 统计指定任务和可选来源下的日志总数,为分页组件提供准确的总记录数。
@Query('''
SELECT COUNT(0) FROM scheduled_task_log
WHERE taskCode = :taskCode
AND (:sourceType = '' OR sourceType = :sourceType)
''')
Future<int?> countByTask(String taskCode, String sourceType);
/// 查询指定任务最近一条日志;从未执行时返回空。
@Query('''
SELECT * FROM scheduled_task_log
WHERE taskCode = :taskCode
ORDER BY id DESC
LIMIT 1
''')
Future<ScheduledTaskLogEntity?> getLatest(String taskCode);
/// 终端长期常驻,执行日志只保留最近 2000 条,避免数据库无限增长。
@Query('''
DELETE FROM scheduled_task_log
WHERE id NOT IN (
SELECT id FROM scheduled_task_log ORDER BY id DESC LIMIT 2000
)
''')
Future<void> trimHistory();
}
+42 -1
View File
@@ -9,6 +9,7 @@ import 'package:sqflite/sqflite.dart' as sqflite;
part 'database.g.dart';
/// Floor 代码生成器使用的全部业务实体类型,新增实体时必须同步数据库版本和连续迁移。
const entities = <Type>[
BroadcastEntity,
OrderEntity,
@@ -23,8 +24,12 @@ const entities = <Type>[
UserEntity,
FaceEntity,
SettingEntity,
ScheduledTaskEntity,
ScheduledTaskLogEntity,
FileUploadRecordEntity,
];
/// Floor 在实体字段与 SQLite 基础类型之间使用的全局转换器集合。
const typeConverters = <Type>[
DateTimeConverter,
DateTimeEmptyConverter,
@@ -41,23 +46,59 @@ const typeConverters = <Type>[
BoolConverter,
];
@Database(version: 7, entities: entities)
/// 收银终端共享 SQLite 数据库入口,统一暴露各业务 DAO 和带迁移的建库方法。
@Database(version: 8, entities: entities)
@TypeConverters(typeConverters)
abstract class AppDatabase extends FloorDatabase {
/// 取餐广播队列 DAO。
BroadcastDao get getBroadcastDao;
/// 本地订单及离线订单 DAO。
OrderDao get getOrderDao;
/// 挂单数据 DAO。
OrderHoldBillDao get getOrderHoldBillDao;
/// 食堂商品 DAO。
CanteenSpuDao get getCanteenSpuDao;
/// 超市 SKU DAO。
SupermarketSkuDao get getSupermarketSkuDao;
/// 超市 SPU DAO。
SupermarketSpuDao get getSupermarketSpuDao;
/// 超市商品条码 DAO。
SupermarketCodeDao get getSupermarketCodeDao;
/// 店铺卡配置 DAO。
ShopCardDao get getShopCardDao;
/// 商品分类 DAO。
ClassifyDao get getClassifyDao;
/// 打印历史记录 DAO。
PrintHistoryDao get getPrintHistoryDao;
/// 本地用户数据 DAO。
UserDao get getUserDao;
/// 本地人脸数据 DAO。
FaceDao get getFaceDao;
/// 终端设置 DAO。
SettingDao get getSettingDao;
/// 定时任务配置及最近执行快照 DAO。
ScheduledTaskDao get getScheduledTaskDao;
/// 定时任务结构化执行日志 DAO。
ScheduledTaskLogDao get getScheduledTaskLogDao;
/// 文件上传状态与幂等快照 DAO。
FileUploadRecordDao get getFileUploadRecordDao;
/// 使用统一外部存储路径打开数据库并按版本顺序执行全部 Floor Migration。
static Future<AppDatabase> getDataBase() async {
final dbPath = await AppFileTool.getDatabasePath();
final name = "$dbPath/${Constants.databaseName}";
+379 -1
View File
@@ -98,13 +98,19 @@ class _$AppDatabase extends AppDatabase {
SettingDao? _getSettingDaoInstance;
ScheduledTaskDao? _getScheduledTaskDaoInstance;
ScheduledTaskLogDao? _getScheduledTaskLogDaoInstance;
FileUploadRecordDao? _getFileUploadRecordDaoInstance;
Future<sqflite.Database> open(
String path,
List<Migration> migrations, [
Callback? callback,
]) async {
final databaseOptions = sqflite.OpenDatabaseOptions(
version: 7,
version: 8,
onConfigure: (database) async {
await database.execute('PRAGMA foreign_keys = ON');
await callback?.onConfigure?.call(database);
@@ -145,6 +151,20 @@ class _$AppDatabase extends AppDatabase {
'CREATE TABLE IF NOT EXISTS `face` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `sampleId` TEXT NOT NULL, `userName` TEXT NOT NULL, `deptId` TEXT, `deptName` TEXT, `imagePath` TEXT NOT NULL, `path` TEXT, `faceModel` TEXT, `createTime` TEXT, `updateTime` TEXT)');
await database.execute(
'CREATE TABLE IF NOT EXISTS `setting` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userJson` TEXT NOT NULL, `sysJson` TEXT NOT NULL, `canteenExtraPriceConfigJson` TEXT)');
await database.execute(
'CREATE TABLE IF NOT EXISTS `scheduled_task` (`taskCode` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `cronExpression` TEXT NOT NULL, `idleOnly` INTEGER NOT NULL, `lastRunAt` TEXT, `lastSuccessAt` TEXT, `lastStatus` TEXT NOT NULL, `lastMessage` TEXT NOT NULL, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, PRIMARY KEY (`taskCode`))');
await database.execute(
'CREATE TABLE IF NOT EXISTS `scheduled_task_log` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `taskCode` TEXT NOT NULL, `taskName` TEXT NOT NULL, `triggerType` TEXT NOT NULL, `sourceType` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `status` TEXT NOT NULL, `message` TEXT NOT NULL, `errorType` TEXT, `errorMessage` TEXT, `errorStack` TEXT, `startedAt` TEXT NOT NULL, `finishedAt` TEXT, `durationMs` INTEGER NOT NULL, `scannedCount` INTEGER, `skippedCount` INTEGER, `successCount` INTEGER, `failureCount` INTEGER)');
await database.execute(
'CREATE TABLE IF NOT EXISTS `file_upload_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `hardId` TEXT NOT NULL, `bizType` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `originalName` TEXT NOT NULL, `sourceSize` INTEGER NOT NULL, `sourceModifiedTime` TEXT NOT NULL, `sanitizedHash` TEXT NOT NULL, `sanitizedSize` INTEGER NOT NULL, `status` TEXT NOT NULL, `serverFileId` TEXT, `attemptCount` INTEGER NOT NULL, `lastAttemptAt` TEXT, `uploadedAt` TEXT, `lastError` TEXT, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL)');
await database.execute(
'CREATE INDEX `index_scheduled_task_log_taskCode_startedAt` ON `scheduled_task_log` (`taskCode`, `startedAt`)');
await database.execute(
'CREATE INDEX `index_scheduled_task_log_sourceType_status_startedAt` ON `scheduled_task_log` (`sourceType`, `status`, `startedAt`)');
await database.execute(
'CREATE UNIQUE INDEX `index_file_upload_record_hardId_bizType_relativePath` ON `file_upload_record` (`hardId`, `bizType`, `relativePath`)');
await database.execute(
'CREATE INDEX `index_file_upload_record_bizType_status_updateTime` ON `file_upload_record` (`bizType`, `status`, `updateTime`)');
await callback?.onCreate?.call(database, version);
},
@@ -223,6 +243,24 @@ class _$AppDatabase extends AppDatabase {
SettingDao get getSettingDao {
return _getSettingDaoInstance ??= _$SettingDao(database, changeListener);
}
@override
ScheduledTaskDao get getScheduledTaskDao {
return _getScheduledTaskDaoInstance ??=
_$ScheduledTaskDao(database, changeListener);
}
@override
ScheduledTaskLogDao get getScheduledTaskLogDao {
return _getScheduledTaskLogDaoInstance ??=
_$ScheduledTaskLogDao(database, changeListener);
}
@override
FileUploadRecordDao get getFileUploadRecordDao {
return _getFileUploadRecordDaoInstance ??=
_$FileUploadRecordDao(database, changeListener);
}
}
class _$BroadcastDao extends BroadcastDao {
@@ -1981,6 +2019,346 @@ class _$SettingDao extends SettingDao {
}
}
class _$ScheduledTaskDao extends ScheduledTaskDao {
_$ScheduledTaskDao(
this.database,
this.changeListener,
) : _queryAdapter = QueryAdapter(database),
_scheduledTaskEntityInsertionAdapter = InsertionAdapter(
database,
'scheduled_task',
(ScheduledTaskEntity item) => <String, Object?>{
'taskCode': item.taskCode,
'enabled': _boolConverter.encode(item.enabled),
'cronExpression': item.cronExpression,
'idleOnly': _boolConverter.encode(item.idleOnly),
'lastRunAt': _dateTimeEmptyConverter.encode(item.lastRunAt),
'lastSuccessAt':
_dateTimeEmptyConverter.encode(item.lastSuccessAt),
'lastStatus': item.lastStatus,
'lastMessage': item.lastMessage,
'createTime': _dateTimeConverter.encode(item.createTime),
'updateTime': _dateTimeConverter.encode(item.updateTime)
}),
_scheduledTaskEntityUpdateAdapter = UpdateAdapter(
database,
'scheduled_task',
['taskCode'],
(ScheduledTaskEntity item) => <String, Object?>{
'taskCode': item.taskCode,
'enabled': _boolConverter.encode(item.enabled),
'cronExpression': item.cronExpression,
'idleOnly': _boolConverter.encode(item.idleOnly),
'lastRunAt': _dateTimeEmptyConverter.encode(item.lastRunAt),
'lastSuccessAt':
_dateTimeEmptyConverter.encode(item.lastSuccessAt),
'lastStatus': item.lastStatus,
'lastMessage': item.lastMessage,
'createTime': _dateTimeConverter.encode(item.createTime),
'updateTime': _dateTimeConverter.encode(item.updateTime)
});
final sqflite.DatabaseExecutor database;
final StreamController<String> changeListener;
final QueryAdapter _queryAdapter;
final InsertionAdapter<ScheduledTaskEntity>
_scheduledTaskEntityInsertionAdapter;
final UpdateAdapter<ScheduledTaskEntity> _scheduledTaskEntityUpdateAdapter;
@override
Future<List<ScheduledTaskEntity>> getAll() async {
return _queryAdapter.queryList(
'SELECT * FROM scheduled_task ORDER BY taskCode',
mapper: (Map<String, Object?> row) => ScheduledTaskEntity(
taskCode: row['taskCode'] as String,
enabled: _boolConverter.decode(row['enabled'] as int),
cronExpression: row['cronExpression'] as String,
idleOnly: _boolConverter.decode(row['idleOnly'] as int),
lastRunAt:
_dateTimeEmptyConverter.decode(row['lastRunAt'] as String),
lastSuccessAt:
_dateTimeEmptyConverter.decode(row['lastSuccessAt'] as String),
lastStatus: row['lastStatus'] as String,
lastMessage: row['lastMessage'] as String,
createTime: _dateTimeConverter.decode(row['createTime'] as String),
updateTime:
_dateTimeConverter.decode(row['updateTime'] as String)));
}
@override
Future<ScheduledTaskEntity?> getByCode(String taskCode) async {
return _queryAdapter.query(
'SELECT * FROM scheduled_task WHERE taskCode = ?1 LIMIT 1',
mapper: (Map<String, Object?> row) => ScheduledTaskEntity(
taskCode: row['taskCode'] as String,
enabled: _boolConverter.decode(row['enabled'] as int),
cronExpression: row['cronExpression'] as String,
idleOnly: _boolConverter.decode(row['idleOnly'] as int),
lastRunAt:
_dateTimeEmptyConverter.decode(row['lastRunAt'] as String),
lastSuccessAt:
_dateTimeEmptyConverter.decode(row['lastSuccessAt'] as String),
lastStatus: row['lastStatus'] as String,
lastMessage: row['lastMessage'] as String,
createTime: _dateTimeConverter.decode(row['createTime'] as String),
updateTime: _dateTimeConverter.decode(row['updateTime'] as String)),
arguments: [taskCode]);
}
@override
Future<void> save(ScheduledTaskEntity entity) async {
await _scheduledTaskEntityInsertionAdapter.insert(
entity, OnConflictStrategy.replace);
}
@override
Future<void> batchSave(List<ScheduledTaskEntity> entities) async {
await _scheduledTaskEntityInsertionAdapter.insertList(
entities, OnConflictStrategy.replace);
}
@override
Future<void> edit(ScheduledTaskEntity entity) async {
await _scheduledTaskEntityUpdateAdapter.update(
entity, OnConflictStrategy.abort);
}
}
class _$ScheduledTaskLogDao extends ScheduledTaskLogDao {
_$ScheduledTaskLogDao(
this.database,
this.changeListener,
) : _queryAdapter = QueryAdapter(database),
_scheduledTaskLogEntityInsertionAdapter = InsertionAdapter(
database,
'scheduled_task_log',
(ScheduledTaskLogEntity item) => <String, Object?>{
'id': item.id,
'taskCode': item.taskCode,
'taskName': item.taskName,
'triggerType': item.triggerType,
'sourceType': item.sourceType,
'sourceName': item.sourceName,
'status': item.status,
'message': item.message,
'errorType': item.errorType,
'errorMessage': item.errorMessage,
'errorStack': item.errorStack,
'startedAt': _dateTimeConverter.encode(item.startedAt),
'finishedAt': _dateTimeEmptyConverter.encode(item.finishedAt),
'durationMs': item.durationMs,
'scannedCount': item.scannedCount,
'skippedCount': item.skippedCount,
'successCount': item.successCount,
'failureCount': item.failureCount
}),
_scheduledTaskLogEntityUpdateAdapter = UpdateAdapter(
database,
'scheduled_task_log',
['id'],
(ScheduledTaskLogEntity item) => <String, Object?>{
'id': item.id,
'taskCode': item.taskCode,
'taskName': item.taskName,
'triggerType': item.triggerType,
'sourceType': item.sourceType,
'sourceName': item.sourceName,
'status': item.status,
'message': item.message,
'errorType': item.errorType,
'errorMessage': item.errorMessage,
'errorStack': item.errorStack,
'startedAt': _dateTimeConverter.encode(item.startedAt),
'finishedAt': _dateTimeEmptyConverter.encode(item.finishedAt),
'durationMs': item.durationMs,
'scannedCount': item.scannedCount,
'skippedCount': item.skippedCount,
'successCount': item.successCount,
'failureCount': item.failureCount
});
final sqflite.DatabaseExecutor database;
final StreamController<String> changeListener;
final QueryAdapter _queryAdapter;
final InsertionAdapter<ScheduledTaskLogEntity>
_scheduledTaskLogEntityInsertionAdapter;
final UpdateAdapter<ScheduledTaskLogEntity>
_scheduledTaskLogEntityUpdateAdapter;
@override
Future<List<ScheduledTaskLogEntity>> getByTask(
String taskCode,
String sourceType,
int limit,
) async {
return _queryAdapter.queryList(
'SELECT * FROM scheduled_task_log WHERE taskCode = ?1 AND (?2 = \'\' OR sourceType = ?2) ORDER BY id DESC LIMIT ?3',
mapper: (Map<String, Object?> row) => ScheduledTaskLogEntity(id: row['id'] as int?, taskCode: row['taskCode'] as String, taskName: row['taskName'] as String, triggerType: row['triggerType'] as String, sourceType: row['sourceType'] as String, sourceName: row['sourceName'] as String, status: row['status'] as String, message: row['message'] as String, errorType: row['errorType'] as String?, errorMessage: row['errorMessage'] as String?, errorStack: row['errorStack'] as String?, startedAt: _dateTimeConverter.decode(row['startedAt'] as String), finishedAt: _dateTimeEmptyConverter.decode(row['finishedAt'] as String), durationMs: row['durationMs'] as int, scannedCount: row['scannedCount'] as int?, skippedCount: row['skippedCount'] as int?, successCount: row['successCount'] as int?, failureCount: row['failureCount'] as int?),
arguments: [taskCode, sourceType, limit]);
}
@override
Future<List<ScheduledTaskLogEntity>> getPageByTask(
String taskCode,
String sourceType,
int pageSize,
int offset,
) async {
return _queryAdapter.queryList(
'SELECT * FROM scheduled_task_log WHERE taskCode = ?1 AND (?2 = \'\' OR sourceType = ?2) ORDER BY id DESC LIMIT ?3 OFFSET ?4',
mapper: (Map<String, Object?> row) => ScheduledTaskLogEntity(id: row['id'] as int?, taskCode: row['taskCode'] as String, taskName: row['taskName'] as String, triggerType: row['triggerType'] as String, sourceType: row['sourceType'] as String, sourceName: row['sourceName'] as String, status: row['status'] as String, message: row['message'] as String, errorType: row['errorType'] as String?, errorMessage: row['errorMessage'] as String?, errorStack: row['errorStack'] as String?, startedAt: _dateTimeConverter.decode(row['startedAt'] as String), finishedAt: _dateTimeEmptyConverter.decode(row['finishedAt'] as String), durationMs: row['durationMs'] as int, scannedCount: row['scannedCount'] as int?, skippedCount: row['skippedCount'] as int?, successCount: row['successCount'] as int?, failureCount: row['failureCount'] as int?),
arguments: [taskCode, sourceType, pageSize, offset]);
}
@override
Future<int?> countByTask(
String taskCode,
String sourceType,
) async {
return _queryAdapter.query(
'SELECT COUNT(0) FROM scheduled_task_log WHERE taskCode = ?1 AND (?2 = \'\' OR sourceType = ?2)',
mapper: (Map<String, Object?> row) => row.values.first as int,
arguments: [taskCode, sourceType]);
}
@override
Future<ScheduledTaskLogEntity?> getLatest(String taskCode) async {
return _queryAdapter.query(
'SELECT * FROM scheduled_task_log WHERE taskCode = ?1 ORDER BY id DESC LIMIT 1',
mapper: (Map<String, Object?> row) => ScheduledTaskLogEntity(id: row['id'] as int?, taskCode: row['taskCode'] as String, taskName: row['taskName'] as String, triggerType: row['triggerType'] as String, sourceType: row['sourceType'] as String, sourceName: row['sourceName'] as String, status: row['status'] as String, message: row['message'] as String, errorType: row['errorType'] as String?, errorMessage: row['errorMessage'] as String?, errorStack: row['errorStack'] as String?, startedAt: _dateTimeConverter.decode(row['startedAt'] as String), finishedAt: _dateTimeEmptyConverter.decode(row['finishedAt'] as String), durationMs: row['durationMs'] as int, scannedCount: row['scannedCount'] as int?, skippedCount: row['skippedCount'] as int?, successCount: row['successCount'] as int?, failureCount: row['failureCount'] as int?),
arguments: [taskCode]);
}
@override
Future<void> trimHistory() async {
await _queryAdapter.queryNoReturn(
'DELETE FROM scheduled_task_log WHERE id NOT IN ( SELECT id FROM scheduled_task_log ORDER BY id DESC LIMIT 2000 )');
}
@override
Future<int> save(ScheduledTaskLogEntity entity) {
return _scheduledTaskLogEntityInsertionAdapter.insertAndReturnId(
entity, OnConflictStrategy.abort);
}
@override
Future<void> edit(ScheduledTaskLogEntity entity) async {
await _scheduledTaskLogEntityUpdateAdapter.update(
entity, OnConflictStrategy.abort);
}
}
class _$FileUploadRecordDao extends FileUploadRecordDao {
_$FileUploadRecordDao(
this.database,
this.changeListener,
) : _queryAdapter = QueryAdapter(database),
_fileUploadRecordEntityInsertionAdapter = InsertionAdapter(
database,
'file_upload_record',
(FileUploadRecordEntity item) => <String, Object?>{
'id': item.id,
'hardId': item.hardId,
'bizType': item.bizType,
'relativePath': item.relativePath,
'originalName': item.originalName,
'sourceSize': item.sourceSize,
'sourceModifiedTime':
_dateTimeConverter.encode(item.sourceModifiedTime),
'sanitizedHash': item.sanitizedHash,
'sanitizedSize': item.sanitizedSize,
'status': item.status,
'serverFileId': item.serverFileId,
'attemptCount': item.attemptCount,
'lastAttemptAt':
_dateTimeEmptyConverter.encode(item.lastAttemptAt),
'uploadedAt': _dateTimeEmptyConverter.encode(item.uploadedAt),
'lastError': item.lastError,
'createTime': _dateTimeConverter.encode(item.createTime),
'updateTime': _dateTimeConverter.encode(item.updateTime)
}),
_fileUploadRecordEntityUpdateAdapter = UpdateAdapter(
database,
'file_upload_record',
['id'],
(FileUploadRecordEntity item) => <String, Object?>{
'id': item.id,
'hardId': item.hardId,
'bizType': item.bizType,
'relativePath': item.relativePath,
'originalName': item.originalName,
'sourceSize': item.sourceSize,
'sourceModifiedTime':
_dateTimeConverter.encode(item.sourceModifiedTime),
'sanitizedHash': item.sanitizedHash,
'sanitizedSize': item.sanitizedSize,
'status': item.status,
'serverFileId': item.serverFileId,
'attemptCount': item.attemptCount,
'lastAttemptAt':
_dateTimeEmptyConverter.encode(item.lastAttemptAt),
'uploadedAt': _dateTimeEmptyConverter.encode(item.uploadedAt),
'lastError': item.lastError,
'createTime': _dateTimeConverter.encode(item.createTime),
'updateTime': _dateTimeConverter.encode(item.updateTime)
});
final sqflite.DatabaseExecutor database;
final StreamController<String> changeListener;
final QueryAdapter _queryAdapter;
final InsertionAdapter<FileUploadRecordEntity>
_fileUploadRecordEntityInsertionAdapter;
final UpdateAdapter<FileUploadRecordEntity>
_fileUploadRecordEntityUpdateAdapter;
@override
Future<List<FileUploadRecordEntity>> getAll(
String hardId,
String bizType,
) async {
return _queryAdapter.queryList(
'SELECT * FROM file_upload_record WHERE hardId = ?1 AND bizType = ?2 ORDER BY updateTime DESC',
mapper: (Map<String, Object?> row) => FileUploadRecordEntity(id: row['id'] as int?, hardId: row['hardId'] as String, bizType: row['bizType'] as String, relativePath: row['relativePath'] as String, originalName: row['originalName'] as String, sourceSize: row['sourceSize'] as int, sourceModifiedTime: _dateTimeConverter.decode(row['sourceModifiedTime'] as String), sanitizedHash: row['sanitizedHash'] as String, sanitizedSize: row['sanitizedSize'] as int, status: row['status'] as String, serverFileId: row['serverFileId'] as String?, attemptCount: row['attemptCount'] as int, lastAttemptAt: _dateTimeEmptyConverter.decode(row['lastAttemptAt'] as String), uploadedAt: _dateTimeEmptyConverter.decode(row['uploadedAt'] as String), lastError: row['lastError'] as String?, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String)),
arguments: [hardId, bizType]);
}
@override
Future<FileUploadRecordEntity?> getByPath(
String hardId,
String bizType,
String relativePath,
) async {
return _queryAdapter.query(
'SELECT * FROM file_upload_record WHERE hardId = ?1 AND bizType = ?2 AND relativePath = ?3 LIMIT 1',
mapper: (Map<String, Object?> row) => FileUploadRecordEntity(id: row['id'] as int?, hardId: row['hardId'] as String, bizType: row['bizType'] as String, relativePath: row['relativePath'] as String, originalName: row['originalName'] as String, sourceSize: row['sourceSize'] as int, sourceModifiedTime: _dateTimeConverter.decode(row['sourceModifiedTime'] as String), sanitizedHash: row['sanitizedHash'] as String, sanitizedSize: row['sanitizedSize'] as int, status: row['status'] as String, serverFileId: row['serverFileId'] as String?, attemptCount: row['attemptCount'] as int, lastAttemptAt: _dateTimeEmptyConverter.decode(row['lastAttemptAt'] as String), uploadedAt: _dateTimeEmptyConverter.decode(row['uploadedAt'] as String), lastError: row['lastError'] as String?, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String)),
arguments: [hardId, bizType, relativePath]);
}
@override
Future<int> save(FileUploadRecordEntity entity) {
return _fileUploadRecordEntityInsertionAdapter.insertAndReturnId(
entity, OnConflictStrategy.replace);
}
@override
Future<void> edit(FileUploadRecordEntity entity) async {
await _fileUploadRecordEntityUpdateAdapter.update(
entity, OnConflictStrategy.abort);
}
}
// ignore_for_file: unused_element
final _dateTimeConverter = DateTimeConverter();
final _dateTimeEmptyConverter = DateTimeEmptyConverter();
@@ -1,5 +1,6 @@
import 'package:floor/floor.dart';
/// 收银终端 SQLite 连续版本迁移清单,禁止跳过中间版本或在原生层重复执行结构 SQL。
abstract class DatabaseMigration {
/// 从版本1到版本2的迁移:添加productNamePy字段
static Migration migration1To2 = Migration(1, 2, (database) async {
@@ -183,6 +184,84 @@ abstract class DatabaseMigration {
}
});
/// 版本8将调度配置、执行日志和文件上传状态从 sysJson 收敛为可查询的结构化表。
static Migration migration7To8 = Migration(7, 8, (database) async {
await database.execute('''
CREATE TABLE IF NOT EXISTS scheduled_task (
taskCode TEXT NOT NULL PRIMARY KEY,
enabled INTEGER NOT NULL,
cronExpression TEXT NOT NULL,
idleOnly INTEGER NOT NULL,
lastRunAt TEXT,
lastSuccessAt TEXT,
lastStatus TEXT NOT NULL,
lastMessage TEXT NOT NULL,
createTime TEXT NOT NULL,
updateTime TEXT NOT NULL
)
''');
await database.execute('''
CREATE TABLE IF NOT EXISTS scheduled_task_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
taskCode TEXT NOT NULL,
taskName TEXT NOT NULL,
triggerType TEXT NOT NULL,
sourceType TEXT NOT NULL,
sourceName TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT NOT NULL,
errorType TEXT,
errorMessage TEXT,
errorStack TEXT,
startedAt TEXT NOT NULL,
finishedAt TEXT,
durationMs INTEGER NOT NULL,
scannedCount INTEGER,
skippedCount INTEGER,
successCount INTEGER,
failureCount INTEGER
)
''');
await database.execute(
'CREATE INDEX IF NOT EXISTS index_scheduled_task_log_taskCode_startedAt '
'ON scheduled_task_log(taskCode, startedAt)',
);
await database.execute(
'CREATE INDEX IF NOT EXISTS index_scheduled_task_log_sourceType_status_startedAt '
'ON scheduled_task_log(sourceType, status, startedAt)',
);
await database.execute('''
CREATE TABLE IF NOT EXISTS file_upload_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hardId TEXT NOT NULL,
bizType TEXT NOT NULL,
relativePath TEXT NOT NULL,
originalName TEXT NOT NULL,
sourceSize INTEGER NOT NULL,
sourceModifiedTime TEXT NOT NULL,
sanitizedHash TEXT NOT NULL,
sanitizedSize INTEGER NOT NULL,
status TEXT NOT NULL,
serverFileId TEXT,
attemptCount INTEGER NOT NULL,
lastAttemptAt TEXT,
uploadedAt TEXT,
lastError TEXT,
createTime TEXT NOT NULL,
updateTime TEXT NOT NULL
)
''');
await database.execute(
'CREATE UNIQUE INDEX IF NOT EXISTS index_file_upload_record_hardId_bizType_relativePath '
'ON file_upload_record(hardId, bizType, relativePath)',
);
await database.execute(
'CREATE INDEX IF NOT EXISTS index_file_upload_record_bizType_status_updateTime '
'ON file_upload_record(bizType, status, updateTime)',
);
});
/// 按起始版本升序返回全部迁移,Floor 打开旧数据库时据此连续升级到当前版本。
static List<Migration> getDatabaseMigrations() {
return [
migration1To2,
@@ -191,6 +270,7 @@ abstract class DatabaseMigration {
migration4To5,
migration5To6,
migration6To7,
migration7To8,
];
}
}
@@ -0,0 +1,97 @@
import 'package:floor/floor.dart';
import 'package:json_annotation/json_annotation.dart';
part 'file_upload_record_entity.g.dart';
/// 本机文件上传记录。
///
/// 目前 [bizType] 使用 `terminal_log`,保留业务类型后可复用到数据库备份等文件任务。
@JsonSerializable()
@Entity(
tableName: 'file_upload_record',
indices: [
Index(value: ['hardId', 'bizType', 'relativePath'], unique: true),
Index(value: ['bizType', 'status', 'updateTime']),
],
)
class FileUploadRecordEntity {
/// SQLite 自增主键;首次保存前为空。
@PrimaryKey(autoGenerate: true)
final int? id;
/// 终端绑定的设备 ID,用于隔离设备重新绑定前后的文件状态。
final String hardId;
/// 文件业务类型;终端日志固定使用 `terminal_log`。
final String bizType;
/// 文件在业务根目录下使用正斜杠的相对路径。
final String relativePath;
/// 原始文件名,不包含目录。
final String originalName;
/// 生成脱敏副本时读取的原文件快照大小,单位为字节。
final int sourceSize;
/// 生成脱敏副本时原文件的最后修改时间。
final DateTime sourceModifiedTime;
/// 脱敏后文件内容的 MD5 哈希;首次尝试尚未完成脱敏时可为空字符串。
final String sanitizedHash;
/// 脱敏后文件大小,单位为字节;尚未完成脱敏时为零。
final int sanitizedSize;
/// 最近一次上传尝试状态,保存 [FileUploadStatus.name]。
final String status;
/// 后端 hard_terminal_log_file 记录 ID;预检判定无需上传且本机无旧记录时可为空。
final String? serverFileId;
/// 对该路径发起过的上传尝试次数,预检前即递增。
final int attemptCount;
/// 最近一次开始上传尝试的本地时间;从未尝试时为空。
final DateTime? lastAttemptAt;
/// 最近一次被服务端确认成功或无需上传的本地时间。
final DateTime? uploadedAt;
/// 最近一次失败的截断错误摘要;成功记录为空。
final String? lastError;
/// 当前设备与业务类型下首次创建该路径记录的本地时间。
final DateTime createTime;
/// 状态、快照或服务端 ID 最近一次更新的本地时间。
final DateTime updateTime;
/// 创建一条文件上传状态记录。
const FileUploadRecordEntity({
this.id,
required this.hardId,
required this.bizType,
required this.relativePath,
required this.originalName,
required this.sourceSize,
required this.sourceModifiedTime,
required this.sanitizedHash,
required this.sanitizedSize,
required this.status,
this.serverFileId,
this.attemptCount = 0,
this.lastAttemptAt,
this.uploadedAt,
this.lastError,
required this.createTime,
required this.updateTime,
});
/// 从 JSON 数据恢复上传记录,仅供调试序列化使用。
factory FileUploadRecordEntity.fromJson(Map<String, dynamic> json) =>
_$FileUploadRecordEntityFromJson(json);
/// 将上传记录转换为 JSON,仅供调试序列化使用。
Map<String, dynamic> toJson() => _$FileUploadRecordEntityToJson(this);
}
@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'file_upload_record_entity.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FileUploadRecordEntity _$FileUploadRecordEntityFromJson(
Map<String, dynamic> json) =>
FileUploadRecordEntity(
id: (json['id'] as num?)?.toInt(),
hardId: json['hardId'] as String,
bizType: json['bizType'] as String,
relativePath: json['relativePath'] as String,
originalName: json['originalName'] as String,
sourceSize: (json['sourceSize'] as num).toInt(),
sourceModifiedTime: DateTime.parse(json['sourceModifiedTime'] as String),
sanitizedHash: json['sanitizedHash'] as String,
sanitizedSize: (json['sanitizedSize'] as num).toInt(),
status: json['status'] as String,
serverFileId: json['serverFileId'] as String?,
attemptCount: (json['attemptCount'] as num?)?.toInt() ?? 0,
lastAttemptAt: json['lastAttemptAt'] == null
? null
: DateTime.parse(json['lastAttemptAt'] as String),
uploadedAt: json['uploadedAt'] == null
? null
: DateTime.parse(json['uploadedAt'] as String),
lastError: json['lastError'] as String?,
createTime: DateTime.parse(json['createTime'] as String),
updateTime: DateTime.parse(json['updateTime'] as String),
);
Map<String, dynamic> _$FileUploadRecordEntityToJson(
FileUploadRecordEntity instance) =>
<String, dynamic>{
'id': instance.id,
'hardId': instance.hardId,
'bizType': instance.bizType,
'relativePath': instance.relativePath,
'originalName': instance.originalName,
'sourceSize': instance.sourceSize,
'sourceModifiedTime': instance.sourceModifiedTime.toIso8601String(),
'sanitizedHash': instance.sanitizedHash,
'sanitizedSize': instance.sanitizedSize,
'status': instance.status,
'serverFileId': instance.serverFileId,
'attemptCount': instance.attemptCount,
'lastAttemptAt': instance.lastAttemptAt?.toIso8601String(),
'uploadedAt': instance.uploadedAt?.toIso8601String(),
'lastError': instance.lastError,
'createTime': instance.createTime.toIso8601String(),
'updateTime': instance.updateTime.toIso8601String(),
};
@@ -0,0 +1,87 @@
import 'package:floor/floor.dart';
import 'package:json_annotation/json_annotation.dart';
part 'scheduled_task_entity.g.dart';
/// 终端定时任务配置及最近一次执行快照。
///
/// [taskCode] 使用代码枚举名作为稳定主键,界面文案调整时不会产生重复任务。
@JsonSerializable()
@Entity(tableName: 'scheduled_task')
class ScheduledTaskEntity {
/// 稳定任务编码,取 [ScheduledTaskType.name],作为表主键且不能随展示文案变化。
@PrimaryKey()
final String taskCode;
/// 是否启用 Cron 自动调度;关闭时仍可通过设置页手动触发。
final bool enabled;
/// cron 0.6.2 使用的六段表达式,字段顺序为秒、分、时、日、月、周。
final String cronExpression;
/// 自动任务是否必须等待连续 60 秒无人操作的完整空闲窗口。
final bool idleOnly;
/// 最近一次任务开始执行的本地时间;从未执行时为空。
final DateTime? lastRunAt;
/// 最近一次成功完成的本地时间;尚无成功记录时为空。
final DateTime? lastSuccessAt;
/// 最近一次运行状态,保存 [ScheduledTaskRunStatus.name]。
final String lastStatus;
/// 最近一次运行结果的简短中文摘要。
final String lastMessage;
/// 任务配置首次写入 SQLite 的本地时间。
final DateTime createTime;
/// 配置或执行快照最近一次更新的本地时间。
final DateTime updateTime;
/// 创建一条定时任务配置与最近执行快照。
const ScheduledTaskEntity({
required this.taskCode,
required this.enabled,
required this.cronExpression,
required this.idleOnly,
this.lastRunAt,
this.lastSuccessAt,
required this.lastStatus,
required this.lastMessage,
required this.createTime,
required this.updateTime,
});
/// 从 JSON 数据恢复实体,仅供兼容迁移和调试序列化使用。
factory ScheduledTaskEntity.fromJson(Map<String, dynamic> json) =>
_$ScheduledTaskEntityFromJson(json);
/// 将实体转换为 JSON,仅供兼容迁移和调试序列化使用。
Map<String, dynamic> toJson() => _$ScheduledTaskEntityToJson(this);
/// 保留主键与创建时间,生成包含配置或最近执行结果变更的新实体。
ScheduledTaskEntity copyWith({
bool? enabled,
String? cronExpression,
bool? idleOnly,
DateTime? lastRunAt,
DateTime? lastSuccessAt,
String? lastStatus,
String? lastMessage,
DateTime? updateTime,
}) =>
ScheduledTaskEntity(
taskCode: taskCode,
enabled: enabled ?? this.enabled,
cronExpression: cronExpression ?? this.cronExpression,
idleOnly: idleOnly ?? this.idleOnly,
lastRunAt: lastRunAt ?? this.lastRunAt,
lastSuccessAt: lastSuccessAt ?? this.lastSuccessAt,
lastStatus: lastStatus ?? this.lastStatus,
lastMessage: lastMessage ?? this.lastMessage,
createTime: createTime,
updateTime: updateTime ?? this.updateTime,
);
}
@@ -0,0 +1,40 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'scheduled_task_entity.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ScheduledTaskEntity _$ScheduledTaskEntityFromJson(Map<String, dynamic> json) =>
ScheduledTaskEntity(
taskCode: json['taskCode'] as String,
enabled: json['enabled'] as bool,
cronExpression: json['cronExpression'] as String,
idleOnly: json['idleOnly'] as bool,
lastRunAt: json['lastRunAt'] == null
? null
: DateTime.parse(json['lastRunAt'] as String),
lastSuccessAt: json['lastSuccessAt'] == null
? null
: DateTime.parse(json['lastSuccessAt'] as String),
lastStatus: json['lastStatus'] as String,
lastMessage: json['lastMessage'] as String,
createTime: DateTime.parse(json['createTime'] as String),
updateTime: DateTime.parse(json['updateTime'] as String),
);
Map<String, dynamic> _$ScheduledTaskEntityToJson(
ScheduledTaskEntity instance) =>
<String, dynamic>{
'taskCode': instance.taskCode,
'enabled': instance.enabled,
'cronExpression': instance.cronExpression,
'idleOnly': instance.idleOnly,
'lastRunAt': instance.lastRunAt?.toIso8601String(),
'lastSuccessAt': instance.lastSuccessAt?.toIso8601String(),
'lastStatus': instance.lastStatus,
'lastMessage': instance.lastMessage,
'createTime': instance.createTime.toIso8601String(),
'updateTime': instance.updateTime.toIso8601String(),
};
@@ -0,0 +1,135 @@
import 'package:floor/floor.dart';
import 'package:json_annotation/json_annotation.dart';
part 'scheduled_task_log_entity.g.dart';
/// 定时任务的结构化执行日志。
///
/// 来源、触发方式、状态和统计值使用独立列,日志页面无需解析拼接文本即可筛选。
@JsonSerializable()
@Entity(
tableName: 'scheduled_task_log',
indices: [
Index(value: ['taskCode', 'startedAt']),
Index(value: ['sourceType', 'status', 'startedAt']),
],
)
class ScheduledTaskLogEntity {
/// SQLite 自增主键;日志尚未插入数据库时为空。
@PrimaryKey(autoGenerate: true)
final int? id;
/// 任务稳定编码,关联 scheduled_task.taskCode,但不建立外键以兼容任务版本演进。
final String taskCode;
/// 执行发生时的任务展示名称快照。
final String taskName;
/// 触发来源,保存 [ScheduledTaskTriggerType.name]。
final String triggerType;
/// 主要依赖类型,保存 [ScheduledTaskSourceType.name]。
final String sourceType;
/// 执行发生时的依赖名称快照,例如 MQTT 消息服务或 HTTP 商品接口。
final String sourceName;
/// 本次执行状态,保存 [ScheduledTaskRunStatus.name]。
final String status;
/// 本次执行结果的简短中文摘要。
final String message;
/// 异常运行时的 Dart 异常类型;成功或跳过时为空。
final String? errorType;
/// 截断并脱敏后的异常摘要;成功或跳过时为空。
final String? errorMessage;
/// 截断后的异常堆栈,最多保存调度器允许的诊断长度。
final String? errorStack;
/// 任务开始执行的本地时间。
final DateTime startedAt;
/// 任务结束的本地时间;执行中日志为空。
final DateTime? finishedAt;
/// 从开始到结束的耗时,单位为毫秒;执行中默认为零。
final int durationMs;
/// 文件类任务扫描到的文件数量;不适用的任务为空。
final int? scannedCount;
/// 文件类任务因未变化或服务端已存在而跳过的数量;不适用时为空。
final int? skippedCount;
/// 文件类任务实际上传成功的数量;不适用时为空。
final int? successCount;
/// 文件类任务处理失败的数量;不适用时为空。
final int? failureCount;
/// 创建一条任务执行日志;开始记录尚未结束时不填写结束信息。
const ScheduledTaskLogEntity({
this.id,
required this.taskCode,
required this.taskName,
required this.triggerType,
required this.sourceType,
required this.sourceName,
required this.status,
required this.message,
this.errorType,
this.errorMessage,
this.errorStack,
required this.startedAt,
this.finishedAt,
this.durationMs = 0,
this.scannedCount,
this.skippedCount,
this.successCount,
this.failureCount,
});
/// 从 JSON 数据恢复任务日志,仅供调试序列化使用。
factory ScheduledTaskLogEntity.fromJson(Map<String, dynamic> json) =>
_$ScheduledTaskLogEntityFromJson(json);
/// 将任务日志转换为 JSON,仅供调试序列化使用。
Map<String, dynamic> toJson() => _$ScheduledTaskLogEntityToJson(this);
/// 使用最终状态、错误和统计值生成同一条日志的完成快照。
ScheduledTaskLogEntity finish({
required String nextStatus,
required String nextMessage,
required DateTime at,
String? nextErrorType,
String? nextErrorMessage,
String? nextErrorStack,
int? scanned,
int? skipped,
int? succeeded,
int? failed,
}) =>
ScheduledTaskLogEntity(
id: id,
taskCode: taskCode,
taskName: taskName,
triggerType: triggerType,
sourceType: sourceType,
sourceName: sourceName,
status: nextStatus,
message: nextMessage,
errorType: nextErrorType,
errorMessage: nextErrorMessage,
errorStack: nextErrorStack,
startedAt: startedAt,
finishedAt: at,
durationMs: at.difference(startedAt).inMilliseconds,
scannedCount: scanned,
skippedCount: skipped,
successCount: succeeded,
failureCount: failed,
);
}
@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'scheduled_task_log_entity.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ScheduledTaskLogEntity _$ScheduledTaskLogEntityFromJson(
Map<String, dynamic> json) =>
ScheduledTaskLogEntity(
id: (json['id'] as num?)?.toInt(),
taskCode: json['taskCode'] as String,
taskName: json['taskName'] as String,
triggerType: json['triggerType'] as String,
sourceType: json['sourceType'] as String,
sourceName: json['sourceName'] as String,
status: json['status'] as String,
message: json['message'] as String,
errorType: json['errorType'] as String?,
errorMessage: json['errorMessage'] as String?,
errorStack: json['errorStack'] as String?,
startedAt: DateTime.parse(json['startedAt'] as String),
finishedAt: json['finishedAt'] == null
? null
: DateTime.parse(json['finishedAt'] as String),
durationMs: (json['durationMs'] as num?)?.toInt() ?? 0,
scannedCount: (json['scannedCount'] as num?)?.toInt(),
skippedCount: (json['skippedCount'] as num?)?.toInt(),
successCount: (json['successCount'] as num?)?.toInt(),
failureCount: (json['failureCount'] as num?)?.toInt(),
);
Map<String, dynamic> _$ScheduledTaskLogEntityToJson(
ScheduledTaskLogEntity instance) =>
<String, dynamic>{
'id': instance.id,
'taskCode': instance.taskCode,
'taskName': instance.taskName,
'triggerType': instance.triggerType,
'sourceType': instance.sourceType,
'sourceName': instance.sourceName,
'status': instance.status,
'message': instance.message,
'errorType': instance.errorType,
'errorMessage': instance.errorMessage,
'errorStack': instance.errorStack,
'startedAt': instance.startedAt.toIso8601String(),
'finishedAt': instance.finishedAt?.toIso8601String(),
'durationMs': instance.durationMs,
'scannedCount': instance.scannedCount,
'skippedCount': instance.skippedCount,
'successCount': instance.successCount,
'failureCount': instance.failureCount,
};