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> CONSTRUCTOR_CACHE = new ConcurrentHashMap<>(); + + /** + * 注册Element类型 + * + * @param type 元素类型 + * @param elementClass Element实现类 + */ + public static void register(ElementType type, Class 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 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 elementClass) { + try { + // 从缓存获取构造器 + Constructor 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());