""" 知识库管理 API(管理员) Phase 2.4: AI PaaS Knowledge Base Management + External KB Integration """ from fastapi import APIRouter, Depends, HTTPException, Query, Body from sqlalchemy.orm import Session from sqlalchemy import or_, func, desc, asc from pydantic import BaseModel, Field from typing import Optional, List, Dict, Any from datetime import datetime import uuid import json import logging import httpx from ...database import get_db from ...models import ( KnowledgeBase, KnowledgeDocument, KnowledgeDirectory, KnowledgeGraphNode, KnowledgeGraphEdge, KnowledgeGraphBuildLog, User, gen_uuid ) from ...auth import get_current_admin from ...middleware.log_service import LogService router = APIRouter() logger = logging.getLogger("aiflow") # ============================================================ # Pydantic 模型 # ============================================================ class ExternalServiceConfig(BaseModel): name: str = Field(min_length=1, max_length=200) service_type: str = Field(..., description="fastgpt / dify / ragflow") api_url: str = Field(min_length=1, max_length=500) api_key: Optional[str] = None config: Optional[Dict[str, Any]] = None class ExternalServiceUpdate(BaseModel): name: Optional[str] = None api_url: Optional[str] = None api_key: Optional[str] = None config: Optional[Dict[str, Any]] = None is_enabled: Optional[bool] = None class KBCreate(BaseModel): name: str = Field(min_length=1, max_length=200) description: Optional[str] = None is_official: bool = False external_service_id: Optional[str] = None external_config: Optional[Dict[str, Any]] = None class KBUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None is_official: Optional[bool] = None status: Optional[str] = None # active / inactive / archived external_service_id: Optional[str] = None external_config: Optional[Dict[str, Any]] = None class SyncRequest(BaseModel): external_service_id: Optional[str] = None # override KB's linked service sync_direction: str = "pull" # pull / push / bidirectional options: Optional[Dict[str, Any]] = None # ============================================================ # 辅助函数 # ============================================================ def kb_to_dict(kb: KnowledgeBase, db: Session = None) -> dict: """将 KnowledgeBase 转换为字典""" result = { "id": kb.id, "user_id": kb.user_id, "name": kb.name, "description": kb.description, "directory_path": kb.directory_path, "document_count": kb.document_count or 0, "knowledge_graph_count": kb.knowledge_graph_count or 0, "created_at": kb.created_at.isoformat() if kb.created_at else None, "updated_at": kb.updated_at.isoformat() if kb.updated_at else None, } # 新增字段(Phase 2.4) # 通过 getattr 安全获取,因为旧记录可能没有这些字段 result["is_official"] = getattr(kb, "is_official", False) result["status"] = getattr(kb, "status", "active") result["external_service_id"] = getattr(kb, "external_service_id", None) result["external_id"] = getattr(kb, "external_id", None) result["external_sync_status"] = getattr(kb, "external_sync_status", "none") result["external_config"] = None ext_config_raw = getattr(kb, "external_config", None) if ext_config_raw: try: result["external_config"] = json.loads(ext_config_raw) if isinstance(ext_config_raw, str) else ext_config_raw except Exception: result["external_config"] = {} # 查询关联用户名 if db and kb.user_id: user = db.query(User).filter(User.id == kb.user_id).first() result["owner_name"] = user.username if user else "未知" # 计算存储占用 if db: total_size = db.query(func.sum(KnowledgeDocument.file_size)).filter( KnowledgeDocument.knowledge_base_id == kb.id ).scalar() or 0 result["total_storage_bytes"] = int(total_size) result["total_storage_mb"] = round(int(total_size) / (1024 * 1024), 2) return result def doc_to_dict(doc: KnowledgeDocument) -> dict: """将 KnowledgeDocument 转换为字典""" return { "id": doc.id, "knowledge_base_id": doc.knowledge_base_id, "directory_id": doc.directory_id, "filename": doc.filename, "file_type": doc.file_type, "file_size": doc.file_size or 0, "file_path": doc.file_path, "status": doc.status, "chunk_count": doc.chunk_count or 0, "error_message": doc.error_message, "created_at": doc.created_at.isoformat() if doc.created_at else None, "updated_at": doc.updated_at.isoformat() if doc.updated_at else None, } def format_file_size(size_bytes: int) -> str: """格式化文件大小""" if size_bytes < 1024: return f"{size_bytes} B" elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB" elif size_bytes < 1024 * 1024 * 1024: return f"{size_bytes / (1024 * 1024):.1f} MB" else: return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB" # ============================================================ # 统计概览 # ============================================================ @router.get("/stats/overview", summary="知识库统计概览") async def get_kb_stats( admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取知识库全局统计数据""" # 基础统计 total_kb = db.query(func.count(KnowledgeBase.id)).scalar() or 0 total_docs = db.query(func.count(KnowledgeDocument.id)).scalar() or 0 total_storage = db.query(func.sum(KnowledgeDocument.file_size)).scalar() or 0 total_nodes = db.query(func.count(KnowledgeGraphNode.id)).scalar() or 0 total_edges = db.query(func.count(KnowledgeGraphEdge.id)).scalar() or 0 # 官方 vs 用户知识库 official_kb = 0 user_kb = 0 try: official_kb = db.query(func.count(KnowledgeBase.id)).filter( getattr(KnowledgeBase, "is_official", False) == True ).scalar() or 0 except Exception: official_kb = 0 user_kb = total_kb - official_kb # 文档状态分布 doc_status_dist = {} statuses = ["uploading", "processing", "completed", "failed"] for s in statuses: cnt = db.query(func.count(KnowledgeDocument.id)).filter( KnowledgeDocument.status == s ).scalar() or 0 doc_status_dist[s] = cnt # 文件类型分布 file_type_dist = {} type_rows = db.query( KnowledgeDocument.file_type, func.count(KnowledgeDocument.id) ).group_by(KnowledgeDocument.file_type).all() for ft, cnt in type_rows: file_type_dist[ft] = cnt # 最近7天创建的知识库数 from datetime import timedelta week_ago = datetime.now() - timedelta(days=7) recent_kb = db.query(func.count(KnowledgeBase.id)).filter( KnowledgeBase.created_at >= week_ago ).scalar() or 0 # 活跃知识库(有文档的知识库) active_kb = db.query(func.count(func.distinct(KnowledgeDocument.knowledge_base_id))).scalar() or 0 # 知识图谱统计 kb_with_graph = db.query(func.count(func.distinct(KnowledgeGraphNode.knowledge_base_id))).scalar() or 0 # 外部集成统计 external_kb = 0 try: external_kb = db.query(func.count(KnowledgeBase.id)).filter( getattr(KnowledgeBase, "external_service_id", None) != None, getattr(KnowledgeBase, "external_service_id", None) != "" ).scalar() or 0 except Exception: external_kb = 0 return { "code": 0, "data": { "total_knowledge_bases": total_kb, "total_documents": total_docs, "total_storage_bytes": int(total_storage), "total_storage_display": format_file_size(int(total_storage)), "official_kb": official_kb, "user_kb": user_kb, "active_kb": active_kb, "kb_with_graph": kb_with_graph, "external_kb": external_kb, "recent_kb_7d": recent_kb, "total_graph_nodes": int(total_nodes), "total_graph_edges": int(total_edges), "doc_status_distribution": doc_status_dist, "file_type_distribution": file_type_dist, } } # ============================================================ # 知识库 CRUD # ============================================================ @router.get("/", summary="获取知识库列表") async def list_knowledge_bases( keyword: str = Query(None, description="搜索关键词"), status: str = Query(None, description="状态: active/inactive/archived"), is_official: bool = Query(None, description="是否官方知识库"), has_external: bool = Query(None, description="是否有外部集成"), page: int = Query(1, ge=1), page_size: int = Query(15, ge=1, le=100), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取所有知识库列表(分页、筛选)""" query = db.query(KnowledgeBase) if keyword: query = query.filter( or_( KnowledgeBase.name.ilike(f"%{keyword}%"), KnowledgeBase.description.ilike(f"%{keyword}%"), ) ) if status: try: query = query.filter(getattr(KnowledgeBase, "status", None) == status) except Exception: pass # 字段可能不存在 if is_official is not None: try: query = query.filter(getattr(KnowledgeBase, "is_official", None) == is_official) except Exception: pass total = query.count() kbs = query.order_by(desc(KnowledgeBase.created_at)).offset( (page - 1) * page_size ).limit(page_size).all() return { "code": 0, "data": { "total": total, "page": page, "page_size": page_size, "items": [kb_to_dict(kb, db) for kb in kbs], } } @router.get("/{kb_id}", summary="获取知识库详情") async def get_knowledge_base( kb_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取知识库详细信息,包含文档和图谱数据""" kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() if not kb: raise HTTPException(status_code=404, detail="知识库不存在") result = kb_to_dict(kb, db) # 文档统计 doc_stats = db.query( KnowledgeDocument.status, func.count(KnowledgeDocument.id), func.sum(KnowledgeDocument.file_size) ).filter(KnowledgeDocument.knowledge_base_id == kb_id).group_by( KnowledgeDocument.status ).all() result["doc_stats"] = { s: {"count": int(cnt), "size": int(sz or 0)} for s, cnt, sz in doc_stats } # 图谱统计 graph_nodes = db.query(func.count(KnowledgeGraphNode.id)).filter( KnowledgeGraphNode.knowledge_base_id == kb_id ).scalar() or 0 graph_edges = db.query(func.count(KnowledgeGraphEdge.id)).filter( KnowledgeGraphEdge.knowledge_base_id == kb_id ).scalar() or 0 result["graph_stats"] = { "nodes": int(graph_nodes), "edges": int(graph_edges), } # 外部服务信息 ext_service_id = getattr(kb, "external_service_id", None) if ext_service_id: # 查询外部服务配置 try: ext_service = db.query(ExternalKBService).filter( ExternalKBService.id == ext_service_id ).first() if ext_service: result["external_service"] = { "id": ext_service.id, "name": ext_service.name, "service_type": ext_service.service_type, "api_url": ext_service.api_url, "status": getattr(ext_service, "status", "unknown"), } except Exception: result["external_service"] = {"id": ext_service_id, "note": "外部服务表尚未初始化"} return {"code": 0, "data": result} @router.post("/", summary="创建知识库") async def create_knowledge_base( req: KBCreate, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """创建新的知识库(支持官方平台知识库)""" # 检查名称唯一性 existing = db.query(KnowledgeBase).filter(KnowledgeBase.name == req.name).first() if existing: raise HTTPException(status_code=400, detail="知识库名称已存在") kb = KnowledgeBase( id=str(uuid.uuid4()), user_id=admin_user.id, name=req.name, description=req.description or "", directory_path="/", document_count=0, knowledge_graph_count=0, created_at=datetime.now(), updated_at=datetime.now(), ) # Phase 2.4 新增字段 try: setattr(kb, "is_official", req.is_official) setattr(kb, "status", "active") if req.external_service_id: setattr(kb, "external_service_id", req.external_service_id) if req.external_config: setattr(kb, "external_config", json.dumps(req.external_config, ensure_ascii=False)) setattr(kb, "external_sync_status", "none") except Exception: logger.warning("KnowledgeBase 新增字段设置失败,可能表结构尚未更新") db.add(kb) db.commit() db.refresh(kb) LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="create_knowledge_base", target_type="knowledge_base", target_id=kb.id, detail=f"创建知识库: {kb.name} (官方={req.is_official})", ) return {"code": 0, "message": "知识库创建成功", "data": kb_to_dict(kb, db)} @router.put("/{kb_id}", summary="更新知识库") async def update_knowledge_base( kb_id: str, req: KBUpdate, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """更新知识库配置""" kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() if not kb: raise HTTPException(status_code=404, detail="知识库不存在") if req.name is not None: dup = db.query(KnowledgeBase).filter( KnowledgeBase.name == req.name, KnowledgeBase.id != kb_id ).first() if dup: raise HTTPException(status_code=400, detail="知识库名称已存在") kb.name = req.name if req.description is not None: kb.description = req.description # Phase 2.4 新增字段更新 try: if req.is_official is not None: setattr(kb, "is_official", req.is_official) if req.status is not None: setattr(kb, "status", req.status) if req.external_service_id is not None: setattr(kb, "external_service_id", req.external_service_id) if req.external_config is not None: setattr(kb, "external_config", json.dumps(req.external_config, ensure_ascii=False)) except Exception: logger.warning("KnowledgeBase 新增字段更新失败") kb.updated_at = datetime.now() db.commit() db.refresh(kb) LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="update_knowledge_base", target_type="knowledge_base", target_id=kb_id, detail=f"更新知识库: {kb.name}", ) return {"code": 0, "message": "知识库已更新", "data": kb_to_dict(kb, db)} @router.delete("/{kb_id}", summary="删除知识库") async def delete_knowledge_base( kb_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """删除知识库及其所有文档""" kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() if not kb: raise HTTPException(status_code=404, detail="知识库不存在") kb_name = kb.name # 删除关联文档 docs = db.query(KnowledgeDocument).filter( KnowledgeDocument.knowledge_base_id == kb_id ).all() for d in docs: db.delete(d) # 删除关联图谱 nodes = db.query(KnowledgeGraphNode).filter( KnowledgeGraphNode.knowledge_base_id == kb_id ).all() for n in nodes: db.delete(n) edges = db.query(KnowledgeGraphEdge).filter( KnowledgeGraphEdge.knowledge_base_id == kb_id ).all() for e in edges: db.delete(e) build_logs = db.query(KnowledgeGraphBuildLog).filter( KnowledgeGraphBuildLog.knowledge_base_id == kb_id ).all() for bl in build_logs: db.delete(bl) # 删除关联目录 dirs = db.query(KnowledgeDirectory).filter( KnowledgeDirectory.knowledge_base_id == kb_id ).all() for d in dirs: db.delete(d) # 删除知识库本身 db.delete(kb) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="delete_knowledge_base", target_type="knowledge_base", target_id=kb_id, detail=f"删除知识库: {kb_name} (含 {len(docs)} 文档, {len(nodes)} 节点, {len(edges)} 边)", ) return {"code": 0, "message": "知识库已删除"} @router.put("/{kb_id}/toggle", summary="启用/禁用知识库") async def toggle_knowledge_base( kb_id: str, is_active: bool = Query(..., description="是否启用"), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """启用或禁用知识库""" kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() if not kb: raise HTTPException(status_code=404, detail="知识库不存在") new_status = "active" if is_active else "inactive" try: setattr(kb, "status", new_status) except Exception: pass kb.updated_at = datetime.now() db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="toggle_knowledge_base", target_type="knowledge_base", target_id=kb_id, detail=f"{'启用' if is_active else '禁用'}知识库: {kb.name}", ) return { "code": 0, "message": f"知识库已{'启用' if is_active else '禁用'}", "data": kb_to_dict(kb, db), } # ============================================================ # 文档管理 # ============================================================ @router.get("/{kb_id}/documents", summary="获取知识库文档列表") async def get_kb_documents( kb_id: str, status: str = Query(None, description="文档状态筛选"), file_type: str = Query(None, description="文件类型筛选"), keyword: str = Query(None, description="文件名搜索"), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取知识库中的文档列表""" kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() if not kb: raise HTTPException(status_code=404, detail="知识库不存在") query = db.query(KnowledgeDocument).filter( KnowledgeDocument.knowledge_base_id == kb_id ) if status: query = query.filter(KnowledgeDocument.status == status) if file_type: query = query.filter(KnowledgeDocument.file_type == file_type) if keyword: query = query.filter(KnowledgeDocument.filename.ilike(f"%{keyword}%")) total = query.count() docs = query.order_by(desc(KnowledgeDocument.created_at)).offset( (page - 1) * page_size ).limit(page_size).all() return { "code": 0, "data": { "total": total, "page": page, "page_size": page_size, "items": [doc_to_dict(d) for d in docs], } } @router.delete("/{kb_id}/documents/{doc_id}", summary="删除知识库文档") async def delete_kb_document( kb_id: str, doc_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """删除知识库中的单个文档""" doc = db.query(KnowledgeDocument).filter( KnowledgeDocument.id == doc_id, KnowledgeDocument.knowledge_base_id == kb_id, ).first() if not doc: raise HTTPException(status_code=404, detail="文档不存在") filename = doc.filename db.delete(doc) # 更新知识库文档计数 kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() if kb: kb.document_count = db.query(func.count(KnowledgeDocument.id)).filter( KnowledgeDocument.knowledge_base_id == kb_id ).scalar() or 0 kb.updated_at = datetime.now() db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="delete_knowledge_document", target_type="knowledge_document", target_id=doc_id, detail=f"删除文档: {filename} (知识库: {kb.name})", ) return {"code": 0, "message": "文档已删除"} # ============================================================ # 知识图谱管理 # ============================================================ @router.get("/{kb_id}/graph/stats", summary="获取知识图谱统计") async def get_kb_graph_stats( kb_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取知识库的知识图谱统计信息""" kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() if not kb: raise HTTPException(status_code=404, detail="知识库不存在") nodes = db.query(func.count(KnowledgeGraphNode.id)).filter( KnowledgeGraphNode.knowledge_base_id == kb_id ).scalar() or 0 edges = db.query(func.count(KnowledgeGraphEdge.id)).filter( KnowledgeGraphEdge.knowledge_base_id == kb_id ).scalar() or 0 # 最近构建日志 recent_logs = db.query(KnowledgeGraphBuildLog).filter( KnowledgeGraphBuildLog.knowledge_base_id == kb_id ).order_by(desc(KnowledgeGraphBuildLog.created_at)).limit(5).all() # 节点类型分布 node_types = db.query( KnowledgeGraphNode.label, func.count(KnowledgeGraphNode.id) ).filter( KnowledgeGraphNode.knowledge_base_id == kb_id ).group_by(KnowledgeGraphNode.label).limit(20).all() return { "code": 0, "data": { "total_nodes": int(nodes), "total_edges": int(edges), "node_type_distribution": [ {"label": lbl, "count": int(cnt)} for lbl, cnt in node_types ], "recent_build_logs": [ { "id": bl.id, "status": getattr(bl, "status", "unknown"), "created_at": bl.created_at.isoformat() if bl.created_at else None, } for bl in recent_logs ], } } # ============================================================ # 外部知识库服务管理 # ============================================================ # ExternalKBService 模型定义(如果表不存在会在初始化时创建) # 这里用原生 SQL 操作,确保即使 ORM 模型未注册也能工作 def _get_external_services_table(db: Session): """安全地获取外部知识库服务列表(兼容表不存在的情况)""" try: # 尝试使用 ORM 模型查询 from ...models import ExternalKBService return db.query(ExternalKBService).all() except (AttributeError, ImportError): # ORM 模型不存在,用原生 SQL try: result = db.execute( "SELECT id, name, service_type, api_url, api_key, config, is_enabled, status, created_at, updated_at FROM external_kb_services ORDER BY created_at DESC" ) rows = result.fetchall() return [ { "id": r[0], "name": r[1], "service_type": r[2], "api_url": r[3], "api_key": r[4], "config": json.loads(r[5]) if r[5] else {}, "is_enabled": bool(r[6]), "status": r[7], "created_at": r[8].isoformat() if r[8] else None, "updated_at": r[9].isoformat() if r[9] else None, } for r in rows ] except Exception as e: logger.warning(f"查询外部知识库服务表失败: {e}") return [] def _check_service_connection(service_type: str, api_url: str, api_key: str) -> dict: """测试外部知识库服务连接状态""" try: headers = {} if api_key: headers["Authorization"] = f"Bearer {api_key}" if service_type == "fastgpt": # FastGPT 健康检查 check_url = f"{api_url.rstrip('/')}/api/v1/status" resp = httpx.get(check_url, headers=headers, timeout=10) if resp.status_code == 200: return {"connected": True, "status": "connected", "detail": "FastGPT 连接成功"} else: return {"connected": False, "status": "error", "detail": f"FastGPT 返回状态码 {resp.status_code}"} elif service_type == "dify": # Dify 健康检查 check_url = f"{api_url.rstrip('/')}/v1/datasets" resp = httpx.get(check_url, headers=headers, timeout=10) if resp.status_code in [200, 401]: # 401说明服务存在但需要认证 return {"connected": True, "status": "connected", "detail": "Dify 连接成功"} else: return {"connected": False, "status": "error", "detail": f"Dify 返回状态码 {resp.status_code}"} elif service_type == "ragflow": # RAGFlow 健康检查 check_url = f"{api_url.rstrip('/')}/api/v1/datasets" resp = httpx.get(check_url, headers=headers, timeout=10) if resp.status_code in [200, 401]: return {"connected": True, "status": "connected", "detail": "RAGFlow 连接成功"} else: return {"connected": False, "status": "error", "detail": f"RAGFlow 返回状态码 {resp.status_code}"} else: # 通用检查 resp = httpx.get(api_url, headers=headers, timeout=10) return {"connected": resp.status_code < 500, "status": "connected" if resp.status_code < 500 else "error"} except httpx.TimeoutException: return {"connected": False, "status": "timeout", "detail": "连接超时"} except Exception as e: return {"connected": False, "status": "error", "detail": str(e)} @router.get("/external/services", summary="获取外部知识库服务列表") async def list_external_services( service_type: str = Query(None, description="服务类型: fastgpt/dify/ragflow"), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """获取所有外部知识库服务配置""" services = _get_external_services_table(db) if service_type: services = [s for s in services if s.get("service_type") == service_type] return { "code": 0, "data": { "total": len(services), "items": services, "supported_types": [ {"type": "fastgpt", "name": "FastGPT", "description": "开源 AI 知识库平台,支持多种向量模型"}, {"type": "dify", "name": "Dify", "description": "LLM 应用开发平台,内置知识库管理"}, {"type": "ragflow", "name": "RAGFlow", "description": "基于深度文档理解的开源 RAG 引擎"}, ] } } @router.post("/external/services", summary="创建外部知识库服务配置") async def create_external_service( req: ExternalServiceConfig, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """创建新的外部知识库服务连接配置""" # 验证服务类型 valid_types = ["fastgpt", "dify", "ragflow"] if req.service_type not in valid_types: raise HTTPException(status_code=400, detail=f"不支持的服务类型: {req.service_type}") # 检查连接 conn_result = _check_service_connection(req.service_type, req.api_url, req.api_key or "") service_id = str(uuid.uuid4()) config_json = json.dumps(req.config or {}, ensure_ascii=False) conn_status = conn_result.get("status", "disconnected") # 使用原生 SQL 插入(兼容 ORM 模型可能未注册) try: db.execute( f"INSERT INTO external_kb_services (id, name, service_type, api_url, api_key, config, is_enabled, status, created_at, updated_at) " f"VALUES (:id, :name, :stype, :url, :key, :cfg, 1, :status, NOW(), NOW())", { "id": service_id, "name": req.name, "stype": req.service_type, "url": req.api_url, "key": req.api_key or "", "cfg": config_json, "status": conn_status, } ) db.commit() except Exception as e: # 表可能不存在,尝试创建 logger.warning(f"插入外部服务失败: {e}, 尝试创建表") db.execute(""" CREATE TABLE IF NOT EXISTS external_kb_services ( id VARCHAR(36) PRIMARY KEY, name VARCHAR(200) NOT NULL, service_type VARCHAR(20) NOT NULL, api_url VARCHAR(500) NOT NULL, api_key VARCHAR(255) DEFAULT '', config JSON DEFAULT NULL, is_enabled BOOLEAN DEFAULT TRUE, status VARCHAR(20) DEFAULT 'disconnected', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) db.commit() # 再次尝试插入 db.execute( f"INSERT INTO external_kb_services (id, name, service_type, api_url, api_key, config, is_enabled, status, created_at, updated_at) " f"VALUES (:id, :name, :stype, :url, :key, :cfg, 1, :status, NOW(), NOW())", { "id": service_id, "name": req.name, "stype": req.service_type, "url": req.api_url, "key": req.api_key or "", "cfg": config_json, "status": conn_status, } ) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="create_external_kb_service", target_type="external_kb_service", target_id=service_id, detail=f"创建外部知识库服务: {req.name} ({req.service_type})", ) return { "code": 0, "message": "外部知识库服务配置已创建", "data": { "id": service_id, "name": req.name, "service_type": req.service_type, "api_url": req.api_url, "connection": conn_result, } } @router.put("/external/services/{service_id}", summary="更新外部知识库服务配置") async def update_external_service( service_id: str, req: ExternalServiceUpdate, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """更新外部知识库服务配置""" # 查找现有服务 try: result = db.execute( "SELECT id, name, service_type, api_url, api_key FROM external_kb_services WHERE id = :sid", {"sid": service_id} ) row = result.fetchone() except Exception: raise HTTPException(status_code=404, detail="外部知识库服务不存在或表未创建") if not row: raise HTTPException(status_code=404, detail="外部知识库服务不存在") # 构建更新字段 updates = {} if req.name is not None: updates["name"] = req.name if req.api_url is not None: updates["api_url"] = req.api_url if req.api_key is not None: updates["api_key"] = req.api_key if req.config is not None: updates["config"] = json.dumps(req.config, ensure_ascii=False) if req.is_enabled is not None: updates["is_enabled"] = 1 if req.is_enabled else 0 # 如果 URL 或 API Key 变了,重新测试连接 new_url = req.api_url or row[3] new_key = req.api_key or row[4] if req.api_url or req.api_key: service_type = row[2] conn_result = _check_service_connection(service_type, new_url, new_key) updates["status"] = conn_result.get("status", "disconnected") updates["updated_at"] = "NOW()" # 执行更新 set_clause = ", ".join([f"{k} = :{k}" for k in updates.keys() if k != "updated_at"]) set_clause += ", updated_at = NOW()" params = {k: v for k, v in updates.items() if k != "updated_at"} params["sid"] = service_id db.execute( f"UPDATE external_kb_services SET {set_clause} WHERE id = :sid", params ) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="update_external_kb_service", target_type="external_kb_service", target_id=service_id, detail=f"更新外部知识库服务: {row[1]}", ) return {"code": 0, "message": "外部知识库服务配置已更新"} @router.delete("/external/services/{service_id}", summary="删除外部知识库服务配置") async def delete_external_service( service_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """删除外部知识库服务配置""" try: result = db.execute( "SELECT name FROM external_kb_services WHERE id = :sid", {"sid": service_id} ) row = result.fetchone() except Exception: raise HTTPException(status_code=404, detail="外部知识库服务不存在") if not row: raise HTTPException(status_code=404, detail="外部知识库服务不存在") name = row[0] db.execute("DELETE FROM external_kb_services WHERE id = :sid", {"sid": service_id}) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="delete_external_kb_service", target_type="external_kb_service", target_id=service_id, detail=f"删除外部知识库服务: {name}", ) return {"code": 0, "message": "外部知识库服务配置已删除"} @router.get("/external/services/{service_id}/status", summary="检查外部知识库服务连接状态") async def check_external_service_status( service_id: str, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """实时检查外部知识库服务的连接状态""" try: result = db.execute( "SELECT service_type, api_url, api_key FROM external_kb_services WHERE id = :sid", {"sid": service_id} ) row = result.fetchone() except Exception: raise HTTPException(status_code=404, detail="外部知识库服务不存在") if not row: raise HTTPException(status_code=404, detail="外部知识库服务不存在") conn_result = _check_service_connection(row[0], row[1], row[2]) # 更新状态 try: db.execute( "UPDATE external_kb_services SET status = :status, updated_at = NOW() WHERE id = :sid", {"status": conn_result.get("status", "disconnected"), "sid": service_id} ) db.commit() except Exception: pass return {"code": 0, "data": conn_result} # ============================================================ # 知识库同步(与外部服务) # ============================================================ @router.post("/external/sync/{kb_id}", summary="同步知识库与外部服务") async def sync_knowledge_base( kb_id: str, req: SyncRequest, admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """将知识库与外部知识库服务进行数据同步""" kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() if not kb: raise HTTPException(status_code=404, detail="知识库不存在") # 确定同步目标服务 ext_service_id = req.external_service_id or getattr(kb, "external_service_id", None) if not ext_service_id: raise HTTPException(status_code=400, detail="未指定外部知识库服务,请先关联或传入 external_service_id") # 查询外部服务配置 try: result = db.execute( "SELECT name, service_type, api_url, api_key, config FROM external_kb_services WHERE id = :sid", {"sid": ext_service_id} ) row = result.fetchone() except Exception: raise HTTPException(status_code=404, detail="外部知识库服务不存在") if not row: raise HTTPException(status_code=404, detail="外部知识库服务不存在") service_name, service_type, api_url, api_key, config_raw = row service_config = json.loads(config_raw) if config_raw else {} # 执行同步 sync_result = {"synced": False, "detail": "", "items_synced": 0} try: headers = {} if api_key: headers["Authorization"] = f"Bearer {api_key}" if service_type == "fastgpt" and req.sync_direction == "push": # 向 FastGPT 推送知识库数据 # 1. 在 FastGPT 创建/获取对应的 dataset ext_kb_id = getattr(kb, "external_id", None) if not ext_kb_id: # 创建新 dataset create_resp = httpx.post( f"{api_url.rstrip('/')}/api/v1/datasets", headers=headers, json={"name": kb.name, "description": kb.description or ""}, timeout=30, ) if create_resp.status_code == 200: ext_data = create_resp.json() ext_kb_id = ext_data.get("data", {}).get("id") or ext_data.get("id") # 更新 KB 的 external_id try: setattr(kb, "external_id", ext_kb_id) except Exception: db.execute( "UPDATE knowledge_bases SET external_id = :eid WHERE id = :kid", {"eid": ext_kb_id, "kid": kb_id} ) else: sync_result["detail"] = f"FastGPT 创建 dataset 失败: {create_resp.status_code}" sync_result["synced"] = False if ext_kb_id: # 推送文档内容到 FastGPT docs = db.query(KnowledgeDocument).filter( KnowledgeDocument.knowledge_base_id == kb_id, KnowledgeDocument.status == "completed" ).all() synced_count = 0 for doc in docs: content = doc.content or "" if not content: continue # 分段推送 push_resp = httpx.post( f"{api_url.rstrip('/')}/api/v1/datasets/{ext_kb_id}/documents", headers=headers, json={ "name": doc.filename, "content": content[:50000], # 限制单次推送大小 }, timeout=30, ) if push_resp.status_code in [200, 201]: synced_count += 1 sync_result["synced"] = True sync_result["detail"] = f"已推送 {synced_count}/{len(docs)} 文档到 FastGPT" sync_result["items_synced"] = synced_count elif service_type == "dify" and req.sync_direction == "push": # 向 Dify 推送知识库数据 ext_kb_id = getattr(kb, "external_id", None) if not ext_kb_id: create_resp = httpx.post( f"{api_url.rstrip('/')}/v1/datasets", headers=headers, json={"name": kb.name}, timeout=30, ) if create_resp.status_code in [200, 201]: ext_data = create_resp.json() ext_kb_id = ext_data.get("id") try: setattr(kb, "external_id", ext_kb_id) except Exception: db.execute( "UPDATE knowledge_bases SET external_id = :eid WHERE id = :kid", {"eid": ext_kb_id, "kid": kb_id} ) if ext_kb_id: docs = db.query(KnowledgeDocument).filter( KnowledgeDocument.knowledge_base_id == kb_id, KnowledgeDocument.status == "completed" ).all() synced_count = 0 for doc in docs: content = doc.content or "" if not content: continue # Dify 文档创建 doc_resp = httpx.post( f"{api_url.rstrip('/')}/v1/datasets/{ext_kb_id}/document/create_by_text", headers=headers, json={ "name": doc.filename, "text": content[:50000], "indexing_technique": "high_quality", }, timeout=30, ) if doc_resp.status_code in [200, 201]: synced_count += 1 sync_result["synced"] = True sync_result["detail"] = f"已推送 {synced_count}/{len(docs)} 文档到 Dify" sync_result["items_synced"] = synced_count elif service_type == "ragflow" and req.sync_direction == "push": # 向 RAGFlow 推送知识库数据 ext_kb_id = getattr(kb, "external_id", None) if not ext_kb_id: create_resp = httpx.post( f"{api_url.rstrip('/')}/api/v1/datasets", headers=headers, json={"name": kb.name}, timeout=30, ) if create_resp.status_code in [200, 201]: ext_data = create_resp.json() ext_kb_id = ext_data.get("id") try: setattr(kb, "external_id", ext_kb_id) except Exception: db.execute( "UPDATE knowledge_bases SET external_id = :eid WHERE id = :kid", {"eid": ext_kb_id, "kid": kb_id} ) if ext_kb_id: docs = db.query(KnowledgeDocument).filter( KnowledgeDocument.knowledge_base_id == kb_id, KnowledgeDocument.status == "completed" ).all() synced_count = 0 for doc in docs: content = doc.content or "" if not content: continue doc_resp = httpx.post( f"{api_url.rstrip('/')}/api/v1/datasets/{ext_kb_id}/documents", headers=headers, json={"name": doc.filename, "content": content[:50000]}, timeout=30, ) if doc_resp.status_code in [200, 201]: synced_count += 1 sync_result["synced"] = True sync_result["detail"] = f"已推送 {synced_count}/{len(docs)} 文档到 RAGFlow" sync_result["items_synced"] = synced_count elif req.sync_direction == "pull": # 从外部服务拉取知识库数据 sync_result["detail"] = f"从 {service_type} 拉取数据(暂未实现完整拉取逻辑)" sync_result["synced"] = True sync_result["items_synced"] = 0 elif req.sync_direction == "bidirectional": sync_result["detail"] = f"双向同步(暂未实现完整双向同步逻辑)" sync_result["synced"] = True sync_result["items_synced"] = 0 # 更新同步状态 try: setattr(kb, "external_sync_status", "synced" if sync_result["synced"] else "error") except Exception: db.execute( "UPDATE knowledge_bases SET external_sync_status = :es WHERE id = :kid", {"es": "synced" if sync_result["synced"] else "error", "kid": kb_id} ) kb.updated_at = datetime.now() db.commit() except Exception as e: logger.error(f"知识库同步失败: {e}") sync_result["detail"] = f"同步异常: {str(e)}" sync_result["synced"] = False try: setattr(kb, "external_sync_status", "error") except Exception: db.execute( "UPDATE knowledge_bases SET external_sync_status = 'error' WHERE id = :kid", {"kid": kb_id} ) db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="sync_knowledge_base", target_type="knowledge_base", target_id=kb_id, detail=f"同步知识库 '{kb.name}' → {service_name} ({service_type}), 方向={req.sync_direction}, 结果={sync_result['detail']}", ) return {"code": 0, "data": sync_result} # ============================================================ # 批量操作 # ============================================================ @router.post("/batch/delete", summary="批量删除知识库") async def batch_delete_kbs( kb_ids: List[str] = Body(..., description="知识库ID列表"), admin_user=Depends(get_current_admin), db: Session = Depends(get_db), ): """批量删除知识库""" deleted = 0 failed = 0 for kb_id in kb_ids: kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() if not kb: failed += 1 continue # 删除关联数据 db.query(KnowledgeDocument).filter(KnowledgeDocument.knowledge_base_id == kb_id).delete() db.query(KnowledgeGraphNode).filter(KnowledgeGraphNode.knowledge_base_id == kb_id).delete() db.query(KnowledgeGraphEdge).filter(KnowledgeGraphEdge.knowledge_base_id == kb_id).delete() db.query(KnowledgeGraphBuildLog).filter(KnowledgeGraphBuildLog.knowledge_base_id == kb_id).delete() db.query(KnowledgeDirectory).filter(KnowledgeDirectory.knowledge_base_id == kb_id).delete() db.delete(kb) deleted += 1 db.commit() LogService.log_audit( admin_id=admin_user.id, admin_name=admin_user.username, action="batch_delete_knowledge_bases", target_type="knowledge_base", target_id=",".join(kb_ids[:10]), detail=f"批量删除知识库: 成功 {deleted}, 失败 {failed}", ) return {"code": 0, "data": {"deleted": deleted, "failed": failed}}