☰
.NET Core MVC在线考试系统源码详解:动态组卷、考试快照与自动判分设计
2026/10/21 16:22:42 网站建设 项目流程

我最近刚交付了一套基于 .NET Core MVC 的在线考试系统,开发周期不算长,但覆盖的东西很杂:题库管理、动态组卷、考生在线作答、自动判分、成绩统计,还要兼顾不同教师角色的后台权限。这套系统最初是一家培训机构找到我做的,他们要的就是一套能自己掌控的 asp.net 考试系统源码,避免被第三方平台的功能绑住手脚。做完以后我把完整源码和文档都整理交给对方,所以现在可以把它当成一套可参考的 .NET Core MVC 在线考试系统源码来说说内部设计。

做这套系统的核心诉求其实反复确认了很多遍:机构里的考试类型很杂,有章节测验、月考、模拟考;题型又包含单选、多选、判断、填空、简答;同一套题库要支持按不同班级随机抽题,难度和知识点还要能配置。这些需求看起来不复杂,落到考试管理系统里却会牵扯出一堆细节,比如随机抽题后如果题目被修改会不会影响已经开始的考试?提交试卷时网络抖动重复点击怎么办?简答题怎么安排人工复核?后面我会把这些决策过程逐个拆开,尽量把能直接抄作业的方案写清楚。

1. 项目概述与技术选型复盘

1.1 需求拆解:先想清楚谁在用这套系统

任何考试管理系统,第一件事不是写代码,而是把角色和流程理清楚。这套系统最终确定有四种角色:系统管理员、教师、考生,还有一个可以临时开的“监考老师”权限,用来查看考试进度和强制收卷。不同角色的权限边界很清晰,所以权限模型直接影响后面的表结构设计。

核心流程也不复杂:管理员或者教师先维护题库,再创建试卷模板,然后发布考试,考生在考试时间窗口内进入系统答题,提交后客观题自动出分,主观题由教师打分,最后生成成绩报表。看起来顺畅,但真正容易出问题的点在“随机组卷”和“考试快照”上。什么叫考试快照?就是考生点击“开始考试”的那一刻,系统要把这个考生拿到的题目固化下来,哪怕后来题库里的题目被修改或删除,已经开始的考试也不能受影响。这个设计我放到数据库章节细讲,因为很多人第一次做在线考试时最容易栽在这里。

1.2 技术选型:为什么是 .NET Core MVC 而不是其他方案

项目立项时也简单评估过几套技术栈,包括传统 ASP.NET WebForms、前后端分离的 Vue + Web API、以及 Python/Node 方案。最终选了 ASP.NET Core MVC,理由比较实际:团队更熟悉 C#,项目周期紧,服务端渲染的 Razor 视图可以少写一整套接口文档。而且 .NET Core 本身跨平台,后续如果要放到 Linux 服务器上跑,不需要为 Windows Server 付额外授权费。

下面是这次选型时的关键对比,也代表我对这类中后台业务系统的主流取舍:

方案优势劣势我的结论
传统 ASP.NET WebForms上手快生命周期复杂、前后端耦合重、测试困难不推荐
ASP.NET Core MVC依赖注入、中间件、控制器和视图分离需要适应 Razor 语法推荐,适合本项目
Vue/React + Web API前端体验灵活需要维护接口层,工期更长杀鸡用牛刀
Python Flask/Django开发效率高.NET 团队不熟悉团队栈不匹配

实际开发中,ASP.NET Core MVC 带来的好处会越来越明显。比如依赖注入是框架内置的,我只需要在Program.cs里按接口注册服务,业务逻辑层就不需要到处new对象,单元测试也好写。中间件管道可以自己控制认证、静态文件、异常处理的位置,不用像 WebForms 那样被页面生命周期限制。对在线考试系统来说,异常处理和认证中间件尤其重要:考试交卷接口必须有事务保护和幂等控制,这些在中间件管道里能很清晰地编排。

1.3 项目结构与分层方式

虽然用的是 MVC,我并没有把业务全部塞进 Controller。整体按表现层、应用服务层、领域服务层、基础设施层来组织。如果你是个人开发或者小组件,不一定非要搞得很“架构”,但至少要让 Controller 尽量薄。

我最终的项目结构大致是这样:

Exam.Web/ // MVC 项目 Controllers/ Views/{Home,Account,Exam,Question,Report}/ Models/ // ViewModel Services/ // IExamService, IPaperGeneratorService 等 Data/ // DbContext、实体、迁移 Repositories/ // 数据访问封装 Publish/

严格说,对一套培训机构用的考试系统来说,这个结构已经足够。如果上来就用整洁架构全套,反而会因为抽象太多拖慢进度。后面如果这套系统需要扩展成多租户或开放 API,再引入 CQRS、事件溯源也不迟。

2. 数据库设计与权限模型

2.1 核心表结构:题库、试卷、考试实例怎么拆

这一章直接决定系统能不能稳定跑起来。我把核心表拆成四组:用户权限组、题库组、考试组、作答记录组。

用户权限组依然是经典的用户-角色-用户角色关联,我直接用 ASP.NET Core Identity 自带表,增加DisplayName等字段即可。题库组包含题目分类表、题目表、选项表。这里我特意没有把选项存成单独的“正确答案”字段,而是选项表里用IsCorrect标识。好处是扩展性更好,坏处是对接自动判分时需要注意选项顺序变化。

考试组是整个系统的设计重点,我用了三层模型:试卷模板、考试、考试会话。试卷模板是配置规则,比如“单选题10道,每题2分;多选题5道,每题3分”。考试是一次具体的发布,包含考试名称、开始时间、结束时间、考试时长、参加班级。考试会话则是每个考生点开始考试时生成的独立记录,保存了他实际抽到的试题快照。

做考试快照时我加了一张“考试试题快照表”,记录ExamSessionId、QuestionId、Score、OriginalContent。OriginalContent会把题干和选项用 JSON 序列化后存一份,这样即使原题库里题目被老师改掉、甚至删除,考生考试时看到的题目依然是最开始抽到的那一版,提交后的判分也能找到对应标准答案。这是很多“半成品”考试系统最常踩的坑,他们直接查题库表去判分,改题后考试成绩就乱了。

下面是几个关键表的字段设计思路,供参考:

表名关键字段用途
QuestionsId, CategoryId, Type, Content, Difficulty, IsDeleted题库主表
QuestionOptionsId, QuestionId, SortNo, OptionText, IsCorrect选项表,客观题用
ExamPapersId, Name, CreatorId试卷模板
PaperRulesId, PaperId, QuestionType, Difficulty, Count, Score, KnowledgePoints组卷规则
ExamsId, PaperId, StartTime, EndTime, DurationMinute, Status考试实例
ExamSessionsId, ExamId, UserId, StartTime, SubmitTime, Status每个考生的考试会话
ExamSessionQuestionsId, SessionId, QuestionId, OriginalContent, Score, SortNo考生试题快照
ExamAnswersId, SessionQuestionId, UserAnswer考生作答明细
ExamRecordsId, SessionId, TotalScore, ObjectiveScore, NeedManualReview成绩汇总与人工复核标记

数据库建好后,第一版我犯过一个错:ExamAnswers没有唯一的联合索引,随后开发期间自己测试时连续提交两次,生成了两条作答记录,成绩被覆盖成乱数据。后来改成SessionQuestionId加唯一约束,从数据库层面防重复作答,这个问题才算根除。

2.2 权限模型:基于 Cookie 认证加角色

在线考试系统的权限相对固定,我直接用 ASP.NET Core 自带的.AddAuthentication加 Cookie 方案,没用复杂的 IdentityServer。项目里的注册和登录页面都用 MVC 完成,管理员首次启动时通过数据库种子脚本创建。

Program.cs里关键配置是这样:

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/Account/Login"; options.AccessDeniedPath = "/Account/Denied"; options.ExpireTimeSpan = TimeSpan.FromHours(4); options.SlidingExpiration = true; }); builder.Services.AddAuthorization(options => { options.AddPolicy("TeacherOnly", policy => policy.RequireRole("Teacher", "Admin")); });

