☰
Django+Vue3构建电影院售票系统:从建模到并发锁座实战
2026/10/6 9:19:33 网站建设 项目流程

1. 为什么是 Django + Vue3:电影院售票系统的技术选型逻辑

做电影院售票管理系统这个项目之前,我花了不少时间纠结技术栈。市面上类似的选题很多——课程设计、毕设、商业外包都有人做,但大多数成品停留在“能跑就行”的层面:admin后台硬凑个界面、前端套个模板、业务逻辑全靠页面堆,真正把选座、锁座、订单状态流转做明白的少之又少。

我最终敲定 Python + Django + Vue3 这套组合,核心理由有三条。

第一条,Django 对业务系统的建模能力太契合了。售票系统的本质是强状态、强约束、强事务:座位有锁定状态、订单有创建到退票的生命周期、场次有排期冲突校验。Django 的 ORM、Admin、Migration 体系,以及自带的事务处理,走的是“模型驱动”的路子,几乎不用做任何额外设计,就能把影院业务的骨架搭起来。对比 Flask,Django 的“全家桶”属性在这里反而成了优势——你不用自己拼 Session、ORM、Admin 这些零件。

第二条,Vue3 对页面交互的处理效率,决定了用户体验的上限。选座是售票系统最核心的交互场景——几十个座位要实时反馈锁定状态、已售状态、当前选中状态,还要同步倒计时。这类高频状态切换用 Vue3 的响应式系统做起来非常顺手,特别是 Composition API 把选座逻辑抽成独立 Hook 之后,组件代码能压缩三分之一以上。

第三条,前后端分离形态更适合实际部署和二次开发。Django 负责 REST API,Vue3 负责单页应用,两端可以分别部署、分别迭代——前端要换皮肤不影响后端逻辑,后端要加支付模块不动前端结构。

再说说为什么不用 Vue2。说实话,如果项目在 2022 年之前启动,用 Vue2 完全没问题。但到了 2026 年,Vue2 已经停止维护,生态里的主流组件库 —— Element Plus、Naive UI、Ant Design Vue —— 全部围绕 Vue3 重写了。你在新项目里选 Vue2,等于主动放弃长期维护和组件升级的可能。另外,Vue3 的 TypeScript 支持,对“选座”这种需要严格数据结构的场景是实打实的红利:座位编号、订单状态、票种类型,定义成 TS 枚举后,前后端联调时的低级错误能少一大半。

这套技术栈的组合逻辑可以用一句话概括:用 Django 守业务底线,用 Vue3 提升交互上限,前后端各管一段,职责清晰、迭代顺畅。

2. 先建模还是先写页面:数据模型设计是整套系统的骨架

很多新手做这类项目,习惯先画页面、先搭 UI,等页面差不多了再回头建表。我在这个项目里吃过亏——第二次重构时才意识到,售票系统的数据模型远比页面复杂,模型设计错了,页面改起来是伤筋动骨;模型对了,页面只是“填内容”的工作。

2.1 核心模型拆解:影片、影厅、场次、订单

电影院售票系统的主干数据流是这样的:影片决定场次排期,场次挂在影厅上,用户针对场次选座位、生成订单,订单关联场次和座位。沿着这条线,核心模型其实就五张表:

# models.py 核心部分 from django.db import models from django.contrib.auth.models import User class Movie(models.Model): title = models.CharField(max_length=128, verbose_name="影片名称") duration = models.IntegerField(help_text="时长,单位分钟") cover = models.URLField(blank=True, verbose_name="海报链接") release_date = models.DateField(verbose_name="上映日期") STATUS_CHOICES = ( ("coming", "即将上映"), ("showing", "热映中"), ("ended", "已下映"), ) status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="coming") class Hall(models.Model): name = models.CharField(max_length=64, verbose_name="影厅名称") rows = models.IntegerField(verbose_name="座位排数") cols = models.IntegerField(verbose_name="每排座位数") @property def seat_layout(self): """生成座位表,1 表示可用,0 表示过道/不可售""" layout = [] for row in range(1, self.rows + 1): row_list = [] for col in range(1, self.cols + 1): row_list.append(1 if col != 3 else 0) # 示例:每排第3列为过道 layout.append(row_list) return layout class Schedule(models.Model): movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name="schedules") hall = models.ForeignKey(Hall, on_delete=models.CASCADE, related_name="schedules") start_time = models.DateTimeField(verbose_name="开场时间") end_time = models.DateTimeField(verbose_name="散场时间", blank=True, null=True) price = models.DecimalField(max_digits=6, decimal_places=2, verbose_name="基础票价")

