feat(puzzle): 实现拼图生成功能模块

- 新增拼图生成控制器 PuzzleGenerateController,支持 /api/puzzle/generate 接口
- 新增拼图模板管理控制器 PuzzleTemplateController,提供完整的 CRUD 和元素管理功能
- 定义拼图相关 DTO 类,包括模板、元素、生成请求与响应等数据传输对象
- 创建拼图相关的实体类 PuzzleTemplateEntity、PuzzleElementEntity 和 PuzzleGenerationRecordEntity
- 实现 Mapper 接口用于数据库操作,支持模板和元素的增删改查及生成记录管理
- 开发 PuzzleGenerateServiceImpl 服务,完成从模板渲染到图片上传的完整流程
- 提供 PuzzleTemplateServiceImpl 服务,实现模板及其元素的全生命周期管理
- 引入 PuzzleImageRenderer 工具类负责图像合成渲染逻辑
- 支持将生成结果上传至 OSS 并记录生成过程的日志和元数据
This commit is contained in:
2025-11-17 12:54:56 +08:00
parent 630d344b5a
commit 443f92ff92
22 changed files with 2600 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
package com.ycwl.basic.puzzle.controller;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 拼图生成Controller(C端API)
*
* @author Claude
* @since 2025-01-17
*/
@Slf4j
@RestController
@RequestMapping("/api/puzzle")
@RequiredArgsConstructor
public class PuzzleGenerateController {
private final IPuzzleGenerateService generateService;
/**
* 生成拼图图片
*/
@PostMapping("/generate")
public ApiResponse<PuzzleGenerateResponse> generatePuzzle(@RequestBody PuzzleGenerateRequest request) {
log.info("拼图生成请求: templateCode={}, userId={}, orderId={}",
request.getTemplateCode(), request.getUserId(), request.getOrderId());
// 参数校验
if (request.getTemplateCode() == null || request.getTemplateCode().trim().isEmpty()) {
return ApiResponse.fail("模板编码不能为空");
}
try {
PuzzleGenerateResponse response = generateService.generate(request);
return ApiResponse.success(response);
} catch (IllegalArgumentException e) {
log.warn("拼图生成参数错误: {}", e.getMessage());
return ApiResponse.fail(e.getMessage());
} catch (Exception e) {
log.error("拼图生成失败", e);
return ApiResponse.fail("图片生成失败,请稍后重试");
}
}
}

View File

@@ -0,0 +1,134 @@
package com.ycwl.basic.puzzle.controller;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
import com.ycwl.basic.puzzle.dto.TemplateCreateRequest;
import com.ycwl.basic.puzzle.service.IPuzzleTemplateService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 拼图模板管理Controller(管理后台)
*
* @author Claude
* @since 2025-01-17
*/
@Slf4j
@RestController
@RequestMapping("/api/puzzle/admin")
@RequiredArgsConstructor
public class PuzzleTemplateController {
private final IPuzzleTemplateService templateService;
/**
* 创建模板
*/
@PostMapping("/templates")
public ApiResponse<Long> createTemplate(@RequestBody TemplateCreateRequest request) {
log.info("创建模板请求: code={}, name={}", request.getCode(), request.getName());
Long templateId = templateService.createTemplate(request);
return ApiResponse.success(templateId);
}
/**
* 更新模板
*/
@PutMapping("/templates/{id}")
public ApiResponse<Void> updateTemplate(@PathVariable Long id,
@RequestBody TemplateCreateRequest request) {
log.info("更新模板请求: id={}", id);
templateService.updateTemplate(id, request);
return ApiResponse.success(null);
}
/**
* 删除模板
*/
@DeleteMapping("/templates/{id}")
public ApiResponse<Void> deleteTemplate(@PathVariable Long id) {
log.info("删除模板请求: id={}", id);
templateService.deleteTemplate(id);
return ApiResponse.success(null);
}
/**
* 获取模板详情
*/
@GetMapping("/templates/{id}")
public ApiResponse<PuzzleTemplateDTO> getTemplateDetail(@PathVariable Long id) {
log.debug("获取模板详情: id={}", id);
PuzzleTemplateDTO template = templateService.getTemplateDetail(id);
return ApiResponse.success(template);
}
/**
* 获取模板列表
*/
@GetMapping("/templates")
public ApiResponse<List<PuzzleTemplateDTO>> listTemplates(
@RequestParam(required = false) Long scenicId,
@RequestParam(required = false) String category,
@RequestParam(required = false) Integer status) {
log.debug("查询模板列表: scenicId={}, category={}, status={}", scenicId, category, status);
List<PuzzleTemplateDTO> templates = templateService.listTemplates(scenicId, category, status);
return ApiResponse.success(templates);
}
/**
* 为模板添加单个元素
*/
@PostMapping("/elements")
public ApiResponse<Long> addElement(@RequestBody ElementCreateRequest request) {
log.info("添加元素请求: templateId={}, elementKey={}", request.getTemplateId(), request.getElementKey());
Long elementId = templateService.addElement(request);
return ApiResponse.success(elementId);
}
/**
* 为模板批量添加元素
*/
@PostMapping("/templates/{templateId}/elements")
public ApiResponse<Void> batchAddElements(@PathVariable Long templateId,
@RequestBody List<ElementCreateRequest> elements) {
log.info("批量添加元素请求: templateId={}, count={}", templateId, elements.size());
templateService.batchAddElements(templateId, elements);
return ApiResponse.success(null);
}
/**
* 更新元素
*/
@PutMapping("/elements/{id}")
public ApiResponse<Void> updateElement(@PathVariable Long id,
@RequestBody ElementCreateRequest request) {
log.info("更新元素请求: id={}", id);
templateService.updateElement(id, request);
return ApiResponse.success(null);
}
/**
* 删除元素
*/
@DeleteMapping("/elements/{id}")
public ApiResponse<Void> deleteElement(@PathVariable Long id) {
log.info("删除元素请求: id={}", id);
templateService.deleteElement(id);
return ApiResponse.success(null);
}
/**
* 获取元素详情
*/
@GetMapping("/elements/{id}")
public ApiResponse<PuzzleElementDTO> getElementDetail(@PathVariable Long id) {
log.debug("获取元素详情: id={}", id);
PuzzleElementDTO element = templateService.getElementDetail(id);
return ApiResponse.success(element);
}
}

View File

@@ -0,0 +1,141 @@
package com.ycwl.basic.puzzle.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 创建元素请求DTO
*
* @author Claude
* @since 2025-01-17
*/
@Data
public class ElementCreateRequest {
/**
* 模板ID
*/
private Long templateId;
/**
* 元素类型:1-图片 2-文字
*/
private Integer elementType;
/**
* 元素标识
*/
private String elementKey;
/**
* 元素名称
*/
private String elementName;
// ===== 位置和布局属性 =====
/**
* X坐标
*/
private Integer xPosition;
/**
* Y坐标
*/
private Integer yPosition;
/**
* 宽度
*/
private Integer width;
/**
* 高度
*/
private Integer height;
/**
* 层级
*/
private Integer zIndex;
/**
* 旋转角度
*/
private Integer rotation;
/**
* 不透明度
*/
private Integer opacity;
// ===== 图片元素属性 =====
/**
* 默认图片URL
*/
private String defaultImageUrl;
/**
* 图片适配模式
*/
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;
}

View File

@@ -0,0 +1,146 @@
package com.ycwl.basic.puzzle.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 拼图元素DTO
*
* @author Claude
* @since 2025-01-17
*/
@Data
public class PuzzleElementDTO {
/**
* 元素ID
*/
private Long id;
/**
* 模板ID
*/
private Long templateId;
/**
* 元素类型:1-图片 2-文字
*/
private Integer elementType;
/**
* 元素标识(用于动态数据替换)
*/
private String elementKey;
/**
* 元素名称
*/
private String elementName;
// ===== 位置和布局属性 =====
/**
* X坐标
*/
private Integer xPosition;
/**
* Y坐标
*/
private Integer yPosition;
/**
* 宽度
*/
private Integer width;
/**
* 高度
*/
private Integer height;
/**
* 层级
*/
private Integer zIndex;
/**
* 旋转角度
*/
private Integer rotation;
/**
* 不透明度
*/
private Integer opacity;
// ===== 图片元素属性 =====
/**
* 默认图片URL
*/
private String defaultImageUrl;
/**
* 图片适配模式
*/
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;
}

View File

@@ -0,0 +1,58 @@
package com.ycwl.basic.puzzle.dto;
import lombok.Data;
import java.util.Map;
/**
* 拼图生成请求DTO
*
* @author Claude
* @since 2025-01-17
*/
@Data
public class PuzzleGenerateRequest {
/**
* 模板编码(必填)
*/
private String templateCode;
/**
* 用户ID(可选)
*/
private Long userId;
/**
* 订单ID(可选)
*/
private String orderId;
/**
* 业务类型(可选)
*/
private String businessType;
/**
* 景区ID(可选)
*/
private Long scenicId;
/**
* 动态数据(key为元素的elementKey,value为实际值)
* 例如:{"userAvatar": "https://...", "userName": "张三", "orderNumber": "ORDER123"}
*/
private Map<String, String> dynamicData;
/**
* 输出格式(可选,默认PNG)
* 支持:PNG、JPEG
*/
private String outputFormat;
/**
* 图片质量(可选,默认90,范围0-100)
* 仅对JPEG格式有效
*/
private Integer quality;
}

View File

@@ -0,0 +1,54 @@
package com.ycwl.basic.puzzle.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 拼图生成响应DTO
*
* @author Claude
* @since 2025-01-17
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PuzzleGenerateResponse {
/**
* 生成的图片URL
*/
private String imageUrl;
/**
* 文件大小(字节)
*/
private Long fileSize;
/**
* 图片宽度
*/
private Integer width;
/**
* 图片高度
*/
private Integer height;
/**
* 生成耗时(毫秒)
*/
private Integer generationDuration;
/**
* 生成记录ID
*/
private Long recordId;
/**
* 创建成功响应
*/
public static PuzzleGenerateResponse success(String imageUrl, Long fileSize, Integer width, Integer height, Integer duration, Long recordId) {
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId);
}
}

View File

@@ -0,0 +1,80 @@
package com.ycwl.basic.puzzle.dto;
import lombok.Data;
import java.util.List;
/**
* 拼图模板详情DTO(包含元素列表)
*
* @author Claude
* @since 2025-01-17
*/
@Data
public class PuzzleTemplateDTO {
/**
* 模板ID
*/
private Long id;
/**
* 模板名称
*/
private String name;
/**
* 模板编码
*/
private String code;
/**
* 画布宽度(像素)
*/
private Integer canvasWidth;
/**
* 画布高度(像素)
*/
private Integer canvasHeight;
/**
* 背景类型:0-纯色 1-图片
*/
private Integer backgroundType;
/**
* 背景颜色
*/
private String backgroundColor;
/**
* 背景图片URL
*/
private String backgroundImage;
/**
* 模板描述
*/
private String description;
/**
* 模板分类
*/
private String category;
/**
* 状态:0-禁用 1-启用
*/
private Integer status;
/**
* 景区ID
*/
private Long scenicId;
/**
* 元素列表
*/
private List<PuzzleElementDTO> elements;
}

View File

@@ -0,0 +1,68 @@
package com.ycwl.basic.puzzle.dto;
import lombok.Data;
/**
* 创建模板请求DTO
*
* @author Claude
* @since 2025-01-17
*/
@Data
public class TemplateCreateRequest {
/**
* 模板名称
*/
private String name;
/**
* 模板编码
*/
private String code;
/**
* 画布宽度
*/
private Integer canvasWidth;
/**
* 画布高度
*/
private Integer canvasHeight;
/**
* 背景类型:0-纯色 1-图片
*/
private Integer backgroundType;
/**
* 背景颜色
*/
private String backgroundColor;
/**
* 背景图片URL
*/
private String backgroundImage;
/**
* 模板描述
*/
private String description;
/**
* 模板分类
*/
private String category;
/**
* 状态:0-禁用 1-启用
*/
private Integer status;
/**
* 景区ID
*/
private Long scenicId;
}

View File

@@ -0,0 +1,202 @@
package com.ycwl.basic.puzzle.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
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
*
* @author Claude
* @since 2025-01-17
*/
@Data
@TableName("puzzle_element")
public class PuzzleElementEntity {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 模板ID
*/
@TableField("template_id")
private Long templateId;
/**
* 元素类型:1-图片 2-文字
*/
@TableField("element_type")
private Integer elementType;
/**
* 元素标识(用于动态数据替换)
*/
@TableField("element_key")
private String elementKey;
/**
* 元素名称(便于管理识别)
*/
@TableField("element_name")
private String elementName;
// ===== 位置和布局属性 =====
/**
* X坐标(相对于画布左上角)
*/
@TableField("x_position")
private Integer xPosition;
/**
* Y坐标(相对于画布左上角)
*/
@TableField("y_position")
private Integer yPosition;
/**
* 宽度(像素)
*/
@TableField("width")
private Integer width;
/**
* 高度(像素)
*/
@TableField("height")
private Integer height;
/**
* 层级(数值越大越靠上)
*/
@TableField("z_index")
private Integer zIndex;
/**
* 旋转角度(0-360度,顺时针)
*/
@TableField("rotation")
private Integer rotation;
/**
* 不透明度(0-100,100为完全不透明)
*/
@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;
/**
* 创建时间
*/
@TableField("create_time")
private Date createTime;
/**
* 更新时间
*/
@TableField("update_time")
private Date updateTime;
/**
* 删除标记:0-未删除 1-已删除
*/
@TableField("deleted")
private Integer deleted;
/**
* 删除时间
*/
@TableField("deleted_at")
private Date deletedAt;
}

View File

