优化水印

This commit is contained in:
litianxiang
2026-06-02 15:01:56 +08:00
parent 5f13ced8cd
commit a5ab27dcbb
8 changed files with 193 additions and 46 deletions

View File

@@ -0,0 +1,45 @@
package com.aida.seller.module.listing.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 商品图片水印实体
*/
@Data
@TableName("seller_listing_watermark_image")
public class ListingWatermarkImageEntity implements Serializable {
private static final long serialVersionUID = 1L;
/** 主键ID */
@TableId(type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
/** 商品ID */
@JsonSerialize(using = ToStringSerializer.class)
private Long listingId;
/** 图片类别: cover/main_product/product/sketch/apparel */
private String category;
/** 原图 logical path */
private String originalUrl;
/** 加水印图的 logical path */
private String watermarkedUrl;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 是否删除0-否1-是 */
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,19 @@
package com.aida.seller.module.listing.mapper;
import com.aida.seller.module.listing.entity.ListingWatermarkImageEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 商品图片水印 Mapper
*/
@Mapper
public interface ListingWatermarkImageMapper extends BaseMapper<ListingWatermarkImageEntity> {
int deleteByListingId(@Param("listingId") Long listingId);
List<ListingWatermarkImageEntity> selectByListingId(@Param("listingId") Long listingId);
}

View File

@@ -6,12 +6,15 @@ import com.aida.seller.common.exception.BusinessException;
import com.aida.seller.module.listing.dto.*;
import com.aida.seller.module.listing.entity.ListingEntity;
import com.aida.seller.module.listing.entity.ListingImageEntity;
import com.aida.seller.module.listing.entity.ListingWatermarkImageEntity;
import com.aida.seller.module.listing.enums.ImageCategoryEnum;
import com.aida.seller.module.listing.enums.ListingStatusEnum;
import com.aida.seller.module.listing.enums.DesignForEnum;
import com.aida.seller.module.listing.enums.ProductCategoryEnum;
import com.aida.seller.module.listing.mapper.ListingImageMapper;
import com.aida.seller.module.listing.mapper.ListingMapper;
import com.aida.seller.module.listing.mapper.ListingWatermarkImageMapper;
import com.aida.seller.util.ImageWatermarkUtil;
import com.aida.seller.module.listing.vo.ListingPageVO;
import com.aida.seller.module.order.mapper.OrderItemMapper;
import com.aida.seller.module.order.entity.OrderItemEntity;
@@ -44,9 +47,11 @@ import java.util.stream.Collectors;
public class ListingServiceImpl extends ServiceImpl<ListingMapper, ListingEntity> implements ListingService {
private final ListingImageMapper listingImageMapper;
private final ListingWatermarkImageMapper listingWatermarkImageMapper;
private final OrderItemMapper orderItemMapper;
private final RedisTemplate<String, Object> redisTemplate;
private final MinioUtil minioUtil;
private final ImageWatermarkUtil imageWatermarkUtil;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -68,6 +73,7 @@ public class ListingServiceImpl extends ServiceImpl<ListingMapper, ListingEntity
}
Long listingId;
Map<String, String> oldWatermarks = Map.of();
if (dto.getId() == null) {
entity.setStatus(dto.getStatus());
this.save(entity);
@@ -86,6 +92,15 @@ public class ListingServiceImpl extends ServiceImpl<ListingMapper, ListingEntity
listingImageMapper.delete(new LambdaQueryWrapper<ListingImageEntity>()
.eq(ListingImageEntity::getListingId, dto.getId()));
listingId = dto.getId();
if (Objects.equals(dto.getStatus(), 1)) {
List<ListingWatermarkImageEntity> oldWmList = listingWatermarkImageMapper.selectByListingId(listingId);
oldWatermarks = oldWmList.stream()
.collect(Collectors.toMap(ListingWatermarkImageEntity::getOriginalUrl,
ListingWatermarkImageEntity::getWatermarkedUrl,
(a, b) -> a));
listingWatermarkImageMapper.deleteByListingId(listingId);
}
}
if (!CollectionUtils.isEmpty(dto.getImages())) {
@@ -98,6 +113,10 @@ public class ListingServiceImpl extends ServiceImpl<ListingMapper, ListingEntity
update.setCover(minioUtil.convertToLogicalPath(cover));
this.updateById(update);
}
if (Objects.equals(dto.getStatus(), 1)) {
generateWatermarks(listingId, oldWatermarks);
}
}
}
@@ -259,6 +278,45 @@ public class ListingServiceImpl extends ServiceImpl<ListingMapper, ListingEntity
return null;
}
private void generateWatermarks(Long listingId, Map<String, String> oldWatermarks) {
Set<String> watermarkCategories = Set.of(
ImageCategoryEnum.COVER.getCode(),
ImageCategoryEnum.MAIN_PRODUCT.getCode(),
ImageCategoryEnum.PRODUCT.getCode(),
ImageCategoryEnum.SKETCH.getCode(),
ImageCategoryEnum.APPAREL.getCode());
List<ListingImageEntity> images = listingImageMapper.selectList(
new LambdaQueryWrapper<ListingImageEntity>()
.eq(ListingImageEntity::getListingId, listingId)
.in(ListingImageEntity::getCategory, watermarkCategories));
for (ListingImageEntity img : images) {
String originalUrl = img.getImageUrl();
String watermarkedUrl;
String existingWm = oldWatermarks.get(originalUrl);
if (existingWm != null) {
watermarkedUrl = existingWm;
} else {
watermarkedUrl = imageWatermarkUtil.applyWatermark(originalUrl);
watermarkedUrl = minioUtil.convertToLogicalPath(watermarkedUrl);
}
ListingWatermarkImageEntity watermark = new ListingWatermarkImageEntity();
watermark.setListingId(listingId);
watermark.setCategory(img.getCategory());
watermark.setOriginalUrl(originalUrl);
watermark.setWatermarkedUrl(watermarkedUrl);
try {
listingWatermarkImageMapper.insert(watermark);
} catch (Exception e) {
// 唯一索引冲突,忽略
}
}
}
@Override
public void setPopupReminder(Long sellerId) {

View File

@@ -8,17 +8,18 @@ import com.aida.seller.module.designer.mapper.DesignerMapper;
import com.aida.seller.module.listing.dto.ListingMallQueryDTO;
import com.aida.seller.module.listing.entity.ListingEntity;
import com.aida.seller.module.listing.entity.ListingImageEntity;
import com.aida.seller.module.listing.entity.ListingWatermarkImageEntity;
import com.aida.seller.module.listing.enums.DesignForEnum;
import com.aida.seller.module.listing.enums.ImageCategoryEnum;
import com.aida.seller.module.listing.mapper.ListingImageMapper;
import com.aida.seller.module.listing.mapper.ListingMallMapper;
import com.aida.seller.module.listing.mapper.ListingWatermarkImageMapper;
import com.aida.seller.module.listing.vo.ListingDetailVO;
import com.aida.seller.module.listing.vo.ListingMallVO;
import com.aida.seller.module.order.entity.OrderItemEntity;
import com.aida.seller.module.order.entity.OrderItemImageEntity;
import com.aida.seller.module.order.mapper.OrderItemImageMapper;
import com.aida.seller.module.order.mapper.OrderItemMapper;
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;
@@ -44,17 +45,17 @@ 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 ListingWatermarkImageMapper listingWatermarkImageMapper;
private final DesignerMapper designerMapper;
private final OrderItemImageMapper orderItemImageMapper;
private final OrderItemMapper orderItemMapper;
public ListingMallServiceImpl(MinioUtil minioUtil, ImageWatermarkUtil imageWatermarkUtil, ListingImageMapper listingImageMapper, DesignerMapper designerMapper,
public ListingMallServiceImpl(MinioUtil minioUtil, ListingImageMapper listingImageMapper, ListingWatermarkImageMapper listingWatermarkImageMapper, DesignerMapper designerMapper,
OrderItemImageMapper orderItemImageMapper, OrderItemMapper orderItemMapper) {
this.minioUtil = minioUtil;
this.imageWatermarkUtil = imageWatermarkUtil;
this.listingImageMapper = listingImageMapper;
this.listingWatermarkImageMapper = listingWatermarkImageMapper;
this.designerMapper = designerMapper;
this.orderItemImageMapper = orderItemImageMapper;
this.orderItemMapper = orderItemMapper;
@@ -252,6 +253,40 @@ public class ListingMallServiceImpl extends ServiceImpl<ListingMallMapper, Listi
}
}
// 步骤3从数据库读取预生成的水印记录将原图 URL 替换为水印图 URL
List<ListingWatermarkImageEntity> watermarks = listingWatermarkImageMapper.selectByListingId(id);
if (!watermarks.isEmpty()) {
// 以 originalUrl 为 key水印图 logical path 为 value构建查询 map
Map<String, String> watermarkMap = watermarks.stream()
.collect(Collectors.toMap(
ListingWatermarkImageEntity::getOriginalUrl,
ListingWatermarkImageEntity::getWatermarkedUrl,
(a, b) -> a
));
for (Map.Entry<String, List<String>> entry : imageMap.entrySet()) {
List<String> urls = entry.getValue();
if (urls == null || urls.isEmpty()) {
continue;
}
// 遍历该类别的所有图片 URL尝试从 watermarkMap 中找到对应的水印图
// 命中则替换为水印图的 presigned URL未命中则保留原 URL说明该图未生成水印或不在水印表
List<String> watermarkedUrls = urls.stream()
.map(url -> {
try {
String logicalPath = minioUtil.convertToLogicalPath(url);
String wmUrl = watermarkMap.get(logicalPath);
if (wmUrl != null) {
return minioUtil.getImageUrl(wmUrl, CommonConstants.MINIO_PATH_TIMEOUT);
}
} catch (Exception ignored) {
}
return url;
})
.toList();
imageMap.put(entry.getKey(), watermarkedUrls);
}
}
DesignerEntity designer = designerMapper.selectOne(
new LambdaQueryWrapper<DesignerEntity>()
.eq(DesignerEntity::getUserId, entity.getSellerId())
@@ -292,14 +327,6 @@ public class ListingMallServiceImpl extends ServiceImpl<ListingMallMapper, Listi
}
}
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;
}

