分片文件上传的逻辑比较复杂,为了方便代码阅读这里写一下大致流程

  1. 从前端接收文件和文件的分片信息,生成文件ID;
  2. 如果是第一个分片,进行如下步骤:
    1. 根据文件MD5查询数据库是否已经存在该文件;
    2. 如果存在则为秒传,此时判断用户的剩余空间是否充足;
    3. 如果空间充足,则保存文件到数据库,即另存一份信息为当前用户上传的文件。
  3. 判断用户的空间是否充足,不充足直接返回错误信息;
  4. 空间充足,将分片文件保存到临时目录,同时记录文件上传的累积大小到Redis,此时上传状态为上传中
  5. 当上传到最后一个分片时,将文件信息保存到数据库(文件状态设置为转码中),同时更新Redis和数据库中用户的空间使用情况,此时上传状态为完成;
  6. 使用异步方式进行文件合并。

接口信息

路径地址 http://localhost:7090/api/file/uploadFile
请求方式 POST
请求参数 MultipartFile、FileUploadDTO
返回结果 UploadResultVO

实体类

查询参数DTO

在包com.swx.easypan.entity.dto,创建 FileUploadDTO 实体类,将下面代码放入:

FileUploadDTO
@Data
public class FileUploadDTO {

private String id;

@NotEmpty
private String filename;

private String filePid;

@NotEmpty
private String fileMd5;

// 当前分片
@NotNull
private Integer chunkIndex;

// 总分片数量
@NotNull
private Integer chunks;
}

视图实体类

在包com.swx.easypan.entity.vo,创建 UploadResultVO 实体类,将下面代码放入:

UploadResultVO
@JsonIgnoreProperties(ignoreUnknown = true)
public class UploadResultVO implements Serializable {

private String id;
private String status;

public String getId() {
return id;
}

public void setId(String id) {
this.id = id;
}

public String getStatus() {
return status;
}

public void setStatus(String status) {
this.status = status;
}
}

Redis缓存操作

文件上传过程中需要保存临时变量和用户的

文件上传过程中需要保存临时变量

静态常量,在com.swx.easypan.entity.constants包下的 Constants 类中定义常量

public class Constants {
public static final String REDIS_KEY_USER_FILE_TEMP_SIZE = "easypan:user:file:temp:";
}

从Redis缓存中获取和保存文件临时信息,在RedisComponent 编写获取和保存方法。

RedisComponent
@Component("redisComponent")
public class RedisComponent {

@Resource
private RedisUtils<Object> redisUtils;

public void saveFileTempSize(String userId, String fileId, Long fileSize) {
Long currentSize = getFileTempSize(userId, fileId);
String key = Constants.REDIS_KEY_USER_FILE_TEMP_SIZE + userId + fileId;
redisUtils.setex(key, currentSize + fileSize, Constants.REDIS_KEY_EXPIRE_ONE_HOUR);
}

// 获取临时文件大小
public Long getFileTempSize(String userId, String fileId) {
Object sizeObj = redisUtils.get(Constants.REDIS_KEY_USER_FILE_TEMP_SIZE + userId + fileId);
if (sizeObj == null) {
return 0L;
}
if (sizeObj instanceof Integer) {
return ((Integer) sizeObj).longValue();
} else if (sizeObj instanceof Long) {
return (Long) sizeObj;
}
return 0L;
}
}

定义Service

文件信息Service

基础的文件信息操作

找到 FileInfoService,定义保存文件信息方法

FileInfoService
public interface FileInfoService extends IService<FileInfo> {
/**
* 保存文件信息
*
* @param userId 文件用户ID
* @param fileId 文件ID
* @param fileDTO 文件信息
*/
boolean saveFileInfo(String userId, String fileId, FileUploadDTO fileDTO);

/**
* 从文件保存文件,即保存秒传文件
*
* @param userId 当前用户
* @param fileId 当前文件ID
* @param fileDTO 当前文件信息
* @param dbFile 已存在文件信息
*/
boolean saveFileInfoFromFile(String userId, String fileId, FileUploadDTO fileDTO, FileInfo dbFile);
}

