Skip to content
第 11 章 ⏱ 10 分钟阅读

第 11 章:文件存储 ​

学习目标 ​

  • 实现通用文件上传接口
  • 集成本地存储和 MinIO 对象存储
  • 处理文件名、文件类型、大小限制

一、为什么需要存储抽象 ​

文件存储有两类方案:

  • 本地存储:简单,但扩容和迁移麻烦
  • 对象存储(MinIO/OSS/S3):分布式,可扩展,生产推荐

设计阶段就抽Storage 接口,业务层只依赖接口,不同实现可热切换。

⚠️ 坑 1:不要把"保存到 /var/uploads/"直接写在 Controller。哪天迁移到 OSS,你得改所有上传点。接口先行,实现可换。

二、Storage 接口 ​

java
public interface Storage {

    /**
     * 上传文件
     * @param inputStream    文件流
     * @param originalName   原始文件名(用于推断扩展名)
     * @param size           文件大小(字节)
     * @return               访问 URL
     */
    String upload(InputStream inputStream, String originalName, long size);

    /**
     * 删除文件
     */
    void delete(String url);
}

三、本地存储实现 ​

java
@Component
@ConditionalOnProperty(name = "storage.type", havingValue = "local", matchIfMissing = true)
public class LocalStorage implements Storage {

    @Value("${storage.local.path:/var/uploads/taskflow}")
    private String basePath;

    @Value("${storage.local.url-prefix:http://localhost:8080/static}")
    private String urlPrefix;

    @Override
    public String upload(InputStream inputStream, String originalName, long size) {
        // ① 校验
        if (size > 100 * 1024 * 1024) {
            throw new BusinessException("文件不能超过 100MB");
        }

        // ② 生成存储路径
        String ext = getExtension(originalName);
        String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
        String filename = UUID.randomUUID().toString().replace("-", "") + ext;
        String relativePath = dateDir + "/" + filename;
        File target = new File(basePath, relativePath);

        // ③ 创建目录
        File parent = target.getParentFile();
        if (!parent.exists() && !parent.mkdirs()) {
            throw new BusinessException("目录创建失败");
        }

        // ④ 写入
        try {
            Files.copy(inputStream, target.toPath(), StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            throw new BusinessException("文件保存失败: " + e.getMessage());
        }

        // ⑤ 返回访问 URL
        return urlPrefix + "/" + relativePath;
    }

    @Override
    public void delete(String url) {
        if (url == null || !url.startsWith(urlPrefix)) return;

        String relativePath = url.substring(urlPrefix.length() + 1);
        File file = new File(basePath, relativePath);
        if (file.exists()) {
            file.delete();
        }
    }

    private String getExtension(String filename) {
        if (filename == null) return "";
        int idx = filename.lastIndexOf('.');
        return idx == -1 ? "" : filename.substring(idx).toLowerCase();
    }
}

⚠️ 坑 2:永远不要用用户提供的文件名直接保存。恶意用户传 ../../etc/passwd 就能覆盖系统文件。必须用 UUID 或时间戳重命名,只保留扩展名做 MIME 推断。

四、静态资源映射 ​

本地存储要在 Spring 里暴露 /static 目录:

java
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Value("${storage.local.path:/var/uploads/taskflow}")
    private String uploadPath;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
                .addResourceLocations("file:" + uploadPath + "/");
    }
}

访问 http://localhost:8080/static/2026/08/14/xxx.jpg 就能取到文件。

五、MinIO 实现 ​

xml
<!-- pom.xml -->
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.10</version>
</dependency>
yaml
# application.yml
storage:
  type: minio            # 切换实现: local / minio
  minio:
    endpoint: http://127.0.0.1:9000
    access-key: minioadmin
    secret-key: minioadmin
    bucket: taskflow
    url-prefix: http://127.0.0.1:9000
java
@Component
@ConditionalOnProperty(name = "storage.type", havingValue = "minio")
public class MinioStorage implements Storage {

    private final MinioClient minioClient;

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

    @Value("${storage.minio.url-prefix}")
    private String urlPrefix;

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

    @Override
    public String upload(InputStream inputStream, String originalName, long size) {
        try {
            // ① 确保 bucket 存在
            ensureBucket();

            // ② 生成对象名
            String ext = getExtension(originalName);
            String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
            String objectName = dateDir + "/" + UUID.randomUUID() + ext;

            // ③ 上传
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(bucket)
                    .object(objectName)
                    .stream(inputStream, size, -1)
                    .contentType(getContentType(ext))
                    .build());

            return urlPrefix + "/" + bucket + "/" + objectName;

        } catch (Exception e) {
            throw new BusinessException("MinIO 上传失败: " + e.getMessage());
        }
    }

    @Override
    public void delete(String url) {
        try {
            String objectName = url.substring(urlPrefix.length() + bucket.length() + 2);
            minioClient.removeObject(RemoveObjectArgs.builder()
                    .bucket(bucket)
                    .object(objectName)
                    .build());
        } catch (Exception e) {
            // 删除失败不抛异常(防止阻塞业务)
        }
    }

    private void ensureBucket() throws Exception {
        boolean exists = minioClient.bucketExists(BucketExistsArgs.builder()
                .bucket(bucket).build());
        if (!exists) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
        }
    }
}

