简介:这份资源是一份基于ASP.NET的在线考试系统设计与实现文档,面向计算机相关专业的毕业设计学生、课程设计开发者以及需要搭建B/S架构考试平台的入门与中级技术人员。文档围绕在线考试的实际需求展开,完整梳理了系统从研究背景、可行性分析到总体设计的全过程,涵盖考生与管理员两类角色的功能划分:管理员负责考生信息、科目、试题、试卷与成绩管理,考生完成在线答题,交卷后客观题自动计分、主观题由管理员评分,最终成绩为两者之和。同时介绍了ASP.NET、C#、Visual Studio 2010与SQL Server 2008等关键技术的选用理由与开发环境配置,并给出功能模块设计、业务流程与数据库访问思路。资源包内共1个docx文档,约1.45MB,结构完整、排版规范,可直接作为论文写作模板与系统设计参考。目前已有228人学习,适合需要快速理解考试系统整体架构、理清设计思路并着手实现的读者参考借鉴。
1. 从 200 人同时交卷说起:ASP.NET 在线考试系统真正难在哪
一场 200 人的期末机考,监考老师看到的是最后 30 秒全班一起点交卷。那一刻暴露的问题很典型:数据库出现行锁等待,有学生连点三次提交,同一份卷子被写进两条成绩记录;有人中途断网重连,最后 5 道题的答案丢了;还有人把浏览器时间改慢,倒计时从 60 秒变回 10 分钟。基于 ASP.NET 的在线考试系统,难点从来不是「把题干渲染到浏览器上」,而是组卷的可控随机、答题会话的断点续存、交卷瞬间的并发与幂等、以及全程服务端说了算的防作弊。
这套系统通常拆成五块:题库与知识点维护、按规则自动组卷、答题会话与倒计时、交卷判分、成绩与错题分析。技术栈上,ASP.NET 这一支既能用 WebForms 快速拖出后台管理页,也能用 ASP.NET Core MVC 走前后端分离、跑在 Linux 容器里。读这篇的人大概有两类:一类是正在做课程设计、毕业设计,需要一套能跑通、能答辩的系统;一类是要把企业内部培训考试搬到线上,关心并发、防作弊和上线后的排错。后面从表结构一路写到压测,代码可以直接抄,参数按自己题库规模改。
2. ASP.NET 在线考试系统的选型与数据表设计
选型和表结构是这套系统里最容易返工的两处。页面框架定错了,答题页会随着题量膨胀到几百 KB;表结构设计成「一题一行答案」,交卷时就是几百次 INSERT 的并发风暴。
2.1 WebForms、MVC 与 ASP.NET Core 怎么选
| 维度 | WebForms | ASP.NET MVC 5 | ASP.NET Core MVC |
|---|---|---|---|
| 页面状态载体 | ViewState 随控件膨胀 | 无 | 无 |
| 100 题答题页体积 | 常见 200KB~500KB | 几十 KB | 几十 KB |
| 后台管理开发速度 | 拖控件最快 | 中 | 中 |
| 跨平台部署 | 仅 Windows/IIS | 仅 Windows/IIS | Linux 容器可跑 |
| 后台定时任务 | 另建 Windows 服务 | 同左 | IHostedService 内置 |
| 长尾维护成本 | 老项目为主 | 中 | 低 |
我一般这样定:新项目直接上 ASP.NET Core MVC + Razor,答题页用轻量 AJAX 提交,判分和组卷都放服务端;已经在校内的老 WebForms 系统,只维护后台题库管理,答题页务必把 ViewState 关掉,否则一台 2 核 4G 的服务器扛不住 100 人同时翻页。面试常问「ASP.NET MVC 里还有没有 ViewState」,答案是没有 ViewState 容器,但 Session、TempData 依然存在,Session 的读写锁在高并发答题页上反而是新的瓶颈,第 5 章会展开。
https://learn.microsoft.com/zh-cn/aspnet/core/ 上的官方文档可以对照着读,重点是 MVC 的模型绑定和过滤器两节,判分、限流、日志基本都是靠 ActionFilter 挂上去的。
2.2 在线考试系统的四张核心表
先把最小可用的表结构落下来,答案集中存 JSON,避免交卷时的写放大:
-- 试卷主表 CREATE TABLE Exam_Paper ( PaperId INT IDENTITY(1,1) PRIMARY KEY, PaperName NVARCHAR(100) NOT NULL, TotalScore DECIMAL(6,1) NOT NULL DEFAULT 100, -- 与规则表分值之和必须相等 DurationMin INT NOT NULL DEFAULT 90, -- 考试时长,交卷校验以 EndTime 为准 StartTime DATETIME2 NOT NULL, EndTime DATETIME2 NOT NULL, PaperType TINYINT NOT NULL DEFAULT 1 -- 1 固定卷 2 随机卷 ); -- 题库表:选项存 JSON,一个知识点一行 CREATE TABLE Exam_Question ( QuestionId INT IDENTITY(1,1) PRIMARY KEY, CourseId INT NOT NULL, QType TINYINT NOT NULL, -- 1 单选 2 多选 3 判断 4 填空 5 主观 Stem NVARCHAR(MAX) NOT NULL, OptionsJson NVARCHAR(MAX) NULL, -- [{"k":"A","v":"..."},...] Answer NVARCHAR(200) NOT NULL, -- 多选按 A,B,C 升序存,填空多个答案用 || 分隔 Score DECIMAL(4,1) NOT NULL DEFAULT 2, Difficulty TINYINT NOT NULL DEFAULT 3, -- 1~5 IsActive BIT NOT NULL DEFAULT 1 ); CREATE INDEX IX_Question_Pick ON Exam_Question(CourseId, QType, Difficulty, IsActive); -- 考试记录表:这是全系统争抢最狠的一张表 CREATE TABLE Exam_Record ( RecordId BIGINT IDENTITY(1,1) PRIMARY KEY, PaperId INT NOT NULL, UserId INT NOT NULL, BeginTime DATETIME2 NOT NULL, SubmitTime DATETIME2 NULL, LastActiveTime DATETIME2 NULL, -- 心跳时间,用于判定掉线 Status TINYINT NOT NULL DEFAULT 0, -- 0 答题中 1 已交卷 2 强制交卷 3 已判分 SwitchCount INT NOT NULL DEFAULT 0, -- 切屏次数 Score DECIMAL(6,1) NULL, AnswerJson NVARCHAR(MAX) NULL ); -- 一个学生对一份卷子只能有一条记录,这条唯一索引就是幂等的底线 CREATE UNIQUE INDEX UX_Record_Paper_User ON Exam_Record(PaperId, UserId); -- 组卷规则表:随机卷按这张表抽题 CREATE TABLE Exam_PaperRule ( RuleId INT IDENTITY(1,1) PRIMARY KEY, PaperId INT NOT NULL, CourseId INT NOT NULL, QType TINYINT NOT NULL, DifficultyMin TINYINT NOT NULL DEFAULT 1, DifficultyMax TINYINT NOT NULL DEFAULT 5, QuestionCount INT NOT NULL, PerScore DECIMAL(4,1) NOT NULL );AnswerJson的结构建议是{"12":"A","13":"A,C","14":"true"},键是 QuestionId,值是用户作答。这样做的好处是交卷只写一次,判分时一次性反序列化;代价是没法直接在数据库里按题目统计错误率。如果要做题目维度分析,再补一张Exam_AnswerDetail(RecordId, QuestionId, UserAnswer, IsRight, GotScore)明细表,判分时批量SqlBulkCopy写入,不要一条一条 INSERT。
2.3 组卷规则里的三个必调参数
QuestionCount、DifficultyMin/Max、PerScore是组卷成败的关键。常见误用是把难度区间写成 1~5 全开,抽出来的卷子难度方差极大,同一考场两套卷子平均分差 15 分;另一个误用是PerScore * QuestionCount之和与Exam_Paper.TotalScore对不上,导致成绩单上「总分 100,实际 98」。建议在保存规则时做一次服务端校验,把校验写在组卷服务里做硬约束,而不是靠后台管理员自觉。
3. 组卷、答题与判分:用 ASP.NET MVC 跑通在线考试主链路
主链路就四步:抽题生成卷、开考写会话、答题过程存草稿、交卷判分写成绩。每一步都有个容易踩的坑。
3.1 随机组卷的两种 SQL 写法与性能差别
-- 写法 A:小题库够用,问题是 SQL Server 要对整个候选集排序 SELECT TOP (20) QuestionId, Score FROM Exam_Question WHERE CourseId = @CourseId AND QType = 1 AND IsActive = 1 AND Difficulty BETWEEN @dMin AND @dMax ORDER BY NEWID(); -- 写法 B:并发抽题用 TABLESAMPLE 先粗筛,再在内存里精确抽 SELECT QuestionId, Score, Difficulty FROM Exam_Question TABLESAMPLE (2000 ROWS) WHERE CourseId = @CourseId AND QType = 1 AND IsActive = 1;ORDER BY NEWID()会给每行算一个 GUID 再排序,题库到 5 万条以上、20 人同时抽卷时会明显变慢。写法 B 先按行数粗筛再在 C# 里按难度补齐,实测抽卷耗时从 300ms 级降到 30ms 级。参数上,TABLESAMPLE的 ROWS 建议取「目标题量 × 50」,太少会导致某些难度档抽不满,抽不满时要有兜底逻辑:放宽到相邻难度,并在组卷日志里记一条RuleFallback事件,否则学生拿到的卷子少两道题却没人发现。
3.2 答题会话:Redis + 心跳帧做断线续答
答题草稿不要每次都写 SQL Server,写 Redis 的 Hash 结构,按 RecordId 分键:
// 保存草稿:一次只写被改动的题,避免整卷 100 题反复覆盖 public async Task SaveDraftAsync(long recordId, int questionId, string answer) { var key = $"exam:record:{recordId}"; // Field 用题目号,Value 是作答,/0 是答案的键名后缀 await _redis.HashSetAsync(key, $"{questionId}", answer); // TTL 给考试时长 + 2 小时,考完不用手动清,过期自动回收 await _redis.KeyExpireAsync(key, TimeSpan.FromHours(4)); } // 心跳:前端每 15 秒上报一次,服务端只更新活跃时间 public async Task HeartbeatAsync(long recordId, int userId) { await _db.Database.ExecuteSqlRawAsync( "UPDATE Exam_Record SET LastActiveTime = SYSDATETIME() WHERE RecordId = @p0 AND UserId = @p1 AND Status = 0", recordId, userId); }HashSet而不是StringSet的理由是:Redis Hash 可以只更新单个 field,100 道题的草稿不会每次全量覆盖网络传输。TTL 设成「考试时长 + 2 小时」而不是不过期,是防止 Redis 里堆一批永远不删的僵尸键。断线重连时,前端拉一次HashGetAll就能复原作答,但注意复原后要把服务端时间与本地时间重新对齐,别信浏览器里的倒计时。
3.3 交卷与判分:把幂等和判分拆成两步
交卷接口是全系统并发最高的地方,写法上要用「更新行数」当闸门,而不是先 SELECT 再判断:
public async Task<SubmitResult> SubmitAsync(long recordId, int userId, string answerJson) { // 闸门:只有 Status=0 的记录能被改成 1,并发下受影响行数只会有一个 1 var rows = await _db.Database.ExecuteSqlRawAsync(@" UPDATE Exam_Record SET Status = 1, SubmitTime = SYSDATETIME(), AnswerJson = @p0 WHERE RecordId = @p1 AND UserId = @p2 AND Status = 0", new SqlParameter("@p0", answerJson), new SqlParameter("@p1", recordId), new SqlParameter("@p2", userId)); if (rows == 0) return SubmitResult.AlreadySubmitted; // 重复提交,前端直接跳成绩页 // 判分单独一步:失败可重入,不会长时间占着 Exam_Record 的行锁 var score = await _grader.GradeAsync(recordId); return SubmitResult.Ok(score); }把判分从提交事务里拆出来的原因很实际:判分要读题库、要算分、要写明细表,放在同一个事务里,200 人同时交卷时会互相等锁,提交接口 P95 直接飙到秒级。拆开之后,即使判分中途挂了,Status=1的记录还在,后台起个补判任务扫一遍就能把分补上,不会丢卷。
3.4 各类题型的判分规则与参数
| 题型 | 比对方式 | 给分规则 | 相关参数 |
|---|---|---|---|
| 单选 / 判断 | 去空格、忽略大小写后等值比较 | 对得满分 | Score |
| 多选 | 作答与标准答案各自排序后比较 | 全对满分,漏选给一半,错选 0 | HalfOnPartial |
| 填空 | 标准答案按 ` | ` 切分后逐一比较 | |
| 主观 | 不参与自动判分 | 置 Status=1 待人工阅卷 | PendingReview |
多选「漏选给一半」这条要不要开,取决于考试性质:资格类考试通常不给,校内测验给一半更友好。另外填空题的MatchMode默认设成Any会带来误判风险,比如标准答案「中国|中华人民共和国」,学生写「中华」不会命中,但写「中国香港」也不会命中——因为比的是完整值,不是包含,这一点在实现时用Equals而不是Contains。
4. 交卷并发、ViewState 与防作弊:在线考试系统的安全边界
上线前被问得最多的三个问题:能不能重复交卷、页面状态能不能篡改、切屏有没有记录。这三件事都必须在服务端留证据。
4.1 用唯一索引 + 状态机挡住重复交卷
上一章的UPDATE ... WHERE Status = 0是第一道闸门,UX_Record_Paper_User唯一索引是第二道。有人喜欢在提交前用SELECT查一遍是否已交卷,这个做法在网络抖动重试、用户双击的场景下会漏,因为两条请求可能同时 SELECT 到Status=0。正确姿势永远是让数据库来裁决,应用层只负责解释「影响 0 行」这个结果。
还要处理一种情况:学生点击交卷后网络超时,实际服务端已经成功。前端要做的不是「提示失败让用户重试」,而是拿到AlreadySubmitted后调一次查分接口兜底。这个细节决定了考场上老师要处理多少次「老师我交了但没分」。
4.2 答题页的 ViewState 与反序列化面收敛
WebForms 项目里,__VIEWSTATE会被签名和加密后塞在表单里,很多性能调优文章会建议把EnableViewStateMac关掉、或者把 ViewState 放在 Session 里省带宽——这两个建议在生产环境都不要采纳。关掉 MAC 等于允许客户端伪造页面状态,绕过 MAC 之后再构造恶意的序列化对象,就是被反复讨论的那类反序列化入口问题;日志里如果出现陌生的类型名(曾经出现过以TypeConfusedDelegate一类 gadget 命名的探测流量),把它当成入侵信号处理,而不是当成误报。
<system.web> <!-- 这两项默认值就是安全的,任何“优化”都不要改成 false / Never --> <pages enableViewStateMac="true" viewStateEncryptionMode="Always" /> <!-- 固定 machineKey 并离线保管,用于 ViewState 的签名与加密 --> <machineKey validation="HMACSHA256" decryption="AES" /> <!-- 不要给攻击者返回堆栈信息 --> <customErrors mode="On" defaultRedirect="~/Error" /> <httpRuntime enableVersionHeader="false" /> </system.web>答题页(ExamAnswer.aspx)建议直接EnableViewState="false",控件级再设ViewStateMode="Disabled",作答数据走 AJAX 存服务端,页面本身不需要任何状态。迁移到 ASP.NET Core MVC 之后 ViewState 这一整块风险直接消失,但要注意另一个同类入口:老代码里用 Newtonsoft.Json 时把TypeNameHandling设成了All或Objects,反序列化客户端 JSON 时同样能加载任意类型。核对一下全局配置,TypeNameHandling保持None。
4.3 防作弊校验项与服务端权威字段
前端能做的都不算数,只用来提示;判断一律以服务端字段为准。
| 校验项 | 前端做法 | 服务端权威字段 |
|---|---|---|
| 剩余时间 | JS 倒计时显示 | Paper.EndTime与SYSDATETIME()比较,超时拒绝提交 |
| 切屏次数 | visibilitychange计数上报 | 累加写入Exam_Record.SwitchCount |
| 掉线检测 | 每 15 秒心跳 | LastActiveTime超过 90 秒标记异常 |
| 单题停留 | 前端计时仅供参考 | 不落库,避免把噪声当证据 |
| 同人多次入场 | 无 | PaperId + UserId唯一索引天然挡住 |
倒计时这块一定要用服务端的EndTime做终审,把SYSDATETIME()和EndTime的差值拿来算剩余秒数,允许 30 秒的网络补偿窗口,超过就直接置Status=2强制交卷。切屏次数的阈值建议设 3 次以上才标记「待核查」,而不是直接判作弊,否则输入法弹窗、系统通知都会误伤。
4.4 别让答案从接口漏出去
组卷接口返回给前端的题目对象里不能带Answer字段,这个低级错误在答辩项目里出现频率极高——F12 一看网络请求,正确答案就在 JSON 里。做法是定义独立的 DTO,用 AutoMapper 或者手写映射只挑字段出来:
public class QuestionDto // 只暴露给答题页的字段 { public int QuestionId { get; set; } public int QType { get; set; } public string Stem { get; set; } public List<OptionDto> Options { get; set; } public decimal Score { get; set; } // 注意:这里没有 Answer,也没有 Difficulty }Difficulty也不建议下发,学生看到难度分布就能推断出自己是不是抽到了简单卷。所有查询一律用参数化 SQL 或 EF Core 的 LINQ,不要把前端传来的paperId拼进字符串。
5. 上线前的压测、排错与身份核验
功能跑通跟能上线是两回事。这一章讲三件事:本地怎么跑起来、压测盯哪些指标、需要实名核验时读卡器怎么接。
5.1 从 VS Code 到 IIS 的部署检查
本地开发用dotnet run或 VS Code 的 C# Dev Kit 直接 F5 最省事,注意launch.json里ASPNETCORE_ENVIRONMENT设成Development方便看详细错误。发布时:
# 发布到指定目录,Release 配置会做裁剪和预编译 dotnet publish -c Release -o ./publish # 连接字符串不要写进 appsettings.json 提交到仓库,用用户机密 dotnet user-secrets init dotnet user-secrets set "ConnectionStrings:ExamDb" "Server=.;Database=ExamDb;Trusted_Connection=True;"IIS 上要装 ASP.NET Core Hosting Bundle,应用池的「.NET CLR 版本」必须选「无托管代码」,否则站点会报 500.30 启动失败。publish目录里生成的web.config不要手改,重新发布会被覆盖,要加环境变量就改appsettings.Production.json或用 IIS 的配置编辑器。
5.2 压测指标与 Session 锁这个隐形杀手
用 bombardier 或 JMeter 压两个接口:开考(/Exam/Start)和交卷(/Exam/Submit)。
# 压交卷接口 200 并发,持续 30 秒 bombardier -c 200 -d 30s -m POST \ -H "Content-Type: application/json" \ -b '{"recordId":1024,"answers":{}}' \ http://localhost:5000/api/exam/submit盯四个指标:QPS、P95 响应时间、SQL Server 的Lock Wait Time、以及 Worker 进程的线程数。最常见的坑不是数据库,而是 Session:ASP.NET 的 Session 对同一会话是串行的,答题页每请求都写 Session 会导致同一个学生的请求排队,用 200 个不同账号压测就发现不了,用同一个账号压测才会暴露。解法是把 Session 换成 Redis 分布式缓存,或者答题相关接口做成无状态,只靠 RecordId 校验。连接池也要看一眼,默认Max Pool Size=100,200 并发交卷时会等连接,按服务器情况调到 200 并配合Connect Timeout=15。
5.3 读卡器 sdtapi.dll 的调用要点
需要实名核验的考场会接身份证阅读器,这类设备的 SDK 一般只提供 32 位驱动库,主程序必须显式编译成 x86(项目文件里<PlatformTarget>x86</PlatformTarget>),否则DllImport会直接报「试图加载格式不正确的程序」。
// 以厂商文档为准,不同机型参数顺序可能不同 [DllImport("sdtapi.dll", CallingConvention = CallingConvention.StdCall)] private static extern int SDT_ReadBaseMsg( int port, byte[] chMsg, ref uint chMsgLen, byte[] phMsg, ref uint phMsgLen, int ifOpen); // 先按 256 字节试长度,返回长度不足的状态码再重新申请缓冲区 var ch = new byte[256]; var ph = new byte[1024]; uint chLen = (uint)ch.Length, phLen = (uint)ph.Length; int ret = SDT_ReadBaseMsg(1001, ch, ref chLen, ph, ref phLen, 1);chMsg里是 base64 编码的文字信息,phMsg是 WLT 格式的人像照片,需要额外解码成 BMP/JPG 再存档。两个边界要守住:一是核验必须在服务端做,前端传来的姓名、证件号一律不信任;二是照片和证件信息属于个人敏感信息,存库前加密、设置访问权限和保留期限,别直接丢在wwwroot下面让任何人猜到文件名就能下载。把LastActiveTime和SubmitTime一起写进Exam_Record,事后核查异常考试才有据可查。
本文还有配套的精品资源,点击获取