Skip to content
第 71 / 250 章后端⏱ 10 分钟阅读

第 71 章:文件上传与对象存储

学习目标

  • 掌握大文件分片上传与断点续传
  • 学会接入 MinIO / 阿里 OSS 对象存储
  • 实现文件安全校验与防盗链

一、上传的三种方案

方案优点缺点适用
本地存储简单多服务器同步难、单点单机应用
对象存储弹性、便宜走外网绝大多数场景
CDN全球加速适合静态不常变图片、视频

二、基础上传(Spring Boot)

yaml
spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 100MB                  # ① 单文件大小
      max-request-size: 200MB              # ② 整个请求大小
      file-size-threshold: 10MB            # ③ 超过此大小才落盘
java
@RestController
@RequestMapping("/api/file")
public class FileController {

    @PostMapping("/upload")
    public Result<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
        // ① 校验
        if (file.isEmpty()) {
            throw new BusinessException("文件不能为空");
        }

        // ② 校验类型
        String contentType = file.getContentType();
        if (!ALLOWED_TYPES.contains(contentType)) {
            throw new BusinessException("不支持的文件类型");
        }

        // ③ 校验大小
        if (file.getSize() > 10 * 1024 * 1024) {
            throw new BusinessException("文件超过 10MB");
        }

        // ④ 生成文件名(避免冲突)
        String ext = getExtension(file.getOriginalFilename());
        String filename = UUID.randomUUID() + "." + ext;

        // ⑤ 保存
        File dest = new File("/data/upload/" + filename);
        file.transferTo(dest);

        return Result.ok("/upload/" + filename);
    }
}

三、MinIO 集成(自建对象存储)

xml
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.10</version>
</dependency>
yaml
minio:
  endpoint: http://localhost:9000
  access-key: minioadmin
  secret-key: minioadmin
  bucket: taskflow
java
@Configuration
public class MinioConfig {

    @Bean
    public MinioClient minioClient(
            @Value("${minio.endpoint}") String endpoint,
            @Value("${minio.access-key}") String accessKey,
            @Value("${minio.secret-key}") String secretKey) {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}

@Component
@RequiredArgsConstructor
public class MinioService {

    private final MinioClient minioClient;

    @Value("${minio.bucket}")
    private String bucket;

    // ① 上传文件
    public String upload(InputStream stream, String filename, String contentType)
            throws Exception {
        ensureBucket();

        // ② 用日期分目录
        String objectName = LocalDate.now().toString() + "/" +
                UUID.randomUUID() + "-" + filename;

        minioClient.putObject(PutObjectArgs.builder()
                .bucket(bucket)
                .object(objectName)
                .stream(stream, -1, 10485760)         // 10MB
                .contentType(contentType)
                .build());

        return getPresignedUrl(objectName);
    }

    // ③ 生成临时访问 URL(默认 7 天)
    public String getPresignedUrl(String objectName) {
        try {
            return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
                    .bucket(bucket)
                    .object(objectName)
                    .method(Method.GET)
                    .expiry(7, TimeUnit.DAYS)
                    .build());
        } catch (Exception e) {
            throw new BusinessException("生成访问链接失败");
        }
    }

    // ④ 删除
    public void delete(String objectName) throws Exception {
        minioClient.removeObject(RemoveObjectArgs.builder()
                .bucket(bucket)
                .object(objectName)
                .build());
    }

    private void ensureBucket() throws Exception {
        boolean exists = minioClient.bucketExists(BucketExistsArgs.builder()
                .bucket(bucket).build());
        if (!exists) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
        }
    }
}
java
@RestController
@RequestMapping("/api/file")
@RequiredArgsConstructor
public class FileController {

    private final MinioService minioService;

    @PostMapping("/upload")
    public Result<String> upload(@RequestParam("file") MultipartFile file) {
        try {
            String url = minioService.upload(
                    file.getInputStream(),
                    file.getOriginalFilename(),
                    file.getContentType());
            return Result.ok(url);
        } catch (Exception e) {
            throw new BusinessException("上传失败");
        }
    }
}

四、阿里云 OSS 集成

xml
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.17.4</version>
</dependency>
yaml
aliyun:
  oss:
    endpoint: oss-cn-hangzhou.aliyuncs.com
    access-key: ${OSS_AK}
    secret-key: ${OSS_SK}
    bucket: taskflow
java
@Component
public class OssService {

    private final OSS ossClient;

    public OssService(
            @Value("${aliyun.oss.endpoint}") String endpoint,
            @Value("${aliyun.oss.access-key}") String ak,
            @Value("${aliyun.oss.secret-key}") String sk,
            @Value("${aliyun.oss.bucket}") String bucket) {
        this.ossClient = new OSSClientBuilder().build(endpoint, ak, sk);
        this.bucket = bucket;
    }

