feat(qms): 实现文件锁定及版本保存功能

- 文件上传返回结果增加文件大小信息,单位KB
- 新增文件锁定接口,支持用户锁定文件避免并发编辑
- 实现文件锁定延时刷新,延长锁定时间避免自动解锁
- 新增保存新版本接口,保存版本历史记录并更新文件版本号
- 文件详情展示当前锁定用户名称,锁定超时自动清理锁定状态
- 添加文件修改历史实体及服务,实现版本历史的持久化存储
- 对文件权限进行多级校验,确保锁定和保存操作权限正确
- 优化文件新增逻辑,绑定文件扩展名及大小信息
- 调整文件查询逻辑,自动清理Redis中已过期的锁定信息
- 新增相关验证注解,确保传入数据合法性
- 细节调整代码格式及空格,提升代码规范性和可读性
This commit is contained in:
曹鹏飞 2026-05-07 16:00:50 +08:00
parent 15149edb4d
commit 4a3bb48b91
14 changed files with 326 additions and 48 deletions

View File

@ -1,5 +1,7 @@
package com.nflg.qms.admin.controller;
import cn.hutool.core.io.unit.DataSize;
import cn.hutool.core.io.unit.DataUnit;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
@ -71,7 +73,7 @@ public class FileUpLoadController extends BaseController {
.setCreateBy(UserUtil.getUserName())
.setCreateTime(LocalDateTime.now());
fileControllerService.add(record);
return ApiResult.success(new FileUploadVO(record.getId(), fileName, url));
return ApiResult.success(new FileUploadVO(record.getId(), fileName, url, DataSize.of(file.getSize(), DataUnit.BYTES).toKilobytes()));
} catch (Exception ex) {
log.error("上传文件失败", ex);
throw new NflgException(STATE.BusinessError, "上传文件失败:" + ex.getMessage());

View File

@ -1,5 +1,6 @@
package com.nflg.qms.admin.controller;
import com.nflg.qms.admin.pojo.qo.QmsFileSaveVersionQO;
import com.nflg.qms.admin.service.QmsFileControllerService;
import com.nflg.wms.common.pojo.ApiResult;
import com.nflg.wms.common.pojo.PageData;
@ -9,12 +10,22 @@ import com.nflg.wms.common.pojo.qo.QmsFileSearchQO;
import com.nflg.wms.common.pojo.qo.QmsFileUpdateQO;
import com.nflg.wms.common.pojo.vo.QmsFileCategoryTreeVO;
import com.nflg.wms.common.pojo.vo.QmsFileVO;
import com.nflg.wms.common.util.UserUtil;
import com.nflg.wms.common.util.VUtil;
import com.nflg.wms.repository.entity.User;
import com.nflg.wms.repository.service.IUserService;
import com.nflg.wms.starter.BaseController;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* 文件管理
*/
@ -43,9 +54,37 @@ public class QmsFileController extends BaseController {
return ApiResult.success();
}
/**
* 锁定文件以供编辑
* @param id 文件id
*/
@PostMapping("lockFile")
public ApiResult<Void> lockFile(@RequestParam Long id) {
fileControllerService.lockFile(id);
return ApiResult.success();
}
/**
* 延时锁定时间每10秒调用一次
* @param id 文件id
*/
@PostMapping("lockDelay")
public ApiResult<Void> lockDelay(@RequestParam Long id){
fileControllerService.lockDelay(id);
return ApiResult.success();
}
/**
* 保存新版本文件
*/
@PostMapping("saveVersion")
public ApiResult<Void> saveVersion(@Valid @RequestBody QmsFileSaveVersionQO request){
fileControllerService.saveVersion(request);
return ApiResult.success();
}
/**
* 删除文件
*
* @param id 文件ID
*/
@PostMapping("delete")
@ -64,7 +103,6 @@ public class QmsFileController extends BaseController {
/**
* 获取文件详情
*
* @param id 文件ID
*/
@GetMapping("detail")

View File

@ -33,7 +33,6 @@ public class QmsFileMemberController extends BaseController {
/**
* 删除组员
*
* @param id 组员记录ID
*/
@PostMapping("delete")
@ -44,7 +43,6 @@ public class QmsFileMemberController extends BaseController {
/**
* 查询目标的所有组员
*
* @param targetType 关联类型1-分类2-文件
* @param targetId 关联ID
*/

View File

@ -0,0 +1,23 @@
package com.nflg.qms.admin.pojo.qo;
import com.nflg.wms.common.pojo.vo.FileUploadVO;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class QmsFileSaveVersionQO {
/**
* 文件id
*/
@NotNull
private Long id;
/**
* 文件信息
*/
@Valid
@NotNull(message = "文件信息不能为空")
private FileUploadVO fileInfo;
}

View File

@ -1,9 +1,11 @@
package com.nflg.qms.admin.service;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nflg.qms.admin.pojo.qo.QmsFileSaveVersionQO;
import com.nflg.wms.common.constant.STATE;
import com.nflg.wms.common.exception.NflgException;
import com.nflg.wms.common.pojo.PageData;
@ -16,15 +18,14 @@ import com.nflg.wms.common.pojo.vo.QmsFileMemberVO;
import com.nflg.wms.common.pojo.vo.QmsFileVO;
import com.nflg.wms.common.util.PageUtil;
import com.nflg.wms.common.util.UserUtil;
import com.nflg.wms.repository.entity.QmsFile;
import com.nflg.wms.repository.entity.QmsFileCategory;
import com.nflg.wms.repository.entity.QmsFileMember;
import com.nflg.wms.repository.service.IQmsFileCategoryService;
import com.nflg.wms.repository.service.IQmsFileMemberService;
import com.nflg.wms.repository.service.IQmsFileService;
import com.nflg.wms.common.util.VUtil;
import com.nflg.wms.repository.entity.*;
import com.nflg.wms.repository.service.*;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@ -38,6 +39,7 @@ import java.util.LinkedList;
import java.util.Objects;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
@ -56,6 +58,17 @@ public class QmsFileControllerService {
@Resource
private IQmsFileMemberService fileMemberService;
@Resource
private RedisTemplate<String, Long> redisTemplate;
@Resource
private IUserService userService;
@Resource
private IQmsFileHistoryService historyService;
private static final String LOCK_KEY = "qms:file:lock:{}";
/**
* 新增文件
*/
@ -77,11 +90,9 @@ public class QmsFileControllerService {
QmsFile entity = new QmsFile()
.setCategoryId(request.getCategoryId())
.setName(request.getName())
.setFileExt(request.getFileExt())
.setFileSize(request.getFileSize())
.setStoragePath(request.getStoragePath())
.setStorageName(request.getStorageName())
.setMimeType(request.getMimeType())
.setFileExt(getFileType(request.getFileInfo().getFileName()))
.setFileSize(request.getFileInfo().getSize())
.setStoragePath(request.getFileInfo().getUrl())
.setDescription(request.getDescription())
.setCreateBy(operator)
.setCreateUserId(userId)
@ -93,6 +104,10 @@ public class QmsFileControllerService {
fileService.save(entity);
}
private String getFileType(String fileName) {
return "." + FilenameUtils.getExtension(fileName);
}
/**
* 修改文件
*/
@ -386,7 +401,7 @@ public class QmsFileControllerService {
// 筛选可见分类
List<QmsFileCategory> visibleCategories = allCategories.stream()
.filter(c -> allVisibleIds.contains(c.getId()))
.collect(Collectors.toList());
.toList();
// 按文件分组
Map<Long, List<QmsFile>> filesByCategory = matchedFiles.stream()
@ -419,6 +434,7 @@ public class QmsFileControllerService {
}
// 转换为树形VO
List<Long> fileIds = new ArrayList<>();
List<QmsFileCategoryTreeVO> voList = visibleCategories.stream()
.map(c -> {
QmsFileCategoryTreeVO vo = BeanUtil.copyProperties(c, QmsFileCategoryTreeVO.class);
@ -430,6 +446,12 @@ public class QmsFileControllerService {
if (categoryFiles != null && !categoryFiles.isEmpty()) {
List<QmsFileVO> fileVOs = categoryFiles.stream()
.map(f -> {
if (StrUtil.isNotBlank(f.getCurrentLockUserName())) {
if (!redisTemplate.hasKey(StrUtil.format(LOCK_KEY, f.getId()))) {
f.setCurrentLockUserName(null);
fileIds.add(f.getId());
}
}
QmsFileVO fvo = BeanUtil.copyProperties(f, QmsFileVO.class);
fvo.setCategoryName(c.getName());
fvo.setMembers(membersByFileId.get(f.getId()));
@ -442,6 +464,12 @@ public class QmsFileControllerService {
return vo;
})
.collect(Collectors.toList());
if (CollectionUtil.isNotEmpty(fileIds)) {
fileService.lambdaUpdate()
.in(QmsFile::getId, fileIds)
.set(QmsFile::getCurrentLockUserName, null)
.update();
}
// 构建树
List<QmsFileCategoryTreeVO> tree;
@ -577,4 +605,90 @@ public class QmsFileControllerService {
throw new NflgException(STATE.BusinessError, "无权限操作此文件");
}
}
public void lockFile(Long id) {
Long currentUserId = UserUtil.getUserId();
// 校验写权限文件创建者 文件组员(读写权限) 所属分类/任意上级分类的读写成员
QmsFile file = fileService.getById(id);
VUtil.trueThrowBusinessError(Objects.isNull(file)).throwMessage("文件不存在");
// 1. 文件创建者
boolean hasPermission = Objects.equals(file.getCreateUserId(), currentUserId);
// 2. 文件级读写组员
if (!hasPermission) {
hasPermission = fileMemberService.lambdaQuery()
.eq(QmsFileMember::getTargetType, (short) 2)
.eq(QmsFileMember::getTargetId, id)
.eq(QmsFileMember::getUserId, currentUserId)
.eq(QmsFileMember::getPermissionType, (short) 2)
.exists();
}
// 3. 文件所属分类或任意上级分类的读写成员
if (!hasPermission) {
Long categoryId = file.getCategoryId();
while (!hasPermission && categoryId != null && categoryId > 0) {
boolean isCategoryMember = fileMemberService.lambdaQuery()
.eq(QmsFileMember::getTargetType, (short) 1)
.eq(QmsFileMember::getTargetId, categoryId)
.eq(QmsFileMember::getUserId, currentUserId)
.eq(QmsFileMember::getPermissionType, (short) 2)
.exists();
if (isCategoryMember) {
hasPermission = true;
} else {
QmsFileCategory parent = categoryService.getById(categoryId);
categoryId = (parent != null) ? parent.getParentId() : null;
}
}
}
VUtil.trueThrowBusinessError(!hasPermission).throwMessage("无权限锁定文件");
String key = StrUtil.format(LOCK_KEY, id);
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, currentUserId, 15, TimeUnit.SECONDS);
// setIfAbsent 返回 false 说明 key 已存在文件已被他人锁定
if (Boolean.FALSE.equals(result)) {
Long lockedUserId = redisTemplate.opsForValue().get(key);
if (lockedUserId != null) {
User lockedUser = userService.getById(lockedUserId);
VUtil.trueThrowBusinessError(true).throwMessage("文件已被用户【" + lockedUser.getUserName() + "】锁定");
}
// key 在两次 Redis 操作间隙恰好过期仍属于锁定失败需兜底报错
VUtil.trueThrowBusinessError(true).throwMessage("文件已被锁定");
} else {
file.setCurrentLockUserName(UserUtil.getUserName());
fileService.updateById(file);
}
}
public void lockDelay(Long id) {
String key = StrUtil.format(LOCK_KEY, id);
Long userId = redisTemplate.opsForValue().get(key);
VUtil.trueThrowBusinessError(!Objects.equals(UserUtil.getUserId(), userId))
.throwMessage("文件未被当前用户锁定");
redisTemplate.expire(key, 15, TimeUnit.SECONDS);
}
@Transactional
public void saveVersion(@Valid QmsFileSaveVersionQO request) {
String key = StrUtil.format(LOCK_KEY, request.getId());
Long userId = redisTemplate.opsForValue().get(key);
VUtil.trueThrowBusinessError(!Objects.equals(UserUtil.getUserId(), userId))
.throwMessage("文件未被当前用户锁定");
QmsFile file = fileService.getById(request.getId());
VUtil.trueThrowBusinessError(Objects.isNull(file)).throwMessage("文件不存在");
QmsFileHistory history = new QmsFileHistory()
.setFileId(file.getId())
.setVersion(file.getVersion())
.setFileSize(file.getFileSize())
.setCreateUserId(file.getUpdateUserId())
.setCreateBy(file.getUpdateBy())
.setCreateTime(file.getUpdateTime());
historyService.save(history);
file.setFileSize(request.getFileInfo().getSize());
file.setStoragePath(request.getFileInfo().getUrl());
file.setVersion(file.getVersion() + 1);
file.setUpdateBy(UserUtil.getUserName());
file.setUpdateUserId(UserUtil.getUserId());
file.setUpdateTime(LocalDateTime.now());
fileService.updateById(file);
redisTemplate.delete(key);
}
}

View File

@ -1,5 +1,7 @@
package com.nflg.wms.admin.controller;
import cn.hutool.core.io.unit.DataSize;
import cn.hutool.core.io.unit.DataUnit;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
@ -58,7 +60,7 @@ public class FileController extends BaseController {
String fileName = file.getOriginalFilename();
String fileType = getFileType(fileName);
String url = fileUploadService.upload(buildFilePath(fileType), file);
return ApiResult.success(new FileUploadVO(0L, fileName, url));
return ApiResult.success(new FileUploadVO(0L, fileName, url, DataSize.of(file.getSize(), DataUnit.BYTES).toKilobytes()));
} catch (Exception ex) {
log.error("上传文件失败", ex);
throw new NflgException(STATE.BusinessError, "上传文件失败:" + ex.getMessage());
@ -102,7 +104,7 @@ public class FileController extends BaseController {
*/
@PostMapping("getFileTypes")
@ApiMark(moduleName = "文件管理", apiName = "获取文件类型列表")
public ApiResult<List<String>> getFileTypes(){
public ApiResult<List<String>> getFileTypes() {
return ApiResult.success(fileControllerService.getFileTypes());
}

View File

@ -1,5 +1,7 @@
package com.nflg.wms.common.pojo.qo;
import com.nflg.wms.common.pojo.vo.FileUploadVO;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@ -22,33 +24,15 @@ public class QmsFileAddQO {
@NotBlank(message = "文件名称不能为空")
private String name;
/**
* 文件扩展名
*/
private String fileExt;
/**
* 文件大小字节
*/
private Long fileSize;
/**
* 存储路径
*/
private String storagePath;
/**
* 存储文件名物理文件名
*/
private String storageName;
/**
* MIME类型
*/
private String mimeType;
/**
* 描述
*/
private String description;
/**
* 文件信息
*/
@Valid
@NotNull(message = "文件信息不能为空")
private FileUploadVO fileInfo;
}

View File

@ -1,5 +1,6 @@
package com.nflg.wms.common.pojo.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
@ -23,10 +24,17 @@ public class FileUploadVO {
/**
* 显示名称
*/
@NotBlank
private String fileName;
/**
* url路径
*/
@NotBlank
private String url;
/**
* 文件大小单位KB
*/
private long size;
}

View File

@ -87,4 +87,9 @@ public class QmsFileVO {
* 组员列表
*/
private List<QmsFileMemberVO> members;
/**
* 当前锁定用户名称
*/
private String currentLockUserName;
}

View File

@ -1,8 +1,6 @@
package com.nflg.wms.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@ -66,6 +64,17 @@ public class QmsFile implements Serializable {
*/
private String description;
/**
* 版本号
*/
private Integer version;
/**
* 当前锁定用户名称
*/
@TableField(value = "current_lock_user_name", updateStrategy = FieldStrategy.ALWAYS)
private String currentLockUserName;
/**
* 创建人
*/

View File

@ -0,0 +1,60 @@
package com.nflg.wms.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.Accessors;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 文件修改历史记录表
*/
@Getter
@Setter
@ToString
@Accessors(chain = true)
@TableName("qms_file_history")
public class QmsFileHistory implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
/**
* 文件ID
*/
private Long fileId;
/**
* 版本号
*/
private Integer version;
/**
* 文件大小字节
*/
private Long fileSize;
/**
* 创建人
*/
private String createBy;
/**
* 创建人ID
*/
private Long createUserId;
/**
* 创建时间
*/
private LocalDateTime createTime;
}

View File

@ -0,0 +1,10 @@
package com.nflg.wms.repository.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nflg.wms.repository.entity.QmsFileHistory;
/**
* 文件修改历史记录 Mapper 接口
*/
public interface QmsFileHistoryMapper extends BaseMapper<QmsFileHistory> {
}

View File

@ -0,0 +1,10 @@
package com.nflg.wms.repository.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.nflg.wms.repository.entity.QmsFileHistory;
/**
* 文件修改历史记录 服务类
*/
public interface IQmsFileHistoryService extends IService<QmsFileHistory> {
}

View File

@ -0,0 +1,15 @@
package com.nflg.wms.repository.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.nflg.wms.repository.entity.QmsFileHistory;
import com.nflg.wms.repository.mapper.QmsFileHistoryMapper;
import com.nflg.wms.repository.service.IQmsFileHistoryService;
import org.springframework.stereotype.Service;
/**
* 文件修改历史记录 服务实现类
*/
@Service
public class QmsFileHistoryServiceImpl extends ServiceImpl<QmsFileHistoryMapper, QmsFileHistory>
implements IQmsFileHistoryService {
}