feat:同步人脸优化

This commit is contained in:
dzc committed 2026-09-30 14:24:20 +08:00
1 parent 3f99e73c44
commit 6f0e3ea37e
8 files changed
+552 -47

No files matched your search

+12 -3
View File
@@ -37,6 +37,9 @@ class SyncCheckTool {
/// 人脸同步任务执行中的最新进度快照,任务结束后清空;仅驱动界面展示。
final departmentFaceSyncProgress = Rxn<FaceSyncProgress>();
/// 最近一次人脸同步的结构化结果,供页面弹出统计弹框;失败重试前保持可读。
final departmentFaceSyncSummary = Rxn<FaceSyncSummary>();
/// 当前进程内 Cron 调度器;任务全部停止后置空并关闭其内部计时资源。
Cron? _cron;
@@ -722,8 +725,10 @@ class SyncCheckTool {
),
);
}
if (error is ScheduledTaskDependencyException) {
logger.w('|$_tag|任务依赖暂不可用type:${type.name}|error:${error.message}|');
if (error is ScheduledTaskDependencyException ||
error is FaceSyncPartialFailureException) {
// 依赖暂不可用与部分样本失败都是面向终端人员的业务结果,不按系统错误打整段堆栈。
logger.w('|$_tag|任务未完全成功type:${type.name}|error:${error.toString()}|');
} else {
logger.e(
'|$_tag|任务执行失败type:${type.name}|error:$error|stack:$stack|',
@@ -833,6 +838,7 @@ class SyncCheckTool {
}
return const _ScheduledTaskActionResult.success('设备配置同步完成');
case ScheduledTaskType.departmentFaceSync:
departmentFaceSyncSummary.value = null;
final mode = request.debugAllFaces
? FaceSyncMode.debugAllSnapshot
: request.faceUserIds == null
@@ -841,13 +847,16 @@ class SyncCheckTool {
final summary = await SyncFaceTool.sync(
mode: mode,
userIds: request.faceUserIds,
onProgress: (processed, total) {
onProgress: (processed, total, note) {
departmentFaceSyncProgress.value = FaceSyncProgress(
processed: processed,
total: total,
note: note,
);
},
);
// 同步主体完成即暴露结构化结果;部分失败抛异常前页面也能取到统计。
departmentFaceSyncSummary.value = summary;
if (summary.changed > 0 &&
request.triggerType == ScheduledTaskTriggerType.mqtt &&
Get.isRegistered<AppControl>()) {
+78 -30
View File
@@ -26,6 +26,9 @@ abstract class SyncFaceTool {
/// 服务端单次允许接收的人脸质量结果上限。
static const int _qualityReportBatchSize = 500;
/// 整批上报被服务端拒绝后的降级小批大小,避免个别样本拖住整批。
static const int _qualityRetryBatchSize = 25;
/// 同步图片预取的临时下载目录名;每次同步开始时整体清理中断遗留。
static const String _syncTempDirectoryName = '.face-sync-temp';
@@ -86,11 +89,12 @@ abstract class SyncFaceTool {
/// 任何页面中断都会抛出异常且不会进入本地孤儿删除阶段。
///
/// [onProgress] 在清单分页和逐条处理时上报进度;清单仍在分页获取时总数
/// 为空,进入逐条处理后总数为本次需要处理的记录数。
/// 为空,进入逐条处理后总数为本次需要处理的记录数;比对结束后以 [note]
/// 说明收尾阶段(质量上报、目录清理),避免进度停满后看似卡死。
static Future<FaceSyncSummary> sync({
required FaceSyncMode mode,
Set<String>? userIds,
void Function(int processed, int? total)? onProgress,
void Function(int processed, int? total, String? note)? onProgress,
}) async {
if (!AppNetworkTool.isAutoNetwork()) {
throw const FaceSyncSnapshotException('网络不可用,无法获取人脸权威快照');
@@ -168,7 +172,10 @@ abstract class SyncFaceTool {
onProgress: onProgress,
);
}
final qualityReported = await _reportPersistedFaceQualitySafely();
final qualityReported = await _reportPersistedFaceQualitySafely(
onProgress: onProgress,
);
onProgress?.call(0, null, '正在清理目录文件...');
await _sweepFaceDirectory(storagePath);
final summary = accumulator.toSummary(
qualityReportNote: qualityReported ? null : '质量上报未完成,将在下次同步重试',
@@ -200,7 +207,7 @@ abstract class SyncFaceTool {
static Future<List<ServiceFaceSyncRecord>> _loadV3Snapshot({
required String merchantDeptId,
String? userId,
void Function(int processed, int? total)? onProgress,
void Function(int processed, int? total, String? note)? onProgress,
}) async {
var afterId = '0';
String? snapshotMaxId;
@@ -257,7 +264,7 @@ abstract class SyncFaceTool {
throw const FaceSyncSnapshotException('人脸快照存在空页但仍声明后续数据');
}
afterId = snapshot.nextAfterId;
onProgress?.call(records.length, null);
onProgress?.call(records.length, null, null);
}
throw const FaceSyncSnapshotException('人脸快照分页超过安全上限');
}
@@ -268,7 +275,7 @@ abstract class SyncFaceTool {
/// 图片版本,保证调试同步写入的人脸同样可以参与质量上报和版本比对。
/// 服务端历史脏数据(空编号或重复编号)只跳过并记录计数,不阻断调试同步。
static Future<List<ServiceFaceSyncRecord>> _loadDebugAllSnapshot({
void Function(int processed, int? total)? onProgress,
void Function(int processed, int? total, String? note)? onProgress,
}) async {
final records = <ServiceFaceSyncRecord>[];
final sampleIds = <String>{};
@@ -316,7 +323,7 @@ abstract class SyncFaceTool {
}
return records;
}
onProgress?.call(records.length, null);
onProgress?.call(records.length, null, null);
}
throw const FaceSyncSnapshotException('调试全租户快照分页超过安全上限');
}
@@ -332,7 +339,7 @@ abstract class SyncFaceTool {
required String storagePath,
required _FaceSyncAccumulator accumulator,
Set<String>? orphanScopeDeptIds,
void Function(int processed, int? total)? onProgress,
void Function(int processed, int? total, String? note)? onProgress,
}) async {
final faceDao = Get.find<AppControl>().dataBase.getFaceDao;
final localBySampleId = <String, FaceEntity>{};
@@ -367,17 +374,17 @@ abstract class SyncFaceTool {
final existing = localBySampleId.remove(record.sampleId);
final imageChanged = _imageChanged(existing, record, existingFilePaths);
if (existing == null || imageChanged) {
final success = await _upsertImageFace(
final failureReason = await _upsertImageFace(
record: record,
existing: existing,
storagePath: storagePath,
faceDao: faceDao,
prefetcher: prefetcher,
);
if (success) {
if (failureReason == null) {
existing == null ? accumulator.added++ : accumulator.updated++;
} else {
accumulator.fail(record.sampleId);
accumulator.fail(record.sampleId, failureReason);
}
} else if (_metadataChanged(existing, record)) {
// 元数据变化只改对应列,不回写特征大字段和质量检测结果。
@@ -397,7 +404,7 @@ abstract class SyncFaceTool {
} else {
accumulator.skipped++;
}
onProgress?.call(accumulator.scanned, remote.length);
onProgress?.call(accumulator.scanned, remote.length, null);
}
// 只有远端完整集合已经成功拉取和校验后才会执行此处的集合差删除。
@@ -427,11 +434,12 @@ abstract class SyncFaceTool {
: (processed, total) => onProgress(
remote.length + processed,
remote.length + scopedOrphans.length,
null,
),
);
accumulator.deleted += bulkResult.deleted;
for (final face in bulkResult.failedFaces) {
accumulator.fail(face.sampleId);
accumulator.fail(face.sampleId, '本地样本清理失败');
}
} finally {
await prefetcher?.dispose();
@@ -491,7 +499,8 @@ abstract class SyncFaceTool {
/// 下载临时图片、提取特征并提交文件、SQLite 和虹软单样本增量。
///
/// 虹软替换失败时原生保留旧特征,本方法随后回滚 SQLite 和新文件;新样本失败则不留正式记录。
static Future<bool> _upsertImageFace({
/// 返回空表示成功;失败时返回可直接展示的原因摘要,供调用方统计归类。
static Future<String?> _upsertImageFace({
required ServiceFaceSyncRecord record,
required FaceEntity? existing,
required String storagePath,
@@ -552,7 +561,7 @@ abstract class SyncFaceTool {
if (existingRow?.path != null && existingRow!.path != finalFile.path) {
await delFaceByStorage(filePath: existingRow.path!);
}
return true;
return null;
} catch (error, stack) {
logger.w(
'|$_tag|样本处理失败sampleId:${_maskSampleId(record.sampleId)}|error:${error.runtimeType}|stack:$stack|',
@@ -571,7 +580,8 @@ abstract class SyncFaceTool {
}
}
if (finalFile != null) await delFaceByStorage(filePath: finalFile.path);
return false;
// 快照异常携带面向终端人员的安全摘要;其余异常只归类不透出细节。
return error is FaceSyncSnapshotException ? error.message : '样本处理异常';
} finally {
if (temporaryFile != null) {
await delFaceByStorage(filePath: temporaryFile.path);
@@ -957,8 +967,10 @@ abstract class SyncFaceTool {
///
/// 上报范围按本地 [FaceEntity.qualityReportedAt] 增量判定:服务端确认过的样本
/// 在重新检测前不再重复上报;每批确认后立即落标记,标记失败只导致下次重复
/// 补报,不回滚 SQLite、特征或虹软内存库。
static Future<bool> _reportPersistedFaceQualitySafely() async {
/// 补报,不回滚 SQLite、特征或虹软内存库。[onProgress] 逐批上报收尾进度。
static Future<bool> _reportPersistedFaceQualitySafely({
void Function(int processed, int? total, String? note)? onProgress,
}) async {
if (!AppNetworkTool.isAutoNetwork()) {
logger.w('|$_tag|质量上报跳过|reason:网络不可用|');
return false;
@@ -1020,12 +1032,12 @@ abstract class SyncFaceTool {
var allSucceeded = invalidCount == 0;
var confirmedCount = 0;
var failedBatchCount = 0;
for (var offset = 0;
offset < items.length;
offset += _qualityReportBatchSize) {
final end =
(offset + _qualityReportBatchSize).clamp(0, items.length).toInt();
final batch = items.sublist(offset, end);
onProgress?.call(0, items.length, '正在上报质量结果');
// 上报一小批并在成功时落确认标记;返回空表示本批被服务端拒绝。
Future<List<String>?> reportBatch(
List<ServiceFaceQualityReportItem> batch,
) async {
try {
final result = await UserApi.reportTerminalFaceQuality(batch);
final batchSampleIds = {
@@ -1036,7 +1048,7 @@ abstract class SyncFaceTool {
...result.ignoredStaleSampleIds,
...result.alreadyAbsentSampleIds,
}.where(batchSampleIds.contains).toList(growable: false);
confirmedCount += confirmedSampleIds.length;
if (confirmedSampleIds.length != batch.length) allSucceeded = false;
try {
// 逐批落确认标记,进程中断时已确认批次不会在下次重复上报。
if (confirmedSampleIds.isNotEmpty) {
@@ -1050,15 +1062,45 @@ abstract class SyncFaceTool {
'|$_tag|质量上报标记失败|error:${markError.runtimeType}|stack:$markStack|',
);
}
if (confirmedSampleIds.length != batch.length) allSucceeded = false;
return confirmedSampleIds;
} catch (error, stack) {
allSucceeded = false;
failedBatchCount++;
logger.w(
'|$_tag|质量上报批次失败|size:${batch.length}|error:${error.runtimeType}|stack:$stack|',
);
return null;
}
}
for (var offset = 0;
offset < items.length;
offset += _qualityReportBatchSize) {
final end =
(offset + _qualityReportBatchSize).clamp(0, items.length).toInt();
final batch = items.sublist(offset, end);
final confirmed = await reportBatch(batch);
if (confirmed != null) {
confirmedCount += confirmed.length;
} else if (batch.length > _qualityRetryBatchSize) {
// 服务端对整批做一票否决校验(未审核/重复编号会拒绝整批):
// 降级为小子批重试,个别问题样本只拖住自己的小批,其余照常确认。
for (var sub = 0; sub < batch.length; sub += _qualityRetryBatchSize) {
final subEnd =
(sub + _qualityRetryBatchSize).clamp(0, batch.length).toInt();
final subConfirmed = await reportBatch(batch.sublist(sub, subEnd));
if (subConfirmed == null) {
failedBatchCount++;
allSucceeded = false;
} else {
confirmedCount += subConfirmed.length;
}
onProgress?.call(confirmedCount, items.length, '正在上报质量结果');
}
} else {
failedBatchCount++;
allSucceeded = false;
}
onProgress?.call(confirmedCount, items.length, '正在上报质量结果');
}
logger.i(
'|$_tag|质量上报完成|total:${items.length}|confirmed:$confirmedCount|reported:$alreadyReportedCount|invalid:$invalidCount|failedBatch:$failedBatchCount|',
);
@@ -1344,12 +1386,17 @@ class _FaceSyncAccumulator {
/// 有限保存的脱敏失败样本。
final List<String> maskedFailedSampleIds = [];
/// 累加一个失败样本并按上限保存脱敏编号。
void fail(String sampleId) {
/// 失败原因到数量的归类统计,按出现顺序保留。
final Map<String, int> failureReasons = {};
/// 累加一个失败样本并按上限保存脱敏编号,同时归类失败原因。
void fail(String sampleId, [String? reason]) {
failed++;
if (maskedFailedSampleIds.length < SyncFaceTool._maxFailureSamples) {
maskedFailedSampleIds.add(SyncFaceTool._maskSampleId(sampleId));
}
final key = reason == null || reason.isEmpty ? '未知原因' : reason;
failureReasons[key] = (failureReasons[key] ?? 0) + 1;
}
/// 生成可公开给任务日志和页面的不可变汇总。
@@ -1362,6 +1409,7 @@ class _FaceSyncAccumulator {
skipped: skipped,
failed: failed,
maskedFailedSampleIds: List.unmodifiable(maskedFailedSampleIds),
failureReasons: Map.unmodifiable(failureReasons),
qualityReportNote: qualityReportNote,
);
}