Spring Boot 文件上传与下载最佳实践
引言
文件上传与下载是 Web 应用中常见的功能需求,如图片上传、文档下载、附件管理等。Spring Boot 提供了便捷的文件处理能力,但在实际应用中需要考虑文件大小限制、存储策略、安全防护等诸多因素。本文将深入探讨文件上传与下载的最佳实践。
一、基础配置
1.1 添加依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency>1.2 文件上传配置
spring: servlet: multipart: enabled: true max-file-size: 10MB max-request-size: 50MB file-size-threshold: 2KB location: ${java.io.tmpdir} app: file: upload-dir: ${user.dir}/uploads allowed-extensions: - jpg - jpeg - png - gif - pdf - doc - docx max-file-size-mb: 101.3 配置类
import jakarta.annotation.PostConstruct; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; import java.io.File; import java.util.List; @Data @Configuration @ConfigurationProperties(prefix = "app.file") public class FileStorageConfig { private String uploadDir; private List<String> allowedExtensions; private int maxFileSizeMb; @PostConstruct public void init() { File uploadPath = new File(uploadDir); if (!uploadPath.exists()) { uploadPath.mkdirs(); } } }二、文件上传实现
2.1 文件上传服务
import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.UUID; @Slf4j @Service @RequiredArgsConstructor public class FileStorageService { private final FileStorageConfig fileStorageConfig; public FileUploadResult uploadFile(MultipartFile file) { validateFile(file); String originalFilename = file.getOriginalFilename(); String extension = getFileExtension(originalFilename); String storedFilename = UUID.randomUUID().toString() + "." + extension; try { Path uploadPath = Path.of(fileStorageConfig.getUploadDir()); Path targetPath = uploadPath.resolve(storedFilename).normalize(); Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); return FileUploadResult.builder() .originalFilename(originalFilename) .storedFilename(storedFilename) .filePath(targetPath.toString()) .fileSize(file.getSize()) .contentType(file.getContentType()) .build(); } catch (IOException e) { log.error("Failed to store file: {}", originalFilename, e); throw new BusinessException(500, "文件存储失败"); } } private void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) { throw new BusinessException(400, "上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); if (originalFilename == null || originalFilename.isEmpty()) { throw new BusinessException(400, "文件名不能为空"); } String extension = getFileExtension(originalFilename).toLowerCase(); if (!fileStorageConfig.getAllowedExtensions().contains(extension)) { throw new BusinessException(400, "不支持的文件类型: " + extension); } long maxSize = (long) fileStorageConfig.getMaxFileSizeMb() * 1024 * 1024; if (file.getSize() > maxSize) { throw new BusinessException(400, "文件大小超过限制"); } } private String getFileExtension(String filename) { if (filename == null || !filename.contains(".")) { return ""; } return filename.substring(filename.lastIndexOf(".") + 1); } @Getter @Builder public static class FileUploadResult { private String originalFilename; private String storedFilename; private String filePath; private long fileSize; private String contentType; } }2.2 文件上传控制器
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/files") @RequiredArgsConstructor @Tag(name = "文件管理", description = "文件上传下载接口") public class FileController { private final FileStorageService fileStorageService; private final FileRepository fileRepository; @Operation(summary = "上传单个文件") @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity<FileUploadResult> uploadFile( @Parameter(description = "上传的文件") @RequestParam("file") MultipartFile file) { FileStorageService.FileUploadResult result = fileStorageService.uploadFile(file); FileRecord fileRecord = FileRecord.builder() .originalFilename(result.getOriginalFilename()) .storedFilename(result.getStoredFilename()) .filePath(result.getFilePath()) .fileSize(result.getFileSize()) .contentType(result.getContentType()) .build(); fileRepository.save(fileRecord); return ResponseEntity.ok(FileUploadResult.from(fileRecord)); } @Operation(summary = "批量上传文件") @PostMapping(value = "/upload/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity<BatchUploadResult> uploadFiles( @Parameter(description = "上传的文件列表") @RequestParam("files") MultipartFile[] files) { List<FileUploadResult> results = new ArrayList<>(); for (MultipartFile file : files) { try { FileStorageService.FileUploadResult result = fileStorageService.uploadFile(file); FileRecord fileRecord = FileRecord.builder() .originalFilename(result.getOriginalFilename()) .storedFilename(result.getStoredFilename()) .filePath(result.getFilePath()) .fileSize(result.getFileSize()) .contentType(result.getContentType()) .build(); fileRepository.save(fileRecord); results.add(FileUploadResult.from(fileRecord)); } catch (Exception e) { results.add(FileUploadResult.builder() .originalFilename(file.getOriginalFilename()) .error(e.getMessage()) .build()); } } return ResponseEntity.ok(BatchUploadResult.builder() .total(files.length) .success(results.stream().filter(r -> r.getError() == null).count()) .failed(results.stream().filter(r -> r.getError() != null).count()) .results(results) .build()); } }2.3 文件记录实体
import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; @Entity @Table(name = "file_records") @Data @Builder @NoArgsConstructor @AllArgsConstructor public class FileRecord { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "original_filename", nullable = false) private String originalFilename; @Column(name = "stored_filename", nullable = false, unique = true) private String storedFilename; @Column(name = "file_path", nullable = false) private String filePath; @Column(name = "file_size", nullable = false) private Long fileSize; @Column(name = "content_type") private String contentType; @Column(name = "download_count") @Builder.Default private Integer downloadCount = 0; @Column(name = "created_at") @Builder.Default private LocalDateTime createdAt = LocalDateTime.now(); }三、文件下载实现
3.1 文件下载控制器
import lombok.RequiredArgsConstructor; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.io.IOException; import java.net.MalformedURLException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; @RestController @RequestMapping("/api/files") @RequiredArgsConstructor public class FileController { private final FileStorageConfig fileStorageConfig; private final FileRepository fileRepository; @Operation(summary = "下载文件") @GetMapping("/download/{fileId}") public ResponseEntity<Resource> downloadFile(@PathVariable Long fileId) { FileRecord fileRecord = fileRepository.findById(fileId) .orElseThrow(() -> BusinessException.notFound("文件不存在")); try { Path filePath = Paths.get(fileRecord.getFilePath()).normalize(); Resource resource = new UrlResource(filePath.toUri()); if (!resource.exists() || !resource.isReadable()) { throw new BusinessException(404, "文件不存在或无法读取"); } String encodedFilename = URLEncoder.encode( fileRecord.getOriginalFilename(), StandardCharsets.UTF_8 ).replace("+", "%20"); fileRecord.setDownloadCount(fileRecord.getDownloadCount() + 1); fileRepository.save(fileRecord); return ResponseEntity.ok() .contentType(MediaType.parseMediaType(fileRecord.getContentType())) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + encodedFilename + "\"") .body(resource); } catch (MalformedURLException e) { log.error("File path error: {}", fileRecord.getFilePath(), e); throw new BusinessException(500, "文件路径错误"); } } @Operation(summary = "预览文件(图片)") @GetMapping("/preview/{fileId}") public ResponseEntity<Resource> previewFile(@PathVariable Long fileId) { FileRecord fileRecord = fileRepository.findById(fileId) .orElseThrow(() -> BusinessException.notFound("文件不存在")); String contentType = fileRecord.getContentType(); if (contentType == null || !contentType.startsWith("image/")) { throw new BusinessException(400, "仅支持图片预览"); } try { Path filePath = Paths.get(fileRecord.getFilePath()).normalize(); Resource resource = new UrlResource(filePath.toUri()); if (!resource.exists() || !resource.isReadable()) { throw new BusinessException(404, "文件不存在或无法读取"); } return ResponseEntity.ok() .contentType(MediaType.parseMediaType(contentType)) .body(resource); } catch (MalformedURLException e) { log.error("File path error: {}", fileRecord.getFilePath(), e); throw new BusinessException(500, "文件路径错误"); } } }3.2 文件信息查询
@Operation(summary = "获取文件信息") @GetMapping("/{fileId}") public ResponseEntity<FileInfoResponse> getFileInfo(@PathVariable Long fileId) { FileRecord fileRecord = fileRepository.findById(fileId) .orElseThrow(() -> BusinessException.notFound("文件不存在")); return ResponseEntity.ok(FileInfoResponse.from(fileRecord)); } @Operation(summary = "分页查询文件列表") @GetMapping public ResponseEntity<PageResponse<FileInfoResponse>> listFiles( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { Page<FileRecord> filePage = fileRepository.findAll( PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) ); return ResponseEntity.ok(PageResponse.from(filePage, FileInfoResponse::from)); }四、响应模型
import lombok.Builder; import lombok.Data; import java.time.LocalDateTime; @Data @Builder public class FileUploadResult { private Long id; private String originalFilename; private String storedFilename; private String downloadUrl; private String previewUrl; private long fileSize; private String contentType; private String error; public static FileUploadResult from(FileRecord record) { return FileUploadResult.builder() .id(record.getId()) .originalFilename(record.getOriginalFilename()) .storedFilename(record.getStoredFilename()) .downloadUrl("/api/files/download/" + record.getId()) .previewUrl(record.getContentType() != null && record.getContentType().startsWith("image/") ? "/api/files/preview/" + record.getId() : null) .fileSize(record.getFileSize()) .contentType(record.getContentType()) .build(); } } @Data @Builder public class BatchUploadResult { private int total; private long success; private long failed; private java.util.List<FileUploadResult> results; } @Data @Builder public class FileInfoResponse { private Long id; private String originalFilename; private String storedFilename; private String downloadUrl; private String previewUrl; private long fileSize; private String contentType; private int downloadCount; private LocalDateTime createdAt; public static FileInfoResponse from(FileRecord record) { return FileInfoResponse.builder() .id(record.getId()) .originalFilename(record.getOriginalFilename()) .storedFilename(record.getStoredFilename()) .downloadUrl("/api/files/download/" + record.getId()) .previewUrl(record.getContentType() != null && record.getContentType().startsWith("image/") ? "/api/files/preview/" + record.getId() : null) .fileSize(record.getFileSize()) .contentType(record.getContentType()) .downloadCount(record.getDownloadCount()) .createdAt(record.getCreatedAt()) .build(); } }五、大文件上传
5.1 分片上传服务
import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @Slf4j @Service @RequiredArgsConstructor public class ChunkUploadService { private final FileStorageConfig fileStorageConfig; private final Map<String, ChunkUploadSession> uploadSessions = new ConcurrentHashMap<>(); public ChunkUploadResult initUpload(String filename, long totalSize, int chunkCount) { String uploadId = UUID.randomUUID().toString(); String extension = getFileExtension(filename); String storedFilename = UUID.randomUUID().toString() + "." + extension; ChunkUploadSession session = ChunkUploadSession.builder() .uploadId(uploadId) .originalFilename(filename) .storedFilename(storedFilename) .totalSize(totalSize) .chunkCount(chunkCount) .receivedChunks(new boolean[chunkCount]) .build(); uploadSessions.put(uploadId, session); return ChunkUploadResult.builder().uploadId(uploadId).build(); } public void uploadChunk(String uploadId, int chunkIndex, MultipartFile chunkFile) { ChunkUploadSession session = uploadSessions.get(uploadId); if (session == null) { throw new BusinessException(400, "上传会话不存在"); } if (chunkIndex < 0 || chunkIndex >= session.getChunkCount()) { throw new BusinessException(400, "分片索引无效"); } if (session.getReceivedChunks()[chunkIndex]) { return; } try { Path tempDir = Path.of(fileStorageConfig.getUploadDir(), "temp"); if (!Files.exists(tempDir)) { Files.createDirectories(tempDir); } Path chunkPath = tempDir.resolve(uploadId + "_" + chunkIndex); Files.copy(chunkFile.getInputStream(), chunkPath, StandardOpenOption.CREATE); session.getReceivedChunks()[chunkIndex] = true; session.setReceivedSize(session.getReceivedSize() + chunkFile.getSize()); } catch (IOException e) { log.error("Failed to upload chunk {} for {}", chunkIndex, uploadId, e); throw new BusinessException(500, "分片上传失败"); } } public FileStorageService.FileUploadResult completeUpload(String uploadId) { ChunkUploadSession session = uploadSessions.get(uploadId); if (session == null) { throw new BusinessException(400, "上传会话不存在"); } for (boolean received : session.getReceivedChunks()) { if (!received) { throw new BusinessException(400, "存在未上传的分片"); } } try { Path uploadPath = Path.of(fileStorageConfig.getUploadDir()); Path targetPath = uploadPath.resolve(session.getStoredFilename()).normalize(); try (FileChannel targetChannel = FileChannel.open(targetPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { Path tempDir = Path.of(fileStorageConfig.getUploadDir(), "temp"); for (int i = 0; i < session.getChunkCount(); i++) { Path chunkPath = tempDir.resolve(uploadId + "_" + i); try (FileChannel chunkChannel = FileChannel.open(chunkPath, StandardOpenOption.READ)) { chunkChannel.transferTo(0, chunkChannel.size(), targetChannel); Files.delete(chunkPath); } } } uploadSessions.remove(uploadId); return FileStorageService.FileUploadResult.builder() .originalFilename(session.getOriginalFilename()) .storedFilename(session.getStoredFilename()) .filePath(targetPath.toString()) .fileSize(session.getTotalSize()) .build(); } catch (IOException e) { log.error("Failed to merge chunks for {}", uploadId, e); throw new BusinessException(500, "文件合并失败"); } } public ChunkUploadStatus getUploadStatus(String uploadId) { ChunkUploadSession session = uploadSessions.get(uploadId); if (session == null) { throw new BusinessException(400, "上传会话不存在"); } long receivedCount = 0; for (boolean received : session.getReceivedChunks()) { if (received) receivedCount++; } return ChunkUploadStatus.builder() .uploadId(uploadId) .receivedChunks(receivedCount) .totalChunks(session.getChunkCount()) .receivedSize(session.getReceivedSize()) .totalSize(session.getTotalSize()) .build(); } private String getFileExtension(String filename) { if (filename == null || !filename.contains(".")) { return ""; } return filename.substring(filename.lastIndexOf(".") + 1); } @lombok.Data @lombok.Builder private static class ChunkUploadSession { private String uploadId; private String originalFilename; private String storedFilename; private long totalSize; private int chunkCount; private boolean[] receivedChunks; private long receivedSize; } @lombok.Data @lombok.Builder public static class ChunkUploadResult { private String uploadId; } @lombok.Data @lombok.Builder public static class ChunkUploadStatus { private String uploadId; private long receivedChunks; private int totalChunks; private long receivedSize; private long totalSize; } }5.2 分片上传控制器
@Operation(summary = "初始化分片上传") @PostMapping("/upload/chunk/init") public ResponseEntity<ChunkUploadService.ChunkUploadResult> initChunkUpload( @RequestBody ChunkUploadInitRequest request) { ChunkUploadService.ChunkUploadResult result = chunkUploadService.initUpload( request.getFilename(), request.getTotalSize(), request.getChunkCount() ); return ResponseEntity.ok(result); } @Operation(summary = "上传分片") @PostMapping(value = "/upload/chunk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity<Void> uploadChunk( @RequestParam("uploadId") String uploadId, @RequestParam("chunkIndex") int chunkIndex, @RequestParam("file") MultipartFile chunkFile) { chunkUploadService.uploadChunk(uploadId, chunkIndex, chunkFile); return ResponseEntity.ok().build(); } @Operation(summary = "完成分片上传") @PostMapping("/upload/chunk/complete") public ResponseEntity<FileUploadResult> completeChunkUpload( @RequestBody ChunkUploadCompleteRequest request) { FileStorageService.FileUploadResult result = chunkUploadService.completeUpload(request.getUploadId()); FileRecord fileRecord = FileRecord.builder() .originalFilename(result.getOriginalFilename()) .storedFilename(result.getStoredFilename()) .filePath(result.getFilePath()) .fileSize(result.getFileSize()) .build(); fileRepository.save(fileRecord); return ResponseEntity.ok(FileUploadResult.from(fileRecord)); } @Operation(summary = "查询分片上传状态") @GetMapping("/upload/chunk/status/{uploadId}") public ResponseEntity<ChunkUploadService.ChunkUploadStatus> getChunkUploadStatus( @PathVariable String uploadId) { ChunkUploadService.ChunkUploadStatus status = chunkUploadService.getUploadStatus(uploadId); return ResponseEntity.ok(status); }六、安全防护
6.1 文件类型校验
import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.io.InputStream; import java.util.Set; @Component public class FileTypeValidator { private static final Set<String> ALLOWED_MIME_TYPES = Set.of( "image/jpeg", "image/png", "image/gif", "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ); public void validate(MultipartFile file) { String contentType = file.getContentType(); if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType)) { throw new BusinessException(400, "不支持的文件类型"); } validateFileSignature(file); } private void validateFileSignature(MultipartFile file) { try (InputStream is = file.getInputStream()) { byte[] header = new byte[8]; is.read(header); if (isPdfFile(header) && !"application/pdf".equals(file.getContentType())) { throw new BusinessException(400, "文件类型与内容不匹配"); } if ((isJpegFile(header) || isPngFile(header) || isGifFile(header)) && !file.getContentType().startsWith("image/")) { throw new BusinessException(400, "文件类型与内容不匹配"); } } catch (IOException e) { throw new BusinessException(400, "文件内容校验失败"); } } private boolean isPdfFile(byte[] header) { return header.length >= 4 && header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44 && header[3] == 0x46; } private boolean isJpegFile(byte[] header) { return header.length >= 2 && header[0] == (byte) 0xFF && header[1] == (byte) 0xD8; } private boolean isPngFile(byte[] header) { return header.length >= 8 && header[0] == (byte) 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47; } private boolean isGifFile(byte[] header) { return header.length >= 6 && header[0] == 'G' && header[1] == 'I' && header[2] == 'F' && header[3] == '8'; } }6.2 路径遍历防护
public Path resolveFilePath(String storedFilename) { Path uploadPath = Path.of(fileStorageConfig.getUploadDir()); Path filePath = uploadPath.resolve(storedFilename).normalize(); if (!filePath.startsWith(uploadPath)) { throw new BusinessException(403, "非法文件路径"); } return filePath; }6.3 文件清理定时任务
import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDateTime; import java.time.ZoneId; @Slf4j @Component @RequiredArgsConstructor public class FileCleanupTask { private final FileStorageConfig fileStorageConfig; private final FileRepository fileRepository; @Scheduled(cron = "0 0 3 * * ?") public void cleanupTempFiles() { try { Path tempDir = Path.of(fileStorageConfig.getUploadDir(), "temp"); if (!Files.exists(tempDir)) { return; } LocalDateTime cutoffTime = LocalDateTime.now().minusHours(24); Files.walk(tempDir) .filter(Files::isRegularFile) .forEach(file -> { try { LocalDateTime lastModified = Files.getLastModifiedTime(file) .toInstant() .atZone(ZoneId.systemDefault()) .toLocalDateTime(); if (lastModified.isBefore(cutoffTime)) { Files.delete(file); log.info("Cleaned up temp file: {}", file); } } catch (IOException e) { log.warn("Failed to delete temp file: {}", file, e); } }); } catch (IOException e) { log.error("Failed to cleanup temp files", e); } } }七、最佳实践
7.1 存储策略
| 存储方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 本地文件系统 | 简单、性能好 | 扩展性差、备份困难 | 小型应用、开发环境 |
| 云存储(OSS/S3) | 高可用、可扩展 | 成本、网络延迟 | 生产环境、大规模应用 |
| 数据库 BLOB | 事务支持、统一管理 | 性能差、数据库膨胀 | 小文件、需要事务支持 |
7.2 文件命名策略
- UUID 命名:避免文件名冲突和路径遍历攻击
- 时间戳命名:便于按时间管理文件
- 哈希命名:便于去重和版本管理
7.3 性能优化
- 异步上传:大文件上传使用异步处理
- 分片上传:支持断点续传和并行上传
- 缓存控制:设置合理的缓存策略
- CDN 加速:静态文件使用 CDN 分发
7.4 安全检查清单
- 文件类型白名单校验
- 文件内容签名校验
- 路径遍历攻击防护
- 文件大小限制
- 上传频率限制
- 敏感文件过滤(.exe, .bat 等)
- HTTPS 传输加密
结语
文件上传与下载功能涉及安全、性能、可靠性等多个方面。通过合理的配置、完善的校验机制和安全防护措施,可以构建健壮的文件管理系统。在实际项目中,应根据业务需求选择合适的存储策略,并不断优化上传体验和安全性。