这里有几个设计细节值得展开说。

影厅为什么用 rows + cols 而不是直接存座位表?因为这关系到后续“选座页面的铺座逻辑”。前端拿到 rows 和 cols,就能动态渲染出完整座位网格;而如果直接存 JSON 格式的座位表,数据库字段会变成一个 blob,排查问题非常痛苦。用 rows + cols,配合一个 seat_layout 属性方法生成布局,职责单一、逻辑清晰。

Schedule 为什么要有 end_time 字段?排期冲突校验依赖这个字段——同一影厅不能同时排两部片子。end_time 有三种处理方案:手动录入、开播后自动计算、创建时计算。我在项目中选择了创建时自动计算,在序列化器里根据 movie.duration 和 start_time 推算 end_time,避免了手动输入的负担。

2.2 订单模型的粒度决策:座位存哪张表?这是第一个关键分叉

订单模型是这个系统最需要谨慎设计的部分,因为它牵扯到“一座一单一票”的边界。我见过不少实现,给每个座位建一张 seat_order 关联表,订单和座位多对多,最后查订单时还需要 join 两三次,接口响应速度变得非常难看。

我的做法是:订单表里直接存 JSON 数组字段,记录座位编号列表。

class Order(models.Model): code = models.CharField(max_length=32, unique=True, verbose_name="订单号") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="orders") schedule = models.ForeignKey(Schedule, on_delete=models.PROTECT, related_name="orders") seat_ids = models.JSONField(verbose_name="座位编号列表", help_text='["1-5", "1-6"]') total_amount = models.DecimalField(max_digits=8, decimal_places=2, verbose_name="总金额") STATUS_CHOICES = ( ("pending", "待支付"), ("paid", "已支付"), ("cancelled", "已取消"), ("refunded", "已退款"), ) status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="pending") created_at = models.DateTimeField(auto_now_add=True)

为什么不用独立关联表?两个原因:

一是电影院订单的座位数量通常很少(2-6个),JSON 字段完全够用。用独立表反而要额外维护座位和订单的中间关系,为了一个平均 3 个元素的列表多做两张表的 join,不划算。

二是事务一致性更好控制。整个订单的座位作为一个整体写入,选座锁座时可以一次性比对、一次性更新,避免“订单表写了,关联表没写”这种中间状态。

seat_ids 用"row-col"字符串格式,比如"5-12"表示第 5 排第 12 座。这个格式在选座页面前端也好处理,split("-")一步拿到行列号,不需要额外解析结构体。

2.3 座位锁定与拍片子限时:锁座表的边界

订单表负责“记录购买结果”,那“锁定座位”这个中间状态放哪里?很多初学者把锁定状态直接挂在 Order 上——订单创建后状态为 pending,表示座位被暂占。这种方案有个致命问题:如果用户迟迟不支付,订单的生命周期必须管理“过期”逻辑;而如果同一个场次有多个用户同时选座,两个订单可以同时锁到同一批座位吗?

我在项目中单独建了一张 SeatLock 表,专职管理座位占用状态:

class SeatLock(models.Model): schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE, related_name="locks") seat_id = models.CharField(max_length=16, verbose_name="座位编号") order = models.OneToOneField(Order, on_delete=models.CASCADE, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) expire_at = models.DateTimeField(verbose_name="锁定过期时间") class Meta: unique_together = ("schedule", "seat_id")

SeatLock 的存在,把“选座”和“下单”两个动作解耦了:用户点击选座,系统尝试创建 SeatLock;支付完成后 SeatLock 关联到 Order;支付超时或取消,删除 SeatLock。这样即使多个用户同时选同一排座位,数据库的唯一约束unique_together也能保证同一个场次下同一个座位只可能有一条锁定记录——并发安全从数据库层面就兜住了。

这个设计避免了另一个坑:如果不建 SeatLock,而是把“已售座位”直接硬编码进 Schedule 的一个字段,那查询“哪些座位可售”就要在前端先拉全量座位再过滤,逻辑越写越乱,并发下还会出现“看到座位却买不了”的灵异事件。

