先说一个我观察到的现象:市面上的“课程答疑系统”绝大多数是拿论坛源码或工单系统改的,把发帖叫“提问”,把回帖叫“回答”,角色换一下就交差了。这东西不是不能用,但离真实的课堂场景差得远——没有课程归属、没有教师身份边界、没有“采纳答案”后的闭环反馈,学生问完问题根本不知道有没有人被通知到。最近我把这套基于SpringBoot+Vue的课程答疑管理系统重新从零写了一遍,数据层用MyBatis和MySQL,整个过程走下来最大的体会是:这类系统的难点根本不在CRUD,而在状态流转、权限边界和对用户体验的细节处理上。这篇文章我不打算讲怎么新建一个SpringBoot项目这种基础步骤,而是把这套系统里几个最影响体验的设计选择拆开来说透——问题状态机怎么设计、MyBatis里哪些坑值得避开、Vue路由怎么配才不会在打包进SpringBoot之后404,以及一套能直接跑起来演示的初始化数据该怎么准备。无论你是正在做毕业设计的学生,还是想通过一个完整全栈项目巩固后端能力的开发者,这篇内容应该都能帮你少走几步弯路。
1. 课程答疑系统的边界:它和“论坛”到底差在哪
1.1 三种角色分别管什么
我在动手建表之前,先花了一天时间把角色边界画清楚,这是整个系统里性价比最高的一次设计投入。课程答疑系统至少要有三种角色:学生、教师、管理员。
学生能做的事很明确:登录后在课程下发起提问,对自己提出的问题可以补充内容,可以点赞他人的回答,可以在某个回答让自己满意时将其采纳为“最佳答案”。这里有个容易被忽略的细节——学生只能采纳“自己提出的问题”下的回答,不能去别人问题底下随便点采纳,这个校验在后端Service层必须做,而不能只靠前端把按钮隐藏。
教师的核心动作是“回答问题”和“管理自己课程下的问题”。一套真实可用的答疑系统里,教师不应该看到全校所有课程的问题,而是只能看到自己名下课程的问题列表。所以课程表和教师之间要有关联关系,简单设计就是course表直接存一个teacher_id,一门课只归属一位负责老师,这比搞多对多课程教师关联表更容易理解和维护。
管理员管的是基础数据:创建课程、分配教师、重置用户密码、查看全站答疑统计。管理员不该深度介入某个具体问题的回答环节,否则他会慢慢变成所有问题的“接盘侠”,这是我在早期版本里踩过的坑。
1.2 功能清单:哪些必须有,哪些是加分项
我最终落地的功能清单如下,标注了优先级,方便你对照自己的项目做裁剪:
| 模块 | 功能 | 优先级 | 说明 |
|---|---|---|---|
| 登录注册 | JWT登录、注册、退出 | 必须有 | 前后端分离下Session不好用,JWT是主流选择 |
| 课程模块 | 课程列表、课程详情 | 必须有 | 按课程维度组织问题 |
| 提问模块 | 按课程发起问题、查看我的问题 | 必须有 | 标题+Markdown正文 |
| 回答模块 | 教师/学生回答问题 | 必须有 | 同一个问题允许多条回答 |
| 采纳闭环 | 提问者采纳一条回答 | 必须有 | 被采纳后问题状态变为已解决 |
| 点赞 | 对回答点赞 | 加分项 | 排序权重之一 |
| 站内信 | 新回答、被采纳、被点赞通知 | 必须有 | 这是答疑系统体验的关键 |
| 相似问题推荐 | 提问时提示可能已有答案 | 加分项 | 减少重复提问,演示效果很好 |
| 教师工作台 | 按课程查看待回复问题 | 加分项 | 方便教师快速处理 |
这一版把“必须有”全部做完,“加分项”我做了相似问题推荐和教师工作台,因为它们对答辩和演示效果的提升非常明显,后面我会详细说实现思路。
1.3 2025年这套技术栈为什么这样选
标题写的是SpringBoot+Vue+MyBatis+MySQL,这个组合在2025年依然是全栈项目的主流配置,我的选型理由是这样的:
后端用SpringBoot 3.x,对应JDK 17。如果你还在用SpringBoot 2.x和JDK 8,我不是说不能用,但新项目没必要绑在旧版本上。SpringBoot 3.x的javax包已经迁移到jakarta,网上很多教程里的import语句直接粘过来会报错,这一点需要提醒新手注意。
ORM层我选了原生MyBatis而不是MyBatis-Plus。原因有两个:一是这套系统的核心查询大多是多条件动态SQL,XML Mapper写起来比注解要清晰得多,MyBatis原生能力足够;二是很多面试官和答辩老师对MyBatis-Plus过度的自动CRUD有一些偏见,原生MyBatis更能体现你对SQL的控制力。后面我专门有一节讲XML Mapper的写法。
前端用Vue 3 + Vite,UI库用Element Plus。Vue 2已经停止维护了,新项目直接用Vue 3组合式API。状态管理用Pinia,Vuex在Vue 3里虽然有兼容版但官方已经推荐Pinia了,不要在这个问题上纠结。
数据库用MySQL 8.0,字符集统一utf8mb4。MySQL 5.7还在服役,但8.0的窗口函数、更好的JSON支持和公共密钥检索机制都是实打实的优势,新装环境别再去下载5.7了。
2. 表结构与问题状态机:先把数据模型立住
2.1 核心表Design:五张表就够了
这个系统不需要二十张表,五张核心表就能覆盖全部业务。我列一下实际建表的核心字段。
用户表简化了角色设计,用role字段区分三种身份:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '展示名称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL', `role` varchar(20) NOT NULL DEFAULT 'STUDENT' COMMENT 'STUDENT/TEACHER/ADMIN', `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `gmt_update` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';课程表字段少,但teacher_id外键不能省,这是教师工作台的数据基础:
CREATE TABLE `course` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `description` varchar(500) DEFAULT NULL, `teacher_id` bigint NOT NULL COMMENT '主讲教师', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用', `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_teacher_id` (`teacher_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';问题表是所有表里信息量最大的,我用表格展示字段设计逻辑:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| course_id | bigint | 所属课程,索引必建 |
| user_id | bigint | 提问学生,索引必建 |
| title | varchar(100) | 问题标题,搜索主字段 |
| content_md | text | Markdown格式正文 |
| tags | varchar(255) | 逗号分隔的标签,如“Java,Spring” |
| status | varchar(20) | OPEN/ANSWERED/RESOLVED |
| view_count | int | 浏览次数 |
| like_count | int | 点赞数 |
| is_deleted | tinyint | 软删除标记 |
| gmt_create / gmt_update | datetime | 时间戳 |
回答表是闭环的落点,最关键的是is_adopted字段:
CREATE TABLE `answer` ( `id` bigint NOT NULL AUTO_INCREMENT, `question_id` bigint NOT NULL, `user_id` bigint NOT NULL, `content_md` text NOT NULL, `is_adopted` tinyint NOT NULL DEFAULT 0 COMMENT '是否被采纳', `like_count` int NOT NULL DEFAULT 0, `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_question_id` (`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回答表';加上一张站内信表,整个数据模型就完整了。站内信表我放在第5节细讲,它字段不多但时机很关键。
2.2 状态机:为什么用字符串状态而不是数字
问题状态我设计了三个:OPEN(待回答)、ANSWERED(已有回答)、RESOLVED(已解决)。流转规则是这样:
- 学生提交问题后,状态为OPEN;
- 只要有任意用户提交了一条回答,状态变为ANSWERED;
- 提问者在回答列表里点“采纳”,该回答is_adopted置1,问题状态变为RESOLVED;
- RESOLVED的问题不允许再被采纳其他回答,但依然可以继续回答和点赞,也允许提问者追加内容。
这里有一个很隐蔽的设计点:状态字段到底用tinyint还是varchar。我第一次做的时候用的是tinyint,0、1、2三个数字写进代码里,三个月后回头看代码完全不知道0代表什么。后来我改成varchar存字符串,读代码和排查问题都舒服得多。MySQL存字符串比数字多占一点空间,对这种量级的系统完全可以忽略。
另一个关键是状态流转不能被跳过。采纳操作的Service事务里必须校验当前状态和提问者身份,伪代码如下:
@Transactional public void adoptAnswer(Long questionId, Long answerId, Long currentUserId) { Question question = questionMapper.selectById(questionId); if (question == null || question.getIsDeleted() == 1) { throw new BizException("问题不存在"); } if (!question.getUserId().equals(currentUserId)) { throw new BizException("只有提问者才能采纳回答"); } if ("RESOLVED".equals(question.getStatus())) { throw new BizException("该问题已解决,不能重复采纳"); } Answer answer = answerMapper.selectById(answerId); if (answer == null || !answer.getQuestionId().equals(questionId)) { throw new BizException("回答不存在或不属于当前问题"); } questionMapper.updateStatus(questionId, "RESOLVED"); answerMapper.updateAdopted(answerId, 1); // 发送站内信给回答者:你的回答被采纳了 notifyService.sendAdoptNotify(answer.getUserId(), questionId); }这个方法的@Transactional必须加在public入口上,这样才能保证question状态更新和answer采纳标记要么都成功、要么都回滚。如果分两个Mapper调用但不在同一事务里,一旦更新完问题状态后通知发送失败,用户看到的状态和实际答案标记就不一致了。
2.3 并发更新和软删除的细节
答疑系统的并发量不会很高,但有几个细节值得注意。
点赞操作不要先SELECT再UPDATE,直接用原子SQL更新:
UPDATE answer SET like_count = like_count + 1 WHERE id = #{answerId}这样就算同一时间来了五个点赞请求,也不会出现并发覆盖。
软删除方面,用户删掉自己的问题后,回答应该一并逻辑隐藏。这个问题我在第一版偷懒没做,后来发现用户删除问题后,回答列表还挂在数据库里,统计时数据也不对。正确做法是删除问题时把该问题下的所有回答也标记is_deleted,查询时统一加上过滤条件。
3. MyBatis实战:动态SQL、TypeHandler与二次缓存三处关键点
3.1 为什么必须用XML Mapper
这套系统里最复杂的一条查询是“课程问答列表”。它要支持多条件组合:按课程过滤、按状态过滤、按关键词模糊搜索标题或正文、按点赞数或时间排序、分页。这种SQL用注解写会很痛苦,注解里拼 标签的字符串丑到难以维护。XML Mapper是正解,可读性和可维护性都远超注解。
我贴一条典型的问题分页查询:
<select id="selectQuestionPage" resultType="com.demo.entity.Question"> SELECT q.id, q.course_id, q.user_id, q.title, q.content_md, q.tags, q.status, q.view_count, q.like_count, q.gmt_create, u.nickname AS userName FROM question q LEFT JOIN user u ON q.user_id = u.id <where> q.is_deleted = 0 <if test="courseId != null"> AND q.course_id = #{courseId} </if> <if test="status != null and status != ''"> AND q.status = #{status} </if> <if test="keyword != null and keyword != ''"> AND (q.title LIKE CONCAT('%', #{keyword}, '%') OR q.content_md LIKE CONCAT('%', #{keyword}, '%')) </if> </where> <choose> <when test="orderBy == 'like'"> ORDER BY q.like_count DESC, q.gmt_create DESC </when> <otherwise> ORDER BY q.gmt_create DESC </otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>这里的关键是要用 标签自动处理条件连接,避免出现WHERE后面直接跟AND的语法错误。 是很多新手不熟悉的标签,它可以实现排序策略的切换,比在Java代码里拼字符串干净得多。
分页这里我建议手写LIMIT,不要引入PageHelper。PageHelper的原理是ThreadLocal存储分页参数,在高并发服务里线程复用可能出现页码串号的问题,排查起来非常隐蔽。对这个体量的系统,手写LIMIT带offset和pageSize完全够用,代码一眼就能看明白。
3.2 MySQL排序和分页的配合
问题列表页通常有两个排序维度:按最新时间、按点赞数。MySQL在执行ORDER BY的时候,如果排序字段没有索引,会对结果集进行文件排序。问题表数据量在几千行级别时感觉不明显,但一旦上了讲师工作台、所有课程问题混在一起查,排序字段的索引就该建了。
我实际建的索引策略是:
- question表:(course_id, status, gmt_create)联合索引,覆盖教师工作台的查询条件;
- question表:(user_id, gmt_create)联合索引,覆盖“我的问题”列表;
- answer表:(question_id, gmt_create)联合索引,覆盖回答列表查询。
这里有个小教训:联合索引的最左前缀原则是真实的。索引(course_id, status, gmt_create)能加速course_id单条件查询、course_id+status双条件查询、三条件全查询,但如果你只按status查,这个索引完全帮不上忙。设计联合索引前,先看看你的查询到底以哪个字段作为第一条件。
3.3 自定义TypeHandler处理标签字段
问题表的tags字段我存的是“Java,SpringBoot,MyBatis”这样的逗号分隔字符串,但Java实体里想直接用List 接收,这就轮到TypeHandler出场了。实际上这里存成字符串直接接收也可以,但作为一个练习TypeHandler的机会,价值不小——面试时TypeHandler是个高频考点,亲手实现一次比背八股文有用得多。
自定义TypeHandler的核心逻辑是把Java的List 转成数据库的字符串,再把数据库字符串转回List:
@MappedTypes(List.class) @MappedJdbcTypes(JdbcType.VARCHAR) public class StringListTypeHandler extends BaseTypeHandler<List<String>> { @Override public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, String.join(",", parameter)); } @Override public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException { return splitToList(rs.getString(columnName)); } @Override public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return splitToList(rs.getString(columnIndex)); } @Override public List<String> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return splitToList(cs.getString(columnIndex)); } private List<String> splitToList(String value) { if (value == null || value.isEmpty()) { return Collections.emptyList(); } return Arrays.asList(value.split(",")); } }Mapper接口里对tags字段标注:
List<QuestionVO> selectQuestionList(@Param("courseId") Long courseId);然后在XML的resultMap里把tags列的typeHandler配上:
<resultMap id="questionVOMap" type="com.demo.vo.QuestionVO"> <id property="id" column="id"/> <result property="tags" column="tags" typeHandler="com.demo.handler.StringListTypeHandler"/> </resultMap>这个知识点很多教程只在概念层面讲一遍,但自己写一遍就能理解MyBatis在结果映射时到底做了什么——它就是利用typeHandler把JDBC的String转换成了Java的List。
3.4 二级缓存:这个项目里我的结论是“不开”
MyBatis的二级缓存是面试高频题,真实项目里却要慎用。我先说结论:课程答疑系统我没开二级缓存,原因是收益小、风险大。
二级缓存是namespace级别的,把一次查询的结果缓存起来,要求查询对象可序列化。问题在于答疑系统的数据是动态的:学生提交了新回答、教师采纳了一条回答,同一namespace下别的用户查询结果如果还在缓存里,就会看到过期数据。MyBatis的二级缓存粗粒度失效机制很难精确控制,复杂度上来了,收益却有限。
我的替代方案是:只读且变化不频繁的基础数据(课程列表)用本地缓存,答疑相关的实时数据直接查MySQL。这个量级的系统MySQL单表轻松扛住上万条数据,完全不是性能瓶颈。如果你真的需要缓存,请上Redis并且精细设计缓存key,而不是指望MyBatis二级缓存替你解决。
3.5 数据源配置与两个必踩的MySQL报错
这里分享一个我每次搭环境都会提醒自己的配置,application.yml里MySQL连接串这么写:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/course_qa?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456两个必踩的坑,都出在MySQL 8上。
第一个是SSL连接错误。MySQL 8默认开启SSL,如果你的连接串不加useSSL=false,本地开发环境会出现SSLHandshakeException或者警告刷屏。有些同学会加useSSL=true,然后本地MySQL证书配置不对,又报错。本地开发直接useSSL=false最省心。
第二个是Public Key Retrieval错误。MySQL 8默认使用caching_sha2_password插件,客户端第一次连接需要从服务器获取公钥。如果你的连接串漏掉allowPublicKeyRetrieval=true,会出现Public Key Retrieval is not allowed的异常。这个参数网上很多教程不会提,遇到报错时几乎没人能第一眼想到是这里的问题。
开发阶段想打印SQL方便调试,加到application.yml:
logging: level: com.demo.mapper: debug注意这里的com.demo.mapper要换成你自己的Mapper接口包名,MyBatis的SQL日志是绑定到Mapper接口的logger上的,写错包名日志是不出来的。
4. Vue 3前端与联调:路由、渲染、打包“三板斧”
4.1 前端路由设计:简单方案最稳
前端路由我用的Vue Router 4,整张路由表如下:
| 路径 | 组件 | 角色限制 |
|---|---|---|
| /login | Login.vue | 无 |
| /home | Home.vue | 登录用户 |
| /course/:id | CourseDetail.vue | 学生/教师 |
| /question/:id | QuestionDetail.vue | 登录用户 |
| /my/questions | MyQuestions.vue | 学生 |
| /teacher/questions | TeacherQuestions.vue | 教师 |
| /admin/courses | AdminCourses.vue | 管理员 |
关于“动态路由”,很多人在Vue 3项目里看到“动态路由”这个词就想着用addRoute按权限动态注册路由表。我的建议是:不要为了炫技过度设计。对答疑系统这种角色数量固定的项目,在路由meta里标记roles,用全局前置守卫判断当前用户角色是否在允许列表里,这就是最清晰、最稳定、最容易向别人解释的方案。
// router/index.js const routes = [ { path: '/teacher/questions', component: () => import('../views/TeacherQuestions.vue'), meta: { roles: ['TEACHER', 'ADMIN'], requiresAuth: true } }, { path: '/admin/courses', component: () => import('../views/AdminCourses.vue'), meta: { roles: ['ADMIN'], requiresAuth: true } } ] router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') return } const userRole = localStorage.getItem('userRole') if (to.meta.roles && !to.meta.roles.includes(userRole)) { next('/home') return } next() })用addRoute实现动态路由的坑在于:刷新页面后路由表是从接口重新获取的,如果获取失败,用户明明已登录却掉进404。我们的简单方案完全规避了这个风险。
4.2 Markdown渲染与XSS防护
问题详情和回答内容都是Markdown格式。我在前端用marked做解析,但是这个方案有一个容易被忽略的安全隐患——Markdown里可以嵌HTML,如果学生提交问题内容里有