feat(system): 添加用户意见反馈功能

- 新增意见反馈数据对象和数据库映射
- 实现意见反馈的增删改查和状态更新接口
- 添加意见反馈分类和处理状态字典常量
- 实现意见反馈分页查询和统计功能
- 定义意见反馈相关的请求响应对象
- 添加意见反馈不存在的错误码定义
This commit is contained in:
2026-06-26 16:13:41 +08:00
parent 69e9ea6b9f
commit 9a61de0273
13 changed files with 543 additions and 0 deletions

View File

@@ -42,4 +42,9 @@ public interface DictTypeConstants {
*/
String RDMS_OVERTIME_DURATION = "rdms_overtime_duration";
/** 意见反馈分类 */
String FEEDBACK_TYPE = "feedback_type";
/** 意见反馈处理状态 */
String FEEDBACK_STATUS = "feedback_status";
}

View File

@@ -99,6 +99,9 @@ public interface ErrorCodeConstants {
// ========== 通知公告 1-002-008-000 ==========
ErrorCode NOTICE_NOT_FOUND = new ErrorCode(1_002_008_001, "当前通知公告不存在");
// ========== 用户意见反馈 1-002-009-000 ==========
ErrorCode FEEDBACK_NOT_FOUND = new ErrorCode(1_002_009_001, "用户意见反馈不存在");
// ========= 文件相关 1-001-003-000 =================
ErrorCode FILE_PATH_EXISTS = new ErrorCode(1_001_003_000, "文件路径已存在");
ErrorCode FILE_NOT_EXISTS = new ErrorCode(1_001_003_001, "文件不存在");

View File

@@ -0,0 +1,83 @@
package com.njcn.rdms.module.system.controller.admin.feedback;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackPageReqVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackRespVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackSaveReqVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackStatRespVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackStatusReqVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackUpdateReqVO;
import com.njcn.rdms.module.system.service.feedback.FeedbackService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
// 临时功能:意见反馈过测试阶段即下线。全部接口均「有意」不挂后端鉴权(仅过 token需登录
// 按钮可见性(仅 admin 可见)由前端控制;并非漏挂。如需恢复鉴权,给对应接口加回
// @PreAuthorize("@ss.hasPermission('system:feedback:xxx')") 并配 system_menu 权限码 + 角色授权。
@Tag(name = "管理后台 - 用户意见反馈")
@RestController
@RequestMapping("/system/feedback")
@Validated
public class FeedbackController {
@Resource
private FeedbackService feedbackService;
@PostMapping("/create")
@Operation(summary = "提交意见反馈")
public CommonResult<Long> createFeedback(@Valid @RequestBody FeedbackSaveReqVO reqVO) {
return success(feedbackService.createFeedback(reqVO));
}
@PutMapping("/update")
@Operation(summary = "修改意见反馈")
public CommonResult<Boolean> updateFeedback(@Valid @RequestBody FeedbackUpdateReqVO reqVO) {
feedbackService.updateFeedback(reqVO);
return success(true);
}
@GetMapping("/page")
@Operation(summary = "获得意见反馈分页(全量)")
public CommonResult<PageResult<FeedbackRespVO>> getFeedbackPage(@Valid FeedbackPageReqVO pageReqVO) {
return success(feedbackService.getFeedbackPage(pageReqVO));
}
@GetMapping("/stat")
@Operation(summary = "意见反馈统计(全量:总数 + 按分类 / 按状态分组计数)")
public CommonResult<FeedbackStatRespVO> getFeedbackStat() {
return success(feedbackService.getFeedbackStat());
}
@GetMapping("/get")
@Operation(summary = "获得意见反馈详情")
@Parameter(name = "id", description = "编号", required = true)
public CommonResult<FeedbackRespVO> getFeedback(@RequestParam("id") Long id) {
return success(feedbackService.getFeedback(id));
}
@PutMapping("/{id}/status")
@Operation(summary = "修改意见反馈处理状态")
@Parameter(name = "id", description = "编号", required = true)
public CommonResult<Boolean> updateFeedbackStatus(@PathVariable("id") Long id,
@Valid @RequestBody FeedbackStatusReqVO reqVO) {
feedbackService.updateFeedbackStatus(id, reqVO);
return success(true);
}
@DeleteMapping("/delete")
@Operation(summary = "删除意见反馈")
@Parameter(name = "id", description = "编号", required = true)
public CommonResult<Boolean> deleteFeedback(@RequestParam("id") Long id) {
feedbackService.deleteFeedback(id);
return success(true);
}
}

View File

@@ -0,0 +1,22 @@
package com.njcn.rdms.module.system.controller.admin.feedback.vo;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Schema(description = "管理后台 - 用户意见反馈分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class FeedbackPageReqVO extends PageParam {
@Schema(description = "反馈分类(字典 feedback_type")
private Integer type;
@Schema(description = "处理状态(字典 feedback_status")
private Integer status;
@Schema(description = "标题关键词")
private String title;
}

View File

@@ -0,0 +1,44 @@
package com.njcn.rdms.module.system.controller.admin.feedback.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 用户意见反馈 Response VO")
@Data
public class FeedbackRespVO {
@Schema(description = "主键 ID")
private Long id;
@Schema(description = "反馈分类(字典 feedback_type")
private Integer type;
@Schema(description = "标题")
private String title;
@Schema(description = "详细描述")
private String content;
@Schema(description = "附件 URL 列表JSON 数组字符串)")
private String attachmentUrls;
@Schema(description = "联系方式")
private String contact;
@Schema(description = "处理状态(字典 feedback_status")
private Integer status;
@Schema(description = "提交人 user id")
private String creator;
@Schema(description = "提交人姓名(后端按 creator 翻译,查不到为空串)")
private String creatorName;
@Schema(description = "提交时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") // 本字段按可读字符串返回前端;全局默认是 Long 时间戳,此处显式覆盖
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,30 @@
package com.njcn.rdms.module.system.controller.admin.feedback.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - 用户意见反馈提交 Request VO")
@Data
public class FeedbackSaveReqVO {
@Schema(description = "反馈分类(字典 feedback_type", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "反馈分类不能为空")
private Integer type;
@Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "导出按钮点了没反应")
@NotBlank(message = "标题不能为空")
private String title;
@Schema(description = "详细描述", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "详细描述不能为空")
private String content;
@Schema(description = "附件 URL 列表JSON 数组字符串)", example = "[\"https://.../a.png\"]")
private String attachmentUrls;
@Schema(description = "联系方式", example = "微信 xxx")
private String contact;
}

View File

@@ -0,0 +1,51 @@
package com.njcn.rdms.module.system.controller.admin.feedback.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "管理后台 - 用户意见反馈统计 Response VO")
@Data
public class FeedbackStatRespVO {
@Schema(description = "全部未软删反馈总数")
private Integer total;
@Schema(description = "按分类分组的计数(覆盖字典 feedback_type 全部码值,无数据补 0顺序按字典 sort")
private List<TypeCount> typeCounts;
@Schema(description = "按处理状态分组的计数(覆盖字典 feedback_status 全部码值,无数据补 0顺序按字典 sort")
private List<StatusCount> statusCounts;
@Schema(description = "分类计数项")
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class TypeCount {
@Schema(description = "分类码值(字典 feedback_type")
private Integer type;
@Schema(description = "该分类下未软删反馈数")
private Integer count;
}
@Schema(description = "状态计数项")
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class StatusCount {
@Schema(description = "状态码值(字典 feedback_status")
private Integer status;
@Schema(description = "该状态下未软删反馈数")
private Integer count;
}
}