@@ -0,0 +1,147 @@
package com.ycwl.basic.puzzle.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 拼图生成记录实体类
* 对应表:puzzle_generation_record
*
* @author Claude
* @since 2025-01-17
*/
@Data
@TableName("puzzle_generation_record")
public class PuzzleGenerationRecordEntity {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 模板ID
*/
@TableField("template_id")
private Long templateId;
/**
* 模板编码(冗余字段,方便查询)
*/
@TableField("template_code")
private String templateCode;
/**
* 用户ID
*/
@TableField("user_id")
private Long userId;
/**
* 关联订单号
*/
@TableField("order_id")
private String orderId;
/**
* 业务类型(如:order-订单 ticket-门票 certificate-证书)
*/
@TableField("business_type")
private String businessType;
// ===== 生成参数和结果 =====
/**
* 生成参数(动态数据,JSON格式存储)
*/
@TableField("generation_params")
private String generationParams;
/**
* 生成的图片URL
*/
@TableField("result_image_url")
private String resultImageUrl;
/**
* 文件大小(字节)
*/
@TableField("result_file_size")
private Long resultFileSize;
/**
* 生成图片宽度
*/
@TableField("result_width")
private Integer resultWidth;
/**
* 生成图片高度
*/
@TableField("result_height")
private Integer resultHeight;
// ===== 状态和性能统计 =====
/**
* 状态:0-生成中 1-成功 2-失败
*/
@TableField("status")
private Integer status;
/**
* 错误信息(失败时记录)
*/
@TableField("error_message")
private String errorMessage;
/**
* 生成耗时(毫秒)
*/
@TableField("generation_duration")
private Integer generationDuration;
/**
* 重试次数
*/
@TableField("retry_count")
private Integer retryCount;
// ===== 多租户和扩展字段 =====
/**
* 景区ID
*/
@TableField("scenic_id")
private Long scenicId;
/**
* 客户端IP
*/
@TableField("client_ip")
private String clientIp;
/**
* 客户端User-Agent
*/
@TableField("user_agent")
private String userAgent;
/**
* 创建时间
*/
@TableField("create_time")
private Date createTime;
/**
* 更新时间
*/
@TableField("update_time")
private Date updateTime;
}

View File

@@ -0,0 +1,117 @@
package com.ycwl.basic.puzzle.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 拼图模板实体类
* 对应表:puzzle_template
*
* @author Claude
* @since 2025-01-17
*/
@Data
@TableName("puzzle_template")
public class PuzzleTemplateEntity {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 模板名称
*/
@TableField("name")
private String name;
/**
* 模板编码(用于API调用)
*/
@TableField("code")
private String code;
/**
* 画布宽度(像素)
*/
@TableField("canvas_width")
private Integer canvasWidth;
/**
* 画布高度(像素)
*/
@TableField("canvas_height")
private Integer canvasHeight;
/**
* 背景类型:0-纯色 1-图片
*/
@TableField("background_type")
private Integer backgroundType;
/**
* 背景颜色(hex格式)
*/
@TableField("background_color")
private String backgroundColor;
/**
* 背景图片URL
*/
@TableField("background_image")
private String backgroundImage;
/**
* 模板描述
*/
@TableField("description")
private String description;
/**
* 模板分类(如:order-订单 ticket-票务 certificate-证书)
*/
@TableField("category")
private String category;
/**
* 状态:0-禁用 1-启用
*/
@TableField("status")
private Integer status;
/**
* 景区ID(用于多租户隔离)
*/
@TableField("scenic_id")
private Long scenicId;
/**
* 创建时间
*/
@TableField("create_time")
private Date createTime;
/**
* 更新时间
*/
@TableField("update_time")
private Date updateTime;
/**
* 删除标记:0-未删除 1-已删除
*/
@TableField("deleted")
private Integer deleted;
/**
* 删除时间
*/
@TableField("deleted_at")
private Date deletedAt;
}

View File

@@ -0,0 +1,50 @@
package com.ycwl.basic.puzzle.mapper;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 拼图元素Mapper接口
*
* @author Claude
* @since 2025-01-17
*/
public interface PuzzleElementMapper {
/**
* 根据ID查询元素
*/
PuzzleElementEntity getById(@Param("id") Long id);
/**
* 根据模板ID查询元素列表(按z-index排序)
*/
List<PuzzleElementEntity> getByTemplateId(@Param("templateId") Long templateId);
/**
* 插入元素
*/
int insert(PuzzleElementEntity entity);
/**
* 批量插入元素
*/
int batchInsert(@Param("list") List<PuzzleElementEntity> list);
/**
* 更新元素
*/
int update(PuzzleElementEntity entity);
/**
* 删除元素(逻辑删除)
*/
int deleteById(@Param("id") Long id);
/**
* 根据模板ID删除所有元素(逻辑删除)
*/
int deleteByTemplateId(@Param("templateId") Long templateId);
}

View File

@@ -0,0 +1,57 @@
package com.ycwl.basic.puzzle.mapper;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 拼图生成记录Mapper接口
*
* @author Claude
* @since 2025-01-17
*/
public interface PuzzleGenerationRecordMapper {
/**
* 根据ID查询记录
*/
PuzzleGenerationRecordEntity getById(@Param("id") Long id);
/**
* 查询用户的生成记录列表
*/
List<PuzzleGenerationRecordEntity> listByUserId(@Param("userId") Long userId,
@Param("limit") Integer limit);
/**
* 查询订单的生成记录列表
*/
List<PuzzleGenerationRecordEntity> listByOrderId(@Param("orderId") String orderId);
/**
* 插入记录
*/
int insert(PuzzleGenerationRecordEntity entity);
/**
* 更新记录
*/
int update(PuzzleGenerationRecordEntity entity);
/**
* 更新为成功状态
*/
int updateSuccess(@Param("id") Long id,
@Param("resultImageUrl") String resultImageUrl,
@Param("resultFileSize") Long resultFileSize,
@Param("resultWidth") Integer resultWidth,
@Param("resultHeight") Integer resultHeight,
@Param("generationDuration") Integer generationDuration);
/**
* 更新为失败状态
*/
int updateFail(@Param("id") Long id,
@Param("errorMessage") String errorMessage);
}

View File

@@ -0,0 +1,52 @@
package com.ycwl.basic.puzzle.mapper;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 拼图模板Mapper接口
*
* @author Claude
* @since 2025-01-17
*/
public interface PuzzleTemplateMapper {
/**
* 根据ID查询模板
*/
PuzzleTemplateEntity getById(@Param("id") Long id);
/**
* 根据模板编码查询模板
*/
PuzzleTemplateEntity getByCode(@Param("code") String code);
/**
* 查询模板列表
*/
List<PuzzleTemplateEntity> list(@Param("scenicId") Long scenicId,
@Param("category") String category,
@Param("status") Integer status);
/**
* 插入模板
*/
int insert(PuzzleTemplateEntity entity);
/**
* 更新模板
*/
int update(PuzzleTemplateEntity entity);
/**
* 删除模板(逻辑删除)
*/
int deleteById(@Param("id") Long id);
/**
* 检查模板编码是否存在
*/
int countByCode(@Param("code") String code, @Param("excludeId") Long excludeId);
}

View File

