feat: add enterprise multi-tenancy infrastructure

- Add shared tenant module with AsyncLocalStorage-based context management
- Create TenantContextService, TenantContextMiddleware, TenantGuard
- Add @TenantId(), @Tenant(), @RequireFeatures() decorators
- Create BaseTenantRepository for automatic tenant filtering
- Add TenantORM entity for tenants table
- Add tenant_id column to all 16 ORM entities across 6 services
- Create database migration script for multi-tenancy support
- Add tenant-related error codes

This implements row-level isolation for 100% data separation between tenants.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-25 18:11:12 -08:00
parent 2d4e6285a4
commit 422069be68
30 changed files with 1455 additions and 8 deletions

View File

@ -5,6 +5,7 @@ import {
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { MessageORM } from './message.orm';
import {
@ -18,10 +19,16 @@ import {
* Conversation ORM Entity - Database representation
*/
@Entity('conversations')
@Index('idx_conversations_tenant', ['tenantId'])
@Index('idx_conversations_tenant_user', ['tenantId', 'userId'])
@Index('idx_conversations_tenant_status', ['tenantId', 'status'])
export class ConversationORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;

View File

@ -14,6 +14,7 @@ import { MessageRoleType, MessageTypeType } from '../../../../domain/entities/me
* Message ORM Entity - Database representation
*/
@Entity('messages')
@Index('idx_messages_tenant', ['tenantId'])
@Index('idx_messages_conversation_id', ['conversationId'])
@Index('idx_messages_created_at', ['createdAt'])
@Index('idx_messages_role', ['role'])
@ -21,6 +22,9 @@ export class MessageORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'conversation_id', type: 'uuid', nullable: true })
conversationId: string;

View File

@ -10,6 +10,7 @@ import {
* Token Usage ORM Entity - Database representation
*/
@Entity('token_usages')
@Index('idx_token_usages_tenant', ['tenantId'])
@Index('idx_token_usages_user', ['userId'])
@Index('idx_token_usages_conversation', ['conversationId'])
@Index('idx_token_usages_created', ['createdAt'])
@ -18,6 +19,9 @@ export class TokenUsageORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string | null;

View File

@ -7,6 +7,7 @@ import {
} from 'typeorm';
@Entity('audit_logs')
@Index('idx_audit_logs_tenant', ['tenantId'])
@Index('idx_audit_logs_actor_id', ['actorId'])
@Index('idx_audit_logs_actor_type', ['actorType'])
@Index('idx_audit_logs_action', ['action'])
@ -17,6 +18,9 @@ export class AuditLogORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
actorId: string | null;

View File

@ -9,13 +9,17 @@ import {
} from 'typeorm';
@Entity('daily_statistics')
@Unique(['statDate', 'dimension', 'dimensionValue'])
@Unique(['tenantId', 'statDate', 'dimension', 'dimensionValue'])
@Index('idx_daily_statistics_tenant', ['tenantId'])
@Index('idx_daily_statistics_stat_date', ['statDate'])
@Index('idx_daily_statistics_dimension', ['dimension', 'dimensionValue'])
export class DailyStatisticsORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'stat_date', type: 'date' })
statDate: Date;

View File

@ -8,13 +8,17 @@ import {
} from 'typeorm';
@Entity('monthly_financial_reports')
@Index('idx_monthly_financial_reports_tenant', ['tenantId'])
@Index('idx_monthly_financial_reports_month', ['reportMonth'])
@Index('idx_monthly_financial_reports_status', ['status'])
export class MonthlyFinancialReportORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'report_month', type: 'varchar', length: 7, unique: true })
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'report_month', type: 'varchar', length: 7 })
reportMonth: string;
// Revenue Statistics

View File

@ -4,13 +4,29 @@ import {
PrimaryColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity('admins')
@Index('idx_admins_tenant', ['tenantId'])
@Index('idx_admins_username_tenant', ['tenantId', 'username'])
export class AdminORM {
@PrimaryColumn('uuid')
id: string;
/**
* ID
* null ()
*/
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string | null;
/**
*
*/
@Column({ name: 'is_super_admin', type: 'boolean', default: false })
isSuperAdmin: boolean;
@Column({ length: 50, unique: true })
username: string;

View File

@ -0,0 +1,131 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
/**
*
*/
export enum TenantStatus {
ACTIVE = 'ACTIVE',
SUSPENDED = 'SUSPENDED',
ARCHIVED = 'ARCHIVED',
}
/**
*
*/
export enum TenantPlan {
FREE = 'FREE',
STANDARD = 'STANDARD',
ENTERPRISE = 'ENTERPRISE',
}
/**
*
*/
export interface TenantConfigData {
branding?: {
logoUrl?: string;
primaryColor?: string;
companyName?: string;
favicon?: string;
};
ai?: {
systemPrompt?: string;
model?: string;
temperature?: number;
maxTokens?: number;
};
features?: {
enableFileUpload?: boolean;
enableKnowledgeBase?: boolean;
enablePayments?: boolean;
enableUserMemory?: boolean;
enableAnalytics?: boolean;
};
notifications?: {
webhookUrl?: string;
emailNotifications?: boolean;
smsNotifications?: boolean;
};
}
/**
* ORM
*/
@Entity('tenants')
@Index('idx_tenants_slug', ['slug'], { unique: true })
@Index('idx_tenants_status', ['status'])
export class TenantORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100, unique: true })
name: string;
@Column({ type: 'varchar', length: 50, unique: true })
slug: string;
@Column({
type: 'varchar',
length: 20,
default: TenantStatus.ACTIVE,
})
status: TenantStatus;
@Column({
type: 'varchar',
length: 30,
default: TenantPlan.STANDARD,
})
plan: TenantPlan;
// 配额限制
@Column({ name: 'max_users', type: 'int', default: 100 })
maxUsers: number;
@Column({ name: 'max_conversations_per_month', type: 'int', default: 10000 })
maxConversationsPerMonth: number;
@Column({ name: 'max_storage_mb', type: 'int', default: 5120 })
maxStorageMb: number;
// 当前使用量
@Column({ name: 'current_user_count', type: 'int', default: 0 })
currentUserCount: number;
@Column({ name: 'current_conversation_count', type: 'int', default: 0 })
currentConversationCount: number;
@Column({ name: 'current_storage_bytes', type: 'bigint', default: 0 })
currentStorageBytes: number;
// 配置
@Column({ type: 'jsonb', default: '{}' })
config: TenantConfigData;
// 账单信息
@Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true })
billingEmail: string | null;
@Column({ name: 'billing_name', type: 'varchar', length: 100, nullable: true })
billingName: string | null;
@Column({ name: 'billing_phone', type: 'varchar', length: 20, nullable: true })
billingPhone: string | null;
// 时间戳
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'suspended_at', type: 'timestamptz', nullable: true })
suspendedAt: Date | null;
}

