第 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:9000java
@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 重命名 + 静态资源映射 |
| MinIO | bucket 权限 + 公开访问策略 |
| 安全 | 白名单校验 + 大小限制 + 重命名 |
| 头像 | Element Plus Upload + 拦截器 |
动手练习
- 上传接口:实现一个上传接口,接收头像图片,返回 URL
- 切换实现:在 application.yml 把
storage.type从local切到minio,验证接口无侵入 - 白名单:写一个白名单校验,只允许
jpg/png/gif/pdf,其他返回 400
下一章:第 12 章:接口测试 →