diff --git a/.metadata b/.metadata index 9c3a509..ad140f1 100644 --- a/.metadata +++ b/.metadata @@ -15,9 +15,6 @@ migration: - platform: root create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 - - platform: android - create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 - base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 # User provided section diff --git a/README.md b/README.md index 6c3f7f4..b5131d7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~ ## flutter打包 ``` -flutter build apk --build-name 1.3.6 --build-number 136 +flutter build apk --build-name 1.3.7 --build-number 137 --build-name为版本号(展示) --build-number为版本号(内部比较) ``` diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java index 3333c2b..de52c6e 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java @@ -186,6 +186,10 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me break; case "startRecognition": try { + if (FlutterTool.isRegistrationInProgress()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库更新中,请稍后重试", null); + break; + } result.success(sessionManager.startRecognition(stringArgument(call, "sessionId"), stringArgument(call, "scene"), this)); } catch (FaceRecognitionSessionManager.SessionBusyException e) { result.error("SESSION_BUSY", e.getMessage(), e.getActiveSessionId()); @@ -197,6 +201,10 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me break; case "retryRecognition": try { + if (FlutterTool.isRegistrationInProgress()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库更新中,请稍后重试", null); + break; + } result.success(sessionManager.retryRecognition(stringArgument(call, "sessionId"))); } catch (Exception e) { result.error("RECOGNITION_RETRY_FAILED", e.getMessage(), null); @@ -268,6 +276,12 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me case "rebuildFaceLibrary": startRegistrationTask(call.argument("userId"), result, false); break; + case "extractFaceFeature": + startFaceFeatureExtraction(stringArgument(call, "filePath"), result); + break; + case "applyFaceDelta": + startFaceDelta(call, result); + break; case "getFace": String sampleId = Objects.requireNonNull(call.argument("sampleId")).toString(); Map face = FlutterTool.getFace(context, Integer.parseInt(sampleId)); @@ -312,6 +326,84 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me }); } + /** + * 在统一人脸注册占用标记和后台执行器中提取临时图片特征。 + * + * @param filePath 临时图片绝对路径 + * @param result Flutter MethodChannel 异步结果 + */ + private void startFaceFeatureExtraction(String filePath, MethodChannel.Result result) { + if (!FlutterTool.tryStartRegistration()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null); + return; + } + FACE_EXECUTOR.execute(() -> { + try { + Map extraction = FlutterTool.extractFaceFeature(context, filePath); + MAIN_HANDLER.post(() -> result.success(extraction)); + } catch (Exception exception) { + Log.e(TAG, "提取人脸特征失败:" + exception.getMessage(), exception); + MAIN_HANDLER.post(() -> result.error("EXTRACT_FACE_FEATURE_FAILED", "人脸特征提取失败", null)); + } finally { + FlutterTool.finishRegistration(); + } + }); + } + + /** + * 在统一人脸注册占用标记和后台执行器中应用单样本增删。 + * + * @param call 包含 operation、sampleId、用户元数据和可选特征字节的调用 + * @param result Flutter MethodChannel 异步结果 + */ + private void startFaceDelta(MethodCall call, MethodChannel.Result result) { + if (!FlutterTool.tryStartRegistration()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null); + return; + } + FACE_EXECUTOR.execute(() -> { + try { + String operation = stringArgument(call, "operation"); + Number sampleIdValue = call.argument("sampleId"); + if (sampleIdValue == null) throw new IllegalArgumentException("sampleId不能为空"); + byte[] featureData = byteArrayArgument(call.argument("featureData")); + Map delta = FlutterTool.applyFaceDelta( + context, + operation, + sampleIdValue.intValue(), + stringArgument(call, "userId"), + stringArgument(call, "username"), + featureData + ); + MAIN_HANDLER.post(() -> result.success(delta)); + } catch (Exception exception) { + Log.e(TAG, "应用人脸增量失败:" + exception.getMessage(), exception); + MAIN_HANDLER.post(() -> result.error("APPLY_FACE_DELTA_FAILED", "人脸库增量更新失败", null)); + } finally { + FlutterTool.finishRegistration(); + } + }); + } + + /** + * 将 Flutter StandardMessageCodec 的数字列表转换为虹软特征字节。 + * + * @param value 可为空的 byte[] 或数字列表 + * @return 特征字节;参数为空时返回空数组 + */ + private byte[] byteArrayArgument(Object value) { + if (value instanceof byte[]) return (byte[]) value; + if (!(value instanceof List)) return new byte[0]; + List values = (List) value; + byte[] result = new byte[values.size()]; + for (int index = 0; index < values.size(); index++) { + Object item = values.get(index); + if (!(item instanceof Number)) throw new IllegalArgumentException("featureData包含非数字值"); + result[index] = ((Number) item).byteValue(); + } + return result; + } + private void emitRegistrationProgress(Map progress) { MAIN_HANDLER.post(() -> { EventChannel.EventSink sink = registrationEventSink; diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java index 7944129..22978c2 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java @@ -24,6 +24,7 @@ import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -107,9 +108,17 @@ public class FlutterTool { FaceServer faceServer = FaceServer.getInstance(); if (activated) faceServer.init(context); - int databaseCount = getUserFace(context, null).size(); + List databaseFaces = getUserFace(context, null); + int databaseCount = databaseFaces.size(); int memoryCount = activated && faceServer.isEngineReady() ? Math.max(faceServer.getTotalFaceCount(), 0) : 0; boolean registrationInProgress = REGISTRATION_IN_PROGRESS.get(); + Set databaseSampleIds = new HashSet<>(); + for (FaceEntity face : databaseFaces) { + if (face.getSampleId() != null) databaseSampleIds.add(face.getSampleId()); + } + Set memorySampleIds = activated && faceServer.isEngineReady() + ? faceServer.getRegisteredFaceIds() + : new HashSet<>(); status.put("activated", activated); status.put("activationCode", activeCode); @@ -121,7 +130,7 @@ public class FlutterTool { status.put("databaseCount", databaseCount); status.put("memoryCount", memoryCount); status.put("registrationInProgress", registrationInProgress); - status.put("libraryConsistent", !registrationInProgress && databaseCount == memoryCount); + status.put("libraryConsistent", !registrationInProgress && databaseSampleIds.equals(memorySampleIds)); status.put("registrationProgress", new HashMap<>(latestRegistrationProgress)); status.put("lastRegistrationResult", latestRegistrationResult == null ? null : new HashMap<>(latestRegistrationResult)); return status; @@ -284,6 +293,68 @@ public class FlutterTool { return i == 0; } + /** + * 从临时图片提取虹软注册特征,不修改 SQLite 或虹软内存人脸库。 + * + * @param context Android 应用上下文 + * @param filePath 同步服务写入的临时图片绝对路径 + * @return 包含成功标记、安全摘要和特征字节的 MethodChannel 结果 + */ + static public Map extractFaceFeature(Context context, String filePath) { + Map result = new HashMap<>(); + try { + byte[] featureData = extractFaceModelFromFile(context, filePath); + boolean success = featureData != null && featureData.length > 0; + result.put("success", success); + result.put("message", success ? "人脸特征提取成功" : "图片中未提取到可注册人脸"); + result.put("featureData", success ? featureData : new byte[0]); + return result; + } catch (Exception exception) { + Log.e(TAG, "提取人脸特征异常error:" + exception.getMessage(), exception); + result.put("success", false); + result.put("message", "人脸特征提取失败"); + result.put("featureData", new byte[0]); + return result; + } + } + + /** + * 在虹软内存库中应用单样本增删;替换失败由 FaceServer 恢复旧特征。 + * + * @param context Android 应用上下文 + * @param operation upsert 或 delete + * @param sampleId 虹软 32 位搜索标识 + * @param userId 用户主键,仅 upsert 使用 + * @param username 用户姓名,仅作为内存识别元数据 + * @param featureData 已通过提取校验的特征字节,仅 upsert 使用 + * @return 包含成功标记、SDK 返回码和安全摘要的结果 + */ + static public Map applyFaceDelta(Context context, String operation, int sampleId, + String userId, String username, byte[] featureData) { + Map result = new HashMap<>(); + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + int code; + if ("delete".equals(operation)) { + code = faceServer.removeFace(sampleId); + } else if ("upsert".equals(operation) && featureData != null && featureData.length > 0) { + FaceEntity entity = new FaceEntity(); + entity.setFaceId(sampleId); + entity.setSampleId(sampleId); + entity.setUserId(userId); + entity.setUserName(username); + entity.setFaceModel(featureData); + code = faceServer.replaceFaceFeature(entity); + } else { + code = ErrorInfo.MERR_INVALID_PARAM; + } + boolean success = code == ErrorInfo.MOK; + result.put("success", success); + result.put("code", code); + result.put("message", success ? "人脸库增量更新成功" : "人脸库增量更新失败"); + return result; + } + // 旧通道保留同步包装;新页面通过 rebuildFaceLibrary 获取完整结果和进度。 static public List registerFaceByDatabase(Context context, String userId) { @@ -341,7 +412,13 @@ public class FlutterTool { for (int start = 0; start < validFaces.size(); start += REGISTER_BATCH_SIZE) { int end = Math.min(start + REGISTER_BATCH_SIZE, validFaces.size()); List batch = new ArrayList<>(validFaces.subList(start, end)); - int code = faceServer.registerFaceFeatureInfoBatchFromDb(batch); + int code; + try { + code = faceServer.registerFaceFeatureInfoBatchFromDb(batch); + } catch (Exception exception) { + code = -99; + Log.e(TAG, "批量注册抛出异常,改为逐条处理error:" + exception.getMessage(), exception); + } if (code == ErrorInfo.MOK) { success += batch.size(); processed += batch.size(); @@ -352,7 +429,13 @@ public class FlutterTool { Log.w(TAG, "批量注册失败code:" + code + ";改为逐条定位失败样本"); for (FaceEntity face : batch) { - int singleCode = faceServer.registerFaceFeatureInfoFromDb(face); + int singleCode; + try { + singleCode = faceServer.registerFaceFeatureInfoFromDb(face); + } catch (Exception exception) { + singleCode = -99; + Log.e(TAG, "单条注册异常sampleId:" + maskSampleId(face.getSampleId()) + ";error:" + exception.getMessage(), exception); + } if (singleCode == ErrorInfo.MOK) { success++; } else if (face.getSampleId() != null) { @@ -377,17 +460,7 @@ public class FlutterTool { private static boolean extractAndPersistFaceModel(Context context, FaceEntity face) { if (face == null || face.getFaceFilePath() == null) return false; try { - byte[] bytes = FileUtil.fileToData(new File(face.getFaceFilePath())); - if (bytes == null || bytes.length == 0) return false; - FaceServer faceServer = FaceServer.getInstance(); - faceServer.init(context); - Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); - if (bitmap == null) return false; - Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); - byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); - int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); - if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) return false; - byte[] faceBytes = faceServer.registerBgr24(context, bgr24Data, alignedBitmap.getWidth(), alignedBitmap.getHeight(), String.valueOf(System.currentTimeMillis())); + byte[] faceBytes = extractFaceModelFromFile(context, face.getFaceFilePath()); if (faceBytes == null || faceBytes.length == 0) return false; face.setFaceModel(faceBytes); return updateFaceModel(context, face); @@ -397,6 +470,31 @@ public class FlutterTool { } } + /** + * 解码图片并在统一 FaceServer 引擎锁内提取注册特征,不写入业务数据库。 + * + * @param context Android 应用上下文 + * @param filePath 待校验图片绝对路径 + * @return 虹软特征字节,图片无效或未检测到合格人脸时返回空 + */ + private static byte[] extractFaceModelFromFile(Context context, String filePath) { + if (filePath == null || filePath.isEmpty()) return null; + byte[] bytes = FileUtil.fileToData(new File(filePath)); + if (bytes == null || bytes.length == 0) return null; + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (bitmap == null) return null; + Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); + if (alignedBitmap == null) return null; + byte[] bgr24Data = ArcSoftImageUtil.createImageData( + alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); + int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); + if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) return null; + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + return faceServer.registerBgr24(context, bgr24Data, alignedBitmap.getWidth(), + alignedBitmap.getHeight(), String.valueOf(System.currentTimeMillis())); + } + private static boolean hasFaceModel(FaceEntity face) { return face != null && face.getFaceModel() != null && face.getFaceModel().length > 0; } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java index dc3605a..3bbdefc 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java @@ -26,9 +26,12 @@ import java.io.FileOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.HashSet; import io.flutter.plugins.facediscern.tool.face.model.CompareResult; import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; @@ -129,25 +132,96 @@ public class FaceServer { return code; } + /** + * 在同一虹软引擎互斥边界内原子替换单个搜索标识的人脸特征。 + * + *

