فهرست منبع

master-saas-lt

tanzx 15 ساعت پیش
والد
کامیت
b0c8930a51
8فایلهای تغییر یافته به همراه555 افزوده شده و 341 حذف شده
  1. 132 0
      src/components/tenant-select/index.vue
  2. 7 0
      src/config/setting.js
  3. 4 0
      src/main.js
  4. 4 0
      src/utils/page-tab-util.js
  5. 26 2
      src/utils/request.js
  6. 42 0
      src/utils/tenant-util.js
  7. 1 0
      src/views/login/index.vue
  8. 339 339
      yarn.lock

+ 132 - 0
src/components/tenant-select/index.vue

@@ -0,0 +1,132 @@
+<template>
+  <el-form-item class="tenant-select-item">
+    <el-select
+      v-if="options.length > 0"
+      v-model="value"
+      class="tenant-select"
+      placeholder="请选择租户"
+      :loading="loading"
+      @change="persist"
+    >
+      <el-option
+        v-for="item in options"
+        :key="item.tenantId"
+        :label="item.tenantName"
+        :value="item.tenantId"
+      />
+    </el-select>
+    <el-input
+      v-else
+      v-model="manualValue"
+      class="tenant-select"
+      clearable
+      placeholder="租户 ID"
+      @change="persist"
+    />
+  </el-form-item>
+</template>
+
+<script>
+import request from '@/utils/request';
+import { getTenantId, setTenantId, removeTenantId } from '@/utils/tenant-util';
+
+/**
+ * 登录页租户选择器(多租户)。
+ *
+ * 为什么必须在登录页加:库隔离下账号只存在于各租户库中,服务端要在认证之前
+ * 就知道连哪个库,因此登录请求必须携带 X-Tenant-ID。
+ * token 签发之后租户写入 claim,后续请求由服务端以 claim 为准,本组件不再参与。
+ *
+ * 行为约定:
+ *  - 挂载时拉取 /main/tenant/list(匿名接口,只返回启用且当前实例已生效的租户);
+ *  - 只有一个租户时自动选中 —— 单租户部署的体验与改造前完全一致(零点击);
+ *  - 多个租户时不预设默认值,强制用户显式选择,避免"默认登进某个租户"的误操作;
+ *  - 清单接口不可用时退化为手填租户 ID,保证异常情况下运维仍能进系统;
+ *  - 不提供"平台运营端"入口:该凭证只能访问 /sys/tenant/**,本前端没有对应界面,
+ *    登录后所有业务接口都会被租户隔离切面拒绝,故登录页只允许选择真实租户。
+ */
+export default {
+  name: 'TenantSelect',
+  data() {
+    return {
+      loading: false,
+      options: [],
+      value: '',
+      manualValue: ''
+    };
+  },
+  created() {
+    this.restore();
+    this.loadOptions();
+  },
+  methods: {
+    /* 回填上次选择 */
+    restore() {
+      const stored = getTenantId();
+      if (stored === '') {
+        return;
+      }
+      this.manualValue = stored;
+      // 与选项值保持同一类型:后端把 Long 序列化成字符串,平台项也是字符串 '0'。
+      // 一旦 Number 化,applyDefault 的全等比较就会失配,刷新后下拉框被清空而请求仍带着旧租户。
+      this.value = stored;
+    },
+    /* 拉取租户清单 */
+    loadOptions() {
+      this.loading = true;
+      request
+        .get('/main/tenant/list')
+        .then((res) => {
+          const data = res && res.data ? res.data.data : null;
+          this.options = Array.isArray(data)
+            ? data.filter((item) => item && item.tenantId !== null && item.tenantId !== undefined)
+            : [];
+          this.applyDefault();
+        })
+        .catch(() => {
+          // 拉不到清单时退化为手填:接口异常不该把登录整个堵死
+          this.options = [];
+        })
+        .then(() => {
+          this.loading = false;
+        });
+    },
+    /* 决定默认选中项 */
+    applyDefault() {
+      if (this.options.length === 0) {
+        return;
+      }
+      const hit = this.options.some(
+        (item) => String(item.tenantId) === String(this.value)
+      );
+      if (hit) {
+        return;
+      }
+      if (this.options.length === 1) {
+        // 单租户部署:自动选中,用户无需感知多租户
+        this.value = this.options[0].tenantId;
+        this.persist(this.value);
+        return;
+      }
+      // 多租户:不预设默认值。上次选择的租户已不在清单里(停用/到期/本实例无数据源)时,
+      // 必须把落盘的值一并清掉,否则界面显示"请选择租户"、请求却仍带着失效租户 ID 被服务端拒绝。
+      this.value = '';
+      removeTenantId();
+    },
+    /* 落盘选择结果 */
+    persist(val) {
+      if (val === null || val === undefined || String(val).trim() === '') {
+        removeTenantId();
+        return;
+      }
+      setTenantId(val);
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.tenant-select {
+  width: 100%;
+}
+</style>

+ 7 - 0
src/config/setting.js

@@ -108,6 +108,13 @@ export const TOKEN_HEADER_NAME = 'Authorization';
 // token 存储的名称
 export const TOKEN_STORE_NAME = 'access_token';
 
+// 租户标识传递的 header 名称(多租户:服务端据此在认证前定位租户库)
+export const TENANT_HEADER_NAME = 'X-Tenant-ID';
+
+// 租户标识存储的名称:全平台统一一个 key(不加 SYSTEM_NAME 前缀、不做 qiankun 分支),
+// 保证主应用与各子应用看到的是同一份租户选择,不会出现"存 A 读 B"。
+export const TENANT_STORE_NAME = 'saas-tenant-id';
+
 // 主题配置存储的名称
 export const THEME_STORE_NAME = 'theme';
 

+ 4 - 0
src/main.js

@@ -1,4 +1,8 @@
 import Vue from 'vue';
+import TenantSelect from '@/components/tenant-select/index.vue';
+
+// 登录页租户选择器(多租户):全局注册,各应用登录页无需重复 import
+Vue.component('TenantSelect', TenantSelect);
 import './public-path';
 import App from './App.vue';
 import store from './store';

+ 4 - 0
src/utils/page-tab-util.js

@@ -5,6 +5,7 @@ import store from '@/store';
 import router from '@/router';
 import { Message } from 'element-ui';
 import { removeToken,removeCurrentUser } from '@/utils/token-util';
+import { removeTenantId } from '@/utils/tenant-util';
 import { setDocumentTitle } from '@/utils/document-title-util';
 import {
   HOME_PATH,
@@ -240,6 +241,9 @@ export function goHomeRoute(from) {
 export function logout(route, from) {
   removeToken();
   removeCurrentUser();
+  // 租户选择属于登录态的一部分:退出后清掉,
+  // 避免下一个使用者被上一次的租户选择带进别的库
+  removeTenantId();
   if (route) {
     router.push({
       path: '/login',

+ 26 - 2
src/utils/request.js

@@ -4,8 +4,9 @@
 import axios from 'axios';
 import router from '@/router';
 import { MessageBox, Message } from 'element-ui';
-import { API_BASE_URL, TOKEN_HEADER_NAME, LAYOUT_PATH } from '@/config/setting';
+import { API_BASE_URL, TOKEN_HEADER_NAME, LAYOUT_PATH, TENANT_HEADER_NAME } from '@/config/setting';
 import { getToken, setToken } from './token-util';
+import { getTenantId } from './tenant-util';
 import { logout } from './page-tab-util';
 import JSONBIG from 'json-bigint';
 
@@ -24,16 +25,39 @@ const service = axios.create({
     }
   ]
 });
+/**
+ * 是否为签发登录凭证的入口(对应后端 TenantIsolationAspect 的 LOGIN_URIS)。
+ * @param {string} url 请求地址
+ * @returns {boolean} 是登录入口返回 true
+ */
+function isLoginEntry(url) {
+  return typeof url === 'string' && /(^|\/)user\/login(\?|$)/.test(url);
+}
+
 /**
  * 添加请求拦截器
  */
+
 service.interceptors.request.use(
   (config) => {
     // 添加 token 到 header
-    const token = getToken();
+    // 登录入口必须以匿名身份发出:残留的旧凭证在服务端优先级高于请求头
+    // (token 的租户声明才是权威),它既会顶掉本次选择的租户,又会让 X-Tenant-ID
+    // 被当成外部伪造而忽略 —— 表现为"选了租户却登进平台库"。
+    const token = isLoginEntry(config.url) ? '' : getToken();
     if (token && config.headers) {
       config.headers.common[TOKEN_HEADER_NAME] = token;
     }
+    // 多租户:未登录的请求(登录接口、登录页品牌标识、扫码入职等匿名入口)租户只能靠
+    // 请求头传递 —— 服务端必须在认证之前就知道连哪个租户库。
+    // 已登录时租户已写入 token 声明、服务端以声明为准,这里不重复携带,
+    // 避免为"多余且会被忽略"的头部逐个打告警日志。
+    if (!token && config.headers) {
+      const tenantId = getTenantId();
+      if (tenantId) {
+        config.headers.common[TENANT_HEADER_NAME] = tenantId;
+      }
+    }
     return config;
   },
   (error) => {

+ 42 - 0
src/utils/tenant-util.js

@@ -0,0 +1,42 @@
+/**
+ * 租户标识读写。
+ *
+ * 背景:库隔离(Database-per-Tenant)方案下,账号只存在于各租户库中,
+ * 服务端必须在认证之前就知道该连哪个库,所以登录请求要带 X-Tenant-ID。
+ * token 签发之后租户已写入 claim,后续请求由服务端以 claim 为准,本模块不再参与。
+ *
+ * 存储键全平台统一(见 setting.js 的 TENANT_STORE_NAME):
+ * 主应用与各 qiankun 子应用共用同一个 key,避免"主应用存 A、子应用读 B"。
+ */
+import { TENANT_STORE_NAME } from '@/config/setting';
+
+/**
+ * 读取当前租户标识。
+ * @returns {string} 租户 ID 字符串;未选择时返回空串
+ */
+export function getTenantId() {
+  const value = localStorage.getItem(TENANT_STORE_NAME);
+  return value === null || value === undefined ? '' : value;
+}
+
+/**
+ * 写入当前租户标识。
+ * @param {string|number} tenantId 租户 ID;空值表示未选择,此时直接清理该键
+ */
+export function setTenantId(tenantId) {
+  if (tenantId === null || tenantId === undefined) {
+    removeTenantId();
+    return;
+  }
+  const text = String(tenantId).trim();
+  if (text === '') {
+    removeTenantId();
+    return;
+  }
+  localStorage.setItem(TENANT_STORE_NAME, text);
+}
+
+/** 清理当前租户标识(退出登录时调用) */
+export function removeTenantId() {
+  localStorage.removeItem(TENANT_STORE_NAME);
+}

+ 1 - 0
src/views/login/index.vue

@@ -14,6 +14,7 @@
       @keyup.enter.native="submit"
     >
       <h4>{{ $t('login.title') }}</h4>
+      <tenant-select />
       <el-form-item prop="loginName">
         <el-input
           clearable

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 339 - 339
yarn.lock


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است