3. Django REST Framework 接口层:从 Model 到 API 的规范落地

模型建好后,接下来工作就是把这个模型暴露成 REST API。我用的是 Django REST Framework(DRF),它是 Django 生态里最主流的 API 框架方案。

3.1 序列化器设计:嵌套展示与自定义验证

序列化器(Serializer)是 DRF 的核心。它承担两个职责:把 Model 转成 JSON 响应给前端;把前端提交的 JSON 转成 Model 持久化。我的序列化器分了三个层次。

列表页轻量序列化,比如影片列表接口,只需要返回 id、title、cover、release_date、status 这些基础字段。

详情页嵌套序列化,比如场次详情,需要内嵌影片信息和影厅信息,方便前端一次性拿到渲染所需数据:

class ScheduleDetailSerializer(serializers.ModelSerializer): movie = MovieBriefSerializer(read_only=True) hall = HallBriefSerializer(read_only=True) seat_layout = serializers.SerializerMethodField() class Meta: model = Schedule fields = ("id", "movie", "hall", "start_time", "end_time", "price", "seat_layout") def get_seat_layout(self, obj): return obj.hall.seat_layout

这里注意 seat_layout 用 SerializerMethodField,它是从 hall 实例的方法中读取的,不走数据库的额外查询列,接口返回时就自动包含了完整的座位网格结构。

下单序列化器需要自定义校验逻辑——这是最容易出问题的地方:

class OrderCreateSerializer(serializers.Serializer): schedule_id = serializers.IntegerField() seat_ids = serializers.ListField(child=serializers.CharField(max_length=16)) def validate_seat_ids(self, value): if len(value) > 5: raise serializers.ValidationError("单笔订单最多购买5个座位") if len(set(value)) != len(value): raise serializers.ValidationError("存在重复座位编号") return value

一个很关键的小细节:seat_ids 的校验里必须检查重复。如果前端传了一个["5-1", "5-1"],后面所有座位锁定的唯一约束都会因为这个重复值而变得不可预测。这种防御性校验放在序列化器层,比放到视图层更合适,因为 DRF 处理序列化器校验错误时会自动返回 400 状态码和结构化错误信息。

3.2 视图集与 action 路由:把业务动作显式化

DRF 的 ModelViewSet 对标准的增删改查非常自动——一个视图集几行代码就搞定 CRUD。但售票系统有几个“非 CRUD”的业务动作:下单、锁定座位、取消订单。如果用 ModelViewSet 的 create 硬逼着下单逻辑塞进去,代码会变成一个巨大的 if-else 分支。

我的处理是:标准资源用 ModelViewSet,业务动作用 @action 装饰器单独注册路由。

class OrderViewSet(viewsets.ModelViewSet): queryset = Order.objects.all() serializer_class = OrderSerializer def get_queryset(self): return self.queryset.filter(user=self.request.user) @action(detail=False, methods=["post"]) def create_order(self, request): # 下单动作:事务内校验座位、创建 SeatLock、生成订单 pass @action(detail=True, methods=["post"]) def cancel(self, request, pk=None): # 取消订单:退款、释放座位 pass

这样设计的原因很直接:路由上/orders/create_order/和/orders/{id}/cancel/的语义比POST /orders/后在 body 里传个action=cancel要清晰得多。前端调用也不需要自己记动作枚举——看 URL 就明白在调什么接口。

视图层还有一个容易忽略的细节:get_queryset 里的用户隔离。任何用户查询订单,只能看到自己的订单,这个过滤写在视图集里是 DRF 的标准做法,但很多新手会漏掉,导致接口泄露其他人的购票记录。我在联调阶段抓过一个安全 bug——用户 A 的 token 能查到用户 B 的订单列表,根源就是 queryset 里少写了 filter(user=request.user)。这种低级错误,建议所有的订单类、支付类接口强制加用户过滤。

3.3 鉴权与跨域:JWT 和 CORS 的配套方案

前端 Vue3 项目必然要走前后端分离,那后端在安全配置上要处理两件事:认证和跨域。

认证我用的是 djangorestframework-simplejwt,JSON Web Token 方案。它比 Django 自带的 Session 认证更适合前后端分离——前端每次请求在 Authorization 头里带 Token,后端不依赖 Cookie,天然免疫 CSRF 攻击。

