refactor(puzzle): 重构元素DTO及新增元素基类

- 将ElementCreateRequest和PuzzleElementDTO中的elementType从Integer改为String
- 删除所有类型特定字段,新增config和configMap支持JSON配置
- 新增BaseElement抽象基类定义元素通用行为
- 添加ElementConfig接口和具体实现类ImageConfig、TextConfig
- 创建ElementFactory工厂类和ElementRegistrar注册器
- 新增ElementType枚举和ElementValidationException异常类
- 实现ImageElement和TextElement具体元素类
- 添加Position位置信息封装类
This commit is contained in:
2025-11-18 08:13:38 +08:00
parent 5c49a5af9e
commit 3d361200b0
28 changed files with 2988 additions and 615 deletions

View File

@@ -319,6 +319,9 @@
<testExcludes> <testExcludes>
<testExclude>**/*Test.java</testExclude> <testExclude>**/*Test.java</testExclude>
</testExcludes> </testExcludes>
<source>21</source>
<target>21</target>
<compilerArgs>--enable-preview</compilerArgs>
</configuration> </configuration>
</plugin> </plugin>
</plugins> </plugins>

View File

@@ -0,0 +1,611 @@
# Puzzle 拼图模块技术文档
## 📋 模块概述
Puzzle拼图模块是一个基于模板和元素的动态图片生成系统,支持按照预定义的模板配置,将动态数据渲染成最终的图片输出。常用于订单凭证、门票、证书等场景的图片生成。
**核心能力:**
- 模板化图片生成:通过模板+元素+动态数据生成定制化图片
- 多层次元素渲染:支持图片和文字元素的分层叠加
- 灵活的样式配置:支持位置、大小、透明度、旋转、圆角等属性
- 动态数据注入:通过elementKey进行动态数据替换
- 生成记录追踪:完整记录每次生成的参数和结果
**典型应用场景:**
- 订单凭证图片生成(用户头像+订单信息)
- 电子门票生成(二维码+用户信息)
- 电子证书生成(用户信息+证书模板)
- 营销海报生成(动态用户数据)
---
## 🏗️ 架构设计
### 分层结构
```
puzzle/
├── controller/ # API接口层
│ ├── PuzzleGenerateController.java # 拼图生成接口
│ └── PuzzleTemplateController.java # 模板管理接口
├── service/ # 业务逻辑层
│ ├── IPuzzleGenerateService.java
│ ├── IPuzzleTemplateService.java
│ └── impl/
│ ├── PuzzleGenerateServiceImpl.java
│ └── PuzzleTemplateServiceImpl.java
├── mapper/ # 数据访问层
│ ├── PuzzleTemplateMapper.java
│ ├── PuzzleElementMapper.java
│ └── PuzzleGenerationRecordMapper.java
├── entity/ # 实体类
│ ├── PuzzleTemplateEntity.java # 模板实体
│ ├── PuzzleElementEntity.java # 元素实体
│ └── PuzzleGenerationRecordEntity.java # 生成记录实体
├── dto/ # 数据传输对象
│ ├── PuzzleGenerateRequest.java # 生成请求
│ ├── PuzzleGenerateResponse.java # 生成响应
│ ├── PuzzleTemplateDTO.java # 模板DTO
│ ├── PuzzleElementDTO.java # 元素DTO
│ ├── TemplateCreateRequest.java # 模板创建请求
│ └── ElementCreateRequest.java # 元素创建请求
└── util/ # 工具类
└── PuzzleImageRenderer.java # 图片渲染引擎(核心)
```
### 设计模式
1. **服务层模式(Service Layer)**:业务逻辑封装在service层,controller只负责接口适配
2. **DTO模式**:使用独立的DTO对象处理API输入输出,与Entity分离
3. **策略模式**:图片适配模式(CONTAIN、COVER、FILL等)
4. **建造者模式**:通过模板+元素配置构建最终图片
---
## 🔧 核心组件详解
### 1. PuzzleImageRenderer - 图片渲染引擎
**职责**:核心渲染引擎,负责将模板配置和元素数据渲染成最终图片
**关键方法**
- `render(PuzzleTemplateEntity, List<PuzzleElementEntity>, Map<String, String>)`:主渲染方法
- 创建画布(根据模板宽高)
- 绘制背景(纯色或图片背景)
- 按z-index顺序绘制元素
- 返回BufferedImage对象
**渲染流程**
1. 创建画布:根据模板的canvasWidth和canvasHeight创建BufferedImage
2. 绘制背景:
- backgroundType=0:绘制纯色背景(backgroundColor)
- backgroundType=1:加载并绘制背景图片(backgroundImage)
3. 按z-index排序元素列表(升序,确保层级正确)
4. 逐个绘制元素:
- elementType=1(图片元素):
- 获取动态数据(dynamicData.get(elementKey))或使用defaultImageUrl
- 下载图片
- 根据imageFitMode缩放图片(CONTAIN/COVER/FILL/SCALE_DOWN)
- 应用borderRadius(圆角)
- 应用opacity(透明度)
- 应用rotation(旋转)
- 绘制到画布指定位置(xPosition, yPosition, width, height)
- elementType=2(文字元素):
- 获取动态数据或使用defaultText
- 设置字体(fontFamily, fontSize, fontWeight, fontStyle)
- 设置颜色(fontColor)
- 应用textAlign(对齐方式)
- 应用lineHeight(行高)
- 处理maxLines(最大行数截断)
- 应用textDecoration(下划线/删除线)
- 应用opacity和rotation
- 绘制到画布
**技术要点**
- 使用Java AWT进行图形绘制
- 使用Hutool工具库处理图片下载和基础操作
- 支持图片圆角(通过Ellipse2D.Float或RoundRectangle2D.Float实现clip)
- 支持透明度(通过AlphaComposite实现)
- 支持旋转(通过Graphics2D.rotate)
---
### 2. PuzzleGenerateServiceImpl - 拼图生成服务
**职责**:协调拼图生成的完整流程
**核心方法**
```java
PuzzleGenerateResponse generate(PuzzleGenerateRequest request)
```
**生成流程**
1. **参数校验**
- 校验templateCode是否提供
- 检查dynamicData是否为空
2. **加载模板**
- 根据templateCode查询模板(PuzzleTemplateMapper)
- 检查模板是否存在且启用(status=1)
- 检查多租户权限(scenicId匹配)
3. **加载元素**
- 根据templateId查询所有元素(PuzzleElementMapper)
- 按z-index升序排序
- 过滤未删除的元素(deleted=0)
4. **调用渲染引擎**
- 调用`PuzzleImageRenderer.render()`
- 传入模板、元素列表、动态数据
5. **上传图片**
- 将BufferedImage转换为字节流
- 估算文件大小
- 上传到对象存储(OSS)
- 获取图片URL
6. **创建生成记录**
- 保存到puzzle_generation_record表
- 记录参数、结果、耗时等信息
7. **返回响应**
- 返回图片URL、宽高、文件大小等信息
**辅助方法**
- `createRecord()`:创建生成记录
- `uploadImage()`:上传图片到OSS
- `estimateFileSize()`:估算文件大小
---
### 3. PuzzleTemplateServiceImpl - 模板管理服务
**职责**:管理拼图模板和元素的CRUD操作
**模板管理方法**
- `createTemplate(TemplateCreateRequest)`:创建模板
- `updateTemplate(Long, TemplateCreateRequest)`:更新模板
- `deleteTemplate(Long)`:逻辑删除模板(软删除)
- `getTemplateDetail(Long)`:查询模板详情(包含元素列表)
- `getTemplateByCode(String)`:根据code查询模板
- `listTemplates(Long, String, Integer)`:分页查询模板列表
**元素管理方法**
- `addElement(ElementCreateRequest)`:添加单个元素
- `batchAddElements(Long, List<ElementCreateRequest>)`:批量添加元素
- `updateElement(Long, ElementCreateRequest)`:更新元素
- `deleteElement(Long)`:逻辑删除元素
- `getElementDetail(Long)`:查询元素详情
**业务逻辑要点**
- 删除模板时会级联删除关联的所有元素
- 支持多租户隔离(根据scenicId)
- 支持按category分类查询
- 支持按status过滤启用/禁用的模板
---
### 4. Controller接口层
#### PuzzleGenerateController
```java
POST /puzzle/generate
```
**功能**:生成拼图图片
**请求体**`PuzzleGenerateRequest`
```json
{
"templateCode": "order_certificate",
"userId": 123,
"orderId": "ORDER20250117001",
"businessType": "order",
"scenicId": 1,
"dynamicData": {
"userAvatar": "https://example.com/avatar.jpg",
"userName": "张三",
"orderNumber": "ORDER20250117001",
"qrCode": "https://example.com/qr.png"
},
"outputFormat": "PNG",
"quality": 90
}
```
**响应**`AjaxResult<PuzzleGenerateResponse>`
```json
{
"code": 200,
"msg": "生成成功",
"data": {
"imageUrl": "https://oss.example.com/puzzle/xxx.png",
"width": 750,
"height": 1334,
"fileSize": 245678,
"recordId": 12345
}
}
```
#### PuzzleTemplateController
提供模板和元素的完整CRUD接口(详见Controller类)
---
## 📊 数据模型
### 1. puzzle_template - 拼图模板表
| 字段 | 类型 | 说明 |
|-----|------|-----|
| id | BIGINT | 主键ID |
| name | VARCHAR(100) | 模板名称 |
| code | VARCHAR(50) | 模板编码(唯一,用于API调用) |
| canvas_width | INT | 画布宽度(像素) |
| canvas_height | INT | 画布高度(像素) |
| background_type | TINYINT | 背景类型:0-纯色 1-图片 |
| background_color | VARCHAR(20) | 背景颜色(hex格式,如#FFFFFF) |
| background_image | VARCHAR(500) | 背景图片URL |
| description | TEXT | 模板描述 |
| category | VARCHAR(50) | 模板分类(order/ticket/certificate等) |
| status | TINYINT | 状态:0-禁用 1-启用 |
| scenic_id | BIGINT | 景区ID(多租户隔离) |
| create_time | DATETIME | 创建时间 |
| update_time | DATETIME | 更新时间 |
| deleted | TINYINT | 删除标记:0-未删除 1-已删除 |
| deleted_at | DATETIME | 删除时间 |
**索引**
- UNIQUE KEY `uk_code` (code, deleted)
- KEY `idx_scenic_id` (scenic_id)
- KEY `idx_category_status` (category, status, deleted)
---
### 2. puzzle_element - 拼图元素表
| 字段 | 类型 | 说明 |
|-----|------|-----|
| id | BIGINT | 主键ID |
| template_id | BIGINT | 模板ID(外键) |
| element_type | TINYINT | 元素类型:1-图片 2-文字 |
| element_key | VARCHAR(50) | 元素标识(用于动态数据映射) |
| element_name | VARCHAR(100) | 元素名称(便于管理) |
**位置和布局属性**
| 字段 | 类型 | 说明 |
|-----|------|-----|
| x_position | INT | X坐标(相对画布左上角) |
| y_position | INT | Y坐标(相对画布左上角) |
| width | INT | 宽度(像素) |
| height | INT | 高度(像素) |
| z_index | INT | 层级(数值越大越靠上) |
| rotation | INT | 旋转角度(0-360度,顺时针) |
| opacity | INT | 不透明度(0-100) |
**图片元素专有属性**
| 字段 | 类型 | 说明 |
|-----|------|-----|
| default_image_url | VARCHAR(500) | 默认图片URL |
| image_fit_mode | VARCHAR(20) | 图片适配模式:CONTAIN/COVER/FILL/SCALE_DOWN |
| border_radius | INT | 圆角半径(像素) |
**文字元素专有属性**
| 字段 | 类型 | 说明 |
|-----|------|-----|
| default_text | TEXT | 默认文本内容 |
| font_family | VARCHAR(50) | 字体名称 |
| font_size | INT | 字号(像素) |
| font_color | VARCHAR(20) | 字体颜色(hex) |
| font_weight | VARCHAR(20) | 字重:NORMAL/BOLD |
| font_style | VARCHAR(20) | 字体样式:NORMAL/ITALIC |
| text_align | VARCHAR(20) | 对齐方式:LEFT/CENTER/RIGHT |
| line_height | DECIMAL(3,2) | 行高倍数(如1.5) |
| max_lines | INT | 最大行数(NULL表示不限制) |
| text_decoration | VARCHAR(20) | 文本装饰:NONE/UNDERLINE/LINE_THROUGH |
**索引**
- KEY `idx_template_id` (template_id, deleted)
- KEY `idx_element_key` (element_key)
---
### 3. puzzle_generation_record - 拼图生成记录表
| 字段 | 类型 | 说明 |
|-----|------|-----|
| id | BIGINT | 主键ID |
| template_id | BIGINT | 模板ID |
| template_code | VARCHAR(50) | 模板编码(冗余) |
| user_id | BIGINT | 用户ID |
| order_id | VARCHAR(50) | 关联订单号 |
| business_type | VARCHAR(50) | 业务类型 |
| generation_params | TEXT | 生成参数(JSON格式) |
| result_image_url | VARCHAR(500) | 生成的图片URL |
| result_file_size | BIGINT | 文件大小(字节) |
| result_width | INT | 图片宽度 |
| result_height | INT | 图片高度 |
| status | TINYINT | 状态:0-生成中 1-成功 2-失败 |
| error_message | TEXT | 错误信息(失败时) |
| generation_duration | INT | 生成耗时(毫秒) |
| retry_count | INT | 重试次数 |
| scenic_id | BIGINT | 景区ID |
| client_ip | VARCHAR(50) | 客户端IP |
| user_agent | VARCHAR(500) | 客户端User-Agent |
| create_time | DATETIME | 创建时间 |
| update_time | DATETIME | 更新时间 |
**索引**
- KEY `idx_user_id` (user_id)
- KEY `idx_order_id` (order_id)
- KEY `idx_template_id` (template_id)
- KEY `idx_create_time` (create_time)
---
## 🔄 关键业务流程
### 拼图生成完整流程
```
用户请求 → Controller接收
验证templateCode和dynamicData
根据templateCode查询模板(含权限校验)
根据templateId查询所有元素(按z-index排序)
调用PuzzleImageRenderer.render()
├─ 创建画布
├─ 绘制背景
├─ 遍历元素列表
│ ├─ 图片元素:下载图片 → 缩放/圆角/透明度/旋转 → 绘制
│ └─ 文字元素:设置字体样式 → 计算布局 → 绘制
└─ 返回BufferedImage
将BufferedImage转换为字节流
上传到OSS获取URL
创建生成记录(保存参数和结果)
返回响应(imageUrl、width、height、fileSize等)
```
### 图片适配模式说明
**CONTAIN(等比缩放适应)**
- 图片完全显示在区域内
- 保持图片宽高比
- 可能留白
**COVER(等比缩放填充)**
- 完全覆盖目标区域
- 保持图片宽高比
- 可能裁剪图片
**FILL(拉伸填充)**
- 完全填充目标区域
- 不保持宽高比
- 可能变形
**SCALE_DOWN(缩小适应)**
- 类似CONTAIN
- 但不放大图片(仅缩小)
---
## 🛠️ 技术栈
### 核心依赖
- **Spring Boot**:框架基础
- **MyBatis Plus**:数据访问
- **Lombok**:减少样板代码
- **Hutool**:工具类库(图片处理、HTTP下载)
- **Java AWT/ImageIO**:图形绘制和图片处理
- **SLF4J/Logback**:日志
### 外部依赖
- **OSS对象存储**:图片上传和存储
- **MySQL**:关系型数据库
---
## 📦 对外依赖
puzzle模块与其他模块的依赖关系:
| 依赖模块 | 依赖项 | 用途 |
|---------|--------|-----|
| storage | OSS上传服务 | 上传生成的图片到对象存储 |
| config | 全局配置 | 获取系统配置信息 |
| exception | 自定义异常 | 业务异常处理 |
| utils | 工具类 | 通用工具方法 |
**被依赖情况**
- order模块:订单凭证图片生成
- ticket模块:电子门票图片生成
- 其他需要动态图片生成的业务模块
---
## 🚀 扩展指南
### 1. 新增元素类型
如需支持新的元素类型(如二维码元素、形状元素等):
1.`PuzzleElementEntity`中新增`elementType`枚举值
2.`PuzzleImageRenderer.render()`中添加新类型的渲染逻辑
3. 新增元素专有属性到`puzzle_element`表和实体类
4. 更新DTO和请求对象
### 2. 新增图片格式支持
当前支持PNG和JPEG,如需支持WebP、SVG等:
1. 更新`PuzzleGenerateRequest.outputFormat`校验逻辑
2. 修改`PuzzleGenerateServiceImpl.uploadImage()`中的格式转换逻辑
3. 注意浏览器兼容性
### 3. 新增渲染效果
如需支持阴影、边框、渐变等效果:
1.`PuzzleElementEntity`中新增对应的属性字段
2.`PuzzleImageRenderer`中实现对应的绘制逻辑
3. 使用Java AWT的相关API(如`setShadow``drawRect`等)
### 4. 批量生成优化
如需支持批量生成(如批量生成门票):
1. 新增批量生成接口`POST /puzzle/batchGenerate`
2. 使用线程池并发处理
3. 返回任务ID,支持异步查询结果
---
## ⚠️ 重要注意事项
### 1. 性能优化
- **图片缓存**:对于默认图片URL,考虑使用本地缓存避免重复下载
- **并发控制**:高并发场景下,生成接口应加限流保护
- **资源释放**:及时释放BufferedImage和Graphics2D对象,避免内存泄漏
- **异步处理**:对于复杂模板,考虑异步生成+回调通知
### 2. 安全性
- **URL校验**:对dynamicData中的图片URL进行白名单校验,防止SSRF攻击
- **文件大小限制**:限制下载图片的大小,防止资源耗尽
- **权限控制**:确保scenicId隔离,防止越权访问
- **输入校验**:严格校验所有输入参数,防止XSS和注入攻击
### 3. 多租户隔离
- 所有查询必须带上scenicId条件
- 创建模板和元素时必须关联正确的scenicId
- 生成拼图时校验模板的scenicId权限
### 4. 错误处理
- 图片下载失败时的降级策略(使用默认图片)
- 渲染失败时记录详细错误日志
- 对外暴露友好的错误提示
### 5. 字体问题
- **字体文件**:确保服务器安装了模板使用的字体文件
- **中文字体**:Linux服务器需要安装中文字体(如文泉驿)
- **字体回退**:设置字体回退机制,避免乱码
### 6. 数据一致性
- 删除模板时级联删除元素(软删除)
- 更新模板状态时考虑对正在生成的任务的影响
- 生成记录不可删除,仅供审计和统计
### 7. 监控和日志
- 记录每次生成的耗时,监控性能
- 记录生成失败的详细原因,便于排查
- 统计各模板的使用频率,优化热点模板
---
## 📈 性能指标参考
**典型性能数据**(测试环境):
- 单张简单拼图(2-3个元素):< 500ms
- 单张复杂拼图10+个元素):< 1500ms
- 图片下载耗时200-500ms取决于网络
- 渲染耗时50-200ms
- OSS上传耗时100-300ms
**优化建议**
- 使用CDN加速图片下载
- 预热常用模板的背景图片
- 使用Redis缓存模板和元素配置
---
## 📝 示例代码
### 创建模板示例
```java
// 1. 创建模板
TemplateCreateRequest templateReq = new TemplateCreateRequest();
templateReq.setName("订单凭证模板");
templateReq.setCode("order_certificate_v1");
templateReq.setCanvasWidth(750);
templateReq.setCanvasHeight(1334);
templateReq.setBackgroundType(1);
templateReq.setBackgroundImage("https://oss.example.com/bg.jpg");
templateReq.setCategory("order");
templateReq.setScenicId(1L);
Long templateId = templateService.createTemplate(templateReq);
// 2. 添加元素 - 用户头像(图片元素)
ElementCreateRequest avatarElement = new ElementCreateRequest();
avatarElement.setTemplateId(templateId);
avatarElement.setElementType(1); // 图片
avatarElement.setElementKey("userAvatar");
avatarElement.setElementName("用户头像");
avatarElement.setXPosition(50);
avatarElement.setYPosition(100);
avatarElement.setWidth(100);
avatarElement.setHeight(100);
avatarElement.setZIndex(10);
avatarElement.setDefaultImageUrl("https://oss.example.com/default-avatar.png");
avatarElement.setImageFitMode("COVER");
avatarElement.setBorderRadius(50); // 圆形头像
avatarElement.setOpacity(100);
templateService.addElement(avatarElement);
// 3. 添加元素 - 用户名(文字元素)
ElementCreateRequest nameElement = new ElementCreateRequest();
nameElement.setTemplateId(templateId);
nameElement.setElementType(2); // 文字
nameElement.setElementKey("userName");
nameElement.setElementName("用户名");
nameElement.setXPosition(170);
nameElement.setYPosition(120);
nameElement.setWidth(300);
nameElement.setHeight(60);
nameElement.setZIndex(20);
nameElement.setDefaultText("用户名");
nameElement.setFontFamily("PingFang SC");
nameElement.setFontSize(28);
nameElement.setFontColor("#333333");
nameElement.setFontWeight("BOLD");
nameElement.setTextAlign("LEFT");
nameElement.setLineHeight(new BigDecimal("1.5"));
templateService.addElement(nameElement);
```
### 生成拼图示例
```java
// 调用生成接口
PuzzleGenerateRequest request = new PuzzleGenerateRequest();
request.setTemplateCode("order_certificate_v1");
request.setUserId(123L);
request.setOrderId("ORDER20250117001");
request.setBusinessType("order");
request.setScenicId(1L);
Map<String, String> dynamicData = new HashMap<>();
dynamicData.put("userAvatar", "https://oss.example.com/user123/avatar.jpg");
dynamicData.put("userName", "张三");
dynamicData.put("orderNumber", "ORDER20250117001");
dynamicData.put("qrCode", "https://oss.example.com/qr/ORDER20250117001.png");
request.setDynamicData(dynamicData);
request.setOutputFormat("PNG");
request.setQuality(90);
PuzzleGenerateResponse response = generateService.generate(request);
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)
---
## 📞 联系方式
如有问题或建议请联系模块负责人或提交Issue
**维护者**Claude
**创建时间**2025-01-17
**最后更新**2025-01-17

View File

@@ -2,13 +2,19 @@ package com.ycwl.basic.puzzle.dto;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal; import java.util.Map;
/** /**
* 创建元素请求DTO * 创建元素请求DTO(重构版)
*
* 重构说明:
* - elementType从Integer改为String(TEXT、IMAGE等)
* - 删除所有type-specific字段
* - 新增config和configMap支持JSON配置
* - 支持两种方式:直接传JSON字符串 或 传Map对象
* *
* @author Claude * @author Claude
* @since 2025-01-17 * @since 2025-01-18
*/ */
@Data @Data
public class ElementCreateRequest { public class ElementCreateRequest {
@@ -19,123 +25,76 @@ public class ElementCreateRequest {
private Long templateId; private Long templateId;
/** /**
* 元素类型:1-图片 2-文字 * 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
*/ */
private Integer elementType; private String elementType;
/** /**
* 元素标识 * 元素标识(用于动态数据映射)
*/ */
private String elementKey; private String elementKey;
/** /**
* 元素名称 * 元素名称(便于管理识别)
*/ */
private String elementName; private String elementName;
// ===== 位置和布局属性 ===== // ===== 位置和布局属性(所有元素通用) =====
/** /**
* X坐标 * X坐标(相对于画布左上角,像素)
*/ */
private Integer xPosition; private Integer xPosition;
/** /**
* Y坐标 * Y坐标(相对于画布左上角,像素)
*/ */
private Integer yPosition; private Integer yPosition;
/** /**
* 宽度 * 宽度(像素)
*/ */
private Integer width; private Integer width;
/** /**
* 高度 * 高度(像素)
*/ */
private Integer height; private Integer height;
/** /**
* 层级 * 层级(数值越大越靠上)
*/ */
private Integer zIndex; private Integer zIndex;
/** /**
* 旋转角度 * 旋转角度(0-360度,顺时针)
*/ */
private Integer rotation; private Integer rotation;
/** /**
* 不透明度 * 不透明度(0-100,100为完全不透明)
*/ */
private Integer opacity; private Integer opacity;
// ===== 图片元素属性 ===== // ===== JSON配置(二选一) =====
/** /**
* 默认图片URL * JSON配置字符串(直接传入JSON字符串)
*
* 示例:
* - 文字元素:"{\"defaultText\":\"用户名\", \"fontFamily\":\"微软雅黑\", \"fontSize\":14}"
* - 图片元素:"{\"defaultImageUrl\":\"https://...\", \"imageFitMode\":\"COVER\", \"borderRadius\":10}"
*/ */
private String defaultImageUrl; private String config;
/** /**
* 图片适配模式 * JSON配置Map(传入Map对象,框架自动序列化为JSON)
*
* 示例:
* Map<String, Object> configMap = new HashMap<>();
* configMap.put("defaultText", "用户名");
* configMap.put("fontSize", 14);
* request.setConfigMap(configMap);
*/ */
private String imageFitMode; private Map<String, Object> configMap;
/**
* 圆角半径
*/
private Integer borderRadius;
// ===== 文字元素属性 =====
/**
* 默认文本内容
*/
private String defaultText;
/**
* 字体
*/
private String fontFamily;
/**
* 字号
*/
private Integer fontSize;
/**
* 字体颜色
*/
private String fontColor;
/**
* 字重
*/
private String fontWeight;
/**
* 字体样式
*/
private String fontStyle;
/**
* 对齐方式
*/
private String textAlign;
/**
* 行高倍数
*/
private BigDecimal lineHeight;
/**
* 最大行数
*/
private Integer maxLines;
/**
* 文本装饰
*/
private String textDecoration;
} }

View File

@@ -2,13 +2,18 @@ package com.ycwl.basic.puzzle.dto;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal; import java.util.Map;
/** /**
* 拼图元素DTO * 拼图元素DTO(重构版)
*
* 重构说明:
* - elementType从Integer改为String
* - 删除所有type-specific字段
* - 新增config和configMap字段
* *
* @author Claude * @author Claude
* @since 2025-01-17 * @since 2025-01-18
*/ */
@Data @Data
public class PuzzleElementDTO { public class PuzzleElementDTO {
@@ -24,123 +29,66 @@ public class PuzzleElementDTO {
private Long templateId; private Long templateId;
/** /**
* 元素类型:1-图片 2-文字 * 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
*/ */
private Integer elementType; private String elementType;
/** /**
* 元素标识(用于动态数据替换 * 元素标识(用于动态数据映射
*/ */
private String elementKey; private String elementKey;
/** /**
* 元素名称 * 元素名称(便于管理识别)
*/ */
private String elementName; private String elementName;
// ===== 位置和布局属性 ===== // ===== 位置和布局属性(所有元素通用) =====
/** /**
* X坐标 * X坐标(相对于画布左上角,像素)
*/ */
private Integer xPosition; private Integer xPosition;
/** /**
* Y坐标 * Y坐标(相对于画布左上角,像素)
*/ */
private Integer yPosition; private Integer yPosition;
/** /**
* 宽度 * 宽度(像素)
*/ */
private Integer width; private Integer width;
/** /**
* 高度 * 高度(像素)
*/ */
private Integer height; private Integer height;
/** /**
* 层级 * 层级(数值越大越靠上)
*/ */
private Integer zIndex; private Integer zIndex;
/** /**
* 旋转角度 * 旋转角度(0-360度,顺时针)
*/ */
private Integer rotation; private Integer rotation;
/** /**
* 不透明度 * 不透明度(0-100,100为完全不透明)
*/ */
private Integer opacity; private Integer opacity;
// ===== 图片元素属性 ===== // ===== JSON配置 =====
/** /**
* 默认图片URL * JSON配置字符串
*/ */
private String defaultImageUrl; private String config;
/** /**
* 图片适配模式 * JSON配置Map(方便前端使用)
*/ */
private String imageFitMode; private Map<String, Object> configMap;
/**
* 圆角半径
*/
private Integer borderRadius;
// ===== 文字元素属性 =====
/**
* 默认文本内容
*/
private String defaultText;
/**
* 字体
*/
private String fontFamily;
/**
* 字号
*/
private Integer fontSize;
/**
* 字体颜色
*/
private String fontColor;
/**
* 字重
*/
private String fontWeight;
/**
* 字体样式
*/
private String fontStyle;
/**
* 对齐方式
*/
private String textAlign;
/**
* 行高倍数
*/
private BigDecimal lineHeight;
/**
* 最大行数
*/
private Integer maxLines;
/**
* 文本装饰
*/
private String textDecoration;
} }

View File

@@ -0,0 +1,219 @@
package com.ycwl.basic.puzzle.element.base;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.awt.*;
/**
* 元素抽象基类
* 定义所有Element的通用行为和属性
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
@Data
public abstract class BaseElement {
/**
* 元素ID
*/
protected Long id;
/**
* 元素类型
*/
protected ElementType elementType;
/**
* 元素标识(用于动态数据映射)
*/
protected String elementKey;
/**
* 元素名称(便于管理识别)
*/
protected String elementName;
/**
* 位置信息
*/
protected Position position;
/**
* JSON配置字符串(原始)
*/
protected String configJson;
/**
* 解析后的配置对象(子类特定)
*/
protected ElementConfig config;
// ========== 抽象方法(子类必须实现) ==========
/**
* 加载并解析JSON配置
* 子类需要将configJson解析为具体的Config对象
*
* @param configJson JSON配置字符串
*/
public abstract void loadConfig(String configJson);
/**
* 验证元素配置是否合法
*
* @throws ElementValidationException 配置不合法时抛出
*/
public abstract void validate() throws ElementValidationException;
/**
* 渲染元素到画布
* 这是元素的核心方法,负责将元素绘制到Graphics2D上
*
* @param context 渲染上下文
*/
public abstract void render(RenderContext context);
/**
* 获取配置的JSON Schema或说明
*
* @return 配置说明
*/
public abstract String getConfigSchema();
// ========== 通用方法 ==========
/**
* 初始化元素(加载配置并验证)
* 在创建Element实例后必须调用此方法
*/
public void initialize() {
if (StrUtil.isNotBlank(configJson)) {
loadConfig(configJson);
}
validate();
}
/**
* 应用透明度
* 如果元素有透明度设置,则应用到Graphics2D上
*
* @param g2d Graphics2D对象
* @return 原始的Composite对象(用于恢复)
*/
protected Composite applyOpacity(Graphics2D g2d) {
Composite originalComposite = g2d.getComposite();
if (position != null && position.hasOpacity()) {
g2d.setComposite(AlphaComposite.getInstance(
AlphaComposite.SRC_OVER,
position.getOpacityFloat()
));
}
return originalComposite;
}
/**
* 恢复透明度
*
* @param g2d Graphics2D对象
* @param originalComposite 原始的Composite对象
*/
protected void restoreOpacity(Graphics2D g2d, Composite originalComposite) {
if (originalComposite != null) {
g2d.setComposite(originalComposite);
}
}
/**
* 应用旋转
* 如果元素有旋转设置,则应用到Graphics2D上
*
* @param g2d Graphics2D对象
*/
protected void applyRotation(Graphics2D g2d) {
if (position != null && position.hasRotation()) {
// 以元素中心点为旋转中心
int centerX = position.getX() + position.getWidth() / 2;
int centerY = position.getY() + position.getHeight() / 2;
g2d.rotate(position.getRotationRadians(), centerX, centerY);
}
}
/**
* 解析颜色字符串(支持hex格式)
*
* @param colorStr 颜色字符串(如#FFFFFF)
* @return Color对象
*/
protected Color parseColor(String colorStr) {
if (StrUtil.isBlank(colorStr)) {
return Color.BLACK;
}
try {
// 移除#号
String hex = colorStr.startsWith("#") ? colorStr.substring(1) : colorStr;
// 解析RGB
return new Color(
Integer.valueOf(hex.substring(0, 2), 16),
Integer.valueOf(hex.substring(2, 4), 16),
Integer.valueOf(hex.substring(4, 6), 16)
);
} catch (Exception e) {
log.warn("颜色解析失败: {}, 使用默认黑色", colorStr);
return Color.BLACK;
}
}
/**
* 安全解析JSON配置
*
* @param configJson JSON字符串
* @param configClass 配置类
* @param <T> 配置类型
* @return 配置对象
*/
protected <T extends ElementConfig> T parseConfig(String configJson, Class<T> configClass) {
try {
if (StrUtil.isBlank(configJson)) {
// 返回默认实例
return configClass.getDeclaredConstructor().newInstance();
}
return JacksonUtil.fromJson(configJson, configClass);
} catch (Exception e) {
throw new ElementValidationException(
elementType != null ? elementType.getCode() : "UNKNOWN",
elementKey,
"JSON配置解析失败: " + e.getMessage()
);
}
}
/**
* 启用高质量渲染
*
* @param g2d Graphics2D对象
*/
protected void enableHighQualityRendering(Graphics2D g2d) {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
}
@Override
public String toString() {
return String.format("Element[type=%s, key=%s, name=%s, position=%s]",
elementType != null ? elementType.getCode() : "null",
elementKey,
elementName,
position);
}
}

View File

@@ -0,0 +1,30 @@
package com.ycwl.basic.puzzle.element.base;
/**
* 元素配置接口
* 所有Element的配置类都需要实现此接口
*
* @author Claude
* @since 2025-01-18
*/
public interface ElementConfig {
/**
* 获取配置说明(JSON Schema或描述)
*
* @return 配置说明
*/
default String getConfigSchema() {
return "{}";
}
/**
* 验证配置是否合法
* 子类应该重写此方法实现自己的验证逻辑
*
* @throws IllegalArgumentException 配置不合法时抛出
*/
default void validate() {
// 默认不做验证
}
}

View File

@@ -0,0 +1,172 @@
package com.ycwl.basic.puzzle.element.base;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Constructor;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 元素工厂类
* 负责根据类型创建Element实例
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class ElementFactory {
/**
* Element类型注册表
* key: ElementType枚举
* value: Element实现类的Class对象
*/
private static final Map<ElementType, Class<? extends BaseElement>> ELEMENT_REGISTRY = new ConcurrentHashMap<>();
/**
* 构造器缓存(性能优化)
* key: Element实现类
* value: 无参构造器
*/
private static final Map<Class<? extends BaseElement>, Constructor<? extends BaseElement>> CONSTRUCTOR_CACHE = new ConcurrentHashMap<>();
/**
* 注册Element类型
*
* @param type 元素类型
* @param elementClass Element实现类
*/
public static void register(ElementType type, Class<? extends BaseElement> elementClass) {
if (type == null || elementClass == null) {
throw new IllegalArgumentException("注册参数不能为null");
}
ELEMENT_REGISTRY.put(type, elementClass);
log.info("注册Element类型: {} -> {}", type.getCode(), elementClass.getName());
}
/**
* 根据Entity创建Element实例
*
* @param entity PuzzleElementEntity
* @return Element实例
*/
public static BaseElement create(PuzzleElementEntity entity) {
if (entity == null) {
throw new IllegalArgumentException("Entity不能为null");
}
// 解析元素类型
ElementType type;
try {
type = ElementType.fromCode(entity.getElementType());
} catch (IllegalArgumentException e) {
throw new ElementValidationException(
entity.getElementType(),
entity.getElementKey(),
"未知的元素类型: " + entity.getElementType()
);
}
// 检查类型是否已实现
if (!type.isImplemented()) {
throw new ElementValidationException(
type.getCode(),
entity.getElementKey(),
"元素类型尚未实现: " + type.getName()
);
}
// 获取Element实现类
Class<? extends BaseElement> elementClass = ELEMENT_REGISTRY.get(type);
if (elementClass == null) {
throw new ElementValidationException(
type.getCode(),
entity.getElementKey(),
"元素类型未注册: " + type.getCode()
);
}
// 创建Element实例
BaseElement element = createInstance(elementClass);
// 填充基本属性
element.setId(entity.getId());
element.setElementType(type);
element.setElementKey(entity.getElementKey());
element.setElementName(entity.getElementName());
element.setConfigJson(entity.getConfig());
// 填充位置信息
Position position = new Position(
entity.getXPosition(),
entity.getYPosition(),
entity.getWidth(),
entity.getHeight(),
entity.getZIndex(),
entity.getRotation(),
entity.getOpacity()
);
element.setPosition(position);
// 初始化(加载配置并验证)
element.initialize();
log.debug("创建Element成功: type={}, key={}", type.getCode(), entity.getElementKey());
return element;
}
/**
* 创建Element实例(使用反射)
*
* @param elementClass Element类
* @return Element实例
*/
private static BaseElement createInstance(Class<? extends BaseElement> elementClass) {
try {
// 从缓存获取构造器
Constructor<? extends BaseElement> constructor = CONSTRUCTOR_CACHE.get(elementClass);
if (constructor == null) {
constructor = elementClass.getDeclaredConstructor();
constructor.setAccessible(true);
CONSTRUCTOR_CACHE.put(elementClass, constructor);
}
return constructor.newInstance();
} catch (Exception e) {
throw new ElementValidationException(
"Element实例创建失败: " + elementClass.getName() + ", 原因: " + e.getMessage(),
e
);
}
}
/**
* 获取已注册的Element类型列表
*
* @return Element类型列表
*/
public static Map<ElementType, Class<? extends BaseElement>> getRegisteredTypes() {
return new ConcurrentHashMap<>(ELEMENT_REGISTRY);
}
/**
* 检查类型是否已注册
*
* @param type 元素类型
* @return true-已注册,false-未注册
*/
public static boolean isRegistered(ElementType type) {
return ELEMENT_REGISTRY.containsKey(type);
}
/**
* 清空注册表(主要用于测试)
*/
public static void clearRegistry() {
ELEMENT_REGISTRY.clear();
CONSTRUCTOR_CACHE.clear();
log.warn("Element注册表已清空");
}
}

View File

@@ -0,0 +1,40 @@
package com.ycwl.basic.puzzle.element.base;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
/**
* Element注册器
* 在Spring容器初始化时自动注册所有Element类型
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
@Component
public class ElementRegistrar {
@PostConstruct
public void registerElements() {
log.info("开始注册Element类型...");
// 注册文字元素
ElementFactory.register(ElementType.TEXT, TextElement.class);
// 注册图片元素
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
// 未来扩展的Element类型在这里注册
// ElementFactory.register(ElementType.QRCODE, QRCodeElement.class);
// ElementFactory.register(ElementType.GRADIENT, GradientElement.class);
// ElementFactory.register(ElementType.SHAPE, ShapeElement.class);
// ElementFactory.register(ElementType.DYNAMIC_IMAGE, DynamicImageElement.class);
log.info("Element类型注册完成,共注册{}种类型", ElementFactory.getRegisteredTypes().size());
}
}

View File

@@ -0,0 +1,95 @@
package com.ycwl.basic.puzzle.element.base;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 元素位置信息
* 封装所有与位置、大小、变换相关的属性
*
* @author Claude
* @since 2025-01-18
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Position {
/**
* X坐标(相对于画布左上角,像素)
*/
private Integer x;
/**
* Y坐标(相对于画布左上角,像素)
*/
private Integer y;
/**
* 宽度(像素)
*/
private Integer width;
/**
* 高度(像素)
*/
private Integer height;
/**
* 层级(数值越大越靠上,决定绘制顺序)
*/
private Integer zIndex;
/**
* 旋转角度(0-360度,顺时针)
*/
private Integer rotation;
/**
* 不透明度(0-100,100为完全不透明)
*/
private Integer opacity;
/**
* 获取不透明度的浮点数表示(0.0-1.0)
*
* @return 不透明度(0.0-1.0)
*/
public float getOpacityFloat() {
if (opacity == null) {
return 1.0f;
}
return Math.max(0, Math.min(100, opacity)) / 100.0f;
}
/**
* 获取旋转角度的弧度值
*
* @return 弧度值
*/
public double getRotationRadians() {
if (rotation == null || rotation == 0) {
return 0;
}
return Math.toRadians(rotation);
}
/**
* 是否需要旋转
*
* @return true-需要旋转,false-不需要
*/
public boolean hasRotation() {
return rotation != null && rotation != 0;
}
/**
* 是否有透明度
*
* @return true-有透明度,false-完全不透明
*/
public boolean hasOpacity() {
return opacity != null && opacity < 100;
}
}

View File

@@ -0,0 +1,69 @@
package com.ycwl.basic.puzzle.element.config;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.base.ElementConfig;
import lombok.Data;
/**
* 图片元素配置
*
* @author Claude
* @since 2025-01-18
*/
@Data
public class ImageConfig implements ElementConfig {
/**
* 默认图片URL
*/
private String defaultImageUrl;
/**
* 图片适配模式
* CONTAIN - 等比缩放适应(保持宽高比,可能留白)
* COVER - 等比缩放填充(保持宽高比,可能裁剪)
* FILL - 拉伸填充(不保持宽高比,可能变形)
* SCALE_DOWN - 缩小适应(类似CONTAIN,但不放大)
*/
private String imageFitMode = "FILL";
/**
* 圆角半径(像素,0为直角)
*/
private Integer borderRadius = 0;
@Override
public void validate() {
// 校验圆角半径
if (borderRadius != null && borderRadius < 0) {
throw new IllegalArgumentException("圆角半径不能为负数: " + borderRadius);
}
// 校验图片适配模式
if (StrUtil.isNotBlank(imageFitMode)) {
String mode = imageFitMode.toUpperCase();
if (!"CONTAIN".equals(mode) &&
!"COVER".equals(mode) &&
!"FILL".equals(mode) &&
!"SCALE_DOWN".equals(mode)) {
throw new IllegalArgumentException("图片适配模式只能是CONTAIN、COVER、FILL或SCALE_DOWN: " + imageFitMode);
}
}
// 校验图片URL格式(可选)
if (StrUtil.isNotBlank(defaultImageUrl)) {
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
}
}
}
@Override
public String getConfigSchema() {
return "{\n" +
" \"defaultImageUrl\": \"https://example.com/image.jpg\",\n" +
" \"imageFitMode\": \"CONTAIN|COVER|FILL|SCALE_DOWN\",\n" +
" \"borderRadius\": 0\n" +
"}";
}
}

View File

@@ -0,0 +1,141 @@
package com.ycwl.basic.puzzle.element.config;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.base.ElementConfig;
import lombok.Data;
import java.math.BigDecimal;
/**
* 文字元素配置
*
* @author Claude
* @since 2025-01-18
*/
@Data
public class TextConfig implements ElementConfig {
/**
* 默认文本内容
*/
private String defaultText;
/**
* 字体名称(如"微软雅黑"、"PingFang SC")
*/
private String fontFamily = "微软雅黑";
/**
* 字号(像素,范围10-200)
*/
private Integer fontSize = 14;
/**
* 字体颜色(hex格式,如#000000)
*/
private String fontColor = "#000000";
/**
* 字重:NORMAL-正常 BOLD-粗体
*/
private String fontWeight = "NORMAL";
/**
* 字体样式:NORMAL-正常 ITALIC-斜体
*/
private String fontStyle = "NORMAL";
/**
* 对齐方式:LEFT-左对齐 CENTER-居中 RIGHT-右对齐
*/
private String textAlign = "LEFT";
/**
* 行高倍数(如1.5表示1.5倍行距)
*/
private BigDecimal lineHeight = new BigDecimal("1.5");
/**
* 最大行数(超出后截断,NULL表示不限制)
*/
private Integer maxLines;
/**
* 文本装饰:NONE-无 UNDERLINE-下划线 LINE_THROUGH-删除线
*/
private String textDecoration = "NONE";
@Override
public void validate() {
// 校验字号范围
if (fontSize != null && (fontSize < 10 || fontSize > 200)) {
throw new IllegalArgumentException("字号必须在10-200之间: " + fontSize);
}
// 校验行高
if (lineHeight != null && lineHeight.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("行高必须大于0: " + lineHeight);
}
// 校验最大行数
if (maxLines != null && maxLines <= 0) {
throw new IllegalArgumentException("最大行数必须大于0: " + maxLines);
}
// 校验字重
if (StrUtil.isNotBlank(fontWeight)) {
if (!"NORMAL".equalsIgnoreCase(fontWeight) && !"BOLD".equalsIgnoreCase(fontWeight)) {
throw new IllegalArgumentException("字重只能是NORMAL或BOLD: " + fontWeight);
}
}
// 校验字体样式
if (StrUtil.isNotBlank(fontStyle)) {
if (!"NORMAL".equalsIgnoreCase(fontStyle) && !"ITALIC".equalsIgnoreCase(fontStyle)) {
throw new IllegalArgumentException("字体样式只能是NORMAL或ITALIC: " + fontStyle);
}
}
// 校验对齐方式
if (StrUtil.isNotBlank(textAlign)) {
if (!"LEFT".equalsIgnoreCase(textAlign) &&
!"CENTER".equalsIgnoreCase(textAlign) &&
!"RIGHT".equalsIgnoreCase(textAlign)) {
throw new IllegalArgumentException("对齐方式只能是LEFT、CENTER或RIGHT: " + textAlign);
}
}
// 校验文本装饰
if (StrUtil.isNotBlank(textDecoration)) {
if (!"NONE".equalsIgnoreCase(textDecoration) &&
!"UNDERLINE".equalsIgnoreCase(textDecoration) &&
!"LINE_THROUGH".equalsIgnoreCase(textDecoration)) {
throw new IllegalArgumentException("文本装饰只能是NONE、UNDERLINE或LINE_THROUGH: " + textDecoration);
}
}
// 校验颜色格式
if (StrUtil.isNotBlank(fontColor)) {
String hex = fontColor.startsWith("#") ? fontColor.substring(1) : fontColor;
if (hex.length() != 6 || !hex.matches("[0-9A-Fa-f]{6}")) {
throw new IllegalArgumentException("颜色格式必须是hex格式(如#FFFFFF): " + fontColor);
}
}
}
@Override
public String getConfigSchema() {
return "{\n" +
" \"defaultText\": \"默认文本\",\n" +
" \"fontFamily\": \"微软雅黑\",\n" +
" \"fontSize\": 14,\n" +
" \"fontColor\": \"#000000\",\n" +
" \"fontWeight\": \"NORMAL|BOLD\",\n" +
" \"fontStyle\": \"NORMAL|ITALIC\",\n" +
" \"textAlign\": \"LEFT|CENTER|RIGHT\",\n" +
" \"lineHeight\": 1.5,\n" +
" \"maxLines\": null,\n" +
" \"textDecoration\": \"NONE|UNDERLINE|LINE_THROUGH\"\n" +
"}";
}
}

View File

@@ -0,0 +1,97 @@
package com.ycwl.basic.puzzle.element.enums;
/**
* 元素类型枚举
*
* @author Claude
* @since 2025-01-18
*/
public enum ElementType {
/**
* 文字元素
*/
TEXT("TEXT", "文字元素", "com.ycwl.basic.puzzle.element.impl.TextElement"),
/**
* 固定图片元素
*/
IMAGE("IMAGE", "图片元素", "com.ycwl.basic.puzzle.element.impl.ImageElement"),
/**
* 二维码元素(未来扩展)
*/
QRCODE("QRCODE", "二维码元素", "com.ycwl.basic.puzzle.element.impl.QRCodeElement"),
/**
* 渐变元素(未来扩展)
*/
GRADIENT("GRADIENT", "渐变元素", "com.ycwl.basic.puzzle.element.impl.GradientElement"),
/**
* 形状元素(未来扩展)
*/
SHAPE("SHAPE", "形状元素", "com.ycwl.basic.puzzle.element.impl.ShapeElement"),
/**
* 动态图片元素(未来扩展)
*/
DYNAMIC_IMAGE("DYNAMIC_IMAGE", "动态图片元素", "com.ycwl.basic.puzzle.element.impl.DynamicImageElement");
/**
* 类型代码
*/
private final String code;
/**
* 类型名称
*/
private final String name;
/**
* 实现类全限定名
*/
private final String implementationClass;
ElementType(String code, String name, String implementationClass) {
this.code = code;
this.name = name;
this.implementationClass = implementationClass;
}
public String getCode() {
return code;
}
public String getName() {
return name;
}
public String getImplementationClass() {
return implementationClass;
}
/**
* 根据code获取枚举
*
* @param code 类型代码
* @return 枚举实例
*/
public static ElementType fromCode(String code) {
for (ElementType type : values()) {
if (type.code.equalsIgnoreCase(code)) {
return type;
}
}
throw new IllegalArgumentException("未知的元素类型: " + code);
}
/**
* 检查类型是否已实现
*
* @return true-已实现,false-未实现
*/
public boolean isImplemented() {
return this == TEXT || this == IMAGE;
}
}

View File

@@ -0,0 +1,40 @@
package com.ycwl.basic.puzzle.element.exception;
/**
* 元素验证异常
* 当元素配置不合法时抛出
*
* @author Claude
* @since 2025-01-18
*/
public class ElementValidationException extends RuntimeException {
private final String elementKey;
private final String elementType;
public ElementValidationException(String message) {
super(message);
this.elementKey = null;
this.elementType = null;
}
public ElementValidationException(String elementType, String elementKey, String message) {
super(String.format("[%s:%s] %s", elementType, elementKey, message));
this.elementKey = elementKey;
this.elementType = elementType;
}
public ElementValidationException(String message, Throwable cause) {
super(message, cause);
this.elementKey = null;
this.elementType = null;
}
public String getElementKey() {
return elementKey;
}
public String getElementType() {
return elementType;
}
}

View File

@@ -0,0 +1,254 @@
package com.ycwl.basic.puzzle.element.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.config.ImageConfig;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.Ellipse2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
/**
* 图片元素实现
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class ImageElement extends BaseElement {
private ImageConfig imageConfig;
@Override
public void loadConfig(String configJson) {
this.imageConfig = parseConfig(configJson, ImageConfig.class);
this.config = imageConfig;
}
@Override
public void validate() throws ElementValidationException {
try {
if (imageConfig == null) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"图片配置不能为空"
);
}
imageConfig.validate();
} catch (IllegalArgumentException e) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"配置验证失败: " + e.getMessage()
);
}
}
@Override
public void render(RenderContext context) {
Graphics2D g2d = context.getGraphics();
// 获取图片URL(优先使用动态数据)
String imageUrl = context.getDynamicData(elementKey, imageConfig.getDefaultImageUrl());
if (StrUtil.isBlank(imageUrl)) {
log.warn("图片元素没有图片URL: elementKey={}", elementKey);
return;
}
try {
// 下载图片
BufferedImage image = downloadImage(imageUrl);
if (image == null) {
log.error("图片下载失败: imageUrl={}", imageUrl);
return;
}
// 应用透明度
Composite originalComposite = applyOpacity(g2d);
// 缩放图片(根据适配模式)
BufferedImage scaledImage = scaleImage(image);
// 绘制图片(支持圆角)
if (imageConfig.getBorderRadius() != null && imageConfig.getBorderRadius() > 0) {
drawRoundedImage(g2d, scaledImage);
} else {
// 直接绘制
g2d.drawImage(scaledImage, position.getX(), position.getY(), null);
}
// 恢复透明度
restoreOpacity(g2d, originalComposite);
} catch (Exception e) {
log.error("图片元素渲染失败: elementKey={}, imageUrl={}", elementKey, imageUrl, e);
}
}
@Override
public String getConfigSchema() {
return imageConfig != null ? imageConfig.getConfigSchema() : "{}";
}
/**
* 下载图片
*
* @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);
return null;
}
}
/**
* 缩放图片(根据适配模式)
*
* @param source 原始图片
* @return 缩放后的图片
*/
private BufferedImage scaleImage(BufferedImage source) {
int targetWidth = position.getWidth();
int targetHeight = position.getHeight();
String fitMode = StrUtil.isNotBlank(imageConfig.getImageFitMode())
? imageConfig.getImageFitMode().toUpperCase()
: "FILL";
switch (fitMode) {
case "COVER":
// 等比缩放填充(可能裁剪)- 使用较大的比例
return scaleImageKeepRatio(source, targetWidth, targetHeight, true);
case "CONTAIN":
// 等比缩放适应(可能留白)- 使用较小的比例
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "SCALE_DOWN":
// 缩小适应(不放大)
if (source.getWidth() <= targetWidth && source.getHeight() <= targetHeight) {
return source; // 原图已小于目标,不处理
}
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "FILL":
default:
// 拉伸填充到目标尺寸(不保持宽高比,可能变形)
BufferedImage scaled = new BufferedImage(
targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = scaled.createGraphics();
enableHighQualityRendering(g);
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
g.dispose();
return scaled;
}
}
/**
* 等比缩放图片(保持宽高比)
*
* @param source 原始图片
* @param targetWidth 目标宽度
* @param targetHeight 目标高度
* @param cover true-COVER模式,false-CONTAIN模式
* @return 缩放后的图片
*/
private BufferedImage scaleImageKeepRatio(BufferedImage source,
int targetWidth, int targetHeight,
boolean cover) {
int sourceWidth = source.getWidth();
int sourceHeight = source.getHeight();
double widthRatio = (double) targetWidth / sourceWidth;
double heightRatio = (double) targetHeight / sourceHeight;
// cover模式使用较大比例(填充),contain模式使用较小比例(适应)
double ratio = cover
? Math.max(widthRatio, heightRatio)
: Math.min(widthRatio, heightRatio);
int scaledWidth = (int) (sourceWidth * ratio);
int scaledHeight = (int) (sourceHeight * ratio);
// 创建目标尺寸的画布
BufferedImage result = new BufferedImage(
targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = result.createGraphics();
enableHighQualityRendering(g);
// 居中绘制缩放后的图片
int x = (targetWidth - scaledWidth) / 2;
int y = (targetHeight - scaledHeight) / 2;
g.drawImage(source, x, y, scaledWidth, scaledHeight, null);
g.dispose();
return result;
}
/**
* 绘制圆角图片
*
* @param g2d Graphics2D对象
* @param image 图片
*/
private void drawRoundedImage(Graphics2D g2d, BufferedImage image) {
int width = position.getWidth();
int height = position.getHeight();
int radius = imageConfig.getBorderRadius();
// 创建圆角遮罩
BufferedImage rounded = new BufferedImage(
width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = rounded.createGraphics();
enableHighQualityRendering(g);
// 判断是否需要绘制圆形(当圆角半径>=最小边长的一半时)
boolean isCircle = (radius * 2 >= Math.min(width, height));
if (isCircle) {
// 绘制圆形遮罩
g.setColor(Color.WHITE);
g.fill(new Ellipse2D.Float(0, 0, width, height));
} else {
// 绘制圆角矩形遮罩
g.setColor(Color.WHITE);
g.fill(new RoundRectangle2D.Float(0, 0, width, height, radius * 2, radius * 2));
}
// 应用遮罩
g.setComposite(AlphaComposite.SrcAtop);
g.drawImage(image, 0, 0, width, height, null);
g.dispose();
// 绘制到主画布
g2d.drawImage(rounded, position.getX(), position.getY(), null);
}
}

View File

@@ -0,0 +1,221 @@
package com.ycwl.basic.puzzle.element.impl;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.config.TextConfig;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import lombok.extern.slf4j.Slf4j;
import java.awt.*;
import java.awt.font.LineMetrics;
import java.awt.geom.Rectangle2D;
/**
* 文字元素实现
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class TextElement extends BaseElement {
private TextConfig textConfig;
@Override
public void loadConfig(String configJson) {
this.textConfig = parseConfig(configJson, TextConfig.class);
this.config = textConfig;
}
@Override
public void validate() throws ElementValidationException {
try {
if (textConfig == null) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"文字配置不能为空"
);
}
textConfig.validate();
} catch (IllegalArgumentException e) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"配置验证失败: " + e.getMessage()
);
}
}
@Override
public void render(RenderContext context) {
Graphics2D g2d = context.getGraphics();
// 获取文本内容(优先使用动态数据)
String text = context.getDynamicData(elementKey, textConfig.getDefaultText());
if (StrUtil.isBlank(text)) {
log.debug("文字元素没有文本内容: elementKey={}", elementKey);
return;
}
try {
// 设置字体
Font font = createFont();
g2d.setFont(font);
// 设置颜色
g2d.setColor(parseColor(textConfig.getFontColor()));
// 应用透明度
Composite originalComposite = applyOpacity(g2d);
// 应用旋转
if (position.hasRotation()) {
applyRotation(g2d);
}
// 绘制文本
drawText(g2d, text);
// 恢复透明度
restoreOpacity(g2d, originalComposite);
} catch (Exception e) {
log.error("文字元素渲染失败: elementKey={}, text={}", elementKey, text, e);
}
}
@Override
public String getConfigSchema() {
return textConfig != null ? textConfig.getConfigSchema() : "{}";
}
/**
* 创建字体
*
* @return Font对象
*/
private Font createFont() {
int fontStyle = Font.PLAIN;
// 处理字重(BOLD)
if ("BOLD".equalsIgnoreCase(textConfig.getFontWeight())) {
fontStyle |= Font.BOLD;
}
// 处理字体样式(ITALIC)
if ("ITALIC".equalsIgnoreCase(textConfig.getFontStyle())) {
fontStyle |= Font.ITALIC;
}
return new Font(
textConfig.getFontFamily(),
fontStyle,
textConfig.getFontSize()
);
}
/**
* 绘制文本(支持多行、对齐、行高、最大行数)
*
* @param g2d Graphics2D对象
* @param text 文本内容
*/
private void drawText(Graphics2D g2d, String text) {
FontMetrics fm = g2d.getFontMetrics();
// 计算行高
float lineHeightMultiplier = textConfig.getLineHeight() != null
? textConfig.getLineHeight().floatValue()
: 1.5f;
int lineHeight = (int) (fm.getHeight() * lineHeightMultiplier);
// 分行
String[] lines = text.split("\\n");
Integer maxLines = textConfig.getMaxLines();
int actualLines = maxLines != null ? Math.min(lines.length, maxLines) : lines.length;
// 获取对齐方式
String textAlign = StrUtil.isNotBlank(textConfig.getTextAlign())
? textConfig.getTextAlign().toUpperCase()
: "LEFT";
// 起始Y坐标
int y = position.getY() + fm.getAscent();
// 逐行绘制
for (int i = 0; i < actualLines; i++) {
String line = lines[i];
// 计算X坐标(根据对齐方式)
int x = calculateTextX(line, fm, textAlign);
// 绘制文本
g2d.drawString(line, x, y);
// 绘制文本装饰(下划线、删除线)
if (StrUtil.isNotBlank(textConfig.getTextDecoration())) {
drawTextDecoration(g2d, line, x, y, fm);
}
// 移动到下一行
y += lineHeight;
}
}
/**
* 计算文本X坐标(根据对齐方式)
*
* @param text 文本
* @param fm 字体度量
* @param textAlign 对齐方式
* @return X坐标
*/
private int calculateTextX(String text, FontMetrics fm, String textAlign) {
int textWidth = fm.stringWidth(text);
switch (textAlign) {
case "CENTER":
return position.getX() + (position.getWidth() - textWidth) / 2;
case "RIGHT":
return position.getX() + position.getWidth() - textWidth;
case "LEFT":
default:
return position.getX();
}
}
/**
* 绘制文本装饰(下划线、删除线)
*
* @param g2d Graphics2D对象
* @param text 文本
* @param x 文本X坐标
* @param y 文本Y坐标
* @param fm 字体度量
*/
private void drawTextDecoration(Graphics2D g2d, String text, int x, int y, FontMetrics fm) {
String decoration = textConfig.getTextDecoration().toUpperCase();
int textWidth = fm.stringWidth(text);
switch (decoration) {
case "UNDERLINE":
// 下划线(在文本下方)
int underlineY = y + fm.getDescent() / 2;
g2d.drawLine(x, underlineY, x + textWidth, underlineY);
break;
case "LINE_THROUGH":
// 删除线(在文本中间)
int lineThroughY = y - fm.getAscent() / 2;
g2d.drawLine(x, lineThroughY, x + textWidth, lineThroughY);
break;
case "NONE":
default:
// 无装饰
break;
}
}
}

View File

@@ -0,0 +1,74 @@
package com.ycwl.basic.puzzle.element.renderer;
import lombok.Data;
import java.awt.*;
import java.util.Map;
/**
* 渲染上下文
* 封装渲染时需要的所有上下文信息
*
* @author Claude
* @since 2025-01-18
*/
@Data
public class RenderContext {
/**
* 图形上下文
*/
private Graphics2D graphics;
/**
* 动态数据(key=elementKey, value=实际值)
*/
private Map<String, String> dynamicData;
/**
* 画布宽度
*/
private Integer canvasWidth;
/**
* 画布高度
*/
private Integer canvasHeight;
/**
* 是否启用抗锯齿
*/
private boolean antiAliasing = true;
/**
* 是否启用高质量渲染
*/
private boolean highQuality = true;
public RenderContext(Graphics2D graphics, Map<String, String> dynamicData) {
this.graphics = graphics;
this.dynamicData = dynamicData;
}
public RenderContext(Graphics2D graphics, Map<String, String> dynamicData,
Integer canvasWidth, Integer canvasHeight) {
this.graphics = graphics;
this.dynamicData = dynamicData;
this.canvasWidth = canvasWidth;
this.canvasHeight = canvasHeight;
}
/**
* 获取动态数据(带默认值)
*
* @param key 数据key
* @param defaultValue 默认值
* @return 数据值
*/
public String getDynamicData(String key, String defaultValue) {
if (dynamicData == null) {
return defaultValue;
}
return dynamicData.getOrDefault(key, defaultValue);
}
}

View File

@@ -6,15 +6,19 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal;
import java.util.Date; import java.util.Date;
/** /**
* 拼图元素实体类 * 拼图元素实体类(重构版)
* 对应表:puzzle_element * 对应表:puzzle_element
* *
* 重构说明:
* - element_type 从TINYINT改为VARCHAR,支持TEXT、IMAGE、QRCODE等类型
* - 删除所有type-specific字段,改用JSON配置存储
* - 通过config字段存储元素特定配置,支持灵活扩展
*
* @author Claude * @author Claude
* @since 2025-01-17 * @since 2025-01-18
*/ */
@Data @Data
@TableName("puzzle_element") @TableName("puzzle_element")
@@ -33,13 +37,13 @@ public class PuzzleElementEntity {
private Long templateId; private Long templateId;
/** /**
* 元素类型:1-图片 2-文字 * 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
*/ */
@TableField("element_type") @TableField("element_type")
private Integer elementType; private String elementType;
/** /**
* 元素标识(用于动态数据替换 * 元素标识(用于动态数据映射
*/ */
@TableField("element_key") @TableField("element_key")
private String elementKey; private String elementKey;
@@ -50,16 +54,26 @@ public class PuzzleElementEntity {
@TableField("element_name") @TableField("element_name")
private String elementName; private String elementName;
// ===== 位置和布局属性 ===== /**
* JSON配置(元素特定配置)
*
* 示例:
* - 文字元素:{"defaultText":"用户名", "fontFamily":"微软雅黑", "fontSize":14, ...}
* - 图片元素:{"defaultImageUrl":"https://...", "imageFitMode":"COVER", "borderRadius":10, ...}
*/
@TableField("config")
private String config;
// ===== 位置和布局属性(所有元素通用) =====
/** /**
* X坐标(相对于画布左上角) * X坐标(相对于画布左上角,像素
*/ */
@TableField("x_position") @TableField("x_position")
private Integer xPosition; private Integer xPosition;
/** /**
* Y坐标(相对于画布左上角) * Y坐标(相对于画布左上角,像素
*/ */
@TableField("y_position") @TableField("y_position")
private Integer yPosition; private Integer yPosition;
@@ -77,7 +91,7 @@ public class PuzzleElementEntity {
private Integer height; private Integer height;
/** /**
* 层级(数值越大越靠上) * 层级(数值越大越靠上,决定绘制顺序
*/ */
@TableField("z_index") @TableField("z_index")
private Integer zIndex; private Integer zIndex;
@@ -94,87 +108,7 @@ public class PuzzleElementEntity {
@TableField("opacity") @TableField("opacity")
private Integer opacity; private Integer opacity;
// ===== 图片元素专有属性 ===== // ===== 元数据 =====
/**
* 默认图片URL(图片元素必填)
*/
@TableField("default_image_url")
private String defaultImageUrl;
/**
* 图片适配模式:CONTAIN-等比缩放适应 COVER-等比缩放填充 FILL-拉伸填充 SCALE_DOWN-缩小适应
*/
@TableField("image_fit_mode")
private String imageFitMode;
/**
* 圆角半径(像素,0为直角)
*/
@TableField("border_radius")
private Integer borderRadius;
// ===== 文字元素专有属性 =====
/**
* 默认文本内容(文字元素必填)
*/
@TableField("default_text")
private String defaultText;
/**
* 字体名称
*/
@TableField("font_family")
private String fontFamily;
/**
* 字号(像素)
*/
@TableField("font_size")
private Integer fontSize;
/**
* 字体颜色(hex格式)
*/
@TableField("font_color")
private String fontColor;
/**
* 字重:NORMAL-正常 BOLD-粗体
*/
@TableField("font_weight")
private String fontWeight;
/**
* 字体样式:NORMAL-正常 ITALIC-斜体
*/
@TableField("font_style")
private String fontStyle;
/**
* 对齐方式:LEFT-左对齐 CENTER-居中 RIGHT-右对齐
*/
@TableField("text_align")
private String textAlign;
/**
* 行高倍数(如:1.5表示1.5倍行距)
*/
@TableField("line_height")
private BigDecimal lineHeight;
/**
* 最大行数(超出后截断,NULL表示不限制)
*/
@TableField("max_lines")
private Integer maxLines;
/**
* 文本装饰:NONE-无 UNDERLINE-下划线 LINE_THROUGH-删除线
*/
@TableField("text_decoration")
private String textDecoration;
/** /**
* 创建时间 * 创建时间

View File

@@ -1,4 +1,6 @@
package com.ycwl.basic.puzzle.service.impl; package com.ycwl.basic.puzzle.service.impl;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.util.ElementConfigHelper;
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.BeanUtil;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest; import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
@@ -137,18 +139,24 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
public Long addElement(ElementCreateRequest request) { public Long addElement(ElementCreateRequest request) {
log.info("添加元素到模板: templateId={}, elementKey={}", request.getTemplateId(), request.getElementKey()); log.info("添加元素到模板: templateId={}, elementKey={}", request.getTemplateId(), request.getElementKey());
// 检查模板是否存在 // 1. 验证请求
ElementConfigHelper.validateRequest(request);
// 2. 检查模板是否存在
PuzzleTemplateEntity template = templateMapper.getById(request.getTemplateId()); PuzzleTemplateEntity template = templateMapper.getById(request.getTemplateId());
if (template == null) { if (template == null) {
throw new IllegalArgumentException("模板不存在: " + request.getTemplateId()); throw new IllegalArgumentException("模板不存在: " + request.getTemplateId());
} }
// 转换为实体并插入 // 3. 转换为Entity(使用Helper)
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class); PuzzleElementEntity entity = ElementConfigHelper.toEntity(request);
entity.setDeleted(0); entity.setDeleted(0);
// 4. 插入数据库
elementMapper.insert(entity); elementMapper.insert(entity);
log.info("元素添加成功: id={}, elementKey={}", entity.getId(), entity.getElementKey()); log.info("元素添加成功: id={}, type={}, key={}",
entity.getId(), entity.getElementType(), entity.getElementKey());
return entity.getId(); return entity.getId();
} }
@@ -157,22 +165,23 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
public void batchAddElements(Long templateId, List<ElementCreateRequest> elements) { public void batchAddElements(Long templateId, List<ElementCreateRequest> elements) {
log.info("批量添加元素到模板: templateId={}, count={}", templateId, elements.size()); log.info("批量添加元素到模板: templateId={}, count={}", templateId, elements.size());
// 检查模板是否存在 // 1. 校验模板
PuzzleTemplateEntity template = templateMapper.getById(templateId); PuzzleTemplateEntity template = templateMapper.getById(templateId);
if (template == null) { if (template == null) {
throw new IllegalArgumentException("模板不存在: " + templateId); throw new IllegalArgumentException("模板不存在: " + templateId);
} }
// 转换为实体列表 // 2. 批量转换
List<PuzzleElementEntity> entityList = new ArrayList<>(); List<PuzzleElementEntity> entityList = elements.stream()
for (ElementCreateRequest request : elements) { .peek(req -> {
request.setTemplateId(templateId); req.setTemplateId(templateId);
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class); ElementConfigHelper.validateRequest(req);
entity.setDeleted(0); })
entityList.add(entity); .map(ElementConfigHelper::toEntity)
} .peek(entity -> entity.setDeleted(0))
.collect(Collectors.toList());
// 批量插入 // 3. 批量插入
if (!entityList.isEmpty()) { if (!entityList.isEmpty()) {
elementMapper.batchInsert(entityList); elementMapper.batchInsert(entityList);
log.info("批量添加元素成功: templateId={}, count={}", templateId, entityList.size()); log.info("批量添加元素成功: templateId={}, count={}", templateId, entityList.size());
@@ -184,14 +193,17 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
public void updateElement(Long id, ElementCreateRequest request) { public void updateElement(Long id, ElementCreateRequest request) {
log.info("更新元素: id={}", id); log.info("更新元素: id={}", id);
// 检查元素是否存在 // 1. 校验元素存在
PuzzleElementEntity existing = elementMapper.getById(id); PuzzleElementEntity existing = elementMapper.getById(id);
if (existing == null) { if (existing == null) {
throw new IllegalArgumentException("元素不存在: " + id); throw new IllegalArgumentException("元素不存在: " + id);
} }
// 更新 // 2. 验证请求
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class); ElementConfigHelper.validateRequest(request);
// 3. 转换并更新
PuzzleElementEntity entity = ElementConfigHelper.toEntity(request);
entity.setId(id); entity.setId(id);
elementMapper.update(entity); elementMapper.update(entity);
@@ -222,7 +234,7 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
throw new IllegalArgumentException("元素不存在: " + id); throw new IllegalArgumentException("元素不存在: " + id);
} }
return BeanUtil.copyProperties(element, PuzzleElementDTO.class); return convertElementToDTO(element);
} }
/** /**
@@ -234,10 +246,25 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
// 查询元素列表 // 查询元素列表
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId()); List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
List<PuzzleElementDTO> elementDTOs = elements.stream() List<PuzzleElementDTO> elementDTOs = elements.stream()
.map(e -> BeanUtil.copyProperties(e, PuzzleElementDTO.class)) .map(this::convertElementToDTO)
.collect(Collectors.toList()); .collect(Collectors.toList());
dto.setElements(elementDTOs); dto.setElements(elementDTOs);
return dto; return dto;
} }
/**
* 转换元素为DTO
*/
private PuzzleElementDTO convertElementToDTO(PuzzleElementEntity entity) {
PuzzleElementDTO dto = new PuzzleElementDTO();
BeanUtil.copyProperties(entity, dto);
// 解析config为configMap(方便前端使用)
if (StrUtil.isNotBlank(entity.getConfig())) {
dto.setConfigMap(ElementConfigHelper.parseConfigToMap(entity.getConfig()));
}
return dto;
}
} }

View File

@@ -0,0 +1,162 @@
package com.ycwl.basic.puzzle.util;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
/**
* Element配置辅助类
* 处理ElementCreateRequest到PuzzleElementEntity的转换
* 负责config和configMap之间的序列化/反序列化
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class ElementConfigHelper {
/**
* 将ElementCreateRequest转换为PuzzleElementEntity
*
* @param request 创建请求
* @return Entity对象
*/
public static PuzzleElementEntity toEntity(ElementCreateRequest request) {
PuzzleElementEntity entity = new PuzzleElementEntity();
// 基本属性
entity.setTemplateId(request.getTemplateId());
entity.setElementType(request.getElementType());
entity.setElementKey(request.getElementKey());
entity.setElementName(request.getElementName());
// 位置和布局属性
entity.setXPosition(request.getXPosition());
entity.setYPosition(request.getYPosition());
entity.setWidth(request.getWidth());
entity.setHeight(request.getHeight());
entity.setZIndex(request.getZIndex());
entity.setRotation(request.getRotation());
entity.setOpacity(request.getOpacity());
// 处理配置:优先使用config字符串,否则将configMap序列化为JSON
String configJson = getConfigJson(request);
entity.setConfig(configJson);
return entity;
}
/**
* 从Request获取JSON配置字符串
* 优先级:config字符串 > configMap序列化
*
* @param request 创建请求
* @return JSON配置字符串
*/
public static String getConfigJson(ElementCreateRequest request) {
// 优先使用config字段
if (StrUtil.isNotBlank(request.getConfig())) {
return request.getConfig();
}
// 否则将configMap序列化为JSON
if (request.getConfigMap() != null && !request.getConfigMap().isEmpty()) {
try {
return JacksonUtil.toJson(request.getConfigMap());
} catch (Exception e) {
log.error("configMap序列化为JSON失败", e);
throw new IllegalArgumentException("配置序列化失败: " + e.getMessage());
}
}
// 都为空则返回空JSON对象
return "{}";
}
/**
* 将JSON配置字符串解析为Map
*
* @param configJson JSON配置字符串
* @return Map对象
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> parseConfigToMap(String configJson) {
if (StrUtil.isBlank(configJson)) {
return Map.of();
}
try {
return JacksonUtil.fromJson(configJson, Map.class);
} catch (Exception e) {
log.error("JSON解析为Map失败: {}", configJson, e);
return Map.of();
}
}
/**
* 验证元素类型是否有效
*
* @param elementType 元素类型
* @return true-有效,false-无效
*/
public static boolean isValidElementType(String elementType) {
if (StrUtil.isBlank(elementType)) {
return false;
}
// 当前支持的类型
return "TEXT".equalsIgnoreCase(elementType) ||
"IMAGE".equalsIgnoreCase(elementType) ||
"QRCODE".equalsIgnoreCase(elementType) ||
"GRADIENT".equalsIgnoreCase(elementType) ||
"SHAPE".equalsIgnoreCase(elementType) ||
"DYNAMIC_IMAGE".equalsIgnoreCase(elementType);
}
/**
* 验证元素配置是否完整
*
* @param request 创建请求
* @throws IllegalArgumentException 配置不完整时抛出
*/
public static void validateRequest(ElementCreateRequest request) {
if (request.getTemplateId() == null) {
throw new IllegalArgumentException("模板ID不能为空");
}
if (StrUtil.isBlank(request.getElementType())) {
throw new IllegalArgumentException("元素类型不能为空");
}
if (!isValidElementType(request.getElementType())) {
throw new IllegalArgumentException("不支持的元素类型: " + request.getElementType());
}
if (StrUtil.isBlank(request.getElementKey())) {
throw new IllegalArgumentException("元素标识不能为空");
}
// 验证位置属性
if (request.getXPosition() == null || request.getYPosition() == null) {
throw new IllegalArgumentException("位置坐标不能为空");
}
if (request.getWidth() == null || request.getHeight() == null) {
throw new IllegalArgumentException("宽高不能为空");
}
if (request.getWidth() <= 0 || request.getHeight() <= 0) {
throw new IllegalArgumentException("宽高必须大于0");
}
// 验证配置
if (StrUtil.isBlank(request.getConfig()) &&
(request.getConfigMap() == null || request.getConfigMap().isEmpty())) {
throw new IllegalArgumentException("元素配置不能为空(config或configMap至少提供一个)");
}
}
}

View File

@@ -1,8 +1,10 @@
package com.ycwl.basic.puzzle.util; package com.ycwl.basic.puzzle.util;
import cn.hutool.core.img.ImgUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil; import cn.hutool.http.HttpUtil;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.base.ElementFactory;
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.entity.PuzzleTemplateEntity; import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -10,29 +12,32 @@ import org.springframework.stereotype.Component;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import java.awt.*; import java.awt.*;
import java.awt.geom.Ellipse2D;
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.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.URL;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 拼图图片渲染引擎 * 拼图图片渲染引擎(重构版)
* 核心功能:将模板和元素渲染成最终图片 * 核心功能:将模板和元素渲染成最终图片
* *
* 重构说明:
* - 使用ElementFactory创建Element实例
* - 元素渲染逻辑委托给Element自己实现
* - 删除drawImageElement和drawTextElement方法
* - 保留背景绘制和工具方法
*
* @author Claude * @author Claude
* @since 2025-01-17 * @since 2025-01-18
*/ */
@Slf4j @Slf4j
@Component @Component
public class PuzzleImageRenderer { public class PuzzleImageRenderer {
/** /**
* 渲染拼图图片 * 渲染拼图图片(重构版)
* *
* @param template 模板配置 * @param template 模板配置
* @param elements 元素列表(已按z-index排序) * @param elements 元素列表(已按z-index排序)
@@ -48,7 +53,7 @@ public class PuzzleImageRenderer {
BufferedImage canvas = new BufferedImage( BufferedImage canvas = new BufferedImage(
template.getCanvasWidth(), template.getCanvasWidth(),
template.getCanvasHeight(), template.getCanvasHeight(),
BufferedImage.TYPE_INT_RGB BufferedImage.TYPE_INT_ARGB // 使用ARGB支持透明度
); );
Graphics2D g2d = canvas.createGraphics(); Graphics2D g2d = canvas.createGraphics();
@@ -60,23 +65,33 @@ public class PuzzleImageRenderer {
// 3. 绘制背景 // 3. 绘制背景
drawBackground(g2d, template); drawBackground(g2d, template);
// 4. 按z-index顺序绘制元素 // 4. 创建渲染上下文
for (PuzzleElementEntity element : elements) { RenderContext context = new RenderContext(
g2d,
dynamicData,
template.getCanvasWidth(),
template.getCanvasHeight()
);
// 5. 使用ElementFactory创建Element实例并渲染
for (PuzzleElementEntity entity : elements) {
try { try {
if (element.getElementType() == 1) { // 使用工厂创建Element实例(自动加载配置和验证)
// 图片元素 BaseElement element = ElementFactory.create(entity);
drawImageElement(g2d, element, dynamicData);
} else if (element.getElementType() == 2) { // 委托给Element自己渲染
// 文字元素 element.render(context);
drawTextElement(g2d, element, dynamicData);
} log.debug("元素渲染成功: type={}, key={}", element.getElementType().getCode(), element.getElementKey());
} catch (Exception e) { } catch (Exception e) {
log.error("绘制元素失败: elementId={}, elementKey={}", element.getId(), element.getElementKey(), e); log.error("元素渲染失败: elementId={}, elementKey={}, error={}",
entity.getId(), entity.getElementKey(), e.getMessage(), e);
// 继续绘制其他元素,不中断整个渲染流程 // 继续绘制其他元素,不中断整个渲染流程
} }
} }
log.info("拼图渲染完成: templateId={}", template.getId()); log.info("拼图渲染完成: templateId={}, 成功渲染元素数={}", template.getId(), elements.size());
return canvas; return canvas;
} finally { } finally {
@@ -121,246 +136,9 @@ public class PuzzleImageRenderer {
} }
/** /**
* 绘制图片元素 * 下载图片(工具方法,也可被外部使用)
*/ */
private void drawImageElement(Graphics2D g2d, PuzzleElementEntity element, Map<String, String> dynamicData) { public BufferedImage downloadImage(String imageUrl) throws IOException {
// 获取图片URL(优先使用动态数据)
String imageUrl = dynamicData.getOrDefault(element.getElementKey(), element.getDefaultImageUrl());
if (StrUtil.isBlank(imageUrl)) {
log.warn("图片元素没有图片URL: elementKey={}", element.getElementKey());
return;
}
try {
// 下载图片
BufferedImage image = downloadImage(imageUrl);
// 应用透明度
float opacity = (element.getOpacity() != null ? element.getOpacity() : 100) / 100f;
Composite originalComposite = g2d.getComposite();
if (opacity < 1.0f) {
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity));
}
// 缩放图片
BufferedImage scaledImage = scaleImage(image, element);
// 绘制(支持圆角)
Integer borderRadius = element.getBorderRadius() != null ? element.getBorderRadius() : 0;
if (borderRadius > 0) {
drawRoundedImage(g2d, scaledImage, element.getXPosition(), element.getYPosition(),
element.getWidth(), element.getHeight(), borderRadius);
} else {
// 直接绘制缩放后的图片,不再进行二次缩放
g2d.drawImage(scaledImage, element.getXPosition(), element.getYPosition(), null);
}
// 恢复透明度
g2d.setComposite(originalComposite);
} catch (Exception e) {
log.error("绘制图片元素失败: elementKey={}, imageUrl={}", element.getElementKey(), imageUrl, e);
}
}
/**
* 缩放图片
*/
private BufferedImage scaleImage(BufferedImage source, PuzzleElementEntity element) {
String fitMode = StrUtil.isNotBlank(element.getImageFitMode()) ? element.getImageFitMode() : "FILL";
int targetWidth = element.getWidth();
int targetHeight = element.getHeight();
switch (fitMode) {
case "COVER":
// 等比缩放填充(可能裁剪)- 使用原生Java缩放
return scaleImageKeepRatio(source, targetWidth, targetHeight, true);
case "CONTAIN":
// 等比缩放适应
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "SCALE_DOWN":
// 缩小适应(不放大)
if (source.getWidth() <= targetWidth && source.getHeight() <= targetHeight) {
return source;
}
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "FILL":
default:
// 拉伸填充到目标尺寸
BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
g.dispose();
return scaled;
}
}
/**
* 等比缩放图片
*/
private BufferedImage scaleImageKeepRatio(BufferedImage source, int targetWidth, int targetHeight, boolean cover) {
int sourceWidth = source.getWidth();
int sourceHeight = source.getHeight();
double widthRatio = (double) targetWidth / sourceWidth;
double heightRatio = (double) targetHeight / sourceHeight;
// cover模式使用较大的比例(填充),contain模式使用较小的比例(适应)
double ratio = cover ? Math.max(widthRatio, heightRatio) : Math.min(widthRatio, heightRatio);
int scaledWidth = (int) (sourceWidth * ratio);
int scaledHeight = (int) (sourceHeight * ratio);
// 创建目标尺寸的画布
BufferedImage result = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = result.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
// 居中绘制缩放后的图片
int x = (targetWidth - scaledWidth) / 2;
int y = (targetHeight - scaledHeight) / 2;
g.drawImage(source, x, y, scaledWidth, scaledHeight, null);
g.dispose();
return result;
}
/**
* 将Image转换为BufferedImage(已废弃,改用直接绘制)
*/
@Deprecated
private BufferedImage toBufferedImage(Image image, int width, int height) {
BufferedImage buffered = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = buffered.createGraphics();
// 居中绘制
int x = (width - image.getWidth(null)) / 2;
int y = (height - image.getHeight(null)) / 2;
g.drawImage(image, x, y, null);
g.dispose();
return buffered;
}
/**
* 绘制圆角图片
*/
private void drawRoundedImage(Graphics2D g2d, BufferedImage image, int x, int y, int width, int height, int radius) {
// 创建圆角遮罩
BufferedImage rounded = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = rounded.createGraphics();
enableHighQualityRendering(g);
// 绘制圆角矩形
g.setColor(Color.WHITE);
g.fill(new RoundRectangle2D.Float(0, 0, width, height, radius * 2, radius * 2));
// 应用遮罩
g.setComposite(AlphaComposite.SrcAtop);
g.drawImage(image, 0, 0, width, height, null);
g.dispose();
// 绘制到画布
g2d.drawImage(rounded, x, y, null);
}
/**
* 绘制文字元素
*/
private void drawTextElement(Graphics2D g2d, PuzzleElementEntity element, Map<String, String> dynamicData) {
// 获取文本内容(优先使用动态数据)
String text = dynamicData.getOrDefault(element.getElementKey(), element.getDefaultText());
if (StrUtil.isBlank(text)) {
log.debug("文字元素没有文本内容: elementKey={}", element.getElementKey());
return;
}
// 设置字体
int fontStyle = getFontStyle(element);
String fontFamily = StrUtil.isNotBlank(element.getFontFamily()) ? element.getFontFamily() : "微软雅黑";
int fontSize = element.getFontSize() != null ? element.getFontSize() : 14;
Font font = new Font(fontFamily, fontStyle, fontSize);
g2d.setFont(font);
// 设置颜色
String fontColor = StrUtil.isNotBlank(element.getFontColor()) ? element.getFontColor() : "#000000";
g2d.setColor(parseColor(fontColor));
// 设置透明度
float opacity = (element.getOpacity() != null ? element.getOpacity() : 100) / 100f;
Composite originalComposite = g2d.getComposite();
if (opacity < 1.0f) {
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity));
}
// 绘制文本
drawText(g2d, text, element);
// 恢复透明度
g2d.setComposite(originalComposite);
}
/**
* 绘制文本(支持对齐、行高、最大行数)
*/
private void drawText(Graphics2D g2d, String text, PuzzleElementEntity element) {
FontMetrics fm = g2d.getFontMetrics();
int lineHeight = (int) (fm.getHeight() * (element.getLineHeight() != null ? element.getLineHeight().floatValue() : 1.5f));
// 简单处理:绘制单行或多行文本
String[] lines = text.split("\n");
Integer maxLines = element.getMaxLines();
int actualLines = maxLines != null ? Math.min(lines.length, maxLines) : lines.length;
String textAlign = StrUtil.isNotBlank(element.getTextAlign()) ? element.getTextAlign() : "LEFT";
int y = element.getYPosition() + fm.getAscent();
for (int i = 0; i < actualLines; i++) {
String line = lines[i];
int x = calculateTextX(line, element, fm, textAlign);
g2d.drawString(line, x, y);
y += lineHeight;
}
}
/**
* 计算文本X坐标(根据对齐方式)
*/
private int calculateTextX(String text, PuzzleElementEntity element, FontMetrics fm, String align) {
int textWidth = fm.stringWidth(text);
switch (align) {
case "CENTER":
return element.getXPosition() + (element.getWidth() - textWidth) / 2;
case "RIGHT":
return element.getXPosition() + element.getWidth() - textWidth;
case "LEFT":
default:
return element.getXPosition();
}
}
/**
* 获取字体样式
*/
private int getFontStyle(PuzzleElementEntity element) {
int style = Font.PLAIN;
String fontWeight = StrUtil.isNotBlank(element.getFontWeight()) ? element.getFontWeight() : "NORMAL";
String fontStyleStr = StrUtil.isNotBlank(element.getFontStyle()) ? element.getFontStyle() : "NORMAL";
if ("BOLD".equalsIgnoreCase(fontWeight)) {
style |= Font.BOLD;
}
if ("ITALIC".equalsIgnoreCase(fontStyleStr)) {
style |= Font.ITALIC;
}
return style;
}
/**
* 下载图片
*/
private BufferedImage downloadImage(String imageUrl) throws IOException {
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) { if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
// 网络图片 // 网络图片
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl); byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
@@ -372,9 +150,9 @@ public class PuzzleImageRenderer {
} }
/** /**
* 解析颜色 * 解析颜色(工具方法,也可被外部使用)
*/ */
private Color parseColor(String colorStr) { public Color parseColor(String colorStr) {
try { try {
if (colorStr.startsWith("#")) { if (colorStr.startsWith("#")) {
return Color.decode(colorStr); return Color.decode(colorStr);

View File

@@ -3,13 +3,15 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.puzzle.mapper.PuzzleElementMapper"> <mapper namespace="com.ycwl.basic.puzzle.mapper.PuzzleElementMapper">
<!-- 结果映射 --> <!-- 结果映射(重构版) -->
<resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.entity.PuzzleElementEntity"> <resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.entity.PuzzleElementEntity">
<id column="id" property="id"/> <id column="id" property="id"/>
<result column="template_id" property="templateId"/> <result column="template_id" property="templateId"/>
<result column="element_type" property="elementType"/> <result column="element_type" property="elementType"/>
<result column="element_key" property="elementKey"/> <result column="element_key" property="elementKey"/>
<result column="element_name" property="elementName"/> <result column="element_name" property="elementName"/>
<result column="config" property="config"/>
<!-- 位置和布局属性 -->
<result column="x_position" property="xPosition"/> <result column="x_position" property="xPosition"/>
<result column="y_position" property="yPosition"/> <result column="y_position" property="yPosition"/>
<result column="width" property="width"/> <result column="width" property="width"/>
@@ -17,32 +19,17 @@
<result column="z_index" property="zIndex"/> <result column="z_index" property="zIndex"/>
<result column="rotation" property="rotation"/> <result column="rotation" property="rotation"/>
<result column="opacity" property="opacity"/> <result column="opacity" property="opacity"/>
<result column="default_image_url" property="defaultImageUrl"/> <!-- 元数据 -->
<result column="image_fit_mode" property="imageFitMode"/>
<result column="border_radius" property="borderRadius"/>
<result column="default_text" property="defaultText"/>
<result column="font_family" property="fontFamily"/>
<result column="font_size" property="fontSize"/>
<result column="font_color" property="fontColor"/>
<result column="font_weight" property="fontWeight"/>
<result column="font_style" property="fontStyle"/>
<result column="text_align" property="textAlign"/>
<result column="line_height" property="lineHeight"/>
<result column="max_lines" property="maxLines"/>
<result column="text_decoration" property="textDecoration"/>
<result column="create_time" property="createTime"/> <result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/> <result column="update_time" property="updateTime"/>
<result column="deleted" property="deleted"/> <result column="deleted" property="deleted"/>
<result column="deleted_at" property="deletedAt"/> <result column="deleted_at" property="deletedAt"/>
</resultMap> </resultMap>
<!-- 基础列 --> <!-- 基础列(重构版) -->
<sql id="Base_Column_List"> <sql id="Base_Column_List">
id, template_id, element_type, element_key, element_name, id, template_id, element_type, element_key, element_name, config,
x_position, y_position, width, height, z_index, rotation, opacity, x_position, y_position, width, height, z_index, rotation, opacity,
default_image_url, image_fit_mode, border_radius,
default_text, font_family, font_size, font_color, font_weight, font_style,
text_align, line_height, max_lines, text_decoration,
create_time, update_time, deleted, deleted_at create_time, update_time, deleted, deleted_at
</sql> </sql>
@@ -62,55 +49,44 @@
ORDER BY z_index ASC, id ASC ORDER BY z_index ASC, id ASC
</select> </select>
<!-- 插入 --> <!-- 插入(重构版) -->
<insert id="insert" parameterType="com.ycwl.basic.puzzle.entity.PuzzleElementEntity" <insert id="insert" parameterType="com.ycwl.basic.puzzle.entity.PuzzleElementEntity"
useGeneratedKeys="true" keyProperty="id"> useGeneratedKeys="true" keyProperty="id">
INSERT INTO puzzle_element ( INSERT INTO puzzle_element (
template_id, element_type, element_key, element_name, template_id, element_type, element_key, element_name, config,
x_position, y_position, width, height, z_index, rotation, opacity, x_position, y_position, width, height, z_index, rotation, opacity,
default_image_url, image_fit_mode, border_radius,
default_text, font_family, font_size, font_color, font_weight, font_style,
text_align, line_height, max_lines, text_decoration,
create_time, update_time, deleted create_time, update_time, deleted
) VALUES ( ) VALUES (
#{templateId}, #{elementType}, #{elementKey}, #{elementName}, #{templateId}, #{elementType}, #{elementKey}, #{elementName}, #{config},
#{xPosition}, #{yPosition}, #{width}, #{height}, #{zIndex}, #{rotation}, #{opacity}, #{xPosition}, #{yPosition}, #{width}, #{height}, #{zIndex}, #{rotation}, #{opacity},
#{defaultImageUrl}, #{imageFitMode}, #{borderRadius},
#{defaultText}, #{fontFamily}, #{fontSize}, #{fontColor}, #{fontWeight}, #{fontStyle},
#{textAlign}, #{lineHeight}, #{maxLines}, #{textDecoration},
NOW(), NOW(), 0 NOW(), NOW(), 0
) )
</insert> </insert>
<!-- 批量插入 --> <!-- 批量插入(重构版) -->
<insert id="batchInsert"> <insert id="batchInsert">
INSERT INTO puzzle_element ( INSERT INTO puzzle_element (
template_id, element_type, element_key, element_name, template_id, element_type, element_key, element_name, config,
x_position, y_position, width, height, z_index, rotation, opacity, x_position, y_position, width, height, z_index, rotation, opacity,
default_image_url, image_fit_mode, border_radius,
default_text, font_family, font_size, font_color, font_weight, font_style,
text_align, line_height, max_lines, text_decoration,
create_time, update_time, deleted create_time, update_time, deleted
) VALUES ) VALUES
<foreach collection="list" item="item" separator=","> <foreach collection="list" item="item" separator=",">
( (
#{item.templateId}, #{item.elementType}, #{item.elementKey}, #{item.elementName}, #{item.templateId}, #{item.elementType}, #{item.elementKey}, #{item.elementName}, #{item.config},
#{item.xPosition}, #{item.yPosition}, #{item.width}, #{item.height}, #{item.zIndex}, #{item.rotation}, #{item.opacity}, #{item.xPosition}, #{item.yPosition}, #{item.width}, #{item.height}, #{item.zIndex}, #{item.rotation}, #{item.opacity},
#{item.defaultImageUrl}, #{item.imageFitMode}, #{item.borderRadius},
#{item.defaultText}, #{item.fontFamily}, #{item.fontSize}, #{item.fontColor}, #{item.fontWeight}, #{item.fontStyle},
#{item.textAlign}, #{item.lineHeight}, #{item.maxLines}, #{item.textDecoration},
NOW(), NOW(), 0 NOW(), NOW(), 0
) )
</foreach> </foreach>
</insert> </insert>
<!-- 更新 --> <!-- 更新(重构版) -->
<update id="update" parameterType="com.ycwl.basic.puzzle.entity.PuzzleElementEntity"> <update id="update" parameterType="com.ycwl.basic.puzzle.entity.PuzzleElementEntity">
UPDATE puzzle_element UPDATE puzzle_element
<set> <set>
<if test="elementType != null">element_type = #{elementType},</if> <if test="elementType != null">element_type = #{elementType},</if>
<if test="elementKey != null">element_key = #{elementKey},</if> <if test="elementKey != null">element_key = #{elementKey},</if>
<if test="elementName != null">element_name = #{elementName},</if> <if test="elementName != null">element_name = #{elementName},</if>
<if test="config != null">config = #{config},</if>
<if test="xPosition != null">x_position = #{xPosition},</if> <if test="xPosition != null">x_position = #{xPosition},</if>
<if test="yPosition != null">y_position = #{yPosition},</if> <if test="yPosition != null">y_position = #{yPosition},</if>
<if test="width != null">width = #{width},</if> <if test="width != null">width = #{width},</if>
@@ -118,19 +94,6 @@
<if test="zIndex != null">z_index = #{zIndex},</if> <if test="zIndex != null">z_index = #{zIndex},</if>
<if test="rotation != null">rotation = #{rotation},</if> <if test="rotation != null">rotation = #{rotation},</if>
<if test="opacity != null">opacity = #{opacity},</if> <if test="opacity != null">opacity = #{opacity},</if>
<if test="defaultImageUrl != null">default_image_url = #{defaultImageUrl},</if>
<if test="imageFitMode != null">image_fit_mode = #{imageFitMode},</if>
<if test="borderRadius != null">border_radius = #{borderRadius},</if>
<if test="defaultText != null">default_text = #{defaultText},</if>
<if test="fontFamily != null">font_family = #{fontFamily},</if>
<if test="fontSize != null">font_size = #{fontSize},</if>
<if test="fontColor != null">font_color = #{fontColor},</if>
<if test="fontWeight != null">font_weight = #{fontWeight},</if>
<if test="fontStyle != null">font_style = #{fontStyle},</if>
<if test="textAlign != null">text_align = #{textAlign},</if>
<if test="lineHeight != null">line_height = #{lineHeight},</if>
<if test="maxLines != null">max_lines = #{maxLines},</if>
<if test="textDecoration != null">text_decoration = #{textDecoration},</if>
update_time = NOW() update_time = NOW()
</set> </set>
WHERE id = #{id} AND deleted = 0 WHERE id = #{id} AND deleted = 0

View File

@@ -0,0 +1,99 @@
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.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
/**
* ElementFactory 单元测试
*
* @author Claude
* @since 2025-01-18
*/
@SpringBootTest
class ElementFactoryTest {
@BeforeAll
static void setUp() {
// 确保Element已注册(Spring会自动调用ElementRegistrar)
ElementFactory.register(ElementType.TEXT, TextElement.class);
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
}
@Test
void testCreateTextElement_Success() {
// Given
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
1L, "userName", 100, 200, 300, 50, 10,
"测试文字", 24, "#333333"
);
// When
BaseElement element = ElementFactory.create(entity);
// Then
assertNotNull(element);
assertInstanceOf(TextElement.class, element);
assertEquals(ElementType.TEXT, element.getElementType());
assertEquals("userName", element.getElementKey());
assertEquals(100, element.getPosition().getX());
assertEquals(200, element.getPosition().getY());
assertEquals(300, element.getPosition().getWidth());
assertEquals(50, element.getPosition().getHeight());
}
@Test
void testCreateImageElement_Success() {
// Given
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
1L, "userAvatar", 50, 100, 100, 100, 5,
"https://example.com/avatar.jpg"
);
// When
BaseElement element = ElementFactory.create(entity);
// Then
assertNotNull(element);
assertInstanceOf(ImageElement.class, element);
assertEquals(ElementType.IMAGE, element.getElementType());
assertEquals("userAvatar", element.getElementKey());
}
@Test
void testCreateElement_InvalidType() {
// Given
PuzzleElementEntity entity = new PuzzleElementEntity();
entity.setElementType("INVALID_TYPE");
entity.setElementKey("test");
entity.setConfig("{}");
// When & Then
assertThrows(IllegalArgumentException.class, () -> ElementFactory.create(entity));
}
@Test
void testCreateElement_NullConfig() {
// Given
PuzzleElementEntity entity = new PuzzleElementEntity();
entity.setElementType("TEXT");
entity.setElementKey("test");
entity.setConfig(null);
entity.setXPosition(0);
entity.setYPosition(0);
entity.setWidth(100);
entity.setHeight(50);
// When & Then
assertThrows(IllegalArgumentException.class, () -> ElementFactory.create(entity));
}
}

View File

@@ -0,0 +1,124 @@
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.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.BeforeEach;
import org.junit.jupiter.api.Test;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* ImageElement 单元测试
*
* @author Claude
* @since 2025-01-18
*/
class ImageElementTest {
private Graphics2D graphics;
private RenderContext context;
@BeforeEach
void setUp() {
BufferedImage canvas = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB);
graphics = canvas.createGraphics();
Map<String, String> dynamicData = new HashMap<>();
context = new RenderContext(graphics, dynamicData, 800, 600);
}
@Test
void testImageElement_Creation_Success() {
// Given
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
1L, "userAvatar", 50, 100, 100, 100, 5,
"https://example.com/avatar.jpg"
);
// When
BaseElement element = ElementFactory.create(entity);
// Then
assertNotNull(element);
assertEquals("userAvatar", element.getElementKey());
}
@Test
void testImageElement_RoundedImage_Success() {
// Given
PuzzleElementEntity entity = PuzzleTestDataBuilder.createRoundedImageElement(
1L, "userAvatar", 50, 100, 100, 100, 5,
"https://example.com/avatar.jpg", 50
);
// When
BaseElement element = ElementFactory.create(entity);
// Then
assertNotNull(element);
// 验证配置包含圆角信息
String schema = element.getConfigSchema();
assertTrue(schema.contains("borderRadius"));
}
@Test
void testImageElement_InvalidConfig_MissingDefaultImageUrl() {
// Given
PuzzleElementEntity entity = new PuzzleElementEntity();
entity.setElementType("IMAGE");
entity.setElementKey("testKey");
entity.setConfig("{\"imageFitMode\":\"FILL\"}"); // 缺少 defaultImageUrl
entity.setXPosition(0);
entity.setYPosition(0);
entity.setWidth(100);
entity.setHeight(100);
// When & Then
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
}
@Test
void testImageElement_InvalidConfig_InvalidBorderRadius() {
// Given
PuzzleElementEntity entity = new PuzzleElementEntity();
entity.setElementType("IMAGE");
entity.setElementKey("testKey");
entity.setConfig("{\"defaultImageUrl\":\"test.jpg\",\"borderRadius\":-1}"); // 非法圆角
entity.setXPosition(0);
entity.setYPosition(0);
entity.setWidth(100);
entity.setHeight(100);
// When & Then
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
}
@Test
void testImageElement_GetConfigSchema() {
// Given
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
1L, "userAvatar", 50, 100, 100, 100, 5,
"https://example.com/avatar.jpg"
);
BaseElement element = ElementFactory.create(entity);
// When
String schema = element.getConfigSchema();
// Then
assertNotNull(schema);
assertTrue(schema.contains("defaultImageUrl"));
assertTrue(schema.contains("imageFitMode"));
assertTrue(schema.contains("borderRadius"));
}
}

View File

@@ -0,0 +1,107 @@
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.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.BeforeEach;
import org.junit.jupiter.api.Test;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* TextElement 单元测试
*
* @author Claude
* @since 2025-01-18
*/
class TextElementTest {
private Graphics2D graphics;
private RenderContext context;
@BeforeEach
void setUp() {
BufferedImage canvas = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB);
graphics = canvas.createGraphics();
Map<String, String> dynamicData = new HashMap<>();
context = new RenderContext(graphics, dynamicData, 800, 600);
}
@Test
void testTextElement_Render_Success() {
// Given
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
1L, "userName", 100, 200, 300, 50, 10,
"默认文字", 24, "#333333"
);
BaseElement element = ElementFactory.create(entity);
// 添加动态数据
context.getDynamicData().put("userName", "张三");
// When & Then (不抛出异常即为成功)
assertDoesNotThrow(() -> element.render(context));
}
@Test
void testTextElement_InvalidConfig_MissingDefaultText() {
// Given
PuzzleElementEntity entity = new PuzzleElementEntity();
entity.setElementType("TEXT");
entity.setElementKey("testKey");
entity.setConfig("{\"fontSize\":14,\"fontColor\":\"#000000\"}"); // 缺少 defaultText
entity.setXPosition(0);
entity.setYPosition(0);
entity.setWidth(100);
entity.setHeight(50);
// When & Then
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
}
@Test
void testTextElement_InvalidConfig_InvalidFontSize() {
// Given
PuzzleElementEntity entity = new PuzzleElementEntity();
entity.setElementType("TEXT");
entity.setElementKey("testKey");
entity.setConfig("{\"defaultText\":\"test\",\"fontSize\":0,\"fontColor\":\"#000000\"}"); // 非法字号
entity.setXPosition(0);
entity.setYPosition(0);
entity.setWidth(100);
entity.setHeight(50);
// When & Then
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
}
@Test
void testTextElement_GetConfigSchema() {
// Given
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
1L, "userName", 100, 200, 300, 50, 10,
"测试文字", 24, "#333333"
);
BaseElement element = ElementFactory.create(entity);
// When
String schema = element.getConfigSchema();
// Then
assertNotNull(schema);
assertTrue(schema.contains("defaultText"));
assertTrue(schema.contains("fontSize"));
assertTrue(schema.contains("fontColor"));
}
}

View File

@@ -0,0 +1,103 @@
package com.ycwl.basic.puzzle.integration;
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.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Element 调试测试
* 用于验证 Element 创建和配置是否正确
*/
class ElementDebugTest {
@BeforeAll
static void registerElements() {
ElementFactory.register(ElementType.TEXT, TextElement.class);
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
}
@Test
void testCreateTextElement() {
System.out.println("=== 测试创建 TEXT 元素 ===");
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
1L, "testText", 100, 200, 300, 50, 10,
"测试文字", 24, "#333333"
);
System.out.println("Entity elementType: " + entity.getElementType());
System.out.println("Entity config: " + entity.getConfig());
try {
BaseElement element = ElementFactory.create(entity);
System.out.println("✅ Element 创建成功");
System.out.println("Element type: " + element.getElementType());
System.out.println("Element key: " + element.getElementKey());
assertNotNull(element);
} catch (Exception e) {
System.err.println("❌ Element 创建失败: " + e.getMessage());
e.printStackTrace();
fail("Element 创建失败");
}
}
@Test
void testCreateImageElement() {
System.out.println("\n=== 测试创建 IMAGE 元素 ===");
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
1L, "testImage", 50, 100, 100, 100, 5,
"https://example.com/test.jpg"
);
System.out.println("Entity elementType: " + entity.getElementType());
System.out.println("Entity config: " + entity.getConfig());
try {
BaseElement element = ElementFactory.create(entity);
System.out.println("✅ Element 创建成功");
System.out.println("Element type: " + element.getElementType());
System.out.println("Element key: " + element.getElementKey());
assertNotNull(element);
} catch (Exception e) {
System.err.println("❌ Element 创建失败: " + e.getMessage());
e.printStackTrace();
fail("Element 创建失败");
}
}
@Test
void testRealScenarioElements() {
System.out.println("\n=== 测试现实场景元素创建 ===");
var elements = PuzzleTestDataBuilder.createRealScenarioElements(1L);
System.out.println("元素数量: " + elements.size());
int successCount = 0;
for (PuzzleElementEntity entity : elements) {
System.out.println("\n--- 测试元素: " + entity.getElementKey() + " ---");
System.out.println("Type: " + entity.getElementType());
System.out.println("Config: " + entity.getConfig());
try {
BaseElement element = ElementFactory.create(entity);
System.out.println("✅ 创建成功");
successCount++;
} catch (Exception e) {
System.err.println("❌ 创建失败: " + e.getMessage());
e.printStackTrace();
}
}
System.out.println("\n总结: " + successCount + "/" + elements.size() + " 个元素创建成功");
assertEquals(elements.size(), successCount, "所有元素都应该创建成功");
}
}

View File

@@ -1,11 +1,15 @@
package com.ycwl.basic.puzzle.integration; package com.ycwl.basic.puzzle.integration;
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.puzzle.entity.PuzzleTemplateEntity; import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.test.MockImageUtil; import com.ycwl.basic.puzzle.test.MockImageUtil;
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder; import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer; import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import org.apache.commons.lang3.Strings;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
@@ -18,6 +22,7 @@ import java.nio.file.Path;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -102,6 +107,18 @@ class PuzzleRealScenarioIntegrationTest {
dynamicData.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath()); dynamicData.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
dynamicData.put("bottomText", "奇遇时光乐园\n2025.11.11"); dynamicData.put("bottomText", "奇遇时光乐园\n2025.11.11");
// 打印调试信息
System.out.println("\n=== 调试信息 ===");
System.out.println("元素数量: " + elements.size());
for (PuzzleElementEntity element : elements) {
System.out.println("元素: " + element.getElementKey() +
" | 类型: " + element.getElementType() +
" | 配置: " + element.getConfig());
}
System.out.println("\n动态数据:");
dynamicData.forEach((k, v) -> System.out.println(" " + k + " = " + v));
System.out.println("=================\n");
// When: 渲染拼图 // When: 渲染拼图
BufferedImage result = renderer.render(template, elements, dynamicData); BufferedImage result = renderer.render(template, elements, dynamicData);
@@ -192,17 +209,6 @@ class PuzzleRealScenarioIntegrationTest {
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId()); List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
// 设置默认图片路径
for (PuzzleElementEntity element : elements) {
if (element.getElementType() == 1) {
// 图片元素使用本地文件
String key = element.getElementKey();
if (mockImageFiles.containsKey(key)) {
element.setDefaultImageUrl(mockImageFiles.get(key).getAbsolutePath());
}
}
}
// When: 不传动态数据,使用默认值 // When: 不传动态数据,使用默认值
Map<String, String> dynamicData = new HashMap<>(); Map<String, String> dynamicData = new HashMap<>();
BufferedImage result = renderer.render(template, elements, dynamicData); BufferedImage result = renderer.render(template, elements, dynamicData);
@@ -297,4 +303,17 @@ class PuzzleRealScenarioIntegrationTest {
data.put("bottomText", "测试文字内容"); data.put("bottomText", "测试文字内容");
return data; return data;
} }
@BeforeAll
static void registerElements() {
// 注册 Element 类型(确保工厂可用)
com.ycwl.basic.puzzle.element.base.ElementFactory.register(
com.ycwl.basic.puzzle.element.enums.ElementType.TEXT,
com.ycwl.basic.puzzle.element.impl.TextElement.class
);
com.ycwl.basic.puzzle.element.base.ElementFactory.register(
com.ycwl.basic.puzzle.element.enums.ElementType.IMAGE,
com.ycwl.basic.puzzle.element.impl.ImageElement.class
);
}
} }

View File

@@ -60,7 +60,7 @@ public class PuzzleTestDataBuilder {
PuzzleElementEntity element = new PuzzleElementEntity(); PuzzleElementEntity element = new PuzzleElementEntity();
element.setId(System.currentTimeMillis()); element.setId(System.currentTimeMillis());
element.setTemplateId(templateId); element.setTemplateId(templateId);
element.setElementType(1); // 图片 element.setElementType("IMAGE"); // 修改为字符串类型
element.setElementKey(elementKey); element.setElementKey(elementKey);
element.setElementName(elementKey); element.setElementName(elementKey);
element.setXPosition(x); element.setXPosition(x);
@@ -70,9 +70,11 @@ public class PuzzleTestDataBuilder {
element.setZIndex(zIndex); element.setZIndex(zIndex);
element.setRotation(0); element.setRotation(0);
element.setOpacity(100); element.setOpacity(100);
element.setDefaultImageUrl(imageUrl);
element.setImageFitMode("FILL"); // 使用FILL模式确保图片完全填充区域 // 使用JSON配置
element.setBorderRadius(0); String config = String.format("{\"defaultImageUrl\":\"%s\",\"imageFitMode\":\"FILL\",\"borderRadius\":0}", imageUrl);
element.setConfig(config);
element.setCreateTime(new Date()); element.setCreateTime(new Date());
element.setUpdateTime(new Date()); element.setUpdateTime(new Date());
element.setDeleted(0); element.setDeleted(0);
@@ -85,8 +87,28 @@ public class PuzzleTestDataBuilder {
public static PuzzleElementEntity createRoundedImageElement(Long templateId, String elementKey, public static PuzzleElementEntity createRoundedImageElement(Long templateId, String elementKey,
int x, int y, int width, int height, int x, int y, int width, int height,
int zIndex, String imageUrl, int borderRadius) { int zIndex, String imageUrl, int borderRadius) {
PuzzleElementEntity element = createImageElement(templateId, elementKey, x, y, width, height, zIndex, imageUrl); PuzzleElementEntity element = new PuzzleElementEntity();
element.setBorderRadius(borderRadius); element.setId(System.currentTimeMillis());
element.setTemplateId(templateId);
element.setElementType("IMAGE");
element.setElementKey(elementKey);
element.setElementName(elementKey);
element.setXPosition(x);
element.setYPosition(y);
element.setWidth(width);
element.setHeight(height);
element.setZIndex(zIndex);
element.setRotation(0);
element.setOpacity(100);
// 使用JSON配置(包含圆角)
String config = String.format("{\"defaultImageUrl\":\"%s\",\"imageFitMode\":\"COVER\",\"borderRadius\":%d}",
imageUrl, borderRadius);
element.setConfig(config);
element.setCreateTime(new Date());
element.setUpdateTime(new Date());
element.setDeleted(0);
return element; return element;
} }
@@ -100,7 +122,7 @@ public class PuzzleTestDataBuilder {
PuzzleElementEntity element = new PuzzleElementEntity(); PuzzleElementEntity element = new PuzzleElementEntity();
element.setId(System.currentTimeMillis()); element.setId(System.currentTimeMillis());
element.setTemplateId(templateId); element.setTemplateId(templateId);
element.setElementType(2); // 文字 element.setElementType("TEXT"); // 修改为字符串类型
element.setElementKey(elementKey); element.setElementKey(elementKey);
element.setElementName(elementKey); element.setElementName(elementKey);
element.setXPosition(x); element.setXPosition(x);
@@ -110,16 +132,15 @@ public class PuzzleTestDataBuilder {
element.setZIndex(zIndex); element.setZIndex(zIndex);
element.setRotation(0); element.setRotation(0);
element.setOpacity(100); element.setOpacity(100);
element.setDefaultText(defaultText);
element.setFontFamily("微软雅黑"); // 使用JSON配置
element.setFontSize(fontSize); String config = String.format(
element.setFontColor(fontColor); "{\"defaultText\":\"%s\",\"fontFamily\":\"微软雅黑\",\"fontSize\":%d,\"fontColor\":\"%s\"," +
element.setFontWeight("NORMAL"); "\"fontWeight\":\"NORMAL\",\"fontStyle\":\"NORMAL\",\"textAlign\":\"LEFT\"," +
element.setFontStyle("NORMAL"); "\"lineHeight\":1.5,\"textDecoration\":\"NONE\"}",
element.setTextAlign("LEFT"); defaultText, fontSize, fontColor);
element.setLineHeight(BigDecimal.valueOf(1.5)); element.setConfig(config);
element.setMaxLines(null);
element.setTextDecoration("NONE");
element.setCreateTime(new Date()); element.setCreateTime(new Date());
element.setUpdateTime(new Date()); element.setUpdateTime(new Date());
element.setDeleted(0); element.setDeleted(0);
@@ -133,9 +154,31 @@ public class PuzzleTestDataBuilder {
int x, int y, int width, int height, int x, int y, int width, int height,
int zIndex, String defaultText, int zIndex, String defaultText,
int fontSize, String fontColor, String textAlign) { int fontSize, String fontColor, String textAlign) {
PuzzleElementEntity element = createTextElement(templateId, elementKey, x, y, width, height, zIndex, defaultText, fontSize, fontColor); PuzzleElementEntity element = new PuzzleElementEntity();
element.setFontWeight("BOLD"); element.setId(System.currentTimeMillis());
element.setTextAlign(textAlign); element.setTemplateId(templateId);
element.setElementType("TEXT");
element.setElementKey(elementKey);
element.setElementName(elementKey);
element.setXPosition(x);
element.setYPosition(y);
element.setWidth(width);
element.setHeight(height);
element.setZIndex(zIndex);
element.setRotation(0);
element.setOpacity(100);
// 使用JSON配置(粗体)
String config = String.format(
"{\"defaultText\":\"%s\",\"fontFamily\":\"微软雅黑\",\"fontSize\":%d,\"fontColor\":\"%s\"," +
"\"fontWeight\":\"BOLD\",\"fontStyle\":\"NORMAL\",\"textAlign\":\"%s\"," +
"\"lineHeight\":1.5,\"textDecoration\":\"NONE\"}",
defaultText, fontSize, fontColor, textAlign);
element.setConfig(config);
element.setCreateTime(new Date());
element.setUpdateTime(new Date());
element.setDeleted(0);
return element; return element;
} }
@@ -175,9 +218,9 @@ public class PuzzleTestDataBuilder {
public static List<PuzzleElementEntity> createRealScenarioElements(Long templateId) { public static List<PuzzleElementEntity> createRealScenarioElements(Long templateId) {
List<PuzzleElementEntity> elements = new ArrayList<>(); List<PuzzleElementEntity> elements = new ArrayList<>();
// 画布总高度1520,每张图片460高度 // 画布总高度1520,每张图片470高度
int imageHeight = 460; int imageHeight = 470;
int bottomHeight = 140; // 剩余高度:1520 - 460*3 = 140 int bottomHeight = 110; // 剩余高度:1520 - 470*3 = 110
// 上方3张图片,每张470高度 // 上方3张图片,每张470高度
elements.add(createImageElement(templateId, "image1", 0, 0, 1020, imageHeight, 1, elements.add(createImageElement(templateId, "image1", 0, 0, 1020, imageHeight, 1,
@@ -190,17 +233,50 @@ public class PuzzleTestDataBuilder {
"https://example.com/image3.jpg")); "https://example.com/image3.jpg"));
// 底部区域起始Y坐标 // 底部区域起始Y坐标
int bottomY = imageHeight * 3; // 1380 int bottomY = imageHeight * 3; // 1410
// 底部二维码(左侧,占满底部高度) // 底部二维码(左侧,占满底部高度)
elements.add(createImageElement(templateId, "qrCode", 20, bottomY + 10, 120, 120, 2, PuzzleElementEntity qrElement = new PuzzleElementEntity();
"https://example.com/qrcode.png")); qrElement.setId(System.currentTimeMillis());
qrElement.setTemplateId(templateId);
qrElement.setElementType("IMAGE");
qrElement.setElementKey("qrCode");
qrElement.setElementName("qrCode");
qrElement.setXPosition(20);
qrElement.setYPosition(bottomY + 5);
qrElement.setWidth(100);
qrElement.setHeight(100);
qrElement.setZIndex(2);
qrElement.setRotation(0);
qrElement.setOpacity(100);
qrElement.setConfig("{\"defaultImageUrl\":\"https://example.com/qrcode.png\",\"imageFitMode\":\"CONTAIN\",\"borderRadius\":0}");
qrElement.setCreateTime(new Date());
qrElement.setUpdateTime(new Date());
qrElement.setDeleted(0);
elements.add(qrElement);
// 底部文字(右侧,占满剩余宽度和高度) // 底部文字(右侧,占满剩余宽度和高度)
int textX = 140; // 二维码右侧留20像素间距 int textX = 140; // 二维码右侧留20像素间距
int textWidth = 1020 - textX - 20; // 右侧留20像素边距 int textWidth = 1020 - textX - 20; // 右侧留20像素边距
elements.add(createBoldTextElement(templateId, "bottomText", textX, bottomY + 20, textWidth, bottomHeight - 20, 2,
"扫码查看详情", 18, "#333333", "RIGHT")); PuzzleElementEntity textElement = new PuzzleElementEntity();
textElement.setId(System.currentTimeMillis() + 1);
textElement.setTemplateId(templateId);
textElement.setElementType("TEXT");
textElement.setElementKey("bottomText");
textElement.setElementName("bottomText");
textElement.setXPosition(textX);
textElement.setYPosition(bottomY + 10);
textElement.setWidth(textWidth);
textElement.setHeight(bottomHeight - 10);
textElement.setZIndex(2);
textElement.setRotation(0);
textElement.setOpacity(100);
textElement.setConfig("{\"defaultText\":\"扫码查看详情\",\"fontFamily\":\"微软雅黑\",\"fontSize\":18,\"fontColor\":\"#333333\",\"fontWeight\":\"BOLD\",\"textAlign\":\"RIGHT\",\"lineHeight\":1.5,\"textDecoration\":\"NONE\"}");
textElement.setCreateTime(new Date());
textElement.setUpdateTime(new Date());
textElement.setDeleted(0);
elements.add(textElement);
return elements; return elements;
} }

View File

@@ -1,5 +1,9 @@
package com.ycwl.basic.puzzle.test; package com.ycwl.basic.puzzle.test;
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.entity.PuzzleElementEntity; import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity; import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer; import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
@@ -56,7 +60,7 @@ public class RealScenarioTestHelper {
* 创建场景图片(1020x470) * 创建场景图片(1020x470)
*/ */
private void createSceneImage(String key, String text, Color bgColor) throws IOException { private void createSceneImage(String key, String text, Color bgColor) throws IOException {
BufferedImage image = MockImageUtil.createImageWithText(1020, 460, text, bgColor, Color.BLACK); BufferedImage image = MockImageUtil.createImageWithText(1020, 470, text, bgColor, Color.BLACK);
File file = saveImage(image, key + ".jpg"); File file = saveImage(image, key + ".jpg");
resourceFiles.put(key, file); resourceFiles.put(key, file);
} }
@@ -224,6 +228,10 @@ public class RealScenarioTestHelper {
* 快速测试方法(主方法,可直接运行) * 快速测试方法(主方法,可直接运行)
*/ */
public static void main(String[] args) throws IOException { public static void main(String[] args) throws IOException {
// 【重要】注册 Element 类型(主方法运行必须先注册)
ElementFactory.register(ElementType.TEXT, TextElement.class);
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
// 创建临时目录 // 创建临时目录
Path tempDir = Files.createTempDirectory("puzzle_test_"); Path tempDir = Files.createTempDirectory("puzzle_test_");
System.out.println("📂 测试目录: " + tempDir.toAbsolutePath()); System.out.println("📂 测试目录: " + tempDir.toAbsolutePath());