news 2026/10/15 21:57:28

基于Node.js和Vue的学生选课系统:并发防超卖与事务锁设计实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Node.js和Vue的学生选课系统:并发防超卖与事务锁设计实战

1. 选课系统的核心难点:并发冲突、课程互斥与评分闭环

先说点实际的。学生选课系统这个题目,乍一看就是"增删改查",很多同学也确实是按CRUD来写的——课程列表一张表、选课记录一张表、评价再一张表,然后页面套个表格,完事。但你真跑到线上,把名额一放,把真实的选课时间窗口一开,问题就全出来了。

我当时做这套基于Node.js和Vue的学生选课系统(顺带把课程评价管理也揉进去了),最大的感受就是:一个选课系统的技术含量,不在页面上,而是在"数据一致性"和"状态流转"这两件事上。

先拆解一下这个系统到底要解决什么问题:

  • 并发抢课:同一秒内几十上百个学生点同一门课,容量只剩最后一个名额,谁抢到?怎么保证不多卖?这是典型的并发资源竞争问题,和电商秒杀的"超卖"是一个性质。
  • 课程互斥:一个学生同一时间段只能上一门课。选课的时候得判断和目标课程时间重叠的所有已选课程,而且不能漏掉"周几""第几节""单双周"这种细节。
  • 评价闭环:评价不是随随便便就能评的。没选过这门课不能评;课程还没结束、成绩还没出,也不能评;评过了不能重复评。这个状态链路如果不用数据库约束去卡,纯靠前端判断,一定会被绕过或者搞出脏数据。
  • 角色权限:学生、教师、管理员三个身份,能看到的东西完全不同。学生可以选课退课、看成绩、写评价;教师能看自己课的学生名单、录成绩、看评价统计;管理员管课程、管教师、管选课时间窗口。接口层面如果不做统一鉴权,数据就裸奔了。

拿我自己踩过的坑举例:最开始我做选课接口的时候,用的是"先SELECT查一下剩余容量,够的话就INSERT选课记录"这种朴素写法。单机、单用户测试毫无问题,但当我用脚本模拟30个并发请求抢同一门容量只剩1的课时,直接插进去了4条选课记录,容量变成了负数。为什么?因为多个请求同时读到剩余容量都是1,然后各自判断"够的",再各自插入,谁也不等谁。这个问题的根源就是:检查和写入之间没有一个原子性的锁来保证"读-判-写"是一体的。

所以这篇博文,我打算把我做完这一整套系统之后沉淀下来的设计思路、数据库关键约束、接口实现细节、前端交互的坑,以及压测优化经验,原原本本梳理一遍。不管你是拿它当毕业设计、课程设计,还是想自己搭一个完整的全栈项目练手,这套东西都能直接照着改。

2. 技术选型的取舍:自己搭全栈,为什么是Node.js + Vue

选型这件事,我见过太多人纠结了。有同学一上来就想上Spring Boot + Vue,说"企业主流";也有同学坚持用Python的Django或者Flask。其实对于学生选课这种重业务逻辑、重状态流转、重前后端协作的中小型管理系统,Node.js + Vue的组合反而是我个人非常推荐的一套搭配,理由有三。

2.1 前后端语言统一,心智负担小

后端用Node.js,前端用Vue,两边都是JavaScript/TypeScript。这意味着你不需要在"Java的MVC思想"和"前端组件化思想"之间来回切换。更实际的好处是:数据结构可以在前后端共用同一套心智模型。比如课程对象,后端查出来是{ id, name, credit, teacher, capacity },前端拿到手直接用,不需要像Java后端那样再写一堆DTO转换。对于个人全栈项目来说,少一层概念转换,就少很多bug。

2.2 Express框架轻量,中间件机制正好契合权限控制

Node.js的后端框架我选的是Express。不用NestJS是因为对于这个体量的项目,NestJS的依赖注入、模块化、装饰器这些概念反而增加了理解成本;Express的中间件模型非常简单直接,而选课系统的鉴权逻辑恰好就是一个典型的中间件链条:

请求进来 -> 解析JWT -> 校验角色 -> 放行到具体路由处理函数

我可以把authMiddleware和roleMiddleware写得非常薄,挂在需要保护的路由前面。管理员接口挂adminRequired,学生接口挂studentRequired,教师接口挂teacherRequired,一行代码的事,清晰得很。

2.3 Vue 3 + Vite + Element Plus,快速搭建管理端界面

前端我用的Vue 3 + Vite + Element Plus。选Vue而不是React的理由很简单:Element Plus这类组件库对中后台系统的覆盖度极高。表格、表单、弹窗、分页、日期选择器,全都是现成的,而且风格统一。对于选课系统这种需要大量信息录入和展示的场景,Element Plus能帮你省掉至少三成的前端工作量。

有同学会纠结"用Vue 2还是Vue 3",我的建议是直接Vue 3 +组合式API。现在Vue 3早就是默认选择,组合式API在组织逻辑的时候比选项式API舒服太多——比如选课页面的"加载课程列表""处理筛选条件""点击选课"这三个逻辑,用setup函数可以很自然地聚合在一起。

2.4 数据库选型:MySQL而不是MongoDB

