我最近刚交付了一套基于 .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 序列化后存一份,这样即使原题库里题目被老师改掉、甚至删除,考生考试时看到的题目依然是最开始抽到的那一版,提交后的判分也能找到对应标准答案。这是很多“半成品”考试系统最常踩的坑,他们直接查题库表去判分,改题后考试成绩就乱了。
下面是几个关键表的字段设计思路,供参考:
| 表名 | 关键字段 | 用途 |
|---|---|---|
| Questions | Id, CategoryId, Type, Content, Difficulty, IsDeleted | 题库主表 |
| QuestionOptions | Id, QuestionId, SortNo, OptionText, IsCorrect | 选项表,客观题用 |
| ExamPapers | Id, Name, CreatorId | 试卷模板 |
| PaperRules | Id, PaperId, QuestionType, Difficulty, Count, Score, KnowledgePoints | 组卷规则 |
| Exams | Id, PaperId, StartTime, EndTime, DurationMinute, Status | 考试实例 |
| ExamSessions | Id, ExamId, UserId, StartTime, SubmitTime, Status | 每个考生的考试会话 |
| ExamSessionQuestions | Id, SessionId, QuestionId, OriginalContent, Score, SortNo | 考生试题快照 |
| ExamAnswers | Id, SessionQuestionId, UserAnswer | 考生作答明细 |
| ExamRecords | Id, 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-appNginx 配置要留意把请求转发给 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 报 413 | Kestrel 请求体大小限制 | 配置MaxRequestBodySize并限制上传大小 |
| 考试中 Ajax 突然 401 | Cookie 过期 | 加长过期时间、前端拦截 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 直接识别格式,老师维护题库会更轻松。还可以加入“成绩同比分析”的可视化图表,让机构负责人能直接看到每场考试的通过率变化趋势。对大多数内部使用的在线考试系统来说,稳定、可控、可审计,比功能堆叠重要得多。