简介:一款基于C# Winform的影院售票管理系统,附带完整数据库文件,开发环境为VS2012与SQL Server 2012;系统面向C#窗体应用学习者、高校课程设计及毕业设计人群,可帮助快速上手多窗体管理类项目。数据库文件只需在SQL Server中附加即可运行,免去手动编写建库脚本的繁琐;功能模块覆盖登录认证、售票操作、影片类型维护、影片信息管理、放映厅管理、用户管理等常见业务,并通过数据访问辅助类与实体层实现分层组织,便于理解与二次开发。压缩包共93个文件,总大小4.13MB,以40个C#源文件为主体,辅以界面资源、已编译程序、数据库文件及解决方案等,打开后即可查看完整窗体界面并运行。当前已有1405人学习下载,适合用来掌握WinForm多窗体的设计思路、数据库附加方法及基本增删改查,也可直接作为课设或毕设的基础框架。
1. 这套影院售票系统,先吃透“数据库齐全”四个字
C# Winform影院售票管理系统这类课程设计每年都有大量同学在做,多数人把精力花在窗体美化、按钮动画上,最后交上去一打开数据库只有三张表,老师问一句“两台电脑同时买同一个座位怎么办”就哑火。而这个标题里真正值钱的部分恰恰是“数据库齐全”:完整的表结构、存储过程、视图、事务处理一应俱全,把影片管理、场次编排、选座锁座、订单支付、退票和会员充值整条业务链路都收进了数据层。
这套方案适合正在做数据库课程设计或毕业设计的人,也适合刚入行、想搞明白 Winform 项目怎么和 SQL Server 配合的开发者。新手照着目录把库建起来就能跑,熟手可以重点看锁座和并发那几段。先说结论:让这套系统不被问倒的,是数据库设计,不是窗体代码。下文从数据库开始拆。
2. 数据库先行:从购票流程反推 11 张表和 4 个存储过程
2.1 表结构为什么这样分:从一次购票流程反推数据模型
先别急着建表,把业务走一遍:用户打开系统,查影片列表,选择一个场次,看到座位图,点一个空座,系统锁定该座,用户下单支付,系统出票。如果用户是会员,还要扣余额、记充值记录。管理员要维护影片和影厅,退票时要改状态、写日志。把这些动作拆开,每步需要的数据就是一张表。
这个项目我一般拆出 11 张表:
| 表名 | 职责 | 关键字段 |
|---|---|---|
| User | 系统登录用户 | UserId, UserName, Password, Role |
| Movie | 影片基础信息 | MovieId, MovieName, Duration, Price, Poster |
| Hall | 影厅 | HallId, HallName, RowCount, ColCount |
| HallSeat | 影厅物理座位 | HallSeatId, HallId, RowNo, ColNo |
| Schedule | 场次 | ScheduleId, MovieId, HallId, StartTime, EndTime |
| ScheduleSeat | 场次座位状态 | ScheduleSeatId, ScheduleId, HallSeatId, Status, LockTime, LockUserId |
| Orders | 订单主表 | OrderId, OrderNo, UserId, MemberId, TotalAmount, Status, CreateTime |
| OrderDetail | 订单明细 | OrderDetailId, OrderId, ScheduleSeatId, Price |
| Member | 会员 | MemberId, UserId, Balance, CreateTime |
| MemberRecharge | 会员充值记录 | RechargeId, MemberId, Amount, RechargeTime |
| OperationLog | 操作日志 | LogId, UserId, Action, TargetId, LogTime |
核心是 ScheduleSeat 和 Orders 两张。ScheduleSeat 的 Status 字段用 0 表示空闲、1 表示已售、2 表示锁定,它是防超卖的“闸门”;Orders 和 OrderDetail 分离,是为了支持一个订单里买多张票(虽然影院场景通常一单一座,但拆开结构更规范,后面做统计报表也顺手)。数据库课程设计答辩时,老师最爱问“订单和场次座位怎么关联的”,你直接指 OrderDetail.ScheduleSeatId 就能说清楚。
选型上我推荐 SQL Server,因为 C# 和它同属微软生态,SqlConnection、SqlDataAdapter 都是原生支持,SQL Server Express 免费,SSMS 图形化工具也比 SQLite 和 MySQL 的周边工具更顺手,答辩演示时老师用 SSMS 直接查库也方便。如果非要用 SQLite,连接字符串和类型映射会有细微差异,下面所有存储过程的写法都要改,不建议在课程设计阶段给自己加这个负担。
2.2 建库脚本:从零到能跑的完整 SQL
新建数据库和核心表。下面这段 SQL 是完整的可执行脚本,包含了用户、影片、影厅、场次、场次座位、订单、订单明细、会员、充值记录、日志共 11 张表的核心结构。
CREATE DATABASE MovieTicketing; GO USE MovieTicketing; GO CREATE TABLE dbo.[User] ( UserId INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(50) NOT NULL, Password NVARCHAR(64) NOT NULL, Role INT NOT NULL DEFAULT 0 -- 0=普通用户 1=管理员 ); CREATE TABLE dbo.Movie ( MovieId INT IDENTITY(1,1) PRIMARY KEY, MovieName NVARCHAR(100) NOT NULL, Duration INT NOT NULL, -- 片长,单位分钟 Price DECIMAL(10,2) NOT NULL, -- 基础票价 Poster NVARCHAR(200) NULL ); CREATE TABLE dbo.Hall ( HallId INT IDENTITY(1,1) PRIMARY KEY, HallName NVARCHAR(50) NOT NULL, RowCount INT NOT NULL, -- 排数 ColCount INT NOT NULL -- 每排列数 ); CREATE TABLE dbo.HallSeat ( HallSeatId INT IDENTITY(1,1) PRIMARY KEY, HallId INT NOT NULL, RowNo INT NOT NULL, ColNo INT NOT NULL ); CREATE TABLE dbo.Schedule ( ScheduleId INT IDENTITY(1,1) PRIMARY KEY, MovieId INT NOT NULL, HallId INT NOT NULL, StartTime DATETIME NOT NULL, EndTime DATETIME NOT NULL ); CREATE TABLE dbo.ScheduleSeat ( ScheduleSeatId INT IDENTITY(1,1) PRIMARY KEY, ScheduleId INT NOT NULL, HallSeatId INT NOT NULL, Status INT NOT NULL DEFAULT 0, -- 0=空闲 1=已售 2=锁定 LockTime DATETIME NULL, -- 锁定时间 LockUserId INT NULL ); CREATE TABLE dbo.Orders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL, UserId INT NOT NULL, MemberId INT NULL, TotalAmount DECIMAL(10,2) NOT NULL, Status INT NOT NULL DEFAULT 0, -- 0=未支付 1=已支付 2=已退票 CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE dbo.OrderDetail ( OrderDetailId INT IDENTITY(1,1) PRIMARY KEY, OrderId INT NOT NULL, ScheduleSeatId INT NOT NULL, Price DECIMAL(10,2) NOT NULL ); CREATE TABLE dbo.Member ( MemberId INT IDENTITY(1,1) PRIMARY KEY, UserId INT NOT NULL, Balance DECIMAL(10,2) NOT NULL DEFAULT 0, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE dbo.MemberRecharge ( RechargeId INT IDENTITY(1,1) PRIMARY KEY, MemberId INT NOT NULL, Amount DECIMAL(10,2) NOT NULL, RechargeTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE dbo.OperationLog ( LogId INT IDENTITY(1,1) PRIMARY KEY, UserId INT NOT NULL, Action NVARCHAR(100) NOT NULL, TargetId INT NULL, LogTime DATETIME NOT NULL DEFAULT GETDATE() );几个容易踩的参数点:金额一律用DECIMAL(10,2),别用FLOAT,浮点数做累加会出现 0.1 + 0.2 = 0.30000000000000004 这类问题,会员余额会越算越不对。时间字段用DATETIME,不要用VARCHAR存字符串,否则排序和区间查询都会是灾难。[User]表名要加方括号,因为USER是 SQL Server 的保留关键字,不加括号建表直接报错。
2.3 存储过程加视图:把选座和下单的复杂逻辑收进数据库
表建好后,把关键业务逻辑写成存储过程,这是“数据库齐全”的第二个落脚点。一个完整的影院售票库,我一般放 4 个存储过程加 2 个视图:sp_Login 做登录校验,sp_LockSeat 做锁座,sp_CreateOrder 做下单,sp_RefundTicket 做退票;视图 v_AvailableSeats 查某场次剩余座位,v_SalesReport 做票房统计。
锁座是防超卖的第一道闸,存储过程写法:
CREATE PROCEDURE dbo.sp_LockSeat @ScheduleSeatId INT, @UserId INT, @LockMinutes INT = 5 AS BEGIN SET NOCOUNT ON; -- 先释放所有超过锁定时间的座位 UPDATE dbo.ScheduleSeat SET Status = 0, LockUserId = NULL, LockTime = NULL WHERE Status = 2 AND LockTime < DATEADD(MINUTE, -@LockMinutes, GETDATE()); -- 用行锁 + 状态位实现原子锁定 UPDATE dbo.ScheduleSeat WITH (UPDLOCK, ROWLOCK) SET Status = 2, LockTime = GETDATE(), LockUserId = @UserId WHERE ScheduleSeatId = @ScheduleSeatId AND Status = 0; IF @@ROWCOUNT > 0 SELECT 1 AS Result; ELSE SELECT 0 AS Result; END GO这里最关键的是WITH (UPDLOCK, ROWLOCK)。它告诉 SQL Server:更新这一行时,对该行加更新锁而不是整表锁。两个用户同时选同一个座时,第二个 UPDATE 会阻塞,等第一个事务提交后,第二个事务再看 Status 已经不是 0,受影响行数为 0,就返回失败。这个逻辑放在存储过程里,比在 C# 代码里先 SELECT 再 UPDATE 靠谱得多,因为数据库的行锁机制保证了这个判断和更新是一体的。
下单存储过程要把验证座位、写订单、改座位状态、扣会员余额四件事放进一个事务:
CREATE PROCEDURE dbo.sp_CreateOrder @OrderNo VARCHAR(32), @UserId INT, @ScheduleSeatId INT, @Price DECIMAL(10,2), @MemberId INT = NULL AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; -- 1. 校验座位必须是被当前用户锁定的 IF NOT EXISTS (SELECT 1 FROM dbo.ScheduleSeat WHERE ScheduleSeatId = @ScheduleSeatId AND Status = 2 AND LockUserId = @UserId) BEGIN ROLLBACK; SELECT -1 AS Result; -- 座位状态非法 RETURN; END -- 2. 插入订单主表和明细表 INSERT INTO dbo.Orders (OrderNo, UserId, MemberId, TotalAmount) VALUES (@OrderNo, @UserId, @MemberId, @Price); DECLARE @OrderId INT = SCOPE_IDENTITY(); INSERT INTO dbo.OrderDetail (OrderId, ScheduleSeatId, Price) VALUES (@OrderId, @ScheduleSeatId, @Price); -- 3. 座位状态:锁定变已售 UPDATE dbo.ScheduleSeat SET Status = 1, LockUserId = NULL, LockTime = NULL WHERE ScheduleSeatId = @ScheduleSeatId; -- 4. 会员扣款 IF @MemberId IS NOT NULL UPDATE dbo.Member SET Balance = Balance - @Price WHERE MemberId = @MemberId; COMMIT; SELECT 1 AS Result; END TRY BEGIN CATCH ROLLBACK; SELECT -2 AS Result; -- 未知异常 END CATCH END GOSCOPE_IDENTITY()用来拿刚插入订单的自增主键,比@@IDENTITY更安全,因为在触发器存在时@@IDENTITY可能拿到错误的值。存储过程返回值用 -1、-2 区分业务失败和系统异常,C# 端调用时就不用去猜异常原因了。视图 v_AvailableSeats 就是把 ScheduleSeat 和 HallSeat 连接后筛 Status=0 的行,后面程序里查剩余座位只查这个视图就行。
3. Winform 工程怎么搭:两层架构、连接配置和登录主窗体
3.1 为什么推荐两层而不是三层
很多 Winform 项目案例一上来就搞 Model、DAL、BLL、UI 四层,对影院售票这种规模的系统来说,这是给自己找麻烦。课程设计阶段,我建议用两层:界面层(Forms)加数据访问层(DAL)。界面层负责窗体展示和简单校验,DAL 负责所有 SQL 和存储过程调用。
MovieTicketing.sln ├── MovieTicketing.sln ├── App.config ├── Dal │ ├── SqlHelper.cs -- 连接管理、参数封装 │ ├── MovieDal.cs -- 影片增删改查 │ ├── ScheduleDal.cs -- 场次查询、编排 │ ├── SeatDal.cs -- 调 sp_LockSeat / sp_CreateOrder │ ├── UserDal.cs -- 调 sp_Login │ └── ReportDal.cs -- 票房统计、日志查询 ├── Forms │ ├── LoginForm.cs -- 登录 │ ├── MainForm.cs -- MDI 主窗体 │ ├── MovieManageForm.cs -- 影片管理 │ ├── ScheduleManageForm.cs -- 场次编排 │ ├── SeatSelectForm.cs -- 选座购票 │ └── MemberForm.cs -- 会员充值 └── Models ├── Movie.cs └── Schedule.csModels 目录只放几个纯数据类,比如 Movie、Schedule,因为 DataGridView 绑定对象列表时比绑 DataTable 更直观,你才能在winform datagridview里把 0 和 1 状态列显示成 CheckBox 或自定义文本。当业务逻辑复杂到 DAL 里塞不下时再引入 BLL,不要在项目第一天就把它建出来。我见过不少同学建了 BLL 层,方法却全是空壳调 DAL,这就是过度设计的翻车现场。
3.2 连接字符串:放配置文件,别写死在代码里
连接字符串必须放 App.config,否则换一台电脑部署就要重新编译。尤其新版 SqlClient 对加密连接默认值改了,本地开发时最容易在这里踩坑。
<?xml version="1.0" encoding="utf-8" ?> <configuration> <startup> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" /> </startup> <connectionStrings> <add name="MovieDb" connectionString="Server=.;Database=MovieTicketing;User Id=sa;Password=你的密码;Encrypt=False;TrustServerCertificate=True;Pooling=True;Connection Timeout=10;" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>Server=.表示本机默认实例,如果装了命名实例就写Server=你的计算机名\SQLEXPRESS。Encrypt=False和TrustServerCertificate=True这两个参数必须成对出现,新版 SqlClient 默认 Encrypt=True,而 Express 版 SQL Server 的证书又是自签名的,不加这两句就会在运行时抛“证书链是由不受信任的颁发机构颁发的”错误,你排查半天还以为密码写错了。
数据访问层的 SqlHelper 做一个最小实现就够了:
public static class SqlHelper { private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["MovieDb"].ConnectionString; public static int ExecuteNonQuery(string sql, params SqlParameter[] ps) { using var conn = new SqlConnection(ConnStr); using var cmd = new SqlCommand(sql, conn); if (ps != null) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } public static DataTable ExecuteDataTable(string sql, params SqlParameter[] ps) { using var conn = new SqlConnection(ConnStr); using var da = new SqlDataAdapter(sql, conn); if (ps != null) da.SelectCommand.Parameters.AddRange(ps); var dt = new DataTable(); da.Fill(dt); return dt; } }所有查询和更新都走这两个方法,统一管理连接,避免每个窗体里都 new 一个 SqlConnection,关闭时又忘记 Dispose。连接池Pooling=True保持默认就行,它让频繁打开关闭连接的成本变得很低,这是数据库连接方案里性价比最高的优化,不需要额外写缓存。
3.3 登录与主窗体:从 DataTable 到控件的绑定套路
登录窗体调用 sp_Login 存储过程,不做字符串拼接 SQL,这是防止 SQL 注入的基础习惯。
private void btnLogin_Click(object sender, EventArgs e) { string userName = txtUserName.Text.Trim(); string pwd = txtPassword.Text; using var conn = new SqlConnection(SqlHelper.ConnStr); using var cmd = new SqlCommand("sp_Login", conn) { CommandType = CommandType.StoredProcedure }; cmd.Parameters.AddWithValue("@UserName", userName); cmd.Parameters.AddWithValue("@Password", pwd); conn.Open(); var reader = cmd.ExecuteReader(); if (reader.Read()) { CurrentUser.UserId = Convert.ToInt32(reader["UserId"]); CurrentUser.UserName = reader["UserName"].ToString(); CurrentUser.Role = Convert.ToInt32(reader["Role"]); var mainForm = new MainForm(); mainForm.Show(); this.Hide(); } else { MessageBox.Show("用户名或密码错误"); } }密码不要明文存在数据库里,至少做一层哈希,最简单的做法是用 SHA256 对密码加盐后存储,登录时同样哈希再比对。CurrentUser是一个静态类,存放当前登录用户信息,类似 Web 端的 Session,Winform 单机模式下常用这种套路。主窗体用 MDI 容器,菜单项点击时创建子窗体并设置MdiParent,这样所有子窗体共用主窗体的菜单和状态栏,界面结构清晰,也方便后续加权限控制:管理员能看到影片管理和场次编排菜单,普通用户只能看到购票和会员模块。
4. 售票核心链路:选座、锁座、下单,三步各自防并发
4.1 选座界面:DataGridView 做座位图的关键属性
影院座位图最直接的实现就是用 DataGridView 当画布,每个单元格代表一个座。设置几个关键属性就能获得接近原生控件的效果。
dgvSeats.ReadOnly = true; dgvSeats.RowHeadersVisible = false; dgvSeats.ColumnHeadersVisible = false; dgvSeats.AllowUserToAddRows = false; dgvSeats.AllowUserToDeleteRows = false; dgvSeats.SelectionMode = DataGridViewSelectionMode.CellSelect; dgvSeats.MultiSelect = false; dgvSeats.CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal; dgvSeats.BackgroundColor = Color.FromArgb(30, 30, 30);座位图的尺寸由影厅的 RowCount 和 ColCount 决定,动态生成列,不要在设计器里拖死。每个单元格的 Tag 存 ScheduleSeatId,CellClick 事件里通过 Tag 拿到座位 ID,再去调锁座存储过程。这个方案比用 PictureBox 画座位图简单很多,而且在 winform 界面美化上改起来也容易:可选座单元格背景设浅绿色,已售设灰色,锁定设深红色,观众一眼就能分辨。
座位状态映射建议用表格定义清楚:
| 状态值 | 含义 | 单元格颜色 | 可选操作 |
|---|---|---|---|
| 0 | 空闲 | 浅绿 | 可选座 |
| 1 | 已售 | 灰色 | 不可操作 |
| 2 | 锁定中 | 深红 | 本人锁定可下单,否则为他人占用 |
4.2 锁座不是改界面颜色,是改数据库行
锁座最容易翻车的写法是:先查一遍座位状态存到 List 里,界面点击后只改内存里的状态。这在单机演示没问题,一旦两个窗口或两台电脑同时操作,内存状态不同步,就出现了两个人都能看到“可选”并同时下单的经典超卖事故。数据库的行锁才是唯一的裁决依据。
C# 端调用 sp_LockSeat 的代码:
using var conn = new SqlConnection(SqlHelper.ConnStr); using var cmd = new SqlCommand("sp_LockSeat", conn) { CommandType = CommandType.StoredProcedure }; cmd.Parameters.Add(new SqlParameter("@ScheduleSeatId", SqlDbType.Int) { Value = seatId }); cmd.Parameters.Add(new SqlParameter("@UserId", SqlDbType.Int) { Value = CurrentUser.UserId }); cmd.Parameters.Add(new SqlParameter("@LockMinutes", SqlDbType.Int) { Value = 5 }); conn.Open(); int result = (int)cmd.ExecuteScalar(); if (result == 1) { // 锁定成功,刷新该单元格颜色 } else { MessageBox.Show("该座位刚刚被别人选中,请换一个座位"); RefreshSeatStatus(); }注意这里用了SqlParameter并显式指定SqlDbType.Int,不要为了省事用AddWithValue。AddWithValue在字段是 DECIMAL 或 VARCHAR 时可能推断出错误类型,导致索引失效或隐式转换,这是 C# 数据访问里一个很隐蔽的性能坑。锁存成功后,lockTime字段记录了时间,存储过程里已经处理了超时释放,所以即使某个用户选了座不付钱,5 分钟后这个座位会自动回到空闲状态,这就是“后悔药”机制。
客户端这边还要做一个定时刷新,因为座位状态随时可能被别人改掉。用System.Windows.Forms.Timer每 15 秒调一次RefreshSeatStatus(),只更新状态变化的单元格,避免整表刷新导致闪烁。如果觉得 Timer 不够灵活,可以用async/await配合Task.Delay做循环刷新,这是c# task用法在 Winform 里比较合适的场景,注意别在子线程里直接改 UI。
4.3 生成订单与出票:一个事务里做四件事
下单时不能再靠界面判断“这个座位我锁过了”,要再问一次数据库,把状态校验和业务写入放在同一个事务里。sp_CreateOrder 已经把这些做完了,C# 端只需要调用并处理返回码。
private void btnPay_Click(object sender, EventArgs e) { if (selectedSeatId == 0) return; string orderNo = DateTime.Now.ToString("yyyyMMddHHmmss") + CurrentUser.UserId.ToString(); using var conn = new SqlConnection(SqlHelper.ConnStr); using var cmd = new SqlCommand("sp_CreateOrder", conn) { CommandType = CommandType.StoredProcedure }; cmd.Parameters.Add(new SqlParameter("@OrderNo", SqlDbType.VarChar, 32) { Value = orderNo }); cmd.Parameters.Add(new SqlParameter("@UserId", SqlDbType.Int) { Value = CurrentUser.UserId }); cmd.Parameters.Add(new SqlParameter("@ScheduleSeatId", SqlDbType.Int) { Value = selectedSeatId }); cmd.Parameters.Add(new SqlParameter("@Price", SqlDbType.Decimal) { Value = moviePrice }); cmd.Parameters.Add(new SqlParameter("@MemberId", SqlDbType.Int) { Value = (object)CurrentUser.MemberId ?? DBNull.Value }); conn.Open(); int result = (int)cmd.ExecuteScalar(); if (result == 1) { MessageBox.Show($"出票成功,订单号:{orderNo}"); RefreshSeatStatus(); } else if (result == -1) { MessageBox.Show("座位锁定状态已失效,请重新选座"); } else { MessageBox.Show("下单失败,请稍后重试"); } }DateTime.Now.ToString("yyyyMMddHHmmss")拼接用户 ID 生成订单号,在单机场景够用且可读性强。真正生产环境会用雪花算法或数据库序列,课程设计没必要那么复杂。这里的事务控制完全在数据库内完成,C# 端没有手动开事务,好处是无论程序在哪一步崩溃,数据库都能回滚到一致状态。如果哪一天要支持支付宝微信支付,只需要在 sp_CreateOrder 之前多一个“接入支付回调”的步骤,核心的表结构不用动。
5. 避坑:影院售票系统最常见的 5 个翻车现场
5.1 连不上数据库:“用户登录失败”不一定是密码错了
现象:程序一运行就抛SqlException: 用户 'sa' 登录失败,检查 App.config 里密码明明是对的。原因有两类:一是 SQL Server 安装时只开了 Windows 身份验证模式,sa账号根本不被允许登录;二是连接字符串里混用了两种认证方式,比如User Id=sa和Integrated Security=True同时出现。解决:打开 SSMS,右键服务器选择属性,在“安全性”里勾选“SQL Server 和 Windows 身份验证模式”,然后到“安全性 -> 登录名 -> sa”里重置密码并启用账号。
5.2 座位超卖:两个人同时买到了同一个座
现象:两个客户端同时选同一个座位,都能选上,数据库里出现两条有效订单。原因:C# 端在点击事件里先用一句SELECT ... WHERE Status=0判断座位空闲,再执行 INSERT,这两个操作之间有时间窗,另一个请求正好把状态改成了已售。这类并发问题靠界面变量根本挡不住。解决:锁座和下单都走存储过程,用UPDLOCK行锁保证“检查状态和修改状态”在一瞬间完成,受影响行数为 0 就是抢座失败,没有第二种解释。
5.3 选座界面刷新时狂闪,鼠标滚轮一滚就卡
现象:座位图每隔十几秒刷新一次,整个 DataGridView 白屏闪烁,操作明显卡顿。原因:DataGridView 默认没有开启双缓冲,单元格多时整表重绘开销很大。解决两招:第一招刷新前调BeginUpdate(),刷新完调EndUpdate(),这期间暂停布局和绘制;第二招自定义控件开启双缓冲,在类里加一句SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true),用起来和平常 DataGridView 完全一样。
5.4 订单显示“已支付”,座位却是“空闲”
现象:Orders 表里订单是已支付状态,ScheduleSeat 对应座位却是 0。原因:C# 代码里不是一次性调 sp_CreateOrder,而是分了两步:先执行 UPDATE 改座位状态,再执行 INSERT 写订单。第一步成功了,第二步因为异常没执行,两件事就不一致了。这属于典型的“事务没包住”。解决:把四步收进 sp_CreateOrder 一个事务,要么全部成功要么全部回滚。改造后还有个额外好处:以后退票、改签都复用同一套事务模板,不会有新坑。
5.5 日期字段查出来全是 0001-01-01
现象:DataGridView 里场次时间显示成0001-01-01,但 SSMS 里查明明有值。原因:一行代码问题——直接Convert.ToDateTime(reader["StartTime"]),数据库里那行数据是 NULL,转换后就变成了 DateTime 默认值。解决:封装一个安全的转换方法,遇到 DBNull 返回可空类型,然后根据业务决定显示“未设置”还是跳过这一行。顺带说一句,DAL 里所有读字段操作都建议走这种判空封装,而不是每个窗体各写各的Convert,这是 C# 高级编程里很基础但很多人都没做好的习惯。
6. 收尾验证:用对账 SQL 和并发演示让系统经得起答辩提问
6.1 两条最实用的验证 SQL
数据库搭完,不要急着写窗体。先用两条 SQL 验证模型的完整性,这两条语句也建议你背下来,答辩时现场跑出来比空口解释有说服力得多。
-- 查所有锁定超过10分钟还没释放的座位,用于排查锁座逻辑是否正常 SELECT ScheduleSeatId, ScheduleId, [Status], LockTime FROM dbo.ScheduleSeat WHERE [Status] = 2 AND LockTime < DATEADD(MINUTE, -10, GETDATE()); -- 对账:订单支付状态和座位已售状态必须一致,不一致就是事务有漏洞 SELECT o.OrderNo, o.[Status] AS OrderStatus, ss.[Status] AS SeatStatus FROM dbo.Orders o JOIN dbo.OrderDetail od ON od.OrderId = o.OrderId JOIN dbo.ScheduleSeat ss ON ss.ScheduleSeatId = od.ScheduleSeatId WHERE o.[Status] = 1 AND ss.[Status] != 1;第二句如果查出来有行,说明订单已支付但座位不是已售状态,系统有 bug。我在做过的两个项目里都靠这条对账 SQL 抓到过事务遗漏的问题,建议把它固定在一个“统计查询”菜单里,代替人工检查。
6.2 并发演示:两个 SSMS 窗口抢同一个座
答辩时老师最爱问“你这个系统怎么防止两个人买同一个座位”。口头解释不如现场演示:打开两个 SSMS 窗口,都执行BEGIN TRAN; EXEC dbo.sp_LockSeat @ScheduleSeatId=1, @UserId=1;,第一个窗口执行后别提交,第二个窗口执行就会发现它一直在阻塞,这就是行锁生效的直观证明。然后第一个窗口执行ROLLBACK,第二个窗口立刻执行成功。把这个动作演示一遍,比任何讲解都有效。
6.3 演示前的三件小事
第一件:给场次预灌 2 到 3 天的数据,包括不同时段的影片,演示时不要临时 INSERT。第二件:把数据库文件通过“附加”的方式挂到目标机器上,不要每次用建库脚本现建,否则演示现场建库报个错心态就崩了。第三件:检查 App.config 里的连接字符串和目标机器的 SQL Server 实例名是否一致,最容易忽略的就是Server=.在本机是 OK 的,换台电脑就必须改成那台电脑的实例名。
我个人的习惯是每次改完数据层代码,就先把对账 SQL 跑一遍再动界面,数据库一致性没问题了才去碰窗体。这套顺序帮我少走了很多弯路。停车场、图书馆、超市收银这类管理系统,换掉业务表名和界面文案,骨架完全能复用。希望帮到你。
本文还有配套的精品资源,点击获取