替换前复制旧特征和业务元数据;新特征注册失败或 SDK 抛异常时恢复旧特征, + * 避免坏图导致原本可识别的人脸永久丢失。

+ * + * @param faceEntity 包含搜索标识、用户元数据和新特征的目标人脸 + * @return 虹软返回码,插件异常返回内部错误码 {@code -99} + */ + public synchronized int replaceFaceFeature(FaceEntity faceEntity) { + if (faceEntity == null || faceEngine == null || faceEntity.getFaceId() == null || faceEntity.getFaceModel() == null) { + return -99; + } + int searchId = faceEntity.getFaceId(); + FaceEntity oldEntity = copyFaceEntity(faceRegisterInfoMap.get(searchId)); + try { + FaceFeatureInfo oldFeature = getFaceFeature(searchId); + if (oldEntity == null && oldFeature != null) { + oldEntity = new FaceEntity(searchId, oldFeature.getFeatureData()); + } + faceEngine.removeFaceFeature(searchId); + faceRegisterInfoMap.remove(searchId); + FaceFeatureInfo newFeature = new FaceFeatureInfo(searchId, faceEntity.getFaceModel()); + int code = faceEngine.registerFaceFeature(Collections.singletonList(newFeature)); + if (code == ErrorInfo.MOK && getFaceFeature(searchId) != null) { + faceRegisterInfoMap.put(searchId, faceEntity); + return code; + } + restoreFaceFeature(searchId, oldEntity); + return code == ErrorInfo.MOK ? -99 : code; + } catch (Exception exception) { + Log.e(TAG, "替换人脸特征异常searchId:" + searchId + ";error:" + exception.getMessage(), exception); + restoreFaceFeature(searchId, oldEntity); + return -99; + } + } + + /** + * 返回当前虹软内存库已登记的搜索标识快照,用于集合一致性判断。 + * + * @return 与内部映射解耦的搜索标识集合 + */ + public synchronized Set getRegisteredFaceIds() { + return new HashSet<>(faceRegisterInfoMap.keySet()); + } + + /** + * 恢复单样本替换前的人脸特征;没有旧特征时保持目标搜索标识为空。 + * + * @param searchId 需要恢复的虹软搜索标识 + * @param oldEntity 替换前的特征和元数据,新增场景为空 + */ + private void restoreFaceFeature(int searchId, FaceEntity oldEntity) { + try { + faceEngine.removeFaceFeature(searchId); + faceRegisterInfoMap.remove(searchId); + if (oldEntity == null || oldEntity.getFaceModel() == null) return; + FaceFeatureInfo oldFeature = new FaceFeatureInfo(searchId, oldEntity.getFaceModel()); + if (faceEngine.registerFaceFeature(Collections.singletonList(oldFeature)) == ErrorInfo.MOK) { + faceRegisterInfoMap.put(searchId, oldEntity); + } else { + Log.e(TAG, "恢复旧人脸特征失败searchId:" + searchId); + } + } catch (Exception exception) { + Log.e(TAG, "恢复旧人脸特征异常searchId:" + searchId + ";error:" + exception.getMessage(), exception); + } + } + + /** + * 深拷贝内存库中的人脸业务元数据和特征,避免回滚快照被后续修改。 + * + * @param source 待复制的人脸记录 + * @return 独立副本,源记录为空时返回空 + */ + private FaceEntity copyFaceEntity(FaceEntity source) { + if (source == null) return null; + FaceEntity copy = new FaceEntity(); + copy.setFaceId(source.getFaceId()); + copy.setSampleId(source.getSampleId()); + copy.setUserId(source.getUserId()); + copy.setUserName(source.getUserName()); + copy.setFaceFilePath(source.getFaceFilePath()); + copy.setFaceModel(source.getFaceModel() == null ? null : source.getFaceModel().clone()); + return copy; + } + // 注册单个人脸数据 public synchronized int registerFaceFeatureInfoFromDb(FaceEntity faceEntity) { - if (faceEntity != null && faceEngine != null) { - // ArcSoft 不允许同一个 searchId 重复注册,增量更新前只移除当前用户的旧记录。 - faceEngine.removeFaceFeature(faceEntity.getFaceId()); - faceRegisterInfoMap.remove(faceEntity.getFaceId()); - FaceFeatureInfo faceFeatureInfo = new FaceFeatureInfo(faceEntity.getFaceId(), faceEntity.getFaceModel()); - int code = faceEngine.registerFaceFeature(faceFeatureInfo); - if (code == ErrorInfo.MOK) { - faceEntity.setFaceModel(faceFeatureInfo.getFeatureData()); - // 查询一下确保入库成功 - FaceFeatureInfo afterDetail = getFaceFeature(faceEntity.getFaceId()); - if (Objects.nonNull(afterDetail)) { - faceRegisterInfoMap.put(faceEntity.getFaceId(), faceEntity); - return code; - } - } - } - return -99; + // 历史单条入口也复用带旧特征回滚的替换逻辑,避免 SDK 异常后丢失原可用样本。 + return replaceFaceFeature(faceEntity); } // 从人脸数据库中搜索人脸 @@ -170,6 +244,11 @@ public class FaceServer { // 删除人脸-1删除全部 public synchronized int removeFace(int searchId) { if (faceEngine == null) return -99; + // MQ 和每日快照都可能重复删除同一样本;内存库已不存在时直接按幂等成功处理。 + if (searchId != -1 && getFaceFeature(searchId) == null) { + faceRegisterInfoMap.remove(searchId); + return ErrorInfo.MOK; + } int code = faceEngine.removeFaceFeature(searchId); if (code == ErrorInfo.MOK) { if (searchId == -1) { @@ -199,7 +278,7 @@ public class FaceServer { } // 在特征库中搜索 - public CompareResult searchFaceFeature(FaceFeature faceFeature) { + public synchronized CompareResult searchFaceFeature(FaceFeature faceFeature) { if (faceFeature == null) return null; if (faceEngine == null) return null; SearchResult searchResult = null; @@ -223,7 +302,7 @@ public class FaceServer { } - public byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) { + public synchronized byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) { if (faceEngine == null || context == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) { Log.e(TAG, "registerBgr24: invalid params"); return null; diff --git a/lib/app.dart b/lib/app.dart index d56c215..f70de60 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -32,7 +32,7 @@ class _MainAppState extends State with WidgetsBindingObserver { super.dispose(); } - /// 同步前后台空闲状态,并在挂起时释放人脸会话、恢复时校验副屏窗口。 + /// 同步前后台空闲状态,并在挂起时释放人脸会话、恢复时校验副屏窗口和补排部门人脸对账。 @override void didChangeAppLifecycleState(AppLifecycleState state) async { SyncIdleTool.instance.updateLifecycle(state); @@ -51,6 +51,7 @@ class _MainAppState extends State with WidgetsBindingObserver { try { await AppSubScreenTool.show(verifyPresentation: true); } catch (_) {} + SyncCheckTool.instance.queueMissedDepartmentFaceSync(); } } diff --git a/lib/common/api/user_api.dart b/lib/common/api/user_api.dart index cb11147..81a1da3 100644 --- a/lib/common/api/user_api.dart +++ b/lib/common/api/user_api.dart @@ -75,6 +75,22 @@ abstract class UserApi { .toList(); } + /// 按固定主键上界游标读取本部门及子部门的 V3 人脸权威快照。 + static Future getFaceSyncSnapshot( + Map requestBody, + ) async { + final res = await HttpManage().dio.post( + "/admin/user-face/ad/v3/sync-snapshot", + data: requestBody, + ); + if (res.data is! Map) { + throw const FormatException('服务端未返回有效的人脸快照'); + } + return ServiceFaceSyncSnapshot.fromJson( + Map.from(res.data as Map), + ); + } + /// 获取租户列表 static Future?> getTenantList() async { final res = await HttpManage().dio.get("/admin/tenant/list"); diff --git a/lib/common/models/api/user_api_models.dart b/lib/common/models/api/user_api_models.dart index 5fb3f51..e1f98d6 100644 --- a/lib/common/models/api/user_api_models.dart +++ b/lib/common/models/api/user_api_models.dart @@ -136,6 +136,108 @@ class ServiceAsyncFace { Map toJson() => _$ServiceAsyncFaceToJson(this); } +/// V3 部门人脸快照中的单条服务端权威记录。 +@JsonSerializable() +class ServiceFaceSyncRecord { + /// 人脸记录数据库主键,作为分页顺序和快照范围的稳定标识。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String id; + + /// 虹软 32 位搜索标识,来源于服务端 autoCode。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String sampleId; + + /// 人脸所属用户主键,MQ 增量按该字段回查同一用户的全部人脸。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String userId; + + /// 用户姓名,用于本地识别结果和设置页展示。 + final String? username; + + /// 用户当前部门主键,保证终端只维护绑定部门及其子部门人脸。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String deptId; + + /// 用户当前部门名称,用于本地展示。 + final String? deptName; + + /// 服务端人脸图片地址,地址变化会触发重新下载与特征提取。 + final String? faceImg; + + /// 服务端人脸记录修改时间,和图片地址共同构成图片版本。 + final DateTime? sourceUpdatedAt; + + /// 创建一条 V3 人脸快照记录。 + const ServiceFaceSyncRecord({ + required this.id, + required this.sampleId, + required this.userId, + this.username, + required this.deptId, + this.deptName, + this.faceImg, + this.sourceUpdatedAt, + }); + + /// 从统一网络层返回的 JSON 创建快照记录。 + factory ServiceFaceSyncRecord.fromJson(Map json) => + _$ServiceFaceSyncRecordFromJson(json); + + /// 将快照记录转换为 JSON,主要供模型测试和诊断使用。 + Map toJson() => _$ServiceFaceSyncRecordToJson(this); +} + +/// V3 部门人脸同步的固定上界游标页面。 +@JsonSerializable() +class ServiceFaceSyncSnapshot { + /// 当前页面记录,服务端保证按数据库主键严格升序返回。 + final List records; + + /// 下一页排他游标;末页仅用于终端完成连续性校验。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String nextAfterId; + + /// 是否还有位于固定快照上界内的下一页。 + final bool hasMore; + + /// 首次请求固定的人脸数据库主键上界,后续请求必须原样携带。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String snapshotMaxId; + + /// 创建一个固定上界游标页面。 + const ServiceFaceSyncSnapshot({ + required this.records, + required this.nextAfterId, + required this.hasMore, + required this.snapshotMaxId, + }); + + /// 从统一网络层返回的 JSON 创建快照页面。 + factory ServiceFaceSyncSnapshot.fromJson(Map json) => + _$ServiceFaceSyncSnapshotFromJson(json); + + /// 将快照页面转换为 JSON,主要供模型测试使用。 + Map toJson() => _$ServiceFaceSyncSnapshotToJson(this); +} + @JsonSerializable() class ServiceTenant { /// id diff --git a/lib/common/models/api/user_api_models.g.dart b/lib/common/models/api/user_api_models.g.dart index af81863..7f31f75 100644 --- a/lib/common/models/api/user_api_models.g.dart +++ b/lib/common/models/api/user_api_models.g.dart @@ -77,6 +77,54 @@ Map _$ServiceAsyncFaceToJson(ServiceAsyncFace instance) => 'delFlag': JsonTool.booleanToString(instance.delFlag), }; +ServiceFaceSyncRecord _$ServiceFaceSyncRecordFromJson( + Map json) => + ServiceFaceSyncRecord( + id: JsonTool.anyToRequiredString(json['id']), + sampleId: JsonTool.anyToRequiredString(json['sampleId']), + userId: JsonTool.anyToRequiredString(json['userId']), + username: json['username'] as String?, + deptId: JsonTool.anyToRequiredString(json['deptId']), + deptName: json['deptName'] as String?, + faceImg: json['faceImg'] as String?, + sourceUpdatedAt: json['sourceUpdatedAt'] == null + ? null + : DateTime.parse(json['sourceUpdatedAt'] as String), + ); + +Map _$ServiceFaceSyncRecordToJson( + ServiceFaceSyncRecord instance) => + { + 'id': JsonTool.stringToInt(instance.id), + 'sampleId': JsonTool.stringToInt(instance.sampleId), + 'userId': JsonTool.stringToInt(instance.userId), + 'username': instance.username, + 'deptId': JsonTool.stringToInt(instance.deptId), + 'deptName': instance.deptName, + 'faceImg': instance.faceImg, + 'sourceUpdatedAt': instance.sourceUpdatedAt?.toIso8601String(), + }; + +ServiceFaceSyncSnapshot _$ServiceFaceSyncSnapshotFromJson( + Map json) => + ServiceFaceSyncSnapshot( + records: (json['records'] as List) + .map((e) => ServiceFaceSyncRecord.fromJson(e as Map)) + .toList(), + nextAfterId: JsonTool.anyToRequiredString(json['nextAfterId']), + hasMore: json['hasMore'] as bool, + snapshotMaxId: JsonTool.anyToRequiredString(json['snapshotMaxId']), + ); + +Map _$ServiceFaceSyncSnapshotToJson( + ServiceFaceSyncSnapshot instance) => + { + 'records': instance.records, + 'nextAfterId': JsonTool.stringToInt(instance.nextAfterId), + 'hasMore': instance.hasMore, + 'snapshotMaxId': JsonTool.stringToInt(instance.snapshotMaxId), + }; + ServiceTenant _$ServiceTenantFromJson(Map json) => ServiceTenant( id: json['id'] as String?, diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart index fba2bf7..3231b0f 100644 --- a/lib/common/models/index.dart +++ b/lib/common/models/index.dart @@ -26,6 +26,7 @@ export './manage/usb_facility_manage.dart'; export './manage/terminal_device_status_models.dart'; export './manage/hardware_input_history_models.dart'; export './manage/scheduled_task_models.dart'; +export './manage/face_sync_models.dart'; export './manage/root_access_models.dart'; export './encryption/encryption_tool.dart'; diff --git a/lib/common/models/manage/scheduled_task_models.dart b/lib/common/models/manage/scheduled_task_models.dart index 496cc9e..373b51f 100644 --- a/lib/common/models/manage/scheduled_task_models.dart +++ b/lib/common/models/manage/scheduled_task_models.dart @@ -22,6 +22,9 @@ enum ScheduledTaskType { /// 服务端设备配置同步。 configSync, + /// 本部门及子部门人脸权威快照同步;MQ 用户增量也复用同一任务队列。 + departmentFaceSync, + /// 商品分类数据同步。 classifySync, diff --git a/lib/common/models/manage/scheduled_task_models.g.dart b/lib/common/models/manage/scheduled_task_models.g.dart index 122ef09..1183829 100644 --- a/lib/common/models/manage/scheduled_task_models.g.dart +++ b/lib/common/models/manage/scheduled_task_models.g.dart @@ -30,6 +30,7 @@ const _$ScheduledTaskTypeEnumMap = { ScheduledTaskType.deviceStatus: 'deviceStatus', ScheduledTaskType.autoUpdate: 'autoUpdate', ScheduledTaskType.configSync: 'configSync', + ScheduledTaskType.departmentFaceSync: 'departmentFaceSync', ScheduledTaskType.classifySync: 'classifySync', ScheduledTaskType.productSync: 'productSync', ScheduledTaskType.offlineOrderSync: 'offlineOrderSync', diff --git a/lib/common/models/mq/mq_models.dart b/lib/common/models/mq/mq_models.dart index ed79f9c..9944ff5 100644 --- a/lib/common/models/mq/mq_models.dart +++ b/lib/common/models/mq/mq_models.dart @@ -62,6 +62,34 @@ class MqttInvoiceMsg { Map toJson() => _$MqttInvoiceMsgToJson(this); } +/// FACE 动作专用消息,只携带发生变化的用户标识供终端回查最新状态。 +@JsonSerializable() +class MqttFaceChangeMsg { + /// MQTT 动作,协议值固定为 FACE。 + final MqtActionEnum? action; + + /// 发生人脸 CRUD 或部门调动的用户主键。 + @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) + final String? userId; + + /// 兼容既有协议的全局人脸状态;生产部门同步不据此接收外部门人脸。 + final bool? isGlobalFace; + + /// 创建一条 FACE 变更通知。 + const MqttFaceChangeMsg({ + this.action, + this.userId, + this.isGlobalFace, + }); + + /// 从 MQTT JSON 创建专用消息。 + factory MqttFaceChangeMsg.fromJson(Map json) => + _$MqttFaceChangeMsgFromJson(json); + + /// 将专用消息转换为 JSON,主要供协议测试使用。 + Map toJson() => _$MqttFaceChangeMsgToJson(this); +} + @JsonSerializable() class MqttTakeFoodBroadcastMsg { /// 发送硬件的topic diff --git a/lib/common/models/mq/mq_models.g.dart b/lib/common/models/mq/mq_models.g.dart index 7c14fc9..60b4b70 100644 --- a/lib/common/models/mq/mq_models.g.dart +++ b/lib/common/models/mq/mq_models.g.dart @@ -49,6 +49,20 @@ Map _$MqttInvoiceMsgToJson(MqttInvoiceMsg instance) => 'isGlobalFace': instance.isGlobalFace, }; +MqttFaceChangeMsg _$MqttFaceChangeMsgFromJson(Map json) => + MqttFaceChangeMsg( + action: $enumDecodeNullable(_$MqtActionEnumEnumMap, json['action']), + userId: JsonTool.anyToString(json['userId']), + isGlobalFace: json['isGlobalFace'] as bool?, + ); + +Map _$MqttFaceChangeMsgToJson(MqttFaceChangeMsg instance) => + { + 'action': _$MqtActionEnumEnumMap[instance.action], + 'userId': JsonTool.stringToInt(instance.userId), + 'isGlobalFace': instance.isGlobalFace, + }; + MqttTakeFoodBroadcastMsg _$MqttTakeFoodBroadcastMsgFromJson( Map json) => MqttTakeFoodBroadcastMsg( diff --git a/lib/common/models/plugs/face_discern_models.dart b/lib/common/models/plugs/face_discern_models.dart index 8349029..1af9835 100644 --- a/lib/common/models/plugs/face_discern_models.dart +++ b/lib/common/models/plugs/face_discern_models.dart @@ -287,6 +287,64 @@ class FaceRes { Map toJson() => _$FaceResToJson(this); } +/// 原生图片特征提取结果;提取阶段不修改 SQLite 或虹软内存库。 +class FaceFeatureExtractionResult { + /// 创建一次原生特征提取结果。 + const FaceFeatureExtractionResult({ + required this.success, + required this.message, + required this.featureData, + }); + + /// 从 MethodChannel 返回映射创建特征提取结果。 + factory FaceFeatureExtractionResult.fromMap(Map map) { + final rawFeature = map['featureData']; + return FaceFeatureExtractionResult( + success: map['success'] == true, + message: map['message']?.toString() ?? '人脸特征提取失败', + featureData: rawFeature is List + ? rawFeature.map((item) => (item as num).toInt()).toList() + : const [], + ); + } + + /// 是否成功提取且返回非空特征。 + final bool success; + + /// 不包含图片路径和特征内容的安全结果摘要。 + final String message; + + /// 虹软注册特征字节;仅在进程内传递给增量应用通道。 + final List featureData; +} + +/// 原生单样本人脸增删结果。 +class FaceDeltaApplyResult { + /// 创建一次原生增量应用结果。 + const FaceDeltaApplyResult({ + required this.success, + required this.message, + required this.code, + }); + + /// 从 MethodChannel 返回映射创建增量结果。 + factory FaceDeltaApplyResult.fromMap(Map map) => + FaceDeltaApplyResult( + success: map['success'] == true, + message: map['message']?.toString() ?? '人脸库增量更新失败', + code: (map['code'] as num?)?.toInt() ?? -99, + ); + + /// 虹软内存库是否已完成目标状态。 + final bool success; + + /// 不包含用户姓名、图片地址或特征的安全结果摘要。 + final String message; + + /// 虹软 SDK 返回码;插件异常使用内部负值。 + final int code; +} + /// 原生人脸插件、授权文件和内存人脸库的实时状态。 class FacePluginStatus { const FacePluginStatus({ diff --git a/lib/common/services/database/dao/face_dao.dart b/lib/common/services/database/dao/face_dao.dart index c0ddae6..6e5644c 100644 --- a/lib/common/services/database/dao/face_dao.dart +++ b/lib/common/services/database/dao/face_dao.dart @@ -5,6 +5,7 @@ import 'package:pj_supermarket_cloud/common/index.dart'; // face @dao abstract class FaceDao { + /// 保存一条已完成文件和原生特征校验的人脸记录。 @insert Future save(FaceEntity entity); @@ -75,6 +76,12 @@ abstract class FaceDao { int offset, ); + /// 查询本机全部人脸,用于完整部门快照计算集合差异。 + @Query(''' + SELECT * FROM face ORDER BY sampleId ASC + ''') + Future> getAll(); + @Query(''' SELECT DISTINCT a.deptId FROM face a WHERE a.deptId IS NOT NULL ''') diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index 6b25caa..a77e21b 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -1969,6 +1969,25 @@ class _$FaceDao extends FaceDao { arguments: [userName, pageSize, offset]); } + @override + Future> getAll() async { + return _queryAdapter.queryList('SELECT * FROM face ORDER BY sampleId ASC', + mapper: (Map row) => FaceEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + sampleId: row['sampleId'] as String, + userName: row['userName'] as String, + deptId: row['deptId'] as String?, + deptName: row['deptName'] as String?, + imagePath: row['imagePath'] as String, + path: row['path'] as String?, + faceModel: _listIntConverter.decode(row['faceModel'] as String), + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String))); + } + @override Future> findAllUserIds() async { return _queryAdapter.queryList( diff --git a/lib/common/utils/app/app_face_tool.dart b/lib/common/utils/app/app_face_tool.dart index 3d0afa0..8227fad 100644 --- a/lib/common/utils/app/app_face_tool.dart +++ b/lib/common/utils/app/app_face_tool.dart @@ -176,9 +176,20 @@ abstract class AppFaceTool { return currentUser; } - // 识别会话已经由调用方结束,此处才允许同步和重建 SDK 人脸库,避免并发操作引擎。 - logger.w('|$_tag|识别样本对应人员已失效,开始同步人脸库并刷新样本归属|'); - await SyncFaceTool.syncFace(isAll: true); + // 识别会话已经由调用方结束,此处才允许进入统一人脸队列,避免并发操作引擎。 + // 已知人员只回查该用户;没有可靠用户标识时才对账本部门,禁止恢复流程拉取全租户人脸。 + 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 || diff --git a/lib/common/utils/app/app_mq_tool.dart b/lib/common/utils/app/app_mq_tool.dart index e98781f..547e1e3 100644 --- a/lib/common/utils/app/app_mq_tool.dart +++ b/lib/common/utils/app/app_mq_tool.dart @@ -1,5 +1,5 @@ -import 'dart:convert'; import 'dart:async'; +import 'dart:convert'; import 'package:common_utils/common_utils.dart'; import 'package:get/get.dart'; @@ -144,28 +144,29 @@ class AppMqTool { )); return; case MqtActionEnum.FACE: + try { + final decoded = json.decode(orgStr); + if (decoded is! Map) { + throw const FormatException('FACE 消息不是 JSON 对象'); + } + final faceMessage = MqttFaceChangeMsg.fromJson( + Map.from(decoded), + ); + final userId = faceMessage.userId?.trim(); + final parsedUserId = int.tryParse(userId ?? ''); + if (parsedUserId == null || parsedUserId <= 0) { + throw const FormatException('FACE 消息缺少有效用户标识'); + } + // MQTT 只作为变更通知;队列按 userId 合并,并由 V3 接口回查服务端最新状态。 + await SyncCheckTool.instance.trigger( + ScheduledTaskType.departmentFaceSync, + faceUserIds: {userId!}, + triggerType: ScheduledTaskTriggerType.mqtt, + ); + } catch (error, stack) { + logger.w('|$_tag|FACE 消息处理失败|error:$error|stack:$stack|'); + } return; - // final msg = MqttInvoiceMsg.fromJson(json.decode(orgStr)); - // final storagePath = await AppFileTool.getFacePath(); - // if (ObjectUtil.isEmpty(storagePath)) { - // logger.e("|$_tag|mq同步人脸错误,创建人脸存储路径错误"); - // return; - // } - // final sysSetting = await AppSettingTool.getSysSetting(); - // var merchantDeptId = sysSetting.merchantDeptId; - // if (ObjectUtil.isEmpty(storagePath)) { - // logger.e("|$_tag|mq同步人脸错误,merchantId为空"); - // return; - // } - // await SyncFaceTool.syncGroupFace( - // pageNo: 1, - // userId: msg.userId, - // merchantDeptId: merchantDeptId!, - // storagePath: storagePath!, - // isGlobalFace: msg.isGlobalFace, - // ); - // mqUpdateEventBus.fire(MqtActionEnum.FACE); - // break; } } diff --git a/lib/common/utils/json/json_tool.dart b/lib/common/utils/json/json_tool.dart index ddddf15..9f7ac38 100644 --- a/lib/common/utils/json/json_tool.dart +++ b/lib/common/utils/json/json_tool.dart @@ -41,6 +41,15 @@ abstract class JsonTool { return value.toString(); } + /// 将后端必填 Long 或字符串标识转换为非空字符串;缺失或空值时在模型边界直接拒绝。 + static String anyToRequiredString(Object? value) { + final result = anyToString(value)?.trim(); + if (result == null || result.isEmpty) { + throw const FormatException('必填标识不能为空'); + } + return result; + } + /// 后端为避免 19 位 Long 在部分客户端丢失精度,可能把 ID 序列化为字符串。 /// 这里同时兼容字符串和数字,忽略无法安全转换的异常项。 static List anyListToIntList(Object? value) { diff --git a/lib/common/utils/plugs/face_discern_plugs.dart b/lib/common/utils/plugs/face_discern_plugs.dart index c10008b..12e1997 100644 --- a/lib/common/utils/plugs/face_discern_plugs.dart +++ b/lib/common/utils/plugs/face_discern_plugs.dart @@ -240,6 +240,46 @@ abstract class FaceDiscernPlugs { ); } + /// 从临时图片提取注册特征,不修改虹软内存库和本地数据库。 + static Future extractFaceFeature({ + required String filePath, + }) async { + final map = await _channel.invokeMethod('extractFaceFeature', { + 'filePath': filePath, + }); + if (map is Map) return FaceFeatureExtractionResult.fromMap(map); + return const FaceFeatureExtractionResult( + success: false, + message: '原生未返回有效的人脸特征结果', + featureData: [], + ); + } + + /// 在虹软内存库中原子应用单样本新增、替换或删除。 + /// + /// 替换失败时原生层恢复旧特征;[operation] 仅允许 upsert 或 delete。 + static Future applyFaceDelta({ + required String operation, + required String sampleId, + String? userId, + String? username, + List? featureData, + }) async { + final map = await _channel.invokeMethod('applyFaceDelta', { + 'operation': operation, + 'sampleId': int.tryParse(sampleId), + 'userId': userId, + 'username': username, + 'featureData': featureData, + }); + if (map is Map) return FaceDeltaApplyResult.fromMap(map); + return const FaceDeltaApplyResult( + success: false, + message: '原生未返回有效的人脸增量结果', + code: -99, + ); + } + static Future registerAllStorageFace({ required String filePath, String? fileName, diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart index 3417910..805f836 100644 --- a/lib/common/utils/sync/sync_check_tool.dart +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -52,6 +52,9 @@ class SyncCheckTool { /// 是否已经完成本轮启动,防止 AppControl 重复注册相同 Cron 任务。 bool _started = false; + /// 当前进程最近一次补排部门人脸同步的本地日期,保证同一天最多自动补排一次。 + DateTime? _lastDepartmentFaceCatchUpDate; + /// 常用规则均使用 cron 0.6.2 可解析的标准字段、范围和步长,不包含 Quartz 专属的 L/W/#。 static const List cronPresets = [ ScheduledCronPreset( @@ -134,6 +137,10 @@ class SyncCheckTool { group: ScheduledCronPresetGroup.daily, label: '每天 00:30', expression: '0 30 0 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 00:40', + expression: '0 40 0 * * *'), ScheduledCronPreset( group: ScheduledCronPresetGroup.daily, label: '每天 01:00', @@ -282,6 +289,12 @@ class SyncCheckTool { cron: '0 5 0 * * *', idleOnly: true, ), + ScheduledTaskConfig( + type: ScheduledTaskType.departmentFaceSync, + enabled: true, + cron: '0 40 0 * * *', + idleOnly: true, + ), ScheduledTaskConfig( type: ScheduledTaskType.classifySync, enabled: false, @@ -324,8 +337,40 @@ class SyncCheckTool { for (final config in configs.values) { await _rebuildTask(config); } + queueMissedDepartmentFaceSync(); } + /// 在每天 00:40 之后检查当天是否成功完成部门人脸同步,并在首次启动或恢复时补排一次。 + /// + /// 补排只加入同一人脸任务队列并要求完整空闲,不会阻塞启动或打断支付、识别和业务弹框。 + void queueMissedDepartmentFaceSync() { + if (!_started) return; + final config = configs[ScheduledTaskType.departmentFaceSync]; + if (config == null || !config.enabled) return; + final now = DateTime.now(); + final scheduledAt = DateTime(now.year, now.month, now.day, 0, 40); + if (now.isBefore(scheduledAt)) return; + final lastSuccess = + records[ScheduledTaskType.departmentFaceSync]?.lastSuccessAt; + if (_isSameLocalDate(lastSuccess, now) || + _isSameLocalDate(_lastDepartmentFaceCatchUpDate, now)) { + return; + } + _lastDepartmentFaceCatchUpDate = now; + unawaited(trigger( + ScheduledTaskType.departmentFaceSync, + catchUp: true, + triggerType: ScheduledTaskTriggerType.system, + )); + } + + /// 判断两个可空时间是否属于终端本地时区的同一自然日。 + static bool _isSameLocalDate(DateTime? left, DateTime right) => + left != null && + left.year == right.year && + left.month == right.month && + left.day == right.day; + /// 完成旧 sysJson 一次性迁移,再从 SQLite 加载任务配置、最近结果和上传统计。 Future _loadState() async { final legacyConfigs = (await AppSettingTool.getScheduledTaskConfigs()) @@ -488,6 +533,9 @@ class SyncCheckTool { ScheduledTaskType type, { bool currentLogOnly = false, bool scheduled = false, + bool catchUp = false, + Set? faceUserIds, + bool debugAllFaces = false, ScheduledTaskTriggerType triggerType = ScheduledTaskTriggerType.manual, }) async { final config = @@ -495,8 +543,12 @@ class SyncCheckTool { final request = _PendingTaskRequest( currentLogOnly: currentLogOnly, requireSafeWindow: !scheduled || config.idleOnly, - requireFullIdle: scheduled && config.idleOnly, + requireFullIdle: (scheduled || catchUp) && config.idleOnly, triggerType: scheduled ? ScheduledTaskTriggerType.cron : triggerType, + faceUserIds: faceUserIds == null ? null : Set.unmodifiable(faceUserIds), + fullFaceSnapshot: + type == ScheduledTaskType.departmentFaceSync && faceUserIds == null, + debugAllFaces: debugAllFaces, ); if (runningTasks.contains(type)) { _enqueue(type, request); @@ -577,7 +629,7 @@ class SyncCheckTool { try { final result = await _runAction( type, - currentLogOnly: request.currentLogOnly, + request: request, ); records[type] = ScheduledTaskRecord( lastRunAt: startedAt, @@ -609,9 +661,11 @@ class SyncCheckTool { if (error is TerminalLogUploadException) { terminalLogSummary.value = error.summary; } - final message = error is TerminalLogUploadException - ? error.summary.label - : error.toString(); + final message = switch (error) { + TerminalLogUploadException() => error.summary.label, + FaceSyncPartialFailureException() => error.summary.message, + _ => error.toString(), + }; records[type] = ScheduledTaskRecord( lastRunAt: startedAt, lastSuccessAt: records[type]?.lastSuccessAt, @@ -632,18 +686,26 @@ class SyncCheckTool { nextErrorType: error.runtimeType.toString(), nextErrorMessage: _safeError(error), nextErrorStack: _safeStack(stack), - scanned: error is TerminalLogUploadException - ? error.summary.scanned - : null, - skipped: error is TerminalLogUploadException - ? error.summary.skipped - : null, - succeeded: error is TerminalLogUploadException - ? error.summary.succeeded - : null, - failed: error is TerminalLogUploadException - ? error.summary.failed - : null, + scanned: switch (error) { + TerminalLogUploadException() => error.summary.scanned, + FaceSyncPartialFailureException() => error.summary.scanned, + _ => null, + }, + skipped: switch (error) { + TerminalLogUploadException() => error.summary.skipped, + FaceSyncPartialFailureException() => error.summary.skipped, + _ => null, + }, + succeeded: switch (error) { + TerminalLogUploadException() => error.summary.succeeded, + FaceSyncPartialFailureException() => error.summary.changed, + _ => null, + }, + failed: switch (error) { + TerminalLogUploadException() => error.summary.failed, + FaceSyncPartialFailureException() => error.summary.failed, + _ => null, + }, ), ); } @@ -698,7 +760,7 @@ class SyncCheckTool { /// 将稳定任务类型分派到既有 MQTT、HTTP、插件或文件业务入口并返回统一结果。 Future<_ScheduledTaskActionResult> _runAction( ScheduledTaskType type, { - required bool currentLogOnly, + required _PendingTaskRequest request, }) async { switch (type) { case ScheduledTaskType.mqReconnect: @@ -742,6 +804,33 @@ class SyncCheckTool { case ScheduledTaskType.configSync: await SyncConfigTool.autoSyncSysInfo(); return const _ScheduledTaskActionResult.success('设备配置同步完成'); + case ScheduledTaskType.departmentFaceSync: + final mode = request.debugAllFaces + ? FaceSyncMode.debugAllSnapshot + : request.faceUserIds == null + ? FaceSyncMode.departmentSnapshot + : FaceSyncMode.userDelta; + final summary = await SyncFaceTool.sync( + mode: mode, + userIds: request.faceUserIds, + ); + if (summary.changed > 0 && + request.triggerType == ScheduledTaskTriggerType.mqtt && + Get.isRegistered()) { + Get.find().mqUpdateEventBus.fire(MqtActionEnum.FACE); + } + if (summary.hasFailures) { + throw FaceSyncPartialFailureException(summary); + } + return _ScheduledTaskActionResult.success( + summary.message, + summary: TerminalLogUploadSummary( + scanned: summary.scanned, + skipped: summary.skipped, + succeeded: summary.changed, + failed: summary.failed, + ), + ); case ScheduledTaskType.classifySync: await SyncProductTool.syncClassify(); return const _ScheduledTaskActionResult.success('分类同步完成'); @@ -753,7 +842,7 @@ class SyncCheckTool { return const _ScheduledTaskActionResult.success('离线订单同步完成'); case ScheduledTaskType.terminalLogUpload: final summary = await TerminalLogUploadTool.upload( - currentOnly: currentLogOnly, + currentOnly: request.currentLogOnly, ); terminalLogSummary.value = summary; return _ScheduledTaskActionResult.success( @@ -775,6 +864,7 @@ class SyncCheckTool { bool _isHeavy(ScheduledTaskType type) { return const { ScheduledTaskType.configSync, + ScheduledTaskType.departmentFaceSync, ScheduledTaskType.classifySync, ScheduledTaskType.productSync, ScheduledTaskType.offlineOrderSync, @@ -855,6 +945,11 @@ class SyncCheckTool { sourceType: ScheduledTaskSourceType.http, sourceName: 'HTTP 配置接口', ), + ScheduledTaskType.departmentFaceSync => const _ScheduledTaskDescriptor( + title: '本部门人脸同步', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 人脸快照与虹软插件', + ), ScheduledTaskType.classifySync => const _ScheduledTaskDescriptor( title: '分类同步', sourceType: ScheduledTaskSourceType.http, @@ -932,6 +1027,9 @@ class _PendingTaskRequest { required this.requireSafeWindow, required this.requireFullIdle, required this.triggerType, + required this.faceUserIds, + required this.fullFaceSnapshot, + required this.debugAllFaces, }); /// 是否只上传 Logger 当前写入的日志文件;为假时扫描全部日志。 @@ -946,15 +1044,37 @@ class _PendingTaskRequest { /// 最终写入任务执行日志的触发来源。 final ScheduledTaskTriggerType triggerType; + /// FACE 用户增量回查集合;部门完整快照时为空。 + final Set? faceUserIds; + + /// 当前请求是否为部门或调试完整快照,用于合并时让全量覆盖用户增量。 + final bool fullFaceSnapshot; + + /// 是否执行仅非生产可触发的全租户调试快照。 + final bool debugAllFaces; + /// 全量日志请求优先于“仅当前文件”;任一手动请求到达后只需等待安全窗口。 - _PendingTaskRequest merge(_PendingTaskRequest other) => _PendingTaskRequest( - currentLogOnly: currentLogOnly && other.currentLogOnly, - requireSafeWindow: requireSafeWindow || other.requireSafeWindow, - requireFullIdle: requireFullIdle && other.requireFullIdle, - triggerType: triggerType == ScheduledTaskTriggerType.cron - ? other.triggerType - : triggerType, - ); + _PendingTaskRequest merge(_PendingTaskRequest other) { + final mergedFullFaceSnapshot = fullFaceSnapshot || other.fullFaceSnapshot; + final mergedUserIds = mergedFullFaceSnapshot + ? null + : {...?faceUserIds, ...?other.faceUserIds}; + return _PendingTaskRequest( + currentLogOnly: currentLogOnly && other.currentLogOnly, + requireSafeWindow: requireSafeWindow || other.requireSafeWindow, + // Cron/补排全量一旦进入队列,不因随后到达的 MQ 增量而降低 60 秒空闲要求。 + requireFullIdle: mergedFullFaceSnapshot + ? requireFullIdle || other.requireFullIdle + : requireFullIdle && other.requireFullIdle, + triggerType: triggerType == ScheduledTaskTriggerType.cron + ? other.triggerType + : triggerType, + faceUserIds: + mergedUserIds == null ? null : Set.unmodifiable(mergedUserIds), + fullFaceSnapshot: mergedFullFaceSnapshot, + debugAllFaces: debugAllFaces || other.debugAllFaces, + ); + } } /// MQTT 等外部依赖暂不可用时使用的可诊断任务异常。 diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart index 0107283..5bd22aa 100644 --- a/lib/common/utils/sync/sync_face_tool.dart +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -1,266 +1,602 @@ import 'dart:io'; -import 'package:common_utils/common_utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -class SyncFaceTool { - static const _tag = "sync人脸工具"; +/// 应用级人脸差异同步服务,供页面、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 delEmptyFace() async { - final app = Get.find(); - final faceDao = app.dataBase.getFaceDao; - final faceList = await faceDao.getEmptyFaceList(); - if (ObjectUtil.isEmptyList(faceList)) return; - for (final v in faceList!) { - if (ObjectUtil.isNotEmpty(v.path)) { - await delFaceByStorage(filePath: v.path!); - } - await faceDao.delById(v.id!); + final faceDao = Get.find().dataBase.getFaceDao; + final faces = await faceDao.getEmptyFaceList() ?? const []; + for (final face in faces) { + await _deleteLocalFace(face, faceDao); } } - // 同步人脸 - static Future syncFace({ + /// 兼容既有页面与识别恢复调用的统一入口。 + /// + /// [isAll] 只允许非生产调试使用;生产调用即使误传也会收敛为本部门快照。 + static Future syncFace({ bool isAll = false, VoidCallback? onSyncGroup, }) async { - final appSetting = await AppSettingTool.getSysSetting(); - final merchantDeptId = appSetting.merchantDeptId; - // 人脸存储路径 + 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 (ObjectUtil.isEmpty(storagePath)) { - logger.e("|$_tag|同步人脸错误,创建人脸存储路径错误"); + 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, + ); + } + 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 featureData = AppRuntimeTool.useManualHardwareInput + ? [] + : (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 _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 _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; } - // 同步没有的人脸 - int pageNo = 1; - bool isNext = true; - while (isNext) { - final bool = await syncGroupFace( - pageNo: pageNo, - merchantDeptId: isAll ? "" : merchantDeptId!, - storagePath: storagePath!, + 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, + }) => + 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, ); - if (isNext != bool) isNext = bool; - if (!bool) break; - pageNo += 1; - onSyncGroup?.call(); - } - } - static Future syncGroupFace({ - required int pageNo, - required String merchantDeptId, - required String storagePath, - String? userId, - bool? isGlobalFace, - }) async { - final app = Get.find(); - final faceDao = app.dataBase.getFaceDao; - const size = 100; - var pa = { - "current": pageNo, - "size": size, - "userId": userId, - "merchantDeptId": merchantDeptId, - }; - if (isGlobalFace != null && isGlobalFace) pa["merchantDeptId"] = null; - logger.i("|$_tag|同步人脸api参数:|$pa|"); - try { - final data = await UserApi.getFaceListV2(pa); - if (data == null) throw Error(); - if (ObjectUtil.isEmptyList(data)) { - if (ObjectUtil.isEmpty(userId)) { - // 全量同步第 1 页为空时,服务端没有任何人脸,必须同步清空本地库和原生人脸库。 - if (pageNo == 1 && ObjectUtil.isEmpty(merchantDeptId)) { - await faceDao.delAll(); - await AppFaceTool.removeFace(sampleId: '-1'); - } - return false; - } - // 删除人脸图片 - final faceList = await faceDao.findByUserId(userId!); - if (!ObjectUtil.isEmptyList(faceList)) { - for (final v in faceList!) { - if (ObjectUtil.isNotEmpty(v.path)) { - await delFaceByStorage(filePath: v.path!); - await AppFaceTool.removeFace(sampleId: v.sampleId); - } - } - } - // 删除数据库 - await faceDao.delByUserId(userId); - return false; - } - if (isGlobalFace != null && !isGlobalFace) return false; - if (userId != null) { - final dbUserList = await faceDao.findByUserId(userId); - if (!ObjectUtil.isEmptyList(dbUserList)) { - for (final v in dbUserList!) { - final target = data.firstWhereOrNull((i) => i.id == v.sampleId); - if (target == null) { - if (ObjectUtil.isNotEmpty(v.path)) { - await delFaceByStorage(filePath: v.path!); - await AppFaceTool.removeFace(sampleId: v.sampleId); - } - await faceDao.delById(v.id!); - } - } - } - } - for (final v in data) { - if (ObjectUtil.isEmpty(v.autoCode) || ObjectUtil.isEmpty(v.userId)) { - logger.e( - "|$_tag|同步人脸跳过无效数据:|autoCode=${v.autoCode}|userId=${v.userId}|deptId=${v.deptId}|username=${v.username}|", - ); - continue; - } - - final sampleId = v.autoCode!; - final userId = v.userId!; - // 删除人脸 - if (v.delFlag == true) { - // 先查询数据库是否存在 - final detail = await faceDao.findBySampleId(sampleId); - if (detail == null) { - continue; - } - if (ObjectUtil.isNotEmpty(detail.path)) { - await delFaceByStorage(filePath: detail.path!); - } - await AppFaceTool.removeFace(sampleId: sampleId); - await faceDao.delBySampleId(sampleId); - continue; - } - final faceEntity = await faceDao.findBySampleId(sampleId); - if (faceEntity == null) { - final add = FaceEntity( - userId: userId, - sampleId: sampleId, - userName: v.username ?? "", - deptId: v.deptId, - deptName: v.deptName ?? "", - imagePath: v.faceImg ?? "", - createTime: DateTime.now(), - ); - final addId = await faceDao.save(add); - if (addId != null) { - String? fileName = generateFaceFileName( - userId: add.userId, - sampleId: add.sampleId, - username: add.userName, - facePath: add.imagePath, - ); - final filePath = await writeFaceByStorage( - storagePath: storagePath, - fileName: fileName, - facePath: add.imagePath, - ); - if (ObjectUtil.isNotEmpty(filePath)) { - add.id = addId; - add.updateTime = DateTime.now(); - add.path = filePath; - await faceDao.edit(add); - } - } - await AppFaceTool.removeFace(sampleId: add.sampleId); - await AppFaceTool.faceRegister( - userId: add.userId.toString(), - forceRegister: true, - ); - continue; - } - final edit = FaceEntity( - id: faceEntity.id!, - userId: userId, - sampleId: sampleId, - userName: v.username ?? "", - deptId: v.deptId, - deptName: v.deptName ?? "", - imagePath: v.faceImg ?? "", - createTime: DateTime.now(), - ); - if (edit == faceEntity) { - continue; - } - String? fileName = generateFaceFileName( - userId: edit.userId, - sampleId: edit.sampleId, - username: edit.userName, - facePath: edit.imagePath, - ); - final filePath = await writeFaceByStorage( - storagePath: storagePath, - facePath: edit.imagePath, - fileName: fileName, - ); - if (ObjectUtil.isNotEmpty(filePath)) { - if (ObjectUtil.isNotEmpty(edit.path)) { - await delFaceByStorage(filePath: edit.path!); - } - edit.path = filePath; - } - await faceDao.edit(edit); - // 重新生成人脸模型 - await AppFaceTool.removeFace(sampleId: edit.sampleId); - await AppFaceTool.faceRegister( - userId: edit.userId.toString(), - forceRegister: true, - ); - } - if (data.length >= size) return true; - } catch (e, stack) { - logger.e("|$_tag|同步人脸意外的错误error:|$e|stack:|$stack|"); - } - return false; - } - - /// 删除人脸图片 - static Future delFaceByStorage({ - required String filePath, - }) async { + /// 删除指定本地文件;失败只记录文件路径以外的异常类型,避免同步主流程被清理动作中断。 + static Future delFaceByStorage({required String filePath}) async { try { final file = File(filePath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - logger.e("|$_tag|删除人脸图片意外的错误error:|$e|"); + 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 (ObjectUtil.isEmpty(facePath)) return null; - return "${userId}_${sampleId}_$username${AppFileTool.getFileExtension(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 (ObjectUtil.isEmpty(fileName)) return null; - final storageDirectory = Directory(storagePath); - final file = File('${storageDirectory.path}/$fileName'); - if (await file.exists()) { - await file.delete(); - } - final bytesImg = await AppNetworkTool.downloadImage(facePath); - if (bytesImg == null) return null; - final writeFile = await file.writeAsBytes(bytesImg); - if (await writeFile.exists()) { - return file.path; - } - return null; + 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), + ); +} diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index d6b8f11..775d2ce 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -13,8 +13,8 @@ class Constants { static const pwdEncKey = 'pigxpigxpigxpigx'; /// 服务 url 线上 - // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; - static const apiUrl = 'https://www.zianxin.com/api'; + static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + // static const apiUrl = 'https://www.zianxin.com/api'; // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; diff --git a/lib/pages/terminal/setting/widgets/setting_baseInfo.dart b/lib/pages/terminal/setting/widgets/setting_baseInfo.dart index 752a4bb..6de98ac 100644 --- a/lib/pages/terminal/setting/widgets/setting_baseInfo.dart +++ b/lib/pages/terminal/setting/widgets/setting_baseInfo.dart @@ -27,7 +27,11 @@ Future _refreshMqAfterBinding() async { Future _bindServiceDialog(BuildContext context, SysSetting sys) async { final theme = FTheme.of(context); - final uniqueCodeController = TextEditingController(text: sys.serviceId ?? ''); + var text = sys.serviceId ?? ""; + if (AppSysTool.isDev()) { + text = "2081591297864437761"; + } + final uniqueCodeController = TextEditingController(text: text); await showAppDialog( context: context, builder: (dialogContext) => AppDialog( diff --git a/lib/pages/terminal/setting/widgets/setting_face.dart b/lib/pages/terminal/setting/widgets/setting_face.dart index 1729c2e..acf86e1 100644 --- a/lib/pages/terminal/setting/widgets/setting_face.dart +++ b/lib/pages/terminal/setting/widgets/setting_face.dart @@ -11,44 +11,65 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import 'setting_components.dart'; +/// 设置页人脸管理控制器,只负责列表交互和将人工同步请求提交到应用级队列。 class FaceManageController extends GetxController { + /// 应用级数据库和事件总线持有者,由根 Binding 统一注册。 final appC = Get.find(); + /// 当前终端设置,初始化后用于确定系统类型和设备绑定状态。 late SysSetting sysSetting; + /// 当前终端业务类型,决定搜索框允许的输入方式。 Rx sysType = SysType.CANTEEN.obs; + /// 当前分页展示的人脸本地记录。 RxList faceList = RxList(); + /// 当前列表页码,从 1 开始。 RxInt currentPage = RxInt(1); + /// 当前搜索条件命中的记录总数,供分页组件计算页数。 RxInt totalPage = RxInt(0); + /// 单页展示数量,沿用现有人脸管理网格密度。 RxInt pageSize = RxInt(15); + /// 列表查询是否进行中,仅驱动列表区域加载状态。 RxBool isLoading = false.obs; + /// 最近一次列表查询失败的安全提示,不包含数据库或文件路径。 RxString errorMessage = ''.obs; + /// 姓名搜索输入控制器,由本控制器负责释放。 final usernameC = TextEditingController(); - StreamSubscription? mqUpdateListen; + /// FACE 增量任务成功后的列表刷新订阅,由 [onClose] 取消。 + StreamSubscription? mqUpdateListen; + /// 启动异步设置读取、首次列表查询和 FACE 完成事件监听。 @override - void onInit() async { + void onInit() { super.onInit(); - sysSetting = await AppSettingTool.getSysSetting(); - sysType.value = sysSetting.sysType; - getFaceList(); - _listenMqCallback(); + unawaited(_initialize()); } + /// 读取终端设置后加载列表;初始化失败交由列表状态统一展示。 + Future _initialize() async { + sysSetting = await AppSettingTool.getSysSetting(); + sysType.value = sysSetting.sysType; + _listenMqCallback(); + await getFaceList(); + } + + /// 释放 MQTT 完成事件和输入控制器,防止退出设置页后继续刷新。 @override void onClose() { mqUpdateListen?.cancel(); usernameC.dispose(); + super.onClose(); } + /// 按当前姓名和页码读取本地人脸列表,可选显示旧页面使用的查询遮罩。 Future getFaceList({bool isLoading = false}) async { this.isLoading.value = true; errorMessage.value = ''; @@ -83,16 +104,22 @@ class FaceManageController extends GetxController { } } - // 同步人脸 + /// 确认后将本部门快照或非生产全租户调试请求提交到统一人脸任务队列。 + /// + /// 队列会先避开支付、人脸会话和业务弹框;只有任务已经实际开始时才显示遮罩, + /// 避免遮罩本身被空闲检测识别为业务占用而导致任务无法启动。 Future asyncFace({bool isAll = false}) async { final isNetwork = AppNetworkTool.isAutoNetworkMsg(); if (!isNetwork) return; final setting = await AppSettingTool.getSysSetting(); - final serviceMerchantId = setting.serviceMerchantId; - if (ObjectUtil.isEmpty(serviceMerchantId)) { + if (ObjectUtil.isEmpty(setting.merchantDeptId)) { Toast.error(msg: "请先绑定设备"); return; } + if (isAll && AppSysTool.isProd()) { + Toast.error(msg: '生产环境仅允许同步本部门人脸'); + return; + } if (AppSysTool.isProd()) { final isActive = await AppFaceTool.isActiveSdk(); if (!isActive) { @@ -102,33 +129,49 @@ class FaceManageController extends GetxController { } final isSure = await appMessageDialog( Get.context!, - "是否同步所有人脸,这个过程可能比较长", + isAll ? '是否同步全租户人脸?此能力仅用于非生产调试,过程可能较长' : '是否同步本部门及子部门人脸?过程可能较长', ); if (!isSure) return; - EasyLoading.show( - status: '同步中...', - maskType: EasyLoadingMaskType.black, + final scheduler = SyncCheckTool.instance; + final operation = scheduler.trigger( + ScheduledTaskType.departmentFaceSync, + debugAllFaces: isAll, + triggerType: ScheduledTaskTriggerType.manual, ); - try { - await SyncFaceTool.delEmptyFace(); - await SyncFaceTool.syncFace( - isAll: isAll, - onSyncGroup: () { - currentPage.value = 1; - getFaceList(); - }, + await Future.delayed(Duration.zero); + var overlayVisible = false; + if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync)) { + overlayVisible = true; + EasyLoading.show( + status: '同步中...', + maskType: EasyLoadingMaskType.black, ); + } + try { + await operation; + if (scheduler.pendingTasks + .contains(ScheduledTaskType.departmentFaceSync)) { + Toast.success(msg: '人脸同步已排队,将在业务空闲后执行'); + return; + } + currentPage.value = 1; await getFaceList(); - Toast.success(msg: '同步成功'); + final record = scheduler.records[ScheduledTaskType.departmentFaceSync]; + if (record?.success == true) { + Toast.success(msg: record?.message ?? '人脸同步完成'); + } else { + Toast.error(msg: record?.message ?? '人脸同步失败,请稍后重试'); + } } catch (e, stack) { Toast.error(msg: '同步人脸失败,请稍后重试'); logger.e('|人脸管理|同步失败error:|$e|stack:|$stack|'); } finally { currentPage.value = 1; - EasyLoading.dismiss(); + if (overlayVisible) EasyLoading.dismiss(); } } + /// 经二次确认后清理本机全部人脸记录和虹软内存特征,仅影响本机数据。 Future delAllFace() async { final isSure = await appMessageDialog( Get.context!, @@ -153,19 +196,20 @@ class FaceManageController extends GetxController { } } - _listenMqCallback() { + /// 监听成功完成的 FACE 增量同步,并仅刷新当前设置页本地列表。 + void _listenMqCallback() { mqUpdateListen = appC.mqUpdateEventBus.on().listen((event) async { switch (event) { case MqtActionEnum.FACE: - getFaceList(isLoading: true); + await getFaceList(isLoading: true); break; default: } }); } - /// 搜索 + /// 按指定页码执行姓名搜索,并在查询前收起软键盘。 Future doPage({int? page}) async { if (page != null) currentPage.value = page; // 收起键盘 @@ -173,7 +217,7 @@ class FaceManageController extends GetxController { await getFaceList(isLoading: true); } - /// 重置 + /// 清空姓名条件、回到第一页并重新加载列表。 Future doReset() async { currentPage.value = 1; usernameC.clear(); @@ -182,7 +226,7 @@ class FaceManageController extends GetxController { await getFaceList(isLoading: true); } - // 删除人脸 + /// 删除一条本地人脸及其虹软内存特征,不修改服务端人脸真源。 Future deleteFace(FaceEntity v) async { final isSure = await appMessageDialog(Get.context!, "是否删除人脸"); if (!isSure) return; @@ -208,7 +252,7 @@ class FaceManageController extends GetxController { } } -/// 搜索 +/// 构建人脸搜索与管理操作区;全租户同步按钮仅在非生产调试环境出现。 Widget buildFaceSearch(BuildContext context) { final faceC = Get.find(); final theme = FTheme.of(context); @@ -254,6 +298,10 @@ Widget buildFaceSearch(BuildContext context) { onPress: () => faceC.doPage(), ), FButton( + prefix: FIcon( + FAssets.icons.scanFace, + size: theme.typography.base.fontSize, + ), label: Text( '同步本部门人脸', style: theme.typography.base.copyWith( @@ -263,16 +311,21 @@ Widget buildFaceSearch(BuildContext context) { style: FButtonStyle.outline, onPress: () => faceC.asyncFace(isAll: false), ), - FButton( - label: Text( - '同步全部人脸', - style: theme.typography.base.copyWith( - color: theme.colorScheme.primary, + if (!AppSysTool.isProd()) + FButton( + prefix: FIcon( + FAssets.icons.scanFace, + size: theme.typography.base.fontSize, ), + label: Text( + '同步全部人脸', + style: theme.typography.base.copyWith( + color: theme.colorScheme.primary, + ), + ), + style: FButtonStyle.outline, + onPress: () => faceC.asyncFace(isAll: true), ), - style: FButtonStyle.outline, - onPress: () => faceC.asyncFace(isAll: true), - ), const Spacer(), FButton( label: Text( @@ -285,7 +338,7 @@ Widget buildFaceSearch(BuildContext context) { ].toRowSpace(space: 20.w); } -/// 列表 +/// 构建支持加载、失败、空数据和分页内容的人脸本地样本网格。 Widget buildFaceList(context) { final theme = FTheme.of(context); final productC = Get.find(); diff --git a/lib/pages/terminal/setting/widgets/setting_sync.dart b/lib/pages/terminal/setting/widgets/setting_sync.dart index f13c9d3..eba1ce9 100644 --- a/lib/pages/terminal/setting/widgets/setting_sync.dart +++ b/lib/pages/terminal/setting/widgets/setting_sync.dart @@ -467,6 +467,10 @@ Widget _summaryValue( title: '设备配置同步', icon: FAssets.icons.settings ), + ScheduledTaskType.departmentFaceSync => ( + title: '本部门人脸同步', + icon: FAssets.icons.scanFace + ), ScheduledTaskType.classifySync => ( title: '分类同步', icon: FAssets.icons.packageOpen diff --git a/pubspec.yaml b/pubspec.yaml index 393eb54..cd3e885 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pj_supermarket_cloud description: "资安信智慧后勤-测试" publish_to: "none" -version: 1.3.3+135 +version: 1.3.3+137 environment: sdk: ^3.6.0 diff --git a/test/common/face_sync_models_test.dart b/test/common/face_sync_models_test.dart new file mode 100644 index 0000000..053d7fd --- /dev/null +++ b/test/common/face_sync_models_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 验证部门人脸同步默认调度、V3 契约、MQ 专用模型和脱敏结果摘要。 +void main() { + test('本部门人脸同步默认每天零点四十分且仅空闲执行', () { + final config = SyncCheckTool.defaultConfigs.singleWhere( + (item) => item.type == ScheduledTaskType.departmentFaceSync, + ); + + expect(config.enabled, isTrue); + expect(config.cron, '0 40 0 * * *'); + expect(config.idleOnly, isTrue); + }); + + test('V3 快照兼容数字和字符串标识', () { + final snapshot = ServiceFaceSyncSnapshot.fromJson(const { + 'records': [ + { + 'id': 101, + 'sampleId': '202', + 'userId': 303, + 'username': '测试用户', + 'deptId': '404', + 'deptName': '测试部门', + 'faceImg': '/face/test.jpg', + 'sourceUpdatedAt': '2026-08-17T00:40:00', + }, + ], + 'nextAfterId': 101, + 'hasMore': false, + 'snapshotMaxId': '101', + }); + + expect(snapshot.records.single.id, '101'); + expect(snapshot.records.single.sampleId, '202'); + expect(snapshot.records.single.userId, '303'); + expect(snapshot.records.single.deptId, '404'); + expect(snapshot.nextAfterId, '101'); + expect(snapshot.snapshotMaxId, '101'); + }); + + test('V3 快照缺少必填标识时在反序列化边界失败', () { + expect( + () => ServiceFaceSyncRecord.fromJson(const { + 'id': null, + 'sampleId': 2, + 'userId': 3, + 'deptId': 4, + }), + throwsA(isA()), + ); + }); + + test('FACE 消息使用专用模型兼容数字用户标识', () { + final message = MqttFaceChangeMsg.fromJson(const { + 'action': 'FACE', + 'userId': 9527, + }); + + expect(message.action, MqtActionEnum.FACE); + expect(message.userId, '9527'); + }); + + test('部分失败汇总只包含脱敏样本标识', () { + const summary = FaceSyncSummary( + mode: FaceSyncMode.departmentSnapshot, + scanned: 100, + added: 98, + updated: 1, + deleted: 0, + skipped: 0, + failed: 1, + maskedFailedSampleIds: ['1***003'], + ); + + expect(summary.hasFailures, isTrue); + expect(summary.changed, 99); + expect(summary.message, contains('失败 1')); + expect(summary.message, contains('1***003')); + }); +}