diff --git a/src/main/java/com/aida/seller/common/config/WatermarkProperties.java b/src/main/java/com/aida/seller/common/config/WatermarkProperties.java new file mode 100644 index 0000000..04d97f1 --- /dev/null +++ b/src/main/java/com/aida/seller/common/config/WatermarkProperties.java @@ -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; + } +} diff --git a/src/main/java/com/aida/seller/module/listing/service/impl/ListingMallServiceImpl.java b/src/main/java/com/aida/seller/module/listing/service/impl/ListingMallServiceImpl.java index 9a63984..e9fa2d9 100644 --- a/src/main/java/com/aida/seller/module/listing/service/impl/ListingMallServiceImpl.java +++ b/src/main/java/com/aida/seller/module/listing/service/impl/ListingMallServiceImpl.java @@ -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 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 apparelUrls = imageMap.get("apparel"); + if (!CollectionUtils.isEmpty(apparelUrls)) { + List watermarkedUrls = apparelUrls.parallelStream() + .map(imageWatermarkUtil::applyWatermark) + .toList(); + imageMap.put("apparel", watermarkedUrls); + } + return vo; } diff --git a/src/main/java/com/aida/seller/util/ImageWatermarkUtil.java b/src/main/java/com/aida/seller/util/ImageWatermarkUtil.java new file mode 100644 index 0000000..f370e56 --- /dev/null +++ b/src/main/java/com/aida/seller/util/ImageWatermarkUtil.java @@ -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 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); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b341ea7..e157c97 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: