简介一套基于 ASP.NET 与 SQL Server 2022 的在线考试系统完整源码面向教育机构、企业培训及 ASP.NET 学习者。ZIP 压缩包内共 240 个文件核心包含 65 个 .cs 后端逻辑文件、22 个 .aspx 页面文件以及 .dll 程序集、.js/CSS 前端资源、数据库备份 .bak、配置文件等整体约 13.6MB结构清晰便于研读。系统覆盖用户管理、试题库、试卷设置、在线考试、成绩统计等典型模块实现登录认证、自动评分、数据存储等关键环节。已有 107 人学习适合作为课程设计、毕业设计参考或 Web 开发实战练习尤其有助于理解 ASP.NET 事件驱动模型与 SQL Server 的协同工作方式。数据库备份文件与账号密码文档可辅助快速搭建本地运行环境直接查看各功能页面的实现细节。1. 在线考试系统 ASP.NET为什么说核心难点不在“考试”而在“流程”你负责的某个管理系统里要被塞进一个在线考试模块打开招聘网站满屏都是“在线考试系统 ASP.NET”的简历项目真到自己动手时才发现出题、答题、判分这些功能两天就能写完真正让人翻车的是并发交卷、倒计时续考、试卷还原这些边缘流程。一个考生断网重连、一个老师同时批阅两百份试卷、一次考试中途服务器重启任何一个环节没兜住系统就会在考试当天给你上演大型翻车现场。本文从技术选型到表结构设计从自动组卷到防作弊思路完整拆解一套基于 ASP.NET 的在线考试系统该怎么做重点讲清那些代码之外、但直接决定考试能不能顺利跑完的坑。这套方案适合两类读者一类是要在 .NET 技术栈内交付完整考试模块的开发者另一类是手里有 legacy 项目、想在不重构的前提下把考试系统做稳的维护者。读完你会知道每种选型的代价也能照着把核心功能一步步跑通。2. 在线考试系统 ASP.NET 的架构与数据建模先定边界再写代码2.1 选型依据Web Forms、MVC 还是 Razor Pages在线考试系统 ASP.NET 最常踩的第一个坑就是选型混乱。很多人从网上下到一个用 Web Forms 写的旧项目直接在原工程上改需求结果后续加考试批次、对接用户体系时处处受限。我的建议是先看被集成的宿主环境如果现有系统是 ASP.NET MVC 5考试模块就用 MVC 区域隔离如果是从零开始的新项目首选 ASP.NET Core Razor Pages它比 MVC 轻页面和 handler 一一对应非常适合考试这种页面职责清晰的场景。从维护成本看Core 版本在依赖注入、配置管理、跨平台部署上的优势是 Framework 版比不了的尤其是你需要对接微信小程序或移动端考试入口时Web API 和 Razor Pages 共用一个 host 非常方便。Framework 4.x 不是不能做但 Session 状态、并发下的锁机制、内存回收表现都要你手动补丁这些成本算下来往往比迁移 Core 还高。2.2 核心表结构设计试卷、试题、考试记录三组表缺一不可在线考试系统的数据模型建议拆成三组题库域试题表、题型表、知识点表、试卷域试卷表、试卷题目关系表、考试域考试批次表、考生试卷表、答题明细表。很多半成品项目把试题和试卷混在同一张表里导致后续要支持随机抽卷、A/B 卷时必须大幅重构这就是命名上是“考试系统”、实际是“题库管理系统”的根本原因。考试域里最重要的表是ExamSession考试批次和UserExam考生答卷前者定义了某场考试的时间窗口、时长、允许进入次数后者记录每个考生抽到的试卷快照和当前作答状态。这里的核心经验是考生实际答题的试卷必须做物理快照不能只存试卷 ID 再实时联查——如果老师在中途修改了试卷题目标题或分值已经在考试中的考生界面和判分结果会被悄悄改动这是考试公平性的严重事故。2.3 试卷快照的存储技巧用 JSON 字段而不是多张关联表试卷快照的存储方式推荐直接在UserExam表里加一个PaperSnapshotJson字段把题目内容、选项、分值、顺序全部序列化存储。不要为了所谓的“规范化”把快照拆成题目表、选项表、答题明细表再做多表联查考试会话的生命周期很明确从考生点击开始考试到交卷判分结束快照数据只读为主JSON 存储既保证了一致性又极大简化了恢复逻辑。public class UserExam { public int Id { get; set; } public int ExamSessionId { get; set; } public int UserId { get; set; } public DateTime? StartTime { get; set; } public DateTime? SubmitTime { get; set; } public string PaperSnapshotJson { get; set; } public int Score { get; set; } public bool IsSubmitted { get; set; } }这段模型把每份独立试卷的题目固化在PaperSnapshotJson中判分时只读这个字段就不会受到题库后续变更的干扰。注意UserExam里必须存StartTime和SubmitTime两个时间点因为后端的计时永远以服务器时间为准不能用浏览器端传来的时间做超时判罚。实际项目中我看到不少人把交卷时间存在前端考生把系统时间改了就拿到了额外答题时间这种低级问题完全可以在表结构设计阶段规避。2.4 在线考试系统 ASP.NET 部署边界单机还是集群如果考试系统要支撑校内期末这种级别比如同时在线一千人左右单台服务器配合 SQL Server 完全够用不需要引入 Redis 和消息队列。核心瓶颈在交卷瞬间的写并发而非答题过程中的读请求所以架构上优先保证数据库连接池充足、写入事务短平快即可。如果目标是社会化的认证考试数万人同时在线那就要把试卷快照放进分布式缓存、判分拆成异步任务但这是另一套成本量级的方案不在本文范围内。我的经验是先按单机 数据库锁的模型上线等真正出现性能瓶颈再引入缓存层避免提前过度设计导致两周才能交付的模块拖成一个月。3. 从题库到组卷在线考试系统的资源层实现3.1 题库管理的 CRUD 与题目类型抽象题库管理是整个系统里唯一“增删改查”能直接照搬的部分但题型抽象值得提前设计。常见做法是定义一个QuestionType枚举包括单选、多选、判断、填空、简答然后题目表用QuestionJson存储题干、选项、答案、解析等非结构化内容这样后续增加“匹配题”“排序题”时不需要改表结构。public enum QuestionType { SingleChoice 1, MultipleChoice 2, TrueFalse 3, FillBlank 4, ShortAnswer 5 }当题目类型扩展时只需在枚举中追加新值并用新的 JSON 结构承载对应字段避免为每种题型建一张表。这里的参数含义是SingleChoice和TrueFalse可以机器判分ShortAnswer必须走人工评阅流程所以题型枚举同时决定了后续判分模块的分支逻辑。题库的导入导出功能也建议在第一版就做出来。一个老师手里有几百道 Word 格式的题让人工录入几乎必然出错常见的做法是支持标准化 Excel 模板导入模板里固定列题型、题干、选项A-D、正确答案、解析、知识点。导入时做逐行校验返回错误行号和建议这个功能看着不起眼但在系统推广时决定老师愿不愿意用。3.2 自动组卷的两种策略随机抽题与按知识点配额自动组卷是考试系统区别于普通问卷工具的核心能力。最简单可靠的是“按知识点配额随机抽题”老师配置每套试卷的总题数、各题型比例、各知识点题数系统从题库中随机选取满足条件的题目打乱选项顺序后生成试卷快照。public ListQuestion GeneratePaper(PaperRule rule) { var selected new ListQuestion(); foreach (var item in rule.KnowledgePointQuotas) { var pool _db.Questions .Where(q q.KnowledgePoint item.KnowledgePoint q.Type item.QuestionType) .OrderBy(q Guid.NewGuid()) .Take(item.Count) .ToList(); selected.AddRange(pool); } return selected; }这里的OrderBy(Guid.NewGuid())是 SQL Server 下实现随机排序的常用手段数据量在十万以内性能没有问题Take(item.Count)必须小于等于题库中该知识点可用题目数否则整个组卷事务要回滚并提示老师补充题库。我见过某系统在组卷时忽略了这个校验结果考生抽到的试卷缺了一整道大题整场考试作废这就是组卷策略里必须做前置数量校验的原因。随机组卷还有一种更复杂的“难度系数线性规划”方案按期望平均分和区分度约束来选最优题集。这个方案对题目难度数值的准确性要求极高如果题库里的难度标签本来就是拍脑袋填的线性规划算出来的试卷反而不如简单随机可靠。我的建议是第一版先做配额随机上线后根据实际考试数据修正题库难度标签再考虑是否升级组卷算法。3.3 在线考试系统 ASP.NET 的缓存边界题库与试卷谁该进缓存题库数据的读频率高、写频率低适合用内存缓存。但试卷快照不能只依赖缓存必须落库因为考试一旦开始无论缓存还是 Session 都可能因进程回收而丢失。参考做法是题目基础数据放IMemoryCache组卷结果直接写UserExam.PaperSnapshotJson后续答题页每次都从数据库读快照。var cacheKey $question_pool_{knowledgePointId}; var pool _cache.GetOrCreateAsync(cacheKey, async entry { entry.AbsoluteExpirationRelativeToNow TimeSpan.FromMinutes(30); return await _db.Questions.Where(q q.KnowledgePointId knowledgePointId).ToListAsync(); });这段代码把某知识点的题目列表缓存三十分钟老师的题库修改最长延迟三十分钟才在组卷中生效对内部考试系统来说这个延迟在可接受范围。如果要做到“老师改完立刻生效”保留一个手动清缓存的管理按钮即可不必要引入 Redis 级别的失效订阅机制。4. 核心考试流程落地按状态机推进答题、保存、交卷与判分4.1 在线考试系统 ASP.NET 的状态模型把考试过程收敛成有限状态机考试流程最容易混乱的原因是我们总在写“下一步做什么”而不是“当前是什么状态”。建议为UserExam定义五个状态未开始、答题中、已交卷、已评阅、已作废。所有接口的第一行逻辑都是判断当前状态是否允许该操作而不是先执行再报错。例如只有“答题中”才允许提交答案“未开始”状态下调用交卷接口无论参数怎么传都该返回 409。状态机的好处是让边界情况显式化。断线重连、超时自动交卷、老师手动收卷、管理员强制作废这些操作本质都是状态迁移各自触发条件不同但迁移目标明确。实际开发中只要画清楚状态图后续新增一个“取消考试”场景时你只需要加一个从任意状态到“已作废”的迁移而不会破坏原有流程。4.2 在线答题接口设计局部保存而不是整卷提交在线答题页面的最大误区是让考生做完一题就整卷提交一次。如果题目里有简答题考生刚输入到一半的内容会随着“整卷提交”被清空这个体验问题在低版本浏览器上表现得尤其明显。正确做法是保存单题答案到前端缓存然后每 15 到 30 秒把自上次保存以来变更过的题目提交到后端也就是增量保存模型。[HttpPost] public async TaskIActionResult SaveAnswer(int userExamId, int questionId, string answerJson) { var exam await _examService.GetExamForUpdate(userExamId); if (exam.Status ! ExamStatus.InProgress) return Conflict(考试已结束无法保存答案); if (DateTime.UtcNow exam.EndTime) { await _examService.AutoSubmit(exam.Id); return Conflict(考试超时系统已自动交卷); } await _answerRepo.SaveOrUpdate(userExamId, questionId, answerJson); return Ok(); }接口的核心参数是userExamId和questionId的组合answerJson统一承载单选字符串、多选数组、文本内容避免为每种题型写一个接口。值得注意的是保存答案前必须校验时间是否超过EndTime——后端把DateTime.UtcNow作为唯一可信来源如果超时则直接触发自动交卷不返回成功让前端把最后一次作答存进本地。有人会问增量保存时网络开销会不会太大实测下来每 15 秒一个轻量 POST 请求对服务器压力很小远比交卷瞬间一次性写 50 道题答案的并发峰值安全得多。4.3 交卷与判分同一事务里写完答题记录再计算结果交卷是考试系统最核心的写操作。考生点交卷时如果系统把“标记已交卷”和“判分”拆成两步执行中间进程崩溃就会造成“考生已交卷但成绩为空”的数据不一致。推荐做法是在一个数据库事务中完成状态更新、明细写入、客观题自动判分三件事事务提交后才返回交卷成功。public async Task(bool success, int score) SubmitExamAsync(int userExamId) { using var tx await _db.Database.BeginTransactionAsync(); var exam await _db.UserExams.FindAsync(userExamId); if (exam.Status ! ExamStatus.InProgress) return (false, 0); exam.Status ExamStatus.Submitted; exam.SubmitTime DateTime.UtcNow; var answers await _db.Answers.Where(a a.UserExamId userExamId).ToListAsync(); exam.Score _scoringService.CalculateObjectiveScore(exam.PaperSnapshotJson, answers); await _db.SaveChangesAsync(); await tx.CommitAsync(); return (true, exam.Score); }这里的CalculateObjectiveScore只处理单选、多选、判断这三种题型简答题标记为“待评阅”。事务将交卷和判分合并为原子操作杜绝了一种线上事故考生明明点过交卷但由于数据库写入失败页面报错考生又点了一次交卷系统判了两次分或返回“该考试已交卷”让考生误以为成绩丢失。多选和判断的判分规则要提前跟出题老师确认好多选是少选得一半分还是零分判断对错是否倒扣分这些规则应该集中在一个配置项里而不是写死在判分代码中。4.4 自动交卷的兜底机制后台任务与页面主动轮询双保险超时自动交卷不能只靠前端倒计时触发。考生可能关闭了浏览器标签页、电脑休眠、网络断开前端计时器已经不在了但考试时间仍在流逝。在线考试系统必须有一个后台定时任务扫描所有“答题中”且超过EndTime的UserExam强制将它们置为已交卷并判分。常见的做法是IHostedService每分钟执行一次批量扫描时间精度可以接受。public class AutoSubmitWorker : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var timer new PeriodicTimer(TimeSpan.FromMinutes(1)); while (await timer.WaitForNextTickAsync(stoppingToken)) { await _examService.AutoSubmitExpiredExams(); } } }这个后台扫描与前端倒计时形成了双保险正常情况前端倒计时归零后主动调交卷接口异常情况后台任务在一分钟内也会兜底处理。注意自动交卷的判分逻辑与手动交卷完全一致必须复用同一套SubmitExamAsync方法避免两套代码维护两种行为。防止BackgroundService在高并发场景下扫描全表造成性能抖动添加索引是必须的(Status, EndTime)复合索引让扫描可以定位到最小数据集。5. 在线考试系统 ASP.NET 的避坑指南5 个真实线上的惨痛教训5.1 现象考生刷新页面后试卷题目顺序变了考生答到一半按 F5 刷新重新加载出来的题目顺序和选项顺序全变了考生直接懵了。原因是每次刷新都调用了新的组卷接口而组卷接口用了随机排序逻辑。这个是典型的把“组卷”和“取卷”混为一谈。解决刷新时后端只读取UserExam.PaperSnapshotJson这道题目顺序在首次组卷时已经固定后续任何请求都不允许调用随机组卷逻辑。测试用例要覆盖“刷新十次题目顺序完全一致”。5.2 现象交卷后成绩显示为 0但考生确实答了题判分为 0 的常见原因有两个一是前端整卷提交时JSON结构错位答案是 1-30 题的但接口按 1-50 题遍历没答的题默认空值另一个是answerJson字段在提交时被前端做了 HTML 编码数据库里存的是一堆\u003c转义字符后端解析后匹配不上任何正确答案。解决前端在提交前对答案对象做一次深度校验确保数组长度等于试题数后端判分前先解析answerJson如果出现格式异常立即记录日志并返回明确错误而不是静默按 0 分处理。上线前用不同浏览器各测一遍全流程尤其注意搜狗、360 这类双核浏览器的兼容模式。5.3 现象考试中途服务器重启考生重新进入后提示“考试不存在”UserExam数据写库了但考生每次进入考试页的入口是先查 Redis 里的考试会话Redis 没持久化导致重启后会话丢失。很多考试系统为了提高访问速度把用户会话状态放缓存却忽略了状态丢失后考生无法继续答题的后果这是典型的架构设计没有考虑到运维场景。解决考生进入考试页时以后端数据库中的UserExam状态为准Redis 只做热数据加速不做事实的唯一来源。重启后如果发现 Redis 中没有会话就用userId examSessionId回源数据库重新构建会话。5.4 现象数据库连接池耗尽交卷按钮转圈 30 秒然后报错考试结束时所有人都挤在同一分钟交卷连接池默认上限一百每个交卷事务要短则几十毫秒长则数百毫秒瞬间并发一上来池子就空了。加上SaveAnswer的轮询接口也占连接交卷请求排队时间被拖得很长。这个事故在模拟考试时只有几十个用户测不出来一到正式考试几百人同时交卷就爆了。解决把答案保存与交卷分开处理交卷走独立数据库连接池调整连接池上限到合理值并添加重试策略比如三次指数退避。更重要的是压测时不能只看“考了多少人”要用脚本模拟“所有人在最后五分钟同时保存答案并交卷”的场景这个压力峰值才是系统真实的上线门槛。5.5 现象教师端看不到主观题待评阅列表客观题自动判分没问题但简答题在交卷后没有进入教师评阅队列因为UserExam状态变成了“已交卷”后评阅列表的查询条件是“状态待评阅”两者对不上。这涉及到业务状态和流程状态的区分客观题判分已完成意味着成绩可见但主观题未评阅意味着考试未真正结束。解决去掉单一的Status字段用IsSubmittedIsObjectiveScoredIsSubjectiveScoredIsPublished四个布尔值来描述考试完成度。查询待评阅列表时用IsSubmitted true AND IsSubjectiveScored false作为条件而不是依赖一个模糊的状态字符串。这个修改会对现有代码有较大影响但能在源头杜绝后续凡是涉及多阶段流程的需求都来改状态枚举的恶性循环。6. 进阶用前端缓存与定时上传让答案文件在极端网络下不丢考试系统里最容易被低估的风险是“答题内容本身丢失”。尤其在校园网或考场这种共享无线网络环境中一个瞬间断连就能让考生前端内存里的答案清空而我们平常的“增量保存”技术在断网时无计可施。这里要聊一个最通用也最可靠的兜底习惯前端把答案同时写入localStorage恢复网络后再补传。具体做法是答题页每次修改答案时除了调SaveAnswer接口同时写入浏览器的localStoragekey 是userExamId_questionIdvalue 是答案 JSON。断网瞬间的答案就算没有发给服务器也没有丢失。下次进入页面或网络恢复时启动一个重传队列把本地未同步的答案逐条补传到服务器。function saveAnswerLocally(userExamId, questionId, answer) { localStorage.setItem(exam_${userExamId}_${questionId}, JSON.stringify(answer)); } async function syncLocalAnswers(userExamId) { const keys Object.keys(localStorage).filter(k k.startsWith(exam_${userExamId}_)); for (const key of keys) { const questionId key.split(_)[2]; const answer JSON.parse(localStorage.getItem(key)); try { await fetch(/exam/answer/${userExamId}/${questionId}, { method: POST, body: JSON.stringify(answer) }); localStorage.removeItem(key); } catch (e) { // 网络仍未恢复等待下一次轮询 } } }这套方案弥补了服务端可能丢数据的最后一块短板。我自己习惯在所有移动端优先的考试页面里默认启用这个机制因为它几乎不增加代码复杂度但价值极大考生断网十分钟恢复后所有答案弹出来“已恢复未保存内容”当场避免一场投诉。在考场环境里学生设备类型五花八门前端缓存 定时上传是目前成本最低的后悔药。做在线考试系统不要幻想网络永远稳定把每一次设备断电、每一次网络抖动当成必然会发生的事件来设计系统才真正能用。希望帮到你。本文还有配套的精品资源点击获取