简介:基于C#与SQLServer开发的考试题目生成系统,面向需要完成课程设计或教务管理项目的学习者,尤其适合高校软件工程、数据库应用等相关课程,能有效解决题库管理、自动组卷与AB卷生成等实际问题。压缩包整体约285MB,内容完整,部署运行便捷,适合在课程设计场景中直接套用或二次开发。目前已有560人学习使用,项目成熟度有一定保障。系统支持手动勾选知识点,选择选择题、判断题等题型,设置题目难度和每类题型分值,可自动生成A卷和B卷,灵活覆盖多套试卷需求;底层采用SQLServer数据库存储题库,包含课程设计所需的完整项目逻辑,便于二次扩展或改造为在线考试系统。无论是作为课程设计参考,还是学习C#与数据库连接的实践素材,都具备较高的价值。
1. 一套课程设计级别的C#考试题目生成系统:到底在做什么
期末周出AB卷这件事,靠手搓Word有多痛苦,做过一次就懂。从题库里挑题、按章节和难度配分、调格式、保证两套卷子不重题,一晚上就搭进去了。这套系统做的就是把这件事自动化:用C#连上SQLServer题库,按规则抽题、生成AB两套试卷、导出成能直接打印的Word文档。它是典型的课程设计题目,但也是教育类软件里最值得复用的一个模块。适合三类人:正在做C#课程设计的学生、想给学校或培训机构搭小型组卷工具的人、以及刚接手教育项目想快速理解抽题逻辑的开发者。本文会按数据库设计、抽题算法、Word导出、踩坑排查这条路讲完,代码可以直接照着改。
2. 数据库设计:把题库和AB卷的底层表结构一次定对
2.1 题库表设计:题目内容、答案、难度、章节的字段划分
做这个系统,我一般先把题库表建出来,因为后面所有逻辑都围绕它转。这张表最忌讳的就是“什么都能存,什么都乱存”——比如把选项塞进一个字符串、把答案写在题干里,后期抽题和判分都会很痛苦。
常见的题库表结构如下,字段名可以按自己习惯调整,但语义要清晰:
CREATE TABLE QuestionBank ( QuestionId INT IDENTITY(1,1) PRIMARY KEY, ChapterId INT NOT NULL, -- 所属章节ID,关联Chapter表 QuestionType INT NOT NULL, -- 1单选 2多选 3判断 4填空 5简答 Difficulty INT NOT NULL DEFAULT 3, -- 难度1~5,3为中等 Score INT NOT NULL DEFAULT 2, -- 该题默认分值 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, -- 单选填A,填空填答案文本 CreateTime DATETIME DEFAULT GETDATE() );这里几个关键设计点:ChapterId不要直接存章节名字符串,而是关联一张章节表,这样后面按章节权重抽题时可以写WHERE ChapterId IN (...),比模糊匹配字符串可靠得多。Difficulty用整数1到5而不是“容易/中等/难”,因为抽题规则里要写Difficulty <= @maxDiff这种区间条件,整数最方便。Answer统一用NVARCHAR,判断题存“A/B”、单选题存“A”、填空题存“标准答案文本”,一个字段通吃。题目没有选项时OptionA到D允许NULL,渲染时判断一下就行。
我见过不少人把Score直接写在试卷生成代码的switch里——比如“单选题一律2分,判断题一律1分”。这样一旦老师想调整某道题的分值,就得改代码重新编译。把Score放在题目表里作为默认值,是成本最低的灵活方案。后面讲试卷关系表时会看到,真正生成试卷时还可以再覆盖这个值。
2.2 试卷与试卷题目关系表:AB卷持久化的两种建表路线
题库表只是地基,试卷本身也要落库。课程设计答辩时老师通常会问:“你生成的试卷能不能从数据库里查回来?”如果只生成Word不落库,演示时就很被动。
试卷表结构如下,AB卷通过PaperCode区分:
CREATE TABLE Paper ( PaperId INT IDENTITY(1,1) PRIMARY KEY, PaperCode NVARCHAR(20) NOT NULL, -- A / B PaperTitle NVARCHAR(200) NOT NULL, -- 如《C#程序设计期末试卷(A卷)》 TotalScore INT NOT NULL, IsReleased BIT DEFAULT 0, -- 是否已正式发布 CreateTime DATETIME DEFAULT GETDATE() ); CREATE TABLE PaperQuestion ( Id INT IDENTITY(1,1) PRIMARY KEY, PaperId INT NOT NULL REFERENCES Paper(PaperId), QuestionId INT NOT NULL REFERENCES QuestionBank(QuestionId), DisplayOrder INT NOT NULL, -- 题号,1表示第1题 ScoreInPaper INT NOT NULL -- 本题在该卷中的分值 );AB卷的持久化有两条路线,选哪条取决于你的“防作弊”策略。
路线一是A卷和B卷完全用不同题目:A卷抽完把题号集合存下来,B卷抽题时排除这些题号。这种方案下PaperQuestion表很干净,同一道题不会出现在两卷中。适合选择题、判断题这种题库量大、命题成本低的题型。
路线二是A、B卷共用同一批题目,但通过DisplayOrder重排题序、通过选项重排改变ABCD位置。这种方案适合简答题、论述题——老师出一套已经很难了,两套完全不同的论述题不现实。此时同一道题会出现在两张Paper的PaperQuestion里,PrintOrder不同而已。
不要小看这个选择。很多课程设计在做AB卷时只做到“抽两批不同的题”,遇到论述题就直接抽空题库。正确做法是把两种策略都实现,单选题用错题抽题、论述题用同题换序。这在你答辩时是很加分的细节。
2.3 连接SQLServer:连接字符串的四个填法(含常见的坑)
表建好后,C#项目里第一步是搞定连接串。这一步看起来简单,但课程设计里卡住最多人的就在这里。
// 本地默认实例,Windows身份验证 string connStr1 = "Server=localhost;Database=ExamDB;Integrated Security=True;TrustServerCertificate=True;"; // 本地SQLEXPRESS命名实例 string connStr2 = "Server=localhost\\SQLEXPRESS;Database=ExamDB;Integrated Security=True;TrustServerCertificate=True;"; // SQL Server身份验证(账号sa) string connStr3 = "Server=localhost;Database=ExamDB;User ID=sa;Password=123456;TrustServerCertificate=True;"; // 远程服务器(比如机房服务器) string connStr4 = "Server=192.168.1.100,1433;Database=ExamDB;User ID=sa;Password=123456;TrustServerCertificate=True;";四个填法里最容易翻车的两个点:一是“localhost”连不上命名实例,装了SQLServer Express版后实例名默认是“localhost\SQLEXPRESS”,直接写localhost会报“找不到服务器”;二是信任证书问题,新版SQLServer默认强制加密连接,连接串里不写TrustServerCertificate=True就会报“证书链是由不受信任的颁发机构颁发的”。
写连接串时还有个纪律:不要把连接串硬编码散落在各个Form里。课程设计虽然不要求架构多高级,但至少定义一个静态类统一管理,后面换服务器只用改一处。
public static class DbHelper { private static readonly string ConnStr = "Server=localhost\\SQLEXPRESS;Database=ExamDB;Integrated Security=True;TrustServerCertificate=True;"; public static SqlConnection CreateConnection() { var conn = new SqlConnection(ConnStr); conn.Open(); return conn; } }这段代码里我把Open()放在创建时执行,好处是调用方拿到的一定是可用的连接,查数据出错时异常会直接暴露在调用层,不会出现“连接好像开了又好像没开”的玄学问题。代价是短连接场景下每次都要握手建连,但课程设计这种桌面工具一天开几百次连接完全没压力。
3. 抽题算法与AB卷生成:从随机抽题到防同题、乱序
3.1 按章节/题型/难度抽题:一套可复用的抽题SQL
抽题是整个系统的核心,也是最容易被写成“玄学”的部分。简单做法是不断执行SELECT TOP 1 * FROM QuestionBank ORDER BY NEWID(),但这样做有隐患,后面避坑章节会细说。这里给出一种更适合课程设计的做法:一次把一类题全部抽出来。
SELECT TOP (@count) q.QuestionId, q.ChapterId, q.QuestionType, q.Difficulty, q.Score, q.Content, q.OptionA, q.OptionB, q.OptionC, q.OptionD, q.Answer FROM QuestionBank q WHERE (@chapterId = 0 OR q.ChapterId = @chapterId) AND (@type = 0 OR q.QuestionType = @type) AND (@difficulty = 0 OR q.Difficulty = @difficulty) ORDER BY NEWID();逻辑说明:ORDER BY NEWID()是SQLServer里最常用的随机排序手段,NEWID()为每行生成一个GUID,排序就相当于随机打乱,取前N条即随机抽题。参数@chapterId、@type、@difficulty传0表示不过滤,这样一条SQL可以覆盖“抽全部”“抽某章”“抽某题型+难度”等组合场景。
这条SQL在题目量几千条时没有问题,几万条以上性能会明显下降,因为ORDER BY NEWID()没法走索引。但课程设计的题库一般几百到几千题,完全够用。如果真到了几十万题,再考虑TABLESAMPLE之类的方案。
C#端调用时,注意参数化和数据读取的方式:
public List<Question> DrawQuestions(int chapterId, int type, int difficulty, int count) { var list = new List<Question>(); const string sql = @" SELECT TOP (@count) q.QuestionId, q.ChapterId, q.QuestionType, q.Difficulty, q.Score, q.Content, q.OptionA, q.OptionB, q.OptionC, q.OptionD, q.Answer FROM QuestionBank q WHERE (@chapterId = 0 OR q.ChapterId = @chapterId) AND (@type = 0 OR q.QuestionType = @type) AND (@difficulty = 0 OR q.Difficulty = @difficulty) ORDER BY NEWID();"; using var conn = DbHelper.CreateConnection(); using var cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@chapterId", chapterId); cmd.Parameters.AddWithValue("@type", type); cmd.Parameters.AddWithValue("@difficulty", difficulty); cmd.Parameters.AddWithValue("@count", count); using var reader = cmd.ExecuteReader(); while (reader.Read()) { list.Add(new Question { QuestionId = (int)reader["QuestionId"], ChapterId = (int)reader["ChapterId"], QuestionType = (int)reader["QuestionType"], Difficulty = (int)reader["Difficulty"], Score = (int)reader["Score"], Content = reader["Content"].ToString(), OptionA = reader["OptionA"]?.ToString(), OptionB = reader["OptionB"]?.ToString(), OptionC = reader["OptionC"]?.ToString(), OptionD = reader["OptionD"]?.ToString(), Answer = reader["Answer"].ToString() }); } if (list.Count < count) throw new Exception($"题目不足:按当前筛选条件只有{list.Count}题,需要{count}题"); return list; }这段代码里有几个细节值得说明。AddWithValue在数据量大的场景下可能导致参数类型推断不准,但课程设计里这个影响微乎其微,用AddWithValue可读性更好。读取时用reader["字段名"]而不是reader.GetInt32(0),字段顺序调换时代码不会崩,代价是每次反射查找列序,但同样在几千题的规模下无感。抽完后立即检查list.Count是否小于count,不够就抛异常。这个检查必须放在这里,不能等生成Word时才报错,否则用户填了一堆参数最后一步才看到错误,体验很差。
3.2 AB卷的两种生成策略:错题抽题与同题换序
AB卷的核心诉求是“邻座不能互相抄”,实现上有两种策略,前面建表章节提过,这里写具体实现。
策略A,错题抽题。A卷正常抽,B卷在A卷已用题号之外重新抽。实现方式是在抽题SQL外再包一层排除条件。用临时表比用NOT IN拼接更稳:
CREATE TABLE #UsedQuestions (QuestionId INT PRIMARY KEY); INSERT INTO #UsedQuestions SELECT QuestionId FROM PaperQuestion WHERE PaperId = @paperAId; SELECT TOP (@count) q.QuestionId, q.ChapterId, q.QuestionType, q.Difficulty, q.Score, q.Content, q.OptionA, q.OptionB, q.OptionC, q.OptionD, q.Answer FROM QuestionBank q WHERE NOT EXISTS (SELECT 1 FROM #UsedQuestions u WHERE u.QuestionId = q.QuestionId) AND (@type = 0 OR q.QuestionType = @type) ORDER BY NEWID();逻辑说明:临时表#UsedQuestions在会话期间有效,把A卷已经用过的题号放进去,B卷抽题时用NOT EXISTS排除。为什么不直接写WHERE q.QuestionId NOT IN (SELECT QuestionId FROM PaperQuestion WHERE PaperId=@paperAId)?两条SQL在数据量小时完全等价,但NOT IN有一个经典的NULL陷阱——如果子查询结果里有NULL值,NOT IN的整个结果就是空集。PaperQuestion.QuestionId本身非空,但写成复杂联查时很容易带出NULL行,用NOT EXISTS可以从根上规避这个问题。
策略B,同题换序。A卷和B卷用同一批题,但B卷重排题目顺序、重排选择题选项。其实现核心是洗牌算法,C#里最常见的写法是Fisher-Yates:
public static List<Question> ShuffleQuestions(List<Question> source, int seed) { var items = source.ToList(); var rng = new Random(seed); for (int i = items.Count - 1; i > 0; i--) { int j = rng.Next(i + 1); (items[i], items[j]) = (items[j], items[i]); } return items; }参数说明:seed是随机种子。同一个seed洗出来的顺序完全相同,这个特性用来复现“电子存档和纸质试卷一致”很实用。生成B卷时用一个和A卷不同的seed,题目顺序自然不同。这说明一个容易被忽略的点:Random类的种子不写,每次new都会用当前时间戳做种子,生成结果不可复现。建议生成试卷时把seed存进Paper表,需要重新打印时能还原同一版试卷。
选项重排比题目重排多一步——重排后正确答案的位置变了:
public static string ShuffleOptions(Question q, Random rng) { var options = new[] { q.OptionA, q.OptionB, q.OptionC, q.OptionD }; for (int i = options.Length - 1; i > 0; i--) { int j = rng.Next(i + 1); (options[i], options[j]) = (options[j], options[i]); } char correct = q.Answer[0]; // 原答案:A/B/C/D int oldIndex = correct - 'A'; int newIndex = Array.IndexOf(options, q.OptionA); // 找A选项在新数组的位置 // 实际应根据原选项文本找到新位置,这里仅示意 return string.Join("|", options); }这段代码写得比较示意性,因为完整实现需要把选项数组和正确答案绑定起来一起洗。更稳的做法是定义一个OptionItem类,包含Order(原ABCD序号)和Content两个字段,洗牌时把OptionItem整个洗,洗完后根据正确答案对应的Order算出新答案。这种细节不难,但很容易写错,生成后一定要人工核对一份试卷。
3.3 抽题时校验题量、分数和重复率(核心代码)
生成一张试卷时,最怕的是“题目都抽出来了,一算总分不对”或者“两套卷子重复率快到一半”。把校验逻辑前置,能省掉大量返工。
public void ValidatePaper(List<Question> questionsA, List<Question> questionsB, int expectedTotalScore) { int totalA = questionsA.Sum(q => q.Score); int totalB = questionsB.Sum(q => q.Score); if (totalA != expectedTotalScore) throw new Exception($"A卷总分{totalA},期望{expectedTotalScore},请检查抽题配置"); if (totalB != expectedTotalScore) throw new Exception($"B卷总分{totalB},期望{expectedTotalScore},请检查抽题配置"); var idsA = questionsA.Select(q => q.QuestionId).ToHashSet(); var idsB = questionsB.Select(q => q.QuestionId).ToHashSet(); int duplicateCount = idsA.Intersect(idsB).Count(); double duplicateRate = (double)duplicateCount / idsA.Count; if (duplicateRate > 0.2) throw new Exception($"AB卷同题率{duplicateRate:P0},超过20%上限,请增加题库题量或调整抽题规则"); }逻辑说明:总分校验是最容易被忽略的。抽题规则里单选2分共10题、多选3分共5题,总分是35,但如果配置里漏了一项,生成出的试卷总分是33分,打印出来才发现就尴尬了。同题率校验主要针对采用“错题抽题”策略时——如果题库里符合条件的题目只有40道,A卷抽30道,B卷强制排除A卷题目后只能用剩下的10道,同题率不会超标;但如果题库有50道,A卷抽30道,B卷从剩余20道里抽20道,同题率就是0,一切正常。真正会出问题的场景是规则里同时允许了“可用题量少”和“必抽题量多”,校验会直接拦住。
这两个校验放在“生成试卷”按钮的事件里,在写入Paper表之前执行。凡是校验不通过,一律不落库、不生成Word,给出具体错误信息。比生成到一半再报“索引超出范围”体验好得多。
4. 把试卷导出成可打印的Word文档:零依赖的HTML转doc方案
4.1 为什么不用Word COM和OpenXML:课程设计环境的两个硬约束
试卷生成之后要落成文档。C#生成Word有三种常见路线:Word COM组件、OpenXML SDK、HTML转doc。课程设计场景下我强烈建议第三种。
Word COM组件(Microsoft.Office.Interop.Word)能在代码里精确控制字体、页边距、页眉页脚,但有个硬伤:目标机器必须安装Office,而且安装的版本要和引用的Interop版本兼容。实验室的机器装了WPS没装Office,代码直接抛异常。答辩时你用自己的笔记本演示没问题,但老师可能让你在教室电脑上跑一次,教室电脑的环境谁都没法保证。
OpenXML SDK(DocumentFormat.OpenXml)不依赖Office,生成的是真正的docx文件,但学习成本高。你要理解文档的结构——document.xml、styles.xml、 numbering.xml,写一个带标题和表格的文档至少要几十行代码,而且格式微调起来非常隐晦。对课程设计来说,把时间花在抽题算法上比花在文档格式上性价比高得多。
HTML转doc就是把格式化好的HTML文本保存为.doc后缀。Word和WPS都能直接打开,表格、字体、对齐方式都支持,零外部依赖,代码量也最小。它的边界是:HTML转doc本质是Word兼容的HTML,不是标准二进制docx,复杂的域代码、自动目录支持不了。但考试试卷就是标题+表格+段落,完全在它能力范围内。
4.2 生成HTML试卷模板:AB卷页眉、题目编号、选项排版的占位与替换
HTML转doc实现时,我用StringBuilder拼HTML,因为试卷结构固定,不用模板引擎反而更直观。
public static string BuildPaperHtml( string paperTitle, string studentInfoLine, List<Question> questions) { var sb = new StringBuilder(); sb.AppendLine("<html><head><meta charset='utf-8'></head><body>"); // 试卷标题与考生信息栏 sb.AppendLine($"<h2 style='text-align:center'>{paperTitle}</h2>"); sb.AppendLine($"<p style='text-align:center'>班级:________ 姓名:________ 学号:________ 成绩:________</p>"); sb.AppendLine("<hr style='border:1px solid #000'>"); // 按题型分组渲染,题型顺序固定:单选→多选→判断→填空→简答 var groups = questions .GroupBy(q => q.QuestionType) .OrderBy(g => g.Key); foreach (var group in groups) { string typeName = group.Key switch { 1 => "一、单选题", 2 => "二、多选题", 3 => "三、判断题", 4 => "四、填空题", 5 => "五、简答题", _ => "其他" }; sb.AppendLine($"<h3>{typeName}</h3>"); int order = 1; foreach (var q in group) { // 渲染题干,题号由DisplayOrder决定 sb.AppendLine($"<p>{order}. {q.Content}({q.Score}分)</p>"); // 有选项的题型才渲染ABCD if (q.QuestionType == 1 || q.QuestionType == 2) { sb.AppendLine("<table style='width:100%;border:none'>"); sb.AppendLine($"<tr><td style='width:25%'>A. {q.OptionA}</td>"); sb.AppendLine($"<td style='width:25%'>B. {q.OptionB}</td></tr>"); sb.AppendLine($"<tr><td style='width:25%'>C. {q.OptionC}</td>"); sb.AppendLine($"<td style='width:25%'>D. {q.OptionD}</td></tr>"); sb.AppendLine("</table>"); } // 判断题和填空题预留作答空间 if (q.QuestionType == 3) sb.AppendLine("<p style='text-indent:2em'>( )</p>"); if (q.QuestionType == 4) sb.AppendLine("<p style='text-indent:2em'>答:________________________________</p>"); order++; } } sb.AppendLine("</body></html>"); return sb.ToString(); }逻辑说明:题干后面直接标注分值,例如“1. 以下哪个不是C#的值类型?(2分)”,这样不需要在试卷末尾做分数统计表,阅卷时也方便。选择题选项用表格布局,每个选项占25%宽度,两两一行,长题干不会换行错位。判断题后面留“( )”,填空题留横线,简答题区域这里没展开,实际可以按分数高低留3到6行空白线。
AB卷的差异在这个方法里通过两个入参体现:paperTitle传“计算机基础期末考试试卷(A卷)”和“(B卷)”,studentInfoLine一致。如果是同题换序策略,questions已经是洗过序的列表,渲染出来的题号顺序自然不同。
这里必须强调一个安全细节:渲染时绝不使用q.Answer字段。HTML模板里从头到尾不出现Answer,从源头杜绝“试卷上印着答案”这种事故。你可以在阅读代码时、测试时看答案,但生成文档的路径上和答案彻底隔离。
4.3 保存为doc与文件名规范:按课程名+卷别+时间生成文件名
HTML拼好后,保存成文件并给出提示,代码不长但有两个坑要避开。
public static void SavePaperAsDoc(string html, string courseName, string paperCode) { string fileName = $"{courseName}_{paperCode}_{DateTime.Now:yyyyMMdd_HHmm}.doc"; string fullPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), fileName); // 使用带BOM的UTF8编码,避免WPS打开时乱码 var utf8Bom = new UTF8Encoding(true); File.WriteAllText(fullPath, html, utf8Bom); MessageBox.Show($"试卷已生成:{fullPath},请用Word打开后核对格式"); }第一个坑是编码。File.WriteAllText默认使用UTF8无BOM,Word 2016及以上版本可以正常识别,但WPS和部分旧版Word在打开无BOM的UTF8文件时会按ANSI解码,中文全变问号。这里显式用new UTF8Encoding(true)写入带BOM的UTF8,Word和WPS都能正确识别。
第二个坑是文件名。课程名里不要带/\:*?"<>|这些字符,否则Path.Combine会直接抛异常。文件名里加时间戳是为了防止“生成两次A卷,第二次覆盖了第一次”的惨剧。如果你希望同一次操作生成的AB卷在文件管理器里排在一起,可以用“课程名_A卷_20250110_1430.doc”和“课程名_B卷_20250110_1430.doc”这种格式,排序天然相邻。
生成后最好自动打开文件夹或者调用Process.Start(fullPath)打开文件预览,让用户立刻确认。课程设计答辩时,这一步演示效果很直观。
5. 课程设计里最容易翻车的五个常见问题与排查
5.1 抽出来的题总是重复:NEWID()的随机性与去重误用
现象:A卷里出现两道几乎一样的题目,或者一次抽题操作中同一道题被抽中两次。
原因:很多人写抽题循环时,在循环体里反复执行SELECT TOP 1 ... ORDER BY NEWID()。NEWID()每次调用的随机性是独立的,上一轮抽出的题和下一轮之间没有任何记忆,同一道题被重复抽中是概率事件。还有人在循环外用Random做“随机偏移量”,但Random的种子相同,产生的“随机”序列完全相同,结果每轮抽到的偏移量一模一样。
解决:不要一次抽一道,要一次把一类题全部抽出来,回到3.1的TOP(@count)方案。SQL层面用ORDER BY NEWID()抽取N条,天然不会重复。如果确实需要分批次抽(比如先抽5道再抽5道),维护一个HashSet记录已抽题号,每次抽完后把新题号Add进去,下一轮SQL里加条件AND QuestionId NOT IN (...)。
这段血泪经验来自我见过的一个项目,测试同事在题库里手动复制粘贴了10道题干完全相同的题,结果一次抽题把8道都抽进了同一张试卷,打印出来闹了大笑话。后来在系统里加了去重校验——不仅抽题时不重复,插入题库时也按Content去重,双保险。
5.2 连不上SQLServer:服务器名称、实例名与身份验证的坑
现象:程序启动时报SqlException: 在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误,错误码26或40。
原因:最常见的是三种情况。第一,服务器名写成localhost但本机装的是SQLServer Express,实例名是localhost\SQLEXPRESS,localhost默认连默认实例,连不上。第二,连接串写了Integrated Security=True,但目标机器开启了混合身份验证且当前Windows用户没有登录权限。第三,远程连接时没开TCP/IP协议或防火墙拦了1433端口。
解决:先确认本机到底装了哪个版本——在“服务”里看SQL Server服务的显示名称,如果带SQLEXPRESS字样,连接串里必须写Server=localhost\SQLEXPRESS。身份验证方式上,如果程序要部署到多台机器,直接统一用SQL Server身份验证(User ID=sa),课堂演示最省事,但注意sa密码不要用太弱的组合,避免被扫描爆破。远程连不上时,用本机SQLServer配置管理器检查TCP/IP是否启用,Windows防火墙放行1433端口,再用telnet测试端口通不通。
这个坑在课程设计里出现频率极高,因为学生机器上装的SQLServer版本五花八门,2012到2022都有,默认实例和命名实例的差异足以卡住一晚上。
5.3 题库明明够抽却报“题目不足”:COUNT与抽样结果不一致的原因
现象:界面上显示题库有200道题,配置需要抽50道,但程序报“题目不足,只有30道可选”。
原因:200道是题库总数,但经过题型、章节、难度过滤后,真正符合条件的可能只有30道。比如抽题规则要求“第一章的单选、难度3、抽10道”,而第一章的单选难度3的题一共只有6道。
解决:把“题量检查”和“抽题”用同一套过滤条件。具体做法是先执行COUNT查询,用和抽题完全相同的WHERE条件统计数量,数量不足直接中断并给出各类题型的缺口明细:
public void CheckStockBeforeDraw(List<DrawRule> rules) { foreach (var rule in rules) { const string sql = @" SELECT COUNT(*) FROM QuestionBank WHERE (@chapterId = 0 OR ChapterId = @chapterId) AND (@type = 0 OR QuestionType = @type) AND (@difficulty = 0 OR Difficulty = @difficulty);"; using var conn = DbHelper.CreateConnection(); using var cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@chapterId", rule.ChapterId); cmd.Parameters.AddWithValue("@type", rule.Type); cmd.Parameters.AddWithValue("@difficulty", rule.Difficulty); int stock = (int)cmd.ExecuteScalar(); if (stock < rule.Count) throw new Exception( $"规则[{rule.Name}]库存不足:需{rule.Count}题,当前条件可用{stock}题,请补充题库或调整规则"); } }这段代码在点击“生成试卷”后最先执行,把所有规则的库存一次性验证完,不会出现抽到一半才失败的情况。注意COUNT查询和抽题查询的条件一定要保持一致,复制粘贴时少一个条件就会导致“预检通过、抽题失败”的诡异表现。
5.4 生成的Word在别的机器打不开或乱码:编码、权限与兼容边界
现象:自己机器上生成的.doc双击能打开,拷贝到U盘带到答辩教室,用WPS打开全是乱码,或者打开后提示文件损坏。
原因:两个原因叠加。一是前面说过的编码问题,无BOM的UTF8文件在WPS里按本地代码页解码,中文乱码。二是HTML转doc的文件本质上不是标准docx二进制,WPS某些版本对它的识别宽容度不够。还有可能是文件保存在C盘某目录下,没有写入权限导致只生成了0字节文件。
解决:统一用带BOM的UTF8编码,这是最常见的一步。生成后用Word和WPS各打开一次,不只盯着Word。如果WPS打开确实有兼容问题,切换成OpenXML方案导出标准docx,但那是另一个工作量,课程设计阶段一般用不到。权限问题则把生成目录固定到桌面或Path.GetTempPath(),避免Program Files等受保护目录。
这里多一句嘴:HTML转doc最怕的是在HTML里用CSS3高级特性——flex布局、圆角、阴影这些在浏览器里好看,但Word的HTML渲染引擎根本不认,轻则样式丢失,重则整个表格错位。我的习惯是只用table布局、内联style、基础字体标签,越朴素越稳。
5.5 学生拿到手的试卷里带着答案:字段过滤与视图安全
现象:生成的试卷文档里,题干下面直接印着“答案:B”,或者Word里用白色字体隐藏了答案、学生全选就能看到。
原因:抽题方法把Question实体整体返回,包含Answer字段,渲染模板时粗心把全部字段都遍历输出了。这属于典型的“开发时图省事,上线时酿大祸”。
解决:渲染路径上使用不含Answer的视图模型。最简单的方式是渲染方法不接收Question,而是接收一个PaperQuestionViewModel,只包含题型、题干、选项、分值:
public class PaperQuestionViewModel { public int DisplayOrder { get; set; } public int QuestionType { get; set; } public string Content { get; set; } public string? OptionA { get; set; } public string? OptionB { get; set; } public string? OptionC { get; set; } public string? OptionD { get; set; } public int Score { get; set; } }在Service层把Question映射成ViewModel时,只拷贝需要的字段,Answer在源头就不进入渲染流程。这个习惯不仅适用于试卷生成,任何涉及“数据导出给外部用户”的场景都适用——只导出对方需要的字段,而不是把整行数据全量输出。这个教训是我自己真摔过的:第一次做类似系统时直接在Word模板里绑定了全字段,发出去的试卷哐哐带着答案,教务老师当场脸黑。从那以后,所有输出路径上的对象一律去皮,Answer字段永远不碰。
6. 进阶一步:按知识点权重抽题,并给试卷做同题率校验
6.1 把抽题规则做成配置表:章节权重、题型分布、难度梯度一次说清
基础版抽题规则是写死的——代码里定义“单选10道、多选5道、简答3道”。但真实考试里,第一章节占30分、第四章占20分,难度比例也有要求。把规则写死在代码里,改一次规则就要重新编译一次。进阶做法是把抽题规则落成一张配置表。
CREATE TABLE PaperTemplate ( TemplateId INT IDENTITY(1,1) PRIMARY KEY, TemplateName NVARCHAR(100) NOT NULL, -- 如“期中考试模板” ChapterId INT NOT NULL, QuestionType INT NOT NULL, Difficulty INT NOT NULL DEFAULT 0, -- 0表示不限制难度 QuestionCount INT NOT NULL, -- 该类别抽多少题 IsEnabled BIT DEFAULT 1 );生成试卷时,程序读取模板里所有启用的规则,逐条调用3.1的抽题SQL,最后合并成整套试卷。这样调整试卷结构不需要动代码,老师或管理员直接在界面上改配置表即可。课程设计做到这一层,已经超出“交作业”的水平,接近可实际使用的工具。
抽题算法的选择也可以再进一步——ORDER BY NEWID()本质是每次都全表排序,另一种方案是先用SELECT QuestionId FROM QuestionBank取出全部题号,在C#里用Random洗牌后取前N个,再回表查详情。这个方案的优点是随机性可控、便于复现,缺点是比SQL直接抽取多一次往返。我的建议:默认用SQL方案,因为代码少、语义清晰;只有当你需要“同一批题,不同顺序生成两卷”时才在C#层洗牌。
6.2 用同题率校验代码兜底:A卷和B卷不能长一个样
同题率校验不管在生成前做了多少预检,最后一步都值得再用代码强制确认一次。因为规则组合多了之后,人为推演容易漏。
public static double CalculateDuplicateRate(List<Question> paperA, List<Question> paperB) { var idsA = paperA.Select(q => q.QuestionId).ToHashSet(); var idsB = paperB.Select(q => q.QuestionId).ToHashSet(); if (idsA.Count == 0) return 0; int duplicateCount = idsA.Intersect(idsB).Count(); return (double)duplicateCount / idsA.Count; }逻辑说明:重复率的计算口径是“重复题数除以A卷总题数”。如果A卷50题,B卷50题,两道题重复,重复率是4%而不是“两卷总共100题,重复2题,占2%”。按前者计算更贴近考试场景的感知——因为学生对照的是A卷和B卷的“相似度”,不是你从两卷提取所有题目后的数学统计。
校验的阈值设置没有绝对标准,我的习惯是20%封顶。超过20%意味着邻座学生很容易通过余光发现“这道题我见过”,一个考场基本就废了。如果你用的是同题换序策略,这个校验天然会报警,因为重复率100%;所以这个校验只服务于错题抽题策略,同题换序策略应该走另一条校验逻辑——比较两卷的题目顺序排列差异度。
这里有个容易被忽略的细节:题库题量决定AB卷可用的隔离度。如果某类题型可用题目只有15道,A卷要抽10道,B卷强行排除A卷题目后只有5道可选,根本组不成B卷。所以业界常见做法是“AB卷同源但不同序”和“完全独立命题”混合——基础题靠题量隔离,高分题靠选项和题序隔离。
我自己做这套系统时,最深的教训是:随机抽题只是组卷的一半,另一半是“抽出来的题组合在一起是否合理”。你会遇到A卷整卷都是简单题、B卷整卷都是难题的情况,这只能用“按难度分层配额”解决——比如规则里明确“单选10题中,难度1~2的4题、难度3的4题、难度4~5的2题”,而不是只写“单选10题”。把这一步做成配置后,整卷难度分布才算真正可控。
希望你拿着这套方案能少走我走过的弯路,从第一张表到生成的AB卷,一次跑通。
本文还有配套的精品资源,点击获取