From 96e75a458f0b8d298302a3bba64c756f83bd7dfa Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 3 Dec 2025 18:06:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(face-pipeline):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BA=BA=E8=84=B8=E5=8C=B9=E9=85=8D=E7=AE=A1=E7=BA=BF=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增Stage配置注解@StageConfig,支持Stage元数据和可选性控制 - 创建抽象基类AbstractFaceMatchingStage,提供Stage执行模板方法 - 实现FaceMatchingContext上下文类,用于Stage间状态和数据传递 - 构建Pipeline核心执行类,支持Stage动态添加和执行控制 - 添加PipelineBuilder构建器,支持链式组装管线 - 定义PipelineStage接口和StageResult结果类,规范Stage行为 - 新增人脸匹配场景枚举FaceMatchingScene和Stage可选模式枚举 - 创建管线异常类PipelineException和StageExecutionException - 实现FaceMatchingPipelineFactory工厂类,支持多场景管线组装 - 添加拼图生成编排器PuzzleGenerationOrchestrator,支持异步批量生成 - 创建BuildSourceRelationStage等核心Stage实现类 --- .../face/pipeline/annotation/StageConfig.java | 38 +++ .../core/AbstractFaceMatchingStage.java | 97 ++++++ .../pipeline/core/FaceMatchingContext.java | 287 ++++++++++++++++++ .../basic/face/pipeline/core/Pipeline.java | 119 ++++++++ .../face/pipeline/core/PipelineBuilder.java | 80 +++++ .../face/pipeline/core/PipelineStage.java | 50 +++ .../basic/face/pipeline/core/StageResult.java | 101 ++++++ .../pipeline/enums/FaceMatchingScene.java | 25 ++ .../pipeline/enums/StageOptionalMode.java | 29 ++ .../pipeline/exception/PipelineException.java | 19 ++ .../exception/StageExecutionException.java | 23 ++ .../factory/FaceMatchingPipelineFactory.java | 234 ++++++++++++++ .../helper/PuzzleGenerationOrchestrator.java | 147 +++++++++ .../stages/BuildSourceRelationStage.java | 92 ++++++ .../face/pipeline/stages/CreateTaskStage.java | 73 +++++ .../stages/CustomFaceSearchStage.java | 121 ++++++++ .../stages/DeleteOldRelationsStage.java | 74 +++++ .../pipeline/stages/FaceRecognitionStage.java | 73 +++++ .../pipeline/stages/FaceRecoveryStage.java | 80 +++++ .../stages/FilterByDevicePhotoLimitStage.java | 223 ++++++++++++++ .../stages/FilterByTimeRangeStage.java | 122 ++++++++ .../pipeline/stages/GeneratePuzzleStage.java | 66 ++++ .../stages/HandleVideoRecreationStage.java | 86 ++++++ .../pipeline/stages/LoadFaceSamplesStage.java | 73 +++++ .../stages/LoadMatchedSamplesStage.java | 89 ++++++ .../stages/PersistRelationsStage.java | 92 ++++++ .../pipeline/stages/PrepareContextStage.java | 91 ++++++ .../stages/ProcessBuyStatusStage.java | 86 ++++++ .../stages/ProcessFreeSourceStage.java | 83 +++++ .../stages/RecordCustomMatchMetricsStage.java | 65 ++++ .../pipeline/stages/RecordMetricsStage.java | 63 ++++ .../pipeline/stages/SetTaskStatusStage.java | 63 ++++ .../stages/UpdateFaceResultStage.java | 95 ++++++ 33 files changed, 3059 insertions(+) create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/annotation/StageConfig.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/core/AbstractFaceMatchingStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/core/FaceMatchingContext.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/core/Pipeline.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/core/PipelineBuilder.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/core/PipelineStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/core/StageResult.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/enums/FaceMatchingScene.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/enums/StageOptionalMode.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/exception/PipelineException.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/exception/StageExecutionException.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/factory/FaceMatchingPipelineFactory.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/helper/PuzzleGenerationOrchestrator.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/BuildSourceRelationStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/CreateTaskStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/CustomFaceSearchStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/DeleteOldRelationsStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/FaceRecognitionStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/FaceRecoveryStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByDevicePhotoLimitStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByTimeRangeStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/GeneratePuzzleStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/HandleVideoRecreationStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/LoadFaceSamplesStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/LoadMatchedSamplesStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/PersistRelationsStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/PrepareContextStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/ProcessBuyStatusStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/ProcessFreeSourceStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/RecordCustomMatchMetricsStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/RecordMetricsStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/SetTaskStatusStage.java create mode 100644 src/main/java/com/ycwl/basic/face/pipeline/stages/UpdateFaceResultStage.java diff --git a/src/main/java/com/ycwl/basic/face/pipeline/annotation/StageConfig.java b/src/main/java/com/ycwl/basic/face/pipeline/annotation/StageConfig.java new file mode 100644 index 00000000..c7d472b3 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/annotation/StageConfig.java @@ -0,0 +1,38 @@ +package com.ycwl.basic.face.pipeline.annotation; + +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Stage配置注解 + * 用于标注Stage的元数据和可选性控制 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface StageConfig { + + /** + * Stage唯一标识 + * 用于外部配置控制 + */ + String stageId(); + + /** + * 可选模式 + */ + StageOptionalMode optionalMode() default StageOptionalMode.UNSUPPORT; + + /** + * 描述信息 + */ + String description() default ""; + + /** + * 默认是否启用(当optionalMode=SUPPORT时生效) + */ + boolean defaultEnabled() default true; +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/core/AbstractFaceMatchingStage.java b/src/main/java/com/ycwl/basic/face/pipeline/core/AbstractFaceMatchingStage.java new file mode 100644 index 00000000..71df3420 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/core/AbstractFaceMatchingStage.java @@ -0,0 +1,97 @@ +package com.ycwl.basic.face.pipeline.core; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import lombok.extern.slf4j.Slf4j; + +/** + * 人脸匹配Pipeline Stage抽象基类 + * 提供默认实现和通用逻辑 + * + * @param Context类型,必须继承FaceMatchingContext + */ +@Slf4j +public abstract class AbstractFaceMatchingStage implements PipelineStage { + + /** + * 最终的shouldExecute判断 + * 整合了外部配置控制和业务逻辑判断 + */ + @Override + public final boolean shouldExecute(C context) { + // 1. 检查Stage配置注解 + StageConfig config = getStageConfig(); + if (config != null) { + String stageId = config.stageId(); + StageOptionalMode mode = config.optionalMode(); + + // FORCE_ON:强制执行,不检查外部配置 + if (mode == StageOptionalMode.FORCE_ON) { + return shouldExecuteByBusinessLogic(context); + } + + // SUPPORT:检查外部配置 + if (mode == StageOptionalMode.SUPPORT) { + boolean externalEnabled = context.isStageEnabled(stageId, config.defaultEnabled()); + if (!externalEnabled) { + log.debug("[{}] Stage被外部配置禁用", stageId); + return false; + } + } + + // UNSUPPORT:不检查外部配置,直接走业务逻辑 + } + + // 2. 执行业务逻辑判断 + return shouldExecuteByBusinessLogic(context); + } + + /** + * 子类实现业务逻辑判断 + * 默认总是执行 + * + * 子类可以覆盖此方法实现条件性执行 + * 例如: 只有新用户才设置任务状态, 只有匹配到样本才处理源文件关联等 + */ + protected boolean shouldExecuteByBusinessLogic(C context) { + return true; + } + + /** + * 模板方法:执行Stage前的准备工作 + */ + protected void beforeExecute(C context) { + log.debug("[{}] 开始执行", getName()); + } + + /** + * 模板方法:执行Stage后的清理工作 + */ + protected void afterExecute(C context, StageResult result) { + if (result.isSuccess()) { + log.debug("[{}] 执行成功: {}", getName(), result.getMessage()); + } else if (result.isSkipped()) { + log.debug("[{}] 已跳过: {}", getName(), result.getMessage()); + } else if (result.isDegraded()) { + log.warn("[{}] 降级执行: {}", getName(), result.getMessage()); + } else { + log.error("[{}] 执行失败: {}", getName(), result.getMessage(), result.getException()); + } + } + + /** + * 子类实现具体的处理逻辑 + */ + protected abstract StageResult doExecute(C context); + + /** + * 最终执行方法(带钩子) + */ + @Override + public final StageResult execute(C context) { + beforeExecute(context); + StageResult result = doExecute(context); + afterExecute(context, result); + return result; + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/core/FaceMatchingContext.java b/src/main/java/com/ycwl/basic/face/pipeline/core/FaceMatchingContext.java new file mode 100644 index 00000000..85f372b5 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/core/FaceMatchingContext.java @@ -0,0 +1,287 @@ +package com.ycwl.basic.face.pipeline.core; + +import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter; +import com.ycwl.basic.face.pipeline.enums.FaceMatchingScene; +import com.ycwl.basic.integration.common.manager.ScenicConfigManager; +import com.ycwl.basic.model.pc.face.entity.FaceEntity; +import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; +import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity; +import com.ycwl.basic.model.task.resp.SearchFaceRespVo; +import lombok.Getter; +import lombok.Setter; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 人脸匹配管线上下文 + * 在各个Stage之间传递状态和数据 + */ +@Getter +@Setter +public class FaceMatchingContext { + + // ==================== 核心字段(构造时必填)==================== + + /** + * 人脸ID(必填) + */ + private final Long faceId; + + /** + * 是否新用户 + */ + private final boolean isNew; + + // ==================== 场景标识 ==================== + + /** + * 场景标识 + */ + private FaceMatchingScene scene; + + /** + * 手动选择的样本ID(自定义匹配场景) + */ + private List faceSampleIds; + + // ==================== 中间状态 ==================== + + /** + * 人脸实体 + */ + private FaceEntity face; + + /** + * 景区配置管理器 + */ + private ScenicConfigManager scenicConfig; + + /** + * 人脸识别适配器 + */ + private IFaceBodyAdapter faceBodyAdapter; + + /** + * 人脸搜索结果 + */ + private SearchFaceRespVo searchResult; + + /** + * 人脸样本列表(自定义匹配场景) + */ + private List faceSamples; + + /** + * 匹配到的样本ID列表 + */ + private List sampleListIds; + + /** + * 源文件关联列表 + */ + private List memberSourceList; + + /** + * 免费源文件ID列表 + */ + private List freeSourceIds; + + /** + * 人脸选择后置模式配置(自定义匹配场景) + * 0: 并集, 1: 交集, 2: 直接使用 + */ + private Integer faceSelectPostMode; + + // ==================== 输出结果 ==================== + + /** + * 最终结果 + */ + private SearchFaceRespVo finalResult; + + // ==================== Stage配置 ==================== + + /** + * Stage开关配置表 + * Key: stageId, Value: 是否启用 + */ + private Map stageEnabledMap = new HashMap<>(); + + // ==================== 构造函数(私有)==================== + + private FaceMatchingContext(Builder builder) { + this.faceId = builder.faceId; + this.isNew = builder.isNew; + this.scene = builder.scene; + this.faceSampleIds = builder.faceSampleIds; + } + + // ==================== 静态工厂方法 ==================== + + /** + * 获取 Builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * 快速创建自动匹配场景Context + */ + public static FaceMatchingContext forAutoMatching(Long faceId, boolean isNew) { + return FaceMatchingContext.builder() + .faceId(faceId) + .isNew(isNew) + .scene(FaceMatchingScene.AUTO_MATCHING) + .build(); + } + + /** + * 快速创建自定义匹配场景Context + */ + public static FaceMatchingContext forCustomMatching(Long faceId, List faceSampleIds) { + return FaceMatchingContext.builder() + .faceId(faceId) + .isNew(false) + .faceSampleIds(faceSampleIds) + .scene(FaceMatchingScene.CUSTOM_MATCHING) + .build(); + } + + /** + * 快速创建仅识别场景Context + */ + public static FaceMatchingContext forRecognitionOnly(Long faceId) { + return FaceMatchingContext.builder() + .faceId(faceId) + .isNew(false) + .scene(FaceMatchingScene.RECOGNITION_ONLY) + .build(); + } + + // ==================== 业务方法 ==================== + + /** + * 判断指定Stage是否启用 + * + * @param stageId Stage唯一标识 + * @param defaultEnabled 默认值(当配置未指定时使用) + * @return true-启用, false-禁用 + */ + public boolean isStageEnabled(String stageId, boolean defaultEnabled) { + return stageEnabledMap.getOrDefault(stageId, defaultEnabled); + } + + /** + * 判断指定Stage是否启用(默认为false) + * + * @param stageId Stage唯一标识 + * @return true-启用, false-禁用 + */ + public boolean isStageEnabled(String stageId) { + return stageEnabledMap.getOrDefault(stageId, false); + } + + /** + * 设置指定Stage的启用状态 + * + * @param stageId Stage唯一标识 + * @param enabled 是否启用 + * @return this(支持链式调用) + */ + public FaceMatchingContext setStageState(String stageId, boolean enabled) { + stageEnabledMap.put(stageId, enabled); + return this; + } + + /** + * 启用指定Stage + * + * @param stageId Stage唯一标识 + * @return this(支持链式调用) + */ + public FaceMatchingContext enableStage(String stageId) { + stageEnabledMap.put(stageId, true); + return this; + } + + /** + * 禁用指定Stage + * + * @param stageId Stage唯一标识 + * @return this(支持链式调用) + */ + public FaceMatchingContext disableStage(String stageId) { + stageEnabledMap.put(stageId, false); + return this; + } + + /** + * 批量设置Stage启用状态 + * + * @param stages Stage配置Map(stageId -> enabled) + * @return this(支持链式调用) + */ + public FaceMatchingContext setStages(Map stages) { + if (stages != null) { + stageEnabledMap.putAll(stages); + } + return this; + } + + /** + * 清空所有Stage配置 + * + * @return this(支持链式调用) + */ + public FaceMatchingContext clearStages() { + stageEnabledMap.clear(); + return this; + } + + // ==================== Builder ==================== + + public static class Builder { + private Long faceId; + private boolean isNew = false; + private FaceMatchingScene scene; + private List faceSampleIds; + + public Builder faceId(Long faceId) { + this.faceId = faceId; + return this; + } + + public Builder isNew(boolean isNew) { + this.isNew = isNew; + return this; + } + + public Builder scene(FaceMatchingScene scene) { + this.scene = scene; + return this; + } + + public Builder faceSampleIds(List faceSampleIds) { + this.faceSampleIds = faceSampleIds; + return this; + } + + public FaceMatchingContext build() { + // 参数校验 + if (faceId == null) { + throw new IllegalArgumentException("faceId is required"); + } + if (scene == null) { + throw new IllegalArgumentException("scene is required"); + } + // 自定义匹配场景必须提供faceSampleIds + if (scene == FaceMatchingScene.CUSTOM_MATCHING && (faceSampleIds == null || faceSampleIds.isEmpty())) { + throw new IllegalArgumentException("faceSampleIds is required for CUSTOM_MATCHING scene"); + } + return new FaceMatchingContext(this); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/core/Pipeline.java b/src/main/java/com/ycwl/basic/face/pipeline/core/Pipeline.java new file mode 100644 index 00000000..7e45bfb5 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/core/Pipeline.java @@ -0,0 +1,119 @@ +package com.ycwl.basic.face.pipeline.core; + +import com.ycwl.basic.face.pipeline.exception.PipelineException; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +/** + * 人脸匹配管线 + * 按顺序执行一系列Stage + * + * @param Context类型,必须继承FaceMatchingContext + */ +@Slf4j +public class Pipeline { + + private final List> stages; + private final String name; + + public Pipeline(String name, List> stages) { + this.name = name; + this.stages = new ArrayList<>(stages); + } + + /** + * 执行管线 + * + * @param context 管线上下文 + * @return 执行成功返回true + * @throws PipelineException 管线执行异常 + */ + public boolean execute(C context) { + log.info("[{}] 开始执行人脸匹配管线, Stage数量: {}", name, stages.size()); + long startTime = System.currentTimeMillis(); + int maxStages = 100; // 防止无限循环 + int executedCount = 0; + + try { + for (int i = 0; i < stages.size(); i++) { + if (executedCount >= maxStages) { + log.error("[{}] Stage执行数量超过最大限制({}),可能存在循环依赖", name, maxStages); + throw new PipelineException("Stage执行数量超过最大限制,可能存在循环依赖"); + } + + PipelineStage stage = stages.get(i); + String stageName = stage.getName(); + + log.debug("[{}] [{}/{}] 准备执行Stage: {}", name, i + 1, stages.size(), stageName); + + if (!stage.shouldExecute(context)) { + log.debug("[{}] Stage {} 条件不满足,跳过执行", name, stageName); + continue; + } + + long stageStartTime = System.currentTimeMillis(); + StageResult result = stage.execute(context); + long stageDuration = System.currentTimeMillis() - stageStartTime; + executedCount++; + + logStageResult(stageName, result, stageDuration); + + // 动态添加后续Stage + if (result.getNextStages() != null && !result.getNextStages().isEmpty()) { + List> nextStages = result.getNextStages(); + log.info("[{}] Stage {} 动态添加了 {} 个后续Stage", name, stageName, nextStages.size()); + + for (int j = 0; j < nextStages.size(); j++) { + PipelineStage nextStage = nextStages.get(j); + stages.add(i + 1 + j, nextStage); + log.debug("[{}] - 插入Stage: {} 到位置 {}", name, nextStage.getName(), i + 1 + j); + } + } + + if (result.isFailed()) { + log.error("[{}] Stage {} 执行失败,管线终止", name, stageName); + return false; + } + } + + long totalDuration = System.currentTimeMillis() - startTime; + log.info("[{}] 人脸匹配管线执行完成, 总Stage数: {}, 实际执行: {}, 耗时: {}ms", + name, stages.size(), executedCount, totalDuration); + return true; + + } catch (Exception e) { + log.error("[{}] 人脸匹配管线执行异常", name, e); + throw new PipelineException("管线执行失败: " + e.getMessage(), e); + } + } + + private void logStageResult(String stageName, StageResult result, long duration) { + String statusIcon = switch (result.getStatus()) { + case SUCCESS -> "✓"; + case SKIPPED -> "○"; + case DEGRADED -> "△"; + case FAILED -> "✗"; + }; + + log.info("[{}] {} Stage {} - {} (耗时: {}ms)", + name, statusIcon, stageName, result.getStatus(), duration); + + if (result.getMessage() != null) { + log.debug("[{}] 详情: {}", name, result.getMessage()); + } + } + + public String getName() { + return name; + } + + public int getStageCount() { + return stages.size(); + } + + public List getStageNames() { + return stages.stream().map(PipelineStage::getName).toList(); + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/core/PipelineBuilder.java b/src/main/java/com/ycwl/basic/face/pipeline/core/PipelineBuilder.java new file mode 100644 index 00000000..8fd86a98 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/core/PipelineBuilder.java @@ -0,0 +1,80 @@ +package com.ycwl.basic.face.pipeline.core; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Pipeline构建器 + * 使用Builder模式动态组装人脸匹配管线 + * + * @param Context类型,必须继承FaceMatchingContext + */ +public class PipelineBuilder { + + private String name = "DefaultFaceMatchingPipeline"; + private final List> stages = new ArrayList<>(); + + public PipelineBuilder() { + } + + public PipelineBuilder(String name) { + this.name = name; + } + + /** + * 设置管线名称 + */ + public PipelineBuilder name(String name) { + this.name = name; + return this; + } + + /** + * 添加Stage + */ + public PipelineBuilder addStage(PipelineStage stage) { + if (stage != null) { + this.stages.add(stage); + } + return this; + } + + /** + * 批量添加Stage + */ + public PipelineBuilder addStages(List> stages) { + if (stages != null) { + this.stages.addAll(stages); + } + return this; + } + + /** + * 条件性添加Stage + */ + public PipelineBuilder addStageIf(boolean condition, PipelineStage stage) { + if (condition && stage != null) { + this.stages.add(stage); + } + return this; + } + + /** + * 按优先级排序Stage + */ + public PipelineBuilder sortByPriority() { + this.stages.sort(Comparator.comparingInt(PipelineStage::getPriority)); + return this; + } + + /** + * 构建Pipeline + */ + public Pipeline build() { + if (stages.isEmpty()) { + throw new IllegalStateException("人脸匹配管线至少需要一个Stage"); + } + return new Pipeline<>(name, stages); + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/core/PipelineStage.java b/src/main/java/com/ycwl/basic/face/pipeline/core/PipelineStage.java new file mode 100644 index 00000000..d10d2e0a --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/core/PipelineStage.java @@ -0,0 +1,50 @@ +package com.ycwl.basic.face.pipeline.core; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; + +/** + * 管线处理阶段接口 + * 每个Stage负责一个独立的人脸匹配处理步骤 + * + * @param Context类型,必须继承FaceMatchingContext + */ +public interface PipelineStage { + + /** + * 获取Stage名称(用于日志和监控) + */ + String getName(); + + /** + * 判断是否需要执行此Stage + * 支持条件性执行(如:只有新用户才设置任务状态) + * + * @param context 管线上下文 + * @return true-执行, false-跳过 + */ + boolean shouldExecute(C context); + + /** + * 执行Stage处理逻辑 + * + * @param context 管线上下文 + * @return 执行结果 + */ + StageResult execute(C context); + + /** + * 获取Stage的执行优先级(用于排序) + * 数值越小优先级越高,默认为100 + */ + default int getPriority() { + return 100; + } + + /** + * 获取Stage配置注解(用于反射读取可选性控制信息) + * @return Stage配置注解,如果未标注则返回null + */ + default StageConfig getStageConfig() { + return this.getClass().getAnnotation(StageConfig.class); + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/core/StageResult.java b/src/main/java/com/ycwl/basic/face/pipeline/core/StageResult.java new file mode 100644 index 00000000..7d42d0fb --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/core/StageResult.java @@ -0,0 +1,101 @@ +package com.ycwl.basic.face.pipeline.core; + +import lombok.Getter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Stage执行结果 + * + * @param Context类型,必须继承FaceMatchingContext + */ +@Getter +public class StageResult { + + public enum Status { + SUCCESS, // 执行成功 + SKIPPED, // 跳过执行 + FAILED, // 执行失败 + DEGRADED // 降级执行 + } + + private final Status status; + private final String message; + private final Throwable exception; + private final List> nextStages; + + private StageResult(Status status, String message, Throwable exception, List> nextStages) { + this.status = status; + this.message = message; + this.exception = exception; + this.nextStages = nextStages != null + ? Collections.unmodifiableList(new ArrayList<>(nextStages)) + : Collections.emptyList(); + } + + public static StageResult success() { + return new StageResult<>(Status.SUCCESS, null, null, null); + } + + public static StageResult success(String message) { + return new StageResult<>(Status.SUCCESS, message, null, null); + } + + /** + * 成功执行并动态添加后续Stage + */ + @SafeVarargs + public static StageResult successWithNext(String message, PipelineStage... stages) { + return new StageResult<>(Status.SUCCESS, message, null, Arrays.asList(stages)); + } + + /** + * 成功执行并动态添加后续Stage列表 + */ + public static StageResult successWithNext(String message, List> stages) { + return new StageResult<>(Status.SUCCESS, message, null, stages); + } + + public static StageResult skipped() { + return new StageResult<>(Status.SKIPPED, "条件不满足,跳过执行", null, null); + } + + public static StageResult skipped(String reason) { + return new StageResult<>(Status.SKIPPED, reason, null, null); + } + + public static StageResult failed(String message) { + return new StageResult<>(Status.FAILED, message, null, null); + } + + public static StageResult failed(String message, Throwable exception) { + return new StageResult<>(Status.FAILED, message, exception, null); + } + + public static StageResult degraded(String message) { + return new StageResult<>(Status.DEGRADED, message, null, null); + } + + public boolean isSuccess() { + return status == Status.SUCCESS; + } + + public boolean isSkipped() { + return status == Status.SKIPPED; + } + + public boolean isFailed() { + return status == Status.FAILED; + } + + public boolean isDegraded() { + return status == Status.DEGRADED; + } + + public boolean canContinue() { + return status == Status.SUCCESS || status == Status.SKIPPED || status == Status.DEGRADED; + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/enums/FaceMatchingScene.java b/src/main/java/com/ycwl/basic/face/pipeline/enums/FaceMatchingScene.java new file mode 100644 index 00000000..78cea793 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/enums/FaceMatchingScene.java @@ -0,0 +1,25 @@ +package com.ycwl.basic.face.pipeline.enums; + +/** + * 人脸匹配场景枚举 + */ +public enum FaceMatchingScene { + + /** + * 自动人脸匹配 + * 新用户上传人脸后自动执行匹配,或老用户重新匹配 + */ + AUTO_MATCHING, + + /** + * 自定义人脸匹配 + * 用户手动选择人脸样本进行匹配 + */ + CUSTOM_MATCHING, + + /** + * 仅识别 + * 只执行人脸识别,不处理后续业务逻辑(源文件关联、任务创建等) + */ + RECOGNITION_ONLY +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/enums/StageOptionalMode.java b/src/main/java/com/ycwl/basic/face/pipeline/enums/StageOptionalMode.java new file mode 100644 index 00000000..8f264e02 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/enums/StageOptionalMode.java @@ -0,0 +1,29 @@ +package com.ycwl.basic.face.pipeline.enums; + +/** + * Stage可选模式枚举 + * 控制Stage是否支持外部配置 + */ +public enum StageOptionalMode { + + /** + * 强制执行 + * 不检查外部配置,总是执行(除非业务逻辑判断跳过) + * 例如: PrepareContextStage、FaceRecognitionStage + */ + FORCE_ON, + + /** + * 支持外部控制 + * 检查外部配置来决定是否执行 + * 例如: RecordMetricsStage、FaceRecoveryStage + */ + SUPPORT, + + /** + * 不支持外部控制 + * 完全由业务逻辑控制是否执行,不检查外部配置 + * 例如: SourceRelationStage(需要sampleListIds不为空) + */ + UNSUPPORT +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/exception/PipelineException.java b/src/main/java/com/ycwl/basic/face/pipeline/exception/PipelineException.java new file mode 100644 index 00000000..aa168a1a --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/exception/PipelineException.java @@ -0,0 +1,19 @@ +package com.ycwl.basic.face.pipeline.exception; + +/** + * Pipeline执行异常 + */ +public class PipelineException extends RuntimeException { + + public PipelineException(String message) { + super(message); + } + + public PipelineException(String message, Throwable cause) { + super(message, cause); + } + + public PipelineException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/exception/StageExecutionException.java b/src/main/java/com/ycwl/basic/face/pipeline/exception/StageExecutionException.java new file mode 100644 index 00000000..cd79bfe5 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/exception/StageExecutionException.java @@ -0,0 +1,23 @@ +package com.ycwl.basic.face.pipeline.exception; + +/** + * Stage执行异常 + */ +public class StageExecutionException extends RuntimeException { + + private final String stageName; + + public StageExecutionException(String stageName, String message) { + super(String.format("[%s] %s", stageName, message)); + this.stageName = stageName; + } + + public StageExecutionException(String stageName, String message, Throwable cause) { + super(String.format("[%s] %s", stageName, message), cause); + this.stageName = stageName; + } + + public String getStageName() { + return stageName; + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/factory/FaceMatchingPipelineFactory.java b/src/main/java/com/ycwl/basic/face/pipeline/factory/FaceMatchingPipelineFactory.java new file mode 100644 index 00000000..91b0a186 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/factory/FaceMatchingPipelineFactory.java @@ -0,0 +1,234 @@ +package com.ycwl.basic.face.pipeline.factory; + +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.Pipeline; +import com.ycwl.basic.face.pipeline.core.PipelineBuilder; +import com.ycwl.basic.face.pipeline.enums.FaceMatchingScene; +import com.ycwl.basic.face.pipeline.stages.*; +import com.ycwl.basic.service.pc.helper.ScenicConfigFacade; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 人脸匹配Pipeline工厂 + * 负责为不同场景组装Pipeline + * + * 支持的场景: + * 1. 自动人脸匹配(新用户/老用户) + * 2. 自定义人脸匹配 + * 3. 仅识别 + */ +@Slf4j +@Component +public class FaceMatchingPipelineFactory { + + // ==================== 通用 Stage(13个)==================== + @Autowired + private PrepareContextStage prepareContextStage; + @Autowired + private RecordMetricsStage recordMetricsStage; + @Autowired + private FaceRecognitionStage faceRecognitionStage; + @Autowired + private FaceRecoveryStage faceRecoveryStage; + @Autowired + private UpdateFaceResultStage updateFaceResultStage; + @Autowired + private BuildSourceRelationStage buildSourceRelationStage; + @Autowired + private ProcessFreeSourceStage processFreeSourceStage; + @Autowired + private ProcessBuyStatusStage processBuyStatusStage; + @Autowired + private HandleVideoRecreationStage handleVideoRecreationStage; + @Autowired + private PersistRelationsStage persistRelationsStage; + @Autowired + private CreateTaskStage createTaskStage; + @Autowired + private SetTaskStatusStage setTaskStatusStage; + @Autowired + private GeneratePuzzleStage generatePuzzleStage; + + // ==================== 自定义匹配专属 Stage(6个)==================== + @Autowired + private RecordCustomMatchMetricsStage recordCustomMatchMetricsStage; + @Autowired + private LoadFaceSamplesStage loadFaceSamplesStage; + @Autowired + private CustomFaceSearchStage customFaceSearchStage; + @Autowired + private LoadMatchedSamplesStage loadMatchedSamplesStage; + @Autowired + private FilterByTimeRangeStage filterByTimeRangeStage; + @Autowired + private FilterByDevicePhotoLimitStage filterByDevicePhotoLimitStage; + @Autowired + private DeleteOldRelationsStage deleteOldRelationsStage; + + // ==================== 辅助服务 ==================== + @Autowired + private ScenicConfigFacade scenicConfigFacade; + + /** + * 创建自动人脸匹配Pipeline + * + * @param isNew 是否新用户 + * @return Pipeline + */ + public Pipeline createAutoMatchingPipeline(boolean isNew) { + PipelineBuilder builder = new PipelineBuilder<>("AutoMatching-" + (isNew ? "New" : "Old")); + + // 1. 准备上下文 + builder.addStage(prepareContextStage); + + // 2. 新用户设置任务状态 + if (isNew) { + builder.addStage(setTaskStatusStage); + } + + // 3. 记录识别次数 + builder.addStage(recordMetricsStage); + + // 4. 执行人脸识别 + builder.addStage(faceRecognitionStage); + + // 5. 人脸识别补救 + builder.addStage(faceRecoveryStage); + + // 6. 更新人脸结果 + builder.addStage(updateFaceResultStage); + + // 7. 构建源文件关联 + builder.addStage(buildSourceRelationStage); + + // 8. 处理免费源文件逻辑 + builder.addStage(processFreeSourceStage); + + // 9. 处理购买状态 + builder.addStage(processBuyStatusStage); + + // 10. 处理视频重切 + builder.addStage(handleVideoRecreationStage); + + // 11. 持久化关联关系 + builder.addStage(persistRelationsStage); + + // 12. 创建任务 + builder.addStage(createTaskStage); + + // 13. 异步生成拼图模板 + builder.addStage(generatePuzzleStage); + + log.debug("创建自动人脸匹配Pipeline: isNew={}, stageCount={}", isNew, builder.build().getStageCount()); + + return builder.build(); + } + + /** + * 创建自定义人脸匹配Pipeline + * + * @return Pipeline + */ + public Pipeline createCustomMatchingPipeline() { + PipelineBuilder builder = new PipelineBuilder<>("CustomMatching"); + + // 1. 准备上下文 + builder.addStage(prepareContextStage); + + // 2. 记录自定义匹配次数 + builder.addStage(recordCustomMatchMetricsStage); + + // 3. 加载用户选择的人脸样本 + builder.addStage(loadFaceSamplesStage); + + // 4. 根据配置执行自定义人脸搜索 + builder.addStage(customFaceSearchStage); + + // 5. 加载匹配样本实体到缓存 + builder.addStage(loadMatchedSamplesStage); + + // 6. 应用时间范围筛选 + builder.addStage(filterByTimeRangeStage); + + // 7. 应用设备照片数量限制筛选 + builder.addStage(filterByDevicePhotoLimitStage); + + // 8. 更新人脸结果 + builder.addStage(updateFaceResultStage); + + // 9. 删除旧关系数据 + builder.addStage(deleteOldRelationsStage); + + // 10. 构建源文件关联 + builder.addStage(buildSourceRelationStage); + + // 11. 处理免费源文件逻辑 + builder.addStage(processFreeSourceStage); + + // 12. 处理购买状态 + builder.addStage(processBuyStatusStage); + + // 13. 处理视频重切 + builder.addStage(handleVideoRecreationStage); + + // 14. 持久化关联关系 + builder.addStage(persistRelationsStage); + + // 15. 创建任务 + builder.addStage(createTaskStage); + + log.debug("创建自定义人脸匹配Pipeline: stageCount={}", builder.build().getStageCount()); + + return builder.build(); + } + + /** + * 创建仅识别Pipeline + * 只执行人脸识别,不处理后续业务逻辑 + * + * @return Pipeline + */ + public Pipeline createRecognitionOnlyPipeline() { + PipelineBuilder builder = new PipelineBuilder<>("RecognitionOnly"); + + // 1. 准备上下文 + builder.addStage(prepareContextStage); + + // 2. 执行人脸识别 + builder.addStage(faceRecognitionStage); + + // 3. 人脸识别补救 + builder.addStage(faceRecoveryStage); + + log.debug("创建仅识别Pipeline: stageCount={}", builder.build().getStageCount()); + + return builder.build(); + } + + /** + * 根据场景创建Pipeline + * + * @param scene 场景 + * @param isNew 是否新用户(仅AUTO_MATCHING场景需要) + * @return Pipeline + */ + public Pipeline createPipeline(FaceMatchingScene scene, boolean isNew) { + return switch (scene) { + case AUTO_MATCHING -> createAutoMatchingPipeline(isNew); + case CUSTOM_MATCHING -> createCustomMatchingPipeline(); + case RECOGNITION_ONLY -> createRecognitionOnlyPipeline(); + }; + } + + /** + * 根据Context创建Pipeline + * + * @param context 上下文 + * @return Pipeline + */ + public Pipeline createPipeline(FaceMatchingContext context) { + return createPipeline(context.getScene(), context.isNew()); + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/helper/PuzzleGenerationOrchestrator.java b/src/main/java/com/ycwl/basic/face/pipeline/helper/PuzzleGenerationOrchestrator.java new file mode 100644 index 00000000..081d0dcc --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/helper/PuzzleGenerationOrchestrator.java @@ -0,0 +1,147 @@ +package com.ycwl.basic.face.pipeline.helper; + +import cn.hutool.core.date.DateUtil; +import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO; +import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest; +import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse; +import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO; +import com.ycwl.basic.puzzle.service.IPuzzleGenerateService; +import com.ycwl.basic.puzzle.service.IPuzzleTemplateService; +import com.ycwl.basic.repository.ScenicRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 拼图生成编排器 + * 负责编排拼图模板的批量生成逻辑 + * + * 职责: + * 1. 查询景区的所有启用拼图模板 + * 2. 构建动态数据 + * 3. 逐个生成拼图图片 + * 4. 记录统计信息 + * + * 设计说明: + * - 从GeneratePuzzleStage中抽离出来,符合"薄Stage,厚Service"原则 + * - Stage只负责触发异步任务,业务逻辑由此Orchestrator承担 + */ +@Slf4j +@Service +public class PuzzleGenerationOrchestrator { + + @Autowired + private IPuzzleTemplateService puzzleTemplateService; + + @Autowired + private IPuzzleGenerateService puzzleGenerateService; + + @Autowired + private ScenicRepository scenicRepository; + + /** + * 异步生成景区所有启用的拼图模板 + * + * @param scenicId 景区ID + * @param faceId 人脸ID + * @param memberId 会员ID + * @param faceUrl 人脸URL + */ + public void generateAllTemplatesAsync(Long scenicId, Long faceId, Long memberId, String faceUrl) { + new Thread(() -> { + try { + log.info("开始异步生成景区拼图模板: scenicId={}, faceId={}", scenicId, faceId); + + // 1. 查询该景区所有启用状态的拼图模板 + List templateList = puzzleTemplateService.listTemplates( + scenicId, null, 1); // 查询启用状态的模板 + + if (templateList == null || templateList.isEmpty()) { + log.info("景区不存在启用的拼图模板,跳过生成: scenicId={}", scenicId); + return; + } + + log.info("景区存在 {} 个启用的拼图模板,开始逐个生成: scenicId={}", templateList.size(), scenicId); + + // 2. 获取景区信息用于动态数据 + ScenicV2DTO scenicBasic = scenicRepository.getScenicBasic(scenicId); + + // 3. 准备公共动态数据 + Map baseDynamicData = buildBaseDynamicData(faceId, faceUrl, scenicBasic); + + // 4. 遍历所有模板,逐个生成 + int successCount = 0; + int failCount = 0; + for (PuzzleTemplateDTO template : templateList) { + try { + generateSingleTemplate(scenicId, faceId, memberId, template, baseDynamicData); + successCount++; + } catch (Exception e) { + log.error("拼图生成失败: scenicId={}, templateCode={}, templateName={}", + scenicId, template.getCode(), template.getName(), e); + failCount++; + } + } + + log.info("景区拼图模板批量生成完成: scenicId={}, 总数={}, 成功={}, 失败={}", + scenicId, templateList.size(), successCount, failCount); + + } catch (Exception e) { + // 异步任务失败不影响主流程,仅记录日志 + log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e); + } + }, "PuzzleTemplateGenerator-" + scenicId + "-" + faceId).start(); + } + + /** + * 构建基础动态数据 + */ + private Map buildBaseDynamicData(Long faceId, String faceUrl, ScenicV2DTO scenicBasic) { + Map baseDynamicData = new HashMap<>(); + + if (faceUrl != null) { + baseDynamicData.put("faceImage", faceUrl); + baseDynamicData.put("userAvatar", faceUrl); + } + + baseDynamicData.put("faceId", String.valueOf(faceId)); + baseDynamicData.put("scenicName", scenicBasic.getName()); + baseDynamicData.put("scenicText", scenicBasic.getName()); + baseDynamicData.put("dateStr", DateUtil.format(new Date(), "yyyy.MM.dd")); + + return baseDynamicData; + } + + /** + * 生成单个拼图模板 + */ + private void generateSingleTemplate(Long scenicId, Long faceId, Long memberId, + PuzzleTemplateDTO template, + Map baseDynamicData) { + log.info("开始生成拼图: scenicId={}, templateCode={}, templateName={}", + scenicId, template.getCode(), template.getName()); + + // 构建生成请求 + PuzzleGenerateRequest generateRequest = new PuzzleGenerateRequest(); + generateRequest.setScenicId(scenicId); + generateRequest.setUserId(memberId); + generateRequest.setFaceId(faceId); + generateRequest.setBusinessType("face_matching"); + generateRequest.setTemplateCode(template.getCode()); + generateRequest.setOutputFormat("PNG"); + generateRequest.setQuality(90); + generateRequest.setDynamicData(new HashMap<>(baseDynamicData)); + generateRequest.setRequireRuleMatch(true); + + // 调用拼图生成服务 + PuzzleGenerateResponse response = puzzleGenerateService.generate(generateRequest); + + log.info("拼图生成成功: scenicId={}, templateCode={}, imageUrl={}", + scenicId, template.getCode(), response.getImageUrl()); + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/BuildSourceRelationStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/BuildSourceRelationStage.java new file mode 100644 index 00000000..f1f56355 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/BuildSourceRelationStage.java @@ -0,0 +1,92 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity; +import com.ycwl.basic.service.pc.processor.SourceRelationProcessor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 构建源文件关联Stage + * 负责根据匹配到的样本ID构建member_source关联关系 + * + * 职责: + * 1. 从context.sampleListIds读取匹配的样本ID列表 + * 2. 调用sourceRelationProcessor.processMemberSources()生成MemberSourceEntity列表 + * 3. 更新context.memberSourceList + * + * 前置条件: context.sampleListIds不为空 + * 后置条件: context.memberSourceList已设置 + */ +@Slf4j +@Component +@StageConfig( + stageId = "build_source_relation", + optionalMode = StageOptionalMode.UNSUPPORT, + description = "构建源文件关联关系" +) +public class BuildSourceRelationStage extends AbstractFaceMatchingStage { + + @Autowired + private SourceRelationProcessor sourceRelationProcessor; + + @Override + public String getName() { + return "BuildSourceRelation"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 只有当sampleListIds不为空时才执行 + List sampleListIds = context.getSampleListIds(); + if (sampleListIds == null || sampleListIds.isEmpty()) { + // 从searchResult中获取 + if (context.getSearchResult() != null) { + sampleListIds = context.getSearchResult().getSampleListIds(); + context.setSampleListIds(sampleListIds); + } + } + + if (sampleListIds == null || sampleListIds.isEmpty()) { + log.debug("sampleListIds为空,跳过源文件关联,faceId={}", context.getFaceId()); + return false; + } + + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + List sampleListIds = context.getSampleListIds(); + Long faceId = context.getFaceId(); + + try { + // 处理源文件关联 + List memberSourceEntityList = + sourceRelationProcessor.processMemberSources(sampleListIds, context.getFace()); + + if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) { + log.warn("未找到有效的源文件,faceId={}, sampleListIds={}", faceId, sampleListIds); + return StageResult.skipped("未找到有效的源文件"); + } + + context.setMemberSourceList(memberSourceEntityList); + + log.info("构建源文件关联成功: faceId={}, 关联源文件数={}", faceId, memberSourceEntityList.size()); + + return StageResult.success(String.format("构建了%d个源文件关联", memberSourceEntityList.size())); + + } catch (Exception e) { + log.error("构建源文件关联失败,faceId={}, sampleListIds={}", faceId, sampleListIds, e); + // 源文件关联失败不影响主流程,返回降级 + return StageResult.degraded("构建源文件关联失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/CreateTaskStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/CreateTaskStage.java new file mode 100644 index 00000000..5f0aed0d --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/CreateTaskStage.java @@ -0,0 +1,73 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.biz.TaskStatusBiz; +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.service.pc.helper.ScenicConfigFacade; +import com.ycwl.basic.service.task.TaskService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 创建任务Stage + * 负责根据配置决定是否自动创建任务 + * + * 职责: + * 1. 检查face_select_first配置 + * 2. 如果配置为false,则调用taskService.autoCreateTaskByFaceId() + * 3. 如果配置为true,则设置任务状态为2(等待用户选择) + */ +@Slf4j +@Component +@StageConfig( + stageId = "create_task", + optionalMode = StageOptionalMode.UNSUPPORT, + description = "根据配置创建视频任务" +) +public class CreateTaskStage extends AbstractFaceMatchingStage { + + @Autowired + private ScenicConfigFacade scenicConfigFacade; + + @Autowired + private TaskService taskService; + + @Autowired + private TaskStatusBiz taskStatusBiz; + + @Override + public String getName() { + return "CreateTask"; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + Long scenicId = context.getFace().getScenicId(); + Long faceId = context.getFaceId(); + + try { + boolean faceSelectFirst = scenicConfigFacade.isFaceSelectFirst(scenicId); + + if (!faceSelectFirst) { + // 配置为自动创建任务 + taskService.autoCreateTaskByFaceId(faceId); + log.info("自动创建任务成功: faceId={}", faceId); + return StageResult.success("自动创建任务成功"); + } else { + // 配置为等待用户选择 + taskStatusBiz.setFaceCutStatus(faceId, 2); + log.debug("景区配置 face_select_first=true,跳过自动创建任务: faceId={}", faceId); + return StageResult.skipped("等待用户手动选择"); + } + + } catch (Exception e) { + log.error("创建任务失败,faceId={}", faceId, e); + // 任务创建失败不影响主流程,返回降级而不是失败 + return StageResult.degraded("任务创建失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/CustomFaceSearchStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/CustomFaceSearchStage.java new file mode 100644 index 00000000..1b4504d6 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/CustomFaceSearchStage.java @@ -0,0 +1,121 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.exception.BaseException; +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; +import com.ycwl.basic.model.task.resp.SearchFaceRespVo; +import com.ycwl.basic.service.pc.helper.SearchResultMerger; +import com.ycwl.basic.service.task.TaskFaceService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 自定义人脸搜索Stage + * 负责根据faceSelectPostMode执行不同的搜索策略 + * + * 职责: + * 1. 从context.faceSelectPostMode读取配置 + * 2. 模式2: 直接使用用户选择的样本,不搜索 + * 3. 模式0/1: 对每个样本搜索,然后合并结果 + * 4. 更新context.searchResult + */ +@Slf4j +@Component +@StageConfig( + stageId = "custom_face_search", + optionalMode = StageOptionalMode.FORCE_ON, + description = "根据配置执行自定义人脸搜索" +) +public class CustomFaceSearchStage extends AbstractFaceMatchingStage { + + @Autowired + private TaskFaceService taskFaceService; + + @Autowired + private SearchResultMerger resultMerger; + + @Override + public String getName() { + return "CustomFaceSearch"; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + Integer faceSelectPostMode = context.getFaceSelectPostMode(); + List faceSamples = context.getFaceSamples(); + List faceSampleIds = context.getFaceSampleIds(); + Long faceId = context.getFaceId(); + + if (faceSelectPostMode == null) { + faceSelectPostMode = 0; // 默认为并集模式 + } + + log.debug("face_select_post_mode配置值: {}, faceId={}", faceSelectPostMode, faceId); + + try { + SearchFaceRespVo mergedResult; + + // 模式2:不搜索,直接使用用户选择的faceSampleIds + if (Integer.valueOf(2).equals(faceSelectPostMode)) { + log.debug("使用模式2:直接使用用户选择的人脸样本,不进行搜索,faceId={}", faceId); + mergedResult = resultMerger.createDirectResult(faceSampleIds); + // 保留原始matchResult + if (context.getFace().getMatchResult() != null) { + mergedResult.setSearchResultJson(context.getFace().getMatchResult()); + } + } else { + // 模式0(并集)和模式1(交集):需要进行搜索 + List searchResults = new ArrayList<>(); + + for (FaceSampleEntity faceSample : faceSamples) { + try { + SearchFaceRespVo result = taskFaceService.searchFace( + context.getFaceBodyAdapter(), + String.valueOf(context.getFace().getScenicId()), + faceSample.getFaceUrl(), + "自定义人脸匹配"); + + if (result != null) { + searchResults.add(result); + } + } catch (Exception e) { + log.warn("人脸样本搜索失败,faceSampleId={}, faceUrl={}, faceId={}", + faceSample.getId(), faceSample.getFaceUrl(), faceId, e); + // 继续处理其他样本,不中断整个流程 + } + } + + if (searchResults.isEmpty()) { + log.warn("所有人脸样本搜索都失败,faceId={}, faceSampleIds={}", faceId, faceSampleIds); + throw new BaseException("人脸识别失败,请重试"); + } + + // 根据模式整合多个搜索结果 + mergedResult = resultMerger.merge(searchResults, faceSelectPostMode); + } + + context.setSearchResult(mergedResult); + context.setSampleListIds(mergedResult.getSampleListIds()); + + log.info("自定义人脸搜索完成: faceId={}, mode={}, 匹配数={}", + faceId, faceSelectPostMode, + mergedResult.getSampleListIds() != null ? mergedResult.getSampleListIds().size() : 0); + + return StageResult.success(String.format("自定义搜索完成,模式=%d", faceSelectPostMode)); + + } catch (BaseException e) { + throw e; + } catch (Exception e) { + log.error("自定义人脸搜索失败,faceId={}, faceSampleIds={}", faceId, faceSampleIds, e); + return StageResult.failed("自定义人脸搜索失败", e); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/DeleteOldRelationsStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/DeleteOldRelationsStage.java new file mode 100644 index 00000000..9690e1f7 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/DeleteOldRelationsStage.java @@ -0,0 +1,74 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.mapper.SourceMapper; +import com.ycwl.basic.mapper.VideoMapper; +import com.ycwl.basic.repository.MemberRelationRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 删除旧关系Stage + * 负责在保存新关系前,删除该人脸的旧数据关系 + * + * 职责: + * 1. 删除member_source中该人脸的未购买关系 + * 2. 删除member_video中该人脸的未购买关系 + * 3. 清除缓存 + */ +@Slf4j +@Component +@StageConfig( + stageId = "delete_old_relations", + optionalMode = StageOptionalMode.FORCE_ON, + description = "删除人脸旧关系数据" +) +public class DeleteOldRelationsStage extends AbstractFaceMatchingStage { + + @Autowired + private SourceMapper sourceMapper; + + @Autowired + private VideoMapper videoMapper; + + @Autowired + private MemberRelationRepository memberRelationRepository; + + @Override + public String getName() { + return "DeleteOldRelations"; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + Long faceId = context.getFaceId(); + Long memberId = context.getFace().getMemberId(); + + try { + log.debug("删除人脸旧关系数据:faceId={}, memberId={}", faceId, memberId); + + // 1. 删除member_source中的未购买关系 + sourceMapper.deleteNotBuyFaceRelation(memberId, faceId); + + // 2. 删除member_video中的未购买关系 + videoMapper.deleteNotBuyFaceRelations(memberId, faceId); + + // 3. 清除缓存 + memberRelationRepository.clearSCacheByFace(faceId); + + log.debug("人脸旧关系数据删除完成:faceId={}", faceId); + + return StageResult.success("旧关系数据已删除"); + + } catch (Exception e) { + log.error("删除旧关系数据失败,faceId={}", faceId, e); + // 删除失败不影响主流程,返回降级 + return StageResult.degraded("删除旧关系数据失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/FaceRecognitionStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/FaceRecognitionStage.java new file mode 100644 index 00000000..a874a3e0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/FaceRecognitionStage.java @@ -0,0 +1,73 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.exception.BaseException; +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.model.task.resp.SearchFaceRespVo; +import com.ycwl.basic.service.task.TaskFaceService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 人脸识别Stage + * 负责执行核心的人脸识别搜索 + * + * 职责: + * 1. 调用taskFaceService.searchFace()执行人脸搜索 + * 2. 将结果存入context.searchResult + * 3. 识别失败则返回FAILED + */ +@Slf4j +@Component +@StageConfig( + stageId = "face_recognition", + optionalMode = StageOptionalMode.FORCE_ON, + description = "执行人脸识别搜索" +) +public class FaceRecognitionStage extends AbstractFaceMatchingStage { + + @Autowired + private TaskFaceService taskFaceService; + + @Override + public String getName() { + return "FaceRecognition"; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + try { + SearchFaceRespVo searchResult = taskFaceService.searchFace( + context.getFaceBodyAdapter(), + String.valueOf(context.getFace().getScenicId()), + context.getFace().getFaceUrl(), + "人脸识别"); + + if (searchResult == null) { + log.warn("人脸识别返回结果为空,faceId={}", context.getFaceId()); + return StageResult.failed("人脸识别失败,请换一张试试把~"); + } + + context.setSearchResult(searchResult); + + log.info("人脸识别完成: faceId={}, score={}, 匹配数={}", + context.getFaceId(), + searchResult.getScore(), + searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0); + + return StageResult.success(String.format("识别成功,匹配数=%d", + searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0)); + + } catch (BaseException e) { + throw e; + } catch (Exception e) { + log.error("人脸识别服务调用失败,faceId={}, scenicId={}", + context.getFaceId(), context.getFace().getScenicId(), e); + return StageResult.failed("人脸识别失败,请换一张试试把~", e); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/FaceRecoveryStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/FaceRecoveryStage.java new file mode 100644 index 00000000..fe40ec79 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/FaceRecoveryStage.java @@ -0,0 +1,80 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.model.task.resp.SearchFaceRespVo; +import com.ycwl.basic.service.pc.processor.FaceRecoveryStrategy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 人脸识别补救Stage + * 负责执行人脸识别的补救逻辑(降级) + * + * 职责: + * 1. 从context.searchResult读取识别结果 + * 2. 调用faceRecoveryStrategy.executeFaceRecoveryLogic()执行补救 + * 3. 如果触发补救,更新searchResult并返回DEGRADED + * 4. 否则返回SUCCESS + */ +@Slf4j +@Component +@StageConfig( + stageId = "face_recovery", + optionalMode = StageOptionalMode.SUPPORT, + description = "执行人脸识别补救逻辑", + defaultEnabled = true +) +public class FaceRecoveryStage extends AbstractFaceMatchingStage { + + @Autowired + private FaceRecoveryStrategy faceRecoveryStrategy; + + @Override + public String getName() { + return "FaceRecovery"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 只有当searchResult不为空时才执行 + if (context.getSearchResult() == null) { + log.debug("searchResult为空,跳过补救逻辑,faceId={}", context.getFaceId()); + return false; + } + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + SearchFaceRespVo searchResult = context.getSearchResult(); + Long faceId = context.getFaceId(); + + try { + // 执行补救逻辑(补救逻辑内部会判断是否需要触发) + SearchFaceRespVo recoveredResult = faceRecoveryStrategy.executeFaceRecoveryLogic( + searchResult, + context.getScenicConfig(), + context.getFaceBodyAdapter(), + context.getFace().getScenicId()); + + // 如果结果发生变化,说明触发了补救 + if (recoveredResult != searchResult) { + context.setSearchResult(recoveredResult); + log.info("触发补救逻辑,重新搜索: faceId={}", faceId); + return StageResult.degraded("触发补救逻辑,重新搜索"); + } + + return StageResult.success("无需补救"); + + } catch (Exception e) { + log.error("补救逻辑执行失败,faceId={}", faceId, e); + // 补救失败不影响主流程,返回降级 + return StageResult.degraded("补救逻辑执行失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByDevicePhotoLimitStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByDevicePhotoLimitStage.java new file mode 100644 index 00000000..b594822a --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByDevicePhotoLimitStage.java @@ -0,0 +1,223 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.integration.common.manager.DeviceConfigManager; +import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; +import com.ycwl.basic.repository.DeviceRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 按设备照片数量限制筛选样本Stage + * 负责根据设备配置的照片数量限制(limit_photo)筛选匹配样本 + * + * 职责: + * 1. 从context.faceSamples读取样本实体缓存 + * 2. 按设备ID分组 + * 3. 对每个设备,根据其limit_photo配置筛选样本: + * - 如果样本数 > limit_photo + 2: 按时间排序,去掉首尾,保留中间limit_photo张 + * - 如果样本数 > limit_photo + 1: 按时间排序,去掉尾部,保留前limit_photo张 + * - 如果样本数 > limit_photo: 保留前limit_photo张 + * - 否则: 保留全部 + * 4. 更新context.sampleListIds + * + * 前置条件: context.faceSamples不为空 (由LoadMatchedSamplesStage加载) + * 配置说明: limit_photo=0或null表示不限制数量 + */ +@Slf4j +@Component +@StageConfig( + stageId = "filter_by_device_photo_limit", + optionalMode = StageOptionalMode.SUPPORT, + description = "按设备照片数量限制筛选样本", + defaultEnabled = true +) +public class FilterByDevicePhotoLimitStage extends AbstractFaceMatchingStage { + + @Autowired + private DeviceRepository deviceRepository; + + @Override + public String getName() { + return "FilterByDevicePhotoLimit"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 检查faceSamples是否为空 + List faceSamples = context.getFaceSamples(); + if (faceSamples == null || faceSamples.isEmpty()) { + log.debug("faceSamples为空,跳过设备照片限制筛选,faceId={}", context.getFaceId()); + return false; + } + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + List faceSamples = context.getFaceSamples(); + List sampleListIds = context.getSampleListIds(); + Long faceId = context.getFaceId(); + + try { + // 1. 构建样本ID到实体的映射 + Map sampleMap = faceSamples.stream() + .collect(Collectors.toMap(FaceSampleEntity::getId, sample -> sample, (a, b) -> a)); + + // 2. 按设备ID分组 + Map> deviceSamplesMap = new LinkedHashMap<>(); + Set passthroughSampleIds = new LinkedHashSet<>(); + + for (Long sampleId : sampleListIds) { + FaceSampleEntity sample = sampleMap.get(sampleId); + if (sample == null || sample.getDeviceId() == null) { + passthroughSampleIds.add(sampleId); // 无设备ID的样本直接保留 + continue; + } + deviceSamplesMap + .computeIfAbsent(sample.getDeviceId(), key -> new ArrayList<>()) + .add(sample); + } + + // 3. 对每个设备应用照片数量限制 + Map limitCache = new HashMap<>(); + Set retainedSampleIds = new LinkedHashSet<>(passthroughSampleIds); + + for (Map.Entry> entry : deviceSamplesMap.entrySet()) { + Long deviceId = entry.getKey(); + List deviceSamples = entry.getValue(); + + // 读取设备配置 + Integer limitPhoto = limitCache.computeIfAbsent(deviceId, id -> { + DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(id); + return deviceConfig != null ? deviceConfig.getInteger("limit_photo") : null; + }); + + List retainedForDevice = applyLimitForDevice(deviceId, deviceSamples, limitPhoto); + retainedSampleIds.addAll(retainedForDevice); + } + + // 4. 按原始顺序保留筛选后的样本ID + List resultIds = sampleListIds.stream() + .filter(retainedSampleIds::contains) + .collect(Collectors.toList()); + + // 5. 更新context + context.setSampleListIds(resultIds); + + log.info("设备照片数量限制筛选完成: faceId={}, 原始样本数={}, 筛选后数={}", + faceId, sampleListIds.size(), resultIds.size()); + + return StageResult.success(String.format("设备限制筛选: %d → %d", + sampleListIds.size(), resultIds.size())); + + } catch (Exception e) { + log.error("设备照片数量限制筛选失败,faceId={}", faceId, e); + // 筛选失败不影响主流程,返回降级 + return StageResult.degraded("设备照片数量限制筛选失败: " + e.getMessage()); + } + } + + /** + * 对单个设备的样本应用照片数量限制 + */ + private List applyLimitForDevice(Long deviceId, List deviceSamples, Integer limitPhoto) { + List deviceSampleIds = deviceSamples.stream() + .map(FaceSampleEntity::getId) + .collect(Collectors.toList()); + + // 无限制或限制数量<=0,保留全部 + if (limitPhoto == null || limitPhoto <= 0) { + log.debug("设备照片限制: 设备ID={}, 无限制, 保留{}张照片", deviceId, deviceSampleIds.size()); + return deviceSampleIds; + } + + int sampleCount = deviceSamples.size(); + + // 样本数 > limit_photo + 2: 按时间排序,去掉首尾 + if (sampleCount > (limitPhoto + 2)) { + List retained = processDeviceSamples(deviceSamples, limitPhoto, true); + log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 去首尾后最终{}张", + deviceId, limitPhoto, sampleCount, retained.size()); + return retained; + } + + // 样本数 > limit_photo + 1: 按时间排序,去掉尾部 + if (sampleCount > (limitPhoto + 1)) { + List retained = processDeviceSamples(deviceSamples, limitPhoto, false); + log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 去尾部后最终{}张", + deviceId, limitPhoto, sampleCount, retained.size()); + return retained; + } + + // 样本数 > limit_photo: 保留前limit_photo张 + if (sampleCount > limitPhoto) { + List retained = deviceSamples.stream() + .limit(limitPhoto) + .map(FaceSampleEntity::getId) + .collect(Collectors.toList()); + log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 取前{}张", + deviceId, limitPhoto, sampleCount, retained.size()); + return retained; + } + + // 样本数 <= limit_photo: 保留全部 + log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 无需筛选, 保留全部", + deviceId, limitPhoto, sampleCount); + return deviceSampleIds; + } + + /** + * 处理设备样本,根据参数决定是否去掉首尾 + * + * @param deviceSamples 设备样本列表 + * @param limitPhoto 限制数量 + * @param removeBoth 是否去掉首尾,true去掉首尾,false只去掉尾部 + * @return 处理后的样本ID列表 + */ + private List processDeviceSamples(List deviceSamples, int limitPhoto, boolean removeBoth) { + // 创建原始排序的索引映射,用于后续恢复排序 + Map originalIndexMap = new HashMap<>(); + for (int i = 0; i < deviceSamples.size(); i++) { + originalIndexMap.put(deviceSamples.get(i).getId(), i); + } + + // 按创建时间排序 + List sortedByCreateTime = deviceSamples.stream() + .sorted(Comparator.comparing(FaceSampleEntity::getCreateAt)) + .collect(Collectors.toList()); + + // 根据参数决定去掉首尾还是只去掉尾部 + List filteredSamples; + if (removeBoth && sortedByCreateTime.size() > 2) { + // 去掉首尾 + filteredSamples = sortedByCreateTime.subList(1, sortedByCreateTime.size() - 1); + } else if (!removeBoth && sortedByCreateTime.size() > 1) { + // 只去掉尾部 + filteredSamples = sortedByCreateTime.subList(0, sortedByCreateTime.size() - 1); + } else { + filteredSamples = sortedByCreateTime; + } + + // 取前limitPhoto个 + List limitedSamples = filteredSamples.stream() + .limit(limitPhoto) + .collect(Collectors.toList()); + + // 按原始顺序排序 + List resultIds = limitedSamples.stream() + .sorted(Comparator.comparing(sample -> originalIndexMap.get(sample.getId()))) + .map(FaceSampleEntity::getId) + .collect(Collectors.toList()); + + return resultIds; + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByTimeRangeStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByTimeRangeStage.java new file mode 100644 index 00000000..e316442e --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/FilterByTimeRangeStage.java @@ -0,0 +1,122 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 按时间范围筛选样本Stage + * 负责根据景区配置的游览时间(tour_time)筛选匹配样本 + * + * 职责: + * 1. 从context.scenicConfig读取tour_time配置(分钟) + * 2. 从context.faceSamples读取样本实体缓存 + * 3. 找到最新的样本,以其拍摄时间为基准 + * 4. 筛选出时间范围内(最新样本时间 ± tour_time分钟)的样本 + * 5. 更新context.sampleListIds + * + * 前置条件: + * - context.faceSamples不为空 (由LoadMatchedSamplesStage加载) + * - context.scenicConfig配置了tour_time + * + * 配置说明: tour_time=0或null表示不限制时间范围 + */ +@Slf4j +@Component +@StageConfig( + stageId = "filter_by_time_range", + optionalMode = StageOptionalMode.SUPPORT, + description = "按游览时间范围筛选样本", + defaultEnabled = true +) +public class FilterByTimeRangeStage extends AbstractFaceMatchingStage { + + @Override + public String getName() { + return "FilterByTimeRange"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 检查faceSamples是否为空 + List faceSamples = context.getFaceSamples(); + if (faceSamples == null || faceSamples.isEmpty()) { + log.debug("faceSamples为空,跳过时间范围筛选,faceId={}", context.getFaceId()); + return false; + } + + // 检查是否配置了tour_time + Integer tourMinutes = context.getScenicConfig() != null + ? context.getScenicConfig().getInteger("tour_time") + : null; + if (tourMinutes == null || tourMinutes <= 0) { + log.debug("景区未配置tour_time或配置为0,跳过时间范围筛选,faceId={}", context.getFaceId()); + return false; + } + + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + List faceSamples = context.getFaceSamples(); + List sampleListIds = context.getSampleListIds(); + Integer tourMinutes = context.getScenicConfig().getInteger("tour_time"); + Long faceId = context.getFaceId(); + + try { + // 1. 构建样本ID到实体的映射 + Map sampleMap = faceSamples.stream() + .collect(Collectors.toMap(FaceSampleEntity::getId, sample -> sample, (a, b) -> a)); + + // 2. 找到最新的样本(拍摄时间最晚) + FaceSampleEntity topMatchSample = faceSamples.stream() + .filter(sample -> sample.getCreateAt() != null) + .max(Comparator.comparing(FaceSampleEntity::getCreateAt)) + .orElse(null); + + if (topMatchSample == null || topMatchSample.getCreateAt() == null) { + log.warn("未找到有效的样本拍摄时间,保留所有样本,faceId={}", faceId); + return StageResult.success("样本无拍摄时间,保留所有"); + } + + Date referenceTime = topMatchSample.getCreateAt(); + long referenceMillis = referenceTime.getTime(); + long tourMillis = tourMinutes * 60 * 1000L; + + // 3. 筛选时间范围内的样本 + List filteredIds = sampleListIds.stream() + .filter(sampleId -> { + FaceSampleEntity sample = sampleMap.get(sampleId); + if (sample == null || sample.getCreateAt() == null) { + return false; // 无时间信息的样本被过滤 + } + long timeDiff = Math.abs(sample.getCreateAt().getTime() - referenceMillis); + return timeDiff <= tourMillis; + }) + .collect(Collectors.toList()); + + // 4. 更新context + context.setSampleListIds(filteredIds); + + log.info("时间范围筛选完成: faceId={}, tour_time={}分钟, 原始样本数={}, 筛选后数={}", + faceId, tourMinutes, sampleListIds.size(), filteredIds.size()); + + return StageResult.success(String.format("时间筛选: %d → %d", + sampleListIds.size(), filteredIds.size())); + + } catch (Exception e) { + log.error("时间范围筛选失败,faceId={}", faceId, e); + // 筛选失败不影响主流程,返回降级 + return StageResult.degraded("时间范围筛选失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/GeneratePuzzleStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/GeneratePuzzleStage.java new file mode 100644 index 00000000..eb48d0fa --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/GeneratePuzzleStage.java @@ -0,0 +1,66 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.face.pipeline.helper.PuzzleGenerationOrchestrator; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 生成拼图模板Stage + * 负责触发景区拼图模板的异步生成任务 + * + * 职责: + * 1. 从context读取必要参数(scenicId, faceId, memberId, faceUrl) + * 2. 调用puzzleOrchestrator.generateAllTemplatesAsync()触发异步生成 + * 3. 立即返回,不等待生成完成 + * + * 业务说明: + * - 拼图生成是异步的,不影响主流程 + * - 具体的拼图生成逻辑由PuzzleGenerationOrchestrator负责 + * - Stage只负责触发任务,符合"薄Stage,厚Service"原则 + */ +@Slf4j +@Component +@StageConfig( + stageId = "generate_puzzle", + optionalMode = StageOptionalMode.SUPPORT, + description = "异步生成拼图模板", + defaultEnabled = true +) +public class GeneratePuzzleStage extends AbstractFaceMatchingStage { + + @Autowired + private PuzzleGenerationOrchestrator puzzleOrchestrator; + + @Override + public String getName() { + return "GeneratePuzzle"; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + Long scenicId = context.getFace().getScenicId(); + Long faceId = context.getFaceId(); + Long memberId = context.getFace().getMemberId(); + String faceUrl = context.getFace().getFaceUrl(); + + try { + // 触发异步生成拼图模板 + puzzleOrchestrator.generateAllTemplatesAsync(scenicId, faceId, memberId, faceUrl); + + log.debug("拼图模板异步生成任务已提交: scenicId={}, faceId={}", scenicId, faceId); + + return StageResult.success("拼图模板已提交异步生成"); + + } catch (Exception e) { + log.error("提交拼图生成任务失败: scenicId={}, faceId={}", scenicId, faceId, e); + // 拼图生成失败不影响主流程,返回降级 + return StageResult.degraded("提交拼图生成任务失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/HandleVideoRecreationStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/HandleVideoRecreationStage.java new file mode 100644 index 00000000..96c31041 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/HandleVideoRecreationStage.java @@ -0,0 +1,86 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity; +import com.ycwl.basic.service.pc.processor.VideoRecreationHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 处理视频重切Stage + * 负责触发视频重新切片处理 + * + * 职责: + * 1. 从context读取必要参数(scenicId, memberSourceList, faceId, memberId, sampleListIds, isNew) + * 2. 调用videoRecreationHandler.handleVideoRecreation()触发视频重切 + * + * 前置条件: context.memberSourceList不为空 + * 业务说明: 视频重切用于根据人脸识别结果重新生成个性化视频片段 + */ +@Slf4j +@Component +@StageConfig( + stageId = "handle_video_recreation", + optionalMode = StageOptionalMode.SUPPORT, + description = "处理视频重切逻辑", + defaultEnabled = true +) +public class HandleVideoRecreationStage extends AbstractFaceMatchingStage { + + @Autowired + private VideoRecreationHandler videoRecreationHandler; + + @Override + public String getName() { + return "HandleVideoRecreation"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 只有当memberSourceList不为空时才执行 + List memberSourceList = context.getMemberSourceList(); + if (memberSourceList == null || memberSourceList.isEmpty()) { + log.debug("memberSourceList为空,跳过视频重切,faceId={}", context.getFaceId()); + return false; + } + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + Long scenicId = context.getFace().getScenicId(); + List memberSourceEntityList = context.getMemberSourceList(); + Long faceId = context.getFaceId(); + Long memberId = context.getFace().getMemberId(); + List sampleListIds = context.getSampleListIds(); + boolean isNew = context.isNew(); + + try { + // 处理视频重切 + videoRecreationHandler.handleVideoRecreation( + scenicId, + memberSourceEntityList, + faceId, + memberId, + sampleListIds, + isNew); + + log.info("视频重切处理完成: faceId={}, scenicId={}, 源文件数={}", + faceId, scenicId, memberSourceEntityList.size()); + + return StageResult.success("视频重切处理完成"); + + } catch (Exception e) { + log.error("处理视频重切失败,faceId={}", faceId, e); + // 视频重切失败不影响主流程,返回降级 + return StageResult.degraded("视频重切处理失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/LoadFaceSamplesStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/LoadFaceSamplesStage.java new file mode 100644 index 00000000..dc363fd3 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/LoadFaceSamplesStage.java @@ -0,0 +1,73 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.exception.BaseException; +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.mapper.FaceSampleMapper; +import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 加载人脸样本Stage + * 负责加载用户选择的人脸样本数据 + * + * 职责: + * 1. 从context.faceSampleIds读取用户选择的样本ID列表 + * 2. 调用faceSampleMapper.listByIds()加载样本实体 + * 3. 更新context.faceSamples + */ +@Slf4j +@Component +@StageConfig( + stageId = "load_face_samples", + optionalMode = StageOptionalMode.FORCE_ON, + description = "加载用户选择的人脸样本" +) +public class LoadFaceSamplesStage extends AbstractFaceMatchingStage { + + @Autowired + private FaceSampleMapper faceSampleMapper; + + @Override + public String getName() { + return "LoadFaceSamples"; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + List faceSampleIds = context.getFaceSampleIds(); + Long faceId = context.getFaceId(); + + if (faceSampleIds == null || faceSampleIds.isEmpty()) { + log.warn("faceSampleIds为空,faceId={}", faceId); + return StageResult.failed("faceSampleIds不能为空"); + } + + try { + List faceSamples = faceSampleMapper.listByIds(faceSampleIds); + + if (faceSamples.isEmpty()) { + log.warn("未找到指定的人脸样本,faceSampleIds: {}, faceId={}", faceSampleIds, faceId); + throw new BaseException("未找到指定的人脸样本"); + } + + context.setFaceSamples(faceSamples); + + log.info("加载人脸样本成功: faceId={}, sampleCount={}", faceId, faceSamples.size()); + return StageResult.success(String.format("加载了%d个人脸样本", faceSamples.size())); + + } catch (BaseException e) { + throw e; + } catch (Exception e) { + log.error("加载人脸样本失败,faceId={}, faceSampleIds={}", faceId, faceSampleIds, e); + return StageResult.failed("加载人脸样本失败", e); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/LoadMatchedSamplesStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/LoadMatchedSamplesStage.java new file mode 100644 index 00000000..27a27bfe --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/LoadMatchedSamplesStage.java @@ -0,0 +1,89 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.mapper.FaceSampleMapper; +import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 加载匹配样本实体Stage + * 负责将sampleListIds对应的样本实体加载到context.faceSamples,供后续Stage使用 + * + * 职责: + * 1. 从context.sampleListIds读取匹配到的样本ID列表 + * 2. 调用faceSampleMapper.listByIds()批量加载样本实体 + * 3. 更新context.faceSamples作为样本实体缓存 + * + * 设计目的: + * - 避免后续多个Stage重复调用faceSampleMapper.listByIds() + * - 统一加载时机,提高性能 + * - 为后续筛选Stage提供样本实体数据源 + * + * 前置条件: context.sampleListIds不为空 + * + * 应用场景: 自定义匹配场景,在CustomFaceSearchStage之后 + */ +@Slf4j +@Component +@StageConfig( + stageId = "load_matched_samples", + optionalMode = StageOptionalMode.UNSUPPORT, + description = "加载匹配样本实体到缓存" +) +public class LoadMatchedSamplesStage extends AbstractFaceMatchingStage { + + @Autowired + private FaceSampleMapper faceSampleMapper; + + @Override + public String getName() { + return "LoadMatchedSamples"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 检查sampleListIds是否为空 + List sampleListIds = context.getSampleListIds(); + if (sampleListIds == null || sampleListIds.isEmpty()) { + log.debug("sampleListIds为空,跳过加载匹配样本,faceId={}", context.getFaceId()); + return false; + } + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + List sampleListIds = context.getSampleListIds(); + Long faceId = context.getFaceId(); + + try { + // 批量加载样本实体 + List faceSamples = faceSampleMapper.listByIds(sampleListIds); + + if (faceSamples == null || faceSamples.isEmpty()) { + log.warn("未找到任何匹配样本实体,faceId={}, sampleListIds={}", faceId, sampleListIds); + return StageResult.skipped("未找到匹配样本实体"); + } + + // 存入context缓存,供后续Stage使用 + context.setFaceSamples(faceSamples); + + log.info("加载匹配样本实体完成: faceId={}, 样本数={}", faceId, faceSamples.size()); + + return StageResult.success(String.format("已加载%d个样本实体", faceSamples.size())); + + } catch (Exception e) { + log.error("加载匹配样本实体失败,faceId={}", faceId, e); + // 加载失败影响后续流程,返回失败 + return StageResult.failed("加载匹配样本实体失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/PersistRelationsStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/PersistRelationsStage.java new file mode 100644 index 00000000..0b981760 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/PersistRelationsStage.java @@ -0,0 +1,92 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.mapper.SourceMapper; +import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity; +import com.ycwl.basic.repository.MemberRelationRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 持久化关联关系Stage + * 负责过滤并保存源文件关联关系到数据库 + * + * 职责: + * 1. 从context.memberSourceList读取关联关系 + * 2. 过滤已存在的关联关系和无效的source引用 + * 3. 保存到数据库 + * 4. 清除缓存 + */ +@Slf4j +@Component +@StageConfig( + stageId = "persist_relations", + optionalMode = StageOptionalMode.FORCE_ON, + description = "持久化源文件关联关系" +) +public class PersistRelationsStage extends AbstractFaceMatchingStage { + + @Autowired + private SourceMapper sourceMapper; + + @Autowired + private MemberRelationRepository memberRelationRepository; + + @Override + public String getName() { + return "PersistRelations"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 只有当memberSourceList不为空时才执行 + List memberSourceList = context.getMemberSourceList(); + if (memberSourceList == null || memberSourceList.isEmpty()) { + log.debug("memberSourceList为空,跳过持久化,faceId={}", context.getFaceId()); + return false; + } + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + List memberSourceEntityList = context.getMemberSourceList(); + Long faceId = context.getFaceId(); + + try { + // 1. 过滤已存在的关联关系 + List existingFiltered = sourceMapper.filterExistingRelations(memberSourceEntityList); + + // 2. 过滤无效的source引用 + List validFiltered = sourceMapper.filterValidSourceRelations(existingFiltered); + + if (!validFiltered.isEmpty()) { + // 3. 保存到数据库 + sourceMapper.addRelations(validFiltered); + + log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}", + faceId, memberSourceEntityList.size(), validFiltered.size()); + } else { + log.warn("没有有效的关联关系可创建: faceId={}, 原始数量={}", + faceId, memberSourceEntityList.size()); + return StageResult.skipped("没有有效的关联关系可创建"); + } + + // 4. 清除缓存 + memberRelationRepository.clearSCacheByFace(faceId); + + return StageResult.success(String.format("持久化了%d条关联关系", validFiltered.size())); + + } catch (Exception e) { + log.error("持久化关联关系失败,faceId={}", faceId, e); + return StageResult.failed("保存关联关系失败", e); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/PrepareContextStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/PrepareContextStage.java new file mode 100644 index 00000000..75b96fa4 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/PrepareContextStage.java @@ -0,0 +1,91 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter; +import com.ycwl.basic.integration.common.manager.ScenicConfigManager; +import com.ycwl.basic.model.pc.face.entity.FaceEntity; +import com.ycwl.basic.repository.FaceRepository; +import com.ycwl.basic.repository.ScenicRepository; +import com.ycwl.basic.service.pc.ScenicService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 准备上下文Stage + * 负责加载人脸实体、景区配置、识别适配器等必要数据 + * + * 职责: + * 1. 加载FaceEntity(如不存在则失败) + * 2. 检查是否人工选择(是则跳过,除非isNew=true) + * 3. 加载ScenicConfigManager和IFaceBodyAdapter + * 4. 更新Context + */ +@Slf4j +@Component +@StageConfig( + stageId = "prepare_context", + optionalMode = StageOptionalMode.FORCE_ON, + description = "准备人脸匹配上下文数据" +) +public class PrepareContextStage extends AbstractFaceMatchingStage { + + @Autowired + private FaceRepository faceRepository; + + @Autowired + private ScenicRepository scenicRepository; + + @Autowired + private ScenicService scenicService; + + @Override + public String getName() { + return "PrepareContext"; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + Long faceId = context.getFaceId(); + boolean isNew = context.isNew(); + + // 1. 加载人脸实体 + FaceEntity face = faceRepository.getFace(faceId); + if (face == null) { + log.warn("人脸不存在,faceId: {}", faceId); + return StageResult.failed("人脸不存在,faceId: " + faceId); + } + + context.setFace(face); + log.debug("加载人脸实体成功: faceId={}, memberId={}, scenicId={}", + faceId, face.getMemberId(), face.getScenicId()); + + // 2. 检查是否人工选择 + // 人工选择的无需重新匹配(新用户除外) + if (!isNew && Integer.valueOf(1).equals(face.getIsManual())) { + log.info("人工选择的人脸,无需匹配,faceId: {}", faceId); + return StageResult.skipped("人工选择的人脸,无需重新匹配"); + } + + // 3. 加载景区配置 + ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId()); + context.setScenicConfig(scenicConfig); + log.debug("加载景区配置成功: scenicId={}", face.getScenicId()); + + // 4. 加载人脸识别适配器 + IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(face.getScenicId()); + if (faceBodyAdapter == null) { + log.error("无法获取人脸识别适配器,scenicId: {}", face.getScenicId()); + return StageResult.failed("人脸识别服务不可用,请稍后再试"); + } + + context.setFaceBodyAdapter(faceBodyAdapter); + log.debug("加载人脸识别适配器成功: scenicId={}", face.getScenicId()); + + return StageResult.success("上下文准备完成"); + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/ProcessBuyStatusStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/ProcessBuyStatusStage.java new file mode 100644 index 00000000..9f3f6d18 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/ProcessBuyStatusStage.java @@ -0,0 +1,86 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity; +import com.ycwl.basic.service.pc.processor.BuyStatusProcessor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 处理购买状态Stage + * 负责更新源文件的购买状态标记 + * + * 职责: + * 1. 从context.memberSourceList读取源文件关联列表 + * 2. 从context.freeSourceIds读取免费源文件ID列表 + * 3. 调用buyStatusProcessor.processBuyStatus()更新购买状态 + * + * 前置条件: context.memberSourceList不为空 + * 业务说明: 购买状态影响前端显示和用户下载权限 + */ +@Slf4j +@Component +@StageConfig( + stageId = "process_buy_status", + optionalMode = StageOptionalMode.SUPPORT, + description = "处理源文件购买状态", + defaultEnabled = true +) +public class ProcessBuyStatusStage extends AbstractFaceMatchingStage { + + @Autowired + private BuyStatusProcessor buyStatusProcessor; + + @Override + public String getName() { + return "ProcessBuyStatus"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 只有当memberSourceList不为空时才执行 + List memberSourceList = context.getMemberSourceList(); + if (memberSourceList == null || memberSourceList.isEmpty()) { + log.debug("memberSourceList为空,跳过购买状态处理,faceId={}", context.getFaceId()); + return false; + } + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + List memberSourceEntityList = context.getMemberSourceList(); + List freeSourceIds = context.getFreeSourceIds(); + Long memberId = context.getFace().getMemberId(); + Long scenicId = context.getFace().getScenicId(); + Long faceId = context.getFaceId(); + + try { + // 处理购买状态 + buyStatusProcessor.processBuyStatus( + memberSourceEntityList, + freeSourceIds, + memberId, + scenicId, + faceId); + + log.info("购买状态处理完成: faceId={}, 源文件数={}, 免费数={}", + faceId, memberSourceEntityList.size(), + freeSourceIds != null ? freeSourceIds.size() : 0); + + return StageResult.success("购买状态处理完成"); + + } catch (Exception e) { + log.error("处理购买状态失败,faceId={}", faceId, e); + // 购买状态处理失败不影响主流程,返回降级 + return StageResult.degraded("购买状态处理失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/ProcessFreeSourceStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/ProcessFreeSourceStage.java new file mode 100644 index 00000000..6c549d55 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/ProcessFreeSourceStage.java @@ -0,0 +1,83 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity; +import com.ycwl.basic.service.pc.processor.SourceRelationProcessor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 处理免费源文件Stage + * 负责根据业务规则确定哪些源文件可以免费访问 + * + * 职责: + * 1. 从context.memberSourceList读取源文件关联列表 + * 2. 调用sourceRelationProcessor.processFreeSourceLogic()确定免费源文件 + * 3. 更新context.freeSourceIds + * + * 前置条件: context.memberSourceList不为空 + * 后置条件: context.freeSourceIds已设置 + */ +@Slf4j +@Component +@StageConfig( + stageId = "process_free_source", + optionalMode = StageOptionalMode.SUPPORT, + description = "处理免费源文件逻辑", + defaultEnabled = true +) +public class ProcessFreeSourceStage extends AbstractFaceMatchingStage { + + @Autowired + private SourceRelationProcessor sourceRelationProcessor; + + @Override + public String getName() { + return "ProcessFreeSource"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 只有当memberSourceList不为空时才执行 + List memberSourceList = context.getMemberSourceList(); + if (memberSourceList == null || memberSourceList.isEmpty()) { + log.debug("memberSourceList为空,跳过免费逻辑,faceId={}", context.getFaceId()); + return false; + } + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + List memberSourceEntityList = context.getMemberSourceList(); + Long scenicId = context.getFace().getScenicId(); + boolean isNew = context.isNew(); + Long faceId = context.getFaceId(); + + try { + // 处理免费逻辑 + List freeSourceIds = sourceRelationProcessor.processFreeSourceLogic( + memberSourceEntityList, scenicId, isNew); + + context.setFreeSourceIds(freeSourceIds); + + log.info("免费源文件处理完成: faceId={}, 总源文件数={}, 免费数={}", + faceId, memberSourceEntityList.size(), freeSourceIds != null ? freeSourceIds.size() : 0); + + return StageResult.success(String.format("确定了%d个免费源文件", + freeSourceIds != null ? freeSourceIds.size() : 0)); + + } catch (Exception e) { + log.error("处理免费源文件失败,faceId={}", faceId, e); + // 免费逻辑失败不影响主流程,返回降级 + return StageResult.degraded("免费源文件处理失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/RecordCustomMatchMetricsStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/RecordCustomMatchMetricsStage.java new file mode 100644 index 00000000..0aefe180 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/RecordCustomMatchMetricsStage.java @@ -0,0 +1,65 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.FaceMatchingScene; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.service.pc.helper.FaceMetricsRecorder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 记录自定义匹配次数Stage + * 负责记录自定义人脸匹配调用次数,用于监控 + * + * 职责: + * 1. 仅在CUSTOM_MATCHING场景执行 + * 2. 调用metricsRecorder.recordCustomMatchCount()记录次数 + */ +@Slf4j +@Component +@StageConfig( + stageId = "record_custom_match_metrics", + optionalMode = StageOptionalMode.SUPPORT, + description = "记录自定义匹配指标", + defaultEnabled = true +) +public class RecordCustomMatchMetricsStage extends AbstractFaceMatchingStage { + + @Autowired + private FaceMetricsRecorder metricsRecorder; + + @Override + public String getName() { + return "RecordCustomMatchMetrics"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 只有自定义匹配场景才执行 + if (context.getScene() != FaceMatchingScene.CUSTOM_MATCHING) { + log.debug("非自定义匹配场景,跳过记录,faceId={}", context.getFaceId()); + return false; + } + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + Long faceId = context.getFaceId(); + + try { + metricsRecorder.recordCustomMatchCount(faceId); + log.debug("记录自定义匹配次数: faceId={}", faceId); + return StageResult.success("自定义匹配指标记录完成"); + + } catch (Exception e) { + log.error("记录自定义匹配指标失败,faceId={}", faceId, e); + // 指标记录失败不影响主流程,返回降级 + return StageResult.degraded("指标记录失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/RecordMetricsStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/RecordMetricsStage.java new file mode 100644 index 00000000..a26ddb40 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/RecordMetricsStage.java @@ -0,0 +1,63 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.service.pc.helper.FaceMetricsRecorder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 记录识别次数Stage + * 负责记录人脸识别调用次数,用于监控和防重复检查 + * + * 职责: + * 1. 调用metricsRecorder.recordRecognitionCount()记录识别次数 + * 2. 检查searchResult是否触发低阈值检测 + * 3. 如果是,调用metricsRecorder.recordLowThreshold()记录 + */ +@Slf4j +@Component +@StageConfig( + stageId = "record_metrics", + optionalMode = StageOptionalMode.SUPPORT, + description = "记录人脸识别指标", + defaultEnabled = true +) +public class RecordMetricsStage extends AbstractFaceMatchingStage { + + @Autowired + private FaceMetricsRecorder metricsRecorder; + + @Override + public String getName() { + return "RecordMetrics"; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + Long faceId = context.getFaceId(); + + try { + // 1. 记录识别次数 + metricsRecorder.recordRecognitionCount(faceId); + log.debug("记录识别次数: faceId={}", faceId); + + // 2. 检查是否触发低阈值检测 + if (context.getSearchResult() != null && context.getSearchResult().isLowThreshold()) { + metricsRecorder.recordLowThreshold(faceId); + log.debug("触发低阈值检测,记录faceId: {}", faceId); + } + + return StageResult.success("识别指标记录完成"); + + } catch (Exception e) { + log.error("记录识别指标失败,faceId={}", faceId, e); + // 指标记录失败不影响主流程,返回降级 + return StageResult.degraded("指标记录失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/SetTaskStatusStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/SetTaskStatusStage.java new file mode 100644 index 00000000..49b6ceb3 --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/SetTaskStatusStage.java @@ -0,0 +1,63 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.biz.TaskStatusBiz; +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 设置任务状态Stage + * 负责为新用户设置任务状态为待处理 + * + * 职责: + * 1. 仅在isNew=true时执行 + * 2. 调用taskStatusBiz.setFaceCutStatus(faceId, 0) + */ +@Slf4j +@Component +@StageConfig( + stageId = "set_task_status", + optionalMode = StageOptionalMode.FORCE_ON, + description = "设置新用户任务状态" +) +public class SetTaskStatusStage extends AbstractFaceMatchingStage { + + @Autowired + private TaskStatusBiz taskStatusBiz; + + @Override + public String getName() { + return "SetTaskStatus"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) { + // 只有新用户才执行 + if (!context.isNew()) { + log.debug("非新用户,跳过设置任务状态,faceId={}", context.getFaceId()); + return false; + } + return true; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + Long faceId = context.getFaceId(); + + try { + taskStatusBiz.setFaceCutStatus(faceId, 0); + log.debug("设置新用户任务状态: faceId={}, status=0", faceId); + return StageResult.success("任务状态已设置"); + + } catch (Exception e) { + log.error("设置任务状态失败,faceId={}", faceId, e); + // 任务状态设置失败不影响主流程,返回降级 + return StageResult.degraded("任务状态设置失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/face/pipeline/stages/UpdateFaceResultStage.java b/src/main/java/com/ycwl/basic/face/pipeline/stages/UpdateFaceResultStage.java new file mode 100644 index 00000000..c361699e --- /dev/null +++ b/src/main/java/com/ycwl/basic/face/pipeline/stages/UpdateFaceResultStage.java @@ -0,0 +1,95 @@ +package com.ycwl.basic.face.pipeline.stages; + +import com.ycwl.basic.face.pipeline.annotation.StageConfig; +import com.ycwl.basic.face.pipeline.core.AbstractFaceMatchingStage; +import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; +import com.ycwl.basic.face.pipeline.core.StageResult; +import com.ycwl.basic.face.pipeline.enums.StageOptionalMode; +import com.ycwl.basic.mapper.FaceMapper; +import com.ycwl.basic.model.pc.face.entity.FaceEntity; +import com.ycwl.basic.model.task.resp.SearchFaceRespVo; +import com.ycwl.basic.repository.FaceRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.stream.Collectors; + +/** + * 更新人脸结果Stage + * 负责将人脸识别结果保存到数据库 + * + * 职责: + * 1. 从context.searchResult读取识别结果 + * 2. 更新FaceEntity(score、matchResult、firstMatchRate、matchSampleIds) + * 3. 清除缓存 + */ +@Slf4j +@Component +@StageConfig( + stageId = "update_face_result", + optionalMode = StageOptionalMode.FORCE_ON, + description = "更新人脸识别结果到数据库" +) +public class UpdateFaceResultStage extends AbstractFaceMatchingStage { + + @Autowired + private FaceMapper faceMapper; + + @Autowired + private FaceRepository faceRepository; + + @Override + public String getName() { + return "UpdateFaceResult"; + } + + @Override + protected StageResult doExecute(FaceMatchingContext context) { + SearchFaceRespVo searchResult = context.getSearchResult(); + if (searchResult == null) { + log.warn("searchResult为空,跳过更新人脸结果,faceId={}", context.getFaceId()); + return StageResult.skipped("searchResult为空"); + } + + try { + FaceEntity originalFace = context.getFace(); + Long faceId = context.getFaceId(); + + FaceEntity faceEntity = new FaceEntity(); + faceEntity.setId(faceId); + faceEntity.setScore(searchResult.getScore()); + faceEntity.setMatchResult(searchResult.getSearchResultJson()); + + if (searchResult.getFirstMatchRate() != null) { + faceEntity.setFirstMatchRate(BigDecimal.valueOf(searchResult.getFirstMatchRate())); + } + + if (searchResult.getSampleListIds() != null) { + faceEntity.setMatchSampleIds(searchResult.getSampleListIds().stream() + .map(String::valueOf) + .collect(Collectors.joining(","))); + } + + faceEntity.setCreateAt(new Date()); + faceEntity.setScenicId(originalFace.getScenicId()); + faceEntity.setMemberId(originalFace.getMemberId()); + faceEntity.setFaceUrl(originalFace.getFaceUrl()); + + faceMapper.update(faceEntity); + faceRepository.clearFaceCache(faceId); + + log.debug("人脸结果更新成功:faceId={}, score={}, sampleCount={}", + faceId, searchResult.getScore(), + searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0); + + return StageResult.success("人脸结果更新成功"); + + } catch (Exception e) { + log.error("更新人脸结果失败,faceId={}", context.getFaceId(), e); + return StageResult.failed("保存人脸识别结果失败", e); + } + } +}