☰
C# WinForms学生选课系统实战:从数据库设计到并发控制
2026/10/1 3:17:18 网站建设 项目流程

简介:这份资源是一套基于C# Windows窗体开发的学生信息管理与选课系统,面向正在学习桌面应用开发、需要课程设计或毕业设计参考的高校学生与初级开发者。系统采用SQL Server数据库与三层架构,包含学生端和教师端两个登录入口,覆盖管理员注册登录、学生信息增删改查、课程发布、学生信息上传及选课等完整业务闭环,并涉及图片存储等实用技术点。压缩包共141个文件,以cs源码、dll动态库、pdb调试文件、resx资源文件及config配置为主,整体约1.5MB,另附数据库表结构代码与简要说明,按提示生成数据库即可运行。目前已有4416人学习下载,适合希望理解三层架构分层设计、掌握窗体与数据库交互及选课逻辑实现的读者参考借鉴。

1. 从一份课程设计说起:C# WinForms 学生选课系统到底要解决什么

每年毕业季,计算机专业的学生都会接到一份「数据库课程设计」任务,其中出现频率最高的题目之一就是学生信息管理系统。而一旦把「选课」这个业务塞进去,事情就变得有意思了——它不再是简单的增删改查,而是涉及多表关联、事务控制、并发冲突和界面状态同步。用 C# Windows 窗体配合 SQL Server 或 MySQL 做一套带数据库的选课系统,本质上是在训练一种能力:把现实中的选课规则翻译成表结构,再把表结构翻译成用户能点得动的界面。

这套系统适合谁?一是正在做课程设计、需要一份能跑起来、能答辩的作品的同学;二是刚接触 C# 和 ADO.NET、想找一个完整项目练手的入门开发者;三是需要快速搭一个教务类桌面工具原型的工程师。它不追求高并发分布式,但要求你理解连接池、参数化查询、事务边界和 DataGridView 的数据绑定机制。下面我按实际开发顺序,把选型、建库、核心功能、避坑和进阶技巧一层层拆开讲。

2. 技术选型与环境搭建:为什么是 WinForms + ADO.NET + SQL Server

2.1 桌面端选 WinForms 而不是 WPF 的现实理由

很多教程一上来就推 WPF,说它界面现代、数据绑定强。但如果你做过课程设计答辩就知道,老师看的是功能完整度和代码结构,不是动画效果。WinForms 的优势在于拖控件快、事件模型直观、DataGridView 开箱即用,一个下午就能把主界面搭出来。WPF 的 XAML 学习曲线对新手不友好,数据绑定写错一个 PropertyChanged 就整个界面不刷新,调试成本高。

另一个现实因素是资料密度。你在搜索引擎里搜「C# 学生信息管理系统」,90% 的示例是 WinForms。遇到问题能快速找到参考,这在赶进度的时候比技术先进更重要。当然,WinForms 也有它的边界:高 DPI 缩放会糊、复杂自定义绘制麻烦、MVVM 支持弱。但对于选课系统这种以表格和表单为主的场景,够用。

2.2 数据库选 SQL Server Express 还是 MySQL

标题里写的是「含数据库」,没有指定具体产品。我一般推荐 SQL Server Express,原因有三:第一,Visual Studio 对它的集成最顺,服务器资源管理器里直接建库建表,不用额外装客户端;第二,C# 的 SqlClient 是原生驱动,不需要像 MySQL 那样额外引 NuGet 包;第三,课程设计环境里老师机器上大概率有 SQL Server。

但如果你已经装了 MySQL,或者学校要求用开源方案,也完全可以。下面给一个连接字符串的对比,方便你按自己的环境改:

数据库连接字符串示例需要额外安装
SQL Server ExpressServer=.\SQLEXPRESS;Database=CourseSelectDB;Trusted_Connection=True;SQL Server Express
MySQL 8.xServer=localhost;Database=CourseSelectDB;Uid=root;Pwd=你的密码;MySQL Connector/NET
SQLiteData Source=CourseSelectDB.db;Version=3;System.Data.SQLite

