简介本资源是一套基于C#与SQL Server开发的学生选课及成绩查询管理系统采用WinForm界面面向计算机相关专业学生、课程设计开发者及需要C#数据库实战案例的学习者可帮助理解桌面端信息管理系统的完整实现思路。压缩包共147个文件约3.93MB以44个cs源码文件为核心配合20个resx与20个resources资源文件、23个png及若干jpg图片另有sln解决方案、csproj项目文件、sql脚本、mdf与ldf数据库文件及config配置、dll依赖等结构完整便于直接编译运行与二次开发。系统区分学生与管理员两类角色管理员可维护管理员与学生信息、开设与查询课程、录入统计及修改成绩、修改个人密码学生可选课、查询课程、查看课表与成绩单、修改密码功能覆盖选课与成绩管理的主要业务场景。目前已有581人学习下载适合作为课程设计参考、数据库与WinForm综合练习的实战素材。1. 选课系统为什么总在期末崩从 C# 客户端到 SQL Server 的落地全貌每到选课季教务系统崩溃几乎成了固定节目。学生疯狂刷新课程容量瞬间归零成绩查询页面转圈到超时。很多校内系统的技术栈其实并不复杂——C# WinForms 做客户端SQL Server 做数据存储两层 C/S 架构部署在校园内网。问题往往不出在“技术选型太落后”而出在并发控制、事务边界和索引设计这些基本功上。这个标题指向的正是这样一套系统基于 C# 和 SQL Server 的学生选课及成绩查询管理系统界面用 CSClient/Server模式。它适合两类人一是课程设计或毕设需要从零搭建一套可运行系统的学生开发者二是刚接手校内遗留系统、需要快速理解数据流和改造点的初级工程师。接下来我会按“建库建表 → 客户端连接 → 选课事务 → 成绩查询 → 避坑 → 进阶”的顺序把每一步的参数、代码和踩坑点讲清楚。2. 先把数据库骨架搭对表结构、约束与索引设计2.1 四张核心表与字段类型选择一套能跑通的选课成绩系统最少需要四张表学生表、课程表、选课记录表、成绩表。很多教程把选课和成绩合并成一张表结果查询逻辑越写越乱。我一般拆开选课记录只存“谁选了哪门课、什么时候选的、状态是什么”成绩单独一张表通过选课ID关联。这样做的好处是退课时只改选课状态成绩记录不受影响补考或重修时成绩表可以追加新记录而不覆盖历史。字段类型上学号和课程号用VARCHAR(20)而不是INT因为真实学号常带字母或前导零。选课时间用DATETIME2而不是DATETIME精度更高避免同一秒内多人选课导致排序歧义。状态字段用TINYINT0 表示已退选1 表示已选上2 表示已修完。成绩用DECIMAL(5,2)范围 0.00 到 100.00比FLOAT精确避免 59.999 显示成 60 的玄学问题。-- 学生表学号为主键姓名和班级用于界面展示 CREATE TABLE Students ( StudentId VARCHAR(20) PRIMARY KEY, StudentName NVARCHAR(50) NOT NULL, ClassName NVARCHAR(50) NOT NULL, Password VARCHAR(64) NOT NULL -- 存哈希不存明文 ); -- 课程表容量字段是选课并发控制的关键 CREATE TABLE Courses ( CourseId VARCHAR(20) PRIMARY KEY, CourseName NVARCHAR(100) NOT NULL, Credit DECIMAL(3,1) NOT NULL, Capacity INT NOT NULL DEFAULT 50, TeacherName NVARCHAR(50) NOT NULL ); -- 选课记录表联合主键防止同一学生重复选同一门课 CREATE TABLE Enrollments ( StudentId VARCHAR(20) NOT NULL, CourseId VARCHAR(20) NOT NULL, EnrollTime DATETIME2 NOT NULL DEFAULT SYSDATETIME(), Status TINYINT NOT NULL DEFAULT 1, CONSTRAINT PK_Enrollments PRIMARY KEY (StudentId, CourseId), CONSTRAINT FK_Enroll_Student FOREIGN KEY (StudentId) REFERENCES Students(StudentId), CONSTRAINT FK_Enroll_Course FOREIGN KEY (CourseId) REFERENCES Courses(CourseId) ); -- 成绩表通过选课联合主键关联允许同一学生同一课程有多条成绩记录补考/重修 CREATE TABLE Scores ( StudentId VARCHAR(20) NOT NULL, CourseId VARCHAR(20) NOT NULL, Score DECIMAL(5,2) NULL, ExamTime DATETIME2 NOT NULL DEFAULT SYSDATETIME(), CONSTRAINT FK_Score_Enroll FOREIGN KEY (StudentId, CourseId) REFERENCES Enrollments(StudentId, CourseId) );逻辑说明Enrollments表的联合主键(StudentId, CourseId)是防止重复选课的第一道防线比在应用层用SELECT再INSERT可靠得多。Scores表的外键指向Enrollments的联合主键保证只有选过课的学生才能录入成绩。参数上Capacity默认 50 只是占位实际建课时由教务录入。Password字段长度 64 是为了存 SHA-256 哈希值后面客户端登录会用到。2.2 选课人数统计的索引与视图选课界面最常执行的查询是“查某门课已选人数”和“查某学生已选课程列表”。前者如果每次都用SELECT COUNT(*) FROM Enrollments WHERE CourseIdcid AND Status1在几千条记录时还能忍上万条就开始拖慢界面。我一般建一个非聚集索引在(CourseId, Status)上让统计走索引覆盖。-- 覆盖索引加速按课程统计已选人数 CREATE NONCLUSTERED INDEX IX_Enroll_Course_Status ON Enrollments (CourseId, Status) INCLUDE (StudentId); -- 视图把课程余量算好客户端直接查 CREATE VIEW CourseAvailability AS SELECT c.CourseId, c.CourseName, c.Capacity, COUNT(e.StudentId) AS EnrolledCount, c.Capacity - COUNT(e.StudentId) AS Remaining FROM Courses c LEFT JOIN Enrollments e ON c.CourseId e.CourseId AND e.Status 1 GROUP BY c.CourseId, c.CourseName, c.Capacity;逻辑说明INCLUDE (StudentId)让统计查询不用回表直接走索引页。视图CourseAvailability把余量计算封装在数据库层客户端只负责显示避免每个界面都写一遍COUNT。注意LEFT JOIN的条件里带了e.Status 1这样已退选的记录不会被算进已选人数。如果写成WHERE e.Status 1那些没人选的课程会直接从视图里消失选课界面就看不到余量为满额的课了。3. C# 客户端连接 SQL Server连接字符串与参数化查询3.1 连接字符串的三种写法与选择WinForms 客户端连 SQL Server常见做法有三种Windows 身份验证、SQL Server 身份验证、以及从配置文件读取。课程设计里我推荐用 SQL Server 身份验证因为换一台机器演示时不用配 Windows 账户。连接字符串放在App.config的connectionStrings节点里代码里用ConfigurationManager.ConnectionStrings[SchoolDb].ConnectionString读取。!-- App.config连接字符串独立存放换环境只改这里 -- configuration connectionStrings add nameSchoolDb connectionStringServer192.168.1.10;DatabaseSchoolSystem;User Idsa;PasswordYourStrong!Passw0rd;TrustServerCertificateTrue; providerNameSystem.Data.SqlClient / /connectionStrings /configuration参数说明Server可以写 IP 或localhostTrustServerCertificateTrue在开发环境跳过证书验证生产环境应换成有效证书。User Id不要用sa建一个只有db_datareader和db_datawriter权限的专用账户避免客户端被反编译后直接拿到最高权限。连接字符串里的密码如果明文存放至少把配置文件设为“始终复制”但不要提交到公开仓库。3.2 参数化查询与 SqlConnection 的正确打开方式很多初学者写SELECT * FROM Students WHERE StudentId txtId.Text 这是 SQL 注入的经典翻车现场。正确做法是用SqlParameter。另外SqlConnection实现了IDisposable用using包裹确保连接池及时归还。// 登录校验参数化查询 using 确保连接释放 public bool ValidateStudent(string studentId, string password) { string hash ComputeSha256(password); // 先哈希再比对 string sql SELECT COUNT(1) FROM Students WHERE StudentIdsid AND Passwordpwd; using (var conn new SqlConnection(GetConnectionString())) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.Add(sid, SqlDbType.VarChar, 20).Value studentId; cmd.Parameters.Add(pwd, SqlDbType.VarChar, 64).Value hash; conn.Open(); int count (int)cmd.ExecuteScalar(); return count 0; } }逻辑说明Add方法显式指定SqlDbType和长度比AddWithValue更可控避免字符串截断或隐式转换。ExecuteScalar返回第一行第一列适合COUNT查询。ComputeSha256是自定义方法把明文密码转成 64 位十六进制字符串。注意conn.Open()放在using内部即使抛异常也会在离开作用域时关闭连接。连接池默认开启频繁开关连接不会造成明显开销但不要每次查询都新建SqlConnection而不释放。4. 选课事务与并发控制为什么你的余量总是负数4.1 用事务包裹“检查余量 插入记录”选课的核心逻辑是先查课程余量如果大于 0就插入选课记录。这两步必须在一个事务里否则两个学生同时查到余量 1都插入余量变成 -1。SQL Server 默认隔离级别是READ COMMITTED普通SELECT不会锁住行所以并发插入会绕过检查。// 选课事务在数据库端用 UPDLOCK 锁住课程行再检查余量 public bool EnrollCourse(string studentId, string courseId) { string sql BEGIN TRANSACTION; DECLARE remaining INT; SELECT remaining Capacity - ( SELECT COUNT(*) FROM Enrollments WITH (UPDLOCK) WHERE CourseIdcid AND Status1 ) FROM Courses WITH (UPDLOCK) WHERE CourseIdcid; IF remaining 0 BEGIN INSERT INTO Enrollments (StudentId, CourseId, Status) VALUES (sid, cid, 1); COMMIT TRANSACTION; SELECT 1; END ELSE BEGIN ROLLBACK TRANSACTION; SELECT 0; END; using (var conn new SqlConnection(GetConnectionString())) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.Add(sid, SqlDbType.VarChar, 20).Value studentId; cmd.Parameters.Add(cid, SqlDbType.VarChar, 20).Value courseId; conn.Open(); return (int)cmd.ExecuteScalar() 1; } }逻辑说明WITH (UPDLOCK)在读取课程行和选课记录时加更新锁阻止其他事务同时修改同一课程。BEGIN TRANSACTION和COMMIT/ROLLBACK保证插入和检查原子性。如果插入时违反联合主键重复选课会抛异常外层应捕获SqlException并提示“已选过该课程”。参数sid和cid都是VARCHAR(20)与表定义一致。4.2 退课与容量释放的边界退课不是DELETE而是把Status改成 0。这样成绩表的外键不会断历史记录可追溯。退课后余量自动增加因为视图和统计查询都只算Status1。注意退课也要放在事务里并且要检查该学生是否确实选了这门课。-- 退课只改状态不删记录 UPDATE Enrollments SET Status 0 WHERE StudentId sid AND CourseId cid AND Status 1; -- 检查是否真的更新了行 IF ROWCOUNT 0 RAISERROR(未找到有效选课记录, 16, 1);参数说明ROWCOUNT返回上一条语句影响的行数为 0 说明该学生没有选这门课或已经退选。RAISERROR抛出自定义错误C# 端捕获后提示用户。注意不要用DELETE否则Scores表的外键会阻止删除或者级联删除导致成绩丢失。5. 成绩查询与录入从单条录入到批量导入的取舍5.1 单条成绩录入与校验成绩录入界面通常是一个DataGridView教师选课程后加载学生名单逐个填分数。保存时逐条UPDATE或INSERT。这里的关键是分数范围校验和空值处理。DECIMAL(5,2)允许 NULL表示尚未录入。如果教师填了 105数据库不会报错但业务上不合理所以要在 C# 端用decimal.TryParse并判断 0 到 100。// 保存单条成绩先校验再参数化写入 public bool SaveScore(string studentId, string courseId, string scoreText) { if (!decimal.TryParse(scoreText, out decimal score) || score 0 || score 100) return false; // 分数非法直接拒绝 string sql IF EXISTS (SELECT 1 FROM Scores WHERE StudentIdsid AND CourseIdcid) UPDATE Scores SET Scorescore, ExamTimeSYSDATETIME() WHERE StudentIdsid AND CourseIdcid; ELSE INSERT INTO Scores (StudentId, CourseId, Score) VALUES (sid, cid, score);; using (var conn new SqlConnection(GetConnectionString())) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.Add(sid, SqlDbType.VarChar, 20).Value studentId; cmd.Parameters.Add(cid, SqlDbType.VarChar, 20).Value courseId; cmd.Parameters.Add(score, SqlDbType.Decimal).Value score; conn.Open(); return cmd.ExecuteNonQuery() 0; } }逻辑说明IF EXISTS判断是否已有成绩记录有则更新无则插入。这样补考录入时不会产生重复行。ExamTime每次更新为当前时间方便追溯。参数score用SqlDbType.Decimal而不是AddWithValue避免 C#decimal被隐式转成float导致精度丢失。5.2 成绩查询的三种典型场景与 SQL 写法成绩查询分三种学生查自己所有成绩、教师查某课程所有学生成绩、教务查某班级某课程平均分。三种场景对应不同的JOIN和GROUP BY。学生查询最简单按StudentId过滤即可。教师查询需要关联Enrollments和Students只显示Status1的记录。教务统计需要AVG和GROUP BY ClassName。-- 场景一学生查自己成绩含课程名和学分 SELECT c.CourseName, c.Credit, s.Score, s.ExamTime FROM Scores s JOIN Courses c ON s.CourseId c.CourseId WHERE s.StudentId sid ORDER BY s.ExamTime DESC; -- 场景二教师查某课程成绩单只显示已选学生 SELECT st.StudentId, st.StudentName, st.ClassName, sc.Score FROM Enrollments e JOIN Students st ON e.StudentId st.StudentId LEFT JOIN Scores sc ON e.StudentId sc.StudentId AND e.CourseId sc.CourseId WHERE e.CourseId cid AND e.Status 1 ORDER BY st.StudentId; -- 场景三按班级统计平均分只算有成绩的记录 SELECT st.ClassName, AVG(sc.Score) AS AvgScore, COUNT(sc.Score) AS ScoredCount FROM Scores sc JOIN Students st ON sc.StudentId st.StudentId WHERE sc.CourseId cid AND sc.Score IS NOT NULL GROUP BY st.ClassName;参数说明场景二用LEFT JOIN是为了让未录入成绩的学生也显示在列表中Score为 NULL教师可以继续录入。场景三的WHERE sc.Score IS NOT NULL避免 NULL 参与AVG计算导致结果偏差。COUNT(sc.Score)只统计非空成绩比COUNT(*)更准确。6. 避坑与排查选课系统最常见的五个翻车现场6.1 现象选课成功但余量没变刷新后又显示已满原因客户端缓存了课程列表选课成功后没有重新查询视图。或者统计查询用了WITH (NOLOCK)读到了未提交的脏数据。解决选课成功后强制刷新CourseAvailability视图去掉NOLOCK提示改用默认隔离级别。如果性能实在扛不住用READ COMMITTED SNAPSHOT隔离级别而不是脏读。6.2 现象两个学生同时选最后一门课都提示成功原因事务里用了SELECT但没有加UPDLOCK两个事务都读到余量 1都插入。解决按第 4 章的写法在SELECT课程行和选课记录时加WITH (UPDLOCK)。更彻底的做法是在Courses表加一个Version列用乐观并发但课程设计里UPDLOCK足够。6.3 现象成绩录入后查询不到或者分数变成整数原因Score字段定义成了INT或FLOAT插入 85.5 被截断或四舍五入。解决建表时用DECIMAL(5,2)C# 端用decimal类型SqlParameter显式指定SqlDbType.Decimal。如果已经建错表用ALTER TABLE Scores ALTER COLUMN Score DECIMAL(5,2)修改但注意先备份数据。6.4 现象连接字符串报错“未找到命名连接”原因App.config没有复制到输出目录或者ConfigurationManager引用了错误的程序集。解决在解决方案资源管理器里选中App.config属性中“复制到输出目录”设为“始终复制”。C# 项目需要引用System.Configuration程序集代码里using System.Configuration;。6.5 现象退课后重新选同一门课提示主键冲突原因Enrollments表用了联合主键退课只是把Status改成 0记录还在。重新选课时INSERT违反主键。解决选课前先检查是否存在Status0的记录如果有则UPDATE回Status1并更新EnrollTime而不是直接INSERT。或者把主键改成自增 ID联合唯一约束放在(StudentId, CourseId, Status)上但这样查询逻辑更复杂课程设计里不推荐。7. 进阶技巧用存储过程收口业务逻辑与查询计划缓存7.1 把选课和退课写成存储过程客户端直接拼 SQL 的坏处是业务逻辑散落在 C# 代码里改一个规则要重新编译发布。把选课、退课、成绩录入写成存储过程客户端只传参数逻辑在数据库端统一维护。存储过程还能享受 SQL Server 的执行计划缓存减少编译开销。-- 选课存储过程封装事务和并发控制 CREATE PROCEDURE sp_EnrollCourse StudentId VARCHAR(20), CourseId VARCHAR(20), Result INT OUTPUT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; DECLARE remaining INT; SELECT remaining Capacity - ( SELECT COUNT(*) FROM Enrollments WITH (UPDLOCK) WHERE CourseIdCourseId AND Status1 ) FROM Courses WITH (UPDLOCK) WHERE CourseIdCourseId; IF remaining 0 BEGIN -- 如果存在退选记录改状态否则插入新记录 IF EXISTS (SELECT 1 FROM Enrollments WHERE StudentIdStudentId AND CourseIdCourseId) UPDATE Enrollments SET Status1, EnrollTimeSYSDATETIME() WHERE StudentIdStudentId AND CourseIdCourseId; ELSE INSERT INTO Enrollments (StudentId, CourseId, Status) VALUES (StudentId, CourseId, 1); SET Result 1; END ELSE SET Result 0; COMMIT TRANSACTION; END TRY BEGIN CATCH IF TRANCOUNT 0 ROLLBACK TRANSACTION; SET Result -1; -- -1 表示异常 END CATCH END逻辑说明Result输出参数返回 1 成功、0 余量不足、-1 异常。TRY...CATCH保证异常时回滚。IF EXISTS处理退选后重选的情况避免主键冲突。C# 端调用时用cmd.Parameters.Add(Result, SqlDbType.Int).Direction ParameterDirection.Output;执行后读cmd.Parameters[Result].Value。7.2 用查询计划缓存和索引优化成绩查询成绩查询在期末会变得很慢因为Scores表数据量最大。除了在(StudentId, CourseId)上建聚集索引联合主键默认就是聚集索引还应该在ExamTime上建非聚集索引方便按时间排序。如果经常按课程统计在(CourseId, Score)上建覆盖索引。-- 加速按课程统计成绩 CREATE NONCLUSTERED INDEX IX_Scores_Course_Score ON Scores (CourseId, Score) INCLUDE (StudentId); -- 查看查询是否走索引在 SSMS 里开“实际执行计划” -- 如果看到 Table Scan 或 Clustered Index Scan说明索引没命中参数说明INCLUDE (StudentId)让统计查询不用回表。执行计划里如果出现Key Lookup说明索引缺少覆盖列需要把查询用到的列加进INCLUDE。注意不要过度建索引每次INSERT和UPDATE都要维护索引选课高峰期插入频繁索引太多会拖慢写入。我自己的习惯是每次改完存储过程或索引先在测试库用SET STATISTICS IO ON和SET STATISTICS TIME ON跑一遍典型查询看逻辑读和 CPU 时间有没有下降。如果逻辑读没变说明索引没被用上得检查查询条件是否匹配索引列顺序。这套系统不复杂但并发和事务的坑一个不少把第 4 章和第 6 章多看两遍能省下很多调试时间。希望帮到你。本文还有配套的精品资源点击获取