局域网考试系统源码:Flask+MySQL+Vue3自动判分与内网部署实践

发布时间:2026/11/4 7:54:02

局域网考试系统源码:Flask+MySQL+Vue3自动判分与内网部署实践 简介这是一套面向开发者与教学管理人员的局域网在线考试系统源码包采用B/S架构覆盖题库管理、自动组卷、考生信息维护、考试流程控制、客观题自动批阅、成绩统计及基础防作弊等模块可用于课程练习、校内测评或二次开发学习。压缩包共84个文件、约4.08MB包含cs与csproj/sln源码及工程文件、resx/resources资源、exe/msi安装与运行文件、ini/config配置、mdb数据库以及说明文档等客户端、主考端与服务端结构较完整。已有464人学习适合需要理解考试系统实现、部署安装或定制功能的中高级开发者参考可从源码中梳理模块划分、数据库设计与组卷评分逻辑并借助安装工程快速搭建本地测试环境。1. 局域网考试系统一间机房、一台服务器、四十个考生同时开考某培训中心的机房四十台学生机讲台下面一台旧服务器整层楼没有外网。老师的要求很朴素题库能导进去试卷能组出来学生打开浏览器就能考交完卷当场出分。这套局域网考试系统就是按这个约束写出来的——不依赖云服务账号体系自己维护所有请求只走内网网段断网也照样判分。它适用的场合很集中学校机房的期中期末机考、企业内训考核、竞赛初赛的笔试替代以及任何不方便把题目放到公网上的场景。手里已经有一份源码、想照着改的开发者或者正准备从零搭一套的下面这条从架构到判分的链路可以直接对着落地。2. 架构与目录一套能被非专业人员在内网部署的前后端分层先给结构再谈代码。局域网考试系统最忌讳的是为了显得“专业”而堆中间件装完之后机房管理员搞不定出了问题没人敢动。所以整体收在三件套上Flask 提供 HTTP 接口、MySQL 存业务数据、Vue 3 做两个前端入口。全部通过 Nginx 反代到同一台机器的一个端口部署时只需要装 Python、MySQL、Nginx 三样东西。2.1 技术选型为什么是 Flask MySQL Vue 3后端选 Flask 不是因为它快而是因为它轻。机房服务器往往就是一台闲置的 i5 主机或者虚拟化出来的 Windows Server装 JVM 再调参数对现场维护的人是负担。Python 虚拟环境加一个 requirements.txt两分钟起得来。并发能力上一场考试四十到一百人请求基本都是几十毫秒的读写Gunicorn 起四个 worker 完全够用。数据库这一步别省。很多人图方便用 SQLite小班 20 人以内确实能跑但交卷判分是批量 UPDATESQLite 的写锁是库级别的一锁全库等待成绩提交就会排队。换成 MySQL 8.0行锁加连接池开考瞬间几十个人同时拉题目也不会互相堵。这个切换成本很低SQLAlchemy 只改一行连接串。前端分两个独立工程frontend-admin给老师用功能多、交互重frontend-student给考生用页面要轻因为学生机可能是十年前的老机器。考生端路由一律用 hash 模式这样 Nginx 只需要一条try_files兜底不会出现刷新页面 404 的现场事故。实时通信这块我倾向轮询三十秒拉一次考试状态不用 WebSocket。内网带宽不是瓶颈但 WebSocket 在 Nginx 反代下要单独配 Upgrade 头机房现场排查问题的时候多一个不确定因素就是多一份麻烦。2.2 目录结构从应用工厂到蓝图的边界后端用应用工厂加蓝图好处是测试的时候可以造一个干净的 app 实例不用连生产库。目录按职责切api只做参数校验和响应包装services放业务逻辑models只描述表结构。exam-lan/ ├── backend/ │ ├── app/ │ │ ├── __init__.py # create_app 应用工厂 │ │ ├── config.py # 配置类敏感项走环境变量 │ │ ├── extensions.py # db / jwt / migrate 单例 │ │ ├── models/ # 只描述字段和关系 │ │ │ ├── user.py │ │ │ ├── question.py │ │ │ ├── paper.py │ │ │ ├── exam.py │ │ │ └── attempt.py │ │ ├── api/ # 蓝图参数校验 调 service │ │ │ ├── auth.py │ │ │ ├── question.py │ │ │ ├── paper.py │ │ │ ├── exam.py │ │ │ ├── answer.py │ │ │ └── admin.py │ │ ├── services/ # 纯业务不碰 request 对象 │ │ │ ├── grading.py # 判分 │ │ │ ├── assemble.py # 组卷 │ │ │ └── anti_cheat.py # 防作弊事件聚合 │ │ └── utils/ │ ├── migrations/ │ ├── tests/ │ ├── requirements.txt │ └── wsgi.py ├── frontend-admin/ # 教师端 Vue 3 Vite ├── frontend-student/ # 考生端 Vue 3 Vitehash 路由 ├── deploy/ │ ├── nginx.conf │ ├── exam.service │ └── init.sql └── README.md这条边界要守住services里不允许出现request、g、session这些 Flask 上下文对象参数全部从函数签名进来。这么做不是为了洁癖是因为判分逻辑必须能被单元测试直接调用——一场考试判错分的代价比多写几层函数签名大得多。应用工厂长这样# backend/app/__init__.py from flask import Flask from app.config import config_map from app.extensions import db, jwt, migrate def create_app(env: str production) - Flask: app Flask(__name__) app.config.from_object(config_map[env]) # 扩展初始化集中在这里测试时可以传 testing 配置换库 db.init_app(app) jwt.init_app(app) migrate.init_app(app, db) from app.api.auth import auth_bp from app.api.question import question_bp from app.api.paper import paper_bp from app.api.exam import exam_bp from app.api.answer import answer_bp for bp in (auth_bp, question_bp, paper_bp, exam_bp, answer_bp): app.register_blueprint(bp) return appenv参数决定加载哪套配置testing配置指向内存库跑测试时不会污染真实数据。蓝图在这里集中注册而不是在各自模块里import app避免了循环引用。2.3 数据模型九张表撑起一场完整考试表设计是这套系统的骨架改起来最贵一开始就要想清楚。表名职责关键字段users教师与考生id, username, password_hash, rolequestions题库id, qtype, stem, options, answer, knowledgepapers试卷主表id, title, total_score, created_bypaper_questions试卷题目快照paper_id, question_id, sort_no, scoreexams考试场次id, paper_id, start_at, end_at, duration_minutes, statusexam_students考生名单exam_id, user_id, seat_noattempts答题会话id, exam_id, user_id, started_at, deadline_at, statusanswers作答明细attempt_id, question_id, content, is_correct, scoreevents防作弊日志attempt_id, event_type, payload, created_at有两个容易被忽略的设计点。第一paper_questions必须冗余一份score因为题库里的分值可能被老师改试卷组好之后分值就要冻结。第二answers表要冗余question_score否则判分的时候还得回表查试卷快照一次交卷几十条 SQL性能白白浪费。answers上必须建(attempt_id, question_id)唯一索引。这条索引不是为了查询快是为了让重复提交变成 UPDATE 而不是 INSERT从数据库层面兜住幂等。2.4 部署拓扑一台服务器怎么扛住一个机房部署走最朴素的路径Nginx 监听 80 端口把/api转发给 Gunicorn把/admin和/exam指向两个静态目录MySQL 装在同一台机器上。服务器固定内网 IP比如192.168.10.20考生机只要能 ping 通这个地址就能考试不需要配 DNS。deploy/nginx.conf里最关键的是静态资源缓存策略。index.html必须设Cache-Control: no-cache带 hash 的 JS/CSS 可以设一年。考生端尤其要注意这一点否则老师更新了前端学生机拿到的还是旧缓存页面行为和后台对不上现场会非常混乱。Gunicorn 的 worker 数按 CPU 核数乘二加一超时时间设 60 秒因为交卷判分那一瞬间可能稍慢。systemd 的exam.service里加上Restartalways进程挂了自动拉起来机房没人守着的时候这个配置能救场。3. 后端核心链路从开考请求到自动判分的四段代码一条完整的考试链路是考生点开始 → 服务端校验名额和时间窗 → 创建答题会话并下发脱敏题目 → 考生逐题保存 → 交卷或超时触发判分。四段代码分别对应这四步每一段都有明确的失败返回不能含糊。3.1 开考接口名单、时间窗与题目脱敏这个接口是整个系统里最容易被刷的入口。不校验名单任何知道地址的人都能进来不校验时间窗学生提前拿到题目下发题目时把答案一起带出去F12 一看就全明白了。# backend/app/api/exam.py from datetime import datetime, timedelta from flask import Blueprint, request, g from sqlalchemy import select from app.extensions import db from app.models import Exam, ExamStudent, Attempt, PaperQuestion, Question from app.utils.response import ok, fail from app.utils.auth import student_required exam_bp Blueprint(exam, __name__, url_prefix/api/exam) exam_bp.post(/int:exam_id/start) student_required def start_exam(exam_id: int): exam db.session.get(Exam, exam_id) if exam is None or exam.status ! published: return fail(考试不存在或未发布, code404) now datetime.now() # 时间窗一律以服务端时钟为准客户端时间不可信 if now exam.start_at: return fail(考试尚未开始, code403) if now exam.end_at: return fail(考试已结束, code403) # 名单校验没被安排的考生拿不到题 enrolled db.session.scalar( select(ExamStudent).where( ExamStudent.exam_id exam_id, ExamStudent.user_id g.user_id, ) ) if enrolled is None: return fail(你不在本场考试名单内, code403) attempt db.session.scalar( select(Attempt).where( Attempt.exam_id exam_id, Attempt.user_id g.user_id, ) ) if attempt and attempt.status submitted: return fail(本场考试已交卷, code409) if attempt is None: # deadline 只在首次进入时算一次并落库后续请求不再重算 deadline min(now timedelta(minutesexam.duration_minutes), exam.end_at) attempt Attempt( exam_idexam_id, user_idg.user_id, paper_idexam.paper_id, started_atnow, deadline_atdeadline, statusanswering, client_iprequest.remote_addr, ) db.session.add(attempt) db.session.flush() # 拿到自增 id下面组装响应要用 db.session.commit() return ok(_build_paper_payload(attempt)) def _build_paper_payload(attempt: Attempt): 下发题目时剔除 answer 字段答案只留在服务端。 rows db.session.execute( select(PaperQuestion, Question) .join(Question, Question.id PaperQuestion.question_id) .where(PaperQuestion.paper_id attempt.paper_id) .order_by(PaperQuestion.sort_no) ).all() questions [ { id: q.id, type: q.qtype, stem: q.stem, options: q.options, score: pq.score, # 用试卷快照分值不用题库分值 sort_no: pq.sort_no, } for pq, q in rows ] return { attempt_id: attempt.id, paper_name: attempt.paper.title, server_time: datetime.now().isoformat(timespecseconds), deadline_at: attempt.deadline_at.isoformat(timespecseconds), questions: questions, }几个参数值得单独说。deadline min(now 时长, exam.end_at)是因为考试场次本身有结束时间如果考生在结束前十分钟进场那他只能考十分钟不能顺延。db.session.flush()是为了在提交事务前拿到attempt.id响应里需要它来标识后续所有请求属于同一个会话。_build_paper_payload里刻意不返回answer也不返回is_correct这类字段。考生端只拿到题干、选项、分值和顺序。多选和判断题的选项结构统一成[{key: A, text: ...}]前端用同一套渲染组件减少出错面。3.2 作答提交幂等写入与断线续考考生作答是高频写操作一场四十分钟的考试一个人可能提交几十次。这里最怕两件事网络抖动导致同一题重复提交写入两条记录考生刷新页面之后进度丢了。# backend/app/api/answer.py from datetime import datetime from flask import Blueprint, request, g from sqlalchemy import select from sqlalchemy.exc import IntegrityError from app.extensions import db from app.models import Attempt, Answer, PaperQuestion from app.utils.response import ok, fail from app.utils.auth import student_required answer_bp Blueprint(answer, __name__, url_prefix/api/answer) answer_bp.put(/attempt/int:attempt_id) student_required def save_answer(attempt_id: int): body request.get_json(silentTrue) or {} question_id body.get(question_id) content body.get(content) # 单选是 A多选是 [A,C]主观题是字符串 if question_id is None: return fail(缺少 question_id, code400) attempt db.session.get(Attempt, attempt_id) if attempt is None or attempt.user_id ! g.user_id: return fail(答题会话不存在, code404) if attempt.status ! answering: return fail(已交卷不能再修改, code409) if datetime.now() attempt.deadline_at: # 超时后的写请求直接拒绝交卷交给兜底逻辑处理 return fail(答题时间已结束, code410) pq db.session.scalar( select(PaperQuestion).where( PaperQuestion.paper_id attempt.paper_id, PaperQuestion.question_id question_id, ) ) if pq is None: return fail(题目不在本张试卷内, code400) answer db.session.scalar( select(Answer).where( Answer.attempt_id attempt_id, Answer.question_id question_id, ) ) if answer is None: answer Answer( attempt_idattempt_id, question_idquestion_id, question_scorepq.score, # 冗余分值判分时不再回表 ) db.session.add(answer) answer.content content answer.updated_at datetime.now() try: db.session.commit() except IntegrityError: # 并发下两个请求同时插入唯一索引会让其中一个失败回滚后重试即可 db.session.rollback() return fail(提交冲突请重试, code409) return ok({saved_at: answer.updated_at.isoformat(timespecseconds)}) answer_bp.get(/attempt/int:attempt_id) student_required def load_progress(attempt_id: int): 刷新页面或断线重连后拉回已作答内容。 attempt db.session.get(Attempt, attempt_id) if attempt is None or attempt.user_id ! g.user_id: return fail(答题会话不存在, code404) rows db.session.scalars( select(Answer).where(Answer.attempt_id attempt_id) ).all() return ok({ status: attempt.status, deadline_at: attempt.deadline_at.isoformat(timespecseconds), answers: [{question_id: r.question_id, content: r.content} for r in rows], })PUT而不是POST语义上就是“把这道题的作答设成某个值”天然幂等。数据库唯一索引(attempt_id, question_id)是第二道保险两个请求同时到达时后插入的那个会撞IntegrityError回滚后前端重试一次就落到 UPDATE 分支。第 410 状态码的用法值得注意。超时后拒绝写入但不是说考生就被踢出去了前端收到 410 应该立刻触发交卷流程把已经保存的内容提交上去判分。用 410 而不是 403是为了让前端能区分“没权限”和“时间到了”这两种完全不同的处理路径。load_progress是断线续考的关键。学生机在机房环境里浏览器崩溃、网线松动、误关页面都很常见重新登录之后必须能把已答内容拉回来否则这一场考试就废了。这个接口顺带返回status和deadline_at前端据此决定是继续答题还是跳转到交卷页。3.3 交卷判分客观题自动、主观题进待阅队列判分逻辑放在services/grading.py理由是它必须能脱离 HTTP 被测试。判分规则本身要和老师约定清楚多选是全对才得分还是漏选给一半判断题错选是否倒扣这些规则写死在代码里就是埋雷最好做成可配置。# backend/app/services/grading.py from datetime import datetime from decimal import Decimal from sqlalchemy import select from app.extensions import db from app.models import Answer, Question, Attempt OBJECTIVE_TYPES {single, multiple, judge} def _judge_multiple(correct_keys: set, chosen_keys: set, partial: bool) - Decimal: 多选判分partial 为真时按比例给分否则全对才得分。 if not chosen_keys: return Decimal(0) if chosen_keys correct_keys: return Decimal(1) if partial and chosen_keys.issubset(correct_keys): return Decimal(len(chosen_keys)) / Decimal(len(correct_keys)) return Decimal(0) def grade_attempt(attempt: Attempt) - dict: answers db.session.scalars( select(Answer).where(Answer.attempt_id attempt.id) ).all() qids [a.question_id for a in answers] questions { q.id: q for q in db.session.scalars( select(Question).where(Question.id.in_(qids)) ) } if qids else {} objective_total Decimal(0) pending 0 for ans in answers: q questions.get(ans.question_id) if q is None: continue if q.qtype not in OBJECTIVE_TYPES: ans.is_correct None # None 表示待人工阅卷 pending 1 continue if q.qtype multiple: ratio _judge_multiple( set(q.answer.get(keys, [])), set(ans.content or []), partialq.partial_credit, ) ans.is_correct ratio Decimal(1) ans.score (ans.question_score * ratio).quantize(Decimal(0.1)) else: ans.is_correct (ans.content q.answer.get(key)) ans.score ans.question_score if ans.is_correct else Decimal(0) objective_total ans.score attempt.objective_score objective_total attempt.status submitted attempt.submitted_at datetime.now() attempt.need_manual pending 0 db.session.commit() return {objective_score: str(objective_total), pending: pending}ans.question_score来自前面冗余的分值字段不用回表查试卷。partial_credit是题目上的配置项默认关闭因为大多数机考场景老师更希望规则简单可解释。主观题不走自动判分is_correct置为Noneneed_manual置真教师端会看到一条“待阅卷”的任务。这里别图省事给主观题判 0 分否则考生查到的成绩是错的投诉起来很难解释。分数类型统一用Decimal不要用float。多个分数累加时浮点误差会让总分出现 0.30000000000000004 这种数字考生看到会觉得系统有问题。3.4 超时兜底惰性判定替代定时任务自动交卷有两条路定时任务扫全表或者每次读接口时惰性判定。定时任务在机房环境里问题很多——服务器重启、进程被 kill、任务调度器漏跑都会导致考生超时了但状态还挂在“答题中”。惰性判定简单可靠任何一次读请求都会把过期的会话结算掉。# backend/app/services/grading.py from datetime import datetime def settle_if_expired(attempt: Attempt) - Attempt: 惰性判超时读接口调用它比定时任务少一个失败点。 if attempt.status answering and datetime.now() attempt.deadline_at: grade_attempt(attempt) attempt.status submitted attempt.auto_submitted True db.session.commit() return attempt所有涉及Attempt的查询接口入口第一行都调settle_if_expired()。教师端看考试进度、学生端拉续考数据、后台导成绩都一样。这样即使服务器在考试中途重启考生重新登录的那一刻超时判定和判分也会立刻完成。还有一个场景要覆盖考生点了交卷按钮但请求在路上丢了服务端没收到。前端应该做一次“交卷确认”的重试服务端接口收到重复交卷请求时直接返回当前状态而不是再判一遍分。状态机的判断status submitted就返回已有成绩是这里的核心不能省。4. 时间与状态同步服务端权威倒计时怎么落地局域网考试系统最容易翻车的地方不是判分是时间和状态。学生机的时间不一定是准的中考生的第一反应往往是“改一下系统时间看看能不能多考十分钟”。倒计时如果在前端用new Date()自己算这个防线等于没有。4.1 时间下发的三个字段开考接口返回三个跟时间有关的字段server_time、deadline_at、以及隐含在题目里的started_at。这不是冗余是让前端能算出时钟偏差。server_time服务端当前时间ISO 格式精确到秒。deadline_at这个考生本次考试的最晚交卷时间开考时一次算死并落库。attempt_id会话标识后续所有请求带着它状态的唯一来源。前端拿到这两个时间后先算一次skew Date.now() - new Date(server_time).getTime()把自己机器的时间偏差记下来之后所有算时间的地方都加上这个偏移。考生把系统时间往后调两小时本地的Date.now()变了但skew是相对服务端算的倒计时依然按真实的剩余时间走。服务端这边要保证deadline_at只算一次。如果每次请求都重算考生就能通过反复刷新把截止时间往后推。这也是为什么start_exam里要先查attempt再决定是否创建创建时把deadline_at写死。4.2 前端倒计时差值法而不是本地时间考生端的倒计时组件是这套系统里被改得最多的一块因为它直接影响考试体验。核心逻辑就三件事用服务端时间校准、按差值递减、归零只提示不直接交卷。// frontend-student/src/composables/useCountdown.js import { ref, onUnmounted } from vue export function useCountdown() { const remain ref(0) // 剩余秒数供页面渲染 let timer null let deadlineTs 0 // 校准后的截止时间戳 const sync (serverTime, deadlineAt) { // 时钟偏移 本地时间 - 服务端时间 const skew Date.now() - new Date(serverTime).getTime() deadlineTs new Date(deadlineAt).getTime() skew remain.value Math.max(0, Math.floor((deadlineTs - Date.now()) / 1000)) } const start (serverTime, deadlineAt) { sync(serverTime, deadlineAt) clearInterval(timer) timer setInterval(() { remain.value Math.max(0, remain.value - 1) if (remain.value 0) { clearInterval(timer) // 归零只做提示最终是否交卷以服务端 410 响应为准 window.dispatchEvent(new CustomEvent(exam:timeup)) } }, 1000) } const stop () clearInterval(timer) onUnmounted(stop) return { remain, start, stop, sync } }sync单独暴露出来是因为长时间考试里页面可能被挂起切标签页、锁屏setInterval会被浏览器节流实际递减的秒数会比真实时间慢。我的做法是每三分钟调一次load_progress用返回的deadline_at和服务端时间重新sync一次把误差抹平。倒计时归零的时候不要直接调交卷接口。一是网络可能已经断了二是服务端时间才是最终裁判。正确的做法是提示“时间到正在自动交卷”然后调交卷接口如果服务端返回 410 或者会话已结算直接展示成绩页。4.3 防作弊事件上报与阈值局域网环境防不住人但能留下痕迹。考生端的监听不复杂visibilitychange切屏、copy复制、contextmenu右键、窗口失焦这几类事件打包上报。上报本身要节流否则一个考生来回切几次页面就刷出几十条记录。// frontend-student/src/utils/reporter.js const EVENT_MAP { visibility: tab_hidden, blur: window_blur, copy: copy_text, } let queue [] let flushing false export function reportEvent(type, payload {}) { queue.push({ type, payload, at: Date.now() }) // 十秒内的同类事件只保留第一条避免刷屏 if (queue.length 20) queue queue.slice(-20) scheduleFlush() } function scheduleFlush() { if (flushing) return flushing true setTimeout(async () { const batch queue.splice(0, queue.length) if (batch.length) { await fetch(/api/event/batch, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ events: batch }), }).catch(() {}) // 上报失败不阻塞答题 } flushing false }, 10000) }服务端的阈值判定放在services/anti_cheat.py。切屏累计超过 5 次、复制超过 3 次或者同一attempt出现两个不同 IP都会写一条告警记录给教师端。阈值不要写死做成event_type limit的配置表不同场次可以调。注意这些事件只做记录和提示不自动终止考试。理由是误判成本太高——学生误触右键、浏览器弹窗导致失焦都可能触发。让老师在后台看汇总比系统自作主张强。5. 常见问题排查五种在真实机房里翻车的现场以下都是这类系统上线后反复出现的问题每条按现象、原因、解决写。5.1 倒计时对不上考生提前被交卷现象考生端显示还有十几分钟点保存却提示时间已结束或者反过来倒计时已经归零但还能继续提交。原因前端用本地Date.now()算截止时间考生机时间不准机房机器常年不校时差几分钟很正常或者服务端在多个请求里重复计算deadline_at导致时间漂移。解决deadline_at开考时一次算死落库所有接口只读不重算前端统一用skew校准每三分钟拉一次状态重新校准。查线上问题时先看attempts.deadline_at和started_at的差值是否等于考试时长对不上就是重复计算了。5.2 组卷后改题历史成绩全变现象上周考完的试卷老师这周在题库里修正了一道题的答案考生查历史成绩发现得分变了。原因answers判分时回表读questions.answer而题库被就地修改历史答案跟着变。这是典型的没有做快照。解决组卷时把题目的题干、选项、答案、分值全部复制到paper_questions的 JSON 快照字段里判分只读快照。改动题库不影响已生成的试卷。已经上线又没快照的系统短期解法是把试卷状态锁死published之后禁止关联修改题目。5.3 双击交卷导致重复判分现象考生点了两次交卷成绩出现两条记录或者客观题分数被加了两次。原因交卷接口没有幂等保护POST /submit来一次判一次分attempt.objective_score被累加。解决交卷接口第一行判断attempt.status已经是submitted就直接返回已有成绩判分前用SELECT ... FOR UPDATE锁住这一行防止两个请求同时进入。数据库层面给attempts加状态约束别让业务代码裸奔。5.4 开考瞬间数据库连接打满现象考试开始的头两分钟部分考生页面一直转圈后台日志里全是连接超时。原因四十个人同时点开始每个请求都要建会话、查试卷、查题目一个请求几条 SQL连接池pool_size设成了默认的 5请求全在排队。解决pool_size调到 20max_overflow调到 10pool_pre_pingTrue防止连接被 MySQL 主动断开。更彻底的做法是开考前五分钟由教师端触发一次“预生成会话”把attempts提前建好考生点开始时只做查询和下发。数据库层面给exam_students和paper_questions的查询字段建好索引。5.5 前端更新后学生机拿到旧资源现象老师修了一个答题页的 bug自己电脑上是好的学生机上还是旧界面甚至接口参数对不上报错。原因index.html被 Nginx 缓存了学生机拿到的还是上一次构建的入口文件引用的 JS 文件名是旧的。解决Nginx 里给index.html单独设add_header Cache-Control no-cache, must-revalidate带 hash 的静态资源设长缓存。构建产物用vite build默认的 hash 文件名别手动改成固定名。上线流程里加一步发布后随机挑两台学生机强刷验证别只在开发机上测。6. 二次开发把判分器做成插件新增题型不用改主流程这套系统最常被提的改造需求是加题型。一开始只有单选、多选、判断后面一定会有人要填空、连线、编程题。如果在grade_attempt里写一长串if qtype ...每加一种题型就得改判分主流程改一次测一次风险全压在核心链路上。我一般会把判分抽成注册表每种题型对应一个判分函数函数只负责算这一题的得分比例主流程负责遍历、累加、落库。# backend/app/services/graders.py from decimal import Decimal GRADERS {} def register(qtype: str): 装饰器注册判分器新增题型只加函数不改主流程。 def wrapper(fn): GRADERS[qtype] fn return fn return wrapper register(single) def grade_single(question, answer) - Decimal: return Decimal(1) if answer.content question.answer.get(key) else Decimal(0) register(multiple) def grade_multiple(question, answer) - Decimal: chosen set(answer.content or []) correct set(question.answer.get(keys, [])) if not chosen: return Decimal(0) if chosen correct: return Decimal(1) # 漏选按比例给分错选直接 0规则由题目配置决定 if question.partial_credit and chosen.issubset(correct): return Decimal(len(chosen)) / Decimal(len(correct)) return Decimal(0) register(fill) def grade_fill(question, answer) - Decimal: 填空题逐空比对忽略首尾空格和大小写。 blanks question.answer.get(blanks, []) got answer.content or [] if not blanks: return Decimal(0) hit sum( 1 for a, b in zip(got, blanks) if str(a).strip().lower() str(b).strip().lower() ) return Decimal(hit) / Decimal(len(blanks))主流程改成查表调用# backend/app/services/grading.py 片段 from app.services.graders import GRADERS ratio GRADERS[q.qtype](q, ans) if q.qtype in GRADERS else None if ratio is None: ans.is_correct None # 没有判分器的题型进人工队列 pending 1 continue ans.score (ans.question_score * ratio).quantize(Decimal(0.1)) ans.is_correct ratio Decimal(1)这样加一种“连线题”只需要在graders.py里写一个register(match)的函数把比对逻辑写清楚主流程一行不动。没注册的题型自动进人工阅卷队列不会因为忘了改代码而判 0 分。改造完的验证方法很直接给services/graders.py里每个判分器写单元测试用固定的题目和作答断言分数比例。0 分、满分、部分分三种情况都覆盖到。跑一遍pytest tests/test_graders.py全绿再合并。血泪经验是判分规则的改动一定要和老师在考试前对齐别在考试中途热更新。有一次我在一场考试进行中发了个版本把多选题的漏选给分规则从“给比例”改成“不给分”结果同一场考试前后交卷的人分数口径不一样最后只能人工重算。从那以后我每次发版都强制走一遍“确认当前无进行中考试”的检查宁可等到晚上十点。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/11/4 7:54:02

