想做演唱会、音乐会购票系统的朋友,多半是接到了课程设计、毕业设计,或者单纯想练手前后端分离开发。这个题目从技术栈上看非常典型:Python 负责后端逻辑,Vue 负责前端交互,Django 或 Flask 提供接口,PyCharm 作为开发环境。我前前后后用这套组合写过两三个类似的项目,从学生购票到小型场馆订座都跑过,踩了不少坑,也攒了一些能直接用的经验。这篇就把整个系统的设计思路、核心代码、开发环境和排错方法一次性说清楚,照着做,你也能交出一个能演示、能答辩、能上线的购票管理平台。
1. 项目概述与需求拆解
1.1 这个购票系统到底要做什么
演唱会、音乐会的购票系统,表面看就是“用户选场次、选座位、下单、付款、拿到电子票”。但真落到系统设计上,它要比普通的电商系统多一层复杂度:座位是二维的,一场演出可能有几百上千个座位,每个座位在同一场次只能被一个人锁定;热门演出还要面对多用户同时抢票的并发压力。再加上后台需要管理演出、场馆、场次、订单、用户,整个系统就是一个典型的“前台展示 + 后台管理 + 订单交易”全栈项目。
我用 Python + Vue 实现时,把系统分成了两类角色:
- 普通用户:注册登录、浏览演出列表、查看演出详情、选择场次和座位、创建订单、模拟支付、查看我的订单和电子票。
- 管理员:维护演出信息、管理场馆和座位图、查看所有订单、处理退票或改签、统计售票数据。
如果这是课程设计,把上面这些功能做成页面和接口,已经能覆盖“完整的管理系统”这个评价标准。如果是自己想练技术,建议在订单模块上多花心思,把并发扣库存、座位锁定、事务回滚这些细节做扎实,这才是面试官或老师真正会追问的点。
1.2 需求模块与核心流程
我习惯先列数据流向,再写代码。一遍跑通的核心流程是:
用户注册登录 → 浏览演出 → 选择一个场次 → 进入选座页 → 点击座位(此时座位被临时锁定)→ 确认订单 → 支付(模拟)→ 订单完成 → 我的票据生成。
管理员流程就简单很多:登录后台 → 新增演出(关联场馆和场次)→ 初始化座位 → 查看订单 → 处理退款。
从这些流程里可以拆出下面几个核心模块:
| 模块 | 对应功能 | 关键难点 |
|---|---|---|
| 用户模块 | 注册、登录、信息修改 | 密码加密、Token 认证 |
| 演出模块 | 演出列表、详情、场次 | 图片上传、多场次关联 |
| 场馆与座位模块 | 场馆管理、座位图展示 | 座位二维数据建模、选中状态 |
| 订单模块 | 创建订单、库存扣减、支付状态 | 并发锁、事务、座位锁定 |
| 后台管理模块 | CRUD 接口、数据统计 | 权限控制、分页搜索 |
把模块理清楚之后,无论你写 Django 还是 Flask,代码结构都不会乱。
1.3 技术选型:Django还是Flask,Vue怎么选
标题里同时出现了 Django 和 Flask,很多人会纠结。我的建议:做多表关联复杂的系统,直接上 Django。Django 自带 ORM、Admin、用户认证体系,开发购票系统这种模型关联多的项目,省掉大量底层工作。Flask 更适合非常轻量的单模块接口,如果选 Flask,你要自己集成 SQLAlchemy、Flask-RESTful、JWT 扩展,代码自由但工程量更大。
在我的项目里,后端用 Django + Django REST Framework,理由很实际:
- ORM 管理 User、Order、Seat、Performance 这些外键关系非常顺手。
- DRF 的序列化器和 ViewSet 能快速生成 RESTful API。
- Django Admin 可以直接当后台管理页的雏形,后期再单独做 Vue 后台也不冲突。
如果你想用 Flask,只要保持接口语义不变,前端完全不用动,因为 Vue 只关心 HTTP 接口。前端的 Vue 我建议用 Vue 3 + Vue Router + Axios + Element Plus,组件化和生态都成熟,表格、表单、弹窗这些管理界面组件都能直接用。如果你还要做后台管理页面,可以再配一个 vue-element-admin 风格的后台模板,比自己从头画界面快太多。
2. 系统架构与数据模型设计
2.1 前后端分离的整体架构
这个项目我用的是前后端完全分离:后端跑在 8000 端口,提供 JSON 接口;前端跑在 8080 端口,开发时通过代理转发请求。整体请求链路是:
浏览器 → Vue 页面 → Axios 发送请求 → Vue CLI Dev Server 代理 → Django/Flask 接口 → ORM 读写数据库 → 返回 JSON → Vue 渲染页面。
这里最容易被新手忽略的是“代理”。如果不配置代理,前端直接请求http://localhost:8000/api/...会因为跨域被浏览器拦截。我用 Vue CLI 时会在vue.config.js里写:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } } } }这样前端代码里所有请求都写/api/xxx,浏览器访问 8080 时,Vue 开发服务器会把请求转发到 8000,绕开跨域。部署时再用 Nginx 做反向代理,把同一域名下的/api转发到后端,前端静态文件直接由 Nginx 托管,彻底解决跨域。
2.2 数据库模型设计详解
用 Django 的 ORM 定义模型,最关键的几张表是:用户、演出、场馆、场次、座位、订单。我先说设计思路,再给核心代码。
用户表:直接继承 Django 自带的AbstractUser,不要自己从零建用户表。后续如果要加手机号、昵称、头像,扩展字段就行。
from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): phone = models.CharField(max_length=11, blank=True) avatar = models.URLField(blank=True) class Meta: db_table = 'user'场馆与座位:场馆有名称、城市、地址。座位属于某个场馆,用行号、列号、区域和类型(普通/ VIP)来定位。
class Venue(models.Model): name = models.CharField(max_length=128) city = models.CharField(max_length=64) address = models.CharField(max_length=255) class Seat(models.Model): venue = models.ForeignKey(Venue, on_delete=models.CASCADE, related_name='seats') row = models.IntegerField() column = models.IntegerField() area = models.CharField(max_length=50, default='内场') seat_type = models.CharField(max_length=20, choices=(('normal', '普通'), ('vip', 'VIP'))) price = models.DecimalField(max_digits=8, decimal_places=2)这里的row和column是座位图的关键。前端拿到一个场次的所有座位后,根据row和column在一个二维网格里绘制位置,用户点击某个座位时,传给后端的是“场次 ID + 座位 ID”。
演出与场次:演出是静态信息(名称、艺人、海报、介绍),场次才是真正要卖票的对象,因为一场演出可能有多场(比如周末两天)。
class Performance(models.Model): title = models.CharField(max_length=128) artist = models.CharField(max_length=64) poster = models.URLField() description = models.TextField() class Session(models.Model): performance = models.ForeignKey(Performance, on_delete=models.CASCADE, related_name='sessions') venue = models.ForeignKey(Venue, on_delete=models.CASCADE) start_time = models.DateTimeField() # 同一个场次下,座位和价格关联到 Session 而不是 Performance这里有个细节:同一个座位在不同场次的价格可能不一样(比如首场更贵)。所以“价格”不应该只挂在 Seat 上,最好放到“某个场次下的某个座位”这个中间关系里。简单做法是直接在 Session 下建一个SessionSeat表,字段包括 session、seat、status、order。这样状态管理最精确。
class SessionSeat(models.Model): session = models.ForeignKey(Session, on_delete=models.CASCADE, related_name='session_seats') seat = models.ForeignKey(Seat, on_delete=models.CASCADE) status = models.IntegerField(default=0) # 0 可售,1 锁定,2 已售 order = models.ForeignKey('Order', null=True, blank=True, on_delete=models.SET_NULL) price = models.DecimalField(max_digits=8, decimal_places=2)订单表:订单和用户关联,还要和票关联。一张订单可以包含多张票,也就是多个 SessionSeat。
class Order(models.Model): order_no = models.CharField(max_length=32, unique=True) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders') created_at = models.DateTimeField(auto_now_add=True) total_amount = models.DecimalField(max_digits=10, decimal_places=2) status = models.IntegerField(default=0) # 0 待支付,1 已支付,2 已取消,3 已退款 pay_time = models.DateTimeField(null=True, blank=True)这些模型关系理清后,后面的接口就都是“对模型的增删改查”。如果一开始关系设计错了,后面都要返工,最典型的就是把价格挂在 Seat 上,结果不同场次改价非常痛苦。
2.3 接口协议与统一返回格式
前后端分离最难的不是写接口,而是约定接口格式。我统一使用 RESTful 风格,所有接口返回这样的 JSON:
{ "code": 0, "message": "success", "data": {} }code = 0表示成功,非 0 表示业务错误。前端 Axios 拦截器统一判断:
service.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { Message.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } )这样写的最大好处是,前端所有业务逻辑里不需要再重复判断“请求是不是成功”,只拿data用就行。如果有人说“我不想用 code,直接用 HTTP 状态码”,也可以,但我还是建议保留业务 code,因为比如“座位被抢”这类业务错误用 HTTP 200 + code 2 来表达更优雅,前端处理逻辑更统一。
3. 核心功能实现与踩坑实录
3.1 从零搭建 Django 后端与用户认证
创建虚拟环境和 Django 项目前面会细说,这里直接讲代码。安装依赖:
pip install django djangorestframework django-cors-headers创建项目和应用:
django-admin startproject ticket_system cd ticket_system python manage.py startapp users python manage.py startapp performances python manage.py startapp orders用户认证我直接用 DRF 的TokenAuthentication或JWT。课程设计用 JWT 更现代,推荐djangorestframework-simplejwt。配置好之后,注册和登录接口不需要自己手写 Token 逻辑,几行代码就能搞定。
from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.serializers import TokenObtainPairSerializer class MyTokenObtainPairSerializer(TokenObtainPairSerializer): def validate(self, attrs): data = super().validate(attrs) data['user_id'] = self.user.id data['username'] = self.user.username return data class MyTokenObtainPairView(TokenObtainPairView): serializer_class = MyTokenObtainPairSerializer注册接口需要注意密码不能被明文返回,所以序列化器里要重写create方法,用set_password:
class UserRegisterSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True) class Meta: model = User fields = ('id', 'username', 'password', 'email', 'phone') def create(self, validated_data): user = User.objects.create_user( username=validated_data['username'], password=validated_data['password'], email=validated_data.get('email', ''), phone=validated_data.get('phone', '') ) return user这里踩过的坑:如果直接用User.objects.create(**validated_data),密码是明文存库,登录永远失败。我当年第一次做的时候就被这个坑了半小时。
3.2 演出信息、场馆与座位管理
后台管理的核心是演唱会的 CRUD。用 DRF 的ModelViewSet可以极大减少代码量:
class PerformanceViewSet(viewsets.ModelViewSet): queryset = Performance.objects.all().order_by('-id') serializer_class = PerformanceSerializer pagination_class = StandardResultsSetPagination路由用 DRF 的router注册:
router.register('performances', PerformanceViewSet)这样/api/performances/的 GET、POST、PUT、DELETE 全部自动生成。管理员要做的只是在前端做对应的表单页面。至于“某个场次的座位图”,核心接口是:
class SessionSeatListView(generics.ListAPIView): serializer_class = SessionSeatSerializer def get_queryset(self): session_id = self.kwargs['session_id'] return SessionSeat.objects.filter(session_id=session_id).select_related('seat')返回的座位列表包含seat_row、seat_column、status、price,前端拿到后按行列渲染成矩形块,就完成了座位图。
如果你用的是 Flask,结构同样是这样,只不过把 ORM 换成 SQLAlchemy,序列化换成flask-restful的marshal_with。功能实现没有本质区别。
3.3 下单与库存扣减的并发处理
这是整个系统最值得写的部分。用户点击“立即购买”时,前端会传来一个座位 ID 列表。后端要做三步:
- 检查这些座位在当前场次是否全部可售。
- 把这些座位状态置为锁定,并生成订单。
- 一段时间未支付则释放座位。
最直接的实现是在 Django 里用select_for_update()加行锁:
from django.db import transaction @transaction.atomic def create_order(user_id, session_id, seat_ids): locked_seats = SessionSeat.objects.select_for_update().filter( session_id=session_id, seat_id__in=seat_ids, status=0 ) if locked_seats.count() != len(seat_ids): raise BusinessError('部分座位已被锁定,请重新选择') # 生成唯一订单号 order_no = f"{time.strftime('%Y%m%d%H%M%S')}{user_id}{random.randint(100, 999)}" order = Order.objects.create(order_no=order_no, user_id=user_id, status=0) total = 0 for s in locked_seats: s.status = 1 # 锁定 s.order = order s.save() total += float(s.price) order.total_amount = total order.save() return order这里的select_for_update()会在数据库层面锁住符合条件的行,直到事务结束。两个用户同时抢同一个座位时,第二个请求会等待第一个事务提交,然后查到status已经不是 0,自然失败。如果没有这个锁,使用“先查再改”的普通逻辑,两个请求都会觉得自己买到票了,最后超卖。
如果没有支付就退出,前端要主动调用取消接口释放座位;后端也可以写一个定时任务,把超过 15 分钟未支付的订单状态为 0 的订单还原座位。课程设计阶段用一个释放接口就够。
3.4 Vue 前端页面与 API 联调
前端页面架构,我建议分成:公共布局 + 路由视图。核心页面有:
- 首页:横幅轮播 + 热门演出列表
- 演出详情页:海报、场次选择
- 选座页面:网格座位图 + 价格区
- 订单确认页:选中座位、金额、提交按钮
- 我的订单页:订单状态、电子票二维码
- 登录 / 注册页
选座页面是重点。拿到座位列表后,我按row和column构建二维数组:
const grid = {} seatList.forEach(item => { const key = `${item.row}-${item.column}` grid[key] = item })然后在模板中按场馆最大行和列循环:
<div v-for="row in maxRow" :key="row" class="seat-row"> <div v-for="col in maxCol" :key="col" class="seat-cell"> <div v-if="grid[`${row}-${col}`]" :class="['seat', { selected: selectedSeats.includes(grid[`${row}-${col}`].id) }]" @click="clickSeat(grid[`${row}-${col}`])" > {{ row }}-{{ col }} </div> </div> </div>这里有个体验细节:用户连续点击多个座位时,已经锁定的座位不能选,自己选中的座位再点一次要能取消。所以clickSeat里要更新selectedSeats数组,并且只允许同一个场次下选择最多 6 张票(视项目限制而定)。
Axios 调用时,所有需要登录的接口带上 JWT Token:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })页面联调阶段最常见的报错是 401,原因基本是 Token 过期或者没传到后端。用 Vue Router 的全局前置守卫,在进入“我的订单”这类页面时检查有没有 Token,没有就跳登录页。
4. 开发环境配置与 PyCharm 高效实践
4.1 Python、虚拟环境与依赖安装
关于 Python 版本,我建议直接用 3.10 或 3.11。Django 4.x 和 Flask 2.x 都支持,新版本对类型提示和 ORM 特性也更友好。安装 Python 之后,一定要创建一个虚拟环境,不然项目依赖会污染全局环境:
python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate进入虚拟环境后,再安装依赖:
pip install django djangorestframework django-cors-headers djangorestframework-simplejwt pillow pip freeze > requirements.txtpillow是处理图片上传时必装的,Django 的ImageField依赖它。生成 requirements.txt 后,换机器部署直接pip install -r requirements.txt就能复现环境。
如果你选 Flask,则安装:
pip install flask flask-sqlalchemy flask-restful flask-jwt-extended flask-cors虚拟环境的好处还在于,PyCharm 可以把它识别为项目解释器,避免出现“在终端装好了包,但 PyCharm 里 import 报红”这种常见问题。
4.2 创建 Vue 项目并集成 Element UI
前端建议用 Vue CLI 或 Vite 创建工程。Vite 更快,但如果你在课程设计里需要用到较老的教学资料,也可以选 Vue CLI。创建命令:
npm create vue@latest # 或者旧版 npm install -g @vue/cli vue create ticket-frontend创建后安装核心依赖:
npm install axios vue-router@4 element-plus在main.js里注册 Element Plus 和路由:
import { createApp } from 'vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import App from './App.vue' import router from './router' const app = createApp(App) app.use(ElementPlus) app.use(router) app.mount('#app')Element Plus 能帮我快速搞定表格、表单、分页、弹窗,尤其是后台管理页面,省去手写 CSS 的工作量。对购票系统这种“管理界面多”的项目,非常合适。
4.3 PyCharm 运行与调试技巧
PyCharm 的配置要点有三个:
- 设置解释器:Settings → Project → Python Interpreter → 选择虚拟环境
venv/bin/python。 - 配置 Django 运行方式:Run → Edit Configurations → 添加 Django Server → 填项目路径和端口(8000)。
- 开启 Debug 模式:Debug 运行后,在接口函数里打断点,可以看每一步变量值,比用 print 高效太多了。
我在调试下单接口时,特别依赖 PyCharm 的断点看select_for_update()锁定后的事务状态。有时候并发问题用 print 根本看不出来,必须逐步看 SQL 查询。
前端调试建议在 Chrome 里安装 Vue Devtools,用 Network 面板看接口请求和响应。如果某个接口报 500,第一时间去 PyCharm 的 Run 控制台看异常堆栈,不要在前端瞎猜。
5. 常见问题排查与部署建议
5.1 跨域问题与解决
前面提到 devServer 代理,但如果你直接请求后端地址,还是需要后端允许跨域。Django 装django-cors-headers:
INSTALLED_APPS = [ 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ... ] CORS_ALLOWED_ORIGINS = [ 'http://localhost:8080', ]注意CorsMiddleware要放在CommonMiddleware前面,否则可能不生效。Flask 则用flask-cors:
from flask_cors import CORS CORS(app)排错的时候还可以看浏览器 Network 响应头里有没有Access-Control-Allow-Origin。没有就是后端口没配置,有但前端报错,就可能是请求头里带了不被允许的字段(比如 Authorization)。
5.2 数据库迁移与数据初始化
Django 里模型写完,执行:
python manage.py makemigrations python manage.py migrate python manage.py createsuperuser迁移时报错最多的场景是“外键字段类型不一致”。比如 Seat 的 venue 默认外键到 Venue,但你在数据库中手工插入了一条 venue_id=0 的数据,迁移会因为违反约束失败。解决方法是先清理脏数据再迁移,或者给外键设置db_constraint=False但一般不推荐。
购票系统如果没有真实演出数据,演示效果会非常差。我建议写一个init_data.py脚本,自动创建几个演唱会、场馆、座位和测试账号。用 Django 的manage.py shell执行最方便:
python manage.py shell < init_data.py脚本里用get_or_create避免重复插入,例如:
venue, _ = Venue.objects.get_or_create(name='国家体育场', city='北京') for row in range(1, 6): for col in range(1, 6): Seat.objects.get_or_create(venue=venue, row=row, column=col)5.3 构建部署与上线注意事项
前端构建:
npm run build会在dist目录生成静态文件。部署我推荐 Nginx 托管dist,然后把/api反向代理到后端的 Gunicorn 服务:
server { listen 80; server_name your-domain.com; location / { root /var/www/ticket-frontend/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }后端生产环境不要用 Django 自带的 runserver,改用 Gunicorn:
pip install gunicorn gunicorn ticket_system.wsgi:application -b 127.0.0.1:8000部署后常见问题有三个:
- 静态文件 404,需要
python manage.py collectstatic并配置STATIC_ROOT。 - 数据库连接数不够,调低数据库连接池或者增加超时时间。
- 前端的路由刷新 404,Nginx 里的
try_files配置必须保留/index.html这个兜底。
我在实际部署时还遇到过 Vue 打包后接口地址忘记改的问题。因为开发时前端请求/api是相对路径,到了生产环境仍然请求同域名的/api,只要 Nginx 转发配置没问题就行。如果是打包后要请求另一个域名的接口,记得在.env.production里配置VUE_APP_BASE_API。
5.4 其他高频问题速查
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 前端请求 CORS 报错 | 后端未配置跨域 | 配置django-cors-headers或flask-cors |
| 登录后访问订单接口 401 | Token 未携带或过期 | 检查前端拦截器,刷新 Token |
| 下单时提示座位已锁定 | 有未支付的订单占用座位 | 调用取消接口释放座位,或定时清理 |
| 图片上传报错 | 缺少 Pillow 或 MEDIA 路径没配 | pip install pillow,配置MEDIA_URL |
| Vue 页面刷新 404 | 前端路由为 history 模式 | Nginxtry_files或改为 hash 模式 |
| 数据库表时间显示不对 | 时区未设置 | Django 设置TIME_ZONE = 'Asia/Shanghai',USE_TZ = False |
提示:如果是纯课程设计,不需要用 Nginx,把后端跑在本地,前端用
npm run dev,演示时浏览器开两个页面照样能说明问题。生产部署只作为加分项。
写在最后的实战建议
我前后重做过三次购票系统,最大的心得体会是:先把数据模型和接口约定写清楚再动 UI。这个系统涉及的“演出-场次-座位-订单”关系链很长,一旦中间表设计疏忽,后面选座、下单、退款每一环都会受影响。如果你只有两三天时间,优先保核心流程:登录、看演出、选座、下单、订单展示。后台的编辑功能用 Django Admin 先撑住,不要一开始掉进管理界面的细节里。
另一个容易被忽略的点是座位状态的“锁定”与“释放”。哪怕不做并发锁,至少要在订单取消时写一个释放逻辑,否则演示时点过的座位永远灰掉,观感很差。我在做第一个版本时就吃过这个亏,用户下单后直接关掉浏览器,座位一直被锁着,后台也没法批量恢复,只能手动改数据库。
如果你打算把这个项目扩展成毕设或者实际运营系统,后续可以加:短信验证码登录、Redis 缓存热门演出、座位分区定价、支付接口对接、邮件发送电子票。这些方向每个都够单开一篇,但基础架构就是我上面讲到的这些,把地基打结实,往上加功能是很自然的事。
最后再分享一个小技巧:开发时一定要看 Django 的 Run 面板和浏览器 Network 面板,哪个接口状态不对,两个地方一对照基本就能定位。别在一堆 print 里找错误,用 PyCharm 断点,效率高得多。祝你的购票系统一次跑通,答辩顺利。