做滑雪场售票系统这个项目,老实说一开始我没太放在心上,觉得无非就是选票种、下单、支付那一套,等真接手了才发现,滑雪场门票背后全是约束条件:不同时段价格不同,节假日要加价,票种还分全天、夜场、4小时,库存要跟着场次实时扣减,退票改签又有各种规则。用 Python 写后端,Django 算是做这类业务系统非常顺手的框架,我最后把方案定成了 Django 4.2 + MySQL 8.0 的组合,从数据库建模到接口开发,再到部署上线,整个过程踩了不少坑,也沉淀了一套可以直接复用的设计思路。如果你也想做类似的售票系统、预约系统,或者刚学完 Django 想找一个完整项目练手,这篇应该能省下你很多试错时间。
1. 项目整体设计与需求拆解
1.1 滑雪场售票到底在卖什么
滑雪场卖的票和普通商品不一样,普通商品下单时只要判断库存够不够就行,滑雪场门票则要同时满足多个条件:票种是否在有效期内、当前日期是不是周末或节假日、购买数量有没有超过单人限购数、这个时段是否还有余票。举个例子,客人想买周六上午的4小时滑雪票,系统先得判断周六是否属于“平日/周末/节假日”这个票种规则里的周末类型,再判断4小时票剩余量够不够,还要看周六当天是否在票的售卖截止日期内。
如果只把“票种”设计成一个简单的商品表,到了高峰期就会出现明明库存已经扣光,订单却还在生成的情况。所以需求拆解阶段,我就把票拆成了两层概念:底层是基准定价的票种,上层是具体到某个使用日期的场次库存。这样既保留“周末全天票”这种大家能理解的票种名称,又能精确控制某一天的余票数量。这个思路是整个系统能不能扛住真实运营的关键,比选什么前端框架都重要。
1.2 为什么选择 Django 而不是 Flask
我在这个项目里没有纠结太久就选了 Django,原因很朴素:项目需要后台管理界面,需要用户登录注册,需要订单数据查询,还需要一个能跑数据迁移的 ORM 来折腾数据库。这些需求在 Django 里都是自带能力,Admin 后台稍微配一下就能给运营人员用,Auth 模块天然支持登录和会话管理,ORM 的迁移工具也能让我在开发阶段随时改表结构。如果用 Flask,光是把这些基础能力拼齐就要花不少时间,更别说权限控制和数据校验要自己造轮子。
当然 Flast 框架本身也是一个选择,比如只要做一个非常轻量的接口服务,不涉及后台管理,Flask 确实更轻巧。但滑雪场售票系统既要有用户端购票页面,又要有运营端管理库存、查订单、做退款,Django 的“全家桶”模式在这里优势非常明显。另外 Django 社区活跃,遇到问题搜一下基本都有解决方案,这一点在项目开发中能节省大量时间。
1.3 技术栈与版本选定
开发环境我用了 Python 3.10 + Django 4.2 LTS 版本,数据库本地开发用的 SQLite,上线切到 MySQL 8.0。为什么选 4.2 而不是追最新的版本?因为 LTS 版本官方会持续维护修补安全漏洞,这对涉及支付和用户信息的系统来说很重要。Django 4.2 对 JSONField、Redis 缓存、异步视图都有比较好的支持,接下来就算要扩展功能也撑得住。
前端部分没有刻意上 Vue React,直接用 Django 模板加 Bootstrap 5 完成页面,因为售票系统主要场景是购票流程和后台管理,模板渲染已经够用,维护成本也低。订单并发控制这一块,我后来引入了 Redis 做缓存和分布式锁,不过核心的库存扣减最终还是靠 MySQL 的行级锁来完成,这个后面会详细讲。危险的是团队如果有好几个人同时在改代码,Python 依赖版本不一致就很头疼,所以项目一开始就要用 venv 管理环境,并且把 requirements.txt 固定到一个能跑通的版本组合上。
2. 数据库设计与模型搭建
2.1 实体关系拆解
数据库是整个系统的地基,我把核心实体拆成了四张主表:用户表、票种表、订单表、订单明细表。用户表直接继承 Django 自带的 User 表,省去了注册登录模块的大部分工作。票种表存的是票种的基础信息,比如名称、基准价格、类型、总库存、单笔限购数量。订单表记录每笔交易的主信息,包括订单号、用户、总金额、支付状态、支付时间。订单明细表虽然在这个项目里看起来不是必须的,但我还是建了,因为未来如果要做套票、加购保险或者雪具租赁,订单和票种之间就是一对多关系,有明细表才不会被迫改表结构。
我在实际设计里还加了一张库存表,单独记录每个票种在某一天的可用数量。因为很多票种会设置“仅限本雪季使用”或者“周末票不可用”这样的规则,把可用日期范围留在票种表里,再结合库存表去判断,查询逻辑会清晰得多。用 Django 的 JSONField 存休息日规则很方便,不过要注意不同数据库兼容性,MySQL 8 支持没问题,如果是低版本就得另想办法。
2.2 核心模型代码实现
直接贴我当时精简过的核心模型代码,你可以照着建:
from django.db import models from django.contrib.auth.models import User class TicketType(models.Model): PERIOD_CHOICES = [ ("all_day", "全天"), ("half_day", "半天"), ("night", "夜场"), ] name = models.CharField("票种名称", max_length=64) price = models.DecimalField("单价", max_digits=8, decimal_places=2) total_stock = models.PositiveIntegerField("总库存", default=0) sold_count = models.PositiveIntegerField("已售数量", default=0) single_limit = models.PositiveIntegerField("单笔限购", default=5) period_type = models.CharField("时段类型", max_length=16, choices=PERIOD_CHOICES, default="all_day") valid_weekdays = models.JSONField("可用星期", default=list, blank=True) start_date = models.DateField("售卖开始日期", null=True, blank=True) end_date = models.DateField("售卖结束日期", null=True, blank=True) is_active = models.BooleanField("是否上架", default=True) @property def available_stock(self): return self.total_stock - self.sold_count def __str__(self): return self.name class Order(models.Model): STATUS_CHOICES = [ ("pending", "待支付"), ("paid", "已支付"), ("used", "已使用"), ("refunded", "已退款"), ("cancelled", "已取消"), ("expired", "已过期"), ] order_no = models.CharField("订单号", max_length=32, unique=True) user = models.ForeignKey(User, on_delete=models.PROTECT, verbose_name="用户") ticket_type = models.ForeignKey(TicketType, on_delete=models.PROTECT, verbose_name="票种") quantity = models.PositiveIntegerField("购买数量", default=1) amount = models.DecimalField("订单金额", max_digits=10, decimal_places=2) status = models.CharField("订单状态", max_length=16, choices=STATUS_CHOICES, default="pending") created_at = models.DateTimeField("创建时间", auto_now_add=True) paid_at = models.DateTimeField("支付时间", null=True, blank=True) def __str__(self): return self.order_no票种表里我故意用sold_count记录已售数量,而不是直接在total_stock上减,这样做的好处是每天对账时能清楚地看到这个票种总共卖了多少张,万一有超卖或者退款争议,可以通过已售数量加总去核对。DecimalField是用来存价格的,这个很重要,如果用 FloatField 存金额,计算到小数位时会遇到精度问题,比如 0.1 加 0.2 得到 0.30000000000000004,到时候对账就头疼了。外键我都用的是on_delete=models.PROTECT,因为订单是历史数据,不能让票种被删掉后订单变成孤儿记录。
2.3 建表时最容易忽略的两个细节
第一个细节是订单号一定要自己生成,不要用自增主键直接当订单号暴露给用户。Django 默认给主键分配 id,如果有人遍历请求/order/1/、/order/2/,就能大概推算出平台一天的订单量,这个信息没必要泄露。我这里简单生成了“SKI + 时间戳 + 用户id”的订单号,虽然并发量高时理论上可能重复,但加上用户 id 后重复概率很低,真要严格的话可以再拼一个随机数。
第二个细节是票种表里的valid_weekdays,我用列表类型存储,比如[0, 1, 2, 3, 4]表示周一到周五可用。项目早期这个地方我用的是逗号分隔的字符串,结果判断哪天可用的时候要切字符串再类型转换,代码丑还容易错。改成 JSONField 以后,直接用 Python 的列表判断,配合date.weekday(),一条判断语句就能完成。如果你用的是 MySQL 5.7 以上版本,JSONField 是完全没问题的,字段设计一定要以好读、好写为准。
3. 核心业务逻辑与接口实现
3.1 卖票不等于改库存,并发才是重点
如果只是把购票接口写成“查询余票、减库存、创建订单”,平时没多少并发的时候确实能跑得通,但滑雪场周末高峰期很可能一秒钟涌入几十个购票请求,这时候就很容易出现超卖。原因很简单:两个请求同时读到余票剩下 3 张,都判断可以买,结果都各自减了库存,最后票卖出去 6 张但库存只有 3 张。这就是经典的并发问题,解决方案要么用数据库锁,要么用 Redis 分布式锁。
我的做法是用 Django 的事务加行级锁,直接给票种记录加锁,让同一时刻只有一个请求能修改这条库存数据。关键代码长这样:
from django.db import transaction from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django.utils import timezone def buy_ticket(request): if request.method != "POST" or not request.user.is_authenticated: return JsonResponse({"code": 401, "msg": "请先登录"}, status=401) ticket_id = request.POST.get("ticket_id") quantity = int(request.POST.get("quantity", 1)) if quantity <= 0: return JsonResponse({"code": 400, "msg": "数量不合法"}) with transaction.atomic(): ticket_type = ( TicketType.objects.select_for_update().get(pk=ticket_id) ) if not ticket_type.is_active: return JsonResponse({"code": 400, "msg": "票种已下架"}) if ticket_type.available_stock < quantity: return JsonResponse({"code": 400, "msg": "余票不足"}) if quantity > ticket_type.single_limit: return JsonResponse({"code": 400, "msg": "超出单笔限购数量"}) order_no = f"SKI{timezone.now():%Y%m%d%H%M%S}{request.user.id}" order = Order.objects.create( order_no=order_no, user=request.user, ticket_type=ticket_type, quantity=quantity, amount=ticket_type.price * quantity, ) ticket_type.sold_count += quantity ticket_type.save() return JsonResponse({"code": 200, "order_id": order.id})这里select_for_update()会在数据库层面生成SELECT ... FOR UPDATE,把选中的票种记录锁住,其他要操作同一条记录的事务只能排队等待,这样就避免了超卖。注意这个方法只能在事务里用,所以我用transaction.atomic()包住整个操作。实际测试时,我本地用并发脚本发了 50 个抢购请求,最后库存扣减和订单数完全对得上。
3.2 订单状态机与支付流转
订单状态不能只靠一个字段到处改,那样很容易把状态改乱。我定义了六个状态:待支付、已支付、已使用、已退款、已取消、已过期,各个状态之间有严格的转换方向。待支付订单只能去支付或者取消,已支付订单只能被使用或者退款,已经退款的订单不能重复退款,已取消的订单也不能再次支付。这个状态机在代码里没有用第三方库,就是写了一个 Python dict 来表示状态转换关系,然后在每次修改状态前做校验。
支付环节我做了支付信息的模拟接口,因为真实接入微信支付或支付宝需要商户号,个人开发者一般没有。我让待支付订单调用接口后直接变成已支付,同时记录支付时间:
def mock_pay(request, order_id): if request.method != "POST" or not request.user.is_authenticated: return JsonResponse({"code": 401, "msg": "请先登录"}, status=401) order = get_object_or_404(Order, pk=order_id, user=request.user) if order.status != "pending": return JsonResponse({"code": 400, "msg": "订单状态异常,无法支付"}) order.status = "paid" order.paid_at = timezone.now() order.save() return JsonResponse({"code": 200, "msg": "支付成功"})真实接入支付时,这里需要处理回调的幂等性:支付平台可能因为网络问题把通知推好几次,后端要判断这个订单是不是已经支付过,防止重复回调导致库存被扣两次或者金额被改。我建议在订单表里加一个payment_no字段,存第三方支付流水号,回调里先查payment_no是否已经存在,存在就直接返回成功,不做二次业务处理。
3.3 退票、过期处理和订单删除的边界
退票看起来是支付的反方向,但它不能简单把订单状态改成已退款,还必须把库存加回去。这里同样要注意并发问题:如果用户已经用了票,或者订单已经过期,就不能再退款了。我在退款视图里同样用了事务和select_for_update:
def refund_order(request, order_id): with transaction.atomic(): order = Order.objects.select_for_update().get(pk=order_id, user=request.user) if order.status != "paid": return JsonResponse({"code": 400, "msg": "只有已支付订单才能退款"}) if order.ticket_type.available_stock + order.quantity > order.ticket_type.total_stock: return JsonResponse({"code": 500, "msg": "库存数据异常,请联系客服"}) ticket_type = order.ticket_type ticket_type.sold_count -= order.quantity ticket_type.save() order.status = "refunded" order.save() return JsonResponse({"code": 200, "msg": "退款成功"})至于订单删除,Django 的 ORM 删除对象很简单,一行Order.objects.filter(pk=1).delete()就能完成,但有外键关联时不能随便删。比如订单关联了票种,票种表设置了PROTECT,删除票种会抛出ProtectedError,这其实是好事,防止误删历史票价信息。真正该做的不是物理删除,而是把订单状态置为cancelled或者expired,保留数据用于后续统计和对账。只有那种测试环境造的脏数据,才值得用 ORM 真正的 delete 去清掉。
4. 前端页面与交互实现
4.1 模板架构和页面规划
前端我用的 Django 模板系统加 Bootstrap 5,后端渲染页面,没有单独搭前端工程。这样做主要考虑项目不大,完全没必要引入 Node.js 构建流程,模板继承机制就够了。我建了一个base.html作为公共骨架,里面放导航栏、页面底部和全局进来的 CSS、JS 文件,然后各个页面通过{% extends "base.html" %}的方式扩展内容块。
页面整体分了四块:首页展示滑雪场信息和当前可购票种,票种列表页展示价格和余票,下单确认页填数量、确认金额、提交订单,订单列表页查看历史订单和操作支付/退款。另外登录和注册页面直接复用 Django 自带模板。实际做着做着就会发现,模板继承能减少大量重复代码,比如导航栏只需要写一次,后续不管加多少个页面,都不用来回复制。
4.2 下单交互与CSRF处理
购票流程我设计的是用户在票种列表页点击“立即购买”后,用 fetch 向后端提交数据,不刷新页面就创建订单并跳到支付确认页。这样体验比传统表单提交顺滑得多。不过用 fetch 提交 POST 请求有一个坑,就是必须带上 CSRF Token,否则 Django 会返回 403。我的做法是把 Cookie 里的csrftoken取出来,放到请求头里:
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; } fetch("/order/buy/", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "X-CSRFToken": getCookie("csrftoken"), }, body: new URLSearchParams({ ticket_id: ticketId, quantity: quantity, }) })如果你用了 CSRF 的 Cookie 功能,还要确认 Django 配置里CSRF_COOKIE_NAME没改过,否则取不到正确名字。前端购物按钮在点击后最好加一个禁用状态,等后端返回结果再恢复,避免用户手快连点,造成好几个重复订单。当然,后端也要有对应的限购和库存校验,前端禁用只是体验层面的第一道缓冲。
4.3 后台管理界面直接复用Django admin
内部运营后台我直接用 Django admin 改造,省掉了自己写管理端的时间。票种、订单、用户这几个模型只要在admin.py里注册一下,后台就能直接增删改查。我还给订单列表配置了按状态筛选、按创建时间排序,运营人员看每天订单量非常方便。
from django.contrib import admin from .models import TicketType, Order @admin.register(TicketType) class TicketTypeAdmin(admin.ModelAdmin): list_display = ("name", "price", "total_stock", "sold_count", "is_active") list_editable = ("price", "total_stock", "is_active") list_filter = ("is_active", "period_type") @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ("order_no", "user", "ticket_type", "quantity", "amount", "status", "created_at") list_filter = ("status", "created_at") search_fields = ("order_no", "user__username")一个比较实用的技巧是把sold_count做成只读字段,不要让运营手动修改,统计数据应该完全由订单操作触发。管理员如果需要处理异常订单,可以通过订单详情进去改状态,但我在后台里把状态字段的修改也限制了一下,让下拉框只能从合法状态里选,避免运营把流程搞乱。
5. 环境配置、部署上线和常见问题
5.1 本地环境准备(VSCode 配置 Python 环境)
本地开发我用 VSCode,第一步是安装 Python 解释器,然后创建虚拟环境:
python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install django==4.2.* pip install mysqlclient # 如果连 MySQL 需要装这个,Windows 上装不了就换 pymysql创建 Django 项目和应用也有固定操作,django-admin startproject ski_resort .创建项目,python manage.py startapp tickets创建 app,记得在 settings.py 的 INSTALLED_APPS 里注册新 app。VSCode 里最重要的一步是把 Python 解释器切到当前虚拟环境,快捷键Ctrl+Shift+P,输入 Python: Select Interpreter,选择刚才创建的venv。这一步没做,运行代码时就会惊讶为什么安装好的 Django 还是找不到模块。
开发阶段数据库我用的 SQLite,settings.py 基本不用改。真要切 MySQL 时,把 DATABASES 配置改成下面的形式:
DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "ski_resort", "USER": "root", "PASSWORD": "your_password", "HOST": "127.0.0.1", "PORT": "3306", "OPTIONS": {"charset": "utf8mb4"}, } }然后先python manage.py makemigrations生成迁移文件,再python manage.py migrate把表建出来。这个过程如果遇到编码问题,优先在数据库连接配置里加上utf8mb4,否则中文数据存进去以后可能会变成乱码。
5.2 线上部署:宝塔/Nginx + Gunicorn + MySQL
上线部署我推荐用 Linux 服务器,面板可以用宝塔,不过你也要明白每一步在干什么。先把数据库从 SQLite 迁到 MySQL,导出和导入数据都要检查一遍字符集。代码上传到服务器后,创建虚拟环境安装依赖:pip install -r requirements.txt,再加一个 Gunicorn 作为 WSGI 服务。
有一个步骤很容易漏,就是静态文件收集。Django 开发时由自己处理静态文件,线上必须执行python manage.py collectstatic,把所有 CSS、JS、图片集中到STATIC_ROOT目录,再交给 Nginx 处理。否则页面样式会全部丢失,接口却正常。
Nginx 反向代理配置大概长这样:
server { listen 80; server_name your-domain.com; location /static/ { alias /path/to/ski_resort/static/; } location / { 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; } }Gunicorn 启动时我习惯用gunicorn -w 3 -b 127.0.0.1:8000 ski_resort.wsgi:application,3 个 worker 对小型售票系统足够了。如果用了宝塔面板,它有 Python 项目管理器,可以帮你把项目路径、Python 版本、启动命令都配置好,再把 Nginx 的站点指过去,省心不少。无论哪种方式,上线前必须把DEBUG = False设好,否则程序报错会把详细信息全部暴露给访问者,这是安全大忌。
5.3 问题排查实录与避坑清单
整个项目里我碰到过不少报错,挑几个典型的说。第一个是NoReverseMatch,用reverse("order_detail", args=[order.id])的时候经常出现,因为它跟 urls.py 里的命名空间有关。如果你的项目里有多个 app,urls.py 里用了app_name = "orders",那 reverse 调用必须写成reverse("orders:order_detail", args=[order.id])。解决思路是先把python manage.py show_urls(需要装 django-extensions)或者直接打开 urls 文件核对路由名,再检查 app_name。
第二个坑是mysqlclient在 Windows 上经常编译失败。我测试机器是 Windows,装了各种依赖还是不行,最后干脆在本地开发用 SQLite,线上再切 MySQL,省下大把时间。如果必须本地连 MySQL,用pymysql然后在一开始初始化pymysql.install_as_MySQLdb()也能解决,不过生产环境我建议还是用 mysqlclient,性能和兼容性更靠谱。
第三个是并发测试的时候发现库存偶尔会扣成负数,排查下来发现是自己最开始没有用select_for_update,后来加上事务锁问题才消失。这里要多说一句,Django 的select_for_update()只在支持行级锁的数据库上才有效,SQLite 上的效果和普通查询没有区别,所以做高并发测试一定要在 MySQL 或者 PostgreSQL 上测,否则会给你一个错误的安全感。
还有一个很常见的问题是后台修改票种价格后,已经支付成功的订单金额没变。这个其实是正确的行为,订单金额应该在生成订单那刻就固定下来,后面票种价格涨了跌了都不应该影响历史订单。如果谁的产品说要动态改历史订单金额,一定要想清楚这一改,财务对账会变成什么样。
最后再分享一点个人体会
做这个项目最大的感受是,用 Django 写代码只是表面工作,真正有价值的是把业务规则想清楚。库存怎么扣、订单状态怎么转、退款边界在哪里,这些问题没理清之前写再多代码都会返工。我后面再碰到类似的售票类需求,都会先在纸上把状态流转和并发流程图草稿画出来,再开始建模型。经验告诉我,数据库建模阶段多花一个小时,后面业务逻辑能省出好几天。另外一个心得是不要害怕在项目里反复调试并发问题,你第一次遇到超卖、死锁这些情况时越早越好的,因为这些问题在工作以后一定还会遇到。