文件上传下载
Spring Boot 内置 Multipart 支持,可将文件存到本地磁盘、OSS(阿里云)或 MinIO(私有化对象存储)。
Multipart 配置
spring:
servlet:
multipart:
enabled: true
max-file-size: 100MB # 单文件最大
max-request-size: 200MB # 单次请求最大(多文件之和)
file-size-threshold: 2MB # 超过此大小写临时文件,否则内存处理
location: /tmp # 临时文件目录单文件上传
@RestController
@RequestMapping("/files")
@RequiredArgsConstructor
public class FileController {
private final FileStorageService storageService;
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
validateFile(file);
return storageService.store(file); // 返回访问 URL
}
// 多文件上传
@PostMapping("/upload/batch")
public List<String> uploadBatch(
@RequestParam("files") List<MultipartFile> files) {
return files.stream()
.peek(this::validateFile)
.map(storageService::store)
.toList();
}
// 表单 + 文件混合提交
@PostMapping("/upload/with-meta")
public String uploadWithMeta(
@RequestParam("file") MultipartFile file,
@RequestParam("category") String category,
@RequestParam("description") String description) {
validateFile(file);
return storageService.storeWithMeta(file, category, description);
}
private void validateFile(MultipartFile file) {
if (file.isEmpty()) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "文件不能为空");
}
// 白名单校验(不信任 Content-Type,检查文件头魔术字节)
String contentType = file.getContentType();
Set<String> allowed = Set.of("image/jpeg", "image/png", "image/gif",
"application/pdf", "application/zip");
if (!allowed.contains(contentType)) {
throw new BusinessException(ErrorCode.PARAM_ERROR,
"不支持的文件类型: " + contentType);
}
if (file.getSize() > 100 * 1024 * 1024L) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "文件大小超过 100MB");
}
}
}文件类型安全校验(魔术字节)
Content-Type 可被伪造,应读取文件头字节判断真实类型:
public class FileTypeValidator {
private static final Map<String, byte[]> MAGIC_BYTES = Map.of(
"image/jpeg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF},
"image/png", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47},
"application/pdf", new byte[]{0x25, 0x50, 0x44, 0x46} // %PDF
);
public static boolean validate(MultipartFile file, String expectedType) throws IOException {
byte[] header = new byte[8];
try (InputStream is = file.getInputStream()) {
is.read(header);
}
byte[] magic = MAGIC_BYTES.get(expectedType);
if (magic == null) return true; // 未知类型放行
for (int i = 0; i < magic.length; i++) {
if (header[i] != magic[i]) return false;
}
return true;
}
}本地存储
@Service
public class LocalFileStorageService implements FileStorageService {
@Value("${app.storage.local.path:/data/uploads}")
private String basePath;
@Value("${app.storage.local.url-prefix:http://localhost:8080/files}")
private String urlPrefix;
@Override
public String store(MultipartFile file) {
// 用 UUID 避免文件名冲突和路径穿越攻击
String ext = getExtension(file.getOriginalFilename());
String filename = UUID.randomUUID() + ext;
// 按日期分目录,避免单目录文件过多
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
Path dir = Paths.get(basePath, datePath);
Files.createDirectories(dir);
Path dest = dir.resolve(filename);
file.transferTo(dest);
return urlPrefix + "/" + datePath + "/" + filename;
}
private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) return "";
return filename.substring(filename.lastIndexOf('.'));
}
}静态资源映射(让上传目录可通过 HTTP 访问):
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${app.storage.local.path:/data/uploads}")
private String uploadPath;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/files/**")
.addResourceLocations("file:" + uploadPath + "/");
}
}MinIO 对象存储
implementation 'io.minio:minio:8.5.7'app:
minio:
endpoint: http://minio:9000
access-key: minioadmin
secret-key: minioadmin
bucket: uploads@Configuration
@ConfigurationProperties(prefix = "app.minio")
@Data
public class MinioProperties {
private String endpoint;
private String accessKey;
private String secretKey;
private String bucket;
}
@Configuration
public class MinioConfig {
@Bean
public MinioClient minioClient(MinioProperties props) {
return MinioClient.builder()
.endpoint(props.getEndpoint())
.credentials(props.getAccessKey(), props.getSecretKey())
.build();
}
}
@Service
@RequiredArgsConstructor
public class MinioStorageService implements FileStorageService {
private final MinioClient minioClient;
private final MinioProperties props;
@Override
public String store(MultipartFile file) throws Exception {
ensureBucketExists();
String objectName = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"))
+ "/" + UUID.randomUUID()
+ getExtension(file.getOriginalFilename());
minioClient.putObject(
PutObjectArgs.builder()
.bucket(props.getBucket())
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
return props.getEndpoint() + "/" + props.getBucket() + "/" + objectName;
}
public InputStream download(String objectName) throws Exception {
return minioClient.getObject(
GetObjectArgs.builder()
.bucket(props.getBucket())
.object(objectName)
.build()
);
}
public String getPresignedUrl(String objectName, int expiryMinutes) throws Exception {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(props.getBucket())
.object(objectName)
.expiry(expiryMinutes, TimeUnit.MINUTES)
.build()
);
}
private void ensureBucketExists() throws Exception {
boolean exists = minioClient.bucketExists(
BucketExistsArgs.builder().bucket(props.getBucket()).build());
if (!exists) {
minioClient.makeBucket(
MakeBucketArgs.builder().bucket(props.getBucket()).build());
}
}
}文件下载
直接响应流(中小文件)
@GetMapping("/download/{filename}")
@NoWrap // 排除统一响应包装
public ResponseEntity<Resource> download(@PathVariable String filename) {
Path filePath = Paths.get(basePath).resolve(filename).normalize();
// 防止路径穿越:确保文件在合法目录内
if (!filePath.startsWith(Paths.get(basePath).toAbsolutePath())) {
throw new BusinessException(ErrorCode.FORBIDDEN, "非法路径");
}
Resource resource = new FileSystemResource(filePath);
if (!resource.exists()) {
throw new BusinessException(ErrorCode.NOT_FOUND, "文件不存在");
}
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8)
.replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + encodedFilename)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}StreamingResponseBody(大文件,避免 OOM)
@GetMapping("/download/stream/{objectName}")
@NoWrap
public ResponseEntity<StreamingResponseBody> downloadStream(
@PathVariable String objectName) {
StreamingResponseBody body = outputStream -> {
try (InputStream is = minioStorageService.download(objectName)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=" + objectName)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(body);
}大文件分片上传
前端将大文件切片后逐片上传,服务端合并:
@RestController
@RequestMapping("/files/chunk")
@RequiredArgsConstructor
public class ChunkUploadController {
private final StringRedisTemplate redis;
@Value("${app.storage.chunk-path:/tmp/chunks}")
private String chunkPath;
// 上传分片
@PostMapping("/upload")
public void uploadChunk(
@RequestParam("file") MultipartFile chunk,
@RequestParam("fileHash") String fileHash, // 整文件的 MD5
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks) throws IOException {
Path chunkDir = Paths.get(chunkPath, fileHash);
Files.createDirectories(chunkDir);
chunk.transferTo(chunkDir.resolve(String.valueOf(chunkIndex)));
// 记录已上传的分片
redis.opsForSet().add("chunk:uploaded:" + fileHash,
String.valueOf(chunkIndex));
}
// 合并分片(所有分片上传完毕后调用)
@PostMapping("/merge")
public String mergeChunks(
@RequestParam("fileHash") String fileHash,
@RequestParam("filename") String filename,
@RequestParam("totalChunks") int totalChunks) throws IOException {
// 验证所有分片已上传
Long uploadedCount = redis.opsForSet().size("chunk:uploaded:" + fileHash);
if (uploadedCount == null || uploadedCount < totalChunks) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "分片未全部上传");
}
// 合并文件
Path chunkDir = Paths.get(chunkPath, fileHash);
Path targetFile = Paths.get(basePath, filename);
try (OutputStream os = Files.newOutputStream(targetFile)) {
for (int i = 0; i < totalChunks; i++) {
Files.copy(chunkDir.resolve(String.valueOf(i)), os);
}
}
// 清理分片
FileUtils.deleteDirectory(chunkDir.toFile());
redis.delete("chunk:uploaded:" + fileHash);
return "/files/" + filename;
}
// 秒传:检查文件是否已存在
@GetMapping("/exists")
public boolean checkExists(@RequestParam("fileHash") String fileHash) {
return redis.hasKey("file:hash:" + fileHash);
}
}存储方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 本地磁盘 | 开发/测试、单机部署 | 简单,无额外依赖 | 不支持水平扩展,无备份 |
| MinIO | 私有化部署、K8s | S3 兼容,开源免费 | 需自行运维 |
| 阿里云 OSS | 公有云、高并发 | CDN 加速,免运维 | 按量计费 |
| AWS S3 | 国际化业务 | 全球可用,生态丰富 | 网络延迟(国内) |