简介:一份基于C#与SQL Server开发的火车订票系统完整项目,面向正在学习C#窗体应用、数据库编程及信息管理系统的开发者,可作为课程设计或毕业设计的实战参考。资源共128个文件,压缩包仅1.85MB,主体包含50个cs源码文件、24个resx资源文件以及可直接运行的exe程序,覆盖用户注册登录、车次查询、订票退票、改签与订单管理等模块,并配有窗体设计器文件与数据库交互代码,便于对照学习。另有jpg图片、pdb调试文件及工程配置文件,可帮助还原项目结构。目前已有1437人学习下载。通过研读该项目,读者可掌握C#事件驱动编程、SQL Server增删改查、多用户数据一致性处理等关键技能;大量窗体设计文件清晰展示了界面与业务逻辑的分离方式,数据库部分演示了参数化查询与事务处理的基本用法,既适合初学者进阶,也便于开发者快速二次开发,是理解信息管理系统从界面到数据库完整链路的实用范例。
1. C#火车订票系统到底是什么:从课程设计到小型管理系统的第一选择
如果你拿到“C#火车订票系统”这个需求,大概率它是一门课设、毕设,或者某公司内部一个练手用的信息管理Demo。这个系统听起来只是“增删改查”,但它比普通的图书管理系统多了一个关键约束:余票不能卖超,订单和车次数据必须对得上。很多人一开始觉得无所谓,直接在窗体上拖控件,把SQL全写在按钮点击事件里,等真要演示的时候才发现,并发一点、余票变负数、报表对不上、代码根本没法维护。
这个标题要解决的事情很具体:用C#做一个带用户登录、车次查询、订票、退票、后台管理的桌面端系统,同时在数据层把“余票扣减”这个业务约束做正确。适合课程设计提交、毕业设计展示、以及刚入门的开发者用一个小项目练手。全文会按“架构怎么定 → 数据库怎么建 → 核心代码怎么写 → 常见坑有哪些 → 怎么做得更稳”的顺序讲,你跟着走就能跑通一个能演示、能答辩、能往上加功能的火车订票系统。
2. 先定架构再写代码:三层分层与界面框架选型
2.1 需求边界:一个能验收的火车订票系统该包含哪些模块
动手建项目之前,先把需求边界划清楚。C#火车订票系统最常见的功能清单是这几块:用户注册登录、车次查询、订票、退票、个人订单查看,以及后台的车次管理和订单管理。以下是我在类似项目里习惯性的最小功能划分表:
| 模块 | 功能点 | 说明 |
|---|---|---|
| 用户模块 | 注册、登录、个人信息 | 密码必须加密存储 |
| 车次模块 | 按起止站和日期查询车次 | 列表展示车次、时间、票价、余票 |
| 订票模块 | 选择车次、填写乘客、提交订单 | 核心是余票扣减不超卖 |
| 订单模块 | 我的订单、退票 | 退票要恢复余票 |
| 后台管理 | 车次增删改、订单查看 | 课设里通常只需要简单CRUD |
不建议一上来就加支付、短信通知、选座界面这些东西。一个火车订票系统要拿得出手,关键在于“业务闭环”:注册能登录、查了能订、订了能看、退了能还。先把这个闭环走通,再谈加功能。
很多同学拿到需求先画窗体,画完再想表结构,这是往后所有返工的根源。正确顺序是先定模块边界,再画数据流,最后才落窗体。模块划分决定了你要建几张表、写几个方法,也决定了后面答辩时你怎么讲清楚系统设计。
2.2 为什么我坚持用三层架构而不是把所有代码堆在按钮里
火车订票系统的规模不大,一两百个方法封顶。但如果全部塞进窗体事件里,后面改一个SQL要翻五六个窗体,查一个问题要断点打半天。我一般会默认按三层拆:UI层放窗体,业务层放订票、退票、登录校验这些规则,数据访问层只管SQL和数据库连接。如果你用过ASP.NET MVC,这套结构和Controller、Service、Repository是同一个思路,只是形态从Web变成了WinForms。
三层里最容易偷懒的是直接跳过业务层。有人觉得订票不就是“做个UPDATE再做个INSERT”,写个工具类搞定。真出问题时你会发现,超卖判断放哪、事务提交放哪、异常回滚放哪,这些问题在业务层里是清晰的一条调用链,堆在按钮里就是一团纠缠的代码。举个例子,订票方法在业务层是BookTicket(userId, trainId, count, passengers),窗体里只需要判断返回值是true还是false,底下是扣余票还是插订单,UI层完全不用关心。这样写出来的东西你过两周再看还能改得动。
2.3 界面框架选型:WinForms、WPF还是ASP.NET Core
同一个标题,不同环境里的最佳选择不一样。这里给一张我常用的选型表,你按自己的场景直接抄:
| 选型 | 适合场景 | 优点 | 注意点 |
|---|---|---|---|
| WinForms | 课程设计、快速演示、内部工具 | 拖控件快,资料多,答题面试容易讲 | 界面样式老旧,数据绑定要手动控 |
| WPF | 毕设加分、需要现代界面的桌面端 | XAML布局灵活,MVVM模式适合扩展 | 学习曲线高,做动画容易耗时 |
| ASP.NET Core Web | 公司真实业务、远程访问 | 浏览器访问,适合多用户 | 不是桌面系统,方向偏Web |
如果是课设,我建议WinForms;如果想在毕设里体现一点设计感,就用WPF。核心业务代码三层架构不变,换界面层不至于伤筋动骨。我见过有人用WPF把登录界面做了三周,结果订票逻辑还没写——界面是门面,但火车订票系统的分数大头永远在数据一致性和业务完整性上。
3. 数据模型设计:先画好六张表再动手
3.1 核心表结构与建表SQL
数据模型是整个系统的地基。一个能演示的C#火车订票系统,通常需要四张核心表:用户表、车次表、订单表、订单明细表。明细表的作用很关键:一次订三张票,订单头存一条,明细存三条,退票时才能知道退的是哪几个乘客。
CREATE TABLE Users ( UserId INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(50) NOT NULL UNIQUE, PasswordHash NVARCHAR(200) NOT NULL, RealName NVARCHAR(50) NOT NULL, IdCard NVARCHAR(18) NULL, Phone NVARCHAR(11) NULL, CreatedAt DATETIME DEFAULT GETDATE() ); CREATE TABLE Trains ( TrainId INT IDENTITY(1,1) PRIMARY KEY, TrainNo NVARCHAR(10) NOT NULL UNIQUE, StartStation NVARCHAR(50) NOT NULL, EndStation NVARCHAR(50) NOT NULL, StartTime DATETIME NOT NULL, ArriveTime DATETIME NOT NULL, Price DECIMAL(10,2) NOT NULL, RemainingTickets INT NOT NULL DEFAULT 0 ); CREATE TABLE Orders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, UserId INT NOT NULL REFERENCES Users(UserId), TrainId INT NOT NULL REFERENCES Trains(TrainId), TicketCount INT NOT NULL, TotalAmount DECIMAL(10,2) NOT NULL, OrderTime DATETIME DEFAULT GETDATE(), Status TINYINT DEFAULT 0 ); CREATE TABLE OrderItems ( ItemId INT IDENTITY(1,1) PRIMARY KEY, OrderId INT NOT NULL REFERENCES Orders(OrderId) ON DELETE CASCADE, PassengerName NVARCHAR(50) NOT NULL, IdCard NVARCHAR(18) NOT NULL, SeatNo NVARCHAR(10) NULL );这段DDL里有几个可以答深一点的设计,逻辑上要能讲明白。UserId做成IDENTITY自增,注册时只需要拿到数据库生成的主键,不用自己拼GUID。UserName加UNIQUE约束,不注册重名账号。Orders表里的Status用TINYINT存0和1,0代表已订、1代表已退,这样退票是“改状态”而不是“删记录”,要查历史订单随时能查。OrderItems通过OrderId外键关联订单头,并在外键上加了ON DELETE CASCADE——删订单头时明细自动清掉。
3.2 余票模型:用数字字段还是细到座位
余票是这个系统最容易翻车的点,设计时有两条路线。第一种是在车次表里放一个RemainingTickets余票数字字段,订票时减一,退票时加一。优点是简单直观,查询列表时直接显示余票数;缺点是它不关心票具体是哪个座位,适合课设和演示。第二种是建一张Seats座位表,每个车次生成一排座位记录,每张座位有状态字段,订票时把对应座位改成已售。这个方案更真实但也更琐碎,还要处理车次固定编组、退票释放座位等逻辑。
我建议课设选数字字段方案,理由很实际:火车订票系统的演示重点在“业务闭环”和“并发正确性”,不在座位排布。把余票字段设计对,配合事务条件更新,已经能挡住超卖。如果你后面想升级,再单独加Seats表也不迟。这条路线选择在答辩时很加分——你能说清楚两种方案各自的适用边界,说明你真想过设计而不是照抄。
3.3 外键与级联删除:设置不对会让你删订单时连用户一起删掉
外键关系看起来简单,但级联删除用错非常危险。常见做法是把所有外键都加上ON DELETE CASCADE,图省事。但如果你在Orders的UserId外键上也用了CASCADE,删除一个测试用户时,他名下的所有订单全被连带删光,这在真实业务里是事故。
我的处理习惯是:订单明细属于订单,明细跟着订单删,用CASCADE;订单属于用户,但订单是核心业务数据,用户删除时订单要保留,所以Orders的UserId外键不加级联,用默认的NO ACTION。真要清理测试数据,就写SQL先删订单再删用户,而不是靠外键连锁反应。这个细节在课设答辩里经常被问到,答得清楚说明你懂外键约束的语义,而不是只会画表关系。
4. 用C#把核心流程跑通:从连接数据库到提交订单
4.1 连接字符串与数据访问层的第一个方法
工程落地先从连接数据库开始。WinForms项目里,连接字符串写在App.config中,而不是硬编码在代码里,这样换数据库环境时只改配置不改程序。
<connectionStrings> <add name="TrainDb" connectionString="Server=.\SQLEXPRESS;Database=TrainTicket;Integrated Security=True;Encrypt=False;" providerName="System.Data.SqlClient" /> </connectionStrings>参数含义逐个说:Server=.\SQLEXPRESS表示本机SQL Server实例,如果你装的是默认实例就写Server=.;。Database=TrainTicket是数据库名,大小写一般无所谓。Integrated Security=True表示用Windows身份登录,开发机最省事;如果环境要求SQL账号密码,就改成User ID=sa;Password=xxx。最后的Encrypt=False要特别注意,新版SQL客户端驱动默认开启强制加密,本地开发时没有证书就会连不上,加这一项能省掉一个小时的排查时间。
然后写一个数据访问基类,把查询和执行的样板代码收拢。这样四个窗体共用同一套数据库操作方式,出错时只需要改一个文件。
public static class DbHelper { private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["TrainDb"].ConnectionString; public static DataTable Query(string sql, params SqlParameter[] parameters) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); var table = new DataTable(); conn.Open(); using (var reader = cmd.ExecuteReader()) { table.Load(reader); } return table; } } public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } }这套封装覆盖了火车订票系统绝大多数数据操作:查列表用Query,写操作用ExecuteNonQuery。using关键字保证了连接和命令用完即释放,不写close也不容易泄漏连接。所有SQL参数都通过SqlParameter传入,不拼字符串,这是防SQL注入的底线。
4.2 登录与注册:参数化查询是底线
登录是所有管理系统的第一道门。火车订票系统的登录逻辑不复杂:按用户名查密码,比对哈希值,匹配就放行。但这里有两个必须做对的地方:参数化查询和密码哈希。
public bool Login(string userName, string password) { string sql = "SELECT COUNT(*) FROM Users WHERE UserName = @name AND PasswordHash = @pwd"; var p1 = new SqlParameter("@name", userName); var p2 = new SqlParameter("@pwd", HashPassword(password)); int count = Convert.ToInt32(DbHelper.ExecuteScalar(sql, p1, p2)); return count > 0; } public static string HashPassword(string input) { using var sha = SHA256.Create(); var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); return Convert.ToHexString(bytes); }这里参数化查询解决的是“用户名输入一个单引号就报SQL错误”和“万能密码直接登录”的问题。SqlParameter会把值当作数据而不是SQL语句的一部分,这是铁律。密码不存明文,存的是SHA256哈希,登录时算一次哈希再去比对,数据库被拖库也不会把密码直接暴露。有一点要提醒:Convert.ToHexString这个API在较新的.NET里才有,如果你还在用旧版.NET Framework,就换成BitConverter.ToString(bytes).Replace("-", ""),效果相同。
注册逻辑相比登录就多一个“用户名查重”的步骤,先查UserName是否存在,不存在才插入记录,插入的密码字段同样用哈希值。如果你要在这个系统上展示“安全意识”,把这两段代码写对,比加十个界面特效都有说服力。
4.3 订票主流程:事务 + 条件更新挡住超卖
订票是整个系统含金量最高的一段代码,核心要求是:无论多少客户端同时订票,余票都不能变成负数。很多第一次写的人会先SELECT余票,判断够不够,再UPDATE减掉。这个流程在单机演示没问题,一旦两个窗口同时提交,两个请求都读到余票为1,都通过判断,然后各减一次,余票就成了-1,超卖了。
正确做法是把“判断余票”和“扣减余票”合并成一条条件UPDATE,并让整个订票过程处于一个事务中:
public bool BookTicket(int userId, int trainId, int count, List<Passenger> passengers) { string updateSql = @" UPDATE Trains SET RemainingTickets = RemainingTickets - @count WHERE TrainId = @trainId AND RemainingTickets >= @count;"; string insertOrderSql = @" INSERT INTO Orders(UserId, TrainId, TicketCount, TotalAmount) OUTPUT INSERTED.OrderId SELECT @userId, @trainId, @count, Price * @count FROM Trains WHERE TrainId = @trainId;"; string insertItemSql = @" INSERT INTO OrderItems(OrderId, PassengerName, IdCard, SeatNo) VALUES(@orderId, @name, @idcard, @seatNo);"; using var conn = new SqlConnection(ConnStr); conn.Open(); using var tx = conn.BeginTransaction(); try { using var cmd1 = new SqlCommand(updateSql, conn, tx); cmd1.Parameters.AddWithValue("@count", count); cmd1.Parameters.AddWithValue("@trainId", trainId); int affected = cmd1.ExecuteNonQuery(); if (affected == 0) { tx.Rollback(); return false; // 余票不足,订票失败 } using var cmd2 = new SqlCommand(insertOrderSql, conn, tx); cmd2.Parameters.AddWithValue("@userId", userId); cmd2.Parameters.AddWithValue("@trainId", trainId); cmd2.Parameters.AddWithValue("@count", count); int orderId = (int)cmd2.ExecuteScalar(); foreach (var p in passengers) { using var cmd3 = new SqlCommand(insertItemSql, conn, tx); cmd3.Parameters.AddWithValue("@orderId", orderId); cmd3.Parameters.AddWithValue("@name", p.Name); cmd3.Parameters.AddWithValue("@idcard", p.IdCard); cmd3.Parameters.AddWithValue("@seatNo", p.SeatNo); cmd3.ExecuteNonQuery(); } tx.Commit(); return true; } catch (Exception ex) { tx.Rollback(); Log(ex); return false; } }这段代码的关键点有三个。第一,UPDATE语句带着WHERE RemainingTickets >= @count,数据库在执行更新时会重新判断当前值,两条并发请求同时进来,只有先拿到行锁的那条能更新成功,第二条的更新行数为0,直接回滚。第二,订单头通过OUTPUT INSERTED.OrderId拿到数据库生成的主键,然后用这个主键去插入明细,整个过程被事务包裹,中途任何一步失败就全部回滚,不会出现“票扣了但订单没生成”的脏数据。第三,订单金额直接在SQL里用Price * @count计算,避免先查票价再在C#里乘的做法带来额外查询和不一致。
有一个代码习惯要提:AddWithValue虽然写着方便,但它会让数据库自己推断参数类型,碰到int和decimal混用的情况偶尔会推断错,导致性能下降甚至报错。追求稳妥的话,写成new SqlParameter("@count", SqlDbType.Int) { Value = count },类型明确,排查问题也容易。这个小细节,面试官问到“你写的参数真的防注入吗”时,你能把类型绑定也讲出来,就是加分项。
4.4 车次查询与DataGridView绑定
查询模块算是比较前面的功能。按起止站和出发日期查车次,SQL不复杂,但日期参数的传递方式有讲究。
string sql = @" SELECT TrainNo, StartStation, EndStation, StartTime, ArriveTime, Price, RemainingTickets FROM Trains WHERE StartStation = @from AND EndStation = @to AND StartTime >= @date;"; var table = DbHelper.Query(sql, new SqlParameter("@from", fromStation), new SqlParameter("@to", toStation), new SqlParameter("@date", date)); // date是DateTime类型 dataGridView1.AutoGenerateColumns = false; dataGridView1.Columns.Clear(); dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "TrainNo", HeaderText = "车次" }); dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "StartStation", HeaderText = "出发站" }); dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "EndStation", HeaderText = "到达站" }); dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "StartTime", HeaderText = "出发时间" }); dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Price", HeaderText = "票价" }); dataGridView1.DataSource = table;日期参数这里必须传DateTime对象而不是字符串。如果你把DateTime.ToString()拼进SQL,日期格式会受操作系统区域设置影响,可能变成“2025/6/1 8:30:00”或“2025-06-01”,数据库解析时要么报错要么查不到数据。传DateTime对象就没有这个问题。
DataGridView绑定前,我习惯先设AutoGenerateColumns = false,然后手动映射每一列。默认自动生成列虽然省事,但会把所有查询字段都显示出来,而且重复查询时列会叠加。手动映射加上Columns.Clear(),保证每次查询界面都干净可控。这不算高级技巧,但能让你在演示时少很多尴尬时刻。
5. C#火车订票系统常见问题排查:5个翻车现场
5.1 数据库连不上、登录被注入:基础层最容易踩的3个坑
第一个高频翻车现场是运行程序就报“无法打开数据库”。现象是程序一启动就弹异常,或者登录时提示连接失败。原因多数是连接字符串没写对,或者SQL Server没有启用对应的登录模式。我看到过最常见的情况是Server写了本机IP但实例名漏了,或者Database名字大小写和实际不符。解决方法是先不用程序测,打开SQL Server Management Studio用同样的服务器名和账号试连一次,能连上说明连接字符串的问题;连不上就去检查服务是否启动、身份验证模式是不是混合模式。把SSMS当作第一排查工具,能分走一半的“玄学问题”。
第二个翻车现场是用户名输入一个单引号程序就炸。现象是登录框输入admin'或'1'='1,竟然能进系统,或者输入张三'直接报SQL语法错误。原因百分之百是代码里用了字符串拼接SQL,比如"SELECT * FROM Users WHERE UserName='" + name + "'"。解决方法是把所有拼SQL的地方全部换成SqlParameter参数化查询。这里没有捷径,系统的每一个数据入口——登录、注册、查询、订票,都得用参数化。
第三个翻车现场是数据库里密码明文可见。现象是打开Users表能看到一堆真正的密码,谁有数据库权限谁就能冒充任意用户登录。原因很明显,注册时没做哈希。解决方法就是前面写的HashPassword方法,存哈希不存明文。虽然火车订票系统只是个课设,但你写的是“订票系统”,用户数据天然敏感,密码加密这个动作不做,答辩时被问到就是硬伤。
5.2 余票变负数、界面数据不刷新:业务层的2个隐蔽问题
第四个翻车现场是余票变成负数。现象是开两个客户端,同时订最后一张票,两边都显示订票成功,但车次余票变成了-1。原因是我前面讲过的“先SELECT后UPDATE”的经典超卖问题,两个事务都读到余票1,都通过了判断,然后各减一次。解决办法也是前面写的条件UPDATE加事务,把“判断”和“扣减”合并到一条语句里执行。这条必考,建议你写完后专门开两个窗口实测一次,确认受影响行数为0的那个请求正确返回了失败。
第五个翻车现场是DataGridView数据不刷新或列越加越多。现象是第一次查询正常,第二次查询时列表里出现重复列,或者查完订票后返回查询窗体,数据还是旧的。原因通常是两个:绑定前没清空旧数据,以及把DataReader直接丢给界面用而没有先加载到DataTable。解决方法是每次绑定前调用Columns.Clear()和Rows.Clear(),并且确保传给DataSource的是填充好的DataTable而不是还在读取中的DataReader。顺手把查询按钮里的数据加载逻辑抽成一个BindGrid()方法,每次查询调它,保证不会漏清。
6. 让订单不再超卖的进阶做法:存储过程与并发验证
前文的事务加条件更新已经能解决问题,但如果你想在展示时多讲一层,或者希望在多用户环境下更稳一点,可以把订票核心逻辑下沉到存储过程,用数据库端的事务来保证一致性。这样一来,C#这边只需要调用一次存储过程,余票判断、扣减、订单生成全部在数据库内部完成,减少了网络往返和C#侧出错的概率。
CREATE PROCEDURE sp_BookTicket @userId INT, @trainId INT, @count INT, @orderId INT OUTPUT AS BEGIN SET XACT_ABORT ON; BEGIN TRAN; UPDATE Trains WITH (UPDLOCK) SET RemainingTickets = RemainingTickets - @count WHERE TrainId = @trainId AND RemainingTickets >= @count; IF @@ROWCOUNT = 0 BEGIN ROLLBACK; SET @orderId = -1; RETURN; END; INSERT INTO Orders(UserId, TrainId, TicketCount, TotalAmount) SELECT @userId, @trainId, @count, Price * @count FROM Trains WHERE TrainId = @trainId; SET @orderId = SCOPE_IDENTITY(); COMMIT; END;C#侧调用这个存储过程就简洁多了,一个SqlCommand设置CommandType为StoredProcedure,传入用户、车次、张数,输出参数拿到订单号或-1。这个进阶版的优点在于锁的控制更清楚:UPDATE语句加了WITH (UPDLOCK),直接告诉数据库这一行我要更新,并持有更新锁直到事务结束,后续并发请求会排队等待。这套写法在答辩时能展示你不只会拖控件,还理解锁和事务。
写完所有代码以后,建议你做一次“并发验证”再收工。具体操作很简单:把程序复制到两台机器上,或者在一台机器上开两个实例,查同一趟只剩1张票的车次,然后同时点击订票按钮。正确的结果应该是一个成功一个失败,失败方看到“余票不足”的提示,数据库里余票为0。这个测试通过,说明你的火车订票系统最核心的硬骨头已经啃下来了。
我自己的习惯是再补一个日志方法,把异常记录到本地文本文件,哪怕只是简单的File.AppendAllText,也足够你后续排查问题。说句真心话,火车订票系统做得好不好,不看界面多华丽,看的是你面对并发、脏数据、SQL注入这些问题时的处理方式。把这些基本功练扎实,你以后做任何管理系统都会少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取