feat: 添加文件哈希检查功能,支持秒传

- 新增 HashCheckRespDTO 用于哈希检查响应
- 文件上传接口支持可选的 hash 参数,用于秒传
- 新增 /check 接口用于检查文件哈希是否存在
- 简化上传逻辑,移除同步/异步哈希计算配置

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-10 19:38:30 +08:00
parent 637f125348
commit 8bd56a6001
4 changed files with 71 additions and 44 deletions

View File

@@ -1,6 +1,7 @@
package cn.meowrain.aioj.backend.fileservice.controller;
import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO;
import cn.meowrain.aioj.backend.fileservice.dto.resp.HashCheckRespDTO;
import cn.meowrain.aioj.backend.fileservice.service.AttachmentService;
import cn.meowrain.aioj.backend.framework.core.web.Result;
import cn.meowrain.aioj.backend.framework.core.web.Results;
@@ -29,8 +30,15 @@ public class AttachmentController {
private final AttachmentService attachmentService;
@Operation(summary = "通用文件上传组件")
@PostMapping("/upload")
public Result<AttachmentDO> uploadFile(@RequestParam("file")MultipartFile file) {
return Results.success(attachmentService.upload(file));
public Result<AttachmentDO> uploadFile(@RequestParam("file") MultipartFile file,
@RequestParam(value = "hash", required = false) String hash) {
return Results.success(attachmentService.upload(file, hash));
}
@Operation(summary = "哈希是否存在")
@GetMapping("/check")
public Result<HashCheckRespDTO> checkHash(@RequestParam("hash") String hash) {
return Results.success(attachmentService.checkHash(hash));
}
/**

View File

@@ -0,0 +1,16 @@
package cn.meowrain.aioj.backend.fileservice.dto.resp;
import lombok.Data;
@Data
public class HashCheckRespDTO {
Boolean exists;
Long fileId;
String url;
@Data
static class FileInfo {
Long size;
String mime;
}
}

View File

@@ -1,6 +1,7 @@
package cn.meowrain.aioj.backend.fileservice.service;
import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO;
import cn.meowrain.aioj.backend.fileservice.dto.resp.HashCheckRespDTO;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springframework.web.multipart.MultipartFile;
@@ -15,5 +16,13 @@ import org.springframework.web.multipart.MultipartFile;
public interface AttachmentService extends IService<AttachmentDO> {
AttachmentDO upload(MultipartFile file);
AttachmentDO upload(MultipartFile file, String hash);
/**
* 检查文件哈希是否存在(用于秒传)
*
* @param hash 文件哈希值
* @return 哈希检查结果
*/
HashCheckRespDTO checkHash(String hash);
}

View File

@@ -3,18 +3,16 @@ package cn.meowrain.aioj.backend.fileservice.service.impl;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO;
import cn.meowrain.aioj.backend.fileservice.dao.mapper.AttachmentMapper;
import cn.meowrain.aioj.backend.fileservice.dto.resp.HashCheckRespDTO;
import cn.meowrain.aioj.backend.fileservice.service.AttachmentService;
import cn.meowrain.aioj.backend.fileservice.service.HashCalculationService;
import cn.meowrain.aioj.backend.fileservice.storage.FileStorageStrategy;
import cn.meowrain.aioj.backend.framework.core.enums.DelStatusEnum;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -35,58 +33,30 @@ import java.time.LocalDateTime;
public class AttachmentServiceImpl extends ServiceImpl<AttachmentMapper, AttachmentDO> implements AttachmentService {
private final FileStorageStrategy fileStorageStrategy;
private final HashCalculationService hashCalculationService;
/**
* 是否启用文件去重
*/
@Value("${file-config.deduplication-enabled:true}")
private boolean deduplicationEnabled;
/**
* 同步计算哈希的文件大小阈值(字节)
* 超过此大小异步计算,默认 10MB
*/
@Value("${file-config.sync-hash-threshold:10485760}")
private long syncHashThreshold;
@Override
public AttachmentDO upload(MultipartFile file) {
public AttachmentDO upload(MultipartFile file, String hash) {
try {
String originalFilename = file.getOriginalFilename();
String fileExtension = FileUtil.extName(originalFilename);
long fileSize = file.getSize();
// 判断文件大小,选择处理方式
boolean isSmallFile = fileSize <= syncHashThreshold;
if (deduplicationEnabled && isSmallFile) {
// ========== 小文件:同步计算哈希,立即去重 ==========
String fileHash = DigestUtil.sha256Hex(file.getInputStream());
// 如果传入了hash先检查是否已存在秒传
if (StrUtil.isNotBlank(hash)) {
AttachmentDO existingFile = getOne(new LambdaQueryWrapper<AttachmentDO>()
.eq(AttachmentDO::getFileHash, fileHash)
.eq(AttachmentDO::getFileHash, hash)
.eq(AttachmentDO::getIsDeleted, DelStatusEnum.STATUS_NORMAL.code())
.last("LIMIT 1"));
if (existingFile != null) {
log.info("文件已存在,返回已有文件: fileName={}, fileHash={}, existingId={}",
originalFilename, fileHash, existingFile.getId());
log.info("文件已存在,返回已有文件: fileName={}, hash={}, existingId={}",
originalFilename, hash, existingFile.getId());
return existingFile;
}
// 文件不存在,上传并保存哈希
return uploadAndSave(file, originalFilename, fileExtension, fileHash);
} else {
// ========== 大文件:直接上传,异步计算哈希 ==========
AttachmentDO attachmentDO = uploadAndSave(file, originalFilename, fileExtension, null);
// 异步计算哈希(只计算,不删除文件)
hashCalculationService.calculateHashAsync(attachmentDO.getId(), attachmentDO.getStoragePath(), attachmentDO.getStorageType());
return attachmentDO;
}
// 文件不存在,上传并保存
return uploadAndSave(file, originalFilename, fileExtension, hash);
} catch (IOException e) {
log.error("文件上传失败: {}", file.getOriginalFilename(), e);
throw new RuntimeException("文件上传失败: " + e.getMessage(), e);
@@ -102,7 +72,7 @@ public class AttachmentServiceImpl extends ServiceImpl<AttachmentMapper, Attachm
String datePath = LocalDateTime.now().toLocalDate().toString().replace("-", "/");
String relativePath = datePath + "/" + fileName;
String fileUrl = fileStorageStrategy.upload(file, relativePath);
fileStorageStrategy.upload(file, relativePath);
AttachmentDO attachmentDO = new AttachmentDO();
attachmentDO.setId(IdUtil.getSnowflakeNextId());
@@ -124,4 +94,28 @@ public class AttachmentServiceImpl extends ServiceImpl<AttachmentMapper, Attachm
return attachmentDO;
}
@Override
public HashCheckRespDTO checkHash(String hash) {
HashCheckRespDTO respDTO = new HashCheckRespDTO();
respDTO.setExists(false);
if (StrUtil.isBlank(hash)) {
return respDTO;
}
AttachmentDO existingFile = getOne(new LambdaQueryWrapper<AttachmentDO>()
.eq(AttachmentDO::getFileHash, hash)
.eq(AttachmentDO::getIsDeleted, DelStatusEnum.STATUS_NORMAL.code())
.last("LIMIT 1"));
if (existingFile != null) {
respDTO.setExists(true);
respDTO.setFileId(existingFile.getId());
respDTO.setUrl(fileStorageStrategy.getFileUrl(existingFile.getStoragePath()));
log.info("文件哈希已存在,支持秒传: hash={}, fileId={}", hash, existingFile.getId());
}
return respDTO;
}
}