feat(backend): 实现 Cool-Admin 多租户与组织架构上下文模块及拦截中间件

这个提交包含在:
wikiboss
2026-09-20 17:17:54 +08:00
父节点 5705302cf0
当前提交 9cc49aa329
共有 6 个文件被更改,包括 573 次插入 和 2 次删除

查看文件

@@ -0,0 +1,113 @@
import { Context } from '@midwayjs/koa';
/**
* 租户上下文元数据契约
*/
export interface TenantContextInfo {
/** 租户 ID */
tenantId: number | string;
/** 关联名片 ID (可选) */
cardId?: number | string;
/** 关联用户 ID (可选) */
userId?: number | string;
/** 是否平台超级管理员 / 超级租户 */
isSuper?: boolean;
/** 租户角色 / 权限标识 (可选) */
role?: string;
/** 扩展属性 */
extra?: Record<string, unknown>;
}
/** 默认兜底租户 ID (平台超级租户 / 本地开发兜底) */
export const DEFAULT_TENANT_ID = 1;
/** 租户上下文在 Context 中的唯一 Symbol 键 */
export const TENANT_CONTEXT_KEY = Symbol('CRMMP_TENANT_CONTEXT');
/**
* 设置当前请求的租户上下文
* 同步保证与 Cool-Admin 现存的 ctx.admin.tenantId / ctx.user.tenantId 机制完全兼容
*/
export function setTenantContext(
ctx: Context,
tenantInfo: TenantContextInfo
): void {
if (!ctx) return;
const contextObj = ctx as unknown as Record<string | symbol, unknown>;
contextObj[TENANT_CONTEXT_KEY] = tenantInfo;
contextObj.tenant = tenantInfo;
// 同步兼容 Cool-Admin 原生 ctx.admin 与 ctx.user (如 TenantSubscriber 数据隔离订阅者)
if (contextObj.admin && typeof contextObj.admin === 'object') {
(contextObj.admin as Record<string, unknown>).tenantId =
tenantInfo.tenantId;
}
if (contextObj.user && typeof contextObj.user === 'object') {
(contextObj.user as Record<string, unknown>).tenantId = tenantInfo.tenantId;
}
}
/**
* 获取当前请求的租户上下文完整对象
*/
export function getTenantContext(ctx: Context): TenantContextInfo | undefined {
if (!ctx) return undefined;
const contextObj = ctx as unknown as Record<string | symbol, unknown>;
return (
(contextObj[TENANT_CONTEXT_KEY] as TenantContextInfo | undefined) ??
(contextObj.tenant as TenantContextInfo | undefined)
);
}
/**
* 安全获取当前租户 ID
* 优先级:
* 1. ctx.tenant.tenantId
* 2. ctx.admin.tenantId (Cool-Admin 原生后台)
* 3. ctx.user.tenantId (Cool-Admin 原生客户端)
*/
export function getTenantId(ctx: Context): number | string | undefined {
if (!ctx) return undefined;
const contextInfo = getTenantContext(ctx);
if (
contextInfo &&
contextInfo.tenantId !== undefined &&
contextInfo.tenantId !== null &&
contextInfo.tenantId !== ''
) {
return contextInfo.tenantId;
}
const contextObj = ctx as unknown as Record<string, unknown>;
const adminTenant = (contextObj.admin as Record<string, unknown> | undefined)
?.tenantId;
const userTenant = (contextObj.user as Record<string, unknown> | undefined)
?.tenantId;
return (
(adminTenant as number | string | undefined) ??
(userTenant as number | string | undefined)
);
}
/**
* 安全获取当前请求的名片 ID
*/
export function getCardId(ctx: Context): number | string | undefined {
if (!ctx) return undefined;
return getTenantContext(ctx)?.cardId;
}
/**
* 判断当前是否为平台超级租户或系统超管
*/
export function isSuperTenant(ctx: Context): boolean {
if (!ctx) return false;
const contextInfo = getTenantContext(ctx);
if (contextInfo?.isSuper) return true;
const currentTenantId = getTenantId(ctx);
if (currentTenantId === DEFAULT_TENANT_ID || currentTenantId === 'default')
return true;
const contextObj = ctx as unknown as Record<string, unknown>;
const adminUser = (contextObj.admin as Record<string, unknown> | undefined)
?.username;
return adminUser === 'admin';
}

查看文件

