1. 项目初始需求与选型纠结:为什么偏偏是PHP和UniApp
这套基于 PHP + UniApp 组合开发的智能场馆预订系统源码,我最近刚整理完最后一份交付包。它覆盖微信小程序、支付宝小程序、H5、App 等常见终端,后端统一走 PHP 接口,整个工程可以直接编译上线。写这篇分享,主要面向手里有体育馆、羽毛球馆、游泳馆这类场地资源、需要做订场系统的开发者,也适合正准备接触 uni-app 多端项目、又不想同一套业务写三遍前端的同学参考。
我一开始接到这个需求时,对方只提了两句话:第一,用户要在微信小程序里选场地、选时间、在线支付;第二,同样的场馆和订单,老板还要能在手机 H5 和电脑浏览器上打开。这两句话看起来简单,实际拆开之后,涉及场地资源管理、时间段锁定、微信登录、支付回调、订单取消退款、多端界面适配,哪一个单独拎出来都不算难,但组合在一起就是一台必须一次跑通的流水线。
1.1 场馆预订到底要解决什么
先说清楚业务核心。场馆预订和普通电商完全不同,电商卖的是库存数量,场馆卖的是“某个具体时间段内某个具体场地的使用权”。同样一个羽毛球馆,1 号场 19:00-20:00 被人订了,那么 18:00-19:00 可能仍可订,也可以订 20:00-21:00,绝不能让两单时间重叠。这个“时间片”和“场地资源”的组合,是整套系统的地基,所有代码都要围绕它转。
除开时间冲突,还要处理几个常见场景:
- 用户选了 19:00-20:00 但一直不付款,这个时间段要不要一直给他占着?
- 用户付完款后有事想取消,距离使用开始多久可以退、退多少?
- 场馆临时停电、场地维修,某一天或者某一个场地不能预订,前端怎么显示?
- 价格不是一刀切,晚上黄金时段可能比下午贵,节假日可能有单独价格。
- 管理员需要知道哪个场地现在被谁用着,明天还有多少空档。
这套源码里,我把核心模块收敛成五个:场馆管理、资源管理、价格策略、订单中心、支付流水。前端不做复杂的管理后台,管理后台直接复用 PHP 接口另一套页面。对普通场馆经营者来说,小程序端能订场已经解决 80% 的问题,剩下 20% 是给管理员排场和退款的入口。
1.2 后端选型的取舍
后端为什么选 PHP,没有选 Java 或者 Node.js?我直接说结论:这台系统是给中小场馆用的,日均订单量可能几十单到几百单,峰值也就是晚上黄金时段大家同时抢几个场,这个量级下 PHP 完全够用。而且 PHP 的部署成本实在低,虚拟主机、宝塔面板都能跑,客户后续自己找人维护也容易。如果一上来就搞微服务、搞容器编排,场馆老板未必消化得了。
具体实现上,我用了基于 PHP 8 的 ThinkPHP 8。选它而不是原生 PHP,是因为 ThinkPHP 在国内团队协作、资料查找、招聘人员方面都有优势,ORM 和数据库迁移写起来也顺手。PHP 8 相比老版本在 JIT 和类型安全上进步明显,我用 PHPStorm 写代码时,直接在方法参数和返回值上标注类型,能省掉很多低级错误。
当然 PHP 也有短板,比如长连接、高并发秒杀并不擅长。所以我在源码里做了个很务实的取舍:所有实时性要求高的操作,比如用户抢最后一片场地,用 Redis 锁在前端入口拦截;单纯的数据写库和定时释放过期订单,交给 PHP 的进程调度。这个组合在我实际压测中,单台 2 核 4G 服务器跑 200 个并发订场请求没有大问题,对场馆场景来说绰绰有余。
1.3 前端多端方案的对比
前端最初也纠结过要不要直接写微信原生小程序,再单独做一个 H5。算了一笔账就放弃了。原生小程序语法和 H5 完全不通用,两个端光是页面就要重写两遍,后面如果还要出支付宝小程序、抖音小程序,每一个都得再维护一遍,成本直接翻倍。
我当时列过一张对比表,从可维护性和体量来评估:
| 方案 | 微信小程序 | H5/App | 维护成本 | 学习门槛 |
|---|---|---|---|---|
| 原生微信小程序 | 优秀 | 需要另写 | 高 | 中 |
| Taro | 优秀 | 依赖 React 语法 | 中高 | 中 |
| uni-app | 优秀 | 一稿多编 | 低 | 低(Vue 语法) |
最后锁定 uni-app。它的底层编译器会把同一套 Vue 代码分别编译成小程序、H5 和 App 包,页面和组件还是用 Vue 那一套心智模型。源码里的页面,比如场地列表、订单确认、支付结果、个人中心,我只需要维护一份,微信小程序端、H5 端、App 端共用同一套业务逻辑。
2. 场馆预订最核心的业务建模:场地资源、时间片与订单状态机
很多人在做这类系统时,一上来就写“预订接口”,结果后面对冲突检测、取消退款全乱套。我建议先停下来画业务模型。这套源码里的核心表只有四张:场馆表、资源表、订单表、支付流水表。把这四张表的关系和字段约束想清楚,开发速度会快很多。
2.1 场地资源的层级设计
场馆和资源是父子关系。一个场馆下面有多个可预订资源,资源不只是“场地”,还可以是羽毛球馆的 1 号场、篮球馆的 A 场、瑜伽室的器械时段,这一类都统一叫 resource。不同资源有不同的预订粒度:羽毛球按小时,篮球包场按两小时一个场次,酒店会议室按半天或全天。我用一个字段price_type区分,避免把逻辑写死在接口里。
建表语句我简化后给大家看一眼核心字段:
CREATE TABLE `res_resource` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `venue_id` int unsigned NOT NULL DEFAULT 0 COMMENT '所属场馆ID', `name` varchar(100) NOT NULL COMMENT '资源名称,如1号场', `resource_type` tinyint NOT NULL DEFAULT 1 COMMENT '1场地 2器材 3课程', `capacity` smallint NOT NULL DEFAULT 1 COMMENT '可容纳人数', `price_type` tinyint NOT NULL DEFAULT 1 COMMENT '1按小时 2按场次 3按天', `price` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '基础价格', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1可预订 0停用', PRIMARY KEY (`id`), KEY `idx_venue` (`venue_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='可预订资源';这里最容易忽略的是status字段。场馆临时停用某个场地,不需要去删订单或者改价格,只需要把资源状态改成停用。前端加载资源列表时,停用的资源直接不展示,已存在订单不受影响。这比在查询时叠加一堆“日期是否特殊”的条件要干净得多。
价格策略我单独放了一张价格规则表,支持按星期和时间段配置溢价。比如周一至周五 18:00-22:00 是黄金时段,基础价上浮 30%,周末全天按节假日价。前端传“资源 ID + 开始时间 + 结束时间”,后端根据日期和时段计算出最终金额。这个设计比把价格直接存在资源表里更符合真实场馆的计费逻辑。
2.2 订单状态机的流转规则
订单状态我控制得很克制,只有五个:待支付、已支付、已取消、已退款、已完成。很多人喜欢加“待使用”“使用中”这些状态,但实际运营中意义不大,反而会放大状态判断的复杂度。我核心只关心钱是否到账、场地是否被占用。
状态流转是这样的:
- 用户提交订场请求,系统先锁定时间片,生成待支付订单,订单里保存一个
lock_expire_at字段,表示这个锁定最长保留多久。 - 用户支付成功,支付回调把订单改成已支付,此时场地被真正占用。
- 用户撤销支付或超时未支付,订单变成已取消,时间片释放。
- 已支付订单申请取消,根据项目里提前 2 小时免费取消、2 小时内不可取消的规则,要么直接原路退款变成已退款,要么拒绝取消。
- 使用时间结束后,定时任务把已支付订单批量置为已完成。
这套状态机里最重要的概念是“待支付订单也会占用场地”。因为用户已经在付款页看到了这个时间段,如果同一时刻别人把它订走,体验会很差。所以我在查询冲突时,把待支付且未过期的订单也视作占用;一旦过期释放,别人才能订。
2.3 并发抢场时的冲突检测
冲突检测是整个系统里最容易出 bug 的地方。很多初学的人只写一句 SQLwhere start_time > ? and end_time < ?,但这只覆盖了“新订单完全落在已有订单范围内”的情况,真实重叠场景有四种。正确判断两个时间段是否冲突,用的不是“开始时间是否在中间”,而是“新开始时间是否早于旧结束时间,并且新结束时间是否晚于旧开始时间”。
在事务里,我这样检测:
$exists = Order::where('resource_id', $resourceId) ->whereIn('status', [0, 1]) ->where('start_time', '<', $endTime) ->where('end_time', '>', $startTime) ->where(function ($query) use ($now) { $query->where('status', 1) ->orWhere('lock_expire_at', '>', $now); }) ->lockForUpdate() ->exists();可以理解为两段线条只要首尾有交集,就说明重叠。用代码写出来就是旧订单开始时间早于新订单结束时间,并且旧订单结束时间晚于新订单开始时间。查询时加上lockForUpdate()行锁,防止两个并发请求同时读到无冲突然后一起插入。
这只是数据库层的兜底,更前面还有一道 Redis 锁拦截:
$locked = Cache::store('redis')->set( "booking:{$resourceId}:{$startTime}:{$endTime}", $userId, 30 ); if (!$locked) { return error('该时间段刚刚被其他用户锁定,请选择其他时间'); }Redis 锁保证同一时间只有一个请求进入下单逻辑。两把锁配合的意义在于:Redis 扛并发,数据库事务保证最终一致性。即使 Redis 因故障没有生效,数据库里的行锁和冲突查询仍然能把重叠订单卡住。
3. PHP后端接口设计里值得细看的三个关键链路
业务表建好之后,真正的工作量在接口层。这套源码的接口路径都放在/api下,前端 uni-app 请求时统一带 token,后端中间件校验登录态。这里分享三个我认为最关键的链路:接口约定、微信手机号授权、支付回调。
3.1 统一接口约定与登录流程
前端不管哪个端,调用后端接口返回格式都是同一套结构:
{ "code": 0, "msg": "ok", "data": {} }code为 0 表示成功,其他为业务错误码。比如 20001 是资源不存在,20002 是时间冲突,20003 是支付参数错误。前端在 request 封装里统一判断code,遇到 401 类错误自动跳转登录页。这样小程序端和 H5 端的异常处理逻辑完全一致,不会出现一个接口在小程序里报错、在 H5 里却弹窗不一致的问题。
登录细节上,小程序登录不能直接用用户手机号密码那套逻辑。用户打开小程序时,先调用uni.login()拿到临时 code,后端拿着 code 去微信接口换 openid,然后生成自己系统的 token 返回。这个 token 后续作为请求头Authorization传递。因为场馆预订涉及手机号接收通知和退款,普通匿名登录还不行,需要下一步手机号授权。
3.2 微信小程序获取手机号的完整处理
微信现在不允许小程序前端直接拿到用户完整手机号,正确做法是前端使用<button open-type="getPhoneNumber">,用户点击同意后,前端会拿到一个加密code,把这个 code 传给后端,由后端调用微信接口换取手机号。这个接口是https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=ACCESS_TOKEN,请求体里放{"code": "xxx"}。
后端代码大致这样组织:
public function wxLogin() { $code = $this->request->post('code'); $phoneCode = $this->request->post('phoneCode'); // 用 code 换 openid $session = $this->wxApp->code2Session($code); // 用 phoneCode 换手机号 $phoneInfo = $this->wxApp->getUserPhoneNumber($phoneCode); $phone = $phoneInfo['purePhoneNumber'] ?? ''; $user = User::updateOrCreate( ['openid' => $session['openid']], ['phone' => $phone] ); $token = bin2hex(random_bytes(16)); $user->saveToken($token); return success(['token' => $token, 'isNewUser' => $user->wasRecentlyCreated]); }这里有两个大坑必须提前说。第一,getPhoneNumber要求小程序主体是企业,且小程序后台提前配置了用户隐私保护指引,拿到手机号的目的也要写明,否则接口返回权限不足。个人主体小程序基本没法用这个能力。第二,access_token不能每次都重新拉取,要按微信文档缓存 7200 秒,否则高峰期频繁换取 token 很容易触发接口限流。我在源码里写了一个简单的 access_token 缓存类,内部用 Redis 存,过期才重新请求。
3.3 微信支付回调的幂等处理
支付流程我采用的是统一结算方式:后端先生成微信预支付订单,拿到prepay_id,返回给前端调起支付组件。前端uni.requestPayment完成支付后,微信服务器会异步通知后端一个支付结果,这个通知才是订单状态更新的最终依据,而不是前端支付成功提示。
回调处理最忌讳直接改订单状态。微信回调可能重复推送,也可能之前的回调还没处理完,新的又来了。如果不做幂等,订单会被重复更新,用户可能收到两次成功推送。我的处理方法是先锁支付流水:
$payLog = PayLog::lockForUpdate()->where('out_trade_no', $outTradeNo)->first(); if ($payLog && $payLog->status === 0) { $payLog->status = 1; $payLog->transaction_id = $transactionId; $payLog->save(); Order::where('order_no', $payLog->order_no) ->update(['status' => 1, 'pay_time' => date('Y-m-d H:i:s')]); } echo 'SUCCESS';这里的关键是lockForUpdate(),第二个回调会等第一个事务结束,再读取流水时发现 status 已经是 1,就跳过更新逻辑,只返回 SUCCESS。支付回调里还要用微信的 API v3 密钥做签名校验,校验通过才允许走到下一步。我把签名校验、解密、请求数据验签这三个动作封装成一个PayNotifyHandler,不管是哪个端发起的支付,最终都走同一个处理器,避免小程序端和 H5 端支付逻辑不一致。
4. UniApp前端多平台开发,真正踩出来的适配细节
后端写得再顺,前端适配不够细心,项目一样上线不了。UniApp 的口号是一套代码多端发布,但真实开发中,每个端都有自己的脾气。这里不说大道理,直接讲我在源码里实际处理过的问题。
4.1 创建工程时的技术栈选择
如果你用 HBuilderX 创建项目,默认会给你 Vue 2 模板,但我建议直接选 Vue 3 + Vite + TypeScript 模板。Vue 3 的组合式 API 写业务逻辑更集中,TS 在多人协作时能减少字段拼写错误。命令行方式创建可以用:
npx degit dcloudio/uni-preset-vue#vite-ts my-project创建完成后,manifest.json是重中之重。微信小程序的 appid 写在mp-weixin节点下,H5 的标题和路由模式写在h5节点下。如果一开始 appid 填错或者漏填,后面微信开发者工具编译出来的就是游客模式,无法调用登录和支付。
源码里的前端请求层单独放了一个utils/request.ts,里面做了统一 baseURL 处理:
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.example.com'; export function request<T>(path: string, method: 'GET' | 'POST' = 'GET', data: object = {}) { return new Promise<T>((resolve, reject) => { uni.request({ url: BASE_URL + path, method, data, header: { Authorization: uni.getStorageSync('token') || '' }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data); } else { uni.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail: (err) => reject(err) }); }); }这里要强调,使用 Vite 版 uni-app 时,环境变量文件是.env和.env.production,不同环境下切换后端地址非常方便。千万不要把后端地址硬编码在页面里,后面改一个域名要全局搜索,痛苦。
4.2 条件编译与端差异处理
多端项目中,最常用的技巧是条件编译。小程序里有uni.login,H5 里没有;小程序里可以用getPhoneNumber,H5 只能用短信验证码。源码里做了一个登录中间页,利用条件编译把不同端的登录逻辑分开:
// #ifdef MP-WEIXIN const loginRes = await uni.login(); const code = loginRes.code; // #endif // #ifdef H5 const code = ''; // #endif// #ifdef这种注释写法,编译到对应端时才会保留代码块,其它端直接抹掉。如果不做这一步,在 H5 里调用uni.login()虽然不报错,但拿到的 code 后端无法用于微信登录,就会出现 H5 登录永远失败的问题。
页面上还有一个很典型的多端差异:支付。微信小程序端可以正常调起微信支付,但 H5 端在非微信浏览器里,微信支付能力受限较多。我在源码里做的降级策略是:H5 端优先展示订单二维码,让用户用微信扫码完成支付;同时保留“线下支付/场馆付款”开关,由管理员后台确认收款。这种务实方案比在 H5 上硬接一堆支付渠道要省事得多,也避免因为支付渠道配置不齐全导致线上流程卡死。
4.3 导航栏、键盘与安全区的兼容
小程序的自定义导航栏是新手很容易踩的坑。微信小程序的顶部导航分为两部分:状态栏和导航栏。状态栏高度在不同机型上不一样,iPhone 有刘海,安卓厂商各有各的挖孔。如果用自定义导航栏,必须动态获取状态栏高度。
我在源码里做了一个navbar.ts,统一返回状态栏高度和菜单按钮位置:
// 仅在小程序端可用 // #ifdef MP-WEIXIN const systemInfo = uni.getWindowInfo(); const menuButton = uni.getMenuButtonBoundingClientRect(); // #endif拿到statusBarHeight和胶囊按钮的 top、height 之后,自定导航栏才能做到和系统风格一致。如果写死 64px 或 44px,十台手机里至少三台会出现按钮错位。H5 端不存在胶囊按钮,所以这段逻辑要放在条件编译里面,不能在小程序里用一套、H5 里又串台。
底部安全区也是一样。iPhone 的 Home Indicator 区域如果处理不好,底部按钮会被手势条遮挡。我统一给底部操作栏加了padding-bottom: env(safe-area-inset-bottom),而不是傻乎乎地固定写死像素值。这个细节看起来小,但不处理的话,用户在 iPhone 上点“确认支付”都费劲。
5. 源码部署上线的完整避坑清单
最后一关是部署。代码写得再好,部署环节出问题,前面全部白费。我在这套项目上前后部署了三次,把踩过的坑都记录下来,这里直接给出可复用的清单。
5.1 服务器与PHP环境
后端跑在 PHP 8.1 + Nginx + MySQL 8 + Redis 上。需要注意,PHP 8 的某些老扩展和老语法不兼容,代码里我用的都是 PHP 8 原生写法,部署时不要再拿 PHP 7.4 去跑。我的部署目录是这样规划的:
/var/www/venue/ ├── backend/ # PHP 接口源码 ├── uniapp/ # 前端源码 ├── database/ # 建表 SQL 和初始化数据 └── docs/ # 接口文档和部署说明Nginx 配置的关键是把所有不存在的文件请求转发给入口文件。用 ThinkPHP 的伪静态规则,location 里配置try_files即可。后端runtime目录必须给写权限,否则日志写不进去,接口报错你还什么都看不到。这是个极其低级但发生频率极高的错误。
Redis 建议开启密码并绑定内网,不要用默认端口裸奔。因为订场接口有 Redis 锁,如果 Redis 没启动,所有订场请求都会卡在取锁那一步,前端表现就是“一直转圈”。
5.2 微信小程序后台配置
小程序除了代码打包上传,还要在微信公众平台做三件事:配服务器域名、配隐私保护指引、绑定支付商户号。
服务器域名在“开发管理-开发设置-服务器域名”里,把后端的接口域名填到 request 合法域名里。如果用 IP 加端口,微信不允许,必须用 HTTPS 域名。配置完成后,开发者工具里不要勾选“不校验合法域名”上线,否则用户手机上一片白屏或者请求失败。
隐私保护指引要在“设置-基本设置-服务内容声明”里补充。小程序申请手机号接口时,微信会检查隐私协议里是否声明了“手机号码”这个信息的收集和处理目的。源码里我已经附了一份隐私协议模板,部署时把场馆名称和联系方式替换成你自己的就行。
支付商户号要和这个小程序绑定,绑定完成后,后端配置里的mch_id、api_v3_key、证书路径都要填对。支付回调地址必须是公网 HTTPS 地址,同时把回调路径放到微信支付后台的“支付回调域名”配置里,否则收不到支付通知。
5.3 定时释放与前端构建细节
待支付订单超时释放这件事,不能只靠用户在前端傻等,必须有一个后台定时任务兜底。我在源码里写了一个命令行任务,用 ThinkPHP 的定时指令清理过期订单:
*/5 * * * * php /var/www/venue/backend/think order:release任务每五分钟跑一次,把所有lock_expire_at小于当前时间且状态还是待支付的订单置为已取消。这里还做了一个小优化:释放订单时同时删除对应的 Redis 锁,这样前台用户立刻就能看到时间片空出来,不用等五分钟。
前端构建时,Vite 版 uni-app 要用命令行打包微信小程序版本:
npm run build:mp-weixin打包产物在dist/build/mp-weixin,用微信开发者工具打开这个生成的目录,而不是直接打开整个 uni-app 项目。很多新手在这一步困扰很久,以为代码写错了,其实是打开目录不对。确认没问题后,在开发者工具里上传版本,去公众平台提交审核,等审核通过后发布线上。
部署时还需要检查一个细节:.env文件里APP_URL、VITE_API_BASE_URL、WECHAT_APPID这三者的域名必须保持一致,否则会出现“接口通、但小程序打开的是测试环境数据”或者“登录成功但支付拉起失败”之类的诡异问题。
我把这套 PHP + UniApp 组合开发的智能场馆预订系统整理成源码交付包时,额外做了一份接口文档和一份部署手册。相比代码本身,我更想强调建模和状态机设计上花的时间。如果让我再做一遍,我会第一时间把资源表和价格规则表的关系画清楚,而不是先写前端页面。场地资源没有排好,前端做得再漂亮,订场体验都会很糟糕。现在这套工程已经能直接编译上线,所有配置项都收敛在配置文件和文档里,照着部署,基本不会再走我当初踩过的那几道弯。