从零搭建一个运动场地预约平台:场上见(YuJu)的技术全栈实践

从零搭建一个运动场地预约平台:场上见(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 关键业务流程

  1. 用户打开预订页,前端 GET /api/venues/ 拉取已审核场馆列表;
  2. 选择场地与日期,GET /api/venues/courts/{id}/availability?date=... 返回可用时段及各时段价格(生成时段窗并过滤已占用);
  3. 锁定时段后登录(手机号 + 验证码):POST /api/users/sms/send(登录前可调用),服务端生成 6 位码写入缓存(300s)并经短信通道发送;
  4. POST /api/users/login 提交手机号 + 验证码,服务端校验后立即删除验证码,创建/查询用户并返回 {access, refresh}
  5. POST /api/bookings/create事务 + select_for_update 行锁 + 联合唯一索引,创建 CourtSlot(独占时段)与状态 pending 的 BookingOrder,经支付适配器生成预支付参数,返回订单号与预支付参数;
  6. 用户完成支付后 POST /api/bookings/{id}/pay,支付通道确认成功,订单置为 paid
  7. 到场出示核销码,场馆端 POST /api/bookings/verify:订单置 usedCourtSlotreleased,并经短信通道发送核销通知。

3.3 界面一瞥

YuJu 首页 Hero ▲ 用户端首页(/)——运动主题 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 层统计;
  • 用户管理:调整角色、启用/停用账号(禁止操作自己和其他超级管理员),权限映射表只读;
  • 系统设置SystemSetting DB 级 KV 表,站点名称 / 客服电话 / 公告等 is_public 项经 GET /api/console/site-config 免登录输出;mall_enabled 业务开关被商城下单接口真实读取,可一键关停。

四、关键实现细节

这一部分只讲思路与教训,不贴代码,展开几个"容易踩坑但必须做对"的技术点。

4.1 并发安全:三道防线防止超售

预订类系统最棘手的问题,是同一时段被两个人同时抢到。我们用了三道防线:

  1. 事务 + 行级锁:下单事务内先用 select_for_update 锁定所有与目标时段存在区间重叠的占用记录,再判重。Postgres/MySQL 下是真正的行级锁,SQLite 下退化为全库串行,将来迁移数据库后自动升级。
  2. 数据库唯一索引兜底:时段表上建「场地 + 起止时间」联合唯一索引,即使应用层锁失效(如跨库并发),数据库也不会写入重复时段。
  3. 业务层幂等:同一用户对同一场地同一时段已有有效订单时,直接返回原订单与预支付参数,网络重试不会造成重复下单。

踩坑经验:跨天连订(如 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 模块复刻了同一套防护:

  1. 行锁 + 库存校验:销售事务内逐行锁定商品,再判断库存是否充足;
  2. 数据库约束兜底:用 CheckConstraint 保证库存永不小于 0,绕过应用层也写不进负库存;同场馆 SKU 唯一、非空条码唯一同样由数据库约束保证;
  3. 整单原子性:销售单与多条库存变动在同一事务提交,线上支付任一步失败全部回滚。

此外,所有库存变动只追加 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 namestatus(pending/approved/rejected)、commission_ratio(默认 0.10)
VenueCourt nameis_openbase_price
CourtPriceRule day_type(workday/holiday)、weekday(0-6 或 null 通配)、start_timeprice
CourtSlot start_timeend_timestatus(reserved/released)
BookingOrder order_no(UK)、status(pending_payment/paid/cancelled/used/expired)、amountverify_code(UK)
Product sku(venue 内唯一)、barcode(venue 内非空唯一)、stock_count(Check >= 0)、sale_pricelow_stock_threshold
StockLedger change_type(purchase/sale/refund/adjust/damage)、quantity(带符号)、balance_after(对账凭据)
Supplier nameis_active(有采购记录仅可停用)
PurchaseOrder order_no(UK,PO 前缀)、supplier_name(按单快照)、total_amountstatus(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_DIRSfinders 找不到项目级 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 交流 🏸