From 2e9225852d6ed0917ba60931fe3fbca00a978944 Mon Sep 17 00:00:00 2001 From: Timi Date: Fri, 21 Aug 2026 23:43:40 +0800 Subject: [PATCH] add AttachmentVariant --- .../common/bean/attachment/Metadata.java | 48 --- .../bean/attachment/ThumbnailFitMode.java | 17 + .../controller/AttachmentController.java | 86 ++++- .../common/entity/AttachmentVariant.java | 68 ++++ .../common/mapper/AttachmentMapper.java | 20 -- .../mapper/AttachmentVariantMapper.java | 78 ++++ .../common/service/AttachmentService.java | 12 - .../service/AttachmentVariantService.java | 28 ++ .../implement/AttachmentServiceImplement.java | 124 ++----- .../AttachmentVariantServiceImplement.java | 339 ++++++++++++++++++ .../common/task/AttachmentClearTask.java | 21 +- src/main/java/com/imyeyu/api/util/JavaCV.java | 78 ++-- .../V39__create_attachment_variant_table.sql | 32 ++ .../timi-server/common/AttachmentMapper.xml | 29 -- .../common/AttachmentVariantMapper.xml | 105 ++++++ 15 files changed, 827 insertions(+), 258 deletions(-) delete mode 100644 src/main/java/com/imyeyu/api/modules/common/bean/attachment/Metadata.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/bean/attachment/ThumbnailFitMode.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/entity/AttachmentVariant.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/mapper/AttachmentVariantMapper.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/service/AttachmentVariantService.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/service/implement/AttachmentVariantServiceImplement.java create mode 100644 src/main/resources/db/migration/timiserver/V39__create_attachment_variant_table.sql create mode 100644 src/main/resources/mapper/timi-server/common/AttachmentVariantMapper.xml diff --git a/src/main/java/com/imyeyu/api/modules/common/bean/attachment/Metadata.java b/src/main/java/com/imyeyu/api/modules/common/bean/attachment/Metadata.java deleted file mode 100644 index bbd0325..0000000 --- a/src/main/java/com/imyeyu/api/modules/common/bean/attachment/Metadata.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.imyeyu.api.modules.common.bean.attachment; - -import lombok.Data; -import lombok.EqualsAndHashCode; - -/** - * @author 夜雨 - * @since 2025-12-11 18:14 - */ -public class Metadata { - - /** - * 图片 - * - * @author 夜雨 - * @since 2025-12-11 18:15 - */ - @Data - public static class Image { - - private int width; - - private int height; - } - - /** - * 缩略图 - * - * @author 夜雨 - * @since 2026-01-04 18:10 - */ - @Data - @EqualsAndHashCode(callSuper = true) - public static class ThumbImage extends Image { - - private String sourceId; - - private String sourceMongoId; - - private String sourceMimeType; - - /** 请求宽度(缓存键之一,null 表示未指定) */ - private Integer requestWidth; - - /** 请求高度(缓存键之一,null 表示未指定) */ - private Integer requestHeight; - } -} diff --git a/src/main/java/com/imyeyu/api/modules/common/bean/attachment/ThumbnailFitMode.java b/src/main/java/com/imyeyu/api/modules/common/bean/attachment/ThumbnailFitMode.java new file mode 100644 index 0000000..ed42b48 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/bean/attachment/ThumbnailFitMode.java @@ -0,0 +1,17 @@ +package com.imyeyu.api.modules.common.bean.attachment; + +/// 缩略图缩放模式 +/// +/// @author 夜雨 +/// @since 2026-08-21 +public enum ThumbnailFitMode { + + /// 保持比例完整放入目标尺寸 + FIT, + + /// 保持比例裁剪填满目标尺寸 + COVER, + + /// 强制缩放到目标尺寸,可能导致图片变形 + STRETCH +} diff --git a/src/main/java/com/imyeyu/api/modules/common/controller/AttachmentController.java b/src/main/java/com/imyeyu/api/modules/common/controller/AttachmentController.java index 10ed758..2f7b845 100644 --- a/src/main/java/com/imyeyu/api/modules/common/controller/AttachmentController.java +++ b/src/main/java/com/imyeyu/api/modules/common/controller/AttachmentController.java @@ -1,7 +1,9 @@ package com.imyeyu.api.modules.common.controller; import com.imyeyu.api.modules.common.entity.Attachment; +import com.imyeyu.api.modules.common.bean.attachment.ThumbnailFitMode; import com.imyeyu.api.modules.common.service.AttachmentService; +import com.imyeyu.api.modules.common.service.AttachmentVariantService; import com.imyeyu.api.modules.common.service.TempFileService; import com.imyeyu.api.modules.common.vo.attach.TempFileResp; import com.imyeyu.java.TimiJava; @@ -41,6 +43,7 @@ public class AttachmentController { private final TempFileService tempFileService; private final AttachmentService service; + private final AttachmentVariantService variantService; /** * 查询附件详情 @@ -184,14 +187,19 @@ public class AttachmentController { @AOPLog @IgnoreGlobalReturn @GetMapping({"/read/{id}", "/download/{id}"}) - public void readById(@PathVariable String id, @RequestParam(required = false) Integer thumbWidth, @RequestParam(required = false) Integer thumbHeight) throws UnsupportedEncodingException { + public void readById( + @PathVariable String id, + @RequestParam(required = false) Integer thumbWidth, + @RequestParam(required = false) Integer thumbHeight, + @RequestParam(defaultValue = "FIT") ThumbnailFitMode fitMode + ) throws UnsupportedEncodingException { HttpServletResponse resp = TimiSpring.getResponse(); Attachment attach = service.get(id); if (attach == null) { resp.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } - responseAttachment(attach, thumbWidth, thumbHeight); + responseAttachment(attach, thumbWidth, thumbHeight, fitMode); } @AOPLog @@ -207,21 +215,31 @@ public class AttachmentController { public void read(@RequestParam String id) throws UnsupportedEncodingException { HttpServletResponse resp = TimiSpring.getResponse(); Attachment attach = service.get(id); + if (attach == null) { + resp.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } if (TimiSpring.getRequest().getRequestURI().endsWith("/download")) { resp.setHeader("Content-Disposition", Network.getFileDownloadHeader(attach.getName())); } - response(attach); + response(attach, false); } - private void responseAttachment(Attachment attach, Integer thumbWidth, Integer thumbHeight) throws UnsupportedEncodingException { - HttpServletResponse resp = TimiSpring.getResponse(); - if (attach != null && TimiSpring.getRequest().getRequestURI().endsWith("/download") && attach.getName() != null) { - resp.setHeader("Content-Disposition", Network.getFileDownloadHeader(attach.getName())); - } - responseThumb(attach, thumbWidth, thumbHeight); + private void responseAttachment( + Attachment attach, + Integer thumbWidth, + Integer thumbHeight, + ThumbnailFitMode fitMode + ) throws UnsupportedEncodingException { + responseThumb(attach, thumbWidth, thumbHeight, fitMode); } - protected void responseThumb(Attachment attachment, Integer thumbWidth, Integer thumbHeight) { + protected void responseThumb( + Attachment attachment, + Integer thumbWidth, + Integer thumbHeight, + ThumbnailFitMode fitMode + ) throws UnsupportedEncodingException { HttpServletResponse resp = TimiSpring.getResponse(); if (TimiJava.isEmpty(attachment)) { resp.setStatus(HttpServletResponse.SC_NOT_FOUND); @@ -229,21 +247,65 @@ public class AttachmentController { } if (thumbWidth == null && thumbHeight == null) { // 原图 + setDownloadHeader(attachment); response(attachment); return; } // 缩略图 - Attachment thumb = service.fetchThumb(attachment, thumbWidth, thumbHeight); - response(TimiJava.defaultIfNull(thumb, attachment)); + Attachment thumb = variantService.fetchThumb(attachment, thumbWidth, thumbHeight, fitMode); + setDownloadHeader(thumb); + response(thumb); + } + + private void setDownloadHeader(Attachment attachment) throws UnsupportedEncodingException { + if (attachment != null && TimiSpring.getRequest().getRequestURI().endsWith("/download") && attachment.getName() != null) { + TimiSpring.getResponse().setHeader("Content-Disposition", Network.getFileDownloadHeader(attachment.getName())); + } + } + + /// 设置附件缓存头并处理条件请求 + /// + /// @param attachment 实际响应附件 + /// @return true 表示客户端缓存仍有效,已返回 304 + private boolean responseCache(Attachment attachment) { + HttpServletResponse resp = TimiSpring.getResponse(); + resp.setHeader("Cache-Control", "private, no-cache"); + String md5 = attachment.getMd5(); + if (md5 == null || md5.isBlank()) { + return false; + } + String etag = "\"%s\"".formatted(md5); + resp.setHeader("ETag", etag); + String ifNoneMatch = TimiSpring.getHeader("If-None-Match"); + if (ifNoneMatch == null) { + return false; + } + for (String value : ifNoneMatch.split(",")) { + String candidate = value.trim(); + if ("*".equals(candidate) || etag.equals(candidate) || ("W/" + etag).equals(candidate)) { + resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + return true; + } + } + return false; } protected void response(Attachment attachment) { + response(attachment, true); + } + + private void response(Attachment attachment, boolean cacheable) { HttpServletResponse resp = TimiSpring.getResponse(); try { if (TimiJava.isEmpty(attachment)) { resp.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } + if (!cacheable) { + resp.setHeader("Cache-Control", "private, no-store"); + } else if (responseCache(attachment)) { + return; + } resp.setContentType(attachment.getMimeType()); attachment.doResponse(); } catch (Exception e) { diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/AttachmentVariant.java b/src/main/java/com/imyeyu/api/modules/common/entity/AttachmentVariant.java new file mode 100644 index 0000000..f432840 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/entity/AttachmentVariant.java @@ -0,0 +1,68 @@ +package com.imyeyu.api.modules.common.entity; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.imyeyu.api.modules.common.bean.attachment.ThumbnailFitMode; +import com.imyeyu.spring.annotation.table.AutoUUID; +import com.imyeyu.spring.annotation.table.Id; +import lombok.Data; + +/// 附件缩略图变体记录 +/// +/// @author 夜雨 +/// @since 2026-08-21 +@Data +public class AttachmentVariant { + + /// 变体 ID + @Id + @AutoUUID + private String id; + + /// 源附件 ID + private String sourceAttachmentId; + + /// 源文件版本,使用源 GridFS ID + private String sourceMongoId; + + /// 源文件摘要 + private String sourceMd5; + + /// 请求宽度,0 表示未指定 + private int requestWidth; + + /// 请求高度,0 表示未指定 + private int requestHeight; + + /// 缩放模式 + private ThumbnailFitMode fitMode; + + /// 输出格式 + private String outputFormat; + + /// 缩略图算法版本 + private String algorithmVersion; + + /// 缩略图 GridFS ID + private String mongoId; + + /// 缩略图名称 + private String name; + + /// 缩略图 MIME 类型 + private String mimeType; + + /// 缩略图元数据 + private ObjectNode metadata; + + /// 缩略图大小 + private long size; + + /// 缩略图摘要 + private String md5; + + /// 创建时间 + private long createdAt; + + /// 过期时间 + private long expireAt; +} diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/AttachmentMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/AttachmentMapper.java index bed703e..0579732 100644 --- a/src/main/java/com/imyeyu/api/modules/common/mapper/AttachmentMapper.java +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/AttachmentMapper.java @@ -36,26 +36,6 @@ public interface AttachmentMapper extends BaseMapper, RawMap long countByBizId(Attachment.BizType bizType, String bizId, List attachTypes); - /** - * 按源附件 ID 与请求尺寸查询已缓存缩略图 - * - * @param bizType 源附件业务类型 - * @param sourceId 源附件 ID - * @param requestWidth 请求宽度,{@code null} 表示未指定 - * @param requestHeight 请求高度,{@code null} 表示未指定 - * @return 缩略图附件,不存在返回 {@code null} - */ - Attachment selectThumb(Attachment.BizType bizType, String sourceId, Integer requestWidth, Integer requestHeight); - - /** - * 查询源附件的所有未销毁缩略图(含已软删除) - * - * @param sourceId 源附件 ID - * @return 缩略图列表 - */ - @Select("SELECT * FROM attachment WHERE biz_id = #{sourceId} AND attach_type = 'THUMB' AND is_destroyed = FALSE") - List selectThumbsBySourceId(String sourceId); - @Select("SELECT * FROM attachment WHERE `is_destroyed` = FALSE AND `destroy_at` < " + UNIX_TIME) List selectNeedDestroy(); } diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/AttachmentVariantMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/AttachmentVariantMapper.java new file mode 100644 index 0000000..a4f2ab5 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/AttachmentVariantMapper.java @@ -0,0 +1,78 @@ +package com.imyeyu.api.modules.common.mapper; + +import com.imyeyu.api.modules.common.entity.Attachment; +import com.imyeyu.api.modules.common.entity.AttachmentVariant; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/// 附件缩略图变体 Mapper +/// +/// @author 夜雨 +/// @since 2026-08-21 +public interface AttachmentVariantMapper { + + /// 查询有效缩略图附件 + /// + /// @param sourceAttachmentId 源附件 ID + /// @param sourceMongoId 源 GridFS ID + /// @param requestWidth 请求宽度,0 表示未指定 + /// @param requestHeight 请求高度,0 表示未指定 + /// @param fitMode 缩放模式 + /// @param outputFormat 输出格式 + /// @param algorithmVersion 算法版本 + /// @param now 当前时间 + /// @return 缩略图附件 + Attachment selectValidAttachment( + @Param("sourceAttachmentId") String sourceAttachmentId, + @Param("sourceMongoId") String sourceMongoId, + @Param("requestWidth") int requestWidth, + @Param("requestHeight") int requestHeight, + @Param("fitMode") String fitMode, + @Param("outputFormat") String outputFormat, + @Param("algorithmVersion") String algorithmVersion, + @Param("now") long now + ); + + /// 查询指定变体记录,不判断是否过期 + /// + /// @param sourceAttachmentId 源附件 ID + /// @param sourceMongoId 源 GridFS ID + /// @param requestWidth 请求宽度,0 表示未指定 + /// @param requestHeight 请求高度,0 表示未指定 + /// @param fitMode 缩放模式 + /// @param outputFormat 输出格式 + /// @param algorithmVersion 算法版本 + /// @return 变体记录 + AttachmentVariant selectByKey( + @Param("sourceAttachmentId") String sourceAttachmentId, + @Param("sourceMongoId") String sourceMongoId, + @Param("requestWidth") int requestWidth, + @Param("requestHeight") int requestHeight, + @Param("fitMode") String fitMode, + @Param("outputFormat") String outputFormat, + @Param("algorithmVersion") String algorithmVersion + ); + + /// 查询源附件的所有变体 + /// + /// @param sourceAttachmentId 源附件 ID + /// @return 变体列表 + List selectBySourceAttachmentId(@Param("sourceAttachmentId") String sourceAttachmentId); + + /// 查询已过期变体 + /// + /// @param now 当前时间 + /// @return 已过期变体列表 + List selectExpired(@Param("now") long now); + + /// 新增变体 + /// + /// @param variant 变体 + void insert(AttachmentVariant variant); + + /// 删除变体 + /// + /// @param id 变体 ID + void delete(@Param("id") String id); +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/AttachmentService.java b/src/main/java/com/imyeyu/api/modules/common/service/AttachmentService.java index b89df9c..597eda4 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/AttachmentService.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/AttachmentService.java @@ -25,18 +25,6 @@ public interface AttachmentService extends BaseService { /// @return 克隆后的附件 Attachment clone(String id); - /** - * 按需获取或创建缩略图附件。 - *

