ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Django+Vue3构建电影院售票系统:从建模到并发锁座实战

Django+Vue3构建电影院售票系统:从建模到并发锁座实战 1. 为什么是 Django Vue3电影院售票系统的技术选型逻辑做电影院售票管理系统这个项目之前我花了不少时间纠结技术栈。市面上类似的选题很多——课程设计、毕设、商业外包都有人做但大多数成品停留在“能跑就行”的层面admin后台硬凑个界面、前端套个模板、业务逻辑全靠页面堆真正把选座、锁座、订单状态流转做明白的少之又少。我最终敲定 Python Django Vue3 这套组合核心理由有三条。第一条Django 对业务系统的建模能力太契合了。售票系统的本质是强状态、强约束、强事务座位有锁定状态、订单有创建到退票的生命周期、场次有排期冲突校验。Django 的 ORM、Admin、Migration 体系以及自带的事务处理走的是“模型驱动”的路子几乎不用做任何额外设计就能把影院业务的骨架搭起来。对比 FlaskDjango 的“全家桶”属性在这里反而成了优势——你不用自己拼 Session、ORM、Admin 这些零件。第二条Vue3 对页面交互的处理效率决定了用户体验的上限。选座是售票系统最核心的交互场景——几十个座位要实时反馈锁定状态、已售状态、当前选中状态还要同步倒计时。这类高频状态切换用 Vue3 的响应式系统做起来非常顺手特别是 Composition API 把选座逻辑抽成独立 Hook 之后组件代码能压缩三分之一以上。第三条前后端分离形态更适合实际部署和二次开发。Django 负责 REST APIVue3 负责单页应用两端可以分别部署、分别迭代——前端要换皮肤不影响后端逻辑后端要加支付模块不动前端结构。再说说为什么不用 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_length128, verbose_name影片名称) duration models.IntegerField(help_text时长单位分钟) cover models.URLField(blankTrue, verbose_name海报链接) release_date models.DateField(verbose_name上映日期) STATUS_CHOICES ( (coming, 即将上映), (showing, 热映中), (ended, 已下映), ) status models.CharField(max_length16, choicesSTATUS_CHOICES, defaultcoming) class Hall(models.Model): name models.CharField(max_length64, 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_deletemodels.CASCADE, related_nameschedules) hall models.ForeignKey(Hall, on_deletemodels.CASCADE, related_nameschedules) start_time models.DateTimeField(verbose_name开场时间) end_time models.DateTimeField(verbose_name散场时间, blankTrue, nullTrue) price models.DecimalField(max_digits6, decimal_places2, 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_length32, uniqueTrue, verbose_name订单号) user models.ForeignKey(User, on_deletemodels.CASCADE, related_nameorders) schedule models.ForeignKey(Schedule, on_deletemodels.PROTECT, related_nameorders) seat_ids models.JSONField(verbose_name座位编号列表, help_text[1-5, 1-6]) total_amount models.DecimalField(max_digits8, decimal_places2, verbose_name总金额) STATUS_CHOICES ( (pending, 待支付), (paid, 已支付), (cancelled, 已取消), (refunded, 已退款), ) status models.CharField(max_length16, choicesSTATUS_CHOICES, defaultpending) created_at models.DateTimeField(auto_now_addTrue)为什么不用独立关联表两个原因一是电影院订单的座位数量通常很少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_deletemodels.CASCADE, related_namelocks) seat_id models.CharField(max_length16, verbose_name座位编号) order models.OneToOneField(Order, on_deletemodels.CASCADE, nullTrue, blankTrue) created_at models.DateTimeField(auto_now_addTrue) 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 FrameworkDRF它是 Django 生态里最主流的 API 框架方案。3.1 序列化器设计嵌套展示与自定义验证序列化器Serializer是 DRF 的核心。它承担两个职责把 Model 转成 JSON 响应给前端把前端提交的 JSON 转成 Model 持久化。我的序列化器分了三个层次。列表页轻量序列化比如影片列表接口只需要返回 id、title、cover、release_date、status 这些基础字段。详情页嵌套序列化比如场次详情需要内嵌影片信息和影厅信息方便前端一次性拿到渲染所需数据class ScheduleDetailSerializer(serializers.ModelSerializer): movie MovieBriefSerializer(read_onlyTrue) hall HallBriefSerializer(read_onlyTrue) 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(childserializers.CharField(max_length16)) 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(userself.request.user) action(detailFalse, methods[post]) def create_order(self, request): # 下单动作事务内校验座位、创建 SeatLock、生成订单 pass action(detailTrue, methods[post]) def cancel(self, request, pkNone): # 取消订单退款、释放座位 pass这样设计的原因很直接路由上/orders/create_order/和/orders/{id}/cancel/的语义比POST /orders/后在 body 里传个actioncancel要清晰得多。前端调用也不需要自己记动作枚举——看 URL 就明白在调什么接口。视图层还有一个容易忽略的细节get_queryset 里的用户隔离。任何用户查询订单只能看到自己的订单这个过滤写在视图集里是 DRF 的标准做法但很多新手会漏掉导致接口泄露其他人的购票记录。我在联调阶段抓过一个安全 bug——用户 A 的 token 能查到用户 B 的订单列表根源就是 queryset 里少写了 filter(userrequest.user)。这种低级错误建议所有的订单类、支付类接口强制加用户过滤。3.3 鉴权与跨域JWT 和 CORS 的配套方案前端 Vue3 项目必然要走前后端分离那后端在安全配置上要处理两件事认证和跨域。认证我用的是 djangorestframework-simplejwtJSON 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 vitelatest cinema-frontend -- --template vue-ts cd cinema-frontend npm install npm install vue-router4 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 三个 propsOrderSummary.vue—— 展示已选座位和总金额CountdownTimer.vue—— 倒计时Composition API 在 SeatSelection.vue 的用法script setup langts import { reactive, computed, ref, watch } from vue import type { SeatState } from /types // 座位状态映射 const seatStates reactiveRecordstring, SeatState({}) // 已选座位集合使用 Set 去重 const selectedSeats refSetstring(new Set()) // 倒计时 const countdown refnumber(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 用reactiveRecordstring, 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 refSchedule | null(null) const selectedSeats refstring[]([]) const seatLockExpireAt refnumber | 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(idschedule_id) # 检查当前锁定表中是否已有这些座位 existing_locks SeatLock.objects.filter( scheduleschedule, seat_id__inseat_ids, ) if existing_locks.exists(): locked_seats list(existing_locks.values_list(seat_id, flatTrue)) raise SeatLockError(f以下座位已被锁定: {locked_seats}) # 创建锁定记录占用座位 locks [] for seat_id in seat_ids: locks.append(SeatLock(scheduleschedule, seat_idseat_id)) SeatLock.objects.bulk_create(locks) # 生成订单 order Order.objects.create( codegenerate_order_code(), useruser, scheduleschedule, seat_idsseat_ids, total_amountlen(seat_ids) * schedule.price, statuspending, ) 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__lttimezone.now(), order__statuspending, ) order_ids expired_locks.values_list(order_id, flatTrue) expired_locks.delete() Order.objects.filter(id__inorder_ids, statuspending).update(statuscancelled)这里 share_task 的函数体不要写太多逻辑简单清晰即可出错了也好排查。5.4 前端倒计时的同步策略前端选座后开始 300 秒倒计时这个倒计时不能完全信任前端本地计时——如果用户刷新页面倒计时必须恢复为剩余时间。我的做法是锁定座位成功后后端接口返回lock_expire_at时间戳前端倒计时基于这个时间戳计算const expireAt refnumber(0) let timer: ReturnTypetypeof 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 配置遗漏或域名不匹配。排查思路有三步先用 curl 直接请求后端接口检查响应头里有没有 Access-Control-Allow-Origin 字段。curl 不发浏览器 Origin header所以即使跨域配置不对curl 也可能显示正常。要用curl -H Origin: http://localhost:5173 -i模拟浏览器行为。检查 django-cors-headers 的中间件是否加到了 MIDDLEWARE 列表且位置正确。它应该尽量靠前比如放在 CommonMiddleware 之前。确认 CORS_ALLOWED_ORIGINS 里的地址写了完整的协议域名端口没有多余空格。顺便提醒开发环境跨域代理也是一个有效选项。Vite 的 proxy 配置可以把/api请求代理到 Django 的 8000 端口浏览器视角是同源请求完全避开 CORS。但生产环境还是老老实实配置 CORS别依赖代理。6.2 Django 的 ALLOWED_HOSTS 与 DEBUG 配置这是部署时排在第一位的坑。Django 的 DEBUGFalse 后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: 座位刚刚被其他用户选走请重新选择}, statusstatus.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.htmllocation / { 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 设计到前端交互、并发控制是一条非常完整的全栈学习路径。做完这个项目你对“状态管理”“事务并发”“前后端分离”这三个概念的理解深度会远超只看文档或刷教程的效果。如果从头再来一遍我可能还会在订单支付环节接入真实支付渠道支付宝/微信那个模块的体验又会是另一个层次的挑战。
返回列表