水印
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.order.entity.OrderItemImageEntity;
|
||||
import com.aida.seller.module.order.mapper.OrderItemImageMapper;
|
||||
import com.aida.seller.util.ImageWatermarkUtil;
|
||||
import com.aida.seller.util.MinioUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
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 {
|
||||
|
||||
private final MinioUtil minioUtil;
|
||||
private final ImageWatermarkUtil imageWatermarkUtil;
|
||||
private final ListingImageMapper listingImageMapper;
|
||||
private final DesignerMapper designerMapper;
|
||||
private final OrderItemImageMapper orderItemImageMapper;
|
||||
|
||||
public ListingMallServiceImpl(MinioUtil minioUtil, ListingImageMapper listingImageMapper, DesignerMapper designerMapper,
|
||||
public ListingMallServiceImpl(MinioUtil minioUtil, ImageWatermarkUtil imageWatermarkUtil, ListingImageMapper listingImageMapper, DesignerMapper designerMapper,
|
||||
OrderItemImageMapper orderItemImageMapper) {
|
||||
this.minioUtil = minioUtil;
|
||||
this.imageWatermarkUtil = imageWatermarkUtil;
|
||||
this.listingImageMapper = listingImageMapper;
|
||||
this.designerMapper = designerMapper;
|
||||
this.orderItemImageMapper = orderItemImageMapper;
|
||||
@@ -215,6 +218,15 @@ public class ListingMallServiceImpl extends ServiceImpl<ListingMallMapper, Listi
|
||||
vo.setProductCategory(entity.getProductCategory());
|
||||
vo.setAvatar(minioUtil.processMinioResource(designer != null ? designer.getAvatar() : null, CommonConstants.MINIO_PATH_TIMEOUT));
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
156
src/main/java/com/aida/seller/util/ImageWatermarkUtil.java
Normal file
156
src/main/java/com/aida/seller/util/ImageWatermarkUtil.java
Normal 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 hash,value = 带水印图片的 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,4 +29,15 @@ mybatis-plus:
|
||||
minio:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user