View File

@ -0,0 +1,252 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
*
*
* :
* 1. tenants
* 2. tenant_id
* 3.
* 4.
*/
export class AddMultiTenancy1737817200000 implements MigrationInterface {
name = 'AddMultiTenancy1737817200000';
// 默认租户 ID (用于迁移现有数据)
private readonly DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
// ========== 1. 创建 tenants 表 ==========
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(50) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
plan VARCHAR(30) NOT NULL DEFAULT 'STANDARD',
max_users INT NOT NULL DEFAULT 100,
max_conversations_per_month INT NOT NULL DEFAULT 10000,
max_storage_mb INT NOT NULL DEFAULT 5120,
current_user_count INT NOT NULL DEFAULT 0,
current_conversation_count INT NOT NULL DEFAULT 0,
current_storage_bytes BIGINT NOT NULL DEFAULT 0,
config JSONB NOT NULL DEFAULT '{}',
billing_email VARCHAR(255),
billing_name VARCHAR(100),
billing_phone VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
suspended_at TIMESTAMPTZ
);
`);
// 创建 tenants 表索引
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);`);
// 插入默认租户
await queryRunner.query(`
INSERT INTO tenants (id, name, slug, status, plan)
VALUES ('${this.DEFAULT_TENANT_ID}', 'Default Tenant', 'default', 'ACTIVE', 'ENTERPRISE')
ON CONFLICT (id) DO NOTHING;
`);
// ========== 2. 为 users 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE users SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE users ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_users_tenant_phone ON users(tenant_id, phone) WHERE phone IS NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_users_tenant_fingerprint ON users(tenant_id, fingerprint) WHERE fingerprint IS NOT NULL;`);
// ========== 3. 为 verification_codes 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE verification_codes ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE verification_codes SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE verification_codes ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_verification_codes_tenant ON verification_codes(tenant_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_verification_codes_tenant_phone ON verification_codes(tenant_id, phone);`);
// ========== 4. 为 conversations 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE conversations ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE conversations SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE conversations ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_conversations_tenant ON conversations(tenant_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_conversations_tenant_user ON conversations(tenant_id, user_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_conversations_tenant_status ON conversations(tenant_id, status);`);
// ========== 5. 为 messages 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE messages ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE messages SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE messages ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_messages_tenant ON messages(tenant_id);`);
// ========== 6. 为 token_usages 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE token_usages ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE token_usages SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE token_usages ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_token_usages_tenant ON token_usages(tenant_id);`);
// ========== 7. 为 files 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE files ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE files SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE files ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_files_tenant ON files(tenant_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_files_tenant_user ON files(tenant_id, user_id);`);
// ========== 8. 为 knowledge_articles 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE knowledge_articles ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE knowledge_articles SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE knowledge_articles ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_knowledge_articles_tenant ON knowledge_articles(tenant_id);`);
// ========== 9. 为 knowledge_chunks 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE knowledge_chunks ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE knowledge_chunks SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE knowledge_chunks ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_tenant ON knowledge_chunks(tenant_id);`);
// ========== 10. 为 user_memories 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE user_memories ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE user_memories SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE user_memories ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_user_memories_tenant ON user_memories(tenant_id);`);
// ========== 11. 为 system_experiences 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE system_experiences ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE system_experiences SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE system_experiences ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_system_experiences_tenant ON system_experiences(tenant_id);`);
// ========== 12. 为 orders 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE orders SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE orders ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_orders_tenant ON orders(tenant_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_orders_tenant_user ON orders(tenant_id, user_id);`);
// ========== 13. 为 payments 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE payments ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE payments SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE payments ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_payments_tenant ON payments(tenant_id);`);
// ========== 14. 为 admins 表添加 tenant_id 和 is_super_admin ==========
await queryRunner.query(`ALTER TABLE admins ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`ALTER TABLE admins ADD COLUMN IF NOT EXISTS is_super_admin BOOLEAN DEFAULT false;`);
// 将现有管理员设置为默认租户的管理员
await queryRunner.query(`UPDATE admins SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL AND is_super_admin = false;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_admins_tenant ON admins(tenant_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_admins_username_tenant ON admins(tenant_id, username);`);
// ========== 15. 为 daily_statistics 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE daily_statistics ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE daily_statistics SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE daily_statistics ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_daily_statistics_tenant ON daily_statistics(tenant_id);`);
// 更新唯一约束
await queryRunner.query(`ALTER TABLE daily_statistics DROP CONSTRAINT IF EXISTS daily_statistics_stat_date_dimension_dimension_value_key;`);
await queryRunner.query(`ALTER TABLE daily_statistics ADD CONSTRAINT daily_statistics_tenant_date_dim_unique UNIQUE(tenant_id, stat_date, dimension, dimension_value);`);
// ========== 16. 为 monthly_financial_reports 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE monthly_financial_reports ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE monthly_financial_reports SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE monthly_financial_reports ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_monthly_financial_reports_tenant ON monthly_financial_reports(tenant_id);`);
// 更新唯一约束 (移除原有的 report_month unique改为 tenant_id + report_month)
await queryRunner.query(`ALTER TABLE monthly_financial_reports DROP CONSTRAINT IF EXISTS monthly_financial_reports_report_month_key;`);
await queryRunner.query(`ALTER TABLE monthly_financial_reports ADD CONSTRAINT monthly_financial_reports_tenant_month_unique UNIQUE(tenant_id, report_month);`);
// ========== 17. 为 audit_logs 表添加 tenant_id ==========
await queryRunner.query(`ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS tenant_id UUID;`);
await queryRunner.query(`UPDATE audit_logs SET tenant_id = '${this.DEFAULT_TENANT_ID}' WHERE tenant_id IS NULL;`);
await queryRunner.query(`ALTER TABLE audit_logs ALTER COLUMN tenant_id SET NOT NULL;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant ON audit_logs(tenant_id);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 回滚: 删除所有 tenant_id 列和相关索引
// 注意: 这是破坏性操作,会丢失租户隔离
// audit_logs
await queryRunner.query(`DROP INDEX IF EXISTS idx_audit_logs_tenant;`);
await queryRunner.query(`ALTER TABLE audit_logs DROP COLUMN IF EXISTS tenant_id;`);
// monthly_financial_reports
await queryRunner.query(`ALTER TABLE monthly_financial_reports DROP CONSTRAINT IF EXISTS monthly_financial_reports_tenant_month_unique;`);
await queryRunner.query(`ALTER TABLE monthly_financial_reports ADD CONSTRAINT monthly_financial_reports_report_month_key UNIQUE(report_month);`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_monthly_financial_reports_tenant;`);
await queryRunner.query(`ALTER TABLE monthly_financial_reports DROP COLUMN IF EXISTS tenant_id;`);
// daily_statistics
await queryRunner.query(`ALTER TABLE daily_statistics DROP CONSTRAINT IF EXISTS daily_statistics_tenant_date_dim_unique;`);
await queryRunner.query(`ALTER TABLE daily_statistics ADD CONSTRAINT daily_statistics_stat_date_dimension_dimension_value_key UNIQUE(stat_date, dimension, dimension_value);`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_daily_statistics_tenant;`);
await queryRunner.query(`ALTER TABLE daily_statistics DROP COLUMN IF EXISTS tenant_id;`);
// admins
await queryRunner.query(`DROP INDEX IF EXISTS idx_admins_username_tenant;`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_admins_tenant;`);
await queryRunner.query(`ALTER TABLE admins DROP COLUMN IF EXISTS is_super_admin;`);
await queryRunner.query(`ALTER TABLE admins DROP COLUMN IF EXISTS tenant_id;`);
// payments
await queryRunner.query(`DROP INDEX IF EXISTS idx_payments_tenant;`);
await queryRunner.query(`ALTER TABLE payments DROP COLUMN IF EXISTS tenant_id;`);
// orders
await queryRunner.query(`DROP INDEX IF EXISTS idx_orders_tenant_user;`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_orders_tenant;`);
await queryRunner.query(`ALTER TABLE orders DROP COLUMN IF EXISTS tenant_id;`);
// system_experiences
await queryRunner.query(`DROP INDEX IF EXISTS idx_system_experiences_tenant;`);
await queryRunner.query(`ALTER TABLE system_experiences DROP COLUMN IF EXISTS tenant_id;`);
// user_memories
await queryRunner.query(`DROP INDEX IF EXISTS idx_user_memories_tenant;`);
await queryRunner.query(`ALTER TABLE user_memories DROP COLUMN IF EXISTS tenant_id;`);
// knowledge_chunks
await queryRunner.query(`DROP INDEX IF EXISTS idx_knowledge_chunks_tenant;`);
await queryRunner.query(`ALTER TABLE knowledge_chunks DROP COLUMN IF EXISTS tenant_id;`);
// knowledge_articles
await queryRunner.query(`DROP INDEX IF EXISTS idx_knowledge_articles_tenant;`);
await queryRunner.query(`ALTER TABLE knowledge_articles DROP COLUMN IF EXISTS tenant_id;`);
// files
await queryRunner.query(`DROP INDEX IF EXISTS idx_files_tenant_user;`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_files_tenant;`);
await queryRunner.query(`ALTER TABLE files DROP COLUMN IF EXISTS tenant_id;`);
// token_usages
await queryRunner.query(`DROP INDEX IF EXISTS idx_token_usages_tenant;`);
await queryRunner.query(`ALTER TABLE token_usages DROP COLUMN IF EXISTS tenant_id;`);
// messages
await queryRunner.query(`DROP INDEX IF EXISTS idx_messages_tenant;`);
await queryRunner.query(`ALTER TABLE messages DROP COLUMN IF EXISTS tenant_id;`);
// conversations
await queryRunner.query(`DROP INDEX IF EXISTS idx_conversations_tenant_status;`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_conversations_tenant_user;`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_conversations_tenant;`);
await queryRunner.query(`ALTER TABLE conversations DROP COLUMN IF EXISTS tenant_id;`);
// verification_codes
await queryRunner.query(`DROP INDEX IF EXISTS idx_verification_codes_tenant_phone;`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_verification_codes_tenant;`);
await queryRunner.query(`ALTER TABLE verification_codes DROP COLUMN IF EXISTS tenant_id;`);
// users
await queryRunner.query(`DROP INDEX IF EXISTS idx_users_tenant_fingerprint;`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_users_tenant_phone;`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_users_tenant;`);
await queryRunner.query(`ALTER TABLE users DROP COLUMN IF EXISTS tenant_id;`);
// tenants 表
await queryRunner.query(`DROP INDEX IF EXISTS idx_tenants_status;`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_tenants_slug;`);
await queryRunner.query(`DROP TABLE IF EXISTS tenants;`);
}
}

View File

@ -8,12 +8,17 @@ import {
} from 'typeorm';
@Entity('files')
@Index('idx_files_tenant', ['tenantId'])
@Index('idx_files_tenant_user', ['tenantId', 'userId'])
@Index(['userId', 'createdAt'])
@Index(['conversationId', 'createdAt'])
export class FileORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid' })
@Index()
userId: string;

View File

@ -24,12 +24,16 @@ const vectorTransformer = {
};
@Entity('knowledge_articles')
@Index('idx_knowledge_articles_tenant', ['tenantId'])
@Index('idx_knowledge_articles_category', ['category'])
@Index('idx_knowledge_articles_published', ['isPublished'])
export class KnowledgeArticleORM {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 500 })
title: string;

View File

@ -23,11 +23,15 @@ const vectorTransformer = {
};
@Entity('knowledge_chunks')
@Index('idx_knowledge_chunks_tenant', ['tenantId'])
@Index('idx_knowledge_chunks_article', ['articleId'])
export class KnowledgeChunkORM {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'article_id', type: 'uuid' })
articleId: string;

View File

@ -24,6 +24,7 @@ const vectorTransformer = {
};
@Entity('system_experiences')
@Index('idx_system_experiences_tenant', ['tenantId'])
@Index('idx_system_experiences_type', ['experienceType'])
@Index('idx_system_experiences_status', ['verificationStatus'])
@Index('idx_system_experiences_active', ['isActive'])
@ -31,6 +32,9 @@ export class SystemExperienceORM {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'experience_type', type: 'varchar', length: 30 })
experienceType: string;

