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,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;
}
}