    public String upload(InputStream stream, String filename) {
        String objectName = "uploads/" + UUID.randomUUID() + "-" + filename;
        ossClient.putObject(bucket, objectName, stream);

        // 生成访问 URL
        Date expiry = new Date(System.currentTimeMillis() + 3600 * 1000);
        return ossClient.generatePresignedUrl(bucket, objectName, expiry).toString();
    }

    // 生成前端直传 Policy(前端直传 OSS,不经过应用服务器)
    public Map<String, String> generatePostPolicy(String dir) {
        long expireTime = 30;
        Date expire = new Date(System.currentTimeMillis() + expireTime * 1000);
        PolicyConditions policy = new PolicyConditions();
        policy.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 104857600);
        policy.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);

        String postPolicy = ossClient.generatePostPolicy(expire, policy);
        String signature = ossClient.calculatePostSignature(postPolicy);

        Map<String, String> map = new HashMap<>();
        map.put("accessKeyId", accessKey);
        map.put("policy", postPolicy);
        map.put("signature", signature);
        map.put("dir", dir);
        map.put("expire", String.valueOf(expireTime));
        return map;
    }
}

五、大文件分片上传

场景:上传 10GB 视频,单次 HTTP 请求不可能。

后端实现

java
@Service
@RequiredArgsConstructor
public class ChunkUploadService {

    private final RedisTemplate<String, Object> redis;
    private final MinioService minioService;

    // ① 初始化分片上传
    public String initUpload(String filename, Long totalSize, String md5) {
        // 检查是否已秒传(MD5 已存在)
        if (Boolean.TRUE.equals(redis.hasKey("file:md5:" + md5))) {
            return (String) redis.opsForValue().get("file:md5:" + md5);
        }

        // 生成 uploadId
        String uploadId = UUID.randomUUID().toString();

        // 保存分片信息
        Map<String, Object> uploadInfo = new HashMap<>();
        uploadInfo.put("filename", filename);
        uploadInfo.put("totalSize", totalSize);
        uploadInfo.put("md5", md5);
        uploadInfo.put("chunks", new HashSet<Integer>());   // 已上传分片
        uploadInfo.put("createTime", System.currentTimeMillis());

        redis.opsForValue().set("upload:" + uploadId, uploadInfo, 24, TimeUnit.HOURS);

        return uploadId;
    }

    // ② 上传分片
    public void uploadChunk(String uploadId, int chunkIndex, MultipartFile chunk)
            throws Exception {
        Map<String, Object> info = (Map<String, Object>) redis.opsForValue()
                .get("upload:" + uploadId);
        if (info == null) {
            throw new BusinessException("上传会话不存在");
        }

        // 保存分片到临时目录
        File chunkFile = new File("/tmp/upload/" + uploadId + "/" + chunkIndex);
        chunkFile.getParentFile().mkdirs();
        chunk.transferTo(chunkFile);

        // 记录已上传分片
        Set<Integer> uploadedChunks = (Set<Integer>) info.get("chunks");
        uploadedChunks.add(chunkIndex);
        info.put("chunks", uploadedChunks);
        redis.opsForValue().set("upload:" + uploadId, info, 24, TimeUnit.HOURS);
    }

    // ③ 合并分片
    public String mergeChunks(String uploadId) throws Exception {
        Map<String, Object> info = (Map<String, Object>) redis.opsForValue()
                .get("upload:" + uploadId);
        if (info == null) {
            throw new BusinessException("上传会话不存在");
        }

        String filename = (String) info.get("filename");
        String md5 = (String) info.get("md5");
        File chunkDir = new File("/tmp/upload/" + uploadId);

        // 合并分片
        File merged = new File("/tmp/upload/" + uploadId + "-merged-" + filename);
        try (FileOutputStream fos = new FileOutputStream(merged)) {
            File[] chunks = chunkDir.listFiles();
            Arrays.sort(chunks, Comparator.comparing(File::getName));

            for (File chunk : chunks) {
                Files.copy(chunk.toPath(), fos);
            }
        }

        // 上传到 MinIO
        try (FileInputStream fis = new FileInputStream(merged)) {
            String url = minioService.upload(fis, filename, "application/octet-stream");
            redis.opsForValue().set("file:md5:" + md5, url, 30, TimeUnit.DAYS);
            return url;
        } finally {
            // 清理临时文件
            FileUtils.deleteDirectory(chunkDir);
            merged.delete();
            redis.delete("upload:" + uploadId);
        }
    }
}

Controller

java
@RestController
@RequestMapping("/api/upload")
@RequiredArgsConstructor
public class ChunkUploadController {

    private final ChunkUploadService service;