在 FileInfoServiceImpl 中,实现上述方法

FileInfoServiceImpl
@Slf4j
@Service
public class FileInfoServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> implements FileInfoService {

@Override
public boolean saveFileInfo(String userId, String fileId, FileUploadDTO fileDTO) {
String month = DateFormatUtils.format(new Date(), DateTimePatternEnum.YYYYMM.getPattern());
String fileSuffix = StringTools.getFileSuffix(fileDTO.getFilename());
FileTypeEnums fileTypeEnums = FileTypeEnums.getBySuffix(fileSuffix);
String realFileName = userId + fileId + fileSuffix;
FileInfo fileInfo = new FileInfo();
fileInfo.setId(fileId);
fileInfo.setUserId(userId);
fileInfo.setFileMd5(fileDTO.getFileMd5());
fileInfo.setFilename(autoRename(fileDTO.getFilePid(), userId, fileDTO.getFilename()));
fileInfo.setFilePath(month + "/" + realFileName);
fileInfo.setFilePid(fileDTO.getFilePid());
fileInfo.setFileCategory(fileTypeEnums.getCategory().getCategory());
fileInfo.setFileType(fileTypeEnums.getType());
fileInfo.setStatus(FileStatusEnums.TRANSFER.getStatus());
fileInfo.setFolderType(FileFolderTypeEnums.FILE.getType());
return save(fileInfo);
}

@Override
public boolean saveFileInfoFromFile(String userId, String fileId, FileUploadDTO fileDTO, FileInfo dbFile) {
dbFile.setId(fileId);
dbFile.setFilePid(fileDTO.getFilePid());
dbFile.setUserId(userId);
dbFile.setStatus(FileStatusEnums.USING.getStatus());
dbFile.setFileMd5(fileDTO.getFileMd5());
dbFile.setDeleted(FileDelFlagEnums.USING.getFlag());
dbFile.setFilename(autoRename(fileDTO.getFilePid(), userId, fileDTO.getFilename()));
return save(dbFile);
}

}

用户文件Service

文件上传的操作涉及文件服务和用户服务,所以这里新加一层用户文件服务,解决循环依赖的问题。

新建 UserFileService,定义上传文件接口

UserFileService
/**
* 用户文件服务
*/
public interface UserFileService {
UploadResultVO uploadFile(String userId, MultipartFile file, FileUploadDTO fileDTO) throws IOException;
}

新建 UserFileServiceImpl 类实现用户文件接口

