test(puzzle

This commit is contained in:
2026-01-27 09:47:33 +08:00
parent bf6b866e67
commit ecbdec4518
25 changed files with 168 additions and 3783 deletions

View File

@@ -1,219 +0,0 @@
package com.ycwl.basic.puzzle.element.base;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.awt.*;
/**
* 元素抽象基类
* 定义所有Element的通用行为和属性
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
@Data
public abstract class BaseElement {
/**
* 元素ID
*/
protected Long id;
/**
* 元素类型
*/
protected ElementType elementType;
/**
* 元素标识(用于动态数据映射)
*/
protected String elementKey;
/**
* 元素名称(便于管理识别)
*/
protected String elementName;
/**
* 位置信息
*/
protected Position position;
/**
* JSON配置字符串(原始)
*/
protected String configJson;
/**
* 解析后的配置对象(子类特定)
*/
protected ElementConfig config;
// ========== 抽象方法(子类必须实现) ==========
/**
* 加载并解析JSON配置
* 子类需要将configJson解析为具体的Config对象
*
* @param configJson JSON配置字符串
*/
public abstract void loadConfig(String configJson);
/**
* 验证元素配置是否合法
*
* @throws ElementValidationException 配置不合法时抛出
*/
public abstract void validate() throws ElementValidationException;
/**
* 渲染元素到画布
* 这是元素的核心方法,负责将元素绘制到Graphics2D上
*
* @param context 渲染上下文
*/
public abstract void render(RenderContext context);
/**
* 获取配置的JSON Schema或说明
*
* @return 配置说明
*/
public abstract String getConfigSchema();
// ========== 通用方法 ==========
/**
* 初始化元素(加载配置并验证)
* 在创建Element实例后必须调用此方法
*/
public void initialize() {
if (StrUtil.isNotBlank(configJson)) {
loadConfig(configJson);
}
validate();
}
/**
* 应用透明度
* 如果元素有透明度设置,则应用到Graphics2D上
*
* @param g2d Graphics2D对象
* @return 原始的Composite对象(用于恢复)
*/
protected Composite applyOpacity(Graphics2D g2d) {
Composite originalComposite = g2d.getComposite();
if (position != null && position.hasOpacity()) {
g2d.setComposite(AlphaComposite.getInstance(
AlphaComposite.SRC_OVER,
position.getOpacityFloat()
));
}
return originalComposite;
}
/**
* 恢复透明度
*
* @param g2d Graphics2D对象
* @param originalComposite 原始的Composite对象
*/
protected void restoreOpacity(Graphics2D g2d, Composite originalComposite) {
if (originalComposite != null) {
g2d.setComposite(originalComposite);
}
}
/**
* 应用旋转
* 如果元素有旋转设置,则应用到Graphics2D上
*
* @param g2d Graphics2D对象
*/
protected void applyRotation(Graphics2D g2d) {
if (position != null && position.hasRotation()) {
// 以元素中心点为旋转中心
int centerX = position.getX() + position.getWidth() / 2;
int centerY = position.getY() + position.getHeight() / 2;
g2d.rotate(position.getRotationRadians(), centerX, centerY);
}
}
/**
* 解析颜色字符串(支持hex格式)
*
* @param colorStr 颜色字符串(如#FFFFFF)
* @return Color对象
*/
protected Color parseColor(String colorStr) {
if (StrUtil.isBlank(colorStr)) {
return Color.BLACK;
}
try {
// 移除#号
String hex = colorStr.startsWith("#") ? colorStr.substring(1) : colorStr;
// 解析RGB
return new Color(
Integer.valueOf(hex.substring(0, 2), 16),
Integer.valueOf(hex.substring(2, 4), 16),
Integer.valueOf(hex.substring(4, 6), 16)
);
} catch (Exception e) {
log.warn("颜色解析失败: {}, 使用默认黑色", colorStr);
return Color.BLACK;
}
}
/**
* 安全解析JSON配置
*
* @param configJson JSON字符串
* @param configClass 配置类
* @param <T> 配置类型
* @return 配置对象
*/
protected <T extends ElementConfig> T parseConfig(String configJson, Class<T> configClass) {
try {
if (StrUtil.isBlank(configJson)) {
// 返回默认实例
return configClass.getDeclaredConstructor().newInstance();
}
return JacksonUtil.fromJson(configJson, configClass);
} catch (Exception e) {
throw new ElementValidationException(
elementType != null ? elementType.getCode() : "UNKNOWN",
elementKey,
"JSON配置解析失败: " + e.getMessage()
);
}
}
/**
* 启用高质量渲染
*
* @param g2d Graphics2D对象
*/
protected void enableHighQualityRendering(Graphics2D g2d) {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
}
@Override
public String toString() {
return String.format("Element[type=%s, key=%s, name=%s, position=%s]",
elementType != null ? elementType.getCode() : "null",
elementKey,
elementName,
position);
}
}

View File

@@ -1,30 +0,0 @@
package com.ycwl.basic.puzzle.element.base;
/**
* 元素配置接口
* 所有Element的配置类都需要实现此接口
*
* @author Claude
* @since 2025-01-18
*/
public interface ElementConfig {
/**
* 获取配置说明(JSON Schema或描述)
*
* @return 配置说明
*/
default String getConfigSchema() {
return "{}";
}
/**
* 验证配置是否合法
* 子类应该重写此方法实现自己的验证逻辑
*
* @throws IllegalArgumentException 配置不合法时抛出
*/
default void validate() {
// 默认不做验证
}
}

View File

