feat(file、requirement): 优化文件服务和需求管理功能

- 新增按配置ID和路径获取文件的方法
- 实现文件不存在时的404响应处理
- 优化文件上传路径生成逻辑,使用UUID确保文件名安全性
- 添加需求执行选项查询接口和实现
- 支持排除特定状态码的需求分页查询
- 重构需求模块过滤逻辑并提取公共方法
- 修复S3客户端对象键解析问题
This commit is contained in:
dk
2026-07-06 16:50:52 +08:00
parent 9e502e6a80
commit 66c187f3de
8 changed files with 98 additions and 49 deletions

View File

@@ -76,6 +76,13 @@ public class ProjectRequirementController {
return success(requirementService.getRequirementTree(pageReqVO)); return success(requirementService.getRequirementTree(pageReqVO));
} }
@GetMapping("/execution-options")
@Operation(summary = "获取执行可关联的项目需求下拉选项")
public CommonResult<PageResult<ProjectRequirementRespVO>> getExecutionRequirementOptions(
@Valid ProjectRequirementPageReqVO pageReqVO) {
return success(requirementService.getExecutionRequirementOptions(pageReqVO));
}
@PostMapping("/change-status") @PostMapping("/change-status")
@Operation(summary = "变更需求状态") @Operation(summary = "变更需求状态")
public CommonResult<Boolean> changeRequirementStatus(@Valid @RequestBody ProjectRequirementStatusActionReqVO reqVO) { public CommonResult<Boolean> changeRequirementStatus(@Valid @RequestBody ProjectRequirementStatusActionReqVO reqVO) {

View File

@@ -22,20 +22,28 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
* 分页查询需求列表 * 分页查询需求列表
*/ */
default PageResult<ProjectRequirementDO> selectPage(ProjectRequirementPageReqVO reqVO) { default PageResult<ProjectRequirementDO> selectPage(ProjectRequirementPageReqVO reqVO) {
return selectPage(reqVO, buildQueryWrapper(reqVO)); return selectPage(reqVO, buildQueryWrapper(reqVO, null));
}
/**
* 分页查询需求列表,并排除指定状态集合。
*/
default PageResult<ProjectRequirementDO> selectPage(ProjectRequirementPageReqVO reqVO, Collection<String> excludeStatusCodes) {
return selectPage(reqVO, buildQueryWrapper(reqVO, excludeStatusCodes));
} }
/** /**
* 查询所有符合条件的需求列表,用于树查询 * 查询所有符合条件的需求列表,用于树查询
*/ */
default List<ProjectRequirementDO> selectList(ProjectRequirementPageReqVO reqVO) { default List<ProjectRequirementDO> selectList(ProjectRequirementPageReqVO reqVO) {
return selectList(buildQueryWrapper(reqVO)); return selectList(buildQueryWrapper(reqVO, null));
} }
/** /**
* 构建查询条件 * 构建查询条件
*/ */
private LambdaQueryWrapperX<ProjectRequirementDO> buildQueryWrapper(ProjectRequirementPageReqVO reqVO) { private LambdaQueryWrapperX<ProjectRequirementDO> buildQueryWrapper(ProjectRequirementPageReqVO reqVO,
Collection<String> excludeStatusCodes) {
LambdaQueryWrapperX<ProjectRequirementDO> queryWrapper = new LambdaQueryWrapperX<>(); LambdaQueryWrapperX<ProjectRequirementDO> queryWrapper = new LambdaQueryWrapperX<>();
if (StringUtils.hasText(reqVO.getTitle())) { if (StringUtils.hasText(reqVO.getTitle())) {
queryWrapper.like(ProjectRequirementDO::getTitle, reqVO.getTitle()); queryWrapper.like(ProjectRequirementDO::getTitle, reqVO.getTitle());
@@ -49,8 +57,11 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
.inIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleIds()) .inIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleIds())
.eqIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleId()) .eqIfPresent(ProjectRequirementDO::getModuleId, reqVO.getModuleId())
.eqIfPresent(ProjectRequirementDO::getParentId, reqVO.getParentId()) .eqIfPresent(ProjectRequirementDO::getParentId, reqVO.getParentId())
.eq(ProjectRequirementDO::getProjectId, reqVO.getProjectId()) .eq(ProjectRequirementDO::getProjectId, reqVO.getProjectId());
.orderByAsc(ProjectRequirementDO::getSort) if (excludeStatusCodes != null && !excludeStatusCodes.isEmpty()) {
queryWrapper.notIn(ProjectRequirementDO::getStatusCode, excludeStatusCodes);
}
queryWrapper.orderByAsc(ProjectRequirementDO::getSort)
.orderByDesc(ProjectRequirementDO::getCreateTime); .orderByDesc(ProjectRequirementDO::getCreateTime);
return queryWrapper; return queryWrapper;
} }

View File

@@ -32,6 +32,8 @@ public interface ProjectRequirementService {
PageResult<ProjectRequirementRespVO> getRequirementTree(ProjectRequirementPageReqVO pageReqVO); PageResult<ProjectRequirementRespVO> getRequirementTree(ProjectRequirementPageReqVO pageReqVO);
PageResult<ProjectRequirementRespVO> getExecutionRequirementOptions(ProjectRequirementPageReqVO pageReqVO);
void changeRequirementStatus(ProjectRequirementStatusActionReqVO reqVO); void changeRequirementStatus(ProjectRequirementStatusActionReqVO reqVO);
void changeRequirementStatusForReview(ProjectRequirementStatusActionReqVO reqVO); void changeRequirementStatusForReview(ProjectRequirementStatusActionReqVO reqVO);

View File

@@ -255,21 +255,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
@Override @Override
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", accessible = true) @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", accessible = true)
public PageResult<ProjectRequirementRespVO> getRequirementPage(ProjectRequirementPageReqVO pageReqVO) { public PageResult<ProjectRequirementRespVO> getRequirementPage(ProjectRequirementPageReqVO pageReqVO) {
if (pageReqVO.getModuleId() != null) { normalizePageModuleFilter(pageReqVO);
if (isAllRequirementsModule(pageReqVO.getModuleId())) {
pageReqVO.setModuleId(null);
} else {
List<Long> moduleIds = getAllModuleIdsWithChildren(pageReqVO.getModuleId(), pageReqVO.getProjectId());
pageReqVO.setModuleIds(moduleIds);
pageReqVO.setModuleId(null);
}
}
PageResult<ProjectRequirementDO> pageResult = requirementMapper.selectPage(pageReqVO); PageResult<ProjectRequirementDO> pageResult = requirementMapper.selectPage(pageReqVO);
Map<String, ObjectStatusModelDO> statusModelMap = getStatusModelMap(); return toRequirementRespPage(pageResult);
List<ProjectRequirementRespVO> list = pageResult.getList().stream()
.map(requirement -> buildRequirementRespVO(requirement, statusModelMap))
.collect(Collectors.toList());
return new PageResult<>(list, pageResult.getTotal());
} }
@Override @Override
@@ -325,6 +313,16 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
return new PageResult<>(list, (long) total); return new PageResult<>(list, (long) total);
} }
@Override
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", accessible = true)
public PageResult<ProjectRequirementRespVO> getExecutionRequirementOptions(ProjectRequirementPageReqVO pageReqVO) {
normalizePageModuleFilter(pageReqVO);
Set<String> excludedStatusCodes = new HashSet<>(getTerminalStatusCodes());
excludedStatusCodes.add(STATUS_REVIEW_REJECTED);
PageResult<ProjectRequirementDO> pageResult = requirementMapper.selectPage(pageReqVO, excludedStatusCodes);
return toRequirementRespPage(pageResult);
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId", @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId",
@@ -749,6 +747,27 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
return childrenMap; return childrenMap;
} }
private void normalizePageModuleFilter(ProjectRequirementPageReqVO pageReqVO) {
if (pageReqVO.getModuleId() == null) {
return;
}
if (isAllRequirementsModule(pageReqVO.getModuleId())) {
pageReqVO.setModuleId(null);
return;
}
List<Long> moduleIds = getAllModuleIdsWithChildren(pageReqVO.getModuleId(), pageReqVO.getProjectId());
pageReqVO.setModuleIds(moduleIds);
pageReqVO.setModuleId(null);
}
private PageResult<ProjectRequirementRespVO> toRequirementRespPage(PageResult<ProjectRequirementDO> pageResult) {
Map<String, ObjectStatusModelDO> statusModelMap = getStatusModelMap();
List<ProjectRequirementRespVO> list = pageResult.getList().stream()
.map(requirement -> buildRequirementRespVO(requirement, statusModelMap))
.collect(Collectors.toList());
return new PageResult<>(list, pageResult.getTotal());
}
/** /**
* 一次性加载当前对象类型下的状态模型,供列表和树查询复用。 * 一次性加载当前对象类型下的状态模型,供列表和树查询复用。
*/ */

