diff --git a/backend/jest.config.js b/backend/jest.config.js index c5bd388..442aff4 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,6 +1,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + testMatch: ['/test/**/*.test.ts'], testPathIgnorePatterns: ['/test/fixtures'], coveragePathIgnorePatterns: ['/test/'], }; \ No newline at end of file diff --git a/backend/src/comm/context/tenant.ts b/backend/src/comm/context/tenant.ts new file mode 100644 index 0000000..7be3589 --- /dev/null +++ b/backend/src/comm/context/tenant.ts @@ -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; +} + +/** 默认兜底租户 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; + 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).tenantId = + tenantInfo.tenantId; + } + if (contextObj.user && typeof contextObj.user === 'object') { + (contextObj.user as Record).tenantId = tenantInfo.tenantId; + } +} + +/** + * 获取当前请求的租户上下文完整对象 + */ +export function getTenantContext(ctx: Context): TenantContextInfo | undefined { + if (!ctx) return undefined; + const contextObj = ctx as unknown as Record; + 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; + const adminTenant = (contextObj.admin as Record | undefined) + ?.tenantId; + const userTenant = (contextObj.user as Record | 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; + const adminUser = (contextObj.admin as Record | undefined) + ?.username; + return adminUser === 'admin'; +} diff --git a/backend/src/comm/middleware/tenant.ts b/backend/src/comm/middleware/tenant.ts new file mode 100644 index 0000000..a8a8cf0 --- /dev/null +++ b/backend/src/comm/middleware/tenant.ts @@ -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 { + @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; + const adminObj = contextObj.admin as Record | undefined; + const userObj = contextObj.user as Record | 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/'); + } +} diff --git a/backend/src/configuration.ts b/backend/src/configuration.ts index 116ab6a..ca2a37f 100644 --- a/backend/src/configuration.ts +++ b/backend/src/configuration.ts @@ -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); + } } diff --git a/backend/test/comm/tenant.test.ts b/backend/test/comm/tenant.test.ts new file mode 100644 index 0000000..d88475c --- /dev/null +++ b/backend/test/comm/tenant.test.ts @@ -0,0 +1,273 @@ +import * as jwt from 'jsonwebtoken'; +import { + DEFAULT_TENANT_ID, + TENANT_CONTEXT_KEY, + getCardId, + getTenantContext, + getTenantId, + isSuperTenant, + setTenantContext, + TenantContextInfo, +} from '../../src/comm/context/tenant'; +import { TenantMiddleware } from '../../src/comm/middleware/tenant'; +import { CoolCommException } from '@cool-midway/core'; + +describe('多租户与组织架构上下文模块 (Tenant Context & Middleware)', () => { + const mockJwtSecret = 'omnicard-secret-2026'; + + describe('1. 租户上下文工具函数 (comm/context/tenant.ts)', () => { + it('空上下文或未设置时应安全返回 undefined 或 false', () => { + const emptyCtx: any = {}; + expect(getTenantId(emptyCtx)).toBeUndefined(); + expect(getCardId(emptyCtx)).toBeUndefined(); + expect(getTenantContext(emptyCtx)).toBeUndefined(); + expect(isSuperTenant(emptyCtx)).toBe(false); + + expect(getTenantId(undefined as any)).toBeUndefined(); + expect(getCardId(undefined as any)).toBeUndefined(); + expect(getTenantContext(undefined as any)).toBeUndefined(); + expect(isSuperTenant(undefined as any)).toBe(false); + }); + + it('setTenantContext 应正确绑定上下文并与 ctx.admin / ctx.user 同步', () => { + const ctx: any = { + admin: { userId: 10, username: 'tester' }, + user: { userId: 20 }, + }; + + const tenantInfo: TenantContextInfo = { + tenantId: 888, + cardId: 999, + userId: 10, + isSuper: false, + role: 'tenant_admin', + }; + + setTenantContext(ctx, tenantInfo); + + expect(getTenantContext(ctx)).toEqual(tenantInfo); + expect(ctx.tenant).toEqual(tenantInfo); + expect(ctx[TENANT_CONTEXT_KEY]).toEqual(tenantInfo); + expect(getTenantId(ctx)).toBe(888); + expect(getCardId(ctx)).toBe(999); + expect(isSuperTenant(ctx)).toBe(false); + + // 同步验证对 Cool-Admin 原生字段的兼容性 + expect(ctx.admin.tenantId).toBe(888); + expect(ctx.user.tenantId).toBe(888); + }); + + it('当仅存在原生 ctx.admin.tenantId 或 ctx.user.tenantId 时,getTenantId 能够平滑兜底读取', () => { + const adminCtx: any = { admin: { tenantId: 300 } }; + expect(getTenantId(adminCtx)).toBe(300); + + const userCtx: any = { user: { tenantId: 'tenant_abc' } }; + expect(getTenantId(userCtx)).toBe('tenant_abc'); + }); + + it('isSuperTenant 应能准确识别超级管理员状态', () => { + // 1. isSuper 显式为 true + const superCtx1: any = {}; + setTenantContext(superCtx1, { tenantId: 99, isSuper: true }); + expect(isSuperTenant(superCtx1)).toBe(true); + + // 2. tenantId 为 DEFAULT_TENANT_ID (1) 或 'default' + const superCtx2: any = {}; + setTenantContext(superCtx2, { tenantId: DEFAULT_TENANT_ID }); + expect(isSuperTenant(superCtx2)).toBe(true); + + const superCtx3: any = {}; + setTenantContext(superCtx3, { tenantId: 'default' }); + expect(isSuperTenant(superCtx3)).toBe(true); + + // 3. admin 用户名为 'admin' + const superCtx4: any = { admin: { username: 'admin' } }; + expect(isSuperTenant(superCtx4)).toBe(true); + }); + }); + + describe('2. 多租户拦截中间件 (comm/middleware/tenant.ts)', () => { + let middleware: TenantMiddleware; + + beforeEach(() => { + middleware = new TenantMiddleware(); + middleware.prefix = ''; + middleware.tenantConfig = { + enable: true, + strict: true, + defaultTenantId: DEFAULT_TENANT_ID, + ignoreUrls: ['/api/public/'], + }; + }); + + it('应能从 HTTP Header (x-tenant-id 与 x-card-id) 中解析并注入上下文', async () => { + const ctx: any = { + url: '/admin/crm/lead/page', + headers: { + 'x-tenant-id': '10086', + 'x-card-id': '2001', + }, + get(key: string) { + return this.headers[key.toLowerCase()]; + }, + }; + + let nextCalled = false; + const next = async () => { + nextCalled = true; + }; + + const handler = middleware.resolve(); + await handler(ctx, next); + + expect(nextCalled).toBe(true); + expect(getTenantId(ctx)).toBe(10086); + expect(getCardId(ctx)).toBe(2001); + expect(ctx.tenant.tenantId).toBe(10086); + }); + + it('应能从 JWT Authorization 头解析 tenantId 与 cardId', async () => { + const token = jwt.sign( + { userId: 101, tenantId: 666, cardId: 777, username: 'john' }, + mockJwtSecret + ); + + const ctx: any = { + url: '/admin/crm/card/info', + headers: { + authorization: `Bearer ${token}`, + }, + get(key: string) { + return this.headers[key.toLowerCase()]; + }, + }; + + let nextCalled = false; + const next = async () => { + nextCalled = true; + }; + + const handler = middleware.resolve(); + await handler(ctx, next); + + expect(nextCalled).toBe(true); + expect(getTenantId(ctx)).toBe(666); + expect(getCardId(ctx)).toBe(777); + expect(ctx.tenant.userId).toBe(101); + }); + + it('当 Header 与 JWT 同时存在时,Header 优先级高于 JWT', async () => { + const token = jwt.sign( + { userId: 101, tenantId: 666, cardId: 777 }, + mockJwtSecret + ); + + const ctx: any = { + url: '/admin/crm/card/info', + headers: { + 'x-tenant-id': '9999', + 'x-card-id': '8888', + authorization: `Bearer ${token}`, + }, + get(key: string) { + return this.headers[key.toLowerCase()]; + }, + }; + + const handler = middleware.resolve(); + await handler(ctx, async () => {}); + + expect(getTenantId(ctx)).toBe(9999); + expect(getCardId(ctx)).toBe(8888); + }); + + it('针对免校验开放接口 (如 /admin/base/open/login),即便无租户信息也应直接放行', async () => { + const ctx: any = { + url: '/admin/base/open/login?type=pwd', + headers: {}, + get(key: string) { + return this.headers[key.toLowerCase()]; + }, + }; + + let nextCalled = false; + const next = async () => { + nextCalled = true; + }; + + const handler = middleware.resolve(); + await handler(ctx, next); + + expect(nextCalled).toBe(true); + expect(getTenantId(ctx)).toBeUndefined(); + }); + + it('受保护路由在 strict=true 模式且无租户上下文时应抛出 400 异常', async () => { + const ctx: any = { + url: '/admin/crm/lead/page', + headers: {}, + get(key: string) { + return this.headers[key.toLowerCase()]; + }, + }; + + const handler = middleware.resolve(); + + await expect(handler(ctx, async () => {})).rejects.toThrow(CoolCommException); + expect(ctx.status).toBe(400); + }); + + it('受保护路由在 strict=false 模式时应提供默认兜底租户 ID', async () => { + middleware.tenantConfig.strict = false; + middleware.tenantConfig.defaultTenantId = 1; + + const ctx: any = { + url: '/admin/crm/lead/page', + headers: {}, + get(key: string) { + return this.headers[key.toLowerCase()]; + }, + }; + + let nextCalled = false; + const next = async () => { + nextCalled = true; + }; + + const handler = middleware.resolve(); + await handler(ctx, next); + + expect(nextCalled).toBe(true); + expect(getTenantId(ctx)).toBe(1); + }); + + it('超级管理员访问受保护路由时,即便缺失显式 Header 也自动兜底为平台超级租户', async () => { + const superAdminToken = jwt.sign( + { userId: 1, username: 'admin' }, + mockJwtSecret + ); + + const ctx: any = { + url: '/admin/crm/lead/page', + headers: { + authorization: `Bearer ${superAdminToken}`, + }, + get(key: string) { + return this.headers[key.toLowerCase()]; + }, + }; + + let nextCalled = false; + const next = async () => { + nextCalled = true; + }; + + const handler = middleware.resolve(); + await handler(ctx, next); + + expect(nextCalled).toBe(true); + expect(getTenantId(ctx)).toBe(DEFAULT_TENANT_ID); + expect(isSuperTenant(ctx)).toBe(true); + }); + }); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 00074bd..37998d3 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -26,5 +26,6 @@ "dist", "node_modules", "test", - ], + "**/*.test.ts" + ] }