
第八讲来了。这一讲不写算法也不调模型先把后端最基础的文件工具层补齐。智能视频推荐小程序里视频上传、封面图保存、知识库文档加载、运营后台的素材导出全都绕不开文件读写。如果这一层写得乱后面接 LangChain4j 文档解析、接 SpringAI 知识库问答都会反复踩坑。文件工具类在项目里承担的职责比想象中要大这里先梳理一下接收小程序端上传的视频和封面图落盘到本地目录给上传文件生成唯一文件名并校验扩展名和文件大小提供批量上传能力方便运营端一次性处理多个素材把 TXT、Markdown、PDF 等知识库文件读取成纯文本为后续 RAG 流程打底。本文会从工具类方法开始逐步演示存储层、上传接口、批量并发控制再讲到与 LangChain4j / SpringAI 的衔接方式。所有代码都以 SpringBoot 3.x JDK 17 为基准如果项目是 JDK 8 SpringBoot 2.x个别 API 需要对应调整。整个系列已经完成了项目骨架搭建、小程序端基础页面、后端统一返回结构和 AI 配置初始化这一讲的代码会直接复用前面封装的ResultT统一返回结构。文章结构保持一贯的实操风格先看文件工具类的能力列表再进入环境准备然后是工具类核心代码、存储服务层、上传接口、批量任务、AI 文档读取最后是常见问题清单和最佳实践。建议打开 IDE 跟着敲一遍比只读文章收获大得多。1. 文件工具类核心能力速览能力项说明所属模块后端服务 common 工具层 / 文件存储模块技术栈SpringBoot 3.x、Java 17、Maven、Spring Web主要功能文件扩展名校验、唯一文件名生成、大小格式化、目录创建、文件保存、批量上传、文档转文本运行环境Linux / Windows / macOS本地磁盘目录存储启动方式随 SpringBoot 应用启动无需独立部署文件服务接口形式REST API/api/file/upload单文件上传/api/file/upload/batch批量上传批量任务支持批量上传、并发处理可限制线程池大小配合组件LangChain4j DocumentReader、Spring AI Document 读取适合场景视频推荐小程序素材管理、AI 知识库文件接入、运营后台批量导入这一讲的文件工具类不是简单的FileUtils.copy封装而是围绕“小程序素材上传 AI 文档解析”这两个核心场景设计的。视频上传需要控制格式和大小知识库文件上传需要支持多种文档类型并能转成纯文本所以工具类至少包含校验、存储、读取三部分能力。2. 适用场景与使用边界文件工具类在智能视频推荐小程序里主要覆盖三类场景第一类是视频素材上传。用户在推荐结果页看到的视频封面图、视频文件本身、运营后台投放的横幅图片都需要一个统一入口落到服务器本地或对象存储。第二类是知识库文档导入。如果小程序要做 AI 客服或基于资料库的问答运营人员会上传产品说明 PDF、FAQ 文本、活动规则 Markdown这些文件需要被读取成纯文本后交给 LangChain4j 或 SpringAI 处理。第三类是批量数据处理。运营后台一次性导入多张封面上传、批量导入文档素材这类操作不能一个文件一个文件地手动处理。需要明确的是文件工具类不适合承载大数据量视频存储业务。如果视频文件达到 GB 级别本地磁盘既影响读写速度也不利于横向扩容。更稳妥的做法是使用 MinIO、阿里云 OSS、腾讯云 COS 等对象存储服务本地工具类只负责生成访问路径和临时文件处理。此外凡是涉及用户上传内容的场景必须考虑版权和隐私边界。视频、封面图、文档如果包含他人肖像或受版权保护的内容小程序端应增加授权确认机制运营后台需要审核流程。本地文件存储目录默认是可访问的静态资源路径上线前必须做访问权限控制避免未登录用户直接扫描文件列表。3. 环境准备与前置条件开始写代码之前先确认项目环境。这一讲假定你已经有一个可运行的 SpringBoot 后端工程并且前面几讲已经把统一返回结构、全局异常处理、基础配置类准备好。开发环境建议如下JDK 17 及以上版本SpringBoot 3.x 项目默认要求 JDK 17Maven 3.6用于依赖管理和打包开发工具可以用 IDEA或者直接用 VSCode Spring Boot 插件磁盘空间至少预留 20GB用于存放视频和图片测试素材端口保持默认 8080如果本地端口被占用启动时改成 8081 即可。文件工具类涉及的核心依赖是spring-boot-starter-web它已经包含MultipartFile、文件上传解析和静态资源映射能力。如果你需要读取 PDF 文档再额外引入 PDF 解析相关依赖如果只需要读取 TXT、MD、JSON 等纯文本文件JDK 自带的Files类就够用。应用配置文件application.yml建议增加以下段落server: port: 8080 spring: servlet: multipart: max-file-size: 200MB max-request-size: 500MB file: upload-dir: ./data/uploads allowed-image-ext: jpg,jpeg,png,gif,webp,bmp allowed-video-ext: mp4,mov,avi,webm,mkv allowed-doc-ext: txt,md,pdf,docxmax-file-size和max-request-size是 SpringBoot 上传文件的内置限制。如果不调整默认只有 1MB视频一传就直接报错。file.upload-dir是自定义配置项表示文件落盘目录这里使用相对路径./data/uploads实际会生成在项目运行目录下。注意本地存储路径不要放在src/main/resources下因为打包成 jar 后无法写入。4. 文件工具类核心实现4.1 扩展名获取与唯一文件名生成工具类第一件事是解决文件名问题。用户上传的原始文件名不可信可能包含中文、空格、特殊字符甚至路径穿越符号直接使用会带来安全隐患。更好的做法是保留扩展名用 UUID 重新生成主文件名。package com.example.smartvideo.common.util; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.text.DecimalFormat; import java.util.Set; import java.util.UUID; public final class FileUtil { private FileUtil() { } public static String getExtension(String fileName) { if (fileName null || !fileName.contains(.)) { return ; } return fileName.substring(fileName.lastIndexOf(.) 1).toLowerCase(); } public static String generateFileName(String originalFileName) { String ext getExtension(originalFileName); String name UUID.randomUUID().toString().replace(-, ); return ext.isEmpty() ? name : name . ext; } public static String formatFileSize(long size) { if (size 0) { return 0B; } if (size 1024) { return size B; } if (size 1024 * 1024) { return new DecimalFormat(#.##).format(size / 1024.0) KB; } if (size 1024L * 1024 * 1024) { return new DecimalFormat(#.##).format(size / (1024.0 * 1024.0)) MB; } return new DecimalFormat(#.##).format(size / (1024.0 * 1024.0 * 1024.0)) GB; } }getExtension方法把扩展名统一转成小写后续做类型白名单校验时不用再担心大小写不一致。generateFileName生成的 UUID 文件名没有中划线减少 URL 拼接时的特殊字符问题。formatFileSize在返回文件大小给前端时比较友好避免让小程序端自己算单位换算。4.2 文件目录创建与保存保存文件必须处理目录不存在的情况。你不能假设./data/uploads一定存在尤其首次启动时这个目录往往是没有的。public static void createDirectoryIfNotExists(String dir) throws IOException { Path path Paths.get(dir); if (Files.notExists(path)) { Files.createDirectories(path); } } public static String save(MultipartFile file, String uploadDir) throws IOException { createDirectoryIfNotExists(uploadDir); String newFileName generateFileName(file.getOriginalFilename()); Path targetPath Paths.get(uploadDir, newFileName); try (InputStream in file.getInputStream()) { Files.copy(in, targetPath, StandardCopyOption.REPLACE_EXISTING); } return newFileName; } public static void deleteQuietly(String filePath) { try { Files.deleteIfExists(Paths.get(filePath)); } catch (IOException ignored) { // 删除失败不阻塞业务 } } }Files.copy配合InputStream是最稳妥的写法。MultipartFile.transferTo在某些 Servlet 容器下也有实现差异而Files.copy的行为一直很稳定。保存成功后返回新文件名外层方法再拼出 URL 给前端使用。deleteQuietly的存在是为了清理失败文件时不抛出异常避免把接口返回变成 500。4.3 文件类型白名单校验文件类型校验不能只判断扩展名严格场景还需要读取文件头字节。为了不让文章失控这里先给出扩展名白名单校验这是最容易落地的第一道防线。public static boolean isAllowedType(String extension, SetString allowedExtensions) { return allowedExtensions ! null allowedExtensions.contains(extension.toLowerCase()); } public static String readTextFile(Path path) throws IOException { return Files.readString(path, java.nio.charset.StandardCharsets.UTF_8); } }调用方把application.yml里配置的扩展名列表转成SetString再交给isAllowedType判断。如果扩展名不在白名单直接抛出业务异常。只按扩展名校验有一个弱点用户可以把恶意文件的扩展名改成.jpg然后上传。所以进阶做法是在保存前读取文件前几个字节判断真实类型例如 JPEG 文件头是FF D8 FFPNG 文件头是89 50 4E 47。这一讲先不过度展开后面最佳实践部分会提醒这个风险。5. 文件存储服务与上传接口工具类只提供底层能力真正决定业务逻辑的是存储服务层。这里设计一个FileStorageService负责读取配置、区分业务类型、调用工具类保存文件。package com.example.smartvideo.service; import com.example.smartvideo.common.util.FileUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.List; import java.util.Set; Service public class FileStorageService { Value(${file.upload-dir:./data/uploads}) private String uploadDir; Value(${file.allowed-image-ext:jpg,jpeg,png,gif,webp,bmp}) private String allowedImageExt; Value(${file.allowed-video-ext:mp4,mov,avi,webm,mkv}) private String allowedVideoExt; Value(${file.allowed-doc-ext:txt,md,pdf,docx}) private String allowedDocExt; public FileUploadResult upload(MultipartFile file, String bizType) throws IOException { if (file null || file.isEmpty()) { throw new IllegalArgumentException(文件不能为空); } String ext FileUtil.getExtension(file.getOriginalFilename()); SetString allowedSet resolveAllowedSet(bizType); if (!FileUtil.isAllowedType(ext, allowedSet)) { throw new IllegalArgumentException(不支持的文件类型: ext); } String relativeDir resolveBizDir(bizType); String dir Paths.get(uploadDir, relativeDir).toString(); String newFileName FileUtil.save(file, dir); FileUploadResult result new FileUploadResult(); result.setOriginalName(file.getOriginalFilename()); result.setFileName(newFileName); result.setSize(file.getSize()); result.setSizeText(FileUtil.formatFileSize(file.getSize())); result.setUrl(/files/ relativeDir / newFileName); result.setUploadTime(LocalDateTime.now()); return result; } private SetString resolveAllowedSet(String bizType) { if (video.equals(bizType)) { return Set.copyOf(List.of(allowedVideoExt.split(,))); } if (doc.equals(bizType)) { return Set.copyOf(List.of(allowedDocExt.split(,))); } return Set.copyOf(List.of(allowedImageExt.split(,))); } private String resolveBizDir(String bizType) { return video.equals(bizType) ? video : doc.equals(bizType) ? doc : image; } }FileUploadResult是文件上传返回的数据结构可以定义成服务内部静态类也可以放在 DTO 包下。字段包括originalName、fileName、size、sizeText、url、uploadTime。具体 getter/setter 在 IDE 里自动生成即可文章正文不全部贴出避免占据过多篇幅。resolveAllowedSet根据bizType切换不同的白名单默认走图片校验这样简化后前端可以根据场景传入image、video、doc三种类型。有了存储服务接下来是上传接口。这里沿用项目统一的ResultT返回结构。package com.example.smartvideo.controller; import com.example.smartvideo.common.model.Result; import com.example.smartvideo.service.FileStorageService; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; RestController RequestMapping(/api/file) public class FileController { private final FileStorageService fileStorageService; public FileController(FileStorageService fileStorageService) { this.fileStorageService fileStorageService; } PostMapping(/upload) public ResultFileUploadResult upload( RequestParam(file) MultipartFile file, RequestParam(value bizType, defaultValue image) String bizType) { try { return Result.ok(fileStorageService.upload(file, bizType)); } catch (IllegalArgumentException e) { return Result.fail(e.getMessage()); } catch (IOException e) { return Result.fail(文件保存失败: e.getMessage()); } } }接口侧只做参数接收和异常转换。上传成功返回Result.ok失败时区分“业务参数错误”和“文件写入失败”两种异常类型。Result.fail的msg会被前端直接展示给用户所以提示文案要尽量友好。为了验证保存后的文件能通过 URL 访问需要配置静态资源映射。SpringBoot 默认只把classpath:/static作为静态资源目录而我们是写到本地磁盘的./data/uploads所以要手动注册。package com.example.smartvideo.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.nio.file.Paths; Configuration public class WebConfig implements WebMvcConfigurer { Value(${file.upload-dir:./data/uploads}) private String uploadDir; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String location Paths.get(uploadDir).toAbsolutePath().toUri().toString(); if (!location.endsWith(/)) { location /; } registry.addResourceHandler(/files/**) .addResourceLocations(location); } }启动后上传得到的url字段是/files/image/xxx.jpg通过http://localhost:8080/files/image/xxx.jpg就可以直接访问。这一步容易踩坑的是.toUri().toString()出来的路径末尾没有斜杠而addResourceLocations对目录映射要求末尾必须是斜杠否则资源映射不生效。6. 批量上传与并发处理运营后台导入多张封面、批量上传视频时单文件接口需要调多次效率低且难以追踪失败原因。所以这里增加批量上传能力。批量上传的接口首先需要接收MultipartFile[]数组然后使用固定线程池并发执行单个文件的保存逻辑。注意这里不推荐用Executors.newCachedThreadPool()因为上传场景并发数不好控文件又大无限创建线程会直接把内存打满。package com.example.smartvideo.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; Service public class FileBatchService { private static final Logger log LoggerFactory.getLogger(FileBatchService.class); private final FileStorageService fileStorageService; private final ExecutorService executor Executors.newFixedThreadPool(4); public FileBatchService(FileStorageService fileStorageService) { this.fileStorageService fileStorageService; } public ListFileUploadResult uploadBatch(MultipartFile[] files, String bizType) throws InterruptedException { ListFutureFileUploadResult futures new ArrayList(); for (MultipartFile file : files) { futures.add(executor.submit(() - fileStorageService.upload(file, bizType))); } ListFileUploadResult results new ArrayList(); for (FutureFileUploadResult future : futures) { try { results.add(future.get()); } catch (ExecutionException e) { log.error(批量上传单文件失败, e); } } return results; } }批量上传失败策略是个设计点。这里选择单文件失败只记录日志不中断整批任务这样运营后台至少能把成功的那部分文件先导入失败文件后续通过日志排查重传。controller 层增加批量接口PostMapping(/upload/batch) public ResultListFileUploadResult uploadBatch( RequestParam(files) MultipartFile[] files, RequestParam(value bizType, defaultValue image) String bizType) { try { ListFileUploadResult results fileBatchService.uploadBatch(files, bizType); return Result.ok(results); } catch (Exception e) { return Result.fail(批量上传失败: e.getMessage()); } }用 curl 验证批量上传接口curl -X POST http://localhost:8080/api/file/upload/batch \ -F files./cover1.png \ -F files./cover2.png \ -F bizTypeimage返回结果是一个数组每个元素包含文件名和访问 URL。批量上传接口上线后要特别关注线程池大小和同时上传的文件数。如果一次上传 50 个文件每个文件 50MB内存压力会很明显。稳妥做法是把线程池改成 Spring 管理的ThreadPoolTaskExecutor设置队列容量并在配置文件中把最大并发数做成可调项。7. 与 LangChain4j / SpringAI 的衔接文件工具类还有一个容易忽略的用途为 AI 知识库提供文件读取能力。智能视频推荐小程序如果要回答用户关于内容推荐规则、活动玩法、免责协议等常见问题运营后台需要先上传一份资料文档再把文档内容向量化后交给大模型检索。LangChain4j 和 Spring AI 都提供了文档读取组件但底层仍然需要文件工具类先拿到文件路径再判断解析方式。以 LangChain4j 读取 PDF 为例流程是先把上传的 PDF 保存到本地然后用PdfDocumentReader读取文本段落最后把文本交给 Embedding API 生成向量。由于不同版本 LangChain4j 的包路径变化较快这里给出核心逻辑思路package com.example.smartvideo.service; import org.springframework.stereotype.Component; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.charset.StandardCharsets; Component public class DocumentLoader { public String loadText(String filePath) throws IOException { String ext ; int index filePath.lastIndexOf(.); if (index 0) { ext filePath.substring(index 1).toLowerCase(); } if (txt.equals(ext) || md.equals(ext) || json.equals(ext)) { return Files.readString(Paths.get(filePath), StandardCharsets.UTF_8); } // PDF / DOCX 使用 LangChain4j 或 Spring AI 的 DocumentReader // 示例PdfDocumentReader reader new PdfDocumentReader(filePath); // ListDocument documents reader.read(); // return documents.stream().map(Document::text).collect(Collectors.joining(\n)); throw new IllegalArgumentException(暂不支持的文件类型: ext); } }如果是 Spring AI 技术栈可以引入spring-ai-tika-document-reader组件Tika 的覆盖范围更广支持 PDF、DOCX、PPT 等多种格式。文件工具类在整条 AI 链路中的角色是统一的所有上传文件的落盘、命名、校验都由FileUtil完成AI 组件只消费最终的文件路径。这样切分职责的好处是未来把本地存储切换成对象存储AI 文档读取逻辑不需要改动。8. 资源占用与性能观察文件上传业务的资源占用主要体现在磁盘 IO 和 JVM 内存上。大视频上传时文件缓冲如果全部加载到内存多个请求并发时会撑爆堆空间。使用MultipartFile.getInputStream()配合Files.copy是流式写入内存占用比一次性FileUtils.copyToByteArray小得多这也是前面工具类坚持流式处理的原因。观察性能可以从三个维度入手第一个维度是磁盘写入速度。上传大文件时磁盘吞吐量决定接口耗时。普通机械硬盘写 100MB 文件可能需要一两秒SSD 会快很多。如果同一时间有多个大视频写入同一个目录磁盘 IO 排队会显著拉高耗时最好按业务类型分目录存储避免所有文件堆在一起。第二个维度是 JVM 内存和线程池。批量上传任务中线程池大小直接影响内存占用。线程数越多同一时刻被打开的文件流越多堆外内存随之上升。建议线程池最大线程数控制在 4 到 8 之间队列长度有上限。可以用 SpringBoot Actuator 暴露metrics端点观察http.server.requests和 JVM 内存指标。第三个维度是静态资源访问。如果所有视频都通过 SpringBoot 应用直接输出应用线程会被 IO 阻塞。一旦业务量上来把文件访问切到 Nginx 或对象存储是不可绕开的路径。本地./data/uploads只适合开发环境和小规模部署。这里格外提醒一点不要为了提速把上传文件完全加载到内存再写磁盘。虽然MultipartFile.getBytes()使用简单但百 MB 级别的文件会瞬间吃掉堆内存导致频繁 GC。流式写入是必须养成的习惯。9. 常见问题与排查方法问题现象可能原因排查方式解决方案上传图片后返回 URL 访问 404静态资源映射路径不对检查/files/**映射配置和实际存储目录确认addResourceLocations目录末尾带斜杠MultipartFile为空前端字段名不是file或请求格式错误查看网络请求体确认 FormData 字段名前端统一使用namefile上传大视频被拒绝SpringBoot multipart 默认限制 1MB查看日志中的MaxUploadSizeExceededException调大spring.servlet.multipart.max-file-size文件名中文乱码浏览器或手机客户端编码不一致打印getOriginalFilename()观察原始内容前端上传前统一encodeURIComponent批量上传部分文件丢失单文件异常被ExecutionException包裹看批量服务日志是否打印 error单文件失败记录日志汇总失败列表返回删除文件失败文件被占用或路径包含相对路径检查进程是否打开文件句柄使用toRealPath()规范化路径避免跨目录删除上传的文件无法被 AI 读取文档类型不在白名单中查看spring.servlet.multipart限制和allowed-doc-ext配置补充pdf、docx扩展名引入对应 DocumentReader本地磁盘被撑满没有及时清理过期素材查看上传目录大小增加定时任务清理超过 N 天的临时文件排查上传问题最直接的方式是打开 SpringBoot 日志观察异常堆栈。大部分上传失败都集中在 multipart 配置、文件白名单、静态资源映射这三个位置先把这三处检查完整能解决百分之八十的异常。10. 最佳实践与扩展建议文件工具类看上去简单但容易在细节上出问题。这里整理几条必须遵守的最佳实践。不要信任原始文件名。UUID 重命名是所有上传逻辑的底线既避免重名覆盖也防止路径穿越攻击。如果确实需要保留原始文件名展示给用户建议把原始文件名单独存数据库字段磁盘上只用 UUID 文件名。文件类型校验要分层。第一层看扩展名白名单第二层读文件头魔数第三层限制文件大小。视频、图片、文档分别设置不同的大小上限不要都用同一个max-file-size兜底。例如图片限制 5MB视频限制 200MB文档限制 20MB。这个控制可以放在FileStorageService里按bizType判断而不是只依赖 Spring 全局配置。批量上传要加失败重试。运营后台上传几十个文件时网络抖动或单文件损坏不可避免。批量任务需要返回每个文件的成功状态和失败原因方便前端提示用户重新上传。线程池要交给 Spring 管理Executors.newFixedThreadPool在测试环境没问题生产环境最好定义ThreadPoolTaskExecutorBean把核心线程数、最大线程数、队列容量都配置化。上线前要接入对象存储。本地磁盘目录开发时很方便但生产部署如果只有单机文件一旦丢失就是永久性故障。建议预留好存储层接口例如定义FileStorage接口本地实现和 OSS/MinIO 实现并存切换时只需变更配置。这一讲没有过度展开对象存储因为系列后面的章节会专门处理。版权和隐私合规不能忽略。小程序端上传视频、封面图、文档时要在用户协议中说明文件用途。运营后台导入的文档如果包含具体个人信息需要走脱敏流程。AI 知识库中的文档内容不要直接对用户全量展示只返回与问题相关的摘要片段降低内容泄露风险。11. 总结与下一步计划这一讲完成了文件工具类的核心实现FileUtil提供扩展名获取、唯一文件名生成、大小格式化、目录创建、文件保存和类型白名单校验FileStorageService负责按业务类型管理上传FileController暴露单文件和批量上传接口FileBatchService控制并发DocumentLoader打通了与 LangChain4j / SpringAI 的文档读取链路。至此小程序的素材上传和 AI 知识库文件接入已经有一个可运行的基础。建议先把单文件上传跑通再试批量任务最后验证 PDF 文档读取。最容易踩的坑是 multipart 大小限制和静态资源映射目录末尾斜杠这两个问题先记住遇到 404 或文件没落地时优先检查。下一讲可以接着做文件访问鉴权和对象存储切换也可以直接进入视频推荐算法与向量检索部分具体顺序按整个系列的进度安排。文件层已经稳定后面的业务功能就不容易被基础问题卡住了。