From 3d361200b080417b6e35a82a9386ce67c869d5e8 Mon Sep 17 00:00:00 2001
From: Jerry Yan <792602257@qq.com>
Date: Tue, 18 Nov 2025 08:13:38 +0800
Subject: [PATCH] =?UTF-8?q?refactor(puzzle):=20=E9=87=8D=E6=9E=84=E5=85=83?=
=?UTF-8?q?=E7=B4=A0DTO=E5=8F=8A=E6=96=B0=E5=A2=9E=E5=85=83=E7=B4=A0?=
=?UTF-8?q?=E5=9F=BA=E7=B1=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 将ElementCreateRequest和PuzzleElementDTO中的elementType从Integer改为String
- 删除所有类型特定字段,新增config和configMap支持JSON配置
- 新增BaseElement抽象基类定义元素通用行为
- 添加ElementConfig接口和具体实现类ImageConfig、TextConfig
- 创建ElementFactory工厂类和ElementRegistrar注册器
- 新增ElementType枚举和ElementValidationException异常类
- 实现ImageElement和TextElement具体元素类
- 添加Position位置信息封装类
---
pom.xml | 3 +
src/main/java/com/ycwl/basic/puzzle/claude.md | 611 ++++++++++++++++++
.../puzzle/dto/ElementCreateRequest.java | 113 ++--
.../basic/puzzle/dto/PuzzleElementDTO.java | 102 +--
.../puzzle/element/base/BaseElement.java | 219 +++++++
.../puzzle/element/base/ElementConfig.java | 30 +
.../puzzle/element/base/ElementFactory.java | 172 +++++
.../puzzle/element/base/ElementRegistrar.java | 40 ++
.../basic/puzzle/element/base/Position.java | 95 +++
.../puzzle/element/config/ImageConfig.java | 69 ++
.../puzzle/element/config/TextConfig.java | 141 ++++
.../puzzle/element/enums/ElementType.java | 97 +++
.../exception/ElementValidationException.java | 40 ++
.../puzzle/element/impl/ImageElement.java | 254 ++++++++
.../puzzle/element/impl/TextElement.java | 221 +++++++
.../element/renderer/RenderContext.java | 74 +++
.../puzzle/entity/PuzzleElementEntity.java | 116 +---
.../impl/PuzzleTemplateServiceImpl.java | 65 +-
.../puzzle/util/ElementConfigHelper.java | 162 +++++
.../puzzle/util/PuzzleImageRenderer.java | 298 ++-------
.../resources/mapper/PuzzleElementMapper.xml | 65 +-
.../puzzle/element/ElementFactoryTest.java | 99 +++
.../puzzle/element/ImageElementTest.java | 124 ++++
.../basic/puzzle/element/TextElementTest.java | 107 +++
.../puzzle/integration/ElementDebugTest.java | 103 +++
.../PuzzleRealScenarioIntegrationTest.java | 41 +-
.../puzzle/test/PuzzleTestDataBuilder.java | 132 +++-
.../puzzle/test/RealScenarioTestHelper.java | 10 +-
28 files changed, 2988 insertions(+), 615 deletions(-)
create mode 100644 src/main/java/com/ycwl/basic/puzzle/claude.md
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/base/BaseElement.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/base/ElementConfig.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/base/ElementFactory.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/base/ElementRegistrar.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/base/Position.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/config/TextConfig.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/enums/ElementType.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/exception/ElementValidationException.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/impl/TextElement.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/element/renderer/RenderContext.java
create mode 100644 src/main/java/com/ycwl/basic/puzzle/util/ElementConfigHelper.java
create mode 100644 src/test/java/com/ycwl/basic/puzzle/element/ElementFactoryTest.java
create mode 100644 src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java
create mode 100644 src/test/java/com/ycwl/basic/puzzle/element/TextElementTest.java
create mode 100644 src/test/java/com/ycwl/basic/puzzle/integration/ElementDebugTest.java
diff --git a/pom.xml b/pom.xml
index 7511e9b0..2272efe7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -319,6 +319,9 @@
**/*Test.java
+ 21
+ 21
+ --enable-preview
diff --git a/src/main/java/com/ycwl/basic/puzzle/claude.md b/src/main/java/com/ycwl/basic/puzzle/claude.md
new file mode 100644
index 00000000..5036947c
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/claude.md
@@ -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, Map)`:主渲染方法
+ - 创建画布(根据模板宽高)
+ - 绘制背景(纯色或图片背景)
+ - 按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)`:批量添加元素
+- `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`
+```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 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
diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/ElementCreateRequest.java b/src/main/java/com/ycwl/basic/puzzle/dto/ElementCreateRequest.java
index 0b39a36e..e2b3abdf 100644
--- a/src/main/java/com/ycwl/basic/puzzle/dto/ElementCreateRequest.java
+++ b/src/main/java/com/ycwl/basic/puzzle/dto/ElementCreateRequest.java
@@ -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 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 configMap;
}
diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleElementDTO.java b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleElementDTO.java
index 503b07a0..ec197667 100644
--- a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleElementDTO.java
+++ b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleElementDTO.java
@@ -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 configMap;
}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/base/BaseElement.java b/src/main/java/com/ycwl/basic/puzzle/element/base/BaseElement.java
new file mode 100644
index 00000000..bf93b6d7
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/base/BaseElement.java
@@ -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 配置类型
+ * @return 配置对象
+ */
+ protected T parseConfig(String configJson, Class 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);
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/base/ElementConfig.java b/src/main/java/com/ycwl/basic/puzzle/element/base/ElementConfig.java
new file mode 100644
index 00000000..dfe70483
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/base/ElementConfig.java
@@ -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() {
+ // 默认不做验证
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/base/ElementFactory.java b/src/main/java/com/ycwl/basic/puzzle/element/base/ElementFactory.java
new file mode 100644
index 00000000..aa8586ac
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/base/ElementFactory.java
@@ -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> ELEMENT_REGISTRY = new ConcurrentHashMap<>();
+
+ /**
+ * 构造器缓存(性能优化)
+ * key: Element实现类
+ * value: 无参构造器
+ */
+ private static final Map, 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> 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注册表已清空");
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/base/ElementRegistrar.java b/src/main/java/com/ycwl/basic/puzzle/element/base/ElementRegistrar.java
new file mode 100644
index 00000000..a082c7db
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/base/ElementRegistrar.java
@@ -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());
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/base/Position.java b/src/main/java/com/ycwl/basic/puzzle/element/base/Position.java
new file mode 100644
index 00000000..79347568
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/base/Position.java
@@ -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;
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java b/src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java
new file mode 100644
index 00000000..a2b6bc50
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java
@@ -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" +
+ "}";
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/config/TextConfig.java b/src/main/java/com/ycwl/basic/puzzle/element/config/TextConfig.java
new file mode 100644
index 00000000..0851d801
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/config/TextConfig.java
@@ -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" +
+ "}";
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/enums/ElementType.java b/src/main/java/com/ycwl/basic/puzzle/element/enums/ElementType.java
new file mode 100644
index 00000000..37e0d932
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/enums/ElementType.java
@@ -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;
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/exception/ElementValidationException.java b/src/main/java/com/ycwl/basic/puzzle/element/exception/ElementValidationException.java
new file mode 100644
index 00000000..5fa04803
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/exception/ElementValidationException.java
@@ -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;
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java b/src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java
new file mode 100644
index 00000000..41601fea
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java
@@ -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);
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/impl/TextElement.java b/src/main/java/com/ycwl/basic/puzzle/element/impl/TextElement.java
new file mode 100644
index 00000000..4a5522ee
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/impl/TextElement.java
@@ -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;
+ }
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/element/renderer/RenderContext.java b/src/main/java/com/ycwl/basic/puzzle/element/renderer/RenderContext.java
new file mode 100644
index 00000000..259a6f11
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/element/renderer/RenderContext.java
@@ -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 dynamicData;
+
+ /**
+ * 画布宽度
+ */
+ private Integer canvasWidth;
+
+ /**
+ * 画布高度
+ */
+ private Integer canvasHeight;
+
+ /**
+ * 是否启用抗锯齿
+ */
+ private boolean antiAliasing = true;
+
+ /**
+ * 是否启用高质量渲染
+ */
+ private boolean highQuality = true;
+
+ public RenderContext(Graphics2D graphics, Map dynamicData) {
+ this.graphics = graphics;
+ this.dynamicData = dynamicData;
+ }
+
+ public RenderContext(Graphics2D graphics, Map 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);
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleElementEntity.java b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleElementEntity.java
index d54b7e8b..3c22102a 100644
--- a/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleElementEntity.java
+++ b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleElementEntity.java
@@ -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;
+ // ===== 元数据 =====
/**
* 创建时间
diff --git a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleTemplateServiceImpl.java b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleTemplateServiceImpl.java
index 095f7def..ce008a86 100644
--- a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleTemplateServiceImpl.java
+++ b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleTemplateServiceImpl.java
@@ -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 elements) {
log.info("批量添加元素到模板: templateId={}, count={}", templateId, elements.size());
- // 检查模板是否存在
+ // 1. 校验模板
PuzzleTemplateEntity template = templateMapper.getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + templateId);
}
- // 转换为实体列表
- List 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 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 elements = elementMapper.getByTemplateId(template.getId());
List 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;
+ }
}
diff --git a/src/main/java/com/ycwl/basic/puzzle/util/ElementConfigHelper.java b/src/main/java/com/ycwl/basic/puzzle/util/ElementConfigHelper.java
new file mode 100644
index 00000000..9d11642c
--- /dev/null
+++ b/src/main/java/com/ycwl/basic/puzzle/util/ElementConfigHelper.java
@@ -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 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至少提供一个)");
+ }
+ }
+}
diff --git a/src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java b/src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java
index 418971dd..4fb8c0d1 100644
--- a/src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java
+++ b/src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java
@@ -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 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 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);
diff --git a/src/main/resources/mapper/PuzzleElementMapper.xml b/src/main/resources/mapper/PuzzleElementMapper.xml
index 5afebfe1..42f7b16a 100644
--- a/src/main/resources/mapper/PuzzleElementMapper.xml
+++ b/src/main/resources/mapper/PuzzleElementMapper.xml
@@ -3,13 +3,15 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
-
+
+
+
@@ -17,32 +19,17 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
- 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
@@ -62,55 +49,44 @@
ORDER BY z_index ASC, id ASC
-
+
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 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
(
- #{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
)
-
+
UPDATE puzzle_element
element_type = #{elementType},
element_key = #{elementKey},
element_name = #{elementName},
+ config = #{config},
x_position = #{xPosition},
y_position = #{yPosition},
width = #{width},
@@ -118,19 +94,6 @@
z_index = #{zIndex},
rotation = #{rotation},
opacity = #{opacity},
- default_image_url = #{defaultImageUrl},
- image_fit_mode = #{imageFitMode},
- border_radius = #{borderRadius},
- default_text = #{defaultText},
- font_family = #{fontFamily},
- font_size = #{fontSize},
- font_color = #{fontColor},
- font_weight = #{fontWeight},
- font_style = #{fontStyle},
- text_align = #{textAlign},
- line_height = #{lineHeight},
- max_lines = #{maxLines},
- text_decoration = #{textDecoration},
update_time = NOW()
WHERE id = #{id} AND deleted = 0
diff --git a/src/test/java/com/ycwl/basic/puzzle/element/ElementFactoryTest.java b/src/test/java/com/ycwl/basic/puzzle/element/ElementFactoryTest.java
new file mode 100644
index 00000000..3b30666c
--- /dev/null
+++ b/src/test/java/com/ycwl/basic/puzzle/element/ElementFactoryTest.java
@@ -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));
+ }
+}
diff --git a/src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java b/src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java
new file mode 100644
index 00000000..cf346a3c
--- /dev/null
+++ b/src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java
@@ -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 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"));
+ }
+}
diff --git a/src/test/java/com/ycwl/basic/puzzle/element/TextElementTest.java b/src/test/java/com/ycwl/basic/puzzle/element/TextElementTest.java
new file mode 100644
index 00000000..54070fee
--- /dev/null
+++ b/src/test/java/com/ycwl/basic/puzzle/element/TextElementTest.java
@@ -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 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"));
+ }
+}
diff --git a/src/test/java/com/ycwl/basic/puzzle/integration/ElementDebugTest.java b/src/test/java/com/ycwl/basic/puzzle/integration/ElementDebugTest.java
new file mode 100644
index 00000000..3913f1b0
--- /dev/null
+++ b/src/test/java/com/ycwl/basic/puzzle/integration/ElementDebugTest.java
@@ -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, "所有元素都应该创建成功");
+ }
+}
diff --git a/src/test/java/com/ycwl/basic/puzzle/integration/PuzzleRealScenarioIntegrationTest.java b/src/test/java/com/ycwl/basic/puzzle/integration/PuzzleRealScenarioIntegrationTest.java
index f85a00a9..57b3e0d0 100644
--- a/src/test/java/com/ycwl/basic/puzzle/integration/PuzzleRealScenarioIntegrationTest.java
+++ b/src/test/java/com/ycwl/basic/puzzle/integration/PuzzleRealScenarioIntegrationTest.java
@@ -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 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 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
+ );
+ }
}
diff --git a/src/test/java/com/ycwl/basic/puzzle/test/PuzzleTestDataBuilder.java b/src/test/java/com/ycwl/basic/puzzle/test/PuzzleTestDataBuilder.java
index 4ad9e08e..2dd7ff10 100644
--- a/src/test/java/com/ycwl/basic/puzzle/test/PuzzleTestDataBuilder.java
+++ b/src/test/java/com/ycwl/basic/puzzle/test/PuzzleTestDataBuilder.java
@@ -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 createRealScenarioElements(Long templateId) {
List 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;
}
diff --git a/src/test/java/com/ycwl/basic/puzzle/test/RealScenarioTestHelper.java b/src/test/java/com/ycwl/basic/puzzle/test/RealScenarioTestHelper.java
index fedd1f48..78f0f043 100644
--- a/src/test/java/com/ycwl/basic/puzzle/test/RealScenarioTestHelper.java
+++ b/src/test/java/com/ycwl/basic/puzzle/test/RealScenarioTestHelper.java
@@ -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());