简介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 nameTrainDb connectionStringServer.;DatabaseTrainBooking;User Idsa;Password你的密码; providerNameSystem.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);为什么不用AutoGenerateColumnstrue自动生成的列会把ScheduleId、价格这些内部字段暴露给用户而且英文表头很难看。手动声明列后列名必须和DataTable列名严格对应。如果你不想手动声明列也可以在SELECT查询里把列名用AS重命名为中文但代码改动要同步容易出现列名对不上的尴尬。4.2 车次筛选与座位选择的交互出发站和到达站建议用ComboBox下拉数据来自SELECT DISTINCT StartCity FROM TrainInfo减少输入不一致导致查不到。日期用DateTimePicker设置FormatShort且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.ScheduleIds.ScheduleId JOIN TrainInfo t ON s.TrainIdt.TrainId WHERE o.UserIduid ORDER BY o.CreateTime DESC。状态字段不要直接把0/1显示给用户建议在SELECT里用CASE WHEN Status0 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等值不准左闭右开区间并发超卖余票变负数订单多一条先查后改无原子性条件UPDATESQL注入万能密码登录成功直接拼接用户输入参数化命令界面假死查车次时整窗白屏UI线程阻塞async/await重复下单快速点击生成两笔订单无按钮防抖状态锁禁用5.1 数据层日期边界、并发超卖、SQL注入第一条坑日期用字符串等值比较。现象界面选11月1日查询结果却少了当天早上的几个车次看SQL监控发现条件变成了RunDate2025/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 ScheduleIdsid AND Remain cnt返回0行就Rollback。如果需要更强控制可以给查询加WITH (UPDLOCK, ROWLOCK)锁提示。测试时可以开两个线程同时调用BookTicket验证是否只生成一张成功订单。第三条坑SQL注入。现象登录页输入 OR 11 --密码随便填居然登录成功并看到所有用户订单。这个雷在课程设计里无人关注部署出去就会被扫描工具扫穿。原因程序把用户输入直接拼进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脚本已经按这些习惯处理好了希望帮到你。本文还有配套的精品资源点击获取