ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ASP.NET在线考试系统:组卷、交卷并发与防作弊设计

ASP.NET在线考试系统:组卷、交卷并发与防作弊设计 简介这份资源是一份基于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 怎么选维度WebFormsASP.NET MVC 5ASP.NET Core MVC页面状态载体ViewState 随控件膨胀无无100 题答题页体积常见 200KB500KB几十 KB几十 KB后台管理开发速度拖控件最快中中跨平台部署仅 Windows/IIS仅 Windows/IISLinux 容器可跑后台定时任务另建 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 可以只更新单个 field100 道题的草稿不会每次全量覆盖网络传输。TTL 设成「考试时长 2 小时」而不是不过期是防止 Redis 里堆一批永远不删的僵尸键。断线重连时前端拉一次HashGetAll就能复原作答但注意复原后要把服务端时间与本地时间重新对齐别信浏览器里的倒计时。3.3 交卷与判分把幂等和判分拆成两步交卷接口是全系统并发最高的地方写法上要用「更新行数」当闸门而不是先 SELECT 再判断public async TaskSubmitResult SubmitAsync(long recordId, int userId, string answerJson) { // 闸门只有 Status0 的记录能被改成 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 直接飙到秒级。拆开之后即使判分中途挂了Status1的记录还在后台起个补判任务扫一遍就能把分补上不会丢卷。3.4 各类题型的判分规则与参数题型比对方式给分规则相关参数单选 / 判断去空格、忽略大小写后等值比较对得满分Score多选作答与标准答案各自排序后比较全对满分漏选给一半错选 0HalfOnPartial填空标准答案按 切分后逐一比较主观不参与自动判分置 Status1 待人工阅卷PendingReview多选「漏选给一半」这条要不要开取决于考试性质资格类考试通常不给校内测验给一半更友好。另外填空题的MatchMode默认设成Any会带来误判风险比如标准答案「中国|中华人民共和国」学生写「中华」不会命中但写「中国香港」也不会命中——因为比的是完整值不是包含这一点在实现时用Equals而不是Contains。4. 交卷并发、ViewState 与防作弊在线考试系统的安全边界上线前被问得最多的三个问题能不能重复交卷、页面状态能不能篡改、切屏有没有记录。这三件事都必须在服务端留证据。4.1 用唯一索引 状态机挡住重复交卷上一章的UPDATE ... WHERE Status 0是第一道闸门UX_Record_Paper_User唯一索引是第二道。有人喜欢在提交前用SELECT查一遍是否已交卷这个做法在网络抖动重试、用户双击的场景下会漏因为两条请求可能同时 SELECT 到Status0。正确姿势永远是让数据库来裁决应用层只负责解释「影响 0 行」这个结果。还要处理一种情况学生点击交卷后网络超时实际服务端已经成功。前端要做的不是「提示失败让用户重试」,而是拿到AlreadySubmitted后调一次查分接口兜底。这个细节决定了考场上老师要处理多少次「老师我交了但没分」。4.2 答题页的 ViewState 与反序列化面收敛WebForms 项目里__VIEWSTATE会被签名和加密后塞在表单里很多性能调优文章会建议把EnableViewStateMac关掉、或者把 ViewState 放在 Session 里省带宽——这两个建议在生产环境都不要采纳。关掉 MAC 等于允许客户端伪造页面状态绕过 MAC 之后再构造恶意的序列化对象就是被反复讨论的那类反序列化入口问题日志里如果出现陌生的类型名曾经出现过以TypeConfusedDelegate一类 gadget 命名的探测流量把它当成入侵信号处理而不是当成误报。system.web !-- 这两项默认值就是安全的任何“优化”都不要改成 false / Never -- pages enableViewStateMactrue viewStateEncryptionModeAlways / !-- 固定 machineKey 并离线保管用于 ViewState 的签名与加密 -- machineKey validationHMACSHA256 decryptionAES / !-- 不要给攻击者返回堆栈信息 -- customErrors modeOn defaultRedirect~/Error / httpRuntime enableVersionHeaderfalse / /system.web答题页ExamAnswer.aspx建议直接EnableViewStatefalse控件级再设ViewStateModeDisabled作答数据走 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 秒的网络补偿窗口超过就直接置Status2强制交卷。切屏次数的阈值建议设 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 ListOptionDto 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.;DatabaseExamDb;Trusted_ConnectionTrue;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 进程的线程数。最常见的坑不是数据库而是 SessionASP.NET 的 Session 对同一会话是串行的答题页每请求都写 Session 会导致同一个学生的请求排队用 200 个不同账号压测就发现不了用同一个账号压测才会暴露。解法是把 Session 换成 Redis 分布式缓存或者答题相关接口做成无状态只靠 RecordId 校验。连接池也要看一眼默认Max Pool Size100200 并发交卷时会等连接按服务器情况调到 200 并配合Connect Timeout15。5.3 读卡器 sdtapi.dll 的调用要点需要实名核验的考场会接身份证阅读器这类设备的 SDK 一般只提供 32 位驱动库主程序必须显式编译成 x86项目文件里PlatformTargetx86/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事后核查异常考试才有据可查。本文还有配套的精品资源点击获取
返回列表