ARTICLE DETAIL

资讯详情

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

C#考试题目生成系统核心:基于SQL Server的自动组卷与AB卷实现

C#考试题目生成系统核心:基于SQL Server的自动组卷与AB卷实现 简介一套基于C#与SQLServer的考试题目生成系统适用于课程设计或毕业设计中的试卷管理场景。系统可自动生成试卷及A/B卷支持按需勾选知识点范围、指定选择题或判断题等题型并自定义题目难度与各题型分值满足差异化组卷需求。资源打包为zip格式压缩包约285MB内含项目源码、数据库脚本及配套文档便于本地部署与二次开发。目前已有560人学习下载可作为理解WinForm界面操作、数据库表设计、随机抽题算法与导出打印流程的完整参考。借助本套代码读者可快速搭建可运行的考试组卷工具也可在其基础上扩展批量导题、在线答题、试卷导出等功能。1. C# 考试题目生成系统在课程设计里到底做什么自动组卷才是核心课程设计做 C# 考试题目生成系统很多人交上去的代码只是把 SQL Server 里的题随机抽出来拼成一张卷子答辩时被老师问一句“你的 A 卷和 B 卷差别在哪”就卡住了。这个系统的难点不在增删改查而在组卷如何让数据库里的题目按知识点和难度分布被抽出来如何把同一份知识点分布映射成 A、B 两套不等同的卷子。下面会从 SQL Server 的表结构讲到 C# 的分层抽题算法再用一段排除重复题的 AB 卷生成代码收尾适合正在做课程设计、毕业设计或者打算把小型考试系统接到 .NET 项目里的同学直接照做。2. 先把 SQL Server 表结构定好四张表和题量准备2.1 四张表怎么拆题库、试卷、试卷明细和知识点常见的翻车是把题型、难度、知识点字段直接塞在 Question 表里组卷规则写在 C# 代码的 if-else 里这样改一次规则就要改一遍代码答辩也讲不清。正确做法是把组卷规则数据化至少拆成四张表KnowledgePoint知识点表只存名称组卷时按知识点 ID 定位题目标签。Question题目表包含正文、选项、答案、难度、题型、所属知识点 ID。Paper试卷主表保存 A 卷 / B 卷的基础信息。PaperDetail试卷明细表保存每张卷子里有哪些题、题目顺序。建表 SQL 如下CREATE TABLE KnowledgePoint ( KnowledgePointId INT IDENTITY(1,1) PRIMARY KEY, KnowledgePointName NVARCHAR(100) NOT NULL ); CREATE TABLE Question ( QuestionId INT IDENTITY(1,1) PRIMARY KEY, KnowledgePointId INT NOT NULL REFERENCES KnowledgePoint(KnowledgePointId), QuestionType TINYINT NOT NULL, -- 1 单选 2 多选 3 判断 4 填空 5 简答 Difficulty TINYINT NOT NULL, -- 1 易 2 中 3 难 Content NVARCHAR(500) NOT NULL, OptionA NVARCHAR(200) NULL, OptionB NVARCHAR(200) NULL, OptionC NVARCHAR(200) NULL, OptionD NVARCHAR(200) NULL, Answer NVARCHAR(500) NOT NULL, Score DECIMAL(4,1) NOT NULL ); CREATE TABLE Paper ( PaperId INT IDENTITY(1,1) PRIMARY KEY, PaperName NVARCHAR(100) NOT NULL, PaperType CHAR(1) NOT NULL, -- A 或 B TotalScore DECIMAL(5,1) NOT NULL, CreatedAt DATETIME DEFAULT GETDATE() ); CREATE TABLE PaperDetail ( PaperDetailId INT IDENTITY(1,1) PRIMARY KEY, PaperId INT NOT NULL REFERENCES Paper(PaperId), QuestionId INT NOT NULL REFERENCES Question(QuestionId), OrderNo INT NOT NULL ); CREATE INDEX IX_Question_KpDiff ON Question(KnowledgePointId, Difficulty); CREATE INDEX IX_PaperDetail_Paper ON PaperDetail(PaperId);这里有几个设计选择要说明。第一知识点必须独立成表不要直接在 Question 里存字符串否则同一知识点会被写成“数据结构”“DATA STRUCTURE”“数据结构与算法”三种脏数据组卷规则就彻底没法按知识点对齐。第二Question.ShowCode 和 PaperDetail.OrderNo 分开存题目表只描述题目本身的属性题目进卷之后再决定顺序和分值这样 A、B 卷即使换了题OrderNo 重新赋值即可。第三Question 表里冗余了 Score 字段课程设计的卷子通常按题型给统一分值冗余一份到题目表可以少做一次 JOIN如果以后要做“同一道题在不同卷子分值不同”再把 Score 迁移到 PaperDetail 也不迟。2.2 连接字符串与 SQL Server 配置先把库连上课程设计最常见的翻车点是程序写完了库连不上。先给出最常用的本地连接字符串// SQL Server 本地实例SQLEXPRESS 实例名连接字符串 string connString Data Sourcelocalhost\SQLEXPRESS; Initial CatalogExamDB; Integrated SecurityTrue; TrustServerCertificateTrue;;连接字符串里的 DataSource 最容易被照抄出错。localhost\SQLEXPRESS 只适用于安装时选了“命名实例 SQLEXPRESS”的情况如果安装的是默认实例DataSource 要写 localhost 或一个小圆点 .。拿不准就先在命令行里跑一句sqlcmd -L这个命令会列出本机所有可见实例。看到类似 localhost\SQLEXPRESS 的条目就把 DataSource 改成对应值如果只看到一个 MSSQLSERVER说明是默认实例DataSource 写 localhost 即可。很多同学安装 SQL Server 2016 / 2017 / 2019 时遇到过“无法找到数据库引擎启动句柄”这类报错这其实是服务没有成功启动不是连接字符串的问题。处理顺序是先打开 SQL Server 配置管理器确认 SQL Server (MSSQLSERVER) 服务正在运行再确认 SQL Server 网络配置里的 TCP/IP 协议已启用最后重启服务。注意配置管理器里改了协议后必须重启 SQL Server 服务才会生效这是经常会漏的一步。2.3 塞入可用的题量先统计后抽题组卷算法再正确题库没题量也白搭。生成试卷之前先跑一遍统计看看每个“知识点 × 难度 × 题型”组合下有多少可用题SELECT KnowledgePointId, Difficulty, QuestionType, COUNT(*) AS Cnt FROM Question GROUP BY KnowledgePointId, Difficulty, QuestionType HAVING COUNT(*) 10 ORDER BY Cnt;这个 HAVING 条件里的 10 是保险系数。AB 卷同知识点同难度要各抽 N 题且不能重复所以该组合的题量至少要有 2N。如果规则要求一个组合各抽 5 题那少于 10 题就需要补题。补题最省力的方式是写一个 C# 导入工具用 DataTable 承接 Excel 内容再交给 SqlBulkCopy 批量写入using (var bulk new SqlBulkCopy(connString)) { bulk.DestinationTableName Question; bulk.BatchSize 500; bulk.WriteToServer(questionDataTable); }这里要注意 DataTable 的列顺序和 Question 表的字段顺序保持一致否则 SqlBulkCopy 会按位置对应列导致答案写进题干这种诡异问题。Common practice 是先 SELECT TOP 0 * FROM Question 拿一个空结构再往 DataTable 里加行这样列顺序永远不会错。3. C# 自动组卷核心算法按知识点和难度分层抽题3.1 为什么不能一把 NEWID() 到底分布会失控SQL Server 里随机取 N 条的经典写法是 ORDER BY NEWID()这句话本身没有错错在只靠它组整张卷子。假设试卷规则要求知识点 1 出 5 题、知识点 2 出 5 题、知识点 3 出 5 题难度全部为“中”。直接用一条 TOP 15 ORDER BY NEWID() 抽取最终结果可能 8 道题都落在知识点 1因为随机抽样天然有聚集性数据量越小越明显。课程设计答辩时老师会拿规则表逐项对比你的卷子分布不均匀一眼就被看出来。正确的思路是把组卷拆成“先定规则再逐规则取题”。每条规则指定一个知识点和一个难度再指定取题数量规则之间互不干扰。这样 A 卷和 B 卷只要使用同一套规则知识点覆盖率和难度分布就天然一致后面的一切问题都变成“如何让具体题目不重复”。3.2 把组卷需求翻译成规则表知识点 × 难度 × 题量在 C# 里定义一个 PaperRule 类把组卷需求变成可遍历的数据结构public class PaperRule { public int KnowledgePointId { get; set; } public int Difficulty { get; set; } // 1 易 / 2 中 / 3 难 public int Count { get; set; } // 本组合抽几题 }规则怎么来两个常见做法一是从界面勾选按知识点和难度组合设置题量二是读取一张 RuleTemp 临时表。我一般推荐第二种因为课程设计报告里能写清楚“规则数据化”这个设计点。规则表的字段就是 KnowledgePointId、Difficulty、Count生成试卷时一次性读进 List 抽题算法只依赖这个列表不依赖任何界面逻辑方便单元测试。3.3 分层抽题核心代码逐组合查询并排除已抽题这是整个系统的核心方法输入是规则列表和需要排除的试卷 ID输出是题目列表public ListQuestion DrawByRules( SqlConnection conn, SqlTransaction tran, ListPaperRule rules, int excludePaperId) { var result new ListQuestion(); foreach (var rule in rules) { string sql SELECT TOP (count) q.QuestionId, q.Content, q.OptionA, q.OptionB, q.OptionC, q.OptionD, q.Answer, q.Difficulty, q.KnowledgePointId, q.QuestionType, q.Score FROM Question q WHERE q.KnowledgePointId kpId AND q.Difficulty diff; // 排除指定试卷已用过的题AB 卷关键就在这里 if (excludePaperId 0) { sql AND NOT EXISTS ( SELECT 1 FROM PaperDetail pd WHERE pd.QuestionId q.QuestionId AND pd.PaperId excludePaperId ); } sql ORDER BY NEWID();; using (var cmd new SqlCommand(sql, conn, tran)) { cmd.Parameters.Add(count, SqlDbType.Int).Value rule.Count; cmd.Parameters.Add(kpId, SqlDbType.Int).Value rule.KnowledgePointId; cmd.Parameters.Add(diff, SqlDbType.TinyInt).Value rule.Difficulty; if (excludePaperId 0) { cmd.Parameters.Add(excludePaperId, SqlDbType.Int).Value excludePaperId; } using (var reader cmd.ExecuteReader()) { while (reader.Read()) { result.Add(new Question { QuestionId reader.GetInt32(0), Content reader.GetString(1), OptionA reader.IsDBNull(2) ? null : reader.GetString(2), OptionB reader.IsDBNull(3) ? null : reader.GetString(3), OptionC reader.IsDBNull(4) ? null : reader.GetString(4), OptionD reader.IsDBNull(5) ? null : reader.GetString(5), Answer reader.GetString(6), Difficulty reader.GetByte(7), KnowledgePointId reader.GetInt32(8), QuestionType reader.GetByte(9), Score reader.GetDecimal(10) }); } } } } return result; }这段代码有三个关键点。第一每条规则单独查询确保每个知识点和难度组合的题量严格符合规则不会出现知识点的聚集。第二excludePaperId 参数让同一个方法既能生成 A 卷也能生成 B 卷A 卷传 0 不排除任何题B 卷传 A 卷的 PaperIdSQL 里的 NOT EXISTS 子查询把 A 卷已用题目全部挡在外面。第三所有参数都用 SqlParameter 传入不要用字符串拼接否则题目内容里出现单引号时整个 SQL 就会报错。这个方法每执行一条规则就是一次数据库往返规则多时有几十次往返对于课程设计这个量级完全够用。如果将来题库达到几万题并且规则上百条可以把多条规则拼成 UNION ALL 查询但那样参数化会变得很复杂先用跑通的简单版本性能不够再优化。4. AB 卷生成策略同知识点换题而不是打乱题序4.1 三种常见 AB 卷做法对比AB 卷看起来简单做起来有讲究。常见做法有三种做法实现要点优点答辩风险纯换序A 卷生成后复制明细随机打乱 OrderNo实现只要一行 SQL老师对比两卷第一题就知道是同一套题同知识点换题B 卷沿用相同规则排除 A 卷已用题目 ID知识点覆盖率和难度分布完全一致卷面无重复题库量不足时会抽不齐混合策略单选多选换题简答论述换序效果好规则复杂容易把两卷难度偏差做出来纯换序是很多人的第一反应也是最容易在答辩翻车的方案。同知识点换题把“等价性”建立在规则上而不是题目上A 卷和 B 卷的每个知识点题量完全一致难度比例完全一致但具体题目不同这是性价比最高的方案推荐优先采用。4.2 B 卷抽题时排除 A 卷题号完整生成流程现在把 3.3 的 DrawByRules 接到 AB 卷生成主流程里public void GenerateABPapers(string paperBaseName, ListPaperRule rules) { using (var conn new SqlConnection(_connString)) { conn.Open(); using (var tran conn.BeginTransaction()) { try { // 先插试卷主表拿到 A 卷 PaperId int paperAId CreatePaper(conn, tran, paperBaseName _A, A); // A 卷不排除任何题 var questionsA DrawByRules(conn, tran, rules, 0); InsertPaperDetails(conn, tran, paperAId, questionsA); // B 卷排除 A 卷已用题目实现同知识点换题 int paperBId CreatePaper(conn, tran, paperBaseName _B, B); var questionsB DrawByRules(conn, tran, rules, paperAId); InsertPaperDetails(conn, tran, paperBId, questionsB); tran.Commit(); } catch { tran.Rollback(); throw; } } } }CreatePaper 负责插入 Paper 表并返回新 IDInsertPaperDetails 负责把题目按顺序写入 PaperDetailprivate void InsertPaperDetails( SqlConnection conn, SqlTransaction tran, int paperId, ListQuestion questions) { for (int i 0; i questions.Count; i) { string sql INSERT INTO PaperDetail (PaperId, QuestionId, OrderNo) VALUES (paperId, questionId, orderNo);; using (var cmd new SqlCommand(sql, conn, tran)) { cmd.Parameters.Add(paperId, SqlDbType.Int).Value paperId; cmd.Parameters.Add(questionId, SqlDbType.Int).Value questions[i].QuestionId; cmd.Parameters.Add(orderNo, SqlDbType.Int).Value i 1; cmd.ExecuteNonQuery(); } } }整个流程放在一个事务里很重要。如果不加事务A 卷生成后、B 卷生成前另一个并发请求可能把 A 卷刚用过的题抽走B 卷就只能抽到次优题甚至可能和 A 卷撞题。事务保证 AB 卷在同一个数据库快照下完成Commit 前任何一步失败都可以 Rollback不留半张残卷。这一点在课程设计报告里写清楚是明显的加分项。4.3 把试卷落库并导出 WordC# 生成 docx 的常用做法卷子生成出来是数据库里的数据课程设计通常还要导出 Word 文档。用 OpenXML SDK 是官方推荐的做法代码不复杂核心是创建一个 WordprocessingDocument往 Body 里逐个加段落// 需要 NuGet 包DocumentFormat.OpenXml public void ExportPaperToWord(int paperId, string savePath) { string query SELECT q.Content, q.OptionA, q.OptionB, q.OptionC, q.OptionD, pd.OrderNo FROM PaperDetail pd JOIN Question q ON pd.QuestionId q.QuestionId WHERE pd.PaperId paperId ORDER BY pd.OrderNo;; using (var doc WordprocessingDocument.Create(savePath, WordprocessingDocumentType.Document)) { var mainPart doc.AddMainDocumentPart(); var body new Body(); foreach (var row in FetchQuestions(query, paperId)) { var paraText ${row.OrderNo}. {row.Content}; if (!string.IsNullOrEmpty(row.OptionA)) { paraText $\nA. {row.OptionA} B. {row.OptionB} $C. {row.OptionC} D. {row.OptionD}; } body.Append(new Paragraph(new Run(new Text(paraText)))); } mainPart.Document new Document(body); mainPart.Document.Save(); } }这个方案把每题作为一个段落选择题把四个选项拼在题干下面。要注意 OpenXML 里的 Run 内文本如果包含换行纯 Text 不会自动换行要用 Break 元素或者拆成多个 Run否则导出后所有选项挤在一行。更稳妥的方案是每个选项单独一个 Run 并追加 Break。答辩演示时Word 文档排版正常比代码更漂亮更有说服力。5. 课程设计最常见的 5 个坑从连不上 SQL Server 到卷子出重题5.1 连不上数据库先从 SQL Server 配置管理器查起现象程序里 conn.Open() 抛异常错误类似“在建立到服务器的连接时出错。在连接到 SQL Server 时默认设置 SQL Server 不允许远程连接”或者安装 SQL Server 2016 / 2017 / 2019 后一直报“无法找到数据库引擎启动句柄”。原因绝大多数不是连接字符串写错而是 SQL Server 服务没启动或者 SQL Server 配置管理器里的 TCP/IP 协议是禁用状态。安装时如果没勾选“数据库引擎服务”也会导致根本没有实例可连。解决按顺序排查。先在 Windows 服务里找到 SQL Server (MSSQLSERVER)确认状态是“正在运行”把启动类型改成自动。再打开 SQL Server 配置管理器进入 SQL Server 网络配置选择对应实例的协议右键 TCP/IP 选择“启用”。改完后必须重启 SQL Server 服务。最后用 2.2 节的 sqlcmd -L 确认实例名把 DataSource 改对。很多教程默认写 localhost\SQLEXPRESS但如果你装的是默认实例那就写成 localhost 或一个小圆点。5.2 中文写入变成问号字段类型和参数类型都得是 NVARCHAR现象往 Question 表插入题目后在 SQL Server Management Studio 里看到一堆 ??????读出来也是乱码。原因最常见的不是连接字符串字符集问题而是建表时把 Content、Answer 建成了 VARCHAR。VARCHAR 只能存单字节字符中文写入时丢失。另一个原因是 SqlParameter 没有指定类型默认按 ASCII 处理中文。解决凡是可能存中文的字段统一用 NVARCHAR这是治本。插入时显式指定参数类型不要偷懒cmd.Parameters.Add(content, SqlDbType.NVarChar, 500).Value question.Content;建表时如果已经建成 VARCHAR改列类型不会丢失已有数据直接执行 ALTER TABLE Question ALTER COLUMN Content NVARCHAR(500) 即可。这个问题在课程设计里非常高频因为题目数据几乎全是中文一个字段漏改就会导致整张卷子乱码。5.3 组卷卡死先把题量统计做在前面现象点击“生成试卷”后界面长时间无响应日志里看到同一个查询反复执行数据库 CPU 飙高。原因规则里要求的题量大于满足条件的候选题目数。例如规则要求某个知识点难度为“难”的题抽 5 道但题库里只有 3 道“难”题NOT EXISTS 排除后永远凑不齐如果外层还套了 while 重试循环就会死循环。解决组卷前先按规则做一次统计任何一组 (知识点, 难度) 的候选题量低于规则 Count 的 1.2 倍直接抛出业务异常提示“题库不足请补题”。同时给抽题循环加一个最大重试次数比如 10 次超过次数就终止并报错。宁可让用户看到明确提示也不要静默返回一份残缺卷子。这个坑的根因是“规则优先于数据”数据不足时再好的算法也没用。5.4 AB 卷出现重复题目B 卷必须排除 A 卷题号现象A 卷和 B 卷对比发现大量重复题只是题序不同第二题和 A 卷第七题完全一样。原因两卷各自执行 ORDER BY NEWID()题库量小的时候撞题概率非常高。比如某个知识点只有 6 道题A、B 卷各抽 5 道至少 4 道重复。纯换序做法就更不用说B 卷本身就是 A 卷的副本。解决B 卷生成时把 A 卷的 PaperId 传入 DrawByRules让 SQL 里的 NOT EXISTS 把 A 卷题目全部排除。关键点是 A、B 卷必须使用同一套规则而题目集合互斥。生成完成后跑一次重复题检查重复数为 0 再提交事务否则回滚重抽。事务在这里非常关键否则并发状态下两卷仍可能撞题。5.5 生成速度慢给知识点和难度加组合索引现象题库 2 万题生成一次 AB 卷要 5 到 10 秒页面一直转圈。原因ORDER BY NEWID() 需要对所有满足条件的行做随机排序NOT EXISTS 子查询如果没走索引还要逐行扫描 PaperDetail。题量一大这个组合查询就会很慢。解决给 Question 表加组合索引 (KnowledgePointId, Difficulty)给 PaperDetail 加 (PaperId, QuestionId) 唯一索引。前者让 WHERE 条件快速过滤后者让 NOT EXISTS 快速判断。加了这两个索引后2 万题量一般能压到 1 秒内。另外答辩演示时提前把卷子生成好存成 Word不要在现场边讲边生成万一抽题慢会影响整个演示节奏。6. 最后一招用自查脚本验证 AB 卷的零重复和难度一致6.1 一张查询验证两卷零重复生成完 AB 卷第一件事是验证而不是直接导出。用下面这条查询检查两份卷子的重复题数量和题目总数DECLARE AId INT 1; -- A 卷 PaperId DECLARE BId INT 2; -- B 卷 PaperId SELECT (SELECT COUNT(*) FROM PaperDetail a JOIN PaperDetail b ON a.QuestionId b.QuestionId WHERE a.PaperId AId AND b.PaperId BId) AS DuplicateCount, (SELECT COUNT(*) FROM PaperDetail WHERE PaperId AId) AS ACnt, (SELECT COUNT(*) FROM PaperDetail WHERE PaperId BId) AS BCnt;DuplicateCount 必须为 0ACnt 和 BCnt 必须相等且等于所有规则 Count 之和。如果 BCnt 小于 ACnt说明 B 卷某个知识点组合抽不齐有规则被静默跳过要回到 5.3 的题量统计去补题。还要对比知识点覆盖情况。把两卷按知识点分组的题量拉出来A 和 B 的每一行都应该一致因为两卷用的是同一套规则SELECT pd.PaperId, q.KnowledgePointId, COUNT(*) AS Cnt FROM PaperDetail pd JOIN Question q ON pd.QuestionId q.QuestionId WHERE pd.PaperId IN (1, 2) GROUP BY pd.PaperId, q.KnowledgePointId ORDER BY q.KnowledgePointId, pd.PaperId;6.2 给课程设计加分的小改进UseCount 出场顺序如果还想做得更完善给 Question 表加一个 UseCount 字段每次组卷时优先抽 UseCount 小的题抽完把 UseCount 加一。排序条件从 ORDER BY NEWID() 改成ORDER BY q.UseCount ASC, NEWID();这样多次组卷时没被抽过的题会优先出场抽过的题自动沉底整个题库的出题会越来越均匀。这个设计改动很小答辩时却能讲出“系统可持续使用”的亮点比单纯“随机抽题”高一个层次。我第一次做这套系统时AB 卷只做了题序重排答辩时老师当着全班对比两卷第三题问“这两道题一样吗”。那节课后我才明白卷面不重复要比卷面长度更重要。后来每次生成 AB 卷我都强制跑一遍 6.1 的重复题自查DuplicateCount 不为 0 就重抽宁可少出一道题也不要出重题。希望帮到你。本文还有配套的精品资源点击获取
返回列表