feat(admin): Implement System Knowledge Base management module
Features:
- Backend: SystemKbService with full CRUD (knowledge bases + documents)
- Backend: 8 RESTful API endpoints (list/detail/create/update/delete/upload/download)
- Backend: OSS storage integration (system/knowledge-bases/{kbId}/{docId})
- Backend: RAG engine integration (document parsing, chunking, vectorization)
- Frontend: SystemKbListPage with card-based layout
- Frontend: SystemKbDetailPage with document management table
- Frontend: Master-Detail UX pattern for better user experience
- Document upload (single/batch), download (preserving original filename), delete
Technical:
- Database migration for system_knowledge_bases and system_kb_documents tables
- OSSAdapter.getSignedUrl with Content-Disposition for original filename
- Reuse RAG engine from common/rag for document processing
Tested: Local environment verified, all features working
This commit is contained in:
36
backend/apply-migration.cjs
Normal file
36
backend/apply-migration.cjs
Normal file
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -1265,12 +1265,13 @@ enum VerificationType {
|
|||||||
|
|
||||||
/// Prompt模板 - 存储Prompt的元信息
|
/// Prompt模板 - 存储Prompt的元信息
|
||||||
model prompt_templates {
|
model prompt_templates {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
code String @unique /// 唯一标识符,如 'RVW_EDITORIAL'
|
code String @unique /// 唯一标识符,如 'RVW_EDITORIAL'
|
||||||
name String /// 人类可读名称,如 "稿约规范性评估"
|
name String /// 人类可读名称,如 "稿约规范性评估"
|
||||||
module String /// 所属模块: RVW, ASL, DC, IIT, PKB, AIA
|
module String /// 所属模块: RVW, ASL, DC, IIT, PKB, AIA
|
||||||
description String? /// 描述
|
description String? /// 描述
|
||||||
variables Json? /// 预期变量列表,如 ["title", "abstract"]
|
variables Json? /// 预期变量列表,如 ["title", "abstract"]
|
||||||
|
knowledge_config Json? @map("knowledge_config") /// 知识库增强配置
|
||||||
|
|
||||||
versions prompt_versions[]
|
versions prompt_versions[]
|
||||||
|
|
||||||
@@ -1312,6 +1313,52 @@ enum PromptStatus {
|
|||||||
@@schema("capability_schema")
|
@@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)
|
// EKB Schema - 知识库引擎 (Enterprise Knowledge Base)
|
||||||
// 参考文档: docs/02-通用能力层/03-RAG引擎/04-数据模型设计.md
|
// 参考文档: docs/02-通用能力层/03-RAG引擎/04-数据模型设计.md
|
||||||
|
|||||||
@@ -91,10 +91,18 @@ export class OSSAdapter implements StorageAdapter {
|
|||||||
try {
|
try {
|
||||||
const normalizedKey = this.normalizeKey(key)
|
const normalizedKey = this.normalizeKey(key)
|
||||||
|
|
||||||
// 使用 put 方法上传 Buffer(适合 < 30MB 文件)
|
// 根据文件扩展名设置 Content-Type
|
||||||
const result = await this.client.put(normalizedKey, buffer)
|
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)
|
// 返回签名URL(假设是私有Bucket)
|
||||||
return this.getSignedUrl(normalizedKey)
|
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<string, string> = {
|
||||||
|
// 文档类型
|
||||||
|
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)
|
* 流式上传(适合大文件 > 30MB)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -108,12 +108,14 @@ logger.info('✅ Prompt管理路由已注册: /api/admin/prompts');
|
|||||||
import { tenantRoutes, moduleRoutes } from './modules/admin/routes/tenantRoutes.js';
|
import { tenantRoutes, moduleRoutes } from './modules/admin/routes/tenantRoutes.js';
|
||||||
import { userRoutes } from './modules/admin/routes/userRoutes.js';
|
import { userRoutes } from './modules/admin/routes/userRoutes.js';
|
||||||
import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.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(tenantRoutes, { prefix: '/api/admin/tenants' });
|
||||||
await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' });
|
await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' });
|
||||||
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
|
await fastify.register(userRoutes, { prefix: '/api/admin/users' });
|
||||||
await fastify.register(statsRoutes, { prefix: '/api/admin/stats' });
|
await fastify.register(statsRoutes, { prefix: '/api/admin/stats' });
|
||||||
await fastify.register(userOverviewRoute, { prefix: '/api/admin/users' });
|
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
|
// 【临时】平台基础设施测试API
|
||||||
|
|||||||
108
backend/src/modules/admin/__tests__/test-system-kb-api.http
Normal file
108
backend/src/modules/admin/__tests__/test-system-kb-api.http
Normal file
@@ -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}}
|
||||||
178
backend/src/modules/admin/__tests__/test-system-kb-api.ps1
Normal file
178
backend/src/modules/admin/__tests__/test-system-kb-api.ps1
Normal file
@@ -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
|
||||||
168
backend/src/modules/admin/__tests__/test-system-kb-upload.ps1
Normal file
168
backend/src/modules/admin/__tests__/test-system-kb-upload.ps1
Normal file
@@ -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
|
||||||
|
}
|
||||||
7
backend/src/modules/admin/system-kb/index.ts
Normal file
7
backend/src/modules/admin/system-kb/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 系统知识库模块导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { systemKbRoutes } from './systemKbRoutes.js';
|
||||||
|
export { SystemKbService, getSystemKbService } from './systemKbService.js';
|
||||||
|
export * from './systemKbController.js';
|
||||||
337
backend/src/modules/admin/system-kb/systemKbController.ts
Normal file
337
backend/src/modules/admin/system-kb/systemKbController.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* 系统知识库控制器
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { getSystemKbService } from './systemKbService.js';
|
||||||
|
import { prisma } from '../../../config/database.js';
|
||||||
|
import { logger } from '../../../common/logging/index.js';
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
interface CreateKbBody {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateKbBody {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListKbQuery {
|
||||||
|
category?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KbIdParams {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocIdParams {
|
||||||
|
id: string;
|
||||||
|
docId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 控制器函数 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识库列表
|
||||||
|
*/
|
||||||
|
export async function listKnowledgeBases(
|
||||||
|
request: FastifyRequest<{ Querystring: ListKbQuery }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { category, status } = request.query;
|
||||||
|
const service = getSystemKbService(prisma);
|
||||||
|
const kbs = await service.listKnowledgeBases({ category, status });
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: kbs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('获取知识库列表失败', { error: message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识库详情
|
||||||
|
*/
|
||||||
|
export async function getKnowledgeBase(
|
||||||
|
request: FastifyRequest<{ Params: KbIdParams }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = request.params;
|
||||||
|
const service = getSystemKbService(prisma);
|
||||||
|
const kb = await service.getKnowledgeBase(id);
|
||||||
|
|
||||||
|
if (!kb) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
success: false,
|
||||||
|
error: '知识库不存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: kb,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('获取知识库详情失败', { error: message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建知识库
|
||||||
|
*/
|
||||||
|
export async function createKnowledgeBase(
|
||||||
|
request: FastifyRequest<{ Body: CreateKbBody }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { code, name, description, category } = request.body;
|
||||||
|
|
||||||
|
if (!code || !name) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
success: false,
|
||||||
|
error: '编码和名称为必填项',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getSystemKbService(prisma);
|
||||||
|
const kb = await service.createKnowledgeBase({ code, name, description, category });
|
||||||
|
|
||||||
|
return reply.status(201).send({
|
||||||
|
success: true,
|
||||||
|
data: kb,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('创建知识库失败', { error: message });
|
||||||
|
|
||||||
|
if (message.includes('已存在')) {
|
||||||
|
return reply.status(409).send({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新知识库
|
||||||
|
*/
|
||||||
|
export async function updateKnowledgeBase(
|
||||||
|
request: FastifyRequest<{ Params: KbIdParams; Body: UpdateKbBody }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = request.params;
|
||||||
|
const { name, description, category } = request.body;
|
||||||
|
|
||||||
|
const service = getSystemKbService(prisma);
|
||||||
|
const kb = await service.updateKnowledgeBase(id, { name, description, category });
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: kb,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('更新知识库失败', { error: message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除知识库
|
||||||
|
*/
|
||||||
|
export async function deleteKnowledgeBase(
|
||||||
|
request: FastifyRequest<{ Params: KbIdParams }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = request.params;
|
||||||
|
const service = getSystemKbService(prisma);
|
||||||
|
await service.deleteKnowledgeBase(id);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
message: '删除成功',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('删除知识库失败', { error: message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档列表
|
||||||
|
*/
|
||||||
|
export async function listDocuments(
|
||||||
|
request: FastifyRequest<{ Params: KbIdParams }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = request.params;
|
||||||
|
const service = getSystemKbService(prisma);
|
||||||
|
const docs = await service.listDocuments(id);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: docs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('获取文档列表失败', { error: message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文档
|
||||||
|
*/
|
||||||
|
export async function uploadDocument(
|
||||||
|
request: FastifyRequest<{ Params: KbIdParams }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: kbId } = request.params;
|
||||||
|
|
||||||
|
// 处理 multipart 文件上传
|
||||||
|
const data = await request.file();
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
success: false,
|
||||||
|
error: '请上传文件',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = data.filename;
|
||||||
|
const fileBuffer = await data.toBuffer();
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
const allowedTypes = ['pdf', 'docx', 'doc', 'txt', 'md'];
|
||||||
|
const ext = filename.toLowerCase().split('.').pop();
|
||||||
|
if (!ext || !allowedTypes.includes(ext)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
success: false,
|
||||||
|
error: `不支持的文件类型: ${ext},支持: ${allowedTypes.join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小(最大 50MB)
|
||||||
|
const maxSize = 50 * 1024 * 1024;
|
||||||
|
if (fileBuffer.length > maxSize) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
success: false,
|
||||||
|
error: '文件大小超过限制(最大 50MB)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getSystemKbService(prisma);
|
||||||
|
const result = await service.uploadDocument(kbId, filename, fileBuffer);
|
||||||
|
|
||||||
|
return reply.status(201).send({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
docId: result.docId,
|
||||||
|
filename,
|
||||||
|
chunkCount: result.ingestResult.chunkCount,
|
||||||
|
tokenCount: result.ingestResult.tokenCount,
|
||||||
|
duration: result.ingestResult.duration,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('上传文档失败', { error: message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文档
|
||||||
|
*/
|
||||||
|
export async function deleteDocument(
|
||||||
|
request: FastifyRequest<{ Params: DocIdParams }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: kbId, docId } = request.params;
|
||||||
|
const service = getSystemKbService(prisma);
|
||||||
|
await service.deleteDocument(kbId, docId);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
message: '删除成功',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('删除文档失败', { error: message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文档
|
||||||
|
*/
|
||||||
|
export async function downloadDocument(
|
||||||
|
request: FastifyRequest<{ Params: DocIdParams }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: kbId, docId } = request.params;
|
||||||
|
const service = getSystemKbService(prisma);
|
||||||
|
const result = await service.getDocumentDownloadUrl(kbId, docId);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('获取下载链接失败', { error: message });
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
75
backend/src/modules/admin/system-kb/systemKbRoutes.ts
Normal file
75
backend/src/modules/admin/system-kb/systemKbRoutes.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 系统知识库路由
|
||||||
|
*
|
||||||
|
* 路由前缀:/api/v1/admin/system-kb
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import {
|
||||||
|
listKnowledgeBases,
|
||||||
|
getKnowledgeBase,
|
||||||
|
createKnowledgeBase,
|
||||||
|
updateKnowledgeBase,
|
||||||
|
deleteKnowledgeBase,
|
||||||
|
listDocuments,
|
||||||
|
uploadDocument,
|
||||||
|
deleteDocument,
|
||||||
|
downloadDocument,
|
||||||
|
} from './systemKbController.js';
|
||||||
|
import { authenticate, requireRoles } from '../../../common/auth/auth.middleware.js';
|
||||||
|
|
||||||
|
export async function systemKbRoutes(fastify: FastifyInstance) {
|
||||||
|
// 所有路由都需要认证 + SUPER_ADMIN 或 ADMIN 角色
|
||||||
|
const preHandler = [authenticate, requireRoles('SUPER_ADMIN', 'ADMIN')];
|
||||||
|
|
||||||
|
// ==================== 知识库 CRUD ====================
|
||||||
|
|
||||||
|
// 获取知识库列表
|
||||||
|
fastify.get('/', {
|
||||||
|
preHandler,
|
||||||
|
}, listKnowledgeBases as any);
|
||||||
|
|
||||||
|
// 创建知识库
|
||||||
|
fastify.post('/', {
|
||||||
|
preHandler,
|
||||||
|
}, createKnowledgeBase as any);
|
||||||
|
|
||||||
|
// 获取知识库详情
|
||||||
|
fastify.get('/:id', {
|
||||||
|
preHandler,
|
||||||
|
}, getKnowledgeBase as any);
|
||||||
|
|
||||||
|
// 更新知识库
|
||||||
|
fastify.patch('/:id', {
|
||||||
|
preHandler,
|
||||||
|
}, updateKnowledgeBase as any);
|
||||||
|
|
||||||
|
// 删除知识库
|
||||||
|
fastify.delete('/:id', {
|
||||||
|
preHandler,
|
||||||
|
}, deleteKnowledgeBase as any);
|
||||||
|
|
||||||
|
// ==================== 文档管理 ====================
|
||||||
|
|
||||||
|
// 获取文档列表
|
||||||
|
fastify.get('/:id/documents', {
|
||||||
|
preHandler,
|
||||||
|
}, listDocuments as any);
|
||||||
|
|
||||||
|
// 上传文档
|
||||||
|
fastify.post('/:id/documents', {
|
||||||
|
preHandler,
|
||||||
|
}, uploadDocument as any);
|
||||||
|
|
||||||
|
// 删除文档
|
||||||
|
fastify.delete('/:id/documents/:docId', {
|
||||||
|
preHandler,
|
||||||
|
}, deleteDocument as any);
|
||||||
|
|
||||||
|
// 下载文档(获取签名 URL)
|
||||||
|
fastify.get('/:id/documents/:docId/download', {
|
||||||
|
preHandler,
|
||||||
|
}, downloadDocument as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default systemKbRoutes;
|
||||||
505
backend/src/modules/admin/system-kb/systemKbService.ts
Normal file
505
backend/src/modules/admin/system-kb/systemKbService.ts
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
/**
|
||||||
|
* 系统知识库服务
|
||||||
|
*
|
||||||
|
* 运营管理端的公共知识库,供 Prompt 引用
|
||||||
|
* 复用 RAG 引擎的核心能力
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import path from 'path';
|
||||||
|
import { logger } from '../../../common/logging/index.js';
|
||||||
|
import { getDocumentIngestService, type IngestResult } from '../../../common/rag/index.js';
|
||||||
|
import { storage, OSSAdapter } from '../../../common/storage/index.js';
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
export interface CreateKbInput {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateKbInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemKb {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
category: string | null;
|
||||||
|
documentCount: number;
|
||||||
|
totalTokens: number;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemKbDocument {
|
||||||
|
id: string;
|
||||||
|
kbId: string;
|
||||||
|
filename: string;
|
||||||
|
filePath: string | null;
|
||||||
|
fileSize: number | null;
|
||||||
|
fileType: string | null;
|
||||||
|
tokenCount: number;
|
||||||
|
status: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SystemKbService ====================
|
||||||
|
|
||||||
|
export class SystemKbService {
|
||||||
|
private prisma: PrismaClient;
|
||||||
|
|
||||||
|
constructor(prisma: PrismaClient) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识库列表
|
||||||
|
*/
|
||||||
|
async listKnowledgeBases(options?: {
|
||||||
|
category?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<SystemKb[]> {
|
||||||
|
const where: any = {};
|
||||||
|
if (options?.category) where.category = options.category;
|
||||||
|
if (options?.status) where.status = options.status;
|
||||||
|
|
||||||
|
const kbs = await this.prisma.system_knowledge_bases.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return kbs.map(kb => ({
|
||||||
|
id: kb.id,
|
||||||
|
code: kb.code,
|
||||||
|
name: kb.name,
|
||||||
|
description: kb.description,
|
||||||
|
category: kb.category,
|
||||||
|
documentCount: kb.document_count,
|
||||||
|
totalTokens: kb.total_tokens,
|
||||||
|
status: kb.status,
|
||||||
|
createdAt: kb.created_at,
|
||||||
|
updatedAt: kb.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识库详情
|
||||||
|
*/
|
||||||
|
async getKnowledgeBase(id: string): Promise<SystemKb | null> {
|
||||||
|
const kb = await this.prisma.system_knowledge_bases.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!kb) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: kb.id,
|
||||||
|
code: kb.code,
|
||||||
|
name: kb.name,
|
||||||
|
description: kb.description,
|
||||||
|
category: kb.category,
|
||||||
|
documentCount: kb.document_count,
|
||||||
|
totalTokens: kb.total_tokens,
|
||||||
|
status: kb.status,
|
||||||
|
createdAt: kb.created_at,
|
||||||
|
updatedAt: kb.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建知识库
|
||||||
|
*/
|
||||||
|
async createKnowledgeBase(input: CreateKbInput): Promise<SystemKb> {
|
||||||
|
const { code, name, description, category } = input;
|
||||||
|
|
||||||
|
// 检查 code 是否已存在
|
||||||
|
const existing = await this.prisma.system_knowledge_bases.findUnique({
|
||||||
|
where: { code },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`知识库编码 ${code} 已存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用事务同时创建两个表的记录
|
||||||
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
|
// 1. 创建系统知识库记录
|
||||||
|
const systemKb = await tx.system_knowledge_bases.create({
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 在 ekb_schema 创建对应的知识库记录(用于向量存储)
|
||||||
|
await tx.ekbKnowledgeBase.create({
|
||||||
|
data: {
|
||||||
|
id: systemKb.id, // 使用相同的 ID
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
type: 'SYSTEM',
|
||||||
|
ownerId: code, // 使用 code 作为 ownerId
|
||||||
|
config: {
|
||||||
|
source: 'system_knowledge_base',
|
||||||
|
systemKbCode: code,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return systemKb;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`创建系统知识库: ${code}`, { id: result.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
code: result.code,
|
||||||
|
name: result.name,
|
||||||
|
description: result.description,
|
||||||
|
category: result.category,
|
||||||
|
documentCount: result.document_count,
|
||||||
|
totalTokens: result.total_tokens,
|
||||||
|
status: result.status,
|
||||||
|
createdAt: result.created_at,
|
||||||
|
updatedAt: result.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新知识库
|
||||||
|
*/
|
||||||
|
async updateKnowledgeBase(id: string, input: UpdateKbInput): Promise<SystemKb> {
|
||||||
|
const kb = await this.prisma.system_knowledge_bases.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
category: input.category,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步更新 ekb_knowledge_base
|
||||||
|
await this.prisma.ekbKnowledgeBase.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
},
|
||||||
|
}).catch(() => {
|
||||||
|
// 忽略更新失败(可能不存在)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: kb.id,
|
||||||
|
code: kb.code,
|
||||||
|
name: kb.name,
|
||||||
|
description: kb.description,
|
||||||
|
category: kb.category,
|
||||||
|
documentCount: kb.document_count,
|
||||||
|
totalTokens: kb.total_tokens,
|
||||||
|
status: kb.status,
|
||||||
|
createdAt: kb.created_at,
|
||||||
|
updatedAt: kb.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除知识库
|
||||||
|
*/
|
||||||
|
async deleteKnowledgeBase(id: string): Promise<void> {
|
||||||
|
// 使用事务删除
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
// 1. 删除 ekb_schema 中的数据(级联删除 documents 和 chunks)
|
||||||
|
await tx.ekbKnowledgeBase.delete({
|
||||||
|
where: { id },
|
||||||
|
}).catch(() => {
|
||||||
|
// 忽略删除失败
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 删除系统知识库文档记录
|
||||||
|
await tx.system_kb_documents.deleteMany({
|
||||||
|
where: { kb_id: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 删除系统知识库记录
|
||||||
|
await tx.system_knowledge_bases.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`删除系统知识库: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档列表
|
||||||
|
*/
|
||||||
|
async listDocuments(kbId: string): Promise<SystemKbDocument[]> {
|
||||||
|
const docs = await this.prisma.system_kb_documents.findMany({
|
||||||
|
where: { kb_id: kbId },
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return docs.map(doc => ({
|
||||||
|
id: doc.id,
|
||||||
|
kbId: doc.kb_id,
|
||||||
|
filename: doc.filename,
|
||||||
|
filePath: doc.file_path,
|
||||||
|
fileSize: doc.file_size,
|
||||||
|
fileType: doc.file_type,
|
||||||
|
tokenCount: doc.token_count,
|
||||||
|
status: doc.status,
|
||||||
|
errorMessage: doc.error_message,
|
||||||
|
createdAt: doc.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文档
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 创建文档记录获取 docId
|
||||||
|
* 2. 上传文件到 OSS: system/knowledge-bases/{kbId}/{docId}.{ext}
|
||||||
|
* 3. 调用 RAG 引擎入库(向量化)
|
||||||
|
* 4. 更新文档状态和统计
|
||||||
|
*/
|
||||||
|
async uploadDocument(
|
||||||
|
kbId: string,
|
||||||
|
filename: string,
|
||||||
|
fileBuffer: Buffer
|
||||||
|
): Promise<{ docId: string; ingestResult: IngestResult }> {
|
||||||
|
// 1. 检查知识库是否存在
|
||||||
|
const kb = await this.prisma.system_knowledge_bases.findUnique({
|
||||||
|
where: { id: kbId },
|
||||||
|
});
|
||||||
|
if (!kb) {
|
||||||
|
throw new Error(`知识库 ${kbId} 不存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 创建文档记录(状态:pending),获取 docId
|
||||||
|
const doc = await this.prisma.system_kb_documents.create({
|
||||||
|
data: {
|
||||||
|
kb_id: kbId,
|
||||||
|
filename,
|
||||||
|
file_size: fileBuffer.length,
|
||||||
|
file_type: this.getFileType(filename),
|
||||||
|
status: 'processing',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. 生成 OSS 存储路径并上传
|
||||||
|
const ossKey = this.generateOssKey(kbId, doc.id, filename);
|
||||||
|
const ossUrl = await storage.upload(ossKey, fileBuffer);
|
||||||
|
|
||||||
|
// 4. 更新 file_path
|
||||||
|
await this.prisma.system_kb_documents.update({
|
||||||
|
where: { id: doc.id },
|
||||||
|
data: { file_path: ossKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`文件已上传到 OSS: ${ossKey}`, { kbId, docId: doc.id });
|
||||||
|
|
||||||
|
// 5. 调用 RAG 引擎入库(向量化)
|
||||||
|
const ingestService = getDocumentIngestService(this.prisma);
|
||||||
|
const ingestResult = await ingestService.ingestDocument(
|
||||||
|
{ filename, fileBuffer },
|
||||||
|
{ kbId, contentType: 'REFERENCE' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ingestResult.success) {
|
||||||
|
// 6. 更新文档状态为 ready
|
||||||
|
await this.prisma.system_kb_documents.update({
|
||||||
|
where: { id: doc.id },
|
||||||
|
data: {
|
||||||
|
status: 'ready',
|
||||||
|
token_count: ingestResult.tokenCount || 0,
|
||||||
|
content: null, // 内容存在 ekb_chunk 中
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. 更新知识库统计
|
||||||
|
await this.prisma.system_knowledge_bases.update({
|
||||||
|
where: { id: kbId },
|
||||||
|
data: {
|
||||||
|
document_count: { increment: 1 },
|
||||||
|
total_tokens: { increment: ingestResult.tokenCount || 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`文档上传成功: ${filename}`, {
|
||||||
|
kbId,
|
||||||
|
docId: doc.id,
|
||||||
|
ossKey,
|
||||||
|
chunks: ingestResult.chunkCount,
|
||||||
|
tokens: ingestResult.tokenCount,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 入库失败,但文件已上传到 OSS
|
||||||
|
await this.prisma.system_kb_documents.update({
|
||||||
|
where: { id: doc.id },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
error_message: ingestResult.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { docId: doc.id, ingestResult };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// 异常处理
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.prisma.system_kb_documents.update({
|
||||||
|
where: { id: doc.id },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
error_message: errorMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.error(`文档上传失败: ${filename}`, { error: errorMessage });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文档
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 删除 OSS 文件
|
||||||
|
* 2. 删除 ekb_schema 向量数据
|
||||||
|
* 3. 删除 system_kb_documents 记录
|
||||||
|
* 4. 更新知识库统计
|
||||||
|
*/
|
||||||
|
async deleteDocument(kbId: string, docId: string): Promise<void> {
|
||||||
|
const doc = await this.prisma.system_kb_documents.findFirst({
|
||||||
|
where: { id: docId, kb_id: kbId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error(`文档 ${docId} 不存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 删除 OSS 文件
|
||||||
|
if (doc.file_path) {
|
||||||
|
try {
|
||||||
|
await storage.delete(doc.file_path);
|
||||||
|
logger.info(`已删除 OSS 文件: ${doc.file_path}`);
|
||||||
|
} catch (error) {
|
||||||
|
// OSS 删除失败不阻塞流程,只记录警告
|
||||||
|
logger.warn(`删除 OSS 文件失败: ${doc.file_path}`, { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用事务删除数据库记录
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
// 2. 查找 ekb_document(通过 filename 匹配)
|
||||||
|
const ekbDoc = await tx.ekbDocument.findFirst({
|
||||||
|
where: { kbId, filename: doc.filename },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ekbDoc) {
|
||||||
|
// 3. 删除 ekb_chunk(级联删除)
|
||||||
|
await tx.ekbChunk.deleteMany({
|
||||||
|
where: { documentId: ekbDoc.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 删除 ekb_document
|
||||||
|
await tx.ekbDocument.delete({
|
||||||
|
where: { id: ekbDoc.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 删除系统知识库文档记录
|
||||||
|
await tx.system_kb_documents.delete({
|
||||||
|
where: { id: docId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. 更新知识库统计
|
||||||
|
await tx.system_knowledge_bases.update({
|
||||||
|
where: { id: kbId },
|
||||||
|
data: {
|
||||||
|
document_count: { decrement: 1 },
|
||||||
|
total_tokens: { decrement: doc.token_count },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`删除文档: ${doc.filename}`, { kbId, docId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档下载 URL
|
||||||
|
*/
|
||||||
|
async getDocumentDownloadUrl(kbId: string, docId: string): Promise<{
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
fileSize: number | null;
|
||||||
|
}> {
|
||||||
|
const doc = await this.prisma.system_kb_documents.findFirst({
|
||||||
|
where: { id: docId, kb_id: kbId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error(`文档 ${docId} 不存在`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc.file_path) {
|
||||||
|
throw new Error('文档文件路径不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取签名 URL(有效期 1 小时),传入原始文件名以设置 Content-Disposition
|
||||||
|
// 这样浏览器下载时会使用原始文件名而不是 UUID
|
||||||
|
const ossAdapter = storage as OSSAdapter;
|
||||||
|
const url = ossAdapter.getSignedUrl(doc.file_path, 3600, doc.filename);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
filename: doc.filename,
|
||||||
|
fileSize: doc.file_size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 OSS 存储路径
|
||||||
|
*
|
||||||
|
* 格式:system/knowledge-bases/{kbId}/{docId}.{ext}
|
||||||
|
*
|
||||||
|
* @param kbId - 知识库 ID
|
||||||
|
* @param docId - 文档 ID
|
||||||
|
* @param filename - 原始文件名(用于获取扩展名)
|
||||||
|
*/
|
||||||
|
private generateOssKey(kbId: string, docId: string, filename: string): string {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
return `system/knowledge-bases/${kbId}/${docId}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件类型
|
||||||
|
*/
|
||||||
|
private getFileType(filename: string): string {
|
||||||
|
const ext = filename.toLowerCase().split('.').pop();
|
||||||
|
return ext || 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 单例工厂 ====================
|
||||||
|
|
||||||
|
let serviceInstance: SystemKbService | null = null;
|
||||||
|
|
||||||
|
export function getSystemKbService(prisma: PrismaClient): SystemKbService {
|
||||||
|
if (!serviceInstance) {
|
||||||
|
serviceInstance = new SystemKbService(prisma);
|
||||||
|
}
|
||||||
|
return serviceInstance;
|
||||||
|
}
|
||||||
104
backend/test-oss-upload.cjs
Normal file
104
backend/test-oss-upload.cjs
Normal file
@@ -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);
|
||||||
22
backend/verify-migration.cjs
Normal file
22
backend/verify-migration.cjs
Normal file
@@ -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();
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
# AIclinicalresearch 系统当前状态与开发指南
|
# AIclinicalresearch 系统当前状态与开发指南
|
||||||
|
|
||||||
> **文档版本:** v4.3
|
> **文档版本:** v4.4
|
||||||
> **创建日期:** 2025-11-28
|
> **创建日期:** 2025-11-28
|
||||||
> **维护者:** 开发团队
|
> **维护者:** 开发团队
|
||||||
> **最后更新:** 2026-01-25
|
> **最后更新:** 2026-01-27
|
||||||
> **🎉 重大里程碑:**
|
> **🎉 重大里程碑:**
|
||||||
|
> - **2026-01-27:系统知识库管理功能完成!** 运营管理端新增知识库管理+文档上传下载
|
||||||
> - **2026-01-25:Protocol Agent MVP完整交付!** 一键生成研究方案+Word导出
|
> - **2026-01-25:Protocol Agent MVP完整交付!** 一键生成研究方案+Word导出
|
||||||
> - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程
|
> - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程
|
||||||
> - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层
|
> - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层
|
||||||
> - **2026-01-21:成功替换 Dify!** PKB 模块完全使用自研 pgvector RAG 引擎
|
> - **2026-01-21:成功替换 Dify!** PKB 模块完全使用自研 pgvector RAG 引擎
|
||||||
>
|
>
|
||||||
> **最新进展(Protocol Agent MVP 完整交付 2026-01-25):**
|
> **最新进展(系统知识库管理 2026-01-27):**
|
||||||
> - ✅ **一键生成研究方案**:流式输出+A4预览+12章节结构
|
> - ✅ **系统知识库管理**:运营管理端新增知识库模块,支持 Prompt 引用
|
||||||
> - ✅ **Word文档导出**:Pandoc转换,格式完美
|
> - ✅ **主从页面模式**:Master-Detail UX,卡片列表+文档管理表格
|
||||||
> - ✅ **动态双面板布局**:可拖拽调整,收集65:35/生成35:65
|
> - ✅ **文档管理**:上传(单个/批量)、下载(保留原始文件名)、删除
|
||||||
> - ✅ **用户体验优化**:折叠展开、延迟创建、滚动跟随
|
> - ✅ **RAG 引擎集成**:文档解析、分块、向量化存储
|
||||||
> - ✅ **代码总量**:~8,500行(前端3,300+后端4,700+Python500)
|
> - ✅ **OSS 存储集成**:system/knowledge-bases/{kbId}/{docId} 路径
|
||||||
>
|
>
|
||||||
> **部署状态:** ✅ 生产环境运行中 | 公网地址:http://8.140.53.236/
|
> **部署状态:** ✅ 生产环境运行中 | 公网地址:http://8.140.53.236/
|
||||||
> **文档目的:** 快速了解系统当前状态,为新AI助手提供上下文
|
> **文档目的:** 快速了解系统当前状态,为新AI助手提供上下文
|
||||||
@@ -58,7 +59,7 @@
|
|||||||
| **SSA** | 智能统计分析 | 队列/预测模型/RCT分析 | ⭐⭐⭐⭐⭐ | 📋 规划中 | P2 |
|
| **SSA** | 智能统计分析 | 队列/预测模型/RCT分析 | ⭐⭐⭐⭐⭐ | 📋 规划中 | P2 |
|
||||||
| **ST** | 统计分析工具 | 100+轻量化统计工具 | ⭐⭐⭐⭐ | 📋 规划中 | P2 |
|
| **ST** | 统计分析工具 | 100+轻量化统计工具 | ⭐⭐⭐⭐ | 📋 规划中 | P2 |
|
||||||
| **RVW** | 稿件审查系统 | 方法学评估、审稿流程、Word导出 | ⭐⭐⭐⭐ | ✅ **开发完成(95%)** | P3 |
|
| **RVW** | 稿件审查系统 | 方法学评估、审稿流程、Word导出 | ⭐⭐⭐⭐ | ✅ **开发完成(95%)** | P3 |
|
||||||
| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.2完成(80%)** - 运营监控MVP+登录优化 | **P0** |
|
| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控、系统知识库 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.5完成(85%)** - 系统知识库管理+文档上传下载 | **P0** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# ADMIN-运营管理端 - 模块当前状态与开发指南
|
# ADMIN-运营管理端 - 模块当前状态与开发指南
|
||||||
|
|
||||||
> **最后更新:** 2026-01-25
|
> **最后更新:** 2026-01-27
|
||||||
> **状态:** ✅ Phase 4.2 运营监控系统MVP完成!登录跳转逻辑优化完成!
|
> **状态:** ✅ Phase 4.5 系统知识库管理功能完成!
|
||||||
> **版本:** v0.6 (Alpha)
|
> **版本:** v0.7 (Alpha)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -113,6 +113,16 @@
|
|||||||
- [x] 修复:PKB 工作区问答页面布局问题(CSS类名冲突)
|
- [x] 修复:PKB 工作区问答页面布局问题(CSS类名冲突)
|
||||||
- [x] 修复:Protocol Agent 模块 CSS 类名重命名(.pa-chat-container)
|
- [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 - 用户管理增强(可选)**
|
**P2 - 用户管理增强(可选)**
|
||||||
|
|||||||
146
docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-27_系统知识库管理功能完成.md
Normal file
146
docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-27_系统知识库管理功能完成.md
Normal file
@@ -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 路由配置
|
||||||
|
<Route path="system-kb" element={<SystemKbListPage />} />
|
||||||
|
<Route path="system-kb/:id" element={<SystemKbDetailPage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 代码量统计
|
||||||
|
|
||||||
|
| 文件类型 | 文件数 | 代码行数(约) |
|
||||||
|
|---------|--------|---------------|
|
||||||
|
| 后端 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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*开发记录完成*
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
|
||||||
**维护人:** 技术架构师
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 个任务。
|
|
||||||
@@ -21,6 +21,9 @@ import { MODULES } from './framework/modules/moduleRegistry'
|
|||||||
import UserListPage from './modules/admin/pages/UserListPage'
|
import UserListPage from './modules/admin/pages/UserListPage'
|
||||||
import UserFormPage from './modules/admin/pages/UserFormPage'
|
import UserFormPage from './modules/admin/pages/UserFormPage'
|
||||||
import UserDetailPage from './modules/admin/pages/UserDetailPage'
|
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'
|
import ProfilePage from './pages/user/ProfilePage'
|
||||||
|
|
||||||
@@ -109,6 +112,9 @@ function App() {
|
|||||||
<Route path="users/create" element={<UserFormPage mode="create" />} />
|
<Route path="users/create" element={<UserFormPage mode="create" />} />
|
||||||
<Route path="users/:id" element={<UserDetailPage />} />
|
<Route path="users/:id" element={<UserDetailPage />} />
|
||||||
<Route path="users/:id/edit" element={<UserFormPage mode="edit" />} />
|
<Route path="users/:id/edit" element={<UserFormPage mode="edit" />} />
|
||||||
|
{/* 系统知识库 */}
|
||||||
|
<Route path="system-kb" element={<SystemKbListPage />} />
|
||||||
|
<Route path="system-kb/:id" element={<SystemKbDetailPage />} />
|
||||||
{/* 系统配置 */}
|
{/* 系统配置 */}
|
||||||
<Route path="system" element={<div className="text-center py-20">🚧 系统配置页面开发中...</div>} />
|
<Route path="system" element={<div className="text-center py-20">🚧 系统配置页面开发中...</div>} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
|
BookOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { useAuth } from '../auth'
|
import { useAuth } from '../auth'
|
||||||
@@ -83,6 +84,11 @@ const AdminLayout = () => {
|
|||||||
icon: <CodeOutlined />,
|
icon: <CodeOutlined />,
|
||||||
label: 'Prompt管理',
|
label: 'Prompt管理',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/system-kb',
|
||||||
|
icon: <BookOutlined />,
|
||||||
|
label: '系统知识库',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '/admin/tenants',
|
key: '/admin/tenants',
|
||||||
icon: <TeamOutlined />,
|
icon: <TeamOutlined />,
|
||||||
|
|||||||
125
frontend-v2/src/modules/admin/api/systemKbApi.ts
Normal file
125
frontend-v2/src/modules/admin/api/systemKbApi.ts
Normal file
@@ -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<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识库列表
|
||||||
|
*/
|
||||||
|
export async function listKnowledgeBases(params?: {
|
||||||
|
category?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<SystemKb[]> {
|
||||||
|
const response = await apiClient.get<ApiResponse<SystemKb[]>>(BASE_URL, { params });
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识库详情
|
||||||
|
*/
|
||||||
|
export async function getKnowledgeBase(id: string): Promise<SystemKb> {
|
||||||
|
const response = await apiClient.get<ApiResponse<SystemKb>>(`${BASE_URL}/${id}`);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建知识库
|
||||||
|
*/
|
||||||
|
export async function createKnowledgeBase(data: CreateKbRequest): Promise<SystemKb> {
|
||||||
|
const response = await apiClient.post<ApiResponse<SystemKb>>(BASE_URL, data);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新知识库
|
||||||
|
*/
|
||||||
|
export async function updateKnowledgeBase(id: string, data: UpdateKbRequest): Promise<SystemKb> {
|
||||||
|
const response = await apiClient.patch<ApiResponse<SystemKb>>(`${BASE_URL}/${id}`, data);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除知识库
|
||||||
|
*/
|
||||||
|
export async function deleteKnowledgeBase(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`${BASE_URL}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识库文档列表
|
||||||
|
*/
|
||||||
|
export async function listDocuments(kbId: string): Promise<SystemKbDocument[]> {
|
||||||
|
const response = await apiClient.get<ApiResponse<SystemKbDocument[]>>(
|
||||||
|
`${BASE_URL}/${kbId}/documents`
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文档
|
||||||
|
*/
|
||||||
|
export async function uploadDocument(
|
||||||
|
kbId: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (percent: number) => void
|
||||||
|
): Promise<UploadDocumentResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await apiClient.post<ApiResponse<UploadDocumentResponse>>(
|
||||||
|
`${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<void> {
|
||||||
|
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<ApiResponse<{
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
fileSize: number | null;
|
||||||
|
}>>(`${BASE_URL}/${kbId}/documents/${docId}/download`);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
* - 用户管理
|
* - 用户管理
|
||||||
* - 租户管理(已有)
|
* - 租户管理(已有)
|
||||||
* - Prompt管理(已有)
|
* - Prompt管理(已有)
|
||||||
|
* - 系统知识库管理
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -14,6 +15,8 @@ import UserListPage from './pages/UserListPage';
|
|||||||
import UserFormPage from './pages/UserFormPage';
|
import UserFormPage from './pages/UserFormPage';
|
||||||
import UserDetailPage from './pages/UserDetailPage';
|
import UserDetailPage from './pages/UserDetailPage';
|
||||||
import StatsDashboardPage from './pages/StatsDashboardPage';
|
import StatsDashboardPage from './pages/StatsDashboardPage';
|
||||||
|
import SystemKbListPage from './pages/SystemKbListPage';
|
||||||
|
import SystemKbDetailPage from './pages/SystemKbDetailPage';
|
||||||
|
|
||||||
const AdminModule: React.FC = () => {
|
const AdminModule: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +31,10 @@ const AdminModule: React.FC = () => {
|
|||||||
<Route path="users/create" element={<UserFormPage mode="create" />} />
|
<Route path="users/create" element={<UserFormPage mode="create" />} />
|
||||||
<Route path="users/:id" element={<UserDetailPage />} />
|
<Route path="users/:id" element={<UserDetailPage />} />
|
||||||
<Route path="users/:id/edit" element={<UserFormPage mode="edit" />} />
|
<Route path="users/:id/edit" element={<UserFormPage mode="edit" />} />
|
||||||
|
|
||||||
|
{/* 系统知识库管理 */}
|
||||||
|
<Route path="system-kb" element={<SystemKbListPage />} />
|
||||||
|
<Route path="system-kb/:id" element={<SystemKbDetailPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
451
frontend-v2/src/modules/admin/pages/SystemKbDetailPage.tsx
Normal file
451
frontend-v2/src/modules/admin/pages/SystemKbDetailPage.tsx
Normal file
@@ -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 <FilePdfOutlined className="text-red-500" />;
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
return <FileWordOutlined className="text-blue-500" />;
|
||||||
|
case 'txt':
|
||||||
|
case 'md':
|
||||||
|
return <FileTextOutlined className="text-gray-500" />;
|
||||||
|
default:
|
||||||
|
return <FileUnknownOutlined className="text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 格式化文件大小 */
|
||||||
|
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<SystemKb | null>(null);
|
||||||
|
const [documents, setDocuments] = useState<SystemKbDocument[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [docsLoading, setDocsLoading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
|
||||||
|
// 加载知识库详情
|
||||||
|
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<SystemKbDocument> = [
|
||||||
|
{
|
||||||
|
title: '文件名',
|
||||||
|
dataIndex: 'filename',
|
||||||
|
key: 'filename',
|
||||||
|
render: (text, record) => (
|
||||||
|
<Space>
|
||||||
|
{getFileIcon(record.fileType)}
|
||||||
|
<span className="font-medium">{text}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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<string, { color: string; text: string }> = {
|
||||||
|
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 (
|
||||||
|
<Tooltip title={record.errorMessage}>
|
||||||
|
<Tag color={config.color}>{config.text}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '上传时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 160,
|
||||||
|
render: (val) => formatDate(val),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 120,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Tooltip title="下载">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={() => handleDownload(record)}
|
||||||
|
disabled={record.status !== 'ready'}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此文档?"
|
||||||
|
onConfirm={() => handleDeleteDoc(record)}
|
||||||
|
>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="h-96 flex items-center justify-center">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kb) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<Empty description="知识库不存在" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* 面包屑 */}
|
||||||
|
<Breadcrumb
|
||||||
|
className="mb-4"
|
||||||
|
items={[
|
||||||
|
{ title: <a onClick={() => navigate('/admin/system-kb')}>系统知识库</a> },
|
||||||
|
{ title: kb.name },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 返回按钮和标题 */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/admin/system-kb')}
|
||||||
|
>
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<Title level={4} className="mb-0">{kb.name}</Title>
|
||||||
|
<Text code>{kb.code}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 知识库信息卡片 */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<Descriptions
|
||||||
|
column={{ xs: 1, sm: 2, md: 4 }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'documents',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<FileTextOutlined />
|
||||||
|
<span>文档数</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: <span className="text-xl font-semibold">{kb.documentCount}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tokens',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<DatabaseOutlined />
|
||||||
|
<span>总 Tokens</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: <span className="text-xl font-semibold">{kb.totalTokens.toLocaleString()}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'created',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
<span>创建时间</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: formatDate(kb.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
label: '描述',
|
||||||
|
children: kb.description || <Text type="secondary">无</Text>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 文档管理卡片 */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FileTextOutlined />
|
||||||
|
<span>文档列表</span>
|
||||||
|
<Tag>{documents.length} 个</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
{selectedRowKeys.length > 0 && (
|
||||||
|
<Popconfirm
|
||||||
|
title={`确定删除选中的 ${selectedRowKeys.length} 个文档?`}
|
||||||
|
onConfirm={handleBatchDelete}
|
||||||
|
>
|
||||||
|
<Button danger icon={<DeleteOutlined />}>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
<Upload
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.md"
|
||||||
|
showUploadList={false}
|
||||||
|
multiple
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
handleUpload(file);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="primary" icon={<UploadOutlined />}>
|
||||||
|
上传文档
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 上传进度 */}
|
||||||
|
{uploadProgress !== null && (
|
||||||
|
<div className="mb-4 p-4 bg-blue-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Spin size="small" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Progress percent={uploadProgress} status="active" />
|
||||||
|
<Text type="secondary">正在上传并处理文档(向量化)...</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 文档表格 */}
|
||||||
|
<Spin spinning={docsLoading}>
|
||||||
|
{documents.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: setSelectedRowKeys,
|
||||||
|
}}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={documents}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={documents.length > 10 ? { pageSize: 10 } : false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="暂无文档"
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.md"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
handleUpload(file);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="primary" icon={<UploadOutlined />}>
|
||||||
|
上传第一个文档
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemKbDetailPage;
|
||||||
316
frontend-v2/src/modules/admin/pages/SystemKbListPage.tsx
Normal file
316
frontend-v2/src/modules/admin/pages/SystemKbListPage.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
guidelines: '#1890ff',
|
||||||
|
methodology: '#52c41a',
|
||||||
|
regulations: '#faad14',
|
||||||
|
templates: '#722ed1',
|
||||||
|
other: '#8c8c8c',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SystemKbListPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [knowledgeBases, setKnowledgeBases] = useState<SystemKb[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<Title level={3} className="mb-1">系统知识库</Title>
|
||||||
|
<Text type="secondary">管理运营端公共知识库,供 Prompt 引用</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={loadKnowledgeBases}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
新建知识库
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 知识库卡片列表 */}
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{knowledgeBases.length > 0 ? (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{knowledgeBases.map((kb) => (
|
||||||
|
<Col key={kb.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
className="h-full cursor-pointer transition-all hover:shadow-lg"
|
||||||
|
onClick={() => handleCardClick(kb)}
|
||||||
|
actions={[
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title="确定删除此知识库?"
|
||||||
|
description="将同时删除所有文档,此操作不可恢复"
|
||||||
|
onConfirm={(e) => handleDelete(e as React.MouseEvent, kb)}
|
||||||
|
onCancel={(e) => e?.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>,
|
||||||
|
<Button
|
||||||
|
key="manage"
|
||||||
|
type="text"
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
onClick={() => handleCardClick(kb)}
|
||||||
|
>
|
||||||
|
管理
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* 卡片头部 */}
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-lg flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${CATEGORY_COLORS[kb.category || 'other']}15`,
|
||||||
|
color: CATEGORY_COLORS[kb.category || 'other'],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOutlined className="text-2xl" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Title level={5} className="mb-0 truncate" title={kb.name}>
|
||||||
|
{kb.name}
|
||||||
|
</Title>
|
||||||
|
<Text code className="text-xs">{kb.code}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
{kb.description && (
|
||||||
|
<Paragraph
|
||||||
|
type="secondary"
|
||||||
|
className="text-sm mb-4"
|
||||||
|
ellipsis={{ rows: 2 }}
|
||||||
|
>
|
||||||
|
{kb.description}
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 统计数据 */}
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title={<span className="text-xs">文档数</span>}
|
||||||
|
value={kb.documentCount}
|
||||||
|
prefix={<FileTextOutlined />}
|
||||||
|
valueStyle={{ fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title={<span className="text-xs">Tokens</span>}
|
||||||
|
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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 新建卡片 */}
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
className="h-full cursor-pointer border-dashed flex items-center justify-center"
|
||||||
|
style={{ minHeight: 260 }}
|
||||||
|
onClick={() => setCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<PlusOutlined className="text-4xl text-gray-400 mb-4" />
|
||||||
|
<div className="text-gray-500">新建知识库</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="暂无知识库"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
创建第一个知识库
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
{/* 创建知识库弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="创建知识库"
|
||||||
|
open={createModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
okText="创建"
|
||||||
|
cancelText="取消"
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleCreate}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="code"
|
||||||
|
label="知识库编码"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入编码' },
|
||||||
|
{ pattern: /^[A-Z][A-Z0-9_]*$/, message: '编码只能包含大写字母、数字和下划线,且以字母开头' },
|
||||||
|
]}
|
||||||
|
extra="唯一标识符,用于 Prompt 引用"
|
||||||
|
>
|
||||||
|
<Input placeholder="如:CLINICAL_GUIDELINES" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="知识库名称"
|
||||||
|
rules={[{ required: true, message: '请输入名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="如:临床指南知识库" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="category" label="分类">
|
||||||
|
<Select
|
||||||
|
placeholder="选择分类"
|
||||||
|
options={CATEGORY_OPTIONS}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<TextArea rows={3} placeholder="知识库描述(可选)" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemKbListPage;
|
||||||
55
frontend-v2/src/modules/admin/types/systemKb.ts
Normal file
55
frontend-v2/src/modules/admin/types/systemKb.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 系统知识库类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 知识库 */
|
||||||
|
export interface SystemKb {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
category: string | null;
|
||||||
|
documentCount: number;
|
||||||
|
totalTokens: number;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 知识库文档 */
|
||||||
|
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: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建知识库请求 */
|
||||||
|
export interface CreateKbRequest {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新知识库请求 */
|
||||||
|
export interface UpdateKbRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传文档响应 */
|
||||||
|
export interface UploadDocumentResponse {
|
||||||
|
docId: string;
|
||||||
|
filename: string;
|
||||||
|
chunkCount: number;
|
||||||
|
tokenCount: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user