文件秒传是指当用户上传一个文件时,如果服务器上已经存在相同的文件,则无需再次上传文件内容,直接返回上传成功。这种技术可以显著减少网络传输量,提升用户体验。
秒传的核心原理是文件内容指纹比对:
| 算法 | 安全性 | 速度 | 适用场景 |
|---|---|---|---|
| MD5 | 较低 | 快 | 一般文件校验 |
| SHA-1 | 中等 | 较快 | 普通安全需求 |
| SHA-256 | 高 | 较慢 | 高安全需求 |
CREATE TABLE file_metadata (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
file_hash VARCHAR(64) NOT NULL UNIQUE COMMENT '文件哈希值',
file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
file_size BIGINT NOT NULL COMMENT '文件大小',
file_path VARCHAR(500) NOT NULL COMMENT '存储路径',
upload_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
upload_count INT DEFAULT 1 COMMENT '上传次数',
INDEX idx_hash (file_hash)
);
src/main/java/com/example/upload/
├── config/
│ └── FileStorageConfig.java
├── controller/
│ └── FileUploadController.java
├── entity/
│ └── FileMetadata.java
├── service/
│ ├── FileStorageService.java
│ └── HashService.java
├── repository/
│ └── FileMetadataRepository.java
└── dto/
├── UploadRequest.java
└── UploadResponse.java
@Configuration
public class FileStorageConfig {
@Value("${file.upload-dir:./uploads}")
private String uploadDir;
@Bean
public Path fileStorageLocation() {
Path path = Paths.get(uploadDir).toAbsolutePath().normalize();
try {
Files.createDirectories(path);
return path;
} catch (IOException e) {
throw new RuntimeException("无法创建文件存储目录", e);
}
}
}
@Entity
@Table(name = "file_metadata")
@Data
public class FileMetadata {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 64)
private String fileHash;
@Column(nullable = false)
private String fileName;
@Column(nullable = false)
private Long fileSize;
@Column(nullable = false, length = 500)
private String filePath;
@Column(updatable = false)
private LocalDateTime uploadTime = LocalDateTime.now();
private Integer uploadCount = 1;
}
@Service
@Slf4j
public class HashService {
private static final int BUFFER_SIZE = 8192;
/**
* 计算文件的MD5哈希值
*/
public String calculateFileHash(File file) throws IOException {
try (FileInputStream fis = new FileInputStream(file);
DigestInputStream dis = new DigestInputStream(fis,
MessageDigest.getInstance("MD5"))) {
byte[] buffer = new byte[BUFFER_SIZE];
while (dis.read(buffer) != -1) {
// 读取文件内容,自动更新哈希
}
byte[] hashBytes = dis.getMessageDigest().digest();
return bytesToHex(hashBytes);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5算法不可用", e);
}
}
/**
* 计算输入流的哈希值(适用于大文件分片计算)
*/
public String calculateStreamHash(InputStream inputStream) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
byte[] hashBytes = digest.digest();
return bytesToHex(hashBytes);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5算法不可用", e);
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}
@Service
@Slf4j
public class FileStorageService {
private final Path fileStorageLocation;
private final FileMetadataRepository metadataRepository;
private final HashService hashService;
public FileStorageService(Path fileStorageLocation,
FileMetadataRepository metadataRepository,
HashService hashService) {
this.fileStorageLocation = fileStorageLocation;
this.metadataRepository = metadataRepository;
this.hashService = hashService;
}
/**
* 检查文件是否已存在(秒传检查)
*/
public UploadResponse checkFileExists(String fileHash, String fileName, long fileSize) {
Optional<FileMetadata> existingFile = metadataRepository.findByFileHash(fileHash);
if (existingFile.isPresent()) {
FileMetadata metadata = existingFile.get();
// 增加上传计数
metadata.setUploadCount(metadata.getUploadCount() + 1);
metadataRepository.save(metadata);
log.info("文件秒传成功: {} -> {}", fileName, metadata.getFilePath());
return UploadResponse.success(metadata, true);
}
return UploadResponse.notExists();
}
/**
* 存储文件
*/
public UploadResponse storeFile(MultipartFile file, String clientFileHash) throws IOException {
String fileName = StringUtils.cleanPath(file.getOriginalFilename());
long fileSize = file.getSize();
// 验证文件名
if (fileName.contains("..")) {
throw new RuntimeException("文件名包含非法路径序列: " + fileName);
}
// 计算服务器端文件哈希(可选,用于双重验证)
String serverFileHash = hashService.calculateStreamHash(file.getInputStream());
// 如果提供了客户端哈希,进行验证
if (clientFileHash != null && !clientFileHash.equals(serverFileHash)) {
log.warn("客户端与服务端哈希不一致: {} != {}", clientFileHash, serverFileHash);
// 可以根据业务需求决定是否继续上传
}
// 再次检查是否已存在(防止并发上传)
Optional<FileMetadata> existingFile = metadataRepository.findByFileHash(
clientFileHash != null ? clientFileHash : serverFileHash);
if (existingFile.isPresent()) {
return checkFileExists(clientFileHash != null ? clientFileHash : serverFileHash, fileName, fileSize);
}
// 生成存储文件名
String storageFileName = generateStorageFileName(fileName, serverFileHash);
Path targetLocation = fileStorageLocation.resolve(storageFileName);
// 确保目录存在
Files.createDirectories(targetLocation.getParent());
// 存储文件
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
// 保存元数据
FileMetadata metadata = new FileMetadata();
metadata.setFileHash(serverFileHash);
metadata.setFileName(fileName);
metadata.setFileSize(fileSize);
metadata.setFilePath(targetLocation.toString());
metadata.setUploadTime(LocalDateTime.now());
FileMetadata savedMetadata = metadataRepository.save(metadata);
log.info("文件上传成功: {} -> {}", fileName, targetLocation);
return UploadResponse.success(savedMetadata, false);
}
/**
* 生成存储文件名:哈希值_原始文件名
*/
private String generateStorageFileName(String originalFileName, String fileHash) {
String fileExtension = "";
int dotIndex = originalFileName.lastIndexOf(".");
if (dotIndex > 0) {
fileExtension = originalFileName.substring(dotIndex);
}
return fileHash + fileExtension;
}
/**
* 根据哈希值加载文件
*/
public Resource loadFileAsResource(String fileHash) {
try {
FileMetadata metadata = metadataRepository.findByFileHash(fileHash)
.orElseThrow(() -> new RuntimeException("文件不存在: " + fileHash));
Path filePath = Paths.get(metadata.getFilePath()).normalize();
Resource resource = new UrlResource(filePath.toUri());
if (resource.exists()) {
return resource;
} else {
throw new RuntimeException("文件不存在: " + fileHash);
}
} catch (MalformedURLException e) {
throw new RuntimeException("文件路径错误: " + fileHash, e);
}
}
}
@RestController
@RequestMapping("/api/file")
@Slf4j
public class FileUploadController {
private final FileStorageService fileStorageService;
public FileUploadController(FileStorageService fileStorageService) {
this.fileStorageService = fileStorageService;
}
/**
* 秒传检查接口
*/
@PostMapping("/check")
public UploadResponse checkFile(@RequestBody UploadRequest request) {
log.info("秒传检查: {}, 大小: {}", request.getFileName(), request.getFileSize());
return fileStorageService.checkFileExists(
request.getFileHash(),
request.getFileName(),
request.getFileSize()
);
}
/**
* 文件上传接口
*/
@PostMapping("/upload")
public UploadResponse uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "fileHash", required = false) String fileHash) {
try {
if (file.isEmpty()) {
return UploadResponse.failure("文件为空");
}
// 先进行秒传检查
if (fileHash != null) {
UploadResponse checkResult = fileStorageService.checkFileExists(
fileHash, file.getOriginalFilename(), file.getSize());
if (checkResult.isExists()) {
return checkResult;
}
}
// 执行实际上传
return fileStorageService.storeFile(file, fileHash);
} catch (IOException e) {
log.error("文件上传失败: {}", e.getMessage());
return UploadResponse.failure("文件上传失败: " + e.getMessage());
} catch (Exception e) {
log.error("系统错误: {}", e.getMessage());
return UploadResponse.failure("系统错误: " + e.getMessage());
}
}
/**
* 文件下载接口
*/
@GetMapping("/download/{fileHash}")
public ResponseEntity<Resource> downloadFile(@PathVariable String fileHash) {
try {
Resource resource = fileStorageService.loadFileAsResource(fileHash);
FileMetadata metadata = fileStorageService.getFileMetadata(fileHash)
.orElseThrow(() -> new RuntimeException("文件不存在"));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + metadata.getFileName() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
}
@Data
public class UploadRequest {
private String fileHash;
private String fileName;
private Long fileSize;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UploadResponse {
private boolean success;
private String message;
private boolean exists; // 是否已存在(秒传)
private FileMetadata fileMetadata;
public static UploadResponse success(FileMetadata metadata, boolean isInstant) {
String message = isInstant ? "文件秒传成功" : "文件上传成功";
return new UploadResponse(true, message, isInstant, metadata);
}
public static UploadResponse notExists() {
return new UploadResponse(true, "文件不存在,需要上传", false, null);
}
public static UploadResponse failure(String message) {
return new UploadResponse(false, message, false, null);
}
}
<!DOCTYPE html>
<html>
<head>
<title>文件秒传演示</title>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
</head>
<body>
<div>
<input type="file" id="fileInput">
<button onclick="uploadFile()">上传文件</button>
<div id="progress"></div>
<div id="result"></div>
</div>
<script>
async function uploadFile() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert('请选择文件');
return;
}
const progressDiv = document.getElementById('progress');
const resultDiv = document.getElementById('result');
try {
// 计算文件哈希
progressDiv.innerHTML = '计算文件哈希中...';
const fileHash = await calculateFileHash(file);
// 秒传检查
progressDiv.innerHTML = '检查文件是否已存在...';
const checkResponse = await fetch('/api/file/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileHash: fileHash,
fileName: file.name,
fileSize: file.size
})
});
const checkResult = await checkResponse.json();
if (checkResult.exists) {
resultDiv.innerHTML = `✅ 秒传成功!${checkResult.message}`;
return;
}
// 执行上传
progressDiv.innerHTML = '上传文件中...';
const formData = new FormData();
formData.append('file', file);
formData.append('fileHash', fileHash);
const uploadResponse = await fetch('/api/file/upload', {
method: 'POST',
body: formData
});
const uploadResult = await uploadResponse.json();
if (uploadResult.success) {
resultDiv.innerHTML = `✅ ${uploadResult.message}`;
} else {
resultDiv.innerHTML = `❌ 上传失败: ${uploadResult.message}`;
}
} catch (error) {
resultDiv.innerHTML = `❌ 错误: ${error.message}`;
} finally {
progressDiv.innerHTML = '';
}
}
function calculateFileHash(file) {
return new Promise((resolve, reject) => {
const chunkSize = 2 * 1024 * 1024; // 2MB分片
const chunks = Math.ceil(file.size / chunkSize);
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
let currentChunk = 0;
function loadNext() {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
fileReader.readAsArrayBuffer(chunk);
}
fileReader.onload = e => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end());
}
};
fileReader.onerror = reject;
loadNext();
});
}
</script>
</body>
</html>
@Service
public class ChunkedUploadService {
/**
* 分片上传检查
*/
public ChunkedUploadResponse checkChunkedUpload(String fileHash, String fileName,
long fileSize, int chunkSize) {
Optional<FileMetadata> existingFile = metadataRepository.findByFileHash(fileHash);
if (existingFile.isPresent()) {
return ChunkedUploadResponse.instant(existingFile.get());
}
// 检查已上传的分片
List<Integer> uploadedChunks = findUploadedChunks(fileHash);
return ChunkedUploadResponse.continueUpload(uploadedChunks);
}
/**
* 上传分片
*/
public void uploadChunk(String fileHash, int chunkIndex,
MultipartFile chunkFile) throws IOException {
// 存储分片文件
String chunkFileName = String.format("%s_%d", fileHash, chunkIndex);
Path chunkPath = chunkStorageLocation.resolve(chunkFileName);
Files.copy(chunkFile.getInputStream(), chunkPath,
StandardCopyOption.REPLACE_EXISTING);
// 记录分片上传状态
recordChunkUpload(fileHash, chunkIndex);
}
}
在分布式环境中,需要使用分布式缓存来存储文件哈希映射:
@Service
public class DistributedFileService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String FILE_HASH_KEY = "file:hash:";
private static final long CACHE_EXPIRE_HOURS = 24;
/**
* 分布式秒传检查
*/
public boolean checkFileExistsDistributed(String fileHash) {
// 先查缓存
String cachedPath = redisTemplate.opsForValue().get(FILE_HASH_KEY + fileHash);
if (cachedPath != null) {
return true;
}
// 查数据库
Optional<FileMetadata> metadata = metadataRepository.findByFileHash(fileHash);
if (metadata.isPresent()) {
// 写入缓存
redisTemplate.opsForValue().set(FILE_HASH_KEY + fileHash,
metadata.get().getFilePath(),
Duration.ofHours(CACHE_EXPIRE_HOURS));
return true;
}
return false;
}
}
@SpringBootTest
class FileUploadServiceTest {
@Autowired
private FileStorageService fileStorageService;
@Test
void testInstantUpload() throws IOException {
// 准备测试文件
MultipartFile file = createTestFile("test.txt", "Hello World");
// 第一次上传
UploadResponse response1 = fileStorageService.storeFile(file, null);
assertTrue(response1.isSuccess());
assertFalse(response1.isExists());
// 第二次上传(应该秒传)
String fileHash = response1.getFileMetadata().getFileHash();
UploadResponse response2 = fileStorageService.checkFileExists(
fileHash, "test.txt", file.getSize());
assertTrue(response2.isSuccess());
assertTrue(response2.isExists());
}
}
文件秒传功能通过文件内容哈希比对,有效避免了重复文件的传输,具有以下优势:
在实际应用中,还需要考虑:
通过本文的实现,你可以快速为Spring Boot应用添加文件秒传功能,并根据实际需求进行扩展和优化。