简介:C#火车订票系统是一份面向C#学习者的完整实战项目,覆盖窗体应用设计、SQL Server数据库操作及用户/车次/订单三大模块。资源包含128个文件,以cs源码、resx界面资源、resources资源文件为主,另含exe可执行程序、jpg界面预览图和项目配置文件,压缩包仅1.85MB,便于快速下载与部署。已有1437人学习下载,适合作为课程设计或毕业设计的参考。通过这份资料,学习者可拿到完整项目源码、窗体设计器代码、数据库交互逻辑及运行示例,对照理解C#事件驱动编程、SQL语句编写、事务处理与UI布局等关键知识点,也可直接在此基础上扩展改签、余票查询等业务功能,是一份实用且轻量的练手资源。
1. C#火车订票系统:不是纯增删改查,而是把余票和订单串成一个闭环
火车订票系统常被当成C#课程的增删改查练习,但大多数实现只做了登录和下单,没有把余票扣减和并发控制当回事。这套基于C#与SQL Server的火车订票系统,把用户、车次、余票、订单和退票串成完整业务闭环,适合刚学完ADO.NET的入门者,也适合课程设计直接复用。它真正值钱的地方在于:订票不是insert一条订单那么简单,余票必须随订单一起原子变化;想通这一点,你就掌握了一类业务系统的核心。下面按拆项目顺序讲清设计、关键代码和容易翻车的坑。
2. 系统拆解与数据库设计:先把订票状态机想明白
2.1 模块划分:登录、车次、订单各管什么
这套源码在拆包时能明显看出边界:用户模块负责注册和登录;车次模块维护线路、日期、票价和余票;订单模块处理订票、退票和订单查询。这个划分比按窗体划分更抗改,因为后两种需求往往落在数据层而不是界面层。模块之间的依赖方向是界面 -> 业务 -> 数据,单向引用,界面层不写SQL。
很多新手习惯把所有逻辑堆进窗体的Click事件里,结果改一个查询条件就会牵动全局,测试时经常这里改好那里崩。我一般会让窗体只调用一个业务类方法,返回DataTable或自定义实体,保持可替换。这套系统的BLL层是整个核心,所有事务都在这里开启,而不是分散在各个窗体里。
2.2 SQL Server 表结构和关键约束
数据库设计是整个系统能跑起来的前提。这里给出整理后的核心表结构,跟源码里基本一致。先看用户表和车次表:
CREATE TABLE [dbo].[UserInfo]( UserId INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(30) NOT NULL UNIQUE, PasswordHash NVARCHAR(64) NOT NULL, RealName NVARCHAR(30) NOT NULL, IdNumber NVARCHAR(18) NOT NULL ); CREATE TABLE [dbo].[TrainInfo]( TrainId INT IDENTITY(1,1) PRIMARY KEY, TrainNo NVARCHAR(10) NOT NULL, StartCity NVARCHAR(30) NOT NULL, EndCity NVARCHAR(30) NOT NULL, StartTime TIME NOT NULL, EndTime TIME NOT NULL, HardSeatPrice DECIMAL(10,2) NOT NULL, HardSleeperPrice DECIMAL(10,2) NOT NULL, SoftSleeperPrice DECIMAL(10,2) NOT NULL );为什么把票价放在TrainInfo而不是每日排班里?因为同一趟线路的票价通常不随日期变,每天真正变化的是余票。再建一个TrainSchedule用来存某车次在某天的开行安排和余票:
CREATE TABLE [dbo].[TrainSchedule]( ScheduleId INT IDENTITY(1,1) PRIMARY KEY, TrainId INT NOT NULL, RunDate DATE NOT NULL, HardSeatRemain INT NOT NULL, HardSleeperRemain INT NOT NULL, SoftSleeperRemain INT NOT NULL, CONSTRAINT FK_Schedule_Train FOREIGN KEY(TrainId) REFERENCES TrainInfo(TrainId), CONSTRAINT UQ_Train_RunDate UNIQUE(TrainId, RunDate) );这个UNIQUE约束很关键,它防止同一车次同一天被插入多行排班,也是后面做条件更新并发扣票的前提。注意,余票总量在初始化时写入,扣票时直接在Remain字段上做递减,不允许出现负数。订单表需要保留下单时价格的快照,避免以后调价影响历史订单:
CREATE TABLE [dbo].[OrderInfo]( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(20) NOT NULL UNIQUE, UserId INT NOT NULL, ScheduleId INT NOT NULL, SeatType INT NOT NULL, -- 1硬座 2硬卧 3软卧 TicketCount INT NOT NULL, UnitPrice DECIMAL(10,2) NOT NULL, TotalPrice DECIMAL(10,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0有效 1已退票 CreateTime DATETIME NOT NULL DEFAULT GETDATE() );订单表里的SeatType和UnitPrice冗余了车次表和排班表的数据,这是刻意为之:订单是流水,不能跟着基础数据变来变去。对时间字段,我用TIME和DATE类型,避免在C#里和字符串反复比较。User表里的PasswordHash字段存SHA256哈希,不存明文。有些网上源码喜欢直接存“123456”,考试演示看不出问题,上线两周就会被撞库打穿。
2.3 连接方式:连接字符串与Data Access层
连接字符串建议读取App.config,而不是硬编码在类里。换环境时只需改一行配置,不用重新编译:
<connectionStrings> <add name="TrainDb" connectionString="Server=.;Database=TrainBooking;User Id=sa;Password=你的密码;" providerName="System.Data.SqlClient"/> </connectionStrings>对应的访问代码用using块管理资源:
var connString = ConfigurationManager.ConnectionStrings["TrainDb"].ConnectionString; using (var conn = new SqlConnection(connString)) { conn.Open(); using (var cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@p", value); // 执行逻辑 } }逻辑说明:using块保证连接和命令在异常时也会被Dispose,不会漏关连接。关键在于所有外部输入都必须走Parameters,而不是拼进字符串。参数名用@开头,占位符顺序不重要,但类型需要和列类型匹配,遇到DATE类型时传入的DateTime最好先取Date属性。如果你用的是较新的.NET环境,可以把System.Data.SqlClient换成Microsoft.Data.SqlClient,连接字符串保持不变。
数据库地基到这里已经够了。接下来是订票事务,余票和订单必须在一个事务里完成,脱离这一点,后面所有界面都是空壳。
3. 核心订票流程实现:事务、条件更新与并发余票
3.1 查询车次与余票的SQL写法
订票页面的第一步是查车次。常见错误写法是把TextBox的值直接拼进SQL,测试环境永远不报错,真正遇到恶意输入就翻车。改成参数化后,查询方法长这样:
public DataTable SearchTrain(string startCity, string endCity, DateTime runDate) { string sql = @" SELECT s.ScheduleId, t.TrainNo, t.StartCity, t.EndCity, t.StartTime, t.EndTime, s.RunDate, s.HardSeatRemain, s.HardSleeperRemain, s.SoftSleeperRemain, t.HardSeatPrice, t.HardSleeperPrice, t.SoftSleeperPrice FROM TrainSchedule s INNER JOIN TrainInfo t ON s.TrainId = t.TrainId WHERE t.StartCity = @start AND t.EndCity = @end AND s.RunDate >= @date0 AND s.RunDate < @date1 ORDER BY s.RunDate, t.StartTime"; using (var conn = new SqlConnection(_connString)) using (var cmd = new SqlCommand(sql, conn)) { DateTime nextDay = runDate.AddDays(1); cmd.Parameters.AddWithValue("@start", startCity); cmd.Parameters.AddWithValue("@end", endCity); cmd.Parameters.AddWithValue("@date0", runDate.Date); cmd.Parameters.AddWithValue("@date1", nextDay.Date); var dt = new DataTable(); conn.Open(); new SqlDataAdapter(cmd).Fill(dt); return dt; } }逻辑说明:这里的RunDate >= @date0 AND RunDate < @date1是日期范围查询的标准写法。为什么不写等值?因为如果RunDate列被存成了带时间的datetime,直接等值会漏数据;就算当前是DATE类型,范围写法也不会破坏索引使用。参数从哪来?出发地、到达地从文本框来,runDate从DateTimePicker来。调用前对runDate做一次runDate.Date,去掉用户可能选到的时间分量。界面层防一次,SQL层再防一次,双保险。
3.2 订票核心:事务内更新余票与生成订单
订票功能要同时完成三件事:扣减余票、写入订单、返回订单号。如果分开做,第一件事成功后第二件事崩了,余票和订单就永远对不上,所以必须在一个SqlTransaction里完成。更关键的是扣减余票要写成条件更新,而不是先读后写:
public string BookTicket(int scheduleId, int seatType, int ticketCount, int userId) { string orderNo = BuildOrderNo(); string updateSql; if (seatType == 1) // 硬座 updateSql = "UPDATE TrainSchedule SET HardSeatRemain = HardSeatRemain - @cnt " + "WHERE ScheduleId = @sid AND HardSeatRemain >= @cnt"; else if (seatType == 2) updateSql = "UPDATE TrainSchedule SET HardSleeperRemain = HardSleeperRemain - @cnt " + "WHERE ScheduleId = @sid AND HardSleeperRemain >= @cnt"; else updateSql = "UPDATE TrainSchedule SET SoftSleeperRemain = SoftSleeperRemain - @cnt " + "WHERE ScheduleId = @sid AND SoftSleeperRemain >= @cnt"; using (var conn = new SqlConnection(_connString)) { conn.Open(); using (var tran = conn.BeginTransaction()) using (var cmd = new SqlCommand(updateSql, conn, tran)) { cmd.Parameters.AddWithValue("@cnt", ticketCount); cmd.Parameters.AddWithValue("@sid", scheduleId); int affected = cmd.ExecuteNonQuery(); if (affected == 0) { tran.Rollback(); throw new InvalidOperationException("余票不足或车次已经取消"); } cmd.CommandText = @" INSERT INTO OrderInfo(OrderNo, UserId, ScheduleId, SeatType, TicketCount, UnitPrice, TotalPrice, Status) VALUES(@orderNo, @uid, @sid, @seatType, @cnt, @price, @total, 0)"; int seatPrice = GetUnitPrice(scheduleId, seatType); cmd.Parameters.AddWithValue("@orderNo", orderNo); cmd.Parameters.AddWithValue("@uid", userId); cmd.Parameters.AddWithValue("@seatType", seatType); cmd.Parameters.AddWithValue("@price", seatPrice); cmd.Parameters.AddWithValue("@total", seatPrice * ticketCount); cmd.ExecuteNonQuery(); tran.Commit(); return orderNo; } } }逻辑说明:这个UPDATE语句里的AND HardSeatRemain >= @cnt就是余票充足的硬性校验,它和扣减是原子操作。两个用户同时订最后两票,第一个更新把余票压到0,第二个执行时才根据当前行状态判断,更新0行,于是走Rollback抛异常,不会出现超卖。这里不需要把隔离级别调到Serializable,因为UPDATE本身持有排它锁直到事务结束,条件更新已经足够防超卖。事务里有多个命令,必须共用同一个SqlTransaction,千万不要在每个操作里重新conn.BeginTransaction()。
3.3 退票和改签:状态回滚的边界
退票的核心不是删除订单,而是把订单状态改成已退票,再把余票加回去。删除订单会让统计和对账变成黑匣子,这条血泪经验在工程里尤为重要。退票核心逻辑如下:
string sql = @" UPDATE OrderInfo SET Status = 1 WHERE OrderNo = @orderNo AND Status = 0; UPDATE TrainSchedule SET HardSeatRemain = HardSeatRemain + @cnt WHERE ScheduleId = (SELECT ScheduleId FROM OrderInfo WHERE OrderNo = @orderNo) AND SeatType = @seatType; ";先标记订单已退,再恢复余票,这两个语句要包在同一个事务里。我一般会先查订单,拿到ScheduleId、SeatType、TicketCount,再执行UPDATE,顺序可控。改签比退票复杂,因为涉及两张表联动,源码采用先退旧座再订新座的方式,但中间不能存在任何提交点;简单做法是把退票和订票两个业务方法放进同一个更大范围的事务,或者用TransactionScope统一管理,否则一旦第二段失败,用户两边都没有票。如果你接手的是老代码,注意检查退票时是否误用了DELETE,一旦删除订单,后续对账会非常痛苦。
后台业务闭环成立之后,下一步是把这些方法接到窗体上,让界面和数据库真正联动。
4. 客户端界面与交互:从WinForms到数据绑定
4.1 窗体结构和数据绑定
这套源码采用三层式窗体布局:登录窗体、主窗体、订票窗体、订单窗体。登录窗体验证用户后把UserId放进主窗体的公共属性里,后续所有下单都通过这个UserId来定位归属。主窗体左侧是查询条件区,中间是车次列表DataGridView,右侧是选中车次的详情和订票按钮。这种布局的好处是操作路径直线:查车次、选日期、双击行、点订票。
DataGridView绑定数据时,建议手动声明列,不要依赖自动生成列:
dataGridView1.AutoGenerateColumns = false; dataGridView1.Columns.Clear(); dataGridView1.Columns.Add("TrainNo", "车次"); dataGridView1.Columns.Add("StartCity", "出发"); dataGridView1.Columns.Add("EndCity", "到达"); dataGridView1.Columns.Add("RunDate", "日期"); dataGridView1.Columns.Add("HardSeatRemain", "硬座余票"); dataGridView1.DataSource = _bll.SearchTrain(start, end, date);为什么不用AutoGenerateColumns=true?自动生成的列会把ScheduleId、价格这些内部字段暴露给用户,而且英文表头很难看。手动声明列后,列名必须和DataTable列名严格对应。如果你不想手动声明列,也可以在SELECT查询里把列名用AS重命名为中文,但代码改动要同步,容易出现列名对不上的尴尬。
4.2 车次筛选与座位选择的交互
出发站和到达站建议用ComboBox下拉,数据来自SELECT DISTINCT StartCity FROM TrainInfo,减少输入不一致导致查不到。日期用DateTimePicker,设置Format=Short且MinDate=今天,避免用户选历史日期后查出来全是旧数据。选完日期后点查询,DataGridView里会出现符合条件的车次。选中某一行后,切换到“订票”窗体会读取当前行的ScheduleId,这时有一个高发坑:DataGridView的Cells["ScheduleId"]取出来是null,因为ScheduleId没有被加入可见列。
解决办法是把ScheduleId做成隐藏列,或者存在行对象的Tag里。下面这段代码展示选中车次后如何刷新余票和座位类型:
private void dgvTrains_SelectionChanged(object sender, EventArgs e) { if (dataGridView1.SelectedRows.Count == 0) return; int scheduleId = Convert.ToInt32(dataGridView1.SelectedRows[0].Cells["ScheduleId"].Value); txtRunDate.Text = Convert.ToDateTime(dataGridView1.SelectedRows[0].Cells["RunDate"].Value).ToString("yyyy-MM-dd"); var remain = _bll.GetRemainBySchedule(scheduleId); cmbSeat.Items.Clear(); if (remain.HardSeatRemain > 0) cmbSeat.Items.Add($"硬座(剩{remain.HardSeatRemain})"); if (remain.HardSleeperRemain > 0) cmbSeat.Items.Add($"硬卧(剩{remain.HardSleeperRemain})"); if (remain.SoftSleeperRemain > 0) cmbSeat.Items.Add($"软卧(剩{remain.SoftSleeperRemain})"); }逻辑说明:SelectionChanged事件里先检查SelectedRows.Count,否则空表或刚加载时会空引用。座位类型在订单表里存的是int,界面展示用字符串,中间需要一个映射。我习惯把int类型直接放进ComboBox项的Tag里,SelectedIndexChanged时从Tag读数字,避免自己维护字典。
4.3 订单列表与异步刷新
订单窗体展示当前用户的历史订单,数据来自SELECT o.OrderNo, o.CreateTime, o.SeatType, o.TicketCount, o.TotalPrice, s.RunDate, t.TrainNo, t.StartCity, t.EndCity FROM OrderInfo o JOIN TrainSchedule s ON o.ScheduleId=s.ScheduleId JOIN TrainInfo t ON s.TrainId=t.TrainId WHERE o.UserId=@uid ORDER BY o.CreateTime DESC。状态字段不要直接把0/1显示给用户,建议在SELECT里用CASE WHEN Status=0 THEN N'有效' ELSE N'已退票' END AS StatusText,这样界面不用二次转换。
界面假死是这类系统的通病。查询按钮点击后,如果直接在UI线程里打开数据库,车次数据量大时窗体就会白屏几秒,用户会以为程序死了。解决思路是异步加载,WinForms里最省事的写法:
private async void btnSearch_Click(object sender, EventArgs e) { btnSearch.Enabled = false; try { var start = txtStart.Text.Trim(); var end = txtEnd.Text.Trim(); var day = dtpDate.Value; var dt = await Task.Run(() => _bll.SearchTrain(start, end, day)); dataGridView1.DataSource = dt; } catch (Exception ex) { MessageBox.Show("查询失败:" + ex.Message); } finally { btnSearch.Enabled = true; } }逻辑说明:await Task.Run让数据库查询在线程池执行,返回后自动回到UI线程更新DataGridView。按钮禁用可以防止用户重复点击造成多个请求并发,这是最简单的防抖。注意不要在Task.Run内访问任何UI控件,要把TextBox值提取成局部变量再传进去,否则会抛出“线程间操作无效”的异常。数据绑定完成后启用按钮,界面体验比白屏强很多。
界面这一层理顺后,系统能正常演示了。但真要交工程验收,数据层那几个雷不提前排掉,很容易被测试同学一触发就当场翻车。
5. 避坑记录:火车订票系统最容易踩的五个雷
系统跑通后,验收前有五个坑值得提前排查。先看一张速查表:
| 坑位 | 现象 | 根因 | 解决 |
|---|---|---|---|
| 日期匹配 | 当天凌晨车次查不出来 | datetime等值不准 | 左闭右开区间 |
| 并发超卖 | 余票变负数,订单多一条 | 先查后改无原子性 | 条件UPDATE |
| SQL注入 | 万能密码登录成功 | 直接拼接用户输入 | 参数化命令 |
| 界面假死 | 查车次时整窗白屏 | UI线程阻塞 | async/await |
| 重复下单 | 快速点击生成两笔订单 | 无按钮防抖 | 状态锁+禁用 |
5.1 数据层:日期边界、并发超卖、SQL注入
第一条坑:日期用字符串等值比较。
现象:界面选11月1日,查询结果却少了当天早上的几个车次;看SQL监控发现条件变成了RunDate='2025/11/01 00:00:00',而排班表里存的是2025/11/01 09:00,等值永远匹配不上。
原因:DateTimePicker的值带时间分量,或者查询时用了RunDate = @date,而RunDate在接入旧库时被设计成datetime类型。
解决:统一用左闭右开区间。在C#层先算好DateTime start = date.Date; DateTime end = start.AddDays(1);,SQL里写RunDate >= @start AND RunDate < @end。从那以后我再也不写日期等值匹配。
第二条坑:并发下单造成余票负数。
现象:两个测试账号同时给最后一个座位下单,两张订单都创建成功,余票变成-1。清空数据库后重试,有时能复现,有时正常,于是被当成偶发玄学。
原因:代码逻辑是先查余票if (remain > 0),再执行UPDATE扣减,两步之间没有原子性;或者UPDATE语句里没带余票>=要求数量条件,两个请求同时进入扣减,最后一张票被卖两次。
解决:把扣票写成单条条件更新,用受影响行数判断是否成功。核心就是UPDATE ... SET Remain = Remain - @cnt WHERE ScheduleId=@sid AND Remain >= @cnt,返回0行就Rollback。如果需要更强控制,可以给查询加WITH (UPDLOCK, ROWLOCK)锁提示。测试时可以开两个线程同时调用BookTicket,验证是否只生成一张成功订单。
第三条坑:SQL注入。
现象:登录页输入' OR 1=1 --,密码随便填,居然登录成功并看到所有用户订单。这个雷在课程设计里无人关注,部署出去就会被扫描工具扫穿。
原因:程序把用户输入直接拼进SQL。登录查询如果拼了WHERE UserName='" + txtUser.Text + "',单引号闭合后,条件永远为真。
解决:全部SQL改成参数化,包括查询和写入。参数化后用户输入即使包含恶意片段,也只是字符串值,不会改变SQL结构。另一个容易被忽略的细节是ORDER BY字段不能参数化,需要做白名单校验,只允许列表里存在的排序列名,而不是接受任意字符串。审查这套源码时要注意所有SqlCommand,一旦发现拼接点都要改掉。
5.2 界面层:假死与重复下单
第四条坑:点击登录后无响应,几秒后突然跳转。
现象:部署到测试环境后,点击登录按钮窗体整个卡住,连关闭都无响应,过一会才弹出主窗体。数据量只有几万行,不该这么慢。
原因:登录时做了用户校验和订单统计,多条SQL在UI线程里同步执行,数据库往返耗时达到几秒,UI消息循环阻塞。
解决:参照第4章的async/await写法,把耗时查询放到Task.Run里,等待期间禁用按钮并显示“登录中”。注意,await返回后会自动切回UI线程,不要自己调用Invoke,除非是在维护老代码。这里也可以用BackgroundWorker,但新代码建议直接async。
第五条坑:双击订票按钮,生成两笔重复订单。
现象:连续快速点击“确认订票”按钮,页面提示成功后,订单列表出现两条相同订单。
原因:按钮没有做防抖,也没有事务锁。WinForms本身是单线程,但双击会在两次Click事件中触发两次下单,时间差只有几十毫秒,数据库连接池给了不同连接,每个连接都执行了完整事务,于是产生两笔订单。
解决:最直接的做法是点击后立刻btn.Enabled = false,业务完成后再恢复。更稳的做法是加一个业务状态锁:
private bool _booking; private void btnBook_Click(object sender, EventArgs e) { if (_booking) return; _booking = true; try { _bll.BookTicket(scheduleId, seatType, count, userId); MessageBox.Show("订票成功"); } finally { _booking = false; } }这样即使双击,第二个事件也会因为_booking为true直接返回。对于Web项目,还需要服务端令牌做防重复提交,但桌面程序用这个模板就够了。
这五个雷排完,系统基本稳定。接下来一个容易被忽略的收尾工作:订单号、备份和发布前检查,做不好会让人觉得这项目“能用但糙”。
6. 让项目往上走一步:订单号生成、防重复提交与数据备份
订票流程能完整跑通后,建议补几个工程化细节,价值立刻不一样。首先是订单号生成。自增主键不适合直接展示,而纯随机数容易撞,我习惯用D+日期时间+随机后缀,比如D20260611143025A7K3。代码可以这么写:
string BuildOrderNo() { var now = DateTime.Now; string ts = now.ToString("yyyyMMddHHmmss"); const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; var rnd = new Random(); string suffix = new string(Enumerable.Range(0, 4).Select(_ => chars[rnd.Next(chars.Length)]).ToArray()); return $"D{ts}{suffix}"; }这段逻辑说明:时间到秒可读性强,随机后缀在毫秒级并发下可能会撞,但OrderNo列上有UNIQUE约束,撞了会抛唯一键冲突,回滚当前事务后重新生成一次即可。rnd用默认种子在高频并发下可能重复,但因为有数据库约束兜底,桌面单机场景够用。
其次是数据备份恢复。课程设计和项目演示前,把数据库恢复到干净状态非常重要。我习惯准备两个SQL模板:一个备份,一个还原,演示前先执行还原,数据回到可控状态:
BACKUP DATABASE TrainBooking TO DISK = 'D:\bak\TrainBooking.bak' WITH INIT; RESTORE DATABASE TrainBooking FROM DISK = 'D:\bak\TrainBooking.bak' WITH REPLACE;演示前做一次恢复,余票数量、用户密码、测试订单都回到初始值,不会出现测试数据污染正式环境的尴尬。注意还原会断开现有连接,最好在无人使用的时间段执行。
最后是发布前检查。我每次交付项目都会走一遍固定检查项:所有SqlCommand是否参数化,连接字符串是否指向配置文件,密码字段是否做了哈希,退票是否只是改状态而不是删除。这四件事听起来基础,但很多源码包就是在这里翻车。我从第一套C#项目开始就强制自己执行这套检查,从那以后每次演示都多一分底气,少一分临时救火的狼狈。这个资源包里的源码和SQL脚本已经按这些习惯处理好了,希望帮到你。
本文还有配套的精品资源,点击获取