318 lines
12 KiB
Dart
318 lines
12 KiB
Dart
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:get/get.dart';
|
|
|
|
import 'package:pj_supermarket_cloud/common/index.dart';
|
|
|
|
/// 统一封装人脸 SDK 激活、状态查询、本地人脸库注册和删除操作。
|
|
///
|
|
/// 所有激活参数均从终端系统设置读取,调用方不得传入或记录密钥明文。
|
|
abstract class AppFaceTool {
|
|
/// 人脸工具日志标签,不记录激活参数和人脸特征等敏感内容。
|
|
static const String _tag = "app 人脸工具";
|
|
|
|
/// 打开原生人脸识别页面;手动硬件模式或插件拒绝打开时返回 false。
|
|
static Future<bool> jumpFacePage() async {
|
|
if (AppRuntimeTool.useManualHardwareInput) return false;
|
|
final isJump = await FaceDiscernPlugs.jumpFaceDiscernPage();
|
|
if (!isJump) return false;
|
|
return true;
|
|
}
|
|
|
|
/// 检查后端下发的人脸激活参数是否全部具有非空有效值。
|
|
static bool validateSysFaceParams(SysSetting sysSetting) {
|
|
return missingSysFaceParams(sysSetting).isEmpty;
|
|
}
|
|
|
|
/// 返回缺失的人脸参数字段名,供 Load 页给出可排查但不泄露密钥的提示。
|
|
static List<String> missingSysFaceParams(SysSetting sysSetting) {
|
|
final missing = <String>[];
|
|
if ((sysSetting.faceAppId ?? '').trim().isEmpty) missing.add('APP_ID');
|
|
if ((sysSetting.faceSdkKey ?? '').trim().isEmpty) missing.add('SDK_KEY');
|
|
if ((sysSetting.faceActiveKey ?? '').trim().isEmpty) {
|
|
missing.add('ACTIVE_KEY');
|
|
}
|
|
return List.unmodifiable(missing);
|
|
}
|
|
|
|
/// 在 Floor 完成建库后激活虹软 SDK,并将本地数据库人脸完整重建到内存库。
|
|
///
|
|
/// 任意插件、权限或样本异常都转换为警告结果,调用方可以继续完成终端启动。
|
|
static Future<FaceStartupPreparationResult> prepareForStartup() async {
|
|
if (AppRuntimeTool.useManualHardwareInput) {
|
|
return const FaceStartupPreparationResult(
|
|
status: FaceStartupPreparationStatus.skipped,
|
|
message: '当前为手动硬件输入模式,已跳过真实人脸设备准备',
|
|
total: 0,
|
|
successCount: 0,
|
|
failedCount: 0,
|
|
failedSampleIds: [],
|
|
missingParameters: [],
|
|
);
|
|
}
|
|
|
|
final setting = await AppSettingTool.getSysSetting();
|
|
final missing = missingSysFaceParams(setting);
|
|
if (missing.isNotEmpty) {
|
|
return FaceStartupPreparationResult(
|
|
status: FaceStartupPreparationStatus.warning,
|
|
message: '缺少 ${missing.join('、')},请先同步设备配置',
|
|
total: 0,
|
|
successCount: 0,
|
|
failedCount: 0,
|
|
failedSampleIds: const [],
|
|
missingParameters: missing,
|
|
);
|
|
}
|
|
|
|
FaceActivationResult activation;
|
|
try {
|
|
activation = await activateSdk();
|
|
} catch (error, stack) {
|
|
logger.w('|$_tag|启动阶段激活人脸插件异常error:|$error|stack:|$stack|');
|
|
return const FaceStartupPreparationResult(
|
|
status: FaceStartupPreparationStatus.warning,
|
|
message: '人脸插件暂时无法激活,可进入设置后重试',
|
|
total: 0,
|
|
successCount: 0,
|
|
failedCount: 0,
|
|
failedSampleIds: [],
|
|
missingParameters: [],
|
|
);
|
|
}
|
|
if (!activation.success) {
|
|
return FaceStartupPreparationResult(
|
|
status: FaceStartupPreparationStatus.warning,
|
|
message: activation.message,
|
|
total: 0,
|
|
successCount: 0,
|
|
failedCount: 0,
|
|
failedSampleIds: const [],
|
|
missingParameters: const [],
|
|
activationCode: activation.code,
|
|
);
|
|
}
|
|
|
|
try {
|
|
final registration = await FaceDiscernPlugs.rebuildFaceLibrary();
|
|
final status = registration.success
|
|
? FaceStartupPreparationStatus.success
|
|
: FaceStartupPreparationStatus.warning;
|
|
return FaceStartupPreparationResult(
|
|
status: status,
|
|
message: registration.message,
|
|
total: registration.total,
|
|
successCount: registration.successCount,
|
|
failedCount: registration.failedCount,
|
|
failedSampleIds: List.unmodifiable(registration.failedSampleIds),
|
|
missingParameters: const [],
|
|
activationCode: activation.code,
|
|
);
|
|
} catch (error, stack) {
|
|
logger.w('|$_tag|启动阶段重建人脸库异常error:|$error|stack:|$stack|');
|
|
return FaceStartupPreparationResult(
|
|
status: FaceStartupPreparationStatus.warning,
|
|
message: '人脸插件已激活,但本地人脸库注册失败,可在设置中重新注册',
|
|
total: 0,
|
|
successCount: 0,
|
|
failedCount: 0,
|
|
failedSampleIds: const [],
|
|
missingParameters: const [],
|
|
activationCode: activation.code,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 按需将本地人脸记录注册到 SDK 内存库。
|
|
///
|
|
/// [userId] 为空时处理完整人脸库,[forceRegister] 为 true 时忽略当前内存库数量并强制重建。
|
|
/// 返回值表示调用完成后内存库中是否至少存在一条可识别人脸。
|
|
static Future<bool> faceRegister({
|
|
String? userId,
|
|
bool forceRegister = false,
|
|
}) async {
|
|
if (AppRuntimeTool.useManualHardwareInput) return true;
|
|
var faceCount = await FaceDiscernPlugs.getFaceCount();
|
|
if (faceCount == 0 || forceRegister) {
|
|
final result = await FaceDiscernPlugs.rebuildFaceLibrary(
|
|
userId: userId,
|
|
);
|
|
if (result.failedSampleIds.isNotEmpty) {
|
|
logger.w('|$_tag|批量注册人脸失败数量:${result.failedSampleIds.length}');
|
|
}
|
|
}
|
|
faceCount = await FaceDiscernPlugs.getFaceCount();
|
|
if (faceCount == 0) return false;
|
|
return true;
|
|
}
|
|
|
|
/// 将 SDK 识别结果解析为当前后端仍有效的人员和钱包信息。
|
|
///
|
|
/// 正常情况直接使用本地样本或 SDK 返回的人员编号查询;编号失效时,仅允许
|
|
/// 通过现有全量人脸同步刷新同一个 [FaceStream.sampleId] 的归属后重试。
|
|
/// 禁止按本地饭卡号替换为另一人员,避免旧人脸样本误绑定到卡片的新持有人。
|
|
static Future<ServiceUser?> resolveRecognizedUser({
|
|
required FaceStream recognition,
|
|
required SysType sysType,
|
|
}) async {
|
|
final app = Get.find<AppControl>();
|
|
final faceDao = app.dataBase.getFaceDao;
|
|
final candidateUserIds = <String>{};
|
|
final sampleId = recognition.sampleId?.toString();
|
|
if (sampleId != null && sampleId.isNotEmpty) {
|
|
final localFace = await faceDao.findBySampleId(sampleId);
|
|
_addCandidateUserId(candidateUserIds, localFace?.userId);
|
|
}
|
|
_addCandidateUserId(candidateUserIds, recognition.userId);
|
|
|
|
if (!AppNetworkTool.isAutoNetwork()) {
|
|
return _findCachedRecognizedUser(candidateUserIds);
|
|
}
|
|
|
|
final currentUser = await _findRemoteRecognizedUser(
|
|
candidateUserIds,
|
|
sysType,
|
|
);
|
|
if (currentUser != null || sampleId == null || sampleId.isEmpty) {
|
|
return currentUser;
|
|
}
|
|
|
|
// 识别会话已经由调用方结束,此处才允许进入统一人脸队列,避免并发操作引擎。
|
|
// 已知人员只回查该用户;没有可靠用户标识时才对账本部门,禁止恢复流程拉取全租户人脸。
|
|
logger.w('|$_tag|识别样本对应人员已失效,开始回查当前范围人脸并刷新样本归属|');
|
|
final scheduler = SyncCheckTool.instance;
|
|
await scheduler.trigger(
|
|
ScheduledTaskType.departmentFaceSync,
|
|
faceUserIds: candidateUserIds.isEmpty ? null : candidateUserIds,
|
|
triggerType: ScheduledTaskTriggerType.system,
|
|
);
|
|
if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync) ||
|
|
scheduler.pendingTasks.contains(ScheduledTaskType.departmentFaceSync)) {
|
|
// 已有同步占用引擎时不读取旧归属继续支付;队列完成后由下一次识别使用新数据。
|
|
return null;
|
|
}
|
|
final refreshedFace = await faceDao.findBySampleId(sampleId);
|
|
final refreshedUserId = refreshedFace?.userId.trim();
|
|
if (refreshedUserId == null ||
|
|
refreshedUserId.isEmpty ||
|
|
candidateUserIds.contains(refreshedUserId)) {
|
|
return null;
|
|
}
|
|
candidateUserIds.add(refreshedUserId);
|
|
return _findRemoteRecognizedUser({refreshedUserId}, sysType);
|
|
}
|
|
|
|
/// 将非空人员编号加入有序候选集合,保持本地样本优先于原生回包。
|
|
static void _addCandidateUserId(Set<String> target, String? userId) {
|
|
final value = userId?.trim();
|
|
if (value == null || value.isEmpty) return;
|
|
target.add(value);
|
|
}
|
|
|
|
/// 离线时从本地人员缓存解析识别结果;没有缓存则返回空并交由业务提示同步。
|
|
static Future<ServiceUser?> _findCachedRecognizedUser(
|
|
Set<String> candidateUserIds,
|
|
) async {
|
|
for (final userId in candidateUserIds) {
|
|
final user = await SyncUserTool.storageUserByUserId(userId);
|
|
if (user != null) return SyncUserTool.parsePayUser(user: user);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 依次使用现有人员查询接口校验候选编号,并缓存首个仍有效的人员。
|
|
static Future<ServiceUser?> _findRemoteRecognizedUser(
|
|
Set<String> candidateUserIds,
|
|
SysType sysType,
|
|
) async {
|
|
for (final userId in candidateUserIds) {
|
|
final user = await HardApi.getUserInfoById({
|
|
'id': userId,
|
|
'type': sysType.name,
|
|
});
|
|
if (user == null || (user.userId ?? '').trim().isEmpty) continue;
|
|
await SyncUserTool.storageUser(user: user);
|
|
return user;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 查询当前设备的人脸 SDK 是否已经激活并可继续初始化。
|
|
static Future<bool> isActiveSdk() async {
|
|
if (AppRuntimeTool.useManualHardwareInput) return true;
|
|
final status = await getStatus();
|
|
return status?.activated == true;
|
|
}
|
|
|
|
/// 获取激活、引擎和人脸库一致性状态。
|
|
///
|
|
/// 参数不完整时返回未就绪状态;手动硬件输入模式不连接原生通道并返回 null。
|
|
static Future<FacePluginStatus?> getStatus() async {
|
|
final setting = await AppSettingTool.getSysSetting();
|
|
// 参数完整性来自本地已同步配置,不依赖人脸硬件或原生通道;虚拟机和手动输入模式也应明确展示“等待同步配置”。
|
|
if (!validateSysFaceParams(setting)) {
|
|
return const FacePluginStatus(
|
|
activated: false,
|
|
activationCode: -1,
|
|
parametersComplete: false,
|
|
engineReady: false,
|
|
engineCode: -1,
|
|
databaseCount: 0,
|
|
memoryCount: 0,
|
|
registrationInProgress: false,
|
|
libraryConsistent: false,
|
|
);
|
|
}
|
|
if (AppRuntimeTool.useManualHardwareInput) return null;
|
|
return FaceDiscernPlugs.getFaceStatus(
|
|
appId: setting.faceAppId ?? '',
|
|
sdkKey: setting.faceSdkKey ?? '',
|
|
activeKey: setting.faceActiveKey ?? '',
|
|
);
|
|
}
|
|
|
|
/// 激活参数只来自后端同步后的系统设置,页面不接收也不展示密钥明文。
|
|
static Future<FaceActivationResult> activateSdk() async {
|
|
final setting = await AppSettingTool.getSysSetting();
|
|
if (!validateSysFaceParams(setting)) {
|
|
return const FaceActivationResult(
|
|
success: false,
|
|
code: -1,
|
|
message: '人脸插件参数不完整,请先同步设备配置',
|
|
requiresPermission: false,
|
|
canOpenSettings: false,
|
|
);
|
|
}
|
|
|
|
final permission = await Permission.phone.status;
|
|
if (!permission.isGranted) {
|
|
final requested = await Permission.phone.request();
|
|
if (!requested.isGranted) {
|
|
return FaceActivationResult(
|
|
success: false,
|
|
code: -1,
|
|
message: requested.isPermanentlyDenied
|
|
? '电话状态权限已被永久拒绝,请在系统设置中开启'
|
|
: '激活需要电话状态权限,请授权后重试',
|
|
requiresPermission: true,
|
|
canOpenSettings: requested.isPermanentlyDenied,
|
|
);
|
|
}
|
|
}
|
|
|
|
return FaceDiscernPlugs.activateFaceSdk(
|
|
appId: setting.faceAppId!.trim(),
|
|
sdkKey: setting.faceSdkKey!.trim(),
|
|
activeKey: setting.faceActiveKey!.trim(),
|
|
);
|
|
}
|
|
|
|
/// 从 SDK 内存库删除指定样本;样本编号为空、SDK 未激活或插件失败时返回 false。
|
|
static Future<bool> removeFace({String? sampleId}) async {
|
|
if (sampleId == null) return false;
|
|
if (AppRuntimeTool.useManualHardwareInput) return true;
|
|
final isActive = await AppFaceTool.isActiveSdk();
|
|
if (!isActive) return false;
|
|
return await FaceDiscernPlugs.removeFace(sampleId: sampleId);
|
|
}
|
|
}
|