feat(aihr): 重构运营后台与租户知识治理
- 重构运营总览、侧栏和内容运营工作台\n- 增加大喇叭、成果审核与运营统计链路\n- 补齐租户、知识空间、分类和知识维护闭环\n- 加固标签页租户上下文、停用租户写保护与迁移预检
This commit is contained in:
@@ -11,18 +11,54 @@ import { getLanguage } from '@/lang';
|
||||
import { encryptBase64, encryptWithAes, generateAesKey, decryptWithAes, decryptBase64 } from '@/utils/crypto';
|
||||
import { encrypt, decrypt } from '@/utils/jsencrypt';
|
||||
import router from '@/router';
|
||||
import {
|
||||
currentExpectedTenantId,
|
||||
currentTenantRequestContextId,
|
||||
dynamicTenantContextHeader,
|
||||
expectedTenantHeader,
|
||||
markTenantContextUnverified
|
||||
} from './tenant-context-state';
|
||||
|
||||
const encryptHeader = 'encrypt-key';
|
||||
let downloadLoadingInstance: LoadingInstance;
|
||||
// 是否显示重新登录
|
||||
export const isRelogin = { show: false };
|
||||
const tenantContextHeaders = () => {
|
||||
const expectedTenantId = currentExpectedTenantId();
|
||||
return {
|
||||
[dynamicTenantContextHeader]: currentTenantRequestContextId(),
|
||||
...(expectedTenantId ? { [expectedTenantHeader]: expectedTenantId } : {})
|
||||
};
|
||||
};
|
||||
export const globalHeaders = () => {
|
||||
return {
|
||||
Authorization: 'Bearer ' + getToken(),
|
||||
clientid: import.meta.env.VITE_APP_CLIENT_ID
|
||||
clientid: import.meta.env.VITE_APP_CLIENT_ID,
|
||||
...tenantContextHeaders()
|
||||
};
|
||||
};
|
||||
|
||||
/** Lets native upload components fail closed as well as Axios requests. */
|
||||
export function reconcileTenantContextResponse(response: unknown) {
|
||||
if (!response || typeof response !== 'object') return false;
|
||||
const { code, msg } = response as { code?: unknown; msg?: unknown };
|
||||
if (code === 409 && msg === '租户上下文已变更,请刷新后重试') {
|
||||
markTenantContextUnverified();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Parses blob error bodies returned by native download/audio requests. */
|
||||
export async function reconcileTenantContextBlobResponse(response: unknown) {
|
||||
if (typeof Blob === 'undefined' || !(response instanceof Blob)) return false;
|
||||
try {
|
||||
return reconcileTenantContextResponse(JSON.parse(await response.text()));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
|
||||
axios.defaults.headers['clientid'] = import.meta.env.VITE_APP_CLIENT_ID;
|
||||
// 创建 axios 实例
|
||||
@@ -40,6 +76,13 @@ service.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 对应国际化资源文件后缀
|
||||
config.headers['Content-Language'] = getLanguage();
|
||||
config.headers[dynamicTenantContextHeader] = currentTenantRequestContextId();
|
||||
const expectedTenantId = currentExpectedTenantId();
|
||||
if (expectedTenantId) {
|
||||
config.headers[expectedTenantHeader] = expectedTenantId;
|
||||
} else {
|
||||
delete config.headers[expectedTenantHeader];
|
||||
}
|
||||
|
||||
const isToken = config.headers?.isToken === false;
|
||||
// 是否需要防止数据重复提交
|
||||
@@ -124,6 +167,7 @@ service.interceptors.response.use(
|
||||
const code = res.data.code || HttpStatus.SUCCESS;
|
||||
// 获取错误信息
|
||||
const msg = errorCode[code] || res.data.msg || errorCode['default'];
|
||||
reconcileTenantContextResponse(res.data);
|
||||
// 二进制数据则直接返回
|
||||
if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
|
||||
return res.data;
|
||||
@@ -198,6 +242,7 @@ export function download(url: string, params: any, fileName: string) {
|
||||
const blob = new Blob([resp]);
|
||||
const resText = await blob.text();
|
||||
const rspObj = JSON.parse(resText);
|
||||
reconcileTenantContextResponse(rspObj);
|
||||
const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode['default'];
|
||||
ElMessage.error(errMsg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
beginTenantContextOperation,
|
||||
clearDynamicTenantContextState,
|
||||
currentExpectedTenantId,
|
||||
currentTenantRequestContextId,
|
||||
dynamicTenantContextVersion,
|
||||
dynamicTenantId,
|
||||
dynamicTenantName,
|
||||
finishTenantContextOperation,
|
||||
restoreDynamicTenantContext,
|
||||
tenantContextVerified,
|
||||
tenantContextSwitching
|
||||
} from './tenant-context-state';
|
||||
|
||||
describe('tenant context state', () => {
|
||||
beforeEach(() => {
|
||||
clearDynamicTenantContextState();
|
||||
});
|
||||
|
||||
it('clears the previous identity context and advances its version', () => {
|
||||
const version = dynamicTenantContextVersion.value;
|
||||
const previousRequestContextId = currentTenantRequestContextId();
|
||||
restoreDynamicTenantContext({ tenantId: 'tenant-a', companyName: 'A 项目' });
|
||||
|
||||
expect(dynamicTenantId.value).toBe('tenant-a');
|
||||
expect(dynamicTenantName.value).toBe('A 项目');
|
||||
expect(tenantContextVerified.value).toBe(true);
|
||||
expect(currentExpectedTenantId()).toBe('tenant-a');
|
||||
expect(currentTenantRequestContextId()).toMatch(/^tab_[A-Za-z0-9_-]{16,}$/);
|
||||
expect(dynamicTenantContextVersion.value).toBe(version + 1);
|
||||
|
||||
tenantContextSwitching.value = true;
|
||||
clearDynamicTenantContextState();
|
||||
|
||||
expect(dynamicTenantId.value).toBeUndefined();
|
||||
expect(dynamicTenantName.value).toBe('');
|
||||
expect(tenantContextVerified.value).toBe(false);
|
||||
expect(currentExpectedTenantId()).toBeUndefined();
|
||||
expect(currentTenantRequestContextId()).not.toBe(previousRequestContextId);
|
||||
expect(tenantContextSwitching.value).toBe(false);
|
||||
expect(dynamicTenantContextVersion.value).toBe(version + 2);
|
||||
});
|
||||
|
||||
it('does not let a stale request unlock a newer tenant operation', () => {
|
||||
const firstOperation = beginTenantContextOperation();
|
||||
clearDynamicTenantContextState();
|
||||
const secondOperation = beginTenantContextOperation();
|
||||
|
||||
finishTenantContextOperation(firstOperation);
|
||||
expect(tenantContextSwitching.value).toBe(true);
|
||||
|
||||
finishTenantContextOperation(secondOperation);
|
||||
expect(tenantContextSwitching.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export type DynamicTenant = {
|
||||
tenantId: string | number;
|
||||
companyName?: string | null;
|
||||
};
|
||||
|
||||
export const dynamicTenantContextHeader = 'X-AIHR-Tenant-Context';
|
||||
export const expectedTenantHeader = 'X-AIHR-Expected-Tenant';
|
||||
const requestContextStorageKey = 'aihr.tenant.page-context';
|
||||
const requestContextPattern = /^tab_[A-Za-z0-9_-]{16,}$/;
|
||||
|
||||
const createRequestContextId = () => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return `tab_${crypto.randomUUID().replace(/-/g, '')}`;
|
||||
}
|
||||
return `tab_${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
};
|
||||
|
||||
function requestContextStorage() {
|
||||
try {
|
||||
return typeof window === 'undefined' ? undefined : window.sessionStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreOrCreateRequestContextId() {
|
||||
const storage = requestContextStorage();
|
||||
const storedContextId = storage?.getItem(requestContextStorageKey);
|
||||
if (storedContextId && requestContextPattern.test(storedContextId)) return storedContextId;
|
||||
|
||||
const requestContextId = createRequestContextId();
|
||||
try {
|
||||
storage?.setItem(requestContextStorageKey, requestContextId);
|
||||
} catch {
|
||||
// Keep the in-memory identifier when browser storage is unavailable.
|
||||
}
|
||||
return requestContextId;
|
||||
}
|
||||
|
||||
// sessionStorage is scoped to one browser tab and survives a refresh. It keeps
|
||||
// a tab's selected tenant stable without allowing another tab sharing the
|
||||
// same login token to overwrite it.
|
||||
let requestContextId = restoreOrCreateRequestContextId();
|
||||
let activeOperationId = 0;
|
||||
|
||||
/**
|
||||
* Shared client-side mirror of the tenant selected by the server for this
|
||||
* browser page. It is dependency-free so authentication can reset it without
|
||||
* creating a store/router import cycle.
|
||||
*/
|
||||
export const dynamicTenantId = ref<string | number>();
|
||||
export const dynamicTenantName = ref('');
|
||||
export const tenantContextSwitching = ref(false);
|
||||
export const tenantContextVerified = ref(false);
|
||||
export const dynamicTenantContextVersion = ref(0);
|
||||
export const hasDynamicTenant = computed(() => dynamicTenantId.value !== undefined && dynamicTenantId.value !== null && dynamicTenantId.value !== '');
|
||||
|
||||
export function currentTenantRequestContextId() {
|
||||
return requestContextId;
|
||||
}
|
||||
|
||||
function resetTenantRequestContextId() {
|
||||
requestContextId = createRequestContextId();
|
||||
try {
|
||||
requestContextStorage()?.setItem(requestContextStorageKey, requestContextId);
|
||||
} catch {
|
||||
// The new in-memory identifier still separates the next identity.
|
||||
}
|
||||
}
|
||||
|
||||
function guardAgainstClonedTabContext(): Promise<void> {
|
||||
if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') return Promise.resolve();
|
||||
const pageInstanceId = createRequestContextId();
|
||||
let channel: BroadcastChannel;
|
||||
try {
|
||||
channel = new BroadcastChannel('aihr-tenant-page-context');
|
||||
} catch {
|
||||
resetTenantRequestContextId();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
resolve();
|
||||
};
|
||||
|
||||
channel.onmessage = (event: MessageEvent<{ type?: string; contextId?: string; pageInstanceId?: string }>) => {
|
||||
const message = event.data;
|
||||
if (message.type === 'probe' && message.contextId === requestContextId && message.pageInstanceId !== pageInstanceId) {
|
||||
channel.postMessage({ type: 'claim', contextId: requestContextId, pageInstanceId: message.pageInstanceId });
|
||||
return;
|
||||
}
|
||||
if (message.type === 'claim' && message.contextId === requestContextId && message.pageInstanceId === pageInstanceId) {
|
||||
resetTenantRequestContextId();
|
||||
markTenantContextUnverified();
|
||||
settle();
|
||||
}
|
||||
};
|
||||
const timeoutId = window.setTimeout(settle, 250);
|
||||
const probeContextOwner = () => channel.postMessage({ type: 'probe', contextId: requestContextId, pageInstanceId });
|
||||
probeContextOwner();
|
||||
|
||||
const closeChannel = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) return;
|
||||
channel.close();
|
||||
window.removeEventListener('pagehide', closeChannel);
|
||||
window.removeEventListener('pageshow', probeOnPageShow);
|
||||
};
|
||||
const probeOnPageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) probeContextOwner();
|
||||
};
|
||||
window.addEventListener('pagehide', closeChannel);
|
||||
window.addEventListener('pageshow', probeOnPageShow);
|
||||
});
|
||||
}
|
||||
|
||||
export function currentExpectedTenantId() {
|
||||
if (!tenantContextVerified.value || !hasDynamicTenant.value) return undefined;
|
||||
return String(dynamicTenantId.value);
|
||||
}
|
||||
|
||||
export function restoreDynamicTenantContext(tenant?: DynamicTenant | null) {
|
||||
dynamicTenantId.value = tenant?.tenantId;
|
||||
dynamicTenantName.value = tenant?.companyName?.trim() || '';
|
||||
tenantContextVerified.value = true;
|
||||
dynamicTenantContextVersion.value += 1;
|
||||
}
|
||||
|
||||
/** Starts the only active switch/clear request for this browser page. */
|
||||
export function beginTenantContextOperation() {
|
||||
activeOperationId += 1;
|
||||
dynamicTenantContextVersion.value += 1;
|
||||
tenantContextSwitching.value = true;
|
||||
return activeOperationId;
|
||||
}
|
||||
|
||||
export function isTenantContextOperationCurrent(operationId: number) {
|
||||
return operationId === activeOperationId;
|
||||
}
|
||||
|
||||
export function finishTenantContextOperation(operationId: number) {
|
||||
if (isTenantContextOperationCurrent(operationId)) {
|
||||
tenantContextSwitching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTenantContextVersionCurrent(version: number) {
|
||||
return dynamicTenantContextVersion.value === version && !tenantContextSwitching.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail closed when the browser cannot prove that its display matches the
|
||||
* server-side context. Requests then omit the expected-tenant header and the
|
||||
* server rejects any still-active dynamic context.
|
||||
*/
|
||||
export function markTenantContextUnverified() {
|
||||
activeOperationId += 1;
|
||||
dynamicTenantId.value = undefined;
|
||||
dynamicTenantName.value = '';
|
||||
tenantContextVerified.value = false;
|
||||
dynamicTenantContextVersion.value += 1;
|
||||
tenantContextSwitching.value = false;
|
||||
}
|
||||
|
||||
/** Clears the UI mirror immediately when the authenticated identity changes. */
|
||||
export function clearDynamicTenantContextState() {
|
||||
markTenantContextUnverified();
|
||||
resetTenantRequestContextId();
|
||||
}
|
||||
|
||||
// Some browsers clone sessionStorage when duplicating a tab. A live owner
|
||||
// claims its context over BroadcastChannel, so the clone fails closed and gets
|
||||
// a fresh page context instead of sharing the owner's dynamic tenant.
|
||||
const tenantContextOwnershipReady = guardAgainstClonedTabContext();
|
||||
|
||||
export async function waitForTenantRequestContextOwnership() {
|
||||
await tenantContextOwnershipReady;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import router from '@/router';
|
||||
import { dynamicClear, dynamicTenant } from '@/api/system/tenant';
|
||||
import tab from '@/plugins/tab';
|
||||
import {
|
||||
beginTenantContextOperation,
|
||||
finishTenantContextOperation,
|
||||
isTenantContextOperationCurrent,
|
||||
markTenantContextUnverified,
|
||||
restoreDynamicTenantContext,
|
||||
tenantContextSwitching,
|
||||
type DynamicTenant
|
||||
} from './tenant-context-state';
|
||||
|
||||
export {
|
||||
currentExpectedTenantId,
|
||||
currentTenantRequestContextId,
|
||||
dynamicTenantContextHeader,
|
||||
clearDynamicTenantContextState,
|
||||
dynamicTenantContextVersion,
|
||||
dynamicTenantId,
|
||||
dynamicTenantName,
|
||||
expectedTenantHeader,
|
||||
hasDynamicTenant,
|
||||
isTenantContextVersionCurrent,
|
||||
markTenantContextUnverified,
|
||||
restoreDynamicTenantContext,
|
||||
tenantContextVerified,
|
||||
tenantContextSwitching,
|
||||
waitForTenantRequestContextOwnership,
|
||||
type DynamicTenant
|
||||
} from './tenant-context-state';
|
||||
|
||||
export async function switchDynamicTenantContext(tenant: DynamicTenant, destination = '/') {
|
||||
if (tenant.tenantId === undefined || tenant.tenantId === null || String(tenant.tenantId).trim() === '') {
|
||||
throw new Error('租户编号不能为空');
|
||||
}
|
||||
if (tenantContextSwitching.value) {
|
||||
throw new Error('租户切换正在进行,请稍候');
|
||||
}
|
||||
|
||||
const operationId = beginTenantContextOperation();
|
||||
let serverContextConfirmed = false;
|
||||
try {
|
||||
await dynamicTenant(tenant.tenantId);
|
||||
if (!isTenantContextOperationCurrent(operationId)) return;
|
||||
restoreDynamicTenantContext(tenant);
|
||||
serverContextConfirmed = true;
|
||||
await router.push(destination);
|
||||
await tab.closeAllPage();
|
||||
await tab.refreshPage();
|
||||
} catch (error) {
|
||||
if (!serverContextConfirmed && isTenantContextOperationCurrent(operationId)) {
|
||||
markTenantContextUnverified();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
finishTenantContextOperation(operationId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearDynamicTenantContext(destination = '/') {
|
||||
if (tenantContextSwitching.value) {
|
||||
throw new Error('租户切换正在进行,请稍候');
|
||||
}
|
||||
|
||||
const operationId = beginTenantContextOperation();
|
||||
let serverContextConfirmed = false;
|
||||
try {
|
||||
await dynamicClear();
|
||||
if (!isTenantContextOperationCurrent(operationId)) return;
|
||||
restoreDynamicTenantContext();
|
||||
serverContextConfirmed = true;
|
||||
await router.push(destination);
|
||||
await tab.closeAllPage();
|
||||
await tab.refreshPage();
|
||||
} catch (error) {
|
||||
if (!serverContextConfirmed && isTenantContextOperationCurrent(operationId)) {
|
||||
markTenantContextUnverified();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
finishTenantContextOperation(operationId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user