简介:C#火车订票系统是一份面向初、中级开发者的课程设计与项目实战资源,用于学习基于C#窗体应用和SQL Server数据库的订票业务流程。系统覆盖用户注册登录、车次管理、订票退票改签等模块,突出事件驱动界面设计、SQL语句编写、事务处理与并发控制等关键知识点,适合作为毕业设计、课程作业或C#与数据库联调练习的参考。压缩包为rar格式,共128个文件、约1.85MB,主体包含50个.cs源码文件、24个.resx界面资源文件、24个.resources编译资源文件,还有sln/csproj工程文件、exe可执行程序、jpg图片及少量缓存调试文件,可直接打开完整工程查看窗体设计、业务逻辑与数据访问代码。目前已有1437人学习下载,说明该选题在同类资源中具备一定热度。通过多窗体实现与数据库交互示例,可帮助理解登录校验、车次增删查改、订单状态变更等功能的落地写法,为后续扩展余票查询、在线支付等能力打下基础。
1. C# 火车订票系统:先确认这套代码能不能直接跑
C# 火车订票系统这类课程设计资源,常见打包形式是一个 Visual Studio 解决方案加一份 SQL Server 建库脚本,表面功能很全:注册登录、车次查询、余票展示、下单订票、退票处理、后台车次管理,但拿到手能一次跑通的没几个。问题通常不在代码,而在三处:数据库实例和脚本版本不匹配、连接字符串写死成别人的机器名、登录查询用字符串拼接埋了雷。这篇笔记按“先拆数据模型,再打通连接,然后把订票事务跑顺,最后补验证”的顺序来写,中间会给出可直接套用的建库脚本、存储过程和 C# 调用代码。适合刚拿到这套资源,想在自己电脑上还原、做课程设计验收或二次开发的开发者。
2. 先拆数据库:建库脚本与余票模型
很多资源包打开以后第一件事就是让你附加数据库,但 MDF 文件附加失败是最高频的报错,原因多半是 SQL Server 版本对不上。与其折腾附加,我更建议直接执行建库脚本,把表结构重新生成一遍,这样既能看清各表关系,也能顺手把脏数据洗掉。这一章先把资源包里最核心的数据模型拆开。
2.1 主流程涉及哪几张表
火车订票系统虽然叫“订票”,核心流程其实是“用户选车次 → 查余票 → 下单 → 扣余票 → 生成订单”。对应到数据库,最少需要五类实体:用户、车次、运行计划、订单、退票记录。
不少初版资源为了省事,只在车次表里放一个总余票字段,下单时直接减一。这种做法演示时看不出问题,但一旦出现多个日期、多个车次,或者要区分一等座二等座,整个设计就得推倒重来。所以拆这份资源时,先看它有没有把“计划表”单独拆出来,这是判断数据模型是否可扩展的第一个信号。
一个结构合理的数据库通常会拆成下面五张表:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| Users | 用户与管理员 | UserName, PasswordHash, IsAdmin |
| Trains | 车次基础信息 | TrainNo, StartStation, EndStation, Price, TotalSeats |
| Schedules | 某车次在某日的运行实例与余票 | TrainId, ScheduleDate, AvailableSeats |
| Orders | 订单主体 | OrderNo, UserId, ScheduleId, TicketCount, Status |
| Refunds | 退票记录 | OrderId, RefundAmount, Reason |
有的版本还会拆出订单明细表 OrderItems,用来支持一次买多张不同座位号的票。如果原资源没有选座功能,明细表不是必须的,硬加反而会让多表 JOIN 变复杂。
2.2 建库建表脚本直接执行
我用一版在 SQL Server 2012 及以上都能跑的建库脚本,逻辑与大多数课程设计资源包一致:
CREATE DATABASE TrainDB; GO USE TrainDB; GO CREATE TABLE Users ( UserId INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(50) NOT NULL, PasswordHash NVARCHAR(64) NOT NULL, RealName NVARCHAR(50) NOT NULL, IdCard NVARCHAR(18) NOT NULL UNIQUE, Phone NVARCHAR(11), IsAdmin BIT NOT NULL DEFAULT 0, CreatedAt DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE Trains ( TrainId INT IDENTITY(1,1) PRIMARY KEY, TrainNo NVARCHAR(10) NOT NULL, StartStation NVARCHAR(50) NOT NULL, EndStation NVARCHAR(50) NOT NULL, DepartTime DATETIME NOT NULL, ArriveTime DATETIME NOT NULL, Price DECIMAL(10,2) NOT NULL, TotalSeats INT NOT NULL DEFAULT 100, IsActive BIT NOT NULL DEFAULT 1 ); CREATE TABLE Schedules ( ScheduleId INT IDENTITY(1,1) PRIMARY KEY, TrainId INT NOT NULL FOREIGN KEY REFERENCES Trains(TrainId), ScheduleDate DATE NOT NULL, AvailableSeats INT NOT NULL, IsBookable BIT NOT NULL DEFAULT 1, CONSTRAINT UQ_Schedule UNIQUE (TrainId, ScheduleDate) ); CREATE TABLE Orders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(64) NOT NULL UNIQUE, UserId INT NOT NULL FOREIGN KEY REFERENCES Users(UserId), ScheduleId INT NOT NULL FOREIGN KEY REFERENCES Schedules(ScheduleId), TicketCount INT NOT NULL CHECK (TicketCount > 0), TotalPrice DECIMAL(10,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 1, OrderTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE Refunds ( RefundId INT IDENTITY(1,1) PRIMARY KEY, OrderId INT NOT NULL FOREIGN KEY REFERENCES Orders(OrderId), RefundAmount DECIMAL(10,2) NOT NULL, Reason NVARCHAR(200), RefundTime DATETIME NOT NULL DEFAULT GETDATE() );代码逻辑说明:Users 表用 NVARCHAR 而不是 VARCHAR,是为了保证中文不乱码。IdCard 加 UNIQUE 约束,防止同一证件重复注册。Schedules 表的 AvailableSeats 是核心业务字段,刻意和 Trains 的 TotalSeats 分开,因为 TotalSeats 是列车固定载客量,AvailableSeats 是某一天这趟车剩余的可售票数。两者一旦混用,后面做每日余票初始化会非常别扭。
参数与扩展点:如果你要在原资源上增加一等座、二等座,需要把 Schedules 继续拆分或增加 SeatClass 字段;如果要做选座,还要增加座位表。字段名尽量不要改,很多窗口代码直接按列名绑定 DataGridView,改了列名会引发索引越界或显示空白。
2.3 余票初始化:没这一步就别想订票
Schedules 表不会自动产生数据。车次表里加了车次,不代表某一天有余票可售。资源包里一般在管理端提供一个“生成某日车次计划 / 初始化余票”的功能,背后就是一条批量 INSERT:
INSERT INTO Schedules (TrainId, ScheduleDate, AvailableSeats) SELECT TrainId, @targetDate, TotalSeats FROM Trains WHERE IsActive = 1 AND TrainId NOT IN ( SELECT TrainId FROM Schedules WHERE ScheduleDate = @targetDate );这段 SQL 的作用是给指定日期批量生成全部有效车次的运行计划,余票初始化为车次总座位数。后半段 NOT IN 子查询防止重复初始化。很多新手手动往 Schedules 里插一条数据,漏了某天的计划,表现就是客户端查余票永远显示 0。那不是真没票,是根本没生成当天的票。
订票完成后,如果发现数据库里 AvailableSeats 变成了负数,先查初始化脚本是不是执行了多次,或者新增车次时没有同步生成当天计划,不必一上来就怀疑并发问题。
3. 连接层与登录模块:连接字符串和防注入改造
拿到资源包后,第二个高频翻车点就是连接配置。这一章把这部分讲透,同时把登录模块里的安全隐患一起处理掉。
3.1 不同 SQL Server 实例怎么选连接字符串
在 Visual Studio 的 App.config 或窗体代码里,大概率会看到下面这行:
<connectionStrings> <add name="TrainDb" connectionString="Server=.;Database=TrainDB;Integrated Security=True;" providerName="System.Data.SqlClient" /> </connectionStrings>Server=.表示连接本机默认实例。如果你的电脑装的是 SQL Server Express,实例名是 SQLEXPRESS,就改成:
Server=.\SQLEXPRESS;Database=TrainDB;Integrated Security=True;如果用的是 Visual Studio 自带的 LocalDB,则写成:
Server=(localdb)\MSSQLLocalDB;Database=TrainDB;Integrated Security=True;这三种写法是下载资源里最容易出现的差异。资源包作者写的是他自己的机器或实例名,直接复制会报“网络相关错误或特定实例不存在”。原因就是服务器名不对。SqlConnection 解析服务器名失败时抛出的 SqlException 错误码通常是 -2 或 29,看到这类错误先看服务器名,再回头看实例名。
另外,Integrated Security=True 表示用 Windows 身份验证,不需要用户名密码。如果换了电脑后登录失败,先确认 SQL Server 配置管理器里的 TCP/IP 协议是否启用,以及 SQL Server 服务是否已在运行。我一般会先把 SQL Server Management Studio 打开连一遍,确认数据库实例本身能连,再去怀疑程序的问题。
3.2 登录窗口:别用拼接 SQL
不少基础版本登录代码长这样:
string sql = "SELECT * FROM Users WHERE UserName='" + txtUser.Text + "' AND Password='" + txtPwd.Text + "'";这是资源包里最常见的写法,也是最危险的一处。用户名输入一个' OR 1=1 --就能直接登录,甚至输入'; DROP TABLE Orders; --会把订单表直接删掉。解决办法不复杂,改成参数化查询即可:
private bool CheckLogin(string userName, string password, out bool isAdmin) { isAdmin = false; string sql = @"SELECT IsAdmin FROM Users WHERE UserName = @u AND PasswordHash = HASHBYTES('SHA2_256', @p)"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@u", userName); cmd.Parameters.AddWithValue("@p", password); conn.Open(); object result = cmd.ExecuteScalar(); if (result == null) return false; isAdmin = Convert.ToBoolean(result); return true; } }代码逻辑说明:HASHBYTES('SHA2_256', @p)在 SQL Server 2016 及以上版本可用,它会直接计算密码哈希并与库里保存的哈希值比对。ExecuteScalar()在没有匹配行时返回 null,所以先判空再转换。参数名必须与 SQL 文本里的 @ 前缀完全对应,少一个就会抛“未提供参数”的异常。
3.3 密码字段要不要改
如果你下载的这份资源里 Users 表的密码字段是明文,说明作者省了这一步。课程设计验收时,导师大概率会问“密码是怎么保存的”。建议把字段改成 PasswordHash,并用上面的参数化代码替换登录逻辑。注册模块也要同步改:
string sql = @"INSERT INTO Users (UserName, PasswordHash, RealName, IdCard, Phone) VALUES (@u, HASHBYTES('SHA2_256', @p), @r, @i, @ph)";一个提醒:已经存进去的明文密码不会自动变成哈希。如果资源包还没跑通,先改表结构再注册新账号,别等测试数据填满了再迁移。
4. 订票核心:事务、锁与防超卖
这一章是整篇笔记的重点。订票不是简单的“INSERT 一条订单”,它涉及到余票扣减,处理不好会出现超卖。
4.1 一次订票涉及哪几步
从用户点击“购买”到界面显示“订票成功”,数据库侧要完成四件事:查询当前余票是否够用,扣减余票,计算订单总价,插入订单记录。如果中间有一步失败,比如余票扣了但订单没插进去,就会出现“票没了钱也扣了”的脏数据,所以这几步必须放在同一个事务里。
很多人习惯把余票判断放在 C# 端,先查一次余票,再执行两条 update 和 insert,这是一个典型的心智陷阱。C# 端两次数据库操作之间有时间差,多个用户同时点击时,两个线程都读到余票等于 5,都认为能买,最终可能卖出 10 张票。防超卖必须靠数据库层的原子操作,不能依赖界面层加锁或延迟。
4.2 存储过程与锁提示
推荐用存储过程实现订票,逻辑集中且更容易验收:
CREATE PROCEDURE sp_BookTicket @UserId INT, @ScheduleId INT, @TicketCount INT, @OrderNo NVARCHAR(64) AS BEGIN SET NOCOUNT ON; BEGIN TRANSACTION; BEGIN TRY DECLARE @Available INT; DECLARE @Price DECIMAL(10,2); SELECT @Available = AvailableSeats FROM Schedules WITH (UPDLOCK, HOLDLOCK) WHERE ScheduleId = @ScheduleId; IF @Available IS NULL THROW 50001, N'车次计划不存在', 1; IF @Available < @TicketCount THROW 50002, N'余票不足', 1; UPDATE Schedules SET AvailableSeats = AvailableSeats - @TicketCount WHERE ScheduleId = @ScheduleId; SELECT @Price = t.Price FROM Schedules s INNER JOIN Trains t ON s.TrainId = t.TrainId WHERE s.ScheduleId = @ScheduleId; INSERT INTO Orders (OrderNo, UserId, ScheduleId, TicketCount, TotalPrice, Status) VALUES (@OrderNo, @UserId, @ScheduleId, @TicketCount, @Price * @TicketCount, 1); COMMIT TRANSACTION; END TRY BEGIN CATCH ROLLBACK TRANSACTION; THROW; END CATCH END;参数说明:WITH (UPDLOCK, HOLDLOCK)是关键。UPDLOCK 在读余票时直接取更新锁,避免两个事务同时读到同一个可用票数;HOLDLOCK 把锁保持到事务结束,防止其他事务在中间插入修改。这两个锁提示配合起来,才能保证“查询余票 → 扣减余票 → 插入订单”是一条不会被别人打断的原子链。THROW只能用于 SQL Server 2012 及以上版本,如果资源包脚本在旧实例上报语法错误,要换成RAISERROR。
退票逻辑也要写成对称的回补事务:
CREATE PROCEDURE sp_RefundTicket @OrderId INT, @Reason NVARCHAR(200) AS BEGIN SET NOCOUNT ON; BEGIN TRANSACTION; BEGIN TRY DECLARE @ScheduleId INT; DECLARE @TicketCount INT; DECLARE @Status TINYINT; DECLARE @Amount DECIMAL(10,2); SELECT @ScheduleId = s.ScheduleId, @TicketCount = o.TicketCount, @Status = o.Status FROM Orders o INNER JOIN Schedules s ON o.ScheduleId = s.ScheduleId WHERE o.OrderId = @OrderId; IF @Status <> 1 THROW 50003, N'订单状态不允许退票', 1; UPDATE Schedules SET AvailableSeats = AvailableSeats + @TicketCount WHERE ScheduleId = @ScheduleId; UPDATE Orders SET Status = 2 WHERE OrderId = @OrderId; SELECT @Amount = TotalPrice FROM Orders WHERE OrderId = @OrderId; INSERT INTO Refunds (OrderId, RefundAmount, Reason) VALUES (@OrderId, @Amount, @Reason); COMMIT TRANSACTION; END TRY BEGIN CATCH ROLLBACK TRANSACTION; THROW; END CATCH END;退票存储过程的目的不只是插一条退款记录,它会把 AvailableSeats 加回去。课程设计里最常见的功能缺失就是只往 Refunds 表插记录,忘了回补余票。
4.3 C# 侧调用存储过程
C# 端用 SqlCommand 调用即可:
public async Task<bool> BookTicketAsync(int userId, int scheduleId, int ticketCount) { string orderNo = DateTime.Now.ToString("yyyyMMddHHmmssff") + Guid.NewGuid().ToString("N").Substring(0, 6); using var conn = new SqlConnection(connStr); using var cmd = new SqlCommand("sp_BookTicket", conn); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@UserId", userId); cmd.Parameters.AddWithValue("@ScheduleId", scheduleId); cmd.Parameters.AddWithValue("@TicketCount", ticketCount); cmd.Parameters.AddWithValue("@OrderNo", orderNo); try { await conn.OpenAsync(); await cmd.ExecuteNonQueryAsync(); return true; } catch (SqlException ex) { if (ex.Number == 50002) MessageBox.Show("余票不足"); else MessageBox.Show("订票失败:" + ex.Message); return false; } }参数说明:CommandType.StoredProcedure必须显式指定,否则 SqlCommand 会把存储过程名当普通 SQL 文本执行。SqlException.Number对应存储过程 THROW 后面那个数字,50002 就是自定义的“余票不足”,可以根据不同错误码弹不同提示。OrderNo 用时间戳加 GUID 片段,长度控制在 64 个字符内,避免并发时撞单。
5. 本机跑通全流程:五条高频踩坑记录
无论拿到的是整理好的完整包还是只有源码和脚本,在本机还原过程中,下面五条坑出现频率最高,逐条按“现象 → 原因 → 解决”说明。
5.1 附加数据库时报权限不足
现象:在 SQL Server Management Studio 里右键附加,选择 MDF 文件后报“无法为该文件检索行集”或“操作系统错误 5(拒绝访问)”。
原因:MDF 文件从压缩包解压出来后,SQL Server 账户没有继承文件的读权限,常见于在系统盘用户目录或下载目录下解压的场景。
解决:把 MDF 和 LDF 文件移动到非系统目录,例如 D 盘下的独立数据文件夹,授权后再附加;或者直接改用第二章的建库脚本重建库,再把原始数据导出导入。我一般直接用脚本重建,省去权限问题。
5.2 程序报“在建立与服务器的连接时出错”
现象:点击登录按钮后等 30 秒,弹出“在与 SQL Server 建立连接时出现与网络相关或特定实例的错误。未找到或无法访问服务器”。
原因:连接字符串里的服务器名不对。课程设计作者写的是他自己的机器名或实例名,换到新电脑上必然连不上。
解决:打开 SQL Server 配置管理器确认实例类型,然后按第三章的三套连接字符串依次替换,每改一次重启程序,不要用热重载骗自己。
5.3 界面中文变成一串问号
现象:注册的用户名是“张三”,存入数据库后显示“???”,查询站名也全是问号。
原因:表结构用了 VARCHAR,而客户端传的是中文字符。SQL Server 的 VARCHAR 用系统代码页,中文需要 NVARCHAR 才能正确存储。
解决:把所有含中文字段的类型改成 NVARCHAR。
ALTER TABLE Users ALTER COLUMN RealName NVARCHAR(50); ALTER TABLE Trains ALTER COLUMN StartStation NVARCHAR(50);同时检查连接字符串里有没有把编码强制成非 UTF-8 的参数。
5.4 同一用户快速点两次下单,生成两笔订单
现象:在订票窗口快速双击购买按钮,界面出现两条“订票成功”,订单表里多了一条重复订单。
原因:按钮没有在提交后禁用,或事务提交期间没有做操作防抖。数据库侧响应两次合法请求就会生成两条订单,这是 C# 侧的责任。
解决:提交前加一个 isSubmitting 标志位,请求发出后置 true,结束再放开;同时在 Orders 表保留 OrderNo 唯一索引做兜底。只靠 UI 防抖不够,因为自动化脚本同样可以发起连续请求。
5.5 存储过程 THROW 报语法错误
现象:执行建存储过程脚本时,SSMS 报“THROW 附近有语法错误”,或者提示版本不支持。
原因:当前 SQL Server 是 2008 或 2008 R2,THROW 从 2012 才开始支持。
解决:把 THROW 换成 RAISERROR,第七章会看到等价写法:
IF @Available < @TicketCount RAISERROR(N'余票不足', 16, 1);先执行SELECT @@VERSION确认版本,再决定是否需要降级语法。
6. 验证与扩展:把静态 Demo 改造成能验收的系统
6.1 用双会话验证是否超卖
跑通之后不要直接交。打开两个 SSMS 查询窗口,把 Schedules 表对应记录的最后可用余票设为 1,然后两个窗口同时执行:
EXEC sp_BookTicket @UserId = 1, @ScheduleId = 1, @TicketCount = 1, @OrderNo = 'TEST-0001';正常情况下只会有一笔成功,另一笔会等待后报“余票不足 50002”。如果两笔都成功,说明事务或锁提示没生效,要回头检查存储过程里的WITH (UPDLOCK, HOLDLOCK)。
6.2 验证退票回补
退票后可用余票必须恢复。正确顺序是:记录当前 AvailableSeats → 执行退票 → 再次查询余票。如果余票比原来多出了 TicketCount,说明回补逻辑生效;如果不变,说明 sp_RefundTicket 没执行成功。
6.3 一点经验
最后分享一个习惯:资源包拿到手,我不急着改界面,先把连接字符串、建库脚本、订票事务、退票回补按顺序跑一遍。很多黑匣子问题都出在中间环节——订票界面提示成功,订单表里却没有记录,那多半是事务没有提交或被提前回滚。从那以后每次遇到这种源码包,我都会强制走一遍数据库脚本,再用订单状态对照验证整体流程,而不是等界面报错才回头翻 SQL。希望帮到你。
本文还有配套的精品资源,点击获取