
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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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-typegetPhoneNumber用户点击同意后前端会拿到一个加密code把这个 code 传给后端由后端调用微信接口换取手机号。这个接口是https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_tokenACCESS_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 requestT(path: string, method: GET | POST GET, data: object {}) { return new PromiseT((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.loginH5 里没有小程序里可以用getPhoneNumberH5 只能用短信验证码。源码里做了一个登录中间页利用条件编译把不同端的登录逻辑分开// #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 组合开发的智能场馆预订系统整理成源码交付包时额外做了一份接口文档和一份部署手册。相比代码本身我更想强调建模和状态机设计上花的时间。如果让我再做一遍我会第一时间把资源表和价格规则表的关系画清楚而不是先写前端页面。场地资源没有排好前端做得再漂亮订场体验都会很糟糕。现在这套工程已经能直接编译上线所有配置项都收敛在配置文件和文档里照着部署基本不会再走我当初踩过的那几道弯。