@@ -1,172 +0,0 @@
package com.ycwl.basic.puzzle.element.base;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Constructor;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 元素工厂类
* 负责根据类型创建Element实例
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class ElementFactory {
/**
* Element类型注册表
* key: ElementType枚举
* value: Element实现类的Class对象
*/
private static final Map<ElementType, Class<? extends BaseElement>> ELEMENT_REGISTRY = new ConcurrentHashMap<>();
/**
* 构造器缓存(性能优化)
* key: Element实现类
* value: 无参构造器
*/
private static final Map<Class<? extends BaseElement>, Constructor<? extends BaseElement>> CONSTRUCTOR_CACHE = new ConcurrentHashMap<>();
/**
* 注册Element类型
*
* @param type 元素类型
* @param elementClass Element实现类
*/
public static void register(ElementType type, Class<? extends BaseElement> elementClass) {
if (type == null || elementClass == null) {
throw new IllegalArgumentException("注册参数不能为null");
}
ELEMENT_REGISTRY.put(type, elementClass);
log.info("注册Element类型: {} -> {}", type.getCode(), elementClass.getName());
}
/**
* 根据Entity创建Element实例
*
* @param entity PuzzleElementEntity
* @return Element实例
*/
public static BaseElement create(PuzzleElementEntity entity) {
if (entity == null) {
throw new IllegalArgumentException("Entity不能为null");
}
// 解析元素类型
ElementType type;
try {
type = ElementType.fromCode(entity.getElementType());
} catch (IllegalArgumentException e) {
throw new ElementValidationException(
entity.getElementType(),
entity.getElementKey(),
"未知的元素类型: " + entity.getElementType()
);
}
// 检查类型是否已实现
if (!type.isImplemented()) {
throw new ElementValidationException(
type.getCode(),
entity.getElementKey(),
"元素类型尚未实现: " + type.getName()
);
}
// 获取Element实现类
Class<? extends BaseElement> elementClass = ELEMENT_REGISTRY.get(type);
if (elementClass == null) {
throw new ElementValidationException(
type.getCode(),
entity.getElementKey(),
"元素类型未注册: " + type.getCode()
);
}
// 创建Element实例
BaseElement element = createInstance(elementClass);
// 填充基本属性
element.setId(entity.getId());
element.setElementType(type);
element.setElementKey(entity.getElementKey());
element.setElementName(entity.getElementName());
element.setConfigJson(entity.getConfig());
// 填充位置信息
Position position = new Position(
entity.getXPosition(),
entity.getYPosition(),
entity.getWidth(),
entity.getHeight(),
entity.getZIndex(),
entity.getRotation(),
entity.getOpacity()
);
element.setPosition(position);
// 初始化(加载配置并验证)
element.initialize();
log.debug("创建Element成功: type={}, key={}", type.getCode(), entity.getElementKey());
return element;
}
/**
* 创建Element实例(使用反射)
*
* @param elementClass Element类
* @return Element实例
*/
private static BaseElement createInstance(Class<? extends BaseElement> elementClass) {
try {
// 从缓存获取构造器
Constructor<? extends BaseElement> constructor = CONSTRUCTOR_CACHE.get(elementClass);
if (constructor == null) {
constructor = elementClass.getDeclaredConstructor();
constructor.setAccessible(true);
CONSTRUCTOR_CACHE.put(elementClass, constructor);
}
return constructor.newInstance();
} catch (Exception e) {
throw new ElementValidationException(
"Element实例创建失败: " + elementClass.getName() + ", 原因: " + e.getMessage(),
e
);
}
}
/**
* 获取已注册的Element类型列表
*
* @return Element类型列表
*/
public static Map<ElementType, Class<? extends BaseElement>> getRegisteredTypes() {
return new ConcurrentHashMap<>(ELEMENT_REGISTRY);
}
/**
* 检查类型是否已注册
*
* @param type 元素类型
* @return true-已注册,false-未注册
*/
public static boolean isRegistered(ElementType type) {
return ELEMENT_REGISTRY.containsKey(type);
}
/**
* 清空注册表(主要用于测试)
*/
public static void clearRegistry() {
ELEMENT_REGISTRY.clear();
CONSTRUCTOR_CACHE.clear();
log.warn("Element注册表已清空");
}
}

View File

@@ -1,40 +0,0 @@
package com.ycwl.basic.puzzle.element.base;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.element.impl.ImageElement;
import com.ycwl.basic.puzzle.element.impl.TextElement;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
/**
* Element注册器
* 在Spring容器初始化时自动注册所有Element类型
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
@Component
public class ElementRegistrar {
@PostConstruct
public void registerElements() {
log.info("开始注册Element类型...");
// 注册文字元素
ElementFactory.register(ElementType.TEXT, TextElement.class);
// 注册图片元素
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
// 未来扩展的Element类型在这里注册
// ElementFactory.register(ElementType.QRCODE, QRCodeElement.class);
// ElementFactory.register(ElementType.GRADIENT, GradientElement.class);
// ElementFactory.register(ElementType.SHAPE, ShapeElement.class);
// ElementFactory.register(ElementType.DYNAMIC_IMAGE, DynamicImageElement.class);
log.info("Element类型注册完成,共注册{}种类型", ElementFactory.getRegisteredTypes().size());
}
}

View File

@@ -1,95 +0,0 @@
package com.ycwl.basic.puzzle.element.base;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 元素位置信息
* 封装所有与位置、大小、变换相关的属性
*
* @author Claude
* @since 2025-01-18
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Position {
/**
* X坐标(相对于画布左上角,像素)
*/
private Integer x;
/**
* Y坐标(相对于画布左上角,像素)
*/
private Integer y;
/**
* 宽度(像素)
*/
private Integer width;
/**
* 高度(像素)
*/
private Integer height;
/**
* 层级(数值越大越靠上,决定绘制顺序)
*/
private Integer zIndex;
/**
* 旋转角度(0-360度,顺时针)
*/
private Integer rotation;
/**
* 不透明度(0-100,100为完全不透明)
*/
private Integer opacity;
/**
* 获取不透明度的浮点数表示(0.0-1.0)
*
* @return 不透明度(0.0-1.0)
*/
public float getOpacityFloat() {
if (opacity == null) {
return 1.0f;
}
return Math.max(0, Math.min(100, opacity)) / 100.0f;
}
/**
* 获取旋转角度的弧度值
*
* @return 弧度值
*/
public double getRotationRadians() {
if (rotation == null || rotation == 0) {
return 0;
}
return Math.toRadians(rotation);
}
/**
* 是否需要旋转
*
* @return true-需要旋转,false-不需要
*/
public boolean hasRotation() {
return rotation != null && rotation != 0;
}
/**
* 是否有透明度
*
* @return true-有透明度,false-完全不透明
*/
public boolean hasOpacity() {
return opacity != null && opacity < 100;
}
}

