Files
biz_xfj_cloud/lib/common/utils/sync/sync_face_tool.dart
T

603 lines
22 KiB
Dart

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 Future<void> delEmptyFace() async {
final faceDao = Get.find<AppControl>().dataBase.getFaceDao;
final faces = await faceDao.getEmptyFaceList() ?? const [];
for (final face in faces) {
await _deleteLocalFace(face, faceDao);
}
}
/// 兼容既有页面与识别恢复调用的统一入口。
///
/// [isAll] 只允许非生产调试使用;生产调用即使误传也会收敛为本部门快照。
static Future<FaceSyncSummary> 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<FaceSyncSummary> sync({
required FaceSyncMode mode,
Set<String>? 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 <String>{})
.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<AppControl>().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<AppControl>().dataBase.getFaceDao.getAll();
await _reconcile(
remote: remote,
local: local,
storagePath: storagePath,
accumulator: accumulator,
);
}
final summary = accumulator.toSummary();
logger.i('|$_tag|同步完成|${summary.message}|');
return summary;
}
/// 拉取 V3 固定上界游标快照并校验页面连续性、记录唯一性和字段有效性。
static Future<List<ServiceFaceSyncRecord>> _loadV3Snapshot({
required String merchantDeptId,
String? userId,
}) async {
var afterId = '0';
String? snapshotMaxId;
final records = <ServiceFaceSyncRecord>[];
final databaseIds = <String>{};
final sampleIds = <String>{};
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<List<ServiceFaceSyncRecord>> _loadDebugAllSnapshot() async {
final records = <ServiceFaceSyncRecord>[];
final sampleIds = <String>{};
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<void> _reconcile({
required List<ServiceFaceSyncRecord> remote,
required List<FaceEntity> local,
required String storagePath,
required _FaceSyncAccumulator accumulator,
}) async {
final faceDao = Get.find<AppControl>().dataBase.getFaceDao;
final localBySampleId = <String, FaceEntity>{};
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<bool> _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<bool> _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 featureData = AppRuntimeTool.useManualHardwareInput
? <int>[]
: (await FaceDiscernPlugs.extractFaceFeature(
filePath: temporaryFile.path,
))
.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,
);
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<bool> _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 delFaceByStorage(filePath: pendingFile.path);
}
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 重新装载。
static Future<void> _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<File> _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<int>? faceModel,
}) =>
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,
);
/// 删除指定本地文件;失败只记录文件路径以外的异常类型,避免同步主流程被清理动作中断。
static Future<void> 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<String?> 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<String> 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),
);
}