diff --git a/src/main/java/com/ycwl/basic/puzzle/controller/PuzzleGenerateController.java b/src/main/java/com/ycwl/basic/puzzle/controller/PuzzleGenerateController.java new file mode 100644 index 00000000..d40de0b0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/controller/PuzzleGenerateController.java @@ -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 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("图片生成失败,请稍后重试"); + } + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/controller/PuzzleTemplateController.java b/src/main/java/com/ycwl/basic/puzzle/controller/PuzzleTemplateController.java new file mode 100644 index 00000000..d774e612 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/controller/PuzzleTemplateController.java @@ -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 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 updateTemplate(@PathVariable Long id, + @RequestBody TemplateCreateRequest request) { + log.info("更新模板请求: id={}", id); + templateService.updateTemplate(id, request); + return ApiResponse.success(null); + } + + /** + * 删除模板 + */ + @DeleteMapping("/templates/{id}") + public ApiResponse deleteTemplate(@PathVariable Long id) { + log.info("删除模板请求: id={}", id); + templateService.deleteTemplate(id); + return ApiResponse.success(null); + } + + /** + * 获取模板详情 + */ + @GetMapping("/templates/{id}") + public ApiResponse getTemplateDetail(@PathVariable Long id) { + log.debug("获取模板详情: id={}", id); + PuzzleTemplateDTO template = templateService.getTemplateDetail(id); + return ApiResponse.success(template); + } + + /** + * 获取模板列表 + */ + @GetMapping("/templates") + public ApiResponse> 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 templates = templateService.listTemplates(scenicId, category, status); + return ApiResponse.success(templates); + } + + /** + * 为模板添加单个元素 + */ + @PostMapping("/elements") + public ApiResponse 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 batchAddElements(@PathVariable Long templateId, + @RequestBody List elements) { + log.info("批量添加元素请求: templateId={}, count={}", templateId, elements.size()); + templateService.batchAddElements(templateId, elements); + return ApiResponse.success(null); + } + + /** + * 更新元素 + */ + @PutMapping("/elements/{id}") + public ApiResponse updateElement(@PathVariable Long id, + @RequestBody ElementCreateRequest request) { + log.info("更新元素请求: id={}", id); + templateService.updateElement(id, request); + return ApiResponse.success(null); + } + + /** + * 删除元素 + */ + @DeleteMapping("/elements/{id}") + public ApiResponse deleteElement(@PathVariable Long id) { + log.info("删除元素请求: id={}", id); + templateService.deleteElement(id); + return ApiResponse.success(null); + } + + /** + * 获取元素详情 + */ + @GetMapping("/elements/{id}") + public ApiResponse getElementDetail(@PathVariable Long id) { + log.debug("获取元素详情: id={}", id); + PuzzleElementDTO element = templateService.getElementDetail(id); + return ApiResponse.success(element); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/ElementCreateRequest.java b/src/main/java/com/ycwl/basic/puzzle/dto/ElementCreateRequest.java new file mode 100644 index 00000000..0b39a36e --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/dto/ElementCreateRequest.java @@ -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; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleElementDTO.java b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleElementDTO.java new file mode 100644 index 00000000..503b07a0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleElementDTO.java @@ -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; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateRequest.java b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateRequest.java new file mode 100644 index 00000000..7337c767 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateRequest.java @@ -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 dynamicData; + + /** + * 输出格式(可选,默认PNG) + * 支持:PNG、JPEG + */ + private String outputFormat; + + /** + * 图片质量(可选,默认90,范围0-100) + * 仅对JPEG格式有效 + */ + private Integer quality; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateResponse.java b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateResponse.java new file mode 100644 index 00000000..b7b0af77 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateResponse.java @@ -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); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleTemplateDTO.java b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleTemplateDTO.java new file mode 100644 index 00000000..6a1fa30f --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleTemplateDTO.java @@ -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 elements; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/TemplateCreateRequest.java b/src/main/java/com/ycwl/basic/puzzle/dto/TemplateCreateRequest.java new file mode 100644 index 00000000..f7a34516 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/dto/TemplateCreateRequest.java @@ -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; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleElementEntity.java b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleElementEntity.java new file mode 100644 index 00000000..d54b7e8b --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleElementEntity.java @@ -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; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleGenerationRecordEntity.java b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleGenerationRecordEntity.java new file mode 100644 index 00000000..76402f1c --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleGenerationRecordEntity.java @@ -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; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleTemplateEntity.java b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleTemplateEntity.java new file mode 100644 index 00000000..0ffd2038 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleTemplateEntity.java @@ -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; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleElementMapper.java b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleElementMapper.java new file mode 100644 index 00000000..fcda3ad7 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleElementMapper.java @@ -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 getByTemplateId(@Param("templateId") Long templateId); + + /** + * 插入元素 + */ + int insert(PuzzleElementEntity entity); + + /** + * 批量插入元素 + */ + int batchInsert(@Param("list") List list); + + /** + * 更新元素 + */ + int update(PuzzleElementEntity entity); + + /** + * 删除元素(逻辑删除) + */ + int deleteById(@Param("id") Long id); + + /** + * 根据模板ID删除所有元素(逻辑删除) + */ + int deleteByTemplateId(@Param("templateId") Long templateId); +} diff --git a/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleGenerationRecordMapper.java b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleGenerationRecordMapper.java new file mode 100644 index 00000000..8b6839dc --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleGenerationRecordMapper.java @@ -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 listByUserId(@Param("userId") Long userId, + @Param("limit") Integer limit); + + /** + * 查询订单的生成记录列表 + */ + List 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); +} diff --git a/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleTemplateMapper.java b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleTemplateMapper.java new file mode 100644 index 00000000..889311a0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleTemplateMapper.java @@ -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 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); +} diff --git a/src/main/java/com/ycwl/basic/puzzle/service/IPuzzleGenerateService.java b/src/main/java/com/ycwl/basic/puzzle/service/IPuzzleGenerateService.java new file mode 100644 index 00000000..959fac44 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/service/IPuzzleGenerateService.java @@ -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); +} diff --git a/src/main/java/com/ycwl/basic/puzzle/service/IPuzzleTemplateService.java b/src/main/java/com/ycwl/basic/puzzle/service/IPuzzleTemplateService.java new file mode 100644 index 00000000..437e27f1 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/service/IPuzzleTemplateService.java @@ -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 listTemplates(Long scenicId, String category, Integer status); + + /** + * 为模板添加元素 + */ + Long addElement(ElementCreateRequest request); + + /** + * 批量添加元素 + */ + void batchAddElements(Long templateId, List elements); + + /** + * 更新元素 + */ + void updateElement(Long id, ElementCreateRequest request); + + /** + * 删除元素 + */ + void deleteElement(Long id); + + /** + * 获取元素详情 + */ + PuzzleElementDTO getElementDetail(Long id); +} diff --git a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java new file mode 100644 index 00000000..30624cba --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java @@ -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 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; + } + } +} 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 new file mode 100644 index 00000000..095f7def --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleTemplateServiceImpl.java @@ -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 listTemplates(Long scenicId, String category, Integer status) { + log.debug("查询拼图模板列表: scenicId={}, category={}, status={}", scenicId, category, status); + + List 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 elements) { + log.info("批量添加元素到模板: templateId={}, count={}", templateId, elements.size()); + + // 检查模板是否存在 + 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); + } + + // 批量插入 + 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 elements = elementMapper.getByTemplateId(template.getId()); + List elementDTOs = elements.stream() + .map(e -> BeanUtil.copyProperties(e, PuzzleElementDTO.class)) + .collect(Collectors.toList()); + dto.setElements(elementDTOs); + + return dto; + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java b/src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java new file mode 100644 index 00000000..8767d4bf --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java @@ -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 elements, + Map 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 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 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; + } +} diff --git a/src/main/resources/mapper/PuzzleElementMapper.xml b/src/main/resources/mapper/PuzzleElementMapper.xml new file mode 100644 index 00000000..5afebfe1 --- /dev/null +++ b/src/main/resources/mapper/PuzzleElementMapper.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + 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 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 + + ( + #{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 + ) + + + + + + UPDATE puzzle_element + + element_type = #{elementType}, + element_key = #{elementKey}, + element_name = #{elementName}, + x_position = #{xPosition}, + y_position = #{yPosition}, + width = #{width}, + height = #{height}, + 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 + + + + + UPDATE puzzle_element + SET deleted = 1, deleted_at = NOW(), update_time = NOW() + WHERE id = #{id} + + + + + UPDATE puzzle_element + SET deleted = 1, deleted_at = NOW(), update_time = NOW() + WHERE template_id = #{templateId} + + + diff --git a/src/main/resources/mapper/PuzzleGenerationRecordMapper.xml b/src/main/resources/mapper/PuzzleGenerationRecordMapper.xml new file mode 100644 index 00000000..80c363bc --- /dev/null +++ b/src/main/resources/mapper/PuzzleGenerationRecordMapper.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + 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() + ) + + + + + UPDATE puzzle_generation_record + + result_image_url = #{resultImageUrl}, + result_file_size = #{resultFileSize}, + result_width = #{resultWidth}, + result_height = #{resultHeight}, + status = #{status}, + error_message = #{errorMessage}, + generation_duration = #{generationDuration}, + retry_count = #{retryCount}, + update_time = NOW() + + WHERE id = #{id} + + + + + 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 puzzle_generation_record + SET status = 2, + error_message = #{errorMessage}, + update_time = NOW() + WHERE id = #{id} + + + diff --git a/src/main/resources/mapper/PuzzleTemplateMapper.xml b/src/main/resources/mapper/PuzzleTemplateMapper.xml new file mode 100644 index 00000000..1ddb2bbf --- /dev/null +++ b/src/main/resources/mapper/PuzzleTemplateMapper.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + 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 + ) + + + + + UPDATE puzzle_template + + name = #{name}, + code = #{code}, + canvas_width = #{canvasWidth}, + canvas_height = #{canvasHeight}, + background_type = #{backgroundType}, + background_color = #{backgroundColor}, + background_image = #{backgroundImage}, + description = #{description}, + category = #{category}, + status = #{status}, + scenic_id = #{scenicId}, + update_time = NOW() + + WHERE id = #{id} AND deleted = 0 + + + + + UPDATE puzzle_template + SET deleted = 1, deleted_at = NOW(), update_time = NOW() + WHERE id = #{id} + + + + + +