先交代一下背景。我们团队手上原本跑着一套单体酒店预订小程序,只服务自营的几家门店,房源录入、排房、订单、收款都在同一个后台里。上线两年后,加盟和托管业主越来越多,业务方提出每个商户要有独立的房源管理、独立的订单视图、独立的结算对账,“不要动不动就来平台后台翻数据”。于是我们花了大概四个月时间,把整个系统从“单店工具”改造成了“多商户酒店预订小程序系统”。这轮全新升级完成后,“一站式订房平台”不再是一句宣传语,而是一个真正跑通了的业务形态。
这篇文章不聊空泛的架构概念,直接把我在这轮升级里踩过的坑、验证过的方案、以及那些热搜词背后对应的真实问题(微信支付v3对接、动态设置标题、顶部导航栏高度、软键盘遮挡查询内容、小程序反编译排查异常、导出Excel)一一拆开讲。做小程序开发的、做酒店SaaS产品的、或者正准备把单体业务平台化的朋友,应该都能从这里找到参照。
1. 为什么非要从单体小程序拆成多商户平台
1.1 单体架构撑不住“多业主”时,第一波阵痛出现在哪里
业务没有变复杂之前,单体架构是最舒服的。一个后台管所有门店,订单全汇总,财务看总数就行。但多商户入驻后,问题一下就暴露了。
最痛的是数据边界。业主A只能看自己家的订单,业主B不能看到A的房价策略和入住率。原来所有表都挂在同一套门店ID下,商户一多,查询条件里到处要拼“当前商户ID = ?”,漏掉一个条件就是越权。其次是结算。每个商户的抽佣比例、结算周期、提现方式都不一样,如果还是统一走一个财务通道,月底对账基本靠Excel人工拉数据,时间全部耗在沟通上。
所以这次升级的核心目标,不是把界面换好看一点,而是把系统从一个“能用的后台”变成一个“能让商户自主经营、平台统一管控”的多商户生态。
1.2 升级后整体变成了三端结构
| 端 | 使用对象 | 核心职责 | 技术形态 |
|---|---|---|---|
| 用户端 | C端住客 | 搜索、比价、预订、支付、入住、评价 | uni-app微信小程序 |
| 商户端 | 各酒店/民宿业主 | 房源管理、房价库存、订单处理、结算对账 | uni-app小程序 + H5 |
| 平台运营端 | 平台管理员 | 商户审核、类目配置、抽佣设置、平台级报表 | Vue3管理后台 |
这三端共用同一套后端接口,但权限模型完全隔离。用户端小程序只暴露“浏览-下单-支付-售后”链路,商户端小程序只能操作自己名下的门店和订单,平台端则是全局视角。这样拆分以后,每一端的迭代都可以独立发版,而不会互相踩脚。
这里有一个取舍想提醒大家:商户端不要一开始就做成App,太重了。小程序很合适,商户不需要下载安装,微信扫码就能进入。我们后来把商户端也打包成了H5版,方便商户在电脑上操作房价日历和大批量订单导出,体验比在手机上舒服很多。
2. 多商户体系的设计核心:权限边界、数据归属与审核流转
2.1 账号体系别再等了,先建RBAC权限模型
单体系统时代,我们直接用了“用户表里加一个role字段”的简化做法,但多商户场景下这远远不够。这次升级,我第一步就是把权限模型落成标准的五表结构:用户表、角色表、权限表、商户表以及用户-商户关联表。
这里商户一定是独立一张表,不能只作为用户的一个属性存在。一个自然人可能同时在两个不同商户上挂职,比如既是A酒店店长,又管理B民宿的渠道。用户与商户之间是“用户在某个商户下拥有某个角色”,所以关系表里必须同时记录user_id、merchant_id、role_id,并且查询时永远带着merchant_id这个条件。
RBAC模型落地后还有一个额外好处:平台运营端的菜单权限可以直接复用同一套模型。平台超管、审核员、财务、客服,各自的按钮级权限都可以精确控制,省掉了后面还要单独为后台管理员做权限系统的成本。
2.2 数据归属:所有业务表必须带 merchant_id,且不允许越权拼接
多商户系统的“命门”就是数据归属。酒店行业的数据链路比普通电商更长:商户 -> 门店 -> 房型 -> 具体房间 -> 房价计划 -> 库存日历 -> 订单 -> 支付流水。我们给这条链路上的每一张业务表都加上了merchant_id字段。
但加了字段只是第一步,真正要命的是查询必须规范化。拿订单查询举例,商户端接口的SQL绝不能是“根据订单号查订单”,然后由前端传过来的userId来决定能不能看。正确的是:先从token里解出user_id,再查用户与商户的关联关系拿到merchant_id,SQL永远带上merchant_id作为第一过滤条件。接口层面再做一次参数校验,防止商户传别人的merchantId。
这条规则我踩过很深的坑。有一次商户反馈“我看到了别人的订单”,排查下来是导出接口里用了两个不同的DAO查询,一个带了商户过滤,一个没带,结果那个没带的被报表模块复用,数据就穿了。所以我们现在有两条硬性规范:第一,所有查询入口必须统一走一个带商户上下文的Service基类;第二,代码Review时第一眼看的就是有没有用当前登录商户的上下文,而不是盲目信任前端参数。
2.3 房源上架审核与状态流转
多商户平台必须有审核机制,否则平台对房源质量完全失控。我们把房源状态设计成了五个状态组成的闭环:草稿、待审核、已上架、已驳回、已下架。
商户提交新房源后进入“待审核”,平台运营端审核通过后变“已上架”,审核不过打回“已驳回”并附原因。已上架的房源如果被平台风控或用户投诉下架,则进入“已下架”状态,商户需要修改后重新提交审核。
状态变更全部走一个状态机Service,不能直接在代码里乱改status字段。比如“已驳回”只能由“待审核”流转过来,不能从“已上架”直接改成“已驳回”。状态机还天然形成操作日志,谁在什么时间把房源从什么状态改成了什么状态,全部留痕。多商户系统最怕扯皮,日志就是解决扯皮的最好工具。
3. 微信支付v3对接实践:从商户号配置到多商户分账
3.1 为什么这次升级选择了V3而不是继续延用V2
旧系统用的是微信支付v2,APIv2的签名方式是MD5验签,密钥是32位,回调通知是明文XML,调试倒是比较方便,但安全性确实一般。而且v2接口对新商户号、新应用的审核限制越来越多,一旦涉及退款、分账等高级功能,基本都会被引导到v3。
V3最大的变化是:API密钥变成APIv3密钥,签名改用商户私钥加SHA256-RSA2048,回调通知用AES-256-GCM加密。初看模板很啰嗦,但安全性高了一个量级。多商户平台涉及资金流水,我强烈建议直接用V3,别在这个问题上给自己留旧债。
3.2 对接前需要预备的四样东西
- 商户号(mchid)
- APIv3密钥(32位,用于回调报文解密)
- 商户API证书(apiclient_cert.p12/apiclient_key.pem)
- 证书序列号(用于请求头Authorization里的serial_no)
这里是最容易乱的地方。APIv3密钥和商户API证书的私钥是两回事。前者是回调里解密resource字段用的对称密钥;后者是请求签名用的非对称私钥。当时团队里新来的同学把这两个混在一起,导致下单签名成功但回调解密一直失败,定位了很久才发现是解密的Key搞错了。建议在配置文件里把这两个值分开命名,注释写清楚各自用途。
3.3 下单、回调验签与解密的关键代码逻辑
统一下单接口是/v3/pay/transactions/jsapi,核心参数就是appid、mchid、description、out_trade_no、notify_url、amount。这里的out_trade_no必须保证唯一,我们用的是“商户ID + 日期 + 随机数”的拼接规则,既保证唯一,又方便反查商户。
回调处理是整个支付环节最容易出问题的地方。V3回调的Header里有Wechatpay-Signature和Wechatpay-Nonce,Body里是加密的resource,格式大致如下:
{ "id": "回调通知ID", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", "resource": { "ciphertext": "加密密文", "nonce": "加密随机串", "associated_data": "附加数据" } }处理顺序一定不能错:先验签,再解密,再更新订单状态,最后返回200状态码。验签时要从微信支付平台证书拿公钥,用SHA256withRSA校验签名;验签通过后,再用APIv3密钥对ciphertext做AES-256-GCM解密。解密后拿到的明文JSON里有out_trade_no和trade_state,这时才允许把本地订单状态改成已支付。
为什么一定要先验签?因为回调地址如果泄露,攻击者可以伪造一个“支付成功”通知打到你服务器上,如果不验签就直接改订单状态,那系统就形同虚设。验签代码网上有很多现成封装,但每家的异常处理质量参差不齐。我们最终把验签失败、解密失败、订单状态更新失败都做了独立的日志埋点和告警,因为这几类错误是支付对账排查时最重要的线索。
3.4 多商户分账:平台单商户号 + 分账接口
多商户系统不可能每个商户都申请一个微信支付商户号,这不现实,尤其是平台初期。我们采用的是“平台一个商户号,钱先进平台,再通过分账接口把属于商户的那部分结算出去”。
微信支付V3的分账接口是/v3/profitsharing/orders,请求分账前需要在商户平台配置分账接收方,接收方类型通常是MERCHANT_ID或PERSON_OPENID。需要说明的是,分账比例有上限,默认单笔订单分账给接收方的总额不能超过30%,如果需要更高比例,要额外申请调整。
实际操作中,我们不建议在支付回调里即时分账。更稳的方案是:支付完成后先冻结分账,等用户实际入住、没有发起退款后再触发自动分账,这样的资金流更安全。由于分账是资金操作,建议做一个单独的分账Job,每天定时跑一次,把前一天的订单统一处理,并生成分账流水供财务核对。当时我们把分账逻辑和订单列表耦合在一起,结果报表数据老是对不上,拆成独立任务后瞬间清爽了。
4. 前端体验升级:自定义导航栏、动态标题与软键盘遮挡
4.1 自定义导航栏高度计算,适配不同机型不再拍脑袋
多商户系统和C端自营商城不一样,顶部的导航栏要给商户或住客呈现不同的操作入口。自带的navigationBar样式太受限,按钮位置、颜色、字重都很难完全贴合UI稿,所以我们很多页面使用了自定义导航栏,即pages.json里配"navigationStyle": "custom"。
但自定义导航栏的最大坑是高度适配。不同手机的状态栏高度不一样,iPhone X系列有刘海,刘海高度还分好几代,安卓机型更是五花八门。计算公式其实业内已经有公认答案:
const systemInfo = uni.getSystemInfoSync() const statusBarHeight = systemInfo.statusBarHeight // 状态栏高度 const menuButton = uni.getMenuButtonBoundingClientRect() // 胶囊按钮信息(仅小程序端有效) const navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height简单解释一下:胶囊按钮的上边界与状态栏底部的距离乘以2,再加胶囊按钮自身高度,就是导航栏的总高度。这样算出来之后,不管什么机型,都能保证导航栏内容垂直居中,并且和胶囊按钮在一条水平线上。这个公式在H5端不适用,所以代码里要判断当前环境,H5端直接用固定高度44px加安全距离就行。
4.2 页面动态设置标题的两种实现路径
热搜词里有“小程序动态设置标题”,这是我们开发里几乎天天碰到的需求。多商户场景下,标题往往要展示商户名或房型名,不可能全部静态写在pages.json里。
第一种方式最简单,直接调用uni.setNavigationBarTitle({ title: 'xxx' }),适合页面加载后根据接口数据改标题的场景。比如酒店详情页,拿到酒店名称后再设置“某某酒店”这个标题。
第二种方式适合自定义导航栏,这时没有原生标题可用,只能在页面组件里动态渲染。要注意的是自定义导航栏标题如果太长,会跟右侧的胶囊按钮重叠。我们专门封装了一个TitleBar组件,对标题做了自动截断处理,超过一定宽度就在CSS层面截断加省略号。这个细节不高大上,但真正用起来能避免大量“标题打架”的视觉问题。
4.3 input组件被软键盘遮挡的修复方案
这个热搜词“uniapp 微信小程序 手机软键盘会遮挡住查询内容”几乎每天都有开发者求助。我一开始也以为是input的focus状态没处理好,后来反复测试才发现,问题出在页面的滚动容器和键盘弹起后的视口变化上。
小程序默认情况下,键盘弹起会把页面整体上推(adjust-position为true),但在某些自定义导航栏或fixed定位的页面里,键盘弹起后上推效果不生效,或者内容被键盘盖住。我们的处理方案是:在pages.json对应页面里设置"adjustPosition": false,然后在input的focus事件里手动滚动到目标区域,比如让搜索结果的第一个item滚动到可视区顶部。
还有一种更简单的兜底方案:把查询按钮和input放在页面上方固定区域,结果列表用scroll-view包裹,并设置scroll-into-view指向当前高亮项。这样键盘弹起时,用户仍然可以通过滚动scroll-view看到内容,不会被键盘挡住。我们在酒店搜索页就是用这个方案解决的,实测在iOS和安卓上表现都稳定。
5. 首屏加载页优化与站点信息的动态化
5.1 默认启动页升级:用好第一印象,而不是简单替换一张图
很多团队对小程序启动页面的理解就是“换一张广告图”,这其实是不够的。小程序启动链路分两层:第一层是微信客户端的启动图(就是微信展示你的小程序图标和名称的页面),这一层我们控制不了样式;第二层是我们自己小程序的第一个页面加载过程,这一层完全由代码控制。
之前我们的首屏是一个纯白页面,然后数据回来再渲染,用户打开小程序的前一秒基本是白屏。这次升级我们做了两件事:第一,把第一个页面改成启动页(splash),上面有品牌Logo和一句Slogan,用CSS动画做淡入;第二,启动页内部并行发起几个关键请求,比如首页banner、城市列表、默认定位,等数据回来后再跳转到首页或让用户进入主页。
注意:启动页不要一上来就uni.switchTab跳走,那样有时候会闪一下tabBar。更平滑的方式是先把这个启动页的根组件做好过渡,比如透明渐变或者上滑效果,用户感知不到页面切换。这一点对酒店预订这种用户可能反复打开小程序的场景尤其重要,每次打开都像第一次一样流畅,信任感会强很多。
5.2 动态设置标题与站点信息统一从配置中心读取
多商户平台涉及很多“站点级”配置:平台名称、客服电话、用户协议版本、支付方式开关、帮助中心URL等。这些内容不能散落在代码里,否则每次改动都要重新发版,运营体验非常差。
我们建了一张platform_config表,用key-value方式存储配置项,后端提供/api/config/list接口。小程序启动时拉取一次配置,存放在全局状态管理里,后续页面通过getter读取。动态标题、首页banner文案、客服电话、甚至是主题色,都可以通过配置中心下发。
这里有一个细节:配置接口的缓存策略要设计好。我们给配置接口设置了5分钟本地缓存,后端变更配置后,用户最多5分钟后能看到新值。成本极低,但改起来非常方便,运营再也不必因为一个电话号码变动而催着前端发版了。
5.3 骨架屏:比loading菊花更高级的加载占位
酒店详情页有房型列表、酒店设施、评价、地图等内容,接口在弱网环境下可能要两秒才能全部返回。这个时候整页loading并不好看,我们改用骨架屏占位——页面上先渲染出几个灰色的矩形块,形状和真实内容的排版一致,比如顶部轮播图位置、标题位置、几个按钮位置,让用户一进页面就知道这里会有什么内容。
骨架屏的实现不需要自己从头画。如果UI稿简单,用CSS动画模拟块状脉冲即可;如果复杂,可以给每个区块写一个.skeleton类,统一灰色背景加呼吸闪动效果。注意骨架屏不要全屏用同一个动画速度,块和块之间稍微错开一点动画延迟,视觉效果会自然很多。
6. 升级过程中的真实问题排查:从请求分析到线上产物比对
6.1 接口请求分析:定位“订单状态不对”最快的方式
开发多商户系统时,我遇到最多的问题是“明明支付成功了,小程序里却显示未支付”。这类问题靠肉眼读代码很难定位,必须抓真实请求链路。手机端小程序走的是HTTPS加密流量,要在可控环境下做请求分析,通常需要在电脑上配置代理,并安装对应的CA证书,让手机的HTTPS流量能够被本地工具解密查看。这一步对排查回调通知、请求参数、响应体特别有用。
比如有一次,商户端小程序提交房价计划后,前端一直报500。我通过代理工具看到请求体里传的startDate是“2025-03-01”,而后端接口接收参数名是start_date,下划线命名对不上,数据丢失导致校验不过。这类问题在社区里被反复问到,其实从请求数据角度一眼就能定位。
强调一下,这种手段用在自己的小程序和自建服务端上是完全正当的调试行为,不要拿来扒别人家的接口数据,风险和法律问题都不值得。
6.2 线上产物与本地代码不一致,反编译工具的正确打开方式
小程序在线上跑的是微信后台编译上传的产物包(wxapkg),如果“开发者工具里跑得好好的,手机上线上版就是不对”,大概率是上传的包不对或缓存没更新。有一次酒店详情页面显示的价格和后台配置完全不一致,我们本地代码改来改去都没用,最后把手机上的小程序缓存清掉重新进,结果一切正常。这说明问题出在微信客户端的本地缓存。
但如果清缓存、重新进还是不对,那就需要用小程序反编译工具把线上wxapkg包解开,看看当前线上跑的到底是哪一版代码。反编译工具主要用来解开wxml、wxss、js文件,然后对照本地打包产物,确认线上包是否包含了最新修改。这个操作本质上是“现场取证”,能快速判断是发布流程的锅还是代码逻辑的锅。
再多说一句,我们后来规范了发布流程:每次发版都记录“本地git commit号”和“微信开发者工具上传时的版本号”,并把这个版本号透传到小程序端的某个接口日志里。之后再遇到线上问题,先在日志里查版本号,就能立刻判断是否最新代码,不用动不动就反编译。
6.3 支付功能被限制的处理预案
热搜词里有一条“由于小程序违规,支付功能暂时无法使用”,多商户平台最怕这个。微信对小程序的支付权限有严格的风控,一旦某个主体或某个小程序被判定违规,支付功能可能被暂时限制,整个平台就停摆了。
我们的预案是:第一,所有涉及支付能力的调用都做统一的封装,万一某天支付被限制,可以快速切换到其他支付通道或临时调整业务流程;第二,平台运营端需要有专门的“支付健康检查”入口,定期调用一次小额下单查询API,确认支付链路通畅;第三,一旦收到违规通知,第一时间评估影响范围,该申诉的马上申诉,并准备整改方案。
多商户系统还有一个特殊性:C端用户的小程序支付被限制,影响的是所有商户的订单量。所以我们在平台和商户之间会有个公告机制,支付异常时第一时间通知商户,避免商户自己发现后产生恐慌。
7. 运营提效:房态数据导出Excel与多商户报表体系
7.1 商户为什么那么需要Excel导出
酒店行业的商户运营者,很多并不习惯在后台看图表,他们更习惯“把今天的订单、明天的预订、下周的房价拉出来,放到Excel里面自己算”。所以这次升级我们把Excel导出作为商户端一个非常重要的功能来做,而不是“锦上添花”。
商户端常见导出场景有三种:订单明细导出(按日期范围、入住状态、渠道来源筛选)、房价库存导出(按酒店/房型查看每天的房源可售数和价格)、对账单导出(一个结算周期内的订单金额、平台服务费、应结算金额)。
技术实现上,我们是在后端生成真正的Excel文件,而不是返回JSON让前端组装。后端用Java的EasyExcel,几万条数据导出也稳定;生成的文件上传到对象存储,返回一个临时下载链接给前端。小程序前端拿到链接后用uni.downloadFile下载,再用uni.openDocument打开预览。这个方案比前端生成Excel稳定得多,小程序端对文件处理能力有限,不要勉强。
7.2 导出接口的性能防线:异步任务不能省
第一次做导出功能时,我图省事直接用同步接口查询并返回,结果一个商户导三个月的订单,几万条数据查了十几秒,请求直接超时。后来改成异步任务模式:商户点击导出,后端创建一个导出任务,任务在后台线程池里执行,完成后生成文件并通知。商户再通过任务列表页面下载结果文件。
这样做有两个好处:一是接口响应时间始终在1秒以内,体验不会卡;二是大文件导出不会占用请求线程,避免了把应用拖垮。给做一个参考的简单流程:创建任务 -> 任务执行 -> 结果写入OSS -> 前端轮询任务状态 -> 下载成功。状态用枚举管理:待执行、执行中、已完成、失败。
7.3 多商户报表维度:不止是GMV
报表体系方面,我们最终确定了四个核心维度:
- 经营概览:今日GMV、订单量、入住率、客单价
- 房态分析:各房型未来7天可售、满房预警
- 渠道分析:直连订单/平台引流/线下散客各自占比
- 评价分析:好评率、差评标签云、回复时效
这些报表都能按商户维度隔离,平台端有汇总版。做报表时一个比较重要的心得是:多商户的报表查询条件一定要预计算汇总表。如果直接在原始订单表上做Group By,商户一多,性能明显下降。我们每天凌晨跑离线任务,把每个商户每天的经营指标汇总到一张日汇总表里,前端展示时直接查汇总表,速度能提升一个数量级。
8. 升级后的四点沉淀:如果重新做一遍,我会怎么优化
多商户酒店预订小程序这轮升级,整体算是达到了预期。但如果再让我重新做一遍,有几个设计我会在一开始就注意,而不是中途返工。
第一,商户端的菜单和操作权限不要在数据库里写死,尽量做成运营端可配置。我们一开始是按代码常量写死的,后来商户分类一多,A类商户和B类商户功能完全不一样,只能加班改代码。如果一开始就直接做动态配置,后面会轻松很多。
第二,所有涉及支付分账和结算的逻辑,从第一天起就做成独立模块。旧代码里支付和订单逻辑耦合比较深,升级时拆了好几个晚上。资金链路的代码应该是全项目中最清晰、注释最完整的部分,没有例外。
第三,小程序前端的请求层要做统一封装,尤其是token过期、登录态刷新、支付状态检查这些跨端逻辑。多商户端和用户端共用同一套请求框架,但各自的error handler不一样,否则商户端报错会弹成用户端的提示文案。
第四,日志体系一定要从项目第一天就配上,而且要有请求ID贯穿全链路。排查支付问题、权限问题、数据不一致问题的时候,一个requestId能省掉大量“贴代码截图”的沟通时间。
最后分享一个我们上线后稳定运行的小技巧:每天凌晨定时跑一次支付订单状态对账任务,把“平台侧订单状态”和“微信支付平台侧订单状态”做全量比对,发现不一致的自动告警。多商户平台资金链路复杂,这笔“自动巡检”的投入,比事后人工对账划算太多。