View File

@ -25,12 +25,16 @@ const vectorTransformer = {
};
@Entity('user_memories')
@Index('idx_user_memories_tenant', ['tenantId'])
@Index('idx_user_memories_user', ['userId'])
@Index('idx_user_memories_type', ['memoryType'])
export class UserMemoryORM {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;

View File

@ -9,11 +9,16 @@ import {
} from 'typeorm';
@Entity('orders')
@Index('idx_orders_tenant', ['tenantId'])
@Index('idx_orders_tenant_user', ['tenantId', 'userId'])
@Index('idx_orders_user_status', ['userId', 'status'])
export class OrderORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;

View File

@ -9,12 +9,16 @@ import {
} from 'typeorm';
@Entity('payments')
@Index('idx_payments_tenant', ['tenantId'])
@Index('idx_payments_transaction_id', ['transactionId'], { unique: true, where: '"transaction_id" IS NOT NULL' })
@Index('idx_payments_order_status', ['orderId', 'status'])
export class PaymentORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'order_id', type: 'uuid' })
orderId: string;

View File

@ -4,6 +4,7 @@ import {
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
/**
@ -11,10 +12,16 @@ import {
* This is the TypeORM entity with database-specific decorators
*/
@Entity('users')
@Index('idx_users_tenant', ['tenantId'])
@Index('idx_users_tenant_phone', ['tenantId', 'phone'])
@Index('idx_users_tenant_fingerprint', ['tenantId', 'fingerprint'])
export class UserORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({
type: 'varchar',
length: 20,

View File

@ -3,6 +3,7 @@ import {
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
/**
@ -10,10 +11,15 @@ import {
* This is the TypeORM entity with database-specific decorators
*/
@Entity('verification_codes')
@Index('idx_verification_codes_tenant', ['tenantId'])
@Index('idx_verification_codes_tenant_phone', ['tenantId', 'phone'])
export class VerificationCodeORM {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 20 })
phone: string;

View File

@ -21,6 +21,10 @@
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.js"
},
"./tenant": {
"types": "./dist/tenant/index.d.ts",
"import": "./dist/tenant/index.js"
}
},
"scripts": {
@ -29,7 +33,19 @@
"clean": "rm -rf dist",
"lint": "eslint src --ext .ts"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"express": "^4.18.0",
"typeorm": "^0.3.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"typeorm": "^0.3.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"typescript": "^5.3.0"
}
}

View File

