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 { 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<ConversationEntity, ConversationORM>
implements IConversationRepository
{
constructor(
@InjectRepository(ConversationORM)
private readonly repo: Repository<ConversationORM>,
) {}
@InjectRepository(ConversationORM) repo: Repository<ConversationORM>,
tenantContext: TenantContextService,
) {
super(repo, tenantContext);
}
async save(conversation: ConversationEntity): Promise<ConversationEntity> {
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<ConversationEntity | null> {
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<ConversationEntity[]> {
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<ConversationEntity[]> {
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<ConversationEntity> {
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<number> {
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;

View File

@ -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<MessageEntity, MessageORM>
implements IMessageRepository
{
constructor(
@InjectRepository(MessageORM)
private readonly repo: Repository<MessageORM>,
) {}
@InjectRepository(MessageORM) repo: Repository<MessageORM>,
tenantContext: TenantContextService,
) {
super(repo, tenantContext);
}
async save(message: MessageEntity): Promise<MessageEntity> {
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<MessageEntity | null> {
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<MessageEntity[]> {
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<number> {
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;

View File

@ -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<TokenUsageEntity, TokenUsageORM>
implements ITokenUsageRepository
{
constructor(
@InjectRepository(TokenUsageORM)
private readonly repo: Repository<TokenUsageORM>,
) {}
@InjectRepository(TokenUsageORM) repo: Repository<TokenUsageORM>,
tenantContext: TenantContextService,
) {
super(repo, tenantContext);
}
async save(tokenUsage: TokenUsageEntity): Promise<TokenUsageEntity> {
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<TokenUsageEntity[]> {
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<TokenUsageEntity[]> {
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;

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 { 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('*');
}
}

View File

@ -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",

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 { 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('*');
}
}

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 { 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<FileEntity, FileORM>
implements IFileRepository
{
constructor(
@InjectRepository(FileORM)
private readonly repo: Repository<FileORM>,
) {}
@InjectRepository(FileORM) repo: Repository<FileORM>,
tenantContext: TenantContextService,
) {
super(repo, tenantContext);
}
async save(file: FileEntity): Promise<FileEntity> {
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<FileEntity | null> {
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<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;
}
@ -33,7 +40,7 @@ export class FilePostgresRepository implements IFileRepository {
userId: string,
status: FileStatus,
): 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;
}
@ -42,20 +49,21 @@ export class FilePostgresRepository implements IFileRepository {
status: FileStatus,
conversationId?: string,
): 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) {
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<FileEntity> {
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;

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 { 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('*');
}
}

View File

@ -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",

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 { 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('*');
}
}

View File

@ -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<OrderEntity, OrderORM>
implements IOrderRepository
{
constructor(
@InjectRepository(OrderORM)
private readonly repo: Repository<OrderORM>,
) {}
@InjectRepository(OrderORM) repo: Repository<OrderORM>,
tenantContext: TenantContextService,
) {
super(repo, tenantContext);
}
async save(order: OrderEntity): Promise<OrderEntity> {
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<OrderEntity | null> {
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<OrderEntity[]> {
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<OrderEntity> {
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;

View File

@ -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<PaymentEntity, PaymentORM>
implements IPaymentRepository
{
constructor(
@InjectRepository(PaymentORM)
private readonly repo: Repository<PaymentORM>,
) {}
@InjectRepository(PaymentORM) repo: Repository<PaymentORM>,
tenantContext: TenantContextService,
) {
super(repo, tenantContext);
}
async save(payment: PaymentEntity): Promise<PaymentEntity> {
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<PaymentEntity | null> {
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<PaymentEntity | null> {
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<PaymentEntity | null> {
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<PaymentEntity> {
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;

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 { 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('*');
}
}

View File

@ -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<UserEntity, UserORM>
implements IUserRepository
{
constructor(
@InjectRepository(UserORM)
private readonly repo: Repository<UserORM>,
) {}
@InjectRepository(UserORM) repo: Repository<UserORM>,
tenantContext: TenantContextService,
) {
super(repo, tenantContext);
}
async save(user: UserEntity): Promise<UserEntity> {
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<UserEntity | null> {
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<UserEntity | null> {
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<UserEntity | null> {
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<void> {
await this.repo.update(userId, { lastActiveAt: new Date() });
await this.updateWithTenant(userId, { lastActiveAt: new Date() } as any);
}
async findAll(options: UserQueryOptions): Promise<PaginatedUsers> {
@ -46,25 +53,22 @@ export class UserPostgresRepository implements IUserRepository {
const pageSize = options.pageSize || 20;
const skip = (page - 1) * pageSize;
const where: FindOptionsWhere<UserORM> = {};
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<Record<UserType, number>> {
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<UserEntity[]> {
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;

View File

@ -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<VerificationCodeEntity, VerificationCodeORM>
implements IVerificationCodeRepository
{
constructor(
@InjectRepository(VerificationCodeORM)
private readonly repo: Repository<VerificationCodeORM>,
) {}
@InjectRepository(VerificationCodeORM) repo: Repository<VerificationCodeORM>,
tenantContext: TenantContextService,
) {
super(repo, tenantContext);
}
async save(code: VerificationCodeEntity): Promise<VerificationCodeEntity> {
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<VerificationCodeEntity | null> {
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<number> {
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<void> {
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;

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 { 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('*');
}
}

View File

@ -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';

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':
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)