资讯动态

C#火车订票系统:WinForm+SQL Server全链路实战指南

发布时间:2026/10/21 7:20:57 来源:尧图企业网站定制
简介这是一套基于C#与SQL Server开发的完整火车订票系统实战项目资源面向C#初学者及.NET桌面应用入门学习者聚焦GUI开发、数据库交互与多模块业务逻辑实现。资源包含128个文件主体为50个C#源码文件.cs构成核心业务逻辑与窗体界面24个.resx资源文件支持多语言/本地化24个.resources二进制资源及1个.sln解决方案文件保障项目可直接编译运行另有exe可执行程序、jpg界面截图、dll依赖库等整体压缩包仅1.85MB轻量易部署。已有1437人学习下载适合用于课程设计、毕业实训或自学练手。读者可获得结构清晰的三层窗体架构用户/车次/订单模块、完整的SQL Server数据库操作示例含事务处理与CRUD封装、带事件驱动的订退改签功能实现以及VS工程标准目录组织方式是理解C# WinFormSQL Server全栈开发流程的典型参考案例。1. 一个能跑通、能改、能交作业的C#火车订票系统从WinForm界面到SQL Server数据库全链路闭环上周帮某高校实验室带毕设的学生调试代码他拿着一份“C#火车订票系统”源码包来找我第一句话是“老师它点‘查询车次’就崩数据库连不上但README里写‘开箱即用’……”——这项目不是玩具Demo而是一个真实可运行、结构完整、覆盖用户端核心流程查票→选座→下单→支付模拟→订单管理的C#桌面应用工程。它用WinForm做界面SQL Server做后端含建库脚本ADO.NET直连无ORM无Web层纯本地部署。适合三类人刚学完C#基础想练手的真实项目的新手需要快速交付课程设计/实训作业的本科生或想逆向拆解传统CS架构数据流的老手。它不炫技不套壳所有逻辑都在.cs文件里裸写连SQL拼接都明明白白——这意味着你改一行代码就能看到效果也意味着你漏一个分号就会在登录页卡死。下面我就带你从零把它跑起来再一层层剥开它的皮肉筋骨。2. 环境准备与一键启动Visual Studio SQL Server Express 的最小可行组合这个项目对环境要求极简但恰恰是“极简”最容易翻车。它不依赖.NET Core或.NET 6而是锁定在.NET Framework 4.7.2项目属性里明确写着SQL Server 版本最低需SQL Server 2016 Express含LocalDB。别急着装最新版VS先确认你的开发机是否已具备这两个基石。2.1 安装与版本校验三步锁死兼容链首先打开你的 Visual Studio Installer哪怕你装的是VS 2022。在“工作负载”页确保勾选了“.NET 桌面开发”—— 这会自动带上.NET Framework 4.7.2 SDK。别跳过这步很多同学装了VS却没勾这个结果打开.sln就报“无法加载项目目标框架不可用”。其次检查SQL Server。如果你没装过任何SQL Server直接安装 SQL Server 2019 Express免费版安装时务必勾选“SQL Server Express LocalDB”和“SQL Server Management Studio (SSMS)”。LocalDB是轻量嵌入式实例专为桌面应用设计无需配置服务启动即用。装完后在命令行执行sqllocaldb info你应该看到类似MSSQLLocalDB的实例名。如果返回空说明LocalDB没装成功重装时务必勾选它。最后验证连接字符串。项目根目录下有一个App.config文件打开它找到connectionStrings节点add nameTrainDBConnectionString connectionStringData Source(localdb)\MSSQLLocalDB;Initial CatalogTrainBookingDB;Integrated SecurityTrue; providerNameSystem.Data.SqlClient /注意两点Data Source必须和你sqllocaldb info输出的实例名完全一致大小写敏感Initial Catalog是数据库名项目自带的建库脚本会创建这个库但首次运行前它并不存在——这点我们下一节处理。提示如果你的SQL Server实例名不是MSSQLLocalDB比如是.\SQLEXPRESS请立刻修改此处并同步更新项目中所有硬编码连接字符串的地方搜索new SqlConnection(可定位。2.2 数据库初始化用SQL脚本而非图形界面手动建库项目压缩包里一定包含一个Database/TrainBookingDB.sql文件如果没有请检查是否下载完整。这是整个系统的数据基石不能跳过也不能用SSMS右键“新建数据库”糊弄过去——因为表结构、主外键、初始数据如车站、车次、车厢座位全在里面。打开SSMS连接到(localdb)\MSSQLLocalDB或你修改后的实例名新建查询窗口全选粘贴TrainBookingDB.sql内容然后执行F5。你会看到类似这样的输出已创建数据库 TrainBookingDB。 正在创建表 [Stations]... 已完成。 正在创建表 [Trains]... 已完成。 正在插入初始车站数据... 23 行受影响。 ...关键点来了脚本末尾有INSERT INTO [Users]插入一条管理员账号用户名admin密码123456明文仅用于教学。这是你登录系统的第一把钥匙。如果执行失败90%原因是实例名不匹配报错A network-related or instance-specific error...当前Windows账户没有LocalDB的sysadmin权限此时需以管理员身份运行SSMS或改用SQL Server身份验证在连接时输入sa密码。执行成功后在SSMS左侧“对象资源管理器”中展开“数据库”你应该能看到TrainBookingDB。双击打开展开“表”确认Users,Trains,Tickets,Orders等表均已存在——这才是真正的起点。2.3 启动项目解决“未将对象引用设置到对象的实例”玄学报错双击打开.sln文件不是.csproj。VS会加载整个解决方案通常包含两个项目TrainBookingSystem主程序和TrainBookingSystem.Data数据访问层可能为类库。右键TrainBookingSystem→ “设为启动项目”。按CtrlF5不调试启动。如果一切顺利你会看到一个蓝白配色的WinForm登录窗口。输入admin/123456点击登录。但大概率你会卡在登录按钮点击后弹出一个红色错误框“未将对象引用设置到对象的实例。”这是C#新手最熟悉的“玄学”报错根源几乎100%在数据库连接上。排查路径如下打开LoginForm.cs找到登录按钮的Click事件处理方法通常是btnLogin_Click在SqlConnection conn new SqlConnection(...)这行前加一句断点按F5启动调试输入账号密码点击登录程序停在断点处把鼠标悬停在conn变量上看ConnectionString是否是你刚在App.config里配置的值继续按F10单步走到conn.Open()这行如果这里抛异常说明连接字符串或SQL Server服务本身有问题如果conn.Open()成功但后续SqlDataReader.Read()报空引用则说明SELECT * FROM Users WHERE ...没查到数据——检查TrainBookingDB库里的Users表是否真有admin这条记录SSMS里右键表→“选择前1000行”。注意这个报错绝不是代码bug而是环境没配好。我带过的23个学生里21个卡在这里原因全是App.config连接字符串写错了实例名。记住连接字符串是活的不是文档里的摆设。3. 核心模块拆解从LoginForm到OrderForm的三层数据流穿透这个系统虽小但严格遵循“界面层-业务逻辑层-数据访问层”三层架构。所有.cs文件命名直白LoginForm.cs,MainForm.cs,QueryTrainForm.cs,OrderForm.cs。我们不讲理论直接钻进三个最关键的.cs文件看数据怎么从用户点击流到数据库再回到界面。3.1 LoginForm.cs登录验证的硬核写法无加密纯教学登录逻辑集中在btnLogin_Click方法。它不做任何密码哈希而是用明文比对——这是教学项目的合理妥协但你要清楚它在哪private void btnLogin_Click(object sender, EventArgs e) { string username txtUsername.Text.Trim(); string password txtPassword.Text.Trim(); // 1. 构建SQL查询⚠️ 注意此处有SQL注入风险教学项目简化处理 string sql SELECT * FROM Users WHERE Username username AND Password password; // 2. 创建连接复用App.config里的连接字符串 using (SqlConnection conn new SqlConnection(ConfigurationManager.ConnectionStrings[TrainDBConnectionString].ConnectionString)) { using (SqlCommand cmd new SqlCommand(sql, conn)) { // 3. 使用参数化查询防注入关键 cmd.Parameters.AddWithValue(username, username); cmd.Parameters.AddWithValue(password, password); try { conn.Open(); using (SqlDataReader reader cmd.ExecuteReader()) { if (reader.Read()) // 查到用户登录成功 { // 4. 将用户信息存入全局静态类简单粗暴的Session模拟 CurrentUser.UserID (int)reader[UserID]; CurrentUser.Username reader[Username].ToString(); CurrentUser.Role reader[Role].ToString(); // 5. 关闭当前窗体打开主界面 this.Hide(); MainForm mainForm new MainForm(); mainForm.ShowDialog(); this.Close(); } else { MessageBox.Show(用户名或密码错误, 登录失败, MessageBoxButtons.OK, MessageBoxIcon.Error); } } } catch (Exception ex) { MessageBox.Show($数据库连接失败{ex.Message}, 错误, MessageBoxButtons.OK, MessageBoxIcon.Error); } } } }逻辑说明username和password是参数占位符AddWithValue自动推断类型避免字符串拼接WHERE Username username 是经典翻车写法CurrentUser是一个静态类CurrentUser.cs里面只有几个public static字段用于跨窗体传递登录态——这是WinForm里最轻量的“Session”this.Hide()mainForm.ShowDialog()是WinForm模态对话框的标准写法保证登录窗体隐藏但不销毁主窗体关闭后还能回来。3.2 QueryTrainForm.cs查票功能的完整链路含日期格式陷阱查票是核心功能入口在主菜单“车次查询”。它调用QueryTrainForm核心是LoadTrainData()方法private void LoadTrainData() { string fromStation cmbFromStation.Text; string toStation cmbToStation.Text; DateTime travelDate; // 1. 日期控件取值DateTimePicker.Value 是DateTime类型但SQL Server DATETIME字段要求格式 // 常见坑直接.ToString() 会带时分秒而车次表里Date字段只存日期部分如2024-05-20 travelDate dtpTravelDate.Value.Date; // .Date 强制截断为00:00:00 string sql SELECT t.TrainID, t.TrainNumber, s1.StationName AS FromStation, s2.StationName AS ToStation, t.DepartureTime, t.ArrivalTime, DATEDIFF(MINUTE, t.DepartureTime, t.ArrivalTime) AS DurationMinutes, (SELECT COUNT(*) FROM Tickets tk WHERE tk.TrainID t.TrainID AND tk.TravelDate travelDate AND tk.Status Available) AS AvailableSeats FROM Trains t INNER JOIN Stations s1 ON t.FromStationID s1.StationID INNER JOIN Stations s2 ON t.ToStationID s2.StationID WHERE s1.StationName fromStation AND s2.StationName toStation AND CAST(t.DepartureTime AS DATE) travelDate; using (SqlConnection conn new SqlConnection(...)) { using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(fromStation, fromStation); cmd.Parameters.AddWithValue(toStation, toStation); cmd.Parameters.AddWithValue(travelDate, travelDate); // 传入已截断的Date try { conn.Open(); SqlDataAdapter adapter new SqlDataAdapter(cmd); DataTable dt new DataTable(); adapter.Fill(dt); // 填充到DataTable绑定到DataGridView dgvTrainList.DataSource dt; } catch (Exception ex) { MessageBox.Show($查询失败{ex.Message}); } } } }参数说明dtpTravelDate.Value.Date是血泪经验如果不加.Date传给SQL的可能是2024-05-20 14:30:00而CAST(... AS DATE)会把它转成2024-05-20但若数据库字段是DATETIME且存了具体时间比较可能失效AvailableSeats子查询是亮点它实时统计当天该车次的“可用票数”逻辑在SQL里完成避免C#层循环查表性能更优SqlDataAdapter.Fill(dt)是ADO.NET经典用法把结果集灌进DataTable再直接赋给DataGridView.DataSource——WinForm数据绑定的黄金组合。3.3 OrderForm.cs下单逻辑与事务控制模拟支付的关键一环下单流程在OrderForm中完成核心是btnConfirmOrder_Click。它要干三件事扣减库存、生成订单、记录购票明细。必须用事务否则出现“票卖出去了但订单没生成”的脏数据private void btnConfirmOrder_Click(object sender, EventArgs e) { // 1. 获取用户选择的车次、座位、日期从界面上读取 int selectedTrainID (int)dgvTrainList.SelectedRows[0].Cells[TrainID].Value; string seatNumber txtSeatNumber.Text.Trim(); DateTime travelDate dtpOrderDate.Value.Date; // 2. 开启数据库事务 using (SqlConnection conn new SqlConnection(...)) { conn.Open(); using (SqlTransaction trans conn.BeginTransaction()) { try { // 步骤1检查该座位当天是否可用SELECT FOR UPDATE 语义此处用UPDATE尝试 string checkSql UPDATE Tickets SET Status Locked WHERE TrainID trainID AND TravelDate date AND SeatNumber seat AND Status Available; using (SqlCommand checkCmd new SqlCommand(checkSql, conn, trans)) { checkCmd.Parameters.AddWithValue(trainID, selectedTrainID); checkCmd.Parameters.AddWithValue(date, travelDate); checkCmd.Parameters.AddWithValue(seat, seatNumber); int rowsAffected checkCmd.ExecuteNonQuery(); if (rowsAffected 0) { trans.Rollback(); MessageBox.Show(该座位已被占用请重新选择, 下单失败, MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } } // 步骤2插入订单主表 string insertOrderSql INSERT INTO Orders (UserID, OrderDate, TotalAmount, Status) VALUES (userID, GETDATE(), amount, Pending); SELECT SCOPE_IDENTITY();; int orderID; using (SqlCommand orderCmd new SqlCommand(insertOrderSql, conn, trans)) { orderCmd.Parameters.AddWithValue(userID, CurrentUser.UserID); orderCmd.Parameters.AddWithValue(amount, 288.50m); // 模拟票价 orderID Convert.ToInt32(orderCmd.ExecuteScalar()); } // 步骤3插入订单明细关联Ticket string insertDetailSql INSERT INTO OrderDetails (OrderID, TicketID, Price) SELECT orderID, TicketID, 288.50m FROM Tickets WHERE TrainID trainID AND TravelDate date AND SeatNumber seat; using (SqlCommand detailCmd new SqlCommand(insertDetailSql, conn, trans)) { detailCmd.Parameters.AddWithValue(orderID, orderID); detailCmd.Parameters.AddWithValue(trainID, selectedTrainID); detailCmd.Parameters.AddWithValue(date, travelDate); detailCmd.Parameters.AddWithValue(seat, seatNumber); detailCmd.ExecuteNonQuery(); } // 全部成功提交事务 trans.Commit(); MessageBox.Show($订单创建成功订单号{orderID}, 恭喜, MessageBoxButtons.OK, MessageBoxIcon.Information); this.DialogResult DialogResult.OK; this.Close(); } catch (Exception ex) { trans.Rollback(); MessageBox.Show($下单失败{ex.Message}); } } } }关键设计点用UPDATE ... SET Status Locked替代SELECT ... FOR UPDATESQL Server不支持标准语法通过更新行来实现“抢占式锁”这是高并发场景下的常见技巧SCOPE_IDENTITY()确保拿到刚插入订单的自增ID用于关联明细表所有SqlCommand都显式传入trans参数这是事务生效的前提Rollback()在catch块里强制回滚保证数据一致性——这是你二次开发时绝对不能删的底线。4. 避坑指南那些让我重启三次SQL Server才搞懂的5个致命细节这个项目看似简单但每个模块都埋着教学项目特有的“温柔陷阱”。以下是我在帮学生调试时反复踩坑、记录、验证后总结的5条血泪经验。每一条都对应一个真实报错和可复现的操作。4.1 现象登录成功后主界面菜单栏显示乱码如“车次查询”变成“车次查询”原因项目默认编码是UTF-8但Windows系统区域设置为中文GBKWinForm窗体资源文件.resx在VS中保存时未指定编码导致编译后字符串被错误解码。解决右键项目 → “属性” → “应用程序”选项卡 → 点击“程序集信息…”按钮 → 在弹出窗口中勾选“使程序集COM可见”下方的“不指定语言”实际是强制使用Unicode或者更彻底地在LoginForm.Designer.cs顶部添加[assembly: System.Runtime.CompilerServices.StringFreezing].NET Framework 4.7.2支持。4.2 现象查询车次时DataGridView显示空白但dt.Rows.Count为0且无任何错误提示原因cmbFromStation和cmbToStation下拉框的数据源未正确绑定。项目通常在QueryTrainForm_Load里用FillComboBox()方法从Stations表加载但如果Stations表为空建库脚本执行失败或FillComboBox()方法里DisplayMember和ValueMember设置错误如写成StationID而不是StationName下拉框就显示不出数据导致查询条件为空字符串。解决在QueryTrainForm_Load断点检查cmbFromStation.Items.Count若为0手动执行SELECT * FROM Stations确认表有数据再检查cmbFromStation.DisplayMember StationName; cmbFromStation.ValueMember StationID;是否匹配数据库字段名。4.3 现象下单时提示“无法将DBNull转换为System.Int32”错误指向dgvTrainList.SelectedRows[0].Cells[TrainID].Value原因DataGridView的SelectedRows属性在多选模式下行为诡异。当用户点击行但未真正“选中”比如只触发了CellClick事件SelectedRows.Count可能为0但代码仍强行取[0]导致Cells[TrainID].Value为null即DBNull.Value。解决在取值前加双重校验if (dgvTrainList.SelectedRows.Count 0 dgvTrainList.SelectedRows[0].Cells[TrainID].Value ! null dgvTrainList.SelectedRows[0].Cells[TrainID].Value ! DBNull.Value) { int trainID (int)dgvTrainList.SelectedRows[0].Cells[TrainID].Value; // 后续逻辑 } else { MessageBox.Show(请先选择一个车次); return; }4.4 现象修改App.config连接字符串后重启VS程序仍连老数据库旧实例名原因Visual Studio 的App.config编译后会生成TrainBookingSystem.exe.config但有时VS缓存了旧配置或项目输出目录bin\Debug\下残留了旧的.config文件。解决清理解决方案菜单栏“生成” → “清理解决方案”手动删除bin\和obj\文件夹重新生成检查bin\Debug\TrainBookingSystem.exe.config文件内容是否已更新如果仍有问题在代码中临时打印连接字符串MessageBox.Show(ConfigurationManager.ConnectionStrings[TrainDBConnectionString].ConnectionString);。4.5 现象在SSMS中执行SELECT * FROM Orders能看到数据但在C#里SqlDataReader.Read()返回false原因SqlCommand的CommandType默认是Text但如果SQL语句里有分号;ADO.NET会将其视为批处理而ExecuteReader()只返回第一个结果集。项目SQL脚本里常有GO或多个SELECT但C#代码里没处理。解决检查你的查询SQL确保单个SqlCommand只执行一个独立语句。例如不要写SELECT * FROM Users; SELECT * FROM Stations; -- 错误分号分隔而应拆成两个独立的SqlCommand。如果必须用批处理改用ExecuteNonQuery()并处理多个结果集NextResult()但教学项目不推荐。5. 二次开发实战如何安全地接入微信支付模拟与增加余票预警现在系统跑通了订单只是状态为Pending没有真正的支付环节。作为二次开发的第一步我们不碰复杂的第三方SDK而是用最可控的方式在订单确认后弹出一个模拟支付窗口用户点击“支付成功”后才将订单状态更新为Paid并释放锁定的票。同时为提升用户体验我们给查票页面加一个“余票预警”——当可售座位少于5张时车次行背景变黄。5.1 支付流程改造从“立即下单”到“下单支付”两步走原流程在OrderForm中一步完成扣减和下单。我们要拆成两步第一步生成订单状态Pending第二步单独支付。步骤1修改OrderForm的下单逻辑将原btnConfirmOrder_Click中的事务代码拆成两个方法CreatePendingOrder()只插入Orders主表状态为Pending返回OrderIDProcessPayment(int orderID)在新窗体中模拟支付成功后更新Orders.Status为Paid并更新对应Tickets.Status为Sold。步骤2新增PaymentForm.cs这是一个简单的WinForm只有一个btnPayNow按钮和一个lblOrderInfo标签。在btnPayNow_Click中private void btnPayNow_Click(object sender, EventArgs e) { // 1. 更新订单状态 string updateOrderSql UPDATE Orders SET Status Paid, PaymentTime GETDATE() WHERE OrderID orderID; // 2. 更新票状态释放锁定改为已售 string updateTicketSql UPDATE Tickets SET Status Sold WHERE TicketID IN (SELECT TicketID FROM OrderDetails WHERE OrderID orderID); using (SqlConnection conn new SqlConnection(...)) { conn.Open(); using (SqlTransaction trans conn.BeginTransaction()) { try { using (SqlCommand cmd new SqlCommand(updateOrderSql, conn, trans)) { cmd.Parameters.AddWithValue(orderID, _currentOrderID); cmd.ExecuteNonQuery(); } using (SqlCommand cmd new SqlCommand(updateTicketSql, conn, trans)) { cmd.Parameters.AddWithValue(orderID, _currentOrderID); cmd.ExecuteNonQuery(); } trans.Commit(); MessageBox.Show(支付成功车票已锁定。); this.DialogResult DialogResult.OK; this.Close(); } catch (Exception ex) { trans.Rollback(); MessageBox.Show($支付失败{ex.Message}); } } } }步骤3在OrderForm中调用新流程原btnConfirmOrder_Click最后不直接关闭窗体而是int orderID CreatePendingOrder(); if (orderID 0) { PaymentForm paymentForm new PaymentForm(orderID); if (paymentForm.ShowDialog() DialogResult.OK) { this.DialogResult DialogResult.OK; this.Close(); } }这样支付就变成了一个可插拔的环节。未来要接入真实微信支付你只需重写PaymentForm的btnPayNow_Click调用微信JSAPI或Native API而主业务流完全不动。5.2 余票预警用DataGridView的CellFormatting事件动态着色QueryTrainForm的dgvTrainList是数据驱动的我们不能在SQL里硬编码颜色而要用WinForm的CellFormatting事件。在QueryTrainForm.Designer.cs的InitializeComponent()后或在LoadTrainData()方法末尾添加dgvTrainList.CellFormatting DgvTrainList_CellFormatting;然后实现事件处理private void DgvTrainList_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { // 只处理“AvailableSeats”列 if (dgvTrainList.Columns[e.ColumnIndex].Name AvailableSeats e.Value ! null e.Value ! DBNull.Value) { if (e.Value is int availableSeats availableSeats 5) { // 当前行背景变黄 dgvTrainList.Rows[e.RowIndex].DefaultCellStyle.BackColor Color.LightYellow; // 可选加个感叹号图标 if (e.RowIndex 0 e.ColumnIndex 0) { dgvTrainList.Rows[e.RowIndex].Cells[AvailableSeats].ToolTipText $仅剩{availableSeats}张票; } } } }这里的关键是e.RowIndex和e.ColumnIndex的配合。CellFormatting在每次单元格绘制时触发所以性能很好且完全解耦于数据源。5.3 一个习惯每次改完数据库必跑三遍验证脚本从那以后我每次接手一个带SQL Server的C#桌面项目都会在本地建一个verify_db.sql脚本内容固定三行-- 1. 检查库是否存在 IF NOT EXISTS (SELECT * FROM sys.databases WHERE name TrainBookingDB) PRINT ❌ 数据库不存在 -- 2. 检查关键表 IF NOT EXISTS (SELECT * FROM TrainBookingDB.sys.tables WHERE name Orders) PRINT ❌ Orders表缺失 -- 3. 检查关键数据如管理员账号 IF NOT EXISTS (SELECT * FROM TrainBookingDB.dbo.Users WHERE Username admin) PRINT ❌ 管理员账号未初始化每次改完TrainBookingDB.sql我就在SSMS里执行这个验证脚本。它不解决所有问题但能瞬间定位90%的部署失败——比如学生把建库脚本复制错了行忘了建Orders表这种低级错误靠人眼检查太慢靠脚本一秒揪出。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