ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flask+Vue前后端分离的火车票订票系统实战详解

Flask+Vue前后端分离的火车票订票系统实战详解 说起来有点不好意思这是我练手做的一个“火车票订票系统”后端用 Flask前端用 Vue前后端完全分离。项目虽然不大但从数据库设计、接口开发到前端页面联调、打包部署整个链路都走了一遍。这篇文章就是把Flask和Vue在这样一个真实业务场景里的配合方式、代码实现思路、还有我踩过的坑都整理出来希望能给正在做类似毕业设计或练手项目的朋友一个可参考的模板。之所以选择“火车票订票系统”这个方向是因为它麻雀虽小五脏俱全有用户体系、有查询搜索、有库存扣减、有订单状态流转还要处理并发下的余票问题这几乎覆盖了一个业务系统的核心难点。对于想入门 Web 全栈的人来说把这一套东西吃透比刷十遍教程都有用。1.1 订票系统的核心需求拆解在写任何代码之前我先把这个系统到底要干什么列成了清单。火车票订票普通人能想到的无非是查车次、买车票、看订单。但如果站在开发者的角度拆至少要分成三块业务用户端注册、登录、查询车次、选择车次下单、模拟支付、查看订单、取消订单。管理端管理车次信息比如新增车次、调整票价、维护余票数量查看所有用户的订单。系统底层用户身份校验、余票扣减与回补、订单状态机管理。这中间最关键的业务规则是同一趟车同一时段余票不能超卖。比如一列高铁只有500张票系统收到600个请求最终只能有500个下单成功。这个逻辑看起来简单但实现的时候如果考虑不周很容易出现超卖、负数库存这些事故。另外一个容易忽视的点是订单状态。我把订单状态定为待支付、已支付、已取消、已退票四种。用户下单之后如果一直不支付系统要给一个超时取消的兜底方案否则余票一直被占着不放其他用户就买不到票。这些梳理清楚之后才开始动工。1.2 为什么选定 Flask Vue 这套组合选技术栈的时候其实犹豫过当时纠结的是要不要上 FastAPI、前端要不要用 React。最后定下来“Flask Vue”是综合考虑了项目规模、团队熟悉度和部署成本。Flask 足够轻量一个应用文件就能启动配合 SQLAlchemy 做 ORM 上手很快。订票系统这种中小体量项目Flask 完全不会成为瓶颈。Vue 的模板语法和组件化设计对新手非常友好特别是 v-model 双向绑定能少写很多 DOM 操作代码开发查询表单这类页面很顺手。前后端分离之后后端只需要输出 JSON 接口前端只管渲染页面调试时可以完全脱离页面环境用 Postman 测接口效率很高。如果是大流量高并发的场景我会推荐 FastAPI异步性能确实强。但对我们这个项目来说Flask 的生态更成熟Flask-SQLAlchemy、Flask-Migrate、Flask-CORS 这套组合用起来非常稳出问题也好查资料。1.3 项目最终交付的功能清单整个项目做完之后页面和接口大概是下面这些我也顺便列出来给大家一个参考模块页面/功能对应后端接口用户认证注册页、登录页POST /api/auth/register、POST /api/auth/login车次查询首页搜索车次、车次列表展示GET /api/trains/search订购车票车次详情页、选择乘客、提交订单POST /api/orders/create订单中心我的订单列表、订单详情GET /api/orders/my、GET /api/orders/int:id订单操作模拟支付、取消订单POST /api/orders/int:id/pay、POST /api/orders/int:id/cancel管理后台车次管理、订单总览、用户管理GET/POST /api/admin/trains、GET /api/admin/orders这套接口设计基本覆盖了订票业务的全部核心链路而且每个接口的职责都尽量单一。别看现在列出来轻描淡写实际写代码的时候光是车次查询的模糊匹配和时分秒格式转换就折腾了不少时间。2. 技术选型的深层考量与架构设计很多新手拿到这种项目习惯直接开写我觉得这是最容易翻车的做法。先花时间把架构想清楚后面写代码才会顺畅。这里分享一下我当时在设计上几个关键决策的思路。2.1 Flask 和 FastAPI 到底怎么选这个话题在社区里讨论过很多次我当时的判断标准很简单看项目的核心矛盾是什么。我们的核心矛盾是业务逻辑复杂度而不是网络吞吐量。对比项FlaskFastAPI上手难度低约定少灵活中等依赖类型标注、Pydantic 模型异步支持需要插件传统同步为主原生 async/await文档生态非常成熟中文资料多官方文档优秀中文资料相对少ORM 集成SQLAlchemy 集成方案多也可以但容易踩类型转换的坑适用场景中小型业务系统高并发 API、数据分析服务订票系统最复杂的是订单状态流转和余票并发控制这些都属于业务逻辑问题跟异步高并发没有太大关系。所以我选择了更有把握的 Flask后期部署用 Gunicorn 同时跑几个 worker完全够用。2.2 前后端分离的架构分工这里说的前后端分离核心是“后端只负责数据前端只负责交互”。前端不再是 Flask 渲染的 HTML 模板而是一个独立的 Vue 应用通过 HTTP 请求和 JSON 数据与后端通信。开发模式下我的规划是这样的Vue 开发服务器跑在http://localhost:5173负责页面渲染。Flask 后端跑在http://localhost:5000负责 API 接口。前端通过 Vite 的 proxy 配置把/api开头的请求转发到后端5000端口顺带解决跨域问题。部署到服务器之后架构略有调整Vue 执行npm run build生成dist静态文件目录。Nginx 监听 80 端口把/路径指向dist目录把/api路径反向代理到 Flask 服务。这种架构的好处是前端页面和接口逻辑被彻底解耦前端想换皮肤、后端想换数据库都不会互相影响。2.3 数据表设计与字段规划数据库设计是整个系统的地基。我建了三张核心表用户表、车次表、订单表。用户表的关键设计是只保存密码哈希绝不保存明文密码。Python 的 werkzeug.security 库提供了generate_password_hash和check_password_hash这是 Flask 自带的安全工具比自己去写哈希算法靠谱得多。车次表的核心字段是seat_total总余票数和seat_remain当前余票数。我之所以单独用字段记录余票而不是每次实时统计订单是为了在扣减余票时可以直接执行原子 SQL 更新保证并发安全。这个点下面会详细展开。订单表是连接用户和车次的桥梁字段包括用户 ID、车次 ID、乘车人姓名、身份证号、订单状态、订单金额、创建时间。订单金额不能从车次表实时取必须在下单那一刻把当时的价格快照保存进来否则车次调价后历史订单金额就乱了。2.4 接口设计与业务规则梳理接口设计遵循 RESTful 风格每个资源都有清晰的语义。比如/api/orders/123/pay是对订单 ID 为 123 的资源执行支付操作一看就知道是做什么的。业务规则层面我最重视的是三点下单防重用户对一个车次重复提交下单请求后端必须能识别出来不能创建两条一模一样的订单。我处理的方式是在创建订单前先查同一用户、同一车次、状态为“待支付或已支付”的记录若存在直接返回“不可重复下单”。余票扣减采用 SQL 原子更新UPDATE trains SET seat_remain seat_remain - 1 WHERE id ? AND seat_remain 0只有受影响行数为 1才说明扣减成功。取消回补取消订单或者退票之后要把余票加回车次表同时更新订单状态。这些规则的先后顺序很重要先扣票再建订单。如果先建订单再扣票扣票失败时订单就是脏数据先扣票再建订单建订单失败时要把票回补。写代码的时候多想想这些边界场景项目质量会明显上一个台阶。3. Flask 后端核心模块实操后端我用的 Python 3.10 Flask 2.3数据库 SQLite生产环境切 MySQL 也可以因为 SQLAlchemy 的模型定义本身不依赖具体数据库。接下来我把几个核心模块的代码和思路贴出来每一段都是可以直接跑通的。3.1 项目初始化与依赖管理我习惯在项目里建一个虚拟环境把依赖都锁在requirements.txt里。整个项目结构是这样backend/ ├── app.py # Flask 入口应用 ├── models.py # SQLAlchemy 数据模型 ├── auth.py # 用户注册登录与 JWT 工具 ├── routes/ │ ├── trains.py # 车次查询接口 │ └── orders.py # 订单相关接口 ├── requirements.txt └── config.py # 配置文件依赖清单大致有这些Flask2.3.3 Flask-SQLAlchemy3.1.1 Flask-CORS4.0.0 Flask-Migrate4.0.5 PyJWT2.8.0 python-dotenv1.0.0创建虚拟环境并安装依赖python -m venv venv venv/Scripts/activate # Windows source venv/bin/activate # macOS / Linux pip install -r requirements.txt3.2 用户注册登录与 JWT 鉴权JWT 登录是目前前后端分离项目的主流方案。用户在登录页输入账号密码后端校验通过后签发一个带有效期的 token前端把它存在本地之后每次请求在请求头里带上Authorization: Bearer token后端解析 token 即可识别用户身份。核心代码分三块注册逻辑from werkzeug.security import generate_password_hash from models import User, db def register(username, password, phone): if User.query.filter_by(usernameusername).first(): return {error: 用户名已存在}, 400 user User( usernameusername, password_hashgenerate_password_hash(password), phonephone, roleuser ) db.session.add(user) db.session.commit() return {message: 注册成功}, 201登录签名逻辑import jwt, datetime from werkzeug.security import check_password_hash SECRET_KEY your-secret-key def login(username, password): user User.query.filter_by(usernameusername).first() if not user or not check_password_hash(user.password_hash, password): return {error: 用户名或密码错误}, 401 token jwt.encode( { user_id: user.id, username: user.username, role: user.role, exp: datetime.datetime.utcnow() datetime.timedelta(hours12) }, SECRET_KEY, algorithmHS256 ) return {token: token, username: user.username, role: user.role}, 200鉴权装饰器from functools import wraps import jwt from flask import request def login_required(f): wraps(f) def decorated(*args, **kwargs): token None auth_header request.headers.get(Authorization) if auth_header and auth_header.startswith(Bearer ): token auth_header.split( )[1] if not token: return {error: 未登录}, 401 try: payload jwt.decode(token, SECRET_KEY, algorithms[HS256]) request.user_id payload[user_id] request.user_role payload[role] except jwt.ExpiredSignatureError: return {error: 登录已过期}, 401 except jwt.InvalidTokenError: return {error: 无效的 token}, 401 return f(*args, **kwargs) return decorated这套代码里最容易被新手忽略的是密码处理。很多人图省事直接明文存储这在真实项目里是非常危险的做法一旦数据库泄露所有用户账号都会被撞库。用 werkzeug 内置的哈希方案零成本安全等级直接拉满。3.3 车次查询与余票扣减的并发处理车次查询接口是用来支持首页搜索的我允许用户按出发城市、到达城市、出发日期三个条件筛选from flask import Blueprint, request from models import Train trains_bp Blueprint(trains, __name__) trains_bp.route(/api/trains/search, methods[GET]) def search_trains(): from_city request.args.get(from_city, ) to_city request.args.get(to_city, ) date request.args.get(date, ) query Train.query if from_city: query query.filter(Train.from_city.like(f%{from_city}%)) if to_city: query query.filter(Train.to_city.like(f%{to_city}%)) if date: query query.filter(Train.depart_date date) trains query.order_by(Train.train_no.asc()).all() return { code: 0, data: [ { id: t.id, train_no: t.train_no, from_city: t.from_city, to_city: t.to_city, depart_time: t.depart_time.strftime(%H:%M), arrive_time: t.arrive_time.strftime(%H:%M), price: float(t.price), seat_remain: t.seat_remain } for t in trains ] }, 200余票扣减是并发控制的重头戏我直接分享当时踩坑的经历。第一版我写的是“先查余票判断大于 0再减一”这个流程在并发请求下会出大问题。两个请求同时查出余票为 1同时判定可以购买然后各自减一结果余票就变成了 -1。正确的姿势是使用 SQL 层的原子操作把判断和扣减合并成一条 UPDATE 语句from sqlalchemy import update from sqlalchemy.sql import func orders_bp.route(/api/orders/create, methods[POST]) def create_order(): ... # 原子扣减余票 result db.session.execute( update(Train) .where(Train.id train_id, Train.seat_remain 0) .values(seat_remainTrain.seat_remain - 1) ) if result.rowcount 0: db.session.rollback() return {error: 余票不足下单失败}, 400 ... db.session.commit()注意这里有一个非常关键的地方seat_remain 0判断是写在 WHERE 条件里的数据库自己去拿锁校验然后执行减一整个过程是原子性的并发下也不会超卖。这个思路在做秒杀、抢票类功能时完全通用核心就是“把业务校验下沉到数据库条件里”而不是先查出来再 Java/Python 里判断。3.4 下单、模拟支付与取消回补创建订单的核心流程是校验用户登录装饰器→ 校验车次存在 → 原子扣减余票 → 创建订单记录 → 返回订单 ID。订单创建代码简化后如下import uuid from models import Order, Train, db orders_bp.route(/api/orders/create, methods[POST]) login_required def create_order(): payload request.get_json() train_id payload.get(train_id) passenger_name payload.get(passenger_name) id_card payload.get(id_card) seat_type payload.get(seat_type, 二等座) train Train.query.get(train_id) if not train: return {error: 车次不存在}, 404 # 防重复下单 existing Order.query.filter_by( user_idrequest.user_id, train_idtrain_id, statuspending ).first() if existing: return {error: 请勿重复提交订单}, 400 # 原子扣减余票 result db.session.execute( update(Train) .where(Train.id train_id, Train.seat_remain 0) .values(seat_remainTrain.seat_remain - 1) ) if result.rowcount 0: db.session.rollback() return {error: 余票不足}, 400 order_no TP uuid.uuid4().hex[:14].upper() order Order( order_noorder_no, user_idrequest.user_id, train_idtrain_id, passenger_namepassenger_name, id_cardid_card, seat_typeseat_type, amounttrain.price, statuspending ) db.session.add(order) db.session.commit() return {order_no: order_no, amount: float(train.price)}, 201支付接口就简单很多了因为我们在演示项目里做的是模拟支付orders_bp.route(/api/orders/int:order_id/pay, methods[POST]) login_required def pay_order(order_id): order Order.query.get(order_id) if not order or order.user_id ! request.user_id: return {error: 订单不存在}, 404 if order.status ! pending: return {error: 当前状态不可支付}, 400 order.status paid db.session.commit() return {message: 支付成功}, 200取消订单时需要把余票加回来orders_bp.route(/api/orders/int:order_id/cancel, methods[POST]) login_required def cancel_order(order_id): order Order.query.get(order_id) if not order or order.user_id ! request.user_id: return {error: 订单不存在}, 404 if order.status not in (pending, paid): return {error: 当前订单状态不可取消}, 400 original_status order.status order.status cancelled # 回补余票 Train.query.filter_by(idorder.train_id).update( {Train.seat_remain: Train.seat_remain 1} ) db.session.commit() # 如果是已支付订单取消这里还可以接真实退款逻辑 return {message: 订单已取消余票已回补}, 200“先扣票、后回补”这套流程把库存管理从纯订单系统里剥离了出来保证了即使页面反复刷新、用户反复取消余票数字始终准确。4. Vue 前端核心模块实操前端这边我用的是 Vue 3 Vite Vue Router Pinia Axios 这套组合。相比 Vue 2 的 Options APIVue 3 的 Composition API 写业务逻辑更集中而且 setup 语法糖让组件代码清爽不少。4.1 Vue 环境搭建与工程初始化我直接用的新版 Vite 脚手架命令npm create vitelatest frontend -- --template vue cd frontend npm install npm install vue-router4 pinia axios创建项目之后把不需要的 HelloWorld.vue 组件删掉然后建立自己的目录结构frontend/src/ ├── api/ # axios 实例和各模块接口 │ ├── request.js │ ├── auth.js │ ├── train.js │ └── order.js ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 ├── views/ # 页面组件 │ ├── Login.vue │ ├── Register.vue │ ├── TrainSearch.vue │ ├── TrainDetail.vue │ ├── OrderList.vue │ └── Admin.vue └── App.vue4.2 请求封装与登录态管理Axios 实例的核心作用是统一处理 token 注入和错误提示。我个人习惯把所有业务请求的 token 都放在请求头里由后端 JWT 验证。封装代码如下// src/api/request.js import axios from axios import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动附带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理 401 request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } ) export default request登录态的存储我用localStorage持久化刷新页面依然能保持登录状态。Pinia 里维护一个userStore保存用户名、角色这些用户信息组件里可以直接响应式读取。4.3 车次列表和订票页面交互查询页面的核心逻辑是表单绑定搜索条件点击查询后调接口拿到车次数组渲染成表格每一行有一个“订票”按钮点击后跳转到详情页。我用v-model绑定表单字段input v-modelqueryForm.fromCity placeholder出发城市 / input v-modelqueryForm.toCity placeholder到达城市 / input typedate v-modelqueryForm.date / button clickfetchTrains查询车次/button对应的事件处理const queryForm reactive({ fromCity: , toCity: , date: }) const trainList ref([]) const loading ref(false) const fetchTrains async () { loading.value true try { const res await trainApi.search(queryForm) trainList.value res.data || [] } finally { loading.value false } }订票时我把车次 ID 通过路由参数传递const goDetail (train) { router.push({ path: /train/${train.id} }) }在详情页里展示车次基本信息同时让用户填写乘车人姓名和身份证号点击“提交订单”后调用后端创建订单接口之后把返回的订单号展示出来引导用户去“模拟支付”。4.4 路由守卫与页面权限控制Vue Router 的路由守卫非常适合处理“未登录不能访问”这种需求。配置方式是在路由对象里给需要登录的页面加上meta.requiresAuth然后在beforeEach里检查 token。// src/router/index.js const routes [ { path: /login, component: Login }, { path: /register, component: Register }, { path: /, component: TrainSearch, meta: { requiresAuth: true } }, { path: /train/:id, component: TrainDetail, meta: { requiresAuth: true } }, { path: /orders, component: OrderList, meta: { requiresAuth: true } }, { path: /admin, component: Admin, meta: { requiresAuth: true, role: admin } } ] router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.role to.meta.role ! role) { next(/) } else { next() } })这里要注意的是前端守卫只是用户体验层面的控制真正做权限校验必须依赖后端。后端的每个管理接口都要加上角色判断不然别人直接调接口就能绕过页面限制这一点必须时刻提醒自己。4.5 Vite 代理配置因为开发环境前后端端口不同跨域是必须处理的。我在 Vite 配置文件里这样设置// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:5000, changeOrigin: true } } } })这里的原理是浏览器请求是http://localhost:5173/api/xxxVite 开发服务器收到后代理转发到http://localhost:5000/api/xxx服务器之间的请求不存在跨域问题返回数据再原路返回给浏览器。开发阶段完全不用配置 Flask-CORS部署时用 Nginx 反向代理也是同样的思路。5. 部署、测试与常见问题排查项目跑通不难但想上线部署或者给导师演示还是要解决一堆环境问题。下面把我在部署阶段遇到的问题和排查过程记录下来。5.1 Flask 部署时的环境与数据库问题Flask 自带的开发服务器只适合本地调试部署时我用 Gunicorn 启动gunicorn -w 4 -b 0.0.0.0:5000 app:app-w 4表示开 4 个 worker 进程可以同时处理 4 个请求。注意这里有个坑如果用了 SQLite多个 worker 并发写同一个 SQLite 文件可能会报database is locked原因很简单SQLite 的写锁是文件级别的并发写就容易锁冲突。所以我建议生产环境换成 MySQL这也是我为什么特意强调 SQLAlchemy 模型要独立建切数据库只需要改连接串。必装依赖python-dotenv配合.env文件管理数据库连接串和密钥SECRET_KEYyour-strong-secret-key DATABASE_URLmysqlpymysql://user:passwordlocalhost/train_db启动时读取import os from dotenv import load_dotenv load_dotenv() app.config[SECRET_KEY] os.getenv(SECRET_KEY) app.config[SQLALCHEMY_DATABASE_URI] os.getenv(DATABASE_URL)密钥和数据库密码绝对不能写死在代码里这个习惯从练手项目开始就养成。5.2 Vue 打包与 Nginx 配置前端部署流程是标准的三步npm install npm run build # 生成的 dist 目录就是静态文件Nginx 配置里的重点是把/api反向代理到 Flask 端口同时解决 history 路由刷新 404 的问题。如果使用createWebHistory模式刷新页面时 Nginx 会按真实路径找文件找不到就 404需要加一个try_files回退到 index.html。server { listen 80; server_name your-domain.com; root /var/www/frontend/dist; index index.html; location /api { proxy_pass http://127.0.0.1:5000/api; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这个try_files是我当时排查很久才解决的页面一级路由/orders没问题二级路由/orders/123刷新就白屏原因就在这里。5.3 常见问题速查表整理了一份我自己在开发过程中踩过的坑列成表格方便查阅问题现象排查结果解决方式前端请求接口 CORS 报错开发环境 proxy 未生效检查 Vite proxy 配置路径是否匹配登录后接口仍提示 401请求头未携带 Authorization检查 axios 拦截器是否正确注入 token订单重复创建后端未做防重判断下单前查询同名同车次待支付订单余票出现负数先查后改产生竞态改为 SQL 原子 UPDATE 带余票条件取消订单后余票不变忘记更新车次余票字段回补逻辑里加Train.seat_remain 1生产环境刷新二级路由 404history 模式导致Nginx 加try_files $uri $uri/ /index.htmlSQLite 并发写报 locked多 worker 同时写 SQLite生产换 MySQLToken 过期频繁签发时间设置太短调大timedelta(hours12)或实现 refresh token6. 开发流程复盘与扩展方向6.1 开发流程复盘与效率技巧做这个项目我最大的体会是先定义接口再同步并行开发前后端不然很容易做着做着发现接口字段对不上。我建议先画一份接口文档写清楚每个接口的入参、出参、状态码然后后端按文档实现前端按文档 mock 数据两边同时开工联调的时候问题会少很多。另外我强烈建议后端开发时用 Postman 或 Apifox 一边写一边测试。接口逻辑最复杂的下单接口每改一次并发逻辑就用压力测试工具同时发多个请求直接看会不会超卖。这个习惯帮我发现了第一版“先查后改”的并发漏洞。6.2 后续可以扩展的方向这个项目做完之后如果要继续扩展我有几个方向上的建议加入座位选择功能比如一等座、二等座分别扣减余票座席表单独建一张这样可以进一步锻炼复杂数据建模能力。引入消息队列处理订单超时比如 Redis 延迟队列让用户下单后 15 分钟不支付自动释放余票目前项目里我是用定时任务扫描待支付订单简单但不够优雅。增加余票实时数据可视化把车次余票数据用图表展示正好可以用 Flask 提供数据接口、前端引入 ECharts也能顺带练习前两天大家老提的数据可视化场景。对接真实支付沙箱比如支付宝沙箱或微信沙箱把模拟支付替换成真实调用这里面的签名算法和回调验签逻辑又是一大块值得学习的内容。最后再分享一个小技巧做这一类管理系统前端不一定非要自己从零撸 UI可以直接引入 Element Plus 这类组件库表格、表单、弹窗全都现成配色也统一。省下来的时间多花在余票并发和状态机这些核心业务上面试或者答辩的时候能讲的东西反而更多。
返回列表