很多人觉得在线电影票购买系统就是个“给网站套个支付接口”的活儿,真正动手做一遍才发现,从选座到出票的每一步都藏着坑。我这次用Django完整实现了一个可运行的在线电影票购买系统(源码包编号84025),涵盖影片展示、场次排期、选座锁座、订单支付全流程。这篇文章把整个设计和实现过程拆开讲清楚——为什么数据表要这样建、锁座逻辑怎么保证不多卖一张票、Cookie和Token在实际项目里怎么配合、上线部署时哪些问题最容易翻车。内容偏实战,适合已经掌握Django基础的开发者,也适合正在做课程设计或毕业设计、想找一个完整项目参考的同学。
1. 从“看起来简单”到“真正动手”:电影票系统到底难在哪
先聊点实在的。网上随便一搜,电影票系统的“教程”特别多,但大部分是把管理后台里的增删改查搬出来演示一遍,真正到了选座、锁座、支付回调这些核心环节就含糊带过。如果你照着那种教程做,大概率会在联调时发现:两个人同时买同一场同一个座位,系统居然都提示“购买成功”。这种问题一旦上线,补票、退款、客服扯皮,能把人折腾死。
所以动工前,先把这个系统的真实复杂度摊开看。
1.1 需求拆解:这个系统到底要做哪些事
我习惯先把业务流程画清楚(用文字描述,不依赖画图工具):
- 用户打开系统,浏览正在热映的电影列表,点进详情页看简介、海报、评分。
- 选择电影院、场次、放映厅,进入选座页。
- 选座页展示该场次的座位图,灰色是已售出,绿色是可选,黄色是当前选中。
- 确认座位后提交订单,系统锁定座位,进入支付环节。
- 用户完成支付,系统回调更新订单状态,生成电子票。
- 如果超过规定时间未支付,系统自动释放座位。
对应到Django后端,核心模块就是:影片管理、影院与场次管理、座位与票档管理、订单管理、用户与会员体系、支付回调。听起来模块不多,但每个模块之间的状态流转才是真正的难点。比如座位和订单耦合——订单创建成功时座位必须锁定,订单超时取消时座位必须释放,支付成功后座位永久标记为已售出。
1.2 为什么这座“小系统”容易写崩:并发与状态
选座购票和普通电商买衣服最大的区别在于资源唯一性。衣服没库存可以等补货,但一个座位在同一场次只能属于一个人。如果系统任意时刻允许两个用户同时锁定同一个座位,后面的所有逻辑都是错的。
要解决这个问题,就必须从数据库层面设计好行级锁和原子更新,配合订单超时释放机制。这块在后面的章节详细展开,先记住一句话:状态机设计得越清楚,代码写起来越舒服。我会在代码里把每个模型的状态字段用常量定义好,不做“魔法数字”。
另外,支付回调的幂等性也很关键。微信/支付宝的通知接口可能会重复推送,如果每次收到通知都盲目更新订单状态,订单金额和状态都可能被覆盖错。所以订单状态流转要设计成单向不可逆的,状态字段从“待支付”到“已支付”只能走一次,重复回调直接忽略。
1.3 技术选型:为什么还是选了Django
技术栈方面,Django在ORM层面提供了很好的事务支持,select_for_update()配合数据库行锁可以稳定解决座位超卖问题;自带Admin后台,影片、场次、影院数据的维护几乎不写代码;还有内置的认证系统和CSRF防护,省掉很多安全功能的自研成本。
前端部分我采用的是Django模板加Bootstrap布局,选座页用原生JavaScript加局部刷新,没有引入复杂的前端框架。这样一个项目可以完整跑通,源码结构清楚,也便于你后续替换成Vue、React等方案。
2. 项目初始化与数据建模:一张表设计定成败
这个项目一开始用django-admin startproject ticket_system和python manage.py startapp movies、startapp orders、startapp users来创建工程。很多新手会问app怎么拆,我的标准很简单:一个独立的业务域一个app。影片、场次属于“排片管理”,归到movies;订单、支付回调、座位锁定属于“交易链路”,归到orders;用户、登录态、会员信息归到users。后续扩展票种、优惠券时,不会出现一个app几千行代码的尴尬。
2.1 模型设计:每张表都在回答一个业务问题
数据模型是整个系统的地基,先说核心的几张表。
电影表(Movie)
一个影片会有海报、名称、时长、类型、上映日期等字段。这里很多人喜欢把评分直接存进电影表,但评分是动态的,如果后面接入真实评分接口,频繁更新主表就很没必要。我做的是单独建了一张MovieRating表,把多个数据源的评分存进去,主表只做展示时聚合。
影院、放映厅与场次表(Cinema、Hall、Showtime)
这三个表是关联的。影院和放映厅是一对多,放映厅和场次是一对多。有个细节我建议新手特别留意:不要直接在场次表里存“放映厅名称”这种冗余字段,要通过外键关联后动态获取。否则后来放映厅改名,所有历史场次都得连带更新,小项目看不出问题,数据量大了就头疼。
座位表(Seat)
座位需要关联到具体的放映厅,同时要有一个全局唯一的座位状态。我在设计时会把“座位是否已售出”和“场次”也关联起来,因为同一个座位在不同场次是独立可售的。所以正确的设计不是Seat表直接标记“已售出”,而是通过ShowtimeSeat这种中间表记录某场次下座位的锁定状态。
class ShowtimeSeat(models.Model): showtime = models.ForeignKey(Showtime, on_delete=models.CASCADE, related_name="seat_map") seat = models.ForeignKey(Seat, on_delete=models.CASCADE) status = models.CharField(max_length=10, choices=SEAT_STATUS, default="available") order = models.ForeignKey("orders.Order", null=True, blank=True, on_delete=models.SET_NULL)这里的status字段是并发控制的关键,配合事务使用,后面详细说。
订单表(Order)
订单表包含用户、场次、座位快照、金额、状态、支付流水号。要注意座位快照——用户下单后,即便管理员调整了放映厅座位图,订单里的座位信息也不能变。所以要在订单表里冗余一份座位编号、放映厅名称、影片名称、场次时间,这些字段从业务角度看是冗余,但从订单数据的不可变性角度看是必须的。
2.2 用Django ORM管理查询与删除:新手最易翻车的细节
数据表建好后,查询和删除是用的最多的操作。热搜词里有“django执行查询-删除对象”,这确实是一个经常踩坑的地方。新手容易犯的错误是循环查询和盲目使用级联删除。
举个例子:展示一个场次的座位图,正确做法是一次查询拿到该场次全部座位状态:
seat_map = ShowtimeSeat.objects.select_related("seat").filter(showtime=showtime)而不是先查座位列表,再在循环里查询每个座位状态。用select_related把外键关系表一次性join出来,数据库只跑一次,这个习惯建议从第一天就养成。
删除对象同样有讲究。Django默认的CASCADE删除会把关联数据一并删掉。在电影票系统里,订单表和支付流水表都是准财务数据,绝对不能因为“用户删除了场次”就把订单也删了。所以我做的是:涉及交易的数据一律不物理删除,只做状态标记。比如取消的场次我改成cancelled状态,选座页直接过滤掉这个状态的场次,但历史订单还能查得到,财务对账也拿得到数据。
# 错误示范:直接删除会导致历史订单查无此人 Showtime.objects.filter(id=showtime_id).delete() # 正确做法:软删除标记 Showtime.objects.filter(id=showtime_id).update(status="cancelled")坦白说,这种“不删数据只改状态”的习惯在实际职场里非常重要。线上系统里物理删除的权限往往被严格限制,因为数据是需要审计追溯的。
2.3 数据表之间的约束:数据库外键与索引
光靠ORM层的逻辑约束是不够的。我给ShowtimeSeat建了联合唯一约束,保证同一个场次下同一个座位只能出现一条记录:
class Meta: constraints = [ models.UniqueConstraint(fields=["showtime", "seat"], name="unique_showtime_seat") ]另外,外键字段和经常用于查询的字段(如showtime_id、status、created_at)建议加索引。很多新手看测试环境数据量小,查什么都快,就忽略了索引设计。一旦线上某个热门场次有几万条座位记录、每天几十万订单,没有索引的查询能把数据库拖垮。实测下来,给orders_order.showtime_id和orders_order.user_id加上联合索引之后,订单列表查询从800ms降到50ms左右,差距非常明显。
3. 核心购票链路:选座、锁座、下单、释放座位的完整实现
这是整个系统最核心的部分。我直接讲实现方案和踩过的坑,不绕弯子。
3.1 选座接口:返回全量座位数据
前端进入选座页时,后端需要返回该场次所有座位状态。接口路径大致是/api/showtimes/<id>/seats/,返回JSON:座位编号、行号、列号、状态、票价档位。这里有一个性能细节:座位图数据是典型的“一次查询、局部变更”,我为每个场次生成了一份座位状态列表,存在Redis里,前端请求直接从Redis读取,打不到数据库。
3.2 锁定座位:用数据库行锁解决超卖
座位锁定的核心问题:多个用户同时点击同一个座位,系统只允许一个人成功。Xhr请求并发场景下,传统“先查询状态再更新状态”的写法是必然出问题的。
正确解法是用Django的select_for_update()配合事务,把ShowtimeSeat那一行数据锁住,然后再做状态判断和更新:
import django.db.transaction from django.db import transaction @transaction.atomic def lock_seat(showtime_id, seat_id, user): locked_seat = ShowtimeSeat.objects.select_for_update().get( showtime_id=showtime_id, seat_id=seat_id ) if locked_seat.status != "available": raise SeatUnavailable("座位已被锁定或售出") locked_seat.status = "locked" locked_seat.locked_by = user locked_seat.locked_at = timezone.now() locked_seat.save()select_for_update()会在数据库层面锁住这行记录,第二个请求必须等第一个事务提交或回滚后才能继续。这在MySQL InnoDB引擎下是可靠的。但使用时有几个前提要注意:
- 必须在事务内使用,
@transaction.atomic不能少。 - 对应的数据库表必须使用支持行级锁的引擎(InnoDB),MyISAM虽然也能跑但锁粒度为表级,高并发下单直接锁死。
- 事务要保持短小,锁行之后不要执行远程调用或耗时操作,否则严重影响吞吐量。
3.3 订单创建与支付链接生成
座位锁定成功后,创建订单。订单表里除了基本字段外,我设计了expire_time字段,默认是创建时间加15分钟。用户必须在15分钟内完成支付,到期未支付则订单自动取消、座位释放。
生成支付链接这里要说一下。对接第三方支付(微信/支付宝)时,需要下单参数:订单号、订单金额、商品描述。金额单位换算是最容易错的地方——比如微信支付以“分”为单位,支付宝新版接口以“元”为单位,写错一个单位钱就对不上。我的做法是在配置文件中定义好金额单位常量,支付下单前统一转换,并且写了一个单元测试用例专门验证金额转换逻辑。
3.4 超时释放:为什么不能用定时扫描硬删
座位超时未支付需要释放。很多初学者第一反应是“起一个定时任务,每5分钟扫描一次过期订单,然后释放座位”。这个方案能跑,但会有两个问题:一是释放不及时,用户明明超过10分钟了还在高并发场景下占着座位;二是释放操作如果直接调用“删除”接口,容易误删刚完成支付但回调还没到达的订单。
我的实现是用Redis做订单过期key监听:创建订单时把order_id存进Redis并设置过期时间为15分钟,当key过期时触发回调,回调里检查订单状态——只有状态还是“待支付”时才执行取消和释放座位。如果用户已经支付成功,状态已经是“已支付”,回调直接忽略。
伪代码逻辑大致是:
def release_expired_order(order_id): order = Order.objects.select_for_update().get(id=order_id) if order.status != "PENDING": # 已支付或已取消直接返回 return order.status = "CANCELLED" order.save() ShowtimeSeat.objects.filter(order=order).update(status="available", order=None)使用Redis过期监听的好处是时效性高,不会出现“系统扫描发现已超时但还要等下一轮定时任务”的空窗期,同时靠订单状态二次确认,避免了支付回调之间的竞态条件。
3.5 支付回调:幂等是铁律
用户支付成功后,微信或支付宝会向我们的回调URL发送通知。这个通知可能来一次,也可能因为网络抖动来好几次。所以在回调处理函数里,第一件事就是检查订单当前状态:
if order.status == "PAID": return JsonResponse({"code": "SUCCESS"})只有待支付状态才执行支付成功流程:更新订单状态、生成电子票、把锁定的座位标记为已售出。这里生成的电子票我给了一个随机唯一编码,用户可以在“我的订单”里查看二维码信息。
4. 会话与安全:Cookie、Token、CSRF防护的Django实战
网站不只是“能买票”,还得保证“只有登录用户才能买票”“用户不能篡改订单金额”“站点不被CSRF攻击”。这部分我把Cookie、Token和Django安全机制串起来讲清楚。
4.1 Django里如何设置和读取Cookie
用户登录成功后,需要把用户身份标记种到浏览器。Django默认的SessionMiddleware就是通过Cookie保存会话ID实现的。登录接口里可以用Django内置方法:
from django.contrib.auth import login def user_login(request): user = authenticate(username=username, password=password) if user: login(request, user) return JsonResponse({"code": 200})如果想自定义Cookie,比如在登录成功后给前端种一个user_token,可以这样:
response.set_cookie( key="user_token", value=token_string, max_age=7 * 24 * 3600, # 7天有效 httponly=True, # JS无法读取,防XSS窃取 secure=True, # 仅HTTPS下传输 samesite="Lax" # 防止CSRF跨站请求携带Cookie )这几个参数每个都有讲究,简单解释:
max_age控制过期时间,登录态一般7~30天,短了用户频繁登录,长了安全性下降。httponly是防XSS的关键,很多前端XSS攻击就是为了拿到用户Cookie。samesite和CSRF防护相关,Lax模式对POST请求已经起到了跨站拦截作用。
4.2 移动端场景下的Token方案
纯浏览器场景用Session Cookie没问题,但如果你的前端是独立部署的小程序或App,我更推荐用Token认证。常见做法是登录成功后签发JWT,前端把Token存在本地存储中,每次请求在Header里带上Authorization: Bearer <token>。
自己写JWT解析中间件也不难:
import jwt class JWTAuthenticationMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) request.user_id = payload["user_id"] except jwt.ExpiredSignatureError: return JsonResponse({"code": 401, "msg": "登录过期"}, status=401) return self.get_response(request)注意JWT有个细节:默认的JWT是无状态的,签发后无法在服务端主动失效。用户改密码后,旧Token理论上仍然有效。所以我把JWT的jti字段和Redis里存储的用户Token版本号做绑定,密码修改时递增版本号,旧Token即便解码成功,版本比对不通过仍会被拒绝。这个优化在实际项目里很必要,尤其是涉及支付功能的系统。
4.3 CSRF与请求校验:不是有Token就万事大吉
Django默认开启了CSRF中间件,模板里需要在表单中添加{% csrf_token %}。使用API接口时,需要先从Cookie中读取csrftoken,然后在请求头里携带X-CSRFToken。这个流程很多人忽略了,导致POST请求一直403。
我的一般做法是写一个前端公共函数,在发起Ajax请求时自动带上CSRF头:
function getCookie(name) { let cookieValue = null; if (document.cookie && document.cookie !== '') { const cookies = document.cookie.split(';'); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.substring(0, name.length + 1) === (name + '=')) { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } } } return cookieValue; }还有个很多人不知道的细节:如果API接口是无状态的JWT认证模式,就不需要依赖Session,这时可以把CSRF_COOKIE和SessionMiddleware排除在外,用@csrf_exempt装饰纯API视图,或者直接自定义一个不启用CSRF的中间件配置。否则线上环境因为配置冲突,会出现登录失效或403时好时坏的灵异问题。
4.4 敏感操作与金额校验
支付类系统对金额和商品的篡改要防得很严。用户发出下单请求时,后端绝对不能信任前端传过来的金额,必须从数据库重新计算价格。我在订单创建接口里只接收场次ID和座位ID列表,所有金额都通过查询票价表计算得出,再和前端展示金额做一致性比较,避免“改价格薅羊毛”的低级漏洞。
5. 前端交互与页面集成:选座组件、倒计时提醒和票档选择
后端逻辑再完善,页面交互跟不上,用户一样会流失。这一部分说说我在模板页面里怎么和Django视图配合做得顺畅。
5.1 页面结构与视图跳转
项目我采用的是前后端不分离的Django模板方案,页面包括:
- 首页:正在热映电影列表
- 电影详情页:影片介绍、影院列表
- 选座页:座位图、票档选择、倒计时锁座区
- 订单确认与支付页:订单信息确认、生成支付二维码
- 我的订单页:订单列表、电子票查看
路由配置方面,每个app独立urls.py,主工程统一 include。建议不要把所有路由堆在主urls里,否则后期新增功能会让你觉得路由文件特别臃肿。
5.2 选座组件的实现思路
选座页是交互最重的页面。座位图按行渲染,每行一个按钮组。我用了一个相对讨巧的方案:座位数据全部由后端渲染成HTML片段返回,前端只做局部刷新的区域替换,不在前端维护复杂的座位数据结构。这有什么好处?选座逻辑、状态判断全在后端,前端只需要展示结果和接收点击请求,业务逻辑不会因为前端页面刷新而丢失。
用户点击座位时,前端发Ajax请求给后端锁定接口,后端返回成功或失败。失败时(比如座位刚被别人买了)直接提示并刷新座位图;成功时把座位标记为黄色选中状态,同时把按钮禁用。
倒计时功能是这样的:订单创建后,后端返回expire_time,前端根据当前时间计算剩余秒数,每秒钟更新页面上的倒计时数字。时间归零时,前端自动刷新座位图并提示用户“订单已超时释放”。这个倒计时不需要太复杂,前端显示,后端有Redis兜底,两边的过期逻辑各管各的,不会出现前端显示还有10秒但后端座位已释放的严重错位。
5.3 票档设计:价格与座位区域绑定
票价不是一个简单的单一价格字段,而是通过“票档”来管理的:一等座、二等座、情侣座等,每个票档对应不同区域、不同价格。为了灵活排片,我设计了TicketType表关联到具体场次,前端选座时把座位和票档绑定。
有个细节:同一个座位在不同票档下价格不同,所以用户在选座时选了座位后,再选票档,后端要根据组合来校验该座位是否支持这个票档。我在ShowtimeSeat旁边加了区域字段,座位区域的归属直接取放映厅的区域配置,这样选座和选票档之间不会产生数据冲突。
6. 调试、踩坑与部署:新手最容易翻车的地方
最后这部分,我把整个项目从开发到上线的过程中遇到的那些“不试不知道”的问题集中过一遍,每一条都是真金白银换来的经验。
6.1 本地开发环境的配置与迁移
第一次初始化数据库时,记得按顺序执行:
python manage.py makemigrations python manage.py migrate python manage.py createsuperuser python manage.py runserver新手最常见的报错是“Table already exists”,这通常是因为手动在数据库里建过表,或者迁移记录被误删。解决方式很简单:如果数据不重要,直接删掉数据库重新migrate;如果数据重要,可以用python manage.py migrate --fake appname把迁移记录标记为已执行,但这个方法需要你确定数据库结构和模型完全一致,否则后续会有隐藏问题。
还有一点,Django 3.2+ 的默认主键字段是BigAutoField,老版本迁移过来的项目要注意主键类型,否则插入数据时可能报bigint out of range。
6.2 时间时区问题:电影票日期错了是事故
电影票系统对时间的敏感度极高。我遇到过这样的问题:用户买晚上的票,后台看到的支付时间是第二天凌晨,订单日期显示错误。原因是在settings.py里没有设置好TIME_ZONE和USE_TZ。
正确的做法是USE_TZ = True,数据库统一存UTC时间,展示给用户时再转换到当前时区。模板里用Django的模板过滤器{{ showtime.start_time|date:"Y-m-d H:i" }}可以自动转成当地时间,但接口返回JSON时就要手动转:
from django.utils import timezone from django.conf import settings local_time = timezone.localtime(showtime.start_time)实测下来,时区配置问题在前后端分离项目中尤其容易出bug——前端拿到一个UTC字符串,直接用new Date()解析,不同浏览器解析结果还可能不一样,一定要在后端统一转成带时区的ISO字符串或时间戳。
6.3 静态文件与生产环境部署
本地开发时runserver会自动处理静态文件,但生产环境完全不是一回事。我用的是gunicorn + nginx部署,Django的静态文件需要先收集:
python manage.py collectstatic然后nginx里配置location /static/指向STATIC_ROOT目录。很多新手在这里卡很久,页面样式全丢了,原因就是忘了collectstatic,或者nginx的路径和Django配置不匹配。
另外,生产环境必须关闭DEBUG,否则访问不存在的URL时会暴露完整的报错信息和代码路径,这在线上是绝对不允许的。
6.4 源码包84025的目录结构说明
这个项目的源码包编号是84025,我按标准Django工程结构整理:
ticket_system/ ├── manage.py ├── config/ # 项目配置与主路由 ├── apps/ │ ├── users/ # 用户认证与会员系统 │ ├── movies/ # 电影、影院、场次、座位管理 │ ├── orders/ # 订单、支付回调、座位锁定 │ └── payments/ # 第三方支付对接 ├── templates/ # 前端模板 ├── static/ # CSS、JS、图片 └── requirements.txt之所以把支付单独拆一个app,是因为支付回调逻辑往往比下单逻辑还复杂,单独管理不会污染订单模块。源码包里已经包含了完整的迁移文件、初始化数据脚本和一个可直接运行的SQLite数据库,clone下来之后迁移一下就可以跑起来。
6.5 线上环境最容易忽略的配置项
有几个配置我建议直接在项目里预设好,避免上线后返工:
ALLOWED_HOSTS:明确指定域名,不要用*,尤其是涉及支付回调的接口,容易被伪造Host头。SECURE_SSL_REDIRECT:线上开启强制HTTPS跳转。SESSION_COOKIE_SECURE和CSRF_COOKIE_SECURE:HTTPS下需要设为True,否则浏览器拒绝在HTTPS页面发送不安全Cookie。DATA_UPLOAD_MAX_MEMORY_SIZE:如果有头像上传功能,建议设置合理上限,防止内存耗尽。
这些配置项并不是“越多越好”,而是每一层防护都对应一种真实攻击场景。做支付类系统,安全配置永远不是可有可无的“炫技操作”。
从数据建模到并发锁座,从Token认证到线上部署,这套Django在线电影票购买系统的核心链路算是完整走了一遍。我一直觉得,能跑通一个完整业务闭环项目带来的提升,比刷十遍教程都大——因为整个过程中踩过的坑、想过的取舍、查过的文档,才是真正长在身上的能力。如果你手头也正在做类似的项目,建议别急着抄代码,先把订单状态流转画清楚,把座位锁定的并发问题想明白,再动手写,后面会顺很多。