View File

@@ -1,199 +0,0 @@
package com.ycwl.basic.puzzle.element.config;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.base.ElementConfig;
import lombok.Data;
/**
* 图片元素配置
*
* @author Claude
* @since 2025-01-18
*/
@Data
public class ImageConfig implements ElementConfig {
/**
* 默认图片URL
*/
private String defaultImageUrl;
/**
* 图片适配模式
* CONTAIN - 等比缩放适应(保持宽高比,可能留白)
* COVER - 等比缩放填充(保持宽高比,可能裁剪)
* FILL - 拉伸填充(不保持宽高比,可能变形)
* SCALE_DOWN - 缩小适应(类似CONTAIN,但不放大)
*/
private String imageFitMode = "FILL";
/**
* 圆角半径(像素,0为直角)
* 注意:当 borderRadius >= min(width, height) / 2 时,效果为圆形
*/
private Integer borderRadius = 0;
/**
* 叠加图片配置
* 用于在主图上叠加另一张图片(如二维码中心的头像)
*/
private OverlayImageConfig overlayImage;
/**
* 叠加图片配置类
*/
@Data
public static class OverlayImageConfig {
/**
* 叠加图片的数据源 key(从 dynamicData 获取 URL)
* 例如:faceAvatar
*/
private String imageKey;
/**
* 叠加图片的默认 URL(当 dynamicData 中无对应值时使用)
*/
private String defaultImageUrl;
/**
* 叠加图片宽度占主图宽度的比例(0.0 - 1.0)
* 默认 0.45(与现有水印实现一致)
*/
private Double widthRatio = 0.45;
/**
* 叠加图片高度占主图高度的比例(0.0 - 1.0)
* 默认 0.45
*/
private Double heightRatio = 0.45;
/**
* 叠加图片的适配模式
* 默认 COVER(与现有水印实现一致)
*/
private String imageFitMode = "COVER";
/**
* 叠加图片的圆角半径
* 默认 -1 表示自动计算为圆形(min(width, height) / 2)
*/
private Integer borderRadius = -1;
/**
* 叠加图片的水平对齐方式
* CENTER - 居中(默认)
* LEFT - 左对齐
* RIGHT - 右对齐
*/
private String horizontalAlign = "CENTER";
/**
* 叠加图片的垂直对齐方式
* CENTER - 居中(默认)
* TOP - 顶部对齐
* BOTTOM - 底部对齐
*/
private String verticalAlign = "CENTER";
/**
* 水平偏移量(像素),正值向右,负值向左
*/
private Integer offsetX = 0;
/**
* 垂直偏移量(像素),正值向下,负值向上
*/
private Integer offsetY = 0;
}
@Override
public void validate() {
// 校验圆角半径
if (borderRadius != null && borderRadius < 0) {
throw new IllegalArgumentException("圆角半径不能为负数: " + borderRadius);
}
// 校验图片适配模式
if (StrUtil.isNotBlank(imageFitMode)) {
String mode = imageFitMode.toUpperCase();
if (!"CONTAIN".equals(mode) &&
!"COVER".equals(mode) &&
!"FILL".equals(mode) &&
!"SCALE_DOWN".equals(mode)) {
throw new IllegalArgumentException("图片适配模式只能是CONTAIN、COVER、FILL或SCALE_DOWN: " + imageFitMode);
}
}
// 校验图片URL(注意:现在可以通过 dynamicData 动态填充,所以允许为空)
if (StrUtil.isNotBlank(defaultImageUrl)) {
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
}
}
// 校验叠加图片配置
if (overlayImage != null) {
validateOverlayImage(overlayImage);
}
}
private void validateOverlayImage(OverlayImageConfig overlay) {
// 校验比例范围
if (overlay.getWidthRatio() != null && (overlay.getWidthRatio() <= 0 || overlay.getWidthRatio() > 1)) {
throw new IllegalArgumentException("叠加图片宽度比例必须在 0-1 之间: " + overlay.getWidthRatio());
}
if (overlay.getHeightRatio() != null && (overlay.getHeightRatio() <= 0 || overlay.getHeightRatio() > 1)) {
throw new IllegalArgumentException("叠加图片高度比例必须在 0-1 之间: " + overlay.getHeightRatio());
}
// 校验对齐方式
if (StrUtil.isNotBlank(overlay.getHorizontalAlign())) {
String align = overlay.getHorizontalAlign().toUpperCase();
if (!"CENTER".equals(align) && !"LEFT".equals(align) && !"RIGHT".equals(align)) {
throw new IllegalArgumentException("水平对齐方式只能是CENTER、LEFT或RIGHT: " + overlay.getHorizontalAlign());
}
}
if (StrUtil.isNotBlank(overlay.getVerticalAlign())) {
String align = overlay.getVerticalAlign().toUpperCase();
if (!"CENTER".equals(align) && !"TOP".equals(align) && !"BOTTOM".equals(align)) {
throw new IllegalArgumentException("垂直对齐方式只能是CENTER、TOP或BOTTOM: " + overlay.getVerticalAlign());
}
}
// 校验叠加图片URL
if (StrUtil.isNotBlank(overlay.getDefaultImageUrl())) {
if (!overlay.getDefaultImageUrl().startsWith("http://") && !overlay.getDefaultImageUrl().startsWith("https://")) {
throw new IllegalArgumentException("叠加图片URL必须以http://或https://开头: " + overlay.getDefaultImageUrl());
}
}
// 校验适配模式
if (StrUtil.isNotBlank(overlay.getImageFitMode())) {
String mode = overlay.getImageFitMode().toUpperCase();
if (!"CONTAIN".equals(mode) && !"COVER".equals(mode) && !"FILL".equals(mode) && !"SCALE_DOWN".equals(mode)) {
throw new IllegalArgumentException("叠加图片适配模式只能是CONTAIN、COVER、FILL或SCALE_DOWN: " + overlay.getImageFitMode());
}
}
}
@Override
public String getConfigSchema() {
return "{\n" +
" \"defaultImageUrl\": \"https://example.com/image.jpg\",\n" +
" \"imageFitMode\": \"CONTAIN|COVER|FILL|SCALE_DOWN\",\n" +
" \"borderRadius\": 0,\n" +
" \"overlayImage\": {\n" +
" \"imageKey\": \"faceAvatar\",\n" +
" \"defaultImageUrl\": \"https://example.com/default-avatar.png\",\n" +
" \"widthRatio\": 0.45,\n" +
" \"heightRatio\": 0.45,\n" +
" \"imageFitMode\": \"COVER\",\n" +
" \"borderRadius\": -1,\n" +
" \"horizontalAlign\": \"CENTER\",\n" +
" \"verticalAlign\": \"CENTER\",\n" +
" \"offsetX\": 0,\n" +
" \"offsetY\": 0\n" +
" }\n" +
"}";
}
}

