SpringBoot+Mybatis构建网盘存储系统:文件上传、秒传与目录树设计
简介这是基于SpringBoot与Mybatis构建的网盘存储系统毕业设计项目适合软件工程、计算机科学等专业学生用于毕设、课设或项目练习。项目代码完整经过测试运行成功可直接部署使用同时配有详细文档与配套资料便于理解系统设计思路和二次开发。压缩包内共241个文件涵盖191个Java源文件、15个HTML页面以及SQL脚本、YAML配置、Gradle构建文件等覆盖后端业务逻辑、前端展示、数据库初始化与项目配置等环节整体大小约894KB。已有117人学习下载适合需要完整可运行毕业设计案例、且希望基于SpringBoot生态快速上手网盘类系统的读者。资源目录结构清晰从代码到文档一目了然可帮助使用者节省搭建和撰写时间也能作为课程设计或项目初期演示的参考基础。1. 从毕业设计到可演示的网盘系统SpringBootMybatis 的完整闭环上传、下载、删除三个接口就能让一个网盘“看起来能跑”但真正决定这个项目能不能过评审、能不能写进简历的是上传时数据库与文件落盘的一致性、目录树的递归查询、秒传与分片这类边界设计。用 SpringBootMybatis 做网盘存储系统胜在技术栈不虚高却在每个环节都有得可问、有得可答Mybatis 的动态 SQL、一对多映射、参数传递SpringBoot 的配置文件与拦截器这些恰好是 Java 面试题里最常被追问的部分。这个项目的典型做法是前端只管文件流和目录操作后端用 SpringBoot 暴露 REST 接口Mybatis 负责把文件元数据、用户数据、分享链接落到 MySQL文件本身不存数据库而是按规则写到服务器磁盘目录。一个用户上传文件等于“磁盘落一份 数据库插一条记录”删除时也要同步处理否则就会出现数据库里有记录但文件不存在或者文件占用磁盘却查不到记录的情况。这套方案适合正在做 Java 毕设、想从“只会 CRUD”往前走出一步的开发者把它当成一个中型 Web 系统的完整样本。2. 网盘存储系统的数据模型与 Mybatis 持久层设计2.1 网盘存储最少需要哪几张表从文件记录到分享链接网盘系统的数据模型第一原则是“文件只存一份”。同一个文件被多个用户收藏、转存不复制实体文件只新增记录。因此表设计通常拆成“文件实体”和“用户文件引用”两层。表名核心字段作用注意事项userid, username, password, salt用户登录与基础信息密码不能明文存 md5/salt 或 bcryptfile_infoid, file_md5, file_size, storage_path, create_time文件物理落盘后的实体记录storage_path 存服务端相对路径不存完整 URLuser_fileid, user_id, parent_id, file_name, file_type, is_dir, file_id, delete_flag用户视角的目录树和文件名同一目录下文件名可做唯一约束share_linkid, user_file_id, code, expire_time, visit_count分享链接的生成与访问code 用短随机串避免自增 id 暴露范围chunk_infoid, user_id, file_md5, chunk_index, chunk_path大文件分片上传的暂存记录分片合并后整体删除避免垃圾数据文件实体表file_info与用户文件表user_file分离是这套设计中比较关键的一步。删除某个用户的某个文件只删user_file记录只有当file_info没有任何引用时才真正删除磁盘文件。这种设计使得秒传、转存功能都只需要操作数据库记录不需要复制 IO 流。2.2 用 application.yml 把 SpringBoot 连接池与 Mybatis 配置对齐数据模型定好后第一步是让 SpringBoot 和 Mybatis 正常协作。连接池我一般用 HikariCPSpringBoot 默认Mybatis 只负责 SQL 映射和结果映射事务交给 Spring 的Transactional。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/net_disk?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.netdisk.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl default-fetch-size: 100这段配置里mapper-locations指定 XML 文件的扫描路径type-aliases-package让实体类可以直接用类名作为别名map-underscore-to-camel-case解决file_md5转fileMd5的自动映射这三个组合是 SpringBootMybatis 项目的标准起步配置。需要调试 SQL 时保留log-impl正式上线时改成Slf4jImpl避免 SQL 日志打到控制台造成性能损耗。MySQL 连接的serverTimezone必须与大版本匹配否则时间字段报错是比较常见的坑。2.3 Mybatis 动态 SQL上传幂等检查与目录列表查询上传文件的第一个动作不是写磁盘而是查重。前端把文件 MD5 传过来后端在user_file和file_info里查询是否存在相同文件。这里需要 Mybatis 的动态 SQL 支撑多条件查询。public interface UserFileMapper { UserFile selectByUserAndName(Param(userId) Long userId, Param(parentId) Long parentId, Param(fileName) String fileName); ListUserFile selectDirList(Param(userId) Long userId, Param(parentId) Long parentId, Param(type) Integer type); }select idselectByUserAndName resultTypecom.example.netdisk.entity.UserFile SELECT * FROM user_file WHERE user_id #{userId} AND delete_flag 0 if testparentId ! null AND parent_id #{parentId} /if if testparentId null AND parent_id IS NULL /if if testfileName ! null and fileName ! AND file_name #{fileName} /if LIMIT 1 /select这里的要点是parentId为 null 时不能写parent_id null要用IS NULL判断否则 MySQL 三值逻辑会查出空结果。delete_flag字段统一在查询条件里过滤是做“回收站”和“软删除”的基础如果直接 DELETE 记录回收站功能就得重做一套表结构。目录列表查询类似按parent_id查子文件和子目录type参数为 0 查文件、1 查目录null 则全部返回。2.4 结果映射的两个坑驼峰失效与懒加载失效map-underscore-to-camel-case开启后Mybatis 会自动把file_size映射到fileSize。但如果你在 XML 里用了resultMap并手动指定了column与property此时全局配置的自动驼峰映射不会生效必须手动把columnfile_size propertyfileSize写全这是 Mybatis 新手容易踩的隐性坑。另一个坑是“懒加载不生效”。在user_file关联file_info的场景中如果配置了association并且开了懒加载但查询时没有开启aggressiveLazyLoadingfalseMybatis 的默认行为会把所有关联对象一次性查出来懒加载形同虚设。网盘列表只涉及单表或简单的两表关联建议直接使用嵌套查询或显式 JOIN不要依赖懒加载优化性能避免调试时出现LazyInitializationException。3. 文件上传下载的核心实现磁盘落盘与 SpringBoot 接口3.1 为什么先选本地磁盘存储而不是集成 OSS 或 MinIO毕业设计阶段的网盘系统不建议一上来就引入对象存储中间件。OSS 需要云账号和费用MinIO 需要额外部署服务端会增加答辩时环境搭建的复杂度。最常见做法是文件存到应用服务器的指定目录数据库只记录相对路径和 MD5后续需要切换 OSS 时只需要替换存储实现层不影响上层业务接口。存储目录建议定义成可配置项核心是net-disk.storage-path./data。上传时按“年/月/日”建子目录文件名替换为 UUID 加上原始扩展名例如2026/03/16/3f2a7e8c-2d41-4d5b-9f1e-6c8a2b0d9e4a.pdf。这种组织方式有两个直接好处一是避免中文文件名和特殊字符引起的编码问题二是避免两个同名文件互相覆盖。用户看到的文件名保存在user_file.file_name里磁盘上的名字只作为唯一标识。3.2 上传接口的完整代码校验、落盘、落库上传接口是网盘系统的核心入口代码要同时处理三件事参数校验、文件写入、数据库记录创建。PostMapping(/upload) public ResultLong upload(RequestParam(file) MultipartFile file, RequestParam(parentId) Long parentId, RequestParam(fileMd5) String fileMd5, RequestParam(value fileName, required false) String fileName) throws IOException { // 1. 参数校验文件不能为空大小不能超过设定值 if (file null || file.isEmpty()) { return Result.error(上传文件不能为空); } long maxSize 100 * 1024 * 1024L; // 100MB if (file.getSize() maxSize) { return Result.error(文件大小超出限制); } // 2. 计算或校验 MD5用于秒传和去重 String md5 fileMd5; if (md5 null || md5.length() ! 32) { md5 DigestUtils.md5DigestAsHex(file.getInputStream()); } // 3. 查询是否已有相同文件记录 FileInfo fileInfo fileInfoMapper.selectByMd5(md5); Long fileId; if (fileInfo null) { fileId saveFileToDisk(file, md5); } else { fileId fileInfo.getId(); } // 4. 创建用户文件记录 UserFile userFile new UserFile(); userFile.setUserId(currentUserId()); userFile.setParentId(parentId); userFile.setFileName(fileName ! null ? fileName : file.getOriginalFilename()); userFile.setFileType(file.getContentType()); userFile.setFileId(fileId); userFile.setIsDir(0); userFile.setDeleteFlag(0); userFileMapper.insert(userFile); return Result.success(userFile.getId()); } private Long saveFileToDisk(MultipartFile file, String md5) throws IOException { String dateDir LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); String originalName file.getOriginalFilename(); String ext ; if (originalName ! null originalName.contains(.)) { ext originalName.substring(originalName.lastIndexOf(.)); } String uuidName UUID.randomUUID().toString().replace(-, ) ext; String relativePath dateDir / uuidName; File dest new File(storagePath File.separator relativePath); dest.getParentFile().mkdirs(); file.transferTo(dest); FileInfo fileInfo new FileInfo(); fileInfo.setFileMd5(md5); fileInfo.setFileSize(file.getSize()); fileInfo.setStoragePath(relativePath); fileInfoMapper.insert(fileInfo); return fileInfo.getId(); }逻辑上最重要的部分是“第三步的查重”和“第一步的校验”顺序先查重再做落盘可以避免大文件重复写入磁盘MD5 在秒传场景中由前端预先计算但后端必须做二次校验否则某个客户端传错了 MD5会导致后续所有秒传的文件内容都是错的。参数说明上parentId在根目录下为 nullfileMd5是可选参数但建议前端必须传fileName可选不传时用originalFilename保证文件名兼容。file.transferTo(dest)在 SpringBoot 中实际上是标准的多部分传输写法但如果目标目录在多台机器上共享需要改为先存临时文件再移动的流程。单机部署场景下transferTo的可移植性最好这也是毕业设计时比较推荐的方式。3.3 下载接口与断点续传ResponseEntity 的正确写法下载文件不能简单地返回一个字节数组否则大文件会撑爆内存。用ResponseEntityResource流式返回SpringBoot 会通过ResourceRegion或ServletOutputStream处理大文件输出。GetMapping(/download/{userFileId}) public ResponseEntityResource download(PathVariable Long userFileId) throws IOException { UserFile userFile userFileMapper.selectById(userFileId); if (userFile null || userFile.getIsDir() 1) { return ResponseEntity.notFound().build(); } FileInfo fileInfo fileInfoMapper.selectById(userFile.getFileId()); File file new File(storagePath File.separator fileInfo.getStoragePath()); if (!file.exists()) { return ResponseEntity.notFound().build(); } Resource resource new FileSystemResource(file); String encodedName URLEncoder.encode(userFile.getFileName(), StandardCharsets.UTF_8) .replace(, %20); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filename\ encodedName \) .header(HttpHeaders.CONTENT_TYPE, application/octet-stream) .contentLength(file.length()) .body(resource); }这段代码里URLEncoder.encode处理了中文文件名.replace(, %20)是因为浏览器会把空格解码成加号这是下载文件名乱码的常见修复。实现断点续传可以在 Controller 上检查Range头public ResponseEntityResource downloadWithRange(RequestHeader(value Range, required false) String range, PathVariable Long userFileId) throws IOException { // 解析 range bytesstart-end // 使用 FileSystemResource 并配合 HttpHeaders.ACCEPT_RANGES }实际项目里如果只做 Web 端预览浏览器默认会发送Range请求处理后响应 206 状态码即为支持断点续传的完整实现。这个细节在答辩时可以重点讲体现对 HTTP 协议的理解。3.4 秒传与大文件分片MD5 与 chunk 的取舍秒传的实现可以独立于上传接口。前端先计算整个文件的 MD5请求一个/upload/check/md5接口后端在file_info表中查到相同 MD5 就返回“文件已存在”并直接创建user_file记录不再走数据流。GetMapping(/check/md5) public ResultBoolean checkMd5(RequestParam(fileMd5) String md5, RequestParam(parentId) Long parentId, RequestParam(fileName) String fileName) { FileInfo fileInfo fileInfoMapper.selectByMd5(md5); if (fileInfo null) { return Result.success(false); } // 秒传创建用户文件引用不做物理写入 UserFile userFile new UserFile(); userFile.setUserId(currentUserId()); userFile.setParentId(parentId); userFile.setFileName(fileName); userFile.setFileId(fileInfo.getId()); userFile.setIsDir(0); userFile.setDeleteFlag(0); userFileMapper.insert(userFile); return Result.success(true); }分片上传则需要把大文件切分为固定大小例如 5MB的分片每片计算独立 MD5后端顺序或并发接收全部上传完成后触发合并。分片带来的复杂度包括分片与主文件的匹配、合并时的顺序保证、合并失败后的清理。毕业设计阶段如果项目定位是“功能完整”建议实现秒传而不是分片因为分片涉及的状态机逻辑会显著增加代码量和调试难度如果文件上限就是 100MB整文件上传已经足够分片只作为加分项写在文档里。4. 目录树、分享链接与权限校验把网盘做完整4.1 邻接表与路径快照网盘目录的两种建模方式一个网盘系统没有目录管理充其量是个文件堆积站。目录树的建模有两种常见做法。方案表结构优点缺点适用场景邻接表user_file表加parent_id指向自己的父记录插入、删除、重命名自由不会影响其他节点查询整棵子树需要递归或多次查询项目初期、毕设推荐路径快照user_file表加parent_path字段存/a/b/c/类似路径一次查询即可拿到所有后代节点移动目录时需要批量更新所有后代的parent_path目录移动不频繁、查询频繁的成熟系统毕设场景我建议用邻接表因为目录重命名和移动会更灵活配合 Mybatis 的递归查询能够优雅地展示技术能力。若采用路径快照移动一个包含 1000 个文件的目录需要拼接动态 UPDATE 语句批量改路径实现难度和工作量都会上升。4.2 用 Mybatis 以递归方式查询目录树展示“我的网盘”页面时需要一次性返回用户某个目录下的所有子目录和文件。邻接表模式下有两种实现方式一种是在 Java 里手动递归适合层级不深、数据量可控的场景另一种是数据库递归 CTEMySQL 8.0 及以上版本支持但不建议在 Mybatis 的 XML 里写过多的数据库方言以免换数据库时重构。public ListUserFile buildTree(ListUserFile allFiles, Long parentId) { ListUserFile children new ArrayList(); for (UserFile file : allFiles) { if (Objects.equals(file.getParentId(), parentId)) { ListUserFile grandchildren buildTree(allFiles, file.getId()); file.setChildren(grandchildren); children.add(file); } } return children; }调用方式是先查出该用户全部未删除记录再以根节点parentIdnull为入口构建树。这个方案的取舍在于一次查询全部数据到内存层级深、数据量大时会有性能压力但网盘系统单个用户的文件数量通常不超过几千且需要展示时往往也是全量展示所以在毕设这个规模下完全够用。配套地在UserFile实体中增加ListUserFile children字段并用TableField(exist false)之类的注解或 XML 的resultMap避免 Mybatis 把它当作表字段处理。4.3 分享链接的生成与过期校验分享功能的核心是把user_file的一条记录暴露给无登录用户通过一个短码访问。短码可以用 UUID 的前 8 位也可以定义一套自己的编码规则。关键点在于访问分享时只能通过session或request里的短期 token 来获取文件不能把用户 ID 或文件 ID 直接放进 URL否则越权访问会成为一个严重漏洞。public static String generateShareCode() { String uuid UUID.randomUUID().toString().replace(-, ).substring(0, 8); // 避免冲突插入时检查 share_link.code 是否已存在 return uuid; }校验分享码时需要同时验证过期时间和访问次数。过期时间存放在expire_time访问时对比当前时间大于过期时间则返回失效visit_count每次访问自增如果后端在share_link表上做了LIMIT次数的控制第 N1 次访问要返回错误。自增操作使用UPDATE share_link SET visit_count visit_count 1 WHERE code #{code}不要先查再更否则并发下计数会丢。4.4 登录校验与接口权限不引入 Spring Security 的最简拦截器毕业设计网盘系统可以用 Session HandlerInterceptor 做权限控制不额外引入 Spring Security。因为在这类项目中安全框架的学习成本大于收益拦截器的好处是直观、好解释、适合答辩时现场展示。Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { String requestURI request.getRequestURI(); if (requestURI.contains(/login) || requestURI.contains(/register) || requestURI.startsWith(/share/)) { return true; } Object userInfo request.getSession().getAttribute(userInfo); if (userInfo null) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\未登录\}); return false; } return true; } }注册拦截器时需要通过WebMvcConfigurer注册并设置排除路径。注意/share/开头的分享访问路径必须放行否则分享链接无法匿名访问放行后还要在分享接口内部手动校验不能因为放行就省略校验逻辑。这个设计把“匿名可访问”和“登录才能用”两个边界分得很清楚在答辩时也是一个可以展开的设计点。5. 答辩和上线前必调的 3 处配置日志、上传限制、建表策略5.1 开启 Mybatis SQL 日志快速定位慢查询开发阶段建议把 Mybatis 的日志输出到控制台最简单的配置是spring.profiles.activedev时启用标准输出spring: profiles: active: dev mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl生产环境切换配置时把log-impl删除改为在logback-spring.xml中配置 mapper 包的日志级别为DEBUG这样既能看到 SQL也能控制日志量。如果列表页响应慢优先看 Mybatis 输出的 SQL检查是否缺少索引、是否产生了 N1 查询。文件列表接口如果每行数据都触发一次file_info查询那就是典型的 N1需要用 JOIN 或IN批量查询来优化。5.2 上传大小、请求超时与临时目录三处配置缺一不可SpringBoot 默认上传文件大小限制是 1MB超过就会报 500 异常。要支持 100MB 文件需要同时设置 servlet 和 tomcat 的配置spring: servlet: multipart: max-file-size: 100MB max-request-size: 110MB location: ./tmp server: tomcat: max-swallow-size: 110MB connection-timeout: 60000max-file-size是单个文件上限max-request-size是整个请求的上限多文件上传时必须大于所有文件之和。location指定 multipart 解析时使用的临时目录不指定时使用系统默认临时目录文件较大时磁盘空间不足也会报错。max-swallow-size需要设置否则 Tomcat 在解析异常时可能吞掉错误信息排查问题时会浪费很多时间。5.3 表不存在自动建表还是用 SQL 脚本初始化“springboot mybatis 当表不存在自动建表”是常见诉求推荐做法是使用 SpringBoot 的spring.sql.init配合schema.sql自动初始化而不是让 Mybatis 在运行时建表。在application.yml中配置spring: sql: init: mode: always schema-locations: classpath:db/schema.sql style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />