直接以代码和业务逻辑为线索,聊聊这个微信小程序 PHP 酒店预定系统的设计与实现。如果你手头正好在搞类似的景区、民宿或者酒店预订项目,这里面有不少可以直接抄作业的方案,也有一些必须提前知道的坑。
1. 项目整体设计与需求拆解
先把这个项目的底色说清楚。这是一个面向景区场景的酒店在线预定系统,客户端是微信小程序,服务端用的是 PHP,数据库基本就是 MySQL,标题里那串 _98434fj3 是源码包或者平台的编号,不用管它。核心就三件事:让游客在小程序里找酒店、看房型、下单支付;让管理员在后台维护房间数据、处理订单;让景区运营方拿到一整套可用的线上预订能力。
先说为什么选微信小程序,而不是 H5 或者 App。景区的消费场景很特殊,游客是从各个渠道进来的,大部分是到了景区门口或者在山下找住宿时才想起来订房,根本不会提前安装你的 App。小程序属于真实环境下的随用随走,扫码就能进,不用下载、不用注册账号,微信授权就能拿到用户身份。这一点对景区酒店预订这种低频、高即时性需求来说,是压倒性的优势。
再说为什么后端用 PHP,很多人觉得 PHP 老,但放在这个业务场景里,PHP 的优势非常明显——部署简单、运维成本低、生态成熟。景区项目的体量一般是中小型,日订单量几百到几千,这台服务上跑 PHP 和 MySQL,不会有任何性能压力。我见过很多项目一上来就搞微服务、消息队列,最后维护的人苦不堪言。这个系统的正确做法就是老老实实用 PHP + MySQL,把钱和精力花在业务逻辑的正确性上。
从功能上拆,系统分成两条线:
- 用户端(微信小程序):景区首页、酒店列表、酒店详情、房型与日历、创建订单、微信支付、订单列表、订单详情、个人信息。
- 管理端(Web 后台):登录鉴权、酒店管理、房型管理、楼层房间管理、订单管理、价格管理、基础配置。
核心业务流是:用户选酒店 → 选日期 → 选房型 → 选择房间数量 → 提交订单 → 支付 → 商家确认/系统自动确认 → 入住核销 → 完成。整个系统的难点全在中间这一段:房间的库存怎么控制、日期价格怎么算、订单状态怎么迁移、支付回调怎么处理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:表结构就是业务的骨架
数据库设计是这个项目最关键的部分,表结构一旦定下来,后续的业务逻辑都得围着它转。我直接把我实操过的一套表结构拿出来讲,你可以根据自家景区的实际情况微调。
2.1 用户表与管理员表
用户表字段简化后是这个样子:
sql复制CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`openid` varchar(64) NOT NULL COMMENT '微信openid',
`nickname` varchar(64) DEFAULT '' COMMENT '昵称',
`avatar` varchar(255) DEFAULT '' COMMENT '头像',
`phone` varchar(20) DEFAULT '' COMMENT '手机号',
`create_time` int(11) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_openid` (`openid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
openid 是用户在这个小程序里的唯一身份标识,必须建唯一索引。注意一点,同一个微信号在不同的小程序里 openid 是不一样的,这个设计本身就是微信平台的安全机制,保存 openid 即可识别用户身份。手机上会用手机号,但只有在用户主动授权时才能拿到。别一上来就强制要求填写手机号,这样会流失用户,建议让微信授权登录先行,下单时再引导绑定手机号。
管理员表就简单了,id、用户名、密码字段,密码用 password_hash() 哈希加密存储。
2.2 酒店表与房型表
酒店表字段包括酒店名称、封面图、地址、简介、经纬度、联系电话、状态(营业/停用)、排序权重。经纬度这玩意儿很实用,小程序端可以用地图功能展示酒店位置,方便游客找路。
房型表要特别注意,我贴个关键结构:
sql复制CREATE TABLE `room_type` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`hotel_id` int(11) NOT NULL COMMENT '所属酒店',
`type_name` varchar(50) NOT NULL COMMENT '房型名,如大床房',
`bed_info` varchar(100) DEFAULT '' COMMENT '床型描述',
`area` varchar(30) DEFAULT '' COMMENT '面积',
`max_people` tinyint(4) DEFAULT 2 COMMENT '可住人数',
`cover_image` varchar(255) DEFAULT '',
`image_list` text COMMENT '多图,json',
`price` decimal(10,2) NOT NULL COMMENT '挂牌价',
`inventory` int(11) NOT NULL DEFAULT 0 COMMENT '总房量',
`status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1上架 0下架',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
重点说一下房型库存。很多人在这里容易把“库存”设计成一个单纯减少的数字,比如 inventory - 1,这在酒店行业是行不通的。酒店的房间是物理存在的,比如某酒店有 20 间大床房,你卖出去 18 间,还剩 2 间,如果同一时间段内有人退订、有人取消,这个数字就会乱。更合理的做法是房型表只存一个总房量,也就是物理房间数,而“还剩几间”是根据日期去实时计算出来的,而不是存一个被减过的库存值。
2.3 订单表:最核心的一张表
订单表的字段比较多,直接看结构:
sql复制CREATE TABLE `order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '订单号',
`user_id` int(11) NOT NULL COMMENT '用户id',
`hotel_id` int(11) NOT NULL,
`room_type_id` int(11) NOT NULL COMMENT '房型id',
`check_in_date` date NOT NULL COMMENT '入住日期',
`check_out_date` date NOT NULL COMMENT '离店日期',
`room_count` tinyint(4) NOT NULL DEFAULT 1 COMMENT '房间数',
`total_nights` tinyint(4) NOT NULL COMMENT '总晚数',
`order_amount` decimal(10,2) NOT NULL COMMENT '订单金额',
`pay_amount` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '实付金额',
`customer_name` varchar(30) DEFAULT '' COMMENT '入住人姓名',
`customer_phone` varchar(20) DEFAULT '',
`status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '订单状态',
`transaction_id` varchar(64) DEFAULT '' COMMENT '微信支付单号',
`pay_time` int(11) DEFAULT 0,
`cancel_time` int(11) DEFAULT 0,
`remark` varchar(255) DEFAULT '',
`create_time` int(11) NOT NULL DEFAULT 0,
`update_time` int(11) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_hotel_id` (`hotel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
订单状态 status 是整个系统的业务核心,我用数字枚举表示,建议从 0 开始递增:
| 状态值 | 含义 | 说明 |
|---|---|---|
| 0 | 待支付 | 用户已下单,未支付或支付超时 |
| 1 | 已支付/待确认 | 支付成功,等待商家确认(也可设为自动确认) |
| 2 | 已确认 | 商家确认接单,游客届时可直接办理入住 |
| 3 | 已入住 | 办理入住完成(核销) |
| 4 | 已完成 | 退房完成,订单结束 |
| 5 | 已取消 | 用户或超时取消 |
| 6 | 退款中 | 用户申请退款或商家发起退款 |
| 7 | 已退款 | 退款完成 |
这里我特别强调一件事:订单金额和实付金额是两个字段,不要只用一个金额字段。因为存在优惠、临时调价、部分退款的情况,金额字段分开后,账才对得上。我当时就是因为偷懒只存了一个金额,后面做退款对账的时候差点把数据库翻了个底朝天,才开始把这两者分开。
订单号建议用日期 + 随机串,我用的规则是 date('YmdHis') . mt_rand(1000, 9999),不加用户 id 因为订单号要全局唯一,不能凭借用户 id 区分,必须完全随机生成,防止他人顺着订单号规律批量遍历接口。
2.4 日期价格库存表
酒店行业还有一个特殊的地方:同一种房间在国庆和平时价格不一样,甚至周末和工作日都不同。所以价格不能只写在房型表里,得有一个按日期动态配置的表:
sql复制CREATE TABLE `room_price` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`room_type_id` int(11) NOT NULL,
`date` date NOT NULL COMMENT '日期',
`price` decimal(10,2) NOT NULL COMMENT '当日价格',
`status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1可售 0不可售',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_room_date` (`room_type_id`, `date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
库存实时计算时,需要同时查这个表判断当天该房型是否可卖、卖多少钱。
3. 小程序端核心实现细节
3.1 项目结构与登录逻辑
小程序端我建议用原生加一个轻量 API 封装即可,不需要上框架,景区项目页面量不大,原生足够。
关键目录结构就是 pages/ 下的首页、酒店详情、预订、订单、个人中心、支付结果页,再加 utils/request.js 封装请求和 utils/auth.js 管理登录态。
登录流程是这个项目里很核心的一环。我用的流程是:
- 小程序
wx.login()拿到临时 code。 - 调用后端接口
login,后端用 code 换 openid 和 session_key,查用户表,若存在则登录成功,不存在则自动注册并返回新用户 id。 - 后端生成自定义 token(我用的
md5(openid . time() . rand())),存到 redis 或数据库,返回给前端。 - 前端把 token 存到
wx.setStorageSync,后续所有请求头里带上Authorization: Bearer token。
这里要注意,小程序端不能直接用 openid 做身份标识传给服务端,openid 需要由后端通过 code 换取,前端永远不要接触 openid,这是微信平台的多重安全防线之一,谁让对方拿到 openid 谁就是给脚本攻击留了后门。
3.2 首页与酒店列表页
首页就是景区的门面,一般放景区大图轮播、热门酒店推荐、公告信息三个模块就行。景区项目的用户决策链路很短,游客不怎么在你的首页里深度逛,他们要的就是快速找到能住的酒店。所以首页设计不需要太花哨,图片清晰、价格明确、位置清楚,这三样比什么都重要。
酒店列表页建议支持三个筛选条件:区域(如果景区大,可以按山下、山里、景区门口分)、价格区间、排序(推荐 / 价格从低到高 / 价格从高到低)。数据量不大,直接后端查询并用 order by 语句实现排序即可。
3.3 预订页:日期与价格的联动计算
这是整个小程序端技术含量最高的页面,没有之一。
页面包含:日期选择器(入住和离店)、房型列表(含当日价格和剩余房间数)、入住人信息填写。
日期选择器我用的是日历组件,入住日当天、离店日之后的可选范围用 disabled 属性控制。选完日期后,前端根据 check_in_date 和 check_out_date 计算出总晚数,然后请求后端接口:
javascript复制// 请求某房型在某日期段的价格库存
wx.request({
url: `${baseUrl}/api/room/price`,
data: {
room_type_id: this.data.roomTypeId,
check_in_date: this.data.checkInDate,
check_out_date: this.data.checkOutDate
}
})
后端返回一个数组,包含每一天的价格和一个总价。前端把总价展示出来,用户确认后下单。
价格计算逻辑在 PHP 里可能是这样的:
php复制$days = [];
$current = $checkInDate;
while ($current < $checkOutDate) {
$price = getRoomPriceByDate($roomTypeId, $current);
if (!$price || $price['status'] != 1) {
// 该日期不可售,直接返回错误
}
$days[] = $price;
$current = date('Y-m-d', strtotime($current . ' +1 day'));
}
$totalPrice = array_sum(array_column($days, 'price'));
这里有个很容易被忽略的细节:跨夜和跨月都没问题,但一定要用日期字符串比较而不是时间戳直接除以 86400。跨夏令时、跨月的场景下,除法计算天数会莫名多一天,这是 Java/PHP 出生日期间隔计算的经典坑,做日期的从业者都知道,必须用 strtotime 加一天往前走。
3.4 下单与支付流程
下单的流程要谨慎设计。我建议走两步:第一步创建订单,订单状态为待支付;第二步调起微信支付。
创建订单的完整流程:
- 用户点“提交订单”,前端把
hotel_id、room_type_id、check_in_date、check_out_date、room_count、customer_name、customer_phone传给后端。 - 后端先校验房间是否可达,再校验价格是否跟当前实际价格一致,防止前端改价格。
- 后端生成订单记录,状态为待支付。
- 后端把订单号返回给前端。
- 小程序前端调用
wx.requestPayment,参数由后端通过微信支付统一下单接口获取后返回。 - 用户支付成功后,前端跳转到订单详情页,等待后端回调更新状态。
这里强调下,价格校验必须放在后端,前端传过来的金额只能作为订单金额,不能作为支付金额依据。我在实际项目中遇过有人直接改前端参数把 598 改成 5.98 的情况,后端没有校验前端传过来的总价,直接照着支付了。这个 bug 上线第一天就被薅了,堪称真实环境下的经典灾难现场,所以后端一定用自己根据日期算出来的价格参与支付金额计算。
微信支付回调地址,要注意配置成公网能访问的地址,并且回调处理必须是幂等的(处理回调时先查订单状态,如果是已支付就直接返回成功,不再重复更新)。
4. PHP 后端接口与业务逻辑实现
4.1 接口设计风格
后端接口我建议做成统一返回格式:
json复制{
"code": 0,
"msg": "success",
"data": {}
}
code 为 0 表示成功,非 0 为各类错误码。这个格式虽然简单,但对小程序端来说足够。接口风格统一,前端封装 request.js 时就只需要判断一个 code 而不需要额外抓取异常文本。
我基于原生的 PDO 封装了一个简单的 Db 类和 ApiResponse 类。框架方面如果你习惯用 ThinkPHP 或 Laravel 也可以,但除非你后续要做很复杂的后台,否则原生 PHP 写个简单的路由就够了,维护成本最低。
路由风格我用的还是简单的 POST /api/xxx 形式,通过一个 index.php 做入口分发,没搞复杂的路由组件。小程序端请求路径都是相对路径加统一前缀,部署时也方便。
4.2 库存检查与订单创建(防超卖)
酒店预订系统最常见的坑是超卖,就是两个人同时订最后一间房。
我先讲讲为什么会超卖。假设房间总量是 1,两个用户同时下单,以下逻辑就会出现问题:
code复制A: SELECT inventory FROM room WHERE id=1 => 1
B: SELECT inventory FROM room WHERE id=1 => 1
A: UPDATE room SET inventory = inventory - 1 => 0
B: UPDATE room SET inventory = inventory - 1 => -1 // 超卖
那怎么办?有几种思路:
- 方案一:悲观锁,在事务里用
SELECT ... FOR UPDATE锁住房间记录,B 等待 A 提交后才能继续。 - 方案二:乐观锁,UPDATE 时带上版本号或者条件
WHERE inventory > 0。 - 方案三:预占库存设计。酒店行业更合理的方案是,用户提交订单时记一条“预占记录”,支付成功才真正扣减,支付超时自动释放。但这样做实现复杂,对于日均单量几百的景区项目,直接用事务 + 行锁就够了,简单可靠。
我的实际实现是创建一个订单时,直接在一个事务里先锁房型行并检查以验证可售数量:
php复制try {
$pdo->beginTransaction();
// 锁定房型记录
$stmt = $pdo->prepare("SELECT * FROM room_type WHERE id = ? FOR UPDATE");
$stmt->execute([$roomTypeId]);
$roomType = $stmt->fetch();
if (!$roomType) {
throw new Exception('房型不存在');
}
// 检查目标日期段的剩余可售量
// 可以在事务里查询该房型、日期段的订单数,判断是否超过总量
$soldCount = 查询已占用房间数(房型, 日期段);
if ($soldCount >= $roomType['inventory']) {
throw new Exception('该日期段已满房');
}
// 插入订单
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
return error($e->getMessage());
}
事务的特性是原子性、一致性、隔离性、持久性,在这个场景里能保证:要么两个人同时创建订单只有一个人成功,要么两个人同时提交时后一个人能看到前一个人的结果。项目单量不是特别大的情况下,FOR UPDATE 的性能开销完全不是问题。
还有一个细节:锁的记录是房型,而不是单日。因为订酒店往往跨多晚,锁房型才能一次性把整个日期段保护住。如果锁的是单日库存记录,跨多晚查询和扣减时逻辑会复杂很多。
4.3 微信支付回调与订单状态流转
支付回调往往是整个项目里最容易出问题的环节。主要原因是谁也不能保证回调一定会到达你的服务器,网络问题、服务器重启、回调超时都有可能。
回调处理的核心规则是:处理结果必须幂等。什么意思?就是同一个回调请求发过来多次,处理结果都一样,不会重复更新订单状态、不会重复加钱。
我的实现是:
php复制// 先查订单是否存在
$order = 查订单(order_no);
if (!$order) 返回失败;
// 如果已经是已支付状态,直接返回成功,不再处理
if ($order['status'] == 1) {
return success('重复回调');
}
// 校验金额和商户单号
if ($order['pay_amount'] != $callback['total_fee']/100) {
return fail('金额不符');
}
// 更新订单状态为已支付,记录 transaction_id 和支付时间
$order->status = 1;
$order->transaction_id = $callback['transaction_id'];
$order->pay_time = time();
$order->save();
return success('OK');
回调接口返回给微信支付的通知成功标识很重要,我当年接口返回的是 JSON,微信那边认不出来,就一直重试,最后查日志才发现问题。回调接口必须返回纯文本并且仅为标准的成功标识字符串,这是一个运营过程中的实际教训,现在还记得很清楚。
订单状态流转图我不用画,心里有数就行:
code复制待支付 --支付成功--> 已支付/待确认
待支付 --超时/取消--> 已取消
已支付 --商家确认--> 已确认
已确认 --办理入住--> 已入住
已入住 --退房--> 已完成
已支付/已确认 --取消/退款--> 已取消/已退款
4.4 管理后台功能
管理后台我用了最简单的 Web 形式,考虑到实际让景区前台员工用,界面不用炫,逻辑清晰最重要。
后台核心功能:
- 酒店和房型管理:增删改查,图片上传。
- 日期价格管理:默认继承房型挂牌价,支持单个日期批量设置(比如把国庆期间的房间价格全部上调 200)。
- 订单管理:列表、详情、确认接单、办理入住、退款操作、取消订单。
- 数据统计:按日订单量、成交金额、房态利用率。
其中“日期价格批量设置”是我后来加的功能。第一期只做了挂牌价,结果景区每逢周末,员工就得一张一张改价格,累得不行。后来我加了一排日期和价格的批量设置功能,后台直接选节假日、选房型、填价格、一键生效。因为有了酒店行业的实际痛点反馈,多为用户想一步,系统才是真正在解决运营问题。
5. 部署上线与常见问题排查
5.1 部署清单
部署这块其实不复杂,但有几个坑必须提前说:
- 微信小程序域名要求:小程序请求的接口地址必须是 HTTPS 域名,并且要在小程序管理后台添加服务器域名白名单。开发阶段可以勾选“不校验合法域名”,但上线必须关掉。
- PHP 环境建议:PHP 7.4 以上(我推荐 8.0 或 8.1),MySQL 5.7 以上,Nginx 或 Apache 均可,建议 Nginx 加 PHP-FPM。
- HTTPS 证书:可以用免费的 SSL 证书,注意证书到期及时续期,否则小程序突然请求失败。
- 图片资源:酒店图片建议用对象存储加 CDN,不要直接放在服务器本地,否则日积月累会把磁盘撑爆。
- 时间时区:服务器时区一定要设置为
Asia/Shanghai,否则订单创建时间和支付回调时间会对不上,对账会疯掉。
5.2 常见问题速查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
小程序请求接口报 request:fail |
域名不是 HTTPS 或者域名不在白名单 | 配置证书,添加 request 合法域名 |
| 登录后刷新就掉线 | token 存了但没有过期时间校验 | 增加 token 过期机制,前端拦截器检测 401 重新登录 |
| 用户已支付但订单未更新 | 支付回调没收到或处理失败 | 查回调日志,确认返回格式,补发手动对账按钮 |
| 明明有房却提示满房 | 库存计算逻辑重复扣减 | 检查订单状态统计时是否把已取消订单也算进去了 |
| 价格对不上 | 日期价格表没有覆盖到某天 | 使用日期价格回退机制,缺失日期使用挂牌价兜底 |
| 图片加载慢 | 服务器带宽不够 | 图片改走对象存储 CDN,压缩尺寸 |
| 超卖 | 缺少事务或锁 | 订单创建增加行锁,状态检查与扣减必须在同一事务 |
5.3 开发环境联调技巧
本地开发时,微信开发者工具可以勾选“不校验合法域名”,所以前端可以请求 http://localhost 或局域网 IP。但后端一定要处理跨域,因为小程序的请求不是从浏览器发起的,跨域限制和普通浏览器不同,不过现实情况里不同,对后端来说,只需要直接把接口返回 JSON 就好,不涉及跨域,真正难办的是本地想用 HTTPS 调试支付回调。
微信支付的回调必须要是公网域名且是 HTTPS,本地开发想调通支付,比较常用的方案是用内网穿透工具把本地端口映射到公网,或者直接在后端留一个手动标记支付成功的测试按钮,开发阶段用。我用的就是后者,省事且稳定得多,上线前再走真实支付流程测一遍就行。
6. 上线后的一些心得体会
这个项目做下来,我最大的感受是:一个景区酒店预定系统,技术难度并不高,难的是把酒店行业的业务细节处理好。
比如日期价格、跨天订单、满房校验、支付回调幂等、退款流程,每一块都得提前想清楚,否则上线就是事故现场。很多做这类项目的开发者第一次都是牧师视角,觉得 CRUD 不就完了吗,直到第一个五一节订单量暴涨、库存开始超卖、支付回调堆积、价格对不上的时候才意识到,真正的工程难度全在边界条件和并发控制里。
在数据库设计上,我建议从一开始就把“订单表”设计得足够完整,所有金额、日期、联系电话、备注信息都存下来,不然后面运营方要导出报表的时候,你会发现缺了各种字段,补数据可比写代码痛苦多了。
还有一点想对正在做类似项目的朋友说:技术选型就用你最顺手的。PHP 在这个体量的系统里完全够用,MySQL 也是,别被网上天天鼓吹换新架构的文章带偏。一个景区项目的核心诉求是稳定、能改、好维护,而不是技术栈新。我见过太多系统因为用了过度设计的架构,最后连改个字段都要折腾半天,那才是真正的灾难。
最后分享一个小技巧,也是我后来一直在用的:给订单表加一个 remark 字段,用户在后台可以记录一些备注(比如“客人要求高楼层”“带婴儿需要安静房间”),这个小功能在真实运营中非常受欢迎,前台和客房沟通顺畅了很多。有时候提升体验的,不是复杂的功能,而是这些细节上的用心。
