ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

局域网考试系统终版:断网续考、幂等写入与收卷兜底

局域网考试系统终版:断网续考、幂等写入与收卷兜底

简介:《局域网考试系统终版》面向学校教师、教务管理者及需要组织课堂测验或校园考试的教学团队,定位于在局域网内部署的电子考核工具,重点解决出卷、阅卷、成绩统计与考试安全控制等环节的效率问题。压缩包约7.58MB,包内文件总数与类型明细暂未提供,从配套说明可知包含安装程序与使用手册,便于在封闭网络环境中完成部署和上手。已有207人学习或下载,具备一定参考热度。资源围绕试题库管理、自定义试卷、客观题自动批改、成绩报告生成、考试时间与题型设置、防作弊控制和答题数据分析展开,可帮助教师减少人工批改负担,快速掌握学生答题情况并调整教学;界面与操作流程偏简洁,适合中小规模课堂测验及校内考试场景使用。

1. 局域网考试系统终版:一间机房把外网拔了,考试还得照常跑完

机房里的局域网考试,最怕的往往不是题难,而是网断。外网一断,云端考试系统集体白屏,六十台机器同时举手,监考老师只能干瞪眼。这套「局域网考试系统终版」针对的就是这个场景:服务端跑在一台普通 PC 或讲台机上,考生用浏览器访问内网 IP,整套系统不依赖任何外部网络。它适合学校机房、培训机构机考、单位内部考核这类“网络封闭、人数几十到一两百、必须当天出成绩”的场合。终版之所以值得单独拿出来讲,是因为它把考场上真正会出问题的几件事补齐了:答案断线暂存、服务端崩溃后的数据恢复、收卷高峰的并发压制,以及一个能让你在开考前十分钟发现问题的自检流程。下面按“骨架怎么搭、链路怎么走、故障怎么兜、坑在哪”的顺序拆开,能直接照着复现。

2. 拆开终版看骨架:六张表、B/S 架构与十分钟能跑起来的部署

拿到一套考试系统,先别急着导题库。机房环境跟互联网产品不一样:没有运维值守、没有灰度发布、没有回滚窗口,考试铃一响就是生产环境。所以第一件事是看懂它的架构取舍和数据模型,知道哪一层出问题会直接导致丢答案,哪一层出问题只是页面难看。

2.1 为什么考场天然适合 B/S 而不是 C/S

老机房里不少考试系统是 C/S 的,客户端装一遍,配置一遍,还要注册组件。平时没事,一到考试就出问题:某台机器还原卡把客户端干掉了,某台机器 .NET 版本不对,某台机器杀软把 exe 拦了。这类问题在考试当天基本无解,因为你没有第二台机器可以换。

B/S 把这些风险挪到了服务端。考生端就是一个浏览器,地址栏输http://10.10.10.10:8000就能进考场,机器还原了也不影响,重启后重新输地址即可。代价是前端要自己兜底:浏览器会缓存、会休眠标签页、会在切屏时挂起定时器,这些东西在考场上都会被放大。

维度C/S 客户端B/S 浏览器
机房部署每台机器装一次,还原卡重启即失效服务端一份,考生端零安装
改题改卷全机房重装或打补丁服务端改完,考生刷新页面
断网续考客户端本地库可离线答题依赖前端缓存加服务端重连
故障定位逐台翻客户端日志服务端集中看,浏览器 F12 辅助
兼容风险与操作系统版本强绑定浏览器内核差异、缓存策略

提示:B/S 在考场上的隐性成本是“浏览器不可控”。老机房常见的双核机器配老内核浏览器,前端别用太新的语法特性,构建时把兼容目标压低一档,比事后打补丁省事。

2.2 数据模型:六张表撑住一场考试

整套系统的核心是六张表。判断一套考试系统靠不靠谱,看它的作答表有没有唯一索引就够了——没有唯一索引,自动保存就是“后写覆盖先写”,考生改过的答案会被几秒前的一次重试冲掉,这是最典型的丢分现场。

-- 考试场次:时间以服务端为准,考生端只做展示 CREATE TABLE exam ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(64) NOT NULL, start_at DATETIME NOT NULL, end_at DATETIME NOT NULL, duration INT NOT NULL, -- 分钟,用于个人倒计时 status TINYINT NOT NULL DEFAULT 0 -- 0未开始 1进行中 2已收卷 3已判分 ); -- 考生:机房用学号做主键,避免重名带来的串号 CREATE TABLE student ( sno VARCHAR(20) PRIMARY KEY, name VARCHAR(32) NOT NULL, class_name VARCHAR(32) NOT NULL ); -- 题目:type 决定判分策略,answer 统一存 JSON 数组 CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type TINYINT NOT NULL, -- 1单选 2多选 3判断 4填空 5主观 stem TEXT NOT NULL, options JSON NULL, -- [{"k":"A","v":"..."}, ...] answer JSON NOT NULL, -- ["A"] / ["A","C"] / ["对"] / ["TCP","传输控制协议"] score DECIMAL(4,1) NOT NULL, knowledge VARCHAR(32) NOT NULL, -- 知识点标签,组卷按它配额 difficulty TINYINT NOT NULL, -- 1易 2中 3难 content_hash CHAR(32) NOT NULL, UNIQUE KEY uk_hash (content_hash) -- 同一道题重复导入直接拦掉 ); -- 试卷:一人一卷,seed 保证可复现 CREATE TABLE paper ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL, sno VARCHAR(20) NOT NULL, seed BIGINT NOT NULL, total DECIMAL(5,1) NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_exam_sno (exam_id, sno) ); -- 试卷题目快照:题目后续被改,不影响已经发出去的卷 CREATE TABLE paper_question ( paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, seq INT NOT NULL, score DECIMAL(4,1) NOT NULL, PRIMARY KEY (paper_id, question_id) ); -- 作答:主键就是幂等键,重复提交不会产生第二条记录 CREATE TABLE answer_record ( paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, answer JSON NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (paper_id, question_id) );

paper_question这张快照表容易被省掉,省掉的后果是:考试进行到一半,有老师发现某道题有问题去改了题干或答案,已经发出去的卷子跟着变,考生看到的内容和判分依据对不上。快照表把“发卷那一刻的题目状态”固定下来,后续改题库只影响下一场。

answer_record的主键设计是关键。写入走INSERT ... ON DUPLICATE KEY UPDATE,接口天然幂等,前端重试、浏览器重复提交、网络抖动导致的补发,都不会写出两条记录。这条 SQL 后面还会用到。

2.3 十分钟把服务端跑起来

服务端常见做法是 Python 的 FastAPI 加 SQLAlchemy,小机房直接用 SQLite,人多或要多人同时评阅就换 MySQL。下列步骤从空机器开始,一台能跑 Python 的机器就够。

# 1. 建虚拟环境。离线机房要提前在有网机器上 pip download 好整包,拷进内网装 python -m venv venv && source venv/bin/activate pip install "fastapi==0.110.*" "uvicorn[standard]" sqlalchemy pymysql openpyxl # 2. 建库建表,DDL 放在 sql/schema.sql mysql -h127.0.0.1 -uroot -p exam_db < sql/schema.sql # 3. 改配置:数据库地址、监听端口、考生端可访问的静态目录 export DATABASE_URL="mysql+pymysql://exam:exam@127.0.0.1:3306/exam_db?charset=utf8mb4" # 4. 启动。workers 不要超过 CPU 核数,4 核机房机就开 4 uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 # 5. 自检:能在本机 curl 通,再去别的机器试 curl -s http://127.0.0.1:8000/api/health
参数建议值(60 人机房)说明
--host0.0.0.0只绑 127.0.0.1 会导致考生端连不上,这是最常见的第一晚翻车点
--port8000机房防火墙若只放行 80/443,就用 nginx 反代
--workers4等于物理核数,开太多反而在数据库连接上互相抢
连接池pool_size20每个 worker 一份,总量 = workers × pool_size
连接池max_overflow10留给收卷高峰的临时扩容

服务端起来之后,先在讲台机上用浏览器打开考生端,完整走一遍“登录 → 抽卷 → 答两道题 → 交卷”,再去任意一台考生机重复一遍。这一步看起来多余,但它能提前暴露跨机器访问、时间格式、静态资源路径这三类问题,比开考后排查便宜得多。

3. 组卷到收卷的整条链路:题库导入、分层抽题与状态机推进

骨架搭好只是能跑,真正决定考试体验的是中间这条链路:题库怎么进来、卷子怎么抽出来、状态怎么推进、分数怎么落下去。这一段出问题,现象往往很隐蔽——题抽重了、某类题不够、交卷时间和服务端差几分钟,考完才发现。

3.1 题库导入:字段映射与三道校验

老师手里的题库基本都是 Excel,列名五花八门:“题干”“题目”“试题内容”都可能是同一个意思。导入脚本的第一件事不是读数据,而是把列名统一映射成内部字段,映射不上就直接报错,不要猜。

Excel 列名内部字段必填说明
题干stem是为空整行跳过并记录行号
题型type是单选/多选/判断/填空/主观
选项A~Doptions选择题必填判断题不填,选项由系统生成
答案answer是多选写AC,填空多个答案用 `
分值score是小数,最多一位
知识点knowledge是组卷配额的依据,不能空
难度difficulty否空则默认中等
import hashlib, json from openpyxl import load_workbook TYPE_MAP = {"单选": 1, "多选": 2, "判断": 3, "填空": 4, "主观": 5} HEADERS = ["题干", "题型", "选项A", "选项B", "选项C", "选项D", "答案", "分值", "知识点", "难度"] def build_answer(qtype, raw): """把 Excel 里的答案文本规整成 JSON 数组,判分时只认数组""" raw = str(raw or "").strip() if qtype in (1, 2): return [c for c in raw.upper() if c in "ABCD"] if qtype == 4: # 填空允许多个等价答案,例如 TCP||传输控制协议 return [s.strip() for s in raw.split("||") if s.strip()] return [raw] def import_rows(path, session): ws = load_workbook(path, read_only=True).active ok, skipped = 0, [] for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): rec = dict(zip(HEADERS, row)) stem = str(rec.get("题干") or "").strip() if not stem: skipped.append((idx, "题干为空")) continue qtype = TYPE_MAP.get(str(rec.get("题型") or "").strip()) if qtype is None: skipped.append((idx, f"题型无法识别: {rec.get('题型')}")) continue options = [{"k": k, "v": str(rec.get(f"选项{k}") or "").strip()} for k in "ABCD" if rec.get(f"选项{k}")] answer = build_answer(qtype, rec.get("答案")) if not answer: skipped.append((idx, "答案为空")) continue # 内容哈希:题干+选项+答案一起算,同一道题导两遍会被唯一索引拦下 raw = stem + json.dumps(options, ensure_ascii=False) + json.dumps(answer, ensure_ascii=False) session.execute( "INSERT IGNORE INTO question(type, stem, options, answer, score, knowledge, difficulty, content_hash)" " VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", (qtype, stem, json.dumps(options, ensure_ascii=False), json.dumps(answer, ensure_ascii=False), float(rec.get("分值") or 0), str(rec.get("知识点") or "未分类"), int(rec.get("难度") or 2), hashlib.md5(raw.encode()).hexdigest()) ) ok += 1 session.commit() return ok, skipped

INSERT IGNORE配合唯一索引做去重,比在 Python 里查一遍再插入快得多,也避免了并发导入时的竞态。返回的skipped一定要打出来给老师看,行号加原因,让他在导入阶段就把脏数据改掉——等到考生登录发现少了两道题,那就不是改数据的问题了。

注意:难度列别让老师随意填。统一约定 1/2/3 三档,其他值一律按 2 处理,否则后面按难度分层抽题时会冒出第四层,配额算不平。

3.2 随机组卷:分层抽样加固定种子

“随机组卷”四个字听着简单,实际要满足三件事:题型题数对得上、难度分布别太偏、同一份卷子能复现。第三点最容易被忽略,但它是申诉复查的唯一依据——考生说某题有问题,你得能把当时那张卷原样还原出来。

做法是给每张卷子生成一个seed,用random.Random(seed)做抽样,不用全局的random。同一 seed 抽出来的题目序列完全一致,存库即可复现。

import random from collections import defaultdict # 题型 -> (题数, 每题分值) SPEC = {1: (20, 2.0), 2: (10, 3.0), 3: (10, 1.0), 4: (5, 2.0), 5: (2, 10.0)} # 易 / 中 / 难 的卷面占比 DIFF_RATIO = (0.5, 0.35, 0.15) def build_paper(bank, seed): rnd = random.Random(seed) # 固定种子,事后可原样复现 picked, shortage = [], [] for qtype, (count, score) in SPEC.items(): pool = [q for q in bank if q["type"] == qtype] buckets = defaultdict(list) for q in pool: buckets[q["difficulty"]].append(q) for b in buckets.values(): b.sort(key=lambda q: q["id"]) # 先定序再洗牌,保证跨版本可复现 rnd.shuffle(b) # 按占比算每层要几道,余数补到中等难度,避免卷面过难或过易 target = [round(count * r) for r in DIFF_RATIO] target[1] += count - sum(target) chosen = [] for diff, need in zip((1, 2, 3), target): take = buckets[diff][:need] chosen.extend(take) if len(take) < need: shortage.append((qtype, diff, need - len(take))) # 某一层不够时从同题型其他难度补齐,宁可难度偏,也不能缺题 if len(chosen) < count: rest = [q for q in pool if q not in chosen] rnd.shuffle(rest) chosen.extend(rest[:count - len(chosen)]) picked.extend((q, score) for q in chosen) return picked, shortage

shortage的返回值是给排考人看的:如果某场考试的“难题”配额差了 3 道,脚本要明确报出来,而不是静默从简单题里补。补题逻辑本身要保留,因为缺题比难度偏更严重,但缺口的数量必须暴露。

q not in chosen在大题库上是 O(n²),两三千道题的规模完全够用,题量上万就改成用 id 集合判断。

3.3 状态机:时间永远以服务端为准

考试的四个状态之间只允许单向流转,所有涉及时间的判断都走服务端NOW(),考生端的倒计时只是展示。

当前状态触发条件允许操作禁止操作
0 未开始创建场次改题、改组卷规则、导名单考生登录作答
1 进行中服务端时间 ≥start_at作答、暂存、提前交卷改组卷规则
2 已收卷到达end_at或全部交卷只读、导出原始答卷任何写操作
3 已判分判分任务跑完导出成绩、复核改客观题答案(需走重判)

前端每秒刷新倒计时,但这个倒计时不能用来做交卷判断。考生端把系统时间往回调一小时、改时区、休眠后唤醒,都会让本地倒计时和服务端差出几分钟。常见的翻车现象是“考生端显示还有 5 分钟,点提交却提示已截止”。正确做法是提交接口带上服务端的截止时间校验,前端倒计时只负责提醒;每次提交的响应里回传一个server_ts,前端拿它修正本地偏差,倒计时就不会越走越偏。

3.4 客观题判分与主观题人工评阅的边界

判分只做客观题:单选、判断、填空按字符串比对;多选按“漏选给一半分、错选零分”处理,这个规则要在考前明确告知考生,别在成绩出来后才解释。

主观题不自动判分,只把答卷快照落库,交给人工评阅模块。这里有个容易忽视的细节:主观题答案往往很长,不能和客观题挤在同一张answer_record的 JSON 字段里频繁更新,否则每次自动保存都在写一条几百 KB 的记录,收卷高峰期数据库会被这些大字段拖慢。常见做法是主观题单独一张表,只在“交卷”这个动作里写一次,中途不暂存。

4. 断网、断电、掉进程:考场三类故障的兜底参数与恢复步骤

前面都是顺利路径。考场的真实情况是:网线被踢掉、插排被碰掉、服务端进程被某个杀软顺手结束。这三类故障不一定会发生,但一旦发生,没有兜底就是全机房重考。

4.1 答案暂存与断线续考

前端不能“答一题提交一次”,那就是把数据库当消息队列用,收卷前的高峰直接压垮写接口。常见做法是本地暂存加定时批量提交:内存里维护一个待提交队列,每 15 秒刷一次,同时把答案同步写进localStorage,断电后重进页面还能捞回来。

// 每 15 秒批量提交一次;答案同时落 localStorage,断电兜底 const pending = new Map(); const FLUSH_MS = 15000; function markDirty(qid, answer) { pending.set(qid, answer); localStorage.setItem(`ans:${PAPER_ID}:${qid}`, JSON.stringify(answer)); } async function flush() { if (!pending.size) return; const batch = [...pending.entries()].map(([qid, ans]) => ({ question_id: qid, answer: ans })); pending.clear(); try { const rsp = await fetch(`/api/paper/${PAPER_ID}/answers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: batch, client_ts: Date.now() }), }); if (!rsp.ok) throw new Error(rsp.status); const { server_ts } = await rsp.json(); syncClock(server_ts); // 用服务端时间校正倒计时 batch.forEach(x => localStorage.removeItem(`ans:${PAPER_ID}:${x.question_id}`)); } catch (e) { // 提交失败原样放回队列,下一轮重试;这里绝对不能清 localStorage batch.forEach(x => pending.set(x.question_id, x.answer)); } } setInterval(flush, FLUSH_MS); window.addEventListener('beforeunload', () => { navigator.sendBeacon?.(`/api/paper/${PAPER_ID}/answers/beacon`, JSON.stringify([...pending])); });

