refactor(puzzle): 重构元素DTO及新增元素基类

- 将ElementCreateRequest和PuzzleElementDTO中的elementType从Integer改为String
- 删除所有类型特定字段,新增config和configMap支持JSON配置
- 新增BaseElement抽象基类定义元素通用行为
- 添加ElementConfig接口和具体实现类ImageConfig、TextConfig
- 创建ElementFactory工厂类和ElementRegistrar注册器
- 新增ElementType枚举和ElementValidationException异常类
- 实现ImageElement和TextElement具体元素类
- 添加Position位置信息封装类
This commit is contained in:
2025-11-18 08:13:38 +08:00
parent 5c49a5af9e
commit 3d361200b0
28 changed files with 2988 additions and 615 deletions

View File

@@ -0,0 +1,162 @@
package com.ycwl.basic.puzzle.util;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
/**
* Element配置辅助类
* 处理ElementCreateRequest到PuzzleElementEntity的转换
* 负责config和configMap之间的序列化/反序列化
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class ElementConfigHelper {
/**
* 将ElementCreateRequest转换为PuzzleElementEntity
*
* @param request 创建请求
* @return Entity对象
*/
public static PuzzleElementEntity toEntity(ElementCreateRequest request) {
PuzzleElementEntity entity = new PuzzleElementEntity();
// 基本属性
entity.setTemplateId(request.getTemplateId());
entity.setElementType(request.getElementType());
entity.setElementKey(request.getElementKey());
entity.setElementName(request.getElementName());
// 位置和布局属性
entity.setXPosition(request.getXPosition());
entity.setYPosition(request.getYPosition());
entity.setWidth(request.getWidth());
entity.setHeight(request.getHeight());
entity.setZIndex(request.getZIndex());
entity.setRotation(request.getRotation());
entity.setOpacity(request.getOpacity());
// 处理配置:优先使用config字符串,否则将configMap序列化为JSON
String configJson = getConfigJson(request);
entity.setConfig(configJson);
return entity;
}
/**
* 从Request获取JSON配置字符串
* 优先级:config字符串 > configMap序列化
*
* @param request 创建请求
* @return JSON配置字符串
*/
public static String getConfigJson(ElementCreateRequest request) {
// 优先使用config字段
if (StrUtil.isNotBlank(request.getConfig())) {
return request.getConfig();
}
// 否则将configMap序列化为JSON
if (request.getConfigMap() != null && !request.getConfigMap().isEmpty()) {
try {
return JacksonUtil.toJson(request.getConfigMap());
} catch (Exception e) {
log.error("configMap序列化为JSON失败", e);
throw new IllegalArgumentException("配置序列化失败: " + e.getMessage());
}
}
// 都为空则返回空JSON对象
return "{}";
}
/**
* 将JSON配置字符串解析为Map
*
* @param configJson JSON配置字符串
* @return Map对象
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> parseConfigToMap(String configJson) {
if (StrUtil.isBlank(configJson)) {
return Map.of();
}
try {
return JacksonUtil.fromJson(configJson, Map.class);
} catch (Exception e) {
log.error("JSON解析为Map失败: {}", configJson, e);
return Map.of();
}
}
/**
* 验证元素类型是否有效
*
* @param elementType 元素类型
* @return true-有效,false-无效
*/
public static boolean isValidElementType(String elementType) {
if (StrUtil.isBlank(elementType)) {
return false;
}
// 当前支持的类型
return "TEXT".equalsIgnoreCase(elementType) ||
"IMAGE".equalsIgnoreCase(elementType) ||
"QRCODE".equalsIgnoreCase(elementType) ||
"GRADIENT".equalsIgnoreCase(elementType) ||
"SHAPE".equalsIgnoreCase(elementType) ||
"DYNAMIC_IMAGE".equalsIgnoreCase(elementType);
}
/**
* 验证元素配置是否完整
*
* @param request 创建请求
* @throws IllegalArgumentException 配置不完整时抛出
*/
public static void validateRequest(ElementCreateRequest request) {
if (request.getTemplateId() == null) {
throw new IllegalArgumentException("模板ID不能为空");
}
if (StrUtil.isBlank(request.getElementType())) {
throw new IllegalArgumentException("元素类型不能为空");
}
if (!isValidElementType(request.getElementType())) {
throw new IllegalArgumentException("不支持的元素类型: " + request.getElementType());
}
if (StrUtil.isBlank(request.getElementKey())) {
throw new IllegalArgumentException("元素标识不能为空");
}
// 验证位置属性
if (request.getXPosition() == null || request.getYPosition() == null) {
throw new IllegalArgumentException("位置坐标不能为空");
}
if (request.getWidth() == null || request.getHeight() == null) {
throw new IllegalArgumentException("宽高不能为空");
}
if (request.getWidth() <= 0 || request.getHeight() <= 0) {
throw new IllegalArgumentException("宽高必须大于0");
}
// 验证配置
if (StrUtil.isBlank(request.getConfig()) &&
(request.getConfigMap() == null || request.getConfigMap().isEmpty())) {
throw new IllegalArgumentException("元素配置不能为空(config或configMap至少提供一个)");
}
}
}

