简介:本资源是一套基于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 的学生选课及成绩查询管理系统,界面用 CS(Client/Server)模式。它适合两类人:一是课程设计或毕设需要从零搭建一套可运行系统的学生开发者,二是刚接手校内遗留系统、需要快速理解数据流和改造点的初级工程师。接下来我会按“建库建表 → 客户端连接 → 选课事务 → 成绩查询 → 避坑 → 进阶”的顺序,把每一步的参数、代码和踩坑点讲清楚。
2. 先把数据库骨架搭对:表结构、约束与索引设计
2.1 四张核心表与字段类型选择
一套能跑通的选课成绩系统,最少需要四张表:学生表、课程表、选课记录表、成绩表。很多教程把选课和成绩合并成一张表,结果查询逻辑越写越乱。我一般拆开:选课记录只存“谁选了哪门课、什么时候选的、状态是什么”,成绩单独一张表,通过选课ID关联。这样做的好处是,退课时只改选课状态,成绩记录不受影响;补考或重修时,成绩表可以追加新记录而不覆盖历史。
字段类型上,学号和课程号用VARCHAR(20)而不是INT,因为真实学号常带字母或前导零。选课时间用DATETIME2而不是DATETIME,精度更高,避免同一秒内多人选课导致排序歧义。状态字段用TINYINT,0 表示已退选,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 CourseId=@cid AND Status=1,在几千条记录时还能忍,上万条就开始拖慢界面。我一般建一个非聚集索引在(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 name="SchoolDb" connectionString="Server=192.168.1.10;Database=SchoolSystem;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>参数说明:Server可以写 IP 或localhost;TrustServerCertificate=True在开发环境跳过证书验证,生产环境应换成有效证书。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 StudentId=@sid AND Password=@pwd"; 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 CourseId=@cid AND Status=1 ) FROM Courses WITH (UPDLOCK) WHERE CourseId=@cid; 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。这样成绩表的外键不会断,历史记录可追溯。退课后余量自动增加,因为视图和统计查询都只算Status=1。注意退课也要放在事务里,并且要检查该学生是否确实选了这门课。
-- 退课:只改状态,不删记录 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 StudentId=@sid AND CourseId=@cid) UPDATE Scores SET Score=@score, ExamTime=SYSDATETIME() WHERE StudentId=@sid AND CourseId=@cid; 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,只显示Status=1的记录。教务统计需要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违反主键。解决:选课前先检查是否存在Status=0的记录,如果有则UPDATE回Status=1并更新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 CourseId=@CourseId AND Status=1 ) FROM Courses WITH (UPDLOCK) WHERE CourseId=@CourseId; IF @remaining > 0 BEGIN -- 如果存在退选记录,改状态;否则插入新记录 IF EXISTS (SELECT 1 FROM Enrollments WHERE StudentId=@StudentId AND CourseId=@CourseId) UPDATE Enrollments SET Status=1, EnrollTime=SYSDATETIME() WHERE StudentId=@StudentId AND CourseId=@CourseId; 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 章多看两遍,能省下很多调试时间。希望帮到你。
本文还有配套的精品资源,点击获取