小文件上传接口,为什么能单独讲一讲
在智能视频推荐小程序这类项目里,上传接口往往被当成“工具人”角色:前端调一个接口,后端存一下文件,完事。但真正做过生产环境的人都知道,文件上传是所有接口里最容易出问题的几个点之一——它不是难在“能传”,而是难在“传得稳、传得对、传完之后还能用”。
这一讲我们聚焦小文件上传,就是那种头像、封面图、短视频素材、电子签名之类的文件,大小通常在几 MB 到几十 MB 之间。大文件上传要讲分片、断点续传、秒传,小文件上传看似简单,但它涉及的前后端协作细节、参数约定、存储策略、异常处理,反而是后续所有文件功能的基础。
读完这篇文章,你能掌握一套完整的小文件上传接口设计方案:从 Uniapp 前端如何选择上传 API,到 Spring Boot 后端如何接收参数、校验文件、落盘存储、返回可访问 URL,再到接入 LangChain4j / Spring AI 相关的视频推荐服务时,文件路径该怎么管理。更重要的是,我会把最容易踩的坑,比如 Content-Type、multipart 表单字段名、临时目录权限、跨域配置、文件名乱码、文件类型伪造等,按排查顺序讲清楚。
这套代码不是只为了“跑通”,而是可以直接往项目里集成。下面我们进入正题。
1. 先判断:你的项目真的需要“小文件上传接口”吗
很多同学在写这类项目时,容易把上传功能做得过度复杂。一上来就设计分片、秒传、进度条、断点续传,结果前端和后端来回折腾了一个月,最后发现产品需求里只是上传一张 500 KB 的头像。
从材料看,这个智能视频推荐小程序的模块划分里,小文件上传接口是独立的一讲。这意味着它在整个项目里承担的任务是明确的:
- 上传用户头像、视频封面、频道图标等图片类文件;
- 上传短视频的默认预览图;
- 上传用户反馈截图、运营素材等辅助文件;
- 为后续大文件分片上传提供统一的文件访问基础。
所以,我们设计时不需要做分片,但必须把以下能力做扎实:
- 大小限制与类型校验;
- 文件名安全处理(防路径穿越、防中文乱码);
- 存储目录规划(区分临时目录和正式目录);
- 返回稳定的可访问 URL;
- 异常时前端能感知并给出友好提示。
如果这些基础能力没做好,后面做视频上传、封面上传、OCR 素材上传时,全都会在同一个地方翻车。这个判断不是危言耸听——我在排障时见过太多“接口明明返回成功,但文件没存上”或者“文件存上了,但访问 404”的情况,根因都是小文件上传阶段埋下的雷。
2. 核心概念:multipart/form-data 与上传链路里的角色
2.1 为什么上传要用 multipart/form-data
HTTP 请求体的 Content-Type 有很多种。传 JSON 时我们用application/json,传表单键值对时用application/x-www-form-urlencoded。但文件上传不一样,文件是二进制数据,JSON 和普通表单都难以高效承载。浏览器和各个 HTTP 客户端约定了一个专门格式:multipart/form-data。
这个格式的核心思路是:把表单里的多个字段打包成一个多部分消息,每个部分有自己的Content-Disposition头,用来标识它是普通字段还是文件字段。普通的键值对可以直接出现在请求体里,而文件内容会以二进制形式放在文件字段对应的部分。
application/x-www-form-urlencoded对二进制文件的处理效率太低,而且没有内置的文件名、Content-Type 等元数据。所以任何涉及文件上传的接口,不管是 Uniapp、Vue、微信小程序,还是浏览器原生表单,最终都会拼成multipart/form-data发送。
2.2 Uniapp 前端在上传链路里的两个角色
Uniapp 是跨端框架,它同时需要处理两套环境:H5 和 App/小程序。在 H5 端,我们可以使用uni.uploadFile或浏览器的XMLHttpRequest/fetch;在 App 端和小程序端,通常使用uni.uploadFile,它底层会调用各自平台的原生上传能力。
关键点在于:uni.uploadFile的返回值并不直接是一个 Blob 或二进制内容,而是一个上传任务对象。你无法用uni.request直接塞一个本地临时文件路径进去。很多新手在 Uniapp 里写成key: fileValue直接传给请求体,结果后端收到的是一个空对象或者其他字符串,排查半天才发现根本没有走 multipart 格式。
2.3 Spring Boot 后端如何解析 multipart
Spring Boot 使用MultipartFile接口来接收上传的文件。它由StandardServletMultipartResolver解析 HTTP 请求体,把 multipart 部分转换成MultipartFile实例。
MultipartFile提供的主要方法:
getOriginalFilename():获取原始文件名;getContentType():获取文件的 Content-Type;getSize():获取文件大小;getInputStream():获取文件输入流;transferTo(File dest):把文件内容写入目标文件。
在使用transferTo时要注意:它默认不是原子操作,如果目标文件已存在或者目录没有写权限,会抛异常。生产环境更保险的做法是用InputStream配合Files.copy来写。
2.4 为什么说“小文件”也要有边界
小文件是一个业务概念,不是一个 HTTP 标准。我们必须给它一个明确的数值边界,才能做限制和校验。从实践来看,几 MB 到几十 MB 的文件算小文件,比如:
- 用户头像:建议限制 2~5 MB;
- 视频封面:建议限制 5~10 MB;
- 短视频草稿素材:建议限制 50 MB 以内。
超过这个范围,就应该考虑分片上传。因为一次性把 1 个 GB 的文件塞进 multipart 请求里,无论是内存压力、网络超时还是后端解析耗时,都会带来问题。我们的项目里,小文件接口默认限制单文件 20 MB,完全够用。
3. 环境准备与项目结构说明
我们假设你已经有一个可运行的 Spring Boot 3.x 项目,并且已经集成了 LangChain4j 或 Spring AI 的视频推荐服务。这一讲的上传接口不依赖具体的 AI 模型,但需要统一的应用配置、数据库表(如果有文件表)和工具类。
3.1 后端环境
- JDK:17 及以上(Spring Boot 3 要求);
- Spring Boot:3.2.x 或 3.3.x,版本以实际项目为准;
- 依赖管理:Maven 或 Gradle;
- 可选的 ORM:MyBatis-Plus 或 Spring Data JPA;
- 可选缓存:Redis(用于存储临时上传凭证,非必须)。
3.2 前端环境
- HBuilderX 或 VS Code;
- Vue 3 + Vite(或 Vue 2 项目也可以,但推荐 Vue 3);
- Uniapp CLI 创建的项目;
- 一个可用的 baseURL,比如
http://localhost:8080或局域网 IP。
3.3 项目目录约定
在后端src/main/java/com/example/video下,我们需要以下几类文件:
controller/FileController.java;service/FileStorageService.java;config/WebConfig.java(负责静态资源映射和跨域);dto/UploadResponse.java(返回统一结构);exception/BusinessException.java(统一异常)。
前端在src/api/file.js中封装上传方法,在src/pages/upload/upload.vue中编写测试页面。
4. 小文件上传接口的后端设计与实现
4.1 接口设计约定
我们定义如下接口:
- URL:
POST /api/v1/file/upload - Content-Type:
multipart/form-data - 请求字段:
file:文件字段,必填;bizType:业务类型字符串,比如avatar、cover、feedback,必填;description:可选,备注信息。
响应结构:
code:20000(成功)或 4xxxx(业务失败);message:提示信息;data:上传结果,包含url、size、originalName、storagePath。
这种设计的好处是:通过bizType区分文件归属和目录,后续做 AI 视频推荐时,能根据业务类型决定是否触发 OCR、图片分析等下游处理。
4.2 依赖配置
在pom.xml里最关键的依赖是:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>如果你的项目已经引入了spring-boot-starter-web,那么 multipart 的解析组件已经包含在里面,不需要额外引入 servlet 依赖。
4.3 配置 multipart 参数
在application.yml中:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB enabled: true注意max-request-size要比max-file-size大,因为 multipart 请求体里除了文件还有图表字段。如果只配置max-file-size,当文件加表单字段超过 Tomcat 的请求体限制时,也会报错。
4.4 编写文件工具类
我们需要一个FileNameUtils来处理文件名。核心目标:
- 防止路径穿越;
- 保留合理后缀;
- 避免中文文件名带来的兼容问题;
- 生成唯一存储文件名。
public class FileNameUtils { public static String getExtension(String originalFilename) { if (originalFilename == null || originalFilename.isEmpty()) { return ""; } int index = originalFilename.lastIndexOf("."); return index == -1 ? "" : originalFilename.substring(index).toLowerCase(); } public static String generateStorageName(String originalFilename) { String ext = getExtension(originalFilename); return System.currentTimeMillis() + "_" + UUID.randomUUID().toString().replace("-", "") + ext; } public static String getCleanOriginalName(String originalFilename) { // 去掉路径分隔符,避免客户端传入 ../../xx.jsp String name = originalFilename.replace("\\", "/"); int lastSlash = name.lastIndexOf("/"); return lastSlash == -1 ? name : name.substring(lastSlash + 1); } }这里最容易被忽略的就是getCleanOriginalName。如果你直接把客户端传来的文件名当成绝对路径使用,对方传一个../../../etc/passwd加上合适的目录结构,可能造成文件写入到预期之外的目录。虽然我们在后端不会直接用前端文件名拼接绝对路径,但规范处理总是必要的。
4.5 编写存储服务
存储服务负责创建目录、校验扩展名、写文件、返回访问路径。
@Service public class FileStorageService { @Value("${file.upload-dir}") private String uploadDir; @Value("${file.url-prefix}") private String urlPrefix; private static final Map<String, List<String>> BIZ_ALLOWED_EXT = new HashMap<>(); static { BIZ_ALLOWED_EXT.put("avatar", Arrays.asList(".jpg", ".jpeg", ".png", ".webp", ".gif")); BIZ_ALLOWED_EXT.put("cover", Arrays.asList(".jpg", ".jpeg", ".png", ".webp")); BIZ_ALLOWED_EXT.put("feedback", Arrays.asList(".jpg", ".jpeg", ".png", ".gif", ".mp4", ".zip")); BIZ_ALLOWED_EXT.put("material", Arrays.asList(".jpg", ".jpeg", ".png", ".mp4", ".mov", ".zip")); } public UploadResponse store(MultipartFile file, String bizType) { if (file == null || file.isEmpty()) { throw new BusinessException("上传文件不能为空"); } long size = file.getSize(); if (size > 20 * 1024 * 1024) { throw new BusinessException("文件大小不能超过20MB"); } String originalName = FileNameUtils.getCleanOriginalName(file.getOriginalFilename()); String ext = FileNameUtils.getExtension(originalName); if (!BIZ_ALLOWED_EXT.getOrDefault(bizType, Collections.emptyList()).contains(ext)) { throw new BusinessException("文件类型不支持"); } String storageName = FileNameUtils.generateStorageName(originalName); // 按业务类型分目录,便于后续清理和管理 String relativePath = bizType + "/" + storageName; File dest = new File(uploadDir, relativePath); if (!dest.getParentFile().exists()) { if (!dest.getParentFile().mkdirs()) { throw new BusinessException("创建上传目录失败"); } } try (InputStream in = file.getInputStream()) { Files.copy(in, dest.toPath(), StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { throw new BusinessException("文件保存失败: " + e.getMessage()); } String accessUrl = urlPrefix + "/" + relativePath; return UploadResponse.builder() .url(accessUrl) .storagePath(relativePath) .size(size) .originalName(originalName) .build(); } }注意这段代码:
- 用
Files.copy(InputStream, Path, StandardCopyOption.REPLACE_EXISTING)替代transferTo,减少临时目录迁移问题; - 配置里的
file.upload-dir是绝对路径,如果是 Windows 环境注意分隔符; file.url-prefix通常是http://localhost:8080/files,也可以是用 Nginx 映射的地址。
4.6 编写 Controller
@RestController @RequestMapping("/api/v1/file") public class FileController { @PostMapping("/upload") public Result<UploadResponse> upload(@RequestParam("file") MultipartFile file, @RequestParam("bizType") String bizType, @RequestParam(value = "description", required = false) String description) { UploadResponse response = fileStorageService.store(file, bizType); return Result.success(response); } }这里有两个容易踩的细节:
- 前端字段名必须叫
file,否则 Spring MVC 无法完成绑定,报错Required part 'file' is not present; @RequestParam("bizType")是必填的,前端遗漏时后端会返回 400,这个错误提示对用户不友好,所以建议在 Controller 层或全局异常处理器里统一转成中文提示。
4.7 静态资源映射与跨域配置
如果我们的访问 URL 直接通过 Spring Boot 提供文件,需要添加静态资源映射:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 将 /files/** 映射到磁盘目录 registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadDir + "/"); } @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*"); } }addCorsMappings一定不要漏。Uniapp 在 H5 端运行时,如果前后端端口不同,浏览器会发起跨域请求。小程序和 App 端不存在浏览器跨域限制,但如果你在调试 H5 页面,跨域配置是必须的。允许的来源建议细化,生产环境不要用*,而是要配置成你的前端域名。
5. Uniapp 前端上传实现
5.1 封装上传 API
在src/api/file.js中:
export const uploadFile = (filePath, bizType, description = '') => { return new Promise((resolve, reject) => { uni.uploadFile({ url: `${baseURL}/api/v1/file/upload`, filePath: filePath, name: 'file', formData: { bizType: bizType, description: description }, success: (res) => { // 注意:res.statusCode 是 HTTP 状态码 // res.data 是响应体字符串,需要 JSON.parse if (res.statusCode === 200) { const result = JSON.parse(res.data); if (result.code === 20000) { resolve(result.data); } else { reject(new Error(result.message)); } } else { reject(new Error(`上传失败,状态码:${res.statusCode}`)); } }, fail: (err) => { reject(err); } }); }); };核心点:
name必须是后端约定的file;formData里传业务参数;res.data不是对象,必须先用JSON.parse解析,很多同学在这个地方直接res.data.code拿不到值,转而怀疑后端接口有问题。
5.2 页面调用示例
以选择图片并上传头像为例:
<template> <view class="page"> <image v-if="avatarUrl" :src="avatarUrl" mode="aspectFill" /> <button @click="chooseImage">选择头像</button> </view> </template> <script setup> import { ref } from 'vue'; import { uploadFile } from '@/api/file.js'; const avatarUrl = ref(''); const chooseImage = () => { uni.chooseImage({ count: 1, sizeType: ['compressed'], success: async (res) => { const tempFilePath = res.tempFilePaths[0]; try { const data = await uploadFile(tempFilePath, 'avatar', '用户头像'); avatarUrl.value = data.url; uni.showToast({ title: '上传成功', icon: 'success' }); } catch (e) { uni.showToast({ title: e.message || '上传失败', icon: 'none' }); } } }); }; </script>如果你使用的是 Vue 2 项目,把<script setup>换成 Options API 的data和methods即可,逻辑一致。
5.3 处理 H5 端的特殊场景
在 H5 端,uni.chooseImage走的是浏览器 file input 的逻辑,最终tempFilePath是一个 blob URL。调用uni.uploadFile时,uni-app 内部会处理好 multipart 格式,无需额外改动。
但如果你的项目里已经有原生 Vue 组件,使用<input type="file">时,不能直接把这个File对象传给uni.uploadFile。这时要么用 Uniapp 的 API,要么用XMLHttpRequest自己拼 multipart。为了保持跨端统一,建议所有上传入口都走uni.uploadFile。
6. 接入 LangChain4j / Spring AI 后的文件路径管理
这个项目叫“智能视频推荐”,所以上传的文件很有可能会被后续的 AI 模块使用。比如用户上传一个视频封面,AI 模型可能需要读这张图做标签识别;或者用户上传一段视频素材,AI 需要抽取关键帧。这个时候文件路径的管理就变得重要。
我们建议在数据库中建一张file_info表,记录storage_path、url、biz_type、size、upload_time、status等字段。上传接口只负责落盘,而 AI 服务通过storagePath读取本地文件,避免每次通过 URL 去下载,减少网络开销。
示例表结构:
CREATE TABLE file_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(50) NOT NULL COMMENT '业务类型', storage_path VARCHAR(255) NOT NULL COMMENT '相对存储路径', url VARCHAR(255) NOT NULL COMMENT '访问地址', original_name VARCHAR(255) COMMENT '原始文件名', size BIGINT COMMENT '文件大小(字节)', status TINYINT DEFAULT 1 COMMENT '1-有效 0-删除', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );在推荐服务中,我们可以这样使用:
@Service public class VideoRecommendService { private final FileInfoMapper fileInfoMapper; private final LangChain4jService langChain4jService; public RecommendResult recommendByImage(Long fileId) { FileInfo fileInfo = fileInfoMapper.selectById(fileId); if (fileInfo == null) { throw new BusinessException("文件不存在"); } File imageFile = new File(uploadDir, fileInfo.getStoragePath()); // 调用 LangChain4j 的多模态模型接口,读取图片内容 return langChain4jService.analyzeImage(imageFile); } }这里核心的提醒是:千万不要把上传接口返回的url直接当作绝对本地路径传给 AI 服务。URL 是为了 HTTP 访问用的,本地 AI 服务应该从存储路径读取。如果把http://localhost:8080/files/cover/xxx.jpg直接传给文件读取 API,大概率会报 FileNotFoundException 或访问不到,除非你做了额外的下载逻辑。
LangChain4j 和 Spring AI 在视频推荐场景里主要负责的是“内容理解”和“推荐排序”这些 AI 能力,而文件上传和存储属于基础设施。两者之间通过file_info表和本地磁盘路径解耦,这样后续更换存储策略,比如从本地磁盘换成 OSS、MinIO,只需要改 FileStorageService,AI 服务完全不用动。
7. 运行与验证:怎么判断接口是否真的成功
7.1 使用 curl 快速验证后端接口
在前后端还没联调时,最快的方式是用 curl 发送 multipart 请求:
curl -X POST http://localhost:8080/api/v1/file/upload \ -F "file=@/tmp/test.png" \ -F "bizType=avatar" \ -F "description=测试头像"预期返回 JSON:
{ "code": 20000, "message": "success", "data": { "url": "http://localhost:8080/files/avatar/1718000000000_abc123.png", "storagePath": "avatar/1718000000000_abc123.png", "size": 10245, "originalName": "test.png" } }判断成功的标准:
- HTTP 状态码为 200;
code为 20000;- 用浏览器打开返回的
url,能看到图片内容; - 在磁盘目录
uploadDir/avatar/下能看到对应的文件。
如果失败,优先看控制台日志里的异常信息。常见的是上传目录不存在或没有写权限,后端会抛创建上传目录失败或文件保存失败。
7.2 使用 Uniapp 联调验证
在 H5 端通过开发者工具打开页面,选择图片后,浏览器 Network 面板里能看到upload请求。Request Headers 中Content-Type应该是:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...如果没有这个头,说明前端没有走uni.uploadFile,或者你在uni.request里强行传了filePath,这种请求后端是无法解析的。
在 App 端调试时,注意真机预览下的临时文件路径可能与开发工具有差异,只要chooseImage成功返回了路径,上传逻辑是一致的。
7.3 自动化测试建议
后端可以用MockMvc写一个简单的上传测试。如果不想引入真实文件,可以使用 Spring 的MockMultipartFile:
@Test void testUpload() throws Exception { MockMultipartFile file = new MockMultipartFile( "file", "test.png", "image/png", "fake-image-content".getBytes(StandardCharsets.UTF_8) ); mockMvc.perform(multipart("/api/v1/file/upload") .file(file) .param("bizType", "avatar") .param("description", "unit-test")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(20000)); }注意MockMultipartFile的第一个参数必须是file,和@RequestParam("file")对应。
8. 常见问题与排查思路
围绕小文件上传,我把实践中出现频率最高的问题整理成一张表。这些问题不是理论推演,而是真实项目里常见的报错。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
后端报Required part 'file' is not present | 前端字段名不是 file,或者没有走 multipart 格式 | 看请求体内容,用开发者工具 Network 检查 Request Payload | 前端uni.uploadFile的name改为 file |
| 上传超大文件被拒绝 | 超过max-file-size或max-request-size | 看异常信息里的 MaxUploadSizeExceededException | 调大配置,或提示用户使用大文件分片接口 |
| 文件保存到磁盘但访问 404 | 静态资源映射未配置,或配置路径错误 | 检查 WebConfig 里的addResourceLocations是否为file: + 磁盘目录 | 确保目录末尾有/,并且目录权限可读 |
| 文件名乱码 | 未处理中文文件名,或未使用 UTF-8 编码 | 查看原始请求头 Content-Disposition 里的 filename | 使用FileNameUtils.getCleanOriginalName处理,并统一存储为系统生成名 |
| 上传成功但数据库没有记录 | Controller 只写了文件,没有写数据库 | 检查 service 层是否包含fileInfoMapper.insert | 把存储路径、URL、大小等落库,方便 AI 服务引用 |
| H5 跨域报错 | 后端未配置 COORS,或配置的 origin 不匹配 | 看浏览器 console 的 CORS 错误 | 在 WebConfig 的addCorsMappings中配置正确的 origin |
前端JSON.parse(res.data)报错 | 后端返回的不是 JSON 字符串,或响应体为 HTML 错误页 | 打印res.data内容 | 优先排查后端异常是否是 404/500,再看全局异常处理器是否返回统一 JSON |
| 接口返回成功但文件内容被截断 | 网络中断或超时,或前端压缩图片后未重传 | 比较前端上传的 size 和后端存储的 size | 在 service 层记录 size,上传完成后主动比对 |
这张表的中心思想是:排错顺序应该是“请求格式对不对 → 后端能不能接收 → 存储是否成功 → 访问是否通”。很多人一上来就查数据库,其实文件上传出问题很少是数据库导致的。
9. 最佳实践与工程建议
9.1 定义明确的白名单,不要用黑名单
不要只判断“不是 exe 就行”。因为文件类型可以由客户端伪造,Content-Type 也不可信。更稳妥的做法是白名单管理:每个bizType明确允许哪些扩展名。头像允许图片格式,封面允许图片格式,反馈附件可以允许压缩包,但绝不放开一切。
如果你需要更强的安全能力,可以进一步检测文件头,比如读取图片的前几个字节判断是不是真的 PNG/JPG。这个我们不做展开,但要记住:扩展名只能作为第一层校验。
9.2 统一返回结构,不要成功/失败各写一套
前端封装上传 API 时,依赖的返回结构必须是稳定的。建议用全局的Result<T>:
@Data public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 20000; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(int code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }配合全局异常处理器,把BusinessException转成 JSON 返回。否则上传失败时 Spring Boot 默认返回的是一个带时间戳和状态的 JSON,前端还要针对不同错误类型特殊处理。
9.3 记录日志,尤其是失败原因
文件上传是 I/O 密集型操作,失败原因可能是权限、磁盘满、网络中断、文件名异常。建议在 service 层记录结构化日志:
log.info("Upload start, bizType={}, fileName={}, size={}", bizType, originalName, size); try { // ... 存储逻辑 log.info("Upload success, storagePath={}, url={}", relativePath, accessUrl); } catch (IOException e) { log.error("Upload failed, bizType={}, fileName={}, error={}", bizType, originalName, e.getMessage(), e); throw new BusinessException("文件保存失败"); }生产环境建议把日志接入 ELK 或 Loki,排查问题时非常高效。
9.4 前端要限制文件类型和大小,但不能只靠前端
在chooseImage里可以配置sizeType: ['compressed']来压缩图片,或者在uploadFile前通过uni.getFileInfo获取大小,提前拦截超大文件。但前端限制只是用户体验,安全边界必须由后端控制。攻击者可以绕过前端直接调用接口,所以后端校验不能省略。
9.5 合理规划存储目录,避免全部塞到一个目录
不要把所有文件都放到uploadDir/下。用bizType作为第一层目录,然后按yyyy/MM/dd作为第二层,比如:
uploadDir/avatar/2025/06/07/xxx.jpg这样在后续做定期清理、备份、迁移时会更清晰。如果目录过于扁平,文件数量会迅速膨胀,导致文件系统访问变慢。
9.6 生产环境上线前必须检查磁盘权限和备份策略
如果服务器以www用户运行 Java 进程,那么uploadDir必须对该用户可写。否则会一直报权限错误。同时,小文件可能因为各种原因变成垃圾文件,最好有一个过期清理策略。可以每天凌晨把status=0的数据库记录对应的磁盘文件删除,并把storagePath记录到操作日志里。
10. 总结与实际项目落地方案
这一讲里,我们围绕小文件上传接口讲解了完整的技术链路:
- 后端用
MultipartFile接收文件,通过bizType做业务分类和扩展名白名单校验; - 存储层把文件写入磁盘,返回可访问 URL;
- 前端用 Uniapp 的
uni.uploadFile完成上传,并通过封装 API 统一处理返回结果; - 文件落库后,LangChain4j 和 Spring AI 的推荐服务可以通过
file_info表拿到本地路径做后续 AI 分析; - 整套方案的异常处理和最佳实践足以支撑生产环境使用。
如果你要把它集成进你现在写的智能视频推荐小程序,我建议按这个顺序改代码:
- 先把
application.yml的 multipart 配置和file.upload-dir、file.url-prefix写好; - 把
FileController、FileStorageService、FileNameUtils三件套复制进项目; - 前端新建
file.js和测试页面,用 curl 或 H5 页面跑通一次上传; - 然后建立
file_info表,在 service 里补上插入逻辑; - 最后再接入 LangChain4j 或 Spring AI 的文件分析服务。
上一步跑通再走下一步,不要一口气把上传、分片、AI 分析全写完。文件上传做对了,后面视频推荐、封面识别、用户头像这些功能就都有了一个稳定地基。
下一步你可以继续探索几块内容:大文件分片上传的实现、Nginx 反向代理下的文件访问与缓存策略、以及对象存储(OSS/MinIO)替换本地存储的方案。这三块和你现在写的小文件上传接口是天然衔接的,等当前功能稳定后,值得继续投入。