You've already forked FrameTour-BE
feat(puzzle): 实现拼图生成去重机制
- 新增内容哈希计算逻辑,基于元素内容生成SHA256哈希用于去重判断 - 添加重复图片检测功能,当所有IMAGE元素使用相同URL时抛出异常 - 实现历史记录查询接口,根据模板ID、内容哈希和景区ID查找重复记录 - 扩展生成响应对象,增加isDuplicate和originalRecordId字段标识复用情况 - 更新数据库实体和Mapper,新增content_hash、is_duplicate等字段支持去重 - 添加完整的单元测试和集成测试,覆盖去重检测、哈希计算等核心逻辑 - 引入DuplicateImageException和PuzzleBizException异常类完善错误处理
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
package com.ycwl.basic.puzzle.util;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.exception.DuplicateImageException;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 拼图去重检测器
|
||||
* 负责检测重复内容和重复图片,避免不必要的图片生成
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-21
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PuzzleDuplicationDetector {
|
||||
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
|
||||
/**
|
||||
* 计算内容哈希
|
||||
* 基于所有元素的实际内容值(按elementKey排序后拼接)计算SHA256哈希
|
||||
*
|
||||
* @param finalData 最终的元素数据映射(elementKey -> value)
|
||||
* @return SHA256哈希值(64位16进制字符串)
|
||||
*/
|
||||
public String calculateContentHash(Map<String, String> finalData) {
|
||||
if (finalData == null || finalData.isEmpty()) {
|
||||
log.warn("计算内容哈希时发现空数据,返回空哈希");
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 按key排序,确保相同内容生成相同哈希
|
||||
List<String> sortedKeys = new ArrayList<>(finalData.keySet());
|
||||
Collections.sort(sortedKeys);
|
||||
|
||||
// 2. 拼接为固定格式: "key1:value1|key2:value2|..."
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < sortedKeys.size(); i++) {
|
||||
String key = sortedKeys.get(i);
|
||||
String value = finalData.get(key);
|
||||
sb.append(key).append(":").append(value != null ? value : "");
|
||||
if (i < sortedKeys.size() - 1) {
|
||||
sb.append("|");
|
||||
}
|
||||
}
|
||||
|
||||
String content = sb.toString();
|
||||
log.debug("内容哈希计算原文: {}", content);
|
||||
|
||||
// 3. 计算SHA256
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashBytes = digest.digest(content.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// 4. 转换为16进制字符串
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
|
||||
String hash = hexString.toString();
|
||||
log.debug("计算得到内容哈希: {}", hash);
|
||||
return hash;
|
||||
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
log.error("SHA-256算法不可用", e);
|
||||
throw new RuntimeException("内容哈希计算失败: SHA-256算法不可用", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测重复图片
|
||||
* 检查所有IMAGE类型元素是否使用相同的图片URL
|
||||
* 如果发现所有图片元素使用同一个URL,抛出异常
|
||||
*
|
||||
* @param finalData 最终的元素数据映射
|
||||
* @param elements 元素列表
|
||||
* @throws DuplicateImageException 所有图片元素使用相同URL时抛出
|
||||
*/
|
||||
public void detectDuplicateImages(Map<String, String> finalData, List<PuzzleElementEntity> elements) {
|
||||
if (finalData == null || finalData.isEmpty() || elements == null || elements.isEmpty()) {
|
||||
log.debug("跳过重复图片检测: 数据为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 筛选出所有IMAGE类型的元素(elementType="IMAGE")
|
||||
List<PuzzleElementEntity> imageElements = elements.stream()
|
||||
.filter(e -> "IMAGE".equals(e.getElementType()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (imageElements.size() < 2) {
|
||||
log.debug("图片元素数量不足2个,跳过重复检测");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 提取所有图片元素的实际URL值
|
||||
List<String> imageUrls = new ArrayList<>();
|
||||
for (PuzzleElementEntity element : imageElements) {
|
||||
String url = finalData.get(element.getElementKey());
|
||||
if (url != null && !url.trim().isEmpty()) {
|
||||
imageUrls.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrls.isEmpty()) {
|
||||
log.debug("没有有效的图片URL,跳过重复检测");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 对URL去重
|
||||
Set<String> uniqueUrls = new HashSet<>(imageUrls);
|
||||
|
||||
// 4. 如果去重后只有1个URL,说明所有图片相同
|
||||
if (uniqueUrls.size() == 1) {
|
||||
String duplicateUrl = uniqueUrls.iterator().next();
|
||||
log.warn("检测到重复图片: 所有{}个图片元素使用相同URL: {}", imageUrls.size(), duplicateUrl);
|
||||
throw new DuplicateImageException(duplicateUrl, imageUrls.size());
|
||||
}
|
||||
|
||||
log.debug("重复图片检测通过: 发现{}个不同的图片URL", uniqueUrls.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找重复记录
|
||||
* 根据模板ID、内容哈希和景区ID查询历史记录
|
||||
* 返回最新的成功生成记录(如果存在)
|
||||
*
|
||||
* @param templateId 模板ID
|
||||
* @param contentHash 内容哈希
|
||||
* @param scenicId 景区ID
|
||||
* @return 历史记录,如果不存在则返回null
|
||||
*/
|
||||
public PuzzleGenerationRecordEntity findDuplicateRecord(Long templateId, String contentHash, Long scenicId) {
|
||||
if (contentHash == null || contentHash.isEmpty()) {
|
||||
log.debug("内容哈希为空,跳过去重查询");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
PuzzleGenerationRecordEntity record = recordMapper.findByContentHash(templateId, contentHash, scenicId);
|
||||
|
||||
if (record != null) {
|
||||
log.info("发现重复内容: templateId={}, contentHash={}, 历史记录ID={}, imageUrl={}",
|
||||
templateId, contentHash, record.getId(), record.getResultImageUrl());
|
||||
} else {
|
||||
log.debug("未发现重复内容: templateId={}, contentHash={}", templateId, contentHash);
|
||||
}
|
||||
|
||||
return record;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("查询重复记录失败: templateId={}, contentHash={}", templateId, contentHash, e);
|
||||
// 查询失败时返回null,继续正常生成流程
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user