服务端写入用一条幂等 SQL 收口:

INSERT INTO answer_record (paper_id, question_id, answer, updated_at) VALUES (%s, %s, %s, NOW()) ON DUPLICATE KEY UPDATE answer = VALUES(answer), updated_at = VALUES(updated_at);

批量提交的粒度值得琢磨。15 秒是经验值:60 人机房,每人平均每 15 秒改 3 到 5 题,折算下来每秒 12 到 20 次写,对 MySQL 是小负载;间隔拉到 60 秒,断电时最多丢一分钟的作答,风险偏大。断线后重连,前端先拉一次服务端已保存的答案做合并,冲突时以“最后修改时间更晚”的一方为准,并且把合并结果在页面上提示给考生,别让他以为答案被吃掉了。

4.2 服务端进程挂了怎么救

小机房用 SQLite 的话,三个 PRAGMA 必须设对:

# WAL 提升并发读;synchronous=FULL 保证每次事务真正落盘 sqlite3 exam.db <<'SQL' PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA wal_autocheckpoint = 200; SQL

synchronous=FULL会慢,但考试系统里“慢一点”和“丢答案”之间没有选择题可做。用 MySQL 的话,把innodb_flush_log_at_trx_commit设成 1,别为了性能改成 2 或 0——省下的那点 IOPS 换不来一次丢数据的后悔药。