@@ -0,0 +1,21 @@
package com.ycwl.basic.puzzle.service;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
/**
* 拼图图片生成服务接口
*
* @author Claude
* @since 2025-01-17
*/
public interface IPuzzleGenerateService {
/**
* 生成拼图图片
*
* @param request 生成请求
* @return 生成结果(包含图片URL等信息)
*/
PuzzleGenerateResponse generate(PuzzleGenerateRequest request);
}

View File

@@ -0,0 +1,72 @@
package com.ycwl.basic.puzzle.service;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
import com.ycwl.basic.puzzle.dto.TemplateCreateRequest;
import java.util.List;
/**
* 拼图模板管理服务接口
*
* @author Claude
* @since 2025-01-17
*/
public interface IPuzzleTemplateService {
/**
* 创建模板
*/
Long createTemplate(TemplateCreateRequest request);
/**
* 更新模板
*/
void updateTemplate(Long id, TemplateCreateRequest request);
/**
* 删除模板(会同时删除关联的元素)
*/
void deleteTemplate(Long id);
/**
* 获取模板详情(包含元素列表)
*/
PuzzleTemplateDTO getTemplateDetail(Long id);
/**
* 根据编码获取模板详情
*/
PuzzleTemplateDTO getTemplateByCode(String code);
/**
* 获取模板列表
*/
List<PuzzleTemplateDTO> listTemplates(Long scenicId, String category, Integer status);
/**
* 为模板添加元素
*/
Long addElement(ElementCreateRequest request);
/**
* 批量添加元素
*/
void batchAddElements(Long templateId, List<ElementCreateRequest> elements);
/**
* 更新元素
*/
void updateElement(Long id, ElementCreateRequest request);
/**
* 删除元素
*/
void deleteElement(Long id);
/**
* 获取元素详情
*/
PuzzleElementDTO getElementDetail(Long id);
}

View File

@@ -0,0 +1,180 @@
package com.ycwl.basic.puzzle.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import com.ycwl.basic.storage.StorageFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
/**
* 拼图图片生成服务实现
*
* @author Claude
* @since 2025-01-17
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
private final PuzzleTemplateMapper templateMapper;
private final PuzzleElementMapper elementMapper;
private final PuzzleGenerationRecordMapper recordMapper;
private final PuzzleImageRenderer imageRenderer;
@Override
public PuzzleGenerateResponse generate(PuzzleGenerateRequest request) {
long startTime = System.currentTimeMillis();
log.info("开始生成拼图: templateCode={}, userId={}, orderId={}",
request.getTemplateCode(), request.getUserId(), request.getOrderId());
// 1. 查询模板和元素
PuzzleTemplateEntity template = templateMapper.getByCode(request.getTemplateCode());
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + request.getTemplateCode());
}
if (template.getStatus() != 1) {
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
}
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
if (elements.isEmpty()) {
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
}
// 2. 按z-index排序元素
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
Comparator.nullsFirst(Comparator.naturalOrder())));
// 3. 创建生成记录
PuzzleGenerationRecordEntity record = createRecord(template, request);
recordMapper.insert(record);
try {
// 4. 渲染图片
BufferedImage resultImage = imageRenderer.render(template, elements, request.getDynamicData());
// 5. 上传到OSS
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("图片上传成功: url={}", imageUrl);
// 6. 更新记录为成功
long duration = (int) (System.currentTimeMillis() - startTime);
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
recordMapper.updateSuccess(
record.getId(),
imageUrl,
fileSize,
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration
);
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
record.getId(), imageUrl, duration);
return PuzzleGenerateResponse.success(
imageUrl,
fileSize,
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration,
record.getId()
);
} catch (Exception e) {
log.error("拼图生成失败: templateCode={}", request.getTemplateCode(), e);
// 更新记录为失败
recordMapper.updateFail(record.getId(), e.getMessage());
throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
}
}
/**
* 创建生成记录
*/
private PuzzleGenerationRecordEntity createRecord(PuzzleTemplateEntity template, PuzzleGenerateRequest request) {
PuzzleGenerationRecordEntity record = new PuzzleGenerationRecordEntity();
record.setTemplateId(template.getId());
record.setTemplateCode(template.getCode());
record.setUserId(request.getUserId());
record.setOrderId(request.getOrderId());
record.setBusinessType(request.getBusinessType());
record.setScenicId(request.getScenicId());
record.setStatus(0); // 生成中
record.setRetryCount(0);
// 将动态数据保存为JSON
if (request.getDynamicData() != null && !request.getDynamicData().isEmpty()) {
record.setGenerationParams(JSONUtil.toJsonStr(request.getDynamicData()));
}
return record;
}
/**
* 上传图片到OSS
*/
private String uploadImage(BufferedImage image, String templateCode, String format, Integer quality) throws IOException {
// 确定格式
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
if (!"PNG".equals(outputFormat) && !"JPEG".equals(outputFormat) && !"JPG".equals(outputFormat)) {
outputFormat = "PNG";
}
// 转换为字节数组
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, outputFormat, baos);
byte[] imageBytes = baos.toByteArray();
// 生成文件名
String fileName = String.format("puzzle/%s/%s.%s",
templateCode,
UUID.randomUUID().toString().replace("-", ""),
outputFormat.toLowerCase()
);
// 使用项目现有的存储工厂上传
try {
return StorageFactory.use().uploadFile(imageBytes, "puzzle", fileName);
} catch (Exception e) {
log.error("上传图片失败: fileName={}", fileName, e);
throw new IOException("图片上传失败", e);
}
}
/**
* 估算文件大小(字节)
*/
private long estimateFileSize(BufferedImage image, String format) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
ImageIO.write(image, outputFormat, baos);
return baos.size();
} catch (IOException e) {
log.warn("估算文件大小失败", e);
return 0L;
}
}
}

View File

