
把在线教育系统源码从仓库里拉下来那天产品经理直接丢过来一句话“暑期活动之前考试答题小程序必须上线学员要在手机上完成章节测练和模拟考试。”当时这套源码里已经有了完整的课程管理、题库管理和学员体系课程能看、题能刷唯独缺“考试”这个动作——学员学完到底掌握得怎么样系统里没有闭环答案。于是整个团队围绕这套源码花了两个多月把答题小程序从立项做到上线。这篇实录就按“代码哪些能复用、哪些必须重写、答题链路怎么打通、最容易翻车的地方在哪”来讲给准备接手教育类小程序或后端项目的团队一个参考。1. 为什么一定要做考试闭环录播课系统的业务缺口1.1 课程、题库、学员体系都在就差“考试”这一环先说背景。我们手里的这套源码属于典型的在线教育基础盘课程模块负责录播课、直播课和课程包管理题库模块负责题目录入、分类打标签和日常练习学员模块负责注册、登录、购买课程和学习进度记录。单看每个模块功能都算完整但把它们串起来就会发现问题——题库里的题目只被“练习”功能使用学员刷完题只能看到对错没有任何成绩沉淀老师也看不到班级整体的掌握情况。这个缺口在产品层面非常致命。教育产品的完整链路是“学—练—考—评”前面两步有了后面两步是空的。机构端想要做模拟考试、入学测评、结业认证都需要“考试”作为一个独立闭环存在。更现实的问题是没有考试数据课程续费率、教学效果这些核心运营指标都拿不出依据。这个需求不是我们自己想出来的是真实客户推动的。当时有合作机构提出他们的学员学完一期课程后需要一个在线正规考试的出口最好能在微信里直接打开减少下载App的门槛。于是“在线教育系统源码支撑考试答题小程序”这个项目就立项了。1.2 业务需求拆解三种完全不同的考试场景拿到需求后我们没有急着写代码而是先把考试场景拆成了三类因为这三类场景对技术设计的要求差异很大场景典型用途题目数量计时规则防作弊要求成绩处理并发预期章节测练课后随堂练习5-20题不限时或宽松无实时出分低模拟考试考前全真演练50-100题严格限时适中实时出分中正式认证考试机构结业/认证50-100题严格限时强成绩存档导出高这个拆解直接影响后续的表结构设计和接口设计。章节测练可以做成轻量模式甚至不生成正式试卷模拟考试和正式考试必须走完整的考试会话流程否则成绩没有可信度。1.3 为什么答题端选择小程序而不是App或H5这里需要解释一下技术选型的逻辑。我们当时有三个选项微信小程序、H5页面、独立App。独立App最先被否掉理由很直接机构用户不想增加下载安装成本而且App的审核和版本更新周期太长对考试这种需要频繁调整规则和题库的场景太笨重。H5的优势是开发快、跨端好但劣势也很明显——考试过程中用户切走再回来H5页面很容易被浏览器回收答题状态丢失这是考试场景不能接受的。小程序是三个选项里最合适的。它位于微信生态内打开率远高于H5有自己的生命周期管理切后台的状态能感知原生组件渲染题目比H5方案更稳定对机构客户来说小程序分享给学员也方便。代价是包体积限制和部分富文本渲染能力不足我们在第3章会详细说明这些限制是怎么处理的。2. 源码拆解哪些模块直接复用哪些必须重写2.1 直接复用的三块账号体系、题库中心、课程上下文面对一套已有源码最忌讳的做法是“看着代码别扭就重写”。在线教育系统的核心资产是题库和学员数据这两块经过多年的业务迭代已经非常稳定动它们的风险极高。我们的原则很清楚稳定模块只调用、不修改。第一块直接复用的是学员账号体系。小程序端不需要重新登录注册直接走源码已有的手机号验证码登录流程登录后拿到token后续所有考试相关接口都带着这个token走。源码里原本就有单点登录和会话管理逻辑我们没有动一行代码只在接到登录态后额外保存了一份学员基本信息到Redis。第二块复用题库中心。题目维护、分类管理、标签体系、难度等级这些数据都存在MySQL里由源码后台的题目管理页进行维护。答题小程序只需要读题目不需要对题目做任何写操作。这里的关键设计是小程序端不直接查题库表而是通过考试服务调题库的只读接口避免小程序渗透到原业务的数据层。第三块复用课程上下文。每道题目、每份试卷都要挂在某个课程或课程包下面这个“题目属于哪门课”的关联关系直接取自源码的课程-题库绑定逻辑我们只需要在创建试卷时传入课程ID。2.2 需要新建的核心模块考试编排、会话管理、判分引擎源码里没有的东西才是这次开发的重点。我们要补三个模块考试编排模块负责组卷。它要做的事包括从题库中按规则选题、生成试卷快照、配置考试时长和及格线、控制试卷的发布与回收状态。这里有一个重要的设计原则试卷一旦生成题目内容必须快照固化不能跟着题库后续编辑变动。很多团队在这里偷懒考试时实时读题库结果题库管理员改了一道题已经下发的试卷也跟着变了这个坑我在第5章会专门展开。会话管理模块负责一次考试从开始到交卷的完整生命周期。核心概念是考试会话exam_session一场考试一次会话包含考试开始时间、每道题的作答状态、切屏记录、最终提交时间等。会话状态流转分五步待开始、进行中、已提交、已判分、已过期。判分引擎负责自动判分。客观题单选、多选、判断通过答案比对自动判分主观题简答、论述走人工批改接口。判分规则要支持灵活配置多选是漏选不得分还是漏选得部分分判断题怎么计分主观题满分几分。这些配置如果写死在代码里后面每次调整都要发版所以必须在考试编排模块中做成参数。2.3 新增表结构设计考试三件套所有考试功能最终落到几张表上。我直接把核心表结构贴出来方便大家对照自己的系统做设计-- 试卷表定义一次考试的规则 CREATE TABLE exam_paper ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT 所属课程ID, paper_title VARCHAR(200) NOT NULL COMMENT 试卷名称, total_score DECIMAL(5,2) NOT NULL COMMENT 总分, pass_score DECIMAL(5,2) NOT NULL COMMENT 及格线, duration_minutes INT NOT NULL COMMENT 考试时长分钟, question_policy TINYINT NOT NULL DEFAULT 1 COMMENT 组卷策略1固定卷 2随机卷, single_choice_score DECIMAL(5,2) DEFAULT 1.00 COMMENT 单选题分值, multi_choice_score DECIMAL(5,2) DEFAULT 2.00 COMMENT 多选题分值, judge_score DECIMAL(5,2) DEFAULT 1.00 COMMENT 判断题分值, essay_score DECIMAL(5,2) DEFAULT 10.00 COMMENT 主观题分值, status TINYINT NOT NULL DEFAULT 1 COMMENT 1草稿 2已发布 3已下线, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ) COMMENT考试试卷表; -- 试卷题目快照表固化下发时的题目内容 CREATE TABLE exam_paper_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL COMMENT 试卷ID, question_id BIGINT NOT NULL COMMENT 原题库题目ID仅记录用, question_type TINYINT NOT NULL COMMENT 题型1单选 2多选 3判断 4主观题, question_snapshot TEXT NOT NULL COMMENT 题目内容快照JSON, answer_snapshot VARCHAR(500) NOT NULL COMMENT 标准答案快照, score DECIMAL(5,2) NOT NULL COMMENT 本题分值, display_order INT NOT NULL COMMENT 题目顺序, UNIQUE KEY uk_paper_question (paper_id, question_id, display_order) ) COMMENT试卷题目快照表; -- 考试会话表一次考试的完整状态 CREATE TABLE exam_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT 学员ID, paper_id BIGINT NOT NULL COMMENT 试卷ID, start_time DATETIME NOT NULL COMMENT 实际开始时间, end_time DATETIME NULL COMMENT 实际提交时间, should_end_time DATETIME NOT NULL COMMENT 应结束时间服务端计算, status TINYINT NOT NULL DEFAULT 1 COMMENT 1进行中 2已交卷 3已判分 4超时强制交卷, total_score DECIMAL(5,2) NULL COMMENT 判分后总分, request_id VARCHAR(64) DEFAULT NULL COMMENT 幂等请求ID, UNIQUE KEY uk_session_request (student_id, paper_id, request_id), INDEX idx_student_status (student_id, status) ) COMMENT考试会话表; -- 答题明细表每题一行的作答记录 CREATE TABLE exam_answer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL COMMENT 考试会话ID, paper_question_id BIGINT NOT NULL COMMENT 试卷题目快照ID, student_answer TEXT NULL COMMENT 学员作答内容, is_correct TINYINT NULL COMMENT 客观题是否判对1是 0否 NULL未判, obtained_score DECIMAL(5,2) NULL COMMENT 本题得分, answer_duration_seconds INT NULL COMMENT 本题用时, UNIQUE KEY uk_session_answer (session_id, paper_question_id) ) COMMENT考试答题明细表;这套表结构设计有两个关键点。第一exam_paper_question 里的 question_snapshot 和 answer_snapshot 就是第2.1节说的“快照”它把题目内容和标准答案原样复制了一份考试期间考生看到的是快照后台修改题库不会影响正在进行中的考试。第二exam_session 里加了 should_end_time交卷判定永远以服务端时间为准不信任客户端时间。3. 小程序端与后端接口一条完整的答题链路3.1 小程序端页面和状态流转小程序端的页面结构不复杂核心就五个页面考试列表页、考试规则确认页、答题页、交卷确认弹窗、成绩结果页。真正复杂的是答题页的状态管理。答题页需要维护当前第几题、本题已选答案、剩余时间、切屏次数这些状态。一个容易踩的坑是小程序页面在用户切后台时会被冻结切回来时需要重新拉取数据和状态不能只依赖内存变量。所以我们把当前进度存在storage里每次进入答题页先拉一次服务端会话状态再和本地状态做合并。用户切后台太久导致考试超时也是在这里发现的——服务端已经把会话状态置为“已交卷”小程序端拉回来后要给明确的提示而不是继续答题。3.2 核心接口设计接口设计遵循RESTful风格所有接口都要求携带token。接口列表如下接口方法路径说明获取我的考试列表GET/api/exam/my-exams返回当前学员可参加的考试开始考试POST/api/exam/start传入paperId创建考试会话返回试卷快照保存单题答案POST/api/exam/answer/save每答一题实时保存支持断点续答提交答案POST/api/exam/submit整卷提交幂等处理返回判分结果查询考试成绩GET/api/exam/result/{sessionId}已判分后返回成绩和答题明细上报切屏记录POST/api/exam/switch-log记录切出/切回时间和次数其中开始考试和提交答案这两个接口最需要注意。开始考试接口不仅要做会话创建还要返回完整的试卷快照数据包括每道题的题干、选项和分值。小程序拿到这份快照后直接渲染后续答题过程中不再请求题库数据。这样设计的好处是——题目的展示稳定性和响应速度都有保障不会因为题库接口慢而影响考试体验。3.3 客观题判分规则一眼就能看懂的判分代码判分逻辑是所有逻辑里最无聊但也最容易出错的部分。我给判分引擎写了一套干净的规则判断题精确定配单选题精确定配多选题有三种模式完全匹配、漏选不得分、漏选得一半分由试卷上的配置项控制。核心代码用Java写出来大概是这样的public BigDecimal scoreObjectiveQuestion(ExamAnswer answer, QuestionSnapshot snapshot) { if (QuestionType.SINGLE_CHOICE.equals(snapshot.getQuestionType())) { return answer.getStudentAnswer().equals(snapshot.getAnswerSnapshot()) ? snapshot.getScore() : BigDecimal.ZERO; } if (QuestionType.JUDGE.equals(snapshot.getQuestionType())) { return answer.getStudentAnswer().equals(snapshot.getAnswerSnapshot()) ? snapshot.getScore() : BigDecimal.ZERO; } if (QuestionType.MULTI_CHOICE.equals(snapshot.getQuestionType())) { // 多选答案按固定顺序去重后比较 String studentAnswer normalizeAnswer(answer.getStudentAnswer()); String standardAnswer normalizeAnswer(snapshot.getAnswerSnapshot()); if (studentAnswer.equals(standardAnswer)) { return snapshot.getScore(); } // 漏选场景得分减半还是不得分支持配置 if (containsAll(standardAnswer, studentAnswer)) { return snapshot.getScore().multiply(new BigDecimal(0.5)); } return BigDecimal.ZERO; } return null; }这里有个细节要注意多选题标准答案和学生答案都存成字符串比如“A,B,C”比较前必须先做去重和排序否则“B,A,C”会被误判为错误答案。当年我们为此吃过亏测试同学提交了一道顺序不一样的正确答案被判了零分从那以后判分前多了一步 normalizeAnswer。3.4 富文本题目的渲染小程序最头疼的兼容问题在线教育题库里的题目从来不是纯文本。数学题带公式图片编程题带代码块英语题带录音音频这些内容在后台管理页面用富文本编辑器录入后保存的是HTML格式。小程序端的rich-text组件能解析一部分HTML但对样式支持有限尤其是table、外链图片、自定义样式的支持很糟糕。我们最终采用了“服务端转结构”的方案不再把原始HTML直接塞给小程序而是在试卷快照生成时由后端把题目内容里的HTML解析成小程序能识别的节点数组格式图片统一处理到自有图床并在节点中带上宽高音频转成audio组件可识别的URL代码块转成pre标签包裹的纯文本。这一步在生成快照时一次性完成考生端不需要做任何实时转换。这块经验很重要给小程序做富文本内容接口永远不要在端上做HTML清洗和转换要在服务端做。端上跑同样的解析代码Android和iOS的表现经常不一致老机型上尤其明显。服务端解析一次、两端共用省掉无数兼容性工单。4. 并发、计时与防作弊考试场景最棘手的三个问题4.1 计时逻辑一切以服务端时间为准考试计时是答题小程序最容易翻车的环节。客户端倒计时显示得再好用户改系统时间、切后台、关掉小程序重进都会干扰计时。我们从第一天就定了铁律考试时长由服务端计算客户端只做展示。具体的做法是开始考试时服务端记录 start_time根据试卷配置计算 should_end_time start_time duration_minutes。考生答每一题时接口请求都会带上当前时间戳以服务器时间为准保存答案的同时检查当前时间是否超过 should_end_time。超过则直接将考试标记为“超时强制交卷”返回给客户端“考试已结束”的状态码。超时强制交卷不能只靠实时检查还要有一个兜底任务。我们写了一个定时任务每30秒扫描一次 exam_session 表把所有状态为“进行中”且 should_end_time 小于当前时间的数据批量置为“已交卷”同时调用判分逻辑。这样就算考生彻底关闭小程序不再发请求考试也会被自动判分不会出现挂在那里的僵尸会话。4.2 交卷的并发和幂等防止重复提交毁掉成绩用户很容易在交卷时狂点按钮这导致交卷接口成为全链路并发压力最大的接口。如果没有幂等保护一次考试可能被提交两次造成重复判分、成绩覆盖等诡异问题。我们的交卷逻辑做了三层保护。第一层是客户端按钮置灰加防抖点击后立即禁用交卷按钮第二层是服务端查重在 exam_session 表上增加了 request_id唯一索引客户端每次进入考试页面就生成一个requestId一个会话同时只有一个requestId第二次带同样的requestId提交直接返回第一次的结果第三层是Redis分布式锁用Redisson对 sessionId 加锁确保同一会话的交卷请求串行执行。为了让读者直观理解交卷逻辑的核心流程是客户端生成 requestId随交卷请求发送服务端用 sessionId 作为Key获取锁获取不到则说明正在处理中直接返回“处理中请稍候”获取到锁后检查会话状态是否已经是“已交卷”是则直接返回已保存的结果未交卷则执行交卷遍历所有答题记录、计算得分、更新会话状态、写入requestId释放锁返回成绩这套流程上线后交卷接口再没出现过重复提交导致的问题。4.3 防作弊切屏检测、题目乱序、随机组卷防作弊的度很难拿捏——做得太重影响正常用户体验做得太轻形同虚设。我们分了三档机构可以按考试等级自由配置。第一档是基础的行为记录。学员在小程序内点击交卷确认页、或者从答题页切到微信聊天再切回小程序会通过 onHide 和 onShow 生命周期记录切出和切回的时间上报给服务端。服务端只做记录不直接判作弊但在后台管理页把切屏次数展示给监考老师看。切屏超过10次的考试成绩单上会带一个“需要人工复核”的标签。第二档是题目防作弊。固定卷对所有人发一样的题目很容易互相传答案。我们在发题时做了两件事题目选项顺序打乱每道题的题序也打乱。同一场考试不同考生拿到的题目顺序不同、选项顺序不同但考察内容相同。这部分在组卷时通过为每个 session 生成独立的 randomSeed 实现。第三档是进阶能力正式认证考试可以开启人脸识别验证。考前拍一张照片考中随机抓拍三张自动比对是否为同一人。这块不是必须的我们只对高价值认证考试启用普通模拟考不开避免引起学员反感。5. 从联调到灰度一个真实bug的完整排查链路5.1 灰度期间出现的诡异现象成绩为0功能联调全部通过后我们挑选了约5%的学员做灰度发布。灰度第三天运营同学反馈了一个问题少数学员提交考试后成绩显示为0分但看答题记录明明是做了题的。这个问题的特点是偶发不是所有人都有而且集中在模拟考试场景。我们迅速拉了一个临时群让运营那边提供反馈用户的手机型号、系统版本号和截图。5.2 完整的排查链路从客户端到服务端一层层查第一步先复现。我用管理账号模拟了一整套考试流程提交后成绩正常。说明问题不是必现需要找到触发条件。第二步对比用户数据。看了出问题的20多个账号发现一个规律这些用户几乎都是iOS设备个别是安卓老旧机型。但同样是这些设备也有人成绩正常。设备因素不成立。第三步查服务端日志。把出问题的用户考试会话调出来对比发现一个关键线索他们的 exam_session 记录没问题但 exam_answer 表的题目ID和 exam_paper_question 的快照ID对不上。答案是答在A卷上的判分时却去B卷找标准答案自然判成0分。第四步查试卷快照的数据一致性。我们把同一场模拟考试的 exam_paper_question 数据导出来看发现里面出现了两套快照一套是生成试卷时的旧题目一套是考试中途重新生成的题目。问题就出在这里——随机卷的组卷逻辑是“考试开始时才生成快照”而定时任务或后台更新在某些情况下会触发重新组卷导致已经开始考试的会话拿到了一份新的试卷ID。第五步定位根因。看了组卷代码后终于明白了。模拟考试用的是随机卷策略源码里的原逻辑是进入考试时按规则抽题、生成快照。但如果同一时间段有两台服务器同时处理或者后台在考试开始时恰好更新了课程下的题库题目就会触发重新生成快照。新快照生成后已经开始答题的会话所对应的 paper_question_id 就失效了学员的答案找不到对应的判分标准。第六步修复。方案很简单但改动不小彻底断开考试会话与实时题库的依赖。在创建考试时固定卷一版快照后就把 paper_id 和快照ID绑定死随机卷在开始考试时生成快照同时把 paper_id 写入 exam_session任何后台操作都不能修改已经开始考试的试卷快照。组卷接口增加了一个状态判断只要 exam_session 中存在该 paper_id 的活动会话就拒绝修改。修复提测后我们专门做了压测和回归测试组模拟了“后台改题库 学员正在答题 同时多用户提交”的场景之前的问题不再出现。5.3 这个bug给我们的三个教训这个bug暴露出源码边缘设计的一个系统性隐患凡是考试类业务数据只认快照不认实时引用。题库可以被编辑课程可以被下架学员信息可以变更但考试一旦开始所有输入必须冻结。这个原则后来写进了我们团队的开发规范。第二个教训是完整排查比急着修更重要。如果当时拿到反馈就一股脑去查判分代码很可能查不出问题因为判分逻辑本身没有问题问题在数据源头。对比日志、追踪快照ID这种思路才是正解。第三个教训是灰度发布要有“病理对照”思维。灰度期间不仅仅是看功能是否可用更要把异常数据找出来做分析。这次是运营同学在后台点了一遍成绩列表才发现0分问题。后来我们给管理后台加了异常数据看板低于及格线比例异常高的考试自动高亮。6. 线上运营复盘与源码复用带来的真实收益6.1 上线后的数据表现考试答题小程序上线三个月后整体数据比预想中好。根据后台统计考试模块的周活跃用户占比达到了全系统的30%左右章节测练的完成率接近45%模拟考试的参与率也超过了25%。相比之下之前纯练习模块的月活跃度只有不到15%——有一个明确的“考试目标”确实能明显拉动学员使用意愿。成绩数据也很有说服力。参与考试的学员中完成一套完整模拟考试50题以上、限时完成的比例在70%左右。自动判分能够覆盖约八成题型剩下主观题走人工批改批改平均周转时间控制在6小时内。及格率方面机构客户自己统计的结业通过率稳定在85%以上证书发放功能后续也顺理成章地加上了。6.2 这套源码复用的边界在哪里回头看这个项目在一个已有教育系统源码上做增量开发最大的收益是节省了基础设施的建设成本。账号、权限、题库管理、课程关系这些复杂且积累深厚的模块完全不用碰我们能集中精力啃“考试”这块硬骨头。新写的 exam 模块是独立的它和旧系统之间只有接口调用关系没有表结构耦合。这样一来以后如果客户需要把考试能力接到App、Pad端或者做线下考试的大屏模式都可以直接在 exam 模块上延展不需要再动课程和题库系统。这也是我们坚持“只调用、不修改”的回报——代码边界清晰后面的人维护起来才不吃力。6.3 给即将接手类似项目的团队三条经验第一对接已有源码时把“哪些能复用、哪些必须重写”的边界画清楚比急着写代码重要得多。我们花了一周时间梳理源码模块和依赖关系这个投入代价是值得的避免了后面返工。第二考试系统的数据快照是整个项目的命根子。试卷、题目、标准答案全部要快照化凡是没有快照的地方都出现过线上问题。宁可多存一份数据也不要在运行时去依赖可变数据。第三小程序端的答题体验要以“断网、切后台、杀进程”为设计前提。这是一次交互时间可能长达一个小时的页面必须假设用户随时可能被打断。做好断点续答、服务端计时、幂等提交相当于给系统上了一半的保险。我个人实际操作中的体会是在线教育系统的考试模块真正难的不是组卷也不是判分而是让系统在真实网络环境中做到“一次考试可信可追溯”。把这四张表设计好、把交卷幂等做好、把快照固化做好这个项目的骨架就立住了。剩下的事情都是在骨架上填充血肉而已。