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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ycwl.basic.puzzle.exception;
|
||||
|
||||
public class PuzzleBizException extends RuntimeException {
|
||||
public PuzzleBizException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.ycwl.basic.puzzle.service.impl;
|
||||
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.exception.DuplicateImageException;
|
||||
import com.ycwl.basic.puzzle.fill.FillResult;
|
||||
import com.ycwl.basic.puzzle.fill.PuzzleElementFillEngine;
|
||||
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.util.PuzzleDuplicationDetector;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* PuzzleGenerateServiceImpl 去重功能集成测试
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-21
|
||||
*/
|
||||
class PuzzleGenerateServiceDeduplicationTest {
|
||||
|
||||
@Mock
|
||||
private PuzzleTemplateMapper templateMapper;
|
||||
|
||||
@Mock
|
||||
private PuzzleElementMapper elementMapper;
|
||||
|
||||
@Mock
|
||||
private PuzzleGenerationRecordMapper recordMapper;
|
||||
|
||||
@Mock
|
||||
private PuzzleImageRenderer imageRenderer;
|
||||
|
||||
@Mock
|
||||
private PuzzleElementFillEngine fillEngine;
|
||||
|
||||
@Mock
|
||||
private ScenicRepository scenicRepository;
|
||||
|
||||
private PuzzleDuplicationDetector duplicationDetector;
|
||||
|
||||
private PuzzleGenerateServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
// 创建真实的 duplicationDetector 实例
|
||||
duplicationDetector = new PuzzleDuplicationDetector(recordMapper);
|
||||
|
||||
// 手动注入所有依赖
|
||||
service = new PuzzleGenerateServiceImpl(
|
||||
templateMapper,
|
||||
elementMapper,
|
||||
recordMapper,
|
||||
imageRenderer,
|
||||
fillEngine,
|
||||
scenicRepository,
|
||||
duplicationDetector
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试场景1: 首次生成 - 应该正常渲染并保存
|
||||
*/
|
||||
@Test
|
||||
void testGenerate_首次生成() throws Exception {
|
||||
// 准备请求
|
||||
PuzzleGenerateRequest request = createBasicRequest();
|
||||
|
||||
// Mock模板
|
||||
PuzzleTemplateEntity template = createMockTemplate();
|
||||
when(templateMapper.getByCode("test_template")).thenReturn(template);
|
||||
|
||||
// Mock元素
|
||||
List<PuzzleElementEntity> elements = createMockElements();
|
||||
when(elementMapper.getByTemplateId(1L)).thenReturn(elements);
|
||||
|
||||
// Mock自动填充
|
||||
FillResult fillResult = FillResult.noMatch();
|
||||
when(fillEngine.execute(anyLong(), anyLong(), anyLong())).thenReturn(fillResult);
|
||||
|
||||
// Mock去重查询 - 未找到历史记录
|
||||
when(recordMapper.findByContentHash(anyLong(), anyString(), anyLong())).thenReturn(null);
|
||||
|
||||
// Mock渲染
|
||||
BufferedImage mockImage = new BufferedImage(750, 1334, BufferedImage.TYPE_INT_RGB);
|
||||
when(imageRenderer.render(eq(template), anyList(), anyMap())).thenReturn(mockImage);
|
||||
|
||||
// Mock插入记录
|
||||
doAnswer(invocation -> {
|
||||
PuzzleGenerationRecordEntity record = invocation.getArgument(0);
|
||||
record.setId(100L);
|
||||
return 1;
|
||||
}).when(recordMapper).insert(any());
|
||||
|
||||
// Mock更新成功
|
||||
when(recordMapper.updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(1);
|
||||
|
||||
// 执行
|
||||
PuzzleGenerateResponse response = service.generate(request);
|
||||
|
||||
// 验证
|
||||
assertNotNull(response);
|
||||
assertFalse(response.getIsDuplicate()); // 非复用
|
||||
assertNull(response.getOriginalRecordId());
|
||||
verify(imageRenderer, times(1)).render(any(), any(), any()); // 确实进行了渲染
|
||||
verify(recordMapper, times(1)).insert(any()); // 插入了一条记录
|
||||
verify(recordMapper, times(1)).updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试场景2: 相同内容再次生成 - 应该复用历史记录
|
||||
*/
|
||||
@Test
|
||||
void testGenerate_复用历史记录() throws Exception {
|
||||
// 准备请求
|
||||
PuzzleGenerateRequest request = createBasicRequest();
|
||||
|
||||
// Mock模板
|
||||
PuzzleTemplateEntity template = createMockTemplate();
|
||||
when(templateMapper.getByCode("test_template")).thenReturn(template);
|
||||
|
||||
// Mock元素
|
||||
List<PuzzleElementEntity> elements = createMockElements();
|
||||
when(elementMapper.getByTemplateId(1L)).thenReturn(elements);
|
||||
|
||||
// Mock自动填充
|
||||
FillResult fillResult = FillResult.noMatch();
|
||||
when(fillEngine.execute(anyLong(), anyLong(), anyLong())).thenReturn(fillResult);
|
||||
|
||||
// Mock去重查询 - 找到历史记录
|
||||
PuzzleGenerationRecordEntity historicalRecord = createHistoricalRecord();
|
||||
when(recordMapper.findByContentHash(anyLong(), anyString(), anyLong())).thenReturn(historicalRecord);
|
||||
|
||||
// 执行
|
||||
PuzzleGenerateResponse response = service.generate(request);
|
||||
|
||||
// 验证
|
||||
assertNotNull(response);
|
||||
assertTrue(response.getIsDuplicate()); // 标记为复用
|
||||
assertEquals(999L, response.getOriginalRecordId()); // 原始记录ID(复用时指向自己)
|
||||
assertEquals(999L, response.getRecordId()); // recordId也是历史记录的ID
|
||||
assertEquals("https://example.com/old-image.jpg", response.getImageUrl()); // 复用的URL
|
||||
verify(imageRenderer, never()).render(any(), any(), any()); // 没有进行渲染
|
||||
verify(recordMapper, never()).insert(any()); // 没有插入新记录
|
||||
verify(recordMapper, never()).updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试场景3: 所有图片相同 - 应该抛出异常
|
||||
*/
|
||||
@Test
|
||||
void testGenerate_所有图片相同抛出异常() {
|
||||
// 准备请求 - 所有图片URL相同
|
||||
PuzzleGenerateRequest request = new PuzzleGenerateRequest();
|
||||
request.setTemplateCode("test_template");
|
||||
request.setFaceId(1000L);
|
||||
request.setScenicId(1L);
|
||||
|
||||
Map<String, String> dynamicData = new HashMap<>();
|
||||
dynamicData.put("image1", "https://example.com/same.jpg");
|
||||
dynamicData.put("image2", "https://example.com/same.jpg");
|
||||
request.setDynamicData(dynamicData);
|
||||
|
||||
// Mock模板
|
||||
PuzzleTemplateEntity template = createMockTemplate();
|
||||
when(templateMapper.getByCode("test_template")).thenReturn(template);
|
||||
|
||||
// Mock元素 - 两个图片元素
|
||||
List<PuzzleElementEntity> elements = new ArrayList<>();
|
||||
elements.add(createImageElement(1L, "image1"));
|
||||
elements.add(createImageElement(2L, "image2"));
|
||||
when(elementMapper.getByTemplateId(1L)).thenReturn(elements);
|
||||
|
||||
// Mock自动填充
|
||||
FillResult fillResult = FillResult.noMatch();
|
||||
when(fillEngine.execute(anyLong(), anyLong(), anyLong())).thenReturn(fillResult);
|
||||
|
||||
// 执行并验证 - 应该抛出DuplicateImageException
|
||||
assertThrows(DuplicateImageException.class, () -> service.generate(request));
|
||||
|
||||
// 验证没有进行渲染和保存
|
||||
verify(imageRenderer, never()).render(any(), any(), any());
|
||||
verify(recordMapper, never()).insert(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试场景4: 不同参数生成不同图片
|
||||
*/
|
||||
@Test
|
||||
void testGenerate_不同内容生成不同图片() throws Exception {
|
||||
// 第一次请求
|
||||
PuzzleGenerateRequest request1 = createBasicRequest();
|
||||
request1.getDynamicData().put("userName", "张三");
|
||||
|
||||
// 第二次请求 - 不同的用户名
|
||||
PuzzleGenerateRequest request2 = createBasicRequest();
|
||||
request2.getDynamicData().put("userName", "李四");
|
||||
|
||||
// Mock基础数据
|
||||
PuzzleTemplateEntity template = createMockTemplate();
|
||||
when(templateMapper.getByCode("test_template")).thenReturn(template);
|
||||
|
||||
List<PuzzleElementEntity> elements = createMockElements();
|
||||
when(elementMapper.getByTemplateId(1L)).thenReturn(elements);
|
||||
|
||||
FillResult fillResult = FillResult.noMatch();
|
||||
when(fillEngine.execute(anyLong(), anyLong(), anyLong())).thenReturn(fillResult);
|
||||
|
||||
// Mock去重查询 - 两次都未找到
|
||||
when(recordMapper.findByContentHash(anyLong(), anyString(), anyLong())).thenReturn(null);
|
||||
|
||||
// Mock渲染
|
||||
BufferedImage mockImage = new BufferedImage(750, 1334, BufferedImage.TYPE_INT_RGB);
|
||||
when(imageRenderer.render(eq(template), anyList(), anyMap())).thenReturn(mockImage);
|
||||
|
||||
doAnswer(invocation -> {
|
||||
PuzzleGenerationRecordEntity record = invocation.getArgument(0);
|
||||
record.setId(Math.abs(record.hashCode()) % 1000L); // 模拟不同ID
|
||||
return 1;
|
||||
}).when(recordMapper).insert(any());
|
||||
|
||||
when(recordMapper.updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(1);
|
||||
|
||||
// 执行两次生成
|
||||
PuzzleGenerateResponse response1 = service.generate(request1);
|
||||
PuzzleGenerateResponse response2 = service.generate(request2);
|
||||
|
||||
// 验证: 两次都是新生成
|
||||
assertFalse(response1.getIsDuplicate());
|
||||
assertFalse(response2.getIsDuplicate());
|
||||
verify(imageRenderer, times(2)).render(any(), any(), any()); // 渲染了两次
|
||||
}
|
||||
|
||||
// ===== 辅助方法 =====
|
||||
|
||||
private PuzzleGenerateRequest createBasicRequest() {
|
||||
PuzzleGenerateRequest request = new PuzzleGenerateRequest();
|
||||
request.setTemplateCode("test_template");
|
||||
request.setUserId(100L);
|
||||
request.setFaceId(1000L);
|
||||
request.setScenicId(1L);
|
||||
request.setBusinessType("order");
|
||||
|
||||
Map<String, String> dynamicData = new HashMap<>();
|
||||
dynamicData.put("image1", "https://example.com/img1.jpg");
|
||||
dynamicData.put("image2", "https://example.com/img2.jpg");
|
||||
request.setDynamicData(dynamicData);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private PuzzleTemplateEntity createMockTemplate() {
|
||||
PuzzleTemplateEntity template = new PuzzleTemplateEntity();
|
||||
template.setId(1L);
|
||||
template.setCode("test_template");
|
||||
template.setName("测试模板");
|
||||
template.setStatus(1);
|
||||
template.setScenicId(1L);
|
||||
template.setCanvasWidth(750);
|
||||
template.setCanvasHeight(1334);
|
||||
template.setBackgroundType(0);
|
||||
template.setBackgroundColor("#FFFFFF");
|
||||
return template;
|
||||
}
|
||||
|
||||
private List<PuzzleElementEntity> createMockElements() {
|
||||
List<PuzzleElementEntity> elements = new ArrayList<>();
|
||||
elements.add(createImageElement(1L, "image1"));
|
||||
elements.add(createImageElement(2L, "image2"));
|
||||
return elements;
|
||||
}
|
||||
|
||||
private PuzzleElementEntity createImageElement(Long id, String elementKey) {
|
||||
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||
element.setId(id);
|
||||
element.setElementKey(elementKey);
|
||||
element.setElementType("IMAGE"); // IMAGE类型
|
||||
element.setZIndex(10);
|
||||
return element;
|
||||
}
|
||||
|
||||
private PuzzleGenerationRecordEntity createHistoricalRecord() {
|
||||
PuzzleGenerationRecordEntity record = new PuzzleGenerationRecordEntity();
|
||||
record.setId(999L);
|
||||
record.setTemplateId(1L);
|
||||
record.setTemplateCode("test_template");
|
||||
record.setResultImageUrl("https://example.com/old-image.jpg");
|
||||
record.setResultFileSize(123456L);
|
||||
record.setResultWidth(750);
|
||||
record.setResultHeight(1334);
|
||||
record.setStatus(1);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* PuzzleDuplicationDetector 单元测试
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-21
|
||||
*/
|
||||
class PuzzleDuplicationDetectorTest {
|
||||
|
||||
@Mock
|
||||
private PuzzleGenerationRecordMapper recordMapper;
|
||||
|
||||
@InjectMocks
|
||||
private PuzzleDuplicationDetector detector;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCalculateContentHash_基本功能() {
|
||||
// 准备数据
|
||||
Map<String, String> data = new HashMap<>();
|
||||
data.put("userName", "张三");
|
||||
data.put("userAvatar", "https://example.com/avatar.jpg");
|
||||
|
||||
// 执行
|
||||
String hash = detector.calculateContentHash(data);
|
||||
|
||||
// 验证
|
||||
assertNotNull(hash);
|
||||
assertEquals(64, hash.length()); // SHA256产生64位16进制字符串
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCalculateContentHash_相同内容产生相同哈希() {
|
||||
// 准备数据
|
||||
Map<String, String> data1 = new HashMap<>();
|
||||
data1.put("userName", "张三");
|
||||
data1.put("userAvatar", "https://example.com/avatar.jpg");
|
||||
|
||||
Map<String, String> data2 = new HashMap<>();
|
||||
data2.put("userAvatar", "https://example.com/avatar.jpg");
|
||||
data2.put("userName", "张三");
|
||||
|
||||
// 执行
|
||||
String hash1 = detector.calculateContentHash(data1);
|
||||
String hash2 = detector.calculateContentHash(data2);
|
||||
|
||||
// 验证: 不同顺序的key应该产生相同哈希
|
||||
assertEquals(hash1, hash2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCalculateContentHash_不同内容产生不同哈希() {
|
||||
// 准备数据
|
||||
Map<String, String> data1 = new HashMap<>();
|
||||
data1.put("userName", "张三");
|
||||
|
||||
Map<String, String> data2 = new HashMap<>();
|
||||
data2.put("userName", "李四");
|
||||
|
||||
// 执行
|
||||
String hash1 = detector.calculateContentHash(data1);
|
||||
String hash2 = detector.calculateContentHash(data2);
|
||||
|
||||
// 验证
|
||||
assertNotEquals(hash1, hash2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCalculateContentHash_空数据() {
|
||||
Map<String, String> emptyData = new HashMap<>();
|
||||
|
||||
String hash = detector.calculateContentHash(emptyData);
|
||||
|
||||
assertNotNull(hash);
|
||||
assertEquals("", hash);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDetectDuplicateImages_所有图片相同抛出异常() {
|
||||
// 准备数据
|
||||
Map<String, String> data = new HashMap<>();
|
||||
data.put("image1", "https://example.com/same.jpg");
|
||||
data.put("image2", "https://example.com/same.jpg");
|
||||
data.put("image3", "https://example.com/same.jpg");
|
||||
|
||||
List<PuzzleElementEntity> elements = new ArrayList<>();
|
||||
elements.add(createImageElement(1L, "image1"));
|
||||
elements.add(createImageElement(2L, "image2"));
|
||||
elements.add(createImageElement(3L, "image3"));
|
||||
|
||||
// 执行并验证
|
||||
DuplicateImageException exception = assertThrows(
|
||||
DuplicateImageException.class,
|
||||
() -> detector.detectDuplicateImages(data, elements)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("https://example.com/same.jpg"));
|
||||
assertEquals(3, exception.getElementCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDetectDuplicateImages_不同图片不抛异常() {
|
||||
// 准备数据
|
||||
Map<String, String> data = new HashMap<>();
|
||||
data.put("image1", "https://example.com/img1.jpg");
|
||||
data.put("image2", "https://example.com/img2.jpg");
|
||||
|
||||
List<PuzzleElementEntity> elements = new ArrayList<>();
|
||||
elements.add(createImageElement(1L, "image1"));
|
||||
elements.add(createImageElement(2L, "image2"));
|
||||
|
||||
// 执行: 不应该抛出异常
|
||||
assertDoesNotThrow(() -> detector.detectDuplicateImages(data, elements));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDetectDuplicateImages_只有一个图片元素不检测() {
|
||||
Map<String, String> data = new HashMap<>();
|
||||
data.put("image1", "https://example.com/same.jpg");
|
||||
|
||||
List<PuzzleElementEntity> elements = new ArrayList<>();
|
||||
elements.add(createImageElement(1L, "image1"));
|
||||
|
||||
// 执行: 不应该抛出异常
|
||||
assertDoesNotThrow(() -> detector.detectDuplicateImages(data, elements));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDetectDuplicateImages_混合元素类型() {
|
||||
Map<String, String> data = new HashMap<>();
|
||||
data.put("image1", "https://example.com/same.jpg");
|
||||
data.put("text1", "标题文本");
|
||||
data.put("image2", "https://example.com/same.jpg");
|
||||
|
||||
List<PuzzleElementEntity> elements = new ArrayList<>();
|
||||
elements.add(createImageElement(1L, "image1"));
|
||||
elements.add(createTextElement(2L, "text1"));
|
||||
elements.add(createImageElement(3L, "image2"));
|
||||
|
||||
// 执行并验证: 应该抛出异常(两个图片相同)
|
||||
assertThrows(DuplicateImageException.class,
|
||||
() -> detector.detectDuplicateImages(data, elements));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindDuplicateRecord_找到记录() {
|
||||
// 准备数据
|
||||
Long templateId = 100L;
|
||||
String contentHash = "abc123";
|
||||
Long scenicId = 1L;
|
||||
|
||||
PuzzleGenerationRecordEntity mockRecord = new PuzzleGenerationRecordEntity();
|
||||
mockRecord.setId(999L);
|
||||
mockRecord.setResultImageUrl("https://example.com/old.jpg");
|
||||
|
||||
// Mock行为
|
||||
when(recordMapper.findByContentHash(templateId, contentHash, scenicId))
|
||||
.thenReturn(mockRecord);
|
||||
|
||||
// 执行
|
||||
PuzzleGenerationRecordEntity result = detector.findDuplicateRecord(templateId, contentHash, scenicId);
|
||||
|
||||
// 验证
|
||||
assertNotNull(result);
|
||||
assertEquals(999L, result.getId());
|
||||
assertEquals("https://example.com/old.jpg", result.getResultImageUrl());
|
||||
verify(recordMapper).findByContentHash(templateId, contentHash, scenicId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindDuplicateRecord_未找到记录() {
|
||||
Long templateId = 100L;
|
||||
String contentHash = "abc123";
|
||||
Long scenicId = 1L;
|
||||
|
||||
when(recordMapper.findByContentHash(templateId, contentHash, scenicId))
|
||||
.thenReturn(null);
|
||||
|
||||
PuzzleGenerationRecordEntity result = detector.findDuplicateRecord(templateId, contentHash, scenicId);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
// 辅助方法: 创建图片元素
|
||||
private PuzzleElementEntity createImageElement(Long id, String elementKey) {
|
||||
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||
element.setId(id);
|
||||
element.setElementKey(elementKey);
|
||||
element.setElementType("IMAGE"); // IMAGE类型
|
||||
return element;
|
||||
}
|
||||
|
||||
// 辅助方法: 创建文本元素
|
||||
private PuzzleElementEntity createTextElement(Long id, String elementKey) {
|
||||
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||
element.setId(id);
|
||||
element.setElementKey(elementKey);
|
||||
element.setElementType("TEXT"); // TEXT类型
|
||||
return element;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user