View File

@@ -1,141 +0,0 @@
package com.ycwl.basic.puzzle.element.config;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.base.ElementConfig;
import lombok.Data;
import java.math.BigDecimal;
/**
* 文字元素配置
*
* @author Claude
* @since 2025-01-18
*/
@Data
public class TextConfig implements ElementConfig {
/**
* 默认文本内容
*/
private String defaultText;
/**
* 字体名称(如"微软雅黑"、"PingFang SC")
*/
private String fontFamily = "微软雅黑";
/**
* 字号(像素,范围10-200)
*/
private Integer fontSize = 14;
/**
* 字体颜色(hex格式,如#000000)
*/
private String fontColor = "#000000";
/**
* 字重:NORMAL-正常 BOLD-粗体
*/
private String fontWeight = "NORMAL";
/**
* 字体样式:NORMAL-正常 ITALIC-斜体
*/
private String fontStyle = "NORMAL";
/**
* 对齐方式:LEFT-左对齐 CENTER-居中 RIGHT-右对齐
*/
private String textAlign = "LEFT";
/**
* 行高倍数(如1.5表示1.5倍行距)
*/
private BigDecimal lineHeight = new BigDecimal("1.5");
/**
* 最大行数(超出后截断,NULL表示不限制)
*/
private Integer maxLines;
/**
* 文本装饰:NONE-无 UNDERLINE-下划线 LINE_THROUGH-删除线
*/
private String textDecoration = "NONE";
@Override
public void validate() {
// 校验字号范围
if (fontSize != null && (fontSize < 10 || fontSize > 200)) {
throw new IllegalArgumentException("字号必须在10-200之间: " + fontSize);
}
// 校验行高
if (lineHeight != null && lineHeight.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("行高必须大于0: " + lineHeight);
}
// 校验最大行数
if (maxLines != null && maxLines <= 0) {
throw new IllegalArgumentException("最大行数必须大于0: " + maxLines);
}
// 校验字重
if (StrUtil.isNotBlank(fontWeight)) {
if (!"NORMAL".equalsIgnoreCase(fontWeight) && !"BOLD".equalsIgnoreCase(fontWeight)) {
throw new IllegalArgumentException("字重只能是NORMAL或BOLD: " + fontWeight);
}
}
// 校验字体样式
if (StrUtil.isNotBlank(fontStyle)) {
if (!"NORMAL".equalsIgnoreCase(fontStyle) && !"ITALIC".equalsIgnoreCase(fontStyle)) {
throw new IllegalArgumentException("字体样式只能是NORMAL或ITALIC: " + fontStyle);
}
}
// 校验对齐方式
if (StrUtil.isNotBlank(textAlign)) {
if (!"LEFT".equalsIgnoreCase(textAlign) &&
!"CENTER".equalsIgnoreCase(textAlign) &&
!"RIGHT".equalsIgnoreCase(textAlign)) {
throw new IllegalArgumentException("对齐方式只能是LEFT、CENTER或RIGHT: " + textAlign);
}
}
// 校验文本装饰
if (StrUtil.isNotBlank(textDecoration)) {
if (!"NONE".equalsIgnoreCase(textDecoration) &&
!"UNDERLINE".equalsIgnoreCase(textDecoration) &&
!"LINE_THROUGH".equalsIgnoreCase(textDecoration)) {
throw new IllegalArgumentException("文本装饰只能是NONE、UNDERLINE或LINE_THROUGH: " + textDecoration);
}
}
// 校验颜色格式
if (StrUtil.isNotBlank(fontColor)) {
String hex = fontColor.startsWith("#") ? fontColor.substring(1) : fontColor;
if (hex.length() != 6 || !hex.matches("[0-9A-Fa-f]{6}")) {
throw new IllegalArgumentException("颜色格式必须是hex格式(如#FFFFFF): " + fontColor);
}
}
}
@Override
public String getConfigSchema() {
return "{\n" +
" \"defaultText\": \"默认文本\",\n" +
" \"fontFamily\": \"微软雅黑\",\n" +
" \"fontSize\": 14,\n" +
" \"fontColor\": \"#000000\",\n" +
" \"fontWeight\": \"NORMAL|BOLD\",\n" +
" \"fontStyle\": \"NORMAL|ITALIC\",\n" +
" \"textAlign\": \"LEFT|CENTER|RIGHT\",\n" +
" \"lineHeight\": 1.5,\n" +
" \"maxLines\": null,\n" +
" \"textDecoration\": \"NONE|UNDERLINE|LINE_THROUGH\"\n" +
"}";
}
}

View File

@@ -1,40 +0,0 @@
package com.ycwl.basic.puzzle.element.exception;
/**
* 元素验证异常
* 当元素配置不合法时抛出
*
* @author Claude
* @since 2025-01-18
*/
public class ElementValidationException extends RuntimeException {
private final String elementKey;
private final String elementType;
public ElementValidationException(String message) {
super(message);
this.elementKey = null;
this.elementType = null;
}
public ElementValidationException(String elementType, String elementKey, String message) {
super(String.format("[%s:%s] %s", elementType, elementKey, message));
this.elementKey = elementKey;
this.elementType = elementType;
}
public ElementValidationException(String message, Throwable cause) {
super(message, cause);
this.elementKey = null;
this.elementType = null;
}
public String getElementKey() {
return elementKey;
}
public String getElementType() {
return elementType;
}
}

