尧图精选

SpringBoot+Vue3+MyBatis教学资源库开发实战:从选型到部署

🕒 发布时间:2026/10/2 20:00:31 📁 来源:尧图网络
1. 为什么是SpringBootVue3MyBatis教学资源库的技术选型复盘做教学资源库这个项目之前我其实纠结过一段时间。市面上现成的网盘系统、知识付费框架不少但真正落到学校或培训机构内部场景要管课件、PDF、视频、实验指导书还要按课程、专业、教师维度去分类授权通用方案往往要么太重要么授权模型对不上。最后我决定自己写一套技术栈锁定在Java SpringBootVue3MyBatisMySQL前后端分离架构。1.1 这套组合到底解决了什么问题先说说选型逻辑。教学资源库的核心诉求是教师上传资源、学生按课程浏览下载、管理员做审核和统计。这个场景读多写少权限粒度要求不高但必须清晰文件类型多样但以Office文档和视频为主。用SpringBoot做后端不是因为它流行而是因为它能快速把REST接口、拦截器、异常处理、定时任务这些事儿组织得井井有条尤其适合一个人维护的中小型系统。Vue3这边我选的是组合式API加Element Plus。之前用Vue2的选项式API写多了确实有状态管理混乱的问题Vue3的ref和reactive让数据流的可读性强很多。而且Vite构建速度比Webpack快一个量级开发期改代码几乎秒刷新这对反复调接口的联调阶段太重要了。MyBatis作为持久层框架刚开始有人劝我用MyBatis-Plus说CRUD不用写SQL。但我的观点是教学资源库的查询需求看起来很常规实际上一涉及多表关联、动态条件、统计报表MyBatis的XML文件反而更可控。尤其是后期加了资源标签、课程分组、下载日志这些表之后手写SQL的优势非常明显。当然为了避免重复劳动我引入了通用Mapper风格的BaseMapper接口简单的单表操作用注解复杂查询走XML。为什么不用重型的权限框架比如Spring Security OAuth2因为校内系统用不到第三方授权用户就两类教师和管理员学生只读下载。自己写一个基于JWT的拦截器配合数据库角色表完全够用而且代码量少一大半。这个设计在后面排障时也证明了它的简单优势。1.2 前后端分离的目录结构参考很多初学者拿到这种项目最头疼的就是工程结构。我的做法是后端按模块分包前端按功能目录组织。后端Maven工程里controller层只做参数接收和结果封装service层管业务逻辑mapper层只管SQL。一开始就严格分层后面加功能、修bug都不容易拆东墙补西墙。backend ├── src/main/java/com/edu/reslib │ ├── controller # 资源、分类、用户、上传下载接口 │ ├── service # 业务逻辑与事务控制 │ ├── mapper # MyBatis Mapper接口 │ ├── entity # 数据库实体类 │ ├── dto # 前端交互参数对象 │ ├── config # 拦截器、跨域、文件路径配置 │ ├── util # JWT工具、MD5、文件类型判断 │ └── common # 统一返回体、异常处理 └── src/main/resources └── mapper # MyBatis XML文件前端Vue3工程我用Vite初始化目录上不搞花活frontend ├── src │ ├── api # axios请求集中管理 │ ├── router # 路由与守卫 │ ├── store # Pinia状态管理 │ ├── views # 页面视图 │ ├── components # 通用组件 │ └── utils # 封装方法这套结构最大的好处是见名知意。比如后来加一个审核管理功能后端加AuditController前端加views/audit.vue前后端接口路径也能保持/api/audit/**自测时候用Postman打一遍基本不会迷路。1.3 数据库设计中的几个关键决策教学资源库的表结构核心其实就三块用户权限、资源元数据、下载记录。我这里给出最终跑的库表设计要点你可以直接抄。用户表我用了简单的role字段0代表管理员1代表教师2代表学生。本来想建关联表做细粒度权限后来评估了一下需求学生只能看公开资源教师能上传和下载全部课件管理员能审核删除三个角色足够。角色表单独建的话对后续扩展更友好但不建表能减少4次联表查询对于这个小项目来说性能反而更好。资源表的设计是核心我加了这些字段CREATE TABLE resource_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL COMMENT 资源标题, file_name VARCHAR(255) NOT NULL COMMENT 存储文件名, original_name VARCHAR(255) NOT NULL COMMENT 原始文件名, file_type VARCHAR(50) COMMENT 文件扩展名, file_size BIGINT COMMENT 字节大小, category_id INT COMMENT 所属分类, uploader_id BIGINT COMMENT 上传人ID, description TEXT COMMENT 资源描述, download_count INT DEFAULT 0 COMMENT 下载次数, status TINYINT DEFAULT 1 COMMENT 1正常 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意我把file_name和original_name分开。file_name是服务器上实际存储的名字用UUID重命名避免中文文件名和重名覆盖问题original_name前端下载时还原显示。这两个字段不拆开后面做文件去重和防盗链会很痛苦。下载记录表单独建除了能统计资源热门程度更重要的是可以做每日下载次数限制防止某个学生用脚本刷下载把学校带宽打满。2. 后端骨架搭建从启动类到登录鉴权的完整链路后端部分我先把工程跑起来再逐步讲核心代码。如果你已经会用SpringBoot可以直接跳到2.3看鉴权设计如果是新手建议从头过一遍。2.1 启动类与全局配置SpringBoot的启动类没什么特殊关键是application.yml里几个容易被忽略的配置点server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/reslib?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 2048MB max-request-size: 4096MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.edu.reslib.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl简单解读几个关键点。context-path: /api意味着所有接口统一前缀/api前端Nginx转发时只需把/api指向后端端口后面部署省事很多。map-underscore-to-camel-case必须开否则数据库的create_time映射不到实体类的createTime属性。log-impl在开发期一定要开能看到MyBatis实际执行的SQL和传入参数排查问题快很多上线前记得换成Slf4jImpl不然控制台会被日志刷爆。文件上传大小我这里设了2GB因为教学视频动不动就几百兆。但注意max-file-size是单文件上限max-request-size是单次请求上限如果前端一次传多个文件两个都要配够。2.2 用户、角色与JWT登录实现登录逻辑我直接用用户名密码换TokenToken有效期设为8小时。为了保证密码安全数据库存的密码是MD5加盐之后的值加盐字段就存在用户表里虽然盐固定不是最安全的方案但对这种内部系统已经够了。核心JWT工具类我用的是jjwt库代码不长public String generateToken(Long userId, String username, Integer role) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() 8 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }登录接口返回Token的同时把用户基本信息也返回给前端前端存在Pinia里页面顶栏显示某某老师不需要每次请求都重新查用户表。这里有个小坑如果Token过期前端必须能感知并自动跳转登录页。我的做法是axios响应拦截器里判断HTTP状态码遇到401就清空本地状态并跳/login。这个后面细说。2.3 拦截器里的坑放行路径与跨域顺序鉴权拦截器实现HandlerInterceptor重写preHandle方法。但这里有个常见的连环坑如果你同时配置了拦截器和CORS跨域而且拦截器先注册了那么前端从localhost:5173访问后端时OPTIONS预检请求会被拦截器拦下来返回401浏览器就直接报跨域失败了。解决方案有两种要么在拦截器里放行所有OPTIONS请求要么让CORS配置先于拦截器生效。我的做法是两者都做Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } // 校验Token }然后注册拦截器时排除登录、注册、资源列表、文件下载这些公开接口registry.addInterceptor(jwtInterceptor) .addPathPatterns(/**) .excludePathPatterns(/auth/login, /resource/list, /file/download/**, /category/tree);这里就体现了前后端分离需要注意的路径规划。像资源列表和文件下载学生不需要登录也能看就放到公开路径里。但上传和删除必须登录而且上传接口还要额外校验角色。3. 核心业务接口实现上传、分页检索、下载统计资源库除了登录最重要的就是资源的上传下载和检索功能。这些接口看着简单实际写起来有不少细节值得记录下来。3.1 文件存储方案本地磁盘就够别一上来就上MinIO项目里我最初设计的是本地磁盘存储路径配置在application.yml里file: upload-dir: /data/reslib/files access-prefix: /file原理很简单上传时用MultipartFile.transferTo()把文件写到指定目录下载时用Resource返回文件流。但这里有个隐患如果你用Nginx直接代理这个目录别人知道文件名就能下载如果通过后端接口下载就能顺便做下载次数统计和权限控制。所以我的设计是文件不直接走Nginx静态目录而是统一走后端接口/file/download/{id}接口里先查资源状态再决定是否输出文件流。为什么我不建议教学资源库一上来就上MinIO或者OSS因为内部系统的并发量通常很低本地磁盘搭配后端校验完全够用。MinIO的好处在于扩展性和对象存储能力但引入分布式存储意味着要多维护一个服务还要考虑桶权限、断点续传开发成本直接翻倍。系统跑起来一个月后如果确实遇到单机磁盘不够的情况再加MinIO也不迟到时候把FileStorageService抽个接口本地实现换成MinIO实现替换成本并不高。所以代码里我把存储逻辑抽了一个StorageService现在只有本地实现但接口层面已经预留了替换空间这也是老开发常说的先找个最简方案跑通留好演进的可能。3.2 分类树的递归查询与动态条件分页教学资源库的分类一般不超过三级专业、课程、资源类型。前端展示通常要一棵树最方便的方式是后端一次性返回树形结构。我的分类表设计是经典的id、parent_id、name结构查询时查出所有分类到内存中用递归或两层循环组装成树避免数据库递归查询。动态分页用的是MyBatis的where标签。前端传关键词keyword、分类categoryId、文件类型fileType后端拼动态SQLselect idselectResourcePage resultTypecom.edu.reslib.dto.ResourceDTO SELECT r.id, r.title, r.original_name, r.file_type, r.file_size, r.category_id, c.name AS category_name, u.username AS uploader_name, r.download_count, r.create_time FROM resource_info r LEFT JOIN category c ON r.category_id c.id LEFT JOIN user u ON r.uploader_id u.id where r.status 1 if testkeyword ! null and keyword ! AND r.title LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND r.category_id #{categoryId} /if if testfileType ! null and fileType ! AND r.file_type #{fileType} /if /where ORDER BY r.create_time DESC /select这里的LEFT JOIN一定要用因为分类可能被删除联表查询时不会丢掉资源记录。category_name如果为null前端显示未分类即可。分页我用的是PageHelper插件。虽然网上有人说PageHelper有坑但只要注意一点就不会出错PageHelper.startPage()必须紧跟要分页的Mapper查询中间不能有别的查询否则分页会作用到错误的SQL上。比如你在startPage之后先查了一下分类列表再查资源分页就会莫名其妙失效或者限制到了别的查询上。3.3 上传接口的防重名、校验与进度条支持上传接口常见问题面试也常问文件重名怎么办超大文件怎么传我的方案很朴素后端用UUID作为存储名称所以磁盘不会重名业务上在插入资源表前查一下original_name file_size是否已存在如果存在就提示该文件已存在请勿重复上传。文件校验不能只靠前端后端必须重新判断。如果只根据扩展名判断文件类型别人把一个exe改名成pdf就绕过限制了。一种可靠的办法是读取文件的Magic Number也就是文件头几个字节。比如PDF文件头是%PDFJPEG是FF D8 FF。我写了一个简单的工具类public static String detectType(byte[] bytes) { if (bytes.length 4) return unknown; // PDF if (bytes[0] 0x25 bytes[1] 0x50 bytes[2] 0x44 bytes[3] 0x46) return pdf; // ZIP (docx/xlsx/pptx都是zip容器) if (bytes[0] 0x50 bytes[1] 0x4B) return zip; // 其他... }如果检测出来是zip再进一步细分是docx还是xlsx还是zip压缩包这就得解析内部的文件结构了。我没有做那么深实际场景教师上传的基本都是常见格式我在上传时判断检测出的类型和扩展名是否匹配不匹配就直接拒绝。进度条这个功能完全靠前端。我用的是axios上传时监听upload.onprogress事件计算已传字节数除以总字节数。后端不需要额外处理只要确认Nginx没有把请求体缓冲关闭不然进度条会一直卡在0%。下载时的文件名编码也踩过坑。后端下载接口返回文件流时需要设置响应头String encodedFileName URLEncoder.encode(originalName, UTF-8).replace(, %20); response.setHeader(Content-Disposition, attachment; filename*UTF-8 encodedFileName);直接设置filename课件.pptx会在某些浏览器上乱码必须用RFC 5987的filename*格式。这个坑不踩一次很难发现。3.4 下载统计与缓存更新下载次数不能每次下载都UPDATE resource_info SET download_count download_count 1这样在高并发下会有行锁竞争。我这个系统虽然并发低但也不想写太丑。方案是下载记录插入download_log表然后异步更新resource_info的download_count字段。更新操作使用UPDATE resource_info SET download_count download_count 1 WHERE id #{id}这条SQL是原子的而且并发时只会让次数准确累加。热门的资源列表页我在Redis里缓存了前20个热门资源每隔5分钟刷新一次。刚开始做的时候没加缓存结果SQL日志里全是ORDER BY download_count DESC的查询拖慢了主列表速度。加了Redis缓存后首页热门榜的响应时间从80ms降到了5ms。4. Vue3Element Plus前端的开发实录从路由守卫到文件上传前端这块是很多人觉得难的部分其实Vue3的写法熟悉之后很多麻烦来自惯性思维——总想着用Vue2的方式解决问题。4.1 项目初始化与目录调整我用Vite创建项目然后装了vue-router、pinia、element-plus、axios。Element Plus的使用有两种方式全量引入和按需引入。教学资源库这种后台管理项目直接全量引入就行反正最终打包出来Gzip后也就几百KB换来的是不用折腾自动导入插件。路由设置时我把后台布局作为一个父路由里面嵌套资源列表、上传资源、分类管理、用户管理、下载记录等子路由。登录页单独放在外面。这样设计的好处是导航栏和顶栏只写一次子页面内容动态渲染在router-view /里。路由守卫是前端鉴权的重要一环。我的逻辑很简单router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else if (!token) { next(/login) } else { next() } })这里有个细节后端Token过期时间是8小时但前端不知道什么时候过期。所以遇到401时必须在axios响应拦截器里统一处理清除本地token并跳转登录页同时用ElMessage提示登录状态已过期请重新登录。4.2 Axios封装与接口统一管理我不喜欢在组件里直接写axios.get因为接口路径散落各处后期改后端路径得全局搜索。我单独建了src/api目录每个模块一个文件比如resource.jsimport request from /utils/request export function getResourceList(params) { return request({ url: /resource/list, method: get, params }) } export function uploadResource(data) { return request({ url: /resource/upload, method: post, data, headers: { Content-Type: multipart/form-data }, onUploadProgress: (progressEvent) { // 通过回调给页面 } }) }request封装里我要额外做三件事请求拦截器拿localStorage里的token放到Authorization头。响应拦截器统一处理业务码后端返回格式是{ code: 200, data: ... }如果code不是200弹出错误提示。响应拦截器处理401实现自动跳转登录。关于上传进度条onUploadProgress是要放在配置里的。如果你在request封装里想要让每个组件都能感知进度可以用一个简单的回调参数传递。我的做法是上传接口直接接收一个onProgress回调内部调用axios配置。这样页面调用时uploadResource(formData, (percent) { progress.value percent })组件里再把这个percent绑定到el-progress组件上一个带进度条的上传就完成了。4.3 用el-upload还是自定义上传Element Plus的el-upload组件自带默认的上传行为它会自己发请求。如果你把action设成后端地址其实也能用但如果你想统一走自己封装的request就会遇到组件的默认行为和axios拦截器冲突的问题。我的经验是el-upload只负责文件选择不直接发送改为监听on-change事件拿到file对象手动调用uploadResource方法。这样上传的进度、错误处理、成功回调全部能控制不会出现文件传上去了但页面不知道到底传了没有的尴尬。资源列表页的删除按钮我加了二次确认ElMessageBox.confirm。这个虽然看起来啰嗦但真能防止误删——尤其是教师角色手一抖删除整门课的课件那就麻烦了。顺便说一句删除接口后端我做得比较保守管理员可以真删教师只能把自己的资源下架status0不直接删文件这样即使下架错了还能恢复。4.4 分类树与筛选联动前端分类筛选我用的el-tree-select组件可以直接把后端返回的三级分类树数据绑上去。这里有个常见的UX小问题如果选了父级分类计算机学院但资源是挂在Java程序设计这门课下的那么按照category_id ?查出来是子级的父级筛选就失效了。我做了个简单处理选中父级时后端会把它下面的所有子类也一起查出来相当于子树查询。SQL里的写法是在动态条件里加一段AND r.category_id IN ( SELECT id FROM category WHERE id #{categoryId} OR parent_id #{categoryId} OR parent_id IN (SELECT id FROM category WHERE parent_id #{categoryId}) )虽然是子查询但分类表的数据量很小性能完全没问题。5. 部署与运维教学资源库从开发到上线的完整记录项目开发完最刺激的是部署环节。我自己在部署时踩了不少坑这里完整记录一下。5.1 前端打包与Nginx反向代理Vue3项目打包先执行npm run build生成dist目录。这里有个关键配置如果你后端接口带了context-path: /api那么前端静态文件放在Nginx的html/reslib下配置一个location即可。我的Nginx配置核心片段server { listen 80; server_name reslib.example.edu.cn; root /opt/reslib/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意proxy_pass后面的路径写法。http://127.0.0.1:8080/api/这种带斜杠的写法会把/api前缀去掉吗不会因为Nginx的location /api/和proxy_pass带URI时匹配部分的/api/会被替换成代理地址后面的/api/。所以最终到后端的是http://127.0.0.1:8080/api/resource/list正好匹配SpringBoot的context-path。如果你不想让后端有context-path可以反过来这样Nginx更简单。但我的建议是后端保留前缀这样在本地开发时直接用Postman打http://localhost:8080/api也很清晰。上传大文件时Nginx容易报413 Request Entity Too Large那是client_max_body_size没设置。我直接设为2048mlocation /api/ { client_max_body_size 2048m; proxy_request_buffering off; # 关键关闭请求体缓冲才能看到前端进度条 }proxy_request_buffering off这个指令是血泪教训。不关掉的话Nginx会把整个请求体缓冲完再转发给后端前端进度条会一直显示0%直到上传完成才跳到100%。关了之后进度条才流畅。5.2 MySQL连接池和慢查询优化教学资源库上线后第一次大批量导入课件时我发现接口响应变得很慢。排查后定位到两个问题。第一个是连接池参数。SpringBoot默认的HikariCP参数对小型应用是够用的但如果不设置maximum-pool-size默认是10。当上传接口在写文件、同时列表页在查询大字段比如description很长时连接池很容易被打满。我把连接池改成spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000第二个是慢查询。我在MySQL里开启了慢查询日志发现一个奇怪的现象download_log表的查询特别慢。后来一看是因为没有加索引。download_log表最常用的查询是按资源ID统计下载量和按用户ID查下载记录所以我建了两个索引ALTER TABLE download_log ADD INDEX idx_resource (resource_id); ALTER TABLE download_log ADD INDEX idx_user (user_id);加完之后统计接口的耗时从几百毫秒降到十几毫秒。这里也提醒各位小项目也要养成看执行计划的习惯EXPLAIN SELECT ...看一眼有没有走索引比瞎调代码效率高得多。5.3 三个上线后遇到的真实Bug线上跑了两周遇到了几个很有意思的bug值得记录一下。第一个是日期显示相差8小时。数据库时间是对的接口返回也对但前端页面显示少了8小时。原因是SpringBoot默认的Jackson序列化时把LocalDateTime按ISO格式序列化但没有指定时区导致前端按浏览器时区解析时差8小时。解决方案是在application.yml里配置spring: jackson: time-zone: Asia/Shanghai或者在实体类日期字段上注解JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createTime;两者选一个就行我推荐注解方式因为更明确。第二个是跨域问题在部署后依然存在。本地开发因为有Vite代理跨域被绕过了部署到Nginx后前端和后端同域了但其实还有另一个坑如果前端域名是https而后端接口地址写的是http浏览器会直接拦截混合内容。我的解决方法是前后端走同一个域名和同一个HTTPS证书/api由Nginx转发这样既避免跨域也没有混合内容问题。第三个是文件删除时磁盘空间没释放。原因是删除操作只删了数据库记录没删磁盘文件。后来我写了一个定时任务每天凌晨扫描resource_info表不存在的文件记录去磁盘上比对并清理遗留文件。这个任务虽然简单但救回了差点爆掉的磁盘。写到这里这个教学资源库系统基本从零到上线跑通了。整个过程最深的体会就是前后端分离的项目真正麻烦的不是某个技术多难而是前后端衔接处的细节——上传进度、文件名编码、跨域预检、路径规划。把这些边角料处理干净项目就成功了一大半。如果你是打算照着做一套资源库建议先按我前面的表结构建库再逐个接口调通最后再碰前端页面这样排查问题时会清晰很多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →