多酒店PHP系统源码实战:数据库设计、三端预订与门店隔离
简介这是一套基于PHP开发的多酒店管理系统源码面向酒店运营方、连锁酒店管理者以及希望学习PHP实战项目的开发者配套APP、H5与微信小程序预订端可解决多门店统一管理、客房预订与订单处理等业务需求。压缩包共约2000个文件以1428个PHP源码文件为核心辅以HTML、CSS、SCSS、JS等前端资源以及JPG、PNG、GIF图片和MP3音频素材另含SQL数据库脚本与JSON、XML等配置文件整体约78.76MB。系统覆盖多酒店独立配置、客房预订、入住退房、订单跟踪、会员积分优惠券、财务账单与入住率收入报表等模块移动端支持实时同步、在线支付与消息推送小程序则侧重即用即走与社交分享。目前已有3603人学习下载适合作为PHP酒店管理领域的学习参考与二次开发基础。1. 多酒店 PHP 系统到底解决了什么从一张房价表说起同一座城市里三家门店前台在微信群里发一句「今天大床房还剩几间」后台却要三个人分别登录三套系统去改价、改库存、改订单状态——这是我见过最典型的多酒店管理翻车现场。PHP酒店管理系统源码多酒店数据库酒店管理系统APPH5小程序预订这套东西要解决的本质就是「一套代码、一个数据库、多个门店、三种终端」的集中管控问题总部改一次房价门店 APP、H5 页面、微信小程序同时生效订单落到同一张表里谁也不用再对账。它适合谁一是做酒店管理系统毕业设计的学生需要一套能跑通、能讲清架构的完整源码二是中小连锁酒店的技术负责人预算有限但门店在 320 家之间买不起主流酒店管理系统的定制版三是接私活的开发者客户要「APP H5 小程序」三端预订还想自己管房价。不适合谁单体酒店没必要上多酒店架构纯 SaaS 大厂方案也不该拿这套源码硬扛。这套系统的技术底座很朴素PHP 做后端接口MySQL 存业务数据多酒店靠「门店 ID 隔离」实现前端三端共用一套 REST 接口。下面按「架构怎么立 → 数据库怎么设计 → 接口怎么写 → 三端怎么接 → 坑在哪」的顺序拆开讲每一步都能照着复现。2. 多酒店架构怎么立门店隔离的三种方案与选型2.1 为什么不做「一店一库」而选共享库加门店 ID多酒店系统最核心的架构决策是数据隔离方式常见三种一店一库、一店一表、共享库加门店 ID 字段。一店一库听起来最干净但门店超过 5 家后改一次表结构要跑 N 个库备份、统计、跨店报表全是噩梦一店一表更糟表数量爆炸SQL 拼接容易出错。我一般会选共享库加hotel_id字段理由是改表结构只改一次跨店统计一条GROUP BY hotel_id搞定权限控制靠中间件统一拦截。代价是每个查询都必须带hotel_id条件漏一个就是数据串店——这是这套架构唯一的、也是最致命的坑。解决办法是在数据访问层强制注入而不是靠开发者自觉。下面是一个极简的查询封装示例?php // Db.php —— 带门店隔离的查询封装 class Db { private $pdo; private $hotelId; public function __construct($pdo, $hotelId) { $this-pdo $pdo; $this-hotelId (int)$hotelId; // 当前登录用户所属门店 } // 所有查询强制拼 hotel_id杜绝串店 public function query($table, $where [], $fields *) { $sql SELECT {$fields} FROM {$table} WHERE hotel_id :hotel_id; $params [:hotel_id $this-hotelId]; foreach ($where as $k $v) { $sql . AND {$k} :{$k}; $params[:{$k}] $v; } $stmt $this-pdo-prepare($sql); $stmt-execute($params); return $stmt-fetchAll(PDO::FETCH_ASSOC); } }逻辑说明构造函数接收当前门店 IDquery方法在拼接 SQL 时无条件加上hotel_id :hotel_id调用方无法绕过。参数说明$table是表名需在白名单内校验防止注入$where是等值条件数组$fields控制返回字段。这样即使业务代码忘了写门店条件底层也会自动补上。2.2 总部与门店的权限分层怎么设计多酒店不是简单的数据隔离还有「总部能看所有店、店长只能看自己店、前台只能操作订单」的权限分层。常见做法是角色表加数据范围字段role表存角色admin表存账号并关联hotel_id和role_id其中hotel_id 0表示总部账号。登录后把hotel_id和role_id写进 session 或 JWT每次请求由中间件解析。?php // Auth.php —— 登录后签发带门店信息的 token function issueToken($admin) { $payload [ uid $admin[id], hotel_id (int)$admin[hotel_id], // 0 表示总部 role_id (int)$admin[role_id], exp time() 7200 ]; return base64_encode(json_encode($payload)); // 生产环境请用 JWT 库签名 } // 中间件总部账号可跨店查询门店账号强制锁定 function resolveHotelScope($payload, $requestHotelId) { if ($payload[hotel_id] 0) { return (int)$requestHotelId; // 总部可指定任意门店 } return $payload[hotel_id]; // 门店账号只能是自己 }逻辑说明issueToken把门店和角色信息编码进 tokenresolveHotelScope决定本次请求实际能操作哪个门店的数据。参数说明hotel_id 0是总部约定值exp是过期时间戳。注意生产环境必须用带签名的 JWT示例里的 base64 只是演示结构直接上线等于把权限裸奔。2.3 房价与库存的实时同步策略多酒店最怕的是「A 店改了价B 店页面还显示旧价」。房价和库存必须集中存储不能缓存在各端。常见做法是房价表按「门店 房型 日期」建唯一索引改价时直接UPDATE前端每次查询实时读库。库存同理用UPDATE ... SET stock stock - 1 WHERE stock 0的原子操作防超卖。-- 房价表门店房型日期唯一防止重复录入 CREATE TABLE room_price ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, hotel_id INT UNSIGNED NOT NULL COMMENT 门店ID, room_type VARCHAR(32) NOT NULL COMMENT 房型编码, date DATE NOT NULL COMMENT 入住日期, price DECIMAL(10,2) NOT NULL COMMENT 当日价格, stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 可售库存, PRIMARY KEY (id), UNIQUE KEY uk_hotel_room_date (hotel_id,room_type,date), KEY idx_hotel_date (hotel_id,date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明唯一索引uk_hotel_room_date保证同一门店同一房型同一天只有一条记录改价用INSERT ... ON DUPLICATE KEY UPDATE即可。参数说明price用DECIMAL不用FLOAT避免金额精度问题stock用无符号整数扣减时配合WHERE stock 0防负数。这个表结构是整套系统的地基索引建错后面查询全慢。3. 数据库设计订单、房态、会员三张核心表怎么落地3.1 订单表状态机比字段更重要订单表字段谁都会写难的是状态流转。酒店订单有「待支付 → 已支付 → 已入住 → 已退房 → 已取消」五种状态跨状态跳转必须校验否则会出现「已退房又变已支付」的玄学 bug。常见做法是用状态机常量加校验函数而不是在业务代码里到处if。?php // OrderState.php —— 订单状态机 class OrderState { const PENDING 0; // 待支付 const PAID 1; // 已支付 const CHECKED 2; // 已入住 const DONE 3; // 已退房 const CANCELED 4; // 已取消 // 允许的状态流转 private static $transitions [ self::PENDING [self::PAID, self::CANCELED], self::PAID [self::CHECKED, self::CANCELED], self::CHECKED [self::DONE], self::DONE [], self::CANCELED [], ]; public static function canTransfer($from, $to) { return in_array($to, self::$transitions[$from] ?? [], true); } }逻辑说明$transitions定义每个状态能去往哪些状态canTransfer在更新订单前调用非法流转直接拒绝。参数说明状态值用整数存库省空间但要在表注释里写清含义。这样写的好处是以后加「已退款」状态只改一处不用满项目搜if ($status 1)。3.2 房态表按日期铺开还是按区间存房态管理有两种存法按日期一行每天一条记录和按区间存一条记录表示一段连续日期。按日期铺开查询简单WHERE date BETWEEN直接命中索引缺点是提前铺一年的数据量不小按区间存省空间但查询「某天有没有房」要处理区间重叠SQL 复杂容易出错。我一般选按日期铺开配合定时任务每天凌晨补未来 90 天的房态数据量对中小连锁完全扛得住。-- 房态表按日期铺开配合房价表使用 CREATE TABLE room_calendar ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, hotel_id INT UNSIGNED NOT NULL, room_type VARCHAR(32) NOT NULL, date DATE NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可售 2满房 3停售, PRIMARY KEY (id), UNIQUE KEY uk_hotel_room_date (hotel_id,room_type,date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明status用枚举值控制房态查询可售房时WHERE status 1 AND date BETWEEN ? AND ?。参数说明uk_hotel_room_date与房价表保持一致的唯一键方便两表 JOIN。注意补数据的定时任务要幂等用INSERT IGNORE或ON DUPLICATE KEY UPDATE否则重复跑会报唯一键冲突。3.3 会员表与跨店积分多酒店场景下会员是共享的一个会员在三家店都能用积分和等级要跨店累计。会员表不存hotel_id而是单独建一张member_hotel_log记录消费来源积分汇总时按会员 ID 聚合。-- 会员表跨店共享不绑定单一门店 CREATE TABLE member ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, mobile VARCHAR(20) NOT NULL COMMENT 手机号登录凭证, nickname VARCHAR(64) DEFAULT , points INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 累计积分, level TINYINT NOT NULL DEFAULT 1 COMMENT 会员等级, PRIMARY KEY (id), UNIQUE KEY uk_mobile (mobile) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 消费日志记录每笔订单来自哪个门店 CREATE TABLE member_hotel_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, member_id BIGINT UNSIGNED NOT NULL, hotel_id INT UNSIGNED NOT NULL, order_id BIGINT UNSIGNED NOT NULL, points INT NOT NULL COMMENT 本次获得积分, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_member (member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明会员主表只存汇总积分明细在日志表对账时可重算。参数说明mobile加唯一索引作为登录凭证points用无符号整数但日志表用有符号方便记录扣减。跨店积分汇总就是SELECT SUM(points) FROM member_hotel_log WHERE member_id ?简单可靠。4. 接口层APP、H5、小程序三端共用一套 REST 接口4.1 接口统一返回格式与错误码三端共用接口的前提是返回格式统一否则前端每接一个接口都要写一套解析逻辑。常见做法是固定{code, msg, data}三段式code 0表示成功非 0 是业务错误码。?php // Response.php —— 统一响应 function jsonResponse($code 0, $msg ok, $data null) { header(Content-Type: application/json; charsetutf-8); echo json_encode([ code $code, msg $msg, data $data ], JSON_UNESCAPED_UNICODE); exit; } // 业务错误码集中定义避免各接口乱编 class ErrCode { const OK 0; const PARAM_INVALID 1001; // 参数错误 const NO_STOCK 1002; // 库存不足 const ORDER_STATE 1003; // 订单状态非法 const AUTH_FAIL 1004; // 鉴权失败 }逻辑说明jsonResponse统一输出并终止脚本ErrCode集中管理错误码。参数说明JSON_UNESCAPED_UNICODE保证中文不被转义成\uXXXX方便调试。三端拿到code后统一处理1004就跳登录1002就提示无房不用各写各的。4.2 预订接口的完整实现与并发防超卖预订是三端最核心的接口也是最容易出并发问题的地方。两个用户同时抢最后一间房如果先查库存再扣减中间有窗口期就会超卖。正确做法是把「查」和「扣」合并成一条原子 SQL。?php // Booking.php —— 预订接口核心逻辑 function bookRoom($pdo, $hotelId, $roomType, $date, $memberId) { $pdo-beginTransaction(); try { // 原子扣减库存大于0才扣返回受影响行数 $stmt $pdo-prepare( UPDATE room_price SET stock stock - 1 WHERE hotel_id ? AND room_type ? AND date ? AND stock 0 ); $stmt-execute([$hotelId, $roomType, $date]); if ($stmt-rowCount() 0) { $pdo-rollBack(); return jsonResponse(ErrCode::NO_STOCK, 该房型当日已售罄); } // 扣减成功后再写订单 $orderNo date(YmdHis) . mt_rand(1000, 9999); $ins $pdo-prepare( INSERT INTO orders (order_no, hotel_id, room_type, date, member_id, status) VALUES (?, ?, ?, ?, ?, ?) ); $ins-execute([$orderNo, $hotelId, $roomType, $date, $memberId, OrderState::PENDING]); $pdo-commit(); return jsonResponse(ErrCode::OK, 预订成功, [order_no $orderNo]); } catch (Exception $e) { $pdo-rollBack(); return jsonResponse(ErrCode::PARAM_INVALID, 系统繁忙请重试); } }逻辑说明UPDATE ... WHERE stock 0是原子操作rowCount()为 0 说明库存已空直接回滚。参数说明$date是入住日期$memberId是会员 ID。注意事务里不要做耗时操作比如发短信否则锁持有时间过长会拖垮并发。这个写法在中小流量下足够高并发再考虑 Redis 预扣减。4.3 三端如何共用接口参数差异与适配APP、H5、小程序调同一套接口差异主要在鉴权方式和支付回调。小程序用wx.login换openidH5 用手机号加验证码APP 用账号密码。常见做法是接口层做一层适配把三种登录方式统一映射到member_id。?php // Login.php —— 三端登录统一入口 function login($pdo, $channel, $credential) { switch ($channel) { case miniapp: // 小程序credential 是 code换取 openid $openid exchangeOpenid($credential); $member findOrCreateByOpenid($pdo, $openid); break; case h5: // H5credential 是手机号验证码验证后登录 $member verifySmsAndGetMember($pdo, $credential); break; case app: // APPcredential 是账号密码 $member verifyPasswordAndGetMember($pdo, $credential); break; default: return jsonResponse(ErrCode::PARAM_INVALID, 不支持的登录渠道); } return jsonResponse(ErrCode::OK, 登录成功, [ member_id $member[id], token issueToken($member) ]); }逻辑说明$channel区分三端各自走不同校验逻辑最终都返回统一的member_id和 token。参数说明$credential是各端凭证小程序是 codeH5 是手机号验证码APP 是账号密码。这样三端后续调预订、查订单接口时完全一致前端只需处理登录差异。5. 避坑与排查多酒店系统上线后最容易翻车的 5 个点5.1 现象B 店能看到 A 店的订单原因某个查询接口漏了hotel_id条件或者总部账号的hotel_id 0被当成普通门店 ID 拼进了 SQL导致WHERE hotel_id 0查不到数据或查错数据。解决所有查询走第 2 章的Db封装强制注入总部账号单独走resolveHotelScope逻辑不要和门店账号共用一条 SQL 分支。上线前用两个门店账号交叉测试A 店账号登录后调所有列表接口看有没有 B 店数据。5.2 现象改价后小程序还显示旧价格原因前端或 CDN 缓存了房价接口的响应或者后端用了 Redis 缓存但改价时没清。解决房价接口响应头加Cache-Control: no-cache如果用了 Redis改价成功后立即DEL对应 key。我一般会在房价表加一个updated_at字段接口返回时带上前端对比时间戳决定是否重新拉取比盲目清缓存更可靠。5.3 现象订单状态出现「已退房又变已支付」原因业务代码里直接UPDATE orders SET status ?没有走状态机校验某个回调接口重复执行把状态改回去了。解决所有状态变更必须调OrderState::canTransfer校验数据库层面可以加触发器或约束兜底。另外支付回调要做幂等用order_no加唯一索引重复回调直接忽略。5.4 现象并发预订时超卖同一间房卖出两次原因先SELECT查库存再UPDATE扣减两个请求都查到有房然后都扣成功。解决用第 4 章的原子UPDATE ... WHERE stock 0靠rowCount()判断。如果用了读写分离注意扣减必须走主库从库延迟会导致读到旧库存。高并发场景再加一层 Redis 预扣减但中小连锁用数据库原子操作就够了。5.5 现象小程序登录后调接口报鉴权失败原因小程序wx.login拿到的 code 只能用一次如果前端重复用同一个 code 换 openid 会失败或者 token 过期时间设太短用户操作到一半就失效。解决code 换 openid 后立即作废前端拿到 token 后存本地过期前用 refresh token 续期。token 过期时间我一般设 2 小时配合静默续期用户无感知。6. 进阶技巧用一条 SQL 查出多酒店实时房态看板总部最想要的是一个「所有门店今日房态」的看板能一眼看到哪家店满房、哪家店空置。很多人写循环逐店查询10 家店查 10 次慢且难维护。正确做法是一条 SQL 聚合出来-- 多酒店实时房态看板一次查出所有门店的可售/满房/停售数量 SELECT h.id AS hotel_id, h.name AS hotel_name, SUM(CASE WHEN rc.status 1 THEN 1 ELSE 0 END) AS available, SUM(CASE WHEN rc.status 2 THEN 1 ELSE 0 END) AS full, SUM(CASE WHEN rc.status 3 THEN 1 ELSE 0 END) AS stopped, COUNT(*) AS total FROM hotel h LEFT JOIN room_calendar rc ON rc.hotel_id h.id AND rc.date CURDATE() GROUP BY h.id, h.name ORDER BY available DESC;逻辑说明LEFT JOIN保证没有房态数据的门店也出现在结果里SUM(CASE WHEN ...)把三种状态分别计数GROUP BY按门店聚合。参数说明CURDATE()是当天改成?可以查任意日期。这条 SQL 在 20 家门店、每家 10 个房型的数据量下毫秒级返回比循环查询快一个数量级。再进一步如果看板要实时刷新可以把这个查询结果缓存 30 秒用 Redis 的SETEXkey 带上日期。改价或订单变更时主动删缓存保证看板不会显示过期数据。我踩过的坑是一开始没加缓存看板每 5 秒轮询一次数据库连接数直接飙满后来加了 30 秒缓存才稳住。最后一个习惯每次改完多酒店相关的代码我一定用两个不同门店的账号各跑一遍完整下单流程从登录到预订到退房确认数据不串、状态不错。这个习惯帮我拦下过至少三次「差点上线才发现的串店 bug」。多酒店系统的复杂度不在代码量而在隔离的严密性宁可多测一遍也别信「应该没问题」。希望帮到你。本文还有配套的精品资源点击获取