# settings.py REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticatedOrReadOnly", ], }

跨域用 django-cors-headers,配置允许的前端站点地址:

CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", # Vue3 dev server 默认端口 ]

这里有一个我踩过的坑:CORS 配置必须在生产环境把外网域名加进去,否则部署后前端调接口全是 CORS 报错。本地联调时 localhost 没问题,一上服务器,前端域名变了,忘了改配置,排查了半小时。

4. Vue3 前端核心:Composition API、状态管理与组件拆分

后端 API 好了之后,前端的重头戏是选座页和购票流程。Vue3 在这一层最大的优势是Composition API 把业务逻辑按功能聚合,而不是像 Vue2 Options API 那样把 data、methods、computed 分散在文件不同区块。

4.1 项目的创建与目录结构

用 Vite 创建 Vue3 项目,是我推荐的方案。Vite 的冷启动速度比 Webpack 时代的 vue-cli 快一个量级,配置也更简洁:

npm create vite@latest cinema-frontend -- --template vue-ts cd cinema-frontend npm install npm install vue-router@4 pinia axios element-plus

目录结构按功能模块划分,而不是按文件类型硬切:

src/ api/ # 接口封装,按模块拆分 movie.ts / schedule.ts / order.ts stores/ # Pinia store,订单状态 views/ # 页面组件 components/ # 通用组件 composables/ # 组合式函数,选座逻辑、倒计时逻辑

这种结构的价值在于:当你需要找“订单超时释放”的逻辑时,你会先找composables/useSeatSelection.ts,而不是在 components 下翻十几个子组件。

4.2 选座页组件拆分与响应式状态设计

选座页是整个前端复杂度最高的部分。它不是简单的“显示几个座位格子”,而是需要同步处理三类状态:座位本身的可售/已售/锁定/选中、当前用户的交互动作、倒计时进度。

我的组件树是这样的:

  • SeatSelection.vue—— 选座页面主容器
    • SeatGrid.vue—— 渲染座位网格
      • SeatCell.vue—— 单个座位,接收 row/col/state 三个 props
    • OrderSummary.vue—— 展示已选座位和总金额
    • CountdownTimer.vue—— 倒计时

Composition API 在 SeatSelection.vue 的用法:

<script setup lang="ts"> import { reactive, computed, ref, watch } from 'vue' import type { SeatState } from '@/types' // 座位状态映射 const seatStates = reactive<Record<string, SeatState>>({}) // 已选座位集合,使用 Set 去重 const selectedSeats = ref<Set<string>>(new Set()) // 倒计时 const countdown = ref<number>(300) const toggleSeat = (seatId: string) => { const state = seatStates[seatId] // 已售出或锁定状态的座位不可选 if (state === 'sold' || state === 'locked') return const next = new Set(selectedSeats.value) if (next.has(seatId)) { next.delete(seatId) } else { if (next.size >= 5) { // 提示"单笔订单最多选择5个座位" return } next.add(seatId) } selectedSeats.value = next } // 计算总价 const totalAmount = computed(() => { return selectedSeats.value.size * basePrice.value }) </script>

这里有个非常关键的细节:selectedSeats 用 Set 而不是数组。因为选座交互天然要求去重,数组 push 一个重复元素会导致 UI 出现两个高亮座位,而 Set 本身就是唯一值集合。Vue3 在 reactive 中对 Set 的响应式追踪是原生支持的,不需要额外包装。

另一个值得注意的设计是seatStates 用reactive<Record<string, SeatState>>()而不是单个 ref 数组。这样设计是因为选座页的座位状态更新非常频繁——后端推送锁定状态变化时,只需要seatStates[seatId] = 'locked'一条赋值语句,就能精准触发该座位对应的组件更新,不用重建整个数组。

4.3 Pinia 状态管理:订单状态的唯一数据源

选座页涉及多个组件共享数据:已选座位要传给 OrderSummary 展示,还要传给订单确认接口。如果这个状态放在某个组件内部,跨组件通信会变得繁琐。用 Pinia 把订单状态提升为全局 store,是最干净的方案。

