From 1b8936f1974566a8c64a29fc17d135183448ba74 Mon Sep 17 00:00:00 2001 From: Timi Date: Fri, 21 Aug 2026 18:13:58 +0800 Subject: [PATCH 1/5] support sms login --- pom.xml | 7 +- .../com/imyeyu/api/config/BeanConfig.java | 9 - .../common/controller/NotifyController.java | 15 ++ .../common/entity/BaseQueueEntity.java | 3 +- .../modules/common/entity/NotifyDetail.java | 3 +- .../modules/common/service/SmsService.java | 60 +++---- .../implement/SmsQueueServiceImplement.java | 10 +- .../implement/SmsServiceImplement.java | 164 +++++++++--------- .../common/vo/SmsCaptchaVerifyRequest.java | 15 ++ .../user/controller/UserController.java | 12 ++ .../user/service/UserLoginService.java | 4 +- .../implement/UserLoginServiceImplement.java | 31 ++-- 12 files changed, 175 insertions(+), 158 deletions(-) create mode 100644 src/main/java/com/imyeyu/api/modules/common/vo/SmsCaptchaVerifyRequest.java diff --git a/pom.xml b/pom.xml index 9dec4b1..5fd7585 100644 --- a/pom.xml +++ b/pom.xml @@ -213,7 +213,7 @@ com.imyeyu.font.icon timi-icon-font - 0.0.1 + 0.0.2 org.springframework.boot @@ -240,11 +240,6 @@ org.flywaydb flyway-mysql - - com.e2ins.ms.notify - e2ins-msg-notify-service-sdk - 0.0.9 - com.tencentcloudapi tencentcloud-sdk-java-sms diff --git a/src/main/java/com/imyeyu/api/config/BeanConfig.java b/src/main/java/com/imyeyu/api/config/BeanConfig.java index c52e365..f7472d3 100644 --- a/src/main/java/com/imyeyu/api/config/BeanConfig.java +++ b/src/main/java/com/imyeyu/api/config/BeanConfig.java @@ -1,12 +1,9 @@ package com.imyeyu.api.config; -import com.e2ins.ms.notify.SmsNotifyService; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.PropertyNamingStrategy; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.imyeyu.utils.Time; @@ -32,12 +29,6 @@ public class BeanConfig { .build(); } - @Bean - public SmsNotifyService smsNotifyService() { - // TODO - return new SmsNotifyService(null, null); - } - @Bean public Yaml yaml() { return new Yaml(); diff --git a/src/main/java/com/imyeyu/api/modules/common/controller/NotifyController.java b/src/main/java/com/imyeyu/api/modules/common/controller/NotifyController.java index 720f4cb..8621182 100644 --- a/src/main/java/com/imyeyu/api/modules/common/controller/NotifyController.java +++ b/src/main/java/com/imyeyu/api/modules/common/controller/NotifyController.java @@ -7,11 +7,16 @@ import com.imyeyu.api.modules.common.entity.Notify; import com.imyeyu.api.modules.common.entity.NotifyDetail; import com.imyeyu.api.modules.common.service.NotifyDetailService; import com.imyeyu.api.modules.common.service.NotifyService; +import com.imyeyu.api.modules.common.service.SmsService; import com.imyeyu.api.modules.user.entity.User; import com.imyeyu.api.modules.user.service.UserLoginService; import com.imyeyu.api.modules.user.service.UserService; import com.imyeyu.java.TimiJava; +import com.imyeyu.spring.annotation.AOPLog; +import com.imyeyu.spring.annotation.CaptchaValid; +import com.imyeyu.spring.annotation.RequestRateLimit; import com.imyeyu.spring.annotation.RequiredToken; +import com.imyeyu.spring.bean.CaptchaData; import com.imyeyu.spring.bean.Page; import com.imyeyu.spring.bean.PageResult; import com.imyeyu.spring.util.ResponseView; @@ -37,6 +42,8 @@ import java.util.stream.Stream; @RequestMapping("/notify") public class NotifyController { + + private final SmsService smsService; private final UserService userService; private final NotifyService service; private final UserLoginService userLoginService; @@ -104,6 +111,14 @@ public class NotifyController { service.cancel(id); } + @AOPLog + @CaptchaValid + @RequestRateLimit(value = 1, inSeconds = 59) + @PostMapping("/sms/captcha/create") + public String smsCaptchaCreate(@RequestBody CaptchaData req) { + return smsService.captchaCreate(req.getData()); + } + /// 查询当前用户的站内通知 /// /// @param page 分页参数 diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/BaseQueueEntity.java b/src/main/java/com/imyeyu/api/modules/common/entity/BaseQueueEntity.java index b49a04c..2b8a32f 100644 --- a/src/main/java/com/imyeyu/api/modules/common/entity/BaseQueueEntity.java +++ b/src/main/java/com/imyeyu/api/modules/common/entity/BaseQueueEntity.java @@ -1,5 +1,6 @@ package com.imyeyu.api.modules.common.entity; +import com.fasterxml.jackson.databind.JsonNode; import com.imyeyu.spring.annotation.table.AutoUUID; import com.imyeyu.spring.annotation.table.Id; import com.imyeyu.spring.entity.Creatable; @@ -28,7 +29,7 @@ public class BaseQueueEntity implements IDEntity, Creatable, Destroyable protected String data; /** 参数 */ - protected String args; + protected JsonNode args; /** 发送去向 */ protected String sendTo; diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/NotifyDetail.java b/src/main/java/com/imyeyu/api/modules/common/entity/NotifyDetail.java index 96482d5..7087cb0 100644 --- a/src/main/java/com/imyeyu/api/modules/common/entity/NotifyDetail.java +++ b/src/main/java/com/imyeyu/api/modules/common/entity/NotifyDetail.java @@ -1,6 +1,7 @@ package com.imyeyu.api.modules.common.entity; import com.fasterxml.jackson.annotation.JsonView; +import com.fasterxml.jackson.databind.JsonNode; import com.imyeyu.api.TimiServerAPI; import com.imyeyu.api.modules.common.service.BaseQueueService; import com.imyeyu.api.modules.common.service.HTTPQueueService; @@ -103,7 +104,7 @@ public class NotifyDetail extends UUIDEntity { /** 参数 */ @JsonView(ResponseView.Public.class) - private String args; + private JsonNode args; /** 发送来源 */ @JsonView(ResponseView.Public.class) diff --git a/src/main/java/com/imyeyu/api/modules/common/service/SmsService.java b/src/main/java/com/imyeyu/api/modules/common/service/SmsService.java index 282e1c5..defc3f8 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/SmsService.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/SmsService.java @@ -1,30 +1,30 @@ -//package com.imyeyu.api.modules.common.service; -// -//import com.e2ins.ms.notify.entity.Notify; -//import com.e2ins.ms.notify.entity.NotifyDetail; -// -///** -// * -// * -// * @author 夜雨 -// * @since 2026-05-14 12:12 -// */ -//public interface SmsService { -// -// /** -// * 创建短信验证码通知 -// * -// * @param detail 通知 -// * @return 通知详情 ID -// */ -// String captchaCreate(Notify detail); -// -// /** -// * 校验短信验证码 -// * -// * @param detailId 通知详情 ID -// * @param captcha 请求验证码 -// * @return 通知详情 -// */ -// NotifyDetail captchaVerify(String detailId, String captcha); -//} +package com.imyeyu.api.modules.common.service; + +import com.imyeyu.api.modules.common.entity.Notify; +import com.imyeyu.api.modules.common.entity.NotifyDetail; + +/** + * + * + * @author 夜雨 + * @since 2026-05-14 12:12 + */ +public interface SmsService { + + /** + * 创建短信验证码通知 + * + * @param detail 通知 + * @return 通知详情 ID + */ + String captchaCreate(Notify detail); + + /** + * 校验短信验证码 + * + * @param detailId 通知详情 ID + * @param captcha 请求验证码 + * @return 通知详情 + */ + NotifyDetail captchaVerify(String detailId, String captcha); +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/SmsQueueServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/SmsQueueServiceImplement.java index 670b515..ba7cdd5 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/implement/SmsQueueServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/SmsQueueServiceImplement.java @@ -1,6 +1,5 @@ package com.imyeyu.api.modules.common.service.implement; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.imyeyu.api.modules.common.config.NotifyQueueConfig; import com.imyeyu.api.modules.common.entity.NotifyDetail; @@ -11,9 +10,9 @@ import com.imyeyu.api.modules.common.service.SmsQueueService; import com.imyeyu.api.modules.common.util.QCloudSmsProxy; import com.imyeyu.java.bean.timi.TimiCode; import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.utils.Regex; import com.tencentcloudapi.sms.v20210111.models.SendSmsResponse; import com.tencentcloudapi.sms.v20210111.models.SendStatus; -import com.imyeyu.utils.Regex; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -56,12 +55,7 @@ public class SmsQueueServiceImplement extends AbstractQueueService redisUserToken; @@ -104,20 +102,15 @@ public class UserLoginServiceImplement implements UserLoginService, TimiJava { @Override - public LoginResponse loginBySms(CaptchaVerifyReq req) { - try { - NotifyDetail detail = smsNotifyService.captchaVerify(req); - // 无报错即成功 - User user = userService.getByPhoneNo(detail.getSendTo()); - if (!user.isValidPhone()) { - user.setPhoneNoVerifyAt(Time.now()); - userService.update(user); - } - return login(UUID.randomUUID().toString(), user.getId()); - } catch (IOException e) { - log.error("login by sms error", e); - throw new TimiException(TimiCode.ERROR, "验证短信失败"); + public LoginResponse loginBySms(SmsCaptchaVerifyRequest request) { + NotifyDetail detail = smsService.captchaVerify(request.getDetailId(), request.getCaptcha()); + // 无报错即成功 + User user = userService.getByPhoneNo(detail.getSendTo()); + if (!user.isValidPhone()) { + user.setPhoneNoVerifyAt(Time.now()); + userService.update(user); } + return login(UUID.randomUUID().toString(), user.getId()); } @Override From 3a3809b6610885fae3a745eee2fa46c9cc25cb61 Mon Sep 17 00:00:00 2001 From: Timi Date: Fri, 21 Aug 2026 21:54:09 +0800 Subject: [PATCH 2/5] fix gao user invite --- .../implement/SmsServiceImplement.java | 3 +++ .../gao/controller/GaoUserController.java | 4 +--- .../gao/service/GaoUserInviteService.java | 4 +++- .../GaoUserInviteServiceImplement.java | 24 +++++++++++++++---- .../gao/vo/GaoUserInviteRegisterRequest.java | 21 ++++++++++++---- 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/SmsServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/SmsServiceImplement.java index 1f1f0d9..395d4ac 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/implement/SmsServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/SmsServiceImplement.java @@ -65,6 +65,9 @@ public class SmsServiceImplement implements SmsService { NotifyQueueConfig.Sms.Captcha captchaConf = config.getSms().getCaptcha(); NotifyDetail detail = notifyDetailService.get(detailId); + TimiException.required(detail, "短信验证码无效"); + TimiException.required(detail.getSendAt(), "短信验证码无效"); + TimiException.requiredTrue(NotifyDetail.MsgType.SMS == detail.getMsgType(), "短信验证码无效"); if (Time.M * captchaConf.getTtlMinute() < Time.now() - detail.getSendAt()) { throw new TimiException(TimiCode.ARG_EXPIRED, "验证码过期"); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoUserController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoUserController.java index ced8d8b..8d44978 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoUserController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoUserController.java @@ -27,7 +27,6 @@ import com.imyeyu.api.modules.user.service.UserService; import com.imyeyu.api.modules.user.vo.LoginResponse; import com.imyeyu.java.TimiJava; import com.imyeyu.spring.annotation.AOPLog; -import com.imyeyu.spring.annotation.CaptchaValid; import com.imyeyu.spring.annotation.RequestRateLimit; import com.imyeyu.spring.bean.Page; import com.imyeyu.spring.bean.PageResult; @@ -80,12 +79,11 @@ public class GaoUserController { @AOPLog @JsonView(ResponseView.Public.class) - @CaptchaValid @RequestRateLimit(value = 5, inSeconds = 60) @PostMapping("/invite/register") public LoginResponse registerByInvite(@RequestBody @Valid GaoUserInviteRegisterRequest req) { GaoUserInviteRegisterData data = req.getData(); - String userId = inviteService.register(data); + String userId = inviteService.register(data, req.getDetailId(), req.getCaptcha()); return userLoginService.login(UUID.randomUUID().toString(), userId); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoUserInviteService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoUserInviteService.java index 51b466b..bedcac9 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/GaoUserInviteService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoUserInviteService.java @@ -26,6 +26,8 @@ public interface GaoUserInviteService { /// 使用邀请注册 GAO 用户 /// /// @param data 注册数据 + /// @param smsDetailId 短信通知详情 ID + /// @param smsCaptcha 短信验证码 /// @return 注册成功的用户 ID - String register(GaoUserInviteRegisterData data); + String register(GaoUserInviteRegisterData data, String smsDetailId, String smsCaptcha); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoUserInviteServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoUserInviteServiceImplement.java index ff94740..9b28eac 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoUserInviteServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoUserInviteServiceImplement.java @@ -3,6 +3,8 @@ package com.imyeyu.api.modules.gao.service.implement; import com.imyeyu.api.bean.CoreRoleCode; import com.imyeyu.api.bean.ModuleCode; import com.imyeyu.api.config.dbsource.TimiServerDBConfig; +import com.imyeyu.api.modules.common.entity.NotifyDetail; +import com.imyeyu.api.modules.common.service.SmsService; import com.imyeyu.api.modules.gao.bean.GaoUserInviteCache; import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; import com.imyeyu.api.modules.gao.bean.GaoRoleCode; @@ -69,6 +71,7 @@ public class GaoUserInviteServiceImplement implements GaoUserInviteService { private final GaoStoreService gaoStoreService; private final GaoUserService gaoUserService; + private final SmsService smsService; private final UserLoginService userLoginService; private final UserRoleService userRoleService; private final UserService userService; @@ -98,9 +101,11 @@ public class GaoUserInviteServiceImplement implements GaoUserInviteService { logger.setUserId(inviterUserId); permissionChecker.checkAny(ModuleCode.GAO, GaoPermissionCode.USER_CREATE.getValue()); Role requestedRole = roleService.get(req.getRoleId()); - boolean adminInviteGlobalManager = roleChecker.hasAny(ModuleCode.CORE, CoreRoleCode.ADMIN.name()) + boolean coreAdmin = isCoreAdmin(); + boolean adminInviteGlobalManager = coreAdmin && isGlobalManagerRole(requestedRole); boolean globalManager = authorizationScopeService.isSystemAdmin(inviterUserId) + || coreAdmin || roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name()); boolean storeManager = !globalManager; GaoStore loginStore = storeManager ? gaoStoreService.getByUserId(inviterUserId) : null; @@ -154,7 +159,7 @@ public class GaoUserInviteServiceImplement implements GaoUserInviteService { @Transactional(TimiServerDBConfig.ROLLBACKER) @Override - public String register(GaoUserInviteRegisterData data) { + public String register(GaoUserInviteRegisterData data, String smsDetailId, String smsCaptcha) { TimiException.required(data, "not found register data"); String code = data.getCode(); TimiException.required(code, "not found code"); @@ -170,6 +175,9 @@ public class GaoUserInviteServiceImplement implements GaoUserInviteService { cache = requireValidInvite(code); logger.setContent(buildLogContent(cache)); validateInviteTarget(cache); + NotifyDetail smsDetail = smsService.captchaVerify(smsDetailId, smsCaptcha); + TimiException.requiredTrue(NotifyDetail.MsgType.SMS == smsDetail.getMsgType() + && data.getPhoneNo().equals(smsDetail.getSendTo()), "短信验证码与手机号不匹配"); TimiException.requiredNull(userService.getByName(data.getName()), "existed user name"); TimiException.requiredNull(userService.getByPhoneNo(data.getPhoneNo()), "existed phone number"); if (!redisInvite.destroy(getInviteKey(code))) { @@ -264,10 +272,11 @@ public class GaoUserInviteServiceImplement implements GaoUserInviteService { .stream() .map(Role::getId) .toList(); - boolean descendant = authorizationScopeService.listManageableRole(inviterUserId, ModuleCode.GAO) + boolean adminInviteGlobalManager = isCoreAdmin() && isGlobalManagerRole(role); + boolean descendant = adminInviteGlobalManager || (authorizationScopeService.listManageableRole(inviterUserId, ModuleCode.GAO) .stream() .anyMatch(item -> role.getId().equals(item.getId())) - && !directRoleIdList.contains(role.getId()); + && !directRoleIdList.contains(role.getId())); if (!descendant) { throw new TimiException(TimiCode.PERMISSION_ERROR, "角色不在可邀请范围内"); } @@ -302,7 +311,8 @@ public class GaoUserInviteServiceImplement implements GaoUserInviteService { } private void checkStoreScope(String userId, String storeId) { - if (authorizationScopeService.isSystemAdmin(userId) || roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name())) { + if (authorizationScopeService.isSystemAdmin(userId) || isCoreAdmin() + || roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name())) { return; } GaoStore userStore = gaoStoreService.getByUserId(userId); @@ -346,4 +356,8 @@ public class GaoUserInviteServiceImplement implements GaoUserInviteService { private TimiException invalidInvite() { return new TimiException(TimiCode.ARG_BAD, "邀请码无效或已过期"); } + + private boolean isCoreAdmin() { + return roleChecker.hasAny(ModuleCode.CORE, CoreRoleCode.ADMIN.name()); + } } diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoUserInviteRegisterRequest.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoUserInviteRegisterRequest.java index d13cce7..f4a9fa5 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoUserInviteRegisterRequest.java +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoUserInviteRegisterRequest.java @@ -1,20 +1,33 @@ package com.imyeyu.api.modules.gao.vo; -import com.imyeyu.spring.bean.CaptchaData; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; /// GAO 用户邀请注册请求 /// /// @author Codex /// @since 2026-08-13 -public class GaoUserInviteRegisterRequest extends CaptchaData { +@Data +public class GaoUserInviteRegisterRequest { + + /// 短信通知详情 ID + @NotBlank + private String detailId; + + /// 短信验证码 + @NotBlank + @Pattern(regexp = "\\d{6}") + private String captcha; + + private GaoUserInviteRegisterData data; /// 级联校验注册数据 - @Override @NotNull @Valid public GaoUserInviteRegisterData getData() { - return super.getData(); + return data; } } From 2e9225852d6ed0917ba60931fe3fbca00a978944 Mon Sep 17 00:00:00 2001 From: Timi Date: Fri, 21 Aug 2026 23:43:40 +0800 Subject: [PATCH 3/5] 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} + + From fcae2eac09ebd52d35d88f91ddd70231cca1aec0 Mon Sep 17 00:00:00 2001 From: Timi Date: Fri, 21 Aug 2026 23:51:38 +0800 Subject: [PATCH 4/5] fix gao user invite validate --- .../implement/GaoUserInviteServiceImplement.java | 16 ++++++++++++---- .../gao/vo/GaoUserInviteValidateResponse.java | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoUserInviteServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoUserInviteServiceImplement.java index 9b28eac..3d40765 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoUserInviteServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoUserInviteServiceImplement.java @@ -66,6 +66,7 @@ public class GaoUserInviteServiceImplement implements GaoUserInviteService { private static final long REGISTER_RATE_LIMIT_TTL = Time.M * 10; private static final int CODE_RATE_LIMIT = 30; private static final int REGISTER_VALUE_RATE_LIMIT = 10; + private static final String INVALID_INVITE_MESSAGE = "邀请码无效或已过期"; private static final SecureRandom SECURE_RANDOM = new SecureRandom(); @@ -152,9 +153,16 @@ public class GaoUserInviteServiceImplement implements GaoUserInviteService { public GaoUserInviteValidateResponse validate(String code) { TimiException.required(code, "not found code"); checkCodeRateLimit(code); - GaoUserInviteCache cache = requireValidInvite(code); - validateInviteTarget(cache); - return new GaoUserInviteValidateResponse(true, cache.getExpireAt()); + try { + GaoUserInviteCache cache = requireValidInvite(code); + validateInviteTarget(cache); + return new GaoUserInviteValidateResponse(true, cache.getExpireAt()); + } catch (TimiException e) { + if (TimiCode.ARG_BAD == e.getCode() && INVALID_INVITE_MESSAGE.equals(e.getMessage())) { + return new GaoUserInviteValidateResponse(false, -1L); + } + throw e; + } } @Transactional(TimiServerDBConfig.ROLLBACKER) @@ -354,7 +362,7 @@ public class GaoUserInviteServiceImplement implements GaoUserInviteService { } private TimiException invalidInvite() { - return new TimiException(TimiCode.ARG_BAD, "邀请码无效或已过期"); + return new TimiException(TimiCode.ARG_BAD, INVALID_INVITE_MESSAGE); } private boolean isCoreAdmin() { diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoUserInviteValidateResponse.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoUserInviteValidateResponse.java index e6197ae..5c9ff40 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoUserInviteValidateResponse.java +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoUserInviteValidateResponse.java @@ -14,6 +14,6 @@ public class GaoUserInviteValidateResponse { /// true 为邀请码可用 private boolean valid; - /// 邀请过期时间,毫秒时间戳 + /// 邀请过期时间,毫秒时间戳;valid 为 false 时为 0 private Long expireAt; } From fc62663f2c15644d9c7091629eb349bfd160e0a0 Mon Sep 17 00:00:00 2001 From: Timi Date: Sat, 22 Aug 2026 01:27:28 +0800 Subject: [PATCH 5/5] v1.0.22 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5fd7585..5ec527b 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.imyeyu.timiserverapi TimiServerAPI - 1.0.21 + 1.0.22 jar TimiServerAPI imyeyu.com API