feat(admin): Implement System Knowledge Base management module
Features:
- Backend: SystemKbService with full CRUD (knowledge bases + documents)
- Backend: 8 RESTful API endpoints (list/detail/create/update/delete/upload/download)
- Backend: OSS storage integration (system/knowledge-bases/{kbId}/{docId})
- Backend: RAG engine integration (document parsing, chunking, vectorization)
- Frontend: SystemKbListPage with card-based layout
- Frontend: SystemKbDetailPage with document management table
- Frontend: Master-Detail UX pattern for better user experience
- Document upload (single/batch), download (preserving original filename), delete
Technical:
- Database migration for system_knowledge_bases and system_kb_documents tables
- OSSAdapter.getSignedUrl with Content-Disposition for original filename
- Reuse RAG engine from common/rag for document processing
Tested: Local environment verified, all features working
This commit is contained in:
7
backend/src/modules/admin/system-kb/index.ts
Normal file
7
backend/src/modules/admin/system-kb/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 系统知识库模块导出
|
||||
*/
|
||||
|
||||
export { systemKbRoutes } from './systemKbRoutes.js';
|
||||
export { SystemKbService, getSystemKbService } from './systemKbService.js';
|
||||
export * from './systemKbController.js';
|
||||
337
backend/src/modules/admin/system-kb/systemKbController.ts
Normal file
337
backend/src/modules/admin/system-kb/systemKbController.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 系统知识库控制器
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { getSystemKbService } from './systemKbService.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
interface CreateKbBody {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface UpdateKbBody {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface ListKbQuery {
|
||||
category?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface KbIdParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface DocIdParams {
|
||||
id: string;
|
||||
docId: string;
|
||||
}
|
||||
|
||||
// ==================== 控制器函数 ====================
|
||||
|
||||
/**
|
||||
* 获取知识库列表
|
||||
*/
|
||||
export async function listKnowledgeBases(
|
||||
request: FastifyRequest<{ Querystring: ListKbQuery }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { category, status } = request.query;
|
||||
const service = getSystemKbService(prisma);
|
||||
const kbs = await service.listKnowledgeBases({ category, status });
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: kbs,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取知识库列表失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库详情
|
||||
*/
|
||||
export async function getKnowledgeBase(
|
||||
request: FastifyRequest<{ Params: KbIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const service = getSystemKbService(prisma);
|
||||
const kb = await service.getKnowledgeBase(id);
|
||||
|
||||
if (!kb) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: '知识库不存在',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: kb,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取知识库详情失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建知识库
|
||||
*/
|
||||
export async function createKnowledgeBase(
|
||||
request: FastifyRequest<{ Body: CreateKbBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { code, name, description, category } = request.body;
|
||||
|
||||
if (!code || !name) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '编码和名称为必填项',
|
||||
});
|
||||
}
|
||||
|
||||
const service = getSystemKbService(prisma);
|
||||
const kb = await service.createKnowledgeBase({ code, name, description, category });
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: kb,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('创建知识库失败', { error: message });
|
||||
|
||||
if (message.includes('已存在')) {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新知识库
|
||||
*/
|
||||
export async function updateKnowledgeBase(
|
||||
request: FastifyRequest<{ Params: KbIdParams; Body: UpdateKbBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const { name, description, category } = request.body;
|
||||
|
||||
const service = getSystemKbService(prisma);
|
||||
const kb = await service.updateKnowledgeBase(id, { name, description, category });
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: kb,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('更新知识库失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除知识库
|
||||
*/
|
||||
export async function deleteKnowledgeBase(
|
||||
request: FastifyRequest<{ Params: KbIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const service = getSystemKbService(prisma);
|
||||
await service.deleteKnowledgeBase(id);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '删除成功',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('删除知识库失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档列表
|
||||
*/
|
||||
export async function listDocuments(
|
||||
request: FastifyRequest<{ Params: KbIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const service = getSystemKbService(prisma);
|
||||
const docs = await service.listDocuments(id);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: docs,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取文档列表失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文档
|
||||
*/
|
||||
export async function uploadDocument(
|
||||
request: FastifyRequest<{ Params: KbIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id: kbId } = request.params;
|
||||
|
||||
// 处理 multipart 文件上传
|
||||
const data = await request.file();
|
||||
|
||||
if (!data) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '请上传文件',
|
||||
});
|
||||
}
|
||||
|
||||
const filename = data.filename;
|
||||
const fileBuffer = await data.toBuffer();
|
||||
|
||||
// 验证文件类型
|
||||
const allowedTypes = ['pdf', 'docx', 'doc', 'txt', 'md'];
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
if (!ext || !allowedTypes.includes(ext)) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: `不支持的文件类型: ${ext},支持: ${allowedTypes.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 验证文件大小(最大 50MB)
|
||||
const maxSize = 50 * 1024 * 1024;
|
||||
if (fileBuffer.length > maxSize) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '文件大小超过限制(最大 50MB)',
|
||||
});
|
||||
}
|
||||
|
||||
const service = getSystemKbService(prisma);
|
||||
const result = await service.uploadDocument(kbId, filename, fileBuffer);
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: {
|
||||
docId: result.docId,
|
||||
filename,
|
||||
chunkCount: result.ingestResult.chunkCount,
|
||||
tokenCount: result.ingestResult.tokenCount,
|
||||
duration: result.ingestResult.duration,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('上传文档失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档
|
||||
*/
|
||||
export async function deleteDocument(
|
||||
request: FastifyRequest<{ Params: DocIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id: kbId, docId } = request.params;
|
||||
const service = getSystemKbService(prisma);
|
||||
await service.deleteDocument(kbId, docId);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '删除成功',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('删除文档失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文档
|
||||
*/
|
||||
export async function downloadDocument(
|
||||
request: FastifyRequest<{ Params: DocIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { id: kbId, docId } = request.params;
|
||||
const service = getSystemKbService(prisma);
|
||||
const result = await service.getDocumentDownloadUrl(kbId, docId);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取下载链接失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
75
backend/src/modules/admin/system-kb/systemKbRoutes.ts
Normal file
75
backend/src/modules/admin/system-kb/systemKbRoutes.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 系统知识库路由
|
||||
*
|
||||
* 路由前缀:/api/v1/admin/system-kb
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
listKnowledgeBases,
|
||||
getKnowledgeBase,
|
||||
createKnowledgeBase,
|
||||
updateKnowledgeBase,
|
||||
deleteKnowledgeBase,
|
||||
listDocuments,
|
||||
uploadDocument,
|
||||
deleteDocument,
|
||||
downloadDocument,
|
||||
} from './systemKbController.js';
|
||||
import { authenticate, requireRoles } from '../../../common/auth/auth.middleware.js';
|
||||
|
||||
export async function systemKbRoutes(fastify: FastifyInstance) {
|
||||
// 所有路由都需要认证 + SUPER_ADMIN 或 ADMIN 角色
|
||||
const preHandler = [authenticate, requireRoles('SUPER_ADMIN', 'ADMIN')];
|
||||
|
||||
// ==================== 知识库 CRUD ====================
|
||||
|
||||
// 获取知识库列表
|
||||
fastify.get('/', {
|
||||
preHandler,
|
||||
}, listKnowledgeBases as any);
|
||||
|
||||
// 创建知识库
|
||||
fastify.post('/', {
|
||||
preHandler,
|
||||
}, createKnowledgeBase as any);
|
||||
|
||||
// 获取知识库详情
|
||||
fastify.get('/:id', {
|
||||
preHandler,
|
||||
}, getKnowledgeBase as any);
|
||||
|
||||
// 更新知识库
|
||||
fastify.patch('/:id', {
|
||||
preHandler,
|
||||
}, updateKnowledgeBase as any);
|
||||
|
||||
// 删除知识库
|
||||
fastify.delete('/:id', {
|
||||
preHandler,
|
||||
}, deleteKnowledgeBase as any);
|
||||
|
||||
// ==================== 文档管理 ====================
|
||||
|
||||
// 获取文档列表
|
||||
fastify.get('/:id/documents', {
|
||||
preHandler,
|
||||
}, listDocuments as any);
|
||||
|
||||
// 上传文档
|
||||
fastify.post('/:id/documents', {
|
||||
preHandler,
|
||||
}, uploadDocument as any);
|
||||
|
||||
// 删除文档
|
||||
fastify.delete('/:id/documents/:docId', {
|
||||
preHandler,
|
||||
}, deleteDocument as any);
|
||||
|
||||
// 下载文档(获取签名 URL)
|
||||
fastify.get('/:id/documents/:docId/download', {
|
||||
preHandler,
|
||||
}, downloadDocument as any);
|
||||
}
|
||||
|
||||
export default systemKbRoutes;
|
||||
505
backend/src/modules/admin/system-kb/systemKbService.ts
Normal file
505
backend/src/modules/admin/system-kb/systemKbService.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* 系统知识库服务
|
||||
*
|
||||
* 运营管理端的公共知识库,供 Prompt 引用
|
||||
* 复用 RAG 引擎的核心能力
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import path from 'path';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { getDocumentIngestService, type IngestResult } from '../../../common/rag/index.js';
|
||||
import { storage, OSSAdapter } from '../../../common/storage/index.js';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface CreateKbInput {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface UpdateKbInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface SystemKb {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
documentCount: number;
|
||||
totalTokens: number;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SystemKbDocument {
|
||||
id: string;
|
||||
kbId: string;
|
||||
filename: string;
|
||||
filePath: string | null;
|
||||
fileSize: number | null;
|
||||
fileType: string | null;
|
||||
tokenCount: number;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// ==================== SystemKbService ====================
|
||||
|
||||
export class SystemKbService {
|
||||
private prisma: PrismaClient;
|
||||
|
||||
constructor(prisma: PrismaClient) {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库列表
|
||||
*/
|
||||
async listKnowledgeBases(options?: {
|
||||
category?: string;
|
||||
status?: string;
|
||||
}): Promise<SystemKb[]> {
|
||||
const where: any = {};
|
||||
if (options?.category) where.category = options.category;
|
||||
if (options?.status) where.status = options.status;
|
||||
|
||||
const kbs = await this.prisma.system_knowledge_bases.findMany({
|
||||
where,
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
|
||||
return kbs.map(kb => ({
|
||||
id: kb.id,
|
||||
code: kb.code,
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
category: kb.category,
|
||||
documentCount: kb.document_count,
|
||||
totalTokens: kb.total_tokens,
|
||||
status: kb.status,
|
||||
createdAt: kb.created_at,
|
||||
updatedAt: kb.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库详情
|
||||
*/
|
||||
async getKnowledgeBase(id: string): Promise<SystemKb | null> {
|
||||
const kb = await this.prisma.system_knowledge_bases.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!kb) return null;
|
||||
|
||||
return {
|
||||
id: kb.id,
|
||||
code: kb.code,
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
category: kb.category,
|
||||
documentCount: kb.document_count,
|
||||
totalTokens: kb.total_tokens,
|
||||
status: kb.status,
|
||||
createdAt: kb.created_at,
|
||||
updatedAt: kb.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建知识库
|
||||
*/
|
||||
async createKnowledgeBase(input: CreateKbInput): Promise<SystemKb> {
|
||||
const { code, name, description, category } = input;
|
||||
|
||||
// 检查 code 是否已存在
|
||||
const existing = await this.prisma.system_knowledge_bases.findUnique({
|
||||
where: { code },
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error(`知识库编码 ${code} 已存在`);
|
||||
}
|
||||
|
||||
// 使用事务同时创建两个表的记录
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
// 1. 创建系统知识库记录
|
||||
const systemKb = await tx.system_knowledge_bases.create({
|
||||
data: {
|
||||
code,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. 在 ekb_schema 创建对应的知识库记录(用于向量存储)
|
||||
await tx.ekbKnowledgeBase.create({
|
||||
data: {
|
||||
id: systemKb.id, // 使用相同的 ID
|
||||
name,
|
||||
description,
|
||||
type: 'SYSTEM',
|
||||
ownerId: code, // 使用 code 作为 ownerId
|
||||
config: {
|
||||
source: 'system_knowledge_base',
|
||||
systemKbCode: code,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return systemKb;
|
||||
});
|
||||
|
||||
logger.info(`创建系统知识库: ${code}`, { id: result.id });
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
code: result.code,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
category: result.category,
|
||||
documentCount: result.document_count,
|
||||
totalTokens: result.total_tokens,
|
||||
status: result.status,
|
||||
createdAt: result.created_at,
|
||||
updatedAt: result.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新知识库
|
||||
*/
|
||||
async updateKnowledgeBase(id: string, input: UpdateKbInput): Promise<SystemKb> {
|
||||
const kb = await this.prisma.system_knowledge_bases.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
category: input.category,
|
||||
},
|
||||
});
|
||||
|
||||
// 同步更新 ekb_knowledge_base
|
||||
await this.prisma.ekbKnowledgeBase.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
},
|
||||
}).catch(() => {
|
||||
// 忽略更新失败(可能不存在)
|
||||
});
|
||||
|
||||
return {
|
||||
id: kb.id,
|
||||
code: kb.code,
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
category: kb.category,
|
||||
documentCount: kb.document_count,
|
||||
totalTokens: kb.total_tokens,
|
||||
status: kb.status,
|
||||
createdAt: kb.created_at,
|
||||
updatedAt: kb.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除知识库
|
||||
*/
|
||||
async deleteKnowledgeBase(id: string): Promise<void> {
|
||||
// 使用事务删除
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// 1. 删除 ekb_schema 中的数据(级联删除 documents 和 chunks)
|
||||
await tx.ekbKnowledgeBase.delete({
|
||||
where: { id },
|
||||
}).catch(() => {
|
||||
// 忽略删除失败
|
||||
});
|
||||
|
||||
// 2. 删除系统知识库文档记录
|
||||
await tx.system_kb_documents.deleteMany({
|
||||
where: { kb_id: id },
|
||||
});
|
||||
|
||||
// 3. 删除系统知识库记录
|
||||
await tx.system_knowledge_bases.delete({
|
||||
where: { id },
|
||||
});
|
||||
});
|
||||
|
||||
logger.info(`删除系统知识库: ${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档列表
|
||||
*/
|
||||
async listDocuments(kbId: string): Promise<SystemKbDocument[]> {
|
||||
const docs = await this.prisma.system_kb_documents.findMany({
|
||||
where: { kb_id: kbId },
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
|
||||
return docs.map(doc => ({
|
||||
id: doc.id,
|
||||
kbId: doc.kb_id,
|
||||
filename: doc.filename,
|
||||
filePath: doc.file_path,
|
||||
fileSize: doc.file_size,
|
||||
fileType: doc.file_type,
|
||||
tokenCount: doc.token_count,
|
||||
status: doc.status,
|
||||
errorMessage: doc.error_message,
|
||||
createdAt: doc.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文档
|
||||
*
|
||||
* 流程:
|
||||
* 1. 创建文档记录获取 docId
|
||||
* 2. 上传文件到 OSS: system/knowledge-bases/{kbId}/{docId}.{ext}
|
||||
* 3. 调用 RAG 引擎入库(向量化)
|
||||
* 4. 更新文档状态和统计
|
||||
*/
|
||||
async uploadDocument(
|
||||
kbId: string,
|
||||
filename: string,
|
||||
fileBuffer: Buffer
|
||||
): Promise<{ docId: string; ingestResult: IngestResult }> {
|
||||
// 1. 检查知识库是否存在
|
||||
const kb = await this.prisma.system_knowledge_bases.findUnique({
|
||||
where: { id: kbId },
|
||||
});
|
||||
if (!kb) {
|
||||
throw new Error(`知识库 ${kbId} 不存在`);
|
||||
}
|
||||
|
||||
// 2. 创建文档记录(状态:pending),获取 docId
|
||||
const doc = await this.prisma.system_kb_documents.create({
|
||||
data: {
|
||||
kb_id: kbId,
|
||||
filename,
|
||||
file_size: fileBuffer.length,
|
||||
file_type: this.getFileType(filename),
|
||||
status: 'processing',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// 3. 生成 OSS 存储路径并上传
|
||||
const ossKey = this.generateOssKey(kbId, doc.id, filename);
|
||||
const ossUrl = await storage.upload(ossKey, fileBuffer);
|
||||
|
||||
// 4. 更新 file_path
|
||||
await this.prisma.system_kb_documents.update({
|
||||
where: { id: doc.id },
|
||||
data: { file_path: ossKey },
|
||||
});
|
||||
|
||||
logger.info(`文件已上传到 OSS: ${ossKey}`, { kbId, docId: doc.id });
|
||||
|
||||
// 5. 调用 RAG 引擎入库(向量化)
|
||||
const ingestService = getDocumentIngestService(this.prisma);
|
||||
const ingestResult = await ingestService.ingestDocument(
|
||||
{ filename, fileBuffer },
|
||||
{ kbId, contentType: 'REFERENCE' }
|
||||
);
|
||||
|
||||
if (ingestResult.success) {
|
||||
// 6. 更新文档状态为 ready
|
||||
await this.prisma.system_kb_documents.update({
|
||||
where: { id: doc.id },
|
||||
data: {
|
||||
status: 'ready',
|
||||
token_count: ingestResult.tokenCount || 0,
|
||||
content: null, // 内容存在 ekb_chunk 中
|
||||
},
|
||||
});
|
||||
|
||||
// 7. 更新知识库统计
|
||||
await this.prisma.system_knowledge_bases.update({
|
||||
where: { id: kbId },
|
||||
data: {
|
||||
document_count: { increment: 1 },
|
||||
total_tokens: { increment: ingestResult.tokenCount || 0 },
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`文档上传成功: ${filename}`, {
|
||||
kbId,
|
||||
docId: doc.id,
|
||||
ossKey,
|
||||
chunks: ingestResult.chunkCount,
|
||||
tokens: ingestResult.tokenCount,
|
||||
});
|
||||
} else {
|
||||
// 入库失败,但文件已上传到 OSS
|
||||
await this.prisma.system_kb_documents.update({
|
||||
where: { id: doc.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
error_message: ingestResult.error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { docId: doc.id, ingestResult };
|
||||
|
||||
} catch (error) {
|
||||
// 异常处理
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.prisma.system_kb_documents.update({
|
||||
where: { id: doc.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
error_message: errorMessage,
|
||||
},
|
||||
});
|
||||
|
||||
logger.error(`文档上传失败: ${filename}`, { error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档
|
||||
*
|
||||
* 流程:
|
||||
* 1. 删除 OSS 文件
|
||||
* 2. 删除 ekb_schema 向量数据
|
||||
* 3. 删除 system_kb_documents 记录
|
||||
* 4. 更新知识库统计
|
||||
*/
|
||||
async deleteDocument(kbId: string, docId: string): Promise<void> {
|
||||
const doc = await this.prisma.system_kb_documents.findFirst({
|
||||
where: { id: docId, kb_id: kbId },
|
||||
});
|
||||
|
||||
if (!doc) {
|
||||
throw new Error(`文档 ${docId} 不存在`);
|
||||
}
|
||||
|
||||
// 1. 删除 OSS 文件
|
||||
if (doc.file_path) {
|
||||
try {
|
||||
await storage.delete(doc.file_path);
|
||||
logger.info(`已删除 OSS 文件: ${doc.file_path}`);
|
||||
} catch (error) {
|
||||
// OSS 删除失败不阻塞流程,只记录警告
|
||||
logger.warn(`删除 OSS 文件失败: ${doc.file_path}`, { error });
|
||||
}
|
||||
}
|
||||
|
||||
// 使用事务删除数据库记录
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// 2. 查找 ekb_document(通过 filename 匹配)
|
||||
const ekbDoc = await tx.ekbDocument.findFirst({
|
||||
where: { kbId, filename: doc.filename },
|
||||
});
|
||||
|
||||
if (ekbDoc) {
|
||||
// 3. 删除 ekb_chunk(级联删除)
|
||||
await tx.ekbChunk.deleteMany({
|
||||
where: { documentId: ekbDoc.id },
|
||||
});
|
||||
|
||||
// 4. 删除 ekb_document
|
||||
await tx.ekbDocument.delete({
|
||||
where: { id: ekbDoc.id },
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 删除系统知识库文档记录
|
||||
await tx.system_kb_documents.delete({
|
||||
where: { id: docId },
|
||||
});
|
||||
|
||||
// 6. 更新知识库统计
|
||||
await tx.system_knowledge_bases.update({
|
||||
where: { id: kbId },
|
||||
data: {
|
||||
document_count: { decrement: 1 },
|
||||
total_tokens: { decrement: doc.token_count },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
logger.info(`删除文档: ${doc.filename}`, { kbId, docId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档下载 URL
|
||||
*/
|
||||
async getDocumentDownloadUrl(kbId: string, docId: string): Promise<{
|
||||
url: string;
|
||||
filename: string;
|
||||
fileSize: number | null;
|
||||
}> {
|
||||
const doc = await this.prisma.system_kb_documents.findFirst({
|
||||
where: { id: docId, kb_id: kbId },
|
||||
});
|
||||
|
||||
if (!doc) {
|
||||
throw new Error(`文档 ${docId} 不存在`);
|
||||
}
|
||||
|
||||
if (!doc.file_path) {
|
||||
throw new Error('文档文件路径不存在');
|
||||
}
|
||||
|
||||
// 获取签名 URL(有效期 1 小时),传入原始文件名以设置 Content-Disposition
|
||||
// 这样浏览器下载时会使用原始文件名而不是 UUID
|
||||
const ossAdapter = storage as OSSAdapter;
|
||||
const url = ossAdapter.getSignedUrl(doc.file_path, 3600, doc.filename);
|
||||
|
||||
return {
|
||||
url,
|
||||
filename: doc.filename,
|
||||
fileSize: doc.file_size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OSS 存储路径
|
||||
*
|
||||
* 格式:system/knowledge-bases/{kbId}/{docId}.{ext}
|
||||
*
|
||||
* @param kbId - 知识库 ID
|
||||
* @param docId - 文档 ID
|
||||
* @param filename - 原始文件名(用于获取扩展名)
|
||||
*/
|
||||
private generateOssKey(kbId: string, docId: string, filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return `system/knowledge-bases/${kbId}/${docId}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型
|
||||
*/
|
||||
private getFileType(filename: string): string {
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
return ext || 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 单例工厂 ====================
|
||||
|
||||
let serviceInstance: SystemKbService | null = null;
|
||||
|
||||
export function getSystemKbService(prisma: PrismaClient): SystemKbService {
|
||||
if (!serviceInstance) {
|
||||
serviceInstance = new SystemKbService(prisma);
|
||||
}
|
||||
return serviceInstance;
|
||||
}
|
||||
Reference in New Issue
Block a user