feat(ai-cam): 实现AI相机商品识别与会员关联功能

- 新增AppAiCamController控制器,提供获取AI相机识别商品和添加会员素材关联接口
- 实现AppAiCamService服务,完成从人脸识别日志到商品详情的转换逻辑
- 扩展FaceDetectLogAiCamMapper,支持根据faceId查询识别记录
- 扩展SourceMapper,新增根据faceSampleIds和type查询source列表的方法
- 添加设备配置管理,支持按设备设置识别分数阈值和照片数量限制
- 实现人脸识别结果解析,提取匹配度高的faceSampleId并去重处理
- 完成商品详情VO转换,包含素材URL、视频URL及购买状态等信息
- 支持批量添加会员与素材的关联关系,确保数据一致性校验
This commit is contained in:
2025-12-05 17:51:30 +08:00
parent e9916d6aca
commit 1f4a16f0e6
8 changed files with 329 additions and 4 deletions

View File

@@ -0,0 +1,61 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
import com.ycwl.basic.service.mobile.AppAiCamService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* AI相机相关接口
*/
@Slf4j
@RestController
@RequestMapping("/api/mobile/ai_cam/v1")
@RequiredArgsConstructor
public class AppAiCamController {
private final AppAiCamService appAiCamService;
/**
* 根据faceId获取AI相机识别到的商品列表
* @param faceId 人脸ID
* @return 商品详情列表
*/
@GetMapping("/{faceId}/content")
public ApiResponse<List<GoodsDetailVO>> getAiCamGoods(@PathVariable Long faceId) {
try {
List<GoodsDetailVO> goods = appAiCamService.getAiCamGoodsByFaceId(faceId);
return ApiResponse.success(goods);
} catch (Exception e) {
log.error("获取AI相机商品失败: faceId={}", faceId, e);
return ApiResponse.fail("获取商品列表失败");
}
}
/**
* 批量添加会员与source的关联关系
* @param faceId 人脸ID
* @param sourceIds source ID列表
* @return 添加结果
*/
@PostMapping("/{faceId}/relations")
public ApiResponse<String> addMemberSourceRelations(
@PathVariable Long faceId,
@RequestBody List<Long> sourceIds
) {
try {
int count = appAiCamService.addMemberSourceRelations(faceId, sourceIds);
return ApiResponse.success("成功添加" + count + "条关联记录");
} catch (IllegalArgumentException e) {
log.warn("添加关联失败: faceId={}, error={}", faceId, e.getMessage());
return ApiResponse.fail(e.getMessage());
} catch (Exception e) {
log.error("添加关联失败: faceId={}", faceId, e);
return ApiResponse.fail("添加关联失败");
}
}
}

View File

@@ -3,10 +3,19 @@ package com.ycwl.basic.mapper;
import com.ycwl.basic.model.pc.faceDetectLog.entity.FaceDetectLogAiCamEntity; import com.ycwl.basic.model.pc.faceDetectLog.entity.FaceDetectLogAiCamEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/** /**
* AI相机人脸识别日志Mapper * AI相机人脸识别日志Mapper
*/ */
@Mapper @Mapper
public interface FaceDetectLogAiCamMapper { public interface FaceDetectLogAiCamMapper {
int add(FaceDetectLogAiCamEntity entity); int add(FaceDetectLogAiCamEntity entity);
/**
* 根据faceId查询所有识别记录
* @param faceId 人脸ID
* @return 识别记录列表
*/
List<FaceDetectLogAiCamEntity> listByFaceId(Long faceId);
} }

View File

@@ -148,4 +148,12 @@ public interface SourceMapper {
* @return source实体 * @return source实体
*/ */
SourceEntity getSourceByFaceAndDeviceId(Long faceId, Long deviceId, Integer type, String sortStrategy); SourceEntity getSourceByFaceAndDeviceId(Long faceId, Long deviceId, Integer type, String sortStrategy);
/**
* 根据faceSampleId列表和type查询source列表
* @param faceSampleIds faceSampleId列表
* @param type 素材类型
* @return source实体列表
*/
List<SourceEntity> listByFaceSampleIdsAndType(List<Long> faceSampleIds, Integer type);
} }

View File

@@ -0,0 +1,26 @@
package com.ycwl.basic.service.mobile;
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
import java.util.List;
/**
* AI相机相关服务
*/
public interface AppAiCamService {
/**
* 根据faceId获取AI相机识别到的商品列表
* @param faceId 人脸ID
* @return 商品详情列表
*/
List<GoodsDetailVO> getAiCamGoodsByFaceId(Long faceId);
/**
* 批量添加会员与source的关联关系
* @param faceId 人脸ID
* @param sourceIds source ID列表
* @return 添加成功的数量
*/
int addMemberSourceRelations(Long faceId, List<Long> sourceIds);
}

View File

