Compare commits

...

11 Commits

Author SHA1 Message Date
fa0c3a1a43 feat(printer): 支持按数量创建多个打印任务- 根据照片数量生成对应数量的打印任务- 默认至少创建1个打印任务
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 保留原有单任务逻辑并适配多任务场景
- 确保每个任务独立插入数据库
- 维持打印机轮询选择机制
2025-11-05 14:45:55 +08:00
779334a09e 1 2025-11-05 13:05:43 +08:00
3a3bdee296 feat(printer): 添加 faceId 参数支持照片打印功能
- 在多个接口中新增 faceId 请求参数,用于关联人脸识别信息
- 修改 getUserPhotoList 方法支持按 faceId 过滤照片列表
- 更新 addUserPhoto 和 addUserPhotoFromSource 方法保存 faceId信息
- 调整 queryPrice 和 createOrder 方法支持 faceId 查询条件- 新增 listRelationByFaceId Mapper 方法实现按 faceId 查询照片
- 在 MemberPrintEntity 和 MemberPrintResp 中添加 faceId 字段- 更新数据库插入语句,添加 face_id 字段写入支持
2025-11-05 11:38:04 +08:00
546ddfbb62 test(facebody): 添加删除人脸库的测试方法
- 新增测试方法 testDeleteDb 验证删除人脸库功能
- 调用 deleteFaceDb 方法测试删除指定人脸库
- 使用固定ID "test" 进行删除测试
2025-11-05 10:00:46 +08:00
58b642f356 feat(device): 更新景区设备查询接口
- 删除冗余的景区IPC设备和激活设备查询接口
- 合并设备查询逻辑到统一接口 /scenic/{scenicId}
- 新增设备名称、类型、编号作为可选查询参数
- 简化控制器代码结构,提升维护性
-保留分页功能支持大规模数据展示
- 统一异常处理流程增强系统健壮性
2025-11-05 10:00:28 +08:00
fa8a8ed711 feat(face):人脸上传接口增加scene参数
- 在AioDeviceController中调用faceUpload时添加空字符串scene参数
- 在LyCompatibleController中调用faceUpload时添加空字符串scene参数- 在AppFaceController中增加scene请求参数并传递给faceService
- 修改FaceService接口和实现类faceUpload方法签名,添加scene参数- 移除多个控制器和服务中未使用的导入依赖
- 调整代码格式以符合规范
2025-11-04 14:26:00 +08:00
6886f87fe9 Merge branch 'face_service_refactor'
# Conflicts:
#	src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java
2025-11-02 22:06:57 +08:00
c1b9a42c73 3 2025-10-31 21:04:10 +08:00
4c10c1d939 2 2025-10-31 18:44:43 +08:00
3000e18cb7 refactor(face):重构人脸识别服务逻辑
- 将人脸识别补救逻辑提取到FaceRecoveryStrategy类中
- 将源文件关联处理逻辑提取到SourceRelationProcessor类中
- 将购买状态处理逻辑提取到BuyStatusProcessor类中
- 将视频重切处理逻辑提取到VideoRecreationHandler类中
- 在FaceServiceImpl中引入四个新的处理器组件
- 删除原有的冗长方法实现,改为调用对应处理器
- 更新方法调用方式以使用新的处理器实例
- 保留核心业务流程但解耦具体实现细节
2025-10-31 17:31:48 +08:00
bf014db7ff feat(face): 引入人脸识别指标记录与搜索结果合并功能
- 新增 FaceMetricsRecorder 类用于记录人脸识别、自定义匹配及低阈值检测次数
- 新增 SearchResultMerger 类用于合并多个人脸搜索结果,支持并集与交集模式- 在 FaceServiceImpl 中引入 metricsRecorder 和 resultMerger 辅助类
- 替换原有的 Redis 操作代码为 FaceMetricsRecorder 的方法调用- 将搜索结果合并逻辑从 FaceServiceImpl 提取至 SearchResultMerger- 新增策略模式相关类:RematchContext、RematchModeStrategy 接口及四种实现
- 使用策略工厂 Rematch
2025-10-31 17:11:02 +08:00
34 changed files with 1997 additions and 793 deletions

View File

