You've already forked FrameTour-BE
Compare commits
5 Commits
062a128dcc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 47ae60b203 | |||
| 703a5baf13 | |||
| 7454111615 | |||
| 39c955b55c | |||
| 9a18ffc167 |
@@ -0,0 +1,111 @@
|
||||
package com.ycwl.basic.controller.pc;
|
||||
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.mapper.FaceSampleAssociationMapper;
|
||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleAssociationEntity;
|
||||
import com.ycwl.basic.model.pc.faceSample.req.FaceSampleAssociationReq;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 人脸样本关联分组管理
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/faceSampleAssociation/v1")
|
||||
@IgnoreToken
|
||||
public class FaceSampleAssociationController {
|
||||
|
||||
@Autowired
|
||||
private FaceSampleAssociationMapper faceSampleAssociationMapper;
|
||||
|
||||
/**
|
||||
* 添加关联:将一批 faceSampleId 加入指定的 groupKey
|
||||
*/
|
||||
@PostMapping("/add")
|
||||
public ApiResponse<String> add(@RequestBody FaceSampleAssociationReq req) {
|
||||
if (req.getScenicId() == null || req.getGroupKey() == null || req.getGroupKey().isBlank()) {
|
||||
return ApiResponse.fail("scenicId 和 groupKey 不能为空");
|
||||
}
|
||||
if (req.getFaceSampleIds() == null || req.getFaceSampleIds().isEmpty()) {
|
||||
return ApiResponse.fail("faceSampleIds 不能为空");
|
||||
}
|
||||
|
||||
List<FaceSampleAssociationEntity> entities = req.getFaceSampleIds().stream()
|
||||
.map(sampleId -> {
|
||||
FaceSampleAssociationEntity entity = new FaceSampleAssociationEntity();
|
||||
entity.setScenicId(req.getScenicId());
|
||||
entity.setGroupKey(req.getGroupKey());
|
||||
entity.setFaceSampleId(sampleId);
|
||||
return entity;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
faceSampleAssociationMapper.batchInsertIgnore(entities);
|
||||
log.info("添加人脸样本关联: scenicId={}, groupKey={}, count={}",
|
||||
req.getScenicId(), req.getGroupKey(), entities.size());
|
||||
return ApiResponse.success("添加成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除关联:从指定 groupKey 中移除一批 faceSampleId
|
||||
*/
|
||||
@PostMapping("/remove")
|
||||
public ApiResponse<String> remove(@RequestBody FaceSampleAssociationReq req) {
|
||||
if (req.getScenicId() == null || req.getGroupKey() == null || req.getGroupKey().isBlank()) {
|
||||
return ApiResponse.fail("scenicId 和 groupKey 不能为空");
|
||||
}
|
||||
if (req.getFaceSampleIds() == null || req.getFaceSampleIds().isEmpty()) {
|
||||
return ApiResponse.fail("faceSampleIds 不能为空");
|
||||
}
|
||||
|
||||
faceSampleAssociationMapper.deleteByGroupAndSampleIds(
|
||||
req.getScenicId(), req.getGroupKey(), req.getFaceSampleIds());
|
||||
log.info("移除人脸样本关联: scenicId={}, groupKey={}, count={}",
|
||||
req.getScenicId(), req.getGroupKey(), req.getFaceSampleIds().size());
|
||||
return ApiResponse.success("移除成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除整个组
|
||||
*/
|
||||
@PostMapping("/deleteGroup")
|
||||
public ApiResponse<String> deleteGroup(@RequestParam Long scenicId, @RequestParam String groupKey) {
|
||||
if (scenicId == null || groupKey == null || groupKey.isBlank()) {
|
||||
return ApiResponse.fail("scenicId 和 groupKey 不能为空");
|
||||
}
|
||||
|
||||
faceSampleAssociationMapper.deleteByGroup(scenicId, groupKey);
|
||||
log.info("删除关联组: scenicId={}, groupKey={}", scenicId, groupKey);
|
||||
return ApiResponse.success("删除成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定组的所有样本ID
|
||||
*/
|
||||
@GetMapping("/listByGroup")
|
||||
public ApiResponse<List<Long>> listByGroup(@RequestParam Long scenicId, @RequestParam String groupKey) {
|
||||
List<Long> sampleIds = faceSampleAssociationMapper.listSampleIdsByGroup(scenicId, groupKey);
|
||||
return ApiResponse.success(sampleIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定景区下的所有分组及其样本ID
|
||||
*/
|
||||
@GetMapping("/listGroups")
|
||||
public ApiResponse<Map<String, List<Long>>> listGroups(@RequestParam Long scenicId) {
|
||||
List<String> groupKeys = faceSampleAssociationMapper.listGroupKeysByScenicId(scenicId);
|
||||
Map<String, List<Long>> result = groupKeys.stream()
|
||||
.collect(Collectors.toMap(
|
||||
groupKey -> groupKey,
|
||||
groupKey -> faceSampleAssociationMapper.listSampleIdsByGroup(scenicId, groupKey)
|
||||
));
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,12 @@ public class FaceMatchingContext implements PipelineContext {
|
||||
*/
|
||||
private List<Long> freeSourceIds;
|
||||
|
||||
/**
|
||||
* 关联扩展的样本ID列表(由 ExpandSampleAssociationStage 设置)
|
||||
* 用于标识哪些 sampleId 是通过关联分组扩展得到的,而非直接匹配
|
||||
*/
|
||||
private List<Long> associatedSampleIds;
|
||||
|
||||
/**
|
||||
* 人脸选择后置模式配置(自定义匹配场景)
|
||||
* 0: 并集, 1: 交集, 2: 直接使用
|
||||
|
||||
@@ -66,6 +66,8 @@ public class FaceMatchingPipelineFactory {
|
||||
private FilterByDevicePhotoLimitStage filterByDevicePhotoLimitStage;
|
||||
@Autowired
|
||||
private DeleteOldRelationsStage deleteOldRelationsStage;
|
||||
@Autowired
|
||||
private ExpandSampleAssociationStage expandSampleAssociationStage;
|
||||
|
||||
// ==================== 辅助服务 ====================
|
||||
@Autowired
|
||||
@@ -91,13 +93,16 @@ public class FaceMatchingPipelineFactory {
|
||||
// 3. 人脸识别补救
|
||||
builder.addStage(faceRecoveryStage);
|
||||
|
||||
// 4. 更新人脸结果(落库)
|
||||
// 4. 关联样本扩展
|
||||
builder.addStage(expandSampleAssociationStage);
|
||||
|
||||
// 5. 更新人脸结果(落库)
|
||||
builder.addStage(updateFaceResultStage);
|
||||
|
||||
// 5. 构建源文件关联(建关系)
|
||||
// 6. 构建源文件关联(建关系)
|
||||
builder.addStage(buildSourceRelationStage);
|
||||
|
||||
// 6. 持久化关联关系(建关系)
|
||||
// 7. 持久化关联关系(建关系)
|
||||
builder.addStage(persistRelationsStage);
|
||||
|
||||
log.debug("创建打印机大屏识别试点Pipeline: stageCount={}", builder.build().getStageCount());
|
||||
@@ -131,28 +136,31 @@ public class FaceMatchingPipelineFactory {
|
||||
// 5. 人脸识别补救
|
||||
builder.addStage(faceRecoveryStage);
|
||||
|
||||
// 6. 更新人脸结果
|
||||
// 6. 关联样本扩展
|
||||
builder.addStage(expandSampleAssociationStage);
|
||||
|
||||
// 7. 更新人脸结果
|
||||
builder.addStage(updateFaceResultStage);
|
||||
|
||||
// 7. 构建源文件关联
|
||||
// 8. 构建源文件关联
|
||||
builder.addStage(buildSourceRelationStage);
|
||||
|
||||
// 8. 处理免费源文件逻辑
|
||||
// 9. 处理免费源文件逻辑
|
||||
builder.addStage(processFreeSourceStage);
|
||||
|
||||
// 9. 处理购买状态
|
||||
// 10. 处理购买状态
|
||||
builder.addStage(processBuyStatusStage);
|
||||
|
||||
// 10. 处理视频重切
|
||||
// 11. 处理视频重切
|
||||
builder.addStage(handleVideoRecreationStage);
|
||||
|
||||
// 11. 持久化关联关系
|
||||
// 12. 持久化关联关系
|
||||
builder.addStage(persistRelationsStage);
|
||||
|
||||
// 12. 创建任务
|
||||
// 13. 创建任务
|
||||
builder.addStage(createTaskStage);
|
||||
|
||||
// 13. 异步生成拼图模板
|
||||
// 14. 异步生成拼图模板
|
||||
builder.addStage(generatePuzzleStage);
|
||||
|
||||
log.debug("创建自动人脸匹配Pipeline: isNew={}, stageCount={}", isNew, builder.build().getStageCount());
|
||||
@@ -189,28 +197,31 @@ public class FaceMatchingPipelineFactory {
|
||||
// 7. 应用设备照片数量限制筛选
|
||||
builder.addStage(filterByDevicePhotoLimitStage);
|
||||
|
||||
// 8. 更新人脸结果
|
||||
// 8. 关联样本扩展
|
||||
builder.addStage(expandSampleAssociationStage);
|
||||
|
||||
// 9. 更新人脸结果
|
||||
builder.addStage(updateFaceResultStage);
|
||||
|
||||
// 9. 删除旧关系数据
|
||||
// 10. 删除旧关系数据
|
||||
builder.addStage(deleteOldRelationsStage);
|
||||
|
||||
// 10. 构建源文件关联
|
||||
// 11. 构建源文件关联
|
||||
builder.addStage(buildSourceRelationStage);
|
||||
|
||||
// 11. 处理免费源文件逻辑
|
||||
// 12. 处理免费源文件逻辑
|
||||
builder.addStage(processFreeSourceStage);
|
||||
|
||||
// 12. 处理购买状态
|
||||
// 13. 处理购买状态
|
||||
builder.addStage(processBuyStatusStage);
|
||||
|
||||
// 13. 处理视频重切
|
||||
// 14. 处理视频重切
|
||||
builder.addStage(handleVideoRecreationStage);
|
||||
|
||||
// 14. 持久化关联关系
|
||||
// 15. 持久化关联关系
|
||||
builder.addStage(persistRelationsStage);
|
||||
|
||||
// 15. 创建任务
|
||||
// 16. 创建任务
|
||||
builder.addStage(createTaskStage);
|
||||
|
||||
log.debug("创建自定义人脸匹配Pipeline: stageCount={}", builder.build().getStageCount());
|
||||
|
||||
@@ -2,6 +2,7 @@ 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.mapper.SourceMapper;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
|
||||
@@ -44,6 +45,9 @@ public class PuzzleGenerationOrchestrator {
|
||||
@Autowired
|
||||
private ScenicRepository scenicRepository;
|
||||
|
||||
@Autowired
|
||||
private SourceMapper sourceMapper;
|
||||
|
||||
/**
|
||||
* 异步生成景区所有启用的拼图模板
|
||||
*
|
||||
@@ -74,6 +78,11 @@ public class PuzzleGenerationOrchestrator {
|
||||
// 3. 准备公共动态数据
|
||||
Map<String, String> baseDynamicData = buildBaseDynamicData(faceId, faceUrl, scenicBasic);
|
||||
|
||||
// 4. 设置dateStr为图片素材的最大创建时间
|
||||
Date maxCreateTime = sourceMapper.getMaxCreateTimeByFaceId(faceId);
|
||||
baseDynamicData.put("dateStr", DateUtil.format(
|
||||
maxCreateTime != null ? maxCreateTime : new Date(), "yyyy.MM.dd"));
|
||||
|
||||
// 4. 使用虚拟线程池并行生成所有模板
|
||||
java.util.concurrent.atomic.AtomicInteger successCount = new java.util.concurrent.atomic.AtomicInteger(0);
|
||||
java.util.concurrent.atomic.AtomicInteger failCount = new java.util.concurrent.atomic.AtomicInteger(0);
|
||||
@@ -121,7 +130,6 @@ public class PuzzleGenerationOrchestrator {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.ycwl.basic.face.pipeline.stages;
|
||||
|
||||
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||
import com.ycwl.basic.mapper.FaceSampleAssociationMapper;
|
||||
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||
import com.ycwl.basic.pipeline.core.StageResult;
|
||||
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 根据人脸样本关联分组扩展匹配结果
|
||||
* <p>
|
||||
* 当匹配结果中包含某分组的任一 faceSampleId 时,
|
||||
* 将该分组内所有 faceSampleId 加入匹配结果。
|
||||
* 关联扩展的样本ID跳过时间范围和设备限制筛选(通过Stage执行顺序保证)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@StageConfig(
|
||||
stageId = "expand_sample_association",
|
||||
optionalMode = StageOptionalMode.SUPPORT,
|
||||
description = "根据人脸样本关联分组扩展匹配结果",
|
||||
defaultEnabled = true
|
||||
)
|
||||
public class ExpandSampleAssociationStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||
|
||||
@Autowired
|
||||
private FaceSampleAssociationMapper faceSampleAssociationMapper;
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "ExpandSampleAssociation";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||
SearchFaceRespVo searchResult = context.getSearchResult();
|
||||
if (searchResult == null || searchResult.getSampleListIds() == null
|
||||
|| searchResult.getSampleListIds().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return context.getFace() != null && context.getFace().getScenicId() != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||
// 防御性检查
|
||||
SearchFaceRespVo searchResult = context.getSearchResult();
|
||||
if (searchResult == null || searchResult.getSampleListIds() == null
|
||||
|| searchResult.getSampleListIds().isEmpty()) {
|
||||
return StageResult.skipped("searchResult或sampleListIds为空");
|
||||
}
|
||||
|
||||
Long scenicId = context.getFace().getScenicId();
|
||||
List<Long> originalMatchedIds = searchResult.getSampleListIds();
|
||||
|
||||
try {
|
||||
List<Long> associatedIds = faceSampleAssociationMapper
|
||||
.findAssociatedSampleIds(scenicId, originalMatchedIds);
|
||||
|
||||
if (associatedIds == null || associatedIds.isEmpty()) {
|
||||
log.debug("未找到关联样本, faceId={}, scenicId={}", context.getFaceId(), scenicId);
|
||||
return StageResult.success("无关联样本");
|
||||
}
|
||||
|
||||
// 获取当前的 sampleListIds(可能已经过筛选,也可能与 searchResult 相同)
|
||||
List<Long> currentSampleIds = context.getSampleListIds();
|
||||
if (currentSampleIds == null || currentSampleIds.isEmpty()) {
|
||||
currentSampleIds = new ArrayList<>(originalMatchedIds);
|
||||
}
|
||||
|
||||
// 计算净新增的关联ID
|
||||
Set<Long> currentSet = new HashSet<>(currentSampleIds);
|
||||
List<Long> netNewIds = associatedIds.stream()
|
||||
.filter(id -> !currentSet.contains(id))
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (netNewIds.isEmpty()) {
|
||||
log.debug("关联样本均已存在于当前结果中, faceId={}", context.getFaceId());
|
||||
return StageResult.success("关联样本均已存在");
|
||||
}
|
||||
|
||||
// 合并:当前筛选后的ID + 净新增关联ID
|
||||
List<Long> expandedIds = Stream.concat(
|
||||
currentSampleIds.stream(),
|
||||
netNewIds.stream()
|
||||
).distinct().collect(Collectors.toList());
|
||||
|
||||
context.setSampleListIds(expandedIds);
|
||||
context.setAssociatedSampleIds(netNewIds);
|
||||
|
||||
log.info("关联样本扩展完成: faceId={}, scenicId={}, 原始匹配数={}, 扩展前={}, 净新增={}, 扩展后={}",
|
||||
context.getFaceId(), scenicId,
|
||||
originalMatchedIds.size(), currentSampleIds.size(),
|
||||
netNewIds.size(), expandedIds.size());
|
||||
|
||||
return StageResult.success(String.format("关联扩展: +%d个样本", netNewIds.size()));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("关联样本扩展失败, faceId={}, scenicId={}", context.getFaceId(), scenicId, e);
|
||||
return StageResult.degraded("关联样本扩展失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -67,8 +68,14 @@ public class UpdateFaceResultStage extends AbstractPipelineStage<FaceMatchingCon
|
||||
faceEntity.setFirstMatchRate(BigDecimal.valueOf(searchResult.getFirstMatchRate()));
|
||||
}
|
||||
|
||||
if (searchResult.getSampleListIds() != null) {
|
||||
faceEntity.setMatchSampleIds(searchResult.getSampleListIds().stream()
|
||||
// 优先使用 context.sampleListIds(可能包含关联扩展的ID),
|
||||
// 回退到 searchResult.sampleListIds
|
||||
List<Long> finalSampleIds = context.getSampleListIds();
|
||||
if (finalSampleIds == null || finalSampleIds.isEmpty()) {
|
||||
finalSampleIds = searchResult.getSampleListIds();
|
||||
}
|
||||
if (finalSampleIds != null && !finalSampleIds.isEmpty()) {
|
||||
faceEntity.setMatchSampleIds(finalSampleIds.stream()
|
||||
.map(String::valueOf)
|
||||
.collect(Collectors.joining(",")));
|
||||
}
|
||||
@@ -83,7 +90,7 @@ public class UpdateFaceResultStage extends AbstractPipelineStage<FaceMatchingCon
|
||||
|
||||
log.debug("人脸结果更新成功:faceId={}, score={}, sampleCount={}",
|
||||
faceId, searchResult.getScore(),
|
||||
searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0);
|
||||
finalSampleIds != null ? finalSampleIds.size() : 0);
|
||||
|
||||
return StageResult.success("人脸结果更新成功");
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleAssociationEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 人脸样本关联分组 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface FaceSampleAssociationMapper {
|
||||
/**
|
||||
* 根据景区ID和样本ID列表,查找同组的所有关联样本ID
|
||||
*/
|
||||
List<Long> findAssociatedSampleIds(@Param("scenicId") Long scenicId,
|
||||
@Param("sampleIds") List<Long> sampleIds);
|
||||
|
||||
/**
|
||||
* 批量插入关联记录(忽略重复)
|
||||
*/
|
||||
void batchInsertIgnore(@Param("list") List<FaceSampleAssociationEntity> list);
|
||||
|
||||
/**
|
||||
* 删除指定组内的指定样本关联
|
||||
*/
|
||||
void deleteByGroupAndSampleIds(@Param("scenicId") Long scenicId,
|
||||
@Param("groupKey") String groupKey,
|
||||
@Param("sampleIds") List<Long> sampleIds);
|
||||
|
||||
/**
|
||||
* 删除整个组的所有关联
|
||||
*/
|
||||
void deleteByGroup(@Param("scenicId") Long scenicId,
|
||||
@Param("groupKey") String groupKey);
|
||||
|
||||
/**
|
||||
* 查询指定组的所有样本ID
|
||||
*/
|
||||
List<Long> listSampleIdsByGroup(@Param("scenicId") Long scenicId,
|
||||
@Param("groupKey") String groupKey);
|
||||
|
||||
/**
|
||||
* 查询指定景区下的所有分组标识
|
||||
*/
|
||||
List<String> listGroupKeysByScenicId(@Param("scenicId") Long scenicId);
|
||||
}
|
||||
@@ -143,6 +143,13 @@ public interface SourceMapper {
|
||||
*/
|
||||
List<Long> getDeviceIdsByFaceId(Long faceId);
|
||||
|
||||
/**
|
||||
* 获取faceId关联的图片素材的最大创建时间
|
||||
* @param faceId 人脸ID
|
||||
* @return 最大创建时间,无记录时返回null
|
||||
*/
|
||||
Date getMaxCreateTimeByFaceId(Long faceId);
|
||||
|
||||
/**
|
||||
* 根据faceId和设备ID获取source
|
||||
* @param faceId 人脸ID
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ycwl.basic.model.pc.faceSample.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 人脸样本关联分组实体
|
||||
* 同一景区下相同 groupKey 的记录互为关联
|
||||
*/
|
||||
@Data
|
||||
@TableName("face_sample_association")
|
||||
public class FaceSampleAssociationEntity {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
/** 景区ID */
|
||||
private Long scenicId;
|
||||
/** 关联分组标识 */
|
||||
private String groupKey;
|
||||
/** 人脸样本ID */
|
||||
private Long faceSampleId;
|
||||
private Date createAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ycwl.basic.model.pc.faceSample.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 人脸样本关联分组请求
|
||||
*/
|
||||
@Data
|
||||
public class FaceSampleAssociationReq {
|
||||
/** 景区ID */
|
||||
private Long scenicId;
|
||||
/** 关联分组标识 */
|
||||
private String groupKey;
|
||||
/** 人脸样本ID列表 */
|
||||
private List<Long> faceSampleIds;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import java.util.stream.Collectors;
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PuzzleDuplicationDetector {
|
||||
private final Set<String> skippedElementKeys = Set.of("dateStr");
|
||||
private final Set<String> skippedElementKeys = Set.of();
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,7 @@ public class SourceRepository {
|
||||
Runtime.getRuntime().availableProcessors(),
|
||||
runnable -> {
|
||||
Thread thread = new Thread(runnable);
|
||||
thread.setName("ai-cam-image-processor-" + thread.getId());
|
||||
thread.setName("ai-cam-image-processor-" + thread.threadId());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.ycwl.basic.face.pipeline.factory.FaceMatchingPipelineFactory;
|
||||
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
|
||||
import com.ycwl.basic.facebody.entity.SearchFaceResultItem;
|
||||
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
|
||||
import com.ycwl.basic.mapper.FaceSampleAssociationMapper;
|
||||
import com.ycwl.basic.mapper.FaceSampleMapper;
|
||||
import com.ycwl.basic.mapper.ProjectMapper;
|
||||
import com.ycwl.basic.mapper.SourceMapper;
|
||||
@@ -101,6 +102,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.File;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
@@ -161,6 +163,8 @@ public class FaceServiceImpl implements FaceService {
|
||||
@Autowired
|
||||
private FaceSampleMapper faceSampleMapper;
|
||||
@Autowired
|
||||
private FaceSampleAssociationMapper faceSampleAssociationMapper;
|
||||
@Autowired
|
||||
private GoodsService goodsService;
|
||||
@Autowired
|
||||
private ProjectMapper projectMapper;
|
||||
@@ -422,6 +426,37 @@ public class FaceServiceImpl implements FaceService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联样本扩展
|
||||
* 根据 face_sample_association 表,将同组样本ID加入匹配结果
|
||||
*/
|
||||
private void expandSampleAssociation(SearchFaceRespVo searchResult, Long scenicId, Long faceId) {
|
||||
List<Long> sampleListIds = searchResult.getSampleListIds();
|
||||
if (sampleListIds == null || sampleListIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
List<Long> associatedIds = faceSampleAssociationMapper.findAssociatedSampleIds(scenicId, sampleListIds);
|
||||
if (associatedIds == null || associatedIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<Long> currentSet = new HashSet<>(sampleListIds);
|
||||
List<Long> netNewIds = associatedIds.stream()
|
||||
.filter(id -> !currentSet.contains(id))
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (!netNewIds.isEmpty()) {
|
||||
List<Long> expandedIds = new ArrayList<>(sampleListIds);
|
||||
expandedIds.addAll(netNewIds);
|
||||
searchResult.setSampleListIds(expandedIds);
|
||||
log.info("关联样本扩展完成: faceId={}, scenicId={}, 原始数={}, 新增数={}, 扩展后={}",
|
||||
faceId, scenicId, sampleListIds.size(), netNewIds.size(), expandedIds.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("关联样本扩展失败, faceId={}, scenicId={}", faceId, scenicId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新人脸实体结果信息
|
||||
* 仅用于 handleCustomFaceMatching 方法
|
||||
@@ -484,12 +519,52 @@ public class FaceServiceImpl implements FaceService {
|
||||
List<DeviceV2DTO> deviceList = deviceRepository.getAllDeviceByScenicId(face.getScenicId());
|
||||
List<SourceEntity> sourceEntityList = sourceMapper.listSourceByFaceRelation(face.getId(), 2);
|
||||
List<MemberSourceEntity> memberSourceRelations = memberRelationRepository.listSourceByFaceRelation(face.getId(), 2);
|
||||
// 按 deviceList 顺序排列结果
|
||||
Set<Long> deviceIds = deviceList.stream().map(DeviceV2DTO::getId).collect(Collectors.toSet());
|
||||
for (DeviceV2DTO device : deviceList) {
|
||||
List<SourceEntity> deviceSources = sourceEntityList.stream()
|
||||
.filter(s -> device.getId().equals(s.getDeviceId()))
|
||||
.toList();
|
||||
if (deviceSources.isEmpty()) {
|
||||
ContentPageVO content = new ContentPageVO();
|
||||
content.setName(device.getName());
|
||||
content.setGroup(device.getName());
|
||||
content.setContentId(device.getId());
|
||||
content.setGoodsType(2);
|
||||
content.setContentType(2);
|
||||
content.setScenicId(face.getScenicId());
|
||||
content.setSourceType(2);
|
||||
content.setTemplateCoverUrl("");
|
||||
content.setIsBuy(0);
|
||||
content.setLockType(1);
|
||||
result.add(content);
|
||||
} else {
|
||||
for (SourceEntity sourceEntity : deviceSources) {
|
||||
ContentPageVO content = new ContentPageVO();
|
||||
content.setName("摄影师拍照");
|
||||
content.setGroup(device.getName());
|
||||
content.setContentId(sourceEntity.getId());
|
||||
content.setGoodsType(2);
|
||||
content.setContentType(2);
|
||||
content.setScenicId(sourceEntity.getScenicId());
|
||||
content.setSourceType(2);
|
||||
content.setOrigUrl(sourceEntity.getUrl());
|
||||
content.setTemplateCoverUrl(sourceEntity.getThumbUrl());
|
||||
memberSourceRelations.stream().filter(relation -> relation.getSourceId().equals(sourceEntity.getId())).findAny().ifPresent(relation -> {
|
||||
content.setIsBuy(relation.getIsBuy());
|
||||
});
|
||||
content.setLockType(-1);
|
||||
result.add(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 补充不属于任何设备的源
|
||||
for (SourceEntity sourceEntity : sourceEntityList) {
|
||||
if (sourceEntity.getDeviceId() != null && deviceIds.contains(sourceEntity.getDeviceId())) {
|
||||
continue;
|
||||
}
|
||||
ContentPageVO content = new ContentPageVO();
|
||||
content.setName("摄影师拍照");
|
||||
deviceList.stream().filter(device -> device.getId().equals(sourceEntity.getDeviceId())).findFirst().ifPresent(device -> {
|
||||
content.setGroup(device.getName());
|
||||
});
|
||||
content.setContentId(sourceEntity.getId());
|
||||
content.setGoodsType(2);
|
||||
content.setContentType(2);
|
||||
@@ -503,21 +578,6 @@ public class FaceServiceImpl implements FaceService {
|
||||
content.setLockType(-1);
|
||||
result.add(content);
|
||||
}
|
||||
List<Long> containedDeviceId = sourceEntityList.stream().map(SourceEntity::getDeviceId).filter(Objects::nonNull).distinct().toList();
|
||||
deviceList.stream().filter(device -> !containedDeviceId.contains(device.getId())).forEach(device -> {
|
||||
ContentPageVO content = new ContentPageVO();
|
||||
content.setName(device.getName());
|
||||
content.setGroup(device.getName());
|
||||
content.setContentId(device.getId());
|
||||
content.setGoodsType(2);
|
||||
content.setContentType(2);
|
||||
content.setScenicId(face.getScenicId());
|
||||
content.setSourceType(2);
|
||||
content.setTemplateCoverUrl("");
|
||||
content.setIsBuy(0);
|
||||
content.setLockType(1);
|
||||
result.add(content);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(face.getScenicId());
|
||||
@@ -1060,6 +1120,9 @@ public class FaceServiceImpl implements FaceService {
|
||||
allFaceSampleList.size(), filteredSampleIds.size());
|
||||
}
|
||||
|
||||
// 4. 关联样本扩展
|
||||
expandSampleAssociation(mergedResult, face.getScenicId(), faceId);
|
||||
|
||||
updateFaceEntityResult(face, mergedResult, faceId);
|
||||
|
||||
List<Long> sampleListIds = mergedResult.getSampleListIds();
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.ycwl.basic.exception.BaseException;
|
||||
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
|
||||
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||
import com.ycwl.basic.mapper.FaceMapper;
|
||||
import com.ycwl.basic.mapper.FaceSampleAssociationMapper;
|
||||
import com.ycwl.basic.mapper.SourceMapper;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||
@@ -41,8 +42,11 @@ import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -107,6 +111,8 @@ public class FaceMatchingOrchestrator {
|
||||
private FaceStatusManager faceStatusManager;
|
||||
@Autowired
|
||||
private PuzzleRepository puzzleRepository;
|
||||
@Autowired
|
||||
private FaceSampleAssociationMapper faceSampleAssociationMapper;
|
||||
|
||||
/**
|
||||
* 编排人脸匹配的完整流程
|
||||
@@ -146,6 +152,9 @@ public class FaceMatchingOrchestrator {
|
||||
searchResult = faceRecoveryStrategy.executeFaceRecoveryLogic(
|
||||
searchResult, context.scenicConfig, context.faceBodyAdapter, context.face.getScenicId());
|
||||
|
||||
// 关联样本扩展
|
||||
expandSampleAssociation(searchResult, context.face.getScenicId(), faceId);
|
||||
|
||||
// 步骤3: 更新人脸结果
|
||||
updateFaceResult(context.face, searchResult, faceId);
|
||||
|
||||
@@ -396,7 +405,9 @@ public class FaceMatchingOrchestrator {
|
||||
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"));
|
||||
Date maxCreateTime = sourceMapper.getMaxCreateTimeByFaceId(faceId);
|
||||
baseDynamicData.put("dateStr", DateUtil.format(
|
||||
maxCreateTime != null ? maxCreateTime : new Date(), "yyyy.MM.dd"));
|
||||
|
||||
templateList
|
||||
.forEach(template -> {
|
||||
@@ -428,6 +439,40 @@ public class FaceMatchingOrchestrator {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联样本扩展
|
||||
* 根据 face_sample_association 表,将同组样本ID加入匹配结果
|
||||
*/
|
||||
private void expandSampleAssociation(SearchFaceRespVo searchResult, Long scenicId, Long faceId) {
|
||||
List<Long> sampleListIds = searchResult.getSampleListIds();
|
||||
if (sampleListIds == null || sampleListIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
List<Long> associatedIds = faceSampleAssociationMapper.findAssociatedSampleIds(scenicId, sampleListIds);
|
||||
if (associatedIds == null || associatedIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Long> currentSet = new HashSet<>(sampleListIds);
|
||||
List<Long> netNewIds = associatedIds.stream()
|
||||
.filter(id -> !currentSet.contains(id))
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!netNewIds.isEmpty()) {
|
||||
List<Long> expandedIds = new ArrayList<>(sampleListIds);
|
||||
expandedIds.addAll(netNewIds);
|
||||
searchResult.setSampleListIds(expandedIds);
|
||||
log.info("关联样本扩展完成: faceId={}, scenicId={}, 原始数={}, 新增数={}, 扩展后={}",
|
||||
faceId, scenicId, sampleListIds.size(), netNewIds.size(), expandedIds.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("关联样本扩展失败, faceId={}, scenicId={}", faceId, scenicId, e);
|
||||
// 扩展失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配上下文
|
||||
* 封装匹配过程中需要的所有上下文信息
|
||||
|
||||
@@ -85,7 +85,7 @@ public class DownloadNotificationTasker {
|
||||
}
|
||||
variables.put("videoDeviceCount", videoTaskRepository.getTaskDeviceNum(item.getTaskId()));
|
||||
variables.put("videoLensCount", videoTaskRepository.getTaskLensNum(item.getTaskId()));
|
||||
variables.put("videoShotTime", DateUtil.format(videoTaskRepository.getTaskShotDate(item.getTaskId()), "yyyy-MM-dd"));
|
||||
variables.put("videoShotTime", DateUtil.format(videoTaskRepository.getTaskShotDate(item.getTaskId()), "yyyy-MM-dd HH:mm"));
|
||||
WechatSubscribeNotifyTriggerRequest request = WechatSubscribeNotifyTriggerRequest.builder()
|
||||
.scenicId(item.getScenicId())
|
||||
.memberId(item.getMemberId())
|
||||
@@ -137,7 +137,7 @@ public class DownloadNotificationTasker {
|
||||
} else {
|
||||
variables.put("videoResultPage", "videoSynthesis");
|
||||
}
|
||||
variables.put("expireDate", DateUtil.format(expireDate, "yyyy-MM-dd"));
|
||||
variables.put("expireDate", DateUtil.format(expireDate, "yyyy-MM-dd HH:mm"));
|
||||
variables.put("videoDeviceCount", videoTaskRepository.getTaskDeviceNum(item.getTaskId()));
|
||||
variables.put("videoLensCount", videoTaskRepository.getTaskLensNum(item.getTaskId()));
|
||||
variables.put("videoShotTime", DateUtil.format(videoTaskRepository.getTaskShotDate(item.getTaskId()), "yyyy-MM-dd HH:mm"));
|
||||
@@ -191,7 +191,7 @@ public class DownloadNotificationTasker {
|
||||
} else {
|
||||
variables.put("videoResultPage", "videoSynthesis");
|
||||
}
|
||||
variables.put("expireDate", DateUtil.format(expireDate, "yyyy-MM-dd"));
|
||||
variables.put("expireDate", DateUtil.format(expireDate, "yyyy-MM-dd HH:mm"));
|
||||
variables.put("videoDeviceCount", videoTaskRepository.getTaskDeviceNum(item.getTaskId()));
|
||||
variables.put("videoLensCount", videoTaskRepository.getTaskLensNum(item.getTaskId()));
|
||||
variables.put("videoShotTime", DateUtil.format(videoTaskRepository.getTaskShotDate(item.getTaskId()), "yyyy-MM-dd HH:mm"));
|
||||
|
||||
@@ -64,10 +64,10 @@ public class SourceNotificationTasker {
|
||||
variables.put("faceId", item.getId());
|
||||
List<MemberSourceEntity> sourceVideoList = memberRelationRepository.listSourceByFaceRelation(item.getId(), 1);
|
||||
variables.put("sourceVideoCount", sourceVideoList.size());
|
||||
variables.put("sourceVideoCreateTime", DateUtil.format(item.getCreateAt(), "yyyy-MM-dd"));
|
||||
variables.put("sourceVideoCreateTime", DateUtil.format(item.getCreateAt(), "yyyy-MM-dd HH:mm"));
|
||||
List<MemberSourceEntity> sourcePhotoList = memberRelationRepository.listSourceByFaceRelation(item.getId(), 2);
|
||||
variables.put("sourcePhotoCount", sourcePhotoList.size());
|
||||
variables.put("sourcePhotoCreateTime", DateUtil.format(item.getCreateAt(), "yyyy-MM-dd"));
|
||||
variables.put("sourcePhotoCreateTime", DateUtil.format(item.getCreateAt(), "yyyy-MM-dd HH:mm"));
|
||||
|
||||
WechatSubscribeNotifyTriggerRequest request = WechatSubscribeNotifyTriggerRequest.builder()
|
||||
.scenicId(item.getScenicId())
|
||||
|
||||
@@ -342,7 +342,7 @@ public class VideoPieceGetter {
|
||||
ffmpegTask.setDuration(duration);
|
||||
ffmpegTask.setOffsetStart(BigDecimal.valueOf(offset, 3));
|
||||
// 使用时间戳和线程ID确保输出文件名唯一性,避免并发冲突
|
||||
String uniqueSuffix = System.currentTimeMillis() + "_" + Thread.currentThread().getId();
|
||||
String uniqueSuffix = System.currentTimeMillis() + "_" + Thread.currentThread().threadId();
|
||||
File outFile = new File(deviceId.toString() + "_" + faceSampleId + "_" + uniqueSuffix + ".mp4");
|
||||
ffmpegTask.setOutputFile(outFile.getAbsolutePath());
|
||||
boolean result = startFfmpegTask(ffmpegTask);
|
||||
|
||||
58
src/main/resources/mapper/FaceSampleAssociationMapper.xml
Normal file
58
src/main/resources/mapper/FaceSampleAssociationMapper.xml
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ycwl.basic.mapper.FaceSampleAssociationMapper">
|
||||
|
||||
<select id="findAssociatedSampleIds" resultType="java.lang.Long">
|
||||
SELECT DISTINCT a2.face_sample_id
|
||||
FROM face_sample_association a1
|
||||
INNER JOIN face_sample_association a2
|
||||
ON a1.scenic_id = a2.scenic_id
|
||||
AND a1.group_key = a2.group_key
|
||||
WHERE a1.scenic_id = #{scenicId}
|
||||
AND a1.face_sample_id IN
|
||||
<foreach collection="sampleIds" item="sampleId" open="(" separator="," close=")">
|
||||
#{sampleId}
|
||||
</foreach>
|
||||
</select>
|
||||
|
||||
<insert id="batchInsertIgnore">
|
||||
INSERT IGNORE INTO face_sample_association (scenic_id, group_key, face_sample_id)
|
||||
VALUES
|
||||
<foreach collection="list" item="item" separator=",">
|
||||
(#{item.scenicId}, #{item.groupKey}, #{item.faceSampleId})
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<delete id="deleteByGroupAndSampleIds">
|
||||
DELETE FROM face_sample_association
|
||||
WHERE scenic_id = #{scenicId}
|
||||
AND group_key = #{groupKey}
|
||||
AND face_sample_id IN
|
||||
<foreach collection="sampleIds" item="sampleId" open="(" separator="," close=")">
|
||||
#{sampleId}
|
||||
</foreach>
|
||||
</delete>
|
||||
|
||||
<delete id="deleteByGroup">
|
||||
DELETE FROM face_sample_association
|
||||
WHERE scenic_id = #{scenicId}
|
||||
AND group_key = #{groupKey}
|
||||
</delete>
|
||||
|
||||
<select id="listSampleIdsByGroup" resultType="java.lang.Long">
|
||||
SELECT face_sample_id
|
||||
FROM face_sample_association
|
||||
WHERE scenic_id = #{scenicId}
|
||||
AND group_key = #{groupKey}
|
||||
ORDER BY create_at ASC
|
||||
</select>
|
||||
|
||||
<select id="listGroupKeysByScenicId" resultType="java.lang.String">
|
||||
SELECT DISTINCT group_key
|
||||
FROM face_sample_association
|
||||
WHERE scenic_id = #{scenicId}
|
||||
ORDER BY group_key ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -464,6 +464,15 @@
|
||||
ORDER BY s.device_id ASC
|
||||
</select>
|
||||
|
||||
<select id="getMaxCreateTimeByFaceId" resultType="java.util.Date">
|
||||
SELECT MAX(s.create_time)
|
||||
FROM member_source ms
|
||||
INNER JOIN source s ON ms.source_id = s.id
|
||||
WHERE ms.face_id = #{faceId}
|
||||
AND s.type = 2
|
||||
AND ms.deleted = 0
|
||||
</select>
|
||||
|
||||
<select id="getSourceByFaceAndDeviceId" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
|
||||
SELECT s.*
|
||||
FROM source s
|
||||
|
||||
Reference in New Issue
Block a user