@ -59,6 +59,16 @@ export const ERROR_CODES = {
// Rate limiting errors (9xxx)
RATE_LIMIT_EXCEEDED: 'RATE_9001',
// Tenant errors (11xxx)
TENANT_NOT_FOUND: 'TENANT_11001',
TENANT_SUSPENDED: 'TENANT_11002',
TENANT_ARCHIVED: 'TENANT_11003',
TENANT_CONTEXT_REQUIRED: 'TENANT_11004',
TENANT_FEATURE_DISABLED: 'TENANT_11005',
TENANT_PLAN_UPGRADE_REQUIRED: 'TENANT_11006',
TENANT_QUOTA_EXCEEDED: 'TENANT_11007',
TENANT_ALREADY_EXISTS: 'TENANT_11008',
// System errors (10xxx)
INTERNAL_ERROR: 'SYS_10001',
SERVICE_UNAVAILABLE: 'SYS_10002',
@ -117,6 +127,15 @@ export const ERROR_MESSAGES: Record<ErrorCode, string> = {
[ERROR_CODES.RATE_LIMIT_EXCEEDED]: '请求过于频繁,请稍后再试',
[ERROR_CODES.TENANT_NOT_FOUND]: '租户不存在',
[ERROR_CODES.TENANT_SUSPENDED]: '租户已暂停服务',
[ERROR_CODES.TENANT_ARCHIVED]: '租户已归档',
[ERROR_CODES.TENANT_CONTEXT_REQUIRED]: '需要租户上下文',
[ERROR_CODES.TENANT_FEATURE_DISABLED]: '该功能未启用',
[ERROR_CODES.TENANT_PLAN_UPGRADE_REQUIRED]: '需要升级套餐',
[ERROR_CODES.TENANT_QUOTA_EXCEEDED]: '租户配额已用尽',
[ERROR_CODES.TENANT_ALREADY_EXISTS]: '租户已存在',
[ERROR_CODES.INTERNAL_ERROR]: '系统内部错误',
[ERROR_CODES.SERVICE_UNAVAILABLE]: '服务暂时不可用',
[ERROR_CODES.DATABASE_ERROR]: '数据库错误',

View File

@ -6,3 +6,6 @@ export * from './constants/index.js';
// Utils
export * from './utils/index.js';
// Tenant (Multi-tenancy)
export * from './tenant/index.js';

View File

@ -0,0 +1,167 @@
import { Repository, SelectQueryBuilder, FindOptionsWhere, DeepPartial } from 'typeorm';
import { TenantContextService } from './tenant-context.service.js';
/**
* tenant_id ORM
*/
export interface TenantAwareEntity {
tenantId: string;
}
/**
*
*
*
* @template _TEntity - (使)
* @template TORM - ORM ( tenantId)
*/
export abstract class BaseTenantRepository<_TEntity, TORM extends TenantAwareEntity> {
constructor(
protected readonly repo: Repository<TORM>,
protected readonly tenantContext: TenantContextService,
) {}
/**
* ID
*
*/
protected getTenantId(): string {
return this.tenantContext.requireTenantId();
}
/**
* ID ( undefined)
*
*/
protected tryGetTenantId(): string | undefined {
return this.tenantContext.getCurrentTenantId();
}
/**
* QueryBuilder
*/
protected createTenantQueryBuilder(alias: string): SelectQueryBuilder<TORM> {
return this.repo
.createQueryBuilder(alias)
.where(`${alias}.tenant_id = :tenantId`, { tenantId: this.getTenantId() });
}
/**
* ()
*/
protected async findOneWithTenant(
where: Omit<FindOptionsWhere<TORM>, 'tenantId'>,
): Promise<TORM | null> {
return this.repo.findOne({
where: {
...where,
tenantId: this.getTenantId(),
} as FindOptionsWhere<TORM>,
});
}
/**
* ()
*/
protected async findWithTenant(
where: Omit<FindOptionsWhere<TORM>, 'tenantId'>,
): Promise<TORM[]> {
return this.repo.find({
where: {
...where,
tenantId: this.getTenantId(),
} as FindOptionsWhere<TORM>,
});
}
/**
* ( ID)
*/
protected async saveWithTenant(entity: Omit<DeepPartial<TORM>, 'tenantId'>): Promise<TORM> {
const entityWithTenant = {
...entity,
tenantId: this.getTenantId(),
} as DeepPartial<TORM>;
return this.repo.save(entityWithTenant);
}
/**
* ( ID)
*/
protected async saveManyWithTenant(
entities: Array<Omit<DeepPartial<TORM>, 'tenantId'>>,
): Promise<TORM[]> {
const tenantId = this.getTenantId();
const entitiesWithTenant = entities.map((entity) => ({
...entity,
tenantId,
})) as DeepPartial<TORM>[];
return this.repo.save(entitiesWithTenant);
}
/**
* ()
*/
protected async countWithTenant(
where?: Omit<FindOptionsWhere<TORM>, 'tenantId'>,
): Promise<number> {
return this.repo.count({
where: {
...(where || {}),
tenantId: this.getTenantId(),
} as FindOptionsWhere<TORM>,
});
}
/**
* ()
*/
protected async updateWithTenant(
id: string,
partial: Partial<Omit<TORM, 'tenantId' | 'id'>>,
): Promise<void> {
await this.repo
.createQueryBuilder()
.update()
.set(partial as any)
.where('id = :id', { id })
.andWhere('tenant_id = :tenantId', { tenantId: this.getTenantId() })
.execute();
}
/**
* ()
*/
protected async softDeleteWithTenant(id: string): Promise<void> {
await this.repo
.createQueryBuilder()
.softDelete()
.where('id = :id', { id })
.andWhere('tenant_id = :tenantId', { tenantId: this.getTenantId() })
.execute();
}
/**
* ()
*/
protected async deleteWithTenant(id: string): Promise<void> {
await this.repo
.createQueryBuilder()
.delete()
.where('id = :id', { id })
.andWhere('tenant_id = :tenantId', { tenantId: this.getTenantId() })
.execute();
}
/**
*
*/
protected async existsInTenant(id: string): Promise<boolean> {
const count = await this.repo
.createQueryBuilder()
.where('id = :id', { id })
.andWhere('tenant_id = :tenantId', { tenantId: this.getTenantId() })
.getCount();
return count > 0;
}
}

View File

@ -0,0 +1,32 @@
// Types
export * from './tenant.types.js';
// Services
export { TenantContextService } from './tenant-context.service.js';
// Middleware
export { TenantContextMiddleware, TENANT_FINDER, createTenantMiddleware } from './tenant-context.middleware.js';
export type { ITenantFinder, TenantMiddlewareOptions } from './tenant-context.middleware.js';
// Guards
export { TenantGuard } from './tenant.guard.js';
// Decorators
export {
TenantId,
Tenant,
RequireFeatures,
RequirePlans,
SkipTenantCheck,
REQUIRED_FEATURES_KEY,
REQUIRED_PLANS_KEY,
SKIP_TENANT_CHECK_KEY,
} from './tenant.decorator.js';
// Base Repository
export { BaseTenantRepository } from './base-tenant.repository.js';
export type { TenantAwareEntity } from './base-tenant.repository.js';
// Module
export { TenantContextModule } from './tenant-context.module.js';
export type { TenantModuleOptions, TenantModuleAsyncOptions } from './tenant-context.module.js';

View File

@ -0,0 +1,166 @@
import { Injectable, NestMiddleware, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { TenantContextService } from './tenant-context.service.js';
import { TenantContext, TenantRequest, TenantStatus } from './tenant.types.js';
/**
*
*
*/
export interface ITenantFinder {
findById(id: string): Promise<TenantContext | null>;
findBySlug?(slug: string): Promise<TenantContext | null>;
}
/**
*
*/
export const TENANT_FINDER = Symbol('TENANT_FINDER');
/**
*
*/
export interface TenantMiddlewareOptions {
/**
* 使 ()
*/
allowDefaultTenant?: boolean;
/**
* ID
*/
defaultTenantId?: string;
/**
* ()
*/
excludePaths?: string[];
}
/**
*
*
*/
@Injectable()
export class TenantContextMiddleware implements NestMiddleware {
constructor(
private readonly tenantContext: TenantContextService,
private readonly tenantFinder: ITenantFinder,
private readonly options: TenantMiddlewareOptions = {},
) {}
async use(req: Request, _res: Response, next: NextFunction): Promise<void> {
// 检查是否是排除的路径
if (this.isExcludedPath(req.path)) {
return next();
}
// 提取租户标识
const tenantId = await this.extractTenantId(req);
if (!tenantId) {
throw new UnauthorizedException('Tenant identification required. Provide x-tenant-id header.');
}
// 加载租户数据
const tenant = await this.tenantFinder.findById(tenantId);
if (!tenant) {
throw new UnauthorizedException('Invalid tenant');
}
// 验证租户状态
if (tenant.status !== TenantStatus.ACTIVE) {
throw new ForbiddenException(`Tenant is ${tenant.status.toLowerCase()}`);
}
// 设置请求属性
const tenantRequest = req as unknown as TenantRequest;
tenantRequest.tenant = tenant;
tenantRequest.tenantId = tenant.id;
// 提取用户 ID
const userId = req.headers['x-user-id'] as string;
// 在租户上下文中执行后续中间件和处理程序
this.tenantContext.run(tenant, () => {
if (userId) {
this.tenantContext.setCurrentUserId(userId);
}
next();
}, userId);
}
/**
*
*/
private async extractTenantId(req: Request): Promise<string | null> {
// 优先级 1: x-tenant-id 请求头
const headerTenantId = req.headers['x-tenant-id'] as string;
if (headerTenantId) {
return headerTenantId;
}
// 优先级 2: 子域名
const host = req.headers.host || '';
const subdomain = this.extractSubdomain(host);
if (subdomain && subdomain !== 'www' && subdomain !== 'api' && this.tenantFinder.findBySlug) {
const tenant = await this.tenantFinder.findBySlug(subdomain);
if (tenant) {
return tenant.id;
}
}
// 优先级 3: 查询参数 (用于测试)
const queryTenantId = req.query['tenant_id'] as string;
if (queryTenantId) {
return queryTenantId;
}
// 优先级 4: 默认租户 (向后兼容)
if (this.options.allowDefaultTenant && this.options.defaultTenantId) {
return this.options.defaultTenantId;
}
return null;
}
/**
*
*/
private extractSubdomain(host: string): string | null {
// 移除端口号
const hostname = host.split(':')[0];
const parts = hostname.split('.');
// 至少需要三个部分才有子域名 (subdomain.domain.tld)
if (parts.length >= 3) {
return parts[0];
}
return null;
}
/**
*
*/
private isExcludedPath(path: string): boolean {
const excludePaths = this.options.excludePaths || [];
return excludePaths.some((excluded) => {
if (excluded.endsWith('*')) {
return path.startsWith(excluded.slice(0, -1));
}
return path === excluded;
});
}
}
/**
*
* DI
*/
export function createTenantMiddleware(
tenantContext: TenantContextService,
tenantFinder: ITenantFinder,
options?: TenantMiddlewareOptions,
): TenantContextMiddleware {
return new TenantContextMiddleware(tenantContext, tenantFinder, options);
}

View File

@ -0,0 +1,159 @@
import { DynamicModule, Global, Module, Provider, Type } from '@nestjs/common';
import { TenantContextService } from './tenant-context.service.js';
import {
TenantContextMiddleware,
ITenantFinder,
TENANT_FINDER,
TenantMiddlewareOptions,
} from './tenant-context.middleware.js';
import { TenantGuard } from './tenant.guard.js';
/**
*
*/
export interface TenantModuleOptions {
/**
*
*/
tenantFinder: Type<ITenantFinder>;
/**
*
*/
middlewareOptions?: TenantMiddlewareOptions;
/**
*
*/
isGlobal?: boolean;
}
/**
*
*/
export interface TenantModuleAsyncOptions {
/**
*
*/
imports?: any[];
/**
*
*/
inject?: any[];
/**
*
*/
useFactory: (...args: any[]) => Promise<TenantModuleOptions> | TenantModuleOptions;
/**
*
*/
isGlobal?: boolean;
}
/**
*
*
* @example
* // 同步注册
* TenantContextModule.forRoot({
* tenantFinder: TenantFinderService,
* middlewareOptions: { allowDefaultTenant: true, defaultTenantId: 'xxx' },
* isGlobal: true,
* })
*
* @example
* // 异步注册
* TenantContextModule.forRootAsync({
* imports: [ConfigModule],
* inject: [ConfigService],
* useFactory: (config: ConfigService) => ({
* tenantFinder: TenantFinderService,
* middlewareOptions: {
* allowDefaultTenant: config.get('ALLOW_DEFAULT_TENANT') === 'true',
* defaultTenantId: config.get('DEFAULT_TENANT_ID'),
* },
* }),
* isGlobal: true,
* })
*/
@Global()
@Module({})
export class TenantContextModule {
/**
*
*/
static forRoot(options: TenantModuleOptions): DynamicModule {
const providers: Provider[] = [
TenantContextService,
TenantGuard,
{
provide: TENANT_FINDER,
useClass: options.tenantFinder,
},
{
provide: 'TENANT_MIDDLEWARE_OPTIONS',
useValue: options.middlewareOptions || {},
},
{
provide: TenantContextMiddleware,
useFactory: (
tenantContext: TenantContextService,
tenantFinder: ITenantFinder,
middlewareOptions: TenantMiddlewareOptions,
) => new TenantContextMiddleware(tenantContext, tenantFinder, middlewareOptions),
inject: [TenantContextService, TENANT_FINDER, 'TENANT_MIDDLEWARE_OPTIONS'],
},
];
return {
module: TenantContextModule,
providers,
exports: [TenantContextService, TenantGuard, TenantContextMiddleware, TENANT_FINDER],
global: options.isGlobal ?? true,
};
}
/**
*
*/
static forRootAsync(options: TenantModuleAsyncOptions): DynamicModule {
const providers: Provider[] = [
TenantContextService,
TenantGuard,
{
provide: 'TENANT_MODULE_OPTIONS',
useFactory: options.useFactory,
inject: options.inject || [],
},
{
provide: TENANT_FINDER,
useFactory: async (moduleOptions: TenantModuleOptions) => {
// 动态实例化租户查找器
return new moduleOptions.tenantFinder();
},
inject: ['TENANT_MODULE_OPTIONS'],
},
{
provide: 'TENANT_MIDDLEWARE_OPTIONS',
useFactory: (moduleOptions: TenantModuleOptions) =>
moduleOptions.middlewareOptions || {},
inject: ['TENANT_MODULE_OPTIONS'],
},
{
provide: TenantContextMiddleware,
useFactory: (
tenantContext: TenantContextService,
tenantFinder: ITenantFinder,
middlewareOptions: TenantMiddlewareOptions,
) => new TenantContextMiddleware(tenantContext, tenantFinder, middlewareOptions),
inject: [TenantContextService, TENANT_FINDER, 'TENANT_MIDDLEWARE_OPTIONS'],
},
];
return {
module: TenantContextModule,
imports: options.imports || [],
providers,
exports: [TenantContextService, TenantGuard, TenantContextMiddleware, TENANT_FINDER],
global: options.isGlobal ?? true,
};
}
}

View File

@ -0,0 +1,111 @@
import { AsyncLocalStorage } from 'async_hooks';
import { Injectable } from '@nestjs/common';
import { TenantContext } from './tenant.types.js';
/**
*
*/
interface TenantStore {
tenant: TenantContext;
userId?: string;
}
/**
*
* 使 AsyncLocalStorage
*/
@Injectable()
export class TenantContextService {
private static storage = new AsyncLocalStorage<TenantStore>();
/**
*
*/
run<T>(tenant: TenantContext, fn: () => T, userId?: string): T {
return TenantContextService.storage.run({ tenant, userId }, fn);
}
/**
*
*/
runAsync<T>(tenant: TenantContext, fn: () => Promise<T>, userId?: string): Promise<T> {
return TenantContextService.storage.run({ tenant, userId }, fn);
}
/**
* ID
*/
getCurrentTenantId(): string | undefined {
return TenantContextService.storage.getStore()?.tenant.id;
}
/**
* ID ()
*/
requireTenantId(): string {
const tenantId = this.getCurrentTenantId();
if (!tenantId) {
throw new Error('Tenant context not set. Ensure TenantContextMiddleware is applied.');
}
return tenantId;
}
/**
*
*/
getCurrentTenant(): TenantContext | undefined {
return TenantContextService.storage.getStore()?.tenant;
}
/**
* ()
*/
requireTenant(): TenantContext {
const tenant = this.getCurrentTenant();
if (!tenant) {
throw new Error('Tenant context not set. Ensure TenantContextMiddleware is applied.');
}
return tenant;
}
/**
* ID
*/
getCurrentUserId(): string | undefined {
return TenantContextService.storage.getStore()?.userId;
}
/**
* ID ()
*/
setCurrentUserId(userId: string): void {
const store = TenantContextService.storage.getStore();
if (store) {
store.userId = userId;
}
}
/**
*
*/
hasTenantContext(): boolean {
return !!TenantContextService.storage.getStore()?.tenant;
}
/**
*
*/
isTenantActive(): boolean {
const tenant = this.getCurrentTenant();
return tenant?.status === 'ACTIVE';
}
/**
*
*/
getTenantConfig<K extends keyof TenantContext['config']>(
key: K,
): TenantContext['config'][K] | undefined {
return this.getCurrentTenant()?.config[key];
}
}

View File

@ -0,0 +1,72 @@
import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common';
import { TenantContext, TenantRequest } from './tenant.types.js';
/**
* ID
* @example
* @Get()
* async findAll(@TenantId() tenantId: string) { ... }
*/
export const TenantId = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest<TenantRequest>();
return request.tenantId;
},
);
/**
*
* @example
* @Get()
* async findAll(@Tenant() tenant: TenantContext) { ... }
*/
export const Tenant = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): TenantContext => {
const request = ctx.switchToHttp().getRequest<TenantRequest>();
return request.tenant;
},
);
/**
*
*/
export const REQUIRED_FEATURES_KEY = 'requiredFeatures';
/**
*
* @example
* @RequireFeatures('enableFileUpload', 'enablePayments')
* @Post('upload')
* async upload() { ... }
*/
export const RequireFeatures = (...features: string[]) =>
SetMetadata(REQUIRED_FEATURES_KEY, features);
/**
*
*/
export const REQUIRED_PLANS_KEY = 'requiredPlans';
/**
*
* @example
* @RequirePlans('STANDARD', 'ENTERPRISE')
* @Get('premium-feature')
* async premiumFeature() { ... }
*/
export const RequirePlans = (...plans: string[]) =>
SetMetadata(REQUIRED_PLANS_KEY, plans);
/**
*
*/
export const SKIP_TENANT_CHECK_KEY = 'skipTenantCheck';
/**
* ()
* @example
* @SkipTenantCheck()
* @Get('health')
* async health() { ... }
*/
export const SkipTenantCheck = () => SetMetadata(SKIP_TENANT_CHECK_KEY, true);

