每次到学期末尾教务那边最头疼的事就是组织评教。以前是发纸质问卷一份一份收再用读卡机扫答题卡光统计一道题的得分率就得折腾两三天。后来某高校的计算机基础课程教研组决定换一套在线方案我接手做了全部前后端。前端用的 Vue后端用 Node.js 搭服务数据库选 MySQL最终做成了一套涵盖学生评教、教师查看反馈、管理员配置批次的完整系统。这篇博客就把整套系统的设计思路、核心代码、排坑经历都摊开讲讲适合正在做类似教务管理系统的开发者参考。我不知道你怎么看评教系统这类项目——很多人的第一反应是 CRUD 而已没什么技术含量。但真做起来完全不是这么回事。评教涉及三个角色、五张核心表、十几个接口、一套统计报表还要保证匿名性、防重复提交、限时开放等业务规则。如果你只是照模板生成代码上线第一周就会收到学生吐槽、教师投诉。下面我从业务梳理开始一步步讲清楚整套东西是怎么做出来的。1. 评教系统的业务底牌为什么 Vue Node.js 是这套组合1.1 先把评教这件事掰开揉碎在写第一行代码之前得把业务里所有参与者、场景和限制条件摆清楚。评教系统的用户看似只有学生实际上有三个完全不同的角色。学生要面对的是一批待评课程每门课按一套量表逐项打分最后还可以留一段文字评价。学生的诉求很简单填得快、不卡顿、别让我重复提交。教师想看到的是自己这门课的平均分、各维度得分、学生文字反馈但通常不能知道每条评语是谁写的也就是匿名性。教务管理员要负责创建评教批次、设置开放时间、关联课程和班级、查看整体数据、导出报表。核心流程是一条线管理员创建评教任务设置起止时间关联某学期的课程列表学生登录后看到自己的待评课程逐门提交打分系统实时汇总结果教师在指定日期后查看自己的反馈管理员跨课程跨批次做横向对比。这里有两个特别容易被忽略的业务细节。第一评教必须限定时间窗口——不是随时能填过了截止时间必须锁定。第二一门课程只能提交一次重复提交不仅没有任何业务意义还会污染统计数据所以接口层面必须做幂等。1.2 为什么不用 JSP/Spring而选了 Vue Node你可能会问教务系统这种典型的企业级业务为什么不用 Spring Boot JSP 或者 Spring Thymeleaf 这类保守组合我说说实际考虑。首先是表单密集型页面的开发效率。评教页面本质上是一堆量表组件评分项、维度标题、文本框、提交按钮。Vue 的组件化非常契合这种场景——量表选项可以抽成一个组件不同批次就算量表条目不同只要传入配置数组就能复用。如果用 JSP 加 jQuery你得写一堆重复的 DOM 操作维护起来更费劲。其次是前后端语言统一。正因为前端是 Vue后端用 Node.js 的话整套系统只有 JavaScript 一种语言。评价项的配置结构、表单提交的数据结构前后端可以各自定义一遍但思维模型必须一致要注意对齐字段调试时从网络请求到服务端逻辑是跳一个语法栈就能回来对单人开发来说效率明显高。第三是 Node.js 在轻量级服务端的表现足够。评教系统并发量并不高一个学期几百个学生同时提交也就几打 QPSNode.js 的异步 I/O 处理这种短事务 少量聚合查询的模式绰绰有余。Express 的中间件机制也让鉴权、日志、错误处理这些横切逻辑非常容易组织。当然不是没有缺点。Node.js 生态里的正统事务处理没有 Spring 那么顺手写事务的时候你得多花点心思但评教的业务复杂度没到需要分布式事务的地步单库单事务完全够用。2. 五张核心数据表评教系统背后串起业务的那根线2.1 角色、课程、批次怎么关联评教系统看起来实体不少但真正打底的只有五张表用户表、课程表、评教批次表、评价项配置表、评教记录表。分清楚它们之间的关联关系后面的接口设计才会顺。用户表存的是账号、密码哈希和角色字段我用users表统一存学生、教师、管理员三类账号不同角色通过role字段区分。课程表存课程基本信息并记录任课教师 ID。评教批次表是一次评教活动的元信息——批次名称、开始时间、结束时间。评价项配置表里存量表的每一条每条属于这个批次所以通过batch_id关联批次。最关键的是评教记录表。它记录哪个学生在哪一批次里给哪门课打了多少分、写了什么评语。这一张表同时关联了学生、批次、课程三个维度统计和防重复全靠它。2.2 表结构怎么建才合理直接贴核心 SQL我建表时做的关键决策都在注释里。CREATE TABLE users ( id int NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password_hash varchar(255) NOT NULL, role tinyint NOT NULL COMMENT 1-学生 2-教师 3-管理员, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE courses ( id int NOT NULL AUTO_INCREMENT, course_name varchar(100) NOT NULL, teacher_id int NOT NULL, semester varchar(20) NOT NULL COMMENT 如 2024-2025-1, PRIMARY KEY (id), KEY idx_teacher (teacher_id), KEY idx_semester (semester) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE evaluation_batches ( id int NOT NULL AUTO_INCREMENT, batch_name varchar(100) NOT NULL, start_time datetime NOT NULL, end_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE evaluation_items ( id int NOT NULL AUTO_INCREMENT, batch_id int NOT NULL, item_text varchar(255) NOT NULL COMMENT 评教题目标题, dimension varchar(50) NOT NULL COMMENT 维度如教学态度、教学内容, max_score int NOT NULL DEFAULT 5, PRIMARY KEY (id), KEY idx_batch (batch_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE evaluation_records ( id int NOT NULL AUTO_INCREMENT, student_id int NOT NULL, course_id int NOT NULL, batch_id int NOT NULL, total_score decimal(5,2) NOT NULL, comment text, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_student_course_batch (student_id, course_id, batch_id), KEY idx_course (course_id), KEY idx_batch (batch_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;那各维度得分存哪我没开一堆dimension1_score、dimension2_score的固定字段而是把一张批次的量表在提交时解析成 JSON 存入一个文本字段里。可以用ECMAScript序列化后的对象例如{教学态度: 5, 教学内容: 4}。这样做扩展性好——下个学期量表加了新维度你不需要改表结构。统计时前端拿到 JSON 再展示后端聚合时只要统一解析 JSON 即可。2.3 联合唯一索引是防重复的命门我在评教记录表上加了uk_student_course_batch联合唯一索引这个索引是整个系统防重复提交的基石。它的含义非常朴素同一个学生、同一门课、同一批次最多只能有一条记录。实际编码时提交接口的流程是先查后插两步走。先查一次如果发现已存在记录就直接返回你已评过这门课再执行插入。但仅靠查这一步还不够稳因为并发请求到来时两个请求可能同时查出没有记录然后都去插入——这时候联合唯一索引就在数据库这一层兜底后插入的那条会被拒绝接口捕获到ER_DUP_ENTRY就能把第二个请求当成重复提交处理。这两层防线叠起来才能真正保证脏数据进不来。课程和批次的关联我用batch_course_rel这种中间表自然会想到其实可以直接查courses表的semester字段来匹配批次学期不必多加一张表。如果系统要继续扩展跨班评教之类功能再引入独立的关联表也不迟。3. 权限与接口实现三种角色如何各守边界又协同工作3.1 基于 Token 的登录鉴权为什么不用 Session评教系统是前后端分离架构前端跑在浏览器后端接口是纯 API这种情况下我最推荐基于 Token 的方案具体用的jsonwebtoken这个库。登录成功后服务端签发一个 JWT把用户的 ID 和角色塞进 payload客户端存到 localStorage每次请求带上Authorization: Bearer token。服务端用一个中间件解析 token把用户信息挂到req上后面的业务代码直接取用。为什么不用 Session因为 Session 要求服务端维护状态前后端分离时还得处理跨域 Cookie、Session 同步问题对一个小型内部系统来说反而复杂了。JWT 是无状态的服务端不需要存任何会话记录横向扩展时不用搬 Session 存储这带来的代码简化非常明显。JWT 的签名用 HS256密钥放在环境变量里不要把密钥写死到代码仓库。以下是一个登录接口的简洁实现const jwt require(jsonwebtoken); const crypto require(crypto); async function login(req, res) { const { username, password } req.body; if (!username || !password) { return res.status(400).json({ message: 请输入账号和密码 }); } const hashed crypto.createHash(sha256).update(password).digest(hex); const [rows] await db.query(SELECT id, role, username FROM users WHERE username ? AND password_hash ?, [username, hashed]); if (rows.length 0) { return res.status(401).json({ message: 账号或密码错误 }); } const user rows[0]; const token jwt.sign( { userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: 12h } ); res.json({ token, username: user.username, role: user.role }); }密码存储我用了 SHA-256 哈希但对于实际生产环境建议使用bcrypt或argon2这类散列算法跑一次加盐运算再存。这里为了演示简单用了 SHA-256语气上重点提醒读者生产环境别照抄。3.2 鉴权中间件怎么组织Express 的中间件机制非常适合做权限统一控制。我先写了一个authMiddlewarefunction authMiddleware(req, res, next) { const authHeader req.headers[authorization]; if (!authHeader) { return res.status(401).json({ message: 未登录 }); } const token authHeader.split( )[1]; try { const decoded jwt.verify(token, process.env.JWT_SECRET); req.user decoded; next(); } catch (err) { return res.status(401).json({ message: 登录已过期请重新登录 }); } }然后在这个基础之上再封装一个requireRole中间件。因为学生的接口、教师的接口、管理员的接口各自的可见范围完全不一样必须在路由层面做角色校验而不能只靠前端隐藏菜单来防君子不防小人。一个请求就算直接拿 Postman 构造到了后端也一样会被拦下来。function requireRole(...roles) { return (req, res, next) { if (!req.user || !roles.includes(req.user.role)) { return res.status(403).json({ message: 无权访问该资源 }); } next(); }; }在路由上这样挂app.post(/api/evaluation/submit, authMiddleware, requireRole(1), submitEvaluation); app.get(/api/evaluation/teacher/dashboard, authMiddleware, requireRole(2), getTeacherDashboard); app.post(/api/batch, authMiddleware, requireRole(3), createBatch);3.3 评教提交接口的事务与幂等实现评教提交是整套系统里最核心的接口。前端提交的数据长这样{ courseId, batchId, scores: { 教学态度: 5, 教学内容: 4, ... }, comment: 讲得很好 }。后端要做三件事校验时间窗口、校验课程归属、幂等插入。时间窗口判断放在最前面——如果当前时间早于批次开始时间或晚于结束时间直接返回 403前端页面也要配合隐藏按钮但后端的判断才是真正的裁判。async function submitEvaluation(req, res) { const { courseId, batchId, scores, comment } req.body; const studentId req.user.userId; // 1. 校验批次时间窗口 const [batchRows] await db.query( SELECT id, start_time, end_time FROM evaluation_batches WHERE id ?, [batchId] ); if (batchRows.length 0) return res.status(404).json({ message: 评教批次不存在 }); const now new Date(); if (now batchRows[0].start_time || now batchRows[0].end_time) { return res.status(403).json({ message: 不在评教开放时间段内 }); } // 2. 校验该课程是否属于该学生的待评列表 const [courseRows] await db.query( SELECT id FROM courses WHERE id ? AND semester (SELECT semester FROM courses WHERE id ?), [courseId, courseId] ); // 真实项目里课程和学生的关联需要一张选课表这里简化成校验 course 是否存在 // 3. 计算总分并组装数据 const totalScore Object.values(scores).reduce((a, b) a b, 0); const scoresJson JSON.stringify(scores); // 4. 幂等插入利用联合唯一索引兜底 const connection await db.getConnection(); try { await connection.beginTransaction(); await connection.query( INSERT INTO evaluation_records (student_id, course_id, batch_id, total_score, scores_json, comment, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW()), [studentId, courseId, batchId, totalScore, scoresJson, comment] ); await connection.commit(); res.json({ message: 评教提交成功 }); } catch (err) { await connection.rollback(); if (err.code ER_DUP_ENTRY) { return res.status(409).json({ message: 该课程已评教请勿重复提交 }); } return res.status(500).json({ message: 服务器内部错误 }); } finally { connection.release(); } }这里用connection.beginTransaction()的原因是如果后面我要连带更新反作弊表或者统计缓存表这些写操作需要保持原子性。评教记录本身这一步单独一条 INSERT 并不需要事务但事务让系统在扩展时更安全所以我在设计方案里保留了它。有一个容易踩的坑需要提醒getConnection()获取连接后必须在finally里release()否则连接池会被耗尽。我一开始漏了finally跑了几轮压测后连接池直接枯竭接口全部超时排查了很久才定位到。3.4 学生和教师各自的接口清单学生端需要三个接口查询自己的待评列表、查询某批次量表、提交评教。查询待评列表的 SQL 是重点——不能把学生已经评过的课程也查出来。我是这么处理的SELECT c.id, c.course_name, e.batch_id FROM courses c JOIN ( SELECT batch_id, course_id FROM batch_course_mapping WHERE batch_id ? ) bcm ON c.id bcm.course_id WHERE c.semester ? AND c.id NOT IN ( SELECT course_id FROM evaluation_records WHERE student_id ? AND batch_id ? )这个NOT IN子查询在数据量小的时候没问题但如果未来单学期评教记录超过十万条性能就要考虑优化。改造方向是用 LEFT JOIN 加IS NULL判断或者把学生已评课程主键存到一个 Redis Set 里每次都去查内存。评教系统这种场景数据量不大SQL 原生方案足够。教师端比较重要的是查看自己课程的统计结果。这里我做了两个接口一个是课程总分平均分布一个是各维度得分明细。两个接口都是SELECTGROUP BYAVG就能搞定SQL 写起来并不复杂但要注意课程必须通过教师 ID 过滤确保一个教师只能看到自己的课程数据。4. 评教数据的统计与可视化把打分变成决策依据4.1 评教数据到底应该看什么指标评教数据如果只是学生打了一堆分那毫无意义。管理者真正要看的指标其实是这么几个单门课程的平均总分的横向排名同一个教师多个学期的纵向趋势各维度得分的雷达分布文字评价的高频词这个是我后来加的功能能快速看出学生集中吐槽的点在哪里。学生提交时我把每个维度的得分单独存进了scores_json字段这样做统计只要把该字段解析出来。但如果你打算用 SQL 聚合不能直接用文本字段做数字聚合。两个路线任选其一要么在保存维度分时再建一张明细表用行列存储要么在后端用 JS 把每一条 JSON 解析后做求和。评教系统记录条数有限我用的是后者——先 SELECT 出目标记录集再用 JavaScript 做维度聚合简单直接代码还好调试。4.2 后端统计接口的设计这门课平均分排名的查询SELECT c.id, c.course_name, AVG(e.total_score) AS avg_score, COUNT(*) AS cnt FROM evaluation_records e JOIN courses c ON e.course_id c.id WHERE e.batch_id ? GROUP BY c.id, c.course_name ORDER BY avg_score DESC;各维度得分的处理稍微绕一点。我把scores_json字段全部取出后在内存里维护一个Map键是维度名值是 { 总分, 数量 }循环累加再求平均最后按维度输出。const rows await db.query(SELECT scores_json FROM evaluation_records WHERE batch_id ?, [batchId]); const dimensionAgg new Map(); for (const row of rows) { const scores JSON.parse(row.scores_json); for (const [dim, score] of Object.entries(scores)) { if (!dimensionAgg.has(dim)) { dimensionAgg.set(dim, { sum: 0, count: 0 }); } const agg dimensionAgg.get(dim); agg.sum score; agg.count 1; } } const result [...dimensionAgg.entries()].map(([dim, { sum, count }]) ({ dimension: dim, avgScore: Number((sum / count).toFixed(2)) }));这个实现有一个明显的性能优化空间。如果评教记录量变大每次都全量拉 JSON 再解析肯定是浪费。优化方向是给scores_json对应的维度单独建列然后直接用 SQL 的AVG()函数一行聚合搞定。不过对于评教系统来说一个批次几千条记录内存聚合的耗时基本在毫秒级完全可以接受。4.3 ECharts 画雷达图和横向柱状图统计结果最终要给教务和教师看不能丢给他们一堆数字。我前端用的是 ECharts学生评教页面和教师统计报告页面各用了几种图表。雷达图展示各维度均分一眼就能看出教学态度5 分拉满教学内容只有 3 分教师哪里要改心里有数。横向柱状图按课程平均分排序教务能清楚看到哪些课程评价偏低需要重点关注。ECharts 的配置本身不复杂雷达图我贴一段配置骨架const chart echarts.init(document.getElementById(radarChart)); chart.setOption({ radar: { indicator: dimensions.map(d ({ name: d.dimension, max: 5 })), radius: 65% }, series: [{ type: radar, data: [{ value: dimensions.map(d d.avgScore), name: 各维度平均分 }] }] });需要注意的一点是图表数据最好由后端接口统一返回不要在页面里直接用fetch出来了再自己做二次加工。前端只负责渲染后端保证数据口径一致。不同页面各算各的最后统计结果对不上排查起来非常痛苦。5. 实测排坑记录评教系统开发里容易翻车的细节5.1 跨域配置开发环境用代理生产环境用反向代理Vue 开发服务器默认跑在 5173 端口后端服务跑在 3000 端口浏览器直接请求 3000 会遇跨域问题。我一开始图省事后端装了个cors中间件直接开放所有源干上线后来发现这么干等于把接口裸奔任何人从其他域名都能调你的接口。正确做法是生产环境同域部署——把前端打包后的dist目录交给 Nginx 托管接口路径用/api/转发到本地的 Node.js 服务。这样浏览器访问的是同一个域名端口不存在跨域问题。开发环境也不要开全局 CORS正确的配置是在vite.config.js里配代理export default { server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } };前端把自己的请求统一写成以/api开头开发时由 Vite 代理转发生产时由 Nginx 转发前端代码完全不用区分环境地址。这个小细节帮我省了很多事要是一开始在代码里写死了http://localhost:3000后面部署还得全局替换。5.2 登录过期后前端 401 的全局处理评教提交过程有个很实际的场景学生填了十分钟评语点提交时 token 过期了后端返回 401页面却没有任何提示数据流静默失败——这个体验非常差。我的解决方法是前端给 axios 统一加响应拦截器遇到 401 时清掉 localStorage 里的 token跳回登录页但保留当前填写内容到路由参数里面重新登录后还能接着提交。这属于提升容错率的小技巧但对真实用户非常有用。axios.interceptors.response.use( response response, error { if (error.response error.response.status 401) { localStorage.removeItem(token); const currentPath router.currentRoute.value.fullPath; router.push({ path: /login, query: { redirect: currentPath } }); } return Promise.reject(error); } );5.3 评教时间窗口前端隐藏和后端校验要双写还有一处是时间控制。教务创建批次时设置了截止时间前端在时间到之后自动把提交按钮置灰、提示评教已截止这看起来没问题吧但前端隐藏只是体验层处理API 还是可以被直接调用的。如果学生熟悉 DevTools完全可以在截止后继续调提交接口。所以我在后端提交逻辑里又做了一次时间判断双重保险。这是安全设计的底线思路前端约束是给普通用户用的后端校验才是系统真正的防线。谁跟你说前端隐藏就够了千万别信。5.4 匿名性保护比想象中要更严谨评教系统有一个很容易被忽略的匿名性问题。界面写匿名提交但数据库里存了student_id数据层面的匿名保护要怎么做我的方案是查询教师接口时绝不返回student_id字段。后端SELECT列表只返回total_score, scores_json, comment, create_time从源头截断关联可能。此外评语里如果有学生自称信息如我是 3 班 XXX这种数据清洗暂时不自动化处理但教务导出时会有提醒。之前有系统就是因为教师接口返回了student_id的明文被学生截图曝光后引发了不小的信任危机。这条必须写进保密校验里。5.5 连接池耗尽这只 隐形杀手前面已经提到一次了我再展开讲。MySQL 连接池用mysql2默认配置时connectionLimit默认是 10。如果代码里每次查询都用await db.getConnection()而忘掉release()连接池很快被打满。小程序或者学生端脚本稍微并发请求一下整个服务就卡死了。我的处理方式其实很简单普通单条 SQL 查询直接走db.query()让连接池自己管理连接生命周期只有必须放在事务里的操作才手动getConnection()并try/finally释放。此外把connectionLimit提到 20加了queueLimit为 50防止突发流量时无限排队。我专门拿几千条假数据模拟过高峰期压测连接池一直稳定在个位数占用接口响应都在 60ms 以内表现不错。6. 一点实测体会系统上线后跑了整整一个学期我自己总结下来的核心体会是评教系统这类项目业务规则远比技术方案复杂。防重复、限时开放、匿名保护、角色边界——每一处都是需求里没有明说但绝不能遗漏的点。如果你只做表面的提交打分功能运行起来一定会出乱子重复评教、超时提交、教师看到学生 ID每一样都能让系统崩出信任危机。最后分享一个小建议评教系统的前端页面不要做太多设计堆砌学生填量表时只需要最快的路径。不要放花哨的动画、不要搞多步引导一张页面滚到底点提交是最舒服的交互。教师端可以适当丰富一些图表多一点、维度更清晰因为那是被服务的重点学生那边反而是流程越短越满意。这套系统三个月后又要跑新学期的评教了我个人目前在规划加入横向匿名评语聚合和导出 PDF 报表的功能。评教数据的价值不在收集而在分析和反馈的闭环这点越早想明白做出来的系统就越有深度。