feat(puzzle): 实现智能自动填充引擎和安全增强

- 新增拼图元素自动填充引擎 PuzzleElementFillEngine
- 支持基于规则的条件匹配和数据源解析
- 实现机位数量、机位ID等多维度条件策略
- 添加 DEVICE_IMAGE、USER_AVATAR 等数据源类型支持
- 增加景区隔离校验确保模板使用安全性
- 强化图片下载安全校验,防范 SSRF 攻击
- 支持本地文件路径解析和公网 URL 安全检查
- 完善静态值数据源策略支持 localPath 配置
- 优化生成流程中 faceId 和 scenicId 的校验逻辑
- 补充相关单元测试覆盖核心功能点
This commit is contained in:
2025-11-19 17:28:41 +08:00
parent cb17ea527b
commit cfb3625ac0
12 changed files with 748 additions and 57 deletions

View File

@@ -2,18 +2,27 @@ package com.ycwl.basic.puzzle.element;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.base.ElementFactory;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.element.impl.ImageElement;
import com.ycwl.basic.puzzle.element.impl.TextElement;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import javax.imageio.ImageIO;
import static org.junit.jupiter.api.Assertions.*;
/**
@@ -27,6 +36,23 @@ class ImageElementTest {
private Graphics2D graphics;
private RenderContext context;
@BeforeAll
static void initRegistry() {
ElementFactory.clearRegistry();
ElementFactory.register(ElementType.TEXT, TextElement.class);
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
}
static class TestableImageElement extends ImageElement {
boolean isSafe(String url) {
return isSafeRemoteUrl(url);
}
BufferedImage load(String path) {
return downloadImage(path);
}
}
@BeforeEach
void setUp() {
BufferedImage canvas = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB);
@@ -121,4 +147,31 @@ class ImageElementTest {
assertTrue(schema.contains("imageFitMode"));
assertTrue(schema.contains("borderRadius"));
}
@Test
void testImageElement_SafeRemoteUrlChecks() {
TestableImageElement element = new TestableImageElement();
assertFalse(element.isSafe("http://127.0.0.1/admin.png"));
assertFalse(element.isSafe("http://localhost/private.png"));
assertFalse(element.isSafe("file:///etc/passwd"));
assertTrue(element.isSafe("https://8.8.8.8/logo.png"));
}
@Test
void testImageElement_LoadLocalImageSuccess() throws IOException {
Path temp = Files.createTempFile("puzzle-image", ".png");
try {
BufferedImage bufferedImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB);
ImageIO.write(bufferedImage, "png", temp.toFile());
TestableImageElement element = new TestableImageElement();
BufferedImage loaded = element.load(temp.toString());
assertNotNull(loaded);
assertEquals(10, loaded.getWidth());
assertEquals(10, loaded.getHeight());
} finally {
Files.deleteIfExists(temp);
}
}
}

View File

@@ -15,6 +15,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.ArrayList;
import java.util.Arrays;
@@ -30,6 +32,7 @@ import static org.mockito.Mockito.*;
*/
@DisplayName("拼图元素填充引擎测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PuzzleElementFillEngineTest {
@Mock
@@ -127,6 +130,51 @@ class PuzzleElementFillEngineTest {
verify(dataSourceResolver, times(4)).resolve(anyString(), anyString(), anyString(), anyString(), any());
}
@Test
@DisplayName("缺少faceId或scenicId时直接返回空结果")
void shouldReturnEmptyWhenRequiredIdsMissing() {
Map<String, String> result = engine.execute(1L, null, 10L);
assertTrue(result.isEmpty());
verifyNoInteractions(ruleMapper, itemMapper, sourceMapper, conditionEvaluator, dataSourceResolver);
Map<String, String> result2 = engine.execute(1L, 10L, null);
assertTrue(result2.isEmpty());
}
@Test
@DisplayName("规则无明细时应继续尝试下一条规则")
void shouldContinueWhenRuleHasNoItems() {
Long templateId = 1L;
Long faceId = 123L;
Long scenicId = 1L;
PuzzleFillRuleEntity highPriorityRule = createRule(1L, "高优先级无明细", 200);
PuzzleFillRuleEntity lowPriorityRule = createRule(2L, "低优先级有效", 100);
when(ruleMapper.listByTemplateAndScenic(templateId, scenicId))
.thenReturn(Arrays.asList(highPriorityRule, lowPriorityRule));
when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(1);
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(List.of(99L));
when(conditionEvaluator.evaluate(eq(highPriorityRule), any())).thenReturn(true);
when(conditionEvaluator.evaluate(eq(lowPriorityRule), any())).thenReturn(true);
when(itemMapper.listByRuleId(highPriorityRule.getId())).thenReturn(new ArrayList<>());
PuzzleFillRuleItemEntity lowRuleItem = createItem(10L, lowPriorityRule.getId(), "avatar",
"DEVICE_IMAGE", "{\"deviceIndex\":0}", "LATEST");
when(itemMapper.listByRuleId(lowPriorityRule.getId())).thenReturn(List.of(lowRuleItem));
when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), anyString(), any()))
.thenReturn("https://oss.example.com/valid.png");
Map<String, String> result = engine.execute(templateId, faceId, scenicId);
assertEquals(1, result.size());
assertEquals("https://oss.example.com/valid.png", result.get("avatar"));
verify(conditionEvaluator, times(2)).evaluate(any(), any());
verify(itemMapper, times(2)).listByRuleId(anyLong());
}
@Test
@DisplayName("应该按优先级顺序评估规则并在匹配第一条后停止")
void shouldEvaluateRulesByPriorityAndStopAfterFirstMatch() {

View File

@@ -6,6 +6,9 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/**
@@ -105,6 +108,32 @@ class StaticValueDataSourceStrategyTest {
testStaticValue("测试中文/Special!@#$%");
}
@Test
@DisplayName("应该支持localPath字段并返回绝对路径")
void shouldSupportLocalPathField() throws Exception {
Path temp = Files.createTempFile("puzzle-static", ".png");
Files.writeString(temp, "test");
try {
JsonNode sourceFilter = objectMapper.readTree("{\"localPath\":\"" + temp.toString().replace("\\", "\\\\") + "\"}");
DataSourceContext context = DataSourceContext.builder().build();
String result = strategy.resolve(sourceFilter, null, context);
assertEquals(temp.toAbsolutePath().toString(), result);
} finally {
Files.deleteIfExists(temp);
}
}
@Test
@DisplayName("当localPath无效时应该返回null")
void shouldReturnNullWhenLocalPathInvalid() throws Exception {
JsonNode sourceFilter = objectMapper.readTree("{\"localPath\":\"/path/not/found.png\"}");
DataSourceContext context = DataSourceContext.builder().build();
String result = strategy.resolve(sourceFilter, null, context);
assertNull(result);
}
private void testStaticValue(String value) throws Exception {
JsonNode sourceFilter = objectMapper.readTree("{\"value\": \"" + value + "\"}");
DataSourceContext context = DataSourceContext.builder().build();

View File

@@ -0,0 +1,173 @@
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.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.test.PuzzleTestDataBuilder;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Field;
import java.awt.image.BufferedImage;
import java.io.InputStream;
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.*;
@ExtendWith(MockitoExtension.class)
class PuzzleGenerateServiceImplTest {
@Mock
private PuzzleTemplateMapper templateMapper;
@Mock
private PuzzleElementMapper elementMapper;
@Mock
private PuzzleGenerationRecordMapper recordMapper;
@Mock
private PuzzleImageRenderer imageRenderer;
@Mock
private PuzzleElementFillEngine fillEngine;
@InjectMocks
private PuzzleGenerateServiceImpl service;
@Test
void shouldRejectWhenTemplateScenicMismatch() {
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createBasicTemplate();
template.setScenicId(100L);
when(templateMapper.getByCode("ticket")).thenReturn(template);
PuzzleGenerateRequest request = new PuzzleGenerateRequest();
request.setTemplateCode("ticket");
request.setScenicId(200L);
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> service.generate(request));
assertTrue(ex.getMessage().contains("模板不属于当前景区"));
verify(elementMapper, never()).getByTemplateId(anyLong());
}
@Test
void shouldUseTemplateScenicAndTriggerFillEngine() {
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createBasicTemplate();
template.setScenicId(9L);
PuzzleElementEntity element = PuzzleTestDataBuilder.createTextElement(
template.getId(), "realName", 0, 0, 100, 30, 1, "默认", 16, "#000000"
);
when(templateMapper.getByCode("ticket")).thenReturn(template);
when(elementMapper.getByTemplateId(template.getId())).thenReturn(List.of(element));
when(fillEngine.execute(eq(template.getId()), eq(88L), eq(9L)))
.thenReturn(Map.of("faceImage", "https://images.test/a.png"));
when(imageRenderer.render(eq(template), anyList(), anyMap()))
.thenReturn(new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB));
doAnswer(invocation -> {
PuzzleGenerationRecordEntity record = invocation.getArgument(0);
record.setId(555L);
return 1;
}).when(recordMapper).insert(any());
when(recordMapper.updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt())).thenReturn(1);
PuzzleGenerateRequest request = new PuzzleGenerateRequest();
request.setTemplateCode("ticket");
request.setScenicId(9L);
request.setFaceId(88L);
request.setDynamicData(Map.of("orderNo", "A001"));
IStorageAdapter storageAdapter = mock(IStorageAdapter.class);
when(storageAdapter.uploadFile(anyString(), any(InputStream.class), any(String[].class)))
.thenReturn("https://oss.example.com/puzzle/final.png");
useStorageAdapter(storageAdapter);
try {
PuzzleGenerateResponse response = service.generate(request);
assertNotNull(response);
assertEquals("https://oss.example.com/puzzle/final.png", response.getImageUrl());
} finally {
resetStorageFactory();
}
verify(fillEngine).execute(template.getId(), 88L, 9L);
ArgumentCaptor<com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity> captor =
ArgumentCaptor.forClass(com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity.class);
verify(recordMapper).insert(captor.capture());
assertEquals(9L, captor.getValue().getScenicId());
}
@Test
void shouldSkipFillEngineWhenScenicMissing() {
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createBasicTemplate();
template.setScenicId(null);
PuzzleElementEntity element = PuzzleTestDataBuilder.createTextElement(
template.getId(), "username", 0, 0, 100, 30, 1, "fallback", 14, "#000"
);
when(templateMapper.getByCode("ticket")).thenReturn(template);
when(elementMapper.getByTemplateId(template.getId())).thenReturn(List.of(element));
when(imageRenderer.render(eq(template), anyList(), anyMap()))
.thenReturn(new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB));
doAnswer(invocation -> {
PuzzleGenerationRecordEntity record = invocation.getArgument(0);
record.setId(777L);
return 1;
}).when(recordMapper).insert(any());
when(recordMapper.updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt())).thenReturn(1);
PuzzleGenerateRequest request = new PuzzleGenerateRequest();
request.setTemplateCode("ticket");
request.setFaceId(188L); // 缺少scenicId
IStorageAdapter storageAdapter = mock(IStorageAdapter.class);
when(storageAdapter.uploadFile(anyString(), any(InputStream.class), any(String[].class)))
.thenReturn("https://oss.example.com/puzzle/default.png");
useStorageAdapter(storageAdapter);
try {
service.generate(request);
} finally {
resetStorageFactory();
}
verify(fillEngine, never()).execute(anyLong(), anyLong(), anyLong());
}
@SuppressWarnings("unchecked")
private void resetStorageFactory() {
try {
Field namedStorageField = StorageFactory.class.getDeclaredField("namedStorage");
namedStorageField.setAccessible(true);
Map<String, IStorageAdapter> map = (Map<String, IStorageAdapter>) namedStorageField.get(null);
map.remove("puzzle-unit-test");
Field defaultStorageField = StorageFactory.class.getDeclaredField("defaultStorage");
defaultStorageField.setAccessible(true);
defaultStorageField.set(null, null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private void useStorageAdapter(IStorageAdapter adapter) {
StorageFactory.register("puzzle-unit-test", adapter);
StorageFactory.setDefault("puzzle-unit-test");
}
}

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.puzzle.util;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ElementConfigHelperTest {
@Test
void shouldAcceptImplementedElementType() {
assertTrue(ElementConfigHelper.isValidElementType("TEXT"));
assertTrue(ElementConfigHelper.isValidElementType("image"));
}
@Test
void shouldRejectUnimplementedElementType() {
assertFalse(ElementConfigHelper.isValidElementType("QRCODE"));
ElementCreateRequest request = new ElementCreateRequest();
request.setTemplateId(1L);
request.setElementType("QRCODE");
request.setElementKey("qr");
request.setElementName("二维码");
request.setXPosition(0);
request.setYPosition(0);
request.setWidth(100);
request.setHeight(100);
request.setConfig("{\"defaultText\":\"qr\"}");
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> ElementConfigHelper.validateRequest(request));
assertTrue(ex.getMessage().contains("不支持的元素类型"));
}
}