尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot+Vue3个人云盘系统实战:文件上传、分片断点续传与JWT鉴权

SpringBoot+Vue3个人云盘系统实战:文件上传、分片断点续传与JWT鉴权 相信不少Java开发者在做完CRUD项目之后都会陷入一种“不知道自己还能做什么”的瓶颈期。我自己也经历过这个阶段。今天想和你聊的是一个很适合用来打破这种僵局的个人云盘管理系统完整技术栈是SpringBoot2 Vue3 MyBatis-Plus MySQL8.0。这篇文章我会把整个项目的设计思路、核心难点、实操细节和踩坑记录都拆开讲清楚无论是拿来练手、写进简历还是学习主流前后端分离开发模式都有比较高的参考价值。个人云盘系统说白了就是自己搭一个百度网盘的简化版。你可以上传文件、创建文件夹、下载文件、删除文件甚至实现分片上传和文件分享。它的价值在于麻雀虽小五脏俱全几乎涵盖了Java Web日常开发的所有核心知识点数据库设计、ORM框架使用、文件流处理、前端交互、接口鉴权、异常处理、部署上线。等你完整走完一遍你会发现自己对“一个Web项目如何跑起来”这件事理解会完全不一样。1. 项目整体设计与技术选型拆解1.1 个人云盘的核心需求边界在动手写代码之前必须想明白一件事你要做的不是一个能对标百度网盘的商业产品而是一个可以跑通核心业务闭环的“个人网盘”。需求一旦失控项目很容易烂尾。我当时的做法是把需求收敛到三个核心模块。第一个模块是用户体系。注册、登录、登出是最基本的登录之后要用JWT生成Token前端拿着Token访问受保护的接口。第二个模块是文件管理这里包括文件上传、文件下载、文件重命名、移动位置、复制、删除、创建文件夹。第三个模块是文件分享生成分享链接和提取码方便别人通过链接访问。这个模块属于加分项但非常能体现你的设计能力。我建议你别一开始就做回收站、秒传、文件预览这些功能这些可以在核心功能跑通后再迭代。记住一个原则先把主链路走通再考虑堆功能。主链路过不了关做再多补充模块都是花架子。1.2 为什么是这套技术栈选型这个话题面试官几乎必问。我当时选这套组合是认真权衡过的。后端用SpringBoot2而不是Spring Boot 3.x主要是考虑到稳定性和生态兼容性。SpringBoot2已经经过大量生产环境验证很多第三方依赖的兼容性问题在2.x版本中都已经踩平了。如果你是新项目其实用Spring Boot 3.x也不算错但我个人认为2.x对国内大多数公司的存量项目来说还是主流。MyBatis-Plus的选择也很现实。它在MyBatis的基础上提供了BaseMapper、条件构造器Wrapper、分页插件、代码生成器这些增强功能能把单表CRUD的开发效率提升不少。有人可能会说JPA不香吗我只能说各有各的适用场景。对个人项目或中小型系统来说MyBatis-Plus的SQL可控性和上手成本确实更友好。前端选择Vue3这是当下前端框架的主流趋势。Vue3的组合式APIComposition API在逻辑复用和代码组织上比Vue2的选项式API更灵活。搭配Vite作为构建工具开发时的热更新速度比Webpack快一个量级。不过要注意Vue3生态和Vue2不完全兼容很多Vue2时代的组件库比如Element UI在Vue3里要换成Element Plus。MySQL选8.0版本是因为它默认使用utf8mb4字符集、支持窗口函数和公共表表达式比5.7强了不少。如果你还在用5.7我建议趁这个项目升到8.0毕竟新项目的起点应该站在当前时代的基准线上。提示如果你的电脑上还没有MySQL8.0安装的时候一定要记得选择“Use Legacy Password Encryption”或者把认证插件设置为mysql_native_password否则后续用一些老客户端或者低版本驱动连接时会报认证失败。1.3 前后端分离的协作模式这套项目的模式是前后端分离的。后端只负责提供RESTful API接口返回JSON数据前端是独立的Vue3应用通过Axios调用后端接口。两者之间通过HTTP协议通信是一种很典型的现代Web协作模式。这种模式带来的第一个好处是职责清晰。后端工程师专注处理业务逻辑和数据前端工程师专注页面交互和用户体验。第二个好处是部署灵活后端可以部署在一台服务器前端构建出来的静态资源可以扔到Nginx里。第三个好处是便于扩展以后要做移动端App或者小程序可以直接复用后端的API。当然前后端分离也会带来一些问题最典型的就是跨域。我在本地开发时前端跑在localhost:5173Vite默认端口后端跑在localhost:8080浏览器直接发起请求会触发CORS跨域限制。解决办法有两个一是在后端配置跨域过滤器二是通过Nginx反向代理把前后端统一到同一个域名下。我本地用的是第一种生产环境用第二种这个后面细说。2. 数据库设计、表结构规划与核心原理解析2.1 用户表与文件表的设计思路个人云盘系统的数据库表不需要很多核心就两张表再配上分享表、分享明细表就能把业务闭环起来。用户表user是系统的地基字段包括主键id、用户名username、密码password需要加密存储、昵称nickname、头像地址avatar、创建时间create_time等。密码加密我当时用的MD5加盐说实话现在回头看安全性只能算及格。更规范的做法是用BCrypt加密Spring Security内置了BCryptPasswordEncoder即使不引入Spring Security单独用jBCrypt库也能实现。加密的逻辑就是在你存储密码的时候不存明文存的是hash值这样即使数据库泄露了攻击者也无法直接拿到用户密码。文件表file_info是核心业务表设计时你需要注意的点比较多。里面通常包含主键id、用户idfile_user_id用来关联文件归属、文件在云盘中的路径file_path、文件的真实存储路径file_store_path、文件名file_name、文件大小file_size、文件类型file_type、上传时间upload_time、存储天数storage_days、分享状态share_status、删除状态del_flag。关于文件路径设计有个理念需要重视要区分“逻辑路径”和“物理存储路径”。用户看到的是云盘内的虚拟目录结构比如“/文档/工作/项目方案.docx”但文件真正存在磁盘上的位置不应该直接使用用户提交的路径而是由系统生成一个唯一标识作为存储名比如通过UUID生成文件名。这样做的原因有两点一是防止重名覆盖二是防止用户提交的路径里包含“../”之类的特殊字符导致路径穿越漏洞。这是一个很典型的安全细节面试官问文件上传时很爱考。还有一个小设计点文件类型不建议从前端传而是后端根据文件扩展名来解析或者用第三方包如Tika识别MIME类型。前端传的content-type是很容易伪造的后端必须自己兜底判断。2.2 为什么选择逻辑删除而不是物理删除文件管理系统中“删除”不是一个简单的DELETE操作。我选择用逻辑删除del_flag字段而不是物理删除有几个考量。第一是安全。用户误删文件之后如果想做回收站功能逻辑删除是天然的实现基础。你把del_flag置为1文件在列表里就不展示了但数据还在用户可以从回收站里恢复。第二是性能。物理删除可能需要级联删除多个关联数据比如分享记录操作成本高而逻辑删除只需要一次UPDATE。第三是可追溯。文件的上传记录、分享记录都是操作审计的重要数据物理删除会把历史完全抹掉。当然逻辑删除也有它的代价最常见的就是分页查询时每个SQL都要手动加“where del_flag 0”条件。但如果你用了MyBatis-Plus这个问题几乎不存在。MyBatis-Plus内置了逻辑删除插件你在配置文件里设定逻辑未删除值0和已删除值1并在实体字段上加TableLogic注解它自动帮你把逻辑删除条件拼接进所有查询SQL里。这个插件我强烈建议你体验一下真的能省很多事。2.3 MyBatis-Plus的高效用法既然技术栈里有MyBatis-Plus我就把自己日常用得最频繁的几个功能点分享出来。首先是BaseMapper接口你的Mapper接口只要继承它就自动拥有了insert、deleteById、selectById、updateById、selectList等基础方法单表CRUD基本连SQL都不用写。其次是条件构造器Wrapper比如你要查询某用户所有未被删除的文件代码可以这么写LambdaQueryWrapperFileInfo wrapper new LambdaQueryWrapper(); wrapper.eq(FileInfo::getFileUserId, userId) .eq(FileInfo::getDelFlag, 0); ListFileInfo fileList fileInfoMapper.selectList(wrapper);用Lambda表达式引用实体字段好处是类型安全字段名如果改了编译期就能发现不会等到运行时报“Unknown column”错误。这个习惯建议你从写第一行代码就开始养成。分页功能也是一个高频需求。MyBatis-Plus使用分页插件只需要两步第一步添加配置类引入PaginationInnerInterceptor第二步在调用时传入Page对象。PageFileInfo page new Page(current, size); LambdaQueryWrapperFileInfo wrapper new LambdaQueryWrapper(); wrapper.eq(FileInfo::getFileUserId, userId) .orderByDesc(FileInfo::getUploadTime); IPageFileInfo result fileInfoMapper.selectPage(page, wrapper);注意MyBatis-Plus的高版本里3.5.3.2之后PaginationInnerInterceptor的构造方式有变化如果引入了JsqlParserSupport相关依赖冲突记得统一版本。3. 后端核心功能实现与关键代码细节3.1 文件上传功能的完整链路文件上传是整个网盘系统最核心的功能也是看起来简单但实际坑最多的功能。前端通过表单方式提交文件后端用MultipartFile接收。但是接收完之后文件应该怎么存有一些细节处理不好后续会有很多麻烦。我先说文件存储目录的规划。我的做法是在服务器指定一个基础目录比如/data/disk然后按照用户id分子目录每个用户一个文件夹。这样设计的好处是显而易见的用户A的文件和用户B的文件物理隔离权限控制清晰后续做用户配额比如每人限制10G的时候只需要统计该用户目录的总大小就行。再说文件名的处理。用户上传的现实文件名如“张三简历最终版.pdf”要存到数据库的file_name字段用于展示给用户但实际存储到磁盘上的文件名我生成的是一个UUID加上原始扩展名如“8f3a2b1c-xxxx.pdf”。为什么要这样设计两个原因防止重名文件互相覆盖以及防止文件名中包含特殊字符导致的安全问题。伪代码逻辑大致如下// 1. 校验文件是否为空 if (file.isEmpty()) { throw new BusinessException(上传文件不能为空); } // 2. 获取原始文件名和扩展名 String originalFilename file.getOriginalFilename(); String extension originalFilename.substring(originalFilename.lastIndexOf(.) 1); // 3. 生成物理存储路径 String storeFileName UUID.randomUUID().toString().replace(-, ) . extension; String userDir uploadDir File.separator userId; File targetDir new File(userDir); if (!targetDir.exists()) { targetDir.mkdirs(); } // 4. 保存文件到本地磁盘 File targetFile new File(userDir File.separator storeFileName); file.transferTo(targetFile); // 5. 将文件元信息插入数据库 FileInfo fileInfo new FileInfo(); fileInfo.setFileName(originalFilename); fileInfo.setFileStorePath(targetFile.getAbsolutePath()); fileInfo.setFileSize(file.getSize()); fileInfo.setFileUserId(userId); fileInfoMapper.insert(fileInfo);这里有个Java开发中非常经典的细节文件路径分隔符。在Windows上是“\”在Linux上是“/”如果你在代码里写死“\”或者“/”另外一个运行环境就会出问题。所以务实用File.separator或者用Java 7之后提供的Paths.get()工具类来做路径拼接。这是一个很小的点但很多人就是这么被坑过的。3.2 文件下载与预览的实现方式下载功能比上传要简单一些。核心思路是根据文件的id查询出文件的物理存储路径然后通过ResponseEntity返回文件流。这里有一个关键点设置Content-Disposition响应头时文件名的中文需要做URL编码否则在浏览器里下载的时候中文名会变成乱码。String downloadName URLEncoder.encode(fileInfo.getFileName(), UTF-8); response.setHeader(Content-Disposition, attachment;filename\ downloadName \);如果要做在线预览功能比如图片预览、PDF预览其实不用后端打印流更简单的方式是前端直接拼接一个接口地址放到img或者iframe标签的src属性里。前提是后端接口要允许GET请求访问文件流同时要去掉Content-Disposition里的attachment改成inline浏览器才会尝试直接渲染而不是下载。但这里我要提醒一个坑不要把文件的物理路径直接暴露给前端。正确做法是提供一个 /file/preview/{fileId} 这样的接口后端根据fileId去数据库查物理路径再流式返回。否则用户直接拿着物理路径去拼接URL等于把服务器目录结构暴露了安全上是重大漏洞。3.3 大文件分片上传与断点续传如果只是上传小文件上面的方案够了。但个人云盘的使用场景中经常要传的视频、压缩包动辄几百MB甚至几个G。普通的上传方式在传输过程中一旦网络中断整个文件就废了必须重新传。这就是分片上传和断点续传的用武之地。前端把大文件按固定大小切成多个分片比如每片5MB然后逐个上传到后端。后端每接收一个分片就把分片保存到临时目录。当所有分片都上传完后前端调用合并接口后端把临时目录里的分片按顺序合并成完整文件。这里要解决的核心问题有三个分片的标识、分片的排序、以及断点后在哪里续传。我的做法是通过文件的MD5值来标识一个完整文件分片序号来标识分片位置。前端在切片之前先计算整个文件的MD5上传每个分片时带上文件的MD5和当前分片的序号。后端收到分片后存放在以“MD5值”命名的临时目录里。续传的核心逻辑是前端每次上传分片之前先调用一个校验接口告诉后端“我要传这个文件这个文件MD5是xxx总共20个分片”。后端检查临时目录里已有哪些分片了把已有分片序号返回给前端。前端过滤掉已存在的分片只传缺的那些。这样一来哪怕传到第18个分片时断网了重新连接后只需要传剩余的2个分片而不是整个文件。这个体验感和性能提升都是巨大的。合并分片的代码逻辑大概是File fullFile new File(finalPath); try (FileOutputStream fos new FileOutputStream(fullFile)) { for (int i 1; i totalChunks; i) { File chunkFile new File(tempDir, i .part); try (FileInputStream fis new FileInputStream(chunkFile)) { byte[] buffer new byte[1024 * 1024]; int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } } }分片合并完成后最好再校验一下合并后文件的MD5是否和前端计算的一致不一致就说明传输过程中数据损坏了需要报错让前端重新上传。这个校验字段我建议务必保留它会让你的分片上传更可信。3.4 JWT鉴权与拦截器配置网盘的大部分接口都需要登录之后才能访问所以需要一个统一的鉴权方案。现在主流的选择是JWTJSON Web Token。JWT工作的链路是用户登录成功后后端生成一个Token字符串里面编码了用户的id、用户名、过期时间等信息。后端用密钥对Token进行签名。前端拿到Token后每次请求在请求头里带上Authorization: Bearer 。后端通过拦截器校验Token解析出当前登录的用户id然后把这个信息放到ThreadLocal或请求上下文里后续代码可以随时获取当前用户。我这里用Shiro或者Spring Security吗没有。原因很简单个人项目用最轻量的方案就够了。我写了一个自定义拦截器实现HandlerInterceptor接口在preHandle方法里完成Token的解析和校验。这样做的好处是代码量少、逻辑透明、方便理解。如果你的项目要做复杂的权限管理比如角色控制、接口级权限那时候再上Spring Security不迟。拦截器配置中有一个特别需要注意的地方登录接口、注册接口以及用于在线预览的文件接口必须放行不需要Token。其他接口一律拦截。这个白名单的配置在注册拦截器时要写严谨registry.addInterceptor(jwtInterceptor) .addPathPatterns(/**) .excludePathPatterns(/api/user/login, /api/user/register, /file/preview/**);另外如果你想在拦截器里就拿到完整请求体因为有些操作可能需要对请求参数做签名校验就需要注意请求体只能读取一次的问题否则会导致后续Controller的参数为null。这个问题的标准解法是使用Spring提供的ContentCachingRequestWrapper或者用OncePerRequestFilter 自定义包装类。个人项目里如果不需要在拦截器读body就绕开这个坑别过度设计。4. Vue3前端核心实现与交互优化4.1 用Vite搭建Vue3项目的基础骨架前端的准备工作第一步是把开发环境跑起来。我用的Vite而不是Webpack因为Vite在开发时按需编译冷启动速度快很多。创建项目的方式很简单npm create vitelatest cloud-disk-web -- --template vue这里加上了--template vue参数创建出来的就是一个标准的Vue3项目骨架。然后安装路由和状态管理npm install vue-router4 piniaVue3对应的路由版本是v4Pinia是Vue3官方推荐的状态管理库用来替代Vue2时代的Vuex。Pinia的API更简洁去掉了mutations的概念直接在store里写action就行。如果你习惯Vuex那套写法刚开始可能会觉得别扭但用一两天就会喜欢上Pinia的清爽。项目的目录结构我习惯按功能模块分而不是按文件类型分。这样做的优点是代码的聚合度高找文件的时候比散乱放容易。components目录放通用组件views目录放页面级组件api目录放接口请求的封装store目录放Pinia状态。个人项目不用分太细分太细反而管理成本高。4.2 组合式API与setup语法糖Vue3和Vue2最大的区别就是组合式API。Vue2里我们写data、methods、computed、watch比较分散。Vue3里可以把一组相关的逻辑全部放在setup里还可以抽成自定义hook函数复用。Vue3.2之后又推出了
返回列表