简介:一份基于C#的小型宾馆管理系统完整项目,内含数据库文件与实验报告,面向计算机专业学生及课程设计开发者。系统覆盖Windows Forms界面、ADO.NET数据库连接、业务逻辑与数据访问分层,典型展示C#桌面应用开发流程,适合学习WinForms与SQL Server交互的读者参考复现。压缩包共121个文件,约9.84MB,以C#源文件(44个cs)和VS资源文件(15个resx/Designer)为主,同时包含SQL脚本、exe可执行程序、DLL依赖及实验报告doc,其中SQL脚本可直接建库,doc报告详细记录开发过程与实现思路,便于直接运行与对照代码学习。已有210人学习使用。项目文件结构完整,从FormMain、FormCheckIn等窗体到Roomer管理模块,配合数据库脚本和报告文档,可为用户提供从需求分析、界面设计到数据操作的完整范例,是巩固面向对象编程与数据库开发技能的实用素材。
1. 先聊聊这套C#宾馆管理系统:一个课设Demo能给你什么
这套基于C#的宾馆管理系统,是典型的课程设计项目:WinForms界面、ADO.NET连库、SQL Server存数据,外加一份实验报告。它不是生产级的酒店PMS,没有会员体系、没有扫码入住,但它把「客户端程序该怎么和数据库打交道」这件事完整地走了一遍。对于还在学C#、正在找课设题目的同学,或者想快速捡起WinForms+ADO.NET这套老技术栈的开发者,这是性价比很高的参考物。项目里能直接看到窗体设计器生成的Designer.cs、数据库文件、还有老师要求的实验报告长什么样。你要做的是把代码跑起来,看懂每条线是怎么串的,然后改成你自己题目的样子。我拆这类项目拆得多,这套源码值得下下来对着撸一遍。
2. 数据库设计是起点:四张表、连接字符串与SqlCommand参数化
很多同学拿到这种项目,第一反应是打开FormMain.cs看界面代码,结果越看越晕。我的习惯是先看数据库文件,数据结构理顺了,代码基本就通了一半。宾馆管理系统的核心数据模型并不复杂,但表之间的关系和状态流转是理解整个系统的钥匙。
2.1 表结构设计:宾馆管理最少要几张表
这套项目的数据库文件一般有两种形态:SQL Server的.mdf/.bak,或者Access的.mdb。看项目引用里有没有System.Data.SqlClient,有就是SQL Server;要是用OleDb,那就是Access。以最常见的SQL Server版本为例,核心表通常是这几张:
CREATE TABLE Room ( RoomID INT IDENTITY(1,1) PRIMARY KEY, RoomNO VARCHAR(10) NOT NULL UNIQUE, RoomType VARCHAR(20) NOT NULL, Price DECIMAL(10,2) NOT NULL, Status INT NOT NULL DEFAULT 0 ); CREATE TABLE Guest ( GuestID INT IDENTITY(1,1) PRIMARY KEY, GuestName NVARCHAR(20) NOT NULL, IDCard VARCHAR(18) NOT NULL, Phone VARCHAR(11) ); CREATE TABLE CheckIn ( CheckInID INT IDENTITY(1,1) PRIMARY KEY, RoomID INT NOT NULL, GuestID INT NOT NULL, CheckInTime DATETIME NOT NULL, CheckOutTime DATETIME NULL, Deposit DECIMAL(10,2) DEFAULT 0, FOREIGN KEY (RoomID) REFERENCES Room(RoomID), FOREIGN KEY (GuestID) REFERENCES Guest(GuestID) );这里说几个需要注意的点:房间状态Status用int而不是字符串,0代表空闲、1代表已入住、2代表脏房/打扫中,这样在下拉框和条件查询里都好处理,不会出现中英文混写导致匹配不上的问题。价格用DECIMAL(10,2),这是成败关键,后面讲房费计算时会展开。Guest表里身份证号IDCard用VARCHAR而不是NVARCHAR,因为身份证是纯数字和字母,不需要Unicode支持。
表结构能看出什么?订单是CheckIn表,它同时关联Room和Guest,这个设计意味着一个客人可以开多个房间(每个房间一条记录),也可以一个房间被不同客人先后入住(时间错开),这是宾馆的基本场景。但要注意:这套表结构里没有单独的「订单明细」表,也没有「消费项目」表,如果你要改成带矿泉水、洗衣费的版本,就得自己加表。
2.2 连接字符串:为什么总有人连不上数据库
连接字符串是WinForms项目的第一个大坑。这套项目里最常见的是写死在App.config里的连接字符串,或者直接在代码里拼。典型写法如下:
<connectionStrings> <add name="HotelDB" connectionString="Data Source=.;Initial Catalog=HotelDB;Integrated Security=True" providerName="System.Data.SqlClient" /> </connectionStrings>读出来的代码也很固定,一般封装成一个DBHelper类:
public static SqlConnection GetConnection() { string connStr = ConfigurationManager.ConnectionStrings["HotelDB"].ConnectionString; SqlConnection conn = new SqlConnection(connStr); return conn; }注意这几个参数:Data Source的.代表本机默认实例,如果你装的是命名实例(比如localhost\SQLEXPRESS),这里就必须写localhost\SQLEXPRESS,这是90%的「数据库连不上」报错根源。Integrated Security=True用的是Windows身份验证,如果目标机器上SQL Server只开了混合验证,这个连接串也会失败,需要改成User ID=sa;Password=xxx。
我一般拿到这种项目,第一步不会是去跑程序,而是先拿SQL Server Management Studio试着连一次这个库。SSMS能连上,程序连不上,那就是代码问题;SSMS都连不上,那是环境问题,别急着改代码。
2.3 SqlCommand参数化:防止注入和日期格式坑
看这种课设项目,我最关心的是SQL语句怎么写。老版本的项目经常看到字符串拼接SQL,比如:
string sql = "SELECT * FROM CheckIn WHERE GuestName = '" + txtName.Text + "'";这种写法在课程设计里能跑,但有两个隐患:一是SQL注入,二是当输入里带单引号时直接报错。正确的做法是用SqlParameter参数化:
string sql = "SELECT * FROM CheckIn WHERE GuestName = @name"; SqlCommand cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@name", txtName.Text.Trim()); SqlDataAdapter da = new SqlDataAdapter(cmd); DataTable dt = new DataTable(); da.Fill(dt); dataGridView1.DataSource = dt;这段代码的逻辑是:先构造带占位符的SQL,再用Parameters填充值,最后通过SqlDataAdapter把结果填充到DataTable里,直接绑定给DataGridView显示。参数化的好处不只是防注入——它还强制把日期、数字按类型传入,避免把2024-01-02这种字符串丢给数据库时被区域设置影响。AddWithValue虽然方便,但在字段是DECIMAL或DATETIME时,建议改用cmd.Parameters.Add("@price", SqlDbType.Decimal).Value = price,更稳。
3. 窗体与主流程:FormMain跳转FormCheckIn,参数怎么传
数据层理顺了,接下来看界面层。WinForms项目的代码量大多集中在窗体里:FormMain是主窗体,FormRoomer是客人管理,FormCheckIn是入住登记。这套项目的主流程是这样的:主窗体加载房间状态列表,点击某个房间触发入住操作,弹出入住窗体,填完信息保存后刷新房间状态。这里面的关键在于窗体之间怎么传值。
3.1 主窗体FormMain:加载房间状态的两种姿势
主窗体的核心是房间列表的展示。常见做法是放一个DataGridView,或者用FlowLayoutPanel动态生成一排房间卡片。加载代码一般长这样:
private void FormMain_Load(object sender, EventArgs e) { LoadRoomStatus(); } private void LoadRoomStatus() { string sql = "SELECT RoomNO, RoomType, Price, Status FROM Room ORDER BY RoomNO"; DataTable dt = DBHelper.ExecuteQuery(sql); dataGridViewRooms.DataSource = dt; // 给状态列显示中文 for (int i = 0; i < dt.Rows.Count; i++) { int status = Convert.ToInt32(dt.Rows[i]["Status"]); dataGridViewRooms.Rows[i].Cells["ColStatus"].Value = status == 0 ? "空闲" : (status == 1 ? "已入住" : "打扫中"); } }这里有个容易被忽略的点:把整张Room表绑到DataGridView上,状态列显示的是0/1/2,用户根本看不懂。常见做法是上面这样遍历行去替换显示文本,但更好的做法是在SQL层就用CASE把状态翻译成中文:
SELECT RoomNO, RoomType, Price, CASE Status WHEN 0 THEN N'空闲' WHEN 1 THEN N'已入住' ELSE N'打扫中' END AS StatusText FROM Room两种做法效果一样,但第二种把展示逻辑放在SQL里,C#代码更干净。缺点是如果后面要按状态数字筛选,还是得保留原始Status字段,所以很多项目干脆两个字段都查出来,一个用来显示,一个用来判断。
3.2 入住窗体FormRoomer:下拉框绑定与缺省值
入住窗体的典型布局是:显示所选房号、选择客人(或新建客人)、设置押金、点保存。客人选择这块,老项目喜欢用一个ComboBox,从Guest表拉数据:
string sql = "SELECT GuestID, GuestName FROM Guest"; DataTable dt = DBHelper.ExecuteQuery(sql); cmbGuest.DataSource = dt; cmbGuest.DisplayMember = "GuestName"; cmbGuest.ValueMember = "GuestID";这三行配置很关键:DisplayMember决定用户看到什么,ValueMember决定选中后你拿到什么。后面取选中值时用cmbGuest.SelectedValue,拿到的就是GuestID了,不用再解析文本。这里有个坑:如果Guest表是空的,下拉框里什么都没有,所以很多项目会加一个「新客人」按钮,点击后弹出一个小窗体录入客人信息,保存后重新绑定下拉框。这类代码的价值不只是「能跑」,它在教你一个交互模式:主从表数据在界面上的联动刷新。
3.3 房间选择与传参:FormCheckIn怎么拿到房号
主窗体点击一个房间,弹出入住窗体,房号怎么传过去?最简单的做法是给入住窗体加一个有参构造函数:
public partial class FormCheckIn : Form { private string _roomNo; private int _roomId; public FormCheckIn(string roomNo, int roomId) { InitializeComponent(); _roomNo = roomNo; _roomId = roomId; } private void FormCheckIn_Load(object sender, EventArgs e) { txtRoomNo.Text = _roomNo; // 可以根据_roomId去查房价等信息 } }调用处就更简洁了:
FormCheckIn frm = new FormCheckIn(roomNo, roomId); frm.ShowDialog(); LoadRoomStatus(); // 关闭后刷新列表用有参构造函数传参,比在窗体里挖空心思找控件赋值要干净得多。ShowDialog后面紧跟刷新,是因为入住保存完成后,主窗体必须立刻反映房间状态变化。有些项目会犯懒不做这一步,结果入住完还要手动刷新,体验很差。
4. 入退房的核心逻辑:房价计算、状态机与并发入住
前台功能里最容易翻车的就是房价计算和房间状态更新。这两块逻辑不复杂,但边界条件多。我拆过的宾馆管理课设里,十有八九在退房时房费算错,或者在并发操作时出现一个房间开两单的情况。
4.1 房价计算:DateTime差值与decimal精度
先看退房时的费用计算代码,这是最常见的写法:
DateTime checkInTime = Convert.ToDateTime(dtRow["CheckInTime"]); DateTime checkOutTime = DateTime.Now; // 按天算,不满一天按一天算 TimeSpan span = checkOutTime - checkInTime; int days = span.Days; if (span.Hours > 0 || span.Minutes > 0) { days += 1; } decimal price = Convert.ToDecimal(dtRow["Price"]); decimal total = price * days;这段逻辑有三个隐藏问题。第一,TimeSpan.Days在跨月或跨年时不代表真实的「自然日」天数,比如2月1日入住到3月1日退房,按TimeSpan算出来是28天或29天,但按自然日应该是28或29天,问题不大;真正的问题是如果入住时间恰好是23:00,第二天凌晨2:00退房,用户住了一晚,按小时算不满24小时,按自然日算应该是2天(跨了两个日期),这里容易扯皮。常见做法是只比较日期部分而不是时间部分。
第二,直接用DateTime.Now去减,会掺杂机器时间。正确做法是让SQL Server返回时间:SELECT GETDATE()。原因很简单:应用服务器的时间和数据库服务器的时间可能不一致。不过课设通常同一台机器,影响不大。
第三,用double类型算钱是新手常见错误。double的浮点表示会导致类似0.1+0.2不等于0.3的问题。我见过有项目用float存房价,退房时显示99.999999,客人投诉。正确做法是所有金额一律用decimal,这也是前面建表时强调DECIMAL(10,2)的原因。
4.2 房间状态机:空闲、已入住与脏房
房间状态流转是整个系统的核心逻辑,它应该是一个严格的状态机:
- 空闲(0) → 已入住(1):客人办理入住
- 已入住(1) → 脏房(2):客人退房,房间待打扫
- 脏房(2) → 空闲(0):保洁打扫完成,房间可售
这个流转在代码里对应着三次UPDATE语句。入住时:
string sql = "UPDATE Room SET Status = 1 WHERE RoomID = @roomId AND Status = 0";注意这里的AND Status = 0不是多余的,它是一个并发保护条件——只有房间当前是空闲状态时才能置为已入住,如果已经被别人抢先入住,这个UPDATE影响的行数是0,程序就能据此提示「房间已被占用」。这是防止一房两单最便宜的做法。
退房时同理:
string sql = "UPDATE Room SET Status = 2 WHERE RoomID = @roomId AND Status = 1";把状态先置为脏房,而不是直接置为空闲,这个设计我很喜欢。它模拟了真实酒店的工作流:退房之后保洁要打扫,打扫完才能卖给下一位客人。很多课设项目直接把房间置为空闲,少了一张「打扫中」的状态,虽然功能上也能跑,但少了这层设计就少了业务上的合理性,答辩时老师一问就露怯。
4.3 并发入住:一房两单怎么防
上面说的AND Status = 0是一种乐观并发控制。但注意,这还不够。完整的入住流程应该在一个事务里完成:
using (SqlTransaction tx = conn.BeginTransaction()) { try { // 1. 插入入住记录 string sqlCheckIn = @" INSERT INTO CheckIn (RoomID, GuestID, CheckInTime, Deposit) VALUES (@roomId, @guestId, GETDATE(), @deposit);"; // 2. 更新房间状态 string sqlUpdateRoom = @" UPDATE Room SET Status = 1 WHERE RoomID = @roomId AND Status = 0"; SqlCommand cmd1 = new SqlCommand(sqlCheckIn, conn, tx); SqlCommand cmd2 = new SqlCommand(sqlUpdateRoom, conn, tx); int affected = cmd2.ExecuteNonQuery(); if (affected == 0) { tx.Rollback(); MessageBox.Show("房间状态已变化,请刷新后重试"); return; } cmd1.ExecuteNonQuery(); tx.Commit(); } catch { tx.Rollback(); throw; } }事务存在的意义是:如果插入入住记录成功但更新房间状态失败,两个操作必须同时回滚,否则会出现「客人信息存在但房间没入住」的数据脏状态。判断affected == 0的时机要在更新房间状态之后、提交事务之前。很多人会把顺序搞反——先更新状态再插入记录,或者干脆不检查返回值,这两种都埋了雷。
另外还要提一下:DataGridView的行选择状态也是并发问题的来源。用户双击一个房间弹出入住窗体,这个期间没有人能操作主窗体,因为ShowDialog是模态的,所以UI层面的并发问题在这个项目里并不严重。真正的威胁来自多台收银机同时操作同一个库,这时事务和状态条件的价值就体现出来了。
5. 常见问题与避坑:编译不过、数据库附加失败、缓存残留
这类课设项目从下载到跑通,中间隔着好几道坎。我按踩坑的频率排个序,基本都是我帮人看项目时反复遇到的问题。
5.1 现象:用Visual Studio打开项目报一堆ResolveAssemblyReference.cache错误
首先是编译层面。你会在项目文件夹里看到ResolveAssemblyReference.cache、DesignTimeResolveAssemblyReferencesInput.cache这些文件,它们不是源码,是Visual Studio在编译时生成的临时缓存文件。如果这些文件损坏、或者它们记录的引用和当前环境不一致,VS会报一些莫名其妙的引用错误,比如「未能找到类型或命名空间」。
原因是:这些cache文件记录了上一次编译时程序集的解析结果,很多同学把项目打包发人时没清理这些文件,下载者用不同版本的VS打开,VS尝试复用旧缓存但路径对不上。
解决方法是手动清理:关掉VS,删除bin、obj、Debug目录,以及所有.cache文件,重新打开工程。如果还报错,检查项目文件里的引用路径是不是指向了本机特定位置的DLL。我在处理这种项目时,一般会先删缓存再开,不删缓存就编译,容易看到假报错。
5.2 现象:附加数据库失败,提示版本号高于当前SQL Server
很多课设用的数据库文件是.mdf,直接在VS里附加或用SSMS附加时,提示「文件版本655,无法附加」。这通常是因为对方用的是较新版本SQL Server(比如2019/2022),而本机是SQL Server 2012或2014,低版本无法附加高版本生成的数据库文件。
解决办法有两条路。一是换用本机能跑的最高版本SQL Server,或者去装一个SQL Server 2022 Express,把.mdf附加进去。二是让发给你项目的人把数据库导出成.bak备份文件,或者生成创建脚本(Script Database → Generate Scripts),你本地执行脚本重建数据库。更省事的方式是直接把数据库转成Access,用OleDb连接,不受版本限制。从资源包里看,如果是这个.mdf文件跟你本机不兼容,我建议先查一下.mdf的版本,匹配不上再走脚本重建。
5.3 现象:程序能编译但运行时连不上数据库,报「网络相关或特定实例错误」
这是最常被问到的问题,原因基本都出在连接字符串的Data Source上。我把数据库附加到本机SQL Server后,第一次跑程序,它报错说找不到服务器。
原因是:项目里写死的连接字符串,比如Data Source=.;Initial Catalog=HotelDB;Integrated Security=True,它指向的是对方机器上的SQL Server。你本机的实例名可能不是默认实例,是个带版本后缀的命名实例,比如localhost\SQLEXPRESS。
解决方法是:打开项目里的App.config(或者Web.config / Settings.settings),把Data Source改成自己机器的实例名。检查方法也很简单,在SQL Server Management Studio登录窗口就能看到服务器名,照着填进去就行。如果你装了多个版本的SQL Server,还要注意默认实例和命名实例的区别,一个冒号加实例名的写法写错就白干。我一般会在DBHelper里加一个小函数,要么允许从配置读取,要么自动探测localhost\SQLEXPRESS和.两种写法,省得每次换电脑都改代码。
5.4 现象:退房时房费算出来是负数
某次我跑一个改过的版本,客人当天入住当天退房,房费居然是负数,后面一查发现是时间相减方向搞反了。
原因是:退房时间取的字段并不是实际的退房时间,而是误取了CheckInTime。也就是说,代码里查出来的两列都是退房时间,或者赋值时变量写反了,比如把Convert.ToDateTime(dtRow["CheckInTime"])和Convert.ToDateTime(dtRow["CheckOutTime"])写反了。
解决方法是:在SQL查询里把两个时间的列名都打出来,检查数据源里到底哪个字段有值。很多表设计里CheckOutTime字段允许NULL,退房时才写入。如果查询语句里没有把CheckOutTime查出来,前端就永远取不到退房时间,退房操作就没法算费。我给这类项目补的常规操作是:退房按钮的Click事件里,先更新CheckIn表的CheckOutTime字段,再重新查询计算费用,两步在一个事务里完成,就不会出现负数和零费用。
5.5 现象:改了代码但运行时还是老界面,怎么看都是旧版本
这是很迷惑的「玄学」问题——明明改了FormMain里的文本,运行起来还是老样子。
原因是:Visual Studio Debug模式下,程序集缓存在bin\Debug目录里,有时候编译没有完全覆盖,或者你运行的是旧进程的残留。特别是带Designer.cs的窗体,如果之前运行的程序没有退出,还在后台占用着EXE文件,新的编译就会失败或跳过。
解决方法是:先确认所有宿留的进程退出(Ctrl+Shift+Esc看进程列表有没有残留的程序名),然后右键项目 → 重新生成,而不是生成。如果还不行,手动删掉bin和obj目录再生成。养成习惯之后,我再也不信「改了没生效」这种话,基本都是编译输出目录的旧文件在捣鬼。
6. 结课提交前做一遍冒烟:从开房到退房全流程验证
最后的验证阶段,我有一套固定的冒烟流程,照着走一遍,系统有没有问题基本心里有数。先说步骤,再说实验报告怎么写能让老师给高分。
冒烟测试的路径是这样:先打开主窗体确认房间列表能加载,然后点一个空闲房间办理入住,选客人(或新建客人),填押金和入住时间,保存后回主窗体看房间状态是不是变成「已入住」。接着再对这个房间做一次「误操作」测试:再点入住,这时候应该提示房间已被占用,或者不允许打开入住窗体——这一步是验证状态机有没有生效。最后办理退房,确认房费计算正确、房间状态变成「打扫中」。如果系统里有「打扫完成」按钮,点一下让房间回到「空闲」,整个链路就闭环了。
这套流程跑完,数据库里应该能看到一条完整的CheckIn记录(CheckInTime有值、CheckOutTime有值),Room的状态从0走到1再到2再到0。如果中途哪一步卡住,优先看是不是SQL语句里漏了条件。
实验报告方面,这份报告的核心价值在于把开发过程和问题解决方案讲清楚。我一般建议报告按这样的顺序展开:需求分析(系统要解决什么问题)→ 数据库设计(表结构说明要用表格列清楚)→ 模块划分(画出窗体导航逻辑)→ 核心代码说明(选关键功能贴代码并逐行解释)→ 测试过程(把冒烟流程截图放进去)→ 遇到的问题与解决。很多同学写报告只会贴代码截图,这其实是最亏的。老师最想看到的是「你遇到了什么问题、怎么定位、怎么解决」——比如连接不上数据库后怎么排查的细节,这个东西最能体现工作量。
最后提醒一句:提交前确认项目文件里有数据库文件和实验报告,但ResolveAssemblyReference.cache这类临时缓存直接删掉,不要打包进去。我就是有一次没清理干净,老师在另一台机器上打开报错,邮件来回两趟才解决。从那以后,我每次发源码包之前,都会强制走一遍「清理解决方案 → 删除bin和obj → 删.cache → 再压缩」的流程。希望帮到你,这套系统值得你下下来从头跑到尾,跑通了,C#这门课的课设基本就稳了。
本文还有配套的精品资源,点击获取