feat(puzzle): 实现拼图生成去重机制

- 新增内容哈希计算逻辑,基于元素内容生成SHA256哈希用于去重判断
- 添加重复图片检测功能,当所有IMAGE元素使用相同URL时抛出异常
- 实现历史记录查询接口,根据模板ID、内容哈希和景区ID查找重复记录
- 扩展生成响应对象,增加isDuplicate和originalRecordId字段标识复用情况
- 更新数据库实体和Mapper,新增content_hash、is_duplicate等字段支持去重
- 添加完整的单元测试和集成测试,覆盖去重检测、哈希计算等核心逻辑
- 引入DuplicateImageException和PuzzleBizException异常类完善错误处理
This commit is contained in:
2025-11-21 11:02:43 +08:00
parent 6ef710201c
commit 0db713b4a8
10 changed files with 890 additions and 11 deletions

View File

@@ -46,9 +46,28 @@ public class PuzzleGenerateResponse {
private Long recordId;
/**
* 创建成功响应
* 是否为复用历史记录
* true-复用历史图片(未重新渲染), false-新生成
*/
private Boolean isDuplicate;
/**
* 原始记录ID
* 当isDuplicate=true时,记录被复用的原始记录ID
*/
private Long originalRecordId;
/**
* 创建成功响应(新生成)
*/
public static PuzzleGenerateResponse success(String imageUrl, Long fileSize, Integer width, Integer height, Integer duration, Long recordId) {
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId);
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId, false, null);
}
/**
* 创建成功响应(支持去重标记)
*/
public static PuzzleGenerateResponse success(String imageUrl, Long fileSize, Integer width, Integer height, Integer duration, Long recordId, Boolean isDuplicate, Long originalRecordId) {
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId, isDuplicate, originalRecordId);
}
}

View File

@@ -63,6 +63,27 @@ public class PuzzleGenerationRecordEntity {
@TableField("generation_params")
private String generationParams;
/**
* 内容哈希(SHA256)
* 用于去重检测,基于所有元素的实际内容计算
*/
@TableField("content_hash")
private String contentHash;
/**
* 是否为复用记录
* 0-新生成, 1-复用历史记录
*/
@TableField("is_duplicate")
private Boolean isDuplicate;
/**
* 原始记录ID
* 当is_duplicate=1时,记录被复用的原始记录ID
*/
@TableField("original_record_id")
private Long originalRecordId;
/**
* 生成的图片URL
*/

View File

@@ -0,0 +1,49 @@
package com.ycwl.basic.puzzle.exception;
/**
* 重复图片异常
* 当所有图片元素使用相同URL时抛出此异常
*
* @author Claude
* @since 2025-01-21
*/
public class DuplicateImageException extends PuzzleBizException {
private static final String DEFAULT_MESSAGE_TEMPLATE = "检测到所有图片元素使用相同URL,拒绝生成: %s (元素数量: %d)";
private final String duplicateImageUrl;
private final int elementCount;
/**
* 构造函数
*
* @param duplicateImageUrl 重复的图片URL
* @param elementCount 使用相同URL的元素数量
*/
public DuplicateImageException(String duplicateImageUrl, int elementCount) {
super(String.format(DEFAULT_MESSAGE_TEMPLATE, duplicateImageUrl, elementCount));
this.duplicateImageUrl = duplicateImageUrl;
this.elementCount = elementCount;
}
/**
* 构造函数(带自定义消息)
*
* @param message 自定义错误消息
* @param duplicateImageUrl 重复的图片URL
* @param elementCount 元素数量
*/
public DuplicateImageException(String message, String duplicateImageUrl, int elementCount) {
super(message);
this.duplicateImageUrl = duplicateImageUrl;
this.elementCount = elementCount;
}
public String getDuplicateImageUrl() {
return duplicateImageUrl;
}
public int getElementCount() {
return elementCount;
}
}

View File

@@ -0,0 +1,7 @@
package com.ycwl.basic.puzzle.exception;
public class PuzzleBizException extends RuntimeException {
public PuzzleBizException(String message) {
super(message);
}
}

View File

@@ -62,4 +62,18 @@ public interface PuzzleGenerationRecordMapper {
*/
int updateFail(@Param("id") Long id,
@Param("errorMessage") String errorMessage);
/**
* 根据内容哈希查询历史记录(用于去重)
* 查询条件: template_id + content_hash + scenic_id + status=1
* 返回最新的成功记录
*
* @param templateId 模板ID
* @param contentHash 内容哈希
* @param scenicId 景区ID
* @return 历史记录,如果不存在返回null
*/
PuzzleGenerationRecordEntity findByContentHash(@Param("templateId") Long templateId,
@Param("contentHash") String contentHash,
@Param("scenicId") Long scenicId);
}

View File

@@ -14,6 +14,7 @@ import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.storage.StorageFactory;
@@ -53,6 +54,7 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
private final PuzzleImageRenderer imageRenderer;
private final PuzzleElementFillEngine fillEngine;
private final ScenicRepository scenicRepository;
private final PuzzleDuplicationDetector duplicationDetector;
@Override
public PuzzleGenerateResponse generate(PuzzleGenerateRequest request) {
@@ -90,19 +92,51 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
// 4. 准备dynamicData(合并自动填充和手动数据)
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
// 5. 创建生成记录
// 5. 执行重复图片检测
// 如果所有IMAGE元素使用相同URL,抛出DuplicateImageException
duplicationDetector.detectDuplicateImages(finalDynamicData, elements);
// 6. 计算内容哈希
String contentHash = duplicationDetector.calculateContentHash(finalDynamicData);
// 7. 查询历史记录(去重核心逻辑)
PuzzleGenerationRecordEntity duplicateRecord = duplicationDetector.findDuplicateRecord(
template.getId(), contentHash, resolvedScenicId);
if (duplicateRecord != null) {
// 发现重复内容,直接返回历史记录
long duration = System.currentTimeMillis() - startTime;
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
// 直接返回历史图片URL(语义化生成成功)
return PuzzleGenerateResponse.success(
duplicateRecord.getResultImageUrl(),
duplicateRecord.getResultFileSize(),
duplicateRecord.getResultWidth(),
duplicateRecord.getResultHeight(),
(int) duration,
duplicateRecord.getId(),
true, // isDuplicate=true
duplicateRecord.getId() // originalRecordId(复用时指向自己)
);
}
// 8. 没有历史记录,创建新的生成记录
PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId);
record.setContentHash(contentHash);
record.setIsDuplicate(false);
recordMapper.insert(record);
try {
// 5. 渲染图片
// 9. 渲染图片
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
// 5. 上传到OSS
// 10. 上传到OSS
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("图片上传成功: url={}", imageUrl);
// 6. 更新记录为成功
// 11. 更新记录为成功
long duration = (int) (System.currentTimeMillis() - startTime);
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
recordMapper.updateSuccess(
@@ -114,7 +148,7 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
(int) duration
);
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
log.info("拼图生成成功(新生成): recordId={}, imageUrl={}, duration={}ms",
record.getId(), imageUrl, duration);
return PuzzleGenerateResponse.success(
@@ -123,7 +157,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration,
record.getId()
record.getId(),
false, // isDuplicate=false
null // originalRecordId=null
);
} catch (Exception e) {

View File

@@ -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;
}
}
}

