简介:面向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表是用户和车次之间的订票记录,用外键把它们关联起来,形成一对多关系。字段设计按最常见的做法来:
| 表 | 字段 | 类型 | 说明 |
|---|---|---|---|
| Users | UserID | INT 主键自增 | 用户唯一标识 |
| Users | UserName | VARCHAR(20) UNIQUE | 登录名,不能重复 |
| Users | Password | VARCHAR(64) | 密码哈希值,不存明文 |
| Users | RealName | VARCHAR(20) | 真实姓名,界面展示用 |
| Users | Phone | VARCHAR(20) | 联系手机号 |
| Trains | TrainCode | VARCHAR(10) 主键 | 车次号,如G101 |
| Trains | DepartureStation | VARCHAR(20) | 出发站 |
| Trains | ArrivalStation | VARCHAR(20) | 到达站 |
| Trains | DepartureTime | DATETIME | 发车时间 |
| Trains | ArrivalTime | DATETIME | 到达时间 |
| Trains | SeatCount | INT | 当前余票数 |
| Trains | TicketPrice | DECIMAL(7,2) | 票价 |
| Orders | OrderID | INT 主键自增 | 订单号 |
| Orders | UserID | INT 外键 | 关联Users |
| Orders | TrainCode | VARCHAR(10) 外键 | 关联Trains |
| Orders | TicketCount | INT | 购票张数 |
| Orders | OrderDate | DATETIME | 下单时间,默认GETDATE() |
| Orders | Status | VARCHAR(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语句被放到了事务外面。这一步五分钟就能跑完,但能防住最严重的数据一致性问题。
从那以后我每次写完涉及扣库存、减余量的逻辑,都会强制自己写一个这种双线程小脚本跑一遍再交出去,十次里大概有两次真能查出并发问题。代码包和数据库脚本在这个资源包里都有,建议下载下来先把这套并发验证跑通,再动自己的需求。这个习惯留给你,希望帮到你。
本文还有配套的精品资源,点击获取