ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot+Vue+OSS直传:网盘项目全栈实战

Spring Boot+Vue+OSS直传:网盘项目全栈实战 简介这是一套基于Spring Boot、Vue与阿里云OSS实现的网盘系统完整前后端分离源码及数据库脚本面向需要快速搭建私有网盘或学习主流JavaVue全栈项目的开发者。前端采用Vue、Vuex、axios与Ant Design of Vue后端基于Spring Boot整合MybatisPlus、Redis与JWT包含文件上传下载、用户认证等核心逻辑。资源共198个文件压缩包约261KB其中包含117个XMLMybatis映射与配置、33个Java后端业务与工具类、17个JS与8个Vue前端页面与组件、1个SQL数据库脚本等结构清晰便于按模块研读。已有162人浏览学习。通过这份源码读者可以掌握前后端分离接口设计、OSS对象存储集成、Redis缓存使用、JWT权限控制以及项目配置与部署思路适合毕业设计、课程项目或入职前项目复盘参考。1. 基于Spring Boot Vue 阿里云OSS实现的网盘项目文件不落本地磁盘以毕业设计和课程设计为起点把文件直接传到本地服务器磁盘是最常见的错误做法。真正的网盘项目里文件本身不进入应用服务器磁盘而是由后端生成上传签名前端把文件直传阿里云OSSMySQL只存一张文件清单记录文件名、大小、OSS路径、上传人和父目录。这种三段式组合的好处是应用服务器不需要处理大流量的二进制流带宽成本低OSS自带分片和断点续传权限控制放在数据库里反而更直观。这个项目适合两类人一是正在做Java毕设或数据库课程设计的学生能完整跑通注册、上传、列表、下载、删除的闭环二是想快速搭一个团队内部小网盘的在职工程师把SSO、审计、配额这些企业级能力留到后续迭代。接下来按架构选型、后端、前端、数据库、部署验证的顺序把这套东西拆开讲。2. 网盘项目架构与选型为什么文件走OSS直传数据库只存元数据清单2.1 三种文件上传方案对比直传为什么是网盘标配先把方案摊开看。第一种是服务端中转浏览器先POST到Spring BootSpring Boot再用MultipartFile.getInputStream()写入本地磁盘或转发给OSS。这种方案在课程设计里最常见但并发稍高就会出现两个问题上传接口长时间被占用应用服务器磁盘被视频、压缩包塞满。第二种是后端拿文件再传OSS相当于把应用服务器当了一回快递员多一次读写还会产生临时文件。第三种是前端直传浏览器直接往OSS发起PUT或POSTSpring Boot只负责签发一个带时效的签名URL。数据流向从“浏览器-后端-OSS”变成“浏览器-OSS”后端只参与签名和记录两条元数据。我用一个表格把差异列清楚答辩时被问到“为什么选直传”可以直接对着讲。方案带宽占用应用服务器负担上传速度断点续传实现复杂度服务端中转全部经过后端高磁盘IO密集慢一倍需自己实现低后端转发OSS全部经过后端中多一次IO慢依赖OSS SDK中前端直传OSS只占OSS带宽几乎为零快OSS原生支持中高直传能成为网盘项目的主流方案不是因为时髦而是因为成本结构合理。上传下载产生的流量费用由OSS承担后端只处理KV大小的JSON应用服务器可以保持很低的配置。项目中需要四个角色配合Vue负责选文件和展示进度Spring Boot负责身份校验和生成签名OSS负责对象存取MySQL负责记录文件清单。理解了这个链路后面所有代码都是在把这四个角色串起来。提示确保OSS Bucket的权限为私有签名URL是唯一的访问入口。公有读会让objectKey被枚举后直接下载这是课程设计答辩时第一个会被追问的点。2.2 数据库的职责边界存文件清单不存二进制很多同学设计表的时候喜欢给file表加一个file_data字段类型选LONGBLOB。这个字段一旦出现就说明项目设计上还在把数据库当文件系统。正确的边界是数据库只存“这张清单”二进制对象全部交给OSS。需要存的是OSS对象名也就是objectKey例如2024/08/12/u123/avatar.png而不是一个完整的URL。为什么不直接存签名URL签名URL默认在15分钟到1小时后过期数据库里存一个必然失效的字符串等于埋雷。而且URL会暴露bucket名称和objectKey的目录结构配合AccessKey泄露场景很容易被枚举遍历。正确做法是存objectKey需要下载时再实时生成签名URL。另一个好处是换桶、换region不需要改历史数据只要改配置里的endpoint。2.3 建表语句user、file_info 的最小可运行结构项目里通常会带一份init.sql我一般把它收敛成两张表就能跑通全部功能。用户表负责登录态文件表负责元数据和目录树。CREATE DATABASE IF NOT EXISTS netdisk DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE netdisk; CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password_hash VARCHAR(64) NOT NULL COMMENT 建议BCrypt加密不要存明文, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE file_info ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL COMMENT 文件所属用户, parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 虚拟目录的父ID0表示根目录, file_name VARCHAR(255) NOT NULL COMMENT 展示用文件名, object_key VARCHAR(512) NOT NULL COMMENT OSS对象名不含域名, file_size BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 字节数必须用BIGINT, md5 CHAR(32) DEFAULT NULL COMMENT 秒传与去重用, is_dir TINYINT NOT NULL DEFAULT 0 COMMENT 1表示目录, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除标记, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_parent (user_id, parent_id), KEY idx_md5 (md5) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两张表建出来后上层代码就只围绕object_key和deleted标记转。user.password_hash字段建议用BCrypt因为课程设计常常会被问“为什么不用MD5”MD5配合彩虹表很容易被反推。parent_id用于实现“网盘里的文件夹”文件夹不占用OSS资源只需要在数据库里有一行is_dir1的记录。deleted字段做逻辑删除而不是物理删除原因在后面章节展开。到这里架构立住了接下来进入Spring Boot后端。3. Spring Boot后端实现上传签名、分页列表与下载跳转3.1 引入OSS依赖与配置项pom.xml里加入阿里云OSS的SDK版本号以Maven中央仓库为准。注意Spring Boot 3.x要求JDK17如果本机还是JDK8就锁Spring Boot 2.7.x这个版本选择问题在“springboot版本太高”的场景里很常见。dependency groupIdcom.aliyun.oss/groupId artifactIdaliyun-sdk-oss/artifactId version3.17.4/version /dependencyapplication.yml里把OSS的配置集中管理。常见做法是建一个OssProperties配置类用ConfigurationProperties注入而不是在每个Controller里直接用Value拼字符串。aliyun: oss: endpoint: oss-cn-hangzhou.aliyuncs.com access-key-id: ${OSS_ACCESS_KEY_ID} access-key-secret: ${OSS_ACCESS_KEY_SECRET} bucket-name: netdisk-bucketAccessKey不要写到代码里用环境变量占位符引用。原因是项目一旦要交到答辩或GitHub明文AccessKey会直接被爬虫扫到别人可以用它往你的bucket里塞任意数据。如果项目用了RAM子用户建议只给目标bucket的读写权限。OSSClient建议以单例Bean的方式注册并指定destroyMethod确保Spring容器关闭时连接池被释放Data Component ConfigurationProperties(prefix aliyun.oss) public class OssProperties { private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; } Bean(destroyMethod shutdown) public OSSClient ossClient(OssProperties props) { return new OSSClient(props.getEndpoint(), props.getAccessKeyId(), props.getAccessKeySecret()); }连接池不关的问题是本地跑几天后接口越来越慢最后报Connection pool shut down这是OSS集成里常见的隐性故障。3.2 生成上传凭证让前端把文件直传OSS后端需要提供两个接口一个是生成上传凭证一个是生成下载URL。上传凭证的核心在于用OSSClient生成预签名URL再把URL和objectKey一起返回给前端。RestController RequestMapping(/api/oss) public class OssController { private final OSSClient ossClient; private final OssProperties props; PostMapping(/policy) public ResultUploadPolicy policy(RequestBody UploadRequest req, RequestAttribute Long userId) { String fileExt StringUtils.getFilenameExtension(req.getFileName()); String objectKey userId / System.currentTimeMillis() / UUID.randomUUID().toString().replace(-, ) . fileExt; Date expiration new Date(System.currentTimeMillis() 15 * 60 * 1000); GeneratePresignedUrlRequest request new GeneratePresignedUrlRequest(props.getBucketName(), objectKey) .withMethod(HttpMethod.PUT) .withExpiration(expiration); String signedUrl ossClient.generatePresignedUrl(request).toString(); UploadPolicy policy new UploadPolicy(signedUrl, objectKey, expiration.getTime()); return Result.ok(policy); } }这里的第一个坑是方法类型generatePresignedUrl默认生成GET请求必须显式设置withMethod(HttpMethod.PUT)否则前端用PUT上传时会收到403 SignatureDoesNotMatch。第二个重点是objectKey的拼接userId加毫秒时间戳加UUID保证同一用户并发上传同名文件时不互相覆盖。真正的文件显示名存在数据库file_name字段objectKey只是存储路径两者解耦后重名问题就转移到了数据库层处理。下载接口的思路相反先从数据库查到objectKey再实时生成一个GET签名URL。如果希望浏览器触发下载而不是预览需要在生成URL时额外设置Content-Disposition响应头。简单项目里我会直接让前端用window.open(signedUrl)图片和PDF可以直接预览其他类型浏览器会按附件下载。3.3 文件列表分页MyBatis-Plus 的简要实现文件列表是网盘最核心的查询分页参数直接暴露给前端。这里用MyBatis-Plus的LambdaQueryWrapper单表查询不需要写XML。public PageResultFileVO listFiles(Long userId, Long parentId, int page, int size) { PageFileInfo p new Page(page, size); LambdaQueryWrapperFileInfo wrapper new LambdaQueryWrapper(); wrapper.eq(FileInfo::getUserId, userId) .eq(FileInfo::getParentId, parentId) .eq(FileInfo::getDeleted, 0) .orderByDesc(FileInfo::getCreatedAt); PageFileInfo result fileInfoMapper.selectPage(p, wrapper); ListFileVO records result.getRecords().stream().map(f - { FileVO vo new FileVO(); vo.setId(f.getId()); vo.setFileName(f.getFileName()); vo.setFileSize(f.getFileSize()); vo.setIsDir(f.getIsDir()); vo.setCreatedAt(f.getCreatedAt()); return vo; }).collect(Collectors.toList()); return new PageResult(records, result.getTotal()); }分页参数size建议封顶200防止前端传一个100000把数据库打满。parentId不传时后端默认按0处理也就是查根目录。file_size在VO里映射成Long对应数据库的BIGINT不要用Integer否则单文件超过2GB时前端拿到的数字是错的。3.4 登录鉴权JWT 拦截器的做事方式上传、下载、列表接口都应该处于登录态之下。常见做法是用JWTHandlerInterceptor里从Header取出token解析出userId塞进RequestAttribute。比Session方案好的点是前后端分离部署时不需要考虑Session共享问题。OSSClient的签名和所有接口的鉴权都在这一层统一收口后面新增“分享链接”功能时只需要再生成一个带短token的下载URL不必改动OSS侧。到这里后端能跑了但前端才是这个项目“看起来像网盘”的关键。4. Vue前端实现文件列表、直传组件与跨域配置4.1 初始化Vue项目与依赖安装Vue 3项目用Vite创建比Vue CLI更轻。进入项目目录后安装四个依赖axios负责HTTP请求element-plus负责表格和上传组件hls.js负责m3u8格式的在线预览pinia负责保存登录态。npm create vitelatest netdisk-web -- --template vue cd netdisk-web npm install axios element-plus hls.js pinia安装依赖时最容易遇到的是网络超时npm默认源在部分网络环境下不稳定可以临时换一个速度更快的镜像源但不要把它写进package.json。Vue 2老项目里element-ui已经停止维护新项目直接用element-plus组件名和API基本兼容。main.js里记得use(ElementPlus)否则组件标签不会被解析。4.2 上传组件先拿签名再用XHR直传上传按钮的逻辑分两步先请求后端 /api/oss/policy拿到签名URL和objectKey再把文件用PUT发给OSS。这里用原生XMLHttpRequest而不是axios因为XHR对上传进度事件的支持更完整axios需要额外配置onUploadProgress回调且细节较多。// components/FileUpload.vue 核心逻辑 import axios from axios function uploadFile(file) { const formData { fileName: file.name } axios.post(/api/oss/policy, formData, { headers: { Authorization: Bearer localStorage.getItem(token) } }).then(res { const { signedUrl, objectKey } res.data.data const xhr new XMLHttpRequest() xhr.open(PUT, signedUrl, true) xhr.setRequestHeader(Content-Type, file.type || application/octet-stream) xhr.upload.onprogress (e) { if (e.lengthComputable) { state.progress Math.round((e.loaded / e.total) * 100) } } xhr.onload () { if (xhr.status 200) { saveRecord(objectKey, file) } } xhr.send(file) }) } function saveRecord(objectKey, file) { axios.post(/api/file/record, { objectKey, fileName: file.name, fileSize: file.size }, { headers: { Authorization: Bearer localStorage.getItem(token) } }).then(() { state.$emit(refresh) }) }两个不能省略的细节。第一Content-Type必须和后端生成签名时预期的一致否则OSS会返回SignatureDoesNotMatch最常见的就是传图片时前端给了application/octet-stream而后端签的是image/jpeg。第二saveRecord在前端直传成功后才调用这一步决定“对象已经存在于OSS但数据库里还没有记录”也就是孤儿对象后面章节单独讲。进度条直接用element-plus的el-progress百分比绑定state.progress即可。上传过程中用户退出页面会导致上传中断这是直传方案的固有短板被追问时可以答“用分片上传任务队列管理”来兜底。4.3 文件列表渲染与下载跳转列表页用el-table展示点击目录行时用vue-router跳转并把parentId放在路由query里列表组件watch一下route.query的变化重新拉数据这就用上了vue-router的路由参数传值。async function downloadRow(row) { if (row.isDir 1) return const res await axios.get(/api/oss/download/${row.id}, { headers: { Authorization: Bearer localStorage.getItem(token) } }) window.open(res.data.data.signedUrl, _blank) } function openDir(row) { router.push({ query: { parentId: row.id } }) }这里不要把objectKey直接拼进URL。签名URL只能由后端生成AccessKey如果出现在前端代码里等于把bucket的管理权限直接送人。video标签里最常见的m3u8文件用hls.js的Hls.isSupported()绑定video再加载签名URL即可播放搜索“vue播放m3u8”基本都是这个方案。4.4 开发环境的跨域转发与打包后布局异常后端接口统一以/api开头开发环境在vite.config.js里配置devServer.proxy把/api请求转发到localhost:8080避免浏览器CORS报错。export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })changeOrigin必须为true否则后端拿到的Host是localhost:5173部分后端框架会在此处直接拒绝请求。打包后如果部署到Tomcat的webapps子路径下vue-router的history模式会白屏布局整个错乱。常见做法是把createWebHistory改成createWebHashHistory或者给createWebHistory传入base参数。部署到OSS静态网站托管时把publicPath设为./否则CSS和JS的引用路径为绝对路径子目录部署时布局绝对异常这个坑搜索“vue 打包后 布局异常”能找到大量同款。5. 数据库层的文件删改查删除顺序、MD5去重与索引优化5.1 删除文件的正确顺序先删数据库记录再删OSS对象很多网盘项目会在删除接口里先调ossClient.deleteObject再执行mapper.deleteById。单机演示时看不出问题一旦OSS删除成功而数据库删除失败数据库里就残留了一个永远下载不了的文件记录用户看到404后反复点击删除每次都报错。反过来先删数据库记录再删OSS对象如果OSS删除失败最多只是OSS里有一个无主对象不影响用户看到的列表也容易通过定时任务清理。Transactional public void deleteFile(Long userId, Long fileId) { FileInfo file fileInfoMapper.selectById(fileId); if (file null || !file.getUserId().equals(userId)) { throw new BizException(文件不存在); } fileInfoMapper.deleteById(fileId); TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { ossClient.deleteObject(props.getBucketName(), file.getObjectKey()); } }); }这里的代价是OSS对象删除可能失败但业务上文件已不可见属于可接受的最终一致。Transactional对OSS调用没有意义OSS服务不受本地事务控制所以必须把删除动作挂到afterCommit回调里。这在Spring Boot面试题里也是事务与外部服务一致性问题的常见变体能答出afterCommit回调的深度会比只说“先删数据库再删OSS”高半级。5.2 重名文件与MD5秒传同一个文件的两种处理方式网盘里用户上传了同名文件常见做法不是覆盖而是“重名加序号”。更进阶一点的是秒传上传前先计算文件MD5后端去file_info表里查一下如果同一用户下已有相同MD5且未删除的文件直接返回已存在前端提示“秒传完成”。SELECT id, file_name, object_key FROM file_info WHERE user_id #{userId} AND md5 #{md5} AND deleted 0 LIMIT 1;秒传的前提是上传前在前端计算文件MD5大文件计算会比较慢需要配合Web Worker避免阻塞UI。很多项目不做秒传只做“查询已存在同名文件时拒绝上传”那就不需要MD5直接查file_name缺点是两个内容不同但文件名相同的文件无法共存。保留md5字段并在上传完成后异步回填至少为后续去重审计留了条路。5.3 file_info 表的索引与字段类型陷阱列表接口的WHERE条件是user_id parent_id deleted正好对应建表语句里的idx_user_parent联合索引。如果以后加“最近上传”排序created_at上的普通索引也能覆盖。注意file_size字段有些项目图省事用INT单个文件超过2GB时INT会溢出插入直接报错而错误往往在文件传到一半才暴露。OSS的上传接口本身支持5GB以内单文件直传超过5GB必须走分片数据库字段类型和OSS限制要一起设计。另一个高频坑是字符集。建表用了latin1或utf8中文文件名和包含emoji的文件名会变成乱码或直接报Incorrect string value建库时必须显式指定utf8mb4。还有DATETIME的默认值MySQL 5.6之前不支持DEFAULT CURRENT_TIMESTAMP写法部署到旧版本数据库时会直接报错新项目用MySQL 8没有这个问题。5.4 逻辑删除与物理删除的取舍deleted0的标记是表设计里故意加的。网盘产品的误删恢复是刚需直接DELETE后数据就没了。加了deleted字段回收站功能只需要把查询条件改成deleted1。代价是查询条件多了一个等值判断但配合联合索引完全可接受。逻辑删除会带来唯一索引冲突的新问题。如果file_info表在(user_id, file_name)上有唯一索引用户删除文件后再上传同名文件插入会因为与deleted1的那行冲突而报错。解决思路有两个唯一索引改成(user_id, file_name, deleted)的组合但同一父目录下不能同时存在两个已删除的同名文件或者干脆不加唯一索引用MD5和file_name双查询保证业务幂等。网盘场景建议选第二种第一种会让回收站里同名文件互相覆盖语义更混乱。6. 落地技巧用curl验证上传链路分片策略避开15分钟签名过期6.1 先手动验证整条链路再写前端配好后端和OSS后不要急着写Vue先用curl把链路打通。第一步请求后端拿签名URL第二步PUT上传测试文件第三步用签名URL下载回来对比内容。# 1. 获取上传签名 curl -X POST http://localhost:8080/api/oss/policy \ -H Content-Type: application/json \ -H Authorization: Bearer token \ -d {fileName:test.txt} # 2. 用返回的signedUrl上传文件 curl -X PUT 上一步返回的signedUrl \ -H Content-Type: text/plain \ --data-binary test.txt # 3. 拉回文件校验 curl -o /tmp/download.txt signature URL这个验证过程的效率远高于在浏览器里反复点击上传。403时先看签名URL里的Expires时间405时检查HTTP方法409通常是objectKey冲突这三种状态对应的后端问题分别是过期时间太短、签发接口没指定PUT、objectKey生成逻辑没加时间戳。6.2 大文件上传与15分钟凭证过期后端把签名过期时间设成15分钟是安全与易用性的常见平衡。但一个2GB的视频文件直传OSS网速一般时很容易超过15分钟上传到一半URL过期直接失败。常见做法是前端分片把文件切成4MB一块分片上传每片拿到独立签名避免单次签名过期。async function uploadChunks(file, objectKey) { const chunkSize 4 * 1024 * 1024 const chunks Math.ceil(file.size / chunkSize) for (let i 0; i chunks; i) { const start i * chunkSize const end Math.min(file.size, start chunkSize) const blob file.slice(start, end) const policy await axios.post(/api/oss/policy, { objectKey: ${objectKey}/part-${i} }) const xhr new XMLHttpRequest() xhr.open(PUT, policy.data.data.signedUrl, true) await new Promise((resolve, reject) { xhr.onload () resolve() xhr.onerror reject xhr.send(blob) }) } }这个简化版分片有个代价OSS会产生多个part对象需要在所有分片完成后调用completeMultipartUpload合并。直接调OSS的MultipartUpload接口把uploadId和分片序号纳入凭证管理是更规范的落地方式。分片上传的正确验证是下载回来后对文件做md5sum比对而不是只确认HTTP 200。6.3 上线前的一个验证清单部署到服务器后检查三个位置OSS的Bucket权限是否为私有AccessKey是否只授权了目标bucket文件列表接口是否做了用户ID隔离。用第6.1节的curl链路逐项验证能快速定位是后端签名问题还是前端跨域问题这两个问题在网盘项目里占了排错量的大头。本文还有配套的精品资源点击获取
返回列表