提示:课程设计建议用 SQL Server Express,省去驱动配置的麻烦。如果换 MySQL,记得在 NuGet 里装 MySql.Data,并把代码里的 SqlConnection 换成 MySqlConnection。

2.3 建库建表:四张核心表的最小可用结构

选课系统的数据模型不复杂,但要把关系理清。核心是四张表:学生表、课程表、选课记录表、用户表(用于登录)。选课记录表是典型的中间表,用联合主键防止重复选课。

-- 学生表 CREATE TABLE Students ( StudentId VARCHAR(20) PRIMARY KEY, -- 学号,业务主键 Name NVARCHAR(50) NOT NULL, Gender CHAR(2), Major NVARCHAR(50), Grade INT ); -- 课程表 CREATE TABLE Courses ( CourseId VARCHAR(20) PRIMARY KEY, -- 课程号 CourseName NVARCHAR(100) NOT NULL, Credit DECIMAL(3,1), -- 学分,保留一位小数 Capacity INT NOT NULL, -- 容量上限 Teacher NVARCHAR(50) ); -- 选课记录表:联合主键防止同一学生重复选同一门课 CREATE TABLE Enrollments ( StudentId VARCHAR(20) NOT NULL, CourseId VARCHAR(20) NOT NULL, EnrollTime DATETIME DEFAULT GETDATE(), Score DECIMAL(5,1) NULL, -- 成绩,选课时为空 PRIMARY KEY (StudentId, CourseId), FOREIGN KEY (StudentId) REFERENCES Students(StudentId), FOREIGN KEY (CourseId) REFERENCES Courses(CourseId) ); -- 用户表:登录用,角色区分学生和管理员 CREATE TABLE Users ( UserId VARCHAR(20) PRIMARY KEY, Password VARCHAR(64) NOT NULL, -- 存哈希,不存明文 Role VARCHAR(10) NOT NULL -- 'student' 或 'admin' );

这里有几个设计决策值得说明。学号和课程号用 VARCHAR 而不是 INT,因为真实学号可能带字母或前导零。选课记录表用 (StudentId, CourseId) 联合主键,数据库层面就杜绝了重复选课,比在代码里查一遍再插入可靠得多。Score 字段允许 NULL,因为选课那一刻还没有成绩,这个空值语义要保留。密码字段长度给 64,是为了存 SHA256 哈希后的十六进制字符串。

2.4 项目结构:三层分离但不过度设计

新手常犯的错是把所有代码写在 Form 的按钮事件里,几百行堆在一起,改一个查询要翻半天。我建议至少分三个文件夹:Models 放实体类,DAL 放数据库访问,Forms 放界面。不用上依赖注入和仓储模式,那对课程设计是过度设计。

// Models/Student.cs public class Student { public string StudentId { get; set; } public string Name { get; set; } public string Gender { get; set; } public string Major { get; set; } public int Grade { get; set; } } // DAL/DbHelper.cs public static class DbHelper { // 连接字符串集中管理,换数据库只改这一处 private static readonly string ConnStr = @"Server=.\SQLEXPRESS;Database=CourseSelectDB;Trusted_Connection=True;"; public static SqlConnection GetConnection() { var conn = new SqlConnection(ConnStr); conn.Open(); return conn; } }

把连接字符串集中在一个地方,后面换环境只改一行。GetConnection 里直接 Open,调用方用 using 包起来,确保释放回连接池。ADO.NET 默认开启连接池,不用手动管理,但前提是你得及时 Dispose,否则池子会被占满。

3. 核心功能实现:登录、选课、退课与容量控制

3.1 登录模块:参数化查询与密码哈希

登录是第一个要跑通的功能,也是新手最容易写出 SQL 注入的地方。下面这段代码演示正确的做法:

