之前一直有人问我,网上的个人网盘管理系统源码那么多,到底该怎么选、怎么改、怎么跑起来。正好我这段时间把一个基于 Spring Boot 的个人网盘管理系统从数据库设计到文件上传下载完整重构了一遍,把源码、数据库脚本和项目文档都整理好了,这里直接把核心思路和实操过程写出来。这个系统解决的是最典型的需求:把自己散落在各处的文件集中管理,支持目录分类、分片上传、秒传、分享链接、回收站,后台还能看存储占用。整套东西用的是 Spring Boot + MyBatis-Plus + MySQL + Redis 的组合,前后端分离,接口文档也一并配好了。适合刚学完 Spring Boot 想做点拿得出手的项目的人,也适合公司内部做轻量级文件共享的场景,直接拿源码改一改就能用。
1. 项目整体设计与模块拆分
1.1 功能需求盘点与边界划分
个人网盘管理和百度网盘那种动辄几十人的团队协作产品不一样,它的核心是"把文件管清楚"、"把文件传得动"。所以我在梳理需求时,没有盲目去堆功能,而是先列了一个最小可用功能清单:用户注册登录、目录树管理、文件上传下载、文件重命名和移动、文件夹打包下载、分享链接生成与取消、回收站恢复与清空、最近上传列表、存储空间统计。看起来功能不少,但每条都能对应用户的真实动作,没有一个是摆设。
边界划分上,我把系统分成了两大块:用户空间和文件操作。用户空间解决"谁在用",文件操作解决"文件怎么流转"。两者之间通过一个简单的业务逻辑层串起来,避免了写代码时把校验逻辑和文件IO混在一起。这个划分在后续维护里帮了大忙,比如后来要加分享码功能,我只动了分享模块和多了一张表,核心文件上传逻辑一行没改。
另外要注意的是,个人网盘和对象存储产品有个本质区别:它通常不追求极致的高并发,更多时候是单个用户在操作自己的文件。所以设计上我刻意做了取舍,没有引入复杂的消息队列,也没有做分布式锁,保持一个 Spring Boot 单体应用就能扛住日常使用,这对新手来说也友好得多。
1.2 技术选型:为什么是 Spring Boot 全家桶
技术选型这块,我用的组合是 Spring Boot 2.7.x + MyBatis-Plus 3.5.x + MySQL 8.0 + Redis + MinIO。有人会问,文件存储为什么不直接用本地磁盘,非要引入 MinIO?我的想法是,本地磁盘在单机部署时确实够用,但一旦你想迁移到云服务器、或者后面打算做多副本容灾,MinIO 这类 S3 兼容存储能让你少改很多代码。而且 MinIO 的 SDK 封装得挺好,上传下载也就多几行配置,收益远大于成本。
Spring Boot 版本这里要特别提醒一句,不要一上来就选 3.x 最新版。3.x 基于 Jakarta EE,很多老教程和老依赖都默认是 javax 命名空间,你照着教程写很可能因为一个 import 报错卡半天。我用的 2.7.x 属于 2.x 系列的稳定尾版,资料多、坑少、兼容性最好,等项目跑熟了再考虑升级也不迟。
MyBatis-Plus 的优势不用多讲,代码生成器把实体、Mapper、Service 一次性生成,单表 CRUD 几乎不用手写 SQL。唯一要注意的是,网盘这种业务里有很多"查父节点下的子文件列表"这种递归查询,MyBatis-Plus 的 QueryWrapper 能做单层查询,但涉及多级目录统计时,我还是选择手写 SQL,性能和对代码的可控性都好很多。Redis 在这里承担了三个职责:存登录 Token、做上传时的临时状态记录、缓存分享链接的访问信息。至于数据库,MySQL 8.0 的 JSON 字段我用在了文件扩展信息上,存文件的分辨率、时长、缩略图地址这类结构不固定的数据,比硬拆成几十个字段要灵活。
1.3 目录结构与源码组织方式
源码组织方式上,我采用的是常见的分层架构:Controller 管接口、Service 管业务、Mapper 管数据库、Entity 映射表结构。除此之外,我单独加了一个storage包,专门对接文件存储,这样以后换存储方案,只需要动这个包和配置类。
com.example.disk ├── controller // 接口层,只做参数校验和结果封装 ├── service // 业务逻辑层,事务边界在这里 │ └── impl ├── mapper // MyBatis-Plus Mapper 接口 ├── entity // 数据库实体 ├── dto // 前端交互的传输对象 ├── vo // 视图对象,比如文件列表的树形结构 ├── storage // 文件存储抽象层,本地磁盘和 MinIO 都在这实现 ├── config // 配置类,跨域、拦截器、Redis 序列化 ├── common // 统一返回结果、异常处理、常量定义 └── utils // 工具类,比如文件类型识别、大小格式化这样的组织方式有几个明显的好处。首先是 Controller 层很薄,核心逻辑都在 Service 里,接口层面的改动不影响业务;其次是storage抽象层把文件 IO 隔离出去了,后面我测试过切换本地存储和 MinIO 只改了一个配置项;最后是common包里的统一返回结构和异常处理,前端对接时只需约定一种格式,省掉大量联调扯皮。
1.4 前后端接口约定与统一返回格式
前后端分离的项目,接口约定混乱是最大的隐形炸弹。我踩过一次坑:不同接口返回成功时有的带 data,有的不带,前端代码里全是if(res.data.data)这种三层判断。这次重构时,我强制性统一了返回结构。
{ "code": 200, "message": "success", "data": {} }code统一用 HTTP 语义,200 是成功,400 是参数错误,401 是未登录,403 是没权限,500 是服务端异常。data类型不限,可以是对象,也可以是数组。为了方便前端处理,我还把分页信息也塞进了data,统一是records、total、pageSize、pageNum四个字段。这套约定一旦定下来,前端只需要封装一个request.js,所有接口的调用姿势就完全一致了。
文件上传接口我单独做了说明,因为它不走 JSON 格式,而是multipart/form-data。上传时的参数有两个:一个是分片序号,一个是分片总大小。前端在分片上传时每次请求都会带上这些元数据,后端据此决定是写临时目录还是触发合并逻辑。这种"元数据和二进制分离"的思路,在网盘项目里非常关键,后面我会专门讲分片上传的细节。
2. 数据库设计与表结构详解
2.1 用户表与文件表的核心字段设计
数据库是网盘系统的地基,表结构设计得好不好,直接决定后面写代码的时候是行云流水还是到处打补丁。我最终落地的表一共有 8 张:用户表、文件信息表、目录信息表、用户文件关系表、分享表、回收站表、上传分片记录表、操作日志表。这里重点讲用户表和文件信息表。
用户表设计得比较简单,核心字段是用户名、密码、昵称、头像、存储空间上限、已用空间。密码我用的是 BCrypt 加密,注册时调用BCryptPasswordEncoder.encode(),登录时用matches()校验。这里有个容易被忽略的问题:不要在数据库里明文存密码,就算你自己部署用也不行,因为日志一旦打印了 SQL,密码就全暴露了。
文件信息表是整个系统的核心,字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| file_name | varchar(255) | 原始文件名 |
| file_path | varchar(500) | 存储路径(相对路径) |
| file_size | bigint | 文件大小(字节) |
| file_type | varchar(20) | 文件类型(image/video/doc等) |
| ext_name | varchar(10) | 扩展名 |
| storage_key | varchar(255) | 存储服务里的唯一标识 |
| uploader_id | bigint | 上传者ID |
| md5 | varchar(32) | 文件MD5,用于秒传 |
| status | tinyint | 1正常 0回收站 |
| create_time | datetime | 上传时间 |
storage_key这列是我后来重构时才加上的。之前直接用文件名做存储标识,结果遇到两个同名文件直接覆盖了。改成唯一标识后,文件名可以随便改,存储层不用动。md5字段就是为秒传设计的,前端选择文件时先计算 MD5,请求后端检查是否存在,存在就直接复用原文件记录,不存在才走上传流程。
2.2 目录信息表与用户文件关系表
目录信息表我采用的是经典的父子结构,字段是 id、parent_id、dir_name、user_id、is_deleted。查询某个用户完整目录树时,一条 SQL 查出来然后在内存里组树,比反复递归查数据库高效得多,因为个人网盘的目录数量通常不会超过几千条,内存组树足够了。
用户文件关系表的设计值得多说两句。一开始我天真地把文件直接挂在用户表下面,后来发现同一个文件被分享给别人后,接收方应该有自己的文件记录,如果共用一条记录,就会出现"一个人删了别人也删了"的尴尬。解决方案是用一张关系表来解耦:file_id 指向文件信息表,user_id 指向用户,再带上用户自定义的文件名和目录位置。这样同一个物理文件可以被多个用户引用,删除时只是删关系,不影响物理文件,直到引用计数归零才真正删除存储上的数据。
2.3 分享表与回收站表的业务设计
分享表的字段包括 id、share_token、file_id、share_user_id、share_code、expire_time、visitor_count、status。share_token是分享链接的唯一标识,我用 UUID 生成,形如http://112.74.56.10:8080/s/{token}。share_code是提取码,可以留空表示免提取码。visitor_count记录访问次数,后端在每次分享校验通过后给它加一。
回收站表设计上,我没有做成独立的物理表,而是复用文件信息表和目录信息表的status字段加一个delete_time。删除文件时把 status 置为 0,同时记录删除时间,超过 30 天由定时任务真正清理。这样设计的好处是恢复操作非常快,直接改 status 就行,不用搬运数据。坏处是文件信息表会积累一些历史脏数据,所以我在定时清理时,需要连同用户文件关系表一起处理。
2.4 分片记录表与 Redis 缓存的关系
上传分片记录表一开始没有做,是我在实现分片上传时现加的。字段是 upload_id、user_id、file_md5、chunk_index、chunk_size、upload_status。但实际上,我并没有把分片进度存进 MySQL,而是放在了 Redis 里,用upload_id作为 key。MySQL 里这张表唯一的作用是断电恢复时做持久化兜底,正常流程根本不会查它。
有人会问,为什么不直接全放 Redis?因为 Redis 有持久化策略,如果设置的是 RDB 快照模式,极端情况下会丢最近几秒的数据。分片合并一旦少了某一片,整个文件就废了。所以我的策略是:Redis 存热数据,负责高频的状态查询和更新;MySQL 只做异步落盘,每完成一个分片就更新一次 upload_status。这套"双写"机制看起来冗余,实际运行下来非常稳。
3. 文件上传下载与秒传的核心实现
3.1 分片上传与断点续传的完整流程
分片上传是网盘系统里最值得认真写的功能,没有之一。它的核心思想是:前端把一个几百 MB 的文件切成若干片,一片一片传给后端,后端把所有片收齐后合并成完整文件。这样做好处有三个:一是避免了单次请求过大导致网关超时或内存溢出;二是网络中断时只需要重传失败的片,不用整文件重来;三是可以显示上传进度,提升用户体验。
具体流程我分成了三步。第一步,前端计算文件 MD5,调用后端的"预上传"接口,传文件 MD5 和文件名。后端检查 MD5 是否已存在,如果存在直接返回一个标记,前端收到这个标记就知道是秒传,直接跳过上传;如果是新文件,后端生成一个upload_id返回给前端,并初始化 Redis 状态。第二步,前端把文件按固定大小分片,比如每片 5MB,逐片调用上传接口,请求体里带上upload_id、chunk_index、total_chunks。后端每收到一片,先写临时文件,再更新 Redis 里的分片位图。第三步,所有分片传完后,前端调用"合并"接口,后端检查分片完整性,合并文件,计算最终 MD5,写入文件信息表。
分片合并的代码核心是文件流合并工具类。这里要注意两个细节:一是合并时必须以正确的顺序写入,我通常在分片文件名的末尾加上序号,比如xxx.part0、xxx.part1,然后按序号排序逐个读入;二是合并结束后一定要记得删临时分片文件,否则临时目录会被撑爆,这个问题我在测试环境真实遇到过。
3.2 秒传功能的实现原理与边界情况
秒传的原理一句话就能说清楚:相同内容的文件只存一份,靠文件 MD5 判断内容是否相同。用户选择文件后,前端调用\/file\/presign接口,传的就是 MD5。后端拿到 MD5 去文件信息表找,找到了就直接建立用户文件关系,把materialized标志设为 true,整个过程完全不传文件内容。
不过秒传有几个边界情况必须处理。最典型的是:MD5 一致但文件大小不同。理论上 MD5 碰撞的概率极低,但大小不相同一定是不同文件,所以判断条件必须是 MD5 和 size 同时匹配,少一个都不行。另外一点是,如果文件已经在回收站里,秒传命中的应该是正常区而不是回收区,这需要 SQL 里加上 status = 1 的条件。我踩过这个坑,第一次做时没加条件,导致用户删除文件后重新上传,秒传直接给了个回收站里的引用,结果文件就神秘消失了。
3.3 上传接口的代码实现与参数校验
上传接口的 Controller 代码逻辑并不复杂,但参数校验和异常处理不能省。在 Spring Boot 中,上传的最大文件大小默认是 1MB,需要在 application.yml 里显式配置:
spring: servlet: multipart: max-file-size: 200MB max-request-size: 5GB我把max-request-size设置得很大,因为分片请求理论上是不受限的,5GB 的判断主要是防止异常请求打爆内存。真正限制上传大小的逻辑在业务层,根据用户的存储空间剩余量来动态判断,不允许超额上传。Service 层处理分片上传时,我用了一段简化的逻辑来展示核心思路:
public void uploadChunk(UploadChunkDTO dto, MultipartFile file) throws IOException { // 从 Redis 中获取上传状态 UploadStatus status = uploadStatusMapper.getFromRedis(dto.getUploadId()); if (status == null) { throw new BizException("上传会话已过期,请重新预上传"); } // 校验分片序号合法性:不能跳过已有分片,也不能重复提交 int received = status.getReceivedChunks(); if (dto.getChunkIndex() != received) { throw new BizException("分片序号错误,请从第 " + received + " 片续传"); } // 分片写入临时目录 File chunkFile = new File(tmpDir + dto.getUploadId() + ".part" + dto.getChunkIndex()); file.transferTo(chunkFile); // 更新 Redis 状态 status.setReceivedChunks(received + 1); if (received + 1 == dto.getTotalChunks()) { status.setCompleted(true); } uploadStatusMapper.saveToRedis(status); }这里做过一次严格的分片序号校验后,前端并发上传多个分片时会存在问题:因为校验要求分片严格按顺序提交,并发场景下会互相阻塞。所以我在实际实现里放宽了策略:允许乱序提交,但每次提交时更新位图,位图存的是每片对应的文件大小,合并时按位图顺序读文件。这样既支持并发,又能保证完整性。
3.4 下载、预览与文件夹打包的实现
下载实现相对简单,核心是ResponseEntity<byte[]>处理,注意设置Content-Disposition响应头,用attachment;filename*=UTF-8''xxx的写法来兼容中文文件名。
大文件下载时不能一次性把整个文件读进内存,会 OOM。必须用流式下载,我写了一个downloadFile方法,返回ResponseEntity<InputStreamResource>,底层流由线程池的异步任务持续输出,前端拿到的是一个流式响应。
文件夹打包下载,我把临时压缩包放在一个约定好的目录,打包完成后生成一个下载地址,这个地址有效期为 5 分钟。打包工具我用的 Java 原生的ZipOutputStream,不用第三方库,逻辑稳定。要注意的是,如果文件夹里嵌套很多层,递归时需要记录目录层级,否则解压出来的全是平铺文件。文件预览则根据fileType判断:图片和视频直接走 Nginx 静态文件访问,PDF 和文本文件用前端 PDF 阅读器和代码高亮组件,Office 文件我提供的方案是转 PDF 预览,这一步在文档里写清楚即可,不一定非要在服务端实现。
4. 安全鉴权与系统配置
4.1 Token 鉴权方案:为什么我选 JWT 加 Redis 双层方案
个人网盘的鉴权,我采用的是 JWT 生成 Token,但 Token 状态存 Redis 的方案。具体流程是:用户登录成功后,后端生成一个 JWT,把userId和expireAt写进去,然后把这个 Token 存一份到 Redis,key 是token:{userId},value 是 JWT 串,过期时间和 JWT 保持一致。用户每次请求,拦截器解析 JWT 验证签名,再查一下 Redis 确认这个 Token 还没被吊销。
为什么这么设计?纯 JWT 的问题在于"无法主动让 Token 失效"——用户退出登录,服务端没有任何状态可改,只要 Token 没过期就永远有效。纯 Redis Token 的问题则是每次请求都要查一次缓存,接口响应会有额外耗时。JWT 加 Redis 的组合,我用 Redis 的 key 来判断是否被拉黑,逻辑上只有两个分支:key 存在且值和 JWT 一致,放行;key 不存在或值不一致,拒绝,重新走登录。这样既保留了 JWT 无状态验证的轻量,又具备了服务端控制会话的能力。
4.2 拦截器里的权限校验细节
拦截器配置有两个细节值得提醒。第一是白名单必须清晰,/user/login、/user/register、/s/、/static/**这些不需要登录的路径要放行,否则前端初始渲染都会直接 401。第二是 CORS 配置和拦截器容易冲突,如果拦截器先拦截了预检请求(OPTIONS 类型),前端跨域就直接废了。我的处理是在拦截器里对所有 OPTIONS 请求直接放行,真正的 CORS 配置交给 Spring 的 CorsFilter 处理。
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new AuthException("未登录"); } Long userId = authService.verifyAndGetUserId(token); request.setAttribute("userId", userId); return true; }request.setAttribute("userId", userId)这个操作非常实用,后续所有 Service 方法里直接UserContext.get()拿当前用户 ID,不用每个接口都传 userId 参数,代码清爽很多。我建了一个UserContext的 ThreadLocal 工具类来封装这个逻辑,请求结束时在afterCompletion中清理,避免线程池复用导致的用户串号。
4.3 全局异常处理与统一错误返回
全局异常处理是 Spring Boot 项目里最容易偷懒的地方。很多项目只在 Controller 里手动 try-catch,返回结构乱七八糟。我用@RestControllerAdvice做了统一处理,把异常分成两类来处理:业务异常和系统异常。
业务异常比如"文件名重复"、"存储空间不足",这些情况前端需要展示友好提示,我定义了BizException异常类,抛出时带 code 和 message,全局处理器捕获后返回 400 加业务码。系统异常比如 SQL 执行错误、空指针,统一返回"服务器开小差了"这种提示,然后在 log 里打印完整的堆栈信息。这里要强调,不要把数据库原始错误信息返回给前端,第一是信息安全问题,暴露表结构;第二是用户体验问题,用户根本看不懂那些英文异常。我在日志里打完整异常,接口返回的只是统一兜底文案,这是大厂的基本规范。
4.4 配置文件分离与环境隔离
配置文件这块,我一直强调环境隔离,否则生产环境排查问题会非常痛苦。我用 Spring Boot 的多 Profile 机制,拆成了三个文件:application-dev.yml连本地数据库,application-test.yml连测试服务器,application-prod.yml连正式数据库。启动时通过参数指定:
java -jar disk-server.jar --spring.profiles.active=prod每个环境文件的差异点主要是数据库连接串、Redis 地址、文件存储路径、日志级别。特别注意生产环境的日志级别,我设置的是com.example.disk: warn,线上业务日志和普通日志分开,排查问题时不会被海量的 debug 日志淹没。数据库密码务必用环境变量注入,不要硬编码在配置文件中,即使仓库是私有仓库,也该养成这个习惯。
5. 实操踩坑与问题排查实录
5.1 前端上传大文件频繁断连的问题排查
第一个要讲的问题是前端上传大文件时频繁断连。症状是文件 50MB 以上时,浏览器请求经常卡死,然后连接被重置。排查过程我记录了完整的链路。前端用的 axios,默认超时时间是 60 秒,但上传大文件时,单个请求很难在 60 秒内完成,于是被 axios 主动断掉。这个问题排查顺序是:前端超时配置 -> 后端接口响应时间 -> Nginx 的 proxy timeout。
最终发现 Nginx 默认的proxy_read_timeout是 60 秒,加上前端也是一样,双重超时叠加。解决方法是,在 Nginx 的 server 块里针对上传接口做单独配置:
location /file/ { proxy_read_timeout 600s; proxy_send_timeout 600s; proxy_request_buffering off; }proxy_request_buffering off是为了让 Nginx 不在内存里缓冲整个请求体,而是边收边传,这对大文件上传非常重要。前端 axios 的超时时间也要调到 15 分钟以上,或者直接针对上传请求单独设 timeout,不要用全局默认值。
5.2 合并分片时文件损坏的问题定位
第二个问题很隐蔽:分片全部上传成功,合并后的文件却是损坏的。排查时我一开始怀疑是并发写入分片导致的文件错位,但检查后发现我的写入逻辑是按序号写入独立的partN文件,并不会互相覆盖。后来我把分片文件按顺序拼接后对比原始文件的字节数,发现字节数对不上。最终定位到问题出在分片序号的传递上。
前端在分片时,chunkIndex是从 0 开始的,而后端从 Redis 里读取的receivedChunks初始值是 1,两边的起始状态不一致,导致第一片被当成重复分片丢弃,整个文件缺失了开头。修复方法很简单,把 Redis 里receivedChunks的初始值改为 0,同时在前端预上传接口的返回值里,把后端当前已接收的分片序号告诉前端,前端从正确的位置继续传。这样即使上传中断,续传也能从断点位置继续,而不是从 0 开始。
5.3 存储路径策略:避免文件名冲突和路径过长
存储路径策略是网盘系统的一个隐蔽深坑。最开始我用的是"日期/原始文件名"这种人类可读的路径,比如2025/01/12/项目方案.docx。结果出现了两个问题:第一,不同用户上传了同名文件,直接互相覆盖了;第二,文件名里有特殊字符时,操作系统层面会报错。后来我改成彻底的随机目录加随机文件名:{storageKey},而这个 storageKey 是 UUID 加 MD5 前缀的组合。文件名和目录结构全部用随机字符串,彻底避免冲突。
文件元数据全部放数据库,访问时再构建 URL 映射,底层存储文件和用户看到的文件名没关系。这种方式牺牲了一点"直接去服务器文件夹里找文件"的便利,但换来的是绝对安全的存储层。分享文件时,后端生成一个短期有效的访问地址,而不是直接暴露底层路径。个人网盘系统如果考虑多机部署,这个策略也更灵活,文件可以分布在多台存储节点上,数据库里记录节点编号即可。
5.4 常见问题速查与排查思路汇总
| 现象 | 可能原因 | 排查优先级 | 解决方法 |
|---|---|---|---|
| 上传时报 413 | Nginx client_max_body_size 过小 | 1 | 调整到 200M 以上 |
| 上传时报 500,日志有 MaxUploadSizeExceededException | Spring 配置未生效 | 2 | 检查 multipart 配置是否在正确文件中 |
| 下载中文文件名乱码 | Content-Disposition 编码问题 | 3 | 使用filename*=UTF-8''格式 |
| 预览图片 404 | 静态资源映射未配置或权限拦截 | 4 | 配置 WebMvcConfigurer 的 addResourceHandlers |
| 登录后接口全部 401 | 前端请求未携带 Authorization 请求头 | 5 | 检查请求拦截器中 headers 设置 |
| Redis 连接断开后上传全部失败 | Redis 未做高可用 | 6 | 增加连接池和重试机制 |
排查问题的时候我有一个习惯:先看日志里的异常堆栈第一行,再往前翻 3 到 5 条日志,基本能定位是配置问题、代码问题还是环境问题。很多新手一遇到问题就怀疑自己代码写错了,其实最常见的反而是配置问题,尤其是 Nginx 和 Spring 的 multipart 配置冲突,我见过不止一次。
5.5 定时清理任务的实现细节
回收站的文件 30 天后要真正从存储中删除,我用了 Spring Boot 自带的@Scheduled定时任务,每天凌晨 3 点执行清理。清理流程是:查询回收站表中状态为 0、且删除时间超过 30 天的记录,先删用户文件关系,再删文件信息记录,最后调用存储层删除物理文件。顺序不能错,先删关系,再删元数据,最后删物理文件,这样即使中途失败,最多残留一个无引用的物理文件,不会出现"记录还在但文件已经没了"的严重问题。
定时任务我用了一个简单的分页循环,每次处理 2000 条记录,防止一次删除太多导致数据库锁表。还要注意,清理动作应该跳过仍处于分享状态的文件,否则外部访问直接 404。这个判断逻辑是:检查分享表里是否有关联该文件、且未过期的分享记录。
5.6 分享链接的安全控制与访问限流
分享链接是网盘系统一不小心就会出问题的点。我做的安全控制有三层:第一层是分享码,默认开启 4 位提取码,用户可以关闭;第二层是过期时间,默认 7 天,到期后分享自动失效;第三层是访问限流,同一个分享 token 在 10 分钟内访问超过 60 次,就直接拒绝。限流的实现用的是 Redis 的计数器,简单可靠:
Long count = redisTemplate.opsForValue().increment("share:" + token); if (count != null && count == 1) { redisTemplate.expire("share:" + token, 10, TimeUnit.MINUTES); } if (count != null && count > 60) { throw new BizException("访问过于频繁,请稍后重试"); }这个限流不追求精确,但它能挡住大多数恶意刷量,保护分享文件的下载带宽。如果有人把分享链接发到群里,流量瞬间涨起来,限流才会生效。网盘系统没有做更复杂的 IP 维度限流,是考虑到网盘大多在公司内网或家庭网络访问,IP 固定且少,没必要自找麻烦。
6. 补充经验与项目扩展建议
6.1 磁盘空间管理:不能只依赖操作系统
个人网盘运行久了,磁盘空间会被吃光,尤其是日志文件和临时分片目录。我做了一个磁盘监控的定时任务,每小时检查一下分片临时目录的大小,超过 2GB 就触发清理。这个数值可以根据磁盘大小调整,但思路很实际:流式大文件的临时区域一定要设置一个硬上限,主动清理远比被动等系统崩溃好。
还有一个容易被忽略的点:日志轮转。Spring Boot 默认的 logback 配置如果没写 RollingPolicy,日志文件会无限增长。我的配置是每天滚动一个日志文件,保留 7 天,超过 7 天的自动删。这个配置用 logback-spring.xml 实现,非常简单,但很多人会忘记,导致服务器 D 盘被日志占满。
6.2 数据库备份策略与恢复演练
数据库备份我采用的是每天凌晨全量备份加每 30 分钟 binlog 增量的方式。全量备份用mysqldump,备份文件按日期命名,保留最近 14 天。增量备份依赖 MySQL 的 binlog,恢复时先恢复最近一次全量备份,再按时间点回放 binlog。
我特别想说的是,备份做了不测试等于没做。很多人配完定时备份任务就觉得万事大吉,结果真正要恢复时发现备份文件是损坏的、或者 mysqldump 命令执行时报错。我养成的习惯是每个季度做一次恢复演练:从备份文件里拉一个测试库,启动应用连上去,验证数据完整性和服务可用性。这种演练虽然耗时,但真正出事的时候能救命。
6.3 后续功能扩展的三个方向
这个系统如果继续往下做,我有三个明确的扩展方向。第一是支持多存储节点的接入,把存储抽象层完全推向 S3 协议,MinIO、阿里云 OSS、腾讯云 COS 都可以作为后端存储,数据库里给文件表加上storage_node字段。第二是增加极速上传体验,前端预上传时可以把文件的 MD5 计算放到 Web Worker 里,避免 UI 卡顿。第三是如果想让分享功能更像知名网盘产品,可以增加"加密分享文件夹"的能力,核心逻辑是分享表加一个is_folder_share字段,配合目录树表的递归标记来实现。
6.4 项目文档的整理思路
这个项目的文档我写了三部分:部署文档、接口文档、开发文档。部署文档里写清楚环境要求、配置文件修改项、启动命令和常见问题。接口文档我用的是 Apifox,可以自动同步接口定义,也有在线文档分享的功能。开发文档主要是讲清楚代码结构和核心流程,业务初期我自己看,后来新同事接手也靠它。
文档这件事我多说一句:写文档的最直接受益人是未来的自己。项目搁置半年再回来看,如果没有文档,你要花大量时间去翻代码才想起来当年的设计意图。我的习惯是完成一个模块就顺手写一段文档,不追求篇幅,把决策原因和坑记下来就够了。
我之前写过一版用原生 Servlet 实现的网盘,代码能跑但扩展性极差,加一个分享功能要改五六个文件。这次用 Spring Boot 重构后,最大的体会是框架约束下的分层设计,让你在动手写第一行代码前就想清楚数据怎么流、模块之间的边界在哪里。个人网盘这个项目看起来简单,但真要做得顺手,从数据库设计到存储选型,再到分片上传的细节和小文件合并策略,每个环节都值得好好打磨。如果你也想做一个类似项目,建议先从最小功能跑通,再逐步加功能,而不是照着大而全的产品清单去堆代码,那样维护成本会让你怀疑人生。