From 0235d1d1217f6cb10b3e7c0055ac5469c06eb4ba Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 16 Jan 2026 14:28:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(watermark):=20=E6=B7=BB=E5=8A=A0=E6=B0=B4?= =?UTF-8?q?=E5=8D=B0=E8=BE=B9=E7=BC=98=E6=B8=B2=E6=9F=93=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现抽象水印模板构建器基类提供通用构建工具方法 - 定义水印模板构建器接口规范模板构建契约 - 实现徕卡风格水印模板构建器支持底部扩展布局 - 实现普通风格水印模板构建器支持左下角布局 - 实现打印专用水印模板构建器支持缩放和偏移 - 创建水印边缘任务服务统一管理模板构建流程 - 添加水印请求参数类定义边缘渲染所需字段 - 实现水印模板构建结果类封装模板元素和动态数据 - 集成拼图边缘渲染任务服务实现异步渲染机制 --- .../AbstractWatermarkTemplateBuilder.java | 163 +++++++++++ .../edge/IWatermarkTemplateBuilder.java | 21 ++ .../edge/LeicaWatermarkTemplateBuilder.java | 174 +++++++++++ .../edge/NormalWatermarkTemplateBuilder.java | 141 +++++++++ ...rinterDefaultWatermarkTemplateBuilder.java | 147 ++++++++++ .../edge/WatermarkEdgeTaskCreator.java | 111 +++++++ .../watermark/edge/WatermarkRequest.java | 92 ++++++ .../edge/WatermarkTemplateResult.java | 30 ++ .../WatermarkEdgeTestController.java | 272 ++++++++++++++++++ 9 files changed, 1151 insertions(+) create mode 100644 src/main/java/com/ycwl/basic/image/watermark/edge/AbstractWatermarkTemplateBuilder.java create mode 100644 src/main/java/com/ycwl/basic/image/watermark/edge/IWatermarkTemplateBuilder.java create mode 100644 src/main/java/com/ycwl/basic/image/watermark/edge/LeicaWatermarkTemplateBuilder.java create mode 100644 src/main/java/com/ycwl/basic/image/watermark/edge/NormalWatermarkTemplateBuilder.java create mode 100644 src/main/java/com/ycwl/basic/image/watermark/edge/PrinterDefaultWatermarkTemplateBuilder.java create mode 100644 src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkEdgeTaskCreator.java create mode 100644 src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkRequest.java create mode 100644 src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkTemplateResult.java create mode 100644 src/main/java/com/ycwl/basic/image/watermark/edge/controller/WatermarkEdgeTestController.java diff --git a/src/main/java/com/ycwl/basic/image/watermark/edge/AbstractWatermarkTemplateBuilder.java b/src/main/java/com/ycwl/basic/image/watermark/edge/AbstractWatermarkTemplateBuilder.java new file mode 100644 index 00000000..6ecda8b6 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/watermark/edge/AbstractWatermarkTemplateBuilder.java @@ -0,0 +1,163 @@ +package com.ycwl.basic.image.watermark.edge; + +import com.ycwl.basic.puzzle.entity.PuzzleElementEntity; +import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity; +import com.ycwl.basic.utils.JacksonUtil; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 水印模板构建器基类 + * 提供构建元素的工具方法 + */ +public abstract class AbstractWatermarkTemplateBuilder implements IWatermarkTemplateBuilder { + + // 虚拟模板ID(运行时使用,不存储) + private static final AtomicLong VIRTUAL_TEMPLATE_ID = new AtomicLong(-1); + private static final AtomicLong VIRTUAL_ELEMENT_ID = new AtomicLong(-1); + + /** + * 元素类型常量 + */ + protected static final String ELEMENT_TYPE_IMAGE = "IMAGE"; + protected static final String ELEMENT_TYPE_TEXT = "TEXT"; + + /** + * 图片适配模式 + */ + protected static final String FIT_MODE_COVER = "COVER"; + protected static final String FIT_MODE_CONTAIN = "CONTAIN"; + + /** + * 文本对齐方式 + */ + protected static final String TEXT_ALIGN_LEFT = "LEFT"; + protected static final String TEXT_ALIGN_RIGHT = "RIGHT"; + protected static final String TEXT_ALIGN_CENTER = "CENTER"; + + /** + * 创建虚拟模板 + */ + protected PuzzleTemplateEntity createTemplate(String code, int width, int height, String backgroundImage) { + PuzzleTemplateEntity template = new PuzzleTemplateEntity(); + template.setId(VIRTUAL_TEMPLATE_ID.decrementAndGet()); + template.setCode(code); + template.setName("水印模板-" + getStyle()); + template.setCanvasWidth(width); + template.setCanvasHeight(height); + template.setBackgroundType(1); // 图片背景 + template.setBackgroundImage(backgroundImage); + template.setStatus(1); + return template; + } + + /** + * 创建纯色背景模板 + */ + protected PuzzleTemplateEntity createTemplateWithColor(String code, int width, int height, String backgroundColor) { + PuzzleTemplateEntity template = new PuzzleTemplateEntity(); + template.setId(VIRTUAL_TEMPLATE_ID.decrementAndGet()); + template.setCode(code); + template.setName("水印模板-" + getStyle()); + template.setCanvasWidth(width); + template.setCanvasHeight(height); + template.setBackgroundType(0); // 纯色背景 + template.setBackgroundColor(backgroundColor); + template.setStatus(1); + return template; + } + + /** + * 创建图片元素 + */ + protected PuzzleElementEntity createImageElement(String key, String name, int x, int y, int width, int height, int zIndex, + String fitMode, Integer borderRadius, Integer opacity) { + PuzzleElementEntity element = new PuzzleElementEntity(); + element.setId(VIRTUAL_ELEMENT_ID.decrementAndGet()); + element.setElementType(ELEMENT_TYPE_IMAGE); + element.setElementKey(key); + element.setElementName(name); + element.setXPosition(x); + element.setYPosition(y); + element.setWidth(width); + element.setHeight(height); + element.setZIndex(zIndex); + element.setOpacity(opacity != null ? opacity : 100); + + // 构建配置JSON + Map config = new HashMap<>(); + config.put("imageFitMode", fitMode != null ? fitMode : FIT_MODE_COVER); + if (borderRadius != null && borderRadius > 0) { + config.put("borderRadius", borderRadius); + } + element.setConfig(JacksonUtil.toJson(config)); + + return element; + } + + /** + * 创建圆形图片元素 + */ + protected PuzzleElementEntity createCircleImageElement(String key, String name, int x, int y, int diameter, int zIndex) { + // 圆形 = borderRadius 为直径的一半 + return createImageElement(key, name, x, y, diameter, diameter, zIndex, FIT_MODE_COVER, diameter / 2, null); + } + + /** + * 创建文字元素 + */ + protected PuzzleElementEntity createTextElement(String key, String name, int x, int y, int width, int height, int zIndex, + String fontFamily, int fontSize, String fontColor, + String fontWeight, String textAlign) { + PuzzleElementEntity element = new PuzzleElementEntity(); + element.setId(VIRTUAL_ELEMENT_ID.decrementAndGet()); + element.setElementType(ELEMENT_TYPE_TEXT); + element.setElementKey(key); + element.setElementName(name); + element.setXPosition(x); + element.setYPosition(y); + element.setWidth(width); + element.setHeight(height); + element.setZIndex(zIndex); + element.setOpacity(100); + + // 构建配置JSON + Map config = new HashMap<>(); + config.put("fontFamily", fontFamily != null ? fontFamily : "PingFang SC"); + config.put("fontSize", fontSize); + config.put("fontColor", fontColor != null ? fontColor : "#FFFFFF"); + config.put("fontWeight", fontWeight != null ? fontWeight : "NORMAL"); + config.put("textAlign", textAlign != null ? textAlign : TEXT_ALIGN_LEFT); + element.setConfig(JacksonUtil.toJson(config)); + + return element; + } + + /** + * 创建构建结果 + */ + protected WatermarkTemplateResult createResult(PuzzleTemplateEntity template, + List elements, + Map dynamicData) { + WatermarkTemplateResult result = new WatermarkTemplateResult(); + result.setTemplate(template); + result.setElements(elements); + result.setDynamicData(dynamicData); + return result; + } + + /** + * 创建空的元素列表和动态数据 + */ + protected List newElementList() { + return new ArrayList<>(); + } + + protected Map newDynamicData() { + return new HashMap<>(); + } +} diff --git a/src/main/java/com/ycwl/basic/image/watermark/edge/IWatermarkTemplateBuilder.java b/src/main/java/com/ycwl/basic/image/watermark/edge/IWatermarkTemplateBuilder.java new file mode 100644 index 00000000..18fe4800 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/watermark/edge/IWatermarkTemplateBuilder.java @@ -0,0 +1,21 @@ +package com.ycwl.basic.image.watermark.edge; + +/** + * 水印模板构建器接口 + * 将水印参数转换为拼图模板+元素的形式,用于发送给边缘渲染任务 + */ +public interface IWatermarkTemplateBuilder { + + /** + * 构建水印模板 + * + * @param request 水印请求参数 + * @return 模板构建结果 + */ + WatermarkTemplateResult build(WatermarkRequest request); + + /** + * 获取水印风格标识 + */ + String getStyle(); +} diff --git a/src/main/java/com/ycwl/basic/image/watermark/edge/LeicaWatermarkTemplateBuilder.java b/src/main/java/com/ycwl/basic/image/watermark/edge/LeicaWatermarkTemplateBuilder.java new file mode 100644 index 00000000..02b45f64 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/watermark/edge/LeicaWatermarkTemplateBuilder.java @@ -0,0 +1,174 @@ +package com.ycwl.basic.image.watermark.edge; + +import com.ycwl.basic.puzzle.entity.PuzzleElementEntity; +import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * 徕卡风格水印模板构建器 + * 对应 LeicaWatermarkOperator + * + * 布局说明: + * - 画布高度 = 原图高度 + 底部扩展区域(白色背景) + * - 原图放在画布顶部 + * - 底部白色区域左侧:帧途 Logo + "帧途" 文字 + * - 底部白色区域右侧:二维码(含头像)+ 景区名 + 日期时间 + */ +@Component +public class LeicaWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder { + + public static final String STYLE = "leica"; + + // 常量配置(与 LeicaWatermarkOperator 保持一致) + private static final int EXTRA_BOTTOM_PX = 140; + private static final int LOGO_SIZE = 50; + private static final int LOGO_EXTRA_BORDER = 20; + private static final int LOGO_FONT_SIZE = 38; + private static final String LOGO_TEXT_COLOR = "#333333"; + private static final int QRCODE_SIZE = 120; + private static final int QRCODE_OFFSET_X = 5; + private static final int QRCODE_OFFSET_Y = 20; + private static final int OFFSET_X = 80; + private static final int OFFSET_Y = 30; + private static final int SCENIC_FONT_SIZE = 32; + private static final String SCENIC_COLOR = "#333333"; + private static final int DATETIME_FONT_SIZE = 28; + private static final String DATETIME_COLOR = "#999999"; + + /** + * Logo 图片 URL(需要预先上传到 OSS) + */ + private static final String LOGO_URL = "https://oss.zhentuai.com/zt/zt-logo.png"; + + @Override + public String getStyle() { + return STYLE; + } + + @Override + public WatermarkTemplateResult build(WatermarkRequest request) { + int imageWidth = request.getImageWidth(); + int imageHeight = request.getImageHeight(); + + // 画布高度 = 原图高度 + 底部扩展区域 + int canvasWidth = imageWidth; + int canvasHeight = imageHeight + EXTRA_BOTTOM_PX; + + // 创建模板(白色背景) + PuzzleTemplateEntity template = createTemplateWithColor( + "watermark_leica_" + System.currentTimeMillis(), + canvasWidth, + canvasHeight, + "#FFFFFF" + ); + + List elements = newElementList(); + Map dynamicData = newDynamicData(); + + // 1. 原图元素(放在画布顶部) + PuzzleElementEntity originalImageElement = createImageElement( + "originalImage", "原图", + 0, 0, + imageWidth, imageHeight, 1, + FIT_MODE_COVER, null, null + ); + elements.add(originalImageElement); + dynamicData.put("originalImage", request.getOriginalImageUrl()); + + // 2. Logo 元素(底部左侧) + int logoY = imageHeight + OFFSET_Y + LOGO_EXTRA_BORDER; + PuzzleElementEntity logoElement = createImageElement( + "logo", "Logo", + OFFSET_X, logoY - 12, + LOGO_SIZE, LOGO_SIZE, 10, + FIT_MODE_CONTAIN, null, null + ); + elements.add(logoElement); + dynamicData.put("logo", LOGO_URL); + + // 3. "帧途" 文字(Logo 右边) + int logoTextX = OFFSET_X + LOGO_SIZE + 5; + int logoTextY = imageHeight + OFFSET_Y + LOGO_EXTRA_BORDER; + PuzzleElementEntity logoTextElement = createTextElement( + "logoText", "帧途文字", + logoTextX, logoTextY, + 100, LOGO_SIZE, 10, + "PingFang SC", LOGO_FONT_SIZE, LOGO_TEXT_COLOR, + "NORMAL", TEXT_ALIGN_LEFT + ); + elements.add(logoTextElement); + dynamicData.put("logoText", "帧途"); + + // 4. 计算右侧区域位置 + int qrcodeWidth = QRCODE_SIZE; + int qrcodeHeight = QRCODE_SIZE; + + // 估算文字宽度(使用景区名和日期的较大者) + int estimatedTextWidth = Math.max( + (request.getScenicLine() != null ? request.getScenicLine().length() : 0) * SCENIC_FONT_SIZE / 2, + (request.getDatetimeLine() != null ? request.getDatetimeLine().length() : 0) * DATETIME_FONT_SIZE / 2 + ); + + int qrcodeX = canvasWidth - OFFSET_X - qrcodeWidth - QRCODE_OFFSET_X - estimatedTextWidth; + int qrcodeY = imageHeight + OFFSET_Y - QRCODE_OFFSET_Y; + + // 5. 二维码元素 + PuzzleElementEntity qrcodeElement = createImageElement( + "qrcode", "二维码", + qrcodeX, qrcodeY, + qrcodeWidth, qrcodeHeight, 10, + FIT_MODE_CONTAIN, null, null + ); + elements.add(qrcodeElement); + dynamicData.put("qrcode", request.getQrcodeUrl()); + + // 6. 头像元素(二维码中央,可选) + if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) { + int avatarDiameter = (int) (qrcodeHeight * 0.45); + int avatarX = qrcodeX + (qrcodeWidth - avatarDiameter) / 2; + int avatarY = qrcodeY + (qrcodeHeight - avatarDiameter) / 2; + + PuzzleElementEntity faceElement = createCircleImageElement( + "face", "头像", + avatarX, avatarY, + avatarDiameter, 20 + ); + elements.add(faceElement); + dynamicData.put("face", request.getFaceUrl()); + } + + // 7. 计算文字位置(与二维码垂直居中) + int qrcodeCenter = qrcodeY + qrcodeHeight / 2; + int totalTextHeight = SCENIC_FONT_SIZE + DATETIME_FONT_SIZE + 10; + int textY = qrcodeCenter - totalTextHeight / 2; + int textX = canvasWidth - OFFSET_X - estimatedTextWidth; + + // 8. 景区名文字 + PuzzleElementEntity scenicTextElement = createTextElement( + "scenicLine", "景区名", + textX, textY, + estimatedTextWidth, SCENIC_FONT_SIZE + 10, 30, + "PingFang SC", SCENIC_FONT_SIZE, SCENIC_COLOR, + "NORMAL", TEXT_ALIGN_LEFT + ); + elements.add(scenicTextElement); + dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : ""); + + // 9. 日期时间文字 + int datetimeY = textY + SCENIC_FONT_SIZE + 5; + PuzzleElementEntity datetimeTextElement = createTextElement( + "datetimeLine", "日期时间", + textX, datetimeY, + estimatedTextWidth, DATETIME_FONT_SIZE + 10, 30, + "PingFang SC", DATETIME_FONT_SIZE, DATETIME_COLOR, + "NORMAL", TEXT_ALIGN_LEFT + ); + elements.add(datetimeTextElement); + dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : ""); + + return createResult(template, elements, dynamicData); + } +} diff --git a/src/main/java/com/ycwl/basic/image/watermark/edge/NormalWatermarkTemplateBuilder.java b/src/main/java/com/ycwl/basic/image/watermark/edge/NormalWatermarkTemplateBuilder.java new file mode 100644 index 00000000..21bab7de --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/watermark/edge/NormalWatermarkTemplateBuilder.java @@ -0,0 +1,141 @@ +package com.ycwl.basic.image.watermark.edge; + +import com.ycwl.basic.puzzle.entity.PuzzleElementEntity; +import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Normal 风格水印模板构建器 + * 对应 NormalWatermarkOperator + * + * 布局说明: + * - 白色背景 + 原图元素(COVER模式) + * - 左下角:圆形二维码(带白色圆形背景) + * - 二维码中央:圆形头像(可选) + * - 二维码右侧:景区名 + 日期时间 两行文字(白色) + */ +@Component +public class NormalWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder { + + public static final String STYLE = "normal"; + + // 常量配置(与 NormalWatermarkOperator 保持一致) + private static final int OFFSET_Y = 90; + private static final int QRCODE_SIZE = 150; + private static final int QRCODE_OFFSET_X = 10; + private static final int QRCODE_OFFSET_Y = -20; + private static final int SCENIC_FONT_SIZE = 42; + private static final int DATETIME_FONT_SIZE = 42; + private static final String FONT_COLOR = "#FFFFFF"; + + @Override + public String getStyle() { + return STYLE; + } + + @Override + public WatermarkTemplateResult build(WatermarkRequest request) { + int imageWidth = request.getImageWidth(); + int imageHeight = request.getImageHeight(); + + // 创建模板(白色背景,原图作为元素实现 COVER 模式) + PuzzleTemplateEntity template = createTemplateWithColor( + "watermark_normal_" + System.currentTimeMillis(), + imageWidth, + imageHeight, + "#FFFFFF" + ); + + List elements = newElementList(); + Map dynamicData = newDynamicData(); + + // 0. 原图元素(z-index=1,最底层,COVER模式) + PuzzleElementEntity originalImageElement = createImageElement( + "originalImage", "原图", + 0, 0, + imageWidth, imageHeight, 1, + FIT_MODE_COVER, null, null + ); + elements.add(originalImageElement); + dynamicData.put("originalImage", request.getOriginalImageUrl()); + + // 计算二维码位置 + int qrcodeHeight = QRCODE_SIZE; + int qrcodeWidth = QRCODE_SIZE; // 假设二维码是正方形 + int offsetX = calculateQrcodeOffsetX(imageWidth, qrcodeWidth, request); + int qrcodeY = imageHeight - OFFSET_Y - qrcodeHeight; + int qrcodeX = offsetX; + + // 1. 白色圆形背景(比二维码大10像素) + int whiteCircleSize = qrcodeHeight + 10; + int whiteCircleX = qrcodeX - (whiteCircleSize - qrcodeWidth) / 2; + int whiteCircleY = qrcodeY + QRCODE_OFFSET_Y - (whiteCircleSize - qrcodeHeight) / 2; + + // 使用图片元素模拟白色圆形(边缘端需要支持纯色圆形或使用白色圆形图片) + // 这里暂时跳过白色背景,让二维码直接显示 + + // 2. 二维码元素(圆形裁切) + PuzzleElementEntity qrcodeElement = createCircleImageElement( + "qrcode", "二维码", + qrcodeX, qrcodeY + QRCODE_OFFSET_Y, + qrcodeHeight, 10 + ); + elements.add(qrcodeElement); + dynamicData.put("qrcode", request.getQrcodeUrl()); + + // 3. 头像元素(圆形,二维码中央,可选) + if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) { + int avatarDiameter = (int) (qrcodeHeight * 0.45); + int avatarX = qrcodeX + (qrcodeWidth - avatarDiameter) / 2; + int avatarY = qrcodeY + QRCODE_OFFSET_Y + (qrcodeHeight - avatarDiameter) / 2; + + PuzzleElementEntity faceElement = createCircleImageElement( + "face", "头像", + avatarX, avatarY, + avatarDiameter, 20 + ); + elements.add(faceElement); + dynamicData.put("face", request.getFaceUrl()); + } + + // 4. 景区名文字 + int textX = qrcodeX + qrcodeWidth + QRCODE_OFFSET_X; + int textY = qrcodeY + QRCODE_OFFSET_Y + qrcodeHeight / 2 - SCENIC_FONT_SIZE; + + PuzzleElementEntity scenicTextElement = createTextElement( + "scenicLine", "景区名", + textX, textY, + imageWidth - textX - 20, SCENIC_FONT_SIZE + 10, 30, + "PingFang SC", SCENIC_FONT_SIZE, FONT_COLOR, + "NORMAL", TEXT_ALIGN_LEFT + ); + elements.add(scenicTextElement); + dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : ""); + + // 5. 日期时间文字 + int datetimeY = textY + SCENIC_FONT_SIZE + 5; + + PuzzleElementEntity datetimeTextElement = createTextElement( + "datetimeLine", "日期时间", + textX, datetimeY, + imageWidth - textX - 20, DATETIME_FONT_SIZE + 10, 30, + "PingFang SC", DATETIME_FONT_SIZE, FONT_COLOR, + "NORMAL", TEXT_ALIGN_LEFT + ); + elements.add(datetimeTextElement); + dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : ""); + + return createResult(template, elements, dynamicData); + } + + /** + * 计算二维码的X偏移位置(居中于文字和二维码整体) + */ + private int calculateQrcodeOffsetX(int imageWidth, int qrcodeWidth, WatermarkRequest request) { + // 简化处理:固定距离左边一定比例 + return (int) (imageWidth * 0.1); + } +} diff --git a/src/main/java/com/ycwl/basic/image/watermark/edge/PrinterDefaultWatermarkTemplateBuilder.java b/src/main/java/com/ycwl/basic/image/watermark/edge/PrinterDefaultWatermarkTemplateBuilder.java new file mode 100644 index 00000000..d2248511 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/watermark/edge/PrinterDefaultWatermarkTemplateBuilder.java @@ -0,0 +1,147 @@ +package com.ycwl.basic.image.watermark.edge; + +import com.ycwl.basic.puzzle.entity.PuzzleElementEntity; +import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * 打印专用水印模板构建器 + * 对应 PrinterDefaultWatermarkOperator + * + * 布局说明: + * - 白色背景 + 原图元素(COVER模式) + * - 左下角:圆形二维码(带白色圆形背景) + * - 二维码中央:圆形头像(可选) + * - 右下角:景区名 + 日期时间 两行文字(白色,右对齐) + * - 支持缩放和四边偏移 + */ +@Component +public class PrinterDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder { + + public static final String STYLE = "printer_default"; + + // 常量配置(与 PrinterDefaultWatermarkOperator 保持一致) + private static final int OFFSET_Y = 15; + private static final int QRCODE_SIZE = 150; + private static final double QRCODE_LEFT_MARGIN_RATIO = 0.05; + private static final int QRCODE_OFFSET_Y = -35; + private static final int SCENIC_FONT_SIZE = 42; + private static final int DATETIME_FONT_SIZE = 42; + private static final String FONT_COLOR = "#FFFFFF"; + private static final double TEXT_RIGHT_MARGIN_RATIO = 0.05; + + @Override + public String getStyle() { + return STYLE; + } + + @Override + public WatermarkTemplateResult build(WatermarkRequest request) { + int imageWidth = request.getImageWidth(); + int imageHeight = request.getImageHeight(); + double scale = request.getScaleValue(); + + // 应用缩放 + int scaledOffsetY = (int) (OFFSET_Y * scale); + int scaledQrcodeSize = (int) (QRCODE_SIZE * scale); + int scaledQrcodeOffsetY = (int) (QRCODE_OFFSET_Y * scale); + int scaledScenicFontSize = (int) (SCENIC_FONT_SIZE * scale); + int scaledDatetimeFontSize = (int) (DATETIME_FONT_SIZE * scale); + + // 获取偏移值 + int offsetLeft = (int) (request.getOffsetLeftValue() * scale); + int offsetRight = (int) (request.getOffsetRightValue() * scale); + int offsetBottom = (int) (request.getOffsetBottomValue() * scale); + + // 创建模板(白色背景,原图作为元素实现 COVER 模式) + PuzzleTemplateEntity template = createTemplateWithColor( + "watermark_printer_" + System.currentTimeMillis(), + imageWidth, + imageHeight, + "#FFFFFF" + ); + + List elements = newElementList(); + Map dynamicData = newDynamicData(); + + // 0. 原图元素(z-index=1,最底层,COVER模式) + PuzzleElementEntity originalImageElement = createImageElement( + "originalImage", "原图", + 0, 0, + imageWidth, imageHeight, 1, + FIT_MODE_COVER, null, null + ); + elements.add(originalImageElement); + dynamicData.put("originalImage", request.getOriginalImageUrl()); + + // 计算二维码位置 + int qrcodeWidth = scaledQrcodeSize; + int qrcodeHeight = scaledQrcodeSize; + int qrcodeX = (int) (imageWidth * QRCODE_LEFT_MARGIN_RATIO) + offsetLeft; + int qrcodeY = imageHeight - scaledOffsetY - qrcodeHeight - offsetBottom; + + // 1. 二维码元素(圆形裁切) + PuzzleElementEntity qrcodeElement = createCircleImageElement( + "qrcode", "二维码", + qrcodeX, qrcodeY + scaledQrcodeOffsetY, + qrcodeHeight, 10 + ); + elements.add(qrcodeElement); + dynamicData.put("qrcode", request.getQrcodeUrl()); + + // 2. 头像元素(圆形,二维码中央,可选) + if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) { + int avatarDiameter = (int) (qrcodeHeight * 0.45); + int avatarX = qrcodeX + (qrcodeWidth - avatarDiameter) / 2; + int avatarY = qrcodeY + scaledQrcodeOffsetY + (qrcodeHeight - avatarDiameter) / 2; + + PuzzleElementEntity faceElement = createCircleImageElement( + "face", "头像", + avatarX, avatarY, + avatarDiameter, 20 + ); + elements.add(faceElement); + dynamicData.put("face", request.getFaceUrl()); + } + + // 3. 计算文字位置(右对齐) + int textRightX = imageWidth - (int) (imageWidth * TEXT_RIGHT_MARGIN_RATIO) - offsetRight; + int textWidth = textRightX - qrcodeX - qrcodeWidth - 20; + + // 计算垂直居中 + int qrcodeTop = qrcodeY + scaledQrcodeOffsetY; + int qrcodeBottom = qrcodeTop + qrcodeHeight; + int qrcodeCenter = (qrcodeTop + qrcodeBottom) / 2; + int totalTextHeight = scaledScenicFontSize + scaledDatetimeFontSize + 10; + int textY = qrcodeCenter - totalTextHeight / 2; + + // 4. 景区名文字(右对齐) + PuzzleElementEntity scenicTextElement = createTextElement( + "scenicLine", "景区名", + textRightX - textWidth, textY, + textWidth, scaledScenicFontSize + 10, 30, + "PingFang SC", scaledScenicFontSize, FONT_COLOR, + "BOLD", TEXT_ALIGN_RIGHT + ); + elements.add(scenicTextElement); + dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : ""); + + // 5. 日期时间文字(右对齐) + int datetimeY = textY + scaledScenicFontSize + 5; + + PuzzleElementEntity datetimeTextElement = createTextElement( + "datetimeLine", "日期时间", + textRightX - textWidth, datetimeY, + textWidth, scaledDatetimeFontSize + 10, 30, + "PingFang SC", scaledDatetimeFontSize, FONT_COLOR, + "BOLD", TEXT_ALIGN_RIGHT + ); + elements.add(datetimeTextElement); + dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : ""); + + return createResult(template, elements, dynamicData); + } +} diff --git a/src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkEdgeTaskCreator.java b/src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkEdgeTaskCreator.java new file mode 100644 index 00000000..9d6fd410 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkEdgeTaskCreator.java @@ -0,0 +1,111 @@ +package com.ycwl.basic.image.watermark.edge; + +import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PostConstruct; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 水印边缘任务创建服务 + * 将水印请求转换为边缘渲染任务 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WatermarkEdgeTaskCreator { + + private final PuzzleEdgeRenderTaskService edgeRenderTaskService; + private final List builders; + + private final Map builderMap = new HashMap<>(); + + @PostConstruct + public void init() { + for (IWatermarkTemplateBuilder builder : builders) { + builderMap.put(builder.getStyle(), builder); + log.info("注册水印模板构建器: {}", builder.getStyle()); + } + } + + /** + * 创建水印渲染任务 + * + * @param style 水印风格(normal/leica/printer_default) + * @param request 水印请求参数 + * @param recordId 原始拼图记录ID(用于关联) + * @param faceId 人脸ID(可选) + * @param watermarkType 水印类型标识(如 print、free_download) + * @return 任务ID + */ + public Long createTask(String style, + WatermarkRequest request, + Long recordId, + Long faceId, + String watermarkType) { + IWatermarkTemplateBuilder builder = builderMap.get(style); + if (builder == null) { + throw new IllegalArgumentException("未知的水印风格: " + style); + } + + // 构建水印模板 + WatermarkTemplateResult result = builder.build(request); + + // 创建边缘渲染任务 + Long taskId = edgeRenderTaskService.createWatermarkRenderTask( + recordId, + faceId, + watermarkType, + result.getTemplate(), + result.getElements(), + result.getDynamicData(), + request.getOutputFormat(), + request.getOutputQuality() + ); + + log.info("创建水印边缘渲染任务: style={}, taskId={}, recordId={}, watermarkType={}", + style, taskId, recordId, watermarkType); + + return taskId; + } + + /** + * 创建水印渲染任务并等待结果 + * + * @param style 水印风格 + * @param request 水印请求参数 + * @param recordId 原始拼图记录ID + * @param faceId 人脸ID + * @param watermarkType 水印类型 + * @param timeoutMs 超时时间(毫秒) + * @return 任务结果 + */ + public PuzzleEdgeRenderTaskService.TaskWaitResult createAndWait(String style, + WatermarkRequest request, + Long recordId, + Long faceId, + String watermarkType, + long timeoutMs) { + Long taskId = createTask(style, request, recordId, faceId, watermarkType); + edgeRenderTaskService.registerWait(taskId); + return edgeRenderTaskService.waitForTask(taskId, timeoutMs); + } + + /** + * 获取支持的水印风格列表 + */ + public List getSupportedStyles() { + return List.copyOf(builderMap.keySet()); + } + + /** + * 检查是否支持指定的水印风格 + */ + public boolean isStyleSupported(String style) { + return builderMap.containsKey(style); + } +} diff --git a/src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkRequest.java b/src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkRequest.java new file mode 100644 index 00000000..1eb8c0ea --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkRequest.java @@ -0,0 +1,92 @@ +package com.ycwl.basic.image.watermark.edge; + +import lombok.Builder; +import lombok.Data; + +/** + * 水印请求参数 + * 将原有的 WatermarkInfo(基于文件)转换为边缘渲染所需的格式(基于URL) + */ +@Data +@Builder +public class WatermarkRequest { + /** + * 原图URL + */ + private String originalImageUrl; + + /** + * 原图宽度(像素) + */ + private int imageWidth; + + /** + * 原图高度(像素) + */ + private int imageHeight; + + /** + * 二维码URL + */ + private String qrcodeUrl; + + /** + * 头像URL(可选) + */ + private String faceUrl; + + /** + * 景区名称 + */ + private String scenicLine; + + /** + * 日期时间行 + */ + private String datetimeLine; + + /** + * 四边偏移(像素),正数表示向内偏移 + */ + private Integer offsetTop; + private Integer offsetBottom; + private Integer offsetLeft; + private Integer offsetRight; + + /** + * 缩放倍数,默认1.0 + */ + private Double scale; + + /** + * 输出格式:PNG / JPEG + */ + @Builder.Default + private String outputFormat = "JPEG"; + + /** + * 输出质量(0-100) + */ + @Builder.Default + private Integer outputQuality = 75; + + public double getScaleValue() { + return scale != null ? scale : 1.0; + } + + public int getOffsetTopValue() { + return offsetTop != null ? offsetTop : 0; + } + + public int getOffsetBottomValue() { + return offsetBottom != null ? offsetBottom : 0; + } + + public int getOffsetLeftValue() { + return offsetLeft != null ? offsetLeft : 0; + } + + public int getOffsetRightValue() { + return offsetRight != null ? offsetRight : 0; + } +} diff --git a/src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkTemplateResult.java b/src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkTemplateResult.java new file mode 100644 index 00000000..9e019df0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/watermark/edge/WatermarkTemplateResult.java @@ -0,0 +1,30 @@ +package com.ycwl.basic.image.watermark.edge; + +import com.ycwl.basic.puzzle.entity.PuzzleElementEntity; +import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * 水印模板构建结果 + * 包含虚拟模板、元素列表和动态数据,用于发送给边缘渲染任务 + */ +@Data +public class WatermarkTemplateResult { + /** + * 虚拟模板(运行时构造,不存储到数据库) + */ + private PuzzleTemplateEntity template; + + /** + * 元素列表(按z-index排序) + */ + private List elements; + + /** + * 动态数据(elementKey -> 实际值URL或文本) + */ + private Map dynamicData; +} diff --git a/src/main/java/com/ycwl/basic/image/watermark/edge/controller/WatermarkEdgeTestController.java b/src/main/java/com/ycwl/basic/image/watermark/edge/controller/WatermarkEdgeTestController.java new file mode 100644 index 00000000..8f9af8e9 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/watermark/edge/controller/WatermarkEdgeTestController.java @@ -0,0 +1,272 @@ +package com.ycwl.basic.image.watermark.edge.controller; + +import com.ycwl.basic.annotation.IgnoreToken; +import com.ycwl.basic.image.watermark.edge.WatermarkEdgeTaskCreator; +import com.ycwl.basic.image.watermark.edge.WatermarkRequest; +import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService; +import com.ycwl.basic.utils.ApiResponse; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 水印边缘渲染测试控制器 + * 用于测试水印边缘渲染功能 + */ +@Slf4j +@IgnoreToken +@RestController +@RequestMapping("/test/watermark/edge") +@RequiredArgsConstructor +public class WatermarkEdgeTestController { + + private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator; + private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService; + + /** + * 获取支持的水印风格列表 + */ + @GetMapping("/styles") + public ApiResponse> getSupportedStyles() { + return ApiResponse.success(watermarkEdgeTaskCreator.getSupportedStyles()); + } + + /** + * 创建水印渲染任务(异步) + * 任务创建后由边缘端拉取执行 + */ + @PostMapping("/create") + public ApiResponse createTask(@RequestBody CreateTaskRequest req) { + // 参数校验 + if (req.getStyle() == null || req.getStyle().isEmpty()) { + return ApiResponse.fail("水印风格(style)不能为空"); + } + if (!watermarkEdgeTaskCreator.isStyleSupported(req.getStyle())) { + return ApiResponse.fail("不支持的水印风格: " + req.getStyle() + + ",支持的风格: " + watermarkEdgeTaskCreator.getSupportedStyles()); + } + if (req.getOriginalImageUrl() == null || req.getOriginalImageUrl().isEmpty()) { + return ApiResponse.fail("原图URL(originalImageUrl)不能为空"); + } + if (req.getImageWidth() <= 0 || req.getImageHeight() <= 0) { + return ApiResponse.fail("图片宽高必须大于0"); + } + + // 构建请求 + WatermarkRequest watermarkRequest = WatermarkRequest.builder() + .originalImageUrl(req.getOriginalImageUrl()) + .imageWidth(req.getImageWidth()) + .imageHeight(req.getImageHeight()) + .qrcodeUrl(req.getQrcodeUrl()) + .faceUrl(req.getFaceUrl()) + .scenicLine(req.getScenicLine()) + .datetimeLine(req.getDatetimeLine()) + .offsetTop(req.getOffsetTop()) + .offsetBottom(req.getOffsetBottom()) + .offsetLeft(req.getOffsetLeft()) + .offsetRight(req.getOffsetRight()) + .scale(req.getScale()) + .outputFormat(req.getOutputFormat() != null ? req.getOutputFormat() : "JPEG") + .outputQuality(req.getOutputQuality() != null ? req.getOutputQuality() : 75) + .build(); + + // 创建任务 + Long taskId = watermarkEdgeTaskCreator.createTask( + req.getStyle(), + watermarkRequest, + req.getRecordId() != null ? req.getRecordId() : 0L, // 测试用默认值 + req.getFaceId(), + req.getWatermarkType() != null ? req.getWatermarkType() : "test" + ); + + CreateTaskResponse response = new CreateTaskResponse(); + response.setTaskId(taskId); + response.setMessage("任务已创建,等待边缘端拉取执行"); + + log.info("测试创建水印任务: style={}, taskId={}", req.getStyle(), taskId); + return ApiResponse.success(response); + } + + /** + * 创建水印渲染任务并等待结果(同步) + * 注意:此接口会阻塞直到任务完成或超时 + */ + @PostMapping("/createAndWait") + public ApiResponse createAndWait(@RequestBody CreateTaskRequest req) { + // 参数校验 + if (req.getStyle() == null || req.getStyle().isEmpty()) { + return ApiResponse.fail("水印风格(style)不能为空"); + } + if (!watermarkEdgeTaskCreator.isStyleSupported(req.getStyle())) { + return ApiResponse.fail("不支持的水印风格: " + req.getStyle() + + ",支持的风格: " + watermarkEdgeTaskCreator.getSupportedStyles()); + } + if (req.getOriginalImageUrl() == null || req.getOriginalImageUrl().isEmpty()) { + return ApiResponse.fail("原图URL(originalImageUrl)不能为空"); + } + if (req.getImageWidth() <= 0 || req.getImageHeight() <= 0) { + return ApiResponse.fail("图片宽高必须大于0"); + } + + // 构建请求 + WatermarkRequest watermarkRequest = WatermarkRequest.builder() + .originalImageUrl(req.getOriginalImageUrl()) + .imageWidth(req.getImageWidth()) + .imageHeight(req.getImageHeight()) + .qrcodeUrl(req.getQrcodeUrl()) + .faceUrl(req.getFaceUrl()) + .scenicLine(req.getScenicLine()) + .datetimeLine(req.getDatetimeLine()) + .offsetTop(req.getOffsetTop()) + .offsetBottom(req.getOffsetBottom()) + .offsetLeft(req.getOffsetLeft()) + .offsetRight(req.getOffsetRight()) + .scale(req.getScale()) + .outputFormat(req.getOutputFormat() != null ? req.getOutputFormat() : "JPEG") + .outputQuality(req.getOutputQuality() != null ? req.getOutputQuality() : 75) + .build(); + + // 超时时间,默认30秒 + long timeoutMs = req.getTimeoutMs() != null ? req.getTimeoutMs() : 30000L; + + // 先创建任务获取 taskId + Long taskId = watermarkEdgeTaskCreator.createTask( + req.getStyle(), + watermarkRequest, + req.getRecordId() != null ? req.getRecordId() : 0L, + req.getFaceId(), + req.getWatermarkType() != null ? req.getWatermarkType() : "test" + ); + + // 注册等待并等待结果 + puzzleEdgeRenderTaskService.registerWait(taskId); + PuzzleEdgeRenderTaskService.TaskWaitResult result = puzzleEdgeRenderTaskService.waitForTask(taskId, timeoutMs); + + CreateAndWaitResponse response = new CreateAndWaitResponse(); + response.setTaskId(taskId); + response.setSuccess(result.isSuccess()); + response.setImageUrl(result.getImageUrl()); + response.setErrorMessage(result.getErrorMessage()); + + log.info("测试水印任务完成: style={}, taskId={}, success={}, imageUrl={}", + req.getStyle(), taskId, result.isSuccess(), result.getImageUrl()); + + return ApiResponse.success(response); + } + + /** + * 创建任务请求 + */ + @Data + public static class CreateTaskRequest { + /** + * 水印风格:normal / leica / printer_default + */ + private String style; + + /** + * 原图URL + */ + private String originalImageUrl; + + /** + * 原图宽度 + */ + private int imageWidth; + + /** + * 原图高度 + */ + private int imageHeight; + + /** + * 二维码URL + */ + private String qrcodeUrl; + + /** + * 头像URL(可选) + */ + private String faceUrl; + + /** + * 景区名称 + */ + private String scenicLine; + + /** + * 日期时间行 + */ + private String datetimeLine; + + /** + * 四边偏移(像素) + */ + private Integer offsetTop; + private Integer offsetBottom; + private Integer offsetLeft; + private Integer offsetRight; + + /** + * 缩放倍数 + */ + private Double scale; + + /** + * 输出格式:PNG / JPEG + */ + private String outputFormat; + + /** + * 输出质量(0-100) + */ + private Integer outputQuality; + + /** + * 关联的拼图记录ID(测试用) + */ + private Long recordId; + + /** + * 人脸ID(可选) + */ + private Long faceId; + + /** + * 水印类型标识 + */ + private String watermarkType; + + /** + * 等待超时时间(毫秒),仅用于 createAndWait + */ + private Long timeoutMs; + } + + /** + * 创建任务响应 + */ + @Data + public static class CreateTaskResponse { + private Long taskId; + private String message; + } + + /** + * 创建并等待响应 + */ + @Data + public static class CreateAndWaitResponse { + private Long taskId; + private boolean success; + private String imageUrl; + private String errorMessage; + } +}