This commit is contained in:
litianxiang
2026-06-01 13:30:24 +08:00
parent db75948bd7
commit 3ddf4051e3
4 changed files with 214 additions and 1 deletions

View File

@@ -0,0 +1,34 @@
package com.aida.seller.common.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "watermark.apparel")
public class WatermarkProperties {
private String text = "AiDA";
private float fontSizeRatio = 0.03f;
private String color = "255,255,255,60";
private int rotationDegrees = -30;
private float spacingRatio = 1.5f;
private int ttlDays = 30;
private String bucketName = "aida-user";
public int[] getColorComponents() {
String[] parts = color.split(",");
int[] rgba = new int[4];
for (int i = 0; i < parts.length; i++) {
rgba[i] = Integer.parseInt(parts[i].trim());
}
return rgba;
}
}

View File

@@ -15,6 +15,7 @@ import com.aida.seller.module.listing.vo.ListingDetailVO;
import com.aida.seller.module.listing.vo.ListingMallVO; import com.aida.seller.module.listing.vo.ListingMallVO;
import com.aida.seller.module.order.entity.OrderItemImageEntity; import com.aida.seller.module.order.entity.OrderItemImageEntity;
import com.aida.seller.module.order.mapper.OrderItemImageMapper; import com.aida.seller.module.order.mapper.OrderItemImageMapper;
import com.aida.seller.util.ImageWatermarkUtil;
import com.aida.seller.util.MinioUtil; import com.aida.seller.util.MinioUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -38,13 +39,15 @@ import com.aida.seller.module.listing.vo.ListingMallVO;
public class ListingMallServiceImpl extends ServiceImpl<ListingMallMapper, ListingEntity> implements com.aida.seller.module.listing.service.ListingMallService { public class ListingMallServiceImpl extends ServiceImpl<ListingMallMapper, ListingEntity> implements com.aida.seller.module.listing.service.ListingMallService {
private final MinioUtil minioUtil; private final MinioUtil minioUtil;
private final ImageWatermarkUtil imageWatermarkUtil;
private final ListingImageMapper listingImageMapper; private final ListingImageMapper listingImageMapper;
private final DesignerMapper designerMapper; private final DesignerMapper designerMapper;
private final OrderItemImageMapper orderItemImageMapper; private final OrderItemImageMapper orderItemImageMapper;
public ListingMallServiceImpl(MinioUtil minioUtil, ListingImageMapper listingImageMapper, DesignerMapper designerMapper, public ListingMallServiceImpl(MinioUtil minioUtil, ImageWatermarkUtil imageWatermarkUtil, ListingImageMapper listingImageMapper, DesignerMapper designerMapper,
OrderItemImageMapper orderItemImageMapper) { OrderItemImageMapper orderItemImageMapper) {
this.minioUtil = minioUtil; this.minioUtil = minioUtil;
this.imageWatermarkUtil = imageWatermarkUtil;
this.listingImageMapper = listingImageMapper; this.listingImageMapper = listingImageMapper;
this.designerMapper = designerMapper; this.designerMapper = designerMapper;
this.orderItemImageMapper = orderItemImageMapper; this.orderItemImageMapper = orderItemImageMapper;
@@ -215,6 +218,15 @@ public class ListingMallServiceImpl extends ServiceImpl<ListingMallMapper, Listi
vo.setProductCategory(entity.getProductCategory()); vo.setProductCategory(entity.getProductCategory());
vo.setAvatar(minioUtil.processMinioResource(designer != null ? designer.getAvatar() : null, CommonConstants.MINIO_PATH_TIMEOUT)); vo.setAvatar(minioUtil.processMinioResource(designer != null ? designer.getAvatar() : null, CommonConstants.MINIO_PATH_TIMEOUT));
vo.setSellerId(entity.getSellerId()); vo.setSellerId(entity.getSellerId());
List<String> apparelUrls = imageMap.get("apparel");
if (!CollectionUtils.isEmpty(apparelUrls)) {
List<String> watermarkedUrls = apparelUrls.parallelStream()
.map(imageWatermarkUtil::applyWatermark)
.toList();
imageMap.put("apparel", watermarkedUrls);
}
return vo; return vo;
} }

View File

@@ -0,0 +1,156 @@
package com.aida.seller.util;
import com.aida.seller.common.config.WatermarkProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Component
@RequiredArgsConstructor
public class ImageWatermarkUtil {
private final WatermarkProperties watermarkProperties;
private final MinioUtil minioUtil;
private static final String WATERMARK_CACHE_PREFIX = "watermark:";
private static final int PRESIGNED_URL_EXPIRE_SECONDS = 7 * 24 * 60 * 60;
/**
* 全局水印结果缓存key = 原图 logical path hashvalue = 带水印图片的 logical path
*/
private final Map<String, String> watermarkCache = new ConcurrentHashMap<>();
/**
* 对指定 MinIO 资源添加平铺文字水印,返回带水印图片的 presigned URL。
* 相同原图结果会被缓存 30 天。
*/
public String applyWatermark(String minioResource) {
try {
String logicalPath = minioUtil.isPresignedUrl(minioResource)
? minioUtil.getLogicalPathFromPresignedUrl(minioResource)
: minioResource.trim();
String cacheKey = String.valueOf(logicalPath.hashCode());
String cachedPath = watermarkCache.get(cacheKey);
if (cachedPath != null) {
return minioUtil.getImageUrl(cachedPath, PRESIGNED_URL_EXPIRE_SECONDS);
}
try (InputStream originalStream = minioUtil.downloadFile(logicalPath);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] watermarkedBytes = addTextWatermark(originalStream,
watermarkProperties.getText(),
detectContentType(logicalPath));
String newPath = uploadWatermarkedImage(watermarkedBytes, logicalPath);
watermarkCache.put(cacheKey, newPath);
return minioUtil.getImageUrl(newPath, PRESIGNED_URL_EXPIRE_SECONDS);
}
} catch (Exception e) {
log.error("添加水印失败 resource={}, error={}", minioResource, e.getMessage(), e);
return minioResource;
}
}
/**
* 向图片流添加平铺文字水印,返回处理后的字节数组。
*/
public byte[] addTextWatermark(InputStream imageStream, String text, String contentType) throws Exception {
BufferedImage original = ImageIO.read(imageStream);
if (original == null) {
throw new IllegalArgumentException("无法读取图片格式");
}
int width = original.getWidth();
int height = original.getHeight();
BufferedImage output = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = output.createGraphics();
try {
g2d.drawImage(original, 0, 0, width, height, null);
int baseFontSize = Math.max(12, (int) (Math.min(width, height) * watermarkProperties.getFontSizeRatio()));
Font font = new Font("Arial", Font.BOLD, baseFontSize);
g2d.setFont(font);
int[] rgba = watermarkProperties.getColorComponents();
g2d.setColor(new Color(rgba[0], rgba[1], rgba[2], rgba.length > 3 ? rgba[3] : 128));
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
double radians = Math.toRadians(watermarkProperties.getRotationDegrees());
int stepX = (int) (baseFontSize * watermarkProperties.getSpacingRatio());
int stepY = (int) (baseFontSize * watermarkProperties.getSpacingRatio());
AffineTransform rotateTransform = new AffineTransform();
rotateTransform.translate(width / 2.0, height / 2.0);
rotateTransform.rotate(radians);
rotateTransform.translate(-width / 2.0, -height / 2.0);
g2d.setTransform(rotateTransform);
for (int y = -height; y < height * 2; y += stepY) {
for (int x = -width; x < width * 2; x += stepX) {
g2d.drawString(text, x, y);
}
}
} finally {
g2d.dispose();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
String formatName = contentType != null && contentType.contains("png") ? "PNG" : "JPEG";
ImageIO.write(output, formatName, baos);
return baos.toByteArray();
}
private String uploadWatermarkedImage(byte[] watermarkedBytes, String originalPath) {
int lastSlash = originalPath.lastIndexOf('/');
String basePath = lastSlash > 0 ? originalPath.substring(0, lastSlash) : "";
String fileName = lastSlash >= 0 ? originalPath.substring(lastSlash + 1) : originalPath;
String nameWithoutExt = fileName.contains(".")
? fileName.substring(0, fileName.lastIndexOf('.'))
: fileName;
String ext = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.')) : ".jpg";
String watermarkedPath = (basePath.isEmpty() ? "" : basePath + "/") + "watermarked/" + nameWithoutExt + "_wm" + ext;
String contentType = ext.contains("png") ? "image/png" : "image/jpeg";
return minioUtil.uploadBytes(watermarkedBytes, watermarkedPath, contentType,
watermarkProperties.getBucketName() != null ? watermarkProperties.getBucketName() : "aida-user");
}
private String detectContentType(String path) {
String lower = path.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
return "image/jpeg";
}
/**
* 清理指定原图的水印缓存(供外部在原图更新时调用)
*/
public void evictCache(String minioResource) {
try {
String logicalPath = minioUtil.isPresignedUrl(minioResource)
? minioUtil.getLogicalPathFromPresignedUrl(minioResource)
: minioResource.trim();
watermarkCache.remove(String.valueOf(logicalPath.hashCode()));
} catch (Exception e) {
log.warn("清理水印缓存失败 resource={}", minioResource, e);
}
}
}

View File

@@ -29,4 +29,15 @@ mybatis-plus:
minio: minio:
default-bucket: aida-user default-bucket: aida-user
# ---------- 水印配置apparel 成衣图平铺文字水印) ----------
watermark:
apparel:
text: "AiDA"
font-size-ratio: 0.05
color: "80,80,80,180"
rotation-degrees: -30
spacing-ratio: 3.0
ttl-days: 30
bucket-name: "aida-user"
logging: logging: