体育馆场地预约系统实战:uni-app+Django+Flask全栈开发
去年帮一位做羽毛球馆的朋友改造预约流程他原来的方式是微信群接龙前台纸质登记一到周末就撞单客服电话被问到崩溃。后来我用微信小程序uni-app做前端Python DjangoFlask 搭后端交付了一套体育馆场地预约综合管理平台。这篇文章把整个项目的技术选型、数据库设计、小程序端实现、后端接口逻辑、部署上线踩坑记录下来给准备做类似预约系统的朋友一个完整参考。这套系统最终实现的能力是用户打开小程序选择体育场馆 - 选择具体场地比如羽毛球1号场、篮球A场- 选择日期和时段 - 下单支付管理员在后台维护场馆场地时段信息核销用户入场码所有操作全部线上化。整个过程覆盖用户端、管理端、消息推送三个入口三个入口分别由小程序、Django后台、Flask推送服务承载。1. 技术选型复盘为什么前端用 uni-app后端用 DjangoFlask 组合1.1 前端uni-app 不是只为了“写小程序”而是为了多端复用最开始也纠结过要不要用原生微信小程序开发。如果只是应付一个场馆原生完全够用代码量还少。但我考虑到两个现实问题体育馆这种项目大概率会发展出多个连锁场馆以后很可能要一个H5页面给没装微信的用户用甚至做一个商家端App另外团队里前端同事更熟Vue原生小程序的语法体系学起来又要成本。uni-app 是 Vue 语法写成一套代码通过 HBuilderX 编译后可以同时发成微信小程序、H5、App。HBuilderX 是 DCloud 提供的 IDE它把微信小程序开发者工具里要手动处理的东西比如 pages.json、manifest.json 的配置可视化掉了编译后自动打开微信开发者工具预览。原生小程序 vs uni-app 的取舍对比维度原生微信小程序uni-app开发语言WXML/WXSS/JSVue 单文件组件多端支持仅微信小程序小程序/H5/AppUI组件生态WeUI、Vant Weapp 等uni-ui、uView 等调试体验微信开发者工具HBuilderX 微信开发者工具联动上手成本需学小程序专属语法会 Vue 基本零成本性能最优略有一层编译损耗日常项目无感实际开发下来uni-app 最舒服的是路由和生命周期非常接近 Vue 习惯数据层用 vuex/pinia 直接管理不用在小程序原生语法里绕来绕去。另一个意外收获是HBuilderX 内置了真机调试、推送配置、打包 App 的能力后期如果需要做体育馆的会员App同一套业务代码可以直接复用。1.2 后端Django 管核心业务Flask 管实时推送各干各的不重叠Django 和 Flask 在 Python Web 里总是被拿来对比很多新手觉得“二选一”就完了。实际上在一个系统里两者完全可以共存前提是职责边界要清晰。我这里的分工是这样的Django 负责核心业务 API用户认证、场馆场地管理、预约订单创建、支付回调、后台管理。选它的原因是它有完整 ORM、自带的 Admin 后台、DRFDjango REST Framework生态成熟写预约这种强业务流程的系统模型层约束越多越好不容易出数据脏账。Flask 负责一个独立配套服务WebSocket 实时推送。场景是管理员在后台审核通过了用户的预约申请用户小程序端要立刻弹出“预约成功”这种“后台有数据前端主动推送”的需求用 Flask-SocketIO 实现非常轻。为什么不直接用 Django Channels从功能上完全能做到 WebSocket但 Django Channels 对 Redis、ASGI 部署的依赖较重配置成本高。Flask-SocketIO 的服务可以单独跑一个端口和 Django 主服务互不影响哪边挂了不会拖累另一边。这个取舍在单体项目里可能显得有点“过度设计”但真实场景里场馆的预约通知推送频率很高把它从核心业务里剥离出去是务实的选择。2. 场馆预约系统的地基业务梳理与数据库设计2.1 从“场馆-场地-时段-订单”四层拆清楚业务边界做预约系统第一步不是写代码是建模。我建议先把业务对象理清楚否则写到后面字段会越加越乱。顺着用户的真实使用路径可以抽象出四层核心对象场馆Venue比如“XX体育馆”包含名称、地址、营业时间、图片。场地Court属于某个场馆的具体场地比如“羽毛球1号场”“篮球A场”包含场地类型、收费标准、是否可预约。时段TimeSlot场地下面按时间切分的最小预约单位比如“19:00-20:00”。时段是场地运营的最小粒度也是冲突检测的核心对象。预约订单Booking用户和场地时段的关系绑定记录谁、在什么时间、订了哪个场、支付了多少钱、当前状态。这是最简模型但足够跑通闭环。真实项目中还可以加会员等级、优惠券、教练预约、器材租赁等衍生表但核心主线不动。核心表结构参考表名核心字段约束说明venueid、name、address、open_time、close_time、cover场馆基本信息courtid、venue_id、name、type、price_per_hour、statusstatus正常/维护中time_slotid、court_id、date、start_time、end_time、is_lockedis_locked后台临时锁场bookingid、booking_no、user_id、slot_id、court_id、amount、status、pay_timestatus待支付/已支付/已取消/已完成/爽约userid、openid、nickname、phone、balance、created_atopenid 唯一索引time_slot 在项目初期可以不单独建表直接在 booking 里存 date court_id start_time 也行。但我建议还是建表因为管理员锁场、取消场次、临时停场都依赖“某个时段是否可用”的状态单独一张表能把这部分管理逻辑从预约订单里解耦出来。2.2 并发抢场怎么防数据库唯一约束 行锁双保险预约系统最容易翻车的就是“同一个场地的同一个时段被两个用户同时抢到”。体育馆周末黄金时段的羽毛球场地是真实存在竞争关系的。这个问题的技术名字叫并发冲突解决方式不是只靠代码判断而是在数据库层面做硬性限制。第一道保险是唯一约束。如果 booking 表在创建时就把“某场地某天某时段”作为唯一键unique_together (court_id, start_time, date)那么数据库层面就只允许一条记录后插入的直接报唯一键冲突。这个方案简单粗暴但要注意它约束的是成功写入如果业务允许“先占坑后支付”就会有大量状态为“待支付”的脏记录卡住其他用户。所以我的做法是待支付状态的预约只保留 15 分钟超过时间自动释放。释放用 Django 的定时任务来实现每次创建新预约之前再执行一次清理查询过滤掉所有超过 15 分钟的待支付单。第二道保险是事务加行锁。在创建订单的核心接口里对选中的 time_slot 记录执行悲观锁select_for_update锁住这一行再做状态判断和订单创建。伪代码大概长这样from django.db import transaction from django.db.models import F, Q with transaction.atomic(): slot TimeSlot.objects.select_for_update().filter( idslot_id, is_lockedFalse, datebooking_date ).first() if not slot: raise APIException(该时段不可预约) if Booking.objects.filter( slot_idslot.id, status__in[pending, paid, completed] ).exists(): raise APIException(该时段已被预定) booking Booking.objects.create( booking_nogenerate_booking_no(), user_iduser_id, slot_idslot.id, court_idslot.court_id, amountslot.court.price_per_hour, statuspending )select_for_update 必须放在 transaction.atomic 里面才起作用锁会在事务提交后自动释放。这里有个容易被忽略的细节select_for_update 查询如果走的是非索引字段可能会锁住整张表而不是单行所以 time_slot 表一定要对 id 建主键索引并且 where 条件尽量用主键或唯一索引字段。2.3 删除对象要谨慎预约数据必须逻辑删除Django 里删除一条记录特别简单models_instance.delete() 或者 QuerySet.delete() 都能删但预约数据是财务和用户的凭证物理删除会带来灾难。比如用户想查历史订单发现订单没了管理员月度对账发现金额对不上。所以订单表千万不要物理删除。我的做法是给所有业务表加一个 status 字段用状态代替删除。比如预约单有 cancelled用户取消、refunded退款、no_show爽约这些终态用户记录可以加 is_active 标记。Django 里面筛数据的时候每次查询都默认带上 filter(statusactive) 这类条件显得麻烦一点但数据永远可追溯。这是从“能用”到“能上线运维”的关键一步。3. 小程序端从 0 到 1HBuilderX 建工程到预约交互闭环3.1 pages.json、tabBar 和自定义导航栏的适配问题用 HBuilderX 新建 uni-app 项目后入口文件是 main.js页面路由配置在 pages.json全局配置在 manifest.json。这三个文件可以理解为小程序的“三件套”pages.json 对应原生小程序的 app.json。我的推荐 tab 结构是三个页面首页场地列表、订单我的预约、我的个人中心。底部 tabBar 在 pages.json 里配置 tabBar 节点注意图标文件必须是本地 png官方要求 81px*81px 尺寸否则有些机型上显示模糊或错位。这个项目里踩坑最深的是导航栏。体育馆场地列表页我为了放筛选条件用了自定义导航栏。自定义导航栏意味着要自己算状态栏高度否则在刘海屏上布局会顶到状态栏下面去。获取状态栏高度的经典写法const systemInfo uni.getSystemInfoSync(); const statusBarHeight systemInfo.statusBarHeight; // 胶囊按钮位置 const capsule uni.getMenuButtonBoundingClientRect ? uni.getMenuButtonBoundingClientRect() : {}; const navBarHeight (capsule.top - statusBarHeight) * 2 capsule.height statusBarHeight;pages.json 里设置对应页面的导航栏为自定义{ pages: [ { path: pages/index/index, style: { navigationStyle: custom } } ] }这个适配逻辑在每个自定义导航栏页面都要复用建议封装成一个公共组件避免每个页面重复写。第一次真机调试时在 iPhone 上预览发现标题偏下后来就是靠上面的计算方式修正的。3.2 请求封装、登录态与缓存时间管理小程序里所有接口请求我都统一封装了个 request.js核心作用是把 baseURL、token、错误提示、加载状态全部集中处理业务代码只关心接口返回的数据。封装逻辑如下// utils/request.js const BASE_URL https://你的域名/api/v1; function request({ url, method GET, data {}, loading true }) { let token uni.getStorageSync(token); const authHeader token ? { Authorization: Bearer token } : {}; return new Promise((resolve, reject) { if (loading) uni.showLoading({ title: 加载中... }); uni.request({ url: BASE_URL url, method, data, header: authHeader, success: (res) { if (res.statusCode 401) { // token 过期清除并跳转登录页 uni.removeStorageSync(token); uni.navigateTo({ url: /pages/login/login }); reject(res); return; } if (res.statusCode 200 res.statusCode 300) { const code res.data.code; if (code 0) resolve(res.data.data); else { uni.showToast({ title: res.data.msg, icon: none }); reject(res); } } else { uni.showToast({ title: 服务器异常, icon: none }); reject(res); } }, fail: (err) { uni.showToast({ title: 网络错误, icon: none }); reject(err); }, complete: () { if (loading) uni.hideLoading(); } }); }); } export default request;登录态这步容易出问题。微信小程序没有 Session 概念官方推荐通过 uni.login 获取 code再把 code 传给后端换取 openid后端生成 token 返回给前端前端把 token 放到 uni.setStorageSync(token) 里以后每个请求带上 token 即可。网上很多教程喜欢把 token 放进 cookie这是从浏览器端带过来的惯性思维。小程序环境并不适合操作 cookie直接存 storage 是更干净的做法。关于缓存时间我用两个参数控制token 的过期时间由后端 JWT 的 exp 决定前端可以配合存储一个 expires_at 来判断是否提前跳登录页另外场地列表这类经常变化的接口我用缓存 60 秒的策略减少流量请求const cacheKey court_cache_ venueId; const cached uni.getStorageSync(cacheKey); const now Date.now(); if (cached cached.expires now) { resolve(cached.data); } else { // 走网络请求并更新缓存 uni.setStorageSync(cacheKey, { data: res, expires: now 60 * 1000 }); }3.3 选场地、选时段、下单提交的页面交互细节场地选择页做了两个关键组件日期滚动栏和时段格子网格。日期用横向日历组件时段格子按“可预约/已约满/维护中”三种状态渲染成不同颜色用户点击格子选中后底部弹出支付卡片。单选框在小程序里虽然基础但要注意不能用 Html 的 radiouni-app 里可以用 uni-ui 的 uni-data-checkbox 或者自己封一套单选样式。我选择自己封装原因是可以把时段格子的选择行为选中高亮、不可点置灰和支付卡片联动。核心代码大致是view classslot-grid view v-foritem in slotList :keyitem.id classslot-item :class{ slot-item--selected: selectedSlot selectedSlot.id item.id, slot-item--disabled: item.status ! available } clickselectSlot(item) {{ item.start_time }}-{{ item.end_time }} /view /viewselectedSlot 被选中后页面底部出现待支付卡片显示场地、时段、金额点击确认支付后调用 PaymentService.wechatPay 发起小程序支付。支付回调成功后后端确认订单状态变成已支付前端跳转订单详情页展示入场二维码。这里必须防重复点击。用户手速快的时候同一个创建订单请求可能被触发两次后端有唯一约束和行锁保护但前端也要做个开关let submitting false; function submitOrder() { if (submitting) return; submitting true; // 请求创建订单 request({ url: /booking, method: POST, data: {...} }) .then(res submitPayment(res.payParams)) .finally(() { submitting false; }); }实测反馈在低端 Android 机上小程序页面频繁切换会有卡顿感入场二维码渲染建议用 canvas 生成不要直接用 image 标签加载大图容易白屏。另外预约成功后的入场二维码每天动态生成不要缓存否则核销时会出现已过期提示。4. 后端核心接口认证、预约流转与 Flask 实时推送4.1 Django App 拆分与业务代码组织创建 Django 项目后核心是通过 startapp 把业务拆成独立模块。我这里的拆分方案python manage.py startapp users python manage.py startapp venues python manage.py startapp booking python manage.py startapp payments要记得在 settings.py 的 INSTALLED_APPS 里注册否则迁移不生效INSTALLED_APPS [ # ... rest_framework, rest_framework_simplejwt, corsheaders, users, venues, booking, payments, ]Django Admin 是个被忽略的宝藏。体育场馆管理员不一定懂数据库但给他一个 Django Admin 后台字段用中文 verbose_name 标好他就能自己维护场馆闭馆时间、场地价格、临时锁场。用户端小程序和管理员后台共用一份 Django model数据天然统一。4.2 JWT 认证为什么不用 Session/Cookie 存 token小程序的认证每个请求都走 wx.request和浏览器里带 Cookie 的机制不同没有必要强行维持 Session。我更推荐 JWTJSON Web Token无状态、可跨端、容易扩展。用 djangorestframework-simplejwt 插件就能快速实现# settings.py REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework_simplejwt.authentication.JWTAuthentication, ], DEFAULT_PERMISSION_CLASSES: [ rest_framework.permissions.IsAuthenticated, ], } # urls.py from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns [ path(api/v1/auth/login/, TokenObtainPairView.as_view()), path(api/v1/auth/refresh/, TokenRefreshView.as_view()), ]小程序端 wx.login 拿到 code 后调一个自定义接口让后端换成 openid再和用户关联。如果用户已经存在直接返回 token如果不存在自动创建用户。我把这个逻辑写在 Django 的视图里配合 simplejwt 的 RefreshToken 生成 tokenfrom rest_framework_simplejwt.tokens import RefreshToken def wx_login(request): code request.data.get(code) # 调用微信 code2session 接口换取 openid openid get_openid_from_wx(code) user, created User.objects.get_or_create(openidopenid) refresh RefreshToken.for_user(user) return JsonResponse({ token: str(refresh.access_token), refresh_token: str(refresh), })JWT 的过期时间我设置 access token 有效期 2 小时refresh token 有效期 7 天。小程序端每次请求 401 时自动用 refresh_token 去刷新刷新成功更新本地 token刷新失败才跳登录页。这套逻辑在两小时的预约支付流程里体验很好用户不会无故被登出。4.3 Flask WebSocket后台有数据小程序端实时收到推送这个系统里 Flask 承担的“场地状态推送服务”是整个体验的加分项。场景是这样的用户在小程序里提交预约后订单状态是“待审核”场馆人工确认或自动确认如果管理员在后台把订单改为已确认小程序端要立刻收到一条“您的预约已确认”不需要用户手动刷新页面。基于 Flask 的实现核心是 Flask-SocketIO# push_service.py from flask import Flask from flask_socketio import SocketIO, emit app Flask(__name__) socketio SocketIO(app, cors_allowed_origins*) socketio.on(connect) def handle_connect(): print(client connected) socketio.on(subscribe) def handle_subscribe(data): user_id data.get(user_id) # 将连接存入用户映射这里生产环境建议用 Redis user_connections[user_id] request.sid def push_to_user(user_id, event, message): sid user_connections.get(user_id) if sid: emit(event, message, tosid)Django 主服务在订单状态变化时通过 HTTP 调用 Flask 服务暴露的一个内部接口Flask 服务再触发 socketio.emit 推送。比如在 Django 的订单确认视图里加一行import requests requests.post(http://127.0.0.1:5001/internal/notify, json{ user_id: booking.user_id, event: booking_confirmed, message: f您的{booking.court.name}预约已确认 })小程序端用 uni.connectSocket 建立连接监听对应事件并展示 Toast。收藏一个关键点开发模式下 Flask 服务直接调用端口没问题部署到服务器后必须把 Flask 服务通过 反向代理 暴露或者让 Django 和 Flask 在内网互通。我这里 Flask 服务单独跑在 5001 端口不对外网开放只有 Django 的服务器内部可以访问安全性更好。5. 部署上线与真实踩坑记录5.1 本地环境VSCode 配置 Python 环境与 HBuilderX 调试开发阶段项目分为两个部分后端代码在 VSCode 里管理小程序端代码在 HBuilderX 里管理两者通过局域网 IP 联调。VSCode 里配置 Python 环境建议用 venv把依赖锁在 requirements.txtpython -m venv venv source venv/bin/activate # Windows 下 venv\Scripts\activate pip install django djangorestframework djangorestframework-simplejwt django-cors-headers flask flask-socketio requests pip freeze requirements.txtVSCode 装 Python 插件后按 CtrlShiftP 选择 Python 解释器指定 venv 路径Debug 配置文件 launch.json 里启动 Django 的 manage.py runserver 即可。小程序端在 HBuilderX 里运行到微信开发者工具注意本地调试时微信开发者工具要勾选“不校验合法域名”否则 uni.request 到局域网 IP 会被拦。这个选项只用于开发正式上线必须配合法域名和 HTTPS 证书否则发布审核过不了。5.2 微信小程序上线域名、HTTPS 和小程序年审小程序正式版本请求的 API 域名必须是 HTTPS且在小程序后台配置请求合法域名。域名要 ICP 备案证书用免费的一年期 DV 证书即可。我的部署架构是一台服务器跑 NginxNginx 反代 Django 的 8000 端口和 Flask 的 5001 端口证书挂在 Nginx 层。微信发布审核这里有几个容易忽略的点类目要选正确涉及场馆服务选“体育体育场馆服务”不能乱报。个人主体的小程序和部分类目不兼容做这种线上预约支付的业务通常要求企业主体。每年有年审年审期间如果小程序涉及支付功能记得续期时把相关类目资质一起核对避免被下架。我第一次提审的时候因为隐私协议里没有明确收集用户位置信息被驳回了。实际项目里场馆预约基本不需要读取位置直接不申请位置权限即可省掉很多麻烦。5.3 最容易翻车的五个问题汇总我按真实项目里遇到的顺序整理了一张排查表这些问题看代码是不会提前发现的只有跑上线才会暴露。问题现象根本原因解决办法并发下单超卖未加行锁状态判断和写入非原子操作select_for_update transaction.atomic订单到期未支付一直占场只创建订单没清理超时单定时任务每5分钟扫一次取消超时待支付单后端返回的 UTC 时间和小程序显示相差8小时Django 默认时区设置问题settings.py 设 TIME_ZONEAsia/ShanghaiUSE_TZFalse支付回调重复通知导致订单状态错乱微信回调可能到达多次回调里加锁订单状态幂等判断只有待支付才允许改成已支付真机预览时接口连不上开发机真机无法通过 localhost 访问电脑手机和电脑同一局域网用内网 IP 调试最后这条单独提一下VSCode 调试 Django 时runserver 默认只监听 127.0.0.1真机访问不到。开发时要加参数python manage.py runserver 0.0.0.0:8000否则苹果手机预览时只会一直转圈报“request:fail”。我从这个项目里得到的最深体会是预约系统的核心不在页面多漂亮而在并发正确性和数据一致性。页面交互这些前端问题调试几次就能解决但超卖、状态错乱、支付重复回调这种问题一旦上线就是事故级别。你设计数据库的时候就要把“同一时段只能被一个人占有”当作第一原则每一层加一道保险而不是等出问题再补。如果未来把这个系统往更多场地类型扩展可以考虑加按小时计价的动态费率节假日折扣、会员折扣、教练资源绑定、场地自动维护提醒这些模块。底层的场馆-场地-时段-订单模型足够稳定往上加功能都是纵向扩展的事。