☰
C#火车订票系统实战:SQL事务与DataGridView完整开发指南
2026/9/29 12:32:01 网站建设 项目流程

简介:面向C#与SQL Server初学者的完整火车订票系统项目,以窗体应用程序实现用户注册登录、车次查询、订票退票改签等核心功能,适用于课程设计、毕业设计或自学项目实战。压缩包为rar格式,共128个文件、约1.85MB,包含50个cs源码文件(业务逻辑与界面交互)、24个resx界面资源文件、24个resources资源文件,以及可执行exe、数据库相关配置和图片素材,完整覆盖界面设计、数据存储与功能实现。项目划分为用户模块(注册、登录、修改密码)、车次管理模块(管理员增删改查)、订单管理模块(订票、退票、改签),涉及SQL语句增删改查、事务处理与并发控制,并提供异常捕获与安全防护思路,有助于深入理解C#事件驱动编程和SQL Server数据库操作。已有1436人学习下载,适合希望通过完整项目提升桌面开发与数据管理能力的开发者。

1. C#火车订票系统:别被名字骗了,重点全在SQL事务和桌面端联动

C#火车订票系统大概是C#入门项目里被大家做烂了、又确实值得认真做一遍的那类东西。名字叫火车订票,实际练的是SQL Server下的表结构设计、DataGridView数据绑定和事务保证余票不超卖这三件基本功。适合正在补C#基础、刚接触数据库开发的人拿来当完整项目的起步点。做完这一套,后面再做进销存、做简单的报名系统,用的都是同一套东西。项目本身不花哨,但模块齐全,从注册登录到查询订票退票都有。照着源码跑一遍,比自己闷头看C#教程效率高得多,拿到手值得先过一遍再改。

2. 先把数据库定下来:SQL Server选型理由与三张表的建表脚本

2.1 为什么这个场景下选SQL Server而不是MySQL或SQLite

火车订票系统的数据量并不大,很多作业项目里一共也就几百条用户和订单记录。用SQLite完全装得下,但最终选SQL Server的原因不在数据量,而在C#和它的配合成本。

Visual Studio里新建项目之后,Server Explorer、SqlConnection、SqlDataAdapter这些工具链是原生支持SQL Server的,连接字符串写起来最短。MySQL要做额外驱动安装,SQLite要先去NuGet拉包。课堂和验收环境里机器普遍装了VS和SQL Server Express,省去装依赖这一步,遇到现场演示不容易翻车。

第二个理由是语法可迁移。SQL Server的T-SQL写法接近标准SQL,练会以后换到别的语言和数据库平台,核心逻辑照样用得上。SQLite有不少方言差异,新手容易把时间花在查它跟标准SQL哪里不一样。不过如果只是本机练手、特别在意零配置,换SQLite也没什么问题,主体SQL基本不用动。选型这事没有标准答案,关键是你所处环境里哪个能最快跑起来。我一般先看机器上装了什么,而不是比较哪个数据库最流行。

2.2 三张核心表的字段设计:用户表、车次表和订单表

一个完整的订票系统最少需要三类数据:谁在订、订的是什么车次、订单怎么记录。对应三张表,Users、Trains、Orders。

Users表存账号和基本资料,Trains表存车次号、出发站、到达站、时间、余票和票价,Orders表是用户和车次之间的订票记录,用外键把它们关联起来,形成一对多关系。字段设计按最常见的做法来:

表字段类型说明
UsersUserIDINT 主键自增用户唯一标识
UsersUserNameVARCHAR(20) UNIQUE登录名,不能重复
UsersPasswordVARCHAR(64)密码哈希值,不存明文
UsersRealNameVARCHAR(20)真实姓名,界面展示用
UsersPhoneVARCHAR(20)联系手机号
TrainsTrainCodeVARCHAR(10) 主键车次号,如G101
TrainsDepartureStationVARCHAR(20)出发站
TrainsArrivalStationVARCHAR(20)到达站
TrainsDepartureTimeDATETIME发车时间
TrainsArrivalTimeDATETIME到达时间
TrainsSeatCountINT当前余票数
TrainsTicketPriceDECIMAL(7,2)票价
OrdersOrderIDINT 主键自增订单号
OrdersUserIDINT 外键关联Users
OrdersTrainCodeVARCHAR(10) 外键关联Trains
OrdersTicketCountINT购票张数
OrdersOrderDateDATETIME下单时间,默认GETDATE()
OrdersStatusVARCHAR(10)有效/已退票

