简介这份资源是一套基于 C# Windows 窗体与 SQL Server 的学生信息管理系统含选课模块面向正在学习桌面应用开发、三层架构与数据库编程的初学者及课程设计需求者。系统设置学生端与教师端两个登录入口覆盖管理员注册登录、学生信息增删改查、课程发布、学生信息上传与选课等完整流程并涉及图片存储等实用技术点。资源包共 141 个文件以 45 个 cs 源码文件、8 个 resx 与 resources 资源文件、4 个 csproj 工程文件及若干 dll、pdb、config 配置为主压缩包约 1.5MB结构清晰便于按项目分层阅读。已有 4416 人学习下载说明其作为课程设计参考具有较高认可度。包内附源码、数据库建表代码与简要说明数据库仅需按给定表结构生成即可运行适合对照三层架构理解业务分层、学习窗体与数据库交互及选课逻辑的实现思路。1. 从零手搓 C# Windows 窗体学生选课系统为什么数据库设计决定了你后面 80% 的返工量很多同学做 C# Windows 窗体学生信息管理系统选课时第一反应是打开 Visual Studio 拖控件把 TextBox、Button、DataGridView 往 Form 上一摆然后开始写按钮点击事件。窗体确实能跑起来但一旦涉及选课这种多对多关系问题就全冒出来了同一个学生重复选同一门课、课程容量超了还能继续选、删除课程后选课记录变成孤儿数据。这些问题的根源不在 C# 代码而在数据库表结构没设计好。这个标题拆开来看核心是三件事用 C# Windows 窗体做界面、管理学生和课程信息、处理选课关系底层用数据库持久化。适合正在做数据库课程设计的学生也适合想用 C# 练手桌面应用的初学者。我见过太多人把精力花在窗体美化上结果数据库就一张表所有信息塞在一起后面加一个“退选”功能就要改十几个地方。这篇笔记按我实际做过的路径从数据库设计讲到窗体联动再到选课逻辑的事务处理把每一步的参数和坑都摊开说。2. 数据库表结构设计与 SQL Server 建库三张核心表怎么定字段和约束2.1 学生表、课程表、选课表的最小字段集选课系统的数据模型本质是三个实体加一个关系。学生和课程是多对多所以必须有一张中间表来记录谁选了哪门课。我一般会建三张表Students、Courses、Enrollments。字段不用多但每个字段的类型和约束要想清楚。Students 表至少要有 StudentId主键用 nvarchar(20) 而不是 int因为学号可能带字母、Name、Gender、Major、Grade。Courses 表要有 CourseId主键、CourseName、Credit、Capacity、Teacher。Enrollments 表是关系表字段是 EnrollId自增主键、StudentId外键、CourseId外键、EnrollTime选课时间。这里有个容易翻车的地方Enrollments 表必须对 (StudentId, CourseId) 加唯一约束否则同一个学生可以无限次选同一门课。这个约束在代码层面也能做但数据库层面加一道锁更可靠。-- 创建数据库 CREATE DATABASE StudentCourseDB; GO USE StudentCourseDB; GO -- 学生表 CREATE TABLE Students ( StudentId NVARCHAR(20) PRIMARY KEY, Name NVARCHAR(50) NOT NULL, Gender NVARCHAR(4) CHECK (Gender IN (N男, N女)), Major NVARCHAR(50), Grade NVARCHAR(10) ); -- 课程表 CREATE TABLE Courses ( CourseId NVARCHAR(20) PRIMARY KEY, CourseName NVARCHAR(100) NOT NULL, Credit DECIMAL(3,1) DEFAULT 2.0, Capacity INT DEFAULT 50, Teacher NVARCHAR(50) ); -- 选课关系表 CREATE TABLE Enrollments ( EnrollId INT IDENTITY(1,1) PRIMARY KEY, StudentId NVARCHAR(20) NOT NULL, CourseId NVARCHAR(20) NOT NULL, EnrollTime DATETIME DEFAULT GETDATE(), CONSTRAINT UQ_Student_Course UNIQUE (StudentId, CourseId), CONSTRAINT FK_Enroll_Student FOREIGN KEY (StudentId) REFERENCES Students(StudentId), CONSTRAINT FK_Enroll_Course FOREIGN KEY (CourseId) REFERENCES Courses(CourseId) );逻辑说明Students 和 Courses 的主键都用 nvarchar因为实际学号和课程号经常带前缀字母。Enrollments 表的唯一约束 UQ_Student_Course 是防止重复选课的第一道防线。两个外键保证不会出现选了不存在的学生或课程。EnrollTime 默认取当前时间方便后面按时间排序。参数说明Capacity 默认 50这个值在选课逻辑里要动态判断已选人数是否达到上限。Credit 用 decimal(3,1) 是为了支持 2.5 学分这种精度。Gender 用 CHECK 约束限制只能填“男”或“女”避免脏数据。2.2 在 SQL Server 里验证约束是否生效建完表别急着写 C# 代码先在 SSMS 里手动插几条数据看看约束会不会拦住你。这一步花五分钟能省后面两小时排查。-- 插入测试数据 INSERT INTO Students VALUES (S001, N张三, N男, N计算机, N2024); INSERT INTO Students VALUES (S002, N李四, N女, N软件工程, N2024); INSERT INTO Courses VALUES (C001, N数据结构, 3.0, 2, N王老师); INSERT INTO Courses VALUES (C002, N操作系统, 3.0, 1, N刘老师); -- 正常选课 INSERT INTO Enrollments (StudentId, CourseId) VALUES (S001, C001); -- 重复选课应该报错 INSERT INTO Enrollments (StudentId, CourseId) VALUES (S001, C001); -- 消息 2627违反了唯一约束 UQ_Student_Course -- 选不存在的课程应该报错 INSERT INTO Enrollments (StudentId, CourseId) VALUES (S001, C999); -- 消息 547与外键约束冲突逻辑说明第一条重复插入会触发唯一约束报错第二条插入不存在的课程会触发外键报错。如果这两条都按预期报错说明表结构没问题。如果没报错检查约束是不是建漏了。参数说明测试数据里的 Capacity 故意设小C001 容量 2C002 容量 1方便后面测容量限制逻辑。实际项目里容量一般设 30 到 100。注意SQL Server 默认不区分大小写但学号字段建议统一用大写字母加数字避免 s001 和 S001 被当成两个不同学生。3. C# 窗体与数据库打通连接字符串、DataGridView 绑定和增删改查按钮3.1 用 SqlConnection 还是 SqlHelper 封装类新手最容易犯的错是把连接字符串写死在每个按钮事件里改一次数据库地址要改十几个文件。我一般会建一个 SqlHelper 静态类把连接字符串放在 App.config 里所有数据库操作走这个类。!-- App.config -- configuration connectionStrings add nameStudentDB connectionStringData Source.;Initial CatalogStudentCourseDB;Integrated SecurityTrue providerNameSystem.Data.SqlClient/ /connectionStrings /configuration// SqlHelper.cs using System.Configuration; using System.Data; using System.Data.SqlClient; public static class SqlHelper { private static readonly string connStr ConfigurationManager.ConnectionStrings[StudentDB].ConnectionString; // 执行增删改返回受影响行数 public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); using (SqlCommand cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); return cmd.ExecuteNonQuery(); } } } // 执行查询返回 DataTable public static DataTable ExecuteQuery(string sql, params SqlParameter[] parameters) { using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); using (SqlCommand cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); SqlDataAdapter adapter new SqlDataAdapter(cmd); DataTable dt new DataTable(); adapter.Fill(dt); return dt; } } } }逻辑说明ExecuteNonQuery 用于 INSERT、UPDATE、DELETE返回受影响行数调用方根据返回值判断是否成功。ExecuteQuery 用于 SELECT返回 DataTable 直接绑定到 DataGridView。两个方法都用 using 包裹连接和命令对象确保释放。参数说明连接字符串里 Data Source. 表示本机默认实例如果用了 SQL Server Express 要改成 .\SQLEXPRESS。Integrated SecurityTrue 表示用 Windows 身份验证如果要用 SQL 账号密码改成 User IDsa;Password你的密码。3.2 学生信息窗体的 DataGridView 绑定与增删改窗体上放一个 DataGridView、几个 TextBox 和四个按钮添加、修改、删除、刷新。DataGridView 的列不用手动加直接绑定 DataTable 就行。// StudentForm.cs 核心代码 private void LoadStudents() { string sql SELECT StudentId, Name, Gender, Major, Grade FROM Students; DataTable dt SqlHelper.ExecuteQuery(sql); dgvStudents.DataSource dt; // 隐藏不需要的列如果有 if (dgvStudents.Columns[StudentId] ! null) dgvStudents.Columns[StudentId].HeaderText 学号; } private void btnAdd_Click(object sender, EventArgs e) { string sql INSERT INTO Students (StudentId, Name, Gender, Major, Grade) VALUES (sid, name, gender, major, grade); SqlParameter[] ps { new SqlParameter(sid, txtStudentId.Text.Trim()), new SqlParameter(name, txtName.Text.Trim()), new SqlParameter(gender, cmbGender.Text), new SqlParameter(major, txtMajor.Text.Trim()), new SqlParameter(grade, txtGrade.Text.Trim()) }; int rows SqlHelper.ExecuteNonQuery(sql, ps); if (rows 0) { MessageBox.Show(添加成功); LoadStudents(); } else { MessageBox.Show(添加失败请检查学号是否重复); } }逻辑说明LoadStudents 每次操作后重新查询保证 DataGridView 显示最新数据。btnAdd_Click 用参数化 SQL 而不是字符串拼接防止 SQL 注入。SqlParameter 的 sid 等占位符和 SQL 语句里的 sid 一一对应。参数说明txtStudentId.Text.Trim() 去掉前后空格避免用户不小心输入空格导致主键重复。cmbGender 是 ComboBox只允许选“男”或“女”和数据库的 CHECK 约束对应。提示DataGridView 的 AllowUserToAddRows 属性设为 false否则用户可以在表格最后一行直接输入绕过你的按钮逻辑容易产生脏数据。3.3 课程管理窗体的容量显示与已选人数统计课程窗体除了基本的增删改查还要显示每门课的已选人数和剩余容量。这个用一条带子查询的 SQL 就能搞定。SELECT c.CourseId, c.CourseName, c.Credit, c.Capacity, c.Teacher, ISNULL(e.EnrolledCount, 0) AS EnrolledCount, c.Capacity - ISNULL(e.EnrolledCount, 0) AS Remaining FROM Courses c LEFT JOIN ( SELECT CourseId, COUNT(*) AS EnrolledCount FROM Enrollments GROUP BY CourseId ) e ON c.CourseId e.CourseId逻辑说明子查询先统计每门课的已选人数然后用 LEFT JOIN 挂到课程表上。ISNULL 处理没人选的课程显示 0 而不是 NULL。Remaining 字段直接算好剩余容量窗体上绑定这一列用户一眼就能看出哪些课还能选。参数说明如果课程表数据量大可以在 Enrollments 表的 CourseId 上建索引加速 GROUP BY 统计。这个查询在几千条数据量下毫秒级返回不用过度优化。4. 选课与退选的事务处理怎么防止超选和并发冲突4.1 选课逻辑的三个检查点选课不是简单往 Enrollments 表插一条记录。我一般会在一个事务里做三件事检查课程是否已满、检查学生是否已选过、插入选课记录。这三个操作必须在一个事务里否则并发情况下会出现超选。// EnrollService.cs public static bool EnrollCourse(string studentId, string courseId, out string message) { message ; using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction tran conn.BeginTransaction()) { try { // 1. 检查课程容量 string sqlCheck SELECT c.Capacity, (SELECT COUNT(*) FROM Enrollments WHERE CourseId cid) AS Enrolled FROM Courses c WHERE c.CourseId cid; SqlCommand cmdCheck new SqlCommand(sqlCheck, conn, tran); cmdCheck.Parameters.AddWithValue(cid, courseId); SqlDataReader reader cmdCheck.ExecuteReader(); if (!reader.Read()) { message 课程不存在; return false; } int capacity reader.GetInt32(0); int enrolled reader.GetInt32(1); reader.Close(); if (enrolled capacity) { message 课程已满; return false; } // 2. 检查是否已选 string sqlDup SELECT COUNT(*) FROM Enrollments WHERE StudentId sid AND CourseId cid; SqlCommand cmdDup new SqlCommand(sqlDup, conn, tran); cmdDup.Parameters.AddWithValue(sid, studentId); cmdDup.Parameters.AddWithValue(cid, courseId); int dupCount (int)cmdDup.ExecuteScalar(); if (dupCount 0) { message 你已经选过这门课; return false; } // 3. 插入选课记录 string sqlInsert INSERT INTO Enrollments (StudentId, CourseId) VALUES (sid, cid); SqlCommand cmdInsert new SqlCommand(sqlInsert, conn, tran); cmdInsert.Parameters.AddWithValue(sid, studentId); cmdInsert.Parameters.AddWithValue(cid, courseId); cmdInsert.ExecuteNonQuery(); tran.Commit(); message 选课成功; return true; } catch (Exception ex) { tran.Rollback(); message 选课失败 ex.Message; return false; } } } }逻辑说明整个选课过程在一个事务里任何一步失败都回滚。第一步查容量如果已选人数大于等于容量直接返回“课程已满”。第二步查重复如果已经选过返回提示。第三步才真正插入。这样即使两个学生同时选最后一门课数据库的唯一约束和事务隔离级别也能保证只有一个成功。参数说明BeginTransaction() 默认使用 ReadCommitted 隔离级别在这个级别下两个事务同时读容量可能都读到“未满”然后都插入。要彻底避免超选可以在 SELECT 时加 WITH (UPDLOCK)锁定课程行。但课程设计级别用默认隔离级别加唯一约束基本够用因为唯一约束会拦住重复插入容量超选的概率在几十人同时选课时才明显。4.2 退选逻辑与选课记录查询退选比选课简单直接删 Enrollments 表里的记录就行。但要注意退选后要刷新课程容量显示。public static bool DropCourse(string studentId, string courseId, out string message) { string sql DELETE FROM Enrollments WHERE StudentId sid AND CourseId cid; SqlParameter[] ps { new SqlParameter(sid, studentId), new SqlParameter(cid, courseId) }; int rows SqlHelper.ExecuteNonQuery(sql, ps); if (rows 0) { message 退选成功; return true; } else { message 你没有选这门课; return false; } }逻辑说明DELETE 语句返回受影响行数如果为 0 说明该学生没有选这门课。退选不需要事务因为只操作一张表。参数说明退选后要重新调用 LoadCourses() 刷新课程列表否则已选人数还是旧的。4.3 用事务保证选课和容量更新的一致性有些实现会在 Courses 表里加一个 EnrolledCount 字段选课时加一退选时减一。这种做法看起来快但容易和 Enrollments 表的实际记录不一致。我一般不用冗余字段每次查询时实时统计。如果确实需要冗余字段必须放在同一个事务里更新。// 不推荐的写法分开更新可能不一致 // UPDATE Courses SET EnrolledCount EnrolledCount 1 WHERE CourseId cid; // INSERT INTO Enrollments ...; // 推荐的写法在事务里同时更新 // 但更推荐直接 COUNT 统计不存冗余字段逻辑说明冗余字段的维护成本高一旦某次更新失败数据就对不上。课程设计的数据量下COUNT 统计完全够快。如果以后数据量大了可以加缓存或物化视图但那是另一个话题。参数说明如果非要用冗余字段记得在 Enrollments 表的 INSERT 和 DELETE 触发器里同步更新或者严格在同一个事务里操作。5. 避坑与排查选课系统开发中最容易翻车的五个地方5.1 连接字符串写错导致“无法打开登录所请求的数据库”现象程序一运行就报错“无法打开登录所请求的数据库 StudentCourseDB。登录失败。”原因连接字符串里的 Initial Catalog 写错了或者数据库根本没建。解决在 SSMS 里确认数据库名称检查 App.config 里的 Initial Catalog 是否一致。如果用的是 SQL Server ExpressData Source 要写成 .\SQLEXPRESS。5.2 DataGridView 绑定后修改数据不生效现象在 DataGridView 里改了单元格的值点保存按钮后数据库没变。原因DataGridView 直接绑定了 DataTable但 DataTable 的修改没有回写到数据库。解决要么用 SqlCommandBuilder 自动生成 UPDATE 语句要么手动遍历 DataGridView 的行逐行执行 UPDATE。我一般用手动方式逻辑更可控。5.3 选课按钮点太快导致重复插入现象用户快速双击选课按钮插入了两条相同记录如果唯一约束没生效。原因按钮点击事件没有防抖两次点击都执行了插入。解决在插入前禁用按钮插入完成后重新启用。或者在 SQL 里用 IF NOT EXISTS 判断。private void btnEnroll_Click(object sender, EventArgs e) { btnEnroll.Enabled false; try { // 选课逻辑 } finally { btnEnroll.Enabled true; } }5.4 删除学生时外键约束报错现象删除一个已经选课的学生报错“DELETE 语句与 REFERENCE 约束冲突”。原因Enrollments 表里有该学生的选课记录外键阻止删除。解决先删除该学生的所有选课记录再删除学生。或者在建表时设置 ON DELETE CASCADE但级联删除要谨慎容易误删数据。5.5 窗体间数据传递用静态变量导致数据错乱现象打开多个学生选课窗体修改一个窗体的数据另一个窗体的数据也跟着变。原因用了静态变量存当前学生信息多个窗体共享同一份数据。解决用构造函数传参每个窗体持有自己的数据副本。// 推荐构造函数传参 public EnrollForm(string studentId) { InitializeComponent(); this.currentStudentId studentId; }6. 进阶技巧用存储过程封装选课逻辑和用事务隔离级别压测并发6.1 把选课逻辑写成存储过程C# 里写事务虽然直观但 SQL 逻辑散落在代码里改一次要重新编译。我后来习惯把选课逻辑写成存储过程C# 只负责调用。CREATE PROCEDURE sp_EnrollCourse StudentId NVARCHAR(20), CourseId NVARCHAR(20), Result NVARCHAR(50) OUTPUT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; -- 检查容量 IF EXISTS ( SELECT 1 FROM Courses c WHERE c.CourseId CourseId AND (SELECT COUNT(*) FROM Enrollments WHERE CourseId CourseId) c.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# 端只需要传参和接收输出参数。这样逻辑集中在数据库层修改时不用重新编译 C# 程序。参数说明Result 是 OUTPUT 参数C# 调用时用 SqlParameter 的 Direction 设为 Output。SqlParameter resultParam new SqlParameter(Result, SqlDbType.NVarChar, 50); resultParam.Direction ParameterDirection.Output; // 执行后读取 resultParam.Value6.2 用 SQL Server Profiler 看实际执行的 SQL有时候程序报错但不知道哪条 SQL 出了问题。打开 SQL Server Profiler新建跟踪勾选 SQL:BatchCompleted 和 RPC:Completed 事件运行程序就能看到所有实际执行的 SQL 语句和耗时。这个工具是排查数据库问题的后悔药比在代码里到处打日志快得多。6.3 并发选课的压测方法想验证选课逻辑在并发下会不会超选可以写一个简单的多线程测试。开 10 个线程同时选同一门容量为 5 的课看最终 Enrollments 表里是不是只有 5 条记录。// 并发测试伪代码 Parallel.For(0, 10, i { string msg; EnrollCourse(S i.ToString(D3), C001, out msg); }); // 查询 Enrollments 表确认记录数不超过 Capacity逻辑说明Parallel.For 模拟 10 个学生同时选课。如果最终记录数超过 5说明事务隔离级别不够需要在 SELECT 时加 UPDLOCK 提示。参数说明测试前把课程容量改成 5测试后检查 COUNT(*)。这个测试在开发机上就能跑不用部署到服务器。我做了这么多年 C# 桌面应用最大的教训就是窗体代码可以随便重构但数据库表结构一旦上线改起来就伤筋动骨。所以每次动手写代码之前先在纸上把表关系画清楚把约束想明白后面能省下大量返工时间。希望帮到你。本文还有配套的精品资源点击获取