多租户隔离 + 权限深度话题
密评系统 — 路由级 + 数据级 + UI 级三层隔离 · 按钮级权限 · 租户无刷新切换
🎬 面试开场怎么引出这个话题
「密评报告系统是 SaaS 模式,多个客户共用一套系统,但数据要完全隔离。我设计了三层隔离方案 — 路由级(不同租户不同可访问路由)、数据级(所有接口带 tenantId 过滤)、UI 级(按钮/菜单按权限显隐)。印象最深的两个点:一是租户切换不刷新页面,用 Pinia + 路由重建做到无感切换;二是token 刷新的竞态问题 — 多个请求同时 401,会触发多次刷新,我用 Promise 队列让它们排队等同一个刷新结果。」
✅ 抛出 4 个可深挖的点:① 三层隔离 ② 无刷新切换 ③ 按钮权限 ④ token 竞态。
多租户 + 权限是 B 端/中后台的核心,有这块经验体现系统设计能力。
多租户 + 权限是 B 端/中后台的核心,有这块经验体现系统设计能力。
1
三层隔离方案
| 层级 | 隔离什么 | 实现 |
|---|---|---|
| 路由级 | 不同租户能访问的页面不同 | 动态路由 + 权限过滤(配合插件化) |
| 数据级 | 租户 A 绝对看不到租户 B 的数据 | 所有接口带 tenantId,后端过滤(关键!) |
| UI 级 | 按钮/菜单/字段按角色显隐 | 权限指令 v-permission + 菜单过滤 |
安全红线: 数据隔离的后端是权威,前端只是体验优化。 前端隐藏菜单/按钮,只是让用户"看不到",不是"做不到"。用户可以直接调接口绕过前端。所以每个接口后端必须校验 tenantId,这是安全的根本。前端隔离是为了体验,不是安全。
🧠 类比
多租户 = 一栋写字楼多个公司共用。路由级 = 每个公司只能进自己的楼层(电梯权限);
数据级 = 每个公司的保险柜只有自己的钥匙能开(后端强校验);
UI 级 = 公司内部,前台能看到大门钥匙但员工看不到(角色权限)。
三层都要做,但数据级是命门。
2
租户无刷新切换
⚠️ 简单做法的痛
切换租户最简单是 location.reload(),但体验差(白屏 1 秒)。用户在多租户间频繁切换很烦。
方案: Pinia 存当前 tenantId,切换时:① 更新 store;② 重置业务相关 state(列表/详情);③ 重新生成菜单和路由(基于新租户权限);④ 跳转到首页。不刷新页面,通过响应式更新视图。
🔑 无刷新切换流程
async function switchTenant(newTenantId) {
// 1. 更新当前租户
userStore.setTenant(newTenantId)
// 2. 重置业务 state(清旧租户数据)
const stores = [taskStore, reportStore, indicatorStore]
stores.forEach(s => s.$reset())
// 3. 重新拉新租户的权限 + 重新生成路由
const perms = await fetchPermissions(newTenantId)
await router.rebuildRoutes(perms) // 先移除旧动态路由,再加新的
// 4. 跳首页(旧路由可能没权限了)
router.push('/dashboard')
}
关键坑: 动态路由要先移除旧的。router.addRoute 加的,要 router.removeRoute 移除,否则旧租户的路由还在,可能访问到没权限页(虽然接口会拦,但体验差)。切换时遍历移除所有动态路由,再重新加。
3
按钮级权限:自定义指令
🔑 v-permission 指令
// main.js 注册全局指令
app.directive('permission', {
mounted(el, binding) {
const requiredPerm = binding.value // 'report:edit'
const userPerms = useUserStore().permissions
// 没权限,直接从 DOM 移除
if (!userPerms.includes(requiredPerm)) {
el.parentNode?.removeChild(el)
}
}
})
// 模板里用
<el-button v-permission="'report:edit'">编辑报告</el-button>
<el-button v-permission="'report:delete'">删除</el-button>
指令 vs v-if: 用指令比 v-if 干净 — 不用每个按钮写 v-if + 长条件,加个 v-permission 就行。但指令是"mounted 时判断",如果权限运行时变了(切租户),要重新挂载。我们配合 key 强制重渲染。
4
路由守卫 vs 接口拦截
路由守卫(前置)
- beforeEach 里判断目标路由权限
- 无权限直接跳 403 或首页
- 优点:用户根本进不去
- 缺点:依赖权限码本地有
接口拦截(响应)
- axios 响应拦截器看 401/403
- 403 跳无权限页或提示
- 优点:后端权威,覆盖 URL 直访
- 缺点:已经请求了才发现
两者都要: 路由守卫挡住"正常点击",接口拦截挡住"直接打 URL / 接口被改"。前端这两层 + 后端校验,三层防护。
5
token 刷新的竞态问题
⚠️ 经典并发 bug
页面同时发了 5 个请求,token 过期了,5 个都返回 401。如果每个 401 都触发一次 refresh token,会并发刷新 5 次 — 后端可能因为 refresh token 被多次使用而失效(安全问题),或者后 4 次刷新拿到的是旧 token。
方案: 用 Promise 共享。第一个 401 触发 refresh,后续的 401 不重复 refresh,而是 await 同一个 refresh Promise。refresh 完成后,所有排队的请求用新 token 重发。
🔑 单次刷新 + 请求队列
let isRefreshing = false
let requestsQueue = [] // 排队等刷新的请求
axios.interceptors.response.use(null, async (error) => {
const { config, response } = error
if (response?.status === 401 && !config._retry) {
if (isRefreshing) {
// 已经在刷新了,排队等
return new Promise(resolve => {
requestsQueue.push((newToken) => {
config.headers.Authorization = `Bearer ${newToken}`
resolve(axios.request(config))
})
})
}
// 第一个 401,开始刷新
isRefreshing = true
config._retry = true
try {
const newToken = await refreshToken()
// 刷新成功,执行排队的请求
requestsQueue.forEach(cb => cb(newToken))
requestsQueue = []
// 重发当前请求
config.headers.Authorization = `Bearer ${newToken}`
return axios.request(config)
} catch (e) {
// 刷新失败,refresh token 也过期,跳登录
router.push('/login')
throw e
} finally {
isRefreshing = false
}
}
throw error
})
6
高频追问 & 容易翻车点
1多租户数据隔离怎么实现?前端能保证吗? 必问
前端不能保证,后端才是权威。 前端做三层隔离(路由/数据/UI)只是体验优化。真正的隔离在后端 — 每个接口校验 tenantId,查询都带
WHERE tenant_id = ?。用户能绕过前端,绕不过后端。前端隐藏菜单是为了体验,不是安全。
💬 这个认知很重要,体现你懂"前端隔离 ≠ 安全"。
2按钮级权限怎么实现? 中等
自定义指令 v-permission。 mounted 时判断用户权限码列表是否包含指令值,不包含就 removeChild。比 v-if 干净。权限码后端下发,格式
模块:操作(如 report:edit)。运行时权限变了(切租户)要配合 key 重渲染。
3多个请求同时 401 怎么办? 高频
单次刷新 + 请求队列。 用 isRefreshing 标志,第一个 401 触发 refresh,后续的 401 不重复 refresh,而是把请求推进队列等。refresh 成功后,遍历队列用新 token 重发。避免并发刷新多次(refresh token 多次用会失效,且后几次拿到旧 token)。
💬 这题答出 Promise 队列方案,说明你处理过真实并发问题。
4租户切换怎么不刷新页面? 中等
Pinia 存 tenantId,切换时:① 更新 store;② $reset 重置业务 state(清旧数据);③ 重新拉权限 + removeRoute 旧动态路由 + addRoute 新的;④ 跳首页。不 location.reload,通过响应式更新。关键:动态路由要先移除旧的,否则旧租户路由残留。
5RBAC 模型了解吗? 基础
RBAC(Role-Based Access Control)基于角色的权限控制。 用户 → 角色 → 权限 三层。用户被赋予角色,角色拥有权限集合。优点:不用直接给用户配权限,通过角色批量管理。扩展:RBAC1(角色继承)、RBAC2(角色职责分离)。权限粒度: 菜单级 > 按钮/操作级 > 数据级(看哪些数据)。
6token 存哪?localStorage 还是 cookie? 高频
各有利弊:
localStorage: JS 可读,灵活(能自定义 header 带),但易受 XSS 攻击(JS 被注入就泄了)。
HttpOnly Cookie: JS 读不到,防 XSS,自动带请求,但要防 CSRF(用 SameSite 或 CSRF token)。
推荐: accessToken 存内存(JS 变量,刷新页丢),refreshToken 存 HttpOnly Cookie。兼顾安全和体验。我们项目用 localStorage + 严格 CSP 防 XSS。
localStorage: JS 可读,灵活(能自定义 header 带),但易受 XSS 攻击(JS 被注入就泄了)。
HttpOnly Cookie: JS 读不到,防 XSS,自动带请求,但要防 CSRF(用 SameSite 或 CSRF token)。
推荐: accessToken 存内存(JS 变量,刷新页丢),refreshToken 存 HttpOnly Cookie。兼顾安全和体验。我们项目用 localStorage + 严格 CSP 防 XSS。
💬 这题没有标准答案,能说清 XSS/CSRF 权衡就加分。
7
30 秒电梯陈述(背熟)
🎤 30 秒版
「密评系统是多租户 SaaS,我设计了三层隔离 — 路由级(动态路由按租户权限)、数据级(接口带 tenantId,后端强校验)、UI 级(v-permission 按钮指令)。强调一点:前端隔离只是体验,数据安全的命门在后端校验。印象深的两个技术点:租户切换用 Pinia + 路由重建做到无刷新;token 刷新的并发竞态用 isRefreshing 标志 + Promise 队列,保证多个 401 只刷新一次。」
这段话的优势:
① 有系统设计(三层隔离)→ 体现架构
② 有安全认知(后端权威)→ 工程素养
③ 有并发处理(token 竞态)→ 真实踩坑
④ 有 RBAC 知识 → 理论扎实
多租户 + 权限是 B 端必问,讲清楚能体现你的系统设计能力。
① 有系统设计(三层隔离)→ 体现架构
② 有安全认知(后端权威)→ 工程素养
③ 有并发处理(token 竞态)→ 真实踩坑
④ 有 RBAC 知识 → 理论扎实
多租户 + 权限是 B 端必问,讲清楚能体现你的系统设计能力。
面试亮点专题 06 — 多租户隔离 + 权限深度话题
三层隔离 · 无刷新切换 · v-permission 指令 · token 竞态刷新 · RBAC · XSS/CSRF 权衡