
简介C#火车订票系统是一份面向初中级.NET开发者的课程设计与毕业设计参考资源完整演示了订票、退票、车次管理和用户登录等核心业务。压缩包共128个文件体积仅1.85MB涵盖50个cs源码文件、24个resx界面资源、SQL Server数据库相关配置以及sln解决方案、csproj工程文件、exe可执行程序、jpg图片和pdb调试符号等。其中的Designer.cs与resx文件对应各窗体布局便于对照学习C#窗体应用的事件驱动与界面设计源码涉及用户模块、车次管理、订单管理及数据库交互适合用于理解SQL语句和事务处理。已有1437人浏览学习。借助这份资源可快速搭建可运行的火车订票系统梳理从数据库表设计到窗体调用的完整链路为同类管理系统开发提供可直接复用的代码与结构。1. C#火车订票系统为什么我建议你从WinFormsSQL Server做起一个C#火车订票系统的典型诉求不是做一个能连12306的抢票器而是完成一套小范围的售票闭环用户能登录、查车次、看余票、下单、退票管理员能维护车次和用户订单可查可统计。这类项目常出现在课程设计、毕业设计、企业内部售票工具或小型代售点系统里。技术选型上我见过太多人一上来就想去搞微服务、Redis、消息队列其实用C# WinForms做界面SQL Server或Access做存储事务保证余票扣减这套组合已经能稳稳支撑每日几千单的业务量。反直觉的地方在于这个项目的难点不在界面多华丽而在余票并发扣减和订单状态流转——换句话说数据库设计和事务边界才是真正值得花时间的部分。适合谁想拿C#完整走一遍数据库编程、桌面应用和并发处理的开发者以及需要一个能演示、能交付的小型管理系统的团队。2. 数据模型先行把车次、余票、订单拆成几张表才不返工2.1 五张核心表先定关系再写界面火车订票系统的数据模型常见做法不是把车次、余票、订单混在一张表里而是拆成五张表用户表、车次表、车站表、订单表、订单明细表。车次表存车次号、出发站、到达站、出发时间、到达时间、票价和总票数余票为什么不放到一张独立表因为余票本质上是“总票数减去已售出票数”但每次查询都去统计订单明细在数据量上来之后会明显变慢。所以生产环境里普遍的做法是在车次表或独立的余票表里冗余一个余票字段订票时扣减退票时回补再用事务保证一致性。建表SQL我一般这样写CREATE TABLE Users ( UserId INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(50) NOT NULL UNIQUE, PasswordHash NVARCHAR(64) NOT NULL, Salt NVARCHAR(32) NOT NULL, Role TINYINT NOT NULL DEFAULT 0 -- 0 普通用户, 1 管理员 ); CREATE TABLE Stations ( StationId INT IDENTITY(1,1) PRIMARY KEY, StationName NVARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE Trains ( TrainId INT IDENTITY(1,1) PRIMARY KEY, TrainNo NVARCHAR(20) NOT NULL, DepartureStationId INT NOT NULL, ArrivalStationId INT NOT NULL, DepartureTime DATETIME NOT NULL, ArrivalTime DATETIME NOT NULL, TicketPrice DECIMAL(10,2) NOT NULL, TotalSeats INT NOT NULL, RemainingSeats INT NOT NULL, -- 冗余余票字段 FOREIGN KEY (DepartureStationId) REFERENCES Stations(StationId), FOREIGN KEY (ArrivalStationId) REFERENCES Stations(StationId) ); CREATE TABLE Orders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(32) NOT NULL UNIQUE, UserId INT NOT NULL, TrainId INT NOT NULL, OrderStatus TINYINT NOT NULL DEFAULT 0, -- 0 已下单, 1 已支付, 2 已退票 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), FOREIGN KEY (UserId) REFERENCES Users(UserId), FOREIGN KEY (TrainId) REFERENCES Trains(TrainId) ); CREATE TABLE OrderDetails ( DetailId INT IDENTITY(1,1) PRIMARY KEY, OrderId INT NOT NULL, PassengerName NVARCHAR(50) NOT NULL, IdCardNo NVARCHAR(18) NOT NULL, FOREIGN KEY (OrderId) REFERENCES Orders(OrderId) );这段SQL的逻辑说明Users表里我用Salt加PasswordHash存密码是防止明文密码直接暴露在数据库里Trains表里DepartureStationId和ArrivalStationId都指向Stations表避免同一个车站在不同车次里写法不一致RemainingSeats这个冗余字段是关键后面所有余票操作都围绕它展开。参数说明Role用TINYINT而不是字符串节省空间同时便于扩展角色TicketPrice用DECIMAL(10,2)而不是FLOAT避免浮点精度误差OrderNo设置UNIQUE约束防止并发下生成重复订单号。2.2 余票字段为什么不直接实时统计性能与一致性的取舍有同学会问余票为什么不直接用票数减去订单明细数量理论上的确可以但实际查询时车次列表页需要展示每个车次的余票如果每个车次都去做一次COUNT聚合再加出发站、到达站、时间条件的过滤整个查询会变成多表关联加聚合数据量到几万条订单时明显卡顿。这个现象在课程设计里体验不明显但放到真实售票场景就很致命。所以系统设计里约定RemainingSeats是唯一的余票事实来源。订票成功时对它执行UPDATE减一退票时UPDATE加一。出现的偏差比如某个车次长时间没卖票但余票不对靠定期的对账任务去校正——用一个后台线程每天凌晨统计一次订单数和RemainingSeats做比对不一致就修正。这个对账逻辑我通常会放在独立的C#服务里而不是塞进业务窗口。注意这种冗余设计的好处是查询快代价是必须在所有写路径上保证这个字段的更新少了一个更新入口数据就会悄悄漂移。2.3 用Access还是SQL Server课程设计与真实项目的分水岭热搜词里“C#与Access”出现频率很高说明很多人在课设阶段用的是Access。我的建议很直接如果是单机演示、数据量几千条内、不需要并发测试Access可以省去安装数据库服务的麻烦连接串也简单。但只要有两个以上的客户端同时访问或者你打算演示“两个人同时抢最后一张票”Access的并发表现会让你当场翻车——它的锁机制对写并发支持很差经常报“文件正在使用”。这类系统的常见部署形态是WinForms客户端 SQL Server Express或LocalDB。连接串放到App.config里换机器只改配置不动代码connectionStrings add nameTicketingDb connectionStringServerlocalhost\\SQLEXPRESS;DatabaseTicketingDb;User Idsa;Passwordyourpassword; providerNameSystem.Data.SqlClient / /connectionStrings参数说明User Idsa是开发环境偷懒的写法生产环境应该建一个最小权限账号附加数据库文件的写法Data Source(LocalDB)\MSSQLLocalDB适合单机部署但多客户端时别用。Access和SQL Server最大的差异还在于SQL方言Access的参数占位符是?SQL Server是参数名分页语法也完全不同。SQLite我提过要不要用不推荐它的并发写锁同样不适合这种带事务的售票场景。3. 登录与权限先把用户体系锁住再谈卖票3.1 登录窗口MD5加盐存储别让密码裸奔登录是C#火车订票系统的第一道门。常见做法是登录窗口里输入用户名密码后台拿用户名查出Salt和PasswordHash把用户输入的密码加上Salt做哈希然后与数据库里的哈希比对。直接存明文密码的一旦数据库文件泄露就是全部账号沦陷用MD5加盐至少让彩虹表失去意义。登录验证的核心代码private bool ValidateUser(string userName, string password) { string sql SELECT PasswordHash, Salt FROM Users WHERE UserName UserName; using (var conn new SqlConnection(ConfigurationManager.ConnectionStrings[TicketingDb].ConnectionString)) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(UserName, userName); conn.Open(); using (var reader cmd.ExecuteReader()) { if (!reader.Read()) return false; string salt reader[Salt].ToString(); string storedHash reader[PasswordHash].ToString(); string inputHash HashPassword(password, salt); return storedHash.Equals(inputHash, StringComparison.OrdinalIgnoreCase); } } } private string HashPassword(string password, string salt) { using (var md5 MD5.Create()) { byte[] bytes Encoding.UTF8.GetBytes(password salt); byte[] hash md5.ComputeHash(bytes); StringBuilder sb new StringBuilder(); foreach (byte b in hash) sb.Append(b.ToString(x2)); return sb.ToString(); } }这里的逻辑说明先按用户名查询找不到直接返回false避免通过不同报错信息猜用户名密码哈希时把Salt拼在后面Salt在创建用户时用Guid生成并持久化到数据库比对哈希用OrdinalIgnoreCase防止大小写问题。参数说明AddWithValue在SQL Server下够用但你要注意它有时会把NVarChar推断成VarChar导致索引失效HashPassword里的编码统一用UTF-8不要一边ASCII一边Unicode否则同一个密码在不同机器上算出的哈希不一致这种“玄学”问题排查起来特别耗时间。3.2 角色与权限管理员和售票员看到的是两个系统火车订票系统至少需要两个角色普通用户负责订票退票管理员负责维护车次、查看所有订单、统计报表。权限控制的做法有两种登录后把Role存在当前会话里每个窗体的Load事件里判断或者统一做一个窗体基类基类里放权限校验让所有业务窗体继承。我倾向于第二种因为第一种写多了容易漏。public partial class AdminBaseForm : Form { protected override void OnLoad(EventArgs e) { base.OnLoad(e); if (GlobalContext.CurrentUser.Role ! (byte)UserRole.Admin) { MessageBox.Show(当前账号无权限访问此功能); this.Close(); return; } InitAdminControls(); } protected virtual void InitAdminControls() { } }逻辑说明AdminBaseForm作为所有管理员窗体的基类OnLoad统一校验角色业务窗体只要继承它并把权限相关逻辑放在InitAdminControls里就天然具备权限保护。GlobalContext是静态类保存登录用户的UserId、UserName、Role等信息相当于一个简单的会话容器。参数说明角色值用TINYINT0和1的判断比字符串比较更快而且不容易因为拼写错误出bug。3.3 连接串配置把数据库地址从代码里搬出去我刚做这类系统时习惯把连接串写死在每个窗体里结果换数据库环境时要改几十处这是血泪经验。正确做法是放在App.config的connectionStrings节点里通过ConfigurationManager读取。上面第2.3节已经给了配置格式这里补充一个细节读取连接串的时机尽量集中在数据访问层的一个静态类里比如DbHelper。所有窗体通过DbHelper.GetConnection()拿连接对象以后切换数据库版本、迁移服务器只改App.config。如果你做了“C#读写注册表”的方案把连接串写到注册表里也不是不行但注册表部署在多用户环境下会碰到权限问题普通用户没有HKLM的写权限。App.config放在exe同目录跟随程序走权限冲突最少。这是我从一个实际部署坑里总结出来的当时把配置放在注册表结果目标机器上没有管理员权限装软件程序直接起不来最后退回App.config才解决。4. 订票核心流程从车次查询到支付状态流转4.1 车次查询多条件拼接与参数化查询订票系统的主界面通常是一个查询表单加一个DataGridView。用户输入出发站、到达站、日期点查询后展示符合条件的车次和余票。这个查询看起来简单但条件不是固定的——用户可能只填出发站不填到达站也可能只填日期。所以SQL要动态拼接但拼接时必须用参数化查询否则SQL注入随时可以把你整个Users表拖走。private DataTable SearchTrains(string depStation, string arrStation, DateTime travelDate) { string sql SELECT t.TrainNo, ds.StationName AS DepStation, as2.StationName AS ArrStation, t.DepartureTime, t.ArrivalTime, t.TicketPrice, t.RemainingSeats FROM Trains t INNER JOIN Stations ds ON t.DepartureStationId ds.StationId INNER JOIN Stations as2 ON t.ArrivalStationId as2.StationId WHERE CAST(t.DepartureTime AS DATE) TravelDate; var parameters new ListSqlParameter { new SqlParameter(TravelDate, travelDate.Date) }; if (!string.IsNullOrEmpty(depStation)) { sql AND ds.StationName LIKE DepStation; parameters.Add(new SqlParameter(DepStation, % depStation %)); } if (!string.IsNullOrEmpty(arrStation)) { sql AND as2.StationName LIKE ArrStation; parameters.Add(new SqlParameter(ArrStation, % arrStation %)); } return DbHelper.ExecuteQuery(sql, parameters.ToArray()); }逻辑说明先确保有一个必然存在的筛选条件日期再把可选条件追加进去。LIKE配合%做模糊匹配用户在站名没写全时也能查到。这里必须用别名as2获取到达站名因为Stations表被关联了两次。参数说明TravelDate用date类型比较而不是datetime避免用户选某一天时把当天零点之后的所有时间的车次排除掉。查询结果绑定到DataGridView后建议顺手把RemainingSeats为0的行的字体颜色改成灰色这个视觉提示比任何弹窗都好用。4.2 下单与扣余票事务加条件更新杜绝超卖订票和扣余票必须在一个数据库事务里完成。我见过很多翻车案例都是把“查余票”和“扣余票”拆成两个独立操作先查询有余票再执行扣减。在单线程下没问题但两个客户端同时提交订单时两个查询都看到有余票两个扣减也都执行了最后余票变成负数。解决思路是把检查与扣减合并成一条带条件的UPDATE语句。private bool CreateOrder(int userId, int trainId, DataTable passengers) { string orderNo GenerateOrderNo(); string updateSql UPDATE Trains SET RemainingSeats RemainingSeats - 1 WHERE TrainId TrainId AND RemainingSeats 0; string insertOrderSql INSERT INTO Orders(OrderNo, UserId, TrainId, OrderStatus) VALUES(OrderNo, UserId, TrainId, 0); SELECT SCOPE_IDENTITY();; string insertDetailSql INSERT INTO OrderDetails(OrderId, PassengerName, IdCardNo) VALUES(OrderId, PassengerName, IdCardNo);; using (var conn DbHelper.GetConnection()) { conn.Open(); using (var tx conn.BeginTransaction()) { try { using (var cmd new SqlCommand(updateSql, conn, tx)) { cmd.Parameters.AddWithValue(TrainId, trainId); int affected cmd.ExecuteNonQuery(); if (affected 0) { tx.Rollback(); return false; // 余票不足或车次不存在 } } int orderId; using (var cmd new SqlCommand(insertOrderSql, conn, tx)) { cmd.Parameters.AddWithValue(OrderNo, orderNo); cmd.Parameters.AddWithValue(UserId, userId); cmd.Parameters.AddWithValue(TrainId, trainId); orderId Convert.ToInt32(cmd.ExecuteScalar()); } foreach (DataRow row in passengers.Rows) { using (var cmd new SqlCommand(insertDetailSql, conn, tx)) { cmd.Parameters.AddWithValue(OrderId, orderId); cmd.Parameters.AddWithValue(PassengerName, row[PassengerName]); cmd.Parameters.AddWithValue(IdCardNo, row[IdCardNo]); cmd.ExecuteNonQuery(); } } tx.Commit(); return true; } catch { tx.Rollback(); throw; } } } }这段代码是整个系统的心脏。逻辑说明UPDATE语句里的AND RemainingSeats 0是关键它把“检查余票”和“扣减余票”合并成一个原子操作数据库行锁保证两个客户端同时执行时只有一个成功INSERT订单用SCOPE_IDENTITY()拿到新生成的OrderId再插入乘客明细所有写操作共享同一个事务对象tx任何一步失败则整体回滚。参数说明事务默认隔离级别是Read Committed配合条件更新已经够用如果你用的数据库是SQL Server不建议在这个场景改用Repeatable Read锁范围太大会拖慢整个表的并发性能。GenerateOrderNo的实现方式在最后一章展开。4.3 订单状态机别用散落的if else管理状态订单不是生下来就一直是“已下单”。一个完整的火车订票系统里订单至少经历已下单、已支付、已出票、已退票、已取消这几个状态。很多课程设计把状态判断散落在各个按钮的点击事件里到处写if (orderStatus 0)改一个状态逻辑要翻遍整个项目。更好的做法是引入状态机——用枚举定义状态用字典或switch定义合法迁移。public enum OrderStatus : byte { Created 0, Paid 1, Issued 2, Refunded 3, Cancelled 4 } public class OrderStateMachine { private static readonly DictionaryOrderStatus, OrderStatus[] AllowedTransitions new() { { OrderStatus.Created, new[] { OrderStatus.Paid, OrderStatus.Cancelled } }, { OrderStatus.Paid, new[] { OrderStatus.Issued, OrderStatus.Refunded } }, { OrderStatus.Issued, new[] { OrderStatus.Refunded } }, { OrderStatus.Refunded, Array.EmptyOrderStatus() }, { OrderStatus.Cancelled, Array.EmptyOrderStatus() } }; public static bool CanTransition(OrderStatus from, OrderStatus to) { return AllowedTransitions.TryGetValue(from, out var nexts) nexts.Contains(to); } }逻辑说明AllowedTransitions定义每个状态能迁往哪些目标状态比如已支付可以出票或退票但不能跳回已下单调用CanTransition做校验非法迁移直接拒绝。这套状态机写成静态类业务窗口里调用它判断按钮是否可用——比如订单处于已退票状态时支付按钮直接禁用。参数说明用数组定义迁移路径比用switch更可读后续加状态只需改动枚举和字典状态值用byte与数据库TINYINT对应序列化到前端也是数字。5. 避坑指南火车订票系统最常见的5个翻车现场5.1 并发订票导致余票变负数现象两个人同时提交订单界面都显示有票实际数据库余票变成-1。原因查询余票和扣减余票是两条独立的SQL中间被其他线程插入了更新典型的并发竞态。解决把检查合并进UPDATE的WHERE条件即RemainingSeats 0时才允许扣减同时整个订票过程放进事务。这条前面代码里已经实现千万别省略。5.2 DataGridView刷新后界面卡死或闪跳现象每次订票后调用DataGridView.DataSource null再重新绑定界面白屏闪烁用户快速操作时直接无响应。原因DataGridView在主线程上频繁重建数据源每个单元格重新布局数据量大时更是灾难。解决把DataGridView设为双缓冲模式绑定前先挂起布局dataGridView1.DataSource null; dataGridView1.Rows.Clear(); dataGridView1.DataSource dataTable; dataGridView1.Refresh();再补充一个细节这类界面刷新应该放在UI线程的定时器里而不是每次操作后手动刷新。我一般用一个System.Windows.Forms.Timer间隔三秒自动重新查询当前筛选条件下的列表这个方案同时照顾了新订单插入后的展示一致性和界面流畅度。5.3 事务提交了但订单明细没插入现象Orders表有记录OrderDetails表是空的乘客信息丢失。原因插入订单头后执行SCOPE_IDENTITY()但连接或事务对象创建位置不对——最常见的写法是用多个using块中途事务对象被Dispose了后面的明细插入自动提交到另一个隐式事务。解决严格执行一个事务一个连接一个using块的写法事务内不要混用其他连接日志里记录SCOPE_IDENTITY()返回的orderId排查时直接看日志比翻数据库快。5.4 SQL Server与Access的SQL方言差异搞出来的诡异报错现象在SQL Server上调试好的查询连Access就报“不支持关键字”或者日期查询结果为空。原因Access参数占位符是?不是参数名日期要用#2024-01-01#包裹LIKE的用法也有差异。解决确定目标数据库后再写数据访问层不要在Access和SQL Server之间来回切换如果你做好学好C#但还不太熟SQL建议直接把系统锁定在SQL Server系列方言统一踩坑面最小。Access适合纯课设演示别让它在生产环境里承担并发压力。5.5 SQL注入从查询框打进数据库现象测试人员在出发站输入框里填了 OR 11结果查出了全部车次如果继续构造还能删表。原因字符串拼接SQL没有参数化。解决所有数据库操作一律用SqlParameter禁止拼字符串对那些拿不准自己写的是参数化还是拼接的全局搜索代码里号拼接SQL的地方逐处改成参数化写法。这一步做完整个系统的安全水位直接抬一个档次。6. 让课设系统具备上线气质票号生成、异步加载与验证方法火车订票系统做完基本功能离“能演示、能交付”只差最后一公里票号生成不能依赖数据库自增因为OrderNo要暴露给用户自增ID会暴露当天订单量查询和出票不能阻塞UI线程否则用户点一下界面就转圈你还需要一套简单可行的验证手段来证明并发扣减没写错。票号生成我常用的做法是时间戳加随机数加序列号private static string GenerateOrderNo() { string ts DateTime.Now.ToString(yyyyMMddHHmmss); string rand Random.Shared.Next(1000, 9999).ToString(); return ${ts}{rand}; }这个方案的逻辑说明时间戳保证大体有序随机数防止同一秒内多个订单号撞车。一秒内并发超过几千单时冲突概率上升但我做过的小型售票系统里这个量级足够。如果你在建表时给了OrderNo一个UNIQUE约束撞号时数据库会报重复键错误捕获后重新生成即可相当于一道保险。参数说明Random.Shared是.NET 6以后的线程安全随机数旧版本要处理Random的线程问题否则高并发下可能生成同样的序列。异步加载方面C# WinForms里最容易接受的方案是async/await配合Task.Run。查询车次这种IO操作从UI线程挪到后台线程执行完再回到UI线程绑定DataGridView。相关热词里“C#线程”经常被问到归根结底就是这个场景耗时操作不放后台线程界面就卡。但注意后台线程不能直接改控件属性要await后在UI上下文里操作async方法天然帮你做了这件事。最后说验证。你写完这套系统怎么证明并发没有超卖我自己的血泪经验是写一个简单的控制台测试程序模拟50个客户端同时订同一个车次的最后一张票然后统计成功订单数——如果超过1个成功或余票为负事务或条件更新必然有疏漏。这个测试跑通了再上界面演示基本不会出丑。另外退票后余票是否回补也要测尤其是先下单再退票再下单的同一条链路这是最容易漏掉的状态流。整套系统我说到底的教训是火车订票demo好写但余票字段、事务边界和订单状态机这三件事写对了才敢说自己是C#做的订票系统而不是花架子。希望上面的实现细节能帮你少走这些弯路。本文还有配套的精品资源点击获取