@@ -0,0 +1,243 @@
package com.ycwl.basic.puzzle.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
import com.ycwl.basic.puzzle.dto.TemplateCreateRequest;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.puzzle.service.IPuzzleTemplateService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 拼图模板管理服务实现
*
* @author Claude
* @since 2025-01-17
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
private final PuzzleTemplateMapper templateMapper;
private final PuzzleElementMapper elementMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createTemplate(TemplateCreateRequest request) {
log.info("创建拼图模板: code={}, name={}", request.getCode(), request.getName());
// 检查编码是否已存在
int count = templateMapper.countByCode(request.getCode(), null);
if (count > 0) {
throw new IllegalArgumentException("模板编码已存在: " + request.getCode());
}
// 转换为实体并插入
PuzzleTemplateEntity entity = BeanUtil.copyProperties(request, PuzzleTemplateEntity.class);
entity.setDeleted(0);
templateMapper.insert(entity);
log.info("拼图模板创建成功: id={}, code={}", entity.getId(), entity.getCode());
return entity.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateTemplate(Long id, TemplateCreateRequest request) {
log.info("更新拼图模板: id={}", id);
// 检查模板是否存在
PuzzleTemplateEntity existing = templateMapper.getById(id);
if (existing == null) {
throw new IllegalArgumentException("模板不存在: " + id);
}
// 如果修改了编码,检查新编码是否已存在
if (request.getCode() != null && !request.getCode().equals(existing.getCode())) {
int count = templateMapper.countByCode(request.getCode(), id);
if (count > 0) {
throw new IllegalArgumentException("模板编码已存在: " + request.getCode());
}
}
// 更新
PuzzleTemplateEntity entity = BeanUtil.copyProperties(request, PuzzleTemplateEntity.class);
entity.setId(id);
templateMapper.update(entity);
log.info("拼图模板更新成功: id={}", id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteTemplate(Long id) {
log.info("删除拼图模板: id={}", id);
// 检查模板是否存在
PuzzleTemplateEntity existing = templateMapper.getById(id);
if (existing == null) {
throw new IllegalArgumentException("模板不存在: " + id);
}
// 删除模板及其所有元素
templateMapper.deleteById(id);
elementMapper.deleteByTemplateId(id);
log.info("拼图模板删除成功: id={}, 同时删除了关联的元素", id);
}
@Override
public PuzzleTemplateDTO getTemplateDetail(Long id) {
log.debug("获取拼图模板详情: id={}", id);
PuzzleTemplateEntity template = templateMapper.getById(id);
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + id);
}
return convertToDTO(template);
}
@Override
public PuzzleTemplateDTO getTemplateByCode(String code) {
log.debug("根据编码获取拼图模板: code={}", code);
PuzzleTemplateEntity template = templateMapper.getByCode(code);
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + code);
}
return convertToDTO(template);
}
@Override
public List<PuzzleTemplateDTO> listTemplates(Long scenicId, String category, Integer status) {
log.debug("查询拼图模板列表: scenicId={}, category={}, status={}", scenicId, category, status);
List<PuzzleTemplateEntity> templates = templateMapper.list(scenicId, category, status);
return templates.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long addElement(ElementCreateRequest request) {
log.info("添加元素到模板: templateId={}, elementKey={}", request.getTemplateId(), request.getElementKey());
// 检查模板是否存在
PuzzleTemplateEntity template = templateMapper.getById(request.getTemplateId());
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + request.getTemplateId());
}
// 转换为实体并插入
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class);
entity.setDeleted(0);
elementMapper.insert(entity);
log.info("元素添加成功: id={}, elementKey={}", entity.getId(), entity.getElementKey());
return entity.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void batchAddElements(Long templateId, List<ElementCreateRequest> elements) {
log.info("批量添加元素到模板: templateId={}, count={}", templateId, elements.size());
// 检查模板是否存在
PuzzleTemplateEntity template = templateMapper.getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + templateId);
}
// 转换为实体列表
List<PuzzleElementEntity> entityList = new ArrayList<>();
for (ElementCreateRequest request : elements) {
request.setTemplateId(templateId);
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class);
entity.setDeleted(0);
entityList.add(entity);
}
// 批量插入
if (!entityList.isEmpty()) {
elementMapper.batchInsert(entityList);
log.info("批量添加元素成功: templateId={}, count={}", templateId, entityList.size());
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateElement(Long id, ElementCreateRequest request) {
log.info("更新元素: id={}", id);
// 检查元素是否存在
PuzzleElementEntity existing = elementMapper.getById(id);
if (existing == null) {
throw new IllegalArgumentException("元素不存在: " + id);
}
// 更新
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class);
entity.setId(id);
elementMapper.update(entity);
log.info("元素更新成功: id={}", id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteElement(Long id) {
log.info("删除元素: id={}", id);
// 检查元素是否存在
PuzzleElementEntity existing = elementMapper.getById(id);
if (existing == null) {
throw new IllegalArgumentException("元素不存在: " + id);
}
elementMapper.deleteById(id);
log.info("元素删除成功: id={}", id);
}
@Override
public PuzzleElementDTO getElementDetail(Long id) {
log.debug("获取元素详情: id={}", id);
PuzzleElementEntity element = elementMapper.getById(id);
if (element == null) {
throw new IllegalArgumentException("元素不存在: " + id);
}
return BeanUtil.copyProperties(element, PuzzleElementDTO.class);
}
/**
* 转换为DTO(包含元素列表)
*/
private PuzzleTemplateDTO convertToDTO(PuzzleTemplateEntity template) {
PuzzleTemplateDTO dto = BeanUtil.copyProperties(template, PuzzleTemplateDTO.class);
// 查询元素列表
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
List<PuzzleElementDTO> elementDTOs = elements.stream()
.map(e -> BeanUtil.copyProperties(e, PuzzleElementDTO.class))
.collect(Collectors.toList());
dto.setElements(elementDTOs);
return dto;
}
}

View File

@@ -0,0 +1,342 @@
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.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import lombok.extern.slf4j.Slf4j;
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;
/**
* 拼图图片渲染引擎
* 核心功能:将模板和元素渲染成最终图片
*
* @author Claude
* @since 2025-01-17
*/
@Slf4j
@Component
public class PuzzleImageRenderer {
/**
* 渲染拼图图片
*
* @param template 模板配置
* @param elements 元素列表(已按z-index排序)
* @param dynamicData 动态数据(key=elementKey, value=实际值)
* @return 渲染后的图片
*/
public BufferedImage render(PuzzleTemplateEntity template,
List<PuzzleElementEntity> elements,
Map<String, String> dynamicData) {
log.info("开始渲染拼图: templateId={}, elementCount={}", template.getId(), elements.size());
// 1. 创建画布
BufferedImage canvas = new BufferedImage(
template.getCanvasWidth(),
template.getCanvasHeight(),
BufferedImage.TYPE_INT_RGB
);
Graphics2D g2d = canvas.createGraphics();
try {
// 2. 开启抗锯齿和优化渲染质量
enableHighQualityRendering(g2d);
// 3. 绘制背景
drawBackground(g2d, template);
// 4. 按z-index顺序绘制元素
for (PuzzleElementEntity element : elements) {
try {
if (element.getElementType() == 1) {
// 图片元素
drawImageElement(g2d, element, dynamicData);
} else if (element.getElementType() == 2) {
// 文字元素
drawTextElement(g2d, element, dynamicData);
}
} catch (Exception e) {
log.error("绘制元素失败: elementId={}, elementKey={}", element.getId(), element.getElementKey(), e);
// 继续绘制其他元素,不中断整个渲染流程
}
}
log.info("拼图渲染完成: templateId={}", template.getId());
return canvas;
} finally {
g2d.dispose();
}
}
/**
* 开启高质量渲染
*/
private 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);
}
/**
* 绘制背景
*/
private void drawBackground(Graphics2D g2d, PuzzleTemplateEntity template) {
if (template.getBackgroundType() == 0) {
// 纯色背景
String bgColor = StrUtil.isNotBlank(template.getBackgroundColor())
? template.getBackgroundColor() : "#FFFFFF";
g2d.setColor(parseColor(bgColor));
g2d.fillRect(0, 0, template.getCanvasWidth(), template.getCanvasHeight());
} else if (template.getBackgroundType() == 1 && StrUtil.isNotBlank(template.getBackgroundImage())) {
// 图片背景
try {
BufferedImage bgImage = downloadImage(template.getBackgroundImage());
BufferedImage scaledBg = ImgUtil.scale(bgImage, template.getCanvasWidth(), template.getCanvasHeight());
g2d.drawImage(scaledBg, 0, 0, null);
} catch (Exception e) {
log.error("绘制背景图片失败: {}", template.getBackgroundImage(), e);
// 降级为白色背景
g2d.setColor(Color.WHITE);
g2d.fillRect(0, 0, template.getCanvasWidth(), template.getCanvasHeight());
}
}
}
/**
* 绘制图片元素
*/
private void drawImageElement(Graphics2D g2d, PuzzleElementEntity element, Map<String, String> dynamicData) {
// 获取图片URL(优先使用动态数据)
String imageUrl = dynamicData.getOrDefault(element.getElementKey(), element.getDefaultImageUrl());
if (StrUtil.isBlank(imageUrl)) {
log.warn("图片元素没有图片URL: elementKey={}", element.getElementKey());
return;
}
try {
// 下载图片
BufferedImage image = downloadImage(imageUrl);
// 应用透明度
float opacity = (element.getOpacity() != null ? element.getOpacity() : 100) / 100f;
Composite originalComposite = g2d.getComposite();
if (opacity < 1.0f) {
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity));
}
// 缩放图片
BufferedImage scaledImage = scaleImage(image, element);
// 绘制(支持圆角)
Integer borderRadius = element.getBorderRadius() != null ? element.getBorderRadius() : 0;
if (borderRadius > 0) {
drawRoundedImage(g2d, scaledImage, element.getXPosition(), element.getYPosition(),
element.getWidth(), element.getHeight(), borderRadius);
} else {
g2d.drawImage(scaledImage, element.getXPosition(), element.getYPosition(),
element.getWidth(), element.getHeight(), 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() : "CONTAIN";
switch (fitMode) {
case "COVER":
// 等比缩放填充(可能裁剪)
return ImgUtil.scale(source, element.getWidth(), element.getHeight(), Color.TRANSPARENT);
case "FILL":
// 拉伸填充
return ImgUtil.scale(source, element.getWidth(), element.getHeight());
case "SCALE_DOWN":
// 缩小适应(不放大)
if (source.getWidth() <= element.getWidth() && source.getHeight() <= element.getHeight()) {
return source;
}
return ImgUtil.scale(source, element.getWidth(), element.getHeight(), Color.TRANSPARENT);
case "CONTAIN":
default:
// 等比缩放适应
return ImgUtil.scale(source, element.getWidth(), element.getHeight(), Color.TRANSPARENT);
}
}
/**
* 绘制圆角图片
*/
private void drawRoundedImage(Graphics2D g2d, BufferedImage image, int x, int y, int width, int height, int radius) {
// 创建圆角遮罩
BufferedImage rounded = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = rounded.createGraphics();
enableHighQualityRendering(g);
// 绘制圆角矩形
g.setColor(Color.WHITE);
g.fill(new RoundRectangle2D.Float(0, 0, width, height, radius * 2, radius * 2));
// 应用遮罩
g.setComposite(AlphaComposite.SrcAtop);
g.drawImage(image, 0, 0, width, height, null);
g.dispose();
// 绘制到画布
g2d.drawImage(rounded, x, y, null);
}
/**
* 绘制文字元素
*/
private void drawTextElement(Graphics2D g2d, PuzzleElementEntity element, Map<String, String> dynamicData) {
// 获取文本内容(优先使用动态数据)
String text = dynamicData.getOrDefault(element.getElementKey(), element.getDefaultText());
if (StrUtil.isBlank(text)) {
log.debug("文字元素没有文本内容: elementKey={}", element.getElementKey());
return;
}
// 设置字体
int fontStyle = getFontStyle(element);
String fontFamily = StrUtil.isNotBlank(element.getFontFamily()) ? element.getFontFamily() : "微软雅黑";
int fontSize = element.getFontSize() != null ? element.getFontSize() : 14;
Font font = new Font(fontFamily, fontStyle, fontSize);
g2d.setFont(font);
// 设置颜色
String fontColor = StrUtil.isNotBlank(element.getFontColor()) ? element.getFontColor() : "#000000";
g2d.setColor(parseColor(fontColor));
// 设置透明度
float opacity = (element.getOpacity() != null ? element.getOpacity() : 100) / 100f;
Composite originalComposite = g2d.getComposite();
if (opacity < 1.0f) {
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity));
}
// 绘制文本
drawText(g2d, text, element);
// 恢复透明度
g2d.setComposite(originalComposite);
}
/**
* 绘制文本(支持对齐、行高、最大行数)
*/
private void drawText(Graphics2D g2d, String text, PuzzleElementEntity element) {
FontMetrics fm = g2d.getFontMetrics();
int lineHeight = (int) (fm.getHeight() * (element.getLineHeight() != null ? element.getLineHeight().floatValue() : 1.5f));
// 简单处理:绘制单行或多行文本
String[] lines = text.split("\n");
Integer maxLines = element.getMaxLines();
int actualLines = maxLines != null ? Math.min(lines.length, maxLines) : lines.length;
String textAlign = StrUtil.isNotBlank(element.getTextAlign()) ? element.getTextAlign() : "LEFT";
int y = element.getYPosition() + fm.getAscent();
for (int i = 0; i < actualLines; i++) {
String line = lines[i];
int x = calculateTextX(line, element, fm, textAlign);
g2d.drawString(line, x, y);
y += lineHeight;
}
}
/**
* 计算文本X坐标(根据对齐方式)
*/
private int calculateTextX(String text, PuzzleElementEntity element, FontMetrics fm, String align) {
int textWidth = fm.stringWidth(text);
switch (align) {
case "CENTER":
return element.getXPosition() + (element.getWidth() - textWidth) / 2;
case "RIGHT":
return element.getXPosition() + element.getWidth() - textWidth;
case "LEFT":
default:
return element.getXPosition();
}
}
/**
* 获取字体样式
*/
private int getFontStyle(PuzzleElementEntity element) {
int style = Font.PLAIN;
String fontWeight = StrUtil.isNotBlank(element.getFontWeight()) ? element.getFontWeight() : "NORMAL";
String fontStyleStr = StrUtil.isNotBlank(element.getFontStyle()) ? element.getFontStyle() : "NORMAL";
if ("BOLD".equalsIgnoreCase(fontWeight)) {
style |= Font.BOLD;
}
if ("ITALIC".equalsIgnoreCase(fontStyleStr)) {
style |= Font.ITALIC;
}
return style;
}
/**
* 下载图片
*/
private BufferedImage downloadImage(String imageUrl) throws IOException {
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
// 网络图片
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
return ImageIO.read(new ByteArrayInputStream(imageBytes));
} else {
// 本地文件
return ImageIO.read(new File(imageUrl));
}
}
/**
* 解析颜色
*/
private Color parseColor(String colorStr) {
try {
if (colorStr.startsWith("#")) {
return Color.decode(colorStr);
} else if (colorStr.startsWith("rgb(")) {
// 简单解析 rgb(r,g,b)
String rgb = colorStr.substring(4, colorStr.length() - 1);
String[] parts = rgb.split(",");
return new Color(
Integer.parseInt(parts[0].trim()),
Integer.parseInt(parts[1].trim()),
Integer.parseInt(parts[2].trim())
);
}
} catch (Exception e) {
log.warn("解析颜色失败: {}, 使用黑色", colorStr);
}
return Color.BLACK;
}
}