View File

@@ -0,0 +1,15 @@
package com.njcn.rdms.module.system.controller.admin.feedback.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - 修改意见反馈处理状态 Request VO")
@Data
public class FeedbackStatusReqVO {
@Schema(description = "目标状态(字典 feedback_status", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
@NotNull(message = "目标状态不能为空")
private Integer status;
}

View File

@@ -0,0 +1,17 @@
package com.njcn.rdms.module.system.controller.admin.feedback.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Schema(description = "管理后台 - 用户意见反馈更新 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class FeedbackUpdateReqVO extends FeedbackSaveReqVO {
@Schema(description = "主键 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "反馈编号不能为空")
private Long id;
}

View File

@@ -0,0 +1,36 @@
package com.njcn.rdms.module.system.dal.dataobject.feedback;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 用户意见反馈 DO
*/
@TableName("system_feedback")
@Data
@EqualsAndHashCode(callSuper = true)
public class FeedbackDO extends BaseDO {
@TableId
private Long id;
/** 反馈分类:字典 {@link com.njcn.rdms.module.system.enums.DictTypeConstants#FEEDBACK_TYPE}1缺陷 2体验问题 3功能建议 */
private Integer type;
/** 标题 */
private String title;
/** 详细描述 */
private String content;
/** 附件列表JSON 数组字符串可空。PUT 全量替换语义下前端传 null 需真正清空,故用 ALWAYS 跳过全局 NOT_NULL 策略(见 CLAUDE.md「接口语义」节 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String attachmentUrls;
/** 联系方式可空。同上PUT 传 null 需落库清空 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String contact;
/** 处理状态:字典 {@link com.njcn.rdms.module.system.enums.DictTypeConstants#FEEDBACK_STATUS}1待处理 2处理中 3已处理 4已忽略 */
private Integer status;
}

View File

@@ -0,0 +1,21 @@
package com.njcn.rdms.module.system.dal.mysql.feedback;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackPageReqVO;
import com.njcn.rdms.module.system.dal.dataobject.feedback.FeedbackDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FeedbackMapper extends BaseMapperX<FeedbackDO> {
default PageResult<FeedbackDO> selectPage(FeedbackPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<FeedbackDO>()
.eqIfPresent(FeedbackDO::getType, reqVO.getType())
.eqIfPresent(FeedbackDO::getStatus, reqVO.getStatus())
.likeIfPresent(FeedbackDO::getTitle, reqVO.getTitle())
.orderByDesc(FeedbackDO::getId));
}
}

View File

@@ -0,0 +1,37 @@
package com.njcn.rdms.module.system.service.feedback;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackPageReqVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackRespVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackSaveReqVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackStatRespVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackStatusReqVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackUpdateReqVO;
public interface FeedbackService {
/** 提交意见反馈,返回新建 id */
Long createFeedback(FeedbackSaveReqVO reqVO);
/** 修改意见反馈内容PUT 全量替换;不改 status/creator。临时功能后端不鉴权、仅过 token编辑权限由前端按钮可见性控制 */
void updateFeedback(FeedbackUpdateReqVO reqVO);
/** 分页查询全量不按提交人过滤creatorName 已按 creator 翻译为提交人姓名 */
PageResult<FeedbackRespVO> getFeedbackPage(FeedbackPageReqVO reqVO);
/** 查询详情,不存在抛 FEEDBACK_NOT_FOUNDcreatorName 已翻译 */
FeedbackRespVO getFeedback(Long id);
/** 修改处理状态(临时功能,后端不鉴权、仅过 token按钮可见性由前端控制 */
void updateFeedbackStatus(Long id, FeedbackStatusReqVO reqVO);
/** 删除(临时功能,后端不鉴权、仅过 token删除权限由前端按钮可见性控制 */
void deleteFeedback(Long id);
/**
* 全量统计:总数 + 按分类feedback_type/ 按状态feedback_status分组计数。
* 排除软删;两个维度均按字典全集补零(无数据的码值 count=0顺序按字典 sort。
*/
FeedbackStatRespVO getFeedbackStat();
}

View File

@@ -0,0 +1,179 @@
package com.njcn.rdms.module.system.service.feedback;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackPageReqVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackRespVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackSaveReqVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackStatRespVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackStatusReqVO;
import com.njcn.rdms.module.system.controller.admin.feedback.vo.FeedbackUpdateReqVO;
import com.njcn.rdms.module.system.dal.dataobject.feedback.FeedbackDO;
import com.njcn.rdms.module.system.dal.dataobject.user.AdminUserDO;
import com.njcn.rdms.module.system.dal.mysql.feedback.FeedbackMapper;
import com.njcn.rdms.module.system.enums.DictTypeConstants;
import com.njcn.rdms.module.system.service.dict.DictDataService;
import com.njcn.rdms.module.system.service.user.AdminUserService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.FEEDBACK_NOT_FOUND;
/**
* 用户意见反馈 Service 实现类
*/
@Service
public class FeedbackServiceImpl implements FeedbackService {
@Resource
private FeedbackMapper feedbackMapper;
@Resource
private AdminUserService adminUserService;
@Resource
private DictDataService dictDataService;
@Override
public Long createFeedback(FeedbackSaveReqVO reqVO) {
FeedbackDO feedback = BeanUtils.toBean(reqVO, FeedbackDO.class);
feedback.setStatus(1); // 默认「待处理」,不信任前端传入
feedbackMapper.insert(feedback);
return feedback.getId();
}
@Override
public void updateFeedback(FeedbackUpdateReqVO reqVO) {
// 临时功能,后端不鉴权(仅过 token编辑权限由前端按钮可见性控制
validateFeedbackExists(reqVO.getId());
// 只更新内容字段toBean 不带 status/creatorupdateById 不会动它们status 走专用接口、creator 为审计字段);
// attachmentUrls/contact 传 null 也会落库清空DO 上 FieldStrategy.ALWAYS
FeedbackDO update = BeanUtils.toBean(reqVO, FeedbackDO.class);
feedbackMapper.updateById(update);
}
@Override
public PageResult<FeedbackRespVO> getFeedbackPage(FeedbackPageReqVO reqVO) {
PageResult<FeedbackDO> pageResult = feedbackMapper.selectPage(reqVO);
return new PageResult<>(buildRespList(pageResult.getList()), pageResult.getTotal());
}
@Override
public FeedbackRespVO getFeedback(Long id) {
FeedbackDO feedback = validateFeedbackExists(id);
return buildRespList(Collections.singletonList(feedback)).get(0);
}
@Override
public void updateFeedbackStatus(Long id, FeedbackStatusReqVO reqVO) {
validateFeedbackExists(id);
FeedbackDO update = new FeedbackDO();
update.setId(id);
update.setStatus(reqVO.getStatus());
feedbackMapper.updateById(update);
}
@Override
public void deleteFeedback(Long id) {
// 临时功能,后端不鉴权(仅过 token删除权限由前端按钮可见性控制
validateFeedbackExists(id);
feedbackMapper.deleteById(id);
}
@Override
public FeedbackStatRespVO getFeedbackStat() {
// 一次性全量加载(软删自动排除),内存按两个维度聚合:同一数据快照天然保证 total 与各维度计数之和一致
List<FeedbackDO> all = feedbackMapper.selectList();
Map<Integer, Long> typeCountMap = all.stream()
.filter(item -> item.getType() != null)
.collect(Collectors.groupingBy(FeedbackDO::getType, Collectors.counting()));
Map<Integer, Long> statusCountMap = all.stream()
.filter(item -> item.getStatus() != null)
.collect(Collectors.groupingBy(FeedbackDO::getStatus, Collectors.counting()));
FeedbackStatRespVO stat = new FeedbackStatRespVO();
stat.setTotal(all.size());
// 按字典全集补零:无数据的码值也返回 count=0保证消费端结构稳定无需自己补零
stat.setTypeCounts(dictCodes(DictTypeConstants.FEEDBACK_TYPE).stream()
.map(code -> new FeedbackStatRespVO.TypeCount(code, countOf(typeCountMap, code)))
.collect(Collectors.toList()));
stat.setStatusCounts(dictCodes(DictTypeConstants.FEEDBACK_STATUS).stream()
.map(code -> new FeedbackStatRespVO.StatusCount(code, countOf(statusCountMap, code)))
.collect(Collectors.toList()));
return stat;
}
/** 取字典某类型下全部码值(含停用项),顺序按字典 sort非数字 value 跳过 */
private List<Integer> dictCodes(String dictType) {
return dictDataService.getDictDataListByDictType(dictType).stream()
.map(dict -> parseDictValue(dict.getValue()))
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
}
private Integer countOf(Map<Integer, Long> countMap, Integer code) {
return countMap.getOrDefault(code, 0L).intValue();
}
/** 字典 value 解析为整数码值;空 / 非数字返回 null避免脏字典数据炸接口 */
private Integer parseDictValue(String value) {
if (StrUtil.isBlank(value)) {
return null;
}
try {
return Integer.valueOf(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
@VisibleForTesting
public FeedbackDO validateFeedbackExists(Long id) {
FeedbackDO feedback = feedbackMapper.selectById(id);
if (feedback == null) {
throw exception(FEEDBACK_NOT_FOUND);
}
return feedback;
}
/** DO 列表转 RespVO并把 creatoruser id 字符串)批量翻译成提交人姓名,避免逐行查库 */
private List<FeedbackRespVO> buildRespList(List<FeedbackDO> list) {
if (CollUtil.isEmpty(list)) {
return new ArrayList<>();
}
Set<Long> userIds = list.stream()
.map(feedback -> parseUserId(feedback.getCreator()))
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, AdminUserDO> userMap = adminUserService.getUserMap(userIds);
return BeanUtils.toBean(list, FeedbackRespVO.class, resp -> {
Long uid = parseUserId(resp.getCreator());
AdminUserDO user = uid == null ? null : userMap.get(uid);
resp.setCreatorName(user != null ? user.getNickname() : "");
});
}
/** creator 是 String 形态的 user id解析为 Long空/非数字返回 null */
private Long parseUserId(String creator) {
if (StrUtil.isBlank(creator)) {
return null;
}
try {
return Long.valueOf(creator.trim());
} catch (NumberFormatException e) {
return null;
}
}
}