// DAL/UserDAL.cs public static bool ValidateUser(string userId, string password, out string role) { role = null; // 先对输入密码做哈希,与库中存储的哈希比对 string hash = ComputeSha256(password); string sql = "SELECT Role FROM Users WHERE UserId=@uid AND Password=@pwd"; using (var conn = DbHelper.GetConnection()) using (var cmd = new SqlCommand(sql, conn)) { // 参数化:杜绝 ' OR '1'='1 这类注入 cmd.Parameters.AddWithValue("@uid", userId); cmd.Parameters.AddWithValue("@pwd", hash); using (var reader = cmd.ExecuteReader()) { if (reader.Read()) { role = reader.GetString(0); return true; } } } return false; } private static string ComputeSha256(string input) { using (var sha = System.Security.Cryptography.SHA256.Create()) { byte[] bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); // 转成十六进制字符串,长度 64,与表结构对应 return BitConverter.ToString(bytes).Replace("-", "").ToLower(); } }

关键点有三个。第一,用 @uid 和 @pwd 占位符,绝不用字符串拼接。第二,密码存哈希不存明文,即使用户表被拖走也拿不到原始密码。第三,out 参数返回角色,调用方根据角色决定跳转到学生界面还是管理员界面。登录成功后把 UserId 和 Role 存到一个静态类或主窗体的字段里,后续选课操作要用。

3.2 选课功能:事务 + 容量检查的完整流程

选课是整套系统的核心,也是最容易出并发问题的地方。假设课程容量 50 人,当前已选 49 人,两个学生同时点选课,如果先查人数再插入,可能都查到 49,然后都插入成功,变成 51 人。解决办法是把检查和插入放在一个事务里,并对课程行加锁。

// DAL/EnrollDAL.cs public static string EnrollCourse(string studentId, string courseId) { using (var conn = DbHelper.GetConnection()) using (var tran = conn.BeginTransaction()) { try { // 第一步:锁定课程行,防止并发超选 string checkSql = @" SELECT Capacity, (SELECT COUNT(*) FROM Enrollments WHERE CourseId=@cid) AS Enrolled FROM Courses WITH (UPDLOCK, ROWLOCK) WHERE CourseId=@cid"; int capacity, enrolled; using (var cmd = new SqlCommand(checkSql, conn, tran)) { cmd.Parameters.AddWithValue("@cid", courseId); using (var reader = cmd.ExecuteReader()) { if (!reader.Read()) return "课程不存在"; capacity = reader.GetInt32(0); enrolled = reader.GetInt32(1); } } if (enrolled >= capacity) return "课程已满"; // 第二步:检查是否已选过(联合主键也会拦,但提前给出友好提示) string dupSql = "SELECT COUNT(*) FROM Enrollments WHERE StudentId=@sid AND CourseId=@cid"; using (var cmd = new SqlCommand(dupSql, conn, tran)) { cmd.Parameters.AddWithValue("@sid", studentId); cmd.Parameters.AddWithValue("@cid", courseId); int exists = (int)cmd.ExecuteScalar(); if (exists > 0) return "你已经选过这门课"; } // 第三步:插入选课记录 string insertSql = "INSERT INTO Enrollments(StudentId, CourseId) VALUES(@sid, @cid)"; using (var cmd = new SqlCommand(insertSql, conn, tran)) { cmd.Parameters.AddWithValue("@sid", studentId); cmd.Parameters.AddWithValue("@cid", courseId); cmd.ExecuteNonQuery(); } tran.Commit(); return "选课成功"; } catch (Exception ex) { tran.Rollback(); return "选课失败:" + ex.Message; } } }

这段代码的逻辑链条是:开事务 → 锁课程行并统计已选人数 → 判断容量 → 判断重复 → 插入 → 提交。WITH (UPDLOCK, ROWLOCK) 是 SQL Server 的锁提示,UPDLOCK 表示在读取时就加更新锁,阻止其他事务同时读取并准备更新同一行;ROWLOCK 表示锁粒度到行而不是页。这两个提示配合事务,能有效防止超选。