@@ -0,0 +1,180 @@
import { Config, Middleware } from '@midwayjs/core';
import { NextFunction, Context } from '@midwayjs/koa';
import { IMiddleware } from '@midwayjs/core';
import * as jwt from 'jsonwebtoken';
import { CoolCommException } from '@cool-midway/core';
import {
setTenantContext,
DEFAULT_TENANT_ID,
TenantContextInfo,
} from '../context/tenant';
/**
* 租户中间件配置契约
*/
export interface TenantMiddlewareConfig {
enable?: boolean;
urls?: string[];
strict?: boolean;
defaultTenantId?: number | string;
ignoreUrls?: string[];
}
/**
* 多租户与组织架构上下文拦截中间件
*
* 1. 拦截 HTTP 请求头中的 x-tenant-id 与 x-card-id
* 2. 解析鉴权 Token 中的 tenantId、cardId 与用户身份
* 3. 注入租户上下文并与 ctx.admin / ctx.user 同步
* 4. 对缺失租户上下文的受保护路由提供严格校验或默认兜底
*/
@Middleware()
export class TenantMiddleware implements IMiddleware<Context, NextFunction> {
@Config('koa.globalPrefix')
prefix = '';
@Config('cool.tenant')
tenantConfig?: TenantMiddlewareConfig;
/**
* 默认开放公共路径白名单
*/
private readonly defaultIgnoreUrls: string[] = [
'/admin/base/open/',
'/app/base/open/',
'/public/',
'/upload/',
'/swagger-ui/',
'/api-docs',
'/favicon.ico',
];
resolve() {
return async (ctx: Context, next: NextFunction) => {
// 1. 规范化路由路径 (剔除 globalPrefix 与 query string)
let url = ctx.url || '';
if (this.prefix && url.startsWith(this.prefix)) {
url = url.slice(this.prefix.length);
}
const cleanPath = url.split('?')[0];
// 2. 从 HTTP Header 提取 x-tenant-id 与 x-card-id
const headerTenantId = ctx.get('x-tenant-id');
const headerCardId = ctx.get('x-card-id');
// 3. 从 Token 或已有上下文解析租户与身份
let tokenTenantId: string | number | undefined;
let tokenCardId: string | number | undefined;
let tokenUserId: string | number | undefined;
let isSuper = false;
const contextObj = ctx as unknown as Record<string, unknown>;
const adminObj = contextObj.admin as Record<string, unknown> | undefined;
const userObj = contextObj.user as Record<string, unknown> | undefined;
if (adminObj) {
tokenTenantId = adminObj.tenantId as string | number | undefined;
tokenUserId = adminObj.userId as string | number | undefined;
if (adminObj.username === 'admin') {
isSuper = true;
}
} else if (userObj) {
tokenTenantId = userObj.tenantId as string | number | undefined;
tokenUserId = userObj.userId as string | number | undefined;
}
// 若未获取但携带 Authorization 头,安全解码 payload
const authHeader = ctx.get('authorization') || ctx.get('Authorization');
if (!tokenTenantId && authHeader) {
try {
const rawToken = authHeader.replace(/^Bearer\s+/i, '');
const decoded = jwt.decode(rawToken) as Record<
string,
unknown
> | null;
if (decoded && typeof decoded === 'object') {
tokenTenantId =
(decoded.tenantId as string | number | undefined) ??
tokenTenantId;
tokenCardId =
(decoded.cardId as string | number | undefined) ?? tokenCardId;
tokenUserId =
tokenUserId ?? (decoded.userId as string | number | undefined);
if (decoded.username === 'admin' || decoded.isSuper === true) {
isSuper = true;
}
}
} catch {
// Token 解码失败忽略,由后续鉴权中间件严格拦截
}
}
// 4. 优先级判定:HTTP Header > Token/Context
const rawTenantId = headerTenantId || tokenTenantId;
const parsedTenantId =
rawTenantId !== undefined && rawTenantId !== ''
? isNaN(Number(rawTenantId))
? rawTenantId
: Number(rawTenantId)
: undefined;
const rawCardId = headerCardId || tokenCardId;
const parsedCardId =
rawCardId !== undefined && rawCardId !== ''
? isNaN(Number(rawCardId))
? rawCardId
: Number(rawCardId)
: undefined;
// 5. 检查是否为白名单路由
const isIgnored = this.isPathIgnored(cleanPath);
// 6. 受保护路由校验与兜底
let finalTenantId = parsedTenantId;
if (!finalTenantId) {
if (isSuper) {
finalTenantId =
this.tenantConfig?.defaultTenantId ?? DEFAULT_TENANT_ID;
} else if (!isIgnored && this.isProtectedPath(cleanPath)) {
if (this.tenantConfig?.strict) {
ctx.status = 400;
throw new CoolCommException('缺少租户上下文~', 400);
} else {
finalTenantId =
this.tenantConfig?.defaultTenantId ?? DEFAULT_TENANT_ID;
}
}
}
// 7. 注入上下文
if (finalTenantId !== undefined) {
const tenantInfo: TenantContextInfo = {
tenantId: finalTenantId,
cardId: parsedCardId,
userId: tokenUserId,
isSuper,
};
setTenantContext(ctx, tenantInfo);
}
await next();
};
}
/**
* 检查路径是否命中免除租户校验白名单
*/
private isPathIgnored(path: string): boolean {
const customIgnores = this.tenantConfig?.ignoreUrls || [];
const allIgnores = [...this.defaultIgnoreUrls, ...customIgnores];
return allIgnores.some(prefix => path.startsWith(prefix));
}
/**
* 检查是否属于需要租户隔离的受保护路由
*/
private isProtectedPath(path: string): boolean {
return path.startsWith('/admin/') || path.startsWith('/app/');
}
}

查看文件

@@ -18,6 +18,7 @@ import * as LocalConfig from './config/config.local';
import * as ProdConfig from './config/config.prod';
import * as cool from '@cool-midway/core';
import * as upload from '@midwayjs/upload';
import { TenantMiddleware } from './comm/middleware/tenant';
// import * as task from '@cool-midway/task';
// import * as rpc from '@cool-midway/rpc';
@@ -66,5 +67,7 @@ export class MainConfiguration {
@Inject()
logger: ILogger;
async onReady() {}
async onReady() {
this.app.useMiddleware(TenantMiddleware);
}
}