这里的ExpireTimeSpan和SlidingExpiration对在线考试场景影响很大。考试时长如果在一个小时以上,滑动过期策略要确保考生在答题中途不会因为 Cookie 过期被强制跳回登录页。我最终把过期时间设成 4 小时,足够覆盖大多数考试场景。

Controller 层直接打特性标签就行:

[Authorize(Roles = "Teacher,Admin")] public class ExamManagementController : Controller

需要注意,Ajax 请求在 Cookie 过期时会返回 401 而不是跳转登录页。如果前端没有统一处理 401,考生会看到一次静默的请求失败,而倒计时还在继续。我后面在前端加了一个全局fetch拦截器,遇到 401 就弹框提示重新登录,已经填写的答案则通过本地缓存保留。

3. 核心功能实现与编码细节

3.1 动态组卷算法:既要随机,也要可控

组卷需求听起来很直接:根据题型、难度、知识点数量,从题库里随机抽取对应数量的题目。实现时最忌讳用“查全部题目到内存再随机筛”,当题库量过万后性能会很难看。但完全用 SQL 随机抽样又不容易做复杂的占比控制。

我最后采用“按规则逐条生成 + 题目 ID 集合去重”的方式。每条组卷规则只负责一类题目,比如“单选题、难度中等、知识点属于第三章,抽取 10 道”。处理逻辑是:

public async Task<List<ExamSessionQuestion>> GeneratePaperSnapshotAsync( Exam exam, List<PaperRule> rules, long userId) { var sessionQuestions = new List<ExamSessionQuestion>(); var usedQuestionIds = new HashSet<long>(); foreach (var rule in rules.OrderBy(r => r.SortNo)) { // 先按规则过滤出候选池 var pool = await _context.Questions .Where(q => q.Type == rule.QuestionType && q.Difficulty == rule.Difficulty && !q.IsDeleted && (rule.KnowledgePointId == null || q.CategoryId == rule.KnowledgePointId)) .Select(q => q.Id) .ToListAsync(); pool = pool.Except(usedQuestionIds).ToList(); // 候选数不足时按实际数量取,但要记录告警 var take = Math.Min(rule.Count, pool.Count); var selectedIds = pool.OrderBy(_ => Guid.NewGuid()).Take(take).ToList(); usedQuestionIds.UnionWith(selectedIds); // 加载完整题目内容并生成快照,省略细节 var fullQuestions = await _context.Questions .Include(q => q.Options) .Where(q => selectedIds.Contains(q.Id)) .ToListAsync(); foreach (var q in fullQuestions.OrderBy(q => selectedIds.IndexOf(q.Id))) { // 保存快照:包括题干、选项、正确答案 } } return sessionQuestions; }

这里有个性能取舍:OrderBy(_ => Guid.NewGuid())在 SQL Server 上会先全表扫描再排序,题库在几千道量级时还可以接受。题目量再大时建议改成“随机偏移取一页”的方式,也就是先按主键取随机位置,然后Take(1),循环多次。对培训机构项目来说,上面的写法已经够用。

组卷时的分值计算也要小心。不能简单认为“每道题分值是规则里的分值”,因为如果一个规则抽取数量不足,实际总分就变少了。我最后在生成快照时,把每道题的分值也写入快照表,并在创建考试时校验“生成的试卷总分与预设总分偏差是否超过 5%”,如果偏差明显就提示教师重新配置规则。

3.2 在线考试会话与防作弊:服务端时间才是权威

考试开始流程不能只在前端做倒计时,必须由服务端生成考试会话。简单来理解:用户点击“开始考试”后,前端向后端发送请求,后端创建ExamSession,记录开始时间为数据库服务器的时间,再返回一个包含截止时间的 JSON。前端只负责展示剩余时间,所有提交和自动保存的请求都带上SessionId,服务端每次校验当前时间是否超过截止时间。

这个设计的核心原因是,本地时间可以随便改,前端按钮可以随便点,但服务端时间不可控。下面是我在ExamController里写的一段简化逻辑:

[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Start(long examId) { var exam = await _examRepository.GetExamAsync(examId); var now = DateTimeOffset.UtcNow; if (now < exam.StartTime || now > exam.EndTime) return BadRequest(new { message = "当前不在考试时间内" }); var existing = await _examRepository.GetSessionAsync(examId, currentUserId); if (existing != null && existing.SubmitTime == null) return Json(new { sessionId = existing.Id, deadline = existing.Deadline }); var session = new ExamSession { ExamId = examId, UserId = currentUserId, StartTime = now, Deadline = now.AddMinutes(exam.DurationMinute), Status = SessionStatus.Ongoing }; // 生成考试试题快照 await _paperService.GeneratePaperSnapshotAsync(session); await _examRepository.SaveAsync(session); return Json(new { sessionId = session.Id, deadline = session.Deadline }); }

再往下是自动保存。学生答题时如果每点一个选项就立刻写库,数据库压力会很大,也可能产生大量无意义修改记录。我做了两道缓冲:前端每 20 秒把当前页答案批量发送一次,服务端判断答案是否变化后再执行更新。如果页面刷新,则根据SessionId从库里恢复答案回显,前端不用重新抽题。

防切屏检测我也做了,思路是监听visibilitychange和blur,浏览器标签页隐藏超过一定秒数就记一次“离屏记录”。累计离开三次就自动弹窗并交卷。这个方法不是绝对安全,因为总有人能绕过前端脚本,但对培训机构日常考试足够。关键点是离屏次数要记录到服务端,不要只放在 JS 变量里,否则刷新页面就清零了。

document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') { unvisibleCount++; if (unvisibleCount >= MAX_LEAVE_COUNT) { submitExam('cut-screen'); } } });

交卷接口必须做幂等控制。考生可能因为网络原因点两次提交,如果接口不防重,就会出现两条成绩记录。我在ExamRecords里加了SessionId唯一索引,提交时先查有没有已提交记录,有就直接返回已提交结果。如果这个查询和插入之间发生并发,唯一索引会在数据库层拦下重复插入,从而保证幂等。

3.3 自动评分与人工复核:客观题不能只看“选项相同”

自动评分是这套系统的体验核心。客观题包含单选、多选、判断。单选题和判断比较直接,多选题的坑在“多选漏选”是否给分。客户要求是有多选漏选不得分,只有选项完全一致才算对,我按这个规则实现。

判分时需要注意,不能简单比较两个字符串拼接后的内容,因为选项顺序不同会导致误判。比如正确答案是 A、C,用户提交 C、A,字符串比较就会认为不同。我先把用户的选项集合和正确答案集合都排序后取哈希,再进行SetEquals比较:

var correctIds = question.CorrectOptions.Select(o => o.Id).OrderBy(id => id).ToHashSet(); var userIds = answer.SelectedOptionIds.OrderBy(id => id).ToHashSet(); var isCorrect = correctIds.SetEquals(userIds);

填空题则需要考虑标准答案的容错。我把每个填空空位存储一个或多个正确答案,判分时忽略首尾空格,并对英文和中文大小写做了统一处理。简答题单独标记为“待人工评分”,不会进入自动判分逻辑。

自动评分完成后,成绩报表按考试维度汇总。我要输出“平均分、最高分、最低分、及格率、各题正确率”,其中各题正确率最有用,老师可以一眼看出哪个知识点学生没掌握。这部分用 SQL 的GROUP BY查询能完成,不需要额外报表组件:

var questionStats = await _context.ExamSessionQuestions .Where(q => q.Session.ExamId == examId) .GroupBy(q => q.QuestionId) .Select(g => new { QuestionId = g.Key, CorrectCount = g.Count(q => q.IsCorrect), TotalCount = g.Count() }) .ToListAsync();

再到 Report 视图里用 Razor 渲染表格。成绩导出我用了简单的 CSV 方案,考虑到培训机构老师一般用 WPS 或 Excel 打开,再通过导入到本地报表系统,CSV 比 Excel 组件更轻便,边角情况也少。

4. 部署上线、自测与常见问题排查

4.1 发布部署与环境配置

这套系统最终部署在 Linux 服务器上,用 Nginx 做反向代理。选择 Linux 不是因为“显得专业”,而是因为客户不想为 Windows Server 付授权费用,而且 .NET Core 本身跨平台,迁移成本很低。

发布命令很简单:

dotnet publish Exam.Web -c Release -o /var/www/exam-app

Nginx 配置要留意把请求转发给 Kestrel:

server { listen 80; server_name exam.example.local; location / { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

对应 systemd 服务文件里指定端口和运行参数:

[Service] WorkingDirectory=/var/www/exam-app ExecStart=/usr/bin/dotnet /var/www/exam-app/Exam.Web.dll --urls http://127.0.0.1:5000 Restart=always Environment=ASPNETCORE_ENVIRONMENT=Production

上线前还需在appsettings.json里配置正确的数据库连接串。我建议连接串里开启Trust Server Certificate=true,避免本地开发环境已连过测试库的人把证书问题带到生产环境。

4.2 高频问题与处理实录

开发到上线这段时间,整理出的几个高频问题对在线考试系统项目特别有代表性:

现象原因解决方案
静态资源 CSS/JS 404没调用app.UseStaticFiles()在管道中启用静态文件中间件
页面回复慢、数据库锁每答一题写一次库改批量保存,前端定时自动保存
提交显示“重复提交”考生双击提交按钮前端禁用按钮,后端按 SessionId 做幂等
考试时间比真实时间差 8 小时服务器时区和本地不一致统一使用DateTimeOffset.UtcNow,界面展示转本地时间
上传题库 Excel 报 413Kestrel 请求体大小限制配置MaxRequestBodySize并限制上传大小
考试中 Ajax 突然 401Cookie 过期加长过期时间、前端拦截 401 并提示重新登录
迁移时重复执行报错未做迁移版本管理使用 EF Core Migration,先dotnet ef migrations add再上线

其中必须再强调一下时间问题。第一版我用的是DateTime.Now存开始和截止时间,开发测试机在北京时区,上传到云服务器后整体差 8 小时。后来把所有相关字段改成DateTimeOffset.UtcNow,只在视图层用.ToLocalTime()展示,这种“服务器统一 UTC、界面转换本地时间”的方式也很适合跨地域考试。如果以后有多考点联动,统一时间戳还会更好处理。

4.3 自动化测试关注点

考试系统这种业务,手动回归成本非常高,所以必须给核心逻辑写自动化测试。我重点写了三个方向:组卷算法、判分逻辑、提交幂等。

组卷测试要覆盖数量、总分、题型占比和你配置的知识点。比如规则要求抽取 10 道单选,生成结果里不能多也不能少;规则要求最低难度为“中等”,结果里不能出现“简单”题。判分逻辑测试要覆盖多选顺序不同、选项未完整选择、大小写不同等情况。提交幂等测试则是在同一 SessionId 下连续调用两次提交接口,确认第二次返回已经提交过的结果。

[Fact] public void GeneratePaper_ShouldReturnExactQuestionCount() { var rule = new PaperRule { QuestionType = QuestionType.Single, Count = 10 }; var service = new PaperGeneratorService(_mockQuestionRepo.Object); var result = service.GeneratePaper(rule); Assert.Equal(10, result.Count); }

这类测试写起来不难,但能把重构时的破坏点快速暴露出来。我后来调整判断题分值配置时,就是靠这组测试第一时间发现某条组卷规则的总分算错了。

最后再说点实际体会

这套系统上线两个多月,最深的感触是:在线考试系统的复杂度不在技术有多深,而在细节的一致性。题库、组卷、考试快照、自动判分、成绩统计这条链路里,任何一环的数据口径不一致,最后都会让成绩可信度打折扣。我个人最满意的是“考试快照”这个决定,它把随机组卷和原题修改彻底解耦,省掉了不少后期维护时的解释成本。

如果后续要扩展,我建议把“题目批量导入”做强一点,支持从 Word 和 Excel 直接识别格式,老师维护题库会更轻松。还可以加入“成绩同比分析”的可视化图表,让机构负责人能直接看到每场考试的通过率变化趋势。对大多数内部使用的在线考试系统来说,稳定、可控、可审计,比功能堆叠重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询