线上授课系统开发:功能边界、技术选型与源码落地
项目标题里写着“基于的线上授课系统”中间那个技术栈是空的。这个细节挺有意思它恰好对应了我见过的很多同类项目的真实状态——功能列表拉了一大页但真正落到“用什么做、做到哪一步算完、代码怎么组织”这几个问题上反而一片模糊。线上授课系统本身不是一个新鲜题目教学平台、课程管理、直播课堂、作业提交这些词谁都能说可一旦要交出一套能跑起来的源码再配一份逻辑自洽的开题方案难点就全冒出来了排课冲突怎么判、课件传一半断了怎么办、回放进度怎么记、权限怎么区分教师和学生、报告里的技术路线怎么写得不像凑数。这篇内容我打算把这三件事一次性讲透系统怎么划边界、代码怎么落地、开题的框架怎么搭。适合正在做课程设计、毕业项目或者想从头捋一遍教育类 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) { MapString, 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) { ListCourseSchedule 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待提交11已提交待批改2、32已批改33打回重做1在 Service 层集中判断流转是否合法private static final MapInteger, SetInteger 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 有没有带时区参数建议显式写serverTimezoneAsia/Shanghai再看实体类的时间字段是不是LocalDateTime如果是Date就要注意序列化格式最后看前端是不是用了new Date(str)直接解析某些格式下浏览器会按 UTC 解释。最省事的方案是全链路统一用yyyy-MM-dd HH:mm:ss字符串格式传输后端在application.yml里配好全局序列化格式前端不做二次解析直接展示。宁可少一点“标准”也不要在时区上反复折腾——这个坑我见过有人查了整整两天。这套东西做下来我最深的一点体会是线上授课系统这个题目难的不是技术而是克制。功能砍到位、表设计到位、状态管理到位代码量其实不大一个后端五六千行、前端七八千行就能撑起来。真正拉开差距的是那些细节——排课的边界条件、上传的中断恢复、进度不能倒退、时区不能错乱。这些点在任何一本教材里都不会专门写但它们决定了你的系统是“能演示”还是“能交付”。如果后面还要继续扩展我会优先加两样东西一是基于章节的学习时长统计把现有心跳数据用起来二是作业的互评功能让学生之间先评一轮教师只做抽查这个功能对教学场景的价值比再加几个图表高得多。