
做“观幕”这个基于 Flask 的电影购票网站最初是因为本地一家小影院还在用微信群接龙选座票务状态经常对不上账。用 Flask Vue MySQL 这套前后端分离方案做了近两个月最后落地了一个能看片、能选座、能下单、能查订单的完整购票平台。这篇文章把整个实现过程、数据库设计思路、选座并发处理、前后端联调和部署踩坑都整理一遍。它特别适合三类人想练前后端分离全栈的开发者、正在找毕设或培训项目的同学以及想给小型影院做信息化系统的团队。1. 项目概览与核心需求拆解1.1 购票网站背后要解决的业务问题网上很多“电影购票系统”demo 看起来简单真正动手做就会发现这不是一个纯增删改查项目。从用户端看需要支持注册登录、电影列表和详情、挑选场次、在线选座、生成订单、模拟支付、查看我的订单。从运营端看还需要维护电影信息、放映厅座位布局、场次排期和订单统计。这两类需求其实对应“内容管理 库存管理 交易流程”三件事其中库存管理是绝大多数人会做砸的地方。选座购票和普通商品电商有本质区别。一件普通商品只有一个库存数字而一个场次的每个座位都是一件独立库存。同一场次两个用户不能锁定同一个座位。这就要求数据库设计、后端接口和前端交互都围绕“座位状态”展开。所以我在动手写代码前先把业务规则固化成几条硬约束一个用户未支付的订单最多锁定座位15分钟同一场次同一座位在同一时刻只能出现在一个有效订单中座位一旦被锁定就必须生成一张待支付订单已售座位不可被再次下单。这几条约束是整个项目的骨架后面所有技术方案都是为了让它们能稳定落地。1.2 为什么是 Flask Vue MySQL而不是别的后端选 Flask理由是稳。Flask 够轻小项目可以从单文件起步业务复杂了再按蓝图拆模块不会把结构搞僵。生态也成熟flask-sqlalchemy、flask-migrate、flask-jwt-extended、flask-cors 这些扩展覆盖了业务后端九成需求。还有一个隐藏优势是 Python 生态的连通能力以后想给“观幕”加个类似“猜你喜欢”的推荐系统或者抓豆瓣评分都能在同一个语言生态里直接解决。有人会问FastAPI 不是更现代、性能更好、还自带 Swagger 文档吗确实是但 FastAPI 的异步模型对刚接触 Web 开发的人来说反而容易踩坑。Flask 同步模型简单直接社区资料多遇到问题搜一下就有答案。在这个项目里性能瓶颈不在框架本身而在数据库事务和座位锁定的设计上所以选 Flask 是对的。前端选 Vue核心原因是复杂交互。选座页要求用户能实时看到哪座能选、哪座已售点击座位要立刻反馈选中状态还要计算总价、提交订单这种“数据驱动界面”的场景天然适合 Vue 的响应式机制。组件化设计也很适合把座位网格拆成独立组件。相比 ReactVue 的模板语法更亲和一些中小团队上手成本低。数据库用 MySQL看重的是事务能力和统计能力。订单状态、座位状态必须靠事务保证强一致用 Redis 或 MongoDB 来做这一层会很吃力。另外票务系统的运营报表、排片统计用 SQL 写起来非常顺手这也是选 MySQL 的重要原因。2. 数据库层票务系统的地基2.1 七张核心表撑起完整业务数据库是整个项目的地基。我画 ER 图改了四版最终稳定下来的核心表有七张用户表 users、电影表 films、放映厅表 halls、场次表 screenings、场次座位表 screening_seats、订单表 orders以及用来记录订单和座位多对多关系的 order_seats 关联表。直接看建表语句更清晰CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, phone VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE films ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL, poster_url VARCHAR(255), genre VARCHAR(50), duration INT COMMENT 时长单位分钟, release_date DATE, synopsis TEXT ); CREATE TABLE halls ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, seat_rows INT NOT NULL, seat_cols INT NOT NULL ); CREATE TABLE screenings ( id INT AUTO_INCREMENT PRIMARY KEY, film_id INT NOT NULL, hall_id INT NOT NULL, start_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, INDEX idx_film_time (film_id, start_time), FOREIGN KEY (film_id) REFERENCES films(id), FOREIGN KEY (hall_id) REFERENCES halls(id) ); CREATE TABLE screening_seats ( id INT AUTO_INCREMENT PRIMARY KEY, screening_id INT NOT NULL, hall_id INT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待售 1锁定 2已售, UNIQUE KEY uk_screening_seat (screening_id, row_no, col_no), FOREIGN KEY (screening_id) REFERENCES screenings(id), FOREIGN KEY (hall_id) REFERENCES halls(id) ); CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, screening_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3超时释放, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME NULL, INDEX idx_user_status (user_id, status), FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (screening_id) REFERENCES screenings(id) ); CREATE TABLE order_seats ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, screening_seat_id INT NOT NULL, UNIQUE KEY uk_order_seat (order_id, screening_seat_id), FOREIGN KEY (order_id) REFERENCES orders(id), FOREIGN KEY (screening_seat_id) REFERENCES screening_seats(id) );这里有个关键设计座位状态放在 screening_seats而不是 halls 表里。原因很简单同一个放映厅在不同场次的座位可用状态不同座位库存必须“按场次管理”。如果只存一个固定座位布局再单独维护已售数组后面并发控制和查询都会非常别扭。2.2 座位状态与超卖防护的并发方案这个部分是整个项目最容易做坏的地方。很多人会把下单写成了“先查座位是否空闲再插入订单”在高并发下必然出问题。两个请求同时查出座位空闲同时下单座位就超卖了。正确思路是用一条条件 UPDATE 抢占座位UPDATE screening_seats SET status 1 WHERE screening_id ? AND row_no ? AND col_no ? AND status 0;执行后如果影响行数是 1说明当前请求成功把座位从未售改成锁定如果影响行数是 0说明有别的请求已经抢先改了状态。这一步利用的是数据库行锁和原子更新。只要所有下单流程都走这一条语句就不会出现两个订单拿到同一个座位。事务方面我推荐的做法是先创建待支付订单再逐个抢占座位每次抢占都检查受影响行数一旦某个座位抢占失败就回滚整个事务并把之前锁成功的座位全部释放。用伪代码表达整个流程from sqlalchemy import text from extensions import db def create_order(user_id, screening_id, seat_keys): seat_keys 形如 [{row: 3, col: 8}, ...] with db.session.begin(): screening db.session.execute( text(SELECT id, price, hall_id FROM screenings WHERE id:id), {id: screening_id} ).fetchone() if not screening: raise BusinessError(场次不存在) total_price screening.price * len(seat_keys) order_no generate_order_no() db.session.execute( text(INSERT INTO orders(order_no, user_id, screening_id, total_price, status) VALUES(:order_no, :user_id, :screening_id, :total, :status)), {order_no: order_no, user_id: user_id, screening_id: screening_id, total: total_price, status: 0} ) order_id db.session.execute(text(SELECT LAST_INSERT_ID())).scalar() for key in seat_keys: row, col key[row], key[col] result db.session.execute( text(UPDATE screening_seats SET status1 WHERE screening_id:sid AND row_no:r AND col_no:c AND status0), {sid: screening_id, r: row, c: col} ) if result.rowcount 0: raise BusinessError(f座位 {row}排{col}号已被锁定或售出请重新选座) seat_id db.session.execute( text(SELECT id FROM screening_seats WHERE screening_id:sid AND row_no:r AND col_no:c), {sid: screening_id, r: row, c: col} ).scalar() db.session.execute( text(INSERT INTO order_seats(order_id, screening_seat_id) VALUES(:oid, :sid)), {oid: order_id, sid: seat_id} ) return order_id关于锁定的超时释放我再多讲一句。如果用户锁了座但一直不支付座位会一直锁着。我的处理是订单创建 15 分钟未支付就标记为超时释放同时把座位状态改回 0。小项目不用引入 Celery用 APScheduler 定时任务轮询“待支付超过15分钟”的订单即可。3. Flask 后端把购票流程变成稳定接口3.1 工程结构和蓝图规划Flask 项目虽然能用单文件 app.py 写完所有逻辑但“观幕”拆成蓝图后结构会清爽很多。我的目录是这样的guanmu-server/ ├── app.py # 应用入口 ├── config.py # 配置数据库、密钥 ├── extensions.py # db, cors 等扩展实例 ├── models.py # SQLAlchemy 模型 ├── common/ │ ├── response.py # 统一返回封装 │ └── errors.py # 业务异常定义 ├── api/ │ ├── auth.py # 注册、登录 │ ├── films.py # 电影列表、详情 │ ├── screenings.py # 场次、座位 │ ├── orders.py # 下单、订单查询、支付 │ └── admin.py # 后台管理接口 └── utils/ ├── jwt_auth.py # token 校验装饰器 └── order_no.py # 订单号生成核心思路是基础依赖集中在 extensions.py 初始化蓝图在 app.py 注册models.py 只放数据模型和映射关系业务逻辑写在各自蓝图里。层次清晰之后排查问题的效率会明显提升。3.2 核心接口逐条拆解电影列表接口要支持“正在热映”和“即将上映”两种状态我的实现大致是这样from sqlalchemy import select films_bp.get(/api/films) def list_films(): status request.args.get(status, now) stmt select(Film) if status now: stmt stmt.where(Film.release_date datetime.today()) elif status coming: stmt stmt.where(Film.release_date datetime.today()) films db.session.scalars(stmt).all() return success({list: [film.to_dict() for film in films]})场次和座位接口是前端选座的地基。我建议场次接口返回电影、厅、时间、价格、剩余座位数方便列表页展示座位接口直接返回整个座位矩阵screenings_bp.get(/api/screenings/int:screening_id/seats) def get_seats(screening_id): seats db.session.scalars( select(ScreeningSeat).where(ScreeningSeat.screening_id screening_id) ).all() hall db.session.get(Hall, seats[0].hall_id) matrix [] for r in range(1, hall.seat_rows 1): row_data [] for c in range(1, hall.seat_cols 1): seat next((s for s in seats if s.row_no r and s.col_no c), None) row_data.append({ row: r, col: c, status: seat.status if seat else 0 }) matrix.append(row_data) return success({rows: hall.seat_rows, cols: hall.seat_cols, matrix: matrix})下单接口的完整事务逻辑在第 2.2 节已经说明。需要补充的是支付模拟接口前端点击“确认支付”后端把订单状态从 0 改为 1同时把座位从锁定状态 1 改为 2 已售。这一样要放在同个事务里避免出现订单已支付但座位还是锁定的脏状态。订单查询接口要支持用户查看“待支付”“已支付”“已取消”三类订单。容易踩的坑是忘了查询关联座位导致订单里看不到具体座位号。我建议在 Order 模型里加一个 seats 属性通过 order_seats 关联查询后拼接成“3排8号、3排9号”返回给前端。3.3 统一响应、跨域和错误捕获前后端分离项目统一接口响应格式能节省大量沟通成本。我用的格式是{ code: 0, message: success, data: {} }code 为 0 表示成功非 0 表示业务错误1001 是参数错误2001 是座位冲突。前端 axios 拦截器只看 code 就能判断结果不需要再去解析 HTTP 状态码。业务异常用全局错误处理器统一捕获app.errorhandler(BusinessError) def handle_business_error(e): return jsonify({code: e.code, message: str(e), data: None}), 200跨域问题在开发环境用 Flask-CORS 直接放开生产环境如果前端由 Nginx 托管就只放开指定域名CORS(app, resources{r/api/*: {origins: [http://localhost:5173, https://guanmu.example.com]}})这里有个容易被忽略的点跨域请求如果携带 Authorization 头必须在 CORS 配置里允许 headers否则浏览器会直接拦截。配置方法是加上allow_headers[Content-Type, Authorization]。我在项目初期就因为这个排查了很久前端一直报跨域后端却看不到任何异常日志。4. Vue 前端选座购票体验的开发4.1 从零搭建 Vue 项目与依赖安装前端我用 Vue 3 Vite 搭建。相比 Vue CLIVite 启动快、依赖少配合 Vue Router 和 Pinia 做状态管理很顺手。组件库选 Element Plus表单、弹窗、消息提示可以直接复用节省开发时间。创建项目和安装关键依赖npm create vuelatest guanmu-front cd guanmu-front npm install npm install axios element-plus pinia开发环境跨域是绕不开的问题。后端跑在 5000 端口前端跑在默认 5173 端口不同端口必然触发跨域。我推荐用 Vite 的代理把/api开头的请求转发到后端// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:5000, changeOrigin: true } } } })配好代理后前端 axios baseURL 直接写成/api请求就完全不受跨域干扰。这个方法比到处开 CORS 优雅得多生产环境换成 Nginx 反代时前端代码几乎不用改。4.2 页面结构与路由规划“观幕”前端路由围绕购票流程设计/电影列表页/film/:id电影详情页展示海报、简介、未来几天的场次列表/booking/:screeningId选座页核心交互页/order/confirm订单确认页/orders我的订单页/login登录页路由配置里需要给需要登录的页面加导航守卫未登录直接跳转到登录页并带上回跳地址。这个细节很多个人项目会漏但对真实用户来说是基本体验。4.3 核心难点座位组件怎么设计状态选座组件是整个前端最值得打磨的部分。我把它拆成独立的 SeatMap 组件输入一份座位矩阵数据输出一个已选座位列表。先看模板结构。后端返回的座位矩阵是二维数组每个座位包含 row、col、status 三个字段。我用表格渲染template div classseat-map div v-for(row, rowIdx) in matrix :keyrowIdx classseat-row span classrow-label{{ rowIdx 1 }}排/span div v-forseat in row :keyseat.col classseat :classseatClass(seat) clicktoggleSeat(seat) /div /div /div /templatescript 部分的核心是 seatClass 和 toggleSeatconst selectedSet ref(new Set()) function seatClass(seat) { if (seat.status 2) return sold if (isSelected(seat)) return selected return available } function toggleSeat(seat) { if (seat.status 2 || seat.status 1) return const key ${seat.row}-${seat.col} if (selectedSet.value.has(key)) { selectedSet.value.delete(key) } else { if (selectedSet.value.size maxSelect) { ElMessage.warning(每单最多选择6个座位) return } selectedSet.value.add(key) } }交互规则很明确status 为 2 的座位已售不可点status 为 1 的座位被锁定不可点可选座位点击后加入或移出已选集合。样式上建议区分四种颜色可选坐位浅灰已选绿色锁定深灰已售红色。用户一进选座页就知道哪些位置能选。还要注意数据新鲜度。座位状态是动态的用户停留时间一长之前查到的座位数据可能已经变化。我在下单前会再调用一次座位接口做二次校验发现不一致就提示用户刷新选座。这虽然多一次请求但能避免用户选完座位后才发现座位已失效。4.4 axios 封装与用户状态管理axios 封装是做前端项目必须养成的习惯。我统一做三件事设置 baseURL、请求拦截器注入 token、响应拦截器统一处理业务码。import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 0) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(error.message) return Promise.reject(error) } ) export default requestPinia 里我只存两类数据用户信息和当前草稿订单。用户信息在登录成功后写入 localStorage 持久化刷新不丢。草稿订单包括场次信息、已选座位、总价选座页写入订单确认页读取支付成功后清空。5. 全流程联调与部署实战5.1 快速填充测试数据的小技巧没有测试数据前后端联调等于空转。我在建表后写了一个 init_data.py 脚本自动往库里塞 3 部电影、2 个放映厅、10 个场次和对应座位。每次重置数据库后一条命令就能恢复可演示状态。关键点在于座位数据的初始化一个厅 8 排 12 列共 96 个座位建厅之后要把所有座位的初始状态写入 screening_seats否则前端选座页就只能看到一片空白。另一种更快的方法是直接导入 SQL 文件。我建议两种方式都准备脚本方便开发环境反复刷新SQL 文件适合部署时快速初始化。5.2 前后端联调的关键动作联调阶段的效率工具我推荐两个Postman 用于后端接口验证浏览器开发者工具的 Network 面板用于前端排查。遇到问题先用 Postman 调接口确认是后端问题还是前端问题再用浏览器看请求和响应定位是参数错误还是渲染错误。这个排查顺序能省掉大量“互相甩锅”的时间。5.3 上线部署的两种简配方案部署取决于有没有 Nginx我提供两种方案。方案 AVue 构建后交给 Flask 托管。执行npm run build生成静态文件把 dist 目录放到 Flask 的 static 目录下再注册一个路由把非 /api 请求指向 index.htmlapp.route(/) def index(): return send_from_directory(static/dist, index.html) app.errorhandler(404) def spa_fallback(e): if request.path.startswith(/api): return e return send_from_directory(static/dist, index.html)这种方式最省事但静态资源性能略差。方案 B标准 Nginx 反代。Nginx 托管 Vue 静态文件/api反向代理到后端 5000 端口。生产环境我推荐方案 B既能做资源缓存又方便后端独立重启。server { listen 80; server_name guanmu.example.com; root /opt/guanmu-front/dist; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }后端启动用 gunicorn指定多 workergunicorn -w 4 -b 127.0.0.1:5000 app:app这里提醒一句多 worker 跑起来后数据库连接池也要跟着调。我见到不少项目 gunicorn 开了 4 个 workerMySQL 连接池还是默认值并发一高就开始疯狂报连接耗尽。调大pool_size和pool_recycle就能解决。6. 常见问题与排查速查表这个项目从零到上线我在六个方向踩了不少坑整理成速查表供大家直接参考。6.1 Python/Flask 环境类问题现象原因解决办法pip 安装包极慢或超时默认源在国外配置清华源pip install -i https://pypi.tuna.tsinghua.edu.cn/simple 包名运行报No module named flask_sqlalchemy包名带下划线环境错乱确认虚拟环境已激活重新安装 Flask-SQLAlchemySQLAlchemy 2.0 里db.query报错2.0 移除了 Query API改用db.session.scalars(select(Model)).all()JWT 解密报错secret key 不一致或过期统一在 config.py 管理 SECRET_KEY不要硬编码散落各处6.2 MySQL 连接类问题现象原因解决办法Authentication plugin caching_sha2_password cannot be loadedMySQL 8.0 默认认证插件不被旧客户端支持将用户改为 mysql_native_password或升级 PyMySQL 并加charsetutf8mb4Access denied for user密码或主机限制检查用户授权GRANT ALL ON guanmu.* TO user%中文乱码数据库、表、连接三个 charset 不统一建库用 utf8mb4连接串加?charsetutf8mb4MySQL server has gone away连接过期或事务过长给 engine 加pool_recycle36006.3 Vue 构建和联调问题现象原因解决办法Node Sass 编译失败node-sass 与 Node 版本不匹配改用 dart-sass或锁定 node-sass 版本请求 404 或 500路径显示http://localhost:5173/api/...Vite 代理未生效检查 vite.config.js proxy 配置并重启 dev server页面刷新后路由 404SPA 路由在服务端没有 fallbackNginx 加try_files $uri $uri/ /index.html;Element Plus 样式没生效未引入样式在 main.js 里import element-plus/dist/index.css6.4 数据与并发类问题现象原因解决办法两个请求同时买同一座位都提示成功下单逻辑没用条件 UPDATE改成UPDATE ... WHERE status0并检查 rowcount订单取消后座位仍然锁定状态回滚逻辑遗漏取消或超时订单时遍历关联座位并 UPDATE 回 0订单已支付座位却显示锁定支付接口和座位更新不在同一事务把订单状态更新和座位状态更新放在同一个事务提交列表页剩余座位数不准只统计 status2 的座位剩余数应为总数减去 status1 和 status2 的数量7. 实操体会与后续扩展方向7.1 整个项目给我留下的几条经验项目跑通之后最大的感受是这类 Flask 购票网站的技术难点不在 Flask 本身而在座位状态的一致性。只要把数据库座位模型和下单事务想清楚前端选座交互、接口设计都是围绕这个核心转的后面加功能也不会伤筋动骨。给想复现的朋友三个具体建议。第一先把表结构和座位状态机画在纸上再写代码不要边写边改否则每加一个功能都要重构数据层。第二下单接口一定要做完整的回滚测试专门模拟“第一个座位成功、第二个座位失败”的情况确认订单和座位不会出现半个成功。第三前端座位组件尽早抽象成独立模块不要和其他业务耦合以后如果要加会员折扣、套票规则改动范围可以很小。7.2 后续可以继续扩展的方向“观幕”如果要继续迭代有三个比较有价值的方向一是后台排片管理用 Flask-Admin 或者自己写一个简单的管理端二是给用户加“猜你喜欢”用 Flask 直接调用一个简单的协同过滤模型三是对接第三方支付沙箱把模拟支付换成真实支付流程。这些方向都建立在现有架构之上不需要推倒重来。按照这个思路搭建的版本不止能用于电影改改字段就能适配演出票、体育赛事票这类场景。最后提醒一句面粉筛得越细面包做得越松数据库状态机想得越透后面的开发就越顺。希望这篇实操记录能帮你把 Flask、Vue、MySQL 这条链路一次跑通。