这个是我翻过车之后才确定的。最初我想着"选课记录就是个关系嘛,用MongoDB存文档岂不是更灵活",但真实现起来发现,选课系统对事务性和约束性的要求非常高:

  • 抢课时扣减容量必须和插入选课记录在同一个事务里,要么都成功,要么都失败;
  • student_courses表必须保证"一个学生对同一门课只能有一条选课记录",这是唯一约束;
  • 课程冲突检测需要做"某个学生已选的所有课程"和"目标课程的时间段"进行交集判断,这种查询在关系型数据库里就是一个普通的JOIN + 条件过滤,而在文档数据库里写起来会非常别扭。

所以最终选型是MySQL(本地开发用8.0版本,部署用云厂商的MySQL也行)。ORM我用的Sequelize,但核心的抢课和退课接口我坚持写原生SQL + 事务。为什么?因为ORM在复杂事务里的行为不够直观,一旦涉及SELECT ... FOR UPDATE这种锁语法,用ORM表达反而绕。后面我会把抢课接口的完整SQL逻辑贴出来。

3. 角色与模块设计:三类用户入口如何划分,页面权限怎么定

系统一共三类角色,对应三种完全不同的操作台。这不是"功能多"的问题,而是如果你不分清楚,后面的路由守卫、接口鉴权、数据隔离就全都无从下手。

3.1 学生端:选课、退课、课表、成绩、评价

学生端的核心操作流是"浏览课程 -> 选课 -> 查看课表 -> 等成绩 -> 写评价"。

  • 课程大厅:按学期展示所有可选课程,支持按课程名称/教师姓名搜索,按课程类型(必修/选修)、学分、上课时间筛选。列表里必须实时显示剩余名额,名额为0的课程直接置灰。
  • 我的选课:展示当前学期已选课程,提供退课按钮(在选课窗口期内才允许退)。
  • 我的课表:按"周一至周日、第1-12节"的网格形式展示已选课程的时间分布。
  • 我的成绩:展示每门课的成绩(成绩由教师录入),只有已出成绩的课程才能进入评价入口。
  • 课程评价:从教学内容、课堂互动、作业负担、课程收获四个维度打分,外加一段文字评语。提交后不可修改(这是产品需求,如果你想让用户可以改,那就在表里加一个updated_at并开放更新接口,但我建议保持简单)。

3.2 教师端:课程列表、学生名单、成绩录入、评价统计

教师不需要看到全校所有课程,只看自己名下的课。

  • 我的课程:当前学期自己教的课列表。
  • 学生名单:点进某门课,看到选课学生列表。这里要注意,教师只能看到"选了这门课"的学生,绝对不能看到全校学生数据——数据隔离是这类系统的基本红线。
  • 成绩录入:给每个学生录成绩。录入后可以批量提交,也可以单个修改。
  • 评价统计:课程结束后,教师可以查看这门课的评价汇总:四个维度的平均分、评价人数、逐条评语列表。为了让教师"敢看真话",评价采用匿名展示,教师端不显示评语对应的学生姓名。

3.3 管理员端:课程管理、教师管理、选课窗口、数据总览

管理员是上帝视角,但也不能让他为所欲为,所有操作留痕是基本要求。

管理员主要负责:

  • 课程的创建、编辑、上下架(每个课程要设置学期、容量、上课时间、任课教师);
  • 教师账号的创建与分配;
  • 设定每学期的选课时间窗口(比如"2025-2026学年第1学期选课时间为9月1日8:00到9月5日20:00"),窗口期内学生才可操作选课和退课;
  • 查看全校选课统计,比如热门课程Top10、各专业选课率等。这些统计图表我用的是ECharts在前端绘制,后端只提供聚合好的JSON数据。

3.4 路由与页面清单

我把完整的前端路由结构列出来,方便你对照着设计自己的页面:

路径角色页面
/login游客登录
/student/courses学生课程大厅
/student/my-courses学生我的选课
/student/schedule学生我的课表
/student/grades学生我的成绩与评价入口
/teacher/courses教师我的课程
/teacher/courses/:id/students教师学生名单与成绩录入
/teacher/courses/:id/evaluations教师评价统计
/admin/courses管理员课程管理
/admin/teachers管理员教师管理
/admin/windows管理员选课窗口管理
/admin/overview管理员数据总览

页面的路由守卫我放在第6节细讲,这里只说一个原则:前端路由守卫只是用户体验的一部分,真正的安全边界一定在后端接口的鉴权中间件里。前端防君子,后端防小人。

4. 数据库建模的关键决策:选课关系表如何避免超选与重复评价

这一节是整个系统最值得花时间的部分。我在设计表结构时没有照搬那些"标准学生选课系统"的三张表,而是认真思考了每一张表要承担的约束职责。下面把这些核心表的结构和设计原因拆开来讲。

4.1 用户表:一张表还是三张表?

我见过不少设计是把学生、教师、管理员拆成三张独立的表。看着直观,但带来两个麻烦:登录接口要查三张表,代码里要写三段几乎一样的查询逻辑;如果未来有"既是学生又是助教"这种复合身份,表结构就可能会僵化。

