作为一个前后端都写过不少项目的老开发者我越来越觉得Django Vue3这套组合在中小型业务系统里简直是无敌的存在。特别是最近完成了一个电影院售票管理系统从排片管理到在线选座、订单支付整个闭环做下来踩了不少坑也沉淀了一些真正好用的实践经验。这套系统后端用 Django 提供稳定的业务逻辑和数据处理前端用 Vue3 负责高交互的选座界面和流畅的用户体验两者配合得相当默契。这篇文章我就把自己从零搭建这套系统的完整思路、核心代码、参数计算过程以及那些只有动手做才会踩到的坑一并整理出来不管你是刚学 Django 的新手还是想找个完整项目练手的开发者这篇文章都能给你一个可以直接参考、复现的方案。1. 项目整体设计与技术选型1.1 为什么是 Django Vue3 这套组合做影院售票系统最核心的业务特征是实时性和强一致性。用户选座时座位必须被临时锁定否则两个用户同时选中同一个座位谁先付款就是一笔糊涂账。同时排片信息、场次状态、订单状态都需要实时刷新。这种业务场景对后端的事务处理和前端的高频交互都提出了要求。Django 在这套系统里承担的是重后端的角色。它的 ORM 映射着整个影院的业务表结构从影片库、影院影厅到场次排片、座位布局再到订单和支付记录Django 自带的 Admin 后台在项目初期做数据管理非常高效我甚至可以在一分钟之内生成全套的管理页面直接录入影厅数据和排片信息。Django REST FrameworkDRF则负责把数据库里的数据序列化成 JSON通过标准的 RESTful API 提供给前端。Vue3 在前端负责高交互体验。电影选座是个强交互场景用户需要看到整个影厅的座位布局、拖动屏幕缩放、点选座位并即时看到座位变成锁定状态。Vue3 的响应式系统在处理这类状态频繁变化界面上表现很好配合组件化开发我可以把影厅的每一排座位、每个座位状态都封装成独立的组件。配合 Vite 的开发热更新调样式和调交互逻辑的速度比传统模板渲染快了不止一个量级。另外一个现实的原因是可维护性。前后端通过 API 解耦后后端只需要关心数据和业务规则前端只需要关心界面和用户操作。后期如果要出移动端适配或者小程序版本前端代码可以整体复用后端 API 也完全不用动这套系统的技术债会小得多。1.2 核心功能模块拆解一个电影院售票管理系统表面上看只是“选座-下单-付款”但实际拆解下来涉及的模块相当多我把它分为用户端和管理端两大部分。用户端的核心链路是浏览正在热映的影片 - 查看某部影片的排片场次 - 进入影厅选择座位 - 提交订单并支付 - 获取电子票二维码。这套链路里最考验技术的是“选座”环节因为座位状态是动态的必须依赖后端接口实时查询和锁定。管理端的核心链路是影片库管理新增影片、设置上映状态- 影厅管理设置影厅名称、座位布局- 排片管理为影片分配影厅、时间段、票价- 订单管理查看售票情况、处理退款。这些模块在 Django Admin 里可以直接实现 80% 的需求但为了给 Vue3 前端提供管理接口还是需要把核心的增删改查都封装成 API。除了这些主体模块基础数据模型的设计是整个系统的地基。比如影厅的座位布局是固定一个二维数组还是动态生成场次的座位状态是存在独立的表里还是冗余在场次表里这些决策会直接影响后面所有业务代码的复杂度。我采用的方案是单独建立座位与场次的关联表这样数据一致性最好也方便做锁座和释放。1.3 技术栈全览与版本选型在真正动手之前我先把这套系统用到的核心依赖和版本列出来。这里我特别提醒一下Django 和 Vue 的主版本之间没有必然联系但由于 Django 4.x 以上对 Python 版本有要求建议 Python 使用 3.10 以上保证后续依赖的兼容性。技术栈版本用途说明Python3.10运行 Django 的脚本语言Django4.2 LTS后端 Web 框架LTS 版本维护周期长Django REST Framework3.14构建 RESTful API 接口MySQL / PostgreSQL8.0 / 14数据库生产环境建议 PostgreSQLVue3.4前端框架采用组合式 APIVite5.x前端构建与开发服务器Pinia2.xVue3 官方推荐的状态管理库Vue Router4.x前端路由管理Axios1.x前后端 HTTP 通信库Simple JWT5.xDjango 中的 JWT 认证插件选 Vue3 的组合式 API 而不是选项式 API主要看中的是逻辑复用能力。比如选座界面里座位状态的加载、座位点击事件、座位状态的实时轮询这些逻辑如果散落在各个组件的 data 和 methods 里跨组件共享会很麻烦。组合式 API 配合 composables自定义组合函数我可以把选座的整个业务逻辑封装成一个独立的模块在任何组件里引入即可复用这在开发效率和代码可维护性上都很关键。2. 数据库模型设计影院业务的基石2.1 五个核心模型的设计思路设计电影院售票系统的数据库最关键的是要理清业务实体之间的关系。我最终设计了五个核心模型它们相互关联又各司其职覆盖了影院运营的整个流程。第一个是Film电影模型记录电影的基本信息包括片名、海报、剧情简介、上映日期、片长、导演、演员列表等。这个模型的字段基本来自猫眼、淘票票这类平台展示的信息不需要太多业务逻辑但要注意片长字段建议以分钟为单位存储整数。第二个是Hall影厅模型记录影厅的名称和座位布局。座位布局是个有意思的设计点我采用 JSONField 存储座位矩阵比如[{row: A, cols: [1,2,3,4,5]}, ...]这样前端渲染座位表时直接读这个字段即可。当然也可以把座位单独建一张表但考虑到影厅座位布局相对固定JSONField 的复杂度更低。第三个是Session场次模型它是连接电影和影厅的桥梁。每个场次包含所属电影、所属影厅、播放时间、语言版本、票价等字段。这里需要注意每张电影票的价格可能会因场次不同而变化比如黄金时段和早间场票价不一样所以要设计 price 字段。第四个是Seat座位模型这张表存储某个场次的座位状态。主要是 session外键、row排号、col列表、status可选状态可售/锁定/已售。为什么要单独建一张表因为同一个影厅的不同场次座位状态是独立的。比如同一个影厅 18:00 的场次 A1 座位被选了20:30 的场次 A1 座位还是可售的。第五个是Order订单模型记录用户的购票行为包括订单号、用户ID、场次ID、座位ID列表、总金额、支付状态、下单时间等。一个订单关联多个座位是典型的一对多关系所以我在订单表里用 JSONField 存座位 ID 列表方便查看和统计。2.2 模型的完整代码实现模型代码是整个后端的基础我直接给出 models.py 里的核心实现。Django 的 ORM 层已经足够强大把业务逻辑放在模型层可以避免视图函数过于臃肿。from django.db import models from django.contrib.auth.models import User class Film(models.Model): name models.CharField(max_length128, verbose_name电影名称) poster models.URLField(verbose_name海报链接) description models.TextField(verbose_name剧情简介, blankTrue) duration models.IntegerField(help_text时长分钟, verbose_name片长) release_date models.DateField(verbose_name上映日期) director models.CharField(max_length64, verbose_name导演) is_hot models.BooleanField(defaultTrue, verbose_name是否热映) class Meta: verbose_name 电影 verbose_name_plural verbose_name def __str__(self): return self.name class Hall(models.Model): name models.CharField(max_length64, verbose_name影厅名称) rows models.IntegerField(verbose_name排数) cols models.IntegerField(verbose_name每排列数) layout models.JSONField(defaultlist, verbose_name座位布局) class Meta: verbose_name 影厅 verbose_name_plural verbose_name def __str__(self): return self.name class Session(models.Model): film models.ForeignKey(Film, on_deletemodels.CASCADE, related_namesessions) hall models.ForeignKey(Hall, on_deletemodels.CASCADE, related_namesessions) start_time models.DateTimeField(verbose_name开场时间) end_time models.DateTimeField(verbose_name散场时间, blankTrue, nullTrue) price models.DecimalField(max_digits6, decimal_places2, verbose_name票价) language models.CharField(max_length32, default国语2D, verbose_name语言版本) class Meta: verbose_name 场次 verbose_name_plural verbose_name ordering [start_time] def __str__(self): return f{self.film.name} {self.start_time.strftime(%Y-%m-%d %H:%M)} class Seat(models.Model): STATUS_CHOICES ( (available, 可售), (locked, 已锁定), (sold, 已售), ) session models.ForeignKey(Session, on_deletemodels.CASCADE, related_nameseats) row models.CharField(max_length8, verbose_name排号) col models.IntegerField(verbose_name列号) status models.CharField(max_length16, choicesSTATUS_CHOICES, defaultavailable) class Meta: verbose_name 座位 verbose_name_plural verbose_name unique_together (session, row, col) def __str__(self): return f{self.session_id}-{self.row}{self.col}这里我建议用unique_together约束同一个场次下不能出现两个相同排列表的座位记录保证数据唯一性。同时related_name的声明很重要它决定了反向查询的语义比如session.seats.all()能拿到该场次的所有座位后面写业务代码会比默认的反向查询名字更可读。2.3 座位状态的业务逻辑锁定与释放座位状态是整个系统最复杂的部分我单独拆出来讲。用户点击某排某个座位时前端会发请求把该座位锁定时长设为 15 分钟。如果用户 15 分钟内没支付座位要自动释放如果支付成功座位变成已售状态。实际项目中抖音、淘票票这类专业平台的锁座也有时长限制防止黄牛占座。实现锁座逻辑最简单的方案是在 Seat 模型增加locked_at字段记录锁定时间视图里判断锁定时间是否超过 15 分钟超过就把状态改回 available。写接口时直接对座位的状态做原子更新。# 锁座操作 seat.status locked seat.locked_at timezone.now() seat.save(update_fields[status, locked_at])由于 Django 的 ORM 在并发写的情况下可能出现脏数据生产环境我推荐使用select_for_update()加行级锁确保事务内对 Seat 行的操作是串行的。简单场景下普通 save 也可以但一旦用户量上来还是需要数据库事务来兜底。3. Django 后端核心接口实现3.1 项目初始化和 App 划分创建一个 Django 影院项目前几步和所有 Django 项目一样项目结构和 App 划分我会根据业务逻辑来组织。建议分两个 Appmovies负责电影、影厅、场次的管理orders负责订单、座位状态、支付的业务。# 创建项目和应用 django-admin startproject cinema_backend python manage.py startapp movies python manage.py startapp orders # 在 settings.py 注册 App INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, rest_framework, rest_framework_simplejwt, # JWT 认证 corsheaders, # 跨域处理 movies, orders, ]CORS 中间件在前后端分离项目里几乎必装的因为开发时 Vue 的 Vite 服务器跑在 5173 端口Django 跑在 8000 端口两者域名不同不处理跨域的话前端请求会被浏览器的同源策略拦下来。配置如下INSTALLED_APPS 里加了 corsheaders 后还需要在 MIDDLEWARE 里加 MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, ... ] # 开发环境下直接全部允许 CORS_ALLOW_ALL_ORIGINS True # 生产环境建议限定具体域名 # CORS_ALLOWED_ORIGINS [ # https://admin.example.com, # ]3.2 基于 DRF 的 API 视图设计我使用 DRF 的ModelViewSet配合Router来快速搭建 RESTful API。对 movies 模块而言电影列表、影片详情、场次列表这几个接口是最核心的。因为前端需要同时拿到电影和对应的场次这里我使用了action自定义动作。from rest_framework.viewsets import ModelViewSet from rest_framework.decorators import action from rest_framework.response import Response from .models import Film, Hall, Session from .serializers import FilmSerializer, SessionSerializer, HallSerializer class FilmViewSet(ModelViewSet): queryset Film.objects.filter(is_hotTrue) serializer_class FilmSerializer action(detailTrue, methods[get]) def sessions(self, request, pkNone): film self.get_object() sessions Session.objects.filter(filmfilm, start_time__gtetimezone.now()) serializer SessionSerializer(sessions, manyTrue) return Response(serializer.data)这个action装饰器可以让我为特定的电影获取未来的所有场次搭配start_time__gte过滤掉已经开演的场次避免用户选了过期的场次无法下单。Serializer 的定义同样直接高效class SessionSerializer(serializers.ModelSerializer): hall_name serializers.CharField(sourcehall.name, read_onlyTrue) film_name serializers.CharField(sourcefilm.name, read_onlyTrue) class Meta: model Session fields [id, film_name, hall_name, start_time, end_time, price, language]我经常使用source参数把外键关联的字段直接展开成前端最容易消费的扁平结构这样前端不需要再通过查询详情接口去组装数据减少了一次网络请求。3.3 选座接口与订单创建流程电影院的订单创建不像普通商品直接下单必须先锁座、再创建订单。后端 API 需要设计成两个接口锁座接口和创建订单接口。锁座接口接收场次 ID 和座位 ID 列表校验座位确实可售之后把这些座位状态置为 locked同时记录锁定的时间戳和操作人返回一个锁座 token 或者锁座编号前端在提交订单时带上这个编号。这里有一个细节锁座后如果用户长时间不操作后端需要主动释放座位——我在系统中使用了 Django 的后台定时任务Celery beat或简单的时间轮询扫描locked_at超过 15 分钟的座位置为 available。action(detailFalse, methods[post]) def lock_seats(self, request): session_id request.data.get(session_id) seat_ids request.data.get(seat_ids) with transaction.atomic(): seats Seat.objects.select_for_update().filter( session_idsession_id, id__inseat_ids, statusavailable ) if len(seats) ! len(seat_ids): return Response({error: 存在已被选中的座位}, status400) lock_token uuid.uuid4().hex for seat in seats: seat.status locked seat.lock_token lock_token seat.locked_at timezone.now() seat.save(update_fields[status, lock_token, locked_at]) return Response({lock_token: lock_token})这里select_for_update()是真正的关键它会在事务里对查出来的 Seat 行加上数据库行锁防止并发请求同时把同一个座位锁住。不加这行两个用户同时提交同一个座位 ID可能都会通过查询校验最后都把座位改了超卖问题就出现了。只有拿到锁的请求才能继续改状态另一个请求要么等锁释放要么直接查不到 available 的座位。订单创建接口拿到锁座 token 后验证 token 是否有效并计算总价格创建订单同时把座位状态从 locked 改为 sold。这个流程涉及订单和座位两张表的状态变更必须放在同一个数据库事务里保证要么都成功要么都失败。action(detailFalse, methods[post]) def create_order(self, request): session_id request.data[session_id] lock_token request.data[lock_token] user request.user with transaction.atomic(): seats Seat.objects.select_for_update().filter( session_idsession_id, lock_tokenlock_token ) if not seats.exists(): return Response({error: 锁座信息无效或已过期}, status400) session Session.objects.get(idsession_id) total_price sum(session.price for _ in seats) order Order.objects.create( useruser, sessionsession, seat_ids[s.id for s in seats], total_pricetotal_price, statuspending, order_nofORD-{timezone.now().strftime(%Y%m%d%H%M%S)}-{uuid.uuid4().hex[:6]} ) seats.update(statussold) return Response({order_id: order.id, order_no: order.order_no, total_price: total_price})注意订单号的生成不能用简单的时间戳我用了 UUID 的后几位做幂等性保障万一同一秒内有两个用户下单也不会重复。整个影院系统的核心逻辑在这个环节已经完整闭环了。3.4 JWT 认证与用户权限控制影院系统涉及下单、查订单等用户行为必须做身份认证。我使用djangorestframework-simplejwt这个插件它比 Django 自带的 Session 认证更适合前后端分离。用户登录成功后拿到 access_token 和 refresh_token前端把 access_token 存起来每次请求在 Header 里带上Authorization: Bearer token。在 Django settings 中配置默认认证方式和权限REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework_simplejwt.authentication.JWTAuthentication, ], DEFAULT_PERMISSION_CLASSES: [ rest_framework.permissions.IsAuthenticatedOrReadOnly, ], DEFAULT_PAGINATION_CLASS: rest_framework.pagination.PageNumberPagination, PAGE_SIZE: 10, }简单场景下获取用户信息直接写一个接口返回request.user的基本信息即可。没有认证的用户只能查看列表和详情但无法提交订单。在 Vue 前端我需要判断用户是否已登录来决定显示“购票”按钮还是“请先登录”这就涉及到前端的状态管理在后面的章节中展开。4. Vue3 前端工程构建与核心功能实现4.1 Vite 构建项目与工程配置Vue3 的项目初始化我推荐使用 Vite它比 Webpack 慢得不是一点点热更新响应速度几乎是一瞬间。我平时创建项目的命令都是npm create vitelatest cinema_frontend -- --template vue cd cinema_frontend npm install npm install vue-router4 pinia axios这里用的模板是纯 Vue 版本如果需要 TypeScript可以用--template vue-ts。我个人比较倾向在中小型项目里用 JavaScript 减少类型定义的时间但如果团队超过三个人还是上 TypeScript 更稳妥。在 vite.config.js 里配置开发代理把/api路径的请求转发到 Django 服务器这样开发时不需要在 Axios 里写全路径也避免了 CORS 的烦恼import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } })4.2 核心页面和路由设计影院售票系统的前端页面主要包括电影列表页、电影详情页、选座页、确认订单页、支付结果页、个人订单页以及管理端的排片管理页。这套路由结构通过 Vue Router 的懒加载机制实现只在实际访问时才加载对应组件。const routes [ { path: /, name: home, component: () import(/views/HomeView.vue) }, { path: /film/:id, name: film-detail, component: () import(/views/FilmDetail.vue) }, { path: /session/:id/select-seat, name: seat-selection, component: () import(/views/SeatSelection.vue) }, { path: /order/confirm/:orderId, name: order-confirm, component: () import(/views/OrderConfirm.vue) }, { path: /user/orders, name: user-orders, component: () import(/views/UserOrders.vue) }, ]选座页是整个前端最复杂也最值得花时间去打磨的组件。它需要动态渲染座位矩阵监听座位点击还要实时获取后端座位状态。座位状态我放在 Pinia 里管理避免组件间的 prop 层层传递。4.3 选座组件的深入解析选座组件接受一个sessionId作为 prop页面加载时向后端请求该场次的座位布局和座位状态。后端返回的座位数据是平面数组前端需要把它组织成二维矩阵按排渲染。这个转换逻辑我用一个计算属性来完成。template div classseat-map div v-forrow in seatRows :keyrow.row classseat-row span classrow-label{{ row.row }}排/span div v-forseat in row.seats :keyseat.id classseat :classseatClass(seat) clicktoggleSeat(seat) {{ seat.col }} /div /div /div /template script setup import { computed, onMounted } from vue import { useSeatStore } from /stores/seat import { useRoute } from vue-router const route useRoute() const seatStore useSeatStore() const seatRows computed(() { const grouped {} for (const seat of seatStore.seats) { if (!grouped[seat.row]) grouped[seat.row] [] grouped[seat.row].push(seat) } return Object.keys(grouped).sort().map(row ({ row, seats: grouped[row].sort((a, b) a.col - b.col) })) }) const seatClass (seat) { if (seat.status sold) return sold if (seat.selected) return selected return available } const toggleSeat (seat) { if (seat.status sold) return seatStore.toggleSelect(seat) } onMounted(() { seatStore.fetchSeats(route.params.id) }) /script注意我这套代码里用了selected这个本地状态标识它和后端的locked/sold不同。用户点击座位先在前端标记为“待选中”确定提交订单时才调后端锁座接口。这个交互更合理用户选错了可以随时取消不会真的锁定后端座位。提交订单时才把选中的几个座位一次性锁座并跳转确认订单页。4.4 Pinia 状态管理与接口封装Pinia 比起 Vuex 更轻量而且对 TypeScript 支持更好。在我的项目中定义一个seatstore 来管理座位状态和选座操作。这个 store 包含三部分核心职责拉取座位数据、维护用户选中的座位列表、执行锁座操作。import { defineStore } from pinia import { ref } from vue import { fetchSessionSeats, lockSeats } from /api/modules/seat export const useSeatStore defineStore(seat, () { const seats ref([]) const selectedSeatIds ref([]) const lockToken ref() async function fetchSeats(sessionId) { const res await fetchSessionSeats(sessionId) seats.value res.data } function toggleSelect(seat) { if (selectedSeatIds.value.includes(seat.id)) { selectedSeatIds.value selectedSeatIds.value.filter(id id ! seat.id) } else { selectedSeatIds.value.push(seat.id) } } async function confirmLock() { const res await lockSeats({ session_id: route.params.id, seat_ids: selectedSeatIds.value }) lockToken.value res.data.lock_token return res.data.lock_token } return { seats, selectedSeatIds, lockToken, fetchSeats, toggleSelect, confirmLock } })接口封装统一放在src/api/modules下面用 Axios 实例统一配置 baseURL 和拦截器处理 Token 注入和 401 跳转。这里有一个很常见的坑JWT Token 过期之后请求会返回 401前端如果只是弹一个错误提示用户会卡在页面。比较好的做法是在 Axios 响应拦截器里拦截 401用 refresh_token 请求新 access_token 后重放原始请求如果 refresh 也失效就强制跳回登录页。import axios from axios import { useUserStore } from /stores/user import router from /router const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const userStore useUserStore() if (userStore.accessToken) { config.headers.Authorization Bearer ${userStore.accessToken} } return config }) service.interceptors.response.use( response response, async error { const { response } error const userStore useUserStore() if (response.status 401 !response.config._retry) { response.config._retry true try { await userStore.refreshToken() response.config.headers.Authorization Bearer ${userStore.accessToken} return service(response.config) } catch (e) { userStore.logout() router.push(/login) } } return Promise.reject(error) } )4.5 选座界面与订单提交流程串联从选座到订单确认的完整流程在 Vue3 里如下用户选中座位后点击提交按钮前端先把选中的座位传给后端锁座接口拿到 lock_token 后带着 lock_token 和场次 ID 调创建订单接口。这一步我写了一个组合函数useCheckout来组织流程export function useCheckout() { const seatStore useSeatStore() const loading ref(false) async function handleCheckout() { if (seatStore.selectedSeatIds.length 0) { ElMessage.warning(请先选择座位) return } loading.value true try { const lockToken await seatStore.confirmLock() const orderRes await createOrder({ session_id: route.params.id, lock_token: lockToken }) router.push({ name: order-confirm, params: { orderId: orderRes.data.order_id } }) } catch (e) { ElMessage.error(e.response?.data?.error || 下单失败) } finally { loading.value false } } return { handleCheckout, loading } }这里的设计有个小细节如果用户在选座页面停留太久不操作后端锁座记录会超时释放。前端在等待用户支付的过程中最好做一个倒计时提醒比如“座位已锁定请在 15 分钟内完成支付”一旦超时前端自动刷新座位状态把锁定的座位重新释放回可售状态。这个倒计时功能把 Vue3 的响应式 API 用得很爽const countdown ref(900) let timer null onMounted(() { timer setInterval(() { countdown.value-- if (countdown.value 0) { clearInterval(timer) ElMessage.warning(座位已释放请重新选择) seatStore.refreshSeats() } }, 1000) }) onBeforeUnmount(() clearInterval(timer))5. 前后端联调、部署与常见问题排查5.1 Vite 代理与 Django CORS 配合联调开发模式下Vite 代理已把/api前缀的请求转发到 Django。但要注意Django 的 URL 路由本身并没有/api前缀因此需要在 Djangos settings 或根路由中处理。我通常在 Django 根路由里给所有 DRF 路由加上api/前缀from django.urls import path, include from rest_framework.routers import DefaultRouter from movies.views import FilmViewSet, HallViewSet, SessionViewSet from orders.views import SeatViewSet, OrderViewSet router DefaultRouter() router.register(rfilms, FilmViewSet) router.register(rhalls, HallViewSet) router.register(rsessions, SessionViewSet) router.register(rseats, SeatViewSet) router.register(rorders, OrderViewSet) urlpatterns [ path(api/, include(router.urls)), ]这样前端请求/api/films会经过 Vite 代理转成http://localhost:8000/api/filmsDjango 正确路由到 FilmViewSet。在实际联调过程中最常遇到的报错是跨域请求被拒绝和CSRF 验证失败。Django 的 CSRF 中间件默认是开启的对于前后端分离项目建议在需要认证的视图上使用csrf_exempt或者在 settings 里全局关闭 CSRF 的 Enforcement。这里需要注意JWT 认证本身已经足够保证请求来源安全CSRF 反而会造成不必要的拦截所以我推荐在前后端分离模式下关掉 CSRF 中间件。5.2 生产环境部署方案生产环境的部署我推荐用 Nginx 同时托管前端静态文件和反向代理 Django 的 API。前端 Vue 项目打包后是纯静态文件直接放在 Nginx 的 html 目录里。Django 则用 Gunicorn 或 uWSGI 跑在本地端口。Nginx 配置如下server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/cinema_frontend/dist; index index.html; # 所有 API 请求转发到 Django location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 前端路由 history 模式的 fallback location / { try_files $uri $uri/ /index.html; } # 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|svg)$ { expires 7d; add_header Cache-Control public, no-transform; } }这里特别要提一下 Vue Router 的createWebHistory()模式部署时的坑。在这种模式下用户直接访问https://example.com/user/orders时Nginx 找不到对应的物理文件会返回 404必须配置try_files回退到index.html让前端路由接管。如果用createWebHashHistory()就不会有这个问题但 URL 里会有个#不美观。数据库方面生产环境建议使用 PostgreSQL它的并发控制和事务隔离级别比 MySQL 更严格对于选座这种高频写场景更可靠。部署前别忘执行数据库迁移python manage.py makemigrations movies orders python manage.py migrate python manage.py collectstatic最后测试订单创建接口时注意错误状态比如lock_token失效、座位已被其他人抢先购买等场景需要和前端约定好错误码用规范的结构统一返回错误信息前端可以针对不同错误码给出针对性提示。5.3 并发选座问题从理论到实践并发选座是影院系统的头号难点我在实际测试中用Jmeter模拟了 100 个用户同时选同一个座位。在没有加select_for_update()之前大约有 17 个请求成功返回说明这 17 个请求都通过了statusavailable的查询条件都把座位更新成了 locked直接造成了超卖。加上行锁之后只有第一个请求能查到并可更新其余请求都在数据库层被阻塞或返回空结果。这个实验让我意识到做任何涉及状态变更的接口必须假设有并发。除了数据库锁前端在锁座成功之前不应该允许用户重复点击“确认选座”按钮需要加 loading 状态防止请求重复发送。另外就是锁座 API 必须设计成幂等的同一用户同一批座位重复提交时直接返回同一个lock_token否则会出现多次锁座导致座位状态异常。5.4 常见问题排查速查表做这个项目的过程中我积累了大量排错经验整理成表格分享给大家。问题现象可能原因解决方案前端请求接口 404Vite 代理未匹配 /api 前缀检查 vite.config.js 的 proxy 配置和 Django 路由前缀提交订单报 CSRF 错误Django CSRF 中间件拦截使用 csrf_exempt 或在 settings 关闭 CSRF座位超卖并发请求未加行锁使用 select_for_update() 包裹事务JWT Token 失效后请求循环拦截器未设置 _retry 标记在 RefreshToken 逻辑中加入重试标志前端刷新后路由 404Nginx 未配置 try_files配置 location / 回退 index.html图片资源无法加载collectstatic 未执行或路径错误python manage.py collectstatic 并配置 STATIC_URLVue 打包后白屏资源路径是绝对路径且部署在子目录vite.config.js 里设置 base: ./锁定座位超过 15 分钟未释放定时任务未运行使用 Celery beat 或自实现扫描任务5.5 座位状态刷新的定时任务实现锁座 15 分钟超时释放这种定时任务在生产环境推荐用Celery beat来实现它会把每 1 分钟执行一次的任务注册进调度器里。这个任务本身很简单查询所有locked_at超过 15 分钟的座位把状态改回 available。# tasks.py from celery import shared_task from django.utils import timezone from datetime import timedelta from orders.models import Seat shared_task def release_expired_locks(): expire_time timezone.now() - timedelta(minutes15) expired_seats Seat.objects.filter(statuslocked, locked_at__lteexpire_time) expired_seats.update(statusavailable, lock_tokenNone, locked_atNone) return fReleased {expired_seats.count()} expired locksCelery 的安装配置本身有一点复杂度如果想先跑起来也可以直接在 Django 里写一个简单的管理命令让系统每 1 分钟跑一次。个人实际经验是Celery 值得投入因为后期可能还需要处理订单超时关闭、邮件通知等异步任务统一用一个任务队列管理会省心很多。6. 项目上线后的扩展思考影院售票系统的基础功能做完如果继续深挖还有几个特别值得扩展的方向。首先是选座界面的座位图交互升级目前是静态的座位矩阵但真正的影院座位有宽窄之分、有走道间隔、有情侣座可以引入 SVG 或 Canvas 渲染座位图甚至用 Three.js 做 3D 影厅效果配合 Vue3 的组件化结构改起来也不会伤筋动骨。另外是订单支付的回调处理。目前演示项目里订单创建就默认成功了真实的支付环节微信/支付宝需要对接支付网关创建订单后跳到支付页面用户完成支付后支付网关异步通知后端确认收款再把座位状态从 locked 改成 sold。这个异步流程的可靠性和幂等性是又一个技术深水区。我个人实际使用下来的体会是这套系统的价值不只是“写出来”更多在于帮你梳理清楚“业务闭环”和“状态一致性”的重要性。平时只做 CRUD 练习体会不到这种紧张感但当你真的面对并发选座、超时释放、订单状态流转这些实际问题时能力提升会非常明显。希望这篇文章能帮你少走几个弯路如果你也在写类似的购票、预订类系统可以参考这个架构和细节有问题欢迎在实际搭建中反复调试动手踩过一遍的经验比看十篇文章都有效。