ToClaw轻量化部署工具:5分钟实现全流程自动化部署

1. 项目背景与核心价值 OpenClaw作为一款开源的自动化部署工具链,近期在DevOps领域获得了广泛关注。它通过模块化设计解决了传统部署流程中的三个痛点:环境依赖复杂、配置项分散、多平台适配成本高。而ToClaw作为OpenClaw的轻量化封装版本,将…

2026/11/4 7:54:01

OpenClaw工具链部署与自动化运维实践

1. 项目概述:OpenClaw工具链解析在自动化运维领域,OpenClaw作为轻量级任务调度工具链近年来获得广泛关注。这套工具的核心价值在于将复杂的任务编排流程简化为声明式配置,特别适合需要快速构建自动化流水线的中小团队。ToClaw作为其官方提供的…

2026/11/4 7:49:00

烟气废气管道工程:从工况参数到施工调试与故障排查

1. 烟气废气管道工程的第一步不是画图,是把工况参数问到底干这行久了,最怕听到一句话:图纸上就这么画的。烟气废气管道工程看着只是把一根管子从车间接到排气筒,实际上从管径、材质、壁厚、坡度、支架间距到补偿方式,每…

2026/11/4 8:49:16

Montserrat字体选型与Web落地:字重、可变字体与性能优化

