简介:涵盖学生、教师、管理员三个角色的C#在线考试系统完整源码包,基于Visual Studio 2010与SQL Server 2008环境开发,适合.NET方向学习者研究Web应用分层架构与在线教育业务实现。项目实现了自动生成试卷、教师自定义题目、学生管理、自动批改等完整流程,源码结构与业务模块划分清晰。压缩包共133个文件,以C#源代码(.cs)和ASP.NET页面(.aspx)为主,辅以CSS样式、数据库文件(.mdf/.ldf)及项目解决方案文件,整体仅1.66MB,便于快速下载与部署调试。目前已有1379人学习此资源。通过阅读代码可掌握C# Web开发、数据库设计与三层架构的实战技巧,也可在此基础上二次开发,定制适应自身教学或考试场景的功能模块。
1. 用C#开发在线考试系统,源码到手后别急着改界面
一位老师向我诉苦:每个单元测试都要手动凑题,单选题、多选题、判断题分别多少分,难度还不能一边倒。他想用C#写在线考试系统,把出题、发卷、答题、判分和成绩管理串起来。这类系统在高校课程设计和中小型培训机构里非常常见,市面上也流传着不少带源码的 .rar 包,打开就是一套 Visual Studio 解决方案。它的价值不在登录页做得好看,而在生成试卷的逻辑:随机抽题要保证不重复、难度分布合理、每套试卷的分值由题型自动算出来。适合谁呢?准备交课程设计的学生、需要给内部考试系统快速立项的教研组,以及想用现成代码二次开发的 .NET 工程师。我把做过这类系统的经验拆开讲,重点讲能照抄的代码和会翻车的细节。
2. 拆解系统结构与数据模型:四个核心表和两个项目分层
这类系统最容易被新手误解的,是以为“在线考试系统”的核心在页面。实际拿到一个能跑的 C# 源码后,我先看的是它的解决方案结构和数据库脚本。页面改得再花哨,组卷和判分的模型不对,后面接真实班级、真实题库时一定会推倒重来。这一章先把骨架说清楚。
2.1 拿到源码先看什么:解决方案文件、项目分层和引用关系
这类 .rar 源码包解压后,第一眼看到的通常不是项目文件,而是一层套一层的目录。我第一次接手时直接双击了里面的 .sln,结果 Visual Studio 报了一堆“找不到引用的包”,原因就是没解压就打开。正确顺序是先用 WinRAR 或者 7-Zip 解压到本地文件夹,7-Zip 完全能处理 .rar 格式,不用纠结装哪个。解压后找到后缀 .sln 的解决方案文件,它才是整个系统的入口。如果一个压缩包里有多个 .sln,优先选名字带 Web、Exam、Main 的那个,其余很可能是某个类库的独立工程。
打开解决方案后,不要急着点运行,先展开项目节点看分层。常见的在线考试系统会拆成两到三块:界面层负责教师端和学生端的页面,业务层负责组卷、判分、成绩统计,数据层负责访问 SQL Server 或者 Access。有些课程设计为了省事会把所有代码堆在 App_Code 里,那种结构后期改起来很痛苦,但也不是不能用,只要你能接受所有逻辑混在一起。下面是一个常见目录结构:
ExamOnline.sln # 解决方案入口 ExamOnline.Web # 教师端/学生端页面 ExamOnline.Core # 实体与公共方法 ExamOnline.Service # 组卷、判分、账号验证 ExamOnline.Data # SQL Server 数据访问 database install.sql # 建表与初始化数据 packages # 第三方库缓存这个结构里,真正值得优先读的是 ExamOnline.Service 和 ExamOnline.Core。页面文件看起来多,但很多是表单。若你只是想改功能,先定位 .sln 里的引用关系:右键项目、选引用,看哪些项目依赖哪些,通常顺序是 Web -> Service -> Data。改数据表时务必从下往上改:先改 Data 层 SQL,再改 Service 层实体,最后调整页面。不要跳过中间层直接放一段 SQL 在页面里,后面对接题库会翻车。
有一点我要单独强调:如果源码包是 ASP.NET Web Forms 写的,改起来和 MVC 有明显差异。Web Forms 的事件驱动机制适合快速做管理后台,但组卷、判分这类操作放在 .aspx.cs 里很难单测。我一般建议维持原项目的风格,不要中途强行引入 MVC,否则光是路由和控件生命周期就能劝退一拨人。反过来,如果是 MVC 项目,Controller 里要尽量薄,别把洗牌逻辑塞进去,尽量放到 Service 层。这样后面写并发模拟或者自动化测试,才不用启动整个网站。
2.2 数据库设计:题库表、试卷表、学生教师表和成绩表的字段取舍
如果源码包自带数据库脚本,优先用原设计;如果只带一个空的 .mdf 文件,也请在 SQL Server Management Studio 里把表结构导出来看一遍。没有脚本的话,可以按下面的基础表自己补。我以 SQL Server 为例,因为 C# 和 SQL Server 的组合最常见,也最容易迁移。
-- 题库表:一门课对应多道题,题型和难度用数字枚举 CREATE TABLE Question ( QuestionId INT IDENTITY PRIMARY KEY, CourseId INT NOT NULL, QuestionType TINYINT NOT NULL, -- 1单选 2多选 3判断 4简答 Difficulty TINYINT NOT NULL, -- 1易 2中 3难 ScoreValue DECIMAL(4,1) NOT NULL, -- 单题分值 Content NVARCHAR(1000) NOT NULL, OptionA NVARCHAR(500), OptionB NVARCHAR(500), OptionC NVARCHAR(500), OptionD NVARCHAR(500), Answer NVARCHAR(1000) NOT NULL ); -- 试卷表:保存一次组卷的结果,而不是动态生成的视图 CREATE TABLE Paper ( PaperId INT IDENTITY PRIMARY KEY, PaperName NVARCHAR(100) NOT NULL, TotalScore DECIMAL(5,1) NOT NULL, CreateTime DATETIME DEFAULT(GETDATE()) ); -- 试卷题目中间表:一试卷对多题,一题目出现在多套试卷 CREATE TABLE PaperQuestion ( PaperId INT NOT NULL, QuestionId INT NOT NULL, SequenceNo INT NOT NULL, ScoreValue DECIMAL(4,1) NOT NULL, PRIMARY KEY (PaperId, QuestionId) ); -- 学生答案表:选择题存提交内容,简答题存文本,教师可改分 CREATE TABLE StudentAnswer ( RecordId INT NOT NULL, QuestionId INT NOT NULL, StudentAnswer NVARCHAR(2000), Score DECIMAL(4,1), PRIMARY KEY (RecordId, QuestionId) ); CREATE TABLE Teacher ( TeacherId INT IDENTITY PRIMARY KEY, LoginName NVARCHAR(50) UNIQUE NOT NULL, PasswordHash NVARCHAR(200) NOT NULL ); CREATE TABLE Student ( StudentId INT IDENTITY PRIMARY KEY, StudentNo NVARCHAR(20) UNIQUE NOT NULL, StudentName NVARCHAR(50) NOT NULL, PasswordHash NVARCHAR(200) NOT NULL ); CREATE TABLE ExamRecord ( RecordId INT IDENTITY PRIMARY KEY, PaperId INT NOT NULL, StudentId INT NOT NULL, StartTime DATETIME, SubmitTime DATETIME, Score DECIMAL(5,1), IsPass BIT DEFAULT(0) );这套结构里有几个字段取舍值得细看。 QuestionType 和 Difficulty 用数字枚举,不要直接存“单选题”这样的中文,否则按题型统计、下拉框绑定、难度筛选都要做字符串比较,既容易写错也影响索引。 Answer 字段则必须统一格式:单选题用 A 或 B,多选题用 A,B,C,判断题用 对/错 或 T/F。最怕题库里既有 B,C 又有 BC,判分时你会疯掉。
PaperQuestion 表是整个在线考试系统的核心。很多课程设计只建一张 Paper 表,把题目用逗号拼接在一个字段里,这种设计查成绩、重做卷子时只能重新解析。用中间表能保留每道题在试卷里的顺序和生成时的分值。我在字段里特意没有让 PaperQuestion 存题干快照,但如果你要正式使用,建议额外加 ContentSnapshot 字段,把抽题时的题干、选项和答案一并复制进去,防止教师改题库后旧试卷不可复现。
2.3 表关系、索引与权限分离:为什么教师和学生不能共用一张账号表
在线考试系统里有三类账号:教师、学生、管理员。最简单的是建一张 User 表加 Role 字段,但我不推荐,原因不是性能,而是字段会越来越杂。教师端要管题库和试卷,学生端要记录学号、班级、年级,如果把 StudentNo、ClassName 塞进 User 表,Teacher 行里这些字段就是空的,逻辑上很脏。分开表的最大收益是权限边界清晰:Teacher 表就是登录教师后台,Student 表只进考试页面。源码包里如果只给了一张 User 表,也建议在二次开发时拆开。
表关系也很直白:Question 和 Paper 通过 PaperQuestion 多对多;Student 和 Paper 通过 ExamRecord 关联,每个学生每套试卷只保留一条考试记录;一条 Record 下挂多条 StudentAnswer。数据库建好后,补这几个索引:
CREATE INDEX IX_PaperQuestion_PaperId ON PaperQuestion(PaperId); CREATE INDEX IX_ExamRecord_StudentId ON ExamRecord(StudentId, PaperId); CREATE INDEX IX_ExamRecord_SubmitTime ON ExamRecord(SubmitTime);第一个索引服务组卷时按试卷编号取题目,第二个服务学生端“我的历史考试”列表,第三个服务教师端“今天哪些人交卷了”。很多源码只在主键上加索引,数据量两三百条时感觉不明显,题库和记录到几万条,交卷统计就会明显变慢。
密码字段我也多说一句:源码包如果直接存的是明文密码,那只能算教学版本,真上线前至少改成 Hash。C# 里最简单的是用 SHA256 加盐,不要自己写加密算法。数据库这一层最忌讳的是“想加功能就加列”,一次考试系统迭代下来,字段没规划好,后面 Group By 会写得非常痛苦。
3. 生成试卷的核心逻辑:从随机抽题到难度、分值的可控参数
生成试卷是标题里最值得拆的点,也是整套源码价值和翻车率最高的地方。很多老师不会在意登录页好不好看,但一定会在意“怎么每次抽出来的卷子难度不一样”“同一道题为什么在同一张卷子里出现两次”。这一章给你能直接抄走的两种抽题路线和一组可控参数。
3.1 抽题的两条技术路线:数据库NEWID()与C#洗牌算法对比
在线考试系统生成试卷,最简单粗暴的做法就是在数据库层面随机取题。这条路线对 SQL Server 非常好用,因为 ORDER BY NEWID() 每次执行都会产生不同的排序,取出来的题就是随机的。最小示例是这样的:
-- 从题库中随机抽 10 道单选题 SELECT TOP 10 QuestionId, Content, OptionA, OptionB, OptionC, OptionD, Answer, Difficulty FROM Question WHERE CourseId = @CourseId AND QuestionType = 1 ORDER BY NEWID();这段 SQL 的优点是代码量少、性能好,几千道题里取 10 道毫秒级完成。缺点是你一旦要控制难度比例,纯 SQL 会变得很绕。比如要求 10 道单选题里 3 道容易、4 道中等、3 道难,你可能会写三次查询:第一次 TOP 3 容易、第二次 TOP 4 中等、第三次 TOP 3 难。但这样有个隐蔽的坑:三次 ORDER BY NEWID() 是相互独立的,同一道题可能同时出现在“中等”和“难”的集合里,最终试卷里出现重复题目。要彻底避免,得把已经抽出的题号放到临时表,再用 NOT IN 排除,SQL 会长不少。
Access 项目则是另一个情况。Access 的随机排序依靠 Rnd 函数,每次返回的随机序列和查询计划有关,表现很不稳定。所以很多 C# 与 Access 做的课程设计,反而更愿意在 C# 内存里组卷,这也是我更喜欢的方式。
C# 侧的核心是洗牌算法,最常用的是 Fisher-Yates,代码很短:
// Fisher-Yates 洗牌,避免随机抽题出现明显重复 static void Shuffle<T>(List<T> list) { Random rng = new Random(Guid.NewGuid().GetHashCode()); for (int i = list.Count - 1; i > 0; i--) { int j = rng.Next(i + 1); T temp = list[i]; list[i] = list[j]; list[j] = temp; } }这里有两个细节。第一,不要用默认的 new Random() 反复创建,尤其在循环里连续调用时,时间种子相同会导致多次抽到同样的序列。用 Guid.NewGuid().GetHashCode() 做种子能大幅减少这种情况。第二,如果系统会并行生成多套试卷,Random 实例最好在方法内部创建,不要做成静态共享,否则多线程同时调用会有竞争问题。
C# 内存抽题的优点是你能拿到完整的题目列表,想按难度、题型、知识点做任何分组都方便,也不会出现跨查询重复。缺点是题目量大时内存占用高一点,但一个课程的题库通常不超过一万题,加载到内存完全没压力。真正要注意的是不要把题库全表一次性 Load 到内存,至少用 CourseId 和 QuestionType 做过滤,取当前课程、当前题型的数据。
3.2 一个能控制题型、难度、分值的组卷方法
下面这个方法是我在类似系统里常用的骨架,直接放在 Service 层。它做的事是:按课程抽题、按难度比例 3:5:2 分配、把抽题结果写进 PaperQuestion,并累加总分。
/// <summary>按课程、各题型数量生成一份试卷</summary> public Paper GeneratePaper(int courseId, int singleCount, int multiCount, int judgeCount) { Paper paper = new Paper { PaperName = $"自动化试卷 {DateTime.Now:yyyyMMddHHmmss}", CreateTime = DateTime.Now }; decimal totalScore = 0; int seq = 0; AppendQuestions(courseId, QuestionType.Single, singleCount, paper, ref seq, ref totalScore); AppendQuestions(courseId, QuestionType.Multi, multiCount, paper, ref seq, ref totalScore); AppendQuestions(courseId, QuestionType.Judge, judgeCount, paper, ref seq, ref totalScore); paper.TotalScore = totalScore; return paper; } private void AppendQuestions(int courseId, int questionType, int count, Paper paper, ref int seq, ref decimal totalScore) { if (count <= 0) return; var questions = _questionDao.GetByCourseAndType(courseId, questionType); if (questions.Count < count) { throw new InvalidOperationException("题库题量不足,无法生成该题型"); } // 按难度分组:1易 2中 3难,默认权重 3:5:2 var byDifficulty = new Dictionary<int, List<Question>>(); foreach (var q in questions) { if (!byDifficulty.ContainsKey(q.Difficulty)) byDifficulty[q.Difficulty] = new List<Question>(); byDifficulty[q.Difficulty].Add(q); } int targetEasy = (int)Math.Round(count * 3.0 / 10.0); int targetMid = (int)Math.Round(count * 5.0 / 10.0); int targetHard = count - targetEasy - targetMid; // 某个难度没有题时,从整体题库补缺口 var selected = new List<Question>(); if (byDifficulty.ContainsKey(1)) { Shuffle(byDifficulty[1]); selected.AddRange(byDifficulty[1].Take(targetEasy)); } if (byDifficulty.ContainsKey(2)) { Shuffle(byDifficulty[2]); selected.AddRange(byDifficulty[2].Take(targetMid)); } if (byDifficulty.ContainsKey(3)) { Shuffle(byDifficulty[3]); selected.AddRange(byDifficulty[3].Take(targetHard)); } if (selected.Count < count) { var hasIds = new HashSet<int>(selected.Select(q => q.QuestionId)); var fill = questions.Where(q => !hasIds.Contains(q.QuestionId)) .OrderBy(q => Guid.NewGuid()) .Take(count - selected.Count); selected.AddRange(fill); } foreach (var q in selected) { seq++; paper.Questions.Add(new PaperQuestion { SequenceNo = seq, QuestionId = q.QuestionId, ScoreValue = q.ScoreValue }); totalScore += q.ScoreValue; } }调用方式很直接:
var paper = examService.GeneratePaper(courseId: 101, singleCount: 10, multiCount: 5, judgeCount: 10); paperDao.Save(paper);参数说明:singleCount 是单选数量,multiCount 是多选数量,judgeCount 是判断数量,数值由前端教师页面传入。难度权重 3:5:2 写死在代码里,你如果希望做成可配置项,可以把数组提出来放到课程表里,做成 SelectedDifficultyRatios 字段,组卷时读取。
这个方法里还有一个容易忽略的设计:题干难度不足时用剩余题随机补。补题逻辑里 OrderBy(Guid.NewGuid()) 也是一种随机排序,但它在内存中为每条记录生成 GUID,如果补题数量很少,性能没问题。真正要注意的是,补题可能把难度比例破坏掉,所以生产环境中我更愿意在题库录入时做校验:每个课程每种题型至少保证一定数量的容易题、中等题和难题,否则干脆不允许组卷。
保存这一步非常关键。生成试卷之后必须把 Paper 和 PaperQuestion 一次性落库,不要返回一个临时对象给前端预览。原因在于:教师预览时看到的卷子和学生实际答题时看到的卷子必须是同一套。如果每次请求都重新走一遍 GeneratePaper,等于刷新一次就换一套题,学生会疯掉,教师也没法核对答案。所以我在 System 里强调:PaperQuestion 是快照表,不是查询结果,保存后永远不要自动覆盖。
4. 学生与教师的管理闭环:题库导入、答题计时、交卷判分怎么落地
在线考试系统不只是“抽题生成试卷”这一个功能,它还要围着教师和学生把整个流程闭环:教师导入题库、安排考试,学生登录答题、交卷,系统判分并导成绩。这一章讲可以直接落到代码里的部分。
4.1 教师端:题库导入和成绩导出的代码骨架
教师端的功能清单很长,但真正容易卡住的是两个:Excel 批量导入题库,以及把成绩导出成 CSV 给教务。手动一道题一道题录题库效率太低,教师端一般都要给一个“下载模板、上传 Excel”的入口。
用 C# 读 Excel 常见有两种方案:OleDb 连接字符串,或者 EPPlus 操作 xlsx。EPPlus 不需要在服务器上装 Office,遇到混合类型单元格时更稳,但要引 NuGet 包。OleDb 不依赖第三方库,老项目里很常见,我用 OleDb 写过一版导入:
public int ImportQuestionsFromExcel(string excelPath, int courseId) { string connStr = "Provider=Microsoft.ACE.OLEDB.12.0;" + "Data Source=" + excelPath + ";" + "Extended Properties='Excel 12.0 Xml;HDR=YES;IMEX=1;'"; var list = new List<Question>(); using (var conn = new OleDbConnection(connStr)) using (var cmd = new OleDbCommand("SELECT * FROM [Sheet1$]", conn)) { conn.Open(); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { list.Add(new Question { CourseId = courseId, QuestionType = Convert.ToInt32(reader["题型"]), Difficulty = Convert.ToInt32(reader["难度"]), Content = reader["题干"].ToString(), OptionA = reader["选项A"].ToString(), OptionB = reader["选项B"].ToString(), OptionC = reader["选项C"].ToString(), OptionD = reader["选项D"].ToString(), Answer = reader["答案"].ToString().Trim() }); } } } // 批量写入数据库,用事务包裹避免导入到一半失败 using (var tx = _connection.BeginTransaction()) { foreach (var q in list) { _questionDao.Insert(q, tx); } tx.Commit(); return list.Count; } }逻辑说明:这里读的是 Sheet1,Excel 模板里列名必须固定为“题型”“难度”“题干”“答案”等,否则 reader["题干"] 会抛异常。连接字符串里 IMEX=1 的作用是告诉驱动把整列按文本处理,避免“数字、文本混合的列被截断”。但 IMEX=1 也不是万能神药,某些情况下数值列仍会被读成丢失精度的样子,所以我更建议模板里把所有题干和选项列提前设置成“文本”格式,让导入不受 Jet 引擎的类型猜测影响。
成绩导出也类似,我习惯导出 CSV 而不是 Excel,因为 CSV 是纯文本,不依赖外部组件。下面是导出成绩的代码骨架:
void ExportScoresToCsv(string filePath, List<ExamRecord> records) { var sb = new StringBuilder(); // 加 BOM,Excel 打开 UTF-8 中文才不会乱码 var utf8Bom = new UTF8Encoding(true); sb.AppendLine("学号,姓名,试卷,得分,是否及格"); foreach (var r in records) { string studentName = r.StudentName.Replace(",", ","); // 避免把 CSV 列拆坏 string paperName = r.PaperName.Replace(",", ","); sb.AppendLine($"{r.StudentNo},{studentName},{paperName},{r.Score},{r.IsPass}"); } File.WriteAllText(filePath, sb.ToString(), utf8Bom); }这段代码里最容易被忽略的是 CSV 转义。学生姓名、试卷名里如果出现英文逗号,直接拼字符串会导致导出的文件列错位。更稳妥的做法是把每个字段用双引号包起来,里面的双引号换成两个双引号。我这里用中文逗号替换只是应急方案,真正要落地建议写成 CsvEscape 方法,否则教务拿到的 Excel 会很乱。
这里也顺便提一个检查点:导入题库时一定要校验题型和难度取值范围。你在页面上放的是下拉框,但 Excel 里用户可以随便填,直接 Convert.ToInt32 遇到“容易”两个字就崩,遇到“4”这种不存在的题型也会让后面组卷异常。所以导入逻辑里加一段:
if (q.QuestionType < 1 || q.QuestionType > 4) throw new FormatException($"第 {index} 行题型不合法:{q.QuestionType}"); if (q.Difficulty < 1 || q.Difficulty > 3) throw new FormatException($"第 {index} 行难度不合法:{q.Difficulty}");这段小校验能省下很多排查时间。经常有老师反馈“导入时报错”,但真正原因是模板里多了一个空格、数字被存成了文本。导入失败时不要只给一个“系统异常”,至少要告知是第几行出了问题。
4.2 学生端:限时答题、自动交卷和判分逻辑
学生端最核心的约束是时间。考试一旦开始,页面就要倒计时,到点强制交卷。很多源码会把倒计时放在 JavaScript 里,到了 0 秒禁用答案控件再调用后台交卷接口。这个方案看起来简单,但有一个隐患:如果学生开了两个页面,或者网络断了几分钟,浏览器的倒计时和服务器的考试时长就会偏离。
我一般会让学生端在进入考试时通过接口拿到服务端计算好的剩余秒数:
public int GetRemainingSeconds(int examId, int studentId) { var record = _examRecordDao.GetCurrentRecord(examId, studentId); if (record == null) return 0; var endTime = record.StartTime.AddMinutes(examDurationMinutes); var remain = (int)(endTime - DateTime.Now).TotalSeconds; return Math.Max(remain, 0); }前端拿到剩余秒数后做倒计时。这样做即使学生刷新页面,剩余时间也不会重置。更严谨的做法是服务端在交卷时再次校验时间,防止客户端把剩余时间改成很大的值。有的人觉得这是小题大做,但正式考试系统里这是一个高频漏洞。
交卷判分的逻辑也要放后端。选择题可以直接判,简答题要人工批阅,所以判分方法里要跳过简答题:
public decimal ScorePaper(int paperId, int studentId, Dictionary<int, string> answers) { var paperQuestions = _paperDao.GetPaperQuestions(paperId); decimal totalScore = 0; foreach (var pq in paperQuestions) { if (pq.QuestionType == 4) continue; // 简答题,由教师人工批改 if (!answers.TryGetValue(pq.QuestionId, out string studentAnswer)) continue; // 未作答记 0 分 string normalized = NormalizeAnswer(studentAnswer); if (string.Equals(normalized, pq.CorrectAnswer, StringComparison.OrdinalIgnoreCase)) { totalScore += pq.ScoreValue; } } return totalScore; } private string NormalizeAnswer(string answer) { // 多选题答案统一成 A,B,C 形式,并排序,避免顺序不同判成錯 string[] parts = answer.Split(new[] { ',', ',' }, StringSplitOptions.RemoveEmptyEntries); Array.Sort(parts, StringComparer.Ordinal); return string.Join(",", parts); }这里最容易翻车的就是多选题。题库里存的答案可能是 B,A,C,学生提交的是 C,A,B,肉眼能看出他选对了,但字符串比较会判错。所以 NormalizeAnswer 核心是:把答案按逗号或中文逗号拆开、排序、再拼接。判断题也要统一,题库用“对/错”还是“T/F”必须二选一,让 NormalizeAnswer 把 T 转成 对、F 转成 错,再比较。如果不在这一层统一,后续导出成绩时你会发现一批判 0 分的学生实际上是做对了的。
交卷的写入流程要加事务。学生交卷瞬间要同时更新 ExamRecord 总分、逐条插入 StudentAnswer,任何一条失败都不能让总分落库。代码骨架:
using (var tx = _connection.BeginTransaction()) { _examRecordDao.UpdateScore(recordId, totalScore, tx); foreach (var ans in answerList) { _studentAnswerDao.Insert(recordId, ans.QuestionId, ans.RawAnswer, tx); } tx.Commit(); }事务是交卷的“后悔药”,哪怕中途断电,也不会出现学生答案没存上但分数已经记录的情况。
5. 部署上线与避坑:.rar 解压到连库跑通的 5 个常见问题
很多源码包最终卡在跑不起来这一步,不是代码写得不好,而是环境配置和数据库连接没有落地。这一章按部署顺序写,并把最常见的 5 个坑按“现象、原因、解决”列全。
5.1 解压、还原与打开:从 .rar 到能编译的解决方案
下载到的源码是个 .rar 文件,常见问题是“7-Zip 可以解压 rar 文件吗”,当然可以,WinRAR 也可以。记住不要在压缩包里直接双击 .sln,Visual Studio 对只读路径下的项目非常敏感,NuGet 还原和 bin 输出都会报异常。先解压到一个干净的目录,比如 D:\Work\ExamOnline,路径里尽量别带中文和空格。
打开 .sln 后,Visual Studio 可能会在顶部提示缺少 NuGet 包。常见操作是:右键解决方案,选“还原 NuGet 包”。如果本机处于内网环境连不上 nuget.org,包还原会失败。这时候先看源码包里有没有 packages 文件夹,如果有,项目会从 packages 里找依赖。不要试图手动把 DLL 拖到 bin 目录,那只能解决编译,解决不了项目引用。
编译通过后先别急着点 IIS Express 启动。先看项目属性里的目标框架,很多老源码是 .NET Framework 4.5 或 4.8,如果你本机没有对应版本,编译会提示“找不到 .NETFramework,Version=v4.8”。解决方法是安装对应的 Developer Pack,或者右击项目属性,把目标框架改成你已有的版本。但改框架版本要谨慎,如果代码里用了高版本 API,改低版本会编译报错。我的习惯是先装对应 Developer Pack,不要上来就降级。
5.2 连接字符串与数据库初始化:让程序认得上数据库
C# 项目的连接字符串通常写在 Web.config 或 App.config 里,在系统里搜关键字 connectionStrings 就能找到。SQL Server 的常见配置是这样的:
<configuration> <connectionStrings> <add name="ExamDB" connectionString="Server=.\SQLEXPRESS;Database=ExamOnline;Integrated Security=True;" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>如果源码包自带 database 目录下的 install.sql,用 SQL Server Management Studio 打开执行,先建库再建表。如果没有脚本,只有一个 .bak 备份文件,需要右键数据库、选择“还原数据库”。我遇到过一个最容易踩的坑:脚本里的库名叫 ExamOnline,连接字符串里写的也是 ExamOnline,但实际安装的 SQL Server 默认实例名不是 SQLEXPRESS,而是本机名加实例名,导致连不上。这时先把 Server 改成 (local) 或者 .,再把 Integrated Security 改成 True,优先用 Windows 身份验证跑通。真需要用账号密码登录,再去 SQL Server 配置管理器里启用混合认证,并设置 sa 密码。
如果你拿到的是支持 Access 数据库的源码,连接字符串会完全不一样,常见是这样:
<add name="ExamDB" connectionString="Provider=Microsoft.ACE.OLEDB.12.0;Data Source=D:\ExamOnline\exam.accdb;" providerName="System.Data.OleDb" />Access 版本部署要特别注意服务器上是否装了对应驱动,以及 ASP.NET 进程对 .accdb 文件是否有写权限。写到这一步,很多人发现页面能开,但登录就报“未实现”或“找不到 OLEDB 提供程序”,那基本是驱动没装。
5.3 高频踩坑记录:现象、原因、解决
我在处理同类源码时,几乎每次都会遇到下面几类问题,按“现象、原因、解决”写上,你可以直接对照。
第一类:生成试卷时同一道题出现两次。现象是教师在预览试卷时看到题干完全一样的题目,看了半天以为是题库录入重复了。原因是如果抽题用多次 ORDER BY NEWID(),每次查询独立随机,题目没有做排除集合。解决是改用 C# 洗牌一次成型,或者在 SQL 临时表里把已抽出的题号排除。千万不要让用户通过刷新页面来解决,刷新一次换一套题的根源也在这里。
第二类:组卷报错 KeyNotFoundException。现象是点“生成试卷”后页面报错,指向 byDifficulty[1] 一行。原因是题库里根本没有难度为 1 的题,字典没有这个键。解决是编码时用 TryGetValue 或者 ContainsKey,并且给出业务提示:“当前题库容易题不足,请先录入容易题”。这个提示比“系统异常”有用得多。
第三类:学生交卷后成绩为 0,但明明做了题。现象是某个学生成绩突然是 0,查数据库发现他的答案确实存在。原因是多选答案排序不一致,或者题库里判断题答案写的是“T”,学生端提交的是“对”。解决是判分前统一 NormalizeAnswer,把答案拆开排序再比较;同时把题库答案格式做一遍清洗,避免历史数据里混有两种格式。
第四类:IIS 部署后页面中文乱码。现象是本地开发都正常,部署到服务器后学生端姓名变成乱码。原因是服务器响应编码不是 UTF-8,或者页面编码和数据库编码不一致,常见的是从 Access 读出来是 GB2312,Web.config 没有配 globalization。解决是在 Web.config 里设置:
<system.web> <globalization requestEncoding="utf-8" responseEncoding="utf-8" fileEncoding="utf-8" /> </system.web>如果还是乱码,看一下数据库排序规则是不是 Chinese_PRC_CI_AS,一般用 UTF-8 到 SQL Server 保存不会有问题,问题大多出在页面编码。
第五类:考试结束瞬间并发交卷把服务器卡死。现象是 20 个学生同时点交卷,页面转圈几十秒,甚至有事务超时。原因是交卷时同时更新成绩和插入答案,数据库连接和事务没有控制好,或者 ExamRecord 表缺少索引,导致大量更新阻塞。解决是给 ExamRecord(StudentId, PaperId) 建立索引,交卷逻辑使用短事务并做连接池配置,必要时在连接字符串里加 Max Pool Size=200。
部署上线这件事,最怕的不是代码 bug,而是环境差异。如果你在本地能跑、换台机器跑不了,优先查连接字符串、目标框架、NuGet 包和数据库脚本,不要一开始就去翻业务代码。
6. 把抽题算法往前推一步:难度校验与并发模拟验证
跑通之后,我强烈建议你多花半小时做两件事:验证抽题结果的难度分布,以及模拟并发交卷。这两步能提前暴露上线后才会出现的坑。
难度分布直接用 SQL 统计多套试卷就能看出来,比如检查已经生成的 10 套试卷里,容易题和难题的分值占比:
SELECT p.PaperId, SUM(CASE WHEN q.Difficulty = 1 THEN pq.ScoreValue ELSE 0 END) AS EasyScore, SUM(CASE WHEN q.Difficulty = 3 THEN pq.ScoreValue ELSE 0 END) AS HardScore, COUNT(*) AS QuestionCount FROM PaperQuestion pq JOIN Question q ON q.QuestionId = pq.QuestionId JOIN Paper p ON p.PaperId = pq.PaperId GROUP BY p.PaperId;如果发现某些试卷 HardScore 明显偏高,说明组卷时的补题逻辑正在把缺口填向难题,这时候要回去调整权重参数,或者补充题库。
并发交卷我习惯写一个小的 C# 控制台测试,用 Task 模拟 20 个学生同时提交:
var tasks = Enumerable.Range(1, 20).Select(i => Task.Run(() => { var answers = BuildAnswersForStudent(i); var result = _examService.SubmitPaper(paperId, studentId: i, answers); Console.WriteLine($"学生 {i} 交卷结果:{result.Score}"); })).ToArray(); Task.WaitAll(tasks);测试前先看数据库连接池参数,SQL Server 默认连接池上限是 100,20 并发不会爆,但如果你自己改了连接字符串,别把 Max Pool Size 调得太小。这个测试的目的不是性能压测,而是确认事务不会互相锁死。
我做这类系统时最深刻的教训是:界面做得再漂亮,组卷逻辑和交卷事务才是命根子。第一次上线时老师反映同一套题出现重复,我排查了一下午,最后发现是两次 NEWID() 之间没有排除已选集合;第二次是并发交卷把数据库锁了,从那以后我养成了两个习惯:任何组卷改动先跑一边随机分布统计,任何交卷改动先模拟 20 人同时提交。这套方法也帮你把源码包从“能跑”推进到“敢用”。做考试系统就是这样,日常功能再花哨,到了考试那一刻,稳定和公平永远排第一。希望帮到你。
本文还有配套的精品资源,点击获取