
简介C#考试题目生成系统是一套用于自动组卷的完整课程设计项目面向需要完成C#或SQLServer课程设计的在校学生主要解决手动出题耗时、试卷结构不均衡、AB卷难易度不一致等问题。系统支持按知识点手动筛选题目按选择题、判断题等题型自由组合并可分别设置整体难度和每种题型的单题分值一键生成A/B两套平行试卷。压缩包大小约285MB内含C#工程源码、SQLServer数据库脚本及说明文档等数据库脚本可直接附加适合在Visual Studio中打开运行并二次开发。目前已有560人学习下载适合作为课程设计或毕业设计的参考实现。通过阅读代码可掌握试题库表结构设计、随机抽题算法、AB卷防重复策略、难度与分值权重计算等关键思路运行效果直观便于在其基础上扩展案例题、填空题或对接新界面。1. C# 考试题目生成系统先搞懂 AB 卷生成的逻辑再动手改做课程设计时直接搜 C# 考试题目生成系统、试卷自动生成、AB 卷、SqlServer 课程设计的人多半是被同一个问题卡住源码能跑但生成的 A/B 卷不对。我拆过不少这类项目最常见的问题是两张卷子撞题太多、题库表设计得很随意、Word 导出后排版乱。这套资源的价值不在于「能登录、能维护题库」而在于把抽题算法和 A/B 卷去重这条主链路完整走通。适合两类人一是 C# 从入门到课程设计之间的学生拿它当骨架去改成自己的选题二是需要快速出卷的老师或教务人员把 Excel 题库导进去就能按规则生成可打印的 Word 卷子。下面按我实际拆解的顺序讲题库怎么建、抽题怎么不撞、Word 怎么导出、SqlServer 联调哪些坑必须躲。2. 题库建模与 AB 卷抽题算法从分值配比到不撞题2.1 先想清楚题库表结构题型、难度、分值一张表搞定很多课程设计的题库表喜欢把所有字段堆在一起比如把 A/B/C/D 四个选项存成逗号分隔的字符串看起来省事后面抽题和导出时到处要 Split反而麻烦。我更推荐把题干、选项、答案、题型、难度、章节分开列字段用一个QuestionType字段区分单选、多选、判断、填空、简答。CREATE TABLE QuestionBank ( QuestionId INT IDENTITY(1,1) PRIMARY KEY, SubjectName NVARCHAR(50) NOT NULL, -- 科目比如“数据结构” QuestionType TINYINT NOT NULL, -- 1单选 2多选 3判断 4填空 5简答 Difficulty TINYINT NOT NULL DEFAULT 2, -- 1易 2中 3难 Score DECIMAL(5,1) NOT NULL DEFAULT 5, -- 单题分值允许 2.5 分 QuestionText NVARCHAR(MAX) NOT NULL, -- 题干 OptionA NVARCHAR(200) NULL, OptionB NVARCHAR(200) NULL, OptionC NVARCHAR(200) NULL, OptionD NVARCHAR(200) NULL, CorrectAnswer NVARCHAR(500) NOT NULL, -- 单选存“A”多选存“ABD” ChapterNo INT NULL, -- 章节号用来做知识点覆盖 CreateTime DATETIME DEFAULT GETDATE() );这里有几个选型细节QuestionType用TINYINT而不是字符串是为了避免把「单选」「多选」的中文写进每一条题目里抽题时传数字条件更直接Score用DECIMAL(5,1)是因为很多老师会给 2.5 分CorrectAnswer用NVARCHAR(500)是为了兼容多选答案和判断题的“对/错”。判断题我会统一存“对”或“错”不存“T/F”否则答案卷打印出来老师看不懂。试卷本身也要落库抽题结果不能只存在内存里。A 卷和 B 卷是同一个题库抽出来的两组不同题目必须拆成试卷主表ExamPaper和试卷明细表PaperDetail这样以后查某张卷子由哪些题组成、每题多少分都有据可查。CREATE TABLE ExamPaper ( 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, GenerateTime DATETIME DEFAULT GETDATE() ); CREATE TABLE PaperDetail ( DetailId INT IDENTITY(1,1) PRIMARY KEY, PaperId INT NOT NULL, QuestionId INT NOT NULL, DisplayOrder INT NOT NULL, -- 题号从 1 开始 Score DECIMAL(5,1) NOT NULL, SortSeed DECIMAL(18,8) NOT NULL -- 抽题随机种子方便复现 );PaperDetail里的DisplayOrder必须单独存因为同一道题在 A 卷排第 3 题、在 B 卷可能排第 8 题Score也单独存因为同一道题在不同卷子里可能因为规则调整分值不一样。SortSeed是我后来加进去的“后悔药”万一老师问某道题是怎么抽进来的我还能按种子重放当时的抽题过程。2.2 用 JSON 规则描述试卷蓝图每种题型抽几题、每个难度抽几题抽题不能写死成“单选 20 题、多选 5 题”因为不同科目、不同考试对难度配比要求不一样。我会把试卷蓝图做成一份 JSONC# 启动时读进来改配比不用重新编译。{ subject: 数据结构, rules: [ { type: 1, difficulty: 1, count: 5, score: 2 }, { type: 1, difficulty: 2, count: 8, score: 2 }, { type: 1, difficulty: 3, count: 2, score: 2 }, { type: 2, difficulty: 2, count: 5, score: 4 }, { type: 3, difficulty: 1, count: 10, score: 2 }, { type: 5, difficulty: 3, count: 3, score: 10 } ] }这份配置算出来是 582 个单选每题 2 分共 30 分5 个多选每题 4 分共 20 分10 个判断每题 2 分共 20 分3 个简答每题 10 分共 30 分总分正好 100。对应到 C#就是两个 POCOpublic class PaperRule { public string Subject { get; set; } public ListQuestionRule Rules { get; set; } } public class QuestionRule { public int Type { get; set; } // 对应 QuestionBank.QuestionType public int Difficulty { get; set; } // 对应 QuestionBank.Difficulty public int Count { get; set; } // 该题型该难度抽几题 public decimal Score { get; set; } // 每题分值 }代码块里这两个类没有业务逻辑纯粹是 JSON 反序列化的载体。实际生成时我把每条规则翻译成一句查询按题型、难度去题库里捞候选题目然后从中抽取需要的数量。2.3 核心抽题算法A/B 卷怎么做到不撞题AB 卷去重的核心很简单A 卷每抽走一道题就把这个QuestionId放进一个公共的HashSetintB 卷抽题时先把 HashSet 里的题排除掉。很多课程设计翻车就是因为 A、B 两卷各自独立跑抽题函数没有共享“已用题集合”。private DataTable LoadCandidates(string subject, int type, int difficulty) { const string sql SELECT QuestionId, QuestionText, OptionA, OptionB, OptionC, OptionD, CorrectAnswer FROM QuestionBank WHERE SubjectName Subject AND QuestionType Type AND Difficulty Difficulty ORDER BY NEWID();; using var conn new SqlConnection(_connString); using var cmd new SqlCommand(sql, conn); cmd.Parameters.AddWithValue(Subject, subject); cmd.Parameters.AddWithValue(Type, type); cmd.Parameters.AddWithValue(Difficulty, difficulty); var adapter new SqlDataAdapter(cmd); var dt new DataTable(); adapter.Fill(dt); return dt; }LoadCandidates把符合条件的所有题目先按NEWID()随机排序再由内存里的抽题函数决定选哪几个。Subject、Type、Difficulty三个参数必须用SqlParameter传避免字符串拼接引入注入问题和中文编码坑。如果你还在用 .NET Framework 4.x把using var改成传统using块即可。抽题函数我单独写因为要处理“排除已用题”和“洗牌”这两件事private static readonly Random Rng new Random(); private ListDataRow PickDistinct(DataTable source, int need, HashSetint usedIds) { var pool source.AsEnumerable() .Where(r !usedIds.Contains(r.Fieldint(QuestionId))) .ToList(); // Fisher-Yates 洗牌保证每次抽出来的组合不同 for (int i pool.Count - 1; i 0; i--) { int j Rng.Next(i 1); var temp pool[i]; pool[i] pool[j]; pool[j] temp; } return pool.Take(need).ToList(); } public Paper BuildPaper(PaperRule rule, HashSetint usedIds, char paperType) { var paper new Paper { PaperName ${rule.Subject} {paperType} 卷, PaperType paperType.ToString() }; int order 1; foreach (var item in rule.Rules) { var candidates LoadCandidates(rule.Subject, item.Type, item.Difficulty); var rows PickDistinct(candidates, item.Count, usedIds); // 题库不足时兜底允许重复但后面要乱序选项 if (rows.Count item.Count) { foreach (DataRow row in candidates.Rows) { if (rows.Count item.Count) break; if (!rows.Contains(row)) rows.Add(row); } } foreach (var row in rows) { paper.Questions.Add(new PaperQuestion { DisplayOrder order, QuestionId row.Fieldint(QuestionId), Text row.Fieldstring(QuestionText), OptionA row.Fieldstring(OptionA), OptionB row.Fieldstring(OptionB), OptionC row.Fieldstring(OptionC), OptionD row.Fieldstring(OptionD), Score item.Score }); usedIds.Add(row.Fieldint(QuestionId)); } } return paper; }调用时先建一个HashSetint传进去生成 A 卷再传同一个集合生成 B 卷B 卷自然拿不到 A 卷已经用过的题。这里的参数说明很重要need是这条规则要抽的题数usedIds是跨卷共享的已用题集合paperType只影响卷名不影响抽题逻辑。题库不足时B 卷会从兜底逻辑里重复取题所以我后面还会把选项顺序打乱至少让两张卷子表面看起来不一样。选项乱序不是“把选项偷偷换一下位置”这么简单正确答案也要跟着重新映射。A/B 卷都用同一道题时如果 A 卷正确选项是 AB 卷洗牌后正确选项变成 C就必须把CorrectAnswer同步改成 C否则答案卷全错。这个映射逻辑我会放在导出前统一处理避免在生成卷子时就改掉题库原始答案。3. Word 导出与答案卷生成书签、排版和 COM 组件的坑3.1 两种导出路线Word 模板书签和纯代码排版试卷最终要交给老师打印导出 Word 比导出 PDF 更通用因为老师大概率会自己改题头、调字号。C# 生成 Word 常见有三条路我给别人改这套资源时会把这三条路的取舍先写在项目 README 里方案上手成本文档控制力部署要求Microsoft.Office.Interop.Word低代码直观强什么都能改目标机器必须装 OfficeOpenXML SDK中要理解 XML 结构适合批量生成不需要装 Office第三方收费控件低强免费版通常有页数或水印限制课程设计阶段我最推荐 COM 方案不是因为它先进而是因为它最容易调试。每段代码操作的是真实的 Word 对象出问题一眼能看出是哪一段排版不对。如果你最后要把系统部署到服务器上且不装 Office再换 OpenXML SDK。3.2 用 COM 生成卷子正文标题、考生信息、题号和选项具体生成代码骨架如下我保留的是最常用的段落追加方式没用复杂模板标题居中考生信息一行然后逐题追加题干和选项。using Word Microsoft.Office.Interop.Word; using System.Runtime.InteropServices; public void SavePaperToWord(Paper paper, string path) { var app new Word.Application { Visible false }; Word.Document doc null; try { doc app.Documents.Add(); var title doc.Paragraphs[1].Range; title.Text paper.PaperName; title.Font.Size 16; title.Font.Bold true; title.ParagraphFormat.Alignment Word.WdParagraphAlignment.wdAlignParagraphCenter; var info doc.Paragraphs.Add(); info.Range.Text 班级__________ 姓名__________ 学号__________ 得分__________; foreach (var q in paper.Questions) { var p doc.Paragraphs.Add(); var range p.Range; string text ${q.DisplayOrder}. {q.Text}{q.Score} 分; if (!string.IsNullOrEmpty(q.OptionA)) { text $\r\nA. {q.OptionA} B. {q.OptionB} C. {q.OptionC} D. {q.OptionD}; } range.Text text; range.Font.Size 12; } doc.SaveAs2(path, Word.WdSaveFormat.wdFormatXMLDocument); } finally { if (doc ! null) { doc.Close(false); Marshal.ReleaseComObject(doc); } app.Quit(); Marshal.ReleaseComObject(app); } }这段代码有两个容易踩的点。第一SaveAs2第二个参数wdFormatXMLDocument表示保存为.docx如果你需要兼容老版 Word 就换成wdFormatDocument。第二COM 对象必须按照“先文档后应用”的顺序释放finally里Marshal.ReleaseComObject的顺序写反Word 进程就会残留在后台连续生成几次后肉眼可见地变卡。这也是为什么很多人做完课程设计机器上残留一堆 WINWORD.EXE 进程。如果老师给的卷头有固定格式比如校名、考试科目、时间、总分我一般会做一个 Word 模板在需要插入内容的位置插入书签然后用doc.Bookmarks[PaperTitle].Range.Text paper.PaperName这种方式写入变量。这样比用代码从头画排版要稳封面不用每张卷子重新排版。书上说的“Word 模板 书签插入变量”就是这条思路模板文件里的中文段落样式直接继承不会因为代码里少设一个字体而出乱码。3.3 答案卷怎么生成一条 SQL 把 A/B 卷答案合并成一行老师打印试卷时往往还要一张单独的答案卷按题号列出正确答案。最省事的做法是在生成卷子的同时往PaperDetail里写题序答案卷直接从数据库汇总而不是从内存里拼接。这里正好可以用上 SqlServer 的多行合并成一行能力SELECT p.PaperId, STRING_AGG( CONCAT(CAST(p.DisplayOrder AS VARCHAR(10)), ., q.CorrectAnswer), ) WITHIN GROUP (ORDER BY p.DisplayOrder) AS AnswerLine FROM PaperDetail p LEFT JOIN QuestionBank q ON p.QuestionId q.QuestionId WHERE p.PaperId PaperId GROUP BY p.PaperId;STRING_AGG是 SqlServer 2017 及以上版本才有的函数如果目标库是 SqlServer 2012就得换成STUFF FOR XML PATH的老写法。我在写这套资源时会把两种写法都放进去用VERSION判断一下版本再选。注意CONCAT里要先把DisplayOrder转成字符串否则数字和中文混拼会报隐式转换错误这就是“SqlServer 字符串转数字”最常见的现场。4. C# SqlServer 联调避坑安装、连接、导入与随机抽题4.1 SqlServer 安装失败先查实例名和安装残留我自己装 SqlServer 2016、2017、2019 都遇到过一个报错安装过程中提示“无法找到数据库引擎启动句柄”。第一次遇到时以为是安装包坏了重下两遍还是报错。后来发现真正原因是这台机器之前装过 SqlServer Express旧实例的服务残留在系统里新安装包检测实例名冲突后数据库引擎根本起不来。解决方法是先到 Windows 服务列表里把所有 SQL Server 相关服务停掉用安装包自带的卸载工具把旧实例清理干净再以管理员身份重新装。装的时候把实例名改成SQLEXPRESS或带版本号的自定义名称避开默认的 MSSQLSERVER。装完第一件事不是打开 Visual Studio而是打开SqlServer 配置管理器确认数据库引擎服务真的在运行。注意连接字符串里写localhost不代表能连上。SqlServer 的默认实例和命名实例写法完全不同默认实例用Serverlocalhost命名实例必须写Serverlocalhost\SQLEXPRESS。4.2 连接字符串写错从配置管理器开始排C# 连 SqlServer 报System.Data.SqlClient.SqlException时错误号 26、40、53 之间有区别26 多半是找不到实例40 多半是网络层连不上53 是路径问题。课程设计最常见的错误是把连接字符串写死成别人的机器名换一台电脑就跑不起来。我一般会先看本地装的是默认实例还是命名实例然后写一个可以改的连接串string connString Serverlocalhost\SQLEXPRESS;DatabaseExamDB;Integrated SecurityTrue;TrustServerCertificateTrue;;如果是远程服务器把Integrated Security改成False再加User Idsa;Password...。在这里我踩过的坑是sa密码里有特殊字符时忘了转义直接写在普通字符串里导致连接串解析失败。后面统一改成SqlConnectionStringBuilder来拼问题就没了var builder new SqlConnectionStringBuilder { DataSource localhost\SQLEXPRESS, InitialCatalog ExamDB, IntegratedSecurity true, TrustServerCertificate true }; using var conn new SqlConnection(builder.ConnectionString);SqlServer 默认可能没开启 TCP/IP本地用共享内存能连换到局域网就连不上。这个必须在SqlServer 配置管理器里把 SQL Server 网络配置的 TCP/IP 协议启用再重启服务不配置的话代码写得再对也没用。4.3 Excel 导入题库DBNull、数字列和 TRY_CAST题库手工录入太慢不少人是先做 Excel再让 C# 读 Excel 导入。操作 Excel 时最容易出的问题是某一格空着读出来不是空字符串而是DBNull某列是数字读出来变成1.0这种 double 类型。直接ToString()就翻车报“无法从 DBNull 读取字符串”。private string GetCell(DataRow row, string column) { if (row.IsNull(column)) return string.Empty; object value row[column]; if (value is double d) return d.ToString(0.##); return value.ToString().Trim(); }这个GetCell函数处理了两种脏数据空单元格返回空字符串数字列按整数形式输出避免“1002”被读成“1002.0”这种诡异题号。除了读端处理写库时我也会在 SQL 里加一道保险用TRY_CAST而不是CASTINSERT INTO QuestionBank (SubjectName, QuestionType, Difficulty, Score, QuestionText, CorrectAnswer) VALUES ( Subject, TRY_CAST(Type AS TINYINT), TRY_CAST(Difficulty AS TINYINT), TRY_CAST(Score AS DECIMAL(5,1)), Text, Answer );TRY_CAST转换失败时返回NULL而不是抛异常这样批量导入时能用一条SELECT把转换失败的题目捞出来写日志而不是让整批数据一起回滚。记住导入功能永远要能告诉你“哪一行坏了”不能只报一个笼统的“导入失败”。4.4 随机抽题的三个低级坑Random、NEWID 和 A/B 卷撞题随机抽题最玄学的地方是看着像随机实际每次都一样。很多初写代码的人会在循环里new Random()而 .NET Framework 的默认种子和当前时间有关循环执行太快时两个Random拿到同一个种子抽题结果完全一致。我通常用一个静态Random实例或者在 .NET 6 以上直接用Random.Shared。前面PickDistinct里的Rng就是静态字段全程序共用。第二个坑是ORDER BY NEWID()的性能。NEWID()会对每一行都生成 GUID再排序题库到十万级以上时查询会明显变慢。课程设计的题量通常只有几百道这个写法完全能接受如果哪天真要撑大题库就先按题型难度把候选集缩到几百行再排序不要让排序作用在整个表上。第三个坑就是 AB 卷撞题。生成 A 卷时如果只用局部的Listint存已用题函数一退出就丢了B 卷再抽又会抽到同一批。必须把HashSetint usedIds从 A 卷一直传到 B 卷结束。题库实在不够时允许重复但重复题一定要做选项乱序。选项乱序后答案映射怎么改我在第 2.3 节已经讲过这一步漏了答案卷和试卷就对不上属于验收时必挂的问题。5. 进阶把抽题规则外置成 JSON 配置后一键生成前面的代码抽题规则写死在程序里换科目就要改代码。我把规则文件改成paperRule.json后程序入口只要两段代码就能同时生成 A、B 卷var rule JsonSerializer.DeserializePaperRule( File.ReadAllText(paperRule.json) ); var usedIds new HashSetint(); var paperA BuildPaper(rule, usedIds, A); var paperB BuildPaper(rule, usedIds, B); SavePaperToWord(paperA, A卷.docx); SavePaperToWord(paperB, B卷.docx);JsonSerializer.Deserialize使用 System.Text.Json 反序列化PaperRule里的属性名和 JSON 字段名一致即可usedIds只在生成 A、B 卷的这段时间里存活所以生成多套卷子时规则变、题量变主程序代码不用动。到这里整套系统的核心链路就通了题库在 SqlServer 里抽题规则在 JSON 里输出是 Word 文档A/B 卷不撞题答案卷用 SQL 汇总。从那以后我每次生成完都会强制跑一遍校验总分是不是 100、题数是不是规则里的总数、答案卷和试卷每题对应是否正确三门课六个专业十几套卷子都没再翻过车。希望帮到你。本文还有配套的精品资源点击获取