1. 为什么我把智能学习平台拆成前后端分离两套工程
去年上半年我接手了一个学习类项目,需求说简单也简单:管理员能上传课件和习题,学生能在线刷题、看学习记录,老师能批改作业、发布通知。但真做起设计时我发现,如果还用传统JSP那套服务端渲染的写法,后面每加一个页面都要动Controller层,前端同事要等后端接口写完才能开工,联调周期长到让人抓狂。
所以第一版架构我直接定了前后端分离:SpringBoot做纯后端接口服务,Vue单独跑一套前端工程,数据层用MyBatis操作MySQL。前后端通过标准JSON格式交互,谁都不依赖谁的内部实现。这个决策后来证明是整套项目性价比最高的一个选择,后面我会详细说为什么。
这套系统的目标用户其实很清晰——某高校内部的一个模拟学习平台,学生端要完成课程浏览、在线答题、成绩查看;教师端要管理题库、批改主观题、查看班级统计;管理员端要维护用户和课程数据。三个角色权限不同,页面不同,但底层共用同一套用户体系和课程模型。
如果你也在做一个类似的教学管理类型系统,这篇文章会比较适合你。我会把整个开发过程中最有价值的部分拆开讲:数据库表为什么这么设计、登录鉴权为什么选JWT而不是Session、上传课件时静态资源怎么处理、部署到Linux服务器时踩了哪些坑。源码层面的思路我会尽量讲透,但不会把每一行代码都贴出来——那样反而读不下去。
提示:整个项目我用的技术栈版本是SpringBoot 2.7.x、Vue 2.6.x、MyBatis 3.5.x、MySQL 8.0。你不会照抄我的配置,但版本选型背后的考虑逻辑是通用的。
先说一个关键选型理由:为什么数据访问层选了MyBatis而不是Spring Data JPA?学习平台这类系统,查询条件往往是动态拼接的——比如学生查题目时可能按章节查、按难度查、按题型查,或者三个条件组合查。MyBatis的动态SQL对这种场景支持非常顺手,写一个<where>标签加几个<if>就能解决问题。而JPA在这种多条件组合查询下要么写Specification要么写@Query,复杂度上来了并没有更省事。当然,JPA在CRUD简单的项目里开发效率确实高,只是不适合这个项目的实际场景。
2. 数据库设计:学习业务的核心表结构和字段取舍
学习平台的业务说到底是"用户-课程-内容-行为"四件事的串联。我设计表的时候一直提醒自己:表的数量可以多,但每张表的职责必须单一,宁可join也不要在字段里塞JSON数组。
2.1 五张核心表的职责划分
第一版我设计了五张核心表:用户表、课程表、课件表、习题表、学习记录表。后来根据答题和统计需求,又拆出了答题记录表和班级表。
用户表其实是最值得细聊的。学生、教师、管理员三种角色,一开始我纠结过是放一张表加role字段,还是分成三张表。最后选择了单表加role字段,理由是三个角色的共性字段(用户名、密码、真实姓名、邮箱、手机号)占80%,差异字段极少。分三张表意味着认证逻辑要查三张表,或者引入user_type再做分发,对一个小团队来说过度设计了。
CREATE TABLE `tb_user` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录用户名', `password` varchar(100) NOT NULL COMMENT '加密后的密码', `real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名', `role` tinyint NOT NULL DEFAULT '2' COMMENT '1管理员 2教师 3学生', `class_id` int DEFAULT NULL COMMENT '班级ID,仅学生有', `email` varchar(100) DEFAULT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1正常 0禁用', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个细节我想提醒你:create_time这类字段,开发初期很可能觉得无所谓,但没有它后面查"某教师这学期传了多少课件"这种运营问题时会非常痛苦。所以时间字段能加就加,别懒。
习题表是题库系统的心脏。字段包含题目内容、选项(四个选项用单独字段存,而非JSON)、正确答案、题型、难度、所属章节、所属课程、创建人。四个选项为什么用字段而不用JSON数组?因为选择题有固定四个选项,字段写法在MyBatis里不需要解析复杂类型,查询和比对都直接。但如果你的题型有不定项选项数量,那另说,用JSON也不算错。
2.2 一对多关系里的冗余设计
课件表相对简单,关联字段是course_id和teacher_id。学习记录表记录的是"某个学生学习了某份课件"这一事件,字段包括user_id、courseware_id、study_duration(学习时长,秒)、last_study_time(最后一次学习时间)。
比较难处理的是答题记录。为了后续做正确率统计,我设计了答题记录主子表:主表存一次答题事件(autoid、user_id、exam_id、总得分、提交时间),子表存每一道题的作答明细(record_id、question_id、user_answer、is_correct、score)。这样设计的好处是,学生查"我某次测验每道题对没对"和老师查"某道题全班正确率"都能在子表上直接聚合,不需要拆JSON。
题库和答题记录之间还有个隐含问题:如果老师修改了一道题的内容或答案,历史答题记录的正确答案对错就会失真。所以题目表里我加了content_version字段(内容版本号),每次编辑题目内容时版本号加一。答题记录只关联当时题目的id和题目快照,而不是实时去查题库。这个设计最初没有,是因为我踩了一次坑才发现需要——后面排错章节会详细讲。
2.3 外键到底要不要建
我在表设计里刻意没有建物理外键,只保留了逻辑关联。原因有两个:一是MySQL在大量写入和删除时,外键约束会触发额外的检查开销;二是项目迭代过程中,表结构调整频率远比你想象的高,物理外键会变成改动表结构的阻力。用应用层的逻辑来保证数据一致性,对这个量级的系统来说够用了。
你可能会问:那数据不一致怎么办?比如删了一门课,课件还在。我的做法是:删除课程时在Service层显式处理关联数据——先删课件、再删习题、再删课程。只要Service层逻辑写对,数据不会出问题。这个取舍不一定适合所有系统,如果你的业务要求极高的数据完整性(比如财务系统),物理外键仍然有必要。
3. 后端SpringBoot:从登录鉴权到接口权限的落地细节
3.1 JWT登录态:为什么不用Session而用无状态Token
前后端分离架构下,Session方案有一个很尴尬的点:前端可能部署在A域名,后端在B域名,跨域请求带着Cookie会有一堆SameSite、CORS的兼容问题。就算用Nginx反代解决了开发环境的问题,将来如果要做小程序或App,Session基本就废了。所以我直接用了JWT做无状态登录。
登录流程是这样的:登录接口校验用户名密码成功后,生成一个JWT返回给前端,前端存在localStorage里。之后每次请求在HTTP头里带Authorization: Bearer <token>。后端用一个拦截器读取token,解析出userId和role,放到ThreadLocal里供本次请求使用。
生成JWT的核心代码我用的是io.jsonwebtoken(jjwt)库:
public String generateToken(Integer userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000)) .signWith(secretKey) .compact(); }token有效期我设了7天。有人会说7天太短,学生隔个周末回来登录态就过期了;也有人会说7天太长,泄露风险大。我的处理办法是:7天有效期,快过期时前端拦截401响应,自动带着refreshToken去换新token。后来觉得对学习平台这个并发量来说没必要做双token机制,就直接拉长到7天,被顶下线或禁用账号由Redis黑名单解决——后台禁用某个用户时,把他的token加入黑名单缓存,拦截器校验时先查一下黑名单。
帖子网上经常推荐Spring Security做权限框架,我对比之后没选它,主要原因是你为了用它得写一堆SecurityConfig配置类、自定义UserDetailsService、放行规则、过滤器链。而这个平台角色就三种,每个角色能访问的接口路径也非常清晰,用一个拦截器完全够用:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行OPTIONS预检请求 if ("OPTIONS".equals(request.getMethod())) return true; // 从header取token String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { try { Claims claims = JwtUtil.parseToken(authHeader.substring(7)); request.setAttribute("userId", claims.getSubject()); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; }权限控制我用了另一种思路:在Controller方法上用自定义注解@RequireRole({"TEACHER", "ADMIN"})标注,然后用同一个拦截器里的HandlerMethod判断当前用户角色是否匹配。这样比写Spring Security的@PreAuthorize少了一层配置,阅读代码时权限要求就在方法旁边,一目了然。
3.2 文件上传:把课件静态资源从数据库中剥离出来
学习平台必然涉及课件上传,PDF、Word、PPT都有。我最初的设计是把文件以二进制流存进MySQL的BLOB字段,结果不到一百份文件就把数据库撑到了几个GB,备份一次慢得要命。
后来改成了现在的方案:文件上传接口接收MultipartFile,把它写到服务器本地磁盘的指定目录,文件名用UUID重命名避免中文乱码和重名问题,文件原始名和存储路径记录到数据库的courseware表里,前端访问时用/files/**路径静态映射到磁盘目录。
spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MBNginx里对应做一层静态资源映射,学生下载课件时实际上走的是Nginx直接读磁盘文件,不经过后端应用服务器。这样做下载速度快,后端压力也小。
这里有一个容易忽略的点:文件上传之后,开发环境部署时磁盘路径和服务器路径不一样,如果用绝对路径写死在配置里,代码换环境就崩。我的做法是在application.yml里配一个file.upload-dir,代码中通过@Value注入,每台服务器部署时只要改这个配置项,代码不用动。
3.3 接口返回体统一封装与全局异常处理
前后端分离后,接口返回的格式必须约定好,不然前端解析逻辑会写得像一坨浆糊。我统一了返回结构:
{ "code": 200, "message": "success", "data": { } }code为200时代表成功,非200时前端统一弹message里的错误提示。这个封装类叫Result<T>,泛型支持任意数据。
全局异常处理用了@RestControllerAdvice,把业务异常(比如"密码错误""该课程已被删除")、参数校验异常、系统异常分层处理,记录日志后返回对应的错误码和提示信息。业务异常统一抛出BizException,这样Controller层不用每次写try-catch,代码干净很多:
throw new BizException("该账号已被禁用,请联系管理员");依赖的校验我用JSR 303的@NotBlank、@Email加在实体字段上,参数不对时MethodArgumentNotValidException被全局处理器捕获,统一返回400和具体的字段错误信息。前端拿到之后能准确定位并高亮哪个输入框填错了。
3.4 高频查询接口的SQL优化
学习平台有一个接口压力比较大:学生端首页展示课程列表的同时,要显示每个课程下课件数量和习题数量。如果写成"先查课程列表,再循环查课件数、习题数",那就是N+1查询,课程多的时候接口能慢到两秒以上。
我用了多表LEFT JOIN + GROUP BY解决的:
SELECT c.id, c.title, COUNT(DISTINCT cw.id) AS courseware_count, COUNT(DISTINCT q.id) AS question_count FROM tb_course c LEFT JOIN tb_courseware cw ON cw.course_id = c.id LEFT JOIN tb_question q ON q.course_id = c.id WHERE c.status = 1 GROUP BY c.id ORDER BY c.sort_order ASC;加上course_id上的索引,这个接口压测下来QPS大概在800左右,已经很够用了。如果你遇到类似查询,记得检查一下LEFT JOIN的表在ON字段上有没有索引,这是最快的优化手段。
4. 前端Vue:路由、状态管理与接口层的组织方式
4.1 按角色拆路由,而不是按页面堆路由
前端最怕的就是路由写成一长串平铺,后面维护时根本分不清哪个页面给谁用。我按角色拆了三层路由结构:公共路由(登录页、首页)、用户路由(学生端)、管理路由(教师端和管理员端复用一部分)。
登录成功之后,前端根据用户角色动态添加路由。Vue Router提供了addRoute方法,可以在运行时注册路由。登录时把后端返回的role存在状态管理里,然后调用动态路由函数:
// router/index.js export function buildRoutes(role) { const dynamicRoutes = role === 'STUDENT' ? studentRoutes : role === 'TEACHER' ? teacherRoutes : adminRoutes; dynamicRoutes.forEach(route => router.addRoute(route)); }路由守卫里做了两层判断:一是本地有没有token,没有就跳登录页;二是有token但路由的meta.roles里没有当前角色,就跳403页面。前端路由权限只能算UI级别的拦截,真正的防线仍然在后端接口权限上。
4.2 axios封装:请求拦截器里的Token刷新和错误统一处理
Vue项目里请求我习惯统一走一个封装的axios实例,而不是在每个组件里直接调axios。封装的核心价值在于:请求拦截器统一加token,响应拦截器统一处理错误码。
service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; }); service.interceptors.response.use( response => { const res = response.data; if (res.code === 200) { return res.data; } else if (res.code === 401) { localStorage.removeItem('token'); router.push('/login'); return Promise.reject(new Error(res.message)); } else { ElementUI.Message.error(res.message); return Promise.reject(new Error(res.message)); } }, error => { ElementUI.Message.error('网络异常,请稍后重试'); return Promise.reject(error); } );这样改造之后,每个页面拿数据只需要const data = await getCourseList(params),不用每个组件里都写错误处理。代码量少了一大半,而且出错误时交互反馈是统一的,不会出现A页面弹红色报错、B页面一片空白的情况。
4.3 状态管理:哪些数据才值得放进VueX/Pinia
Vue项目里新手容易犯的错是:把所有接口返回的数据都塞进状态管理库。其实大部分数据是页面私有的,组件销毁后就应该被垃圾回收。真正需要全局共享的只有三类:登录用户信息、角色权限、以及很多页面都要用到的字典数据(比如课程分类列表)。
用户信息在登录后拉取一次存入VueX,其他页面需要显示昵称或头像时直接读,不需要重复请求。课程分类这种变更是低频的数据,可以在App.vue的mounted里拉一次存起来,后面所有下拉框都从这里取。
Vue 2项目我用的VueX,Vue 3项目用Pinia,本质思路一样。状态管理库最忌讳的就是里面存了一堆永远不读的变量,那既占内存又影响排查问题时的思路清晰度。
4.4 表格和表单组件化的实践
学习平台的后台管理界面大量用到表格+搜索条件+分页的套路。我封装了一个公共组件PageTable.vue,接收columns和fetchData两个属性,内部帮忙处理排序、分页、loading状态。业务页面只需要提供列定义和请求方法,可以省掉两百行模板代码。
表单方面用了动态校验规则:rules对象里定义每个字段的校验规则,提交前this.$refs.form.validate(valid => { ... })判断是否通过。自定义校验器我写了一个常见场景的集合,比如校验手机号、校验学号必须为数字,避免在每个页面上重复写正则。
这个阶段想给你的建议是:前端工程化的核心不是用什么框架,而是"公共逻辑抽一层、业务页面薄一层"。组件封装得好不好,直接决定了后续加一个新管理页面是一天还是三天。
5. 部署上线:从开发环境到Linux服务器的完整流程
5.1 环境准备和初始配置
部署我选了Linux服务器,用宝塔面板做运维管理(如果你不喜欢面板,手动装Nginx、JDK、MySQL也行,只是步骤繁琐。对于小团队,面板效率真的高)。
需要在服务器上装的东西大概有这些:
| 组件 | 版本要求 | 用途 |
|---|---|---|
| JDK | 1.8+ | 运行SpringBoot Jar包 |
| MySQL | 8.0 | 业务数据存储 |
| Nginx | 1.20+ | 托管前端静态文件、反向代理后端接口 |
| Maven | 3.6+ | 后端项目打包 |
| Node.js | 14+ | 前端项目构建 |
注意:MySQL 8.0默认的认证插件是caching_sha2_password,老版本驱动连接时可能会报"Public Key Retrieval is not allowed",如果你用的是8.0的数据库,记得JDBC连接串加上
allowPublicKeyRetrieval=true。
5.2 后端打成Jar包并作为服务运行
后端打包前先确认application.yml里的数据库地址、文件上传目录已经改成了服务器上的实际路径。然后:
mvn clean package -DskipTests打包产物在target目录下,文件名类似learning-platform-0.0.1-SNAPSHOT.jar。我一般会改个简短的名字再传服务器:
scp target/learning-platform-0.0.1-SNAPSHOT.jar root@your-server:/opt/learning-app/SpringBoot应用不能用java -jar直接跑在前台,因为窗口一关服务就没了。我用systemd配置了一个服务项,实现开机自启、崩溃自动重启、日志轮转:
[Unit] Description=Learning Platform Backend After=network.target mysqld.service [Service] WorkingDirectory=/opt/learning-app ExecStart=/usr/bin/java -jar learning-platform.jar --server.port=8080 Restart=always RestartSec=10 SuccessExitStatus=143 User=root [Install] WantedBy=multi-user.target配置保存到/etc/systemd/system/learning.service,然后:
systemctl daemon-reload systemctl enable learning systemctl start learning进程日志用journalctl -u learning -f查看,部署的时候有没有报错一眼就能看出来。
5.3 前端构建与Nginx静态托管
Vue工程在本地打包前,要改一下接口的baseURL。开发环境我用的/api前缀,由Vue CLI的devServer代理到http://localhost:8080。打包上线时,我把/api前缀保留,交给Nginx统一转发,这样前端源码里的接口地址不用区分环境和后端地址。
npm install npm run build构建产物在dist目录,把它传到服务器的/var/www/learning-web/目录下。Nginx的配置:
server { listen 80; server_name your-domain.com; root /var/www/learning-web; index index.html; # 前端路由的history模式需要这个配置 location / { try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静态资源缓存 location /files/ { alias /data/learning-upload/; expires 30d; add_header Cache-Control "public, no-transform"; } }配置里有一个细节很关键:proxy_pass http://127.0.0.1:8080/;末尾的/不能去掉。location /api/会匹配/api/course/list,如果把请求原样转发给后端,后端收到的是/api/course/list;而Controller里RequestMapping是/course/list,没有/api前缀,所以必须通过末尾的/把/api前缀去掉。这是前后端分离部署下最容易踩的坑。
配置文件改完执行:
nginx -t nginx -s reload这时候访问服务器IP或域名,能看到前端页面正常渲染,登录接口也能通,部署就基本成功了。
5.4 上线前的几个安全检查
部署完成后别急着宣告完工,我会做一遍清单式检查:
- 修改MySQL root密码和业务库密码,删除默认的测试库
- 检查Nginx是否泄露了版本号(
server_tokens off;) - 后端接口是否有拦截器放行了不该放行的路径(比如
/files/**有做了权限控制吗——有的课件只允许报名了课程的学生下载,这里必须加鉴权) - 观察日志里有没有异常堆栈,特别是数据库连接池和文件路径相关的报错
这份检查清单帮我避免过很多"开发时好好的,一上服务器就挂"的尴尬。
6. 联调与排错:学习平台开发中踩过的三个坑
6.1 跨域问题不是一劳永逸的
开发环境前后端分离后第一个遇到的就是跨域。我在后端加了一个CorsConfig,允许http://localhost:8081(Vue devServer的默认端口)访问。结果前端启动时Vue CLI默认端口是8080,和后端冲突,改成了8081。配置好之后OPTIONS预检请求被拦截器拦截了,走不进Controller,导致登录接口一直失败。
排查才发现我的拦截器没有放行OPTIONS请求,而浏览器跨域时会先发一个OPTIONS预检。在preHandle里加了个判断——请求方法是OPTIONS时直接return true,问题解决。
这个坑告诉我:任何自定义拦截器/过滤器,都要留意对CORS预检请求的影响。不然你CorsConfig配置得再对,预检请求都进不了后端,照样报跨域错误。
6.2 修改题目后历史答题记录的"正确答案漂移"
这个问题是辅导老师反馈的:某道题老师修正了答案,结果全班学生的历史答题记录里,这道题的对错状态跟着变了,有的学生从"答错"变成了"答对"。
根因在表结构设计阶段埋下了——答题记录子表只存了question_id、user_answer、is_correct,没有存题目当时的正确答案快照。我后来在上面章节说的content_version字段其实是事后补丁,真正的修复是:答题记录子表增加correct_answer_snapshot字段,记录作答当天的正确答案。这个字段不参与任何查询,只作为历史回溯的依据。数据修正之后,历史成绩不再受题库更新影响。
这个坑对做教育类系统的人特别有参考价值:凡是涉及"历史状态"的业务,都要考虑"快照"而不是"实时关联"。
6.3 大并发导出成绩时的内存溢出
教师端有个功能:导出全班成绩Excel。第一版我用POI一次性查出全班数据再写入工作簿,五十人的班级没问题,但某次测试用了五百人的班级,直接OOM了。五百人其实不多,但每个学生关联几十道题的答题明细,全部对象驻留在内存里就大了。
后来我改成POI的SXSSFWorkbook(流式版本),边查边写,内存占用从几百MB降到几十MB。同时限制了单个导出任务最多导出1000条,超过就提示先分班导出。工具选型时花半小时读文档,比线上OOM再查半天强太多了。
6.4 接口响应慢:索引缺失导致的扫描瓶颈
在线题库页面打开后加载越来越慢,我用日志分析发现查询习题列表的SQL执行了将近3秒。EXPLAIN一看,type是ALL,全表扫描——course_id和chapter_id都没有索引。
这个系统表数据量一万多条,全表扫描本来不至于这么慢,但习题表的选项和正文都是长文本,扫描时的行宽大,速度就上不去了。加上联合索引idx_course_chapter(course_id, chapter_id)之后,查询时间降到几十毫秒。
算是用一次教训帮我建立了习惯:任何表只要有where查询条件,第一件事就是检查和建索引。不要因为"数据量小用不到索引"就忽略,等数据量大了再补索引,中间几千次的慢查询浪费的都是用户的时间。
7. 项目复盘和后续还能扩展的方向
整个项目从设计到部署大概用了三周,其中数据库设计和联调排错各占了一周。对个人开发者或者小团队来说,这个周期算是比较健康。复盘下来,几个决策帮了大忙:前后端分离让并行开发成为可能;把文件剥离出数据库让数据库体量保持轻盈;JWT无状态化让服务后续可以水平扩展;权限用注解形式声明降低了理解成本。
如果你想在这个项目基础上继续扩展,我看好这几个方向:一是把考试模块做成智能组卷——根据学生的历史薄弱知识点动态抽题,这种个性化逻辑和学习平台天然契合,也配得上"智能"两个字;二是加一个学习行为埋点,记录学生停留在每个课件页面的时长和滚动位置,做学习专注度分析;三是把部署方式从单机改造成Docker Compose,一键拉起MySQL、后端、前端三件套,环境迁移的成本会骤降。
但我也建议你别一上来就加太多功能。这个项目当前的核心是"课程-课件-习题-成绩"这条链路,把这条链路的体验打磨顺了,比加十个花哨功能都有用。系统是工具,学生和老师用得顺手才是真正的成功。
我在这个项目里最大的一个体会是:架构设计和表结构设计花的时间,后面都以翻倍的形式省回来了。很多人急着写业务代码,结果中期改表、改接口、重构页面,反而更慢。如果你要照着这个项目做,我希望你能先想清楚两件事再动手:用户角色之间真正的权限边界是什么?数据的历史状态需不需要追得回来?这两个问题想明白,后面会走得轻松很多。