// stores/order.ts import { defineStore } from 'pinia' import { ref } from 'vue' export const useOrderStore = defineStore('order', () => { const selectedSchedule = ref<Schedule | null>(null) const selectedSeats = ref<string[]>([]) const seatLockExpireAt = ref<number | null>(null) function setSeats(seats: string[]) { selectedSeats.value = seats } function resetOrder() { selectedSchedule.value = null selectedSeats.value = [] seatLockExpireAt.value = null } return { selectedSchedule, selectedSeats, seatLockExpireAt, setSeats, resetOrder, } })

Pinia 这里有个优势:它在 Vue2 时代对应的 Vuex 需要写 mutation/action 那一套,而 Pinia 直接函数式,逻辑非常轻。如果是 Vuex,一套“选座”的逻辑要拆成 action 和 mutation 两个文件来回找,新手上手成本高。写业务代码时,少一层间接跳转,就少很多心智负担。

4.4 接口封装与 Axios 拦截器

前端所有后端请求统一走src/api/下的封装,Axios 实例统一配置 baseURL、超时时间、Token 注入和错误处理:

// api/http.ts import axios from 'axios' import { useUserStore } from '@/stores/user' import { ElMessage } from 'element-plus' const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000, }) http.interceptors.request.use((config) => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) http.interceptors.response.use( (response) => response.data, (error) => { if (error.response?.status === 401) { // Token 过期,跳转登录页 router.push('/login') } else { ElMessage.error(error.response?.data?.detail || '请求失败') } return Promise.reject(error) } )

这里一定要记住:拦截器返回 response.data 而不是整个 response。否则每个业务代码都要写res.data.data这种很丑的三层嵌套。统一在拦截器里解包一次,后续所有接口调用拿到的是干净的业务数据,代码可读性提升非常明显。

关于环境变量:VITE_API_BASE_URL是 Vite 规范的前端环境变量写法,放在.env.development和.env.production两个文件里,分别指向本地联调地址和线上地址。前端联调时,后端在 Django 的 settings.py 里把http://localhost:5173加进 ALLOWED_HOSTS 和 CORS_ALLOWED_ORIGINS,两边才能互通。

5. 选座与锁座的并发难题:后端事务才是真正的防线

选座是这个项目最需要深度思考的功能,也是面试和答辩时最能体现水平的部分。很多人的实现是:前端选完座位,直接 POST 一个订单请求,后端检查座位是否被占,没被占就创建订单。这在并发场景下会出大问题——两个用户同时选同一个座位,两个请求同时到达后端,同时看到座位是空的,同时下单成功,最后一张座位卖给了两个用户。

5.1 为什么座位锁定不能只靠前端

前端可以做到即时反馈——点击座位后马上把格子变灰,其他用户再进来看到的是锁定位。但前端是单机的,两个用户同时点同一个座位时,彼此都看不到对方的状态,最后提交订单时才暴发冲突。所以座位锁定逻辑必须放在后端,而且必须依赖数据库级别的原子操作,而不是应用层的内存判断。

5.2 Django 事务与 select_for_update 行锁

Django 的 ORM 提供了select_for_update()方法,可以在数据库层面锁定查询的行,直到事务结束。这是解决并发锁座最直接的武器。

from django.db import transaction def create_order_with_lock(user, schedule_id, seat_ids): with transaction.atomic(): # 锁定场次记录,避免并发下单 schedule = Schedule.objects.select_for_update().get(id=schedule_id) # 检查当前锁定表中是否已有这些座位 existing_locks = SeatLock.objects.filter( schedule=schedule, seat_id__in=seat_ids, ) if existing_locks.exists(): locked_seats = list(existing_locks.values_list("seat_id", flat=True)) raise SeatLockError(f"以下座位已被锁定: {locked_seats}") # 创建锁定记录,占用座位 locks = [] for seat_id in seat_ids: locks.append(SeatLock(schedule=schedule, seat_id=seat_id)) SeatLock.objects.bulk_create(locks) # 生成订单 order = Order.objects.create( code=generate_order_code(), user=user, schedule=schedule, seat_ids=seat_ids, total_amount=len(seat_ids) * schedule.price, status="pending", ) return order

这段逻辑的关键是transaction.atomic()和select_for_update()的组合。atomic 保证整个操作要么全部成功要么全部回滚;select_for_update 把场次记录行锁住,其他并发事务操作同一场次时必须等待当前事务释放锁,从而避免一对座位被两个订单同时占用。