UserFileServiceImpl
@Service
public class UserFileServiceImpl implements UserFileService {

private final RedisComponent redisComponent;
private final FileInfoService fileInfoService;
private final UserInfoService userInfoService;
private final AppConfig appConfig;
private final FileInfoServiceImpl fileInfoServiceImpl;

public UserFileServiceImpl(RedisComponent redisComponent, FileInfoService fileInfoService, UserInfoService userInfoService, AppConfig appConfig, FileInfoServiceImpl fileInfoServiceImpl) {
this.redisComponent = redisComponent;
this.fileInfoService = fileInfoService;
this.userInfoService = userInfoService;
this.appConfig = appConfig;
this.fileInfoServiceImpl = fileInfoServiceImpl;
}

@Override
@Transactional(rollbackFor = Exception.class)
public UploadResultVO uploadFile(String userId, MultipartFile file, FileUploadDTO fileDTO) throws IOException {
UploadResultVO resultVO = new UploadResultVO();
File tempFileFolder = null;
try {
// 生成文件ID
String fileId = fileDTO.getId();
if (!StringUtils.hasText(fileId)) {
fileId = StringTools.getRandomString(Constants.LENGTH_10);
}
resultVO.setId(fileId);

// 查询用户已使用空间
UserSpaceDTO userSpaceUse = redisComponent.getUserSpaceUse(userId);
if (userSpaceUse == null) {
userSpaceUse = getUseSpace(userId);
}
if (fileDTO.getChunkIndex() == 0) {
List<FileInfo> dbFileList = fileInfoService.list(new LambdaQueryWrapper<FileInfo>()
.eq(FileInfo::getFileMd5, fileDTO.getFileMd5())
.eq(FileInfo::getStatus, FileStatusEnums.USING.getStatus()));
if (!dbFileList.isEmpty()) {
// 服务器存在该文件,直接秒传
FileInfo dbFile = dbFileList.get(0);
// 判断文件大小
if (dbFile.getFileSize() + userSpaceUse.getUseSpace() > userSpaceUse.getTotalSpace()) {
throw new BizException(ResultCode.OUT_OF_SPACE);
}
boolean save = fileInfoService.saveFileInfoFromFile(userId, fileId, fileDTO, dbFile);
if (!save) {
throw new BizException("文件保存失败");
}
resultVO.setStatus(UploadStatusEnums.UPLOAD_SECONDS.getCode());
// 更新空间使用情况
updateUserSpace(userId, dbFile.getFileSize(), userSpaceUse);
return resultVO;
}
}
// 判断磁盘空间
Long currentTempSize = redisComponent.getFileTempSize(userId, fileId);
if (file.getSize() + currentTempSize + userSpaceUse.getUseSpace() > userSpaceUse.getTotalSpace()) {
throw new BizException(ResultCode.OUT_OF_SPACE);
}
// 暂存临时目录
String tempFolderName = appConfig.getProjectFolder() + Constants.FILE_FOLDER_TEMP;
String currentUserFolderName = userId + fileId;

tempFileFolder = new File(tempFolderName + currentUserFolderName);
if (!tempFileFolder.exists()) {
tempFileFolder.mkdirs();
}
File newFile = new File(tempFileFolder.getPath() + "/" + fileDTO.getChunkIndex());
file.transferTo(newFile);
if (fileDTO.getChunkIndex() < fileDTO.getChunks() - 1) {
resultVO.setStatus(UploadStatusEnums.UPLOADING.getCode());
// 保存临时大小
redisComponent.saveFileTempSize(userId, fileId, file.getSize());
return resultVO;
}
redisComponent.saveFileTempSize(userId, fileId, file.getSize());
// 最后一个分片上传完成,记录是数据库,异步合并
// 写入数据库
fileInfoService.saveFileInfo(userId, fileId, fileDTO);
// 更新用户使用空间
Long totalSize = redisComponent.getFileTempSize(userId, fileId);
updateUserSpace(userId, totalSize, userSpaceUse);
// 上传完成
resultVO.setStatus(UploadStatusEnums.UPLOAD_FINISH.getCode());

String finalFileId = fileId;
// 异步合并
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
fileInfoServiceImpl.transferFile(finalFileId, userId);
}
});
return resultVO;
} catch (Exception e) {
if (tempFileFolder != null) {
FileUtils.deleteDirectory(tempFileFolder);
}
throw e;
}
}

/**
* 更新用户空间,累加更新
*
* @param userId 用户ID
* @param fileSize 文件大小
* @param userSpaceDTO Redis
*/
private void updateUserSpace(String userId, Long fileSize, UserSpaceDTO userSpaceDTO) {
Boolean update = userInfoService.updateUserSpace(userId, fileSize, null);
if (!update) {
throw new BizException(ResultCode.OUT_OF_SPACE);
}
userSpaceDTO.setUseSpace(userSpaceDTO.getUseSpace() + fileSize);
redisComponent.saveUserSpaceUse(userId, userSpaceDTO);
}
}

定义Controller

在 FileInfoController 下定义上传方法,分片上传。

FileInfoController
@RestController
@RequestMapping("/file")
@ResponseResult
@LoginValidator
@Validated
public class FileInfoController {

private final UserFileService userFileService;

public FileInfoController(UserFileService userFileService) {
this.userFileService = userFileService;
}

// 上传文件
@PostMapping("/uploadFile")
public UploadResultVO uploadFile(HttpSession session,
MultipartFile file,
FileUploadDTO fileDTO) throws IOException {
SessionWebUserVO user = (SessionWebUserVO) session.getAttribute(Constants.SESSION_KEY);
return userFileService.uploadFile(user.getId(), file, fileDTO);
}
}