diff --git a/pom.xml b/pom.xml index 84f973d..a1aec18 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.imyeyu.timiserverapi TimiServerAPI - 1.0.17 + 1.0.18 jar TimiServerAPI imyeyu.com API @@ -240,6 +240,15 @@ e2ins-msg-notify-service-sdk 0.0.9 + + com.tencentcloudapi + tencentcloud-sdk-java-sms + 3.1.1281 + + + org.springframework.boot + spring-boot-starter-mail + org.springframework.boot spring-boot-starter-freemarker diff --git a/src/main/java/com/imyeyu/api/bean/CorePermissionCode.java b/src/main/java/com/imyeyu/api/bean/CorePermissionCode.java index 2668094..40d966a 100644 --- a/src/main/java/com/imyeyu/api/bean/CorePermissionCode.java +++ b/src/main/java/com/imyeyu/api/bean/CorePermissionCode.java @@ -55,6 +55,11 @@ public enum CorePermissionCode implements BuiltinPermissionCode { FEEDBACK_UPDATE("FEEDBACK:UPDATE", "反馈修改", false, true), FEEDBACK_DELETE("FEEDBACK:DELETE", "反馈删除", false, true), + NOTIFY_CREATE("NOTIFY:CREATE", "通知创建", false, true), + NOTIFY_READ("NOTIFY:READ", "通知读取", false, true), + NOTIFY_DELETE("NOTIFY:DELETE", "通知删除", false, true), + NOTIFY_CANCEL("NOTIFY:CANCEL", "通知取消", false, true), + USER_ROLE_CREATE("USER_ROLE:CREATE", "用户角色创建", true, true), USER_ROLE_READ("USER_ROLE:READ", "用户角色读取", true, true), USER_ROLE_DELETE("USER_ROLE:DELETE", "用户角色删除", true, true); diff --git a/src/main/java/com/imyeyu/api/modules/common/config/NotifyQueueConfig.java b/src/main/java/com/imyeyu/api/modules/common/config/NotifyQueueConfig.java new file mode 100644 index 0000000..3f35403 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/config/NotifyQueueConfig.java @@ -0,0 +1,68 @@ +package com.imyeyu.api.modules.common.config; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/// 通知消息队列配置 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Data +@Configuration +@ConfigurationProperties(prefix = "notify.queue") +public class NotifyQueueConfig { + + private Mail mail = new Mail(); + + private Sms sms = new Sms(); + + private Wechat wechat = new Wechat(); + + private HTTP http = new HTTP(); + + /// 邮件队列配置 + @Data + @EqualsAndHashCode(callSuper = true) + public static class Mail extends Queue { + + private String defaultFrom; + } + + /// 短信队列配置 + @Data + @EqualsAndHashCode(callSuper = true) + public static class Sms extends Queue { + + private String appId; + + private String sign; + + private String secretId; + + private String secretKey; + } + + /// 微信队列配置 + @Data + @EqualsAndHashCode(callSuper = true) + public static class Wechat extends Queue { + } + + /// HTTP 队列配置 + @Data + @EqualsAndHashCode(callSuper = true) + public static class HTTP extends Queue { + } + + /// 通用队列配置 + @Data + public static class Queue { + + /// true 为启用 + private boolean enable; + + private String cron = "0/30 * * * * ?"; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/controller/FeedbackController.java b/src/main/java/com/imyeyu/api/modules/common/controller/FeedbackController.java index 9ae8e40..76d42de 100644 --- a/src/main/java/com/imyeyu/api/modules/common/controller/FeedbackController.java +++ b/src/main/java/com/imyeyu/api/modules/common/controller/FeedbackController.java @@ -36,21 +36,19 @@ public class FeedbackController { /// 提交反馈。登录用户和匿名用户均需提交图形验证码,匿名用户按 IP 限流 /// - /// @param req 带图形验证码的反馈请求 - /// @return 已创建反馈 + /// @param captchaData 带图形验证码的反馈请求 @AOPLog @JsonView(ResponseView.Public.class) @CaptchaValid @RequestRateLimit(value = 3, inSeconds = 60) - @PostMapping("/submit") - public Feedback submit(@RequestBody @Valid CaptchaData req) { - Feedback feedback = req.getData(); + @PostMapping("/create") + public void create(@RequestBody @Valid CaptchaData captchaData) { + Feedback feedback = captchaData.getData(); feedback.setIp(TimiSpring.getRequestIP()); - if (userLoginService.getLoginUser() != null) { - feedback.setUserId(userLoginService.getLoginUser().getId()); + if (userLoginService.isLogged()) { + feedback.setUserId(userLoginService.getRequireLoginUserId()); } service.create(feedback); - return feedback; } /// 查询反馈列表 @@ -77,20 +75,6 @@ public class FeedbackController { return service.get(id); } - /// 后台创建反馈 - /// - /// @param feedback 反馈 - /// @return 反馈 - @AOPLog - @JsonView(ResponseView.Admin.class) - @RequestRateLimit - @RequireCorePermission(CorePermissionCode.FEEDBACK_CREATE) - @PostMapping("/create") - public Feedback create(@RequestBody Feedback feedback) { - service.create(feedback); - return feedback; - } - /// 更新反馈 /// /// @param feedback 反馈 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 new file mode 100644 index 0000000..720f4cb --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/controller/NotifyController.java @@ -0,0 +1,174 @@ +package com.imyeyu.api.modules.common.controller; + +import com.fasterxml.jackson.annotation.JsonView; +import com.imyeyu.api.annotation.RequireCorePermission; +import com.imyeyu.api.bean.CorePermissionCode; +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.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.RequiredToken; +import com.imyeyu.spring.bean.Page; +import com.imyeyu.spring.bean.PageResult; +import com.imyeyu.spring.util.ResponseView; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/// 通知接口 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@RestController +@RequiredArgsConstructor +@RequestMapping("/notify") +public class NotifyController { + + private final UserService userService; + private final NotifyService service; + private final UserLoginService userLoginService; + private final NotifyDetailService detailService; + + /// 创建通知,同时创建通知详情 + /// + /// @param notify 通知及通知详情 + /// @return 创建后的通知 + @RequireCorePermission(CorePermissionCode.NOTIFY_CREATE) + @RequiredToken + @PostMapping("/create") + public Notify create(@RequestBody Notify notify) { + service.create(notify); + return notify; + } + + /// 查询通知详情 + /// + /// @param id 通知 ID + /// @return 通知及通知详情 + @JsonView(ResponseView.Public.class) + @RequireCorePermission(CorePermissionCode.NOTIFY_READ) + @RequiredToken + @RequestMapping("/detail") + public Notify detail(@RequestParam String id) { + Notify notify = service.get(id); + { + List detailList = detailService.listByNotifyId(id); + Set userIdSet = detailList.stream() + .filter(item -> item.getMsgType() == NotifyDetail.MsgType.INTERNAL) + .flatMap(item -> Stream.of(item.getSendFrom(), item.getSendTo())) + .collect(Collectors.toSet()); + Map userMap = userService.mapByIdList(userIdSet); + for (NotifyDetail detail : detailList) { + if (TimiJava.isNotEmpty(detail.getSendFrom())) { + detail.setFromUser(userMap.get(detail.getSendFrom())); + } + if (TimiJava.isNotEmpty(detail.getSendTo())) { + detail.setToUser(userMap.get(detail.getSendTo())); + } + } + notify.setDetailList(detailList); + } + return notify; + } + + /// 删除通知及其通知详情 + /// + /// @param id 通知 ID + @RequireCorePermission(CorePermissionCode.NOTIFY_DELETE) + @RequiredToken + @PostMapping("/delete") + public void delete(@RequestParam String id) { + service.delete(id); + } + + /// 取消尚未成功发送的通知详情 + /// + /// @param id 通知 ID + @RequireCorePermission(CorePermissionCode.NOTIFY_CANCEL) + @RequiredToken + @PostMapping("/cancel") + public void cancel(@RequestParam String id) { + service.cancel(id); + } + + /// 查询当前用户的站内通知 + /// + /// @param page 分页参数 + /// @param unreadOnly true 为只查询未读通知 + /// @return 站内通知分页结果 + @RequireCorePermission(CorePermissionCode.NOTIFY_READ) + @RequiredToken + @PostMapping("/internal/internal") + public PageResult listInternal( + @RequestBody(required = false) Page page, + @RequestParam(defaultValue = "false") boolean unreadOnly + ) { + return detailService.pageInternalByUserId(page, userLoginService.getRequireLoginUserId(), unreadOnly); + } + + @JsonView(ResponseView.Public.class) + @RequireCorePermission(CorePermissionCode.NOTIFY_READ) + @RequiredToken + @PostMapping("/internal/detail") + public NotifyDetail internalDetail(@RequestParam String id) { + NotifyDetail detail = detailService.getInternalByUserId(id, userLoginService.getRequireLoginUserId()); + if (TimiJava.isNotEmpty(detail.getSendFrom())) { + detail.setFromUser(userService.get(detail.getSendFrom())); + } + if (TimiJava.isNotEmpty(detail.getSendTo())) { + detail.setToUser(userService.get(detail.getSendTo())); + } + return detail; + } + + /// 删除当前用户的一条站内通知 + /// + /// @param id 通知详情 ID + @RequireCorePermission(CorePermissionCode.NOTIFY_READ) + @RequiredToken + @PostMapping("/internal/delete") + public void deleteInternal(@RequestParam String id) { + detailService.deleteInternalByUserId(id, userLoginService.getRequireLoginUserId()); + } + + /// 查询当前用户未读通知数量 + /// + /// @return 未读通知数量 + @RequireCorePermission(CorePermissionCode.NOTIFY_READ) + @RequiredToken + @PostMapping("/internal/unread/count") + public long countUnreadSite() { + return detailService.countUnreadSiteByUserId(userLoginService.getRequireLoginUserId()); + } + + /// 标记当前用户的一条站内通知已读 + /// + /// @param id 通知详情 ID + @RequireCorePermission(CorePermissionCode.NOTIFY_READ) + @RequiredToken + @PostMapping("/internal/read") + public void markSiteRead(@RequestParam String id) { + detailService.markSiteRead(userLoginService.getRequireLoginUserId(), id); + } + + /// 标记当前用户全部站内通知已读 + @RequireCorePermission(CorePermissionCode.NOTIFY_READ) + @RequiredToken + @PostMapping("/internal/read/all") + public void markAllSiteRead() { + detailService.markAllSiteRead(userLoginService.getRequireLoginUserId()); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/Article.java b/src/main/java/com/imyeyu/api/modules/common/entity/Article.java index 52b9973..f7b4aec 100644 --- a/src/main/java/com/imyeyu/api/modules/common/entity/Article.java +++ b/src/main/java/com/imyeyu/api/modules/common/entity/Article.java @@ -52,6 +52,19 @@ public class Article extends UUIDEntity implements SettingSupport { TIP_TAP } + /// 文章状态 + public enum Status { + + /// 草稿 + DART, + + /// 正常 + NORMAL, + + /// 隐藏 + HIDDEN + } + /** 业务类型 */ protected BizType bizType; @@ -61,8 +74,8 @@ public class Article extends UUIDEntity implements SettingSupport { /** 标题 */ protected String title; - /** 摘要 */ - protected String digest; + /** 描述 */ + protected String description; /** 正文类型 */ protected ContentType contentType; @@ -77,6 +90,9 @@ public class Article extends UUIDEntity implements SettingSupport { /** 喜欢数量 */ protected Integer likes; + /** 文章状态 */ + protected Status status; + @Transient protected List settingList; 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 new file mode 100644 index 0000000..b49a04c --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/entity/BaseQueueEntity.java @@ -0,0 +1,60 @@ +package com.imyeyu.api.modules.common.entity; + +import com.imyeyu.spring.annotation.table.AutoUUID; +import com.imyeyu.spring.annotation.table.Id; +import com.imyeyu.spring.entity.Creatable; +import com.imyeyu.spring.entity.Destroyable; +import com.imyeyu.spring.entity.IDEntity; +import lombok.Data; +import lombok.NoArgsConstructor; + +/// 通知基础队列实体 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Data +@NoArgsConstructor +public class BaseQueueEntity implements IDEntity, Creatable, Destroyable { + + /** 主键 */ + @Id + @AutoUUID + protected String id; + + /** 所属通知详情 ID */ + protected String notifyDetailId; + + /** 内容 */ + protected String data; + + /** 参数 */ + protected String args; + + /** 发送去向 */ + protected String sendTo; + + /** 发送时间 */ + protected Long sendAt; + + /** 剩余重试次数 */ + protected Integer retry; + + /** 超时时间 */ + protected Long timeoutAt; + + /** 创建时间 */ + protected Long createdAt; + + /// 从通知详情创建队列实体 + /// + /// @param detail 通知详情 + public BaseQueueEntity(NotifyDetail detail) { + notifyDetailId = detail.getId(); + data = detail.getData(); + args = detail.getArgs(); + sendTo = detail.getSendTo(); + sendAt = detail.getSendAt(); + retry = detail.getRetry(); + timeoutAt = detail.getTimeoutAt(); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/HTTPQueue.java b/src/main/java/com/imyeyu/api/modules/common/entity/HTTPQueue.java new file mode 100644 index 0000000..009f01d --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/entity/HTTPQueue.java @@ -0,0 +1,26 @@ +package com.imyeyu.api.modules.common.entity; + +import com.imyeyu.spring.annotation.table.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/// HTTP 回调队列 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Data +@EqualsAndHashCode(callSuper = true) +@Table("http_queue") +public class HTTPQueue extends BaseQueueEntity { + + /** HTTP 请求方法 */ + protected String method; + + /// 从通知详情创建 HTTP 队列 + /// + /// @param detail 通知详情 + public HTTPQueue(NotifyDetail detail) { + super(detail); + method = "POST"; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/MailQueue.java b/src/main/java/com/imyeyu/api/modules/common/entity/MailQueue.java new file mode 100644 index 0000000..eb6458c --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/entity/MailQueue.java @@ -0,0 +1,54 @@ +package com.imyeyu.api.modules.common.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/// 邮件发送队列 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class MailQueue extends BaseQueueEntity { + + /** 邮件标题 */ + protected String subject; + + /** 邮件附件 */ + protected String attachments; + + /** 发送来源 */ + protected String sendFrom; + + /// 从通知详情创建邮件队列 + /// + /// @param detail 通知详情 + public MailQueue(NotifyDetail detail) { + super(detail); + subject = detail.getSubject(); + sendFrom = detail.getSendFrom(); + } + + /// 邮件附件 + @Data + public static class Attachment { + + /** 附件类型 */ + private Type type; + + /** 附件值 */ + private String value; + + /// 附件来源类型 + public enum Type { + + /** 系统附件 */ + E2INS_ATTACH, + + /** URL 附件 */ + URL + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/Notify.java b/src/main/java/com/imyeyu/api/modules/common/entity/Notify.java index 286f6a9..95b0ce3 100644 --- a/src/main/java/com/imyeyu/api/modules/common/entity/Notify.java +++ b/src/main/java/com/imyeyu/api/modules/common/entity/Notify.java @@ -1,7 +1,5 @@ package com.imyeyu.api.modules.common.entity; -import com.imyeyu.spring.annotation.table.AutoUUID; -import com.imyeyu.spring.annotation.table.Id; import com.imyeyu.spring.annotation.table.Transient; import com.imyeyu.spring.entity.UUIDEntity; import lombok.Data; @@ -19,11 +17,6 @@ import java.util.List; @EqualsAndHashCode(callSuper = true) public class Notify extends UUIDEntity { - /** 主键 */ - @Id - @AutoUUID - private String id; - /** 业务类型 */ private String bizType; 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 48615ed..96482d5 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,7 +1,16 @@ package com.imyeyu.api.modules.common.entity; +import com.fasterxml.jackson.annotation.JsonView; +import com.imyeyu.api.TimiServerAPI; +import com.imyeyu.api.modules.common.service.BaseQueueService; +import com.imyeyu.api.modules.common.service.HTTPQueueService; +import com.imyeyu.api.modules.common.service.MailQueueService; +import com.imyeyu.api.modules.common.service.SmsQueueService; +import com.imyeyu.api.modules.common.service.WechatQueueService; +import com.imyeyu.api.modules.user.entity.User; import com.imyeyu.spring.annotation.table.Transient; import com.imyeyu.spring.entity.UUIDEntity; +import com.imyeyu.spring.util.ResponseView; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -25,13 +34,32 @@ public class NotifyDetail extends UUIDEntity { @AllArgsConstructor public enum MsgType { - SMS, + /** 站内通知,不进入外部消息队列 */ + INTERNAL(null), - MAIL, + SMS(SmsQueueService.class), - HTTP, + MAIL(MailQueueService.class), - WECHAT; + HTTP(HTTPQueueService.class), + + WECHAT(WechatQueueService.class); + + private final Class> queueServiceClass; + + /// 获取消息队列服务 + /// + /// @return 消息队列服务 + public BaseQueueService getService() { + return TimiServerAPI.applicationContext.getBean(queueServiceClass); + } + + /// 判断是否需要外部消息队列 + /// + /// @return true 为需要外部消息队列 + public boolean isQueue() { + return queueServiceClass != null; + } } /** @@ -54,44 +82,67 @@ public class NotifyDetail extends UUIDEntity { } /** 所属通知 ID */ + @JsonView(ResponseView.Public.class) private String notifyId; /** 消息类型 */ + @JsonView(ResponseView.Public.class) private MsgType msgType; /** 模板 ID */ + @JsonView(ResponseView.Admin.class) private String templateId; /** 标题 */ + @JsonView(ResponseView.Public.class) private String subject; /** 内容 */ + @JsonView(ResponseView.Public.class) private String data; /** 参数 */ + @JsonView(ResponseView.Public.class) private String args; /** 发送来源 */ + @JsonView(ResponseView.Public.class) private String sendFrom; /** 发送去向 */ + @JsonView(ResponseView.Public.class) private String sendTo; /** 发送时间 */ + @JsonView(ResponseView.Public.class) private Long sendAt; /** 重试计数 */ + @JsonView(ResponseView.Admin.class) private Integer retry; /** 超时时间 */ + @JsonView(ResponseView.Admin.class) private Long timeoutAt; /** 结果描述 */ + @JsonView(ResponseView.Admin.class) private String resultDesc; /** 状态 */ + @JsonView(ResponseView.Public.class) private Status status; + /** 已读时间,站内通知未读时为空 */ + @JsonView(ResponseView.Public.class) + private Long readAt; + @Transient protected Notify notify; + + @Transient + protected User fromUser; + + @Transient + protected User toUser; } diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/SmsQueue.java b/src/main/java/com/imyeyu/api/modules/common/entity/SmsQueue.java new file mode 100644 index 0000000..e66f7cc --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/entity/SmsQueue.java @@ -0,0 +1,26 @@ +package com.imyeyu.api.modules.common.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/// 短信发送队列 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class SmsQueue extends BaseQueueEntity { + + /** 短信模板 ID */ + protected String templateId; + + /// 从通知详情创建短信队列 + /// + /// @param detail 通知详情 + public SmsQueue(NotifyDetail detail) { + super(detail); + templateId = detail.getTemplateId(); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/WechatQueue.java b/src/main/java/com/imyeyu/api/modules/common/entity/WechatQueue.java new file mode 100644 index 0000000..0165182 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/entity/WechatQueue.java @@ -0,0 +1,20 @@ +package com.imyeyu.api.modules.common.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/// 微信消息发送队列 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Data +@EqualsAndHashCode(callSuper = true) +public class WechatQueue extends BaseQueueEntity { + + /// 从通知详情创建微信队列 + /// + /// @param detail 通知详情 + public WechatQueue(NotifyDetail detail) { + super(detail); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/BaseQueueMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/BaseQueueMapper.java new file mode 100644 index 0000000..5ac239d --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/BaseQueueMapper.java @@ -0,0 +1,26 @@ +package com.imyeyu.api.modules.common.mapper; + +import com.imyeyu.api.modules.common.entity.BaseQueueEntity; +import com.imyeyu.api.modules.common.util.QueueSQLProvider; +import com.imyeyu.spring.mapper.BaseMapper; +import org.apache.ibatis.annotations.DeleteProvider; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.SelectProvider; + +import java.util.List; + +/// 通知队列基础 Mapper +/// +/// @param 队列实体类型 +/// @author 夜雨 +/// @since 2026-08-20 +public interface BaseQueueMapper extends BaseMapper { + + /** 查询已到发送时间的队列 */ + @SelectProvider(type = QueueSQLProvider.class, method = "listReadyQueue") + List listReadyQueue(); + + /** 根据通知详情 ID 物理删除队列 */ + @DeleteProvider(type = QueueSQLProvider.class, method = "destroyByNotifyDetailId") + void destroyByNotifyDetailId(@Param("notifyDetailId") String notifyDetailId); +} diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/HTTPQueueMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/HTTPQueueMapper.java new file mode 100644 index 0000000..2d113a2 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/HTTPQueueMapper.java @@ -0,0 +1,10 @@ +package com.imyeyu.api.modules.common.mapper; + +import com.imyeyu.api.modules.common.entity.HTTPQueue; + +/// HTTP 队列 Mapper +/// +/// @author 夜雨 +/// @since 2026-08-20 +public interface HTTPQueueMapper extends BaseQueueMapper { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/MailQueueMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/MailQueueMapper.java new file mode 100644 index 0000000..47fa471 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/MailQueueMapper.java @@ -0,0 +1,10 @@ +package com.imyeyu.api.modules.common.mapper; + +import com.imyeyu.api.modules.common.entity.MailQueue; + +/// 邮件队列 Mapper +/// +/// @author 夜雨 +/// @since 2026-08-20 +public interface MailQueueMapper extends BaseQueueMapper { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/NotifyDetailMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/NotifyDetailMapper.java index b4ada09..d17daf5 100644 --- a/src/main/java/com/imyeyu/api/modules/common/mapper/NotifyDetailMapper.java +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/NotifyDetailMapper.java @@ -2,6 +2,11 @@ package com.imyeyu.api.modules.common.mapper; import com.imyeyu.api.modules.common.entity.NotifyDetail; import com.imyeyu.spring.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; /** * 通知详情 Mapper @@ -10,4 +15,36 @@ import com.imyeyu.spring.mapper.BaseMapper; * @since 2026-05-13 14:46 */ public interface NotifyDetailMapper extends BaseMapper { + + /** 查询用户的站内通知 */ + @Select("SELECT * FROM `notify_detail` WHERE `msg_type` = 'INTERNAL' AND `send_to` = #{userId} " + NOT_DELETE + " ORDER BY `created_at` DESC LIMIT #{offset}, #{limit}") + List selectSiteByUserId(@Param("userId") String userId, @Param("offset") long offset, @Param("limit") long limit); + + /** 查询用户的未读站内通知 */ + @Select("SELECT * FROM `notify_detail` WHERE `msg_type` = 'INTERNAL' AND `send_to` = #{userId} AND `read_at` IS NULL " + NOT_DELETE + " ORDER BY `created_at` DESC LIMIT #{offset}, #{limit}") + List selectUnreadSiteByUserId(@Param("userId") String userId, @Param("offset") long offset, @Param("limit") long limit); + + /** 统计用户的站内通知数量 */ + @Select("SELECT COUNT(*) FROM `notify_detail` WHERE `msg_type` = 'INTERNAL' AND `send_to` = #{userId} " + NOT_DELETE) + long countSiteByUserId(@Param("userId") String userId); + + /** 统计用户的未读站内通知数量 */ + @Select("SELECT COUNT(*) FROM `notify_detail` WHERE `msg_type` = 'INTERNAL' AND `send_to` = #{userId} AND `read_at` IS NULL " + NOT_DELETE) + long countUnreadSiteByUserId(@Param("userId") String userId); + + /** 查询用户拥有的站内通知 */ + @Select("SELECT * FROM `notify_detail` WHERE `id` = #{detailId} AND `msg_type` = 'INTERNAL' AND `send_to` = #{userId} " + NOT_DELETE + " LIMIT 1") + NotifyDetail selectSiteByIdAndUserId(@Param("detailId") String detailId, @Param("userId") String userId); + + /** 删除用户的站内通知 */ + @Update("UPDATE `notify_detail` SET `deleted_at` = " + UNIX_TIME + ", `updated_at` = " + UNIX_TIME + " WHERE `id` = #{detailId} AND `msg_type` = 'INTERNAL' AND `send_to` = #{userId} " + NOT_DELETE) + int deleteSiteByIdAndUserId(@Param("detailId") String detailId, @Param("userId") String userId); + + /** 标记站内通知已读 */ + @Update("UPDATE `notify_detail` SET `read_at` = #{readAt}, `updated_at` = " + UNIX_TIME + " WHERE `id` = #{detailId} AND `msg_type` = 'INTERNAL' AND `send_to` = #{userId} AND `read_at` IS NULL " + NOT_DELETE) + int markSiteRead(@Param("detailId") String detailId, @Param("userId") String userId, @Param("readAt") long readAt); + + /** 标记用户全部站内通知已读 */ + @Update("UPDATE `notify_detail` SET `read_at` = #{readAt}, `updated_at` = " + UNIX_TIME + " WHERE `msg_type` = 'INTERNAL' AND `send_to` = #{userId} AND `read_at` IS NULL " + NOT_DELETE) + int markAllSiteRead(@Param("userId") String userId, @Param("readAt") long readAt); } diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/SmsQueueMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/SmsQueueMapper.java new file mode 100644 index 0000000..688ddf3 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/SmsQueueMapper.java @@ -0,0 +1,10 @@ +package com.imyeyu.api.modules.common.mapper; + +import com.imyeyu.api.modules.common.entity.SmsQueue; + +/// 短信队列 Mapper +/// +/// @author 夜雨 +/// @since 2026-08-20 +public interface SmsQueueMapper extends BaseQueueMapper { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/TagMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/TagMapper.java index c0a15e2..f3c0f26 100644 --- a/src/main/java/com/imyeyu/api/modules/common/mapper/TagMapper.java +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/TagMapper.java @@ -15,5 +15,23 @@ public interface TagMapper extends BaseMapper { List selectByIdList(Collection idList); + /// 按归属和名称关键词查询标签总数 + /// + /// @param ownerType 归属类型 + /// @param ownerId 归属 ID + /// @param keyword 名称关键词 + /// @return 标签总数 + long countByOwnerAndKeyword(@Param("ownerType") Tag.OwnerType ownerType, @Param("ownerId") String ownerId, @Param("keyword") String keyword); + + /// 按归属和名称关键词分页查询标签 + /// + /// @param ownerType 归属类型 + /// @param ownerId 归属 ID + /// @param keyword 名称关键词 + /// @param offset 分页偏移量 + /// @param limit 分页数量 + /// @return 标签列表 + List selectByOwnerAndKeyword(@Param("ownerType") Tag.OwnerType ownerType, @Param("ownerId") String ownerId, @Param("keyword") String keyword, @Param("offset") long offset, @Param("limit") long limit); + Tag selectByOwnerAndZhCN(@Param("ownerType") Tag.OwnerType ownerType, @Param("ownerId") String ownerId, @Param("zhCN") String zhCN); } diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/WechatQueueMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/WechatQueueMapper.java new file mode 100644 index 0000000..08ea01f --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/WechatQueueMapper.java @@ -0,0 +1,10 @@ +package com.imyeyu.api.modules.common.mapper; + +import com.imyeyu.api.modules.common.entity.WechatQueue; + +/// 微信队列 Mapper +/// +/// @author 夜雨 +/// @since 2026-08-20 +public interface WechatQueueMapper extends BaseQueueMapper { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/BaseQueueService.java b/src/main/java/com/imyeyu/api/modules/common/service/BaseQueueService.java new file mode 100644 index 0000000..3561da9 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/BaseQueueService.java @@ -0,0 +1,30 @@ +package com.imyeyu.api.modules.common.service; + +import com.imyeyu.api.modules.common.entity.BaseQueueEntity; +import com.imyeyu.api.modules.common.entity.NotifyDetail; + +/// 通知队列基础服务 +/// +/// @param 队列实体类型 +/// @author 夜雨 +/// @since 2026-08-20 +public interface BaseQueueService { + + /// 创建队列 + /// + /// @param detail 通知详情 + void createQueue(NotifyDetail detail); + + /// 删除队列 + /// + /// @param detail 通知详情 + void destroyQueue(NotifyDetail detail); + + /// 执行已到期队列 + void invokeQueue(); + + /// 发送队列消息 + /// + /// @param queue 队列 + void send(T queue); +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/HTTPQueueService.java b/src/main/java/com/imyeyu/api/modules/common/service/HTTPQueueService.java new file mode 100644 index 0000000..b116b9e --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/HTTPQueueService.java @@ -0,0 +1,10 @@ +package com.imyeyu.api.modules.common.service; + +import com.imyeyu.api.modules.common.entity.HTTPQueue; + +/// HTTP 队列服务 +/// +/// @author 夜雨 +/// @since 2026-08-20 +public interface HTTPQueueService extends BaseQueueService { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/MailQueueService.java b/src/main/java/com/imyeyu/api/modules/common/service/MailQueueService.java new file mode 100644 index 0000000..3ef32d2 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/MailQueueService.java @@ -0,0 +1,10 @@ +package com.imyeyu.api.modules.common.service; + +import com.imyeyu.api.modules.common.entity.MailQueue; + +/// 邮件队列服务 +/// +/// @author 夜雨 +/// @since 2026-08-20 +public interface MailQueueService extends BaseQueueService { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/NotifyDetailService.java b/src/main/java/com/imyeyu/api/modules/common/service/NotifyDetailService.java index 3fdf427..0380a52 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/NotifyDetailService.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/NotifyDetailService.java @@ -1,8 +1,12 @@ package com.imyeyu.api.modules.common.service; import com.imyeyu.api.modules.common.entity.NotifyDetail; +import com.imyeyu.spring.bean.Page; +import com.imyeyu.spring.bean.PageResult; import com.imyeyu.spring.service.BaseService; +import java.util.List; + /** * 通知详情服务 * @@ -10,4 +14,53 @@ import com.imyeyu.spring.service.BaseService; * @since 2026-05-13 14:46 */ public interface NotifyDetailService extends BaseService { + + /// 查询通知下的全部详情 + /// + /// @param notifyId 通知 ID + /// @return 通知详情列表 + List listByNotifyId(String notifyId); + + /// 重新将通知详情放入消息队列 + /// + /// @param detailId 通知详情 ID + void requeue(String detailId); + + /// 分页查询用户的站内通知 + /// + /// @param page 分页参数 + /// @param userId 用户 ID + /// @param unreadOnly true 为只查询未读通知 + /// @return 站内通知分页结果 + PageResult pageInternalByUserId(Page page, String userId, boolean unreadOnly); + + /// 查询当前用户拥有的站内通知详情 + /// + /// @param detailId 通知详情 ID + /// @param userId 用户 ID + /// @return 通知详情 + NotifyDetail getInternalByUserId(String detailId, String userId); + + /// 删除当前用户拥有的站内通知详情 + /// + /// @param detailId 通知详情 ID + /// @param userId 用户 ID + void deleteInternalByUserId(String detailId, String userId); + + /// 查询用户未读站内通知数量 + /// + /// @param userId 用户 ID + /// @return 未读数量 + long countUnreadSiteByUserId(String userId); + + /// 标记站内通知已读 + /// + /// @param userId 用户 ID + /// @param detailId 通知详情 ID + void markSiteRead(String userId, String detailId); + + /// 标记用户全部站内通知已读 + /// + /// @param userId 用户 ID + void markAllSiteRead(String userId); } diff --git a/src/main/java/com/imyeyu/api/modules/common/service/NotifyService.java b/src/main/java/com/imyeyu/api/modules/common/service/NotifyService.java index 80ebf9f..5f4da6a 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/NotifyService.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/NotifyService.java @@ -10,4 +10,9 @@ import com.imyeyu.spring.service.BaseService; * @since 2026-05-13 14:46 */ public interface NotifyService extends BaseService { + + /// 取消通知中尚未成功发送的通知详情 + /// + /// @param notifyId 通知 ID + void cancel(String notifyId); } diff --git a/src/main/java/com/imyeyu/api/modules/common/service/SmsQueueService.java b/src/main/java/com/imyeyu/api/modules/common/service/SmsQueueService.java new file mode 100644 index 0000000..9484859 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/SmsQueueService.java @@ -0,0 +1,10 @@ +package com.imyeyu.api.modules.common.service; + +import com.imyeyu.api.modules.common.entity.SmsQueue; + +/// 短信队列服务 +/// +/// @author 夜雨 +/// @since 2026-08-20 +public interface SmsQueueService extends BaseQueueService { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/TagService.java b/src/main/java/com/imyeyu/api/modules/common/service/TagService.java index e76f170..d0744a4 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/TagService.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/TagService.java @@ -2,6 +2,8 @@ package com.imyeyu.api.modules.common.service; import com.imyeyu.api.modules.common.entity.Tag; import com.imyeyu.api.modules.common.entity.TagApply; +import com.imyeyu.spring.bean.Page; +import com.imyeyu.spring.bean.PageResult; import com.imyeyu.spring.service.BaseService; import java.util.Collection; @@ -15,6 +17,15 @@ public interface TagService extends BaseService { List listByIdList(Collection idList); + /// 按归属和名称关键词分页查询标签 + /// + /// @param page 分页参数 + /// @param ownerType 归属类型 + /// @param ownerId 归属 ID + /// @param keyword 名称关键词 + /// @return 标签分页 + PageResult pageByOwnerAndKeyword(Page page, Tag.OwnerType ownerType, String ownerId, String keyword); + /// 按业务查询标签列表 /// /// @param bizType 业务类型 diff --git a/src/main/java/com/imyeyu/api/modules/common/service/WechatQueueService.java b/src/main/java/com/imyeyu/api/modules/common/service/WechatQueueService.java new file mode 100644 index 0000000..1344e3f --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/WechatQueueService.java @@ -0,0 +1,10 @@ +package com.imyeyu.api.modules.common.service; + +import com.imyeyu.api.modules.common.entity.WechatQueue; + +/// 微信队列服务 +/// +/// @author 夜雨 +/// @since 2026-08-20 +public interface WechatQueueService extends BaseQueueService { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/AbstractQueueService.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/AbstractQueueService.java new file mode 100644 index 0000000..87c0b40 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/AbstractQueueService.java @@ -0,0 +1,126 @@ +package com.imyeyu.api.modules.common.service.implement; + +import com.imyeyu.api.modules.common.config.NotifyQueueConfig; +import com.imyeyu.api.modules.common.entity.BaseQueueEntity; +import com.imyeyu.api.modules.common.entity.NotifyDetail; +import com.imyeyu.api.modules.common.mapper.BaseQueueMapper; +import com.imyeyu.api.modules.common.service.BaseQueueService; +import com.imyeyu.api.modules.common.service.NotifyDetailService; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiCode; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.utils.Time; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; + +/// 通知队列抽象服务 +/// +/// @param 队列实体类型 +/// @param 队列配置类型 +/// @author 夜雨 +/// @since 2026-08-20 +@Slf4j +public abstract class AbstractQueueService implements BaseQueueService { + + private NotifyDetailService notifyDetailService; + + /// 注入通知详情服务 + @Autowired + private void setNotifyDetailService(NotifyDetailService notifyDetailService) { + this.notifyDetailService = notifyDetailService; + } + + /// 获取队列 Mapper + /// + /// @return 队列 Mapper + protected abstract BaseQueueMapper mapper(); + + /// 获取队列配置 + /// + /// @return 队列配置 + protected abstract C config(); + + /// 校验发送目标 + /// + /// @param sendTo 发送目标 + protected abstract void validateSendTo(String sendTo); + + @Override + public void destroyQueue(NotifyDetail detail) { + TimiException.required(detail, "未找到通知详情"); + TimiException.required(detail.getId(), "未找到通知详情 ID"); + mapper().destroyByNotifyDetailId(detail.getId()); + } + + @Override + public void invokeQueue() { + for (T queue : mapper().listReadyQueue()) { + send(queue); + } + } + + /// 处理单个队列消息 + /// + /// @param queue 队列 + @Override + public final void send(T queue) { + TimiException.required(queue, "未找到通知队列"); + NotifyDetail detail = notifyDetailService.get(queue.getNotifyDetailId()); + if (detail == null) { + mapper().destroy(queue.getId()); + return; + } + + try { + long now = Time.now(); + queue.setSendAt(now); + if (queue.getTimeoutAt() < now) { + throw new TimiException(TimiCode.RESULT_TIMEOUT, "通知队列已超时"); + } + validateSendTo(queue.getSendTo()); + if (!config().isEnable()) { + throw new TimiException(TimiCode.IGNORE, "通知队列未启用"); + } + doSend(queue); + detail.setStatus(NotifyDetail.Status.REMINDED); + } catch (TimiException e) { + if (e.getCode() == TimiCode.IGNORE) { + detail.setStatus(NotifyDetail.Status.CANCEL); + detail.setResultDesc(e.getMessage()); + } else if (e.getCode() == TimiCode.RESULT_TIMEOUT) { + detail.setStatus(NotifyDetail.Status.TIMEOUT); + detail.setResultDesc(e.getMessage()); + } else { + log.error("处理通知队列失败", e); + detail.setStatus(NotifyDetail.Status.FAIL); + detail.setResultDesc(TimiJava.serializeThrowable(e)); + } + } catch (Exception e) { + log.error("处理通知队列失败", e); + detail.setStatus(NotifyDetail.Status.FAIL); + detail.setResultDesc(TimiJava.serializeThrowable(e)); + } finally { + try { + notifyDetailService.update(detail); + if (detail.getStatus() == NotifyDetail.Status.REMINDED + || detail.getStatus() == NotifyDetail.Status.CANCEL + || queue.getRetry() < 0 + ) { + mapper().destroy(queue.getId()); + } else { + queue.setRetry(queue.getRetry() - 1); + mapper().update(queue); + } + } catch (Exception e) { + log.error("通知队列收尾处理失败", e); + mapper().destroy(queue.getId()); + } + } + } + + /// 执行实际发送动作 + /// + /// @param queue 队列 + /// @throws Exception 发送异常 + protected abstract void doSend(T queue) throws Exception; +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/HTTPQueueServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/HTTPQueueServiceImplement.java new file mode 100644 index 0000000..8536cd1 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/HTTPQueueServiceImplement.java @@ -0,0 +1,59 @@ +package com.imyeyu.api.modules.common.service.implement; + +import com.imyeyu.api.modules.common.config.NotifyQueueConfig; +import com.imyeyu.api.modules.common.entity.HTTPQueue; +import com.imyeyu.api.modules.common.entity.NotifyDetail; +import com.imyeyu.api.modules.common.mapper.BaseQueueMapper; +import com.imyeyu.api.modules.common.mapper.HTTPQueueMapper; +import com.imyeyu.api.modules.common.service.HTTPQueueService; +import com.imyeyu.java.bean.timi.TimiCode; +import com.imyeyu.java.bean.timi.TimiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.net.URI; + +/// HTTP 队列服务实现 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Slf4j +@Service +@RequiredArgsConstructor +public class HTTPQueueServiceImplement extends AbstractQueueService implements HTTPQueueService { + + private final NotifyQueueConfig config; + + private final HTTPQueueMapper mapper; + + @Override + protected NotifyQueueConfig.HTTP config() { + return config.getHttp(); + } + + @Override + protected BaseQueueMapper mapper() { + return mapper; + } + + @Override + protected void validateSendTo(String sendTo) { + try { + URI uri = URI.create(sendTo); + TimiException.requiredTrue(uri.isAbsolute(), "发送目标不是有效 URI"); + } catch (IllegalArgumentException e) { + throw new TimiException(TimiCode.ARG_BAD, "发送目标不是有效 URI", e); + } + } + + @Override + public void createQueue(NotifyDetail detail) { + mapper.insert(new HTTPQueue(detail)); + } + + @Override + protected void doSend(HTTPQueue queue) { + throw new TimiException(TimiCode.ERROR_NOT_SUPPORT, "HTTP 推送暂未实现"); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/MailQueueServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/MailQueueServiceImplement.java new file mode 100644 index 0000000..ac23345 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/MailQueueServiceImplement.java @@ -0,0 +1,67 @@ +package com.imyeyu.api.modules.common.service.implement; + +import com.imyeyu.api.modules.common.config.NotifyQueueConfig; +import com.imyeyu.api.modules.common.entity.MailQueue; +import com.imyeyu.api.modules.common.entity.NotifyDetail; +import com.imyeyu.api.modules.common.mapper.BaseQueueMapper; +import com.imyeyu.api.modules.common.mapper.MailQueueMapper; +import com.imyeyu.api.modules.common.service.MailQueueService; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.utils.Regex; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +/// 邮件队列服务实现 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Slf4j +@Service +@RequiredArgsConstructor +public class MailQueueServiceImplement extends AbstractQueueService implements MailQueueService { + + private final NotifyQueueConfig config; + private final JavaMailSender mailSender; + + private final MailQueueMapper mapper; + + @Override + protected NotifyQueueConfig.Mail config() { + return config.getMail(); + } + + @Override + protected BaseQueueMapper mapper() { + return mapper; + } + + @Override + protected void validateSendTo(String sendTo) { + TimiException.requiredTrue(Regex.isMatch(Regex.EMAIL, sendTo), "发送目标不是有效邮箱"); + } + + @Override + public void createQueue(NotifyDetail detail) { + MailQueue queue = new MailQueue(detail); + queue.setSendFrom(TimiJava.defaultIfEmpty(queue.getSendFrom(), config.getMail().getDefaultFrom())); + mapper.insert(queue); + } + + @Override + protected void doSend(MailQueue queue) { + TimiException.required(queue.getSendFrom(), "邮件发送来源不能为空"); + TimiException.required(queue.getSubject(), "邮件标题不能为空"); + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(queue.getSendFrom()); + message.setTo(queue.getSendTo()); + message.setSubject(queue.getSubject()); + message.setText(queue.getData()); + mailSender.send(message); + log.info("邮件发送成功,目标:{}", queue.getSendTo()); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyDetailServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyDetailServiceImplement.java index f9687ab..fd276cf 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyDetailServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyDetailServiceImplement.java @@ -1,12 +1,24 @@ package com.imyeyu.api.modules.common.service.implement; +import com.imyeyu.api.config.dbsource.TimiServerDBConfig; import com.imyeyu.api.modules.common.entity.NotifyDetail; +import com.imyeyu.api.modules.common.entity.Notify; import com.imyeyu.api.modules.common.mapper.NotifyDetailMapper; +import com.imyeyu.api.modules.common.mapper.NotifyMapper; import com.imyeyu.api.modules.common.service.NotifyDetailService; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiCode; +import com.imyeyu.java.bean.timi.TimiException; import com.imyeyu.spring.mapper.BaseMapper; +import com.imyeyu.spring.bean.Page; +import com.imyeyu.spring.bean.PageResult; import com.imyeyu.spring.service.AbstractEntityService; +import com.imyeyu.utils.Time; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; /** * 通知详情服务实现 @@ -18,10 +30,174 @@ import org.springframework.stereotype.Service; @RequiredArgsConstructor public class NotifyDetailServiceImplement extends AbstractEntityService implements NotifyDetailService { + private final NotifyMapper notifyMapper; + private final NotifyDetailMapper mapper; @Override protected BaseMapper mapper() { return mapper; } + + /// 创建通知详情,同时初始化投递状态和时间参数 + /// + /// @param detail 通知详情 + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void create(NotifyDetail detail) { + TimiException.required(detail, "未找到通知详情"); + TimiException.required(detail.getNotifyId(), "未找到通知 ID"); + TimiException.required(detail.getMsgType(), "未找到消息类型"); + Notify notify = notifyMapper.select(detail.getNotifyId()); + TimiException.required(notify, "未找到通知"); + if (detail.getMsgType() == NotifyDetail.MsgType.INTERNAL) { + TimiException.required(detail.getSendTo(), "未找到站内通知接收用户 ID"); + } + + detail.setRetry(TimiJava.defaultIfNull(detail.getRetry(), 1)); + detail.setSendAt(TimiJava.defaultIfNull(detail.getSendAt(), Time.now())); + detail.setTimeoutAt(TimiJava.defaultIfNull(detail.getTimeoutAt(), detail.getSendAt() + Time.D)); + detail.setStatus(detail.getMsgType() == NotifyDetail.MsgType.INTERNAL + ? NotifyDetail.Status.REMINDED + : NotifyDetail.Status.WAITING); + detail.setReadAt(null); + super.create(detail); + + if (detail.getMsgType().isQueue()) { + // 加入外部消息队列 + detail.getMsgType().getService().createQueue(detail); + } + } + + /// 查询通知下的全部详情 + /// + /// @param notifyId 通知 ID + /// @return 通知详情列表 + @Override + public List listByNotifyId(String notifyId) { + NotifyDetail example = new NotifyDetail(); + example.setNotifyId(notifyId); + return mapper.selectAllByExample(example); + } + + /// 删除通知详情 + /// + /// @param detailId 通知详情 ID + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void delete(String detailId) { + TimiException.required(detailId, "未找到通知详情 ID"); + NotifyDetail detail = get(detailId); + TimiException.required(detail, "未找到通知详情"); + if (detail.getMsgType().isQueue()) { + detail.getMsgType().getService().destroyQueue(detail); + } + super.delete(detailId); + } + + /// 重新将通知详情放入消息队列 + /// + /// @param detailId 通知详情 ID + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void requeue(String detailId) { + NotifyDetail detail = get(detailId); + TimiException.required(detail, "未找到通知详情"); + TimiException.required(detail.getMsgType(), "未找到消息类型"); + TimiException.requiredTrue(detail.getMsgType().isQueue(), "站内通知不支持重新入队"); + if (detail.getStatus() == NotifyDetail.Status.WAITING) { + throw new TimiException(TimiCode.RESULT_BAD, "通知详情已经在等待队列中"); + } + + detail.getMsgType().getService().destroyQueue(detail); + detail.setStatus(NotifyDetail.Status.WAITING); + detail.setResultDesc(null); + detail.setRetry(1); + detail.setSendAt(Time.now()); + detail.setTimeoutAt(detail.getSendAt() + Time.D); + update(detail); + + // 加入消息队列 + detail.getMsgType().getService().createQueue(detail); + } + + /// 分页查询用户的站内通知 + /// + /// @param page 分页参数 + /// @param userId 用户 ID + /// @param unreadOnly true 为只查询未读通知 + /// @return 站内通知分页结果 + @Override + public PageResult pageInternalByUserId(Page page, String userId, boolean unreadOnly) { + TimiException.required(userId, "未找到用户 ID"); + Page validPage = TimiJava.defaultIfNull(page, new Page<>()); + PageResult result = new PageResult<>(); + result.setTotal(unreadOnly + ? mapper.countUnreadSiteByUserId(userId) + : mapper.countSiteByUserId(userId)); + result.setList(unreadOnly + ? mapper.selectUnreadSiteByUserId(userId, validPage.getOffset(), validPage.getLimit()) + : mapper.selectSiteByUserId(userId, validPage.getOffset(), validPage.getLimit())); + return result; + } + + /// 查询当前用户拥有的站内通知详情 + /// + /// @param detailId 通知详情 ID + /// @param userId 用户 ID + /// @return 通知详情 + @Override + public NotifyDetail getInternalByUserId(String detailId, String userId) { + TimiException.required(detailId, "未找到通知详情 ID"); + TimiException.required(userId, "未找到用户 ID"); + NotifyDetail detail = mapper.selectSiteByIdAndUserId(detailId, userId); + TimiException.required(detail, "未找到站内通知"); + return detail; + } + + /// 删除当前用户拥有的站内通知详情 + /// + /// @param detailId 通知详情 ID + /// @param userId 用户 ID + @Override + @Transactional(TimiServerDBConfig.ROLLBACKER) + public void deleteInternalByUserId(String detailId, String userId) { + TimiException.required(detailId, "未找到通知详情 ID"); + TimiException.required(userId, "未找到用户 ID"); + int affected = mapper.deleteSiteByIdAndUserId(detailId, userId); + TimiException.requiredTrue(0 < affected, "未找到站内通知"); + } + + /// 查询用户未读站内通知数量 + /// + /// @param userId 用户 ID + /// @return 未读数量 + @Override + public long countUnreadSiteByUserId(String userId) { + TimiException.required(userId, "未找到用户 ID"); + return mapper.countUnreadSiteByUserId(userId); + } + + /// 标记站内通知已读 + /// + /// @param userId 用户 ID + /// @param detailId 通知详情 ID + @Override + @Transactional(TimiServerDBConfig.ROLLBACKER) + public void markSiteRead(String userId, String detailId) { + TimiException.required(userId, "未找到用户 ID"); + TimiException.required(detailId, "未找到通知详情 ID"); + TimiException.required(mapper.selectSiteByIdAndUserId(detailId, userId), "未找到站内通知"); + mapper.markSiteRead(detailId, userId, Time.now()); + } + + /// 标记用户全部站内通知已读 + /// + /// @param userId 用户 ID + @Override + @Transactional(TimiServerDBConfig.ROLLBACKER) + public void markAllSiteRead(String userId) { + TimiException.required(userId, "未找到用户 ID"); + mapper.markAllSiteRead(userId, Time.now()); + } } diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyServiceImplement.java index 5fac11f..9693566 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyServiceImplement.java @@ -1,12 +1,25 @@ package com.imyeyu.api.modules.common.service.implement; +import com.imyeyu.api.config.dbsource.TimiServerDBConfig; import com.imyeyu.api.modules.common.entity.Notify; +import com.imyeyu.api.modules.common.entity.NotifyDetail; import com.imyeyu.api.modules.common.mapper.NotifyMapper; +import com.imyeyu.api.modules.common.service.NotifyDetailService; import com.imyeyu.api.modules.common.service.NotifyService; +import com.imyeyu.api.modules.system.entity.Logger; +import com.imyeyu.api.modules.system.service.LoggerService; +import com.imyeyu.api.modules.user.service.UserLoginService; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiCode; +import com.imyeyu.java.bean.timi.TimiException; import com.imyeyu.spring.mapper.BaseMapper; import com.imyeyu.spring.service.AbstractEntityService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; /** * 通知服务实现 @@ -14,14 +27,118 @@ import org.springframework.stereotype.Service; * @author 夜雨 * @since 2026-05-13 14:46 */ +@Slf4j @Service @RequiredArgsConstructor public class NotifyServiceImplement extends AbstractEntityService implements NotifyService { + private final LoggerService loggerService; + private final UserLoginService userLoginService; + private final NotifyDetailService detailService; + private final NotifyMapper mapper; @Override protected BaseMapper mapper() { return mapper; } + + /// 创建通知并级联创建通知详情 + /// + /// @param notify 通知 + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void create(Notify notify) { + Logger logger = new Logger(Logger.Module.COMMON, "COMMON_NOTIFY_CREATE"); + try { + TimiException.required(notify, "未找到通知"); + TimiException.required(notify.getDetailList(), "未找到通知详情列表"); + super.create(notify); + + String sendFrom = null; + if (userLoginService.isLogged()) { + sendFrom = userLoginService.getRequireLoginUserId(); + } + for (NotifyDetail detail : notify.getDetailList()) { + detail.setNotifyId(notify.getId()); + if (TimiJava.isNotEmpty(sendFrom)) { + detail.setSendFrom(sendFrom); + } + detailService.create(detail); + } + logger.setLevel(Logger.Level.INFO); + logger.setResult(notify.getId()); + } catch (TimiException e) { + logger.setLevel(Logger.Level.WARN); + logger.setException(e.getMessage()); + throw e; + } catch (Exception e) { + logger.setLevel(Logger.Level.ERROR); + logger.setException(TimiJava.serializeThrowable(e)); + log.error("创建通知失败", e); + throw new TimiException(TimiCode.ERROR, "创建通知失败", e); + } finally { + loggerService.create(logger); + } + } + + /// 删除通知并级联软删除通知详情 + /// + /// @param notifyId 通知 ID + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void delete(String notifyId) { + Logger logger = new Logger(Logger.Module.COMMON, "COMMON_NOTIFY_DELETE"); + try { + TimiException.required(notifyId, "未找到通知 ID"); + TimiException.required(get(notifyId), "未找到通知"); + List detailList = detailService.listByNotifyId(notifyId); + for (NotifyDetail detail : detailList) { + detailService.delete(detail.getId()); + } + super.delete(notifyId); + logger.setLevel(Logger.Level.INFO); + logger.setResult(notifyId); + } catch (TimiException e) { + logger.setLevel(Logger.Level.WARN); + logger.setException(e.getMessage()); + throw e; + } catch (Exception e) { + logger.setLevel(Logger.Level.ERROR); + logger.setException(TimiJava.serializeThrowable(e)); + log.error("删除通知失败", e); + throw new TimiException(TimiCode.ERROR, "删除通知失败", e); + } finally { + loggerService.create(logger); + } + } + + /// 取消通知中尚未成功发送的通知详情 + /// + /// @param notifyId 通知 ID + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void cancel(String notifyId) { + TimiException.required(notifyId, "未找到通知 ID"); + TimiException.required(get(notifyId), "未找到通知"); + + boolean canceled = false; + for (NotifyDetail detail : detailService.listByNotifyId(notifyId)) { + if (detail.getStatus() == NotifyDetail.Status.REMINDED + || detail.getStatus() == NotifyDetail.Status.CANCEL + ) { + continue; + } + if (detail.getMsgType() != null && detail.getMsgType().isQueue()) { + detail.getMsgType().getService().destroyQueue(detail); + } + detail.setStatus(NotifyDetail.Status.CANCEL); + detail.setResultDesc("通知已取消"); + detailService.update(detail); + canceled = true; + } + if (!canceled) { + throw new TimiException(TimiCode.RESULT_BAD, "通知没有可取消的通知详情"); + } + } } 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 new file mode 100644 index 0000000..670b515 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/SmsQueueServiceImplement.java @@ -0,0 +1,78 @@ +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; +import com.imyeyu.api.modules.common.entity.SmsQueue; +import com.imyeyu.api.modules.common.mapper.BaseQueueMapper; +import com.imyeyu.api.modules.common.mapper.SmsQueueMapper; +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.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; + +/// 短信队列服务实现 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Slf4j +@Service +@RequiredArgsConstructor +public class SmsQueueServiceImplement extends AbstractQueueService implements SmsQueueService { + + private final NotifyQueueConfig config; + private final ObjectMapper jackson; + private final QCloudSmsProxy smsProxy; + + private final SmsQueueMapper mapper; + + @Override + protected NotifyQueueConfig.Sms config() { + return config.getSms(); + } + + @Override + protected BaseQueueMapper mapper() { + return mapper; + } + + @Override + protected void validateSendTo(String sendTo) { + TimiException.requiredTrue(Regex.isMatch(Regex.MOBILE_PHONE, sendTo), "发送目标不是手机号"); + } + + @Override + public void createQueue(NotifyDetail detail) { + mapper.insert(new SmsQueue(detail)); + } + + @Override + protected void doSend(SmsQueue queue) { + try { + String[] args = new String[0]; + if (queue.getArgs() != null && !queue.getArgs().isBlank()) { + JsonNode argsNode = jackson.readTree(queue.getArgs()); + TimiException.requiredTrue(argsNode != null && argsNode.isArray(), "短信参数必须是数组"); + args = jackson.treeToValue(argsNode, String[].class); + } + SendSmsResponse response = smsProxy.send(queue.getSendTo(), queue.getTemplateId(), args); + TimiException.required(response, "未找到短信响应"); + SendStatus[] statusSet = response.getSendStatusSet(); + TimiException.requiredTrue(statusSet != null && 0 < statusSet.length, "短信响应为空"); + if (!"ok".equalsIgnoreCase(statusSet[0].getCode())) { + throw new TimiException(TimiCode.ERROR, jackson.writeValueAsString(response)); + } + } catch (TimiException e) { + throw e; + } catch (Exception e) { + throw new TimiException(TimiCode.ERROR, "短信发送失败", e); + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/TagServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/TagServiceImplement.java index 2387a57..349434d 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/implement/TagServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/TagServiceImplement.java @@ -7,6 +7,8 @@ import com.imyeyu.api.modules.common.mapper.TagMapper; import com.imyeyu.api.modules.common.service.MultilingualService; import com.imyeyu.api.modules.common.service.TagService; import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.bean.Page; +import com.imyeyu.spring.bean.PageResult; import com.imyeyu.spring.mapper.BaseMapper; import com.imyeyu.spring.service.AbstractEntityService; import lombok.RequiredArgsConstructor; @@ -70,6 +72,14 @@ public class TagServiceImplement extends AbstractEntityService impl return mapper.selectByIdList(new HashSet<>(idList)); } + @Override + public PageResult pageByOwnerAndKeyword(Page page, Tag.OwnerType ownerType, String ownerId, String keyword) { + PageResult result = new PageResult<>(); + result.setTotal(mapper.countByOwnerAndKeyword(ownerType, ownerId, keyword)); + result.setList(mapper.selectByOwnerAndKeyword(ownerType, ownerId, keyword, page.getOffset(), page.getLimit())); + return result; + } + @Override public List listByBizType(TagApply.BizType bizType, String bizId) { return listByIdList(listApply(bizType, bizId).stream().map(TagApply::getTagId).toList()); diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/WechatQueueServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/WechatQueueServiceImplement.java new file mode 100644 index 0000000..2182b85 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/WechatQueueServiceImplement.java @@ -0,0 +1,52 @@ +package com.imyeyu.api.modules.common.service.implement; + +import com.imyeyu.api.modules.common.config.NotifyQueueConfig; +import com.imyeyu.api.modules.common.entity.NotifyDetail; +import com.imyeyu.api.modules.common.entity.WechatQueue; +import com.imyeyu.api.modules.common.mapper.BaseQueueMapper; +import com.imyeyu.api.modules.common.mapper.WechatQueueMapper; +import com.imyeyu.api.modules.common.service.WechatQueueService; +import com.imyeyu.java.bean.timi.TimiCode; +import com.imyeyu.java.bean.timi.TimiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// 微信队列服务实现 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Slf4j +@Service +@RequiredArgsConstructor +public class WechatQueueServiceImplement extends AbstractQueueService implements WechatQueueService { + + private final NotifyQueueConfig config; + + private final WechatQueueMapper mapper; + + @Override + protected NotifyQueueConfig.Wechat config() { + return config.getWechat(); + } + + @Override + protected BaseQueueMapper mapper() { + return mapper; + } + + @Override + protected void validateSendTo(String sendTo) { + TimiException.required(sendTo, "发送目标不能为空"); + } + + @Override + public void createQueue(NotifyDetail detail) { + mapper.insert(new WechatQueue(detail)); + } + + @Override + protected void doSend(WechatQueue queue) { + throw new TimiException(TimiCode.ERROR_NOT_SUPPORT, "微信推送暂未实现"); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/task/MailQueueTask.java b/src/main/java/com/imyeyu/api/modules/common/task/MailQueueTask.java new file mode 100644 index 0000000..6854703 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/task/MailQueueTask.java @@ -0,0 +1,46 @@ +package com.imyeyu.api.modules.common.task; + +import com.imyeyu.api.modules.common.config.NotifyQueueConfig; +import com.imyeyu.api.modules.common.service.MailQueueService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.scheduling.support.CronTrigger; +import org.springframework.stereotype.Service; + +/// 邮件队列定时任务 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Slf4j +@Service +@RequiredArgsConstructor +public class MailQueueTask implements SchedulingConfigurer { + + private final NotifyQueueConfig config; + + private final MailQueueService service; + + private boolean running; + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + if (!config.getMail().isEnable()) { + log.warn("邮件队列任务已禁用"); + return; + } + taskRegistrar.addTriggerTask(() -> { + if (running) { + log.info("邮件队列任务正在运行,跳过本次执行"); + return; + } + running = true; + try { + service.invokeQueue(); + } finally { + running = false; + } + }, triggerContext -> new CronTrigger(config.getMail().getCron()).nextExecution(triggerContext)); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/task/SmsQueueTask.java b/src/main/java/com/imyeyu/api/modules/common/task/SmsQueueTask.java new file mode 100644 index 0000000..4c4f9f4 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/task/SmsQueueTask.java @@ -0,0 +1,46 @@ +package com.imyeyu.api.modules.common.task; + +import com.imyeyu.api.modules.common.config.NotifyQueueConfig; +import com.imyeyu.api.modules.common.service.SmsQueueService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.scheduling.support.CronTrigger; +import org.springframework.stereotype.Service; + +/// 短信队列定时任务 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Slf4j +@Service +@RequiredArgsConstructor +public class SmsQueueTask implements SchedulingConfigurer { + + private final NotifyQueueConfig config; + + private final SmsQueueService service; + + private boolean running; + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + if (!config.getSms().isEnable()) { + log.warn("短信队列任务已禁用"); + return; + } + taskRegistrar.addTriggerTask(() -> { + if (running) { + log.info("短信队列任务正在运行,跳过本次执行"); + return; + } + running = true; + try { + service.invokeQueue(); + } finally { + running = false; + } + }, triggerContext -> new CronTrigger(config.getSms().getCron()).nextExecution(triggerContext)); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/task/WechatQueueTask.java b/src/main/java/com/imyeyu/api/modules/common/task/WechatQueueTask.java new file mode 100644 index 0000000..5bd2792 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/task/WechatQueueTask.java @@ -0,0 +1,8 @@ +package com.imyeyu.api.modules.common.task; + +/// 微信队列定时任务占位 +/// +/// @author 夜雨 +/// @since 2026-08-20 +public class WechatQueueTask { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/util/QCloudSmsProxy.java b/src/main/java/com/imyeyu/api/modules/common/util/QCloudSmsProxy.java new file mode 100644 index 0000000..8fb975f --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/util/QCloudSmsProxy.java @@ -0,0 +1,73 @@ +package com.imyeyu.api.modules.common.util; + +import com.imyeyu.api.modules.common.config.NotifyQueueConfig; +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import com.tencentcloudapi.common.profile.ClientProfile; +import com.tencentcloudapi.common.profile.HttpProfile; +import com.tencentcloudapi.sms.v20210111.SmsClient; +import com.tencentcloudapi.sms.v20210111.models.SendSmsRequest; +import com.tencentcloudapi.sms.v20210111.models.SendSmsResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/// 腾讯云短信服务代理 +/// +/// @author 夜雨 +/// @since 2026-08-20 +@Component +@RequiredArgsConstructor +public class QCloudSmsProxy { + + private final NotifyQueueConfig config; + + /// 发送短信 + /// + /// @param mobilePhone 手机号 + /// @param templateId 模板 ID + /// @param args 模板参数 + /// @return 腾讯云短信响应 + /// @throws TencentCloudSDKException 腾讯云 SDK 调用异常 + public SendSmsResponse send(String mobilePhone, String templateId, String[] args) throws TencentCloudSDKException { + return send(List.of(mobilePhone), templateId, args); + } + + /// 批量发送短信 + /// + /// @param mobilePhoneList 手机号列表 + /// @param templateId 模板 ID + /// @param args 模板参数 + /// @return 腾讯云短信响应 + /// @throws TencentCloudSDKException 腾讯云 SDK 调用异常 + public SendSmsResponse send(List mobilePhoneList, String templateId, String[] args) throws TencentCloudSDKException { + SmsClient client = createSmsClient(); + NotifyQueueConfig.Sms sms = config.getSms(); + + SendSmsRequest request = new SendSmsRequest(); + request.setSmsSdkAppId(sms.getAppId().trim()); + request.setSignName(sms.getSign().trim()); + request.setTemplateId(templateId); + request.setTemplateParamSet(args); + request.setPhoneNumberSet(mobilePhoneList.toArray(new String[0])); + return client.SendSms(request); + } + + private SmsClient createSmsClient() { + NotifyQueueConfig.Sms sms = config.getSms(); + Credential credential = new Credential(sms.getSecretId().trim(), sms.getSecretKey().trim()); + + HttpProfile httpProfile = new HttpProfile(); + httpProfile.setReqMethod("GET"); + httpProfile.setConnTimeout(10); + httpProfile.setWriteTimeout(10); + httpProfile.setReadTimeout(10); + httpProfile.setEndpoint("sms.tencentcloudapi.com"); + + ClientProfile clientProfile = new ClientProfile(); + clientProfile.setSignMethod("HmacSHA256"); + clientProfile.setHttpProfile(httpProfile); + return new SmsClient(credential, "ap-guangzhou", clientProfile); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/util/QueueSQLProvider.java b/src/main/java/com/imyeyu/api/modules/common/util/QueueSQLProvider.java new file mode 100644 index 0000000..7216d6a --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/util/QueueSQLProvider.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.common.util; + +import com.imyeyu.api.modules.common.entity.BaseQueueEntity; +import com.imyeyu.spring.util.SQLProvider; +import com.imyeyu.utils.Time; +import org.apache.ibatis.builder.annotation.ProviderContext; + +/// 通知队列 SQL 提供器 +/// +/// @author 夜雨 +/// @since 2026-08-20 +public class QueueSQLProvider extends SQLProvider { + + /// 查询已到发送时间的队列 + /// + /// @param context Mapper 上下文 + /// @return 查询 SQL + public String listReadyQueue(ProviderContext context) { + EntityMeta meta = getEntityMeta(context); + StringBuilder sql = new StringBuilder("SELECT * FROM %s WHERE 1 = 1".formatted(meta.getTable())); + if (BaseQueueEntity.class.isAssignableFrom(meta.getEntityClass())) { + sql.append(" AND `send_at` < %d".formatted(Time.now())); + } + return sql.toString(); + } + + /// 根据通知详情 ID 物理删除队列 + /// + /// @param context Mapper 上下文 + /// @return 删除 SQL + public String destroyByNotifyDetailId(ProviderContext context) { + EntityMeta meta = getEntityMeta(context); + return "DELETE FROM %s WHERE `notify_detail_id` = #{notifyDetailId}".formatted(meta.getTable()); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoTagController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoTagController.java index c3568ad..0494932 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoTagController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoTagController.java @@ -50,10 +50,16 @@ public class GaoTagController { @PostMapping("/list") public PageResult list(@RequestBody Page page) { TimiException.required(page, "未找到分页参数"); + String ownerId = null; page.setEqualsExample(TimiJava.defaultIfNull(page.getEqualsExample(), new Tag())); page.getEqualsExample().setOwnerType(Tag.OwnerType.GAO_STORE); if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name())) { - page.getEqualsExample().setOwnerId(storeService.getBelongIdByRequiredLoginUserId()); + ownerId = storeService.getBelongIdByRequiredLoginUserId(); + page.getEqualsExample().setOwnerId(ownerId); + } + String keyword = page.getLikesExample() == null ? null : page.getLikesExample().getName(); + if (TimiJava.isNotEmpty(keyword)) { + return service.pageByOwnerAndKeyword(page, Tag.OwnerType.GAO_STORE, ownerId, keyword.trim()); } return service.page(page); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomer.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomer.java index d4613fc..b5570c9 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomer.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomer.java @@ -3,6 +3,7 @@ package com.imyeyu.api.modules.gao.entity; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.JsonNode; import com.imyeyu.api.modules.common.entity.Attachment; +import com.imyeyu.api.modules.common.entity.TagApply; import com.imyeyu.api.modules.user.bean.Gender; import com.imyeyu.api.modules.user.entity.User; import com.imyeyu.java.TimiJava; @@ -109,6 +110,9 @@ public class GaoCustomer extends UUIDEntity { @Transient private List eventRecordList; + @Transient + private TagApply tagApply; + public boolean hasIntroducer() { return TimiJava.isNotEmpty(introducerCustomerId); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java index 7859567..6d3744a 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java @@ -18,6 +18,12 @@ import java.util.List; /// @since 2026-07-27 14:09 public interface GaoEventRecordService extends BaseService { + /// 按客户查询登记事件记录 + /// + /// @param customerId 客户 ID + /// @return 登记事件记录列表 + List listByCustomerId(String customerId); + default void createBatch(List recordList) { for (GaoEventRecord record : TimiJava.safeIterable(recordList)) { create(record); diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerServiceImplement.java index 4c05048..afbdb9c 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerServiceImplement.java @@ -3,10 +3,16 @@ package com.imyeyu.api.modules.gao.service.implement; import com.fasterxml.jackson.databind.ObjectMapper; import com.imyeyu.api.config.dbsource.TimiServerDBConfig; import com.imyeyu.api.modules.common.entity.Attachment; +import com.imyeyu.api.modules.common.entity.TagApply; import com.imyeyu.api.modules.common.service.AttachmentService; +import com.imyeyu.api.modules.common.service.TagService; import com.imyeyu.api.modules.gao.entity.GaoCustomer; +import com.imyeyu.api.modules.gao.entity.GaoEventRecord; +import com.imyeyu.api.modules.gao.entity.GaoPointAccount; import com.imyeyu.api.modules.gao.mapper.GaoCustomerMapper; import com.imyeyu.api.modules.gao.service.GaoCustomerService; +import com.imyeyu.api.modules.gao.service.GaoEventRecordService; +import com.imyeyu.api.modules.gao.service.GaoPointAccountService; import com.imyeyu.api.modules.gao.util.CustomerXSSFBuilder; import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView; import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView; @@ -24,6 +30,7 @@ import com.imyeyu.spring.mapper.BaseMapper; import com.imyeyu.spring.service.AbstractEntityService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -54,9 +61,12 @@ public class GaoCustomerServiceImplement extends AbstractEntityService eventRecordServiceProvider; private final GaoCustomerMapper mapper; @@ -111,6 +121,10 @@ public class GaoCustomerServiceImplement extends AbstractEntityService listByCustomerId(String customerId) { + TimiException.required(customerId, "not found customerId"); + GaoEventRecord example = new GaoEventRecord(); + example.setCustomerId(customerId); + return mapper.selectAllByExample(example); + } + @Transactional(TimiServerDBConfig.ROLLBACKER) @Override public void create(GaoEventRecord record) { @@ -212,6 +220,7 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService AND `deleted_at` IS NULL + + `t`.`owner_type` = #{ownerType} + + AND `t`.`owner_id` = #{ownerId} + + + AND ( + `m`.`zh_cn` LIKE CONCAT('%', #{keyword}, '%') + OR `m`.`zh_tw` LIKE CONCAT('%', #{keyword}, '%') + OR `m`.`en_us` LIKE CONCAT('%', #{keyword}, '%') + OR `m`.`ru_ru` LIKE CONCAT('%', #{keyword}, '%') + OR `m`.`ja_jp` LIKE CONCAT('%', #{keyword}, '%') + OR `m`.`de_de` LIKE CONCAT('%', #{keyword}, '%') + OR `m`.`ko_kr` LIKE CONCAT('%', #{keyword}, '%') + ) + + AND `t`.`deleted_at` IS NULL + AND `m`.`deleted_at` IS NULL + + +