View File

@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.puzzle.mapper.PuzzleElementMapper">
<!-- 结果映射 -->
<resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.entity.PuzzleElementEntity">
<id column="id" property="id"/>
<result column="template_id" property="templateId"/>
<result column="element_type" property="elementType"/>
<result column="element_key" property="elementKey"/>
<result column="element_name" property="elementName"/>
<result column="x_position" property="xPosition"/>
<result column="y_position" property="yPosition"/>
<result column="width" property="width"/>
<result column="height" property="height"/>
<result column="z_index" property="zIndex"/>
<result column="rotation" property="rotation"/>
<result column="opacity" property="opacity"/>
<result column="default_image_url" property="defaultImageUrl"/>
<result column="image_fit_mode" property="imageFitMode"/>
<result column="border_radius" property="borderRadius"/>
<result column="default_text" property="defaultText"/>
<result column="font_family" property="fontFamily"/>
<result column="font_size" property="fontSize"/>
<result column="font_color" property="fontColor"/>
<result column="font_weight" property="fontWeight"/>
<result column="font_style" property="fontStyle"/>
<result column="text_align" property="textAlign"/>
<result column="line_height" property="lineHeight"/>
<result column="max_lines" property="maxLines"/>
<result column="text_decoration" property="textDecoration"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
<result column="deleted" property="deleted"/>
<result column="deleted_at" property="deletedAt"/>
</resultMap>
<!-- 基础列 -->
<sql id="Base_Column_List">
id, template_id, element_type, element_key, element_name,
x_position, y_position, width, height, z_index, rotation, opacity,
default_image_url, image_fit_mode, border_radius,
default_text, font_family, font_size, font_color, font_weight, font_style,
text_align, line_height, max_lines, text_decoration,
create_time, update_time, deleted, deleted_at
</sql>
<!-- 根据ID查询 -->
<select id="getById" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM puzzle_element
WHERE id = #{id} AND deleted = 0
LIMIT 1
</select>
<!-- 根据模板ID查询元素列表(按z-index排序) -->
<select id="getByTemplateId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM puzzle_element
WHERE template_id = #{templateId} AND deleted = 0
ORDER BY z_index ASC, id ASC
</select>
<!-- 插入 -->
<insert id="insert" parameterType="com.ycwl.basic.puzzle.entity.PuzzleElementEntity"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO puzzle_element (
template_id, element_type, element_key, element_name,
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},
#{xPosition}, #{yPosition}, #{width}, #{height}, #{zIndex}, #{rotation}, #{opacity},
#{defaultImageUrl}, #{imageFitMode}, #{borderRadius},
#{defaultText}, #{fontFamily}, #{fontSize}, #{fontColor}, #{fontWeight}, #{fontStyle},
#{textAlign}, #{lineHeight}, #{maxLines}, #{textDecoration},
NOW(), NOW(), 0
)
</insert>
<!-- 批量插入 -->
<insert id="batchInsert">
INSERT INTO puzzle_element (
template_id, element_type, element_key, element_name,
x_position, y_position, width, height, z_index, rotation, opacity,
default_image_url, image_fit_mode, border_radius,
default_text, font_family, font_size, font_color, font_weight, font_style,
text_align, line_height, max_lines, text_decoration,
create_time, update_time, deleted
) VALUES
<foreach collection="list" item="item" separator=",">
(
#{item.templateId}, #{item.elementType}, #{item.elementKey}, #{item.elementName},
#{item.xPosition}, #{item.yPosition}, #{item.width}, #{item.height}, #{item.zIndex}, #{item.rotation}, #{item.opacity},
#{item.defaultImageUrl}, #{item.imageFitMode}, #{item.borderRadius},
#{item.defaultText}, #{item.fontFamily}, #{item.fontSize}, #{item.fontColor}, #{item.fontWeight}, #{item.fontStyle},
#{item.textAlign}, #{item.lineHeight}, #{item.maxLines}, #{item.textDecoration},
NOW(), NOW(), 0
)
</foreach>
</insert>
<!-- 更新 -->
<update id="update" parameterType="com.ycwl.basic.puzzle.entity.PuzzleElementEntity">
UPDATE puzzle_element
<set>
<if test="elementType != null">element_type = #{elementType},</if>
<if test="elementKey != null">element_key = #{elementKey},</if>
<if test="elementName != null">element_name = #{elementName},</if>
<if test="xPosition != null">x_position = #{xPosition},</if>
<if test="yPosition != null">y_position = #{yPosition},</if>
<if test="width != null">width = #{width},</if>
<if test="height != null">height = #{height},</if>
<if test="zIndex != null">z_index = #{zIndex},</if>
<if test="rotation != null">rotation = #{rotation},</if>
<if test="opacity != null">opacity = #{opacity},</if>
<if test="defaultImageUrl != null">default_image_url = #{defaultImageUrl},</if>
<if test="imageFitMode != null">image_fit_mode = #{imageFitMode},</if>
<if test="borderRadius != null">border_radius = #{borderRadius},</if>
<if test="defaultText != null">default_text = #{defaultText},</if>
<if test="fontFamily != null">font_family = #{fontFamily},</if>
<if test="fontSize != null">font_size = #{fontSize},</if>
<if test="fontColor != null">font_color = #{fontColor},</if>
<if test="fontWeight != null">font_weight = #{fontWeight},</if>
<if test="fontStyle != null">font_style = #{fontStyle},</if>
<if test="textAlign != null">text_align = #{textAlign},</if>
<if test="lineHeight != null">line_height = #{lineHeight},</if>
<if test="maxLines != null">max_lines = #{maxLines},</if>
<if test="textDecoration != null">text_decoration = #{textDecoration},</if>
update_time = NOW()
</set>
WHERE id = #{id} AND deleted = 0
</update>
<!-- 逻辑删除 -->
<update id="deleteById">
UPDATE puzzle_element
SET deleted = 1, deleted_at = NOW(), update_time = NOW()
WHERE id = #{id}
</update>
<!-- 根据模板ID删除所有元素 -->
<update id="deleteByTemplateId">
UPDATE puzzle_element
SET deleted = 1, deleted_at = NOW(), update_time = NOW()
WHERE template_id = #{templateId}
</update>
</mapper>

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper">
<!-- 结果映射 -->
<resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity">
<id column="id" property="id"/>
<result column="template_id" property="templateId"/>
<result column="template_code" property="templateCode"/>
<result column="user_id" property="userId"/>
<result column="order_id" property="orderId"/>
<result column="business_type" property="businessType"/>
<result column="generation_params" property="generationParams"/>
<result column="result_image_url" property="resultImageUrl"/>
<result column="result_file_size" property="resultFileSize"/>
<result column="result_width" property="resultWidth"/>
<result column="result_height" property="resultHeight"/>
<result column="status" property="status"/>
<result column="error_message" property="errorMessage"/>
<result column="generation_duration" property="generationDuration"/>
<result column="retry_count" property="retryCount"/>
<result column="scenic_id" property="scenicId"/>
<result column="client_ip" property="clientIp"/>
<result column="user_agent" property="userAgent"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
</resultMap>
<!-- 基础列 -->
<sql id="Base_Column_List">
id, template_id, template_code, user_id, order_id, business_type,
generation_params, result_image_url, result_file_size, result_width, result_height,
status, error_message, generation_duration, retry_count,
scenic_id, client_ip, user_agent, create_time, update_time
</sql>
<!-- 根据ID查询 -->
<select id="getById" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM puzzle_generation_record
WHERE id = #{id}
LIMIT 1
</select>
<!-- 查询用户的生成记录列表 -->
<select id="listByUserId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM puzzle_generation_record
WHERE user_id = #{userId}
ORDER BY create_time DESC
<if test="limit != null">
LIMIT #{limit}
</if>
</select>
<!-- 查询订单的生成记录列表 -->
<select id="listByOrderId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM puzzle_generation_record
WHERE order_id = #{orderId}
ORDER BY create_time DESC
</select>
<!-- 插入 -->
<insert id="insert" parameterType="com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO puzzle_generation_record (
template_id, template_code, user_id, order_id, business_type,
generation_params, result_image_url, result_file_size, result_width, result_height,
status, error_message, generation_duration, retry_count,
scenic_id, client_ip, user_agent, create_time, update_time
) VALUES (
#{templateId}, #{templateCode}, #{userId}, #{orderId}, #{businessType},
#{generationParams}, #{resultImageUrl}, #{resultFileSize}, #{resultWidth}, #{resultHeight},
#{status}, #{errorMessage}, #{generationDuration}, #{retryCount},
#{scenicId}, #{clientIp}, #{userAgent}, NOW(), NOW()
)
</insert>
<!-- 更新 -->
<update id="update" parameterType="com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity">
UPDATE puzzle_generation_record
<set>
<if test="resultImageUrl != null">result_image_url = #{resultImageUrl},</if>
<if test="resultFileSize != null">result_file_size = #{resultFileSize},</if>
<if test="resultWidth != null">result_width = #{resultWidth},</if>
<if test="resultHeight != null">result_height = #{resultHeight},</if>
<if test="status != null">status = #{status},</if>
<if test="errorMessage != null">error_message = #{errorMessage},</if>
<if test="generationDuration != null">generation_duration = #{generationDuration},</if>
<if test="retryCount != null">retry_count = #{retryCount},</if>
update_time = NOW()
</set>
WHERE id = #{id}
</update>
<!-- 更新为成功状态 -->
<update id="updateSuccess">
UPDATE puzzle_generation_record
SET status = 1,
result_image_url = #{resultImageUrl},
result_file_size = #{resultFileSize},
result_width = #{resultWidth},
result_height = #{resultHeight},
generation_duration = #{generationDuration},
update_time = NOW()
WHERE id = #{id}
</update>
<!-- 更新为失败状态 -->
<update id="updateFail">
UPDATE puzzle_generation_record
SET status = 2,
error_message = #{errorMessage},
update_time = NOW()
WHERE id = #{id}
</update>
</mapper>

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper">
<!-- 结果映射 -->
<resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity">
<id column="id" property="id"/>
<result column="name" property="name"/>
<result column="code" property="code"/>
<result column="canvas_width" property="canvasWidth"/>
<result column="canvas_height" property="canvasHeight"/>
<result column="background_type" property="backgroundType"/>
<result column="background_color" property="backgroundColor"/>
<result column="background_image" property="backgroundImage"/>
<result column="description" property="description"/>
<result column="category" property="category"/>
<result column="status" property="status"/>
<result column="scenic_id" property="scenicId"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
<result column="deleted" property="deleted"/>
<result column="deleted_at" property="deletedAt"/>
</resultMap>
<!-- 基础列 -->
<sql id="Base_Column_List">
id, name, code, canvas_width, canvas_height, background_type, background_color,
background_image, description, category, status, scenic_id, create_time, update_time, deleted, deleted_at
</sql>
<!-- 根据ID查询 -->
<select id="getById" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM puzzle_template
WHERE id = #{id} AND deleted = 0
LIMIT 1
</select>
<!-- 根据编码查询 -->
<select id="getByCode" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM puzzle_template
WHERE code = #{code} AND deleted = 0
LIMIT 1
</select>
<!-- 查询列表 -->
<select id="list" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM puzzle_template
WHERE deleted = 0
<if test="scenicId != null">
AND (scenic_id = #{scenicId} OR scenic_id IS NULL)
</if>
<if test="category != null and category != ''">
AND category = #{category}
</if>
<if test="status != null">
AND status = #{status}
</if>
ORDER BY create_time DESC
</select>
<!-- 插入 -->
<insert id="insert" parameterType="com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO puzzle_template (
name, code, canvas_width, canvas_height, background_type, background_color,
background_image, description, category, status, scenic_id, create_time, update_time, deleted
) VALUES (
#{name}, #{code}, #{canvasWidth}, #{canvasHeight}, #{backgroundType}, #{backgroundColor},
#{backgroundImage}, #{description}, #{category}, #{status}, #{scenicId}, NOW(), NOW(), 0
)
</insert>
<!-- 更新 -->
<update id="update" parameterType="com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity">
UPDATE puzzle_template
<set>
<if test="name != null">name = #{name},</if>
<if test="code != null">code = #{code},</if>
<if test="canvasWidth != null">canvas_width = #{canvasWidth},</if>
<if test="canvasHeight != null">canvas_height = #{canvasHeight},</if>
<if test="backgroundType != null">background_type = #{backgroundType},</if>
<if test="backgroundColor != null">background_color = #{backgroundColor},</if>
<if test="backgroundImage != null">background_image = #{backgroundImage},</if>
<if test="description != null">description = #{description},</if>
<if test="category != null">category = #{category},</if>
<if test="status != null">status = #{status},</if>
<if test="scenicId != null">scenic_id = #{scenicId},</if>
update_time = NOW()
</set>
WHERE id = #{id} AND deleted = 0
</update>
<!-- 逻辑删除 -->
<update id="deleteById">
UPDATE puzzle_template
SET deleted = 1, deleted_at = NOW(), update_time = NOW()
WHERE id = #{id}
</update>
<!-- 检查编码是否存在 -->
<select id="countByCode" resultType="int">
SELECT COUNT(1)
FROM puzzle_template
WHERE code = #{code} AND deleted = 0
<if test="excludeId != null">
AND id != #{excludeId}
</if>
</select>
</mapper>