View File

@@ -131,6 +131,13 @@ public class FileController {
// https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/ // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/
path = URLUtil.decode(path, StandardCharsets.UTF_8, false); path = URLUtil.decode(path, StandardCharsets.UTF_8, false);
FileDO file = fileService.getFileByConfigIdAndPath(configId, path);
if (file == null) {
log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path);
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
}
// 读取内容 // 读取内容
byte[] content = fileService.getFileContent(configId, path); byte[] content = fileService.getFileContent(configId, path);
if (content == null) { if (content == null) {
@@ -138,7 +145,7 @@ public class FileController {
response.setStatus(HttpStatus.NOT_FOUND.value()); response.setStatus(HttpStatus.NOT_FOUND.value());
return; return;
} }
writeAttachment(response, path, content); writeAttachment(response, StrUtil.blankToDefault(file.getName(), file.getPath()), content);
} }
@GetMapping("/page") @GetMapping("/page")

View File

@@ -136,11 +136,11 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
} }
private String resolveObjectKey(String urlOrPath) { private String resolveObjectKey(String urlOrPath) {
String cleaned = HttpUtils.removeUrlQuery(urlOrPath); if (!HttpUtil.isHttp(urlOrPath) && !HttpUtil.isHttps(urlOrPath)) {
if (!HttpUtil.isHttp(cleaned) && !HttpUtil.isHttps(cleaned)) { return urlOrPath;
return cleaned;
} }
String cleaned = HttpUtils.removeUrlQuery(urlOrPath);
String path = StrUtil.removePrefix(cleaned, config.getDomain() + "/"); String path = StrUtil.removePrefix(cleaned, config.getDomain() + "/");
if (StrUtil.equals(path, cleaned)) { if (StrUtil.equals(path, cleaned)) {
URI uri = URI.create(cleaned); URI uri = URI.create(cleaned);

View File

@@ -63,6 +63,15 @@ public interface FileService {
Long createFile(FileCreateReqVO createReqVO); Long createFile(FileCreateReqVO createReqVO);
FileDO getFile(Long id); FileDO getFile(Long id);
/**
* 按 (configId, path) 获取文件记录。
*
* @param configId 文件配置编号
* @param path 文件存储路径
* @return 文件记录,不存在返回 null
*/
FileDO getFileByConfigIdAndPath(Long configId, String path);
/** /**
* 通过文件 URL 获得文件。 * 通过文件 URL 获得文件。
* *

View File

@@ -3,6 +3,7 @@ package com.njcn.rdms.module.system.service.file;
import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Assert; import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.crypto.digest.DigestUtil;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
@@ -42,14 +43,6 @@ public class FileServiceImpl implements FileService {
* 目的:按照日期,进行分目录 * 目的:按照日期,进行分目录
*/ */
static boolean PATH_PREFIX_DATE_ENABLE = true; static boolean PATH_PREFIX_DATE_ENABLE = true;
/**
* 上传文件的后缀,是否包含时间戳
* <p>
* 目的:保证文件的唯一性,避免覆盖
* 定制:可按需调整成 UUID、或者其他方式
*/
static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true;
@Resource @Resource
private FileConfigService fileConfigService; private FileConfigService fileConfigService;
@@ -103,34 +96,27 @@ public class FileServiceImpl implements FileService {
@VisibleForTesting @VisibleForTesting
String generateUploadPath(String name, String directory) { String generateUploadPath(String name, String directory) {
// 1. 生成前缀、后缀 // 1. 生成前缀,并将存储文件名切换为安全 key
// path 仅承担对象存储 key / 路径职责,不再复用原文件名,避免 %、&、中文等字符进入 URI 路径。
String prefix = null; String prefix = null;
if (PATH_PREFIX_DATE_ENABLE) { if (PATH_PREFIX_DATE_ENABLE) {
prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN); prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN);
} }
String suffix = null; String ext = FileUtil.extName(name);
if (PATH_SUFFIX_TIMESTAMP_ENABLE) { String storageName = IdUtil.fastSimpleUUID();
suffix = String.valueOf(System.currentTimeMillis()); if (StrUtil.isNotEmpty(ext)) {
storageName = storageName + StrUtil.DOT + ext;
} }
// 2.1 先拼接 suffix // 2. 先拼接 prefix
if (StrUtil.isNotEmpty(suffix)) {
String ext = FileUtil.extName(name);
if (StrUtil.isNotEmpty(ext)) {
name = FileUtil.mainName(name) + StrUtil.C_UNDERLINE + suffix + StrUtil.DOT + ext;
} else {
name = name + StrUtil.C_UNDERLINE + suffix;
}
}
// 2.2 再拼接 prefix 前缀
if (StrUtil.isNotEmpty(prefix)) { if (StrUtil.isNotEmpty(prefix)) {
name = prefix + StrUtil.SLASH + name; storageName = prefix + StrUtil.SLASH + storageName;
} }
// 2.3 最后拼接 directory 目录 // 3. 最后拼接 directory 目录
if (StrUtil.isNotEmpty(directory)) { if (StrUtil.isNotEmpty(directory)) {
name = directory + StrUtil.SLASH + name; storageName = directory + StrUtil.SLASH + storageName;
} }
return name; return storageName;
} }
@Override @Override
@@ -166,6 +152,14 @@ public class FileServiceImpl implements FileService {
return validateFileExists(id); return validateFileExists(id);
} }
@Override
public FileDO getFileByConfigIdAndPath(Long configId, String path) {
if (configId == null || StrUtil.isBlank(path)) {
return null;
}
return fileMapper.selectByConfigIdAndPath(configId, path);
}
@Override @Override
public FileDO getFileByUrl(String url) { public FileDO getFileByUrl(String url) {
if (StrUtil.isBlank(url)) { if (StrUtil.isBlank(url)) {