feat(watermark): 添加边缘端水印处理功能

- 引入 WatermarkEdgeService 支持边缘端渲染
- 在 WatermarkConfig 中添加边缘端相关配置参数
- 在 WatermarkStage 中实现边缘端处理逻辑和降级机制
- 修改 ImageWatermarkOperatorEnum 的默认输出格式为 jpg
- 移除已废弃的 DefaultImageWatermarkOperator 类
- 更新 GoodsServiceImpl 使用边缘端处理水印
- 优化 PuzzleEdgeWorkerIpInterceptor 允许本地回环地址访问
- 修正 PrinterDefaultWatermarkTemplateBuilder 样式常量名称
This commit is contained in:
2026-01-16 17:25:19 +08:00
parent 83e47ed843
commit a5a9ff09f2
9 changed files with 460 additions and 105 deletions

View File

@@ -1,6 +1,8 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeService;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import lombok.Builder;
import lombok.Getter;
@@ -41,4 +43,28 @@ public class WatermarkConfig {
*/
@Builder.Default
private final Double scale = 1.0;
/**
* 边缘端水印服务(可选)
* 如果设置,将优先尝试使用边缘端处理
*/
private final WatermarkEdgeService edgeService;
/**
* 存储适配器(边缘端处理时需要)
* 用于上传原图和二维码到临时位置
*/
private final IStorageAdapter storageAdapter;
/**
* 是否启用边缘端处理
*/
@Builder.Default
private final boolean edgeEnabled = false;
/**
* 边缘端处理超时时间(毫秒)
*/
@Builder.Default
private final long edgeTimeoutMs = 30000L;
}

View File

@@ -3,6 +3,7 @@ package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeService;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.image.watermark.operator.IOperator;
@@ -10,6 +11,7 @@ import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
@@ -21,6 +23,7 @@ import java.util.List;
/**
* 水印处理Stage
* 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印
* 支持边缘端渲染(可选)
*/
@Slf4j
@StageConfig(
@@ -127,6 +130,19 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
File watermarkedFile = context.getTempFileManager()
.createTempFile("watermark_" + type.getType(), "." + fileExt);
// 尝试边缘端处理
if (shouldUseEdgeProcessing(type)) {
File edgeResult = tryEdgeProcessing(context, type, currentFile, watermarkedFile);
if (edgeResult != null && edgeResult.exists()) {
context.updateProcessedFile(edgeResult);
log.info("边缘端水印应用成功: type={}, size={}KB", type.getType(), edgeResult.length() / 1024);
return StageResult.success(String.format("水印(边缘端): %s (%dKB)",
type.getType(), edgeResult.length() / 1024));
}
log.warn("边缘端水印处理失败,降级到本地处理: type={}", type.getType());
}
// 本地处理(降级或直接使用)
WatermarkInfo watermarkInfo = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
IOperator operator = ImageWatermarkFactory.get(type);
@@ -143,6 +159,46 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
type.getType(), result.length() / 1024));
}
/**
* 判断是否应使用边缘端处理
*/
private boolean shouldUseEdgeProcessing(ImageWatermarkOperatorEnum type) {
if (!config.isEdgeEnabled()) {
return false;
}
WatermarkEdgeService edgeService = config.getEdgeService();
if (edgeService == null) {
return false;
}
IStorageAdapter storageAdapter = config.getStorageAdapter();
return storageAdapter != null;
}
/**
* 尝试使用边缘端处理
*
* @return 处理后的文件,失败返回 null
*/
private File tryEdgeProcessing(PhotoProcessContext context,
ImageWatermarkOperatorEnum type,
File currentFile,
File watermarkedFile) {
try {
WatermarkEdgeService edgeService = config.getEdgeService();
IStorageAdapter storageAdapter = config.getStorageAdapter();
// 构建水印信息用于边缘端处理
WatermarkInfo info = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
// 调用边缘端服务处理
return edgeService.processWatermarkFromFile(info, type, storageAdapter);
} catch (Exception e) {
log.error("边缘端水印处理异常: type={}, error={}", type.getType(), e.getMessage(), e);
return null;
}
}
/**
* 构建水印参数
*/

View File

