🎬 面试开场怎么引出这个话题
「密评报告系统是 SaaS 模式,多个客户共用一套系统,但数据要完全隔离。我设计了三层隔离方案 — 路由级(不同租户不同可访问路由)、数据级(所有接口带 tenantId 过滤)、UI 级(按钮/菜单按权限显隐)。印象最深的两个点:一是租户切换不刷新页面,用 Pinia + 路由重建做到无感切换;二是token 刷新的竞态问题 — 多个请求同时 401,会触发多次刷新,我用 Promise 队列让它们排队等同一个刷新结果。」
✅ 抛出 4 个可深挖的点:① 三层隔离 ② 无刷新切换 ③ 按钮权限 ④ token 竞态。
多租户 + 权限是 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。
💬 这题没有标准答案,能说清 XSS/CSRF 权衡就加分。
7

30 秒电梯陈述(背熟)

🎤 30 秒版
「密评系统是多租户 SaaS,我设计了三层隔离 — 路由级(动态路由按租户权限)、数据级(接口带 tenantId,后端强校验)、UI 级(v-permission 按钮指令)。强调一点:前端隔离只是体验,数据安全的命门在后端校验。印象深的两个技术点:租户切换用 Pinia + 路由重建做到无刷新;token 刷新的并发竞态用 isRefreshing 标志 + Promise 队列,保证多个 401 只刷新一次。」
这段话的优势:
① 有系统设计(三层隔离)→ 体现架构
② 有安全认知(后端权威)→ 工程素养
③ 有并发处理(token 竞态)→ 真实踩坑
④ 有 RBAC 知识 → 理论扎实
多租户 + 权限是 B 端必问,讲清楚能体现你的系统设计能力。

面试亮点专题 06 — 多租户隔离 + 权限深度话题

三层隔离 · 无刷新切换 · v-permission 指令 · token 竞态刷新 · RBAC · XSS/CSRF 权衡

← 返回专题首页