    @PostMapping("/init")
    public Result<Map<String, String>> init(
            @RequestBody InitUploadDTO dto) {
        String uploadId = service.initUpload(
                dto.getFilename(), dto.getSize(), dto.getMd5());
        return Result.ok(Map.of("uploadId", uploadId));
    }

    @PostMapping("/chunk")
    public Result<Void> uploadChunk(
            @RequestParam String uploadId,
            @RequestParam Integer chunkIndex,
            @RequestParam MultipartFile chunk) {
        try {
            service.uploadChunk(uploadId, chunkIndex, chunk);
            return Result.ok();
        } catch (Exception e) {
            throw new BusinessException("分片上传失败");
        }
    }

    @PostMapping("/merge")
    public Result<String> merge(@RequestParam String uploadId) {
        try {
            return Result.ok(service.mergeChunks(uploadId));
        } catch (Exception e) {
            throw new BusinessException("合并失败");
        }
    }
}

六、断点续传 + 秒传

java
// ① 断点续传:上传前查询已上传的分片
@GetMapping("/upload/status")
public Result<Set<Integer>> getUploadedChunks(String uploadId) {
    Map<String, Object> info = (Map<String, Object>) redis.opsForValue()
            .get("upload:" + uploadId);
    if (info == null) {
        return Result.ok(new HashSet<>());
    }
    return Result.ok((Set<Integer>) info.get("chunks"));
}

// 前端跳过已上传的分片,只传缺失的

// ② 秒传:MD5 已存在时直接返回 URL
@PostMapping("/init")
public Result<Map<String, Object>> init(@RequestBody InitUploadDTO dto) {
    String md5 = dto.getMd5();
    String existingUrl = (String) redis.opsForValue().get("file:md5:" + md5);
    if (existingUrl != null) {
        // 秒传:直接返回 URL,不占用存储
        return Result.ok(Map.of("url", existingUrl, "instant", true));
    }

    String uploadId = service.initUpload(dto.getFilename(), dto.getSize(), md5);
    return Result.ok(Map.of("uploadId", uploadId, "instant", false));
}

七、文件安全

校验

java
// ① 真实类型校验(不信任 Content-Type 和后缀)
public String getRealType(InputStream stream) {
    try {
        // 读文件头几个字节(魔数)
        byte[] header = new byte[12];
        stream.read(header);
        stream.reset();

        if (header[0] == (byte) 0xFF && header[1] == (byte) 0xD8) return "image/jpeg";
        if (header[0] == 'G' && header[1] == 'I' && header[2] == 'F') return "image/gif";
        if (header[0] == (byte) 0x89 && header[1] == 'P') return "image/png";
        if (header[0] == 'P' && header[1] == 'K') return "application/zip";
        return "unknown";
    } catch (Exception e) {
        return "unknown";
    }
}

// ② 病毒扫描(接入 ClamAV)
Process p = Runtime.getRuntime().exec("clamscan --infected " + file.getAbsolutePath());

防盗链(Referer 校验)

nginx
# Nginx 配置:只允许特定域名访问
location /files/ {
    valid_referers none blocked taskflow.com *.taskflow.com;
    if ($invalid_referer) {
        return 403;
    }
}

临时签名 URL

java
// MinIO / OSS 都支持生成带有效期的 URL,过期失效
String url = minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
        .bucket(bucket)
        .object(objectName)
        .method(Method.GET)
        .expiry(1, TimeUnit.HOURS)          // ① 1 小时后过期
        .build());

八、CDN 加速

yaml
# 把 OSS 的域名接入 CDN
cdn:
  domain: cdn.taskflow.com
  origin: taskflow.oss-cn-hangzhou.aliyuncs.com
  cache-rules:
    - path: "*.jpg"
      ttl: 30d
    - path: "*.html"
      ttl: 1h

九、本章小结

要点关键
方案本地(不推荐)/ 对象存储(推荐)/ CDN(静态资源)
MinIO自建对象存储,S3 兼容
阿里 OSS商用,云端直传 Policy 减轻服务器压力
分片上传大文件必备(视频、压缩包)
断点续传服务端记录已上传分片
秒传MD5 已存在直接返回 URL
安全真实类型校验 + 病毒扫描 + 防盗链 + 临时签名

动手练习

练习 1:基础题

集成 MinIO,实现头像上传:用户上传图片 → 保存到 MinIO → 返回访问 URL。

练习 2:进阶题

实现一个视频分片上传功能:支持 100MB+ 视频、断点续传(刷新页面后能继续上传)、秒传。

练习 3:思考题

你的系统允许用户上传图片。要避免:

  • 上传木马(伪装成图片的 PHP)
  • 上传超大图片占用存储
  • 同一图片被上传 100 次(应该秒传) 如何设计?

下一章第 72 章:WebSocket 与实时通信

本站基于 VitePress 构建 · 由 Codebook 团队维护