import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import type { CreateKnowledgeBaseRequest, CreateKnowledgeDocumentRequest, KnowledgeBaseDto, KnowledgeBaseOptionDto, KnowledgeDocumentDetailDto, KnowledgeDocumentDto, UpdateKnowledgeBaseRequest, UpdateKnowledgeDocumentRequest, } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; type ActorCtx = { actorId: bigint; isSuperAdmin: boolean }; const TEXT_EXT = /\.(txt|md|markdown|csv|json|log)$/i; @Injectable() export class AdminKnowledgeBasesService { constructor(private readonly prisma: PrismaService) {} async resolveActor(actorId: bigint): Promise { const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId }, select: { adminRole: true, status: true }, }); if (!account || account.status !== 'ACTIVE') { throw new ForbiddenException('账号不可用'); } return { actorId, isSuperAdmin: account.adminRole === 'SUPER_ADMIN', }; } async list( actor: ActorCtx, query: { name?: string; enabled?: string; page?: number; pageSize?: number }, ) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: { name?: { contains: string }; enabled?: boolean; createdByHqAccountId?: bigint; } = {}; if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId; if (query.name?.trim()) where.name = { contains: query.name.trim() }; if (query.enabled === 'true' || query.enabled === 'false') { where.enabled = query.enabled === 'true'; } const [items, total] = await Promise.all([ this.prisma.knowledgeBase.findMany({ where, orderBy: [{ id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, include: { _count: { select: { documents: true } } }, }), this.prisma.knowledgeBase.count({ where }), ]); const ownerIds = [...new Set(items.map((i) => i.createdByHqAccountId))]; const owners = ownerIds.length ? await this.prisma.hqAccount.findMany({ where: { id: { in: ownerIds } }, select: { id: true, name: true }, }) : []; const ownerMap = new Map(owners.map((o) => [o.id.toString(), o.name])); return serializeBigInt({ items: items.map((row) => this.toKbDto(row, actor, ownerMap.get(row.createdByHqAccountId.toString()) ?? null, row._count.documents), ), total, page, pageSize, }); } async options(actor: ActorCtx): Promise { const where: { enabled: boolean; createdByHqAccountId?: bigint } = { enabled: true }; if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId; const rows = await this.prisma.knowledgeBase.findMany({ where, orderBy: [{ id: 'desc' }], include: { _count: { select: { documents: true } } }, }); return rows.map((r) => ({ id: r.id.toString(), name: r.name, enabled: r.enabled, documentCount: r._count.documents, })); } async detail(actor: ActorCtx, id: bigint) { const row = await this.requireKb(actor, id); const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } }); const owner = await this.prisma.hqAccount.findUnique({ where: { id: row.createdByHqAccountId }, select: { name: true }, }); return this.toKbDto(row, actor, owner?.name ?? null, count); } async create(actor: ActorCtx, dto: CreateKnowledgeBaseRequest) { const name = dto.name?.trim(); if (!name) throw new BadRequestException('请填写名称'); const row = await this.prisma.knowledgeBase.create({ data: { name, description: dto.description?.trim() || null, enabled: dto.enabled !== false, createdByHqAccountId: actor.actorId, }, }); return this.toKbDto(row, actor, null, 0); } async update(actor: ActorCtx, id: bigint, dto: UpdateKnowledgeBaseRequest) { const row = await this.requireKb(actor, id); this.requireWrite(actor, row); if (!actor.isSuperAdmin) { // 创建人可改名称/描述/启用 const data: { name?: string; description?: string | null; enabled?: boolean; } = {}; if (dto.name !== undefined) { const name = dto.name.trim(); if (!name) throw new BadRequestException('名称不能为空'); data.name = name; } if (dto.description !== undefined) data.description = dto.description?.trim() || null; if (dto.enabled !== undefined) data.enabled = dto.enabled; const updated = await this.prisma.knowledgeBase.update({ where: { id }, data }); const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } }); return this.toKbDto(updated, actor, null, count); } const data: { name?: string; description?: string | null; enabled?: boolean; } = {}; if (dto.name !== undefined) { const name = dto.name.trim(); if (!name) throw new BadRequestException('名称不能为空'); data.name = name; } if (dto.description !== undefined) data.description = dto.description?.trim() || null; if (dto.enabled !== undefined) data.enabled = dto.enabled; const updated = await this.prisma.knowledgeBase.update({ where: { id }, data }); const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } }); return this.toKbDto(updated, actor, null, count); } async remove(actor: ActorCtx, id: bigint) { const row = await this.requireKb(actor, id); this.requireWrite(actor, row); await this.prisma.knowledgeBase.delete({ where: { id } }); return { ok: true }; } async listDocuments(actor: ActorCtx, kbId: bigint) { await this.requireKb(actor, kbId); const rows = await this.prisma.knowledgeDocument.findMany({ where: { knowledgeBaseId: kbId }, orderBy: [{ id: 'desc' }], }); return serializeBigInt({ items: rows.map((r) => this.toDocDto(r)) }); } async addDocument(actor: ActorCtx, kbId: bigint, dto: CreateKnowledgeDocumentRequest) { const kb = await this.requireKb(actor, kbId); this.requireWrite(actor, kb); const title = dto.title?.trim(); if (!title) throw new BadRequestException('请填写标题'); let contentText = dto.contentText?.trim() || ''; let status: 'READY' | 'EMPTY' | 'FAILED' = 'EMPTY'; let errorMessage: string | null = null; if (contentText) { status = 'READY'; } else if (dto.fileUrl?.trim()) { const fileName = dto.fileName?.trim() || ''; if (TEXT_EXT.test(fileName) || isLikelyTextMime(dto.mimeType)) { try { contentText = await fetchText(dto.fileUrl.trim()); status = contentText.trim() ? 'READY' : 'EMPTY'; if (!contentText.trim()) errorMessage = '文件内容为空'; } catch (e) { status = 'FAILED'; errorMessage = e instanceof Error ? e.message : String(e); } } else { status = 'EMPTY'; errorMessage = '非文本文件未抽取正文,请粘贴文本或上传 .txt/.md'; } } else { throw new BadRequestException('请粘贴正文或上传文件'); } const row = await this.prisma.knowledgeDocument.create({ data: { knowledgeBaseId: kbId, title, fileName: dto.fileName?.trim() || null, fileUrl: dto.fileUrl?.trim() || null, mimeType: dto.mimeType?.trim() || null, sizeBytes: dto.sizeBytes ?? null, contentText: contentText || null, status, errorMessage, }, }); return this.toDocDto(row); } async getDocument(actor: ActorCtx, kbId: bigint, docId: bigint): Promise { await this.requireKb(actor, kbId); const doc = await this.prisma.knowledgeDocument.findFirst({ where: { id: docId, knowledgeBaseId: kbId }, }); if (!doc) throw new NotFoundException('文档不存在'); return { ...this.toDocDto(doc), contentText: doc.contentText, }; } async updateDocument( actor: ActorCtx, kbId: bigint, docId: bigint, dto: UpdateKnowledgeDocumentRequest, ) { const kb = await this.requireKb(actor, kbId); this.requireWrite(actor, kb); const doc = await this.prisma.knowledgeDocument.findFirst({ where: { id: docId, knowledgeBaseId: kbId }, }); if (!doc) throw new NotFoundException('文档不存在'); const data: { title?: string; fileName?: string | null; fileUrl?: string | null; mimeType?: string | null; sizeBytes?: number | null; contentText?: string | null; status?: 'READY' | 'EMPTY' | 'FAILED'; errorMessage?: string | null; } = {}; if (dto.title !== undefined) { const title = dto.title.trim(); if (!title) throw new BadRequestException('请填写标题'); data.title = title; } const hasContentInput = dto.contentText !== undefined || dto.fileUrl !== undefined || dto.fileName !== undefined || dto.mimeType !== undefined; if (hasContentInput) { let contentText = dto.contentText !== undefined ? dto.contentText?.trim() || '' : doc.contentText?.trim() || ''; let status: 'READY' | 'EMPTY' | 'FAILED' = doc.status as 'READY' | 'EMPTY' | 'FAILED'; let errorMessage: string | null = doc.errorMessage; if (dto.contentText !== undefined) { if (contentText) { status = 'READY'; errorMessage = null; } else if (!dto.fileUrl?.trim() && !doc.fileUrl) { status = 'EMPTY'; errorMessage = null; } data.contentText = contentText || null; } if (dto.fileUrl !== undefined) data.fileUrl = dto.fileUrl?.trim() || null; if (dto.fileName !== undefined) data.fileName = dto.fileName?.trim() || null; if (dto.mimeType !== undefined) data.mimeType = dto.mimeType?.trim() || null; if (dto.sizeBytes !== undefined) data.sizeBytes = dto.sizeBytes ?? null; const fileUrl = dto.fileUrl?.trim() || doc.fileUrl; const fileName = dto.fileName?.trim() || doc.fileName || ''; const mimeType = dto.mimeType?.trim() || doc.mimeType; if (dto.fileUrl?.trim() && dto.contentText === undefined) { if (TEXT_EXT.test(fileName) || isLikelyTextMime(mimeType)) { try { contentText = await fetchText(dto.fileUrl.trim()); data.contentText = contentText || null; status = contentText.trim() ? 'READY' : 'EMPTY'; errorMessage = contentText.trim() ? null : '文件内容为空'; } catch (e) { status = 'FAILED'; errorMessage = e instanceof Error ? e.message : String(e); } } else { status = 'EMPTY'; errorMessage = '非文本文件未抽取正文,请粘贴文本或上传 .txt/.md'; } } else if (dto.contentText !== undefined && contentText) { status = 'READY'; errorMessage = null; } else if (dto.contentText !== undefined && !contentText && !fileUrl) { status = 'EMPTY'; errorMessage = null; } data.status = status; data.errorMessage = errorMessage; } const updated = await this.prisma.knowledgeDocument.update({ where: { id: docId }, data, }); return this.toDocDto(updated); } async removeDocument(actor: ActorCtx, kbId: bigint, docId: bigint) { const kb = await this.requireKb(actor, kbId); this.requireWrite(actor, kb); const doc = await this.prisma.knowledgeDocument.findFirst({ where: { id: docId, knowledgeBaseId: kbId }, }); if (!doc) throw new NotFoundException('文档不存在'); await this.prisma.knowledgeDocument.delete({ where: { id: docId } }); return { ok: true }; } private async requireKb(actor: ActorCtx, id: bigint) { const row = await this.prisma.knowledgeBase.findUnique({ where: { id } }); if (!row) throw new NotFoundException('知识库不存在'); if (!actor.isSuperAdmin && row.createdByHqAccountId !== actor.actorId) { throw new ForbiddenException('无权查看该知识库'); } return row; } private requireWrite( actor: ActorCtx, row: { createdByHqAccountId: bigint }, ) { if (actor.isSuperAdmin) return; if (row.createdByHqAccountId !== actor.actorId) { throw new ForbiddenException('只能操作自己创建的知识库'); } } private toKbDto( row: { id: bigint; name: string; description: string | null; enabled: boolean; createdByHqAccountId: bigint; createdAt: Date; updatedAt: Date; }, actor: ActorCtx, createdByName: string | null, documentCount: number, ): KnowledgeBaseDto { const isOwner = row.createdByHqAccountId === actor.actorId; return { id: row.id.toString(), name: row.name, description: row.description, enabled: row.enabled, documentCount, createdByHqAccountId: row.createdByHqAccountId.toString(), createdByName, isOwner, canEditFull: actor.isSuperAdmin || isOwner, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } private toDocDto(row: { id: bigint; knowledgeBaseId: bigint; title: string; fileName: string | null; fileUrl: string | null; mimeType: string | null; sizeBytes: number | null; contentText: string | null; status: string; errorMessage: string | null; createdAt: Date; updatedAt: Date; }): KnowledgeDocumentDto { const status = row.status === 'READY' || row.status === 'FAILED' || row.status === 'EMPTY' ? row.status : 'EMPTY'; return { id: row.id.toString(), knowledgeBaseId: row.knowledgeBaseId.toString(), title: row.title, fileName: row.fileName, fileUrl: row.fileUrl, mimeType: row.mimeType, sizeBytes: row.sizeBytes, hasContent: !!(row.contentText && row.contentText.trim()), status, errorMessage: row.errorMessage, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } } function isLikelyTextMime(mime?: string | null) { if (!mime) return false; return ( mime.startsWith('text/') || mime === 'application/json' || mime === 'application/markdown' ); } async function fetchText(url: string): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`下载文件失败 HTTP ${res.status}`); const buf = await res.arrayBuffer(); if (buf.byteLength > 2 * 1024 * 1024) throw new Error('文本文件超过 2MB'); return new TextDecoder('utf-8', { fatal: false }).decode(buf); }