View File

@@ -1,323 +0,0 @@
package com.ycwl.basic.puzzle.element.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.config.ImageConfig;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.Ellipse2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.net.InetAddress;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 图片元素实现
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class ImageElement extends BaseElement {
private static final int DOWNLOAD_TIMEOUT_MS = 5000;
private ImageConfig imageConfig;
@Override
public void loadConfig(String configJson) {
this.imageConfig = parseConfig(configJson, ImageConfig.class);
this.config = imageConfig;
}
@Override
public void validate() throws ElementValidationException {
try {
if (imageConfig == null) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"图片配置不能为空"
);
}
imageConfig.validate();
} catch (IllegalArgumentException e) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"配置验证失败: " + e.getMessage()
);
}
}
@Override
public void render(RenderContext context) {
Graphics2D g2d = context.getGraphics();
// 获取图片URL(优先使用动态数据)
String imageUrl = context.getDynamicData(elementKey, imageConfig.getDefaultImageUrl());
if (StrUtil.isBlank(imageUrl)) {
log.warn("图片元素没有图片URL: elementKey={}", elementKey);
return;
}
try {
// 下载图片
BufferedImage image = downloadImage(imageUrl);
if (image == null) {
log.error("图片下载失败: imageUrl={}", imageUrl);
return;
}
// 应用透明度
Composite originalComposite = applyOpacity(g2d);
// 缩放图片(根据适配模式)
BufferedImage scaledImage = scaleImage(image);
// 绘制图片(支持圆角)
if (imageConfig.getBorderRadius() != null && imageConfig.getBorderRadius() > 0) {
drawRoundedImage(g2d, scaledImage);
} else {
// 直接绘制
g2d.drawImage(scaledImage, position.getX(), position.getY(), null);
}
// 恢复透明度
restoreOpacity(g2d, originalComposite);
} catch (Exception e) {
log.error("图片元素渲染失败: elementKey={}, imageUrl={}", elementKey, imageUrl, e);
}
}
@Override
public String getConfigSchema() {
return imageConfig != null ? imageConfig.getConfigSchema() : "{}";
}
/**
* 下载图片
*
* @param imageUrl 图片URL或本地文件路径
* @return BufferedImage对象
*/
protected BufferedImage downloadImage(String imageUrl) {
if (StrUtil.isBlank(imageUrl)) {
return null;
}
if (isRemoteUrl(imageUrl)) {
if (!isSafeRemoteUrl(imageUrl)) {
log.warn("图片URL未通过安全校验, 已拒绝下载: {}", imageUrl);
return null;
}
try {
log.debug("下载图片: url={}", imageUrl);
byte[] imageBytes = HttpRequest.get(imageUrl)
.timeout(DOWNLOAD_TIMEOUT_MS)
.setFollowRedirects(false)
.execute()
.bodyBytes();
return ImageIO.read(new ByteArrayInputStream(imageBytes));
} catch (Exception e) {
log.error("图片下载失败: url={}", imageUrl, e);
return null;
}
}
return loadLocalImage(imageUrl);
}
/**
* 缩放图片(根据适配模式)
*
* @param source 原始图片
* @return 缩放后的图片
*/
private BufferedImage scaleImage(BufferedImage source) {
int targetWidth = position.getWidth();
int targetHeight = position.getHeight();
String fitMode = StrUtil.isNotBlank(imageConfig.getImageFitMode())
? imageConfig.getImageFitMode().toUpperCase()
: "FILL";
switch (fitMode) {
case "COVER":
// 等比缩放填充(可能裁剪)- 使用较大的比例
return scaleImageKeepRatio(source, targetWidth, targetHeight, true);
case "CONTAIN":
// 等比缩放适应(可能留白)- 使用较小的比例
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "SCALE_DOWN":
// 缩小适应(不放大)
if (source.getWidth() <= targetWidth && source.getHeight() <= targetHeight) {
return source; // 原图已小于目标,不处理
}
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "FILL":
default:
// 拉伸填充到目标尺寸(不保持宽高比,可能变形)
BufferedImage scaled = new BufferedImage(
targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = scaled.createGraphics();
enableHighQualityRendering(g);
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
g.dispose();
return scaled;
}
}
/**
* 等比缩放图片(保持宽高比)
*
* @param source 原始图片
* @param targetWidth 目标宽度
* @param targetHeight 目标高度
* @param cover true-COVER模式,false-CONTAIN模式
* @return 缩放后的图片
*/
private BufferedImage scaleImageKeepRatio(BufferedImage source,
int targetWidth, int targetHeight,
boolean cover) {
int sourceWidth = source.getWidth();
int sourceHeight = source.getHeight();
double widthRatio = (double) targetWidth / sourceWidth;
double heightRatio = (double) targetHeight / sourceHeight;
// cover模式使用较大比例(填充),contain模式使用较小比例(适应)
double ratio = cover
? Math.max(widthRatio, heightRatio)
: Math.min(widthRatio, heightRatio);
int scaledWidth = (int) (sourceWidth * ratio);
int scaledHeight = (int) (sourceHeight * ratio);
// 创建目标尺寸的画布
BufferedImage result = new BufferedImage(
targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = result.createGraphics();
enableHighQualityRendering(g);
// 居中绘制缩放后的图片
int x = (targetWidth - scaledWidth) / 2;
int y = (targetHeight - scaledHeight) / 2;
g.drawImage(source, x, y, scaledWidth, scaledHeight, null);
g.dispose();
return result;
}
/**
* 绘制圆角图片
*
* @param g2d Graphics2D对象
* @param image 图片
*/
private void drawRoundedImage(Graphics2D g2d, BufferedImage image) {
int width = position.getWidth();
int height = position.getHeight();
int radius = imageConfig.getBorderRadius();
// 创建圆角遮罩
BufferedImage rounded = new BufferedImage(
width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = rounded.createGraphics();
enableHighQualityRendering(g);
// 判断是否需要绘制圆形(当圆角半径>=最小边长的一半时)
boolean isCircle = (radius * 2 >= Math.min(width, height));
if (isCircle) {
// 绘制圆形遮罩
g.setColor(Color.WHITE);
g.fill(new Ellipse2D.Float(0, 0, width, height));
} else {
// 绘制圆角矩形遮罩
g.setColor(Color.WHITE);
g.fill(new RoundRectangle2D.Float(0, 0, width, height, radius * 2, radius * 2));
}
// 应用遮罩
g.setComposite(AlphaComposite.SrcAtop);
g.drawImage(image, 0, 0, width, height, null);
g.dispose();
// 绘制到主画布
g2d.drawImage(rounded, position.getX(), position.getY(), null);
}
private boolean isRemoteUrl(String imageUrl) {
return StrUtil.startWithIgnoreCase(imageUrl, "http://") ||
StrUtil.startWithIgnoreCase(imageUrl, "https://");
}
/**
* 判断URL是否为安全的公网HTTP地址,避免SSRF
*/
protected boolean isSafeRemoteUrl(String imageUrl) {
if (StrUtil.isBlank(imageUrl)) {
return false;
}
try {
URL url = new URL(imageUrl);
String protocol = url.getProtocol();
if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) {
return false;
}
InetAddress address = InetAddress.getByName(url.getHost());
if (address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()) {
return false;
}
return true;
} catch (Exception e) {
log.warn("图片URL解析失败: {}", imageUrl, e);
return false;
}
}
private BufferedImage loadLocalImage(String imageUrl) {
try {
Path path;
if (StrUtil.startWithIgnoreCase(imageUrl, "file:")) {
path = Paths.get(new URI(imageUrl));
} else {
path = Paths.get(imageUrl);
}
if (!Files.exists(path) || !Files.isRegularFile(path)) {
log.error("本地图片文件不存在: {}", imageUrl);
return null;
}
log.debug("加载本地图片: {}", path);
try (var inputStream = Files.newInputStream(path)) {
return ImageIO.read(inputStream);
}
} catch (Exception e) {
log.error("本地图片加载失败: {}", imageUrl, e);
return null;
}
}
}

View File

@@ -1,225 +0,0 @@
package com.ycwl.basic.puzzle.element.impl;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.config.TextConfig;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import lombok.extern.slf4j.Slf4j;
import java.awt.*;
import java.awt.font.LineMetrics;
import java.awt.geom.Rectangle2D;
/**
* 文字元素实现
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class TextElement extends BaseElement {
private TextConfig textConfig;
@Override
public void loadConfig(String configJson) {
this.textConfig = parseConfig(configJson, TextConfig.class);
this.config = textConfig;
}
@Override
public void validate() throws ElementValidationException {
try {
if (textConfig == null) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"文字配置不能为空"
);
}
textConfig.validate();
} catch (IllegalArgumentException e) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"配置验证失败: " + e.getMessage()
);
}
}
@Override
public void render(RenderContext context) {
Graphics2D g2d = context.getGraphics();
// 获取文本内容(优先使用动态数据)
String text = context.getDynamicData(elementKey, textConfig.getDefaultText());
if (StrUtil.isBlank(text)) {
log.debug("文字元素没有文本内容: elementKey={}", elementKey);
return;
}
try {
// 设置字体
Font font = createFont();
g2d.setFont(font);
// 设置颜色
g2d.setColor(parseColor(textConfig.getFontColor()));
// 应用透明度
Composite originalComposite = applyOpacity(g2d);
// 应用旋转
if (position.hasRotation()) {
applyRotation(g2d);
}
// 绘制文本
drawText(g2d, text);
// 恢复透明度
restoreOpacity(g2d, originalComposite);
} catch (Exception e) {
log.error("文字元素渲染失败: elementKey={}, text={}", elementKey, text, e);
}
}
@Override
public String getConfigSchema() {
return textConfig != null ? textConfig.getConfigSchema() : "{}";
}
/**
* 创建字体
*
* @return Font对象
*/
private Font createFont() {
int fontStyle = Font.PLAIN;
// 处理字重(BOLD)
if ("BOLD".equalsIgnoreCase(textConfig.getFontWeight())) {
fontStyle |= Font.BOLD;
}
// 处理字体样式(ITALIC)
if ("ITALIC".equalsIgnoreCase(textConfig.getFontStyle())) {
fontStyle |= Font.ITALIC;
}
return new Font(
textConfig.getFontFamily(),
fontStyle,
textConfig.getFontSize()
);
}
/**
* 绘制文本(支持多行、对齐、行高、最大行数)
*
* @param g2d Graphics2D对象
* @param text 文本内容
*/
private void drawText(Graphics2D g2d, String text) {
FontMetrics fm = g2d.getFontMetrics();
// 计算行高
float lineHeightMultiplier = textConfig.getLineHeight() != null
? textConfig.getLineHeight().floatValue()
: 1.5f;
int lineHeight = (int) (fm.getHeight() * lineHeightMultiplier);
// 分行
String[] lines = text.split("\\n");
Integer maxLines = textConfig.getMaxLines();
int actualLines = maxLines != null ? Math.min(lines.length, maxLines) : lines.length;
// 获取对齐方式
String textAlign = StrUtil.isNotBlank(textConfig.getTextAlign())
? textConfig.getTextAlign().toUpperCase()
: "LEFT";
// 计算总文本高度并实现垂直居中
int totalTextHeight = lineHeight * actualLines;
int verticalOffset = (position.getHeight() - totalTextHeight) / 2;
// 起始Y坐标(垂直居中)
int y = position.getY() + verticalOffset + fm.getAscent();
// 逐行绘制
for (int i = 0; i < actualLines; i++) {
String line = lines[i];
// 计算X坐标(根据对齐方式)
int x = calculateTextX(line, fm, textAlign);
// 绘制文本
g2d.drawString(line, x, y);
// 绘制文本装饰(下划线、删除线)
if (StrUtil.isNotBlank(textConfig.getTextDecoration())) {
drawTextDecoration(g2d, line, x, y, fm);
}
// 移动到下一行
y += lineHeight;
}
}
/**
* 计算文本X坐标(根据对齐方式)
*
* @param text 文本
* @param fm 字体度量
* @param textAlign 对齐方式
* @return X坐标
*/
private int calculateTextX(String text, FontMetrics fm, String textAlign) {
int textWidth = fm.stringWidth(text);
switch (textAlign) {
case "CENTER":
return position.getX() + (position.getWidth() - textWidth) / 2;
case "RIGHT":
return position.getX() + position.getWidth() - textWidth;
case "LEFT":
default:
return position.getX();
}
}
/**
* 绘制文本装饰(下划线、删除线)
*
* @param g2d Graphics2D对象
* @param text 文本
* @param x 文本X坐标
* @param y 文本Y坐标
* @param fm 字体度量
*/
private void drawTextDecoration(Graphics2D g2d, String text, int x, int y, FontMetrics fm) {
String decoration = textConfig.getTextDecoration().toUpperCase();
int textWidth = fm.stringWidth(text);
switch (decoration) {
case "UNDERLINE":
// 下划线(在文本下方)
int underlineY = y + fm.getDescent() / 2;
g2d.drawLine(x, underlineY, x + textWidth, underlineY);
break;
case "LINE_THROUGH":
// 删除线(在文本中间)
int lineThroughY = y - fm.getAscent() / 2;
g2d.drawLine(x, lineThroughY, x + textWidth, lineThroughY);
break;
case "NONE":
default:
// 无装饰
break;
}
}
}

