
简介C#宿舍管理系统是一款面向高校课程设计场景的完整项目资料包适合学习C#、SQL数据库及WinForm桌面应用开发的学生参考。该系统区分学生登录与宿管登录两种角色覆盖公告发布、宿舍信息增删改查、物品报修与审批、请假销假、离宿申请等常用管理流程并用数据库存储账号与业务数据同时引入CSkin界面美化、WebService获取地理与天气信息整体功能贴近实际需求。资源包共188个文件约4.7MB主要包含70个cs源文件、28个resx界面资源、SQL及MDF/LDF数据库文件、DLL依赖库、EXE可执行程序、DOCX说明文档等从源码、配置到数据库脚本均有收录目录结构便于对照学习。目前已有1377人学习下载。资料内含说明书、流程图、登录管理模块及原始登录文件适合完成课程设计、理解分层实现或在此基础上二次开发。1. 一个 C# 宿舍管理系统很多人不是倒在代码上而是倒在解压之后的第一步“C#宿舍管理系统.rar”是我在课程设计和毕业设计里看到出现频率最高的压缩包名字之一。打开这种包一般是一个 WinForms 解决方案、一份数据库脚本和几页说明文档看起来什么都有可真要跑起来大多数人的第一道坎是还原程序包、附加数据库、改连接串这三件事跟 C# 语法没有半点关系。下面顺着“拿到这类系统之后怎么把它读透、改好、跑通”的顺序来写先定技术选型和数据库结构再写登录与权限再处理入住退宿的核心事务和床位状态最后把最容易翻车的几个现场提前排掉。适合正在做课设/毕设或者想用 C# WinForms 把三层架构完整练一遍的同学。2. 先定选型再建表WinForms SQL Server 下宿舍、学生、流水三张核心表怎么设计拿到“宿舍管理系统”项目先别急着双击.sln看界面先把两件事定下来跑在什么框架上、数据落在哪里。我一般会花半小时把压缩包里的文件结构过一遍——有没有解决方案文件、有没有建库脚本、有没有说明文档然后对着需求往下拆。2.1 技术选型为什么课设首选 WinForms 而不是 WPF数据库为什么建议 SQL Server如果只交作业WinForms 就是合理答案。WPF 的绑定和样式确实更现代但这个标题下的课设代码九成是 WinForms 老工程导师的评测机上也大概率是 VS2019/2022WinForms 双击就是窗体拖控件就能出界面调试路径最短。真正让界面发卡的场景出现在后面——控件堆了几十个还把数据库查询直接写在按钮事件里UI 线程被占死这是写法问题不是框架问题。用 WPF 的前提是你愿意为数据绑定和自定义控件多花两周。数据库这边我通常建议 SQL Server Express 或 LocalDB。宿舍管理系统是典型单机/局域网并发很小的系统SQL Server 的部署在机房最常见建库脚本.sql直接执行一次就能建库出了问题也好解释。SQLite 虽然单文件更轻但答辩机器没装驱动或 System.Data.SQLite 版本不匹配时现场装依赖非常被动。如果包里带的是 SQL Server 的.mdf文件可以在 VS 里直接用“附加”打开这比还原.bak更省事。对比项WinFormsWPF上手速度拖控件即可适合短时间出活需要理解绑定/模板学习曲线陡课设资料数量老工程最多遇到问题好搜相对少且很多例子偏桌面工具类界面表现力一般复杂布局要靠自定义绘制适合做动态样式和酷炫界面答辩风险老师熟悉功能解释成本低被追问绑定性能需要额外准备架构上建议 UIForm→ 业务层BLL→ 数据访问层DAL三到四层哪怕再简单也分开。原因只有一个答辩时老师大概率会问“如果要把 SQL Server 换成 MySQL要改几处”分层之后答案就是“只改 DAL界面不动”。2.2 三张核心表宿舍、学生、入住流水为什么入住信息不能直接塞进学生表宿舍管理系统的核心实体其实就三个宿舍、学生、入住流水。很多半成品系统把“当前宿舍”“当前床位”直接做成 Student 表里的两个字段看起来查起来方便但学生一旦换宿、退宿旧信息被覆盖后面辅导员问“上学期这名学生住哪”就彻底查不到了。正确做法是引入一张 StayRecord 流水表每发生一次入住、换宿、退宿就插一条记录用 CheckOutTime 是否为空表示“当前是否在住”。这样“学生现在住哪”和“学生曾经住过哪”都变成同一个查询只是加不加过滤条件的问题。建表脚本SQL ServerCREATE TABLE Dormitory ( DormitoryId INT IDENTITY(1,1) PRIMARY KEY, BuildingNo NVARCHAR(20) NOT NULL, -- 楼栋如“1号楼” RoomNo NVARCHAR(20) NOT NULL, -- 房间号如“301” BedCount INT NOT NULL DEFAULT 4, -- 床位总数 RepairBeds NVARCHAR(50) NULL, -- 维修中床位如“1,3”; NULL 表示无维修 Remark NVARCHAR(200) NULL ); GO CREATE TABLE Student ( StudentId INT IDENTITY(1,1) PRIMARY KEY, StudentNo NVARCHAR(20) NOT NULL UNIQUE, -- 学号业务上唯一 Name NVARCHAR(50) NOT NULL, Gender CHAR(1) NOT NULL, -- M/F College NVARCHAR(50) NULL, -- 学院 ClassName NVARCHAR(50) NULL, Phone NVARCHAR(20) NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); GO CREATE TABLE StayRecord ( RecordId INT IDENTITY(1,1) PRIMARY KEY, StudentId INT NOT NULL REFERENCES Student(StudentId), DormitoryId INT NOT NULL REFERENCES Dormitory(DormitoryId), BedNo INT NOT NULL, -- 床位号从 1 开始 CheckInTime DATETIME NOT NULL DEFAULT GETDATE(), CheckOutTime DATETIME NULL, -- NULL在住有值已退 Reason NVARCHAR(100) NULL, -- 入住/换宿/退宿 Operator NVARCHAR(20) NULL, -- 操作人账号 CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); GO CREATE INDEX IX_StayRecord_Active ON StayRecord(DormitoryId, CheckOutTime);说明几个关键设计RepairBeds 用逗号分隔维修床位是简化做法——一张四人间最多四个床位用字符串标记“1,3”完全够用不用为维修状态再造一张床表但如果你打算把系统做成能并发处理很多宿舍的版本这里应该改成独立的 Bed 表。StayRecord单独成表且不删除历史是因为“换宿”不是更新学生表而是把旧记录 CheckOutTime 写上日期再插入一条新记录。所有历史都在答辩时被问“某宿舍这个月的入住率怎么算”直接基于这条流水写 SQL 就行不用改结构。最后的索引 IX_StayRecord_Active 是这个项目里我建议必加的按宿舍查在住人数、查空闲床位全部走这个索引。2.3 功能清单与页面划分先把“宿舍状态”和“学生状态”分清楚一个可交作业的宿舍管理系统页面一般收敛成六个模块登录页、主窗体导航、宿舍管理、学生管理、入住退宿办理、查询统计与导出。宿舍管理管 Dormitory 表学生管理管 Student 表入住退宿办理同时写 StayRecord 和宿舍占用状态查询统计则以 StayRecord 为入口 JOIN 另外两张表。代码再少这个边界也不要打破。模块页面/入口主要操作涉及核心表宿舍管理宿舍信息窗体增改宿舍、床位数量、维修标记Dormitory学生管理学生信息窗体学生档案增删改查、Excel导入Student入住管理入住登记窗体选宿舍选床、登记入住StayRecord / Dormitory退宿/换宿办理窗体退宿、换宿、历史记录查看StayRecord查询统计报表窗体在住名单、入住率、水电费StayRecord JOINDAL 层建议写一个泛型基类兜底把 GetById、GetList、Insert、Update 这类通用操作收敛到一两个文件里专门表再继承它做扩展。我不太提倡课设里每个表都复制一份一模一样的方法那样代码量好看但改一个公共逻辑要改十个文件维护起来全是泪。不管叫宿舍管理系统还是综合教务管理系统本质都是围绕着状态流转做文章结构先立住后面功能才叠得动。3. 登录与权限从加盐哈希到角色菜单再顺手把状态栏和进度条一起解决登录模块是系统入口也是答辩时最容易被点名的部分。这一章按“认证怎么存、权限怎么控制、界面怎么反馈”三层来写。3.1 用户表与加盐哈希为什么密码不能明文落库SysUser 表字段最少要有UserId、Account、UserName、PasswordHash、Salt、Role、CreateTime。Role 用字符串或整数存Admin/DormManager 两种角色对本系统基本够用。注册或初始化账号时把密码按“盐 密码”算哈希盐每次随机生成登录时用账号查出 Salt重新算一次哈希再比对。不要用 MD5 裸算也不要让两个同密码用户的哈希值相同否则一旦库泄露明文密码基本是裸奔。public static string GenerateSalt() { byte[] bytes new byte[16]; using (var rng RandomNumberGenerator.Create()) { rng.GetBytes(bytes); } return Convert.ToBase64String(bytes); } public static string ComputeHash(string password, string salt) { using var sha256 SHA256.Create(); byte[] hash sha256.ComputeHash(Encoding.UTF8.GetBytes(salt password)); return Convert.ToBase64String(hash); }参数说明盐取 16 字节转 Base64 后和密码拼接再哈希。这里拼接顺序不能变变了所有旧密码全部失效也不要直接用“固定盐”否则又回到原点。SHA256.Create()在 .NET Framework 4.7.2 和 .NET 6/8 里都能用不用额外装包。登录校验的写法public User Login(string account, string inputPassword) { const string sql SELECT UserId, Account, UserName, PasswordHash, Salt, Role FROM SysUser WHERE Account Account; using var conn new SqlConnection(_connString); using var cmd new SqlCommand(sql, conn); cmd.Parameters.AddWithValue(Account, account); conn.Open(); using var reader cmd.ExecuteReader(); if (!reader.Read()) return null; string salt reader[Salt].ToString(); string hash ComputeHash(inputPassword, salt); if (hash ! reader[PasswordHash].ToString()) return null; return new User { UserId (int)reader[UserId], Account account, UserName reader[UserName].ToString(), Role (UserRole)Enum.Parse(typeof(UserRole), reader[Role].ToString()) }; }逻辑说明登录查询是参数化的账号值不会拼进 SQL 字符串注入在这条路径上不存在。哈希比对放在 C# 端做而不是写进 SQL是为了让哈希算法只存在于一处将来升级算法不用动存储过程。这里有个容易被忽视的细节AddWithValue对条件字段可能引发隐式转换导致索引失效条件字段最好显式声明成SqlDbType.NVarChar长度和表列一致。3.2 角色菜单与状态栏联动登录成功后哪些窗体可见登录成功后把用户放进一个静态上下文里主窗体根据角色决定哪些菜单可见状态栏显示当前用户名和角色。“状态栏与进度条”的联动从这里就开始了状态栏显示身份进度条留给后面的耗时任务。public static class AuthContext { public static User CurrentUser { get; set; } public static bool IsAdmin CurrentUser ! null CurrentUser.Role UserRole.Admin; } // MainForm.Load private void MainForm_Load(object sender, EventArgs e) { if (AuthContext.CurrentUser null) { Close(); return; } var user AuthContext.CurrentUser; toolStripStatusLabelUser.Text ${user.UserName}{user.Role}; mnuSystemManage.Visible user.Role UserRole.Admin; mnuReport.Visible user.Role UserRole.Admin; mnuCheckIn.Visible true; // 两种角色都能操作入住退宿 }逻辑说明AuthContext 是单客户端程序里最常见的登录态做法比在每个窗体构造器里传 user 参数少改很多代码。注意如果后面你把系统改成多窗口并行的场景静态类要加锁或改用依赖注入的单例否则登录态在异步线程里被覆盖会出现“明明是管理员却跳回登录页”的灵异现象。3.3 耗时操作与进度条用 async/await 把查询从 UI 线程挪走WinForms 里 DataGridView 数据量一大、查询慢界面就开始转圈鼠标变成漏斗——WinForms 窗体卡顿的热搜就是这么来的。绝大多数情况不是 WinForms 性能差而是把 SqlConnection.Open、ExecuteReader、Excel 解析这些开销大的操作直接放进了按钮点击事件UI 线程被占死。改法是用 async/await 把耗时部分放到线程池回到 UI 线程再刷新界面。private async void btnSearch_Click(object sender, EventArgs e) { btnSearch.Enabled false; progressBar1.Visible true; try { var students await Task.Run(() _studentBll.Search(txtKeyword.Text)); dataGridView1.DataSource students; toolStripStatusLabel1.Text $共 {students.Count} 条记录; } catch (Exception ex) { MessageBox.Show(查询失败 ex.Message); } finally { btnSearch.Enabled true; progressBar1.Visible false; } }参数说明Task.Run里的委托不能碰任何 UI 控件只能在后台拿数据返回后由 async/await 自动回到 UI 线程执行后续赋值。这里的 await 让控件禁用/启用的顺序看起来像同步代码实际不阻塞界面。如果你要的是精确到 0%→100% 的进度用IProgressint报告百分比不要在后台线程里直接改progressBar1.Value否则就会触发“线程间操作无效”。4. 入住退宿换宿一张流水表加一个床位状态机把最容易被问倒的业务撑住宿舍管理系统的“管理”二字最核心的体现是宿舍床位状态和学生居住历史。下面写入住登记、退宿换宿和查询分页三个部分。4.1 入住登记事务里查占用、写流水、不破坏一致性入住登记的核心不是 INSERT 一条记录而是先确认床是真的空。用流水表判断占用SELECT BedNo FROM StayRecord WHERE DormitoryIdd AND CheckOutTime IS NULL拿到所有在住房号再和 BedCount、RepairBeds 比对。这三步必须放在同一个事务里否则可能出现两个人同时点“入住”都查到同一张床空着然后都插入成功——宿舍超卖。public bool CheckIn(int studentId, int dormitoryId, int bedNo, string operatorName) { using var conn new SqlConnection(_connString); conn.Open(); using var tx conn.BeginTransaction(IsolationLevel.ReadCommitted); try { // 1. 锁宿舍行避免并发修改床位总数或维修标记 using (var cmd new SqlCommand( SELECT BedCount, RepairBeds FROM Dormitory WHERE DormitoryId d WITH (UPDLOCK, ROWLOCK), conn, tx)) { cmd.Parameters.AddWithValue(d, dormitoryId); using var reader cmd.ExecuteReader(); if (!reader.Read()) return false; int bedCount (int)reader[BedCount]; string repair reader[RepairBeds]?.ToString(); var repairSet new HashSetint(); if (!string.IsNullOrWhiteSpace(repair)) foreach (var x in repair.Split(,)) repairSet.Add(int.Parse(x.Trim())); if (bedNo 1 || bedNo bedCount || repairSet.Contains(bedNo)) return false; // 床位号不在范围内或正在维修 } // 2. 锁流水表查当前占用床位防“超卖” var occupied new HashSetint(); using (var cmd new SqlCommand( SELECT BedNo FROM StayRecord WHERE DormitoryId d AND CheckOutTime IS NULL WITH (UPDLOCK, ROWLOCK), conn, tx)) { cmd.Parameters.AddWithValue(d, dormitoryId); using var reader cmd.ExecuteReader(); while (reader.Read()) occupied.Add(reader.GetInt32(0)); } if (occupied.Contains(bedNo)) return false; // 3. 插入入住流水 using (var cmd new SqlCommand( INSERT INTO StayRecord (StudentId, DormitoryId, BedNo, CheckInTime, Reason, Operator) VALUES (s, d, b, GETDATE(), N入住, op), conn, tx)) { cmd.Parameters.AddWithValue(s, studentId); cmd.Parameters.AddWithValue(d, dormitoryId); cmd.Parameters.AddWithValue(b, bedNo); cmd.Parameters.AddWithValue(op, operatorName); cmd.ExecuteNonQuery(); } tx.Commit(); return true; } catch { tx.Rollback(); throw; } }逻辑说明两个WITH (UPDLOCK, ROWLOCK)是关键参数前者把宿舍行和占用床位记录在事务结束前锁住第二个查询即使并发执行也会被第一个事务锁阻塞而不是“同时读到空床”。返回 false 而不是抛异常是因为“床位已被占用”属于业务规则不是程序错误UI 层直接提示用户换一张床就好。事务里不要弹 MessageBox弹框会让事务挂在那里一直到用户点确认非常容易出现死锁。4.2 退宿与换宿状态机只有三种状态但每次迁移都要留痕迹床位的状态其实只有三种空闲、占用、维修。入住是“空闲 → 占用”退宿是“占用 → 空闲”换宿是“占用 → 占用”但换了宿舍或床位。维修不单独设计状态用 Dormitory.RepairBeds 字段标记即可。所谓状态机就是把所有迁移路径画出来确保每个入口都走同样的检查逻辑。退宿代码一个事务里完成public bool CheckOut(int studentId, string operatorName) { const string sql UPDATE StayRecord SET CheckOutTime GETDATE(), Reason N退宿, Operator op WHERE StudentId s AND CheckOutTime IS NULL; using var conn new SqlConnection(_connString); conn.Open(); using var tx conn.BeginTransaction(); using var cmd new SqlCommand(sql, conn, tx); cmd.Parameters.AddWithValue(s, studentId); cmd.Parameters.AddWithValue(op, operatorName); int affected cmd.ExecuteNonQuery(); tx.Commit(); return affected 0; // 0 表示该生本就不在住 }换宿更简单在一个事务里先把旧记录 CheckOutTime 写上再调用一次插入新记录的逻辑。这里不要删旧记录不要 UPDATE 旧记录的 DormitoryId因为“旧记录”一旦改了就失去流水意义。参数说明退宿 SQL 的 WHERE 条件用CheckOutTime IS NULL来定位“当前在住”记录保证一个人同一时刻最多只有一条在住流水。如果你做的版本里允许同一个人在多个宿舍有床位这条约束就不成立整个业务复杂度会上一档。课设按“一人一床”做合理如果要做一人多床这里就不能一个 UPDATE 全更新得先查出来逐条处理。4.3 多条件查询与分页WHERE 11 和参数化拼接并不矛盾查询窗体一般要按学号、姓名、学院、性别、在住状态组合过滤。动态拼接 SQL 时我最常用 WHERE 11后面每个条件都用 AND 开头漏条件的概率最低这跟注入没关系——所有值仍然走 SqlParameter。var sql new StringBuilder( SELECT s.StudentNo, s.Name, s.Gender, s.College, d.BuildingNo d.RoomNo AS Room, r.BedNo, r.CheckInTime FROM Student s JOIN StayRecord r ON s.StudentId r.StudentId AND r.CheckOutTime IS NULL JOIN Dormitory d ON d.DormitoryId r.DormitoryId WHERE 1 1); var pars new ListSqlParameter(); if (!string.IsNullOrWhiteSpace(txtKeyword.Text.Trim())) { sql.Append( AND (s.StudentNo LIKE kw OR s.Name LIKE kw)); pars.Add(new SqlParameter(kw, $%{txtKeyword.Text.Trim()}%)); } if (cmbGender.SelectedIndex 0) { sql.Append( AND s.Gender g); pars.Add(new SqlParameter(g, cmbGender.SelectedItem.ToString())); } using var conn new SqlConnection(_connString); using var cmd new SqlCommand(sql.ToString(), conn); cmd.Parameters.AddRange(pars.ToArray()); conn.Open();说明几个参数要点kw用 LIKE %但输入要先 Trim 掉首尾空格避免用户敲个空格进来查全表性别下拉的第一项固定放“全部”SelectedIndex 0才拼条件避免把下拉默认值当真条件。如果数据量真到了几万条再在这个 SQL 外包一层ROW_NUMBER()做分页几千条的课设数据直接拉全量也问题不大。5. 避坑记录连接串、DataGridView、SqlBulkCopy 与跨线程的四个踩坑现场这类系统代码量不大但“未知异常”五花八门。下面四条都是高频坑按“现象 → 原因 → 解决”写每条都很具体。5.1 连接串写死换台电脑就“无法连接”首查配置文件现象在自己电脑上运行好好的拷到答辩机器上一启动就报“在与 SQL Server 建立连接时出错”或者“无法打开数据库 Dormitory”。原因连接串里写死了Server.\SQLEXPRESS;DatabaseDormitory目标机器可能装的是 LocalDB实例名根本不存在或者数据库脚本从来没在目标机器上执行过SQL Server 里压根没有这个库。解决把连接串挪到配置文件按环境切换而不是散落在每个 Form 的代码里。.NET Framework 项目可以放 App.config.NET 6/8 项目放 appsettings.json用 JSON 匹配配置是标准做法{ ConnectionStrings: { DormitoryDb: Server.;DatabaseDormitory;Integrated SecurityTrue; } }说明Server.表示本机默认实例如果装的是具名实例改成.\SQLEXPRESSLocalDB 的连接串在 JSON 里注意反斜杠要写成(localdb)\\MSSQLLocalDB。交付时把连接串和建库脚本放在同一个目录里并在说明文档写明“先执行建库脚本再改连接串”。5.2 DataGridView 绑定 List增删改查全做了界面就是不刷新现象点“新增学生”数据库里能查到新行DataGridView 还是旧数据关掉窗体重开才显示。把 DataSource 重新赋值一次能解决但是当前选中行、滚动位置全丢。原因绑定的是ListT。List 只实现 IList不会通知 DataGridView“集合变了”DataGridView 需要 IBindingList 的通知机制。解决改用BindingListTprivate readonly BindingListStudent _students new BindingListStudent(); // 初始化 dataGridView1.DataSource _students; // 增删后 _students.Add(newStudent); // 界面立即出现 _students.Remove(item); // 立即消失参数说明BindingList 的增删会自动触发重置绑定列表本身不负责“单项属性变化”的通知如果你要改某一行的 Name 后界面实时更新需要让实体类实现 INotifyPropertyChanged。课设里做到前者已经够用但答辩被问到“为什么用 BindingList”时能说出上面的区别就很加分。5.3 SqlBulkCopy 批量导入列错位是默认行为别怪 Excel现象用 SqlBulkCopy 从 Excel 导入几百个学生导完发现“姓名”全跑到“学号”列甚至主键冲突直接整批失败。原因SqlBulkCopy 的默认映射是按列顺序第 0 列到第 0 列第 1 列到第 1 列完全不看列名。Excel 模板顺序和表结构不一致时错位是必然的。解决写显式 ColumnMappingsusing var bcp new SqlBulkCopy(conn); bcp.DestinationTableName Student; bcp.ColumnMappings.Add(学号, StudentNo); bcp.ColumnMappings.Add(姓名, Name); bcp.ColumnMappings.Add(性别, Gender); bcp.ColumnMappings.Add(学院, College); bcp.WriteToServer(dt);说明还有一坑目标表如果有自增主键、触发器或索引批量导入会让这些“表变动”产生连锁影响——自增列不能从 Excel 带入触发器会对每一批数据触发导入速度明显变慢批量写大表时索引碎片也会增加。稳妥做法是导入到中间临时表校验完再分批合并。课设数据量小可以直接导入目标表但别把 Excel 里的 Id 列也带进去。5.4 跨线程更新控件Task.Run 里碰了个按钮WinForms 直接翻脸现象把查询放 Task.Run 后在后台线程里写dataGridView1.DataSource ...程序运行到一半抛“线程间操作无效从不是创建控件 dataGridView1 的线程访问它”。原因WinForms 控件只能在创建它的线程UI 线程里操作后台线程直接访问不安全调试器默认会拦下来。解决后台只取数据回到 UI 线程再赋值必须传值给 UI 时用 Invokevar result await Task.Run(() _bll.Search(keyword)); // 已经回到 UI 线程可以安全操作控件 dataGridView1.DataSource result; // 如果一定要在旧式 Thread 里更新用 this.Invoke // this.Invoke(() progressBar1.Value value);参数说明async/await 能自动回到 UI 上下文是最省心的答案BackgroundWorker 的 RunWorkerCompleted 也一样是 UI 线程。最怕的写法是把 Thread 和 Task 混着用后台线程里既跑业务又操作界面排错的时候完全无从下手。除开这四个现场这类系统出错时还常让人对着黑匣子干瞪眼。我一般在 DAL 层 catch 里统一记日志把操作人、操作类型、异常堆栈一次写全日志先写到文本文件成本最低也最有用。日志写法的关键不是写多少条而是把“谁在什么操作里碰到了什么异常”记全不然排错时只能靠猜。6. 从“能跑”到“能演示”造测试数据、导出 Excel、最后三步自检功能都齐了还不算完。我处理这类系统时交付前固定做三件事造一批像样的测试数据、跑一次导出验证、换一台机器重新执行建库脚本。造数据没必要手插写个临时工具循环插入就行。下面的代码按房间批量生成宿舍和学生顺便给部分房间留几个空床位让首页统计“空闲床位”时有数可显for (int room 1; room 12; room) { int dormId InsertDormitory($1号楼, ${room:D3}, 4); for (int bed 1; bed 4; bed) { string no $2024{room:D3}{bed}; InsertStudent(no, $学生{no}, dormId); // 只在部分床位调用 CheckIn留出空闲床位 } }这里的InsertDormitory、InsertStudent是两个内部方法分别写 INSERT 后返回新 IDCheckIn 复用第 4 章的事务逻辑。数据一旦造好Excel 导出就成了验证查询条件最快的方式。用 NPOI 写一个 DataTable 导出public void ExportDataTable(DataTable dt, string filePath) { using var workbook new XSSFWorkbook(); var sheet workbook.CreateSheet(导出结果); var header sheet.CreateRow(0); for (int c 0; c dt.Columns.Count; c) header.CreateCell(c).SetCellValue(dt.Columns[c].ColumnName); for (int r 0; r dt.Rows.Count; r) { var row sheet.CreateRow(r 1); for (int c 0; c dt.Columns.Count; c) row.CreateCell(c).SetCellValue(dt.Rows[r][c].ToString()); } using var fs new FileStream(filePath, FileMode.Create); workbook.Write(fs); }自检顺序我一般这样走先在一台只有基础环境的机器上把“建库脚本 → 改连接串 → 登录 → 入住退宿换宿”完整跑一遍再打开导出功能人工数一遍行数。数据对不上问题多半出在 JOIN 条件和 CheckOutTime 的过滤上而不是 Excel 里。这个顺序看着笨但每回都能在交付前发现至少一个隐藏问题。希望帮到你。本文还有配套的精品资源点击获取