From cfb3625ac00fdb870a980247e9d7562e9cf4d44b Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 19 Nov 2025 17:28:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(puzzle):=20=E5=AE=9E=E7=8E=B0=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E8=87=AA=E5=8A=A8=E5=A1=AB=E5=85=85=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E5=92=8C=E5=AE=89=E5=85=A8=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增拼图元素自动填充引擎 PuzzleElementFillEngine - 支持基于规则的条件匹配和数据源解析 - 实现机位数量、机位ID等多维度条件策略 - 添加 DEVICE_IMAGE、USER_AVATAR 等数据源类型支持 - 增加景区隔离校验确保模板使用安全性 - 强化图片下载安全校验,防范 SSRF 攻击 - 支持本地文件路径解析和公网 URL 安全检查 - 完善静态值数据源策略支持 localPath 配置 - 优化生成流程中 faceId 和 scenicId 的校验逻辑 - 补充相关单元测试覆盖核心功能点 --- src/main/java/com/ycwl/basic/puzzle/claude.md | 215 +++++++++++++++++- .../puzzle/element/config/ImageConfig.java | 11 +- .../puzzle/element/impl/ImageElement.java | 115 ++++++++-- .../puzzle/fill/PuzzleElementFillEngine.java | 7 +- .../StaticValueDataSourceStrategy.java | 51 ++++- .../impl/PuzzleGenerateServiceImpl.java | 54 ++++- .../puzzle/util/ElementConfigHelper.java | 14 +- .../puzzle/element/ImageElementTest.java | 53 +++++ .../fill/PuzzleElementFillEngineTest.java | 48 ++++ .../StaticValueDataSourceStrategyTest.java | 29 +++ .../impl/PuzzleGenerateServiceImplTest.java | 173 ++++++++++++++ .../puzzle/util/ElementConfigHelperTest.java | 35 +++ 12 files changed, 748 insertions(+), 57 deletions(-) create mode 100644 src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImplTest.java create mode 100644 src/test/java/com/ycwl/basic/puzzle/util/ElementConfigHelperTest.java diff --git a/src/main/java/com/ycwl/basic/puzzle/claude.md b/src/main/java/com/ycwl/basic/puzzle/claude.md index 5036947c..3fe2cadb 100644 --- a/src/main/java/com/ycwl/basic/puzzle/claude.md +++ b/src/main/java/com/ycwl/basic/puzzle/claude.md @@ -9,6 +9,7 @@ Puzzle拼图模块是一个基于模板和元素的动态图片生成系统, - 多层次元素渲染:支持图片和文字元素的分层叠加 - 灵活的样式配置:支持位置、大小、透明度、旋转、圆角等属性 - 动态数据注入:通过elementKey进行动态数据替换 +- 智能自动填充:基于规则引擎自动选择和填充素材数据 - 生成记录追踪:完整记录每次生成的参数和结果 **典型应用场景:** @@ -27,28 +28,58 @@ Puzzle拼图模块是一个基于模板和元素的动态图片生成系统, puzzle/ ├── controller/ # API接口层 │ ├── PuzzleGenerateController.java # 拼图生成接口 -│ └── PuzzleTemplateController.java # 模板管理接口 +│ ├── PuzzleTemplateController.java # 模板管理接口 +│ └── PuzzleFillRuleController.java # 填充规则管理接口 ├── service/ # 业务逻辑层 │ ├── IPuzzleGenerateService.java │ ├── IPuzzleTemplateService.java +│ ├── IPuzzleFillRuleService.java │ └── impl/ │ ├── PuzzleGenerateServiceImpl.java -│ └── PuzzleTemplateServiceImpl.java +│ ├── PuzzleTemplateServiceImpl.java +│ └── PuzzleFillRuleServiceImpl.java ├── mapper/ # 数据访问层 │ ├── PuzzleTemplateMapper.java │ ├── PuzzleElementMapper.java -│ └── PuzzleGenerationRecordMapper.java +│ ├── PuzzleGenerationRecordMapper.java +│ ├── PuzzleFillRuleMapper.java +│ └── PuzzleFillRuleItemMapper.java +│ # 拼图引擎会复用基础域的 com.ycwl.basic.mapper.SourceMapper(不在 puzzle 包内) ├── entity/ # 实体类 │ ├── PuzzleTemplateEntity.java # 模板实体 │ ├── PuzzleElementEntity.java # 元素实体 -│ └── PuzzleGenerationRecordEntity.java # 生成记录实体 +│ ├── PuzzleGenerationRecordEntity.java # 生成记录实体 +│ ├── PuzzleFillRuleEntity.java # 填充规则实体 +│ └── PuzzleFillRuleItemEntity.java # 填充规则明细实体 ├── dto/ # 数据传输对象 │ ├── PuzzleGenerateRequest.java # 生成请求 │ ├── PuzzleGenerateResponse.java # 生成响应 │ ├── PuzzleTemplateDTO.java # 模板DTO │ ├── PuzzleElementDTO.java # 元素DTO │ ├── TemplateCreateRequest.java # 模板创建请求 -│ └── ElementCreateRequest.java # 元素创建请求 +│ ├── ElementCreateRequest.java # 元素创建请求 +│ ├── PuzzleFillRuleSaveRequest.java # 填充规则保存请求 +│ ├── PuzzleFillRuleDTO.java # 填充规则DTO +│ └── PuzzleFillRuleItemDTO.java # 填充规则明细DTO +├── fill/ # 自动填充引擎 +│ ├── PuzzleElementFillEngine.java # 填充引擎(核心) +│ ├── condition/ # 条件策略 +│ │ ├── ConditionStrategy.java +│ │ ├── ConditionEvaluator.java +│ │ ├── ConditionContext.java +│ │ ├── AlwaysConditionStrategy.java +│ │ ├── DeviceCountConditionStrategy.java +│ │ ├── DeviceCountRangeConditionStrategy.java +│ │ └── DeviceIdMatchConditionStrategy.java +│ ├── datasource/ # 数据源解析 +│ │ ├── DataSourceResolver.java +│ │ ├── DeviceImageDataSourceStrategy.java +│ │ ├── FaceUrlDataSourceStrategy.java +│ │ └── StaticValueDataSourceStrategy.java +│ └── enums/ # 枚举定义 +│ ├── ConditionType.java +│ ├── DataSourceType.java +│ └── SortStrategy.java └── util/ # 工具类 └── PuzzleImageRenderer.java # 图片渲染引擎(核心) ``` @@ -57,8 +88,16 @@ puzzle/ 1. **服务层模式(Service Layer)**:业务逻辑封装在service层,controller只负责接口适配 2. **DTO模式**:使用独立的DTO对象处理API输入输出,与Entity分离 -3. **策略模式**:图片适配模式(CONTAIN、COVER、FILL等) +3. **策略模式**:图片适配模式(CONTAIN、COVER、FILL等)、条件匹配策略、数据源解析策略、排序策略 4. **建造者模式**:通过模板+元素配置构建最终图片 +5. **责任链模式**:自动填充规则按优先级顺序匹配执行 + +## 🔐 运行时安全与一致性保证 + +- **景区隔离强校验**:PuzzleGenerateServiceImpl 会根据模板的 `scenicId` 判断是否允许当前请求生成图片;模板绑定了景区时,请求必须传入相同的 `scenicId`,否则直接拒绝,避免跨租户串用模板。 +- **自动填充参数兜底**:自动填充引擎 `PuzzleElementFillEngine` 仅在 `faceId + scenicId` 同时存在时触发,且规则没有明细项时会继续匹配下一条,防止空规则截断后续逻辑。 +- **元素类型白名单**:`ElementConfigHelper` 仅允许 `ElementType` 枚举中已经落地的 TEXT、IMAGE 类型入库,杜绝未实现类型绕过验证。 +- **图片下载防护**:`ImageElement` 只接受公网 http/https 地址,自动阻断内网、环回以及 file:// 资源,并设置请求超时,缓解 SSRF 与资源阻塞风险。 --- @@ -179,7 +218,93 @@ PuzzleGenerateResponse generate(PuzzleGenerateRequest request) --- -### 4. Controller接口层 +### 4. PuzzleElementFillEngine - 自动填充引擎(新功能) + +**职责**:基于规则引擎自动选择和填充拼图元素的数据源 + +**核心概念**: +- 通过配置化的规则(而非硬编码)决定每个元素使用哪些素材 +- 支持基于机位数量、机位ID、人脸特征等多维度条件匹配 +- 支持多种数据源(机位图片、用户头像、二维码等) +- 支持灵活的排序策略(最新、评分、随机等) +- 支持优先级和降级策略 + +**核心方法**: +```java +Map execute(Long templateId, Long faceId, Long scenicId) +``` + +**执行流程**: +1. **加载规则列表**: + - 查询指定模板和景区的所有启用规则(`PuzzleFillRuleMapper.listByTemplateAndScenic`) + - 按`priority`降序排序(优先级高的先执行) + +2. **构建上下文**: + - 查询faceId关联的机位数量(`SourceMapper.countDistinctDevicesByFaceId`) + - 查询faceId关联的机位ID列表(`SourceMapper.getDeviceIdsByFaceId`) + - 构建`ConditionContext`对象 + +3. **规则匹配**: + - 遍历规则列表,调用`ConditionEvaluator.evaluate()`评估每条规则 + - 匹配到第一条符合条件的规则后停止(责任链模式) + +4. **执行填充**: + - 查询匹配规则的所有明细项(`PuzzleFillRuleItemMapper.listByRuleId`) + - 按`itemOrder`排序 + - 对每条明细调用`DataSourceResolver.resolve()`解析数据源 + - 返回`Map` + +**条件策略(Strategy Pattern)**: + +| 策略类型 | 类名 | 匹配逻辑 | 配置示例 | +|---------|------|---------|---------| +| 总是匹配 | AlwaysConditionStrategy | 总是返回true,用作兜底规则 | `{}` | +| 机位数量匹配 | DeviceCountConditionStrategy | 精确匹配机位数量 | `{"deviceCount": 4}` | +| 机位数量范围 | DeviceCountRangeConditionStrategy | 机位数量在指定范围内 | `{"minCount": 2, "maxCount": 5}` | +| 机位ID匹配 | DeviceIdMatchConditionStrategy | 匹配指定的机位ID(支持ANY/ALL模式) | `{"deviceIds": [200, 300], "matchMode": "ALL"}` | + +**数据源类型**: + +| 类型 | 说明 | sourceFilter 配置 | +|------|------|------------------| +| DEVICE_IMAGE | 机位图片 | `{"deviceIndex": 0, "type": 2}` - deviceIndex指定使用第几个机位,type指定图片类型 | +| USER_AVATAR | 用户头像 | `{}` | +| QR_CODE | 二维码 | `{"content": "{orderId}"}` - 支持变量替换 | + +**排序策略**: + +| 策略 | 说明 | +|------|------| +| LATEST | 最新优先(按创建时间降序) | +| EARLIEST | 最早优先(按创建时间升序) | +| SCORE_DESC | 评分降序(适用于有评分的素材) | +| SCORE_ASC | 评分升序 | +| RANDOM | 随机选择 | + +**降级策略**: +- 每条明细可配置`fallbackValue` +- 当数据源无法获取到值时,使用降级默认值 +- 如果降级值也为空,则跳过该元素的填充 + +**技术要点**: +- 使用Spring的`@Component`自动注册策略 +- 使用Jackson解析JSON配置 +- 缓存机位数量和机位列表,单次执行仅查询一次 +- 详细日志记录规则匹配和填充过程 + +**使用场景**: +- 根据机位数量选择不同布局(4机位用4宫格,6机位用六宫格) +- 优先使用高质量机位的图片(指定机位200、300) +- 多机位组合场景(只有机位A和B同时存在时使用特定布局) + +**性能优化**: +- 规则数量建议不超过10条/模板 +- 优先级高的规则应配置更精确的条件 +- 使用`ALWAYS`策略作为兜底,确保总有规则匹配 + +--- + +### 5. Controller接口层 #### PuzzleGenerateController ```java @@ -337,9 +462,66 @@ POST /puzzle/generate --- +### 4. puzzle_fill_rule - 拼图填充规则表 + +| 字段 | 类型 | 说明 | +|-----|------|-----| +| id | BIGINT | 主键ID | +| template_id | BIGINT | 关联的模板ID(外键) | +| rule_name | VARCHAR(100) | 规则名称 | +| condition_type | VARCHAR(50) | 条件类型:DEVICE_COUNT/DEVICE_COUNT_RANGE/DEVICE_ID_MATCH/ALWAYS | +| condition_value | TEXT | 条件配置(JSON格式) | +| priority | INT | 优先级(数值越大越优先) | +| enabled | TINYINT | 是否启用:0-禁用 1-启用 | +| scenic_id | BIGINT | 景区ID(多租户隔离) | +| description | TEXT | 规则描述 | +| create_time | DATETIME | 创建时间 | +| update_time | DATETIME | 更新时间 | +| deleted | TINYINT | 删除标记:0-未删除 1-已删除 | +| deleted_at | DATETIME | 删除时间 | + +**索引**: +- KEY `idx_template_scenic` (template_id, scenic_id, deleted) +- KEY `idx_priority` (priority) + +**业务逻辑**: +- 规则按`priority`降序排列执行 +- 匹配到第一条符合条件的规则后停止 +- 建议使用`ALWAYS`类型作为兜底规则(最低优先级) + +--- + +### 5. puzzle_fill_rule_item - 拼图填充规则明细表 + +| 字段 | 类型 | 说明 | +|-----|------|-----| +| id | BIGINT | 主键ID | +| rule_id | BIGINT | 关联的规则ID(外键) | +| element_key | VARCHAR(50) | 目标元素标识(对应puzzle_element的element_key) | +| data_source | VARCHAR(50) | 数据源类型:DEVICE_IMAGE/USER_AVATAR/QR_CODE等 | +| source_filter | TEXT | 数据源过滤条件(JSON格式) | +| sort_strategy | VARCHAR(50) | 排序策略:LATEST/EARLIEST/SCORE_DESC/SCORE_ASC/RANDOM | +| fallback_value | VARCHAR(500) | 降级默认值(数据源无法获取时使用) | +| item_order | INT | 明细排序(决定执行顺序) | +| create_time | DATETIME | 创建时间 | +| update_time | DATETIME | 更新时间 | +| deleted | TINYINT | 删除标记:0-未删除 1-已删除 | +| deleted_at | DATETIME | 删除时间 | + +**索引**: +- KEY `idx_rule_id` (rule_id, deleted) +- KEY `idx_element_key` (element_key) + +**业务逻辑**: +- 明细项按`item_order`升序执行 +- 每条明细对应一个元素的填充逻辑 +- 支持降级策略(fallbackValue) + +--- + ## 🔄 关键业务流程 -### 拼图生成完整流程 +### 拼图生成完整流程(含自动填充) ``` 用户请求 → Controller接收 @@ -350,6 +532,16 @@ POST /puzzle/generate ↓ 根据templateId查询所有元素(按z-index排序) ↓ +【新增】调用PuzzleElementFillEngine.execute()(自动填充) + ├─ 查询该模板的所有填充规则(按优先级排序) + ├─ 构建ConditionContext(机位数量、机位列表等) + ├─ 遍历规则进行条件匹配 + ├─ 找到匹配规则后,加载其明细列表 + ├─ 对每条明细调用DataSourceResolver解析数据源 + └─ 返回Map + ↓ +合并自动填充数据和用户手动数据(用户数据优先级更高) + ↓ 调用PuzzleImageRenderer.render() ├─ 创建画布 ├─ 绘制背景 @@ -595,11 +787,16 @@ System.out.println("生成成功,图片URL: " + response.getImageUrl()); ## 🔗 相关文档 +### 官方文档 - [MyBatis-Plus官方文档](https://baomidou.com/) - [Hutool工具类文档](https://hutool.cn/) - [Java AWT图形绘制教程](https://docs.oracle.com/javase/tutorial/2d/) - [阿里云OSS Java SDK](https://help.aliyun.com/document_detail/32008.html) +### 项目内部文档 +- [拼图填充规则管理接口文档](../../docs/puzzle/拼图填充规则管理接口文档.md) - 前端管理页面API接口文档 +- [DeviceIdMatchConditionStrategy使用文档](../../docs/puzzle/DeviceIdMatchConditionStrategy使用文档.md) - 机位ID匹配策略详细说明 + --- ## 📞 联系方式 @@ -608,4 +805,4 @@ System.out.println("生成成功,图片URL: " + response.getImageUrl()); **维护者**:Claude **创建时间**:2025-01-17 -**最后更新**:2025-01-17 +**最后更新**:2025-01-19 diff --git a/src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java b/src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java index a2b6bc50..066c80dc 100644 --- a/src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java +++ b/src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java @@ -50,11 +50,12 @@ public class ImageConfig implements ElementConfig { } } - // 校验图片URL格式(可选) - if (StrUtil.isNotBlank(defaultImageUrl)) { - if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) { - throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl); - } + // 校验图片URL + if (StrUtil.isBlank(defaultImageUrl)) { + throw new IllegalArgumentException("默认图片URL不能为空"); + } + if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) { + throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl); } } diff --git a/src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java b/src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java index 41601fea..8be18d7d 100644 --- a/src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java +++ b/src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java @@ -1,7 +1,7 @@ package com.ycwl.basic.puzzle.element.impl; import cn.hutool.core.util.StrUtil; -import cn.hutool.http.HttpUtil; +import cn.hutool.http.HttpRequest; import com.ycwl.basic.puzzle.element.base.BaseElement; import com.ycwl.basic.puzzle.element.config.ImageConfig; import com.ycwl.basic.puzzle.element.exception.ElementValidationException; @@ -14,7 +14,12 @@ import java.awt.geom.Ellipse2D; import java.awt.geom.RoundRectangle2D; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; -import java.io.File; +import java.net.InetAddress; +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; /** * 图片元素实现 @@ -25,6 +30,8 @@ import java.io.File; @Slf4j public class ImageElement extends BaseElement { + private static final int DOWNLOAD_TIMEOUT_MS = 5000; + private ImageConfig imageConfig; @Override @@ -105,29 +112,32 @@ public class ImageElement extends BaseElement { * @param imageUrl 图片URL或本地文件路径 * @return BufferedImage对象 */ - private BufferedImage downloadImage(String imageUrl) { - try { - log.debug("下载图片: url={}", imageUrl); - - // 判断是否为本地文件路径 - if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) { - // 网络图片 - byte[] imageBytes = HttpUtil.downloadBytes(imageUrl); - return ImageIO.read(new ByteArrayInputStream(imageBytes)); - } else { - // 本地文件 - File file = new File(imageUrl); - if (file.exists()) { - return ImageIO.read(file); - } else { - log.error("本地图片文件不存在: path={}", imageUrl); - return null; - } - } - } catch (Exception e) { - log.error("图片下载失败: url={}", imageUrl, e); + protected BufferedImage downloadImage(String imageUrl) { + if (StrUtil.isBlank(imageUrl)) { return null; } + + if (isRemoteUrl(imageUrl)) { + if (!isSafeRemoteUrl(imageUrl)) { + log.warn("图片URL未通过安全校验, 已拒绝下载: {}", imageUrl); + return null; + } + + try { + log.debug("下载图片: url={}", imageUrl); + byte[] imageBytes = HttpRequest.get(imageUrl) + .timeout(DOWNLOAD_TIMEOUT_MS) + .setFollowRedirects(false) + .execute() + .bodyBytes(); + return ImageIO.read(new ByteArrayInputStream(imageBytes)); + } catch (Exception e) { + log.error("图片下载失败: url={}", imageUrl, e); + return null; + } + } + + return loadLocalImage(imageUrl); } /** @@ -251,4 +261,63 @@ public class ImageElement extends BaseElement { // 绘制到主画布 g2d.drawImage(rounded, position.getX(), position.getY(), null); } + + private boolean isRemoteUrl(String imageUrl) { + return StrUtil.startWithIgnoreCase(imageUrl, "http://") || + StrUtil.startWithIgnoreCase(imageUrl, "https://"); + } + + /** + * 判断URL是否为安全的公网HTTP地址,避免SSRF + */ + protected boolean isSafeRemoteUrl(String imageUrl) { + if (StrUtil.isBlank(imageUrl)) { + return false; + } + + try { + URL url = new URL(imageUrl); + String protocol = url.getProtocol(); + if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) { + return false; + } + + InetAddress address = InetAddress.getByName(url.getHost()); + if (address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress()) { + return false; + } + + return true; + } catch (Exception e) { + log.warn("图片URL解析失败: {}", imageUrl, e); + return false; + } + } + + private BufferedImage loadLocalImage(String imageUrl) { + try { + Path path; + if (StrUtil.startWithIgnoreCase(imageUrl, "file:")) { + path = Paths.get(new URI(imageUrl)); + } else { + path = Paths.get(imageUrl); + } + + if (!Files.exists(path) || !Files.isRegularFile(path)) { + log.error("本地图片文件不存在: {}", imageUrl); + return null; + } + + log.debug("加载本地图片: {}", path); + try (var inputStream = Files.newInputStream(path)) { + return ImageIO.read(inputStream); + } + } catch (Exception e) { + log.error("本地图片加载失败: {}", imageUrl, e); + return null; + } + } } diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngine.java b/src/main/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngine.java index 136a9fa3..54169a7a 100644 --- a/src/main/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngine.java +++ b/src/main/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngine.java @@ -55,6 +55,11 @@ public class PuzzleElementFillEngine { public Map execute(Long templateId, Long faceId, Long scenicId) { Map dynamicData = new HashMap<>(); + if (faceId == null || scenicId == null) { + log.debug("自动填充被跳过, templateId={}, faceId={}, scenicId={}", templateId, faceId, scenicId); + return dynamicData; + } + try { // 1. 查询模板的所有启用规则(按priority DESC排序) List rules = ruleMapper.listByTemplateAndScenic(templateId, scenicId); @@ -94,7 +99,7 @@ public class PuzzleElementFillEngine { if (items == null || items.isEmpty()) { log.warn("规则[{}]没有配置明细项", rule.getRuleName()); - break; + continue; } // 5. 批量填充dynamicData diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategy.java index c25090e9..9b671d7e 100644 --- a/src/main/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategy.java +++ b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategy.java @@ -1,13 +1,19 @@ package com.ycwl.basic.puzzle.fill.datasource; +import cn.hutool.core.util.StrUtil; import com.fasterxml.jackson.databind.JsonNode; import com.ycwl.basic.puzzle.fill.enums.DataSourceType; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + /** * 静态值数据源策略 - * 直接返回配置的静态值 + * 支持直接返回配置值或指向本地文件的路径 */ @Slf4j @Component @@ -15,14 +21,55 @@ public class StaticValueDataSourceStrategy implements DataSourceStrategy { @Override public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) { - if (sourceFilter != null && sourceFilter.has("value")) { + if (sourceFilter == null) { + return null; + } + + if (sourceFilter.hasNonNull("localPath")) { + String localPath = sourceFilter.get("localPath").asText(); + return resolveLocalPath(localPath); + } + + if (sourceFilter.hasNonNull("value")) { String value = sourceFilter.get("value").asText(); log.debug("解析STATIC_VALUE成功, value={}", value); return value; } + return null; } + private String resolveLocalPath(String rawPath) { + if (StrUtil.isBlank(rawPath)) { + log.warn("localPath为空, 无法解析静态值数据源"); + return null; + } + + try { + Path path; + if (StrUtil.startWithIgnoreCase(rawPath, "file:")) { + path = Paths.get(new URI(rawPath)); + } else { + path = Paths.get(rawPath); + } + + if (!path.isAbsolute()) { + path = path.toAbsolutePath(); + } + + if (!Files.exists(path) || !Files.isRegularFile(path)) { + log.warn("localPath不存在或不是文件: {}", path); + return null; + } + + log.debug("解析STATIC_VALUE本地路径成功: {}", path); + return path.toString(); + } catch (Exception e) { + log.error("解析本地路径失败: {}", rawPath, e); + return null; + } + } + @Override public String getSupportedType() { return DataSourceType.STATIC_VALUE.getCode(); 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 fd5a4515..5ae78aca 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 @@ -62,20 +62,23 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService { throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode()); } + // 2. 校验景区隔离 + Long resolvedScenicId = resolveScenicId(template, request.getScenicId()); + List elements = elementMapper.getByTemplateId(template.getId()); if (elements.isEmpty()) { throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode()); } - // 2. 按z-index排序元素 + // 3. 按z-index排序元素 elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex, Comparator.nullsFirst(Comparator.naturalOrder()))); - // 3. 准备dynamicData(合并自动填充和手动数据) - Map finalDynamicData = buildDynamicData(template, request); + // 4. 准备dynamicData(合并自动填充和手动数据) + Map finalDynamicData = buildDynamicData(template, request, resolvedScenicId); - // 4. 创建生成记录 - PuzzleGenerationRecordEntity record = createRecord(template, request); + // 5. 创建生成记录 + PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId); recordMapper.insert(record); try { @@ -121,14 +124,16 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService { /** * 创建生成记录 */ - private PuzzleGenerationRecordEntity createRecord(PuzzleTemplateEntity template, PuzzleGenerateRequest request) { + private PuzzleGenerationRecordEntity createRecord(PuzzleTemplateEntity template, + PuzzleGenerateRequest request, + Long scenicId) { PuzzleGenerationRecordEntity record = new PuzzleGenerationRecordEntity(); record.setTemplateId(template.getId()); record.setTemplateCode(template.getCode()); record.setUserId(request.getUserId()); record.setOrderId(request.getOrderId()); record.setBusinessType(request.getBusinessType()); - record.setScenicId(request.getScenicId()); + record.setScenicId(scenicId); record.setStatus(0); // 生成中 record.setRetryCount(0); @@ -191,16 +196,18 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService { * 构建dynamicData(合并自动填充和手动数据) * 优先级: 手动传入的数据 > 自动填充的数据 */ - private Map buildDynamicData(PuzzleTemplateEntity template, PuzzleGenerateRequest request) { + private Map buildDynamicData(PuzzleTemplateEntity template, + PuzzleGenerateRequest request, + Long scenicId) { Map dynamicData = new HashMap<>(); // 1. 自动填充(基于faceId和规则) - if (request.getFaceId() != null && request.getScenicId() != null) { + if (request.getFaceId() != null && scenicId != null) { try { Map autoFilled = fillEngine.execute( template.getId(), request.getFaceId(), - request.getScenicId() + scenicId ); if (autoFilled != null && !autoFilled.isEmpty()) { dynamicData.putAll(autoFilled); @@ -210,6 +217,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService { log.error("自动填充异常, templateId={}, faceId={}", template.getId(), request.getFaceId(), e); // 自动填充失败不影响整体流程,继续执行 } + } else if (request.getFaceId() != null) { + log.warn("自动填充被跳过: 缺少scenicId或模板未绑定景区, templateId={}, faceId={}", + template.getId(), request.getFaceId()); } // 2. 手动数据覆盖(优先级更高) @@ -221,4 +231,28 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService { log.info("最终dynamicData: {}", dynamicData.keySet()); return dynamicData; } + + /** + * 校验模板与请求景区的合法性 + * + * @param template 模板 + * @param requestedScenic 请求中的景区ID + * @return 最终生效的景区ID + */ + private Long resolveScenicId(PuzzleTemplateEntity template, Long requestedScenic) { + Long templateScenicId = template.getScenicId(); + if (templateScenicId == null) { + return requestedScenic; + } + + if (requestedScenic == null) { + throw new IllegalArgumentException("模板绑定景区, scenicId为必填项"); + } + + if (!templateScenicId.equals(requestedScenic)) { + throw new IllegalArgumentException("模板不属于当前景区, 请检查templateCode与scenicId"); + } + + return templateScenicId; + } } diff --git a/src/main/java/com/ycwl/basic/puzzle/util/ElementConfigHelper.java b/src/main/java/com/ycwl/basic/puzzle/util/ElementConfigHelper.java index 9d11642c..15f0f12f 100644 --- a/src/main/java/com/ycwl/basic/puzzle/util/ElementConfigHelper.java +++ b/src/main/java/com/ycwl/basic/puzzle/util/ElementConfigHelper.java @@ -2,6 +2,7 @@ package com.ycwl.basic.puzzle.util; import cn.hutool.core.util.StrUtil; import com.ycwl.basic.puzzle.dto.ElementCreateRequest; +import com.ycwl.basic.puzzle.element.enums.ElementType; import com.ycwl.basic.puzzle.entity.PuzzleElementEntity; import com.ycwl.basic.utils.JacksonUtil; import lombok.extern.slf4j.Slf4j; @@ -108,13 +109,12 @@ public class ElementConfigHelper { return false; } - // 当前支持的类型 - return "TEXT".equalsIgnoreCase(elementType) || - "IMAGE".equalsIgnoreCase(elementType) || - "QRCODE".equalsIgnoreCase(elementType) || - "GRADIENT".equalsIgnoreCase(elementType) || - "SHAPE".equalsIgnoreCase(elementType) || - "DYNAMIC_IMAGE".equalsIgnoreCase(elementType); + try { + ElementType type = ElementType.fromCode(elementType); + return type.isImplemented(); + } catch (IllegalArgumentException ex) { + return false; + } } /** diff --git a/src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java b/src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java index cf346a3c..2390b359 100644 --- a/src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java +++ b/src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java @@ -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); + } + } } diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java index 623d5d7d..3208d915 100644 --- a/src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java +++ b/src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java @@ -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 result = engine.execute(1L, null, 10L); + assertTrue(result.isEmpty()); + verifyNoInteractions(ruleMapper, itemMapper, sourceMapper, conditionEvaluator, dataSourceResolver); + + Map 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 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() { diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategyTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategyTest.java index acb2aa14..29438ab2 100644 --- a/src/test/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategyTest.java +++ b/src/test/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategyTest.java @@ -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(); diff --git a/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImplTest.java b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImplTest.java new file mode 100644 index 00000000..9017478b --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImplTest.java @@ -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 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 map = (Map) 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"); + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/util/ElementConfigHelperTest.java b/src/test/java/com/ycwl/basic/puzzle/util/ElementConfigHelperTest.java new file mode 100644 index 00000000..a5e925e6 --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/util/ElementConfigHelperTest.java @@ -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("不支持的元素类型")); + } +}