View File

@@ -1,74 +0,0 @@
package com.ycwl.basic.puzzle.element.renderer;
import lombok.Data;
import java.awt.*;
import java.util.Map;
/**
* 渲染上下文
* 封装渲染时需要的所有上下文信息
*
* @author Claude
* @since 2025-01-18
*/
@Data
public class RenderContext {
/**
* 图形上下文
*/
private Graphics2D graphics;
/**
* 动态数据(key=elementKey, value=实际值)
*/
private Map<String, String> dynamicData;
/**
* 画布宽度
*/
private Integer canvasWidth;
/**
* 画布高度
*/
private Integer canvasHeight;
/**
* 是否启用抗锯齿
*/
private boolean antiAliasing = true;
/**
* 是否启用高质量渲染
*/
private boolean highQuality = true;
public RenderContext(Graphics2D graphics, Map<String, String> dynamicData) {
this.graphics = graphics;
this.dynamicData = dynamicData;
}
public RenderContext(Graphics2D graphics, Map<String, String> dynamicData,
Integer canvasWidth, Integer canvasHeight) {
this.graphics = graphics;
this.dynamicData = dynamicData;
this.canvasWidth = canvasWidth;
this.canvasHeight = canvasHeight;
}
/**
* 获取动态数据(带默认值)
*
* @param key 数据key
* @param defaultValue 默认值
* @return 数据值
*/
public String getDynamicData(String key, String defaultValue) {
if (dynamicData == null) {
return defaultValue;
}
return dynamicData.getOrDefault(key, defaultValue);
}
}

