2026-05-24 11:42:41 +08:00
|
|
|
import { Injectable, BadRequestException, NotFoundException, ForbiddenException, Optional } from '@nestjs/common';
|
2026-05-09 18:25:04 +08:00
|
|
|
import { KnowledgeBaseRepository } from './knowledge-base.repository';
|
2026-05-24 11:23:58 +08:00
|
|
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
|
|
|
|
import { ContentSafetyService } from '../content-safety/content-safety.service';
|
2026-05-28 10:48:34 +08:00
|
|
|
import { StorageService } from '../../infrastructure/storage/storage.service';
|
2026-05-09 18:25:04 +08:00
|
|
|
import { MAX_KNOWLEDGE_BASE_COUNT } from './constants/knowledge-base.constants';
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class KnowledgeBaseService {
|
2026-05-24 11:23:58 +08:00
|
|
|
constructor(
|
|
|
|
|
private readonly repository: KnowledgeBaseRepository,
|
|
|
|
|
private readonly prisma: PrismaService,
|
2026-05-28 10:48:34 +08:00
|
|
|
private readonly storage: StorageService,
|
2026-05-24 11:42:41 +08:00
|
|
|
@Optional() private readonly safety?: ContentSafetyService,
|
2026-05-24 11:23:58 +08:00
|
|
|
) {}
|
2026-05-09 18:25:04 +08:00
|
|
|
|
|
|
|
|
async create(userId: string, dto: any) {
|
|
|
|
|
const count = await this.repository.countByUserId(userId);
|
|
|
|
|
if (count >= MAX_KNOWLEDGE_BASE_COUNT) {
|
|
|
|
|
throw new BadRequestException('知识库数量已达到上限');
|
|
|
|
|
}
|
2026-05-24 11:23:58 +08:00
|
|
|
if (dto.title) {
|
|
|
|
|
const check = await this.safety?.check(dto.title, { userId, contentType: 'kb-title' });
|
|
|
|
|
if (check && !check.safe) throw new ForbiddenException('知识库名称包含违规内容');
|
|
|
|
|
}
|
2026-05-28 10:48:34 +08:00
|
|
|
return this.enrichWithCoverUrl(await this.repository.create(userId, dto));
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:24:21 +08:00
|
|
|
async findAll(userId: string, opts?: { page?: number; limit?: number; visibility?: string; ownerType?: string }) {
|
|
|
|
|
const kbs = await this.repository.findAllByUserId(userId, opts);
|
2026-05-28 10:48:34 +08:00
|
|
|
return Promise.all(kbs.map(kb => this.enrichWithCoverUrl(kb)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async enrichWithCoverUrl(kb: any) {
|
|
|
|
|
if (kb.coverKey) {
|
|
|
|
|
try {
|
|
|
|
|
kb.coverUrl = await this.storage.getDownloadUrl(kb.coverKey, 86400);
|
|
|
|
|
} catch {
|
|
|
|
|
kb.coverUrl = null;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
kb.coverUrl = null;
|
|
|
|
|
}
|
|
|
|
|
return kb;
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async findOne(userId: string, id: string) {
|
|
|
|
|
const kb = await this.repository.findById(id);
|
2026-05-29 19:24:21 +08:00
|
|
|
if (!kb || kb.deletedAt) throw new NotFoundException('知识库不存在');
|
|
|
|
|
// 公开库允许任何人查看
|
|
|
|
|
if (kb.visibility === 'public') return kb;
|
|
|
|
|
// 私有库只能 owner 查看
|
|
|
|
|
if (String(kb.userId) !== userId) throw new NotFoundException('知识库不存在');
|
2026-05-09 18:25:04 +08:00
|
|
|
return kb;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async update(userId: string, id: string, dto: any) {
|
|
|
|
|
const kb = await this.repository.findById(id);
|
2026-05-09 18:57:33 +08:00
|
|
|
if (!kb || String(kb.userId) !== userId) {
|
2026-05-09 18:25:04 +08:00
|
|
|
throw new NotFoundException('知识库不存在');
|
|
|
|
|
}
|
2026-05-24 11:23:58 +08:00
|
|
|
if (dto.title) {
|
|
|
|
|
const check = await this.safety?.check(dto.title, { userId, contentType: 'kb-title' });
|
|
|
|
|
if (check && !check.safe) throw new ForbiddenException('知识库名称包含违规内容');
|
|
|
|
|
}
|
2026-05-09 18:25:04 +08:00
|
|
|
return this.repository.update(id, dto);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async remove(userId: string, id: string) {
|
|
|
|
|
const kb = await this.repository.findById(id);
|
2026-05-09 18:57:33 +08:00
|
|
|
if (!kb || String(kb.userId) !== userId) {
|
2026-05-09 18:25:04 +08:00
|
|
|
throw new NotFoundException('知识库不存在');
|
|
|
|
|
}
|
|
|
|
|
return this.repository.softDelete(id);
|
|
|
|
|
}
|
2026-05-24 11:23:58 +08:00
|
|
|
|
2026-05-29 19:24:21 +08:00
|
|
|
// ── Pin ──
|
|
|
|
|
|
|
|
|
|
async togglePin(userId: string, id: string) {
|
|
|
|
|
const kb = await this.repository.findById(id);
|
|
|
|
|
if (!kb || String(kb.userId) !== userId) throw new NotFoundException('知识库不存在');
|
|
|
|
|
return this.repository.togglePin(id, !kb.isPinned);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Visibility ──
|
|
|
|
|
|
|
|
|
|
async setVisibility(userId: string, id: string, visibility: string) {
|
|
|
|
|
const kb = await this.repository.findById(id);
|
|
|
|
|
if (!kb || String(kb.userId) !== userId) throw new NotFoundException('知识库不存在');
|
|
|
|
|
if (!['private', 'public'].includes(visibility)) throw new BadRequestException('visibility 必须为 private 或 public');
|
|
|
|
|
return this.repository.setVisibility(id, visibility);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Subscribe ──
|
|
|
|
|
|
|
|
|
|
async subscribe(userId: string, knowledgeBaseId: string) {
|
|
|
|
|
const kb = await this.repository.findById(knowledgeBaseId);
|
|
|
|
|
if (!kb || kb.deletedAt) throw new NotFoundException('知识库不存在');
|
|
|
|
|
if (kb.visibility !== 'public') throw new BadRequestException('只能订阅公开知识库');
|
|
|
|
|
if (kb.userId === userId) throw new BadRequestException('不能订阅自己的知识库');
|
|
|
|
|
return this.repository.subscribe(userId, knowledgeBaseId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async unsubscribe(userId: string, knowledgeBaseId: string) {
|
|
|
|
|
return this.repository.unsubscribe(userId, knowledgeBaseId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async listSubscribed(userId: string, opts?: { page?: number; limit?: number }) {
|
|
|
|
|
const kbs = await this.repository.findSubscribed(userId, opts);
|
|
|
|
|
return Promise.all(kbs.map(kb => this.enrichWithCoverUrl(kb)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Discover ──
|
|
|
|
|
|
|
|
|
|
async discoverPublic(opts?: { page?: number; limit?: number; search?: string }) {
|
|
|
|
|
const kbs = await this.repository.findAllPublic(opts);
|
|
|
|
|
return Promise.all(kbs.map(kb => this.enrichWithCoverUrl(kb)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 11:23:58 +08:00
|
|
|
// ── Folder CRUD ──
|
|
|
|
|
|
|
|
|
|
async createFolder(kbId: string, dto: { name: string; parentId?: string }) {
|
|
|
|
|
return this.prisma.knowledgeFolder.create({
|
|
|
|
|
data: { knowledgeBaseId: kbId, name: dto.name, parentId: dto.parentId || null },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getFolders(kbId: string) {
|
|
|
|
|
return this.prisma.knowledgeFolder.findMany({
|
|
|
|
|
where: { knowledgeBaseId: kbId, deletedAt: null },
|
|
|
|
|
orderBy: { sortOrder: 'asc' },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async updateFolder(folderId: string, dto: { name?: string; parentId?: string | null }) {
|
|
|
|
|
return this.prisma.knowledgeFolder.update({ where: { id: folderId }, data: dto });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async deleteFolder(folderId: string) {
|
|
|
|
|
// Soft-delete folder and children
|
|
|
|
|
await this.prisma.knowledgeFolder.updateMany({
|
|
|
|
|
where: { OR: [{ id: folderId }, { parentId: folderId }] },
|
|
|
|
|
data: { deletedAt: new Date() },
|
|
|
|
|
});
|
|
|
|
|
return { success: true };
|
|
|
|
|
}
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|