From 92ee490a5797b9f68688d1408101bb4964803274 Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 25 Jan 2026 19:12:04 -0800 Subject: [PATCH] feat(multi-tenant): complete repository tenant filtering for remaining services - knowledge-postgres.repository: add tenant_id to all queries and raw SQL - memory-postgres.repository: add tenant_id filtering for UserMemory and SystemExperience - admin-postgres.repository: add tenant_id filtering (direct injection for nullable tenantId) - All 11 repositories now have proper tenant isolation Co-Authored-By: Claude Opus 4.5 --- .../persistence/admin-postgres.repository.ts | 27 +++++-- .../knowledge-postgres.repository.ts | 54 ++++++++++---- .../persistence/memory-postgres.repository.ts | 73 +++++++++++++++---- 3 files changed, 119 insertions(+), 35 deletions(-) diff --git a/packages/services/evolution-service/src/adapters/outbound/persistence/admin-postgres.repository.ts b/packages/services/evolution-service/src/adapters/outbound/persistence/admin-postgres.repository.ts index ef212d7..31edd0f 100644 --- a/packages/services/evolution-service/src/adapters/outbound/persistence/admin-postgres.repository.ts +++ b/packages/services/evolution-service/src/adapters/outbound/persistence/admin-postgres.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { TenantContextService } from '@iconsulting/shared'; import { IAdminRepository } from '../../../domain/repositories/admin.repository.interface'; import { AdminEntity } from '../../../domain/entities/admin.entity'; import { AdminRole } from '../../../domain/value-objects/admin-role.enum'; @@ -11,20 +12,32 @@ export class AdminPostgresRepository implements IAdminRepository { constructor( @InjectRepository(AdminORM) private adminRepo: Repository, + private readonly tenantContext: TenantContextService, ) {} + private getTenantId(): string { + const id = this.tenantContext.getCurrentTenantId(); + if (!id) throw new Error('Tenant context not set'); + return id; + } + async save(admin: AdminEntity): Promise { const orm = this.toORM(admin); + orm.tenantId = this.getTenantId(); await this.adminRepo.save(orm); } async findById(id: string): Promise { - const orm = await this.adminRepo.findOne({ where: { id } }); + const orm = await this.adminRepo.findOne({ + where: { id, tenantId: this.getTenantId() }, + }); return orm ? this.toEntity(orm) : null; } async findByUsername(username: string): Promise { - const orm = await this.adminRepo.findOne({ where: { username } }); + const orm = await this.adminRepo.findOne({ + where: { username, tenantId: this.getTenantId() }, + }); return orm ? this.toEntity(orm) : null; } @@ -34,7 +47,8 @@ export class AdminPostgresRepository implements IAdminRepository { limit?: number; offset?: number; }): Promise { - const query = this.adminRepo.createQueryBuilder('admin'); + const query = this.adminRepo.createQueryBuilder('admin') + .where('admin.tenant_id = :tenantId', { tenantId: this.getTenantId() }); if (options?.role) { query.andWhere('admin.role = :role', { role: options.role }); @@ -62,7 +76,8 @@ export class AdminPostgresRepository implements IAdminRepository { role?: AdminRole; isActive?: boolean; }): Promise { - const query = this.adminRepo.createQueryBuilder('admin'); + const query = this.adminRepo.createQueryBuilder('admin') + .where('admin.tenant_id = :tenantId', { tenantId: this.getTenantId() }); if (options?.role) { query.andWhere('admin.role = :role', { role: options.role }); @@ -77,16 +92,18 @@ export class AdminPostgresRepository implements IAdminRepository { async update(admin: AdminEntity): Promise { const orm = this.toORM(admin); + orm.tenantId = this.getTenantId(); await this.adminRepo.save(orm); } async delete(id: string): Promise { - await this.adminRepo.delete(id); + await this.adminRepo.delete({ id, tenantId: this.getTenantId() }); } private toORM(entity: AdminEntity): AdminORM { const orm = new AdminORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.username = entity.username; orm.passwordHash = entity.passwordHash; orm.name = entity.name; diff --git a/packages/services/knowledge-service/src/adapters/outbound/persistence/knowledge-postgres.repository.ts b/packages/services/knowledge-service/src/adapters/outbound/persistence/knowledge-postgres.repository.ts index a33a7fc..f5d14ec 100644 --- a/packages/services/knowledge-service/src/adapters/outbound/persistence/knowledge-postgres.repository.ts +++ b/packages/services/knowledge-service/src/adapters/outbound/persistence/knowledge-postgres.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, ILike } from 'typeorm'; +import { TenantContextService } from '@iconsulting/shared'; import { IKnowledgeRepository } from '../../../domain/repositories/knowledge.repository.interface'; import { KnowledgeArticleEntity, KnowledgeSource } from '../../../domain/entities/knowledge-article.entity'; import { KnowledgeChunkEntity, ChunkType } from '../../../domain/entities/knowledge-chunk.entity'; @@ -14,17 +15,27 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { private articleRepo: Repository, @InjectRepository(KnowledgeChunkORM) private chunkRepo: Repository, + private readonly tenantContext: TenantContextService, ) {} + private getTenantId(): string { + const id = this.tenantContext.getCurrentTenantId(); + if (!id) throw new Error('Tenant context not set'); + return id; + } + // ========== 文章操作 ========== async saveArticle(article: KnowledgeArticleEntity): Promise { const orm = this.toArticleORM(article); + orm.tenantId = this.getTenantId(); await this.articleRepo.save(orm); } async findArticleById(id: string): Promise { - const orm = await this.articleRepo.findOne({ where: { id } }); + const orm = await this.articleRepo.findOne({ + where: { id, tenantId: this.getTenantId() }, + }); return orm ? this.toArticleEntity(orm) : null; } @@ -33,7 +44,8 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { options?: { publishedOnly?: boolean; limit?: number; offset?: number }, ): Promise { const query = this.articleRepo.createQueryBuilder('article') - .where('article.category = :category', { category }); + .where('article.tenant_id = :tenantId', { tenantId: this.getTenantId() }) + .andWhere('article.category = :category', { category }); if (options?.publishedOnly) { query.andWhere('article.isPublished = true'); @@ -58,7 +70,8 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { options?: { category?: string; publishedOnly?: boolean; limit?: number }, ): Promise { const query = this.articleRepo.createQueryBuilder('article') - .where('(article.title ILIKE :search OR article.content ILIKE :search)', { + .where('article.tenant_id = :tenantId', { tenantId: this.getTenantId() }) + .andWhere('(article.title ILIKE :search OR article.content ILIKE :search)', { search: `%${queryStr}%`, }); @@ -86,6 +99,7 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { minSimilarity?: number; }, ): Promise> { + const tenantId = this.getTenantId(); const embeddingStr = `[${embedding.join(',')}]`; const limit = options?.limit || 5; const minSimilarity = options?.minSimilarity || 0.7; @@ -94,7 +108,8 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { SELECT *, 1 - (embedding <=> '${embeddingStr}'::vector) as similarity FROM knowledge_articles - WHERE embedding IS NOT NULL + WHERE tenant_id = $1 + AND embedding IS NOT NULL `; if (options?.category) { @@ -106,13 +121,12 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { } sql += ` - HAVING 1 - (embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity} + AND 1 - (embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity} ORDER BY similarity DESC LIMIT ${limit} `; - // 使用原生查询以利用pgvector - const results = await this.articleRepo.query(sql); + const results = await this.articleRepo.query(sql, [tenantId]); return results.map((row: any) => ({ article: this.toArticleEntityFromRaw(row), @@ -122,15 +136,17 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { async updateArticle(article: KnowledgeArticleEntity): Promise { const orm = this.toArticleORM(article); + orm.tenantId = this.getTenantId(); await this.articleRepo.save(orm); } async deleteArticle(id: string): Promise { - await this.articleRepo.delete(id); + await this.articleRepo.delete({ id, tenantId: this.getTenantId() }); } async countArticles(options?: { category?: string; publishedOnly?: boolean }): Promise { - const query = this.articleRepo.createQueryBuilder('article'); + const query = this.articleRepo.createQueryBuilder('article') + .where('article.tenant_id = :tenantId', { tenantId: this.getTenantId() }); if (options?.category) { query.andWhere('article.category = :category', { category: options.category }); @@ -147,17 +163,23 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { async saveChunk(chunk: KnowledgeChunkEntity): Promise { const orm = this.toChunkORM(chunk); + orm.tenantId = this.getTenantId(); await this.chunkRepo.save(orm); } async saveChunks(chunks: KnowledgeChunkEntity[]): Promise { - const orms = chunks.map(chunk => this.toChunkORM(chunk)); + const tenantId = this.getTenantId(); + const orms = chunks.map(chunk => { + const orm = this.toChunkORM(chunk); + orm.tenantId = tenantId; + return orm; + }); await this.chunkRepo.save(orms); } async findChunksByArticleId(articleId: string): Promise { const orms = await this.chunkRepo.find({ - where: { articleId }, + where: { articleId, tenantId: this.getTenantId() }, order: { chunkIndex: 'ASC' }, }); return orms.map(orm => this.toChunkEntity(orm)); @@ -171,6 +193,7 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { minSimilarity?: number; }, ): Promise> { + const tenantId = this.getTenantId(); const embeddingStr = `[${embedding.join(',')}]`; const limit = options?.limit || 5; const minSimilarity = options?.minSimilarity || 0.7; @@ -180,7 +203,8 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { 1 - (c.embedding <=> '${embeddingStr}'::vector) as similarity FROM knowledge_chunks c JOIN knowledge_articles a ON c.article_id = a.id - WHERE c.embedding IS NOT NULL + WHERE c.tenant_id = $1 + AND c.embedding IS NOT NULL AND a.is_published = true `; @@ -194,7 +218,7 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { LIMIT ${limit} `; - const results = await this.chunkRepo.query(sql); + const results = await this.chunkRepo.query(sql, [tenantId]); return results.map((row: any) => ({ chunk: this.toChunkEntityFromRaw(row), @@ -203,7 +227,7 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { } async deleteChunksByArticleId(articleId: string): Promise { - await this.chunkRepo.delete({ articleId }); + await this.chunkRepo.delete({ articleId, tenantId: this.getTenantId() }); } // ========== 转换方法 ========== @@ -211,6 +235,7 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { private toArticleORM(entity: KnowledgeArticleEntity): KnowledgeArticleORM { const orm = new KnowledgeArticleORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.title = entity.title; orm.content = entity.content; orm.summary = entity.summary; @@ -280,6 +305,7 @@ export class KnowledgePostgresRepository implements IKnowledgeRepository { private toChunkORM(entity: KnowledgeChunkEntity): KnowledgeChunkORM { const orm = new KnowledgeChunkORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.articleId = entity.articleId; orm.content = entity.content; orm.chunkIndex = entity.chunkIndex; diff --git a/packages/services/knowledge-service/src/adapters/outbound/persistence/memory-postgres.repository.ts b/packages/services/knowledge-service/src/adapters/outbound/persistence/memory-postgres.repository.ts index 9ca8628..c04b3fd 100644 --- a/packages/services/knowledge-service/src/adapters/outbound/persistence/memory-postgres.repository.ts +++ b/packages/services/knowledge-service/src/adapters/outbound/persistence/memory-postgres.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan, MoreThan } from 'typeorm'; +import { TenantContextService } from '@iconsulting/shared'; import { IUserMemoryRepository, ISystemExperienceRepository, @@ -19,15 +20,25 @@ export class UserMemoryPostgresRepository implements IUserMemoryRepository { constructor( @InjectRepository(UserMemoryORM) private memoryRepo: Repository, + private readonly tenantContext: TenantContextService, ) {} + private getTenantId(): string { + const id = this.tenantContext.getCurrentTenantId(); + if (!id) throw new Error('Tenant context not set'); + return id; + } + async save(memory: UserMemoryEntity): Promise { const orm = this.toORM(memory); + orm.tenantId = this.getTenantId(); await this.memoryRepo.save(orm); } async findById(id: string): Promise { - const orm = await this.memoryRepo.findOne({ where: { id } }); + const orm = await this.memoryRepo.findOne({ + where: { id, tenantId: this.getTenantId() }, + }); return orm ? this.toEntity(orm) : null; } @@ -36,7 +47,8 @@ export class UserMemoryPostgresRepository implements IUserMemoryRepository { options?: { memoryType?: MemoryType; includeExpired?: boolean; limit?: number }, ): Promise { const query = this.memoryRepo.createQueryBuilder('memory') - .where('memory.userId = :userId', { userId }); + .where('memory.tenant_id = :tenantId', { tenantId: this.getTenantId() }) + .andWhere('memory.userId = :userId', { userId }); if (options?.memoryType) { query.andWhere('memory.memoryType = :type', { type: options.memoryType }); @@ -62,6 +74,7 @@ export class UserMemoryPostgresRepository implements IUserMemoryRepository { embedding: number[], options?: { memoryType?: MemoryType; limit?: number; minSimilarity?: number }, ): Promise> { + const tenantId = this.getTenantId(); const embeddingStr = `[${embedding.join(',')}]`; const limit = options?.limit || 5; const minSimilarity = options?.minSimilarity || 0.7; @@ -70,7 +83,8 @@ export class UserMemoryPostgresRepository implements IUserMemoryRepository { SELECT *, 1 - (embedding <=> '${embeddingStr}'::vector) as similarity FROM user_memories - WHERE user_id = '${userId}' + WHERE tenant_id = $1 + AND user_id = $2 AND embedding IS NOT NULL AND is_expired = false `; @@ -85,7 +99,7 @@ export class UserMemoryPostgresRepository implements IUserMemoryRepository { LIMIT ${limit} `; - const results = await this.memoryRepo.query(sql); + const results = await this.memoryRepo.query(sql, [tenantId, userId]); return results.map((row: any) => ({ memory: this.toEntityFromRaw(row), @@ -95,7 +109,7 @@ export class UserMemoryPostgresRepository implements IUserMemoryRepository { async findTopMemories(userId: string, limit: number): Promise { const orms = await this.memoryRepo.find({ - where: { userId, isExpired: false }, + where: { userId, tenantId: this.getTenantId(), isExpired: false }, order: { importance: 'DESC', accessCount: 'DESC' }, take: limit, }); @@ -104,15 +118,16 @@ export class UserMemoryPostgresRepository implements IUserMemoryRepository { async update(memory: UserMemoryEntity): Promise { const orm = this.toORM(memory); + orm.tenantId = this.getTenantId(); await this.memoryRepo.save(orm); } async delete(id: string): Promise { - await this.memoryRepo.delete(id); + await this.memoryRepo.delete({ id, tenantId: this.getTenantId() }); } async deleteByUserId(userId: string): Promise { - await this.memoryRepo.delete({ userId }); + await this.memoryRepo.delete({ userId, tenantId: this.getTenantId() }); } async markExpiredMemories(userId: string, olderThanDays: number): Promise { @@ -122,6 +137,7 @@ export class UserMemoryPostgresRepository implements IUserMemoryRepository { const result = await this.memoryRepo.update( { userId, + tenantId: this.getTenantId(), isExpired: false, updatedAt: LessThan(cutoffDate), }, @@ -134,6 +150,7 @@ export class UserMemoryPostgresRepository implements IUserMemoryRepository { private toORM(entity: UserMemoryEntity): UserMemoryORM { const orm = new UserMemoryORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.userId = entity.userId; orm.memoryType = entity.memoryType; orm.content = entity.content; @@ -191,15 +208,25 @@ export class SystemExperiencePostgresRepository implements ISystemExperienceRepo constructor( @InjectRepository(SystemExperienceORM) private experienceRepo: Repository, + private readonly tenantContext: TenantContextService, ) {} + private getTenantId(): string { + const id = this.tenantContext.getCurrentTenantId(); + if (!id) throw new Error('Tenant context not set'); + return id; + } + async save(experience: SystemExperienceEntity): Promise { const orm = this.toORM(experience); + orm.tenantId = this.getTenantId(); await this.experienceRepo.save(orm); } async findById(id: string): Promise { - const orm = await this.experienceRepo.findOne({ where: { id } }); + const orm = await this.experienceRepo.findOne({ + where: { id, tenantId: this.getTenantId() }, + }); return orm ? this.toEntity(orm) : null; } @@ -209,7 +236,8 @@ export class SystemExperiencePostgresRepository implements ISystemExperienceRepo offset?: number; }): Promise { const query = this.experienceRepo.createQueryBuilder('exp') - .where('exp.verificationStatus = :status', { status: VerificationStatus.PENDING }); + .where('exp.tenant_id = :tenantId', { tenantId: this.getTenantId() }) + .andWhere('exp.verificationStatus = :status', { status: VerificationStatus.PENDING }); if (options?.experienceType) { query.andWhere('exp.experienceType = :type', { type: options.experienceType }); @@ -232,7 +260,8 @@ export class SystemExperiencePostgresRepository implements ISystemExperienceRepo limit?: number; }): Promise { const query = this.experienceRepo.createQueryBuilder('exp') - .where('exp.isActive = true'); + .where('exp.tenant_id = :tenantId', { tenantId: this.getTenantId() }) + .andWhere('exp.isActive = true'); if (options?.experienceType) { query.andWhere('exp.experienceType = :type', { type: options.experienceType }); @@ -263,6 +292,7 @@ export class SystemExperiencePostgresRepository implements ISystemExperienceRepo minSimilarity?: number; }, ): Promise> { + const tenantId = this.getTenantId(); const embeddingStr = `[${embedding.join(',')}]`; const limit = options?.limit || 5; const minSimilarity = options?.minSimilarity || 0.7; @@ -271,7 +301,8 @@ export class SystemExperiencePostgresRepository implements ISystemExperienceRepo SELECT *, 1 - (embedding <=> '${embeddingStr}'::vector) as similarity FROM system_experiences - WHERE embedding IS NOT NULL + WHERE tenant_id = $1 + AND embedding IS NOT NULL `; if (options?.activeOnly !== false) { @@ -288,7 +319,7 @@ export class SystemExperiencePostgresRepository implements ISystemExperienceRepo LIMIT ${limit} `; - const results = await this.experienceRepo.query(sql); + const results = await this.experienceRepo.query(sql, [tenantId]); return results.map((row: any) => ({ experience: this.toEntityFromRaw(row), @@ -300,28 +331,31 @@ export class SystemExperiencePostgresRepository implements ISystemExperienceRepo embedding: number[], threshold: number, ): Promise { + const tenantId = this.getTenantId(); const embeddingStr = `[${embedding.join(',')}]`; const sql = ` SELECT * FROM system_experiences - WHERE embedding IS NOT NULL + WHERE tenant_id = $1 + AND embedding IS NOT NULL AND 1 - (embedding <=> '${embeddingStr}'::vector) >= ${threshold} ORDER BY 1 - (embedding <=> '${embeddingStr}'::vector) DESC LIMIT 10 `; - const results = await this.experienceRepo.query(sql); + const results = await this.experienceRepo.query(sql, [tenantId]); return results.map((row: any) => this.toEntityFromRaw(row)); } async update(experience: SystemExperienceEntity): Promise { const orm = this.toORM(experience); + orm.tenantId = this.getTenantId(); await this.experienceRepo.save(orm); } async delete(id: string): Promise { - await this.experienceRepo.delete(id); + await this.experienceRepo.delete({ id, tenantId: this.getTenantId() }); } async getStatistics(): Promise<{ @@ -329,12 +363,17 @@ export class SystemExperiencePostgresRepository implements ISystemExperienceRepo byStatus: Record; byType: Record; }> { - const total = await this.experienceRepo.count(); + const tenantId = this.getTenantId(); + + const total = await this.experienceRepo.count({ + where: { tenantId }, + }); const statusCounts = await this.experienceRepo .createQueryBuilder('exp') .select('exp.verificationStatus', 'status') .addSelect('COUNT(*)', 'count') + .where('exp.tenant_id = :tenantId', { tenantId }) .groupBy('exp.verificationStatus') .getRawMany(); @@ -342,6 +381,7 @@ export class SystemExperiencePostgresRepository implements ISystemExperienceRepo .createQueryBuilder('exp') .select('exp.experienceType', 'type') .addSelect('COUNT(*)', 'count') + .where('exp.tenant_id = :tenantId', { tenantId }) .groupBy('exp.experienceType') .getRawMany(); @@ -361,6 +401,7 @@ export class SystemExperiencePostgresRepository implements ISystemExperienceRepo private toORM(entity: SystemExperienceEntity): SystemExperienceORM { const orm = new SystemExperienceORM(); orm.id = entity.id; + orm.tenantId = this.getTenantId(); orm.experienceType = entity.experienceType; orm.content = entity.content; orm.confidence = entity.confidence;