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>
<testExclude>**/*Test.java</testExclude>
</testExcludes>
<source>21</source>
<target>21</target>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
</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 java.math.BigDecimal;
import java.util.Map;
/**
* 创建元素请求DTO
* 创建元素请求DTO(重构版)
*
* 重构说明:
* - elementType从Integer改为String(TEXT、IMAGE等)
* - 删除所有type-specific字段
* - 新增config和configMap支持JSON配置
* - 支持两种方式:直接传JSON字符串 或 传Map对象
*
* @author Claude
* @since 2025-01-17
* @since 2025-01-18
*/
@Data
public class ElementCreateRequest {
@@ -19,123 +25,76 @@ public class ElementCreateRequest {
private Long templateId;
/**
* 元素类型:1-图片 2-文字
* 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
*/
private Integer elementType;
private String elementType;
/**
* 元素标识
* 元素标识(用于动态数据映射)
*/
private String elementKey;
/**
* 元素名称
* 元素名称(便于管理识别)
*/
private String elementName;
// ===== 位置和布局属性 =====
// ===== 位置和布局属性(所有元素通用) =====
/**
* X坐标
* X坐标(相对于画布左上角,像素)
*/
private Integer xPosition;
/**
* Y坐标
* Y坐标(相对于画布左上角,像素)
*/
private Integer yPosition;
/**
* 宽度
* 宽度(像素)
*/
private Integer width;
/**
* 高度
* 高度(像素)
*/
private Integer height;
/**
* 层级
* 层级(数值越大越靠上)
*/
private Integer zIndex;
/**
* 旋转角度
* 旋转角度(0-360度,顺时针)
*/
private Integer rotation;
/**
* 不透明度
* 不透明度(0-100,100为完全不透明)
*/
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 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;
private Map<String, Object> configMap;
}

View File

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

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 lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 拼图元素实体类
* 拼图元素实体类(重构版)
* 对应表:puzzle_element
*
* 重构说明:
* - element_type 从TINYINT改为VARCHAR,支持TEXT、IMAGE、QRCODE等类型
* - 删除所有type-specific字段,改用JSON配置存储
* - 通过config字段存储元素特定配置,支持灵活扩展
*
* @author Claude
* @since 2025-01-17
* @since 2025-01-18
*/
@Data
@TableName("puzzle_element")
@@ -33,13 +37,13 @@ public class PuzzleElementEntity {
private Long templateId;
/**
* 元素类型:1-图片 2-文字
* 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
*/
@TableField("element_type")
private Integer elementType;
private String elementType;
/**
* 元素标识(用于动态数据替换
* 元素标识(用于动态数据映射
*/
@TableField("element_key")
private String elementKey;
@@ -50,16 +54,26 @@ public class PuzzleElementEntity {
@TableField("element_name")
private String elementName;
// ===== 位置和布局属性 =====
/**
* JSON配置(元素特定配置)
*
* 示例:
* - 文字元素:{"defaultText":"用户名", "fontFamily":"微软雅黑", "fontSize":14, ...}
* - 图片元素:{"defaultImageUrl":"https://...", "imageFitMode":"COVER", "borderRadius":10, ...}
*/
@TableField("config")
private String config;
// ===== 位置和布局属性(所有元素通用) =====
/**
* X坐标(相对于画布左上角)
* X坐标(相对于画布左上角,像素
*/
@TableField("x_position")
private Integer xPosition;
/**
* Y坐标(相对于画布左上角)
* Y坐标(相对于画布左上角,像素
*/
@TableField("y_position")
private Integer yPosition;
@@ -77,7 +91,7 @@ public class PuzzleElementEntity {
private Integer height;
/**
* 层级(数值越大越靠上)
* 层级(数值越大越靠上,决定绘制顺序
*/
@TableField("z_index")
private Integer zIndex;
@@ -94,87 +108,7 @@ public class PuzzleElementEntity {
@TableField("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;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.util.ElementConfigHelper;
import cn.hutool.core.bean.BeanUtil;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
@@ -137,18 +139,24 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
public Long addElement(ElementCreateRequest request) {
log.info("添加元素到模板: templateId={}, elementKey={}", request.getTemplateId(), request.getElementKey());
// 检查模板是否存在
// 1. 验证请求
ElementConfigHelper.validateRequest(request);
// 2. 检查模板是否存在
PuzzleTemplateEntity template = templateMapper.getById(request.getTemplateId());
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + request.getTemplateId());
}
// 转换为实体并插入
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class);
// 3. 转换为Entity(使用Helper)
PuzzleElementEntity entity = ElementConfigHelper.toEntity(request);
entity.setDeleted(0);
// 4. 插入数据库
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();
}
@@ -157,22 +165,23 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
public void batchAddElements(Long templateId, List<ElementCreateRequest> elements) {
log.info("批量添加元素到模板: templateId={}, count={}", templateId, elements.size());
// 检查模板是否存在
// 1. 校验模板
PuzzleTemplateEntity template = templateMapper.getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + templateId);
}
// 转换为实体列表
List<PuzzleElementEntity> entityList = new ArrayList<>();
for (ElementCreateRequest request : elements) {
request.setTemplateId(templateId);
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class);
entity.setDeleted(0);
entityList.add(entity);
}
// 2. 批量转换
List<PuzzleElementEntity> entityList = elements.stream()
.peek(req -> {
req.setTemplateId(templateId);
ElementConfigHelper.validateRequest(req);
})
.map(ElementConfigHelper::toEntity)
.peek(entity -> entity.setDeleted(0))
.collect(Collectors.toList());
// 批量插入
// 3. 批量插入
if (!entityList.isEmpty()) {
elementMapper.batchInsert(entityList);
log.info("批量添加元素成功: templateId={}, count={}", templateId, entityList.size());
@@ -184,14 +193,17 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
public void updateElement(Long id, ElementCreateRequest request) {
log.info("更新元素: id={}", id);
// 检查元素是否存在
// 1. 校验元素存在
PuzzleElementEntity existing = elementMapper.getById(id);
if (existing == null) {
throw new IllegalArgumentException("元素不存在: " + id);
}
// 更新
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class);
// 2. 验证请求
ElementConfigHelper.validateRequest(request);
// 3. 转换并更新
PuzzleElementEntity entity = ElementConfigHelper.toEntity(request);
entity.setId(id);
elementMapper.update(entity);
@@ -222,7 +234,7 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
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<PuzzleElementDTO> elementDTOs = elements.stream()
.map(e -> BeanUtil.copyProperties(e, PuzzleElementDTO.class))
.map(this::convertElementToDTO)
.collect(Collectors.toList());
dto.setElements(elementDTOs);
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;
import cn.hutool.core.img.ImgUtil;
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.base.ElementFactory;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import lombok.extern.slf4j.Slf4j;
@@ -10,29 +12,32 @@ import org.springframework.stereotype.Component;
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;
import java.io.IOException;
import java.net.URL;
import java.util.List;
import java.util.Map;
/**
* 拼图图片渲染引擎
* 拼图图片渲染引擎(重构版)
* 核心功能:将模板和元素渲染成最终图片
*
* 重构说明:
* - 使用ElementFactory创建Element实例
* - 元素渲染逻辑委托给Element自己实现
* - 删除drawImageElement和drawTextElement方法
* - 保留背景绘制和工具方法
*
* @author Claude
* @since 2025-01-17
* @since 2025-01-18
*/
@Slf4j
@Component
public class PuzzleImageRenderer {
/**
* 渲染拼图图片
* 渲染拼图图片(重构版)
*
* @param template 模板配置
* @param elements 元素列表(已按z-index排序)
@@ -48,7 +53,7 @@ public class PuzzleImageRenderer {
BufferedImage canvas = new BufferedImage(
template.getCanvasWidth(),
template.getCanvasHeight(),
BufferedImage.TYPE_INT_RGB
BufferedImage.TYPE_INT_ARGB // 使用ARGB支持透明度
);
Graphics2D g2d = canvas.createGraphics();
@@ -60,23 +65,33 @@ public class PuzzleImageRenderer {
// 3. 绘制背景
drawBackground(g2d, template);
// 4. 按z-index顺序绘制元素
for (PuzzleElementEntity element : elements) {
// 4. 创建渲染上下文
RenderContext context = new RenderContext(
g2d,
dynamicData,
template.getCanvasWidth(),
template.getCanvasHeight()
);
// 5. 使用ElementFactory创建Element实例并渲染
for (PuzzleElementEntity entity : elements) {
try {
if (element.getElementType() == 1) {
// 图片元素
drawImageElement(g2d, element, dynamicData);
} else if (element.getElementType() == 2) {
// 文字元素
drawTextElement(g2d, element, dynamicData);
}
// 使用工厂创建Element实例(自动加载配置和验证)
BaseElement element = ElementFactory.create(entity);
// 委托给Element自己渲染
element.render(context);
log.debug("元素渲染成功: type={}, key={}", element.getElementType().getCode(), element.getElementKey());
} 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;
} finally {
@@ -121,246 +136,9 @@ public class PuzzleImageRenderer {
}
/**
* 绘制图片元素
* 下载图片(工具方法,也可被外部使用)
*/
private void drawImageElement(Graphics2D g2d, PuzzleElementEntity element, Map<String, String> dynamicData) {
// 获取图片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 {
public BufferedImage downloadImage(String imageUrl) throws IOException {
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
// 网络图片
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
@@ -372,9 +150,9 @@ public class PuzzleImageRenderer {
}
/**
* 解析颜色
* 解析颜色(工具方法,也可被外部使用)
*/
private Color parseColor(String colorStr) {
public Color parseColor(String colorStr) {
try {
if (colorStr.startsWith("#")) {
return Color.decode(colorStr);

View File

@@ -3,13 +3,15 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.puzzle.mapper.PuzzleElementMapper">
<!-- 结果映射 -->
<!-- 结果映射(重构版) -->
<resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.entity.PuzzleElementEntity">
<id column="id" property="id"/>
<result column="template_id" property="templateId"/>
<result column="element_type" property="elementType"/>
<result column="element_key" property="elementKey"/>
<result column="element_name" property="elementName"/>
<result column="config" property="config"/>
<!-- 位置和布局属性 -->
<result column="x_position" property="xPosition"/>
<result column="y_position" property="yPosition"/>
<result column="width" property="width"/>
@@ -17,32 +19,17 @@
<result column="z_index" property="zIndex"/>
<result column="rotation" property="rotation"/>
<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="update_time" property="updateTime"/>
<result column="deleted" property="deleted"/>
<result column="deleted_at" property="deletedAt"/>
</resultMap>
<!-- 基础列 -->
<!-- 基础列(重构版) -->
<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,
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
</sql>
@@ -62,55 +49,44 @@
ORDER BY z_index ASC, id ASC
</select>
<!-- 插入 -->
<!-- 插入(重构版) -->
<insert id="insert" parameterType="com.ycwl.basic.puzzle.entity.PuzzleElementEntity"
useGeneratedKeys="true" keyProperty="id">
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,
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
) VALUES (
#{templateId}, #{elementType}, #{elementKey}, #{elementName},
#{templateId}, #{elementType}, #{elementKey}, #{elementName}, #{config},
#{xPosition}, #{yPosition}, #{width}, #{height}, #{zIndex}, #{rotation}, #{opacity},
#{defaultImageUrl}, #{imageFitMode}, #{borderRadius},
#{defaultText}, #{fontFamily}, #{fontSize}, #{fontColor}, #{fontWeight}, #{fontStyle},
#{textAlign}, #{lineHeight}, #{maxLines}, #{textDecoration},
NOW(), NOW(), 0
)
</insert>
<!-- 批量插入 -->
<!-- 批量插入(重构版) -->
<insert id="batchInsert">
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,
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
) VALUES
<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.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
)
</foreach>
</insert>
<!-- 更新 -->
<!-- 更新(重构版) -->
<update id="update" parameterType="com.ycwl.basic.puzzle.entity.PuzzleElementEntity">
UPDATE puzzle_element
<set>
<if test="elementType != null">element_type = #{elementType},</if>
<if test="elementKey != null">element_key = #{elementKey},</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="yPosition != null">y_position = #{yPosition},</if>
<if test="width != null">width = #{width},</if>
@@ -118,19 +94,6 @@
<if test="zIndex != null">z_index = #{zIndex},</if>
<if test="rotation != null">rotation = #{rotation},</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()
</set>
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;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.test.MockImageUtil;
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
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.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -18,6 +22,7 @@ import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.*;
@@ -102,6 +107,18 @@ class PuzzleRealScenarioIntegrationTest {
dynamicData.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
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: 渲染拼图
BufferedImage result = renderer.render(template, elements, dynamicData);
@@ -192,17 +209,6 @@ class PuzzleRealScenarioIntegrationTest {
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: 不传动态数据,使用默认值
Map<String, String> dynamicData = new HashMap<>();
BufferedImage result = renderer.render(template, elements, dynamicData);
@@ -297,4 +303,17 @@ class PuzzleRealScenarioIntegrationTest {
data.put("bottomText", "测试文字内容");
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();
element.setId(System.currentTimeMillis());
element.setTemplateId(templateId);
element.setElementType(1); // 图片
element.setElementType("IMAGE"); // 修改为字符串类型
element.setElementKey(elementKey);
element.setElementName(elementKey);
element.setXPosition(x);
@@ -70,9 +70,11 @@ public class PuzzleTestDataBuilder {
element.setZIndex(zIndex);
element.setRotation(0);
element.setOpacity(100);
element.setDefaultImageUrl(imageUrl);
element.setImageFitMode("FILL"); // 使用FILL模式确保图片完全填充区域
element.setBorderRadius(0);
// 使用JSON配置
String config = String.format("{\"defaultImageUrl\":\"%s\",\"imageFitMode\":\"FILL\",\"borderRadius\":0}", imageUrl);
element.setConfig(config);
element.setCreateTime(new Date());
element.setUpdateTime(new Date());
element.setDeleted(0);
@@ -85,8 +87,28 @@ public class PuzzleTestDataBuilder {
public static PuzzleElementEntity createRoundedImageElement(Long templateId, String elementKey,
int x, int y, int width, int height,
int zIndex, String imageUrl, int borderRadius) {
PuzzleElementEntity element = createImageElement(templateId, elementKey, x, y, width, height, zIndex, imageUrl);
element.setBorderRadius(borderRadius);
PuzzleElementEntity element = new PuzzleElementEntity();
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;
}
@@ -100,7 +122,7 @@ public class PuzzleTestDataBuilder {
PuzzleElementEntity element = new PuzzleElementEntity();
element.setId(System.currentTimeMillis());
element.setTemplateId(templateId);
element.setElementType(2); // 文字
element.setElementType("TEXT"); // 修改为字符串类型
element.setElementKey(elementKey);
element.setElementName(elementKey);
element.setXPosition(x);
@@ -110,16 +132,15 @@ public class PuzzleTestDataBuilder {
element.setZIndex(zIndex);
element.setRotation(0);
element.setOpacity(100);
element.setDefaultText(defaultText);
element.setFontFamily("微软雅黑");
element.setFontSize(fontSize);
element.setFontColor(fontColor);
element.setFontWeight("NORMAL");
element.setFontStyle("NORMAL");
element.setTextAlign("LEFT");
element.setLineHeight(BigDecimal.valueOf(1.5));
element.setMaxLines(null);
element.setTextDecoration("NONE");
// 使用JSON配置
String config = String.format(
"{\"defaultText\":\"%s\",\"fontFamily\":\"微软雅黑\",\"fontSize\":%d,\"fontColor\":\"%s\"," +
"\"fontWeight\":\"NORMAL\",\"fontStyle\":\"NORMAL\",\"textAlign\":\"LEFT\"," +
"\"lineHeight\":1.5,\"textDecoration\":\"NONE\"}",
defaultText, fontSize, fontColor);
element.setConfig(config);
element.setCreateTime(new Date());
element.setUpdateTime(new Date());
element.setDeleted(0);
@@ -133,9 +154,31 @@ public class PuzzleTestDataBuilder {
int x, int y, int width, int height,
int zIndex, String defaultText,
int fontSize, String fontColor, String textAlign) {
PuzzleElementEntity element = createTextElement(templateId, elementKey, x, y, width, height, zIndex, defaultText, fontSize, fontColor);
element.setFontWeight("BOLD");
element.setTextAlign(textAlign);
PuzzleElementEntity element = new PuzzleElementEntity();
element.setId(System.currentTimeMillis());
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;
}
@@ -175,9 +218,9 @@ public class PuzzleTestDataBuilder {
public static List<PuzzleElementEntity> createRealScenarioElements(Long templateId) {
List<PuzzleElementEntity> elements = new ArrayList<>();
// 画布总高度1520,每张图片460高度
int imageHeight = 460;
int bottomHeight = 140; // 剩余高度:1520 - 460*3 = 140
// 画布总高度1520,每张图片470高度
int imageHeight = 470;
int bottomHeight = 110; // 剩余高度:1520 - 470*3 = 110
// 上方3张图片,每张470高度
elements.add(createImageElement(templateId, "image1", 0, 0, 1020, imageHeight, 1,
@@ -190,17 +233,50 @@ public class PuzzleTestDataBuilder {
"https://example.com/image3.jpg"));
// 底部区域起始Y坐标
int bottomY = imageHeight * 3; // 1380
int bottomY = imageHeight * 3; // 1410
// 底部二维码(左侧,占满底部高度)
elements.add(createImageElement(templateId, "qrCode", 20, bottomY + 10, 120, 120, 2,
"https://example.com/qrcode.png"));
PuzzleElementEntity qrElement = new PuzzleElementEntity();
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 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;
}

View File

@@ -1,5 +1,9 @@
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.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
@@ -56,7 +60,7 @@ public class RealScenarioTestHelper {
* 创建场景图片(1020x470)
*/
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");
resourceFiles.put(key, file);
}
@@ -224,6 +228,10 @@ public class RealScenarioTestHelper {
* 快速测试方法(主方法,可直接运行)
*/
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_");
System.out.println("📂 测试目录: " + tempDir.toAbsolutePath());