参数说明:@sid 是学号,@cid 是课程号,都通过 AddWithValue 传入。返回字符串直接给界面弹 MessageBox 用,简单直接。如果你用 MySQL,锁提示语法不同,需要用 SELECT ... FOR UPDATE,且要确保表引擎是 InnoDB。

3.3 退课与选课列表刷新:DataGridView 绑定技巧

退课逻辑比选课简单,删记录即可,但要注意只有未出成绩的课才能退。界面这边,选课列表用 DataGridView 展示,刷新时不要每次重新 new 一个 DataGridView,而是更新 DataSource。

// 刷新学生已选课程列表 public static DataTable GetMyCourses(string studentId) { string sql = @" SELECT c.CourseId, c.CourseName, c.Credit, c.Teacher, e.EnrollTime, e.Score FROM Enrollments e INNER JOIN Courses c ON e.CourseId = c.CourseId WHERE e.StudentId = @sid ORDER BY e.EnrollTime DESC"; using (var conn = DbHelper.GetConnection()) using (var cmd = new SqlCommand(sql, conn)) using (var adapter = new SqlDataAdapter(cmd)) { cmd.Parameters.AddWithValue("@sid", studentId); var dt = new DataTable(); adapter.Fill(dt); return dt; } } // 窗体里调用 private void RefreshMyCourses() { dgvMyCourses.DataSource = EnrollDAL.GetMyCourses(CurrentUser.UserId); // 隐藏不需要的列,调整列宽 if (dgvMyCourses.Columns.Contains("EnrollTime")) dgvMyCourses.Columns["EnrollTime"].HeaderText = "选课时间"; }

用 SqlDataAdapter 填充 DataTable,再赋给 DataSource,WinForms 会自动生成列。每次刷新重新查一遍数据库,数据量小的时候完全没问题。如果数据量大,可以考虑 BindingSource 做本地缓存,但课程设计没必要。

注意:DataGridView 的 AutoSizeColumnsMode 建议设为 Fill,否则列宽会很别扭。另外如果绑定的 DataTable 列名和界面显示名不一致,用 HeaderText 改,不要改 SQL 里的别名,保持数据层和界面层解耦。

4. 避坑与排查:那些让我熬夜的选课系统 bug

4.1 现象:选课成功但列表不刷新,重启程序才看到

原因:界面用了新的 DataGridView 实例或者手动 Add 行,没有重新绑定 DataSource。WinForms 的数据绑定是单向通知,DataTable 变了不会自动推给控件。

解决:每次增删改后调用 RefreshMyCourses(),内部重新查库并赋 DataSource。如果坚持用本地缓存,需要调用 BindingSource.ResetBindings(false)。

4.2 现象:连接池耗尽,程序卡死报「超时时间已到」

原因:SqlConnection 没有用 using 包裹,或者异常路径下没走到 Dispose,连接一直占着不还。ADO.NET 默认连接池上限 100,循环里开连接不关,几十次就爆了。

解决:所有 SqlConnection、SqlCommand、SqlDataReader 一律用 using。DbHelper.GetConnection 返回已打开的连接,调用方必须 using。排查时可以在连接字符串加Max Pool Size=50;观察是否缓解,但根治靠正确释放。

4.3 现象:并发选课时容量变成 51,超出上限

原因:先 SELECT COUNT 再 INSERT,两条语句之间没有锁,两个事务都读到 49。这是典型的竞态条件。

解决:如 3.2 节所示,把检查和插入放进同一个事务,并对课程行加 UPDLOCK。另一种方案是在 Courses 表加一个 EnrolledCount 字段,用UPDATE Courses SET EnrolledCount=EnrolledCount+1 WHERE CourseId=@cid AND EnrolledCount<Capacity,根据受影响行数判断是否成功,这样更简洁但需要维护冗余字段。

4.4 现象:中文课程名在数据库里变成问号

原因:数据库排序规则不是中文,或者连接字符串没指定字符集。SQL Server 里如果字段是 VARCHAR 而不是 NVARCHAR,中文会丢。