⚠️ 坑 3:MinIO 默认 bucket 是私有的。要么 bucket 设成 public,要么用 presignedGetObject 生成 7 天有效期的临时 URL。前者简单,但失去访问控制。

六、上传 Controller ​

java
@RestController
@RequestMapping("/api/file")
@RequiredArgsConstructor
@Tag(name = "文件管理")
public class FileController {

    private final Storage storage;

    @PostMapping("/upload")
    @PreAuthorize("isAuthenticated()")      // 任意登录用户可上传
    public Result<String> upload(@RequestParam("file") MultipartFile file) {
        try {
            String url = storage.upload(
                    file.getInputStream(),
                    file.getOriginalFilename(),
                    file.getSize());
            return Result.ok(url);
        } catch (Exception e) {
            throw new BusinessException("上传失败: " + e.getMessage());
        }
    }

    @PostMapping("/upload/batch")
    @PreAuthorize("isAuthenticated()")
    public Result<List<String>> uploadBatch(@RequestParam("files") MultipartFile[] files) {
        List<String> urls = new ArrayList<>();
        for (MultipartFile file : files) {
            try {
                String url = storage.upload(
                        file.getInputStream(),
                        file.getOriginalFilename(),
                        file.getSize());
                urls.add(url);
            } catch (Exception e) {
                urls.add(null);
            }
        }
        return Result.ok(urls);
    }

    @DeleteMapping
    @PreAuthorize("hasAuthority('file:delete')")
    public Result<Void> delete(@RequestParam String url) {
        storage.delete(url);
        return Result.ok();
    }
}

配置文件大小限制:

yaml
spring:
  servlet:
    multipart:
      max-file-size: 100MB       # 单文件
      max-request-size: 200MB    # 整个请求

七、文件类型校验 ​

java
// 白名单校验
private static final Set<String> ALLOWED_IMAGE_EXT = Set.of(".jpg", ".jpeg", ".png", ".gif", ".webp");
private static final Set<String> ALLOWED_DOC_EXT = Set.of(".pdf", ".doc", ".docx", ".xls", ".xlsx");

public void validate(String filename, long size) {
    String ext = getExtension(filename);

    if (!ALLOWED_IMAGE_EXT.contains(ext) && !ALLOWED_DOC_EXT.contains(ext)) {
        throw new BusinessException("不支持的文件类型: " + ext);
    }

    if (size > 100 * 1024 * 1024) {
        throw new BusinessException("文件超过 100MB 限制");
    }
}

⚠️ 坑 4:白名单,不要黑名单。黑名单永远列不完(.exe .bat .sh .php .jsp...),白名单只允许 jpg/png/pdf 等已知安全类型,根本不让危险文件上传。

八、头像上传实战 ​

用户在个人信息页改头像:

vue
<template>
  <el-upload
      action="/api/file/upload"
      :headers="{ Authorization: 'Bearer ' + token }"
      :show-file-list="false"
      :on-success="onSuccess"
      :before-upload="beforeUpload"
      accept="image/*"
  >
      <el-avatar :src="userInfo.avatar" :size="80" />
  </el-upload>
</template>

<script setup>
const userStore = useUserStore()

const beforeUpload = (file) => {
    const isImage = file.type.startsWith('image/')
    const isLt2M = file.size / 1024 / 1024 < 2
    if (!isImage) ElMessage.error('只能上传图片')
    if (!isLt2M) ElMessage.error('图片不能超过 2MB')
    return isImage && isLt2M
}

const onSuccess = (response) => {
    if (response.code === 200) {
        userInfo.avatar = response.data
        ElMessage.success('头像更新成功')
    }
}
</script>

九、本章小结 ​

要点关键
抽象接口Storage 接口,切换不侵入业务
本地存储UUID 重命名 + 静态资源映射
MinIObucket 权限 + 公开访问策略
安全白名单校验 + 大小限制 + 重命名
头像Element Plus Upload + 拦截器

动手练习 ​

  1. 上传接口:实现一个上传接口,接收头像图片,返回 URL
  2. 切换实现:在 application.yml 把 storage.type 从 local 切到 minio,验证接口无侵入
  3. 白名单:写一个白名单校验,只允许 jpg/png/gif/pdf,其他返回 400

下一章:第 12 章:接口测试 →

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