View File

@ -0,0 +1,89 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
REQUIRED_FEATURES_KEY,
REQUIRED_PLANS_KEY,
SKIP_TENANT_CHECK_KEY,
} from './tenant.decorator.js';
import { TenantRequest, TenantPlan, FeatureConfig } from './tenant.types.js';
/**
*
*
*/
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 检查是否跳过租户检查
const skipCheck = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
context.getHandler(),
context.getClass(),
]);
if (skipCheck) {
return true;
}
const request = context.switchToHttp().getRequest<TenantRequest>();
// 验证租户上下文存在
if (!request.tenant || !request.tenantId) {
throw new UnauthorizedException('Tenant context required');
}
// 验证租户状态
if (request.tenant.status !== 'ACTIVE') {
throw new ForbiddenException(`Tenant is ${request.tenant.status.toLowerCase()}`);
}
// 验证所需功能
const requiredFeatures = this.reflector.getAllAndOverride<string[]>(REQUIRED_FEATURES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (requiredFeatures?.length) {
const features = request.tenant.config.features || {};
for (const feature of requiredFeatures) {
if (!features[feature as keyof FeatureConfig]) {
throw new ForbiddenException(`Feature '${feature}' is not enabled for this tenant`);
}
}
}
// 验证所需套餐
const requiredPlans = this.reflector.getAllAndOverride<string[]>(REQUIRED_PLANS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (requiredPlans?.length) {
const planHierarchy: Record<TenantPlan, number> = {
[TenantPlan.FREE]: 0,
[TenantPlan.STANDARD]: 1,
[TenantPlan.ENTERPRISE]: 2,
};
const tenantPlanLevel = planHierarchy[request.tenant.plan as TenantPlan] ?? 0;
const minRequiredLevel = Math.min(
...requiredPlans.map((p: string) => planHierarchy[p as TenantPlan] ?? 999),
);
if (tenantPlanLevel < minRequiredLevel) {
throw new ForbiddenException(
`This feature requires ${requiredPlans.join(' or ')} plan`,
);
}
}
return true;
}
}