@@ -3,7 +3,6 @@ package com.ycwl.basic.image.watermark;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.image.watermark.exception.ImageWatermarkUnsupportedException;
import com.ycwl.basic.image.watermark.operator.IOperator;
import com.ycwl.basic.image.watermark.operator.DefaultImageWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.LeicaWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.NormalWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.PrinterDefaultWatermarkOperator;
@@ -18,11 +17,9 @@ public class ImageWatermarkFactory {
}
public static IOperator get(ImageWatermarkOperatorEnum type) {
return switch (type) {
case WATERMARK -> new DefaultImageWatermarkOperator();
case NORMAL -> new NormalWatermarkOperator();
case LEICA -> new LeicaWatermarkOperator();
case PRINTER_DEFAULT -> new PrinterDefaultWatermarkOperator();
default -> throw new ImageWatermarkUnsupportedException("不支持的类型" + type.name());
};
}
}

View File

@@ -21,7 +21,7 @@ import java.util.Map;
@Component
public class PrinterDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
public static final String STYLE = "printer_default";
public static final String STYLE = "pDefault";
// 常量配置(与 PrinterDefaultWatermarkOperator 保持一致)
private static final int OFFSET_Y = 15;

View File

@@ -0,0 +1,318 @@
package com.ycwl.basic.image.watermark.edge;
import cn.hutool.core.date.DateUtil;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.constant.StorageConstant;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.storage.enums.StorageAcl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Date;
import java.util.UUID;
/**
* 水印边缘端处理服务
* 将原有的 IOperator 本地处理迁移到边缘端渲染
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WatermarkEdgeService {
private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator;
/**
* 默认等待超时时间(毫秒)
*/
private static final long DEFAULT_TIMEOUT_MS = 30_000L;
/**
* 使用边缘端处理水印(适用于 GoodsServiceImpl 场景)
* 直接传入 URL,不需要本地文件
*
* @param type 水印类型
* @param originalUrl 原图URL
* @param qrcodeUrl 二维码URL
* @param faceUrl 头像URL(可选)
* @param scenicLine 景区名称
* @param datetime 日期时间
* @param dtFormat 日期格式
* @param sourceId 关联的sourceId(用于记录追踪)
* @param faceId 人脸ID(可选)
* @return 带水印的图片URL,处理失败返回null
*/
public String processWatermark(ImageWatermarkOperatorEnum type,
String originalUrl,
String qrcodeUrl,
String faceUrl,
String scenicLine,
Date datetime,
String dtFormat,
Long sourceId,
Long faceId) {
return processWatermark(type, originalUrl, qrcodeUrl, faceUrl, scenicLine, datetime, dtFormat,
sourceId, faceId, null, null, null, null, null);
}
/**
* 使用边缘端处理水印(完整参数版本)
*
* @param type 水印类型
* @param originalUrl 原图URL
* @param qrcodeUrl 二维码URL
* @param faceUrl 头像URL(可选)
* @param scenicLine 景区名称
* @param datetime 日期时间
* @param dtFormat 日期格式
* @param sourceId 关联的sourceId(用于记录追踪)
* @param faceId 人脸ID(可选)
* @param scale 缩放倍数(可选)
* @param offsetLeft 左偏移(可选)
* @param offsetRight 右偏移(可选)
* @param offsetTop 上偏移(可选)
* @param offsetBottom 下偏移(可选)
* @return 带水印的图片URL,处理失败返回null
*/
public String processWatermark(ImageWatermarkOperatorEnum type,
String originalUrl,
String qrcodeUrl,
String faceUrl,
String scenicLine,
Date datetime,
String dtFormat,
Long sourceId,
Long faceId,
Double scale,
Integer offsetLeft,
Integer offsetRight,
Integer offsetTop,
Integer offsetBottom) {
// 将 ImageWatermarkOperatorEnum 映射到边缘端风格
String style = mapTypeToStyle(type);
// 检查边缘端是否支持该风格
if (!watermarkEdgeTaskCreator.isStyleSupported(style)) {
log.warn("边缘端不支持水印风格: {}", style);
return null;
}
try {
// 获取图片尺寸
int[] dimensions = getImageDimensions(originalUrl);
if (dimensions == null) {
log.error("无法获取图片尺寸: {}", originalUrl);
return null;
}
// 构建日期时间行
String datetimeLine = datetime != null && dtFormat != null
? DateUtil.format(datetime, dtFormat)
: null;
// 构建水印请求
WatermarkRequest request = WatermarkRequest.builder()
.originalImageUrl(originalUrl)
.imageWidth(dimensions[0])
.imageHeight(dimensions[1])
.qrcodeUrl(qrcodeUrl)
.faceUrl(faceUrl)
.scenicLine(scenicLine)
.datetimeLine(datetimeLine)
.scale(scale)
.offsetLeft(offsetLeft)
.offsetRight(offsetRight)
.offsetTop(offsetTop)
.offsetBottom(offsetBottom)
.outputFormat(type.getPreferFileType().equalsIgnoreCase("png") ? "PNG" : "JPEG")
.outputQuality(90)
.build();
// 创建边缘任务并等待结果
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
style,
request,
sourceId, // recordId
faceId,
type.getType(), // watermarkType
DEFAULT_TIMEOUT_MS
);
if (result.isSuccess()) {
log.info("边缘端水印处理成功: sourceId={}, type={}, url={}", sourceId, type, result.getImageUrl());
return result.getImageUrl();
} else {
log.error("边缘端水印处理失败: sourceId={}, type={}, error={}", sourceId, type, result.getErrorMessage());
return null;
}
} catch (Exception e) {
log.error("边缘端水印处理异常: sourceId={}, type={}", sourceId, type, e);
return null;
}
}
/**
* 使用边缘端处理水印(适用于 WatermarkStage / Pipeline 场景)
* 从本地文件处理,需要先上传原图和二维码
*
* @param info 水印信息(包含本地文件)
* @param type 水印类型
* @param adapter 存储适配器
* @return 处理后的本地文件,失败返回null
*/
public File processWatermarkFromFile(WatermarkInfo info,
ImageWatermarkOperatorEnum type,
IStorageAdapter adapter) {
// 将 ImageWatermarkOperatorEnum 映射到边缘端风格
String style = mapTypeToStyle(type);
// 检查边缘端是否支持该风格
if (!watermarkEdgeTaskCreator.isStyleSupported(style)) {
log.warn("边缘端不支持水印风格: {}", style);
return null;
}
String uploadedOriginalUrl = null;
String uploadedQrcodeUrl = null;
String uploadedFaceUrl = null;
try {
// 1. 获取图片尺寸
BufferedImage originalImage = ImageIO.read(info.getOriginalFile());
if (originalImage == null) {
log.error("无法读取原图文件: {}", info.getOriginalFile());
return null;
}
int imageWidth = originalImage.getWidth();
int imageHeight = originalImage.getHeight();
originalImage.flush();
// 2. 上传原图到临时位置
String originalFileName = "temp_watermark_" + UUID.randomUUID() + ".jpg";
uploadedOriginalUrl = adapter.uploadFile(null, info.getOriginalFile(),
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", originalFileName);
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", originalFileName);
// 3. 上传二维码(如果有)
if (info.getQrcodeFile() != null && info.getQrcodeFile().exists()) {
String qrcodeFileName = "temp_qrcode_" + UUID.randomUUID() + ".jpg";
uploadedQrcodeUrl = adapter.uploadFile(null, info.getQrcodeFile(),
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", qrcodeFileName);
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", qrcodeFileName);
}
// 4. 上传头像(如果有)
if (info.getFaceFile() != null && info.getFaceFile().exists()) {
String faceFileName = "temp_face_" + UUID.randomUUID() + ".jpg";
uploadedFaceUrl = adapter.uploadFile(null, info.getFaceFile(),
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", faceFileName);
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", faceFileName);
}
// 5. 构建水印请求
WatermarkRequest request = WatermarkRequest.builder()
.originalImageUrl(uploadedOriginalUrl)
.imageWidth(imageWidth)
.imageHeight(imageHeight)
.qrcodeUrl(uploadedQrcodeUrl)
.faceUrl(uploadedFaceUrl)
.scenicLine(info.getScenicLine())
.datetimeLine(info.getDatetimeLine())
.scale(info.getScale())
.offsetLeft(info.getOffsetLeft())
.offsetRight(info.getOffsetRight())
.offsetTop(info.getOffsetTop())
.offsetBottom(info.getOffsetBottom())
.outputFormat(type.getPreferFileType().equalsIgnoreCase("png") ? "PNG" : "JPEG")
.outputQuality(90)
.build();
// 6. 创建边缘任务并等待结果
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
style,
request,
null, // recordId
null, // faceId
type.getType(), // watermarkType
DEFAULT_TIMEOUT_MS
);
if (!result.isSuccess()) {
log.error("边缘端水印处理失败: error={}", result.getErrorMessage());
return null;
}
// 7. 下载结果到目标文件
String resultUrl = result.getImageUrl();
File outputFile = info.getWatermarkedFile();
downloadFile(resultUrl, outputFile);
log.info("边缘端水印处理成功: type={}, outputFile={}", type, outputFile);
return outputFile;
} catch (Exception e) {
log.error("边缘端水印处理异常: type={}", type, e);
return null;
}
}
/**
* 将 ImageWatermarkOperatorEnum 映射到边缘端风格
*/
private String mapTypeToStyle(ImageWatermarkOperatorEnum type) {
if (type == null) {
return null;
}
return switch (type) {
case NORMAL -> NormalWatermarkTemplateBuilder.STYLE;
case LEICA -> LeicaWatermarkTemplateBuilder.STYLE;
case PRINTER_DEFAULT -> PrinterDefaultWatermarkTemplateBuilder.STYLE;
};
}
/**
* 获取图片尺寸
*
* @param imageUrl 图片URL
* @return [width, height],失败返回null
*/
private int[] getImageDimensions(String imageUrl) {
try {
// 替换内网域名
String url = imageUrl.replace("oss.zhentuai.com",
"frametour-assets.oss-cn-shanghai-internal.aliyuncs.com");
BufferedImage image = ImageIO.read(new URL(url));
if (image == null) {
return null;
}
int[] dimensions = new int[]{image.getWidth(), image.getHeight()};
image.flush();
return dimensions;
} catch (IOException e) {
log.error("获取图片尺寸失败: {}", imageUrl, e);
return null;
}
}
/**
* 下载文件
*/
private void downloadFile(String url, File targetFile) throws IOException {
// 替换内网域名
String downloadUrl = url.replace("oss.zhentuai.com",
"frametour-assets.oss-cn-shanghai-internal.aliyuncs.com");
HttpUtil.downloadFile(downloadUrl, targetFile);
}
}

View File

@@ -4,10 +4,9 @@ import lombok.Getter;
@Getter
public enum ImageWatermarkOperatorEnum {
WATERMARK("defW", "jpg"),
LEICA("leica", "png"),
NORMAL("normal", "png"),
PRINTER_DEFAULT("pDefault", "png");
LEICA("leica", "jpg"),
NORMAL("normal", "jpg"),
PRINTER_DEFAULT("pDefault", "jpg");
private final String type;
private final String preferFileType;

View File

@@ -1,76 +0,0 @@
package com.ycwl.basic.image.watermark.operator;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.exception.ImageWatermarkException;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@Slf4j
public class DefaultImageWatermarkOperator implements IOperator {
@Override
public File process(WatermarkInfo info) throws ImageWatermarkException {
BufferedImage baseImage;
BufferedImage watermarkImage;
InputStream logoInputStream = getClass().getResourceAsStream("/watermark.png");
if (logoInputStream == null) {
throw new ImageWatermarkException("无法找到 watermark.png 资源文件");
}
try {
baseImage = ImageIO.read(info.getOriginalFile());
watermarkImage = ImageIO.read(logoInputStream);
} catch (IOException e) {
throw new ImageWatermarkException("图片打开失败");
}
// 新图像画布
BufferedImage newImage = new BufferedImage(baseImage.getWidth(), baseImage.getHeight(), BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = newImage.createGraphics();
g2d.drawImage(baseImage, 0, 0, null);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.5f));
g2d.drawImage(watermarkImage, 0, 0, baseImage.getWidth(), baseImage.getHeight(), null);
String fileName = info.getWatermarkedFile().getName();
String formatName = "jpg"; // 默认格式为 jpg
if (fileName.endsWith(".png")) {
formatName = "png";
} else if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) {
formatName = "jpg";
}
ImageWriter writer = ImageIO.getImageWritersByFormatName(formatName).next();
ImageOutputStream ios;
try {
ios = ImageIO.createImageOutputStream(info.getWatermarkedFile());
} catch (IOException e) {
throw new ImageWatermarkException("图片保存失败,目标文件无法写入");
}
writer.setOutput(ios);
try {
// 使用 ImageWriter 设置写入质量
ImageWriteParam writeParam = writer.getDefaultWriteParam();
if (writeParam.canWriteCompressed()) {
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
writeParam.setCompressionQuality(0.8f); // 设置写入质量为 80%
}
writer.write(null, new javax.imageio.IIOImage(newImage, null, null), writeParam);
} catch (IOException e) {
throw new ImageWatermarkException("图片保存失败");
}
finally {
g2d.dispose();
try {
ios.close();
} catch (IOException ignore) {
}
writer.dispose();
}
return info.getWatermarkedFile();
}
}