上个月刚帮朋友把一个基于 Python+Vue 的火车购票系统的设计与实现完整跑通,从技术选型到数据库设计再到并发扣票,踩了不少坑。如果你正准备用 PyCharm 做毕设、课程设计或者找工作的练手项目,这篇文章应该能帮你少走弯路。下面以 Django 为主线展开,同时会说明如果换成 Flask 该怎么调整,前端 Vue 部分也会讲清楚对接细节。整个项目涉及用户注册登录、车次查询、下单购票、订单管理、后台管理这些模块,麻雀虽小但五脏俱全,特别适合用来理解真实业务系统里最常见的状态流转。
1. 项目定位:为什么火车购票系统适合拿来练手
1.1 先搞清楚这个系统到底要做到什么程度
火车购票系统的业务复杂度刚好卡在一个很舒服的位置。用户、车次、订单、余票、支付状态,这些东西组合在一起,既有常规的增删改查,又有订单状态机的流转,还有并发扣票这种面试官最爱问的场景。你要是做一个简单的图书管理系统,聊起来太单薄;但一上来做电商平台,又容易陷入商品规格、促销、物流这些泥潭里出不来。购票系统是中间那个最合适的档位。
拿到这个题目先别急着写代码,把功能范围划清楚。我建议基础版本至少包含这些模块:用户注册登录、车次信息管理、按出发地和目的地查询车次、选择车次和日期后下单购票、个人订单列表、模拟支付。如果能再加一个后台管理页面,用来维护车次和余票,项目完整度直接上一个台阶。
这里给一张我常用的需求分级表,照着拆就不会做过头:
| 模块 | 基础版 | 进阶版 |
|---|---|---|
| 用户 | 注册、登录、个人信息 | 密码加密、邮箱验证码 |
| 车次 | 车次号、起终点、发到时间、票价 | 多座位类型(二等座/一等座/卧铺)、余票分座位统计 |
| 查询 | 按起终点和日期查车次 | 按车次号精确查询、余票不足自动过滤 |
| 购票 | 锁定余票、生成订单 | 并发防超卖、订单超时自动取消 |
| 订单 | 待支付/已支付/已取消 | 退票功能、改签 |
| 后台 | 管理车次 | 余票手动调整、订单查询 |
我建议毕设级别做到进阶版,平时练手做到基础版加并发防超卖就够了。别把功能堆太多,核心是把你做出来的每个功能讲清楚为什么这么做。
1.2 Django 还是 Flask:我的选型思路
标题里同时出现了 Django 和 Flask,这也是很多人在选型时纠结的地方。我的结论很直接:如果你的目标是"把系统做完整、模型关系多、后台管理省事",选 Django;如果你的目标是"展示自己能够把控轻量架构、手写 API 和数据库操作",选 Flask 也没问题。
Django 自带的东西太多了。ORM、Admin 后台、迁移机制、用户认证,这些全是购票系统需要的。车次、余票、订单、用户四个模型之间有一堆外键关系,用 Django ORM 写起来很顺手,而且它在并发控制上提供了select_for_update,这是做扣票逻辑的关键。还有一点很实际,Django Admin 能白嫖一个后台,车次管理页面不用自己从头写,省下的时间足够去打磨前端和并发部分。
Flask 的优势是轻、灵活、代码结构完全由你自己控制。用 Flask 做这个项目的话,通常要搭配 Flask-SQLAlchemy、Flask-Migrate、Flask-CORS 这几个扩展,相当于用额外工作量换框架的掌控感。我在项目里给朋友准备的是一套 Django 主实现,同时写了 Flask 版本的接口对照,方便他答辩时解释选型原因。
对比起来看更直观:
| 维度 | Django | Flask |
|---|---|---|
| ORM | 内置,功能完整 | 需安装 Flask-SQLAlchemy |
| 后台管理 | Admin 自带 | 自己写或用 Flask-Admin |
| 迁移 | migrate 命令 | Flask-Migrate |
| 适合规模 | 中大型业务系统 | 小系统或重度自定义 |
| 学习曲线 | 偏陡但体系完善 | 起步快但踩坑要多查资料 |
选 Django 并不代表 Flask 是错的,关键是答辩或面试时你能把自己的理由说清楚。我这边的做法是:主线用 Django 做完整实现,文档里单独列出 Flask 版本的等价写法。后面讲到扣票逻辑时,我会把两边的代码都贴出来。
2. 整体架构与数据模型设计
2.1 前后端分离还是服务端渲染
我在这个项目里选了前后端分离。后端专门提供 JSON 接口,前端用 Vue 写页面,两边通过 axios 通信。这样做的直接好处是:Vue 这边的路由、组件、状态管理和后端 Python 代码完全解耦,两个人协作开发时互相不干扰,一个人写的时候脑子也不用在两种语言之间来回切。
前后端分离不是说把项目拆成两个互不相干的文件夹就完事了。开发时后端跑在 8000 端口,前端跑在 5173 端口,跨域是绕不开的第一个问题。Django 这边我用django-cors-headers解决,配置很简单,在settings.py里把CORS_ALLOW_ALL_ORIGINS = True打开(仅限开发环境),或者在CORS_ALLOWED_ORIGINS列表里写上 Vue 的开发地址。如果用 Flask,就是装 Flask-CORS,调用CORS(app)就完事了。
Vue 这边的目录结构我习惯这样组织:
frontend/ src/ main.js # 入口,挂载 router App.vue api/ request.js # axios 实例封装 router/ index.js # 路由 views/ Home.vue # 车次查询 Order.vue # 下单确认 Orders.vue # 订单列表 Login.vue后端 Django 我按子应用拆分。不是把所有的 model 和 view 堆在同一个文件夹里,而是拆成apps.user、apps.train、apps.order三个子应用。一个子应用只负责一个领域,查代码的时候不用翻几百行文件。要是用 Flask,就对应改成蓝图(Blueprint)的方式,一个模块一个蓝图,效果是一样的。
2.2 Django 子应用和 Vue 路由的对应关系
后端接口和前端页面是一对一的关系,设计的时候想清楚映射,后面开发会非常顺。下面是一张我实际使用的接口规划表:
| 功能 | 方法 | 接口路径 | Vue 页面 |
|---|---|---|---|
| 用户注册 | POST | /api/auth/register | 登录注册页 |
| 用户登录 | POST | /api/auth/login | 登录注册页 |
| 车次查询 | GET | /api/trains?from=...&to=... | Home.vue |
| 创建订单 | POST | /api/orders | Order.vue |
| 订单列表 | GET | /api/orders | Orders.vue |
| 模拟支付 | POST | /api/orders/{id}/pay | Orders.vue |
Vue 路由方面有两个小细节值得注意。跳转下单页时通常要带上车次信息,我建议用路由参数而不是把整个对象塞到状态管理器里。比如列表页里点击"购买"按钮,通过router.push({ path: '/order/' + trainId })跳过去,Order.vue里再用route.params.trainId发起详情查询。这样刷新页面参数不会丢,代码也好维护。另一个是路由守卫,未登录用户直接访问订单页时,拦截跳到登录页,这个用beforeEach三十秒就写完。
2.3 数据模型设计:车次、余票、订单之间怎么分表
数据模型是整个系统的地基,设计师没想清楚,后面写接口会很痛苦。我一开始就犯过一个典型错误:在 Train 表上加一个remaining_tickets字段,每卖一张就减一。这个设计在单日车次上还说得通,但火车票是按日期卖的,同一趟车 12 月 1 号和 12 月 5 号的余票明明是独立的,用一个字段存,一卖票全乱套了。
正确的做法是把"车次"和"某日期的余票"分开。车次表只存车次本身的信息,余票用一张独立的库存表TrainStock记录。下面的模型设计是精简版,去掉了不必要的字段,保留了最关键的部分:
from django.db import models class Train(models.Model): train_no = models.CharField(max_length=20, unique=True) start_station = models.CharField(max_length=50) end_station = models.CharField(max_length=50) departure_time = models.TimeField() arrival_time = models.TimeField() base_price = models.DecimalField(max_digits=8, decimal_places=2) class TrainStock(models.Model): train = models.ForeignKey(Train, on_delete=models.CASCADE) travel_date = models.DateField() seat_type = models.CharField(max_length=20) # 二等座、一等座、卧铺 remaining = models.IntegerField(default=0) class Meta: unique_together = ('train', 'travel_date', 'seat_type')订单模型里最重要的是status字段。我建议直接用整数定义状态,不要用字符串散落在代码各处,然后在模型里写清楚注释。0 表示待支付,1 表示已支付,2 表示已取消,3 表示已退票。之后所有判断都走这个字段。订单里还要有一个period或expire_at字段,记录支付截止时间,这是后面做超时取消的依据。
订单表里我习惯存下单时的快照信息,比如当时的车次、日期、票价、座位类型。这样就算以后车次信息被后台改过,用户的订单依然能还原出当时买了什么。这个设计说起来简单,但很多人会忽略,等到联调时发现历史订单跟着车次改变化了才想起要存快照。
3. 购票核心逻辑实现:并发防超卖是重头戏
3.1 为什么"先查余票再下单"一定会出事
购票系统最容易被问倒的就是并发超卖问题。最简单的实现流程是:前端提交购票请求,后端先查一下TrainStock里的remaining是不是大于 0,大于 0 就执行扣减并创建订单。这个流程跑起来没问题,但并发一来就崩。
举个例子,一趟车的某座位等级只剩 1 张票,两个用户在同一秒钟同时提交请求。请求 A 查到余票是 1,请求 B 也查到余票是 1。A 继续扣减,B 也继续扣减,最后订单创建了两条,但余票变成了负数或者连同库存行一起被改乱了。原因很简单:查询和扣减不是原子操作,两条请求之间存在时间差,在这个时间差里另一个请求插了进来。
解决思路就是要把"判断余票 + 扣减余票 + 创建订单"这三个操作绑成一个不可分割的整体。数据库事务是干这个用的,但普通事务还不够,还要配合行锁,让并发的扣票请求排队执行。Django ORM 里对应的工具是select_for_update。
3.2 用 Django 事务和行锁保证不超卖
下面是核心购票接口的 Django 实现。关键点有两个:transaction.atomic()开启事务,以及select_for_update()对库存行加锁。
from django.db import transaction from django.db.models import F from .models import TrainStock, Order def create_order(user, train_id, travel_date, seat_type, count): with transaction.atomic(): stock = ( TrainStock.objects .select_for_update() .filter(train_id=train_id, travel_date=travel_date, seat_type=seat_type) .first() ) if stock is None or stock.remaining < count: raise ValueError("余票不足") stock.remaining = F('remaining') - count stock.save(update_fields=['remaining']) order = Order.objects.create( user=user, train_id=train_id, travel_date=travel_date, seat_type=seat_type, ticket_count=count, total_amount=stock.base_price * count if hasattr(stock, 'base_price') else 0, status=0, # 待支付 ) return orderselect_for_update()会锁住满足条件的库存行,一直锁到当前事务结束。事务还没提交前,其他请求如果也想对同一行执行select_for_update,会进入等待状态,直到前一个事务提交或回滚。这样就把并发买票从"同时抢"变成了"排队买",从而避免超卖。
需要注意的一点是F('remaining')这个写法。它让扣减操作在数据库层面完成,而不是先把 Python 对象里的remaining读出来再赋值存回去。这样可以减少一次查询,也避免读到旧值。我一开始直接用stock.remaining -= count再save,并发测试时就出现过更新覆盖的问题,后来改成F表达式才稳住。
对应到 Flask 版本,其实核心逻辑一样。用 Flask-SQLAlchemy 的话,把select_for_update换成.with_for_update():
stock = TrainStock.query.filter_by( train_id=train_id, travel_date=travel_date, seat_type=seat_type ).with_for_update().first() if not stock or stock.remaining < count: db.session.rollback() raise ValueError("余票不足") stock.remaining -= count db.session.add(stock) db.session.add(Order(...)) db.session.commit()不同框架的写法略有差异,但思想完全一样:事务内锁行,别在锁外做判断。答辩时能把这段逻辑讲清楚,这个项目的核心价值就体现了。
讲到这我突然想起来,很多人会在库存行不存在的时候直接创建一条新的库存记录,这个操作在并发场景下也会出问题。更好的做法是后台提前把需要的日期和车次库存准备好,下单只做查询和扣减,不要在业务高峰期去初始库存。
3.3 订单状态机:待支付、已支付、已取消怎么流转
有了库存扣减,还要把订单状态管理好。我把订单状态定义成下面的流转过程:创建订单置为 0(待支付),用户调用模拟支付接口成功后置为 1(已支付)。如果在支付截止时间内没付款,订单要变成 2(已取消),同时把之前扣减的余票补回去。已支付的订单如果发起退票操作,则状态改成 3(已退票),同样恢复余票。
这里最实用的是"超时取消"的实现。很多毕设项目直接不做,或者只在用户主动取消时恢复库存。我觉得至少要做一个简单版本:订单创建时写入expire_at,等于创建时间加 15 分钟。然后写一个 Django 管理命令,定期扫描所有待支付订单,超过expire_at的批量改为取消状态并恢复库存。
# data check_expired_orders.py from django.core.management.base import BaseCommand from django.utils import timezone from order.models import Order class Command(BaseCommand): def handle(self, *args, **options): expired_orders = Order.objects.filter(status=0, expire_at__lt=timezone.now()) for order in expired_orders: # 恢复余票:找到对应 TrainStock 行,remaining 加回 ticket_count order.status = 2 order.save()在 PyCharm 里可以直接通过 Tools -> Run manage.py Task 运行这个命令,也可以用系统定时任务调用,属于既能讲清楚又不用过度设计的方案。如果你想把架构显得更高级一点,可以把扫描逻辑换成 Celery 定时任务,但我个人觉得毕设完全没有必要,重点是把状态流转和数据一致性做对。
4. PyCharm 环境搭建与调试技巧
4.1 从 Python 安装到虚拟环境配置
工欲善其事,必先利其器。我见过太多人代码写得没问题,结果环境没配好卡了半天。在这个项目里,我建议直接用 PyCharm 打开作为开发主界面。PyCharm 社区版完全够用,不用去折腾什么破解激活,这种免费正版工具用起来心里踏实。
环境搭建步骤我整理成了一套固定流程:
- 从官网下载对应系统的 Python 3.10 或 3.11 版本,安装时记得勾选 Add Python to PATH。
- 打开 PyCharm,新建项目时选择 Virtualenv 作为虚拟环境,Python 解释器指向刚安装的 Python。
- 在终端里安装后端依赖:
pip install django djangorestframework django-cors-headers # 如果走 Flask 路线,用下面这行 pip install flask flask-sqlalchemy flask-cors flask-migrate - 前端部分用命令创建 Vue 项目并安装依赖:
npm create vue@latest frontend cd frontend npm install npm install axios vue-router element-plus npm run serve
这里有个很容易踩的坑:在 PyCharm 里明明安装了第三方库,运行却报ModuleNotFoundError。十有八九是解释器选错了,PyCharm 右下角可以切换解释器,一定要确保指向的是当前项目的虚拟环境,而不是全局环境。我在帮朋友调 bug 时,至少有一半的环境问题出在这。
4.2 在 PyCharm 里把 Django 和 Flask 跑起来
Django 项目在 PyCharm 里的运行配置其实可以很顺手。先python manage.py runserver 0.0.0.0:8000跑起来,然后 Add Configuration,选择 Python,Script 参数填manage.py的完整路径,Parameters 填runserver 0.0.0.0:8000,这样每次点绿色按钮就能启动,不用敲命令。
如果跑的是 Flask,更简单:写一个app.py,在文件里配置app.run(host='0.0.0.0', port=8000, debug=True),然后在 PyCharm 里右键运行这个文件即可。Flask 的自动重载功能默认是开着的,改完代码不用手动重启,这点在开发调试时特别舒服。
调试技巧方面,我强烈建议用断点而不是打印日志来排查购票流程。在create_order函数里stock = ...select_for_update()这行打一个断点,用两个调试会话同时进入,能非常直观地看到第二个请求在等待锁释放。我试过用这种调试方式给别人演示并发锁,效果比讲十遍原理都管用。
4.3 查看数据库查询和请求日志的实战方法
Django 在 PyCharm 里查 SQL 有个小技巧:在settings.py里加一段日志配置,就能在控制台看到每个 ORM 操作实际执行的 SQL 语句。这对排查"为什么我的查询这么慢""为什么 select_for_update 没生效"特别有帮助。
LOGGING = { 'version': 1, 'handlers': {'console': {'level': 'DEBUG', 'class': 'logging.StreamHandler'}}, 'loggers': {'django.db.backends': {'handlers': ['console'], 'level': 'DEBUG'}}, }看到 SQL 后你会发现很多性能问题。查车次列表的时候,如果不小心写了TrainStock.objects.filter(...).select_related('train'),生成的 JOIN 和循环查询差异很大。Django 的select_related和prefetch_related是热点词,也是面试常问的优化手段,建议在项目里用起来。比如订单列表需要显示车次号,就可以在查询订单时select_related('train'),避免每个订单都额外查一次车次表。
前端 Vue 的调试主要靠浏览器开发者工具。打开 Network 面板看接口请求的状态码和返回报文,比在代码里瞎猜高效得多。如果接口返回 500,就切到 Django 控制台看完整堆栈;如果返回 403,重点检查用户认证;如果返回跨域错误,先查后端 CORS 配置。
5. 常见问题与排查技巧实录
5.1 前端 Vue 依赖和路由相关的坑
Vue 项目开发中我遇到最多的坑集中在依赖安装和路由跳转。npm install报ERESOLVE unable to resolve dependency tree是很典型的问题,通常是依赖版本冲突。社区里最直接的解决方式是在命令后面加--legacy-peer-deps,能绕过 peerDependencies 的严格校验继续安装。我项目里用 Element Plus 配 Vue 3 的时候碰到过一次,加了这个参数后顺利装完。
路由参数这块有个经典细节:用params传参时,页面一刷新参数可能就没了,尤其是直接访问 URL 的时候。所以涉及车次 ID 这类关键信息的传递,尽量放到路径参数里,比如/order/123,而不是/order?trainId=123。如果担心刷新后查不到车次详情,可以在Order.vue里根据车次 ID 再调一次后端接口补齐信息,这种做法最稳。
源码分享给别人协作时,建议不要直接把本地项目压缩包发过去完事。最好在项目根目录写一个README.md,说清楚 Python 版本、依赖安装命令、数据库迁移命令、前端启动命令。我见过太多人花半小时装依赖发现版本不一样,最后发现 README 里什么都没写。
5.2 后端跨域、时间格式和字段命名问题
跨域是前后端分离项目绕不开的坎。Django 配了django-cors-headers之后还要注意中间件的顺序,CorsMiddleware要放在CommonMiddleware前面,否则可能不生效。Flask 用 Flask-CORS 则简单很多,直接在应用上调用CORS(app)即可,但如果你用了蓝图,需要确认蓝图的请求也经过了扩展处理。
时间格式是另一个容易翻车的地方。Django 默认返回的datetime会被序列化成类似2024-12-01T08:30:00.123456Z的 ISO 格式。前端如果不处理,直接渲染到页面会显示一串不明所以的字符串。我这里的方案是前端统一用dayjs做格式化,展示成2024-12-01 08:30这种用户能看懂的样子。同时后端在settings.py里设置USE_TZ = False(如果只做国内场景)或者统一转成指定时区,保证接口返回的数据和前端展示一致。
字段命名上也别提踩太深的坑。不要用order这种名字做模型类,Django 自己到处都是Order相关的东西,虽说不一定会冲突,但搜索代码时很容易混淆。车的字段建议统一用train_no、start_station,不要一会儿trainNo一会儿start_station,前后端对接时大小写风格不一致会浪费大量时间。
5.3 排查问题速查表
最后整理一张排查表,都是我实际开发时用过的定位思路,照着查能节省不少时间:
| 现象 | 优先检查项 | 解决参考 |
|---|---|---|
| 后端接口 500 | Django 控制台完整报错堆栈 | 看最后几行异常类型 |
| 前端跨域报错 | 后端 CORS 中间件和 allowed origins | django-cors-headers 配置 |
| 接口能通但列表无数据 | 数据库表是否有初始化数据 | 先通过 Admin 后台添加车次 |
| 刷新页面后订单信息丢失 | Vue 路由 params 使用方式 | 改为路径参数并在页面内查询 |
| 并发抢票还超卖 | 是否使用了事务和行锁 | 加 select_for_update 并检查锁范围 |
| 查询很慢 | ORM 有没有 N+1 查询 | 使用 select_related/prefetch_related |
| 依赖装不上 | 网络源和版本冲突 | 换镜像源或加 legacy-peer-deps |
对照表格排查完,大部分问题都能自己解决。剩下解决不了的,还有一个笨办法很有效:把 Django 控制台和浏览器 Network 面板同时打开,照着接口请求一步步走一遍,看是前端没发请求、后端报错还是数据格式没对上,基本一两轮就能定位到具体位置。
写这个项目最大的体会是:别急着写代码,先把"库存和订单状态谁在什么时间点被谁改变"这条线理清楚。我没想明白之前,写出来的接口改了两版才稳定,想明白之后整个后端几乎没怎么重构。如果你也在做火车购票系统,建议先花一晚上把数据模型和状态流转敲定,再用 Django 写事务扣票逻辑,最后用 Vue 做展示层,这个顺序最稳。演示时记得开两个浏览器窗口同时抢最后一张票,能亲眼看到没有超卖,那一刻你会觉得前面的坑都没白踩。