4.1 多门店:账号与数据权限
场景:一家店的好日子,两家店的灾难
第二家店开业当天,前台登录后台看到的预约列表是总店的。客人站在新店前台报名字,系统里查无此人——因为数据串了门。那天我们靠电话和总店前台对暗号一样核对了二十多单,晚上就开始重构权限。
问题根源是系统从单门店长出来的:所有 SQL 都没有 shop_id 过滤,登录态里也不带门店信息。连锁化要做的不是加字段这么简单,而是把整套数据视角按门店重新切一遍,让每个人都有自己那张牌桌上的一手牌。
林姐对权限的要求很朴素但很致命:她要能随时看任何一家店,店长只能看自己店,前台连别的店存在都不知道。听起来就是几句配置的事,落到系统里是五十多张表、上百个接口的重构计划。我们用了一个周末开会列出所有涉及门店数据的页面,清单贴了一整面墙,这场会的结论是:权限不做则已,一做必须做全套。
方案:角色加门店授权的双重过滤
权限模型用的是最朴素的 RBAC,每个账号有两个维度:角色(能做什么)和门店授权(能看哪家)。角色定菜单和按钮,门店授权定数据范围:
| 角色 | 可见门店 | 核心权限 |
|---|---|---|
| 总部 | 全部门店 | 配置、报表、会员规则 |
| 店长 | 本店 | 排班、核销、退款审批 |
| 前台 | 本店 | 接单、改期、现场核销 |
| 美甲师 | 本店 | 接单、个人业绩 |
角色之间的权限不是包含关系而是互斥加协作:前台能动单子但不能审退款,店长能审退款但不能改全局配置,总部的任何写操作都会留审计。这套矩阵我们在白板上改了四版才定稿,诀窍是从敏感动作出发反推角色:先列出退款、改配置、看报表这三件事,再问谁该做谁不该做,角色自然浮现。
账号表里存的就是角色和门店 id 的映射,登录后两个值写进会话。所有查询走一道统一中间件,在 SQL 层面注入 shop_id 条件:
func shopScope(c *gin.Context) *gorm.DB {
shopID := c.GetInt("shop_id")
if isHQ(c) {
return db // 总部不过滤
}
return db.Where("shop_id = ?", shopID)
}
这段代码最妙的在于总部分支返回的是不带条件的 db,而门店分支带条件。所有业务查询都用这个 scope 起步,任何忘记加过滤的代码都过不了 code review,因为 review 清单里第一条就是数据范围。
实现上还有一个选择:把过滤放在中间件里改写 SQL,还是放到 service 层显式传参。我们选了后者——显式传参一眼能看出哪些查询带门店条件,自动改写的魔法在出 bug 时很难追踪。宁可代码多写两行,也要让数据范围看得见。
跨店场景只留一个口子:客人跨店改期。这种单子由总部账号手工处理,系统记录操作日志,店长那边会看到一条跨店提示。权限不是越严越好,而是严到业务能运转、松到出事能追溯。
账号与门店的映射是多对多:一个店长可能同时管两家邻近的门店,所以单独建了关联表而不是在账号表里塞一个 shop_id:
CREATE TABLE t_account_shop (
account_id INT NOT NULL,
shop_id INT NOT NULL,
PRIMARY KEY (account_id, shop_id)
);
登录时把授权门店列表写进会话,前端在门店切换器里渲染,店长管两家店时切换到另一家看到的完全是另一套数据。门店切换器放在页面顶栏最显眼的位置——连锁店的前台经常两家店串班,这个按钮的日点击量全系统第二,仅次于下单。
配置:门店表与账号表
门店表 t_shop 存的不只是名字,还有营业时间、联系电话、坐标,这些字段都会被预约择时和导航用到:
CREATE TABLE t_shop (
shop_id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(32) NOT NULL,
open_min INT NOT NULL COMMENT '开门分钟数',
close_min INT NOT NULL COMMENT '打烊分钟数',
status TINYINT NOT NULL COMMENT '0 营业 1 暂停',
UNIQUE KEY uk_name (name)
);
open_min 和 close_min 用分钟数而不是字符串,是为了时段实例化时直接做整数比较:上午十点开门就是 600,晚上九点打烊就是 1260。第一家店开业时这里写过 21:00 字符串比较的 bug——字符串 9:00 比 21:00 大,时段排序全错。minute 化之后再没出过问题。
所有挂门店的业务表都带 shop_id 加索引,这是连锁化的入场券。我们迁移时写了个脚本扫全表加字段,核心表用停机迁移,非核心表在线分批改,整个迁移两个半小时,回滚预案准备了但没用上。
迁移过程的关键是分批,一把梭会锁表锁到客人骂街。脚本每次更新一千行并提交,间隙读 t_booking 当前 QPS,超过阈值自动暂停。整场迁移的进度看得人心惊肉跳,但停机窗口从预估的四小时压到两个半,门店凌晨一点闭店后开始,开门前已经跑完:
| 步骤 | 耗时 | 风险点 |
|---|---|---|
| 备份全库 | 20 分钟 | 无 |
| 核心表停机迁移 | 60 分钟 | 需暂停下单 |
| 非核心表分批改 | 40 分钟 | 在线进行 |
| 双写校验 | 30 分钟 | 对账脚本兜底 |
双写校验是最后一个保险:迁移后新旧两套读路径同时跑十分钟,结果逐条比对,完全一致才切流量。这种谨慎在后来成了团队做迁移的标准动作。
迁移当晚还出了一段小插曲:分批脚本跑到 t_booking 一半时 QPS 突然上涨,是夜里有客人在小程序里改期。脚本自动暂停了八分钟等流量回落,谁都没被打扰。自动化带来的不只是效率,还有半夜不用睁眼的安心。
踩坑:连锁化现场的四个坑
第一个坑是 session 没刷门店信息。美甲师从 A 店调到 B 店,账号上的门店 id 改了,但会话里还是旧值,直到 token 过期才生效。后来所有敏感变更都强制踢下线重登,管理动作即时生效。踢下线这事要提前培训,不然员工会觉得系统坏了。
顺便说一个数字:这个 bug 上线的当天,调店的美甲师小李在 B 店给客人核销时看到了 A 店的待核销列表,差点把客人的预约核掉。所幸核销前有门店二次校验弹窗拦住了。弹窗这种设计平时被嫌弃多余,出事时就是最后一道闸门。
第二个坑是排班表按门店切但预约表忘了切。分店开业第一周,新店客人在小程序里能约到总店的时段,因为时段查询的接口没传 shop_id。当晚紧急加了中间件校验,所有跨店资源类查询一律拒绝。回过头看,这种漏网之鱼靠人眼 review 总有漏的,后来加了自动化测试:每个接口都必须有用非授权门店 id 访问被拒的用例。
第三个坑是总部账号的权限膨胀。总部什么都能看,什么都能改,运营图省事都申请总部角色。我们做了权限审批流:总部角色必须林姐本人批,每三个月复核一次,谁离职谁第一天就被收走。权限回收表每月自动生成一次复核清单,推送到林姐的企业微信。
第四个坑是暂停营业状态没联动。某分店装修暂停 10 天,但时段表还在对外放号,客人照常预约。修复后暂停状态直接锁住该店时段模板的实例化,并给已约客人推批量改期通知。
暂停联动的修复还顺手做了一个开关:过年放假期间总部一键全部门店暂停,节后一键恢复。这个开关春节用了第一次,林姐评价说比挨个给店长打电话强一百倍。
配置:审计日志
所有跨权限操作必须留痕。审计表记的是谁在什么时间对哪家店的什么资源做了什么,不为追责,为的是连锁体系里每一次异常都能回溯到人:
| 操作 | 记录字段 | 保留 |
|---|---|---|
| 跨店改期 | 操作人、订单、原店新店 | 三年 |
| 手工退款 | 操作人、金额、原因 | 三年 |
| 门店切换 | 账号、切换前后 | 一年 |
| 权限变更 | 审批人、被改人、角色 | 永久 |
审计日志存满一年后转到归档表,主表保持轻量查询才快。林姐起初不在意这套东西,直到有一次店长私自定义价被发现——审计里时间人门店三要素齐全,她从此每周必看权限变更那一行。
小结
多门店权限的核心是把每个用户的视野收窄到他该看到的范围,用 shop_id 这根线把所有数据串起来。双重过滤(角色加门店)的最大好处是新人五分钟能看懂,复杂的三层权限模型反而容易留下暗门。连锁化的技术债务不是加字段,而是让每一行代码都养成带门店条件的肌肉记忆——第二家店开业那天的混乱,我们希望是这套系统最后一次。
延伸阅读
- 门店之上的员工排班,翻 员工端:排班与接单
- 分店数据怎么看,见 数据看板:预约转化漏斗
- 多租构架的另一套实践,翻 PHP SaaS 实战
- 权限中间件的底座,见 需求拆解:门店、档期、预约、核销
- 通知触达的通道设计,翻 触达策略:多通道兜底
- 节假日多门店联动,见 高峰应对:节假日预约洪峰
