1. 为什么我会去做一个"智能学习平台":需求分析与项目定位
先说说这个项目的来龙去脉。去年团队接到一个高校实训平台的外包需求,甲方一开始给的描述很简单:"我们要做一个在线学习系统,能看视频、能刷题、能统计学习进度。"听起来像是个普通的课程网站,但聊深了才发现,真正棘手的是"智能"两个字——他们希望平台能根据学生的答题情况和学习行为,给出个性化的学习路径建议,而不是把所有课程资源一股脑堆在页面上让学生自己翻。
这时候我脑子里第一个组合就是SpringBoot + Vue。原因很直接:这个技术栈在Java生态里足够成熟,招人好招,部署省心,而且前后端分离的架构天然适合"管理后台 + 学生端 + 教师端"这种多角色场景。配合MySQL + MyBatis做数据持久化,整套系统从零到上线,我们大概花了三周左右。这篇文章就是把这个项目的设计与实现过程完整复盘一遍,从需求拆解到数据库建模,从后端接口设计到Vue前端联调,再到部署时踩过的坑,全部写出来。
如果你是正在做毕业设计、个人作品集,或者接手了一个类似的在线教育类项目,这篇内容应该能帮你避开不少弯路。但先提醒一句:任何技术方案都要跟着实际需求走,"智能"这个词的落地程度决定项目复杂度。我见过很多人一上来就上推荐算法、上消息队列,结果连基础的业务闭环都没跑通。下面我会按照当时推进项目的真实顺序来写,该简的地方简,该深的地方深。
1.1 用户角色与核心业务流程梳理
不管什么系统,第一步永远是把"谁在用、用来干什么"想清楚。我们这个学习平台最终确定了三种角色:
- 学生:注册登录、浏览课程、学习视频/文档、做练习、查看学习报告、生成个性化学习建议。
- 教师:管理课程与章节、上传教学资源、发布习题、查看学生学情统计数据。
- 管理员:用户管理、课程分类管理、系统参数配置、数据看板。
围绕这三种角色,核心业务流程可以归纳成一条主线:学生选课 → 学习章节内容 → 完成课后练习 → 系统记录答题数据 → 统计薄弱知识点 → 生成推荐学习内容 → 学生根据建议继续学习。这个闭环里,最容易被低估的是"学习行为记录"这一步。很多初版设计只记录了"是否学完",但要做个性化推荐,必须细化到每个视频的观看时长、每道题的作答时间、错误选项等粒度。推荐不一定要用多复杂的模型,基于知识点的"错题统计分析 + 同知识点题目推送"在中小规模系统里完全够用,这也是我们最终采用的方案。
1.2 功能模块拆分
功能模块按角色拆成几大块,对应到后面的后端Controller和Vue页面:
| 模块 | 子功能 | 涉及角色 |
|---|---|---|
| 用户认证 | 注册、登录、Token刷新、个人信息维护 | 全部 |
| 课程中心 | 课程分类、课程列表、课程详情、章节进度 | 学生、教师 |
| 教学管理 | 课程创建、章节编辑、视频/文档上传 | 教师 |
| 在线练习 | 单选题/多选题/判断题、自动判分、错题记录 | 学生、教师 |
| 智能推荐 | 薄弱知识点分析、个性化练习题推荐、学习路径建议 | 学生 |
| 数据统计 | 学习时长统计、答题正确率、课程完成度 | 教师、管理员 |
| 系统管理 | 用户管理、角色权限、分类管理 | 管理员 |
这个表在项目一开始就定了,后面所有表结构和接口设计都围绕它展开。需要注意,功能列表一定要控制住"想做的"和"必须做的"之间差距。比如"视频上传"我们最终做了,但引入的是本地存储 + 前端Video.js播放,而不是接入OSS转码服务,因为实训场景的视频量级根本不需要那么重的方案。做项目最忌讳的就是在非核心环节上堆技术复杂度。
2. 技术选型背后的真实考量:我为什么坚持SpringBoot+Vue+MyBatis
现在很多项目一上来就是Spring Cloud Alibaba全家桶、Redis缓存、ElasticSearch检索,听起来很唬人,但对于一个学习平台来说,"够用"和"能维护"比"技术新"重要得多。我选型时核心考虑点有这么几个。
2.1 后端框架:SpringBoot 2.7 + MyBatis
SpringBoot 2.7是我们评估后觉得最稳的版本。为什么不用3.x?因为3.x底层是Jakarta命名空间,一些老牌三方库和插件兼容性要额外适配,对于业务型项目没有必要。SpringBoot的自动配置极大简化了项目初始化和第三方集成,但它的"黑盒"属性也意味着,如果你不理解starter背后的原理,出了问题会非常被动,后面我在配置MyBatis和事务的时候就踩了典型的坑,第4章会专门讲。
MyBatis在这套系统里的定位是"半自动化SQL控制"。相比MyBatis-Plus,原生MyBatis的XML配置更繁琐,但它对SQL的可控性更强。考虑到系统里有大量统计分析类SQL(学习时长聚合、知识点正确率计算等),手写SQL反而比MyBatis-Plus的QueryWrapper更直观。另外,MyBatis的一级缓存和二级缓存是一个必须理解透彻的机制,尤其是二级缓存,用不好就会出现脏数据。我们项目的缓存策略是:基础字典表开启二级缓存,业务表一律不开启,用Spring Cache + Caffeine管理热点数据(比如首页课程列表)。
2.2 前端框架:Vue2还是Vue3?
这个选择当时纠结了一下。Vue3 + Composition API + Vite确实是趋势,但考虑到团队里部分成员对Vue2的Options API更熟,而且项目周期紧,最终选了Vue2 + Element UI。如果从现在的视角回看,我可能会选Vue3 + Element Plus,但对这个项目本身,Vue2完全够用。
前端用到的核心依赖包括:
- Vue Router:路由管理,和权限控制联动,路由守卫里做Token校验与角色跳转。
- Axios:封装HTTP请求,统一处理token注入、401跳转、错误提示。
- Element UI:后台管理界面组件库。
- ECharts:统计数据可视化大屏(教师端/管理端)。
- Video.js:视频播放,支持m3u8直播流的场景。
Vue这部分最容易翻车的不是组件写不出来,而是环境配置。很多新手在npm install阶段就开始报错,后面第5章我会把环境安装、依赖版本冲突这些细节单独拿出来讲。
2.3 数据库:MySQL 8.0 + 字符集与事务隔离
MySQL 8.0是当前Java项目的主流选择。8.0相比5.7有几个点对开发体验影响很大:默认字符集是utf8mb4、支持窗口函数、JSON类型能力增强。我们项目中"学生知识点掌握度排名"这类需求,用窗口函数一条SQL就能解决,放在5.7里要写一堆子查询。
连接MySQL时有个细节必须注意:时区参数。JDBC连接串如果不加serverTimezone=Asia/Shanghai,在高版本MySQL驱动下会直接报错或出现时间偏移8小时的问题。另外,MySQL 8.0的mysql_native_password插件在Connector/J 8.x下虽然还能用,但建议直接使用默认的caching_sha2_password,避免后续版本升级出现验证插件不兼容。
到这里,项目基本背景和技术栈都已经明确了。接下来是设计阶段最关键的部分:数据库到底怎么建,表与表之间的关系怎么设计才能支撑"智能推荐"这个功能。这部分我会直接给出核心表的建表思路,并解释每一个字段设计的理由。
3. 数据库设计是项目的"地基":核心表结构与关键字段设计思路
学习平台的表结构并不复杂,但有几个地方如果设计不好,后面写业务代码会非常痛苦。你别看很多教程里贴了一张ER图就带过了,实际建表时每个字段的类型、长度、索引、默认值都要有依据。下面是我总结的几组核心表。
3.1 用户与角色权限表设计
用户表(sys_user)不是简单的一个账号密码字段就行。要支持角色区分,常见的方案有两种:一种是直接在用户表加role_id字段,简单直接;另一种是标准的RBAC模型,用户表、角色表、用户角色关联表、权限表。这个项目最终用的是RBAC的简化版——有用户表、角色表、用户角色关联表,但没有细粒度到"菜单按钮权限",因为三种角色的操作边界通过前端路由守卫 + 后端接口注解就可以控制,再建一层权限表属于过度设计。
用户表核心字段:
id(bigint,主键,自增)username(varchar(50),唯一索引)password(varchar(100),存BCrypt加密后的密文)nickname(varchar(50))avatar(varchar(255),头像URL)email、phonestatus(tinyint,0禁用 1正常)create_time、update_time(datetime)
密码加密这里多说一句:不要用MD5,不要用SHA,直接上BCrypt。Spring Security自带BCryptPasswordEncoder,同样的密码每次加密出的密文都不一样,因为内部掺了随机盐,安全性远高于固定哈希。
3.2 课程与章节表:树形结构的设计
课程分类我用了一张简单的二级分类表还是"类目表 + pid"自关联?这里说结论:用单表自关联(category表,带parent_id字段)。因为学习平台的分类最多两级(如"Java开发"下的"SpringBoot"),单表自关联既支持无限极扩展,又不至于引入单独的组织树组件。
课程主表(course)关键字段:
id、category_id(关联分类表)teacher_id(关联用户表,标识创建课程的教师)title、cover、intro(varchar/text)level(tinyint,难度等级)status(tinyint,0草稿 1已发布 2下架)is_free(tinyint,是否免费)view_count(int,浏览量)、like_count
课程章节表(course_section):
id、course_id(普通索引)section_name、video_url、content(富文本)sort_order(int,用于章节排序)duration(int,视频时长,单位秒)
设计要点:排序字段一定要有。前端展示章节列表时,如果没想好排序规则就按创建时间排,后面调整顺序会非常麻烦。sort_order用int,排序时ORDER BY sort_order ASC, id ASC,可控性拉满。
3.3 学习记录与答题记录:保证"智能推荐"的依据
这是整个数据库设计中最需要花心思的部分。我们要支持学习进度跟踪,也要支持薄弱知识点分析,意味着学生每次答题、每个视频的观看进度都得落库。当时我们设计了这几张表:
study_record(学习记录表):记录学生每个章节的学习行为。字段包括user_id、section_id、watch_duration(本次观看时长,秒)、is_completed(是否学完)、study_date。注意这里记录的是"每一次进入章节的行为",而不是汇总结果,方便后续做学习趋势分析。exam_paper(试卷表)和exam_question(试题表):题目和试卷是多对多关系,所以需要中间表paper_question。题库表(exam_question)核心字段:question_type(1单选 2多选 3判断)、content、options(JSON字符串,存放选项列表)、answer(答案)、analysis(解析)、knowledge_point(varchar,知识点标签)、difficulty(tinyint,难度)。知识点的knowledge_point字段很关键,推荐功能就是靠它把错题和同知识点题目关联起来。answer_record(答题记录表):每次答题都插入一条记录。字段:user_id、question_id、paper_id、user_answer、is_correct、answer_time、create_time。这张表数据量会快速增长,所以user_id和question_id必须建组合索引。后续统计正确率时,直接sELECT COUNT(*) FROM answer_record WHERE user_id=? AND is_correct=1可能慢,我们实际优化时加了knowledge_point冗余字段在答题记录上,统计时可以避免回表查询。
3.4 关键SQL的编写与索引优化
项目跑起来之后,最先扛不住的不是业务增删改,而是统计报表。其中一个很典型的SQL是"统计某学生在每个知识点的正确率":
SELECT knowledge_point, COUNT(*) AS total_count, SUM(is_correct) AS correct_count, ROUND(SUM(is_correct) / COUNT(*) * 100, 2) AS correct_rate FROM answer_record WHERE user_id = #{userId} GROUP BY knowledge_point ORDER BY correct_rate ASC LIMIT 10这条SQL能找出正确率最低的10个知识点,正好对应"薄弱知识点分析"。数据量上来后,注意给answer_record表的(user_id, knowledge_point)建组合索引,否则一次全表扫描几十万条记录,接口响应时间直接飙到几秒。
还有一个统计需求是"课程完成度":某个学生学了多少个章节。这里用study_record表即可:
SELECT course_id, COUNT(DISTINCT section_id) AS learned_section_count FROM study_record WHERE user_id = #{userId} GROUP BY course_id这类SQL没有太多黑魔法,重点在于最开始建表时就把study_record设计成"流水表",不要更新覆盖旧记录,这样所有统计口径都能追溯。
数据库结构确定后,后端开发就有了明确的方向。接下来聊聊后端SpringBoot项目的分层设计和核心模块实现。这部分是"完整源码"项目的重心,我会写清楚后端业务逻辑的分层、接口设计规范、权限验证方式,以及MyBatis里那些让人头疼的细节。
4. 后端核心模块实现:从分层架构到MyBatis的那些坑
SpringBoot项目的标准分层是Controller → Service → Mapper,这个架构几乎人人都会,但真正决定代码能否长期维护的是DTO/VO分离。别把数据库实体直接返给前端,也别把前端传参直接塞进数据库更新语句。项目里我建了entity、dto、vo三个包:entity对应数据库表结构,dto接收前端入参,vo返回给前端展示层。这样做的理由是:数据库字段可能包含密码、逻辑删除标记等敏感信息,如果不加处理直接序列化给前端,就是安全事故。而且前端展示往往需要联合字段(比如"课程名+教师名"),用VO对象组装后会非常干净。
4.1 JWT认证与拦截器实现
登录模块用的JWT(JSON Web Token)。用户登录成功后,后端生成一个token返回,前端把token存在localStorage里,后续每次请求在请求头带Authorization: Bearer <token>。后端用拦截器统一校验。
核心逻辑分三部分:
- 登录接口:校验用户名密码 → 生成JWT → 返回给前端。JWT中我存了
userId和role,过期时间设为24小时。 - 拦截器:继承
HandlerInterceptorAdapter或者实现HandlerInterceptor接口。preHandle方法里从请求头取token,解析并校验,如果无效直接返回401。注意要放行登录接口和静态资源路径。 - 角色权限控制:用自定义注解
@RequireRole("teacher")加到需要权限的Controller方法上,在拦截器里读取注解并比对token中的role,不匹配就返回403。
JWT要注意一个点:token一旦签发,在有效期内是无法主动失效的。如果你要支持"踢人下线"或者"修改密码后让老token失效",就要引入黑名单机制,用Redis存储被吊销的token。我们的项目里暂时用不到这个功能,所以没做,但理解这个缺陷很重要。
4.2 MyBatis的XML映射与缓存问题复盘
MyBatis的使用分两种风格:注解SQL和XML SQL。我的建议是,凡是SQL稍微复杂一点的,一律写XML。注解里写长SQL不仅可读性差,而且没法做动态SQL的复杂拼接。项目里所有Mapper接口都对应一个XML文件,放在resources/mapper目录下。
最典型的动态SQL片段是课程列表的条件查询:
<select id="selectCoursePage" resultType="com.example.entity.Course"> SELECT c.*, u.nickname AS teacher_name FROM course c LEFT JOIN sys_user u ON c.teacher_id = u.id <where> <if test="categoryId != null"> AND c.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (c.title LIKE CONCAT('%', #{keyword}, '%') OR c.intro LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND c.status = #{status} </if> </where> ORDER BY c.create_time DESC </select><where>标签会自动去掉多余的AND,避免写WHERE 1=1这种丑陋的写法。LIKE CONCAT('%', #{keyword}, '%')用了模糊查询,要注意#{}是预编译占位符,能防SQL注入,切记不要在字符串拼接时用${}。
MyBatis缓存这块必须单独说,因为一级缓存和二级缓存都藏着时空陷阱。一级缓存是SqlSession级别的,默认开启。在Spring集成环境下,如果同一个SqlSession执行了两次相同查询,第二次会直接走缓存,这时候如果中间别的地方改了数据库(比如另一个事务提交了),读到旧数据就是脏读。解决方法是:在需要实时性的查询Mapper上设置flushCache="true",或者干脆把Service方法加@Transactional保证读写在一个事务里,让一级缓存的生命周期可控。
二级缓存是Mapper级别的,跨SqlSession共享。我们项目只在字典表这类极少变更的表上开了二级缓存,业务表一律不开。你如果真的想给业务表开二级缓存,必须考虑缓存刷新策略,否则课程改了标题,另一个用户查询时还是旧数据,那体验就是"线上事故级别"了。
4.3 事务管理:@Transactional的正确打开方式
在一个学习平台里,事务最典型的场景是"学生学习完一个章节后,更新学习记录表 + 更新课程完成度统计表",这两个操作必须一起成功或一起失败。此时在Service方法上加@Transactional就能保证原子性。
但@Transactional有几个很容易踩的坑:
- 自调用失效:同一个类里的方法A调用同一个类里的方法B,B上的
@Transactional不会生效。因为Spring事务是通过AOP代理实现的,自调用绕过了代理对象。 - 异常被吞导致不回滚:
@Transactional默认只对RuntimeException和Error回滚,如果方法里catch了异常并正常返回,事务是不会回滚的。所以要么让异常抛出去,要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。 - 锁表问题:
@Transactional方法里如果做了远程调用或耗时操作,会长时间占用数据库连接,高并发场景下很容易把连接池打满。事务方法里尽量只做数据库操作。
如果你在IDEA里想监控MyBatis实际执行的SQL,推荐装一个MyBatis Log Free插件,它能把MyBatis日志里的Preparing和Parameters还原成可直接执行的完整SQL,调试的时候特别好用,尤其是排查"为什么这个条件没生效"这种问题。
4.4 接口设计与统一返回结果
前后端分离项目的接口规范必须统一。我写了一个Result类,所有接口都返回这个结构:
public class Result<T> { private Integer code; // 200成功,其他为失败 private String message; private T data; }为什么不用默认的HTTP状态码直接表意?因为HTTP状态码在浏览器里会被Axios拦截器处理,如果是200以外的状态码,enterprise监控和用户提示逻辑容易混在一起。不如所有业务请求都返回200,然后在JSON里用code区分业务状态,遇到401时由拦截器在HTTP层面返回401,让Axios统一跳到登录页。这个设计是业内比较主流的做法,也便于前端统一处理。
接口命名上,我遵循RESTful风格,资源用复数名词,比如:
POST /api/auth/login登录GET /api/courses课程列表GET /api/courses/{id}课程详情POST /api/courses教师创建课程PUT /api/courses/{id}更新课程POST /api/study/sections/{sectionId}记录学习行为POST /api/exam/submit提交试卷
写完这些接口,后端就基本成型了。下面进入前端的开发环节,这部分我重点讲讲Vue项目的搭建、环境坑、路由守卫和与后端联调过程中容易出的问题。
5. Vue前端实现与前后端联调中的关键细节
我这几年帮人看过的毕业设计项目里,十个有八个卡在Vue环境配置上,代码本身反倒是小问题。环境装不好,后面全白搭。这里把前端从零跑通的流程和容易踩的坑理一遍。
5.1 环境安装与项目初始化
先说Node.js版本。Vue2项目建议Node 14或16,Vue3配Node 16/18问题都不大,但如果Node版本到了20+,再装一些老依赖(比如node-sass)大概率会编译失败。所以装环境之前先确认好Node版本,用nvm管理多个Node版本是Linux/macOS下的好选择,Windows下可以用nvm-windows。
创建Vue2项目用官方CLI:
npm install -g @vue/cli vue create study-platform-frontend注意,创建时选择Manual模式,勾选Router和Vuex,Vue版本选2.x。创建完成后安装项目依赖,这里最大的坑是npm install速度慢或者失败,解决方案是把npm源切到国内镜像:
npm config set registry https://registry.npmmirror.com装Element UI:
npm install element-ui@2.15.8为什么特意指名版本?因为2.15.8是Element UI 2.x的最后一个稳定版,坑最少,直接用latest可能会匹配到Element Plus(那是给Vue3用的),语法完全不同。类似的兼容性问题在Vue生态里非常常见,以后装任何包都要先确认它支持的是Vue2还是Vue3。
5.2 前端路由规划与动态侧边栏
Vue Router是前端页面的骨架。项目里我用了一套"路由配置文件驱动侧边栏"的方案:所有页面组件按角色分目录(views/student、views/teacher、views/admin),路由表写在router/index.js里,侧边栏菜单根据当前用户角色动态过滤路由生成。这样新增页面时只需要往路由表里加一条记录,菜单会自动更新,不需要手动维护两份代码。
路由守卫是权限控制的第一道门槛:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() return } if (!token) { next('/login') return } if (to.meta.roles && !to.meta.roles.includes(store.state.user.role)) { next('/403') return } next() })后端接口上已经做了权限校验,为什么前端还要重复做一次路由拦截?因为用户体验问题:学生手动访问教师管理页面时,后端会返回403,但页面会闪一下白屏再跳转,很突兀。前端直接拦截到403提示页,体验就好很多。记住一个原则:前端拦截是为了体验,后端校验才是为了安全。
5.3 Axios封装与Token管理
Axios封装这一层是整个前端项目的"基础设施"。我在src/utils/request.js里做了统一封装:
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }) 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) { 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') } Message.error(error.message || '网络异常') return Promise.reject(error) } )这里面有几个细节值得讲。每次请求前从localStorage拿token塞到请求头,这是最常见的做法。有人会问为什么不用Cookie?因为跨域环境下Cookie要配置withCredentials和CORS具体域名,纯前端存储更省心。另外,401时自动清token并跳转登录页,这个逻辑要放在响应拦截器的error回调里,因为后端对未认证请求返回的是HTTP 401,业务状态码都走200。
5.4 视频播放与m3u8流播放的坑
学习平台的章节内容通常包含视频。我们最初用原生<video>标签,发现播放MP4文件没问题,但后期接入教师的直播录播流(m3u8格式)时彻底歇菜了——原生标签不支持HLS协议。解决方案是引入Video.js加上videojs-contrib-hls插件:
npm install video.js@7.x videojs-contrib-hls@2.x然后封装一个视频播放组件,核心逻辑:
this.player = videojs(this.$refs.videoPlayer, { sources: [{ src: this.videoUrl, type: this.videoUrl.endsWith('.m3u8') ? 'application/x-mpegURL' : 'video/mp4' }], controls: true, autoplay: false, preload: 'auto' })注意Vue2中要在mounted里初始化播放器,在beforeDestroy里销毁播放器,否则组件销毁后播放器实例残留,切页面后视频还在响。这个bug排查了我整整一个下午,最后发现是播放器没销毁。
5.5 前后端联调:跨域与接口调试
前端开发时用npm run serve启动在localhost:8080,后端接口在localhost:8080吗?不对,我们后端项目配的是8081端口,这样就产生了跨域问题。解决跨域有两种方式:
方式一:后端加CORS配置。在SpringBoot里写一个WebMvcConfigurer,添加跨域映射:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }方式二:前端配置代理。在vue.config.js里配置devServer.proxy,把/api开头的请求转发到后端地址:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } } }方式二更推荐。因为它在开发环境模拟了生产环境Nginx反向代理的行为,上线后只需要在Nginx里配置同样的转发规则,前端代码一行都不用改。如果你用方式一,等打包上线到Nginx后还需要再配置一次跨域,多一套东西要维护。
生产环境部署时,前端打包生成静态文件,后端打成jar包,Nginx把静态目录指向前端dist文件夹,/api请求转发到Java服务的8081端口,整个链路非常清爽。
前后端联调通过后,系统的核心功能基本就通了。但还有一个绕不开的话题:所谓的"智能"到底怎么落地?这一篇我放在最后讲,因为它依赖前面的所有数据积累,却又最容易被人做得华而不实。
6. "智能推荐"功能的落地:不堆算法,先把规则跑通
最开始甲方提"智能学习平台"的时候,团队里有人提议引入协同过滤算法,有人提议上深度学习模型。我当时的判断是:在这个阶段,基于知识点的规则推荐比任何复杂算法都靠谱。原因有两点:一是冷启动问题——新注册学生没有任何行为数据,算法根本推不出东西;二是解释性问题——学生看到"我们推荐你学习SpringMVC,因为你最近在SpringMVC相关题目上正确率只有40%"这个推荐理由,比算法偷偷摸摸推荐一门课更让人信服。
6.1 薄弱知识点识别的两种策略
策略一:基于答题正确率。从answer_record表统计每个知识点的答题总数和正确数,正确率低于阈值的(比如低于50%)直接标记为薄弱知识点。这个策略的优点是简单、直观、实时性好,缺点是学生如果在某个知识点只答了1道题且做错了,就会被打上"薄弱"标签,数据噪音太大。所以我们加了一个条件:答题数必须不少于5道题才参与统计。
策略二:基于学习行为。如果一个学生反复观看某章节视频但长期不完成章节练习,通常意味着这个知识点对他来说有难度。这个推断不够严谨,但作为补充信号很有价值。实现上可以用study_record表统计平均观看时长和完成率。
最终推荐算法是一个加权公式:
weakness_score = 0.7 * (1 - correct_rate) + 0.3 * (1 - completion_rate)按weakness_score降序取Top5知识点,为每个知识点找该分类下难度适中的3~5道题,组装成一份"个性化练习试卷"。
6.2 推荐的完整链路
后端实现时,我没有把推荐逻辑塞在一个Service里,而是拆成了三步:
- RecommendService负责收集特征数据(正确率、学习完成度),调用统计Mapper。
- 计算weakness_score,得到薄弱知识点列表。
- 根据知识点查询题目,组装试卷,返回给前端。
前端在学生的"学习报告"页面展示一个雷达图,六维能力(每个维度对应一个核心知识点分类)直观呈现,下方就是"为你推荐"的练习题列表。体验上比单纯列课程列表好很多,学生能明确感知到这个平台"懂我"。
6.3 推荐效果验证的口径
推荐功能上线后一定要关注数据反馈。我们在教师端加了一个"推荐学习效果"统计:对比学生做推荐题目的第一次正确率和后续相关知识点正确率提升幅度。虽然初期样本量不大,但维护反馈数据有助于判断推荐策略是否需要调参。这里想说一个观点:"智能"不是一次性做出来的,而是一轮一轮调出来的。先跑一个简单版本,看数据,再迭代,远比憋大招直接上模型要实际。
7. 部署上线遇到的坑与调试经验总结
项目写完不代表结束,部署上线是另一个修罗场。这一章记录我们真正发布到Linux服务器时遇到的几个问题,每一个都是线上环境独有的。
7.1 MySQL 8.0时区与连接驱动版本
服务器上装MySQL 8.0后,后端jar包一启动就报错:
The server time zone value 'CST' is unrecognized or represents more than one time zone.原因是MySQL驱动8.x对时区要求显式指定。解决办法是在application.yml的JDBC连接串加上参数:
url: jdbc:mysql://localhost:3306/study_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false第二个经常遇到的问题是把本地的数据库导入服务器后,中文全部变成乱码。排查后发现是MySQL配置文件/etc/mysql/mysql.conf.d/mysqld.cnf里的character-set-server没设置成utf8mb4,只改了建库时的charset根本不够,服务端全局字符集不对,连接层还是会出问题。改完配置文件重启MySQL,再把数据重新导入一遍,乱码问题才彻底解决。
7.2 Vue前端打包后的路由404问题
vue-router如果使用history模式,打包部署到Nginx后,直接访问首页正常,但刷新非根路径(比如刷新/student/courses)会404。原因是Nginx找不到对应的物理文件路径。解决方法是配置try_files:
location / { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; }如果对SEO没有硬性要求,也可以直接把vue-router切回hash模式,地址栏多个#,但刷新永远能找到页面,省心不少。考虑到学习平台是内部系统,不需要SEO,我们最后用的就是hash模式,线上再也没出现过刷新404。
7.3 服务器部署的完整流程
把之前零散的操作整理成一个可复制的部署清单:
- 服务器安装JDK 1.8、MySQL 8.0、Nginx、Git、Maven。
- 拉取代码:
git clone前后端项目。 - 后端:执行
mvn clean package -DskipTests,生成jar包。使用nohup java -jar study-platform.jar > nohup.log 2>&1 &启动。 - 前端:
npm install后执行npm run build,把dist目录下的文件上传到Nginx的html目录。 - 配置Nginx反向代理,把
/api指向http://localhost:8081。 - 初始化数据库:用
spring.sql.init或手动执行init.sql脚本建表,插入初始管理员账号。
执行完后,访问服务器的IP就能看到登录页。
7.4 IDEA调试MyBatis SQL的技巧
最后说一个日常开发提效的方法。IDEA装MyBatis Log Free插件,启动项目后它自动收集MyBatis日志,在插件窗口里能看到带参数的真实SQL。排查"为什么查出来的结果不对"时,直接复制插件生成的SQL去Navicat里执行验证,很快就能定位是SQL问题还是参数问题。另一个技巧是在application.yml里配置:
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样控制台会直接打印SQL和参数,比插件还直观,但生产环境记得关掉,否则日志量会非常大。
最后再分享一个我自己的体会。这个项目从需求分析到上线,前后大概三周,真正花时间最多的不是写业务代码,反而是那些看起来不起眼的"环境问题"和"部署问题"。SpringBoot和Vue这种框架,在网上随便一搜就有大把教程,但能把一个完整项目从零到一真正跑起来,并且让"智能推荐"这种功能落地、可解释、有反馈,才是这个项目最大的价值。你可以试着把其中的"智能推荐"模块继续扩展:比如基于学习行为的遗忘曲线提醒,或者引入更细粒度的知识点图谱。数据都已经在你的表里了,剩下的就是一步步把规则调好、把体验做顺。项目本身不难,难的是你愿不愿意把它做到闭环。