简介:一套基于C#实现的电影院售票系统完整项目,面向计算机专业学生或需要毕业设计、课程设计代码方案的学习者,覆盖从用户登录、选座购票到后台管理的完整票务业务,适合用来综合演练桌面应用开发。压缩包共包含692个文件,以C#源码文件、Windows Forms页面及aspx网页文件为主,同时含有大量gif、png、jpg界面素材、CSS与JS前端资源,以及数据库文件和配置说明,整体约13.81MB,结构上兼顾了前后台代码与演示素材。目前已有90人学习/下载。项目知识点涵盖C#面向对象编程、Windows Forms界面设计、ADO.NET数据访问、数据绑定与控件交互、用户认证与权限控制、异常处理机制等,同时附有运行环境配置说明,便于本地搭建调试,是一份可用于答辩讲解和二次开发的完整参考资料。
1. 为什么一套C#电影院售票系统值得你从头敲一遍
每年毕业设计答辩现场,总能看到几套“电影院售票系统”翻车:选座能选、下单报错,两个窗口同时卖同一张票,数据库里座位状态乱成一团。这类项目看着不起眼,但把C#、数据库事务、UI联动和并发处理全串起来了,是练手性价比很高的题目。
这套系统要解决的核心问题是:把电影的场次、座位、订单、支付状态管清楚,保证一张票不会被卖两次。适合用C#入门不久、想拿一个完整项目练三层架构和数据库设计的学习者,也适合需要交一个课程设计或毕业设计成品的学生。真正动手写一遍,比背一百道C#面试题管用。
2. 先把架子搭稳:C#三层架构与数据库选型
2.1 为什么选WinForms而非WPF或Web
见过不少同学一上来就纠结UI框架。这里直接说结论:如果是毕业设计和课程设计,首选WinForms。原因有三。一是资料最全,从GridView绑定到报表打印,随便一搜就是一堆能跑的案例,C#初学者遇到问题最容易找到现成答案;二是调试直观,窗体上拖控件、双击事件就能把交互逻辑理清楚,不用在前端路由和HTTP请求里绕弯子;三是这套系统的业务本来就在一个局域网或者单机上跑,用不上Web那套部署方案。
WPF不是不行,它的数据绑定和样式确实更现代,但学习成本会分摊到XAML、DataTemplate、MVVM上。如果只有两周时间写一个课设,这些额外成本容易变成翻车点。至于ASP.NET Core Web项目,适合想顺便练前后端的同学,但答辩时你要多解释一堆接口和跨域问题,票务核心逻辑反而讲不透。
2.2 数据库选型:SQL Server还是SQLite
数据库选择也不能拍脑袋。如果是课程设计,老师通常希望看到SQL Server或者MySQL这种“正经数据库”,因为要建表、写存储过程、设主外键。这几种里SQL Server在Windows下部署最省事,Visual Studio自带连接功能,附加数据库文件就能跑,是最常见的做法。
SQLite也可以,它的优点是不需要安装服务,一个.db文件扛起整个系统,适合演示环境极其有限的场景。但缺点也明显:并发写锁机制弱,多个窗口同时卖票时容易出现“数据库被锁定”的提示;而且少了作业、存储过程这些企业级功能,答辩时被问到“你怎么保证数据一致性”会少一些可讲的素材。我一般建议:目标环境是老师电脑、需要当场演示的,用SQL Server LocalDB或Express版;如果只是自己练手,SQLite足够。
2.3 三层架构的工程拆分和数据访问层写法
常见的做法是建三个项目:UI层(窗体)、BLL层(业务逻辑)、DAL层(数据访问),外加一个Models层放实体类。UI层只管收集用户输入和展示结果,BLL层管订票、退票、查场次这些规则,DAL层只写SQL和数据库交互。这样拆的好处是,往后想换成MySQL,只需要改DAL层。
DAL层最常见的落法是写一个SqlHelper类,封装连接、执行SQL、返回DataTable的操作。下面是极简版本:
using System.Configuration; using System.Data; using System.Data.SqlClient; public static class SqlHelper { private static readonly string _connStr = ConfigurationManager.ConnectionStrings["CinemaDB"].ConnectionString; // 执行增删改,返回受影响行数 public static int ExecuteNonQuery(string sql, params SqlParameter[] paras) { using (SqlConnection conn = new SqlConnection(_connStr)) { conn.Open(); using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (paras != null) cmd.Parameters.AddRange(paras); return cmd.ExecuteNonQuery(); } } } // 执行查询,返回DataTable public static DataTable ExecuteDataTable(string sql, params SqlParameter[] paras) { using (SqlConnection conn = new SqlConnection(_connStr)) { using (SqlDataAdapter da = new SqlDataAdapter(sql, conn)) { if (paras != null) da.SelectCommand.Parameters.AddRange(paras); DataTable dt = new DataTable(); da.Fill(dt); return dt; } } } }这段代码的逻辑是:每次操作打开一个新连接,用完自动释放,避免连接泄漏。注意ExecuteDataTable里用了SqlDataAdapter,它会在内部自己打开和关闭连接,所以不需要显式Open。参数paras用来防止SQL注入,凡是拼接用户输入的地方都必须走参数化,不能把字符串直接拼进SQL。
连接字符串放在App.config里,通过ConfigurationManager读取,这是很多初学者会忽略的点。用Visual Studio创建项目后,在App.config里加一段:
<connectionStrings> <add name="CinemaDB" connectionString="Data Source=.;Initial Catalog=CinemaDB;Integrated Security=True" providerName="System.Data.SqlClient" /> </connectionStrings>Initial Catalog对应当前数据库名,Integrated Security=True表示用Windows身份认证登录,不需要填账号密码。如果换到别的机器,只需要改Data Source指向目标数据库服务器,代码一行都不用动。这里值得记住:连接字符串里常用的还有User ID和Password,但那是SQL Server混合认证模式用的,课设环境一般用不上。
把SqlHelper写好之后,DAL层其他类就都是“拼SQL + 调SqlHelper”的模板了。BLL层调用DAL层,UI层调用BLL层,整个数据流是单向的,出了问题顺着调用链一层层找就行。
3. 数据库设计:售出一张票需要哪几张表撑着
3.1 影片、影厅、场次:三张基础表怎么建
很多人的第一版数据库只有“影片表”和“订单表”,结果到场次排期时傻眼了:同一部电影一天放三场,每场在不同的厅,票价还不一样。这就暴露了表结构设计的核心问题:场次才是售票的最小单位,影片不是。
基础表一般拆三张:影片表存电影名称、时长、上映日期;影厅表存厅名和行列数;场次表存哪部电影、哪个厅、什么时间放、卖多少钱。下面是建表SQL:
CREATE TABLE Movie ( MovieId INT IDENTITY(1,1) PRIMARY KEY, MovieName NVARCHAR(50) NOT NULL, Duration INT NOT NULL, -- 影片时长,单位分钟 Price DECIMAL(6,2) NOT NULL -- 基础票价 ); CREATE TABLE CinemaHall ( HallId INT IDENTITY(1,1) PRIMARY KEY, HallName NVARCHAR(20) NOT NULL, RowCount INT NOT NULL, -- 影厅座位行数 ColCount INT NOT NULL -- 影厅座位列数 ); CREATE TABLE SessionPlan ( SessionId INT IDENTITY(1,1) PRIMARY KEY, MovieId INT NOT NULL, HallId INT NOT NULL, StartTime DATETIME NOT NULL, EndTime DATETIME NOT NULL, Price DECIMAL(6,2) NOT NULL, -- 场次实际票价,允许和影片基础票价不同 CONSTRAINT FK_Session_Movie FOREIGN KEY (MovieId) REFERENCES Movie(MovieId), CONSTRAINT FK_Session_Hall FOREIGN KEY (HallId) REFERENCES CinemaHall(HallId) );这里有两个容易被忽略的点。第一,场次表里冗余了Price字段,而不是每次都去查影片表。因为同一步电影晚场票价比早场贵是常态,冗余这个字段能让售票查询少一次联表查询。第二,EndTime也不是必须的,但有了它,后面做“同一影厅同一时间段不能排两场”的冲突检查就非常方便。
3.2 座位表:为什么要按场次复制座位
座位是这套系统里最容易设计错的地方。常见做法是给影厅存一份座位表,订单存“几排几号”,这种做法单机演示没问题,但一旦遇到“同一个厅的座位,不同场次卖出状态不同”就麻烦了。正确做法是:为每一个场次生成一份独立的座位记录。
CREATE TABLE Seat ( SeatId INT IDENTITY(1,1) PRIMARY KEY, SessionId INT NOT NULL, -- 属于哪个场次 HallId INT NOT NULL, -- 冗余影厅ID,方便统计 RowNo INT NOT NULL, ColNo INT NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0空闲 1锁定 2已售 LockOrderId INT NULL, -- 锁定订单ID,用于恢复 CONSTRAINT FK_Seat_Session FOREIGN KEY (SessionId) REFERENCES SessionPlan(SessionId), CONSTRAINT UQ_Seat_Session UNIQUE (SessionId, RowNo, ColNo) );每次创建场次时,程序按影厅的RowCount和ColCount循环插入座位,N行的厅就生成N条Seat记录。这个设计的好处是,座位状态天然归属于场次,不同场次的同一物理座位互不干扰;坏处是数据量会随场次数量增长,但一个电影院一天十几个场次,完全不是问题。
Status字段用TINYINT,0空闲、1锁定、2已售。锁定意思是用户选座后还没付款,这时候座位得先占住,否则别人就买了。LockOrderId记录是谁锁的,方便超时后释放座位。
3.3 订单表:用状态机处理支付和退票
订单表是系统的账本,字段设计直接决定业务逻辑好不好写。推荐用订单号、场次、座位、金额、状态、创建时间这几个核心字段:
CREATE TABLE Orders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(20) NOT NULL UNIQUE, SessionId INT NOT NULL, SeatId INT NOT NULL, MovieName NVARCHAR(50) NOT NULL, -- 冗余影片名,列表页不联表 TotalPrice DECIMAL(6,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已退票 3已失效 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT FK_Order_Session FOREIGN KEY (SessionId) REFERENCES SessionPlan(SessionId), CONSTRAINT FK_Order_Seat FOREIGN KEY (SeatId) REFERENCES Seat(SeatId) );订单状态的流转是这套系统的业务核心:选座成功创建订单,状态是0;支付成功后改成1;用户退票且系统允许时改成2,同时把对应Seat的Status恢复成0;如果用户锁座后超时未支付,订单标记3,座位释放。每一次状态变化都对应一段明确的SQL更新,而不是散落在UI代码里随手改。
这里还要提一个设计决策:订单表和Seat表之间用SeatId直接关联,而不是存“几排几号”。原因是SeatId能唯一定位到某场次的某个座位,查询“这个座位现在属于哪个订单”只需要一条语句;如果存排号列号,还得带上场次ID三重匹配,麻烦且容易出错。
4. 核心售票链路:从锁座到出票的完整实现
4.1 锁座:一条UPDATE语句解决并发抢座
售票系统最容易翻车的地方,就是两个窗口同时卖同一张票。最简单的错误写法是这样的:先SELECT查看座位状态,如果空闲就UPDATE成已售。两个用户同时SELECT,都看到空闲,然后都UPDATE,结果一张票卖给两个人。
正确处理是用“条件更新”代替“先查后改”:
public bool LockSeat(int sessionId, int rowNo, int colNo, int orderId) { string sql = @" UPDATE Seat SET Status = 1, LockOrderId = @OrderId WHERE SessionId = @SessionId AND RowNo = @RowNo AND ColNo = @ColNo AND Status = 0"; int rows = SqlHelper.ExecuteNonQuery(sql, new SqlParameter("@SessionId", sessionId), new SqlParameter("@RowNo", rowNo), new SqlParameter("@ColNo", colNo), new SqlParameter("@OrderId", orderId)); return rows == 1; }这段代码的关键在WHERE子句里的Status = 0。数据库执行UPDATE时会先锁定匹配行,再判断状态,所以即使两个线程同时执行这条SQL,也只有第一个能成功更新一行,第二个受影响行数为0。返回的rows等于1才说明锁座成功,等于0说明座位已经被别人占了。
这里用到的知识点是数据库的行级锁和原子更新,比什么花哨的并发控制都可靠。C#多线程里经常提到的lock语句在这里反而不适用,因为两个售票窗口可能是两台机器上的两个进程,C#的lock只能管住同一个进程内的线程。
4.2 下单:把锁座和创建订单放进一个事务
锁座成功之后,还要插入订单记录。这里有个细节:如果锁座成功但插入订单失败,座位会永远卡在锁定状态。所以这两步必须放在同一个SqlTransaction里,要么都成功,要么都回滚。
BLL层的典型写法是这样:
public bool CreateOrder(int sessionId, int rowNo, int colNo, decimal price, string movieName) { string connStr = ConfigurationManager.ConnectionStrings["CinemaDB"].ConnectionString; using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { try { // 第一步:锁座,条件更新保证不超卖 string lockSql = @" UPDATE Seat SET Status = 1, LockOrderId = @OrderId WHERE SessionId = @SessionId AND RowNo = @RowNo AND ColNo = @ColNo AND Status = 0"; // 生成订单号,比如"T20240521"加自增序号 string orderNo = GenerateOrderNo(); SqlCommand lockCmd = new SqlCommand(lockSql, conn, tran); lockCmd.Parameters.AddWithValue("@SessionId", sessionId); lockCmd.Parameters.AddWithValue("@RowNo", rowNo); lockCmd.Parameters.AddWithValue("@ColNo", colNo); lockCmd.Parameters.AddWithValue("@OrderId", orderId); if (lockCmd.ExecuteNonQuery() != 1) { throw new Exception("座位已被他人锁定"); } // 第二步:插入订单 string insertSql = @" INSERT INTO Orders(OrderNo, SessionId, SeatId, MovieName, TotalPrice, Status) VALUES(@OrderNo, @SessionId, (SELECT SeatId FROM Seat WHERE SessionId = @SessionId AND RowNo = @RowNo AND ColNo = @ColNo), @MovieName, @Price, 0)"; SqlCommand insertCmd = new SqlCommand(insertSql, conn, tran); insertCmd.Parameters.AddWithValue("@OrderNo", orderNo); // 其他参数类似,省略 insertCmd.ExecuteNonQuery(); tran.Commit(); return true; } catch { tran.Rollback(); return false; } } } }代码逻辑分两步:先条件更新锁座,影响行数不是1就抛异常回滚;再插入订单。注意插入订单时用子查询从Seat表取SeatId,避免前端传一个不可信的座位ID进来。参数全部通过AddWithValue传入,这是SqlCommand最基础的用法,也是防止SQL注入的标准姿势。
注意:AddWithValue虽然方便,但遇到NULL值和NVARCHAR长度不匹配时容易出玄学错误。更严谨的做法是用new SqlParameter("参数名", SqlDbType.Int)来显式指定类型和长度。在课设阶段AddWithValue够用,但心里要清楚这个边界。
4.3 退票、场次查询和C#委托在UI联动里的作用
出票之后另一个高频业务是退票。退票的核心不是删订单,而是改状态:
public bool Refund(int orderId) { string sql = @" BEGIN TRAN; UPDATE Orders SET Status = 2 WHERE OrderId = @OrderId AND Status = 1; UPDATE Seat SET Status = 0, LockOrderId = NULL WHERE LockOrderId = @OrderId; COMMIT; "; // 在SqlHelper里执行 return SqlHelper.ExecuteNonQuery(sql, new SqlParameter("@OrderId", orderId)) > 0; }这段SQL把订单状态改成已退票,同时把该订单锁定的座位释放。两个UPDATE用事务包起来,避免出现“票退了座位没释放”的情况。这里要注意UPDATE Orders那行带了Status = 1条件,防止重复退票。
除了数据库操作,UI联动也是这套系统里很有讲头的部分。选座页面上,用户点击一个空座位,座位按钮变灰,同时下方订单面板显示影片名、场次时间和票价——这种联动用C#委托和事件来做非常顺手。主窗体定义一个座位选中事件,子控件触发事件,订单面板订阅事件刷新内容。C#里event本质上是委托的封装,理解了这个,后面做任何多窗体通知都一通百通。
5. 避坑:最容易让售票系统在答辩时翻车的五个细节
5.1 座位状态在界面显示时对不上数据库
现象是:两个窗口同时开着选座界面,A窗口把座位卖了,B窗口界面里还是空的。这是正常现象,因为界面是启动时加载的静态数据。但也有人把它当bug改,越改越乱。
原因是程序只查了一次座位表,之后没有任何刷新机制。解决方法是选座成功后,用委托或Timer重新刷新一次DataGridView;或者在刷新时带上DateTime.Now参数,让SQL只查当前状态。这里的关键是区分“界面缓存”和“数据库状态”,不要把两者混为一谈。
5.2 AddWithValue的NVARCHAR长度玄学
现象是:订单号明明有20位,插入数据库后变成一堆空格,或者查询报“将截断字符串或二进制数据”。
原因是AddWithValue在参数未显式指定长度时,会和数据库列定义不一致。解决方法是构造函数里先取数据长度,或者直接用Parameters.Add(new SqlParameter("@OrderNo", SqlDbType.NVarChar, 20))。这是个很小的细节,但翻车概率极高,尤其是订单号、电话这种定长字符串。
5.3 日期时间过滤用了Between但漏了当天
现象是:查“今天”的场次,结果晚上10点的场次查不出来。
原因是Between and在SQL Server里的边界语义是包含头尾,但如果前端把DateTime截断成yyyy-MM-dd,就会把当天最后一场的时间归零,导致该场次落在区间外。解决方法是不要用Between,用>=和<两段比较,日期加1天作为上界。这条是对付“时间类查询”的通用套路。
5.4 座位锁定后没有释放
现象是:用户选座不支付,关掉窗口,座位一直灰着。
原因是没有做超时释放。解决方法是加一个“订单待支付超过5分钟自动失效”的定时任务,或者退而求其次,在座位表加LastLockTime字段,查询时SQL里带上“锁定时间超过N分钟视为空闲”。虽然不如后台任务干净,但课设演示够用。
5.5 数据库文件附加失败、连接报错
现象是:换了一台电脑,附加.mdf时报“无法打开物理文件”,或者登录时提示“用户登录失败”。
原因是文件权限不足,或者连接字符串里的登录模式不对。解决方法是右键数据库文件给“Everyone”读权限,或者在App.config里改用LocalDB的Data Source=(LocalDB)\MSSQLLocalDB写法。答辩现场换机器是常态,提前在目标机器上跑一遍启动脚本是血泪经验。
6. 进阶验证:用多线程并发脚本证明系统不会超卖
6.1 50个线程抢同一个座位的冒烟测试
到这一步,基础功能基本写完了。但如果你有精力,值得写一个并发冒烟测试:开50个线程同时抢同一个座位,看最终订单数是不是1。这比任何口头解释都有说服力。
代码可以单独建一个控制台项目,引用BLL层:
int success = 0; int fail = 0; object lockObj = new object(); ManualResetEventSlim readyEvent = new ManualResetEventSlim(false); int readyThreads = 0; for (int i = 0; i < 50; i++) { Thread t = new Thread(() => { // 所有线程就绪后同时开抢 Interlocked.Increment(ref readyThreads); readyEvent.Wait(); OrderService service = new OrderService(); bool ok = service.CreateOrder(1001, 5, 8, 45.00m); if (ok) Interlocked.Increment(ref success); else Interlocked.Increment(ref fail); }); t.Start(); } readyEvent.Set(); Thread.Sleep(3000); Console.WriteLine($"成功 {success} 单,失败 {fail} 单");理论上输出必须是成功1单、失败49单。如果出现了成功多于1单,说明锁座逻辑有问题,回到第4章检查条件更新是否正确。这是验证“数据一致性”最直接的证据。
6.2 日志记录和后续扩展方向
除了并发验证,建议再加一个简单的日志类,把订票、退票、异常都写到一个日志文件里。用StreamWriter追加写就行,不用上什么重量级框架。这样才能在出问题时快速定位是哪个环节挂了,而不是开着Visual Studio一点点断点。
后续想往深了做,可以从这几个方向扩展:加入会员积分系统、用Queue处理高并发订票请求、引入存储过程把关键事务从C#挪到数据库端。但无论扩展多少,核心的锁座和订单状态机都不要动,那是整套系统的地基。
我自己的习惯是写完一个模块先跑一遍并发冒烟,再开始写下一个。这个习惯帮我避开了很多线上才会暴露的坑。如果你正在做C#电影院售票系统,把这一套流程走完,项目质量会比多数同龄人的成品高一个台阶,希望帮到你。
本文还有配套的精品资源,点击获取