简介:面向酒店集团及多门店运营场景,这套多酒店版管理系统支持无限创建分店,覆盖入住管理、预订管理、换房、商品销售等核心业务,并提供语音提示、房态实时、图表数据、预警提示、短信营销、会员管理、报表中心、财务管理、设备管理等模块;配套APP、小程序、H5可完成酒店预订、点餐、叫服务等店内操作,另有内部员工App支撑日常运营。资源压缩包含2582个文件,约80.13MB,以1428个PHP业务文件为主,搭配HTML、JS、CSS/SCSS等前端页面样式、MP3语音播报素材及JPG/GIF图片资源,并含SQL数据库脚本、JSON/XML配置及依赖组件,便于完整搭建与二次开发。已有393人学习下载,适合需要开发多端酒店管理系统、研究前后端架构或有快速部署需求的企业开发者、PHP工程师及产品经理。从预览文件可见内置Bootstrap前端框架、Windows启动脚本及PHP运行库组件,可快速在本地环境启动项目,按分店、房态、订单、会员等维度拆解前后端交互与业务逻辑,适合二次开发与功能扩展。
1. 多酒店预订系统:APP、H5、小程序三端到底在做什么
很多团队第一次接到“多酒店预订系统”时,第一反应是把单店的预订网站套壳成 APP,再做一个小程序。结果做到一半就发现,真正麻烦的不是壳,而是多酒店这个前提:每家酒店的房型不一样、房价不一样、退改规则不一样,甚至支付方式也分区域。APP、H5、小程序只是三个展示窗口,背后如果还是单店那套“一个酒店一张房价表”的做法,会很快在数据层翻车。
这套系统的核心价值在于:用同一套业务底座支撑三种前端形态,同时让每家酒店能独立管理自己的房态和房价,预订订单又能汇到总平台统一处理。适合的场景是连锁酒店集团、区域多店民宿品牌,以及做酒店代运营的平台方。本文会从数据模型、三端 API 设计、预订核心链路到部署排错,把一套可复现的方案拆开讲清楚。
2. 先定数据模型:多酒店共享一套系统还是每家一套
这个选择题直接决定后面所有开发的难度。常见的错误是一开始图省事,给每家酒店单独建一套数据库,平台查询时做跨库聚合。短期能用,等到要做报表、统计、会员积分、跨店结算时,跨库 join 写到你怀疑人生。我更推荐的方式是:一套数据库,所有业务表带 hotel_id,用字段隔离酒店数据,用代码保证不串店。
2.1 酒店与房型:统一建模的最小集合
先把“酒店”和“房型”的关系理清。多酒店系统里,房型表不能直接挂酒店 ID 就完事,因为房型还涉及渠道:同样的房型,在自家 APP 和小程序里叫“高级大床房”,在第三方渠道里叫“精品大床房”,价格还不一样。所以房型要在“酒店 + 房型”的组合上再挂一层“渠道映射”。
CREATE TABLE hotel ( id BIGINT PRIMARY KEY, name VARCHAR(128) NOT NULL, region_code VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL ) COMMENT '酒店主表'; CREATE TABLE room_type ( id BIGINT PRIMARY KEY, hotel_id BIGINT NOT NULL, name VARCHAR(128) NOT NULL, area INT NULL, max_guests TINYINT NOT NULL DEFAULT 2, bed_type VARCHAR(32) NULL, status TINYINT NOT NULL DEFAULT 1, INDEX idx_hotel (hotel_id) ) COMMENT '房型表,物理上冗余 hotel_id';房型表冗余 hotel_id,而不是通过关联查出来,是为了让所有后续查询都只要一层过滤,避免一次预订查询关联酒店表再关联房型表。区域编码 region_code 建议直接存行政区划代码,别用自定义编号,后面做按城市搜索、按商圈聚合时会省很多事。
渠道映射表单独设计:
CREATE TABLE room_channel_mapping ( id BIGINT PRIMARY KEY, hotel_id BIGINT NOT NULL, room_type_id BIGINT NOT NULL, channel VARCHAR(16) NOT NULL COMMENT 'APP/H5/WECHAT_MP/OTA', channel_room_name VARCHAR(128) NOT NULL, is_sync_price TINYINT DEFAULT 1, UNIQUE KEY uk_hotel_room_channel (hotel_id, room_type_id, channel) ) COMMENT '房型与渠道名称映射';这里的关键参数是 is_sync_price。如果一家酒店在 OTA 渠道的价格不想同步到自家三端,就把这个字段置为 0,报价逻辑会自动跳过该渠道的价格推送。我在实际项目中是把渠道名统一成小写枚举,APP、H5、小程序各算一个渠道,OTA 每家有独立渠道值,方便后续分别算佣金和结算。
2.2 房价与库存:日历表才是预订系统的地基
单店系统很多人用“房价表存一个今天的价、一个明天的价”,预订时按日期加减。多酒店系统这么做会乱,因为每家酒店的调价周期不同,有的周末价,有的节假日价,有的连住优惠,用“基础房价 + 规则”的方式天然和日历冲突。正确做法是落地一张房价日历表,库存也落一张日历表,日期作为主键维度。
CREATE TABLE room_price_calendar ( id BIGINT PRIMARY KEY, hotel_id BIGINT NOT NULL, room_type_id BIGINT NOT NULL, channel VARCHAR(16) NOT NULL, biz_date DATE NOT NULL, price DECIMAL(10,2) NOT NULL, currency VARCHAR(8) NOT NULL DEFAULT 'CNY', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_calendar (hotel_id, room_type_id, channel, biz_date) ) COMMENT '房价日历表,每日期一价';价格字段我用 DECIMAL(10,2),不用 FLOAT。酒店价格的精度要求高,浮点算总价时容易出现 0.1 + 0.2 不等于 0.3 的问题,虽然最后展示会四舍五入,但订单金额一旦错一分钱,对账环节非常难受。
CREATE TABLE room_stock_calendar ( id BIGINT PRIMARY KEY, hotel_id BIGINT NOT NULL, room_type_id BIGINT NOT NULL, biz_date DATE NOT NULL, total_rooms INT NOT NULL DEFAULT 0, sold_rooms INT NOT NULL DEFAULT 0, sold_updated_at DATETIME NOT NULL, UNIQUE KEY uk_stock (hotel_id, room_type_id, biz_date) ) COMMENT '库存日历表,total 固定,sold 递增';库存控制里最常见的坑是:库存直接存一个剩余数,每次下单减一。这样一旦出现并发下单,两条请求同时读到剩余 1,同时减成 0,就超卖了。正确做法是总房量 total_rooms 保持不变,sold_rooms 在锁住行的情况下递增,判断条件始终是 sold_rooms < total_rooms。这样即使并发来,数据库行锁会排队,不会超卖。这个细节后面避坑章节还会再展开。
2.3 订单主表与订单明细:先想清退款和改期再建表
订单表的设计决定了后续退款、改期、换房好不好做。很多系统只建一张订单表,所有的原价、优惠、实付、退款都堆在上面,结果一个订单改一次期,原来的订单行被改得面目全非。我的习惯是拆成订单主表和订单明细表,明细表记录每一次的日期、房型、价格,主表只维护当前状态。
CREATE TABLE booking_order ( id BIGINT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, hotel_id BIGINT NOT NULL, channel VARCHAR(16) NOT NULL, member_id BIGINT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2入住中 3已完成 4已取消', total_amount DECIMAL(10,2) NOT NULL, paid_amount DECIMAL(10,2) NOT NULL, refund_amount DECIMAL(10,2) NOT NULL DEFAULT 0, guest_name VARCHAR(64) NOT NULL, guest_phone VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, INDEX idx_hotel_date (hotel_id, created_at) ) COMMENT '订单主表'; CREATE TABLE booking_order_item ( id BIGINT PRIMARY KEY, order_id BIGINT NOT NULL, room_type_id BIGINT NOT NULL, biz_date DATE NOT NULL, nights TINYINT NOT NULL, price DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL, INDEX idx_order (order_id) ) COMMENT '订单明细表,一个订单多天';订单明细表按 bed_date 拆行,还是按一段日期区间存?我建议拆行。虽然会多占数据量,但查某一天全平台的入住率、占用数时,直接按日期聚合明细表即可,不需要对区间做日期展开。一次订三晚就三条明细,数据量完全扛得住。多酒店系统的报表需求往往很重,明细拆得越细,后面写统计越轻松。
3. 让 APP、H5、小程序共用一套 API:分层设计与登录打通
三端共用一个后端接口层,是控制成本的关键。如果三个前端各配一套接口,等于同时维护三套权限、三套登录、三套数据校验,项目基本废了。正确做法是 API 层只认渠道参数,不认前端框架,APP、H5、小程序传同一个 channel 枚举值,业务逻辑只按 channel 分支处理差异点。
3.1 三端的技术差异与 API 收敛
技术选型上,APP 我用原生加 WebView 混合的方式,H5 是纯移动端网页,小程序是原生语法。框架不同不要紧,接口层只要保证两点:统一返回 JSON 结构、统一鉴权方式。推荐的返回结构是所有接口固定包一层:
{ "code": 0, "message": "success", "data": {} }code 非 0 时,data 可以为空,message 直接对应用户提示语。这样前端不需要为每个接口写错误解析。真正的差异点只集中在三处:登录方式、唤起支付的方式、消息推送的通道。这三处单独设接口,其他业务接口全部复用。
渠道参数 channel 必须显式传,不能靠后端推断。因为 H5 在微信内置浏览器里打开时,User-Agent 可能带上微信标识,但用户也可能用普通浏览器打开同一个 H5。我在项目里曾吃过这个亏,后端试图自动识别渠道,结果用户复制链接到浏览器打开后,系统误判为微信渠道,调不起微信支付,订单卡住。显式传 channel 虽然多写一次参数,但能杜绝这类玄学问题。
3.2 登录与 session:APP 扫码登录、H5 短信登录、小程序 wx.login
三端登录方式天然不同,但登录后的会话体系必须统一。统一方案是:后端签发一个 token,三端各自拿到 token 后存起来,请求时放到 Authorization header。token 用 Redis 存,设置滑动过期时间,用户在 APP 上登录一次后,H5 或者小程序需要重新登录,这是正常现象,不需要强求单点。
APP 端我一般支持密码登录和短信验证码登录,额外做一个“扫码登录”能力:用户在电脑后台点登录,弹出二维码,APP 扫码确认后后端把 token 通过 WebSocket 推给电脑端。小程序端则走 wx.login 获取 code,后端用 code 换 openid。
// 小程序端登录示例 wx.login({ success: async (res) => { const code = res.code; const resp = await fetch('/api/v1/auth/wx_login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, channel: 'WECHAT_MP' }) }); const data = await resp.json(); if (data.code === 0) { wx.setStorageSync('token', data.data.token); } } });后端收到 code 后,调用对应平台的接口换取 openid,再查会员表绑定关系。首次登录会自动创建会员账号,并把 openid 绑定到该手机号下。这里参数 channel 必须等于 WECHAT_MP,因为同一个 code 只能换一次 openid,换完再换会报错,前端要做好“登录失败重新触发 wx.login”的重试逻辑。
H5 端我推荐只做短信验证码登录,不要做密码登录。移动端用户在 H5 里输密码的体验很差,而且密码找回流程会让开发量膨胀。短信登录的业务要点是:后端生成验证码存 Redis,5 分钟有效,同一手机号 60 秒只能发一次,接口做好频控。
3.3 用一份接口文档约束三端
三端并行开发时,接口不一致的坑比想象中多。APP 端传 hotel_id 用数字,H5 端传字符串,小程序端干脆漏传——这些问题在联调时才暴露,相当浪费时间。我的习惯是分三步避免:
第一步,接口文档用 API 描述文件管理,工具负责生成。字段类型、是否必填、枚举值都在里面定义清楚。第二步,每个接口在服务端做入参校验,hotel_id 必须是正整数,channel 必须是枚举值,不合法直接返回特定错误码。第三步,写一个简单的在线接口调试页面,三端开发都能看到实时请求日志,谁传错参数一目了然。
这里要注意的是:接口返回的时间字段统一用时间戳还是格式化字符串,必须提前定死。我推荐统一用格式化字符串,带时区。APP 端如果用了原生日期组件,直接显示字符串即可;H5 和小程序端格式化也更方便。如果某一端坚持用时间戳,时间戳的时区换算又是一个容易踩坑的地方。
4. 预订核心链路:查房态、锁库存、下单、支付与确认
预订链路是系统的命门。用户从三端任何一端进来,看到房价、提交订单、完成支付,整个过程涉及报价引擎、库存锁定、支付回调、订单状态推进。这个链条任何一环出问题,都会直接变成客诉:要么用户付了钱酒店说没房,要么房间锁了用户没支付成功。
4.1 查房态与报价:渠道价、会员价、门市价三套价
多酒店系统里,价格至少有三种:门市价(酒店挂牌价)、渠道价(APP、H5、小程序各自的价格)、会员价(按会员等级打折或直减)。报价时优先级从高到低:会员价 > 渠道价 > 门市价。
查询接口设计时,一次请求往往要覆盖多天多房型,我在项目里用的是按酒店维度拉取 N 天内所有房型的日历价,再在内存里组装响应。这样做比前端循环逐日查询效率高很多,尤其 H5 端弱网环境下,少一次请求体验差别很大。
SELECT rp.biz_date, rp.room_type_id, rp.price FROM room_price_calendar rp JOIN room_type rt ON rp.room_type_id = rt.id WHERE rp.hotel_id = ? AND rp.channel = ? AND rp.biz_date BETWEEN ? AND ? AND rt.status = 1 ORDER BY rp.biz_date;节日价是另一个常见需求。我一般直接在房价日历表里预生成好,而不是在查询时写规则。比如国庆期间价格上浮 30%,最好是通过后台批量工具把价格生成好,写进日历表。查询时不要做实时计算,否则规则越堆越多,报价逻辑变成黑匣子,排查问题非常痛苦。
4.2 锁库存:用数据库事务还是 Redis
锁库存的争议很多。纯数据库方案简单可靠,但高并发时压力大;Redis 方案性能好,但丢数据时库存会出错。我的做法是以数据库为主,Redis 只做查询层的缓存,锁库存永远走数据库事务。
锁库存的 SQL 核心是原子递增:
UPDATE room_stock_calendar SET sold_rooms = sold_rooms + 1, sold_updated_at = NOW() WHERE hotel_id = ? AND room_type_id = ? AND biz_date = ? AND sold_rooms < total_rooms;如果影响行数为 0,说明库存不足,直接返回“无房”。这一条 SQL 同时完成了判断和更新,不需要先 SELECT 再 UPDATE,从机制上避免了并发下的竞态。这里要说明的是,查询房价和锁库存之间会有时间差,用户看到的房价序号可能到提交时已经没了,这是正常情况,后端返回明确提示“房间已被预订”即可,前端补做推荐附近房型的引导。
订单提交接口里,下单和锁库存必须在同一事务里。我的做法是:接口先开启事务,执行库存扣减,再插入订单主表和明细表,全部成功后提交,如果订单插入失败则事务回滚,库存扣减也跟着回滚。这样不会出现“库存扣了订单没生成”的状态。
4.3 下单、支付与确认:订单状态机的推进
订单状态的推进最好画成一条直线,不要设计太多并行路径。我的状态机是:待支付 -> 已支付 -> 入住中 -> 已完成,另外从待支付可以走到已取消。已支付状态可以走到已取消(退房退款),但需要走退款流程。整个状态机里,最怕的是支付回调到达顺序和本地订单状态修改顺序不一致。
def confirm_payment(order_no, paid_amount): order = get_order(order_no) if order.status == ORDER_STATUS_PAID: log_warning("重复回调", order_no) return OK if order.status == ORDER_STATUS_CANCELLED: log_error("已取消订单收到支付回调", order_no) return FAIL # 更新订单状态 + 记录流水 update_order_status(order_no, ORDER_STATUS_PAID) insert_payment_log(order_no, paid_amount)支付回调接口必须做好两件事:一是幂等,重复回调不能重复入账;二是金额校验,回调金额一定要和订单应付金额比对,不一致直接告警。支付平台回调里都会带金额,但本地订单总价可能因为优惠计算产生偏差,校验不过的订单要进入人工处理队列,而不是直接自动确认。
支付成功后的“预订确认”动作,我放在回调处理里同步做。确认包括:给用户发短信和站内消息、给酒店管理员推送一条新的入住订单提醒、更新运营数据。注意不要放在支付成功页面前端跳转里做,用户关闭页面导致前端没跳转,订单状态就不更新了,必须以后端回调为准。
5. 部署与联调避坑:多酒店场景最容易翻车的五个位置
这一章是从我的血泪经验里提炼出来的。前面几章的方案都跑通了,但真正上线时,出问题的往往是这些看起来不起眼的位置。每条我都按“现象 -> 原因 -> 解决”的方式梳理,你做完遇到同类问题可以直接对照。
5.1 超卖:并发一上来库存就变负数
现象:活动日开始后,同一房型同一日期出现订单数大于总房量的情况,前台和线上同时在卖房,线上订单确认了,客人到店却没房。原因:前台 PMS 系统和线上预订系统用的是两套库存,线上扣完了线下不知道,或者线上用的是“先查剩余再更新”的非原子操作,并发时互相覆盖。解决:线上系统锁库存用前面那一条原子 UPDATE;与 PMS 的库存同步改成接口实时同步,同步失败的酒店在预订接口直接熔断,宁可让用户订不了,也不能收了钱再没房。另外,每天对全量酒店做一次“在售房量 + 当日线上线下总售卖数”的核对,数字对不上立刻告警。
5.2 时区与日期边界:凌晨下单的日期算错
现象:凌晨 1 点用户提交订单,预订的入住日期被算成前一天或者后一天。原因:后端服务器时区用了 UTC,数据库连接串没加时区参数,而前端传的是中国时区的日期字符串。解决:所有日期统一规定传字符串格式 YYYY-MM-DD,后端全部按 Asia/Shanghai 处理;数据库连接参数里强制设置时区;日期计算统一用日期函数,不要自己拼字符串偏移。我在项目里加了一个规约:任何接口返回值里,日期字段不带时间,时间字段才带日期。防止 H5 端把“2025-06-01 00:00:00”直接展示成 6 月 1 号零点,让用户误以为是当天凌晨。
5.3 支付回调乱序或丢失
现象:订单显示已支付,但用户收到银行扣款短信,订单状态还是待支付;或者反过来订单已确认,但支付平台对账发现金额不一致。原因:支付回调网络抖动导致延迟,用户在前端已经点了返回,轮询接口没查到支付结果;另外回调处理没有做幂等,重复回调造成重复入账。解决:回调状态的处理只认最终结果,不认顺序。将回调写成幂等接口,重复处理直接返回成功。用户在支付完成页不依赖前端跳转,改为轮询订单状态接口,轮询超时 30 秒调用支付平台主动查单接口。每天凌晨做一次和支付平台的对账,把系统内已支付订单和平台账单进行比对,差异单自动标记人工处理。
5.4 三端 token 不一致导致登录态混乱
现象:用户在小程序下单后,跳转 H5 活动页发现未登录,要重新输验证码;或者在 APP 上已经登录,打开 H5 却要求登录。原因:三端各自存 token,token 没有打通,后端没有做同一会员多端的 session 绑定。解决:会员表不做三端区分,openid、手机号、APP 账号都绑定到同一 member_id。token 颁发时增加 member_id 关联,任意一端登录后,后端都能通过 member_id 查到该会员在其他端的登录状态。如果需要三端自动登录互通,可以在受控环境下做 token 互换接口,但要限制换 token 的接口频次,防止被刷。
5.5 报表统计口径不一致
现象:订单列表页显示今日订单 100 单,报表中心显示 98 单,财务对账显示 102 单。原因:有的地方按订单创建时间统计,有的按入住日期统计,还有的按支付成功时间统计,统计口径没统一。解决:报表模块单独建一张统计中间表,每天凌晨定时任务按统一口径计算后写入。当日实时看板允许实时查,但必须标注“实时数据”,历史数据一律以统计中间表为准。我在项目里干脆把统计口径做进接口枚举里,创建时间、入住日期、支付时间三个参数值,调用方必须显式传,不传默认拒绝,从源头避免“各算各的”。
6. 进阶:用一张二维表验证订单状态机
三端共用一套状态逻辑,最怕后端改了一个状态,某端还保持着旧判断。我现在有个习惯:把订单状态机画成一张二维表,横向是当前状态,纵向是触发动作,相交格写目标状态。这张表既是设计文档,也是测试用例,而且可以在 CI 里直接跑断言。
比如“取消订单”这个动作,待支付状态点取消直接置为已取消;已支付状态点取消,要先走退款流程,退款成功才能置为已取消;入住中状态不允许取消,只能走提前退房;已完成状态不可取消。把这些规则写成一张状态转换表,比在代码里到处 if else 清晰得多,也方便新加入的开发者快速理解。
# 状态转换表驱动,避免散落的 if else STATE_TRANSITIONS = { ("PENDING", "CANCEL"): "CANCELLED", ("PENDING", "PAY"): "PAID", ("PAID", "CANCEL"): "REFUNDING", ("PAID", "CHECK_IN"): "STAYING", ("STAYING", "CHECK_OUT"): "FINISHED", ("REFUNDING", "REFUND_SUCCESS"): "CANCELLED", } def transition(order, action): key = (order.status, action) if key not in STATE_TRANSITIONS: raise ValueError(f"非法状态转换: {order.status} -> {action}") return STATE_TRANSITIONS[key]这张表的额外好处是:接口测试只需要覆盖表里的每条路径,不需要把所有异常路径都写一遍。每个非法转换会直接抛出异常,返回业务错误码提示“当前状态不可执行该操作”。我在做某个模拟项目时,把这张表贴在了团队文档首页,后端实现、前端判断、测试用例都对着同一份表,状态逻辑的沟通成本降了很多。
自动化验证上,我习惯用接口测试脚本跑一遍关键链路:预订 -> 支付 -> 入住 -> 退房,再单独跑一遍取消和退款。脚本直接调支付平台的沙箱环境,做到每天定时执行,保证三端发版后状态链路没有被改坏。
这个系统做下来,最大的感受是:多酒店的复杂度不在“多”字上,而在“统一”上。三端统一接口、库存统一口径、状态统一推进,把这几条管住了,多酒店预订系统开发就成功了大半。我见过太多的项目是在联调阶段才去补这些统一约定的,结果改一处崩三处,返工周期比写新代码还长。希望这篇文章能让你一次就把这些基础打牢,把精力留给真正的业务优化。
本文还有配套的精品资源,点击获取