简介:这是一份基于C#实现的电影院售票系统完整项目包,适合作为毕业设计或课程设计的实战范本。系统围绕购票、场次管理、用户认证等核心模块展开,覆盖Windows Forms界面开发、ADO.NET数据访问、SQL数据库设计、数据绑定、异常处理与多线程等关键技能,能帮助学习者将理论知识落地为可运行的桌面应用。压缩包共692个文件,大小约13.81MB,以cs源码、aspx页面、js脚本、css样式及png/jpg图片为主,同时包含相关数据库文件与说明文档,结构完整便于直接查阅和二次开发。目前已有90人学习浏览,适合具备一定C#基础、希望系统掌握桌面软件开发流程的学生参考,也可为课程答辩提供可演示的完整方案。
1. 从启动画面到出票成功:C#电影院售票系统到底在做什么
一个电影院售票系统,表面上只是选电影、选座位、付钱、出票,但它真正训练的是一个开发者对业务状态的管理能力。座位从“可选”变成“已售”的那一刻,背后涉及数据表设计、并发处理、界面刷新、事务一致性——这些恰好是毕业设计和课程设计最看重的考察点。用C#来做这件事,最大的优势在于WinForms上手快,你不需要先啃完前端三大框架才能看到界面,从数据库建表到能跑通“选座下单”一条链路,一两天就能搭出雏形,剩下的时间全部用来打磨业务细节和应付答辩提问。这篇笔记就是我基于这个方向梳理的一套完整落地路径:从建库建表讲到出票逻辑,再讲到那些最容易让系统当场翻车的隐藏坑。
2. 用C#和WinForms搭出售票系统骨架:分层结构、数据模型与界面布局
2.1 选型:为什么是C# + WinForms,而不是WPF或Web
很多人在开工会纠结:现在都2025年了,为什么还要用WinForms做课程设计?答案很简单:稳定、可控、好演示。答辩现场没有时间让你折腾环境依赖,WinForms双击就能跑,部署也只需要目标机器装了.NET Framework或对应的桌面运行时。相比之下,WPF虽然界面好看,但数据绑定和样式模板的学习曲线陡峭;Web方案(比如ASP.NET Core + Vue)确实时髦,但你要同时维护前端、后端的构建流程,演示时网络一断,整个系统就废了。
C#在这个项目里的核心作用不只是写界面,而是利用强类型语言特性把业务模型理清楚。比如座位状态用枚举而不是魔法数字:
public enum SeatStatus { Available = 0, // 可选 Locked = 1, // 已被他人锁定 Sold = 2 // 已售出 }这段代码说明了一个关键点:写业务系统最怕的不是逻辑复杂,而是“状态靠注释约定”。如果你在代码里直接写if (seat == 1),没人知道1代表什么,三个月后连你自己都会忘。把状态定义成枚举,切到数据库里用int存储,既保证查询效率,又让代码自解释。
2.2 数据库设计:用户表、电影表、场次表、订单表怎么建
售票系统的表结构不需要复杂,但关系必须清晰。我一般用Access或SQL Server做数据库,毕业设计用Access有个现实好处:零配置,数据库文件跟着项目走,交作业时把整个文件夹拷过去就能运行。这里以Access为例,但它和SQL Server的建表SQL差别不大,换过去只需改连接串。
核心表一共四张:用户表(Users)、电影表(Movies)、场次表(Sessions)、订单表(Orders)。如果要做选座,还需要一张座位明细表(OrderSeats),否则无法记录“这个订单买了哪几个座位”。
CREATE TABLE Users ( UserId AUTO_INCREMENT PRIMARY KEY, Username VARCHAR(50) NOT NULL UNIQUE, PasswordHash VARCHAR(64) NOT NULL, Role VARCHAR(10) DEFAULT 'User' ); CREATE TABLE Movies ( MovieId AUTO_INCREMENT PRIMARY KEY, Title VARCHAR(100) NOT NULL, Duration INT NOT NULL, Price DECIMAL(6,2) NOT NULL, ReleaseDate DATE, Description MEMO ); CREATE TABLE Sessions ( SessionId AUTO_INCREMENT PRIMARY KEY, MovieId INT NOT NULL, HallName VARCHAR(20) NOT NULL, StartTime DATETIME NOT NULL, EndTime DATETIME, FOREIGN KEY (MovieId) REFERENCES Movies(MovieId) ); CREATE TABLE Orders ( OrderId AUTO_INCREMENT PRIMARY KEY, UserId INT NOT NULL, SessionId INT NOT NULL, OrderNo VARCHAR(30) NOT NULL, TotalAmount DECIMAL(8,2) NOT NULL, OrderTime DATETIME DEFAULT NOW(), Status VARCHAR(10) DEFAULT 'Paid', FOREIGN KEY (UserId) REFERENCES Users(UserId), FOREIGN KEY (SessionId) REFERENCES Sessions(SessionId) ); CREATE TABLE OrderSeats ( OrderSeatId AUTO_INCREMENT PRIMARY KEY, OrderId INT NOT NULL, SessionId INT NOT NULL, RowNum INT NOT NULL, ColNum INT NOT NULL, FOREIGN KEY (OrderId) REFERENCES Orders(OrderId), FOREIGN KEY (SessionId) REFERENCES Sessions(SessionId) );我在这套设计里故意把价格放在Movies表而非Sessions表,这是很多初学设计容易忽略的区别。如果你打算做“工作日打折”或“首映场加价”,那就得把价格挪到Sessions表,否则同一个电影不同场次无法差异化定价。做课程设计时要把这个取舍写进答辩说明里,这本身就是很好的加分项。
座位表不存储“是否已售”字段,这一点要重点解释:已售状态只存在于Orders和Sessions的关联查询中——如果某场次某个座位在OrderSeats里存在记录,那它就是已被购买的。这种“状态由数据推导”的设计哲学,比专门建一个SeatStatus字段更严谨,因为任何时刻你都能根据订单反推出场次的所有已售位置,不会出现“订单已删除但座位还显示锁定”的矛盾。
2.3 登录注册模块:MD5加密、验证码与角色权限
登录注册是答辩时被问得最细的模块之一。老师会问:密码是明文存储吗?如果是,直接不合格。这里给出一个最小的安全方案,不是让你去实现复杂的加密体系,而是展示你有这个意识。
public static string MD5Hash(string input) { using (var md5 = System.Security.Cryptography.MD5.Create()) { byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input + "CinemaTicketSalt"); byte[] hash = md5.ComputeHash(bytes); return BitConverter.ToString(hash).Replace("-", "").ToLower(); } }关键点是加了盐(Salt)。原密码直接做MD5,用彩虹表一查就破;加盐后同样的密码产生不同哈希,直白地提升了安全性。注册时写入MD5Hash(password),登录时把用户输入的密码用相同规则哈希后比对。这个方案不是最安全的,但作为课程设计已经体现专业度了。
角色权限用最简单的方式:Users表的Role字段标记管理员或普通用户。管理员能进入管理界面添加电影、设置场次;普通用户只能购票。判断逻辑写在登录成功后的跳转处:
if (user.Role == "Admin") { AdminForm adminForm = new AdminForm(user); adminForm.Show(); } else { TicketForm ticketForm = new TicketForm(user); ticketForm.Show(); }有些同学会把所有界面堆在一个Form里,用控件可见性切换菜单,这是典型反模式。一旦项目稍大,Form文件动辄上千行,改一个按钮事件都可能牵连到别的逻辑。按角色拆Form,本质上是在做模块拆分,这一步想通,后面加功能就是加文件而不是改老代码。
界面布局方面,主窗体建议用Panel做容器嵌套:顶部放菜单栏和当前登录用户信息,左侧放影片分类,中间用DataGridView或FlowLayoutPanel展示影片卡片。用 FlowLayoutPanel 放卡片型按钮时,给每个影片一个自定义UserControl,这个习惯对日后扩展到Web项目也有帮助。
3. 核心售票流程:选片、选座、下单、出票的完整实现
3.1 电影列表与场次查询:联表查询与数据绑定
登录之后,用户看到的是正在上映的电影列表。这里涉及两个查询:电影信息和场次信息。把两者分开查再在内存里关联,是初学会犯的毛病;正确做法是直接联表查询,让数据库替你做关联。
string sql = @"SELECT s.SessionId, m.Title, m.Duration, m.Price, s.HallName, s.StartTime, s.EndTime FROM Sessions s INNER JOIN Movies m ON s.MovieId = m.MovieId WHERE s.StartTime >= @today ORDER BY s.StartTime"; using (OleDbCommand cmd = new OleDbCommand(sql, conn)) { cmd.Parameters.AddWithValue("@today", DateTime.Today); OleDbDataAdapter adapter = new OleDbDataAdapter(cmd); DataTable dt = new DataTable(); adapter.Fill(dt); dataGridView1.DataSource = dt; }注意WHERE s.StartTime >= @today,筛选掉已经开映的场次。在这个场景里我用了参数化查询而不是拼字符串,正是防止SQL注入的常规手段——你的系统虽然只在演示环境跑,这个习惯建议从第一行代码就养成。
展示层还有一个细节:把DataGridView的列头改成中文,隐藏不必要的ID列。曾经见过一个项目,DataGridView把所有外键ID全显示出来,用户买票时表里出现MovieId: 3, SessionId: 8,答辩老师看到会忍不住皱眉。
dataGridView1.Columns["SessionId"].Visible = false; dataGridView1.Columns["Title"].HeaderText = "电影名称"; dataGridView1.Columns["HallName"].HeaderText = "影厅"; dataGridView1.Columns["StartTime"].HeaderText = "开场时间";3.2 选座界面:二维数组与座位状态管理
选座是这个系统里最有“技术含量”的部分,也最容易做砸。常见的方案有两种:用多个Button控件平铺(比如10排×14座就是140个按钮),或者用自定义绘制。课程设计阶段建议用Button铺出来,理由也很直白:Button自带点击事件,不需要处理鼠标坐标换算,代码量小且直观。
但用Button铺,就必须用一个二维数组来映射屏幕控件和数据状态,否则你没法知道“第3排第5座”对应哪个Button。
private Button[,] seatButtons; private int rows = 10; private int cols = 14; // 初始化选座面板 private void InitSeatPanel() { seatButtons = new Button[rows, cols]; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { Button btn = new Button(); btn.Size = new Size(30, 30); btn.Location = new Point(c * 35 + 10, r * 35 + 10); btn.Tag = new SeatPosition(r, c); // 自定义类存储行列 btn.BackColor = Color.Gray; // 灰色=可选 btn.Click += SeatButton_Click; seatPanel.Controls.Add(btn); seatButtons[r, c] = btn; } } }座位状态映射规则是:灰色可点、红色已售不可点、黄色是自己正在选择的座位。为什么还在内存里维护状态?因为已售座位要从数据库读出来初始化,但用户点了哪个座位属于临时状态,不需要写入数据库,直接用控件的BackColor记录就行。
这里遇到的关键问题是点击事件里如何处理选中状态:
private void SeatButton_Click(object sender, EventArgs e) { Button btn = sender as Button; SeatPosition pos = (SeatPosition)btn.Tag; if (btn.BackColor == Color.Red) { MessageBox.Show("该座位已售出,请选择其他座位。"); return; } if (btn.BackColor == Color.Gray) { btn.BackColor = Color.Yellow; // 选中 selectedSeats.Add(pos); } else { btn.BackColor = Color.Gray; // 取消选中 selectedSeats.Remove(pos); } }逻辑说明:每个座位Button在内存中有一个对应位置,选中用黄色标记,加入List保存,取消则移出。已售状态用红色锁定并屏蔽点击,这一步从事件源头堵住了误操作。
3.3 订单生成与票号生成:事务与唯一号规则
用户选好座位后点击“确认支付”,系统要完成三件事:生成订单、写入座位记录、更新座位显示状态。重点是这三件事必须整体成功或整体失败——如果订单生成了但座位写入失败,用户付了钱却没有位置,这是严重的数据事故。
using (OleDbConnection conn = new OleDbConnection(connString)) { conn.Open(); OleDbTransaction tran = conn.BeginTransaction(); try { string orderNo = GenerateOrderNo(); decimal amount = selectedSeats.Count * moviePrice; // 1. 插入订单 string sqlOrder = @"INSERT INTO Orders (UserId, SessionId, OrderNo, TotalAmount) VALUES (@uid, @sid, @orderNo, @amount)"; using (OleDbCommand cmd = new OleDbCommand(sqlOrder, conn, tran)) { cmd.Parameters.AddWithValue("@uid", currentUser.UserId); cmd.Parameters.AddWithValue("@sid", currentSession.SessionId); cmd.Parameters.AddWithValue("@orderNo", orderNo); cmd.Parameters.AddWithValue("@amount", amount); cmd.ExecuteNonQuery(); } // 2. 获取自增ID string sqlGetId = "SELECT @@IDENTITY"; int orderId; using (OleDbCommand cmd = new OleDbCommand(sqlGetId, conn, tran)) { orderId = Convert.ToInt32(cmd.ExecuteScalar()); } // 3. 批量写入座位明细 foreach (SeatPosition pos in selectedSeats) { string sqlSeat = @"INSERT INTO OrderSeats (OrderId, SessionId, RowNum, ColNum) VALUES (@oid, @sid, @row, @col)"; using (OleDbCommand cmd = new OleDbCommand(sqlSeat, conn, tran)) { cmd.Parameters.AddWithValue("@oid", orderId); cmd.Parameters.AddWithValue("@sid", currentSession.SessionId); cmd.Parameters.AddWithValue("@row", pos.Row); cmd.Parameters.AddWithValue("@col", pos.Col); cmd.ExecuteNonQuery(); } } tran.Commit(); MessageBox.Show("购票成功!订单号:" + orderNo); } catch (Exception ex) { tran.Rollback(); MessageBox.Show("购票失败,已回滚:" + ex.Message); } }事务应用是这个项目的灵魂。没有事务时,如果在座位插入过程中异常断开,数据库就会留下一张订单带着一堆没有落地的座位记录——用户界面不管怎么刷新都找不到自己的座位,票却“买掉了”。
订单号生成规则同样值得说明。不要用自增ID直接当订单号暴露给用户,原因是ID会暴露当天订单量,且格式不一致难看。常见的实践是时间戳+随机尾数:
private string GenerateOrderNo() { return DateTime.Now.ToString("yyyyMMddHHmmss") + new Random().Next(100, 999).ToString(); }这串规则保证了同一秒内订单号不会重复(概率极低),并且按时间可读。作为课程设计,还可以再加一个用户ID末尾,增强唯一性。
出票环节利用C#的PrintDocument控件画一个简单的票据画面,输出到打印机即可。这里不展开绘图细节,但如果答辩演示没有打印机,直接用“购票成功”对话框展示订单信息替代就可以。
4. 千万别踩的五个坑:从连接串怪毛病到座位状态卡死
4.1 数据库连接串里的玄学:Access 与编码
现象:用ODBC连Access数据库,程序在本机跑得好好的,发给老师或室友运行,启动后直接报错“找不到文件”或“磁盘或网络错误”。
原因:连接串里用了绝对路径,比如Provider=Microsoft.ACE.OLEDB.12.0;Data Source=C:\Users\张三\Desktop\CinemaDB.mdb,每换一台机器路径就失效。这是最常见的Access项目交付翻车原因。
解决:不要在代码里写死路径。用Application.StartupPath获取程序所在目录,把数据库放在Debug输出目录下:
string connString = @"Provider=Microsoft.ACE.OLEDB.12.0;" + @"Data Source=" + Application.StartupPath + @"\CinemaDB.mdb";注意我把@放在每个字符串前面,是为了避免路径中的反斜杠被当成转义符。另外还要检查一下:数据库文件属性里“复制到输出目录”要设为“如果较新则复制”,否则发布时不会带上数据库。
4.2 座位状态并发不一致的教训
现象:演示时开两个客户端窗口,A用户选了5排8座,B用户也选了5排8座,两边都显示“可选”,B抢着下单成功后A的界面才刷新成红。
原因:座位可用性检查只发生在下单那一瞬间,但你的代码可能没有在查询和插入之间再校验一次。两个终端同时读到灰色,各自下单,最终结果取决于谁先写入。
解决:在事务内部、插入座位之前,先查询该座位是否已经被卖出。
string sqlCheck = @"SELECT COUNT(*) FROM OrderSeats WHERE SessionId=@sid AND RowNum=@row AND ColNum=@col"; // 如果 count > 0,直接抛异常回滚这个方法叫乐观锁的精简版——不是靠数据库锁表,而是靠业务层二次检查。真实商业系统会用行锁或Redis分布式锁,但课程设计做到这一步,答辩老师已经能看到你的并发意识了。
4.3 日期时间显示的水土不服
现象:DataGridView里StartTime显示成2025/1/5 0:00:00,票面上却要显示2025年1月5日 14:30,格式对不上。
原因:Access的DATETIME字段读出来是原生DateTime对象,DataGridView默认按文化格式化显示,票面如果你用.ToString()不带参数,输出的是当前操作系统时间格式。
解决:显示层单独做格式化,不要改数据库。在绑定完成事件里设置列格式:
dataGridView1.Columns["StartTime"].DefaultCellStyle.Format = "yyyy-MM-dd HH:mm";在票据字符串里用显式格式调用:
orderTimeLabel.Text = "放映时间:" + session.StartTime.ToString("yyyy年MM月dd日 HH:mm");4.4 百思不得其解的“已在另一个线程上运行”
现象:在选座或刷新电影列表时,如果用了BackgroundWorker或Task.Run加载数据,后台线程里直接给dataGridView1赋值,界面就报“系统.InvalidOperationException: 跨线程操作无效”。
原因:WinForms的UI控件只能在主线程访问。后台线程执行数据库查询,拿到结果后直接赋值给控件,就触发了这个异常。
解决:回到UI线程再刷新控件。用Invoke是常规做法,或者Control.Invoke加委托。代码示例:
dataGridView1.Invoke(new Action(() => { dataGridView1.DataSource = dt; }));更省事的方案是干脆不用异步线程——课设数据量通常不大,数据库查询都在几十毫秒内完成,同步加载即可,界面短暂卡顿在演示场景反而容易被接受。竞态问题不深究,但知道这个坑会更从容。
4.5 Access数据库文件被占用,改表结构时报“无法锁定文件”
现象:运行时想手动打开CinemaDB.mdb检查数据,发现文件被LDB锁定,修改表结构直接报错。
原因:程序连接未释放。OleDbConnection如果是静态字段且没关闭,就会一直持有文件锁。改表时程序还开着,Access当然不让别人动。
解决:写一个工具类,每次都建立连接并立即用完释放:
public static DataTable ExecuteQuery(string sql, params OleDbParameter[] parameters) { using (OleDbConnection conn = new OleDbConnection(connString)) using (OleDbCommand cmd = new OleDbCommand(sql, conn)) { if (parameters != null) { cmd.Parameters.AddRange(parameters); } conn.Open(); OleDbDataAdapter adapter = new OleDbDataAdapter(cmd); DataTable dt = new DataTable(); adapter.Fill(dt); return dt; } }这里最容易被忽略的细节是using关键字。如果只是conn.Open()而没有Close(),程序一运行连接就不会释放,课设调试期间反复打开关闭,最后系统文件锁积累到连Access都打不开。
5. 查漏与提升:把毕业设计做到答辩不被问倒
5.1 为什么要区分“登录后能做什么”和“直接点按钮做什么”
很多学生会犯同一个错误:普通用户的管理员按钮虽然隐藏了,但还是能通过双击或快捷键触发事件。做权限时,不仅要控制界面显示,还要在按钮事件入口再次判断角色,否则就是“界面挡住了但代码没挡住”。
这个论断实操性很强。比如管理员删除电影的按钮事件,开头写一行校验代码:
if (currentUser.Role != "Admin") { MessageBox.Show("无权限执行此操作。"); return; }这个细节别看小,却是答辩时展示思维严谨度的有效证据,远远好过把全部精力花在界面特效上。
5.2 数据库备份按钮:用代码代替手工复制文件
你的系统如果给管理员界面加一个“一键备份数据库”,答辩时会有意想不到的好效果。思路:复制CinemaDB.mdb到带时间戳的备份目录。
string backupPath = Path.Combine( Application.StartupPath, @"Backup", "CinemaDB_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".mdb"); Directory.CreateDirectory(Path.GetDirectoryName(backupPath)); File.Copy(Application.StartupPath + @"\CinemaDB.mdb", backupPath, true); MessageBox.Show("备份成功:" + backupPath);这段代码的价值在于它把“工程能力”展现出来——系统不仅能用,还要具备基本的运维能力。注意备份前要确保所有连接已关闭,否则文件被占用,复制会失败。
5.3 日志记录,不只是调试工具
写一个全局异常捕获,记录下来,比答辩前临时加提示框好用得多。在Main函数或Program.cs里注册:
static void Main() { Application.ThreadException += new System.Threading.ThreadExceptionEventHandler(OnThreadException); Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); Application.Run(new LoginForm()); } private static void OnThreadException(object sender, System.Threading.ThreadExceptionEventArgs e) { string logPath = Application.StartupPath + @"\error.log"; File.AppendAllText(logPath, DateTime.Now + " - " + e.Exception.ToString() + Environment.NewLine); MessageBox.Show("系统出现异常,请联系管理员查看日志。"); }日志里记录的是完整堆栈信息。你把error.log放在项目交付文件夹里,答辩时如果当场出现未被预期到的异常,靠这个日志可以快速定位问题,比在考场上临时改代码从容得多。
5.4 数据效验:日期不能“昨天开映”
新增电影场次时,如果管理员选了昨天的日期,系统最好直接拒绝,否则就会出现用户能买“已过期场次”的情况。在保存按钮的事件里做一次比较:
if (startDateTime <= DateTime.Now) { MessageBox.Show("场次时间必须在当前时间之后。"); return; }同理还有座位数量的校验:每场座位数不能超过影厅容量,票价格不能为负数。这类校验规则用控件事件而不是数据库约束来写,是因为交互友好性需要前端反馈,数据库约束往往是最后防线,而不是第一道门。
6. 我习惯放在最后做的事情:数据验证、清理与交付
系统写完后,不要急着打包,我会按一套固定顺序走三件事,这套顺序不复杂,但能有效避免交付前翻车。
第一步是清空测试数据。课设开发过程中,数据库里必然有一堆乱七八糟的测试记录,比如反复插入的《测试电影》、几十条TestUser。交付前写一小段SQL,删掉这些数据但保留表结构和几个必要的基础记录(如一个管理员账号)。
DELETE FROM OrderSeats; DELETE FROM Orders; DELETE FROM Sessions; DELETE FROM Movies; DELETE FROM Users WHERE Role = 'User';删除顺序值得说明:先删子表再删父表,因为有外键约束。如果反过来,删除父表会触发外键冲突,这是常见闭坑。删完后重新插入一个管理员账号,密码用你自己脑子里的新字符串再去注册生成。
第二步是检查代码里的魔法字符串。搜一下代码里有没有"Provider=Microsoft.ACE.OLEDB.12.0;..."出现在五个不同地方。如果有,那就是没做封装的问题典型,逐个替换成对工具的调用。这个动作不在性能优化,而在代码结构可维护性。答辩老师翻代码时看到路径散落各处,印象分会受影响。
第三步是做一次“纯净环境”测试。把项目bin\Debug整个目录拷到一台没有Visual Studio的机器上跑一遍完整流程:注册账号 -> 选电影 -> 查场次 -> 选座 -> 下单。这个测试能暴露数据库路径错误、缺少运行时组件、数据库文件未随目录复制等交付常见问题。很多学生打包只拷exe,数据库没带,运行时就报错——这台机器测试一遍就能全部兜住。
最后说一个我自己的习惯:在电影信息、场次界面里留一个“账面核对”功能入口,比如在菜单里加“今日营收统计”。别人做课程设计大多数只做售票,你能做账目汇总,价值显得完整。统计逻辑也不复杂:
SELECT DATE(OrderTime) AS 日期, SUM(TotalAmount) AS 营收 FROM Orders GROUP BY DATE(OrderTime);这段SQL把订单按天聚合,展示销售额。虽然从实际商业系统角度看还不够完整——应该区分已付款和已退款——但在课设场景下,已经能证明你理解“报表也是系统的一部分”。甚至不用做图表,显示在DataGridView里就行。加进项目的代码量不大,但答辩时的讨论空间很大:老师可能会问“如果发生退票怎么处理”,你顺势把订单状态字段扩展方案说出来,基本上就能把这一题变成加分题。
动手做之前记住一句话:这个系统最值得你花时间的不是界面花哨程度,而是那些能自圆其说的业务规则。座位状态如何流转、订单与座位如何保持一致性、用户密钥如何存储——这些才是支撑答辩半小时的底气。把数据关系理清,把事务和权限做到位,哪怕界面朴素一点,老师也挑不出实质性问题。希望这份思路能帮到你,少走几个我已经踩过的弯。
本文还有配套的精品资源,点击获取