feat(watermark): 添加水印边缘渲染模板构建功能

- 实现抽象水印模板构建器基类提供通用构建工具方法
- 定义水印模板构建器接口规范模板构建契约
- 实现徕卡风格水印模板构建器支持底部扩展布局
- 实现普通风格水印模板构建器支持左下角布局
- 实现打印专用水印模板构建器支持缩放和偏移
- 创建水印边缘任务服务统一管理模板构建流程
- 添加水印请求参数类定义边缘渲染所需字段
- 实现水印模板构建结果类封装模板元素和动态数据
- 集成拼图边缘渲染任务服务实现异步渲染机制
This commit is contained in:
2026-01-16 14:28:02 +08:00
parent 8d5a10cce1
commit 0235d1d121
9 changed files with 1151 additions and 0 deletions

View File

@@ -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<String, Object> 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<String, Object> 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<PuzzleElementEntity> elements,
Map<String, String> dynamicData) {
WatermarkTemplateResult result = new WatermarkTemplateResult();
result.setTemplate(template);
result.setElements(elements);
result.setDynamicData(dynamicData);
return result;
}
/**
* 创建空的元素列表和动态数据
*/
protected List<PuzzleElementEntity> newElementList() {
return new ArrayList<>();
}
protected Map<String, String> newDynamicData() {
return new HashMap<>();
}
}

View File

@@ -0,0 +1,21 @@
package com.ycwl.basic.image.watermark.edge;
/**
* 水印模板构建器接口
* 将水印参数转换为拼图模板+元素的形式,用于发送给边缘渲染任务
*/
public interface IWatermarkTemplateBuilder {
/**
* 构建水印模板
*
* @param request 水印请求参数
* @return 模板构建结果
*/
WatermarkTemplateResult build(WatermarkRequest request);
/**
* 获取水印风格标识
*/
String getStyle();
}

View File

@@ -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<PuzzleElementEntity> elements = newElementList();
Map<String, String> 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);
}
}

View File

@@ -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<PuzzleElementEntity> elements = newElementList();
Map<String, String> 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);
}
}

View File

@@ -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<PuzzleElementEntity> elements = newElementList();
Map<String, String> 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);
}
}

View File

@@ -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<IWatermarkTemplateBuilder> builders;
private final Map<String, IWatermarkTemplateBuilder> 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<String> getSupportedStyles() {
return List.copyOf(builderMap.keySet());
}
/**
* 检查是否支持指定的水印风格
*/
public boolean isStyleSupported(String style) {
return builderMap.containsKey(style);
}
}

View File

@@ -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;
}
}

View File

@@ -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<PuzzleElementEntity> elements;
/**
* 动态数据(elementKey -> 实际值URL或文本)
*/
private Map<String, String> dynamicData;
}

View File

@@ -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<List<String>> getSupportedStyles() {
return ApiResponse.success(watermarkEdgeTaskCreator.getSupportedStyles());
}
/**
* 创建水印渲染任务(异步)
* 任务创建后由边缘端拉取执行
*/
@PostMapping("/create")
public ApiResponse<CreateTaskResponse> 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<CreateAndWaitResponse> 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;
}
}