diff --git a/src/main/java/com/imyeyu/api/bean/CorePermissionCode.java b/src/main/java/com/imyeyu/api/bean/CorePermissionCode.java index 652cdb4..2668094 100644 --- a/src/main/java/com/imyeyu/api/bean/CorePermissionCode.java +++ b/src/main/java/com/imyeyu/api/bean/CorePermissionCode.java @@ -50,6 +50,11 @@ public enum CorePermissionCode implements BuiltinPermissionCode { USER_UPDATE("USER:UPDATE", "用户修改", false, true), USER_DELETE("USER:DELETE", "用户删除", false, true), + FEEDBACK_CREATE("FEEDBACK:CREATE", "反馈创建", false, true), + FEEDBACK_READ("FEEDBACK:READ", "反馈读取", false, true), + FEEDBACK_UPDATE("FEEDBACK:UPDATE", "反馈修改", false, true), + FEEDBACK_DELETE("FEEDBACK:DELETE", "反馈删除", 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/controller/FeedbackController.java b/src/main/java/com/imyeyu/api/modules/common/controller/FeedbackController.java new file mode 100644 index 0000000..9ae8e40 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/controller/FeedbackController.java @@ -0,0 +1,116 @@ +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.Feedback; +import com.imyeyu.api.modules.common.service.FeedbackService; +import com.imyeyu.api.modules.user.service.UserLoginService; +import com.imyeyu.spring.TimiSpring; +import com.imyeyu.spring.annotation.AOPLog; +import com.imyeyu.spring.annotation.CaptchaValid; +import com.imyeyu.spring.annotation.RequestRateLimit; +import com.imyeyu.spring.bean.CaptchaData; +import com.imyeyu.spring.bean.Page; +import com.imyeyu.spring.bean.PageResult; +import com.imyeyu.spring.util.ResponseView; +import jakarta.validation.Valid; +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; + +/// 通用反馈接口 +/// +/// @author Codex +/// @since 2026-08-19 +@RestController +@RequiredArgsConstructor +@RequestMapping("/feedback") +public class FeedbackController { + + private final FeedbackService service; + private final UserLoginService userLoginService; + + /// 提交反馈。登录用户和匿名用户均需提交图形验证码,匿名用户按 IP 限流 + /// + /// @param req 带图形验证码的反馈请求 + /// @return 已创建反馈 + @AOPLog + @JsonView(ResponseView.Public.class) + @CaptchaValid + @RequestRateLimit(value = 3, inSeconds = 60) + @PostMapping("/submit") + public Feedback submit(@RequestBody @Valid CaptchaData req) { + Feedback feedback = req.getData(); + feedback.setIp(TimiSpring.getRequestIP()); + if (userLoginService.getLoginUser() != null) { + feedback.setUserId(userLoginService.getLoginUser().getId()); + } + service.create(feedback); + return feedback; + } + + /// 查询反馈列表 + /// + /// @param page 分页参数 + /// @return 反馈分页 + @JsonView(ResponseView.Admin.class) + @RequestRateLimit + @RequireCorePermission(CorePermissionCode.FEEDBACK_READ) + @PostMapping("/list") + public PageResult list(@RequestBody Page page) { + return service.page(page); + } + + /// 查询反馈详情 + /// + /// @param id 反馈 ID + /// @return 反馈详情 + @JsonView(ResponseView.Admin.class) + @RequestRateLimit + @RequireCorePermission(CorePermissionCode.FEEDBACK_READ) + @PostMapping("/detail") + public Feedback detail(@RequestParam String id) { + 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 反馈 + @AOPLog + @RequestRateLimit + @RequireCorePermission(CorePermissionCode.FEEDBACK_UPDATE) + @PostMapping("/update") + public void update(@RequestBody Feedback feedback) { + feedback.setRespondedBy(userLoginService.getRequireLoginUserId()); + service.update(feedback); + } + + /// 删除反馈 + /// + /// @param id 反馈 ID + @AOPLog + @RequestRateLimit + @RequireCorePermission(CorePermissionCode.FEEDBACK_DELETE) + @PostMapping("/delete") + public void delete(@RequestParam String id) { + service.delete(id); + } +} 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 ce3353a..52b9973 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 @@ -28,7 +28,10 @@ public class Article extends UUIDEntity implements SettingSupport { public enum BizType { /** 博客 */ - BLOG + BLOG, + + /// 反馈 + FEEDBACK } /** @@ -79,4 +82,7 @@ public class Article extends UUIDEntity implements SettingSupport { @Transient protected List commentList; + + @Transient + protected List attachmentList; } diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java b/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java index 3ef00fe..8b829bb 100644 --- a/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java +++ b/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java @@ -30,6 +30,8 @@ public class Attachment extends UUIDEntity { /// @since 2026-07-17 20:52 public enum BizType { + ARTICLE, + USER, GAO_CUSTOMER, diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/Feedback.java b/src/main/java/com/imyeyu/api/modules/common/entity/Feedback.java new file mode 100644 index 0000000..98bc433 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/entity/Feedback.java @@ -0,0 +1,79 @@ +package com.imyeyu.api.modules.common.entity; + +import com.fasterxml.jackson.annotation.JsonView; +import com.imyeyu.api.bean.ModuleCode; +import com.imyeyu.spring.annotation.table.Transient; +import com.imyeyu.spring.entity.UUIDEntity; +import com.imyeyu.spring.util.ResponseView; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/// 通用反馈 +/// +/// @author Codex +/// @since 2026-08-19 +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class Feedback extends UUIDEntity { + + /// 反馈处理状态 + public enum Status { + + /// 待处理 + PENDING, + + /// 已回复 + REPLIED, + + /// 已关闭 + CLOSED + } + + /// 所属模块 + @JsonView(ResponseView.Public.class) + private ModuleCode module; + + /// 提交用户 ID,匿名反馈为 null + @JsonView(ResponseView.Admin.class) + private String userId; + + /// 反馈内容文章 ID + @JsonView(ResponseView.Public.class) + private String contentId; + + /// 联系方式 + @JsonView(ResponseView.Public.class) + private String contact; + + /// 提交 IP + @JsonView(ResponseView.Admin.class) + private String ip; + + /// 处理状态 + @JsonView(ResponseView.Public.class) + private Status status; + + /// 管理员回复文章 ID + @JsonView(ResponseView.Public.class) + private String responseId; + + /// 回复用户 ID + @JsonView(ResponseView.Admin.class) + private String respondedBy; + + /// 回复时间 + @JsonView(ResponseView.Public.class) + private Long respondedAt; + + /// 管理员回复文章 + @Transient + @JsonView(ResponseView.Public.class) + private Article content; + + /// 管理员回复文章 + @Transient + @JsonView(ResponseView.Public.class) + private Article response; +} diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/FeedbackMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/FeedbackMapper.java new file mode 100644 index 0000000..d96bf9c --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/FeedbackMapper.java @@ -0,0 +1,11 @@ +package com.imyeyu.api.modules.common.mapper; + +import com.imyeyu.api.modules.common.entity.Feedback; +import com.imyeyu.spring.mapper.BaseMapper; + +/// 通用反馈 Mapper +/// +/// @author Codex +/// @since 2026-08-19 +public interface FeedbackMapper extends BaseMapper { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/FeedbackService.java b/src/main/java/com/imyeyu/api/modules/common/service/FeedbackService.java new file mode 100644 index 0000000..0bc4524 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/FeedbackService.java @@ -0,0 +1,11 @@ +package com.imyeyu.api.modules.common.service; + +import com.imyeyu.api.modules.common.entity.Feedback; +import com.imyeyu.spring.service.BaseService; + +/// 通用反馈服务 +/// +/// @author Codex +/// @since 2026-08-19 +public interface FeedbackService extends BaseService { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/ArticleServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/ArticleServiceImplement.java index 616b3b4..9f8b58a 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/implement/ArticleServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/ArticleServiceImplement.java @@ -1,8 +1,15 @@ package com.imyeyu.api.modules.common.service.implement; import com.imyeyu.api.modules.common.entity.Article; +import com.imyeyu.api.modules.common.entity.Attachment; import com.imyeyu.api.modules.common.mapper.ArticleMapper; import com.imyeyu.api.modules.common.service.ArticleService; +import com.imyeyu.api.modules.common.service.AttachmentService; +import com.imyeyu.api.modules.system.entity.Logger; +import com.imyeyu.api.modules.system.service.LoggerService; +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; @@ -20,10 +27,57 @@ import org.springframework.stereotype.Service; @RequiredArgsConstructor public class ArticleServiceImplement extends AbstractEntityService implements ArticleService { + private final LoggerService loggerService; + private final AttachmentService attachmentService; + private final ArticleMapper mapper; @Override protected BaseMapper mapper() { return mapper; } + + @Override + public void create(Article article) { + Logger logger = new Logger(Logger.Module.COMMON, "COMMON_ARTICLE_CREATE"); + try { + super.create(article); + attachmentService.updateByBizId(Attachment.BizType.ARTICLE, article.getId(), article.getAttachmentList()); + logger.setLevel(Logger.Level.INFO); + logger.setResult(article.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("create article error", e); + throw new TimiException(TimiCode.ERROR, "create article error", e); + } finally { + loggerService.create(logger); + } + } + + @Override + public void update(Article article) { + Logger logger = new Logger(Logger.Module.COMMON, "COMMON_ARTICLE_UPDATE"); + try { + super.update(article); + attachmentService.updateByBizId(Attachment.BizType.ARTICLE, article.getId(), article.getAttachmentList()); + logger.setLevel(Logger.Level.INFO); + logger.setResult(article.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("update article error", e); + throw new TimiException(TimiCode.ERROR, "update article error", e); + } finally { + loggerService.create(logger); + } + } } diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/FeedbackServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/FeedbackServiceImplement.java new file mode 100644 index 0000000..5383458 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/FeedbackServiceImplement.java @@ -0,0 +1,141 @@ +package com.imyeyu.api.modules.common.service.implement; + +import com.imyeyu.api.config.dbsource.TimiServerDBConfig; +import com.imyeyu.api.modules.common.entity.Feedback; +import com.imyeyu.api.modules.common.mapper.FeedbackMapper; +import com.imyeyu.api.modules.common.service.ArticleService; +import com.imyeyu.api.modules.common.service.AttachmentService; +import com.imyeyu.api.modules.common.service.FeedbackService; +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 com.imyeyu.utils.Time; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/// 通用反馈服务实现 +/// +/// @author Codex +/// @since 2026-08-19 +@Service +@RequiredArgsConstructor +@Slf4j +public class FeedbackServiceImplement extends AbstractEntityService implements FeedbackService { + + private static final long ANONYMOUS_ATTACHMENT_MAX_SIZE = 500L * 1024; + + private final LoggerService loggerService; + private final ArticleService articleService; + private final UserLoginService userLoginService; + private final AttachmentService attachmentService; + + private final FeedbackMapper mapper; + + @Override + protected BaseMapper mapper() { + return mapper; + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void create(Feedback feedback) { + Logger logger = new Logger(Logger.Module.COMMON, "COMMON_FEEDBACK_CREATE"); + try { + TimiException.required(feedback, "未找到反馈"); + articleService.create(feedback.getContent()); + feedback.setContentId(feedback.getContent().getId()); + feedback.setStatus(TimiJava.defaultIfNull(feedback.getStatus(), Feedback.Status.PENDING)); + super.create(feedback); + logger.setLevel(Logger.Level.INFO); + logger.setResult(feedback.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("create feedback error", e); + throw new TimiException(TimiCode.ERROR, "create feedback error", e); + } finally { + loggerService.create(logger); + } + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void update(Feedback feedback) { + Logger logger = new Logger(Logger.Module.COMMON, "COMMON_FEEDBACK_UPDATE"); + try { + TimiException.required(feedback, "未找到反馈"); + TimiException.required(feedback.getId(), "未找到反馈 ID"); + Feedback dbFeedback = get(feedback.getId()); + TimiException.required(dbFeedback, "未找到反馈"); + + feedback.getContent().setId(dbFeedback.getContentId()); + articleService.update(feedback.getContent()); + + dbFeedback.setModule(TimiJava.defaultIfNull(feedback.getModule(), dbFeedback.getModule())); + if (TimiJava.isNotEmpty(feedback.getResponse())) { + if (TimiJava.isEmpty(feedback.getResponseId())) { + articleService.create(feedback.getResponse()); + dbFeedback.setResponseId(feedback.getResponse().getId()); + dbFeedback.setRespondedAt(Time.now()); + } else { + feedback.getResponse().setId(dbFeedback.getResponseId()); + articleService.update(feedback.getResponse()); + } + dbFeedback.setStatus(Feedback.Status.REPLIED); + dbFeedback.setRespondedBy(userLoginService.getRequireLoginUserId()); + } + super.update(dbFeedback); + logger.setLevel(Logger.Level.INFO); + logger.setResult(feedback.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); + } + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void delete(String id) { + Logger logger = new Logger(Logger.Module.COMMON, "COMMON_FEEDBACK_DELETE"); + try { + Feedback feedback = get(id); + articleService.delete(feedback.getContentId()); + if (TimiJava.isNotEmpty(feedback.getResponseId())) { + articleService.delete(feedback.getResponseId()); + } + super.delete(id); + logger.setLevel(Logger.Level.INFO); + logger.setResult(id); + } 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("delete feedback error", e); + throw new TimiException(TimiCode.ERROR, "delete feedback error", e); + } finally { + loggerService.create(logger); + } + } +} diff --git a/src/main/resources/db/migration/timiserver/V33__create_feedback_table.sql b/src/main/resources/db/migration/timiserver/V33__create_feedback_table.sql new file mode 100644 index 0000000..041c696 --- /dev/null +++ b/src/main/resources/db/migration/timiserver/V33__create_feedback_table.sql @@ -0,0 +1,21 @@ +CREATE TABLE `feedback` ( + `id` CHAR(36) NOT NULL COMMENT '主键', + `module` VARCHAR(32) NOT NULL COMMENT '所属模块', + `user_id` CHAR(36) NULL COMMENT '提交用户 ID,匿名反馈为空', + `content_id` VARCHAR(36) NOT NULL COMMENT '反馈内容文章 ID', + `contact` VARCHAR(255) NULL COMMENT '联系方式', + `ip` VARCHAR(64) NULL COMMENT '提交 IP', + `status` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '处理状态', + `response_id` VARCHAR(36) NULL COMMENT '管理员回复文章 ID', + `responded_by` CHAR(36) NULL COMMENT '回复用户 ID', + `responded_at` BIGINT NULL COMMENT '回复时间', + `created_at` BIGINT NOT NULL COMMENT '创建时间', + `updated_at` BIGINT NOT NULL COMMENT '更新时间', + `deleted_at` BIGINT NULL COMMENT '删除时间', + PRIMARY KEY (`id`), + KEY `idx_feedback_module_status_created` (`module`, `status`, `created_at`), + KEY `idx_feedback_user_created` (`user_id`, `created_at`), + KEY `idx_feedback_content_id` (`content_id`), + KEY `idx_feedback_response_id` (`response_id`), + KEY `idx_feedback_deleted_at` (`deleted_at`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '通用反馈';