简介:这套局域网考试系统含完整源码,面向需要在内网部署在线考试平台的院校教师、培训机构管理员与.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_key=True) qtype = Column(String(16), index=True) # single/multi/judge/blank/essay stem = Column(Text, nullable=False) # 题干 options = Column(Text) # JSON 字符串,选择题的选项 answer = Column(Text) # 标准答案,主观题为空 score = Column(Float, default=2.0) # 该题分值 knowledge = Column(String(32), index=True) # 知识点标签,抽题时按它分层 class Answer(Base): __tablename__ = "answer" id = Column(Integer, primary_key=True) exam_id = Column(Integer, index=True) student_id = Column(Integer, index=True) question_id = Column(Integer, index=True) content = Column(Text, default="") updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.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(title="lan-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") # 注意:静态目录必须最后挂载,且 html=True 才能让 /student 直接返回 index.html app.mount("/student", StaticFiles(directory="static/student", html=True), name="student") app.mount("/teacher", StaticFiles(directory="static/teacher", html=True), name="teacher")挂载顺序是有讲究的:/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-port=8000/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, db=Depends(get_db), stu=Depends(current_student)): for item in body.answers: stmt = insert(Answer).values( exam_id=stu.exam_id, student_id=stu.id, question_id=item.question_id, content=item.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(score=q.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。改造顺序建议是先换 MySQL(SQLAlchemy 的连接串改一下,其余代码基本不动),再做数据隔离。
隔离的最小改法是加一张exam_room表,把exam_id、room_id、teacher_id绑起来,所有查询带上exam_id条件。不建议每个考场一个库,备份和统计都会变成噩梦。如果确实需要物理隔离(比如不同场次的题目不能互相看到),按exam_id分表比分库更实际。
还要处理跨考场的时钟问题:不同考场可能在不同时间开考,强制收卷的后台任务必须按每个考场自己的started_at + duration算,不能用一个全局截止时间。这一点在单考场版本里因为只有一个开考时间,很容易写成全局变量,扩考场时就会集体误收卷。
改造过程中我吃过一次亏:换 MySQL 后忘了把answer表的唯一索引一起迁过去,on_conflict_do_update在 MySQL 上语义不同,结果同一道题攒了七八条作答记录,导出成绩时分数翻倍。从那以后我每次换数据库,都强制先跑一遍「同一道题连点十次保存、再查表确认只有一行」的验证脚本,再往下写业务。这套局域网考试系统的代码量不大,真正费时间的从来不是写功能,而是把这类数据一致性验证做实。希望帮到你。
本文还有配套的精品资源,点击获取