diff --git a/backend/apply-migration.cjs b/backend/apply-migration.cjs new file mode 100644 index 00000000..a1a2c568 --- /dev/null +++ b/backend/apply-migration.cjs @@ -0,0 +1,36 @@ +const { PrismaClient } = require('@prisma/client'); +const fs = require('fs'); +const path = require('path'); + +const prisma = new PrismaClient(); + +async function main() { + const sqlPath = path.join(__dirname, 'prisma/migrations/20260128_add_system_knowledge_base/migration.sql'); + const sql = fs.readFileSync(sqlPath, 'utf-8'); + + // 按语句分割执行 + const statements = sql.split(';').filter(s => s.trim()); + + console.log(`Executing ${statements.length} SQL statements...`); + + for (let i = 0; i < statements.length; i++) { + const stmt = statements[i].trim(); + if (!stmt) continue; + + try { + await prisma.$executeRawUnsafe(stmt); + console.log(`[${i + 1}/${statements.length}] OK`); + } catch (error) { + if (error.message.includes('already exists')) { + console.log(`[${i + 1}/${statements.length}] SKIPPED (already exists)`); + } else { + console.error(`[${i + 1}/${statements.length}] ERROR:`, error.message); + } + } + } + + console.log('\nDone!'); + await prisma.$disconnect(); +} + +main(); diff --git a/backend/prisma/migrations/20260128_add_system_knowledge_base/migration.sql b/backend/prisma/migrations/20260128_add_system_knowledge_base/migration.sql new file mode 100644 index 00000000..62d469d8 --- /dev/null +++ b/backend/prisma/migrations/20260128_add_system_knowledge_base/migration.sql @@ -0,0 +1,54 @@ +-- Add knowledge_config field to prompt_templates +ALTER TABLE "capability_schema"."prompt_templates" ADD COLUMN "knowledge_config" JSONB; + +-- CreateTable: system_knowledge_bases +CREATE TABLE "capability_schema"."system_knowledge_bases" ( + "id" TEXT NOT NULL, + "code" VARCHAR(50) NOT NULL, + "name" VARCHAR(100) NOT NULL, + "description" TEXT, + "category" VARCHAR(50), + "document_count" INTEGER NOT NULL DEFAULT 0, + "total_tokens" INTEGER NOT NULL DEFAULT 0, + "status" VARCHAR(20) NOT NULL DEFAULT 'active', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "system_knowledge_bases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: system_kb_documents +CREATE TABLE "capability_schema"."system_kb_documents" ( + "id" TEXT NOT NULL, + "kb_id" TEXT NOT NULL, + "filename" VARCHAR(255) NOT NULL, + "file_path" VARCHAR(500), + "file_size" INTEGER, + "file_type" VARCHAR(50), + "content" TEXT, + "token_count" INTEGER NOT NULL DEFAULT 0, + "status" VARCHAR(20) NOT NULL DEFAULT 'pending', + "error_message" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "system_kb_documents_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "system_knowledge_bases_code_key" ON "capability_schema"."system_knowledge_bases"("code"); + +-- CreateIndex +CREATE INDEX "idx_system_kb_category" ON "capability_schema"."system_knowledge_bases"("category"); + +-- CreateIndex +CREATE INDEX "idx_system_kb_status" ON "capability_schema"."system_knowledge_bases"("status"); + +-- CreateIndex +CREATE INDEX "idx_system_kb_docs_kb_id" ON "capability_schema"."system_kb_documents"("kb_id"); + +-- CreateIndex +CREATE INDEX "idx_system_kb_docs_status" ON "capability_schema"."system_kb_documents"("status"); + +-- AddForeignKey +ALTER TABLE "capability_schema"."system_kb_documents" ADD CONSTRAINT "system_kb_documents_kb_id_fkey" FOREIGN KEY ("kb_id") REFERENCES "capability_schema"."system_knowledge_bases"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9d2bc426..c23bfe7f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1265,12 +1265,13 @@ enum VerificationType { /// Prompt模板 - 存储Prompt的元信息 model prompt_templates { - id Int @id @default(autoincrement()) - code String @unique /// 唯一标识符,如 'RVW_EDITORIAL' - name String /// 人类可读名称,如 "稿约规范性评估" - module String /// 所属模块: RVW, ASL, DC, IIT, PKB, AIA - description String? /// 描述 - variables Json? /// 预期变量列表,如 ["title", "abstract"] + id Int @id @default(autoincrement()) + code String @unique /// 唯一标识符,如 'RVW_EDITORIAL' + name String /// 人类可读名称,如 "稿约规范性评估" + module String /// 所属模块: RVW, ASL, DC, IIT, PKB, AIA + description String? /// 描述 + variables Json? /// 预期变量列表,如 ["title", "abstract"] + knowledge_config Json? @map("knowledge_config") /// 知识库增强配置 versions prompt_versions[] @@ -1312,6 +1313,52 @@ enum PromptStatus { @@schema("capability_schema") } +/// 系统知识库 - 运营管理的公共知识库,供 Prompt 引用 +model system_knowledge_bases { + id String @id @default(uuid()) + code String @unique @db.VarChar(50) /// 唯一编码,如 'CONSORT_2010' + name String @db.VarChar(100) /// 名称,如 'CONSORT 2010 声明' + description String? @db.Text /// 描述 + category String? @db.VarChar(50) /// 分类: methodology, statistics, crf + document_count Int @default(0) @map("document_count") /// 文档数量 + total_tokens Int @default(0) @map("total_tokens") /// 总 Token 数 + status String @default("active") @db.VarChar(20) /// 状态: active, archived + + documents system_kb_documents[] + + created_at DateTime @default(now()) @map("created_at") + updated_at DateTime @updatedAt @map("updated_at") + + @@index([category], map: "idx_system_kb_category") + @@index([status], map: "idx_system_kb_status") + @@map("system_knowledge_bases") + @@schema("capability_schema") +} + +/// 系统知识库文档 - 知识库中的文档 +model system_kb_documents { + id String @id @default(uuid()) + kb_id String @map("kb_id") /// 所属知识库ID + filename String @db.VarChar(255) /// 原始文件名 + file_path String? @db.VarChar(500) @map("file_path") /// OSS 存储路径 + file_size Int? @map("file_size") /// 文件大小(字节) + file_type String? @db.VarChar(50) @map("file_type") /// 文件类型: pdf, docx, md, txt + content String? @db.Text /// 解析后的文本内容 + token_count Int @default(0) @map("token_count") /// Token 数量 + status String @default("pending") @db.VarChar(20) /// 状态: pending, processing, ready, failed + error_message String? @db.Text @map("error_message") /// 错误信息 + + knowledge_base system_knowledge_bases @relation(fields: [kb_id], references: [id], onDelete: Cascade) + + created_at DateTime @default(now()) @map("created_at") + updated_at DateTime @updatedAt @map("updated_at") + + @@index([kb_id], map: "idx_system_kb_docs_kb_id") + @@index([status], map: "idx_system_kb_docs_status") + @@map("system_kb_documents") + @@schema("capability_schema") +} + // ============================================================ // EKB Schema - 知识库引擎 (Enterprise Knowledge Base) // 参考文档: docs/02-通用能力层/03-RAG引擎/04-数据模型设计.md diff --git a/backend/src/common/storage/OSSAdapter.ts b/backend/src/common/storage/OSSAdapter.ts index 3fa50a4a..4fbdca8a 100644 --- a/backend/src/common/storage/OSSAdapter.ts +++ b/backend/src/common/storage/OSSAdapter.ts @@ -91,10 +91,18 @@ export class OSSAdapter implements StorageAdapter { try { const normalizedKey = this.normalizeKey(key) - // 使用 put 方法上传 Buffer(适合 < 30MB 文件) - const result = await this.client.put(normalizedKey, buffer) + // 根据文件扩展名设置 Content-Type + const contentType = this.getMimeType(normalizedKey) - console.log(`[OSSAdapter] Upload success: ${normalizedKey}, size=${buffer.length}`) + // 使用 put 方法上传 Buffer(适合 < 30MB 文件) + // 必须设置正确的 headers,否则二进制文件可能损坏 + const result = await this.client.put(normalizedKey, buffer, { + headers: { + 'Content-Type': contentType, + }, + }) + + console.log(`[OSSAdapter] Upload success: ${normalizedKey}, size=${buffer.length}, contentType=${contentType}`) // 返回签名URL(假设是私有Bucket) return this.getSignedUrl(normalizedKey) @@ -104,6 +112,37 @@ export class OSSAdapter implements StorageAdapter { } } + /** + * 根据文件扩展名获取 MIME 类型 + */ + private getMimeType(key: string): string { + const ext = key.toLowerCase().split('.').pop() + const mimeTypes: Record = { + // 文档类型 + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + txt: 'text/plain', + md: 'text/markdown', + // 图片类型 + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + // 其他 + json: 'application/json', + xml: 'application/xml', + zip: 'application/zip', + } + return mimeTypes[ext || ''] || 'application/octet-stream' + } + /** * 流式上传(适合大文件 > 30MB) * diff --git a/backend/src/index.ts b/backend/src/index.ts index 85d19bcf..d0851257 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -108,12 +108,14 @@ logger.info('✅ Prompt管理路由已注册: /api/admin/prompts'); import { tenantRoutes, moduleRoutes } from './modules/admin/routes/tenantRoutes.js'; import { userRoutes } from './modules/admin/routes/userRoutes.js'; import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.js'; +import { systemKbRoutes } from './modules/admin/system-kb/index.js'; await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' }); await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' }); await fastify.register(userRoutes, { prefix: '/api/admin/users' }); await fastify.register(statsRoutes, { prefix: '/api/admin/stats' }); await fastify.register(userOverviewRoute, { prefix: '/api/admin/users' }); -logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats'); +await fastify.register(systemKbRoutes, { prefix: '/api/v1/admin/system-kb' }); +logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb'); // ============================================ // 【临时】平台基础设施测试API diff --git a/backend/src/modules/admin/__tests__/test-system-kb-api.http b/backend/src/modules/admin/__tests__/test-system-kb-api.http new file mode 100644 index 00000000..9d433c27 --- /dev/null +++ b/backend/src/modules/admin/__tests__/test-system-kb-api.http @@ -0,0 +1,108 @@ +### =========================================== +### System Knowledge Base API Test +### =========================================== +### 使用 VS Code REST Client 扩展运行 + +@baseUrl = http://localhost:3001 +@contentType = application/json + +### ----------------------------------------- +### 1. Login (获取 Token) +### ----------------------------------------- + +# @name login +POST {{baseUrl}}/api/v1/auth/login/password +Content-Type: {{contentType}} + +{ + "phone": "13800000001", + "password": "123456" +} + +### 保存 Token +@token = {{login.response.body.data.tokens.accessToken}} + +### ----------------------------------------- +### 2. 获取知识库列表 +### ----------------------------------------- + +# @name listKbs +GET {{baseUrl}}/api/v1/admin/system-kb +Authorization: Bearer {{token}} + +### ----------------------------------------- +### 3. 创建知识库 +### ----------------------------------------- + +# @name createKb +POST {{baseUrl}}/api/v1/admin/system-kb +Authorization: Bearer {{token}} +Content-Type: {{contentType}} + +{ + "code": "CLINICAL_GUIDELINES", + "name": "临床指南知识库", + "description": "存储临床研究相关的指南文档", + "category": "guidelines" +} + +### 保存创建的知识库 ID +@kbId = {{createKb.response.body.data.id}} + +### ----------------------------------------- +### 4. 获取知识库详情 +### ----------------------------------------- + +GET {{baseUrl}}/api/v1/admin/system-kb/{{kbId}} +Authorization: Bearer {{token}} + +### ----------------------------------------- +### 5. 更新知识库 +### ----------------------------------------- + +PATCH {{baseUrl}}/api/v1/admin/system-kb/{{kbId}} +Authorization: Bearer {{token}} +Content-Type: {{contentType}} + +{ + "name": "临床指南知识库(更新)", + "description": "存储临床研究相关的指南文档 - 已更新" +} + +### ----------------------------------------- +### 6. 获取文档列表 +### ----------------------------------------- + +GET {{baseUrl}}/api/v1/admin/system-kb/{{kbId}}/documents +Authorization: Bearer {{token}} + +### ----------------------------------------- +### 7. 上传文档 (需要文件) +### ----------------------------------------- +### 注意:此请求需要使用 multipart/form-data +### 可以使用 curl 或 Postman 测试: +### +### curl -X POST "http://localhost:3001/api/v1/admin/system-kb/{{kbId}}/documents" \ +### -H "Authorization: Bearer {{token}}" \ +### -F "file=@/path/to/your/document.pdf" + +### ----------------------------------------- +### 8. 删除知识库 +### ----------------------------------------- + +DELETE {{baseUrl}}/api/v1/admin/system-kb/{{kbId}} +Authorization: Bearer {{token}} + +### ----------------------------------------- +### 按分类筛选知识库 +### ----------------------------------------- + +GET {{baseUrl}}/api/v1/admin/system-kb?category=guidelines +Authorization: Bearer {{token}} + +### ----------------------------------------- +### 按状态筛选知识库 +### ----------------------------------------- + +GET {{baseUrl}}/api/v1/admin/system-kb?status=active +Authorization: Bearer {{token}} diff --git a/backend/src/modules/admin/__tests__/test-system-kb-api.ps1 b/backend/src/modules/admin/__tests__/test-system-kb-api.ps1 new file mode 100644 index 00000000..7f109ce8 --- /dev/null +++ b/backend/src/modules/admin/__tests__/test-system-kb-api.ps1 @@ -0,0 +1,178 @@ +# =========================================== +# System Knowledge Base API Test +# =========================================== + +$baseUrl = "http://localhost:3001" +$phone = "13800000001" # SUPER_ADMIN +$password = "123456" + +Write-Host "=============================================" -ForegroundColor Cyan +Write-Host "System Knowledge Base API Test" -ForegroundColor Cyan +Write-Host "=============================================" -ForegroundColor Cyan + +# 1. Login +Write-Host "`n[1/7] Login..." -ForegroundColor Yellow + +$loginBody = @{ + phone = $phone + password = $password +} | ConvertTo-Json + +try { + $loginResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/auth/login/password" ` + -Method POST ` + -Body $loginBody ` + -ContentType "application/json" + + $token = $loginResponse.data.tokens.accessToken + $userName = $loginResponse.data.user.name + + Write-Host " [OK] Login success! User: $userName" -ForegroundColor Green +} catch { + Write-Host " [FAIL] Login failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} + +$headers = @{ + "Authorization" = "Bearer $token" +} + +# 2. List Knowledge Bases (should be empty or have existing ones) +Write-Host "`n[2/7] List Knowledge Bases..." -ForegroundColor Yellow + +try { + $listResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb" ` + -Method GET ` + -Headers $headers + + $kbCount = $listResponse.data.Count + Write-Host " [OK] Success! Total: $kbCount knowledge bases" -ForegroundColor Green + + if ($kbCount -gt 0) { + Write-Host " ----------------------------" -ForegroundColor Gray + $listResponse.data | ForEach-Object { + Write-Host " - $($_.code): $($_.name) ($($_.documentCount) docs)" -ForegroundColor Gray + } + } +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red +} + +# 3. Create Knowledge Base +Write-Host "`n[3/7] Create Knowledge Base..." -ForegroundColor Yellow + +$testCode = "TEST_KB_$(Get-Date -Format 'yyyyMMddHHmmss')" +$createBody = @{ + code = $testCode + name = "Test Knowledge Base" + description = "This is a test knowledge base" + category = "test" +} | ConvertTo-Json + +try { + $createResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb" ` + -Method POST ` + -Body $createBody ` + -ContentType "application/json" ` + -Headers $headers + + $kbId = $createResponse.data.id + Write-Host " [OK] Created! ID: $kbId" -ForegroundColor Green + Write-Host " Code: $($createResponse.data.code)" -ForegroundColor Gray + Write-Host " Name: $($createResponse.data.name)" -ForegroundColor Gray +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} + +# 4. Get Knowledge Base Detail +Write-Host "`n[4/7] Get Knowledge Base Detail..." -ForegroundColor Yellow + +try { + $detailResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb/$kbId" ` + -Method GET ` + -Headers $headers + + Write-Host " [OK] Success!" -ForegroundColor Green + Write-Host " ----------------------------" -ForegroundColor Gray + Write-Host " ID: $($detailResponse.data.id)" -ForegroundColor Gray + Write-Host " Code: $($detailResponse.data.code)" -ForegroundColor Gray + Write-Host " Name: $($detailResponse.data.name)" -ForegroundColor Gray + Write-Host " Description: $($detailResponse.data.description)" -ForegroundColor Gray + Write-Host " Status: $($detailResponse.data.status)" -ForegroundColor Gray +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red +} + +# 5. Update Knowledge Base +Write-Host "`n[5/7] Update Knowledge Base..." -ForegroundColor Yellow + +$updateBody = @{ + name = "Updated Test Knowledge Base" + description = "Updated description" +} | ConvertTo-Json + +try { + $updateResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb/$kbId" ` + -Method PATCH ` + -Body $updateBody ` + -ContentType "application/json" ` + -Headers $headers + + Write-Host " [OK] Updated!" -ForegroundColor Green + Write-Host " New Name: $($updateResponse.data.name)" -ForegroundColor Gray + Write-Host " New Description: $($updateResponse.data.description)" -ForegroundColor Gray +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red +} + +# 6. List Documents (should be empty) +Write-Host "`n[6/7] List Documents..." -ForegroundColor Yellow + +try { + $docsResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb/$kbId/documents" ` + -Method GET ` + -Headers $headers + + $docCount = $docsResponse.data.Count + Write-Host " [OK] Success! Total: $docCount documents" -ForegroundColor Green +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red +} + +# 7. Delete Knowledge Base +Write-Host "`n[7/7] Delete Knowledge Base..." -ForegroundColor Yellow + +try { + $deleteResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb/$kbId" ` + -Method DELETE ` + -Headers $headers + + Write-Host " [OK] Deleted!" -ForegroundColor Green +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red +} + +# Verify deletion +Write-Host "`n[Verify] Check deletion..." -ForegroundColor Yellow + +try { + $verifyResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb/$kbId" ` + -Method GET ` + -Headers $headers + + Write-Host " [WARN] KB still exists!" -ForegroundColor Yellow +} catch { + if ($_.Exception.Response.StatusCode -eq 404) { + Write-Host " [OK] KB successfully deleted (404)" -ForegroundColor Green + } else { + Write-Host " [OK] KB deleted (error: $($_.Exception.Message))" -ForegroundColor Green + } +} + +Write-Host "`n=============================================" -ForegroundColor Cyan +Write-Host "Test Complete!" -ForegroundColor Cyan +Write-Host "=============================================" -ForegroundColor Cyan + +Write-Host "`nNote: Document upload test requires a file." -ForegroundColor Yellow +Write-Host "Use test-system-kb-upload.ps1 to test file upload." -ForegroundColor Yellow diff --git a/backend/src/modules/admin/__tests__/test-system-kb-upload.ps1 b/backend/src/modules/admin/__tests__/test-system-kb-upload.ps1 new file mode 100644 index 00000000..a008fc41 --- /dev/null +++ b/backend/src/modules/admin/__tests__/test-system-kb-upload.ps1 @@ -0,0 +1,168 @@ +# =========================================== +# System Knowledge Base - Document Upload Test +# =========================================== +# 测试文档上传功能,包含 OSS 存储和 RAG 向量化 + +$baseUrl = "http://localhost:3001" +$phone = "13800000001" # SUPER_ADMIN +$password = "123456" +$testFile = "D:\MyCursor\AIclinicalresearch\docs\06-测试文档\近红外光谱(NIRS)队列研究举例.pdf" + +Write-Host "=============================================" -ForegroundColor Cyan +Write-Host "System KB - Document Upload Test" -ForegroundColor Cyan +Write-Host "=============================================" -ForegroundColor Cyan + +# 检查测试文件是否存在 +if (-not (Test-Path $testFile)) { + Write-Host "[ERROR] Test file not found: $testFile" -ForegroundColor Red + exit 1 +} + +$fileInfo = Get-Item $testFile +Write-Host "Test file: $($fileInfo.Name)" -ForegroundColor Gray +Write-Host "File size: $([math]::Round($fileInfo.Length / 1024, 2)) KB" -ForegroundColor Gray + +# 1. Login +Write-Host "`n[1/6] Login..." -ForegroundColor Yellow + +$loginBody = @{ + phone = $phone + password = $password +} | ConvertTo-Json + +try { + $loginResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/auth/login/password" ` + -Method POST ` + -Body $loginBody ` + -ContentType "application/json" + + $token = $loginResponse.data.tokens.accessToken + Write-Host " [OK] Login success!" -ForegroundColor Green +} catch { + Write-Host " [FAIL] Login failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} + +$headers = @{ + "Authorization" = "Bearer $token" +} + +# 2. Create Knowledge Base +Write-Host "`n[2/6] Create Knowledge Base..." -ForegroundColor Yellow + +$testCode = "TEST_UPLOAD_$(Get-Date -Format 'yyyyMMddHHmmss')" +$createBody = @{ + code = $testCode + name = "文档上传测试知识库" + description = "用于测试文档上传和 RAG 向量化功能" + category = "test" +} | ConvertTo-Json -Depth 10 + +try { + $createResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb" ` + -Method POST ` + -Body $createBody ` + -ContentType "application/json; charset=utf-8" ` + -Headers $headers + + $kbId = $createResponse.data.id + Write-Host " [OK] Created! ID: $kbId" -ForegroundColor Green + Write-Host " Code: $($createResponse.data.code)" -ForegroundColor Gray +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} + +# 3. Upload Document +Write-Host "`n[3/6] Upload Document (may take a while for RAG processing)..." -ForegroundColor Yellow + +try { + # 使用 curl 上传文件(PowerShell 的 Invoke-RestMethod 对 multipart 支持不好) + $uploadResult = curl.exe -s -X POST "$baseUrl/api/v1/admin/system-kb/$kbId/documents" ` + -H "Authorization: Bearer $token" ` + -F "file=@$testFile" | ConvertFrom-Json + + if ($uploadResult.success) { + Write-Host " [OK] Upload success!" -ForegroundColor Green + Write-Host " ----------------------------" -ForegroundColor Gray + Write-Host " Doc ID: $($uploadResult.data.docId)" -ForegroundColor White + Write-Host " Filename: $($uploadResult.data.filename)" -ForegroundColor White + Write-Host " Chunks: $($uploadResult.data.chunkCount)" -ForegroundColor White + Write-Host " Tokens: $($uploadResult.data.tokenCount)" -ForegroundColor White + Write-Host " Duration: $($uploadResult.data.duration) ms" -ForegroundColor White + + $docId = $uploadResult.data.docId + } else { + Write-Host " [FAIL]: $($uploadResult.error)" -ForegroundColor Red + $docId = $null + } +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red + $docId = $null +} + +# 4. List Documents +Write-Host "`n[4/6] List Documents..." -ForegroundColor Yellow + +try { + $docsResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb/$kbId/documents" ` + -Method GET ` + -Headers $headers + + $docCount = $docsResponse.data.Count + Write-Host " [OK] Total: $docCount documents" -ForegroundColor Green + + if ($docCount -gt 0) { + Write-Host " ----------------------------" -ForegroundColor Gray + $docsResponse.data | ForEach-Object { + Write-Host " - $($_.filename) | Status: $($_.status) | Tokens: $($_.tokenCount)" -ForegroundColor Gray + Write-Host " Path: $($_.filePath)" -ForegroundColor DarkGray + } + } +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red +} + +# 5. Get KB Detail (check updated stats) +Write-Host "`n[5/6] Get KB Detail (verify stats updated)..." -ForegroundColor Yellow + +try { + $detailResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb/$kbId" ` + -Method GET ` + -Headers $headers + + Write-Host " [OK] Success!" -ForegroundColor Green + Write-Host " ----------------------------" -ForegroundColor Gray + Write-Host " Document Count: $($detailResponse.data.documentCount)" -ForegroundColor White + Write-Host " Total Tokens: $($detailResponse.data.totalTokens)" -ForegroundColor White +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red +} + +# 6. Cleanup - Delete KB +Write-Host "`n[6/6] Cleanup - Delete Knowledge Base..." -ForegroundColor Yellow + +try { + $deleteResponse = Invoke-RestMethod -Uri "$baseUrl/api/v1/admin/system-kb/$kbId" ` + -Method DELETE ` + -Headers $headers + + Write-Host " [OK] Deleted!" -ForegroundColor Green +} catch { + Write-Host " [FAIL]: $($_.Exception.Message)" -ForegroundColor Red +} + +Write-Host "`n=============================================" -ForegroundColor Cyan +Write-Host "Test Complete!" -ForegroundColor Cyan +Write-Host "=============================================" -ForegroundColor Cyan + +if ($docId) { + Write-Host "`n[Summary]" -ForegroundColor Green + Write-Host " - Document uploaded successfully" -ForegroundColor White + Write-Host " - RAG vectorization completed" -ForegroundColor White + Write-Host " - OSS path: system/knowledge-bases/$kbId/$docId.pdf" -ForegroundColor White +} else { + Write-Host "`n[Summary]" -ForegroundColor Yellow + Write-Host " - Document upload may have failed" -ForegroundColor White + Write-Host " - Check backend logs for details" -ForegroundColor White +} diff --git a/backend/src/modules/admin/system-kb/index.ts b/backend/src/modules/admin/system-kb/index.ts new file mode 100644 index 00000000..b123f0cc --- /dev/null +++ b/backend/src/modules/admin/system-kb/index.ts @@ -0,0 +1,7 @@ +/** + * 系统知识库模块导出 + */ + +export { systemKbRoutes } from './systemKbRoutes.js'; +export { SystemKbService, getSystemKbService } from './systemKbService.js'; +export * from './systemKbController.js'; diff --git a/backend/src/modules/admin/system-kb/systemKbController.ts b/backend/src/modules/admin/system-kb/systemKbController.ts new file mode 100644 index 00000000..04dcc1d5 --- /dev/null +++ b/backend/src/modules/admin/system-kb/systemKbController.ts @@ -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, + }); + } +} diff --git a/backend/src/modules/admin/system-kb/systemKbRoutes.ts b/backend/src/modules/admin/system-kb/systemKbRoutes.ts new file mode 100644 index 00000000..812536e9 --- /dev/null +++ b/backend/src/modules/admin/system-kb/systemKbRoutes.ts @@ -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; diff --git a/backend/src/modules/admin/system-kb/systemKbService.ts b/backend/src/modules/admin/system-kb/systemKbService.ts new file mode 100644 index 00000000..cfcef4e0 --- /dev/null +++ b/backend/src/modules/admin/system-kb/systemKbService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + // 使用事务删除 + 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 { + 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 { + 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; +} diff --git a/backend/test-oss-upload.cjs b/backend/test-oss-upload.cjs new file mode 100644 index 00000000..089286d1 --- /dev/null +++ b/backend/test-oss-upload.cjs @@ -0,0 +1,104 @@ +/** + * OSS 上传下载完整性测试 + * + * 测试流程: + * 1. 读取本地 PDF 文件 + * 2. 上传到 OSS + * 3. 从 OSS 下载 + * 4. 比较 MD5 值 + */ + +require('dotenv').config(); +const fs = require('fs'); +const crypto = require('crypto'); +const OSS = require('ali-oss'); + +const testFile = 'D:\\MyCursor\\AIclinicalresearch\\docs\\06-测试文档\\近红外光谱(NIRS)队列研究举例.pdf'; +const testKey = 'test/oss-integrity-test.pdf'; + +async function main() { + console.log('='.repeat(60)); + console.log('OSS Upload/Download Integrity Test'); + console.log('='.repeat(60)); + + // 1. 读取本地文件 + console.log('\n[1] Reading local file...'); + const localBuffer = fs.readFileSync(testFile); + const localMd5 = crypto.createHash('md5').update(localBuffer).digest('hex'); + console.log(` File size: ${localBuffer.length} bytes`); + console.log(` MD5: ${localMd5}`); + console.log(` First 20 bytes: ${localBuffer.slice(0, 20).toString('hex')}`); + + // 2. 创建 OSS 客户端 + console.log('\n[2] Creating OSS client...'); + const client = new OSS({ + region: process.env.OSS_REGION, + bucket: process.env.OSS_BUCKET, + accessKeyId: process.env.OSS_ACCESS_KEY_ID, + accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET, + endpoint: `${process.env.OSS_REGION}.aliyuncs.com`, + secure: true, + }); + console.log(` Region: ${process.env.OSS_REGION}`); + console.log(` Bucket: ${process.env.OSS_BUCKET}`); + + // 3. 上传到 OSS + console.log('\n[3] Uploading to OSS...'); + try { + const uploadResult = await client.put(testKey, localBuffer, { + headers: { + 'Content-Type': 'application/pdf', + }, + }); + console.log(` Upload success: ${uploadResult.name}`); + } catch (error) { + console.error(` Upload failed:`, error.message); + return; + } + + // 4. 从 OSS 下载 + console.log('\n[4] Downloading from OSS...'); + try { + const downloadResult = await client.get(testKey); + const downloadBuffer = downloadResult.content; + const downloadMd5 = crypto.createHash('md5').update(downloadBuffer).digest('hex'); + + console.log(` Download size: ${downloadBuffer.length} bytes`); + console.log(` MD5: ${downloadMd5}`); + console.log(` First 20 bytes: ${downloadBuffer.slice(0, 20).toString('hex')}`); + + // 5. 比较 + console.log('\n[5] Comparing...'); + if (localMd5 === downloadMd5) { + console.log(' ✅ MD5 MATCH - File integrity OK!'); + } else { + console.log(' ❌ MD5 MISMATCH - File corrupted!'); + console.log(` Local: ${localMd5}`); + console.log(` Download: ${downloadMd5}`); + } + + // 保存下载的文件用于对比 + const outputPath = 'D:\\MyCursor\\AIclinicalresearch\\docs\\06-测试文档\\oss-downloaded-test.pdf'; + fs.writeFileSync(outputPath, downloadBuffer); + console.log(`\n Downloaded file saved to: ${outputPath}`); + + } catch (error) { + console.error(` Download failed:`, error.message); + return; + } + + // 6. 清理 + console.log('\n[6] Cleanup...'); + try { + await client.delete(testKey); + console.log(' Test file deleted from OSS'); + } catch (error) { + console.log(` Cleanup failed: ${error.message}`); + } + + console.log('\n' + '='.repeat(60)); + console.log('Test Complete!'); + console.log('='.repeat(60)); +} + +main().catch(console.error); diff --git a/backend/verify-migration.cjs b/backend/verify-migration.cjs new file mode 100644 index 00000000..9edc18e0 --- /dev/null +++ b/backend/verify-migration.cjs @@ -0,0 +1,22 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + const tables = await prisma.$queryRawUnsafe(` + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'capability_schema' ORDER BY table_name + `); + console.log('=== capability_schema Tables ==='); + tables.forEach(t => console.log('-', t.table_name)); + + const cols = await prisma.$queryRawUnsafe(` + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'capability_schema' AND table_name = 'prompt_templates' + ORDER BY ordinal_position + `); + console.log('\n=== prompt_templates Columns ==='); + cols.forEach(c => console.log('-', c.column_name)); + + await prisma.$disconnect(); +} +main(); diff --git a/docs/00-系统总体设计/00-系统当前状态与开发指南.md b/docs/00-系统总体设计/00-系统当前状态与开发指南.md index 201484fe..54fe336a 100644 --- a/docs/00-系统总体设计/00-系统当前状态与开发指南.md +++ b/docs/00-系统总体设计/00-系统当前状态与开发指南.md @@ -1,21 +1,22 @@ # AIclinicalresearch 系统当前状态与开发指南 -> **文档版本:** v4.3 +> **文档版本:** v4.4 > **创建日期:** 2025-11-28 > **维护者:** 开发团队 -> **最后更新:** 2026-01-25 +> **最后更新:** 2026-01-27 > **🎉 重大里程碑:** +> - **2026-01-27:系统知识库管理功能完成!** 运营管理端新增知识库管理+文档上传下载 > - **2026-01-25:Protocol Agent MVP完整交付!** 一键生成研究方案+Word导出 > - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程 > - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层 > - **2026-01-21:成功替换 Dify!** PKB 模块完全使用自研 pgvector RAG 引擎 > -> **最新进展(Protocol Agent MVP 完整交付 2026-01-25):** -> - ✅ **一键生成研究方案**:流式输出+A4预览+12章节结构 -> - ✅ **Word文档导出**:Pandoc转换,格式完美 -> - ✅ **动态双面板布局**:可拖拽调整,收集65:35/生成35:65 -> - ✅ **用户体验优化**:折叠展开、延迟创建、滚动跟随 -> - ✅ **代码总量**:~8,500行(前端3,300+后端4,700+Python500) +> **最新进展(系统知识库管理 2026-01-27):** +> - ✅ **系统知识库管理**:运营管理端新增知识库模块,支持 Prompt 引用 +> - ✅ **主从页面模式**:Master-Detail UX,卡片列表+文档管理表格 +> - ✅ **文档管理**:上传(单个/批量)、下载(保留原始文件名)、删除 +> - ✅ **RAG 引擎集成**:文档解析、分块、向量化存储 +> - ✅ **OSS 存储集成**:system/knowledge-bases/{kbId}/{docId} 路径 > > **部署状态:** ✅ 生产环境运行中 | 公网地址:http://8.140.53.236/ > **文档目的:** 快速了解系统当前状态,为新AI助手提供上下文 @@ -58,7 +59,7 @@ | **SSA** | 智能统计分析 | 队列/预测模型/RCT分析 | ⭐⭐⭐⭐⭐ | 📋 规划中 | P2 | | **ST** | 统计分析工具 | 100+轻量化统计工具 | ⭐⭐⭐⭐ | 📋 规划中 | P2 | | **RVW** | 稿件审查系统 | 方法学评估、审稿流程、Word导出 | ⭐⭐⭐⭐ | ✅ **开发完成(95%)** | P3 | -| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.2完成(80%)** - 运营监控MVP+登录优化 | **P0** | +| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控、系统知识库 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.5完成(85%)** - 系统知识库管理+文档上传下载 | **P0** | --- diff --git a/docs/03-业务模块/ADMIN-运营管理端/00-模块当前状态与开发指南.md b/docs/03-业务模块/ADMIN-运营管理端/00-模块当前状态与开发指南.md index ec5fc4d3..5335ada0 100644 --- a/docs/03-业务模块/ADMIN-运营管理端/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/ADMIN-运营管理端/00-模块当前状态与开发指南.md @@ -1,8 +1,8 @@ # ADMIN-运营管理端 - 模块当前状态与开发指南 -> **最后更新:** 2026-01-25 -> **状态:** ✅ Phase 4.2 运营监控系统MVP完成!登录跳转逻辑优化完成! -> **版本:** v0.6 (Alpha) +> **最后更新:** 2026-01-27 +> **状态:** ✅ Phase 4.5 系统知识库管理功能完成! +> **版本:** v0.7 (Alpha) --- @@ -113,6 +113,16 @@ - [x] 修复:PKB 工作区问答页面布局问题(CSS类名冲突) - [x] 修复:Protocol Agent 模块 CSS 类名重命名(.pa-chat-container) +**Phase 4.5:系统知识库管理** ✅ 已完成(2026-01-27)🎉 +- [x] 后端:SystemKbService 完整 CRUD(知识库+文档) +- [x] 后端:8个 RESTful API 接口(列表/详情/创建/更新/删除/上传/下载) +- [x] 后端:OSS 存储集成(system/knowledge-bases/{kbId}/{docId}) +- [x] 后端:RAG 引擎集成(文档解析、分块、向量化) +- [x] 前端:SystemKbListPage 主页面(卡片式布局) +- [x] 前端:SystemKbDetailPage 详情页(文档管理表格) +- [x] 前端:主从页面模式(Master-Detail UX) +- [x] 功能:文档上传(单个/批量)、下载(保留原始文件名)、删除 + ### ⏳ 待开发(按优先级) **P2 - 用户管理增强(可选)** diff --git a/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-27_系统知识库管理功能完成.md b/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-27_系统知识库管理功能完成.md new file mode 100644 index 00000000..f87dbf55 --- /dev/null +++ b/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-27_系统知识库管理功能完成.md @@ -0,0 +1,146 @@ +# 2026-01-27 系统知识库管理功能完成 + +> **开发日期:** 2026-01-27 +> **开发者:** AI Assistant +> **状态:** ✅ 功能完整可用 + +--- + +## 📋 开发内容 + +### 系统知识库管理功能(System KB) + +运营管理端新增「系统知识库」模块,供管理员创建和管理公共知识库,支持 Prompt 引用。 + +--- + +## ✅ 已完成功能 + +### 1. 后端 API(8 个接口) + +| 接口 | 方法 | 路径 | 功能 | +|------|------|------|------| +| 知识库列表 | GET | `/api/v1/admin/system-kb` | 获取所有系统知识库 | +| 知识库详情 | GET | `/api/v1/admin/system-kb/:id` | 获取单个知识库详情 | +| 创建知识库 | POST | `/api/v1/admin/system-kb` | 创建新知识库 | +| 更新知识库 | PATCH | `/api/v1/admin/system-kb/:id` | 更新知识库信息 | +| 删除知识库 | DELETE | `/api/v1/admin/system-kb/:id` | 删除知识库(含所有文档) | +| 文档列表 | GET | `/api/v1/admin/system-kb/:id/documents` | 获取知识库文档列表 | +| 上传文档 | POST | `/api/v1/admin/system-kb/:id/documents` | 上传文档(支持 PDF/Word/TXT) | +| 删除文档 | DELETE | `/api/v1/admin/system-kb/:id/documents/:docId` | 删除单个文档 | +| 下载文档 | GET | `/api/v1/admin/system-kb/:id/documents/:docId/download` | 获取文档下载链接 | + +**后端文件:** +- `backend/src/modules/admin/system-kb/systemKbService.ts` - 核心业务逻辑 +- `backend/src/modules/admin/system-kb/systemKbController.ts` - 请求处理 +- `backend/src/modules/admin/system-kb/systemKbRoutes.ts` - 路由定义 +- `backend/src/modules/admin/system-kb/index.ts` - 模块导出 + +### 2. 前端界面(主从页面模式) + +**Master 页面 - 知识库列表** +- 卡片式布局展示所有知识库 +- 显示知识库名称、代码、文档数、Token 数 +- 支持创建新知识库(Modal 弹窗) +- 支持编辑、删除知识库 +- 点击卡片进入详情页 + +**Detail 页面 - 知识库详情** +- 顶部:面包屑导航 + 返回按钮 +- 统计卡片:文档数、Token 总量 +- 文档表格:文件名、大小、Token、状态、上传时间、操作 +- 支持单个/批量上传文档 +- 支持单个/批量删除文档 +- 上传进度显示 +- 下载文档功能(保留原始文件名) + +**前端文件:** +- `frontend-v2/src/modules/admin/pages/SystemKbListPage.tsx` - 列表页 +- `frontend-v2/src/modules/admin/pages/SystemKbDetailPage.tsx` - 详情页 +- `frontend-v2/src/modules/admin/api/systemKbApi.ts` - API 调用 +- `frontend-v2/src/modules/admin/types/systemKb.ts` - 类型定义 + +### 3. 数据库设计 + +**表结构:** +- `capability_schema.system_knowledge_bases` - 系统知识库表 +- `capability_schema.system_kb_documents` - 知识库文档表 +- `ekb_schema.ekb_knowledge_base` - EKB 知识库(RAG 引擎) +- `ekb_schema.ekb_document` - EKB 文档(向量化) +- `ekb_schema.ekb_chunk` - 文档分块(向量存储) + +### 4. OSS 存储集成 + +**存储路径:** `system/knowledge-bases/{kbId}/{docId}.{ext}` + +**遵循规范:** +- ✅ UUID 命名存储,原始文件名存数据库 +- ✅ 签名 URL 下载,支持 Content-Disposition +- ✅ 下载时恢复原始文件名 + +### 5. RAG 引擎集成 + +复用 `common/rag/` RAG 引擎能力: +- DocumentIngestService:文档解析、分块、向量化 +- EmbeddingService:文本向量化(text-embedding-v4) +- 存储到 pgvector 向量数据库 + +--- + +## 🔧 技术要点 + +### 下载功能实现 + +```typescript +// 使用 OSSAdapter.getSignedUrl 传入原始文件名 +const ossAdapter = storage as OSSAdapter; +const url = ossAdapter.getSignedUrl(doc.file_path, 3600, doc.filename); +// 生成的 URL 带有 Content-Disposition 头,浏览器下载时使用原始文件名 +``` + +### 主从页面路由 + +```typescript +// App.tsx 路由配置 +} /> +} /> +``` + +--- + +## 📊 代码量统计 + +| 文件类型 | 文件数 | 代码行数(约) | +|---------|--------|---------------| +| 后端 Service | 1 | ~500 行 | +| 后端 Controller | 1 | ~340 行 | +| 后端 Routes | 1 | ~75 行 | +| 前端 ListPage | 1 | ~320 行 | +| 前端 DetailPage | 1 | ~450 行 | +| 前端 API | 1 | ~130 行 | +| 前端 Types | 1 | ~60 行 | +| **合计** | **7** | **~1,875 行** | + +--- + +## 📝 下一步计划 + +1. **Prompt + 知识库关联** + - 在 Prompt 编辑页添加知识库选择器 + - 实现 Prompt 调用时自动 RAG 检索 + +2. **知识库搜索** + - 添加知识库内全文检索功能 + - 语义搜索测试界面 + +--- + +## 📚 相关文档 + +- 开发计划:`docs/03-业务模块/ADMIN-运营管理端/04-开发计划/05-Prompt知识库集成开发计划.md` +- OSS 规范:`docs/04-开发规范/11-OSS存储开发规范.md` +- RAG 引擎:`docs/02-通用能力层/03-RAG引擎/05-RAG引擎使用指南.md` + +--- + +*开发记录完成* diff --git a/docs/06-测试文档/Dongen 2003.pdf b/docs/06-测试文档/Dongen 2003.pdf deleted file mode 100644 index f9d2024f..00000000 Binary files a/docs/06-测试文档/Dongen 2003.pdf and /dev/null differ diff --git a/docs/06-测试文档/Ihl 2011.pdf b/docs/06-测试文档/Ihl 2011.pdf deleted file mode 100644 index 0a396963..00000000 Binary files a/docs/06-测试文档/Ihl 2011.pdf and /dev/null differ diff --git a/docs/06-测试文档/README.md b/docs/06-测试文档/README.md deleted file mode 100644 index ebbd281b..00000000 --- a/docs/06-测试文档/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# 测试文档 - -> **文档定位:** 测试策略、测试用例、测试报告 -> **适用范围:** 开发团队、QA团队 - ---- - -## 📋 测试策略 - -### 1. 单元测试 -- 核心业务逻辑测试 -- 工具函数测试 -- 覆盖率目标:60%+ - -### 2. 集成测试 -- API端点测试 -- 数据库集成测试 -- 外部服务集成测试 - -### 3. 端到端测试 -- 关键业务流程测试 -- UI自动化测试 - -### 4. 性能测试 -- API响应时间 -- 并发测试 -- 压力测试 - ---- - -## 📚 测试文档清单 - -| 文档 | 说明 | 状态 | -|------|------|------| -| **01-测试策略.md** | 整体测试策略和方法 | ⏳ 待创建 | -| **02-自动化测试.md** | 自动化测试框架和实践 | ⏳ 待创建 | -| **03-性能测试.md** | 性能测试标准和工具 | ⏳ 待创建 | - ---- - -## 🎯 各模块测试文档 - -每个业务模块的测试文档在各自的目录下: -- `03-业务模块/ASL-AI智能文献/04-测试文档/` -- `03-业务模块/AIA-AI智能问答/04-测试文档/` -- ... - ---- - -**最后更新:** 2025-11-06 -**维护人:** 技术架构师 - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/06-测试文档/故障分析报告 (1).md b/docs/06-测试文档/故障分析报告 (1).md deleted file mode 100644 index 402e1e91..00000000 --- a/docs/06-测试文档/故障分析报告 (1).md +++ /dev/null @@ -1,148 +0,0 @@ -# **PG Boss 任务重复故障分析与修复方案** - -## **1\. 故障核心分析 (Root Cause Analysis) \- 修正版** - -针对 "同一 TaskID 被创建 7 次" 且 "创建时间在同一毫秒" 的现象,在确认 **仅有 1 个 SAE 实例** 运行的情况下,我们排除了多实例并发的可能性。 - -结合已清理的 "rvw\_review\_task 有 7 个重复条目" 这一关键证据,我们得出了确切的结论: - -### **核心根因:持久化配置重复 (Persisted Configuration Duplication)** - -**问题不在于有多少个实例在跑,而在于数据库里存了多少份重复的指令。** - -#### **💡 深度解析:为什么会有 7 个?(7 个闹钟的比喻)** - -你的疑问是:*"每次处理应该是生成不同的任务ID,不可能是重复的对吗?"* - -**答案:是的,pg-boss 生成了 7 个完全不同的 Job ID,但它们都在做同一件事。** - -这就好比你为了早上 7 点起床,设置了 **7 个闹钟**: - -1. **指令 (Schedules/Definitions)**:数据库里那些被清理的 "7 个重复条目",就像是 7 个闹钟配置。它们都设定在同一个触发条件下(比如 Cron 表达式,或系统启动时)。 -2. **触发 (Trigger)**:当时间到了,或者系统启动扫描时,这 **7 个闹钟同时响了**。 -3. **执行 (Jobs)**:系统听到第 1 个闹钟,创建了 Job A;听到第 2 个闹钟,创建了 Job B... 直到 Job G。 - * **结果**:你在一毫秒内,被叫醒了 7 次。 - * **数据**:这 7 个 Job 都有**不同的 UUID**(符合数据库约束),但它们的\*\*内容(Payload)\*\*全是 "处理 Task bd19c3d3"。 - -这就是为什么你在数据库里看到 created\_on 完全一致,但 Job ID 不同。因为那 1 个 SAE 实例在极短的时间内,忠实地执行了数据库里残留的 7 条指令。 - -* **机制解析**:pg-boss 是一个基于数据库的任务队列。它的调度(Schedules)和某些队列配置是**持久化**在 PostgreSQL 数据库中的(通常在 pgboss.schedule 表中)。 -* **故障复盘**: - 1. **积累阶段**:在过去的历史部署或重启中,代码可能在启动时调用了 boss.schedule('queue', 'cron')。由于没有加去重逻辑,每次部署都在数据库里**新增**了一条调度记录,而不是更新旧的。日积月累,数据库里就有了 7 条完全一样的调度记录。 - 2. **爆发阶段**:当你当前的 **1 个 SAE 实例** 运行时,pg-boss 内部的轮询器扫描数据库,读取到了这 7 条重复的记录。 - 3. **瞬间执行**:当触发条件满足,这单个实例在极短的 CPU 周期内,为这 7 条记录分别生成了一个 Job。 -* **证据链闭环**: - * **7 次重复** 对应 **7 个重复的 Schedule/配置记录**。 - * **同一毫秒创建** 对应 **单实例在一次事件循环中连续处理了这 7 条指令**。 - -**结论**:你执行的 "清理了 32 个重复的队列定义" 操作,实际上就是**关掉了多余的 6 个闹钟**,这已经移除了问题的根源。 - -### **为什么 SingletonKey 之前没生效?** - -虽然这是单实例产生的重复,但如果代码使用的是 insert 或者是没有严格 unique constraint 保护的 send,在极快的循环中(Event Loop),数据库可能仍未完成第一条的提交,第二条就来了。 - -但最可能的原因是:**生成 Key 的逻辑有问题**,或者根本没有在产生任务的那段特定逻辑中加上 singletonKey。 - -## **2\. 解决方案:三层防御体系** - -虽然根因(重复配置)已被你清理,但为了防止未来代码逻辑再次意外引入重复配置,或者防止前端意外的连击,我们依然强烈建议保留以下防御措施。 - -### **第一层:入队时防御 (生产者层面 \- 强制去重)** - -这是最关键的一步。无论是因为配置重复导致被调用 7 次,还是前端点了 7 次,这里都能拦住。 - -**修改代码建议 (Producer/Service):** - -// reviewTaskProducer.ts - -import { PgBoss } from 'pg-boss'; - -// 假设这是你的入队逻辑 -export async function createReviewTask(boss: PgBoss, taskId: string, payload: any) { - const queueName \= 'rvw\_review\_task'; - - // ✅ 核心修复:构造确定性的 singletonKey - // 不要包含时间戳等变量,只包含业务唯一标识 (如 taskId) - const singletonKey \= \`review\_task\_${taskId}\`; - - // 发送任务 - const jobId \= await boss.send(queueName, payload, { - // ✅ 启用单例模式 - singletonKey: singletonKey, - // ✅ 节流/防抖:如果任务已存在且活跃,300秒内不再创建 - singletonSeconds: 300, - // ✅ 即使旧任务完成了,保留Key一段时间以防重复触发 - singletonNextSlot: false - }); - - if (\!jobId) { - console.warn(\`\[Duplicate Prevented\] Task ${taskId} already exists in queue.\`); - return null; - } - - return jobId; -} - -### **第二层:处理时防御 (Worker 层面 \- 幂等性检查)** - -你已经添加了状态检查,这很好。为了处理潜在的竞争(虽然单实例下竞争少,但为了健壮性),建议保持乐观锁逻辑。 - -**修改代码建议 (Worker):** - -// reviewWorker.ts - -export async function processReviewTask(job: Job) { - const { taskId } \= job.data; - - // 1\. 业务状态检查 (你已经做了) - const task \= await db.task.findUnique({ where: { id: taskId } }); - - // ✅ 状态检查:如果已经是处理中或完成,直接跳过 - if (task.status \=== 'COMPLETED' || task.status \=== 'PROCESSING') { - console.log(\`\[Skipped\] Task ${taskId} is already ${task.status}\`); - return; - } - - // 2\. 乐观锁更新 (Database Atomic Update) - const updateResult \= await db.task.updateMany({ - where: { - id: taskId, - status: 'PENDING' // 👈 关键:只有当前状态是 PENDING 时才更新 - }, - data: { - status: 'PROCESSING', - startedAt: new Date() - } - }); - - if (updateResult.count \=== 0\) { - console.log(\`\[Concurrency Control\] Task ${taskId} claimed by another worker or logic.\`); - return; - } - - // 3\. 执行逻辑 - try { - await performReviewLogic(taskId); - await db.task.update({ where: { id: taskId }, data: { status: 'COMPLETED' }}); - } catch (error) { - await db.task.update({ where: { id: taskId }, data: { status: 'FAILED' }}); - throw error; - } -} - -### **第三层:初始化代码审查 (防止复发)** - -针对 **"32 个重复队列定义"** 的来源,需要检查你的启动代码。 - -1. **检查 schedule 调用**: - 如果你在代码里使用了 boss.schedule('queue', cron, ...),请确保不要在每次应用启动时都无脑调用。 - * **错误做法**:在 main.ts 直接调用 boss.schedule(...)。每次部署都会尝试再加一个(取决于 pg-boss 版本行为)。 - * **正确做法**:通常 pg-boss 会处理去重,但如果参数稍有不同(比如 cron 表达式或数据),它可能会视为新 Schedules。建议检查 pg-boss 的 schedules 表,确保没有垃圾数据。 -2. **清理脚本**: - 保留一个数据库迁移脚本或运维 SQL,定期检查 pg-boss 的 job 表中是否有异常激增的 created 状态的任务。 - -## **3\. 总结** - -* **问题原因**:数据库中残留的历史重复配置(7个重复条目)导致单实例在循环中瞬间创建了 7 个任务。 -* **当前状态**:你清理了重复条目,这已经解决了根源。 -* **未来保障**:部署带有 singletonKey 的代码,这将是永远的防线,即使数据库里有 100 个重复配置,pg-boss 也会拒绝创建第 2 到 第 100 个任务。 \ No newline at end of file diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index cd2ae3d3..f6e52b74 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -21,6 +21,9 @@ import { MODULES } from './framework/modules/moduleRegistry' import UserListPage from './modules/admin/pages/UserListPage' import UserFormPage from './modules/admin/pages/UserFormPage' import UserDetailPage from './modules/admin/pages/UserDetailPage' +// 系统知识库管理 +import SystemKbListPage from './modules/admin/pages/SystemKbListPage' +import SystemKbDetailPage from './modules/admin/pages/SystemKbDetailPage' // 个人中心页面 import ProfilePage from './pages/user/ProfilePage' @@ -109,6 +112,9 @@ function App() { } /> } /> } /> + {/* 系统知识库 */} + } /> + } /> {/* 系统配置 */} 🚧 系统配置页面开发中...} /> diff --git a/frontend-v2/src/framework/layout/AdminLayout.tsx b/frontend-v2/src/framework/layout/AdminLayout.tsx index e8d4819a..4dab08ff 100644 --- a/frontend-v2/src/framework/layout/AdminLayout.tsx +++ b/frontend-v2/src/framework/layout/AdminLayout.tsx @@ -12,6 +12,7 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, BellOutlined, + BookOutlined, } from '@ant-design/icons' import type { MenuProps } from 'antd' import { useAuth } from '../auth' @@ -83,6 +84,11 @@ const AdminLayout = () => { icon: , label: 'Prompt管理', }, + { + key: '/admin/system-kb', + icon: , + label: '系统知识库', + }, { key: '/admin/tenants', icon: , diff --git a/frontend-v2/src/modules/admin/api/systemKbApi.ts b/frontend-v2/src/modules/admin/api/systemKbApi.ts new file mode 100644 index 00000000..141de213 --- /dev/null +++ b/frontend-v2/src/modules/admin/api/systemKbApi.ts @@ -0,0 +1,125 @@ +/** + * 系统知识库 API + */ + +import apiClient from '@/common/api/axios'; +import type { + SystemKb, + SystemKbDocument, + CreateKbRequest, + UpdateKbRequest, + UploadDocumentResponse, +} from '../types/systemKb'; + +const BASE_URL = '/api/v1/admin/system-kb'; + +/** API 响应包装 */ +interface ApiResponse { + success: boolean; + data: T; + error?: string; +} + +/** + * 获取知识库列表 + */ +export async function listKnowledgeBases(params?: { + category?: string; + status?: string; +}): Promise { + const response = await apiClient.get>(BASE_URL, { params }); + return response.data.data; +} + +/** + * 获取知识库详情 + */ +export async function getKnowledgeBase(id: string): Promise { + const response = await apiClient.get>(`${BASE_URL}/${id}`); + return response.data.data; +} + +/** + * 创建知识库 + */ +export async function createKnowledgeBase(data: CreateKbRequest): Promise { + const response = await apiClient.post>(BASE_URL, data); + return response.data.data; +} + +/** + * 更新知识库 + */ +export async function updateKnowledgeBase(id: string, data: UpdateKbRequest): Promise { + const response = await apiClient.patch>(`${BASE_URL}/${id}`, data); + return response.data.data; +} + +/** + * 删除知识库 + */ +export async function deleteKnowledgeBase(id: string): Promise { + await apiClient.delete(`${BASE_URL}/${id}`); +} + +/** + * 获取知识库文档列表 + */ +export async function listDocuments(kbId: string): Promise { + const response = await apiClient.get>( + `${BASE_URL}/${kbId}/documents` + ); + return response.data.data; +} + +/** + * 上传文档 + */ +export async function uploadDocument( + kbId: string, + file: File, + onProgress?: (percent: number) => void +): Promise { + const formData = new FormData(); + formData.append('file', file); + + const response = await apiClient.post>( + `${BASE_URL}/${kbId}/documents`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total); + onProgress(percent); + } + }, + } + ); + return response.data.data; +} + +/** + * 删除文档 + */ +export async function deleteDocument(kbId: string, docId: string): Promise { + await apiClient.delete(`${BASE_URL}/${kbId}/documents/${docId}`); +} + +/** + * 获取文档下载链接 + */ +export async function getDocumentDownloadUrl(kbId: string, docId: string): Promise<{ + url: string; + filename: string; + fileSize: number | null; +}> { + const response = await apiClient.get>(`${BASE_URL}/${kbId}/documents/${docId}/download`); + return response.data.data; +} diff --git a/frontend-v2/src/modules/admin/index.tsx b/frontend-v2/src/modules/admin/index.tsx index 97790b49..f3abfc39 100644 --- a/frontend-v2/src/modules/admin/index.tsx +++ b/frontend-v2/src/modules/admin/index.tsx @@ -6,6 +6,7 @@ * - 用户管理 * - 租户管理(已有) * - Prompt管理(已有) + * - 系统知识库管理 */ import React from 'react'; @@ -14,6 +15,8 @@ import UserListPage from './pages/UserListPage'; import UserFormPage from './pages/UserFormPage'; import UserDetailPage from './pages/UserDetailPage'; import StatsDashboardPage from './pages/StatsDashboardPage'; +import SystemKbListPage from './pages/SystemKbListPage'; +import SystemKbDetailPage from './pages/SystemKbDetailPage'; const AdminModule: React.FC = () => { return ( @@ -28,6 +31,10 @@ const AdminModule: React.FC = () => { } /> } /> } /> + + {/* 系统知识库管理 */} + } /> + } /> ); }; diff --git a/frontend-v2/src/modules/admin/pages/SystemKbDetailPage.tsx b/frontend-v2/src/modules/admin/pages/SystemKbDetailPage.tsx new file mode 100644 index 00000000..0d2c3f6c --- /dev/null +++ b/frontend-v2/src/modules/admin/pages/SystemKbDetailPage.tsx @@ -0,0 +1,451 @@ +/** + * 系统知识库详情页 + * + * 管理知识库中的文档 + */ + +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Card, + Button, + Table, + Space, + Upload, + message, + Popconfirm, + Tag, + Typography, + Empty, + Spin, + Progress, + Breadcrumb, + Descriptions, + Tooltip, +} from 'antd'; +import { + ArrowLeftOutlined, + UploadOutlined, + DeleteOutlined, + DownloadOutlined, + FileTextOutlined, + FilePdfOutlined, + FileWordOutlined, + FileUnknownOutlined, + ClockCircleOutlined, + DatabaseOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import * as systemKbApi from '../api/systemKbApi'; +import type { SystemKb, SystemKbDocument } from '../types/systemKb'; + +const { Title, Text } = Typography; + +/** 文件图标映射 */ +const getFileIcon = (fileType: string | null) => { + switch (fileType) { + case 'pdf': + return ; + case 'doc': + case 'docx': + return ; + case 'txt': + case 'md': + return ; + default: + return ; + } +}; + +/** 格式化文件大小 */ +const formatFileSize = (bytes: number | null) => { + if (!bytes) return '-'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +/** 格式化日期 */ +const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +}; + +const SystemKbDetailPage: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [kb, setKb] = useState(null); + const [documents, setDocuments] = useState([]); + const [loading, setLoading] = useState(true); + const [docsLoading, setDocsLoading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(null); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + // 加载知识库详情 + const loadKnowledgeBase = async () => { + if (!id) return; + setLoading(true); + try { + const data = await systemKbApi.getKnowledgeBase(id); + setKb(data); + } catch (error) { + message.error('加载知识库失败'); + navigate('/admin/system-kb'); + } finally { + setLoading(false); + } + }; + + // 加载文档列表 + const loadDocuments = async () => { + if (!id) return; + setDocsLoading(true); + try { + const data = await systemKbApi.listDocuments(id); + setDocuments(data); + } catch (error) { + message.error('加载文档列表失败'); + } finally { + setDocsLoading(false); + } + }; + + useEffect(() => { + loadKnowledgeBase(); + loadDocuments(); + }, [id]); + + // 上传文档 + const handleUpload = async (file: File) => { + if (!id) return; + + setUploadProgress(0); + try { + const result = await systemKbApi.uploadDocument( + id, + file, + (percent) => setUploadProgress(percent) + ); + message.success(`上传成功:${result.chunkCount} 个分块,${result.tokenCount.toLocaleString()} tokens`); + await loadDocuments(); + await loadKnowledgeBase(); // 刷新统计 + } catch (error: any) { + message.error(error.response?.data?.error || '上传失败'); + } finally { + setUploadProgress(null); + } + }; + + // 删除单个文档 + const handleDeleteDoc = async (doc: SystemKbDocument) => { + if (!id) return; + + try { + await systemKbApi.deleteDocument(id, doc.id); + message.success('删除成功'); + await loadDocuments(); + await loadKnowledgeBase(); + } catch (error) { + message.error('删除失败'); + } + }; + + // 批量删除 + const handleBatchDelete = async () => { + if (!id || selectedRowKeys.length === 0) return; + + try { + for (const docId of selectedRowKeys) { + await systemKbApi.deleteDocument(id, docId as string); + } + message.success(`成功删除 ${selectedRowKeys.length} 个文档`); + setSelectedRowKeys([]); + await loadDocuments(); + await loadKnowledgeBase(); + } catch (error) { + message.error('删除失败'); + } + }; + + // 下载文档 + const handleDownload = async (doc: SystemKbDocument) => { + if (!id) return; + + try { + const result = await systemKbApi.getDocumentDownloadUrl(id, doc.id); + // 创建临时链接并触发下载 + const link = document.createElement('a'); + link.href = result.url; + link.download = result.filename; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (error) { + message.error('获取下载链接失败'); + } + }; + + // 表格列定义 + const columns: ColumnsType = [ + { + title: '文件名', + dataIndex: 'filename', + key: 'filename', + render: (text, record) => ( + + {getFileIcon(record.fileType)} + {text} + + ), + }, + { + title: '大小', + dataIndex: 'fileSize', + key: 'fileSize', + width: 100, + render: (val) => formatFileSize(val), + }, + { + title: 'Tokens', + dataIndex: 'tokenCount', + key: 'tokenCount', + width: 100, + align: 'right', + render: (val) => val?.toLocaleString() || '-', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status, record) => { + const statusConfig: Record = { + ready: { color: 'success', text: '就绪' }, + processing: { color: 'processing', text: '处理中' }, + failed: { color: 'error', text: '失败' }, + pending: { color: 'warning', text: '等待' }, + }; + const config = statusConfig[status] || { color: 'default', text: status }; + return ( + + {config.text} + + ); + }, + }, + { + title: '上传时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 160, + render: (val) => formatDate(val), + }, + { + title: '操作', + key: 'action', + width: 120, + render: (_, record) => ( + + + +
+ {kb.name} + {kb.code} +
+ + + {/* 知识库信息卡片 */} + + + + 文档数 +
+ ), + children: {kb.documentCount}, + }, + { + key: 'tokens', + label: ( + + + 总 Tokens + + ), + children: {kb.totalTokens.toLocaleString()}, + }, + { + key: 'created', + label: ( + + + 创建时间 + + ), + children: formatDate(kb.createdAt), + }, + { + key: 'description', + label: '描述', + children: kb.description || , + }, + ]} + /> + + + {/* 文档管理卡片 */} + + + 文档列表 + {documents.length} 个 + + } + extra={ + + {selectedRowKeys.length > 0 && ( + + + + )} + { + handleUpload(file); + return false; + }} + > + + + + } + > + {/* 上传进度 */} + {uploadProgress !== null && ( +
+
+ +
+ + 正在上传并处理文档(向量化)... +
+
+
+ )} + + {/* 文档表格 */} + + {documents.length > 0 ? ( + 10 ? { pageSize: 10 } : false} + /> + ) : ( + + { + handleUpload(file); + return false; + }} + > + + + + )} + + + + ); +}; + +export default SystemKbDetailPage; diff --git a/frontend-v2/src/modules/admin/pages/SystemKbListPage.tsx b/frontend-v2/src/modules/admin/pages/SystemKbListPage.tsx new file mode 100644 index 00000000..a571adf5 --- /dev/null +++ b/frontend-v2/src/modules/admin/pages/SystemKbListPage.tsx @@ -0,0 +1,316 @@ +/** + * 系统知识库列表页 + * + * 卡片式展示所有知识库 + */ + +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Card, + Button, + Modal, + Form, + Input, + Select, + message, + Popconfirm, + Typography, + Empty, + Spin, + Row, + Col, + Statistic, +} from 'antd'; +import { + PlusOutlined, + DeleteOutlined, + FolderOutlined, + FileTextOutlined, + ReloadOutlined, + RightOutlined, +} from '@ant-design/icons'; +import * as systemKbApi from '../api/systemKbApi'; +import type { SystemKb, CreateKbRequest } from '../types/systemKb'; + +const { Title, Text, Paragraph } = Typography; +const { TextArea } = Input; + +/** 知识库分类选项 */ +const CATEGORY_OPTIONS = [ + { value: 'guidelines', label: '临床指南' }, + { value: 'methodology', label: '方法学' }, + { value: 'regulations', label: '法规政策' }, + { value: 'templates', label: '模板文档' }, + { value: 'other', label: '其他' }, +]; + +/** 分类标签颜色 */ +const CATEGORY_COLORS: Record = { + guidelines: '#1890ff', + methodology: '#52c41a', + regulations: '#faad14', + templates: '#722ed1', + other: '#8c8c8c', +}; + +const SystemKbListPage: React.FC = () => { + const navigate = useNavigate(); + const [knowledgeBases, setKnowledgeBases] = useState([]); + const [loading, setLoading] = useState(false); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [form] = Form.useForm(); + + // 加载知识库列表 + const loadKnowledgeBases = async () => { + setLoading(true); + try { + const data = await systemKbApi.listKnowledgeBases(); + setKnowledgeBases(data); + } catch (error) { + message.error('加载知识库列表失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadKnowledgeBases(); + }, []); + + // 创建知识库 + const handleCreate = async (values: CreateKbRequest) => { + try { + const newKb = await systemKbApi.createKnowledgeBase(values); + message.success('创建成功'); + setCreateModalOpen(false); + form.resetFields(); + // 直接进入详情页 + navigate(`/admin/system-kb/${newKb.id}`); + } catch (error: any) { + message.error(error.response?.data?.error || '创建失败'); + } + }; + + // 删除知识库 + const handleDelete = async (e: React.MouseEvent, kb: SystemKb) => { + e.stopPropagation(); + try { + await systemKbApi.deleteKnowledgeBase(kb.id); + message.success('删除成功'); + await loadKnowledgeBases(); + } catch (error) { + message.error('删除失败'); + } + }; + + // 进入详情页 + const handleCardClick = (kb: SystemKb) => { + navigate(`/admin/system-kb/${kb.id}`); + }; + + return ( +
+ {/* 页面标题 */} +
+
+ 系统知识库 + 管理运营端公共知识库,供 Prompt 引用 +
+
+ + +
+
+ + {/* 知识库卡片列表 */} + + {knowledgeBases.length > 0 ? ( + + {knowledgeBases.map((kb) => ( +
+ handleCardClick(kb)} + actions={[ + handleDelete(e as React.MouseEvent, kb)} + onCancel={(e) => e?.stopPropagation()} + > + + , + , + ]} + > + {/* 卡片头部 */} +
+
+ +
+
+ + {kb.name} + + {kb.code} +
+
+ + {/* 描述 */} + {kb.description && ( + + {kb.description} + + )} + + {/* 统计数据 */} + +
+ 文档数} + value={kb.documentCount} + prefix={} + valueStyle={{ fontSize: 18 }} + /> + + + Tokens} + value={kb.totalTokens} + valueStyle={{ fontSize: 18 }} + formatter={(val) => { + const num = Number(val); + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; + return num; + }} + /> + + + + + ))} + + {/* 新建卡片 */} + + setCreateModalOpen(true)} + > +
+ +
新建知识库
+
+
+ + + ) : ( + + + + )} + + + {/* 创建知识库弹窗 */} + { + setCreateModalOpen(false); + form.resetFields(); + }} + onOk={() => form.submit()} + okText="创建" + cancelText="取消" + width={480} + > +
+ + + + + + + + + +