我采用的是单用户表 + 角色字段的设计:

CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, role ENUM('student', 'teacher', 'admin') NOT NULL, name VARCHAR(50) NOT NULL, student_no VARCHAR(20) NULL, -- 学生学号,教师和管理员为空 teacher_no VARCHAR(20) NULL, -- 教师工号,学生和管理员为空 created_at DATETIME DEFAULT CURRENT_TIMESTAMP );

登录的时候只需要SELECT * FROM users WHERE username = ?,然后核对密码哈希、确认角色,签发JWT。后续所有接口通过JWT里的role字段区分身份和权限。

4.2 课程表与容量控制:冗余字段是不得已的取舍

课程表本身不复杂,关键在容量设计上:

CREATE TABLE courses ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, code VARCHAR(20) NOT NULL, -- 课程编号 credit DECIMAL(3, 1) NOT NULL, -- 学分 course_type ENUM('required', 'elective') NOT NULL, term VARCHAR(20) NOT NULL, -- 学期,比如 '2025-2026-1' teacher_id BIGINT NOT NULL, capacity INT NOT NULL, -- 总容量 selected_count INT NOT NULL DEFAULT 0, -- 已选人数,关键冗余字段 status ENUM('pending', 'selecting', 'ended', 'graded') NOT NULL DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP );

这里设计selected_count字段而不是每次选课时去COUNT(*)已选记录,是为了避免高频查询时的性能损耗。但正因为有了这个冗余字段,你就必须在每次选课/退课的事务里同时更新它,否则两边一不一致,马上就会出问题。

status字段是一个状态机:

  • pending:未开放选课,学生看不到或只能看到但不能选;
  • selecting:选课窗口中,可选可退;
  • ended:选课结束,但成绩还没录完;
  • graded:成绩已录入,学生可以开始评价。

课程时间安排我单独建了一张子表course_schedules,因为一门课可能在一周内有多个时段:

CREATE TABLE course_schedules ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, day_of_week TINYINT NOT NULL, -- 1-7 表示周一至周日 start_section TINYINT NOT NULL, -- 开始节次,1-12 end_section TINYINT NOT NULL, -- 结束节次 week_type ENUM('all', 'odd', 'even') NOT NULL DEFAULT 'all' -- 全周/单周/双周 );

4.3 选课关系表:唯一约束是防重复的最后一道防线

选课关系表是整个系统的核心,也是并发控制的主战场:

CREATE TABLE student_courses ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, selected_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id) );

uk_student_course这个联合唯一索引,保证一个学生对同一门课只能有一条选课记录。很多人会忽略这个约束,觉得"我代码里已经判断过了"。但并发场景下,代码判断永远是滞后的,唯一索引才是数据库层面的终极兜底。

4.4 成绩表与评价表:评价的"资格校验"要落到约束上

成绩表与选课关系是一对一的,一个学生一门课只有一条成绩:

CREATE TABLE scores ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_course_id BIGINT NOT NULL UNIQUE, score DECIMAL(5, 2) NOT NULL, graded_by BIGINT NOT NULL, -- 录成绩的教师ID graded_at DATETIME DEFAULT CURRENT_TIMESTAMP );

评价表的关键约束同样在唯一索引上:

CREATE TABLE course_evaluations ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, teaching_score TINYINT NOT NULL, -- 教学内容 1-5 interaction_score TINYINT NOT NULL, -- 课堂互动 1-5 workload_score TINYINT NOT NULL, -- 作业负担 1-5 gain_score TINYINT NOT NULL, -- 课程收获 1-5 comment TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_evaluation (student_id, course_id) );

uk_evaluation联合唯一索引,杜绝"同一学生对同一门课重复评价"。你可能会问:既然成绩表已经保证了"一个学生一门课只有一条成绩",那评价也只评一次不就能通过成绩表推导吗?理论上可以,但把唯一索引直接放在评价表上,成本极低、收益直接,属于"加一行就少一个隐患"的典型操作。

4.5 为什么要用事务和锁来操作容量

核心逻辑说白了就是一句话:所有"检查 + 修改"必须在一个数据库事务内完成,并且要给关键行加锁。MySQL默认的InnoDB引擎支持行级锁,我们可以在事务里执行SELECT ... FOR UPDATE把目标课程的行锁住,其他抢同一门课的事务就必须等这个事务提交或回滚之后才能继续。这个机制保证了并发选课的原子性,后面第5节我会给出完整代码。

5. 后端核心逻辑实现:抢课防超卖、冲突检测与评价防重复

后端我按模块来说,核心就是三个接口:选课、退课、提交评价。这三个接口写好了,这个系统的后端基本上就站住了。

5.1 抢课接口:事务 + 行锁,彻底解决超卖

先说结论,抢课的完整流程是:

  1. 校验用户角色是学生;
  2. 校验当前时间在选课窗口内(windows表里有开始和结束时间);
  3. 校验课程状态是selecting;
  4. 在事务里锁定课程行(SELECT ... FOR UPDATE);
  5. 检查剩余容量(capacity - selected_count > 0);
  6. 检查时间冲突(该学生已选的所有课程中,有没有和目标课程在同一时段重叠的);
  7. 插入选课关系记录;
  8. 更新课程的selected_count = selected_count + 1;
  9. 提交事务。