1. Montserrat 到底是什么,凭什么被用烂了如果你最近在接手品牌官网、SaaS 后台或者电商详情页,翻开源码或者设计稿,十有八九会撞见 Montserrat 这个名字。它几乎成了“现代几何无衬线”这个类别的默认答案:免费商用、九档字重、还…

2026/11/4 8:49:16

小米AI语音克隆与多模态增强技术解析

1. 小米AI技术国际舞台新突破 当我们在手机上调出小爱同学时,可能不会想到这背后是一整套复杂的AI技术体系。最近在信号处理领域的"奥斯卡"——ICASSP 2026会议上,小米AI实验室的7项研究成果被收录,这个数字在国内科技企业中相当亮…

2026/11/4 8:49:16

AI批量短视频自动化:任务单、自动混剪与发布验收

简介:这份压缩包围绕AI短视频自动化生产与分发,面向内容创作者、品牌运营人员及具备Python基础的开发者,针对批量生成、自动混剪与多平台发布中效率低、流程分散的问题,提供一套可运行的工程参考。包内共121个文件,整体…

2026/11/4 8:49:16

从API调用到RAG部署:大模型应用开发实战踩坑复盘

1. 从调通第一个接口到把RAG跑上线,中间到底隔着什么 很多人对大模型应用开发的想象是这样的:调个API,把返回结果渲染到页面上,收工。我一开始也是这么想的,直到真正把一个带知识库的问答系统从零推到线上,…