两个细节值得注意。SeatCount存的是当前余票数而不是总座位数,每次订票成功就减一、退票就加一,界面直接绑定显示,不用每次做减法计算。OrderDate这类时间字段建议直接用DATETIME类型,显示层再做格式化。用字符串存时间看起来简单,后面做日期范围查询会非常难受,这个坑第五节专门说。

密码字段设计成64位长度,是因为对密码做SHA256哈希后得到的是固定64位十六进制字符串。如果以后换SHA512,长度就得跟着改到128。

2.3 建库建表SQL脚本与C#连接字符串

下面这段T-SQL脚本可以直接放到SQL Server Management Studio里执行,把库和表一次建好:

CREATE DATABASE TrainBookingDB; GO USE TrainBookingDB; GO CREATE TABLE Users ( UserID INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(20) NOT NULL UNIQUE, Password NVARCHAR(64) NOT NULL, RealName NVARCHAR(20) NOT NULL, Phone NVARCHAR(20) ); CREATE TABLE Trains ( TrainCode NVARCHAR(10) PRIMARY KEY, DepartureStation NVARCHAR(20) NOT NULL, ArrivalStation NVARCHAR(20) NOT NULL, DepartureTime DATETIME NOT NULL, ArrivalTime DATETIME NOT NULL, SeatCount INT NOT NULL, TicketPrice DECIMAL(7,2) NOT NULL ); CREATE TABLE Orders ( OrderID INT IDENTITY(1,1) PRIMARY KEY, UserID INT NOT NULL FOREIGN KEY REFERENCES Users(UserID), TrainCode NVARCHAR(10) NOT NULL FOREIGN KEY REFERENCES Trains(TrainCode), TicketCount INT NOT NULL CHECK (TicketCount > 0), OrderDate DATETIME NOT NULL DEFAULT GETDATE(), Status NVARCHAR(10) NOT NULL DEFAULT '有效' );

这段脚本做了四件事:建库、切库、建用户表、建车次表、建订单表。UserID和OrderID用了IDENTITY自增,插入时不需要手动赋值。订单表的TicketCount加了CHECK约束,不允许买0张或负数,这是数据层的第二道防线,就算上层代码漏判,数据库也会拦一道。

Trains表的SeatCount字段是整个系统的核心,后续订票和退票的所有事务都围绕它做UPDATE。不要在表里同时设计总座位数和余票数字段然后靠相减得出余票,那样多写一遍逻辑不说,并发场景下还更容易出错。

连接字符串一般放在App.config里,方便部署时改配置:

<connectionStrings> <add name="TrainBooking" connectionString="Server=localhost\SQLEXPRESS;Database=TrainBookingDB;User Id=sa;Password=123456;" providerName="System.Data.SqlClient" /> </connectionStrings>

这段配置有几个容易踩的地方。Server=localhost\SQLEXPRESS是命名实例写法,机器装的是SQL Server Express一般就对得上;如果装的是企业版默认实例,要写成Server=localhost。User Id=sa要求SQL Server开启混合验证模式,否则改用Integrated Security=True走Windows身份验证,不用填密码。用sa账号并把密码明文写在配置文件里,只适合本机练手,交付验收时建议换成Windows身份验证或单独的低权限账号。

如果项目跑在.NET 6及以上版本,System.Data.SqlClient会有兼容性警告,换成Microsoft.Data.SqlClient这个NuGet包即可,代码写法几乎不变。

提示:连接字符串写在App.config里而不是new在代码中,换环境部署时只改配置不用重新编译。

3. 注册登录模块落地:参数化查询与登录态保存

3.1 注册逻辑:用户名唯一性校验与密码哈希

