说实话,第一次听到“高校考务管理系统”这个需求的时候,我是有点抗拒的。我当时的想法很简单:排个考试、发个通知,用 Excel 加微信群不就够了?结果真坐下来聊了一个下午,发现自己错得离谱。
某高校信息中心找我们做这套系统时,教务处的 A 老师直接在会议室打开了一个文件夹,里面是六个学期的排考表格。VLOOKUP 套 VLOOKUP,人工标的冲突红黄绿一片。教室被重复排、监考老师被分到两个同时段考场、学生通知发完总有人“没看到”。那一刻我意识到,考务工作的本质不是“排个表”,而是一套资源调度系统,只是过去所有调度都靠人肉完成。
我们给这套基于微信小程序的高校考务管理系统起了个内部代号:Badminton。起因挺随性,产品经理在会议室角落比划着一副羽毛球拍说,考务系统干的事情就是“把考试安排、监考任务、成绩单这些球,精准打到每个老师和学生手上”。羽毛球讲究落点精准、一来一回不能落地。名字就这么定了,后来反而成了整个项目的记忆点。
如果你也在做类似的项目,比如智慧校园、教务数字化、无纸化办公相关的系统,这篇文章应该能给你节省不少弯路。我会把这套系统的需求拆解、技术选型、数据库设计、考场分配算法、踩坑实录讲清楚,尽量还原当时真实决策的过程。
| 热搜词 | 热度表现 | 与考务系统的关系 |
|---|---|---|
| 高校数字化 | 长期高位 | 教务线上化是目前校园数字化的刚需入口 |
| 智慧校园 | 稳定增长 | 考务系统是智慧校园中高频使用的核心模块之一 |
| 无纸化考试 | 上升趋势 | 成绩录入、通知发布、座位查询全流程无纸化 |
| 考试安排 | 周期性爆发 | 每学期末排考冲突是高校管理的集中痛点 |
一、立项之前:考务系统不是“做一个小程序”那么简单
先说需求调研。我们花了两周时间蹲在教务处,看老师们的日常工作流。看到的真实场景是这样的:每学期有几百门课程要安排考试,涉及几千名学生、几十间教室、上百位监考老师。负责排考的教务员需要手动确认三个不冲突——教室不冲突、时间不冲突、监考人员不冲突。全部靠 Excel 和纸质表格完成,一旦出现调课、补考、临时借用教室,整个排考表就要推倒重来。
1.1 真正的痛点藏在细节里
我整理了一下,核心痛点比想象中具体得多:
第一个痛点是教室资源冲突。排考的时候,不同课程的考试时间可能重叠,但教室只有一个,传统做法是靠教务员记忆和肉眼比对,出错的概率非常高。第二个痛点是监考老师冲突。一个老师如果同时段被分到两个考场,系统不会自动报错,只能靠人工逐条核对。第三个痛点是学生通知触达。考试安排确定后,通常由辅导员转到班级群,再一层一层转发,总有人漏看或者看错时间地点。第四个痛点是成绩上报效率。任课老师手里的成绩单一式三份,纸质签字、拍照传送、教务处手工录入,前后周期能拖好几周。
1.2 角色和功能边界:三方入口要一次想清楚
高校考务系统的特殊性在于角色非常清晰,但权限边界必须严格。学生、教师、教务管理员,三类人看到的界面和操作范围完全不同。我们最初的需求文档里,把功能边界画得很明确,这也是 Badminton 项目后面没有返工的核心原因。
| 角色 | 核心功能 | 解决的问题 | 优先级 |
|---|---|---|---|
| 学生 | 查看考试安排、查询考场座位、接收考试通知、查看成绩 | 不用层层转发,信息直达学生本人 | P0 |
| 教师 | 查看监考任务、录入并提交成绩、接收考试变更提醒 | 减少纸质流程,成绩线上化 | P0 |
| 教务管理员 | 创建考试、分配教室、安排监考、导入学生名单、发布成绩 | 避免人工排考的冲突问题 | P0 |
| 系统管理员 | 维护基础数据、管理用户权限、监控运行日志 | 解决账号权限和数据安全问题 | P1 |
这个表格看着简单,但我们后来项目推进中所有棘手的问题,几乎都能回溯到某个角色的边界没有划清。比如成绩录入,到底允许谁改、谁能看全部成绩、教师能不能看到其他老师的分数,如果没有在需求阶段定死,开发中途一定会吵架。
1.3 一句话定义这个系统
说到这可以给 Badminton 下个准确定义:它不是考试信息展示板,而是一个“教室资源 + 教师资源 + 时间窗口 + 学生数据”的调度平台,小程序只是它的交互层。
理解这一点非常重要。因为你一旦把它当成展示板来做,数据库只需要几张表就够了。但当成调度系统来做,你就要考虑资源冲突、约束条件、分配算法、异常流程这些硬核问题。一个学期排考三天变成三分钟,靠的不是小程序界面,而是背后的资源调度逻辑。
二、技术选型与架构:为什么是 UniApp 加微信云开发
技术选型是 Badminton 项目里争论最多的一环。倒不是选项太少,恰恰是选项太多。原生微信小程序、uni-app 搭配自建后端、uni-app 搭配云开发,我们三个方向都做了预研,最终选了 uni-app + 微信云开发的组合。
2.1 三个备选方案的对比
先放结论表,再展开解释理由。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 原生小程序 + 自建后端 | 控制力最强,接口完全自主 | 需要租服务器、配置域名、做安全证书、维护环境 | 有专属运维团队的长期项目 |
| uni-app + 自建后端 | 一次编码多端运行,自由度较高 | 依然要维护服务器和数据库,前后端联调成本高 | 既要小程序又要 App 和 H5 的重型项目 |
| uni-app + 微信云开发 | 免运维、前端直连云函数、天然鉴权 | 冷启动有延迟、复杂事务弱、厂商锁定 | 中小型业务、多端需求、快速交付项目 |
最终选择云开发,理由很实在。这个项目没有独立运维团队,学校的信息中心虽然有人管机房,但不可能为一个小程序单独配一个后端运维岗。云开发免去了服务器、备案、证书这些杂事,云函数本身就是接口,云数据库直接存储,前端调用起来非常顺手。
2.2 为什么前端锁死 uni-app
前端这里没有太多悬念。uni-app 基于 Vue 语法,团队本来就熟悉那一套组合式 API 的写法,开发上手成本很低。另一个考虑是多端适配。虽然学校最初只要微信小程序,但老师手里有安卓、苹果各种机型,学生端也有可能要做成 App 或者 H5,uni-app 一次编码能编译到多个平台,后期扩展成本小。
实测下来,uni-app 在微信小程序端的表现很稳,组件编译基本没有大坑,条件编译也可以精确处理多端差异。唯一要注意的是不要用太多太偏的第三方 UI 库,小程序端兼容性容易出问题。我们当时全部用自定义组件加少量原生样式,页面轻、加载快,效果反而更可控。
2.3 整体架构分层
Badminton 的架构逻辑上分四层,实际部署只有三个东西:小程序端、云函数、云数据库。
| 架构层 | 职责 | 核心内容 | 部署位置 |
|---|---|---|---|
| 客户端 | 用户交互与展示 | 学生端、教师端、管理员端三个小程序页面集合 | 微信小程序 |
| 业务逻辑层 | 接口与调度算法 | 考试创建、考场分配、监考排班、成绩发布 | 云函数 |
| 数据存储层 | 业务数据持久化 | 用户、考试、教室、分配记录、成绩、通知 | 云数据库 |
| 文件存储层 | 非结构化文件 | Excel 成绩单、考试附件 | 云存储 |
目录结构当时是这么设计的,现在回头看依然简洁:
├── pages │ ├── student │ │ ├── examList │ │ ├── seatQuery │ │ └── score │ ├── teacher │ │ ├── invigilation │ │ └── scoreInput │ └── admin │ ├── examCreate │ ├── roomAllocate │ └── scorePublish ├── cloudfunctions │ ├── login │ ├── examList │ ├── allocateRoom │ ├── arrangeInvigilator │ └── importScore └── utils └── date.js这个结构最大的好处是:页面按角色分目录,云函数按业务分文件,任何人接手项目,瞄一眼目录就知道哪里找什么逻辑,基本不需要额外文档。
2.4 云开发的三条软肋要提前知道
选择云开发不代表它可以无脑用,甚至需要比自建后端更谨慎地设计。冷启动就是第一个问题。小程序的云函数如果长时间没有被调用,会进入冷启动状态,第一次请求可能要多花一两秒。高峰期全校学生一起查考试安排的时候,冷启动叠加并发,体验会明显变差。我们当时的应对方案是把高频查询的公共数据做前端缓存,考试列表和座位信息在考试周内几乎不会变,学生端进入页面先读本地缓存,再静默拉取更新。
第二个软肋是事务能力弱。云数据库虽然有事务 API,但使用限制多、性能一般。考场分配和监考排班这类写操作涉及多张表联动,我们没敢依赖数据库事务,而是把整个分配过程放在一个云函数里跑,内部用 Node.js 的逻辑做回滚补偿,分配完毕后把结果作为一个批量记录写入数据库。这个思路后面验证下来是稳妥的。
第三个软肋是数据量的处理能力。单个云数据库集合在数据量超过一定量级后,查询和索引性能会下降,但这套系统目前的规模远没到瓶颈。几千条考试记录、几万条成绩数据,对云数据库来说是小意思。
三、数据库与核心算法:考场分配和监考排班才是灵魂
很多人做管理系统,第一步就盯着界面画原型,把数据库和算法放在后面。Badminton 正好反过来,我们先把数据模型和两个核心算法定死,再回头写页面,开发效率反而高得多。
3.1 核心集合的设计思路
云数据库的集合相当于传统关系型数据库里的表。我没有照搬 MySQL 里的三范式设计,而是针对真实的查询场景做了一定冗余。
| 集合名 | 核心字段 | 说明 |
|---|---|---|
| users | openid、role、name、memberId、department | 用户统一表,按 role 区分学生教师管理员 |
| exams | examId、courseName、semester、startTime、endTime、status | 考试元数据,status 区分草稿和已发布 |
| rooms | roomId、building、capacity、available | 教室资源池 |
| arrangements | examId、roomId、timeSlot、studentIds | 考场分配主记录 |
| invigilations | examId、roomId、teacherId、timeSlot | 监考任务表 |
| scores | examId、studentId、score、status、operatorId | 成绩记录,含操作人审计字段 |
| notifications | userId、type、content、isRead | 站内消息中心,订阅消息失败时的兜底 |
每个集合设计时的核心原则是“查询友好”。比如 arrangements 里直接冗余了 roomId 和 timeSlot,学生查座位时不需要再去 rooms 和 exams 两张表做关联。虽然多占了一点存储,但小程序端查询路径更短,响应更快。
3.2 考场分配算法:贪心加冲突检测
考场分配是 Badminton 里最有含金量的一块逻辑。需求拆解下来是这样的:一门考试有若干个学生,系统从教室池里挑出容量足够的空闲教室,把学生放进去,同时保证同一个班的同学尽量分到同一个考场,某个考场如果塞不下,再按稳定学号顺序拆到相邻教室。
实际算法用了两段式。第一段按考试人数倒序排列,优先处理人数最多的考试。第二段把可用教室按容量升序排列,从小到大依次匹配。匹配完成后立即跑一个冲突检测,确保同一时间段同一个教室没有被两场考试重复占用。
下面是我简化后的核心代码:
// 考场分配:贪心匹配 + 冲突检测 async function allocateRoom(examId, studentIds, availableRooms) { // 按人数倒序处理考试,每场考试独立分配 for (const exam of examsSortedByStudentCount) { let remainingStudents = exam.studentIds.length; const allocatedRooms = []; // 教室容量从低到高依次匹配 const rooms = availableRooms .filter(room => room.available) .sort((a, b) => a.capacity - b.capacity); for (const room of rooms) { if (remainingStudents <= 0) break; // 冲突检测:同一时段教室是否已被占用 const conflict = await checkRoomConflict( room.roomId, exam.timeSlot ); if (conflict) continue; const acceptCount = Math.min(room.capacity, remainingStudents); const assigned = studentIds.splice(0, acceptCount); allocatedRooms.push({ roomId: room.roomId, timeSlot: exam.timeSlot, studentIds: assigned }); remainingStudents -= acceptCount; } if (remainingStudents > 0) { throw new Error(`考场容量不足:${exam.examId}`); } // 批量写入分配记录 await insertArrangements(allocatedRooms); } }这段代码逻辑比较直白,工程化的关键在于把“同班尽量同场”拆成了两个规则:优先找一个容量刚好覆盖整个班级的教室;如果找不到,就按学号顺序拆分,拆完的记录在界面上标注“该班部分同学在 B 栋”,方便教务老师提前通知。
实测这套策略能覆盖绝大多数排考场景,极端情况比如全校统考同时进行,少数人数特别多的课程需要拆分到两三个考场,也不会有大问题。
3.3 监考排班算法:均衡轮转加冲突拒绝
监考排班比考场分配更绕,因为约束不是硬性的,老师之间存在公平性问题。同一个学院的老师,谁监考多一次谁监考少一次,教务老师非常在意。我们的算法目标是尽量让每位老师的监考总次数接近。
做法是维护一个已分配监考次数计数器,每次安排监考时,把候选老师按已分配次数升序排列,优先选监考次数最少的老师。同时检查两个硬冲突:同一时间段不能出现在两个不同的考场;同一老师不能同时被分配为某场考试的监考和自己所授课程的考试主考。
// 监考排班:优先已分配次数少的老师,冲突直接拒绝 function pickInvigilator(candidates, timeSlot, existingTasks) { const sorted = candidates.sort( (a, b) => a.assignedCount - b.assignedCount ); for (const teacher of sorted) { const hasConflict = existingTasks.some(task => task.teacherId === teacher.teacherId && task.timeSlot === timeSlot ); if (!hasConflict) return teacher; } return null; // 全部冲突,需要换时间段或扩充教师池 }这个算法看着简单,实际上足够解决 90% 的监考分配问题。剩下的 10% 是特殊场景,比如某位老师监考次数已经达到学院规定的上限、某位老师当天请假,这些情况我选择直接给教务管理员留一个人工调整入口,不强行自动化。排考系统成熟的重要标志,就是知道哪些环节该让机器做,哪些环节必须让人说了算。
3.4 座位编排的细节
考场分配完成之后,还有一个容易忽略的点:座位号编排。我们把学生名单按学号顺序分成两组,一组从头往尾排,一组从尾往头排,形成蛇形布局。
这样做的目的很简单,让同一个班的学生相对分散,避免考场内同一班级学生扎堆,也方便监考老师清点人数。座位号生成后,考前 24 小时才对全体学生开放查询,防止提前泄露考场布局带来管理上的麻烦。这个时间窗口我们是通过云函数读取系统时间动态控制的,不是简单地在数据库里加一个发布时间。
四、功能实现、测试及上线:那些教科书里没有的坑
这一部分是 Badminton 里真正的“血泪史”。每个坑都对应一次真实的线上事故或者加班到深夜的排查,拿出来说既算复盘,也希望后来的同行少踩两脚。
4.1 身份登录与角色绑定的实现细节
微信小程序登录听起来很简单,用 uni.login 拿 code,云函数里换 openid 就行。但考务系统的特殊性在于:拿到 openid 后还要知道这个人是谁。微信的 openid 只代表微信用户,不等于学生学号,也不等于教师工号。
我们设计的登录注册流程是:首次进入小程序时,用户在绑定页面输入学号或工号,调云函数绑定到当前 openid。绑定后再次进入,直接根据 openid 拉取用户信息。这里有个安全细节容易被忽视,绑定接口一定不能做成“输入学号就把该学号和当前 openid 绑定”,必须校验用户输入的身份信息与数据库登记信息一致,否则任何人都可以把自己绑定到别人的学号上,后续成绩查询就会出大问题。
登录云函数核心代码大概长这样:
// 登录绑定云函数 exports.main = async (event) => { const { OPENID } = cloud.getWXContext(); const { memberId, role, verifyCode } = event; const user = await userCollection .where({ memberId, role, verifyCode }) .get(); if (user.data.length === 0) { throw new Error('身份信息校验失败'); } await userCollection .where({ memberId }) .update({ data: { openid: OPENID } }); return { openid: OPENID, role }; };身份绑定接口上线前,我们额外加了一层限制:同一个 memberId 只能绑定一个 openid,如果已绑定且不一致,走人工申诉流程。这个设计保住了系统的底线。
4.2 考试发布的状态机设计
考试从创建到学生可见,中间要经历好几个状态。Badminton 把考试状态设计成了三个:草稿、已发布、已结束。草稿状态下,只有教务管理员能看到。点击发布后,云函数批量给相关学生和监考老师插入通知记录,同时尝试发送微信订阅消息。
订阅消息是另一个细节点。微信小程序的一次性订阅消息,用户授权几次就能收到几次,一次性授权用完就没了。大部分学生不会有耐心反复点授权,所以只靠订阅消息做通知,一定会有大量漏发。我的解决方式是“订阅消息 + 站内消息中心”双通道。订阅消息发成功的,站内通知标记为已读,没发出去的,学生进入小程序时在消息中心看到红点提醒。实测下来,信息的有效触达率比单纯依赖微信通知高很多。
4.3 成绩导入:SheetJS 云函数解析的边界
成绩录入是教务老师最关心的功能。几千份成绩如果靠手动录入,既慢又容易出错。我们做了一个 Excel 批量导入功能,教师上传成绩表,云函数使用 SheetJS 解析文件,再把数据批量写入 scores 集合。
理想很丰满,现实有个坑:云函数默认内存和时间限制。第一次测试,我上传了一个包含 2000 行成绩的 Excel 文件,云函数直接报超时。排查下来,问题是 SheetJS 解析大文件时内存占用高,加上批量写数据库循环太慢。
解决方案是限制单次导入规模,把大文件拆成几个不超过 500 行的小文件分批上传,每个小文件的解析和入库控制在 3 秒内完成。后来进一步优化:上传的文件先传到云存储,拿到 fileID 后再让云函数从存储中读取文件内容,避免小程序端到云函数的长耗时链路。这个改动让成绩导入功能真正变得可用。
| 问题现象 | 根因 | 解决办法 |
|---|---|---|
| 云函数偶发超时 | 冷启动导致首次调用慢 | 高频数据前端缓存,预热关键云函数 |
| Excel 导入 2000 行超时 | 单文件解析和入库超过云函数限制 | 限制 500 行一个文件,分批上传 |
| 订阅消息大量未送达 | 一次性订阅授权不足 | 站内消息中心兜底,双通道触达 |
| 学生查询座位高峰期白屏 | 数据库并发查询量大 | 座位数据考前生成静态缓存 |
4.4 系统测试:功能用例与并发验证
测试部分小学问也不少。我们把测试重点放在了三块:功能正确性、权限隔离、并发稳定性。功能测试用例表如下:
| 用例名称 | 测试步骤 | 预期结果 | 是否通过 |
|---|---|---|---|
| 学生登录绑定 | 输入学号与验证码完成绑定 | 绑定成功,进入学生首页 | 通过 |
| 考场分配冲突检测 | 创建同一时段两场考试 | 系统拒绝第二场占用同一教室 | 通过 |
| 监考老师冲突拒绝 | 分配同一老师到同时间两个考场 | 第二场分配失败并提示 | 通过 |
| 成绩导入权限校验 | 教师上传非本人课程成绩表 | 接口拒绝操作并记录日志 | 通过 |
| 订阅消息降级 | 用户拒绝订阅授权后发布通知 | 站内消息中心仍然可见 | 通过 |
并发测试我们做了 200 个学生同时查询考试安排的模拟压测,云函数冷启动叠加并发,响应时间平均在 1.8 秒左右,第二次请求以后降到 300 毫秒以内,可接受。真正上线时,一个学院的学生同时间查询考试安排,也没有出现明显卡顿。
4.5 上线后的真实效果
灰度上线两周,覆盖了三千多名学生和一百多位教师。之前教务员排一天考场的学期,现在创建考试、分配教室、生成监考表全过程控制在十分钟内。学生查询自己的考试时间和座位,从小程序首页点两下就能看到,没有人在群里反复问“到底在哪考”。
教师反馈最积极的是成绩录入。以前交成绩要打印纸质单、签字、送到教务处,碰到人不在还得再跑一趟。现在手机端录入提交,教务审核后直接发布,整个流程从几天压缩到几小时。教务处的 A 老师后来跟我说,最大的变化不是“快了”,而是“不用再操心漏了”。系统把冲突查得干干净净,她只需要处理那些真正需要人工判断的特殊情况。
最后再分享一点个人体会
做完 Badminton 这个项目,我自己最大的认知转变是:校园类系统的难点压根不在技术上,而在需求的理解和资源模型的抽象上。几千人的考务系统,用户量不算大,但角色多、状态多、异常多,容错空间又小,一个考场分配错误可能影响几十上百个学生的考试。所以架构设计宁可保守一点,权限校验宁严勿松,算法宁可简单可靠,不在不相关的地方炫技。
如果现在有人要入坑做类似的智慧校园、教务数字化项目,我建议先把 Room、TimeSlot、User、Course 这几个核心资源之间的约束关系用白板画清楚,再动手写代码。资源调度模型想通了,剩下的都是熟练工。
Badminton 这个名字保不齐会被后来的项目沿用。毕竟,给每个老师和学生精准发球,把考试季的混乱安顿好,对于一个软件系统来说,也算是一种值得记住的意义。