@@ -0,0 +1,202 @@
package com.ycwl.basic.service.mobile.impl;
import com.ycwl.basic.facebody.entity.SearchFaceResp;
import com.ycwl.basic.facebody.entity.SearchFaceResultItem;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
import com.ycwl.basic.mapper.FaceDetectLogAiCamMapper;
import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.faceDetectLog.entity.FaceDetectLogAiCamEntity;
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.mobile.AppAiCamService;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* AI相机相关服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AppAiCamServiceImpl implements AppAiCamService {
private final FaceDetectLogAiCamMapper faceDetectLogAiCamMapper;
private final SourceMapper sourceMapper;
private final FaceMapper faceMapper;
private final DeviceRepository deviceRepository;
private static final float DEFAULT_SCORE_THRESHOLD = 0.8f;
private static final int DEFAULT_PHOTO_LIMIT = 10;
private static final int AI_CAM_SOURCE_TYPE = 3;
@Override
public List<GoodsDetailVO> getAiCamGoodsByFaceId(Long faceId) {
// 1. 查询该faceId的所有识别记录
List<FaceDetectLogAiCamEntity> detectLogs = faceDetectLogAiCamMapper.listByFaceId(faceId);
if (detectLogs == null || detectLogs.isEmpty()) {
return Collections.emptyList();
}
// 2. 按设备分组并根据设备配置过滤faceSampleId
Map<Long, List<Long>> deviceFaceSampleMap = new LinkedHashMap<>();
// 按设备分组识别记录
Map<Long, List<FaceDetectLogAiCamEntity>> deviceLogsMap = detectLogs.stream()
.collect(Collectors.groupingBy(FaceDetectLogAiCamEntity::getDeviceId));
// 遍历每个设备的识别记录
for (Map.Entry<Long, List<FaceDetectLogAiCamEntity>> entry : deviceLogsMap.entrySet()) {
Long deviceId = entry.getKey();
List<FaceDetectLogAiCamEntity> deviceLogs = entry.getValue();
// 获取设备配置
DeviceConfigManager configManager = deviceRepository.getDeviceConfigManager(deviceId);
// 获取该设备的分数阈值(百分制,需要转换为0-1)
float scoreThreshold = DEFAULT_SCORE_THRESHOLD;
if (configManager != null) {
Float thresholdPercent = configManager.getFloat("ai_cam_face_score_threshold");
if (thresholdPercent != null) {
scoreThreshold = thresholdPercent / 100.0f;
log.debug("设备{}使用配置的分数阈值: {}% ({})", deviceId, thresholdPercent, scoreThreshold);
}
}
// 获取该设备的照片数量限制
int photoLimit = DEFAULT_PHOTO_LIMIT;
if (configManager != null) {
Integer limit = configManager.getInteger("ai_cam_photo_limit");
if (limit != null && limit > 0) {
photoLimit = limit;
log.debug("设备{}使用配置的照片限制: {}", deviceId, photoLimit);
}
}
// 收集该设备符合阈值的faceSampleId
List<Long> deviceFaceSampleIds = new ArrayList<>();
for (FaceDetectLogAiCamEntity detectLog : deviceLogs) {
if (detectLog.getMatchRawResult() == null) {
continue;
}
try {
SearchFaceResp resp = JacksonUtil.parseObject(detectLog.getMatchRawResult(), SearchFaceResp.class);
if (resp != null && resp.getResult() != null) {
for (SearchFaceResultItem item : resp.getResult()) {
// 使用设备配置的分数阈值
if (item.getScore() != null && item.getScore() >= scoreThreshold && item.getExtData() != null) {
try {
Long faceSampleId = Long.parseLong(item.getExtData());
deviceFaceSampleIds.add(faceSampleId);
} catch (NumberFormatException e) {
log.warn("解析faceSampleId失败: extData={}", item.getExtData());
}
}
}
}
} catch (Exception e) {
log.error("解析matchRawResult失败: logId={}", detectLog.getId(), e);
}
}
// 应用照片数量限制(保留前N个)
if (deviceFaceSampleIds.size() > photoLimit) {
log.debug("设备{}的照片数量{}超过限制{},截取前{}张",
deviceId, deviceFaceSampleIds.size(), photoLimit, photoLimit);
deviceFaceSampleIds = deviceFaceSampleIds.subList(0, photoLimit);
}
if (!deviceFaceSampleIds.isEmpty()) {
deviceFaceSampleMap.put(deviceId, deviceFaceSampleIds);
}
}
// 3. 合并所有设备的faceSampleId(去重)
Set<Long> faceSampleIds = new HashSet<>();
for (List<Long> ids : deviceFaceSampleMap.values()) {
faceSampleIds.addAll(ids);
}
if (faceSampleIds.isEmpty()) {
log.debug("没有符合条件的faceSampleId, faceId={}", faceId);
return Collections.emptyList();
}
log.info("人脸{}在{}个设备上识别到{}个不重复的faceSampleId",
faceId, deviceFaceSampleMap.size(), faceSampleIds.size());
// 4. 根据faceSampleId列表查询type=3的source记录
List<SourceEntity> sources = sourceMapper.listByFaceSampleIdsAndType(
new ArrayList<>(faceSampleIds), AI_CAM_SOURCE_TYPE
);
if (sources == null || sources.isEmpty()) {
log.debug("未找到type=3的source记录, faceId={}", faceId);
return Collections.emptyList();
}
log.info("查询到{}条AI相机图像记录, faceId={}", sources.size(), faceId);
// 5. 查询Face信息以获取scenicId
FaceEntity face = faceMapper.get(faceId);
if (face == null) {
log.warn("Face不存在: faceId={}", faceId);
return Collections.emptyList();
}
// 6. 转换为GoodsDetailVO
return sources.stream().map(source -> {
GoodsDetailVO vo = new GoodsDetailVO();
vo.setFaceId(faceId);
vo.setScenicId(face.getScenicId());
vo.setGoodsType(2); // 2表示原素材
vo.setGoodsId(source.getId());
vo.setUrl(source.getUrl());
vo.setVideoUrl(source.getVideoUrl());
vo.setCreateTime(source.getCreateTime());
vo.setIsBuy(source.getIsBuy() != null ? source.getIsBuy() : 0);
return vo;
}).collect(Collectors.toList());
}
@Override
public int addMemberSourceRelations(Long faceId, List<Long> sourceIds) {
if (sourceIds == null || sourceIds.isEmpty()) {
return 0;
}
// 查询Face信息
FaceEntity face = faceMapper.get(faceId);
if (face == null) {
throw new IllegalArgumentException("Face不存在: faceId=" + faceId);
}
if (face.getMemberId() == null) {
throw new IllegalArgumentException("Face未关联会员: faceId=" + faceId);
}
// 构建MemberSourceEntity列表
List<MemberSourceEntity> relations = sourceIds.stream().map(sourceId -> {
MemberSourceEntity entity = new MemberSourceEntity();
entity.setMemberId(face.getMemberId());
entity.setScenicId(face.getScenicId());
entity.setFaceId(faceId);
entity.setSourceId(sourceId);
entity.setType(AI_CAM_SOURCE_TYPE);
entity.setIsBuy(0);
entity.setIsFree(0);
return entity;
}).collect(Collectors.toList());
// 批量插入
return sourceMapper.addRelations(relations);
}
}

