ARTICLE DETAIL

资讯详情

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

Flask机票预约购票系统实战:数据库设计与订单并发处理

Flask机票预约购票系统实战:数据库设计与订单并发处理 做这个机票预约购票系统最开始只是一门课程设计的要求题目叫“基于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字段类型说明idInteger主键usernameString(50)用户名唯一索引password_hashString(128)密码哈希值emailString(100)邮箱可空is_adminBoolean是否为管理员created_atDateTime注册时间password_hash在登录验证时用check_password_hash做校验存哈希不存明文。is_admin字段切成普通用户和管理员不需要单独建角色表。航班表flights字段类型说明idInteger主键flight_noString(10)航班号如CA1831departure_cityString(50)出发城市arrival_cityString(50)到达城市departure_timeDateTime起飞时间arrival_timeDateTime到达时间priceNumeric(10, 2)经济舱价格total_seatsInteger总座位数remaining_seatsInteger余票数statusInteger状态0停飞1正常这里有个重要决策price的类型用Numeric(10,2)而不是Float。原因后面专门讲。departure_time存完整的DateTime查询的时候用“日期部分匹配”这样排序和比较都可靠。订单表orders字段类型说明idInteger主键order_noString(20)订单号唯一user_idInteger外键→users.idflight_idInteger外键→flights.idpassenger_nameString(50)乘机人姓名id_cardString(18)证件号码phoneString(20)联系电话ticket_priceNumeric(10,2)成交价下单时快照statusInteger0待支付 1已支付 2已取消 3已出行create_timeDateTime下单时间乘机人信息这里我直接做成了订单表的字段没有单独拆子表。因为一个订单只允许订一张票设计上简约查询也方便。如果你的需求允许一个订单订多张票那才需要拆出“订单-乘机人”一对多子表。做课程设计建议守住“一张订单一张票”的边界实现成本最低。2.2 用SQLAlchemy写模型用Flask-SQLAlchemy定义模型很直观重点是把__tablename__、字段类型、关系都定义清楚。下面是我实际使用的模型核心代码去掉注释后可以完整放进models.pyfrom 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_keyTrue) username db.Column(db.String(50), uniqueTrue, nullableFalse) password_hash db.Column(db.String(128), nullableFalse) email db.Column(db.String(100)) is_admin db.Column(db.Boolean, defaultFalse) created_at db.Column(db.DateTime, defaultdatetime.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_keyTrue) flight_no db.Column(db.String(10), nullableFalse, indexTrue) departure_city db.Column(db.String(50), nullableFalse) arrival_city db.Column(db.String(50), nullableFalse) departure_time db.Column(db.DateTime, nullableFalse) arrival_time db.Column(db.DateTime, nullableFalse) price db.Column(db.Numeric(10, 2), nullableFalse) total_seats db.Column(db.Integer, nullableFalse) remaining_seats db.Column(db.Integer, nullableFalse) status db.Column(db.Integer, default1) def __repr__(self): return fFlight {self.flight_no} class Order(db.Model): __tablename__ orders id db.Column(db.Integer, primary_keyTrue) order_no db.Column(db.String(20), uniqueTrue, nullableFalse) user_id db.Column(db.Integer, db.ForeignKey(users.id), nullableFalse) flight_id db.Column(db.Integer, db.ForeignKey(flights.id), nullableFalse) passenger_name db.Column(db.String(50), nullableFalse) id_card db.Column(db.String(18), nullableFalse) phone db.Column(db.String(20), nullableFalse) ticket_price db.Column(db.Numeric(10, 2), nullableFalse) status db.Column(db.Integer, default0) create_time db.Column(db.DateTime, defaultdatetime.now)建表时注意一个容易被忽略的点外键要建索引。SQLAlchemy在定义时加了ForeignKey会默认创建外键约束但某些情况下不会自动建索引如果你的查询经常按flight_id过滤最好显式加indexTrue。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反查路由时容易踩坑。注册逻辑上有个容易忽略的安全细节用户名字段要加uniqueTrue插入前先查一遍是否存在不要等到数据库报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(days1) 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_seatsFlight.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}订单号设计成“短、可读、唯一”即可不追求绝对不可猜测。但注意订单表要加uniqueTrue约束如果撞了重新生成一次。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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title{% block title %}机票预约系统{% endblock %}/title link relstylesheet href{{ url_for(static, filenamecss/style.css) }} /head body nav div classnav-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 classcontainer {% with messages get_flashed_messages(with_categoriestrue) %} {% for category, message in messages %} div classalert alert-{{ category }}{{ message }}/div {% endfor %} {% endwith %} {% block content %}{% endblock %} /div /body /html导航栏里根据session状态做条件显示这个是Jinja2模板里的标准做法。闪现消息flash的处理放在所有页面都能看到的位置这样视图函数里统一用flash而不是手动往模板传error变量代码会简洁很多。4.2 表单处理的两个原则HTML表单处理有两个原则一是一定要限制methodpost二是要给每个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这是FlaskSQLite项目最常见的问题。SQLite的单写多读特性决定了并发写场景下会报锁。我遇到的情况是一个请求在下单事务中执行了多次写操作另一个请求同时尝试写入直接报database is locked。缓解方案有两个层面。第一把事务搞短。很多新手在视图函数里做了大量数据库查询再慢慢处理业务最后才commit事务周期长锁的持有时间久。应该只在最后组装数据时再开启写操作。第二SQLite连接字符串里加check_same_threadFalse并用socket_timeout10。这个坑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 静态资源404Flask对静态资源的处理有个特点如果你的应用在URL前缀挂在子路径下比如/myapp/url_for(static, filenamecss/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装到不兼容版本。比较稳的组合是Flask3.0.0、Flask-SQLAlchemy3.1.1、Werkzeug3.0.1。6.2 生产部署时的几个配置调整本地可以app.run(debugTrue)但部署到生产环境必须关掉debug并用waitress或gunicorn来跑。waitress是纯Windows也能跑的WSGI服务器开发和交作业足够。生产环境建议再加一层Nginx做静态资源和反向代理Flask本身只处理动态请求即可。from waitress import serve from app import app if __name__ __main__: serve(app, host0.0.0.0, port5000)host0.0.0.0才能让局域网其他机器访问到本地调试时用127.0.0.1就够了。6.3 这个项目后续能怎么扩展如果这个项目的骨架搭好了可以往下扩展的方向其实非常多。最实用的是加一个“舱位等级”概念经济舱、商务舱、头等舱价格不同余票分开管理。这个改动涉及航班表增加舱位表订单表增加cabin_class字段其他逻辑可以完全复用。另一个实用扩展是“航班动态通知”用Flask的after_request钩子或者定时任务框架如APScheduler实现在航班取消时给相关订单用户发邮件提醒。管理员取消航班时系统自动把所有待出行订单变成取消状态并通知用户这对系统的完整度加分很明显。如果希望往数据分析方向靠可以做一个简单的统计页面按日期统计订单量、按城市统计热门航线、按月份统计销售额。SQLAlchemy的func.count和func.sum就能搞定出一个管理端仪表盘整个项目的数据价值立刻就有了。最后分享两个实战细节整个项目从设计到跑通我个人最深的体会是这类系统的难点从来不是某个框架的用法而是数据状态的一致性。用户下单扣余票、取消订单加回余票、订单状态迁移的这些约束稳扎稳打地在数据库层面和逻辑层都做好防御整个系统就会很稳。我见过很多同学把精力花在页面画得多好看结果一测试就发现“余票被扣成负数”或者“取消的订单还算成收入”这些才是真正要命的问题。最后再分享一个小技巧开发阶段强烈建议写一个seed.py脚本一键往数据库里插入20条覆盖不同城市、不同时间的航班数据。每次测试时先跑一遍重置数据比在网页上手动点“新增航班”快得多也能保证每次测试的数据环境是一致的。如果你正在做类似的项目希望这份拆解能帮你在动手前把坑都填平。照着这个思路把核心链路实现一遍无论最终评分还是后续改造你都会心里有底。
返回列表