From 1df58548259d33669a23895cc698843f1d05604c Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 25 Jan 2026 18:30:31 -0800 Subject: [PATCH] feat(multi-tenant): apply tenant middleware and refactor repositories - Apply TenantContextMiddleware to all 6 services - Add SimpleTenantFinder for services without direct tenant DB access - Add TenantFinderService for evolution-service with database access - Refactor 8 repositories to extend BaseTenantRepository: - user-postgres.repository.ts - verification-code-postgres.repository.ts - conversation-postgres.repository.ts - message-postgres.repository.ts - token-usage-postgres.repository.ts - file-postgres.repository.ts - order-postgres.repository.ts - payment-postgres.repository.ts - Add @iconsulting/shared dependency to evolution-service and knowledge-service - Configure middleware to exclude health and super-admin paths Co-Authored-By: Claude Opus 4.5 --- .../conversation-postgres.repository.ts | 28 ++++-- .../message-postgres.repository.ts | 30 ++++-- .../token-usage-postgres.repository.ts | 39 ++++---- .../conversation-service/src/app.module.ts | 36 ++++++- .../services/evolution-service/package.json | 1 + .../evolution-service/src/app.module.ts | 43 +++++++- .../tenant/tenant-finder.service.ts | 98 +++++++++++++++++++ .../infrastructure/tenant/tenant.module.ts | 18 ++++ .../persistence/file-postgres.repository.ts | 35 ++++--- .../services/file-service/src/app.module.ts | 36 ++++++- .../services/knowledge-service/package.json | 1 + .../knowledge-service/src/app.module.ts | 36 ++++++- .../persistence/order-postgres.repository.ts | 27 +++-- .../payment-postgres.repository.ts | 27 ++--- .../payment-service/src/app.module.ts | 36 ++++++- .../persistence/user-postgres.repository.ts | 63 ++++++------ .../verification-code-postgres.repository.ts | 44 +++++---- .../services/user-service/src/app.module.ts | 36 ++++++- packages/shared/src/tenant/index.ts | 3 + .../shared/src/tenant/simple-tenant-finder.ts | 64 ++++++++++++ pnpm-lock.yaml | 6 ++ 21 files changed, 575 insertions(+), 132 deletions(-) create mode 100644 packages/services/evolution-service/src/infrastructure/tenant/tenant-finder.service.ts create mode 100644 packages/services/evolution-service/src/infrastructure/tenant/tenant.module.ts create mode 100644 packages/shared/src/tenant/simple-tenant-finder.ts diff --git a/packages/services/conversation-service/src/adapters/outbound/persistence/conversation-postgres.repository.ts b/packages/services/conversation-service/src/adapters/outbound/persistence/conversation-postgres.repository.ts index 748bdac..da65055 100644 --- a/packages/services/conversation-service/src/adapters/outbound/persistence/conversation-postgres.repository.ts +++ b/packages/services/conversation-service/src/adapters/outbound/persistence/conversation-postgres.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared'; import { ConversationORM } from '../../../infrastructure/database/postgres/entities/conversation.orm'; import { IConversationRepository } from '../../../domain/repositories/conversation.repository.interface'; import { @@ -9,20 +10,26 @@ import { } from '../../../domain/entities/conversation.entity'; @Injectable() -export class ConversationPostgresRepository implements IConversationRepository { +export class ConversationPostgresRepository + extends BaseTenantRepository + implements IConversationRepository +{ constructor( - @InjectRepository(ConversationORM) - private readonly repo: Repository, - ) {} + @InjectRepository(ConversationORM) repo: Repository, + tenantContext: TenantContextService, + ) { + super(repo, tenantContext); + } async save(conversation: ConversationEntity): Promise { const orm = this.toORM(conversation); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } async findById(id: string): Promise { - const orm = await this.repo.findOne({ where: { id } }); + const orm = await this.findOneWithTenant({ id } as any); return orm ? this.toEntity(orm) : null; } @@ -30,9 +37,8 @@ export class ConversationPostgresRepository implements IConversationRepository { userId: string, options?: { status?: ConversationStatusType; limit?: number }, ): Promise { - const queryBuilder = this.repo - .createQueryBuilder('conversation') - .where('conversation.user_id = :userId', { userId }); + const queryBuilder = this.createTenantQueryBuilder('conversation') + .andWhere('conversation.user_id = :userId', { userId }); if (options?.status) { queryBuilder.andWhere('conversation.status = :status', { status: options.status }); @@ -53,7 +59,7 @@ export class ConversationPostgresRepository implements IConversationRepository { hoursBack?: number; minMessageCount?: number; }): Promise { - const queryBuilder = this.repo.createQueryBuilder('conversation'); + const queryBuilder = this.createTenantQueryBuilder('conversation'); if (options.status) { queryBuilder.andWhere('conversation.status = :status', { status: options.status }); @@ -77,12 +83,13 @@ export class ConversationPostgresRepository implements IConversationRepository { async update(conversation: ConversationEntity): Promise { const orm = this.toORM(conversation); + orm.tenantId = this.getTenantId(); const updated = await this.repo.save(orm); return this.toEntity(updated); } async count(options?: { status?: ConversationStatusType; daysBack?: number }): Promise { - const queryBuilder = this.repo.createQueryBuilder('conversation'); + const queryBuilder = this.createTenantQueryBuilder('conversation'); if (options?.status) { queryBuilder.andWhere('conversation.status = :status', { status: options.status }); @@ -100,6 +107,7 @@ export class ConversationPostgresRepository implements IConversationRepository { private toORM(entity: ConversationEntity): ConversationORM { const orm = new ConversationORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.userId = entity.userId; orm.status = entity.status; orm.title = entity.title; diff --git a/packages/services/conversation-service/src/adapters/outbound/persistence/message-postgres.repository.ts b/packages/services/conversation-service/src/adapters/outbound/persistence/message-postgres.repository.ts index 1c03ae5..447483c 100644 --- a/packages/services/conversation-service/src/adapters/outbound/persistence/message-postgres.repository.ts +++ b/packages/services/conversation-service/src/adapters/outbound/persistence/message-postgres.repository.ts @@ -1,43 +1,53 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared'; import { MessageORM } from '../../../infrastructure/database/postgres/entities/message.orm'; import { IMessageRepository } from '../../../domain/repositories/message.repository.interface'; import { MessageEntity } from '../../../domain/entities/message.entity'; @Injectable() -export class MessagePostgresRepository implements IMessageRepository { +export class MessagePostgresRepository + extends BaseTenantRepository + implements IMessageRepository +{ constructor( - @InjectRepository(MessageORM) - private readonly repo: Repository, - ) {} + @InjectRepository(MessageORM) repo: Repository, + tenantContext: TenantContextService, + ) { + super(repo, tenantContext); + } async save(message: MessageEntity): Promise { const orm = this.toORM(message); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } async findById(id: string): Promise { - const orm = await this.repo.findOne({ where: { id } }); + const orm = await this.findOneWithTenant({ id } as any); return orm ? this.toEntity(orm) : null; } async findByConversationId(conversationId: string): Promise { - const orms = await this.repo.find({ - where: { conversationId }, - order: { createdAt: 'ASC' }, - }); + const orms = await this.createTenantQueryBuilder('message') + .andWhere('message.conversation_id = :conversationId', { conversationId }) + .orderBy('message.created_at', 'ASC') + .getMany(); return orms.map((orm) => this.toEntity(orm)); } async countByConversationId(conversationId: string): Promise { - return this.repo.count({ where: { conversationId } }); + return this.createTenantQueryBuilder('message') + .andWhere('message.conversation_id = :conversationId', { conversationId }) + .getCount(); } private toORM(entity: MessageEntity): MessageORM { const orm = new MessageORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.conversationId = entity.conversationId; orm.role = entity.role; orm.type = entity.type; diff --git a/packages/services/conversation-service/src/adapters/outbound/persistence/token-usage-postgres.repository.ts b/packages/services/conversation-service/src/adapters/outbound/persistence/token-usage-postgres.repository.ts index a6604a0..9af74c9 100644 --- a/packages/services/conversation-service/src/adapters/outbound/persistence/token-usage-postgres.repository.ts +++ b/packages/services/conversation-service/src/adapters/outbound/persistence/token-usage-postgres.repository.ts @@ -1,35 +1,41 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared'; import { TokenUsageORM } from '../../../infrastructure/database/postgres/entities/token-usage.orm'; import { ITokenUsageRepository } from '../../../domain/repositories/token-usage.repository.interface'; import { TokenUsageEntity } from '../../../domain/entities/token-usage.entity'; @Injectable() -export class TokenUsagePostgresRepository implements ITokenUsageRepository { +export class TokenUsagePostgresRepository + extends BaseTenantRepository + implements ITokenUsageRepository +{ constructor( - @InjectRepository(TokenUsageORM) - private readonly repo: Repository, - ) {} + @InjectRepository(TokenUsageORM) repo: Repository, + tenantContext: TenantContextService, + ) { + super(repo, tenantContext); + } async save(tokenUsage: TokenUsageEntity): Promise { const orm = this.toORM(tokenUsage); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } async findByConversationId(conversationId: string): Promise { - const orms = await this.repo.find({ - where: { conversationId }, - order: { createdAt: 'ASC' }, - }); + const orms = await this.createTenantQueryBuilder('token_usage') + .andWhere('token_usage.conversation_id = :conversationId', { conversationId }) + .orderBy('token_usage.created_at', 'ASC') + .getMany(); return orms.map((orm) => this.toEntity(orm)); } async findByUserId(userId: string, options?: { limit?: number }): Promise { - const queryBuilder = this.repo - .createQueryBuilder('token_usage') - .where('token_usage.user_id = :userId', { userId }) + const queryBuilder = this.createTenantQueryBuilder('token_usage') + .andWhere('token_usage.user_id = :userId', { userId }) .orderBy('token_usage.created_at', 'DESC'); if (options?.limit) { @@ -45,12 +51,11 @@ export class TokenUsagePostgresRepository implements ITokenUsageRepository { totalOutputTokens: number; totalCost: number; }> { - const result = await this.repo - .createQueryBuilder('token_usage') + const result = await this.createTenantQueryBuilder('token_usage') .select('SUM(token_usage.input_tokens)', 'totalInputTokens') .addSelect('SUM(token_usage.output_tokens)', 'totalOutputTokens') .addSelect('SUM(token_usage.estimated_cost)', 'totalCost') - .where('token_usage.conversation_id = :conversationId', { conversationId }) + .andWhere('token_usage.conversation_id = :conversationId', { conversationId }) .getRawOne(); return { @@ -65,12 +70,11 @@ export class TokenUsagePostgresRepository implements ITokenUsageRepository { totalOutputTokens: number; totalCost: number; }> { - const result = await this.repo - .createQueryBuilder('token_usage') + const result = await this.createTenantQueryBuilder('token_usage') .select('SUM(token_usage.input_tokens)', 'totalInputTokens') .addSelect('SUM(token_usage.output_tokens)', 'totalOutputTokens') .addSelect('SUM(token_usage.estimated_cost)', 'totalCost') - .where('token_usage.user_id = :userId', { userId }) + .andWhere('token_usage.user_id = :userId', { userId }) .getRawOne(); return { @@ -83,6 +87,7 @@ export class TokenUsagePostgresRepository implements ITokenUsageRepository { private toORM(entity: TokenUsageEntity): TokenUsageORM { const orm = new TokenUsageORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.userId = entity.userId; orm.conversationId = entity.conversationId; orm.messageId = entity.messageId; diff --git a/packages/services/conversation-service/src/app.module.ts b/packages/services/conversation-service/src/app.module.ts index 529f2ba..068c34d 100644 --- a/packages/services/conversation-service/src/app.module.ts +++ b/packages/services/conversation-service/src/app.module.ts @@ -1,6 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { + TenantContextService, + TenantContextMiddleware, + SimpleTenantFinder, + DEFAULT_TENANT_ID, +} from '@iconsulting/shared'; import { ConversationModule } from './conversation/conversation.module'; import { ClaudeModule } from './infrastructure/claude/claude.module'; import { HealthModule } from './health/health.module'; @@ -47,5 +53,31 @@ import { HealthModule } from './health/health.module'; ConversationModule, ClaudeModule, ], + providers: [TenantContextService, SimpleTenantFinder], }) -export class AppModule {} +export class AppModule implements NestModule { + constructor( + private readonly tenantContext: TenantContextService, + private readonly tenantFinder: SimpleTenantFinder, + ) {} + + configure(consumer: MiddlewareConsumer) { + const tenantMiddleware = new TenantContextMiddleware( + this.tenantContext, + this.tenantFinder, + { + allowDefaultTenant: true, + defaultTenantId: DEFAULT_TENANT_ID, + excludePaths: ['/health', '/health/*'], + }, + ); + + consumer + .apply((req: any, res: any, next: any) => tenantMiddleware.use(req, res, next)) + .exclude( + { path: 'health', method: RequestMethod.GET }, + { path: 'health/(.*)', method: RequestMethod.ALL }, + ) + .forRoutes('*'); + } +} diff --git a/packages/services/evolution-service/package.json b/packages/services/evolution-service/package.json index 51e1ff2..d27795b 100644 --- a/packages/services/evolution-service/package.json +++ b/packages/services/evolution-service/package.json @@ -15,6 +15,7 @@ "test:cov": "jest --coverage" }, "dependencies": { + "@iconsulting/shared": "workspace:*", "@anthropic-ai/sdk": "^0.52.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.0", diff --git a/packages/services/evolution-service/src/app.module.ts b/packages/services/evolution-service/src/app.module.ts index d9f19d1..41a79a9 100644 --- a/packages/services/evolution-service/src/app.module.ts +++ b/packages/services/evolution-service/src/app.module.ts @@ -1,11 +1,18 @@ -import { Module } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; +import { + TenantContextService, + TenantContextMiddleware, + DEFAULT_TENANT_ID, +} from '@iconsulting/shared'; import { EvolutionModule } from './evolution/evolution.module'; import { AdminModule } from './admin/admin.module'; import { HealthModule } from './health/health.module'; import { AnalyticsModule } from './analytics/analytics.module'; +import { TenantModule } from './infrastructure/tenant/tenant.module'; +import { TenantFinderService } from './infrastructure/tenant/tenant-finder.service'; @Module({ imports: [ @@ -36,6 +43,9 @@ import { AnalyticsModule } from './analytics/analytics.module'; }), }), + // 租户模块 + TenantModule, + // Health check HealthModule, @@ -44,5 +54,34 @@ import { AnalyticsModule } from './analytics/analytics.module'; AdminModule, AnalyticsModule, ], + providers: [TenantContextService], }) -export class AppModule {} +export class AppModule implements NestModule { + constructor( + private readonly tenantContext: TenantContextService, + private readonly tenantFinder: TenantFinderService, + ) {} + + configure(consumer: MiddlewareConsumer) { + // 创建租户中间件 + const tenantMiddleware = new TenantContextMiddleware( + this.tenantContext, + this.tenantFinder, + { + allowDefaultTenant: true, + defaultTenantId: DEFAULT_TENANT_ID, + excludePaths: ['/health', '/health/*', '/super-admin/*'], + }, + ); + + consumer + .apply((req: any, res: any, next: any) => tenantMiddleware.use(req, res, next)) + .exclude( + { path: 'health', method: RequestMethod.GET }, + { path: 'health/(.*)', method: RequestMethod.ALL }, + { path: 'super-admin', method: RequestMethod.ALL }, + { path: 'super-admin/(.*)', method: RequestMethod.ALL }, + ) + .forRoutes('*'); + } +} diff --git a/packages/services/evolution-service/src/infrastructure/tenant/tenant-finder.service.ts b/packages/services/evolution-service/src/infrastructure/tenant/tenant-finder.service.ts new file mode 100644 index 0000000..d3c7977 --- /dev/null +++ b/packages/services/evolution-service/src/infrastructure/tenant/tenant-finder.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ITenantFinder, TenantContext, TenantStatus, TenantPlan } from '@iconsulting/shared'; +import { TenantORM } from '../database/postgres/entities/tenant.orm'; + +/** + * 租户查找器实现 (evolution-service) + * 从数据库直接查询租户信息 + */ +@Injectable() +export class TenantFinderService implements ITenantFinder { + // 内存缓存 + private cache = new Map(); + private slugCache = new Map(); // slug -> id 映射 + private readonly cacheTtlMs = 5 * 60 * 1000; // 5 分钟 + + constructor( + @InjectRepository(TenantORM) + private readonly tenantRepo: Repository, + ) {} + + async findById(id: string): Promise { + // 检查缓存 + const cached = this.cache.get(id); + if (cached && cached.expiresAt > Date.now()) { + return cached.tenant; + } + + // 从数据库查询 + const orm = await this.tenantRepo.findOne({ where: { id } }); + if (!orm) { + return null; + } + + const tenant = this.toContext(orm); + this.cache.set(id, { tenant, expiresAt: Date.now() + this.cacheTtlMs }); + this.slugCache.set(orm.slug, orm.id); + + return tenant; + } + + async findBySlug(slug: string): Promise { + // 检查 slug 缓存 + const cachedId = this.slugCache.get(slug); + if (cachedId) { + return this.findById(cachedId); + } + + // 从数据库查询 + const orm = await this.tenantRepo.findOne({ where: { slug } }); + if (!orm) { + return null; + } + + const tenant = this.toContext(orm); + this.cache.set(orm.id, { tenant, expiresAt: Date.now() + this.cacheTtlMs }); + this.slugCache.set(slug, orm.id); + + return tenant; + } + + /** + * 使缓存失效 + */ + invalidate(tenantId: string): void { + const cached = this.cache.get(tenantId); + if (cached) { + // 清除 slug 缓存 + for (const [slug, id] of this.slugCache.entries()) { + if (id === tenantId) { + this.slugCache.delete(slug); + break; + } + } + } + this.cache.delete(tenantId); + } + + /** + * 清空所有缓存 + */ + clearCache(): void { + this.cache.clear(); + this.slugCache.clear(); + } + + private toContext(orm: TenantORM): TenantContext { + return { + id: orm.id, + slug: orm.slug, + name: orm.name, + status: orm.status as TenantStatus, + plan: orm.plan as TenantPlan, + config: orm.config || {}, + }; + } +} diff --git a/packages/services/evolution-service/src/infrastructure/tenant/tenant.module.ts b/packages/services/evolution-service/src/infrastructure/tenant/tenant.module.ts new file mode 100644 index 0000000..c9d2884 --- /dev/null +++ b/packages/services/evolution-service/src/infrastructure/tenant/tenant.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TenantORM } from '../database/postgres/entities/tenant.orm'; +import { TenantFinderService } from './tenant-finder.service'; +import { TENANT_FINDER } from '@iconsulting/shared'; + +@Module({ + imports: [TypeOrmModule.forFeature([TenantORM])], + providers: [ + TenantFinderService, + { + provide: TENANT_FINDER, + useExisting: TenantFinderService, + }, + ], + exports: [TenantFinderService, TENANT_FINDER], +}) +export class TenantModule {} diff --git a/packages/services/file-service/src/adapters/outbound/persistence/file-postgres.repository.ts b/packages/services/file-service/src/adapters/outbound/persistence/file-postgres.repository.ts index 96e4e3c..5e30ea1 100644 --- a/packages/services/file-service/src/adapters/outbound/persistence/file-postgres.repository.ts +++ b/packages/services/file-service/src/adapters/outbound/persistence/file-postgres.repository.ts @@ -1,30 +1,37 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared'; import { IFileRepository } from '../../../domain/repositories/file.repository.interface'; import { FileEntity, FileStatus } from '../../../domain/entities/file.entity'; import { FileORM } from '../../../infrastructure/database/postgres/entities/file.orm'; @Injectable() -export class FilePostgresRepository implements IFileRepository { +export class FilePostgresRepository + extends BaseTenantRepository + implements IFileRepository +{ constructor( - @InjectRepository(FileORM) - private readonly repo: Repository, - ) {} + @InjectRepository(FileORM) repo: Repository, + tenantContext: TenantContextService, + ) { + super(repo, tenantContext); + } async save(file: FileEntity): Promise { const orm = this.toORM(file); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } async findById(id: string): Promise { - const orm = await this.repo.findOne({ where: { id } }); + const orm = await this.findOneWithTenant({ id } as any); return orm ? this.toEntity(orm) : null; } async findByIdAndUser(id: string, userId: string): Promise { - const orm = await this.repo.findOne({ where: { id, userId } }); + const orm = await this.findOneWithTenant({ id, userId } as any); return orm ? this.toEntity(orm) : null; } @@ -33,7 +40,7 @@ export class FilePostgresRepository implements IFileRepository { userId: string, status: FileStatus, ): Promise { - const orm = await this.repo.findOne({ where: { id, userId, status } }); + const orm = await this.findOneWithTenant({ id, userId, status } as any); return orm ? this.toEntity(orm) : null; } @@ -42,20 +49,21 @@ export class FilePostgresRepository implements IFileRepository { status: FileStatus, conversationId?: string, ): Promise { - const where: Record = { userId, status }; + const queryBuilder = this.createTenantQueryBuilder('file') + .andWhere('file.user_id = :userId', { userId }) + .andWhere('file.status = :status', { status }); + if (conversationId) { - where.conversationId = conversationId; + queryBuilder.andWhere('file.conversation_id = :conversationId', { conversationId }); } - const orms = await this.repo.find({ - where, - order: { createdAt: 'DESC' }, - }); + const orms = await queryBuilder.orderBy('file.created_at', 'DESC').getMany(); return orms.map((orm) => this.toEntity(orm)); } async update(file: FileEntity): Promise { const orm = this.toORM(file); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } @@ -63,6 +71,7 @@ export class FilePostgresRepository implements IFileRepository { private toORM(entity: FileEntity): FileORM { const orm = new FileORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.userId = entity.userId; orm.conversationId = entity.conversationId; orm.originalName = entity.originalName; diff --git a/packages/services/file-service/src/app.module.ts b/packages/services/file-service/src/app.module.ts index de077cc..9e4cc5e 100644 --- a/packages/services/file-service/src/app.module.ts +++ b/packages/services/file-service/src/app.module.ts @@ -1,6 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { + TenantContextService, + TenantContextMiddleware, + SimpleTenantFinder, + DEFAULT_TENANT_ID, +} from '@iconsulting/shared'; import { HealthModule } from './health/health.module'; import { FileModule } from './file/file.module'; @@ -35,5 +41,31 @@ import { FileModule } from './file/file.module'; // 功能模块 FileModule, ], + providers: [TenantContextService, SimpleTenantFinder], }) -export class AppModule {} +export class AppModule implements NestModule { + constructor( + private readonly tenantContext: TenantContextService, + private readonly tenantFinder: SimpleTenantFinder, + ) {} + + configure(consumer: MiddlewareConsumer) { + const tenantMiddleware = new TenantContextMiddleware( + this.tenantContext, + this.tenantFinder, + { + allowDefaultTenant: true, + defaultTenantId: DEFAULT_TENANT_ID, + excludePaths: ['/health', '/health/*'], + }, + ); + + consumer + .apply((req: any, res: any, next: any) => tenantMiddleware.use(req, res, next)) + .exclude( + { path: 'health', method: RequestMethod.GET }, + { path: 'health/(.*)', method: RequestMethod.ALL }, + ) + .forRoutes('*'); + } +} diff --git a/packages/services/knowledge-service/package.json b/packages/services/knowledge-service/package.json index 50feeef..2f516d5 100644 --- a/packages/services/knowledge-service/package.json +++ b/packages/services/knowledge-service/package.json @@ -15,6 +15,7 @@ "test:cov": "jest --coverage" }, "dependencies": { + "@iconsulting/shared": "workspace:*", "@anthropic-ai/sdk": "^0.52.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.0", diff --git a/packages/services/knowledge-service/src/app.module.ts b/packages/services/knowledge-service/src/app.module.ts index a99561b..1772204 100644 --- a/packages/services/knowledge-service/src/app.module.ts +++ b/packages/services/knowledge-service/src/app.module.ts @@ -1,6 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { + TenantContextService, + TenantContextMiddleware, + SimpleTenantFinder, + DEFAULT_TENANT_ID, +} from '@iconsulting/shared'; import { KnowledgeModule } from './knowledge/knowledge.module'; import { MemoryModule } from './memory/memory.module'; import { HealthModule } from './health/health.module'; @@ -38,5 +44,31 @@ import { HealthModule } from './health/health.module'; KnowledgeModule, MemoryModule, ], + providers: [TenantContextService, SimpleTenantFinder], }) -export class AppModule {} +export class AppModule implements NestModule { + constructor( + private readonly tenantContext: TenantContextService, + private readonly tenantFinder: SimpleTenantFinder, + ) {} + + configure(consumer: MiddlewareConsumer) { + const tenantMiddleware = new TenantContextMiddleware( + this.tenantContext, + this.tenantFinder, + { + allowDefaultTenant: true, + defaultTenantId: DEFAULT_TENANT_ID, + excludePaths: ['/health', '/health/*'], + }, + ); + + consumer + .apply((req: any, res: any, next: any) => tenantMiddleware.use(req, res, next)) + .exclude( + { path: 'health', method: RequestMethod.GET }, + { path: 'health/(.*)', method: RequestMethod.ALL }, + ) + .forRoutes('*'); + } +} diff --git a/packages/services/payment-service/src/adapters/outbound/persistence/order-postgres.repository.ts b/packages/services/payment-service/src/adapters/outbound/persistence/order-postgres.repository.ts index ee64ac6..3181b3b 100644 --- a/packages/services/payment-service/src/adapters/outbound/persistence/order-postgres.repository.ts +++ b/packages/services/payment-service/src/adapters/outbound/persistence/order-postgres.repository.ts @@ -1,38 +1,46 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared'; import { IOrderRepository } from '../../../domain/repositories/order.repository.interface'; import { OrderEntity } from '../../../domain/entities/order.entity'; import { OrderORM } from '../../../infrastructure/database/postgres/entities/order.orm'; @Injectable() -export class OrderPostgresRepository implements IOrderRepository { +export class OrderPostgresRepository + extends BaseTenantRepository + implements IOrderRepository +{ constructor( - @InjectRepository(OrderORM) - private readonly repo: Repository, - ) {} + @InjectRepository(OrderORM) repo: Repository, + tenantContext: TenantContextService, + ) { + super(repo, tenantContext); + } async save(order: OrderEntity): Promise { const orm = this.toORM(order); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } async findById(id: string): Promise { - const orm = await this.repo.findOne({ where: { id } }); + const orm = await this.findOneWithTenant({ id } as any); return orm ? this.toEntity(orm) : null; } async findByUserId(userId: string): Promise { - const orms = await this.repo.find({ - where: { userId }, - order: { createdAt: 'DESC' }, - }); + const orms = await this.createTenantQueryBuilder('order') + .andWhere('order.user_id = :userId', { userId }) + .orderBy('order.created_at', 'DESC') + .getMany(); return orms.map((orm) => this.toEntity(orm)); } async update(order: OrderEntity): Promise { const orm = this.toORM(order); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } @@ -40,6 +48,7 @@ export class OrderPostgresRepository implements IOrderRepository { private toORM(entity: OrderEntity): OrderORM { const orm = new OrderORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.userId = entity.userId; orm.conversationId = entity.conversationId; orm.serviceType = entity.serviceType; diff --git a/packages/services/payment-service/src/adapters/outbound/persistence/payment-postgres.repository.ts b/packages/services/payment-service/src/adapters/outbound/persistence/payment-postgres.repository.ts index 853a70b..bd5ccc6 100644 --- a/packages/services/payment-service/src/adapters/outbound/persistence/payment-postgres.repository.ts +++ b/packages/services/payment-service/src/adapters/outbound/persistence/payment-postgres.repository.ts @@ -1,44 +1,48 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared'; import { IPaymentRepository } from '../../../domain/repositories/payment.repository.interface'; import { PaymentEntity, PaymentStatus } from '../../../domain/entities/payment.entity'; import { PaymentORM } from '../../../infrastructure/database/postgres/entities/payment.orm'; @Injectable() -export class PaymentPostgresRepository implements IPaymentRepository { +export class PaymentPostgresRepository + extends BaseTenantRepository + implements IPaymentRepository +{ constructor( - @InjectRepository(PaymentORM) - private readonly repo: Repository, - ) {} + @InjectRepository(PaymentORM) repo: Repository, + tenantContext: TenantContextService, + ) { + super(repo, tenantContext); + } async save(payment: PaymentEntity): Promise { const orm = this.toORM(payment); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } async findById(id: string): Promise { - const orm = await this.repo.findOne({ where: { id } }); + const orm = await this.findOneWithTenant({ id } as any); return orm ? this.toEntity(orm) : null; } async findPendingByOrderId(orderId: string): Promise { - const orm = await this.repo.findOne({ - where: { orderId, status: PaymentStatus.PENDING }, - }); + const orm = await this.findOneWithTenant({ orderId, status: PaymentStatus.PENDING } as any); return orm ? this.toEntity(orm) : null; } async findByTransactionId(transactionId: string): Promise { - const orm = await this.repo.findOne({ - where: { transactionId }, - }); + const orm = await this.findOneWithTenant({ transactionId } as any); return orm ? this.toEntity(orm) : null; } async update(payment: PaymentEntity): Promise { const orm = this.toORM(payment); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } @@ -46,6 +50,7 @@ export class PaymentPostgresRepository implements IPaymentRepository { private toORM(entity: PaymentEntity): PaymentORM { const orm = new PaymentORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.orderId = entity.orderId; orm.method = entity.method; orm.amount = entity.amount; diff --git a/packages/services/payment-service/src/app.module.ts b/packages/services/payment-service/src/app.module.ts index 5d8c8ff..5fefe31 100644 --- a/packages/services/payment-service/src/app.module.ts +++ b/packages/services/payment-service/src/app.module.ts @@ -1,6 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { + TenantContextService, + TenantContextMiddleware, + SimpleTenantFinder, + DEFAULT_TENANT_ID, +} from '@iconsulting/shared'; import { PaymentModule } from './payment/payment.module'; import { OrderModule } from './order/order.module'; import { HealthModule } from './health/health.module'; @@ -50,5 +56,31 @@ import { HealthModule } from './health/health.module'; OrderModule, PaymentModule, ], + providers: [TenantContextService, SimpleTenantFinder], }) -export class AppModule {} +export class AppModule implements NestModule { + constructor( + private readonly tenantContext: TenantContextService, + private readonly tenantFinder: SimpleTenantFinder, + ) {} + + configure(consumer: MiddlewareConsumer) { + const tenantMiddleware = new TenantContextMiddleware( + this.tenantContext, + this.tenantFinder, + { + allowDefaultTenant: true, + defaultTenantId: DEFAULT_TENANT_ID, + excludePaths: ['/health', '/health/*'], + }, + ); + + consumer + .apply((req: any, res: any, next: any) => tenantMiddleware.use(req, res, next)) + .exclude( + { path: 'health', method: RequestMethod.GET }, + { path: 'health/(.*)', method: RequestMethod.ALL }, + ) + .forRoutes('*'); + } +} diff --git a/packages/services/user-service/src/adapters/outbound/persistence/user-postgres.repository.ts b/packages/services/user-service/src/adapters/outbound/persistence/user-postgres.repository.ts index 836a4cc..24d616b 100644 --- a/packages/services/user-service/src/adapters/outbound/persistence/user-postgres.repository.ts +++ b/packages/services/user-service/src/adapters/outbound/persistence/user-postgres.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Like, FindOptionsWhere } from 'typeorm'; +import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared'; import { IUserRepository, UserQueryOptions, @@ -10,35 +11,41 @@ import { UserEntity, UserType } from '../../../domain/entities/user.entity'; import { UserORM } from '../../../infrastructure/database/postgres/entities/user.orm'; @Injectable() -export class UserPostgresRepository implements IUserRepository { +export class UserPostgresRepository + extends BaseTenantRepository + implements IUserRepository +{ constructor( - @InjectRepository(UserORM) - private readonly repo: Repository, - ) {} + @InjectRepository(UserORM) repo: Repository, + tenantContext: TenantContextService, + ) { + super(repo, tenantContext); + } async save(user: UserEntity): Promise { const orm = this.toORM(user); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } async findById(id: string): Promise { - const orm = await this.repo.findOne({ where: { id } }); + const orm = await this.findOneWithTenant({ id } as any); return orm ? this.toEntity(orm) : null; } async findByPhone(phone: string): Promise { - const orm = await this.repo.findOne({ where: { phone } }); + const orm = await this.findOneWithTenant({ phone } as any); return orm ? this.toEntity(orm) : null; } async findByFingerprint(fingerprint: string): Promise { - const orm = await this.repo.findOne({ where: { fingerprint } }); + const orm = await this.findOneWithTenant({ fingerprint } as any); return orm ? this.toEntity(orm) : null; } async updateLastActive(userId: string): Promise { - await this.repo.update(userId, { lastActiveAt: new Date() }); + await this.updateWithTenant(userId, { lastActiveAt: new Date() } as any); } async findAll(options: UserQueryOptions): Promise { @@ -46,25 +53,22 @@ export class UserPostgresRepository implements IUserRepository { const pageSize = options.pageSize || 20; const skip = (page - 1) * pageSize; - const where: FindOptionsWhere = {}; + const queryBuilder = this.createTenantQueryBuilder('user'); + if (options.type) { - where.type = options.type; + queryBuilder.andWhere('user.type = :type', { type: options.type }); } if (options.phone) { - where.phone = Like(`%${options.phone}%`); + queryBuilder.andWhere('user.phone LIKE :phone', { phone: `%${options.phone}%` }); } if (options.nickname) { - where.nickname = Like(`%${options.nickname}%`); + queryBuilder.andWhere('user.nickname LIKE :nickname', { nickname: `%${options.nickname}%` }); } - const [items, total] = await this.repo.findAndCount({ - where, - order: { - [options.sortBy || 'createdAt']: options.sortOrder || 'DESC', - }, - skip, - take: pageSize, - }); + queryBuilder.orderBy(`user.${options.sortBy || 'createdAt'}`, options.sortOrder || 'DESC'); + queryBuilder.skip(skip).take(pageSize); + + const [items, total] = await queryBuilder.getManyAndCount(); return { items: items.map((orm) => this.toEntity(orm)), @@ -76,8 +80,7 @@ export class UserPostgresRepository implements IUserRepository { } async countByType(): Promise> { - const result = await this.repo - .createQueryBuilder('user') + const result = await this.createTenantQueryBuilder('user') .select('user.type', 'type') .addSelect('COUNT(*)', 'count') .groupBy('user.type') @@ -96,14 +99,13 @@ export class UserPostgresRepository implements IUserRepository { } async search(keyword: string, limit = 10): Promise { - const items = await this.repo.find({ - where: [ - { phone: Like(`%${keyword}%`) }, - { nickname: Like(`%${keyword}%`) }, - ], - take: limit, - order: { lastActiveAt: 'DESC' }, - }); + const items = await this.createTenantQueryBuilder('user') + .andWhere('(user.phone LIKE :keyword OR user.nickname LIKE :keyword)', { + keyword: `%${keyword}%`, + }) + .orderBy('user.lastActiveAt', 'DESC') + .take(limit) + .getMany(); return items.map((orm) => this.toEntity(orm)); } @@ -111,6 +113,7 @@ export class UserPostgresRepository implements IUserRepository { private toORM(entity: UserEntity): UserORM { const orm = new UserORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.type = entity.type; orm.fingerprint = entity.fingerprint; orm.phone = entity.phone; diff --git a/packages/services/user-service/src/adapters/outbound/persistence/verification-code-postgres.repository.ts b/packages/services/user-service/src/adapters/outbound/persistence/verification-code-postgres.repository.ts index d205213..162aade 100644 --- a/packages/services/user-service/src/adapters/outbound/persistence/verification-code-postgres.repository.ts +++ b/packages/services/user-service/src/adapters/outbound/persistence/verification-code-postgres.repository.ts @@ -1,53 +1,57 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, MoreThan } from 'typeorm'; +import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared'; import { IVerificationCodeRepository } from '../../../domain/repositories/verification-code.repository.interface'; import { VerificationCodeEntity } from '../../../domain/entities/verification-code.entity'; import { VerificationCodeORM } from '../../../infrastructure/database/postgres/entities/verification-code.orm'; @Injectable() -export class VerificationCodePostgresRepository implements IVerificationCodeRepository { +export class VerificationCodePostgresRepository + extends BaseTenantRepository + implements IVerificationCodeRepository +{ constructor( - @InjectRepository(VerificationCodeORM) - private readonly repo: Repository, - ) {} + @InjectRepository(VerificationCodeORM) repo: Repository, + tenantContext: TenantContextService, + ) { + super(repo, tenantContext); + } async save(code: VerificationCodeEntity): Promise { const orm = this.toORM(code); + orm.tenantId = this.getTenantId(); const saved = await this.repo.save(orm); return this.toEntity(saved); } async findValidCode(phone: string, code: string): Promise { - const orm = await this.repo.findOne({ - where: { - phone, - code, - isUsed: false, - expiresAt: MoreThan(new Date()), - }, - order: { createdAt: 'DESC' }, - }); + const orm = await this.createTenantQueryBuilder('vc') + .andWhere('vc.phone = :phone', { phone }) + .andWhere('vc.code = :code', { code }) + .andWhere('vc.is_used = :isUsed', { isUsed: false }) + .andWhere('vc.expires_at > :now', { now: new Date() }) + .orderBy('vc.created_at', 'DESC') + .getOne(); return orm ? this.toEntity(orm) : null; } async countRecentByPhone(phone: string, hoursBack: number): Promise { const since = new Date(Date.now() - hoursBack * 60 * 60 * 1000); - return this.repo.count({ - where: { - phone, - createdAt: MoreThan(since), - }, - }); + return this.createTenantQueryBuilder('vc') + .andWhere('vc.phone = :phone', { phone }) + .andWhere('vc.created_at > :since', { since }) + .getCount(); } async markAsUsed(id: string): Promise { - await this.repo.update(id, { isUsed: true }); + await this.updateWithTenant(id, { isUsed: true } as any); } private toORM(entity: VerificationCodeEntity): VerificationCodeORM { const orm = new VerificationCodeORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.phone = entity.phone; orm.code = entity.code; orm.expiresAt = entity.expiresAt; diff --git a/packages/services/user-service/src/app.module.ts b/packages/services/user-service/src/app.module.ts index 6e04b7c..1fac6e4 100644 --- a/packages/services/user-service/src/app.module.ts +++ b/packages/services/user-service/src/app.module.ts @@ -1,7 +1,13 @@ -import { Module } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { JwtModule } from '@nestjs/jwt'; +import { + TenantContextService, + TenantContextMiddleware, + SimpleTenantFinder, + DEFAULT_TENANT_ID, +} from '@iconsulting/shared'; import { UserModule } from './user/user.module'; import { AuthModule } from './auth/auth.module'; import { HealthModule } from './health/health.module'; @@ -46,5 +52,31 @@ import { HealthModule } from './health/health.module'; UserModule, AuthModule, ], + providers: [TenantContextService, SimpleTenantFinder], }) -export class AppModule {} +export class AppModule implements NestModule { + constructor( + private readonly tenantContext: TenantContextService, + private readonly tenantFinder: SimpleTenantFinder, + ) {} + + configure(consumer: MiddlewareConsumer) { + const tenantMiddleware = new TenantContextMiddleware( + this.tenantContext, + this.tenantFinder, + { + allowDefaultTenant: true, + defaultTenantId: DEFAULT_TENANT_ID, + excludePaths: ['/health', '/health/*'], + }, + ); + + consumer + .apply((req: any, res: any, next: any) => tenantMiddleware.use(req, res, next)) + .exclude( + { path: 'health', method: RequestMethod.GET }, + { path: 'health/(.*)', method: RequestMethod.ALL }, + ) + .forRoutes('*'); + } +} diff --git a/packages/shared/src/tenant/index.ts b/packages/shared/src/tenant/index.ts index 2c3f705..15b1b62 100644 --- a/packages/shared/src/tenant/index.ts +++ b/packages/shared/src/tenant/index.ts @@ -8,6 +8,9 @@ export { TenantContextService } from './tenant-context.service.js'; export { TenantContextMiddleware, TENANT_FINDER, createTenantMiddleware } from './tenant-context.middleware.js'; export type { ITenantFinder, TenantMiddlewareOptions } from './tenant-context.middleware.js'; +// Tenant Finder (Simple implementation) +export { SimpleTenantFinder } from './simple-tenant-finder.js'; + // Guards export { TenantGuard } from './tenant.guard.js'; diff --git a/packages/shared/src/tenant/simple-tenant-finder.ts b/packages/shared/src/tenant/simple-tenant-finder.ts new file mode 100644 index 0000000..42d173a --- /dev/null +++ b/packages/shared/src/tenant/simple-tenant-finder.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { ITenantFinder } from './tenant-context.middleware.js'; +import { TenantContext, TenantStatus, TenantPlan, DEFAULT_TENANT_ID } from './tenant.types.js'; + +/** + * 简单租户查找器 + * 用于内部服务(由网关验证租户后调用) + * 生产环境应使用 HTTP 客户端调用 evolution-service 验证 + */ +@Injectable() +export class SimpleTenantFinder implements ITenantFinder { + // 租户缓存 (简单实现,生产环境应使用 Redis) + private cache = new Map(); + private readonly cacheTtlMs = 5 * 60 * 1000; // 5 分钟缓存 + + async findById(id: string): Promise { + // 检查缓存 + const cached = this.cache.get(id); + if (cached && cached.expiresAt > Date.now()) { + return cached.tenant; + } + + // 默认租户 (向后兼容) + if (id === DEFAULT_TENANT_ID) { + const tenant: TenantContext = { + id: DEFAULT_TENANT_ID, + slug: 'default', + name: 'Default Tenant', + status: TenantStatus.ACTIVE, + plan: TenantPlan.ENTERPRISE, + config: {}, + }; + this.cache.set(id, { tenant, expiresAt: Date.now() + this.cacheTtlMs }); + return tenant; + } + + // 对于其他租户,信任传入的 ID(由网关验证) + // 生产环境应调用 evolution-service 验证 + const tenant: TenantContext = { + id, + slug: id.substring(0, 8), + name: `Tenant ${id.substring(0, 8)}`, + status: TenantStatus.ACTIVE, + plan: TenantPlan.STANDARD, + config: {}, + }; + this.cache.set(id, { tenant, expiresAt: Date.now() + this.cacheTtlMs }); + return tenant; + } + + /** + * 清除缓存 + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * 从缓存中移除指定租户 + */ + invalidate(tenantId: string): void { + this.cache.delete(tenantId); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a18152e..00a12ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,6 +205,9 @@ importers: '@anthropic-ai/sdk': specifier: ^0.52.0 version: 0.52.0 + '@iconsulting/shared': + specifier: workspace:* + version: link:../../shared '@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) @@ -381,6 +384,9 @@ importers: '@anthropic-ai/sdk': specifier: ^0.52.0 version: 0.52.0 + '@iconsulting/shared': + specifier: workspace:* + version: link:../../shared '@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)