2026/11/4 8:49:16

Node.js构建C语言自学平台:代码评测与毕设实战

1. 项目背景与核心价值去年帮学弟调试毕业设计时,发现计算机专业学生普遍存在C语言学习痛点:课堂案例过于理论化,线上编译器又缺乏针对性指导。这个基于Node.js的C语言自学平台正是为解决这些问题而生,它整合了代码评测、错题本、…

2026/11/4 8:44:14

AI视频抖动全解析:从成因到算法去抖实战

1. 从“画面发抖”说起:AI视频里那个让人抓狂的抖动到底从哪来第一次看到自己用AI生成的视频时,我盯着屏幕愣了好几秒——画面里的人物轮廓像被风吹过的水面,边缘一圈一圈地荡,背景的线条时不时抽搐一下,整体看下来就是…

2026/11/3 7:47:06

面向复杂光照与角度的宠物面部识别鲁棒性增强策略

在智慧养宠时代,基于视觉的宠物身份识别技术正逐步从实验室走向家庭应用。然而,与配合度高的人类不同,宠物是典型的“非合作生物识别对象”——它们不会主动配合摄像头调整姿态,更无法控制光照环境。实际应用中,我们常…

2026/11/3 14:24:53

SSM电子书店源码全解析:结构、原理、部署与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/11/4 0:03:20

