import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; /// 应用级人脸差异同步服务,供页面、Cron、MQ 和识别异常恢复共同复用。 abstract class SyncFaceTool { /// 人脸同步日志标签;日志只记录汇总和脱敏样本,不记录姓名、图片地址或特征。 static const String _tag = '人脸差异同步'; /// V3 游标快照页面大小。 static const int _snapshotPageSize = 200; /// V2 调试全租户同步页面大小,仅非生产入口使用。 static const int _debugPageSize = 100; /// 单次日志最多保留的脱敏失败样本数量,避免异常数据导致日志膨胀。 static const int _maxFailureSamples = 20; /// V3 游标保护页数,避免异常服务端持续返回 hasMore 造成无限循环。 static const int _maxSnapshotPages = 10000; /// 服务端单次允许接收的人脸质量结果上限。 static const int _qualityReportBatchSize = 500; /// 删除数据库中缺少本地特征或图片的历史残缺人脸。 /// /// 删除顺序仍先处理虹软内存库;单条失败时保留记录供后续权威快照重试。 static Future delEmptyFace() async { final faceDao = Get.find().dataBase.getFaceDao; final faces = await faceDao.getEmptyFaceList() ?? const []; for (final face in faces) { await _deleteLocalFace(face, faceDao); } } /// 兼容既有页面与识别恢复调用的统一入口。 /// /// [isAll] 只允许非生产调试使用;生产调用即使误传也会收敛为本部门快照。 static Future syncFace({ bool isAll = false, VoidCallback? onSyncGroup, }) async { final mode = isAll && !AppSysTool.isProd() ? FaceSyncMode.debugAllSnapshot : FaceSyncMode.departmentSnapshot; final summary = await sync(mode: mode); onSyncGroup?.call(); return summary; } /// 执行部门权威快照或一组用户增量;用户集合按稳定顺序串行处理。 /// /// 完整快照会一次性拉入内存并完成唯一性、游标和页面连续性校验, /// 任何页面中断都会抛出异常且不会进入本地孤儿删除阶段。 static Future sync({ required FaceSyncMode mode, Set? userIds, }) 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('终端尚未绑定商户部门'); } final storagePath = await AppFileTool.getFacePath(); if (storagePath == null || storagePath.isEmpty) { throw const FaceSyncSnapshotException('无法创建人脸本地存储目录'); } if (!AppRuntimeTool.useManualHardwareInput && AppSysTool.isProd() && !await AppFaceTool.isActiveSdk()) { throw const FaceSyncSnapshotException('人脸 SDK 未激活,无法安全更新人脸库'); } final accumulator = _FaceSyncAccumulator(mode); if (mode == FaceSyncMode.userDelta) { final normalizedUserIds = (userIds ?? const {}) .map((item) => item.trim()) .where((item) => item.isNotEmpty) .toSet() .toList() ..sort(); if (normalizedUserIds.isEmpty) { throw const FaceSyncSnapshotException('用户增量同步缺少用户标识'); } for (final userId in normalizedUserIds) { final remote = await _loadV3Snapshot( merchantDeptId: merchantDeptId, userId: userId, ); if (remote.length > 3) { throw FaceSyncSnapshotException( '用户 ${_maskSampleId(userId)} 的服务端人脸超过3张,已停止本次增量', ); } final local = await Get.find().dataBase.getFaceDao.findByUserId( userId, ) ?? const []; await _reconcile( remote: remote, local: local, storagePath: storagePath, accumulator: accumulator, ); } } else { final remote = mode == FaceSyncMode.debugAllSnapshot ? await _loadDebugAllSnapshot() : await _loadV3Snapshot(merchantDeptId: merchantDeptId); final local = await Get.find().dataBase.getFaceDao.getAll(); await _reconcile( remote: remote, local: local, storagePath: storagePath, accumulator: accumulator, ); } await _reportPersistedFaceQualitySafely(); final summary = accumulator.toSummary(); logger.i('|$_tag|同步完成|${summary.message}|'); return summary; } /// 拉取 V3 固定上界游标快照并校验页面连续性、记录唯一性和字段有效性。 static Future> _loadV3Snapshot({ required String merchantDeptId, String? userId, }) async { var afterId = '0'; String? snapshotMaxId; final records = []; final databaseIds = {}; final sampleIds = {}; for (var page = 0; page < _maxSnapshotPages; page++) { final snapshot = await UserApi.getFaceSyncSnapshot({ 'merchantDeptId': merchantDeptId, 'userId': userId, 'afterId': afterId, 'limit': _snapshotPageSize, 'snapshotMaxId': snapshotMaxId, }); snapshotMaxId ??= snapshot.snapshotMaxId; if (snapshot.snapshotMaxId != snapshotMaxId) { throw const FaceSyncSnapshotException('人脸快照上界在分页期间发生变化'); } var previousId = int.tryParse(afterId) ?? 0; for (final record in snapshot.records) { final currentId = int.tryParse(record.id); final sampleId = int.tryParse(record.sampleId); if (currentId == null || currentId <= previousId) { throw const FaceSyncSnapshotException('人脸快照游标不连续或发生倒退'); } if (sampleId == null || sampleId <= 0 || sampleId > 2147483647) { throw FaceSyncSnapshotException( '存在无效人脸编号 ${_maskSampleId(record.sampleId)}', ); } if (!databaseIds.add(record.id) || !sampleIds.add(record.sampleId)) { throw FaceSyncSnapshotException( '人脸快照包含重复编号 ${_maskSampleId(record.sampleId)}', ); } if (userId != null && record.userId != userId) { throw const FaceSyncSnapshotException('用户增量快照返回了其他用户数据'); } if (record.faceImg == null || record.faceImg!.trim().isEmpty) { throw FaceSyncSnapshotException( '人脸 ${_maskSampleId(record.sampleId)} 缺少图片地址', ); } records.add(record); previousId = currentId; } final expectedNext = snapshot.records.isEmpty ? afterId : snapshot.records.last.id; if (snapshot.nextAfterId != expectedNext) { throw const FaceSyncSnapshotException('人脸快照下一页游标与当前页不一致'); } if (!snapshot.hasMore) return records; if (snapshot.records.isEmpty) { throw const FaceSyncSnapshotException('人脸快照存在空页但仍声明后续数据'); } afterId = snapshot.nextAfterId; } throw const FaceSyncSnapshotException('人脸快照分页超过安全上限'); } /// 使用保留的 V2 接口拉取非生产调试全租户快照。 /// /// 生产入口不会调用本方法;V2 不提供源更新时间,因此仅以图片地址判断图片版本。 static Future> _loadDebugAllSnapshot() async { final records = []; final sampleIds = {}; for (var page = 1; page <= _maxSnapshotPages; page++) { final data = await UserApi.getFaceListV2({ 'current': page, 'size': _debugPageSize, 'merchantDeptId': '', }); if (data == null) { throw const FaceSyncSnapshotException('调试全租户人脸接口未返回有效数据'); } for (final face in data) { final sampleId = face.autoCode?.trim(); final userId = face.userId?.trim(); if (sampleId == null || userId == null || sampleId.isEmpty || userId.isEmpty || !sampleIds.add(sampleId)) { throw const FaceSyncSnapshotException('调试全租户快照包含无效或重复人脸编号'); } records.add(ServiceFaceSyncRecord( id: face.id ?? sampleId, sampleId: sampleId, userId: userId, username: face.username, deptId: face.deptId ?? '', deptName: face.deptName, faceImg: face.faceImg, )); } if (data.length < _debugPageSize) return records; } throw const FaceSyncSnapshotException('调试全租户快照分页超过安全上限'); } /// 对完整服务端集合与指定范围本地集合做差异,只处理新增、变化和孤儿记录。 static Future _reconcile({ required List remote, required List local, required String storagePath, required _FaceSyncAccumulator accumulator, }) async { final faceDao = Get.find().dataBase.getFaceDao; final localBySampleId = {}; for (final face in local) { if (localBySampleId.containsKey(face.sampleId)) { throw FaceSyncSnapshotException( '本地存在重复人脸编号 ${_maskSampleId(face.sampleId)}', ); } 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++; } else { accumulator.fail(record.sampleId); } continue; } if (_metadataChanged(existing, record)) { await faceDao.edit(_toEntity(record, existing: existing)); accumulator.updated++; } else { accumulator.skipped++; } } // 只有远端完整集合已经成功拉取和校验后才会执行此处的集合差删除。 for (final orphan in localBySampleId.values) { if (await _deleteLocalFace(orphan, faceDao)) { accumulator.deleted++; } else { accumulator.fail(orphan.sampleId); } } } /// 判断图片版本、特征或正式文件是否变化;完全一致时不下载也不写虹软。 static Future _imageChanged( FaceEntity? existing, ServiceFaceSyncRecord record, ) async { if (existing == null) return true; if (existing.imagePath != record.faceImg) return true; if (record.sourceUpdatedAt != null && existing.updateTime != record.sourceUpdatedAt) { return true; } if ((existing.faceModel ?? const []).isEmpty) return true; final path = existing.path; return path == null || path.isEmpty || !await File(path).exists(); } /// 判断姓名或部门元数据是否变化;此类变化不需要重新提取图片特征。 static bool _metadataChanged( FaceEntity existing, ServiceFaceSyncRecord record, ) => existing.userId != record.userId || existing.userName != (record.username ?? '') || existing.deptId != record.deptId || existing.deptName != (record.deptName ?? ''); /// 下载临时图片、提取特征并提交文件、SQLite 和虹软单样本增量。 /// /// 虹软替换失败时原生保留旧特征,本方法随后回滚 SQLite 和新文件;新样本失败则不留正式记录。 static Future _upsertImageFace({ required ServiceFaceSyncRecord record, required FaceEntity? existing, required String storagePath, required FaceDao faceDao, }) async { File? temporaryFile; File? finalFile; var databaseChanged = false; try { temporaryFile = await _downloadTemporaryFace(record, storagePath); final extraction = AppRuntimeTool.useManualHardwareInput ? null : await FaceDiscernPlugs.extractFaceFeature( filePath: temporaryFile.path, ); final featureData = extraction?.featureData ?? []; if (!AppRuntimeTool.useManualHardwareInput && featureData.isEmpty) { throw const FaceSyncSnapshotException('图片未提取到可注册人脸'); } finalFile = File(_officialFilePath(record, storagePath)); if (await finalFile.exists()) await finalFile.delete(); await temporaryFile.rename(finalFile.path); temporaryFile = null; final entity = _toEntity( record, existing: existing, path: finalFile.path, faceModel: featureData, extraction: extraction, imageReplaced: true, ); if (existing == null) { final id = await faceDao.save(entity); if (id == null) throw const FaceSyncSnapshotException('本地人脸记录写入失败'); entity.id = id; } else { await faceDao.edit(entity); } databaseChanged = true; if (!AppRuntimeTool.useManualHardwareInput) { final applied = await FaceDiscernPlugs.applyFaceDelta( operation: 'upsert', sampleId: record.sampleId, userId: record.userId, username: record.username ?? '', featureData: featureData, ); if (!applied.success) throw FaceSyncSnapshotException(applied.message); } if (existing?.path != null && existing!.path != finalFile.path) { await delFaceByStorage(filePath: existing.path!); } return true; } catch (error, stack) { logger.w( '|$_tag|样本处理失败sampleId:${_maskSampleId(record.sampleId)}|error:${error.runtimeType}|stack:$stack|', ); if (databaseChanged) { try { if (existing == null) { await faceDao.delBySampleId(record.sampleId); } else { await faceDao.edit(existing); } } catch (rollbackError, rollbackStack) { logger.e( '|$_tag|回滚本地人脸失败sampleId:${_maskSampleId(record.sampleId)}|error:$rollbackError|stack:$rollbackStack|', ); } } if (finalFile != null) await delFaceByStorage(filePath: finalFile.path); return false; } finally { if (temporaryFile != null) { await delFaceByStorage(filePath: temporaryFile.path); } } } /// 删除本地孤儿人脸;文件、虹软或 SQLite 失败时恢复文件和旧特征。 static Future _deleteLocalFace( FaceEntity face, FaceDao faceDao, ) async { File? originalFile; File? pendingFile; try { if (face.path != null && face.path!.isNotEmpty) { originalFile = File(face.path!); if (await originalFile.exists()) { pendingFile = File('${face.path}.pending-delete'); if (await pendingFile.exists()) await pendingFile.delete(); await originalFile.rename(pendingFile.path); } } if (!AppRuntimeTool.useManualHardwareInput) { final removed = await FaceDiscernPlugs.applyFaceDelta( operation: 'delete', sampleId: face.sampleId, ); if (!removed.success) throw FaceSyncSnapshotException(removed.message); } await faceDao.delBySampleId(face.sampleId); if (pendingFile != null && await pendingFile.exists()) { await pendingFile.delete(); } return true; } catch (error, stack) { logger.w( '|$_tag|删除样本失败sampleId:${_maskSampleId(face.sampleId)}|error:${error.runtimeType}|stack:$stack|', ); try { if (pendingFile != null && await pendingFile.exists()) { await pendingFile.rename(originalFile!.path); } final stillExists = await faceDao.findBySampleId(face.sampleId); if (stillExists == null) await faceDao.save(face); await _restoreNativeFace(face); } catch (rollbackError, rollbackStack) { logger.e( '|$_tag|回滚删除失败sampleId:${_maskSampleId(face.sampleId)}|error:$rollbackError|stack:$rollbackStack|', ); } return false; } } /// 通过与同步孤儿删除相同的可回滚流程清理一条已获服务端确认的人脸。 /// /// 调用方必须先完成服务端删除或取得“服务端已不存在”确认;失败时本方法恢复 /// 图片、SQLite 和虹软内存特征,并返回 false 供页面保留重试入口。 static Future deleteLocalFace(FaceEntity face) => _deleteLocalFace(face, Get.find().dataBase.getFaceDao); /// 手动检测存量在库照片质量并将结果批量回写 SQLite。 /// /// [failedOnly] 为 true 时仅重试上次失败记录;不存在或不可读的本地文件会直接 /// 标记为 failed,其他图片交给 Android 原生单线程质量任务处理。 static Future auditLibraryFaceQuality({ bool failedOnly = false, }) async { final faceDao = Get.find().dataBase.getFaceDao; final allCandidates = await faceDao.findQualityAuditCandidates(); final candidates = failedOnly ? allCandidates .where( (face) => face.qualityStatus == FaceQualityStatus.failed.code, ) .toList(growable: false) : allCandidates; if (candidates.isEmpty) { final reported = await _reportPersistedFaceQualitySafely(); return FaceQualityAuditResult( results: [], message: reported ? '没有需要检测的人脸' : '没有需要检测的人脸;服务端上报失败,将在下次重试', ); } final invalidResults = []; final validFaces = []; for (final face in candidates) { final path = face.path; if (path == null || path.isEmpty || !await File(path).exists()) { invalidResults.add(FaceImageQualityResult( sampleId: face.sampleId, status: FaceQualityStatus.failed, message: '本地人脸图片不存在', errorCode: -2, )); } else { validFaces.add(face); } } FaceQualityAuditResult nativeResult = const FaceQualityAuditResult( results: [], message: '在库人脸质量检测完成', ); if (validFaces.isNotEmpty) { try { nativeResult = await FaceDiscernPlugs.auditFaceQuality( items: validFaces .map( (face) => FaceQualityAuditItem( sampleId: face.sampleId, filePath: face.path!, ), ) .toList(growable: false), ); } catch (error, stack) { logger.w('|$_tag|原生质量检测通道失败,将样本记录为检测失败|error:$error|stack:$stack|'); nativeResult = FaceQualityAuditResult( results: [ for (final face in validFaces) FaceImageQualityResult( sampleId: face.sampleId, status: FaceQualityStatus.failed, message: '原生质量检测不可用', errorCode: -99, ), ], message: '原生质量检测不可用,样本已标记为检测失败', ); } } final results = [ ...invalidResults, ...nativeResult.results, ]; final bySampleId = { for (final result in results) result.sampleId: result, }; final checkedAt = DateTime.now(); final updates = []; for (final face in candidates) { final result = bySampleId[face.sampleId] ?? FaceImageQualityResult( sampleId: face.sampleId, status: FaceQualityStatus.failed, message: '原生未返回该样本的质量结果', errorCode: -99, ); face ..qualityStatus = result.status.code ..qualityScore = result.status == FaceQualityStatus.detected ? result.score : null ..qualityCheckedAt = checkedAt ..qualityErrorCode = result.status == FaceQualityStatus.detected ? null : result.errorCode; updates.add(face); } if (updates.isNotEmpty) await faceDao.batchEdit(updates); final reported = await _reportPersistedFaceQualitySafely(); return FaceQualityAuditResult( results: [ for (final face in candidates) bySampleId[face.sampleId] ?? FaceImageQualityResult( sampleId: face.sampleId, status: FaceQualityStatus.failed, message: '原生未返回该样本的质量结果', errorCode: -99, ), ], message: reported ? nativeResult.message : '${nativeResult.message};服务端上报失败,将在下次重试', ); } /// 将本机已检测或检测失败的人脸质量按 500 条幂等补报到服务端。 /// /// 第一版不增加本地“已上报”数据库字段,因此每次完整同步或人工质量检测完成后 /// 都会补报当前持久化结果。服务端按图片版本、成功状态优先级和检测时间保证幂等; /// 任一网络或业务错误只记录汇总日志并返回 false,绝不回滚 SQLite、特征或虹软内存库。 static Future _reportPersistedFaceQualitySafely() async { if (!AppNetworkTool.isAutoNetwork()) { logger.w('|$_tag|质量上报跳过|reason:网络不可用|'); return false; } try { final faces = await Get.find().dataBase.getFaceDao.getAll(); final items = []; var invalidCount = 0; for (final face in faces) { final status = FaceQualityStatus.fromCode(face.qualityStatus); if (status == FaceQualityStatus.pending) continue; final checkedAt = face.qualityCheckedAt; final sourceUpdatedAt = face.updateTime; final score = face.qualityScore; if (checkedAt == null || sourceUpdatedAt == null || (status == FaceQualityStatus.detected && (score == null || score < 0 || score > 1))) { invalidCount++; continue; } items.add(ServiceFaceQualityReportItem( sampleId: face.sampleId, status: status.code, score: status == FaceQualityStatus.detected ? score : null, sourceUpdatedAt: sourceUpdatedAt, checkedAt: checkedAt, errorCode: status == FaceQualityStatus.failed ? face.qualityErrorCode : null, )); } if (items.isEmpty) { if (invalidCount > 0) { logger.w('|$_tag|质量上报无有效结果|invalid:$invalidCount|'); return false; } return true; } final lease = await UserApi.acquireTerminalFaceQualityLease(); if (!lease.granted) { logger.i( '|$_tag|质量上报跳过|reason:其他设备持有租约|remainingSeconds:${lease.remainingSeconds}|', ); return true; } 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); 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; } catch (error, stack) { allSucceeded = false; failedBatchCount++; logger.w( '|$_tag|质量上报批次失败|size:${batch.length}|error:${error.runtimeType}|stack:$stack|', ); } } logger.i( '|$_tag|质量上报完成|total:${items.length}|confirmed:$confirmedCount|invalid:$invalidCount|failedBatch:$failedBatchCount|', ); return allSucceeded; } catch (error, stack) { logger.w('|$_tag|质量上报准备失败|error:${error.runtimeType}|stack:$stack|'); return false; } } /// 删除回滚时恢复旧内存特征;历史记录无特征时按用户从仍存在的 SQLite 重新装载。 static Future _restoreNativeFace(FaceEntity face) async { if (AppRuntimeTool.useManualHardwareInput) return; final featureData = (face.faceModel ?? const []) .map((item) => (item as num).toInt()) .toList(); if (featureData.isNotEmpty) { await FaceDiscernPlugs.applyFaceDelta( operation: 'upsert', sampleId: face.sampleId, userId: face.userId, username: face.userName, featureData: featureData, ); return; } await FaceDiscernPlugs.rebuildFaceLibrary(userId: face.userId); } /// 下载远端图片到专用临时目录;下载失败不会覆盖任何正式文件。 static Future _downloadTemporaryFace( ServiceFaceSyncRecord record, String storagePath, ) async { final imageUrl = record.faceImg!; final bytes = await AppNetworkTool.downloadImage(imageUrl); if (bytes == null || bytes.isEmpty) { throw const FaceSyncSnapshotException('人脸图片下载失败'); } final directory = Directory('$storagePath/.face-sync-temp'); if (!await directory.exists()) await directory.create(recursive: true); final file = File( '${directory.path}/${record.sampleId}_${DateTime.now().microsecondsSinceEpoch}.tmp', ); await file.writeAsBytes(bytes, flush: true); return file; } /// 生成人脸正式文件路径;版本戳确保新文件写入前旧文件始终可回滚。 static String _officialFilePath( ServiceFaceSyncRecord record, String storagePath, ) { final extension = AppFileTool.getFileExtension(record.faceImg!); final sourceVersion = record.sourceUpdatedAt?.millisecondsSinceEpoch ?? 0; final writeVersion = DateTime.now().microsecondsSinceEpoch; return '$storagePath/${record.userId}_${record.sampleId}_${sourceVersion}_$writeVersion$extension'; } /// 将服务端快照合并为 Floor 现有 FaceEntity,不改变 SQLite 字段结构。 static FaceEntity _toEntity( ServiceFaceSyncRecord record, { required FaceEntity? existing, String? path, List? faceModel, FaceFeatureExtractionResult? extraction, bool imageReplaced = false, }) => FaceEntity( id: existing?.id, userId: record.userId, sampleId: record.sampleId, userName: record.username ?? '', deptId: record.deptId, deptName: record.deptName ?? '', imagePath: record.faceImg ?? '', path: path ?? existing?.path, faceModel: faceModel ?? existing?.faceModel, createTime: existing?.createTime ?? DateTime.now(), updateTime: record.sourceUpdatedAt ?? existing?.updateTime, qualityScore: extraction == null ? imageReplaced ? null : existing?.qualityScore : extraction.qualityStatus == FaceQualityStatus.detected ? extraction.qualityScore : null, qualityStatus: extraction?.qualityStatus.code ?? (imageReplaced ? FaceQualityStatus.pending.code : null) ?? existing?.qualityStatus ?? FaceQualityStatus.pending.code, qualityCheckedAt: extraction == null ? imageReplaced ? null : existing?.qualityCheckedAt : DateTime.now(), qualityErrorCode: extraction == null ? imageReplaced ? null : existing?.qualityErrorCode : extraction.qualityStatus == FaceQualityStatus.detected ? null : extraction.qualityErrorCode, ); /// 删除指定本地文件;失败只记录文件路径以外的异常类型,避免同步主流程被清理动作中断。 static Future delFaceByStorage({required String filePath}) async { try { final file = File(filePath); if (await file.exists()) await file.delete(); } catch (error, stack) { logger.w('|$_tag|删除人脸文件失败|error:${error.runtimeType}|stack:$stack|'); } } /// 为兼容其他历史调用保留的文件名生成方法;新差异同步使用带版本戳的内部路径。 static String? generateFaceFileName({ required String userId, required String sampleId, required String username, required String facePath, }) { if (facePath.isEmpty) return null; return '${userId}_${sampleId}_$username${AppFileTool.getFileExtension(facePath)}'; } /// 为兼容其他历史调用保留的下载方法;新差异同步使用临时文件校验后再提升为正式文件。 static Future writeFaceByStorage({ required String storagePath, required String? fileName, required String facePath, }) async { if (fileName == null || fileName.isEmpty) return null; final bytes = await AppNetworkTool.downloadImage(facePath); if (bytes == null || bytes.isEmpty) return null; final file = File('$storagePath/$fileName'); await file.writeAsBytes(bytes, flush: true); return await file.exists() ? file.path : null; } /// 对虹软搜索标识进行脱敏,日志不输出完整样本编号。 static String _maskSampleId(String value) { if (value.length <= 4) return '***$value'; return '${value.substring(0, 1)}***${value.substring(value.length - 3)}'; } } /// 人脸同步内部可变统计器,单次任务结束后转换为不可变汇总。 class _FaceSyncAccumulator { /// 创建指定同步模式的空统计器。 _FaceSyncAccumulator(this.mode); /// 本次同步业务模式。 final FaceSyncMode mode; /// 已扫描服务端记录数。 int scanned = 0; /// 成功新增样本数。 int added = 0; /// 成功更新样本数。 int updated = 0; /// 成功删除孤儿样本数。 int deleted = 0; /// 无变化跳过样本数。 int skipped = 0; /// 处理失败样本数。 int failed = 0; /// 有限保存的脱敏失败样本。 final List maskedFailedSampleIds = []; /// 累加一个失败样本并按上限保存脱敏编号。 void fail(String sampleId) { failed++; if (maskedFailedSampleIds.length < SyncFaceTool._maxFailureSamples) { maskedFailedSampleIds.add(SyncFaceTool._maskSampleId(sampleId)); } } /// 生成可公开给任务日志和页面的不可变汇总。 FaceSyncSummary toSummary() => FaceSyncSummary( mode: mode, scanned: scanned, added: added, updated: updated, deleted: deleted, skipped: skipped, failed: failed, maskedFailedSampleIds: List.unmodifiable(maskedFailedSampleIds), ); }