View File

@@ -11,8 +11,6 @@ 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
@@ -22,17 +20,10 @@ 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 天。
* 对指定 MinIO 资源添加平铺文字水印,返回带水印图片的 presigned URL有效期 7 天)。
*/
public String applyWatermark(String minioResource) {
try {
@@ -40,13 +31,6 @@ public class ImageWatermarkUtil {
? 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()) {
@@ -55,8 +39,6 @@ public class ImageWatermarkUtil {
detectContentType(logicalPath));
String newPath = uploadWatermarkedImage(watermarkedBytes, logicalPath);
watermarkCache.put(cacheKey, newPath);
return minioUtil.getImageUrl(newPath, PRESIGNED_URL_EXPIRE_SECONDS);
}
} catch (Exception e) {
@@ -141,18 +123,4 @@ public class ImageWatermarkUtil {
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

@@ -112,3 +112,17 @@ CREATE TABLE seller_order_item_image (
deleted INT(1) DEFAULT 0 COMMENT '是否删除0-否1-是',
INDEX idx_listing_buyer (listing_id, buyer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单商品图片快照表';
-- 商品图片水印表
CREATE TABLE seller_listing_watermark_image (
id BIGINT PRIMARY KEY COMMENT '主键ID',
listing_id BIGINT NOT NULL COMMENT '商品ID',
category VARCHAR(50) NOT NULL COMMENT '图片类别: cover/main_product/product/sketch/apparel',
original_url VARCHAR(500) NOT NULL COMMENT '原图 logical path',
watermarked_url VARCHAR(500) NOT NULL COMMENT '加水印图的 logical path',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
deleted INT(1) DEFAULT 0 COMMENT '是否删除0-否1-是',
UNIQUE KEY uk_listing_category (listing_id, category, original_url),
INDEX idx_listing_id (listing_id),
INDEX idx_deleted (deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品图片水印表';

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.aida.seller.module.listing.mapper.ListingWatermarkImageMapper">
<delete id="deleteByListingId">
DELETE FROM seller_listing_watermark_image
WHERE listing_id = #{listingId} AND deleted = 0
</delete>
<select id="selectByListingId" resultType="com.aida.seller.module.listing.entity.ListingWatermarkImageEntity">
SELECT id, listing_id, category, original_url, watermarked_url, create_time
FROM seller_listing_watermark_image
WHERE listing_id = #{listingId} AND deleted = 0
</select>
</mapper>

View File

@@ -53,7 +53,7 @@
<update id="incrementSalesVolumeByOrderIds">
UPDATE seller_listing
SET sales_volume = sales_volume + 1
SET sales_volume = sales_volume + 1, update_time = update_time
WHERE id IN (
SELECT DISTINCT listing_id FROM seller_order_item
WHERE order_id IN