
简介这是一份基于ASP.NET与SQL Server的在线考试系统完整源码面向教育机构、企业培训部门以及正在学习Web开发的程序员可用于搭建支持用户管理、题库维护、试卷生成、在线作答与自动评分的考试平台。资源共240个文件压缩包约13.6MB其中含65个cs后端逻辑、22个aspx页面、45个dll运行库、9个js与7个css前端资源并附带数据库备份文件与账号密码说明。从内容预览可见系统已覆盖用户注册登录、试题与试卷管理、在线考试、成绩查看、系统配置等典型模块。已有107人学习下载。借助这份源码开发者可掌握用户认证、试题维护、自动评分、成绩统计等关键实现理解Visual Studio 2019与SQL Server 2022在Web项目中的协作方式适合用于毕业设计、课程实训或二次开发。1. 在线考试系统用 ASP.NET 做到底值不值先把技术选型讲清楚在线考试系统在 ASP.NET 技术栈里的存在感一直不低原因很现实很多高校、培训机构和企业的内部考核系统原本就跑在 Windows Server SQL Server 上团队里最熟的技术就是 ASP.NET选它意味着能用最小成本把出题、组卷、答题、判分、成绩导出这一整条链路收拾干净。我从零搭过一套给某公司内部技能认证用的考试系统从建项目到撑住两百人同时在线答题前后不到三周这中间踩过的坑比预想的多。这篇文章不打算给你演示一个玩具 Demo而是把一个能真实上线、能扛住并发提交、能处理断网重连的 ASP.NET 在线考试系统拆开讲清楚。适合谁看适合手头有 ASP.NET 基础、准备在现有 Windows 环境里快速落地在线考试功能的人也适合正在技术选型、想知道这条路有没有暗坑的人。先说结论这套方案值得做但前提是你在数据模型和考试会话设计上别偷懒。2. 从建项目到出第一张试卷ASP.NET 在线考试的最小可运行骨架2.1 项目结构与数据模型先行做在线考试系统最忌讳一上来就写页面。页面是结果数据模型才是地基。我通常用 ASP.NET Core MVC 作为主框架Razor 视图直接渲染配合 EF Core 操作数据库。如果你所在环境还是旧的 ASP.NET Framework 4.x思路完全一样只是 EF 版本换成 6.0 而已。先看项目结构ExamSystem/ ├── Controllers/ │ ├── AccountController.cs │ ├── ExamController.cs │ ├── PaperController.cs │ └── AdminController.cs ├── Models/ │ ├── User.cs │ ├── ExamPaper.cs │ ├── Question.cs │ ├── ExamSession.cs │ └── AnswerRecord.cs ├── ViewModels/ ├── Views/ └── Data/ └── AppDbContext.cs核心表就五张用户表、试卷表、题目表、考试会话表、答题记录表。考试会话表和答题记录表是这套系统的灵魂很多半路翻车的项目就是把它们省了导致断线重连、重复提交、成绩对不上这类问题全冒出来。下面直接给出 EF Core 的实体定义这是整个系统的数据契约// Models/ExamPaper.cs public class ExamPaper { public int Id { get; set; } public string Title { get; set; } string.Empty; public int DurationMinutes { get; set; } public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } public ListQuestion Questions { get; set; } new(); } // Models/Question.cs public class Question { public int Id { get; set; } public int ExamPaperId { get; set; } public string Content { get; set; } string.Empty; public string Type { get; set; } single; // single/multiple/truefalse public string OptionsJson { get; set; } []; public string Answer { get; set; } string.Empty; // 单选存A多选存A,B,C public int Score { get; set; } 5; } // Models/ExamSession.cs public class ExamSession { public int Id { get; set; } public int UserId { get; set; } public int ExamPaperId { get; set; } public DateTime StartTime { get; set; } public DateTime? SubmitTime { get; set; } public int RemainingSeconds { get; set; } public string Status { get; set; } ongoing; // ongoing/submitted/expired } // Models/AnswerRecord.cs public class AnswerRecord { public int Id { get; set; } public int ExamSessionId { get; set; } public int QuestionId { get; set; } public string UserAnswer { get; set; } string.Empty; public bool IsCorrect { get; set; } }注意两点。第一题目选项用 JSON 字符串存不要单独建一个选项表因为选项永远跟着题目走拆表只会增加联表查询成本而且不同题型单选、多选、判断的选项结构完全一致一个OptionsJson字段通吃。第二ExamSession里的RemainingSeconds是给倒计时用的它不是从数据库实时读的而是在每次心跳请求时由服务器计算后回写这个设计后面讲计时时会详细说。2.2 用 EF Core 建表并初始化种子数据数据模型定好后建表非常直接。我习惯在Program.cs里用EnsureCreated做开发环境的快速建表但生产环境请务必改成正规迁移。下面是开发阶段的写法// Program.cs 片段 var builder WebApplication.CreateBuilder(args); builder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(Default))); var app builder.Build(); using (var scope app.Services.CreateScope()) { var db scope.ServiceProvider.GetRequiredServiceAppDbContext(); db.Database.EnsureCreated(); SeedData(db); // 初始化管理账号与示例试卷 }SeedData里做的事很简单插入一个管理员账号插入一张包含 10 道单选题、5 道多选题的示例试卷。注意种子数据里的密码不能是明文我用的是 ASP.NET Core 自带的PasswordHasherUser。这是标准做法别自己发明加密算法。public static void SeedData(AppDbContext db) { if (db.Users.Any()) return; var hasher new PasswordHasherUser(); var admin new User { UserName admin, Role admin, PasswordHash hasher.HashPassword(null!, Admin123) }; db.Users.Add(admin); var paper new ExamPaper { Title ASP.NET 基础认证模拟卷, DurationMinutes 60, StartTime DateTime.Now.AddDays(-1), EndTime DateTime.Now.AddDays(30), Questions new ListQuestion { new Question { Content ASP.NET Core 默认使用的依赖注入容器支持哪种生命周期, Type single, OptionsJson [\Transient\,\Scoped\,\Singleton\,\以上都支持\], Answer D, Score 10 }, // 其余题目略 } }; db.ExamPapers.Add(paper); db.SaveChanges(); }这段代码的逻辑先检查有没有用户避免重复播种。管理员密码用PasswordHasher加密后台登录验证时用同一个 hasher 校验。试卷起止时间设成过去到未来一段区间这样种子数据创建后立刻可以被考试入口查到。OptionsJson里就是一个字符串数组的 JSON 文本前端解析后渲染成 A、B、C、D 选项。2.3 试卷创建与题目管理的核心页面逻辑管理后台的试卷创建页面不需要花哨核心是一个表单 一个动态增删题目的 JavaScript 块。我用一个PaperViewModel来承载表单提交数据// ViewModels/PaperViewModel.cs public class PaperViewModel { public int Id { get; set; } public string Title { get; set; } string.Empty; public int DurationMinutes { get; set; } public ListQuestionInput Questions { get; set; } new(); } public class QuestionInput { public string Content { get; set; } string.Empty; public string Type { get; set; } single; public Liststring Options { get; set; } new(); public string Answer { get; set; } string.Empty; public int Score { get; set; } }控制器接收这个 ViewModel 后把选项列表序列化成 JSON然后把题目批量插入数据库。这里的批量操作是常见性能坑如果有 50 道题千万不要循环里每次SaveChanges()而是先AddRange再一次性SaveChanges。注意 EF Core 的AddRange在题目数量达到几百道时依然能保持较快的插入速度。如果题目真的上千了就改用SqlBulkCopy但对考试系统来说单张试卷几百道已经是极限AddRange足够。3. 考试与阅卷的核心流程答题、计时、自动判分是怎么串起来的3.1 考试会话与计时不要用 Session 裸奔很多初版考试系统直接用Session[ExamId]存考试状态一旦用户清掉 Cookie 或者服务器回收 Session整个考试状态就丢了考生只能重考这在实际运营中会被骂死。我的做法是让ExamSession成为数据库里的真实实体所有计时和答题状态都围绕它转。考生点击“开始考试”时控制器做三件事创建ExamSession写入数据库、把考试开始时间写入服务器内存、返回考试页面。前端页面通过定时器每隔 30 秒向后端发送一个心跳请求后端根据ExamSession.StartTime 试卷时长计算出剩余秒数再回写给前端。这样即使浏览器崩溃后重新打开考生也能通过一个未完成的ExamSession恢复考试。核心的计时接口代码// ExamController.cs [HttpPost] public IActionResult Heartbeat(int sessionId) { var session _db.ExamSessions.Find(sessionId); if (session null) return NotFound(); // 只有状态为 ongoing 的会话才更新剩余时间 if (session.Status ! ongoing) return Ok(new { remainingSeconds 0, status session.Status }); var paper _db.ExamPapers.Find(session.ExamPaperId); var elapsed (DateTime.Now - session.StartTime).TotalSeconds; var remaining paper.DurationMinutes * 60 - (int)elapsed; if (remaining 0) { session.Status expired; session.SubmitTime DateTime.Now; session.RemainingSeconds 0; _db.SaveChanges(); return Ok(new { remainingSeconds 0, status expired }); } session.RemainingSeconds remaining; _db.SaveChanges(); return Ok(new { remainingSeconds remaining, status ongoing }); }这个接口有两个容易被忽略的点。第一计算时间差必须用服务器时间不能信任前端发来的剩余秒数否则考生改本地时间就能无限答题。第二当剩余时间小于等于 0 时直接把会话状态改成expired这相当于在服务器端强制收卷前端随后会收到status expired并自动跳转到结果页。这样即使考生一直挂在那里不点提交系统也不会让他白拿时间。3.2 提交答卷与自动判分的实现细节自动判分是考试系统的重头戏。题目类型主要分三种单选、多选、判断。判分逻辑可以统一写成下面这样// ExamController.cs [HttpPost] public IActionResult Submit(ListAnswerInput answers, int sessionId) { var session _db.ExamSessions .Include(s s.AnswerRecords) .FirstOrDefault(s s.Id sessionId); if (session null || session.Status submitted) return BadRequest(重复提交); var questions _db.Questions .Where(q q.ExamPaperId session.ExamPaperId) .ToList(); // 先清空旧的答题记录防止重复提交导致重复判分 _db.AnswerRecords.RemoveRange(session.AnswerRecords); int totalScore 0; foreach (var q in questions) { var userAnswer answers.FirstOrDefault(a a.QuestionId q.Id)?.UserAnswer; if (string.IsNullOrEmpty(userAnswer)) { // 未作答按错误处理不扣分这里按 0 分算 _db.AnswerRecords.Add(new AnswerRecord { ExamSessionId session.Id, QuestionId q.Id, UserAnswer , IsCorrect false }); continue; } bool isCorrect CheckAnswer(q, userAnswer); if (isCorrect) totalScore q.Score; _db.AnswerRecords.Add(new AnswerRecord { ExamSessionId session.Id, QuestionId q.Id, UserAnswer userAnswer, IsCorrect isCorrect }); } session.Status submitted; session.SubmitTime DateTime.Now; _db.SaveChanges(); return Ok(new { totalScore totalScore }); }这里的CheckAnswer方法按题型处理private bool CheckAnswer(Question q, string userAnswer) { switch (q.Type) { case single: case truefalse: return string.Equals(q.Answer.Trim(), userAnswer.Trim(), StringComparison.OrdinalIgnoreCase); case multiple: var right q.Answer.Split(,, StringSplitOptions.RemoveEmptyEntries) .Select(a a.Trim().ToUpper()).OrderBy(a a).ToArray(); var user userAnswer.Split(,, StringSplitOptions.RemoveEmptyEntries) .Select(a a.Trim().ToUpper()).OrderBy(a a).ToArray(); return string.Join(,, right) string.Join(,, user); default: return false; } }多选判分用的是最严格的“完全一致才算对”策略。这个策略在真实考试里经常被质疑所以我在管理后台留了一个开关允许设置“漏选得一半分”。混合题型而且规则不一的时候判分逻辑会变成一张策略表但我不建议一开始就做得很复杂先跑通“严格判分”再根据业务方反馈逐步加宽松规则。提交接口里我专门做了重复提交拦截如果session.Status submitted直接返回BadRequest(重复提交)。这是防重的基本功但很多人会忘记在事务层面做唯一约束。真正稳妥的做法是给ExamSession表加一个数据库层面的唯一索引比如保证同一个用户同一张试卷只有一个submitted状态的记录或者在SubmitTime上做并发控制。生产环境我会把所有判分操作放进一个事务里确保答题记录和会话状态一起生效。3.3 并发提交与防作弊的基础手段在线考试系统最怕的就是两个情况一个是考生同时开了两个浏览器标签页最后提交时互相覆盖另一个是考试结束那一刻几百人同时点提交数据库连接被打满。第一种情况我用ExamSession表加锁来解决。提交接口进来时先执行一条带行锁的查询SELECT ... FROM ExamSessions WITH (UPDLOCK, ROWLOCK) WHERE Id sessionId然后在同一个事务里做状态判断和写入。这样第二个标签页提交时会发现状态已经是submitted直接拒掉。EF Core 中可以通过BeginTransaction加IsolationLevel.Serializable来做到类似效果但用 SQL 锁更直观。第二种情况我在Program.cs里给数据库连接池设置了最大连接数并且把提交接口的响应时间压到最低。判分逻辑无非是字符串比较和整数相加性能瓶颈只在数据库写入。一个更实际的优化是把答题记录批量插入而不是一条条Add。下面的代码演示了如何用AddRange攒一批再保存var records new ListAnswerRecord(); foreach (var q in questions) { records.Add(new AnswerRecord { ... }); } _db.AnswerRecords.AddRange(records); _db.SaveChanges();这样一千人同时交卷每个人的SaveChanges也只有一次数据库压力会小很多。如果你们公司有 DBA还可以让 TA 在AnswerRecord表上建一个以ExamSessionId为索引的聚集索引提交查询会快不少。防作弊方面除了题目顺序随机化我还做了两个基础措施考试页面禁用右键复制、每个考生的题目选项顺序打乱。选项乱序的做法很简单查询题目后在服务器端对OptionsJson反序列化并随机打乱再重新序列化发给前端。注意打乱必须在服务器做不能在前端做否则懂技术的考生看前端源码就能还原原顺序。判断题和填空题不存在选项顺序问题所以乱序只对选择题生效。4. 在线考试系统的 ASP.NET 避坑指南5 个让人翻车的实际问题4.1 现象考试过程中点击“上一题”突然丢失已选答案原因前端答题状态是保存在 JavaScript 内存里的页面一刷新就没了而此前没有把答案持久化到数据库。解决每答完一题就通过心跳接口把这题的答案同步到服务器。我后来把心跳接口扩展成“心跳 答案草稿”二合一前端每 15 秒把当前页面的题目答案数组 POST 到后端后端存到独立的AnswerDraft表。这样刷新页面时从AnswerDraft恢复该考生的答案。注意草稿表只做临时恢复用最终判分以提交时重新解析的AnswerRecord为准避免草稿数据和正式记录混淆。4.2 现象考生用两个浏览器登录后登录的把先登录的踢下线原因默认的 Cookie 认证是按用户身份来的不是按设备来的。两个浏览器登录的是同一个账号ASP.NET Core 默认会为每个浏览器签发不同的 Cookie但用户体系不区分设备于是产生会话覆盖。解决在ExamSession表里增加DeviceToken字段前端生成一个 GUID 存 localStorage每次心跳和提交都带上这个 Token。后端在创建考试会话时检查同一用户同一试卷如果已经存在一个Status ongoing且DeviceToken不同的会话就提示“检测到其他设备是否继续”确认后把旧会话标记为expired。这个机制同时能防止有人用两个设备同时答题。4.3 现象提交时提示“未找到答题记录”成绩是 0原因最常见的翻车现场是前端把答案存在localStorage里提交时localStorage被清空或者浏览器缓存策略不同导致 POST 上去的答案数组是空的。解决答案必须从前端 DOM 或者内存中实时获取不能依赖localStorage。我在前端封装了一个collectAnswers()方法直接从页面上所有input和textarea控件读取值然后组装成 JSON。提交前还要做一次前端的空数组检查如果发现一道题都没收集到弹窗提示而不是直接提交。血泪经验永远不要用sessionStorage或localStorage作为答题的持久化容器那只适合存设备标识符。4.4 现象服务器重启后考生正在进行的考试全部超时原因ExamSession的RemainingSeconds是每次心跳时由服务器计算并存储的但StartTime还是原来的。服务器重启后如果代码里用的是DateTime.Now - session.StartTime计算剩余时间这个差值是正确的问题出在很多项目把StartTime存成了服务器本地时间重启后时区不同导致计算偏差。解决所有时间字段统一存 UTC前端展示时再转本地时间。具体写法是DateTime.UtcNow赋给StartTime和SubmitTime心跳接口计算时也用DateTime.UtcNow。另外服务器重启不会影响未提交的会话因为剩余时间是按StartTime实时计算的重启后第一次心跳就会重新算好。如果发现重启后考生时间变少了检查是不是把StartTime改成了DateTime.Now而没改成 UTC。4.5 现象大量考生同时交卷时数据库死锁原因所有提交操作都在同一个事务里更新ExamSession和插入AnswerRecord如果更新顺序不一致比如一个事务先查试卷再更新会话另一个事务先更新会话再查试卷就容易造成死锁。解决统一事务内的更新顺序。我采取的策略是任何事务里都要先锁定会话行再做其他操作。在使用 EF Core 时先执行一条SELECT ... WHERE Id id WITH (UPDLOCK, ROWLOCK)这个语句会锁住该行直到事务结束后面的AddRange和状态更新都在锁内完成。此外把事务隔离级别设置为ReadCommitted避免范围锁降低死锁概率。如果还是出现死锁就在数据库里把AnswerRecord表的主键索引改成非聚集索引减少锁竞争。5. 让系统敢上线安全、性能与数据回收的进阶做法5.1 表单防伪造与越权访问在线考试系统的接口一旦上线就会被扫描工具和各种脚本盯上。我做了三层防护。第一层是 ASP.NET Core 自带的ValidateAntiForgeryToken在提交试卷和心跳接口的 POST 请求里强制校验防伪标记前端用Html.AntiForgeryToken()生成隐藏字段。第二层是角色授权所有管理接口都标注[Authorize(Roles admin)]考生接口[Authorize(Roles student)]。第三层是越权校验考生只能操作自己的考试会话在拿到sessionId后先查询UserId是否等于当前登录用户的 ID不等就返回 403。下面是一个典型的越权检查var userId int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)); var session _db.ExamSessions.Find(sessionId); if (session null || session.UserId ! userId) { return Forbid(); }不要相信前端传来的任何用户 ID永远从User身份声明里取。这个习惯能挡住九成的越权攻击。5.2 大题量和多媒体题目的性能优化当一张试卷有几百道题尤其是包含音频、视频或图片时一次把整张卷子传给浏览器会变得很慢。我用的办法是分页加载但考试场景比较特殊考生不能因为加载下一页而丢失已答状态。常见做法是首次进入考试时只加载前 50 道题的文本内容图片和音频等多媒体资源不随页面返回而是用懒加载的img src/Exam/GetMedia?fileIdxxx地址替换占位符浏览器滚动到对应位置时才发起请求。这样初始页面体积可以控制在几百 KB 以内。后端还需要给多媒体资源加上必要的缓存头否则每次滚动都会触发新的请求。我一般这样设置响应头Response.Headers.Add(Cache-Control, public,max-age86400);这个方法对图片和音频非常有效考生第二次打开试卷时资源直接从浏览器缓存读取不会反复占用服务器带宽。如果一张卷子最大不超过 200 道题这个方案完全够用没必要上 CDN。5.3 考试中断恢复一个容易被忽略的后悔药在线考试必然遇到网络闪断、电脑蓝屏、浏览器崩溃。真正专业的做法是提供一个“恢复考试”入口。我在登录后的考生主页上会查出该考生所有状态为ongoing且SubmitTime为空的考试会话。考生点“继续考试”时后端做一次时间校验如果超过试卷时长就强制标记为expired并展示“已超时”否则将草稿答案回传前端跳转到上次答题的页码。这里的关键是草稿恢复的数据结构。我用的AnswerDraft表非常简单SessionId、QuestionId、AnswerJson这三个字段其中AnswerJson存的是多选题的数组或填空题的文本。恢复时按QuestionId批量查询组一个Dictionaryint, string前端渲染时逐个回填控件。这套机制要给前端留一个明确的“已恢复”状态提示避免考生以为答案丢了。最后分享一个习惯我每次给考试系统加新功能前会先写一个针对ExamSession状态机的测试覆盖ongoing - submitted、ongoing - expired、submitted - 重复提交三条主路径。这个测试在后来每次重构时都帮了大忙因为考试系统的状态流转是最容易出逻辑漏洞的地方而逻辑漏洞直接表现为考生成绩异常属于事故级别的问题。希望这些踩坑经验能帮到你让你的在线考试系统少走一圈弯路。本文还有配套的精品资源点击获取