View File

@@ -1,8 +1,10 @@
package com.ycwl.basic.puzzle.util;
import cn.hutool.core.img.ImgUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.base.ElementFactory;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import lombok.extern.slf4j.Slf4j;
@@ -10,29 +12,32 @@ import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.Ellipse2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.List;
import java.util.Map;
/**
* 拼图图片渲染引擎
* 拼图图片渲染引擎(重构版)
* 核心功能:将模板和元素渲染成最终图片
*
* 重构说明:
* - 使用ElementFactory创建Element实例
* - 元素渲染逻辑委托给Element自己实现
* - 删除drawImageElement和drawTextElement方法
* - 保留背景绘制和工具方法
*
* @author Claude
* @since 2025-01-17
* @since 2025-01-18
*/
@Slf4j
@Component
public class PuzzleImageRenderer {
/**
* 渲染拼图图片
* 渲染拼图图片(重构版)
*
* @param template 模板配置
* @param elements 元素列表(已按z-index排序)
@@ -48,7 +53,7 @@ public class PuzzleImageRenderer {
BufferedImage canvas = new BufferedImage(
template.getCanvasWidth(),
template.getCanvasHeight(),
BufferedImage.TYPE_INT_RGB
BufferedImage.TYPE_INT_ARGB // 使用ARGB支持透明度
);
Graphics2D g2d = canvas.createGraphics();
@@ -60,23 +65,33 @@ public class PuzzleImageRenderer {
// 3. 绘制背景
drawBackground(g2d, template);
// 4. 按z-index顺序绘制元素
for (PuzzleElementEntity element : elements) {
// 4. 创建渲染上下文
RenderContext context = new RenderContext(
g2d,
dynamicData,
template.getCanvasWidth(),
template.getCanvasHeight()
);
// 5. 使用ElementFactory创建Element实例并渲染
for (PuzzleElementEntity entity : elements) {
try {
if (element.getElementType() == 1) {
// 图片元素
drawImageElement(g2d, element, dynamicData);
} else if (element.getElementType() == 2) {
// 文字元素
drawTextElement(g2d, element, dynamicData);
}
// 使用工厂创建Element实例(自动加载配置和验证)
BaseElement element = ElementFactory.create(entity);
// 委托给Element自己渲染
element.render(context);
log.debug("元素渲染成功: type={}, key={}", element.getElementType().getCode(), element.getElementKey());
} catch (Exception e) {
log.error("绘制元素失败: elementId={}, elementKey={}", element.getId(), element.getElementKey(), e);
log.error("元素渲染失败: elementId={}, elementKey={}, error={}",
entity.getId(), entity.getElementKey(), e.getMessage(), e);
// 继续绘制其他元素,不中断整个渲染流程
}
}
log.info("拼图渲染完成: templateId={}", template.getId());
log.info("拼图渲染完成: templateId={}, 成功渲染元素数={}", template.getId(), elements.size());
return canvas;
} finally {
@@ -121,246 +136,9 @@ public class PuzzleImageRenderer {
}
/**
* 绘制图片元素
* 下载图片(工具方法,也可被外部使用)
*/
private void drawImageElement(Graphics2D g2d, PuzzleElementEntity element, Map<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(), null);
}
// 恢复透明度
g2d.setComposite(originalComposite);
} catch (Exception e) {
log.error("绘制图片元素失败: elementKey={}, imageUrl={}", element.getElementKey(), imageUrl, e);
}
}
/**
* 缩放图片
*/
private BufferedImage scaleImage(BufferedImage source, PuzzleElementEntity element) {
String fitMode = StrUtil.isNotBlank(element.getImageFitMode()) ? element.getImageFitMode() : "FILL";
int targetWidth = element.getWidth();
int targetHeight = element.getHeight();
switch (fitMode) {
case "COVER":
// 等比缩放填充(可能裁剪)- 使用原生Java缩放
return scaleImageKeepRatio(source, targetWidth, targetHeight, true);
case "CONTAIN":
// 等比缩放适应
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "SCALE_DOWN":
// 缩小适应(不放大)
if (source.getWidth() <= targetWidth && source.getHeight() <= targetHeight) {
return source;
}
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "FILL":
default:
// 拉伸填充到目标尺寸
BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
g.dispose();
return scaled;
}
}
/**
* 等比缩放图片
*/
private BufferedImage scaleImageKeepRatio(BufferedImage source, int targetWidth, int targetHeight, boolean cover) {
int sourceWidth = source.getWidth();
int sourceHeight = source.getHeight();
double widthRatio = (double) targetWidth / sourceWidth;
double heightRatio = (double) targetHeight / sourceHeight;
// cover模式使用较大的比例(填充),contain模式使用较小的比例(适应)
double ratio = cover ? Math.max(widthRatio, heightRatio) : Math.min(widthRatio, heightRatio);
int scaledWidth = (int) (sourceWidth * ratio);
int scaledHeight = (int) (sourceHeight * ratio);
// 创建目标尺寸的画布
BufferedImage result = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = result.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
// 居中绘制缩放后的图片
int x = (targetWidth - scaledWidth) / 2;
int y = (targetHeight - scaledHeight) / 2;
g.drawImage(source, x, y, scaledWidth, scaledHeight, null);
g.dispose();
return result;
}
/**
* 将Image转换为BufferedImage(已废弃,改用直接绘制)
*/
@Deprecated
private BufferedImage toBufferedImage(Image image, int width, int height) {
BufferedImage buffered = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = buffered.createGraphics();
// 居中绘制
int x = (width - image.getWidth(null)) / 2;
int y = (height - image.getHeight(null)) / 2;
g.drawImage(image, x, y, null);
g.dispose();
return buffered;
}
/**
* 绘制圆角图片
*/
private void drawRoundedImage(Graphics2D g2d, BufferedImage image, int x, int y, int width, int height, int radius) {
// 创建圆角遮罩
BufferedImage rounded = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = rounded.createGraphics();
enableHighQualityRendering(g);
// 绘制圆角矩形
g.setColor(Color.WHITE);
g.fill(new RoundRectangle2D.Float(0, 0, width, height, radius * 2, radius * 2));
// 应用遮罩
g.setComposite(AlphaComposite.SrcAtop);
g.drawImage(image, 0, 0, width, height, null);
g.dispose();
// 绘制到画布
g2d.drawImage(rounded, x, y, null);
}
/**
* 绘制文字元素
*/
private void drawTextElement(Graphics2D g2d, PuzzleElementEntity element, Map<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 {
public BufferedImage downloadImage(String imageUrl) throws IOException {
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
// 网络图片
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
@@ -372,9 +150,9 @@ public class PuzzleImageRenderer {
}
/**
* 解析颜色
* 解析颜色(工具方法,也可被外部使用)
*/
private Color parseColor(String colorStr) {
public Color parseColor(String colorStr) {
try {
if (colorStr.startsWith("#")) {
return Color.decode(colorStr);