简介这份资源是面向C#初学者与.NET课程实践者的火车订票系统完整源码工程以窗体应用形式呈现可用于练习GUI交互设计、事件驱动编程与数据库增删改查帮助读者把课堂语法落到一个可运行的小型业务系统上。压缩包共128个文件约1.85MB以50个cs源文件与24个resx、24个resources资源文件为主体配合sln解决方案、csproj项目文件及少量jpg界面素材构成可直接在Visual Studio中打开的工程结构。已有1437人学习下载说明其在同类练手项目中具备一定参考价值。源码围绕用户注册登录、车次增删改查与订票退票改签等模块展开涉及SQL Server数据存储、事务与并发控制、异常捕获及SQL注入防范等要点读者可据此梳理窗体间跳转、业务状态流转与数据库访问层的组织方式并对照自身代码查漏补缺。1. 从一张手写车票说起这个 C# 火车订票系统到底能干什么上周帮一个做毕业设计的同学看代码他打开一个 WinForms 窗口左边是车次列表右边是座位图点一下「预订」按钮数据库里就多一条订单记录。我问他这玩意儿能跑吗他说能跑但一到并发就翻车——两个人同时抢最后一张票系统双双提示成功。这就是典型的「能演示、不能上线」的订票系统。这份 C# 火车订票系统源码核心价值不在于界面多漂亮而在于它把车次查询、余票扣减、订单生成、退票回滚这条链路完整串了起来用的是 C# WinForms SQL Server 这套老派但极稳的组合。适合谁适合正在做课程设计、想找一个能跑通「查询—下单—扣库存」闭环的 C# 练手项目的开发者也适合想看看桌面端怎么用事务和存储过程处理并发扣减的熟手。下面我按「先跑起来、再看结构、最后动手改」的顺序把这个项目拆开讲。2. 环境准备与首次运行从零把项目跑起来2.1 技术栈与运行环境确认这个项目是典型的 .NET Framework 桌面应用不是 .NET Core 那套跨平台的东西。动手之前先把环境对齐否则后面报错会让人怀疑人生。组件版本要求说明Visual Studio2019 或 2022需勾选「.NET 桌面开发」工作负载.NET Framework4.7.2 及以上项目文件里的 TargetFrameworkVersion 决定SQL Server2016 及以上 / Express 版即可本地实例默认端口 1433SQL Server Management Studio任意较新版本用来执行建库脚本常见做法是直接用 VS 打开解决方案文件.sln如果提示「目标框架不兼容」右键项目 → 属性 → 目标框架改成你机器上装了的版本。SQL Server Express 的实例名通常是.\SQLEXPRESS连接字符串里要写对这一点后面会细说。2.2 数据库初始化建库、建表、灌初始数据项目根目录下一般会有一个Database或SQL文件夹里面放着建库脚本。我一般按这个顺序执行先跑建库建表脚本再跑初始数据脚本最后跑存储过程脚本。-- 建库如果脚本里没有 CREATE DATABASE TrainTicketDB; GO USE TrainTicketDB; GO -- 车次表一条记录代表一趟车 CREATE TABLE TrainInfo ( TrainId INT IDENTITY(1,1) PRIMARY KEY, TrainNo VARCHAR(20) NOT NULL, -- 车次号如 G1234 StartStation VARCHAR(50) NOT NULL, -- 始发站 EndStation VARCHAR(50) NOT NULL, -- 终点站 DepartTime DATETIME NOT NULL, -- 发车时间 ArriveTime DATETIME NOT NULL, -- 到达时间 TotalSeats INT NOT NULL -- 总座位数 ); -- 余票表按车次日期记录剩余座位扣减就改这里 CREATE TABLE SeatInventory ( InventoryId INT IDENTITY(1,1) PRIMARY KEY, TrainId INT NOT NULL, TravelDate DATE NOT NULL, RemainSeats INT NOT NULL, CONSTRAINT FK_Seat_Train FOREIGN KEY (TrainId) REFERENCES TrainInfo(TrainId) ); -- 订单表 CREATE TABLE TicketOrder ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL UNIQUE, -- 业务订单号 TrainId INT NOT NULL, TravelDate DATE NOT NULL, PassengerName VARCHAR(50) NOT NULL, IdCard VARCHAR(20) NOT NULL, SeatNo VARCHAR(10) NULL, OrderStatus TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已出票 2已退票 CreateTime DATETIME NOT NULL DEFAULT GETDATE() );这段脚本的关键在于把「车次信息」和「余票」拆成两张表。很多新手项目图省事直接在车次表里放一个RemainSeats字段结果同一趟车不同日期没法区分余票一改就全乱。拆表之后SeatInventory用TrainId TravelDate定位一条库存记录扣减时锁的粒度更细并发冲突也小。TicketOrder里的OrderStatus用 TINYINT 而不是字符串是为了索引效率和状态机判断方便0/1/2 三个状态对应待支付、已出票、已退票后面退票逻辑就靠它做幂等判断。2.3 连接字符串配置与首次启动数据库建好后打开项目里的App.config找到connectionStrings节点。这是最容易翻车的地方我见过太多人卡在这里。connectionStrings add nameTrainDB connectionStringData Source.\SQLEXPRESS;Initial CatalogTrainTicketDB;Integrated SecurityTrue; providerNameSystem.Data.SqlClient / /connectionStringsData Source写你的实例名本地默认实例可以写.或(local)Express 版写.\SQLEXPRESS。Integrated SecurityTrue表示用 Windows 身份验证省去账号密码如果你的 SQL Server 只开了 SQL 验证就改成User Idsa;Password你的密码;。改完编译运行主窗体应该能加载出车次列表。如果列表是空的先确认初始数据脚本跑没跑再确认连接字符串指向的库名对不对——这两个点排查完九成启动问题都能解决。3. 核心模块拆解一条订单从查询到落库的完整链路3.1 数据访问层为什么用存储过程而不是拼 SQL这个项目的数据访问没有用 Entity Framework而是手写 ADO.NET核心扣减逻辑放在存储过程里。很多人觉得这是「老古董」但在订票这种对一致性要求高的场景存储过程反而更可控。// DbHelper.cs 片段统一的连接与命令封装 public static class DbHelper { private static readonly string ConnStr ConfigurationManager.ConnectionStrings[TrainDB].ConnectionString; // 执行存储过程返回受影响行数 public static int ExecuteProc(string procName, params SqlParameter[] paras) { using (var conn new SqlConnection(ConnStr)) using (var cmd new SqlCommand(procName, conn)) { cmd.CommandType CommandType.StoredProcedure; if (paras ! null paras.Length 0) cmd.Parameters.AddRange(paras); conn.Open(); return cmd.ExecuteNonQuery(); } } // 查询并填充 DataTable供界面绑定 public static DataTable QueryProc(string procName, params SqlParameter[] paras) { using (var conn new SqlConnection(ConnStr)) using (var cmd new SqlCommand(procName, conn)) using (var adapter new SqlDataAdapter(cmd)) { cmd.CommandType CommandType.StoredProcedure; if (paras ! null paras.Length 0) cmd.Parameters.AddRange(paras); var dt new DataTable(); adapter.Fill(dt); return dt; } } }DbHelper把连接创建、命令类型设置、参数绑定统一收口业务层只关心「调哪个存储过程、传什么参数」。using块保证连接和命令对象及时释放这是 ADO.NET 的基本功漏了会在高并发下把连接池耗干。QueryProc返回DataTable直接绑到DataGridView省去实体映射的样板代码对课程设计这种规模刚好够用。3.2 余票扣减存储过程并发下的最后一道闸真正决定这个系统能不能扛住「抢票」的是下面这个存储过程。它把「查余票」和「扣余票」放在同一个事务里并用UPDLOCK行锁防止两个人同时读到同一张票。CREATE PROCEDURE sp_BookTicket TrainId INT, TravelDate DATE, Passenger VARCHAR(50), IdCard VARCHAR(20), OrderNo VARCHAR(32) OUTPUT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; -- 关键UPDLOCK 锁住目标库存行防止并发超卖 DECLARE Remain INT; SELECT Remain RemainSeats FROM SeatInventory WITH (UPDLOCK, ROWLOCK) WHERE TrainId TrainId AND TravelDate TravelDate; IF Remain IS NULL OR Remain 0 BEGIN ROLLBACK TRANSACTION; RAISERROR(余票不足, 16, 1); RETURN; END -- 扣减余票 UPDATE SeatInventory SET RemainSeats RemainSeats - 1 WHERE TrainId TrainId AND TravelDate TravelDate; -- 生成订单号并插入订单 SET OrderNo T CONVERT(VARCHAR(8), GETDATE(), 112) RIGHT(000000 CAST(ABS(CHECKSUM(NEWID())) % 1000000 AS VARCHAR), 6); INSERT INTO TicketOrder (OrderNo, TrainId, TravelDate, PassengerName, IdCard, OrderStatus) VALUES (OrderNo, TrainId, TravelDate, Passenger, IdCard, 0); COMMIT TRANSACTION; END TRY BEGIN CATCH IF TRANCOUNT 0 ROLLBACK TRANSACTION; THROW; END CATCH END逐段看WITH (UPDLOCK, ROWLOCK)是这段代码的灵魂它在读取库存行时就加上更新锁别的事务想读同一行会被阻塞直到本事务提交或回滚从而杜绝「两个事务都读到余票为 1、都扣成功」的超卖。RAISERROR抛出业务异常C# 端用SqlException捕获后提示用户。订单号用日期加随机数拼接CHECKSUM(NEWID())保证同一秒内也不重复。整个TRY...CATCH包住事务任何一步失败都回滚不会出现「票扣了订单没生成」的脏数据。这套写法不花哨但把一致性守住了比在 C# 层用lock关键字靠谱得多——进程内锁管不了多客户端。3.3 界面层与业务层WinForms 事件如何串起整条链路界面层用DataGridView展示车次用户选中一行、填好乘客信息、点「预订」事件处理器调用业务层业务层再调存储过程。// BookingForm.cs 片段预订按钮点击 private void btnBook_Click(object sender, EventArgs e) { if (dgvTrains.CurrentRow null) { MessageBox.Show(请先选择车次); return; } int trainId Convert.ToInt32(dgvTrains.CurrentRow.Cells[TrainId].Value); DateTime travelDate dtpTravelDate.Value.Date; string passenger txtPassenger.Text.Trim(); string idCard txtIdCard.Text.Trim(); if (string.IsNullOrEmpty(passenger) || string.IsNullOrEmpty(idCard)) { MessageBox.Show(乘客信息不能为空); return; } var pOrderNo new SqlParameter(OrderNo, SqlDbType.VarChar, 32) { Direction ParameterDirection.Output }; try { DbHelper.ExecuteProc(sp_BookTicket, new SqlParameter(TrainId, trainId), new SqlParameter(TravelDate, travelDate), new SqlParameter(Passenger, passenger), new SqlParameter(IdCard, idCard), pOrderNo); MessageBox.Show($预订成功订单号{pOrderNo.Value}); LoadTrains(); // 刷新余票显示 } catch (SqlException ex) { MessageBox.Show(预订失败 ex.Message); } }这里有几个细节值得说。OrderNo声明为Output方向存储过程里SET OrderNo之后C# 端通过pOrderNo.Value拿到订单号回显给用户避免再查一次库。travelDate用.Date去掉时间部分保证和库存表的DATE类型对齐不然2024-01-01 10:00和2024-01-01匹配不上查不到库存行。catch (SqlException)专门捕获数据库异常把存储过程里RAISERROR的「余票不足」原样弹给用户比笼统的「操作失败」友好得多。预订成功后调LoadTrains()刷新列表用户能立刻看到余票减一交互闭环就完整了。4. 项目结构梳理目录怎么分、类怎么归位4.1 典型目录布局与职责划分拿到源码先别急着改花十分钟把目录结构摸清后面改起来才不迷路。这类 WinForms 订票项目通常是三层结构虽然不一定严格分层但职责大致能对上。TrainTicketSystem/ ├── Forms/ # 所有窗体 │ ├── MainForm.cs # 主窗体车次列表入口 │ ├── BookingForm.cs # 预订窗体 │ ├── OrderQueryForm.cs # 订单查询与退票 │ └── LoginForm.cs # 管理员登录如有 ├── DAL/ # 数据访问层 │ ├── DbHelper.cs # 连接与命令封装 │ ├── TrainDAL.cs # 车次相关查询 │ └── OrderDAL.cs # 订单相关操作 ├── BLL/ # 业务逻辑层部分项目合并进 DAL │ └── BookingService.cs # 预订、退票的业务编排 ├── Models/ # 实体类 │ ├── TrainInfo.cs │ └── TicketOrder.cs ├── Database/ # SQL 脚本 │ ├── 01_create_tables.sql │ ├── 02_init_data.sql │ └── 03_procedures.sql └── App.config # 连接字符串Forms只负责界面展示和事件响应不写 SQLDAL收口所有数据库交互Models是纯数据载体。有些小项目会把 BLL 省掉窗体直接调 DAL能跑但耦合重二次开发时改一处牵一片。如果你拿到的版本没有 BLL建议改造时先补一个薄薄的 Service 层把「扣库存 生成订单」这种跨表操作收进去后面加日志、加校验都有地方放。4.2 车次查询与订单查询的 SQL 组织方式查询类功能相对简单但 SQL 写得好不好直接影响体验。车次查询一般支持按始发站、终点站、日期过滤常见做法是用动态拼接或者存储过程带可选参数。-- 车次查询按出发站、到达站、日期过滤 SELECT t.TrainId, t.TrainNo, t.StartStation, t.EndStation, t.DepartTime, t.ArriveTime, ISNULL(s.RemainSeats, 0) AS RemainSeats FROM TrainInfo t LEFT JOIN SeatInventory s ON t.TrainId s.TrainId AND s.TravelDate TravelDate WHERE (StartStation OR t.StartStation LIKE % StartStation %) AND (EndStation OR t.EndStation LIKE % EndStation %) ORDER BY t.DepartTime;用LEFT JOIN而不是INNER JOIN是为了让没有库存记录的车次也能显示出来余票显示 0而不是直接消失。StartStation 这种写法让同一个存储过程支持「不填条件查全部」省去在 C# 里拼 SQL 字符串——拼接 SQL 是注入漏洞的重灾区能避就避。ISNULL(s.RemainSeats, 0)处理左连接产生的 NULL界面上就不会出现空白单元格。订单查询则按订单号或身份证号过滤退票操作本质是把OrderStatus从 1 改成 2同时把余票加回去这两步必须在同一个事务里否则会出现「退了票但库存没回」或者「库存回了但订单还是已出票」的不一致。改造时如果发现退票逻辑是分两条独立 SQL 执行的第一件事就是把它包进事务。5. 避坑与排查那些让我熬夜的坑5.1 连接字符串写错导致「登录失败」现象程序一启动就弹「无法打开登录所请求的数据库」或者「在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误」。原因八成是Data Source的实例名不对或者 SQL Server 的 TCP/IP 协议没启用。解决先在 SSMS 里用同样的连接信息手动连一次能连上再回来看代码Express 版确认服务SQL Server (SQLEXPRESS)在运行如果用的是命名实例连接字符串里实例名不能省。我一般会把连接字符串抽到App.config而不是硬编码在类里改的时候只改一处。5.2 余票扣成负数并发没锁住现象压测或者两个人同时操作时余票出现 -1。原因扣减逻辑写成了「先 SELECT 查余票C# 判断大于 0再 UPDATE 减一」查和改之间没有锁两个请求都通过了判断。解决把判断和扣减合并进存储过程用UPDLOCK锁行或者直接用UPDATE ... SET RemainSeats RemainSeats - 1 WHERE RemainSeats 0然后看受影响行数是否为 1。后者更简洁受影响行数为 0 就说明没票了回滚即可。这个坑我在第一个项目里踩过后来凡是涉及库存扣减一律不在应用层做「先查后改」。5.3 日期类型不匹配导致查不到库存现象明明数据库里有当天的库存记录界面上余票却显示 0。原因C# 传的DateTime带了时分秒而库存表的TravelDate是DATE类型2024-01-01 14:30:00和2024-01-01在比较时对不上。解决传参前统一.Date去掉时间部分或者存储过程里用CAST(TravelDate AS DATE)转换。这个坑很隐蔽因为不报错只是查不到数据排查时容易怀疑到数据库连接上去。5.4 DataGridView 绑定后刷新不生效现象预订成功后调了重新查询的方法但表格里的余票数字没变。原因重复DataSource dt赋值时如果AutoGenerateColumns为 true 且列已经生成过有时不会刷新显示或者查询用的还是旧的DataTable引用。解决刷新前先dgvTrains.DataSource null再赋新的DataTable或者用BindingSource包一层调ResetBindings(false)。另外确认刷新方法里真的重新执行了查询而不是复用了缓存变量。5.5 退票后库存没回滚现象用户退票成功订单状态变成已退票但余票没增加。原因退票逻辑只更新了订单表忘了同步SeatInventory或者两步操作没放在同一事务里中间失败导致只成功一半。解决退票存储过程里先校验订单状态为「已出票」再UPDATE订单状态、UPDATE库存加一整体包在事务里任何一步失败全部回滚。同时用订单状态做幂等判断防止同一订单被退两次、库存加两次。6. 二次开发改造从「能跑」到「敢用」的几个动手点6.1 把硬编码的 SQL 收进存储过程或参数化查询如果你拿到的版本里到处是SELECT * FROM TrainInfo WHERE TrainNo txtNo.Text 这种拼接第一优先级是改掉它。拼接 SQL 不只是注入风险遇到车次号里带单引号还会直接报语法错误。改法是统一走SqlParameter或者把查询收进存储过程。改造时可以先加一个SqlHelper的ExecuteQuery(string sql, params SqlParameter[] paras)重载把现有调用逐个替换改一个测一个别一次性全改完再跑出了问题不好定位。6.2 给订单状态加状态机校验现在的OrderStatus是 0/1/2 三个值但代码里可能到处直接赋值没有校验「待支付能不能直接跳已退票」。改造思路是抽一个OrderStateMachine小类定义合法迁移0→1支付出票、1→2退票其他迁移一律拒绝。public static class OrderStateMachine { private static readonly Dictionaryint, int[] Allowed new() { { 0, new[] { 1 } }, // 待支付 - 已出票 { 1, new[] { 2 } }, // 已出票 - 已退票 { 2, new int[0] } // 已退票为终态 }; public static bool CanTransit(int from, int to) Allowed.TryGetValue(from, out var targets) targets.Contains(to); }退票和支付前都先调CanTransit判断不合法就拒绝。这样即使界面按钮被误点、或者接口被重复调用状态也不会乱跳。这个改动很小但对系统健壮性的提升很明显尤其是有多个入口能改订单状态的时候。6.3 加一层简单的操作日志便于排查订票系统出问题最怕的是「用户说下单了但没订单」。改造时可以在sp_BookTicket里加一张OperationLog表记录每次预订的入参、结果、时间。不用搞得多复杂一张表几个字段就够LogId、Action、TrainId、Passenger、Result、CreateTime。存储过程成功和失败分支各插一条失败时把ERROR_MESSAGE()也记进去。后面再遇到「明明点了没反应」直接查日志就能定位是没进存储过程、还是进了但被余票不足拦下。我一般会在改造任何涉及钱和库存的系统时先把这层日志加上相当于给自己留了后悔药。6.4 验证改造是否生效的最小回归清单改完之后别只点一遍「预订成功」就完事按这个清单走一遍两个人同时抢最后一张票只能一个成功退票后余票加一且订单状态正确重复退同一张票被拒绝日期边界今天、明天查询正常连接字符串改错时给出可读的错误提示而不是直接崩溃。这几条覆盖了并发、幂等、类型、异常四个最容易出问题的面。我自己的习惯是每次动完存储过程都强制把这份清单跑一遍哪怕改动看起来只有一行——血泪经验是存储过程里少写一个COMMIT界面上的表现可能只是「偶尔卡住」不跑清单根本发现不了。希望这份拆解能帮你把这个订票系统从「能演示」推到「敢给人用」源码就在那里跑起来、改起来比读十篇原理都实在。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?