View File

@ -0,0 +1,96 @@
/**
*
*/
/**
*
*/
export enum TenantStatus {
ACTIVE = 'ACTIVE',
SUSPENDED = 'SUSPENDED',
ARCHIVED = 'ARCHIVED',
}
/**
*
*/
export enum TenantPlan {
FREE = 'FREE',
STANDARD = 'STANDARD',
ENTERPRISE = 'ENTERPRISE',
}
/**
*
*/
export interface BrandingConfig {
logoUrl?: string;
primaryColor?: string;
companyName?: string;
favicon?: string;
}
/**
* AI
*/
export interface AIConfig {
systemPrompt?: string;
model?: string;
temperature?: number;
maxTokens?: number;
}
/**
*
*/
export interface FeatureConfig {
enableFileUpload?: boolean;
enableKnowledgeBase?: boolean;
enablePayments?: boolean;
enableUserMemory?: boolean;
enableAnalytics?: boolean;
}
/**
*
*/
export interface NotificationConfig {
webhookUrl?: string;
emailNotifications?: boolean;
smsNotifications?: boolean;
}
/**
*
*/
export interface TenantConfig {
branding?: BrandingConfig;
ai?: AIConfig;
features?: FeatureConfig;
notifications?: NotificationConfig;
}
/**
* -
*/
export interface TenantContext {
id: string;
slug: string;
name: string;
status: TenantStatus;
plan: TenantPlan;
config: TenantConfig;
}
/**
*
*/
export interface TenantRequest extends Request {
tenant: TenantContext;
tenantId: string;
}
/**
* ID ()
*/
export const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001';

