做演唱会门票售票预约系统是个挺有意思的项目,功能不算复杂,但该有的模块一个不少——用户认证、场次展示、选座、预约下单、订单状态流转。我用 Python 做后端(Django 和 Flask 两条路线都走了一遍),前端用 Vue,IDE 用的是 PyCharm。这篇文章把从零搭建到前后端联调的完整过程捋一遍,包含表结构设计、核心接口实现、抢票并发控制思路、以及我在实际开发里踩过的一些坑。适合正在学 Django/Flask 的读者参考,也适合想完整走一遍前后端分离项目的同学抄作业。
1. 项目整体设计与思路拆解
1.1 这个项目到底要解决什么问题
演唱会的售票预约场景,核心痛点就三个:一是信息展示,用户要能快速看到有哪些场次、票价、剩余票量;二是选座下单,用户要能直观选座并提交预约;三是订单管理,用户能查看、取消自己的预约记录,管理员要能控制每场演出的可售座位数。
对应到系统模块上,就是用户端(注册登录、演出列表、演出详情、选座页、订单页)和管理端(场次管理、座位管理、订单查看)。前后端分离是不二之选:后端只用出 JSON 接口,前端 Vue 负责渲染和交互,两边的开发可以并行推进。我这套项目后端用 Django 实现了一套完整的版本,同时把 Flask 的替代写法也整理了出来,同一个需求两种框架都能落地,区别只在代码组织和生态选择。
1.2 为什么技术栈选 Python + Vue,以及 PyCharm 在里面的角色
Python 作为后端语言的优点是生态成熟、上手快,Django 自带 ORM、Admin 后台、认证体系,Flask 则以轻量灵活著称。Vue 选它是看中它的组件化开发模式和渐进式框架定位,从 Vue 2 到 Vue 3 生态都已经很稳,配 Element Plus 或者 Ant Design Vue 做后台管理界面效率非常高。
PyCharm 在整个开发流程里的作用容易被低估。它不只是编辑器,更是集成了虚拟环境管理、数据库工具、调试器和 HTTP Client 的完整工作台。我的开发流程基本是:PyCharm 里建好 Python 虚拟环境,配置好解释器,写好模型后用自带终端执行迁移命令,调试接口时直接用 HTTP Client 发请求验证,甚至 Vue 前端代码也在同一个 IDE 里编辑。一个工具覆盖全链路,省去了来回切换的开销。
提示:不要纠结"Django 和 Flask 到底选哪个"汴京问题。实操经验是——如果你希望快速生成完整可用的后台管理界面、用户认证、模型迁移,选 Django;如果项目以后要做微服务拆分、或者你更倾向自由组装扩展,选 Flask。本文后面两条路都给出关键代码,按需取用。
2. 技术选型:Django 与 Flask 在售票场景下的取舍
2.1 Django 路线:自带体系的完整方案
Django 的项目结构非常清晰,一个项目包含多个应用,每个应用负责一个业务域。售票系统我拆了三个应用:users 负责用户,events 负责演出场次和座位,orders 负责订单。用 Django 写这个系统的优势是很多能力是开箱即用的。
创建项目和应用:
pip install django djangorestframework django-admin startproject ticket_system cd ticket_system python manage.py startapp users python manage.py startapp events python manage.py startapp orders然后在 settings.py 里注册这三个应用和 REST framework:
INSTALLED_APPS = [ # ... 'rest_framework', 'users', 'events', 'orders', ]Django 自带的 User 模型可以直接扩展使用,我通过 OneToOne 字段关联一个 Profile 来存手机号等额外信息。用户注册登录用 JWT 方案,配合 djangorestframework-simplejwt,认证接口基本不用自己写。
后台管理这个地方是 Django 的真正杀伤力。我把 Event 和 Order 注册到 admin.py,管理方直接获得了增删改查界面,对于演唱会门票这种需要快速上线的预约系统来说,等于白送了一套管理后台。对自己开发的小项目来说,这个价值很大——省出来的时间全部可以花在核心业务流程上。
2.2 Flask 路线:灵活组装的轻量方案
Flask 的设计哲学是"不强制",核心只负责路由和请求响应,其他功能通过扩展组装。同一个售票系统用 Flask 实现,我通常会加入 Flask-SQLAlchemy(ORM)、Flask-Migrate(数据库迁移)、Flask-JWT-Extended(令牌认证)、Flask-CORS(跨域处理)。
pip install flask flask-sqlalchemy flask-migrate flask-jwt-extended flask-corsFlask 的应用结构更自由,推荐按功能模块拆 Blueprint:
from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_jwt_extended import JWTManager from flask_cors import CORS app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///ticket.db' app.config['JWT_SECRET_KEY'] = 'your-secret-key' db = SQLAlchemy(app) jwt = JWTManager(app) CORS(app)业务模块用 Blueprint 注册:
from flask import Blueprint events_bp = Blueprint('events', __name__) @events_bp.route('/api/events') def list_events(): # 查询演出列表 pass @events_bp.route('/api/events/<int:event_id>', methods=['GET']) def event_detail(event_id): # 查询单个演出详情 passFlask 对开发者要求更高的自律性——没有自动的 Admin 后台,需要自己写管理页面或者接一个简单的管理模板;没有内置的 ORM,但换来的是更小的项目体积和更高的自由度。初学阶段我更建议先用 Django 走通全流程,然后再把关键接口用 Flask 重写一遍,体会两种框架在"约定"和"自由"之间的差别。
2.3 PyCharm 环境准备与项目导入
PyCharm 安装完成之后第一件事是配置解释器。我习惯在 PyCharm 底部 Terminal 里创建虚拟环境:
python -m venv venv然后 File > Settings > Project > Python Interpreter,选择刚才创建的 venv。之后安装依赖就不会污染全局 Python 环境。注意一个细节:如果本机装了多个 Python 版本,创建虚拟环境之前先执行python --version确认默认版本。如果系统里 Python 3.8 和 3.11 混着用,在后端开发中容易因为新特性兼容问题翻车,建议全部代码统一跑在同一个版本上。
Vue 前端项目的打开方式是 File > Open,选中前端目录,PyCharm 会自动识别 package.json。它的优势在于前后端代码在一个 IDE 窗口里管理,切换上下文没有障碍。
3. 数据库设计与核心模型
3.1 四张核心表的关系拆解
售票系统的数据模型核心是用户、场次、座位、订单四张表。它们的关系不复杂:一个用户有多个订单,一个订单对应一个场次的多张票,座位表中每个座位绑定一个场次。用表结构画出来是这样:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| users | id, username, password_hash, phone, email | 用户账户信息 |
| events | id, title, artist, venue, show_time, price_levels, total_seats, sold_count | 演唱会场次信息 |
| seats | id, event_id, zone, row_number, seat_number, status | 座位状态 |
| orders | id, user_id, event_id, status, created_at, total_price | 预约订单 |
以 Django ORM 为例,模型定义如下:
from django.db import models from django.contrib.auth.models import User class Event(models.Model): title = models.CharField(max_length=200) # 演唱会名称 artist = models.CharField(max_length=100) # 歌手/乐队 venue = models.CharField(max_length=200) # 场馆 show_time = models.DateTimeField() # 演出时间 total_seats = models.IntegerField() # 总座位数 sold_count = models.IntegerField(default=0) # 已售数量 price_detail = models.JSONField(default=dict) # 价格档位 {"看台A": 480, "内场B": 980} class Seat(models.Model): STATUS_CHOICES = [ ('available', '可售'), ('locked', '锁定'), ('sold', '已售'), ] event = models.ForeignKey(Event, related_name='seats', on_delete=models.CASCADE) zone = models.CharField(max_length=50) # 区域 row_number = models.CharField(max_length=10) # 排号 seat_number = models.CharField(max_length=10) # 座位号 status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='available') class Meta: unique_together = ('event', 'zone', 'row_number', 'seat_number')3.2 座位与余票字段设计的关键细节
座位字段的unique_together是个容易踩坑的地方。如果不在数据库层面对座位唯一性做约束,并发情况下同一个座位被下两单是迟早的事。这个约束的价值在于:即使应用层逻辑有 bug,数据库也会拒绝重复数据。
status字段用字符串还是数字也有讲究。我推荐用带语义的字符串做 choices,因为直接看数据库就能懂。千万不要直接用数字 0/1/2 表示,一周后自己维护的时候完全想不起来哪个数字对应哪个状态。
订单表建议加一个expire_time字段,处理"锁座超时释放"这个需求。演唱会门票的预约选座,通常用户选座后不是立即支付,而是预留一段时间。我设置预约超时时间为 15 分钟,超过时间如果未完成支付,座位状态自动从 locked 恢复为 available。这个定时任务不必用 Celery 那么重的方案,Django 里写一个管理命令(management command)定时执行即可;Flask 里则用 flask-apscheduler 或者直接依赖请求时懒检查过期订单。
数据库选型上,开发环境用 SQLite 完全够用,一行配置搞定,PyCharm 的 Database 工具可以直接打开 SQLite 文件查看表数据。上线部署建议迁到 PostgreSQL 或 MySQL,代码层面只需要改连接字符串。
4. 后端 API 核心实现与抢票逻辑
4.1 接口清单与序列化处理
按照前端页面倒推,后端需要提供这些接口:
| 方法 | 路径 | 功能 | 权限 |
|---|---|---|---|
| POST | /api/auth/register | 用户注册 | 公开 |
| POST | /api/auth/login | 用户登录(返回 JWT) | 公开 |
| GET | /api/events | 演出列表(含余票状态) | 公开 |
| GET | /api/events/ | 演出详情(含座位图) | 公开 |
| GET | /api/events/ /seats | 某场次的座位状态 | 公开 |
| POST | /api/orders | 创建预约订单(锁定座位) | 登录 |
| GET | /api/orders/my | 当前用户的订单列表 | 登录 |
| POST | /api/orders/ /cancel | 取消预约、释放座位 | 登录 |
| GET | /api/orders/ | 订单详情 | 登录 |
Django REST Framework 里,这些接口用 ViewSet 组织非常顺手。以 events 为例:
from rest_framework import viewsets, serializers class EventSerializer(serializers.ModelSerializer): class Meta: model = Event fields = ['id', 'title', 'artist', 'venue', 'show_time', 'total_seats', 'sold_count', 'price_detail'] def get_remaining(self, obj): return obj.total_seats - obj.sold_count class EventViewSet(viewsets.ReadOnlyModelViewSet): queryset = Event.objects.all() serializer_class = EventSerializer只读接口用ReadOnlyModelViewSet就够了,创建订单这种写操作单独放到 orders 应用里用 APIView 实现。这样职责清晰,代码也好维护。
4.2 抢票下单的并发控制(核心难点)
售票系统开发中真正让人头疼的是并发问题。同一场演出开票,前 10 秒可能进来几千个请求,其中一大半指向同一个热门内场区域。如果代码写成"先查剩余票数,够了就减少库存,再创建订单"这种朴素逻辑,并发条件下超卖是必然的。
我的处理分三层:
第一层是数据库事务。Django 里使用select_for_update()对 Seat 行加锁,确保同一时刻只有一个事务能修改某个座位:
from django.db import transaction from rest_framework.views import APIView class CreateOrderView(APIView): def post(self, request): event_id = request.data.get('event_id') seat_ids = request.data.get('seat_ids') with transaction.atomic(): seats = Seat.objects.select_for_update().filter( id__in=seat_ids, event_id=event_id ) for seat in seats: if seat.status != 'available': return Response({'error': f'座位 {seat.id} 已被锁定'}, status=400) # 批量更新座位状态 Seat.objects.filter(id__in=seat_ids).update(status='locked') # 更新已售数量 Event.objects.filter(id=event_id).update( sold_count=models.F('sold_count') + len(seat_ids) ) order = Order.objects.create( user=request.user, event_id=event_id, status='pending' ) order.seats.set(seat_ids) return Response(OrderSerializer(order).data)select_for_update()在 SQLite 下效果有限,因为 SQLite 的并发写能力很弱。代码层面仍然要写,因为切换到 MySQL/PostgreSQL 时这套逻辑是通用的。如果项目上线后真实流量很高,建议加一层 Redis 预扣库存:先把热门座位 ID 写入 Redis Set,用户点击下单时先在 Redis 里判断座位是否被抢走,抢到才真正落到数据库。Redis 的原子操作比数据库锁抗压能力高一个数量级。
第二层是状态机的严格管理。座位从 available 到 locked 到 sold,不允许跳变。取消预约时只有 locked 的座位才能回滚到 available。这层逻辑通过模型方法统一封装:
class Seat(models.Model): def lock(self): if self.status != 'available': raise ValueError(f'座位不可锁定: {self.status}') self.status = 'locked' self.save(update_fields=['status']) def release(self): if self.status != 'locked': raise ValueError(f'座位不可释放: {self.status}') self.status = 'available' self.save(update_fields=['status']) def mark_sold(self): if self.status != 'locked': raise ValueError(f'座位不可售出: {self.status}') self.status = 'sold' self.save(update_fields=['status'])第三层是接口重试与幂等。用户点击"提交预约"时网络抖动,前端容易重复提交,订单就会重复创建。解决办法是前端在按钮点击后立即置灰禁用,同时后端接收一个幂等键(比如前端生成的随机 UUID),用该键做唯一约束,重复请求直接返回已存在的订单数据。
Flask 里实现同样的接口,核心事务逻辑用 SQLAlchemy 的 session 控制:
from flask_jwt_extended import jwt_required, get_jwt_identity @orders_bp.route('/api/orders', methods=['POST']) @jwt_required() def create_order(): user_id = get_jwt_identity() data = request.get_json() event_id = data['event_id'] seat_ids = data['seat_ids'] try: seats = db.session.execute( db.select(Seat).where(Seat.id.in_(seat_ids), Seat.event_id == event_id) .with_for_update() ).scalars().all() for seat in seats: if seat.status != 'available': return {'error': f'座位 {seat.id} 已被锁定'}, 400 for seat in seats: seat.status = 'locked' order = Order(user_id=user_id, event_id=event_id, status='pending') db.session.add(order) db.session.flush() order.seats.extend(seats) db.session.commit() return {'order_id': order.id}, 201 except Exception: db.session.rollback() return {'error': '创建订单失败'}, 5004.3 数据库查询中的性能注意事项
Django 初学者经常踩的一个坑是列表页 N+1 查询。演出列表页除了查 Event 还要查关联的 Seat 数量,如果不加优化,查 20 场演出就要多打 20 条 SQL。用select_related或prefetch_related解决:
queryset = Event.objects.all().select_related('venue').prefetch_related('seats')数据库删对象这一点也值得一提。Django 里Model.objects.filter(...).delete()和Model.objects.all().delete()都是批量删除,删除前确认关联的外键行为——models.CASCADE会连带删掉子表数据。我遇到过不止一次,在管理后台测试删除演出现场时把整个场次的座位记录全部删光,恢复都不好恢复。所以在开发前期,凡是涉及关联删除的操作,建议先开一个 Django shell 做一次完整的事务演练,确认级联行为符合预期再放进接口。
5. Vue 前端开发与接口联调
5.1 项目搭建、路由与组件设计
前端用 Vue 3 + Vite + Vue Router + Axios + Element Plus 这套组合,npm 命令如下:
npm create vite@latest frontend -- --template vue cd frontend npm install npm install vue-router axios element-plusVue Router 路由配置对应的是页面结构。页面划分直接影响开发进度,我这样规划:
// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', component: () => import('../views/EventList.vue') }, { path: '/event/:id', component: () => import('../views/EventDetail.vue') }, { path: '/event/:id/seat', component: () => import('../views/SeatSelect.vue'), meta: { requiresAuth: true } }, { path: '/orders', component: () => import('../views/OrderList.vue'), meta: { requiresAuth: true } }, { path: '/login', component: () => import('../views/Login.vue') }, { path: '/register', component: () => import('../views/Register.vue') }, ] const router = createRouter({ history: createWebHistory(), routes, }) // 全局前置守卫:检查登录状态 router.beforeEach((to, from, next) => { if (to.meta.requiresAuth && !localStorage.getItem('token')) { next('/login') } else { next() } }) export default router路由守卫是前后端分离项目里身份控制的关键一环。后端接口必须加权限校验,前端路由守卫只是用户体验层面的拦截——没有 token 的用户直接跳转到登录页,而不是等接口 401 了再被动处理。路由懒加载用 dynamic import,初始包小,首屏加载快,这个优化建议保留。
组件方面,选座页是核心。我用一个二维数组描述座位图,每个座位渲染成一个可点击的 div,区域基本信息由后端返回。座位状态映射:
const seatStatusMap = { available: { label: '可售', class: 'seat-available', disabled: false }, locked: { label: '已锁定', class: 'seat-locked', disabled: true }, sold: { label: '已售', class: 'seat-sold', disabled: true }, }选座时的交互逻辑是:点击可选座位加入待选列表(高亮显示),再次点击取消;确认后调用 /api/orders 接口创建订单。这里有一个体验细节——选座和提交是两个步骤,选好座后不要直接创建订单,而要先展示确认弹窗,让用户看到票价合计后再提交,减少误操作导致的锁座。
5.2 Axios 请求封装与 token 处理
Axios 请求封装是个必做工作。统一配置 baseURL、超时时间、请求拦截器自动带 token、响应拦截器统一处理错误码:
// src/utils/request.js import axios from 'axios' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 15000, }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } ) export default request统一 token 处理省去了在每个接口重复写 Authorization 头的麻烦。响应拦截器里对 401 的全局处理也很关键——token 过期后任何接口都会自动跳登录页。
所谓"vue 播放 m3u8"之类的东西在这个项目里用不上。如果后续要加演唱会直播或者宣传片功能,可以考虑 hls.js 处理 m3u8 视频流,但核心预约流程不需要。嵌入式 PDF 也不在功能范围里。做项目要聚焦,别被边缘需求带偏。
5.3 本地联调:跨域问题一次性解决
前后端分离开发最典型的问题是跨域。前端跑在 5173 端口,后端 Django 跑在 8000,Flask 跑在 5000,直接发请求会被浏览器拦截。一个干净利落的方案是用 Vite 的 proxy 代理,配置后在开发环境中前端请求/api前缀的接口都会转发到后端地址,浏览器视角里是同源的,不触发 CORS:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://127.0.0.1:8000', // Django 后端;Flask 则改 5000 changeOrigin: true, }, }, }, })使用 Vite proxy 等于在开发层把跨域问题屏蔽掉了,后端的 CORS 中间件只在部署到生产环境、前后端完全分离域名时真正需要。有些同学开发时改了 Django 的 ALLOWED_HOSTS 和 CORS_WHITELIST,其实没必要,Vite proxy 方案最直接。
注意:代理配置生效的前提是前端请求路径确实以 /api 开头。如果后端路由不是这个前缀,代理规则就没法匹配,这是个很隐蔽的问题。统一约定 /api 前缀写在团队规范里。
5.4 动态路由与页面状态管理
热词里提到"vue 动态路由""vue 插槽",这两个点在实际项目中都有用到。动态路由的一个典型场景是管理员的后台菜单——不同角色拥有不同权限,前端根据角色动态注册路由。售票系统的用户端和管理端权限差异大,管理端可以单独拆一个路由模块,登录后根据用户角色动态添加。
Vue 插槽主要用于组件复用。比如订单列表中的状态标签,不同状态显示不同颜色和文案,如果用插槽封装成 StatusTag 组件,业务逻辑改动时只需要维护一处。在一个多接口的前端项目中,组件复用是降低维护成本最重要的手段,这点建议从一开始就坚持,而不是写完了才回头重构。
6. 实操过程:从零搭建到前后端联调
6.1 环境准备与项目初始化清单
完整的实操顺序整理成一份清单,照着走不会出错:
- 安装 Python 3.10/3.11,在官网下载安装包,勾选 Add to PATH。验证命令
python --version。 - 安装 PyCharm 社区版(免费)或专业版,打开 PyCharm 后配置好 Python 解释器。
- 创建后端项目目录,在 PyCharm 终端执行虚拟环境初始化并安装 Django/Flask 依赖。
- 初始化前端项目,安装 Node.js(Vite 要求 Node 16+),
npm create vite初始化 Vue 项目。 - 后端写模型、迁移数据库、创建超级管理员。
- 后端写接口,先用 PyCharm HTTP Client 或浏览器验证接口可用。
- 前端写页面、配置代理。
- 联调,先在本地用两套服务跑通全流程。
以 Django 后端为例,数据库迁移的命令要在 PyCharm 终端执行:
python manage.py makemigrations python manage.py migrate python manage.py createsuperuser这三步完成,数据库表就建好了,admin 后台也能登录。
6.2 一次典型的全流程跑通记录
我开发时按这个顺序走:先写好 users 应用的注册登录接口,用 HTTP Client 发 POST 请求拿 token;然后写 events 应用的两个查询接口,确认返回 JSON 数据;再写 orders 应用的下单和取消接口。接口层面全部用工具验证过,才开始写 Vue 页面。
写前端的时候,一个页面接一个接口,先用 Axios 直连后端验证,成功后再搬到组件里。比如 EventList.vue 页面这样拉取数据:
<script setup> import { onMounted, ref } from 'vue' import request from '../utils/request' const events = ref([]) const loading = ref(false) onMounted(async () => { loading.value = true try { events.value = await request.get('/events') } finally { loading.value = false } }) </script> <template> <div> <el-card v-for="evt in events" :key="evt.id" class="event-card"> <h3>{{ evt.title }}</h3> <p>{{ evt.artist }} | {{ evt.venue }} | {{ evt.show_time }}</p> <p>剩余票量:{{ evt.total_seats - evt.sold_count }}</p> <el-button @click="$router.push(`/event/${evt.id}`)">查看详情</el-button> </el-card> </div> </template>每次接口从后端拿到真实数据时,及时在 PyCharm 的调试工具里确认入参出参。前后端联调阶段 90% 的问题都可以通过"看请求到底发到哪了、返回了什么"来解决。打开浏览器开发者工具的 Network 面板,如果请求标红,看状态码和响应体——404 多半是路径写错了,500 就去翻后端日志,CORS 错误检查代理配置,401/403 检查 token。速度最快的排查方式一定是从网络层开始,不要先把代码翻一遍。
6.3 PyCharm 在日常开发中的高效用法
这套流程里 PyCharm 给我的帮助集中在三处:
第一是数据库工具。右侧 Database 面板连上 SQLite 或 MySQL 后,能直接查看表数据、执行 SQL,省掉了单独打开数据库客户端的功夫。改完模型后顺手查一下表结构是否符合预期,效率很高。
第二是调试器。Django 接口返回错误时,在视图函数里打断点,用调试模式启服务,就能看到每个变量具体值。尤其是下单接口的事务逻辑,断点调试可以完整看到 select_for_update 的上锁效果。
第三是 HTTP Client。在 .http 文件里写好接口测试用例,比如"创建订单输入非法座位 ID""取消不存在的订单"等等,之后每次改完代码点一下就能回归测试,比 Postman 更贴近代码。热词里有"pycharm 安装 ai 插件""pycharm 插件推荐",实际上 AI 插件辅助写正则表达式、生成 SQL 这类琐碎工作是可行的,但核心业务逻辑仍然建议自己逐个字段检查,毕竟售票系统涉及真金白银和用户体验。
7. 常见问题与排查技巧实录
7.1 环境与启动问题速查
| 问题 | 现象 | 解决办法 |
|---|---|---|
| python 命令无法识别 | 终端提示 not found | 安装时勾选 Add to PATH,或者重新安装 |
| pip 下载慢或失败 | 安装依赖超时 | 换国内镜像源:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple 包名 |
| 数据库迁移时找不到表 | django.db.utils.OperationalError | 先执行 makemigrations 再 migrate,不要跳步;确认 settings.py 里 DATABASES 配置正确 |
| Django 启动报端口占用 | Error: That port is already in use | lsof -i:8000找到占用进程并 kill,或换端口 |
| Vue 启动失败 | npm install 报 peer deps 冲突 | 按提示加--legacy-peer-deps重装,或统一依赖版本 |
| 前端请求 404 | 打开 Network 面板发现请求 URL 少前缀 | 在 axios 请求地址前统一加 /api,确认路由定义 |
7.2 接口联调阶段的隐蔽问题
让我挑三个实际调试时最头疼的问题详细展开。
第一个是后端日期格式的时区问题。Django 默认 USE_TZ=True,拿到的时间是 UTC 的 ISO 字符串,前端显示出来比北京时间慢了 8 小时。解决办法是 settings.py 里确认TIME_ZONE = 'Asia/Shanghai',并注意 Django 在 USE_TZ=True 时存储到数据库仍然是 UTC。专门做序列化时,可以用:
import pytz from rest_framework import serializers class EventSerializer(serializers.ModelSerializer): show_time = serializers.DateTimeField(format='%Y-%m-%d %H:%M', default_timezone=pytz.timezone('Asia/Shanghai'))这个坑在小项目里不明显,一旦上线部署到服务器,时区问题立刻暴露。建议一开始就明确所有时间字段都按用户当地时区前端转换,前端可以写一个全局过滤器统一格式化。
第二个是 SQLite 在并发写场景下的锁问题。我本地模拟高并发时发现,多个线程同时写 SQLite 会报 database is locked。因为 SQLite 同一时刻只允许一个写事务。开发阶段能用,但高并发验证做不了,比较稳妥的做法是尽早把数据库切到 MySQL 或 PostgreSQL。Django 切换只需改 settings 配置,数据迁移用 fixture 导出再导入。Flask 切换 SQLAlchemy 连接串也简单。不要等到上线前才发现 SQLite 扛不住。
第三个是 Vue 打包后路由刷新 404。前端npm run build之后部署到 Nginx,刷新子页面路径时出现 404,这是 SPA 的典型问题——静态服务器不知道前端路由。因为 dev 环境 Vite 已经处理了 history fallback,生产环境需要 Nginx 配置try_files:
location / { try_files $uri $uri/ /index.html; }这一条配置能解决所有刷新 404 的问题。如果后端也部署在同一域名下,还需要把 /api 路径反向代理到后端服务。
7.3 并发抢票数据一致性经验
回到最核心的抢票场景。实测体会是:写代码前先把并发模型想清楚,比事后加锁重要得多。真实项目里我建议按优先级这样设计:
- Redis 预扣库存,承载瞬时流量。可售座位数存 Redis,用户进入选座页时从 Redis 读取实时余票,提交订单时通过 Lua 脚本原子性地将座位从 available 集合移动到 locked 集合。
- 数据库行锁保证最终一致性。Redis 被击穿或者数据不一致时,数据库事务中的
select_for_update()是兜底防线。 - 定时任务释放过期锁座。兜住"用户锁座后长时间不支付"的极端情况,保证座位可以回归库存池。
这三层如果不要求系统能顶住十万级并发,其实可以简化为一条经验:数据库事务加行锁已经足够应付一个演唱会项目的正常流量(几千到几万用户同时在线)。真正的难点反而在于把状态机逻辑写对、把锁的粒度控制好,不要锁整张表,只锁需要修改的那几个座位行。
7.4 几个值得保留的实战技巧
最后分享几个我做完这个项目后最想保留的小技巧:
后端返回错误信息时,统一封装成{"code": 40000, "message": "座位已被抢走"}这样的结构。不要把异常堆栈直接返回给前端,既暴露服务器信息又不利于前端展示。统一错误码的好处是前端响应拦截器可以根据 code 做全局处理,而不是反复判断 HTTP 状态码。
座位图的渲染成本在座位数量多的时候是个隐形问题。一个大型体育馆可能有几千个座位,一次性渲染全部 DOM 节点会导致页面卡顿。正确做法是只渲染可视区域内的座位(虚拟滚动),或者在选座页先渲染当前选中区域的座位,切换区域再加载。开发时我用 Element Plus 的分页标签做区域切换,每个区域 200 个座位左右,渲染压力完全可接受。
运营后台里售罄的场次应该自动隐藏或标注。这个逻辑可以放在后端查询时处理:sold_count >= total_seats的场次 filter 掉。前端也做第二层保险,显示"已售罄"标签,点击按钮置灰。双重保障避免用户尝试下单却发现没座位。
数据库备份这件事要养成习惯。开发过程中改模型、跑迁移、乱删数据是常态,我因为这个项目交过学费——一次误操作把测试环境订单全删了,幸好提前导出了 fixture。PyCharm 里配置一个定期数据库导出任务非常简单,花两分钟配好,能省掉很多后悔药。
8. 部署与后续扩展方向
8.1 前后端分离部署方案
部署这个项目,我推荐用一台轻量云服务器(2核4G 起步),装好 Nginx 和 Python 环境。前端打包后将 dist 目录丢到 Nginx 静态目录,后端用 Gunicorn 跑 Django 或 Flask 服务,Nginx 反向代理 /api 到对应的端口。
Django 静态文件和 Media 文件的处理在部署时容易让人困惑。如果 Django 的 admin 后台也要用,需要执行python manage.py collectstatic,并且让 Nginx 直接服务这些 static 文件,否则 admin 样式会全部丢失。Flask 的静态资源处理相对简单,因为 Flask 项目往往只提供 API,前端页面完全由 Vue 负责。
8.2 项目后续可以怎么扩展
这套售票预约系统的骨架很清晰,扩展方向也明确:管理后台目前只是 DTRF 的 ReadOnly 接口,可以加一个 Vue 的管理端页面,支持场次创建、座位批量生成、订单明细查看;支付环节当前是模拟支付(用户确认后直接标记已支付),后续可以接入微信支付或支付宝的预下单流程;如果要做真实抢票,建议引入 Redis 和消息队列,把"库存扣减"和"订单写入"解耦。
就到这里,说说我做完整个项目的最大感受:前后端分离项目的复杂度不在于单点技术多深,而在于把数据模型、接口契约、前端页面、并发边界理顺。如果这篇文章能帮你在自己的售票预约系统开发中少踩几个坑,那这份记录就没白写。