做这个机票预约购票系统,最开始只是一门课程设计的要求:题目叫“基于Python的Flask机票预约购票出行服务系统”。听起来像是要做一个完整电商平台,实际上拿到需求之后我发现,只要把“航班查询—选座下单—订单管理—后台维护”这条链路理清楚,再用Flask把每个环节串起来,整个项目并没有想象中复杂。但难点在于很多细节如果不在设计阶段想明白,写代码的时候就会处处别扭——比如余票扣减的并发问题、订单状态怎么流转、价格字段用什么类型存、查询日期怎么处理,这些坑我后面都会逐个讲到。
这套系统说白了就是一个“机票版”的小型交易平台。用户登录后能按出发城市、到达城市、日期组合搜航班,看到航班号、起降时间、舱位价格、剩余票量,然后提交订单完成“支付”。管理员则可以维护航班信息、看订单列表、手动调整航班状态。适合拿来练手的人群很明确:正在做Python Web课程设计的学生、想系统掌握Flask框架的初学者、或者希望把一个业务闭环项目改造成自己毕设的开发者。整个项目用到的东西非常主流——Python 3 + Flask + SQLAlchemy + Jinja2模板,部署也方便,放进服务器就能跑。
我这篇就把完整的设计过程、核心代码思路、踩过的坑全部复盘一遍,尽量做到你看完能直接动手复现。
1. 这套系统到底要做什么:需求拆解与整体设计
1.1 用户视角下的核心流程
先把业务场景摆出来。一个陌生的用户访问这个系统,他想要的体验链路是这样的:打开首页 → 注册/登录 → 输入“北京到上海、明天” → 看到所有符合条件的航班列表 → 点进某个航班选择舱位 → 填写乘机人信息 → 生成订单 → 支付(学生项目一般做模拟支付) → 在“我的订单”里看到状态。与此同时,管理员要从后台录入航班、调整价格、停飞某个航班、查看所有订单。
整个系统的核心就是围绕“用户”和“航班”这两条线展开的。用户线提供注册登录、个人信息、订单管理;航班线提供查询、选择、下单、支付;后台线则负责数据维护。这三条线交叉在一起,不能简单做成几个孤立页面。
1.2 功能模块怎么切分
我最终把系统功能切成了四个模块:
- 用户模块:注册、登录、会话保持、退出登录。密码不存明文,用哈希处理。
- 航班模块:航班信息录入、列表展示、按起降城市和日期筛选、查看航班详情。
- 订单模块:创建订单、订单支付状态更新、取消订单、订单列表。这是整套系统的核心逻辑所在。
- 管理模块:管理员登录、航班增删改、订单状态管理、基础数据统计。
模块之间是单向依赖关系:用户模块最底层,航班模块依赖数据库表,订单模块同时依赖用户和航班,管理模块则复用了订单模块的部分视图逻辑。
1.3 为什么用Flask而不用Django或FastAPI
选Flask有几个很现实的原因。第一,课程设计/毕设场景下,团队一般是一个人开发,Flask的“微框架”属性让人能快速看清每一个请求从路由到视图再到模板的完整路径,调试非常直观。第二,Flask的ORM集成生态很成熟,Flask-SQLAlchemy把数据库会话管理做得近乎透明,对新手非常友好。第三,Flask的Jinja2模板服务端渲染方案,天然适合这类管理型系统,不需要额外搞前后端分离,开发量小很多。
有人会问FastAPI现在不是更火吗?FastAPI的优势在异步和高性能接口,但业务系统里大量页面是表单提交和页面跳转,FastAPI的异步优势用不上,反而模板渲染这块不如Flask生态顺滑。所以在这个场景下,Flask就是最“不折腾”的选择。
2. 数据库设计:表怎么建,字段怎么定
2.1 四张核心表的字段设计
数据库是整套系统的地基。我设计了四张表:用户表、航班表、订单表、乘机人表(订单子表)。航班和订单是主表,乘机人从属于订单,不单独建用户和乘机人的关联表。
用户表(users)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| username | String(50) | 用户名,唯一索引 |
| password_hash | String(128) | 密码哈希值 |
| String(100) | 邮箱,可空 | |
| is_admin | Boolean | 是否为管理员 |
| created_at | DateTime | 注册时间 |
password_hash在登录验证时用check_password_hash做校验,存哈希不存明文。is_admin字段切成普通用户和管理员,不需要单独建角色表。
航班表(flights)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| flight_no | String(10) | 航班号,如CA1831 |
| departure_city | String(50) | 出发城市 |
| arrival_city | String(50) | 到达城市 |
| departure_time | DateTime | 起飞时间 |
| arrival_time | DateTime | 到达时间 |
| price | Numeric(10, 2) | 经济舱价格 |
| total_seats | Integer | 总座位数 |
| remaining_seats | Integer | 余票数 |
| status | Integer | 状态(0停飞,1正常) |
这里有个重要决策:price的类型用Numeric(10,2)而不是Float。原因后面专门讲。departure_time存完整的DateTime,查询的时候用“日期部分匹配”,这样排序和比较都可靠。
订单表(orders)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| order_no | String(20) | 订单号,唯一 |
| user_id | Integer | 外键→users.id |
| flight_id | Integer | 外键→flights.id |
| passenger_name | String(50) | 乘机人姓名 |
| id_card | String(18) | 证件号码 |
| phone | String(20) | 联系电话 |
| ticket_price | Numeric(10,2) | 成交价(下单时快照) |
| status | Integer | 0待支付 1已支付 2已取消 3已出行 |
| create_time | DateTime | 下单时间 |
乘机人信息这里我直接做成了订单表的字段,没有单独拆子表。因为一个订单只允许订一张票,设计上简约,查询也方便。如果你的需求允许一个订单订多张票,那才需要拆出“订单-乘机人”一对多子表。做课程设计建议守住“一张订单一张票”的边界,实现成本最低。
2.2 用SQLAlchemy写模型
用Flask-SQLAlchemy定义模型很直观,重点是把__tablename__、字段类型、关系都定义清楚。下面是我实际使用的模型核心代码,去掉注释后可以完整放进models.py:
from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash from datetime import datetime db = SQLAlchemy() class User(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(50), unique=True, nullable=False) password_hash = db.Column(db.String(128), nullable=False) email = db.Column(db.String(100)) is_admin = db.Column(db.Boolean, default=False) created_at = db.Column(db.DateTime, default=datetime.now) def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) class Flight(db.Model): __tablename__ = 'flights' id = db.Column(db.Integer, primary_key=True) flight_no = db.Column(db.String(10), nullable=False, index=True) departure_city = db.Column(db.String(50), nullable=False) arrival_city = db.Column(db.String(50), nullable=False) departure_time = db.Column(db.DateTime, nullable=False) arrival_time = db.Column(db.DateTime, nullable=False) price = db.Column(db.Numeric(10, 2), nullable=False) total_seats = db.Column(db.Integer, nullable=False) remaining_seats = db.Column(db.Integer, nullable=False) status = db.Column(db.Integer, default=1) def __repr__(self): return f'<Flight {self.flight_no}>' class Order(db.Model): __tablename__ = 'orders' id = db.Column(db.Integer, primary_key=True) order_no = db.Column(db.String(20), unique=True, nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) flight_id = db.Column(db.Integer, db.ForeignKey('flights.id'), nullable=False) passenger_name = db.Column(db.String(50), nullable=False) id_card = db.Column(db.String(18), nullable=False) phone = db.Column(db.String(20), nullable=False) ticket_price = db.Column(db.Numeric(10, 2), nullable=False) status = db.Column(db.Integer, default=0) create_time = db.Column(db.DateTime, default=datetime.now)建表时注意一个容易被忽略的点:外键要建索引。SQLAlchemy在定义时加了ForeignKey会默认创建外键约束,但某些情况下不会自动建索引,如果你的查询经常按flight_id过滤,最好显式加index=True。
2.3 外键字段到底要不要带下划线
很多新手会在外键字段命名上犹豫:表叫users,那外键应该叫user_id还是uid还是user?我建议统一用“主表名_id”的格式。这样下游引用和理解都清晰。SQLAlchemy里如果想让ORM自动填充关联对象,可以在模型里加relationship,但实际开发中这种简单系统直接通过db.session.get(User, order.user_id)就能取到用户对象,不一定非要用relationship。
另外提醒一句:SQLite虽然支持外键,但默认不强制启用。而Flask-SQLAlchemy创建的SQLite数据库文件,外键约束默认也是关闭的。这意味着即使你删除了一个被订单引用的航班记录,数据库也不会报错,只会在业务逻辑上留下脏数据。我的建议是:在create_all之后手动开启外键支持,或者干脆在逻辑层控制——“航班删除前先检查是否有未完成的订单”,这个我在后面会详细说。
3. 核心业务代码:如果这四段代码出问题,系统基本就废了
3.1 用户注册与登录的会话管理
登录认证我直接用Flask自带的session解决,不开JWT、不开flask-login,原因是系统复杂度不高,服务端Session完全够用。登录成功之后把user_id和is_admin写进session,后续视图函数里通过一个统一的装饰器做访问控制:
from functools import wraps from flask import session, redirect, url_for, flash def login_required(view_func): @wraps(view_func) def wrapped(*args, **kwargs): if session.get('user_id') is None: flash('请先登录', 'warning') return redirect(url_for('login')) return view_func(*args, **kwargs) return wrapped def admin_required(view_func): @wraps(view_func) def wrapped(*args, **kwargs): if session.get('is_admin') != True: flash('管理员权限不足', 'danger') return redirect(url_for('index')) return view_func(*args, **kwargs) return wrapped这个装饰器是“登录才能访问”的最简实现,直接放在app.py顶部就行。注意wraps必须带,否则被装饰的函数名字会变,url_for反查路由时容易踩坑。
注册逻辑上有个容易忽略的安全细节:用户名字段要加unique=True,插入前先查一遍是否存在,不要等到数据库报IntegrityError再处理。另外密码的哈希方案用Werkzeug自带的,不要自己去搞加盐拼接,所谓“不要发明自己的加密方案”。
3.2 航班查询:日期范围过滤的正确姿势
航班查询是用户用得最多的功能,也是很多人写错的地方。前端传过来的是一个“日期字符串”2025-06-10,而数据库存的是完整DateTime,比如2025-06-10 08:30:00。直接==匹配肯定查不到。我用的方案是区间过滤:当天零点作为下界,第二天零点作为上界。
from datetime import datetime, timedelta def query_flights(departure_city, arrival_city, depart_date_str): depart_date = datetime.strptime(depart_date_str, '%Y-%m-%d') next_day = depart_date + timedelta(days=1) flights = Flight.query.filter( Flight.departure_city == departure_city, Flight.arrival_city == arrival_city, Flight.departure_time >= depart_date, Flight.departure_time < next_day, Flight.status == 1 ).order_by(Flight.departure_time.asc()).all() return flights这里order_by按起飞时间升序排,符合用户预期。查询结果再把时间的strftime('%Y-%m-%d %H:%M')格式化传到前端展示,时间格式控制放在视图层,不在模板里做复杂的过滤。
另外一个要处理的问题是:城市输入用下拉框还是文本框。下拉框数据从Flight.departure_city和arrival_city的distinct查询里取,这样能避免用户输入一个根本不存在的目的地。
3.3 下单的核心:余票检查与扣减
下单是整个系统最容易出并发问题的地方。用户点击“立即预订”之后,系统要做的逻辑顺序是:接收表单 → 验证航班存在且未停飞 → 检查余票>0 → 创建订单 → 扣减余票 → 跳转支付。这里最忌讳的是先用select查出余票,再在业务代码里判断余票>0,然后update扣减。因为两个请求如果同时通过这个逻辑,都查到了余票还有1张,然后都去扣减,最后余票会变成负数。
对于课程设计级别的项目,最稳妥的办法是把“检查余票”和“扣减余票”合并到一条SQL更新语句里:
from sqlalchemy import update result = db.session.execute( update(Flight) .where(Flight.id == flight_id) .where(Flight.remaining_seats > 0) .values(remaining_seats=Flight.remaining_seats - 1) ) if result.rowcount == 0: db.session.rollback() flash('该航班余票不足,订票失败', 'danger') return redirect(url_for('flight_list'))这段SQL等价于“只有当余票大于0时才扣减”,数据库层面保证原子性,这就避免了Java/Flask业务代码里经典的check-then-act竞态问题。rowcount == 0说明条件不满足,直接回滚事务,返回提示。然后再创建订单,提交事务。数据库的默认隔离级别对于这种单条update已经足够,不需要额外上锁。
订单号生成也不能随便用自增id,用户会拿订单号去“查询、核销、投诉”,要有唯一性。我用的是时间戳加用户id加随机数的组合:
import random import time def generate_order_no(): ts = time.strftime('%Y%m%d%H%M%S') rand = random.randint(1000, 9999) return f'{ts}{rand}'订单号设计成“短、可读、唯一”即可,不追求绝对不可猜测。但注意订单表要加unique=True约束,如果撞了重新生成一次。
3.4 订单状态的流转设计
订单状态我用数字存,0待支付,1已支付,2已取消,3已出行。为什么不用字符串“PENDING”“PAID”?原因很简单,数字省空间、查询快、代码里用常量映射即可。状态迁移有明确约束:
- 待支付 → 已支付(用户点“模拟支付”)
- 待支付 → 已取消(用户取消,需恢复余票)
- 已支付 → 已出行(管理员标记,表示航班已执行)
- 不让已支付的订单直接取消,先要在逻辑层判断
取消订单的恢复余票逻辑要小心。有一种场景是用户先下单,余票被扣了,然后用户取消,此时必须把余票加回来。如果忘了恢复,会越积越多。取消的逻辑:
def cancel_order(order): if order.status != 0: return False, '订单当前状态不可取消' order.status = 2 order.flight.remaining_seats += 1 db.session.commit() return True, '订单已取消'这里用到order.flight.remaining_seats += 1,前提是Order模型定义了与Flight的relationship,或者你在取订单时顺手把flight对象取出来。如果没有relationship,就需要先db.session.get(Flight, order.flight_id)再操作。代码里推荐显式取对象,避免对relationship的隐式依赖。
4. 界面层与前后端交互:模板继承和表单处理的实战方案
4.1 Jinja2模板继承与导航栏设计
页面数量不多,大概六个页面:首页、航班列表、航班详情/下单页、订单列表、登录、注册,外加管理员端航班管理页。每个页面顶部导航栏是相同的,底部也相同,所以模板继承必须用上。
基础模板base.html的典型结构:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}机票预约系统{% endblock %}</title> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> </head> <body> <nav> <div class="nav-container"> <a href="{{ url_for('index') }}">首页</a> {% if session.get('user_id') %} <a href="{{ url_for('order_list') }}">我的订单</a> <a href="{{ url_for('logout') }}">退出</a> {% else %} <a href="{{ url_for('login') }}">登录</a> <a href="{{ url_for('register') }}">注册</a> {% endif %} </div> </nav> <div class="container"> {% with messages = get_flashed_messages(with_categories=true) %} {% for category, message in messages %} <div class="alert alert-{{ category }}">{{ message }}</div> {% endfor %} {% endwith %} {% block content %}{% endblock %} </div> </body> </html>导航栏里根据session状态做条件显示,这个是Jinja2模板里的标准做法。闪现消息flash的处理放在所有页面都能看到的位置,这样视图函数里统一用flash而不是手动往模板传error变量,代码会简洁很多。
4.2 表单处理的两个原则
HTML表单处理有两个原则:一是一定要限制method="post",二是要给每个input加required和name。我见过太多人表单提交后获取不到数据,最后发现是input没有name属性。
下单页涉及乘客姓名、身份证号、手机号三个字段,后端要做基本校验:
def validate_passenger_form(name, id_card, phone): if not name or not id_card or not phone: return '请完整填写乘机人信息' if len(id_card) != 18: return '身份证号长度不正确' if not phone.isdigit() or len(phone) != 11: return '手机号格式不正确' return None身份证校验只做长度和字符校验就够了,不做真实校验算法,毕竟只是课程项目。后续如果你有兴趣,可以引入校验码算法增强一下。
4.3 航班详情的下单页该怎么展示
航班详情页要让用户在同一个页面确认航班信息再填写乘机人信息,而不是跳来跳去。我用了表格展示航班信息,下面是乘客信息表单,一个页面搞定。提交按钮上写明“确认支付”,避免用户误会成“只是预订”,因为我们的设计里下单即支付。
管理员端航班信息维护用同一个模板加一个表单即可。把新增和编辑合并在同一张表单里,通过URL中的flight_id区分是编辑还是新增,这个技巧能让模板数量减少三分之一。
5. 常见问题与排查实录:这些坑我基本都踩过
5.1 SQLite并发写入报"database is locked"
这是Flask+SQLite项目最常见的问题。SQLite的单写多读特性决定了并发写场景下会报锁。我遇到的情况是:一个请求在下单事务中执行了多次写操作,另一个请求同时尝试写入,直接报database is locked。
缓解方案有两个层面。第一,把事务搞短。很多新手在视图函数里做了大量数据库查询,再慢慢处理业务,最后才commit,事务周期长,锁的持有时间久。应该只在最后组装数据时再开启写操作。第二,SQLite连接字符串里加check_same_thread=False并用socket_timeout=10。这个坑Windows开发时尤其常见,因为SQLite默认是线程单连接模式。
生产部署时如果并发真的上来了,建议直接换MySQL/PostgreSQL,连接配置改动很小:只要把SQLALCHEMY_DATABASE_URI改一下,模型代码完全不用动。这也是业务系统最终应该走的方向。
5.2 价格用Float存导致显示成一串9
买东西算总价,单价都是两位小数,但如果你用了Float存价格,0.1 + 0.2经常会变成0.30000000000000004,这在订单金额显示上是非常尴尬的。所以我从一开始就坚持用Numeric(10,2)。另外在模板显示价格时建议用format(item.ticket_price, '.2f')或者Jinja2的"%.2f"|format(...),确保价格永远显示两位小数。
# 视图层格式化输出 order.ticket_price = round(float(order.ticket_price), 2)注意SQLAlchemy取出来的Numeric是Decimal对象,做加减时要保持Decimal,别中途转float之后再算,Decimal与float混用会丢精度。
5.3 日期时间时区与页面显示错乱
另一个常见问题是服务器存储的datetime.now()是本地时间,而你直接在模板里渲染时,如果不格式化,会显示成2025-06-10 08:30:00这种原始格式。如果是面向普通用户,显示2025-06-10 08:30更友好。我建议在视图层用一个统一函数格式化,模板里不做任何时间运算。另外HTTP请求里传的date字符串和数据库比较时务必转成datetime,否则SQLAlchemy会报隐式类型转换错误。
5.4 静态资源404
Flask对静态资源的处理有个特点:如果你的应用在URL前缀挂在子路径下(比如/myapp/),url_for('static', filename='css/style.css')生成的地址开头是/myapp/static/css/style.css,而不是直接/static/...。这在本地开发时没问题,但部署到Nginx反向代理下面时很容易404。排查思路是:打开浏览器F12看静态文件实际请求地址,对比路由配置,顺着前缀去检查Nginx的location配置即可。别在CSS引用路径里写死绝对路径,统一用url_for是用Flask的基本素养。
6. 部署上线与后续扩展思路
6.1 本地开发环境的搭建步骤
如果你要从零开始复现这个项目,环境搭建的顺序建议这样走:
- 安装Python 3.10+,记得勾选“Add Python to PATH”。
- 创建虚拟环境:
python -m venv venv,激活后安装依赖包。 - 安装核心依赖:
pip install flask flask-sqlalchemy,如果涉及表单验证再加flask-wtf。 - 数据库初始化:简单起见直接
db.create_all(),然后写一小段脚本插入几条航班测试数据。 python app.py启动,浏览器访问127.0.0.1:5000。
依赖建议写进requirements.txt固定版本,避免以后pip install装到不兼容版本。比较稳的组合是Flask==3.0.0、Flask-SQLAlchemy==3.1.1、Werkzeug==3.0.1。
6.2 生产部署时的几个配置调整
本地可以app.run(debug=True),但部署到生产环境必须关掉debug,并用waitress或gunicorn来跑。waitress是纯Windows也能跑的WSGI服务器,开发和交作业足够。生产环境建议再加一层Nginx做静态资源和反向代理,Flask本身只处理动态请求即可。
from waitress import serve from app import app if __name__ == '__main__': serve(app, host='0.0.0.0', port=5000)host='0.0.0.0'才能让局域网其他机器访问到,本地调试时用127.0.0.1就够了。
6.3 这个项目后续能怎么扩展
如果这个项目的骨架搭好了,可以往下扩展的方向其实非常多。最实用的是加一个“舱位等级”概念:经济舱、商务舱、头等舱价格不同,余票分开管理。这个改动涉及航班表增加舱位表,订单表增加cabin_class字段,其他逻辑可以完全复用。
另一个实用扩展是“航班动态通知”,用Flask的after_request钩子或者定时任务框架(如APScheduler)实现在航班取消时给相关订单用户发邮件提醒。管理员取消航班时,系统自动把所有待出行订单变成取消状态并通知用户,这对系统的完整度加分很明显。
如果希望往数据分析方向靠,可以做一个简单的统计页面:按日期统计订单量、按城市统计热门航线、按月份统计销售额。SQLAlchemy的func.count和func.sum就能搞定,出一个管理端仪表盘,整个项目的数据价值立刻就有了。
最后分享两个实战细节
整个项目从设计到跑通,我个人最深的体会是:这类系统的难点从来不是某个框架的用法,而是数据状态的一致性。用户下单扣余票、取消订单加回余票、订单状态迁移的这些约束,稳扎稳打地在数据库层面和逻辑层都做好防御,整个系统就会很稳。我见过很多同学把精力花在页面画得多好看,结果一测试就发现“余票被扣成负数”或者“取消的订单还算成收入”,这些才是真正要命的问题。
最后再分享一个小技巧:开发阶段强烈建议写一个seed.py脚本,一键往数据库里插入20条覆盖不同城市、不同时间的航班数据。每次测试时先跑一遍重置数据,比在网页上手动点“新增航班”快得多,也能保证每次测试的数据环境是一致的。
如果你正在做类似的项目,希望这份拆解能帮你在动手前把坑都填平。照着这个思路把核心链路实现一遍,无论最终评分还是后续改造,你都会心里有底。