diff --git a/backend/package-lock.json b/backend/package-lock.json index 69a370d9..207f0f50 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@fastify/cors": "^11.1.0", "@fastify/jwt": "^10.0.0", + "@fastify/multipart": "^9.2.1", "@prisma/client": "^6.17.0", "@types/form-data": "^2.2.1", "axios": "^1.12.2", @@ -507,6 +508,12 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@fastify/cors": { "version": "11.1.0", "resolved": "https://registry.npmmirror.com/@fastify/cors/-/cors-11.1.0.tgz", @@ -527,6 +534,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@fastify/deepmerge/-/deepmerge-3.1.0.tgz", + "integrity": "sha512-lCVONBQINyNhM6LLezB6+2afusgEYR4G8xenMsfe+AT+iZ7Ca6upM5Ha8UkZuYSnuMw3GWl/BiPXnLMi/gSxuQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/@fastify/error/-/error-4.2.0.tgz", @@ -620,6 +643,29 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "9.2.1", + "resolved": "https://registry.npmmirror.com/@fastify/multipart/-/multipart-9.2.1.tgz", + "integrity": "sha512-U4221XDMfzCUtfzsyV1/PkR4MNgKI0158vUUyn/oF2Tl6RxMc+N7XYLr5fZXQiEC+Fmw5zFaTjxsTGTgtDtK+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "resolved": "https://registry.npmmirror.com/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index d89ce1c5..9e0bd6ff 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "dependencies": { "@fastify/cors": "^11.1.0", "@fastify/jwt": "^10.0.0", + "@fastify/multipart": "^9.2.1", "@prisma/client": "^6.17.0", "@types/form-data": "^2.2.1", "axios": "^1.12.2", diff --git a/backend/src/controllers/documentController.ts b/backend/src/controllers/documentController.ts new file mode 100644 index 00000000..9af1a68b --- /dev/null +++ b/backend/src/controllers/documentController.ts @@ -0,0 +1,246 @@ +import type { FastifyRequest, FastifyReply } from 'fastify'; +import * as documentService from '../services/documentService.js'; + +// Mock用户ID(实际应从JWT token中获取) +const MOCK_USER_ID = 'user-mock-001'; + +/** + * 上传文档 + */ +export async function uploadDocument( + request: FastifyRequest<{ + Params: { + kbId: string; + }; + }>, + reply: FastifyReply +) { + try { + const { kbId } = request.params; + + // 获取上传的文件 + const data = await request.file(); + + if (!data) { + return reply.status(400).send({ + success: false, + message: 'No file uploaded', + }); + } + + const file = await data.toBuffer(); + const filename = data.filename; + const fileType = data.mimetype; + const fileSizeBytes = file.length; + + // 文件大小限制(10MB) + if (fileSizeBytes > 10 * 1024 * 1024) { + return reply.status(400).send({ + success: false, + message: 'File size exceeds 10MB limit', + }); + } + + // 文件类型限制 + const allowedTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', + 'text/markdown', + ]; + + if (!allowedTypes.includes(fileType)) { + return reply.status(400).send({ + success: false, + message: 'File type not supported. Allowed: PDF, DOC, DOCX, TXT, MD', + }); + } + + // 上传文档(这里fileUrl暂时为空,实际应该上传到对象存储) + const document = await documentService.uploadDocument( + MOCK_USER_ID, + kbId, + file, + filename, + fileType, + fileSizeBytes, + '' // fileUrl - 可以上传到OSS后填入 + ); + + return reply.status(201).send({ + success: true, + data: document, + }); + } catch (error: any) { + console.error('Failed to upload document:', error); + + if (error.message.includes('not found') || error.message.includes('access denied')) { + return reply.status(404).send({ + success: false, + message: error.message, + }); + } + + if (error.message.includes('limit exceeded')) { + return reply.status(400).send({ + success: false, + message: error.message, + }); + } + + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to upload document', + }); + } +} + +/** + * 获取文档列表 + */ +export async function getDocuments( + request: FastifyRequest<{ + Params: { + kbId: string; + }; + }>, + reply: FastifyReply +) { + try { + const { kbId } = request.params; + + const documents = await documentService.getDocuments(MOCK_USER_ID, kbId); + + return reply.send({ + success: true, + data: documents, + }); + } catch (error: any) { + console.error('Failed to get documents:', error); + + if (error.message.includes('not found')) { + return reply.status(404).send({ + success: false, + message: error.message, + }); + } + + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to get documents', + }); + } +} + +/** + * 获取文档详情 + */ +export async function getDocumentById( + request: FastifyRequest<{ + Params: { + id: string; + }; + }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + + const document = await documentService.getDocumentById(MOCK_USER_ID, id); + + return reply.send({ + success: true, + data: document, + }); + } catch (error: any) { + console.error('Failed to get document:', error); + + if (error.message.includes('not found')) { + return reply.status(404).send({ + success: false, + message: error.message, + }); + } + + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to get document', + }); + } +} + +/** + * 删除文档 + */ +export async function deleteDocument( + request: FastifyRequest<{ + Params: { + id: string; + }; + }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + + await documentService.deleteDocument(MOCK_USER_ID, id); + + return reply.send({ + success: true, + message: 'Document deleted successfully', + }); + } catch (error: any) { + console.error('Failed to delete document:', error); + + if (error.message.includes('not found')) { + return reply.status(404).send({ + success: false, + message: error.message, + }); + } + + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to delete document', + }); + } +} + +/** + * 重新处理文档 + */ +export async function reprocessDocument( + request: FastifyRequest<{ + Params: { + id: string; + }; + }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + + await documentService.reprocessDocument(MOCK_USER_ID, id); + + return reply.send({ + success: true, + message: 'Document reprocessing started', + }); + } catch (error: any) { + console.error('Failed to reprocess document:', error); + + if (error.message.includes('not found')) { + return reply.status(404).send({ + success: false, + message: error.message, + }); + } + + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to reprocess document', + }); + } +} + diff --git a/backend/src/controllers/knowledgeBaseController.ts b/backend/src/controllers/knowledgeBaseController.ts new file mode 100644 index 00000000..4d3f7a01 --- /dev/null +++ b/backend/src/controllers/knowledgeBaseController.ts @@ -0,0 +1,291 @@ +import type { FastifyRequest, FastifyReply } from 'fastify'; +import * as knowledgeBaseService from '../services/knowledgeBaseService.js'; + +// Mock用户ID(实际应从JWT token中获取) +const MOCK_USER_ID = 'user-mock-001'; + +/** + * 创建知识库 + */ +export async function createKnowledgeBase( + request: FastifyRequest<{ + Body: { + name: string; + description?: string; + }; + }>, + reply: FastifyReply +) { + try { + const { name, description } = request.body; + + if (!name || name.trim().length === 0) { + return reply.status(400).send({ + success: false, + message: 'Knowledge base name is required', + }); + } + + const knowledgeBase = await knowledgeBaseService.createKnowledgeBase( + MOCK_USER_ID, + name, + description + ); + + return reply.status(201).send({ + success: true, + data: knowledgeBase, + }); + } catch (error: any) { + console.error('Failed to create knowledge base:', error); + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to create knowledge base', + }); + } +} + +/** + * 获取知识库列表 + */ +export async function getKnowledgeBases( + _request: FastifyRequest, + reply: FastifyReply +) { + try { + const knowledgeBases = await knowledgeBaseService.getKnowledgeBases( + MOCK_USER_ID + ); + + return reply.send({ + success: true, + data: knowledgeBases, + }); + } catch (error: any) { + console.error('Failed to get knowledge bases:', error); + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to get knowledge bases', + }); + } +} + +/** + * 获取知识库详情 + */ +export async function getKnowledgeBaseById( + request: FastifyRequest<{ + Params: { + id: string; + }; + }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + + const knowledgeBase = await knowledgeBaseService.getKnowledgeBaseById( + MOCK_USER_ID, + id + ); + + return reply.send({ + success: true, + data: knowledgeBase, + }); + } catch (error: any) { + console.error('Failed to get knowledge base:', error); + + if (error.message.includes('not found')) { + return reply.status(404).send({ + success: false, + message: error.message, + }); + } + + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to get knowledge base', + }); + } +} + +/** + * 更新知识库 + */ +export async function updateKnowledgeBase( + request: FastifyRequest<{ + Params: { + id: string; + }; + Body: { + name?: string; + description?: string; + }; + }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + const updateData = request.body; + + const knowledgeBase = await knowledgeBaseService.updateKnowledgeBase( + MOCK_USER_ID, + id, + updateData + ); + + return reply.send({ + success: true, + data: knowledgeBase, + }); + } catch (error: any) { + console.error('Failed to update knowledge base:', error); + + if (error.message.includes('not found')) { + return reply.status(404).send({ + success: false, + message: error.message, + }); + } + + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to update knowledge base', + }); + } +} + +/** + * 删除知识库 + */ +export async function deleteKnowledgeBase( + request: FastifyRequest<{ + Params: { + id: string; + }; + }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + + await knowledgeBaseService.deleteKnowledgeBase(MOCK_USER_ID, id); + + return reply.send({ + success: true, + message: 'Knowledge base deleted successfully', + }); + } catch (error: any) { + console.error('Failed to delete knowledge base:', error); + + if (error.message.includes('not found')) { + return reply.status(404).send({ + success: false, + message: error.message, + }); + } + + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to delete knowledge base', + }); + } +} + +/** + * 检索知识库 + */ +export async function searchKnowledgeBase( + request: FastifyRequest<{ + Params: { + id: string; + }; + Querystring: { + query: string; + top_k?: string; + }; + }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + const { query, top_k } = request.query; + + if (!query || query.trim().length === 0) { + return reply.status(400).send({ + success: false, + message: 'Query parameter is required', + }); + } + + const topK = top_k ? parseInt(top_k, 10) : 3; + + const results = await knowledgeBaseService.searchKnowledgeBase( + MOCK_USER_ID, + id, + query, + topK + ); + + return reply.send({ + success: true, + data: results, + }); + } catch (error: any) { + console.error('Failed to search knowledge base:', error); + + if (error.message.includes('not found')) { + return reply.status(404).send({ + success: false, + message: error.message, + }); + } + + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to search knowledge base', + }); + } +} + +/** + * 获取知识库统计信息 + */ +export async function getKnowledgeBaseStats( + request: FastifyRequest<{ + Params: { + id: string; + }; + }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + + const stats = await knowledgeBaseService.getKnowledgeBaseStats( + MOCK_USER_ID, + id + ); + + return reply.send({ + success: true, + data: stats, + }); + } catch (error: any) { + console.error('Failed to get knowledge base stats:', error); + + if (error.message.includes('not found')) { + return reply.status(404).send({ + success: false, + message: error.message, + }); + } + + return reply.status(500).send({ + success: false, + message: error.message || 'Failed to get knowledge base stats', + }); + } +} + diff --git a/backend/src/index.ts b/backend/src/index.ts index 6ecade4b..4b1ef2ba 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,10 +1,17 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; +import multipart from '@fastify/multipart'; import { config, validateEnv } from './config/env.js'; import { testDatabaseConnection, prisma } from './config/database.js'; import { projectRoutes } from './routes/projects.js'; import { agentRoutes } from './routes/agents.js'; import { conversationRoutes } from './routes/conversations.js'; +import knowledgeBaseRoutes from './routes/knowledgeBases.js'; + +// 全局处理BigInt序列化 +(BigInt.prototype as any).toJSON = function() { + return Number(this); +}; const fastify = Fastify({ logger: { @@ -26,6 +33,13 @@ await fastify.register(cors, { credentials: true, }); +// 注册文件上传插件 +await fastify.register(multipart, { + limits: { + fileSize: 10 * 1024 * 1024, // 10MB + }, +}); + // 健康检查路由 fastify.get('/health', async () => { // 检查数据库连接 @@ -63,6 +77,9 @@ await fastify.register(agentRoutes, { prefix: '/api/v1' }); // 注册对话管理路由 await fastify.register(conversationRoutes, { prefix: '/api/v1' }); +// 注册知识库管理路由 +await fastify.register(knowledgeBaseRoutes, { prefix: '/api/v1' }); + // 启动服务器 const start = async () => { try { diff --git a/backend/src/routes/knowledgeBases.ts b/backend/src/routes/knowledgeBases.ts new file mode 100644 index 00000000..3259d68f --- /dev/null +++ b/backend/src/routes/knowledgeBases.ts @@ -0,0 +1,46 @@ +import type { FastifyInstance } from 'fastify'; +import * as knowledgeBaseController from '../controllers/knowledgeBaseController.js'; +import * as documentController from '../controllers/documentController.js'; + +export default async function knowledgeBaseRoutes(fastify: FastifyInstance) { + // ==================== 知识库管理 API ==================== + + // 创建知识库 + fastify.post('/knowledge-bases', knowledgeBaseController.createKnowledgeBase); + + // 获取知识库列表 + fastify.get('/knowledge-bases', knowledgeBaseController.getKnowledgeBases); + + // 获取知识库详情 + fastify.get('/knowledge-bases/:id', knowledgeBaseController.getKnowledgeBaseById); + + // 更新知识库 + fastify.put('/knowledge-bases/:id', knowledgeBaseController.updateKnowledgeBase); + + // 删除知识库 + fastify.delete('/knowledge-bases/:id', knowledgeBaseController.deleteKnowledgeBase); + + // 检索知识库 + fastify.get('/knowledge-bases/:id/search', knowledgeBaseController.searchKnowledgeBase); + + // 获取知识库统计信息 + fastify.get('/knowledge-bases/:id/stats', knowledgeBaseController.getKnowledgeBaseStats); + + // ==================== 文档管理 API ==================== + + // 上传文档 + fastify.post('/knowledge-bases/:kbId/documents', documentController.uploadDocument); + + // 获取文档列表 + fastify.get('/knowledge-bases/:kbId/documents', documentController.getDocuments); + + // 获取文档详情 + fastify.get('/documents/:id', documentController.getDocumentById); + + // 删除文档 + fastify.delete('/documents/:id', documentController.deleteDocument); + + // 重新处理文档 + fastify.post('/documents/:id/reprocess', documentController.reprocessDocument); +} + diff --git a/backend/src/services/documentService.ts b/backend/src/services/documentService.ts new file mode 100644 index 00000000..62a29c18 --- /dev/null +++ b/backend/src/services/documentService.ts @@ -0,0 +1,327 @@ +import { prisma } from '../config/database.js'; +import { difyClient } from '../clients/DifyClient.js'; + +/** + * 文档服务 + */ + +/** + * 上传文档到知识库 + */ +export async function uploadDocument( + userId: string, + kbId: string, + file: Buffer, + filename: string, + fileType: string, + fileSizeBytes: number, + fileUrl: string +) { + // 1. 验证知识库权限 + const knowledgeBase = await prisma.knowledgeBase.findFirst({ + where: { + id: kbId, + userId, + }, + }); + + if (!knowledgeBase) { + throw new Error('Knowledge base not found or access denied'); + } + + // 2. 检查文档数量限制(每个知识库最多50个文档) + const documentCount = await prisma.document.count({ + where: { kbId }, + }); + + if (documentCount >= 50) { + throw new Error('Document limit exceeded. Maximum 50 documents per knowledge base'); + } + + // 3. 在数据库中创建文档记录(状态:uploading) + const document = await prisma.document.create({ + data: { + kbId, + userId, + filename, + fileType, + fileSizeBytes, + fileUrl, + difyDocumentId: '', // 暂时为空,稍后更新 + status: 'uploading', + progress: 0, + }, + }); + + try { + // 4. 上传到Dify + const difyResult = await difyClient.uploadDocumentDirectly( + knowledgeBase.difyDatasetId, + file, + filename + ); + + // 5. 更新文档记录(更新difyDocumentId和状态) + const updatedDocument = await prisma.document.update({ + where: { id: document.id }, + data: { + difyDocumentId: difyResult.document.id, + status: difyResult.document.indexing_status, + progress: 50, + }, + }); + + // 6. 启动后台轮询任务,等待处理完成 + pollDocumentStatus(userId, kbId, document.id, difyResult.document.id).catch(error => { + console.error('Failed to poll document status:', error); + }); + + // 7. 更新知识库统计 + await updateKnowledgeBaseStats(kbId); + + // 8. 转换BigInt为Number + return { + ...updatedDocument, + fileSizeBytes: Number(updatedDocument.fileSizeBytes), + }; + } catch (error) { + // 上传失败,更新状态为error + await prisma.document.update({ + where: { id: document.id }, + data: { + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Upload failed', + }, + }); + + throw error; + } +} + +/** + * 轮询文档处理状态 + */ +async function pollDocumentStatus( + userId: string, + kbId: string, + documentId: string, + difyDocumentId: string, + maxAttempts: number = 30 +) { + const knowledgeBase = await prisma.knowledgeBase.findFirst({ + where: { id: kbId, userId }, + }); + + if (!knowledgeBase) { + return; + } + + for (let i = 0; i < maxAttempts; i++) { + await new Promise(resolve => setTimeout(resolve, 2000)); // 等待2秒 + + try { + // 查询Dify中的文档状态 + const difyDocument = await difyClient.getDocument( + knowledgeBase.difyDatasetId, + difyDocumentId + ); + + // 更新数据库中的状态 + await prisma.document.update({ + where: { id: documentId }, + data: { + status: difyDocument.indexing_status, + progress: difyDocument.indexing_status === 'completed' ? 100 : 50 + (i * 2), + segmentsCount: difyDocument.indexing_status === 'completed' ? difyDocument.word_count : null, + tokensCount: difyDocument.indexing_status === 'completed' ? difyDocument.tokens : null, + processedAt: difyDocument.indexing_status === 'completed' ? new Date() : null, + errorMessage: difyDocument.error || null, + }, + }); + + // 如果完成或失败,退出轮询 + if (difyDocument.indexing_status === 'completed') { + await updateKnowledgeBaseStats(kbId); + break; + } + + if (difyDocument.indexing_status === 'error') { + break; + } + } catch (error) { + console.error(`Polling attempt ${i + 1} failed:`, error); + } + } +} + +/** + * 获取文档列表 + */ +export async function getDocuments(userId: string, kbId: string) { + // 1. 验证权限 + const knowledgeBase = await prisma.knowledgeBase.findFirst({ + where: { + id: kbId, + userId, + }, + }); + + if (!knowledgeBase) { + throw new Error('Knowledge base not found or access denied'); + } + + // 2. 查询文档列表 + const documents = await prisma.document.findMany({ + where: { kbId }, + orderBy: { uploadedAt: 'desc' }, + }); + + // 3. 转换BigInt为Number + return documents.map(doc => ({ + ...doc, + fileSizeBytes: Number(doc.fileSizeBytes), + })); +} + +/** + * 获取文档详情 + */ +export async function getDocumentById(userId: string, documentId: string) { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + userId, // 确保只能访问自己的文档 + }, + include: { + knowledgeBase: true, + }, + }); + + if (!document) { + throw new Error('Document not found or access denied'); + } + + // 转换BigInt为Number + return { + ...document, + fileSizeBytes: Number(document.fileSizeBytes), + knowledgeBase: { + ...document.knowledgeBase, + totalSizeBytes: Number(document.knowledgeBase.totalSizeBytes), + }, + }; +} + +/** + * 删除文档 + */ +export async function deleteDocument(userId: string, documentId: string) { + // 1. 查询文档信息 + const document = await prisma.document.findFirst({ + where: { + id: documentId, + userId, + }, + include: { + knowledgeBase: true, + }, + }); + + if (!document) { + throw new Error('Document not found or access denied'); + } + + // 2. 删除Dify中的文档 + if (document.difyDocumentId) { + try { + await difyClient.deleteDocument( + document.knowledgeBase.difyDatasetId, + document.difyDocumentId + ); + } catch (error) { + console.error('Failed to delete Dify document:', error); + // 继续删除本地记录 + } + } + + // 3. 删除数据库记录 + await prisma.document.delete({ + where: { id: documentId }, + }); + + // 4. 更新知识库统计 + await updateKnowledgeBaseStats(document.kbId); +} + +/** + * 重新处理文档 + */ +export async function reprocessDocument(userId: string, documentId: string) { + // 1. 查询文档信息 + const document = await prisma.document.findFirst({ + where: { + id: documentId, + userId, + }, + include: { + knowledgeBase: true, + }, + }); + + if (!document) { + throw new Error('Document not found or access denied'); + } + + // 2. 触发Dify重新索引 + if (document.difyDocumentId) { + try { + await difyClient.updateDocument( + document.knowledgeBase.difyDatasetId, + document.difyDocumentId + ); + + // 3. 更新状态为processing + await prisma.document.update({ + where: { id: documentId }, + data: { + status: 'parsing', + progress: 0, + errorMessage: null, + }, + }); + + // 4. 启动轮询 + pollDocumentStatus( + userId, + document.kbId, + documentId, + document.difyDocumentId + ).catch(error => { + console.error('Failed to poll document status:', error); + }); + } catch (error) { + throw new Error('Failed to reprocess document'); + } + } +} + +/** + * 更新知识库统计信息 + */ +async function updateKnowledgeBaseStats(kbId: string) { + const documents = await prisma.document.findMany({ + where: { kbId }, + }); + + const totalSizeBytes = documents.reduce((sum, d) => sum + Number(d.fileSizeBytes), 0); + const fileCount = documents.length; + + await prisma.knowledgeBase.update({ + where: { id: kbId }, + data: { + fileCount, + totalSizeBytes: BigInt(totalSizeBytes), + }, + }); +} + diff --git a/backend/src/services/knowledgeBaseService.ts b/backend/src/services/knowledgeBaseService.ts new file mode 100644 index 00000000..eae7cc10 --- /dev/null +++ b/backend/src/services/knowledgeBaseService.ts @@ -0,0 +1,261 @@ +import { prisma } from '../config/database.js'; +import { difyClient } from '../clients/DifyClient.js'; + +/** + * 知识库服务 + */ + +/** + * 创建知识库 + */ +export async function createKnowledgeBase( + userId: string, + name: string, + description?: string +) { + // 1. 检查用户知识库配额 + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { kbQuota: true, kbUsed: true } + }); + + if (!user) { + throw new Error('User not found'); + } + + if (user.kbUsed >= user.kbQuota) { + throw new Error(`Knowledge base quota exceeded. Maximum: ${user.kbQuota}`); + } + + // 2. 在Dify中创建Dataset + const difyDataset = await difyClient.createDataset({ + name: `${userId}_${name}_${Date.now()}`, + description: description || `Knowledge base for user ${userId}`, + indexing_technique: 'high_quality', + }); + + // 3. 在数据库中创建记录 + const knowledgeBase = await prisma.knowledgeBase.create({ + data: { + userId, + name, + description, + difyDatasetId: difyDataset.id, + }, + }); + + // 4. 更新用户的知识库使用计数 + await prisma.user.update({ + where: { id: userId }, + data: { + kbUsed: { increment: 1 }, + }, + }); + + // 5. 转换BigInt为Number + return { + ...knowledgeBase, + totalSizeBytes: Number(knowledgeBase.totalSizeBytes), + }; +} + +/** + * 获取用户的知识库列表 + */ +export async function getKnowledgeBases(userId: string) { + const knowledgeBases = await prisma.knowledgeBase.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + include: { + _count: { + select: { documents: true }, + }, + }, + }); + + // 转换BigInt为Number + return knowledgeBases.map(kb => ({ + ...kb, + totalSizeBytes: Number(kb.totalSizeBytes), + })); +} + +/** + * 获取知识库详情 + */ +export async function getKnowledgeBaseById(userId: string, kbId: string) { + const knowledgeBase = await prisma.knowledgeBase.findFirst({ + where: { + id: kbId, + userId, // 确保只能访问自己的知识库 + }, + include: { + documents: { + orderBy: { uploadedAt: 'desc' }, + }, + _count: { + select: { documents: true }, + }, + }, + }); + + if (!knowledgeBase) { + throw new Error('Knowledge base not found or access denied'); + } + + // 转换BigInt为Number + const result = { + ...knowledgeBase, + totalSizeBytes: Number(knowledgeBase.totalSizeBytes), + documents: knowledgeBase.documents.map(doc => ({ + ...doc, + fileSizeBytes: Number(doc.fileSizeBytes), + })), + }; + + return result; +} + +/** + * 更新知识库 + */ +export async function updateKnowledgeBase( + userId: string, + kbId: string, + data: { name?: string; description?: string } +) { + // 1. 验证权限 + const existingKb = await prisma.knowledgeBase.findFirst({ + where: { + id: kbId, + userId, + }, + }); + + if (!existingKb) { + throw new Error('Knowledge base not found or access denied'); + } + + // 2. 更新数据库 + const knowledgeBase = await prisma.knowledgeBase.update({ + where: { id: kbId }, + data, + }); + + // 3. 转换BigInt为Number + return { + ...knowledgeBase, + totalSizeBytes: Number(knowledgeBase.totalSizeBytes), + }; +} + +/** + * 删除知识库 + */ +export async function deleteKnowledgeBase(userId: string, kbId: string) { + // 1. 验证权限 + const knowledgeBase = await prisma.knowledgeBase.findFirst({ + where: { + id: kbId, + userId, + }, + }); + + if (!knowledgeBase) { + throw new Error('Knowledge base not found or access denied'); + } + + // 2. 删除Dify中的Dataset + try { + await difyClient.deleteDataset(knowledgeBase.difyDatasetId); + } catch (error) { + console.error('Failed to delete Dify dataset:', error); + // 继续删除本地记录,即使Dify删除失败 + } + + // 3. 删除数据库记录(会级联删除documents) + await prisma.knowledgeBase.delete({ + where: { id: kbId }, + }); + + // 4. 更新用户的知识库使用计数 + await prisma.user.update({ + where: { id: userId }, + data: { + kbUsed: { decrement: 1 }, + }, + }); +} + +/** + * 检索知识库 + */ +export async function searchKnowledgeBase( + userId: string, + kbId: string, + query: string, + topK: number = 3 +) { + // 1. 验证权限 + const knowledgeBase = await prisma.knowledgeBase.findFirst({ + where: { + id: kbId, + userId, + }, + }); + + if (!knowledgeBase) { + throw new Error('Knowledge base not found or access denied'); + } + + // 2. 调用Dify检索API + const results = await difyClient.retrieveKnowledge( + knowledgeBase.difyDatasetId, + query, + { + retrieval_model: { + search_method: 'semantic_search', + top_k: topK, + score_threshold_enabled: true, + score_threshold: 0.3, + }, + } + ); + + return results; +} + +/** + * 获取知识库统计信息 + */ +export async function getKnowledgeBaseStats(userId: string, kbId: string) { + // 1. 验证权限 + const knowledgeBase = await prisma.knowledgeBase.findFirst({ + where: { + id: kbId, + userId, + }, + include: { + documents: true, + }, + }); + + if (!knowledgeBase) { + throw new Error('Knowledge base not found or access denied'); + } + + // 2. 统计信息 + const stats = { + totalDocuments: knowledgeBase.documents.length, + completedDocuments: knowledgeBase.documents.filter(d => d.status === 'completed').length, + processingDocuments: knowledgeBase.documents.filter(d => + ['uploading', 'parsing', 'indexing'].includes(d.status) + ).length, + errorDocuments: knowledgeBase.documents.filter(d => d.status === 'error').length, + totalSizeBytes: knowledgeBase.totalSizeBytes, + totalTokens: knowledgeBase.documents.reduce((sum, d) => sum + (d.tokensCount || 0), 0), + }; + + return stats; +} + diff --git a/backend/test-kb-api.ps1 b/backend/test-kb-api.ps1 new file mode 100644 index 00000000..889be566 --- /dev/null +++ b/backend/test-kb-api.ps1 @@ -0,0 +1,111 @@ +# 测试知识库API脚本 + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " 测试知识库API" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# 测试1: 创建知识库 +Write-Host "测试1: 创建知识库..." -ForegroundColor Yellow +$createBody = @{ + name = "Test Knowledge Base" + description = "This is a test knowledge base" +} | ConvertTo-Json + +try { + $result1 = Invoke-RestMethod -Uri "http://localhost:3001/api/v1/knowledge-bases" ` + -Method Post ` + -Body $createBody ` + -ContentType "application/json" + + Write-Host "✓ 创建成功" -ForegroundColor Green + Write-Host " 知识库ID: $($result1.data.id)" -ForegroundColor Gray + Write-Host " 名称: $($result1.data.name)" -ForegroundColor Gray + $kbId = $result1.data.id +} catch { + Write-Host "✗ 创建失败: $_" -ForegroundColor Red + exit 1 +} + +Write-Host "" + +# 测试2: 获取知识库列表 +Write-Host "测试2: 获取知识库列表..." -ForegroundColor Yellow +try { + $result2 = Invoke-RestMethod -Uri "http://localhost:3001/api/v1/knowledge-bases" ` + -Method Get + + Write-Host "✓ 获取成功" -ForegroundColor Green + Write-Host " 知识库数量: $($result2.data.Count)" -ForegroundColor Gray +} catch { + Write-Host "✗ 获取失败: $_" -ForegroundColor Red +} + +Write-Host "" + +# 测试3: 获取知识库详情 +Write-Host "测试3: 获取知识库详情..." -ForegroundColor Yellow +try { + $result3 = Invoke-RestMethod -Uri "http://localhost:3001/api/v1/knowledge-bases/$kbId" ` + -Method Get + + Write-Host "✓ 获取成功" -ForegroundColor Green + Write-Host " 名称: $($result3.data.name)" -ForegroundColor Gray + Write-Host " 文档数: $($result3.data._count.documents)" -ForegroundColor Gray +} catch { + Write-Host "✗ 获取失败: $_" -ForegroundColor Red +} + +Write-Host "" + +# 测试4: 更新知识库 +Write-Host "测试4: 更新知识库..." -ForegroundColor Yellow +$updateBody = @{ + name = "Updated Test KB" + description = "Updated description" +} | ConvertTo-Json + +try { + $result4 = Invoke-RestMethod -Uri "http://localhost:3001/api/v1/knowledge-bases/$kbId" ` + -Method Put ` + -Body $updateBody ` + -ContentType "application/json" + + Write-Host "✓ 更新成功" -ForegroundColor Green + Write-Host " 新名称: $($result4.data.name)" -ForegroundColor Gray +} catch { + Write-Host "✗ 更新失败: $_" -ForegroundColor Red +} + +Write-Host "" + +# 测试5: 检索知识库 +Write-Host "测试5: 检索知识库..." -ForegroundColor Yellow +try { + $result5 = Invoke-RestMethod -Uri "http://localhost:3001/api/v1/knowledge-bases/$kbId/search?query=test" ` + -Method Get + + Write-Host "✓ 检索成功" -ForegroundColor Green + Write-Host " 结果数: $($result5.data.records.Count)" -ForegroundColor Gray +} catch { + Write-Host "✗ 检索失败: $_" -ForegroundColor Red +} + +Write-Host "" + +# 测试6: 删除知识库 +Write-Host "测试6: 删除知识库..." -ForegroundColor Yellow +try { + $result6 = Invoke-RestMethod -Uri "http://localhost:3001/api/v1/knowledge-bases/$kbId" ` + -Method Delete + + Write-Host "✓ 删除成功" -ForegroundColor Green +} catch { + Write-Host "✗ 删除失败: $_" -ForegroundColor Red +} + +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " 测试完成!" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +