项目标题里写着“基于的线上授课系统”,中间那个技术栈是空的。这个细节挺有意思,它恰好对应了我见过的很多同类项目的真实状态——功能列表拉了一大页,但真正落到“用什么做、做到哪一步算完、代码怎么组织”这几个问题上,反而一片模糊。线上授课系统本身不是一个新鲜题目,教学平台、课程管理、直播课堂、作业提交这些词谁都能说,可一旦要交出一套能跑起来的源码,再配一份逻辑自洽的开题方案,难点就全冒出来了:排课冲突怎么判、课件传一半断了怎么办、回放进度怎么记、权限怎么区分教师和学生、报告里的技术路线怎么写得不像凑数。这篇内容我打算把这三件事一次性讲透:系统怎么划边界、代码怎么落地、开题的框架怎么搭。适合正在做课程设计、毕业项目或者想从头捋一遍教育类 Web 系统的朋友,零基础也能看懂主干,有经验的可以只看选型和踩坑部分。
1. 先把“线上授课系统”这五个字拆开:功能边界怎么划才不失控
我见过太多这类项目死在同一件事上——一开始想做一个“完整的教学平台”,最后做出来一个半成品的四不像。所以第一件事不是选框架,而是砍功能。
1.1 从真实的教学动作倒推功能清单
不要从“系统应该有什么模块”出发,那是教科书思路。换个方式:把自己当成一个要上一门课的老师,从开课前一周到考完试,把手上的动作一个个列出来,列完再翻译成功能。
我实际列出来的动作大概是这样一串:建课程、写课程简介、传课件、排上课时间、发通知、上课(讲、放视频、答疑)、布置作业、学生交作业、批改、算平时分、出成绩。这十一个动作里,有四个是纯记录性质(建课程、传课件、发通知、出成绩),三个是交互性质(上课、交作业、批改),剩下是排期和统计。这么一拆你会发现,真正需要“实时”的只有上课这一个环节,其余全是普通的增删改查。
这个结论很关键。很多同学一上手就想做直播间、白板、连麦、屏幕共享,结果光一个实时音视频就把项目周期吃干净了,剩下的功能全是空壳。我的建议是:把“上课”拆成两种形态——直播形态和录播+讨论形态,第一版只做后者。录播课件的播放、进度记录、章节下的讨论区,这三样已经足够支撑一个完整的教学闭环,而且技术成本低一个数量级。
提示:如果题目或需求里明确要求实时直播,也建议把它做成“外链跳转 + 本地记录”的形式——课堂入口指向一个外部会议链接,系统只负责生成链接、记录出勤、课后上传回放。这样既满足功能描述,又不用自己造音视频轮子。
1.2 三种角色和它们的最小可用闭环
角色不用多,三个就够:管理员、教师、学生。有些人会加“助教”或者“督导”,除非题目明确要求,否则不要加,每多一个角色就多一套权限矩阵和一堆分支判断。
三个角色的最小闭环分别是什么,我列个表更清楚:
| 角色 | 必须能做的事 | 明确不做的事 |
|---|---|---|
| 管理员 | 用户管理、课程审核、数据概览 | 不参与具体教学,不做内容编辑 |
| 教师 | 建课、排课、上传资源、布置批改作业、看学情 | 不管账号注册和系统配置 |
| 学生 | 选课、看课、交作业、看成绩、提问 | 不能看未选课程的任何资源 |
这张表的价值在于“明确不做的事”那一列。写代码的时候最怕边界模糊,比如“教师能不能删课”——如果课程已经有学生选了,删掉会造成数据孤儿,所以正确做法是逻辑删除 + 状态置为已下架,而不是物理删除。类似这种决定,必须在动手前定下来,写进文档,后面才不会返工。
1.3 功能膨胀的两个高发区和裁剪手法
第一个高发区是统计报表。一说到学情分析,很容易想到“学习时长趋势图、知识点掌握雷达图、班级对比柱状图”。这类需求好看但难做,因为底层数据根本不够。学习时长需要前端定时上报心跳,知识点掌握需要题目和知识点的关联表,没有这两层数据支撑,图表就是假数据。
我的处理方式是:只做能算出来的指标。比如作业提交率、平均分、课程完成进度(已看章节数/总章节数),这三个都能从现有表直接聚合出来,一个 SQL 就够,而且真实可信。
第二个高发区是消息通知。站内信、邮件、短信、微信推送,听着很全,实际上站内信就够了。而且站内信也不需要做得很复杂,一张消息表 + 一个红点未读数,前端每 30 秒轮询一次,几十行代码解决。
裁剪的原则只有一条:任何一个功能,如果你没法在十分钟内说清它的数据从哪来到哪去,就砍掉。说不清数据流的功能,做出来一定是摆设。
2. 技术栈怎么选:这套组合为什么既能讲清楚又好维护
标题里“基于”后面空着的那部分,通常填的是技术栈。我的建议是别贪新,选一套你能在评审时逐层讲明白的组合。
2.1 后端坚持单体:Spring Boot 一个大包打完
很多人会被“微服务”三个字吸引,觉得分层清晰显得高级。但对这个体量的系统,微服务是纯粹的负担:注册中心、网关、配置中心、服务间调用、分布式事务,任何一样出问题都够你查一整天,而你的业务量根本用不上水平扩展。
单体应用的好处很实在:一个启动类,一个端口,一次打包成一个 jar,扔到任何一台机器上java -jar就能跑。模块之间用包名隔离,controller、service、mapper、entity、dto各归各位,层次一样清楚,只是不用跨进程调用而已。
分层我习惯这么切:
com.example.teaching ├── common 通用返回体、异常、常量、工具类 ├── config 跨域、拦截器、文件存储、线程池配置 ├── controller 只做参数校验和调用,不写业务 ├── service 业务逻辑,接口 + impl ├── mapper MyBatis 接口 ├── entity 数据库实体 ├── dto 入参出参对象 └── task 定时任务(自动结束过期课程、清理临时文件)一个坑先说明白:Controller 里绝对不要写业务逻辑。我见过太多把校验、计算、入库全塞在 Controller 里的代码,一个方法两百行,改一个字段要通读全文。哪怕只是“课程下架”,也应该在 Service 里写成courseService.offline(courseId, operatorId),Controller 只负责把参数递进去。
另一个选择是持久层用 MyBatis 还是 JPA。这个体量的项目我倾向 MyBatis-Plus,原因是它自带的分页插件和条件构造器能省掉大量样板代码,而且写复杂 SQL(比如排课冲突检测、成绩聚合)的时候直接写 XML 更顺手,不用跟对象映射规则较劲。
2.2 前端 Vue3 加组件库,重点在“状态怎么管”
前端这块没有太多争议,Vue3 + Vite + Element Plus 是当前比较省心的组合。Vite 的冷启动是秒级的,改一行代码页面立刻刷新,这个体验差距在反复调样式的时候非常明显。
真正需要提前想清楚的是登录态和角色路由这两个状态放在哪。我的做法是:
- 登录成功后拿到 token,存
localStorage,同时把用户信息(id、姓名、角色)存一份到 Pinia; - 路由守卫里读角色,动态过滤路由表,教师看不到学生端的提交页,学生也进不去教师端的管理页;
- 请求拦截器统一往 header 里塞 token,响应拦截器统一处理 401 跳登录。
这里有个容易忽略的点:刷新页面后 Pinia 里的状态会丢,因为它是内存状态。所以用户信息要么从 localStorage 里恢复,要么在应用初始化时用 token 换一次/api/user/profile。我选后者,因为角色可能会被管理员改动,以前端缓存的旧角色做路由判断是不可靠的,必须以后端为准。
2.3 数据存储:关系库为主,文件单独处理
业务数据全放 MySQL 没问题,这个系统没有高并发写入的场景,单表几十万行用索引完全扛得住。真正需要单独考虑的是文件——课件 PPT、PDF、视频、作业附件。
新手最常踩的坑是把文件存成数据库的 BLOB 字段。这个做法在演示阶段看不出问题,一旦传几个视频,数据库体积暴涨,备份变慢,查询也跟着卡。正确做法是文件落到磁盘或对象存储,数据库只存路径和元信息。
本地开发的阶段,用本地目录完全够:
// 存储路径按业务和日期分目录,避免单目录文件过多 String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); Path targetDir = Paths.get(basePath, "courseware", datePath); Files.createDirectories(targetDir); // 文件名用 UUID 重命名,保留原始扩展名 String ext = originalName.substring(originalName.lastIndexOf(".")); String storedName = UUID.randomUUID().toString().replace("-", "") + ext;要注意的是原始文件名必须单独存一列。用 UUID 重命名后,用户下载时看到的应该还是他上传时的名字,不能是一串乱码。这个细节看着小,但演示的时候老师一眼就能看出来。
2.4 实时互动的三个方案,按成本从低到高排
如果确实需要实时能力,方案我按实现成本排一下:
| 方案 | 实现难度 | 适用场景 | 主要问题 |
|---|---|---|---|
| 短轮询 | 极低 | 消息通知、在线人数 | 请求量大、有延迟 |
| SSE 服务端推送 | 低 | 单向通知、进度同步 | 不支持双向 |
| WebSocket | 中 | 聊天室、举手、签到 | 需要管连接、心跳、断线重连 |
| 实时音视频 | 高 | 直播授课 | 成本高,通常依赖第三方 |
对课程项目来说,讨论区和答疑用轮询就够了,五秒拉一次新消息,用户基本感知不到延迟。如果一定要展示技术含量,就在“课堂签到”这个场景上用 WebSocket——老师点开始签到,学生端立刻弹出签到按钮,这个交互的即时感很强,代码量也就一百多行,性价比最高。
3. 数据库设计:排课、选课、资源这三块最容易埋雷
表设计做得好,后面写代码是顺水推舟;设计得糙,后面每个功能都要绕路补漏洞。我只讲三个最容易出问题的部分。
3.1 核心表清单与几个关键字段
整体大概十张表能覆盖全部功能:用户表、课程表、课程章节表、选课关系表、课表(排课)表、课件资源表、作业表、作业提交表、讨论帖表、消息表。这个数量不多不少,一张不多余。
几张关键表的结构我挑重点说:
CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(120) NOT NULL, cover_url VARCHAR(255) DEFAULT NULL, teacher_id BIGINT NOT NULL, intro TEXT, status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已发布 2已下架', max_students INT NOT NULL DEFAULT 100, created_at DATETIME NOT NULL, deleted TINYINT NOT NULL DEFAULT 0 );status和deleted是两个不同维度的字段,很多人会合并成一个。不合并的原因是:下架是可逆的业务状态,删除是不可逆的数据状态,混在一起以后想做“回收站”就做不了。
CREATE TABLE course_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, weekday TINYINT NOT NULL COMMENT '1-7', start_time TIME NOT NULL, end_time TIME NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, location VARCHAR(80) DEFAULT '线上' );排课这块我特意用了“星期 + 时间段 + 起止日期”的重复规则,而不是把每一节课都展开成一条记录。原因很现实:一门课上十六周,每周两次,展开就是三十二条记录,改一次时间要批量更新,非常难受。用规则存储,只在需要生成“某天的课程实例”时动态展开,灵活得多。
3.2 选课关系和资源归属:别在课程表里塞学生列表
新手最容易犯的错是在course表里加一个student_ids字段,存个逗号分隔的字符串。这个设计的问题不用多解释:查不了、改不了、加不了约束。
正确做法是一张纯粹的关系表:
CREATE TABLE course_enroll ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, student_id BIGINT NOT NULL, enrolled_at DATETIME NOT NULL, UNIQUE KEY uk_course_student (course_id, student_id) );那个唯一索引是核心,它让“重复选课”在数据库层面就被拦住了,不用在代码里先查再插,也就不用担心并发下的脏数据。
资源表同理,courseware表里存course_id和chapter_id,而不是反过来在课程里存资源列表。一对多关系,外键永远放在“多”的那一侧,这是关系建模最基本的规则,但我在实际代码里见到的反例多得惊人。
3.3 索引怎么建:三个高频查询定生死
索引不需要每个字段都建,按查询语句来定。这个系统里最高频的三类查询是:
- 按教师查课程:
WHERE teacher_id = ? AND deleted = 0 - 按学生查已选课程:
SELECT c.* FROM course c JOIN course_enroll e ON c.id = e.course_id WHERE e.student_id = ? - 按课程查章节和资源:
WHERE course_id = ? ORDER BY sort_no
对应的索引:
ALTER TABLE course ADD INDEX idx_teacher (teacher_id, deleted); ALTER TABLE course_enroll ADD INDEX idx_student (student_id); ALTER TABLE course_chapter ADD INDEX idx_course_sort (course_id, sort_no); ALTER TABLE courseware ADD INDEX idx_course (course_id);注意:联合索引的顺序不能随便调。
(teacher_id, deleted)和(deleted, teacher_id)是两回事,前者能命中“某教师的未删除课程”,后者只能命中“所有未删除记录”,区分度极低,等于白建。
还有一个容易被忽视的点:不要在status这种只有两三个取值的字段上单独建索引。区分度太低,优化器大概率不会用,白占空间还拖慢写入。
4. 核心模块的代码落地:从鉴权到课件上传
前面说的是骨架,这一节说血肉。四个模块我挑最关键的实现点讲,都是直接能拿去用的。
4.1 登录鉴权:JWT 怎么用才不出岔子
选 JWT 的理由很简单:无状态,服务端不用存 session,前后端分离部署时最省事。但它有几个必须处理好的坑。
第一个坑是过期时间。设太长不安全,设太短用户频繁掉线。我的做法是有效期设两小时,前端在响应拦截器里捕获 401 后跳登录页,同时把当前路由记下来,登录成功后跳回去。
第二个坑是载荷里放什么。只放用户 id 和角色,绝不放密码、手机号这类敏感信息,因为 JWT 的 payload 只是 Base64 编码,不是加密,任何人拿到都能解开看。
public String generateToken(Long userId, String role) { Map<String, Object> claims = new HashMap<>(); claims.put("uid", userId); claims.put("role", role); return Jwts.builder() .setClaims(claims) .setSubject(String.valueOf(userId)) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000L)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }第三个坑是注销。JWT 天生无法主动失效,用户点了退出,token 在有效期内仍然可用。要在课程项目里做得干净一点,就加一层 Redis 黑名单:注销时把 token 的 jti 写进 Redis,过期时间设为 token 剩余有效期,拦截器里查一下黑名单。多这十几行代码,逻辑就闭环了。
拦截器注册的时候记得放行登录、注册、静态资源这几类路径,否则登录接口自己都被拦,会陷入“登不进去”的死循环。
4.2 排课冲突检测:一段能直接抄的判断逻辑
这是这个系统里少有的“有算法味”的地方,也是评审时最容易被问到的点。冲突分两种:同一教师时间冲突和同一课程时间冲突。因为课程本身就绑定了教师,实际上只要判断教师维度就够了。
先把两条排课规则展开成具体的时间区间,再判断区间是否相交。区间相交的判断条件是aStart < bEnd && bStart < aEnd,这个条件比“枚举所有情况”清晰得多。
public boolean hasConflict(Long teacherId, LocalDate startDate, LocalDate endDate, LocalTime startTime, LocalTime endTime, Integer weekday) { List<CourseSchedule> existing = scheduleMapper.selectByTeacher(teacherId); for (CourseSchedule s : existing) { // 第一步:日期区间是否重叠 if (startDate.isAfter(s.getEndDate()) || endDate.isBefore(s.getStartDate())) { continue; } // 第二步:星期几是否相同 if (!weekday.equals(s.getWeekday())) { continue; } // 第三步:时间段是否相交 boolean overlap = startTime.isBefore(s.getEndTime()) && s.getStartTime().isBefore(endTime); if (overlap) { return true; } } return false; }三层过滤的顺序很讲究:先比日期范围,代价最低;再比星期,几乎无成本;最后才比具体时间。这样绝大多数记录在前两步就被排除了,实际循环里做时间比较的没几条。
有个边界情况要特别处理:首尾相接不算冲突。一节课 8:00–9:40,下一节 9:40–11:20,这是正常排课。上面的isBefore用的是严格小于,恰好能正确处理这种情况。如果误写成isBefore || equals,就会把正常课表判成冲突,这个 bug 我在别人的代码里见过不止一次。
4.3 课件与视频上传:分片、断点、进度三件事
小文件(几兆的 PDF、PPT)直接表单上传就行。麻烦的是视频,动辄几百兆,一次传完很容易超时中断,尤其是在网络不稳的环境下。所以视频必须分片。
分片上传的完整流程是四步:
- 前端把文件切成固定大小的块(建议 5MB),每块单独请求;
- 每个分片带
fileMd5 + chunkIndex + totalChunks三个参数; - 后端把分片存到以 md5 命名的临时目录,返回已收到的分片序号列表;
- 全部分片到齐后,后端按序号拼接,校验 md5,然后挪到正式目录。
第三步那个“返回已收到的分片列表”是断点续传的关键。用户传了一半断网,重新进来时前端先调一个检查接口,拿到已传分片号,跳过它们继续传即可。
// 合并分片的核心逻辑 try (FileChannel out = FileChannel.open(target, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { for (int i = 0; i < totalChunks; i++) { Path chunk = chunkDir.resolve(i + ".part"); try (FileChannel in = FileChannel.open(chunk, StandardOpenOption.READ)) { in.transferTo(0, in.size(), out); } Files.deleteIfExists(chunk); } }用FileChannel.transferTo而不是流拷贝,是因为前者走的是操作系统的零拷贝通道,几十上百兆的合并耗时会明显低一些。
注意:合并前必须校验总分片数和 md5。少一片就合并,产出的文件能打开但播不了,排查起来极其痛苦。
前端那边还要做一件事:上传进度条要真实。axios的onUploadProgress在分片模式下只会给出当前分片的进度,你要按“已完成分片数 / 总分片数”自己算总进度,否则进度条会来回跳。
4.4 作业提交与批改:状态机比字段堆叠清晰
作业这个模块最容易写乱,因为它状态多:未提交、已提交、已批改、被打回重做。很多人用三四个布尔字段来表示,最后判断逻辑变成一坨if-else。
更清晰的做法是一个状态字段 + 一个状态流转方法:
| 状态值 | 含义 | 可流转到 |
|---|---|---|
| 0 | 待提交 | 1 |
| 1 | 已提交待批改 | 2、3 |
| 2 | 已批改 | 3 |
| 3 | 打回重做 | 1 |
在 Service 层集中判断流转是否合法:
private static final Map<Integer, Set<Integer>> ALLOWED = Map.of( 0, Set.of(1), 1, Set.of(2, 3), 2, Set.of(3), 3, Set.of(1) ); public void changeStatus(Long submitId, int target) { HomeworkSubmit submit = submitMapper.selectById(submitId); if (!ALLOWED.getOrDefault(submit.getStatus(), Set.of()).contains(target)) { throw new BizException("当前状态不允许此操作"); } submit.setStatus(target); submitMapper.updateById(submit); }这个写法的好处是,以后新增状态(比如“优秀作业”标记)只需要改这张 Map,判断逻辑一处都不用动。而且把非法操作堵在服务端,比前端按钮置灰可靠得多——前端置灰只是体验,后端校验才是防线。
5. 开题部分:技术路线怎么写才站得住
“源码 + 开题”这个组合意味着交付物不只是能跑的代码,还有一份要经得起问的方案。开题写不好,代码再多也会被挑毛病。这里讲几个高频扣分点。
5.1 选题背景别写成行业报告
最常见的写法是开头三段宏观叙述:教育信息化如何推进、在线学习规模如何增长、传统教学如何受限。这类内容不是不能写,但通篇都是这种段落,就成了凑字数。
有效的背景应该迅速收敛到具体问题上。可以这么组织:现有教学流程中,课件分发靠群文件、作业收集靠邮箱、成绩统计靠手工表格,这三个动作分散在三个工具里,导致资源找不到、提交时间说不清、统计容易出错——所以要做一个把它们收拢到一处的系统。
这么写的好处是,后文的每个功能模块都能在背景里找到对应的问题,形成“问题—方案”的对应关系。评审最看重的就是这个对应关系,而不是背景写得多宏大。
5.2 研究内容和技术路线要能对上代码
研究内容那一节,很多人写成功能罗列:“本系统实现课程管理、用户管理、作业管理、资源管理……”这种写法信息量极低,因为任何系统都能这么写。
我建议按问题维度组织,每条内容都指向一个具体的技术难点:
- 多角色权限模型的设计与实现——解决教师、学生、管理员操作边界不清的问题;
- 基于时间区间的排课冲突检测算法——解决人工排课易撞车的问题;
- 大文件分片上传与断点续传方案——解决课件上传中断后需重传的问题;
- 学习进度与作业状态的状态机建模——解决流程状态混乱的问题。
技术路线那部分,画一条从需求到部署的链路即可,每一环都要能对应到你代码里的实际目录或配置文件。比如写“采用 Spring Boot 构建单体服务,MyBatis-Plus 完成持久层”,那你的pom.xml里就得真有这两个依赖,版本号还得对得上。曾经见过开题里写“使用微服务架构”,代码交上来是一个单体 jar 的情况,这种不一致是最容易被追问的。
5.3 进度安排和参考文献的两个扣分细节
进度安排最常见的问题是时间分配和功能量不匹配。比如“第三周完成所有核心模块开发,第四周测试”,一周做完四五个模块,一看就是编的。合理的排法是:需求与设计占两到三周,环境搭建和基础框架一周,核心模块两周,辅助模块一周,测试与文档一到两周,留一周缓冲。
参考文献的问题在于凑数。列十篇里九篇是近两年的综述,明显是检索后随手复制的。比较稳妥的做法是:三到五篇偏理论的(教学设计、在线学习相关),三到五篇偏技术的(Web 框架、数据库设计、权限模型),再 optionally 一两篇同类系统的设计分析。关键是正文里要真的引用到,如果正文一次都没提,列表就是摆设。
6. 源码整理与部署:让别人能跑起来才算交付
代码在自己电脑上能跑,和“交付一个项目”,中间差着一整套工程习惯。这部分最不显眼,但最影响别人对你的评价。
6.1 目录结构和配置外置
一个清晰的仓库结构应该是这样:
teaching-system/ ├── backend/ 后端工程 │ ├── src/main/java/ │ ├── src/main/resources/ │ │ ├── application.yml │ │ └── mapper/ MyBatis XML │ └── pom.xml ├── frontend/ 前端工程 │ ├── src/ │ └── package.json ├── sql/ │ ├── schema.sql 建表语句 │ └── data.sql 初始化数据 ├── docs/ 开题、设计说明、接口文档 └── README.md 启动步骤配置外置的重点是数据库连接、文件路径、密钥这三样必须写在配置文件里,不能硬编码在 Java 代码里。硬编码的后果是换一台机器就要改代码重新编译,非常尴尬。
配置文件里也不要把真实密码提交到仓库。本地开发用application-dev.yml并加进.gitignore,仓库里只留一个application-dev.yml.example,让别人复制一份自己填。这个习惯一旦养成,以后做任何项目都用得上。
6.2 数据库初始化脚本要能一键跑通
schema.sql里除了建表,还要包含索引和外键约束(或者明确说明为什么不用外键)。data.sql里放测试数据,至少要有:一个管理员账号、两个教师账号、五个学生账号、两门带章节的课程、若干条选课记录。
测试数据里有一个细节容易被忽略:密码要存加密后的值。如果代码里用了 BCrypt,那初始化脚本里的密码也得是 BCrypt 密文,不能存明文,否则第一登录就失败,而你会以为是加密逻辑写错了,白查半小时。
我习惯在 README 里写清楚三条命令:
# 1. 导入数据库 mysql -u root -p teaching < sql/schema.sql mysql -u root -p teaching < sql/data.sql # 2. 启动后端 cd backend && mvn spring-boot:run # 3. 启动前端 cd frontend && npm install && npm run dev能让人照着这三条命令跑起来,交付质量就及格了。
6.3 演示环境要准备一个“讲故事”的路径
部署不是把项目跑起来就完事,还要想清楚演示时走哪条路径。我的习惯是准备一条完整的故事线:管理员登录建一个教师账号 → 教师登录建课程、加章节、传课件、排课 → 学生登录选课、看课件、交作业 → 教师批改 → 学生看成绩。
这条线覆盖了三种角色、七八个核心功能,五分钟能走完。演示前把数据清空重跑一遍,确保没有脏数据干扰。这一步花的时间不多,但能避免演示现场点开一个页面发现是空的、或者点某个按钮报 500 的尴尬。
7. 实测踩过的几个坑,都是看起来不该出问题的
最后说几个我实际调试时踩过的坑。它们不属于“架构设计”层面,但真实开发中卡住的时间往往比架构决策还多。
7.1 跨域配置看着生效了,登录态还是丢
前后端分离开发时,跨域是第一个拦路虎。常见现象是:GET 请求正常,POST 请求 403;或者登录接口返回了 token,但后续请求全是 401。
第一个问题的原因是浏览器在跨域时会先发一个 OPTIONS 预检请求,如果你的拦截器没放行 OPTIONS,预检就被拦了,真正的 POST 根本发不出去。解决办法是在拦截器里加一句:if ("OPTIONS".equals(request.getMethod())) return true;。
第二个问题的原因通常是withCredentials相关的配置不匹配。如果用了 Cookie 传递会话,前端的axios.defaults.withCredentials要设为 true,后端的allowCredentials也要设为 true,而且此时allowedOrigins不能用*,必须写具体域名,否则浏览器会直接拒绝。用 JWT 走 header 传递的话就没这个问题,这也是我倾向 JWT 的一个现实原因。
7.2 视频播放进度上报,别用定时器硬怼
记录学习进度最直觉的做法是开一个setInterval,每五秒上报一次当前播放时间。这个方案的问题是:用户暂停了还在上报,页面切到后台浏览器会节流定时器,关掉页面最后一次进度就丢了。
更稳的做法是监听播放器事件 + 节流:
let lastReport = 0; player.on('timeupdate', () => { const now = Date.now(); if (now - lastReport < 10000) return; // 10 秒最多报一次 lastReport = now; reportProgress(courseId, chapterId, Math.floor(player.currentTime)); }); // 页面隐藏时补报一次,解决切后台丢进度 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { reportProgress(courseId, chapterId, Math.floor(player.currentTime)); } });还有一个业务逻辑上的坑:进度只能增不能减。用户看到第 30 分钟,后退到第 10 分钟又看了会儿,这时上报的 10 不能被写入,否则进度会倒退。后端更新时加一个判断,只有新值大于旧值才更新,一句 SQL 就能解决:UPDATE ... SET progress = GREATEST(progress, #{value})。
7.3 时间字段差八小时,问题往往不在数据库
时区问题几乎每个项目都会遇到一次。典型现象是:数据库里存的是2024-05-10 14:00:00,页面上显示成2024-05-10 06:00:00。
排查顺序我建议这样走:先看数据库连接的 JDBC URL 有没有带时区参数,建议显式写serverTimezone=Asia/Shanghai;再看实体类的时间字段是不是LocalDateTime,如果是Date就要注意序列化格式;最后看前端是不是用了new Date(str)直接解析,某些格式下浏览器会按 UTC 解释。
最省事的方案是全链路统一用yyyy-MM-dd HH:mm:ss字符串格式传输,后端在application.yml里配好全局序列化格式,前端不做二次解析,直接展示。宁可少一点“标准”,也不要在时区上反复折腾——这个坑我见过有人查了整整两天。
这套东西做下来,我最深的一点体会是:线上授课系统这个题目,难的不是技术,而是克制。功能砍到位、表设计到位、状态管理到位,代码量其实不大,一个后端五六千行、前端七八千行就能撑起来。真正拉开差距的是那些细节——排课的边界条件、上传的中断恢复、进度不能倒退、时区不能错乱。这些点在任何一本教材里都不会专门写,但它们决定了你的系统是“能演示”还是“能交付”。如果后面还要继续扩展,我会优先加两样东西:一是基于章节的学习时长统计,把现有心跳数据用起来;二是作业的互评功能,让学生之间先评一轮,教师只做抽查,这个功能对教学场景的价值比再加几个图表高得多。