文件上传下载

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私有化部署、K8sS3 兼容,开源免费需自行运维
阿里云 OSS公有云、高并发CDN 加速,免运维按量计费
AWS S3国际化业务全球可用,生态丰富网络延迟(国内)

相关链接

  • 统一响应格式 — 文件接口排除 ResponseBodyAdvice 自动包装
  • 配置管理 — 存储路径、OSS 密钥等敏感配置的外部化
  • 属性绑定 — MinIO / OSS 配置类的 @ConfigurationProperties 绑定
  • 安全 — 文件访问权限控制,防止未授权下载
  • 缓存 — 分片上传状态用 Redis 跟踪
  • 静态资源 — 本地上传目录的静态资源映射配置