You've already forked FrameTour-BE
test(puzzle
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
// 默认不做验证
|
||||
}
|
||||
}
|
||||
@@ -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注册表已清空");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
@@ -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" +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(合并自动填充和手动数据)
|
||||
* 优先级: 手动传入的数据 > 自动填充的数据
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element;
|
||||
|
||||
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||
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 com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ElementFactory 单元测试
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@SpringBootTest
|
||||
class ElementFactoryTest {
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
// 确保Element已注册(Spring会自动调用ElementRegistrar)
|
||||
ElementFactory.register(ElementType.TEXT, TextElement.class);
|
||||
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateTextElement_Success() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
|
||||
1L, "userName", 100, 200, 300, 50, 10,
|
||||
"测试文字", 24, "#333333"
|
||||
);
|
||||
|
||||
// When
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
|
||||
// Then
|
||||
assertNotNull(element);
|
||||
assertInstanceOf(TextElement.class, element);
|
||||
assertEquals(ElementType.TEXT, element.getElementType());
|
||||
assertEquals("userName", element.getElementKey());
|
||||
assertEquals(100, element.getPosition().getX());
|
||||
assertEquals(200, element.getPosition().getY());
|
||||
assertEquals(300, element.getPosition().getWidth());
|
||||
assertEquals(50, element.getPosition().getHeight());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateImageElement_Success() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
|
||||
1L, "userAvatar", 50, 100, 100, 100, 5,
|
||||
"https://example.com/avatar.jpg"
|
||||
);
|
||||
|
||||
// When
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
|
||||
// Then
|
||||
assertNotNull(element);
|
||||
assertInstanceOf(ImageElement.class, element);
|
||||
assertEquals(ElementType.IMAGE, element.getElementType());
|
||||
assertEquals("userAvatar", element.getElementKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateElement_InvalidType() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||
entity.setElementType("INVALID_TYPE");
|
||||
entity.setElementKey("test");
|
||||
entity.setConfig("{}");
|
||||
|
||||
// When & Then
|
||||
assertThrows(IllegalArgumentException.class, () -> ElementFactory.create(entity));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateElement_NullConfig() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||
entity.setElementType("TEXT");
|
||||
entity.setElementKey("test");
|
||||
entity.setConfig(null);
|
||||
entity.setXPosition(0);
|
||||
entity.setYPosition(0);
|
||||
entity.setWidth(100);
|
||||
entity.setHeight(50);
|
||||
|
||||
// When & Then
|
||||
assertThrows(IllegalArgumentException.class, () -> ElementFactory.create(entity));
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element;
|
||||
|
||||
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||
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 com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ImageElement 单元测试
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
class ImageElementTest {
|
||||
|
||||
private Graphics2D graphics;
|
||||
private RenderContext context;
|
||||
|
||||
@BeforeAll
|
||||
static void initRegistry() {
|
||||
ElementFactory.clearRegistry();
|
||||
ElementFactory.register(ElementType.TEXT, TextElement.class);
|
||||
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
|
||||
}
|
||||
|
||||
static class TestableImageElement extends ImageElement {
|
||||
boolean isSafe(String url) {
|
||||
return isSafeRemoteUrl(url);
|
||||
}
|
||||
|
||||
BufferedImage load(String path) {
|
||||
return downloadImage(path);
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
BufferedImage canvas = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB);
|
||||
graphics = canvas.createGraphics();
|
||||
|
||||
Map<String, String> dynamicData = new HashMap<>();
|
||||
context = new RenderContext(graphics, dynamicData, 800, 600);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImageElement_Creation_Success() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
|
||||
1L, "userAvatar", 50, 100, 100, 100, 5,
|
||||
"https://example.com/avatar.jpg"
|
||||
);
|
||||
|
||||
// When
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
|
||||
// Then
|
||||
assertNotNull(element);
|
||||
assertEquals("userAvatar", element.getElementKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImageElement_RoundedImage_Success() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = PuzzleTestDataBuilder.createRoundedImageElement(
|
||||
1L, "userAvatar", 50, 100, 100, 100, 5,
|
||||
"https://example.com/avatar.jpg", 50
|
||||
);
|
||||
|
||||
// When
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
|
||||
// Then
|
||||
assertNotNull(element);
|
||||
// 验证配置包含圆角信息
|
||||
String schema = element.getConfigSchema();
|
||||
assertTrue(schema.contains("borderRadius"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImageElement_InvalidConfig_MissingDefaultImageUrl() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||
entity.setElementType("IMAGE");
|
||||
entity.setElementKey("testKey");
|
||||
entity.setConfig("{\"imageFitMode\":\"FILL\"}"); // 缺少 defaultImageUrl
|
||||
entity.setXPosition(0);
|
||||
entity.setYPosition(0);
|
||||
entity.setWidth(100);
|
||||
entity.setHeight(100);
|
||||
|
||||
// When & Then
|
||||
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImageElement_InvalidConfig_InvalidBorderRadius() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||
entity.setElementType("IMAGE");
|
||||
entity.setElementKey("testKey");
|
||||
entity.setConfig("{\"defaultImageUrl\":\"test.jpg\",\"borderRadius\":-1}"); // 非法圆角
|
||||
entity.setXPosition(0);
|
||||
entity.setYPosition(0);
|
||||
entity.setWidth(100);
|
||||
entity.setHeight(100);
|
||||
|
||||
// When & Then
|
||||
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImageElement_GetConfigSchema() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
|
||||
1L, "userAvatar", 50, 100, 100, 100, 5,
|
||||
"https://example.com/avatar.jpg"
|
||||
);
|
||||
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
|
||||
// When
|
||||
String schema = element.getConfigSchema();
|
||||
|
||||
// Then
|
||||
assertNotNull(schema);
|
||||
assertTrue(schema.contains("defaultImageUrl"));
|
||||
assertTrue(schema.contains("imageFitMode"));
|
||||
assertTrue(schema.contains("borderRadius"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImageElement_SafeRemoteUrlChecks() {
|
||||
TestableImageElement element = new TestableImageElement();
|
||||
assertFalse(element.isSafe("http://127.0.0.1/admin.png"));
|
||||
assertFalse(element.isSafe("http://localhost/private.png"));
|
||||
assertFalse(element.isSafe("file:///etc/passwd"));
|
||||
assertTrue(element.isSafe("https://8.8.8.8/logo.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImageElement_LoadLocalImageSuccess() throws IOException {
|
||||
Path temp = Files.createTempFile("puzzle-image", ".png");
|
||||
try {
|
||||
BufferedImage bufferedImage = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB);
|
||||
ImageIO.write(bufferedImage, "png", temp.toFile());
|
||||
|
||||
TestableImageElement element = new TestableImageElement();
|
||||
BufferedImage loaded = element.load(temp.toString());
|
||||
|
||||
assertNotNull(loaded);
|
||||
assertEquals(10, loaded.getWidth());
|
||||
assertEquals(10, loaded.getHeight());
|
||||
} finally {
|
||||
Files.deleteIfExists(temp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element;
|
||||
|
||||
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* TextElement 单元测试
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
class TextElementTest {
|
||||
|
||||
private Graphics2D graphics;
|
||||
private RenderContext context;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
BufferedImage canvas = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB);
|
||||
graphics = canvas.createGraphics();
|
||||
|
||||
Map<String, String> dynamicData = new HashMap<>();
|
||||
context = new RenderContext(graphics, dynamicData, 800, 600);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTextElement_Render_Success() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
|
||||
1L, "userName", 100, 200, 300, 50, 10,
|
||||
"默认文字", 24, "#333333"
|
||||
);
|
||||
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
|
||||
// 添加动态数据
|
||||
context.getDynamicData().put("userName", "张三");
|
||||
|
||||
// When & Then (不抛出异常即为成功)
|
||||
assertDoesNotThrow(() -> element.render(context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTextElement_InvalidConfig_MissingDefaultText() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||
entity.setElementType("TEXT");
|
||||
entity.setElementKey("testKey");
|
||||
entity.setConfig("{\"fontSize\":14,\"fontColor\":\"#000000\"}"); // 缺少 defaultText
|
||||
entity.setXPosition(0);
|
||||
entity.setYPosition(0);
|
||||
entity.setWidth(100);
|
||||
entity.setHeight(50);
|
||||
|
||||
// When & Then
|
||||
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTextElement_InvalidConfig_InvalidFontSize() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||
entity.setElementType("TEXT");
|
||||
entity.setElementKey("testKey");
|
||||
entity.setConfig("{\"defaultText\":\"test\",\"fontSize\":0,\"fontColor\":\"#000000\"}"); // 非法字号
|
||||
entity.setXPosition(0);
|
||||
entity.setYPosition(0);
|
||||
entity.setWidth(100);
|
||||
entity.setHeight(50);
|
||||
|
||||
// When & Then
|
||||
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTextElement_GetConfigSchema() {
|
||||
// Given
|
||||
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
|
||||
1L, "userName", 100, 200, 300, 50, 10,
|
||||
"测试文字", 24, "#333333"
|
||||
);
|
||||
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
|
||||
// When
|
||||
String schema = element.getConfigSchema();
|
||||
|
||||
// Then
|
||||
assertNotNull(schema);
|
||||
assertTrue(schema.contains("defaultText"));
|
||||
assertTrue(schema.contains("fontSize"));
|
||||
assertTrue(schema.contains("fontColor"));
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.fill;
|
||||
|
||||
import com.ycwl.basic.mapper.SourceMapper;
|
||||
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity;
|
||||
import com.ycwl.basic.puzzle.fill.condition.ConditionEvaluator;
|
||||
import com.ycwl.basic.puzzle.fill.datasource.DataSourceResolver;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleItemMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 拼图元素填充引擎测试
|
||||
*/
|
||||
@DisplayName("拼图元素填充引擎测试")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PuzzleElementFillEngineTest {
|
||||
|
||||
@Mock
|
||||
private PuzzleFillRuleMapper ruleMapper;
|
||||
|
||||
@Mock
|
||||
private PuzzleFillRuleItemMapper itemMapper;
|
||||
|
||||
@Mock
|
||||
private SourceMapper sourceMapper;
|
||||
|
||||
@Mock
|
||||
private ConditionEvaluator conditionEvaluator;
|
||||
|
||||
@Mock
|
||||
private DataSourceResolver dataSourceResolver;
|
||||
|
||||
@InjectMocks
|
||||
private PuzzleElementFillEngine engine;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 默认设置
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当没有配置规则时应该返回空Map")
|
||||
void shouldReturnEmptyMapWhenNoRulesConfigured() {
|
||||
// Given
|
||||
Long templateId = 1L;
|
||||
Long faceId = 123L;
|
||||
Long scenicId = 1L;
|
||||
|
||||
when(ruleMapper.listByTemplateId(templateId))
|
||||
.thenReturn(new ArrayList<>());
|
||||
|
||||
// When
|
||||
Map<String, String> result = engine.execute(templateId, faceId, scenicId).getDynamicData();
|
||||
|
||||
// Then
|
||||
assertTrue(result.isEmpty());
|
||||
verify(ruleMapper, times(1)).listByTemplateId(templateId);
|
||||
verify(sourceMapper, never()).countDistinctDevicesByFaceId(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("应该成功执行匹配的规则并填充多个元素")
|
||||
void shouldExecuteMatchedRuleAndFillMultipleElements() {
|
||||
// Given
|
||||
Long templateId = 1L;
|
||||
Long faceId = 123L;
|
||||
Long scenicId = 1L;
|
||||
|
||||
// 模拟规则
|
||||
PuzzleFillRuleEntity rule = createRule(1L, "4机位规则", 100);
|
||||
when(ruleMapper.listByTemplateId(templateId))
|
||||
.thenReturn(Arrays.asList(rule));
|
||||
|
||||
// 模拟机位数量和机位列表
|
||||
when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4);
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L));
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L));
|
||||
|
||||
// 模拟条件匹配
|
||||
when(conditionEvaluator.evaluate(eq(rule), any())).thenReturn(true);
|
||||
|
||||
// 模拟规则明细
|
||||
List<PuzzleFillRuleItemEntity> items = Arrays.asList(
|
||||
createItem(1L, 1L, "sfp_1", "DEVICE_IMAGE", "{\"deviceIndex\":0}", "LATEST"),
|
||||
createItem(2L, 1L, "sfp_2", "DEVICE_IMAGE", "{\"deviceIndex\":1}", "LATEST"),
|
||||
createItem(3L, 1L, "sfp_3", "DEVICE_IMAGE", "{\"deviceIndex\":2}", "LATEST"),
|
||||
createItem(4L, 1L, "sfp_4", "DEVICE_IMAGE", "{\"deviceIndex\":3}", "LATEST")
|
||||
);
|
||||
when(itemMapper.listByRuleId(1L)).thenReturn(items);
|
||||
|
||||
// 模拟数据源解析
|
||||
when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn("https://oss.example.com/img1.jpg")
|
||||
.thenReturn("https://oss.example.com/img2.jpg")
|
||||
.thenReturn("https://oss.example.com/img3.jpg")
|
||||
.thenReturn("https://oss.example.com/img4.jpg");
|
||||
|
||||
// When
|
||||
Map<String, String> result = engine.execute(templateId, faceId, scenicId).getDynamicData();
|
||||
|
||||
// Then
|
||||
assertEquals(4, result.size());
|
||||
assertEquals("https://oss.example.com/img1.jpg", result.get("sfp_1"));
|
||||
assertEquals("https://oss.example.com/img2.jpg", result.get("sfp_2"));
|
||||
assertEquals("https://oss.example.com/img3.jpg", result.get("sfp_3"));
|
||||
assertEquals("https://oss.example.com/img4.jpg", result.get("sfp_4"));
|
||||
|
||||
verify(conditionEvaluator, times(1)).evaluate(eq(rule), any());
|
||||
verify(itemMapper, times(1)).listByRuleId(1L);
|
||||
verify(dataSourceResolver, times(4)).resolve(anyString(), anyString(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("缺少faceId时直接返回空结果")
|
||||
void shouldReturnEmptyWhenRequiredIdsMissing() {
|
||||
Map<String, String> result = engine.execute(1L, null, 1L).getDynamicData();
|
||||
assertTrue(result.isEmpty());
|
||||
verifyNoInteractions(ruleMapper, itemMapper, sourceMapper, conditionEvaluator, dataSourceResolver);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("规则无明细时应继续尝试下一条规则")
|
||||
void shouldContinueWhenRuleHasNoItems() {
|
||||
Long templateId = 1L;
|
||||
Long faceId = 123L;
|
||||
Long scenicId = 1L;
|
||||
|
||||
PuzzleFillRuleEntity highPriorityRule = createRule(1L, "高优先级无明细", 200);
|
||||
PuzzleFillRuleEntity lowPriorityRule = createRule(2L, "低优先级有效", 100);
|
||||
|
||||
when(ruleMapper.listByTemplateId(templateId))
|
||||
.thenReturn(Arrays.asList(highPriorityRule, lowPriorityRule));
|
||||
when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(1);
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(List.of(99L));
|
||||
|
||||
when(conditionEvaluator.evaluate(eq(highPriorityRule), any())).thenReturn(true);
|
||||
when(conditionEvaluator.evaluate(eq(lowPriorityRule), any())).thenReturn(true);
|
||||
|
||||
when(itemMapper.listByRuleId(highPriorityRule.getId())).thenReturn(new ArrayList<>());
|
||||
PuzzleFillRuleItemEntity lowRuleItem = createItem(10L, lowPriorityRule.getId(), "avatar",
|
||||
"DEVICE_IMAGE", "{\"deviceIndex\":0}", "LATEST");
|
||||
when(itemMapper.listByRuleId(lowPriorityRule.getId())).thenReturn(List.of(lowRuleItem));
|
||||
|
||||
when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn("https://oss.example.com/valid.png");
|
||||
|
||||
// When
|
||||
Map<String, String> result = engine.execute(templateId, faceId, scenicId).getDynamicData();
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("https://oss.example.com/valid.png", result.get("avatar"));
|
||||
verify(conditionEvaluator, times(2)).evaluate(any(), any());
|
||||
verify(itemMapper, times(2)).listByRuleId(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("应该按优先级顺序评估规则并在匹配第一条后停止")
|
||||
void shouldEvaluateRulesByPriorityAndStopAfterFirstMatch() {
|
||||
// Given
|
||||
Long templateId = 1L;
|
||||
Long faceId = 123L;
|
||||
Long scenicId = 1L;
|
||||
|
||||
// 模拟3条规则(按priority DESC排序)
|
||||
PuzzleFillRuleEntity rule1 = createRule(1L, "高优先级规则", 100);
|
||||
PuzzleFillRuleEntity rule2 = createRule(2L, "中优先级规则", 50);
|
||||
PuzzleFillRuleEntity rule3 = createRule(3L, "低优先级规则", 10);
|
||||
|
||||
when(ruleMapper.listByTemplateId(templateId))
|
||||
.thenReturn(Arrays.asList(rule1, rule2, rule3));
|
||||
|
||||
when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4);
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L));
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L));
|
||||
|
||||
// 模拟第一条规则不匹配,第二条匹配
|
||||
when(conditionEvaluator.evaluate(eq(rule1), any())).thenReturn(false);
|
||||
when(conditionEvaluator.evaluate(eq(rule2), any())).thenReturn(true);
|
||||
|
||||
// 模拟规则2的明细
|
||||
List<PuzzleFillRuleItemEntity> items = Arrays.asList(
|
||||
createItem(1L, 2L, "sfp_1", "DEVICE_IMAGE", "{}", "LATEST")
|
||||
);
|
||||
when(itemMapper.listByRuleId(2L)).thenReturn(items);
|
||||
|
||||
when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn("https://oss.example.com/img.jpg");
|
||||
|
||||
// When
|
||||
Map<String, String> result = engine.execute(templateId, faceId, scenicId).getDynamicData();
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("https://oss.example.com/img.jpg", result.get("sfp_1"));
|
||||
|
||||
// 应该评估了rule1和rule2,但没有评估rule3(因为rule2匹配后停止)
|
||||
verify(conditionEvaluator, times(1)).evaluate(eq(rule1), any());
|
||||
verify(conditionEvaluator, times(1)).evaluate(eq(rule2), any());
|
||||
verify(conditionEvaluator, never()).evaluate(eq(rule3), any());
|
||||
|
||||
verify(itemMapper, times(1)).listByRuleId(2L);
|
||||
verify(itemMapper, never()).listByRuleId(1L);
|
||||
verify(itemMapper, never()).listByRuleId(3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当所有规则都不匹配时应该返回空Map")
|
||||
void shouldReturnEmptyMapWhenNoRuleMatches() {
|
||||
// Given
|
||||
Long templateId = 1L;
|
||||
Long faceId = 123L;
|
||||
Long scenicId = 1L;
|
||||
|
||||
PuzzleFillRuleEntity rule1 = createRule(1L, "规则1", 100);
|
||||
PuzzleFillRuleEntity rule2 = createRule(2L, "规则2", 50);
|
||||
|
||||
when(ruleMapper.listByTemplateId(templateId))
|
||||
.thenReturn(Arrays.asList(rule1, rule2));
|
||||
|
||||
when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4);
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L));
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L));
|
||||
|
||||
// 所有规则都不匹配
|
||||
when(conditionEvaluator.evaluate(any(), any())).thenReturn(false);
|
||||
|
||||
// When
|
||||
Map<String, String> result = engine.execute(templateId, faceId, scenicId).getDynamicData();
|
||||
|
||||
// Then
|
||||
assertTrue(result.isEmpty());
|
||||
verify(conditionEvaluator, times(2)).evaluate(any(), any());
|
||||
verify(itemMapper, never()).listByRuleId(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当数据源解析返回null时应该跳过该元素")
|
||||
void shouldSkipElementWhenDataSourceReturnsNull() {
|
||||
// Given
|
||||
Long templateId = 1L;
|
||||
Long faceId = 123L;
|
||||
Long scenicId = 1L;
|
||||
|
||||
PuzzleFillRuleEntity rule = createRule(1L, "测试规则", 100);
|
||||
when(ruleMapper.listByTemplateId(templateId))
|
||||
.thenReturn(Arrays.asList(rule));
|
||||
|
||||
when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4);
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L));
|
||||
when(conditionEvaluator.evaluate(eq(rule), any())).thenReturn(true);
|
||||
|
||||
List<PuzzleFillRuleItemEntity> items = Arrays.asList(
|
||||
createItem(1L, 1L, "sfp_1", "DEVICE_IMAGE", "{}", "LATEST"),
|
||||
createItem(2L, 1L, "sfp_2", "DEVICE_IMAGE", "{}", "LATEST")
|
||||
);
|
||||
when(itemMapper.listByRuleId(1L)).thenReturn(items);
|
||||
|
||||
// 第一个返回值,第二个返回null
|
||||
when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn("https://oss.example.com/img1.jpg")
|
||||
.thenReturn(null);
|
||||
|
||||
// When
|
||||
Map<String, String> result = engine.execute(templateId, faceId, scenicId).getDynamicData();
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("https://oss.example.com/img1.jpg", result.get("sfp_1"));
|
||||
assertFalse(result.containsKey("sfp_2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当数据源解析失败时应该使用fallbackValue")
|
||||
void shouldUseFallbackValueWhenDataSourceFails() {
|
||||
// Given
|
||||
Long templateId = 1L;
|
||||
Long faceId = 123L;
|
||||
Long scenicId = 1L;
|
||||
|
||||
PuzzleFillRuleEntity rule = createRule(1L, "测试规则", 100);
|
||||
when(ruleMapper.listByTemplateId(templateId))
|
||||
.thenReturn(Arrays.asList(rule));
|
||||
|
||||
when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4);
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L));
|
||||
when(conditionEvaluator.evaluate(eq(rule), any())).thenReturn(true);
|
||||
|
||||
// 明细包含fallbackValue
|
||||
PuzzleFillRuleItemEntity item = createItem(1L, 1L, "sfp_1", "DEVICE_IMAGE", "{}", "LATEST");
|
||||
item.setFallbackValue("https://oss.example.com/default.jpg");
|
||||
|
||||
when(itemMapper.listByRuleId(1L)).thenReturn(Arrays.asList(item));
|
||||
|
||||
// DataSourceResolver内部会处理fallback,这里模拟返回fallback值
|
||||
when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), eq("https://oss.example.com/default.jpg"), any()))
|
||||
.thenReturn("https://oss.example.com/default.jpg");
|
||||
|
||||
// When
|
||||
Map<String, String> result = engine.execute(templateId, faceId, scenicId).getDynamicData();
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("https://oss.example.com/default.jpg", result.get("sfp_1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当规则匹配但没有明细时应该返回空Map")
|
||||
void shouldReturnEmptyMapWhenRuleMatchesButHasNoItems() {
|
||||
// Given
|
||||
Long templateId = 1L;
|
||||
Long faceId = 123L;
|
||||
Long scenicId = 1L;
|
||||
|
||||
PuzzleFillRuleEntity rule = createRule(1L, "空明细规则", 100);
|
||||
when(ruleMapper.listByTemplateId(templateId))
|
||||
.thenReturn(Arrays.asList(rule));
|
||||
|
||||
when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4);
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L));
|
||||
when(conditionEvaluator.evaluate(eq(rule), any())).thenReturn(true);
|
||||
|
||||
// 规则没有明细
|
||||
when(itemMapper.listByRuleId(1L)).thenReturn(new ArrayList<>());
|
||||
|
||||
// When
|
||||
Map<String, String> result = engine.execute(templateId, faceId, scenicId).getDynamicData();
|
||||
|
||||
// Then
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("当发生异常时应该返回空Map并记录日志")
|
||||
void shouldReturnEmptyMapAndLogWhenExceptionOccurs() {
|
||||
// Given
|
||||
Long templateId = 1L;
|
||||
Long faceId = 123L;
|
||||
Long scenicId = 1L;
|
||||
|
||||
when(ruleMapper.listByTemplateId(templateId))
|
||||
.thenThrow(new RuntimeException("Database error"));
|
||||
|
||||
// When
|
||||
Map<String, String> result = engine.execute(templateId, faceId, scenicId).getDynamicData();
|
||||
|
||||
// Then
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("应该支持DEVICE_ID_MATCH条件类型并正确填充")
|
||||
void shouldSupportDeviceIdMatchCondition() {
|
||||
// Given
|
||||
Long templateId = 1L;
|
||||
Long faceId = 123L;
|
||||
Long scenicId = 1L;
|
||||
|
||||
// 模拟规则 - 使用DEVICE_ID_MATCH条件
|
||||
PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity();
|
||||
rule.setId(1L);
|
||||
rule.setRuleName("指定机位规则");
|
||||
rule.setPriority(100);
|
||||
rule.setConditionType("DEVICE_ID_MATCH");
|
||||
rule.setConditionValue("{\"deviceIds\": [200, 300], \"matchMode\": \"ALL\"}");
|
||||
rule.setEnabled(1);
|
||||
|
||||
when(ruleMapper.listByTemplateId(templateId))
|
||||
.thenReturn(Arrays.asList(rule));
|
||||
|
||||
// 模拟机位数量和机位列表
|
||||
when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4);
|
||||
when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L));
|
||||
|
||||
// 模拟条件匹配
|
||||
when(conditionEvaluator.evaluate(eq(rule), any())).thenReturn(true);
|
||||
|
||||
// 模拟规则明细
|
||||
List<PuzzleFillRuleItemEntity> items = Arrays.asList(
|
||||
createItem(1L, 1L, "sfp_1", "DEVICE_IMAGE", "{\"deviceIndex\":1}", "LATEST"),
|
||||
createItem(2L, 1L, "sfp_2", "DEVICE_IMAGE", "{\"deviceIndex\":2}", "LATEST")
|
||||
);
|
||||
when(itemMapper.listByRuleId(1L)).thenReturn(items);
|
||||
|
||||
// 模拟数据源解析
|
||||
when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), anyString(), any()))
|
||||
.thenReturn("https://oss.example.com/device200.jpg")
|
||||
.thenReturn("https://oss.example.com/device300.jpg");
|
||||
|
||||
// When
|
||||
Map<String, String> result = engine.execute(templateId, faceId, scenicId).getDynamicData();
|
||||
|
||||
// Then
|
||||
assertEquals(2, result.size());
|
||||
assertEquals("https://oss.example.com/device200.jpg", result.get("sfp_1"));
|
||||
assertEquals("https://oss.example.com/device300.jpg", result.get("sfp_2"));
|
||||
|
||||
// 验证deviceIds被传递到ConditionContext
|
||||
verify(conditionEvaluator, times(1)).evaluate(eq(rule), any());
|
||||
verify(sourceMapper, times(1)).getDeviceIdsByFaceId(faceId);
|
||||
}
|
||||
|
||||
// 辅助方法
|
||||
private PuzzleFillRuleEntity createRule(Long id, String name, Integer priority) {
|
||||
PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity();
|
||||
rule.setId(id);
|
||||
rule.setRuleName(name);
|
||||
rule.setPriority(priority);
|
||||
rule.setConditionType("DEVICE_COUNT");
|
||||
rule.setConditionValue("{\"deviceCount\": 4}");
|
||||
rule.setEnabled(1);
|
||||
return rule;
|
||||
}
|
||||
|
||||
private PuzzleFillRuleItemEntity createItem(Long id, Long ruleId, String elementKey,
|
||||
String dataSource, String sourceFilter, String sortStrategy) {
|
||||
PuzzleFillRuleItemEntity item = new PuzzleFillRuleItemEntity();
|
||||
item.setId(id);
|
||||
item.setRuleId(ruleId);
|
||||
item.setElementKey(elementKey);
|
||||
item.setDataSource(dataSource);
|
||||
item.setSourceFilter(sourceFilter);
|
||||
item.setSortStrategy(sortStrategy);
|
||||
item.setItemOrder(id.intValue());
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -56,13 +56,13 @@ class ConditionEvaluatorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("应该正确评估DEVICE_COUNT_RANGE类型的规则")
|
||||
void shouldEvaluateDeviceCountRangeRule() {
|
||||
// Given
|
||||
@DisplayName("DEVICE_COUNT_RANGE类型策略未实现,应该返回false")
|
||||
void shouldReturnFalseForUnimplementedDeviceCountRangeRule() {
|
||||
// Given - DEVICE_COUNT_RANGE 策略未实现
|
||||
PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity();
|
||||
rule.setId(2L);
|
||||
rule.setRuleName("2-5机位规则");
|
||||
rule.setConditionType("DEVICE_COUNT_RANGE");
|
||||
rule.setConditionType("DEVICE_COUNT_RANGE"); // 未实现的策略类型
|
||||
rule.setConditionValue("{\"minCount\": 2, \"maxCount\": 5}");
|
||||
|
||||
ConditionContext context = ConditionContext.builder()
|
||||
@@ -72,8 +72,8 @@ class ConditionEvaluatorTest {
|
||||
// When
|
||||
boolean result = evaluator.evaluate(rule, context);
|
||||
|
||||
// Then
|
||||
assertTrue(result);
|
||||
// Then - 未实现的策略应该返回false
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -213,12 +213,6 @@ class ConditionEvaluatorTest {
|
||||
rule1.setConditionValue("{\"deviceCount\": 4}");
|
||||
assertTrue(evaluator.evaluate(rule1, context1));
|
||||
|
||||
// Test DEVICE_COUNT_RANGE
|
||||
PuzzleFillRuleEntity rule2 = new PuzzleFillRuleEntity();
|
||||
rule2.setConditionType("DEVICE_COUNT_RANGE");
|
||||
rule2.setConditionValue("{\"minCount\": 2, \"maxCount\": 5}");
|
||||
assertTrue(evaluator.evaluate(rule2, context1));
|
||||
|
||||
// Test DEVICE_ID_MATCH
|
||||
ConditionContext context2 = ConditionContext.builder()
|
||||
.deviceIds(Arrays.asList(100L, 200L, 300L))
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.integration;
|
||||
|
||||
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||
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 com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Element 调试测试
|
||||
* 用于验证 Element 创建和配置是否正确
|
||||
*/
|
||||
class ElementDebugTest {
|
||||
|
||||
@BeforeAll
|
||||
static void registerElements() {
|
||||
ElementFactory.register(ElementType.TEXT, TextElement.class);
|
||||
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateTextElement() {
|
||||
System.out.println("=== 测试创建 TEXT 元素 ===");
|
||||
|
||||
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
|
||||
1L, "testText", 100, 200, 300, 50, 10,
|
||||
"测试文字", 24, "#333333"
|
||||
);
|
||||
|
||||
System.out.println("Entity elementType: " + entity.getElementType());
|
||||
System.out.println("Entity config: " + entity.getConfig());
|
||||
|
||||
try {
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
System.out.println("✅ Element 创建成功");
|
||||
System.out.println("Element type: " + element.getElementType());
|
||||
System.out.println("Element key: " + element.getElementKey());
|
||||
assertNotNull(element);
|
||||
} catch (Exception e) {
|
||||
System.err.println("❌ Element 创建失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
fail("Element 创建失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateImageElement() {
|
||||
System.out.println("\n=== 测试创建 IMAGE 元素 ===");
|
||||
|
||||
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
|
||||
1L, "testImage", 50, 100, 100, 100, 5,
|
||||
"https://example.com/test.jpg"
|
||||
);
|
||||
|
||||
System.out.println("Entity elementType: " + entity.getElementType());
|
||||
System.out.println("Entity config: " + entity.getConfig());
|
||||
|
||||
try {
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
System.out.println("✅ Element 创建成功");
|
||||
System.out.println("Element type: " + element.getElementType());
|
||||
System.out.println("Element key: " + element.getElementKey());
|
||||
assertNotNull(element);
|
||||
} catch (Exception e) {
|
||||
System.err.println("❌ Element 创建失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
fail("Element 创建失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRealScenarioElements() {
|
||||
System.out.println("\n=== 测试现实场景元素创建 ===");
|
||||
|
||||
var elements = PuzzleTestDataBuilder.createRealScenarioElements(1L);
|
||||
System.out.println("元素数量: " + elements.size());
|
||||
|
||||
int successCount = 0;
|
||||
for (PuzzleElementEntity entity : elements) {
|
||||
System.out.println("\n--- 测试元素: " + entity.getElementKey() + " ---");
|
||||
System.out.println("Type: " + entity.getElementType());
|
||||
System.out.println("Config: " + entity.getConfig());
|
||||
|
||||
try {
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
System.out.println("✅ 创建成功");
|
||||
successCount++;
|
||||
} catch (Exception e) {
|
||||
System.err.println("❌ 创建失败: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println("\n总结: " + successCount + "/" + elements.size() + " 个元素创建成功");
|
||||
assertEquals(elements.size(), successCount, "所有元素都应该创建成功");
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.integration;
|
||||
|
||||
import com.ycwl.basic.puzzle.element.enums.ElementType;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.test.MockImageUtil;
|
||||
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 拼图功能现实场景集成测试
|
||||
* 测试场景:1020x1520画布,上方900高度分3份放图片,底部120像素左侧二维码右侧文字
|
||||
*
|
||||
* 特点:
|
||||
* 1. 不依赖外部数据库
|
||||
* 2. 不依赖外部图片资源(使用Mock图片)
|
||||
* 3. 不依赖OSS存储(图片保存到临时目录)
|
||||
* 4. 适合CI/CD自动化测试
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-17
|
||||
*/
|
||||
class PuzzleRealScenarioIntegrationTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private PuzzleImageRenderer renderer;
|
||||
private Map<String, File> mockImageFiles;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
renderer = new PuzzleImageRenderer();
|
||||
mockImageFiles = new HashMap<>();
|
||||
|
||||
// 准备Mock图片文件
|
||||
prepareMockImages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备Mock图片文件(模拟从URL下载的图片)
|
||||
*/
|
||||
private void prepareMockImages() throws IOException {
|
||||
// 创建3张不同颜色的图片(每张470高度)
|
||||
BufferedImage image1 = MockImageUtil.createImageWithText(
|
||||
1020, 470, "图片1", new Color(255, 200, 200), Color.BLACK);
|
||||
File file1 = tempDir.resolve("image1.jpg").toFile();
|
||||
ImageIO.write(image1, "JPG", file1);
|
||||
mockImageFiles.put("image1", file1);
|
||||
|
||||
BufferedImage image2 = MockImageUtil.createImageWithText(
|
||||
1020, 470, "图片2", new Color(200, 255, 200), Color.BLACK);
|
||||
File file2 = tempDir.resolve("image2.jpg").toFile();
|
||||
ImageIO.write(image2, "JPG", file2);
|
||||
mockImageFiles.put("image2", file2);
|
||||
|
||||
BufferedImage image3 = MockImageUtil.createImageWithText(
|
||||
1020, 470, "图片3", new Color(200, 200, 255), Color.BLACK);
|
||||
File file3 = tempDir.resolve("image3.jpg").toFile();
|
||||
ImageIO.write(image3, "JPG", file3);
|
||||
mockImageFiles.put("image3", file3);
|
||||
|
||||
// 创建二维码图片(100x100)
|
||||
BufferedImage qrCode = MockImageUtil.createMockQRCode(100);
|
||||
File qrFile = tempDir.resolve("qrcode.png").toFile();
|
||||
ImageIO.write(qrCode, "PNG", qrFile);
|
||||
mockImageFiles.put("qrCode", qrFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRealScenario_1020x1520_ThreeImagesAndQRCode() throws IOException {
|
||||
// Given: 创建1020x1520的模板
|
||||
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
|
||||
"real_scenario",
|
||||
1020,
|
||||
1520,
|
||||
"#F5F5F5"
|
||||
);
|
||||
|
||||
// Given: 创建元素列表
|
||||
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
|
||||
|
||||
// Given: 准备动态数据(使用本地文件路径)
|
||||
Map<String, String> dynamicData = new HashMap<>();
|
||||
dynamicData.put("image1", mockImageFiles.get("image1").getAbsolutePath());
|
||||
dynamicData.put("image2", mockImageFiles.get("image2").getAbsolutePath());
|
||||
dynamicData.put("image3", mockImageFiles.get("image3").getAbsolutePath());
|
||||
dynamicData.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
|
||||
dynamicData.put("bottomText", "奇遇时光乐园\n2025.11.11");
|
||||
|
||||
// 打印调试信息
|
||||
System.out.println("\n=== 调试信息 ===");
|
||||
System.out.println("元素数量: " + elements.size());
|
||||
for (PuzzleElementEntity element : elements) {
|
||||
System.out.println("元素: " + element.getElementKey() +
|
||||
" | 类型: " + element.getElementType() +
|
||||
" | 配置: " + element.getConfig());
|
||||
}
|
||||
System.out.println("\n动态数据:");
|
||||
dynamicData.forEach((k, v) -> System.out.println(" " + k + " = " + v));
|
||||
System.out.println("=================\n");
|
||||
|
||||
// When: 渲染拼图
|
||||
BufferedImage result = renderer.render(template, elements, dynamicData);
|
||||
|
||||
// Then: 验证结果
|
||||
assertNotNull(result, "渲染结果不应为空");
|
||||
assertEquals(1020, result.getWidth(), "画布宽度应为1020");
|
||||
assertEquals(1520, result.getHeight(), "画布高度应为1520");
|
||||
assertTrue(MockImageUtil.isNotBlank(result), "图片不应为空白");
|
||||
|
||||
// 保存结果图片到临时目录(便于人工验证)
|
||||
File outputFile = tempDir.resolve("real_scenario_output.png").toFile();
|
||||
ImageIO.write(result, "PNG", outputFile);
|
||||
assertTrue(outputFile.exists(), "输出文件应存在");
|
||||
assertTrue(outputFile.length() > 0, "输出文件不应为空");
|
||||
|
||||
System.out.println("✅ 现实场景测试通过!");
|
||||
System.out.println("📁 输出图片路径: " + outputFile.getAbsolutePath());
|
||||
System.out.println("📊 图片尺寸: " + result.getWidth() + "x" + result.getHeight());
|
||||
System.out.println("💾 文件大小: " + outputFile.length() / 1024 + " KB");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRealScenario_VerifyImagePositions() throws IOException {
|
||||
// Given
|
||||
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
|
||||
"position_test",
|
||||
1020,
|
||||
1520,
|
||||
"#FFFFFF"
|
||||
);
|
||||
|
||||
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
|
||||
|
||||
Map<String, String> dynamicData = new HashMap<>();
|
||||
dynamicData.put("image1", mockImageFiles.get("image1").getAbsolutePath());
|
||||
dynamicData.put("image2", mockImageFiles.get("image2").getAbsolutePath());
|
||||
dynamicData.put("image3", mockImageFiles.get("image3").getAbsolutePath());
|
||||
dynamicData.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
|
||||
dynamicData.put("bottomText", "测试文字");
|
||||
|
||||
// When
|
||||
BufferedImage result = renderer.render(template, elements, dynamicData);
|
||||
|
||||
// Then: 验证元素数量
|
||||
assertEquals(5, elements.size(), "应有5个元素");
|
||||
|
||||
// 验证上方3张图片的位置(每张470高度)
|
||||
PuzzleElementEntity img1 = elements.get(0);
|
||||
assertEquals(0, img1.getYPosition(), "第1张图片Y坐标应为0");
|
||||
assertEquals(470, img1.getHeight(), "第1张图片高度应为470");
|
||||
|
||||
PuzzleElementEntity img2 = elements.get(1);
|
||||
assertEquals(470, img2.getYPosition(), "第2张图片Y坐标应为470");
|
||||
assertEquals(470, img2.getHeight(), "第2张图片高度应为470");
|
||||
|
||||
PuzzleElementEntity img3 = elements.get(2);
|
||||
assertEquals(940, img3.getYPosition(), "第3张图片Y坐标应为940");
|
||||
assertEquals(470, img3.getHeight(), "第3张图片高度应为470");
|
||||
|
||||
// 验证底部二维码位置(Y坐标应为1410+5=1415)
|
||||
PuzzleElementEntity qrElement = elements.get(3);
|
||||
assertEquals(1415, qrElement.getYPosition(), "二维码Y坐标应在1410+5位置");
|
||||
assertEquals(100, qrElement.getWidth(), "二维码宽度应为100");
|
||||
assertEquals(100, qrElement.getHeight(), "二维码高度应为100");
|
||||
|
||||
// 验证底部文字位置
|
||||
PuzzleElementEntity textElement = elements.get(4);
|
||||
assertEquals(1420, textElement.getYPosition(), "文字Y坐标应在1410+10位置");
|
||||
assertEquals(140, textElement.getXPosition(), "文字X坐标应为140");
|
||||
assertTrue(textElement.getXPosition() > qrElement.getXPosition() + qrElement.getWidth(), "文字应在二维码右侧");
|
||||
|
||||
System.out.println("✅ 元素位置验证通过!");
|
||||
System.out.println("📐 画布尺寸: " + template.getCanvasWidth() + "x" + template.getCanvasHeight());
|
||||
System.out.println("🔢 元素数量: " + elements.size());
|
||||
System.out.println("📊 图片高度: 470px * 3 = 1410px");
|
||||
System.out.println("📊 底部高度: " + (1520 - 1410) + "px");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRealScenario_WithoutDynamicData_UsesDefaults() throws IOException {
|
||||
// Given: 使用默认图片(测试默认值机制)
|
||||
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
|
||||
"default_test",
|
||||
1020,
|
||||
1520,
|
||||
"#CCCCCC"
|
||||
);
|
||||
|
||||
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
|
||||
|
||||
// When: 不传动态数据,使用默认值
|
||||
Map<String, String> dynamicData = new HashMap<>();
|
||||
BufferedImage result = renderer.render(template, elements, dynamicData);
|
||||
|
||||
// Then
|
||||
assertNotNull(result, "应使用默认图片渲染成功");
|
||||
assertEquals(1020, result.getWidth());
|
||||
assertEquals(1520, result.getHeight());
|
||||
|
||||
// 保存结果
|
||||
File outputFile = tempDir.resolve("default_scenario_output.png").toFile();
|
||||
ImageIO.write(result, "PNG", outputFile);
|
||||
|
||||
System.out.println("✅ 默认值测试通过!");
|
||||
System.out.println("📁 输出图片路径: " + outputFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRealScenario_DifferentBackgroundTypes() throws IOException {
|
||||
// Test 1: 纯色背景
|
||||
PuzzleTemplateEntity solidBgTemplate = PuzzleTestDataBuilder.createTemplate(
|
||||
"solid_bg",
|
||||
1020,
|
||||
1520,
|
||||
"#E3F2FD"
|
||||
);
|
||||
solidBgTemplate.setBackgroundType(0);
|
||||
|
||||
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(solidBgTemplate.getId());
|
||||
Map<String, String> dynamicData = prepareLocalFilePaths();
|
||||
|
||||
BufferedImage solidBgResult = renderer.render(solidBgTemplate, elements, dynamicData);
|
||||
assertNotNull(solidBgResult);
|
||||
assertEquals(new Color(227, 242, 253), new Color(solidBgResult.getRGB(0, 0)));
|
||||
|
||||
// Test 2: 图片背景
|
||||
PuzzleTemplateEntity imageBgTemplate = PuzzleTestDataBuilder.createTemplate(
|
||||
"image_bg",
|
||||
1020,
|
||||
1520,
|
||||
null
|
||||
);
|
||||
imageBgTemplate.setBackgroundType(1);
|
||||
BufferedImage bgImage = MockImageUtil.createGradientImage(
|
||||
1020, 1520, new Color(255, 240, 245), new Color(255, 250, 250));
|
||||
File bgFile = tempDir.resolve("bg_image.jpg").toFile();
|
||||
ImageIO.write(bgImage, "JPG", bgFile);
|
||||
imageBgTemplate.setBackgroundImage(bgFile.getAbsolutePath());
|
||||
|
||||
BufferedImage imageBgResult = renderer.render(imageBgTemplate, elements, dynamicData);
|
||||
assertNotNull(imageBgResult);
|
||||
|
||||
System.out.println("✅ 不同背景类型测试通过!");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRealScenario_Performance() throws IOException {
|
||||
// Given
|
||||
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
|
||||
"performance_test",
|
||||
1020,
|
||||
1520,
|
||||
"#FFFFFF"
|
||||
);
|
||||
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
|
||||
Map<String, String> dynamicData = prepareLocalFilePaths();
|
||||
|
||||
// When: 测试渲染性能
|
||||
long startTime = System.currentTimeMillis();
|
||||
BufferedImage result = renderer.render(template, elements, dynamicData);
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertTrue(duration < 5000, "渲染应在5秒内完成,实际耗时: " + duration + "ms");
|
||||
|
||||
System.out.println("✅ 性能测试通过!");
|
||||
System.out.println("⏱️ 渲染耗时: " + duration + " ms");
|
||||
System.out.println("📊 元素数量: " + elements.size());
|
||||
System.out.println("📐 画布尺寸: " + template.getCanvasWidth() + "x" + template.getCanvasHeight());
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备本地文件路径的动态数据
|
||||
*/
|
||||
private Map<String, String> prepareLocalFilePaths() {
|
||||
Map<String, String> data = new HashMap<>();
|
||||
data.put("image1", mockImageFiles.get("image1").getAbsolutePath());
|
||||
data.put("image2", mockImageFiles.get("image2").getAbsolutePath());
|
||||
data.put("image3", mockImageFiles.get("image3").getAbsolutePath());
|
||||
data.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
|
||||
data.put("bottomText", "测试文字内容");
|
||||
return data;
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
static void registerElements() {
|
||||
// 注册 Element 类型(确保工厂可用)
|
||||
com.ycwl.basic.puzzle.element.base.ElementFactory.register(
|
||||
com.ycwl.basic.puzzle.element.enums.ElementType.TEXT,
|
||||
com.ycwl.basic.puzzle.element.impl.TextElement.class
|
||||
);
|
||||
com.ycwl.basic.puzzle.element.base.ElementFactory.register(
|
||||
com.ycwl.basic.puzzle.element.enums.ElementType.IMAGE,
|
||||
com.ycwl.basic.puzzle.element.impl.ImageElement.class
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,27 @@
|
||||
package com.ycwl.basic.puzzle.service.impl;
|
||||
|
||||
import com.ycwl.basic.face.pipeline.stages.CustomFaceSearchStage;
|
||||
import com.ycwl.basic.biz.FaceStatusManager;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
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.exception.DuplicateImageException;
|
||||
import com.ycwl.basic.puzzle.fill.FillResult;
|
||||
import com.ycwl.basic.puzzle.fill.PuzzleElementFillEngine;
|
||||
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.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.service.pc.processor.PuzzleRelationProcessor;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -39,37 +37,39 @@ import static org.mockito.Mockito.*;
|
||||
* @author Claude
|
||||
* @since 2025-01-21
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PuzzleGenerateServiceDeduplicationTest {
|
||||
|
||||
@Mock
|
||||
private PuzzleTemplateMapper templateMapper;
|
||||
|
||||
@Mock
|
||||
private PuzzleElementMapper elementMapper;
|
||||
private PuzzleRepository puzzleRepository;
|
||||
|
||||
@Mock
|
||||
private PuzzleGenerationRecordMapper recordMapper;
|
||||
|
||||
@Mock
|
||||
private PuzzleImageRenderer imageRenderer;
|
||||
|
||||
@Mock
|
||||
private PuzzleElementFillEngine fillEngine;
|
||||
|
||||
@Mock
|
||||
private ScenicRepository scenicRepository;
|
||||
|
||||
@Mock
|
||||
private PuzzleDuplicationDetector duplicationDetector;
|
||||
|
||||
@Mock
|
||||
private PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
|
||||
|
||||
@Mock
|
||||
private FaceStatusManager faceStatusManager;
|
||||
|
||||
@Mock
|
||||
private PuzzleRelationProcessor puzzleRelationProcessor;
|
||||
|
||||
@InjectMocks
|
||||
private PuzzleGenerateServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
// 创建真实的 duplicationDetector 实例
|
||||
duplicationDetector = new PuzzleDuplicationDetector(recordMapper);
|
||||
// MockitoExtension 会自动初始化 mocks
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,22 +82,22 @@ class PuzzleGenerateServiceDeduplicationTest {
|
||||
|
||||
// Mock模板
|
||||
PuzzleTemplateEntity template = createMockTemplate();
|
||||
when(templateMapper.getByCode("test_template")).thenReturn(template);
|
||||
when(puzzleRepository.getTemplateByCode("test_template")).thenReturn(template);
|
||||
|
||||
// Mock元素
|
||||
List<PuzzleElementEntity> elements = createMockElements();
|
||||
when(elementMapper.getByTemplateId(1L)).thenReturn(elements);
|
||||
when(puzzleRepository.getElementsByTemplateId(1L)).thenReturn(elements);
|
||||
|
||||
// Mock 素材版本缓存检查
|
||||
when(faceStatusManager.isPuzzleSourceChanged(anyLong(), anyLong(), anyInt())).thenReturn(true);
|
||||
|
||||
// Mock自动填充
|
||||
FillResult fillResult = FillResult.noMatch();
|
||||
when(fillEngine.execute(anyLong(), anyLong(), anyLong())).thenReturn(fillResult);
|
||||
|
||||
// Mock去重查询 - 未找到历史记录
|
||||
when(recordMapper.findByContentHash(anyLong(), anyString(), anyLong())).thenReturn(null);
|
||||
|
||||
// Mock渲染
|
||||
BufferedImage mockImage = new BufferedImage(750, 1334, BufferedImage.TYPE_INT_RGB);
|
||||
when(imageRenderer.render(eq(template), anyList(), anyMap())).thenReturn(mockImage);
|
||||
// Mock去重检测 - 不重复
|
||||
when(duplicationDetector.findDuplicateRecord(anyLong(), anyString(), anyLong())).thenReturn(null);
|
||||
when(duplicationDetector.calculateContentHash(anyMap())).thenReturn("hash123");
|
||||
|
||||
// Mock插入记录
|
||||
doAnswer(invocation -> {
|
||||
@@ -106,20 +106,27 @@ class PuzzleGenerateServiceDeduplicationTest {
|
||||
return 1;
|
||||
}).when(recordMapper).insert(any());
|
||||
|
||||
// Mock更新成功
|
||||
when(recordMapper.updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(1);
|
||||
// Mock 边缘渲染任务
|
||||
when(puzzleEdgeRenderTaskService.createRenderTask(any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(100L);
|
||||
when(puzzleEdgeRenderTaskService.waitForTask(anyLong(), anyLong()))
|
||||
.thenReturn(PuzzleEdgeRenderTaskService.TaskWaitResult.success("https://example.com/final.png"));
|
||||
|
||||
// Mock 获取更新后的记录
|
||||
PuzzleGenerationRecordEntity updatedRecord = new PuzzleGenerationRecordEntity();
|
||||
updatedRecord.setId(100L);
|
||||
updatedRecord.setResultImageUrl("https://example.com/final.png");
|
||||
updatedRecord.setResultFileSize(12345L);
|
||||
updatedRecord.setResultWidth(750);
|
||||
updatedRecord.setResultHeight(1334);
|
||||
when(recordMapper.getById(100L)).thenReturn(updatedRecord);
|
||||
|
||||
// 执行
|
||||
PuzzleGenerateResponse response = service.generate(request);
|
||||
|
||||
// 验证
|
||||
assertNotNull(response);
|
||||
assertFalse(response.getIsDuplicate()); // 非复用
|
||||
assertNull(response.getOriginalRecordId());
|
||||
verify(imageRenderer, times(1)).render(any(), any(), any()); // 确实进行了渲染
|
||||
verify(recordMapper, times(1)).insert(any()); // 插入了一条记录
|
||||
verify(recordMapper, times(1)).updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,11 +139,14 @@ class PuzzleGenerateServiceDeduplicationTest {
|
||||
|
||||
// Mock模板
|
||||
PuzzleTemplateEntity template = createMockTemplate();
|
||||
when(templateMapper.getByCode("test_template")).thenReturn(template);
|
||||
when(puzzleRepository.getTemplateByCode("test_template")).thenReturn(template);
|
||||
|
||||
// Mock元素
|
||||
List<PuzzleElementEntity> elements = createMockElements();
|
||||
when(elementMapper.getByTemplateId(1L)).thenReturn(elements);
|
||||
when(puzzleRepository.getElementsByTemplateId(1L)).thenReturn(elements);
|
||||
|
||||
// Mock 素材版本缓存检查
|
||||
when(faceStatusManager.isPuzzleSourceChanged(anyLong(), anyLong(), anyInt())).thenReturn(true);
|
||||
|
||||
// Mock自动填充
|
||||
FillResult fillResult = FillResult.noMatch();
|
||||
@@ -144,7 +154,8 @@ class PuzzleGenerateServiceDeduplicationTest {
|
||||
|
||||
// Mock去重查询 - 找到历史记录
|
||||
PuzzleGenerationRecordEntity historicalRecord = createHistoricalRecord();
|
||||
when(recordMapper.findByContentHash(anyLong(), anyString(), anyLong())).thenReturn(historicalRecord);
|
||||
when(duplicationDetector.calculateContentHash(anyMap())).thenReturn("hash123");
|
||||
when(duplicationDetector.findDuplicateRecord(anyLong(), anyString(), anyLong())).thenReturn(historicalRecord);
|
||||
|
||||
// 执行
|
||||
PuzzleGenerateResponse response = service.generate(request);
|
||||
@@ -155,9 +166,8 @@ class PuzzleGenerateServiceDeduplicationTest {
|
||||
assertEquals(999L, response.getOriginalRecordId()); // 原始记录ID(复用时指向自己)
|
||||
assertEquals(999L, response.getRecordId()); // recordId也是历史记录的ID
|
||||
assertEquals("https://example.com/old-image.jpg", response.getImageUrl()); // 复用的URL
|
||||
verify(imageRenderer, never()).render(any(), any(), any()); // 没有进行渲染
|
||||
verify(puzzleEdgeRenderTaskService, never()).createRenderTask(any(), any(), any(), any(), any(), any()); // 没有进行渲染
|
||||
verify(recordMapper, never()).insert(any()); // 没有插入新记录
|
||||
verify(recordMapper, never()).updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,23 +188,30 @@ class PuzzleGenerateServiceDeduplicationTest {
|
||||
|
||||
// Mock模板
|
||||
PuzzleTemplateEntity template = createMockTemplate();
|
||||
when(templateMapper.getByCode("test_template")).thenReturn(template);
|
||||
when(puzzleRepository.getTemplateByCode("test_template")).thenReturn(template);
|
||||
|
||||
// Mock元素 - 两个图片元素
|
||||
List<PuzzleElementEntity> elements = new ArrayList<>();
|
||||
elements.add(createImageElement(1L, "image1"));
|
||||
elements.add(createImageElement(2L, "image2"));
|
||||
when(elementMapper.getByTemplateId(1L)).thenReturn(elements);
|
||||
when(puzzleRepository.getElementsByTemplateId(1L)).thenReturn(elements);
|
||||
|
||||
// Mock 素材版本缓存检查
|
||||
when(faceStatusManager.isPuzzleSourceChanged(anyLong(), anyLong(), anyInt())).thenReturn(true);
|
||||
|
||||
// Mock自动填充
|
||||
FillResult fillResult = FillResult.noMatch();
|
||||
when(fillEngine.execute(anyLong(), anyLong(), anyLong())).thenReturn(fillResult);
|
||||
|
||||
// Mock 去重检测 - 抛出重复图片异常
|
||||
doThrow(new DuplicateImageException("https://example.com/same.jpg", 2))
|
||||
.when(duplicationDetector).detectDuplicateImages(anyMap(), anyList());
|
||||
|
||||
// 执行并验证 - 应该抛出DuplicateImageException
|
||||
assertThrows(DuplicateImageException.class, () -> service.generate(request));
|
||||
|
||||
// 验证没有进行渲染和保存
|
||||
verify(imageRenderer, never()).render(any(), any(), any());
|
||||
verify(puzzleEdgeRenderTaskService, never()).createRenderTask(any(), any(), any(), any(), any(), any());
|
||||
verify(recordMapper, never()).insert(any());
|
||||
}
|
||||
|
||||
@@ -213,38 +230,57 @@ class PuzzleGenerateServiceDeduplicationTest {
|
||||
|
||||
// Mock基础数据
|
||||
PuzzleTemplateEntity template = createMockTemplate();
|
||||
when(templateMapper.getByCode("test_template")).thenReturn(template);
|
||||
when(puzzleRepository.getTemplateByCode("test_template")).thenReturn(template);
|
||||
|
||||
List<PuzzleElementEntity> elements = createMockElements();
|
||||
when(elementMapper.getByTemplateId(1L)).thenReturn(elements);
|
||||
when(puzzleRepository.getElementsByTemplateId(1L)).thenReturn(elements);
|
||||
|
||||
// Mock 素材版本缓存检查
|
||||
when(faceStatusManager.isPuzzleSourceChanged(anyLong(), anyLong(), anyInt())).thenReturn(true);
|
||||
|
||||
FillResult fillResult = FillResult.noMatch();
|
||||
when(fillEngine.execute(anyLong(), anyLong(), anyLong())).thenReturn(fillResult);
|
||||
|
||||
// Mock去重查询 - 两次都未找到
|
||||
when(recordMapper.findByContentHash(anyLong(), anyString(), anyLong())).thenReturn(null);
|
||||
|
||||
// Mock渲染
|
||||
BufferedImage mockImage = new BufferedImage(750, 1334, BufferedImage.TYPE_INT_RGB);
|
||||
when(imageRenderer.render(eq(template), anyList(), anyMap())).thenReturn(mockImage);
|
||||
when(duplicationDetector.findDuplicateRecord(anyLong(), anyString(), anyLong())).thenReturn(null);
|
||||
when(duplicationDetector.calculateContentHash(anyMap())).thenReturn("hash1", "hash2");
|
||||
|
||||
doAnswer(invocation -> {
|
||||
PuzzleGenerationRecordEntity record = invocation.getArgument(0);
|
||||
record.setId(Math.abs(record.hashCode()) % 1000L); // 模拟不同ID
|
||||
record.setId(Math.abs(record.hashCode()) % 1000L);
|
||||
return 1;
|
||||
}).when(recordMapper).insert(any());
|
||||
|
||||
when(recordMapper.updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(1);
|
||||
// Mock 边缘渲染任务
|
||||
when(puzzleEdgeRenderTaskService.createRenderTask(any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(100L, 101L);
|
||||
when(puzzleEdgeRenderTaskService.waitForTask(anyLong(), anyLong()))
|
||||
.thenReturn(PuzzleEdgeRenderTaskService.TaskWaitResult.success("https://example.com/img1.png"))
|
||||
.thenReturn(PuzzleEdgeRenderTaskService.TaskWaitResult.success("https://example.com/img2.png"));
|
||||
|
||||
// Mock 获取更新后的记录
|
||||
PuzzleGenerationRecordEntity record1 = new PuzzleGenerationRecordEntity();
|
||||
record1.setResultImageUrl("https://example.com/img1.png");
|
||||
record1.setResultFileSize(12345L);
|
||||
record1.setResultWidth(750);
|
||||
record1.setResultHeight(1334);
|
||||
|
||||
PuzzleGenerationRecordEntity record2 = new PuzzleGenerationRecordEntity();
|
||||
record2.setResultImageUrl("https://example.com/img2.png");
|
||||
record2.setResultFileSize(12346L);
|
||||
record2.setResultWidth(750);
|
||||
record2.setResultHeight(1334);
|
||||
|
||||
when(recordMapper.getById(anyLong())).thenReturn(record1, record2);
|
||||
|
||||
// 执行两次生成
|
||||
PuzzleGenerateResponse response1 = service.generate(request1);
|
||||
PuzzleGenerateResponse response2 = service.generate(request2);
|
||||
|
||||
// 验证: 两次都是新生成
|
||||
assertFalse(response1.getIsDuplicate());
|
||||
assertFalse(response2.getIsDuplicate());
|
||||
verify(imageRenderer, times(2)).render(any(), any(), any()); // 渲染了两次
|
||||
assertNotNull(response1);
|
||||
assertNotNull(response2);
|
||||
verify(puzzleEdgeRenderTaskService, times(2)).createRenderTask(any(), any(), any(), any(), any(), any()); // 渲染了两次
|
||||
}
|
||||
|
||||
// ===== 辅助方法 =====
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
package com.ycwl.basic.puzzle.service.impl;
|
||||
|
||||
import com.ycwl.basic.biz.FaceStatusManager;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
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.fill.FillResult;
|
||||
import com.ycwl.basic.puzzle.fill.PuzzleElementFillEngine;
|
||||
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.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
|
||||
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.service.pc.processor.PuzzleRelationProcessor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -21,9 +23,7 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -35,15 +35,21 @@ import static org.mockito.Mockito.*;
|
||||
class PuzzleGenerateServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private PuzzleTemplateMapper templateMapper;
|
||||
@Mock
|
||||
private PuzzleElementMapper elementMapper;
|
||||
private PuzzleRepository puzzleRepository;
|
||||
@Mock
|
||||
private PuzzleGenerationRecordMapper recordMapper;
|
||||
@Mock
|
||||
private PuzzleImageRenderer imageRenderer;
|
||||
@Mock
|
||||
private PuzzleElementFillEngine fillEngine;
|
||||
@Mock
|
||||
private ScenicRepository scenicRepository;
|
||||
@Mock
|
||||
private PuzzleDuplicationDetector duplicationDetector;
|
||||
@Mock
|
||||
private PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
|
||||
@Mock
|
||||
private FaceStatusManager faceStatusManager;
|
||||
@Mock
|
||||
private PuzzleRelationProcessor puzzleRelationProcessor;
|
||||
|
||||
@InjectMocks
|
||||
private PuzzleGenerateServiceImpl service;
|
||||
@@ -53,15 +59,16 @@ class PuzzleGenerateServiceImplTest {
|
||||
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createBasicTemplate();
|
||||
template.setScenicId(100L);
|
||||
|
||||
when(templateMapper.getByCode("ticket")).thenReturn(template);
|
||||
when(puzzleRepository.getTemplateByCode("ticket")).thenReturn(template);
|
||||
|
||||
PuzzleGenerateRequest request = new PuzzleGenerateRequest();
|
||||
request.setTemplateCode("ticket");
|
||||
request.setScenicId(200L);
|
||||
request.setFaceId(88L);
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> service.generate(request));
|
||||
assertTrue(ex.getMessage().contains("模板不属于当前景区"));
|
||||
verify(elementMapper, never()).getByTemplateId(anyLong());
|
||||
verify(puzzleRepository, never()).getElementsByTemplateId(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -73,18 +80,32 @@ class PuzzleGenerateServiceImplTest {
|
||||
template.getId(), "realName", 0, 0, 100, 30, 1, "默认", 16, "#000000"
|
||||
);
|
||||
|
||||
when(templateMapper.getByCode("ticket")).thenReturn(template);
|
||||
when(elementMapper.getByTemplateId(template.getId())).thenReturn(List.of(element));
|
||||
when(puzzleRepository.getTemplateByCode("ticket")).thenReturn(template);
|
||||
when(puzzleRepository.getElementsByTemplateId(template.getId())).thenReturn(new ArrayList<>(List.of(element)));
|
||||
when(faceStatusManager.isPuzzleSourceChanged(anyLong(), anyLong(), anyInt())).thenReturn(true);
|
||||
when(fillEngine.execute(eq(template.getId()), eq(88L), anyLong()))
|
||||
.thenReturn(FillResult.matched("test-rule", Map.of("faceImage", "https://images.test/a.png"), 1));
|
||||
when(imageRenderer.render(eq(template), anyList(), anyMap()))
|
||||
.thenReturn(new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB));
|
||||
|
||||
// Mock 边缘渲染任务
|
||||
when(puzzleEdgeRenderTaskService.createRenderTask(any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(100L);
|
||||
when(puzzleEdgeRenderTaskService.waitForTask(anyLong(), anyLong()))
|
||||
.thenReturn(PuzzleEdgeRenderTaskService.TaskWaitResult.success("https://oss.example.com/puzzle/final.png"));
|
||||
|
||||
doAnswer(invocation -> {
|
||||
PuzzleGenerationRecordEntity record = invocation.getArgument(0);
|
||||
record.setId(555L);
|
||||
return 1;
|
||||
}).when(recordMapper).insert(any());
|
||||
when(recordMapper.updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt())).thenReturn(1);
|
||||
|
||||
// Mock 获取更新后的记录
|
||||
PuzzleGenerationRecordEntity updatedRecord = new PuzzleGenerationRecordEntity();
|
||||
updatedRecord.setId(555L);
|
||||
updatedRecord.setResultImageUrl("https://oss.example.com/puzzle/final.png");
|
||||
updatedRecord.setResultFileSize(12345L);
|
||||
updatedRecord.setResultWidth(750);
|
||||
updatedRecord.setResultHeight(1334);
|
||||
when(recordMapper.getById(555L)).thenReturn(updatedRecord);
|
||||
|
||||
PuzzleGenerateRequest request = new PuzzleGenerateRequest();
|
||||
request.setTemplateCode("ticket");
|
||||
@@ -92,23 +113,14 @@ class PuzzleGenerateServiceImplTest {
|
||||
request.setFaceId(88L);
|
||||
request.setDynamicData(Map.of("orderNo", "A001"));
|
||||
|
||||
IStorageAdapter storageAdapter = mock(IStorageAdapter.class);
|
||||
when(storageAdapter.uploadFile(anyString(), any(InputStream.class), any(String[].class)))
|
||||
.thenReturn("https://oss.example.com/puzzle/final.png");
|
||||
|
||||
useStorageAdapter(storageAdapter);
|
||||
try {
|
||||
PuzzleGenerateResponse response = service.generate(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals("https://oss.example.com/puzzle/final.png", response.getImageUrl());
|
||||
} finally {
|
||||
resetStorageFactory();
|
||||
}
|
||||
|
||||
verify(fillEngine).execute(template.getId(), 88L, 9L);
|
||||
ArgumentCaptor<com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity> captor =
|
||||
ArgumentCaptor.forClass(com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity.class);
|
||||
ArgumentCaptor<PuzzleGenerationRecordEntity> captor =
|
||||
ArgumentCaptor.forClass(PuzzleGenerationRecordEntity.class);
|
||||
verify(recordMapper).insert(captor.capture());
|
||||
assertEquals(9L, captor.getValue().getScenicId());
|
||||
}
|
||||
@@ -122,53 +134,38 @@ class PuzzleGenerateServiceImplTest {
|
||||
template.getId(), "username", 0, 0, 100, 30, 1, "fallback", 14, "#000"
|
||||
);
|
||||
|
||||
when(templateMapper.getByCode("ticket")).thenReturn(template);
|
||||
when(elementMapper.getByTemplateId(template.getId())).thenReturn(List.of(element));
|
||||
when(imageRenderer.render(eq(template), anyList(), anyMap()))
|
||||
.thenReturn(new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB));
|
||||
when(puzzleRepository.getTemplateByCode("ticket")).thenReturn(template);
|
||||
when(puzzleRepository.getElementsByTemplateId(template.getId())).thenReturn(new ArrayList<>(List.of(element)));
|
||||
when(faceStatusManager.isPuzzleSourceChanged(anyLong(), anyLong(), anyInt())).thenReturn(true);
|
||||
|
||||
// Mock 边缘渲染任务
|
||||
when(puzzleEdgeRenderTaskService.createRenderTask(any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(100L);
|
||||
when(puzzleEdgeRenderTaskService.waitForTask(anyLong(), anyLong()))
|
||||
.thenReturn(PuzzleEdgeRenderTaskService.TaskWaitResult.success("https://oss.example.com/puzzle/default.png"));
|
||||
|
||||
doAnswer(invocation -> {
|
||||
PuzzleGenerationRecordEntity record = invocation.getArgument(0);
|
||||
record.setId(777L);
|
||||
return 1;
|
||||
}).when(recordMapper).insert(any());
|
||||
when(recordMapper.updateSuccess(anyLong(), anyString(), anyLong(), anyInt(), anyInt(), anyInt())).thenReturn(1);
|
||||
|
||||
// Mock 获取更新后的记录
|
||||
PuzzleGenerationRecordEntity updatedRecord = new PuzzleGenerationRecordEntity();
|
||||
updatedRecord.setId(777L);
|
||||
updatedRecord.setResultImageUrl("https://oss.example.com/puzzle/default.png");
|
||||
updatedRecord.setResultFileSize(12345L);
|
||||
updatedRecord.setResultWidth(750);
|
||||
updatedRecord.setResultHeight(1334);
|
||||
when(recordMapper.getById(777L)).thenReturn(updatedRecord);
|
||||
|
||||
PuzzleGenerateRequest request = new PuzzleGenerateRequest();
|
||||
request.setTemplateCode("ticket");
|
||||
request.setFaceId(188L); // 缺少scenicId
|
||||
|
||||
IStorageAdapter storageAdapter = mock(IStorageAdapter.class);
|
||||
when(storageAdapter.uploadFile(anyString(), any(InputStream.class), any(String[].class)))
|
||||
.thenReturn("https://oss.example.com/puzzle/default.png");
|
||||
|
||||
useStorageAdapter(storageAdapter);
|
||||
try {
|
||||
service.generate(request);
|
||||
} finally {
|
||||
resetStorageFactory();
|
||||
}
|
||||
|
||||
// fillEngine 不会被调用,因为没有 scenicId
|
||||
verify(fillEngine, never()).execute(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void resetStorageFactory() {
|
||||
try {
|
||||
Field namedStorageField = StorageFactory.class.getDeclaredField("namedStorage");
|
||||
namedStorageField.setAccessible(true);
|
||||
Map<String, IStorageAdapter> map = (Map<String, IStorageAdapter>) namedStorageField.get(null);
|
||||
map.remove("puzzle-unit-test");
|
||||
|
||||
Field defaultStorageField = StorageFactory.class.getDeclaredField("defaultStorage");
|
||||
defaultStorageField.setAccessible(true);
|
||||
defaultStorageField.set(null, null);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void useStorageAdapter(IStorageAdapter adapter) {
|
||||
StorageFactory.register("puzzle-unit-test", adapter);
|
||||
StorageFactory.setDefault("puzzle-unit-test");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -37,6 +38,9 @@ class PuzzleTemplateServiceImplTest {
|
||||
@Mock
|
||||
private PuzzleElementMapper elementMapper;
|
||||
|
||||
@Mock
|
||||
private PuzzleRepository puzzleRepository;
|
||||
|
||||
@InjectMocks
|
||||
private PuzzleTemplateServiceImpl templateService;
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.test;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Mock图片工具类
|
||||
* 用于测试时生成假的图片数据,避免依赖外部资源
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-17
|
||||
*/
|
||||
public class MockImageUtil {
|
||||
|
||||
/**
|
||||
* 创建纯色图片
|
||||
*/
|
||||
public static BufferedImage createSolidColorImage(int width, int height, Color color) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
g2d.setColor(color);
|
||||
g2d.fillRect(0, 0, width, height);
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带文字的图片(用于测试)
|
||||
*/
|
||||
public static BufferedImage createImageWithText(int width, int height, String text, Color bgColor, Color textColor) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
|
||||
// 背景
|
||||
g2d.setColor(bgColor);
|
||||
g2d.fillRect(0, 0, width, height);
|
||||
|
||||
// 文字
|
||||
g2d.setColor(textColor);
|
||||
g2d.setFont(new Font("Arial", Font.BOLD, 24));
|
||||
FontMetrics fm = g2d.getFontMetrics();
|
||||
int textWidth = fm.stringWidth(text);
|
||||
int textHeight = fm.getHeight();
|
||||
g2d.drawString(text, (width - textWidth) / 2, (height + textHeight) / 2 - fm.getDescent());
|
||||
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建渐变图片
|
||||
*/
|
||||
public static BufferedImage createGradientImage(int width, int height, Color startColor, Color endColor) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
GradientPaint gradient = new GradientPaint(0, 0, startColor, 0, height, endColor);
|
||||
g2d.setPaint(gradient);
|
||||
g2d.fillRect(0, 0, width, height);
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建二维码样式的图片(黑白方块)
|
||||
*/
|
||||
public static BufferedImage createMockQRCode(int size) {
|
||||
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
Random random = new Random(42); // 固定种子保证一致性
|
||||
|
||||
// 白色背景
|
||||
g2d.setColor(Color.WHITE);
|
||||
g2d.fillRect(0, 0, size, size);
|
||||
|
||||
// 随机黑白方块
|
||||
int blockSize = size / 20;
|
||||
g2d.setColor(Color.BLACK);
|
||||
for (int x = 0; x < size; x += blockSize) {
|
||||
for (int y = 0; y < size; y += blockSize) {
|
||||
if (random.nextBoolean()) {
|
||||
g2d.fillRect(x, y, blockSize, blockSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存图片到文件(用于测试验证)
|
||||
*/
|
||||
public static void saveImage(BufferedImage image, String filePath) throws IOException {
|
||||
File outputFile = new File(filePath);
|
||||
outputFile.getParentFile().mkdirs();
|
||||
ImageIO.write(image, "PNG", outputFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将图片转换为字节数组
|
||||
*/
|
||||
public static byte[] imageToBytes(BufferedImage image, String format) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, format, baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建头像样式的图片(带首字母)
|
||||
*/
|
||||
public static BufferedImage createAvatarImage(int size, String initial, Color bgColor) {
|
||||
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
// 圆形背景
|
||||
g2d.setColor(bgColor);
|
||||
g2d.fillOval(0, 0, size, size);
|
||||
|
||||
// 首字母
|
||||
g2d.setColor(Color.WHITE);
|
||||
g2d.setFont(new Font("Arial", Font.BOLD, size / 2));
|
||||
FontMetrics fm = g2d.getFontMetrics();
|
||||
int textWidth = fm.stringWidth(initial);
|
||||
int textHeight = fm.getHeight();
|
||||
g2d.drawString(initial, (size - textWidth) / 2, (size + textHeight) / 2 - fm.getDescent());
|
||||
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证图片尺寸
|
||||
*/
|
||||
public static boolean validateImageSize(BufferedImage image, int expectedWidth, int expectedHeight) {
|
||||
return image.getWidth() == expectedWidth && image.getHeight() == expectedHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证图片不为空白
|
||||
*/
|
||||
public static boolean isNotBlank(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
|
||||
// 采样检查几个点
|
||||
int samplePoints = 10;
|
||||
Color firstColor = new Color(image.getRGB(0, 0));
|
||||
boolean hasVariation = false;
|
||||
|
||||
for (int i = 0; i < samplePoints && !hasVariation; i++) {
|
||||
int x = (width * i) / samplePoints;
|
||||
int y = (height * i) / samplePoints;
|
||||
if (x < width && y < height) {
|
||||
Color sampleColor = new Color(image.getRGB(x, y));
|
||||
if (!sampleColor.equals(firstColor)) {
|
||||
hasVariation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasVariation;
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.test;
|
||||
|
||||
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||
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 com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 现实场景自动化测试助手
|
||||
* 提供便捷方法快速创建和测试1020x1520拼图场景
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-17
|
||||
*/
|
||||
public class RealScenarioTestHelper {
|
||||
|
||||
private final PuzzleImageRenderer renderer;
|
||||
private final Path outputDir;
|
||||
private final Map<String, File> resourceFiles;
|
||||
|
||||
public RealScenarioTestHelper(Path outputDir) throws IOException {
|
||||
this.renderer = new PuzzleImageRenderer();
|
||||
this.outputDir = outputDir;
|
||||
this.resourceFiles = new HashMap<>();
|
||||
initializeMockResources();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化Mock资源(图片、二维码等)
|
||||
*/
|
||||
private void initializeMockResources() throws IOException {
|
||||
Files.createDirectories(outputDir);
|
||||
|
||||
// 创建3张场景图片(每张470高度)
|
||||
createSceneImage("image1", "1", new Color(255, 200, 200));
|
||||
createSceneImage("image2", "2", new Color(200, 255, 200));
|
||||
createSceneImage("image3", "3", new Color(200, 200, 255));
|
||||
|
||||
// 创建二维码(100x100)
|
||||
BufferedImage qrCode = MockImageUtil.createMockQRCode(100);
|
||||
File qrFile = saveImage(qrCode, "qrcode.png");
|
||||
resourceFiles.put("qrCode", qrFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建场景图片(1020x470)
|
||||
*/
|
||||
private void createSceneImage(String key, String text, Color bgColor) throws IOException {
|
||||
BufferedImage image = MockImageUtil.createImageWithText(1020, 470, text, bgColor, Color.BLACK);
|
||||
File file = saveImage(image, key + ".jpg");
|
||||
resourceFiles.put(key, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存图片
|
||||
*/
|
||||
private File saveImage(BufferedImage image, String filename) throws IOException {
|
||||
File file = outputDir.resolve(filename).toFile();
|
||||
ImageIO.write(image, filename.endsWith(".png") ? "PNG" : "JPG", file);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并测试完整的现实场景
|
||||
*
|
||||
* @param bottomText 底部显示的文字
|
||||
* @return 生成的图片文件
|
||||
*/
|
||||
public TestResult createAndTestRealScenario(String bottomText) throws IOException {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 创建模板
|
||||
PuzzleTemplateEntity template = createTemplate();
|
||||
|
||||
// 2. 创建元素
|
||||
List<PuzzleElementEntity> elements = createElements(template.getId());
|
||||
|
||||
// 3. 准备动态数据
|
||||
Map<String, String> dynamicData = prepareDynamicData(bottomText);
|
||||
|
||||
// 4. 渲染图片
|
||||
BufferedImage result = renderer.render(template, elements, dynamicData);
|
||||
|
||||
// 5. 保存结果
|
||||
File outputFile = saveImage(result, "real_scenario_result.png");
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
|
||||
return new TestResult(
|
||||
outputFile,
|
||||
result.getWidth(),
|
||||
result.getHeight(),
|
||||
outputFile.length(),
|
||||
duration,
|
||||
true,
|
||||
"测试成功"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建1020x1520模板
|
||||
*/
|
||||
private PuzzleTemplateEntity createTemplate() {
|
||||
return PuzzleTestDataBuilder.createTemplate(
|
||||
"real_scenario_auto",
|
||||
1020,
|
||||
1520,
|
||||
"#F5F5F5"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建元素列表
|
||||
*/
|
||||
private List<PuzzleElementEntity> createElements(Long templateId) {
|
||||
return PuzzleTestDataBuilder.createRealScenarioElements(templateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备动态数据
|
||||
*/
|
||||
private Map<String, String> prepareDynamicData(String bottomText) {
|
||||
Map<String, String> data = new HashMap<>();
|
||||
data.put("image1", resourceFiles.get("image1").getAbsolutePath());
|
||||
data.put("image2", resourceFiles.get("image2").getAbsolutePath());
|
||||
data.put("image3", resourceFiles.get("image3").getAbsolutePath());
|
||||
data.put("qrCode", resourceFiles.get("qrCode").getAbsolutePath());
|
||||
data.put("bottomText", bottomText != null ? bottomText : "扫码查看详情");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证生成的图片
|
||||
*/
|
||||
public boolean validateResult(BufferedImage image) {
|
||||
// 验证尺寸
|
||||
if (image.getWidth() != 1020 || image.getHeight() != 1520) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证不为空白
|
||||
return MockImageUtil.isNotBlank(image);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印测试报告
|
||||
*/
|
||||
public void printTestReport(TestResult result) {
|
||||
System.out.println("\n" + "=".repeat(60));
|
||||
System.out.println("📊 拼图功能现实场景测试报告");
|
||||
System.out.println("=".repeat(60));
|
||||
System.out.println("✅ 测试状态: " + (result.isSuccess() ? "通过" : "失败"));
|
||||
System.out.println("📁 输出文件: " + result.getOutputFile().getAbsolutePath());
|
||||
System.out.println("📐 图片尺寸: " + result.getWidth() + " x " + result.getHeight() + " 像素");
|
||||
System.out.println("💾 文件大小: " + result.getFileSize() / 1024 + " KB");
|
||||
System.out.println("⏱️ 渲染耗时: " + result.getDuration() + " ms");
|
||||
System.out.println("📝 测试消息: " + result.getMessage());
|
||||
System.out.println("=".repeat(60) + "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试结果类
|
||||
*/
|
||||
public static class TestResult {
|
||||
private final File outputFile;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final long fileSize;
|
||||
private final long duration;
|
||||
private final boolean success;
|
||||
private final String message;
|
||||
|
||||
public TestResult(File outputFile, int width, int height, long fileSize,
|
||||
long duration, boolean success, String message) {
|
||||
this.outputFile = outputFile;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.fileSize = fileSize;
|
||||
this.duration = duration;
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public File getOutputFile() {
|
||||
return outputFile;
|
||||
}
|
||||
|
||||
public int getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public int getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public long getFileSize() {
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
public long getDuration() {
|
||||
return duration;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速测试方法(主方法,可直接运行)
|
||||
*/
|
||||
public static void main(String[] args) throws IOException {
|
||||
// 【重要】注册 Element 类型(主方法运行必须先注册)
|
||||
ElementFactory.register(ElementType.TEXT, TextElement.class);
|
||||
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
|
||||
|
||||
// 创建临时目录
|
||||
Path tempDir = Files.createTempDirectory("puzzle_test_");
|
||||
System.out.println("📂 测试目录: " + tempDir.toAbsolutePath());
|
||||
|
||||
// 创建测试助手
|
||||
RealScenarioTestHelper helper = new RealScenarioTestHelper(tempDir);
|
||||
|
||||
// 执行测试
|
||||
TestResult result = helper.createAndTestRealScenario("奇遇时光乐园\n2025.11.11");
|
||||
|
||||
// 打印报告
|
||||
helper.printTestReport(result);
|
||||
|
||||
// 验证结果
|
||||
if (result.isSuccess()) {
|
||||
System.out.println("🎉 恭喜!现实场景测试全部通过!");
|
||||
System.out.println("💡 你可以打开输出图片查看效果: " + result.getOutputFile().getAbsolutePath());
|
||||
} else {
|
||||
System.err.println("❌ 测试失败: " + result.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user