ARTICLE DETAIL

资讯详情

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

基于Django的滑雪场售票系统设计与实践:从数据库建模到并发扣库存

基于Django的滑雪场售票系统设计与实践:从数据库建模到并发扣库存 做滑雪场售票系统这个项目老实说一开始我没太放在心上觉得无非就是选票种、下单、支付那一套等真接手了才发现滑雪场门票背后全是约束条件不同时段价格不同节假日要加价票种还分全天、夜场、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_length64) price models.DecimalField(单价, max_digits8, decimal_places2) total_stock models.PositiveIntegerField(总库存, default0) sold_count models.PositiveIntegerField(已售数量, default0) single_limit models.PositiveIntegerField(单笔限购, default5) period_type models.CharField(时段类型, max_length16, choicesPERIOD_CHOICES, defaultall_day) valid_weekdays models.JSONField(可用星期, defaultlist, blankTrue) start_date models.DateField(售卖开始日期, nullTrue, blankTrue) end_date models.DateField(售卖结束日期, nullTrue, blankTrue) is_active models.BooleanField(是否上架, defaultTrue) 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_length32, uniqueTrue) user models.ForeignKey(User, on_deletemodels.PROTECT, verbose_name用户) ticket_type models.ForeignKey(TicketType, on_deletemodels.PROTECT, verbose_name票种) quantity models.PositiveIntegerField(购买数量, default1) amount models.DecimalField(订单金额, max_digits10, decimal_places2) status models.CharField(订单状态, max_length16, choicesSTATUS_CHOICES, defaultpending) created_at models.DateTimeField(创建时间, auto_now_addTrue) paid_at models.DateTimeField(支付时间, nullTrue, blankTrue) def __str__(self): return self.order_no票种表里我故意用sold_count记录已售数量而不是直接在total_stock上减这样做的好处是每天对账时能清楚地看到这个票种总共卖了多少张万一有超卖或者退款争议可以通过已售数量加总去核对。DecimalField是用来存价格的这个很重要如果用 FloatField 存金额计算到小数位时会遇到精度问题比如 0.1 加 0.2 得到 0.30000000000000004到时候对账就头疼了。外键我都用的是on_deletemodels.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: 请先登录}, status401) 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(pkticket_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 fSKI{timezone.now():%Y%m%d%H%M%S}{request.user.id} order Order.objects.create( order_noorder_no, userrequest.user, ticket_typeticket_type, quantityquantity, amountticket_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: 请先登录}, status401) order get_object_or_404(Order, pkorder_id, userrequest.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_updatedef refund_order(request, order_id): with transaction.atomic(): order Order.objects.select_for_update().get(pkorder_id, userrequest.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(pk1).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 django4.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 解释器切到当前虚拟环境快捷键CtrlShiftP输入 Python: Select Interpreter选择刚才创建的venv。这一步没做运行代码时就会惊讶为什么安装好的 Django 还是找不到模块。开发阶段数据库我用的 SQLitesettings.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:application3 个 worker 对小型售票系统足够了。如果用了宝塔面板它有 Python 项目管理器可以帮你把项目路径、Python 版本、启动命令都配置好再把 Nginx 的站点指过去省心不少。无论哪种方式上线前必须把DEBUG False设好否则程序报错会把详细信息全部暴露给访问者这是安全大忌。5.3 问题排查实录与避坑清单整个项目里我碰到过不少报错挑几个典型的说。第一个是NoReverseMatch用reverse(order_detail, args[order.id])的时候经常出现因为它跟 urls.py 里的命名空间有关。如果你的项目里有多个 appurls.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 写代码只是表面工作真正有价值的是把业务规则想清楚。库存怎么扣、订单状态怎么转、退款边界在哪里这些问题没理清之前写再多代码都会返工。我后面再碰到类似的售票类需求都会先在纸上把状态流转和并发流程图草稿画出来再开始建模型。经验告诉我数据库建模阶段多花一个小时后面业务逻辑能省出好几天。另外一个心得是不要害怕在项目里反复调试并发问题你第一次遇到超卖、死锁这些情况时越早越好的因为这些问题在工作以后一定还会遇到。
返回列表