
简介这套局域网考试系统含完整源码面向需要在内网部署在线考试平台的院校教师、培训机构管理员与.NET开发者可用于试题库管理、自动组卷、考生信息维护、成绩批阅与统计分析的二次开发或教学实践。压缩包共84个文件约4.08MB以C#源码.cs、WinForms界面资源.resx/.resources、解决方案与项目文件.sln/.csproj为核心并附客户端与服务端安装包.exe/.msi、配置说明.ini/.config及Access数据库.mdb覆盖主考端、客户端和安装部署模块。已有464人学习说明其在局域网考试场景中具备一定参考价值。读者可据此梳理考试流程、组卷策略、防作弊与成绩统计的实现方式也能运行安装包体验主考端与客户端交互适合作为课程设计、毕业设计或内部考试系统改造的对照样本。1. 机房外网断了那场机考照样开考去年冬天某高校机房的外网出口突然断了监考老师跑来问要不要延期负责技术的同事只回了一句「不用局域网考试系统本来就不走外网」。那场机考照常发卷、照常收卷考完一键判分导出学生甚至没察觉异常。这套局域网考试系统的思路很朴素教师机起服务学生机用浏览器直连内网地址题库、作答、成绩全部留在本地外网断不断跟它没关系。它适合机房条件一般、又不想把考题和考生数据放到公网的中小学、培训机构和内部认证场景也适合想拿来改题型、接自己业务系统的开发者——代码结构不复杂一两天能摸透主干。2. 架构与目录一套只在局域网内自洽的考试系统长什么样先把这套局域网考试系统拆成三个角色看教师机上的服务端、监考端页面、考生端页面。服务端同时承担 API 和静态资源托管两件事学生机打开浏览器输入内网地址就能答题不需要装任何客户端。这个选择在机房场景里几乎是必选项——学生机大多装了还原卡重启就回滚装客户端的方案每次考试前都要重新部署一遍运维成本高得离谱。浏览器方案只要保证内网通、端口放行剩下的都在服务端。2.1 三层结构监考端、考生端、服务端各管什么监考端负责出卷、开考、延时、强制收卷、看在线状态和异常记录考生端只做四件事登录、拉题、答题暂存、交卷服务端是唯一可信的数据源所有判分、计时、状态变更都以它为准。很多自研考试系统翻车根源就是把「剩余时间」「是否已交卷」这类状态交给前端维护学生一刷新页面或者改一下本地存储状态就乱了。角色访问地址主要功能数据权限服务端教师机 0.0.0.0:8000组卷、发卷、暂存、判分、导出全部读写监考端/teacher开考、延时、强制收卷、看异常只读作答可改考试状态考生端/student登录、答题、交卷只能读写自己的作答这张表看起来简单落地时有个隐含约束考生端的接口必须带考生身份校验不能用「传一个 student_id 就返回对应作答」这种写法。局域网里虽然没有外网攻击面但同一个机房的学生互相串号、改 URL 参数看别人答案的情况我在实际考场里见过不止一次。2.2 技术选型为什么局域网场景更适合 B/S 加轻后端后端常见做法是 Python 的 FastAPI 或 Flask前者自带 OpenAPI 文档、异步支持好写心跳和长轮询更顺手数据库单考场用 SQLite 足够一个考场几十到一两百人写入压力集中在交卷那几分钟SQLite 加 WAL 模式完全扛得住多考场、几百人并发再换 MySQL。前端用 Vue 3 加 Vite 打包或者干脆原生 JS考生端页面元素不多原生写反而少一层构建依赖。这里有一条血泪经验局域网考试系统的前端绝对不能引外网 CDN。字体、图标库、UI 框架的 CDN 地址在内网环境里全部超时页面会白屏几十秒学生以为系统坏了就开始乱点。所有静态资源必须本地打包进web/dist构建产物里搜一遍https://确认没有外链这是上线前的固定动作。实时性方面不用一上来就上 WebSocket。答题暂存用普通 POST 就够监考端的在线状态用 5 到 10 秒一次的心跳轮询实现实现简单、断线重连逻辑也好写。真要做切屏实时告警再对单个考场加一条 WebSocket 通道属于增量改造而不是架构前提。2.3 目录结构与启动入口目录按「服务端 / 前端 / 脚本」三分考生端和监考端分开打包互不干扰。下面是我拆过的这类项目里最典型的一种组织方式lan-exam/ ├── server/ │ ├── main.py # 应用入口注册路由并挂载前端静态目录 │ ├── config.py # 端口、数据库路径、默认考试时长 │ ├── models.py # 考生/试卷/题目/作答/成绩/异常 六张核心表 │ ├── api/ │ │ ├── auth.py # 考生登录、监考登录、会话校验 │ │ ├── paper.py # 组卷、开考发卷、按考生取题 │ │ ├── answer.py # 作答暂存、交卷 │ │ └── admin.py # 成绩导出、异常记录查询 │ ├── service/ │ │ ├── grader.py # 判分逻辑按题型分发 │ │ └── monitor.py # 心跳、切屏事件、超时强制收卷 │ └── data/ │ └── exam.db # SQLite 数据文件考试前直接拷贝备份 ├── web/ │ ├── teacher/ # 监考端源码 │ └── student/ # 考生端源码 └── scripts/ ├── init_db.py # 建表 导入演示题库 └── export_score.py # 命令行导出成绩不依赖网页server/data/exam.db这个设计值得单独说一句整个考试的数据就是一个文件考前拷贝一份到 U 盘、考完再拷一份存档出问题能立刻回滚到开考前的状态。这种「后悔药」在考场环境里比任何备份系统都实用因为监考老师不会等你连数据库管理工具。接口层面考生端真正需要的只有五个登录、取卷、暂存、交卷、心跳。监考端多出开考、延时、强制收卷、导出四个。接口越少考场出问题时排查路径越短。3. 跑通教师机起服务、学生机连进来的完整步骤整套系统的部署链路是教师机装 Python 依赖、初始化数据库、构建前端、启动服务然后学生机用内网 IP 访问。听起来四步实际最容易卡住的是最后一步——教师机自己能打开学生机打不开。这一节按顺序把每一步的命令和参数含义讲清楚照着走一遍基本能在半小时内跑起来。3.1 服务端依赖与数据库初始化先把运行环境准备好。Python 建议 3.9 以上虚拟环境隔离依赖避免和教师机上其他程序打架cd lan-exam python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install fastapi uvicorn sqlalchemy openpyxl python scripts/init_db.py # 建表并写入一套演示题库uvicorn是 ASGI 服务器openpyxl只服务于成绩导出sqlalchemy负责 ORM 映射。init_db.py做的事是建六张表并塞进演示数据核心建表逻辑大致是这样# scripts/init_db.py from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Float from sqlalchemy.orm import declarative_base, sessionmaker import datetime Base declarative_base() class Question(Base): __tablename__ question id Column(Integer, primary_keyTrue) qtype Column(String(16), indexTrue) # single/multi/judge/blank/essay stem Column(Text, nullableFalse) # 题干 options Column(Text) # JSON 字符串选择题的选项 answer Column(Text) # 标准答案主观题为空 score Column(Float, default2.0) # 该题分值 knowledge Column(String(32), indexTrue) # 知识点标签抽题时按它分层 class Answer(Base): __tablename__ answer id Column(Integer, primary_keyTrue) exam_id Column(Integer, indexTrue) student_id Column(Integer, indexTrue) question_id Column(Integer, indexTrue) content Column(Text, default) updated_at Column(DateTime, defaultdatetime.datetime.now, onupdatedatetime.datetime.now) engine create_engine(sqlite:///server/data/exam.db) Base.metadata.create_all(engine)字段设计里有两个点影响后面所有逻辑。question.qtype决定了判分走哪个分支question.knowledge决定了抽题能不能按知识点均衡——如果一套卷子随机抽出来八道题全是同一个章节难度就失控了。answer表用(exam_id, student_id, question_id)三元组定位一条作答暂存就是 upsert不用先查再判断插入还是更新直接按唯一索引做INSERT ... ON CONFLICT DO UPDATE。数据库文件路径写死成server/data/exam.db意味着启动服务前必须先建好data目录否则 SQLite 会报「unable to open database file」。这个问题第一次部署几乎人人踩一次。3.2 前端构建与静态资源托管前端两个入口分别构建产物丢进server/static下的子目录cd web/student npm install npm run build # 产物默认在 dist/ cd ../teacher npm install npm run build mkdir -p ../../server/static cp -r ../student/dist ../../server/static/student cp -r ../teacher/dist ../../server/static/teacher构建完成后由后端统一托管静态文件服务端只需要多挂两行# server/main.py from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from api import auth, paper, answer, admin app FastAPI(titlelan-exam) app.include_router(auth.router, prefix/api) app.include_router(paper.router, prefix/api) app.include_router(answer.router, prefix/api) app.include_router(admin.router, prefix/api) # 注意静态目录必须最后挂载且 htmlTrue 才能让 /student 直接返回 index.html app.mount(/student, StaticFiles(directorystatic/student, htmlTrue), namestudent) app.mount(/teacher, StaticFiles(directorystatic/teacher, htmlTrue), nameteacher)挂载顺序是有讲究的/api前缀的路由必须先注册根路径和子路径的静态挂载放在最后。如果反过来写/api/answer/save这类请求会被静态文件中间件先接住返回 404 而不是进业务逻辑排查起来很费时间因为日志里看起来「请求根本没到后端」。启动命令uvicorn main:app --host 0.0.0.0 --port 8000--host 0.0.0.0是整套部署里最关键的一个参数。默认的127.0.0.1只监听本机回环地址教师机浏览器打得开学生机一律连不上。--port 8000是内网访问端口如果机房有统一的端口规范在config.py里同步改掉别只改命令行参数否则前端里写死的请求地址会对不上。3.3 多机联调IP、端口、防火墙三件事服务起来后先在教师机上确认监听状态再让学生机试连。教师机的内网地址用系统命令查# Linux / macOS ip addr | grep inet | grep -v 127.0.0.1 # Windows ipconfig | findstr IPv4拿到类似192.168.1.20的地址后学生机浏览器访问http://192.168.1.20:8000/student。打不开时按这个顺序查先在学生机上ping 192.168.1.20确认二层通不通通的话用telnet 192.168.1.20 8000或 PowerShell 的Test-NetConnection测端口端口不通基本就是教师机防火墙没放行。# Linux 放行 8000 端口 sudo ufw allow 8000/tcp # CentOS / RHEL 系 sudo firewall-cmd --add-port8000/tcp --permanent sudo firewall-cmd --reload # Windows在高级防火墙里新建入站规则放行 TCP 8000还有一个容易被忽略的点机房如果有多台交换机或者划分了不同网段教师机和学生机不一定在同一个广播域ping不通但两边都能上外网的情况确实存在。考前一定要用真实的学生机实测一遍别只在教师机上自我验证。我一般会在开考前半小时让学生机随便打开一个页面确认能拿到登录框再统一发卷。4. 核心链路一个考生的 90 分钟发生了什么从学生点击登录到成绩落库中间真正有技术含量的只有四段发卷时的抽题与快照、作答时的暂存与续考、交卷时的判分、全程的心跳与异常记录。这四段串起来就是整套局域网考试系统的主干改题型、改判分规则、加监考功能都是往这四段上挂东西。4.1 开考与试卷下发随机抽题怎么保证同一考场题目等值抽题不能纯随机。纯随机抽八道题可能出现三道都是同一个知识点、分值还都一样的情况两个考生的卷子难度差异会很大。可行的做法是按知识点分层抽样同一知识点抽固定数量题目顺序再打乱# service/paper.py import json, random from sqlalchemy import select def build_paper(db, exam_id: int, student_id: int, rule: dict) - list[dict]: rule 形如 {single: {count: 10, score: 2}, judge: {count: 5, score: 1}} picked [] for qtype, cfg in rule.items(): rows db.execute( select(Question).where(Question.qtype qtype) ).scalars().all() # 按知识点分组每个知识点内部随机组间轮转取样保证覆盖均匀 buckets: dict[str, list] {} for q in rows: buckets.setdefault(q.knowledge or default, []).append(q) keys list(buckets.keys()) random.shuffle(keys) cursor, per_bucket 0, 0 while len(picked) sum(c[count] for c in rule.values()) or per_bucket cfg[count]: bucket buckets[keys[cursor % len(keys)]] if bucket: q bucket.pop() picked.append({id: q.id, score: cfg[score]}) per_bucket 1 cursor 1 if cursor 10000: break random.shuffle(picked) # 打乱题序降低邻座抄袭收益 return picked这段的核心思路是「先分组再轮转」不会因为某个知识点题目少就抽空也不会让某类题扎堆。cfg[score]允许同一题型在不同考试里配不同分值比如单元测验判断题 1 分、期末 2 分。抽题结果必须立刻写成快照存进exam_paper表字段exam_id、student_id、paper_json、started_at。快照的意义在于考试中途有人在后台改了题库答案或者删了题已经发出去的卷子不受影响否则学生答的题和判分用的题就对不上了。这个坑我在一次性小系统里踩过考完发现三个人的某道题判分是 0因为那道题在考试期间被删了。发卷接口返回给前端的只有题干、选项、分值和题序绝不能带answer字段。这一点再小心都不过分前端 F12 一看就全露了。4.2 答题暂存断线续考靠的是服务端而不是浏览器存储作答保存分两层前端每次输入做 500 毫秒防抖后提交后端按三元组 upsert。前端那层只是减少请求量真正保证不丢答案的是服务端。// web/student/src/answer.js const pending new Map(); function queueSave(questionId, content) { pending.set(questionId, content); clearTimeout(window.__saveTimer); window.__saveTimer setTimeout(flush, 500); // 防抖停止输入 500ms 后批量提交 } async function flush() { if (pending.size 0) return; const payload [...pending.entries()].map(([qid, content]) ({ question_id: qid, content })); pending.clear(); try { await fetch(/api/answer/save, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ answers: payload }) // 批量提交一次请求多条作答 }); } catch (e) { // 提交失败就放回队列下一个心跳周期重试避免网络抖动丢答案 payload.forEach(a pending.set(a.question_id, a.content)); } }后端接收时按考生会话里的student_id落库不信任前端传来的身份字段# api/answer.py from fastapi import APIRouter, Depends from sqlalchemy.dialects.sqlite import insert router.post(/answer/save) def save_answers(body: SaveBody, dbDepends(get_db), stuDepends(current_student)): for item in body.answers: stmt insert(Answer).values( exam_idstu.exam_id, student_idstu.id, question_iditem.question_id, contentitem.content ) # 主键冲突时只更新内容与时间不重复插入 stmt stmt.on_conflict_do_update( index_elements[exam_id, student_id, question_id], set_{content: item.content} ) db.execute(stmt) db.commit() return {ok: True}current_student这个依赖从登录时下发的会话 token 里解出考生身份前端改不了。on_conflict_do_update依赖answer表上的唯一索引建表时记得加上否则每次都会插入新行一个学生一道题攒出十几条记录导出成绩时全是脏数据。断线续考的逻辑就落在这里学生机掉线重连后前端先调/api/paper/current拿回自己的卷子和已保存的作答再继续答题。剩余时间由服务端按started_at算出来下发不读本地时间。4.3 交卷与判分客观题机判、主观题留痕判分按题型分发客观题直接比对多选用集合比较避免「AB」和「BA」判错主观题不机判但要把作答原文、字数、提交时间一起落库方便后面人工阅卷# service/grader.py def grade(exam_id: int, student_id: int, db) - float: total 0.0 rows db.execute( select(Answer, Question).join(Question, Answer.question_id Question.id) .where(Answer.exam_id exam_id, Answer.student_id student_id) ).all() for ans, q in rows: raw (ans.content or ).strip() if q.qtype single: ok raw.upper() (q.answer or ).strip().upper() elif q.qtype multi: ok set(raw.upper()) set((q.answer or ).strip().upper()) elif q.qtype judge: ok raw in (对, 错) and raw q.answer elif q.qtype blank: # 填空题允许多个标准答案用 | 分隔忽略首尾空格与全半角差异 ok raw in [a.strip() for a in (q.answer or ).split(|)] else: ok None # 主观题置空等人工 if ok: total q.score db.execute(update(Answer).where(Answer.id ans.id) .values(scoreq.score if ok else 0, need_manual(ok is None))) db.commit() return totalneed_manual这个标记很实用导出成绩时按它筛出待人工阅卷的记录阅卷界面只显示这些不用在几百份卷子里翻。q.answer为空又没标主观题的情况要在题库录入时就拦住否则会静默判 0 分考完才发现退分流程非常麻烦。交卷接口要做幂等同一个学生重复提交只认第一次第二次直接返回已交卷状态。考场里网络卡顿导致学生连点两次交卷的情况太常见了没有幂等保护就会出现两套成绩。4.4 监考心跳与切屏检测监考端要看到「谁在线、谁掉线、谁切出去了」靠两样东西定时心跳和浏览器的可见性事件。// web/student/src/monitor.js setInterval(() { fetch(/api/monitor/heartbeat, { method: POST }); // 10 秒一次服务端更新 last_seen }, 10000); document.addEventListener(visibilitychange, () { if (document.hidden) { fetch(/api/monitor/event, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ type: blur, at: Date.now() }) // 切屏/最小化 }); } });服务端把心跳更新到student_session.last_seen监考端每 5 秒拉一次在线列表超过 30 秒没心跳标黄、超过 90 秒标红。切屏事件写进monitor_event表按考生聚合后在监考端显示次数。需要说清楚的是这类检测只能作为参考不能作为作弊认定的唯一依据浏览器标签页被系统弹窗顶掉、输入法候选框展开都可能触发visibilitychange。监考端显示的是「异常次数」最终判定还是要人看。另外心跳间隔别设太短一个考场 100 人、5 秒一次心跳就是每秒 20 个请求SQLite 会开始出现写锁等待反而拖慢答题提交。5. 避坑与排查局域网考试系统最容易翻车的 5 个地方5.1 教师机能打开学生机全部打不开现象教师机浏览器访问http://127.0.0.1:8000/student正常学生机访问内网 IP 一直转圈直到超时。原因服务启动时用了默认的127.0.0.1只监听回环地址或者监听地址对了但教师机防火墙没放行 8000 端口。解决启动命令改成--host 0.0.0.0再按系统放行端口ufw allow 8000/tcp或 Windows 入站规则。改完先在本机用内网 IP 访问一遍能通再让学生试别拿学生机当测试机。5.2 开考瞬间全班卡住登录接口超时现象几十个学生同时点「开始考试」页面卡十几秒部分人报 504监考端在线列表一片灰。原因开考时每个学生一次请求要抽题、查题库、把整张卷子含图片序列化返回题库里的图片还是 base64 塞在题干里单次响应几 MB几十个人同时拉直接把进程打满。解决把组卷提前到开考前监考端点「开考」时后台批量给所有考生生成快照并缓存图片改成静态文件题干里只存路径接口层加分页或分批下发。实测把图片从 base64 挪出去单次响应体积能降一个数量级。5.3 交卷后成绩少了几个人现象监考端显示 48 人交卷成绩表只有 45 条缺的三个人说自己明明点了交卷。原因交卷靠前端定时器触发「时间到自动交卷」但浏览器标签页在后台会被降频setTimeout延迟几分钟甚至不执行学生切出去看别的页面回来时页面已经不再计时。解决服务端按started_at duration做强制收卷后台任务每 30 秒扫一遍超时未交的考生直接把当前已暂存的作答封卷判分。前端的自动交卷只作为体验优化不能作为数据完整性的保证。5.4 断线重连后答案变空白现象学生机网络抖了一下刷新页面重新登录前面答的题全空了学生当场慌了。原因作答只存在localStorage里换浏览器、清缓存、或者用了无痕模式就全没了也可能是暂存接口一直失败但前端吞了异常。解决每次输入落服务端4.2 那套防抖加批量提交重连后从/api/paper/current拉回已保存作答localStorage只作为兜底缓存不作为唯一数据源。另外前端提交失败时要有可见提示别静默重试到考试结束。5.5 倒计时每个人显示的时间不一样现象同一个考场有人显示还剩 43 分钟有人显示 47 分钟最后交卷时间差了四五分钟。原因倒计时用的是学生机本地时间减去页面加载时间机房机器的时间本来就没同步差几分钟很正常。解决开考时服务端下发剩余秒数和服务器时间戳前端用「服务器时间 本地流逝时间」计算显示每次心跳回来校准一次。考试结束以服务端时间为准前端倒计时归零只是提示。6. 二次开发加题型和扩考场改动最小化的两条路6.1 加一种新题型改三个地方就够新题型的改动点固定在三处题库表的qtype取值、考生端的渲染分支、判分器的分发逻辑。判分那块建议一开始就写成注册表而不是 if-else 长链后面加题型只需要多注册一个函数# service/grader.py GRADERS {} def register(qtype): def deco(fn): GRADERS[qtype] fn return fn return deco register(multi) def grade_multi(ans: str, std: str, full: float) - float: 多选全对得满分漏选得一半错选不得分 a, s set(ans.upper()), set(std.upper()) if a s: return full if a and a.issubset(s): return round(full * 0.5, 1) return 0.0 register(blank) def grade_blank(ans: str, std: str, full: float) - float: return full if ans.strip() in [x.strip() for x in std.split(|)] else 0.0 def grade_one(qtype, ans, std, full): fn GRADERS.get(qtype) return None if fn is None else fn(ans or , std or , full)register装饰器把题型和判分函数绑在一起grade_one遇到没注册的题型返回None走人工阅卷分支不会静默判 0。加编程题这种需要跑测试用例的题型就再注册一个code内部调沙箱执行其余流程不用动。前端对应加一个组件判断题两个按钮、填空题一个输入框、编程题一个带行号的文本框加「运行样例」。渲染层用qtype做component :is...动态映射别在模板里堆v-if否则题型一多就没法维护。改完一定要跑一遍回归拿一套十道题的小卷每种题型各两道从开考到导出成绩走完整流程。我一般会额外验证一个边界——某道题标准答案为空时系统是不是走了人工阅卷而不是判 0。6.2 从单考场扩到多考场先换数据库再谈隔离单考场用 SQLite 很舒服多考场同时开考就不行了SQLite 同一时刻只允许一个写者几个考场的交卷高峰撞在一起会出现database is locked。改造顺序建议是先换 MySQLSQLAlchemy 的连接串改一下其余代码基本不动再做数据隔离。隔离的最小改法是加一张exam_room表把exam_id、room_id、teacher_id绑起来所有查询带上exam_id条件。不建议每个考场一个库备份和统计都会变成噩梦。如果确实需要物理隔离比如不同场次的题目不能互相看到按exam_id分表比分库更实际。还要处理跨考场的时钟问题不同考场可能在不同时间开考强制收卷的后台任务必须按每个考场自己的started_at duration算不能用一个全局截止时间。这一点在单考场版本里因为只有一个开考时间很容易写成全局变量扩考场时就会集体误收卷。改造过程中我吃过一次亏换 MySQL 后忘了把answer表的唯一索引一起迁过去on_conflict_do_update在 MySQL 上语义不同结果同一道题攒了七八条作答记录导出成绩时分数翻倍。从那以后我每次换数据库都强制先跑一遍「同一道题连点十次保存、再查表确认只有一行」的验证脚本再往下写业务。这套局域网考试系统的代码量不大真正费时间的从来不是写功能而是把这类数据一致性验证做实。希望帮到你。本文还有配套的精品资源点击获取