进程被结束之后的重启,要有脚本兜底,不能靠人手动敲命令。常见的做法是一个每分钟跑一次的守护脚本加定时备份:

#!/usr/bin/env bash # 每分钟由计划任务调用:进程不在就拉起,顺带做一次增量备份 PID=$(pgrep -f "uvicorn app.main:app" | head -1) if [ -z "$PID" ]; then cd /opt/exam && nohup venv/bin/uvicorn app.main:app \ --host 0.0.0.0 --port 8000 --workers 4 >> logs/server.log 2>&1 & echo "$(date '+%F %T') restarted" >> logs/watchdog.log fi # 每 10 分钟落一份备份,保留最近 12 份 if [ $(( $(date +%M) % 10 )) -eq 0 ]; then mysqldump -uexam -pexam exam_db | gzip > /data/backup/exam_$(date +%H%M).sql.gz ls -t /data/backup/*.sql.gz | tail -n +13 | xargs -r rm -f fi

重启后第一件事不是看页面能不能打开,而是核对answer_record的条数和最后一次写入时间。如果时间戳停在崩溃前几分钟,说明前端那几批提交确实没落库,这时候要引导考生重进页面,前端会从localStorage把没提交成功的答案补交上来——这套兜底链条要串起来才有意义,只做一半等于没做。

4.3 收卷高峰的并发压制

收卷那两分钟是整个考试里唯一的流量尖峰。60 个人同时点交卷,每次交卷触发判分、写成绩、更新状态,如果没有约束,就是 60 个并发写事务撞在同一批行上。

参数建议值作用
--workers4与核数一致,多了在锁上互相等
pool_size20单个 worker 的连接数
max_overflow10尖峰时的临时连接
nginxkeepalive_timeout65s要大于前端 15s 的提交间隔
交卷接口超时30s小于浏览器默认超时,保证能拿到错误码
单场并发上限100留 40% 余量给断线重连

做法是给交卷接口加一层轻量约束:判分不放在请求里同步做,交卷只负责翻转状态和落快照,判分交给后台任务按队列跑。考生点完交卷立刻看到“已交卷”的确认页,成绩延后十几秒出来,体验反而更好。另一个细节是别在收卷时做全表统计,成绩汇总放到考试结束后统一跑,收卷那一刻只做单条更新。

5. 避坑与排查:五个能让整场考试当场翻车的细节

下面这几条都是实际排障时反复遇到的,现象看着玄学,原因都很具体。

5.1 考生端倒计时和服务端差了 3 分钟

现象:考生页面显示还剩 4 分钟,点提交提示“考试已结束”;或者反过来,已经过了截止时间还能继续答。

原因:倒计时用的是浏览器本地时间。机房里机器长期不校时,CMOS 电池老化,差几分钟很正常;标签页被切到后台时定时器还会被节流,累计误差越走越大。

解决:所有截止判断都放在服务端,接口里比对NOW()和end_at;前端每次提交的响应里带回server_ts,用它算一个偏移量修正显示。开考前把所有考生机的时间同步一遍是治标,代码里不信本地时间才是治本。

5.2 自动保存把上一题的答案覆盖成空

现象:考生明明选了 B,交卷后该题显示未作答;日志里能看到两条针对同一题的写入,后一条是空值。