另一个细节是bulk_create 批量创建锁定记录。如果逐条 create,会发起多次数据库往返,耗时更长,锁的窗口期也更长。批量创建一条 SQL 搞定,而且锁表控制在最小范围。

5.3 超时释放与定时任务

SeatLock 表有 expire_at 字段,用于处理“用户锁了座位但不支付”的情况。我的实现里有两条策略:

主动清理策略:订单查询接口里增加一个逻辑——查订单前先判断是否有关联的 SeatLock 已过期,如果过期,删除 SeatLock 并把订单标记为 cancelled。这样用户 A 在支付超时后重新进入选座页,后端能立刻释放座位,不需要等定时任务跑完。

兜底定时策略:用 Django-Celery 的定时任务每小时扫描一次过期锁定,统一清理。这个兜底是为了防止“主动清理”那条路在某些边界情况下没走到(比如用户关掉了页面,一直没有触发新的查询接口)。

# tasks.py from celery import shared_task from datetime import timedelta from django.utils import timezone from .models import SeatLock, Order @shared_task def clean_expired_locks(): expired_locks = SeatLock.objects.filter( expire_at__lt=timezone.now(), order__status="pending", ) order_ids = expired_locks.values_list("order_id", flat=True) expired_locks.delete() Order.objects.filter(id__in=order_ids, status="pending").update(status="cancelled")

这里 share_task 的函数体不要写太多,逻辑简单清晰即可,出错了也好排查。

5.4 前端倒计时的同步策略

前端选座后开始 300 秒倒计时,这个倒计时不能完全信任前端本地计时——如果用户刷新页面,倒计时必须恢复为剩余时间。我的做法是:锁定座位成功后,后端接口返回lock_expire_at时间戳,前端倒计时基于这个时间戳计算:

