From ecbdec4518fb92c7c6688bd12fce0b36221db95b Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 27 Jan 2026 09:47:33 +0800 Subject: [PATCH] test(puzzle --- .../puzzle/element/base/BaseElement.java | 219 --------- .../puzzle/element/base/ElementConfig.java | 30 -- .../puzzle/element/base/ElementFactory.java | 172 ------- .../puzzle/element/base/ElementRegistrar.java | 40 -- .../basic/puzzle/element/base/Position.java | 95 ---- .../puzzle/element/config/ImageConfig.java | 199 -------- .../puzzle/element/config/TextConfig.java | 141 ------ .../exception/ElementValidationException.java | 40 -- .../puzzle/element/impl/ImageElement.java | 323 ------------- .../puzzle/element/impl/TextElement.java | 225 --------- .../element/renderer/RenderContext.java | 74 --- .../impl/PuzzleGenerateServiceImpl.java | 238 ---------- .../puzzle/util/PuzzleImageRenderer.java | 174 ------- .../puzzle/element/ElementFactoryTest.java | 99 ---- .../puzzle/element/ImageElementTest.java | 177 ------- .../basic/puzzle/element/TextElementTest.java | 107 ----- .../fill/PuzzleElementFillEngineTest.java | 446 ------------------ .../condition/ConditionEvaluatorTest.java | 18 +- .../puzzle/integration/ElementDebugTest.java | 103 ---- .../PuzzleRealScenarioIntegrationTest.java | 319 ------------- ...uzzleGenerateServiceDeduplicationTest.java | 146 +++--- .../impl/PuzzleGenerateServiceImplTest.java | 137 +++--- .../impl/PuzzleTemplateServiceImplTest.java | 4 + .../ycwl/basic/puzzle/test/MockImageUtil.java | 169 ------- .../puzzle/test/RealScenarioTestHelper.java | 256 ---------- 25 files changed, 168 insertions(+), 3783 deletions(-) delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/base/BaseElement.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/base/ElementConfig.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/base/ElementFactory.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/base/ElementRegistrar.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/base/Position.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/config/TextConfig.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/exception/ElementValidationException.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/impl/TextElement.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/element/renderer/RenderContext.java delete mode 100644 src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java delete mode 100644 src/test/java/com/ycwl/basic/puzzle/element/ElementFactoryTest.java delete mode 100644 src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java delete mode 100644 src/test/java/com/ycwl/basic/puzzle/element/TextElementTest.java delete mode 100644 src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java delete mode 100644 src/test/java/com/ycwl/basic/puzzle/integration/ElementDebugTest.java delete mode 100644 src/test/java/com/ycwl/basic/puzzle/integration/PuzzleRealScenarioIntegrationTest.java delete mode 100644 src/test/java/com/ycwl/basic/puzzle/test/MockImageUtil.java delete mode 100644 src/test/java/com/ycwl/basic/puzzle/test/RealScenarioTestHelper.java diff --git a/src/main/java/com/ycwl/basic/puzzle/element/base/BaseElement.java b/src/main/java/com/ycwl/basic/puzzle/element/base/BaseElement.java deleted file mode 100644 index bf93b6d7..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/base/BaseElement.java +++ /dev/null @@ -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 配置类型 - * @return 配置对象 - */ - protected T parseConfig(String configJson, Class 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); - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/element/base/ElementConfig.java b/src/main/java/com/ycwl/basic/puzzle/element/base/ElementConfig.java deleted file mode 100644 index dfe70483..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/base/ElementConfig.java +++ /dev/null @@ -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() { - // 默认不做验证 - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/element/base/ElementFactory.java b/src/main/java/com/ycwl/basic/puzzle/element/base/ElementFactory.java deleted file mode 100644 index aa8586ac..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/base/ElementFactory.java +++ /dev/null @@ -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> ELEMENT_REGISTRY = new ConcurrentHashMap<>(); - - /** - * 构造器缓存(性能优化) - * key: Element实现类 - * value: 无参构造器 - */ - private static final Map, Constructor> CONSTRUCTOR_CACHE = new ConcurrentHashMap<>(); - - /** - * 注册Element类型 - * - * @param type 元素类型 - * @param elementClass Element实现类 - */ - public static void register(ElementType type, Class 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 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 elementClass) { - try { - // 从缓存获取构造器 - Constructor 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> 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注册表已清空"); - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/element/base/ElementRegistrar.java b/src/main/java/com/ycwl/basic/puzzle/element/base/ElementRegistrar.java deleted file mode 100644 index a082c7db..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/base/ElementRegistrar.java +++ /dev/null @@ -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()); - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/element/base/Position.java b/src/main/java/com/ycwl/basic/puzzle/element/base/Position.java deleted file mode 100644 index 79347568..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/base/Position.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java b/src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java deleted file mode 100644 index d74a74aa..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/config/ImageConfig.java +++ /dev/null @@ -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" + - "}"; - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/element/config/TextConfig.java b/src/main/java/com/ycwl/basic/puzzle/element/config/TextConfig.java deleted file mode 100644 index 0851d801..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/config/TextConfig.java +++ /dev/null @@ -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" + - "}"; - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/element/exception/ElementValidationException.java b/src/main/java/com/ycwl/basic/puzzle/element/exception/ElementValidationException.java deleted file mode 100644 index 5fa04803..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/exception/ElementValidationException.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java b/src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java deleted file mode 100644 index 8be18d7d..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/impl/ImageElement.java +++ /dev/null @@ -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; - } - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/element/impl/TextElement.java b/src/main/java/com/ycwl/basic/puzzle/element/impl/TextElement.java deleted file mode 100644 index 7dcf7795..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/impl/TextElement.java +++ /dev/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; - } - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/element/renderer/RenderContext.java b/src/main/java/com/ycwl/basic/puzzle/element/renderer/RenderContext.java deleted file mode 100644 index 259a6f11..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/element/renderer/RenderContext.java +++ /dev/null @@ -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 dynamicData; - - /** - * 画布宽度 - */ - private Integer canvasWidth; - - /** - * 画布高度 - */ - private Integer canvasHeight; - - /** - * 是否启用抗锯齿 - */ - private boolean antiAliasing = true; - - /** - * 是否启用高质量渲染 - */ - private boolean highQuality = true; - - public RenderContext(Graphics2D graphics, Map dynamicData) { - this.graphics = graphics; - this.dynamicData = dynamicData; - } - - public RenderContext(Graphics2D graphics, Map 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); - } -} diff --git a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java index 717048a8..c7e7fd85 100644 --- a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java +++ b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java @@ -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 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 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 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 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(合并自动填充和手动数据) * 优先级: 手动传入的数据 > 自动填充的数据 diff --git a/src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java b/src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java deleted file mode 100644 index 4fb8c0d1..00000000 --- a/src/main/java/com/ycwl/basic/puzzle/util/PuzzleImageRenderer.java +++ /dev/null @@ -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 elements, - Map 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; - } -} diff --git a/src/test/java/com/ycwl/basic/puzzle/element/ElementFactoryTest.java b/src/test/java/com/ycwl/basic/puzzle/element/ElementFactoryTest.java deleted file mode 100644 index 3b30666c..00000000 --- a/src/test/java/com/ycwl/basic/puzzle/element/ElementFactoryTest.java +++ /dev/null @@ -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)); - } -} diff --git a/src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java b/src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java deleted file mode 100644 index 2390b359..00000000 --- a/src/test/java/com/ycwl/basic/puzzle/element/ImageElementTest.java +++ /dev/null @@ -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 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); - } - } -} diff --git a/src/test/java/com/ycwl/basic/puzzle/element/TextElementTest.java b/src/test/java/com/ycwl/basic/puzzle/element/TextElementTest.java deleted file mode 100644 index 54070fee..00000000 --- a/src/test/java/com/ycwl/basic/puzzle/element/TextElementTest.java +++ /dev/null @@ -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 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")); - } -} diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java deleted file mode 100644 index fddefbb1..00000000 --- a/src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java +++ /dev/null @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; - } -} diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/condition/ConditionEvaluatorTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/condition/ConditionEvaluatorTest.java index d5fe8ca7..e0e14033 100644 --- a/src/test/java/com/ycwl/basic/puzzle/fill/condition/ConditionEvaluatorTest.java +++ b/src/test/java/com/ycwl/basic/puzzle/fill/condition/ConditionEvaluatorTest.java @@ -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)) diff --git a/src/test/java/com/ycwl/basic/puzzle/integration/ElementDebugTest.java b/src/test/java/com/ycwl/basic/puzzle/integration/ElementDebugTest.java deleted file mode 100644 index 3913f1b0..00000000 --- a/src/test/java/com/ycwl/basic/puzzle/integration/ElementDebugTest.java +++ /dev/null @@ -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, "所有元素都应该创建成功"); - } -} diff --git a/src/test/java/com/ycwl/basic/puzzle/integration/PuzzleRealScenarioIntegrationTest.java b/src/test/java/com/ycwl/basic/puzzle/integration/PuzzleRealScenarioIntegrationTest.java deleted file mode 100644 index 57b3e0d0..00000000 --- a/src/test/java/com/ycwl/basic/puzzle/integration/PuzzleRealScenarioIntegrationTest.java +++ /dev/null @@ -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 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 elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId()); - - // Given: 准备动态数据(使用本地文件路径) - Map 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 elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId()); - - Map 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 elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId()); - - // When: 不传动态数据,使用默认值 - Map 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 elements = PuzzleTestDataBuilder.createRealScenarioElements(solidBgTemplate.getId()); - Map 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 elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId()); - Map 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 prepareLocalFilePaths() { - Map 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 - ); - } -} diff --git a/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceDeduplicationTest.java b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceDeduplicationTest.java index 1ddc9abd..3acf3b8b 100644 --- a/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceDeduplicationTest.java +++ b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceDeduplicationTest.java @@ -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 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 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 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 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()); // 渲染了两次 } // ===== 辅助方法 ===== diff --git a/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImplTest.java b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImplTest.java index a9048f9d..475a0161 100644 --- a/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImplTest.java +++ b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImplTest.java @@ -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"); + PuzzleGenerateResponse response = service.generate(request); - useStorageAdapter(storageAdapter); - try { - PuzzleGenerateResponse response = service.generate(request); - - assertNotNull(response); - assertEquals("https://oss.example.com/puzzle/final.png", response.getImageUrl()); - } finally { - resetStorageFactory(); - } + assertNotNull(response); + assertEquals("https://oss.example.com/puzzle/final.png", response.getImageUrl()); verify(fillEngine).execute(template.getId(), 88L, 9L); - ArgumentCaptor captor = - ArgumentCaptor.forClass(com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity.class); + ArgumentCaptor 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(); - } + service.generate(request); + // 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 map = (Map) 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"); - } } diff --git a/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleTemplateServiceImplTest.java b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleTemplateServiceImplTest.java index 0088efe5..7fa6485a 100644 --- a/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleTemplateServiceImplTest.java +++ b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleTemplateServiceImplTest.java @@ -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; diff --git a/src/test/java/com/ycwl/basic/puzzle/test/MockImageUtil.java b/src/test/java/com/ycwl/basic/puzzle/test/MockImageUtil.java deleted file mode 100644 index 7db90d37..00000000 --- a/src/test/java/com/ycwl/basic/puzzle/test/MockImageUtil.java +++ /dev/null @@ -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; - } -} diff --git a/src/test/java/com/ycwl/basic/puzzle/test/RealScenarioTestHelper.java b/src/test/java/com/ycwl/basic/puzzle/test/RealScenarioTestHelper.java deleted file mode 100644 index 78f0f043..00000000 --- a/src/test/java/com/ycwl/basic/puzzle/test/RealScenarioTestHelper.java +++ /dev/null @@ -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 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 elements = createElements(template.getId()); - - // 3. 准备动态数据 - Map 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 createElements(Long templateId) { - return PuzzleTestDataBuilder.createRealScenarioElements(templateId); - } - - /** - * 准备动态数据 - */ - private Map prepareDynamicData(String bottomText) { - Map 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()); - } - } -}