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

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

View File

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