add AttachmentVariant
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.imyeyu.api.modules.common.bean.attachment;
|
||||
|
||||
/// 缩略图缩放模式
|
||||
///
|
||||
/// @author 夜雨
|
||||
/// @since 2026-08-21
|
||||
public enum ThumbnailFitMode {
|
||||
|
||||
/// 保持比例完整放入目标尺寸
|
||||
FIT,
|
||||
|
||||
/// 保持比例裁剪填满目标尺寸
|
||||
COVER,
|
||||
|
||||
/// 强制缩放到目标尺寸,可能导致图片变形
|
||||
STRETCH
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -36,26 +36,6 @@ public interface AttachmentMapper extends BaseMapper<Attachment, String>, RawMap
|
||||
|
||||
long countByBizId(Attachment.BizType bizType, String bizId, List<String> 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<Attachment> selectThumbsBySourceId(String sourceId);
|
||||
|
||||
@Select("SELECT * FROM attachment WHERE `is_destroyed` = FALSE AND `destroy_at` < " + UNIX_TIME)
|
||||
List<Attachment> selectNeedDestroy();
|
||||
}
|
||||
|
||||
@@ -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<AttachmentVariant> selectBySourceAttachmentId(@Param("sourceAttachmentId") String sourceAttachmentId);
|
||||
|
||||
/// 查询已过期变体
|
||||
///
|
||||
/// @param now 当前时间
|
||||
/// @return 已过期变体列表
|
||||
List<AttachmentVariant> selectExpired(@Param("now") long now);
|
||||
|
||||
/// 新增变体
|
||||
///
|
||||
/// @param variant 变体
|
||||
void insert(AttachmentVariant variant);
|
||||
|
||||
/// 删除变体
|
||||
///
|
||||
/// @param id 变体 ID
|
||||
void delete(@Param("id") String id);
|
||||
}
|
||||
@@ -25,18 +25,6 @@ public interface AttachmentService extends BaseService<Attachment, String> {
|
||||
/// @return 克隆后的附件
|
||||
Attachment clone(String id);
|
||||
|
||||
/**
|
||||
* 按需获取或创建缩略图附件。
|
||||
* <p>当附件为图片或视频时,若指定尺寸的缩略图已存在则直接返回缓存,
|
||||
* 否则生成并持久化后返回。不支持缩略图的附件类型返回 {@code null}。</p>
|
||||
*
|
||||
* @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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+24
-100
@@ -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<Attachment
|
||||
private final ObjectMapper jackson;
|
||||
private final GridFSBucket gridFSBucket;
|
||||
private final GridFsTemplate gridFsTemplate;
|
||||
private final AttachmentVariantService variantService;
|
||||
|
||||
@Override
|
||||
protected BaseMapper<Attachment, String> mapper() {
|
||||
@@ -124,6 +115,7 @@ public class AttachmentServiceImplement extends AbstractEntityService<Attachment
|
||||
}
|
||||
// 有新文件时,先查旧记录获取旧 mongoId 和兜底字段
|
||||
Attachment old = mapper.selectRaw(attachment.getId());
|
||||
TimiException.required(old, "not found attachment");
|
||||
// 存储新文件
|
||||
newMongoId = gridFsTemplate.store(stream, attachment.buildMongoName()).toString();
|
||||
GridFSFile gridFSFile = gridFsTemplate.findOne(new Query(Criteria.where("_id").is(newMongoId)));
|
||||
@@ -141,6 +133,8 @@ public class AttachmentServiceImplement extends AbstractEntityService<Attachment
|
||||
|
||||
// 更新数据库后删除旧 GridFS 文件
|
||||
super.update(attachment);
|
||||
// 源文件版本已变化,旧缩略图全部失效
|
||||
variantService.deleteBySourceId(attachment.getId());
|
||||
gridFsTemplate.delete(Query.query(Criteria.where("_id").is(old.getMongoId())));
|
||||
} catch (Exception e) {
|
||||
// 新文件已上传但后续操作失败,清理新文件防止孤儿数据
|
||||
@@ -155,36 +149,18 @@ public class AttachmentServiceImplement extends AbstractEntityService<Attachment
|
||||
@Transactional(TimiServerDBConfig.ROLLBACKER)
|
||||
@Override
|
||||
public void delete(String id) {
|
||||
mapper.selectThumbsBySourceId(id).forEach(thumb -> super.delete(thumb.getId()));
|
||||
variantService.deleteBySourceId(id);
|
||||
super.delete(id);
|
||||
}
|
||||
|
||||
@Transactional(TimiServerDBConfig.ROLLBACKER)
|
||||
@Override
|
||||
public void destroy(String id) {
|
||||
CallbackArg<Attachment> 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<Attachment
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(TimiServerDBConfig.ROLLBACKER)
|
||||
@Override
|
||||
public Attachment fetchThumb(Attachment source, Integer requestWidth, Integer requestHeight) {
|
||||
TimiException.requiredTrue(requestWidth != null || requestHeight != null, "required width or height");
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
Attachment cached = mapper.selectThumb(source.getBizType(), source.getId(), requestWidth, requestHeight);
|
||||
if (cached != null) {
|
||||
// 命中缓存直接返回
|
||||
return cached;
|
||||
}
|
||||
private void destroyAttachment(Attachment attachment) {
|
||||
try {
|
||||
ByteArrayOutputStream thumbStream = new ByteArrayOutputStream();
|
||||
CallbackArg<InputStream> applyThumbSize = inputStream -> {
|
||||
try {
|
||||
Thumbnails.Builder<? extends InputStream> 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<Attachment
|
||||
// 将临时文件的 GridFS 存储转移至当前永久记录,废弃临时记录
|
||||
Attachment current = mapper.selectRaw(item.getId());
|
||||
Attachment tempFile = mapper.selectRaw(tempFileId);
|
||||
// 源文件版本已变化,旧缩略图全部失效
|
||||
variantService.deleteBySourceId(current.getId());
|
||||
// 删除旧 GridFS 文件
|
||||
gridFsTemplate.delete(Query.query(Criteria.where("_id").is(current.getMongoId())));
|
||||
// 将存储信息从临时记录迁移到永久记录
|
||||
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
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.ThumbnailFitMode;
|
||||
import com.imyeyu.api.modules.common.entity.Attachment;
|
||||
import com.imyeyu.api.modules.common.entity.AttachmentVariant;
|
||||
import com.imyeyu.api.modules.common.mapper.AttachmentVariantMapper;
|
||||
import com.imyeyu.api.modules.common.service.AttachmentVariantService;
|
||||
import com.imyeyu.api.util.JavaCV;
|
||||
import com.imyeyu.io.IO;
|
||||
import com.imyeyu.java.TimiJava;
|
||||
import com.imyeyu.java.bean.timi.TimiCode;
|
||||
import com.imyeyu.java.bean.timi.TimiException;
|
||||
import com.imyeyu.network.Network;
|
||||
import com.imyeyu.spring.util.Redis;
|
||||
import com.imyeyu.utils.Time;
|
||||
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 net.coobird.thumbnailator.geometry.Positions;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.data.mongodb.core.query.Criteria;
|
||||
import org.springframework.data.mongodb.core.query.Query;
|
||||
import org.springframework.data.mongodb.gridfs.GridFsTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
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.nio.file.Files;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/// 附件缩略图变体服务实现
|
||||
///
|
||||
/// 负责缩略图生成、变体缓存、并发控制和变体文件清理,源附件生命周期由 `AttachmentService` 负责
|
||||
///
|
||||
/// @author 夜雨
|
||||
/// @since 2026-08-21
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AttachmentVariantServiceImplement implements AttachmentVariantService {
|
||||
|
||||
private static final int THUMB_MAX_WIDTH = 4096;
|
||||
private static final int THUMB_MAX_HEIGHT = 4096;
|
||||
private static final long THUMB_MAX_PIXELS = 16_000_000L;
|
||||
private static final long THUMB_CACHE_TTL = Time.D * 7;
|
||||
private static final long THUMB_LOCK_TTL = Time.M * 10;
|
||||
private static final double THUMB_TARGET_SECONDS = 2;
|
||||
private static final String THUMB_OUTPUT_FORMAT = "png";
|
||||
private static final String THUMB_ALGORITHM_VERSION = "v3";
|
||||
private static final String THUMB_LOCK_KEY_PREFIX = "ATTACHMENT:THUMB:LOCK:";
|
||||
|
||||
private final AttachmentVariantMapper variantMapper;
|
||||
private final ObjectMapper jackson;
|
||||
private final GridFSBucket gridFSBucket;
|
||||
private final GridFsTemplate gridFsTemplate;
|
||||
|
||||
@Qualifier("redisLocker")
|
||||
private final Redis<String, Integer> 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<? extends InputStream> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Attachment> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user