PHP+UniApp场馆预订系统开发:从数据库设计到多端打包实践 做场馆预订系统我这些年经手的需求真不算少。羽毛球馆、篮球场、共享会议室、自习室甚至一些团建场地核心诉求大同小异用户要能在线看场次、选时段、下单支付运营方要能管场地、管订单、看营收。最近整理了一套 PHP UniApp 组合的智能场馆预订系统源码把后端接口、后台管理和前端多端打包整个链路都跑通了代码可以直接部署成微信小程序、支付宝小程序、H5 网页和安卓 App。这篇文章就是我从设计到落地的完整记录表结构、核心 API、前端关键页面、多端打包、踩坑点都摊开讲给想用低成本技术栈做场馆预约的朋友一份能直接照做的参考。为什么我对这个组合特别有感触因为这两年找我做预约类小程序的人越来越多多数项目预算不高、周期又紧。PHP 做后端接口UniApp 做前端多端编译恰好把“开发成本”和“业务复杂度”压到了平衡点。很多朋友一听 PHP 觉得老但老有老的好处部署门槛低、资料多、云服务器上随便一套 nginx php-fpm 就能跑UniApp 则解决“今天要小程序、明天要 H5、后天又要 App”的反复需求一套 Vue 语法代码多端编译比原生开发省事太多。1. 项目定位与技术选型为什么是PHPUniApp组合1.1 这套源码到底解决什么问题我整理这套场馆预订系统业务原型是很常见的场景一个场馆下有多个场地比如羽毛球馆有8片场地共享办公区有3间会议室用户打开小程序选日期、选时间段能看到某片场地在某个时段是否空闲选定后下单支付到店后直接核销入场。后台要做的则是维护场馆和场地信息、设置不同时段的价格、查看订单流水、处理退款。这套系统里“智能”主要体现在三块一是库存自动管理每个场地每天按预设时段生成库存可约、锁定、已售状态实时更新二是超时订单自动释放用户下单后没付款倒计时结束库存自动放回不浪费场地资源三是防并发超卖同一个时段同时有两个人下单系统只允许一个人锁单成功另一人只能换时间。实现了这些场馆线上预订的核心链路就算完整了。1.2 PHP和UniApp的分工边界用生活里的例子打比方UniApp 是前台和菜单PHP 是后厨API 接口就是传菜窗口。用户看到的页面、点击的逻辑归前端管场地到底可不可订、订单金额怎么算、支付结果怎么确认全部交给后端。两端通过 JSON 数据格式互相“传菜”前端不直接操作数据库后端的业务改动也不会牵连页面。选 PHP 做后端不是因为技术多花哨而是因为这类 CRUD 型业务系统PHP 的生态实在太成熟。PHP 8 之后加入了 JIT性能比以前提升明显写场馆预订这种量级的接口完全够用。ThinkPHP 8、Laravel 10 都有很完整的路由、模型、中间件、队列组件数据校验、鉴权、支付回调都有现成方案。最重要的是部署成本低个人开发者随便买一台低配云服务器甚至虚拟主机都能跑起来这对预算有限的小场馆特别友好。UniApp 的价值在另一边。它基于 Vue 语法一次编写代码可编译到微信小程序、支付宝小程序、百度小程序、H5、iOS App、安卓 App。做外包项目时客户的需求经常变今天只要小程序明天看到别人有 App 又想要 App用 UniApp 就不用推翻重来前端主代码保持一套差异用条件编译隔离即可。1.3 多平台部署的关键认知条件编译不是玄学需要特别注意多端部署不是“一个按钮全搞定”的魔法。不同端的登录方式、支付通道、分享逻辑、定位授权、甚至包体积限制都不一样。以登录为例微信小程序里可以用uni.login拿 code 再向后端换 openidApp 端就得考虑一键登录、微信授权登录或账号密码登录的组合支付也是小程序端直接调wx.requestPaymentH5 端可能是跳转支付或 JSAPI 支付App 端又要用支付宝 SDK、微信 SDK。我前端代码里会大量出现#ifdef MP-WEIXIN、#ifdef H5、#ifdef APP-PLUS这类条件编译块。把所有平台差异塞进独立的小函数或条件分支公共页面逻辑保持一套。实际跑起来后你会发现这样写前期虽然稍微麻烦后期维护多端版本时特别省心。真正部署时只要把微信小程序、H5、App 对应的域名和密钥配好编译产物各自上传到对应平台就行。2. 数据库设计与接口设计把“预订”这件事拆到表里2.1 核心表结构说明预订系统的数据模型其实不复杂核心思路是把“场馆—场地—时段—库存—订单”拆成独立却又关联清晰的表。下面是我在实际项目中用的简化表结构字段做了裁剪但核心关系都在。-- 场馆表 CREATE TABLE venue ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 场馆名称, address varchar(255) NOT NULL DEFAULT , cover varchar(255) NOT NULL DEFAULT COMMENT 封面图, lat decimal(10,7) DEFAULT NULL COMMENT 纬度, lng decimal(10,7) DEFAULT NULL COMMENT 经度, status tinyint NOT NULL DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 场地表 CREATE TABLE venue_place ( id int unsigned NOT NULL AUTO_INCREMENT, venue_id int NOT NULL COMMENT 所属场馆ID, name varchar(50) NOT NULL COMMENT 场地名称如A1号场, type varchar(20) NOT NULL DEFAULT COMMENT 场地类型, status tinyint NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY venue_id (venue_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 时段规则表 CREATE TABLE venue_slot_rule ( id int unsigned NOT NULL AUTO_INCREMENT, venue_id int NOT NULL, start_time time NOT NULL COMMENT 开始时间, end_time time NOT NULL COMMENT 结束时间, price decimal(10,2) NOT NULL COMMENT 该时段基础价格, sort int NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 场地库存表 CREATE TABLE venue_slot_inventory ( id bigint unsigned NOT NULL AUTO_INCREMENT, venue_id int NOT NULL, place_id int NOT NULL COMMENT 场地ID, slot_date date NOT NULL COMMENT 日期, start_time time NOT NULL, end_time time NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0可订 1锁定 2已售, order_id int unsigned NOT NULL DEFAULT 0 COMMENT 占用订单ID, PRIMARY KEY (id), UNIQUE KEY uk_place_time (place_id,slot_date,start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE venue_order ( id int unsigned NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id int NOT NULL, venue_id int NOT NULL, total_amount decimal(10,2) NOT NULL, pay_amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退款 4已完成, pay_time datetime DEFAULT NULL, expire_time datetime NOT NULL COMMENT 支付过期时间, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表一个订单可能包含多个连续时段 CREATE TABLE venue_order_detail ( id int unsigned NOT NULL AUTO_INCREMENT, order_id int NOT NULL, place_id int NOT NULL, slot_date date NOT NULL, start_time time NOT NULL, end_time time NOT NULL, price decimal(10,2) NOT NULL, PRIMARY KEY (id), KEY order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的关键设计是venue_slot_inventory表。每一行代表“某天某片场地某个时间段”这种最小粒度的库存单元status字段直接标记可订、锁定、已售。这样做的好处是用户查询当日场次时一条 SQL 就能把可订状态拉出来不需要在多个表之间做复杂计算查询压力很小。时段规则表则用来批量生成每天的库存数据。2.2 时段库存与并发防超卖预订模块最容易出事故的地方是并发。用户点击支付时如果后端先查一遍库存发现状态为可订再去生成订单这个流程在两个人同时操作时一定会出问题。更合理的做法是直接执行一条带条件的原子更新语句用数据库行锁保证只有一个请求能抢到库存。我在下单接口里常用的是悲观锁或原子更新。ThinkPHP 里可以用lock(true)锁行再判断库存状态也可以用类似下面的 SQL 手法UPDATE venue_slot_inventory SET status 1, order_id ? WHERE id ? AND status 0执行后返回受影响行数如果为 0说明这个时段已经被别的用户抢占了直接提示“手慢了请换个时段”。这套逻辑就像抢火车票先查票再买票会遇到超卖直接拿着身份证去闸机口抢占谁先刷进去谁就拿到票。订单创建和库存锁定一定要放在同一个数据库事务里。先锁库存再写订单主表和明细表全部成功才提交事务。否则库存改了订单没写成功数据就对不上了。支付超时释放库存也很重要我通常会让后端在订单表里记录一个expire_time配合一个定时任务每5分钟扫描一次待支付且超时的订单把它改成已取消状态同时把对应库存的status从 1 改回 0。2.3 统一接口返回格式与基础代码示例前端和后端约定统一的 JSON 返回格式会让联调效率高很多。我习惯用code/msg/data三段式{ code: 0, msg: ok, data: { list: [], total: 100, page: 1 } }code为 0 表示成功非 0 表示业务失败msg是给用户看的提示文案data是业务数据。这样前端请求封装里只需要判断一次code就能处理成功和失败。下面是一段 ThinkPHP 8 风格的场馆列表接口public function venueList(Request $request) { $page $request-param(page, 1); $limit $request-param(limit, 10); $keyword $request-param(keyword, ); $query Venue::where(status, 1); if (!empty($keyword)) { $query-where(name, like, %{$keyword}%); } $list $query-page($page, $limit)-select(); $total $query-count(); return json([ code 0, msg ok, data [ list $list, total $total, page $page, ] ]); }下单接口则是重头戏。一个完整的创建订单流程包括验证场地是否可约、计算金额、锁库存、生成订单号、设置支付过期时间。注意订单号不要用自增 ID 直接暴露给前端可以用date(YmdHis) . random_int(100000, 999999)生成一串可读性较强的订单号避免被猜测和遍历。3. 用UniApp实现前端从页面到微信小程序打包3.1 前端项目结构与manifest配置UniApp 前端我一般按业务模块分目录典型结构如下pages/ index/index.vue 首页场馆列表 venue/detail.vue 场馆详情 booking/booking.vue 预订选场次 order/list.vue 我的订单 order/detail.vue 订单详情 user/index.vue 个人中心 static/ images/ api/ request.js 请求封装 venue.js 接口模块manifest.json是多端配置的核心。微信小程序要在这里填mp-weixin.appidH5 端要确认路由模式是不是history否则部分页面刷新后会 404App 端需要根据功能开启定位、相机等模块权限。我遇到过不少朋友把appid写成测试号结果预览时一直提示“未找到对应小程序”排查半天才发现配置没有同步。请求封装建议单独拎出来。所有接口走同一个request函数自动携带 token统一处理 401 跳登录和网络错误提示。下面是我常用的一版const BASE_URL https://api.example.com; export function request(url, method GET, data {}) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL url, method, data, header: { Content-Type: application/json, Authorization: uni.getStorageSync(token) || }, success: (res) { if (res.data.code 0) { resolve(res.data); } else { uni.showToast({ title: res.data.msg, icon: none }); reject(res.data); } }, fail: (err) { uni.showToast({ title: 网络异常请重试, icon: none }); reject(err); } }); }); }BASE_URL要在不同环境切换。我在开发环境用http://localhost:8000打包上线时再改成 HTTPS 域名。需要注意的是微信小程序正式版强制要求域名必须备案且支持 HTTPS所以开发调试和上线部署通常要两套配置。3.2 小程序登录与手机号获取场馆预订一般建议先登录再下单最简单的方式是微信小程序静默登录调uni.login拿到临时code传给后端后端拿着code调微信code2Session接口换回openid再生成我们自己的登录态 token 返回给前端。手机号获取现在跟早期逻辑不一样了不是随便调接口就能拿全号。现在通常的做法是页面上放一个button设置open-typegetPhoneNumber用户点击授权后前端拿到带有加密数据的code再把code传给后端由后端调用微信接口解密或换取手机号。前端代码大概是button open-typegetPhoneNumber getphonenumberhandleGetPhoneNumber 获取手机号 /buttonasync handleGetPhoneNumber(e) { if (e.detail.code) { const res await request(/api/user/bindPhone, POST, { code: e.detail.code }); uni.showToast({ title: 绑定成功, icon: success }); } else { uni.showToast({ title: 已取消授权, icon: none }); } }这里有个很常见的坑开发者工具里可以模拟返回手机号但真机上e.detail不一定跟开发工具完全一样所以一定要用真机测试授权流程。否则你辛辛苦苦写完客户一拿手机试就发现绑定不了。3.3 场馆列表和预订时段选择首页场馆列表我用uni.request拉接口加上onReachBottom做分页加载。列表卡片展示封面图、场馆名称、地址、最低价格和距离。场馆详情页再请求一个详情接口把场地数、营业时间、图片集、公告这些信息渲染出来。预订页是整套前端里交互最复杂的页面。我一般分三块顶部日期选择器展示未来7天用横向scroll-view滚动每个日期显示星期几和日期号。中间场地/时段矩阵每一行是一个场地每一列是一个时段格子颜色表示状态绿色可订、灰色锁定、红色已售。底部订单栏实时累计用户选中的时段和金额点击“去支付”时把所有选中项一次性提交给后端。前端拿到库存接口后要把status字段直接映射到格子的禁用态。这里不能只做 UI 禁用后端接口还必须再次校验因为前端所有数据都是可以被修改的。前端的作用是体验优化真正的“守门员”永远是后端。3.4 微信小程序打包超2MB的经典问题小程序平台限制主包大小不能超过 2MB这是很多开发者的噩梦。我见过有人打包后提示source size 2612kb exceed max limit 2mb第一反应是删代码结果删完还是超。真正有效的方案是分包加载。在pages.json里配置subPackages把不常用的页面拆进分包。比如订单列表、订单详情、用户协议、关于我们这些功能使用率低完全可以放到子包里{ pages: [ { path: pages/index/index, style: {} }, { path: pages/venue/detail, style: {} } ], subPackages: [ { root: pages/order, pages: [ { path: list, style: {} }, { path: detail, style: {} } ] } ] }分包的意义是把首次打开必须用到的内容留在主包把低频页面拆出去小程序会按需加载。除了分包还要做三件事图片尽量放线上 CDN 而不是本地静态目录UI 组件用到了哪个引哪个不要全量引入整套组件库在微信开发者工具里勾选“上传代码时自动压缩脚本文件”和“ES6 转 ES5”。4. PHP后端开发与部署避坑4.1 JWT登录鉴权与身份安全场馆预订系统涉及订单和支付不能把用户 ID 直接存到前端 storage 里当作登录凭证。我用 JWT 做用户认证用户登录后后端签发一个 token前端存在本地每次请求放到Authorization头里。后端中间件校验 token并把解析出的用户 ID 注入到请求对象中。JWT 的过期时间我一般设置成 7 天。场馆预订用户不是天天打开小程序Token 过期太短会让用户频繁重新登录过期太长又有安全风险。如果需要“长期登录”可以让前端在请求返回 401 时先调刷新 token 接口再重放原请求。实现起来也不复杂核心还是前后端遵守同一套约定。接口越权是另一个不得不防的点。比如用户 A 想取消用户 B 的订单如果后端只校验“订单存在”不去比对user_id就会出大事故。所有涉及订单、个人信息的接口拿到订单后第一步必须是判断当前用户是否为订单归属人否则直接返回无权限。4.2 跨域处理与JSONP兼容H5 端部署时最容易遇到跨域问题。小程序没有浏览器同源策略但 H5 有。后端正则是在所有对外接口前统一设置 CORS 响应头header(Access-Control-Allow-Origin: *); header(Access-Control-Allow-Methods: GET, POST, OPTIONS); header(Access-Control-Allow-Headers: Authorization, Content-Type); if ($_SERVER[REQUEST_METHOD] OPTIONS) { http_response_code(204); exit; }如果接口需要携带 CookieAccess-Control-Allow-Origin就不能用*必须写成具体域名。以前老的 H5 项目里还会用 JSONP 解决跨域到现在已经不建议了JSONP 只能支持 GET而且没法在请求头里加 token类型的业务接口根本没法安全使用。新项目直接 CORS 就够了。开发阶段 UniApp 也可以自己配代理转发把/api开头的请求转发到后端地址这样不需要后端额外处理跨域。但生产环境如果后端不设 CORSH5 部署在 CDN 上照样会出问题所以后端统一设置 CORS 是最省心的方案。4.3 支付回调与订单状态机支付环节是场馆预订系统的定盘星。用户前端发起支付前后端要先调用微信支付或支付宝的“统一下单”接口拿到预支付参数然后返回给前端调起支付。支付结果不是靠前端跳转页判断的而是微信或支付宝服务器主动请求我们后端的一个回调地址。回调地址必须是一个外网可访问的 HTTPS 接口。回调逻辑表面看简单实际坑很多。第一要验签回调数据里的签名不合法直接忽略第二要幂等同一笔订单可能收到多次回调后端要保证第二次回调不会重复处理第三要返回微信规定的成功报文否则微信会认为回调失败反复通知public function payNotify() { $xml file_get_contents(php://input); // 1. 验证签名 // 2. 解析订单号、支付金额 // 3. 校验订单状态只能从未支付更新为已支付 // 4. 修改订单状态、写支付时间 // 5. 返回微信成功报文 return response( xmlreturn_code![CDATA[SUCCESS]]/return_code/xml, 200 ); }订单状态机我固定为0 待支付1 已支付2 已取消3 已退款4 已完成。只有“待支付”状态可以变成“已支付”或“已取消”已支付可以变成已退款或已完成。这样在代码里就杜绝了状态乱跳的问题。支付成功之后如果之前锁的是「锁定」状态要更新为「已售」同时通知场地运营方。4.4 常用开发环境和老旧环境问题实录PHP 8 配合 PhpStorm 是我最喜欢的开发组合。PhpStorm 对 ThinkPHP 的跳转、补全都很友好调试接口时可以用 Postman 或 Apifox 做接口测试。本地环境我常用小皮面板这种集成环境省去手工配置 PHP、MySQL、Nginx 的过程几秒钟就能起一个干净的环境。不过 PHP 8 在 Windows 下有一个高频问题就是php warning: c:\windows\system32\vcruntime140.dll 14.0 is not compatible。这个报错多数时候是系统里的 VC 运行库版本太旧PHP 8 需要的是较新的 Microsoft Visual C Redistributable。解决办法是把 2015-2022 版本的 x64 运行库装上重开命令行再测试php -v就正常了。还有一个是 Linux 下源码编译 PHP 时报no package libzip found。这是因为新版 PHP 编译要求libzip扩展库低于 1.3 版本都不认。用包管理器提前装好再 configure# CentOS yum install -y libzip-devel # Ubuntu/Debian apt install -y libzip-dev如果你完全用不到 ZipArchive 功能也可以编译时加--without-zip跳过这个依赖但一般服务器上部署站点都离不开 zip 解压建议还是正装好依赖。5. 常见问题排查与源码交付实用技巧5.1 高频问题速查表代码部署和交付过程中我经常会遇到下面这些重复出现的问题整理成一张速查表问题现象常见原因排查与解决uniapp 微信小程序打包提示 source size 2612kb exceed max limit 2mb主包过大依赖和图片太多启用分包低频页面拆到 subPackages图片放 CDN按需引入组件库勾选压缩脚本uniapp 不打印日志信息控制台过滤级别或真机调试模式问题在 HBuilderX 控制台切换日志级别小程序端打开 vConsole确认代码在非生产环境PHP 命令行报 vcruntime140.dll 版本不兼容VC 运行库版本过旧安装 Microsoft Visual C 2015-2022 x64 运行库编译 PHP 报 no package libzip found缺少 libzip-devel 依赖安装对应包确认版本不低于 1.3不需要 zip 时可加--without-zipH5 端请求接口跨域失败后端未设置 CORS后端加 CORS 响应头或前端 devServer 代理微信开发者工具打开项目空白导错了目录导入dist/dev/mp-weixin目录不是整个 uni-app 工程小程序真机获取不到手机号未用 button 触发、未认证或后端未正确解密确认按钮 open-type开发者工具模拟数据与真机有差异后端使用官方接口解密上线小程序接口请求失败域名未配置或未备案小程序后台配置合法 request 域名要求 HTTPS 已备案这张表虽然不能覆盖所有细节但基本把初学者高频撞墙的地方都列全了。遇到问题先按表格逐项排查不要第一反应就去改业务逻辑。5.2 拿到源码后的正确启动顺序源码交付之后最容易出现的情况是拿到手就傻眼。我这里给出一个标准的启动顺序照着做能少走很多弯路准备后端环境安装 PHP 8 MySQL 5.7/8.0 Nginx/Apache本地可以直接用小皮面板。导入数据库把项目里的*.sql文件导入 MySQL确认表结构完整生成。修改后端配置数据库连接信息、Redis 配置、支付密钥等写在.env或config/database.php里按实际环境改掉。启动后端接口本地能访问/api/venue/list之类接口并返回 JSON 即可。用 HBuilderX 导入前端源码不要直接双击.vue文件要用 HBuilderX 的“导入项目”功能。修改前端api/request.js里的BASE_URL指向你的后端地址。运行到微信开发者工具HBuilderX 点击“运行到小程序模拟器”微信开发者工具里查看效果。线上部署后端接口上传服务器前端源码在 HBuilderX 里点击“发行”分别生成小程序包、H5 压缩包或 App 安装包。这个顺序只要走通一遍你脑子里就会很清晰地知道哪一层负责什么。以后不管是改样式还是加功能都能快速定位到具体文件。5.3 从场馆预订扩展到更多场景这套表结构和接口设计并不只适合运动场馆很多预约类业务都能直接改。共享会议室、健身房私教课、自习室座位、甚至美容美发店的时段预约核心都是“资源 时间 订单”三元组。二次开发时你只需要改场馆名称把场地换成会议室、工位或教练接口逻辑基本不用大动。我还建议在此基础上逐步扩展会员体系。可以加 VIP 等级不同等级享受不同折扣加次卡和储值卡用户购买后按次抵扣加优惠券按场馆、按品类、按金额门槛发放。这些都建立在现有用户表和订单表之上只是多设计几张券表和核销记录不会伤筋动骨。预约业务做到后面“核销”环节很重要。用户到店后场馆前台可以用管理员账号或扫码枪扫用户订单二维码核销成功后再把订单状态改为已完成。这个功能我一般会在订单详情页生成一个动态二维码前端定时刷新后端核销接口做状态校验能有效防止订单截图重复使用。做这套 PHP UniApp 场馆预订系统我最大的实际体会是第一版千万不要贪大求全。先跑通“场馆列表—选时段—创建订单—模拟支付—后台看单”这条主链路哪怕 UI 丑一点都没关系。主链路稳定了用户信息、优惠券、多图展示、分享裂变这些都是后续慢慢加锦上添花的功能。如果你正准备接手一个预约类小程序项目抓住“库存状态原子更新”和“支付回调幂等处理”这两个核心这个项目就不会翻车。剩下的细节在跑代码的过程里自然会一点点暴露出来比看一百篇文档都管用。