解决:建表时中文列一律用 NVARCHAR。连接字符串对 MySQL 要加CharSet=utf8mb4;。已经建错表的,用 ALTER TABLE 改列类型,但已有数据可能已经损坏,需要重新导入。

4.5 现象:登录时输入' OR '1'='1竟然进去了

原因:SQL 拼接。"SELECT * FROM Users WHERE UserId='" + userId + "'"这种写法,输入特殊字符就能绕过验证。

解决:全部改参数化查询,如 3.1 节所示。这是血泪教训,课程设计答辩时老师很可能当场试这个。改完之后顺便把密码改成哈希存储,一步到位。

5. 进阶技巧:用存储过程收口选课逻辑与成绩录入

当基本功能跑通后,我习惯把选课这种多步骤操作收进存储过程。好处是逻辑集中在数据库端,C# 只负责调用和展示,界面层变薄,也避免了不同客户端重复实现同一套规则。下面这个存储过程把容量检查、重复检查和插入合并在一起,配合事务使用。

CREATE PROCEDURE sp_EnrollCourse @StudentId VARCHAR(20), @CourseId VARCHAR(20), @Result NVARCHAR(50) OUTPUT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; -- 锁定课程行 DECLARE @Capacity INT, @Enrolled INT; SELECT @Capacity = Capacity FROM Courses WITH (UPDLOCK, ROWLOCK) WHERE CourseId = @CourseId; IF @Capacity IS NULL BEGIN SET @Result = N'课程不存在'; ROLLBACK TRANSACTION; RETURN; END SELECT @Enrolled = COUNT(*) FROM Enrollments WHERE CourseId = @CourseId; IF @Enrolled >= @Capacity BEGIN SET @Result = N'课程已满'; ROLLBACK TRANSACTION; RETURN; END IF EXISTS (SELECT 1 FROM Enrollments WHERE StudentId=@StudentId AND CourseId=@CourseId) BEGIN SET @Result = N'已选过该课程'; ROLLBACK TRANSACTION; RETURN; END INSERT INTO Enrollments(StudentId, CourseId) VALUES(@StudentId, @CourseId); SET @Result = N'选课成功'; COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; SET @Result = N'系统错误:' + ERROR_MESSAGE(); END CATCH END

C# 端调用时,用 SqlCommand 的 CommandType.StoredProcedure,把 @Result 设为 Output 参数:

public static string EnrollViaSp(string studentId, string courseId) { using (var conn = DbHelper.GetConnection()) using (var cmd = new SqlCommand("sp_EnrollCourse", conn)) { cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@StudentId", studentId); cmd.Parameters.AddWithValue("@CourseId", courseId); var outParam = new SqlParameter("@Result", SqlDbType.NVarChar, 50) { Direction = ParameterDirection.Output }; cmd.Parameters.Add(outParam); cmd.ExecuteNonQuery(); return outParam.Value.ToString(); } }

这样 C# 端只剩三行有效逻辑,规则全在数据库里。成绩录入也是类似思路,写一个 sp_UpdateScore,限制只有管理员角色能调用,并在存储过程里校验分数范围 0 到 100。

验证方法上,我一般会开两个 SSMS 查询窗口,同时执行选课存储过程,观察是否有一个返回「课程已满」。如果两个都成功,说明锁没生效,检查是否漏了 UPDLOCK 或者隔离级别被改过。另一个习惯是每次改完存储过程,先在 SSMS 里用 EXEC 带参数跑一遍,确认输出符合预期,再回到 C# 里调。

这套系统做完,最大的收获不是 WinForms 拖控件,而是理解了「业务规则放在哪一层」这件事。我踩过最深的坑是把容量检查写在按钮点击事件里,结果测试时用两个客户端同时点,数据就乱了。后来把规则收进事务和存储过程,世界才安静下来。如果你也在做类似的东西,先把并发场景想清楚,再动手写界面,能省下很多后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询