View File

@@ -12,6 +12,9 @@
<result column="face_id" property="faceId"/>
<result column="business_type" property="businessType"/>
<result column="generation_params" property="generationParams"/>
<result column="content_hash" property="contentHash"/>
<result column="is_duplicate" property="isDuplicate"/>
<result column="original_record_id" property="originalRecordId"/>
<result column="result_image_url" property="resultImageUrl"/>
<result column="result_file_size" property="resultFileSize"/>
<result column="result_width" property="resultWidth"/>
@@ -30,7 +33,8 @@
<!-- 基础列 -->
<sql id="Base_Column_List">
id, template_id, template_code, user_id, face_id, business_type,
generation_params, result_image_url, result_file_size, result_width, result_height,
generation_params, content_hash, is_duplicate, original_record_id,
result_image_url, result_file_size, result_width, result_height,
status, error_message, generation_duration, retry_count,
scenic_id, client_ip, user_agent, create_time, update_time
</sql>
@@ -74,12 +78,14 @@
useGeneratedKeys="true" keyProperty="id">
INSERT INTO puzzle_generation_record (
template_id, template_code, user_id, face_id, business_type,
generation_params, result_image_url, result_file_size, result_width, result_height,
generation_params, content_hash, is_duplicate, original_record_id,
result_image_url, result_file_size, result_width, result_height,
status, error_message, generation_duration, retry_count,
scenic_id, client_ip, user_agent, create_time, update_time
) VALUES (
#{templateId}, #{templateCode}, #{userId}, #{faceId}, #{businessType},
#{generationParams}, #{resultImageUrl}, #{resultFileSize}, #{resultWidth}, #{resultHeight},
#{generationParams}, #{contentHash}, #{isDuplicate}, #{originalRecordId},
#{resultImageUrl}, #{resultFileSize}, #{resultWidth}, #{resultHeight},
#{status}, #{errorMessage}, #{generationDuration}, #{retryCount},
#{scenicId}, #{clientIp}, #{userAgent}, NOW(), NOW()
)
@@ -124,4 +130,17 @@
WHERE id = #{id}
</update>
<!-- 根据内容哈希查询历史记录(用于去重) -->
<select id="findByContentHash" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM puzzle_generation_record
WHERE template_id = #{templateId}
AND content_hash = #{contentHash}
AND scenic_id = #{scenicId}
AND status = 1
AND is_duplicate = 0
ORDER BY create_time DESC
LIMIT 1
</select>
</mapper>