最近做了一套基于Spring Boot的网盘类管理系统,核心功能就是个人云盘。像这种"Spring Boot + 前端JS"的Title,其实是很典型的Java Web练手项目,网上也有各种各样的教程,但绝大多数都是跑通就完事,真正像工程一样考虑细节的很少。这篇文章就按我实际开发这套个人云盘管理系统的经验,把这个题目的技术架构、数据库、核心功能、中间踩的坑从头梳理一遍,给想拿这个题目做毕业设计或者练手的同学一个可以直接照抄的参考。整个系统功能覆盖得很完整:用户注册登录、文件上传下载、文件夹管理、分享链接、回收站恢复、图片和文本在线预览。别看这些功能听起来简单,真正做起来,文件目录层级、分片上传、逻辑删除、权限校验这些模块单独拎出来都能写很多文章。所以我这里会把重点放在整体设计和最容易踩坑的实现细节上,而不是贴几百行代码充数。1. 项目整体设计与技术选型思路1.1 核心需求拆解个人云盘听起来简单,但真正拆开需求以后会发现,很多东西如果不提前理清楚,开发到后面很容易自己把自己绕晕。我把需求分成四块。第一块是用户相关。登录、注册、退出、获取当前登录用户信息。这个系统定位是"个人云盘",所以权限模型不需要做多角色,只要认准当前登录的用户是谁就行。所有文件数据都归属于某个用户ID,A用户看不到B用户的文件。第二块是文件管理。这是核心中的核心,包括上传文件、下载文件、新建文件夹、重命名、删除、移动、文件列表展示、回收站恢复。其中文件列表的树形展示或者叫目录结构,是很多新手容易卡住的地方。因为文件系统是有层级关系的,不是平铺的列表。第三块是分享功能。文件可以生成一个分享链接,加上提取码发给别人,对方在浏览器里打开链接,输入提取码后能看到文件详情或者直接下载文件。分享链接有过期时间,过期后自动失效。第四块是其他辅助功能,比如文件图标展示、图片预览、文本预览、磁盘使用情况统计之类的。这属于加分项,但实际开发时也要考虑进去,因为页面展示的时候,得根据文件类型做不同的标识。需求拆解完以后,最好不要急着写代码,先设计一下数据表。数据表设计得好,后面编码会很顺畅;数据表设计得有问题,后面会不断返工。所以我把数据库设计放在开发之前一步。1.2 为什么选Spring Boot + 前端JS这个技术组合在毕业设计和各类实战项目里出现频率非常高。后端用Spring Boot,核心原因在于它把Spring家族的配置简化到了不需要XML的程度,内嵌Tomcat容器,一个main方法就能启动服务。相比传统的SSH(Spring + Struts + Hibernate)或者SSM(Spring + Spring MVC + MyBatis)方案,省去大量配置时间,让开发者能把精力集中在业务逻辑上。另外Spring Boot有非常成熟的生态,后面我们用到Web上传、拦截器、配置文件多环境切换,都有现成方案。前端选择JavaScript方向,具体我用了Vue框架加Element UI组件库。Vue的写法比原生JS更清晰,数据驱动视图,文件列表、上传弹窗这些交互用组件化开发会舒服很多。当然,如果说题目里只写了"基于JS",用原生JS照样可以做,只是开发速度慢一些。我的建议是如果自己有Vue基础,就用Vue;如果完全不会框架,用原生JS配合jQuery也能完成,只要确保和后端交互是标准HTTP接口即可。前后端分离以后,开发的时候启动两个服务,前端开发服务器和后端Java进程。遇到跨域问题不要怕,后面我会专门讲CORS怎么配。重要的是不要在前端JS里写死绝对路径,所有环境相关配置放到全局配置里统一管理。1.3 系统架构设计整个系统的架构并不复杂,思路如下。前端页面通过浏览器发起HTTP请求,请求走到后端的Controller层。Controller负责接收参数、校验参数、调用Service,再返回统一格式的JSON给前端。Service层处理具体业务逻辑,比如文件上传时判断大小、生成存储路径、更新数据库记录。数据访问用MyBatis Plus内置的BaseMapper,常规的增删改查不需要手写SQL,只有递归查询目录、多表统计这种特殊场景需要自定义XML。工程内的包结构大概是:controller:控制层service:业务层mapper:数据访问层entity:数据库实体dto:前端出入参封装config:配置类utils:工具类另外为了统一返回消息,我写了一个单独的结果封装类,比如返回码200表示成功,500表示业务异常。所有Controller接口都返回这个封装,前端根据返回码统一处理错误提示,这样不需要每个接口单独写异常逻辑。提示:前期架构可以简单,但结果封装和异常处理务必要统一。前期偷懒只返回裸数据,后期加了复杂页面之后,前端会被各种非标准返回搞得焦头烂额。2. 数据库设计与核心表结构2.1 用户表设计用户表我用了这张结构:字段名:id、username、password、nickname、avatar、create_time、status。id是自增主键。username要加唯一索引,避免注册时重名。password存BCrypt加密后的密文,绝对禁止明文。avatar可以先存一个字符串,路径指向服务器上的头像图片链接,也可以不做头像功能完全省掉。status用于禁用账户,1表示正常,0表示禁用。注册接口的逻辑很简单,先检查用户名是否已存在,存在则提示"用户名已被注册",不存在则用BCrypt工具类加密密码写入。登录接口查询用户名和密码匹配,匹配成功生成JWT令牌返回给前端。这里要注意用户密码的密文长度,一般60位,在数据库里字段长度不要建短了,我遇到过有人把密码字段设置成varchar(32),结果BCrypt密文根本存不下。2.2 文件信息表设计文件表是整个系统的核心,字段比较多。我用的核心字段是:id:文件IDfile_name:文件名file_size:文件大小,单位字节file_type:文件类型,比如image/png、application/pdffile_path:存储的相对路径(不含根目录前缀)user_id:所属用户IDparent_id:父目录ID,0表示根目录folder_flag:是否文件夹,1是0否del_flag:逻辑删除标记,0正常1已删除create_time:创建时间update_time:更新时间这里有一个关键点,很多初学者喜欢把文件的实际存储路径直接存一个绝对路径,比如C:\server\data\user1\abc.txt。这个在本地跑没问题,但是部署到服务器上,服务器环境和本地不一致,很可能路径就失效了。正确做法是存相对路径,比如user/1/2025/03/abc.txt,在启动配置里设置一个根目录,结合起来才是完整物理路径。我做的系统就是上传后先根据用户ID生成一个目录用户ID,然后把文件写到根目录下的用户ID目录里,数据库中只存用户ID/文件名的相对路径。第二个关键点是文件夹本身也要在文件表里占一条记录。folder_flag=1表示这条记录是一个文件夹记录,没有实际物理文件,file_path可以为空,但有parent_id用于确定归属。这样文件夹树和管理操作全都是同一张表,不需要额外的目录表。2.3 分享表设计分享功能只需要一张表。字段包括:id:分享IDshare_token:随机生成的短链接标志,用UUID去掉横线的形式user_id:分享者IDfile_id:被分享的文件IDextract_code:提取码,4到6位随机数字expire_time:过期时间status:状态,1有效0失效每次生成分享时,后端生成share_token和extract_code,把文件ID和分享者ID写进去。其他用户访问分享链接时,后端通过share_token拿到分享记录,判断是否过期。然后前端页面上输入提取码,比对成功后返回文件信息,文件信息如果是文件夹,则返回该文件夹下的文件列表;如果是一个普通文件,则可以直接触发下载。这里要注意分享文件夹的场景,在提取码校验通过后,访问文件夹下的子文件时,还需要校验子文件的归属是否确实在这个分享文件夹下,不然容易被越权访问。2.4 回收站与逻辑删除设计回收站我不建议单独建表,原因很简单:文件表已经足够承载这个逻辑了。在文件表里加一个del_flag字段,删除文件时只是把del_flag改成1,同时更新时间置为当前时间。回收站页面查询时条件加上del_flag=1,就能把删除过的文件捞出来。点击恢复时,把del_flag改回0,同时可能需要检查父ID是否还存在,防止父目录已经被彻底删除导致无法挂载。彻底删除时,才真正删除数据库记录,并调用文件工具类删除物理文件。这个逻辑对比单独建回收站表要清爽很多,也便于后面加"回收站保留天数"这样的扩展需求。注意:逻辑删除容易出现的一个坑是,查询文件列表时忘了加del_flag=0条件,导致回收站里的文件出现在正常列表里。如果使用MyBatis Plus,可以给文件表实体加@TableLogic注解做全局逻辑删除,这样内置的selectList方法会自动追加del_flag=0条件,省心很多。3. 核心功能模块的实现细节3.1 用户认证与JWT权限控制登录认证这块,我选择了JWT。原因是项目一旦前后端分离,服务端如果还用Session,就会面临跨域共享Session、跨服务部署Session不一致的问题。JWT的思路是登录成功后把用户ID和过期时间声明进一个加密的Token,前端保存Token,之后每个请求在Header里带上。后端写一个拦截器统一解析,效率高,也不需要额外的Redis存储。JWT工具类我封装了两个方法,一个是生成Token,一个是解析Token。生成Token时把用户ID作为claim放进去,同时设置过期时间为24小时。拦截器在每个请求里读取Header的Authorization字段,去掉Bearer前缀后解析。如果解析成功就把用户ID放入ThreadLocal,如果解析失败则返回401错误。登录注册等少数公开接口需要放行。拦截器注册的时候要特别注意放行顺序。比如用Spring Boot的注册方式,拦截器对所有路径生效,需要在addPathPatterns时设置放行路径列表。我在实际开发中就把文件下载接口忘记放行,结果前端跳转浏览器的下载链接全部被拦截,排查了很久才发现问题是拦截器把所有下载请求拦截了,不仅没有返回文件流,还返回了JSON错误,浏览器直接下载了一个json错误文件。这个经验值得记住。3.2 文件上传与分片秒传实现文件上传是最容易出问题的模块。常规上传,后端接口就是接收MultipartFile参数,获取文件名和大小,然后把文件写到磁盘上,再保存一条记录到数据库。但真实业务里,大文件上传如果不做处理,用户传几个GB的文件基本必失败,不是连接超时就是内存溢出。我的方案是分片上传。前端把文件切片,每片大小5MB,然后循环调用上传接口,上传接口接收文件分片,以及一个分片编号,还有一个上传ID用来标识同一个文件的分片集合。每个分片作为临时文件存到临时目录,全部上传完成后,前端调用合并接口,后端把所有分片按编号拼接成一个完整文件。这样即使传到一半网络断了,也能重新上传未完成的分片,而不需要重头再传。这里核心代码如下,Controller接收分片时用到MultipartFile和@RequestParam:@PostMapping("/upload/chunk") public Result uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("uploadId") String uploadId, @RequestParam("chunkIndex") int chunkIndex) { String tempDir = storageConfig.getRootPath() + "/tmp/" + uploadId; File dir = new File(tempDir); if (!dir.exists()) dir.mkdirs(); file.transferTo(new File(dir, String.valueOf(chunkIndex))); return Result.ok(); }秒传功能就是另一个思路了。上传前前端用JavaScript读取文件的MD5值,调用后端接口查询该文件是否已经存在于共用文件库中。如果存在,直接给当前用户生成一条文件记录,不再写入实际文件。我做的个人云盘系统里没有做全局去重库,但可以先在用户自己的文件目录里查询,如果同MD5文件已存在,则直接建立引用关系,避免重复存储。实际开发时推荐加一个独立的文件指纹表,字段包括MD5、文件大小、物理路径、被引用次数,这样才能支撑真正的秒传。分片上传的合并操作要小心,不要把所有分片内容一次性读入内存,应该用InputStream循环读取每个分片文件,然后按顺序写入OutputStream。合并完成之后,再删除临时分片文件。这个过程中还要注意处理并发上传同一个上传ID的情况,避免合并时文件被占用。3.3 文件下载与在线预览文件下载接口的核心是返回文件流。我用了Spring的ResponseEntity ,读取文件为Resource,设置Content-Type和Content-Disposition。这里的坑主要在中英文文件名。中文文件名如果直接放到Content-Disposition里,不同浏览器展示效果不一样,最稳妥的做法是用URLEncoder编码,然后前端或者浏览器会自动解码。另外自己拼接HTTP响应头时,要注意header不能包含换行符,否则会在安全扫描中被判为注入漏洞。在线预览的话,图片和文本最简单。图片直接通过接口返回原始二进制,前端用一个组件接收blob流来展示。文本文件读取时要注意编码,UTF-8文件正常,GBK文件会出现乱码。我的处理方式是先通过工具读取文件的前几百个字节尝试判断编码,然后按对应编码读取内容再转成UTF-8返回给前端。这样预览体验会好很多。PDF和Office文件预览要复杂一些,需要第三方组件转码或者使用在线预览服务,依赖环境,我在毕设项目中就没有做复杂的在线编辑,只展示了图片、文本、视频音频这几种常见的。3.4 文件管理接口设计文件管理包括根目录列表、进入子目录、新建文件夹、重命名、移动文件、删除文件。接口设计其实都围绕文件表。根目录列表就是查询user_id=当前用户ID AND parent_id=0 AND del_flag=0的所有文件。进入子目录就是查询parent_id=某个ID的文件列表。新建文件夹就插入一条folder_flag=1的记录。重命名很简单,直接更新file_name字段。移动文件就是修改parent_id。删除文件则是逻辑删除。文件夹的删除是特殊的,因为它是递归结构。后端删除文件夹时,不能只删当前记录,需要先把文件夹下所有子文件找出来。我的处理办法是用一个递归方法,先查询当前文件夹的所有直接子节点,对每个子节点,如果是文件夹则递归调用,如果是文件则删除当前记录,最后再删除当前文件夹记录。在逻辑删除模式下,也可以只删除当前文件夹记录,然后让子文件的信息在文件夹树中"悬挂",但这种状态恢复时很容易出错,还是老老实实递归处理。前端展示文件树时,我建议不要一次性把所有层级的文件夹全部查出来。可以按需加载,点击文件夹时请求一次子文件接口。这样接口简单,数据量也小。如果确实需要展示整棵树,那就后端递归组装树形JSON,前端用Vue的递归组件渲染。3.5 分享链接生成与回收站逻辑分享链接我在实现时用了两个随机值。一个是share_token,作为链接的唯一标识,直接放在URL里;另一个是extract_code,作为输入提取码,用户访问分享页面时必须输入。分享者可以选择分享有效天数,比如1天、7天、永久。对应后端记录过期时间。校验时先比对share_token是否存在,然后比对提取码,最后判断是否过期。分享链接的生成逻辑很简单,核心就是创建一条分享记录:public Share createShare(Long userId, Long fileId, Integer expireDays) { Share share = new Share(); share.setUserId(userId); share.setFileId(fileId); share.setShareToken(UUID.randomUUID().toString().replace("-", "")); share.setExtractCode(RandomUtil.randomNumbers(4)); if (expireDays != null && expireDays > 0) { share.setExpireTime(LocalDateTime.now().plusDays(expireDays)); } share.setStatus(1); shareMapper.insert(share); return share; }回收站逻辑我在文件表里用del_flag实现,文件表里有一个delete_time字段,方便定时清理超过保留期的记录。回收站页面复用文件列表接口,只不过查询条件换成了del_flag=1,并且按删除时间倒序。恢复操作和删除正好相反,将del_flag重置为0。彻底删除时,后台会同时调用物理文件删除方法。如果文件在多个地方被引用(比如秒传引用),物理文件删除还需要处理引用计数,不能直接删,否则可能导致其他用户文件缺失。我的系统没有做跨用户引用,所以没有这个顾虑,但做扩展时要注意。4. 实操过程中的关键配置与调试4.1 核心依赖与配置文件这个项目的pom.xml里,依赖不需要很多,主要包括spring-boot-starter-web、spring-boot-starter-validation、mybatis-plus-boot-starter、mysql-connector-java、jjwt、hutool-all、lombok。hutool这个工具包帮了很大忙,生成随机数、计算文件MD5、日期工具都用它,省下大量重复代码。application.yml这里没有特别难的东西,但有几个配置容易出错。第一个是数据源url,数据库名要和本地建的一致,时区必须加serverTimezone=Asia/Shanghai,否则JDBC连接会报时区异常。第二个是MyBatis Plus的mapper-locations配置,如果XML文件没放到默认classpath下,需要手动指定。第三个是文件上传大小,我设置了max-file-size: 512MB,max-request-size: 1GB。第四个是自定义存储路径,Spring Boot里使用@ConfigurationProperties读取,比如cloud.storage.root-path: ./data。这里的相对路径要小心,启动时的工作目录不同,会指向不同位置,建议部署时使用绝对路径。我贴上最核心的配置片段,可以直接改用自己的:spring: servlet: multipart: max-file-size: 512MB max-request-size: 1GB datasource: url: jdbc:mysql://localhost:3306/cloud_disk?serverTimezone=Asia/Shanghai username: root password: 123456 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl cloud: storage: root-path: ./data4.2 CORS跨域问题前后端分离后,跨域问题基本是必踩的。如果前端项目跑在http://localhost:8080,后端接口在http://localhost:8081,这时候前端直接调后端接口,浏览器就会因为跨域拦截。解决方式是在后端写一个配置类实现WebMvcConfigurer,在addCorsMappings方法里配置允许的源、方法、请求头。我当时的经验是,允许源可以直接用allowedOriginPatterns("*"),开发环境这么配最省事。allowedHeaders需要把Authorization放进去,否则带了Token的请求会被浏览器预检拦下来。配置类写起来也不复杂,直接贴出来:@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }另外,如果自己实现了拦截器,跨域配置和拦截器之间还有顺序问题。前端的预检请求是OPTIONS方法,拦截器如果拦了OPTIONS请求,就会导致预检失败。解决办法是在拦截器里面判断请求方法,如果为OPTIONS则直接放行。4.3 上传大小限制的完整配置Spring Boot对文件上传的限制来自两个地方。一是Spring MVC的配置,spring.servlet.multipart.max-file-size和max-request-size,默认只有1MB和10MB,不修改的话传大文件马上报FileSizeLimitExceededException。二是Tomcat服务器本身的连接器限制,如果超过这个限制,可能返回Connection reset,前端看起来像是连接被服务器断开。我在做分片上传时,因为每一片只有几MB,主要是把分片大小调好,整体上传接口的max-file-size还不能设太小,否则即使分片没超过限制,后端临时文件处理也可能不被允许。综合下来,我直接把单文件上传限制改成512MB,请求总大小改成1GB,基本能满足大多数使用场景。这里补充一个容易忽略的知识点:Spring Boot 2.x以上版本中,如果使用了自定义的MultipartConfigElement配置类,它的优先级会覆盖配置文件中的spring.servlet.multipart配置。我建议不要同时用两种配置,只保留yml配置就好,否则出了问题排查时会很困惑。4.4 日志配置与接口调试调试阶段强烈建议把日志级别调到DEBUG,至少在开发环境要调。MyBatis Plus会在控制台打印SQL日志,你可以看到执行的每条SQL,对于排查数据有没有查对很有帮助。另外可以开启Spring Boot的web日志,查看请求和响应的基本状态。我习惯把项目里所有Controller接口的出入参在AOP里打一条日志,打印请求地址、请求参数、耗时,这个做法在联调时省了无数口舌。前端调试我推荐用浏览器开发者工具配合Postman。遇到上传类接口异常,先看请求体里file字段名称和后端MultipartFile参数名是否一致,不一致的话后端拿到的file就是null。遇到下载接口异常,直接用Postman请求下载地址,看返回的Content-Type和Content-Disposition是不是正确。如果返回的是JSON错误,则说明请求被拦截器或者异常处理接管了。5. 常见问题与避坑指南5.1 中文文件名乱码问题这个几乎是所有网盘类项目都会碰上的问题。表现是前端上传中文文件,后端保存到磁盘后文件名变成乱码,下载时设置的文件名也是乱码。原因有两层,一是HTTP传输时编码不是UTF-8,二是Content-Disposition头里不能直接放中文。解决办法是前端在FormData里正常传文件名,后端获取文件名后,到输出响应时用URLEncoder.encode(fileName, "UTF-8")重新编码。如果保存到磁盘的文件名乱码,检查一下服务器操作系统字符集和Tomcat的URIEncoding配置,通常设成UTF-8就能解决。我写下载接口的时候,对响应头做了一定处理,关键在于文件名要编码,而不是直接拼接:String encodedFileName = URLEncoder.encode(fileName, "UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");5.2 跨域配置正确但请求依然失败遇到过一次很迷惑的问题:跨域配置看起来没问题,但前端带Authorization的请求依然失败。后来发现是拦截器把OPTIONS预检请求拦了。浏览器在发起复杂请求时会先发一个OPTIONS请求,如果后端返回的不是成功响应,真实请求就不会发出。解决办法很简单,拦截器里对OPTIONS请求直接返回成功。这个坑我之前提过一次,但值得再强调,因为排查时很容易忽略拦截器的存在。5.3 文件路径与部署环境不兼容本地写的是绝对路径,比如某个盘符下的目录,部署到Linux服务器上直接找不到路径。这是因为操作系统的路径分隔符和磁盘挂载方式完全不同。规范做法是数据库存相对路径,根目录通过配置文件指定。我在制作这个系统时就把存储根目录放到配置文件里,所有物理文件写入时获取根目录再拼相对路径,这样换机器只需要改一个配置项。另外,在Java里拼接文件路径时不要手写斜杠,统一用Paths.get或者File.separator,否则在不同操作系统上会出现斜杠方向不一致的问题。5.4 回收站恢复后文件夹结构错乱恢复一个处于回收站的文件夹时,如果原本的父目录已经被彻底删除了,会出现一条记录指向一个不存在的父目录。前端打开这个文件夹就会查不到数据。解决办法是,恢复时先检查父目录是否存在,如果不存在则把parent_id置为0,也就是回到用户根目录。顺带还要检查重命名冲突,如果目标位置已经有同名文件,恢复时自动在文件名后面加一个后缀。5.5 文件流下载时无法终止连接下载大文件时,如果用户点击了取消,后端的输出流可能还在写入,最终导致链路占用很久。如果并发下载的人多,连接数会飙升。我给下载接口的ResponseEntity设置缓冲区,并且前端取消时浏览器会关闭连接,后端需要在写入时处理IOException,遇到异常就及时退出循环,不要继续写。另外可以考虑用Servlet的异步输出做高并发优化,但这个系统作为个人云盘,并发量很小,不用过度设计。这套个人云盘管理系统做下来,我个人体感最难的其实不是写代码,而是把文件操作的逻辑想清楚,尤其是目录层级、回收站、分享这些和“文件关系”相关的功能。一旦数据表设计合理,后端的每个接口都很直白;如果一开始就草草建表,后面补字段、改逻辑会非常痛苦。最后分享一个实用小技巧:在开发时可以提前在本地准备一些不同类型的测试文件,比如大文件、图片、中文文件名、无扩展名文件,上传下载和预览每个环节都测一遍,很多问题是在这些边界文件上会暴露出来。希望这篇文章能帮到准备做类似项目的朋友。
最近做了一套基于Spring Boot的网盘类管理系统,核心功能就是个人云盘。像这种"Spring Boot + 前端JS"的Title,其实是很典型的Java Web练手项目,网上也有各种各样的教程,但绝大多数都是跑通就完事,真正像工程一样考虑细节的很少。这篇文章就按我实际开发这套个人云盘管理系统的经验,把这个题目的技术架构、数据库、核心功能、中间踩的坑从头梳理一遍,给想拿这个题目做毕业设计或者练手的同学一个可以直接照抄的参考。整个系统功能覆盖得很完整:用户注册登录、文件上传下载、文件夹管理、分享链接、回收站恢复、图片和文本在线预览。别看这些功能听起来简单,真正做起来,文件目录层级、分片上传、逻辑删除、权限校验这些模块单独拎出来都能写很多文章。所以我这里会把重点放在整体设计和最容易踩坑的实现细节上,而不是贴几百行代码充数。1. 项目整体设计与技术选型思路1.1 核心需求拆解个人云盘听起来简单,但真正拆开需求以后会发现,很多东西如果不提前理清楚,开发到后面很容易自己把自己绕晕。我把需求分成四块。第一块是用户相关。登录、注册、退出、获取当前登录用户信息。这个系统定位是"个人云盘",所以权限模型不需要做多角色,只要认准当前登录的用户是谁就行。所有文件数据都归属于某个用户ID,A用户看不到B用户的文件。第二块是文件管理。这是核心中的核心,包括上传文件、下载文件、新建文件夹、重命名、删除、移动、文件列表展示、回收站恢复。其中文件列表的树形展示或者叫目录结构,是很多新手容易卡住的地方。因为文件系统是有层级关系的,不是平铺的列表。第三块是分享功能。文件可以生成一个分享链接,加上提取码发给别人,对方在浏览器里打开链接,输入提取码后能看到文件详情或者直接下载文件。分享链接有过期时间,过期后自动失效。第四块是其他辅助功能,比如文件图标展示、图片预览、文本预览、磁盘使用情况统计之类的。这属于加分项,但实际开发时也要考虑进去,因为页面展示的时候,得根据文件类型做不同的标识。需求拆解完以后,最好不要急着写代码,先设计一下数据表。数据表设计得好,后面编码会很顺畅;数据表设计得有问题,后面会不断返工。所以我把数据库设计放在开发之前一步。1.2 为什么选Spring Boot + 前端JS这个技术组合在毕业设计和各类实战项目里出现频率非常高。后端用Spring Boot,核心原因在于它把Spring家族的配置简化到了不需要XML的程度,内嵌Tomcat容器,一个main方法就能启动服务。相比传统的SSH(Spring + Struts + Hibernate)或者SSM(Spring + Spring MVC + MyBatis)方案,省去大量配置时间,让开发者能把精力集中在业务逻辑上。另外Spring Boot有非常成熟的生态,后面我们用到Web上传、拦截器、配置文件多环境切换,都有现成方案。前端选择JavaScript方向,具体我用了Vue框架加Element UI组件库。Vue的写法比原生JS更清晰,数据驱动视图,文件列表、上传弹窗这些交互用组件化开发会舒服很多。当然,如果说题目里只写了"基于JS",用原生JS照样可以做,只是开发速度慢一些。我的建议是如果自己有Vue基础,就用Vue;如果完全不会框架,用原生JS配合jQuery也能完成,只要确保和后端交互是标准HTTP接口即可。前后端分离以后,开发的时候启动两个服务,前端开发服务器和后端Java进程。遇到跨域问题不要怕,后面我会专门讲CORS怎么配。重要的是不要在前端JS里写死绝对路径,所有环境相关配置放到全局配置里统一管理。1.3 系统架构设计整个系统的架构并不复杂,思路如下。前端页面通过浏览器发起HTTP请求,请求走到后端的Controller层。Controller负责接收参数、校验参数、调用Service,再返回统一格式的JSON给前端。Service层处理具体业务逻辑,比如文件上传时判断大小、生成存储路径、更新数据库记录。数据访问用MyBatis Plus内置的BaseMapper,常规的增删改查不需要手写SQL,只有递归查询目录、多表统计这种特殊场景需要自定义XML。工程内的包结构大概是:controller:控制层service:业务层mapper:数据访问层entity:数据库实体dto:前端出入参封装config:配置类utils:工具类另外为了统一返回消息,我写了一个单独的结果封装类,比如返回码200表示成功,500表示业务异常。所有Controller接口都返回这个封装,前端根据返回码统一处理错误提示,这样不需要每个接口单独写异常逻辑。提示:前期架构可以简单,但结果封装和异常处理务必要统一。前期偷懒只返回裸数据,后期加了复杂页面之后,前端会被各种非标准返回搞得焦头烂额。2. 数据库设计与核心表结构2.1 用户表设计用户表我用了这张结构:字段名:id、username、password、nickname、avatar、create_time、status。id是自增主键。username要加唯一索引,避免注册时重名。password存BCrypt加密后的密文,绝对禁止明文。avatar可以先存一个字符串,路径指向服务器上的头像图片链接,也可以不做头像功能完全省掉。status用于禁用账户,1表示正常,0表示禁用。注册接口的逻辑很简单,先检查用户名是否已存在,存在则提示"用户名已被注册",不存在则用BCrypt工具类加密密码写入。登录接口查询用户名和密码匹配,匹配成功生成JWT令牌返回给前端。这里要注意用户密码的密文长度,一般60位,在数据库里字段长度不要建短了,我遇到过有人把密码字段设置成varchar(32),结果BCrypt密文根本存不下。2.2 文件信息表设计文件表是整个系统的核心,字段比较多。我用的核心字段是:id:文件IDfile_name:文件名file_size:文件大小,单位字节file_type:文件类型,比如image/png、application/pdffile_path:存储的相对路径(不含根目录前缀)user_id:所属用户IDparent_id:父目录ID,0表示根目录folder_flag:是否文件夹,1是0否del_flag:逻辑删除标记,0正常1已删除create_time:创建时间update_time:更新时间这里有一个关键点,很多初学者喜欢把文件的实际存储路径直接存一个绝对路径,比如C:\server\data\user1\abc.txt。这个在本地跑没问题,但是部署到服务器上,服务器环境和本地不一致,很可能路径就失效了。正确做法是存相对路径,比如user/1/2025/03/abc.txt,在启动配置里设置一个根目录,结合起来才是完整物理路径。我做的系统就是上传后先根据用户ID生成一个目录用户ID,然后把文件写到根目录下的用户ID目录里,数据库中只存用户ID/文件名的相对路径。第二个关键点是文件夹本身也要在文件表里占一条记录。folder_flag=1表示这条记录是一个文件夹记录,没有实际物理文件,file_path可以为空,但有parent_id用于确定归属。这样文件夹树和管理操作全都是同一张表,不需要额外的目录表。2.3 分享表设计分享功能只需要一张表。字段包括:id:分享IDshare_token:随机生成的短链接标志,用UUID去掉横线的形式user_id:分享者IDfile_id:被分享的文件IDextract_code:提取码,4到6位随机数字expire_time:过期时间status:状态,1有效0失效每次生成分享时,后端生成share_token和extract_code,把文件ID和分享者ID写进去。其他用户访问分享链接时,后端通过share_token拿到分享记录,判断是否过期。然后前端页面上输入提取码,比对成功后返回文件信息,文件信息如果是文件夹,则返回该文件夹下的文件列表;如果是一个普通文件,则可以直接触发下载。这里要注意分享文件夹的场景,在提取码校验通过后,访问文件夹下的子文件时,还需要校验子文件的归属是否确实在这个分享文件夹下,不然容易被越权访问。2.4 回收站与逻辑删除设计回收站我不建议单独建表,原因很简单:文件表已经足够承载这个逻辑了。在文件表里加一个del_flag字段,删除文件时只是把del_flag改成1,同时更新时间置为当前时间。回收站页面查询时条件加上del_flag=1,就能把删除过的文件捞出来。点击恢复时,把del_flag改回0,同时可能需要检查父ID是否还存在,防止父目录已经被彻底删除导致无法挂载。彻底删除时,才真正删除数据库记录,并调用文件工具类删除物理文件。这个逻辑对比单独建回收站表要清爽很多,也便于后面加"回收站保留天数"这样的扩展需求。注意:逻辑删除容易出现的一个坑是,查询文件列表时忘了加del_flag=0条件,导致回收站里的文件出现在正常列表里。如果使用MyBatis Plus,可以给文件表实体加@TableLogic注解做全局逻辑删除,这样内置的selectList方法会自动追加del_flag=0条件,省心很多。3. 核心功能模块的实现细节3.1 用户认证与JWT权限控制登录认证这块,我选择了JWT。原因是项目一旦前后端分离,服务端如果还用Session,就会面临跨域共享Session、跨服务部署Session不一致的问题。JWT的思路是登录成功后把用户ID和过期时间声明进一个加密的Token,前端保存Token,之后每个请求在Header里带上。后端写一个拦截器统一解析,效率高,也不需要额外的Redis存储。JWT工具类我封装了两个方法,一个是生成Token,一个是解析Token。生成Token时把用户ID作为claim放进去,同时设置过期时间为24小时。拦截器在每个请求里读取Header的Authorization字段,去掉Bearer前缀后解析。如果解析成功就把用户ID放入ThreadLocal,如果解析失败则返回401错误。登录注册等少数公开接口需要放行。拦截器注册的时候要特别注意放行顺序。比如用Spring Boot的注册方式,拦截器对所有路径生效,需要在addPathPatterns时设置放行路径列表。我在实际开发中就把文件下载接口忘记放行,结果前端跳转浏览器的下载链接全部被拦截,排查了很久才发现问题是拦截器把所有下载请求拦截了,不仅没有返回文件流,还返回了JSON错误,浏览器直接下载了一个json错误文件。这个经验值得记住。3.2 文件上传与分片秒传实现文件上传是最容易出问题的模块。常规上传,后端接口就是接收MultipartFile参数,获取文件名和大小,然后把文件写到磁盘上,再保存一条记录到数据库。但真实业务里,大文件上传如果不做处理,用户传几个GB的文件基本必失败,不是连接超时就是内存溢出。我的方案是分片上传。前端把文件切片,每片大小5MB,然后循环调用上传接口,上传接口接收文件分片,以及一个分片编号,还有一个上传ID用来标识同一个文件的分片集合。每个分片作为临时文件存到临时目录,全部上传完成后,前端调用合并接口,后端把所有分片按编号拼接成一个完整文件。这样即使传到一半网络断了,也能重新上传未完成的分片,而不需要重头再传。这里核心代码如下,Controller接收分片时用到MultipartFile和@RequestParam:@PostMapping("/upload/chunk") public Result uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("uploadId") String uploadId, @RequestParam("chunkIndex") int chunkIndex) { String tempDir = storageConfig.getRootPath() + "/tmp/" + uploadId; File dir = new File(tempDir); if (!dir.exists()) dir.mkdirs(); file.transferTo(new File(dir, String.valueOf(chunkIndex))); return Result.ok(); }秒传功能就是另一个思路了。上传前前端用JavaScript读取文件的MD5值,调用后端接口查询该文件是否已经存在于共用文件库中。如果存在,直接给当前用户生成一条文件记录,不再写入实际文件。我做的个人云盘系统里没有做全局去重库,但可以先在用户自己的文件目录里查询,如果同MD5文件已存在,则直接建立引用关系,避免重复存储。实际开发时推荐加一个独立的文件指纹表,字段包括MD5、文件大小、物理路径、被引用次数,这样才能支撑真正的秒传。分片上传的合并操作要小心,不要把所有分片内容一次性读入内存,应该用InputStream循环读取每个分片文件,然后按顺序写入OutputStream。合并完成之后,再删除临时分片文件。这个过程中还要注意处理并发上传同一个上传ID的情况,避免合并时文件被占用。3.3 文件下载与在线预览文件下载接口的核心是返回文件流。我用了Spring的ResponseEntity ,读取文件为Resource,设置Content-Type和Content-Disposition。这里的坑主要在中英文文件名。中文文件名如果直接放到Content-Disposition里,不同浏览器展示效果不一样,最稳妥的做法是用URLEncoder编码,然后前端或者浏览器会自动解码。另外自己拼接HTTP响应头时,要注意header不能包含换行符,否则会在安全扫描中被判为注入漏洞。在线预览的话,图片和文本最简单。图片直接通过接口返回原始二进制,前端用一个组件接收blob流来展示。文本文件读取时要注意编码,UTF-8文件正常,GBK文件会出现乱码。我的处理方式是先通过工具读取文件的前几百个字节尝试判断编码,然后按对应编码读取内容再转成UTF-8返回给前端。这样预览体验会好很多。PDF和Office文件预览要复杂一些,需要第三方组件转码或者使用在线预览服务,依赖环境,我在毕设项目中就没有做复杂的在线编辑,只展示了图片、文本、视频音频这几种常见的。3.4 文件管理接口设计文件管理包括根目录列表、进入子目录、新建文件夹、重命名、移动文件、删除文件。接口设计其实都围绕文件表。根目录列表就是查询user_id=当前用户ID AND parent_id=0 AND del_flag=0的所有文件。进入子目录就是查询parent_id=某个ID的文件列表。新建文件夹就插入一条folder_flag=1的记录。重命名很简单,直接更新file_name字段。移动文件就是修改parent_id。删除文件则是逻辑删除。文件夹的删除是特殊的,因为它是递归结构。后端删除文件夹时,不能只删当前记录,需要先把文件夹下所有子文件找出来。我的处理办法是用一个递归方法,先查询当前文件夹的所有直接子节点,对每个子节点,如果是文件夹则递归调用,如果是文件则删除当前记录,最后再删除当前文件夹记录。在逻辑删除模式下,也可以只删除当前文件夹记录,然后让子文件的信息在文件夹树中"悬挂",但这种状态恢复时很容易出错,还是老老实实递归处理。前端展示文件树时,我建议不要一次性把所有层级的文件夹全部查出来。可以按需加载,点击文件夹时请求一次子文件接口。这样接口简单,数据量也小。如果确实需要展示整棵树,那就后端递归组装树形JSON,前端用Vue的递归组件渲染。3.5 分享链接生成与回收站逻辑分享链接我在实现时用了两个随机值。一个是share_token,作为链接的唯一标识,直接放在URL里;另一个是extract_code,作为输入提取码,用户访问分享页面时必须输入。分享者可以选择分享有效天数,比如1天、7天、永久。对应后端记录过期时间。校验时先比对share_token是否存在,然后比对提取码,最后判断是否过期。分享链接的生成逻辑很简单,核心就是创建一条分享记录:public Share createShare(Long userId, Long fileId, Integer expireDays) { Share share = new Share(); share.setUserId(userId); share.setFileId(fileId); share.setShareToken(UUID.randomUUID().toString().replace("-", "")); share.setExtractCode(RandomUtil.randomNumbers(4)); if (expireDays != null && expireDays > 0) { share.setExpireTime(LocalDateTime.now().plusDays(expireDays)); } share.setStatus(1); shareMapper.insert(share); return share; }回收站逻辑我在文件表里用del_flag实现,文件表里有一个delete_time字段,方便定时清理超过保留期的记录。回收站页面复用文件列表接口,只不过查询条件换成了del_flag=1,并且按删除时间倒序。恢复操作和删除正好相反,将del_flag重置为0。彻底删除时,后台会同时调用物理文件删除方法。如果文件在多个地方被引用(比如秒传引用),物理文件删除还需要处理引用计数,不能直接删,否则可能导致其他用户文件缺失。我的系统没有做跨用户引用,所以没有这个顾虑,但做扩展时要注意。4. 实操过程中的关键配置与调试4.1 核心依赖与配置文件这个项目的pom.xml里,依赖不需要很多,主要包括spring-boot-starter-web、spring-boot-starter-validation、mybatis-plus-boot-starter、mysql-connector-java、jjwt、hutool-all、lombok。hutool这个工具包帮了很大忙,生成随机数、计算文件MD5、日期工具都用它,省下大量重复代码。application.yml这里没有特别难的东西,但有几个配置容易出错。第一个是数据源url,数据库名要和本地建的一致,时区必须加serverTimezone=Asia/Shanghai,否则JDBC连接会报时区异常。第二个是MyBatis Plus的mapper-locations配置,如果XML文件没放到默认classpath下,需要手动指定。第三个是文件上传大小,我设置了max-file-size: 512MB,max-request-size: 1GB。第四个是自定义存储路径,Spring Boot里使用@ConfigurationProperties读取,比如cloud.storage.root-path: ./data。这里的相对路径要小心,启动时的工作目录不同,会指向不同位置,建议部署时使用绝对路径。我贴上最核心的配置片段,可以直接改用自己的:spring: servlet: multipart: max-file-size: 512MB max-request-size: 1GB datasource: url: jdbc:mysql://localhost:3306/cloud_disk?serverTimezone=Asia/Shanghai username: root password: 123456 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl cloud: storage: root-path: ./data4.2 CORS跨域问题前后端分离后,跨域问题基本是必踩的。如果前端项目跑在http://localhost:8080,后端接口在http://localhost:8081,这时候前端直接调后端接口,浏览器就会因为跨域拦截。解决方式是在后端写一个配置类实现WebMvcConfigurer,在addCorsMappings方法里配置允许的源、方法、请求头。我当时的经验是,允许源可以直接用allowedOriginPatterns("*"),开发环境这么配最省事。allowedHeaders需要把Authorization放进去,否则带了Token的请求会被浏览器预检拦下来。配置类写起来也不复杂,直接贴出来:@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }另外,如果自己实现了拦截器,跨域配置和拦截器之间还有顺序问题。前端的预检请求是OPTIONS方法,拦截器如果拦了OPTIONS请求,就会导致预检失败。解决办法是在拦截器里面判断请求方法,如果为OPTIONS则直接放行。4.3 上传大小限制的完整配置Spring Boot对文件上传的限制来自两个地方。一是Spring MVC的配置,spring.servlet.multipart.max-file-size和max-request-size,默认只有1MB和10MB,不修改的话传大文件马上报FileSizeLimitExceededException。二是Tomcat服务器本身的连接器限制,如果超过这个限制,可能返回Connection reset,前端看起来像是连接被服务器断开。我在做分片上传时,因为每一片只有几MB,主要是把分片大小调好,整体上传接口的max-file-size还不能设太小,否则即使分片没超过限制,后端临时文件处理也可能不被允许。综合下来,我直接把单文件上传限制改成512MB,请求总大小改成1GB,基本能满足大多数使用场景。这里补充一个容易忽略的知识点:Spring Boot 2.x以上版本中,如果使用了自定义的MultipartConfigElement配置类,它的优先级会覆盖配置文件中的spring.servlet.multipart配置。我建议不要同时用两种配置,只保留yml配置就好,否则出了问题排查时会很困惑。4.4 日志配置与接口调试调试阶段强烈建议把日志级别调到DEBUG,至少在开发环境要调。MyBatis Plus会在控制台打印SQL日志,你可以看到执行的每条SQL,对于排查数据有没有查对很有帮助。另外可以开启Spring Boot的web日志,查看请求和响应的基本状态。我习惯把项目里所有Controller接口的出入参在AOP里打一条日志,打印请求地址、请求参数、耗时,这个做法在联调时省了无数口舌。前端调试我推荐用浏览器开发者工具配合Postman。遇到上传类接口异常,先看请求体里file字段名称和后端MultipartFile参数名是否一致,不一致的话后端拿到的file就是null。遇到下载接口异常,直接用Postman请求下载地址,看返回的Content-Type和Content-Disposition是不是正确。如果返回的是JSON错误,则说明请求被拦截器或者异常处理接管了。5. 常见问题与避坑指南5.1 中文文件名乱码问题这个几乎是所有网盘类项目都会碰上的问题。表现是前端上传中文文件,后端保存到磁盘后文件名变成乱码,下载时设置的文件名也是乱码。原因有两层,一是HTTP传输时编码不是UTF-8,二是Content-Disposition头里不能直接放中文。解决办法是前端在FormData里正常传文件名,后端获取文件名后,到输出响应时用URLEncoder.encode(fileName, "UTF-8")重新编码。如果保存到磁盘的文件名乱码,检查一下服务器操作系统字符集和Tomcat的URIEncoding配置,通常设成UTF-8就能解决。我写下载接口的时候,对响应头做了一定处理,关键在于文件名要编码,而不是直接拼接:String encodedFileName = URLEncoder.encode(fileName, "UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");5.2 跨域配置正确但请求依然失败遇到过一次很迷惑的问题:跨域配置看起来没问题,但前端带Authorization的请求依然失败。后来发现是拦截器把OPTIONS预检请求拦了。浏览器在发起复杂请求时会先发一个OPTIONS请求,如果后端返回的不是成功响应,真实请求就不会发出。解决办法很简单,拦截器里对OPTIONS请求直接返回成功。这个坑我之前提过一次,但值得再强调,因为排查时很容易忽略拦截器的存在。5.3 文件路径与部署环境不兼容本地写的是绝对路径,比如某个盘符下的目录,部署到Linux服务器上直接找不到路径。这是因为操作系统的路径分隔符和磁盘挂载方式完全不同。规范做法是数据库存相对路径,根目录通过配置文件指定。我在制作这个系统时就把存储根目录放到配置文件里,所有物理文件写入时获取根目录再拼相对路径,这样换机器只需要改一个配置项。另外,在Java里拼接文件路径时不要手写斜杠,统一用Paths.get或者File.separator,否则在不同操作系统上会出现斜杠方向不一致的问题。5.4 回收站恢复后文件夹结构错乱恢复一个处于回收站的文件夹时,如果原本的父目录已经被彻底删除了,会出现一条记录指向一个不存在的父目录。前端打开这个文件夹就会查不到数据。解决办法是,恢复时先检查父目录是否存在,如果不存在则把parent_id置为0,也就是回到用户根目录。顺带还要检查重命名冲突,如果目标位置已经有同名文件,恢复时自动在文件名后面加一个后缀。5.5 文件流下载时无法终止连接下载大文件时,如果用户点击了取消,后端的输出流可能还在写入,最终导致链路占用很久。如果并发下载的人多,连接数会飙升。我给下载接口的ResponseEntity设置缓冲区,并且前端取消时浏览器会关闭连接,后端需要在写入时处理IOException,遇到异常就及时退出循环,不要继续写。另外可以考虑用Servlet的异步输出做高并发优化,但这个系统作为个人云盘,并发量很小,不用过度设计。这套个人云盘管理系统做下来,我个人体感最难的其实不是写代码,而是把文件操作的逻辑想清楚,尤其是目录层级、回收站、分享这些和“文件关系”相关的功能。一旦数据表设计合理,后端的每个接口都很直白;如果一开始就草草建表,后面补字段、改逻辑会非常痛苦。最后分享一个实用小技巧:在开发时可以提前在本地准备一些不同类型的测试文件,比如大文件、图片、中文文件名、无扩展名文件,上传下载和预览每个环节都测一遍,很多问题是在这些边界文件上会暴露出来。希望这篇文章能帮到准备做类似项目的朋友。