View File

@@ -1,6 +1,5 @@
package com.ycwl.basic.puzzle.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.ycwl.basic.biz.FaceStatusManager;
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
@@ -16,23 +15,16 @@ import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.pc.processor.PuzzleRelationProcessor;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.utils.WxMpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Comparator;
import java.util.HashMap;
@@ -52,11 +44,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
private final PuzzleRepository puzzleRepository;
private final PuzzleGenerationRecordMapper recordMapper;
private final PuzzleImageRenderer imageRenderer;
private final PuzzleElementFillEngine fillEngine;
private final ScenicRepository scenicRepository;
private final PuzzleDuplicationDetector duplicationDetector;
private final PrinterService printerService;
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
private final FaceStatusManager faceStatusManager;
private final PuzzleRelationProcessor puzzleRelationProcessor;
@@ -64,21 +54,17 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
public PuzzleGenerateServiceImpl(
PuzzleRepository puzzleRepository,
PuzzleGenerationRecordMapper recordMapper,
@Lazy PuzzleImageRenderer imageRenderer,
@Lazy PuzzleElementFillEngine fillEngine,
@Lazy ScenicRepository scenicRepository,
@Lazy PuzzleDuplicationDetector duplicationDetector,
@Lazy PrinterService printerService,
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService,
@Lazy FaceStatusManager faceStatusManager,
@Lazy PuzzleRelationProcessor puzzleRelationProcessor) {
this.puzzleRepository = puzzleRepository;
this.recordMapper = recordMapper;
this.imageRenderer = imageRenderer;
this.fillEngine = fillEngine;
this.scenicRepository = scenicRepository;
this.duplicationDetector = duplicationDetector;
this.printerService = printerService;
this.puzzleEdgeRenderTaskService = puzzleEdgeRenderTaskService;
this.faceStatusManager = faceStatusManager;
this.puzzleRelationProcessor = puzzleRelationProcessor;
@@ -340,84 +326,6 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
return record.getId();
}
/**
* 核心生成逻辑(同步执行)
*/
private PuzzleGenerateResponse doGenerate(PuzzleGenerateRequest request) {
long startTime = System.currentTimeMillis();
log.info("开始生成拼图: templateCode={}, userId={}, faceId={}",
request.getTemplateCode(), request.getUserId(), request.getFaceId());
// 参数校验
validateRequest(request);
// 1. 查询模板和元素(使用缓存)
PuzzleTemplateEntity template = puzzleRepository.getTemplateByCode(request.getTemplateCode());
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + request.getTemplateCode());
}
if (template.getStatus() != 1) {
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
}
// 2. 校验景区隔离
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
if (elements.isEmpty()) {
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
}
// 3. 按z-index排序元素
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
Comparator.nullsFirst(Comparator.naturalOrder())));
// 4. 准备dynamicData(合并自动填充和手动数据)
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
// 5. 执行重复图片检测
// 如果所有IMAGE元素使用相同URL,抛出DuplicateImageException
duplicationDetector.detectDuplicateImages(finalDynamicData, elements);
// 6. 计算内容哈希
String contentHash = duplicationDetector.calculateContentHash(finalDynamicData);
// 7. 查询历史记录(去重核心逻辑)
PuzzleGenerationRecordEntity duplicateRecord = duplicationDetector.findDuplicateRecord(
template.getId(), contentHash, resolvedScenicId);
if (duplicateRecord != null) {
// 发现重复内容,直接返回历史记录
long duration = System.currentTimeMillis() - startTime;
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
// 直接返回历史图片URL(语义化生成成功)
return PuzzleGenerateResponse.success(
duplicateRecord.getResultImageUrl(),
duplicateRecord.getResultFileSize(),
duplicateRecord.getResultWidth(),
duplicateRecord.getResultHeight(),
(int) duration,
duplicateRecord.getId(),
true, // isDuplicate=true
duplicateRecord.getId() // originalRecordId(复用时指向自己)
);
}
// 8. 没有历史记录,创建新的生成记录
PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId);
record.setContentHash(contentHash);
recordMapper.insert(record);
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
// 9. 执行核心生成逻辑
return doGenerateInternal(request, template, resolvedScenicId, record, startTime);
}
/**
* 校验请求参数
*/
@@ -427,105 +335,6 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
}
}
/**
* 核心生成逻辑(内部方法,同步/异步共用)
* 注意:此方法会在调用线程中执行渲染和上传操作
*
* @param request 生成请求
* @param template 模板
* @param resolvedScenicId 景区ID
* @param record 生成记录(已插入数据库)
* @return 生成结果(异步模式下不关心返回值)
*/
private PuzzleGenerateResponse doGenerateInternal(PuzzleGenerateRequest request,
PuzzleTemplateEntity template,
Long resolvedScenicId,
PuzzleGenerationRecordEntity record) {
return doGenerateInternal(request, template, resolvedScenicId, record, System.currentTimeMillis());
}
/**
* 核心生成逻辑(内部方法,同步/异步共用)
*/
private PuzzleGenerateResponse doGenerateInternal(PuzzleGenerateRequest request,
PuzzleTemplateEntity template,
Long resolvedScenicId,
PuzzleGenerationRecordEntity record,
long startTime) {
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
if (elements.isEmpty()) {
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
}
// 按z-index排序元素
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
Comparator.nullsFirst(Comparator.naturalOrder())));
// 准备dynamicData
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
try {
// 渲染图片
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
// 上传图片到OSS
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("图片上传成功: url={}", imageUrl);
// 更新记录为成功
long duration = System.currentTimeMillis() - startTime;
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
recordMapper.updateSuccess(
record.getId(),
imageUrl,
fileSize,
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration
);
// 清除生成记录缓存(状态已更新)
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
record.getId(), imageUrl, duration);
// 检查是否自动添加到打印队列
if (template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
try {
Integer printRecordId = printerService.addUserPhotoFromPuzzle(
request.getUserId(),
resolvedScenicId,
request.getFaceId(),
imageUrl,
record.getId() // 拼图记录ID,用于关联 puzzle_generation_record 表
);
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
} catch (Exception e) {
log.error("自动添加到打印队列失败: recordId={}", record.getId(), e);
}
}
return PuzzleGenerateResponse.success(
imageUrl,
fileSize,
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration,
record.getId(),
false,
null
);
} catch (Exception e) {
log.error("拼图生成失败: templateCode={}", request.getTemplateCode(), e);
recordMapper.updateFail(record.getId(), e.getMessage());
// 清除生成记录缓存(状态已更新)
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
}
}
/**
* 创建生成记录
*/
@@ -550,53 +359,6 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
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("%s.%s",
UUID.randomUUID().toString().replace("-", ""),
outputFormat.toLowerCase()
);
// 使用项目现有的存储工厂上传(转换为InputStream)
try {
ByteArrayInputStream inputStream = new ByteArrayInputStream(imageBytes);
String contentType = "PNG".equals(outputFormat) ? "image/png" : "image/jpeg";
return StorageFactory.use().uploadFile(contentType, inputStream, "puzzle", templateCode, 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;
}
}
/**
* 构建dynamicData(合并自动填充和手动数据)
* 优先级: 手动传入的数据 > 自动填充的数据

View File

@@ -1,174 +0,0 @@
package com.ycwl.basic.puzzle.util;
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;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
* 拼图图片渲染引擎(重构版)
* 核心功能:将模板和元素渲染成最终图片
*
* 重构说明:
* - 使用ElementFactory创建Element实例
* - 元素渲染逻辑委托给Element自己实现
* - 删除drawImageElement和drawTextElement方法
* - 保留背景绘制和工具方法
*
* @author Claude
* @since 2025-01-18
*/
@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_ARGB // 使用ARGB支持透明度
);
Graphics2D g2d = canvas.createGraphics();
try {
// 2. 开启抗锯齿和优化渲染质量
enableHighQualityRendering(g2d);
// 3. 绘制背景
drawBackground(g2d, template);
// 4. 创建渲染上下文
RenderContext context = new RenderContext(
g2d,
dynamicData,
template.getCanvasWidth(),
template.getCanvasHeight()
);
// 5. 使用ElementFactory创建Element实例并渲染
for (PuzzleElementEntity entity : elements) {
try {
// 使用工厂创建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={}, error={}",
entity.getId(), entity.getElementKey(), e.getMessage(), e);
// 继续绘制其他元素,不中断整个渲染流程
}
}
log.info("拼图渲染完成: templateId={}, 成功渲染元素数={}", template.getId(), elements.size());
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());
Image scaledBg = bgImage.getScaledInstance(template.getCanvasWidth(), template.getCanvasHeight(), Image.SCALE_SMOOTH);
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());
}
}
}
/**
* 下载图片(工具方法,也可被外部使用)
*/
public 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));
}
}
/**
* 解析颜色(工具方法,也可被外部使用)
*/
public 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;
}
}