原因:前端把“这道题没动过”和“这道题被清空”混为一谈,整卷定时提交时把未作答的题也一起打包发出去,服务端老老实实按空值 upsert。

解决:只提交 dirty 的题,用pending队列维护改动集合;确实需要清空时显式传null而不是缺省。服务端加一层防御,对选择题的空答案不覆盖已有非空值,宁可留旧值也别写空。

5.3 组卷抽出来的题和昨天模拟考高度重合

现象:连着两场考试,前 20 道单选里有一多半是同一批题,考生一眼认出来。

原因:抽样只用了全局random,没有按 seed 隔离;或者题库里某知识点题量太少,分层抽样每层都只能取到那几道。

解决:每张卷子用独立random.Random(seed);组卷前先跑一次配额校验,某知识点可用题量低于需求量的 2 倍就报警,让老师补题。同一个考生跨场次去重也能加,用paper_question的历史记录排除最近 N 场出过的题。

5.4 收卷瞬间页面转圈 40 秒

现象:交卷按钮点下去没反应,进度条转很久,部分考生刷新后重复交卷。

原因:交卷接口里同步做了判分加统计,一次请求扫了整张answer_record;多个请求撞在一起,行锁排队。

解决:交卷只翻转状态、写快照,判分挪到后台任务;接口加幂等键(paper_id + sno),重复交卷直接返回上次结果。nginx 的proxy_read_timeout调大到 60s,避免网关先超时断开而服务端还在跑。

5.5 机房还原卡把考生端缓存的答案一起清了

现象:某几台机器中途重启,重进页面后之前答的全没了。

原因:答案只存在localStorage,而还原卡重启会把浏览器数据一起还原;同时那几台机器的提交间隔恰好卡在断网窗口里。

解决:把提交间隔压到 15 秒以内,缩短“只存在本地”的窗口;服务端在考生重新登录时主动回推已保存的答案;机房机器统一关掉“退出清除浏览数据”的策略,或者干脆给浏览器指定一个非还原分区的用户数据目录。

6. 进阶用法:开考前十分钟的自检脚本与两条改造路线

终版给的是能跑通的基线,真要用进自己的考场流程,还差一步:把“开考前检查”这件事固化成脚本。人肉检查靠不住,尤其是早上八点半,谁都紧张。

import socket, sqlite3, shutil def check(host, port, db_path, min_questions): problems = [] # 1. 端口通不通:绑了 127.0.0.1 是最常见的第一次翻车 with socket.socket() as s: s.settimeout(2) if s.connect_ex((host, port)) != 0: problems.append(f"{host}:{port} 连不上") # 2. 数据库能不能写:只读挂载、磁盘满都会在这里暴露 try: con = sqlite3.connect(db_path, timeout=3) con.execute("CREATE TABLE IF NOT EXISTS _probe(id INT)") con.execute("DELETE FROM _probe") con.commit() except Exception as e: return problems + [f"数据库不可写: {e}"] # 3. 题量够不够组卷,别等考生登录才发现少题 n = con.execute("SELECT COUNT(*) FROM question").fetchone()[0] if n < min_questions: problems.append(f"题库仅 {n} 题,低于下限 {min_questions}") # 4. 磁盘余量,日志写满盘是收卷失败的经典原因 free_gb = shutil.disk_usage(db_path).free / 2**30 if free_gb < 2: problems.append(f"磁盘剩余仅 {free_gb:.1f} GB") return problems if __name__ == "__main__": result = check("10.10.10.10", 8000, "exam.db", 120) print("\n".join(result) if result else "自检通过")

这个脚本的价值不在代码本身,而在它把四件事变成了机械动作:网络可达、数据库可写、题量充足、磁盘有余。四项里任何一项不通过,就别开考。

两条改造路线也顺带说一下。第一条是把 SQLite 换成 MySQL,改动集中在连接串和那条 upsert 语句,INSERT IGNORE换成ON DUPLICATE KEY UPDATE,其余逻辑不动,适合人数过百或者要多人同时在线评阅的场合。第二条是把组卷规则从代码里挪到配置文件,题型配额、难度占比、知识点必选清单都做成可编辑的 JSON,排考的人自己改,不用每次找你改代码。第二条路走完,这套东西才算真正交得出去。

血泪经验是有一年模拟考,我图省事没跑自检,结果服务端绑在 127.0.0.1 上,六十台机器全连不上,临时改配置重启花了十二分钟,那十二分钟里机房里安静得能听见风扇声。从那以后我每次开考前都强制走一遍自检脚本,哪怕前一天晚上已经验证过。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进