@@ -0,0 +1,71 @@
package com.ycwl.basic.constant;
/**
* 购买状态枚举
* 定义源文件的已购买和未购买两种状态
*
* @author Claude
* @since 2025-10-31
*/
public enum BuyStatus {
/**
* 未购买状态
*/
NOT_BOUGHT(0, "未购买"),
/**
* 已购买状态
*/
BOUGHT(1, "已购买");
private final int code;
private final String description;
BuyStatus(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据代码值获取枚举
*
* @param code 状态代码
* @return 对应的枚举值,如果不存在返回 null
*/
public static BuyStatus fromCode(int code) {
for (BuyStatus status : values()) {
if (status.code == code) {
return status;
}
}
return null;
}
/**
* 判断给定的代码是否为已购买状态
*
* @param code 状态代码
* @return true-已购买,false-未购买
*/
public static boolean isBought(Integer code) {
return code != null && code == BOUGHT.code;
}
/**
* 判断给定的代码是否为未购买状态
*
* @param code 状态代码
* @return true-未购买,false-已购买
*/
public static boolean isNotBought(Integer code) {
return code != null && code == NOT_BOUGHT.code;
}
}

View File

@@ -0,0 +1,71 @@
package com.ycwl.basic.constant;
/**
* 免费状态枚举
* 定义源文件的收费和免费两种状态
*
* @author Claude
* @since 2025-10-31
*/
public enum FreeStatus {
/**
* 收费状态
*/
PAID(0, "收费"),
/**
* 免费状态
*/
FREE(1, "免费");
private final int code;
private final String description;
FreeStatus(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据代码值获取枚举
*
* @param code 状态代码
* @return 对应的枚举值,如果不存在返回 null
*/
public static FreeStatus fromCode(int code) {
for (FreeStatus status : values()) {
if (status.code == code) {
return status;
}
}
return null;
}
/**
* 判断给定的代码是否为免费状态
*
* @param code 状态代码
* @return true-免费,false-收费
*/
public static boolean isFree(Integer code) {
return code != null && code == FREE.code;
}
/**
* 判断给定的代码是否为收费状态
*
* @param code 状态代码
* @return true-收费,false-免费
*/
public static boolean isPaid(Integer code) {
return code != null && code == PAID.code;
}
}

View File

@@ -0,0 +1,71 @@
package com.ycwl.basic.constant;
/**
* 源文件类型枚举
* 定义视频和图片两种源文件类型
*
* @author Claude
* @since 2025-10-31
*/
public enum SourceType {
/**
* 视频类型
*/
VIDEO(1, "视频"),
/**
* 图片类型
*/
IMAGE(2, "图片");
private final int code;
private final String description;
SourceType(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据代码值获取枚举
*
* @param code 类型代码
* @return 对应的枚举值,如果不存在返回 null
*/
public static SourceType fromCode(int code) {
for (SourceType type : values()) {
if (type.code == code) {
return type;
}
}
return null;
}
/**
* 判断给定的代码是否为视频类型
*
* @param code 类型代码
* @return true-是视频,false-不是视频
*/
public static boolean isVideo(Integer code) {
return code != null && code == VIDEO.code;
}
/**
* 判断给定的代码是否为图片类型
*
* @param code 类型代码
* @return true-是图片,false-不是图片
*/
public static boolean isImage(Integer code) {
return code != null && code == IMAGE.code;
}
}

View File

@@ -12,29 +12,22 @@ import com.ycwl.basic.model.aio.entity.AioDevicePriceConfigEntity;
import com.ycwl.basic.model.aio.req.AioDeviceCreateOrderReq;
import com.ycwl.basic.model.aio.resp.AioDeviceCreateOrderResp;
import com.ycwl.basic.model.aio.resp.AioDeviceInfoResp;
import com.ycwl.basic.model.jwt.JwtInfo;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
import com.ycwl.basic.model.mobile.goods.GoodsReqQuery;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
import com.ycwl.basic.model.pc.member.entity.MemberEntity;
import com.ycwl.basic.model.pc.member.resp.MemberRespVO;
import com.ycwl.basic.pay.entity.PayResponse;
import com.ycwl.basic.service.aio.AioDeviceService;
import com.ycwl.basic.service.mobile.GoodsService;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.service.pc.OrderService;
import com.ycwl.basic.service.pc.ScenicService;
import com.ycwl.basic.service.pc.SourceService;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
import com.ycwl.basic.utils.SnowFlakeUtil;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
@@ -119,7 +112,7 @@ public class AioDeviceController {
memberEntity.setId(SnowFlakeUtil.getLongId());
memberEntity.setNickname("用户");
memberMapper.add(memberEntity);
FaceRecognizeResp resp = faceService.faceUpload(file, aioDevice.getScenicId(), memberEntity.getId());
FaceRecognizeResp resp = faceService.faceUpload(file, aioDevice.getScenicId(), memberEntity.getId(), "");
// 尝试超分
new Thread(() -> {
try {

View File

@@ -18,7 +18,6 @@ import com.ycwl.basic.model.pc.task.entity.TaskEntity;
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
import com.ycwl.basic.repository.VideoRepository;
import com.ycwl.basic.repository.VideoTaskRepository;
import com.ycwl.basic.service.mobile.AppScenicService;
import com.ycwl.basic.service.mobile.GoodsService;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.service.task.impl.TaskTaskServiceImpl;
@@ -114,7 +113,7 @@ public class LyCompatibleController {
}
FaceRecognizeResp resp;
try {
resp = faceService.faceUpload(file, scenicId, member.getId());
resp = faceService.faceUpload(file, scenicId, member.getId(), "");
} catch (Exception e) {
return R.error("上传失败!报错:"+e.getMessage());
}

View File

@@ -40,11 +40,13 @@ public class AppFaceController {
*/
// 人脸照片上传
@PostMapping("/faceUPload")
public ApiResponse<FaceRecognizeResp> faceUpload(@RequestParam("file")MultipartFile file, @RequestParam("scenicId") Long scenicId) {
public ApiResponse<FaceRecognizeResp> faceUpload(@RequestParam("file")MultipartFile file,
@RequestParam(value = "scene", defaultValue = "", required = false) String scene,
@RequestParam("scenicId") Long scenicId) {
//获取用户id
JwtInfo worker = JwtTokenUtil.getWorker();
Long userId = worker.getUserId();
FaceRecognizeResp resp = faceService.faceUpload(file, scenicId, userId);
FaceRecognizeResp resp = faceService.faceUpload(file, scenicId, userId, scene);
return ApiResponse.success(resp);
}

View File

@@ -37,9 +37,9 @@ public class AppPrinterController {
}
@GetMapping("/getListFor/{scenicId}")
public ApiResponse<List<MemberPrintResp>> getListFor(@PathVariable("scenicId") Long scenicId) {
public ApiResponse<List<MemberPrintResp>> getListFor(@PathVariable("scenicId") Long scenicId, @RequestParam(required = false) Long faceId) {
JwtInfo worker = JwtTokenUtil.getWorker();
return ApiResponse.success(printerService.getUserPhotoList(worker.getUserId(), scenicId));
return ApiResponse.success(printerService.getUserPhotoList(worker.getUserId(), scenicId, faceId));
}
@GetMapping("/getItem/{scenicId}/{id}")
@@ -53,24 +53,26 @@ public class AppPrinterController {
}
@PostMapping("/deleteFrom/{scenicId}/{id}")
public ApiResponse<?> deleteFrom(@PathVariable("scenicId") Long scenicId, @PathVariable("id") Long id) throws IOException {
public ApiResponse<?> deleteFrom(@PathVariable("scenicId") Long scenicId, @PathVariable("id") Long id) {
JwtInfo worker = JwtTokenUtil.getWorker();
printerService.deleteUserPhoto(worker.getUserId(), scenicId, id);
return ApiResponse.success(null);
}
@PostMapping("/uploadTo/{scenicId}")
public ApiResponse<?> upload(@PathVariable("scenicId") Long scenicId, @RequestParam(value = "file") MultipartFile file) throws IOException {
public ApiResponse<?> upload(@PathVariable("scenicId") Long scenicId,
@RequestParam(value = "file") MultipartFile file,
@RequestParam(value = "faceId", required = false) Long faceId) {
String[] split = file.getOriginalFilename().split("\\.");
String ext = split[split.length - 1];
String url = StorageFactory.use().uploadFile(file, "printer", UUID.randomUUID() + "." + ext);
Integer id = printerService.addUserPhoto(JwtTokenUtil.getWorker().getUserId(), scenicId, url);
Integer id = printerService.addUserPhoto(JwtTokenUtil.getWorker().getUserId(), scenicId, url, faceId);
return ApiResponse.success(id);
}
@PostMapping(value = "/uploadTo/{scenicId}/cropped/{id}", consumes = "multipart/form-data")
public ApiResponse<?> uploadReplace(@PathVariable("scenicId") Long scenicId,
@PathVariable("id") Long id,
@RequestPart(value = "crop", required = false) String crop,
@RequestPart(value = "file") MultipartFile file) throws IOException {
@RequestPart(value = "file") MultipartFile file) {
String[] split = file.getOriginalFilename().split("\\.");
String ext = split[split.length - 1];
String url = StorageFactory.use().uploadFile(file, "printer", UUID.randomUUID() + "." + ext);
@@ -78,8 +80,10 @@ public class AppPrinterController {
return ApiResponse.success(url);
}
@PostMapping("/uploadTo/{scenicId}/formSource")
public ApiResponse<?> uploadFromSource(@PathVariable("scenicId") Long scenicId, @RequestBody FromSourceReq req) throws IOException {
List<Integer> list = printerService.addUserPhotoFromSource(JwtTokenUtil.getWorker().getUserId(), scenicId, req);
public ApiResponse<?> uploadFromSource(@PathVariable("scenicId") Long scenicId,
@RequestBody FromSourceReq req,
@RequestParam(value = "faceId", required = false) Long faceId) {
List<Integer> list = printerService.addUserPhotoFromSource(JwtTokenUtil.getWorker().getUserId(), scenicId, req, faceId);
return ApiResponse.success(list);
}
@@ -95,16 +99,20 @@ public class AppPrinterController {
return ApiResponse.success(null);
}
@GetMapping("/price/{scenicId}")
public ApiResponse<?> queryPrice(@PathVariable("scenicId") Long scenicId) {
return ApiResponse.success(printerService.queryPrice(JwtTokenUtil.getWorker().getUserId(), scenicId));
public ApiResponse<?> queryPrice(@PathVariable("scenicId") Long scenicId,
@RequestParam(value = "faceId", required = false) Long faceId) {
return ApiResponse.success(printerService.queryPrice(JwtTokenUtil.getWorker().getUserId(), scenicId, faceId));
}
@PostMapping("/order/{scenicId}")
public ApiResponse<Map<String, Object>> createOrder(@PathVariable("scenicId") Long scenicId) {
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, null));
public ApiResponse<Map<String, Object>> createOrder(@PathVariable("scenicId") Long scenicId,
@RequestParam(value = "faceId", required = false) Long faceId) {
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, null, faceId));
}
@PostMapping("/order/{scenicId}/toPrinter/{printerId}")
public ApiResponse<Map<String, Object>> createOrderToPrinter(@PathVariable("scenicId") Long scenicId, @PathVariable("printerId") Integer printerId) {
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, printerId));
public ApiResponse<Map<String, Object>> createOrderToPrinter(@PathVariable("scenicId") Long scenicId,
@PathVariable("printerId") Integer printerId,
@RequestParam(value = "faceId", required = false) Long faceId) {
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, printerId, faceId));
}
}

View File

@@ -178,7 +178,7 @@ public class UserNotificationAuthController {
resp.setTemplates(templateAuthInfos);
log.info("成功获取景区通知模板ID及用户授权余额: scenicId={}, templateCount={}, memberId={}",
log.debug("成功获取景区通知模板ID及用户授权余额: scenicId={}, templateCount={}, memberId={}",
scenicId, templateIds.size(), memberId);
return ApiResponse.success(resp);

View File

@@ -368,50 +368,19 @@ public class DeviceV2Controller {
// ========== 景区设备管理操作 ==========
/**
* 获取景区IPC设备列表
*/
@GetMapping("/scenic/{scenicId}/ipc")
public ApiResponse<PageResponse<DeviceV2DTO>> getScenicIpcDevices(@PathVariable Long scenicId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize) {
log.info("获取景区IPC设备列表, scenicId: {}, page: {}, pageSize: {}", scenicId, page, pageSize);
try {
PageResponse<DeviceV2DTO> response = deviceIntegrationService.getScenicIpcDevices(scenicId, page, pageSize);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("获取景区IPC设备列表失败, scenicId: {}", scenicId, e);
return ApiResponse.fail("获取景区IPC设备列表失败: " + e.getMessage());
}
}
/**
* 获取景区激活设备列表
*/
@GetMapping("/scenic/{scenicId}/active")
public ApiResponse<PageResponse<DeviceV2DTO>> getScenicActiveDevices(@PathVariable Long scenicId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize) {
log.info("获取景区激活设备列表, scenicId: {}, page: {}, pageSize: {}", scenicId, page, pageSize);
try {
PageResponse<DeviceV2DTO> response = deviceIntegrationService.getScenicActiveDevices(scenicId, page, pageSize);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("获取景区激活设备列表失败, scenicId: {}", scenicId, e);
return ApiResponse.fail("获取景区激活设备列表失败: " + e.getMessage());
}
}
/**
* 获取景区所有设备列表
*/
@GetMapping("/scenic/{scenicId}/all")
@GetMapping("/scenic/{scenicId}")
public ApiResponse<PageResponse<DeviceV2DTO>> getScenicAllDevices(@PathVariable Long scenicId,
@RequestParam(required = false) String name,
@RequestParam(required = false) String type,
@RequestParam(required = false) String no,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize) {
log.info("获取景区所有设备列表, scenicId: {}, page: {}, pageSize: {}", scenicId, page, pageSize);
try {
PageResponse<DeviceV2DTO> response = deviceIntegrationService.listDevices(page, pageSize, null, null, null, null, scenicId);
PageResponse<DeviceV2DTO> response = deviceIntegrationService.listDevices(page, pageSize, name, no, type, null, scenicId);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("获取景区所有设备列表失败, scenicId: {}", scenicId, e);

View File

@@ -37,6 +37,7 @@ public interface PrinterMapper {
List<PrinterResp> listByScenicId(@Param("scenicId") Long scenicId);
List<MemberPrintResp> listRelation(@Param("memberId") Long memberId, @Param("scenicId") Long scenicId);
List<MemberPrintResp> listRelationByFaceId(Long memberId, Long scenicId, Long faceId);
int deleteUserPhoto(Long memberId, Long scenicId, Long relationId);

View File

@@ -14,6 +14,7 @@ public class MemberPrintEntity {
private Integer id;
private Long scenicId;
private Long memberId;
private Long faceId;
private String origUrl;
private String cropUrl;
private String printUrl;

View File

@@ -9,6 +9,7 @@ public class MemberPrintResp {
private Integer id;
private Long scenicId;
private String scenicName;
private Long faceId;
private Long memberId;
private String origUrl;
private String cropUrl;

View File

@@ -828,7 +828,7 @@ public class GoodsServiceImpl implements GoodsService {
result.setTotalSegmentCount(taskResult.getTotalSegmentCount());
result.setOriginalSegmentCount(taskResult.getOriginalSegmentCount());
log.info("视频更新检查完成: videoId={}, taskId={}, canUpdate={}, newSegmentCount={}",
log.debug("视频更新检查完成: videoId={}, taskId={}, canUpdate={}, newSegmentCount={}",
videoId, taskId, result.isCanUpdate(), result.getNewSegmentCount());
return result;

View File

@@ -28,7 +28,7 @@ public interface FaceService {
ApiResponse<Integer> deleteById(Long id);
ApiResponse<Integer> deleteByIds(List<Long> ids);
FaceRecognizeResp faceUpload(MultipartFile file, Long scenicId, Long userId);
FaceRecognizeResp faceUpload(MultipartFile file, Long scenicId, Long userId, String scene);
List<FaceRespVO> listByUser(Long userId, String scenicId);
SearchFaceRespVo matchFaceId(Long faceId);

View File

@@ -0,0 +1,169 @@
package com.ycwl.basic.service.pc.helper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
import static com.ycwl.basic.constant.FaceConstant.*;
/**
* 人脸识别指标记录器
* 负责记录人脸识别相关的计数指标到Redis
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class FaceMetricsRecorder {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 记录人脸识别次数到Redis
* 设置2天过期时间
*
* @param faceId 人脸ID
*/
public void recordRecognitionCount(Long faceId) {
if (faceId == null) {
return;
}
try {
String redisKey = FACE_RECOGNITION_COUNT_PFX + faceId;
// 使用Redis原子操作INCR增加计数
Long count = redisTemplate.opsForValue().increment(redisKey);
// 设置2天过期时间(48小时)
redisTemplate.expire(redisKey, 2, TimeUnit.DAYS);
log.debug("人脸识别计数更新:faceId={}, count={}", faceId, count);
} catch (Exception e) {
// 计数失败不应影响主要业务逻辑,只记录错误日志
log.error("记录人脸识别次数失败:faceId={}", faceId, e);
}
}
/**
* 记录自定义人脸匹配次数到Redis
* 设置2天过期时间
*
* @param faceId 人脸ID
*/
public void recordCustomMatchCount(Long faceId) {
if (faceId == null) {
return;
}
try {
String redisKey = FACE_CUSTOM_MATCH_COUNT_PFX + faceId;
Long count = redisTemplate.opsForValue().increment(redisKey);
redisTemplate.expire(redisKey, 2, TimeUnit.DAYS);
log.debug("自定义人脸匹配计数更新:faceId={}, count={}", faceId, count);
} catch (Exception e) {
log.error("记录自定义人脸匹配次数失败:faceId={}", faceId, e);
}
}
/**
* 记录低阈值检测的人脸ID到Redis
* 设置2天过期时间
*
* @param faceId 人脸ID
*/
public void recordLowThreshold(Long faceId) {
if (faceId == null) {
return;
}
try {
String redisKey = FACE_LOW_THRESHOLD_PFX + faceId;
// 设置标记,表示该人脸ID触发了低阈值检测
redisTemplate.opsForValue().set(redisKey, "1", 2, TimeUnit.DAYS);
log.debug("记录低阈值检测人脸:faceId={}", faceId);
} catch (Exception e) {
// 记录失败不应影响主要业务逻辑,只记录错误日志
log.error("记录低阈值检测人脸失败:faceId={}", faceId, e);
}
}
/**
* 获取人脸识别次数
*
* @param faceId 人脸ID
* @return 识别次数
*/
public long getRecognitionCount(Long faceId) {
if (faceId == null) {
return 0L;
}
try {
String countKey = FACE_RECOGNITION_COUNT_PFX + faceId;
String countStr = redisTemplate.opsForValue().get(countKey);
if (countStr != null) {
return Long.parseLong(countStr);
}
} catch (Exception e) {
log.warn("获取识别次数失败:faceId={}", faceId, e);
}
return 0L;
}
/**
* 获取自定义匹配次数
*
* @param faceId 人脸ID
* @return 自定义匹配次数
*/
public long getCustomMatchCount(Long faceId) {
if (faceId == null) {
return 0L;
}
try {
String customMatchKey = FACE_CUSTOM_MATCH_COUNT_PFX + faceId;
String customMatchCountStr = redisTemplate.opsForValue().get(customMatchKey);
if (customMatchCountStr != null) {
return Long.parseLong(customMatchCountStr);
}
} catch (Exception e) {
log.warn("获取自定义匹配次数失败:faceId={}", faceId, e);
}
return 0L;
}
/**
* 检查是否触发过低阈值检测
*
* @param faceId 人脸ID
* @return 是否触发过低阈值检测
*/
public boolean hasLowThreshold(Long faceId) {
if (faceId == null) {
return false;
}
try {
String lowThresholdKey = FACE_LOW_THRESHOLD_PFX + faceId;
return Boolean.TRUE.equals(redisTemplate.hasKey(lowThresholdKey));
} catch (Exception e) {
log.warn("检查低阈值状态失败:faceId={}", faceId, e);
return false;
}
}
}

View File

@@ -0,0 +1,214 @@
package com.ycwl.basic.service.pc.helper;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 景区配置门面类
* 提供类型安全的配置访问方法,避免配置键的字符串硬编码
* 集中管理所有景区配置项的访问逻辑
*
* @author Claude
* @since 2025-10-31
*/
@Slf4j
@Component
public class ScenicConfigFacade {
@Autowired
private ScenicRepository scenicRepository;
/**
* 获取景区配置管理器
*/
private ScenicConfigManager getConfig(Long scenicId) {
return scenicRepository.getScenicConfigManager(scenicId);
}
// ==================== 人脸识别相关配置 ====================
/**
* 是否优先手动选择人脸
* 如果为 true,则不自动创建任务
*
* @param scenicId 景区ID
* @return true-需要手动选择,false-自动创建任务
*/
public boolean isFaceSelectFirst(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return false;
}
Boolean value = config.getBoolean("face_select_first");
return Boolean.TRUE.equals(value);
}
/**
* 获取自定义人脸匹配的最大次数
* 用于限制用户手动重新匹配的次数
*
* @param scenicId 景区ID
* @return 最大匹配次数,null 或 0 表示不限制
*/
public Integer getFaceSelectMaxCount(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return null;
}
return config.getInteger("face_select_max_count");
}
/**
* 获取人脸分数低阈值
* 低于此分数的匹配结果会被标记为低置信度
*
* @param scenicId 景区ID
* @return 分数阈值,默认 30.0
*/
public Float getFaceScoreLowThreshold(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return 30.0F;
}
return config.getFloat("face_score_low_threshold", 30.0F);
}
/**
* 获取人脸检测辅助阈值
* 当匹配结果数量少于此阈值时,会触发补救逻辑
*
* @param scenicId 景区ID
* @return 辅助阈值,默认 0(不启用)
*/
public Integer getFaceDetectHelperThreshold(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return 0;
}
return config.getInteger("face_detect_helper_threshold", 0);
}
/**
* 获取人脸选择后处理模式
* 0-并集模式,1-交集模式
*
* @param scenicId 景区ID
* @return 后处理模式,默认 0
*/
public Integer getFaceSelectPostMode(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return 0;
}
return config.getInteger("face_select_post_mode", 0);
}
/**
* 获取重新匹配模式
* 用于决定在什么条件下需要重新进行人脸匹配
*
* @param scenicId 景区ID
* @return 匹配模式,默认 0
*/
public Integer getRematchMode(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return 0;
}
return config.getInteger("re_match_mode", 0);
}
// ==================== 源文件相关配置 ====================
/**
* 是否禁用源图片功能
*
* @param scenicId 景区ID
* @return true-禁用,false-启用
*/
public boolean isDisableSourceImage(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return false;
}
Boolean value = config.getBoolean("disable_source_image");
return Boolean.TRUE.equals(value);
}
/**
* 是否禁用源视频功能
*
* @param scenicId 景区ID
* @return true-禁用,false-启用
*/
public boolean isDisableSourceVideo(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return false;
}
Boolean value = config.getBoolean("disable_source_video");
return Boolean.TRUE.equals(value);
}
/**
* 获取免费照片数量
* 新用户首次识别时赠送的免费照片数量
*
* @param scenicId 景区ID
* @return 免费照片数量,null 或 0 表示不赠送
*/
public Integer getPhotoFreeNum(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return null;
}
return config.getInteger("photo_free_num");
}
// ==================== 游玩时间相关配置 ====================
/**
* 获取最大游玩时间(分钟)
* 用于判断照片是否在合理的游玩时间范围内
*
* @param scenicId 景区ID
* @return 最大游玩时间(分钟),null 表示不限制
*/
public Integer getTourTime(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return null;
}
return config.getInteger("tour_time");
}
/**
* 获取最小游玩时间(分钟)
* 用于判断照片是否在合理的游玩时间范围内
*
* @param scenicId 景区ID
* @return 最小游玩时间(分钟),null 表示不限制
*/
public Integer getTourMinTime(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return null;
}
return config.getInteger("tour_min_time");
}
/**
* 检查是否配置了游玩时间限制
*
* @param scenicId 景区ID
* @return true-已配置,false-未配置
*/
public boolean hasTourTimeConfig(Long scenicId) {
Integer maxTime = getTourTime(scenicId);
Integer minTime = getTourMinTime(scenicId);
return maxTime != null && minTime != null;
}
}

View File

@@ -0,0 +1,154 @@
package com.ycwl.basic.service.pc.helper;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.stream.Collectors;
/**
* 搜索结果合并器
* 负责合并多个人脸搜索结果
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class SearchResultMerger {
/**
* 合并多个搜索结果(默认使用并集模式)
*
* @param searchResults 搜索结果列表
* @return 合并后的结果
*/
public SearchFaceRespVo merge(List<SearchFaceRespVo> searchResults) {
return merge(searchResults, 0);
}
/**
* 合并多个搜索结果
*
* @param searchResults 搜索结果列表
* @param mergeMode 合并模式:0-并集,1-交集
* @return 合并后的结果
*/
public SearchFaceRespVo merge(List<SearchFaceRespVo> searchResults, Integer mergeMode) {
SearchFaceRespVo mergedResult = new SearchFaceRespVo();
if (searchResults == null || searchResults.isEmpty()) {
return mergedResult;
}
List<String> allSearchJsons = new ArrayList<>();
float maxScore = 0f;
float maxFirstMatchRate = 0f;
boolean hasLowThreshold = false;
// 收集基础信息
for (SearchFaceRespVo result : searchResults) {
if (result.getSearchResultJson() != null) {
allSearchJsons.add(result.getSearchResultJson());
}
if (result.getScore() > maxScore) {
maxScore = result.getScore();
}
if (result.getFirstMatchRate() > maxFirstMatchRate) {
maxFirstMatchRate = result.getFirstMatchRate();
}
if (result.isLowThreshold()) {
hasLowThreshold = true;
}
}
// 根据合并模式处理样本ID
List<Long> finalSampleIds;
if (Integer.valueOf(1).equals(mergeMode)) {
// 模式1:交集 - 只保留所有搜索结果中都出现的样本ID
finalSampleIds = computeIntersection(searchResults);
log.debug("使用交集模式合并搜索结果,交集样本数: {}", finalSampleIds.size());
} else {
// 模式0:并集(默认) - 收集所有样本ID并去重
Set<Long> allSampleIds = new LinkedHashSet<>();
for (SearchFaceRespVo result : searchResults) {
if (result.getSampleListIds() != null) {
allSampleIds.addAll(result.getSampleListIds());
}
}
finalSampleIds = new ArrayList<>(allSampleIds);
log.debug("使用并集模式合并搜索结果,并集样本数: {}", finalSampleIds.size());
}
mergedResult.setSampleListIds(finalSampleIds);
mergedResult.setSearchResultJson(String.join("|", allSearchJsons));
mergedResult.setScore(maxScore);
mergedResult.setFirstMatchRate(maxFirstMatchRate);
mergedResult.setLowThreshold(hasLowThreshold);
log.debug("合并搜索结果完成,模式={}, 最终样本数: {}", mergeMode, finalSampleIds.size());
return mergedResult;
}
/**
* 计算多个搜索结果的交集
* 返回在所有搜索结果中都出现的样本ID
*
* @param searchResults 搜索结果列表
* @return 交集样本ID列表
*/
public List<Long> computeIntersection(List<SearchFaceRespVo> searchResults) {
if (searchResults == null || searchResults.isEmpty()) {
return new ArrayList<>();
}
// 过滤掉空结果
List<List<Long>> validSampleLists = searchResults.stream()
.filter(result -> result.getSampleListIds() != null && !result.getSampleListIds().isEmpty())
.map(SearchFaceRespVo::getSampleListIds)
.toList();
if (validSampleLists.isEmpty()) {
return new ArrayList<>();
}
// 如果只有一个有效结果,直接返回
if (validSampleLists.size() == 1) {
return new ArrayList<>(validSampleLists.getFirst());
}
// 计算交集:从第一个列表开始,保留在所有其他列表中都出现的ID
Set<Long> intersection = new LinkedHashSet<>(validSampleLists.getFirst());
for (int i = 1; i < validSampleLists.size(); i++) {
intersection.retainAll(validSampleLists.get(i));
}
return new ArrayList<>(intersection);
}
/**
* 创建直接结果(模式2:不搜索,直接使用用户选择的faceSampleIds)
*
* @param faceSampleIds 用户选择的人脸样本ID列表
* @return 搜索结果对象
*/
public SearchFaceRespVo createDirectResult(List<Long> faceSampleIds) {
SearchFaceRespVo result = new SearchFaceRespVo();
// 直接使用用户选择的faceSampleIds作为结果
result.setSampleListIds(new ArrayList<>(faceSampleIds));
// 设置默认值
result.setScore(1.0f);
result.setFirstMatchRate(1.0f);
result.setLowThreshold(false);
result.setSearchResultJson("");
log.debug("创建直接结果,样本数: {}", faceSampleIds.size());
return result;
}
}

View File

@@ -6,7 +6,6 @@ import com.github.pagehelper.PageInfo;
import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.biz.TemplateBiz;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.enums.StatisticEnum;
import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.facebody.entity.SearchFaceResultItem;
@@ -16,7 +15,6 @@ import com.ycwl.basic.mapper.ProjectMapper;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.mapper.StatisticsMapper;
import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.mapper.TemplateMapper;
import com.ycwl.basic.mapper.VideoMapper;
import com.ycwl.basic.mapper.OrderMapper;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
@@ -24,7 +22,6 @@ import com.ycwl.basic.model.mobile.face.FaceStatusResp;
import com.ycwl.basic.model.mobile.goods.VideoTaskStatusVO;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.mobile.face.FaceRecognitionUpdateReq;
@@ -58,6 +55,18 @@ import com.ycwl.basic.repository.VideoTaskRepository;
import com.ycwl.basic.service.mobile.GoodsService;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.service.pc.ScenicService;
import com.ycwl.basic.constant.SourceType;
import com.ycwl.basic.service.pc.helper.FaceMetricsRecorder;
import com.ycwl.basic.service.pc.helper.SearchResultMerger;
import com.ycwl.basic.service.pc.helper.ScenicConfigFacade;
import com.ycwl.basic.service.pc.orchestrator.FaceMatchingOrchestrator;
import com.ycwl.basic.service.pc.processor.BuyStatusProcessor;
import com.ycwl.basic.service.pc.processor.FaceRecoveryStrategy;
import com.ycwl.basic.service.pc.processor.SourceRelationProcessor;
import com.ycwl.basic.service.pc.processor.VideoRecreationHandler;
import com.ycwl.basic.service.pc.strategy.RematchContext;
import com.ycwl.basic.service.pc.strategy.RematchModeStrategy;
import com.ycwl.basic.service.pc.strategy.RematchStrategyFactory;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.service.task.TaskFaceService;
import com.ycwl.basic.service.task.TaskService;
@@ -65,12 +74,10 @@ import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.storage.enums.StorageAcl;
import com.ycwl.basic.storage.utils.StorageUtil;
import com.ycwl.basic.task.VideoPieceGetter;
import com.ycwl.basic.utils.*;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -78,10 +85,8 @@ import java.io.File;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -90,14 +95,9 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static com.ycwl.basic.constant.FaceConstant.FACE_CUSTOM_MATCH_COUNT_PFX;
import static com.ycwl.basic.constant.FaceConstant.FACE_LOW_THRESHOLD_PFX;
import static com.ycwl.basic.constant.FaceConstant.FACE_RECOGNITION_COUNT_PFX;
import static com.ycwl.basic.constant.FaceConstant.USER_FACE_DB_NAME;
import static com.ycwl.basic.constant.StorageConstant.USER_FACE;
@@ -130,8 +130,6 @@ public class FaceServiceImpl implements FaceService {
@Autowired
private ScenicService scenicService;
@Autowired
private TemplateMapper templateMapper;
@Autowired
private VideoRepository videoRepository;
@Autowired
private VideoTaskRepository videoTaskRepository;
@@ -142,8 +140,6 @@ public class FaceServiceImpl implements FaceService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private FaceSampleMapper faceSampleMapper;
@Autowired
private GoodsService goodsService;
@@ -156,6 +152,30 @@ public class FaceServiceImpl implements FaceService {
@Autowired
private PrinterService printerService;
// 第一阶段的辅助类
@Autowired
private FaceMetricsRecorder metricsRecorder;
@Autowired
private SearchResultMerger resultMerger;
@Autowired
private RematchStrategyFactory rematchStrategyFactory;
@Autowired
private ScenicConfigFacade scenicConfigFacade;
// 编排器
@Autowired
private FaceMatchingOrchestrator faceMatchingOrchestrator;
// 第二阶段的处理器
@Autowired
private SourceRelationProcessor sourceRelationProcessor;
@Autowired
private BuyStatusProcessor buyStatusProcessor;
@Autowired
private VideoRecreationHandler videoRecreationHandler;
@Autowired
private FaceRecoveryStrategy faceRecoveryStrategy;
@Override
public ApiResponse<PageInfo<FaceRespVO>> pageQuery(FaceReqQuery faceReqQuery) {
PageHelper.startPage(faceReqQuery.getPageNum(),faceReqQuery.getPageSize());
@@ -203,7 +223,7 @@ public class FaceServiceImpl implements FaceService {
}
@Override
public FaceRecognizeResp faceUpload(MultipartFile file, Long scenicId, Long userId) {
public FaceRecognizeResp faceUpload(MultipartFile file, Long scenicId, Long userId, String scene) {
//1、上传人脸照片
IStorageAdapter adapter = StorageFactory.use("faces");
String filePath = StorageUtil.joinPath(USER_FACE, DateUtil.format(new Date(),"yyyy-MM-dd"));
@@ -271,7 +291,15 @@ public class FaceServiceImpl implements FaceService {
// 异步执行自动添加打印
Long finalFaceId = newFaceId;
new Thread(() -> autoAddPhotosToPreferPrint(finalFaceId), "auto-add-print-" + newFaceId).start();
Thread thread = new Thread(() -> autoAddPhotosToPreferPrint(finalFaceId), "auto-add-print-" + newFaceId);
thread.start();
if (org.apache.commons.lang3.Strings.CI.equals("print", scene)) {
try {
thread.join();
} catch (InterruptedException ignore) {
}
}
return resp;
}
@@ -284,135 +312,17 @@ public class FaceServiceImpl implements FaceService {
@Override
public SearchFaceRespVo matchFaceId(Long faceId) {
return matchFaceId(faceId, false);
return faceMatchingOrchestrator.orchestrateMatching(faceId, false);
}
@Override
public SearchFaceRespVo matchFaceId(Long faceId, boolean isNew) {
if (faceId == null) {
throw new IllegalArgumentException("faceId 不能为空");
}
// 1. 数据准备:获取人脸信息、景区配置、适配器等
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.warn("人脸不存在,faceId: {}", faceId);
return null;
}
if (!isNew && Integer.valueOf(1).equals(face.getIsManual())) {
log.info("人工选择的,无需匹配,faceId: {}", faceId);
return null;
}
log.debug("开始人脸匹配:faceId={}, isNew={}", faceId, isNew);
// 记录识别次数到Redis,设置2天过期时间
recordFaceRecognitionCount(faceId);
try {
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(face.getScenicId());
if (faceBodyAdapter == null) {
log.error("无法获取人脸识别适配器,scenicId: {}", face.getScenicId());
throw new BaseException("人脸识别服务不可用,请稍后再试");
}
// 2. 人脸识别:执行人脸搜索和补救逻辑
SearchFaceRespVo scenicDbSearchResult;
try {
scenicDbSearchResult = faceService.searchFace(faceBodyAdapter,
String.valueOf(face.getScenicId()),
face.getFaceUrl(),
"人脸识别");
} catch (Exception e) {
log.error("人脸识别服务调用失败,faceId={}, scenicId={}", faceId, face.getScenicId(), e);
throw new BaseException("人脸识别失败,请换一张试试把~");
}
if (scenicDbSearchResult == null) {
log.warn("人脸识别返回结果为空,faceId={}", faceId);
throw new BaseException("人脸识别失败,请换一张试试把~");
}
// 执行补救逻辑(如需要)
scenicDbSearchResult = executeFaceRecoveryLogic(scenicDbSearchResult, scenicConfig,
faceBodyAdapter, face.getScenicId());
// 3. 结果处理:更新人脸实体信息
try {
updateFaceEntityResult(face, scenicDbSearchResult, faceId);
} catch (Exception e) {
log.error("更新人脸结果失败,faceId={}", faceId, e);
throw new BaseException("保存人脸识别结果失败");
}
List<Long> sampleListIds = scenicDbSearchResult.getSampleListIds();
if (sampleListIds != null && !sampleListIds.isEmpty()) {
try {
// 4. 源文件关联:处理匹配到的源文件
List<MemberSourceEntity> memberSourceEntityList = processMemberSources(sampleListIds, face);
if (!memberSourceEntityList.isEmpty()) {
// 5. 业务逻辑处理:免费逻辑、购买状态、任务创建
List<Long> freeSourceIds = processFreeSourceLogic(memberSourceEntityList, scenicConfig, isNew);
processBuyStatus(memberSourceEntityList, freeSourceIds, face.getMemberId(),
face.getScenicId(), faceId);
// 处理视频重切逻辑
handleVideoRecreation(scenicConfig, memberSourceEntityList, faceId,
face.getMemberId(), sampleListIds, isNew);
// 过滤已存在的关联关系和无效的source引用,防止数据不一致
List<MemberSourceEntity> existingFiltered = sourceMapper.filterExistingRelations(memberSourceEntityList);
List<MemberSourceEntity> validFiltered = sourceMapper.filterValidSourceRelations(existingFiltered);
if (!validFiltered.isEmpty()) {
sourceMapper.addRelations(validFiltered);
log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}",
faceId, memberSourceEntityList.size(), validFiltered.size());
} else {
log.warn("没有有效的关联关系可创建: faceId={}, 原始数量={}", faceId, memberSourceEntityList.size());
}
memberRelationRepository.clearSCacheByFace(faceId);
// 检查景区配置中的 face_select_first,如果为 true 则不自动创建任务
Boolean faceSelectFirst = scenicConfig != null ? scenicConfig.getBoolean("face_select_first") : null;
if (!Boolean.TRUE.equals(faceSelectFirst)) {
taskTaskService.autoCreateTaskByFaceId(faceId);
} else {
log.debug("景区配置 face_select_first=true,跳过自动创建任务:faceId={}", faceId);
}
log.info("人脸匹配完成:faceId={}, 匹配样本数={}, 关联源文件数={}, 免费数={}",
faceId, sampleListIds.size(), memberSourceEntityList.size(), freeSourceIds.size());
}
} catch (Exception e) {
log.error("处理源文件关联失败,faceId={}", faceId, e);
// 源文件关联失败不影响主流程,记录错误但不抛出异常
}
} else {
log.warn("人脸匹配无结果:faceId={}", faceId);
// 检查低阈值检测结果,如果为true则记录该人脸ID到Redis
if (scenicDbSearchResult != null && scenicDbSearchResult.isLowThreshold()) {
recordLowThresholdFace(faceId);
log.debug("触发低阈值检测,记录faceId: {}", faceId);
}
}
return scenicDbSearchResult;
} catch (BaseException e) {
// 业务异常直接抛出
throw e;
} catch (Exception e) {
log.error("人脸匹配处理异常,faceId={}, isNew={}", faceId, isNew, e);
throw new BaseException("人脸匹配处理失败,请稍后重试");
}
return faceMatchingOrchestrator.orchestrateMatching(faceId, isNew);
}
/**
* 更新人脸实体结果信息
* 仅用于 handleCustomFaceMatching 方法
*/
private void updateFaceEntityResult(FaceEntity originalFace, SearchFaceRespVo searchResult, Long faceId) {
FaceEntity faceEntity = new FaceEntity();
@@ -443,270 +353,6 @@ public class FaceServiceImpl implements FaceService {
searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0);
}
/**
* 执行人脸识别补救逻辑
* 当匹配结果数量少于阈值时,使用第一个匹配结果重新进行人脸搜索
*/
private SearchFaceRespVo executeFaceRecoveryLogic(SearchFaceRespVo originalResult,
ScenicConfigManager scenicConfig,
IFaceBodyAdapter faceBodyAdapter,
Long scenicId) {
if (originalResult == null || originalResult.getSampleListIds() == null ||
originalResult.getFirstMatchRate() == null || originalResult.getSampleListIds().isEmpty()) {
return originalResult;
}
if (scenicConfig == null) {
return originalResult;
}
// 检查是否需要执行补救逻辑
Integer helperThreshold = scenicConfig.getInteger("face_detect_helper_threshold", 0);
if (helperThreshold == null || helperThreshold <= 0) {
return originalResult;
}
// 检查匹配结果数量是否少于阈值
if (originalResult.getSampleListIds().size() >= helperThreshold) {
return originalResult;
}
log.info("执行人脸识别补救逻辑,原匹配数量: {}, 阈值: {}",
originalResult.getSampleListIds().size(), helperThreshold);
// 获取第一个匹配结果
Long firstResultId = originalResult.getSampleListIds().getFirst();
FaceSampleEntity faceSample = faceRepository.getFaceSample(firstResultId);
if (faceSample == null) {
log.warn("补救逻辑失败:无法找到人脸样本, sampleId: {}", firstResultId);
return originalResult;
}
// 使用人脸样本重新进行搜索
try {
SearchFaceRespVo recoveryResult = faceService.searchFace(faceBodyAdapter,
String.valueOf(scenicId),
faceSample.getFaceUrl(),
"人脸补救措施1");
if (recoveryResult != null && recoveryResult.getSampleListIds() != null &&
!recoveryResult.getSampleListIds().isEmpty()) {
log.info("补救逻辑成功,新匹配数量: {}", recoveryResult.getSampleListIds().size());
return recoveryResult;
}
} catch (Exception e) {
log.warn("补救逻辑执行失败", e);
}
return originalResult;
}
/**
* 处理源文件关联逻辑
* 根据匹配的样本ID创建MemberSourceEntity列表
*/
private List<MemberSourceEntity> processMemberSources(List<Long> sampleListIds, FaceEntity face) {
if (sampleListIds == null || sampleListIds.isEmpty()) {
return Collections.emptyList();
}
List<SourceEntity> sourceEntities = sourceMapper.listBySampleIds(sampleListIds);
if (sourceEntities.isEmpty()) {
return Collections.emptyList();
}
List<SourceEntity> filteredSourceEntities = sourceEntities.stream()
.sorted(Comparator.comparing(SourceEntity::getCreateTime).reversed())
.collect(Collectors.groupingBy(SourceEntity::getDeviceId))
.entrySet()
.stream().flatMap(entry -> {
DeviceConfigManager configManager = deviceRepository.getDeviceConfigManager(entry.getKey());
if (configManager.getInteger("limit_video", 0) > 0) {
return Stream.concat(
entry.getValue().stream().filter(item -> item.getType() == 2),
entry.getValue().stream().filter(item -> item.getType() == 1).limit(Math.min(entry.getValue().size(), configManager.getInteger("limit_video", 0)))
);
}
return entry.getValue().stream();
}).toList();
return filteredSourceEntities.stream().map(sourceEntity -> {
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(sourceEntity.getDeviceId());
MemberSourceEntity memberSourceEntity = new MemberSourceEntity();
memberSourceEntity.setScenicId(face.getScenicId());
memberSourceEntity.setFaceId(face.getId());
memberSourceEntity.setMemberId(face.getMemberId());
memberSourceEntity.setSourceId(sourceEntity.getId());
memberSourceEntity.setType(sourceEntity.getType());
// 设置免费状态 - 默认收费
memberSourceEntity.setIsFree(0);
if (deviceConfig != null) {
// 视频类型检查
if (sourceEntity.getType() == 1) {
if (Integer.valueOf(1).equals(deviceConfig.getInteger("video_free"))) {
memberSourceEntity.setIsFree(1);
}
}
// 图片类型检查
else if (sourceEntity.getType() == 2) {
if (Integer.valueOf(1).equals(deviceConfig.getInteger("image_free"))) {
memberSourceEntity.setIsFree(1);
}
}
}
return memberSourceEntity;
}).collect(Collectors.toList());
}
/**
* 处理免费源文件逻辑
* 根据景区配置和是否新用户决定哪些照片可以免费
*/
private List<Long> processFreeSourceLogic(List<MemberSourceEntity> memberSourceEntityList,
ScenicConfigManager scenicConfig,
boolean isNew) {
List<Long> freeSourceIds = new ArrayList<>();
if (memberSourceEntityList.isEmpty()) {
return freeSourceIds;
}
if (isNew) {
// 新用户送照片逻辑
List<MemberSourceEntity> photoSource = memberSourceEntityList.stream()
.filter(item -> item.getIsFree() == 0) // 只考虑收费的
.filter(item -> Integer.valueOf(2).equals(item.getType())) // 只考虑照片类型
.toList();
Integer photoFreeNum = scenicConfig != null ? scenicConfig.getInteger("photo_free_num") : null;
if (scenicConfig != null && photoFreeNum != null && photoFreeNum > 0) {
int freePhotoCount = Math.min(photoFreeNum, photoSource.size());
freeSourceIds.addAll(photoSource.stream()
.limit(freePhotoCount)
.map(MemberSourceEntity::getSourceId)
.toList());
log.debug("新用户免费照片逻辑:配置免费数量 {}, 实际可用 {}, 赠送 {} 张",
photoFreeNum, photoSource.size(), freePhotoCount);
}
}
return freeSourceIds;
}
/**
* 处理购买状态逻辑
* 设置每个源文件的购买状态和免费状态
*/
private void processBuyStatus(List<MemberSourceEntity> memberSourceEntityList,
List<Long> freeSourceIds,
Long memberId,
Long scenicId,
Long faceId) {
if (memberSourceEntityList.isEmpty()) {
return;
}
// 获取用户购买状态
IsBuyRespVO isBuy = orderBiz.isBuy(memberId, scenicId,
memberSourceEntityList.getFirst().getType(),
faceId);
for (MemberSourceEntity memberSourceEntity : memberSourceEntityList) {
// 设置购买状态
if (isBuy.isBuy()) {
// 如果用户买过
memberSourceEntity.setIsBuy(1);
} else if (isBuy.isFree()) {
// 全免费逻辑
memberSourceEntity.setIsBuy(1);
} else {
memberSourceEntity.setIsBuy(0);
}
// 设置免费状态
if (freeSourceIds.contains(memberSourceEntity.getSourceId())) {
memberSourceEntity.setIsFree(1);
}
}
log.debug("购买状态处理完成:用户购买状态 isBuy={}, isFree={}, 免费源文件数量={}",
isBuy.isBuy(), isBuy.isFree(), freeSourceIds.size());
}
/**
* 处理视频重切逻辑
* 当非新用户且照片数量大于视频数量时,创建视频重切任务
*/
private void handleVideoRecreation(ScenicConfigManager scenicConfig,
List<MemberSourceEntity> memberSourceEntityList,
Long faceId,
Long memberId,
List<Long> sampleListIds,
boolean isNew) {
// 新用户不执行视频重切逻辑
if (isNew) {
return;
}
// 检查景区是否禁用源视频功能
Boolean disableSourceVideo = scenicConfig != null ? scenicConfig.getBoolean("disable_source_video") : null;
if (scenicConfig == null || Boolean.TRUE.equals(disableSourceVideo)) {
log.debug("视频重切逻辑跳过:景区禁用了源视频功能");
return;
}
// 统计视频和照片数量
long videoCount = memberSourceEntityList.stream()
.filter(item -> Integer.valueOf(1).equals(item.getType()))
.count();
long photoCount = memberSourceEntityList.stream()
.filter(item -> Integer.valueOf(2).equals(item.getType()))
.count();
List<FaceSampleEntity> faceSampleList = faceRepository.getFaceSampleList(faceId);
if (faceSampleList.isEmpty()) {
log.info("faceId:{} sample list not exist", faceId);
return;
}
List<Long> faceSampleIds = faceSampleList.stream()
.sorted(Comparator.comparing(FaceSampleEntity::getCreateAt).reversed())
.collect(Collectors.groupingBy(FaceSampleEntity::getDeviceId))
.entrySet()
.stream().flatMap(entry -> {
DeviceConfigManager configManager = deviceRepository.getDeviceConfigManager(entry.getKey());
if (configManager.getInteger("limit_video", 0) > 0) {
return entry.getValue().subList(0, Math.min(entry.getValue().size(), configManager.getInteger("limit_video", 0))).stream();
}
return entry.getValue().stream();
}).toList()
.stream().map(FaceSampleEntity::getId).toList();
log.info("视频切分任务: faceId={}, 原始数量={}, 筛选后数量={}", faceId, faceSampleList.size(), faceSampleIds.size());
log.debug("视频重切逻辑:视频数量 {}, 照片数量 {}", videoCount, photoCount);
// 只有照片数量大于视频数量时才创建重切任务
if (photoCount > videoCount) {
VideoPieceGetter.Task task = new VideoPieceGetter.Task();
task.faceId = faceId;
task.faceSampleIds = faceSampleIds;
task.templateId = null;
task.memberId = memberId;
task.callback = () -> {
log.info("视频重切任务回调: {}", task);
};
VideoPieceGetter.addTask(task);
log.debug("视频重切任务已创建:faceId={}, memberId={}, sampleIds={}",
faceId, memberId, sampleListIds.size());
} else {
log.debug("视频重切逻辑跳过:照片数量({})未超过视频数量({})", photoCount, videoCount);
}
}
@Override
public ApiResponse<String> deleteFace(Long faceId) {
FaceEntity face = faceRepository.getFace(faceId);
@@ -817,8 +463,8 @@ public class FaceServiceImpl implements FaceService {
sourceVideoContent.setGroup("直出原片");
sourceImageContent.setGroup("直出原片");
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
if (!Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_image"))) {
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(userId, face.getScenicId(), 2, faceId);
if (!scenicConfigFacade.isDisableSourceImage(face.getScenicId())) {
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(userId, face.getScenicId(), SourceType.IMAGE.getCode(), faceId);
sourceImageContent.setSourceType(isBuyRespVO.getGoodsType());
sourceImageContent.setContentId(isBuyRespVO.getGoodsId());
if (isBuyRespVO.isBuy()) {
@@ -836,8 +482,8 @@ public class FaceServiceImpl implements FaceService {
sourceImageContent.setFreeCount((int) freeCount);
contentList.add(sourceImageContent);
}
if (!Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_video"))) {
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(userId, face.getScenicId(), 1, faceId);
if (!scenicConfigFacade.isDisableSourceVideo(face.getScenicId())) {
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(userId, face.getScenicId(), SourceType.VIDEO.getCode(), faceId);
sourceVideoContent.setSourceType(isBuyRespVO.getGoodsType());
sourceVideoContent.setContentId(isBuyRespVO.getGoodsId());
if (isBuyRespVO.isBuy()) {
@@ -957,21 +603,11 @@ public class FaceServiceImpl implements FaceService {
statusResp.setFaceUrl(face.getFaceUrl());
// 查询识别次数
String countKey = FACE_RECOGNITION_COUNT_PFX + faceId;
String countStr = redisTemplate.opsForValue().get(countKey);
long recognitionCount = 0L;
if (countStr != null) {
try {
recognitionCount = Long.parseLong(countStr);
} catch (NumberFormatException e) {
log.warn("识别次数解析失败,faceId={}, count={}", faceId, countStr);
}
}
long recognitionCount = metricsRecorder.getRecognitionCount(faceId);
statusResp.setRecognitionCount(recognitionCount);
// 查询是否触发过低阈值检测
String lowThresholdKey = FACE_LOW_THRESHOLD_PFX + faceId;
Boolean hasLowThreshold = redisTemplate.hasKey(lowThresholdKey);
Boolean hasLowThreshold = metricsRecorder.hasLowThreshold(faceId);
statusResp.setHasLowThreshold(hasLowThreshold);
log.debug("查询人脸状态:faceId={}, recognitionCount={}, hasLowThreshold={}",
@@ -1013,37 +649,20 @@ public class FaceServiceImpl implements FaceService {
}
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
String recognitionKey = FACE_RECOGNITION_COUNT_PFX + faceId;
String recognitionCountStr = redisTemplate.opsForValue().get(recognitionKey);
long recognitionCount = 0L;
if (recognitionCountStr != null) {
try {
recognitionCount = Long.parseLong(recognitionCountStr);
} catch (NumberFormatException e) {
log.warn("识别次数解析失败,faceId={}, count={}", faceId, recognitionCountStr);
}
}
// 使用FaceMetricsRecorder获取计数信息
long recognitionCount = metricsRecorder.getRecognitionCount(faceId);
long customMatchCount = metricsRecorder.getCustomMatchCount(faceId);
boolean hasLowThreshold = metricsRecorder.hasLowThreshold(faceId);
String customMatchKey = FACE_CUSTOM_MATCH_COUNT_PFX + faceId;
String customMatchCountStr = redisTemplate.opsForValue().get(customMatchKey);
long customMatchCount = 0L;
if (customMatchCountStr != null) {
try {
customMatchCount = Long.parseLong(customMatchCountStr);
} catch (NumberFormatException e) {
log.warn("自定义匹配次数解析失败,faceId={}, count={}", faceId, customMatchCountStr);
}
}
Integer faceSelectMaxCount = scenicConfig.getInteger("face_select_max_count");
Integer faceSelectMaxCount = scenicConfigFacade.getFaceSelectMaxCount(face.getScenicId());
if (faceSelectMaxCount != null && faceSelectMaxCount > 0 && customMatchCount > faceSelectMaxCount) {
log.debug("自定义人脸匹配次数超过限制:faceId={}, customMatchCount={}, limit={}",
faceId, customMatchCount, faceSelectMaxCount);
return false;
}
Integer maxTourTime = scenicConfig.getInteger("tour_time");
Integer minTourTime = scenicConfig.getInteger("tour_min_time");
Integer maxTourTime = scenicConfigFacade.getTourTime(face.getScenicId());
Integer minTourTime = scenicConfigFacade.getTourMinTime(face.getScenicId());
boolean tourMatch = false;
if (maxTourTime != null && minTourTime != null) {
if ((new Date().getTime()) - face.getCreateAt().getTime() < maxTourTime * 60 * 1000
@@ -1080,16 +699,20 @@ public class FaceServiceImpl implements FaceService {
if (projectMatch) {
ruleMatched++;
}
// 查询是否触发过低阈值检测
String lowThresholdKey = FACE_LOW_THRESHOLD_PFX + faceId;
boolean hasLowThreshold = redisTemplate.hasKey(lowThresholdKey);
Integer mode = scenicConfig.getInteger("re_match_mode", 0);
return switch (mode) {
case 1 -> tourMatch || recognitionCount > 1 || hasLowThreshold;
case 5 -> hasLowThreshold || (ruleMatched >= 2);
case 9 -> hasLowThreshold && ruleMatched >= 2;
default -> false;
};
// 使用策略模式替换switch语句
Integer mode = scenicConfigFacade.getRematchMode(face.getScenicId());
RematchContext context = RematchContext.builder()
.recognitionCount(recognitionCount)
.hasLowThreshold(hasLowThreshold)
.tourMatch(tourMatch)
.projectMatch(projectMatch)
.ruleMatched(ruleMatched)
.faceCreateAt(face.getCreateAt())
.build();
RematchModeStrategy strategy = rematchStrategyFactory.getStrategy(mode);
return strategy.shouldRematch(context);
}
@Override
@@ -1106,7 +729,7 @@ public class FaceServiceImpl implements FaceService {
if (scenicConfig == null) {
return List.of();
}
Float lowThreshold = scenicConfig.getFloat("face_score_low_threshold", 30.0F);
Float lowThreshold = scenicConfigFacade.getFaceScoreLowThreshold(face.getScenicId());
List<SearchFaceResultItem> resultItems = JacksonUtil.fromJsonToList(matchResult, SearchFaceResultItem.class);
if (resultItems == null || resultItems.isEmpty()) {
return List.of();
@@ -1136,7 +759,7 @@ public class FaceServiceImpl implements FaceService {
log.debug("开始自定义人脸匹配:faceId={}, faceSampleIds={}", faceId, faceSampleIds);
// 记录自定义匹配调用次数,便于监控调用频率
recordCustomMatchCount(faceId);
metricsRecorder.recordCustomMatchCount(faceId);
try {
FaceEntity face = faceRepository.getFace(faceId);
@@ -1160,7 +783,7 @@ public class FaceServiceImpl implements FaceService {
}
// 获取face_select_post_mode配置,默认为0(并集)
Integer faceSelectPostMode = scenicConfig != null ? scenicConfig.getInteger("face_select_post_mode", 0) : 0;
Integer faceSelectPostMode = scenicConfigFacade.getFaceSelectPostMode(face.getScenicId());
log.debug("face_select_post_mode配置值: {}", faceSelectPostMode);
SearchFaceRespVo mergedResult;
@@ -1169,7 +792,7 @@ public class FaceServiceImpl implements FaceService {
if (Integer.valueOf(2).equals(faceSelectPostMode)) {
// 模式2:不搜索,直接使用用户选择的faceSampleIds
log.debug("使用模式2:直接使用用户选择的人脸样本,不进行搜索");
mergedResult = createDirectResult(faceSampleIds);
mergedResult = resultMerger.createDirectResult(faceSampleIds);
mergedResult.setSearchResultJson(face.getMatchResult()); // 没有检索
} else {
// 模式0(并集)和模式1(交集):需要进行搜索
@@ -1197,7 +820,7 @@ public class FaceServiceImpl implements FaceService {
}
// 2.2 根据模式整合多个搜索结果
mergedResult = mergeSearchResults(searchResults, faceSelectPostMode);
mergedResult = resultMerger.merge(searchResults, faceSelectPostMode);
}
// 3. 应用后置筛选逻辑
@@ -1222,14 +845,14 @@ public class FaceServiceImpl implements FaceService {
memberRelationRepository.clearSCacheByFace(faceId);
log.debug("人脸旧关系数据删除完成:faceId={}", faceId);
List<MemberSourceEntity> memberSourceEntityList = processMemberSources(sampleListIds, face);
List<MemberSourceEntity> memberSourceEntityList = sourceRelationProcessor.processMemberSources(sampleListIds, face);
if (!memberSourceEntityList.isEmpty()) {
List<Long> freeSourceIds = processFreeSourceLogic(memberSourceEntityList, scenicConfig, false);
processBuyStatus(memberSourceEntityList, freeSourceIds, face.getMemberId(),
List<Long> freeSourceIds = sourceRelationProcessor.processFreeSourceLogic(memberSourceEntityList, face.getScenicId(), false);
buyStatusProcessor.processBuyStatus(memberSourceEntityList, freeSourceIds, face.getMemberId(),
face.getScenicId(), faceId);
handleVideoRecreation(scenicConfig, memberSourceEntityList, faceId,
videoRecreationHandler.handleVideoRecreation(face.getScenicId(), memberSourceEntityList, faceId,
face.getMemberId(), sampleListIds, false);
List<MemberSourceEntity> existingFiltered = sourceMapper.filterExistingRelations(memberSourceEntityList);
@@ -1264,13 +887,6 @@ public class FaceServiceImpl implements FaceService {
}
}
/**
* 合并多个搜索结果(兼容老版本,默认使用并集模式)
*/
private SearchFaceRespVo mergeSearchResults(List<SearchFaceRespVo> searchResults) {
return mergeSearchResults(searchResults, 0);
}
@Override
public void updateRecognition(FaceRecognitionUpdateReq req) {
if (req == null || req.getFaceId() == null) {
@@ -1334,7 +950,7 @@ public class FaceServiceImpl implements FaceService {
detail.setFaceUrl(face.getFaceUrl());
detail.setScore(face.getScore());
detail.setFirstMatchRate(face.getFirstMatchRate() != null ? face.getFirstMatchRate().floatValue() : null);
detail.setLowThreshold(redisTemplate.hasKey(FACE_LOW_THRESHOLD_PFX + faceId));
detail.setLowThreshold(metricsRecorder.hasLowThreshold(faceId));
detail.setLastMatchedAt(face.getUpdateAt() != null ? face.getUpdateAt() : face.getCreateAt());
String matchResultJson = face.getMatchResult();
@@ -1534,200 +1150,6 @@ public class FaceServiceImpl implements FaceService {
return null;
}
/**
* 合并多个搜索结果
*
* @param searchResults 搜索结果列表
* @param mergeMode 合并模式:0-并集,1-交集
* @return 合并后的结果
*/
private SearchFaceRespVo mergeSearchResults(List<SearchFaceRespVo> searchResults, Integer mergeMode) {
SearchFaceRespVo mergedResult = new SearchFaceRespVo();
if (searchResults == null || searchResults.isEmpty()) {
return mergedResult;
}
List<String> allSearchJsons = new ArrayList<>();
float maxScore = 0f;
float maxFirstMatchRate = 0f;
boolean hasLowThreshold = false;
// 收集基础信息
for (SearchFaceRespVo result : searchResults) {
if (result.getSearchResultJson() != null) {
allSearchJsons.add(result.getSearchResultJson());
}
if (result.getScore() > maxScore) {
maxScore = result.getScore();
}
if (result.getFirstMatchRate() > maxFirstMatchRate) {
maxFirstMatchRate = result.getFirstMatchRate();
}
if (result.isLowThreshold()) {
hasLowThreshold = true;
}
}
// 根据合并模式处理样本ID
List<Long> finalSampleIds;
if (Integer.valueOf(1).equals(mergeMode)) {
// 模式1:交集 - 只保留所有搜索结果中都出现的样本ID
finalSampleIds = computeIntersection(searchResults);
log.debug("使用交集模式合并搜索结果,交集样本数: {}", finalSampleIds.size());
} else {
// 模式0:并集(默认) - 收集所有样本ID并去重
Set<Long> allSampleIds = new LinkedHashSet<>();
for (SearchFaceRespVo result : searchResults) {
if (result.getSampleListIds() != null) {
allSampleIds.addAll(result.getSampleListIds());
}
}
finalSampleIds = new ArrayList<>(allSampleIds);
log.debug("使用并集模式合并搜索结果,并集样本数: {}", finalSampleIds.size());
}
mergedResult.setSampleListIds(finalSampleIds);
mergedResult.setSearchResultJson(String.join("|", allSearchJsons));
mergedResult.setScore(maxScore);
mergedResult.setFirstMatchRate(maxFirstMatchRate);
mergedResult.setLowThreshold(hasLowThreshold);
log.debug("合并搜索结果完成,模式={}, 最终样本数: {}", mergeMode, finalSampleIds.size());
return mergedResult;
}
/**
* 计算多个搜索结果的交集
* 返回在所有搜索结果中都出现的样本ID
*/
private List<Long> computeIntersection(List<SearchFaceRespVo> searchResults) {
if (searchResults == null || searchResults.isEmpty()) {
return new ArrayList<>();
}
// 过滤掉空结果
List<List<Long>> validSampleLists = searchResults.stream()
.filter(result -> result.getSampleListIds() != null && !result.getSampleListIds().isEmpty())
.map(SearchFaceRespVo::getSampleListIds)
.toList();
if (validSampleLists.isEmpty()) {
return new ArrayList<>();
}
// 如果只有一个有效结果,直接返回
if (validSampleLists.size() == 1) {
return new ArrayList<>(validSampleLists.getFirst());
}
// 计算交集:从第一个列表开始,保留在所有其他列表中都出现的ID
Set<Long> intersection = new LinkedHashSet<>(validSampleLists.getFirst());
for (int i = 1; i < validSampleLists.size(); i++) {
intersection.retainAll(validSampleLists.get(i));
}
return new ArrayList<>(intersection);
}
/**
* 创建直接结果(模式2:不搜索,直接使用用户选择的faceSampleIds)
*
* @param faceSampleIds 用户选择的人脸样本ID列表
* @return 搜索结果对象
*/
private SearchFaceRespVo createDirectResult(List<Long> faceSampleIds) {
SearchFaceRespVo result = new SearchFaceRespVo();
// 直接使用用户选择的faceSampleIds作为结果
result.setSampleListIds(new ArrayList<>(faceSampleIds));
// 设置默认值
result.setScore(1.0f);
result.setFirstMatchRate(1.0f);
result.setLowThreshold(false);
result.setSearchResultJson("");
log.debug("创建直接结果,样本数: {}", faceSampleIds.size());
return result;
}
/**
* 记录自定义人脸匹配次数到Redis
*
* @param faceId 人脸ID
*/
private void recordCustomMatchCount(Long faceId) {
if (faceId == null) {
return;
}
try {
String redisKey = FACE_CUSTOM_MATCH_COUNT_PFX + faceId;
Long count = redisTemplate.opsForValue().increment(redisKey);
redisTemplate.expire(redisKey, 2, TimeUnit.DAYS);
log.debug("自定义人脸匹配计数更新:faceId={}, count={}", faceId, count);
} catch (Exception e) {
log.error("记录自定义人脸匹配次数失败:faceId={}", faceId, e);
}
}
/**
* 记录人脸识别次数到Redis
*
* @param faceId 人脸ID
*/
private void recordFaceRecognitionCount(Long faceId) {
if (faceId == null) {
return;
}
try {
String redisKey = FACE_RECOGNITION_COUNT_PFX + faceId;
// 使用Redis原子操作INCR增加计数
Long count = redisTemplate.opsForValue().increment(redisKey);
// 设置2天过期时间(48小时)
redisTemplate.expire(redisKey, 2, TimeUnit.DAYS);
log.debug("人脸识别计数更新:faceId={}, count={}", faceId, count);
} catch (Exception e) {
// 计数失败不应影响主要业务逻辑,只记录错误日志
log.error("记录人脸识别次数失败:faceId={}", faceId, e);
}
}
/**
* 记录低阈值检测的人脸ID到Redis
*
* @param faceId 人脸ID
*/
private void recordLowThresholdFace(Long faceId) {
if (faceId == null) {
return;
}
try {
String redisKey = FACE_LOW_THRESHOLD_PFX + faceId;
// 设置标记,表示该人脸ID触发了低阈值检测
redisTemplate.opsForValue().set(redisKey, "1", 2, TimeUnit.DAYS);
log.debug("记录低阈值检测人脸:faceId={}", faceId);
} catch (Exception e) {
// 记录失败不应影响主要业务逻辑,只记录错误日志
log.error("记录低阈值检测人脸失败:faceId={}", faceId, e);
}
}
/**
* 自动将人脸关联的照片添加到优先打印列表
* 根据景区和设备配置自动添加type=2的照片到用户打印列表
@@ -1817,7 +1239,7 @@ public class FaceServiceImpl implements FaceService {
// 10. 批量添加到打印列表
for (SourceEntity source : sourcesToAdd) {
try {
printerService.addUserPhoto(memberId, scenicId, source.getUrl());
printerService.addUserPhoto(memberId, scenicId, source.getUrl(), faceId);
totalAdded++;
} catch (Exception e) {
log.warn("添加照片到打印列表失败: sourceId={}, url={}, error={}",

View File

@@ -0,0 +1,333 @@
package com.ycwl.basic.service.pc.orchestrator;
import com.ycwl.basic.biz.OrderBiz;
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.SourceMapper;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.pc.ScenicService;
import com.ycwl.basic.service.pc.helper.FaceMetricsRecorder;
import com.ycwl.basic.service.pc.helper.ScenicConfigFacade;
import com.ycwl.basic.service.pc.processor.BuyStatusProcessor;
import com.ycwl.basic.service.pc.processor.FaceRecoveryStrategy;
import com.ycwl.basic.service.pc.processor.SourceRelationProcessor;
import com.ycwl.basic.service.pc.processor.VideoRecreationHandler;
import com.ycwl.basic.service.task.TaskFaceService;
import com.ycwl.basic.service.task.TaskService;
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.List;
import java.util.stream.Collectors;
/**
* 人脸匹配流程编排器
* 将 matchFaceId 的复杂流程拆分为清晰的步骤,每个步骤负责单一职责
*
* 主要步骤:
* 1. 数据准备 - 获取人脸信息、配置、适配器
* 2. 人脸识别 - 执行人脸搜索和补救逻辑
* 3. 结果更新 - 更新人脸实体信息
* 4. 源文件关联 - 处理匹配到的源文件
* 5. 业务处理 - 免费逻辑、购买状态、任务创建
* 6. 数据持久化 - 保存关联关系
*
* @author Claude
* @since 2025-10-31
*/
@Slf4j
@Component
public class FaceMatchingOrchestrator {
@Autowired
private FaceRepository faceRepository;
@Autowired
private FaceMapper faceMapper;
@Autowired
private ScenicRepository scenicRepository;
@Autowired
private ScenicService scenicService;
@Autowired
private TaskFaceService taskFaceService;
@Autowired
private TaskService taskService;
@Autowired
private SourceMapper sourceMapper;
@Autowired
private MemberRelationRepository memberRelationRepository;
// 辅助类和处理器
@Autowired
private FaceMetricsRecorder metricsRecorder;
@Autowired
private ScenicConfigFacade scenicConfigFacade;
@Autowired
private FaceRecoveryStrategy faceRecoveryStrategy;
@Autowired
private SourceRelationProcessor sourceRelationProcessor;
@Autowired
private BuyStatusProcessor buyStatusProcessor;
@Autowired
private VideoRecreationHandler videoRecreationHandler;
/**
* 编排人脸匹配的完整流程
*
* @param faceId 人脸ID
* @param isNew 是否新用户
* @return 人脸搜索结果
*/
public SearchFaceRespVo orchestrateMatching(Long faceId, boolean isNew) {
if (faceId == null) {
throw new IllegalArgumentException("faceId 不能为空");
}
// 步骤1: 数据准备
MatchingContext context = prepareMatchingContext(faceId, isNew);
if (context == null) {
return null;
}
// 记录识别次数
metricsRecorder.recordRecognitionCount(faceId);
try {
// 步骤2: 人脸识别
SearchFaceRespVo searchResult = executeFaceRecognition(context);
if (searchResult == null) {
log.warn("人脸识别返回结果为空,faceId={}", faceId);
throw new BaseException("人脸识别失败,请换一张试试把~");
}
// 执行补救逻辑
searchResult = faceRecoveryStrategy.executeFaceRecoveryLogic(
searchResult, context.scenicConfig, context.faceBodyAdapter, context.face.getScenicId());
// 步骤3: 更新人脸结果
updateFaceResult(context.face, searchResult, faceId);
// 步骤4-6: 处理源文件关联和业务逻辑
processSourceRelations(context, searchResult, faceId, isNew);
return searchResult;
} catch (BaseException e) {
throw e;
} catch (Exception e) {
log.error("人脸匹配流程异常,faceId={}", faceId, e);
throw new BaseException("人脸匹配处理失败");
}
}
/**
* 步骤1: 准备匹配上下文
* 获取人脸信息、景区配置、人脸识别适配器等必要数据
*/
private MatchingContext prepareMatchingContext(Long faceId, boolean isNew) {
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.warn("人脸不存在,faceId: {}", faceId);
return null;
}
// 人工选择的无需重新匹配(新用户除外)
if (!isNew && Integer.valueOf(1).equals(face.getIsManual())) {
log.info("人工选择的,无需匹配,faceId: {}", faceId);
return null;
}
log.debug("开始人脸匹配:faceId={}, isNew={}", faceId, isNew);
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(face.getScenicId());
if (faceBodyAdapter == null) {
log.error("无法获取人脸识别适配器,scenicId: {}", face.getScenicId());
throw new BaseException("人脸识别服务不可用,请稍后再试");
}
MatchingContext context = new MatchingContext();
context.face = face;
context.scenicConfig = scenicConfig;
context.faceBodyAdapter = faceBodyAdapter;
return context;
}
/**
* 步骤2: 执行人脸识别
* 调用人脸识别服务进行人脸搜索
*/
private SearchFaceRespVo executeFaceRecognition(MatchingContext context) {
try {
return taskFaceService.searchFace(
context.faceBodyAdapter,
String.valueOf(context.face.getScenicId()),
context.face.getFaceUrl(),
"人脸识别");
} catch (Exception e) {
log.error("人脸识别服务调用失败,faceId={}, scenicId={}",
context.face.getId(), context.face.getScenicId(), e);
throw new BaseException("人脸识别失败,请换一张试试把~");
}
}
/**
* 步骤3: 更新人脸结果
* 保存人脸匹配结果到数据库
*/
private void updateFaceResult(FaceEntity originalFace, SearchFaceRespVo searchResult, Long faceId) {
try {
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(faceEntity.getId());
log.debug("人脸结果更新成功:faceId={}, score={}, sampleCount={}",
faceId, searchResult.getScore(),
searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0);
} catch (Exception e) {
log.error("更新人脸结果失败,faceId={}", faceId, e);
throw new BaseException("保存人脸识别结果失败");
}
}
/**
* 步骤4-6: 处理源文件关联和业务逻辑
* 包括:源文件关联、免费逻辑、购买状态、视频重切、任务创建、数据持久化
*/
private void processSourceRelations(MatchingContext context, SearchFaceRespVo searchResult,
Long faceId, boolean isNew) {
List<Long> sampleListIds = searchResult.getSampleListIds();
if (sampleListIds == null || sampleListIds.isEmpty()) {
log.warn("人脸匹配无结果:faceId={}", faceId);
// 记录低阈值检测
if (searchResult.isLowThreshold()) {
metricsRecorder.recordLowThreshold(faceId);
log.debug("触发低阈值检测,记录faceId: {}", faceId);
}
return;
}
try {
// 4. 源文件关联:处理匹配到的源文件
List<MemberSourceEntity> memberSourceEntityList =
sourceRelationProcessor.processMemberSources(sampleListIds, context.face);
if (memberSourceEntityList.isEmpty()) {
log.warn("未找到有效的源文件,faceId={}", faceId);
return;
}
// 5. 业务逻辑处理
processBusinessLogic(context, memberSourceEntityList, faceId, sampleListIds, isNew);
// 6. 数据持久化
persistSourceRelations(faceId, memberSourceEntityList);
// 7. 任务创建
createTaskIfNeeded(context.face.getScenicId(), faceId);
log.info("人脸匹配完成:faceId={}, 匹配样本数={}, 关联源文件数={}",
faceId, sampleListIds.size(), memberSourceEntityList.size());
} catch (Exception e) {
log.error("处理源文件关联失败,faceId={}", faceId, e);
// 源文件关联失败不影响主流程,记录错误但不抛出异常
}
}
/**
* 步骤5: 业务逻辑处理
* 处理免费逻辑、购买状态、视频重切
*/
private void processBusinessLogic(MatchingContext context, List<MemberSourceEntity> memberSourceEntityList,
Long faceId, List<Long> sampleListIds, boolean isNew) {
// 免费逻辑
List<Long> freeSourceIds = sourceRelationProcessor.processFreeSourceLogic(
memberSourceEntityList, context.face.getScenicId(), isNew);
// 购买状态
buyStatusProcessor.processBuyStatus(
memberSourceEntityList, freeSourceIds,
context.face.getMemberId(), context.face.getScenicId(), faceId);
// 视频重切
videoRecreationHandler.handleVideoRecreation(
context.face.getScenicId(), memberSourceEntityList,
faceId, context.face.getMemberId(), sampleListIds, isNew);
}
/**
* 步骤6: 数据持久化
* 过滤并保存源文件关联关系
*/
private void persistSourceRelations(Long faceId, List<MemberSourceEntity> memberSourceEntityList) {
// 过滤已存在的关联关系和无效的source引用
List<MemberSourceEntity> existingFiltered = sourceMapper.filterExistingRelations(memberSourceEntityList);
List<MemberSourceEntity> validFiltered = sourceMapper.filterValidSourceRelations(existingFiltered);
if (!validFiltered.isEmpty()) {
sourceMapper.addRelations(validFiltered);
log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}",
faceId, memberSourceEntityList.size(), validFiltered.size());
} else {
log.warn("没有有效的关联关系可创建: faceId={}, 原始数量={}",
faceId, memberSourceEntityList.size());
}
// 清除缓存
memberRelationRepository.clearSCacheByFace(faceId);
}
/**
* 步骤7: 任务创建
* 根据配置决定是否自动创建任务
*/
private void createTaskIfNeeded(Long scenicId, Long faceId) {
if (!scenicConfigFacade.isFaceSelectFirst(scenicId)) {
taskService.autoCreateTaskByFaceId(faceId);
} else {
log.debug("景区配置 face_select_first=true,跳过自动创建任务:faceId={}", faceId);
}
}
/**
* 匹配上下文
* 封装匹配过程中需要的所有上下文信息
*/
private static class MatchingContext {
FaceEntity face;
ScenicConfigManager scenicConfig;
IFaceBodyAdapter faceBodyAdapter;
}
}

View File

@@ -0,0 +1,73 @@
package com.ycwl.basic.service.pc.processor;
import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.constant.BuyStatus;
import com.ycwl.basic.constant.FreeStatus;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 购买状态处理器
* 负责处理源文件的购买状态和免费状态
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class BuyStatusProcessor {
@Autowired
private OrderBiz orderBiz;
/**
* 处理购买状态逻辑
* 设置每个源文件的购买状态和免费状态
*
* @param memberSourceEntityList 源文件关联列表
* @param freeSourceIds 免费的源文件ID列表
* @param memberId 会员ID
* @param scenicId 景区ID
* @param faceId 人脸ID
*/
public void processBuyStatus(List<MemberSourceEntity> memberSourceEntityList,
List<Long> freeSourceIds,
Long memberId,
Long scenicId,
Long faceId) {
if (memberSourceEntityList.isEmpty()) {
return;
}
// 获取用户购买状态
IsBuyRespVO isBuy = orderBiz.isBuy(memberId, scenicId,
memberSourceEntityList.getFirst().getType(),
faceId);
for (MemberSourceEntity memberSourceEntity : memberSourceEntityList) {
// 设置购买状态
if (isBuy.isBuy()) {
// 如果用户买过
memberSourceEntity.setIsBuy(BuyStatus.BOUGHT.getCode());
} else if (isBuy.isFree()) {
// 全免费逻辑
memberSourceEntity.setIsBuy(BuyStatus.BOUGHT.getCode());
} else {
memberSourceEntity.setIsBuy(BuyStatus.NOT_BOUGHT.getCode());
}
// 设置免费状态
if (freeSourceIds.contains(memberSourceEntity.getSourceId())) {
memberSourceEntity.setIsFree(FreeStatus.FREE.getCode());
}
}
log.debug("购买状态处理完成:用户购买状态 isBuy={}, isFree={}, 免费源文件数量={}",
isBuy.isBuy(), isBuy.isFree(), freeSourceIds.size());
}
}

View File

@@ -0,0 +1,98 @@
package com.ycwl.basic.service.pc.processor;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.service.pc.helper.ScenicConfigFacade;
import com.ycwl.basic.service.task.TaskFaceService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 人脸识别补救策略
* 负责执行人脸识别的补救逻辑
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class FaceRecoveryStrategy {
@Autowired
private FaceRepository faceRepository;
@Autowired
private TaskFaceService faceService;
@Autowired
private ScenicConfigFacade scenicConfigFacade;
/**
* 执行人脸识别补救逻辑
* 当匹配结果数量少于阈值时,使用第一个匹配结果重新进行人脸搜索
*
* @param originalResult 原始搜索结果
* @param scenicConfig 景区配置
* @param faceBodyAdapter 人脸识别适配器
* @param scenicId 景区ID
* @return 补救后的搜索结果(如果不需要补救或补救失败则返回原始结果)
*/
public SearchFaceRespVo executeFaceRecoveryLogic(SearchFaceRespVo originalResult,
ScenicConfigManager scenicConfig,
IFaceBodyAdapter faceBodyAdapter,
Long scenicId) {
if (originalResult == null || originalResult.getSampleListIds() == null ||
originalResult.getFirstMatchRate() == null || originalResult.getSampleListIds().isEmpty()) {
return originalResult;
}
if (scenicConfig == null) {
return originalResult;
}
// 检查是否需要执行补救逻辑
Integer helperThreshold = scenicConfigFacade.getFaceDetectHelperThreshold(scenicId);
if (helperThreshold == null || helperThreshold <= 0) {
return originalResult;
}
// 检查匹配结果数量是否少于阈值
if (originalResult.getSampleListIds().size() >= helperThreshold) {
return originalResult;
}
log.info("执行人脸识别补救逻辑,原匹配数量: {}, 阈值: {}",
originalResult.getSampleListIds().size(), helperThreshold);
// 获取第一个匹配结果
Long firstResultId = originalResult.getSampleListIds().getFirst();
FaceSampleEntity faceSample = faceRepository.getFaceSample(firstResultId);
if (faceSample == null) {
log.warn("补救逻辑失败:无法找到人脸样本, sampleId: {}", firstResultId);
return originalResult;
}
// 使用人脸样本重新进行搜索
try {
SearchFaceRespVo recoveryResult = faceService.searchFace(faceBodyAdapter,
String.valueOf(scenicId),
faceSample.getFaceUrl(),
"人脸补救措施1");
if (recoveryResult != null && recoveryResult.getSampleListIds() != null &&
!recoveryResult.getSampleListIds().isEmpty()) {
log.info("补救逻辑成功,新匹配数量: {}", recoveryResult.getSampleListIds().size());
return recoveryResult;
}
} catch (Exception e) {
log.warn("补救逻辑执行失败", e);
}
return originalResult;
}
}

View File

@@ -0,0 +1,150 @@
package com.ycwl.basic.service.pc.processor;
import com.ycwl.basic.constant.FreeStatus;
import com.ycwl.basic.constant.SourceType;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.repository.DeviceRepository;
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;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 源文件关联处理器
* 负责处理人脸匹配后的源文件关联逻辑
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class SourceRelationProcessor {
@Autowired
private SourceMapper sourceMapper;
@Autowired
private DeviceRepository deviceRepository;
@Autowired
private ScenicConfigFacade scenicConfigFacade;
/**
* 处理源文件关联逻辑
* 根据匹配的样本ID创建MemberSourceEntity列表
*
* @param sampleListIds 匹配的样本ID列表
* @param face 人脸实体
* @return MemberSourceEntity列表
*/
public List<MemberSourceEntity> processMemberSources(List<Long> sampleListIds, FaceEntity face) {
if (sampleListIds == null || sampleListIds.isEmpty()) {
return Collections.emptyList();
}
List<SourceEntity> sourceEntities = sourceMapper.listBySampleIds(sampleListIds);
if (sourceEntities.isEmpty()) {
return Collections.emptyList();
}
// 按设备分组并应用限制
List<SourceEntity> filteredSourceEntities = sourceEntities.stream()
.sorted(Comparator.comparing(SourceEntity::getCreateTime).reversed())
.collect(Collectors.groupingBy(SourceEntity::getDeviceId))
.entrySet()
.stream().flatMap(entry -> {
DeviceConfigManager configManager = deviceRepository.getDeviceConfigManager(entry.getKey());
if (configManager.getInteger("limit_video", 0) > 0) {
// 优先保留所有图片,然后限制视频数量
return Stream.concat(
entry.getValue().stream().filter(item -> SourceType.isImage(item.getType())),
entry.getValue().stream().filter(item -> SourceType.isVideo(item.getType()))
.limit(Math.min(entry.getValue().size(), configManager.getInteger("limit_video", 0)))
);
}
return entry.getValue().stream();
}).toList();
// 创建MemberSourceEntity列表
return filteredSourceEntities.stream().map(sourceEntity -> {
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(sourceEntity.getDeviceId());
MemberSourceEntity memberSourceEntity = new MemberSourceEntity();
memberSourceEntity.setScenicId(face.getScenicId());
memberSourceEntity.setFaceId(face.getId());
memberSourceEntity.setMemberId(face.getMemberId());
memberSourceEntity.setSourceId(sourceEntity.getId());
memberSourceEntity.setType(sourceEntity.getType());
// 设置免费状态 - 默认收费
memberSourceEntity.setIsFree(FreeStatus.PAID.getCode());
if (deviceConfig != null) {
// 视频类型检查
if (SourceType.isVideo(sourceEntity.getType())) {
if (Integer.valueOf(1).equals(deviceConfig.getInteger("video_free"))) {
memberSourceEntity.setIsFree(FreeStatus.FREE.getCode());
}
}
// 图片类型检查
else if (SourceType.isImage(sourceEntity.getType())) {
if (Integer.valueOf(1).equals(deviceConfig.getInteger("image_free"))) {
memberSourceEntity.setIsFree(FreeStatus.FREE.getCode());
}
}
}
return memberSourceEntity;
}).collect(Collectors.toList());
}
/**
* 处理免费源文件逻辑
* 根据景区配置和是否新用户决定哪些照片可以免费
*
* @param memberSourceEntityList 源文件关联列表
* @param scenicId 景区ID
* @param isNew 是否新用户
* @return 免费的源文件ID列表
*/
public List<Long> processFreeSourceLogic(List<MemberSourceEntity> memberSourceEntityList,
Long scenicId,
boolean isNew) {
List<Long> freeSourceIds = new ArrayList<>();
if (memberSourceEntityList.isEmpty()) {
return freeSourceIds;
}
if (isNew) {
// 新用户送照片逻辑
List<MemberSourceEntity> photoSource = memberSourceEntityList.stream()
.filter(item -> FreeStatus.isPaid(item.getIsFree())) // 只考虑收费的
.filter(item -> SourceType.isImage(item.getType())) // 只考虑照片类型
.toList();
Integer photoFreeNum = scenicConfigFacade.getPhotoFreeNum(scenicId);
if (photoFreeNum != null && photoFreeNum > 0) {
int freePhotoCount = Math.min(photoFreeNum, photoSource.size());
freeSourceIds.addAll(photoSource.stream()
.limit(freePhotoCount)
.map(MemberSourceEntity::getSourceId)
.toList());
log.debug("新用户免费照片逻辑:配置免费数量 {}, 实际可用 {}, 赠送 {} 张",
photoFreeNum, photoSource.size(), freePhotoCount);
}
}
return freeSourceIds;
}
}

View File

@@ -0,0 +1,117 @@
package com.ycwl.basic.service.pc.processor;
import com.ycwl.basic.constant.SourceType;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.service.pc.helper.ScenicConfigFacade;
import com.ycwl.basic.task.VideoPieceGetter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* 视频重切处理器
* 负责处理视频重切任务的创建逻辑
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class VideoRecreationHandler {
@Autowired
private FaceRepository faceRepository;
@Autowired
private DeviceRepository deviceRepository;
@Autowired
private ScenicConfigFacade scenicConfigFacade;
/**
* 处理视频重切逻辑
* 当非新用户且照片数量大于视频数量时,创建视频重切任务
*
* @param scenicId 景区ID
* @param memberSourceEntityList 源文件关联列表
* @param faceId 人脸ID
* @param memberId 会员ID
* @param sampleListIds 样本ID列表(用于日志)
* @param isNew 是否新用户
*/
public void handleVideoRecreation(Long scenicId,
List<MemberSourceEntity> memberSourceEntityList,
Long faceId,
Long memberId,
List<Long> sampleListIds,
boolean isNew) {
// 新用户不执行视频重切逻辑
if (isNew) {
return;
}
// 检查景区是否禁用源视频功能
if (scenicConfigFacade.isDisableSourceVideo(scenicId)) {
log.debug("视频重切逻辑跳过:景区禁用了源视频功能");
return;
}
// 统计视频和照片数量
long videoCount = memberSourceEntityList.stream()
.filter(item -> SourceType.isVideo(item.getType()))
.count();
long photoCount = memberSourceEntityList.stream()
.filter(item -> SourceType.isImage(item.getType()))
.count();
List<FaceSampleEntity> faceSampleList = faceRepository.getFaceSampleList(faceId);
if (faceSampleList.isEmpty()) {
log.info("faceId:{} sample list not exist", faceId);
return;
}
// 筛选样本ID
List<Long> faceSampleIds = faceSampleList.stream()
.sorted(Comparator.comparing(FaceSampleEntity::getCreateAt).reversed())
.collect(Collectors.groupingBy(FaceSampleEntity::getDeviceId))
.entrySet()
.stream().flatMap(entry -> {
DeviceConfigManager configManager = deviceRepository.getDeviceConfigManager(entry.getKey());
if (configManager.getInteger("limit_video", 0) > 0) {
return entry.getValue().subList(0, Math.min(entry.getValue().size(), configManager.getInteger("limit_video", 0))).stream();
}
return entry.getValue().stream();
}).toList()
.stream().map(FaceSampleEntity::getId).toList();
log.info("视频切分任务: faceId={}, 原始数量={}, 筛选后数量={}", faceId, faceSampleList.size(), faceSampleIds.size());
log.debug("视频重切逻辑:视频数量 {}, 照片数量 {}", videoCount, photoCount);
// 只有照片数量大于视频数量时才创建重切任务
if (photoCount > videoCount) {
VideoPieceGetter.Task task = new VideoPieceGetter.Task();
task.faceId = faceId;
task.faceSampleIds = faceSampleIds;
task.templateId = null;
task.memberId = memberId;
task.callback = () -> {
log.info("视频重切任务回调: {}", task);
};
VideoPieceGetter.addTask(task);
log.debug("视频重切任务已创建:faceId={}, memberId={}, sampleIds={}",
faceId, memberId, sampleListIds.size());
} else {
log.debug("视频重切逻辑跳过:照片数量({})未超过视频数量({})", photoCount, videoCount);
}
}
}

View File

@@ -0,0 +1,47 @@
package com.ycwl.basic.service.pc.strategy;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
/**
* 重匹配上下文
* 包含判断是否需要重匹配的所有必要信息
*
* @author longbinbin
* @date 2025-01-31
*/
@Data
@Builder
public class RematchContext {
/**
* 人脸识别次数
*/
private long recognitionCount;
/**
* 是否触发低阈值检测
*/
private boolean hasLowThreshold;
/**
* 是否符合游览时间匹配
*/
private boolean tourMatch;
/**
* 是否符合项目时间匹配
*/
private boolean projectMatch;
/**
* 规则匹配数量
*/
private int ruleMatched;
/**
* 人脸创建时间
*/
private Date faceCreateAt;
}

View File

@@ -0,0 +1,26 @@
package com.ycwl.basic.service.pc.strategy;
/**
* 重匹配模式策略接口
* 用于判断是否需要进行人脸重新匹配
*
* @author longbinbin
* @date 2025-01-31
*/
public interface RematchModeStrategy {
/**
* 判断是否应该重新匹配
*
* @param context 重匹配上下文
* @return true-需要重匹配, false-不需要重匹配
*/
boolean shouldRematch(RematchContext context);
/**
* 获取策略对应的模式值
*
* @return 模式值
*/
int getMode();
}

View File

@@ -0,0 +1,60 @@
package com.ycwl.basic.service.pc.strategy;
import com.ycwl.basic.service.pc.strategy.impl.DefaultRematchStrategy;
import com.ycwl.basic.service.pc.strategy.impl.RematchMode1Strategy;
import com.ycwl.basic.service.pc.strategy.impl.RematchMode5Strategy;
import com.ycwl.basic.service.pc.strategy.impl.RematchMode9Strategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 重匹配策略工厂
* 根据模式值获取对应的策略实例
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class RematchStrategyFactory {
@Autowired
private List<RematchModeStrategy> strategies;
private final Map<Integer, RematchModeStrategy> strategyMap = new HashMap<>();
@PostConstruct
public void init() {
for (RematchModeStrategy strategy : strategies) {
strategyMap.put(strategy.getMode(), strategy);
log.debug("注册重匹配策略: mode={}, class={}",
strategy.getMode(), strategy.getClass().getSimpleName());
}
}
/**
* 根据模式值获取对应的策略
*
* @param mode 模式值(0-默认, 1-模式1, 5-模式5, 9-模式9)
* @return 对应的策略实例,如果没有找到则返回默认策略
*/
public RematchModeStrategy getStrategy(Integer mode) {
if (mode == null) {
return strategyMap.getOrDefault(0, new DefaultRematchStrategy());
}
RematchModeStrategy strategy = strategyMap.get(mode);
if (strategy == null) {
log.warn("未找到重匹配模式{}对应的策略,使用默认策略", mode);
return strategyMap.getOrDefault(0, new DefaultRematchStrategy());
}
return strategy;
}
}

View File

@@ -0,0 +1,29 @@
package com.ycwl.basic.service.pc.strategy.impl;
import com.ycwl.basic.service.pc.strategy.RematchContext;
import com.ycwl.basic.service.pc.strategy.RematchModeStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 默认重匹配策略(模式0或其他未定义模式)
* 条件: 不触发重匹配
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class DefaultRematchStrategy implements RematchModeStrategy {
@Override
public boolean shouldRematch(RematchContext context) {
log.debug("DefaultRematchStrategy判断: 默认不重匹配");
return false;
}
@Override
public int getMode() {
return 0;
}
}

View File

@@ -0,0 +1,37 @@
package com.ycwl.basic.service.pc.strategy.impl;
import com.ycwl.basic.service.pc.strategy.RematchContext;
import com.ycwl.basic.service.pc.strategy.RematchModeStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 重匹配模式1策略
* 条件: tourMatch || recognitionCount > 1 || hasLowThreshold
* 满足任一条件即可重匹配
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class RematchMode1Strategy implements RematchModeStrategy {
@Override
public boolean shouldRematch(RematchContext context) {
boolean result = context.isTourMatch()
|| context.getRecognitionCount() > 1
|| context.isHasLowThreshold();
log.debug("RematchMode1Strategy判断: tourMatch={}, recognitionCount={}, hasLowThreshold={}, result={}",
context.isTourMatch(), context.getRecognitionCount(),
context.isHasLowThreshold(), result);
return result;
}
@Override
public int getMode() {
return 1;
}
}

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.service.pc.strategy.impl;
import com.ycwl.basic.service.pc.strategy.RematchContext;
import com.ycwl.basic.service.pc.strategy.RematchModeStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 重匹配模式5策略
* 条件: hasLowThreshold || (ruleMatched >= 2)
* 触发低阈值或匹配2个及以上规则即可重匹配
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class RematchMode5Strategy implements RematchModeStrategy {
@Override
public boolean shouldRematch(RematchContext context) {
boolean result = context.isHasLowThreshold()
|| context.getRuleMatched() >= 2;
log.debug("RematchMode5Strategy判断: hasLowThreshold={}, ruleMatched={}, result={}",
context.isHasLowThreshold(), context.getRuleMatched(), result);
return result;
}
@Override
public int getMode() {
return 5;
}
}

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.service.pc.strategy.impl;
import com.ycwl.basic.service.pc.strategy.RematchContext;
import com.ycwl.basic.service.pc.strategy.RematchModeStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 重匹配模式9策略
* 条件: hasLowThreshold && (ruleMatched >= 2)
* 必须同时触发低阈值且匹配2个及以上规则才可重匹配
*
* @author longbinbin
* @date 2025-01-31
*/
@Slf4j
@Component
public class RematchMode9Strategy implements RematchModeStrategy {
@Override
public boolean shouldRematch(RematchContext context) {
boolean result = context.isHasLowThreshold()
&& context.getRuleMatched() >= 2;
log.debug("RematchMode9Strategy判断: hasLowThreshold={}, ruleMatched={}, result={}",
context.isHasLowThreshold(), context.getRuleMatched(), result);
return result;
}
@Override
public int getMode() {
return 9;
}
}

View File

@@ -32,13 +32,13 @@ public interface PrinterService {
void taskFail(Integer taskId, WorkerAuthReqVo req);
List<MemberPrintResp> getUserPhotoList(Long userId, Long scenicId);
List<MemberPrintResp> getUserPhotoList(Long userId, Long scenicId, Long faceId);
List<MemberPrintResp> getUserPhotoListByOrderId(Long orderId);
boolean deleteUserPhoto(Long memberId, Long scenicId, Long relationId);
Integer addUserPhoto(Long memberId, Long scenicId, String url);
Integer addUserPhoto(Long memberId, Long scenicId, String url, Long faceId);
MemberPrintResp getUserPhoto(Long memberId, Long scenicId, Long id);
@@ -46,11 +46,11 @@ public interface PrinterService {
int setPhotoQuantity(Long memberId, Long scenicId, Long id, Integer quantity);
PriceObj queryPrice(Long memberId, Long scenicId);
PriceObj queryPrice(Long memberId, Long scenicId, Long faceId);
List<Integer> addUserPhotoFromSource(Long memberId, Long scenicId, FromSourceReq req);
List<Integer> addUserPhotoFromSource(Long memberId, Long scenicId, FromSourceReq req, Long faceId);
Map<String, Object> createOrder(Long memberId, Long scenicId, Integer printerId);
Map<String, Object> createOrder(Long memberId, Long scenicId, Integer printerId, Long faceId);
void batchSetUserPhotoListToPrinter(Long memberId, Long scenicId, Integer printerId);

View File

@@ -9,7 +9,6 @@ import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.image.watermark.exception.ImageWatermarkException;
import com.ycwl.basic.image.watermark.operator.IOperator;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.MemberMapper;
@@ -21,7 +20,6 @@ import com.ycwl.basic.model.mobile.order.PriceObj;
import com.ycwl.basic.model.pc.member.resp.MemberRespVO;
import com.ycwl.basic.model.pc.order.entity.OrderEntity;
import com.ycwl.basic.model.pc.order.entity.OrderItemEntity;
import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity;
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
import com.ycwl.basic.pricing.dto.ProductItem;
@@ -38,7 +36,6 @@ import com.ycwl.basic.model.printer.req.PrinterSyncReq;
import com.ycwl.basic.model.printer.req.WorkerAuthReqVo;
import com.ycwl.basic.model.printer.resp.PrintTaskResp;
import com.ycwl.basic.model.wx.WXPayOrderReqVO;
import com.ycwl.basic.repository.PriceRepository;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.mobile.WxPayService;
import com.ycwl.basic.service.printer.PrinterService;
@@ -216,11 +213,14 @@ public class PrinterServiceImpl implements PrinterService {
}
@Override
public List<MemberPrintResp> getUserPhotoList(Long userId, Long scenicId) {
public List<MemberPrintResp> getUserPhotoList(Long userId, Long scenicId, Long faceId) {
if (faceId != null) {
List<MemberPrintResp> list = printerMapper.listRelation(userId, scenicId);
if (list.isEmpty()) {
// 额外逻辑
return list.stream()
.filter(item -> Objects.nonNull(item.getFaceId()))
.collect(Collectors.toList());
}
List<MemberPrintResp> list = printerMapper.listRelationByFaceId(userId, scenicId, faceId);
return list;
}
@@ -236,10 +236,11 @@ public class PrinterServiceImpl implements PrinterService {
}
@Override
public Integer addUserPhoto(Long memberId, Long scenicId, String url) {
public Integer addUserPhoto(Long memberId, Long scenicId, String url, Long faceId) {
MemberPrintEntity entity = new MemberPrintEntity();
entity.setMemberId(memberId);
entity.setScenicId(scenicId);
entity.setFaceId(faceId);
entity.setOrigUrl(url);
// 获取打印尺寸
@@ -314,30 +315,29 @@ public class PrinterServiceImpl implements PrinterService {
}
@Override
public PriceObj queryPrice(Long memberId, Long scenicId) {
List<MemberPrintResp> userPhotoList = getUserPhotoList(memberId, scenicId);
public PriceObj queryPrice(Long memberId, Long scenicId, Long faceId) {
List<MemberPrintResp> userPhotoList = getUserPhotoList(memberId, scenicId, faceId);
// 计算照片总数量
long count = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity()))
.mapToInt(MemberPrintResp::getQuantity)
.sum();
PriceObj obj = new PriceObj();
obj.setScenicId(scenicId);
obj.setGoodsId(faceId);
obj.setFaceId(faceId);
obj.setGoodsType(3);
if (count == 0) {
// 如果没有照片,返回零价格
obj.setPrice(BigDecimal.ZERO);
obj.setSlashPrice(BigDecimal.ZERO);
obj.setGoodsType(3);
obj.setFree(false);
obj.setScenicId(scenicId);
return obj;
}
// 构建价格计算请求
PriceCalculationRequest request = new PriceCalculationRequest();
request.setUserId(memberId);
request.setScenicId(scenicId);
// 创建照片打印商品项
ProductItem photoItem = new ProductItem();
@@ -355,15 +355,13 @@ public class PrinterServiceImpl implements PrinterService {
// 转换为原有的 PriceObj 格式
obj.setPrice(result.getFinalAmount());
obj.setSlashPrice(result.getOriginalAmount());
obj.setGoodsType(3);
obj.setFree(result.getFinalAmount().compareTo(BigDecimal.ZERO) == 0);
obj.setScenicId(scenicId);
return obj;
}
@Override
public List<Integer> addUserPhotoFromSource(Long memberId, Long scenicId, FromSourceReq req) {
public List<Integer> addUserPhotoFromSource(Long memberId, Long scenicId, FromSourceReq req, Long faceId) {
List<Integer> resultIds = new ArrayList<>();
req.getIds().forEach(id -> {
SourceRespVO byId = sourceMapper.getById(id);
@@ -374,6 +372,7 @@ public class PrinterServiceImpl implements PrinterService {
MemberPrintEntity entity = new MemberPrintEntity();
entity.setMemberId(memberId);
entity.setScenicId(scenicId);
entity.setFaceId(faceId);
entity.setOrigUrl(byId.getUrl());
entity.setCropUrl(byId.getUrl());
entity.setStatus(0);
@@ -394,7 +393,7 @@ public class PrinterServiceImpl implements PrinterService {
}
@Override
public Map<String, Object> createOrder(Long memberId, Long scenicId, Integer printerId) {
public Map<String, Object> createOrder(Long memberId, Long scenicId, Integer printerId, Long faceId) {
if (printerId == null) {
List<PrinterResp> printerList = printerMapper.listByScenicId(scenicId);
if (printerList.size() != 1) {
@@ -415,7 +414,7 @@ public class PrinterServiceImpl implements PrinterService {
}
}
// 验证照片数量
List<MemberPrintResp> userPhotoList = getUserPhotoList(memberId, scenicId);
List<MemberPrintResp> userPhotoList = getUserPhotoList(memberId, scenicId, faceId);
long count = userPhotoList.stream().filter(item -> Objects.nonNull(item.getQuantity())).mapToInt(MemberPrintResp::getQuantity).sum();
if (count == 0) {
throw new BaseException("没有可打印的照片");
@@ -425,13 +424,12 @@ public class PrinterServiceImpl implements PrinterService {
Long orderId = SnowFlakeUtil.getLongId();
order.setId(orderId);
order.setMemberId(memberId);
order.setFaceId(faceId);
MemberRespVO member = memberMapper.getById(memberId);
order.setOpenId(member.getOpenId());
order.setScenicId(scenicId);
order.setType(3); // 照片打印类型
batchSetUserPhotoListToPrinter(memberId, scenicId, printerId);
// 重新获取照片列表(包含打印机信息)
userPhotoList = getUserPhotoList(memberId, scenicId);
List<OrderItemEntity> orderItems = userPhotoList.stream().map(goods -> {
OrderItemEntity orderItem = new OrderItemEntity();
orderItem.setOrderId(orderId);
@@ -462,7 +460,7 @@ public class PrinterServiceImpl implements PrinterService {
order.setPrice(priceResult.getFinalAmount());
order.setSlashPrice(priceResult.getOriginalAmount());
order.setPayPrice(priceResult.getFinalAmount());
// order.setFaceId();
order.setFaceId(faceId);
if (order.getPayPrice().equals(BigDecimal.ZERO)) {
order.setStatus(OrderStateEnum.PAID.getState());
order.setPayAt(new Date());
@@ -622,9 +620,15 @@ public class PrinterServiceImpl implements PrinterService {
log.error("获取景区配置失败,使用原始照片进行打印。景区ID: {}, 照片ID: {}", item.getScenicId(), item.getId(), e);
}
// 根据数量创建多个打印任务
Integer quantity = item.getQuantity();
if (quantity == null || quantity <= 0) {
quantity = 1; // 默认至少打印1张
}
for (int i = 0; i < quantity; i++) {
// 获取打印机名称(支持轮询)
String selectedPrinter = getNextPrinter(printer);
PrintTaskEntity task = new PrintTaskEntity();
task.setPrinterId(printer.getId());
task.setPrinterName(selectedPrinter);
@@ -637,6 +641,7 @@ public class PrinterServiceImpl implements PrinterService {
task.setCreateTime(new Date());
task.setUpdateTime(new Date());
printTaskMapper.insertTask(task);
}
});
}

View File

@@ -42,6 +42,11 @@
FROM member_print p
WHERE p.member_id = #{memberId} AND p.scenic_id = #{scenicId} AND p.status = 0
</select>
<select id="listRelationByFaceId" resultType="com.ycwl.basic.model.pc.printer.resp.MemberPrintResp">
SELECT p.*
FROM member_print p
WHERE p.member_id = #{memberId} AND p.scenic_id = #{scenicId} AND p.face_id = #{faceId} AND p.status = 0
</select>
<select id="getUserPhoto" resultType="com.ycwl.basic.model.pc.printer.resp.MemberPrintResp">
SELECT p.*
FROM member_print p
@@ -96,6 +101,7 @@
INSERT INTO member_print (
member_id,
scenic_id,
face_id,
orig_url,
crop_url,
quantity,
@@ -105,6 +111,7 @@
) VALUES (
#{memberId},
#{scenicId},
#{faceId},
#{origUrl},
#{cropUrl},
1,

View File

@@ -97,4 +97,10 @@ public class BceFaceBodyAdapterTest {
adapter.deleteFace(db, entityId);
});
}
@Test
public void testDeleteDb() {
BceFaceBodyAdapter adapter = getAdapter();
adapter.deleteFaceDb("test");
}
}