diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateResponse.java b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateResponse.java index b7b0af77..a34d3ee4 100644 --- a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateResponse.java +++ b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateResponse.java @@ -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); } } diff --git a/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleGenerationRecordEntity.java b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleGenerationRecordEntity.java index 152b14ec..0eccca75 100644 --- a/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleGenerationRecordEntity.java +++ b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleGenerationRecordEntity.java @@ -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 */ diff --git a/src/main/java/com/ycwl/basic/puzzle/exception/DuplicateImageException.java b/src/main/java/com/ycwl/basic/puzzle/exception/DuplicateImageException.java new file mode 100644 index 00000000..22e5df91 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/exception/DuplicateImageException.java @@ -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; + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/exception/PuzzleBizException.java b/src/main/java/com/ycwl/basic/puzzle/exception/PuzzleBizException.java new file mode 100644 index 00000000..0cde3d54 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/exception/PuzzleBizException.java @@ -0,0 +1,7 @@ +package com.ycwl.basic.puzzle.exception; + +public class PuzzleBizException extends RuntimeException { + public PuzzleBizException(String message) { + super(message); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleGenerationRecordMapper.java b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleGenerationRecordMapper.java index 42c27593..3a18b1e5 100644 --- a/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleGenerationRecordMapper.java +++ b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleGenerationRecordMapper.java @@ -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); } diff --git a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java index 001ea9fc..dae62138 100644 --- a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java +++ b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java @@ -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 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) { diff --git a/src/main/java/com/ycwl/basic/puzzle/util/PuzzleDuplicationDetector.java b/src/main/java/com/ycwl/basic/puzzle/util/PuzzleDuplicationDetector.java new file mode 100644 index 00000000..4287a8e5 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/util/PuzzleDuplicationDetector.java @@ -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 finalData) { + if (finalData == null || finalData.isEmpty()) { + log.warn("计算内容哈希时发现空数据,返回空哈希"); + return ""; + } + + try { + // 1. 按key排序,确保相同内容生成相同哈希 + List 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 finalData, List elements) { + if (finalData == null || finalData.isEmpty() || elements == null || elements.isEmpty()) { + log.debug("跳过重复图片检测: 数据为空"); + return; + } + + // 1. 筛选出所有IMAGE类型的元素(elementType="IMAGE") + List imageElements = elements.stream() + .filter(e -> "IMAGE".equals(e.getElementType())) + .collect(Collectors.toList()); + + if (imageElements.size() < 2) { + log.debug("图片元素数量不足2个,跳过重复检测"); + return; + } + + // 2. 提取所有图片元素的实际URL值 + List 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 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; + } + } +} diff --git a/src/main/resources/mapper/PuzzleGenerationRecordMapper.xml b/src/main/resources/mapper/PuzzleGenerationRecordMapper.xml index feb18b64..6f7460a1 100644 --- a/src/main/resources/mapper/PuzzleGenerationRecordMapper.xml +++ b/src/main/resources/mapper/PuzzleGenerationRecordMapper.xml @@ -12,6 +12,9 @@ + + + @@ -30,7 +33,8 @@ 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 @@ -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} + + + diff --git a/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceDeduplicationTest.java b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceDeduplicationTest.java new file mode 100644 index 00000000..84c49c1a --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceDeduplicationTest.java @@ -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 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 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 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 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 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 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 createMockElements() { + List 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; + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/util/PuzzleDuplicationDetectorTest.java b/src/test/java/com/ycwl/basic/puzzle/util/PuzzleDuplicationDetectorTest.java new file mode 100644 index 00000000..8dc2d000 --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/util/PuzzleDuplicationDetectorTest.java @@ -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 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 data1 = new HashMap<>(); + data1.put("userName", "张三"); + data1.put("userAvatar", "https://example.com/avatar.jpg"); + + Map 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 data1 = new HashMap<>(); + data1.put("userName", "张三"); + + Map data2 = new HashMap<>(); + data2.put("userName", "李四"); + + // 执行 + String hash1 = detector.calculateContentHash(data1); + String hash2 = detector.calculateContentHash(data2); + + // 验证 + assertNotEquals(hash1, hash2); + } + + @Test + void testCalculateContentHash_空数据() { + Map emptyData = new HashMap<>(); + + String hash = detector.calculateContentHash(emptyData); + + assertNotNull(hash); + assertEquals("", hash); + } + + @Test + void testDetectDuplicateImages_所有图片相同抛出异常() { + // 准备数据 + Map 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 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 data = new HashMap<>(); + data.put("image1", "https://example.com/img1.jpg"); + data.put("image2", "https://example.com/img2.jpg"); + + List elements = new ArrayList<>(); + elements.add(createImageElement(1L, "image1")); + elements.add(createImageElement(2L, "image2")); + + // 执行: 不应该抛出异常 + assertDoesNotThrow(() -> detector.detectDuplicateImages(data, elements)); + } + + @Test + void testDetectDuplicateImages_只有一个图片元素不检测() { + Map data = new HashMap<>(); + data.put("image1", "https://example.com/same.jpg"); + + List elements = new ArrayList<>(); + elements.add(createImageElement(1L, "image1")); + + // 执行: 不应该抛出异常 + assertDoesNotThrow(() -> detector.detectDuplicateImages(data, elements)); + } + + @Test + void testDetectDuplicateImages_混合元素类型() { + Map data = new HashMap<>(); + data.put("image1", "https://example.com/same.jpg"); + data.put("text1", "标题文本"); + data.put("image2", "https://example.com/same.jpg"); + + List 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; + } +}