前阵子把一个企业级课程答疑系统从零到一完整做了一遍,技术栈正好就是 SpringBoot + Vue + MyBatis 架构 + MySQL 数据库,全部源码整理完后我自己又跑了一遍部署流程,期间踩了不少坑。这套系统覆盖了课程管理、在线提问、教师回答、后台审核、附件上传、视频回看这些典型场景,做完之后我最大的感受是:这类“企业级”项目真正考验人的不是某个炫技功能,而是权限设计、数据模型、缓存策略和异常处理这些基本功。
这篇文章我会按实际做项目的顺序来拆解:先讲需求从哪来、表怎么设计,再讲 MyBatis 层那些容易踩坑的细节,然后是 SpringBoot 后端和 Vue 前端的核心实现,最后把我在 MySQL 安装、SSL 连接报错、Vue 打包进 SpringBoot 等环节踩过的坑都列成速查表。适合正在做毕业设计的学生、刚接触前后端分离项目的初级开发,以及想了解管理系统落地细节的读者。
1. 项目整体设计与技术选型思路拆解
1.1 课程答疑系统的核心需求到底有哪些
动手写代码之前,我习惯先把业务场景想清楚。所谓课程答疑系统,本质上是把线下的“课后问老师”搬到线上:学生看课程视频或课件时产生疑问,可以随时提问;教师或助教登录后看到未回答问题,进行解答;管理员负责维护课程、用户和内容审核。这个链路看着简单,真正做起来会牵扯出很多子需求。
我在梳理需求时列了一个核心清单:
- 课程模块:管理员维护课程、章节、封面图,学生能查看课程列表和详情。
- 提问模块:学生针对某门课程的某个章节发起提问,可以选择问题类型(课程内容、系统使用、其他),并关联标签方便检索。
- 回答模块:教师/助教回答问题,支持文字和图片,回答后系统通知提问学生。
- 审核模块:管理员可以隐藏违规问题或回答,防止低质量内容影响其他用户。
- 统计模块:按课程维度统计提问数、回答数、未回复数,辅助教师了解教学情况。
- 用户模块:学生、教师、管理员三种角色,权限分级管理。
这些需求拆完之后,项目的“骨架”就出来了。一个企业级管理系统不能只做 CRUD,还需要考虑权限控制、操作日志、数据统计、文件存储这些横切关注点,我在后面的设计中把这些都纳入进去了。
1.2 为什么选 SpringBoot + Vue + MyBatis + MySQL 这套组合
说实话,这套组合不是最“新”的,但它是当前企业里最稳、最常见、最容易招到人维护的组合。我见过不少项目一上来就搞微服务、消息队列、分布式缓存,最后连部署环境都搭不齐。对于课程答疑这个体量的系统,单体应用完全够用,过度设计反而会让项目失去可维护性。
SpringBoot 的价值在于“约定大于配置”,原来 SSM 时代要写一大堆 XML 配置,现在一个启动类就能跑起来;内嵌 Tomcat 也让部署变得非常简单,一个 jar 包搞定。Vue 负责前端界面和交互,组件化开发让页面复用性提升,Vue Router 和 Pinia 能很好地支撑单页应用的路由与状态管理。MyBatis 的定位是“半自动 ORM”,SQL 由开发者自己控制,这在复杂报表查询、动态条件拼接、批量操作时特别有用,比全自动 ORM 更灵活。MySQL 则是开源数据库里最流行的选择,成本和社区生态都有优势,配合 Navicat 这类客户端开发效率很高。
有人可能觉得 MyBatis 写 XML 很麻烦,但正是因为“SQL 掌握在自己手里”,我们才能对慢查询、索引命中、批量插入这些环节做精细优化,这在面试和实际工作中都是加分项。
1.3 模块划分与数据库整体设计思路
数据库设计是整套系统的地基,我反复调整了两版才定下来。核心表分为五组:用户与权限、课程内容、问答主流程、附件文件、通知与统计。
用户相关表包括t_user(登录账号、昵称、密码密文、头像等)和t_role(角色),用户与角色采用多对多关联,通过t_user_role中间表连接,这样一个用户以后既可以是学生,也可以被授予教师权限,扩展性更好。
课程相关表包括t_course(课程标题、封面、简介、状态)和t_course_chapter(章节名称、排序号、视频地址),章节可以挂接视频或文档,学生提问时能关联到具体章节,这样教师一看就知道学生问的是哪部分内容。
问答主流程是核心中的核心:t_question保存问题标题、内容、提问人、所属课程章节、状态(待回答/已回答/已关闭)、浏览数;t_answer保存回答内容、回答人、所属问题 ID。这里有个细节,我在t_question上冗余了一个answer_count字段,每次回答成功后对该字段做原子更新,避免列表页用COUNT去实时聚合,查询性能会好很多。
附件表t_attachment记录文件原始名、存储路径、大小、上传人、关联业务类型和业务 ID,统一管理图片、视频、文档。通知表t_notification在回答产生时写入一条记录,前端通过轮询或 WebSocket 拉取未读数量。统计表t_course_stat每日跑定时任务汇总,避免实时统计拖垮数据库。
2. 数据库设计与 MyBatis 层的实战细节
2.1 表结构设计要点与索引规划
很多人设计表时只关注字段能不能存下数据,忽略了索引和查询路径,结果数据量一上来接口就超时。我在设计答疑列表查询时,明确了三个高频入口:按课程查看问题、按状态筛选问题、按创建时间倒序查看最新问题。针对这些入口,我给t_question建了这样的复合索引:
ALTER TABLE t_question ADD INDEX idx_course_status_time (course_id, status, create_time DESC); ALTER TABLE t_question ADD INDEX idx_user_time (user_id, create_time DESC); ALTER TABLE t_answer ADD INDEX idx_question_time (question_id, create_time ASC);复合索引的字段顺序不是随便写的。course_id放在最前面,是因为用户进入课程详情页后最常见的动作就是查看“这门课的问题”;status放第二位用来过滤未回答状态;create_time放最后做排序。这样查询时索引能直接覆盖过滤条件和排序,避免 filesort。
排序还有一个关键细节:如果按浏览量排序,不要直接用view_count做索引,因为浏览量更新极其频繁,每次浏览都做一次UPDATE会产生大量行锁。我是把浏览数先放 Redis 里累加,定时批量刷回 MySQL,或者干脆接受一定的延迟,用异步队列去更新。
字符集方面,我统一使用utf8mb4而不是老旧的utf8mb3,因为 utf8mb4 能存储 emoji 表情和生僻字,现在很多用户输入的昵称里就带表情,不用 utf8mb4 会出现乱码和存储报错。排序规则在 MySQL 8.0 下我选的是utf8mb4_general_ci,如果你对某些字段需要精确区分大小写,需要单独指定utf8mb4_bin,这个后面在坑位排查里会细说。
2.2 动态 SQL 与复杂查询:让 Mapper 少返工
课程答疑系统的列表查询条件非常多:课程 ID 可选、关键词模糊搜索、时间范围、状态、排序方式。如果每个条件都写一个查询方法,Mapper 会爆炸。MyBatis 的动态 SQL 能力在这里派上了大用场。
我在QuestionMapper.xml里写了一个通用的查询模型:
<select id="selectQuestionPage" resultType="com.xxx.QuestionVO"> SELECT q.id, q.title, q.status, q.create_time, u.nickname AS askerName, c.course_name AS courseName FROM t_question q LEFT JOIN t_user u ON q.user_id = u.id LEFT JOIN t_course c ON q.course_id = c.id <where> <if test="courseId != null"> AND q.course_id = #{courseId} </if> <if test="status != null"> AND q.status = #{status} </if> <if test="keyword != null and keyword != ''"> AND (q.title LIKE CONCAT('%', #{keyword}, '%') OR q.content LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="startTime != null"> AND q.create_time >= #{startTime} </if> <if test="endTime != null"> AND q.create_time <= #{endTime} </if> </where> ORDER BY <choose> <when test="orderBy == 'hot'">q.answer_count DESC, q.create_time DESC</when> <otherwise>q.create_time DESC</otherwise> </choose> </select>这里我用了<where>标签,MyBatis 会自动处理条件前缀的AND拼接问题,不需要人为判断是不是第一条。<choose>则用来实现“最新排序/最热排序”的可变排序逻辑,比在 Java 层拼 SQL 字符串干净得多。
分页我用的是手写LIMIT而不是 PageHelper,原因是 PageHelper 的 COUNT 语句在复杂多表关联时经常不走最优索引,导致接口变慢。手写分页时我会单独写一个 COUNT 查询,并把 COUNT 查询裁剪掉ORDER BY,这样查询计划会比带ORDER BY的原始 SQL 快很多:
<select id="countQuestionPage" resultType="long"> SELECT COUNT(1) FROM t_question q <!-- 相同条件的 where 片段 --> </select>两个 SQL 共用条件片段的话,可以抽成<sql id="questionQueryCondition">来复用,减少重复。
2.3 MyBatis 缓存机制与 TypeHandler 的应用
MyBatis 的缓存是面试常问、实际项目里也容易踩坑的点。一级缓存默认开启,基于 SqlSession 级别,同一个会话中执行两次相同查询会走缓存;二级缓存默认关闭,需要手动在 Mapper XML 里配置<cache/>,基于 namespace 级别共享。但二级缓存不是银弹,多表关联查询时如果一张表更新了,另一个 namespace 里的缓存并不会自动失效,很容易出现“别的用户改了数据,你还看到旧数据”的脏读。我的建议是:配置类、课程分类这类极少变更、只读型的数据可以开二级缓存;问答这种高频更新的业务数据,不要开二级缓存,直接走 MySQL,配合 Redis 做热点数据缓存更可控。
TypeHandler 则是一个容易被忽略却非常实用的机制。举个例子,我的问题类型字段在数据库里存的是varchar,Java 里我希望直接用枚举而不是字符串,否则业务代码里到处是魔法值。自定义 TypeHandler 可以把 Java 枚举和数据库字段互转:
@MappedTypes(QuestionType.class) public class QuestionTypeHandler extends BaseTypeHandler<QuestionType> { @Override public void setNonNullParameter(PreparedStatement ps, int i, QuestionType parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, parameter.getCode()); } @Override public QuestionType getNullableResult(ResultSet rs, String columnName) throws SQLException { return QuestionType.fromCode(rs.getString(columnName)); } }注册方式是在mybatis-config.xml里配置typeHandlers,或者在 SpringBoot 里用@Bean返回ConfigurationCustomizer。除此之外,JSON 字段转 List 也可以用它实现,比如问题附件 ID 列表存的是[1,2,3],Java 里直接反序列化成List<Long>,省去每处手动转换的重复代码。
还有一点想提醒一下,MyBatis 启动时XMLConfigBuilder会解析mybatis-config.xml,依次加载 properties、settings、typeAliases、typeHandlers 和 mappers,这一步如果 XML 有问题启动会直接报错。常见错误是 Mapper 接口和 XML 的 namespace 不匹配,或者typeAlias写成包含$符号的占位符导致解析失败,这类问题日志里通常会提示得很明显,多看第一行异常信息就能定位。
3. SpringBoot 后端核心功能实现要点
3.1 用户认证与权限体系搭建
企业级系统里权限设计必须从第一天就开始做,不然后面加接口时到处漏风。这个答疑系统有三种角色:学生、教师、管理员。学生只能提问和查看自己的内容;教师可以回答问题、查看所有问到自己课程的问题;管理员则拥有课程管理、用户管理、内容审核等全部权限。
我选择了 JWT 方案而不是 Session,原因是前后端分离后,前端可能部署在 Nginx 静态服务器、后端跑在独立端口,Session 需要处理跨域 Cookie 的问题,JWT 通过请求头传递更干净。核心实现分三步:
- 用户登录成功后,后端用
jjwt库签发一个 token,里面包含用户 ID 和角色列表。 - 前端每次请求在 axios 拦截器中带上
Authorization: Bearer <token>。 - 后端用一个
OncePerRequestFilter校验 token,解析出用户信息放入ThreadLocal,一个请求内任何地方都能取到当前用户。
密码加密必须用BCryptPasswordEncoder,不要用 MD5。MD5 是摘要算法,同一个密码的摘要始终一致,配合彩虹表极容易被破解;BCrypt 每次生成的哈希都带随机盐,同一个密码存出来的密文都不同,安全性高一个量级。
权限控制我采用了 Spring Security + JWT 的组合,在SecurityFilterChain里按 URL 前缀配置规则:/api/student/**需要学生角色,/api/admin/**需要管理员角色,/api/auth/**完全放行。这部分看起来复杂,但一旦配好,后面加接口时就只需要写业务逻辑,不用每次重复判断权限。
3.2 接口设计与统一响应规范
前后端联调最怕各写各的,状态码混乱。我的项目里所有接口不管成功失败,统一返回一个 Result 结构:
public class Result<T> { private Integer code; private String message; private T data; // 成功/失败静态工厂方法 }成功时 code 为 200,业务异常 code 为 400/403/404 等,系统异常 code 为 500。前端 axios 拦截器里直接判断 code,非 200 就调用统一的错误提示组件,不用每个页面单独处理。这样做最大的好处是前端逻辑简单,出现问题时看 code 和 message 就能定位是参数问题、权限问题还是服务器内部错误。
全局异常处理我用@RestControllerAdvice配合@ExceptionHandler完成。业务异常、参数校验异常(MethodArgumentNotValidException)、数据库唯一键冲突、文件上传超时等,全部集中在一个类里处理,避免异常堆栈直接暴露给前端。
参数校验方面,DTO 字段上用@NotBlank、@Size、@Email等注解,Controller 里只需要加一个@Validated注解,框架就会自动拦截非法参数,省去大量手工 if 判断。
接口路径规划也值得一提:/api/question/create、/api/question/list、/api/answer/create、/api/admin/course/save。路径里带模块名和动作,语义清晰,配合统一的 Result 结构,前端对接几乎不用看文档就能猜出接口含义。
3.3 附件上传与视频播放方案:MinIO + m3u8
课程答疑系统里不可避免要处理图片上传和视频回放。学生提问时可以贴截图,管理员上传课程视频后学生要能在浏览器直接播放。这里我选择了 MinIO 作为对象存储,而不是直接把文件存到服务器本地磁盘。
原因有三点:第一,MinIO 支持 S3 协议,以后系统规模大了可以直接平滑迁移到云上的对象存储;第二,文件与业务代码分离,重新部署后端不会丢文件;第三,MinIO 自带访问控制,可以通过预签名 URL 控制文件的有效访问时间。
文件上传流程上,我采用了后端接口中转的方式:前端先把文件 POST 到后端/api/file/upload,后端校验文件类型和大小后,用 MinIO Java SDK 上传到指定 bucket,返回文件 ID 和访问 URL。这样做的考虑是安全校验统一收敛在后端,而不是让前端直接拿着 AccessKey 去连 MinIO,避免密钥泄露。
视频播放这一块是很容易踩坑的环节。浏览器原生<video>标签对 mp4 格式支持还行,但 mp4 文件通常体积大、拖动加载缓慢;而 m3u8 是 HLS 协议的视频索引文件,将视频切片成一个个 ts 文件,播放时可以按需加载片段,拖动进度条快、节省带宽。所以我用 ffmpeg 将上传的视频转成 m3u8 切片:
ffmpeg -i input.mp4 -codec copy -f hls -hls_time 10 -hls_list_size 0 -hls_segment_filename "output_%03d.ts" output.m3u8这里用了-codec copy说明不重新编码,直接复用原视频编码流,转码速度非常快。如果源视频的编码格式播放器不支持,再考虑用-c:v libx264转成 H.264。转出的 m3u8 文件存到 MinIO,前端用 hls.js 播放,具体实现我放到 Vue 部分细说。
3.4 回答通知与定时任务
课程答疑场景里有一个很重要的体验点:学生提问后,如果教师回答了却不通知学生,学生还得反复刷新页面去查,体验会非常差。我做了站内信 + 轮询未读数的方案,没有一上来就引入 WebSocket 或消息队列。原因很实际:疑问回答这个场景对实时性要求没那么极端,轮询 30 秒一次已经足够;WebSocket 要处理连接管理、断线重连、集群会话同步,复杂度高不少,初期完全没必要。
回答成功后,业务代码里做两件事:写入t_answer记录,同时往t_notification插入一条新纪录,状态为未读。前端登录后进入系统,页面加载时拉一次未读数量,之后每 30 秒拉一次,发现未读数变化就弹通知提醒。
定时任务方面,我用 Spring 自带的@Scheduled实现每日课程答疑统计:凌晨 2 点汇总每门课程的提问总数、未回答数、平均首次回答时间,写入t_course_stat。管理后台的统计报表直接查这张表,不用实时刷大表,查询速度极快。@Scheduled在单机模式下完全够用,如果以后要部署多个后端实例,记得给定时任务加分布式锁,避免重复执行。
4. Vue 前端与交互实现细节
4.1 前端路由设计与动态路由的实现
Vue 前端的路由设计直接影响用户体验和代码维护。我分成两块:公共路由和动态路由。公共路由包括登录页、注册页、课程列表、问答列表、问答详情,这些页面所有登录用户都可以访问;动态路由则是管理后台的页面,只有管理员或教师才能看到,例如用户管理、课程维护、问题审核、数据统计。
动态路由的实现逻辑是:登录成功后,前端调后端接口/api/user/routes,根据当前用户的角色返回一个路由配置数组。前端拿到数组后,用router.addRoute动态注册这些路由。
这个方案比“把所有路由都注册进去、页面里再通过 v-if 控制菜单显示”要干净得多。存在的问题是:刷新页面时 Vue Router 的路由表会重置,动态注册的路由会丢失,所以我在全局路由守卫里加了一个判断,如果用户已登录但动态路由还没注入,就重新拉取并注入,然后再放行:
router.beforeEach(async (to, from, next) => { const userStore = useUserStore() if (to.path === '/login') { next() return } const token = userStore.token if (!token) { next('/login') return } if (!userStore.dynamicRoutesLoaded) { await userStore.fetchUserRoutes() } next() })静态路由里还有一个容易忽略的点:单页应用默认是 history 模式,直接访问/admin/user会 404,因为 Nginx 里没有这个物理文件。解决方式是 Nginx 配置try_files $uri $uri/ /index.html;,把所有未知路径都回退到 index.html。如果你图省事,也可以直接用 hash 模式,访问路径变成/#/admin/user,但页面 URL 会带个#,观感差一些。
4.2 状态管理与接口请求封装
Vue 3 项目里状态管理我推荐用 Pinia,比 Vuex 更轻量,TypeScript 支持也更好。这个项目里我建了三个 store:userStore(用户信息和 token)、questionStore(当前问题列表的筛选条件和分页状态)、appStore(全局 UI 状态如侧边栏折叠、通知未读数)。
接口请求封装是前端工程质量的关键。我建了一个request.js,内部创建 axios 实例,统一设置baseURL、超时时间、请求头,并在拦截器里处理三件套:
service.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response?.status === 401) { // token 失效,清除登录态并跳转登录页 } ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } )封装好之后,业务代码里调用接口非常简洁,比如const list = await getQuestionList(params),不需要在每个页面里重复处理错误提示和 loading 状态。搜索框的联想查询我加了防抖函数,避免用户每敲一个字就发一次请求,实践中 300ms 的防抖时间体验最好。
4.3 富文本编辑与 m3u8 播放器封装
学生提问的内容可能包含多行文字、代码片段、图片,纯文本框显然不够用。我集成了富文本编辑器,让用户能对问题进行排版。编辑器里粘贴或上传图片时,图片会先传到后端的上传接口,返回 URL 后插入内容中,这样问题详情页展示时图片不会依赖编辑器的临时状态。
这里有一个要注意的安全隐患:用户上传的富文本内容不能直接v-html渲染,否则会有 XSS 攻击风险。我在后端做了一个 HTML 白名单过滤,只保留p、br、img、strong、em等安全标签,script 和 onerror 事件全部清除。宁愿让用户排版能力弱一点,也不能让系统被注入脚本。
m3u8 播放器的实现也值得展开。原生 video 标签不能直接播 HLS 流,尤其是 Chrome 桌面版根本不支持 m3u8。我封装了一个HlsPlayer.vue组件,核心逻辑如下:
import Hls from 'hls.js' onMounted(() => { if (Hls.isSupported() && videoRef.value) { const hls = new Hls() hls.loadSource(props.src) hls.attachMedia(videoRef.value) } })组件对外只暴露一个src属性,调用方传入 m3u8 地址即可,内部自动处理初始化、错误监听和销毁清理。如果遇到浏览器原生支持 HLS(比如 Safari),要兼容回退到video.src = src的方式。视频跨域播放的问题也要注意,MinIO 或 Nginx 需要配置Access-Control-Allow-Origin,否则浏览器会拦截。
5. 部署环境搭建与常见问题排查
5.1 开发环境搭建:MySQL 安装与配置注意事项
很多同学在 Windows 上装 MySQL 时一路下一步,最后连数据库都连不上,问题往往出在字符集、时区和服务未启动。我建议在安装时记得做两件事:第一,选择 utf8mb4 作为默认字符集;第二,把 MySQL 安装为 Windows 服务,方便开机自启。
如果是通过 zip 压缩包手动安装 MySQL,需要自己写一个my.ini配置。我常用的最小配置如下:
[mysqld] basedir=D:/mysql-8.0.33 datadir=D:/mysql-8.0.33/data port=3306 character-set-server=utf8mb4 default-time-zone=+08:00 [client] default-character-set=utf8mb4初始化数据目录后,root 用户默认密码为空或随机密码,登录后第一时间改成自己的密码:
ALTER USER 'root'@'localhost' IDENTIFIED BY '你的密码';default-time-zone一定要显式设置成+08:00,否则 JDBC 连接时如果serverTimezone没有正确配置,SpringBoot 启动后查询时间会差 8 个小时。
数据库初始化我用的是 SQL 脚本,把建库、建表、初始数据全部写在init.sql里。执行顺序是:先建库,再建表,再插入管理员账号等基础数据。密码字段的初始值要写 BCrypt 加密后的字符串,不能存明文,这一点经常被人忽略。
5.2 常见报错与解决方案速查表
这个项目从开发到部署,我踩过不少雷,下面整理成速查表,按报错现象、原因、解决方案三列列出,你遇到同类问题直接对号入座。
| 报错/现象 | 根因分析 | 解决方式 |
|---|---|---|
| MySQL 连接报 Communications link failure 或 SSL 错误 | JDBC 连接串未处理 SSL 握手 | 连接 URL 加上?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true |
SpringBoot 启动报org.springframework.boot:spring-boot-starter-web无法解析 | SpringBoot 版本过高,部分依赖未兼容 | 调整至稳定的 2.7.x 版本,或升级 MyBatis Starter 到对应版本 |
MyBatis 报Invalid bound statement (not found) | Mapper 接口与 XML namespace 不匹配,或 XML 未被打包进 classpath | 检查 namespace 是否正确,确认mybatis.mapper-locations配置指向 classpath 下 XML 文件 |
| 查询结果中文乱码 | 数据库连接字符集与表字符集不一致 | 连接 URL 加characterEncoding=utf8,表统一使用 utf8mb4 |
| Vue 打包后丢进 SpringBoot,刷新页面 404 | history 路由没有 fallback 到 index.html | 在 SpringBoot 静态资源映射或 Nginx 里配置try_files $uri $uri/ /index.html |
| 视频 m3u8 播放不了,报跨域错误 | MinIO/Nginx 没有配置 CORS 头 | 在对象存储或 Nginx 上加Access-Control-Allow-Origin: * |
| SQL 排序结果不符合预期 | 使用了大小写不敏感的排序规则 | 对区分大小写的字段单独设置utf8mb4_bin排序规则 |
这里单独展开两个典型坑。
第一个是 SpringBoot 版本太高的兼容性问题。SpringBoot 3.x 从javax迁移到了jakarta包名,很多老 MyBatis 相关依赖还是基于javax写的,直接整合会启动失败。如果你拿到的项目模板是网上比较老的,建议先用 SpringBoot 2.7.x 把功能跑通,再平滑升级。这个取舍我想多说一句:新版本不一定适合所有人,稳定可运行比版本新更重要。
第二个是 Vue 打包放进 SpringBoot。有人喜欢把前端打包后的dist文件夹复制到后端的src/main/resources/static下面,这样只有一个 jar 包,部署简单。这样做没问题,但要注意前端路由模式。如果用 history 模式,SpringBoot 收到/admin/user这种路径时找不到对应映射会返回 404,需要自定义一个转发规则,把所有非/api/开头的路径都转发到/index.html。这时候我通常会更推荐干脆用 Nginx 做前端静态服务器,前后端分离部署,各自升级互不影响。
5.3 生产部署实践:Nginx + jar 包方式
生产环境我采用的是 Nginx 提供前端静态文件、反向代理后端 API,后端 SpringBoot 用 jar 包方式跑在独立端口。前端构建命令是:
npm run build打包完成后把dist目录上传到服务器的/opt/answers/dist。后端构建命令:
mvn clean package -DskipTests将生成的answers-system.jar上传到/opt/answers/app/。Nginx 的配置如下:
server { listen 80; server_name your-domain.com; root /opt/answers/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }后端进程我用 systemd 管理,写一个 unit 文件,实现开机自启和崩溃自动重启。配置文件的路径和数据源、MinIO 连接信息都通过--spring.config.location指定外部配置文件,而不是写死在 jar 包里,这样以后只改配置就能切换环境,不用重新打包。
文件存储和数据备份也要提前规划。MinIO 的数据目录定期用mc mirror同步到异地机器备份;MySQL 每天凌晨用mysqldump全量备份,保留最近 7 天,这个任务同样用 systemd timer 或 crontab 实现。系统上线后的故障大部分不是代码问题,而是备份没做好、磁盘满了、服务挂了没人知道。这些运维细节,越早部署越省心。
6. 项目优化与后续扩展的实践经验
6.1 数据库与查询优化心得
把系统跑起来不难,跑得快才是区别。我在压测和实际使用中发现,问答列表页是最容易卡的地方,因为要关联用户表、课程表,还要统计回答数。针对这个问题做了三项优化:
第一,列表页只查id列表,然后用IN批量查详情,避免一次性查出所有大字段文本内容。分页场景下这种方式能显著降低传输数据量。第二,对于“未回答问题数”这类高频统计,从实时COUNT改为在t_course上增加冗余字段,回答和提问时原子更新,查询直接取字段值。第三,给问题详情页加了一个简单热点缓存,用本地 Caffeine 缓存最近 10 分钟热门问题的浏览次数,减少数据库读写压力。
6.2 并发场景下的一致性问题
虽然课程答疑系统并发量不高,但有些边界情况还是需要处理。比如同一个学生快速点击两次“提交问题”,会生成两条重复记录。我在后端加了一个防重复提交的拦截器,基于用户 ID 和提交内容 hash,在 Redis 里存一个 3 秒的有效期 key,相同请求在有效期内直接拒绝。
再比如学生提问的同时教师正在回答,可能产生重复回答或回复到已关闭的问题。我在回答接口里加了一个状态判断,如果问题已经是“已关闭”状态,返回业务异常。同时更新问题状态时用带条件的 UPDATE 语句:UPDATE t_question SET status = 已回答 WHERE id = ? AND status != 已关闭,通过受影响行数判断是否成功,避免并发覆盖状态。这种乐观锁思路比用悲观锁简单有效得多。
6.3 这个系统后续还能怎么扩展
项目做完之后,我仍然觉得它的扩展空间很大。答疑场景天然适合沉淀知识库,学生问得最多的问题往往集中在少数几个知识点。下一步可以考虑给问题自动打标签,或者接入大模型做相似问题推荐,把常见问题的回答自动收敛成帮助文档。
另一个方向是把视频播放和答疑深度融合。现在学生看视频时遇到疑问,需要跳到问答页面去提问,割裂感明显。可以在视频播放器旁边嵌入一个随堂提问面板,当前播放时间点暂停,学生直接在该时间点发起提问,教师回答时能看到对应的视频片段,这样上下文更完整。不过这个功能对技术细节要求更高,需要记录视频进度、做时间戳关联,还要考虑视频进度上报的频率控制,建议作为二期功能来做。
从运维角度,如果以后用户量上来,答疑系统的瓶颈会在数据库层。届时可以把按课程维度的查询做读写分离,把统计报表迁移到单独的从库;通知推送可以接 WebSocket 替代轮询;定时任务如果多实例部署,需要引入分布式任务调度。这些扩展方向我都已经想好了,但不会在一开始就全部上,避免过度设计。
最后分享一点个人体会。做完这个课程答疑系统,我最大的收获不是学会了某个框架的某个 API,而是理解了做管理系统最核心的东西:数据模型要经得起推敲,权限边界要清晰,接口返回要统一,日志和异常处理要完善。这些能力在面试时很难突击,但在实际项目里日积月累。如果你也在做一个类似的管理系统,我建议不要一开始就纠结用多新的技术,先把基础 CRUD、权限、文件上传、列表筛选这些基本功做扎实,再逐步优化性能和体验,这个过程的收获会比项目本身更大。