从零搭建一个运动场地预约平台:场上见(YuJu)的技术全栈实践
本文带你走进「场上见」(项目代号 YuJu)——一个基于 Django 6 + DRF 构建的运动场地在线预约平台后端。从手机号/微信登录、选场预订、支付核销的核心闭环,到场馆商品进销存(POS 收银 + 在线商城)与平台运营控制台,我们将从项目背景、技术选型、核心架构到关键实现细节,完整剖析一个真实可部署的单体 API 后端是如何一步步落地的。
一、项目背景与目标
1.1 为什么做这个项目?
随着城市运动消费的兴起,羽毛球馆、篮球场、足球场等场馆的线下排期管理逐渐暴露出痛点:电话预订效率低、现场询问无法看到实时空位、高峰期"撞场"时有发生。与此同时,场馆的饮料、手胶、球拍租售等二次消费仍靠纸笔/收银机记账,库存和营收对不上账;平台方也缺少全局经营视角。我们希望构建一个轻量但完整的线上预约 + 经营系统,打通 用户 → 场馆 → 平台 三方链路,解决从场馆入驻、场地定价、用户选时预订、现场核销,到商品进销存与平台运营管理的全流程问题。
1.2 项目定位
| 维度 | 说明 |
|---|---|
| 产品形态 | 单体 API 后端 + Django 模板页面(先跑通后端,前端可后续独立) |
| 目标用户 | C 端运动爱好者、B 端场馆经营者、平台管理员 |
| 核心场景 | 手机号/短信(或微信小程序)登录 → 浏览场馆 → 选场地选时段 → 在线支付 → 到场核销;场馆商品 POS 收银 / C 端商城下单自提 |
| 非目标 | APP 原生客户端、复杂营销体系、会员等级体系(后续迭代) |
1.3 交付标准
项目落地需满足:
- 功能闭环:用户能完整走通「注册 → 预订 → 支付 → 核销」流程,并延伸到场馆商品采购、收银、商城自提的经营闭环
- 并发安全:高峰期多用户同时下单不会出现超售,商品销售不会出现超卖
- 可运营:平台管理员经控制台查看经营看板、管理用户角色、调整系统设置(含一键关停商城),无需改代码
- 可部署:一键脚本安装 systemd 服务 + Nginx 反代 + Gunicorn,可直接上云
- 可扩展:短信、支付、微信登录、缓存等第三方通道通过适配器 + 配置切换,便于替换
二、技术选型与架构
2.1 技术栈概览
- 后端框架:Django 6.0.3 + Django REST Framework 3.16.1
- 数据库:SQLite(开发/单机生产,WAL 模式)→ 可切换 PostgreSQL
- 鉴权:SimpleJWT(Access + Refresh,支持黑名单轮换)+ 微信小程序 code 登录
- 缓存:LocMemCache(开发)→ FileBasedCache(多 worker 共享)/ Redis(后续)
- 第三方通道:短信 / 支付 / 微信登录均走 Adapter 抽象层 + settings 开关
- 业务模块:users / venues / bookings / notifications,外加 shop(场馆进销存 + C 端商城)与 console(平台控制台)
- 部署:Gunicorn + systemd + Nginx,deploy.sh 一键脚本
- 测试:Django TestCase,364 个用例全绿(含 shop 114 / console 23)
2.2 为什么选 Django 6 + DRF?
- Django 6:首个无 LTS 后缀的"特性版",async 支持、查询优化、类型提示等特性更完善
- DRF:ViewSet + Router 的约定式路由能让 API 开发效率翻倍,
WrappedResponseMixin一行就能实现统一响应包裹 - SQLite:单机部署足够用,WAL 模式下读写并发接近 Postgres 的 90%;升级只需改
settings.DATABASES一段
2.3 整体架构图
客户端层:Django 模板页面(首页/预订页/管理页) / 第三方 APP·小程序(消费 REST API)
│
网关层:Nginx(静态文件 + 反代 + 子路径剥离)
│
应用层(Gunicorn Workers):
URL Router(统一 api/ 前缀 + 无尾斜杠)
→ ViewSet / Generic Views(薄视图:参数解析 + 权限校验)
→ services.py 业务层(预订事务 / 定价引擎 / 入驻审核)
→ shop 进销存(采购 / POS 收银 / 库存流水 / 商城自提)
→ console 平台控制台(数据看板 / 用户权限 / 系统设置)
→ Serializer(输入校验 + 输出序列化)
│
基础设施:
SQLite(WAL) | Cache(LocMem / FileBased / Redis)
SMS Adapter(Console / 阿里云 / 腾讯云)
Payment Adapter(Mock / 聚合支付)
WeChat Adapter(Mock / 小程序 code2session)
2.4 模块划分
YuJu/
├── config/ # 项目配置入口
│ ├── settings.py # 全局配置(JWT、缓存、限流、通道开关)
│ ├── urls.py # 根路由(api/ + 页面路由 + health/)
│ ├── api.py # WrappedResponseMixin 统一响应包裹
│ ├── exceptions.py # 统一错误格式处理
│ └── admin.py # 自定义 YujuAdminSite(未登录重定向 + 分入口)
├── users/ # 用户模块:自定义 User、短信验证码、JWT、微信小程序登录
├── venues/ # 场馆模块:入驻审核、场地、定价规则
├── bookings/ # 预订模块:CourtSlot + BookingOrder、支付、核销
├── notifications/ # 通知模块:SMSAdapter 抽象 + ConsoleSMSAdapter
├── shop/ # 进销存:商品/库存流水/采购/销售(POS + 在线商城自提)/供应商
├── console/ # 平台控制台:数据看板、用户角色管理、SystemSetting KV
├── templates/ # Django 模板(首页/预订/商城/收银台/控制台等 15 页)
├── static/ # 静态资源(图片/样式)
├── manage.py
├── deploy.sh # 一键部署脚本
└── requirements.txt
三、核心功能一览
3.1 三大角色
| 角色 | 标识 | 典型能力 |
|---|---|---|
| 普通用户 | USER_ROLE='user' |
浏览场馆、选时预订、支付、查看订单、取消;商城下单自提 |
| 场馆端 | VENUE_STAFF='venue_staff' |
入驻申请、管理场地/定价规则、核销预订订单、查看场馆统计;商品档案、采购入库、POS 收银(支持扫码枪)、库存盘点、自提核销、供应商对账 |
| 平台管理员 | ADMIN='admin' |
审核入驻、管理任意场馆、使用 Django Admin 后台;平台控制台(经营看板 / 用户角色与启停 / 系统设置) |
3.2 关键业务流程
- 用户打开预订页,前端
GET /api/venues/拉取已审核场馆列表; - 选择场地与日期,
GET /api/venues/courts/{id}/availability?date=...返回可用时段及各时段价格(生成时段窗并过滤已占用); - 锁定时段后登录(手机号 + 验证码):
POST /api/users/sms/send(登录前可调用),服务端生成 6 位码写入缓存(300s)并经短信通道发送; POST /api/users/login提交手机号 + 验证码,服务端校验后立即删除验证码,创建/查询用户并返回{access, refresh};POST /api/bookings/create:事务 +select_for_update行锁 + 联合唯一索引,创建CourtSlot(独占时段)与状态 pending 的BookingOrder,经支付适配器生成预支付参数,返回订单号与预支付参数;- 用户完成支付后
POST /api/bookings/{id}/pay,支付通道确认成功,订单置为paid; - 到场出示核销码,场馆端
POST /api/bookings/verify:订单置used、CourtSlot置released,并经短信通道发送核销通知。
3.3 界面一瞥
▲ 用户端首页(/)——运动主题 Hero 区 + 场馆快捷入口
▲ 场馆端管理页(/venue-courts/)——场地列表、开放状态、定价规则配置
本项目采用「后端 API + Django 模板」的极简架构,所有页面通过同一套 REST API 渲染;后续独立前端(Vue / React)只需替换消费端,后端零改动。
3.4 增值模块:不止于预订
预订闭环跑通后,我们沿「场馆经营」和「平台运营」两条线扩展了两个模块:
shop —— 场馆进销存 + C 端商城
场馆除了卖场地时段,还卖饮料、手胶、球拍租售等商品。模块覆盖完整经营链路:
商品档案(分类 / SKU / 条码)→ 采购入库 → POS 柜台收银 / C 端在线商城下单
→ 到店自提核销 → 退货退款整单回补 → 报损盘点 → 低库存预警 → 经营报表
- 库存以
StockLedger流水为唯一事实来源(采购/销售/退货/盘点/报损五类带符号记录),Product.stock_count只是可重建的冗余余额; - 商品支持
barcode条码字段,POS 页接扫码枪可直接扫条码结算; - 在线商城订单自动生成 6 位自提码,还支持「预订 + 商品」合并支付;
- 供应商按单名称快照,有采购记录的供应商仅可停用(防历史单据失联),并支持按供应商输出采购对账单。
console —— 平台运营控制台
仅平台管理员可访问(IsSuperuser 权限),页面 /console/ 分三 Tab:
- 数据看板:用户 / 场馆 / 预订 / 商城概览 + 每日趋势,
days参数控制区间,低库存用stock_count__lte=F('low_stock_threshold')直接在 DB 层统计; - 用户管理:调整角色、启用/停用账号(禁止操作自己和其他超级管理员),权限映射表只读;
- 系统设置:
SystemSettingDB 级 KV 表,站点名称 / 客服电话 / 公告等is_public项经GET /api/console/site-config免登录输出;mall_enabled业务开关被商城下单接口真实读取,可一键关停。
四、关键实现细节
这一部分只讲思路与教训,不贴代码,展开几个"容易踩坑但必须做对"的技术点。
4.1 并发安全:三道防线防止超售
预订类系统最棘手的问题,是同一时段被两个人同时抢到。我们用了三道防线:
- 事务 + 行级锁:下单事务内先用
select_for_update锁定所有与目标时段存在区间重叠的占用记录,再判重。Postgres/MySQL 下是真正的行级锁,SQLite 下退化为全库串行,将来迁移数据库后自动升级。 - 数据库唯一索引兜底:时段表上建「场地 + 起止时间」联合唯一索引,即使应用层锁失效(如跨库并发),数据库也不会写入重复时段。
- 业务层幂等:同一用户对同一场地同一时段已有有效订单时,直接返回原订单与预支付参数,网络重试不会造成重复下单。
踩坑经验:跨天连订(如 23:00~次日 01:00)产生的是部分重叠而非完全相同时段,精确匹配的唯一索引防不住,必须靠「半开区间重叠判定(已有开始 < 新结束 且 已有结束 > 新开始)+ 行锁」。
4.2 定价引擎:灵活的时段定价规则
给定 court 与 datetime,先按日期类型分流(weekday >= 5 为节假日 HOLIDAY,否则工作日 WORKDAY),再查询启用状态且时间区间覆盖当前时刻的 CourtPriceRule:匹配 weekday 精确值或 weekday 通配(null),命中即取 price;未命中则回退 court.base_price。
匹配优先级:先按工作日/节假日分流,再找启用状态且时间区间覆盖当前时刻的规则;weekday 精确规则优先于通配规则,同优先级取低价;全部未命中则回退场地基础价。
关键教训:Django 中用
field__in=[x, None]表达"精确值或空"是错的——SQL 里NULL IN (...)永不匹配。通配条件必须拆成「字段等于 x」与「字段为空」两个条件做或运算,再用 nulls_last 排序保证精确规则优先。
4.3 短信验证码:防止暴力枚举
6 位数字验证码有 100 万种组合,仅靠 5 分钟有效期并不够。校验策略是:连续输错 5 次,锁定该手机号 10 分钟并作废验证码;验证成功立即删除,保证一次性使用。再叠加 IP 维度的发送限流(30 次/小时)与同手机号 60 秒发送间隔,无论枚举验证码还是刷短信接口都无利可图。
4.4 角色-权限映射:解耦判断
权限点定义为 User 类上的常量(场馆管理、任意场馆管理、入驻审核、商品管理、任意场馆商品管理 5 个),再用一张静态的「角色 → 权限集合」映射表桥接,鉴权时只问一句 has_permission(权限点),超级管理员直接放行。新增进销存模块时没有改动任何判断逻辑——只加两个权限点并挂到对应角色即可。这套业务权限与 Django Admin 内置的 has_perm 完全互不干扰。
4.5 统一响应包裹与异常处理
所有成功响应统一为 {success, code, message, data} 四段结构:通过一个 Mixin 挂在全部视图上,在响应返回前自动包裹;校验错误、权限错误等异常则交给全局异常处理器,从 DRF 的错误结构中提取第一条信息作为 message,失败时 data 恒为 null。前端只需要面对一种响应契约。
4.6 第三方通道适配器:零侵入切换
短信、支付、微信登录全部采用「抽象基类 + settings 开关」模式:
- 短信:开发态 ConsoleSMSAdapter 把验证码打印到服务日志(生产环境翻 systemd 日志就能拿到登录码),上线把 SMS_BACKEND 换成阿里云/腾讯云适配器即可;
- 支付:MockPaymentAdapter 模拟即时支付成功,真实聚合支付实现同一接口替换;
- 微信登录:MockWeChatAdapter 约定
mock:<openid>形式的 code 即可本地跑通小程序登录,上线切换为 code2session 实现,AppID/Secret 由环境变量注入。
业务代码只依赖抽象接口,切换通道不动调用方一行代码。
4.7 库存安全:把「三道防线」复制到商品超卖
库存扣减和时段预订本质相同,都是对有限资源的并发抢占,shop 模块复刻了同一套防护:
- 行锁 + 库存校验:销售事务内逐行锁定商品,再判断库存是否充足;
- 数据库约束兜底:用 CheckConstraint 保证库存永不小于 0,绕过应用层也写不进负库存;同场馆 SKU 唯一、非空条码唯一同样由数据库约束保证;
- 整单原子性:销售单与多条库存变动在同一事务提交,线上支付任一步失败全部回滚。
此外,所有库存变动只追加 StockLedger 流水(采购/销售/退货/盘点/报损五类带符号记录,每条带变动后余额),库存数只是可由流水重建的冗余缓存,两者随时可以对账。退货整单回补库存并走支付适配器退款;低库存只在「从阈值之上跌破阈值」的跨越瞬间发一次预警短信,避免低水位反复销售时重复骚扰。
在线商城还支持「预订 + 商品」合并支付:同一事务内复用预订支付逻辑把待支付订单一并置为已支付,任一步失败整单回滚,不会出现「扣了货却没订到场」。
4.8 DB 级系统设置:运营调配置不发版
站点名称、客服电话、公告这类展示配置,以及 mall_enabled 商城开关这类业务开关,如果写在配置文件里,每次调整都要改代码、重启服务。我们用一张 KV 表(SystemSetting)把运营配置搬进数据库:
- 公开配置免登录读:site-config 接口只输出标记为公开的项,首页/登录页无需登录即可拿到站点名、公告、客服电话;
- 业务开关真实生效:商城下单前读取
mall_enabled,为 false 直接拒单——管理员在控制台一键关停,不发版、不重启; - 种子数据迁移化:初始设置项由数据迁移写入;批量更新遇到未知 key 直接返回 400,防止拼错键名产生脏配置。
五、数据模型关系一览
实体关系(1:N)
- User 1—N Venue(owns)、BookingOrder(places)、CourtSlot(books)、SalesOrder(buys)
- Venue 1—N VenueCourt、BookingOrder(serves)、Product、Supplier、PurchaseOrder、SalesOrder
- VenueCourt 1—N CourtPriceRule、CourtSlot、BookingOrder
- CourtSlot N—1 BookingOrder(bound_by)
- ProductCategory 1—N Product;Product 1—N StockLedger、PurchaseItem、SalesItem
- Supplier 1—N PurchaseOrder;PurchaseOrder 1—N PurchaseItem;SalesOrder 1—N SalesItem
核心实体关键字段
| 实体 | 关键字段 |
|---|---|
| User | phone(UK 手机号)、role(user/venue_staff/admin) |
| Venue | name、status(pending/approved/rejected)、commission_ratio(默认 0.10) |
| VenueCourt | name、is_open、base_price |
| CourtPriceRule | day_type(workday/holiday)、weekday(0-6 或 null 通配)、start_time、price |
| CourtSlot | start_time、end_time、status(reserved/released) |
| BookingOrder | order_no(UK)、status(pending_payment/paid/cancelled/used/expired)、amount、verify_code(UK) |
| Product | sku(venue 内唯一)、barcode(venue 内非空唯一)、stock_count(Check >= 0)、sale_price、low_stock_threshold |
| StockLedger | change_type(purchase/sale/refund/adjust/damage)、quantity(带符号)、balance_after(对账凭据) |
| Supplier | name、is_active(有采购记录仅可停用) |
| PurchaseOrder | order_no(UK,PO 前缀)、supplier_name(按单快照)、total_amount、status(done/cancelled) |
| SalesOrder | order_no(UK,SO 前缀)、channel(pos/online)、status(paid/refunded)、pickup_code(UK,在线单自提码) |
| SystemSetting | key(UK)、value(JSON)、is_public(公开项免登录输出) |
六、使用指南
6.1 环境准备
# Python >= 3.12
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
6.2 本地启动
# 首次运行
python3 manage.py migrate # SQLite 自动创建 db.sqlite3(WAL 模式)
python3 manage.py check # 静态检查,确认无警告
python3 manage.py createsuperuser # 按提示输入手机号/密码
# 启动开发服务器
python3 manage.py runserver
# 访问 http://127.0.0.1:8000/ 查看首页
# 健康检查:http://127.0.0.1:8000/health/
# 其他页面:/book/ 预订、/mall/ 在线商城、/venue-home/ 场馆端、
# /shop/pos/ 收银台、/console/ 平台控制台(管理员)
6.3 运行测试
python3 manage.py test
# 输出示例:
# Ran 364 tests in 73.5s
# OK
# 分模块:users 46 / venues 76 / bookings 29 / notifications 6
# / config 70 / shop 114 / console 23
6.4 一键部署(生产)
# 克隆到服务器后
sudo bash deploy.sh --install # 安装依赖 + 迁移 + collectstatic
sudo bash deploy.sh --daemon # 注册 systemd 服务 + 重启 + 状态检查
sudo bash deploy.sh --nginx-install # 生成 Nginx 反代片段
# 部署完成后服务跑在 http://127.0.0.1:8001
6.5 API 快速体验
核心链路三个请求(验证码在 Console 通道下直接打印到服务日志):
# 发送验证码 → 用验证码换 JWT → 带 Token 访问
curl -X POST .../api/users/sms/send -d '{"phone":"13800000000"}'
curl -X POST .../api/users/login -d '{"phone":"13800000000","code":"日志中的6位码"}'
curl -H "Authorization: Bearer <ACCESS>" .../api/venues/
其余端点:免登录的 api/console/site-config(公开站点配置)、api/venues/courts/{id}/availability(场地时段),以及 api/bookings/create(创建订单)、api/bookings/{id}/pay(支付)、api/bookings/verify(核销)。
6.6 Django Admin 后台
# 创建一个超级管理员
python3 manage.py createsuperuser
# 登录入口:http://127.0.0.1:8000/login/ → 管理员入口
# 登录后跳转 Django Admin:http://127.0.0.1:8000/admin/
七、踩坑记录与经验总结
| # | 问题 | 根因 | 解决方式 |
|---|---|---|---|
| 1 | 某时段明明显示可订却被占 | build_slot_windows 返回 naive datetime,与库中 aware 时间比较永远不等,导致已占用时段漏排 |
统一用 timezone.make_aware |
| 2 | 通配定价规则查不到 | weekday__in=[x, None] 不匹配 SQL NULL |
改用 Q(weekday=x) \| Q(weekday__isnull=True) |
| 3 | PATCH 更新静默失效 | read_only_fields = fields 把所有字段都设为只读 |
显式区分可写字段 |
| 4 | DRF POST 被 301 重定向丢 body | Django 默认 APPEND_SLASH=True 会把 POST 重定向到带斜杠版本 |
全局关闭 APPEND_SLASH=False,API 统一无尾斜杠 |
| 5 | 静态文件 collectstatic 拷贝不到 | 没配置 STATICFILES_DIRS,finders 找不到项目级 static/ |
补上 STATICFILES_DIRS = [BASE_DIR / 'static'] |
| 6 | 条码唯一约束导致多条「无条码」商品互相冲突 | unique_together 无法表达条件,空串 '' 也是确定值,多条空条码被判定重复 |
改用 UniqueConstraint(fields=['venue','barcode'], condition=~Q(barcode='')) 部分唯一索引,只约束非空条码 |
| 7 | Nginx 子路径 /YuJu/ 部署后,页面 JS 的 API 请求全 404 |
FORCE_SCRIPT_NAME 只影响 Django reverse()/{% url %},浏览器仍按页面 URL 解析 JS 里的 ../api/ 相对路径 |
按页面目录深度书写:一级页面 ../api/、二级页面 ../../api/,并加专门的路径测试守卫 |
八、附录
A. 目录与模块速查
完整目录树见 2.4 模块划分。各 app 职责一句话速记:
- config:全局配置、统一响应包裹、全局异常处理、自定义 Admin 站点
- users:手机号验证码登录 / 注册、微信小程序登录、JWT
- venues:场馆入驻审核、场地与运动类型、时段定价规则、可用时段生成
- bookings:预订事务与三道防线、订单状态机、支付适配、核销
- notifications:短信通道抽象与 Console 实现
- shop:商品档案、库存流水、采购/POS 销售/在线商城、供应商对账
- console:系统设置 KV、经营看板、用户角色管理(仅超级管理员)
B. 响应格式规范
所有 API 统一返回:
// 成功
{"success": true, "code": "ok", "message": "success", "data": { /* 业务数据 */ }}
// 失败
{"success": false, "code": "validation_error", "message": "该时段已被预订", "data": null}
C. 限流策略
| Scope | 速率 | 说明 |
|---|---|---|
anon |
100/min | 匿名用户整体防刷 |
user |
300/min | 已登录用户接口 |
sms |
30/hour | 短信 IP 维度防滥用 |
| 业务层 | 60s/手机号 | 同一号码不能连续发两次 |
D. 订单状态机
pay_order
┌──────────────────────┐
▼ │
create_booking ┌─────────────────┐ verify_order ┌──────┐
──────────────▶│ pending_payment │─────────────▶│ paid │──▶ used
└─────────────────┘ └──────┘
│ │ │
│ │ 30min 未支付(定时任务) │ cancel_order
│ ▼ ▼
│ expired cancelled
└── cancel_order ──▶ cancelled
YuJu 项目地址:github.com/freerain2017/yuju 部署演示地址:
https://www.freerain.cloud/YuJu/欢迎 Star / Fork / Issue 交流 🏸