AI辅助技术栈评估:提升研发决策效率60%

1. 项目背景与核心价值技术决策一直是研发团队面临的重大挑战。随着技术生态的快速演进,技术栈的选择、维护和迭代变得愈发复杂。我们团队在过去两年里,先后经历了三次重大技术栈迁移,每次都需要投入大量人力进行技术评估和风险分析。直到去年…

2026/11/4 0:03:20

从生成Token到直接决策:Jev如何重塑AI Agent工程实践

1. 从"生成文字"到"拍板决策":Jev 到底在解决什么问题大多数人第一次接触大模型,脑子里建立的模型是"输入问题、输出文字"。你问它今天天气怎么样,它给你一段描述;你让它写个周报,它给你…

2026/11/4 0:03:20

32G统一内存跑大模型:内存账、TPS实测与端云分流

想在一台 32G 统一内存的小主机上把大模型跑成"日常可用",这件事我前后折腾了小半年。标题里说的这台 Mac mini M6,是我们这次讨论的目标平台,具体到手规格以实物为准;但下面这套推算方法和实测思路,对任何统…

2026/11/3 7:47:06

面向复杂光照与角度的宠物面部识别鲁棒性增强策略

在智慧养宠时代,基于视觉的宠物身份识别技术正逐步从实验室走向家庭应用。然而,与配合度高的人类不同,宠物是典型的“非合作生物识别对象”——它们不会主动配合摄像头调整姿态,更无法控制光照环境。实际应用中,我们常…

2026/11/3 14:24:53

SSM电子书店源码全解析:结构、原理、部署与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