注册窗体的逻辑分三步:先校验两次密码是否一致,再检查用户名是否占用,最后插入新用户。新手容易把三种判断全部塞进一个方法里,看着能跑,但并发注册时会产生重复键报错。

常见做法是先查后插,同时捕获可能出现的唯一键冲突。下面代码里connStr变量就是上一节配置里那串连接字符串,从ConfigurationManager里读出来:

private void btnRegister_Click(object sender, EventArgs e) { if (txtPwd.Text != txtPwdConfirm.Text) { MessageBox.Show("两次密码不一致"); return; } string connStr = ConfigurationManager.ConnectionStrings["TrainBooking"].ConnectionString; string passwordHash = GetSha256(txtPwd.Text); // SHA256哈希,避免明文存储 using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); // 第一步:校验用户名是否已被占用 string checkSql = "SELECT COUNT(*) FROM Users WHERE UserName = @name"; using (SqlCommand checkCmd = new SqlCommand(checkSql, conn)) { checkCmd.Parameters.AddWithValue("@name", txtName.Text.Trim()); int existCount = (int)checkCmd.ExecuteScalar(); if (existCount > 0) { MessageBox.Show("用户名已存在"); return; } } // 第二步:插入新用户 string insertSql = "INSERT INTO Users(UserName, Password, RealName, Phone) VALUES(@name, @pwd, @realName, @phone)"; using (SqlCommand insertCmd = new SqlCommand(insertSql, conn)) { insertCmd.Parameters.AddWithValue("@name", txtName.Text.Trim()); insertCmd.Parameters.AddWithValue("@pwd", passwordHash); insertCmd.Parameters.AddWithValue("@realName", txtRealName.Text.Trim()); insertCmd.Parameters.AddWithValue("@phone", txtPhone.Text.Trim()); insertCmd.ExecuteNonQuery(); } } MessageBox.Show("注册成功"); } private string GetSha256(string input) { using (SHA256 sha256 = SHA256.Create()) { byte[] bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(input)); StringBuilder sb = new StringBuilder(); foreach (byte b in bytes) sb.Append(b.ToString("x2")); return sb.ToString(); } }

这段代码有两个关键点。第一,所有用户输入都通过Parameters.AddWithValue传入,没有直接拼SQL字符串。参数化不只是规范问题,从源头挡住了SQL注入——即使输入内容里带引号和百分号,也会被当作普通字符对待。

第二,密码入库前做了SHA256哈希,数据库里存的是64位十六进制字符串。如果有人拖走了数据库,看到的也不是明文密码。GetSha256方法里用了foreach逐字节转x2格式,这是最常见的哈希转字符串写法。

这里还有个细节,AddWithValue虽然名字叫"添加值",本质是创建SqlParameter对象并加入命令参数集合,参数名必须以@开头。它对性能不太敏感的场景够用,但如果数据量大,建议手动new SqlParameter并指定SqlDbType,避免类型推断不准导致查询索引失效。

3.2 登录校验:参数化查询与密码哈希比对

登录逻辑跟注册方向相反:先按用户名查出用户记录,再把数据库里的哈希和用户输入密码的哈希比对。一致才算通过。

private void btnLogin_Click(object sender, EventArgs e) { string connStr = ConfigurationManager.ConnectionStrings["TrainBooking"].ConnectionString; string sql = "SELECT UserID, Password, RealName FROM Users WHERE UserName = @name"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@name", txtLoginName.Text.Trim()); conn.Open(); using (SqlDataReader reader = cmd.ExecuteReader()) { if (reader.Read()) { string dbHash = reader["Password"].ToString(); string inputHash = GetSha256(txtLoginPwd.Text); if (dbHash == inputHash) { SessionUser.UserID = (int)reader["UserID"]; SessionUser.UserName = txtLoginName.Text.Trim(); SessionUser.RealName = reader["RealName"].ToString(); this.DialogResult = DialogResult.OK; } else { MessageBox.Show("密码错误"); } } else { MessageBox.Show("用户名不存在"); } } } }

这里用ExecuteReader逐行读取,比先查COUNT再查资料少一次数据库往返。reader.Read()第一次调用会定位到第一行,如果一条记录都没有,返回false走else分支。

密码比对是哈希后字符串精确相等。这也意味着系统不能"找回原密码",只能重置密码。别觉得这样不方便,存明文密码才是更大的安全隐患。

3.3 登录态保存:静态类比到处传参更省事

登录成功后,主窗口和订票窗口都要知道当前用户是谁。一种做法是把用户ID和姓名通过构造函数传过去,但窗口一多,构造函数参数列表会越来越长。更省事的是定义静态类:

public static class SessionUser { public static int UserID { get; set; } public static string UserName { get; set; } public static string RealName { get; set; } public static void Clear() { UserID = 0; UserName = string.Empty; RealName = string.Empty; } }

静态类在整个程序生命周期里只有一个副本,登录成功时赋值,订票窗口随时能读SessionUser.UserID把订单关联到当前用户,主窗口的欢迎语也能直接显示SessionUser.RealName。退出登录时调Clear()清空,避免状态残留到下一次登录。

这正是理解C#语言里static关键字的一个实际案例:静态成员属于类型而不属于某个实例。等后面学到C#委托和事件,可以用事件在登录成功后通知主窗体刷新状态,那是更松耦合的写法。但入门阶段先靠静态类把流程跑通,等这个项目完整做完一遍再回头改事件也不迟。

4. 车次查询与订票下单:从DataGridView绑定到事务扣减余票

4.1 车次查询:参数化LIKE查询与DataGridView绑定

主窗体加载和用户点击查询按钮时,执行的都是同一段查询逻辑。最省事的写法是封装一个方法,传入出发站和到达站,返回DataTable:

public DataTable SearchTrains(string from, string to) { string connStr = ConfigurationManager.ConnectionStrings["TrainBooking"].ConnectionString; string sql = @"SELECT TrainCode, DepartureStation, ArrivalStation, DepartureTime, ArrivalTime, SeatCount, TicketPrice FROM Trains WHERE DepartureStation LIKE @from AND ArrivalStation LIKE @to ORDER BY DepartureTime"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) using (SqlDataAdapter adapter = new SqlDataAdapter(cmd)) { cmd.Parameters.AddWithValue("@from", "%" + from + "%"); cmd.Parameters.AddWithValue("@to", "%" + to + "%"); DataTable dt = new DataTable(); adapter.Fill(dt); return dt; } }

拿到DataTable之后,赋给DataGridView的DataSource属性,界面就会根据列名自动生成列:

dgvTrains.DataSource = SearchTrains(txtFrom.Text.Trim(), txtTo.Text.Trim());

这一步不需要手写for循环往DataGridView里塞数据。DataGridView绑定的是一组数据集合,DataTable本质是一张带列名和数据类型的内存表,跟数组、集合的区别在于它有"表的形状",能直接跟表格控件对上。C#数组长度固定、没有列名概念,List集合有增删方法但也没有列名信息,直接绑定表格会显示成没有列头的数据。

LIKE模糊匹配是这种查询的关键。参数里主动拼了%通配符,用户在界面输入"北京"也能匹配到"北京南"。这就是为什么文本框内容要做Trim(),否则用户不小心打了尾空格,LIKE '%北京 %'什么都匹配不到,翻车现场多半是这么来的。

4.2 订票下单:事务、行锁和余票扣减的先后顺序

订票是整个系统的核心业务。流程本身不复杂:用户选中车次、填写张数、点订票,系统检查余票够不够,够了扣减余票并插入订单记录。难点在检查和扣减必须是原子操作,否则两个用户同时买票,都读到余票还剩2,各自扣减后逻辑上就卖超了。

我第一次做这个功能就直接两条SQL顺序执行,生产上那就是典型的并发翻车。正确做法是把检查和扣减放进同一个SqlTransaction事务,用WITH(ROWLOCK, UPDLOCK)提前锁住车次那行:

private void btnBook_Click(object sender, EventArgs e) { if (dgvTrains.CurrentRow == null) { MessageBox.Show("请先选择车次"); return; } string connStr = ConfigurationManager.ConnectionStrings["TrainBooking"].ConnectionString; string trainCode = dgvTrains.CurrentRow.Cells["TrainCode"].Value.ToString(); int ticketCount = (int)numericUpDown1.Value; using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); SqlTransaction tran = conn.BeginTransaction(); try { // 锁行读取当前余票 string lockSql = @"SELECT SeatCount FROM Trains WITH (ROWLOCK, UPDLOCK) WHERE TrainCode = @code"; using (SqlCommand lockCmd = new SqlCommand(lockSql, conn, tran)) { lockCmd.Parameters.AddWithValue("@code", trainCode); int seatCount = (int)lockCmd.ExecuteScalar(); if (seatCount < ticketCount) { tran.Rollback(); MessageBox.Show("余票不足"); return; } } // 扣减余票 string updateSql = @"UPDATE Trains SET SeatCount = SeatCount - @count WHERE TrainCode = @code"; using (SqlCommand updateCmd = new SqlCommand(updateSql, conn, tran)) { updateCmd.Parameters.AddWithValue("@count", ticketCount); updateCmd.Parameters.AddWithValue("@code", trainCode); updateCmd.ExecuteNonQuery(); } // 写入订单表 string insertSql = @"INSERT INTO Orders(UserID, TrainCode, TicketCount) VALUES(@userId, @code, @count)"; using (SqlCommand insertCmd = new SqlCommand(insertSql, conn, tran)) { insertCmd.Parameters.AddWithValue("@userId", SessionUser.UserID); insertCmd.Parameters.AddWithValue("@code", trainCode); insertCmd.Parameters.AddWithValue("@count", ticketCount); insertCmd.ExecuteNonQuery(); } tran.Commit(); MessageBox.Show("订票成功"); RefreshTrainList(); } catch (Exception ex) { tran.Rollback(); MessageBox.Show("订票失败:" + ex.Message); } } }

这段代码的唯一要点是:所有SqlCommand都挂上了tran参数,最后统一Commit或Rollback。

WITH(ROWLOCK, UPDLOCK)是关键。ROWLOCK把锁粒度缩小到行,避免锁整张表影响其他车次的查询;UPDLOCK是更新锁,读取时就把这行锁住,阻止其他事务在检查和扣减之间插队。不加这个提示,两个事务能同时读到同一个SeatCount值,余票就会超卖。

事务顺序也有讲究:先锁行读余票,再UPDATE,最后INSERT。Commit之后锁才释放,界面上同步刷新余票。RefreshTrainList方法重新执行一次查询并绑回DataGridView,让界面显示扣减后的最新余票。

提示:不要在事务里弹MessageBox或者做耗时操作。锁会一直持有到事务结束,如果弹窗等用户点确定,其他订票请求全部会被卡住。

4.3 退票操作:订单状态更新与余票回补

退票的逻辑和订票正好相反:把Orders表里对应记录的Status改成已退票,再把Trains表里该车次的SeatCount加回去。这两步也必须在同一个事务里,否则会出现订单已退但余票没加回去的数据不一致。

退票的UPDATE要小心重复退票问题。解决办法是在SQL条件里加上Status='有效',这样只有状态为有效的订单能被更新:

private void btnRefund_Click(object sender, EventArgs e) { string connStr = ConfigurationManager.ConnectionStrings["TrainBooking"].ConnectionString; string sql = @"UPDATE Orders SET Status = '已退票' WHERE OrderID = @orderId AND Status = '有效'"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@orderId", orderId); conn.Open(); int affected = cmd.ExecuteNonQuery(); if (affected == 0) { MessageBox.Show("该订单已退过票"); return; } } }

注意这里有一个顺序问题:UpdateOrders先执行,然后还要把余票加回去。如果不放在事务里,订单状态改了一半、余票没回补,这种状态很难排查。退票和订票一样,操作要配事务。

实际项目里还有一种常见做法:在应用层判断订单状态,让"已退票"的订单在界面上直接隐藏退票按钮。但数据层用WHERE Status='有效'兜底更保险,UI漏判了数据库也会挡一道。

4.4 我的订单查询:JOIN两张表取完整车次信息

"我的订单"功能要把订单表和车次表拼起来,否则订单行里只有一个TrainCode外键,界面上没法显示出发站、到达站和时间:

public DataTable GetMyOrders(int userId) { string connStr = ConfigurationManager.ConnectionStrings["TrainBooking"].ConnectionString; string sql = @"SELECT o.OrderID, t.TrainCode, t.DepartureStation, t.ArrivalStation, t.DepartureTime, o.TicketCount, o.OrderDate, o.Status FROM Orders o INNER JOIN Trains t ON o.TrainCode = t.TrainCode WHERE o.UserID = @userId ORDER BY o.OrderDate DESC"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) using (SqlDataAdapter adapter = new SqlDataAdapter(cmd)) { cmd.Parameters.AddWithValue("@userId", userId); DataTable dt = new DataTable(); adapter.Fill(dt); return dt; } }

这里用INNER JOIN把订单表和车次表拼起来,订单表存的是TrainCode外键而不是冗余的出发站、到达站。这符合第二范式:车次信息只存在于Trains表,订单表通过外键引用,避免同一条车次信息被重复存储。

从DataGridView选中行取数据的标准写法,是读CurrentRow的Cells集合并按列名取值,比如Cells["TrainCode"]。列名要和上层绑定DataGridView时看到的列对应,如果手动配置过列并设置了DataPropertyName,这里取的是DataPropertyName对应的列名,不是显示列的HeaderText。

5. 常见问题排查:连接失败、日期丢失和并发扣减的五个坑

5.1 现象:连接字符串看着没问题,还是报无法连接到数据库

这个报错信息是最常见的翻车点。排查分三步走。

先确认SQL Server服务有没有启动,打开服务管理器services.msc,找到SQL Server的实例名对应的服务,状态要显示"正在运行"。

再看实例名是否匹配。Server=localhost\SQLEXPRESS连接的是命名实例,如果机器装的是企业版默认实例,就要写成Server=localhost。有些机器上localhost会被解析成IPv6地址,而SQL Server只监听了IPv4,这种玄学问题把localhost换成127.0.0.1通常就通了。

最后看验证模式。报"用户sa登录失败"而不是"无法连接"时,要确认SQL Server配置管理器里启用了混合验证模式,并且sa账号没有禁用。

5.2 现象:库里有车次,按站名查询却一条都查不到

最常见原因是精确匹配和模糊匹配用混了。如果SQL写的是WHERE DepartureStation = @station,而界面输入的是"北京",库表里的值却是"北京南",永远匹配不上。

解决方法是统一用LIKE模糊查询,在参数两端拼上%,把精确等于改成包含关系。更规范的方案是建一张站点表存标准站名,出发站和到达站都改成站点ID关联,这样上游录入和下游查询用同一份数据,不存在叫法不一致的问题。

5.3 现象:两个用户同时下单,余票从2变成1而不是从1变成0

这是并发事务的教科书问题。两个会话同时执行SELECT SeatCount,都读到2,然后都执行UPDATE Trains SET SeatCount = SeatCount - 1,最终余票变成0,但产生了两个订单,超卖了。

解决办法就是第四节里写的:在SELECT语句后加WITH(ROWLOCK, UPDLOCK),让第一个事务锁住这一行,第二个事务只能等锁释放后再读,读到的新余票是1,一核对余票不足就走Rollback。面试里问"怎么避免库存超卖",答这个机制基本就是标准答案。

提示:UPDLOCK锁会一直持有到Commit或Rollback,所以事务里的业务逻辑要尽量精简,不要在事务里做网络请求或者等待用户输入。

5.4 现象:DataGridView重新赋值DataSource后,界面纹丝不动

原因多半有两种。一是AutoGenerateColumns设为False且没有手动配置列,新数据源的列对不上;二是绑定的BindingSource实例没换,数据源换了但界面没收到刷新通知。

我一般用BindingSource包一层:

this.bindingSource1.DataSource = SearchTrains(txtFrom.Text.Trim(), txtTo.Text.Trim()); this.dgvTrains.DataSource = this.bindingSource1;

绑定之后,任何数据源变化都通过bindingSource1统一通知界面。需要刷新时调bindingSource1.ResetBindings(false),或者直接重新给DataSource赋值。这比直接操作DataGridView的DataSource稳定得多,也是WinForms项目里常见的刷新套路。

如果ResetBindings都不起作用,检查是不是在异步方法里更新了数据源却没回到UI线程。跨线程更新WinForms控件要用Invoke包一层委托。

5.5 现象:按日期查订单查不出来,或者时间显示成一大串

SQL Server的DATETIME字段本身带时分秒。如果查询条件写成WHERE DepartureTime = '2024-03-05',这个字面量会被解析成2024-03-05 00:00:00,当天所有车次的发车时间都不等于零点,一条都查不出来。

正确做法是范围查询:

WHERE DepartureTime >= @start AND DepartureTime < @end

@start传当天零点,@end传次日零点。这是处理DATETIME字段的通用写法,任何带时间的字段做日期过滤都应该用这个半开区间。

显示端如果不想让DataGridView展示时分秒,可以在CellFormatting事件里做格式化:e.Value = DateTime.Parse(...).ToString("yyyy-MM-dd HH:mm")。显示格式不影响数据库里存的值,两者分离更干净。

6. 改成三层结构收尾:DAL取数、BLL管业务、UI只画界面

6.1 快速重构:把连接和SQL收进DAL

现在代码能跑,但所有按钮事件里直接new SqlConnection的写法,过一个月再看会很难改。常见做法是拆三层:DAL层只做数据库访问,方法返回DataTable或实体;BLL层做业务判断和事务控制;UI层只负责把数据绑到控件上。

拿订票逻辑举例,从DAL层开始切入:

// DAL层:负责执行SQL,连接和事务由调用方传入 public int UpdateSeatCount(string trainCode, int count, SqlConnection conn, SqlTransaction tran) { string sql = "UPDATE Trains SET SeatCount = SeatCount - @count WHERE TrainCode = @code"; using (SqlCommand cmd = new SqlCommand(sql, conn, tran)) { cmd.Parameters.AddWithValue("@count", count); cmd.Parameters.AddWithValue("@code", trainCode); return cmd.ExecuteNonQuery(); } }

这样调用方不需要关心SQL怎么拼,DAL方法接收事务参数,复用性很强。BLL的订票方法负责开事务、组装调用多个DAL方法、失败时回滚。UI的按钮事件里只有一行BLL调用和弹窗提示。

好多同类项目的源码到交付那天还是Form里塞了上千行代码,一半是SQL一半是控件操作。三层结构不一定要引入任何框架,就是纯代码分文件。收益很直接:以后SQL写错了只看DAL层,不用在Form的几百行事件里翻找。

6.2 验证并发安全:开两个线程同时订票

重构完成之后,务必做一次并发验证。最简单的方法是开一个小控制台工程,两个线程同时去订同一车次的票:

Task.Run(() => BookTicket("G101", 1, "user1")); Task.Run(() => BookTicket("G101", 1, "user2")); void BookTicket(string code, int count, string user) { // 不走UI,直接调用BLL方法 bool ok = new BookingService().PlaceOrder(code, count, user); Console.WriteLine($"{user} 订票结果:{ok}"); }

如果SeatCount从2被正确扣到0且只有一个订单成功,说明事务锁生效了;如果两个都返回成功,就回去检查是不是忘了加WITH(UPDLOCK),或者UPDATE语句被放到了事务外面。这一步五分钟就能跑完,但能防住最严重的数据一致性问题。

从那以后我每次写完涉及扣库存、减余量的逻辑,都会强制自己写一个这种双线程小脚本跑一遍再交出去,十次里大概有两次真能查出并发问题。代码包和数据库脚本在这个资源包里都有,建议下载下来先把这套并发验证跑通,再动自己的需求。这个习惯留给你,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询