C# WinForm+SQL Server考试系统源码:组卷判分与机房部署
简介:一套面向计算机专业学生与 .NET 初学者的 C# WinForm 考试系统实战源码,采用教师端与学生端双端设计,两端均基于经典三层架构分层组织,可解决课程设计、毕业设计及小型在线考试场景中缺少完整可运行案例的问题。压缩包共 357 个文件,约 2.86MB,以 155 个 cs 源码文件为主体,配合 resx/resources 窗体资源、csproj 项目文件、exe/dll 编译产物,以及 mdf/ldf 数据库文件和 sql 脚本,数据库基于 SQL Server 2014,可直接附加或通过脚本还原。代码逐行注释、结构简洁,现已吸引 613 人学习。读者可从中获得双端登录与权限、题库维护、组卷与考试、自动判分等模块的完整实现思路,也能对照三层架构理解 UI、业务逻辑与数据访问的职责划分,既适合边看边调试的学习方式,也可作为实际项目的二次开发基础。
1. 机房断网环境里,一套 c# winform 考试系统为什么还站得住
考场不许连外网、机房机器还是 Win7、临时装个浏览器环境都可能被策略拦下来——这种场景里,一套 c# winform 考试系统配 sqlserver2014 反而最省事:服务端只放一台数据库机器,客户端是绿色 exe,双击登录、抽题、答题、交卷,客观题当场出分。这套源码走的就是这个路子,题库录入、按题型和难度组卷、限时答题卡、自动判分、成绩导出全在一个桌面客户端里闭环。它适合两类人:需要在教室或机房组织随堂测、期末机考的老师和技术员,以及想拿一个结构完整、依赖极少的 C# 桌面项目练手或做课程设计的开发者。它的边界也很清楚——按小规模单考场设计,几十人同时开考时的并发和防作弊要自己补。
2. 技术栈与项目骨架:WinForms 三层结构怎么落地
2.1 选型清单:为什么没上 EF,也没上 Web
先把这套系统用到的技术摆清楚,后面看代码才不会飘。
| 技术 | 版本 | 角色 | 选它的实际理由 |
|---|---|---|---|
| .NET Framework | 4.5 / 4.6.1 | 运行时 | 机房 Win7 机器普遍自带,不用额外装运行时 |
| WinForms | 随框架 | 全部界面 | 拖控件出页面,考场机器不需要浏览器和 IIS |
| ADO.NET(System.Data.SqlClient) | 框架内置 | 数据访问 | SQL 直写,出问题能一眼看到语句 |
| SQL Server | 2014 | 数据存储 | 考场服务器常见版本,Express 版免费够用 |
| NPOI | 2.x | 导出成绩、题库模板 | 不依赖机器上装没装 Office |
| MD5 | 框架内置 | 密码存储 | 够用,不引入额外加密库 |
最容易被问的是「为什么不用 Entity Framework」。血泪经验是机房环境不可控:目标机器 .NET 版本参差,EF 还要带一堆 dll 一起走,出了问题报的是框架内部异常,现场根本没法查。ADO.NET 直接拼 SqlCommand,日志里打印出来的就是能粘进 SSMS 跑的 SQL,排查成本低一个量级。这不是说 EF 不好,是这个场景下「看得见」比「写得快」重要。
界面层全部用 WinForms 标准控件:DataGridView 做题库列表和成绩列表,FlowLayoutPanel 做答题卡题号按钮,Timer 做倒计时,TabControl 做「单选 / 多选 / 判断 / 主观」分页。没有第三方 UI 库,这也意味着你去改造时不用先搞懂某套皮肤框架的规则。
2.2 解决方案目录:四个项目各管一摊
打开 sln 能看到四个类库加一个启动项目,引用方向是单向的:
ExamSystem/ ├── ExamSystem.sln ├── ExamSystem.Model/ # 实体类,只有属性,不引用任何人 │ ├── User.cs │ ├── Question.cs │ └── Paper.cs ├── ExamSystem.DAL/ # 只跟数据库说话,引用 Model │ ├── DbHelper.cs │ ├── UserDal.cs │ ├── QuestionDal.cs │ └── PaperDal.cs ├── ExamSystem.BLL/ # 业务规则:组卷、判分、成绩统计 │ ├── PaperService.cs │ ├── ExamService.cs │ └── ScoreService.cs ├── ExamSystem.UI/ # WinForms 窗体,引用 BLL + Model │ ├── Forms/ │ │ ├── FrmLogin.cs │ │ ├── FrmQuestionBank.cs │ │ ├── FrmPaperBuild.cs │ │ ├── FrmExam.cs │ │ └── FrmScore.cs │ ├── Program.cs │ └── App.config └── DB/ ├── 01_create.sql └── 02_init_data.sql引用方向必须是 UI → BLL → DAL → Model,Model 谁都不引用。这条线守不住,最常见的结果就是有人在窗体里直接new SqlConnection,半年后要换数据库或者加缓存,改动量直接爆炸。我在评审这种项目时第一件事就是搜 UI 项目里有没有using System.Data.SqlClient,有就说明分层已经破了。
Model 层保持「贫血」就行,一个 Question 类只放 QuestionID、Content、OptionA 到 OptionD、StandardAnswer、Score、Difficulty 这些属性,不要往里塞任何校验逻辑。校验放 BLL,因为同一道题在「录入」「组卷」「判分」三个环节的校验规则不一样,塞进实体类会互相打架。
2.3 数据访问基类:所有 SqlCommand 都收口到 DbHelper
DAL 层只有一个入口类,所有 SQL 都从这里走,不允许各 Dal 自己开连接。
public static class DbHelper { // 连接串从 App.config 读,绝不写死在窗体里 private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["ExamDb"].ConnectionString; // 查询:返回 DataTable,列表类界面基本都用它 public static DataTable Query(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) using (var da = new SqlDataAdapter(cmd)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); var dt = new DataTable(); da.Fill(dt); // Fill 内部自动 Open/Close,不用手写 return dt; } } // 增删改:返回受影响行数 public static int Execute(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } // 单值:交卷算总分、查是否已考过时用 public static object Scalar(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteScalar(); } } }三个方法对应三类调用,参数一律用SqlParameter传,不做字符串拼接。拼接 SQL 在这种题库系统里翻车特别快——题干里出现一个单引号(语文题引用原文很常见)就整条语句语法错误,更别说注入。using保证 SqlConnection 一定被 Dispose,回到连接池;不写 using 的后果是考试高峰期连接池被打满,后面所有人登录都卡在「正在连接」。
参数用@name占位,调用端这样写:
var dt = DbHelper.Query( "SELECT QuestionID, Content, StandardAnswer FROM Questions " + "WHERE CourseID = @cid AND QuestionType = @type AND IsDeleted = 0", new SqlParameter("@cid", courseId), new SqlParameter("@type", questionType));这里@cid和@type的类型由 SqlParameter 自动推断,传 int 就是 int,别转成字符串再传——SQL Server 做隐式转换时会放弃索引,题库过万以后抽题明显变慢。这条我踩过,当时以为是 NEWID() 的问题,查了半天执行计划才发现是参数类型不匹配导致的扫描。
2.4 App.config:两行配置决定能不能连上
连接字符串只出现一处:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <connectionStrings> <!-- 考场部署时只改这一行;Integrated Security 表示走 Windows 身份验证 --> <add name="ExamDb" connectionString="Data Source=192.168.1.10;Initial Catalog=ExamDB;User ID=sa;Password=YourPwd123;Connect Timeout=8" providerName="System.Data.SqlClient" /> </connectionStrings> <appSettings> <!-- 交卷后是否允许考生查看答案 --> <add key="AllowReview" value="false" /> </appSettings> </configuration>Data Source换成考场服务器的 IP,Initial Catalog是库名,Connect Timeout我一般设 8 秒——默认 15 秒太长,机房网络有问题时学生会盯着登录按钮等半分钟,体验极差,8 秒失败直接弹「连不上服务器,请联系监考」。密码不要提交到代码仓库,部署时现场改。
3. 数据库落地:SQL Server 2014 里的六张核心表
3.1 题库表:一道题的所有形态都塞在一张表里
题库是整个系统的地基,表结构设计得合不合理,直接决定组卷和判分写起来顺不顺。
CREATE TABLE dbo.Questions ( QuestionID INT IDENTITY(1,1) NOT NULL PRIMARY KEY, CourseID INT NOT NULL, -- 所属科目 QuestionType TINYINT NOT NULL, -- 1单选 2多选 3判断 4填空 5问答 Content NVARCHAR(MAX) NOT NULL, -- 题干 OptionA NVARCHAR(500) NULL, OptionB NVARCHAR(500) NULL, OptionC NVARCHAR(500) NULL, OptionD NVARCHAR(500) NULL, StandardAnswer NVARCHAR(500) NOT NULL, -- 单选存"A",多选存"ACD" Score DECIMAL(5,1) NOT NULL DEFAULT 2, -- 题库默认分值 Difficulty TINYINT NOT NULL DEFAULT 1, -- 1易 2中 3难 IsDeleted BIT NOT NULL DEFAULT 0, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); -- 组卷抽题全靠这个索引,别省 CREATE NONCLUSTERED INDEX IX_Questions_Course_Type ON dbo.Questions(CourseID, QuestionType, IsDeleted) INCLUDE(Score, Difficulty);几个字段值得单独说。StandardAnswer不拆成多列,多选就存"ACD"这种排序后的字符串,简单但要求录入端强制排序——这个约束放在 BLL 的保存方法里做,别指望录入的人自觉。Score是题库默认分,后面试卷关联表还会存一份分值,原因在 3.2 讲。
IsDeleted用逻辑删除而不是物理删除,因为历史考试记录还引用着这道题,物理删了会导致旧成绩查不到题目内容。所有查询都要带IsDeleted = 0,这个条件漏一次就是一个 bug。
3.2 试卷表与关联表:分值为什么存两份
试卷和题目是多对多,中间表必须有,关键是中间表里放什么。
CREATE TABLE dbo.Papers ( PaperID INT IDENTITY(1,1) NOT NULL PRIMARY KEY, PaperName NVARCHAR(100) NOT NULL, CourseID INT NOT NULL, TotalScore DECIMAL(6,1) NOT NULL DEFAULT 0, Duration INT NOT NULL DEFAULT 60, -- 分钟 Status TINYINT NOT NULL DEFAULT 0, -- 0草稿 1已发布 2已归档 CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE dbo.PaperQuestions ( PaperID INT NOT NULL, QuestionID INT NOT NULL, SortOrder INT NOT NULL, -- 题号顺序 Score DECIMAL(5,1) NOT NULL, -- 这道题在本卷中的分值 CONSTRAINT PK_PaperQuestions PRIMARY KEY (PaperID, QuestionID) );PaperQuestions.Score和Questions.Score是两份数据,很多人第一反应是冗余。但同一道题在章节小测里值 2 分、在期末卷里可能值 4 分,如果只存题库那一份,要么改题库污染历史卷,要么每次判分都遍历规则,都别扭。存两份,判分时以PaperQuestions.Score为准,Papers.TotalScore在组卷保存时用SUM回写一次,这样成绩页面的「满分」不用每次现算。
PK_PaperQuestions用 (PaperID, QuestionID) 复合主键,天然防止同一份卷子里抽到重复题——这个约束比在 C# 里Distinct()靠谱,因为并发组卷或者导题库重跑时,应用层校验可能被绕过。
3.3 考试记录表与答题明细表:边答边存的底气
这两张表决定了断电后数据还在不在。
CREATE TABLE dbo.ExamRecords ( ExamID INT IDENTITY(1,1) NOT NULL PRIMARY KEY, PaperID INT NOT NULL, UserID INT NOT NULL, StartTime DATETIME NOT NULL DEFAULT GETDATE(), SubmitTime DATETIME NULL, ObjectiveScore DECIMAL(6,1) NOT NULL DEFAULT 0, -- 客观题机评分 FinalScore DECIMAL(6,1) NULL, -- 含主观题后的总分 Status TINYINT NOT NULL DEFAULT 0 -- 0考试中 1已交卷 2已阅卷 ); CREATE TABLE dbo.AnswerDetails ( ExamID INT NOT NULL, QuestionID INT NOT NULL, UserAnswer NVARCHAR(MAX) NULL, AnswerTime DATETIME NOT NULL DEFAULT GETDATE(), IsCorrect BIT NULL, -- 交卷时回填 GotScore DECIMAL(5,1) NOT NULL DEFAULT 0, CONSTRAINT PK_AnswerDetails PRIMARY KEY (ExamID, QuestionID) );AnswerDetails的主键是 (ExamID, QuestionID),天生支持「同一考生同一题只留最后答案」,写入用 UPDATE 或 MERGE 都行,不会产生重复行。GotScore在交卷判分时回填,好处是成绩分析不需要每次重新跑判分逻辑,直接SUM(GotScore)出分。
ExamRecords.Status这个状态机很关键:0 表示考生还在考,1 表示已交卷,2 表示主观题阅完。中途崩溃重进时,客户端先查有没有 Status=0 的记录,有就恢复现场继续考,没有就新建。这个设计是「断电不丢卷」的核心,第 5 章会展开讲。
3.4 建库脚本与初始化:先跑通再加数据
DB/01_create.sql里按 Users → Questions → Papers → PaperQuestions → ExamRecords → AnswerDetails 的顺序建表,因为有外键依赖。初始化脚本02_init_data.sql里塞一个管理员账号和一门课的十道样例题,够你第一次跑起来看到界面。
-- 管理员初始账号,密码是 123456 的 MD5,上线第一件事就是改掉 INSERT INTO dbo.Users(UserName, Password, RealName, Role) VALUES('admin', 'E10ADC3949BA59ABBE56E057F20F883E', N'系统管理员', 1); -- 一道单选题样例 INSERT INTO dbo.Questions(CourseID, QuestionType, Content, OptionA, OptionB, OptionC, OptionD, StandardAnswer, Score, Difficulty) VALUES(1, 1, N'以下哪个不是值类型?', N'int', N'double', N'string', N'bool', N'C', 2, 1);执行顺序别搞反,外键会让脚本在中途报错。用 SSMS 打开脚本、切到目标库、F5 执行就行,也可以在命令行用sqlcmd -S . -i 01_create.sql跑,适合部署时写进批处理。
4. 核心链路拆解:从登录到交卷的四个关键实现
4.1 登录与角色分流:一个窗体装下三种身份
Users 表里 Role 字段区分 1 管理员、2 教师、3 学生,登录成功后不是简单Close()再Show(),而是用DialogResult把主窗体挂起来。
private void btnLogin_Click(object sender, EventArgs e) { string name = txtUser.Text.Trim(); string pwd = Md5Helper.Hash(txtPwd.Text.Trim()); // 与库里存的口径保持一致 if (name.Length == 0 || pwd.Length == 0) { MessageBox.Show("账号和密码不能为空"); return; } // 参数化查询,顺带把已禁用账号过滤掉 var dt = DbHelper.Query( "SELECT UserID, RealName, Role FROM Users WHERE UserName=@n AND Password=@p AND IsEnabled=1", new SqlParameter("@n", name), new SqlParameter("@p", pwd)); if (dt.Rows.Count == 0) { MessageBox.Show("账号或密码错误"); txtPwd.Clear(); txtPwd.Focus(); return; } var row = dt.Rows[0]; CurrentUser.Id = Convert.ToInt32(row["UserID"]); CurrentUser.Name = row["RealName"].ToString(); CurrentUser.Role = Convert.ToInt32(row["Role"]); this.DialogResult = DialogResult.OK; // Program.cs 里据此决定开哪个主窗体 }Program.cs里先Application.Run(new FrmLogin()),登录返回 OK 之后再按角色new FrmMain()或new FrmExamEntry(),用Application.Run再起一个消息循环。这样登录窗体的资源被彻底释放,不会出现主窗体关掉后程序还挂在后台的情况。
MD5 这里只是防明文,不是安全方案。真要在意,就换成加盐哈希,但改动要同时动初始化脚本和登录逻辑,别忘了改测试账号的密码。
4.2 组卷算法:随机抽题怎么抽得可控
组卷的需求是「每门课抽 20 单选、10 多选、10 判断,难度比例大致 6:3:1」。最省事的写法是一条 SQL 带ORDER BY NEWID(),但它没法按难度配额分配,而且大表上NEWID()排序是拿临时表扛的,题量上万会明显卡。
我一般分两步:先按「题型 + 难度」把候选 ID 捞到内存,再在内存里按配额洗牌。
public List<int> PickQuestionIds(int courseId, int type, int need, Dictionary<int, int> difficultyQuota) { var picked = new List<int>(); foreach (var quota in difficultyQuota) // key=难度, value=需要几道 { int level = quota.Key; int count = quota.Value; if (count <= 0) continue; // 只取 ID,不带题干大字段,网络传输量小很多 var dt = DbHelper.Query( @"SELECT TOP (@cnt) QuestionID FROM Questions WHERE CourseID=@cid AND QuestionType=@type AND Difficulty=@lv AND IsDeleted=0 ORDER BY NEWID()", new SqlParameter("@cnt", count), new SqlParameter("@cid", courseId), new SqlParameter("@type", type), new SqlParameter("@lv", level)); foreach (DataRow r in dt.Rows) picked.Add(Convert.ToInt32(r["QuestionID"])); } // 某个难度题不够时,用同题型其他难度补齐,避免卷子题数不达标 if (picked.Count < need) { int gap = need - picked.Count; var dt = DbHelper.Query( @"SELECT TOP (@cnt) QuestionID FROM Questions WHERE CourseID=@cid AND QuestionType=@type AND IsDeleted=0 AND QuestionID NOT IN (SELECT * FROM dbo.SplitInts(@ids)) ORDER BY NEWID()", new SqlParameter("@cnt", gap), new SqlParameter("@cid", courseId), new SqlParameter("@type", type), new SqlParameter("@ids", string.Join(",", picked))); foreach (DataRow r in dt.Rows) picked.Add(Convert.ToInt32(r["QuestionID"])); } return picked.Take(need).ToList(); }TOP (@cnt)这种带变量的写法 SQL Server 2008 以后都支持,别再用字符串拼TOP 10。NOT IN那里用了一个自定义表值函数SplitInts把逗号串变集合,这是为了避开NOT IN ('1,2,3')这种错误写法——那只会匹配一整个字符串。没有这个函数的话,可以换成在 C# 侧先取够量的候选再Except,逻辑一样。
配额的来源是窗体上的四个数字框,教师填完点「抽题」,BLL 调这个方法,返回的 ID 列表再按题型顺序插入PaperQuestions,SortOrder按 1、2、3 递增。分值这一步允许教师手工改,改完回写Papers.TotalScore。
4.3 答题卡与倒计时:Timer 到点自动交卷
答题界面是这套系统交互最重的地方。左侧放题号和题干,右侧 FlowLayoutPanel 动态生成答题卡按钮,中间是选项。
答题卡上的按钮点击不跳转,而是把左侧题面切换过去。每次选择选项立刻写库,不等交卷:
private void SaveAnswer(int examId, int questionId, string userAnswer) { // MERGE 一次搞定「有则更新、无则插入」,避免先查后写的竞态 const string sql = @" MERGE dbo.AnswerDetails AS t USING (SELECT @eid AS ExamID, @qid AS QuestionID) AS s ON t.ExamID = s.ExamID AND t.QuestionID = s.QuestionID WHEN MATCHED THEN UPDATE SET UserAnswer = @ans, AnswerTime = GETDATE() WHEN NOT MATCHED THEN INSERT (ExamID, QuestionID, UserAnswer, AnswerTime) VALUES (@eid, @qid, @ans, GETDATE());"; DbHelper.Execute(sql, new SqlParameter("@eid", examId), new SqlParameter("@qid", questionId), new SqlParameter("@ans", userAnswer ?? string.Empty)); }倒计时用System.Windows.Forms.Timer,间隔 1000 毫秒,剩余秒数在进入考试时用Duration * 60初始化,如果是从崩溃中恢复,就用「开始时间 + 时长 - 当前时间」重算,绝不能直接用本地变量。
private int _remainSeconds; private void timerExam_Tick(object sender, EventArgs e) { _remainSeconds--; lblRemain.Text = TimeSpan.FromSeconds(Math.Max(_remainSeconds, 0)).ToString(@"hh\:mm\:ss"); if (_remainSeconds <= 300) // 最后五分钟变红提醒 lblRemain.ForeColor = Color.Red; if (_remainSeconds <= 0) { timerExam.Stop(); SubmitPaper(isAuto: true); // 自动交卷,弹提示并禁止继续作答 } }这里有个细节:SubmitPaper里要先把ExamRecords.Status从 0 改成 1,改成功了才允许关窗。如果直接关窗,界面关了但库里还是「考试中」,下次进来会被当成未完成考试,反而更乱。
4.4 自动判分:多选的排序比较和主观题留白
交卷后调判分服务,客观题机评,主观题留IsCorrect = NULL等人工阅。
public decimal JudgeObjective(int examId, int paperId) { var dt = DbHelper.Query( @"SELECT a.QuestionID, a.UserAnswer, q.QuestionType, q.StandardAnswer, pq.Score FROM AnswerDetails a JOIN Questions q ON q.QuestionID = a.QuestionID JOIN PaperQuestions pq ON pq.QuestionID = a.QuestionID AND pq.PaperID = @pid WHERE a.ExamID = @eid", new SqlParameter("@eid", examId), new SqlParameter("@pid", paperId)); decimal total = 0; foreach (DataRow row in dt.Rows) { int type = Convert.ToInt32(row["QuestionType"]); if (type >= 4) continue; // 填空、问答留人工 string user = (row["UserAnswer"] as string ?? string.Empty).Trim(); string std = row["StandardAnswer"].ToString().Trim(); bool ok; if (type == 2) // 多选:排序后整串比较,全对才给分 ok = SortChars(user) == SortChars(std); else // 单选、判断:忽略大小写 ok = string.Equals(user, std, StringComparison.OrdinalIgnoreCase); if (ok) { decimal s = Convert.ToDecimal(row["Score"]); total += s; DbHelper.Execute( "UPDATE AnswerDetails SET IsCorrect=1, GotScore=@s WHERE ExamID=@eid AND QuestionID=@qid", new SqlParameter("@s", s), new SqlParameter("@eid", examId), new SqlParameter("@qid", row["QuestionID"])); } else { DbHelper.Execute( "UPDATE AnswerDetails SET IsCorrect=0, GotScore=0 WHERE ExamID=@eid AND QuestionID=@qid", new SqlParameter("@eid", examId), new SqlParameter("@qid", row["QuestionID"])); } } DbHelper.Execute( "UPDATE ExamRecords SET ObjectiveScore=@s, FinalScore=@s, Status=1, SubmitTime=GETDATE() WHERE ExamID=@eid", new SqlParameter("@s", total), new SqlParameter("@eid", examId)); return total; } private static string SortChars(string s) { var arr = s.ToUpperInvariant().Where(char.IsLetterOrDigit).ToArray(); Array.Sort(arr); return new string(arr); }多选这里的关键在SortChars:考生按 A、C、D 顺序点,库里存的是"ACD";如果有人先点 D 再点 A,界面提交上来可能是"DAC"。不做排序直接比较,正确率会莫名其妙掉一截,这是最典型的「判分玄学」来源。半对给不给分?这套默认不给,要改就在ok判断那里加分支,改成比较交集数量按比例给分,GotScore字段已经支持小数,不用改表结构。
5. 避坑排查:上线前必须验的五件事
5.1 交卷前断电,答题记录全没了
现象:考试中途机房跳闸或者客户端崩溃,重进系统提示「未找到考试记录」,考生得重考。 原因:答题只在内存里攒着,SubmitPaper的时候才一次性批量写库。 解决:改成每次选择选项就调SaveAnswer落库,同时进考场时先查ExamRecords里有没有Status=0的同卷记录,有就带着ExamID恢复。恢复逻辑要重算剩余时间,别用本地变量。这条是我见过最贵的坑,一场四十人的考试重来一次,场面很难看。
5.2 组卷点一下按钮要等十几秒
现象:题库导入到两三万道以后,教师点「自动组卷」,界面卡住十几秒甚至超时。 原因:抽题 SQL 的WHERE条件没有走索引,NEWID()排序又把整张表拉进临时表。 解决:先确认IX_Questions_Course_Type索引存在,并且查询里的CourseID、QuestionType、IsDeleted三个条件顺序和索引列一致。参数类型要和列类型对齐,int 列别传字符串。题量再大就改成先SELECT QuestionID只取 ID 列,取回来在内存洗牌,题干大字段留在显示时按 ID 查。
5.3 DataGridView 数据改了界面不刷新
现象:题库窗体里点「保存修改」,数据库确实变了,表格里还是旧值。 原因:控件绑定的是List<T>,List不实现INotifyCollectionChanged,改了属性界面收不到通知。 解决:绑定改成BindingList<T>,实体类实现INotifyPropertyChanged,保存成功后调bindingList.ResetBindings(false)。或者更省事——保存成功后重新查一遍数据源赋值。我一般选后者,代码短,坑少。
5.4 判分结果偶尔多给分或少给分
现象:同一个考生同一份卷子,两次判分分数不一样。 原因:多选答案没有统一排序就去比较;或者PaperQuestions.Score和Questions.Score混用,判分取错了分值字段。 解决:所有写入StandardAnswer和UserAnswer的入口统一走一个NormalizeAnswer方法排序去重;判分 SQL 里明确SELECT pq.Score,不要写q.Score,字段前缀必须带表别名。查历史脏数据可以用SELECT * FROM AnswerDetails WHERE UserAnswer LIKE '%,%'之类先扫一遍。
5.5 考生把本机时间改早,考试永远不结束
现象:有人在考试中把 Windows 时间往回调,倒计时停住或者回退。 原因:剩余时间用DateTime.Now算,客户端时间被改就跟着变。 解决:剩余时间一律用「服务端时间」算。最简单的方式是在ExamRecords.StartTime用GETDATE()写入,恢复现场时用SELECT DATEDIFF(SECOND, StartTime, GETDATE())让数据库算已经过了多少秒,客户端只做展示。Windows 客户端在考场是普通用户权限,改时间本身需要管理员,但你不能假设每台机器都关了这个口子。
6. 二次开发改造:从单机考到整机房 60 人同时开考
拿到这套源码直接单人跑通不难,难的是把它推到机房用。我一般按三步改,每一步都能单独验证。
第一步,把连接串从localhost换成考场服务器 IP,然后验证三件事:服务器上 SQL Server 配置管理器里 TCP/IP 协议是否启用、1433 端口是否放行、SQL Server 是否允许「SQL Server 和 Windows 身份验证模式」。这三项任何一项没开,客户端报的都是含糊的「连接超时」,现场根本分不清是哪一层。验证方法很简单,在考场任意一台学生机上用 SSMS 连一次,能连上才算通。
第二步,处理并发写入。这套代码的SaveAnswer是每题一次独立连接,60 人同时答题时每秒可能上百次写入,一般撑得住,但交卷瞬间几十个人同时跑判分,会集中打在同一张AnswerDetails表上。改造思路是把判分放进一个事务里批量提交,或者干脆把判分从客户端挪到一个后台服务里排队处理——客户端交卷只写Status=1,成绩页显示「阅卷中」,过几秒刷新出分。下面这个改造点值得先做:
| 改造点 | 原实现 | 建议改法 | 验证方式 |
|---|---|---|---|
| 判分入口 | 客户端交卷即判 | 交卷只落库,判分由一个轮询线程统一跑 | 把判分线程停掉,确认交卷仍成功 |
| 答题写入 | 每次选择一次连接 | 每 3 秒批量提交一次,交卷时强制刷 | 拔网线,重连后答案还在 |
| 题目顺序 | 全考场同一套卷 | 按 ExamID 做随机种子打乱题序和选项序 | 两台机器对比选项顺序 |
| 防作弊 | 无 | 监听Deactivate事件记录切屏次数 | 手动切窗口,看日志是否记到 |
第三步,防作弊和题库导入。切屏检测用窗体的Deactivate事件累加计数,超过阈值在交卷时给标记,别直接强制交卷——误判一次就是投诉。题库批量导入用 NPOI 读 Excel,模板列就是 Questions 表的字段,导入前先在内存里做一遍「题干重复、答案不合法、分值越界」校验,通过再开事务批量插。导入失败要能定位到第几行,别只弹一句「导入失败」。
最后说个我自己的习惯。每次改完判分或者组卷逻辑,我都会手工造一份「标准卷」:20 道题、答案全对、预期满分,再用一份「故意错 5 道」的卷子跑一遍,两次分数必须严格等于手算值。这套回归用例不写进数据库,放在一个test_cases.sql里随代码走。吃过一次亏——改多选判分的时候把排序去掉了,单测没覆盖,期末考完才发现有人多选了反而拿满分。从那以后我每次动判分代码都强制走一遍这两份卷子,确认分差为零才提交。希望这套东西到你手里,能少走几段我走过的弯路。
本文还有配套的精品资源,点击获取