资讯详情 C# Winform影院售票系统实战:座位锁定与订单事务避坑指南
📅 2026/10/15 15:00:43
简介这是一套面向C#初学者与课程设计者的Winform影院售票管理系统完整源码基于VS2012与SQL Server 2012开发可直接附加数据库运行适合作为毕业设计、课程大作业或Winform实战练手项目。压缩包共93个文件约4.13MB以40个cs源码文件为核心配合15个resx与15个resources界面资源、3个exe可执行程序、3个config配置及mdf、ldf数据库文件另含sln解决方案、csproj工程文件与图标等素材结构完整、层次清晰。系统涵盖登录、影片类型、影厅、排片、售票、用户管理等多个功能窗体并封装了DBHelper数据访问类与Model实体层便于理解三层架构与数据库交互思路。目前已有1405人学习下载读者可据此掌握Winform控件布局、事件处理、SQL Server建库建表及增删改查的完整实现也能在此基础上二次开发或直接用于答辩演示。1. 从一张排片表说起这套 C# Winform 售票系统到底能跑通什么如果你接过影院、剧场或者小型演出场馆的票务私活大概率遇到过这种局面客户要一个能选座、能出票、能退票、能查流水的桌面端预算不高但数据库得齐全最好拿过去改改就能用。这套 C# Winform 影院售票管理系统就是冲着这个场景来的——它把影片管理、影厅座位、排片场次、订单出票、退票改签、会员折扣这几条主线全串起来了数据库脚本一并给全不是那种只画了界面、点按钮没反应的壳子工程。它适合三类人一是刚学完 C# 基础、想找一个完整 CRUD 加事务的练手项目二是接了小场馆私活、需要一个能快速二次开发的底子三是想看看 Winform 里座位图、订单状态机、并发锁座怎么落地的人。下面我按「先跑起来、再看结构、最后抠细节」的顺序拆中间会重点讲座位锁定和订单事务这两个最容易翻车的地方。2. 环境搭起来从数据库脚本到第一个可登录窗口2.1 数据库选型与脚本导入这套系统默认走 SQL Server原因很直接Winform 配 SQL Server 是微软系最顺的组合SqlConnection、SqlDataAdapter、事务、存储过程全都原生支持不用额外装驱动。数据库脚本一般包含建库、建表、索引、初始数据四部分表结构大致是这几张核心表表名作用关键字段Movie影片信息MovieId, Title, Duration, RatingHall影厅信息HallId, HallName, RowCount, ColCountSchedule排片场次ScheduleId, MovieId, HallId, StartTime, PriceSeat座位状态SeatId, ScheduleId, RowNo, ColNo, StatusOrder订单主表OrderId, ScheduleId, UserId, TotalAmount, OrderStatusTicket票明细TicketId, OrderId, SeatId, Price导入时我一般用 SSMS 直接执行脚本或者命令行走sqlcmd# 用 sqlcmd 导入建库脚本-i 指定脚本文件-S 指定实例 sqlcmd -S localhost -U sa -P yourpassword -i CinemaDB.sql执行完先别急着开项目手动查一下SELECT COUNT(*) FROM Movie确认初始数据进去了。常见坑是脚本里用了GO批处理分隔符用某些图形化工具执行会报错这时候换 SSMS 或者 sqlcmd 就行。2.2 连接字符串配置与登录模块连接字符串不要硬编码在代码里放到App.config的connectionStrings节点改起来不用重新编译configuration connectionStrings !-- 把 Data Source 换成你的实例名Initial Catalog 换成实际库名 -- add nameCinemaDB connectionStringData Sourcelocalhost;Initial CatalogCinemaDB;User IDsa;Passwordyourpassword; providerNameSystem.Data.SqlClient / /connectionStrings /configuration读取的时候用ConfigurationManager.ConnectionStrings[CinemaDB].ConnectionString。登录模块一般是查User表比对账号密码密码字段如果存的是明文二次开发时建议换成 SHA256 加盐别直接上线。// 登录校验参数化查询防注入返回用户角色用于后续权限控制 public User Login(string account, string password) { string sql SELECT UserId, UserName, Role FROM [User] WHERE Accountacc AND Passwordpwd; using (SqlConnection conn new SqlConnection(connStr)) { SqlCommand cmd new SqlCommand(sql, conn); cmd.Parameters.AddWithValue(acc, account); cmd.Parameters.AddWithValue(pwd, password); // 生产环境请存哈希 conn.Open(); using (SqlDataReader reader cmd.ExecuteReader()) { if (reader.Read()) { return new User { UserId (int)reader[UserId], UserName reader[UserName].ToString(), Role reader[Role].ToString() }; } } } return null; }这里AddWithValue是最省事的参数化写法但要注意它按值推断类型遇到decimal金额字段时最好显式用SqlParameter指定SqlDbType.Decimal否则可能出现精度丢失。登录成功后把User对象存到全局静态类或者主窗体的字段里后续开单、退票都要拿UserId和Role做判断。2.3 主窗体导航与模块划分主窗体一般用MenuStrip加Panel容器做模块切换点菜单把对应UserControl塞进 Panel。这种写法比开一堆独立 Form 好维护也方便传参。模块划分建议按业务走影片管理、排片管理、售票选座、订单查询、退票处理、统计报表。每个模块一个UserControl数据访问层单独抽一个DAL文件夹别把 SQL 写在按钮点击事件里不然改一个字段要满项目搜。3. 选座与订单座位状态机和事务锁座怎么落地3.1 座位图渲染与状态映射座位图是这套系统最直观的部分。影厅有RowCount行、ColCount列渲染时用TableLayoutPanel或者动态生成Button网格都行。每个座位按钮的Tag存SeatIdBackColor映射状态可售是绿色已售是红色锁定中是黄色过道用灰色不可点。// 根据 ScheduleId 加载座位状态并渲染网格 private void RenderSeats(int scheduleId) { var seats seatDAL.GetSeatsBySchedule(scheduleId); // 返回 ListSeat seatPanel.Controls.Clear(); seatPanel.ColumnCount hall.ColCount; foreach (var seat in seats) { Button btn new Button(); btn.Text ${seat.RowNo}排{seat.ColNo}座; btn.Tag seat.SeatId; btn.Enabled seat.Status 0; // 0 可售1 已售2 锁定 btn.BackColor seat.Status switch { 0 Color.LightGreen, 1 Color.LightCoral, 2 Color.Gold, _ Color.Gray }; btn.Click Seat_Click; seatPanel.Controls.Add(btn, seat.ColNo - 1, seat.RowNo - 1); } }Status字段是整个选座逻辑的核心所有操作都是围绕它做状态迁移。这里有个容易忽略的点座位状态是跟ScheduleId绑定的同一影厅不同场次的座位状态互不影响所以Seat表里ScheduleId必须是联合主键的一部分不能只用SeatId。3.2 下单事务与并发锁座选完座位点确认要做三件事写订单主表、写票明细、把座位状态改成已售。这三步必须在一个事务里否则中途失败会出现「钱扣了座没锁」或者「座锁了订单没生成」的脏数据。更关键的是并发——两个人同时选同一个座位谁先提交谁赢。// 下单核心事务 行锁防止同一座位被重复售出 public bool CreateOrder(int scheduleId, Listint seatIds, int userId, decimal total) { using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction tran conn.BeginTransaction()) { try { // 1. 先锁定座位行检查是否仍可售UPDLOCK 防止并发读到旧状态 string checkSql SELECT COUNT(*) FROM Seat WITH (UPDLOCK, ROWLOCK) WHERE SeatId IN (ids) AND ScheduleIdsid AND Status0; SqlCommand checkCmd new SqlCommand(checkSql, conn, tran); checkCmd.Parameters.AddWithValue(ids, string.Join(,, seatIds)); checkCmd.Parameters.AddWithValue(sid, scheduleId); int available (int)checkCmd.ExecuteScalar(); if (available ! seatIds.Count) { tran.Rollback(); return false; // 有座位已被抢回滚 } // 2. 写订单主表 string orderSql INSERT INTO [Order](ScheduleId, UserId, TotalAmount, OrderStatus, CreateTime) VALUES(sid, uid, amt, 1, GETDATE()); SELECT SCOPE_IDENTITY();; SqlCommand orderCmd new SqlCommand(orderSql, conn, tran); orderCmd.Parameters.AddWithValue(sid, scheduleId); orderCmd.Parameters.AddWithValue(uid, userId); orderCmd.Parameters.AddWithValue(amt, total); int orderId Convert.ToInt32(orderCmd.ExecuteScalar()); // 3. 写票明细并更新座位状态 foreach (int seatId in seatIds) { string ticketSql INSERT INTO Ticket(OrderId, SeatId, Price) VALUES(oid, seat, price); SqlCommand ticketCmd new SqlCommand(ticketSql, conn, tran); ticketCmd.Parameters.AddWithValue(oid, orderId); ticketCmd.Parameters.AddWithValue(seat, seatId); ticketCmd.Parameters.AddWithValue(price, total / seatIds.Count); ticketCmd.ExecuteNonQuery(); string updateSql UPDATE Seat SET Status1 WHERE SeatIdseat; SqlCommand updateCmd new SqlCommand(updateSql, conn, tran); updateCmd.Parameters.AddWithValue(seat, seatId); updateCmd.ExecuteNonQuery(); } tran.Commit(); return true; } catch (Exception ex) { tran.Rollback(); // 记录日志返回失败 throw; } } } }WITH (UPDLOCK, ROWLOCK)是这段代码的关键。UPDLOCK在读取时就加更新锁别的事务想改这行会被挡住直到当前事务提交或回滚ROWLOCK把锁粒度控制在行级避免锁整张表影响其他场次售票。没有这两个提示两个并发请求可能都读到Status0然后都去更新最后超卖。参数说明ids这里用字符串拼接IN子句是简化写法实际项目建议用表值参数或者循环单查避免 SQL 注入和参数长度限制。SCOPE_IDENTITY()拿当前作用域自增主键比IDENTITY安全不会拿到触发器插入的 ID。3.3 退票与状态回滚退票逻辑是下单的逆操作把订单状态改成已退、票明细标记退票、座位状态从 1 改回 0。同样要事务包裹。这里有个业务规则要确认退票是否允许部分退如果允许座位状态要按票逐张回滚如果不允许整单退。常见做法是整单退实现简单对账也清楚。// 整单退票订单状态置为已退座位释放 public bool RefundOrder(int orderId) { using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction tran conn.BeginTransaction()) { try { // 查出该订单所有座位 string seatSql SELECT SeatId FROM Ticket WHERE OrderIdoid; SqlCommand seatCmd new SqlCommand(seatSql, conn, tran); seatCmd.Parameters.AddWithValue(oid, orderId); Listint seatIds new Listint(); using (SqlDataReader r seatCmd.ExecuteReader()) { while (r.Read()) seatIds.Add((int)r[SeatId]); } // 释放座位 foreach (int seatId in seatIds) { SqlCommand releaseCmd new SqlCommand( UPDATE Seat SET Status0 WHERE SeatIdseat, conn, tran); releaseCmd.Parameters.AddWithValue(seat, seatId); releaseCmd.ExecuteNonQuery(); } // 更新订单状态 SqlCommand orderCmd new SqlCommand( UPDATE [Order] SET OrderStatus2 WHERE OrderIdoid, conn, tran); orderCmd.Parameters.AddWithValue(oid, orderId); orderCmd.ExecuteNonQuery(); tran.Commit(); return true; } catch { tran.Rollback(); throw; } } } }退票的时间窗口、手续费计算这些属于业务层数据库层只负责状态回滚。如果客户要求「开场前两小时可退」那就在RefundOrder前面加一层时间校验别混在事务里。4. 避坑排查这套系统我踩过的五个坑4.1 座位超卖并发下两个订单抢同一个座现象压测或者两个人同时点确认两个订单都生成成功同一个座位卖了两张票。原因下单时先查后改中间没有加锁两个事务都读到Status0。解决按 3.2 的写法在检查座位时加WITH (UPDLOCK, ROWLOCK)把「查」和「改」放进同一个事务让数据库来保证互斥。如果并发量再大可以考虑把座位状态放 Redis 做分布式锁但小场馆用数据库行锁足够。4.2 金额精度丢失decimal 存进去变成两位小数现象票价 35.5 元存进数据库变成 35.50 或者 35对账差几分钱。原因AddWithValue按值推断类型C# 的double或者float传给decimal字段会隐式转换丢精度。解决金额字段统一用decimal参数显式指定类型SqlParameter priceParam new SqlParameter(price, SqlDbType.Decimal); priceParam.Precision 10; priceParam.Scale 2; priceParam.Value price;数据库里Price字段定义成DECIMAL(10,2)别用FLOAT或MONEY。4.3 连接池耗尽窗体没关连接没释放现象程序跑一段时间后报「超时时间已到但是尚未从池中获取连接」。原因SqlConnection没写using或者DataReader没关连接一直占着不还。解决所有SqlConnection、SqlCommand、SqlDataReader一律用using包裹确保Dispose被调用。连接字符串里可以加Max Pool Size100调大池子但根治还是靠及时释放。4.4 排片时间冲突同一影厅同一时段排了两场现象排片表里同一个影厅 14:00 和 14:30 各有一场第二场开始的时候第一场还没放完。原因插入排片时只校验了开始时间没校验结束时间是否重叠。解决插入前查一下该影厅有没有场次的[StartTime, StartTimeDuration]区间和新区间重叠SELECT COUNT(*) FROM Schedule s JOIN Movie m ON s.MovieId m.MovieId WHERE s.HallId hallId AND newStart DATEADD(MINUTE, m.Duration, s.StartTime) AND s.StartTime DATEADD(MINUTE, newDuration, newStart)返回大于 0 就拒绝插入。4.5 退票后座位没释放状态改了但界面没刷新现象退票成功数据库里座位状态已经是 0但选座界面还是红色。原因退票后没有重新加载座位数据界面用的是缓存。解决退票成功后调用RenderSeats(scheduleId)重新渲染或者用seatPanel.Controls遍历找到对应SeatId的按钮手动改BackColor和Enabled。前者简单粗暴但可靠后者性能好但要小心索引对不上。5. 二次开发进阶把统计报表和权限控制补上这套系统的底子跑通之后真正让它能交付给客户用的往往是统计和权限这两块。统计报表我一般直接在数据库层写视图或者存储过程Winform 端用DataGridView绑DataTable展示导出走ClosedXML或者NPOI写 Excel。比如按日统计票房-- 按日统计票房和出票数排除已退订单 CREATE VIEW v_DailySales AS SELECT CONVERT(DATE, o.CreateTime) AS SaleDate, COUNT(DISTINCT o.OrderId) AS OrderCount, COUNT(t.TicketId) AS TicketCount, SUM(t.Price) AS TotalAmount FROM [Order] o JOIN Ticket t ON o.OrderId t.OrderId WHERE o.OrderStatus 1 -- 1 已支付2 已退 GROUP BY CONVERT(DATE, o.CreateTime);Winform 端直接SELECT * FROM v_DailySales WHERE SaleDate BETWEEN start AND end绑到DataGridView再加一个导出按钮。视图的好处是逻辑集中在数据库改统计口径不用动 C# 代码。权限控制这块登录时拿到的Role字段决定菜单可见性。常见角色分管理员、售票员、财务三种管理员能改排片和票价售票员只能开单退票财务只能看报表。实现上在主窗体Load的时候根据Role遍历MenuStrip的Items把没权限的Visible设成false。别只在按钮点击里判断菜单藏起来更干净。// 根据角色控制菜单可见性 private void ApplyPermission(string role) { switch (role) { case Admin: break; // 全部可见 case Seller: menuSchedule.Visible false; // 排片管理隐藏 menuMovie.Visible false; // 影片管理隐藏 menuReport.Visible false; // 报表隐藏 break; case Finance: menuTicket.Visible false; // 售票隐藏 menuRefund.Visible false; // 退票隐藏 break; } }还有一个容易被忽略的点数据库连接字符串里的账号别用sa给应用单独建一个登录名只授予必要的SELECT/INSERT/UPDATE权限DROP、ALTER这些一律不给。我见过太多私活项目直接sa裸奔客户服务器被拖库都不知道。最后说个习惯。这套系统我每次改完座位状态相关的逻辑都会手动开两个客户端用两个账号同时选同一个座位看谁先提交、另一个是不是被正确拒绝。这个动作花不了两分钟但能挡住后面百分之八十的客诉。从那以后我每次动事务和锁相关的代码都强制走一遍双客户端并发测试希望帮到你。本文还有配套的精品资源点击获取