代码我用Express路由来写,关键部分如下:

// routes/student.js router.post('/courses/:courseId/select', authMiddleware.studentRequired, async (req, res) => { const { courseId } = req.params; const studentId = req.user.id; const connection = await db.promise().getConnection(); try { await connection.beginTransaction(); // 1. 锁定课程行 const [courses] = await connection.execute( 'SELECT * FROM courses WHERE id = ? FOR UPDATE', [courseId] ); if (courses.length === 0) { await connection.rollback(); return res.status(404).json({ message: '课程不存在' }); } const course = courses[0]; // 2. 校验课程状态 if (course.status !== 'selecting') { await connection.rollback(); return res.status(400).json({ message: '当前不在选课时间内' }); } // 3. 校验容量 if (course.selected_count >= course.capacity) { await connection.rollback(); return res.status(400).json({ message: '该课程名额已满' }); } // 4. 校验是否重复选课(唯一索引是兜底,这里提前判断是为了返回友好提示) const [duplicates] = await connection.execute( 'SELECT id FROM student_courses WHERE student_id = ? AND course_id = ?', [studentId, courseId] ); if (duplicates.length > 0) { await connection.rollback(); return res.status(400).json({ message: '你已经选过这门课了' }); } // 5. 校验时间冲突 const [conflicts] = await connection.execute( `SELECT c.name FROM student_courses sc JOIN course_schedules mine ON mine.course_id = sc.course_id WHERE sc.student_id = ? AND EXISTS ( SELECT 1 FROM course_schedules target WHERE target.course_id = ? AND target.day_of_week = mine.day_of_week AND target.week_type IN ('all', mine.week_type) AND mine.week_type IN ('all', target.week_type) AND target.start_section <= mine.end_section AND target.end_section >= mine.start_section )`, [studentId, courseId] ); if (conflicts.length > 0) { await connection.rollback(); return res.status(400).json({ message: `与课程「${conflicts[0].name}」时间冲突` }); } // 6. 插入选课记录 await connection.execute( 'INSERT INTO student_courses (student_id, course_id) VALUES (?, ?)', [studentId, courseId] ); // 7. 更新已选人数 await connection.execute( 'UPDATE courses SET selected_count = selected_count + 1 WHERE id = ?', [courseId] ); await connection.commit(); res.json({ message: '选课成功' }); } catch (err) { await connection.rollback(); // 如果是唯一索引冲突导致的插入失败,这里会捕获到 ER_DUP_ENTRY if (err.code === 'ER_DUP_ENTRY') { return res.status(400).json({ message: '你已经选过这门课了' }); } console.error(err); res.status(500).json({ message: '服务器内部错误' }); } finally { connection.release(); } });

这里有三个细节值得单独说:

第一,SELECT * FROM courses WHERE id = ? FOR UPDATE必须发生在所有检查之前。一旦锁定了课程行,其他抢同一门课的事务就只能排队等待,我们的"检查容量 -> 插入记录 -> 更新人数"整条链路就不会被干扰。

第二,为什么第4步重复选课判断明明已经做了,还要靠唯一索引兜底?因为在高并发下,两个请求可能同时通过了第4步的检查,然后同时走进第6步的INSERT,这时候uk_student_course会拦下后一个插入,抛出ER_DUP_ENTRY。这个错误我必须在catch块里捕获并转成友好的提示返回,否则前端会收到一个500的"服务器内部错误",用户根本不知道发生了什么。

第三,时间冲突SQL里的week_type处理是整个系统里最容易出错的地方。"单周课"和"双周课"在同一周几同一节次的情况下,是不冲突的;"全周课"和"单周课/双周课"则是冲突的。我的判断逻辑用上了两个方向的IN判断:目标课程的周类型必须和已选课程的周类型互相兼容,要么相同,要么一个是all。这个逻辑看起来对称,但你要是不仔细测试,很容易写出"单周课能同时选两门同时间的课"的bug。

5.2 退课接口:对称释放容量,别把窗口限制忘了

退课就是选课的逆操作,同样要开事务:

router.post('/courses/:courseId/drop', authMiddleware.studentRequired, async (req, res) => { const { courseId } = req.params; const studentId = req.user.id; const connection = await db.promise().getConnection(); try { await connection.beginTransaction(); // 锁定课程行,防止同时有人退课和选课导致数量错乱 const [courses] = await connection.execute( 'SELECT * FROM courses WHERE id = ? FOR UPDATE', [courseId] ); if (courses.length === 0) { await connection.rollback(); return res.status(404).json({ message: '课程不存在' }); } const course = courses[0]; if (course.status !== 'selecting') { await connection.rollback(); return res.status(400).json({ message: '当前不在退课时间内' }); } // 删除选课记录 const [result] = await connection.execute( 'DELETE FROM student_courses WHERE student_id = ? AND course_id = ?', [studentId, courseId] ); if (result.affectedRows === 0) { await connection.rollback(); return res.status(400).json({ message: '你未选这门课,无法退课' }); } // 释放容量 await connection.execute( 'UPDATE courses SET selected_count = selected_count - 1 WHERE id = ?', [courseId] ); await connection.commit(); res.json({ message: '退课成功' }); } catch (err) { await connection.rollback(); console.error(err); res.status(500).json({ message: '服务器内部错误' }); } finally { connection.release(); } });

这里必须注意:退课也要锁课程行。设想一下,一个学生退课释放了1个名额,与此同时另一个学生正在选这门课且容量只剩1个。如果退课事务不锁行,两个事务可能同时读到selected_count = 1,一个执行+1一个执行-1,最终结果看着没问题,但中间可能产生"选课判断容量已满但实际有空位"的意外。锁行让这些操作串行化,代价极小,收益是数据绝对可靠。

5.3 提交评价接口:三重校验 + 唯一索引兜底

提交评价要校验三件事:

  1. 该学生确实选过这门课(查student_courses);
  2. 课程状态是graded(成绩已录完才能评);
  3. 该学生对这门课没有评价过(查course_evaluations唯一索引兜底)。
router.post('/courses/:courseId/evaluations', authMiddleware.studentRequired, async (req, res) => { const { courseId } = req.params; const studentId = req.user.id; const { teaching_score, interaction_score, workload_score, gain_score, comment } = req.body; // 参数校验 for (const score of [teaching_score, interaction_score, workload_score, gain_score]) { if (!Number.isInteger(score) || score < 1 || score > 5) { return res.status(400).json({ message: '评分必须是1到5的整数' }); } } const connection = await db.promise().getConnection(); try { await connection.beginTransaction(); // 1. 查选课关系 const [scs] = await connection.execute( 'SELECT sc.id, c.status FROM student_courses sc JOIN courses c ON c.id = sc.course_id WHERE sc.student_id = ? AND sc.course_id = ?', [studentId, courseId] ); if (scs.length === 0) { await connection.rollback(); return res.status(400).json({ message: '你未选这门课,无法评价' }); } if (scs[0].status !== 'graded') { await connection.rollback(); return res.status(400).json({ message: '该课程成绩未发布,暂不能评价' }); } // 2. 插入评价记录 await connection.execute( `INSERT INTO course_evaluations (student_id, course_id, teaching_score, interaction_score, workload_score, gain_score, comment) VALUES (?, ?, ?, ?, ?, ?, ?)`, [studentId, courseId, teaching_score, interaction_score, workload_score, gain_score, comment || ''] ); await connection.commit(); res.json({ message: '评价成功' }); } catch (err) { await connection.rollback(); if (err.code === 'ER_DUP_ENTRY') { return res.status(400).json({ message: '你已经评价过这门课了' }); } console.error(err); res.status(500).json({ message: '服务器内部错误' }); } finally { connection.release(); } });

5.4 评价统计接口:一次SQL搞定平均分和评语列表

教师端的评价统计接口相对简单,一个聚合查询:

router.get('/teacher/courses/:courseId/evaluations', authMiddleware.teacherRequired, async (req, res) => { const { courseId } = req.params; // 先确认这门课确实是该教师名下 const [courses] = await db.query( 'SELECT id FROM courses WHERE id = ? AND teacher_id = ?', [courseId, req.user.id] ); if (courses.length === 0) { return res.status(404).json({ message: '课程不存在或无权访问' }); } const [[stats]] = await db.query( `SELECT COUNT(*) AS eval_count, ROUND(AVG(teaching_score), 2) AS avg_teaching, ROUND(AVG(interaction_score), 2) AS avg_interaction, ROUND(AVG(workload_score), 2) AS avg_workload, ROUND(AVG(gain_score), 2) AS avg_gain FROM course_evaluations WHERE course_id = ?`, [courseId] ); const [comments] = await db.query( 'SELECT comment, created_at FROM course_evaluations WHERE course_id = ? AND comment IS NOT NULL AND comment != \'\' ORDER BY created_at DESC', [courseId] ); res.json({ stats, comments }); });

注意教师端查询时,我先校验了课程的teacher_id是不是当前登录教师,否则就会出现"一个教师能看到别人课的评价"这种数据越权问题。这种大数据权限校验在每一个涉及多角色的接口里都要想一遍。

6. 前端实现细节:路由守卫、状态管理与页面复用

前端这块,我重点分享三个容易被忽略但又特别影响使用体验的点:登录态管理、选课按钮的交互细节、以及课表网格的实现思路。

6.1 路由守卫:前端防君子,但也要防得体面

我用Vue Router的全局前置守卫来控制页面访问:

// router/index.js import { createRouter, createWebHistory } from 'vue-router'; const routes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/student', component: () => import('@/layouts/StudentLayout.vue'), meta: { role: 'student' }, children: [ { path: 'courses', component: () => import('@/views/student/CourseHall.vue') }, { path: 'my-courses', component: () => import('@/views/student/MyCourses.vue') }, { path: 'schedule', component: () => import('@/views/student/Schedule.vue') }, { path: 'grades', component: () => import('@/views/student/Grades.vue') }, ], }, { path: '/teacher', component: () => import('@/layouts/TeacherLayout.vue'), meta: { role: 'teacher' }, children: [ { path: 'courses', component: () => import('@/views/teacher/MyCourses.vue') }, { path: 'courses/:id/students', component: () => import('@/views/teacher/CourseStudents.vue') }, { path: 'courses/:id/evaluations', component: () => import('@/views/teacher/CourseEvaluations.vue') }, ], }, // admin路由类似 ]; router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); const user = JSON.parse(localStorage.getItem('user') || '{}'); if (to.path === '/login') { next(); return; } if (!token) { next('/login'); return; } if (to.meta.role && to.meta.role !== user.role) { next('/login'); return; } next(); });

路由守卫做的是"体面的拦截"——用户没登录、或者角色不对,直接踢回登录页。但这只是UI层面的保护,后端接口的鉴权中间件才是最终防线。这里有个小坑:localStorage里存的用户信息过期之后,页面刷新可能还能看到内容,但发请求会401。所以我还在axios的响应拦截器里统一处理了401,跳回登录页并清掉本地token。

6.2 axios拦截器与跨域代理

前端所有请求都走axios,我在src/utils/request.js里统一配置了baseURL和请求头:

import axios from 'axios'; const request = axios.create({ baseURL: '/api', timeout: 10000, }); request.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( (response) => response.data, (error) => { if (error.response?.status === 401) { localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.href = '/login'; } const message = error.response?.data?.message || '网络异常,请稍后重试'; return Promise.reject(new Error(message)); } );

开发环境里的跨域问题,我是通过Vite的server.proxy配置解决的,把/api开头的请求代理到Node后端的3000端口:

// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, }, }, }, };

6.3 选课按钮的防重复点击与状态反馈

课程大厅里,每门课卡片上有一个"选课"按钮。最初我直接绑了个@click="selectCourse(course)",结果用户连点三下,前端发了三个请求,虽然后端有唯一索引兜底不会产生脏数据,但用户会看到两个错误弹窗,体验很差。

后来我改成了"点击后立即禁用按钮 + 显示loading + 请求完成后恢复"的模式:

<template> <el-button type="primary" :disabled="course.selected_count >= course.capacity || selectingId === course.id" :loading="selectingId === course.id" @click="handleSelect(course)" > 选课 </el-button> </template> <script setup> const selectingId = ref(null); async function handleSelect(course) { if (selectingId.value !== null) return; // 防止请求进行中再次点击 selectingId.value = course.id; try { await selectCourseApi(course.id); ElMessage.success('选课成功'); // 刷新课程列表,拿到最新剩余名额 await fetchCourses(); } catch (e) { ElMessage.error(e.message); } finally { selectingId.value = null; } } </script>

还有一个交互细节:选课成功后要立刻刷新课程列表中的selected_count,不要让用户看到"明明选成功了,剩余名额还是旧值"的错位。这里我用的是fetchCourses()重新拉数据,简单粗暴但可靠。

6.4 课表网格实现:表格合并与时间冲突的可视化

我的课表页面用Element Plus的el-table改造的:行是第1到第12节,列是周一至周日,每个课程占一个或多个节次。实现的方式是构造一个二维数组,每个单元格要么是空的,要么是课程名(跨行合并)。

合并用el-table的:span-method方法,根据当前行号和列号判断要显示什么。这里我遇到过一个问题:当一门课占3节、另一门课占2节,且它们在同一列相邻时,表格的合并算法必须保证合并行数不超过当前行的实际存在单元格。我的解决办法是预先把每个格子都填充上"占位数据",再在span-method里决定合并几行几列,这样就不会出现表格错位。

代码大概是这样:

const scheduleMatrix = computed(() => { const matrix = []; for (let section = 1; section <= 12; section++) { const row = []; for (let day = 1; day <= 7; day++) { row.push(null); // null 表示空课 } matrix.push(row); } for (const item of myCourses.value) { for (const schedule of item.schedules) { for (let s = schedule.start_section; s <= schedule.end_section; s++) { matrix[s - 1][schedule.day_of_week - 1] = { courseName: item.name, color: item.color, }; } } } return matrix; });

然后在表格的span-method里:如果某个单元格有课程且它上面一格没有同课程的单元格(即这是该课程的起始格),就做rowspan合并,合并行数等于课程占用节数;否则返回rowspan: 0表示这个格子被合并掉了。这个逻辑不难,但需要细心测试连续两门课的边界情况。

6.5 权限路由的前端细节:侧边栏菜单按角色动态渲染

除了路由守卫,侧边栏菜单也要按角色动态渲染。我在Layout里根据user.role渲染不同的菜单组,数据是写在前端的一个常量里:

const menus = { student: [ { path: '/student/courses', title: '课程大厅' }, { path: '/student/my-courses', title: '我的选课' }, { path: '/student/schedule', title: '我的课表' }, { path: '/student/grades', title: '我的成绩' }, ], teacher: [ { path: '/teacher/courses', title: '我的课程' }, ], admin: [ { path: '/admin/courses', title: '课程管理' }, { path: '/admin/teachers', title: '教师管理' }, { path: '/admin/windows', title: '选课窗口' }, { path: '/admin/overview', title: '数据总览' }, ], };

这样不同角色登录后,UI上只显示自己权限范围内的入口,观感干净,操作路径也短。

7. 实测中的坑与优化:从并发压测到用户体验的调整

系统功能全部写完、能跑通之后,真正的考验才刚开始。我用了一些测试脚本做了并发模拟,也让学生朋友们实际用了几天,反馈出不少问题。这一节我把典型问题和解决方案整理成一个清单,你以后做类似系统可以直接对着查。

7.1 并发压测暴露的容量超卖问题与修复过程

先说我最初的版本。我用Node的node:http模块写了一个并发模拟脚本,模拟20个请求同时抢同一门容量为1的课,结果如下:

选课成功的学生ID:12 选课成功的学生ID:7 选课成功的学生ID:19 选课成功的学生ID:3 ...

5个请求都返回了成功,课程容量变成了-4。原因正如我在第1节说的:初版代码是"先查容量,够就插入",检查和插入之间没有锁,多个请求同时通过检查。

修复方式就是第5节的FOR UPDATE方案。修复后再跑同样的脚本,结果变成:

选课成功的学生ID:12 选课失败的学生ID:7 -> 该课程名额已满 选课失败的学生ID:19 -> 该课程名额已满 ...

只有1个成功,课程容量从0变成1。这个结果是正确的。

这里我想强调一个正反馈:加锁之后,并发请求其实是串行处理的,每秒能处理的选课请求数量会下降。但选课系统的并发量通常远低于秒杀系统,一个学校同时在线选课几千人已经是很大压力了,MySQL的行锁完全扛得住。如果你未来真要做到"全校几万人同时抢课",那就要上Redis预减库存 + 消息队列异步落库的方案了,这是后话。

7.2 BETWEEN时间冲突判断的边界问题:跨节次和课间休息

时间冲突检测里有个很容易忽略的边界:甲课是第3-4节,乙课是第4-5节。按我的区间重叠公式:

target.start_section <= mine.end_section AND target.end_section >= mine.start_section

这里判定为冲突,是正确的。但如果中间有课间休息,比如甲课3-4节、乙课5-6节,这个公式也判定为不冲突,同样正确。问题在于"第4节下课时间和第5节上课时间"之间可能只有10分钟,有的学校是15分钟,你要不要算冲突?

我的处理是:按节次号直接判断,不引入具体时间分钟数。因为课间休息的分钟数是全局统一的,如果第4节和第5节之间有课间休息,默认不冲突;如果学校规定了"第4节下课到第5节上课之间必须间隔至少20分钟",那这个规则属于教学管理规矩,不属于课表排期的核心需求。保持简单,用节次号区间判断即可。

从实际使用反馈看,这个公式漏掉了一个场景:单双周。当时有朋友反馈"我选了一门单周的周三第7-8节,又选了一门双周的周三第7-8节,系统说不冲突,但我的课表上显示两门课重叠了"。看代码,我的冲突判断SQL已经处理了week_type,但前端课表的单元格合并逻辑没有考虑单双周,直接把两门课画在同一个格子上了。

前端修复方案:让课表查询接口把每门课的week_type也返回,然后在scheduleMatrix里给有单双周冲突的格子标记为"第单周/第双周显示"两种状态。考虑到咱们这个系统的定位,我在前端做了一个简化处理:同一时间格子里如果有多门课,就上下堆叠显示,同时标出各自的周类型。这样信息是完整的,虽然格子高一点,但绝对不会出现"明明有冲突却看起来不冲突"的误导。

7.3 评价统计的浮点精度坑:ROUND要放在最后一步

统计接口里我用的是ROUND(AVG(teaching_score), 2),看起来没问题,但如果你在应用层再做一次parseFloat和四舍五入,很容易出现4.845 -> 4.84还是4.85的差异。

我的建议是:SQL里算完平均分后,不要再用JavaScript的浮点逻辑处理。MYSQL的ROUND(..., 2)返回的是字符串形式的"4.85",前端拿过来直接展示就行。如果你非要转成数字,用Number()转一下,不要自己写四舍五入函数。这个小坑看着不起眼,但正好我踩过,说出来能帮你省十分钟排查时间。

7.4 事务嵌套与连接池耗尽:一个低级但致命的错误

写代码过程中我犯过一个很低级的错误:在选课接口的事务里,我调用了另一个封装了事务的方法(想顺便记录操作日志),结果两个方法各开了一个事务,二层的那个事务其实是在"假事务"状态里执行的,因为它用的是同一个连接对象。

后来我统一了这个规范:一个请求的业务链路只开一个事务,所有数据库操作都通过同一个connection执行。如果确实需要同时记录日志,就把它放在同一个事务里,或者干脆用单独的表连接去插(但不要和主业务一起提交)。这能避免很多"明明事务里报错了,但数据还是写进去了"的诡异bug。

另一个问题是连接池。开发环境下我一开始没有配置连接池上限,默认是10个。压测时并发一高,连接全部被占满,后续请求直接排队等待,响应时间飙升。我把连接池上限调到了30,并且确保了每个请求用完连接后都在finally里release()。就这个简单调整,压测时接口的P95延迟从1200ms降到了300ms左右。

7.5 前端体验优化:剩余名额实时刷新与选课结果反馈

学生反馈最多的体验问题不是功能少,而是"选课到底有没有成功"不够明确。比如点击选课后,按钮转圈了3秒,然后提示"选课成功",但列表里的剩余名额还是原来的。用户会怀疑系统是不是出错了。

我的解决方案是:选课接口返回成功之后,立即用最新的接口重新拉取当前课程列表,用服务端数据覆盖前端缓存。同时页面上加了一个小小的"最近选课记录"区域,展示刚选上的课程名称和时间,让学生在心理上有一个"已完成"的锚点。

另外还有一个细节:当课程容量只剩1时,按钮文案会变成"最后1个名额",给用户一点紧迫感,但也提示了稀缺性。这个文案在管理端是可以配置的,做成一个简单的开关,不过默认开启。

7.6 未来优化方向:Redis预减库存与消息队列削峰

最后说一点不算坑、但值得思考的方向。如果这门课真的出现了"开始选课3秒内万人抢一门容量100的课"的情况,MySQL的行锁会变成瓶颈。这时候常规的方案是:

  1. Redis预减库存:选课请求先打Redis,DECR课程库存,如果结果小于0则直接拒绝,不再打到数据库;
  2. 消息队列削峰:通过的请求写入消息队列,由后端消费者异步处理真实插入和计数更新;
  3. 分布式锁:在Redis层面加上SETNX锁来保证同一个学生在同一门课上的操作是串行的。

这些方案会显著增大系统复杂度,但也是学生选课系统从"实验室Demo"走向"生产可用"的必由之路。如果你的项目还需要做系统设计答辩,能讲清楚这些演进路径,是很加分的。

最后

这套系统我从数据库设计、后端接口、前端页面,到压测优化、真实使用反馈调整,前前后后花了大概两周时间。最大的体会是:选课系统的难点不在功能多,而在"数据的一致性"上。只要把并发选课、重复评价这两条硬约束用数据库机制卡死,这个系统就立住了。至于界面好看不好看、按钮圆角大不大,那都是锦上添花的东西。

如果你也正在做类似的课程设计或者个人全栈项目,我的建议是:先把数据库表结构和约束想清楚,再动写代码的心思。表设计对了,后面所有的接口、前端、压测都顺理成章;表设计一旦埋了雷,你会在项目后半程反复被同一个问题炸到。

还有一个小技巧:把课程、老师、学生的测试数据做足一点,至少要有50门课、10个老师、200个学生,每个学期的时间安排尽量错开。这样你的选课冲突检测、容量控制、评价统计才有足够复杂的场景去测。用假数据测系统,比写一百行评论区的经验都有用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 21:56:10

PRTG 通过阿里云 API 获取云产品运行状况:自定义传感器与 Python 脚本实践

1. 项目缘起与整体设计思路1.1 为什么要把云产品运行状况拉进本地监控体系很多团队在云上跑业务&#xff0c;本地机房或者办公网里已经有一套成熟的监控系统&#xff0c;比如 PRTG。它负责盯着物理服务器、交换机、专线、UPS 这些设备&#xff0c;值班同事每天看的就是那一个仪…

作者头像 李华
网站建设 2026/10/15 21:55:10

OpenClaw接入飞书机器人:应用创建、长连接与事件订阅配置指南

一直在用开源自动化框架干活的人&#xff0c;大概率都有过这种纠结&#xff1a;任务跑在本地&#xff0c;人在手机前&#xff0c;想临时改个指令、查个状态&#xff0c;总得打开终端或者登面板。我最近把某开源个人助理框架&#xff08;本文用OpenClaw代称&#xff09;接到了飞…

作者头像 李华
网站建设 2026/10/15 21:54:47

2020a海温海冰数据集实战:从网格结构到SST梯度检验的完整链路

简介&#xff1a;这份资源面向气象、海洋与气候方向的初学者及科研入门者&#xff0c;提供来自Met Office Hadley Centre观测数据集的全球海水表面温度与海冰浓度数据&#xff0c;并附带入门级处理代码&#xff0c;帮助读者快速了解nc文件的变量结构与数据组织方式&#xff0c;…

作者头像 李华
网站建设 2026/10/15 21:53:48

护网行动全攻略:从准备到复盘的60道工序详解

1. 护网到底怎么做&#xff1f;先把60道工序的完整框架摆出来1.1 为什么护网必须当一条工序流水线来对待这几年“护网”几乎成了安全圈绕不开的词。它不是某一次特定活动的代号&#xff0c;而是一类高强度攻防对抗的统称&#xff1a;攻击方会想尽办法突破边界&#xff0c;防守方…

作者头像 李华
网站建设 2026/10/15 21:53:33

耦合传输线奇偶模阻抗与耦合系数:原理、换算与工程实战

身边不少做射频、做硬件集成的朋友&#xff0c;第一次接触耦合传输线时都会觉得它“看起来很简单”&#xff1a;不就是两条微带线并排走一段吗&#xff1f;可一旦真到自己动手设计定向耦合器、滤波器&#xff0c;或者分析一条走线对另一条走线的串扰时&#xff0c;就会被奇偶模…

作者头像 李华