十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#宾馆管理系统实战:WinForms与SQL Server数据库设计

C#宾馆管理系统实战:WinForms与SQL Server数据库设计 简介这是一份基于C#开发的小型宾馆管理系统完整工程包面向计算机、软件工程等专业学生及有一定WinForms基础的开发者可用于课程设计、毕业设计或自学练手。压缩包共121个文件主要包含四十余个C#源文件窗体界面、实体类与数据访问类、资源文件与resx界面配置、DLL依赖库以及SQL数据库脚本和doc实验报告并附有可直接运行的exe程序整体约9.84MB目录结构清晰便于打开工程对照学习。已有209人学习下载适合作为课程设计模板或C#入门实践参考。通过阅读源码和实验报告可掌握客房预订、入住登记、退房计费等核心功能的编码流程了解ADO.NET与窗体控件的交互方式、数据库表设计以及分层业务逻辑的拆分思路是一份兼具示范性与可复现性的实践材料。1. 一套 C# 宾馆管理系统到底能拆出多少东西不少人在做课程设计或接外包时遇到的第一个真实系统往往就是宾馆管理这类 CRUD 项目。它不大但前面连着 WinForms 界面后面挂着 SQL Server 数据库中间还得处理房间状态流转、入住登记、退房结算这些业务逻辑。这套基于 C# 的小型宾馆管理系统正好把这些环节都串起来了主窗体、登记窗体、房客管理窗体配一份数据库文件和实验报告适合用来研究 WinForms 和 ADO.NET 的配合方式。从我拆这套系统的角度来说它真正有价值的地方不在功能多而在结构完整。三四个窗体的分工、数据库表的关联、参数化 SQL 的写法、DataGridView 的绑定方式都是实际开发里高频用到的东西。对新手是完整的起步样例对写过几年代码的人来说也可以用很短的时间把它的数据层和状态流转重新捋一遍当作复习框架。下面按我实际复现时的顺序把关键部分逐层展开讲。2. 系统结构与数据库设计三个窗体如何分工表怎么建2.1 窗体职责拆分主窗体、入住登记、房客管理的边界这套系统的窗体从命名上就能看出职责FormMain 是主窗体负责整体导航和房间状态总览FormCheckIn 做入住登记处理新客人开房FormRoomer 管在住房客信息查询和退房。很多新手项目会把所有按钮堆在同一个窗体里最后代码量全集中在 FormMain.cs 里改一处动全身。这个项目的拆分方式值得学习——窗体的边界基本对应业务流程的阶段看房态、办入住、管住客。FormMain 在设计时通常放一个 DataGridView 用来展示房间列表再加几个按钮跳到 FormCheckIn 或 FormRoomer。窗体和窗体之间的数据传递一般通过构造函数传参比如主窗体把选中的房间号传给登记窗体登记窗体拿到房间号后做后续操作。这种方式比用静态变量传值干净得多后续扩展时也不会产生莫名其妙的耦合。FormCheckIn 的职责更聚焦选房间、填客人信息、设置押金、确认入住。这里有几个典型的字段控件组合——房号用 ComboBox 或只读文本框入住日期用 DateTimePicker押金用 NumericUpDown 控制数值范围。FormRoomer 则负责展示当前在住客人的列表通常提供按姓名或证件号搜索以及退房操作。退房时计算费用、更新房间状态和入住记录这是一条完整的事务链路。2.2 数据库表结构客房表、客人表、入住记录表的关系这套系统的数据库文件里包含三张核心表它们的关联关系是整个系统的数据基础。先看表结构我按常见的设计方式还原如下-- 客房表 CREATE TABLE Rooms ( RoomNo VARCHAR(10) PRIMARY KEY, -- 房间号如 201、302 RoomType VARCHAR(20) NOT NULL, -- 房型单人间/标准间/套房 Price DECIMAL(10,2) NOT NULL, -- 门市价 Status INT NOT NULL DEFAULT 0 -- 0空闲 1入住 2预订 3维修 ); -- 客人表 CREATE TABLE Guests ( GuestId INT IDENTITY(1,1) PRIMARY KEY, GuestName NVARCHAR(50) NOT NULL, IdCard VARCHAR(18) NOT NULL UNIQUE, -- 身份证号唯一防重复登记 Phone VARCHAR(20) ); -- 入住记录表 CREATE TABLE CheckInRecords ( RecordId INT IDENTITY(1,1) PRIMARY KEY, RoomNo VARCHAR(10) NOT NULL REFERENCES Rooms(RoomNo), GuestId INT NOT NULL REFERENCES Guests(GuestId), CheckInTime DATETIME NOT NULL DEFAULT GETDATE(), CheckOutTime DATETIME NULL, Deposit DECIMAL(10,2) DEFAULT 0, TotalFee DECIMAL(10,2) DEFAULT 0 );这组表的设计有几个细节值得注意。房间状态单独用一个 int 字段而不是字符串好处是查询时索引友好状态枚举在 C# 里对应一个枚举类型即可避免到处写魔法字符串。身份证号设置 UNIQUE 约束可以防止同一个客人重复建档——实际开发中这是相当常见的需求很多新手设计时容易忽略。入住记录表外键关联到客房表形成一对多的关系一间房在不同时间可以对应多条入住记录但同一时刻只能有一条未退房的记录。2.3 房间状态机的定义与流转房间状态是整个系统的核心状态机理解它的流转方式对后续写业务逻辑至关重要当前状态触发操作下个状态关联动作空闲(0)入住登记入住(1)新建入住记录写入客人信息入住(1)退房结算空闲(0)更新入住记录退房时间计算费用空闲(0)预订预订(2)预留房间等客人到店转入住入住(1)换房空闲/入住原房间置空新房间置入住从实现角度看状态变更在数据库里就是一次 UPDATE但关键是这个 UPDATE 必须与业务操作放在同一个事务里。比如退房时一条 UPDATE 更新房态为 0另一条 UPDATE 写入退房时间和总费用任何一条失败都要回滚否则会出现房间显示空闲但入住记录还挂着的脏数据。后面讲数据访问层的时候会专门演示事务写法。3. 数据访问层实现SqlHelper 与参数化查询3.1 连接串与数据库文件挂载方式这套系统使用的是 SQL Server 数据库文件在开发环境下有两种挂载方式一种是把 .mdf 文件附加到 SQL Server 实例另一种是在连接串里用 AttachDbFilename 直接指定路径。两种方式对应不同的连接字符串这张表对比一下连接方式连接串示例适用场景指定实例Server.\SQLEXPRESS;DatabaseHotelDB;Trusted_ConnectionTrue;数据库已附加或已部署推荐直接挂载Data Source(LocalDB)\MSSQLLocalDB;AttachDbFilenameD:\Hotel\HotelDB.mdf;Integrated SecurityTrue;开发环境下随项目带 .mdf 文件实际项目里我建议用第一种因为 AttachDbFilename 依赖绝对路径项目换机器后路径一变就容易报找不到数据库。用指定实例的方式只需要在部署环境里一次性执行附加数据库的脚本后面都通过 DatabaseHotelDB 访问逻辑干净。3.2 SqlHelper 类的核心方法封装数据访问层是这套系统里复用性最高的部分。有了它窗体代码里不需要到处写 SqlConnection 的打开关闭逻辑统一走封装好的方法。一个精简版本如下public class SqlHelper { private static readonly string connStr Server.\SQLEXPRESS;DatabaseHotelDB;Trusted_ConnectionTrue;; /// summary执行增删改返回受影响行数/summary public static int ExecuteNonQuery(string sql, params SqlParameter[] ps) { using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); using (SqlCommand cmd new SqlCommand(sql, conn)) { if (ps ! null) cmd.Parameters.AddRange(ps); return cmd.ExecuteNonQuery(); } } } /// summary执行查询返回首行首列值/summary public static object ExecuteScalar(string sql, params SqlParameter[] ps) { using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); using (SqlCommand cmd new SqlCommand(sql, conn)) { if (ps ! null) cmd.Parameters.AddRange(ps); return cmd.ExecuteScalar(); } } } /// summary执行查询返回 DataTable用于绑定 DataGridView/summary public static DataTable ExecuteDataTable(string sql, params SqlParameter[] ps) { using (SqlConnection conn new SqlConnection(connStr)) { SqlDataAdapter adapter new SqlDataAdapter(sql, conn); if (ps ! null) adapter.SelectCommand.Parameters.AddRange(ps); DataTable dt new DataTable(); adapter.Fill(dt); return dt; } } }这段代码里用了 using 块来确保连接自动释放。C# 开发者对using (SqlConnection conn new SqlConnection(...))这种写法应该很熟它的作用等同于 finally 块里手动调用 conn.Dispose()只是编译器帮我们做了。SqlParameter 数组的传入是参数化查询的关键——所有外部输入都通过参数传递绝不拼字符串进 SQL。3.3 为什么必须用参数化查询而不是字符串拼接新手最容易犯的错误是把用户输入直接拼 SQL// 错误写法示范千万别这么写 string sql SELECT * FROM Guests WHERE GuestName txtName.Text ;这种写法有两个问题。第一是 SQL 注入风险比如文本框里输入 OR 11拼出来的 SQL 就变成了WHERE GuestName OR 11会把整张表查出来。第二是性能问题每次拼接出的 SQL 文本都不同SQL Server 无法复用执行计划导致缓存失效。参数化写法执行计划可复用SQL Server 会缓存第一次编译的执行计划后续同样结构的语句直接走缓存。另外一点要注意参数化不只针对字符串。数值型参数同样建议用 SqlParameter 传值而不是直接拼接数字。比如查询房间号时WHERE RoomNo 201看似没问题但如果传入的是用户可控的输入同样的注入风险依然存在。方案统一就一条规则凡是进入 SQL 的变量一律走参数。4. 业务实现与界面交互从房间总览到办理入住4.1 主窗体加载房间列表的完整流程FormMain 的 Load 事件是系统的入口这里要做三件事连接数据库、查询房间状态、把结果显示到 DataGridView。关键代码如下private void FormMain_Load(object sender, EventArgs e) { // 查询所有房间及对应房型、价格、状态 string sql SELECT r.RoomNo AS 房间号, r.RoomType AS 房型, r.Price AS 房价, CASE r.Status WHEN 0 THEN 空闲 WHEN 1 THEN 入住 WHEN 2 THEN 预订 ELSE 维修 END AS 状态 FROM Rooms r; DataTable dt SqlHelper.ExecuteDataTable(sql); dgvRooms.DataSource dt; // 设置列宽和只读模式避免用户误操作单元格 dgvRooms.ReadOnly true; dgvRooms.AutoSizeColumnsMode DataGridViewAutoSizeColumnsMode.Fill; }这里把状态数值转成中文的操作放在 SQL 里用 CASE WHEN 完成好处是 C# 代码里不用再写转换逻辑。如果你更习惯在 C# 侧处理也可以查出原始数值后在 DataGridView 的 CellFormatting 事件里转换效果相同但 SQL 侧处理更直观。DataGridView 绑定 DataTable 之后用户点击行时可以通过 CurrentRow 拿到当前房间的所有信息。比如双击一行打开登记窗口就需要把房间号传过去private void dgvRooms_CellDoubleClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex 0) return; // 点击列头时直接返回 string roomNo dgvRooms.Rows[e.RowIndex].Cells[房间号].Value.ToString(); int status Convert.ToInt32( SqlHelper.ExecuteScalar(SELECT Status FROM Rooms WHERE RoomNono, new SqlParameter(no, roomNo))); if (status ! 0) { MessageBox.Show(当前房间不是空闲状态无法办理入住); return; } FormCheckIn form new FormCheckIn(roomNo); // 通过构造函数传房间号 form.ShowDialog(); // 模态窗口处理完再回到主窗体 LoadRoomList(); // 刷新房间状态 }这段逻辑的关键在于状态判断前置——只有空闲房间才能办入住。这种校验放在界面层只是第一道防线真正可靠的校验应该在 SQL 更新语句里加上WHERE Status 0条件的乐观锁。4.2 FormCheckIn 的登记逻辑与参数化插入登记窗口拿到房间号后需要执行的操作包含两步往 Guests 表插入客人信息如果身份证号已存在则跳过再往 CheckInRecords 表插入入住记录同时把房间状态改成入住。这里我用一个事务来保证一致性private void btnConfirm_Click(object sender, EventArgs e) { string roomNo _roomNo; string name txtName.Text.Trim(); string idCard txtIdCard.Text.Trim(); string phone txtPhone.Text.Trim(); decimal deposit nudDeposit.Value; if (name.Length 0 || idCard.Length ! 18) { MessageBox.Show(请检查姓名和身份证号是否填写正确); return; } using (SqlConnection conn new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); SqlTransaction trans conn.BeginTransaction(); try { // 第一步查出客人ID若不存在则先插入 string selectGuest SELECT GuestId FROM Guests WHERE IdCard ic; SqlCommand cmdSelect new SqlCommand(selectGuest, conn, trans); cmdSelect.Parameters.AddWithValue(ic, idCard); object guestIdObj cmdSelect.ExecuteScalar(); int guestId; if (guestIdObj null) { string insertGuest INSERT INTO Guests(GuestName, IdCard, Phone) VALUES(n, ic, p); SELECT CAST(SCOPE_IDENTITY() AS INT); SqlCommand cmdInsertGuest new SqlCommand(insertGuest, conn, trans); cmdInsertGuest.Parameters.AddWithValue(n, name); cmdInsertGuest.Parameters.AddWithValue(ic, idCard); cmdInsertGuest.Parameters.AddWithValue(p, phone); guestId (int)cmdInsertGuest.ExecuteScalar(); } else { guestId (int)guestIdObj; } // 第二步插入入住记录 string insertRecord INSERT INTO CheckInRecords(RoomNo, GuestId, Deposit) VALUES(room, gid, dep); SqlCommand cmdRecord new SqlCommand(insertRecord, conn, trans); cmdRecord.Parameters.AddWithValue(room, roomNo); cmdRecord.Parameters.AddWithValue(gid, guestId); cmdRecord.Parameters.AddWithValue(dep, deposit); cmdRecord.ExecuteNonQuery(); // 第三步房间状态改为已入住注意 WHERE 条件做并发保护 string updateRoom UPDATE Rooms SET Status1 WHERE RoomNoroom AND Status0; SqlCommand cmdRoom new SqlCommand(updateRoom, conn, trans); cmdRoom.Parameters.AddWithValue(room, roomNo); int affected cmdRoom.ExecuteNonQuery(); if (affected 0) { throw new Exception(房间状态已变化请刷新后重试); } trans.Commit(); MessageBox.Show(入住登记成功); } catch (Exception ex) { trans.Rollback(); MessageBox.Show(登记失败 ex.Message); } } }事务在这个场景里是必须的。想象一下如果只执行前两条 SQL 不更新房态或者更新房态成功但插入记录失败系统会进入不一致状态。UPDATE Rooms SET Status1 WHERE RoomNoroom AND Status0这句是典型的乐观并发控制——它保证只有房间当前确实为空闲时才允许更新更新影响行数为 0 说明房间被别的操作抢先占用了。4.3 DataGridView 房客列表查询与退房计算FormRoomer 窗体通常要完成两个功能按条件查询在住房客以及退房结算。查询的核心是一个支持可选条件的 SQLprivate void btnSearch_Click(object sender, EventArgs e) { string keyword txtSearch.Text.Trim(); // 动态拼接 WHERE 条件但值仍然走参数化 string sql SELECT cr.RecordId, g.GuestName AS 客人姓名, g.IdCard AS 身份证号, r.RoomNo AS 房间号, cr.CheckInTime AS 入住时间, cr.Deposit AS 押金 FROM CheckInRecords cr JOIN Guests g ON g.GuestId cr.GuestId JOIN Rooms r ON r.RoomNo cr.RoomNo WHERE cr.CheckOutTime IS NULL; // 只查未退房的记录 if (keyword.Length 0) { sql AND (g.GuestName LIKE kw OR g.IdCard LIKE kw OR r.RoomNo LIKE kw); } SqlParameter[] ps keyword.Length 0 ? new SqlParameter[] { new SqlParameter(kw, % keyword %) } : null; DataTable dt SqlHelper.ExecuteDataTable(sql, ps); dgvInHouse.DataSource dt; }这里的动态 SQL 注意点在于动态的部分只影响结构所有值仍然走参数化。有些新手在拼接 AND 条件时顺手把 keyword 也拼进去等于开了一个口子。正确的方式是始终用kw参数拼接的只是 SQL 语句本身的文本。退房时费用计算是业务逻辑里相对容易出错的部分。房费怎么算、押金怎么退需要在代码里明确规则private void btnCheckOut_Click(object sender, EventArgs e) { if (dgvInHouse.CurrentRow null) return; string recordId dgvInHouse.CurrentRow.Cells[RecordId].Value.ToString(); string roomNo dgvInHouse.CurrentRow.Cells[房间号].Value.ToString(); // 查房型和入住时间 string query SELECT r.Price, cr.CheckInTime, cr.Deposit FROM CheckInRecords cr JOIN Rooms r ON r.RoomNo cr.RoomNo WHERE cr.RecordId rid; SqlParameter[] qps { new SqlParameter(rid, recordId) }; DataTable dt SqlHelper.ExecuteDataTable(query, qps); if (dt.Rows.Count 0) return; decimal price Convert.ToDecimal(dt.Rows[0][Price]); DateTime inTime Convert.ToDateTime(dt.Rows[0][CheckInTime]); decimal deposit Convert.ToDecimal(dt.Rows[0][Deposit]); // 计算天数不足一天按一天算这是宾馆行业常见规则 int days (int)Math.Ceiling((DateTime.Now - inTime).TotalDays); if (days 0) days 1; decimal totalFee price * days; decimal refund deposit - totalFee; string message string.Format(入住 {0} 天房费 {1:C}押金 {2:C}应退 {3:C}, days, totalFee, deposit, refund); MessageBox.Show(message); }天数取整用Math.Ceiling是对的方向但要注意边界条件——如果是当天入住当天退房(DateTime.Now - inTime).TotalDays 可能小于 1 也可能略大于 0这时Math.Ceiling可能得到 1也可能得到 0所以要在天数小于等于 0 时强制置 1。实际项目中退房时间点、延迟退房是否加收费用这些规则得跟需求方确认清楚再写进代码不要自己默认规则。5. 高频报错与运行环境注意点5.1 ResolveAssemblyReference.cache 是什么需要管吗项目文件清单里出现了多个 ResolveAssemblyReference.cache 和 DesignTimeResolveAssemblyReferencesInput.cache 文件不少初次接触 Visual Studio 的人会困惑这些文件是干什么的。简单说这是 Visual Studio 在编译和设计时生成的缓存文件用于记录程序集引用的解析结果例如哪个 DLL 被解析到哪个路径、引用的版本号等。Cache 文件会影响编译决策但不属于项目源代码。这个文件一般在项目自动生成的 obj 文件夹中MSBuild 每次编译时都会先对比 cache 内记录的引用时间戳如果引用的程序集没有变化就直接复用缓存结果可以省去重新解析的时间。如果项目出现引用了但编译不识别明明刚加了引用还是报找不到这种诡异问题顺手清理一下 obj 和 Bin 目录里的 cache 往往会有效果。操作方法项目右键 → 清理解决方案 → 再重新生成或者手动删除 obj 目录后重新编译。重点结论这些 cache 文件不需要提交到 Git打包课程设计时直接忽略即可它们不影响程序运行。5.2 数据库连接失败的排查思路从实际运行的报错概率来看高频异常集中在以下几条报错信息根源解决方式无法连接到 .\SQLEXPRESSSQL Server 实例名不对或服务未启动运行 services.msc 确认 SQL Server 服务已启动用户 sa 登录失败服务器处于 Windows 身份验证模式用 SSMS 改成混合模式并为 sa 设置密码数据库 HotelDB 不存在.mdf 未附加或连接串 Database 名称错在 SSMS 中执行附加数据库操作文件正由另一进程使用.mdf 已附加到本地实例连接串又用 AttachDbFilename改成直接指定实例访问方式针对最后一条这是初学者最容易踩的。如果一个 .mdf 文件已经通过 SSMS 附加到 SQL Server 实例然后连接串里又写了 AttachDbFilename 指向同一个文件SQL Server 会直接报文件占用错误。解决方法是去掉 AttachDbFilename只保留DatabaseHotelDB;Server.\SQLEXPRESS这种形式。5.3 WinForms 界面卡顿与刷新问题这套系统的体量不大常规操作不太会卡顿但如果你在它基础上扩展功能比如加实时房态刷新就要注意 WinForms 的跨线程控件更新问题。热词里频繁出现的C# 循环数据采集和 UI 刷新卡顿在这类系统里表现为用 Timer 定时刷新 DataGridView 或者循环插入数据时界面失去响应。原因很简单长时间占用 UI 线程会导致消息泵无法处理鼠标键盘事件。你有两个选择一是把耗时操作丢到 BackgroundWorker 或 Task.Run 中操作完成后通过 Invoke 回到 UI 线程更新控件二是减少刷新频率比如原来每 100ms 刷新一次改为 1 秒刷新一次同时只在数据发生变化时才重新绑定 DataSource避免每次都重建整个 DataGridView。这两条路一个改架构一个改参数视场景取舍。像宾馆管理系统这种低频业务数据用 Timer 一秒刷新一次完全够用没必要引入复杂方案。6. 把系统往正规项目改状态机重构与扩展技巧如果不想让这套系统停留在课程设计层面有几处值得动手改。第一是状态流转散落在各个事件里代码重复度高。比如 FormMain 里判断房间是否空闲、FormCheckIn 里更新状态、FormRoomer 里退房改状态各自独立写了一套逻辑。更好的是把状态迁移收敛到一个服务类中比如 RoomService.CheckIn(roomNo, guestId)、RoomService.CheckOut(recordId)让调用方只知道自己要做什么操作不关心 SQL 细节。第二是实验报告里提到系统用 Git 做版本控制但实际仓库里没有看到 .gitignore 配置。建议把 bin、obj、*.cache 目录加入忽略列表避免每次提交都带着几 MB 编译产物。另外数据库的 .mdf 文件如果也进了版本库团队协作时容易出冲突更好的做法是提交建表脚本和种子数据 SQL成员各自本地建库。第三是针对 DataGridView 的实用性增强给房间总览表格的状态列设置条件格式空闲显示绿色入住显示红色在 CellFormatting 事件里修改单元格背景色只需要几行代码但对日常使用的体验提升很明显。房型列可以加一个筛选 ComboBox只显示当前选择的房型SQL 侧拼一个AND RoomTypetype参数即可。最后给读者留一个验证方法拿到这套系统后编译运行的第一步不要急着点按钮先在 SQL Server Management Studio 里跑一遍数据库文件附带的三张表的建表脚本确认本地实例版本是 SQL Server 2008 以上这套系统用的是基础 T-SQL兼容性没有问题。然后用 SSMS 手动插入一条测试客人记录再回到程序里用身份证号搜索能查到说明数据库连接完全正常。接着走一遍完整的入住→查询→退房流程每一步操作完后回到 SSMS 查看对应表的数据变化确认事务回滚逻辑在异常场景下真的能生效。这套动作跑完这个系统在你手里才算真正落地了。本文还有配套的精品资源点击获取
返回列表