当附件为图片或视频时,若指定尺寸的缩略图已存在则直接返回缓存, - * 否则生成并持久化后返回。不支持缩略图的附件类型返回 {@code null}。

- * - * @param source 源附件(必须已持久化,mimeType 已填充) - * @param requestWidth 请求宽度,{@code null} 表示未指定 - * @param requestHeight 请求高度,{@code null} 表示未指定 - * @return 缩略图附件,不支持时返回 {@code null} - */ - Attachment fetchThumb(Attachment source, Integer requestWidth, Integer requestHeight); - Attachment getByBizId(Attachment.BizType bizType, String bizId); Attachment getByAttachType(Attachment.BizType bizType, String bizId, String attachType); diff --git a/src/main/java/com/imyeyu/api/modules/common/service/AttachmentVariantService.java b/src/main/java/com/imyeyu/api/modules/common/service/AttachmentVariantService.java new file mode 100644 index 0000000..fef1a73 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/AttachmentVariantService.java @@ -0,0 +1,28 @@ +package com.imyeyu.api.modules.common.service; + +import com.imyeyu.api.modules.common.bean.attachment.ThumbnailFitMode; +import com.imyeyu.api.modules.common.entity.Attachment; + +/// 附件缩略图变体服务 +/// +/// @author 夜雨 +/// @since 2026-08-21 +public interface AttachmentVariantService { + + /// 按需获取或创建缩略图附件 + /// + /// @param source 源附件 + /// @param requestWidth 请求宽度 + /// @param requestHeight 请求高度 + /// @param fitMode 缩放模式 + /// @return 缩略图附件 + Attachment fetchThumb(Attachment source, Integer requestWidth, Integer requestHeight, ThumbnailFitMode fitMode); + + /// 删除源附件对应的全部缩略图变体 + /// + /// @param sourceAttachmentId 源附件 ID + void deleteBySourceId(String sourceAttachmentId); + + /// 清理已过期的缩略图变体 + void clearExpired(); +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/AttachmentServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/AttachmentServiceImplement.java index 9bc93c2..87e1317 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/implement/AttachmentServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/AttachmentServiceImplement.java @@ -3,19 +3,14 @@ package com.imyeyu.api.modules.common.service.implement; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.imyeyu.api.config.dbsource.TimiServerDBConfig; -import com.imyeyu.api.modules.common.bean.attachment.MediaAttach; -import com.imyeyu.api.modules.common.bean.attachment.Metadata; import com.imyeyu.api.modules.common.entity.Attachment; import com.imyeyu.api.modules.common.mapper.AttachmentMapper; import com.imyeyu.api.modules.common.service.AttachmentService; -import com.imyeyu.api.util.JavaCV; +import com.imyeyu.api.modules.common.service.AttachmentVariantService; import com.imyeyu.io.IO; import com.imyeyu.java.TimiJava; -import com.imyeyu.java.bean.CallbackArg; import com.imyeyu.java.bean.timi.TimiCode; import com.imyeyu.java.bean.timi.TimiException; -import com.imyeyu.java.ref.Ref; -import com.imyeyu.network.Network; import com.imyeyu.spring.TimiSpring; import com.imyeyu.spring.bean.Page; import com.imyeyu.spring.bean.PageResult; @@ -26,7 +21,6 @@ import com.mongodb.client.gridfs.GridFSBucket; import com.mongodb.client.gridfs.model.GridFSFile; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import net.coobird.thumbnailator.Thumbnails; import org.apache.tika.Tika; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; @@ -36,9 +30,6 @@ import org.springframework.transaction.annotation.Transactional; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; @@ -47,7 +38,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.UUID; import java.util.stream.Collectors; /** @@ -64,6 +54,7 @@ public class AttachmentServiceImplement extends AbstractEntityService mapper() { @@ -124,6 +115,7 @@ public class AttachmentServiceImplement extends AbstractEntityService super.delete(thumb.getId())); + variantService.deleteBySourceId(id); super.delete(id); } @Transactional(TimiServerDBConfig.ROLLBACKER) @Override public void destroy(String id) { - CallbackArg doDestroy = attach -> { - try { - if (attach.getMongoId() != null) { - gridFsTemplate.delete(Query.query(Criteria.where("_id").is(attach.getMongoId()))); - } - if (!attach.isDeleted()) { - attach.setDeletedAt(Time.now()); - } - attach.setIsDestroyed(true); - attach.setDestroyAt(Time.now()); - mapper.update(attach); - - if (Ref.toType(MediaAttach.Type.class, attach.getAttachType()) == MediaAttach.Type.THUMB) { - // 缩略图记录物理删除 - mapper.destroy(attach.getId()); - } - } catch (Exception e) { - log.error("delete mongo file error, id={}", attach.getId(), e); - throw new TimiException(TimiCode.ERROR, "TODO delete mongo file error"); - } - }; - mapper.selectThumbsBySourceId(id).forEach(doDestroy::handler); - doDestroy.handler(mapper.selectRaw(id)); + variantService.deleteBySourceId(id); + Attachment attachment = mapper.selectRaw(id); + if (attachment != null) { + destroyAttachment(attachment); + } } @Transactional(TimiServerDBConfig.ROLLBACKER) @@ -216,74 +192,20 @@ public class AttachmentServiceImplement extends AbstractEntityService applyThumbSize = inputStream -> { - try { - Thumbnails.Builder builder = Thumbnails.of(inputStream); - if (requestWidth != null && requestHeight != null) { - builder.size(requestWidth, requestHeight).keepAspectRatio(false).toOutputStream(thumbStream); - } else if (requestWidth != null) { - builder.width(requestWidth).keepAspectRatio(true).toOutputStream(thumbStream); - } else { - builder.height(requestHeight).keepAspectRatio(true).toOutputStream(thumbStream); - } - } catch (IOException e) { - log.error("applyThumbSize error for fetchThumb", e); - throw new RuntimeException(e); - } - }; - if (isImage) { - applyThumbSize.handler(source.openInputStream()); - } else { - String name = source.getName(); - File tempFile = IO.file("temp/%s_%s".formatted(UUID.randomUUID(), name)); - try { - IO.toFile(tempFile, source.openInputStream()); - ByteArrayOutputStream baos = JavaCV.captureThumbnail(IO.getInputStream(tempFile), 2); - applyThumbSize.handler(IO.toInputStream(baos)); - } finally { - IO.destroy(tempFile); - } + if (attachment.getMongoId() != null) { + gridFsTemplate.delete(Query.query(Criteria.where("_id").is(attachment.getMongoId()))); } - Metadata.ThumbImage thumbMeta = new Metadata.ThumbImage(); - thumbMeta.setSourceId(source.getId()); - thumbMeta.setSourceMongoId(source.getMongoId()); - thumbMeta.setSourceMimeType(mimeType); - thumbMeta.setRequestWidth(requestWidth); - thumbMeta.setRequestHeight(requestHeight); - - Attachment thumb = new Attachment(); - thumb.setName(Network.simpleURIFileName(source.getName()) + ".png"); - thumb.setBizType(source.getBizType()); - thumb.setBizId(source.getId()); - thumb.setAttachType(MediaAttach.Type.THUMB.toString()); - thumb.setMetadata(jackson.valueToTree(thumbMeta)); - thumb.setInputStream(new ByteArrayInputStream(thumbStream.toByteArray())); - // 缩略图 7 天后过期,额外 1 天宽限供后台任务销毁 - long expireAt = Time.now() + 7 * Time.D; - thumb.setDeletedAt(expireAt); - thumb.setDestroyAt(expireAt + Time.D); - create(thumb); - return thumb; + if (!attachment.isDeleted()) { + attachment.setDeletedAt(Time.now()); + } + attachment.setIsDestroyed(true); + attachment.setDestroyAt(Time.now()); + mapper.update(attachment); } catch (Exception e) { - log.error("create thumbnail error", e); - throw new TimiException(TimiCode.ERROR, "exception.attachment.create.error"); + log.error("delete mongo file error, id={}", attachment.getId(), e); + throw new TimiException(TimiCode.ERROR, "TODO delete mongo file error", e); } } @@ -390,6 +312,8 @@ public class AttachmentServiceImplement extends AbstractEntityService redisLocker; + + @Override + public Attachment fetchThumb(Attachment source, Integer requestWidth, Integer requestHeight, ThumbnailFitMode fitMode) { + TimiException.required(source, "not found source attachment"); + TimiException.required(source.getId(), "not found source attachment.id"); + TimiException.required(source.getMongoId(), "not found source attachment.mongoId"); + TimiException.requiredTrue(requestWidth != null || requestHeight != null, "required width or height"); + if (requestWidth != null && !(0 < requestWidth && requestWidth < THUMB_MAX_WIDTH + 1)) { + throw new TimiException(TimiCode.ARG_BAD, "attachment.thumbnail.width.invalid"); + } + if (requestHeight != null && !(0 < requestHeight && requestHeight < THUMB_MAX_HEIGHT + 1)) { + throw new TimiException(TimiCode.ARG_BAD, "attachment.thumbnail.height.invalid"); + } + if (requestWidth != null && requestHeight != null) { + long pixels = (long) requestWidth * requestHeight; + if (THUMB_MAX_PIXELS < pixels) { + throw new TimiException(TimiCode.ARG_BAD, "attachment.thumbnail.area.invalid"); + } + } + fitMode = TimiJava.defaultIfNull(fitMode, ThumbnailFitMode.FIT); + + String mimeType = source.getMimeType(); + boolean isImage = mimeType != null && Set.of("image/png", "image/bmp", "image/jpeg").contains(mimeType); + boolean isVideo = mimeType != null && Set.of("video/mp4", "video/quicktime").contains(mimeType); + if (!isImage && !isVideo) { + throw new TimiException(TimiCode.ARG_BAD, "attachment.thumbnail.not.support"); + } + + int width = TimiJava.defaultIfNull(requestWidth, 0); + int height = TimiJava.defaultIfNull(requestHeight, 0); + long now = Time.now(); + Attachment cached = variantMapper.selectValidAttachment( + source.getId(), + source.getMongoId(), + width, + height, + fitMode.toString(), + THUMB_OUTPUT_FORMAT, + THUMB_ALGORITHM_VERSION, + now + ); + if (cached != null) { + return cached; + } + + String lockKey = buildThumbLockKey(source, width, height, fitMode); + if (!redisLocker.lock(lockKey, 1, THUMB_LOCK_TTL)) { + throw new TimiException(TimiCode.ERROR_SERVICE_BUSY, "attachment.thumbnail.generating"); + } + try { + cached = variantMapper.selectValidAttachment( + source.getId(), + source.getMongoId(), + width, + height, + fitMode.toString(), + THUMB_OUTPUT_FORMAT, + THUMB_ALGORITHM_VERSION, + Time.now() + ); + if (cached != null) { + return cached; + } + AttachmentVariant oldVariant = variantMapper.selectByKey( + source.getId(), + source.getMongoId(), + width, + height, + fitMode.toString(), + THUMB_OUTPUT_FORMAT, + THUMB_ALGORITHM_VERSION + ); + if (oldVariant != null) { + deleteVariant(oldVariant); + } + File thumbFile = null; + AttachmentVariant variant = null; + boolean variantInserted = false; + try { + thumbFile = createThumbFile(source, requestWidth, requestHeight, fitMode, isImage); + variant = new AttachmentVariant(); + variant.setId(UUID.randomUUID().toString()); + variant.setName(Network.simpleURIFileName(source.getName()) + "." + THUMB_OUTPUT_FORMAT); + variant.setMimeType("image/" + THUMB_OUTPUT_FORMAT); + long expireAt = Time.now() + THUMB_CACHE_TTL; + variant.setSourceAttachmentId(source.getId()); + variant.setSourceMongoId(source.getMongoId()); + variant.setSourceMd5(source.getMd5()); + variant.setRequestWidth(width); + variant.setRequestHeight(height); + variant.setFitMode(fitMode); + variant.setOutputFormat(THUMB_OUTPUT_FORMAT); + variant.setAlgorithmVersion(THUMB_ALGORITHM_VERSION); + variant.setCreatedAt(Time.now()); + variant.setExpireAt(expireAt); + BufferedImage image = ImageIO.read(thumbFile); + if (image != null) { + ObjectNode metadata = jackson.createObjectNode(); + metadata.put("width", image.getWidth()); + metadata.put("height", image.getHeight()); + variant.setMetadata(metadata); + } + try (InputStream inputStream = Files.newInputStream(thumbFile.toPath())) { + String mongoId = gridFsTemplate.store(inputStream, variant.getName()).toString(); + GridFSFile gridFSFile = gridFsTemplate.findOne(new Query(Criteria.where("_id").is(mongoId))); + variant.setMongoId(mongoId); + variant.setSize(gridFSFile.getLength()); + try (InputStream md5InputStream = gridFSBucket.openDownloadStream(gridFSFile.getObjectId())) { + variant.setMd5(IO.md5(md5InputStream)); + } + } + try { + variantMapper.insert(variant); + variantInserted = true; + } catch (DuplicateKeyException e) { + destroyVariantFile(variant); + Attachment concurrent = variantMapper.selectValidAttachment( + source.getId(), + source.getMongoId(), + width, + height, + fitMode.toString(), + THUMB_OUTPUT_FORMAT, + THUMB_ALGORITHM_VERSION, + Time.now() + ); + if (concurrent != null) { + return concurrent; + } + throw e; + } + return toThumbAttachment(source, variant); + } catch (Exception e) { + if (variant != null) { + destroyVariantFile(variant); + if (variantInserted) { + variantMapper.delete(variant.getId()); + } + } + throw e; + } finally { + IO.destroy(thumbFile); + } + } catch (TimiException e) { + throw e; + } catch (Exception e) { + log.error("create thumbnail error", e); + throw new TimiException(TimiCode.ERROR, "exception.attachment.create.error", e); + } finally { + redisLocker.releaseLock(lockKey); + } + } + + private String buildThumbLockKey(Attachment source, int width, int height, ThumbnailFitMode fitMode) { + return "%s%s:%s:%s:%s:%s".formatted( + THUMB_LOCK_KEY_PREFIX, + source.getId(), + source.getMongoId(), + width, + height, + fitMode + ); + } + + private File createThumbFile( + Attachment source, + Integer requestWidth, + Integer requestHeight, + ThumbnailFitMode fitMode, + boolean isImage + ) throws Exception { + File thumbFile = Files.createTempFile("timi-thumb-", ".png").toFile(); + try { + if (isImage) { + try (InputStream inputStream = source.openInputStream()) { + resize(inputStream, thumbFile, requestWidth, requestHeight, fitMode); + } + return thumbFile; + } + + try (InputStream inputStream = source.openInputStream()) { + ByteArrayOutputStream frame = JavaCV.captureThumbnail(inputStream, THUMB_TARGET_SECONDS); + if (!(0 < frame.size())) { + throw new TimiException(TimiCode.ERROR_NOT_SUPPORT, "attachment.thumbnail.video.frame.empty"); + } + try (InputStream frameInputStream = new ByteArrayInputStream(frame.toByteArray())) { + resize(frameInputStream, thumbFile, requestWidth, requestHeight, fitMode); + } + return thumbFile; + } + } catch (Exception e) { + IO.destroy(thumbFile); + throw e; + } + } + + private void resize( + InputStream inputStream, + File outputFile, + Integer requestWidth, + Integer requestHeight, + ThumbnailFitMode fitMode + ) throws IOException { + Thumbnails.Builder builder = Thumbnails.of(inputStream) + .useExifOrientation(true) + .outputFormat(THUMB_OUTPUT_FORMAT); + if (requestWidth != null && requestHeight != null) { + switch (fitMode) { + case FIT -> builder.size(requestWidth, requestHeight).keepAspectRatio(true); + case COVER -> builder.size(requestWidth, requestHeight).keepAspectRatio(true).crop(Positions.CENTER); + case STRETCH -> builder.size(requestWidth, requestHeight).keepAspectRatio(false); + } + } else if (requestWidth != null) { + builder.width(requestWidth).keepAspectRatio(true); + } else { + builder.height(requestHeight).keepAspectRatio(true); + } + builder.toFile(outputFile); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void deleteBySourceId(String sourceAttachmentId) { + for (AttachmentVariant variant : variantMapper.selectBySourceAttachmentId(sourceAttachmentId)) { + deleteVariant(variant); + } + } + + private void deleteVariant(AttachmentVariant variant) { + destroyVariantFile(variant); + variantMapper.delete(variant.getId()); + } + + private void destroyVariantFile(AttachmentVariant variant) { + if (variant.getMongoId() != null) { + gridFsTemplate.delete(Query.query(Criteria.where("_id").is(variant.getMongoId()))); + } + } + + private Attachment toThumbAttachment(Attachment source, AttachmentVariant variant) { + Attachment thumb = new Attachment(); + thumb.setId(variant.getId()); + thumb.setBizType(source.getBizType()); + thumb.setBizId(source.getId()); + thumb.setAttachType(MediaAttach.Type.THUMB.toString()); + thumb.setMongoId(variant.getMongoId()); + thumb.setName(variant.getName()); + thumb.setMimeType(variant.getMimeType()); + thumb.setMetadata(variant.getMetadata()); + thumb.setSize(variant.getSize()); + thumb.setMd5(variant.getMd5()); + thumb.setIsDestroyed(false); + thumb.setDestroyAt(variant.getExpireAt()); + return thumb; + } + + @Override + public void clearExpired() { + for (AttachmentVariant variant : variantMapper.selectExpired(Time.now())) { + try { + deleteVariant(variant); + } catch (Exception e) { + log.error("clear expired thumbnail variant error, id={}", variant.getId(), e); + } + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/task/AttachmentClearTask.java b/src/main/java/com/imyeyu/api/modules/common/task/AttachmentClearTask.java index 5befa55..1cb96b0 100644 --- a/src/main/java/com/imyeyu/api/modules/common/task/AttachmentClearTask.java +++ b/src/main/java/com/imyeyu/api/modules/common/task/AttachmentClearTask.java @@ -2,6 +2,7 @@ package com.imyeyu.api.modules.common.task; import com.imyeyu.api.modules.common.entity.Attachment; import com.imyeyu.api.modules.common.service.AttachmentService; +import com.imyeyu.api.modules.common.service.AttachmentVariantService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; @@ -23,6 +24,7 @@ import java.util.List; public class AttachmentClearTask { private final AttachmentService attachmentService; + private final AttachmentVariantService attachmentVariantService; @Scheduled(cron = "0 0 1 * * ?") public void run() { @@ -31,19 +33,20 @@ public class AttachmentClearTask { List needDestroyList = attachmentService.listNeedDestroy(); if (needDestroyList.isEmpty()) { log.info("nothing attachment need clear"); - return; - } - for (Attachment attach : needDestroyList) { - try { - attachmentService.destroy(attach.getId()); - log.info("clear attachment success: id[{}]", attach.getId()); - } catch (Exception e) { - log.error("clear attachment error: id[{}]", attach.getId(), e); + } else { + for (Attachment attach : needDestroyList) { + try { + attachmentService.destroy(attach.getId()); + log.info("clear attachment success: id[{}]", attach.getId()); + } catch (Exception e) { + log.error("clear attachment error: id[{}]", attach.getId(), e); + } } } + attachmentVariantService.clearExpired(); log.info("end clear expired attachments"); } catch (Exception e) { log.error("attachment clear task error", e); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/imyeyu/api/util/JavaCV.java b/src/main/java/com/imyeyu/api/util/JavaCV.java index bd88805..ece6fc8 100644 --- a/src/main/java/com/imyeyu/api/util/JavaCV.java +++ b/src/main/java/com/imyeyu/api/util/JavaCV.java @@ -7,36 +7,58 @@ import org.bytedeco.javacv.Java2DFrameConverter; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; -/** - * - * - * @author 夜雨 - * @since 2025-10-23 17:05 - */ +/// JavaCV 工具类 +/// +/// @author 夜雨 +/// @since 2025-10-23 17:05 public class JavaCV { - public static ByteArrayOutputStream captureThumbnail(InputStream stream, double targetSeconds) throws Exception { - ByteArrayOutputStream outStream = new ByteArrayOutputStream(); - try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(stream)) { - grabber.start(); - long targetMillis = (long) (targetSeconds * 1000); - grabber.setTimestamp(targetMillis); - Frame frame; - while ((frame = grabber.grabImage()) != null) { - if (grabber.getTimestamp() >= targetMillis) { - Java2DFrameConverter converter = new Java2DFrameConverter(); - try (converter) { - BufferedImage bi = converter.getBufferedImage(frame); - if (bi != null) { - ImageIO.write(bi, "png", outStream); - break; - } - } - } - } - } - return outStream; - } + /// 从视频流顺序解码并提取指定时间附近的一帧 PNG 图片 + /// + /// 不执行时间定位,避免为了取实时缩略图而把整个视频复制到临时文件 + /// + /// @param stream 视频输入流 + /// @param targetSeconds 目标时间,单位为秒 + /// @return PNG 图片字节流 + /// @throws Exception 视频解析或取帧失败 + public static ByteArrayOutputStream captureThumbnail(InputStream stream, double targetSeconds) throws Exception { + long targetMicros = toMicros(targetSeconds); + ByteArrayOutputStream firstFrame = new ByteArrayOutputStream(); + try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(stream)) { + grabber.start(); + try (Java2DFrameConverter converter = new Java2DFrameConverter()) { + Frame frame; + while ((frame = grabber.grabImage()) != null) { + BufferedImage image = converter.getBufferedImage(frame); + if (image == null) { + continue; + } + if (!(0 < firstFrame.size())) { + firstFrame = toPng(image); + } + if (grabber.getTimestamp() >= targetMicros) { + return targetMicros == 0 ? firstFrame : toPng(image); + } + } + } + } + // 短视频提前结束时退回首个有效帧 + return firstFrame; + } + + private static long toMicros(double targetSeconds) { + double seconds = Double.isFinite(targetSeconds) ? Math.max(0, targetSeconds) : 0; + return Math.round(seconds * 1_000_000D); + } + + private static ByteArrayOutputStream toPng(BufferedImage image) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + if (ImageIO.write(image, "png", output)) { + return output; + } + return new ByteArrayOutputStream(); + } } diff --git a/src/main/resources/db/migration/timiserver/V39__create_attachment_variant_table.sql b/src/main/resources/db/migration/timiserver/V39__create_attachment_variant_table.sql new file mode 100644 index 0000000..c9456b8 --- /dev/null +++ b/src/main/resources/db/migration/timiserver/V39__create_attachment_variant_table.sql @@ -0,0 +1,32 @@ +CREATE TABLE `attachment_variant` ( + `id` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `source_attachment_id` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `source_mongo_id` VARCHAR(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `source_md5` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `request_width` INT UNSIGNED NOT NULL DEFAULT 0, + `request_height` INT UNSIGNED NOT NULL DEFAULT 0, + `fit_mode` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `output_format` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `algorithm_version` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `mongo_id` VARCHAR(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `name` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `mime_type` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `metadata` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `size` BIGINT(20) NOT NULL, + `md5` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `created_at` BIGINT(20) NOT NULL, + `expire_at` BIGINT(20) NOT NULL, + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_source_variant`( + `source_attachment_id`, + `source_mongo_id`, + `request_width`, + `request_height`, + `fit_mode`, + `output_format`, + `algorithm_version` + ) USING BTREE, + UNIQUE INDEX `uk_mongo_id`(`mongo_id`) USING BTREE, + INDEX `idx_source_attachment_id`(`source_attachment_id`) USING BTREE, + INDEX `idx_expire_at`(`expire_at`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '附件缩略图变体' ROW_FORMAT = Dynamic; diff --git a/src/main/resources/mapper/timi-server/common/AttachmentMapper.xml b/src/main/resources/mapper/timi-server/common/AttachmentMapper.xml index 2910e83..0eed0ad 100644 --- a/src/main/resources/mapper/timi-server/common/AttachmentMapper.xml +++ b/src/main/resources/mapper/timi-server/common/AttachmentMapper.xml @@ -26,33 +26,4 @@ ORDER BY `created_at` - diff --git a/src/main/resources/mapper/timi-server/common/AttachmentVariantMapper.xml b/src/main/resources/mapper/timi-server/common/AttachmentVariantMapper.xml new file mode 100644 index 0000000..625fd59 --- /dev/null +++ b/src/main/resources/mapper/timi-server/common/AttachmentVariantMapper.xml @@ -0,0 +1,105 @@ + + + + + `source_attachment_id` = #{sourceAttachmentId} + AND `source_mongo_id` = #{sourceMongoId} + AND `request_width` = #{requestWidth} + AND `request_height` = #{requestHeight} + AND `fit_mode` = #{fitMode} + AND `output_format` = #{outputFormat} + AND `algorithm_version` = #{algorithmVersion} + + + + + + + + + + + + INSERT INTO `attachment_variant` ( + `id`, + `source_attachment_id`, + `source_mongo_id`, + `source_md5`, + `request_width`, + `request_height`, + `fit_mode`, + `output_format`, + `algorithm_version`, + `mongo_id`, + `name`, + `mime_type`, + `metadata`, + `size`, + `md5`, + `created_at`, + `expire_at` + ) VALUES ( + #{id}, + #{sourceAttachmentId}, + #{sourceMongoId}, + #{sourceMd5}, + #{requestWidth}, + #{requestHeight}, + #{fitMode}, + #{outputFormat}, + #{algorithmVersion}, + #{mongoId}, + #{name}, + #{mimeType}, + #{metadata}, + #{size}, + #{md5}, + #{createdAt}, + #{expireAt} + ) + + + + DELETE FROM `attachment_variant` + WHERE `id` = #{id} + +