You've already forked FrameTour-BE
feat(puzzle): 实现智能自动填充引擎和安全增强
- 新增拼图元素自动填充引擎 PuzzleElementFillEngine - 支持基于规则的条件匹配和数据源解析 - 实现机位数量、机位ID等多维度条件策略 - 添加 DEVICE_IMAGE、USER_AVATAR 等数据源类型支持 - 增加景区隔离校验确保模板使用安全性 - 强化图片下载安全校验,防范 SSRF 攻击 - 支持本地文件路径解析和公网 URL 安全检查 - 完善静态值数据源策略支持 localPath 配置 - 优化生成流程中 faceId 和 scenicId 的校验逻辑 - 补充相关单元测试覆盖核心功能点
This commit is contained in:
@@ -9,6 +9,7 @@ Puzzle拼图模块是一个基于模板和元素的动态图片生成系统,
|
|||||||
- 多层次元素渲染:支持图片和文字元素的分层叠加
|
- 多层次元素渲染:支持图片和文字元素的分层叠加
|
||||||
- 灵活的样式配置:支持位置、大小、透明度、旋转、圆角等属性
|
- 灵活的样式配置:支持位置、大小、透明度、旋转、圆角等属性
|
||||||
- 动态数据注入:通过elementKey进行动态数据替换
|
- 动态数据注入:通过elementKey进行动态数据替换
|
||||||
|
- 智能自动填充:基于规则引擎自动选择和填充素材数据
|
||||||
- 生成记录追踪:完整记录每次生成的参数和结果
|
- 生成记录追踪:完整记录每次生成的参数和结果
|
||||||
|
|
||||||
**典型应用场景:**
|
**典型应用场景:**
|
||||||
@@ -27,28 +28,58 @@ Puzzle拼图模块是一个基于模板和元素的动态图片生成系统,
|
|||||||
puzzle/
|
puzzle/
|
||||||
├── controller/ # API接口层
|
├── controller/ # API接口层
|
||||||
│ ├── PuzzleGenerateController.java # 拼图生成接口
|
│ ├── PuzzleGenerateController.java # 拼图生成接口
|
||||||
│ └── PuzzleTemplateController.java # 模板管理接口
|
│ ├── PuzzleTemplateController.java # 模板管理接口
|
||||||
|
│ └── PuzzleFillRuleController.java # 填充规则管理接口
|
||||||
├── service/ # 业务逻辑层
|
├── service/ # 业务逻辑层
|
||||||
│ ├── IPuzzleGenerateService.java
|
│ ├── IPuzzleGenerateService.java
|
||||||
│ ├── IPuzzleTemplateService.java
|
│ ├── IPuzzleTemplateService.java
|
||||||
|
│ ├── IPuzzleFillRuleService.java
|
||||||
│ └── impl/
|
│ └── impl/
|
||||||
│ ├── PuzzleGenerateServiceImpl.java
|
│ ├── PuzzleGenerateServiceImpl.java
|
||||||
│ └── PuzzleTemplateServiceImpl.java
|
│ ├── PuzzleTemplateServiceImpl.java
|
||||||
|
│ └── PuzzleFillRuleServiceImpl.java
|
||||||
├── mapper/ # 数据访问层
|
├── mapper/ # 数据访问层
|
||||||
│ ├── PuzzleTemplateMapper.java
|
│ ├── PuzzleTemplateMapper.java
|
||||||
│ ├── PuzzleElementMapper.java
|
│ ├── PuzzleElementMapper.java
|
||||||
│ └── PuzzleGenerationRecordMapper.java
|
│ ├── PuzzleGenerationRecordMapper.java
|
||||||
|
│ ├── PuzzleFillRuleMapper.java
|
||||||
|
│ └── PuzzleFillRuleItemMapper.java
|
||||||
|
│ # 拼图引擎会复用基础域的 com.ycwl.basic.mapper.SourceMapper(不在 puzzle 包内)
|
||||||
├── entity/ # 实体类
|
├── entity/ # 实体类
|
||||||
│ ├── PuzzleTemplateEntity.java # 模板实体
|
│ ├── PuzzleTemplateEntity.java # 模板实体
|
||||||
│ ├── PuzzleElementEntity.java # 元素实体
|
│ ├── PuzzleElementEntity.java # 元素实体
|
||||||
│ └── PuzzleGenerationRecordEntity.java # 生成记录实体
|
│ ├── PuzzleGenerationRecordEntity.java # 生成记录实体
|
||||||
|
│ ├── PuzzleFillRuleEntity.java # 填充规则实体
|
||||||
|
│ └── PuzzleFillRuleItemEntity.java # 填充规则明细实体
|
||||||
├── dto/ # 数据传输对象
|
├── dto/ # 数据传输对象
|
||||||
│ ├── PuzzleGenerateRequest.java # 生成请求
|
│ ├── PuzzleGenerateRequest.java # 生成请求
|
||||||
│ ├── PuzzleGenerateResponse.java # 生成响应
|
│ ├── PuzzleGenerateResponse.java # 生成响应
|
||||||
│ ├── PuzzleTemplateDTO.java # 模板DTO
|
│ ├── PuzzleTemplateDTO.java # 模板DTO
|
||||||
│ ├── PuzzleElementDTO.java # 元素DTO
|
│ ├── PuzzleElementDTO.java # 元素DTO
|
||||||
│ ├── TemplateCreateRequest.java # 模板创建请求
|
│ ├── 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/ # 工具类
|
└── util/ # 工具类
|
||||||
└── PuzzleImageRenderer.java # 图片渲染引擎(核心)
|
└── PuzzleImageRenderer.java # 图片渲染引擎(核心)
|
||||||
```
|
```
|
||||||
@@ -57,8 +88,16 @@ puzzle/
|
|||||||
|
|
||||||
1. **服务层模式(Service Layer)**:业务逻辑封装在service层,controller只负责接口适配
|
1. **服务层模式(Service Layer)**:业务逻辑封装在service层,controller只负责接口适配
|
||||||
2. **DTO模式**:使用独立的DTO对象处理API输入输出,与Entity分离
|
2. **DTO模式**:使用独立的DTO对象处理API输入输出,与Entity分离
|
||||||
3. **策略模式**:图片适配模式(CONTAIN、COVER、FILL等)
|
3. **策略模式**:图片适配模式(CONTAIN、COVER、FILL等)、条件匹配策略、数据源解析策略、排序策略
|
||||||
4. **建造者模式**:通过模板+元素配置构建最终图片
|
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<String, String> 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<elementKey, dataValue>`
|
||||||
|
|
||||||
|
**条件策略(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
|
#### PuzzleGenerateController
|
||||||
```java
|
```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接收
|
用户请求 → Controller接收
|
||||||
@@ -350,6 +532,16 @@ POST /puzzle/generate
|
|||||||
↓
|
↓
|
||||||
根据templateId查询所有元素(按z-index排序)
|
根据templateId查询所有元素(按z-index排序)
|
||||||
↓
|
↓
|
||||||
|
【新增】调用PuzzleElementFillEngine.execute()(自动填充)
|
||||||
|
├─ 查询该模板的所有填充规则(按优先级排序)
|
||||||
|
├─ 构建ConditionContext(机位数量、机位列表等)
|
||||||
|
├─ 遍历规则进行条件匹配
|
||||||
|
├─ 找到匹配规则后,加载其明细列表
|
||||||
|
├─ 对每条明细调用DataSourceResolver解析数据源
|
||||||
|
└─ 返回Map<elementKey, dataValue>
|
||||||
|
↓
|
||||||
|
合并自动填充数据和用户手动数据(用户数据优先级更高)
|
||||||
|
↓
|
||||||
调用PuzzleImageRenderer.render()
|
调用PuzzleImageRenderer.render()
|
||||||
├─ 创建画布
|
├─ 创建画布
|
||||||
├─ 绘制背景
|
├─ 绘制背景
|
||||||
@@ -595,11 +787,16 @@ System.out.println("生成成功,图片URL: " + response.getImageUrl());
|
|||||||
|
|
||||||
## 🔗 相关文档
|
## 🔗 相关文档
|
||||||
|
|
||||||
|
### 官方文档
|
||||||
- [MyBatis-Plus官方文档](https://baomidou.com/)
|
- [MyBatis-Plus官方文档](https://baomidou.com/)
|
||||||
- [Hutool工具类文档](https://hutool.cn/)
|
- [Hutool工具类文档](https://hutool.cn/)
|
||||||
- [Java AWT图形绘制教程](https://docs.oracle.com/javase/tutorial/2d/)
|
- [Java AWT图形绘制教程](https://docs.oracle.com/javase/tutorial/2d/)
|
||||||
- [阿里云OSS Java SDK](https://help.aliyun.com/document_detail/32008.html)
|
- [阿里云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
|
**维护者**:Claude
|
||||||
**创建时间**:2025-01-17
|
**创建时间**:2025-01-17
|
||||||
**最后更新**:2025-01-17
|
**最后更新**:2025-01-19
|
||||||
|
|||||||
@@ -50,13 +50,14 @@ public class ImageConfig implements ElementConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验图片URL格式(可选)
|
// 校验图片URL
|
||||||
if (StrUtil.isNotBlank(defaultImageUrl)) {
|
if (StrUtil.isBlank(defaultImageUrl)) {
|
||||||
|
throw new IllegalArgumentException("默认图片URL不能为空");
|
||||||
|
}
|
||||||
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
||||||
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getConfigSchema() {
|
public String getConfigSchema() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.ycwl.basic.puzzle.element.impl;
|
package com.ycwl.basic.puzzle.element.impl;
|
||||||
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
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.base.BaseElement;
|
||||||
import com.ycwl.basic.puzzle.element.config.ImageConfig;
|
import com.ycwl.basic.puzzle.element.config.ImageConfig;
|
||||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
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.geom.RoundRectangle2D;
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.ByteArrayInputStream;
|
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
|
@Slf4j
|
||||||
public class ImageElement extends BaseElement {
|
public class ImageElement extends BaseElement {
|
||||||
|
|
||||||
|
private static final int DOWNLOAD_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
private ImageConfig imageConfig;
|
private ImageConfig imageConfig;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -105,31 +112,34 @@ public class ImageElement extends BaseElement {
|
|||||||
* @param imageUrl 图片URL或本地文件路径
|
* @param imageUrl 图片URL或本地文件路径
|
||||||
* @return BufferedImage对象
|
* @return BufferedImage对象
|
||||||
*/
|
*/
|
||||||
private BufferedImage downloadImage(String imageUrl) {
|
protected BufferedImage downloadImage(String imageUrl) {
|
||||||
try {
|
if (StrUtil.isBlank(imageUrl)) {
|
||||||
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;
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("图片下载失败: url={}", imageUrl, e);
|
log.error("图片下载失败: url={}", imageUrl, e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return loadLocalImage(imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 缩放图片(根据适配模式)
|
* 缩放图片(根据适配模式)
|
||||||
*
|
*
|
||||||
@@ -251,4 +261,63 @@ public class ImageElement extends BaseElement {
|
|||||||
// 绘制到主画布
|
// 绘制到主画布
|
||||||
g2d.drawImage(rounded, position.getX(), position.getY(), null);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ public class PuzzleElementFillEngine {
|
|||||||
public Map<String, String> execute(Long templateId, Long faceId, Long scenicId) {
|
public Map<String, String> execute(Long templateId, Long faceId, Long scenicId) {
|
||||||
Map<String, String> dynamicData = new HashMap<>();
|
Map<String, String> dynamicData = new HashMap<>();
|
||||||
|
|
||||||
|
if (faceId == null || scenicId == null) {
|
||||||
|
log.debug("自动填充被跳过, templateId={}, faceId={}, scenicId={}", templateId, faceId, scenicId);
|
||||||
|
return dynamicData;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 查询模板的所有启用规则(按priority DESC排序)
|
// 1. 查询模板的所有启用规则(按priority DESC排序)
|
||||||
List<PuzzleFillRuleEntity> rules = ruleMapper.listByTemplateAndScenic(templateId, scenicId);
|
List<PuzzleFillRuleEntity> rules = ruleMapper.listByTemplateAndScenic(templateId, scenicId);
|
||||||
@@ -94,7 +99,7 @@ public class PuzzleElementFillEngine {
|
|||||||
|
|
||||||
if (items == null || items.isEmpty()) {
|
if (items == null || items.isEmpty()) {
|
||||||
log.warn("规则[{}]没有配置明细项", rule.getRuleName());
|
log.warn("规则[{}]没有配置明细项", rule.getRuleName());
|
||||||
break;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 批量填充dynamicData
|
// 5. 批量填充dynamicData
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package com.ycwl.basic.puzzle.fill.datasource;
|
package com.ycwl.basic.puzzle.fill.datasource;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.ycwl.basic.puzzle.fill.enums.DataSourceType;
|
import com.ycwl.basic.puzzle.fill.enums.DataSourceType;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
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
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@@ -15,14 +21,55 @@ public class StaticValueDataSourceStrategy implements DataSourceStrategy {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) {
|
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();
|
String value = sourceFilter.get("value").asText();
|
||||||
log.debug("解析STATIC_VALUE成功, value={}", value);
|
log.debug("解析STATIC_VALUE成功, value={}", value);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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
|
@Override
|
||||||
public String getSupportedType() {
|
public String getSupportedType() {
|
||||||
return DataSourceType.STATIC_VALUE.getCode();
|
return DataSourceType.STATIC_VALUE.getCode();
|
||||||
|
|||||||
@@ -62,20 +62,23 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
|
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 校验景区隔离
|
||||||
|
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
|
||||||
|
|
||||||
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
|
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
|
||||||
if (elements.isEmpty()) {
|
if (elements.isEmpty()) {
|
||||||
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
|
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 按z-index排序元素
|
// 3. 按z-index排序元素
|
||||||
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
|
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
|
||||||
Comparator.nullsFirst(Comparator.naturalOrder())));
|
Comparator.nullsFirst(Comparator.naturalOrder())));
|
||||||
|
|
||||||
// 3. 准备dynamicData(合并自动填充和手动数据)
|
// 4. 准备dynamicData(合并自动填充和手动数据)
|
||||||
Map<String, String> finalDynamicData = buildDynamicData(template, request);
|
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId);
|
||||||
|
|
||||||
// 4. 创建生成记录
|
// 5. 创建生成记录
|
||||||
PuzzleGenerationRecordEntity record = createRecord(template, request);
|
PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId);
|
||||||
recordMapper.insert(record);
|
recordMapper.insert(record);
|
||||||
|
|
||||||
try {
|
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();
|
PuzzleGenerationRecordEntity record = new PuzzleGenerationRecordEntity();
|
||||||
record.setTemplateId(template.getId());
|
record.setTemplateId(template.getId());
|
||||||
record.setTemplateCode(template.getCode());
|
record.setTemplateCode(template.getCode());
|
||||||
record.setUserId(request.getUserId());
|
record.setUserId(request.getUserId());
|
||||||
record.setOrderId(request.getOrderId());
|
record.setOrderId(request.getOrderId());
|
||||||
record.setBusinessType(request.getBusinessType());
|
record.setBusinessType(request.getBusinessType());
|
||||||
record.setScenicId(request.getScenicId());
|
record.setScenicId(scenicId);
|
||||||
record.setStatus(0); // 生成中
|
record.setStatus(0); // 生成中
|
||||||
record.setRetryCount(0);
|
record.setRetryCount(0);
|
||||||
|
|
||||||
@@ -191,16 +196,18 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
* 构建dynamicData(合并自动填充和手动数据)
|
* 构建dynamicData(合并自动填充和手动数据)
|
||||||
* 优先级: 手动传入的数据 > 自动填充的数据
|
* 优先级: 手动传入的数据 > 自动填充的数据
|
||||||
*/
|
*/
|
||||||
private Map<String, String> buildDynamicData(PuzzleTemplateEntity template, PuzzleGenerateRequest request) {
|
private Map<String, String> buildDynamicData(PuzzleTemplateEntity template,
|
||||||
|
PuzzleGenerateRequest request,
|
||||||
|
Long scenicId) {
|
||||||
Map<String, String> dynamicData = new HashMap<>();
|
Map<String, String> dynamicData = new HashMap<>();
|
||||||
|
|
||||||
// 1. 自动填充(基于faceId和规则)
|
// 1. 自动填充(基于faceId和规则)
|
||||||
if (request.getFaceId() != null && request.getScenicId() != null) {
|
if (request.getFaceId() != null && scenicId != null) {
|
||||||
try {
|
try {
|
||||||
Map<String, String> autoFilled = fillEngine.execute(
|
Map<String, String> autoFilled = fillEngine.execute(
|
||||||
template.getId(),
|
template.getId(),
|
||||||
request.getFaceId(),
|
request.getFaceId(),
|
||||||
request.getScenicId()
|
scenicId
|
||||||
);
|
);
|
||||||
if (autoFilled != null && !autoFilled.isEmpty()) {
|
if (autoFilled != null && !autoFilled.isEmpty()) {
|
||||||
dynamicData.putAll(autoFilled);
|
dynamicData.putAll(autoFilled);
|
||||||
@@ -210,6 +217,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
log.error("自动填充异常, templateId={}, faceId={}", template.getId(), request.getFaceId(), e);
|
log.error("自动填充异常, templateId={}, faceId={}", template.getId(), request.getFaceId(), e);
|
||||||
// 自动填充失败不影响整体流程,继续执行
|
// 自动填充失败不影响整体流程,继续执行
|
||||||
}
|
}
|
||||||
|
} else if (request.getFaceId() != null) {
|
||||||
|
log.warn("自动填充被跳过: 缺少scenicId或模板未绑定景区, templateId={}, faceId={}",
|
||||||
|
template.getId(), request.getFaceId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 手动数据覆盖(优先级更高)
|
// 2. 手动数据覆盖(优先级更高)
|
||||||
@@ -221,4 +231,28 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
log.info("最终dynamicData: {}", dynamicData.keySet());
|
log.info("最终dynamicData: {}", dynamicData.keySet());
|
||||||
return dynamicData;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ycwl.basic.puzzle.util;
|
|||||||
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
|
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.puzzle.entity.PuzzleElementEntity;
|
||||||
import com.ycwl.basic.utils.JacksonUtil;
|
import com.ycwl.basic.utils.JacksonUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -108,13 +109,12 @@ public class ElementConfigHelper {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前支持的类型
|
try {
|
||||||
return "TEXT".equalsIgnoreCase(elementType) ||
|
ElementType type = ElementType.fromCode(elementType);
|
||||||
"IMAGE".equalsIgnoreCase(elementType) ||
|
return type.isImplemented();
|
||||||
"QRCODE".equalsIgnoreCase(elementType) ||
|
} catch (IllegalArgumentException ex) {
|
||||||
"GRADIENT".equalsIgnoreCase(elementType) ||
|
return false;
|
||||||
"SHAPE".equalsIgnoreCase(elementType) ||
|
}
|
||||||
"DYNAMIC_IMAGE".equalsIgnoreCase(elementType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.BaseElement;
|
||||||
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
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.exception.ElementValidationException;
|
||||||
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
import java.awt.image.BufferedImage;
|
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.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +36,23 @@ class ImageElementTest {
|
|||||||
private Graphics2D graphics;
|
private Graphics2D graphics;
|
||||||
private RenderContext context;
|
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
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
BufferedImage canvas = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB);
|
BufferedImage canvas = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB);
|
||||||
@@ -121,4 +147,31 @@ class ImageElementTest {
|
|||||||
assertTrue(schema.contains("imageFitMode"));
|
assertTrue(schema.contains("imageFitMode"));
|
||||||
assertTrue(schema.contains("borderRadius"));
|
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.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.junit.jupiter.MockitoSettings;
|
||||||
|
import org.mockito.quality.Strictness;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -30,6 +32,7 @@ import static org.mockito.Mockito.*;
|
|||||||
*/
|
*/
|
||||||
@DisplayName("拼图元素填充引擎测试")
|
@DisplayName("拼图元素填充引擎测试")
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
class PuzzleElementFillEngineTest {
|
class PuzzleElementFillEngineTest {
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
@@ -127,6 +130,51 @@ class PuzzleElementFillEngineTest {
|
|||||||
verify(dataSourceResolver, times(4)).resolve(anyString(), anyString(), anyString(), anyString(), any());
|
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
|
@Test
|
||||||
@DisplayName("应该按优先级顺序评估规则并在匹配第一条后停止")
|
@DisplayName("应该按优先级顺序评估规则并在匹配第一条后停止")
|
||||||
void shouldEvaluateRulesByPriorityAndStopAfterFirstMatch() {
|
void shouldEvaluateRulesByPriorityAndStopAfterFirstMatch() {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +108,32 @@ class StaticValueDataSourceStrategyTest {
|
|||||||
testStaticValue("测试中文/Special!@#$%");
|
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 {
|
private void testStaticValue(String value) throws Exception {
|
||||||
JsonNode sourceFilter = objectMapper.readTree("{\"value\": \"" + value + "\"}");
|
JsonNode sourceFilter = objectMapper.readTree("{\"value\": \"" + value + "\"}");
|
||||||
DataSourceContext context = DataSourceContext.builder().build();
|
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