You've already forked FrameTour-BE
feat(puzzle): 实现智能自动填充引擎和安全增强
- 新增拼图元素自动填充引擎 PuzzleElementFillEngine - 支持基于规则的条件匹配和数据源解析 - 实现机位数量、机位ID等多维度条件策略 - 添加 DEVICE_IMAGE、USER_AVATAR 等数据源类型支持 - 增加景区隔离校验确保模板使用安全性 - 强化图片下载安全校验,防范 SSRF 攻击 - 支持本地文件路径解析和公网 URL 安全检查 - 完善静态值数据源策略支持 localPath 配置 - 优化生成流程中 faceId 和 scenicId 的校验逻辑 - 补充相关单元测试覆盖核心功能点
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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("不支持的元素类型"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user