From 422069be681621ab2deee4115163c95687824f0e Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 25 Jan 2026 18:11:12 -0800 Subject: [PATCH] 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 --- .../postgres/entities/conversation.orm.ts | 7 + .../database/postgres/entities/message.orm.ts | 4 + .../postgres/entities/token-usage.orm.ts | 4 + .../postgres/entities/audit-log.orm.ts | 4 + .../postgres/entities/daily-statistics.orm.ts | 6 +- .../entities/monthly-financial-report.orm.ts | 6 +- .../database/postgres/entities/admin.orm.ts | 16 ++ .../database/postgres/entities/tenant.orm.ts | 131 +++++++++ .../1737817200000-AddMultiTenancy.ts | 252 ++++++++++++++++++ .../database/postgres/entities/file.orm.ts | 5 + .../entities/knowledge-article.orm.ts | 4 + .../postgres/entities/knowledge-chunk.orm.ts | 4 + .../entities/system-experience.orm.ts | 4 + .../postgres/entities/user-memory.orm.ts | 4 + .../database/postgres/entities/order.orm.ts | 5 + .../database/postgres/entities/payment.orm.ts | 4 + .../database/postgres/entities/user.orm.ts | 7 + .../entities/verification-code.orm.ts | 6 + packages/shared/package.json | 16 ++ .../src/constants/error-codes.constants.ts | 19 ++ packages/shared/src/index.ts | 3 + .../src/tenant/base-tenant.repository.ts | 167 ++++++++++++ packages/shared/src/tenant/index.ts | 32 +++ .../src/tenant/tenant-context.middleware.ts | 166 ++++++++++++ .../src/tenant/tenant-context.module.ts | 159 +++++++++++ .../src/tenant/tenant-context.service.ts | 111 ++++++++ .../shared/src/tenant/tenant.decorator.ts | 72 +++++ packages/shared/src/tenant/tenant.guard.ts | 89 +++++++ packages/shared/src/tenant/tenant.types.ts | 96 +++++++ pnpm-lock.yaml | 60 ++++- 30 files changed, 1455 insertions(+), 8 deletions(-) create mode 100644 packages/services/evolution-service/src/infrastructure/database/postgres/entities/tenant.orm.ts create mode 100644 packages/services/evolution-service/src/migrations/1737817200000-AddMultiTenancy.ts create mode 100644 packages/shared/src/tenant/base-tenant.repository.ts create mode 100644 packages/shared/src/tenant/index.ts create mode 100644 packages/shared/src/tenant/tenant-context.middleware.ts create mode 100644 packages/shared/src/tenant/tenant-context.module.ts create mode 100644 packages/shared/src/tenant/tenant-context.service.ts create mode 100644 packages/shared/src/tenant/tenant.decorator.ts create mode 100644 packages/shared/src/tenant/tenant.guard.ts create mode 100644 packages/shared/src/tenant/tenant.types.ts diff --git a/packages/services/conversation-service/src/infrastructure/database/postgres/entities/conversation.orm.ts b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/conversation.orm.ts index ecbc3bd..bdc404f 100644 --- a/packages/services/conversation-service/src/infrastructure/database/postgres/entities/conversation.orm.ts +++ b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/conversation.orm.ts @@ -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; diff --git a/packages/services/conversation-service/src/infrastructure/database/postgres/entities/message.orm.ts b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/message.orm.ts index 4315dcb..c2d5edf 100644 --- a/packages/services/conversation-service/src/infrastructure/database/postgres/entities/message.orm.ts +++ b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/message.orm.ts @@ -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; diff --git a/packages/services/conversation-service/src/infrastructure/database/postgres/entities/token-usage.orm.ts b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/token-usage.orm.ts index b656c21..93bc31c 100644 --- a/packages/services/conversation-service/src/infrastructure/database/postgres/entities/token-usage.orm.ts +++ b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/token-usage.orm.ts @@ -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; diff --git a/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/audit-log.orm.ts b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/audit-log.orm.ts index f0e486f..361cf32 100644 --- a/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/audit-log.orm.ts +++ b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/audit-log.orm.ts @@ -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; diff --git a/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/daily-statistics.orm.ts b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/daily-statistics.orm.ts index bea2147..2a2133c 100644 --- a/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/daily-statistics.orm.ts +++ b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/daily-statistics.orm.ts @@ -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; diff --git a/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/monthly-financial-report.orm.ts b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/monthly-financial-report.orm.ts index 13b2e57..6b1410e 100644 --- a/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/monthly-financial-report.orm.ts +++ b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/monthly-financial-report.orm.ts @@ -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 diff --git a/packages/services/evolution-service/src/infrastructure/database/postgres/entities/admin.orm.ts b/packages/services/evolution-service/src/infrastructure/database/postgres/entities/admin.orm.ts index 7d098f1..8538451 100644 --- a/packages/services/evolution-service/src/infrastructure/database/postgres/entities/admin.orm.ts +++ b/packages/services/evolution-service/src/infrastructure/database/postgres/entities/admin.orm.ts @@ -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; diff --git a/packages/services/evolution-service/src/infrastructure/database/postgres/entities/tenant.orm.ts b/packages/services/evolution-service/src/infrastructure/database/postgres/entities/tenant.orm.ts new file mode 100644 index 0000000..01f5d02 --- /dev/null +++ b/packages/services/evolution-service/src/infrastructure/database/postgres/entities/tenant.orm.ts @@ -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; +} diff --git a/packages/services/evolution-service/src/migrations/1737817200000-AddMultiTenancy.ts b/packages/services/evolution-service/src/migrations/1737817200000-AddMultiTenancy.ts new file mode 100644 index 0000000..626c388 --- /dev/null +++ b/packages/services/evolution-service/src/migrations/1737817200000-AddMultiTenancy.ts @@ -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 { + // ========== 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 { + // 回滚: 删除所有 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;`); + } +} diff --git a/packages/services/file-service/src/infrastructure/database/postgres/entities/file.orm.ts b/packages/services/file-service/src/infrastructure/database/postgres/entities/file.orm.ts index c130f8a..9b22cc0 100644 --- a/packages/services/file-service/src/infrastructure/database/postgres/entities/file.orm.ts +++ b/packages/services/file-service/src/infrastructure/database/postgres/entities/file.orm.ts @@ -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; diff --git a/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/knowledge-article.orm.ts b/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/knowledge-article.orm.ts index bfa050c..fb6312f 100644 --- a/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/knowledge-article.orm.ts +++ b/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/knowledge-article.orm.ts @@ -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; diff --git a/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/knowledge-chunk.orm.ts b/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/knowledge-chunk.orm.ts index a64787b..f9fbd33 100644 --- a/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/knowledge-chunk.orm.ts +++ b/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/knowledge-chunk.orm.ts @@ -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; diff --git a/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/system-experience.orm.ts b/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/system-experience.orm.ts index eb1afa4..48e80e4 100644 --- a/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/system-experience.orm.ts +++ b/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/system-experience.orm.ts @@ -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; diff --git a/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/user-memory.orm.ts b/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/user-memory.orm.ts index 13e1538..89eec94 100644 --- a/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/user-memory.orm.ts +++ b/packages/services/knowledge-service/src/infrastructure/database/postgres/entities/user-memory.orm.ts @@ -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; diff --git a/packages/services/payment-service/src/infrastructure/database/postgres/entities/order.orm.ts b/packages/services/payment-service/src/infrastructure/database/postgres/entities/order.orm.ts index 0737e65..f337d0c 100644 --- a/packages/services/payment-service/src/infrastructure/database/postgres/entities/order.orm.ts +++ b/packages/services/payment-service/src/infrastructure/database/postgres/entities/order.orm.ts @@ -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; diff --git a/packages/services/payment-service/src/infrastructure/database/postgres/entities/payment.orm.ts b/packages/services/payment-service/src/infrastructure/database/postgres/entities/payment.orm.ts index 857bfba..3c1e3e3 100644 --- a/packages/services/payment-service/src/infrastructure/database/postgres/entities/payment.orm.ts +++ b/packages/services/payment-service/src/infrastructure/database/postgres/entities/payment.orm.ts @@ -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; diff --git a/packages/services/user-service/src/infrastructure/database/postgres/entities/user.orm.ts b/packages/services/user-service/src/infrastructure/database/postgres/entities/user.orm.ts index 5a88090..e73c7b3 100644 --- a/packages/services/user-service/src/infrastructure/database/postgres/entities/user.orm.ts +++ b/packages/services/user-service/src/infrastructure/database/postgres/entities/user.orm.ts @@ -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, diff --git a/packages/services/user-service/src/infrastructure/database/postgres/entities/verification-code.orm.ts b/packages/services/user-service/src/infrastructure/database/postgres/entities/verification-code.orm.ts index b8b1b47..efdcc00 100644 --- a/packages/services/user-service/src/infrastructure/database/postgres/entities/verification-code.orm.ts +++ b/packages/services/user-service/src/infrastructure/database/postgres/entities/verification-code.orm.ts @@ -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; diff --git a/packages/shared/package.json b/packages/shared/package.json index 841eb05..b5407c7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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" } } diff --git a/packages/shared/src/constants/error-codes.constants.ts b/packages/shared/src/constants/error-codes.constants.ts index 667b685..2316417 100644 --- a/packages/shared/src/constants/error-codes.constants.ts +++ b/packages/shared/src/constants/error-codes.constants.ts @@ -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 = { [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]: '数据库错误', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6e7608b..b7ee602 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,3 +6,6 @@ export * from './constants/index.js'; // Utils export * from './utils/index.js'; + +// Tenant (Multi-tenancy) +export * from './tenant/index.js'; diff --git a/packages/shared/src/tenant/base-tenant.repository.ts b/packages/shared/src/tenant/base-tenant.repository.ts new file mode 100644 index 0000000..160fdb2 --- /dev/null +++ b/packages/shared/src/tenant/base-tenant.repository.ts @@ -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, + 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 { + return this.repo + .createQueryBuilder(alias) + .where(`${alias}.tenant_id = :tenantId`, { tenantId: this.getTenantId() }); + } + + /** + * 查找单个实体 (自动添加租户过滤) + */ + protected async findOneWithTenant( + where: Omit, 'tenantId'>, + ): Promise { + return this.repo.findOne({ + where: { + ...where, + tenantId: this.getTenantId(), + } as FindOptionsWhere, + }); + } + + /** + * 查找多个实体 (自动添加租户过滤) + */ + protected async findWithTenant( + where: Omit, 'tenantId'>, + ): Promise { + return this.repo.find({ + where: { + ...where, + tenantId: this.getTenantId(), + } as FindOptionsWhere, + }); + } + + /** + * 保存实体 (自动设置租户 ID) + */ + protected async saveWithTenant(entity: Omit, 'tenantId'>): Promise { + const entityWithTenant = { + ...entity, + tenantId: this.getTenantId(), + } as DeepPartial; + return this.repo.save(entityWithTenant); + } + + /** + * 批量保存实体 (自动设置租户 ID) + */ + protected async saveManyWithTenant( + entities: Array, 'tenantId'>>, + ): Promise { + const tenantId = this.getTenantId(); + const entitiesWithTenant = entities.map((entity) => ({ + ...entity, + tenantId, + })) as DeepPartial[]; + return this.repo.save(entitiesWithTenant); + } + + /** + * 统计符合条件的实体数量 (自动添加租户过滤) + */ + protected async countWithTenant( + where?: Omit, 'tenantId'>, + ): Promise { + return this.repo.count({ + where: { + ...(where || {}), + tenantId: this.getTenantId(), + } as FindOptionsWhere, + }); + } + + /** + * 更新实体 (确保只更新当前租户的数据) + */ + protected async updateWithTenant( + id: string, + partial: Partial>, + ): Promise { + 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 { + await this.repo + .createQueryBuilder() + .softDelete() + .where('id = :id', { id }) + .andWhere('tenant_id = :tenantId', { tenantId: this.getTenantId() }) + .execute(); + } + + /** + * 硬删除实体 (确保只删除当前租户的数据) + */ + protected async deleteWithTenant(id: string): Promise { + await this.repo + .createQueryBuilder() + .delete() + .where('id = :id', { id }) + .andWhere('tenant_id = :tenantId', { tenantId: this.getTenantId() }) + .execute(); + } + + /** + * 检查实体是否存在于当前租户 + */ + protected async existsInTenant(id: string): Promise { + const count = await this.repo + .createQueryBuilder() + .where('id = :id', { id }) + .andWhere('tenant_id = :tenantId', { tenantId: this.getTenantId() }) + .getCount(); + return count > 0; + } +} diff --git a/packages/shared/src/tenant/index.ts b/packages/shared/src/tenant/index.ts new file mode 100644 index 0000000..2c3f705 --- /dev/null +++ b/packages/shared/src/tenant/index.ts @@ -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'; diff --git a/packages/shared/src/tenant/tenant-context.middleware.ts b/packages/shared/src/tenant/tenant-context.middleware.ts new file mode 100644 index 0000000..10c0152 --- /dev/null +++ b/packages/shared/src/tenant/tenant-context.middleware.ts @@ -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; + findBySlug?(slug: string): Promise; +} + +/** + * 租户查找器注入令牌 + */ +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 { + // 检查是否是排除的路径 + 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 { + // 优先级 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); +} diff --git a/packages/shared/src/tenant/tenant-context.module.ts b/packages/shared/src/tenant/tenant-context.module.ts new file mode 100644 index 0000000..e672fa6 --- /dev/null +++ b/packages/shared/src/tenant/tenant-context.module.ts @@ -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; + /** + * 中间件选项 + */ + middlewareOptions?: TenantMiddlewareOptions; + /** + * 是否全局注册 + */ + isGlobal?: boolean; +} + +/** + * 租户模块异步配置选项 + */ +export interface TenantModuleAsyncOptions { + /** + * 导入的模块 + */ + imports?: any[]; + /** + * 注入的依赖 + */ + inject?: any[]; + /** + * 工厂函数 + */ + useFactory: (...args: any[]) => Promise | 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, + }; + } +} diff --git a/packages/shared/src/tenant/tenant-context.service.ts b/packages/shared/src/tenant/tenant-context.service.ts new file mode 100644 index 0000000..1a43cb6 --- /dev/null +++ b/packages/shared/src/tenant/tenant-context.service.ts @@ -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(); + + /** + * 在指定租户上下文中运行函数 + */ + run(tenant: TenantContext, fn: () => T, userId?: string): T { + return TenantContextService.storage.run({ tenant, userId }, fn); + } + + /** + * 在指定租户上下文中运行异步函数 + */ + runAsync(tenant: TenantContext, fn: () => Promise, userId?: string): Promise { + 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( + key: K, + ): TenantContext['config'][K] | undefined { + return this.getCurrentTenant()?.config[key]; + } +} diff --git a/packages/shared/src/tenant/tenant.decorator.ts b/packages/shared/src/tenant/tenant.decorator.ts new file mode 100644 index 0000000..6f252e8 --- /dev/null +++ b/packages/shared/src/tenant/tenant.decorator.ts @@ -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(); + return request.tenantId; + }, +); + +/** + * 获取完整的租户上下文 + * @example + * @Get() + * async findAll(@Tenant() tenant: TenantContext) { ... } + */ +export const Tenant = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): TenantContext => { + const request = ctx.switchToHttp().getRequest(); + 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); diff --git a/packages/shared/src/tenant/tenant.guard.ts b/packages/shared/src/tenant/tenant.guard.ts new file mode 100644 index 0000000..882134e --- /dev/null +++ b/packages/shared/src/tenant/tenant.guard.ts @@ -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(SKIP_TENANT_CHECK_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (skipCheck) { + return true; + } + + const request = context.switchToHttp().getRequest(); + + // 验证租户上下文存在 + 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(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(REQUIRED_PLANS_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (requiredPlans?.length) { + const planHierarchy: Record = { + [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; + } +} diff --git a/packages/shared/src/tenant/tenant.types.ts b/packages/shared/src/tenant/tenant.types.ts new file mode 100644 index 0000000..b050876 --- /dev/null +++ b/packages/shared/src/tenant/tenant.types.ts @@ -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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d49b5cf..a18152e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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