基于Django+Vue的火车购票系统全栈实战开发
做火车购票系统这个项目的时候我属于典型的“半路出家”Python语法写了一年Vue只会改模板Django和Flask都在“用过但没深入”的状态。当时想做一个人人能看懂、又有真实业务价值的全栈项目翻了一圈下来发现火车购票系统特别适合——它既不是千篇一律的博客系统也不是单纯的管理后台车次查询、余票扣减、订单状态流转、支付回调这些逻辑刚好能把Python后端和Vue前端逼到真刀真枪联调的地步。这个项目从零到一涉及Pycharm、Django、Flask、Vue的完整技术栈选型我会尽量把每一步的取舍和踩坑都讲清楚包括为什么最后用Django做主力后端而不是Flask、为什么Vue做前端、以及环境配置和联调交付中的那些细碎问题。如果你想找一个既有实现难度又能在面试或答辩中讲出亮点的全栈练习项目这篇应该能帮你节省不少试错成本。1. 项目整体设计与技术选型1.1 为什么火车购票系统适合作为全栈实战项目我记得当时调研方向时很多人推荐电商项目但电商的支付流程、库存扣减太依赖第三方不利于本地演示博客和ToDo又太单薄。火车购票系统有一个非常关键的优点它的业务约束是看得见摸得着的——一趟车有多少座位、卖出去多少张票、退票后恢复多少余票这些都是可以量化验证的条件。面试官问起来你可以直接说“我这里用了事务和行锁解决超卖”而不是只能聊CRUD。对学生和转行者来说这种“可验证的业务逻辑”比花哨的页面更能体现工程能力。再加上火车购票的流程覆盖了典型的状态流转提交订单是一个状态支付成功是一个状态出票又是一个状态退票、改签、取消也都对应着不同的状态分支。把这些状态管理好了你的后端代码质量会明显上一个台阶。Vue前端也不是简单渲染列表它需要处理车次筛选、日期选择、座位余票的实时展示、订单操作的按钮状态切换这些交互天然适合组件化设计。所以这个项目不是“又一个增删改查”而是一个能讲出设计深度的中小型业务系统。1.2 后端选型Django、Flask与FastAPI怎么权衡后端选型这一步不要盲目拍板先把三者的区别搞清楚。Django适合“要快速搭建完整业务系统”的场景因为它自带ORM、Admin后台、认证体系和中间件机制这对火车购票这类需要用户管理、后台管理、权限控制的项目非常友好Flask则适合轻量接口服务、微服务或你只想要一个灵活骨架的场景FastAPI的特点是异步和高性能适合IO密集型的API服务但它生态的积累没有Django那么全面。框架ORM自带后台生态成熟度适合场景Django完善有高业务系统、快速开发Flask需自行集成无中高轻量服务、定制型项目FastAPI需自行集成无中等高性能API、异步任务为什么最终用Django核心原因是火车购票系统的业务实体非常多车次、车站、订单、用户、余票、乘客信息每个实体都有数据关系。Django的ORM和迁移机制可以非常高效地管理这些模型并且它自带的Admin在开发期可以直接当后台用。比如你创建完车次模型后在admin后台就能直接增删改查车次数据对前期调试数据来说太方便了。那Flask在这个项目里有没有用有。我当时把Flask用在一个辅助模块——给前端提供一个轻量的车次余票快查接口独立于主站之外压力测试时观察Flask处理简单查询的表现。这种“主应用用Django辅助脚本用Flask”的布局在真实项目中很常见。你不需要在一个项目里二选一把合适的工具放在合适位置就行。1.3 功能模块与角色权限的通盘考虑项目整体拆成用户端和管理端。用户端功能是注册登录、车次查询、在线下单、订单支付、订单退改。管理端功能是用户管理、车次信息维护、余票调整、订单查询、基础数据统计。对应的模块边界就清晰了认证模块、查询模块、订单模块、支付模块、管理模块。表的初步规划是用户表、车次表、车站表、经停表、订单表、乘客信息表。权限用Django自带的Group和Permission在DRF层面用IsAuthenticated配合自定义权限类控制管理接口。这个阶段最关键的是把数据流想清楚用户搜索车次前端把出发地、目的地、日期参数传给后端后端根据车次表和经停表算出可用班次用户选择班次和坐席等级后创建订单系统先锁定对应余票再引导支付支付成功后出票并扣减余票。这条线捋顺了后面写代码就不会东一榔头西一棒子。建议动手前先画一张简单的流程草图哪怕用纸笔画也行。2. 环境准备与项目骨架搭建2.1 Python和Pycharm安装的细节问题说一个容易踩的坑不要装最新版Python就完事。Django框架对Python版本有要求一些第三方库在老版本编译时容易出现兼容问题。我当时用的是Python 3.9配合Django 3.2 LTS版本这套组合非常稳定。如果你已经装了更高版本优先在虚拟环境里指定版本而不是贸然让所有项目共享同一个解释器。在Windows下安装Python记得勾选“Add Python to PATH”否则后面命令行里python会直接找不到。Pycharm建议直接用官方渠道下载的社区版或专业版社区版做这个项目完全够用。安装好之后创建项目时Pycharm会自动基于当前解释器生成一个venv文件夹所有的依赖包都装在这个环境里不会污染系统Python。这也是我为什么坚持“每个项目一个虚拟环境”的原因Django版本不同、依赖版本不同全部装全局早晚踩依赖冲突的坑。创建完虚拟环境后在Pycharm右下角确认解释器路径已经指向venv这一步很多人会忽略结果装包装到了错误环境。2.2 用Pycharm创建Django项目和应用的顺序创建步骤按顺序来不容易出错。先用命令行或Pycharm终端安装Django固定版本pip install django3.2.*。接着创建项目django-admin startproject railway_backend。然后创建业务应用python manage.py startapp userspython manage.py startapp orderspython manage.py startapp trainspython manage.py startapp stations。创建完不要急着写代码先把这些app注册到INSTALLED_APPS里再在settings.py里做三件事配置LANGUAGE_CODE zh-hans配置TIME_ZONE Asia/Shanghai配置数据库连接。我建议把项目命名为railway_backend前端目录叫railway_frontend这样前后端分离边界一目了然。app的拆分不要按“功能页面”拆而是按“业务域”拆users管用户认证、orders管订单、trains管车次、stations管车站与经停。这样后面写接口、模型、权限都能快速找到归属。如果打算用MySQL就在DATABASES里把默认的sqlite3改成mysql配置并提前创建好空数据库如果只是先跑通功能sqlite3足够部署时再切换也不难。2.3 Vue工程创建与环境配置前端我用的是Vue 3配合Vue Router 4。用npm create vuelatest创建项目比较快脚手架更简洁也可以用vue create railway_frontend区别不大看个人习惯。创建完成之后需要装几样东西vue-router负责路由axios负责HTTP请求element-plus用来做后台管理界面sass可选但写样式很方便。装完依赖先跑一次npm run serve确认能在浏览器访问localhost:8080再继续。前后端分离项目的边界是前端只负责渲染和交互后端只提供JSON接口。所以前端目录里不直接写Python代码后端也不放Vue源码。联调阶段通过代理或CORS解决跨域但那是后话。还有一个容易被新手忽略的点Vue项目移植给别人时node_modules文件夹一定不要发——它体积巨大且可以随时由package.json重新生成。正确做法是把package.json和package-lock.json发过去对方执行npm install就能安装完全相同版本的依赖。这也是团队协作里非常基础的习惯。3. 后端核心实现模型、接口与业务逻辑3.1 核心表结构与模型代码示例火车购票系统的核心数据模型比普通博客复杂不少但也没有复杂到要上微服务的程度。我最终落地的表结构大致是这样用户表继承Django的AbstractUser存手机号、昵称、身份证号等扩展字段车站表存站名、城市、经纬度车次表存车次编号、类型、始发站、终点站、出发到达时间经停表存车次与车站的多对多关系并且额外记录到达时刻、出发时刻、停靠顺序订单表存用户、车次、乘车日期、坐席等级、乘客数量、订单金额和状态乘客表存常用乘车人信息。这里最需要设计感的是经停表。很多人会直接在车次表里放一个逗号分隔的车站字段但那样查“某站到某站有哪些车”会非常痛苦。正确做法是单独建一张TrainStation关联表让车次和车站做多对多关联同时附带顺序和停靠时间。模型代码可以这样写# stations/models.py from django.db import models class Station(models.Model): name models.CharField(车站名称, max_length50, uniqueTrue) city models.CharField(所在城市, max_length50) pinyin_code models.CharField(拼音码, max_length10) def __str__(self): return f{self.city}{self.name} # trains/models.py from django.db import models class Train(models.Model): TRAIN_TYPE ( (G, 高铁), (D, 动车), (K, 普快), ) train_no models.CharField(车次编号, max_length20, uniqueTrue) train_type models.CharField(列车类型, max_length1, choicesTRAIN_TYPE, defaultG) start_station models.ForeignKey(stations.Station, related_namestart_trains, on_deletemodels.PROTECT) end_station models.ForeignKey(stations.Station, related_nameend_trains, on_deletemodels.PROTECT) start_time models.TimeField(出发时间) end_time models.TimeField(到达时间) class TrainStation(models.Model): train models.ForeignKey(Train, related_namestop_stations, on_deletemodels.CASCADE) station models.ForeignKey(stations.Station, related_namein_trains, on_deletemodels.CASCADE) order models.IntegerField(停靠顺序) arrive_time models.TimeField(到达时间, nullTrue, blankTrue) depart_time models.TimeField(出发时间, nullTrue, blankTrue) class Meta: ordering [train, order] unique_together (train, station)on_deletemodels.PROTECT是我刻意选的目的是防止有人直接删除还有车次引用的车站导致历史数据悬空。这是一种保护型约束比CASCADE更适合作基础数据表。3.2 订单状态流转与动态工作流设计订单状态是这个项目里最容易被做成一团浆糊的地方。很多人直接用status models.IntegerField(choices...)然后前端按钮随便改状态结果出现“未支付订单变成了已出票”这种事故。我参考了“django动态工作流”的思路虽然没有引入完整的工作流引擎但把状态迁移规则做在了模型层。我自己是这样设计的订单状态用IntegerChoices定义配合一个transition_to方法做合法迁移校验。例如订单只能从“待支付”变到“已支付”然后再变到“已出票”待支付状态下超过15分钟就自动置为“已取消”已出票才能发起退票或改签。代码类似这样# orders/models.py from django.db import models class Order(models.Model): class Status(models.IntegerChoices): PENDING 1, 待支付 PAID 2, 已支付 ISSUED 3, 已出票 REFUNDED 4, 已退票 CHANGED 5, 已改签 CANCELLED 6, 已取消 status models.IntegerField(订单状态, choicesStatus.choices, defaultStatus.PENDING) # 其他字段省略 def transition_to(self, target_status): allowed { self.Status.PENDING: {self.Status.PAID, self.Status.CANCELLED}, self.Status.PAID: {self.Status.ISSUED}, self.Status.ISSUED: {self.Status.REFUNDED, self.Status.CHANGED}, } if target_status not in allowed.get(self.status, set()): raise ValueError(f非法状态迁移: {self.get_status_display()} - {target_status}) self.status target_status self.save(update_fields[status])这样做的好处是所有状态变化的入口都被收拢到了同一个方法里不会再出现“支付没成功订单却出票”的诡异情况。如果你后续想引入真正的动态工作流引擎这个模型的transition_to方法就是最自然的接入点。3.3 ORM关联查询优化与数据删除的工程细节热搜词里有个“django select_related personinfo vocation”其实就是典型的ORM关联查询优化问题。写Django的人都知道如果不做关联预取查询订单列表时会先查出所有订单再为每一条订单单独查一次用户信息这就是N1查询。我之前实测过一次查100条订单关联查询用户和车次时如果不优化数据库会执行两百多次查询耗时在一秒以上加上select_related之后直接降到两次查询。select_related适合一对一、外键这种“正向单对象”关联它会用SQL的JOIN一次取回prefetch_related适合多对多、反向外键这种“集合型”关联它会先查主表再单独查关联表最后在内存里组装。两种方法适用场景不一样别混用。查询订单的推荐写法是orders (Order.objects .select_related(user, train) .prefetch_related(passengers) .filter(userrequest.user))再说“django执行查询-删除对象”这个点。Django的delete()操作分为查询集删除和实例删除两种Model.objects.filter(...).delete()返回一个元组表示删除了多少对象实例的obj.delete()则只处理当前对象。这里有两个值得注意的细节一是on_delete策略要提前想好是CASCADE级联删除还是PROTECT保护性拒绝删除还是SET_NULL置空二是业务表最好不要硬删除建议加一个is_active布尔字段做软删除。原因很简单订单、用户这些数据有审计价值物理删了就再难找回。3.4 基于DRF的RESTful接口设计与认证接口层我直接用Django REST Framework这在项目里是最能提升开发效率的一步。手写JsonResponse不是不行但要自己处理序列化、反序列化、参数校验、分页、过滤器工作量会翻好几倍。DRF自带这些能力而且天然和Django的Model绑定。用ModelViewSet加Router的方式最省事。比如用户接口# users/views.py from rest_framework.viewsets import ModelViewSet from .models import UserProfile from .serializers import UserProfileSerializer class UserViewSet(ModelViewSet): queryset UserProfile.objects.all() serializer_class UserProfileSerializer permission_classes [IsAuthenticated] # urls.py from rest_framework.routers import DefaultRouter router DefaultRouter() router.register(users, UserViewSet) urlpatterns router.urls认证方案我选了djangorestframework-simplejwt比Django自带的Token更成熟适合前后端分离项目。登录成功后返回access_token和refresh_token前端把token存到localStorage或内存中每次请求在Authorization请求头里带上。权限控制方面普通用户接口用IsAuthenticated管理端接口用IsAdminUser再配合自定义权限类就能实现“用户只能查自己的订单、管理员才能改车次”的边界。另外我建议接口返回值统一封装成固定结构比如{code: 0, message: success, data: {...}}。这样前端axios拦截器只需要解析code就能全局处理业务错误不需要为每个接口单独做异常判断。这个习惯一旦养成写任何后端项目都会轻松很多。4. 前端Vue实现与前后端联调4.1 Vue Router路由规划与登录守卫Vue项目不是简单堆页面路由规划直接决定项目的可维护性。我按用户端和管理端划分了父子路由/login是登录页/register是注册页/trains是车次列表/order/:id是订单详情/admin下面是Dashboard、车次管理、订单管理等后台页面。订单详情这类带参数的页面使用Vue Router的路由参数传递orderId组件内部通过route.params.id获取。登录守卫是必不可少的。我的做法是在路由配置的meta里标记哪些路由需要登录、哪些需要管理员权限然后在router.beforeEach全局前置守卫里做判断没有token且访问的是受保护页面就重定向到/login。代码逻辑不复杂但把这层守卫做好后前端就不会出现“未登录也能打开订单详情”的漏洞。4.2 组件化拆分与插槽实战Vue开发很容易犯的错误是“页面即组件”——把整个车次列表都写在页面文件里导致代码动辄几百行。我的拆分原则是“能复用的都拆出来”车次卡片是一个组件余票标签是一个组件订单状态徽章是一个组件分页器是一个组件日期选择器是一个组件。组件之间用props传数据用自定义事件或Vuex/Pinia管理公共状态。Vue插槽这个点也值得展开说。后台管理页里经常会遇到“表格操作列不确定”的情况比如车次管理表格里的操作有编辑、删除、停运用户管理表格里又变成禁用、重置密码。如果每个表格都写死操作按钮后期加一个操作就要大改组件。用插槽就能很好解决父组件把操作按钮作为内容传给子组件表格组件子组件在表格列里用slot nameactions :rowrow /渲染。这种作用域插槽的用法在开源后台项目里非常普遍。4.3 axios请求封装与联调配置前后端联调是很多全栈新手最难受的阶段问题大多出在请求封装不统一。我建议在src/utils/request.js里创建一个统一的axios实例import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 0) { return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { router.push(/login) } return Promise.reject(error) } )baseURL写成/api是一个刻意的设计开发环境下通过Vue CLI的代理把/api转发到后端8000端口生产环境下再让Nginx把/api转发到后端这样前端代码里不需要写死后端地址环境切换非常顺滑。vue.config.js里的代理配置大概长这样module.exports { devServer: { proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true } } } }这个代理方案能直接绕开跨域问题同时cookie和token都会保持在同一域名下比后端配CORS省心得多。5. 常见问题与排查技巧实录5.1 跨域问题从报警到理解再到解决前后端分离开发时最常看到的浏览器报错就是CORS相关的“Access-Control-Allow-Origin”。这个问题要从原理上理解浏览器出于安全限制默认禁止页面访问不同源的接口。开发环境的解法有两种前端起代理或者后端配跨域。我的经验是优先用前端代理因为它在开发和上线阶段都能保持同源不会出现“本地能请求、线上又跨域”的割裂状态。如果某些场景必须后端开CORS这里用Django的django-cors-headers库最稳。先安装并添加app然后在settings.py里配置CORS_ALLOWED_ORIGINS [ http://localhost:8080, http://127.0.0.1:8080, ] CORS_ALLOW_CREDENTIALS True注意如果前端请求里带Cookie或自定义请求头别忘记把CORS_ALLOW_HEADERS和CORS_ALLOW_CREDENTIALS配置好否则登录状态仍然传不过去。5.2 并发余票扣减与事务处理火车票系统最大的技术难点不是CRUD是并发。模拟一下场景高铁同一趟车只剩最后一张票两个用户同时提交订单如果代码逻辑是先查余票是否大于0再执行插入订单最后扣减余票那么两个请求可能同时读到“余票还有1张”同时创建订单最终造成超卖。这个问题我是在压测时真实复现的压力一起来订单数就超过了座位数。解决方式是在扣减余票时加事务和行锁。Django里用transaction.atomic()包住业务逻辑再用select_for_update()锁定车次的余票记录from django.db import transaction transaction.atomic def create_order(train_id, user, passengers): train Train.objects.select_for_update().get(pktrain_id) if train.remaining_tickets len(passengers): raise ValueError(余票不足) order Order.objects.create(...) train.remaining_tickets - len(passengers) train.save() return orderselect_for_update()会在数据库层面锁住这行记录直到事务提交。第二个请求只能等第一个事务结束再去读余票这时候余票已经变了自然不会超卖。除行锁以外还可以用乐观锁——在模型里加版本号字段更新时判断版本号是否匹配但实现上更繁琐普通系统用行锁就足够。5.3 时区、序列化与日期格式问题时区问题属于那种“平时不显眼、一上线就翻车”的经典故障。Django默认USE_TZTrue数据库里存的是UTC时间而中国用户看到的是北京时间。如果序列化时不处理时区前端会收到一条比本地时间少8小时的数据。我的处理是在DRF的序列化器里显式指定时间格式并且转换为本地时区class OrderSerializer(serializers.ModelSerializer): create_time serializers.DateTimeField(format%Y-%m-%d %H:%M:%S, requiredFalse) class Meta: model Order fields __all__如果前端拿到的是标准ISO格式也可以用dayjs或Moment在前端做一次本地化转换。但最省心的方法还是后端直接返回格式化好的字符串前端不需要感知时区逻辑。5.4 部署与上线避坑记录把一个前后端分离项目部署上线问题会比本地开发多不少。第一个坑是Django的ALLOWED_HOSTS默认是空的部署后必须把域名或服务器IP加进去否则直接报DisallowedHost。第二个坑是DEBUGFalse之后Django不再托管静态文件Admin后台样式会全丢这时候要么用whitenoise要么把静态文件交给Nginx托管。第三个坑是Vue打包出来的dist目录必须注意路由模式如果用了history模式需要在Nginx配置里做try_files回退到index.html否则刷新页面会404。部署架构也比较常规前后端分离Nginx托管Vue打包产物并反向代理/api请求到DjangoDjango用Gunicorn运行在8000端口。没有用上Docker但如果你会Docker Compose把这套架构容器化也不难。5.5 常见报错速查表报错/问题最可能的排查方向ModuleNotFoundError: No module named django虚拟环境没激活或没安装对应包CORS policy: No Access-Control-Allow-Origin前端未配置代理或后端CORS白名单没加域名DisallowedHostsettings.py的ALLOWED_HOSTS没有添加当前域名/IPCSRF token missing or incorrectDRF未配置JWT或Session认证请求未携带tokendatabase is lockedsqlite并发写入冲突生产尽量换MySQL/PostgresFailed to load tsconfig vue/tsconfigVue TS模板缺少vue/tsconfig依赖npm install后检查ERR_CONNECTION_REFUSED后端服务没启动或代理target端口不对6. 项目复盘与扩展方向项目做到这个程度我对火车购票系统的理解已经从“写接口、套模板”变成了“设计业务、保证数据一致性”。纯技术上的收获反而不是最主要的真正有价值的是那几个反复打磨的点订单状态迁移的一致性、并发余票的扣减、查询性能的优化这些都是真实业务里躲不开的问题也是面试官最喜欢追问的方向。如果时间充裕这个项目还有很多扩展空间。比如用Redis缓存热门车次的余票数量把数据库的压力前置到缓存层引入消息队列处理订单超时自动取消在管理端加一套商品量图表统计展示每日票务销售情况。我个人的体会是从一个能跑通的项目转变成一个能抗住压力的系统中间隔着的就是这些问题意识。最后再分享一个小技巧——写后端业务代码时把每个接口的入口日志和关键业务节点的日志都打上trace_id排查线上问题时你会感谢自己这个习惯。