SpringBoot+Vue学习平台开发实战:从数据库设计到智能推荐 最近帮人做基于SpringBootVue的学习平台算是我接触过的Web项目里比较经典的一类了。光说“学习平台管理系统”不同人脑子里浮现的东西完全不一样有人觉得就是课程表加个在线视频有人想要题库、考试、成绩分析还有人盯着“智能”两个字希望系统能根据学习记录给用户推课程、出训练题。这篇就把我做这套系统的完整思路、数据库设计、核心代码逻辑和跑通后的坑一起写出来给正在做毕业设计或者想练手全栈项目的朋友参考。整套系统不依赖那些很重的微服务组件也没有复杂的中间件就是SpringBoot Vue MySQL MyBatis四件套但这几个东西组合起来足够把“用户权限、课程学习、行为记录、在线测评、数据统计”这些核心链路完整走通。下面按我的实际开发顺序来拆解。1. 为什么选这套技术栈以及“智能”两个字落实在哪先聊选型。市面上做这类系统的主流路线其实就几条要么SpringBoot配JPA要么用MyBatis前端Vue或者React。我最终定的是SpringBoot 2.7 MyBatis MySQL 5.7 Vue 2 Element UI理由很实在SpringBoot天然适合快速搭建RESTful APIMyBatis在写复杂关联查询和动态SQL时比JPA更直白Vue 2配合Element UI在管理后台的开发效率上非常成熟网上可参考的资料也多遇到问题基本都能搜到答案。MySQL选5.7而不是8.0纯粹是图省心。很多老机器上的MySQL还是5.7且8.0的认证插件和时区配置对新手不友好后面部署时少踩一个坑是一个坑。真要上8.0也不麻烦无非是多配一下时区参数这个我在第五节会提到。再说“智能”怎么体现。一个学习平台不能只是“课程信息的单表CRUD”否则跟管理系统没区别。我在这个项目里把“智能”落实在三个具体功能上学习行为记录用户每一次看视频、做练习、收藏资源都留下记录系统通过这些数据计算学习时长、知识点掌握度。个性化推荐根据用户的历史学习记录和答题正确率推荐薄弱环节的课程和题目。学习计划生成按用户的日常学习时间、目标课程难度自动生成一个分阶段的计划并在计划结束后给出复盘统计。说白了这里的“智能”不是堆算法而是把用户行为数据真正用起来让系统越用越“懂”这个人。对于毕设或者中小型项目来说这种可解释、可演示、代码量可控的智能功能比硬上一套深度学习模型反而更有说服力。2. 后端代码怎么组织分层设计、核心表和关键接口SpringBoot后端我按标准的三层结构来拆controller、service、mapper加上entity实体、common公共类、config配置类。项目结构长这样src/main/java/com/study/platform ├── controller # 接口层 │ ├── AuthController.java │ ├── CourseController.java │ ├── StudyRecordController.java │ ├── ExamController.java │ └── StatisticsController.java ├── service # 业务逻辑层 │ ├── impl │ └── RecommenderService.java ├── mapper # MyBatis映射接口 ├── entity # 数据库实体 ├── common # 统一返回结果、异常处理 │ ├── Result.java │ └── JwtUtil.java └── config # 拦截器、跨域配置分层的好处不用多说重点说几个我做的时候觉得值得注意的设计。2.1 统一返回结果和全局异常处理不管前端要什么数据接口都返回同一个结构前端处理起来才清爽。我的Result类是这么写的public class ResultT { private Integer code; // 200成功非200失败 private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message 操作成功; r.data data; return r; } public static T ResultT error(String message) { ResultT r new Result(); r.code 500; r.message message; return r; } }再配一个RestControllerAdvice全局异常处理把业务异常、参数校验异常、未知异常全部兜住前端永远能拿到结构一致的错误信息调起接口来心里踏实。2.2 权限校验用JWT而不是Session学习平台有学生、教师、管理员三种角色接口不可能裸奔。我用JWT做无状态鉴权登录成功后返回token前端存到localStorage每次请求在请求头带上Authorization: Bearer token。JWT的生成和解析用一个工具类搞定核心逻辑是关键。签名密钥放到application.yml里解析时顺便把用户ID和角色取出来public String generateToken(User user) { MapString, Object claims new HashMap(); claims.put(userId, user.getId()); claims.put(role, user.getRole()); return Jwts.builder() .setClaims(claims) .setSubject(user.getUsername()) .setExpiration(new Date(System.currentTimeMillis() 1000L * 60 * 60 * 24)) // 24小时 .signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))) .compact(); }然后写一个拦截器实现HandlerInterceptor在preHandle里校验token顺便把用户信息放进ThreadLocal业务层需要当前用户时直接取。遇到白名单接口登录、注册、课程列表等直接放行。2.3 核心用户表与课程表设计数据库是整个系统的地基。我设计了9张核心表先看最重要的两张。用户表t_user字段名类型说明idbigint主键自增usernamevarchar(50)登录名唯一索引passwordvarchar(100)加密后的密码BCryptreal_namevarchar(50)真实姓名avatarvarchar(255)头像地址rolevarchar(20)student / teacher / adminstatustinyint1启用0禁用created_timedatetime注册时间密码我统一用BCrypt加密。这是Spring Security自带密码加密器单抽出来用也很方便。别用MD5MD5查表撞库的成本低到几乎为零生产上全是风险。课程表t_course字段名类型说明idbigint主键titlevarchar(100)课程名称covervarchar(255)封面图categoryvarchar(50)课程分类difficultytinyint难度 1-3teacher_idbigint授课教师descriptiontext课程简介total_hoursint预计学时statustinyint0未发布 1已发布这些字段看着简单其实都是从实际使用场景逆推出来的。比如difficulty系统做推荐时要用做一些毕设答辩展示这种字段可以很好地说明你设计是经过思考的。推荐逻辑会在后面详细讲。2.4 学习记录与行为追踪“智能”的根基在于用户行为数据所以t_study_record这张表我设计得比较细字段名类型说明idbigint主键user_idbigint用户IDcourse_idbigint课程IDchapter_idbigint章节IDdurationint本次学习时长分钟study_datedate学习日期finish_statustinyint0进行中 1已完成create_timedatetime记录时间每次用户进入某个章节开始学习前端就调一次/api/study/start记录开始离开或视频播完时调/api/study/finish更新时长和完成状态。这样按天汇总就能拿到学习热力图数据按课程汇总就能算出学习进度统计模块根本不缺数据。2.5 大体上走一遍核心代码先写Mapper接口比如按用户ID分页查询学习记录Mapper public interface StudyRecordMapper { ListStudyRecordVO pageStudyRecord(Param(userId) Long userId, Param(offset) int offset, Param(size) int size); int countStudyRecordByUser(Param(userId) Long userId); }对应的XMLselect idpageStudyRecord resultTypecom.study.platform.entity.vo.StudyRecordVO SELECT r.id, r.duration, r.study_date, c.title AS courseTitle, ch.title AS chapterTitle FROM t_study_record r LEFT JOIN t_course c ON r.course_id c.id LEFT JOIN t_course_chapter ch ON r.chapter_id ch.id WHERE r.user_id #{userId} ORDER BY r.create_time DESC LIMIT #{offset}, #{size} /select我特别提醒分页不要自己拿到的学习记录全部load到内存里数据一多必然会挂优先用上面这种SQL层LIMIT分页后期数据量大一点也不会慢多少。Service层的核心在StudyRecordService里主要做三件事记录行为、校验课程状态、更新学习进度。比如用户完成某个章节后会联动更新t_course_progress表让用户随时看到自己课程的百分比进度。3. 课程、题库和考试模块的具体实现3.1 课程发布与管理流程教师端发布课程的流程我设计成先创建课程基本信息标题、分类、封面 - 维护章节列表 - 给每个章节上传备课资料 - 一键发布。课程表状态字段status是发布开关下架就把状态改成0。学生的课程列表只查状态为1的管理端则不管发布状态都能看到。上传文件我单独写了一个FileController使用本地磁盘存储保存路径放在配置里upload: path: /data/study-platform/files/文件上传接口的实现逻辑其实很简单——接收MultipartFile生成一个带时间戳的唯一文件名写到配置目录然后把访问URL返回给前端。做毕设或者小项目时用本地存储完全够用不一定要上OSS/MinIO这种对象存储。3.2 题库设计与自动组卷题库表t_question的字段要好好设计因为后面出卷、成绩分析都靠它字段名类型说明idbigint主键course_idbigint所属课程typetinyint1单选 2多选 3判断 4填空difficultytinyint难度 1-3contenttext题干optionstext选项JSON格式answervarchar(255)正确答案analysistext答案解析scoreint每题分值自动组卷算法我用的是比例抽题按题型和难度比例搭配从题库里随机抽取组成一套试卷。举个例子如果考试总分100分单选题20道每道2分那组卷时就从难度为1、2、3的各抽一部分保证试卷难度梯度合理而不是同一难度扎堆。public ListQuestion buildPaper(Long courseId, int singleCount, int multiCount, int judgeCount, int totalScore) { ListQuestion paper new ArrayList(); // 简单难度抽 60%中等抽 30%困难抽 10% paper.addAll(randomSelect(courseId, 1, 60, (int)(singleCount * 0.6), 2)); paper.addAll(randomSelect(courseId, 2, 60, (int)(singleCount * 0.3), 2)); // ... 其他题型同理最后打乱顺序 Collections.shuffle(paper); return paper; }这段逻辑看起来朴实但演示效果很好——教师每次点击“一键组卷”都能出一份结构和难度都在设计范围内的新试卷体现了系统的可用性。3.3 在线考试与自动判分学生端考试流程我设计成三步进入考试时会校验有没有答题资格避免重复考试 - 答完一题自动提交答案并暂存 - 交卷后服务端批量判分并计算出成绩保存到成绩表。判分逻辑要注意多选题的判分规则。我的规则是全对满分多选、漏选得一半分选错得零分。选项我用逗号分隔的字符串存判定的时候先拆分成集合再比对private boolean judgeMulti(String correct, String userAnswer) { String[] arr1 correct.split(,); String[] arr2 userAnswer.split(,); SetString s1 new HashSet(Arrays.asList(arr1)); SetString s2 new HashSet(Arrays.asList(arr2)); if (s1.equals(s2)) return true; // 全对 if (s1.containsAll(s2) !s2.isEmpty()) { return partialScore; // 漏选给一半分 } return false; // 有错选 }每个科目考完成绩表记录总分、正确率、答题耗时这些数据都是后续“知识点薄弱分析”的重要输入。做了这张表之后统计模块的数据就全齐了。4. Vue前端是怎么把页面和接口串起来的4.1 前端目录与路由设计Vue项目我用的Vue 2 Element UI Vuex Vue Router Axios结构如下src ├── api # 所有接口请求封装 ├── assets ├── components # 公共组件 ├── router # 路由配置 ├── store # Vuex状态管理 ├── views │ ├── login.vue │ ├── home.vue │ ├── course │ │ ├── courseList.vue │ │ └── courseDetail.vue │ ├── exam │ │ ├── examList.vue │ │ └── examRoom.vue │ ├── admin # 管理后台页面 │ └── user │ └── studyCenter.vue └── main.js路由鉴权是这里不能省的步骤。我给每个路由加上meta信息标记需要的角色然后在全局前置守卫beforeEach里判断登录状态和角色权限router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next(/login); } else if (to.meta.role) { const role localStorage.getItem(role); if (role ! to.meta.role) { next(/403); } else { next(); } } else { next(); } });这样做的好处在于虽然前端路由本身不是安全边界但它在用户体验上能提前拦住“学生账号打开教师后台”这种明显越权的访问后端接口层仍然用JWT做二次校验。4.2 axios请求封装里的两个细节axios封装这块我做了两件容易被忽略但很重要的事第一请求拦截器自动带上token并统一处理响应码。后端返回的Result结构里只要code ! 200就在拦截器里弹出统一提示页面层不用每次都写错误处理。service.interceptors.response.use( response { const res response.data; if (res.code ! 200) { this.$message.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res; }, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(error); } );第二处理跨域。开发阶段我用了Vue脚手架自带的代理在vue.config.js里配module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } };这样前端的/api请求全都转发到后端的8081端口浏览器层面不存在跨域问题。生产部署时再由Nginx统一反代这个我后面会细说。4.3 几个核心页面是怎么实现的学习首页是学生登录后看到的第一个页面我用ECharts做了两个图一个学习时长趋势折线图一个课程类别分布饼图数据全部来自/api/statistics/overview接口这个接口统计了学习总时长、完成课程数和最近7天的学习趋势。用图展示系统显得很完整“智能学习平台”的观感立刻上来。考试页面是前端相对复杂的一个交互场景。我用了定时器做考试倒计时到点强制交卷答题区域分题号导航点击左侧题号能跳转对应题目做题进度条实时显示“已答/剩余”。这些交互看起来简单但写的时候要注意组件间通信我把答题状态存在Vuex里离开页面再回来状态不丢避免误刷新导致答题记录丢失。管理端则是典型的Element UI表格表单弹窗三板斧。为了少写重复代码我把列表页的“分页、搜索、删除确认、新增编辑弹窗”抽成了一个公共组件CrudTable通过传入列配置和接口对象自动渲染。教师端题库管理就复用了这个组件后续再加公告管理等新模块工作量会大幅下降。5. 运行环境、初始化数据和踩坑排查指南5.1 本地跑的完整步骤克隆代码之后按下面顺序启动成功率最高创建数据库study_platform字符集utf8mb4导入sql/init.sql。修改application.yml里的数据库账号密码。用mvn spring-boot:run启动后端确认8081端口起来。进入前端目录执行npm install再执行npm run serve。浏览器打开http://localhost:8080用初始化账号管理员admin、学生student、教师teacher登录。数据库初始化脚本这一步比较关键我不光建表还把管理员账号、课程样例、题库样例、一个测试学生的记录都给写进去了。有了初始化数据页面打开就有内容可看做演示时也会顺畅很多。5.2 我见过的几个高频报错和解决办法MyBatis映射失败。最常见的错误是Invalid bound statement (not found)原因是Mapper接口和XML没有放在同一个包路径下或者XML里的namespace跟接口全限定名不一致。解决办法检查application.yml的mapper-locations配置确保指向classpath:mapper/*.xml同时保持namespace写全。MySQL连接报Public Key Retrieval异常。这是MySQL 8.0用caching_sha2_password认证时的一个典型问题。我的解决办法是在连接串上加一个参数url: jdbc:mysql://localhost:3306/study_platform?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueuseSSLfalseserverTimezone一定要指定不然本地时间比实际少8个小时。allowPublicKeyRetrieval是MySQL 8才需要的5.7不会有这个问题。前端登录成功后刷新就退出。这个坑多半是路由跳转时token没有续期或者beforeEach里读到的token为空。我统一在登录成功后把token和role写入localStorage后续发起请求时从localStorage里取而不是从Vuex里取。Vuex刷新会丢localStorage不会这是很多人容易搞混的点。CORS跨域错误。如果没走代理直接让前端8080访问后端8081需要在后端做跨域配置。我用一个WebMvcConfigurer配置类解决允许的源和请求头都放开但不建议生产环境用*最好配具体域名。5.3 性能与安全性上要做的几件小事项目能跑起来只是开始答辩和实际使用中下面几件事建议提前做掉给t_study_record的user_id study_date建联合索引。给t_course的category、status加普通索引。密码存储用BCrypt加密别用明文或MD5。在Controller层用注释校验参数比如用户名长度、分数范围。把SQL日志在生产环境关掉日志等级调到WARN级。6. 可以继续扩展的进阶方向核心系统做完后如果你还有余力这几个扩展方向在技术上不复杂但对系统的完整性和可展示性帮助很大方向一引入更细粒度的知识点标签体系。目前课程、题目只关联到课程如果再给每道题、每个章节打上“知识点标签”那么学完后系统就能精确告诉你“你第X章第X个知识点掌握不足”推荐的精确度会上一个台阶。实现上就是给题目表加knowledge_point字段统计时按知识点聚合答题正确率。方向二WebSocket做在线学习状态同步。学生在观看课程视频时用stomp或socket.io实时上报学习进度实现在线人数统计、教师端看到“谁正在看什么课”。做出来很提气也不算特别难。方向三基于行为数据的协同过滤推荐。如果用户量上来之后规则推荐不够精准可以换用协同过滤核心思路是“跟你学习记录相似的人也在学什么”。用Python训练好模型导出结果给Java调用也可以直接基于SQL算相似度矩阵。复杂度可控但讲起来很加分。方向四容器化部署。写一份Dockerfile把后端打成一个镜像前端用Nginx镜像托管再用docker-compose把MySQL、后端、前端一起编排起来。这样拿去线上演示或者交付别人只需要执行docker-compose up -d就能完整跑起来体验完全不同。7. 项目跑通之后的一些实在感受带过不少同学做类似系统我发现最容易出问题的往往不是业务代码本身而是几个基本功细节。比如数据库字段类型不统一导致时间格式化报错接口路径多个斜杠导致前端404JSON字段大小写对不上前端取值取不到这类问题排查起来很浪费时间。建议一开始就定好规范接口路径全部小写、时间字段统一DateTime、数据库表名统一t_前缀后面会少很多无谓的折腾。另外不要觉得“管理系统”就等于照着别人模板抄一遍就完事。真正有区分度的是业务逻辑的细节闭环像学习记录怎么统计、考试漏选怎么判分、组卷难度怎么控制这些每个点都可以做得很深。把其中两三个做扎实整套系统的成色立刻就不一样了。这也是我把代码和表结构都透传在本文里的原因希望你能顺着这条链路自己动手跑通一遍而不是只收藏。