
去年做了一个考公考编的在线学习考试系统项目项目代号就按团队习惯命名为 python156n。这个系统面向的是公务员考试、事业单位编考试备考人群前后端分离架构后端用 Python 写接口前端用 Vue3 组织页面。整个项目从需求梳理到上线交付前前后后花了三个多月踩了不少坑也沉淀了一些比较实用的设计经验。这篇文章把我认为最有参考价值的部分整理出来包括业务拆解、技术选型、核心模块设计、考试场景的特殊处理以及上线前后遇到的实际问题希望对正在做类似学习考试平台的团队有点帮助。1. 这个考公系统到底要做什么业务剖析与选型逻辑1.1 考公备考场景的业务拆解考公备考和普通在线学习有一个很大的不同它不是一个单纯的看视频、做练习场景而是高度围绕刷题—模考—错题—提升这条闭环来转的。用户的核心诉求非常明确就是在有限备考时间里最大化刷题效率搞清楚自己的薄弱知识点然后针对性突破。所以业务上必须围绕题库、在线考试、学习分析三个大块来建设。题库是整个系统的地基。行测部分要覆盖常识判断、言语理解、数量关系、判断推理、资料分析五大题型申论部分要支持概括题、分析题、对策题、应用文和议论文。每一道题都要有类型、难度、知识点标签、答案解析这些基础属性。在线考试则要支持两种模式一种是章节练习式的即时练习做完立刻出解析另一种是模拟考试模式严格计时到点自动交卷按真实考试规则计算成绩。除此之外错题本、做题记录、知识点掌握度这些数据分析能力才是把普通题库产品变成智慧学习系统的关键。我当时做技术方案前先花了一周时间把竞品和用户反馈梳理了一遍。发现市面上的产品要么题库全但交互体验一般要么是功能花哨但题目质量跟不上。我们自己的定位很清晰题库质量必须过硬考试流程必须稳定数据分析必须能落实到下一步练什么。1.2 技术选型Python做后端、Vue3做前端的理由后端选 Python 其实纠结过一阵子。团队里一开始有人建议用 Java理由是考公系统后面对接的机构客户可能要求私有化部署Java 生态更稳妥。但最终我们还是定了 Python原因有几个一是团队核心成员对 Python 的 FastAPI 框架非常熟开发效率高二是这个系统的核心复杂度在题库组卷逻辑和统计分析算法上Python 写起来更顺手三是 FastAPI 天然支持异步对考试场景下大量并发的交卷请求和答题心跳上报很友好。数据库选了 MySQL Redis 的组合。MySQL 存题目、用户、考试记录这些结构化数据Redis 处理在线状态、倒计时同步、防作弊标记这类临时数据。后来验证这个选型是对的考试高峰期大量用户同时在线Redis 帮我们扛住了读写压力。前端选 Vue3 基本没有悬念。Vue3 的组合式 API 对复杂页面状态的维护比 Vue2 时代清晰太多答案答题页这种交互密集的页面用ref、computed、watch组合起来写逻辑代码可读性和维护性都很好。再加上 Vite 的构建速度和 Element Plus、ECharts 这些配套生态做管理后台和数据大屏都很方便。1.3 核心功能清单项目最终落地的功能模块大概是这样的用户端注册登录、个人中心、刷题练习、模拟考试、错题本、学习报告管理端题库管理批量导入、编辑、上下架、试卷管理人工组卷、智能组卷、用户管理、考试数据统计核心业务能力智能组卷、自动判分、倒计时控制、切屏告警、知识点掌握度分析、成绩排行这套功能清单看上去不算特别复杂但每一块拆开做都有不少细节。下面的内容我会挑几个我认为最有技术含量的模块展开讲。2. Python接口层的三块硬骨头题库、组卷与判分2.1 题库表结构设计题目与选项如何建模题库表设计是整个系统的基础这块设计不好后面组卷和统计都会很难受。我直接说最终方案。题目表question用了统一的宽表设计核心字段如下CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, subject_type VARCHAR(20) NOT NULL COMMENT 行测/申论, question_type VARCHAR(20) NOT NULL COMMENT 单选/多选/判断/主观, question_content TEXT NOT NULL, analysis TEXT COMMENT 答案解析, difficulty TINYINT NOT NULL DEFAULT 3 COMMENT 1-5难度, knowledge_point_id BIGINT NOT NULL, source_exam VARCHAR(100) COMMENT 来源真题如2024国考行测, creator_id BIGINT, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_kp (knowledge_point_id), KEY idx_type (subject_type, question_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;选项没有用逗号分隔字符串存在题目表里而是单独拆了一张question_option表每个选项一行通过question_id关联。这样做的最大好处是客观题判分可以直接用 SQL 或简单查询比对不用在代码里做字符串切割。知识点单独建一张树形结构的表比如判断推理下面挂图形推理图形推理再挂空间重构。树形结构方便我们在统计掌握度时做聚合用户答错一道空间重构的题可以同时更新图形推理和判断推理这两个层级的掌握度。这里容易踩的一个坑是不要为了省事把选择题的正确选项直接存成A、B这种字母。因为选项顺序一旦需要调整或者要做选项乱序字母存的答案就作废了。正确做法是正确答案存选项的 ID 或选项的唯一标识题面里的 A、B、C、D 只是展示层的位置。2.2 智能组卷按知识点、难度、历史正确率的抽题逻辑组卷是考公系统的核心能力尤其是模拟考试模式试卷的质量直接决定用户对系统的信任度。人工组卷当然可以但运营小姐姐不可能每天手动从几千道题里挑出一套合理分布的卷子所以必须做智能组卷。智能组卷我把它拆成两个步骤先定组卷蓝图再按蓝图抽题。组卷蓝图规则类似这样一套行测模拟卷固定题目数量135道各题型占比按真实考试比例来比如常识判断20道、言语理解40道、数量关系15道、判断推理35道、资料分析25道。难度分布按照 易:中:难 3:5:2 来控制。光有题型和难度还不够还要考虑用户的历史正确率。系统会根据用户在某个知识点上的历史表现动态调整抽题权重。薄弱知识点多抽题强项知识点少抽题但难度稍微拉高。这里我用了一个比较简单的加权随机算法import random def pick_question(question_pool, weight_fieldweight): total_weight sum(q[weight_field] for q in question_pool) rand_val random.uniform(0, total_weight) cumulative 0 for q in question_pool: cumulative q[weight_field] if rand_val cumulative: return q return question_pool[-1]权重值由几个因子相乘得到(3 - 用户在该知识点平均正确率) * 难度系数 * 知识点薄弱度。客户正确率越低权重越大抽中概率越高。这里必须注意抽题要加一个去重约束同一套试卷里不能出现重复题目。如果用户之前做过某道题下次模考再抽到同一道题体验感会很差。所以我加了一个过滤条件优先从用户未做过的题目池里抽如果未做过的题目不够了再放宽条件。实测下来题库量在五千道以上时基本不会遇到题目不够的情况。2.3 自动判分客观题秒判、主观题辅助打分判分逻辑直接决定用户对考试成绩的信任度。客观题没有悬念用户提交的答案和我们存储的正确选项比对即可。多选稍微复杂一点如果是严格模式必须和标准答案完全一致才算对如果是宽松模式漏选算半对多选算错。考公场景下多选都很严格所以我们只做了严格模式。判断题本质也是客观题正确答案存一个布尔值或对/错字符串。真正的难点在申论这种主观题。完全靠程序做语义理解现阶段还是不太靠谱尤其是大作文。我们的方案是机器评分 人工复核两步走机器评分做三个维度的打分然后加权求和关键词覆盖度把标准答案里的核心关键词提取出来看用户答案命中了多少个命中比例打到基础分。内容长度与结构句子是否完整、段落是否清晰、是否有标题和使用序号标点等。知识点匹配用户答案中提到的是否对应题目要求的知识方向。这三个维度加权后得到一个初步分数同时生成一份评分理由比如关键词覆盖度60%、结构得分80%让用户知道扣分点在哪里。人工复核则提供给付费用户或机构客户由教师在后台对主观题进行二次批改。这里的经验是主观题辅助评分不要试图做到完全准确能做到参考价值 节省人工批改时间就已经很实用了。用户也理解机器阅卷的局限把评分逻辑公开透明地展示出来反而会提升信任感。3. Vue3前端的完整链路从登录到交卷的状态流转3.1 路由设计、动态菜单与登录态处理前端用 Vue3 Vite Pinia Vue Router 这套组合。项目分两个大端用户端和运营管理后台。用户端路由相对固定管理后台则要做成动态路由因为不同角色的运营人员能看到的功能模块不一样。动态路由的实现逻辑是用户登录后后端根据角色返回一个权限列表前端拿到权限列表后用router.addRoute()动态注册路由。这样避免了把所有路由一次性打到前端包里的隐私风险也保证了权限的严格隔离。登录态处理我们用了双 token 方案access_token短期有效refresh_token长期有效。用户操作时发现access_token过期前端用refresh_token静默刷新用户无感知。这里要注意的是刷新 token 的接口要做并发锁否则多个请求同时失败会发起多个刷新请求造成 token 反复刷新甚至互相覆盖。一个我建议所有 Vue3 项目都做的小细节是axios 拦截器里统一处理 401。如果接口返回 401先判断当前是否正在刷新 token不是的话就进入刷新流程刷新期间的其他请求先挂起一个 Promise 队列里刷新完成后再统一重放避免页面刷一下跳登录页的尴尬情况。3.2 答题页核心交互题卡、标记、导航答题页是整个前端体验的重中之重。考公模拟考试一套卷子一百多道题用户要在一个页面里做将近两个小时交互设计稍有不慎就会让用户烦躁。答题页布局分成三块左侧为题目区右上为倒计时右下方为答题卡区域。答题卡要能展示全部题目并标注每道题的状态未作答、已作答、标记未答、标记已答。标记的功能很重要考场上大家都有跳题的习惯先做会的拿不准的先标记完再回头看。Vue3 组合式 API 在做这个状态管理时非常顺手。我把整个答题状态拆成几个refconst currentIndex ref(0) const answerMap reactive({}) // questionId - userAnswer const markedQuestions reactive(new Set())切换下一题时只需要更新currentIndex题目区的内容通过computed根据索引实时响应。答题卡上的状态汇总也是一个computed遍历所有题目状态生成网格用户一眼就能看到哪些题还没做。答题时的选择交互我建议用大按钮而不是下拉或者输入框。行测题目选项基本是 A、B、C、D 四个或八个选项用大卡片按钮点选命中区域大触屏设备上也友好。点了之后立刻更新answerMap同时自动跳到下一题。这个选完自动跳题的交互看起来小但对刷题流畅度影响很大。3.3 倒计时组件与服务端时间同步考试倒计时是最容易出问题的功能尤其是用户把本机时间改了就能续命这种事必须提前堵住。我们的方案是服务端在用户进入考试时下发两个时间戳start_time和end_time前端显示剩余时间为end_time - 当前时间。但当前时间不能用Date.now()因为那是本地时间用户改系统时钟会直接影响。正确的做法是计算一个时间偏移量进入考试页面时前端请求一次服务端时间接口得到server_time然后计算offset server_time - Date.now()后续所有倒计时都用Date.now() offset来算。这样一来用户改了本地时间偏移量的基准虽然会变但因为我们在倒计时更新时用performance.now()这种不受系统时间影响的单调时钟来配合校准整体偏差可以控制在很小的范围。Vue3 倒计时实现我建议用setInterval每秒更新一次剩余秒数但要注意页面切到后台再回来时浏览器会节流甚至暂停定时器。所以每次触发更新时不要直接remaining - 1而是重新计算endTime - currentServerTime()这样即使定时器暂停了一段时间恢复后也能立刻跳到正确的时间。另外还有一个细节考试结束前 1 分钟、30 秒、10 秒分别做声音或弹窗提醒。交卷时间到了如果用户还没手动交卷前端要自动交卷把当前已答内容全部提交上去。这个自动交卷逻辑必须写在按钮触发之外不能依赖用户点击。4. 在线考试真正的难点时钟、断连与防作弊4.1 刷新页面不丢答题进度本地暂存与服务端快照在线考试最怕的就是用户答到一半不小心刷新了页面结果做题记录全丢了。这种事故出一次系统口碑基本就崩了。所以防刷新丢数据是考试模块的底线功能。我们的做法是双保险。第一层是本地暂存每次答题动作触发后同步把最新的answerMap和当前题目索引写入localStoragekey 按考试记录 ID 区分。刷新页面时先检查 localStorage 里有没有未提交的答题数据有就恢复。第二层是服务端快照每 10 秒把答题进度上报一次到后端存 Redis。本地暂存与服务端快照的区别在于本地数据可能因为用户清缓存丢失但服务端数据只要 Redis 没挂就不会丢。恢复时优先用服务端数据服务端没有则用本地数据。手工实际操作时恢复进度还要验证考试状态。如果这份考试已经提交过就不能再恢复答题了否则会出现重复交卷的问题。所以恢复前先请求一个exam/status接口确认考试处于进行中状态再恢复。4.2 切屏检测与全屏限制公务员考试虽然是在线模考但大家心里默认还是要模拟真实考场纪律。切屏检测是刚需功能。浏览器的visibilitychange事件可以检测到用户切走了页面。切走一次记录一次并且前端弹窗警告切屏超过三次系统自动交卷并把切屏记录记入考试成绩详情里。document.addEventListener(visibilitychange, () { if (document.hidden) { window.dispatchEvent(new CustomEvent(exam:switch-away)) } })不过只看这个事件还不够。有些用户是多显示器考试窗口在 A 屏人在 B 屏查资料页面虽然没有hidden但视线已经不在考试页面了。我们后来加了一个基于焦点监听的方式window.blur事件配合document.hasFocus()判断页面是否失去焦点效果比单独监听visibilitychange更好一些。还有一个思路是请求浏览器全屏 API考试开始后强制全屏退出全屏视为一次切屏。这个方案在 PC 端体验略激进用户如果找不到退出全屏的方式会焦虑。所以最终我们没有默认强制全屏而是把它做成了可选配置机构客户可以按需开启。4.3 交卷接口的幂等与并发控制交卷是整个系统里并发风险最高的接口。用户点了交卷按钮网络卡顿又点了一次或者自动交卷和手动交卷同时触发如果后端不做幂等同一个考试记录可能出现两条成绩数据就乱了。交卷接口幂等我用的是 Redis 锁 数据库唯一约束的双重方案。用户交卷时后端先通过SET exam:submit:{exam_record_id} 1 NX EX 60获取锁如果拿不到锁说明已经提交过了直接返回当前成绩。同时数据库的考试记录表里对exam_record_id加唯一索引万一 Redis 锁失效或者 Redis 挂了数据库层面也能挡住重复提交。自动交卷和手动交卷冲突的问题还涉及到一个时间窗口自动交卷脚本已经在遍历用户答案准备提交了用户此刻又手动点了交卷按钮。前端也要在自动交卷触发时设置一个isAutoSubmitting标志手动按钮在标志为 true 时置灰避免两次提交同时发生。判分接口本身也要做成异步任务。一套卷子一百多道题如果用户点击交卷后同步判分再返回结果快的也要三四秒体验很差。我们的做法是交卷接口只负责把原始答案存库并返回已收卷状态判分通过消息队列异步执行完成后把成绩推送或等用户刷新成绩页时按需拉取。实践中做题记录是实时的所以判分其实很快但异步化后的架构稳妥多了。5. 备考数据画像错题本、知识点掌握度与预测分数5.1 知识点标签体系与答题行为采集如果题库只是刷题工具做完就没了那和纸质题集没有本质区别。所谓智慧的部分体现在对用户答题行为的采集和分析上。知识点体系我们设计成了三级树状结构。以行测为例一级为判断推理二级为图形推理三级为空间重构、数量类规律这种更细的考点。题目建模时最细粒度的题目都挂在三级知识点上这样在做聚合统计时可以通过父子关系层层汇总从三级掌握度汇总到二级再到一级。答题行为采集关键要记录四个要素题目 ID、作答内容、是否正确、作答耗时。这四个要素里作答耗时经常被忽略但它的价值很高。一道题用户花了 3 分钟才答对和花 30 秒答对对掌握度的评估应该是不一样的。后者说明知识点很熟练前者可能只是碰巧想对了方向。所以我们把答题耗时的分布也纳入掌握度算法里。客户端在上报答题记录时是在每道题作答后立刻上报。不要等交卷后一起上报因为如果用户中途退出已经做完的题目的记录就丢了学习数据产生缺口。5.2 掌握度计算的EWM滑动模型掌握度不是静态的用户练得越多系统对掌握度的估算应该越准。这里我用了指数加权移动平均EWM模型核心思想是最近一次答题结果对掌握度的影响最大历史结果的影响随时间衰减。算法公式大致是def update_mastery(old_mastery, is_correct, answer_duration, baseline_duration): performance 1.0 if is_correct else 0.0 # 答题越慢绩效折扣越多 time_factor max(0.5, baseline_duration / answer_duration) performance performance * (0.7 0.3 * time_factor) alpha 0.3 # 新答题结果权重 new_mastery alpha * performance (1 - alpha) * old_mastery return round(new_mastery, 2)old_mastery初始值设为 0.5也就是掌握与否未知的中间态。答对一题加一点答错一题减一点最近答题的权重高历史数据逐步衰减。这样用户连续答对几道题后掌握度会明显上涨但隔一段时间不练掌握度又不会自己往下跌因为有历史成绩兜底。掌握度结果落到用户画像页面上就是一张雷达图。五个科目各一个顶点数值是各科目下所有二级知识点掌握度的加权平均。用户打开学习报告一眼就能看出自己的短板在哪一科然后点击进去看具体的薄弱知识点系统再推荐对应知识点的练习题。这个闭环落实之后用户留存率明显提升。5.3 错题本和预测分数怎么落地错题本看着简单就是一个错题列表但实现时有一个产品细节很关键错题本不是简单地列出用户答错的题而是需要按知识点聚合并且标记这个知识点错了几次。同一道题如果用户第二次做对了应该从错题状态变为已订正状态但仍然保留错题记录供回看。为了做到这一点错题本功能不能直接查答题记录中 is_correct false的题目那样同一道题错两次会出现两条记录很乱。我们单独建了一张wrong_book表记录了user_id、question_id、wrong_count、last_wrong_time、is_mastered字段。每次提交答案发现答错时执行 UPSERT 操作wrong_count加一答对时把is_mastered置为 true。预测分数是这个系统里最有智慧感的一个功能也是用户最愿意截图分享的功能。预测分数不能随便拍脑袋必须基于用户的历史数据。模型我用了分类别的加权平均按题目类型分别计算用户的平均正确率再结合最近 N 次模拟考试的难度系数和得分综合计算出一个估分。def predict_score(user_profile): base 0 for subject, accuracy in user_profile.accuracy_by_subject.items(): base subject_weights[subject] * accuracy * 100 exam_adjust (user_profile.avg_exam_score - base) * 0.3 return round(base exam_adjust, 1)公式不复杂但结果要配合置信区间展示比如预测分数 68.5 分波动范围 ±3.2 分让用户明白这是一个估算而不是精确值。另外要对新用户做特殊处理数据量不足十次作答时不给预测分数而是提示再答 10 道题解锁预测能力避免预测结果失真流失用户信任。6. 部署上线与一次真实的压测踩坑记录6.1 部署架构Nginx Uvicorn MySQL Redis系统上线采用的是经典的前后端分离部署方案。前端 Vue3 构建后的静态文件交给 Nginx 托管同时通过 Nginx 反向代理/api路径到后端服务。后端用 FastAPI Uvicorn 启动多个 Worker 进程跑在服务器上。MySQL 存业务数据Redis 做缓存和考试状态管理。Nginx 配置有几个关键点。一个是静态缓存前端打包后的 JS、CSS、图片文件都带 hash 文件名可以放心设置长缓存Cache-Control: max-age31536000这样可以显著减少每次页面加载的静态资源请求量。另一个是 Gzip 压缩Vue3 打包出来的 JS 文件动辄上几百 KB不开 Gzip 的话用户弱网环境下页面加载会非常慢。服务器配置根据预估并发来定。我们初期是 4 核 8G 的单机部署Uvicorn 起了 4 个 Worker。这个配置扛住了第一波上线流量但后来推广活动带来了并发高峰单机瓶颈就暴露出来了。6.2 一次压测暴露的三大问题上线前我们用 Locust 做了一轮压测模拟 500 个并发用户同时在线每人不断切换章节练习和提交答案。压测一开始就发现了三个大问题。第一个问题出在随机抽题接口上。题库有八千多道题抽题时用了ORDER BY RAND() LIMIT 20这个 SQL 在数据量小的时候毫无感觉但并发一上来MySQL 瞬间被打爆。每条ORDER BY RAND()查询都要全表扫描生成随机序列五千并发时这个查询慢到 3 秒开外。解决方案是改为先随机取 ID 再查询具体是SELECT id FROM question WHERE id (SELECT FLOOR(MAX(id) * RAND()) FROM question) ORDER BY id LIMIT 20配合 ID 主键索引查询速度从秒级降到毫秒级。第二个问题是数据库连接池不够用。FastAPI 默认的数据库连接池大小偏保守压测时大量请求在等待连接释放接口响应时间直线上升。我们把连接池上限调大同时加了pool_pre_pingTrue参数避免 MySQL 主动断开连接后应用还在使用失效连接。第三个问题是 Redis 缓存穿透。答题开始时要查询大量题目详情第一次请求时缓存里没有数据全部直接打到 MySQL造成瞬时数据库压力。我们给题目详情加了本地内存缓存 Redis 分布式缓存的双层结构热点题目在进程内就能命中穿透量大幅下降。6.3 针对线上环境的实用调优压测之后我们做了一轮集中调优把一些更适合线上环境的手段也加了进去。API 层面做了统一的响应时间日志和慢请求追踪。每个超过 500ms 的请求都会记录到日志表之后每周复盘一次把 TOP 慢接口优化掉。这个习惯坚持下来后系统整体响应时间中位数从 230ms 降到了 80ms 左右。前端层面做了路由懒加载和组件按需加载。Vue3 项目用defineAsyncComponent把大组件如 ECharts 图表、富文本编辑器拆开只有进入对应页面时才加载对应 JS。首屏体积从 1.2MB 降到了 400KB 出头在 4G 网络下体验差距非常明显。数据库层面把一些高频查询的字段加了覆盖索引比如用户做题记录的查询条件基本是user_id created_at就建了联合索引(user_id, created_at)。这套调优做完之后同样 500 并发压测接口成功率从 97.2% 提升到了 99.8%平均响应时间降到了 150ms 以内。这里顺便说一个运营层面的经验上线后的题库更新频率要控制好。我们每两周批量更新一次题库更新时不直接改线上表而是先导入到临时表做完整性和去重校验后通过rename table切换。这个流程虽然多写了几步但是彻底避免了更新过程中出现重复题、空选项这些低级事故。7. 回顾与实用建议系统上线已经跑了半年目前稳定支撑了大约三万注册用户最高同时在线考试人数突破了八百人。回想整个开发过程有几个经验值得分享给做类似学习考试系统的团队。第一题库建模一定要花时间做好。题目是系统的灵魂表结构如果一开始设计得不合理后续组卷、判分、统计全部都要返工。宁可前期多讨论两天也不要赶工上线后重建数据表。第二考试类功能的底线是数据不丢失、不重复。交卷幂等、答题快照、自动交卷这些能力必须提前设计不要等出了问题再补。第三数据分析功能不是锦上添花而是真正把普通题库变成学习系统的差别所在。一个能告诉用户你该练什么的系统比只能让你刷题的系统留存率高出一大截。最后分享一个小技巧如果项目工期紧智能组卷和掌握度分析可以先做基础版本但题库的表结构一定要按最终形态来建。因为功能可以迭代数据结构是底层地基地基歪了后面很难回正。考公在线系统不算特别冷门但把细节做扎实的并不多希望这篇记录能给做同类产品的团队一些参考。