View File

@ -213,7 +213,7 @@ importers:
version: 3.3.0(@nestjs/common@10.4.21)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.0.0
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.0.0
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/core@10.4.21)
@ -301,7 +301,7 @@ importers:
version: 3.3.0(@nestjs/common@10.4.21)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.0.0
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.0.0
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/core@10.4.21)
@ -389,7 +389,7 @@ importers:
version: 3.3.0(@nestjs/common@10.4.21)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.0.0
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.0.0
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/core@10.4.21)
@ -471,7 +471,7 @@ importers:
version: 3.3.0(@nestjs/common@10.4.21)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.0.0
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.0.0
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/core@10.4.21)
@ -541,7 +541,7 @@ importers:
version: 3.3.0(@nestjs/common@10.4.21)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.0.0
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt':
specifier: ^10.0.0
version: 10.2.0(@nestjs/common@10.4.21)
@ -614,7 +614,23 @@ importers:
version: 5.9.3
packages/shared:
dependencies:
'@nestjs/common':
specifier: ^10.0.0
version: 10.4.21(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.0.0
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
express:
specifier: ^4.18.0
version: 4.22.1
typeorm:
specifier: ^0.3.0
version: 0.3.28(ioredis@5.9.1)(pg@8.16.3)(ts-node@10.9.2)
devDependencies:
'@types/express':
specifier: ^4.17.0
version: 4.17.25
typescript:
specifier: ^5.3.0
version: 5.9.3
@ -2114,6 +2130,38 @@ packages:
transitivePeerDependencies:
- encoding
/@nestjs/core@10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2):
resolution: {integrity: sha512-MhiSGplB4TkadceA7opn/NaZmJhwYYNdB8nS8I29nLNx3vU+8aGHBiueZgcphEVDETZJSfc2VA5Mn/FC3JcsrA==}
requiresBuild: true
peerDependencies:
'@nestjs/common': ^10.0.0
'@nestjs/microservices': ^10.0.0
'@nestjs/platform-express': ^10.0.0
'@nestjs/websockets': ^10.0.0
reflect-metadata: ^0.1.12 || ^0.2.0
rxjs: ^7.1.0
peerDependenciesMeta:
'@nestjs/microservices':
optional: true
'@nestjs/platform-express':
optional: true
'@nestjs/websockets':
optional: true
dependencies:
'@nestjs/common': 10.4.21(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': 10.4.21(@nestjs/common@10.4.21)(@nestjs/core@10.4.21)
'@nuxtjs/opencollective': 0.3.2
fast-safe-stringify: 2.1.1
iterare: 1.2.1
path-to-regexp: 3.3.0
reflect-metadata: 0.2.2
rxjs: 7.8.2
tslib: 2.8.1
uid: 2.0.2
transitivePeerDependencies:
- encoding
dev: false
/@nestjs/jwt@10.2.0(@nestjs/common@10.4.21):
resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==}
peerDependencies:
@ -2164,7 +2212,7 @@ packages:
'@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0
dependencies:
'@nestjs/common': 10.4.21(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cron: 3.2.1
uuid: 11.0.3
dev: false