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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-25 18:30:31 -08:00
parent 422069be68
commit 1df5854825
21 changed files with 575 additions and 132 deletions

View File

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared';
import { ConversationORM } from '../../../infrastructure/database/postgres/entities/conversation.orm'; import { ConversationORM } from '../../../infrastructure/database/postgres/entities/conversation.orm';
import { IConversationRepository } from '../../../domain/repositories/conversation.repository.interface'; import { IConversationRepository } from '../../../domain/repositories/conversation.repository.interface';
import { import {
@ -9,20 +10,26 @@ import {
} from '../../../domain/entities/conversation.entity'; } from '../../../domain/entities/conversation.entity';
@Injectable() @Injectable()
export class ConversationPostgresRepository implements IConversationRepository { export class ConversationPostgresRepository
extends BaseTenantRepository<ConversationEntity, ConversationORM>
implements IConversationRepository
{
constructor( constructor(
@InjectRepository(ConversationORM) @InjectRepository(ConversationORM) repo: Repository<ConversationORM>,
private readonly repo: Repository<ConversationORM>, tenantContext: TenantContextService,
) {} ) {
super(repo, tenantContext);
}
async save(conversation: ConversationEntity): Promise<ConversationEntity> { async save(conversation: ConversationEntity): Promise<ConversationEntity> {
const orm = this.toORM(conversation); const orm = this.toORM(conversation);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
async findById(id: string): Promise<ConversationEntity | null> { async findById(id: string): Promise<ConversationEntity | null> {
const orm = await this.repo.findOne({ where: { id } }); const orm = await this.findOneWithTenant({ id } as any);
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
@ -30,9 +37,8 @@ export class ConversationPostgresRepository implements IConversationRepository {
userId: string, userId: string,
options?: { status?: ConversationStatusType; limit?: number }, options?: { status?: ConversationStatusType; limit?: number },
): Promise<ConversationEntity[]> { ): Promise<ConversationEntity[]> {
const queryBuilder = this.repo const queryBuilder = this.createTenantQueryBuilder('conversation')
.createQueryBuilder('conversation') .andWhere('conversation.user_id = :userId', { userId });
.where('conversation.user_id = :userId', { userId });
if (options?.status) { if (options?.status) {
queryBuilder.andWhere('conversation.status = :status', { status: options.status }); queryBuilder.andWhere('conversation.status = :status', { status: options.status });
@ -53,7 +59,7 @@ export class ConversationPostgresRepository implements IConversationRepository {
hoursBack?: number; hoursBack?: number;
minMessageCount?: number; minMessageCount?: number;
}): Promise<ConversationEntity[]> { }): Promise<ConversationEntity[]> {
const queryBuilder = this.repo.createQueryBuilder('conversation'); const queryBuilder = this.createTenantQueryBuilder('conversation');
if (options.status) { if (options.status) {
queryBuilder.andWhere('conversation.status = :status', { status: options.status }); queryBuilder.andWhere('conversation.status = :status', { status: options.status });
@ -77,12 +83,13 @@ export class ConversationPostgresRepository implements IConversationRepository {
async update(conversation: ConversationEntity): Promise<ConversationEntity> { async update(conversation: ConversationEntity): Promise<ConversationEntity> {
const orm = this.toORM(conversation); const orm = this.toORM(conversation);
orm.tenantId = this.getTenantId();
const updated = await this.repo.save(orm); const updated = await this.repo.save(orm);
return this.toEntity(updated); return this.toEntity(updated);
} }
async count(options?: { status?: ConversationStatusType; daysBack?: number }): Promise<number> { async count(options?: { status?: ConversationStatusType; daysBack?: number }): Promise<number> {
const queryBuilder = this.repo.createQueryBuilder('conversation'); const queryBuilder = this.createTenantQueryBuilder('conversation');
if (options?.status) { if (options?.status) {
queryBuilder.andWhere('conversation.status = :status', { status: options.status }); queryBuilder.andWhere('conversation.status = :status', { status: options.status });
@ -100,6 +107,7 @@ export class ConversationPostgresRepository implements IConversationRepository {
private toORM(entity: ConversationEntity): ConversationORM { private toORM(entity: ConversationEntity): ConversationORM {
const orm = new ConversationORM(); const orm = new ConversationORM();
orm.id = entity.id; orm.id = entity.id;
orm.tenantId = this.getTenantId();
orm.userId = entity.userId; orm.userId = entity.userId;
orm.status = entity.status; orm.status = entity.status;
orm.title = entity.title; orm.title = entity.title;

View File

@ -1,43 +1,53 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared';
import { MessageORM } from '../../../infrastructure/database/postgres/entities/message.orm'; import { MessageORM } from '../../../infrastructure/database/postgres/entities/message.orm';
import { IMessageRepository } from '../../../domain/repositories/message.repository.interface'; import { IMessageRepository } from '../../../domain/repositories/message.repository.interface';
import { MessageEntity } from '../../../domain/entities/message.entity'; import { MessageEntity } from '../../../domain/entities/message.entity';
@Injectable() @Injectable()
export class MessagePostgresRepository implements IMessageRepository { export class MessagePostgresRepository
extends BaseTenantRepository<MessageEntity, MessageORM>
implements IMessageRepository
{
constructor( constructor(
@InjectRepository(MessageORM) @InjectRepository(MessageORM) repo: Repository<MessageORM>,
private readonly repo: Repository<MessageORM>, tenantContext: TenantContextService,
) {} ) {
super(repo, tenantContext);
}
async save(message: MessageEntity): Promise<MessageEntity> { async save(message: MessageEntity): Promise<MessageEntity> {
const orm = this.toORM(message); const orm = this.toORM(message);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
async findById(id: string): Promise<MessageEntity | null> { async findById(id: string): Promise<MessageEntity | null> {
const orm = await this.repo.findOne({ where: { id } }); const orm = await this.findOneWithTenant({ id } as any);
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
async findByConversationId(conversationId: string): Promise<MessageEntity[]> { async findByConversationId(conversationId: string): Promise<MessageEntity[]> {
const orms = await this.repo.find({ const orms = await this.createTenantQueryBuilder('message')
where: { conversationId }, .andWhere('message.conversation_id = :conversationId', { conversationId })
order: { createdAt: 'ASC' }, .orderBy('message.created_at', 'ASC')
}); .getMany();
return orms.map((orm) => this.toEntity(orm)); return orms.map((orm) => this.toEntity(orm));
} }
async countByConversationId(conversationId: string): Promise<number> { async countByConversationId(conversationId: string): Promise<number> {
return this.repo.count({ where: { conversationId } }); return this.createTenantQueryBuilder('message')
.andWhere('message.conversation_id = :conversationId', { conversationId })
.getCount();
} }
private toORM(entity: MessageEntity): MessageORM { private toORM(entity: MessageEntity): MessageORM {
const orm = new MessageORM(); const orm = new MessageORM();
orm.id = entity.id; orm.id = entity.id;
orm.tenantId = this.getTenantId();
orm.conversationId = entity.conversationId; orm.conversationId = entity.conversationId;
orm.role = entity.role; orm.role = entity.role;
orm.type = entity.type; orm.type = entity.type;

View File

@ -1,35 +1,41 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared';
import { TokenUsageORM } from '../../../infrastructure/database/postgres/entities/token-usage.orm'; import { TokenUsageORM } from '../../../infrastructure/database/postgres/entities/token-usage.orm';
import { ITokenUsageRepository } from '../../../domain/repositories/token-usage.repository.interface'; import { ITokenUsageRepository } from '../../../domain/repositories/token-usage.repository.interface';
import { TokenUsageEntity } from '../../../domain/entities/token-usage.entity'; import { TokenUsageEntity } from '../../../domain/entities/token-usage.entity';
@Injectable() @Injectable()
export class TokenUsagePostgresRepository implements ITokenUsageRepository { export class TokenUsagePostgresRepository
extends BaseTenantRepository<TokenUsageEntity, TokenUsageORM>
implements ITokenUsageRepository
{
constructor( constructor(
@InjectRepository(TokenUsageORM) @InjectRepository(TokenUsageORM) repo: Repository<TokenUsageORM>,
private readonly repo: Repository<TokenUsageORM>, tenantContext: TenantContextService,
) {} ) {
super(repo, tenantContext);
}
async save(tokenUsage: TokenUsageEntity): Promise<TokenUsageEntity> { async save(tokenUsage: TokenUsageEntity): Promise<TokenUsageEntity> {
const orm = this.toORM(tokenUsage); const orm = this.toORM(tokenUsage);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
async findByConversationId(conversationId: string): Promise<TokenUsageEntity[]> { async findByConversationId(conversationId: string): Promise<TokenUsageEntity[]> {
const orms = await this.repo.find({ const orms = await this.createTenantQueryBuilder('token_usage')
where: { conversationId }, .andWhere('token_usage.conversation_id = :conversationId', { conversationId })
order: { createdAt: 'ASC' }, .orderBy('token_usage.created_at', 'ASC')
}); .getMany();
return orms.map((orm) => this.toEntity(orm)); return orms.map((orm) => this.toEntity(orm));
} }
async findByUserId(userId: string, options?: { limit?: number }): Promise<TokenUsageEntity[]> { async findByUserId(userId: string, options?: { limit?: number }): Promise<TokenUsageEntity[]> {
const queryBuilder = this.repo const queryBuilder = this.createTenantQueryBuilder('token_usage')
.createQueryBuilder('token_usage') .andWhere('token_usage.user_id = :userId', { userId })
.where('token_usage.user_id = :userId', { userId })
.orderBy('token_usage.created_at', 'DESC'); .orderBy('token_usage.created_at', 'DESC');
if (options?.limit) { if (options?.limit) {
@ -45,12 +51,11 @@ export class TokenUsagePostgresRepository implements ITokenUsageRepository {
totalOutputTokens: number; totalOutputTokens: number;
totalCost: number; totalCost: number;
}> { }> {
const result = await this.repo const result = await this.createTenantQueryBuilder('token_usage')
.createQueryBuilder('token_usage')
.select('SUM(token_usage.input_tokens)', 'totalInputTokens') .select('SUM(token_usage.input_tokens)', 'totalInputTokens')
.addSelect('SUM(token_usage.output_tokens)', 'totalOutputTokens') .addSelect('SUM(token_usage.output_tokens)', 'totalOutputTokens')
.addSelect('SUM(token_usage.estimated_cost)', 'totalCost') .addSelect('SUM(token_usage.estimated_cost)', 'totalCost')
.where('token_usage.conversation_id = :conversationId', { conversationId }) .andWhere('token_usage.conversation_id = :conversationId', { conversationId })
.getRawOne(); .getRawOne();
return { return {
@ -65,12 +70,11 @@ export class TokenUsagePostgresRepository implements ITokenUsageRepository {
totalOutputTokens: number; totalOutputTokens: number;
totalCost: number; totalCost: number;
}> { }> {
const result = await this.repo const result = await this.createTenantQueryBuilder('token_usage')
.createQueryBuilder('token_usage')
.select('SUM(token_usage.input_tokens)', 'totalInputTokens') .select('SUM(token_usage.input_tokens)', 'totalInputTokens')
.addSelect('SUM(token_usage.output_tokens)', 'totalOutputTokens') .addSelect('SUM(token_usage.output_tokens)', 'totalOutputTokens')
.addSelect('SUM(token_usage.estimated_cost)', 'totalCost') .addSelect('SUM(token_usage.estimated_cost)', 'totalCost')
.where('token_usage.user_id = :userId', { userId }) .andWhere('token_usage.user_id = :userId', { userId })
.getRawOne(); .getRawOne();
return { return {
@ -83,6 +87,7 @@ export class TokenUsagePostgresRepository implements ITokenUsageRepository {
private toORM(entity: TokenUsageEntity): TokenUsageORM { private toORM(entity: TokenUsageEntity): TokenUsageORM {
const orm = new TokenUsageORM(); const orm = new TokenUsageORM();
orm.id = entity.id; orm.id = entity.id;
orm.tenantId = this.getTenantId();
orm.userId = entity.userId; orm.userId = entity.userId;
orm.conversationId = entity.conversationId; orm.conversationId = entity.conversationId;
orm.messageId = entity.messageId; orm.messageId = entity.messageId;

View File

@ -1,6 +1,12 @@
import { Module } from '@nestjs/common'; import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import {
TenantContextService,
TenantContextMiddleware,
SimpleTenantFinder,
DEFAULT_TENANT_ID,
} from '@iconsulting/shared';
import { ConversationModule } from './conversation/conversation.module'; import { ConversationModule } from './conversation/conversation.module';
import { ClaudeModule } from './infrastructure/claude/claude.module'; import { ClaudeModule } from './infrastructure/claude/claude.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@ -47,5 +53,31 @@ import { HealthModule } from './health/health.module';
ConversationModule, ConversationModule,
ClaudeModule, 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('*');
}
}

View File

@ -15,6 +15,7 @@
"test:cov": "jest --coverage" "test:cov": "jest --coverage"
}, },
"dependencies": { "dependencies": {
"@iconsulting/shared": "workspace:*",
"@anthropic-ai/sdk": "^0.52.0", "@anthropic-ai/sdk": "^0.52.0",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.0", "@nestjs/config": "^3.2.0",

View File

@ -1,11 +1,18 @@
import { Module } from '@nestjs/common'; import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import {
TenantContextService,
TenantContextMiddleware,
DEFAULT_TENANT_ID,
} from '@iconsulting/shared';
import { EvolutionModule } from './evolution/evolution.module'; import { EvolutionModule } from './evolution/evolution.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { AnalyticsModule } from './analytics/analytics.module'; import { AnalyticsModule } from './analytics/analytics.module';
import { TenantModule } from './infrastructure/tenant/tenant.module';
import { TenantFinderService } from './infrastructure/tenant/tenant-finder.service';
@Module({ @Module({
imports: [ imports: [
@ -36,6 +43,9 @@ import { AnalyticsModule } from './analytics/analytics.module';
}), }),
}), }),
// 租户模块
TenantModule,
// Health check // Health check
HealthModule, HealthModule,
@ -44,5 +54,34 @@ import { AnalyticsModule } from './analytics/analytics.module';
AdminModule, AdminModule,
AnalyticsModule, 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('*');
}
}

View File

@ -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<string, { tenant: TenantContext; expiresAt: number }>();
private slugCache = new Map<string, string>(); // slug -> id 映射
private readonly cacheTtlMs = 5 * 60 * 1000; // 5 分钟
constructor(
@InjectRepository(TenantORM)
private readonly tenantRepo: Repository<TenantORM>,
) {}
async findById(id: string): Promise<TenantContext | null> {
// 检查缓存
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<TenantContext | null> {
// 检查 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 || {},
};
}
}

View File

@ -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 {}

View File

@ -1,30 +1,37 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared';
import { IFileRepository } from '../../../domain/repositories/file.repository.interface'; import { IFileRepository } from '../../../domain/repositories/file.repository.interface';
import { FileEntity, FileStatus } from '../../../domain/entities/file.entity'; import { FileEntity, FileStatus } from '../../../domain/entities/file.entity';
import { FileORM } from '../../../infrastructure/database/postgres/entities/file.orm'; import { FileORM } from '../../../infrastructure/database/postgres/entities/file.orm';
@Injectable() @Injectable()
export class FilePostgresRepository implements IFileRepository { export class FilePostgresRepository
extends BaseTenantRepository<FileEntity, FileORM>
implements IFileRepository
{
constructor( constructor(
@InjectRepository(FileORM) @InjectRepository(FileORM) repo: Repository<FileORM>,
private readonly repo: Repository<FileORM>, tenantContext: TenantContextService,
) {} ) {
super(repo, tenantContext);
}
async save(file: FileEntity): Promise<FileEntity> { async save(file: FileEntity): Promise<FileEntity> {
const orm = this.toORM(file); const orm = this.toORM(file);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
async findById(id: string): Promise<FileEntity | null> { async findById(id: string): Promise<FileEntity | null> {
const orm = await this.repo.findOne({ where: { id } }); const orm = await this.findOneWithTenant({ id } as any);
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
async findByIdAndUser(id: string, userId: string): Promise<FileEntity | null> { async findByIdAndUser(id: string, userId: string): Promise<FileEntity | null> {
const orm = await this.repo.findOne({ where: { id, userId } }); const orm = await this.findOneWithTenant({ id, userId } as any);
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
@ -33,7 +40,7 @@ export class FilePostgresRepository implements IFileRepository {
userId: string, userId: string,
status: FileStatus, status: FileStatus,
): Promise<FileEntity | null> { ): Promise<FileEntity | null> {
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; return orm ? this.toEntity(orm) : null;
} }
@ -42,20 +49,21 @@ export class FilePostgresRepository implements IFileRepository {
status: FileStatus, status: FileStatus,
conversationId?: string, conversationId?: string,
): Promise<FileEntity[]> { ): Promise<FileEntity[]> {
const where: Record<string, unknown> = { userId, status }; const queryBuilder = this.createTenantQueryBuilder('file')
.andWhere('file.user_id = :userId', { userId })
.andWhere('file.status = :status', { status });
if (conversationId) { if (conversationId) {
where.conversationId = conversationId; queryBuilder.andWhere('file.conversation_id = :conversationId', { conversationId });
} }
const orms = await this.repo.find({ const orms = await queryBuilder.orderBy('file.created_at', 'DESC').getMany();
where,
order: { createdAt: 'DESC' },
});
return orms.map((orm) => this.toEntity(orm)); return orms.map((orm) => this.toEntity(orm));
} }
async update(file: FileEntity): Promise<FileEntity> { async update(file: FileEntity): Promise<FileEntity> {
const orm = this.toORM(file); const orm = this.toORM(file);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
@ -63,6 +71,7 @@ export class FilePostgresRepository implements IFileRepository {
private toORM(entity: FileEntity): FileORM { private toORM(entity: FileEntity): FileORM {
const orm = new FileORM(); const orm = new FileORM();
orm.id = entity.id; orm.id = entity.id;
orm.tenantId = this.getTenantId();
orm.userId = entity.userId; orm.userId = entity.userId;
orm.conversationId = entity.conversationId; orm.conversationId = entity.conversationId;
orm.originalName = entity.originalName; orm.originalName = entity.originalName;

View File

@ -1,6 +1,12 @@
import { Module } from '@nestjs/common'; import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import {
TenantContextService,
TenantContextMiddleware,
SimpleTenantFinder,
DEFAULT_TENANT_ID,
} from '@iconsulting/shared';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { FileModule } from './file/file.module'; import { FileModule } from './file/file.module';
@ -35,5 +41,31 @@ import { FileModule } from './file/file.module';
// 功能模块 // 功能模块
FileModule, 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('*');
}
}

View File

@ -15,6 +15,7 @@
"test:cov": "jest --coverage" "test:cov": "jest --coverage"
}, },
"dependencies": { "dependencies": {
"@iconsulting/shared": "workspace:*",
"@anthropic-ai/sdk": "^0.52.0", "@anthropic-ai/sdk": "^0.52.0",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.0", "@nestjs/config": "^3.2.0",

View File

@ -1,6 +1,12 @@
import { Module } from '@nestjs/common'; import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import {
TenantContextService,
TenantContextMiddleware,
SimpleTenantFinder,
DEFAULT_TENANT_ID,
} from '@iconsulting/shared';
import { KnowledgeModule } from './knowledge/knowledge.module'; import { KnowledgeModule } from './knowledge/knowledge.module';
import { MemoryModule } from './memory/memory.module'; import { MemoryModule } from './memory/memory.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@ -38,5 +44,31 @@ import { HealthModule } from './health/health.module';
KnowledgeModule, KnowledgeModule,
MemoryModule, 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('*');
}
}

View File

@ -1,38 +1,46 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared';
import { IOrderRepository } from '../../../domain/repositories/order.repository.interface'; import { IOrderRepository } from '../../../domain/repositories/order.repository.interface';
import { OrderEntity } from '../../../domain/entities/order.entity'; import { OrderEntity } from '../../../domain/entities/order.entity';
import { OrderORM } from '../../../infrastructure/database/postgres/entities/order.orm'; import { OrderORM } from '../../../infrastructure/database/postgres/entities/order.orm';
@Injectable() @Injectable()
export class OrderPostgresRepository implements IOrderRepository { export class OrderPostgresRepository
extends BaseTenantRepository<OrderEntity, OrderORM>
implements IOrderRepository
{
constructor( constructor(
@InjectRepository(OrderORM) @InjectRepository(OrderORM) repo: Repository<OrderORM>,
private readonly repo: Repository<OrderORM>, tenantContext: TenantContextService,
) {} ) {
super(repo, tenantContext);
}
async save(order: OrderEntity): Promise<OrderEntity> { async save(order: OrderEntity): Promise<OrderEntity> {
const orm = this.toORM(order); const orm = this.toORM(order);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
async findById(id: string): Promise<OrderEntity | null> { async findById(id: string): Promise<OrderEntity | null> {
const orm = await this.repo.findOne({ where: { id } }); const orm = await this.findOneWithTenant({ id } as any);
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
async findByUserId(userId: string): Promise<OrderEntity[]> { async findByUserId(userId: string): Promise<OrderEntity[]> {
const orms = await this.repo.find({ const orms = await this.createTenantQueryBuilder('order')
where: { userId }, .andWhere('order.user_id = :userId', { userId })
order: { createdAt: 'DESC' }, .orderBy('order.created_at', 'DESC')
}); .getMany();
return orms.map((orm) => this.toEntity(orm)); return orms.map((orm) => this.toEntity(orm));
} }
async update(order: OrderEntity): Promise<OrderEntity> { async update(order: OrderEntity): Promise<OrderEntity> {
const orm = this.toORM(order); const orm = this.toORM(order);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
@ -40,6 +48,7 @@ export class OrderPostgresRepository implements IOrderRepository {
private toORM(entity: OrderEntity): OrderORM { private toORM(entity: OrderEntity): OrderORM {
const orm = new OrderORM(); const orm = new OrderORM();
orm.id = entity.id; orm.id = entity.id;
orm.tenantId = this.getTenantId();
orm.userId = entity.userId; orm.userId = entity.userId;
orm.conversationId = entity.conversationId; orm.conversationId = entity.conversationId;
orm.serviceType = entity.serviceType; orm.serviceType = entity.serviceType;

View File

@ -1,44 +1,48 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared';
import { IPaymentRepository } from '../../../domain/repositories/payment.repository.interface'; import { IPaymentRepository } from '../../../domain/repositories/payment.repository.interface';
import { PaymentEntity, PaymentStatus } from '../../../domain/entities/payment.entity'; import { PaymentEntity, PaymentStatus } from '../../../domain/entities/payment.entity';
import { PaymentORM } from '../../../infrastructure/database/postgres/entities/payment.orm'; import { PaymentORM } from '../../../infrastructure/database/postgres/entities/payment.orm';
@Injectable() @Injectable()
export class PaymentPostgresRepository implements IPaymentRepository { export class PaymentPostgresRepository
extends BaseTenantRepository<PaymentEntity, PaymentORM>
implements IPaymentRepository
{
constructor( constructor(
@InjectRepository(PaymentORM) @InjectRepository(PaymentORM) repo: Repository<PaymentORM>,
private readonly repo: Repository<PaymentORM>, tenantContext: TenantContextService,
) {} ) {
super(repo, tenantContext);
}
async save(payment: PaymentEntity): Promise<PaymentEntity> { async save(payment: PaymentEntity): Promise<PaymentEntity> {
const orm = this.toORM(payment); const orm = this.toORM(payment);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
async findById(id: string): Promise<PaymentEntity | null> { async findById(id: string): Promise<PaymentEntity | null> {
const orm = await this.repo.findOne({ where: { id } }); const orm = await this.findOneWithTenant({ id } as any);
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
async findPendingByOrderId(orderId: string): Promise<PaymentEntity | null> { async findPendingByOrderId(orderId: string): Promise<PaymentEntity | null> {
const orm = await this.repo.findOne({ const orm = await this.findOneWithTenant({ orderId, status: PaymentStatus.PENDING } as any);
where: { orderId, status: PaymentStatus.PENDING },
});
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
async findByTransactionId(transactionId: string): Promise<PaymentEntity | null> { async findByTransactionId(transactionId: string): Promise<PaymentEntity | null> {
const orm = await this.repo.findOne({ const orm = await this.findOneWithTenant({ transactionId } as any);
where: { transactionId },
});
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
async update(payment: PaymentEntity): Promise<PaymentEntity> { async update(payment: PaymentEntity): Promise<PaymentEntity> {
const orm = this.toORM(payment); const orm = this.toORM(payment);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
@ -46,6 +50,7 @@ export class PaymentPostgresRepository implements IPaymentRepository {
private toORM(entity: PaymentEntity): PaymentORM { private toORM(entity: PaymentEntity): PaymentORM {
const orm = new PaymentORM(); const orm = new PaymentORM();
orm.id = entity.id; orm.id = entity.id;
orm.tenantId = this.getTenantId();
orm.orderId = entity.orderId; orm.orderId = entity.orderId;
orm.method = entity.method; orm.method = entity.method;
orm.amount = entity.amount; orm.amount = entity.amount;

View File

@ -1,6 +1,12 @@
import { Module } from '@nestjs/common'; import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import {
TenantContextService,
TenantContextMiddleware,
SimpleTenantFinder,
DEFAULT_TENANT_ID,
} from '@iconsulting/shared';
import { PaymentModule } from './payment/payment.module'; import { PaymentModule } from './payment/payment.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@ -50,5 +56,31 @@ import { HealthModule } from './health/health.module';
OrderModule, OrderModule,
PaymentModule, 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('*');
}
}

View File

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, FindOptionsWhere } from 'typeorm'; import { Repository, Like, FindOptionsWhere } from 'typeorm';
import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared';
import { import {
IUserRepository, IUserRepository,
UserQueryOptions, UserQueryOptions,
@ -10,35 +11,41 @@ import { UserEntity, UserType } from '../../../domain/entities/user.entity';
import { UserORM } from '../../../infrastructure/database/postgres/entities/user.orm'; import { UserORM } from '../../../infrastructure/database/postgres/entities/user.orm';
@Injectable() @Injectable()
export class UserPostgresRepository implements IUserRepository { export class UserPostgresRepository
extends BaseTenantRepository<UserEntity, UserORM>
implements IUserRepository
{
constructor( constructor(
@InjectRepository(UserORM) @InjectRepository(UserORM) repo: Repository<UserORM>,
private readonly repo: Repository<UserORM>, tenantContext: TenantContextService,
) {} ) {
super(repo, tenantContext);
}
async save(user: UserEntity): Promise<UserEntity> { async save(user: UserEntity): Promise<UserEntity> {
const orm = this.toORM(user); const orm = this.toORM(user);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
async findById(id: string): Promise<UserEntity | null> { async findById(id: string): Promise<UserEntity | null> {
const orm = await this.repo.findOne({ where: { id } }); const orm = await this.findOneWithTenant({ id } as any);
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
async findByPhone(phone: string): Promise<UserEntity | null> { async findByPhone(phone: string): Promise<UserEntity | null> {
const orm = await this.repo.findOne({ where: { phone } }); const orm = await this.findOneWithTenant({ phone } as any);
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
async findByFingerprint(fingerprint: string): Promise<UserEntity | null> { async findByFingerprint(fingerprint: string): Promise<UserEntity | null> {
const orm = await this.repo.findOne({ where: { fingerprint } }); const orm = await this.findOneWithTenant({ fingerprint } as any);
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
async updateLastActive(userId: string): Promise<void> { async updateLastActive(userId: string): Promise<void> {
await this.repo.update(userId, { lastActiveAt: new Date() }); await this.updateWithTenant(userId, { lastActiveAt: new Date() } as any);
} }
async findAll(options: UserQueryOptions): Promise<PaginatedUsers> { async findAll(options: UserQueryOptions): Promise<PaginatedUsers> {
@ -46,25 +53,22 @@ export class UserPostgresRepository implements IUserRepository {
const pageSize = options.pageSize || 20; const pageSize = options.pageSize || 20;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const where: FindOptionsWhere<UserORM> = {}; const queryBuilder = this.createTenantQueryBuilder('user');
if (options.type) { if (options.type) {
where.type = options.type; queryBuilder.andWhere('user.type = :type', { type: options.type });
} }
if (options.phone) { if (options.phone) {
where.phone = Like(`%${options.phone}%`); queryBuilder.andWhere('user.phone LIKE :phone', { phone: `%${options.phone}%` });
} }
if (options.nickname) { if (options.nickname) {
where.nickname = Like(`%${options.nickname}%`); queryBuilder.andWhere('user.nickname LIKE :nickname', { nickname: `%${options.nickname}%` });
} }
const [items, total] = await this.repo.findAndCount({ queryBuilder.orderBy(`user.${options.sortBy || 'createdAt'}`, options.sortOrder || 'DESC');
where, queryBuilder.skip(skip).take(pageSize);
order: {
[options.sortBy || 'createdAt']: options.sortOrder || 'DESC', const [items, total] = await queryBuilder.getManyAndCount();
},
skip,
take: pageSize,
});
return { return {
items: items.map((orm) => this.toEntity(orm)), items: items.map((orm) => this.toEntity(orm)),
@ -76,8 +80,7 @@ export class UserPostgresRepository implements IUserRepository {
} }
async countByType(): Promise<Record<UserType, number>> { async countByType(): Promise<Record<UserType, number>> {
const result = await this.repo const result = await this.createTenantQueryBuilder('user')
.createQueryBuilder('user')
.select('user.type', 'type') .select('user.type', 'type')
.addSelect('COUNT(*)', 'count') .addSelect('COUNT(*)', 'count')
.groupBy('user.type') .groupBy('user.type')
@ -96,14 +99,13 @@ export class UserPostgresRepository implements IUserRepository {
} }
async search(keyword: string, limit = 10): Promise<UserEntity[]> { async search(keyword: string, limit = 10): Promise<UserEntity[]> {
const items = await this.repo.find({ const items = await this.createTenantQueryBuilder('user')
where: [ .andWhere('(user.phone LIKE :keyword OR user.nickname LIKE :keyword)', {
{ phone: Like(`%${keyword}%`) }, keyword: `%${keyword}%`,
{ nickname: Like(`%${keyword}%`) }, })
], .orderBy('user.lastActiveAt', 'DESC')
take: limit, .take(limit)
order: { lastActiveAt: 'DESC' }, .getMany();
});
return items.map((orm) => this.toEntity(orm)); return items.map((orm) => this.toEntity(orm));
} }
@ -111,6 +113,7 @@ export class UserPostgresRepository implements IUserRepository {
private toORM(entity: UserEntity): UserORM { private toORM(entity: UserEntity): UserORM {
const orm = new UserORM(); const orm = new UserORM();
orm.id = entity.id; orm.id = entity.id;
orm.tenantId = this.getTenantId();
orm.type = entity.type; orm.type = entity.type;
orm.fingerprint = entity.fingerprint; orm.fingerprint = entity.fingerprint;
orm.phone = entity.phone; orm.phone = entity.phone;

View File

@ -1,53 +1,57 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan } from 'typeorm'; import { Repository, MoreThan } from 'typeorm';
import { BaseTenantRepository, TenantContextService } from '@iconsulting/shared';
import { IVerificationCodeRepository } from '../../../domain/repositories/verification-code.repository.interface'; import { IVerificationCodeRepository } from '../../../domain/repositories/verification-code.repository.interface';
import { VerificationCodeEntity } from '../../../domain/entities/verification-code.entity'; import { VerificationCodeEntity } from '../../../domain/entities/verification-code.entity';
import { VerificationCodeORM } from '../../../infrastructure/database/postgres/entities/verification-code.orm'; import { VerificationCodeORM } from '../../../infrastructure/database/postgres/entities/verification-code.orm';
@Injectable() @Injectable()
export class VerificationCodePostgresRepository implements IVerificationCodeRepository { export class VerificationCodePostgresRepository
extends BaseTenantRepository<VerificationCodeEntity, VerificationCodeORM>
implements IVerificationCodeRepository
{
constructor( constructor(
@InjectRepository(VerificationCodeORM) @InjectRepository(VerificationCodeORM) repo: Repository<VerificationCodeORM>,
private readonly repo: Repository<VerificationCodeORM>, tenantContext: TenantContextService,
) {} ) {
super(repo, tenantContext);
}
async save(code: VerificationCodeEntity): Promise<VerificationCodeEntity> { async save(code: VerificationCodeEntity): Promise<VerificationCodeEntity> {
const orm = this.toORM(code); const orm = this.toORM(code);
orm.tenantId = this.getTenantId();
const saved = await this.repo.save(orm); const saved = await this.repo.save(orm);
return this.toEntity(saved); return this.toEntity(saved);
} }
async findValidCode(phone: string, code: string): Promise<VerificationCodeEntity | null> { async findValidCode(phone: string, code: string): Promise<VerificationCodeEntity | null> {
const orm = await this.repo.findOne({ const orm = await this.createTenantQueryBuilder('vc')
where: { .andWhere('vc.phone = :phone', { phone })
phone, .andWhere('vc.code = :code', { code })
code, .andWhere('vc.is_used = :isUsed', { isUsed: false })
isUsed: false, .andWhere('vc.expires_at > :now', { now: new Date() })
expiresAt: MoreThan(new Date()), .orderBy('vc.created_at', 'DESC')
}, .getOne();
order: { createdAt: 'DESC' },
});
return orm ? this.toEntity(orm) : null; return orm ? this.toEntity(orm) : null;
} }
async countRecentByPhone(phone: string, hoursBack: number): Promise<number> { async countRecentByPhone(phone: string, hoursBack: number): Promise<number> {
const since = new Date(Date.now() - hoursBack * 60 * 60 * 1000); const since = new Date(Date.now() - hoursBack * 60 * 60 * 1000);
return this.repo.count({ return this.createTenantQueryBuilder('vc')
where: { .andWhere('vc.phone = :phone', { phone })
phone, .andWhere('vc.created_at > :since', { since })
createdAt: MoreThan(since), .getCount();
},
});
} }
async markAsUsed(id: string): Promise<void> { async markAsUsed(id: string): Promise<void> {
await this.repo.update(id, { isUsed: true }); await this.updateWithTenant(id, { isUsed: true } as any);
} }
private toORM(entity: VerificationCodeEntity): VerificationCodeORM { private toORM(entity: VerificationCodeEntity): VerificationCodeORM {
const orm = new VerificationCodeORM(); const orm = new VerificationCodeORM();
orm.id = entity.id; orm.id = entity.id;
orm.tenantId = this.getTenantId();
orm.phone = entity.phone; orm.phone = entity.phone;
orm.code = entity.code; orm.code = entity.code;
orm.expiresAt = entity.expiresAt; orm.expiresAt = entity.expiresAt;

View File

@ -1,7 +1,13 @@
import { Module } from '@nestjs/common'; import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import {
TenantContextService,
TenantContextMiddleware,
SimpleTenantFinder,
DEFAULT_TENANT_ID,
} from '@iconsulting/shared';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@ -46,5 +52,31 @@ import { HealthModule } from './health/health.module';
UserModule, UserModule,
AuthModule, 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('*');
}
}

View File

@ -8,6 +8,9 @@ export { TenantContextService } from './tenant-context.service.js';
export { TenantContextMiddleware, TENANT_FINDER, createTenantMiddleware } from './tenant-context.middleware.js'; export { TenantContextMiddleware, TENANT_FINDER, createTenantMiddleware } from './tenant-context.middleware.js';
export type { ITenantFinder, TenantMiddlewareOptions } 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 // Guards
export { TenantGuard } from './tenant.guard.js'; export { TenantGuard } from './tenant.guard.js';

View File

@ -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<string, { tenant: TenantContext; expiresAt: number }>();
private readonly cacheTtlMs = 5 * 60 * 1000; // 5 分钟缓存
async findById(id: string): Promise<TenantContext | null> {
// 检查缓存
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);
}
}

View File

@ -205,6 +205,9 @@ importers:
'@anthropic-ai/sdk': '@anthropic-ai/sdk':
specifier: ^0.52.0 specifier: ^0.52.0
version: 0.52.0 version: 0.52.0
'@iconsulting/shared':
specifier: workspace:*
version: link:../../shared
'@nestjs/common': '@nestjs/common':
specifier: ^10.0.0 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) 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': '@anthropic-ai/sdk':
specifier: ^0.52.0 specifier: ^0.52.0
version: 0.52.0 version: 0.52.0
'@iconsulting/shared':
specifier: workspace:*
version: link:../../shared
'@nestjs/common': '@nestjs/common':
specifier: ^10.0.0 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) version: 10.4.21(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)