11
This commit is contained in:
1 parent
58a8a1ffde
commit
fdec7bed06
16 files changed
+637
-118
No files matched your search
@@ -10,6 +10,9 @@ abstract final class ScheduledTaskStorageTool {
|
||||
/// 上一版本商品同步任务的默认 Cron,仅用于把未自定义配置升级为每 3 小时。
|
||||
static const String _legacyFiveMinuteProductSyncCron = '0 */5 * * * *';
|
||||
|
||||
/// 旧版离线订单同步的每日默认 Cron,仅用于把未自定义配置升级为每 10 分钟自动直连。
|
||||
static const String _legacyDailyOfflineOrderSyncCron = '0 35 0 * * *';
|
||||
|
||||
/// 当前应用数据库中的任务配置 DAO;数据库由 AppControl 在调度器启动前完成初始化。
|
||||
static ScheduledTaskDao get _taskDao =>
|
||||
Get.find<AppControl>().dataBase.getScheduledTaskDao;
|
||||
@@ -18,9 +21,9 @@ abstract final class ScheduledTaskStorageTool {
|
||||
static ScheduledTaskLogDao get _logDao =>
|
||||
Get.find<AppControl>().dataBase.getScheduledTaskLogDao;
|
||||
|
||||
/// 首次升级时把旧 sysJson 导入表中,并补齐新增任务及升级仍沿用旧默认值的商品同步任务。
|
||||
/// 首次升级时把旧 sysJson 导入表中,并补齐新增任务及升级仍沿用旧默认值的任务。
|
||||
///
|
||||
/// 商品同步只迁移完整匹配旧默认值的记录;用户调整过 Cron 或空闲策略时保留现场配置。
|
||||
/// 迁移只处理完整匹配旧默认值的记录;用户调整过 Cron 或空闲策略时保留现场配置。
|
||||
static Future<void> initialize({
|
||||
required List<ScheduledTaskConfig> defaults,
|
||||
required List<ScheduledTaskConfig> legacyConfigs,
|
||||
@@ -35,7 +38,7 @@ abstract final class ScheduledTaskStorageTool {
|
||||
for (final defaultConfig in defaults) {
|
||||
final current = existing[defaultConfig.type.name];
|
||||
if (current != null) {
|
||||
if (_usesLegacyProductSyncDefault(
|
||||
if (_usesLegacyTaskDefault(
|
||||
type: defaultConfig.type,
|
||||
enabled: current.enabled,
|
||||
cron: current.cronExpression,
|
||||
@@ -53,7 +56,7 @@ abstract final class ScheduledTaskStorageTool {
|
||||
continue;
|
||||
}
|
||||
final legacyConfig = legacy[defaultConfig.type] ?? defaultConfig;
|
||||
final config = _usesLegacyProductSyncDefault(
|
||||
final config = _usesLegacyTaskDefault(
|
||||
type: legacyConfig.type,
|
||||
enabled: legacyConfig.enabled,
|
||||
cron: legacyConfig.cron,
|
||||
@@ -84,17 +87,23 @@ abstract final class ScheduledTaskStorageTool {
|
||||
if (writes.isNotEmpty) await _taskDao.batchSave(writes);
|
||||
}
|
||||
|
||||
/// 判断配置是否仍为旧版商品同步默认值,用于升级默认策略且避免覆盖其他任务或自定义规则。
|
||||
static bool _usesLegacyProductSyncDefault({
|
||||
/// 判断配置是否仍为旧版任务默认值,用于升级默认策略且避免覆盖用户自定义规则。
|
||||
static bool _usesLegacyTaskDefault({
|
||||
required ScheduledTaskType type,
|
||||
required bool enabled,
|
||||
required String cron,
|
||||
required bool idleOnly,
|
||||
}) =>
|
||||
type == ScheduledTaskType.productSync &&
|
||||
idleOnly &&
|
||||
((!enabled && cron == _legacyDisabledProductSyncCron) ||
|
||||
(enabled && cron == _legacyFiveMinuteProductSyncCron));
|
||||
}) {
|
||||
if (type == ScheduledTaskType.productSync) {
|
||||
return idleOnly &&
|
||||
((!enabled && cron == _legacyDisabledProductSyncCron) ||
|
||||
(enabled && cron == _legacyFiveMinuteProductSyncCron));
|
||||
}
|
||||
return type == ScheduledTaskType.offlineOrderSync &&
|
||||
!enabled &&
|
||||
idleOnly &&
|
||||
cron == _legacyDailyOfflineOrderSyncCron;
|
||||
}
|
||||
|
||||
/// 从 scheduled_task 读取有效任务,并转换为调度器使用的配置和最近执行快照。
|
||||
///
|
||||
|
||||
@@ -316,9 +316,9 @@ class SyncCheckTool {
|
||||
),
|
||||
ScheduledTaskConfig(
|
||||
type: ScheduledTaskType.offlineOrderSync,
|
||||
enabled: false,
|
||||
cron: '0 35 0 * * *',
|
||||
idleOnly: true,
|
||||
enabled: true,
|
||||
cron: '0 */10 * * * *',
|
||||
idleOnly: false,
|
||||
),
|
||||
ScheduledTaskConfig(
|
||||
type: ScheduledTaskType.orderEvidenceUpload,
|
||||
@@ -881,8 +881,18 @@ class SyncCheckTool {
|
||||
}
|
||||
return const _ScheduledTaskActionResult.success('商品同步完成');
|
||||
case ScheduledTaskType.offlineOrderSync:
|
||||
await SyncOrderTool.syncOfflineOrder();
|
||||
return const _ScheduledTaskActionResult.success('离线订单同步完成');
|
||||
final syncedCount = await SyncOrderTool.syncOfflineOrder();
|
||||
return switch (syncedCount) {
|
||||
null => const _ScheduledTaskActionResult.skipped('没有待同步的离线订单'),
|
||||
// 订单已上传但后台未确认任何一单:后端按用户/钱包校验静默跳过,
|
||||
// 需要对照后台"订单同步"日志定位,不能与本地无欠账单混为一谈。
|
||||
0 => const _ScheduledTaskActionResult.skipped(
|
||||
'订单已上传但后台未确认,请检查后台订单同步日志',
|
||||
),
|
||||
int count => _ScheduledTaskActionResult.success(
|
||||
'离线订单同步完成,共 $count 单',
|
||||
),
|
||||
};
|
||||
case ScheduledTaskType.orderEvidenceUpload:
|
||||
if (!AppNetworkTool.isAutoNetwork()) {
|
||||
return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
@@ -25,15 +26,34 @@ abstract class SyncFaceTool {
|
||||
/// 服务端单次允许接收的人脸质量结果上限。
|
||||
static const int _qualityReportBatchSize = 500;
|
||||
|
||||
/// 删除数据库中缺少本地特征或图片的历史残缺人脸。
|
||||
/// 同步图片预取的临时下载目录名;每次同步开始时整体清理中断遗留。
|
||||
static const String _syncTempDirectoryName = '.face-sync-temp';
|
||||
|
||||
/// 并行预检本地人脸文件存在性的批大小。
|
||||
static const int _fileCheckBatchSize = 64;
|
||||
|
||||
/// 需要重新下载图片的样本预取并发数。
|
||||
static const int _downloadPrefetchConcurrency = 3;
|
||||
|
||||
/// 删除数据库中缺少人脸特征的历史残缺行;启动整库装载完成后调用。
|
||||
///
|
||||
/// 空特征行永远注册不进虹软内存库,却参与"内存库与本地库一致"的集合
|
||||
/// 比对,会让收银弹框反复判定库损坏并进入就地注册引导。整库装载已先
|
||||
/// 尝试从本地图片补回特征,这里只清理最终仍无特征的行。
|
||||
/// 删除顺序仍先处理虹软内存库;单条失败时保留记录供后续权威快照重试。
|
||||
static Future<void> delEmptyFace() async {
|
||||
static Future<int> delEmptyFace() async {
|
||||
final faceDao = Get.find<AppControl>().dataBase.getFaceDao;
|
||||
final faces = await faceDao.getEmptyFaceList() ?? const [];
|
||||
for (final face in faces) {
|
||||
await _deleteLocalFace(face, faceDao);
|
||||
// 轻量档案以哨兵区分特征有无,避免为找出残缺行反序列化全表特征。
|
||||
final profiles = await faceDao.getSyncProfiles();
|
||||
var deleted = 0;
|
||||
for (final profile in profiles) {
|
||||
if ((profile.faceModel ?? const []).isNotEmpty) continue;
|
||||
if (await _deleteLocalFace(profile, faceDao)) deleted++;
|
||||
}
|
||||
if (deleted > 0) {
|
||||
logger.i('|$_tag|启动清理无可注册特征的残缺人脸|count:$deleted|');
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// 兼容既有页面与识别恢复调用的统一入口。
|
||||
@@ -75,6 +95,7 @@ abstract class SyncFaceTool {
|
||||
if (storagePath == null || storagePath.isEmpty) {
|
||||
throw const FaceSyncSnapshotException('无法创建人脸本地存储目录');
|
||||
}
|
||||
await _cleanSyncTempDirectory(storagePath);
|
||||
if (!AppRuntimeTool.useManualHardwareInput &&
|
||||
AppSysTool.isProd() &&
|
||||
!await AppFaceTool.isActiveSdk()) {
|
||||
@@ -122,7 +143,11 @@ abstract class SyncFaceTool {
|
||||
merchantDeptId: merchantDeptId,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
final local = await Get.find<AppControl>().dataBase.getFaceDao.getAll();
|
||||
// 轻量档案不反序列化特征大字段;需要完整行时在单条处理时按 sampleId 回读。
|
||||
final local = await Get.find<AppControl>()
|
||||
.dataBase
|
||||
.getFaceDao
|
||||
.getSyncProfiles();
|
||||
await _reconcile(
|
||||
remote: remote,
|
||||
local: local,
|
||||
@@ -285,53 +310,101 @@ abstract class SyncFaceTool {
|
||||
localBySampleId[face.sampleId] = face;
|
||||
}
|
||||
|
||||
for (final record in remote) {
|
||||
accumulator.scanned++;
|
||||
final existing = localBySampleId.remove(record.sampleId);
|
||||
final imageChanged = await _imageChanged(existing, record);
|
||||
if (existing == null || imageChanged) {
|
||||
final success = await _upsertImageFace(
|
||||
record: record,
|
||||
existing: existing,
|
||||
storagePath: storagePath,
|
||||
faceDao: faceDao,
|
||||
);
|
||||
if (success) {
|
||||
existing == null ? accumulator.added++ : accumulator.updated++;
|
||||
// 文件存在性分批并行预检,避免大库逐条串行 stat 拖慢比对阶段。
|
||||
final existingFilePaths = await _collectExistingFilePaths(local);
|
||||
// 需要重下图片的记录先并行预取,下载与主流程的提取、注册流水线并行。
|
||||
final prefetchRecords = [
|
||||
for (final record in remote)
|
||||
if (_needsImageWork(
|
||||
localBySampleId[record.sampleId],
|
||||
record,
|
||||
existingFilePaths,
|
||||
))
|
||||
record,
|
||||
];
|
||||
final prefetcher = _FaceImagePrefetcher.start(
|
||||
prefetchRecords,
|
||||
storagePath,
|
||||
);
|
||||
try {
|
||||
for (final record in remote) {
|
||||
accumulator.scanned++;
|
||||
final existing = localBySampleId.remove(record.sampleId);
|
||||
final imageChanged = _imageChanged(existing, record, existingFilePaths);
|
||||
if (existing == null || imageChanged) {
|
||||
final success = await _upsertImageFace(
|
||||
record: record,
|
||||
existing: existing,
|
||||
storagePath: storagePath,
|
||||
faceDao: faceDao,
|
||||
prefetcher: prefetcher,
|
||||
);
|
||||
if (success) {
|
||||
existing == null ? accumulator.added++ : accumulator.updated++;
|
||||
} else {
|
||||
accumulator.fail(record.sampleId);
|
||||
}
|
||||
} else if (_metadataChanged(existing, record)) {
|
||||
// 元数据变化只改对应列,不回写特征大字段和质量检测结果。
|
||||
// 可空字段统一传空串,由 SQL 的 NULLIF/COALESCE 还原空值或保留旧版本。
|
||||
await faceDao.editMetadataBySampleId(
|
||||
record.sampleId,
|
||||
record.userId,
|
||||
record.username ?? '',
|
||||
record.deptId,
|
||||
record.deptName ?? '',
|
||||
record.faceImg ?? '',
|
||||
record.sourceUpdatedAt == null
|
||||
? ''
|
||||
: DateUtil.formatDate(record.sourceUpdatedAt!),
|
||||
);
|
||||
accumulator.updated++;
|
||||
} else {
|
||||
accumulator.fail(record.sampleId);
|
||||
accumulator.skipped++;
|
||||
}
|
||||
} else if (_metadataChanged(existing, record)) {
|
||||
await faceDao.edit(_toEntity(record, existing: existing));
|
||||
accumulator.updated++;
|
||||
} else {
|
||||
accumulator.skipped++;
|
||||
onProgress?.call(accumulator.scanned, remote.length);
|
||||
}
|
||||
onProgress?.call(accumulator.scanned, remote.length);
|
||||
}
|
||||
|
||||
// 只有远端完整集合已经成功拉取和校验后才会执行此处的集合差删除。
|
||||
// 孤儿数量已知且通常远小于远端集合,并入总数保持进度单调推进。
|
||||
final orphanTotal = remote.length + localBySampleId.length;
|
||||
for (final orphan in localBySampleId.values) {
|
||||
if (await _deleteLocalFace(orphan, faceDao)) {
|
||||
accumulator.deleted++;
|
||||
} else {
|
||||
accumulator.fail(orphan.sampleId);
|
||||
// 只有远端完整集合已经成功拉取和校验后才会执行此处的集合差删除。
|
||||
// 孤儿数量已知且通常远小于远端集合,并入总数保持进度单调推进。
|
||||
final orphanTotal = remote.length + localBySampleId.length;
|
||||
for (final orphan in localBySampleId.values) {
|
||||
if (await _deleteLocalFace(orphan, faceDao)) {
|
||||
accumulator.deleted++;
|
||||
} else {
|
||||
accumulator.fail(orphan.sampleId);
|
||||
}
|
||||
onProgress?.call(remote.length + accumulator.deleted, orphanTotal);
|
||||
}
|
||||
onProgress?.call(remote.length + accumulator.deleted, orphanTotal);
|
||||
} finally {
|
||||
await prefetcher?.dispose();
|
||||
}
|
||||
logger.i(
|
||||
'|$_tag|图片预取统计|计划:${prefetchRecords.length}|命中:${prefetcher?.hitCount ?? 0}|',
|
||||
);
|
||||
}
|
||||
|
||||
/// 判断快照记录是否需要走下载与特征重提取流程。
|
||||
static bool _needsImageWork(
|
||||
FaceEntity? existing,
|
||||
ServiceFaceSyncRecord record,
|
||||
Set<String> existingFilePaths,
|
||||
) =>
|
||||
existing == null || _imageChanged(existing, record, existingFilePaths);
|
||||
|
||||
/// 判断图片版本、特征或正式文件是否变化;完全一致时不下载也不写虹软。
|
||||
///
|
||||
/// 历史本地记录可能缺少图片版本时间(早期 V2 调试同步写入的 updateTime 为空);
|
||||
/// 此时只要图片地址一致且本地特征与文件完好,就不再强制重新下载,由
|
||||
/// [_metadataChanged] 的版本回填补齐,避免服务端图片损坏时记录永远无法修复。
|
||||
static Future<bool> _imageChanged(
|
||||
///
|
||||
/// [existingFilePaths] 为预检得到的仍存在的本地文件路径集合,见
|
||||
/// [_collectExistingFilePaths]。
|
||||
static bool _imageChanged(
|
||||
FaceEntity? existing,
|
||||
ServiceFaceSyncRecord record,
|
||||
) async {
|
||||
Set<String> existingFilePaths,
|
||||
) {
|
||||
if (existing == null) return true;
|
||||
if (existing.imagePath != record.faceImg) return true;
|
||||
if (record.sourceUpdatedAt != null &&
|
||||
@@ -341,7 +414,7 @@ abstract class SyncFaceTool {
|
||||
}
|
||||
if ((existing.faceModel ?? const []).isEmpty) return true;
|
||||
final path = existing.path;
|
||||
return path == null || path.isEmpty || !await File(path).exists();
|
||||
return path == null || path.isEmpty || !existingFilePaths.contains(path);
|
||||
}
|
||||
|
||||
/// 判断姓名、部门元数据或图片版本时间是否变化;此类变化不需要重新提取图片特征。
|
||||
@@ -366,12 +439,18 @@ abstract class SyncFaceTool {
|
||||
required FaceEntity? existing,
|
||||
required String storagePath,
|
||||
required FaceDao faceDao,
|
||||
_FaceImagePrefetcher? prefetcher,
|
||||
}) async {
|
||||
File? temporaryFile;
|
||||
File? finalFile;
|
||||
var databaseChanged = false;
|
||||
// 比对用轻量档案不含特征;单条回读完整行,保证替换与回滚都写回真实特征。
|
||||
final existingRow = existing == null
|
||||
? null
|
||||
: await faceDao.findBySampleId(existing.sampleId) ?? existing;
|
||||
try {
|
||||
temporaryFile = await _downloadTemporaryFace(record, storagePath);
|
||||
temporaryFile = prefetcher?.take(record.sampleId) ??
|
||||
await _downloadTemporaryFace(record, storagePath);
|
||||
final extraction = AppRuntimeTool.useManualHardwareInput
|
||||
? null
|
||||
: await FaceDiscernPlugs.extractFaceFeature(
|
||||
@@ -388,13 +467,13 @@ abstract class SyncFaceTool {
|
||||
temporaryFile = null;
|
||||
final entity = _toEntity(
|
||||
record,
|
||||
existing: existing,
|
||||
existing: existingRow,
|
||||
path: finalFile.path,
|
||||
faceModel: featureData,
|
||||
extraction: extraction,
|
||||
imageReplaced: true,
|
||||
);
|
||||
if (existing == null) {
|
||||
if (existingRow == null) {
|
||||
final id = await faceDao.save(entity);
|
||||
if (id == null) throw const FaceSyncSnapshotException('本地人脸记录写入失败');
|
||||
entity.id = id;
|
||||
@@ -413,8 +492,8 @@ abstract class SyncFaceTool {
|
||||
);
|
||||
if (!applied.success) throw FaceSyncSnapshotException(applied.message);
|
||||
}
|
||||
if (existing?.path != null && existing!.path != finalFile.path) {
|
||||
await delFaceByStorage(filePath: existing.path!);
|
||||
if (existingRow?.path != null && existingRow!.path != finalFile.path) {
|
||||
await delFaceByStorage(filePath: existingRow.path!);
|
||||
}
|
||||
return true;
|
||||
} catch (error, stack) {
|
||||
@@ -423,10 +502,10 @@ abstract class SyncFaceTool {
|
||||
);
|
||||
if (databaseChanged) {
|
||||
try {
|
||||
if (existing == null) {
|
||||
if (existingRow == null) {
|
||||
await faceDao.delBySampleId(record.sampleId);
|
||||
} else {
|
||||
await faceDao.edit(existing);
|
||||
await faceDao.edit(existingRow);
|
||||
}
|
||||
} catch (rollbackError, rollbackStack) {
|
||||
logger.e(
|
||||
@@ -448,6 +527,8 @@ abstract class SyncFaceTool {
|
||||
FaceEntity face,
|
||||
FaceDao faceDao,
|
||||
) async {
|
||||
// 比对用轻量档案不含特征,回滚与原生恢复需要完整行;单行回读代价可忽略。
|
||||
face = await faceDao.findBySampleId(face.sampleId) ?? face;
|
||||
File? originalFile;
|
||||
File? pendingFile;
|
||||
try {
|
||||
@@ -617,20 +698,23 @@ abstract class SyncFaceTool {
|
||||
);
|
||||
}
|
||||
|
||||
/// 将本机已检测或检测失败的人脸质量按 500 条幂等补报到服务端。
|
||||
/// 将本机待补报的人脸质量按 500 条幂等上报到服务端。
|
||||
///
|
||||
/// 第一版不增加本地“已上报”数据库字段,因此每次完整同步或人工质量检测完成后
|
||||
/// 都会补报当前持久化结果。服务端按图片版本、成功状态优先级和检测时间保证幂等;
|
||||
/// 任一网络或业务错误只记录汇总日志并返回 false,绝不回滚 SQLite、特征或虹软内存库。
|
||||
/// 上报范围按本地 [FaceEntity.qualityReportedAt] 增量判定:服务端确认过的样本
|
||||
/// 在重新检测前不再重复上报;每批确认后立即落标记,标记失败只导致下次重复
|
||||
/// 补报,不回滚 SQLite、特征或虹软内存库。
|
||||
static Future<bool> _reportPersistedFaceQualitySafely() async {
|
||||
if (!AppNetworkTool.isAutoNetwork()) {
|
||||
logger.w('|$_tag|质量上报跳过|reason:网络不可用|');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final faces = await Get.find<AppControl>().dataBase.getFaceDao.getAll();
|
||||
final faceDao = Get.find<AppControl>().dataBase.getFaceDao;
|
||||
// 上报只需要质量与版本列,走轻量档案避免全表特征反序列化。
|
||||
final faces = await faceDao.getSyncProfiles();
|
||||
final items = <ServiceFaceQualityReportItem>[];
|
||||
var invalidCount = 0;
|
||||
var alreadyReportedCount = 0;
|
||||
for (final face in faces) {
|
||||
final status = FaceQualityStatus.fromCode(face.qualityStatus);
|
||||
if (status == FaceQualityStatus.pending) continue;
|
||||
@@ -644,6 +728,11 @@ abstract class SyncFaceTool {
|
||||
invalidCount++;
|
||||
continue;
|
||||
}
|
||||
final reportedAt = face.qualityReportedAt;
|
||||
if (reportedAt != null && !checkedAt.isAfter(reportedAt)) {
|
||||
alreadyReportedCount++;
|
||||
continue;
|
||||
}
|
||||
items.add(ServiceFaceQualityReportItem(
|
||||
sampleId: face.sampleId,
|
||||
status: status.code,
|
||||
@@ -659,6 +748,9 @@ abstract class SyncFaceTool {
|
||||
logger.w('|$_tag|质量上报无有效结果|invalid:$invalidCount|');
|
||||
return false;
|
||||
}
|
||||
logger.i(
|
||||
'|$_tag|质量上报跳过|reason:无待上报结果|reported:$alreadyReportedCount|',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -681,11 +773,29 @@ abstract class SyncFaceTool {
|
||||
final batch = items.sublist(offset, end);
|
||||
try {
|
||||
final result = await UserApi.reportTerminalFaceQuality(batch);
|
||||
final batchConfirmed = result.updatedSampleIds.length +
|
||||
result.ignoredStaleSampleIds.length +
|
||||
result.alreadyAbsentSampleIds.length;
|
||||
confirmedCount += batchConfirmed;
|
||||
if (batchConfirmed != batch.length) allSucceeded = false;
|
||||
final batchSampleIds = {
|
||||
for (final item in batch) item.sampleId,
|
||||
};
|
||||
final confirmedSampleIds = {
|
||||
...result.updatedSampleIds,
|
||||
...result.ignoredStaleSampleIds,
|
||||
...result.alreadyAbsentSampleIds,
|
||||
}.where(batchSampleIds.contains).toList(growable: false);
|
||||
confirmedCount += confirmedSampleIds.length;
|
||||
try {
|
||||
// 逐批落确认标记,进程中断时已确认批次不会在下次重复上报。
|
||||
if (confirmedSampleIds.isNotEmpty) {
|
||||
await faceDao.markQualityReported(
|
||||
confirmedSampleIds,
|
||||
DateTime.now(),
|
||||
);
|
||||
}
|
||||
} catch (markError, markStack) {
|
||||
logger.w(
|
||||
'|$_tag|质量上报标记失败|error:${markError.runtimeType}|stack:$markStack|',
|
||||
);
|
||||
}
|
||||
if (confirmedSampleIds.length != batch.length) allSucceeded = false;
|
||||
} catch (error, stack) {
|
||||
allSucceeded = false;
|
||||
failedBatchCount++;
|
||||
@@ -695,7 +805,7 @@ abstract class SyncFaceTool {
|
||||
}
|
||||
}
|
||||
logger.i(
|
||||
'|$_tag|质量上报完成|total:${items.length}|confirmed:$confirmedCount|invalid:$invalidCount|failedBatch:$failedBatchCount|',
|
||||
'|$_tag|质量上报完成|total:${items.length}|confirmed:$confirmedCount|reported:$alreadyReportedCount|invalid:$invalidCount|failedBatch:$failedBatchCount|',
|
||||
);
|
||||
return allSucceeded;
|
||||
} catch (error, stack) {
|
||||
@@ -733,7 +843,7 @@ abstract class SyncFaceTool {
|
||||
if (bytes == null || bytes.isEmpty) {
|
||||
throw const FaceSyncSnapshotException('人脸图片下载失败');
|
||||
}
|
||||
final directory = Directory('$storagePath/.face-sync-temp');
|
||||
final directory = Directory('$storagePath/$_syncTempDirectoryName');
|
||||
if (!await directory.exists()) await directory.create(recursive: true);
|
||||
final file = File(
|
||||
'${directory.path}/${record.sampleId}_${DateTime.now().microsecondsSinceEpoch}.tmp',
|
||||
@@ -742,6 +852,45 @@ abstract class SyncFaceTool {
|
||||
return file;
|
||||
}
|
||||
|
||||
/// 删除同步临时目录中上次中断遗留的文件;失败只记录告警不阻断同步。
|
||||
static Future<void> _cleanSyncTempDirectory(String storagePath) async {
|
||||
try {
|
||||
final directory = Directory('$storagePath/$_syncTempDirectoryName');
|
||||
if (await directory.exists()) {
|
||||
await directory.delete(recursive: true);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
logger.w(
|
||||
'|$_tag|清理同步临时目录失败|error:${error.runtimeType}|stack:$stack|',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分批并行预检本地人脸文件存在性,返回仍存在的绝对路径集合。
|
||||
static Future<Set<String>> _collectExistingFilePaths(
|
||||
List<FaceEntity> local,
|
||||
) async {
|
||||
final paths = local
|
||||
.map((face) => face.path)
|
||||
.whereType<String>()
|
||||
.where((path) => path.isNotEmpty)
|
||||
.toSet();
|
||||
final existing = <String>{};
|
||||
for (var start = 0; start < paths.length; start += _fileCheckBatchSize) {
|
||||
final chunk = paths
|
||||
.skip(start)
|
||||
.take(_fileCheckBatchSize)
|
||||
.toList(growable: false);
|
||||
final checked = await Future.wait(
|
||||
chunk.map(
|
||||
(path) async => await File(path).exists() ? path : null,
|
||||
),
|
||||
);
|
||||
existing.addAll(checked.whereType<String>());
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
/// 生成人脸正式文件路径;版本戳确保新文件写入前旧文件始终可回滚。
|
||||
static String _officialFilePath(
|
||||
ServiceFaceSyncRecord record,
|
||||
@@ -891,3 +1040,79 @@ class _FaceSyncAccumulator {
|
||||
qualityReportNote: qualityReportNote,
|
||||
);
|
||||
}
|
||||
|
||||
/// 图片预取器:在主流程逐条提取、注册期间按固定并发提前下载后续样本图片。
|
||||
///
|
||||
/// 下载失败的样本不缓存,由消费方按原有同步下载逻辑重试;预取文件命中后被
|
||||
/// 消费方接管改名,未消费的文件由 [dispose] 统一清理。
|
||||
class _FaceImagePrefetcher {
|
||||
_FaceImagePrefetcher._(this._storagePath);
|
||||
|
||||
/// 为需要重下图片的记录启动预取;集合为空时返回空避免空转。
|
||||
static _FaceImagePrefetcher? start(
|
||||
List<ServiceFaceSyncRecord> records,
|
||||
String storagePath,
|
||||
) {
|
||||
if (records.isEmpty) return null;
|
||||
return _FaceImagePrefetcher._(storagePath).._download(records);
|
||||
}
|
||||
|
||||
/// 预取图片写入的同步临时目录根路径。
|
||||
final String _storagePath;
|
||||
|
||||
/// sampleId 到已下载临时文件的缓存;取走即移除。
|
||||
final _cache = <String, File>{};
|
||||
|
||||
/// 在途下载任务,[dispose] 等待其结束后再清理。
|
||||
Future<void>? _running;
|
||||
|
||||
/// 预取文件被主流程命中的次数,仅用于同步日志统计。
|
||||
int _hitCount = 0;
|
||||
|
||||
/// 命中次数,供日志观察预取收益。
|
||||
int get hitCount => _hitCount;
|
||||
|
||||
/// 以固定并发依次下载全部预取记录。
|
||||
void _download(List<ServiceFaceSyncRecord> records) {
|
||||
var next = 0;
|
||||
Future<void> worker() async {
|
||||
while (true) {
|
||||
final index = next++;
|
||||
if (index >= records.length) return;
|
||||
final record = records[index];
|
||||
try {
|
||||
_cache[record.sampleId] = await SyncFaceTool._downloadTemporaryFace(
|
||||
record,
|
||||
_storagePath,
|
||||
);
|
||||
} catch (_) {
|
||||
// 预取失败不缓存,消费方按原逻辑同步下载重试。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_running = Future.wait(
|
||||
List.generate(
|
||||
SyncFaceTool._downloadPrefetchConcurrency,
|
||||
(_) => worker(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 取走一条已预取的临时文件;未就绪或预取失败时返回空。
|
||||
File? take(String sampleId) {
|
||||
final file = _cache.remove(sampleId);
|
||||
if (file != null) _hitCount++;
|
||||
return file;
|
||||
}
|
||||
|
||||
/// 等待在途下载结束并清理未消费的预取文件。
|
||||
Future<void> dispose() async {
|
||||
await _running;
|
||||
final files = _cache.values.toList(growable: false);
|
||||
_cache.clear();
|
||||
for (final file in files) {
|
||||
await SyncFaceTool.delFaceByStorage(filePath: file.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,37 +6,46 @@ import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
abstract class SyncOrderTool {
|
||||
static const _tag = "sync订单工具";
|
||||
|
||||
// 同步离线订单
|
||||
static Future<void> syncOfflineOrder() async {
|
||||
// 同步离线订单;返回本轮上传成功并回写本地状态的订单数。
|
||||
//
|
||||
// 返回 null 表示本地没有待同步订单;返回 0 表示有待同步订单但后台一单都
|
||||
// 未确认(通常被后端用户/钱包校验静默跳过),两者在任务结果中需要区分。
|
||||
//
|
||||
// 不能按设备当前 sysType 过滤:订单的 sysType 来自下单时的收银页模式,
|
||||
// 而收银页模式与 sysSetting.sysType 可能脱节(如后台设备配置把本地 sysType
|
||||
// 覆盖成另一模式),按当前模式过滤会让欠账订单永远查不到且任务假成功。
|
||||
// 订单 JSON 自带 sysType,后端按订单自身的 sysType 建单,无需设备当前模式。
|
||||
static Future<int?> syncOfflineOrder() async {
|
||||
final app = Get.find<AppControl>();
|
||||
final sysSetting = await AppSettingTool.getSysSetting();
|
||||
final sysType = sysSetting.sysType.name;
|
||||
|
||||
final orderDao = app.dataBase.getOrderDao;
|
||||
|
||||
// 查询离线订单
|
||||
// 查询近 30 天全部模式的离线订单;'-1' 表示不过滤 sysType。
|
||||
final startDate = DateTime.now().subtract(const Duration(days: 30));
|
||||
final endDate = DateTime.now().add(const Duration(days: 1));
|
||||
final list = await orderDao.findOffline(
|
||||
sysType,
|
||||
'-1',
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
if (ObjectUtil.isEmptyList(list)) {
|
||||
logger.i("|$_tag|错误,没有需要同步的离线订单");
|
||||
return;
|
||||
logger.i("|$_tag|没有需要同步的离线订单");
|
||||
return null;
|
||||
}
|
||||
var syncedCount = 0;
|
||||
final chunkList = chunkArray(list, 5);
|
||||
for (var v in chunkList) {
|
||||
await _syncOfflineGroupOrder(v);
|
||||
syncedCount += await _syncOfflineGroupOrder(v);
|
||||
}
|
||||
// 更新同步时间
|
||||
sysSetting.offlineOrderSyncTime = DateTime.now();
|
||||
await AppSettingTool.setSysSetting(sysSetting);
|
||||
return syncedCount;
|
||||
}
|
||||
|
||||
// 同步一组离线订单
|
||||
static Future<void> _syncOfflineGroupOrder(List<OrderEntity> list) async {
|
||||
// 同步一组离线订单;返回本组上传成功并回写本地状态的订单数。
|
||||
static Future<int> _syncOfflineGroupOrder(List<OrderEntity> list) async {
|
||||
final app = Get.find<AppControl>();
|
||||
|
||||
final sysSetting = await AppSettingTool.getSysSetting();
|
||||
@@ -72,12 +81,16 @@ abstract class SyncOrderTool {
|
||||
if (data == null) throw Error();
|
||||
final batchEditCount = await handEditOfflineStatus(data);
|
||||
logger.i("|$_tag|同步成功的数量为:|$batchEditCount|ids为:|$data|");
|
||||
return batchEditCount;
|
||||
} catch (e) {
|
||||
logger.e("|$_tag|同步一组离线订单意外的错误error:$e");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static Future<int> handEditOfflineStatus(List<dynamic> codes) async {
|
||||
// 后端可能返回空列表(订单被后端静默跳过),空 IN () 是 SQL 语法错误。
|
||||
if (ObjectUtil.isEmptyList(codes)) return 0;
|
||||
final app = Get.find<AppControl>();
|
||||
final placeholders = List.generate(codes.length, (index) => '?').join(', ');
|
||||
final sql = '''
|
||||
|
||||
Reference in new issue
Block a user