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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-25 19:12:04 -08:00
parent 1df5854825
commit 92ee490a57
3 changed files with 119 additions and 35 deletions

View File

@ -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<AdminORM>,
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<void> {
const orm = this.toORM(admin);
orm.tenantId = this.getTenantId();
await this.adminRepo.save(orm);
}
async findById(id: string): Promise<AdminEntity | null> {
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<AdminEntity | null> {
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<AdminEntity[]> {
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<number> {
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<void> {
const orm = this.toORM(admin);
orm.tenantId = this.getTenantId();
await this.adminRepo.save(orm);
}
async delete(id: string): Promise<void> {
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;

View File

@ -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<KnowledgeArticleORM>,
@InjectRepository(KnowledgeChunkORM)
private chunkRepo: Repository<KnowledgeChunkORM>,
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<void> {
const orm = this.toArticleORM(article);
orm.tenantId = this.getTenantId();
await this.articleRepo.save(orm);
}
async findArticleById(id: string): Promise<KnowledgeArticleEntity | null> {
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<KnowledgeArticleEntity[]> {
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<KnowledgeArticleEntity[]> {
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<Array<{ article: KnowledgeArticleEntity; similarity: number }>> {
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<void> {
const orm = this.toArticleORM(article);
orm.tenantId = this.getTenantId();
await this.articleRepo.save(orm);
}
async deleteArticle(id: string): Promise<void> {
await this.articleRepo.delete(id);
await this.articleRepo.delete({ id, tenantId: this.getTenantId() });
}
async countArticles(options?: { category?: string; publishedOnly?: boolean }): Promise<number> {
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<void> {
const orm = this.toChunkORM(chunk);
orm.tenantId = this.getTenantId();
await this.chunkRepo.save(orm);
}
async saveChunks(chunks: KnowledgeChunkEntity[]): Promise<void> {
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<KnowledgeChunkEntity[]> {
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<Array<{ chunk: KnowledgeChunkEntity; similarity: number }>> {
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<void> {
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;

View File

@ -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<UserMemoryORM>,
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<void> {
const orm = this.toORM(memory);
orm.tenantId = this.getTenantId();
await this.memoryRepo.save(orm);
}
async findById(id: string): Promise<UserMemoryEntity | null> {
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<UserMemoryEntity[]> {
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<Array<{ memory: UserMemoryEntity; similarity: number }>> {
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<UserMemoryEntity[]> {
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<void> {
const orm = this.toORM(memory);
orm.tenantId = this.getTenantId();
await this.memoryRepo.save(orm);
}
async delete(id: string): Promise<void> {
await this.memoryRepo.delete(id);
await this.memoryRepo.delete({ id, tenantId: this.getTenantId() });
}
async deleteByUserId(userId: string): Promise<void> {
await this.memoryRepo.delete({ userId });
await this.memoryRepo.delete({ userId, tenantId: this.getTenantId() });
}
async markExpiredMemories(userId: string, olderThanDays: number): Promise<number> {
@ -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<SystemExperienceORM>,
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<void> {
const orm = this.toORM(experience);
orm.tenantId = this.getTenantId();
await this.experienceRepo.save(orm);
}
async findById(id: string): Promise<SystemExperienceEntity | null> {
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<SystemExperienceEntity[]> {
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<SystemExperienceEntity[]> {
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<Array<{ experience: SystemExperienceEntity; similarity: number }>> {
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<SystemExperienceEntity[]> {
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<void> {
const orm = this.toORM(experience);
orm.tenantId = this.getTenantId();
await this.experienceRepo.save(orm);
}
async delete(id: string): Promise<void> {
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<VerificationStatus, number>;
byType: Record<ExperienceType, number>;
}> {
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;