const expireAt = ref<number>(0) let timer: ReturnType<typeof setInterval> function startCountdown(expireAtFromServer: number) { expireAt.value = expireAtFromServer timer = setInterval(() => { const remaining = Math.floor((expireAt.value - Date.now()) / 1000) if (remaining <= 0) { // 超时,释放座位,重置选座状态 clearInterval(timer) resetSelection() ElMessage.warning('座位已释放,请重新选座') } else { countdown.value = remaining } }, 1000) }

关键就一句:倒计时永远基于服务器下发的 expireAt,而不是本地 startTime + 300。本地时间可以随意改,但服务器决定什么时候真正释放座位。前后端的时间基准对齐后,就不会出现“前端显示还剩 10 秒,后端已经释放了座位”的撕裂问题。

6. 联调与部署的实战经验:那些文档里不会写清楚的坑

项目走到联调和部署阶段,真正花时间的不是功能开发,而是处理各种“本地没问题、一上环境就炸”的坑。这一节的几个问题,是我在这个项目上真实踩过的,分享出来帮大家少走弯路。

6.1 跨域问题的真实链排查

本地联调时最常见的报错是浏览器控制台出现CORS policy: No 'Access-Control-Allow-Origin' header is present。这个报错 90% 的根源是后端 CORS 配置遗漏或域名不匹配。

排查思路有三步:

  1. 先用 curl 直接请求后端接口,检查响应头里有没有 Access-Control-Allow-Origin 字段。curl 不发浏览器 Origin header,所以即使跨域配置不对,curl 也可能显示正常。要用curl -H "Origin: http://localhost:5173" -i模拟浏览器行为。
  2. 检查 django-cors-headers 的中间件是否加到了 MIDDLEWARE 列表且位置正确。它应该尽量靠前,比如放在 CommonMiddleware 之前。
  3. 确认 CORS_ALLOWED_ORIGINS 里的地址写了完整的协议+域名+端口,没有多余空格。

顺便提醒:开发环境跨域代理也是一个有效选项。Vite 的 proxy 配置可以把/api请求代理到 Django 的 8000 端口,浏览器视角是同源请求,完全避开 CORS。但生产环境还是老老实实配置 CORS,别依赖代理。

6.2 Django 的 ALLOWED_HOSTS 与 DEBUG 配置

这是部署时排在第一位的坑。Django 的 DEBUG=False 后,ALLOWED_HOSTS 必须显式列出允许访问的域名,否则直接报DisallowedHost。很多新手本地开发时依赖ALLOWED_HOSTS = ["*"],一上服务器忘了改,IP 访问直接被拒。

# settings.py 生产环境示例 DEBUG = False ALLOWED_HOSTS = ["cinema.example.com", "你的服务器公网IP"]

另一个细节是STATIC_ROOT和MEDIA_ROOT设置。前后端分离架构下 Django 不负责前端静态文件,但 admin 后台的静态资源还是要 Django 托管。部署时需要执行python manage.py collectstatic把 admin 静态文件收集到一个目录,然后 nginx 配置 alias 静态目录。忘了这步,admin 后台会变成没有样式的裸表格,一眼就能看出来部署不完整。

6.3 数据库事务与并发压测

交付前我做了个简单的并发压测——用 Locust 模拟 50 个用户同时抢同一个场次最后 5 张票。结果发现一个并发边界情况:select_for_update 虽然锁住了 Schedule 行,但如果两个请求在锁释放后、订单生成前的一瞬间同时到达,SeatLock 层的唯一约束unique_together会抛出 IntegrityError,而不是优雅地返回“座位已被锁定”。

我的处理是在 view 层捕获 IntegrityError,转成业务提示:

try: with transaction.atomic(): # 锁座+下单 except IntegrityError: return Response( {"detail": "座位刚刚被其他用户选走,请重新选择"}, status=status.HTTP_409_CONFLICT, )

不要小看这个 409 处理——不加这个 try/except,用户遇到并发冲突时看到的会是 500 服务器错误页面,体验极差;加了之后,前端可以捕获 409 并回到选座页刷新座位状态,整个流程就顺畅了。

6.4 前端路由模式与部署刷新 404 的问题

Vue3 前端如果使用 createWebHistory 路由模式(HTML5 History 模式),部署到 nginx 后有个经典坑:用户直接访问页面 URL 或刷新页面时,nginx 返回 404。因为前端路由跳转是 SPA 内部行为,但浏览器刷新时,nginx 会拿着整个 URL 去找对应文件,找不到自然 404。

解决方案是 nginx 配置 try_files 指令,把所有未匹配的请求都 fallback 到 index.html:

location / { try_files $uri $uri/ /index.html; }

如果觉得麻烦,也可以用 createWebHashHistory(哈希模式),URL 会多一个#/,刷新不会 404。但 SEO 和美观度不如 History 模式,自行权衡。我个人推荐 History 模式 + try_files,这才是正规操作。

7. 项目完整跑通后的复盘:这套架构还能复用到哪里

售票系统的完整链路——选座、锁定、下单、支付、退款、释放——本质上是一套“资源管理 + 订单状态机 + 并发控制”的组合逻辑。项目跑通后我没急着找下一个项目,而是把这次的设计方法论梳理了一遍,发现它不止适用于电影院售票。

一切需要抢占资源并限时支付的场景都能复用这套模型。比如演唱会选座(和电影选座完全同构)、课程预约(把座位换成时间段)、酒店预订(把座位换成房型)。核心都是三件事:资源表 + 锁定表 + 订单表,锁定表负责中间状态,订单表负责最终状态,状态流转用事务保证一致性。

我在实际开发中的体会是,这个系统真正难的地方不是 Vue3 怎么写、Django 怎么配,而是“选座”这个业务动作背后的并发语义。前端的交互再炫、组件再花哨,最终还是回到一个问题:两个用户同时提交时,系统如何保证数据不矛盾?把这一层想透、实现好,换什么技术栈都只是换工具的差别。

最后再分享一个小技巧:项目开发阶段就尽早引入接口文档工具,比如 DRF 自带的 schema 或 drf-spectacular + Swagger UI。我在项目早期没在意,结果前后端联调时每次都要手动对字段、对类型,效率很低。后边配好 drf-spectacular,前端同事直接看 Swagger 文档就能知道接口要传什么、返回什么,联调时间至少省了一半。

这套 Django + Vue3 的电影院售票管理系统,从数据建模、API 设计到前端交互、并发控制,是一条非常完整的全栈学习路径。做完这个项目,你对“状态管理”“事务并发”“前后端分离”这三个概念的理解深度,会远超只看文档或刷教程的效果。如果从头再来一遍,我可能还会在订单支付环节接入真实支付渠道(支付宝/微信),那个模块的体验又会是另一个层次的挑战。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询