View File

@@ -42,7 +42,7 @@ public class FaceDetectLogAiCamServiceImpl implements FaceDetectLogAiCamService
// 调用适配器搜索人脸 // 调用适配器搜索人脸
resp = adapter.searchFace(dbName, faceUrl); resp = adapter.searchFace(dbName, faceUrl);
} catch (Exception e) { } catch (Exception e) {
log.error("AI相机人脸搜索异常: scenicId={}, deviceId={}, faceSampleId={}", scenicId, deviceId, faceId, e); log.error("AI相机人脸搜索异常: scenicId={}, deviceId={}, faceId={}", scenicId, deviceId, faceId, e);
// 发生异常时记录空结果或错误信息,视业务需求而定。这里暂不中断流程,继续记录日志 // 发生异常时记录空结果或错误信息,视业务需求而定。这里暂不中断流程,继续记录日志
} }
@@ -71,7 +71,7 @@ public class FaceDetectLogAiCamServiceImpl implements FaceDetectLogAiCamService
faceDetectLogAiCamMapper.add(logEntity); faceDetectLogAiCamMapper.add(logEntity);
} catch (Exception e) { } catch (Exception e) {
log.error("保存AI相机人脸识别日志失败: faceSampleId={}", faceId, e); log.error("保存AI相机人脸识别日志失败: faceId={}", faceId, e);
} }
return resp; return resp;

View File

@@ -2,7 +2,16 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.mapper.FaceDetectLogAiCamMapper"> <mapper namespace="com.ycwl.basic.mapper.FaceDetectLogAiCamMapper">
<insert id="add" useGeneratedKeys="true" keyProperty="id"> <insert id="add" useGeneratedKeys="true" keyProperty="id">
insert into face_detect_log_ai_cam(scenic_id, device_id, face_sample_id, db_name, face_url, score, match_raw_result, create_time) insert into face_detect_log_ai_cam(scenic_id, device_id, face_id, db_name, face_url, score, match_raw_result, create_time)
values (#{scenicId}, #{deviceId}, #{faceSampleId}, #{dbName}, #{faceUrl}, #{score}, #{matchRawResult}, #{createTime}) values (#{scenicId}, #{deviceId}, #{faceId}, #{dbName}, #{faceUrl}, #{score}, #{matchRawResult}, #{createTime})
</insert> </insert>
<select id="listByFaceId" resultType="com.ycwl.basic.model.pc.faceDetectLog.entity.FaceDetectLogAiCamEntity">
select id, scenic_id as scenicId, device_id as deviceId, face_id as faceId,
db_name as dbName, face_url as faceUrl, score, match_raw_result as matchRawResult,
create_time as createTime
from face_detect_log_ai_cam
where face_id = #{faceId}
order by create_time desc
</select>
</mapper> </mapper>

View File

@@ -486,4 +486,14 @@
</choose> </choose>
LIMIT 1 LIMIT 1
</select> </select>
<select id="listByFaceSampleIdsAndType" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
SELECT * FROM source
WHERE face_sample_id IN
<foreach collection="faceSampleIds" item="item" open="(" separator="," close=")">
#{item}
</foreach>
AND `type` = #{type}
ORDER BY create_time DESC
</select>
</mapper> </mapper>