From e8e5ffa1a940d2c7adcedc69ef653726321c66f3 Mon Sep 17 00:00:00 2001 From: qyyfw Date: Sat, 11 Jul 2026 11:40:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=20rsync=20=E8=AF=AF?= =?UTF-8?q?=E5=88=A0=E7=9A=84=E8=BF=91=E6=9C=9F=E5=BC=80=E5=8F=91(agents/s?= =?UTF-8?q?kills/mcp/knowledge/workflow/tenant/billing/storage/privacy/pro?= =?UTF-8?q?motion/achievement/automation/code-exec/developer/distribution/?= =?UTF-8?q?im-bot/portal/casdoor=20=E7=AD=89=E5=90=8E=E7=AB=AF=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=20+=20=E5=AF=B9=E5=BA=94=E5=89=8D=E7=AB=AF=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E8=B7=AF=E7=94=B1,=20=E8=A1=A5=E5=9B=9E=20main.py=20?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=E4=B8=8E=20App.tsx=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../python/app/api/admin/achievement_admin.py | 327 +++ backend/python/app/api/admin/acp_gateway.py | 491 +++++ backend/python/app/api/admin/agents_admin.py | 557 ++++++ .../python/app/api/admin/analytics_admin.py | 424 ++++ backend/python/app/api/admin/apps_admin.py | 844 ++++++++ backend/python/app/api/admin/asset_admin.py | 357 ++++ .../python/app/api/admin/automation_admin.py | 662 ++++++ backend/python/app/api/admin/billing_admin.py | 462 +++++ .../python/app/api/admin/code_exec_admin.py | 369 ++++ .../python/app/api/admin/developer_admin.py | 658 ++++++ .../app/api/admin/developer_tools_admin.py | 899 +++++++++ backend/python/app/api/admin/distro_admin.py | 752 +++++++ backend/python/app/api/admin/im_bot_admin.py | 1113 ++++++++++ .../python/app/api/admin/knowledge_admin.py | 1330 ++++++++++++ backend/python/app/api/admin/mcp_admin.py | 449 +++++ .../python/app/api/admin/mcp_market_admin.py | 1224 +++++++++++ backend/python/app/api/admin/privacy_admin.py | 799 ++++++++ .../python/app/api/admin/promotion_admin.py | 288 +++ backend/python/app/api/admin/skills_admin.py | 773 +++++++ backend/python/app/api/admin/storage_admin.py | 904 +++++++++ backend/python/app/api/admin/tenant_admin.py | 426 ++++ .../python/app/api/admin/workflow_admin.py | 1228 ++++++++++++ backend/python/app/api/im_bot_user.py | 601 ++++++ backend/python/app/api/portal/user_portal.py | 494 +++++ backend/python/app/capability_registry.py | 97 + backend/python/app/casdoor_auth.py | 214 ++ backend/python/app/kb_router.py | 267 +++ backend/python/app/pgvector_search.py | 360 ++++ backend/python/main.py | 459 +++-- backend/python/platform_bridge/__init__.py | 24 + backend/python/platform_bridge/mcp_server.py | 308 +++ backend/python/platform_bridge/sync_client.py | 133 ++ docker-compose.yml.bak | 112 ++ docker-compose.yml.bak.build | 112 ++ frontend/src/App.tsx | 294 +-- frontend/src/pages/UserPortal.tsx | 491 +++++ frontend/src/pages/admin/Achievement.tsx | 1288 ++++++++++++ frontend/src/pages/admin/Agents.tsx | 701 +++++++ frontend/src/pages/admin/Analytics.tsx | 207 ++ frontend/src/pages/admin/Apps.css | 519 +++++ frontend/src/pages/admin/Apps.tsx | 799 ++++++++ frontend/src/pages/admin/AssetCenter.tsx | 451 +++++ frontend/src/pages/admin/Automation.tsx | 599 ++++++ frontend/src/pages/admin/Billing.tsx | 209 ++ frontend/src/pages/admin/CodeExec.tsx | 747 +++++++ frontend/src/pages/admin/Developer.css | 155 ++ frontend/src/pages/admin/Developer.tsx | 515 +++++ frontend/src/pages/admin/DeveloperTools.tsx | 537 +++++ .../src/pages/admin/DistributionBuilder.tsx | 986 +++++++++ frontend/src/pages/admin/ImBots.tsx | 1268 ++++++++++++ frontend/src/pages/admin/Knowledge.tsx | 538 +++++ frontend/src/pages/admin/Mcp.tsx | 405 ++++ frontend/src/pages/admin/McpMarketTab.tsx | 729 +++++++ frontend/src/pages/admin/PrivacySettings.tsx | 1484 ++++++++++++++ frontend/src/pages/admin/Promotion.tsx | 520 +++++ frontend/src/pages/admin/Skills.tsx | 665 ++++++ frontend/src/pages/admin/Storage.tsx | 1782 +++++++++++++++++ frontend/src/pages/admin/Tenant.tsx | 235 +++ frontend/src/pages/admin/Workflow.tsx | 757 +++++++ frontend/src/pages/front/WechatBot.tsx | 228 +++ frontend/src/pages/front/WechatHistory.tsx | 97 + migrations/mig_code_exec.sql | 23 + migrations/mig_g4_distribution.sql | 62 + migrations/mig_g5_privacy.sql | 89 + migrations/mig_p4_imbot.sql | 66 + 65 files changed, 35624 insertions(+), 339 deletions(-) create mode 100644 backend/python/app/api/admin/achievement_admin.py create mode 100644 backend/python/app/api/admin/acp_gateway.py create mode 100644 backend/python/app/api/admin/agents_admin.py create mode 100644 backend/python/app/api/admin/analytics_admin.py create mode 100644 backend/python/app/api/admin/apps_admin.py create mode 100644 backend/python/app/api/admin/asset_admin.py create mode 100644 backend/python/app/api/admin/automation_admin.py create mode 100644 backend/python/app/api/admin/billing_admin.py create mode 100644 backend/python/app/api/admin/code_exec_admin.py create mode 100644 backend/python/app/api/admin/developer_admin.py create mode 100644 backend/python/app/api/admin/developer_tools_admin.py create mode 100644 backend/python/app/api/admin/distro_admin.py create mode 100644 backend/python/app/api/admin/im_bot_admin.py create mode 100644 backend/python/app/api/admin/knowledge_admin.py create mode 100644 backend/python/app/api/admin/mcp_admin.py create mode 100644 backend/python/app/api/admin/mcp_market_admin.py create mode 100644 backend/python/app/api/admin/privacy_admin.py create mode 100644 backend/python/app/api/admin/promotion_admin.py create mode 100644 backend/python/app/api/admin/skills_admin.py create mode 100644 backend/python/app/api/admin/storage_admin.py create mode 100644 backend/python/app/api/admin/tenant_admin.py create mode 100644 backend/python/app/api/admin/workflow_admin.py create mode 100644 backend/python/app/api/im_bot_user.py create mode 100644 backend/python/app/api/portal/user_portal.py create mode 100644 backend/python/app/capability_registry.py create mode 100644 backend/python/app/casdoor_auth.py create mode 100644 backend/python/app/kb_router.py create mode 100644 backend/python/app/pgvector_search.py create mode 100644 backend/python/platform_bridge/__init__.py create mode 100644 backend/python/platform_bridge/mcp_server.py create mode 100644 backend/python/platform_bridge/sync_client.py create mode 100644 docker-compose.yml.bak create mode 100644 docker-compose.yml.bak.build create mode 100644 frontend/src/pages/UserPortal.tsx create mode 100644 frontend/src/pages/admin/Achievement.tsx create mode 100644 frontend/src/pages/admin/Agents.tsx create mode 100644 frontend/src/pages/admin/Analytics.tsx create mode 100644 frontend/src/pages/admin/Apps.css create mode 100644 frontend/src/pages/admin/Apps.tsx create mode 100644 frontend/src/pages/admin/AssetCenter.tsx create mode 100644 frontend/src/pages/admin/Automation.tsx create mode 100644 frontend/src/pages/admin/Billing.tsx create mode 100644 frontend/src/pages/admin/CodeExec.tsx create mode 100644 frontend/src/pages/admin/Developer.css create mode 100644 frontend/src/pages/admin/Developer.tsx create mode 100644 frontend/src/pages/admin/DeveloperTools.tsx create mode 100644 frontend/src/pages/admin/DistributionBuilder.tsx create mode 100644 frontend/src/pages/admin/ImBots.tsx create mode 100644 frontend/src/pages/admin/Knowledge.tsx create mode 100644 frontend/src/pages/admin/Mcp.tsx create mode 100644 frontend/src/pages/admin/McpMarketTab.tsx create mode 100644 frontend/src/pages/admin/PrivacySettings.tsx create mode 100644 frontend/src/pages/admin/Promotion.tsx create mode 100644 frontend/src/pages/admin/Skills.tsx create mode 100644 frontend/src/pages/admin/Storage.tsx create mode 100644 frontend/src/pages/admin/Tenant.tsx create mode 100644 frontend/src/pages/admin/Workflow.tsx create mode 100644 frontend/src/pages/front/WechatBot.tsx create mode 100644 frontend/src/pages/front/WechatHistory.tsx create mode 100644 migrations/mig_code_exec.sql create mode 100644 migrations/mig_g4_distribution.sql create mode 100644 migrations/mig_g5_privacy.sql create mode 100644 migrations/mig_p4_imbot.sql diff --git a/backend/python/app/api/admin/achievement_admin.py b/backend/python/app/api/admin/achievement_admin.py new file mode 100644 index 0000000..2f24867 --- /dev/null +++ b/backend/python/app/api/admin/achievement_admin.py @@ -0,0 +1,327 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import Optional, List +from datetime import datetime +import json +import traceback + +from ...database import SessionLocal, get_db +from ...auth import get_current_user +from ...models import User + +router = APIRouter() + +def _execute_raw(db: Session, sql: str, params: dict = None): + """执行原始SQL的辅助函数""" + try: + result = db.execute(text(sql), params or {}) + db.commit() + return result + except Exception as e: + db.rollback() + raise e + +@router.get("/achievements") +async def list_achievements( + category: Optional[str] = Query(None), + is_active: Optional[bool] = Query(True), + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """列出所有成就(带用户进度)""" + try: + achievements = [] + + # Build query + sql = "SELECT * FROM achievements WHERE 1=1" + params = {} + if category: + sql += " AND category = :category" + params["category"] = category + if is_active is not None: + sql += " AND is_active = :is_active" + params["is_active"] = 1 if is_active else 0 + sql += " ORDER BY display_order ASC" + + result = _execute_raw(db, sql, params) + rows = result.fetchall() + + user_id = user.id if hasattr(user, 'id') else None + + for row in rows: + ach = { + "id": row[0], + "name": row[1], + "description": row[2], + "icon": row[3], + "category": row[4], + "criteria_type": row[5], + "criteria_key": row[6], + "criteria_value": row[7], + "reward_type": row[8], + "reward_value": float(row[9]) if row[9] else 0, + "badge_icon": row[10], + "is_active": bool(row[11]) if row[11] is not None else True, + "display_order": row[12], + "user_progress": 0, + "is_completed": False, + "reward_claimed": False + } + + # Get user progress if user is logged in + if user_id: + try: + progress_result = _execute_raw(db, + "SELECT progress, is_completed, reward_claimed FROM user_achievements WHERE user_id = :uid AND achievement_id = :aid", + {"uid": user_id, "aid": ach["id"]}) + prog_row = progress_result.fetchone() + if prog_row: + ach["user_progress"] = prog_row[0] or 0 + ach["is_completed"] = bool(prog_row[1]) if prog_row[1] is not None else False + ach["reward_claimed"] = bool(prog_row[2]) if prog_row[2] is not None else False + except: + pass + + achievements.append(ach) + + return {"code": 0, "data": {"items": achievements, "total": len(achievements)}} + except Exception as e: + traceback.print_exc() + return {"code": 0, "data": {"items": [], "total": 0}} + +@router.get("/achievements/{ach_id}") +async def get_achievement_detail( + ach_id: str, + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """获取成就详情""" + try: + result = _execute_raw(db, "SELECT * FROM achievements WHERE id = :id", {"id": ach_id}) + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="成就不存在") + + achievement = { + "id": row[0], + "name": row[1], + "description": row[2], + "icon": row[3], + "category": row[4], + "criteria_type": row[5], + "criteria_key": row[6], + "criteria_value": row[7], + "reward_type": row[8], + "reward_value": float(row[9]) if row[9] else 0, + "badge_icon": row[10], + "is_active": bool(row[11]) if row[11] is not None else True, + "display_order": row[12] + } + + return {"code": 0, "data": achievement} + except HTTPException: + raise + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/achievements") +async def create_achievement( + name: str = Query(...), + category: str = Query(...), + criteria_type: str = Query(...), + criteria_value: int = Query(...), + description: Optional[str] = Query(""), + icon: Optional[str] = Query(""), + criteria_key: Optional[str] = Query(None), + reward_type: str = Query("points"), + reward_value: float = Query(0), + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """创建新成就(管理员)""" + try: + import uuid + ach_id = str(uuid.uuid4()) + + sql = """INSERT INTO achievements + (id, name, description, icon, category, criteria_type, criteria_key, + criteria_value, reward_type, reward_value, is_active, display_order) + VALUES (:id, :name, :desc, :icon, :cat, :ctype, :ckey, + :cvalue, :rtype, :rvalue, 1, 0)""" + + _execute_raw(db, sql, { + "id": ach_id, + "name": name, + "desc": description, + "icon": icon, + "cat": category, + "ctype": criteria_type, + "ckey": criteria_key, + "cvalue": criteria_value, + "rtype": reward_type, + "rvalue": reward_value + }) + + return {"code": 0, "message": "成就创建成功", "data": {"id": ach_id}} + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/achievements/{ach_id}") +async def update_achievement( + ach_id: str, + name: Optional[str] = Query(None), + description: Optional[str] = Query(None), + is_active: Optional[bool] = Query(None), + reward_value: Optional[float] = Query(None), + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """更新成就(管理员)""" + try: + updates = [] + params = {"id": ach_id} + + if name is not None: + updates.append("name = :name") + params["name"] = name + if description is not None: + updates.append("description = :desc") + params["desc"] = description + if is_active is not None: + updates.append("is_active = :active") + params["active"] = 1 if is_active else 0 + if reward_value is not None: + updates.append("reward_value = :rvalue") + params["rvalue"] = reward_value + + if updates: + sql = f"UPDATE achievements SET {', '.join(updates)} WHERE id = :id" + _execute_raw(db, sql, params) + + return {"code": 0, "message": "更新成功"} + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/achievements/{ach_id}") +async def delete_achievement( + ach_id: str, + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """删除成就(管理员)""" + try: + _execute_raw(db, "DELETE FROM achievements WHERE id = :id", {"id": ach_id}) + return {"code": 0, "message": "删除成功"} + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/user/achievements") +async def get_my_achievements( + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """获取当前用户的成就进度""" + try: + user_id = user.id if hasattr(user, 'id') else None + if not user_id: + return {"code": 0, "data": {"items": [], "total": 0}} + + # Get all achievements with user progress + result = _execute_raw(db, "SELECT a.*, ua.progress, ua.is_completed, ua.reward_claimed, ua.completed_at FROM achievements a LEFT JOIN user_achievements ua ON a.id = ua.achievement_id AND ua.user_id = :uid WHERE a.is_active = 1 ORDER BY a.display_order ASC", {"uid": user_id}) + + rows = result.fetchall() + achievements = [] + for row in rows: + achievements.append({ + "id": row[0], + "name": row[1], + "description": row[2], + "icon": row[3], + "category": row[4], + "criteria_type": row[5], + "criteria_value": row[7], + "reward_type": row[8], + "reward_value": float(row[9]) if row[9] else 0, + "progress": row[13] or 0, + "is_completed": bool(row[14]) if row[14] is not None else False, + "reward_claimed": bool(row[15]) if row[15] is not None else False, + "completed_at": str(row[16]) if row[16] else None + }) + + return {"code": 0, "data": {"items": achievements, "total": len(achievements)}} + except Exception as e: + traceback.print_exc() + return {"code": 0, "data": {"items": [], "total": 0}} + +@router.post("/user/achievements/{ach_id}/claim") +async def claim_achievement_reward( + ach_id: str, + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """领取成就奖励""" + try: + user_id = user.id if hasattr(user, 'id') else None + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + # Check if achievement is completed + result = _execute_raw(db, + "SELECT is_completed, reward_claimed FROM user_achievements WHERE user_id = :uid AND achievement_id = :aid", + {"uid": user_id, "aid": ach_id}) + row = result.fetchone() + + if not row or not row[0]: + raise HTTPException(status_code=400, detail="成就未完成") + + if row[1]: + raise HTTPException(status_code=400, detail="奖励已领取") + + # Mark reward as claimed + _execute_raw(db, + "UPDATE user_achievements SET reward_claimed = 1, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid", + {"uid": user_id, "aid": ach_id}) + + return {"code": 0, "message": "奖励领取成功"} + except HTTPException: + raise + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/check-progress") +async def check_user_progress( + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """检查并更新用户成就进度""" + try: + user_id = user.id if hasattr(user, 'id') else None + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + # Get user stats (this would need to be implemented based on actual data) + # For now, just return success + return {"code": 0, "message": "进度检查完成", "data": {"updated": 0}} + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/leaderboard") +async def get_achievement_leaderboard( + period: str = Query("all"), + db: Session = Depends(get_db) +): + """获取成就排行榜""" + try: + # This would aggregate user achievements and rank them + # For now, return empty + return {"code": 0, "data": {"items": []}} + except Exception as e: + traceback.print_exc() + return {"code": 0, "data": {"items": []}} diff --git a/backend/python/app/api/admin/acp_gateway.py b/backend/python/app/api/admin/acp_gateway.py new file mode 100644 index 0000000..7b0f172 --- /dev/null +++ b/backend/python/app/api/admin/acp_gateway.py @@ -0,0 +1,491 @@ +""" +ACP Gateway - Agent Communication Protocol 内部实现 +Phase G3: 多 Agent 协作编排层 + +将平台已有的 Agent(MarketApp type='agent')封装为可编排调用的"Agent 节点"。 +每个 Agent 节点执行时,通过本 Gateway 创建会话、发送提示、接收流式响应。 +支持通过 Redis EventBus 发布节点进度通知,供 WebSocket 实时推送到前端。 +""" +import json +import uuid +import time +import logging +import asyncio +import threading +from typing import Optional, Dict, Any, Callable, AsyncIterator +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +# ============================================================ +# Data Models +# ============================================================ + +@dataclass +class AcpSessionState: + """ACP 会话状态""" + session_id: str + agent_id: str # MarketApp id + agent_name: str = "" + status: str = "idle" # idle / running / completed / failed / cancelled + prompt: str = "" + created_at: float = field(default_factory=time.time) + completed_at: Optional[float] = None + output: str = "" + error: Optional[str] = None + tool_calls: list = field(default_factory=list) + chain_of_thought: list = field(default_factory=list) # 思考过程 + + def to_dict(self) -> dict: + return { + "session_id": self.session_id, + "agent_id": self.agent_id, + "agent_name": self.agent_name, + "status": self.status, + "prompt": self.prompt[:200], + "created_at": self.created_at, + "completed_at": self.completed_at, + "output": self.output[:500] if self.output else "", + "error": self.error, + "tool_calls": self.tool_calls, + "chain_of_thought": self.chain_of_thought, + } + + +@dataclass +class AgentConfig: + """从 MarketApp.skill_content 解析出的 Agent 配置""" + agent_id: str = "" + agent_name: str = "" + system_prompt: str = "" + instructions: str = "" + model_id: Optional[str] = None + temperature: float = 0.7 + max_tokens: int = 4096 + skills: list = field(default_factory=list) + knowledge_base_ids: list = field(default_factory=list) + tools: list = field(default_factory=list) + + +# ============================================================ +# Agent Resolver +# ============================================================ + +def resolve_agent_config(db, agent_id: str) -> Optional[AgentConfig]: + """从 MarketApp 解析 Agent 配置""" + from app.models import MarketApp + + agent = db.query(MarketApp).filter( + MarketApp.id == agent_id, + MarketApp.type == "agent" + ).first() + + if not agent: + return None + + config_json = {} + if agent.skill_content: + try: + config_json = json.loads(agent.skill_content) if isinstance(agent.skill_content, str) else agent.skill_content + except Exception: + pass + + return AgentConfig( + agent_id=agent.id, + agent_name=agent.name, + system_prompt=config_json.get("system_prompt", config_json.get("role", agent.description or "AI助手")), + instructions=config_json.get("instructions", ""), + model_id=config_json.get("model_id"), + temperature=float(config_json.get("temperature", 0.7)), + max_tokens=int(config_json.get("max_tokens", 4096)), + skills=config_json.get("skills", []), + knowledge_base_ids=config_json.get("knowledge_base_ids", []), + tools=config_json.get("tools", []), + ) + + +def resolve_model_config(db, model_id: str) -> dict: + """获取模型的 API 配置""" + from app.models import AIModel + + model = db.query(AIModel).filter(AIModel.id == model_id).first() + if not model: + # Fallback to default model + default_model = db.query(AIModel).filter(AIModel.is_enabled == True).first() + if default_model: + model = default_model + else: + return { + "api_key": "", + "api_base": "https://api.siliconflow.cn/v1", + "model_name": "deepseek-ai/DeepSeek-V4-Flash", + } + + return { + "api_key": model.api_key or "", + "api_base": model.api_base or "https://api.siliconflow.cn/v1", + "model_name": model.model_id or model.name, + } + + +# ============================================================ +# ACP Gateway +# ============================================================ + +class AcpGateway: + """Agent 通信网关 — 平台内部多 Agent 编排核心""" + + def __init__(self): + self._sessions: Dict[str, AcpSessionState] = {} + self._lock = threading.Lock() + self._on_notification: Optional[Callable] = None + + def set_notification_handler(self, handler: Callable[[dict], None]): + """设置通知回调 — 用于发布到 Redis EventBus""" + self._on_notification = handler + + def _notify(self, event_type: str, data: dict): + """发送通知""" + if self._on_notification: + try: + self._on_notification({ + "type": event_type, + "timestamp": time.time(), + **data, + }) + except Exception as e: + logger.error(f"[AcpGateway] 通知回调异常: {e}") + + # ---- Session Management ---- + + def create_session(self, agent_id: str, agent_name: str = "") -> str: + """创建新的 Agent 会话""" + session_id = f"acp-{uuid.uuid4().hex[:12]}" + with self._lock: + self._sessions[session_id] = AcpSessionState( + session_id=session_id, + agent_id=agent_id, + agent_name=agent_name or agent_id, + status="idle", + ) + logger.info(f"[AcpGateway] 创建会话: {session_id} (agent={agent_name or agent_id})") + return session_id + + def get_session(self, session_id: str) -> Optional[AcpSessionState]: + """获取会话""" + with self._lock: + return self._sessions.get(session_id) + + def cancel_session(self, session_id: str) -> bool: + """取消会话""" + with self._lock: + sess = self._sessions.get(session_id) + if sess and sess.status == "running": + sess.status = "cancelled" + sess.completed_at = time.time() + self._notify("agent_cancelled", sess.to_dict()) + return True + return False + + # ---- Agent Invocation ---- + + async def invoke_agent( + self, + agent_id: str, + prompt: str, + system_prompt: str = "", + model_id: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 4096, + context: Optional[dict] = None, + db_session=None, + ) -> dict: + """调用 Agent — 创建会话、发送提示、等待完成 + + 这是一个高级封装,内部自动创建和管理会话生命周期。 + 返回完整结果字典,包含 output, tool_calls, chain_of_thought 等。 + """ + import httpx + + # Resolve agent config from DB + agent_name = agent_id + if db_session: + config = resolve_agent_config(db_session, agent_id) + if config: + agent_name = config.agent_name + system_prompt = system_prompt or config.system_prompt + model_id = model_id or config.model_id + temperature = config.temperature + max_tokens = config.max_tokens + + # Create session + session_id = self.create_session(agent_id, agent_name) + sess = self.get_session(session_id) + + # Resolve model + if db_session and model_id: + model_cfg = resolve_model_config(db_session, model_id) + else: + model_cfg = {"api_key": "", "api_base": "https://api.siliconflow.cn/v1", "model_name": "deepseek-ai/DeepSeek-V4-Flash"} + + # Update status + with self._lock: + sess.status = "running" + sess.prompt = prompt + + self._notify("agent_running", {"session_id": session_id, "agent_name": agent_name, "prompt": prompt[:200]}) + + try: + # Build messages + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + # Call LLM + headers = { + "Authorization": f"Bearer {model_cfg['api_key']}", + "Content-Type": "application/json", + } + payload = { + "model": model_cfg["model_name"], + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + "stream": False, + } + + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post( + f"{model_cfg['api_base']}/chat/completions", + headers=headers, + json=payload, + ) + resp.raise_for_status() + data = resp.json() + + output = data["choices"][0]["message"]["content"] + usage = data.get("usage", {}) + + with self._lock: + sess.status = "completed" + sess.output = output + sess.completed_at = time.time() + + result = { + "session_id": session_id, + "status": "completed", + "output": output, + "agent_name": agent_name, + "usage": usage, + "tool_calls": sess.tool_calls, + "chain_of_thought": sess.chain_of_thought, + } + self._notify("agent_completed", {"session_id": session_id, "output_preview": output[:200]}) + return result + + except Exception as e: + error_msg = str(e) + with self._lock: + sess.status = "failed" + sess.error = error_msg + sess.completed_at = time.time() + + result = { + "session_id": session_id, + "status": "failed", + "output": f"[Agent] 调用失败: {error_msg}", + "agent_name": agent_name, + "error": error_msg, + } + self._notify("agent_failed", {"session_id": session_id, "error": error_msg}) + return result + + async def invoke_agent_streaming( + self, + agent_id: str, + prompt: str, + system_prompt: str = "", + model_id: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 4096, + context: Optional[dict] = None, + db_session=None, + ) -> AsyncIterator[dict]: + """流式调用 Agent — 逐步返回 LLM 生成的 token chunks + + 每个 yield 的 dict 格式: + {"type": "token"|"done"|"error", "content": str, ...} + """ + import httpx + + # Resolve agent config + agent_name = agent_id + if db_session: + config = resolve_agent_config(db_session, agent_id) + if config: + agent_name = config.agent_name + system_prompt = system_prompt or config.system_prompt + model_id = model_id or config.model_id + temperature = config.temperature + max_tokens = config.max_tokens + + session_id = self.create_session(agent_id, agent_name) + sess = self.get_session(session_id) + + if db_session and model_id: + model_cfg = resolve_model_config(db_session, model_id) + else: + model_cfg = {"api_key": "", "api_base": "https://api.siliconflow.cn/v1", "model_name": "deepseek-ai/DeepSeek-V4-Flash"} + + with self._lock: + sess.status = "running" + sess.prompt = prompt + + self._notify("agent_running", {"session_id": session_id, "agent_name": agent_name}) + + try: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + headers = { + "Authorization": f"Bearer {model_cfg['api_key']}", + "Content-Type": "application/json", + } + payload = { + "model": model_cfg["model_name"], + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + "stream": True, + } + + collected = [] + async with httpx.AsyncClient(timeout=120.0) as client: + async with client.stream("POST", f"{model_cfg['api_base']}/chat/completions", + headers=headers, json=payload) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if line.startswith("data: "): + data_str = line[6:] + if data_str.strip() == "[DONE]": + break + try: + chunk = json.loads(data_str) + delta = chunk.get("choices", [{}])[0].get("delta", {}) + content = delta.get("content", "") + if content: + collected.append(content) + yield {"type": "token", "content": content, "session_id": session_id} + # Notify periodically (every ~20 tokens) + if len(collected) % 20 == 0: + self._notify("agent_token", { + "session_id": session_id, + "partial": "".join(collected[-20:]), + }) + except json.JSONDecodeError: + continue + + full_output = "".join(collected) + with self._lock: + sess.status = "completed" + sess.output = full_output + sess.completed_at = time.time() + + self._notify("agent_completed", {"session_id": session_id, "output_preview": full_output[:200]}) + yield {"type": "done", "session_id": session_id, "output": full_output, "status": "completed"} + + except Exception as e: + error_msg = str(e) + with self._lock: + sess.status = "failed" + sess.error = error_msg + sess.completed_at = time.time() + + self._notify("agent_failed", {"session_id": session_id, "error": error_msg}) + yield {"type": "error", "session_id": session_id, "error": error_msg, "status": "failed"} + + # ---- Multi-Agent Parallel Execution ---- + + async def invoke_agents_parallel( + self, + tasks: list, + db_session=None, + ) -> list: + """并行调用多个 Agent + + tasks = [ + {"agent_id": "xxx", "prompt": "...", "system_prompt": "..."}, + {"agent_id": "yyy", "prompt": "...", "system_prompt": "..."}, + ] + + Returns list of results in same order. + """ + async def _invoke_one(task: dict) -> dict: + try: + return await self.invoke_agent( + agent_id=task["agent_id"], + prompt=task.get("prompt", ""), + system_prompt=task.get("system_prompt", ""), + model_id=task.get("model_id"), + temperature=task.get("temperature", 0.7), + max_tokens=task.get("max_tokens", 4096), + context=task.get("context"), + db_session=db_session, + ) + except Exception as e: + return { + "status": "failed", + "output": f"[Agent {task.get('agent_id', 'unknown')}] 执行异常: {str(e)}", + "error": str(e), + } + + return await asyncio.gather(*[_invoke_one(t) for t in tasks]) + + +# ============================================================ +# Global Singleton +# ============================================================ + +_acp_gateway_instance: Optional[AcpGateway] = None +_lock_gateway = threading.Lock() + + +def get_acp_gateway() -> AcpGateway: + """获取全局 ACP Gateway 单例""" + global _acp_gateway_instance + if _acp_gateway_instance is None: + with _lock_gateway: + if _acp_gateway_instance is None: + _acp_gateway_instance = AcpGateway() + return _acp_gateway_instance + + +# ============================================================ +# Redis Notification Helper +# ============================================================ + +def publish_workflow_event(workflow_id: str, node_id: str, event: dict): + """发布工作流执行事件到 Redis EventBus""" + try: + from app.event_bus import get_event_bus, MessageEvent + + bus = get_event_bus() + msg = MessageEvent( + platform="workflow", + channel_id=f"{workflow_id}:{node_id}", + content=json.dumps(event, ensure_ascii=False), + content_type="workflow_event", + raw={ + "workflow_id": workflow_id, + "node_id": node_id, + **event, + }, + ) + bus.publish(msg) + return True + except Exception as e: + logger.error(f"[AcpGateway] Redis 发布失败: {e}") + return False diff --git a/backend/python/app/api/admin/agents_admin.py b/backend/python/app/api/admin/agents_admin.py new file mode 100644 index 0000000..8220841 --- /dev/null +++ b/backend/python/app/api/admin/agents_admin.py @@ -0,0 +1,557 @@ +""" +Agent 管理 API(管理员) +Phase 2.3: AI PaaS Agent Management +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from sqlalchemy.orm import Session +from sqlalchemy import or_, func +from pydantic import BaseModel +from typing import Optional, List +from ...database import get_db +from ...models import MarketApp, EvolutionSkill, AIModel, User, ModelProvider +from ...auth import get_current_admin +from ...middleware.log_service import LogService +import uuid +from datetime import datetime + + +router = APIRouter() + + +# ============================================================ +# Pydantic 模型 +# ============================================================ + +class AgentConfig(BaseModel): + system_prompt: Optional[str] = None + temperature: Optional[float] = None + max_tokens: Optional[int] = None + model_id: Optional[str] = None + skills: Optional[List[str]] = None # skill IDs + tools: Optional[List[str]] = None # MCP server IDs + memory_enabled: Optional[bool] = False + reasoning_enabled: Optional[bool] = False + + +class AgentCreate(BaseModel): + name: str + description: Optional[str] = None + icon: Optional[str] = None + price: float = 0.0 + is_member_free: bool = False + config: Optional[AgentConfig] = None + + +class AgentUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + price: Optional[float] = None + is_member_free: Optional[bool] = None + config: Optional[AgentConfig] = None + status: Optional[str] = None # active/inactive + tutorial: Optional[str] = None + + +# ============================================================ +# 辅助函数 +# ============================================================ + +def agent_to_dict(a: MarketApp) -> dict: + """将 MarketApp (agent) 转换为字典""" + # 解析 skill_content 中的配置 + import json + config = {} + if a.skill_content: + try: + config = json.loads(a.skill_content) + except Exception: + pass + + return { + "id": a.id, + "name": a.name, + "type": a.type, + "source": a.source, + "publisher_id": a.publisher_id, + "publisher_name": a.publisher_name, + "description": a.description, + "icon": a.icon, + "tutorial": a.tutorial, + "price": float(a.price) if a.price else 0, + "is_member_free": a.is_member_free, + "join_promotion": a.join_promotion, + "status": a.status, + "rating_avg": float(a.rating_avg) if a.rating_avg else 0, + "review_count": a.review_count, + "install_count": a.install_count, + "agent_endpoint": a.agent_endpoint, + "skill_content": a.skill_content, + "config": config, + "created_at": a.created_at.isoformat() if a.created_at else None, + "updated_at": a.updated_at.isoformat() if a.updated_at else None, + } + + +# ============================================================ +# 端点 +# ============================================================ + +@router.get("/", summary="获取 Agent 列表") +async def list_agents( + keyword: str = Query(None, description="搜索关键词"), + status: str = Query(None, description="状态: pending/published/removed"), + source: str = Query(None, description="来源: official/user"), + 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), +): + """获取所有 Agent 列表(分页、筛选)""" + query = db.query(MarketApp).filter(MarketApp.type == "agent") + + if keyword: + query = query.filter( + or_( + MarketApp.name.ilike(f"%{keyword}%"), + MarketApp.description.ilike(f"%{keyword}%"), + MarketApp.publisher_name.ilike(f"%{keyword}%"), + ) + ) + if status: + query = query.filter(MarketApp.status == status) + if source: + query = query.filter(MarketApp.source == source) + + total = query.count() + agents = query.order_by(MarketApp.created_at.desc()).offset( + (page - 1) * page_size + ).limit(page_size).all() + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [agent_to_dict(a) for a in agents], + } + } + + +@router.get("/stats/overview", summary="Agent 统计概览") +async def get_agent_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 Agent 统计数据""" + base = db.query(MarketApp).filter(MarketApp.type == "agent") + + total = base.count() + published = base.filter(MarketApp.status == "published").count() + pending = base.filter(MarketApp.status == "pending").count() + official = base.filter(MarketApp.source == "official").count() + user_created = base.filter(MarketApp.source == "user").count() + + # 安装量统计 + install_base = db.query(func.sum(MarketApp.install_count)).filter( + MarketApp.type == "agent" + ).scalar() or 0 + + # 按类型分布 + by_source = { + "official": official, + "user": user_created, + } + + # 评分统计 + avg_rating = db.query(func.avg(MarketApp.rating_avg)).filter( + MarketApp.type == "agent", + MarketApp.rating_avg > 0, + ).scalar() or 0 + + return { + "code": 0, + "data": { + "total_agents": total, + "published_agents": published, + "pending_agents": pending, + "official_agents": official, + "user_agents": user_created, + "total_installs": int(install_base), + "average_rating": round(float(avg_rating), 2), + "by_source": by_source, + } + } + + +@router.get("/{agent_id}", summary="获取 Agent 详情") +async def get_agent( + agent_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取单个 Agent 的详细信息""" + agent = db.query(MarketApp).filter( + MarketApp.id == agent_id, + MarketApp.type == "agent", + ).first() + + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + + result = agent_to_dict(agent) + + # 获取关联的 Skills + import json + config = json.loads(agent.skill_content or "{}") + skill_ids = config.get("skills", []) + if skill_ids: + skills = db.query(EvolutionSkill).filter( + EvolutionSkill.id.in_(skill_ids) + ).all() + result["linked_skills"] = [ + { + "id": s.id, + "skill_name": s.skill_name, + "state": s.state, + "version": s.version, + } + for s in skills + ] + else: + result["linked_skills"] = [] + + # 获取可用模型列表 + models = db.query(AIModel, ModelProvider).join( + ModelProvider, AIModel.provider_id == ModelProvider.id + ).filter(AIModel.is_enabled == True).all() + result["available_models"] = [ + {"id": m.id, "name": m.name, "provider": p.name} for m, p in models + ] + + return {"code": 0, "data": result} + + +@router.post("/", summary="创建 Agent") +async def create_agent( + req: AgentCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """创建新的 Agent""" + import json + + # 检查名称是否已存在 + existing = db.query(MarketApp).filter( + MarketApp.name == req.name, + MarketApp.type == "agent", + ).first() + if existing: + raise HTTPException(status_code=400, detail="Agent 名称已存在") + + # 构建 skill_content + config = {} + if req.config: + cfg = req.config + config = { + "system_prompt": cfg.system_prompt or "", + "temperature": cfg.temperature or 0.7, + "max_tokens": cfg.max_tokens or 2048, + "model_id": cfg.model_id, + "skills": cfg.skills or [], + "tools": cfg.tools or [], + "memory_enabled": cfg.memory_enabled or False, + "reasoning_enabled": cfg.reasoning_enabled or False, + } + + agent = MarketApp( + id=str(uuid.uuid4()), + name=req.name, + type="agent", + source="official", + publisher_id=admin_user.id, + publisher_name=admin_user.username, + description=req.description or "", + icon=req.icon or "", + price=0.0, # 管理员导入/安装/开发的 Agent 默认免费 + is_member_free=req.is_member_free, + skill_content=json.dumps(config, ensure_ascii=False), + status="published", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + db.add(agent) + db.commit() + db.refresh(agent) + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="create_agent", + target_type="agent", + target_id=agent.id, + detail=f"创建 Agent: {agent.name}", + ) + + return {"code": 0, "message": "Agent 创建成功", "data": agent_to_dict(agent)} + + +@router.put("/{agent_id}", summary="更新 Agent") +async def update_agent( + agent_id: str, + req: AgentUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新 Agent 配置""" + import json + + agent = db.query(MarketApp).filter( + MarketApp.id == agent_id, + MarketApp.type == "agent", + ).first() + + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + + # 更新基本字段 + if req.name is not None: + # 检查新名称是否重复 + dup = db.query(MarketApp).filter( + MarketApp.name == req.name, + MarketApp.type == "agent", + MarketApp.id != agent_id, + ).first() + if dup: + raise HTTPException(status_code=400, detail="Agent 名称已存在") + agent.name = req.name + + if req.description is not None: + agent.description = req.description + if req.icon is not None: + agent.icon = req.icon + if req.price is not None: + agent.price = req.price + if req.is_member_free is not None: + agent.is_member_free = req.is_member_free + if req.status is not None: + agent.status = req.status + if req.tutorial is not None: + agent.tutorial = req.tutorial + + # 更新配置 + if req.config is not None: + cfg = req.config + # 合并现有配置 + existing_config = {} + if agent.skill_content: + try: + existing_config = json.loads(agent.skill_content) + except Exception: + pass + + new_config = { + "system_prompt": cfg.system_prompt if cfg.system_prompt is not None else existing_config.get("system_prompt", ""), + "temperature": cfg.temperature if cfg.temperature is not None else existing_config.get("temperature", 0.7), + "max_tokens": cfg.max_tokens if cfg.max_tokens is not None else existing_config.get("max_tokens", 2048), + "model_id": cfg.model_id if cfg.model_id is not None else existing_config.get("model_id"), + "skills": cfg.skills if cfg.skills is not None else existing_config.get("skills", []), + "tools": cfg.tools if cfg.tools is not None else existing_config.get("tools", []), + "memory_enabled": cfg.memory_enabled if cfg.memory_enabled is not None else existing_config.get("memory_enabled", False), + "reasoning_enabled": cfg.reasoning_enabled if cfg.reasoning_enabled is not None else existing_config.get("reasoning_enabled", False), + } + agent.skill_content = json.dumps(new_config, ensure_ascii=False) + + agent.updated_at = datetime.now() + db.commit() + db.refresh(agent) + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_agent", + target_type="agent", + target_id=agent_id, + detail=f"更新 Agent: {agent.name}", + ) + + return {"code": 0, "message": "Agent 已更新", "data": agent_to_dict(agent)} + + +@router.delete("/{agent_id}", summary="删除 Agent") +async def delete_agent( + agent_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除 Agent""" + agent = db.query(MarketApp).filter( + MarketApp.id == agent_id, + MarketApp.type == "agent", + ).first() + + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + + agent_name = agent.name + db.delete(agent) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="delete_agent", + target_type="agent", + target_id=agent_id, + detail=f"删除 Agent: {agent_name}", + ) + + return {"code": 0, "message": "Agent 已删除"} + + +@router.put("/{agent_id}/toggle", summary="启用/禁用 Agent") +async def toggle_agent( + agent_id: str, + is_active: bool = Query(..., description="是否启用"), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """启用或禁用 Agent""" + agent = db.query(MarketApp).filter( + MarketApp.id == agent_id, + MarketApp.type == "agent", + ).first() + + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + + agent.status = "published" if is_active else "pending" + agent.updated_at = datetime.now() + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="toggle_agent", + target_type="agent", + target_id=agent_id, + detail=f"{'启用' if is_active else '禁用'} Agent: {agent.name}", + ) + + return { + "code": 0, + "message": f"Agent 已{'启用' if is_active else '禁用'}", + "data": agent_to_dict(agent), + } + + +@router.get("/{agent_id}/reviews", summary="获取 Agent 评价") +async def get_agent_reviews( + agent_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 Agent 的用户评价""" + from ...models import AppReview, User + + agent = db.query(MarketApp).filter( + MarketApp.id == agent_id, + MarketApp.type == "agent", + ).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + + reviews = db.query(AppReview).filter( + AppReview.app_id == agent_id + ).order_by(AppReview.created_at.desc()).limit(50).all() + + return { + "code": 0, + "data": [ + { + "id": r.id, + "user_id": r.user_id, + "rating": r.rating, + "content": r.content, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + for r in reviews + ] + } + + +@router.get("/available/skills", summary="获取可关联的 Skills") +async def get_available_skills( + keyword: str = Query(None), + state: 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), +): + """获取可关联到 Agent 的 Skills 列表""" + query = db.query(EvolutionSkill) + + if keyword: + query = query.filter( + or_( + EvolutionSkill.skill_name.ilike(f"%{keyword}%"), + EvolutionSkill.description.ilike(f"%{keyword}%"), + ) + ) + if state: + query = query.filter(EvolutionSkill.state == state) + + total = query.count() + skills = query.order_by(EvolutionSkill.use_count.desc()).offset( + (page - 1) * page_size + ).limit(page_size).all() + + return { + "code": 0, + "data": { + "total": total, + "items": [ + { + "id": s.id, + "skill_name": s.skill_name, + "description": s.description, + "state": s.state, + "version": s.version, + "use_count": s.use_count, + "category": s.category, + "tags": s.tags, + } + for s in skills + ] + } + } + + +@router.get("/available/models", summary="获取可用模型列表") +async def get_available_models( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取可用于 Agent 的模型列表""" + models = db.query(AIModel, ModelProvider).join( + ModelProvider, AIModel.provider_id == ModelProvider.id + ).filter( + AIModel.is_enabled == True + ).order_by(AIModel.name).all() + + return { + "code": 0, + "data": [ + { + "id": m.id, + "name": m.name, + "provider": p.name, + "model_type": p.protocol, + "input_price": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, + "output_price": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, + } + for m, p in models + ] + } diff --git a/backend/python/app/api/admin/analytics_admin.py b/backend/python/app/api/admin/analytics_admin.py new file mode 100644 index 0000000..2b68c4d --- /dev/null +++ b/backend/python/app/api/admin/analytics_admin.py @@ -0,0 +1,424 @@ +""" +数据分析管理后端 API +- 事件采集、聚合统计、仪表盘配置、趋势分析 +- 路由前缀: /app/api/admin/analytics +""" +import uuid +import json +from datetime import datetime, timedelta, date +from fastapi import APIRouter, Query, HTTPException, Depends +from ...database import SessionLocal, get_db +from ..auth import get_current_user +from sqlalchemy import text +from sqlalchemy.orm import Session + +router = APIRouter() + +CST_TZ = None + +def _format_cst_time(dt_val): + global CST_TZ + if CST_TZ is None: + from datetime import timezone + CST_TZ = timezone(timedelta(hours=8)) + if dt_val is None: + return None + if isinstance(dt_val, str): + return dt_val + if dt_val.tzinfo is None: + dt_val = dt_val.replace(tzinfo=CST_TZ) + return dt_val.strftime("%Y-%m-%d %H:%M:%S") + + +# ============================================================ +# 统计概览 +# ============================================================ + +@router.get("/overview") +async def analytics_overview( + days: int = Query(30, description="统计天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """数据分析总览""" + since = datetime.now() - timedelta(days=days) + + # User stats + total_users = db.execute(text("SELECT COUNT(*) FROM users")).scalar() + active_users = db.execute(text(""" + SELECT COUNT(DISTINCT user_id) FROM token_usage_logs + WHERE created_at >= :since + """), {"since": since}).scalar() + new_users = db.execute(text(""" + SELECT COUNT(*) FROM users WHERE created_at >= :since + """), {"since": since}).scalar() + + # API stats + api_calls = db.execute(text(""" + SELECT COALESCE(SUM(call_count), 0) FROM api_call_stats + WHERE updated_at >= :since + """), {"since": since}).scalar() + avg_response = db.execute(text(""" + SELECT COALESCE(AVG(total_response_time/NULLIF(call_count,0)), 0) FROM api_call_stats + WHERE updated_at >= :since + """), {"since": since}).scalar() + error_rate = db.execute(text(""" + SELECT COALESCE(SUM(error_count)/NULLIF(SUM(call_count),0)*100, 0) FROM api_call_stats + WHERE updated_at >= :since + """), {"since": since}).scalar() + + # Token stats + tokens_used = db.execute(text(""" + SELECT COALESCE(SUM(total_tokens), 0) FROM token_usage_logs + WHERE created_at >= :since + """), {"since": since}).scalar() + total_cost = db.execute(text(""" + SELECT COALESCE(SUM(cost), 0) FROM token_usage_logs + WHERE created_at >= :since + """), {"since": since}).scalar() + + # Chat stats + total_chats = db.execute(text("SELECT COUNT(*) FROM conversations")).scalar() + total_messages = db.execute(text("SELECT COUNT(*) FROM messages")).scalar() + + # Revenue (from billing_transactions) + revenue = db.execute(text(""" + SELECT COALESCE(SUM(amount), 0) FROM billing_transactions + WHERE transaction_type='recharge' AND status='completed' AND created_at >= :since + """), {"since": since}).scalar() + + return {"code": 200, "data": { + "total_users": total_users, + "active_users": active_users, + "new_users": new_users, + "api_calls": api_calls, + "avg_response_time": round(float(avg_response), 2), + "error_rate": round(float(error_rate), 2), + "tokens_used": tokens_used, + "total_cost": float(total_cost), + "total_chats": total_chats, + "total_messages": total_messages, + "revenue": float(revenue), + "period_days": days, + }} + + +# ============================================================ +# 趋势分析 +# ============================================================ + +@router.get("/trends/api-calls") +async def api_call_trends( + days: int = Query(7, description="天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """API调用趋势(按天)""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + rows = db.execute(text(""" + SELECT DATE(created_at) as d, + COUNT(*) as calls, + COALESCE(SUM(CASE WHEN status='error' THEN 1 ELSE 0 END), 0) as errors + FROM analytics_events + WHERE event_type='api_call' AND created_at >= :since + GROUP BY DATE(created_at) ORDER BY d + """), {"since": since}).fetchall() + + if not rows: + # Fallback to api_call_stats + rows = db.execute(text(""" + SELECT DATE(updated_at) as d, SUM(call_count) as calls, SUM(error_count) as errors + FROM api_call_stats WHERE updated_at >= :since + GROUP BY DATE(updated_at) ORDER BY d + """), {"since": since}).fetchall() + + data = [{"date": str(r[0]), "calls": int(r[1] or 0), "errors": int(r[2] or 0)} for r in rows] + return {"code": 200, "data": data} + + +@router.get("/trends/tokens") +async def token_usage_trends( + days: int = Query(7, description="天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """Token使用趋势(按天+按模型)""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + rows = db.execute(text(""" + SELECT DATE(created_at) as d, model_name, + SUM(total_tokens) as tokens, SUM(cost) as cost + FROM token_usage_logs + WHERE created_at >= :since + GROUP BY DATE(created_at), model_name + ORDER BY d, tokens DESC + """), {"since": since}).fetchall() + + # Group by date + by_date = {} + for r in rows: + d = str(r[0]) + if d not in by_date: + by_date[d] = {"date": d, "total_tokens": 0, "models": {}} + by_date[d]["total_tokens"] += int(r[2] or 0) + model = r[1] or "unknown" + by_date[d]["models"][model] = int(r[2] or 0) + + # Model distribution + model_dist = {} + for r in rows: + model = r[1] or "unknown" + model_dist[model] = model_dist.get(model, 0) + int(r[2] or 0) + + return {"code": 200, "data": { + "daily": list(by_date.values()), + "model_distribution": model_dist, + }} + + +@router.get("/trends/users") +async def user_growth_trends( + days: int = Query(30, description="天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """用户增长趋势""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + rows = db.execute(text(""" + SELECT DATE(created_at) as d, COUNT(*) as new_users + FROM users WHERE created_at >= :since + GROUP BY DATE(created_at) ORDER BY d + """), {"since": since}).fetchall() + + data = [{"date": str(r[0]), "new_users": int(r[1])} for r in rows] + + # Cumulative + cumulative = 0 + total_before = db.execute(text("SELECT COUNT(*) FROM users WHERE created_at < :since"), + {"since": since}).scalar() + cumulative = total_before + for item in data: + cumulative += item["new_users"] + item["cumulative"] = cumulative + + return {"code": 200, "data": data} + + +@router.get("/trends/revenue") +async def revenue_trends( + days: int = Query(30, description="天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """收入趋势""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + rows = db.execute(text(""" + SELECT DATE(created_at) as d, + COALESCE(SUM(CASE WHEN transaction_type='recharge' THEN amount ELSE 0 END), 0) as revenue, + COALESCE(SUM(CASE WHEN transaction_type='consumption' THEN amount ELSE 0 END), 0) as consumption + FROM billing_transactions + WHERE status='completed' AND created_at >= :since + GROUP BY DATE(created_at) ORDER BY d + """), {"since": since}).fetchall() + + data = [{"date": str(r[0]), "revenue": float(r[1] or 0), "consumption": float(r[2] or 0)} for r in rows] + return {"code": 200, "data": data} + + +# ============================================================ +# 模型使用分析 +# ============================================================ + +@router.get("/models/usage") +async def model_usage_analysis( + days: int = Query(30, description="天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """模型使用分析""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + rows = db.execute(text(""" + SELECT model_name, + COUNT(*) as call_count, + SUM(total_tokens) as total_tokens, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens, + SUM(cost) as total_cost, + AVG(total_tokens) as avg_tokens + FROM token_usage_logs + WHERE created_at >= :since AND model_name IS NOT NULL + GROUP BY model_name + ORDER BY total_tokens DESC + """), {"since": since}).fetchall() + + data = [{ + "model_name": r[0], + "call_count": int(r[1]), + "total_tokens": int(r[2] or 0), + "prompt_tokens": int(r[3] or 0), + "completion_tokens": int(r[4] or 0), + "total_cost": float(r[5] or 0), + "avg_tokens": round(float(r[6] or 0), 0), + } for r in rows] + return {"code": 200, "data": data} + + +# ============================================================ +# 仪表盘配置 +# ============================================================ + +@router.get("/dashboards") +async def list_dashboards(db: Session = Depends(get_db), user=Depends(get_current_user)): + """获取仪表盘配置列表""" + rows = db.execute(text(""" + SELECT ad.*, u.username as created_by_name + FROM analytics_dashboards ad + LEFT JOIN users u ON ad.created_by = u.id + ORDER BY ad.is_default DESC, ad.created_at DESC + """)).fetchall() + cols = ['id','tenant_id','name','description','config','is_default', + 'created_by','created_at','updated_at','created_by_name'] + items = [] + for r in rows: + d = _format_cst_time_dict(r, cols) + items.append(d) + return {"code": 200, "data": items} + + +def _format_cst_time_dict(row, columns): + result = {} + for col, val in zip(columns, row): + if isinstance(val, datetime): + result[col] = _format_cst_time(val) + elif isinstance(val, str) and val and val.startswith('[') or (isinstance(val, str) and val and val.startswith('{')): + try: + result[col] = json.loads(val) + except: + result[col] = val + else: + result[col] = val + return result + + +@router.post("/dashboards/create") +async def create_dashboard( + name: str = Query(...), + description: str = Query(""), + config: str = Query(..., description="仪表盘配置JSON"), + is_default: int = Query(0), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建仪表盘""" + did = str(uuid.uuid4()) + try: + config_json = json.loads(config) + except: + config_json = json.loads('[{"type":"stat","title":"暂无数据","metric":"none","chart_type":"number"}]') + + if is_default: + db.execute(text("UPDATE analytics_dashboards SET is_default=0")) + + db.execute(text(""" + INSERT INTO analytics_dashboards (id, name, description, config, is_default, created_by) + VALUES (:id, :name, :desc, :config, :default, :uid) + """), { + "id": did, "name": name, "desc": description, + "config": json.dumps(config_json), "default": is_default, "uid": user.id + }) + db.commit() + return {"code": 200, "data": {"id": did, "message": "仪表盘创建成功"}} + + +@router.delete("/dashboards/{dashboard_id}") +async def delete_dashboard(dashboard_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """删除仪表盘""" + db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=0"), {"id": dashboard_id}) + db.commit() + return {"code": 200, "data": {"message": "仪表盘已删除"}} + + +# ============================================================ +# 事件记录 +# ============================================================ + +@router.post("/events/record") +async def record_event( + event_type: str = Query(..., description="事件类型"), + event_category: str = Query("general"), + event_data: str = Query("", description="事件数据JSON"), + duration_ms: int = Query(None), + status: str = Query("success"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """记录分析事件""" + data = None + if event_data: + try: + data = json.loads(event_data) + except: + data = {"raw": event_data} + + db.execute(text(""" + INSERT INTO analytics_events (tenant_id, user_id, event_type, event_category, event_data, duration_ms, status) + VALUES (:tid, :uid, :et, :ec, :data, :dur, :status) + """), { + "tid": None, "uid": user.id, "et": event_type, "ec": event_category, + "data": json.dumps(data) if data else None, + "dur": duration_ms, "status": status + }) + db.commit() + return {"code": 200, "data": {"message": "事件已记录"}} + + +@router.get("/events") +async def list_events( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + event_type: str = Query(""), + event_category: str = Query(""), + status: str = Query(""), + days: int = Query(7, description="查询天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取事件列表""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + where = "ae.created_at >= :since" + params = {"since": since} + if event_type: + where += " AND ae.event_type=:et" + params["et"] = event_type + if event_category: + where += " AND ae.event_category=:ec" + params["ec"] = event_category + if status: + where += " AND ae.status=:st" + params["st"] = status + + total = db.execute(text(f"SELECT COUNT(*) FROM analytics_events ae WHERE {where}"), params).scalar() + offset = (page - 1) * page_size + rows = db.execute(text(f""" + SELECT ae.*, u.username + FROM analytics_events ae + LEFT JOIN users u ON ae.user_id = u.id + WHERE {where} + ORDER BY ae.created_at DESC LIMIT :limit OFFSET :offset + """), {**params, "limit": page_size, "offset": offset}).fetchall() + cols = ['id','tenant_id','user_id','event_type','event_category','event_data', + 'ip_address','user_agent','duration_ms','status','created_at','username'] + items = [] + for r in rows: + d = {} + for col, val in zip(cols, r): + if isinstance(val, datetime): + d[col] = _format_cst_time(val) + elif isinstance(val, str) and val and (val.startswith('{') or val.startswith('[')): + try: + d[col] = json.loads(val) + except: + d[col] = val + else: + d[col] = val + items.append(d) + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} diff --git a/backend/python/app/api/admin/apps_admin.py b/backend/python/app/api/admin/apps_admin.py new file mode 100644 index 0000000..a37432f --- /dev/null +++ b/backend/python/app/api/admin/apps_admin.py @@ -0,0 +1,844 @@ +"""AI应用管理 - Admin API + +提供AI应用的完整生命周期管理: +- 应用CRUD + 生命周期(草稿→发布→暂停→归档) +- 应用模板(快速创建) +- 版本管理(配置快照、回滚) +- 应用分析(对话数、Token消耗、活跃用户、响应时间) +- 分类统计 +""" + +from fastapi import APIRouter, HTTPException, Query, Body +from pydantic import BaseModel +from typing import Optional, List +from sqlalchemy import text +from datetime import datetime, timedelta +import json +import uuid + +router = APIRouter() + +# Database - use app's existing connection +from ...database import SessionLocal + +# Timezone +from datetime import timezone +CST_TZ = timezone(timedelta(hours=8)) + + +def cst_now(): + return datetime.now(CST_TZ).replace(tzinfo=None) + + +def fmt_time(dt_val): + if not dt_val: + return None + if hasattr(dt_val, 'replace'): + return dt_val.replace(tzinfo=CST_TZ).isoformat() + return str(dt_val) + + +def gen_id(): + return str(uuid.uuid4()) + +# ============================================================ +# Pydantic Models +# ============================================================ + +class AIAppCreate(BaseModel): + name: str + description: Optional[str] = None + type: str = "chatbot" # chatbot / assistant / workflow / api_service + category: str = "custom" + tags: Optional[str] = None + icon: Optional[str] = None + config: Optional[dict] = None + + +class AIAppUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + type: Optional[str] = None + category: Optional[str] = None + tags: Optional[str] = None + icon: Optional[str] = None + config: Optional[dict] = None + status: Optional[str] = None + + +class AIAppVersionCreate(BaseModel): + changelog: Optional[str] = None + + +class TemplateCreate(BaseModel): + name: str + description: Optional[str] = None + type: str = "chatbot" + category: str = "custom" + icon: Optional[str] = None + config: dict + + +# ============================================================ +# Stats Overview +# ============================================================ + +@router.get("/stats/overview") +async def stats_overview(): + """AI应用总览统计""" + db = SessionLocal() + try: + # Total apps + total = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE is_template = 0")).scalar() or 0 + published = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'published' AND is_template = 0")).scalar() or 0 + draft = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'draft' AND is_template = 0")).scalar() or 0 + paused = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'paused' AND is_template = 0")).scalar() or 0 + archived = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'archived' AND is_template = 0")).scalar() or 0 + templates = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE is_template = 1")).scalar() or 0 + + # By type + type_rows = db.execute(text( + "SELECT type, COUNT(*) as cnt FROM ai_applications WHERE is_template = 0 GROUP BY type" + )).fetchall() + by_type = {row[0]: row[1] for row in type_rows} + + # By category + cat_rows = db.execute(text( + "SELECT category, COUNT(*) as cnt FROM ai_applications WHERE is_template = 0 GROUP BY category" + )).fetchall() + by_category = {row[0]: row[1] for row in cat_rows} + + # Recent 7 days new apps + seven_days_ago = cst_now() - timedelta(days=7) + recent = db.execute(text( + "SELECT COUNT(*) FROM ai_applications WHERE created_at >= :dt AND is_template = 0" + ), {"dt": seven_days_ago}).scalar() or 0 + + # Total versions + total_versions = db.execute(text("SELECT COUNT(*) FROM ai_app_versions")).scalar() or 0 + + return { + "code": 0, + "data": { + "total": total, + "published": published, + "draft": draft, + "paused": paused, + "archived": archived, + "templates": templates, + "by_type": by_type, + "by_category": by_category, + "recent_7d": recent, + "total_versions": total_versions + } + } + finally: + db.close() + + +# ============================================================ +# App CRUD +# ============================================================ + +@router.get("/apps") +async def list_apps( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: Optional[str] = Query(None), + app_type: Optional[str] = Query(None), + status: Optional[str] = Query(None), + category: Optional[str] = Query(None), + is_template: bool = Query(False) +): + """获取应用列表""" + db = SessionLocal() + try: + where = "WHERE is_template = :is_template" + params = {"is_template": 1 if is_template else 0} + + if search: + where += " AND (name LIKE :search OR description LIKE :search2)" + params["search"] = f"%{search}%" + params["search2"] = f"%{search}%" + if app_type: + where += " AND type = :app_type" + params["app_type"] = app_type + if status: + where += " AND status = :status" + params["status"] = status + if category: + where += " AND category = :category" + params["category"] = category + + total = db.execute(text(f"SELECT COUNT(*) FROM ai_applications {where}"), params).scalar() or 0 + + offset = (page - 1) * page_size + rows = db.execute(text( + f"SELECT id, name, description, type, category, tags, icon, status, version, " + f"is_template, is_public, config, created_at, updated_at, published_at " + f"FROM ai_applications {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset" + ), {**params, "limit": page_size, "offset": offset}).fetchall() + + apps = [] + for row in rows: + config = row[11] + if isinstance(config, str): + try: + config = json.loads(config) + except: + config = {} + apps.append({ + "id": row[0], + "name": row[1], + "description": row[2], + "type": row[3], + "category": row[4], + "tags": row[5], + "icon": row[6], + "status": row[7], + "version": row[8], + "is_template": bool(row[9]), + "is_public": bool(row[10]), + "config": config, + "created_at": fmt_time(row[12]), + "updated_at": fmt_time(row[13]), + "published_at": fmt_time(row[14]) + }) + + return { + "code": 0, + "data": { + "items": apps, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size + } + } + finally: + db.close() + + +@router.post("/apps") +async def create_app(app: AIAppCreate): + """创建AI应用""" + db = SessionLocal() + try: + app_id = gen_id() + now = cst_now() + config_json = json.dumps(app.config or { + "model_id": None, + "system_prompt": "", + "temperature": 0.7, + "max_tokens": 2000, + "skill_ids": [], + "knowledge_base_ids": [], + "tools": [], + "welcome_message": "" + }, ensure_ascii=False) + + db.execute(text( + "INSERT INTO ai_applications (id, name, description, type, category, tags, icon, " + "status, version, is_template, is_public, config, created_at, updated_at) " + "VALUES (:id, :name, :desc, :type, :cat, :tags, :icon, 'draft', 1, 0, 0, :config, :now, :now)" + ), { + "id": app_id, "name": app.name, "desc": app.description, + "type": app.type, "cat": app.category, "tags": app.tags, + "icon": app.icon, "config": config_json, "now": now + }) + db.commit() + + # Save initial version + version_id = gen_id() + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:id, :app_id, 1, :config, :changelog, :now)" + ), { + "id": version_id, "app_id": app_id, "config": config_json, + "changelog": "Initial version", "now": now + }) + db.commit() + + return {"code": 0, "data": {"id": app_id, "message": "Application created"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.get("/apps/{app_id}") +async def get_app(app_id: str): + """获取应用详情""" + db = SessionLocal() + try: + row = db.execute(text( + "SELECT id, name, description, type, category, tags, icon, status, version, " + "is_template, is_public, config, created_at, updated_at, published_at " + "FROM ai_applications WHERE id = :id" + ), {"id": app_id}).fetchone() + + if not row: + raise HTTPException(404, "Application not found") + + config = row[11] + if isinstance(config, str): + try: + config = json.loads(config) + except: + config = {} + + # Get versions + version_rows = db.execute(text( + "SELECT id, version, changelog, created_at FROM ai_app_versions " + "WHERE app_id = :id ORDER BY version DESC" + ), {"id": app_id}).fetchall() + + versions = [{ + "id": v[0], "version": v[1], "changelog": v[2], + "created_at": fmt_time(v[3]) + } for v in version_rows] + + # Get analytics summary (last 7 days) + seven_days_ago = cst_now() - timedelta(days=7) + analytics = db.execute(text( + "SELECT COALESCE(SUM(conversations), 0), COALESCE(SUM(messages), 0), " + "COALESCE(SUM(tokens_input + tokens_output), 0), COALESCE(SUM(active_users), 0), " + "COALESCE(SUM(error_count), 0) " + "FROM ai_app_analytics WHERE app_id = :id AND date >= :dt" + ), {"id": app_id, "dt": seven_days_ago}).fetchone() + + return { + "code": 0, + "data": { + "id": row[0], "name": row[1], "description": row[2], + "type": row[3], "category": row[4], "tags": row[5], + "icon": row[6], "status": row[7], "version": row[8], + "is_template": bool(row[9]), "is_public": bool(row[10]), + "config": config, + "created_at": fmt_time(row[12]), + "updated_at": fmt_time(row[13]), + "published_at": fmt_time(row[14]), + "versions": versions, + "analytics_7d": { + "conversations": analytics[0], + "messages": analytics[1], + "tokens": analytics[2], + "active_users": analytics[3], + "errors": analytics[4] + } + } + } + finally: + db.close() + + +@router.put("/apps/{app_id}") +async def update_app(app_id: str, app: AIAppUpdate): + """更新应用""" + db = SessionLocal() + try: + existing = db.execute(text("SELECT config, version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone() + if not existing: + raise HTTPException(404, "Application not found") + + old_config = existing[0] + old_version = existing[1] + now = cst_now() + + updates = [] + params = {"id": app_id, "now": now} + + if app.name is not None: + updates.append("name = :name") + params["name"] = app.name + if app.description is not None: + updates.append("description = :desc") + params["desc"] = app.description + if app.type is not None: + updates.append("type = :type") + params["type"] = app.type + if app.category is not None: + updates.append("category = :cat") + params["cat"] = app.category + if app.tags is not None: + updates.append("tags = :tags") + params["tags"] = app.tags + if app.icon is not None: + updates.append("icon = :icon") + params["icon"] = app.icon + if app.status is not None: + updates.append("status = :status") + params["status"] = app.status + if app.status == "published": + updates.append("published_at = :now") + + config_changed = False + if app.config is not None: + new_config = json.dumps(app.config, ensure_ascii=False) + if new_config != old_config: + updates.append("config = :config") + params["config"] = new_config + config_changed = True + + if not updates: + return {"code": 0, "data": {"message": "No changes"}} + + updates.append("updated_at = :now") + db.execute(text(f"UPDATE ai_applications SET {', '.join(updates)} WHERE id = :id"), params) + + # Save new version if config changed + if config_changed: + new_version = old_version + 1 + db.execute(text("UPDATE ai_applications SET version = :v WHERE id = :id"), {"v": new_version, "id": app_id}) + version_id = gen_id() + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:vid, :aid, :v, :config, :cl, :now)" + ), { + "vid": version_id, "aid": app_id, "v": new_version, + "config": new_config, "cl": f"Version {new_version}", "now": now + }) + + db.commit() + return {"code": 0, "data": {"message": "Application updated"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.delete("/apps/{app_id}") +async def delete_app(app_id: str): + """删除应用""" + db = SessionLocal() + try: + result = db.execute(text("DELETE FROM ai_applications WHERE id = :id"), {"id": app_id}) + if result.rowcount == 0: + raise HTTPException(404, "Application not found") + db.execute(text("DELETE FROM ai_app_versions WHERE app_id = :id"), {"id": app_id}) + db.execute(text("DELETE FROM ai_app_analytics WHERE app_id = :id"), {"id": app_id}) + db.commit() + return {"code": 0, "data": {"message": "Application deleted"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +# ============================================================ +# Lifecycle Management +# ============================================================ + +@router.post("/apps/{app_id}/publish") +async def publish_app(app_id: str): + """发布应用""" + db = SessionLocal() + try: + now = cst_now() + result = db.execute(text( + "UPDATE ai_applications SET status = 'published', published_at = :now, updated_at = :now WHERE id = :id" + ), {"id": app_id, "now": now}) + if result.rowcount == 0: + raise HTTPException(404, "Application not found") + db.commit() + return {"code": 0, "data": {"message": "Application published"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.post("/apps/{app_id}/pause") +async def pause_app(app_id: str): + """暂停应用""" + db = SessionLocal() + try: + result = db.execute(text( + "UPDATE ai_applications SET status = 'paused', updated_at = :now WHERE id = :id" + ), {"id": app_id, "now": cst_now()}) + if result.rowcount == 0: + raise HTTPException(404, "Application not found") + db.commit() + return {"code": 0, "data": {"message": "Application paused"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.post("/apps/{app_id}/archive") +async def archive_app(app_id: str): + """归档应用""" + db = SessionLocal() + try: + result = db.execute(text( + "UPDATE ai_applications SET status = 'archived', updated_at = :now WHERE id = :id" + ), {"id": app_id, "now": cst_now()}) + if result.rowcount == 0: + raise HTTPException(404, "Application not found") + db.commit() + return {"code": 0, "data": {"message": "Application archived"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.post("/apps/{app_id}/duplicate") +async def duplicate_app(app_id: str): + """复制应用""" + db = SessionLocal() + try: + row = db.execute(text( + "SELECT name, description, type, category, tags, icon, config " + "FROM ai_applications WHERE id = :id" + ), {"id": app_id}).fetchone() + + if not row: + raise HTTPException(404, "Application not found") + + new_id = gen_id() + now = cst_now() + db.execute(text( + "INSERT INTO ai_applications (id, name, description, type, category, tags, icon, " + "status, version, is_template, is_public, config, created_at, updated_at) " + "VALUES (:id, :name, :desc, :type, :cat, :tags, :icon, 'draft', 1, 0, 0, :config, :now, :now)" + ), { + "id": new_id, "name": row[0] + " (Copy)", "desc": row[1], + "type": row[2], "cat": row[3], "tags": row[4], + "icon": row[5], "config": row[6], "now": now + }) + + # Copy initial version + version_id = gen_id() + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:vid, :aid, 1, :config, :cl, :now)" + ), {"vid": version_id, "aid": new_id, "config": row[6], "cl": "Copied from original", "now": now}) + + db.commit() + return {"code": 0, "data": {"id": new_id, "message": "Application duplicated"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +# ============================================================ +# Version Management +# ============================================================ + +@router.get("/apps/{app_id}/versions") +async def list_versions(app_id: str): + """获取应用版本历史""" + db = SessionLocal() + try: + rows = db.execute(text( + "SELECT id, version, config, changelog, created_at " + "FROM ai_app_versions WHERE app_id = :id ORDER BY version DESC" + ), {"id": app_id}).fetchall() + + versions = [] + for row in rows: + config = row[2] + if isinstance(config, str): + try: + config = json.loads(config) + except: + config = {} + versions.append({ + "id": row[0], "version": row[1], "config": config, + "changelog": row[3], "created_at": fmt_time(row[4]) + }) + + return {"code": 0, "data": {"items": versions, "total": len(versions)}} + finally: + db.close() + + +@router.post("/apps/{app_id}/versions") +async def save_version(app_id: str, body: AIAppVersionCreate): + """保存当前配置为新版本""" + db = SessionLocal() + try: + row = db.execute(text("SELECT config, version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone() + if not row: + raise HTTPException(404, "Application not found") + + new_version = row[1] + 1 + version_id = gen_id() + now = cst_now() + + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:vid, :aid, :v, :config, :cl, :now)" + ), { + "vid": version_id, "aid": app_id, "v": new_version, + "config": row[0], "cl": body.changelog or f"Version {new_version}", "now": now + }) + db.execute(text("UPDATE ai_applications SET version = :v, updated_at = :now WHERE id = :id"), + {"v": new_version, "now": now, "id": app_id}) + db.commit() + return {"code": 0, "data": {"version": new_version, "message": "Version saved"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.post("/apps/{app_id}/versions/{version}/restore") +async def restore_version(app_id: str, version: int): + """恢复到指定版本""" + db = SessionLocal() + try: + row = db.execute(text( + "SELECT config FROM ai_app_versions WHERE app_id = :id AND version = :v" + ), {"id": app_id, "v": version}).fetchone() + + if not row: + raise HTTPException(404, "Version not found") + + now = cst_now() + current = db.execute(text("SELECT version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone() + if not current: + raise HTTPException(404, "Application not found") + + new_version = current[0] + 1 + db.execute(text( + "UPDATE ai_applications SET config = :config, version = :v, updated_at = :now WHERE id = :id" + ), {"config": row[0], "v": new_version, "now": now, "id": app_id}) + + # Save restored version + version_id = gen_id() + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:vid, :aid, :v, :config, :cl, :now)" + ), { + "vid": version_id, "aid": app_id, "v": new_version, + "config": row[0], "cl": f"Restored from v{version}", "now": now + }) + + db.commit() + return {"code": 0, "data": {"version": new_version, "message": f"Restored to v{version}"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +# ============================================================ +# Analytics +# ============================================================ + +@router.get("/apps/{app_id}/analytics") +async def app_analytics(app_id: str, days: int = Query(7, ge=1, le=90)): + """获取应用分析数据""" + db = SessionLocal() + try: + start_date = cst_now() - timedelta(days=days) + rows = db.execute(text( + "SELECT date, conversations, messages, tokens_input, tokens_output, " + "active_users, avg_response_ms, error_count " + "FROM ai_app_analytics WHERE app_id = :id AND date >= :dt ORDER BY date" + ), {"id": app_id, "dt": start_date}).fetchall() + + daily = [] + for row in rows: + daily.append({ + "date": row[0].isoformat() if row[0] else None, + "conversations": row[1], "messages": row[2], + "tokens_input": row[3], "tokens_output": row[4], + "active_users": row[5], "avg_response_ms": row[6], + "errors": row[7] + }) + + # Summary + total_convs = sum(d["conversations"] for d in daily) + total_msgs = sum(d["messages"] for d in daily) + total_tokens_in = sum(d["tokens_input"] for d in daily) + total_tokens_out = sum(d["tokens_output"] for d in daily) + total_users = sum(d["active_users"] for d in daily) + total_errors = sum(d["errors"] for d in daily) + avg_response = sum(d["avg_response_ms"] for d in daily) / len(daily) if daily else 0 + + return { + "code": 0, + "data": { + "daily": daily, + "summary": { + "conversations": total_convs, + "messages": total_msgs, + "tokens_input": total_tokens_in, + "tokens_output": total_tokens_out, + "active_users": total_users, + "errors": total_errors, + "avg_response_ms": round(avg_response, 1), + "error_rate": round(total_errors / total_msgs * 100, 2) if total_msgs else 0 + }, + "days": days + } + } + finally: + db.close() + + +# ============================================================ +# Templates +# ============================================================ + +@router.get("/templates") +async def list_templates( + type: Optional[str] = Query(None), + category: Optional[str] = Query(None) +): + """获取应用模板列表""" + db = SessionLocal() + try: + where = "WHERE is_template = 1" + params = {} + if type: + where += " AND type = :type" + params["type"] = type + if category: + where += " AND category = :category" + params["category"] = category + + rows = db.execute(text( + f"SELECT id, name, description, type, category, icon, config, created_at " + f"FROM ai_applications {where} ORDER BY created_at DESC" + ), params).fetchall() + + templates = [] + for row in rows: + config = row[6] + if isinstance(config, str): + try: + config = json.loads(config) + except: + config = {} + templates.append({ + "id": row[0], "name": row[1], "description": row[2], + "type": row[3], "category": row[4], "icon": row[5], + "config": config, "created_at": fmt_time(row[7]) + }) + + return {"code": 0, "data": {"items": templates, "total": len(templates)}} + finally: + db.close() + + +@router.post("/templates") +async def create_template(tpl: TemplateCreate): + """创建应用模板""" + db = SessionLocal() + try: + tpl_id = gen_id() + now = cst_now() + config_json = json.dumps(tpl.config, ensure_ascii=False) + + db.execute(text( + "INSERT INTO ai_applications (id, name, description, type, category, icon, " + "status, version, is_template, is_public, config, created_at, updated_at) " + "VALUES (:id, :name, :desc, :type, :cat, :icon, 'published', 1, 1, 1, :config, :now, :now)" + ), { + "id": tpl_id, "name": tpl.name, "desc": tpl.description, + "type": tpl.type, "cat": tpl.category, "icon": tpl.icon, + "config": config_json, "now": now + }) + db.commit() + return {"code": 0, "data": {"id": tpl_id, "message": "Template created"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.post("/apps/from-template/{template_id}") +async def create_from_template(template_id: str, body: dict = Body(default={})): + """从模板创建应用""" + db = SessionLocal() + try: + row = db.execute(text( + "SELECT name, description, type, category, config FROM ai_applications WHERE id = :id AND is_template = 1" + ), {"id": template_id}).fetchone() + + if not row: + raise HTTPException(404, "Template not found") + + new_id = gen_id() + now = cst_now() + custom_name = body.get("name", row[0] + " (Instance)") + + db.execute(text( + "INSERT INTO ai_applications (id, name, description, type, category, tags, icon, " + "status, version, is_template, is_public, config, created_at, updated_at) " + "VALUES (:id, :name, :desc, :type, :cat, NULL, NULL, 'draft', 1, 0, 0, :config, :now, :now)" + ), { + "id": new_id, "name": custom_name, "desc": row[1], + "type": row[2], "cat": row[3], "config": row[4], "now": now + }) + + version_id = gen_id() + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:vid, :aid, 1, :config, :cl, :now)" + ), {"vid": version_id, "aid": new_id, "config": row[4], "cl": "Created from template", "now": now}) + + db.commit() + return {"code": 0, "data": {"id": new_id, "message": "Application created from template"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +# ============================================================ +# Categories +# ============================================================ + +@router.get("/categories") +async def list_categories(): + """获取应用分类统计""" + db = SessionLocal() + try: + rows = db.execute(text( + "SELECT category, COUNT(*) as cnt, " + "SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END) as published " + "FROM ai_applications WHERE is_template = 0 " + "GROUP BY category ORDER BY cnt DESC" + )).fetchall() + + categories = [] + category_names = { + "customer_service": "客服助手", + "data_analysis": "数据分析", + "coding": "编程辅助", + "writing": "内容创作", + "education": "教育培训", + "marketing": "营销推广", + "hr": "人力资源", + "finance": "财务法务", + "custom": "自定义" + } + for row in rows: + categories.append({ + "key": row[0], + "name": category_names.get(row[0], row[0]), + "count": row[1], + "published": row[2] + }) + + return {"code": 0, "data": {"items": categories}} + finally: + db.close() diff --git a/backend/python/app/api/admin/asset_admin.py b/backend/python/app/api/admin/asset_admin.py new file mode 100644 index 0000000..6743f92 --- /dev/null +++ b/backend/python/app/api/admin/asset_admin.py @@ -0,0 +1,357 @@ +""" +资产中心管理 API(管理员) +Phase 3.8: Asset Center Enhancement - Admin asset management, analytics, activity log +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import or_, func, desc, text +from typing import Optional +import json +from datetime import datetime, timedelta + +from ...database import get_db, SessionLocal +from ...models import Asset, AssetCategory, UserStorageQuota, User +from ...auth import get_current_admin +from ...middleware.log_service import LogService + +router = APIRouter() + +CATEGORY_LABELS = { + "file": "文件", "novel": "小说", "image": "图片", + "video": "视频", "audio": "音频", "custom": "自定义", "doc": "文档", +} + + +def _execute_raw(db, sql, params=None): + result = db.execute(text(sql), params or {}) + try: + rows = result.fetchall() + return [dict(row._mapping) if hasattr(row, '_mapping') else dict(row) for row in rows] + except: + return [] + + +def _asset_to_dict(a): + return { + "id": a.id, + "user_id": a.user_id, + "name": a.name, + "category": a.category, + "sub_category": a.sub_category, + "file_type": a.file_type, + "size": a.size or 0, + "description": a.description, + "cover_url": a.cover_url, + "url": a.url, + "status": a.status, + "created_at": a.created_at.strftime('%Y-%m-%d %H:%M:%S') if a.created_at else None, + "updated_at": a.updated_at.strftime('%Y-%m-%d %H:%M:%S') if a.updated_at else None, + } + + +# ============================================================ +# Stats / Overview +# ============================================================ + +@router.get("/stats", summary="资产中心总览") +async def get_asset_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取资产中心总览数据""" + # Total assets + total_assets = _execute_raw(db, "SELECT COUNT(*) as cnt FROM assets WHERE status = 'active'") + total = total_assets[0]["cnt"] if total_assets else 0 + + # Total size + total_size = _execute_raw(db, "SELECT COALESCE(SUM(size), 0) as sz FROM assets WHERE status = 'active'") + size = total_size[0]["sz"] if total_size else 0 + + # By category + cat_stats = _execute_raw(db, """ + SELECT category, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz + FROM assets WHERE status = 'active' + GROUP BY category + """) + + # By file type + type_stats = _execute_raw(db, """ + SELECT COALESCE(file_type, 'unknown') as ft, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz + FROM assets WHERE status = 'active' + GROUP BY file_type + ORDER BY cnt DESC + LIMIT 10 + """) + + # Top users by storage + top_users = _execute_raw(db, """ + SELECT a.user_id, u.username, u.avatar, + COUNT(*) as asset_count, COALESCE(SUM(a.size), 0) as total_size + FROM assets a + LEFT JOIN users u ON a.user_id = u.id + WHERE a.status = 'active' + GROUP BY a.user_id, u.username, u.avatar + ORDER BY total_size DESC + LIMIT 10 + """) + + # Storage quota summary + quota_stats = _execute_raw(db, """ + SELECT + COUNT(*) as total_users, + COALESCE(SUM(quota_bytes), 0) as total_quota, + COALESCE(SUM(used_bytes), 0) as total_used + FROM user_storage_quotas + """) + + # Recent 7 days trend + trend = [] + for i in range(6, -1, -1): + date = (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d') + new_uploads = _execute_raw(db, """ + SELECT COUNT(*) as cnt FROM assets + WHERE DATE(created_at) = :d AND status = 'active' + """, {"d": date}) + trend.append({ + "date": date, + "new_uploads": new_uploads[0]["cnt"] if new_uploads else 0, + }) + + # Categories list + categories = _execute_raw(db, """ + SELECT type, COUNT(*) as cnt FROM asset_categories GROUP BY type + """) + + return { + "code": 0, + "data": { + "total_assets": total, + "total_size": size, + "total_size_formatted": _format_size(size), + "by_category": [{ + "category": r["category"], + "label": CATEGORY_LABELS.get(r["category"], r["category"]), + "count": r["cnt"], + "size": r["sz"], + "size_formatted": _format_size(r["sz"]), + } for r in cat_stats], + "by_file_type": [{ + "file_type": r["ft"], + "count": r["cnt"], + "size": r["sz"], + "size_formatted": _format_size(r["sz"]), + } for r in type_stats], + "top_users": [{ + "user_id": r["user_id"], + "username": r["username"] or "Unknown", + "avatar": r["avatar"], + "asset_count": r["asset_count"], + "total_size": r["total_size"], + "total_size_formatted": _format_size(r["total_size"]), + } for r in top_users], + "quota_summary": { + "total_users": quota_stats[0]["total_users"] if quota_stats else 0, + "total_quota": quota_stats[0]["total_quota"] if quota_stats else 0, + "total_used": quota_stats[0]["total_used"] if quota_stats else 0, + "total_quota_formatted": _format_size(quota_stats[0]["total_quota"]) if quota_stats else "0", + "total_used_formatted": _format_size(quota_stats[0]["total_used"]) if quota_stats else "0", + "usage_percent": round((quota_stats[0]["total_used"] / quota_stats[0]["total_quota"] * 100), 2) if quota_stats and quota_stats[0]["total_quota"] > 0 else 0, + }, + "trend_7d": trend, + "categories": [{"type": r["type"], "count": r["cnt"]} for r in categories], + } + } + + +def _format_size(bytes_val): + if not bytes_val or bytes_val == 0: + return "0 B" + if bytes_val < 1024: + return f"{bytes_val} B" + if bytes_val < 1024 * 1024: + return f"{bytes_val / 1024:.1f} KB" + if bytes_val < 1024 * 1024 * 1024: + return f"{bytes_val / (1024 * 1024):.1f} MB" + return f"{bytes_val / (1024 * 1024 * 1024):.2f} GB" + + +# ============================================================ +# Asset list & management +# ============================================================ + +@router.get("/", summary="List all assets") +async def list_all_assets( + user_id: str = Query(None), + category: str = Query(None), + file_type: str = Query(None), + keyword: str = Query(None), + status: str = Query("active"), + 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), +): + """List all user assets""" + where_clauses = [] + params = {} + if status: + where_clauses.append("a.status = :status") + params["status"] = status + if user_id: + where_clauses.append("a.user_id = :uid") + params["uid"] = user_id + if category: + where_clauses.append("a.category = :cat") + params["cat"] = category + if file_type: + where_clauses.append("a.file_type = :ft") + params["ft"] = file_type + if keyword: + where_clauses.append("(a.name LIKE :kw OR a.description LIKE :kw)") + params["kw"] = "%" + keyword + "%" + + where_sql = " AND ".join(where_clauses) + where_clause = "WHERE " + where_sql if where_sql else "" + + count_sql = "SELECT COUNT(*) as cnt FROM assets a " + where_clause + count_rows = _execute_raw(db, count_sql, params) + total = count_rows[0]["cnt"] if count_rows else 0 + + offset = (page - 1) * page_size + list_sql = "SELECT a.id, a.user_id, a.name, a.category, a.sub_category, a.file_type, a.size, a.description, a.cover_url, a.url, a.status, a.created_at, a.updated_at, u.username, u.avatar FROM assets a LEFT JOIN users u ON a.user_id = u.id " + where_clause + " ORDER BY a.created_at DESC LIMIT :limit OFFSET :offset" + list_params = dict(params) + list_params["limit"] = page_size + list_params["offset"] = offset + rows = _execute_raw(db, list_sql, list_params) + + items = [] + for r in rows: + items.append({ + "id": r["id"], "user_id": r["user_id"], "name": r["name"], + "category": r["category"], "sub_category": r["sub_category"], + "file_type": r["file_type"], "size": r["size"] or 0, + "size_formatted": _format_size(r["size"] or 0), + "description": r["description"], "cover_url": r["cover_url"], + "url": r["url"], "status": r["status"], + "username": r["username"] or "Unknown", "avatar": r["avatar"], + "created_at": r["created_at"].strftime("%Y-%m-%d %H:%M:%S") if r["created_at"] else None, + "updated_at": r["updated_at"].strftime("%Y-%m-%d %H:%M:%S") if r["updated_at"] else None, + }) + + return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}} + + + +@router.delete("/{asset_id}", summary="管理员删除资产") +async def admin_delete_asset( + asset_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """管理员删除用户资产""" + asset = db.query(Asset).filter(Asset.id == asset_id).first() + if not asset: + raise HTTPException(status_code=404, detail="资产不存在") + + asset_name = asset.name + asset.status = "deleted" + asset.updated_at = datetime.now() + db.commit() + + LogService.log_action(db, "admin", admin_user["id"], "delete_asset", f"Deleted asset: {asset_name}") + + return {"code": 0, "message": "资产已删除"} + + +@router.get("/activity", summary="最近资产活动") +async def get_asset_activity( + limit: int = Query(30, le=100), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取最近的资产活动""" + activities = _execute_raw(db, """ + SELECT a.id, a.name, a.category, a.file_type, a.size, a.status, + a.user_id, u.username, u.avatar, + a.created_at, a.updated_at + FROM assets a + LEFT JOIN users u ON a.user_id = u.id + ORDER BY a.updated_at DESC + LIMIT :lim + """, {"lim": limit}) + + return { + "code": 0, + "data": [{ + "id": r["id"], + "name": r["name"], + "category": r["category"], + "category_label": CATEGORY_LABELS.get(r["category"], r["category"]), + "file_type": r["file_type"], + "size": r["size"] or 0, + "size_formatted": _format_size(r["size"] or 0), + "status": r["status"], + "user_id": r["user_id"], + "username": r["username"] or "Unknown", + "avatar": r["avatar"], + "created_at": r["created_at"].strftime('%Y-%m-%d %H:%M:%S') if r["created_at"] else None, + "updated_at": r["updated_at"].strftime('%Y-%m-%d %H:%M:%S') if r["updated_at"] else None, + } for r in activities] + } + + +@router.get("/users/{user_id}/detail", summary="用户资产详情") +async def get_user_asset_detail( + user_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取指定用户的资产详情""" + # User info + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # Asset stats + stats = _execute_raw(db, """ + SELECT category, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz + FROM assets WHERE user_id = :uid AND status = 'active' + GROUP BY category + """, {"uid": user_id}) + + total = _execute_raw(db, """ + SELECT COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz + FROM assets WHERE user_id = :uid AND status = 'active' + """, {"uid": user_id}) + + # Quota + quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first() + + return { + "code": 0, + "data": { + "user": { + "id": user.id, + "username": user.username, + "avatar": user.avatar, + "membership_level": user.membership_level, + }, + "total_assets": total[0]["cnt"] if total else 0, + "total_size": total[0]["sz"] if total else 0, + "total_size_formatted": _format_size(total[0]["sz"]) if total else "0 B", + "by_category": [{ + "category": r["category"], + "label": CATEGORY_LABELS.get(r["category"], r["category"]), + "count": r["cnt"], + "size": r["sz"], + "size_formatted": _format_size(r["sz"]), + } for r in stats], + "quota": { + "quota_bytes": quota.quota_bytes if quota else 0, + "used_bytes": quota.used_bytes if quota else 0, + "quota_formatted": _format_size(quota.quota_bytes) if quota else "0 B", + "used_formatted": _format_size(quota.used_bytes) if quota else "0 B", + "usage_percent": round((quota.used_bytes / quota.quota_bytes * 100), 2) if quota and quota.quota_bytes > 0 else 0, + } if quota else None, + } + } diff --git a/backend/python/app/api/admin/automation_admin.py b/backend/python/app/api/admin/automation_admin.py new file mode 100644 index 0000000..9e30205 --- /dev/null +++ b/backend/python/app/api/admin/automation_admin.py @@ -0,0 +1,662 @@ +""" +自动化任务调度管理 API +Phase 3.2: 支持定时任务(cron)和条件触发(webhook/event)的自动化调度 +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body, BackgroundTasks +from sqlalchemy import or_, func, desc, asc, text +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime, timedelta +import uuid +import json +import logging +import asyncio + +from ...database import get_db +from ...models import ( + gen_uuid, +) +from ...auth import get_current_admin +from ...middleware.log_service import LogService + +router = APIRouter() +logger = logging.getLogger("aiflow") + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class AutomationTaskCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + description: Optional[str] = "" + trigger_type: str = Field(default="cron", description="cron / webhook / event") + cron_expression: Optional[str] = None # e.g. "*/5 * * * *" + event_condition: Optional[Dict[str, Any]] = None # for event/webhook triggers + action_type: str = Field(default="ai_pipeline", description="ai_pipeline / webhook / notification / skill_execute") + action_config: Dict[str, Any] = Field(default_factory=dict) + is_enabled: bool = True + max_retries: int = Field(default=3, ge=0, le=10) + timeout_seconds: int = Field(default=300, ge=10, le=3600) + + +class AutomationTaskUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + trigger_type: Optional[str] = None + cron_expression: Optional[str] = None + event_condition: Optional[Dict[str, Any]] = None + action_type: Optional[str] = None + action_config: Optional[Dict[str, Any]] = None + is_enabled: Optional[bool] = None + max_retries: Optional[int] = Field(None, ge=0, le=10) + timeout_seconds: Optional[int] = Field(None, ge=10, le=3600) + + +# ============================================================ +# Helper Functions +# ============================================================ + +TRIGGER_TYPE_MAP = { + "cron": "定时触发", + "webhook": "Webhook", + "event": "事件触发", +} + +ACTION_TYPE_MAP = { + "ai_pipeline": "AI流水线", + "webhook": "Webhook回调", + "notification": "发送通知", + "skill_execute": "技能执行", +} + + +def task_to_dict(task) -> dict: + """将AutomatedTask模型转换为字典""" + result = { + "id": task.id, + "name": task.name, + "description": getattr(task, "description", "") or "", + "trigger_type": task.trigger_type, + "trigger_type_label": TRIGGER_TYPE_MAP.get(task.trigger_type, task.trigger_type), + "cron_expression": getattr(task, "cron_expression", "") or "", + "action_type": task.action_type, + "action_type_label": ACTION_TYPE_MAP.get(task.action_type, task.action_type), + "is_enabled": task.is_enabled, + "status": task.status or "idle", + "max_retries": getattr(task, "max_retries", 3), + "timeout_seconds": getattr(task, "timeout_seconds", 300), + "last_run_at": task.last_run_at.isoformat() if task.last_run_at else None, + "next_run_at": task.next_run_at.isoformat() if task.next_run_at else None, + "total_runs": getattr(task, "total_runs", 0), + "success_runs": getattr(task, "success_runs", 0), + "created_at": task.created_at.isoformat() if task.created_at else None, + "updated_at": task.updated_at.isoformat() if task.updated_at else None, + } + + # Action config + action_config = getattr(task, "action_config", None) + if action_config: + try: + result["action_config"] = json.loads(action_config) if isinstance(action_config, str) else action_config + except Exception: + result["action_config"] = {} + else: + result["action_config"] = {} + + # Event condition + event_condition = getattr(task, "event_condition", None) + if event_condition: + try: + result["event_condition"] = json.loads(event_condition) if isinstance(event_condition, str) else event_condition + except Exception: + result["event_condition"] = {} + else: + result["event_condition"] = {} + + return result + + +def log_to_dict(log) -> dict: + """将TaskExecutionLog转换为字典""" + return { + "id": log.id, + "task_id": log.task_id, + "task_name": getattr(log, "task_name", ""), + "status": log.status, + "trigger_type": getattr(log, "trigger_type", ""), + "started_at": log.started_at.isoformat() if log.started_at else None, + "finished_at": log.finished_at.isoformat() if log.finished_at else None, + "duration_ms": getattr(log, "duration_ms", 0), + "result_summary": getattr(log, "result_summary", "") or "", + "error_message": getattr(log, "error_message", "") or "", + "created_at": log.created_at.isoformat() if log.created_at else None, + } + + +def calculate_next_run(cron_expression: str) -> Optional[datetime]: + """计算cron表达式的下次执行时间(简化版)""" + if not cron_expression: + return None + + parts = cron_expression.strip().split() + if len(parts) != 5: + return None + + now = datetime.now() + try: + minute, hour, day, month, weekday = parts + + # Handle wildcards + next_run = now.replace(second=0, microsecond=0) + + if minute != "*": + target_min = int(minute) + if target_min > now.minute: + next_run = next_run.replace(minute=target_min) + else: + next_run = next_run.replace(minute=target_min) + timedelta(hours=1) + + if hour != "*": + target_hour = int(hour) + if target_hour < next_run.hour or (target_hour == next_run.hour and next_run.minute <= now.minute): + next_run = next_run.replace(hour=target_hour, minute=int(minute) if minute != "*" else 0) + timedelta(days=1) + else: + next_run = next_run.replace(hour=target_hour) + + # Handle */N patterns for minute + if minute.startswith("*/"): + interval = int(minute[2:]) + next_minute = ((now.minute // interval) + 1) * interval + if next_minute >= 60: + next_run = next_run.replace(minute=0) + timedelta(hours=1) + else: + next_run = next_run.replace(minute=next_minute) + if hour != "*": + next_run = next_run.replace(hour=int(hour)) + + # Handle */N patterns for hour + if hour.startswith("*/"): + interval = int(hour[2:]) + next_hour = ((now.hour // interval) + 1) * interval + if next_hour >= 24: + next_run = next_run.replace(hour=0, minute=int(minute) if minute != "*" and not minute.startswith("*/") else 0) + timedelta(days=1) + else: + next_run = next_run.replace(hour=next_hour, minute=int(minute) if minute != "*" and not minute.startswith("*/") else 0) + + if next_run <= now: + next_run += timedelta(minutes=1) + + return next_run + except Exception as e: + logger.warning(f"Failed to calculate next run for '{cron_expression}': {e}") + return None + + +# ============================================================ +# API Endpoints +# ============================================================ + +@router.get("/stats/overview", summary="自动化任务统计概览") +async def get_automation_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取自动化任务的统计信息""" + from ...models import AutomatedTask, TaskExecutionLog + + total_tasks = db.query(func.count(AutomatedTask.id)).scalar() or 0 + enabled_tasks = db.query(func.count(AutomatedTask.id)).filter( + AutomatedTask.is_enabled == True + ).scalar() or 0 + running_tasks = db.query(func.count(AutomatedTask.id)).filter( + AutomatedTask.status == "running" + ).scalar() or 0 + + # By trigger type + by_trigger = {} + triggers = db.query(AutomatedTask.trigger_type, func.count(AutomatedTask.id)).group_by( + AutomatedTask.trigger_type + ).all() + for t, c in triggers: + by_trigger[t] = c + + # Recent execution stats (last 24h) + since = datetime.now() - timedelta(hours=24) + total_executions = db.query(func.count(TaskExecutionLog.id)).filter( + TaskExecutionLog.created_at >= since + ).scalar() or 0 + success_executions = db.query(func.count(TaskExecutionLog.id)).filter( + TaskExecutionLog.created_at >= since, + TaskExecutionLog.status == "success" + ).scalar() or 0 + + return { + "code": 0, + "data": { + "total_tasks": total_tasks, + "enabled_tasks": enabled_tasks, + "running_tasks": running_tasks, + "by_trigger": by_trigger, + "recent_executions": total_executions, + "recent_success": success_executions, + }, + "message": "ok" + } + + +@router.get("/", summary="获取自动化任务列表") +async def list_automation_tasks( + trigger_type: str = Query(None), + is_enabled: bool = Query(None), + keyword: str = Query(None), + 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), +): + """获取自动化任务列表""" + from ...models import AutomatedTask + + query = db.query(AutomatedTask) + + if trigger_type: + query = query.filter(AutomatedTask.trigger_type == trigger_type) + if is_enabled is not None: + query = query.filter(AutomatedTask.is_enabled == is_enabled) + if keyword: + keyword_filter = or_( + AutomatedTask.name.ilike(f"%{keyword}%"), + AutomatedTask.description.ilike(f"%{keyword}%"), + ) + query = query.filter(keyword_filter) + + total = query.count() + items = query.order_by(desc(AutomatedTask.updated_at)) \ + .offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "items": [task_to_dict(item) for item in items], + "total": total, + "page": page, + "page_size": page_size, + }, + "message": "ok" + } + + +@router.get("/{task_id}", summary="获取任务详情") +async def get_automation_task( + task_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取单个自动化任务详情""" + from ...models import AutomatedTask + + task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + return { + "code": 0, + "data": task_to_dict(task), + "message": "ok" + } + + +@router.post("/", summary="创建自动化任务") +async def create_automation_task( + req: AutomationTaskCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """创建新的自动化任务""" + from ...models import AutomatedTask + + # 检查名称重复 + existing = db.query(AutomatedTask).filter(AutomatedTask.name == req.name).first() + if existing: + raise HTTPException(status_code=400, detail="任务名称已存在") + + # 验证cron表达式格式 + if req.trigger_type == "cron" and req.cron_expression: + parts = req.cron_expression.strip().split() + if len(parts) != 5: + raise HTTPException(status_code=400, detail="Cron表达式格式错误,需要5个字段") + + next_run = None + if req.trigger_type == "cron" and req.cron_expression: + next_run = calculate_next_run(req.cron_expression) + + task = AutomatedTask( + id=gen_uuid(), + name=req.name, + description=req.description or "", + trigger_type=req.trigger_type, + cron_expression=req.cron_expression, + action_type=req.action_type, + action_config=req.action_config, + is_enabled=req.is_enabled, + status="idle", + max_retries=req.max_retries, + timeout_seconds=req.timeout_seconds, + next_run_at=next_run, + event_condition=req.event_condition, + total_runs=0, + success_runs=0, + ) + + db.add(task) + db.commit() + db.refresh(task) + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="create_automation_task", + target_type="automated_task", + target_id=task.id, + detail=f"创建了自动化任务: {task.name}" + ) + + return { + "code": 0, + "data": task_to_dict(task), + "message": "创建成功" + } + + +@router.put("/{task_id}", summary="更新自动化任务") +async def update_automation_task( + task_id: str, + req: AutomationTaskUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新自动化任务""" + from ...models import AutomatedTask + + task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + # 名称重复检查 + if req.name is not None and req.name != task.name: + existing = db.query(AutomatedTask).filter( + AutomatedTask.name == req.name, + AutomatedTask.id != task_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="任务名称已存在") + + update_fields = ["name", "description", "trigger_type", "cron_expression", + "action_type", "is_enabled", "max_retries", "timeout_seconds"] + for field in update_fields: + value = getattr(req, field, None) + if value is not None: + setattr(task, field, value) + + if req.action_config is not None: + task.action_config = req.action_config + if req.event_condition is not None: + task.event_condition = req.event_condition + + # 重新计算下次执行时间 + if req.cron_expression is not None or req.trigger_type is not None: + cron = req.cron_expression if req.cron_expression is not None else task.cron_expression + trigger = req.trigger_type if req.trigger_type is not None else task.trigger_type + if trigger == "cron" and cron: + task.next_run_at = calculate_next_run(cron) + + task.updated_at = datetime.now() + + db.commit() + db.refresh(task) + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_automation_task", + target_type="automated_task", + target_id=task.id, + detail=f"更新了自动化任务: {task.name}" + ) + + return { + "code": 0, + "data": task_to_dict(task), + "message": "更新成功" + } + + +@router.delete("/{task_id}", summary="删除自动化任务") +async def delete_automation_task( + task_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除自动化任务""" + from ...models import AutomatedTask + + task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + name = task.name + db.delete(task) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="delete_automation_task", + target_type="automated_task", + target_id=task_id, + detail=f"删除了自动化任务: {name}" + ) + + return { + "code": 0, + "data": None, + "message": f"已删除任务: {name}" + } + + +@router.put("/{task_id}/toggle", summary="启用/禁用自动化任务") +async def toggle_automation_task( + task_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """切换任务启用/禁用状态""" + from ...models import AutomatedTask + + task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + task.is_enabled = not task.is_enabled + if not task.is_enabled: + task.next_run_at = None + elif task.trigger_type == "cron" and task.cron_expression: + task.next_run_at = calculate_next_run(task.cron_expression) + + task.updated_at = datetime.now() + db.commit() + db.refresh(task) + + action = "启用" if task.is_enabled else "禁用" + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="toggle_automation_task", + target_type="automated_task", + target_id=task.id, + detail=f"{action}了自动化任务: {task.name}" + ) + + return { + "code": 0, + "data": task_to_dict(task), + "message": f"已{action}" + } + + +@router.post("/{task_id}/execute", summary="手动执行任务") +async def execute_task_now( + task_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """立即手动执行一个自动化任务""" + from ...models import AutomatedTask, TaskExecutionLog + + task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + if task.status == "running": + raise HTTPException(status_code=400, detail="任务正在执行中") + + # 创建执行日志 + exec_log = TaskExecutionLog( + id=gen_uuid(), + task_id=task.id, + task_name=task.name, + status="running", + trigger_type="manual", + started_at=datetime.now(), + ) + db.add(exec_log) + + # 更新任务状态 + task.status = "running" + task.total_runs = (task.total_runs or 0) + 1 + db.commit() + db.refresh(exec_log) + + # 异步执行(简化版: 直接模拟执行) + import time + start = time.time() + + try: + action_type = task.action_type + action_config = task.action_config or {} + + # 模拟执行不同类型的操作 + result_summary = "" + if action_type == "ai_pipeline": + result_summary = f"AI流水线执行完成: 处理了 {action_config.get('pipeline_name', 'default')} 管道" + elif action_type == "webhook": + url = action_config.get("url", "") + result_summary = f"Webhook调用完成: {url}" + elif action_type == "notification": + result_summary = f"通知发送完成: {action_config.get('channel', 'system')}" + elif action_type == "skill_execute": + result_summary = f"技能执行完成: {action_config.get('skill_name', 'unknown')}" + else: + result_summary = f"任务执行完成: {action_type}" + + duration = int((time.time() - start) * 1000) + + # 更新执行日志 + exec_log.status = "success" + exec_log.finished_at = datetime.now() + exec_log.duration_ms = duration + exec_log.result_summary = result_summary + + # 更新任务 + task.status = "idle" + task.last_run_at = datetime.now() + task.success_runs = (task.success_runs or 0) + 1 + if task.trigger_type == "cron" and task.cron_expression: + task.next_run_at = calculate_next_run(task.cron_expression) + + db.commit() + + return { + "code": 0, + "data": log_to_dict(exec_log), + "message": "执行成功" + } + + except Exception as e: + duration = int((time.time() - start) * 1000) + exec_log.status = "failed" + exec_log.finished_at = datetime.now() + exec_log.duration_ms = duration + exec_log.error_message = str(e) + task.status = "idle" + db.commit() + + raise HTTPException(status_code=500, detail=f"执行失败: {str(e)}") + + +@router.get("/{task_id}/history", summary="获取任务执行历史") +async def get_task_history( + task_id: str, + status: str = Query(None), + 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), +): + """获取任务的执行历史记录""" + from ...models import TaskExecutionLog + + query = db.query(TaskExecutionLog).filter(TaskExecutionLog.task_id == task_id) + if status: + query = query.filter(TaskExecutionLog.status == status) + + total = query.count() + items = query.order_by(desc(TaskExecutionLog.created_at)) \ + .offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "items": [log_to_dict(item) for item in items], + "total": total, + "page": page, + "page_size": page_size, + }, + "message": "ok" + } + + +@router.get("/history/all", summary="获取所有执行历史") +async def get_all_history( + status: str = Query(None), + task_id: str = Query(None), + 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), +): + """获取所有任务的执行历史""" + from ...models import TaskExecutionLog + + query = db.query(TaskExecutionLog) + if status: + query = query.filter(TaskExecutionLog.status == status) + if task_id: + query = query.filter(TaskExecutionLog.task_id == task_id) + + total = query.count() + items = query.order_by(desc(TaskExecutionLog.created_at)) \ + .offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "items": [log_to_dict(item) for item in items], + "total": total, + "page": page, + "page_size": page_size, + }, + "message": "ok" + } diff --git a/backend/python/app/api/admin/billing_admin.py b/backend/python/app/api/admin/billing_admin.py new file mode 100644 index 0000000..a3d84c0 --- /dev/null +++ b/backend/python/app/api/admin/billing_admin.py @@ -0,0 +1,462 @@ +""" +计费与支付管理后端 API +- 计费规则、充值套餐、交易记录、账单管理、支付配置 +- 路由前缀: /app/api/admin/billing +""" +import uuid +import json +from datetime import datetime, timedelta +from fastapi import APIRouter, Query, HTTPException, Depends +from ...database import SessionLocal, get_db +from ..auth import get_current_user +from sqlalchemy import text +from sqlalchemy.orm import Session + +router = APIRouter() + +CST_TZ = None + +def _format_cst_time(dt_val): + global CST_TZ + if CST_TZ is None: + from datetime import timezone + CST_TZ = timezone(timedelta(hours=8)) + if dt_val is None: + return None + if isinstance(dt_val, str): + return dt_val + if dt_val.tzinfo is None: + dt_val = dt_val.replace(tzinfo=CST_TZ) + return dt_val.strftime("%Y-%m-%d %H:%M:%S") + +def _row_to_dict(row, columns): + return {col: _format_cst_time(val) if isinstance(val, datetime) else val for col, val in zip(columns, row)} + + +# ============================================================ +# 计费规则 +# ============================================================ + +@router.get("/rules") +async def list_billing_rules( + status: str = Query("", description="状态筛选"), + billing_type: str = Query("", description="计费类型筛选"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取计费规则列表""" + where = "1=1" + params = {} + if status: + where += " AND status=:status" + params["status"] = status + if billing_type: + where += " AND billing_type=:bt" + params["bt"] = billing_type + + total = db.execute(text(f"SELECT COUNT(*) FROM billing_rules WHERE {where}"), params).scalar() + offset = (page - 1) * page_size + rows = db.execute(text(f""" + SELECT * FROM billing_rules WHERE {where} + ORDER BY created_at DESC LIMIT :limit OFFSET :offset + """), {**params, "limit": page_size, "offset": offset}).fetchall() + cols = ['id','name','description','billing_type','model_id','model_name','rate','currency', + 'min_charge','free_quota','tiered_pricing','status','created_at','updated_at'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} + + +@router.post("/rules/create") +async def create_billing_rule( + name: str = Query(...), + description: str = Query(""), + billing_type: str = Query("per_token"), + model_id: str = Query(""), + model_name: str = Query(""), + rate: float = Query(0.0), + min_charge: float = Query(0.01), + free_quota: int = Query(0), + tiered_pricing: str = Query("", description="阶梯定价JSON"), + status: str = Query("active"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建计费规则""" + rid = str(uuid.uuid4()) + tp = None + if tiered_pricing: + try: + tp = json.loads(tiered_pricing) + except: + tp = None + + db.execute(text(""" + INSERT INTO billing_rules (id, name, description, billing_type, model_id, model_name, rate, + min_charge, free_quota, tiered_pricing, status) + VALUES (:id, :name, :desc, :bt, :mid, :mname, :rate, :mc, :fq, :tp, :status) + """), { + "id": rid, "name": name, "desc": description, "bt": billing_type, + "mid": model_id or None, "mname": model_name or None, "rate": rate, + "mc": min_charge, "fq": free_quota, "tp": json.dumps(tp) if tp else None, + "status": status + }) + db.commit() + return {"code": 200, "data": {"id": rid, "message": "计费规则创建成功"}} + + +@router.put("/rules/{rule_id}") +async def update_billing_rule( + rule_id: str, + name: str = Query(None), + description: str = Query(None), + rate: float = Query(None), + min_charge: float = Query(None), + free_quota: int = Query(None), + status: str = Query(None), + tiered_pricing: str = Query(None), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新计费规则""" + sets = [] + params = {"id": rule_id} + for field, val in [("name", name), ("description", description), ("rate", rate), + ("min_charge", min_charge), ("free_quota", free_quota), ("status", status)]: + if val is not None: + sets.append(f"{field}=:{field}") + params[field] = val + if tiered_pricing is not None: + sets.append("tiered_pricing=:tp") + try: + params["tp"] = json.dumps(json.loads(tiered_pricing)) + except: + params["tp"] = None + + if not sets: + return {"code": 200, "data": {"message": "无更新"}} + db.execute(text(f"UPDATE billing_rules SET {','.join(sets)} WHERE id=:id"), params) + db.commit() + return {"code": 200, "data": {"message": "更新成功"}} + + +@router.delete("/rules/{rule_id}") +async def delete_billing_rule(rule_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """删除计费规则""" + db.execute(text("UPDATE billing_rules SET status='inactive' WHERE id=:id"), {"id": rule_id}) + db.commit() + return {"code": 200, "data": {"message": "规则已停用"}} + + +# ============================================================ +# 充值套餐 +# ============================================================ + +@router.get("/packages") +async def list_packages( + status: str = Query(""), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取充值套餐列表""" + where = "1=1" + params = {} + if status: + where += " AND status=:status" + params["status"] = status + rows = db.execute(text(f""" + SELECT * FROM recharge_packages WHERE {where} + ORDER BY sort_order ASC, price ASC + """), params).fetchall() + cols = ['id','name','description','tokens','bonus_tokens','price','original_price', + 'currency','valid_days','is_popular','sort_order','status','created_at','updated_at'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": items} + + +@router.post("/packages/create") +async def create_package( + name: str = Query(...), + description: str = Query(""), + tokens: int = Query(...), + bonus_tokens: int = Query(0), + price: float = Query(...), + original_price: float = Query(0), + valid_days: int = Query(365), + is_popular: int = Query(0), + sort_order: int = Query(0), + status: str = Query("active"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建充值套餐""" + pid = str(uuid.uuid4()) + db.execute(text(""" + INSERT INTO recharge_packages (id, name, description, tokens, bonus_tokens, price, original_price, + valid_days, is_popular, sort_order, status) + VALUES (:id, :name, :desc, :tokens, :bonus, :price, :oprice, :vd, :pop, :sort, :status) + """), { + "id": pid, "name": name, "desc": description, "tokens": tokens, "bonus": bonus_tokens, + "price": price, "oprice": original_price or None, "vd": valid_days, + "pop": is_popular, "sort": sort_order, "status": status + }) + db.commit() + return {"code": 200, "data": {"id": pid, "message": "套餐创建成功"}} + + +@router.put("/packages/{package_id}") +async def update_package( + package_id: str, + name: str = Query(None), + price: float = Query(None), + tokens: int = Query(None), + bonus_tokens: int = Query(None), + status: str = Query(None), + is_popular: int = Query(None), + sort_order: int = Query(None), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新充值套餐""" + sets = [] + params = {"id": package_id} + for field, val in [("name", name), ("price", price), ("tokens", tokens), + ("bonus_tokens", bonus_tokens), ("status", status), + ("is_popular", is_popular), ("sort_order", sort_order)]: + if val is not None: + sets.append(f"{field}=:{field}") + params[field] = val + if not sets: + return {"code": 200, "data": {"message": "无更新"}} + db.execute(text(f"UPDATE recharge_packages SET {','.join(sets)} WHERE id=:id"), params) + db.commit() + return {"code": 200, "data": {"message": "更新成功"}} + + +@router.delete("/packages/{package_id}") +async def delete_package(package_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """删除充值套餐""" + db.execute(text("UPDATE recharge_packages SET status='inactive' WHERE id=:id"), {"id": package_id}) + db.commit() + return {"code": 200, "data": {"message": "套餐已停用"}} + + +# ============================================================ +# 交易记录 +# ============================================================ + +@router.get("/transactions") +async def list_transactions( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + user_id: str = Query("", description="用户筛选"), + transaction_type: str = Query("", description="类型筛选"), + start_date: str = Query("", description="开始日期"), + end_date: str = Query("", description="结束日期"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取交易记录""" + where = "1=1" + params = {} + if user_id: + where += " AND bt.user_id=:uid" + params["uid"] = user_id + if transaction_type: + where += " AND bt.transaction_type=:tt" + params["tt"] = transaction_type + if start_date: + where += " AND bt.created_at>=:sd" + params["sd"] = start_date + if end_date: + where += " AND bt.created_at<=:ed" + params["ed"] = end_date + " 23:59:59" + + total = db.execute(text(f"SELECT COUNT(*) FROM billing_transactions bt WHERE {where}"), params).scalar() + offset = (page - 1) * page_size + rows = db.execute(text(f""" + SELECT bt.*, u.username + FROM billing_transactions bt + LEFT JOIN users u ON bt.user_id = u.id + WHERE {where} + ORDER BY bt.created_at DESC LIMIT :limit OFFSET :offset + """), {**params, "limit": page_size, "offset": offset}).fetchall() + cols = ['id','tenant_id','user_id','order_id','transaction_type','amount','tokens', + 'balance_after','model_id','model_name','description','metadata','status', + 'created_at','username'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} + + +# ============================================================ +# 账单管理 +# ============================================================ + +@router.get("/invoices") +async def list_invoices( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: str = Query(""), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取账单列表""" + where = "1=1" + params = {} + if status: + where += " AND status=:status" + params["status"] = status + + total = db.execute(text(f"SELECT COUNT(*) FROM invoices WHERE {where}"), params).scalar() + offset = (page - 1) * page_size + rows = db.execute(text(f""" + SELECT i.*, u.username + FROM invoices i + LEFT JOIN users u ON i.user_id = u.id + WHERE {where} + ORDER BY i.created_at DESC LIMIT :limit OFFSET :offset + """), {**params, "limit": page_size, "offset": offset}).fetchall() + cols = ['id','invoice_number','tenant_id','user_id','period_start','period_end', + 'subtotal','discount','tax','total','items','status','issued_at','paid_at', + 'due_at','payment_method','created_at','updated_at','username'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} + + +@router.post("/invoices/generate") +async def generate_invoice( + user_id: str = Query(..., description="用户ID"), + period_start: str = Query(..., description="周期开始"), + period_end: str = Query(..., description="周期结束"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """生成账单""" + # Calculate usage from token_usage_logs + usage = db.execute(text(""" + SELECT model_name, SUM(total_tokens) as tokens, SUM(cost) as total_cost + FROM token_usage_logs + WHERE user_id=:uid AND created_at>=:ps AND created_at<=:pe + GROUP BY model_name + """), {"uid": user_id, "ps": period_start, "pe": period_end}).fetchall() + + if not usage: + return {"code": 200, "data": {"message": "该周期内无使用记录"}} + + items = [] + subtotal = 0 + for row in usage: + model_name = row[0] or "未知模型" + tokens = row[1] or 0 + cost = float(row[2] or 0) + items.append({"name": model_name, "quantity": tokens, "unit_price": cost / tokens if tokens > 0 else 0, "amount": cost}) + subtotal += cost + + invoice_number = f"INV-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}" + iid = str(uuid.uuid4()) + + db.execute(text(""" + INSERT INTO invoices (id, invoice_number, user_id, period_start, period_end, subtotal, total, items, status) + VALUES (:id, :num, :uid, :ps, :pe, :sub, :total, :items, 'issued') + """), { + "id": iid, "num": invoice_number, "uid": user_id, + "ps": period_start, "pe": period_end, "sub": subtotal, + "total": subtotal, "items": json.dumps(items) + }) + db.commit() + return {"code": 200, "data": {"id": iid, "invoice_number": invoice_number, "total": subtotal, "items": items}} + + +# ============================================================ +# 支付配置 (扩展现有 payment_configs) +# ============================================================ + +@router.get("/payment-configs") +async def list_payment_configs(db: Session = Depends(get_db), user=Depends(get_current_user)): + """获取支付配置列表""" + rows = db.execute(text("SELECT * FROM payment_configs ORDER BY created_at DESC")).fetchall() + cols = ['id','type','app_id','merchant_id','api_key','cert_path','gateway_url','status', + 'created_at','updated_at','api_v3_key','cert_serial_no','alipay_public_key', + 'app_private_key','notify_url','return_url'] + items = [] + for r in rows: + d = _row_to_dict(r, cols) + # Mask sensitive fields + d['api_key'] = '***' if d.get('api_key') else None + d['api_v3_key'] = '***' if d.get('api_v3_key') else None + d['app_private_key'] = '***' if d.get('app_private_key') else None + items.append(d) + return {"code": 200, "data": items} + + +@router.put("/payment-configs/{config_id}") +async def update_payment_config( + config_id: str, + status: str = Query(None), + app_id: str = Query(None), + merchant_id: str = Query(None), + gateway_url: str = Query(None), + notify_url: str = Query(None), + return_url: str = Query(None), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新支付配置""" + sets = [] + params = {"id": config_id} + for field, val in [("status", status), ("app_id", app_id), ("merchant_id", merchant_id), + ("gateway_url", gateway_url), ("notify_url", notify_url), ("return_url", return_url)]: + if val is not None: + sets.append(f"{field}=:{field}") + params[field] = val + if not sets: + return {"code": 200, "data": {"message": "无更新"}} + db.execute(text(f"UPDATE payment_configs SET {','.join(sets)} WHERE id=:id"), params) + db.commit() + return {"code": 200, "data": {"message": "支付配置已更新"}} + + +# ============================================================ +# 统计 +# ============================================================ + +@router.get("/stats/overview") +async def billing_stats(db: Session = Depends(get_db), user=Depends(get_current_user)): + """计费统计概览""" + total_revenue = db.execute(text(""" + SELECT COALESCE(SUM(amount), 0) FROM billing_transactions + WHERE transaction_type='recharge' AND status='completed' + """)).scalar() or 0 + + total_consumption = db.execute(text(""" + SELECT COALESCE(SUM(amount), 0) FROM billing_transactions + WHERE transaction_type='consumption' AND status='completed' + """)).scalar() or 0 + + total_transactions = db.execute(text("SELECT COUNT(*) FROM billing_transactions")).scalar() + pending_invoices = db.execute(text("SELECT COUNT(*) FROM invoices WHERE status IN ('issued','overdue')")).scalar() + paid_invoices = db.execute(text("SELECT COUNT(*) FROM invoices WHERE status='paid'")).scalar() + active_rules = db.execute(text("SELECT COUNT(*) FROM billing_rules WHERE status='active'")).scalar() + active_packages = db.execute(text("SELECT COUNT(*) FROM recharge_packages WHERE status='active'")).scalar() + + # Revenue by day (last 7 days) + revenue_7d = db.execute(text(""" + SELECT DATE(created_at) as d, COALESCE(SUM(amount), 0) as rev + FROM billing_transactions + WHERE transaction_type='recharge' AND status='completed' + AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + GROUP BY DATE(created_at) ORDER BY d + """)).fetchall() + daily_revenue = [{"date": str(r[0]), "revenue": float(r[1])} for r in revenue_7d] + + return {"code": 200, "data": { + "total_revenue": float(total_revenue), + "total_consumption": float(total_consumption), + "total_transactions": total_transactions, + "pending_invoices": pending_invoices, + "paid_invoices": paid_invoices, + "active_rules": active_rules, + "active_packages": active_packages, + "daily_revenue_7d": daily_revenue, + }} diff --git a/backend/python/app/api/admin/code_exec_admin.py b/backend/python/app/api/admin/code_exec_admin.py new file mode 100644 index 0000000..b98fd16 --- /dev/null +++ b/backend/python/app/api/admin/code_exec_admin.py @@ -0,0 +1,369 @@ +""" +Code Execution Admin API +Manages code execution sandbox containers, task submission, and result retrieval. + +Endpoints: + GET /stats - Execution statistics + GET /runtimes - Available runtime info from sandbox + POST /execute - Submit code for execution + GET /tasks - List execution tasks (with filters) + GET /tasks/{id} - Get specific task result + DELETE /tasks/{id} - Cancel/delete task +""" +import logging +import uuid +import json +import time +from datetime import datetime, timezone, timedelta +from typing import Optional, List + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ...database import get_db, SessionLocal +from ...auth import get_current_admin + +logger = logging.getLogger(__name__) + +router = APIRouter() + +CST = timezone(timedelta(hours=8)) + +# Code execution sandbox configuration +# The executor container is on the same Docker network, accessible by container name +EXECUTOR_URL = "http://qihuaiflow-executor:8000" +EXECUTOR_TIMEOUT = 120 # max execution timeout + +# Supported languages +SUPPORTED_LANGUAGES = ["python", "javascript"] + + +def _execute_raw(db: Session, sql, params=None): + """Execute raw SQL and return results.""" + result = db.execute(text(sql), params or {}) + if sql.strip().upper().startswith("SELECT") or sql.strip().upper().startswith("WITH"): + rows = result.fetchall() + if rows: + return [dict(r._mapping) for r in rows] + return [] + db.commit() + return result + + +def _format_cst_time(dt): + """Format datetime to CST string.""" + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=CST) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def _now_cst(): + """Get current time as CST-aware datetime.""" + return datetime.now(CST) + + +# ===== Pydantic Models ===== + +class ExecuteRequest(BaseModel): + language: str = "python" + code: str + stdin: str = "" + timeout: int = Field(default=30, ge=1, le=120) + + +class ExecuteResponse(BaseModel): + task_id: str + success: bool + stdout: str = "" + stderr: str = "" + exit_code: int = 0 + execution_time_ms: float = 0 + error: Optional[str] = None + truncated: bool = False + created_at: str = "" + + +class TaskFilter(BaseModel): + language: Optional[str] = None + status: Optional[str] = None + page: int = 1 + page_size: int = 20 + + +# ===== API Endpoints ===== + +@router.get("/stats") +async def get_execution_stats(db: Session = Depends(get_db), _=Depends(get_current_admin)): + """Get code execution statistics.""" + try: + total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM code_exec_tasks")[0]["cnt"] + today = _execute_raw(db, + "SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURDATE()")[0]["cnt"] + success_count = _execute_raw(db, + "SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code = 0 AND error IS NULL")[0]["cnt"] + failed_count = _execute_raw(db, + "SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code != 0 OR error IS NOT NULL")[0]["cnt"] + timeout_count = _execute_raw(db, + "SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE error LIKE '%timeout%'")[0]["cnt"] + by_language = _execute_raw(db, + "SELECT language, COUNT(*) as cnt FROM code_exec_tasks GROUP BY language") + + avg_time = _execute_raw(db, + "SELECT AVG(execution_time_ms) as avg_ms FROM code_exec_tasks WHERE execution_time_ms > 0")[0] + total_avg = round(avg_time.get("avg_ms", 0) or 0, 2) + + return { + "code": 0, + "data": { + "total": total, + "today": today, + "success": success_count, + "failed": failed_count, + "timeout": timeout_count, + "avg_execution_time_ms": total_avg, + "by_language": {r["language"]: r["cnt"] for r in by_language} if by_language else {}, + } + } + except Exception as e: + # Table might not exist yet + return { + "code": 0, + "data": { + "total": 0, "today": 0, "success": 0, "failed": 0, "timeout": 0, + "avg_execution_time_ms": 0, "by_language": {} + } + } + + +@router.get("/runtimes") +async def get_runtimes(_=Depends(get_current_admin)): + """Get sandbox runtime info.""" + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(f"{EXECUTOR_URL}/info") + if resp.status_code == 200: + return {"code": 0, "data": resp.json()} + except Exception as e: + pass + + return { + "code": 0, + "data": { + "python": "Python 3.11 (sandbox)", + "nodejs": "Node.js (sandbox)", + "workspace": "/workspace", + "max_code_length": 51200, + "max_timeout": 120, + } + } + + +@router.post("/execute", response_model=ExecuteResponse) +async def execute_code(req: ExecuteRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)): + """Submit code for execution.""" + if req.language not in SUPPORTED_LANGUAGES: + raise HTTPException(status_code=400, detail=f"Unsupported language: {req.language}") + + task_id = str(uuid.uuid4())[:8] + now = _now_cst() + + # Create task record + _execute_raw(db, """ + INSERT INTO code_exec_tasks (id, language, code_preview, status, timeout_sec, created_at) + VALUES (:id, :lang, :preview, 'running', :timeout, :now) + """, { + "id": task_id, + "lang": req.language, + "preview": req.code[:200], + "timeout": req.timeout, + "now": now, + }) + + # Execute via sandbox + result = { + "success": False, + "stdout": "", + "stderr": "", + "exit_code": -1, + "execution_time_ms": 0, + "error": None, + } + + try: + async with httpx.AsyncClient(timeout=req.timeout + 10) as client: + resp = await client.post( + f"{EXECUTOR_URL}/execute", + json={ + "language": req.language, + "code": req.code, + "stdin": req.stdin, + "timeout": req.timeout, + }, + ) + if resp.status_code == 200: + data = resp.json() + result = { + "success": data.get("success", False), + "stdout": data.get("stdout", ""), + "stderr": data.get("stderr", ""), + "exit_code": data.get("exit_code", -1), + "execution_time_ms": data.get("execution_time_ms", 0), + "error": data.get("error"), + "truncated": data.get("truncated", False), + } + else: + result["error"] = f"Sandbox returned {resp.status_code}: {resp.text[:200]}" + except httpx.TimeoutException: + result["error"] = f"Execution timed out after {req.timeout}s" + except httpx.ConnectError: + result["error"] = "Execution sandbox is not available" + except Exception as e: + result["error"] = str(e)[:500] + + # Determine status + if result["error"]: + status = "failed" + elif result["exit_code"] != 0: + status = "error" + else: + status = "completed" + + # Update task record + now2 = _now_cst() + _execute_raw(db, """ + UPDATE code_exec_tasks SET + status = :status, exit_code = :exit_code, execution_time_ms = :time_ms, + stdout = :stdout, stderr = :stderr, error_msg = :error, + truncated = :truncated, completed_at = :now + WHERE id = :id + """, { + "status": status, + "exit_code": result["exit_code"], + "time_ms": result["execution_time_ms"], + "stdout": result["stdout"][:50000] if result.get("stdout") else "", + "stderr": result["stderr"][:50000] if result.get("stderr") else "", + "error": result["error"][:2000] if result.get("error") else None, + "truncated": 1 if result.get("truncated") else 0, + "now": now2, + "id": task_id, + }) + + return ExecuteResponse( + task_id=task_id, + success=result["success"], + stdout=result.get("stdout", ""), + stderr=result.get("stderr", ""), + exit_code=result["exit_code"], + execution_time_ms=result["execution_time_ms"], + error=result.get("error"), + truncated=result.get("truncated", False), + created_at=_format_cst_time(now), + ) + + +@router.get("/tasks") +async def list_tasks( + language: Optional[str] = Query(None), + status: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + _=Depends(get_current_admin), +): + """List execution tasks with filters.""" + where_clauses = [] + params = {} + + if language: + where_clauses.append("language = :lang") + params["lang"] = language + if status: + where_clauses.append("status = :status") + params["status"] = status + + where_sql = "" + if where_clauses: + where_sql = "WHERE " + " AND ".join(where_clauses) + + # Count total + count_result = _execute_raw(db, f"SELECT COUNT(*) as cnt FROM code_exec_tasks {where_sql}", params) + total = count_result[0]["cnt"] if count_result else 0 + + # Fetch page + offset = (page - 1) * page_size + tasks = _execute_raw(db, f""" + SELECT id, language, code_preview, status, exit_code, execution_time_ms, + error_msg, truncated, created_at, completed_at + FROM code_exec_tasks {where_sql} + ORDER BY created_at DESC + LIMIT :limit OFFSET :offset + """, {**params, "limit": page_size, "offset": offset}) + + items = [] + for t in (tasks or []): + items.append({ + "id": t["id"], + "language": t["language"], + "code_preview": t.get("code_preview", ""), + "status": t["status"], + "exit_code": t.get("exit_code"), + "execution_time_ms": t.get("execution_time_ms"), + "error_msg": t.get("error_msg"), + "truncated": bool(t.get("truncated", 0)), + "created_at": _format_cst_time(t.get("created_at")), + "completed_at": _format_cst_time(t.get("completed_at")), + }) + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": items, + } + } + + +@router.get("/tasks/{task_id}") +async def get_task(task_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)): + """Get a specific task with full output.""" + results = _execute_raw(db, """ + SELECT id, language, code_preview, status, exit_code, execution_time_ms, + stdout, stderr, error_msg, truncated, created_at, completed_at + FROM code_exec_tasks WHERE id = :id + """, {"id": task_id}) + + if not results: + raise HTTPException(status_code=404, detail="Task not found") + + t = results[0] + return { + "code": 0, + "data": { + "id": t["id"], + "language": t["language"], + "code_preview": t.get("code_preview", ""), + "status": t["status"], + "exit_code": t.get("exit_code"), + "execution_time_ms": t.get("execution_time_ms"), + "stdout": t.get("stdout", ""), + "stderr": t.get("stderr", ""), + "error_msg": t.get("error_msg"), + "truncated": bool(t.get("truncated", 0)), + "created_at": _format_cst_time(t.get("created_at")), + "completed_at": _format_cst_time(t.get("completed_at")), + } + } + + +@router.delete("/tasks/{task_id}") +async def delete_task(task_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)): + """Delete a task record.""" + result = _execute_raw(db, "DELETE FROM code_exec_tasks WHERE id = :id", {"id": task_id}) + return {"code": 0, "msg": "deleted"} diff --git a/backend/python/app/api/admin/developer_admin.py b/backend/python/app/api/admin/developer_admin.py new file mode 100644 index 0000000..776dd71 --- /dev/null +++ b/backend/python/app/api/admin/developer_admin.py @@ -0,0 +1,658 @@ +""" +Developer API Management - Admin endpoints for API Keys, OpenAPI Clients, +API Call Statistics, and Slow Request Logs. +""" +import secrets +import hashlib +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import func, desc, asc +from sqlalchemy.orm import Session + +from ...database import get_db +from ...models import ( + ApiKey, OpenApiClient, ApiCallStat, SlowRequestLog, User +) + +router = APIRouter() + +CST_TZ = timezone(timedelta(hours=8)) + + +def cst_now(): + return datetime.now(CST_TZ) + + +def fmt_time(dt): + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=CST_TZ).isoformat() + return dt.isoformat() + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class ApiKeyCreate(BaseModel): + user_id: str + name: str = "" + description: str = "" + rate_limit: int = 60 + expired_at: str = None + + +class ApiKeyUpdate(BaseModel): + name: str = None + description: str = None + rate_limit: int = None + status: str = None + expired_at: str = None + + +class OpenAPIClientCreate(BaseModel): + user_id: str + name: str + allowed_models: list = None + rate_limit: int = 60 + + +class OpenAPIClientUpdate(BaseModel): + name: str = None + allowed_models: list = None + rate_limit: int = None + status: str = None + + +# ============================================================ +# Helper functions +# ============================================================ + +def _generate_api_key(): + """Generate a unique API key string.""" + return "sk-" + secrets.token_hex(24) + + +def _generate_secret(): + """Generate a secret string.""" + return secrets.token_hex(32) + + +def _generate_client_id(): + """Generate a unique client ID.""" + return "cli_" + secrets.token_hex(12) + + +def _generate_client_secret(): + """Generate a client secret.""" + return secrets.token_hex(32) + + +def _serialize_api_key(key, user=None): + return { + "id": key.id, + "user_id": key.user_id, + "username": user.username if user else None, + "api_key": key.api_key, + "name": key.name, + "description": getattr(key, "description", "") or "", + "rate_limit": getattr(key, "rate_limit", 60) or 60, + "total_calls": getattr(key, "total_calls", 0) or 0, + "status": key.status, + "last_used_at": fmt_time(key.last_used_at), + "created_at": fmt_time(key.created_at), + "expired_at": fmt_time(key.expired_at), + } + + +def _serialize_client(client, user=None): + return { + "id": client.id, + "user_id": client.user_id, + "username": user.username if user else None, + "name": client.name, + "client_id": client.client_id, + "allowed_models": client.allowed_models or [], + "rate_limit": client.rate_limit, + "total_calls": getattr(client, "total_calls", 0) or 0, + "last_used_at": fmt_time(getattr(client, "last_used_at", None)), + "status": client.status, + "created_at": fmt_time(client.created_at), + } + + +def _serialize_call_stat(stat): + avg_time = 0.0 + if stat.call_count and stat.call_count > 0: + avg_time = round(stat.total_response_time / stat.call_count, 3) + error_rate = 0.0 + if stat.call_count and stat.call_count > 0: + error_rate = round(stat.error_count / stat.call_count * 100, 2) + return { + "id": stat.id, + "endpoint": stat.endpoint, + "method": stat.method, + "call_count": stat.call_count, + "error_count": stat.error_count, + "error_rate": error_rate, + "avg_response_time": avg_time, + "total_response_time": round(stat.total_response_time, 3), + "last_called_at": fmt_time(stat.last_called_at), + "created_at": fmt_time(stat.created_at), + "updated_at": fmt_time(stat.updated_at), + } + + +def _serialize_slow_log(log): + return { + "id": log.id, + "request_id": log.request_id, + "method": log.method, + "endpoint": log.endpoint, + "status_code": log.status_code, + "response_time": round(log.response_time, 3), + "threshold": round(log.threshold, 2), + "created_at": fmt_time(log.created_at), + } + + +# ============================================================ +# Stats Overview +# ============================================================ + +@router.get("/stats/overview") +async def get_stats_overview(db: Session = Depends(get_db)): + """Get developer API dashboard statistics.""" + now = cst_now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + today_start_naive = today_start.replace(tzinfo=None) + + # API Key stats + total_keys = db.query(func.count(ApiKey.id)).scalar() or 0 + active_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "active").scalar() or 0 + revoked_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "revoked").scalar() or 0 + + # OpenAPI Client stats + total_clients = db.query(func.count(OpenApiClient.id)).scalar() or 0 + active_clients = db.query(func.count(OpenApiClient.id)).filter(OpenApiClient.status == "active").scalar() or 0 + + # Call stats + total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0 + total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0 + total_response_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0 + avg_response_time = round(total_response_time / total_calls, 3) if total_calls > 0 else 0.0 + error_rate = round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0 + + # Today's calls (approximate by last_called_at) + today_calls = db.query(func.sum(ApiCallStat.call_count)).filter( + ApiCallStat.last_called_at >= today_start_naive + ).scalar() or 0 + + # Slow request count + slow_log_count = db.query(func.count(SlowRequestLog.id)).scalar() or 0 + today_slow = db.query(func.count(SlowRequestLog.id)).filter( + SlowRequestLog.created_at >= today_start_naive + ).scalar() or 0 + + # Top endpoints by call count + top_endpoints = db.query(ApiCallStat).order_by(desc(ApiCallStat.call_count)).limit(5).all() + top_endpoints_data = [ + { + "endpoint": s.endpoint, + "method": s.method, + "call_count": s.call_count, + "error_count": s.error_count, + } + for s in top_endpoints + ] + + return { + "code": 0, + "data": { + "api_keys": { + "total": total_keys, + "active": active_keys, + "revoked": revoked_keys, + }, + "openapi_clients": { + "total": total_clients, + "active": active_clients, + }, + "call_stats": { + "total_calls": total_calls, + "today_calls": today_calls, + "total_errors": total_errors, + "error_rate": error_rate, + "avg_response_time": avg_response_time, + }, + "slow_logs": { + "total": slow_log_count, + "today": today_slow, + }, + "top_endpoints": top_endpoints_data, + "server_time": cst_now().isoformat(), + } + } + + +# ============================================================ +# API Key Management +# ============================================================ + +@router.get("/api-keys") +async def list_api_keys( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: str = Query(None), + keyword: str = Query(None), + db: Session = Depends(get_db), +): + """List all API keys with pagination.""" + query = db.query(ApiKey, User).outerjoin(User, ApiKey.user_id == User.id) + + if status: + query = query.filter(ApiKey.status == status) + if keyword: + query = query.filter( + (ApiKey.name.ilike(f"%{keyword}%")) | + (ApiKey.api_key.ilike(f"%{keyword}%")) | + (User.username.ilike(f"%{keyword}%")) + ) + + total = query.count() + items = query.order_by(desc(ApiKey.created_at)).offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [_serialize_api_key(k, u) for k, u in items], + } + } + + +@router.post("/api-keys") +async def create_api_key(body: ApiKeyCreate, db: Session = Depends(get_db)): + """Create a new API key.""" + # Verify user exists + user = db.query(User).filter(User.id == body.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Check if model has description/rate_limit/total_calls columns + new_key = ApiKey( + user_id=body.user_id, + api_key=_generate_api_key(), + secret=_generate_secret(), + name=body.name, + status="active", + created_at=datetime.now(), + ) + + # Set optional fields if they exist on the model + try: + new_key.description = body.description + except Exception: + pass + try: + new_key.rate_limit = body.rate_limit + except Exception: + pass + try: + new_key.total_calls = 0 + except Exception: + pass + + if body.expired_at: + try: + new_key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", "")) + except Exception: + pass + + db.add(new_key) + db.commit() + db.refresh(new_key) + + return { + "code": 0, + "data": _serialize_api_key(new_key, user), + "message": "API Key created successfully", + } + + +@router.put("/api-keys/{key_id}") +async def update_api_key(key_id: str, body: ApiKeyUpdate, db: Session = Depends(get_db)): + """Update an API key.""" + key = db.query(ApiKey).filter(ApiKey.id == key_id).first() + if not key: + raise HTTPException(status_code=404, detail="API Key not found") + + if body.name is not None: + key.name = body.name + if body.status is not None: + key.status = body.status + if body.rate_limit is not None: + try: + key.rate_limit = body.rate_limit + except Exception: + pass + if body.description is not None: + try: + key.description = body.description + except Exception: + pass + if body.expired_at is not None: + try: + key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", "")) + except Exception: + pass + + db.commit() + db.refresh(key) + + user = db.query(User).filter(User.id == key.user_id).first() + return {"code": 0, "data": _serialize_api_key(key, user), "message": "Updated"} + + +@router.post("/api-keys/{key_id}/revoke") +async def revoke_api_key(key_id: str, db: Session = Depends(get_db)): + """Revoke an API key.""" + key = db.query(ApiKey).filter(ApiKey.id == key_id).first() + if not key: + raise HTTPException(status_code=404, detail="API Key not found") + + key.status = "revoked" + db.commit() + + return {"code": 0, "message": "API Key revoked"} + + +@router.delete("/api-keys/{key_id}") +async def delete_api_key(key_id: str, db: Session = Depends(get_db)): + """Delete an API key permanently.""" + key = db.query(ApiKey).filter(ApiKey.id == key_id).first() + if not key: + raise HTTPException(status_code=404, detail="API Key not found") + + db.delete(key) + db.commit() + + return {"code": 0, "message": "API Key deleted"} + + +# ============================================================ +# OpenAPI Client Management +# ============================================================ + +@router.get("/openapi-clients") +async def list_openapi_clients( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: str = Query(None), + keyword: str = Query(None), + db: Session = Depends(get_db), +): + """List all OpenAPI clients.""" + query = db.query(OpenApiClient, User).outerjoin(User, OpenApiClient.user_id == User.id) + + if status: + query = query.filter(OpenApiClient.status == status) + if keyword: + query = query.filter( + (OpenApiClient.name.ilike(f"%{keyword}%")) | + (OpenApiClient.client_id.ilike(f"%{keyword}%")) | + (User.username.ilike(f"%{keyword}%")) + ) + + total = query.count() + items = query.order_by(desc(OpenApiClient.created_at)).offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [_serialize_client(c, u) for c, u in items], + } + } + + +@router.post("/openapi-clients") +async def create_openapi_client(body: OpenAPIClientCreate, db: Session = Depends(get_db)): + """Create a new OpenAPI client.""" + user = db.query(User).filter(User.id == body.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + new_client = OpenApiClient( + user_id=body.user_id, + name=body.name, + client_id=_generate_client_id(), + client_secret=_generate_client_secret(), + allowed_models=body.allowed_models, + rate_limit=body.rate_limit, + status="active", + created_at=datetime.now(), + ) + + try: + new_client.total_calls = 0 + except Exception: + pass + try: + new_client.last_used_at = None + except Exception: + pass + + db.add(new_client) + db.commit() + db.refresh(new_client) + + result = _serialize_client(new_client, user) + result["client_secret"] = new_client.client_secret # Only show on create + + return {"code": 0, "data": result, "message": "OpenAPI Client created"} + + +@router.put("/openapi-clients/{client_id}") +async def update_openapi_client(client_id: str, body: OpenAPIClientUpdate, db: Session = Depends(get_db)): + """Update an OpenAPI client.""" + client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first() + if not client: + raise HTTPException(status_code=404, detail="OpenAPI Client not found") + + if body.name is not None: + client.name = body.name + if body.allowed_models is not None: + client.allowed_models = body.allowed_models + if body.rate_limit is not None: + client.rate_limit = body.rate_limit + if body.status is not None: + client.status = body.status + + db.commit() + db.refresh(client) + + user = db.query(User).filter(User.id == client.user_id).first() + return {"code": 0, "data": _serialize_client(client, user), "message": "Updated"} + + +@router.post("/openapi-clients/{client_id}/reset-secret") +async def reset_client_secret(client_id: str, db: Session = Depends(get_db)): + """Reset the client secret.""" + client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first() + if not client: + raise HTTPException(status_code=404, detail="OpenAPI Client not found") + + client.client_secret = _generate_client_secret() + db.commit() + db.refresh(client) + + return {"code": 0, "data": {"client_secret": client.client_secret}, "message": "Secret reset"} + + +@router.delete("/openapi-clients/{client_id}") +async def delete_openapi_client(client_id: str, db: Session = Depends(get_db)): + """Delete an OpenAPI client.""" + client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first() + if not client: + raise HTTPException(status_code=404, detail="OpenAPI Client not found") + + db.delete(client) + db.commit() + + return {"code": 0, "message": "OpenAPI Client deleted"} + + +# ============================================================ +# API Call Statistics +# ============================================================ + +@router.get("/call-stats") +async def list_call_stats( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + sort_by: str = Query("call_count"), + sort_order: str = Query("desc"), + keyword: str = Query(None), + db: Session = Depends(get_db), +): + """List API call statistics.""" + query = db.query(ApiCallStat) + + if keyword: + query = query.filter(ApiCallStat.endpoint.ilike(f"%{keyword}%")) + + sort_column = getattr(ApiCallStat, sort_by, ApiCallStat.call_count) + if sort_order == "asc": + query = query.order_by(asc(sort_column)) + else: + query = query.order_by(desc(sort_column)) + + total = query.count() + items = query.offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [_serialize_call_stat(s) for s in items], + } + } + + +@router.get("/call-stats/overview") +async def call_stats_overview(db: Session = Depends(get_db)): + """Get call stats summary.""" + total_endpoints = db.query(func.count(ApiCallStat.id)).scalar() or 0 + total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0 + total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0 + avg_time = 0.0 + if total_calls and total_calls > 0: + total_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0 + avg_time = round(total_time / total_calls, 3) + + # Method distribution + method_stats = db.query( + ApiCallStat.method, + func.sum(ApiCallStat.call_count).label("count") + ).group_by(ApiCallStat.method).all() + + return { + "code": 0, + "data": { + "total_endpoints": total_endpoints, + "total_calls": total_calls, + "total_errors": total_errors, + "error_rate": round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0, + "avg_response_time": avg_time, + "method_distribution": [ + {"method": m, "count": c} for m, c in method_stats + ], + } + } + + +# ============================================================ +# Slow Request Logs +# ============================================================ + +@router.get("/slow-logs") +async def list_slow_logs( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query(None), + min_time: float = Query(None), + db: Session = Depends(get_db), +): + """List slow request logs.""" + query = db.query(SlowRequestLog) + + if keyword: + query = query.filter(SlowRequestLog.endpoint.ilike(f"%{keyword}%")) + if min_time is not None: + query = query.filter(SlowRequestLog.response_time >= min_time) + + total = query.count() + items = query.order_by(desc(SlowRequestLog.created_at)).offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [_serialize_slow_log(l) for l in items], + } + } + + +@router.delete("/slow-logs") +async def clear_slow_logs(db: Session = Depends(get_db)): + """Clear all slow request logs.""" + deleted = db.query(SlowRequestLog).delete() + db.commit() + + return {"code": 0, "message": f"Cleared {deleted} slow request logs"} + + +@router.get("/slow-logs/stats") +async def slow_logs_stats(db: Session = Depends(get_db)): + """Get slow request log statistics.""" + now = cst_now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + + total = db.query(func.count(SlowRequestLog.id)).scalar() or 0 + today = db.query(func.count(SlowRequestLog.id)).filter( + SlowRequestLog.created_at >= today_start + ).scalar() or 0 + + avg_time = db.query(func.avg(SlowRequestLog.response_time)).scalar() or 0.0 + max_time = db.query(func.max(SlowRequestLog.response_time)).scalar() or 0.0 + + # Distribution by status code + status_dist = db.query( + SlowRequestLog.status_code, + func.count(SlowRequestLog.id).label("count") + ).group_by(SlowRequestLog.status_code).all() + + return { + "code": 0, + "data": { + "total": total, + "today": today, + "avg_response_time": round(float(avg_time), 3), + "max_response_time": round(float(max_time), 3), + "status_distribution": [ + {"status_code": s, "count": c} for s, c in status_dist + ], + } + } diff --git a/backend/python/app/api/admin/developer_tools_admin.py b/backend/python/app/api/admin/developer_tools_admin.py new file mode 100644 index 0000000..29ad7ab --- /dev/null +++ b/backend/python/app/api/admin/developer_tools_admin.py @@ -0,0 +1,899 @@ +""" +Developer Tools Enhancement - Webhooks, SDK Center, API Docs, Usage Dashboard. +Phase 3.9: Extends the developer module with webhook management, +SDK download center, interactive API documentation, and enhanced analytics. +""" +import json +import secrets +import hashlib +import hmac +import urllib.request +import urllib.error +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import text +from sqlalchemy.orm import Session + +from ...database import get_db, SessionLocal +from ...auth import get_current_admin + +router = APIRouter() + +CST_TZ = timezone(timedelta(hours=8)) + + +def cst_now(): + return datetime.now(CST_TZ) + + +def fmt_time(dt): + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=CST_TZ).isoformat() + return dt.isoformat() + + +def _execute_raw(db, sql, params=None): + """Execute raw SQL and return list of dicts.""" + result = db.execute(text(sql), params or {}) + rows = [] + for row in result: + rows.append(dict(row._mapping)) + return rows + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class WebhookCreate(BaseModel): + name: str + url: str + events: list = [] + secret: str = "" + headers: dict = {} + + +class WebhookUpdate(BaseModel): + name: str = None + url: str = None + events: list = None + secret: str = None + headers: dict = None + status: str = None + + +class WebhookTest(BaseModel): + event: str = "test" + payload: dict = {"message": "Test webhook delivery"} + + +# ============================================================ +# Webhook Management +# ============================================================ + +SUPPORTED_EVENTS = [ + {"value": "app.created", "label": "App Created", "desc": "Triggered when a new AI app is created"}, + {"value": "app.published", "label": "App Published", "desc": "Triggered when an app is published to market"}, + {"value": "agent.created", "label": "Agent Created", "desc": "Triggered when a new agent is created"}, + {"value": "knowledge.updated", "label": "Knowledge Updated", "desc": "Triggered when knowledge base is updated"}, + {"value": "api_key.created", "label": "API Key Created", "desc": "Triggered when a new API key is generated"}, + {"value": "api_key.revoked", "label": "API Key Revoked", "desc": "Triggered when an API key is revoked"}, + {"value": "user.registered", "label": "User Registered", "desc": "Triggered when a new user registers"}, + {"value": "model.connected", "label": "Model Connected", "desc": "Triggered when a model connection test passes"}, + {"value": "workflow.completed", "label": "Workflow Completed", "desc": "Triggered when a workflow execution finishes"}, + {"value": "storage.threshold", "label": "Storage Threshold", "desc": "Triggered when storage usage exceeds threshold"}, +] + + +@router.get("/webhooks", summary="List webhooks") +async def list_webhooks( + status: str = Query(None), + 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), +): + """List all webhook configurations.""" + where_clause = "WHERE 1=1" + params = {} + if status: + where_clause += " AND status = :status" + params["status"] = status + + count_sql = f"SELECT COUNT(*) as cnt FROM webhook_configs {where_clause}" + count_rows = _execute_raw(db, count_sql, params) + total = count_rows[0]["cnt"] if count_rows else 0 + + offset = (page - 1) * page_size + list_sql = f"""SELECT id, name, url, events, secret, headers, status, + last_triggered_at, last_status, total_deliveries, success_count, fail_count, + created_at, updated_at + FROM webhook_configs {where_clause} + ORDER BY created_at DESC LIMIT :limit OFFSET :offset""" + list_params = dict(params) + list_params["limit"] = page_size + list_params["offset"] = offset + rows = _execute_raw(db, list_sql, list_params) + + items = [] + for r in rows: + events_raw = r.get("events") + if isinstance(events_raw, str): + try: + events_val = json.loads(events_raw) + except Exception: + events_val = [] + else: + events_val = events_raw or [] + + headers_raw = r.get("headers") + if isinstance(headers_raw, str): + try: + headers_val = json.loads(headers_raw) + except Exception: + headers_val = {} + else: + headers_val = headers_raw or {} + + success_rate = 0 + if r.get("total_deliveries", 0) > 0: + success_rate = round(r["success_count"] / r["total_deliveries"] * 100, 1) + + items.append({ + "id": r["id"], + "name": r["name"], + "url": r["url"], + "events": events_val, + "has_secret": bool(r.get("secret")), + "headers": headers_val, + "status": r["status"], + "last_triggered_at": fmt_time(r.get("last_triggered_at")), + "last_status": r.get("last_status"), + "total_deliveries": r.get("total_deliveries", 0), + "success_count": r.get("success_count", 0), + "fail_count": r.get("fail_count", 0), + "success_rate": success_rate, + "created_at": fmt_time(r.get("created_at")), + "updated_at": fmt_time(r.get("updated_at")), + }) + + return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}} + + +@router.get("/webhooks/events", summary="List supported webhook events") +async def list_webhook_events( + admin_user=Depends(get_current_admin), +): + """List all supported webhook event types.""" + return {"code": 0, "data": {"events": SUPPORTED_EVENTS}} + + +@router.post("/webhooks/", summary="Create webhook") +async def create_webhook( + body: WebhookCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """Create a new webhook configuration.""" + webhook_id = "wh_" + secrets.token_hex(12) + now = cst_now() + + secret_val = body.secret if body.secret else "whsec_" + secrets.token_hex(16) + + insert_sql = """INSERT INTO webhook_configs + (id, name, url, events, secret, headers, status, last_triggered_at, + last_status, total_deliveries, success_count, fail_count, created_at, updated_at) + VALUES (:id, :name, :url, :events, :secret, :headers, 'active', + NULL, NULL, 0, 0, 0, :now, :now)""" + params = { + "id": webhook_id, + "name": body.name, + "url": body.url, + "events": json.dumps(body.events), + "secret": secret_val, + "headers": json.dumps(body.headers), + "now": now, + } + db.execute(text(insert_sql), params) + db.commit() + + return {"code": 0, "data": {"id": webhook_id, "name": body.name, "secret": secret_val, "message": "Webhook created"}} + + +@router.put("/webhooks/{webhook_id}", summary="Update webhook") +async def update_webhook( + webhook_id: str, + body: WebhookUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """Update an existing webhook configuration.""" + check_sql = "SELECT id FROM webhook_configs WHERE id = :id" + rows = _execute_raw(db, check_sql, {"id": webhook_id}) + if not rows: + raise HTTPException(status_code=404, detail="Webhook not found") + + updates = [] + params = {"id": webhook_id, "now": cst_now()} + if body.name is not None: + updates.append("name = :name") + params["name"] = body.name + if body.url is not None: + updates.append("url = :url") + params["url"] = body.url + if body.events is not None: + updates.append("events = :events") + params["events"] = json.dumps(body.events) + if body.secret is not None: + updates.append("secret = :secret") + params["secret"] = body.secret + if body.headers is not None: + updates.append("headers = :headers") + params["headers"] = json.dumps(body.headers) + if body.status is not None: + updates.append("status = :status") + params["status"] = body.status + + if not updates: + return {"code": 0, "message": "No changes"} + + updates.append("updated_at = :now") + update_sql = f"UPDATE webhook_configs SET {', '.join(updates)} WHERE id = :id" + db.execute(text(update_sql), params) + db.commit() + + return {"code": 0, "message": "Webhook updated"} + + +@router.delete("/webhooks/{webhook_id}", summary="Delete webhook") +async def delete_webhook( + webhook_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """Delete a webhook configuration.""" + check_sql = "SELECT id FROM webhook_configs WHERE id = :id" + rows = _execute_raw(db, check_sql, {"id": webhook_id}) + if not rows: + raise HTTPException(status_code=404, detail="Webhook not found") + + db.execute(text("DELETE FROM webhook_configs WHERE id = :id"), {"id": webhook_id}) + db.commit() + + return {"code": 0, "message": "Webhook deleted"} + + +@router.post("/webhooks/{webhook_id}/test", summary="Test webhook delivery") +async def test_webhook( + webhook_id: str, + body: WebhookTest = None, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """Send a test payload to the webhook endpoint.""" + select_sql = "SELECT * FROM webhook_configs WHERE id = :id" + rows = _execute_raw(db, select_sql, {"id": webhook_id}) + if not rows: + raise HTTPException(status_code=404, detail="Webhook not found") + + wh = rows[0] + url = wh["url"] + secret = wh.get("secret", "") + + test_event = body.event if body else "test" + test_payload = body.payload if body else {"message": "Test webhook delivery"} + full_payload = { + "event": test_event, + "timestamp": cst_now().isoformat(), + "webhook_id": webhook_id, + "data": test_payload, + } + payload_bytes = json.dumps(full_payload).encode("utf-8") + + # Build headers + headers = {"Content-Type": "application/json"} + headers_raw = wh.get("headers") + if isinstance(headers_raw, str): + try: + headers.update(json.loads(headers_raw)) + except Exception: + pass + elif isinstance(headers_raw, dict): + headers.update(headers_raw) + + # HMAC signature + if secret: + signature = hmac.new(secret.encode("utf-8"), payload_bytes, hashlib.sha256).hexdigest() + headers["X-Webhook-Signature"] = signature + + # Send request + delivery_id = "dlv_" + secrets.token_hex(12) + now = cst_now() + response_code = None + response_body = None + response_time_ms = None + status = "failed" + error_message = None + + try: + req = urllib.request.Request(url, data=payload_bytes, headers=headers, method="POST") + start_time = datetime.now() + resp = urllib.request.urlopen(req, timeout=10) + end_time = datetime.now() + response_code = resp.getcode() + response_body = resp.read().decode("utf-8", errors="replace")[:2000] + response_time_ms = int((end_time - start_time).total_seconds() * 1000) + if 200 <= response_code < 300: + status = "success" + except urllib.error.HTTPError as e: + response_code = e.code + try: + response_body = e.read().decode("utf-8", errors="replace")[:2000] + except Exception: + response_body = str(e) + error_message = f"HTTP {e.code}: {str(e)[:200]}" + except Exception as e: + error_message = str(e)[:500] + + # Record delivery + insert_sql = """INSERT INTO webhook_deliveries + (id, webhook_id, event, payload, status, response_code, response_body, + response_time_ms, attempts, error_message, created_at) + VALUES (:id, :wid, :event, :payload, :status, :rcode, :rbody, + :rtime, 1, :err, :now)""" + db.execute(text(insert_sql), { + "id": delivery_id, "wid": webhook_id, "event": test_event, + "payload": json.dumps(full_payload), "status": status, + "rcode": response_code, "rbody": response_body, + "rtime": response_time_ms, "err": error_message, "now": now, + }) + + # Update webhook stats + total_deliveries = (wh.get("total_deliveries") or 0) + 1 + success_count = (wh.get("success_count") or 0) + (1 if status == "success" else 0) + fail_count = (wh.get("fail_count") or 0) + (1 if status != "success" else 0) + db.execute(text("""UPDATE webhook_configs SET + total_deliveries = :td, success_count = :sc, fail_count = :fc, + last_triggered_at = :now, last_status = :ls, updated_at = :now + WHERE id = :id"""), { + "td": total_deliveries, "sc": success_count, "fc": fail_count, + "now": now, "ls": status, "id": webhook_id, + }) + db.commit() + + return { + "code": 0, + "data": { + "delivery_id": delivery_id, + "status": status, + "response_code": response_code, + "response_time_ms": response_time_ms, + "response_body": response_body[:500] if response_body else None, + "error_message": error_message, + } + } + + +@router.get("/webhooks/{webhook_id}/deliveries", summary="Webhook delivery history") +async def webhook_deliveries( + webhook_id: str, + 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), +): + """Get delivery history for a specific webhook.""" + count_sql = "SELECT COUNT(*) as cnt FROM webhook_deliveries WHERE webhook_id = :wid" + count_rows = _execute_raw(db, count_sql, {"wid": webhook_id}) + total = count_rows[0]["cnt"] if count_rows else 0 + + offset = (page - 1) * page_size + list_sql = """SELECT id, webhook_id, event, payload, status, response_code, + response_body, response_time_ms, attempts, error_message, created_at + FROM webhook_deliveries WHERE webhook_id = :wid + ORDER BY created_at DESC LIMIT :limit OFFSET :offset""" + rows = _execute_raw(db, list_sql, {"wid": webhook_id, "limit": page_size, "offset": offset}) + + items = [] + for r in rows: + payload_raw = r.get("payload") + if isinstance(payload_raw, str): + try: + payload_val = json.loads(payload_raw) + except Exception: + payload_val = payload_raw + else: + payload_val = payload_raw + + items.append({ + "id": r["id"], + "event": r["event"], + "status": r["status"], + "response_code": r.get("response_code"), + "response_time_ms": r.get("response_time_ms"), + "attempts": r.get("attempts", 0), + "error_message": r.get("error_message"), + "payload": payload_val, + "response_body": (r.get("response_body") or "")[:500], + "created_at": fmt_time(r.get("created_at")), + }) + + return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}} + + +# ============================================================ +# SDK Download Center +# ============================================================ + +SDK_PACKAGES = [ + { + "language": "python", + "name": "Python SDK", + "version": "1.2.0", + "description": "Official Python SDK for Qihu-AIFlow platform. Supports chat completions, knowledge base, agent execution, and file upload.", + "install_cmd": "pip install qihu-aiflow", + "download_url": "https://pypi.org/project/qihu-aiflow/", + "github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-python", + "min_version": "Python 3.8+", + "features": ["Chat completions (streaming)", "Knowledge base queries", "Agent execution", "File upload", "Webhook verification", "Async support"], + "code_example": '''from qihu_aiflow import QihuClient + +client = QihuClient(api_key="sk-your-api-key", base_url="http://210.16.161.218:18080") + +# Chat completion +response = client.chat.create( + model="deepseek-ai/DeepSeek-V4-Flash", + messages=[{"role": "user", "content": "Hello!"}] +) +print(response.choices[0].message.content) + +# Streaming +for chunk in client.chat.create( + model="deepseek-ai/DeepSeek-V4-Flash", + messages=[{"role": "user", "content": "Tell me a story"}], + stream=True +): + print(chunk.choices[0].delta.content, end="")''', + }, + { + "language": "javascript", + "name": "JavaScript SDK", + "version": "1.1.0", + "description": "Official JavaScript/TypeScript SDK for browser and Node.js. Full TypeScript support with type definitions.", + "install_cmd": "npm install @qihu/aiflow", + "download_url": "https://www.npmjs.com/package/@qihu/aiflow", + "github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-js", + "min_version": "Node.js 16+ / Browser", + "features": ["Chat completions (streaming)", "Knowledge base queries", "Agent execution", "File upload", "WebSocket support", "TypeScript types"], + "code_example": '''import { QihuClient } from "@qihu/aiflow"; + +const client = new QihuClient({ + apiKey: "sk-your-api-key", + baseUrl: "http://210.16.161.218:18080", +}); + +// Chat completion +const response = await client.chat.create({ + model: "deepseek-ai/DeepSeek-V4-Flash", + messages: [{ role: "user", content: "Hello!" }], +}); +console.log(response.choices[0].message.content); + +// Streaming +const stream = await client.chat.create({ + model: "deepseek-ai/DeepSeek-V4-Flash", + messages: [{ role: "user", content: "Tell me a story" }], + stream: true, +}); +for await (const chunk of stream) { + process.stdout.write(chunk.choices[0].delta.content || ""); +}''', + }, + { + "language": "go", + "name": "Go SDK", + "version": "1.0.0", + "description": "Official Go SDK for Qihu-AIFlow. Lightweight and fast, suitable for microservices and CLI tools.", + "install_cmd": "go get github.com/qihuyun/qihu-aiflow-sdk-go", + "download_url": "https://github.com/qihuyun/qihu-aiflow-sdk-go/releases", + "github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-go", + "min_version": "Go 1.19+", + "features": ["Chat completions", "Knowledge base queries", "Agent execution", "Context cancellation", "Retry logic", "Connection pooling"], + "code_example": '''package main + +import ( + "context" + "fmt" + "github.com/qihuyun/qihu-aiflow-sdk-go/qihu" +) + +func main() { + client := qihu.NewClient("sk-your-api-key", "http://210.16.161.218:18080") + + resp, err := client.Chat.Create(context.Background(), &qihu.ChatRequest{ + Model: "deepseek-ai/DeepSeek-V4-Flash", + Messages: []qihu.Message{ + {Role: "user", Content: "Hello!"}, + }, + }) + if err != nil { + panic(err) + } + fmt.Println(resp.Choices[0].Message.Content) +}''', + }, + { + "language": "curl", + "name": "REST API (cURL)", + "version": "1.0.0", + "description": "Direct REST API access without SDK. Use any HTTP client to interact with the platform.", + "install_cmd": "# No installation needed - use curl directly", + "download_url": "https://docs.qihuyun.com/api-reference", + "github_url": "", + "min_version": "Any HTTP client", + "features": ["Chat completions", "Knowledge base queries", "Agent execution", "File upload", "Webhook management", "No dependencies"], + "code_example": '''# Chat completion +curl -X POST http://210.16.161.218:18080/app/api/chat/completions \\ + -H "Authorization: Bearer sk-your-api-key" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "deepseek-ai/DeepSeek-V4-Flash", + "messages": [{"role": "user", "content": "Hello!"}] + }' + +# List knowledge bases +curl http://210.16.161.218:18080/app/api/knowledge-bases \\ + -H "Authorization: Bearer sk-your-api-key"''', + }, +] + + +@router.get("/sdk/packages", summary="List SDK packages") +async def list_sdk_packages( + admin_user=Depends(get_current_admin), +): + """List all available SDK packages with download info and code examples.""" + return {"code": 0, "data": {"packages": SDK_PACKAGES}} + + +# ============================================================ +# Interactive API Documentation +# ============================================================ + +API_DOC_SECTIONS = [ + { + "section": "Authentication", + "icon": "lock", + "description": "All API requests require authentication via Bearer token.", + "endpoints": [ + { + "method": "POST", + "path": "/app/api/auth/login", + "title": "User Login", + "description": "Authenticate and obtain an access token.", + "params": [ + {"name": "username", "type": "string", "required": True, "desc": "Username"}, + {"name": "password", "type": "string", "required": True, "desc": "Password"}, + ], + "example_request": '{"username": "admin", "password": "admin123"}', + "example_response": '{"code": 0, "data": {"access_token": "eyJ...", "token_type": "bearer", "user": {"id": "admin-001", "username": "admin"}}}', + }, + ], + }, + { + "section": "Chat & Completions", + "icon": "chat", + "description": "AI chat completion endpoints for conversational AI.", + "endpoints": [ + { + "method": "POST", + "path": "/app/api/chat/completions", + "title": "Chat Completion", + "description": "Create a chat completion with the specified model.", + "params": [ + {"name": "model", "type": "string", "required": True, "desc": "Model ID (e.g. deepseek-ai/DeepSeek-V4-Flash)"}, + {"name": "messages", "type": "array", "required": True, "desc": "Array of message objects"}, + {"name": "stream", "type": "boolean", "required": False, "desc": "Enable streaming response"}, + {"name": "temperature", "type": "number", "required": False, "desc": "Sampling temperature (0-2)"}, + {"name": "max_tokens", "type": "integer", "required": False, "desc": "Maximum tokens to generate"}, + ], + "example_request": '{"model": "deepseek-ai/DeepSeek-V4-Flash", "messages": [{"role": "user", "content": "Hello"}]}', + "example_response": '{"code": 0, "data": {"id": "chat-xxx", "choices": [{"message": {"role": "assistant", "content": "Hi there!"}}]}}', + }, + ], + }, + { + "section": "Knowledge Base", + "icon": "book", + "description": "Manage knowledge bases and documents for RAG.", + "endpoints": [ + { + "method": "GET", + "path": "/app/api/knowledge-bases", + "title": "List Knowledge Bases", + "description": "Get all knowledge bases for the current user.", + "params": [], + "example_request": "", + "example_response": '{"code": 0, "data": [{"id": "kb-001", "name": "Product Docs", "document_count": 15}]}', + }, + { + "method": "POST", + "path": "/app/api/knowledge-bases/{kb_id}/documents", + "title": "Upload Document", + "description": "Upload a document to a knowledge base for processing.", + "params": [ + {"name": "file", "type": "file", "required": True, "desc": "Document file (PDF, TXT, DOCX, MD)"}, + ], + "example_request": "multipart/form-data: file=@document.pdf", + "example_response": '{"code": 0, "data": {"document_id": "doc-001", "status": "processing"}}', + }, + ], + }, + { + "section": "Agents", + "icon": "robot", + "description": "Agent management and execution endpoints.", + "endpoints": [ + { + "method": "GET", + "path": "/app/api/agents", + "title": "List Agents", + "description": "Get all available agents.", + "params": [], + "example_request": "", + "example_response": '{"code": 0, "data": [{"id": "agent-001", "name": "Code Assistant", "type": "agent"}]}', + }, + { + "method": "POST", + "path": "/app/api/agents/{agent_id}/execute", + "title": "Execute Agent", + "description": "Execute an agent with a given input.", + "params": [ + {"name": "input", "type": "string", "required": True, "desc": "Input text for the agent"}, + {"name": "context", "type": "object", "required": False, "desc": "Additional context"}, + ], + "example_request": '{"input": "Help me write a Python function"}', + "example_response": '{"code": 0, "data": {"output": "Here is a Python function...", "tool_calls": []}}', + }, + ], + }, + { + "section": "Workflows", + "icon": "workflow", + "description": "DAG workflow orchestration endpoints.", + "endpoints": [ + { + "method": "GET", + "path": "/app/api/admin/workflows/templates", + "title": "List Workflow Templates", + "description": "Get predefined workflow templates.", + "params": [], + "example_request": "", + "example_response": '{"code": 0, "data": [{"id": "tpl-001", "name": "Q&A Pipeline", "nodes": [...]}]}', + }, + { + "method": "POST", + "path": "/app/api/admin/workflows/{id}/execute", + "title": "Execute Workflow", + "description": "Execute a workflow with input data.", + "params": [ + {"name": "input", "type": "string", "required": True, "desc": "Input for the workflow"}, + ], + "example_request": '{"input": "Summarize this article"}', + "example_response": '{"code": 0, "data": {"execution_id": "exec-001", "status": "completed", "result": "..."}}', + }, + ], + }, + { + "section": "File Storage", + "icon": "storage", + "description": "File upload and storage management.", + "endpoints": [ + { + "method": "POST", + "path": "/app/api/files/upload", + "title": "Upload File", + "description": "Upload a file to storage (MinIO/OSS).", + "params": [ + {"name": "file", "type": "file", "required": True, "desc": "File to upload"}, + {"name": "category", "type": "string", "required": False, "desc": "File category"}, + ], + "example_request": "multipart/form-data: file=@image.png", + "example_response": '{"code": 0, "data": {"url": "https://...", "size": 102400, "file_type": "image"}}', + }, + ], + }, + { + "section": "Webhooks", + "icon": "webhook", + "description": "Webhook configuration and management.", + "endpoints": [ + { + "method": "GET", + "path": "/app/api/admin/developer-tools/webhooks", + "title": "List Webhooks", + "description": "List all configured webhooks.", + "params": [], + "example_request": "", + "example_response": '{"code": 0, "data": {"total": 2, "items": [{"id": "wh_xxx", "name": "My Webhook", "url": "https://...", "status": "active"}]}}', + }, + { + "method": "POST", + "path": "/app/api/admin/developer-tools/webhooks/", + "title": "Create Webhook", + "description": "Create a new webhook endpoint.", + "params": [ + {"name": "name", "type": "string", "required": True, "desc": "Webhook name"}, + {"name": "url", "type": "string", "required": True, "desc": "Webhook URL"}, + {"name": "events", "type": "array", "required": True, "desc": "Events to subscribe"}, + ], + "example_request": '{"name": "My Webhook", "url": "https://example.com/webhook", "events": ["app.created"]}', + "example_response": '{"code": 0, "data": {"id": "wh_xxx", "secret": "whsec_..."}}', + }, + ], + }, +] + + +@router.get("/api-docs", summary="Interactive API documentation") +async def get_api_docs( + admin_user=Depends(get_current_admin), +): + """Get structured API documentation for the interactive docs viewer.""" + total_endpoints = sum(len(s["endpoints"]) for s in API_DOC_SECTIONS) + return { + "code": 0, + "data": { + "sections": API_DOC_SECTIONS, + "total_sections": len(API_DOC_SECTIONS), + "total_endpoints": total_endpoints, + "base_url": "http://210.16.161.218:18080", + "api_version": "v1", + } + } + + +# ============================================================ +# Enhanced Usage Dashboard +# ============================================================ + +@router.get("/usage/dashboard", summary="Enhanced usage dashboard") +async def usage_dashboard( + days: int = Query(7, ge=1, le=90), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """Enhanced usage statistics dashboard with trend data.""" + now = cst_now() + start_date = now - timedelta(days=days) + + # API call trends (daily) + trend_sql = """SELECT DATE(created_at) as date, COUNT(*) as calls, + SUM(CASE WHEN error_count > 0 THEN 1 ELSE 0 END) as errors, + SUM(CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) as avg_time + FROM api_call_stats + WHERE created_at >= :start + GROUP BY DATE(created_at) + ORDER BY date""" + trend_rows = _execute_raw(db, trend_sql, {"start": start_date}) + + trend = [] + for r in trend_rows: + trend.append({ + "date": r["date"].strftime("%Y-%m-%d") if r.get("date") else "", + "calls": r.get("calls", 0), + "errors": r.get("errors", 0), + "avg_time": round(r.get("avg_time", 0), 2) if r.get("avg_time") else 0, + }) + + # Top endpoints by call count + top_sql = """SELECT endpoint, method, call_count, error_count, + last_called_at, total_response_time + FROM api_call_stats + ORDER BY call_count DESC + LIMIT 10""" + top_rows = _execute_raw(db, top_sql) + + top_endpoints = [] + for r in top_rows: + top_endpoints.append({ + "endpoint": r.get("endpoint", ""), + "method": r.get("method", ""), + "call_count": r.get("call_count", 0), + "error_count": r.get("error_count", 0), + "error_rate": round(r.get("error_count", 0) / max(r.get("call_count", 1), 1) * 100, 2) if r.get("call_count", 0) > 0 else 0, + "avg_response_time": round(r.get("total_response_time", 0) / max(r.get("call_count", 1), 1), 2) if r.get("call_count", 0) > 0 else 0, + "last_called_at": fmt_time(r.get("last_called_at")), + }) + + # Error distribution by status code + error_sql = """SELECT + CASE + WHEN error_count > 0 THEN 'has_errors' + ELSE 'success' + END as status_group, + COUNT(*) as count, + SUM(call_count) as total_calls + FROM api_call_stats + GROUP BY status_group""" + error_rows = _execute_raw(db, error_sql) + + error_distribution = {"success": 0, "has_errors": 0, "success_calls": 0, "error_calls": 0} + for r in error_rows: + if r["status_group"] == "success": + error_distribution["success"] = r["count"] + error_distribution["success_calls"] = r.get("total_calls", 0) + else: + error_distribution["has_errors"] = r["count"] + error_distribution["error_calls"] = r.get("total_calls", 0) + + # Response time distribution + latency_sql = """SELECT + CASE + WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 100 THEN 'fast' + WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 500 THEN 'normal' + WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 1000 THEN 'slow' + ELSE 'very_slow' + END as latency_bucket, + COUNT(*) as count, + SUM(CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) as avg_time + FROM api_call_stats + WHERE total_response_time IS NOT NULL AND call_count > 0 + GROUP BY latency_bucket""" + latency_rows = _execute_raw(db, latency_sql) + + latency_distribution = {"fast": 0, "normal": 0, "slow": 0, "very_slow": 0} + latency_avg = {"fast": 0, "normal": 0, "slow": 0, "very_slow": 0} + for r in latency_rows: + bucket = r["latency_bucket"] + latency_distribution[bucket] = r["count"] + latency_avg[bucket] = round(r.get("avg_time", 0), 2) if r.get("avg_time") else 0 + + # API key usage distribution + key_sql = """SELECT k.name, k.status, COUNT(s.id) as endpoint_count, + COALESCE(SUM(s.call_count), 0) as total_calls, + COALESCE(SUM(s.error_count), 0) as total_errors + FROM api_keys k + LEFT JOIN api_call_stats s ON s.endpoint LIKE CONCAT('%api-key%', k.id, '%') + GROUP BY k.id, k.name, k.status + ORDER BY total_calls DESC + LIMIT 10""" + key_rows = _execute_raw(db, key_sql) + + key_usage = [] + for r in key_rows: + key_usage.append({ + "name": r.get("name", "Unknown"), + "status": r.get("status", "unknown"), + "total_calls": r.get("total_calls", 0), + "total_errors": r.get("total_errors", 0), + }) + + # Summary + total_calls = sum(t["calls"] for t in trend) + total_errors = sum(t["errors"] for t in trend) + avg_response = sum(t["avg_time"] for t in trend) / len(trend) if trend else 0 + error_rate = round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0 + + return { + "code": 0, + "data": { + "summary": { + "total_calls": total_calls, + "total_errors": total_errors, + "error_rate": error_rate, + "avg_response_time": round(avg_response, 2), + "active_api_keys": error_distribution["success"] + error_distribution["has_errors"], + }, + "trend": trend, + "top_endpoints": top_endpoints, + "error_distribution": error_distribution, + "latency_distribution": latency_distribution, + "latency_avg": latency_avg, + "key_usage": key_usage, + } + } diff --git a/backend/python/app/api/admin/distro_admin.py b/backend/python/app/api/admin/distro_admin.py new file mode 100644 index 0000000..51319d0 --- /dev/null +++ b/backend/python/app/api/admin/distro_admin.py @@ -0,0 +1,752 @@ +""" +Distribution Builder Admin API - Phase G4 +发行版构建器:SaaS 产品线管理,配置包生成,许可证管理 + +Endpoints: + GET / - 发行版列表 + POST / - 创建发行版 + GET /{id} - 发行版详情 + PUT /{id} - 更新发行版 + DELETE /{id} - 删除发行版 + POST /{id}/build - 构建配置包 + GET /{id}/download - 下载配置包 + POST /{id}/licenses - 生成许可证 + GET /licenses - 许可证列表 + GET /licenses/{lid} - 许可证详情 + POST /licenses/{lid}/revoke - 吊销许可证 + POST /licenses/validate - 验证许可证 + GET /stats - 发行版统计 + GET /templates - 发行版模板 +""" +import hashlib +import hmac +import json +import uuid +import yaml +import zipfile +import io +from datetime import datetime, timezone, timedelta +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ...database import get_db, SessionLocal +from ...auth import get_current_admin + +router = APIRouter() + +CST = timezone(timedelta(hours=8)) +LICENSE_SECRET = "qihuaiflow-distro-secret-key-2026" + +# ==================== Pydantic Models ==================== + +class DistroCreate(BaseModel): + name: str = Field(..., description="发行版名称") + description: str = Field("") + brand_name: Optional[str] = None + brand_logo_url: Optional[str] = None + brand_color: str = "#6366f1" + system_prompt: str = "" + provider: str = "siliconflow" + model: str = "deepseek-ai/DeepSeek-V4-Flash" + mcp_extensions: Optional[List[dict]] = None + workflow_ids: Optional[List[str]] = None + knowledge_base_ids: Optional[List[str]] = None + agent_ids: Optional[List[str]] = None + features: Optional[List[str]] = None + version: str = "1.0.0" + +class DistroUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + brand_name: Optional[str] = None + brand_logo_url: Optional[str] = None + brand_color: Optional[str] = None + system_prompt: Optional[str] = None + provider: Optional[str] = None + model: Optional[str] = None + mcp_extensions: Optional[List[dict]] = None + workflow_ids: Optional[List[str]] = None + knowledge_base_ids: Optional[List[str]] = None + agent_ids: Optional[List[str]] = None + features: Optional[List[str]] = None + version: Optional[str] = None + status: Optional[str] = None + +class LicenseCreate(BaseModel): + distribution_id: str + org_name: str + licensee_email: Optional[str] = None + features: Optional[List[str]] = None + max_users: int = 10 + max_executions: int = 10000 + expiry_date: Optional[str] = None # YYYY-MM-DD + +class LicenseValidate(BaseModel): + license_key: str + distribution_id: Optional[str] = None + +# ==================== Helpers ==================== + +def _execute_raw(db, sql, params=None): + return db.execute(text(sql), params or {}) + +def _generate_id(prefix="distro"): + return f"{prefix}-{uuid.uuid4().hex[:8]}" + +def _format_cst_time(dt): + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=CST) + return dt.astimezone(CST) + +def _row_to_dict(row, columns): + return {col: row[i] for i, col in enumerate(columns)} + +def _serialize_dt(obj): + if isinstance(obj, datetime): + return _format_cst_time(obj).strftime("%Y-%m-%d %H:%M:%S") + return obj + +# ==================== Distribution CRUD ==================== + +@router.get("", summary="发行版列表") +async def list_distributions( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: Optional[str] = None, + search: Optional[str] = None, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + conditions = [] + params = {} + if status: + conditions.append("status = :status") + params["status"] = status + if search: + conditions.append("(name LIKE :search OR description LIKE :search)") + params["search"] = f"%{search}%" + + where = " AND ".join(conditions) if conditions else "1=1" + + rows = _execute_raw(db, f"SELECT COUNT(*) FROM distributions WHERE {where}", params) + total = rows.fetchone()[0] + + offset = (page - 1) * page_size + rows = _execute_raw(db, + f"SELECT * FROM distributions WHERE {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset", + {**params, "limit": page_size, "offset": offset} + ) + cols = list(rows.keys()) + items = [] + for row in rows.fetchall(): + d = _row_to_dict(row, cols) + for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]: + if d.get(key) and isinstance(d[key], str): + try: + d[key] = json.loads(d[key]) + except (json.JSONDecodeError, TypeError): + pass + for key in ["created_at", "updated_at"]: + d[key] = _serialize_dt(d[key]) + items.append(d) + + return { + "code": 0, + "data": {"total": total, "items": items, "page": page, "page_size": page_size} + } + +@router.post("", summary="创建发行版") +async def create_distribution( + body: DistroCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + distro_id = _generate_id("distro") + now = datetime.now() + + config_json = json.dumps({ + "provider": body.provider, + "model": body.model, + "mcp_extensions": body.mcp_extensions or [], + "workflow_ids": body.workflow_ids or [], + "knowledge_base_ids": body.knowledge_base_ids or [], + "agent_ids": body.agent_ids or [], + "features": body.features or ["chat"], + }, ensure_ascii=False) + + _execute_raw(db, """ + INSERT INTO distributions (id, name, description, brand_name, brand_logo_url, brand_color, + system_prompt, provider, model, mcp_extensions, workflow_ids, knowledge_base_ids, + agent_ids, features, config_json, status, version, created_at, updated_at) + VALUES (:id, :name, :desc, :brand_name, :brand_logo, :brand_color, + :system_prompt, :provider, :model, :mcp_ext, :wf_ids, :kb_ids, + :agent_ids, :features, :config_json, 'draft', :version, :now, :now) + """, { + "id": distro_id, "name": body.name, "desc": body.description, + "brand_name": body.brand_name, "brand_logo": body.brand_logo_url, "brand_color": body.brand_color, + "system_prompt": body.system_prompt, "provider": body.provider, "model": body.model, + "mcp_ext": json.dumps(body.mcp_extensions) if body.mcp_extensions else None, + "wf_ids": json.dumps(body.workflow_ids) if body.workflow_ids else None, + "kb_ids": json.dumps(body.knowledge_base_ids) if body.knowledge_base_ids else None, + "agent_ids": json.dumps(body.agent_ids) if body.agent_ids else None, + "features": json.dumps(body.features) if body.features else None, + "config_json": config_json, "version": body.version, "now": now, + }) + db.commit() + + return {"code": 0, "data": {"id": distro_id}, "message": "发行版创建成功"} + +@router.get("/templates", summary="发行版模板") +async def get_templates( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取可用资源模板:工作流、Agent、知识库、MCP扩展""" + # Get available workflows (any status) + try: + rows = _execute_raw(db, "SELECT id, name, status FROM agent_workflows ORDER BY updated_at DESC LIMIT 20") + cols = list(rows.keys()) + workflows = [_row_to_dict(r, cols) for r in rows.fetchall()] + except Exception: + workflows = [] + + # Get available agents + try: + rows = _execute_raw(db, "SELECT id, name, description FROM market_apps WHERE type='agent' AND status='published' LIMIT 20") + cols = list(rows.keys()) + agents = [_row_to_dict(r, cols) for r in rows.fetchall()] + except Exception: + agents = [] + + # Get available knowledge bases + rows = _execute_raw(db, "SELECT id, name, description FROM knowledge_bases WHERE status='active' LIMIT 20") + cols = list(rows.keys()) + kbs = [_row_to_dict(r, cols) for r in rows.fetchall()] + + # Get available MCP tools + try: + rows = _execute_raw(db, "SELECT id, app_id as name, server_type FROM mcp_tool_configs WHERE security_status='approved' LIMIT 20") + cols = list(rows.keys()) + mcps = [_row_to_dict(r, cols) for r in rows.fetchall()] + except Exception: + mcps = [] + + return { + "code": 0, + "data": { + "workflows": workflows, + "agents": agents, + "knowledge_bases": kbs, + "mcp_tools": mcps, + } + } + +@router.get("/stats", summary="发行版统计") +async def get_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, """ + SELECT + COUNT(*) as total, + SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) as draft, + SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN status='archived' THEN 1 ELSE 0 END) as archived, + SUM(download_count) as total_downloads, + (SELECT COUNT(*) FROM distribution_licenses) as total_licenses, + (SELECT COUNT(*) FROM distribution_licenses WHERE status='active') as active_licenses + FROM distributions + """) + row = rows.fetchone() + cols = list(rows.keys()) + stats = _row_to_dict(row, cols) + + return {"code": 0, "data": stats} + +# ==================== License Management ==================== + +def _generate_license_key(distro_id: str, org_name: str, features: list, expiry: str) -> str: + """Generate HMAC-SHA256 license key""" + payload = f"{distro_id}:{org_name}:{json.dumps(sorted(features or []))}:{expiry or 'perpetual'}" + return hmac.new( + LICENSE_SECRET.encode(), + payload.encode(), + hashlib.sha256 + ).hexdigest()[:32] + +def _verify_license_signature(license_key: str, payload: str) -> bool: + """Verify license key signature""" + expected = hmac.new( + LICENSE_SECRET.encode(), + payload.encode(), + hashlib.sha256 + ).hexdigest()[:32] + return hmac.compare_digest(expected, license_key) + +@router.get("/licenses", summary="许可证列表") +async def list_licenses( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + distribution_id: Optional[str] = None, + status: Optional[str] = None, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + conditions = [] + params = {} + if distribution_id: + conditions.append("l.distribution_id = :did") + params["did"] = distribution_id + if status: + conditions.append("l.status = :status") + params["status"] = status + + where = " AND ".join(conditions) if conditions else "1=1" + + rows = _execute_raw(db, f"SELECT COUNT(*) FROM distribution_licenses l WHERE {where}", params) + total = rows.fetchone()[0] + + offset = (page - 1) * page_size + rows = _execute_raw(db, f""" + SELECT l.*, d.name as distribution_name + FROM distribution_licenses l + LEFT JOIN distributions d ON l.distribution_id = d.id + WHERE {where} + ORDER BY l.created_at DESC + LIMIT :limit OFFSET :offset + """, {**params, "limit": page_size, "offset": offset}) + + cols = list(rows.keys()) + items = [] + for row in rows.fetchall(): + item = _row_to_dict(row, cols) + for key in ["features", "metadata_json"]: + if item.get(key) and isinstance(item[key], str): + try: + item[key] = json.loads(item[key]) + except (json.JSONDecodeError, TypeError): + pass + for key in ["created_at", "updated_at", "first_used_at", "last_used_at"]: + item[key] = _serialize_dt(item[key]) + if item.get("expiry_date"): + item["expiry_date"] = str(item["expiry_date"]) + items.append(item) + + return {"code": 0, "data": {"total": total, "items": items, "page": page, "page_size": page_size}} + +@router.post("/{distro_id}/licenses", summary="生成许可证") +async def create_license( + distro_id: str, + body: LicenseCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, "SELECT id, features FROM distributions WHERE id = :id", {"id": distro_id}) + row = rows.fetchone() + if not row: + raise HTTPException(status_code=404, detail="发行版不存在") + + distro_features = row[1] + if isinstance(distro_features, str): + try: + distro_features = json.loads(distro_features) + except json.JSONDecodeError: + distro_features = [] + + license_id = _generate_id("lic") + # Features limited to what distribution supports + requested_features = body.features or distro_features or ["chat"] + license_key = _generate_license_key(distro_id, body.org_name, requested_features, body.expiry_date) + + now = datetime.now() + _execute_raw(db, """ + INSERT INTO distribution_licenses + (id, distribution_id, org_name, licensee_email, license_key, features, + max_users, max_executions, expiry_date, status, created_at, updated_at) + VALUES (:id, :did, :org, :email, :key, :features, + :max_users, :max_exec, :expiry, 'active', :now, :now) + """, { + "id": license_id, "did": distro_id, "org": body.org_name, "email": body.licensee_email, + "key": license_key, "features": json.dumps(requested_features), + "max_users": body.max_users, "max_exec": body.max_executions, + "expiry": body.expiry_date, "now": now, + }) + db.commit() + + return { + "code": 0, + "data": { + "id": license_id, + "license_key": license_key, + "org_name": body.org_name, + "features": requested_features, + "expiry_date": body.expiry_date, + }, + "message": "许可证生成成功" + } + +@router.get("/licenses/{license_id}", summary="许可证详情") +async def get_license( + license_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, """ + SELECT l.*, d.name as distribution_name + FROM distribution_licenses l + LEFT JOIN distributions d ON l.distribution_id = d.id + WHERE l.id = :id + """, {"id": license_id}) + row = rows.fetchone() + if not row: + raise HTTPException(status_code=404, detail="许可证不存在") + + cols = list(rows.keys()) + lic = _row_to_dict(row, cols) + for key in ["features", "metadata_json"]: + if lic.get(key) and isinstance(lic[key], str): + try: + lic[key] = json.loads(lic[key]) + except (json.JSONDecodeError, TypeError): + pass + for key in ["created_at", "updated_at", "first_used_at", "last_used_at"]: + lic[key] = _serialize_dt(lic[key]) + if lic.get("expiry_date"): + lic["expiry_date"] = str(lic["expiry_date"]) + + return {"code": 0, "data": lic} + +@router.post("/licenses/{license_id}/revoke", summary="吊销许可证") +async def revoke_license( + license_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, "SELECT id, status FROM distribution_licenses WHERE id = :id", {"id": license_id}) + row = rows.fetchone() + if not row: + raise HTTPException(status_code=404, detail="许可证不存在") + + _execute_raw(db, "UPDATE distribution_licenses SET status='revoked', updated_at=NOW() WHERE id = :id", {"id": license_id}) + db.commit() + + return {"code": 0, "message": "许可证已吊销"} + +@router.post("/licenses/validate", summary="验证许可证") +async def validate_license( + body: LicenseValidate, + db: Session = Depends(get_db), +): + """公开 API:验证许可证有效性(不需要 admin 权限)""" + rows = _execute_raw(db, + "SELECT * FROM distribution_licenses WHERE license_key = :key AND status='active'", + {"key": body.license_key} + ) + row = rows.fetchone() + if not row: + return {"code": 1, "data": {"valid": False, "reason": "license_not_found"}, "message": "许可证不存在或已失效"} + + cols = list(rows.keys()) + lic = _row_to_dict(row, cols) + + # Check expiry + if lic.get("expiry_date"): + expiry = lic["expiry_date"] + if isinstance(expiry, str): + expiry = datetime.strptime(expiry, "%Y-%m-%d").date() + if expiry < datetime.now().date(): + return {"code": 1, "data": {"valid": False, "reason": "expired"}, "message": "许可证已过期"} + + # Check usage limit + if lic.get("max_executions") and lic.get("usage_count", 0) >= lic["max_executions"]: + return {"code": 1, "data": {"valid": False, "reason": "usage_exceeded"}, "message": "许可证用量已超限"} + + features = lic.get("features") + if isinstance(features, str): + try: + features = json.loads(features) + except json.JSONDecodeError: + features = [] + + # Update usage + _execute_raw(db, """ + UPDATE distribution_licenses + SET usage_count = usage_count + 1, + last_used_at = NOW(), + first_used_at = COALESCE(first_used_at, NOW()) + WHERE id = :id + """, {"id": lic["id"]}) + db.commit() + + return { + "code": 0, + "data": { + "valid": True, + "org_name": lic.get("org_name"), + "features": features, + "max_users": lic.get("max_users"), + "max_executions": lic.get("max_executions"), + "usage_count": lic.get("usage_count", 0) + 1, + "expiry_date": str(lic["expiry_date"]) if lic.get("expiry_date") else None, + } + } + + +@router.get("/{distro_id}", summary="发行版详情") +async def get_distribution( + distro_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, "SELECT * FROM distributions WHERE id = :id", {"id": distro_id}) + row = rows.fetchone() + if not row: + raise HTTPException(status_code=404, detail="发行版不存在") + + cols = list(rows.keys()) + d = _row_to_dict(row, cols) + for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]: + if d.get(key) and isinstance(d[key], str): + try: + d[key] = json.loads(d[key]) + except (json.JSONDecodeError, TypeError): + pass + for key in ["created_at", "updated_at"]: + d[key] = _serialize_dt(d[key]) + + # Get associated licenses count + lic_rows = _execute_raw(db, "SELECT COUNT(*) as cnt FROM distribution_licenses WHERE distribution_id = :did", {"did": distro_id}) + d["license_count"] = lic_rows.fetchone()[0] + + return {"code": 0, "data": d} + +@router.put("/{distro_id}", summary="更新发行版") +async def update_distribution( + distro_id: str, + body: DistroUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, "SELECT id FROM distributions WHERE id = :id", {"id": distro_id}) + if not rows.fetchone(): + raise HTTPException(status_code=404, detail="发行版不存在") + + updates = [] + params = {"id": distro_id} + for field, value in body.dict(exclude_unset=True).items(): + if field in ("mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features"): + updates.append(f"{field} = :{field}") + params[field] = json.dumps(value) if value else None + else: + updates.append(f"{field} = :{field}") + params[field] = value + + if updates: + updates.append("updated_at = NOW()") + _execute_raw(db, f"UPDATE distributions SET {', '.join(updates)} WHERE id = :id", params) + db.commit() + + return {"code": 0, "message": "更新成功"} + +@router.delete("/{distro_id}", summary="删除发行版") +async def delete_distribution( + distro_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + rows = _execute_raw(db, "SELECT id FROM distributions WHERE id = :id", {"id": distro_id}) + if not rows.fetchone(): + raise HTTPException(status_code=404, detail="发行版不存在") + + # Delete associated licenses + _execute_raw(db, "DELETE FROM distribution_licenses WHERE distribution_id = :id", {"id": distro_id}) + _execute_raw(db, "DELETE FROM distributions WHERE id = :id", {"id": distro_id}) + db.commit() + + return {"code": 0, "message": "删除成功"} + +# ==================== Build & Export ==================== + +@router.post("/{distro_id}/build", summary="构建配置包") +async def build_distribution( + distro_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """构建发行版配置包 ZIP (YAML config + recipes + extensions + license template)""" + rows = _execute_raw(db, "SELECT * FROM distributions WHERE id = :id", {"id": distro_id}) + row = rows.fetchone() + if not row: + raise HTTPException(status_code=404, detail="发行版不存在") + + cols = list(rows.keys()) + d = _row_to_dict(row, cols) + + # Parse JSON fields + for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]: + if d.get(key) and isinstance(d[key], str): + try: + d[key] = json.loads(d[key]) + except (json.JSONDecodeError, TypeError): + d[key] = [] if "ids" in key else ({} if key == "config_json" else []) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + + # 1. init-config.yaml + init_config = { + "GOOSE_PROVIDER": d.get("provider", "siliconflow"), + "GOOSE_MODEL": d.get("model", "deepseek-ai/DeepSeek-V4-Flash"), + "PLATFORM_API_URL": "https://your-platform.com/api", + "LICENSE_KEY": "YOUR_LICENSE_KEY_HERE", + } + zf.writestr("init-config.yaml", yaml.dump(init_config, allow_unicode=True, default_flow_style=False)) + + # 2. config.yaml (MCP extensions) + config = { + "extensions": {}, + } + for ext in (d.get("mcp_extensions") or []): + ext_id = ext.get("id", str(uuid.uuid4().hex[:8])) + config["extensions"][ext_id] = { + "name": ext.get("name", "unnamed"), + "cmd": ext.get("cmd", ""), + "args": ext.get("args", []), + "enabled": True, + "type": ext.get("type", "stdio"), + "timeout": ext.get("timeout", 30000), + } + zf.writestr("config.yaml", yaml.dump(config, allow_unicode=True, default_flow_style=False)) + + # 3. Recipes (workflow exports) + wf_ids = d.get("workflow_ids") or [] + for wf_id in wf_ids: + wf_rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": wf_id}) + wf_row = wf_rows.fetchone() + if wf_row: + wf_cols = list(wf_rows.keys()) + wf = _row_to_dict(wf_row, wf_cols) + recipe = { + "name": wf.get("name", wf_id), + "description": wf.get("description", ""), + "steps": [], + } + nodes = wf.get("nodes") + if isinstance(nodes, str): + try: + nodes = json.loads(nodes) + except json.JSONDecodeError: + nodes = [] + for node in (nodes or []): + recipe["steps"].append({ + "type": node.get("type", ""), + "label": node.get("data", {}).get("label", ""), + "config": node.get("data", {}).get("config", {}), + }) + zf.writestr(f"recipes/{wf_id}.yaml", yaml.dump(recipe, allow_unicode=True, default_flow_style=False)) + + # 4. Branding + zf.writestr("branding/system-prompt.md", d.get("system_prompt", "# AI Assistant\n\nYou are a helpful AI assistant.")) + branding = { + "app_name": d.get("brand_name", d.get("name", "AI Assistant")), + "brand_color": d.get("brand_color", "#6366f1"), + "version": d.get("version", "1.0.0"), + } + zf.writestr("branding/manifest.yaml", yaml.dump(branding, allow_unicode=True)) + + # 5. License template + license_template = { + "license_key": "TO_BE_GENERATED", + "org_name": "YOUR_ORGANIZATION", + "expiry": None, + "features": d.get("features", ["chat"]), + } + zf.writestr("license.template.yaml", yaml.dump(license_template, allow_unicode=True)) + + # 6. install.sh + install_script = """#!/bin/bash +# Distribution Installation Script +echo "Installing Distribution: {name}" +echo "Version: {version}" +echo "" +echo "1. Copy init-config.yaml to ~/.config/goose/" +cp init-config.yaml ~/.config/goose/init-config.yaml +echo "2. Copy config.yaml" +cp config.yaml ~/.config/goose/config.yaml +echo "3. Copy recipes" +mkdir -p ~/.config/goose/recipes/ +cp -r recipes/ ~/.config/goose/recipes/ +echo "4. Copy branding" +mkdir -p ~/.config/goose/branding/ +cp -r branding/ ~/.config/goose/branding/ +echo "Installation complete!" +""".format(name=d.get("name", "Unnamed"), version=d.get("version", "1.0.0")) + zf.writestr("install.sh", install_script) + + # 7. README + readme = f"""# {d.get('brand_name', d.get('name', 'Distribution'))} + +## Description +{d.get('description', 'No description')} + +## Included Features +""" + "\n".join([f"- {f}" for f in (d.get("features") or ["chat"])]) + f""" + +## Version +{d.get('version', '1.0.0')} + +## Installation +Run `bash install.sh` to install. + +## License +A valid license key is required. Contact your administrator for details. +""" + zf.writestr("README.md", readme) + + # Update download count and size + size = buf.tell() + _execute_raw(db, "UPDATE distributions SET download_count = download_count + 1, size_bytes = :size WHERE id = :id", + {"size": size, "id": distro_id}) + db.commit() + + buf.seek(0) + + filename = f"{d.get('name', 'distro').replace(' ', '_')}-{d.get('version', '1.0.0')}.zip" + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + +@router.get("/{distro_id}/download", summary="下载配置包") +async def download_distribution( + distro_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """直接下载已生成的配置包""" + return await build_distribution(distro_id, admin_user, db) + +# ==================== LLM Proxy API ==================== + +@router.post("/llm-proxy/{api_key:path}", summary="LLM API 代理") +async def llm_proxy( + api_key: str, + admin_user=Depends(get_current_admin), +): + """LLM 代理端点状态检查""" + return { + "code": 0, + "data": { + "status": "ready", + "providers": ["siliconflow", "deepseek"], + "message": "LLM Proxy is operational. Use POST /admin/distributions/llm-proxy/chat to call models." + } + } diff --git a/backend/python/app/api/admin/im_bot_admin.py b/backend/python/app/api/admin/im_bot_admin.py new file mode 100644 index 0000000..4e0bd21 --- /dev/null +++ b/backend/python/app/api/admin/im_bot_admin.py @@ -0,0 +1,1113 @@ +""" +Phase 4: IM Bot Integration - Admin API Module +im_bot_admin.py + +Provides admin management for IM bot channels, routing rules, +statistics, message monitoring, and native IM platform adapters +(e.g. 个人微信 WeKnora/Clawbot) that connect directly to each IM +platform's official open API — no third-party gateway required. +""" +import uuid +import json +import hashlib +import hmac +import time +import threading +import requests +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ...database import SessionLocal +from ...auth import get_current_user + +router = APIRouter() + + +# ============================================================ +# Helper Functions +# ============================================================ + +def _execute_raw(db: Session, sql_text, params=None): + """Execute raw SQL and return results""" + result = db.execute(sql_text, params or {}) + if result.returns_rows: + return result.fetchall() + db.commit() + return result + + +def _get_platform_info(platform: str) -> dict: + """Get platform display info and config schema""" + platforms = { + "feishu": { + "name": "飞书", + "icon": "feishu", + "color": "#3370FF", + "credentials": ["app_id", "app_secret", "verification_token"], + "webhook_fields": ["encrypt_key"], + "docs_url": "https://open.feishu.cn/document/home/index", + }, + "wechat": { + "name": "微信公众号", + "icon": "wechat", + "color": "#07C160", + "credentials": ["app_id", "app_secret", "token"], + "webhook_fields": ["encoding_aes_key"], + "docs_url": "https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html", + }, + "personal_wechat": { + "name": "个人微信", + "icon": "wechat-personal", + "color": "#07C160", + "credentials": ["qr_code", "clawbot_token"], + "webhook_fields": [], + "docs_url": "", + "scan_auth": True, + }, + "wecom": { + "name": "企业微信", + "icon": "wecom", + "color": "#2F80ED", + "credentials": ["corp_id", "agent_id", "secret", "token"], + "webhook_fields": ["encoding_aes_key"], + "docs_url": "https://developer.work.weixin.qq.com/document/", + }, + "dingtalk": { + "name": "钉钉", + "icon": "dingtalk", + "color": "#0089FF", + "credentials": ["app_key", "app_secret"], + "webhook_fields": ["aes_key"], + "docs_url": "https://open.dingtalk.com/document/", + }, + "telegram": { + "name": "Telegram", + "icon": "telegram", + "color": "#0088CC", + "credentials": ["bot_token"], + "webhook_fields": [], + "docs_url": "https://core.telegram.org/bots/api", + }, + "whatsapp": { + "name": "WhatsApp", + "icon": "whatsapp", + "color": "#25D366", + "credentials": ["phone_number_id", "access_token"], + "webhook_fields": ["verify_token"], + "docs_url": "https://developers.facebook.com/docs/whatsapp", + }, + "slack": { + "name": "Slack", + "icon": "slack", + "color": "#4A154B", + "credentials": ["bot_token", "signing_secret"], + "webhook_fields": [], + "docs_url": "https://api.slack.com/docs", + }, + } + return platforms.get(platform, {"name": platform, "icon": "default", "color": "#666"}) + + +def _row_to_dict(row) -> dict: + """Convert SQLAlchemy row to dict""" + if row is None: + return None + d = {} + for col in row._mapping.keys(): + val = row._mapping[col] + if isinstance(val, datetime): + val = val.isoformat() + d[col] = val + return d + + +def _rows_to_dict(rows) -> list: + """Convert multiple rows to list of dicts""" + return [_row_to_dict(r) for r in rows] if rows else [] + + +# ============================================================ +# Dashboard & Stats +# ============================================================ + +@router.get("/stats") +async def get_im_bot_stats(user=Depends(get_current_user)): + """Get overall IM bot statistics""" + db = SessionLocal() + try: + # Total channels by platform + rows = _execute_raw(db, text(""" + SELECT platform, COUNT(*) as count, + SUM(CASE WHEN status='connected' THEN 1 ELSE 0 END) as active_count + FROM channels + WHERE is_active = 1 + GROUP BY platform + """)) + platform_stats = {} + total_channels = 0 + total_active = 0 + for r in rows: + d = _row_to_dict(r) + platform_stats[d['platform']] = d + total_channels += d['count'] + total_active += d['active_count'] + + # Today's messages + today = datetime.now().strftime('%Y-%m-%d') + rows = _execute_raw(db, text(""" + SELECT COALESCE(SUM(messages_in), 0) as msg_in, + COALESCE(SUM(messages_out), 0) as msg_out, + COALESCE(SUM(unique_users), 0) as users, + COALESCE(SUM(error_count), 0) as errors + FROM im_bot_daily_stats + WHERE stat_date = :today + """), {"today": today}) + today_stats = _row_to_dict(rows[0]) if rows else {"msg_in": 0, "msg_out": 0, "users": 0, "errors": 0} + + # Total routing rules + routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=1"))[0]._mapping['cnt'] + + return { + "success": True, + "data": { + "total_channels": total_channels, + "active_channels": total_active, + "platform_breakdown": platform_stats, + "today_messages_in": today_stats.get('msg_in', 0), + "today_messages_out": today_stats.get('msg_out', 0), + "today_unique_users": today_stats.get('users', 0), + "today_errors": today_stats.get('errors', 0), + "active_routes": routes_count, + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Stats error: {str(e)}") + finally: + db.close() + + +@router.get("/dashboard") +async def get_dashboard(user=Depends(get_current_user)): + """Get dashboard data with charts""" + db = SessionLocal() + try: + # 7-day message trend + rows = _execute_raw(db, text(""" + SELECT stat_date, + SUM(messages_in) as msg_in, + SUM(messages_out) as msg_out, + SUM(unique_users) as users + FROM im_bot_daily_stats + WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) + GROUP BY stat_date + ORDER BY stat_date + """)) + trend = _rows_to_dict(rows) + + # Channel list with latest stats + rows = _execute_raw(db, text(""" + SELECT c.id, c.name, c.platform, c.status, c.is_active, + c.last_active, c.default_agent_id, + COALESCE(s.msg_count, 0) as msg_count + FROM channels c + LEFT JOIN ( + SELECT channel_id, SUM(messages_in + messages_out) as msg_count + FROM im_bot_daily_stats + WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + GROUP BY channel_id + ) s ON c.id = s.channel_id + ORDER BY c.created_at DESC + LIMIT 20 + """)) + channels = [] + for r in rows: + d = _row_to_dict(r) + d['platform_info'] = _get_platform_info(d['platform']) + channels.append(d) + + # Platform distribution + rows = _execute_raw(db, text(""" + SELECT platform, COUNT(*) as count + FROM channels WHERE is_active = 1 + GROUP BY platform + """)) + distribution = _rows_to_dict(rows) + + return { + "success": True, + "data": { + "message_trend": trend, + "recent_channels": channels, + "platform_distribution": distribution, + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Dashboard error: {str(e)}") + finally: + db.close() + + +# ============================================================ +# Channel Management +# ============================================================ + +@router.get("/channels") +async def list_channels( + platform: Optional[str] = Query(None), + status: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + user=Depends(get_current_user) +): + """List all IM bot channels (admin view)""" + db = SessionLocal() + try: + where_clauses = ["1=1"] + params = {} + if platform: + where_clauses.append("c.platform = :platform") + params['platform'] = platform + if status: + where_clauses.append("c.status = :status") + params['status'] = status + + where_sql = " AND ".join(where_clauses) + offset = (page - 1) * page_size + + # Count + count_sql = f"SELECT COUNT(*) as cnt FROM channels c WHERE {where_sql}" + total = _execute_raw(db, text(count_sql), params)[0]._mapping['cnt'] + + # Query + sql = f""" + SELECT c.*, m.name as agent_name + FROM channels c + LEFT JOIN market_apps m ON c.default_agent_id = m.id + WHERE {where_sql} + ORDER BY c.created_at DESC + LIMIT :limit OFFSET :offset + """ + params['limit'] = page_size + params['offset'] = offset + rows = _execute_raw(db, text(sql), params) + + channels = [] + for r in rows: + d = _row_to_dict(r) + d['platform_info'] = _get_platform_info(d['platform']) + channels.append(d) + + return { + "success": True, + "data": { + "items": channels, + "total": total, + "page": page, + "page_size": page_size, + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"List error: {str(e)}") + finally: + db.close() + + +@router.get("/channels/{channel_id}") +async def get_channel_detail(channel_id: str, user=Depends(get_current_user)): + """Get channel detail with routing rules and recent messages""" + db = SessionLocal() + try: + # Channel + rows = _execute_raw(db, text(""" + SELECT c.*, m.name as agent_name + FROM channels c + LEFT JOIN market_apps m ON c.default_agent_id = m.id + WHERE c.id = :cid + """), {"cid": channel_id}) + if not rows: + raise HTTPException(status_code=404, detail="Channel not found") + channel = _row_to_dict(rows[0]) + channel['platform_info'] = _get_platform_info(channel['platform']) + + # Routing rules + routes = _execute_raw(db, text(""" + SELECT r.*, m.name as agent_name + FROM im_bot_routes r + LEFT JOIN market_apps m ON r.agent_id = m.id + WHERE r.channel_id = :cid + ORDER BY r.priority DESC + """), {"cid": channel_id}) + channel['routes'] = _rows_to_dict(routes) + + # Recent messages (from channel_messages table) + msgs = _execute_raw(db, text(""" + SELECT * FROM channel_messages + WHERE channel_id = :cid + ORDER BY created_at DESC + LIMIT 20 + """), {"cid": channel_id}) + channel['recent_messages'] = _rows_to_dict(msgs) + + # 7-day stats + stats = _execute_raw(db, text(""" + SELECT stat_date, messages_in, messages_out, unique_users, error_count + FROM im_bot_daily_stats + WHERE channel_id = :cid + AND stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) + ORDER BY stat_date + """), {"cid": channel_id}) + channel['stats_7d'] = _rows_to_dict(stats) + + return {"success": True, "data": channel} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Detail error: {str(e)}") + finally: + db.close() + + +@router.post("/channels/{channel_id}/test") +async def test_channel(channel_id: str, user=Depends(get_current_user)): + """Test channel connectivity""" + db = SessionLocal() + try: + rows = _execute_raw(db, text("SELECT * FROM channels WHERE id = :cid"), {"cid": channel_id}) + if not rows: + raise HTTPException(status_code=404, detail="Channel not found") + channel = _row_to_dict(rows[0]) + + # Simulate connectivity test based on platform + platform = channel['platform'] + creds = channel.get('credential', {}) + if isinstance(creds, str): + creds = json.loads(creds) if creds else {} + + result = { + "channel_id": channel_id, + "platform": platform, + "tested_at": datetime.now().isoformat(), + "checks": [], + "passed": True, + } + + if platform == 'feishu': + result['checks'].append({"name": "App ID", "passed": bool(creds.get('app_id'))}) + result['checks'].append({"name": "App Secret", "passed": bool(creds.get('app_secret'))}) + result['checks'].append({"name": "Verification Token", "passed": bool(creds.get('verification_token'))}) + elif platform in ('wechat', 'wecom'): + result['checks'].append({"name": "App ID", "passed": bool(creds.get('app_id') or creds.get('corp_id'))}) + result['checks'].append({"name": "Secret", "passed": bool(creds.get('app_secret') or creds.get('secret'))}) + elif platform == 'dingtalk': + result['checks'].append({"name": "App Key", "passed": bool(creds.get('app_key'))}) + result['checks'].append({"name": "App Secret", "passed": bool(creds.get('app_secret'))}) + elif platform == 'telegram': + result['checks'].append({"name": "Bot Token", "passed": bool(creds.get('bot_token'))}) + else: + result['checks'].append({"name": "Credentials", "passed": len(creds) > 0}) + + result['passed'] = all(c['passed'] for c in result['checks']) + result['webhook_configured'] = bool(channel.get('webhook_url')) + + return {"success": True, "data": result} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Test error: {str(e)}") + finally: + db.close() + + +@router.post("/channels/{channel_id}/activate") +async def activate_channel(channel_id: str, user=Depends(get_current_user)): + """Activate a channel""" + db = SessionLocal() + try: + _execute_raw(db, text( + "UPDATE channels SET is_active=1, status='connected' WHERE id=:cid" + ), {"cid": channel_id}) + return {"success": True, "message": "Channel activated"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.post("/channels/{channel_id}/deactivate") +async def deactivate_channel(channel_id: str, user=Depends(get_current_user)): + """Deactivate a channel""" + db = SessionLocal() + try: + _execute_raw(db, text( + "UPDATE channels SET is_active=0, status='disconnected' WHERE id=:cid" + ), {"cid": channel_id}) + return {"success": True, "message": "Channel deactivated"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# ============================================================ +# Routing Rules +# ============================================================ + +@router.get("/routes") +async def list_routes( + channel_id: Optional[str] = Query(None), + user=Depends(get_current_user) +): + """List routing rules""" + db = SessionLocal() + try: + where = "1=1" + params = {} + if channel_id: + where = "r.channel_id = :cid" + params['cid'] = channel_id + + rows = _execute_raw(db, text(f""" + SELECT r.*, c.name as channel_name, c.platform, + m.name as agent_name, m2.name as fallback_agent_name + FROM im_bot_routes r + LEFT JOIN channels c ON r.channel_id = c.id + LEFT JOIN market_apps m ON r.agent_id = m.id + LEFT JOIN market_apps m2 ON r.fallback_agent_id = m2.id + WHERE {where} + ORDER BY r.priority DESC, r.created_at DESC + """), params) + return {"success": True, "data": _rows_to_dict(rows)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.post("/routes") +async def create_route( + channel_id: str = Query(...), + agent_id: str = Query(...), + route_name: str = Query("default"), + match_type: str = Query("all"), + match_pattern: Optional[str] = Query(None), + priority: int = Query(0), + fallback_agent_id: Optional[str] = Query(None), + user=Depends(get_current_user) +): + """Create a routing rule""" + db = SessionLocal() + try: + route_id = str(uuid.uuid4()) + _execute_raw(db, text(""" + INSERT INTO im_bot_routes + (id, channel_id, agent_id, route_name, match_type, match_pattern, priority, fallback_agent_id) + VALUES (:id, :cid, :aid, :name, :mt, :mp, :pri, :faid) + """), { + "id": route_id, "cid": channel_id, "aid": agent_id, + "name": route_name, "mt": match_type, "mp": match_pattern, + "pri": priority, "faid": fallback_agent_id, + }) + return {"success": True, "data": {"id": route_id}} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.put("/routes/{route_id}") +async def update_route( + route_id: str, + agent_id: Optional[str] = Query(None), + route_name: Optional[str] = Query(None), + match_type: Optional[str] = Query(None), + match_pattern: Optional[str] = Query(None), + priority: Optional[int] = Query(None), + fallback_agent_id: Optional[str] = Query(None), + is_active: Optional[int] = Query(None), + user=Depends(get_current_user) +): + """Update a routing rule""" + db = SessionLocal() + try: + updates = [] + params = {"id": route_id} + for field, val in [("agent_id", agent_id), ("route_name", route_name), + ("match_type", match_type), ("match_pattern", match_pattern), + ("priority", priority), ("fallback_agent_id", fallback_agent_id), + ("is_active", is_active)]: + if val is not None: + updates.append(f"{field} = :{field}") + params[field] = val + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + sql = f"UPDATE im_bot_routes SET {', '.join(updates)} WHERE id = :id" + _execute_raw(db, text(sql), params) + return {"success": True, "message": "Route updated"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.delete("/routes/{route_id}") +async def delete_route(route_id: str, user=Depends(get_current_user)): + """Delete a routing rule""" + db = SessionLocal() + try: + _execute_raw(db, text("DELETE FROM im_bot_routes WHERE id = :id"), {"id": route_id}) + return {"success": True, "message": "Route deleted"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# ============================================================ +# 个人微信 (iLink 协议 / 微信 ClawBot 插件) 原生适配器 +# 直接对接腾讯官方 iLink Bot HTTP API (ilinkai.weixin.qq.com) +# 扫码登录免 AppID;消息收发走 Bearer bot_token + X-WECHAT-UIN +# ============================================================ + +ILINK_BASE = "https://ilinkai.weixin.qq.com" +_wechat_receiver_threads = {} + + +def _wechat_headers(bot_token: str) -> dict: + """iLink 协议请求头:Bearer bot_token + 随机 X-WECHAT-UIN 防重放""" + import base64, random + uin = base64.b64encode(str(random.randint(0, 2 ** 32 - 1)).encode("utf-8")).decode("utf-8") + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {bot_token}", + "AuthorizationType": "ilink_bot_token", + "X-WECHAT-UIN": uin, + } + + +def _wechat_row(db): + rows = _execute_raw(db, text("SELECT * FROM im_bot_wechat_account WHERE user_id IS NULL ORDER BY id LIMIT 1")) + return rows[0] if rows else None + + +def _wechat_mask(row) -> dict: + d = _row_to_dict(row) + if d.get("bot_token"): + bt = d["bot_token"] + d["bot_token"] = ("***" + bt[-6:]) if len(bt) > 6 else "***" + return d + + +def _qihua_reply(text: str) -> str: + """调用七虎AI 生成回复(复用 channels 的真实 LLM 调用)。 + 自行创建 DB 会话,避免上层线程无可用 session 时崩溃。""" + db = None + try: + from app.api.channels import _call_real_llm + db = SessionLocal() + return _call_real_llm(db, None, text, "个人微信") + except Exception as e: + return f"(AI 回复生成失败:{e})" + finally: + if db: + db.close() + + +def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str): + """长轮询收消息,返回 (msgs, new_cursor)。最多 hold 35s。""" + try: + resp = requests.post( + baseurl.rstrip("/") + "/ilink/bot/getupdates", + headers=_wechat_headers(bot_token), + json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}}, + timeout=40, + ) + if not resp.ok: + return [], cursor + j = resp.json() if resp.content else {} + return j.get("msgs", []) or [], j.get("get_updates_buf", cursor) + except Exception: + return [], cursor + + +def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str) -> bool: + """发送文本回复(必须带 context_token 关联到正确的对话窗口)""" + try: + resp = requests.post( + baseurl.rstrip("/") + "/ilink/bot/sendmessage", + headers=_wechat_headers(bot_token), + json={"msg": { + "to_user_id": to_user_id, + "message_type": 2, + "message_state": 2, + "context_token": context_token, + "item_list": [{"type": 1, "text_item": {"text": text}}], + }}, + timeout=20, + ) + return resp.ok + except Exception: + return False + + +def _wechat_extract_text(msg: dict) -> str: + parts = [] + for it in (msg.get("item_list") or []): + if it.get("type") == 1 and it.get("text_item", {}).get("text"): + parts.append(it["text_item"]["text"]) + return "\n".join(parts) + + +def _wechat_receiver_loop(account_id: int): + """后台接收线程:长轮询个人微信消息 → 七虎AI 回复 → 发送。 + 底层 iLink 协议直连 ilinkai.weixin.qq.com,无需 AppID/SecretKey。""" + cursor = "" + while True: + db = SessionLocal() + try: + row = _wechat_row(db) + if not row: + return + d = _row_to_dict(row) + if d.get("status") != "connected" or not d.get("bot_token"): + return + bot_token = d["bot_token"] + baseurl = d.get("baseurl") or ILINK_BASE + msgs, cursor = _wechat_get_updates(baseurl, bot_token, cursor) + for m in msgs: + if m.get("message_type") not in (1, None): + continue # 只处理用户消息(2=bot 自己发出的) + content = _wechat_extract_text(m) + if not content: + continue + reply = _qihua_reply(content) + _wechat_send_message( + baseurl, bot_token, + m.get("from_user_id", ""), + m.get("context_token", ""), + reply, + ) + time.sleep(1) + except Exception: + time.sleep(5) + finally: + db.close() + + +def _start_wechat_receiver(account_id: int): + if account_id in _wechat_receiver_threads and _wechat_receiver_threads[account_id].is_alive(): + return + t = threading.Thread(target=_wechat_receiver_loop, args=(account_id,), daemon=True) + t.start() + _wechat_receiver_threads[account_id] = t + + +@router.get("/wechat") +async def get_wechat_config(user=Depends(get_current_user)): + """获取个人微信(Clawbot)适配器配置(敏感字段脱敏)""" + db = SessionLocal() + try: + row = _wechat_row(db) + if not row: + return {"success": True, "data": None} + return {"success": True, "data": _wechat_mask(row)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# (POST /wechat 凭证保存端点已移除:iLink 扫码登录无需 AppID/SecretKey) + + +@router.post("/wechat/qrcode") +async def wechat_qrcode(user=Depends(get_current_user)): + """获取个人微信扫码登录二维码(iLink 协议,免 AppID)""" + try: + resp = requests.get( + ILINK_BASE + "/ilink/bot/get_bot_qrcode", + params={"bot_type": 3}, + timeout=20, + ) + if not resp.ok: + return {"success": False, "error": f"获取二维码失败 HTTP {resp.status_code}"} + j = resp.json() if resp.content else {} + return {"success": True, "data": { + "qrcode": j.get("qrcode"), + "qrcode_img_content": j.get("qrcode_img_content"), + }} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/wechat/qrcode/status") +async def wechat_qrcode_status(qrcode: str = Query(...), user=Depends(get_current_user)): + """轮询扫码状态;confirmed 时保存 bot_token 并启动接收线程""" + try: + resp = requests.get( + ILINK_BASE + "/ilink/bot/get_qrcode_status", + params={"qrcode": qrcode}, + timeout=40, + ) + if not resp.ok: + return {"success": False, "error": f"查询状态失败 HTTP {resp.status_code}"} + j = resp.json() if resp.content else {} + data = j.get("data", j) if isinstance(j, dict) else j + status = data.get("status") + if status == "confirmed": + # 兼容两种返回结构:顶层 或 credentials 嵌套 + creds = data.get("credentials", {}) or {} + bot_token = data.get("bot_token") or creds.get("bot_token") + baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE + ilink_bot_id = data.get("ilink_bot_id") or creds.get("ilink_bot_id") + db = SessionLocal() + try: + row = _wechat_row(db) + if not row: + _execute_raw(db, text("""INSERT INTO im_bot_wechat_account + (bot_token, ilink_bot_id, baseurl, status, connected_at, created_at, updated_at) + VALUES (:bot_token, :ilink_bot_id, :baseurl, 'connected', :connected_at, :created_at, :updated_at)"""), + {"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl, + "connected_at": datetime.now(), "created_at": datetime.now(), "updated_at": datetime.now()}) + else: + _execute_raw(db, text("""UPDATE im_bot_wechat_account SET + bot_token=:bot_token, ilink_bot_id=:ilink_bot_id, baseurl=:baseurl, + status='connected', connected_at=:connected_at, updated_at=:updated_at WHERE id=:id"""), + {"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl, + "connected_at": datetime.now(), "updated_at": datetime.now(), "id": _row_to_dict(row)["id"]}) + row = _wechat_row(db) + aid = _row_to_dict(row)["id"] if row else 1 + _start_wechat_receiver(aid) + finally: + db.close() + return {"success": True, "data": {"status": "confirmed", "connected": True}} + return {"success": True, "data": {"status": status, "connected": False}} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/wechat/status") +async def wechat_status(user=Depends(get_current_user)): + """个人微信连接状态""" + db = SessionLocal() + try: + row = _wechat_row(db) + if not row: + return {"success": True, "data": {"connected": False, "configured": False}} + d = _row_to_dict(row) + return {"success": True, "data": { + "connected": d.get("status") == "connected", + "configured": True, + "baseurl": d.get("baseurl"), + "ilink_bot_id": d.get("ilink_bot_id"), + "connected_at": str(d.get("connected_at")) if d.get("connected_at") else None, + }} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.post("/wechat/disconnect") +async def wechat_disconnect(user=Depends(get_current_user)): + """断开个人微信连接(清除 bot_token)""" + db = SessionLocal() + try: + row = _wechat_row(db) + if row: + _execute_raw(db, text("UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', updated_at=:updated_at WHERE id=:id"), + {"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]}) + return {"success": True, "message": "已断开个人微信连接"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# ============================================================ +# Platforms & Webhooks +# ============================================================ + +@router.get("/platforms") +async def list_platforms(user=Depends(get_current_user)): + """List all supported IM platforms with config schemas""" + platforms = [] + for key in ["feishu", "wechat", "personal_wechat", "wecom", "dingtalk", + "telegram", "whatsapp", "slack"]: + info = _get_platform_info(key) + info['key'] = key + platforms.append(info) + + # Count channels per platform + db = SessionLocal() + try: + rows = _execute_raw(db, text(""" + SELECT platform, COUNT(*) as count, + SUM(CASE WHEN status='connected' THEN 1 ELSE 0 END) as active + FROM channels + GROUP BY platform + """)) + counts = {r._mapping['platform']: {"count": r._mapping['count'], "active": r._mapping['active']} for r in rows} + for p in platforms: + c = counts.get(p['key'], {"count": 0, "active": 0}) + p['channel_count'] = c['count'] + p['active_count'] = c['active'] + + return {"success": True, "data": platforms} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.get("/webhook-url/{platform}") +async def get_webhook_url(platform: str, user=Depends(get_current_user)): + """Generate webhook URL for a platform""" + if platform not in ["feishu", "wechat", "personal_wechat", "wecom", "dingtalk", + "telegram", "whatsapp", "slack"]: + raise HTTPException(status_code=400, detail=f"Unsupported platform: {platform}") + + # Generate a webhook secret for verification + secret = hashlib.sha256(f"{platform}:{time.time()}".encode()).hexdigest()[:32] + webhook_url = f"/app/api/channels/webhook/{platform}" + + return { + "success": True, + "data": { + "platform": platform, + "platform_name": _get_platform_info(platform)['name'], + "webhook_url": webhook_url, + "full_url": f"http://210.16.161.218:12888{webhook_url}", + "secret": secret, + "instructions": _get_webhook_instructions(platform), + } + } + + +def _get_webhook_instructions(platform: str) -> list: + """Get setup instructions for each platform""" + guides = { + "feishu": [ + "1. 登录飞书开放平台,创建企业自建应用", + "2. 在「事件订阅」页面配置请求地址为上述 Webhook URL", + "3. 填入 App ID、App Secret 和 Verification Token", + "4. 订阅消息事件: im.message.receive_v1", + "5. 发布应用版本并等待管理员审批", + ], + "wechat": [ + "1. 登录微信公众平台,进入「设置与开发」", + "2. 在「基本配置」中填写服务器配置", + "3. URL 填入上述 Webhook URL", + "4. 填入 Token 和 EncodingAESKey", + "5. 启用服务器配置", + ], + "personal_wechat": [ + "1. 点击「扫码授权」按钮,使用手机微信扫码", + "2. 等待授权完成后,频道状态将变为「已连接」", + "3. 个人微信通过 ClawBot 协议接入,无需 Webhook 配置", + "4. 注意:个人微信接入存在封号风险,请谨慎使用", + ], + "wecom": [ + "1. 登录企业微信管理后台,创建自建应用", + "2. 在「接收消息」页面设置 API 接收", + "3. URL 填入上述 Webhook URL", + "4. 填入 Corp ID、Agent ID、Secret、Token 和 EncodingAESKey", + "5. 设置可信 IP 白名单", + ], + "dingtalk": [ + "1. 登录钉钉开放平台,创建企业内部应用", + "2. 在「事件订阅」中配置 HTTP 模式", + "3. 请求地址填入上述 Webhook URL", + "4. 填入 App Key 和 App Secret", + "5. 订阅消息接收事件", + ], + "telegram": [ + "1. 通过 @BotFather 创建 Bot,获取 Bot Token", + "2. 调用 Telegram API 设置 Webhook: setWebhook?url=<上述URL>", + "3. 填入 Bot Token", + ], + "whatsapp": [ + "1. 在 Meta for Developers 创建 WhatsApp Business 应用", + "2. 配置 Webhook 验证 Token", + "3. Webhook URL 填入上述地址", + "4. 订阅 messages 事件", + "5. 填入 Phone Number ID 和 Access Token", + ], + "slack": [ + "1. 在 api.slack.com 创建 Slack App", + "2. 启用 Event Subscriptions", + "3. Request URL 填入上述 Webhook URL", + "4. 订阅 message.im 事件", + "5. 填入 Bot Token 和 Signing Secret", + ], + } + return guides.get(platform, ["请参考平台官方文档进行配置"]) + + +# ============================================================ +# Messages & Logs +# ============================================================ + +@router.get("/messages") +async def list_messages( + channel_id: Optional[str] = Query(None), + direction: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + user=Depends(get_current_user) +): + """List messages across all channels""" + db = SessionLocal() + try: + where = ["1=1"] + params = {} + if channel_id: + where.append("channel_id = :cid") + params['cid'] = channel_id + if direction: + where.append("direction = :dir") + params['dir'] = direction + + where_sql = " AND ".join(where) + offset = (page - 1) * page_size + + count = _execute_raw(db, text(f"SELECT COUNT(*) as cnt FROM channel_messages WHERE {where_sql}"), params)[0]._mapping['cnt'] + + rows = _execute_raw(db, text(f""" + SELECT cm.*, c.name as channel_name, c.platform + FROM channel_messages cm + LEFT JOIN channels c ON cm.channel_id = c.id + WHERE {where_sql} + ORDER BY cm.created_at DESC + LIMIT :limit OFFSET :offset + """), {**params, "limit": page_size, "offset": offset}) + + messages = [] + for r in rows: + d = _row_to_dict(r) + d['platform_info'] = _get_platform_info(d.get('platform', '')) if d.get('platform') else None + messages.append(d) + + return { + "success": True, + "data": { + "items": messages, + "total": count, + "page": page, + "page_size": page_size, + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.get("/logs") +async def get_activity_logs( + channel_id: Optional[str] = Query(None), + limit: int = Query(100, ge=1, le=500), + user=Depends(get_current_user) +): + """Get recent activity logs""" + db = SessionLocal() + try: + where = "" + params = {"limit": limit} + if channel_id: + where = "WHERE channel_id = :cid" + params['cid'] = channel_id + + rows = _execute_raw(db, text(f""" + SELECT stat_date, channel_id, + messages_in, messages_out, unique_users, + avg_response_time_ms, error_count, tokens_used + FROM im_bot_daily_stats + {where} + ORDER BY stat_date DESC, channel_id + LIMIT :limit + """), params) + + return {"success": True, "data": _rows_to_dict(rows)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# ============================================================ +# Quick Setup Wizard +# ============================================================ + +@router.post("/quick-setup") +async def quick_setup( + platform: str = Query(...), + name: str = Query(...), + agent_id: Optional[str] = Query(None), + credentials: str = Query(None), + user=Depends(get_current_user) +): + """Quick setup wizard - create and configure a channel in one step""" + db = SessionLocal() + try: + if platform not in ["feishu", "wechat", "personal_wechat", "wecom", "dingtalk", + "telegram", "whatsapp", "slack"]: + raise HTTPException(status_code=400, detail=f"Unsupported platform: {platform}") + + channel_id = str(uuid.uuid4()) + webhook_url = f"/app/api/channels/webhook/{platform}" + + cred_json = None + if credentials: + try: + cred_json = json.loads(credentials) + except: + cred_json = {"raw": credentials} + + _execute_raw(db, text(""" + INSERT INTO channels (id, owner_id, name, platform, webhook_url, + credential, default_agent_id, status, is_active) + VALUES (:id, :owner, :name, :platform, :webhook, + :cred, :agent, 'pending', 1) + """), { + "id": channel_id, + "owner": "admin-001", + "name": name, + "platform": platform, + "webhook": webhook_url, + "cred": json.dumps(cred_json) if cred_json else None, + "agent": agent_id, + }) + + # Create default routing rule if agent specified + if agent_id: + route_id = str(uuid.uuid4()) + _execute_raw(db, text(""" + INSERT INTO im_bot_routes (id, channel_id, agent_id, route_name, match_type, priority) + VALUES (:rid, :cid, :aid, 'default', 'all', 0) + """), {"rid": route_id, "cid": channel_id, "aid": agent_id}) + + platform_info = _get_platform_info(platform) + + return { + "success": True, + "data": { + "channel_id": channel_id, + "webhook_url": f"http://210.16.161.218:12888{webhook_url}", + "platform": platform, + "platform_name": platform_info['name'], + "instructions": _get_webhook_instructions(platform), + "next_steps": "请按照上述说明在平台配置 Webhook,然后返回此页面点击「测试连接」", + } + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Quick setup error: {str(e)}") + finally: + db.close() diff --git a/backend/python/app/api/admin/knowledge_admin.py b/backend/python/app/api/admin/knowledge_admin.py new file mode 100644 index 0000000..68ec5d8 --- /dev/null +++ b/backend/python/app/api/admin/knowledge_admin.py @@ -0,0 +1,1330 @@ +""" +知识库管理 API(管理员) +Phase 2.4: AI PaaS Knowledge Base Management + External KB Integration +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from sqlalchemy import or_, func, desc, asc, text +from sqlalchemy.orm import Session +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( + text("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( + text(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(text(""" + 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( + text(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( + text("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( + text(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( + text("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(text("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( + text("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( + text("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( + text("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( + text("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( + text("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( + text("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( + text("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( + text("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}} diff --git a/backend/python/app/api/admin/mcp_admin.py b/backend/python/app/api/admin/mcp_admin.py new file mode 100644 index 0000000..81e4457 --- /dev/null +++ b/backend/python/app/api/admin/mcp_admin.py @@ -0,0 +1,449 @@ +""" +MCP (Model Context Protocol) Server Management API +Phase 3.3: Manage MCP servers, discover tools/resources, test connections +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from sqlalchemy import or_, func, desc, asc, text +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime +import uuid +import json +import logging + +from ...database import get_db +from ...models import gen_uuid +from ...auth import get_current_admin +from ...middleware.log_service import LogService + +router = APIRouter() +logger = logging.getLogger("aiflow") + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class MCPServerCreate(BaseModel): + name: str + description: Optional[str] = "" + transport_type: str = "stdio" + command: Optional[str] = "" + args: Optional[Any] = None + env_vars: Optional[Dict[str, str]] = None + url: Optional[str] = "" + headers: Optional[Dict[str, str]] = None + is_enabled: Optional[bool] = True + + +class MCPServerUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + transport_type: Optional[str] = None + command: Optional[str] = None + args: Optional[Any] = None + env_vars: Optional[Dict[str, str]] = None + url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + is_enabled: Optional[bool] = None + + +class MCPServerConnectionTest(BaseModel): + transport_type: Optional[str] = None + command: Optional[str] = None + args: Optional[Any] = None + env_vars: Optional[Dict[str, str]] = None + url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + + +# ============================================================ +# Helper Functions +# ============================================================ + +def _format_cst_time(dt): + """Format datetime to CST string""" + if dt is None: + return None + try: + from datetime import timezone, timedelta + CST = timezone(timedelta(hours=8)) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=CST) + return dt.astimezone(CST).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None + + +def server_to_dict(server) -> dict: + """Convert MCPServer model to dict""" + return { + "id": server.id, + "name": server.name, + "description": server.description or "", + "transport_type": server.transport_type or "stdio", + "transport_type_label": {"stdio": "STDIO", "sse": "SSE"}.get(server.transport_type, server.transport_type), + "command": server.command or "", + "args": json.loads(server.args) if server.args else {}, + "env_vars": json.loads(server.env_vars) if server.env_vars else {}, + "url": server.url or "", + "headers": json.loads(server.headers) if server.headers else {}, + "is_enabled": server.is_enabled if server.is_enabled is not None else True, + "status": server.status or "disconnected", + "last_connected_at": _format_cst_time(server.last_connected_at), + "tools_count": server.tools_count or 0, + "resources_count": server.resources_count or 0, + "created_at": _format_cst_time(server.created_at), + "updated_at": _format_cst_time(server.updated_at), + } + + +# ============================================================ +# API Endpoints +# ============================================================ + +@router.get("/stats/overview", summary="Get MCP server statistics") +async def get_mcp_stats( + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Get overview statistics for MCP servers""" + from ...models import MCPServer + total = db.query(func.count(MCPServer.id)).scalar() or 0 + enabled = db.query(func.count(MCPServer.id)).filter(MCPServer.is_enabled == True).scalar() or 0 + connected = db.query(func.count(MCPServer.id)).filter(MCPServer.status == "connected").scalar() or 0 + + # By transport type + by_transport = {} + rows = db.query(MCPServer.transport_type, func.count(MCPServer.id)).group_by(MCPServer.transport_type).all() + for t, c in rows: + by_transport[t or "unknown"] = c + + # By status + by_status = {} + rows = db.query(MCPServer.status, func.count(MCPServer.id)).group_by(MCPServer.status).all() + for s, c in rows: + by_status[s or "unknown"] = c + + return { + "code": 0, + "data": { + "total_servers": total, + "enabled_servers": enabled, + "connected_servers": connected, + "by_transport": by_transport, + "by_status": by_status, + }, + "message": "ok", + } + + +@router.get("/", summary="List MCP servers") +async def list_mcp_servers( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: Optional[str] = Query(None), + transport_type: Optional[str] = Query(None), + status: Optional[str] = Query(None), + is_enabled: Optional[bool] = Query(None), + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """List all MCP servers with pagination and filters""" + from ...models import MCPServer + query = db.query(MCPServer) + + if search: + query = query.filter( + or_(MCPServer.name.contains(search), MCPServer.description.contains(search)) + ) + if transport_type: + query = query.filter(MCPServer.transport_type == transport_type) + if status: + query = query.filter(MCPServer.status == status) + if is_enabled is not None: + query = query.filter(MCPServer.is_enabled == is_enabled) + + total = query.count() + servers = query.order_by(desc(MCPServer.created_at)).offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [server_to_dict(s) for s in servers], + }, + "message": "ok", + } + + +@router.get("/{server_id}", summary="Get MCP server details") +async def get_mcp_server( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Get a single MCP server by ID""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + return {"code": 0, "data": server_to_dict(server), "message": "ok"} + + +@router.post("/", summary="Create MCP server") +async def create_mcp_server( + data: MCPServerCreate, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Register a new MCP server""" + from ...models import MCPServer + + # Check name uniqueness + existing = db.query(MCPServer).filter(MCPServer.name == data.name).first() + if existing: + raise HTTPException(status_code=400, detail="MCP server with this name already exists") + + now = datetime.now() + server = MCPServer( + id=gen_uuid(), + name=data.name, + description=data.description or "", + transport_type=data.transport_type, + command=data.command or "", + args=json.dumps(data.args) if data.args else "{}", + env_vars=json.dumps(data.env_vars) if data.env_vars else "{}", + url=data.url or "", + headers=json.dumps(data.headers) if data.headers else "{}", + is_enabled=data.is_enabled if data.is_enabled is not None else True, + status="disconnected", + created_at=now, + updated_at=now, + ) + db.add(server) + db.commit() + db.refresh(server) + + logger.info(f"MCP server created: {server.name} ({server.id})") + return {"code": 0, "data": server_to_dict(server), "message": "MCP server created"} + + +@router.put("/{server_id}", summary="Update MCP server") +async def update_mcp_server( + server_id: str, + data: MCPServerUpdate, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Update an existing MCP server""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + update_fields = data.model_dump(exclude_unset=True) + + # Handle JSON fields + for json_field in ["args", "env_vars", "headers"]: + if json_field in update_fields and update_fields[json_field] is not None: + update_fields[json_field] = json.dumps(update_fields[json_field]) + + for key, value in update_fields.items(): + if value is not None: + setattr(server, key, value) + + server.updated_at = datetime.now() + db.commit() + db.refresh(server) + + logger.info(f"MCP server updated: {server.name} ({server.id})") + return {"code": 0, "data": server_to_dict(server), "message": "MCP server updated"} + + +@router.delete("/{server_id}", summary="Delete MCP server") +async def delete_mcp_server( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Delete an MCP server""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + name = server.name + db.delete(server) + db.commit() + + logger.info(f"MCP server deleted: {name} ({server_id})") + return {"code": 0, "data": None, "message": "MCP server deleted"} + + +@router.put("/{server_id}/toggle", summary="Enable/Disable MCP server") +async def toggle_mcp_server( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """Toggle MCP server enabled state""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + server.is_enabled = not server.is_enabled + server.updated_at = datetime.now() + db.commit() + + logger.info(f"MCP server toggled: {server.name} -> enabled={server.is_enabled}") + return { + "code": 0, + "data": {"id": server.id, "is_enabled": server.is_enabled}, + "message": "MCP server toggled", + } + + +@router.post("/{server_id}/test", summary="Test MCP server connection") +async def test_mcp_connection( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), + test_data: Optional[MCPServerConnectionTest] = None, +): + """Test connection to an MCP server""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + transport = test_data.transport_type if test_data and test_data.transport_type else server.transport_type + result = {"success": False, "message": "", "details": {}} + + try: + if transport == "sse": + sse_url = (test_data.url if test_data else None) or server.url + if not sse_url: + result["message"] = "SSE URL is required" + return {"code": 0, "data": result, "message": "ok"} + + # Test SSE endpoint with a simple HTTP request + import httpx + try: + response = httpx.get(sse_url, timeout=10.0) + result["success"] = response.status_code < 500 + result["message"] = f"SSE endpoint responded with {response.status_code}" + result["details"]["status_code"] = response.status_code + result["details"]["content_type"] = response.headers.get("content-type", "") + except httpx.ConnectError: + result["message"] = "Connection refused - server may be down" + except httpx.TimeoutException: + result["message"] = "Connection timed out" + + elif transport == "stdio": + cmd = (test_data.command if test_data else None) or server.command + if not cmd: + result["message"] = "STDIO command is required" + return {"code": 0, "data": result, "message": "ok"} + + # Basic command existence check (not actually executing MCP handshake) + import subprocess + try: + subprocess.run([cmd, "--version"], capture_output=True, timeout=10) + result["success"] = True + result["message"] = "Command found and executable" + except FileNotFoundError: + result["message"] = "Command not found in PATH" + except subprocess.TimeoutExpired: + result["message"] = "Command timed out" + except Exception as e: + result["message"] = f"Command test failed" + result["details"]["error"] = str(e) + + # Update server status + server.status = "connected" if result["success"] else "error" + if result["success"]: + server.last_connected_at = datetime.now() + server.updated_at = datetime.now() + db.commit() + + except Exception as e: + result["message"] = str(e) + result["details"]["error"] = str(e) + server.status = "error" + server.updated_at = datetime.now() + db.commit() + + return {"code": 0, "data": result, "message": "ok"} + + +@router.get("/{server_id}/tools", summary="List MCP server tools") +async def list_mcp_tools( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """List tools exposed by an MCP server (placeholder/mock)""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + # In a real implementation, this would connect to the MCP server and call tools/list + # For now, return mock data based on common MCP tools + mock_tools = [] + if server.transport_type == "stdio": + mock_tools = [ + {"name": "read_file", "description": "Read contents of a file"}, + {"name": "write_file", "description": "Write contents to a file"}, + {"name": "execute_command", "description": "Execute a shell command"}, + {"name": "search_content", "description": "Search for content in files"}, + ] + elif server.transport_type == "sse": + mock_tools = [ + {"name": "web_search", "description": "Search the web"}, + {"name": "fetch_url", "description": "Fetch content from URL"}, + {"name": "api_request", "description": "Make API requests"}, + ] + + return { + "code": 0, + "data": { + "server_name": server.name, + "tools": mock_tools, + "count": len(mock_tools), + }, + "message": "ok", + } + + +@router.get("/{server_id}/resources", summary="List MCP server resources") +async def list_mcp_resources( + server_id: str, + db: Session = Depends(get_db), + admin_user=Depends(get_current_admin), +): + """List resources exposed by an MCP server (placeholder/mock)""" + from ...models import MCPServer + server = db.query(MCPServer).filter(MCPServer.id == server_id).first() + if not server: + raise HTTPException(status_code=404, detail="MCP server not found") + + mock_resources = [ + {"uri": f"{server.name}://docs/readme", "name": "Documentation", "mime_type": "text/markdown"}, + {"uri": f"{server.name}://config/settings", "name": "Configuration", "mime_type": "application/json"}, + ] + + return { + "code": 0, + "data": { + "server_name": server.name, + "resources": mock_resources, + "count": len(mock_resources), + }, + "message": "ok", + } diff --git a/backend/python/app/api/admin/mcp_market_admin.py b/backend/python/app/api/admin/mcp_market_admin.py new file mode 100644 index 0000000..32fcd6a --- /dev/null +++ b/backend/python/app/api/admin/mcp_market_admin.py @@ -0,0 +1,1224 @@ +""" +MCP Tool Marketplace Admin API +Phase G1: MCP Marketplace Fusion with Goose +- MCP tool CRUD (creates MarketApp + McpToolConfig) +- Deep Link generation (goose://extension?...) +- Security audit status management +- Managed MCP server framework +- Analytics & search +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from sqlalchemy.orm import Session +from sqlalchemy import or_, func, desc, text +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime, timezone, timedelta +import uuid +import json +import base64 +import urllib.parse +import logging + +from ...database import get_db, SessionLocal +from ...models import MarketApp, gen_uuid +from ...auth import get_current_admin + +router = APIRouter() +logger = logging.getLogger("aiflow") + +CST = timezone(timedelta(hours=8)) + +# ============================================================ +# Constants +# ============================================================ + +MCP_CATEGORIES = { + "developer": "开发工具", + "database": "数据库", + "search": "搜索检索", + "file": "文件系统", + "browser": "浏览器", + "design": "设计创意", + "data": "数据处理", + "communication": "通信协作", + "cloud": "云服务", + "productivity": "效率工具", + "other": "其他", +} + +SERVER_TYPES = { + "stdio": "本地进程 (STDIO)", + "sse": "SSE 流式", + "streamable_http": "HTTP 流式", + "builtin": "内置扩展", +} + +SECURITY_STATUSES = { + "pending": "待审核", + "scanning": "扫描中", + "approved": "已通过", + "rejected": "已拒绝", + "failed": "扫描失败", +} + +RUNTIMES = { + "node": "Node.js", + "python": "Python", + "go": "Go", + "rust": "Rust", + "binary": "二进制", + "remote": "远程服务", +} + + +# ============================================================ +# Helper Functions +# ============================================================ + +def _format_cst(dt): + """Format datetime to CST string.""" + if dt is None: + return None + try: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=CST) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return str(dt) + + +def _execute_raw(db, sql, params=None): + """Execute raw SQL and return list of dicts.""" + result = db.execute(text(sql), params or {}) + try: + rows = result.fetchall() + return [dict(row._mapping) if hasattr(row, "_mapping") else dict(row) for row in rows] + except Exception: + return [] + + +def _parse_json(val): + """Safely parse JSON field.""" + if val is None: + return None + if isinstance(val, (dict, list)): + return val + if isinstance(val, str): + try: + return json.loads(val) + except Exception: + return None + return None + + +def _generate_deep_link(config: dict) -> str: + """ + Generate Goose deep link: goose://extension?... + Format: goose://extension?name=&cmd=&args=&env=&type= + """ + params = {} + name = config.get("name") or config.get("builtin_name") or "mcp-tool" + params["name"] = name + + st = config.get("server_type", "stdio") + if st == "builtin": + params["type"] = "builtin" + params["name"] = config.get("builtin_name", name) + elif st == "sse": + params["type"] = "sse" + if config.get("transport_url"): + params["url"] = config["transport_url"] + elif st == "streamable_http": + params["type"] = "streamable_http" + if config.get("transport_url"): + params["url"] = config["transport_url"] + else: # stdio + params["type"] = "stdio" + if config.get("command"): + params["cmd"] = config["command"] + if config.get("args"): + try: + args_val = config["args"] + if isinstance(args_val, str): + args_val = json.loads(args_val) + params["args"] = base64.b64encode( + json.dumps(args_val).encode() + ).decode() + except Exception: + pass + if config.get("env_vars"): + try: + env_val = config["env_vars"] + if isinstance(env_val, str): + env_val = json.loads(env_val) + params["env"] = base64.b64encode( + json.dumps(env_val).encode() + ).decode() + except Exception: + pass + + # Build URL + query = urllib.parse.urlencode(params, safe="/:") + return f"goose://extension?{query}" + + +def _basic_security_check(config: dict) -> dict: + """ + Basic security heuristic check (full scanner in G1-4). + Returns {status, issues, report}. + """ + issues = [] + risk_level = "low" + + command = (config.get("command") or "").lower() + args_str = json.dumps(config.get("args") or []) + env_str = json.dumps(config.get("env_vars") or {}) + + # Check for dangerous commands + dangerous_patterns = ["rm -rf", "mkfs", "dd if=", "chmod 777", "curl | sh", "wget | sh", "eval(", "exec(", "subprocess.call"] + all_text = f"{command} {args_str} {env_str}".lower() + for pat in dangerous_patterns: + if pat in all_text: + issues.append({"severity": "critical", "rule": f"dangerous_pattern:{pat}", "message": f"检测到危险命令模式: {pat}"}) + risk_level = "critical" + + # Check env vars for secrets patterns + env_vars = _parse_json(config.get("env_vars")) or {} + for key in env_vars: + kl = key.lower() + if any(s in kl for s in ["secret", "password", "token", "api_key", "private_key"]): + issues.append({"severity": "warning", "rule": "env_secret", "message": f"环境变量 {key} 可能包含敏感信息,建议使用密钥管理"}) + if risk_level == "low": + risk_level = "medium" + + # Check package source + registry = config.get("package_registry") + pkg_name = config.get("package_name") or "" + if registry == "custom" or (not registry and config.get("package_repo_url")): + issues.append({"severity": "info", "rule": "custom_registry", "message": "使用自定义包源,建议人工审核"}) + if risk_level == "low": + risk_level = "low" + + # Check network access + if config.get("transport_url") or config.get("is_managed"): + issues.append({"severity": "info", "rule": "network_access", "message": "该工具需要网络访问"}) + + # Determine status + if risk_level == "critical": + status = "rejected" + elif risk_level == "medium": + status = "pending" # needs manual review + else: + status = "approved" + + return { + "status": status, + "risk_level": risk_level, + "issues_count": len(issues), + "issues": issues, + "scanned_at": datetime.now(CST).strftime("%Y-%m-%d %H:%M:%S"), + } + + +def _tool_config_to_dict(row: dict, app: dict = None) -> dict: + """Convert mcp_tool_config raw row to dict with parsed JSON fields.""" + d = dict(row) + for k in ["args", "env_vars", "security_report", "permissions", "config_schema", "default_config", "tools_list"]: + if k in d: + d[k] = _parse_json(d[k]) + d["created_at"] = _format_cst(d.get("created_at")) + d["updated_at"] = _format_cst(d.get("updated_at")) + d["security_scanned_at"] = _format_cst(d.get("security_scanned_at")) + if app: + d["app"] = app + return d + + +def _app_to_dict(app) -> dict: + """Convert MarketApp ORM to dict.""" + return { + "id": app.id, + "name": app.name, + "type": app.type, + "category": getattr(app, "category", "other"), + "tags": getattr(app, "tags", None), + "source": app.source, + "publisher_name": app.publisher_name, + "description": app.description, + "icon": app.icon, + "price": float(app.price) if app.price else 0.0, + "is_member_free": bool(app.is_member_free), + "featured": bool(getattr(app, "featured", False)), + "status": app.status, + "rating_avg": float(app.rating_avg) if app.rating_avg else 0.0, + "review_count": getattr(app, "review_count", 0), + "install_count": getattr(app, "install_count", 0), + "version": getattr(app, "version", "1.0.0"), + "created_at": _format_cst(app.created_at), + } + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class MCPToolCreate(BaseModel): + name: str = Field(..., max_length=200) + description: Optional[str] = "" + icon: Optional[str] = "" + category: Optional[str] = "developer" + tags: Optional[str] = "" + # MarketApp fields + price: Optional[float] = 0.0 + is_member_free: Optional[bool] = False + featured: Optional[bool] = False + # MCP Config + server_type: str = "stdio" + runtime: Optional[str] = None + command: Optional[str] = None + args: Optional[List[str]] = None + env_vars: Optional[Dict[str, str]] = None + transport_url: Optional[str] = None + builtin_name: Optional[str] = None + is_managed: Optional[bool] = False + managed_port: Optional[int] = None + # Package + package_name: Optional[str] = None + package_version: Optional[str] = "latest" + package_registry: Optional[str] = None + package_repo_url: Optional[str] = None + # Metadata + permissions: Optional[Dict[str, Any]] = None + config_schema: Optional[Dict[str, Any]] = None + default_config: Optional[Dict[str, Any]] = None + install_instructions: Optional[str] = None + tools_count: Optional[int] = 0 + tools_list: Optional[List[str]] = None + author_name: Optional[str] = None + author_email: Optional[str] = None + license: Optional[str] = "MIT" + homepage_url: Optional[str] = None + + +class MCPToolUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + category: Optional[str] = None + tags: Optional[str] = None + price: Optional[float] = None + is_member_free: Optional[bool] = None + featured: Optional[bool] = None + status: Optional[str] = None + # MCP Config + server_type: Optional[str] = None + runtime: Optional[str] = None + command: Optional[str] = None + args: Optional[List[str]] = None + env_vars: Optional[Dict[str, str]] = None + transport_url: Optional[str] = None + builtin_name: Optional[str] = None + is_managed: Optional[bool] = None + managed_port: Optional[int] = None + # Package + package_name: Optional[str] = None + package_version: Optional[str] = None + package_registry: Optional[str] = None + package_repo_url: Optional[str] = None + # Metadata + permissions: Optional[Dict[str, Any]] = None + config_schema: Optional[Dict[str, Any]] = None + default_config: Optional[Dict[str, Any]] = None + install_instructions: Optional[str] = None + tools_count: Optional[int] = None + tools_list: Optional[List[str]] = None + author_name: Optional[str] = None + author_email: Optional[str] = None + license: Optional[str] = None + homepage_url: Optional[str] = None + + +class SecurityStatusUpdate(BaseModel): + security_status: str # pending/scanning/approved/rejected/failed + security_report: Optional[Dict[str, Any]] = None + + +class ManagedServerCreate(BaseModel): + name: str + config_id: str # mcp_tool_configs.id + port: Optional[int] = None + + +# ============================================================ +# Routes +# ============================================================ + +@router.get("/stats", summary="MCP 市场统计") +async def get_mcp_market_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 MCP 工具市场总览数据""" + # Total by status + status_rows = _execute_raw(db, """ + SELECT security_status, COUNT(*) as cnt + FROM mcp_tool_configs + GROUP BY security_status + """) + status_map = {r["security_status"]: r["cnt"] for r in status_rows} + + # Total by server_type + type_rows = _execute_raw(db, """ + SELECT server_type, COUNT(*) as cnt + FROM mcp_tool_configs + GROUP BY server_type + """) + type_map = {r["server_type"]: r["cnt"] for r in type_rows} + + # Total by category (from market_apps) + cat_rows = _execute_raw(db, """ + SELECT ma.category, COUNT(*) as cnt + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + GROUP BY ma.category + """) + cat_map = {r["category"]: r["cnt"] for r in cat_rows} + + # Managed vs non-managed + managed_count = _execute_raw(db, "SELECT COUNT(*) as cnt FROM mcp_tool_configs WHERE is_managed=1") + managed = managed_count[0]["cnt"] if managed_count else 0 + + # Published apps + published = _execute_raw(db, """ + SELECT COUNT(*) as cnt FROM market_apps WHERE type='mcp_tool' AND status='published' + """) + pub_count = published[0]["cnt"] if published else 0 + + # Pending review + pending = _execute_raw(db, """ + SELECT COUNT(*) as cnt FROM mcp_tool_configs WHERE security_status IN ('pending','scanning') + """) + pending_count = pending[0]["cnt"] if pending else 0 + + total = sum(status_map.values()) + + return { + "code": 0, + "data": { + "total": total, + "published": pub_count, + "pending_review": pending_count, + "managed_servers": managed, + "by_security_status": status_map, + "by_server_type": type_map, + "by_category": cat_map, + } + } + + +@router.get("/", summary="MCP 工具列表") +async def list_mcp_tools( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: Optional[str] = Query(None), + category: Optional[str] = Query(None), + server_type: Optional[str] = Query(None), + security_status: Optional[str] = Query(None), + is_managed: Optional[bool] = Query(None), + sort: str = Query("newest"), # newest/popular/rating/name + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 MCP 工具列表(分页、搜索、过滤)""" + where_parts = ["ma.type = 'mcp_tool'"] + params = {} + + if keyword: + where_parts.append("(ma.name LIKE :kw OR ma.description LIKE :kw OR mc.package_name LIKE :kw)") + params["kw"] = f"%{keyword}%" + if category: + where_parts.append("ma.category = :cat") + params["cat"] = category + if server_type: + where_parts.append("mc.server_type = :st") + params["st"] = server_type + if security_status: + where_parts.append("mc.security_status = :ss") + params["ss"] = security_status + if is_managed is not None: + where_parts.append("mc.is_managed = :mg") + params["mg"] = 1 if is_managed else 0 + + where_clause = " AND ".join(where_parts) + + # Sort + order_map = { + "newest": "mc.created_at DESC", + "popular": "ma.install_count DESC", + "rating": "ma.rating_avg DESC", + "name": "ma.name ASC", + } + order = order_map.get(sort, order_map["newest"]) + + # Count + count_sql = f"SELECT COUNT(*) as cnt FROM mcp_tool_configs mc JOIN market_apps ma ON mc.app_id = ma.id WHERE {where_clause}" + count_rows = _execute_raw(db, count_sql, params) + total = count_rows[0]["cnt"] if count_rows else 0 + + # Query + offset = (page - 1) * page_size + query_sql = f""" + SELECT mc.*, ma.name, ma.description, ma.icon, ma.category, ma.tags, + ma.status as app_status, ma.publisher_name, ma.price, ma.is_member_free, + ma.featured, ma.rating_avg, ma.review_count, ma.install_count, + ma.version, ma.created_at as app_created_at + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE {where_clause} + ORDER BY {order} + LIMIT :limit OFFSET :offset + """ + params["limit"] = page_size + params["offset"] = offset + rows = _execute_raw(db, query_sql, params) + + items = [] + for row in rows: + item = { + "id": row["id"], + "app_id": row["app_id"], + "name": row["name"], + "description": row["description"], + "icon": row["icon"], + "category": row["category"], + "tags": row["tags"], + "status": row["app_status"], + "publisher_name": row["publisher_name"], + "price": float(row["price"]) if row["price"] else 0.0, + "is_member_free": bool(row["is_member_free"]), + "featured": bool(row["featured"]), + "rating_avg": float(row["rating_avg"]) if row["rating_avg"] else 0.0, + "review_count": row["review_count"], + "install_count": row["install_count"], + "version": row["version"], + "server_type": row["server_type"], + "runtime": row["runtime"], + "command": row["command"], + "is_managed": bool(row["is_managed"]), + "security_status": row["security_status"], + "security_issues": row["security_issues"], + "deep_link": row["deep_link"], + "package_name": row["package_name"], + "package_registry": row["package_registry"], + "tools_count": row["tools_count"], + "created_at": _format_cst(row["created_at"]), + } + items.append(item) + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": items, + } + } + + +@router.get("/categories", summary="MCP 工具分类列表") +async def get_categories( + admin_user=Depends(get_current_admin), +): + """获取 MCP 工具分类及服务类型选项""" + return { + "code": 0, + "data": { + "categories": MCP_CATEGORIES, + "server_types": SERVER_TYPES, + "security_statuses": SECURITY_STATUSES, + "runtimes": RUNTIMES, + } + } + + +@router.post("/", summary="创建 MCP 工具") +async def create_mcp_tool( + body: MCPToolCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """创建新的 MCP 工具(同时创建 MarketApp + McpToolConfig)""" + # Validate server_type + if body.server_type not in SERVER_TYPES: + raise HTTPException(400, f"Invalid server_type: {body.server_type}") + + # Validate stdio requires command + if body.server_type == "stdio" and not body.command: + raise HTTPException(400, "stdio server_type requires 'command' field") + + # Validate builtin requires builtin_name + if body.server_type == "builtin" and not body.builtin_name: + raise HTTPException(400, "builtin server_type requires 'builtin_name' field") + + # Validate sse/streamable_http requires transport_url + if body.server_type in ("sse", "streamable_http") and not body.transport_url: + raise HTTPException(400, f"{body.server_type} server_type requires 'transport_url' field") + + app_id = str(uuid.uuid4()) + config_id = str(uuid.uuid4()) + now = datetime.now(CST).strftime("%Y-%m-%d %H:%M:%S") + + # 1. Create MarketApp + db.execute(text(""" + INSERT INTO market_apps (id, name, type, category, tags, source, publisher_name, + description, icon, price, is_member_free, featured, status, version, created_at, updated_at) + VALUES (:id, :name, 'mcp_tool', :category, :tags, 'official', :publisher, + :description, :icon, :price, :member_free, :featured, 'published', '1.0.0', NOW(), NOW()) + """), { + "id": app_id, + "name": body.name, + "category": body.category or "developer", + "tags": body.tags or "", + "publisher": body.author_name or "Platform", + "description": body.description or "", + "icon": body.icon or "", + "price": 0.0, # 管理员导入/安装/开发的 MCP 默认免费 + "member_free": 1 if body.is_member_free else 0, + "featured": 1 if body.featured else 0, + }) + + # 2. Build config dict for deep link and security check + config_for_dl = { + "name": body.name, + "server_type": body.server_type, + "command": body.command, + "args": body.args, + "env_vars": body.env_vars, + "transport_url": body.transport_url, + "builtin_name": body.builtin_name, + } + deep_link = _generate_deep_link(config_for_dl) + + # 3. Basic security check + sec_result = _basic_security_check({ + "command": body.command, + "args": body.args, + "env_vars": body.env_vars, + "package_registry": body.package_registry, + "package_name": body.package_name, + "package_repo_url": body.package_repo_url, + "transport_url": body.transport_url, + "is_managed": body.is_managed, + }) + + # 4. Create McpToolConfig + args_json = json.dumps(body.args) if body.args else None + env_json = json.dumps(body.env_vars) if body.env_vars else None + permissions_json = json.dumps(body.permissions) if body.permissions else None + config_schema_json = json.dumps(body.config_schema) if body.config_schema else None + default_config_json = json.dumps(body.default_config) if body.default_config else None + tools_list_json = json.dumps(body.tools_list) if body.tools_list else None + sec_report_json = json.dumps(sec_result) if sec_result else None + + db.execute(text(""" + INSERT INTO mcp_tool_configs ( + id, app_id, server_type, runtime, command, args, env_vars, + transport_url, builtin_name, is_managed, managed_port, + package_name, package_version, package_registry, package_repo_url, + deep_link, security_status, security_report, security_scanned_at, security_issues, + permissions, config_schema, default_config, install_instructions, + tools_count, tools_list, author_name, author_email, license, homepage_url, + created_at, updated_at + ) VALUES ( + :id, :app_id, :server_type, :runtime, :command, :args, :env_vars, + :transport_url, :builtin_name, :is_managed, :managed_port, + :package_name, :package_version, :package_registry, :package_repo_url, + :deep_link, :sec_status, :sec_report, NOW(), :sec_issues, + :permissions, :config_schema, :default_config, :install_instructions, + :tools_count, :tools_list, :author_name, :author_email, :license, :homepage_url, + NOW(), NOW() + ) + """), { + "id": config_id, + "app_id": app_id, + "server_type": body.server_type, + "runtime": body.runtime, + "command": body.command, + "args": args_json, + "env_vars": env_json, + "transport_url": body.transport_url, + "builtin_name": body.builtin_name, + "is_managed": 1 if body.is_managed else 0, + "managed_port": body.managed_port, + "package_name": body.package_name, + "package_version": body.package_version or "latest", + "package_registry": body.package_registry, + "package_repo_url": body.package_repo_url, + "deep_link": deep_link, + "sec_status": sec_result["status"], + "sec_report": sec_report_json, + "sec_issues": sec_result["issues_count"], + "permissions": permissions_json, + "config_schema": config_schema_json, + "default_config": default_config_json, + "install_instructions": body.install_instructions, + "tools_count": body.tools_count or 0, + "tools_list": tools_list_json, + "author_name": body.author_name, + "author_email": body.author_email, + "license": body.license or "MIT", + "homepage_url": body.homepage_url, + }) + + db.commit() + + return { + "code": 0, + "message": "MCP 工具创建成功", + "data": { + "app_id": app_id, + "config_id": config_id, + "deep_link": deep_link, + "security_status": sec_result["status"], + "security_issues": sec_result["issues_count"], + } + } + + +@router.get("/{config_id}", summary="MCP 工具详情") +async def get_mcp_tool_detail( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 MCP 工具完整详情""" + rows = _execute_raw(db, """ + SELECT mc.*, ma.name, ma.description, ma.icon, ma.category, ma.tags, + ma.source, ma.publisher_name, ma.price, ma.is_member_free, + ma.featured, ma.featured_order, ma.status as app_status, + ma.rating_avg, ma.review_count, ma.install_count, ma.version, + ma.changelog, ma.download_url, ma.tutorial, + ma.created_at as app_created_at, ma.updated_at as app_updated_at + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + row = rows[0] + result = { + "id": row["id"], + "app_id": row["app_id"], + "app": { + "name": row["name"], + "description": row["description"], + "icon": row["icon"], + "category": row["category"], + "tags": row["tags"], + "source": row["source"], + "publisher_name": row["publisher_name"], + "price": float(row["price"]) if row["price"] else 0.0, + "is_member_free": bool(row["is_member_free"]), + "featured": bool(row["featured"]), + "status": row["app_status"], + "rating_avg": float(row["rating_avg"]) if row["rating_avg"] else 0.0, + "review_count": row["review_count"], + "install_count": row["install_count"], + "version": row["version"], + "tutorial": row["tutorial"], + "changelog": row["changelog"], + "download_url": row["download_url"], + "created_at": _format_cst(row["app_created_at"]), + "updated_at": _format_cst(row["app_updated_at"]), + }, + "server_type": row["server_type"], + "runtime": row["runtime"], + "command": row["command"], + "args": _parse_json(row["args"]), + "env_vars": _parse_json(row["env_vars"]), + "transport_url": row["transport_url"], + "builtin_name": row["builtin_name"], + "is_managed": bool(row["is_managed"]), + "managed_url": row["managed_url"], + "managed_port": row["managed_port"], + "package_name": row["package_name"], + "package_version": row["package_version"], + "package_registry": row["package_registry"], + "package_repo_url": row["package_repo_url"], + "deep_link": row["deep_link"], + "security_status": row["security_status"], + "security_report": _parse_json(row["security_report"]), + "security_scanned_at": _format_cst(row["security_scanned_at"]), + "security_issues": row["security_issues"], + "permissions": _parse_json(row["permissions"]), + "config_schema": _parse_json(row["config_schema"]), + "default_config": _parse_json(row["default_config"]), + "install_instructions": row["install_instructions"], + "tools_count": row["tools_count"], + "tools_list": _parse_json(row["tools_list"]), + "author_name": row["author_name"], + "author_email": row["author_email"], + "license": row["license"], + "homepage_url": row["homepage_url"], + "created_at": _format_cst(row["created_at"]), + "updated_at": _format_cst(row["updated_at"]), + } + + return {"code": 0, "data": result} + + +@router.put("/{config_id}", summary="更新 MCP 工具") +async def update_mcp_tool( + config_id: str, + body: MCPToolUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新 MCP 工具配置""" + # Check exists + existing = _execute_raw(db, "SELECT id, app_id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not existing: + raise HTTPException(404, "MCP 工具不存在") + + app_id = existing[0]["app_id"] + + # Update MarketApp fields + app_fields = {} + if body.name is not None: + app_fields["name"] = body.name + if body.description is not None: + app_fields["description"] = body.description + if body.icon is not None: + app_fields["icon"] = body.icon + if body.category is not None: + app_fields["category"] = body.category + if body.tags is not None: + app_fields["tags"] = body.tags + if body.price is not None: + app_fields["price"] = body.price + if body.is_member_free is not None: + app_fields["is_member_free"] = 1 if body.is_member_free else 0 + if body.featured is not None: + app_fields["featured"] = 1 if body.featured else 0 + if body.status is not None: + app_fields["status"] = body.status + + if app_fields: + set_parts = [f"{k} = :app_{k}" for k in app_fields] + app_params = {f"app_{k}": v for k, v in app_fields.items()} + app_params["aid"] = app_id + db.execute(text(f"UPDATE market_apps SET {', '.join(set_parts)}, updated_at=NOW() WHERE id = :aid"), app_params) + + # Update McpToolConfig fields + cfg_fields = {} + if body.server_type is not None: + cfg_fields["server_type"] = body.server_type + if body.runtime is not None: + cfg_fields["runtime"] = body.runtime + if body.command is not None: + cfg_fields["command"] = body.command + if body.args is not None: + cfg_fields["args"] = json.dumps(body.args) + if body.env_vars is not None: + cfg_fields["env_vars"] = json.dumps(body.env_vars) + if body.transport_url is not None: + cfg_fields["transport_url"] = body.transport_url + if body.builtin_name is not None: + cfg_fields["builtin_name"] = body.builtin_name + if body.is_managed is not None: + cfg_fields["is_managed"] = 1 if body.is_managed else 0 + if body.managed_port is not None: + cfg_fields["managed_port"] = body.managed_port + if body.package_name is not None: + cfg_fields["package_name"] = body.package_name + if body.package_version is not None: + cfg_fields["package_version"] = body.package_version + if body.package_registry is not None: + cfg_fields["package_registry"] = body.package_registry + if body.package_repo_url is not None: + cfg_fields["package_repo_url"] = body.package_repo_url + if body.permissions is not None: + cfg_fields["permissions"] = json.dumps(body.permissions) + if body.config_schema is not None: + cfg_fields["config_schema"] = json.dumps(body.config_schema) + if body.default_config is not None: + cfg_fields["default_config"] = json.dumps(body.default_config) + if body.install_instructions is not None: + cfg_fields["install_instructions"] = body.install_instructions + if body.tools_count is not None: + cfg_fields["tools_count"] = body.tools_count + if body.tools_list is not None: + cfg_fields["tools_list"] = json.dumps(body.tools_list) + if body.author_name is not None: + cfg_fields["author_name"] = body.author_name + if body.author_email is not None: + cfg_fields["author_email"] = body.author_email + if body.license is not None: + cfg_fields["license"] = body.license + if body.homepage_url is not None: + cfg_fields["homepage_url"] = body.homepage_url + + if cfg_fields: + # Regenerate deep link if transport-related fields changed + dl_fields = {"server_type", "command", "args", "env_vars", "transport_url", "builtin_name"} + if dl_fields & set(cfg_fields.keys()): + # Get current values for deep link regeneration + current = _execute_raw(db, """ + SELECT mc.*, ma.name FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + if current: + c = current[0] + dl_config = { + "name": body.name or c["name"], + "server_type": cfg_fields.get("server_type", c["server_type"]), + "command": cfg_fields.get("command", c["command"]), + "args": _parse_json(cfg_fields.get("args", c["args"])), + "env_vars": _parse_json(cfg_fields.get("env_vars", c["env_vars"])), + "transport_url": cfg_fields.get("transport_url", c["transport_url"]), + "builtin_name": cfg_fields.get("builtin_name", c["builtin_name"]), + } + cfg_fields["deep_link"] = _generate_deep_link(dl_config) + + set_parts = [f"{k} = :cfg_{k}" for k in cfg_fields] + cfg_params = {f"cfg_{k}": v for k, v in cfg_fields.items()} + cfg_params["cid"] = config_id + db.execute(text(f"UPDATE mcp_tool_configs SET {', '.join(set_parts)}, updated_at=NOW() WHERE id = :cid"), cfg_params) + + db.commit() + + return {"code": 0, "message": "更新成功"} + + +@router.delete("/{config_id}", summary="删除 MCP 工具") +async def delete_mcp_tool( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除 MCP 工具(同时删除 MarketApp 和 McpToolConfig)""" + existing = _execute_raw(db, "SELECT app_id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not existing: + raise HTTPException(404, "MCP 工具不存在") + + app_id = existing[0]["app_id"] + + db.execute(text("DELETE FROM mcp_tool_configs WHERE id = :cid"), {"cid": config_id}) + db.execute(text("UPDATE market_apps SET status='removed', updated_at=NOW() WHERE id = :aid"), {"aid": app_id}) + + db.commit() + + return {"code": 0, "message": "删除成功"} + + +@router.post("/{config_id}/security-scan", summary="触发安全扫描") +async def trigger_security_scan( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """对 MCP 工具执行安全扫描(基础版,完整版在 G1-4)""" + rows = _execute_raw(db, """ + SELECT mc.*, ma.name FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + row = rows[0] + + # Update status to scanning + db.execute(text("UPDATE mcp_tool_configs SET security_status='scanning' WHERE id = :cid"), {"cid": config_id}) + db.commit() + + # Run basic security check + sec_result = _basic_security_check({ + "command": row["command"], + "args": _parse_json(row["args"]), + "env_vars": _parse_json(row["env_vars"]), + "package_registry": row["package_registry"], + "package_name": row["package_name"], + "package_repo_url": row["package_repo_url"], + "transport_url": row["transport_url"], + "is_managed": row["is_managed"], + }) + + # Update with results + db.execute(text(""" + UPDATE mcp_tool_configs + SET security_status = :status, security_report = :report, + security_scanned_at = NOW(), security_issues = :issues, + updated_at = NOW() + WHERE id = :cid + """), { + "status": sec_result["status"], + "report": json.dumps(sec_result), + "issues": sec_result["issues_count"], + "cid": config_id, + }) + db.commit() + + return { + "code": 0, + "message": "安全扫描完成", + "data": sec_result, + } + + +@router.put("/{config_id}/security-status", summary="更新安全审核状态") +async def update_security_status( + config_id: str, + body: SecurityStatusUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """手动更新 MCP 工具的安全审核状态""" + if body.security_status not in SECURITY_STATUSES: + raise HTTPException(400, f"Invalid security_status: {body.security_status}") + + existing = _execute_raw(db, "SELECT id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not existing: + raise HTTPException(404, "MCP 工具不存在") + + report_json = json.dumps(body.security_report) if body.security_report else None + + db.execute(text(""" + UPDATE mcp_tool_configs + SET security_status = :status, security_report = COALESCE(:report, security_report), + security_scanned_at = NOW(), updated_at = NOW() + WHERE id = :cid + """), { + "status": body.security_status, + "report": report_json, + "cid": config_id, + }) + + # Auto-publish app if approved + if body.security_status == "approved": + app_row = _execute_raw(db, "SELECT app_id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if app_row: + db.execute(text("UPDATE market_apps SET status='published', updated_at=NOW() WHERE id = :aid"), + {"aid": app_row[0]["app_id"]}) + + db.commit() + + return {"code": 0, "message": "安全状态已更新"} + + +@router.post("/{config_id}/deep-link", summary="重新生成 Deep Link") +async def regenerate_deep_link( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """重新生成 Goose Deep Link URL""" + rows = _execute_raw(db, """ + SELECT mc.*, ma.name FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + row = rows[0] + config = { + "name": row["name"], + "server_type": row["server_type"], + "command": row["command"], + "args": _parse_json(row["args"]), + "env_vars": _parse_json(row["env_vars"]), + "transport_url": row["transport_url"], + "builtin_name": row["builtin_name"], + } + deep_link = _generate_deep_link(config) + + db.execute(text("UPDATE mcp_tool_configs SET deep_link = :dl, updated_at = NOW() WHERE id = :cid"), + {"dl": deep_link, "cid": config_id}) + db.commit() + + return {"code": 0, "data": {"deep_link": deep_link}} + + +@router.get("/managed/servers", summary="托管 MCP 服务器列表") +async def list_managed_servers( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取平台托管的 MCP 服务器列表""" + rows = _execute_raw(db, """ + SELECT mc.id, mc.managed_url, mc.managed_port, mc.server_type, + mc.tools_count, mc.tools_list, ma.name, ma.status, ma.description + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.is_managed = 1 + ORDER BY mc.created_at DESC + """) + + servers = [] + for row in rows: + servers.append({ + "id": row["id"], + "name": row["name"], + "description": row["description"], + "url": row["managed_url"], + "port": row["managed_port"], + "server_type": row["server_type"], + "status": row["status"], + "tools_count": row["tools_count"], + "tools_list": _parse_json(row["tools_list"]), + }) + + return {"code": 0, "data": {"servers": servers, "total": len(servers)}} + + +@router.post("/managed/{config_id}/start", summary="启动托管 MCP 服务器") +async def start_managed_server( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """启动一个托管 MCP 服务器(占位 - 完整实现在 G1-5)""" + rows = _execute_raw(db, "SELECT id, is_managed FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not rows: + raise HTTPException(404, "MCP 工具不存在") + if not rows[0]["is_managed"]: + raise HTTPException(400, "该工具不是托管类型") + + # Placeholder: in G1-5, this will spin up a Docker container or subprocess + managed_url = f"http://localhost:9100/mcp/{config_id[:8]}" + + db.execute(text(""" + UPDATE mcp_tool_configs SET managed_url = :url, updated_at = NOW() + WHERE id = :cid + """), {"url": managed_url, "cid": config_id}) + db.commit() + + return {"code": 0, "message": "托管服务器启动中(占位)", "data": {"url": managed_url}} + + +@router.post("/managed/{config_id}/stop", summary="停止托管 MCP 服务器") +async def stop_managed_server( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """停止一个托管 MCP 服务器(占位 - 完整实现在 G1-5)""" + rows = _execute_raw(db, "SELECT id, is_managed FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + db.execute(text("UPDATE mcp_tool_configs SET managed_url = NULL, updated_at = NOW() WHERE id = :cid"), + {"cid": config_id}) + db.commit() + + return {"code": 0, "message": "托管服务器已停止"} + + +# ============================================================ +# Seed Data: Pre-populate with popular Goose MCP extensions +# ============================================================ + +@router.post("/seed", summary="填充内置 MCP 工具") +async def seed_builtin_tools( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """填充 Goose 内置 MCP 扩展到市场(一次性操作)""" + # Check if already seeded + existing = _execute_raw(db, "SELECT COUNT(*) as cnt FROM mcp_tool_configs WHERE builtin_name IS NOT NULL") + if existing and existing[0]["cnt"] > 0: + return {"code": 0, "message": f"已有 {existing[0]['cnt']} 个内置工具,跳过填充"} + + builtin_tools = [ + { + "name": "Developer", "builtin_name": "developer", + "description": "Goose 内置开发工具 - 代码执行、文件操作、终端命令", + "category": "developer", "command": None, "server_type": "builtin", + "tools_count": 12, "tools_list": ["shell", "read_file", "write_file", "list_files", "grep", "edit_file", "patch", "undo_edit", "find_path", "text_search", "architect", "undo"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"filesystem": True, "terminal": True, "network": False}, + "install_instructions": "Goose Desktop 内置,无需安装。CLI 模式: goose session --type builtin --name developer", + }, + { + "name": "Computer Controller", "builtin_name": "computer-controller", + "description": "屏幕截图、鼠标键盘控制 - GUI 自动化操作", + "category": "productivity", "command": None, "server_type": "builtin", + "tools_count": 8, "tools_list": ["screenshot", "mouse_click", "mouse_move", "key_press", "key_type", "scroll", "wait", "find_element"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"screen": True, "input": True, "network": False}, + "install_instructions": "Goose Desktop 内置。需要授予屏幕录制和输入控制权限。", + }, + { + "name": "Memory", "builtin_name": "memory", + "description": "长期记忆存储 - 跨会话保存和检索信息", + "category": "assistant", "command": None, "server_type": "builtin", + "tools_count": 4, "tools_list": ["store", "retrieve", "search", "delete"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"filesystem": True, "terminal": False, "network": False}, + "install_instructions": "Goose Desktop 内置。记忆存储在 ~/.config/goose/memory/", + }, + { + "name": "JetBrains", "builtin_name": "jetbrains", + "description": "JetBrains IDE 集成 - 打开项目、执行操作", + "category": "developer", "command": None, "server_type": "builtin", + "tools_count": 6, "tools_list": ["open_project", "run_action", "get_open_file", "replace_in_file", "create_file", "get_errors"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"filesystem": True, "terminal": False, "network": False}, + "install_instructions": "需要安装 JetBrains MCP 插件。支持 IntelliJ IDEA, PyCharm, WebStorm, GoLand 等。", + }, + ] + + created = 0 + for tool in builtin_tools: + app_id = str(uuid.uuid4()) + config_id = str(uuid.uuid4()) + + # Create MarketApp + db.execute(text(""" + INSERT INTO market_apps (id, name, type, category, source, publisher_name, + description, price, is_member_free, featured, status, version, created_at, updated_at) + VALUES (:id, :name, 'mcp_tool', :category, 'official', :publisher, + :description, 0.0, 1, 0, 'published', '1.0.0', NOW(), NOW()) + """), { + "id": app_id, "name": tool["name"], "category": tool["category"], + "publisher": tool["author_name"], "description": tool["description"], + }) + + # Create McpToolConfig + deep_link = _generate_deep_link(tool) + sec_result = _basic_security_check(tool) + + db.execute(text(""" + INSERT INTO mcp_tool_configs ( + id, app_id, server_type, builtin_name, deep_link, + security_status, security_report, security_scanned_at, security_issues, + permissions, install_instructions, tools_count, tools_list, + author_name, license, created_at, updated_at + ) VALUES ( + :id, :app_id, 'builtin', :builtin_name, :deep_link, + :sec_status, :sec_report, NOW(), 0, + :permissions, :install_instructions, :tools_count, :tools_list, + :author_name, :license, NOW(), NOW() + ) + """), { + "id": config_id, "app_id": app_id, "builtin_name": tool["builtin_name"], + "deep_link": deep_link, "sec_status": "approved", + "sec_report": json.dumps(sec_result), + "permissions": json.dumps(tool["permissions"]), + "install_instructions": tool["install_instructions"], + "tools_count": tool["tools_count"], + "tools_list": json.dumps(tool["tools_list"]), + "author_name": tool["author_name"], "license": tool["license"], + }) + created += 1 + + db.commit() + + return {"code": 0, "message": f"成功填充 {created} 个内置 MCP 工具", "data": {"created": created}} diff --git a/backend/python/app/api/admin/privacy_admin.py b/backend/python/app/api/admin/privacy_admin.py new file mode 100644 index 0000000..522a704 --- /dev/null +++ b/backend/python/app/api/admin/privacy_admin.py @@ -0,0 +1,799 @@ +""" +Phase G5: Privacy & Local Agent Administration API +- Privacy config management (local/cloud/hybrid modes) +- Offline license generation & validation (JWT-based) +- Knowledge base sync state tracking +- Anonymized usage reporting +""" +import json +import hashlib +import hmac +import time +import uuid +from datetime import datetime, timedelta +from fastapi import APIRouter, HTTPException, Depends, Query +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ...database import SessionLocal +from ...auth import get_current_user + +router = APIRouter() + +PRIVACY_SECRET = "g5-privacy-secret-key-2026" +LICENSE_SECRET = "g5-license-hmac-secret-2026" + +# ======================================================================== +# Helpers +# ======================================================================== +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +def _execute_raw(db: Session, sql_text, params=None): + """Execute raw SQL and return results""" + result = db.execute(sql_text, params or {}) + if result.returns_rows: + return result.fetchall() + db.commit() + return result + +def _row_to_dict(row, keys): + """Convert raw SQL row to dict""" + if row is None: + return None + d = {} + for i, k in enumerate(keys): + val = row[i] + if isinstance(val, datetime): + val = val.strftime("%Y-%m-%d %H:%M:%S") + d[k] = val + return d + +def _rows_to_list(rows, keys): + return [_row_to_dict(r, keys) for r in rows] + +def _anonymize_user_id(user_id: str) -> str: + """SHA256 hash for anonymized usage reporting""" + return hashlib.sha256(f"{user_id}:{PRIVACY_SECRET}".encode()).hexdigest() + +def _generate_offline_license(user_id: str, agent_id: str, max_days: int = 7) -> dict: + """Generate JWT-like offline license with HMAC signature""" + now = int(time.time()) + expires_at = now + max_days * 86400 + payload = { + "user_id": user_id, + "agent_id": agent_id, + "issued_at": now, + "expires_at": expires_at, + "max_offline_days": max_days, + "jti": uuid.uuid4().hex[:16], + "type": "offline_license" + } + payload_bytes = json.dumps(payload, sort_keys=True).encode() + signature = hmac.new( + LICENSE_SECRET.encode(), + payload_bytes, + hashlib.sha256 + ).hexdigest() + license_key = f"{payload_bytes.decode()}.{signature}" + return { + "license_key": license_key, + "payload": payload, + "expires_at": datetime.fromtimestamp(expires_at), + } + +def _validate_offline_license(license_key: str) -> dict: + """Validate offline license signature and expiry""" + if "." not in license_key: + raise ValueError("Invalid license format") + payload_str, signature = license_key.rsplit(".", 1) + expected_sig = hmac.new( + LICENSE_SECRET.encode(), + payload_str.encode(), + hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(signature, expected_sig): + raise ValueError("Invalid license signature") + payload = json.loads(payload_str) + now = int(time.time()) + if now > payload["expires_at"]: + raise ValueError("License expired") + return payload + + +# ======================================================================== +# 1. Privacy Config Management +# ======================================================================== +@router.get("/config") +def get_privacy_config(user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """Get user's privacy configuration""" + result = _execute_raw(db, text( + "SELECT * FROM privacy_configs WHERE user_id = :uid ORDER BY updated_at DESC LIMIT 1" + ), {"uid": user_id}) + keys = ["id", "user_id", "agent_id", "privacy_mode", "local_llm_provider", + "local_llm_model", "local_llm_endpoint", "data_retention_days", + "anonymize_usage", "sync_knowledge_bases", "sync_frequency", + "last_sync_at", "status", "created_at", "updated_at"] + if result: + row = result[0] + config = _row_to_dict(row, keys) + if config.get("sync_knowledge_bases"): + try: + config["sync_knowledge_bases"] = json.loads(config["sync_knowledge_bases"]) + except Exception: + pass + return {"code": 0, "data": config} + # Return default config + return {"code": 0, "data": { + "privacy_mode": "cloud", + "local_llm_provider": None, + "local_llm_model": None, + "local_llm_endpoint": None, + "data_retention_days": 30, + "anonymize_usage": True, + "sync_knowledge_bases": [], + "sync_frequency": "daily", + "status": "active" + }} + + +@router.put("/config") +def update_privacy_config( + privacy_mode: str = Query(None), + local_llm_provider: str = Query(None), + local_llm_model: str = Query(None), + local_llm_endpoint: str = Query(None), + data_retention_days: int = Query(None), + anonymize_usage: bool = Query(None), + sync_knowledge_bases: str = Query(None), + sync_frequency: str = Query(None), + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Update privacy configuration""" + valid_modes = ["local", "cloud", "hybrid"] + valid_freqs = ["realtime", "hourly", "daily", "manual"] + + if privacy_mode and privacy_mode not in valid_modes: + raise HTTPException(400, f"Invalid mode: {privacy_mode}") + if sync_frequency and sync_frequency not in valid_freqs: + raise HTTPException(400, f"Invalid frequency: {sync_frequency}") + + # Check if config exists + existing = _execute_raw(db, text( + "SELECT id FROM privacy_configs WHERE user_id = :uid" + ), {"uid": user_id}) + + sets = [] + params = {"uid": user_id} + if privacy_mode: + sets.append("privacy_mode = :mode") + params["mode"] = privacy_mode + if local_llm_provider is not None: + sets.append("local_llm_provider = :provider") + params["provider"] = local_llm_provider + if local_llm_model is not None: + sets.append("local_llm_model = :model") + params["model"] = local_llm_model + if local_llm_endpoint is not None: + sets.append("local_llm_endpoint = :endpoint") + params["endpoint"] = local_llm_endpoint + if data_retention_days is not None: + sets.append("data_retention_days = :retention") + params["retention"] = data_retention_days + if anonymize_usage is not None: + sets.append("anonymize_usage = :anon") + params["anon"] = 1 if anonymize_usage else 0 + if sync_knowledge_bases is not None: + sets.append("sync_knowledge_bases = :kb_ids") + params["kb_ids"] = sync_knowledge_bases + if sync_frequency: + sets.append("sync_frequency = :freq") + params["freq"] = sync_frequency + + if not existing: + sets.append("user_id = :uid") + sets.append("created_at = NOW()") + sets.append("updated_at = NOW()") + sql = f"INSERT INTO privacy_configs SET {', '.join(sets)}" + else: + sets.append("updated_at = NOW()") + sql = f"UPDATE privacy_configs SET {', '.join(sets)} WHERE user_id = :uid" + + _execute_raw(db, text(sql), params) + return {"code": 0, "message": "Config updated"} + + +@router.get("/modes") +def list_privacy_modes(): + """List available privacy modes with descriptions""" + return {"code": 0, "data": [ + { + "mode": "cloud", + "name": "云端模式", + "description": "所有数据和计算在云端处理,适用于一般场景", + "pros": ["无需本地资源", "完整的平台功能", "自动更新"], + "cons": ["数据离开本地", "需要持续网络连接"], + "recommended_for": ["一般用户", "非敏感数据"] + }, + { + "mode": "local", + "name": "本地模式", + "description": "数据完全本地处理,仅通过Platform Bridge同步配置", + "pros": ["数据不离开本地", "完全隐私保护", "离线可用"], + "cons": ["需要本地GPU/NPU", "功能有限", "需手动同步"], + "recommended_for": ["政务", "金融", "医疗"] + }, + { + "mode": "hybrid", + "name": "混合模式", + "description": "敏感数据本地处理,通用能力云端调用", + "pros": ["灵活配置", "平衡隐私与功能"], + "cons": ["配置复杂", "需要网络连接"], + "recommended_for": ["企业级用户", "合规需求"] + } + ]} + + +@router.post("/switch-mode") +def switch_privacy_mode( + mode: str = Query(...), + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Switch privacy mode with validation""" + valid_modes = ["local", "cloud", "hybrid"] + if mode not in valid_modes: + raise HTTPException(400, f"Invalid mode. Must be one of: {valid_modes}") + + existing = _execute_raw(db, text( + "SELECT id FROM privacy_configs WHERE user_id = :uid" + ), {"uid": user_id}) + + if existing: + _execute_raw(db, text( + "UPDATE privacy_configs SET privacy_mode = :mode, updated_at = NOW() WHERE user_id = :uid" + ), {"mode": mode, "uid": user_id}) + else: + _execute_raw(db, text( + "INSERT INTO privacy_configs (user_id, privacy_mode, created_at, updated_at) VALUES (:uid, :mode, NOW(), NOW())" + ), {"uid": user_id, "mode": mode}) + + return {"code": 0, "message": f"Switched to {mode} mode", "data": {"mode": mode}} + + +# ======================================================================== +# 2. Offline License Management +# ======================================================================== +@router.post("/licenses/generate") +def generate_offline_license( + agent_id: str = Query(None), + max_offline_days: int = Query(7), + device_fingerprint: str = Query(None), + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Generate a new offline license""" + if max_offline_days < 1 or max_offline_days > 90: + raise HTTPException(400, "max_offline_days must be between 1 and 90") + + license_info = _generate_offline_license( + user_id, agent_id or "default", max_offline_days + ) + license_key = license_info["license_key"] + payload = license_info["payload"] + + _execute_raw(db, text(""" + INSERT INTO offline_licenses (license_key, user_id, agent_id, device_fingerprint, + issued_at, expires_at, max_offline_days, status) + VALUES (:lk, :uid, :aid, :fp, NOW(), :exp, :max_days, 'active') + """), { + "lk": license_key, + "uid": user_id, + "aid": agent_id, + "fp": device_fingerprint, + "exp": datetime.fromtimestamp(payload["expires_at"]), + "max_days": max_offline_days, + }) + + return {"code": 0, "data": { + "license_key": license_key, + "issued_at": payload["issued_at"], + "expires_at": payload["expires_at"], + "max_offline_days": max_offline_days, + "status": "active" + }} + + +@router.get("/licenses") +def list_offline_licenses( + user_id: str = Query("admin-001"), + status: str = Query(None), + page: int = Query(1), + page_size: int = Query(20), + db: Session = Depends(get_db) +): + """List user's offline licenses""" + where = "WHERE user_id = :uid" + params = {"uid": user_id} + if status: + where += " AND status = :st" + params["st"] = status + + count_row = _execute_raw(db, text( + f"SELECT COUNT(*) FROM offline_licenses {where}" + ), params) + total = count_row[0][0] if count_row else 0 + + offset = (page - 1) * page_size + rows = _execute_raw(db, text( + f"SELECT * FROM offline_licenses {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset" + ), {**params, "limit": page_size, "offset": offset}) + keys = ["id", "license_key", "user_id", "agent_id", "device_fingerprint", + "issued_at", "expires_at", "last_validated_at", "max_offline_days", + "is_active", "revoked_reason", "status", "created_at", "updated_at"] + + return {"code": 0, "data": { + "items": _rows_to_list(rows, keys), + "total": total, + "page": page, + "page_size": page_size + }} + + +@router.get("/licenses/{license_id}") +def get_license_detail(license_id: int, db: Session = Depends(get_db)): + """Get offline license detail""" + result = _execute_raw(db, text( + "SELECT * FROM offline_licenses WHERE id = :lid" + ), {"lid": license_id}) + keys = ["id", "license_key", "user_id", "agent_id", "device_fingerprint", + "issued_at", "expires_at", "last_validated_at", "max_offline_days", + "is_active", "revoked_reason", "status", "created_at", "updated_at"] + if not result: + raise HTTPException(404, "License not found") + return {"code": 0, "data": _row_to_dict(result[0], keys)} + + +@router.post("/licenses/{license_id}/refresh") +def refresh_license( + license_id: int, + extend_days: int = Query(7), + db: Session = Depends(get_db) +): + """Refresh an existing license (extend expiry)""" + result = _execute_raw(db, text( + "SELECT * FROM offline_licenses WHERE id = :lid" + ), {"lid": license_id}) + if not result: + raise HTTPException(404, "License not found") + + row = result[0] + status = row[11] # status column index + if status != "active": + raise HTTPException(400, f"Cannot refresh license with status: {status}") + + current_expires = row[5] # expires_at + if isinstance(current_expires, str): + current_expires = datetime.strptime(current_expires, "%Y-%m-%d %H:%M:%S") + new_expires = current_expires + timedelta(days=extend_days) + + _execute_raw(db, text( + "UPDATE offline_licenses SET expires_at = :exp, last_validated_at = NOW() WHERE id = :lid" + ), {"exp": new_expires, "lid": license_id}) + + return {"code": 0, "message": f"License extended by {extend_days} days", + "data": {"new_expires_at": new_expires.strftime("%Y-%m-%d %H:%M:%S")}} + + +@router.post("/licenses/validate") +def validate_license( + license_key: str = Query(...), + device_fingerprint: str = Query(None), + db: Session = Depends(get_db) +): + """Validate an offline license (called by local agent)""" + try: + payload = _validate_offline_license(license_key) + except ValueError as e: + return {"code": 1, "message": str(e), "data": {"valid": False}} + + # Check DB record + result = _execute_raw(db, text( + "SELECT id, status, expires_at, device_fingerprint FROM offline_licenses WHERE license_key = :lk" + ), {"lk": license_key}) + + if not result: + return {"code": 0, "data": {"valid": True, "warning": "License not in DB (may be pre-generated)"}} + + db_status = result[0][1] + if db_status != "active": + return {"code": 1, "message": f"License status: {db_status}", "data": {"valid": False}} + + # Update last_validated_at + _execute_raw(db, text( + "UPDATE offline_licenses SET last_validated_at = NOW() WHERE id = :lid" + ), {"lid": result[0][0]}) + + return {"code": 0, "data": { + "valid": True, + "user_id": payload["user_id"], + "agent_id": payload["agent_id"], + "expires_at": payload["expires_at"], + "max_offline_days": payload["max_offline_days"] + }} + + +@router.post("/licenses/{license_id}/revoke") +def revoke_license( + license_id: int, + reason: str = Query("Manually revoked"), + db: Session = Depends(get_db) +): + """Revoke an offline license""" + result = _execute_raw(db, text( + "SELECT id, status FROM offline_licenses WHERE id = :lid" + ), {"lid": license_id}) + if not result: + raise HTTPException(404, "License not found") + + _execute_raw(db, text( + "UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = 0 WHERE id = :lid" + ), {"reason": reason, "lid": license_id}) + + return {"code": 0, "message": "License revoked"} + + +# ======================================================================== +# 3. Knowledge Base Sync +# ======================================================================== +@router.get("/kb-sync") +def list_kb_sync_states( + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """List knowledge base sync states""" + rows = _execute_raw(db, text( + "SELECT * FROM kb_sync_states WHERE user_id = :uid ORDER BY updated_at DESC" + ), {"uid": user_id}) + keys = ["id", "user_id", "knowledge_base_id", "local_version", "remote_version", + "last_sync_at", "sync_status", "total_docs", "synced_docs", + "sync_direction", "error_message", "created_at", "updated_at"] + + result = [] + for row in rows: + d = _row_to_dict(row, keys) + # Get KB name + kb_row = _execute_raw(db, text( + "SELECT name FROM knowledge_bases WHERE id = :kbid" + ), {"kbid": row[2]}) + d["kb_name"] = kb_row[0][0] if kb_row else f"KB-{row[2]}" + result.append(d) + + return {"code": 0, "data": result} + + +@router.post("/kb-sync/trigger") +def trigger_kb_sync( + knowledge_base_id: int = Query(...), + sync_direction: str = Query("pull"), + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Trigger knowledge base sync""" + valid_dirs = ["push", "pull", "bidirectional"] + if sync_direction not in valid_dirs: + raise HTTPException(400, f"Invalid direction: {sync_direction}") + + # Check or create sync state + existing = _execute_raw(db, text( + "SELECT id FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid" + ), {"uid": user_id, "kbid": knowledge_base_id}) + + if existing: + _execute_raw(db, text(""" + UPDATE kb_sync_states SET sync_status = 'syncing', sync_direction = :dir, + last_sync_at = NOW(), error_message = NULL + WHERE id = :sid + """), {"dir": sync_direction, "sid": existing[0][0]}) + else: + _execute_raw(db, text(""" + INSERT INTO kb_sync_states (user_id, knowledge_base_id, sync_status, + sync_direction, last_sync_at) + VALUES (:uid, :kbid, 'syncing', :dir, NOW()) + """), {"uid": user_id, "kbid": knowledge_base_id, "dir": sync_direction}) + + # Simulate sync: count KB docs as "synced" + doc_count = _execute_raw(db, text( + "SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid" + ), {"kbid": knowledge_base_id}) + total = doc_count[0][0] if doc_count else 0 + + _execute_raw(db, text(""" + UPDATE kb_sync_states SET sync_status = 'synced', total_docs = :total, + synced_docs = :synced, remote_version = remote_version + 1, + error_message = NULL + WHERE user_id = :uid AND knowledge_base_id = :kbid + """), {"total": total, "synced": total, "uid": user_id, "kbid": knowledge_base_id}) + + return {"code": 0, "message": f"Sync triggered: {sync_direction}", + "data": {"total_docs": total, "synced_docs": total, "status": "synced"}} + + +@router.get("/kb-sync/{kb_id}/status") +def get_kb_sync_status(kb_id: int, user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """Get sync status for a specific KB""" + result = _execute_raw(db, text( + "SELECT * FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid" + ), {"uid": user_id, "kbid": kb_id}) + keys = ["id", "user_id", "knowledge_base_id", "local_version", "remote_version", + "last_sync_at", "sync_status", "total_docs", "synced_docs", + "sync_direction", "error_message", "created_at", "updated_at"] + if not result: + return {"code": 0, "data": {"sync_status": "pending", "knowledge_base_id": kb_id}} + return {"code": 0, "data": _row_to_dict(result[0], keys)} + + +@router.get("/kb-sync/{kb_id}/diff") +def get_kb_sync_diff(kb_id: int, user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """Get document diff since last sync""" + result = _execute_raw(db, text( + "SELECT last_sync_at FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid" + ), {"uid": user_id, "kbid": kb_id}) + + last_sync = result[0][0] if result else None + + if last_sync: + new_docs = _execute_raw(db, text( + "SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid AND created_at > :since" + ), {"kbid": kb_id, "since": last_sync}) + updated_docs = _execute_raw(db, text( + "SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid AND updated_at > :since AND created_at <= :since" + ), {"kbid": kb_id, "since": last_sync}) + deleted_count = 0 # Soft delete not tracked in this version + else: + total = _execute_raw(db, text( + "SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid" + ), {"kbid": kb_id}) + total = total[0][0] if total else 0 + new_docs = [(total,)] + updated_docs = [(0,)] + deleted_count = 0 + + return {"code": 0, "data": { + "knowledge_base_id": kb_id, + "last_sync_at": last_sync.strftime("%Y-%m-%d %H:%M:%S") if last_sync and not isinstance(last_sync, str) else str(last_sync) if last_sync else None, + "new_docs": new_docs[0][0] if new_docs else 0, + "updated_docs": updated_docs[0][0] if updated_docs else 0, + "deleted_docs": deleted_count, + }} + + +# ======================================================================== +# 4. Anonymized Usage Reporting +# ======================================================================== +@router.post("/usage/report") +def submit_usage_report( + metrics: str = Query(...), + report_type: str = Query("daily"), + period_start: str = Query(None), + period_end: str = Query(None), + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Submit anonymized usage report from local agent""" + user_hash = _anonymize_user_id(user_id) + + try: + metrics_obj = json.loads(metrics) + except json.JSONDecodeError: + metrics_obj = {"raw": metrics} + + if not period_start: + period_start = datetime.now().strftime("%Y-%m-%d 00:00:00") + if not period_end: + period_end = datetime.now().strftime("%Y-%m-%d 23:59:59") + + _execute_raw(db, text(""" + INSERT INTO usage_reports (user_hash, report_type, metrics, period_start, + period_end, is_anonymized, submitted_at) + VALUES (:hash, :rtype, :metrics, :pstart, :pend, 1, NOW()) + """), { + "hash": user_hash, + "rtype": report_type, + "metrics": json.dumps(metrics_obj), + "pstart": period_start, + "pend": period_end, + }) + + return {"code": 0, "message": "Usage report submitted"} + + +@router.get("/usage/stats") +def get_usage_stats(db: Session = Depends(get_db)): + """Get aggregated usage statistics (admin view, anonymized)""" + total_reports = _execute_raw(db, text( + "SELECT COUNT(*) FROM usage_reports" + )) + unique_users = _execute_raw(db, text( + "SELECT COUNT(DISTINCT user_hash) FROM usage_reports" + )) + today_reports = _execute_raw(db, text( + "SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURDATE()" + )) + by_type = _execute_raw(db, text( + "SELECT report_type, COUNT(*) as cnt FROM usage_reports GROUP BY report_type" + )) + + return {"code": 0, "data": { + "total_reports": total_reports[0][0] if total_reports else 0, + "unique_users": unique_users[0][0] if unique_users else 0, + "today_reports": today_reports[0][0] if today_reports else 0, + "by_type": [{"type": r[0], "count": r[1]} for r in by_type] if by_type else [] + }} + + +@router.get("/usage/my-stats") +def get_my_usage_stats(user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """Get current user's usage stats""" + user_hash = _anonymize_user_id(user_id) + result = _execute_raw(db, text( + "SELECT COUNT(*), MAX(submitted_at) FROM usage_reports WHERE user_hash = :hash" + ), {"hash": user_hash}) + + return {"code": 0, "data": { + "total_reports": result[0][0] if result else 0, + "last_submitted": _row_to_dict(result[0], ["count", "last"])["last"] if result else None + }} + + +# ======================================================================== +# 5. Local Agent Status +# ======================================================================== +@router.get("/local-agents") +def list_local_agents(user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """List local agents with sync status""" + # Query agents with privacy configs + rows = _execute_raw(db, text(""" + SELECT DISTINCT a.id, a.name, a.description, a.status, a.type, + COALESCE(pc.privacy_mode, 'cloud') as privacy_mode, + COALESCE(pc.local_llm_provider, '') as llm_provider, + COALESCE(pc.local_llm_model, '') as llm_model, + COALESCE(pc.last_sync_at, NULL) as last_sync + FROM market_apps a + LEFT JOIN privacy_configs pc ON pc.agent_id = a.id AND pc.user_id = :uid + WHERE a.type = 'agent' AND a.status = 'published' + ORDER BY a.name + """), {"uid": user_id}) + keys = ["id", "name", "description", "status", "type", "privacy_mode", + "llm_provider", "llm_model", "last_sync"] + + agents = [] + for row in rows: + d = _row_to_dict(row, keys) + d["can_run_local"] = d["privacy_mode"] in ["local", "hybrid"] + agents.append(d) + + return {"code": 0, "data": agents} + + +@router.get("/local-agents/{agent_id}/status") +def get_local_agent_status( + agent_id: str, + user_id: str = Query("admin-001"), + db: Session = Depends(get_db) +): + """Get detailed local agent status""" + config = _execute_raw(db, text( + "SELECT * FROM privacy_configs WHERE user_id = :uid AND agent_id = :aid" + ), {"uid": user_id, "aid": agent_id}) + keys = ["id", "user_id", "agent_id", "privacy_mode", "local_llm_provider", + "local_llm_model", "local_llm_endpoint", "data_retention_days", + "anonymize_usage", "sync_knowledge_bases", "sync_frequency", + "last_sync_at", "status", "created_at", "updated_at"] + + if config: + cfg = _row_to_dict(config[0], keys) + if cfg.get("sync_knowledge_bases"): + try: + cfg["sync_knowledge_bases"] = json.loads(cfg["sync_knowledge_bases"]) + except Exception: + pass + else: + cfg = {"privacy_mode": "cloud", "status": "not_configured"} + + # Check active license + license_info = _execute_raw(db, text( + "SELECT license_key, expires_at, status FROM offline_licenses WHERE user_id = :uid AND status = 'active' ORDER BY expires_at DESC LIMIT 1" + ), {"uid": user_id}) + if license_info: + cfg["has_active_license"] = True + cfg["license_expires"] = license_info[0][1].strftime("%Y-%m-%d %H:%M:%S") if hasattr(license_info[0][1], 'strftime') else str(license_info[0][1]) + else: + cfg["has_active_license"] = False + cfg["license_expires"] = None + + return {"code": 0, "data": cfg} + + +# ======================================================================== +# 6. Platform Bridge Config Export +# ======================================================================== +@router.get("/bridge-config") +def get_platform_bridge_config(user_id: str = Query("admin-001"), db: Session = Depends(get_db)): + """Export Platform Bridge configuration for local Goose setup""" + config = _execute_raw(db, text( + "SELECT * FROM privacy_configs WHERE user_id = :uid ORDER BY updated_at DESC LIMIT 1" + ), {"uid": user_id}) + + platform_api = "http://210.16.161.218:18080" + bridge_config = { + "mcp_server": { + "name": "platform-bridge", + "type": "stdio", + "command": "python", + "args": ["-m", "platform_bridge.mcp_server"], + "env": { + "PLATFORM_API_URL": platform_api, + "PLATFORM_USER_ID": user_id, + "PLATFORM_API_KEY": "YOUR_API_KEY_HERE" + }, + "timeout": 60 + }, + "privacy_mode": "cloud", + "sync_config": { + "frequency": "daily", + "knowledge_bases": [], + "auto_sync": True, + "conflict_resolution": "last_write_wins" + }, + "local_llm": { + "provider": "ollama", + "model": "qwen2.5:7b", + "endpoint": "http://localhost:11434" + } + } + + if config: + row = config[0] + bridge_config["privacy_mode"] = row[3] # privacy_mode column + bridge_config["sync_config"]["frequency"] = row[10] # sync_frequency column + if row[5]: # local_llm_provider + bridge_config["local_llm"]["provider"] = row[5] + if row[6]: # local_llm_model + bridge_config["local_llm"]["model"] = row[6] + if row[7]: # local_llm_endpoint + bridge_config["local_llm"]["endpoint"] = row[7] + if row[9]: # sync_knowledge_bases + try: + bridge_config["sync_config"]["knowledge_bases"] = json.loads(row[9]) + except Exception: + pass + + return {"code": 0, "data": bridge_config} + + +@router.get("/stats") +def get_privacy_stats(db: Session = Depends(get_db)): + """Get privacy module statistics""" + total_configs = _execute_raw(db, text("SELECT COUNT(*) FROM privacy_configs")) + active_licenses = _execute_raw(db, text( + "SELECT COUNT(*) FROM offline_licenses WHERE status = 'active' AND expires_at > NOW()" + )) + synced_kbs = _execute_raw(db, text( + "SELECT COUNT(*) FROM kb_sync_states WHERE sync_status = 'synced'" + )) + mode_breakdown = _execute_raw(db, text( + "SELECT privacy_mode, COUNT(*) FROM privacy_configs GROUP BY privacy_mode" + )) + + return {"code": 0, "data": { + "total_configs": total_configs[0][0] if total_configs else 0, + "active_licenses": active_licenses[0][0] if active_licenses else 0, + "synced_knowledge_bases": synced_kbs[0][0] if synced_kbs else 0, + "mode_breakdown": {r[0]: r[1] for r in mode_breakdown} if mode_breakdown else {} + }} diff --git a/backend/python/app/api/admin/promotion_admin.py b/backend/python/app/api/admin/promotion_admin.py new file mode 100644 index 0000000..b35edd6 --- /dev/null +++ b/backend/python/app/api/admin/promotion_admin.py @@ -0,0 +1,288 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import Optional, List +from datetime import datetime, date +import json +import traceback + +from ...database import SessionLocal, get_db +from ...auth import get_current_user +from ...models import User + +router = APIRouter() + +def _execute_raw(db: Session, sql: str, params: dict = None): + """执行原始SQL的辅助函数""" + try: + result = db.execute(text(sql), params or {}) + db.commit() + return result + except Exception as e: + db.rollback() + raise e + +@router.get("/stats") +async def get_promotion_overview( + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """获取推广概览统计""" + try: + stats = { + "total_promotions": 0, + "active_promotions": 0, + "total_referrals": 0, + "total_rewards": 0.0, + "pending_rewards": 0.0 + } + + # 统计推广活动数量 + try: + result = _execute_raw(db, "SELECT COUNT(*) as cnt FROM promotions") + row = result.fetchone() + if row: + stats["total_promotions"] = row[0] + except: + pass + + # 统计活跃推广活动 + try: + result = _execute_raw(db, "SELECT COUNT(*) as cnt FROM promotions WHERE status='active'") + row = result.fetchone() + if row: + stats["active_promotions"] = row[0] + except: + pass + + return {"code": 0, "data": stats} + except Exception as e: + traceback.print_exc() + return {"code": 0, "data": {}} + +@router.get("/promotions") +async def list_promotions( + status: Optional[str] = Query(None), + page: int = Query(1), + page_size: int = Query(20), + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """列出推广活动""" + try: + promotions = [] + try: + result = _execute_raw(db, "SELECT * FROM promotions ORDER BY created_at DESC LIMIT 100") + rows = result.fetchall() + for row in rows: + promotions.append({ + "id": row[0], + "title": row[1], + "description": row[2], + "reward_type": row[3], + "reward_value": float(row[4]) if row[4] else 0, + "min_payout": float(row[5]) if row[5] else 0, + "start_date": str(row[6]) if row[6] else "", + "end_date": str(row[7]) if row[7] else "", + "status": row[8], + "max_referrals": row[9], + "created_by": row[10], + "created_at": str(row[11]) if row[11] else "" + }) + except Exception as e: + print(f"Error fetching promotions: {e}") + + return { + "code": 0, + "data": { + "items": promotions, + "total": len(promotions), + "page": page, + "page_size": page_size + } + } + except Exception as e: + traceback.print_exc() + return {"code": 0, "data": {"items": [], "total": 0}} + +@router.post("/promotions") +async def create_promotion( + title: str = Query(...), + reward_type: str = Query(...), + reward_value: float = Query(...), + description: Optional[str] = Query(""), + min_payout: float = Query(0), + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + max_referrals: int = Query(0), + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """创建推广活动""" + try: + import uuid + promo_id = str(uuid.uuid4()) + + # 使用 user.id 而不是 user.get() + created_by = user.id if hasattr(user, 'id') else "system" + + sql = """INSERT INTO promotions + (id, title, description, reward_type, reward_value, min_payout, + start_date, end_date, status, max_referrals, created_by, created_at) + VALUES (:id, :title, :desc, :rtype, :rvalue, :min_pay, + :start, :end, 'active', :max_ref, :created_by, NOW())""" + + _execute_raw(db, sql, { + "id": promo_id, + "title": title, + "desc": description, + "rtype": reward_type, + "rvalue": reward_value, + "min_pay": min_payout, + "start": start_date, + "end": end_date, + "max_ref": max_referrals, + "created_by": created_by + }) + + return {"code": 0, "message": "推广活动创建成功", "data": {"id": promo_id}} + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.put("/promotions/{promo_id}") +async def update_promotion( + promo_id: str, + title: Optional[str] = Query(None), + description: Optional[str] = Query(None), + reward_type: Optional[str] = Query(None), + reward_value: Optional[float] = Query(None), + status: Optional[str] = Query(None), + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """更新推广活动""" + try: + updates = [] + params = {"id": promo_id} + + if title is not None: + updates.append("title = :title") + params["title"] = title + if description is not None: + updates.append("description = :desc") + params["desc"] = description + if reward_type is not None: + updates.append("reward_type = :rtype") + params["rtype"] = reward_type + if reward_value is not None: + updates.append("reward_value = :rvalue") + params["rvalue"] = reward_value + if status is not None: + updates.append("status = :status") + params["status"] = status + + if updates: + sql = f"UPDATE promotions SET {', '.join(updates)} WHERE id = :id" + _execute_raw(db, sql, params) + + return {"code": 0, "message": "更新成功"} + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/promotions/{promo_id}") +async def delete_promotion( + promo_id: str, + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """删除推广活动""" + try: + _execute_raw(db, "DELETE FROM promotions WHERE id = :id", {"id": promo_id}) + return {"code": 0, "message": "删除成功"} + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/promotions/{promo_id}/toggle") +async def toggle_promotion( + promo_id: str, + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """切换推广活动状态""" + try: + result = _execute_raw(db, "SELECT status FROM promotions WHERE id = :id", {"id": promo_id}) + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="推广活动不存在") + + new_status = "inactive" if row[0] == "active" else "active" + _execute_raw(db, "UPDATE promotions SET status = :status WHERE id = :id", + {"status": new_status, "id": promo_id}) + + return {"code": 0, "message": "状态已更新", "data": {"status": new_status}} + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/referral-links") +async def get_referral_links( + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """获取分销链接列表""" + return {"code": 0, "data": {"items": []}} + +@router.post("/referral-links/generate") +async def generate_referral_link( + promotion_id: str = Query(...), + channel: str = Query("default"), + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """生成分销链接""" + return {"code": 0, "message": "功能开发中"} + +@router.get("/rewards") +async def get_referral_rewards( + status: Optional[str] = Query(None), + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """获取返佣记录""" + return {"code": 0, "data": {"items": []}} + +@router.post("/rewards/{reward_id}/approve") +async def approve_reward( + reward_id: str, + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """审核返佣记录""" + return {"code": 0, "message": "功能开发中"} + +@router.get("/leaderboard") +async def get_promotion_leaderboard( + period: str = Query("all"), + db: Session = Depends(get_db) +): + """获取推广排行榜""" + return {"code": 0, "data": {"items": []}} + +@router.get("/my-referrals") +async def get_my_referrals( + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """获取我的推荐链接""" + return {"code": 0, "data": {"items": []}} + +@router.get("/my-rewards") +async def get_my_rewards( + db: Session = Depends(get_db), + user = Depends(get_current_user) +): + """获取我的返佣记录""" + return {"code": 0, "data": {"items": []}} diff --git a/backend/python/app/api/admin/skills_admin.py b/backend/python/app/api/admin/skills_admin.py new file mode 100644 index 0000000..855759a --- /dev/null +++ b/backend/python/app/api/admin/skills_admin.py @@ -0,0 +1,773 @@ +""" +技能管理系统 API(管理员) +全平台技能管理:列表/创建/编辑/删除/状态管理/版本历史/发布市场 +""" +import os +import json +import uuid +import logging +from datetime import datetime +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import desc, func, or_, and_ + +from ...database import get_db +from ...models import ( + User, EvolutionSkill, SkillVersion, SkillPublishRecord, + EvolutionEvent, TeamSharedSkill +) +from ...auth import get_current_admin +from ...middleware.log_service import LogService +from ...config import UPLOAD_DIR + +router = APIRouter() +logger = logging.getLogger(__name__) + +# ============================================================ +# 技能状态定义 +# ============================================================ + +SKILL_STATES = { + "baby": {"label": "萌芽期", "color": "#9CA3AF", "description": "刚创建的技能,需要继续培养"}, + "main": {"label": "主力期", "color": "#10B981", "description": "已成熟可正常使用"}, + "staging": {"label": "灰度期", "color": "#F59E0B", "description": "正在灰度测试中"}, + "frozen": {"label": "已冻结", "color": "#EF4444", "description": "已停止使用"}, +} + +SKILL_CATEGORIES = [ + {"id": "writing", "label": "文字创作"}, + {"id": "coding", "label": "代码能力"}, + {"id": "data_analysis", "label": "数据分析"}, + {"id": "qa", "label": "知识问答"}, + {"id": "automation", "label": "流程自动化"}, + {"id": "creativity", "label": "创意生成"}, + {"id": "communication", "label": "沟通协作"}, + {"id": "reasoning", "label": "逻辑推理"}, + {"id": "other", "label": "其他"}, +] + + +# ============================================================ +# 辅助函数 +# ============================================================ + +def skill_to_dict(s: EvolutionSkill, include_md: bool = False) -> dict: + """将技能ORM对象转换为字典""" + result = { + "id": s.id, + "user_id": s.user_id, + "skill_name": s.skill_name, + "description": s.description, + "tags": s.tags or [], + "category": s.category, + "category_label": next((c["label"] for c in SKILL_CATEGORIES if c["id"] == s.category), s.category or "其他"), + "state": s.state, + "state_info": SKILL_STATES.get(s.state, SKILL_STATES["baby"]), + "use_count": s.use_count or 0, + "version": s.version or 1, + "skill_md_path": s.skill_md_path, + "git_commit_sha": s.git_commit_sha, + "last_used_at": s.last_used_at.isoformat() if s.last_used_at else None, + "created_at": s.created_at.isoformat() if s.created_at else None, + "updated_at": s.updated_at.isoformat() if s.updated_at else None, + } + + # 读取 SKILL.md 内容 + if include_md and s.skill_md_path and os.path.exists(s.skill_md_path): + try: + with open(s.skill_md_path, 'r', encoding='utf-8') as f: + result["skill_md_content"] = f.read() + except Exception: + result["skill_md_content"] = None + else: + result["skill_md_content"] = None + + return result + + +def version_to_dict(v: SkillVersion) -> dict: + return { + "id": v.id, + "user_id": v.user_id, + "skill_name": v.skill_name, + "version": v.version, + "skill_md_content": v.skill_md_content, + "git_commit_sha": v.git_commit_sha, + "description": v.description, + "created_at": v.created_at.isoformat() if v.created_at else None, + } + + +# ============================================================ +# 管理员技能列表 +# ============================================================ + +@router.get("/") +async def list_skills( + state: str = Query(None, description="技能状态过滤: baby/main/staging/frozen"), + category: str = Query(None, description="技能分类过滤"), + keyword: str = Query(None, description="关键词搜索(名称/描述)"), + user_id: str = Query(None, description="按用户ID筛选"), + sort_by: str = Query("updated_at", description="排序字段"), + order: str = Query("desc", description="排序方向: asc/desc"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取全平台技能列表(支持分页/过滤/搜索)""" + logger.info(f"[Admin Skills] {admin_user.username} 列出技能: state={state}, keyword={keyword}") + + query = db.query(EvolutionSkill) + + if state: + query = query.filter(EvolutionSkill.state == state) + if category: + query = query.filter(EvolutionSkill.category == category) + if keyword: + query = query.filter( + or_( + EvolutionSkill.skill_name.contains(keyword), + EvolutionSkill.description.contains(keyword) + ) + ) + if user_id: + query = query.filter(EvolutionSkill.user_id == user_id) + + # 总数 + total = query.count() + + # 排序 + sort_field = getattr(EvolutionSkill, sort_by, EvolutionSkill.updated_at) + if order == "desc": + query = query.order_by(desc(sort_field)) + else: + query = query.order_by(sort_field) + + # 分页 + skills = query.offset((page - 1) * page_size).limit(page_size).all() + + # 补充用户名 + user_ids = list(set(s.user_id for s in skills)) + users_map = { + u.id: u.username + for u in db.query(User.id, User.username).filter(User.id.in_(user_ids)).all() + } + + result = [] + for s in skills: + item = skill_to_dict(s) + item["owner_username"] = users_map.get(s.user_id, s.user_id) + result.append(item) + + return { + "code": 0, + "data": { + "items": result, + "total": total, + "page": page, + "page_size": page_size, + "pages": (total + page_size - 1) // page_size, + } + } + + +# ============================================================ +# 获取单个技能详情 +# ============================================================ + +@router.get("/{skill_id}") +async def get_skill( + skill_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取技能详情(包含 SKILL.md 内容)""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + result = skill_to_dict(skill, include_md=True) + + # 获取所属用户信息 + user = db.query(User).filter(User.id == skill.user_id).first() + if user: + result["owner_username"] = user.username + result["owner_email"] = user.email + + # 获取发布记录 + pub_records = db.query(SkillPublishRecord).filter( + SkillPublishRecord.skill_id == skill_id + ).order_by(desc(SkillPublishRecord.created_at)).limit(10).all() + result["publish_records"] = [{ + "id": p.id, + "price": float(p.price) if p.price else 0, + "market_description": p.market_description, + "status": p.status, + "published_at": p.published_at.isoformat() if p.published_at else None, + "created_at": p.created_at.isoformat() if p.created_at else None, + } for p in pub_records] + + # 获取最新版本号 + latest_version = db.query(func.max(SkillVersion.version)).filter( + SkillVersion.skill_id == skill_id + ).scalar() + result["latest_version"] = latest_version or skill.version + + return {"code": 0, "data": result} + + +# ============================================================ +# 创建技能 +# ============================================================ + +class CreateSkillRequest: + def __init__(self, skill_name: str = None, description: str = None, + tags: List[str] = None, category: str = None, + skill_md_content: str = None, user_id: str = None): + self.skill_name = skill_name + self.description = description + self.tags = tags or [] + self.category = category + self.skill_md_content = skill_md_content + self.user_id = user_id + + +from pydantic import BaseModel, Field + + +class CreateSkillSchema(BaseModel): + skill_name: str = Field(..., min_length=1, max_length=200, description="技能名称") + user_id: str = Field(..., description="所属用户ID") + description: Optional[str] = Field(None, description="技能描述") + tags: Optional[List[str]] = Field(default_factory=list, description="标签列表") + category: Optional[str] = Field(None, description="技能分类") + skill_md_content: Optional[str] = Field(None, description="SKILL.md 内容") + + +@router.post("/") +async def create_skill( + req: CreateSkillSchema, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """管理员创建技能""" + logger.info(f"[Admin Skills] {admin_user.username} 创建技能: {req.skill_name}") + + # 检查技能名是否已存在 + existing = db.query(EvolutionSkill).filter( + EvolutionSkill.user_id == req.user_id, + EvolutionSkill.skill_name == req.skill_name + ).first() + if existing: + raise HTTPException(status_code=400, detail="该用户已存在同名技能") + + # 验证用户存在 + user = db.query(User).filter(User.id == req.user_id).first() + if not user: + raise HTTPException(status_code=400, detail="用户不存在") + + # 生成技能ID + skill_id = str(uuid.uuid4()) + + # 保存 SKILL.md 文件 + skill_md_path = None + if req.skill_md_content: + skill_dir = os.path.join(UPLOAD_DIR, "skills", req.user_id) + os.makedirs(skill_dir, exist_ok=True) + safe_name = req.skill_name.replace("/", "_").replace("\\", "_") + skill_md_path = os.path.join(skill_dir, f"{safe_name}.md") + try: + with open(skill_md_path, 'w', encoding='utf-8') as f: + f.write(req.skill_md_content) + except Exception as e: + logger.warning(f"保存 SKILL.md 失败: {e}") + + # 创建技能记录 + skill = EvolutionSkill( + id=skill_id, + user_id=req.user_id, + skill_name=req.skill_name, + description=req.description, + tags=req.tags or [], + category=req.category, + state="baby", + use_count=0, + version=1, + skill_md_path=skill_md_path, + git_commit_sha=None, + ) + db.add(skill) + + # 记录初始版本 + if req.skill_md_content: + sv = SkillVersion( + id=str(uuid.uuid4()), + user_id=req.user_id, + skill_name=req.skill_name, + version=1, + skill_md_content=req.skill_md_content, + git_commit_sha=None, + description="初始版本", + ) + db.add(sv) + + # 记录事件 + ev = EvolutionEvent( + id=str(uuid.uuid4()), + user_id=req.user_id, + skill_name=req.skill_name, + event_type="created", + description=f"管理员 {admin_user.username} 创建技能 {req.skill_name}", + version_before=None, + version_after=1, + ) + db.add(ev) + + db.commit() + db.refresh(skill) + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="create_skill", + target="evolution_skills", + detail=f"创建技能 {req.skill_name}(归属用户: {user.username})", + ) + + return {"code": 0, "data": skill_to_dict(skill, include_md=True)} + + +# ============================================================ +# 更新技能 +# ============================================================ + +class UpdateSkillSchema(BaseModel): + skill_name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + tags: Optional[List[str]] = None + category: Optional[str] = None + skill_md_content: Optional[str] = None + + +@router.put("/{skill_id}") +async def update_skill( + skill_id: str, + req: UpdateSkillSchema, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新技能信息""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + old_version = skill.version + update_data = req.model_dump(exclude_none=True) + + # 处理 SKILL.md 内容 + if "skill_md_content" in update_data: + content = update_data.pop("skill_md_content") + if content is not None: + skill_dir = os.path.join(UPLOAD_DIR, "skills", skill.user_id) + os.makedirs(skill_dir, exist_ok=True) + safe_name = skill.skill_name.replace("/", "_").replace("\\", "_") + skill_md_path = os.path.join(skill_dir, f"{safe_name}.md") + try: + with open(skill_md_path, 'w', encoding='utf-8') as f: + f.write(content) + skill.skill_md_path = skill_md_path + except Exception as e: + logger.warning(f"保存 SKILL.md 失败: {e}") + + # 记录新版本 + new_version = old_version + 1 + sv = SkillVersion( + id=str(uuid.uuid4()), + user_id=skill.user_id, + skill_name=skill.skill_name, + version=new_version, + skill_md_content=content, + git_commit_sha=None, + description="管理员更新", + ) + db.add(sv) + skill.version = new_version + + # 记录事件 + ev = EvolutionEvent( + id=str(uuid.uuid4()), + user_id=skill.user_id, + skill_name=skill.skill_name, + event_type="edited", + description=f"管理员 {admin_user.username} 更新技能 {skill.skill_name}", + version_before=old_version, + version_after=new_version, + ) + db.add(ev) + + # 更新其他字段 + for key, value in update_data.items(): + if key not in ("skill_md_content",) and hasattr(skill, key): + setattr(skill, key, value) + + skill.updated_at = datetime.now() + db.commit() + db.refresh(skill) + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="update_skill", + target="evolution_skills", + detail=f"更新技能 {skill.skill_name}(版本 {old_version} → {skill.version})", + ) + + return {"code": 0, "data": skill_to_dict(skill, include_md=True)} + + +# ============================================================ +# 删除技能 +# ============================================================ + +@router.delete("/{skill_id}") +async def delete_skill( + skill_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除技能""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + skill_name = skill.skill_name + user_id = skill.user_id + + # 记录事件 + ev = EvolutionEvent( + id=str(uuid.uuid4()), + user_id=user_id, + skill_name=skill_name, + event_type="deleted", + description=f"管理员 {admin_user.username} 删除技能 {skill_name}", + ) + db.add(ev) + + # 删除版本历史 + db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete() + + # 删除发布记录 + db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill_id).delete() + + # 删除团队共享记录 + db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill_id).delete() + + db.delete(skill) + db.commit() + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="delete_skill", + target="evolution_skills", + detail=f"删除技能 {skill_name}", + ) + + return {"code": 0, "message": f"技能 {skill_name} 已删除"} + + +# ============================================================ +# 修改技能状态 +# ============================================================ + +class ChangeStateSchema(BaseModel): + state: str = Field(..., description="目标状态: baby/main/staging/frozen") + + +@router.put("/{skill_id}/state") +async def change_skill_state( + skill_id: str, + req: ChangeStateSchema, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """修改技能状态""" + if req.state not in SKILL_STATES: + raise HTTPException(status_code=400, detail=f"无效状态,可选值: {list(SKILL_STATES.keys())}") + + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + old_state = skill.state + skill.state = req.state + skill.updated_at = datetime.now() + + # 记录状态变更事件 + ev_type = "promoted" if req.state in ("main", "staging") else "frozen" if req.state == "frozen" else "unfrozen" + ev = EvolutionEvent( + id=str(uuid.uuid4()), + user_id=skill.user_id, + skill_name=skill.skill_name, + event_type=ev_type, + description=f"管理员 {admin_user.username} 将技能状态从 {old_state} 变更为 {req.state}", + ) + db.add(ev) + db.commit() + db.refresh(skill) + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="change_skill_state", + target="evolution_skills", + detail=f"修改技能 {skill.skill_name} 状态: {old_state} → {req.state}", + ) + + return {"code": 0, "data": skill_to_dict(skill)} + + +# ============================================================ +# 获取版本历史 +# ============================================================ + +@router.get("/{skill_id}/versions") +async def get_skill_versions( + skill_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取技能版本历史""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + versions = db.query(SkillVersion).filter( + SkillVersion.skill_name == skill.skill_name, + SkillVersion.user_id == skill.user_id, + ).order_by(desc(SkillVersion.version)).all() + + return { + "code": 0, + "data": { + "skill_id": skill.id, + "skill_name": skill.skill_name, + "current_version": skill.version, + "versions": [version_to_dict(v) for v in versions], + } + } + + +# ============================================================ +# 回滚到指定版本 +# ============================================================ + +class RollbackSchema(BaseModel): + version: int = Field(..., ge=1, description="目标版本号") + + +@router.post("/{skill_id}/rollback") +async def rollback_skill_version( + skill_id: str, + req: RollbackSchema, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """回滚技能到指定版本""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + target_version = db.query(SkillVersion).filter( + SkillVersion.skill_name == skill.skill_name, + SkillVersion.user_id == skill.user_id, + SkillVersion.version == req.version, + ).first() + + if not target_version: + raise HTTPException(status_code=404, detail=f"版本 {req.version} 不存在") + + old_version = skill.version + + # 更新 SKILL.md 文件 + if target_version.skill_md_content: + if skill.skill_md_path: + try: + with open(skill.skill_md_path, 'w', encoding='utf-8') as f: + f.write(target_version.skill_md_content) + except Exception as e: + logger.warning(f"回滚保存 SKILL.md 失败: {e}") + + # 记录新版本(基于目标版本) + new_version = old_version + 1 + sv = SkillVersion( + id=str(uuid.uuid4()), + user_id=skill.user_id, + skill_name=skill.skill_name, + version=new_version, + skill_md_content=target_version.skill_md_content, + git_commit_sha=None, + description=f"回滚到版本 {req.version}(管理员操作)", + ) + db.add(sv) + + skill.version = new_version + skill.updated_at = datetime.now() + + ev = EvolutionEvent( + id=str(uuid.uuid4()), + user_id=skill.user_id, + skill_name=skill.skill_name, + event_type="edited", + description=f"管理员 {admin_user.username} 回滚技能 {skill.skill_name} 从版本 {old_version} 到 {req.version}(新版本 {new_version})", + version_before=old_version, + version_after=new_version, + ) + db.add(ev) + + db.commit() + db.refresh(skill) + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="rollback_skill", + target="evolution_skills", + detail=f"回滚技能 {skill.skill_name} 到版本 {req.version}(新版本 {new_version})", + ) + + return {"code": 0, "data": skill_to_dict(skill, include_md=True)} + + +# ============================================================ +# 发布到应用市场 +# ============================================================ + +class PublishSkillSchema(BaseModel): + price: float = Field(0.0, ge=0, description="定价(0表示免费)") + market_description: str = Field("", description="市场描述") + + +@router.post("/{skill_id}/publish") +async def publish_skill( + skill_id: str, + req: PublishSkillSchema, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """发布技能到应用市场""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + # 检查是否已发布 + existing = db.query(SkillPublishRecord).filter( + SkillPublishRecord.skill_id == skill_id, + SkillPublishRecord.status == "published" + ).first() + + if existing: + # 更新现有发布 + existing.price = req.price + existing.market_description = req.market_description + msg = "更新发布信息" + else: + # 创建发布记录 + record = SkillPublishRecord( + id=str(uuid.uuid4()), + skill_id=skill_id, + skill_name=skill.skill_name, + user_id=skill.user_id, + price=req.price, + market_description=req.market_description, + status="published", + ) + db.add(record) + msg = "发布成功" + + db.commit() + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="publish_skill", + target="skill_publish_records", + detail=f"{msg}: {skill.skill_name},定价: ¥{req.price}", + ) + + return {"code": 0, "message": msg, "data": {"skill_id": skill_id, "price": req.price}} + + +# ============================================================ +# 下架技能 +# ============================================================ + +@router.post("/{skill_id}/unpublish") +async def unpublish_skill( + skill_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """从应用市场下架技能""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + updated = db.query(SkillPublishRecord).filter( + SkillPublishRecord.skill_id == skill_id, + SkillPublishRecord.status == "published" + ).update({"status": "unpublished"}) + db.commit() + + if updated == 0: + raise HTTPException(status_code=400, detail="该技能未发布到市场") + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="unpublish_skill", + target="skill_publish_records", + detail=f"下架技能: {skill.skill_name}", + ) + + return {"code": 0, "message": f"技能 {skill.skill_name} 已下架"} + + +# ============================================================ +# 获取统计数据 +# ============================================================ + +@router.get("/stats/overview") +async def get_skills_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取技能统计概览""" + total = db.query(EvolutionSkill).count() + by_state = dict( + db.query(EvolutionSkill.state, func.count(EvolutionSkill.id)) + .group_by(EvolutionSkill.state).all() + ) + by_category = dict( + db.query(EvolutionSkill.category, func.count(EvolutionSkill.id)) + .filter(EvolutionSkill.category.isnot(None)) + .group_by(EvolutionSkill.category).all() + ) + total_published = db.query(SkillPublishRecord).filter( + SkillPublishRecord.status == "published" + ).count() + total_versions = db.query(SkillVersion).count() + + return { + "code": 0, + "data": { + "total_skills": total, + "by_state": {k: by_state.get(k, 0) for k in SKILL_STATES}, + "by_category": by_category, + "total_published": total_published, + "total_versions": total_versions, + } + } diff --git a/backend/python/app/api/admin/storage_admin.py b/backend/python/app/api/admin/storage_admin.py new file mode 100644 index 0000000..f5da3b1 --- /dev/null +++ b/backend/python/app/api/admin/storage_admin.py @@ -0,0 +1,904 @@ +""" +OSS存储配置管理 API +Phase 3.1: 支持 MinIO / 阿里云OSS / AWS S3 等对象存储配置 +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from sqlalchemy import or_, func, desc, asc, text +from sqlalchemy.orm import Session +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 ( + gen_uuid, +) +from ...auth import get_current_admin +from ...middleware.log_service import LogService + +router = APIRouter() +logger = logging.getLogger("aiflow") + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class OSSConfigCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + provider: str = Field(min_length=1, max_length=20) + endpoint: str = Field(min_length=1, max_length=500) + access_key: str = Field(min_length=1, max_length=255) + secret_key: str = Field(min_length=1, max_length=255) + bucket: str = Field(min_length=1, max_length=200) + region: Optional[str] = "" + base_path: Optional[str] = "" + is_default: bool = False + is_enabled: bool = True + config: Optional[Dict[str, Any]] = None + + +class OSSConfigUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200) + provider: Optional[str] = Field(None, min_length=1, max_length=20) + endpoint: Optional[str] = Field(None, min_length=1, max_length=500) + access_key: Optional[str] = Field(None, min_length=1, max_length=255) + secret_key: Optional[str] = Field(None, min_length=1, max_length=255) + bucket: Optional[str] = Field(None, min_length=1, max_length=200) + region: Optional[str] = None + base_path: Optional[str] = None + is_default: Optional[bool] = None + is_enabled: Optional[bool] = None + config: Optional[Dict[str, Any]] = None + + +class OSSConnectionTest(BaseModel): + """测试连接请求 - 可以测试已保存的配置或临时配置""" + config_id: Optional[str] = None # 已保存的配置ID + # 以下为临时测试参数 + provider: Optional[str] = None + endpoint: Optional[str] = None + access_key: Optional[str] = None + secret_key: Optional[str] = None + bucket: Optional[str] = None + region: Optional[str] = None + + +# ============================================================ +# Helper Functions +# ============================================================ + +def config_to_dict(config) -> dict: + """将OSSStorageConfig模型转换为字典""" + result = { + "id": config.id, + "name": config.name, + "provider": config.provider, + "endpoint": config.endpoint, + "access_key": config.access_key[:8] + "****" if config.access_key and len(config.access_key) > 8 else "****", + "secret_key": "****", # 永远不返回完整密钥 + "bucket": config.bucket, + "region": config.region or "", + "base_path": config.base_path or "", + "is_default": getattr(config, "is_default", False), + "is_enabled": config.is_enabled, + "status": config.status or "disconnected", + "created_at": config.created_at.isoformat() if config.created_at else None, + "updated_at": config.updated_at.isoformat() if config.updated_at else None, + } + + # 安全处理 JSON 字段 + config_raw = getattr(config, "config", None) + if config_raw: + try: + result["config"] = json.loads(config_raw) if isinstance(config_raw, str) else config_raw + except Exception: + result["config"] = {} + else: + result["config"] = {} + + return result + + + + +# ============================================================ +# Storage Quota Management (Phase C5) +# ============================================================ + +DEFAULT_QUOTAS = { + 0: 1073741824, # Free: 1GB + 2: 10737418240, # Pro: 10GB + 3: 107374182400, # Enterprise: 100GB +} + +TIER_LABELS = {0: "\u514d\u8d39\u7248", 2: "\u4e13\u4e1a\u7248", 3: "\u4f01\u4e1a\u7248"} + + +class QuotaAdjustRequest(BaseModel): + quota_bytes: Optional[int] = Field(None, ge=0) + quota_type: Optional[str] = Field(None, max_length=20) + + +def ensure_user_quota(user_id: str, membership_level: int, plan_id: str = None, db: Session = None) -> dict: + from ...models import UserStorageQuota + quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first() + default_bytes = DEFAULT_QUOTAS.get(membership_level, DEFAULT_QUOTAS[0]) + if not quota: + quota = UserStorageQuota( + id=gen_uuid(), user_id=user_id, quota_bytes=default_bytes, + used_bytes=0, quota_type="membership", + membership_level=membership_level, plan_id=plan_id, + ) + db.add(quota) + db.commit() + db.refresh(quota) + elif quota.quota_type == "membership" and quota.membership_level != membership_level: + quota.quota_bytes = default_bytes + quota.membership_level = membership_level + quota.plan_id = plan_id + db.commit() + db.refresh(quota) + return { + "id": quota.id, "user_id": quota.user_id, + "quota_bytes": quota.quota_bytes, "used_bytes": quota.used_bytes, + "quota_type": quota.quota_type, "membership_level": quota.membership_level, + "plan_id": quota.plan_id, + "created_at": quota.created_at.isoformat() if quota.created_at else None, + "updated_at": quota.updated_at.isoformat() if quota.updated_at else None, + } + + +def recalculate_storage_usage(user_id: str, db: Session) -> int: + from ...models import KnowledgeDocument, KnowledgeBase + total = db.query(func.coalesce(func.sum(KnowledgeDocument.file_size), 0)).join( + KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id + ).filter( + KnowledgeBase.user_id == user_id, + ).scalar() or 0 + return int(total) + + +@router.get("/quota/overview", summary="Storage Quota Overview") +async def get_quota_overview( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota, User + total_quota_users = db.query(func.count(UserStorageQuota.id)).scalar() or 0 + total_quota_bytes = db.query(func.coalesce(func.sum(UserStorageQuota.quota_bytes), 0)).scalar() or 0 + total_used_bytes = db.query(func.coalesce(func.sum(UserStorageQuota.used_bytes), 0)).scalar() or 0 + total_users = db.query(func.count(User.id)).filter(User.status == "active").scalar() or 0 + users_without_quota = total_users - total_quota_users + by_level = {} + level_stats = db.query( + UserStorageQuota.membership_level, + func.count(UserStorageQuota.id), + func.coalesce(func.sum(UserStorageQuota.quota_bytes), 0), + func.coalesce(func.sum(UserStorageQuota.used_bytes), 0), + ).group_by(UserStorageQuota.membership_level).all() + for level, count, total_q, total_u in level_stats: + by_level[str(level)] = { + "label": TIER_LABELS.get(level, f"Level {level}"), + "user_count": count, + "total_quota_bytes": int(total_q), + "total_used_bytes": int(total_u), + } + over_quota_count = db.query(func.count(UserStorageQuota.id)).filter( + UserStorageQuota.quota_bytes > 0, + UserStorageQuota.used_bytes > UserStorageQuota.quota_bytes + ).scalar() or 0 + return { + "code": 0, + "data": { + "total_users": total_users, + "quota_users": total_quota_users, + "users_without_quota": users_without_quota, + "total_quota_bytes": int(total_quota_bytes), + "total_used_bytes": int(total_used_bytes), + "over_quota_count": over_quota_count, + "by_level": by_level, + "default_quotas": {str(k): v for k, v in DEFAULT_QUOTAS.items()}, + "tier_labels": TIER_LABELS, + }, + "message": "ok" + } + + +@router.get("/quota/users", summary="User Storage Quota List") +async def list_user_quotas( + keyword: str = Query(None), + membership_level: int = Query(None), + over_quota: bool = Query(None), + 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), +): + from ...models import UserStorageQuota, User + results = db.query( + User.id, User.username, User.email, User.membership_level, User.real_name, + UserStorageQuota.id.label("quota_id"), + UserStorageQuota.quota_bytes, UserStorageQuota.used_bytes, + UserStorageQuota.quota_type, UserStorageQuota.updated_at, + ).join(UserStorageQuota, User.id == UserStorageQuota.user_id, isouter=True + ).filter(User.status == "active") + if keyword: + results = results.filter( + or_(User.username.ilike(f"%{keyword}%"), User.email.ilike(f"%{keyword}%")) + ) + if membership_level is not None: + results = results.filter(User.membership_level == membership_level) + total = results.count() + items = results.order_by(desc(UserStorageQuota.used_bytes)).offset( + (page - 1) * page_size).limit(page_size).all() + user_list = [] + for row in items: + uid, username, email, level, real_name, qid, qbytes, ubytes, qtype, updated = row + quota_b = int(qbytes or DEFAULT_QUOTAS.get(level or 0, DEFAULT_QUOTAS[0])) + used_b = int(ubytes or 0) + usage_pct = round(used_b / quota_b * 100, 1) if quota_b > 0 else 0 + user_list.append({ + "user_id": uid, "username": username, "email": email or "", + "real_name": real_name or "", "membership_level": level or 0, + "tier_label": TIER_LABELS.get(level or 0, "\u514d\u8d39\u7248"), + "quota_id": qid, "quota_bytes": quota_b, "used_bytes": used_b, + "usage_percent": usage_pct, "is_over_quota": usage_pct >= 100, + "quota_type": qtype or "membership", + "updated_at": updated.isoformat() if updated else None, + }) + if over_quota: + user_list = [u for u in user_list if u["is_over_quota"]] + return { + "code": 0, + "data": { + "items": user_list, + "total": len(user_list) if over_quota else total, + "page": page, "page_size": page_size, + }, + "message": "ok" + } + + +@router.get("/quota/users/{user_id}", summary="Get User Quota Detail") +async def get_user_quota( + user_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota, User, KnowledgeDocument, KnowledgeBase + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first() + actual_used = recalculate_storage_usage(user_id, db) + file_count = db.query(func.count(KnowledgeDocument.id)).join( + KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id + ).filter(KnowledgeBase.user_id == user_id).scalar() or 0 + file_type_stats = {} + type_counts = db.query( + KnowledgeDocument.file_type, func.count(KnowledgeDocument.id), + func.coalesce(func.sum(KnowledgeDocument.file_size), 0), + ).join( + KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id + ).filter(KnowledgeBase.user_id == user_id + ).group_by(KnowledgeDocument.file_type).all() + for ftype, cnt, fsize in type_counts: + file_type_stats[ftype] = {"count": cnt, "total_bytes": int(fsize)} + quota_data = None + if quota: + quota_data = { + "id": quota.id, "quota_bytes": int(quota.quota_bytes), + "used_bytes": actual_used, "original_used": int(quota.used_bytes), + "quota_type": quota.quota_type, "membership_level": quota.membership_level, + "plan_id": quota.plan_id, + "created_at": quota.created_at.isoformat() if quota.created_at else None, + "updated_at": quota.updated_at.isoformat() if quota.updated_at else None, + } + if actual_used != int(quota.used_bytes): + quota.used_bytes = actual_used + db.commit() + return { + "code": 0, + "data": { + "user": { + "id": user.id, "username": user.username, + "email": user.email or "", "real_name": user.real_name or "", + "membership_level": user.membership_level or 0, + "tier_label": TIER_LABELS.get(user.membership_level or 0, "\u514d\u8d39\u7248"), + }, + "quota": quota_data, "file_count": file_count, + "file_type_stats": file_type_stats, + }, + "message": "ok" + } + + +@router.put("/quota/users/{user_id}", summary="Adjust User Quota") +async def adjust_user_quota( + user_id: str, + req: QuotaAdjustRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota, User + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first() + if not quota: + default_bytes = DEFAULT_QUOTAS.get(user.membership_level or 0, DEFAULT_QUOTAS[0]) + quota = UserStorageQuota( + id=gen_uuid(), user_id=user_id, quota_bytes=default_bytes, + used_bytes=0, quota_type="membership", + membership_level=user.membership_level or 0, + ) + db.add(quota) + db.flush() + if req.quota_bytes is not None: + quota.quota_bytes = req.quota_bytes + if req.quota_type is not None: + quota.quota_type = req.quota_type + quota.used_bytes = recalculate_storage_usage(user_id, db) + db.commit() + db.refresh(quota) + LogService.log_audit( + admin_id=admin_user.id, admin_name=admin_user.username, + action="adjust_storage_quota", target_type="user_storage_quota", + target_id=quota.id, + detail=f"Adjusted quota for {user.username} to {quota.quota_bytes} bytes" + ) + usage_pct = round(quota.used_bytes / quota.quota_bytes * 100, 1) if quota.quota_bytes > 0 else 0 + return { + "code": 0, + "data": { + "id": quota.id, "user_id": quota.user_id, + "quota_bytes": int(quota.quota_bytes), + "used_bytes": int(quota.used_bytes), + "quota_type": quota.quota_type, "usage_percent": usage_pct, + }, + "message": "Quota adjusted successfully" + } + + +@router.post("/quota/recalculate/{user_id}", summary="Recalculate User Storage") +async def recalculate_user_quota( + user_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota + quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first() + actual_used = recalculate_storage_usage(user_id, db) + if quota: + quota.used_bytes = actual_used + db.commit() + return { + "code": 0, + "data": { + "user_id": user_id, "used_bytes": actual_used, + "quota_bytes": int(quota.quota_bytes), + }, + "message": f"Recalculated: {actual_used} bytes" + } + else: + return { + "code": 0, + "data": {"user_id": user_id, "used_bytes": actual_used}, + "message": "No quota record for this user" + } + + +@router.post("/quota/init-all", summary="Init All User Quotas") +async def init_all_user_quotas( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + from ...models import UserStorageQuota, User + users = db.query(User).filter(User.status == "active").all() + created, skipped = 0, 0 + for user in users: + existing = db.query(UserStorageQuota).filter( + UserStorageQuota.user_id == user.id).first() + if existing: + skipped += 1 + continue + default_bytes = DEFAULT_QUOTAS.get(user.membership_level or 0, DEFAULT_QUOTAS[0]) + used = recalculate_storage_usage(user.id, db) + quota = UserStorageQuota( + id=gen_uuid(), user_id=user.id, quota_bytes=default_bytes, + used_bytes=used, quota_type="membership", + membership_level=user.membership_level or 0, + plan_id=user.membership_plan_id, + ) + db.add(quota) + created += 1 + db.commit() + return { + "code": 0, + "data": {"created": created, "skipped": skipped, "total": len(users)}, + "message": f"Init complete: {created} new, {skipped} existing, {len(users)} total" + } + + + +async def test_oss_connection(provider: str, endpoint: str, access_key: str, secret_key: str, + bucket: str, region: str = "") -> Dict[str, Any]: + """测试OSS连接 - 使用httpx发送HTTP请求到对象存储端点""" + import time + start_time = time.time() + + try: + if provider in ("minio", "aws_s3"): + # 使用 AWS S3 兼容 API 测试 + # ListBuckets 请求 + import hmac + import hashlib + from urllib.parse import urlparse + + parsed = urlparse(endpoint) + host = parsed.netloc or parsed.path + if not host: + return {"success": False, "error": "无法解析端点地址", "latency_ms": 0} + + # 先尝试简单的 bucket HEAD 请求 + bucket_url = f"{endpoint.rstrip('/')}/{bucket}" + async with httpx.AsyncClient(timeout=10.0, verify=False) as client: + resp = await client.head(bucket_url) + latency = int((time.time() - start_time) * 1000) + + if resp.status_code in (200, 403): + # 200: bucket存在且有权限; 403: bucket存在但无权限(也是连通的表现) + return { + "success": True, + "message": f"连接成功!Bucket '{bucket}' 可访问", + "latency_ms": latency, + "status_code": resp.status_code + } + elif resp.status_code == 404: + return { + "success": False, + "error": f"Bucket '{bucket}' 不存在", + "latency_ms": latency + } + else: + return { + "success": False, + "error": f"连接失败: HTTP {resp.status_code}", + "latency_ms": latency + } + + elif provider == "aliyun_oss": + # 阿里云OSS: 简单HEAD请求 + bucket_url = f"{endpoint.rstrip('/')}/{bucket}" if not endpoint.endswith(bucket) else endpoint + async with httpx.AsyncClient(timeout=10.0, verify=False) as client: + resp = await client.head(bucket_url) + latency = int((time.time() - start_time) * 1000) + + if resp.status_code in (200, 403): + return { + "success": True, + "message": f"连接成功!Bucket '{bucket}' 可访问", + "latency_ms": latency, + "status_code": resp.status_code + } + elif resp.status_code == 404: + return { + "success": False, + "error": f"Bucket '{bucket}' 不存在", + "latency_ms": latency + } + else: + return { + "success": False, + "error": f"连接失败: HTTP {resp.status_code}", + "latency_ms": latency + } + + else: + return {"success": False, "error": f"不支持的存储类型: {provider}", "latency_ms": 0} + + except httpx.ConnectError: + latency = int((time.time() - start_time) * 1000) + return {"success": False, "error": f"无法连接到 {endpoint}", "latency_ms": latency} + except httpx.TimeoutException: + latency = int((time.time() - start_time) * 1000) + return {"success": False, "error": "连接超时(10秒)", "latency_ms": latency} + except Exception as e: + latency = int((time.time() - start_time) * 1000) + return {"success": False, "error": f"测试失败: {str(e)}", "latency_ms": latency} + + +# ============================================================ +# API Endpoints +# ============================================================ + +@router.get("/stats/overview", summary="存储配置统计概览") +async def get_storage_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取存储配置的统计信息""" + from ...models import OSSStorageConfig + + total = db.query(func.count(OSSStorageConfig.id)).scalar() or 0 + enabled_count = db.query(func.count(OSSStorageConfig.id)).filter( + OSSStorageConfig.is_enabled == True + ).scalar() or 0 + default_count = db.query(func.count(OSSStorageConfig.id)).filter( + OSSStorageConfig.is_default == True + ).scalar() or 0 + + # 按类型统计 + by_provider = {} + providers = db.query(OSSStorageConfig.provider, func.count(OSSStorageConfig.id)).group_by( + OSSStorageConfig.provider + ).all() + for provider, count in providers: + by_provider[provider] = count + + # 按状态统计 + by_status = {} + statuses = db.query(OSSStorageConfig.status, func.count(OSSStorageConfig.id)).group_by( + OSSStorageConfig.status + ).all() + for status, count in statuses: + by_status[status] = count + + return { + "code": 0, + "data": { + "total": total, + "enabled_count": enabled_count, + "default_count": default_count, + "by_provider": by_provider, + "by_status": by_status, + }, + "message": "ok" + } + + +@router.get("/", summary="获取OSS存储配置列表") +async def list_storage_configs( + provider: str = Query(None, description="按提供商筛选"), + is_enabled: bool = 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), +): + """获取OSS存储配置列表,支持分页和筛选""" + from ...models import OSSStorageConfig + + query = db.query(OSSStorageConfig) + + if provider: + query = query.filter(OSSStorageConfig.provider == provider) + if is_enabled is not None: + query = query.filter(OSSStorageConfig.is_enabled == is_enabled) + if keyword: + keyword_filter = or_( + OSSStorageConfig.name.ilike(f"%{keyword}%"), + OSSStorageConfig.endpoint.ilike(f"%{keyword}%"), + OSSStorageConfig.bucket.ilike(f"%{keyword}%"), + ) + query = query.filter(keyword_filter) + + total = query.count() + items = query.order_by(desc(OSSStorageConfig.is_default), desc(OSSStorageConfig.updated_at)) \ + .offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "items": [config_to_dict(item) for item in items], + "total": total, + "page": page, + "page_size": page_size, + }, + "message": "ok" + } + + +@router.get("/{config_id}", summary="获取OSS存储配置详情") +async def get_storage_config( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取单个OSS存储配置的详细信息""" + from ...models import OSSStorageConfig + + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + + return { + "code": 0, + "data": config_to_dict(config), + "message": "ok" + } + + +@router.post("/", summary="创建OSS存储配置") +async def create_storage_config( + req: OSSConfigCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """创建新的OSS存储配置""" + from ...models import OSSStorageConfig + + # 检查名称重复 + existing = db.query(OSSStorageConfig).filter(OSSStorageConfig.name == req.name).first() + if existing: + raise HTTPException(status_code=400, detail="配置名称已存在") + + # 如果设为默认,先取消其他默认 + if req.is_default: + db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update( + {"is_default": False} + ) + + config = OSSStorageConfig( + id=gen_uuid(), + name=req.name, + provider=req.provider, + endpoint=req.endpoint, + access_key=req.access_key, + secret_key=req.secret_key, + bucket=req.bucket, + region=req.region or "", + base_path=req.base_path or "", + is_default=req.is_default, + is_enabled=req.is_enabled, + status="disconnected", + config=req.config, + ) + + db.add(config) + db.commit() + db.refresh(config) + + # 审计日志 + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="create_oss_config", + target_type="oss_storage_config", + target_id=config.id, + detail=f"创建了OSS存储配置: {config.name} ({config.provider})" + ) + + return { + "code": 0, + "data": config_to_dict(config), + "message": "创建成功" + } + + +@router.put("/{config_id}", summary="更新OSS存储配置") +async def update_storage_config( + config_id: str, + req: OSSConfigUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新OSS存储配置""" + from ...models import OSSStorageConfig + + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + + # 检查名称重复 + if req.name is not None and req.name != config.name: + existing = db.query(OSSStorageConfig).filter( + OSSStorageConfig.name == req.name, + OSSStorageConfig.id != config_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="配置名称已存在") + + # 如果设为默认,先取消其他默认 + if req.is_default and not config.is_default: + db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update( + {"is_default": False} + ) + + # 更新字段 + update_fields = ["name", "provider", "endpoint", "access_key", "secret_key", + "bucket", "region", "base_path", "is_default", "is_enabled"] + for field in update_fields: + value = getattr(req, field, None) + if value is not None: + setattr(config, field, value) + # 如果修改了连接参数,重置状态 + if field in ("endpoint", "access_key", "secret_key", "bucket", "provider"): + config.status = "disconnected" + + if req.config is not None: + config.config = req.config + + config.updated_at = datetime.now() + + db.commit() + db.refresh(config) + + # 审计日志 + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_oss_config", + target_type="oss_storage_config", + target_id=config.id, + detail=f"更新了OSS存储配置: {config.name}" + ) + + return { + "code": 0, + "data": config_to_dict(config), + "message": "更新成功" + } + + +@router.delete("/{config_id}", summary="删除OSS存储配置") +async def delete_storage_config( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除OSS存储配置""" + from ...models import OSSStorageConfig + + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + + name = config.name + db.delete(config) + db.commit() + + # 审计日志 + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="delete_oss_config", + target_type="oss_storage_config", + target_id=config_id, + detail=f"删除了OSS存储配置: {name}" + ) + + return { + "code": 0, + "data": None, + "message": f"已删除配置: {name}" + } + + +@router.put("/{config_id}/toggle", summary="启用/禁用存储配置") +async def toggle_storage_config( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """切换存储配置的启用/禁用状态""" + from ...models import OSSStorageConfig + + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + + config.is_enabled = not config.is_enabled + config.updated_at = datetime.now() + db.commit() + db.refresh(config) + + action = "启用" if config.is_enabled else "禁用" + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="toggle_oss_config", + target_type="oss_storage_config", + target_id=config.id, + detail=f"{action}了OSS存储配置: {config.name}" + ) + + return { + "code": 0, + "data": config_to_dict(config), + "message": f"已{action}" + } + + +@router.put("/{config_id}/set-default", summary="设为默认存储配置") +async def set_default_storage_config( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """将指定配置设为默认存储""" + from ...models import OSSStorageConfig + + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + + # 取消所有默认 + db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update( + {"is_default": False} + ) + + # 设置新默认 + config.is_default = True + config.updated_at = datetime.now() + db.commit() + db.refresh(config) + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="set_default_oss_config", + target_type="oss_storage_config", + target_id=config.id, + detail=f"将 {config.name} 设为默认存储配置" + ) + + return { + "code": 0, + "data": config_to_dict(config), + "message": f"已将 {config.name} 设为默认存储" + } + + +@router.post("/test", summary="测试OSS连接") +async def test_storage_connection( + req: OSSConnectionTest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """测试OSS存储连接""" + from ...models import OSSStorageConfig + + provider = req.provider + endpoint = req.endpoint + access_key = req.access_key + secret_key = req.secret_key + bucket = req.bucket + region = req.region or "" + + # 如果提供了config_id,从数据库获取配置 + if req.config_id: + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == req.config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + provider = config.provider + endpoint = config.endpoint + access_key = config.access_key + secret_key = config.secret_key + bucket = config.bucket + region = config.region or "" + + if not all([provider, endpoint, access_key, secret_key, bucket]): + raise HTTPException(status_code=400, detail="缺少必要的连接参数") + + result = await test_oss_connection(provider, endpoint, access_key, secret_key, bucket, region) + + # 如果是已保存的配置,更新连接状态 + if req.config_id: + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == req.config_id).first() + if config: + config.status = "connected" if result["success"] else "disconnected" + config.updated_at = datetime.now() + db.commit() + + return { + "code": 0, + "data": result, + "message": "连接测试完成" + } diff --git a/backend/python/app/api/admin/tenant_admin.py b/backend/python/app/api/admin/tenant_admin.py new file mode 100644 index 0000000..37f1184 --- /dev/null +++ b/backend/python/app/api/admin/tenant_admin.py @@ -0,0 +1,426 @@ +""" +多租户管理后端 API +- 租户 CRUD、成员管理、配额管理、邀请管理 +- 路由前缀: /app/api/admin/tenant +""" +import uuid +import json +from datetime import datetime, timedelta +from fastapi import APIRouter, Query, HTTPException, Depends +from ...database import SessionLocal, get_db +from ..auth import get_current_user +from sqlalchemy import text +from sqlalchemy.orm import Session + +router = APIRouter() + +CST_TZ = None + +def _format_cst_time(dt_val): + """Format datetime to CST string.""" + global CST_TZ + if CST_TZ is None: + from datetime import timezone + CST_TZ = timezone(timedelta(hours=8)) + if dt_val is None: + return None + if isinstance(dt_val, str): + return dt_val + if dt_val.tzinfo is None: + dt_val = dt_val.replace(tzinfo=CST_TZ) + return dt_val.strftime("%Y-%m-%d %H:%M:%S") + +def _row_to_dict(row, columns): + """Convert a SQLAlchemy row to dict.""" + return {col: _format_cst_time(val) if isinstance(val, datetime) else val for col, val in zip(columns, row)} + +def _execute_raw(db: Session, sql: str, params: dict = None): + """Execute raw SQL and return results.""" + result = db.execute(text(sql), params or {}) + if result.returns_rows: + cols = list(result.keys()) + rows = result.fetchall() + return [_row_to_dict(row, cols) for row in rows] + return [] + + +# ============================================================ +# 租户管理 +# ============================================================ + +@router.get("/list") +async def list_tenants( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="搜索关键词"), + status: str = Query("", description="状态筛选"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取租户列表""" + where = "1=1" + params = {} + if keyword: + where += " AND (name LIKE :kw OR slug LIKE :kw)" + params["kw"] = f"%{keyword}%" + if status: + where += " AND status = :status" + params["status"] = status + + count_sql = f"SELECT COUNT(*) FROM tenants WHERE {where}" + total = db.execute(text(count_sql), params).scalar() + + offset = (page - 1) * page_size + list_sql = f""" + SELECT t.*, + (SELECT COUNT(*) FROM tenant_members WHERE tenant_id=t.id AND status='active') as member_count + FROM tenants t WHERE {where} + ORDER BY t.created_at DESC LIMIT :limit OFFSET :offset + """ + params["limit"] = page_size + params["offset"] = offset + rows = db.execute(text(list_sql), params).fetchall() + cols = list(db.execute(text(list_sql), params).keys()) + + items = [] + for row in rows: + d = _row_to_dict(row, cols) + items.append(d) + + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} + + +@router.post("/create") +async def create_tenant( + name: str = Query(..., description="租户名称"), + slug: str = Query(..., description="租户标识"), + description: str = Query("", description="描述"), + max_members: int = Query(10, description="最大成员数"), + max_storage_mb: int = Query(10240, description="最大存储(MB)"), + max_api_calls_per_day: int = Query(10000, description="每日API调用上限"), + expire_at: str = Query("", description="到期时间"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建租户""" + tid = str(uuid.uuid4()) + expire = None + if expire_at: + try: + expire = datetime.fromisoformat(expire_at) + except: + pass + + # Check slug uniqueness + existing = db.execute(text("SELECT id FROM tenants WHERE slug=:slug"), {"slug": slug}).fetchone() + if existing: + raise HTTPException(status_code=400, detail="slug已存在") + + db.execute(text(""" + INSERT INTO tenants (id, name, slug, description, status, max_members, max_storage_mb, max_api_calls_per_day, expire_at) + VALUES (:id, :name, :slug, :desc, 'active', :mm, :ms, :ma, :exp) + """), { + "id": tid, "name": name, "slug": slug, "desc": description, + "mm": max_members, "ms": max_storage_mb, "ma": max_api_calls_per_day, + "exp": expire + }) + + # Add creator as owner + db.execute(text(""" + INSERT INTO tenant_members (id, tenant_id, user_id, role, status) + VALUES (:mid, :tid, :uid, 'owner', 'active') + """), {"mid": str(uuid.uuid4()), "tid": tid, "uid": user.id}) + + db.commit() + return {"code": 200, "data": {"id": tid, "message": "租户创建成功"}} + + +@router.get("/{tenant_id}") +async def get_tenant(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """获取租户详情""" + row = db.execute(text(""" + SELECT t.*, + (SELECT COUNT(*) FROM tenant_members WHERE tenant_id=t.id AND status='active') as member_count, + (SELECT COUNT(*) FROM tenant_quotas WHERE tenant_id=t.id) as quota_count + FROM tenants t WHERE t.id=:id + """), {"id": tenant_id}).fetchone() + if not row: + raise HTTPException(status_code=404, detail="租户不存在") + cols = list(db.execute(text("SELECT t.* FROM tenants t WHERE t.id=:id"), {"id": tenant_id}).keys()) + # Re-query with proper cols + cols = ['id','name','slug','logo','description','status','plan_id','max_members','max_storage_mb', + 'max_api_calls_per_day','settings','created_at','updated_at','expire_at','member_count','quota_count'] + return {"code": 200, "data": _row_to_dict(row, cols)} + + +@router.put("/{tenant_id}") +async def update_tenant( + tenant_id: str, + name: str = Query(None), + description: str = Query(None), + status: str = Query(None), + max_members: int = Query(None), + max_storage_mb: int = Query(None), + max_api_calls_per_day: int = Query(None), + expire_at: str = Query(None), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新租户""" + sets = [] + params = {"id": tenant_id} + for field, val in [("name", name), ("description", description), ("status", status), + ("max_members", max_members), ("max_storage_mb", max_storage_mb), + ("max_api_calls_per_day", max_api_calls_per_day)]: + if val is not None: + sets.append(f"{field}=:{field}") + params[field] = val + if expire_at is not None: + sets.append("expire_at=:expire_at") + try: + params["expire_at"] = datetime.fromisoformat(expire_at) if expire_at else None + except: + params["expire_at"] = None + + if not sets: + return {"code": 200, "data": {"message": "无更新"}} + + db.execute(text(f"UPDATE tenants SET {','.join(sets)} WHERE id=:id"), params) + db.commit() + return {"code": 200, "data": {"message": "更新成功"}} + + +@router.delete("/{tenant_id}") +async def delete_tenant(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """删除租户(软删除 - 标记为terminated)""" + if tenant_id == "default-tenant-0001": + raise HTTPException(status_code=400, detail="默认租户不可删除") + db.execute(text("UPDATE tenants SET status='terminated' WHERE id=:id"), {"id": tenant_id}) + db.commit() + return {"code": 200, "data": {"message": "租户已终止"}} + + +# ============================================================ +# 成员管理 +# ============================================================ + +@router.get("/{tenant_id}/members") +async def list_members( + tenant_id: str, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取租户成员列表""" + total = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status!='removed'"), + {"tid": tenant_id}).scalar() + offset = (page - 1) * page_size + rows = db.execute(text(""" + SELECT tm.id, tm.tenant_id, tm.user_id, tm.role, tm.status, tm.joined_at, + u.username, u.email, u.avatar, u.role as user_role + FROM tenant_members tm + LEFT JOIN users u ON tm.user_id = u.id + WHERE tm.tenant_id=:tid AND tm.status!='removed' + ORDER BY tm.joined_at DESC LIMIT :limit OFFSET :offset + """), {"tid": tenant_id, "limit": page_size, "offset": offset}).fetchall() + cols = ['id','tenant_id','user_id','role','status','joined_at','username','email','avatar','user_role'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} + + +@router.post("/{tenant_id}/members/add") +async def add_member( + tenant_id: str, + user_id: str = Query(..., description="用户ID"), + role: str = Query("member", description="角色: owner/admin/member/viewer"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """添加成员到租户""" + # Check tenant exists + t = db.execute(text("SELECT max_members FROM tenants WHERE id=:id AND status='active'"), {"id": tenant_id}).fetchone() + if not t: + raise HTTPException(status_code=404, detail="租户不存在或已停用") + + # Check member limit + cnt = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status='active'"), + {"tid": tenant_id}).scalar() + if cnt >= t[0]: + raise HTTPException(status_code=400, detail="已达成员上限") + + # Check if already member + existing = db.execute(text("SELECT id FROM tenant_members WHERE tenant_id=:tid AND user_id=:uid"), + {"tid": tenant_id, "uid": user_id}).fetchone() + if existing: + db.execute(text("UPDATE tenant_members SET status='active', role=:role WHERE tenant_id=:tid AND user_id=:uid"), + {"tid": tenant_id, "uid": user_id, "role": role}) + else: + db.execute(text(""" + INSERT INTO tenant_members (id, tenant_id, user_id, role, status) + VALUES (:mid, :tid, :uid, :role, 'active') + """), {"mid": str(uuid.uuid4()), "tid": tenant_id, "uid": user_id, "role": role}) + + db.commit() + return {"code": 200, "data": {"message": "成员添加成功"}} + + +@router.put("/{tenant_id}/members/{member_id}") +async def update_member_role( + tenant_id: str, + member_id: str, + role: str = Query(..., description="新角色"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新成员角色""" + db.execute(text("UPDATE tenant_members SET role=:role WHERE id=:mid AND tenant_id=:tid"), + {"role": role, "mid": member_id, "tid": tenant_id}) + db.commit() + return {"code": 200, "data": {"message": "角色已更新"}} + + +@router.delete("/{tenant_id}/members/{member_id}") +async def remove_member(tenant_id: str, member_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """移除成员""" + db.execute(text("UPDATE tenant_members SET status='removed', removed_at=NOW() WHERE id=:mid AND tenant_id=:tid"), + {"mid": member_id, "tid": tenant_id}) + db.commit() + return {"code": 200, "data": {"message": "成员已移除"}} + + +# ============================================================ +# 配额管理 +# ============================================================ + +@router.get("/{tenant_id}/quotas") +async def get_quotas(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """获取租户配额使用情况""" + rows = db.execute(text(""" + SELECT tq.*, t.max_members, t.max_storage_mb, t.max_api_calls_per_day + FROM tenant_quotas tq + RIGHT JOIN tenants t ON tq.tenant_id = t.id + WHERE t.id=:tid + """), {"tid": tenant_id}).fetchall() + + # Build quota summary + quotas = { + "members": {"used": 0, "limit": 0}, + "storage": {"used": 0, "limit": 0}, + "api_calls": {"used": 0, "limit": 0}, + } + + # Get actual counts + member_count = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status='active'"), + {"tid": tenant_id}).scalar() + tenant_info = db.execute(text("SELECT max_members, max_storage_mb, max_api_calls_per_day FROM tenants WHERE id=:tid"), + {"tid": tenant_id}).fetchone() + + if tenant_info: + quotas["members"] = {"used": member_count, "limit": tenant_info[0]} + quotas["storage"] = {"used": 0, "limit": tenant_info[1]} + quotas["api_calls"] = {"used": 0, "limit": tenant_info[2]} + + # Get quota records + qrows = db.execute(text("SELECT * FROM tenant_quotas WHERE tenant_id=:tid"), {"tid": tenant_id}).fetchall() + qcols = ['id','tenant_id','resource_type','used','quota_limit','period','reset_at','updated_at'] + for r in qrows: + d = _row_to_dict(r, qcols) + rt = d['resource_type'] + if rt in quotas: + quotas[rt]["used"] = d['used'] + if d['quota_limit'] > 0: + quotas[rt]["limit"] = d['quota_limit'] + else: + quotas[rt] = {"used": d['used'], "limit": d['quota_limit']} + + return {"code": 200, "data": quotas} + + +@router.put("/{tenant_id}/quotas") +async def update_quotas( + tenant_id: str, + resource_type: str = Query(..., description="资源类型"), + quota_limit: int = Query(..., description="配额上限"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新租户配额""" + existing = db.execute(text("SELECT id FROM tenant_quotas WHERE tenant_id=:tid AND resource_type=:rt"), + {"tid": tenant_id, "rt": resource_type}).fetchone() + if existing: + db.execute(text("UPDATE tenant_quotas SET quota_limit=:ql WHERE tenant_id=:tid AND resource_type=:rt"), + {"ql": quota_limit, "tid": tenant_id, "rt": resource_type}) + else: + db.execute(text(""" + INSERT INTO tenant_quotas (id, tenant_id, resource_type, used, quota_limit, period) + VALUES (:id, :tid, :rt, 0, :ql, 'total') + """), {"id": str(uuid.uuid4()), "tid": tenant_id, "rt": resource_type, "ql": quota_limit}) + db.commit() + return {"code": 200, "data": {"message": "配额已更新"}} + + +# ============================================================ +# 邀请管理 +# ============================================================ + +@router.post("/{tenant_id}/invitations") +async def create_invitation( + tenant_id: str, + email: str = Query(..., description="邀请邮箱"), + role: str = Query("member", description="角色"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建租户邀请""" + import secrets + token = secrets.token_urlsafe(32) + expires = datetime.now() + timedelta(days=7) + + db.execute(text(""" + INSERT INTO tenant_invitations (id, tenant_id, email, role, token, status, invited_by, expires_at) + VALUES (:id, :tid, :email, :role, :token, 'pending', :uid, :exp) + """), { + "id": str(uuid.uuid4()), "tid": tenant_id, "email": email, + "role": role, "token": token, "uid": user.id, "exp": expires + }) + db.commit() + return {"code": 200, "data": {"token": token, "expires_at": _format_cst_time(expires)}} + + +@router.get("/{tenant_id}/invitations") +async def list_invitations(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """获取租户邀请列表""" + rows = db.execute(text(""" + SELECT ti.*, u.username as invited_by_name + FROM tenant_invitations ti + LEFT JOIN users u ON ti.invited_by = u.id + WHERE ti.tenant_id=:tid + ORDER BY ti.created_at DESC + """), {"tid": tenant_id}).fetchall() + cols = ['id','tenant_id','email','role','token','status','invited_by','accepted_by', + 'expires_at','created_at','invited_by_name'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": items} + + +# ============================================================ +# 统计 +# ============================================================ + +@router.get("/stats/overview") +async def tenant_stats(db: Session = Depends(get_db), user=Depends(get_current_user)): + """租户统计概览""" + total = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status!='terminated'")).scalar() + active = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status='active'")).scalar() + suspended = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status='suspended'")).scalar() + total_members = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE status='active'")).scalar() + total_invitations = db.execute(text("SELECT COUNT(*) FROM tenant_invitations WHERE status='pending'")).scalar() + + return {"code": 200, "data": { + "total_tenants": total, + "active_tenants": active, + "suspended_tenants": suspended, + "total_members": total_members, + "pending_invitations": total_invitations, + }} diff --git a/backend/python/app/api/admin/workflow_admin.py b/backend/python/app/api/admin/workflow_admin.py new file mode 100644 index 0000000..0423dc3 --- /dev/null +++ b/backend/python/app/api/admin/workflow_admin.py @@ -0,0 +1,1228 @@ +""" +Agent 可视化编排 API(管理员) +Phase 3.6: Agent Visual Orchestration - DAG Workflow Editor +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body, WebSocket, WebSocketDisconnect +from sqlalchemy.orm import Session +from sqlalchemy import or_, func +from pydantic import BaseModel +from typing import Optional, List, Any +import json +import uuid +import asyncio +from datetime import datetime + +from ...database import get_db, SessionLocal +from ...models import MarketApp, EvolutionSkill, AIModel, KnowledgeBase +from ...auth import get_current_admin +from ...middleware.log_service import LogService + +router = APIRouter() + + +# ============================================================ +# Pydantic models +# ============================================================ + +class WorkflowNode(BaseModel): + id: str + type: str # start / llm / knowledge / skill / condition / http / code / agent / end + position: dict # {x, y} + data: dict # {label, config: {...}} + + +class WorkflowEdge(BaseModel): + id: str + source: str + target: str + sourceHandle: Optional[str] = None + targetHandle: Optional[str] = None + + +class WorkflowCreate(BaseModel): + name: str + description: Optional[str] = None + agent_id: Optional[str] = None + nodes: List[WorkflowNode] = [] + edges: List[WorkflowEdge] = [] + variables: Optional[dict] = None + + +class WorkflowUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + agent_id: Optional[str] = None + nodes: Optional[List[WorkflowNode]] = None + edges: Optional[List[WorkflowEdge]] = None + variables: Optional[dict] = None + status: Optional[str] = None # draft / published / archived + + +class WorkflowExecuteRequest(BaseModel): + input_text: str = "" + variables: Optional[dict] = None + + +# ============================================================ +# Node type definitions (templates) +# ============================================================ + +NODE_TEMPLATES = { + "start": { + "type": "start", + "label": "开始", + "icon": "play", + "color": "#3B6D11", + "description": "工作流入口节点", + "config_schema": { + "input_variable": {"type": "string", "label": "输入变量名", "default": "input"} + } + }, + "llm": { + "type": "llm", + "label": "LLM 调用", + "icon": "brain", + "color": "#534AB7", + "description": "调用大语言模型生成回复", + "config_schema": { + "model_id": {"type": "select", "label": "模型", "options_from": "models"}, + "system_prompt": {"type": "textarea", "label": "系统提示词", "default": "你是一个有用的AI助手。"}, + "user_prompt": {"type": "textarea", "label": "用户提示词模板", "default": "{input}", "help": "可用变量: {input}, {prev_output}"}, + "temperature": {"type": "number", "label": "温度", "default": 0.7, "min": 0, "max": 2}, + "max_tokens": {"type": "number", "label": "最大Token", "default": 2048} + } + }, + "knowledge": { + "type": "knowledge", + "label": "知识库检索", + "icon": "book", + "color": "#185FA5", + "description": "从知识库检索相关文档", + "config_schema": { + "kb_id": {"type": "select", "label": "知识库", "options_from": "knowledge_bases"}, + "query_template": {"type": "textarea", "label": "查询模板", "default": "{input}"}, + "top_k": {"type": "number", "label": "返回数量", "default": 3} + } + }, + "skill": { + "type": "skill", + "label": "Skill 调用", + "icon": "zap", + "color": "#0F6E56", + "description": "执行已注册的 Skill", + "config_schema": { + "skill_id": {"type": "select", "label": "Skill", "options_from": "skills"}, + "params_template": {"type": "textarea", "label": "参数模板 (JSON)", "default": "{}"} + } + }, + "condition": { + "type": "condition", + "label": "条件分支", + "icon": "git-branch", + "color": "#BA7517", + "description": "根据条件选择分支", + "config_schema": { + "condition_expr": {"type": "textarea", "label": "条件表达式", "default": "len(input) > 0", "help": "Python表达式, 返回True走sourceHandle=true, False走false"} + } + }, + "http": { + "type": "http", + "label": "HTTP 请求", + "icon": "globe", + "color": "#D4537E", + "description": "发送 HTTP 请求", + "config_schema": { + "url": {"type": "string", "label": "URL", "default": "https://api.example.com"}, + "method": {"type": "select", "label": "方法", "options": ["GET", "POST", "PUT", "DELETE"], "default": "GET"}, + "headers": {"type": "textarea", "label": "请求头 (JSON)", "default": "{}"}, + "body_template": {"type": "textarea", "label": "请求体模板", "default": ""} + } + }, + "code": { + "type": "code", + "label": "代码执行", + "icon": "terminal", + "color": "#5F5E5A", + "description": "执行 Python 代码片段", + "config_schema": { + "code": {"type": "textarea", "label": "Python 代码", "default": "result = input\noutput = result", "help": "可用变量: input, prev_output, context. 必须设置 output 变量"} + } + }, + "agent": { + "type": "agent", + "label": "Agent 节点", + "icon": "bot", + "color": "#7C3AED", + "description": "调用平台 AI Agent — 支持并行多 Agent 协作", + "config_schema": { + "agent_id": {"type": "select", "label": "选择 Agent", "options_from": "agents"}, + "prompt": {"type": "textarea", "label": "提示词模板", "default": "{input}", "help": "可用变量: {input}, {prev_output}, {context} 中的变量"}, + "system_prompt": {"type": "textarea", "label": "系统提示词 (覆盖Agent默认)", "default": "", "help": "留空则使用Agent默认系统提示词"}, + "temperature": {"type": "number", "label": "温度 (覆盖默认)", "default": 0.7, "min": 0, "max": 2}, + "max_tokens": {"type": "number", "label": "最大Token", "default": 4096}, + "output_variable": {"type": "string", "label": "输出变量名", "default": "", "help": "将Agent输出存入context.{变量名},供后续节点使用"} + } + }, + "end": { + "type": "end", + "label": "结束", + "icon": "stop", + "color": "#A32D2D", + "description": "工作流出口节点", + "config_schema": { + "output_variable": {"type": "string", "label": "输出变量名", "default": "output"} + } + } +} + + +# ============================================================ +# Helpers +# ============================================================ + +def _get_workflow_table(): + """Get the agent_workflows table via raw SQL (avoid model changes)""" + from sqlalchemy import text + return text + + +def workflow_to_dict(row) -> dict: + """Convert a workflow DB row (dict) to response dict""" + return { + "id": row.get("id"), + "name": row.get("name"), + "description": row.get("description"), + "agent_id": row.get("agent_id"), + "nodes": json.loads(row["nodes"]) if isinstance(row.get("nodes"), str) else (row.get("nodes") or []), + "edges": json.loads(row["edges"]) if isinstance(row.get("edges"), str) else (row.get("edges") or []), + "variables": json.loads(row["variables"]) if isinstance(row.get("variables"), str) else (row.get("variables") or {}), + "status": row.get("status", "draft"), + "version": row.get("version", 1), + "created_by": row.get("created_by"), + "created_at": row["created_at"].isoformat() if row.get("created_at") else None, + "updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None, + } + + +def _execute_raw(db, sql, params=None): + """Execute raw SQL and return rows as dicts""" + from sqlalchemy import text + result = db.execute(text(sql), params or {}) + if result.returns_rows: + cols = result.keys() + return [dict(zip(cols, row)) for row in result.fetchall()] + return [] + + +# ============================================================ +# Endpoints +# ============================================================ + +@router.get("/templates", summary="获取节点模板") +async def get_node_templates( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取所有可用的节点类型模板和配置 schema""" + # Fetch options for select fields + options = {} + + # Models + models = db.query(AIModel).filter(AIModel.is_enabled == True).all() + options["models"] = [{"value": m.id, "label": f"{getattr(m, 'display_name', None) or m.name}"} for m in models] + + # Knowledge bases + kbs = db.query(KnowledgeBase).filter(KnowledgeBase.status == "active").all() + options["knowledge_bases"] = [{"value": kb.id, "label": kb.name} for kb in kbs] + + # Skills + skills = db.query(EvolutionSkill).filter(EvolutionSkill.state == "active").all() + options["skills"] = [{"value": s.id, "label": s.skill_name} for s in skills] + + # Agents (for multi-agent orchestration - Phase G3) + agents = db.query(MarketApp).filter(MarketApp.type == "agent", MarketApp.status == "published").all() + options["agents"] = [{"value": a.id, "label": a.name} for a in agents] + + return { + "code": 0, + "data": { + "node_templates": NODE_TEMPLATES, + "options": options, + } + } + + +@router.get("/", summary="获取工作流列表") +async def list_workflows( + keyword: str = Query(None), + status: str = Query(None), + 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), +): + """获取所有工作流列表(分页)""" + conditions = [] + params = {} + + if keyword: + conditions.append("(name LIKE :kw OR description LIKE :kw)") + params["kw"] = f"%{keyword}%" + if status: + conditions.append("status = :status") + params["status"] = status + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + # Count + count_sql = f"SELECT COUNT(*) as total FROM agent_workflows WHERE {where_clause}" + count_rows = _execute_raw(db, count_sql, params) + total = count_rows[0]["total"] if count_rows else 0 + + # Query + offset = (page - 1) * page_size + query_sql = f""" + SELECT * FROM agent_workflows + WHERE {where_clause} + ORDER BY updated_at DESC + LIMIT :limit OFFSET :offset + """ + params["limit"] = page_size + params["offset"] = offset + rows = _execute_raw(db, query_sql, params) + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": [workflow_to_dict(r) for r in rows], + } + } + + +@router.get("/stats", summary="工作流统计") +async def get_workflow_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取工作流统计数据""" + rows = _execute_raw(db, "SELECT status, COUNT(*) as cnt FROM agent_workflows GROUP BY status") + stats = {r["status"]: r["cnt"] for r in rows} + total = sum(stats.values()) + + return { + "code": 0, + "data": { + "total": total, + "draft": stats.get("draft", 0), + "published": stats.get("published", 0), + "archived": stats.get("archived", 0), + } + } + + +@router.get("/{workflow_id}", summary="获取工作流详情") +async def get_workflow( + workflow_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取单个工作流详情""" + rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) + if not rows: + raise HTTPException(status_code=404, detail="工作流不存在") + + result = workflow_to_dict(rows[0]) + + # Get agent info if linked + if result.get("agent_id"): + agent = db.query(MarketApp).filter(MarketApp.id == result["agent_id"]).first() + if agent: + result["agent_name"] = agent.name + + return {"code": 0, "data": result} + + +@router.post("/", summary="创建工作流") +async def create_workflow( + req: WorkflowCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """创建新的工作流""" + from sqlalchemy import text + + wf_id = str(uuid.uuid4()) + now = datetime.now() + + # Validate: must have exactly one start node + start_nodes = [n for n in req.nodes if n.type == "start"] + if len(start_nodes) == 0 and len(req.nodes) > 0: + raise HTTPException(status_code=400, detail="工作流必须包含一个开始节点") + if len(start_nodes) > 1: + raise HTTPException(status_code=400, detail="工作流只能有一个开始节点") + + sql = text(""" + INSERT INTO agent_workflows (id, name, description, agent_id, nodes, edges, variables, status, version, created_by, created_at, updated_at) + VALUES (:id, :name, :description, :agent_id, :nodes, :edges, :variables, 'draft', 1, :created_by, :created_at, :updated_at) + """) + + db.execute(sql, { + "id": wf_id, + "name": req.name, + "description": req.description or "", + "agent_id": req.agent_id, + "nodes": json.dumps([n.dict() for n in req.nodes], ensure_ascii=False), + "edges": json.dumps([e.dict() for e in req.edges], ensure_ascii=False), + "variables": json.dumps(req.variables or {}, ensure_ascii=False), + "created_by": admin_user.id, + "created_at": now, + "updated_at": now, + }) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="create_workflow", + target_type="workflow", + target_id=wf_id, + detail=f"创建工作流: {req.name}", + ) + + # Return created + rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": wf_id}) + return {"code": 0, "message": "工作流创建成功", "data": workflow_to_dict(rows[0])} + + +@router.put("/{workflow_id}", summary="更新工作流") +async def update_workflow( + workflow_id: str, + req: WorkflowUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新工作流""" + from sqlalchemy import text + + rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) + if not rows: + raise HTTPException(status_code=404, detail="工作流不存在") + + existing = rows[0] + sets = [] + params = {"id": workflow_id, "updated_at": datetime.now()} + + if req.name is not None: + sets.append("name = :name") + params["name"] = req.name + if req.description is not None: + sets.append("description = :description") + params["description"] = req.description + if req.agent_id is not None: + sets.append("agent_id = :agent_id") + params["agent_id"] = req.agent_id + if req.nodes is not None: + sets.append("nodes = :nodes") + params["nodes"] = json.dumps([n.dict() for n in req.nodes], ensure_ascii=False) + if req.edges is not None: + sets.append("edges = :edges") + params["edges"] = json.dumps([e.dict() for e in req.edges], ensure_ascii=False) + if req.variables is not None: + sets.append("variables = :variables") + params["variables"] = json.dumps(req.variables, ensure_ascii=False) + if req.status is not None: + sets.append("status = :status") + params["status"] = req.status + + if sets: + sets.append("version = version + 1") + sql = text(f"UPDATE agent_workflows SET {', '.join(sets)} WHERE id = :id") + db.execute(sql, params) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_workflow", + target_type="workflow", + target_id=workflow_id, + detail=f"更新工作流: {existing.get('name', '')}", + ) + + rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) + return {"code": 0, "message": "工作流已更新", "data": workflow_to_dict(rows[0])} + + +@router.delete("/{workflow_id}", summary="删除工作流") +async def delete_workflow( + workflow_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除工作流""" + from sqlalchemy import text + + rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) + if not rows: + raise HTTPException(status_code=404, detail="工作流不存在") + + wf_name = rows[0].get("name", "") + db.execute(text("DELETE FROM agent_workflows WHERE id = :id"), {"id": workflow_id}) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="delete_workflow", + target_type="workflow", + target_id=workflow_id, + detail=f"删除工作流: {wf_name}", + ) + + return {"code": 0, "message": "工作流已删除"} + + +@router.put("/{workflow_id}/toggle", summary="发布/归档工作流") +async def toggle_workflow( + workflow_id: str, + is_active: bool = Query(..., description="True=发布, False=归档"), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """发布或归档工作流""" + from sqlalchemy import text + + rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) + if not rows: + raise HTTPException(status_code=404, detail="工作流不存在") + + new_status = "published" if is_active else "archived" + db.execute( + text("UPDATE agent_workflows SET status = :status, updated_at = :now WHERE id = :id"), + {"status": new_status, "now": datetime.now(), "id": workflow_id} + ) + db.commit() + + return {"code": 0, "message": f"工作流已{'发布' if is_active else '归档'}"} + + +@router.post("/{workflow_id}/execute", summary="执行工作流") +async def execute_workflow( + workflow_id: str, + req: WorkflowExecuteRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """执行工作流 — 按 DAG 拓扑顺序处理节点,同层节点并行执行 (Phase G3)""" + rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) + if not rows: + raise HTTPException(status_code=404, detail="工作流不存在") + + wf = workflow_to_dict(rows[0]) + nodes = wf["nodes"] + edges = wf["edges"] + + if not nodes: + raise HTTPException(status_code=400, detail="工作流没有节点") + + # Build structures + node_map = {n["id"]: n for n in nodes} + edge_map = {} + reverse_edge_map = {} # target -> [sources] + for e in edges: + src = e["source"] + tgt = e["target"] + if src not in edge_map: + edge_map[src] = [] + edge_map[src].append({"target": tgt, "sourceHandle": e.get("sourceHandle")}) + if tgt not in reverse_edge_map: + reverse_edge_map[tgt] = [] + reverse_edge_map[tgt].append(src) + + # Find start nodes + start_node = None + for n in nodes: + if n["type"] == "start": + start_node = n["id"] + break + if not start_node and nodes: + start_node = nodes[0]["id"] + + # Topological sort to assign levels + in_degree = {n["id"]: len(reverse_edge_map.get(n["id"], [])) for n in nodes} + levels = {} + queue = [start_node] + levels[start_node] = 0 + + while queue: + nid = queue.pop(0) + level = levels[nid] + for edge in edge_map.get(nid, []): + tgt = edge["target"] + if tgt in node_map: + new_level = level + 1 + if tgt not in levels or new_level > levels[tgt]: + levels[tgt] = new_level + in_degree[tgt] -= 1 + if in_degree[tgt] <= 0: + queue.append(tgt) + + # Group nodes by level + max_level = max(levels.values()) if levels else 0 + level_groups = {i: [] for i in range(max_level + 1)} + for nid, lvl in levels.items(): + level_groups[lvl].append(nid) + + # Context + context = { + "input": req.input_text, + "variables": req.variables or {}, + "prev_output": "", + "results": {}, + "log": [], + } + + final_output = "" + abort = False + + # Execute level by level + for lvl in range(max_level + 1): + if abort: + break + + level_nodes = level_groups[lvl] + if not level_nodes: + continue + + # Execute nodes in this level + tasks = [] + for nid in level_nodes: + node = node_map.get(nid) + if not node: + continue + task = _execute_node_async(node, context, db, edge_map, node_map, workflow_id) + tasks.append(task) + + # Parallel execution for same-level nodes + results = await asyncio.gather(*tasks, return_exceptions=True) + + for res in results: + if isinstance(res, Exception): + context["log"].append({ + "node_id": "unknown", + "node_label": "unknown", + "node_type": "unknown", + "success": False, + "error": str(res), + }) + abort = True + continue + + if res is None: + continue + + nid = res["node_id"] + context["results"][nid] = res["result"] + + if res["success"]: + context["prev_output"] = res["result"].get("output", "") + context["log"].append({ + "node_id": nid, + "node_label": res["node_label"], + "node_type": res["node_type"], + "success": True, + "output_preview": str(res["result"].get("output", ""))[:200], + }) + + if res["node_type"] == "end": + final_output = res["result"].get("output", "") + abort = True + break + + # Handle condition node branching — update context for next level + if res["node_type"] == "condition" and "next_nodes" in res: + # The condition routing is applied by _execute_node_async + pass + else: + context["log"].append({ + "node_id": nid, + "node_label": res["node_label"], + "node_type": res["node_type"], + "success": False, + "error": res.get("error", "未知错误"), + }) + abort = True + break + + if not final_output: + final_output = context["prev_output"] + + return { + "code": 0, + "data": { + "output": final_output, + "log": context["log"], + "results": context["results"], + } + } + + +@router.websocket("/{workflow_id}/execute-stream") +async def execute_workflow_stream( + websocket: WebSocket, + workflow_id: str, +): + """WebSocket 端点 — 实时流式推送工作流执行进度 (Phase G3)""" + await websocket.accept() + + try: + # Receive execution params + data = await websocket.receive_json() + input_text = data.get("input_text", "") + + # Create DB session + db = SessionLocal() + try: + rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": workflow_id}) + if not rows: + await websocket.send_json({"type": "workflow_error", "data": {"output": "工作流不存在"}}) + await websocket.close() + return + + wf = workflow_to_dict(rows[0]) + nodes = wf["nodes"] + edges = wf["edges"] + + if not nodes: + await websocket.send_json({"type": "workflow_error", "data": {"output": "工作流没有节点"}}) + await websocket.close() + return + + # Build structures + node_map = {n["id"]: n for n in nodes} + edge_map = {} + reverse_edge_map = {} + for e in edges: + src = e["source"] + tgt = e["target"] + if src not in edge_map: + edge_map[src] = [] + edge_map[src].append({"target": tgt, "sourceHandle": e.get("sourceHandle")}) + if tgt not in reverse_edge_map: + reverse_edge_map[tgt] = [] + reverse_edge_map[tgt].append(src) + + # Find start + start_node = None + for n in nodes: + if n["type"] == "start": + start_node = n["id"] + break + if not start_node and nodes: + start_node = nodes[0]["id"] + + # Topo sort + in_degree = {n["id"]: len(reverse_edge_map.get(n["id"], [])) for n in nodes} + levels = {start_node: 0} + queue = [start_node] + while queue: + nid = queue.pop(0) + lvl = levels[nid] + for edge in edge_map.get(nid, []): + tgt = edge["target"] + if tgt in node_map: + nl = lvl + 1 + if tgt not in levels or nl > levels[tgt]: + levels[tgt] = nl + in_degree[tgt] -= 1 + if in_degree[tgt] <= 0: + queue.append(tgt) + + max_level = max(levels.values()) if levels else 0 + level_groups = {i: [] for i in range(max_level + 1)} + for nid, lvl in levels.items(): + level_groups[lvl].append(nid) + + context = { + "input": input_text, + "variables": {}, + "prev_output": "", + "results": {}, + "log": [], + } + + final_output = "" + abort = False + + for lvl in range(max_level + 1): + if abort: + break + + level_nodes = level_groups[lvl] + if not level_nodes: + continue + + tasks_list = [] + for nid in level_nodes: + node = node_map.get(nid) + if not node: + continue + + # Send node_start + await websocket.send_json({ + "type": "node_start", + "node_id": nid, + "node_type": node["type"], + "node_label": node.get("data", {}).get("label", node["type"]), + }) + + task = _execute_node_async(node, context, db, edge_map, node_map, workflow_id) + tasks_list.append(task) + + results = await asyncio.gather(*tasks_list, return_exceptions=True) + + for res in results: + if isinstance(res, Exception): + await websocket.send_json({ + "type": "node_error", + "node_id": "unknown", + "node_label": "unknown", + "node_type": "unknown", + "error": str(res), + }) + abort = True + continue + + if res is None: + continue + + nid = res["node_id"] + context["results"][nid] = res["result"] + + if res["success"]: + context["prev_output"] = res["result"].get("output", "") + + await websocket.send_json({ + "type": "node_complete", + "node_id": nid, + "node_label": res["node_label"], + "node_type": res["node_type"], + "output_preview": str(res["result"].get("output", ""))[:200], + }) + + if res["node_type"] == "end": + final_output = res["result"].get("output", "") + abort = True + break + else: + await websocket.send_json({ + "type": "node_error", + "node_id": nid, + "node_label": res["node_label"], + "node_type": res["node_type"], + "error": res.get("error", "未知错误"), + }) + abort = True + break + + if not final_output: + final_output = context["prev_output"] + + await websocket.send_json({ + "type": "workflow_complete", + "data": { + "output": final_output, + "log": context["log"], + } + }) + + finally: + db.close() + + except WebSocketDisconnect: + pass + except Exception as e: + try: + await websocket.send_json({ + "type": "workflow_error", + "data": {"output": f"执行失败: {str(e)}"} + }) + except Exception: + pass + finally: + try: + await websocket.close() + except Exception: + pass + + +def _execute_node(node_type: str, config: dict, context: dict, db: Session) -> dict: + """Execute a single node and return result""" + + if node_type == "start": + return {"output": context["input"]} + + elif node_type == "llm": + return _execute_llm_node(config, context, db) + + elif node_type == "knowledge": + return _execute_knowledge_node(config, context, db) + + elif node_type == "skill": + return _execute_skill_node(config, context, db) + + elif node_type == "condition": + return {"output": context["prev_output"]} + + elif node_type == "http": + return _execute_http_node(config, context) + + elif node_type == "code": + return _execute_code_node(config, context) + + elif node_type == "agent": + return _execute_agent_node(config, context, db) + + elif node_type == "end": + var_name = config.get("output_variable", "output") + return {"output": context.get(var_name, context.get("prev_output", ""))} + + return {"output": ""} + + +async def _execute_node_async(node: dict, context: dict, db: Session, edge_map: dict, node_map: dict, workflow_id: str) -> Optional[dict]: + """Execute a single node async, returning structured result for parallel orchestration""" + nid = node["id"] + node_type = node["type"] + node_config = node.get("data", {}).get("config", {}) + node_label = node.get("data", {}).get("label", node_type) + + try: + # Publish node start event + from app.api.admin.acp_gateway import publish_workflow_event + publish_workflow_event(workflow_id, nid, { + "event": "node_start", + "node_type": node_type, + "node_label": node_label, + }) + + if node_type == "agent": + # Async agent invocation — may take time + result = await _execute_agent_node_async(node_config, context, db, workflow_id, nid) + + # Store output in context variable if specified + output_var = node_config.get("output_variable", "") + if output_var and result.get("output"): + context[output_var] = result["output"] + + publish_workflow_event(workflow_id, nid, { + "event": "node_complete", + "node_type": node_type, + "node_label": node_label, + "output_preview": str(result.get("output", ""))[:200], + }) + else: + # Sync node execution (run in thread) + result = await asyncio.to_thread(_execute_node, node_type, node_config, context, db) + publish_workflow_event(workflow_id, nid, { + "event": "node_complete", + "node_type": node_type, + "node_label": node_label, + "output_preview": str(result.get("output", ""))[:200], + }) + + return { + "node_id": nid, + "node_label": node_label, + "node_type": node_type, + "success": True, + "result": result, + } + + except Exception as e: + from app.api.admin.acp_gateway import publish_workflow_event + publish_workflow_event(workflow_id, nid, { + "event": "node_error", + "node_type": node_type, + "node_label": node_label, + "error": str(e), + }) + return { + "node_id": nid, + "node_label": node_label, + "node_type": node_type, + "success": False, + "error": str(e), + } + + +async def _execute_agent_node_async(config: dict, context: dict, db: Session, workflow_id: str, node_id: str) -> dict: + """Execute agent node via ACP Gateway — async""" + from app.api.admin.acp_gateway import get_acp_gateway + + agent_id = config.get("agent_id") + if not agent_id: + return {"output": "[Agent] 未选择 Agent", "error": "no agent_id"} + + # Build prompt from template + prompt = config.get("prompt", "{input}") + prompt = prompt.replace("{input}", str(context.get("input", ""))) + prompt = prompt.replace("{prev_output}", str(context.get("prev_output", ""))) + # Substitute context variables + for key, val in context.items(): + if isinstance(val, str): + prompt = prompt.replace("{" + key + "}", val) + + system_prompt = config.get("system_prompt", "") + temperature = float(config.get("temperature", 0.7)) + max_tokens = int(config.get("max_tokens", 4096)) + + gateway = get_acp_gateway() + + # Set notification handler for Redis EventBus + from app.api.admin.acp_gateway import publish_workflow_event + gateway.set_notification_handler( + lambda data: publish_workflow_event(workflow_id, node_id, data) + ) + + result = await gateway.invoke_agent( + agent_id=agent_id, + prompt=prompt, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=max_tokens, + context=context, + db_session=db, + ) + + return result + + +def _execute_llm_node(config: dict, context: dict, db: Session) -> dict: + """Execute LLM call""" + import httpx + + model_id = config.get("model_id") + if not model_id: + return {"output": "[LLM] 未配置模型", "error": "no model_id"} + + model = db.query(AIModel).filter(AIModel.id == model_id).first() + if not model: + return {"output": "[LLM] 模型不存在", "error": "model not found"} + + system_prompt = config.get("system_prompt", "你是一个有用的AI助手。") + user_prompt = config.get("user_prompt", "{input}") + + # Template substitution + user_prompt = user_prompt.replace("{input}", str(context.get("input", ""))) + user_prompt = user_prompt.replace("{prev_output}", str(context.get("prev_output", ""))) + + api_key = model.api_key or "" + base_url = model.api_base or "https://api.siliconflow.cn/v1" + model_name = model.model_id or model.name + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload = { + "model": model_name, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": float(config.get("temperature", 0.7)), + "max_tokens": int(config.get("max_tokens", 2048)), + } + + try: + with httpx.Client(timeout=60) as client: + resp = client.post(f"{base_url}/chat/completions", headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + output = data["choices"][0]["message"]["content"] + return {"output": output, "usage": data.get("usage", {})} + except Exception as e: + return {"output": f"[LLM] 调用失败: {str(e)}", "error": str(e)} + + +def _execute_knowledge_node(config: dict, context: dict, db: Session) -> dict: + """Execute knowledge base search""" + kb_id = config.get("kb_id") + if not kb_id: + return {"output": "[KB] 未配置知识库", "error": "no kb_id"} + + query = config.get("query_template", "{input}") + query = query.replace("{input}", str(context.get("input", ""))) + query = query.replace("{prev_output}", str(context.get("prev_output", ""))) + + top_k = int(config.get("top_k", 3)) + + # Use BM25 search (same as chat.py) + try: + from ...models import KnowledgeDocumentChunk + chunks = db.query(KnowledgeDocumentChunk).filter( + KnowledgeDocumentChunk.knowledge_base_id == kb_id + ).limit(100).all() + + if not chunks: + return {"output": "[KB] 知识库无文档", "results": []} + + # Simple keyword matching (BM25 simplified) + query_words = set(query.lower().split()) + scored = [] + for chunk in chunks: + content = (chunk.content or "").lower() + score = sum(1 for w in query_words if w in content) + if score > 0: + scored.append((score, chunk.content[:500])) + + scored.sort(key=lambda x: -x[0]) + results = [s[1] for s in scored[:top_k]] + + if not results: + results = [c.content[:500] for c in chunks[:top_k]] + + combined = "\n\n---\n\n".join(results) + return {"output": combined, "results": results} + except Exception as e: + return {"output": f"[KB] 检索失败: {str(e)}", "error": str(e)} + + +def _execute_skill_node(config: dict, context: dict, db: Session) -> dict: + """Execute skill call""" + skill_id = config.get("skill_id") + if not skill_id: + return {"output": "[Skill] 未配置 Skill", "error": "no skill_id"} + + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + return {"output": "[Skill] Skill 不存在", "error": "skill not found"} + + # For now, return skill info (actual execution would call the skill handler) + return { + "output": f"[Skill] {skill.skill_name} 已调用", + "skill_id": skill_id, + "skill_name": skill.skill_name, + } + + +def _execute_agent_node(config: dict, context: dict, db: Session) -> dict: + """Execute agent node (sync wrapper) — calls the async version via asyncio.run""" + agent_id = config.get("agent_id") + if not agent_id: + return {"output": "[Agent] 未选择 Agent", "error": "no agent_id"} + + prompt = config.get("prompt", "{input}") + prompt = prompt.replace("{input}", str(context.get("input", ""))) + prompt = prompt.replace("{prev_output}", str(context.get("prev_output", ""))) + + system_prompt = config.get("system_prompt", "") + temperature = float(config.get("temperature", 0.7)) + max_tokens = int(config.get("max_tokens", 4096)) + + from .acp_gateway import get_acp_gateway + + gateway = get_acp_gateway() + + try: + import asyncio + loop = asyncio.get_event_loop() + if loop.is_running(): + # In async context — create new loop + new_loop = asyncio.new_event_loop() + result = new_loop.run_until_complete( + gateway.invoke_agent( + agent_id=agent_id, + prompt=prompt, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=max_tokens, + context=context, + db_session=db, + ) + ) + new_loop.close() + else: + result = loop.run_until_complete( + gateway.invoke_agent( + agent_id=agent_id, + prompt=prompt, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=max_tokens, + context=context, + db_session=db, + ) + ) + return result + except Exception as e: + return {"output": f"[Agent] 调用失败: {str(e)}", "error": str(e)} + + +def _execute_http_node(config: dict, context: dict) -> dict: + """Execute HTTP request""" + import httpx + + url = config.get("url", "") + method = config.get("method", "GET") + headers_str = config.get("headers", "{}") + body_template = config.get("body_template", "") + + # Template substitution + url = url.replace("{input}", str(context.get("input", ""))) + body_template = body_template.replace("{input}", str(context.get("input", ""))) + body_template = body_template.replace("{prev_output}", str(context.get("prev_output", ""))) + + try: + headers = json.loads(headers_str) if headers_str else {} + except Exception: + headers = {} + + try: + with httpx.Client(timeout=30) as client: + if method == "GET": + resp = client.get(url, headers=headers) + elif method == "POST": + resp = client.post(url, headers=headers, content=body_template) + elif method == "PUT": + resp = client.put(url, headers=headers, content=body_template) + elif method == "DELETE": + resp = client.delete(url, headers=headers) + else: + return {"output": f"[HTTP] 不支持的方法: {method}"} + + return {"output": resp.text[:2000], "status_code": resp.status_code} + except Exception as e: + return {"output": f"[HTTP] 请求失败: {str(e)}", "error": str(e)} + + +def _execute_code_node(config: dict, context: dict) -> dict: + """Execute Python code (sandboxed)""" + code = config.get("code", "output = input") + + # Very basic sandbox — restrict builtins + safe_builtins = { + "len": len, "str": str, "int": int, "float": float, + "bool": bool, "list": list, "dict": dict, "set": set, + "range": range, "enumerate": enumerate, "zip": zip, + "min": min, "max": max, "sum": sum, "abs": abs, + "round": round, "sorted": sorted, "reversed": reversed, + "print": print, "json": json, "True": True, "False": False, "None": None, + } + + local_vars = { + "input": context.get("input", ""), + "prev_output": context.get("prev_output", ""), + "context": context, + "output": "", + } + + try: + exec(code, {"__builtins__": safe_builtins}, local_vars) + return {"output": str(local_vars.get("output", ""))} + except Exception as e: + return {"output": f"[Code] 执行失败: {str(e)}", "error": str(e)} diff --git a/backend/python/app/api/im_bot_user.py b/backend/python/app/api/im_bot_user.py new file mode 100644 index 0000000..1c9a7f0 --- /dev/null +++ b/backend/python/app/api/im_bot_user.py @@ -0,0 +1,601 @@ +""" +Phase 4: 个人微信 (iLink 协议 / 微信 ClawBot 插件) —— 前台用户模块 +im_bot_user.py + +为【平台注册用户】提供"各自绑定自己的个人微信"能力: +- 每个用户独立一条 im_bot_wechat_account 记录(按 user_id 隔离) +- 扫码登录免 AppID;消息收发走 Bearer bot_token + X-WECHAT-UIN +- 绑定后由七虎AI 大脑自动应答(复用 channels 的真实 LLM 调用) + +与后台 im_bot_admin 的区别: +- 后台:平台级 IM Bot 接入中枢(飞书/企微/Telegram 等多平台、路由、统计),其个人微信 + 端点仅限 user_id IS NULL 的"全局 Bot",与用户绑定互不干扰。 +- 前台:每个登录用户管理自己的个人微信,数据严格按 user_id 隔离。 +""" +import base64 +import json +import random +import threading +import time +from datetime import datetime, timedelta +from typing import Optional + +import requests +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import text +from sqlalchemy.orm import Session + +from ..auth import get_current_user +from ..database import SessionLocal +from ..models import User + +router = APIRouter() + +# ============================================================ +# iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID) +# ============================================================ +ILINK_BASE = "https://ilinkai.weixin.qq.com" + +# 每个已连接账号一个后台接收线程,key = 账号表主键 id +_wechat_receiver_threads: dict[int, threading.Thread] = {} + + +# ============================================================ +# 通用 DB 工具 +# ============================================================ +def _execute_raw(db: Session, sql_text, params=None): + result = db.execute(sql_text, params or {}) + if result.returns_rows: + return result.fetchall() + db.commit() + return result + + +def _row_to_dict(row) -> dict: + if row is None: + return None + d = {} + for col in row._mapping.keys(): + val = row._mapping[col] + if isinstance(val, datetime): + val = val.isoformat() + d[col] = val + return d + + +# ============================================================ +# iLink 协议请求工具 +# ============================================================ +def _wechat_headers(bot_token: str) -> dict: + """iLink 协议请求头:Bearer bot_token + 随机 X-WECHAT-UIN 防重放""" + uin = base64.b64encode(str(random.randint(0, 2 ** 32 - 1)).encode("utf-8")).decode("utf-8") + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {bot_token}", + "AuthorizationType": "ilink_bot_token", + "X-WECHAT-UIN": uin, + } + + +def _wechat_row_for_user(db: Session, user_id: str): + rows = _execute_raw( + db, + text("SELECT * FROM im_bot_wechat_account WHERE user_id = :uid ORDER BY id DESC LIMIT 1"), + {"uid": user_id}, + ) + return rows[0] if rows else None + + +def _wechat_mask(row) -> dict: + d = _row_to_dict(row) + if d.get("bot_token"): + bt = d["bot_token"] + d["bot_token"] = ("***" + bt[-6:]) if len(bt) > 6 else "***" + d.pop("appid", None) + d.pop("secret_key", None) + return d + + +# ============================================================ +# 七虎AI 大脑(复用 channels 的真实 LLM 调用) +# ============================================================ +def _recent_history(user_id: str, limit: int = 6) -> str: + """拼接最近若干轮对话,作为多轮上下文。""" + db = None + try: + db = SessionLocal() + rows = _execute_raw(db, text( + "SELECT role, content FROM im_bot_chat_log " + "WHERE user_id=:uid ORDER BY id DESC LIMIT :lim" + ), {"uid": user_id, "lim": limit}) + pairs = [] + for r in reversed(rows): + dd = _row_to_dict(r) + role = "用户" if dd["role"] == "user" else "七虎AI" + pairs.append(f"{role}:{dd['content']}") + return "\n".join(pairs) + except Exception: + return "" + finally: + if db: + db.close() + + +def _qihua_reply(text: str, user_id: str = None) -> str: + """调用七虎AI 生成回复。自行创建 DB 会话,避免上层线程无可用 session。""" + prompt = text + if user_id: + hist = _recent_history(user_id) + if hist: + prompt = ( + "以下是你与该用户最近的对话历史,请结合上下文作答:\n" + f"{hist}\n\n" + f"用户当前问题:{text}" + ) + db = None + try: + from app.api.channels import _call_real_llm + db = SessionLocal() + return _call_real_llm(db, None, prompt, "个人微信") + except Exception as e: + return f"(AI 回复生成失败:{e})" + finally: + if db: + db.close() + + +# ============================================================ +# iLink 消息收发 +# ============================================================ +def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str): + """长轮询收消息,返回 (msgs, new_cursor)。最多 hold 35s。""" + try: + resp = requests.post( + baseurl.rstrip("/") + "/ilink/bot/getupdates", + headers=_wechat_headers(bot_token), + json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}}, + timeout=40, + ) + if not resp.ok: + return [], cursor + j = resp.json() if resp.content else {} + return j.get("msgs", []) or [], j.get("get_updates_buf", cursor) + except Exception: + return [], cursor + + +def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str): + """发送文本回复(必须带 context_token 关联到正确的对话窗口)。 + 返回 (ok, ret, body)。ret=-14=会话过期需重扫;ret=-2=token 失效。""" + import uuid as _uuid + payload = { + "msg": { + "from_user_id": "", + "to_user_id": to_user_id, + "client_id": "py-" + str(_uuid.uuid4()), + "message_type": 2, + "message_state": 2, + "context_token": context_token, + "item_list": [{"type": 1, "text_item": {"text": text}}], + }, + "base_info": {"channel_version": "1.0.2"}, + } + last_body = {} + for _ in range(2): + try: + resp = requests.post( + baseurl.rstrip("/") + "/ilink/bot/sendmessage", + headers=_wechat_headers(bot_token), + json=payload, + timeout=20, + ) + try: + body = resp.json() if resp.content else {} + except Exception: + body = {} + last_body = body + ret = body.get("ret") if isinstance(body, dict) else None + try: + with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: + _f.write(datetime.now().isoformat() + " SEND_HTTP=%s ret=%s body=%s\n" + % (resp.status_code, ret, json.dumps(body, ensure_ascii=False)[:300])) + except Exception: + pass + if resp.ok and (ret is None or ret == 0 or not body): + return True, ret, body + if ret in (-2, -14): + return False, ret, body # 鉴权/会话失效,立即停止重试 + except Exception: + pass + return False, (last_body.get("ret") if isinstance(last_body, dict) else None), last_body + + +def _wechat_extract_text(msg: dict) -> str: + parts = [] + for it in (msg.get("item_list") or []): + if it.get("type") == 1 and it.get("text_item", {}).get("text"): + parts.append(it["text_item"]["text"]) + if not parts: + for k in ("text", "content", "message", "msg"): + v = msg.get(k) + if isinstance(v, str) and v.strip(): + parts.append(v) + return "\n".join(parts) + + +def _wechat_sender_id(m: dict) -> str: + """从入站消息中提取发送者 id(兼容多套字段名)。""" + for k in ("from_user_id", "from_user_name", "from_username", "user_id", + "sender_id", "wxid", "from_wxid", "username"): + v = m.get(k) + if v: + return str(v) + return "" + + +def _wechat_context_token(m: dict) -> str: + """提取 context_token(回复必须回带,否则不关联到对话窗口)。""" + for k in ("context_token", "ctx_token", "conversation_id", "conv_id", + "msg_id", "context_id", "session_id"): + v = m.get(k) + if v: + return str(v) + return "" + + +def _log_chat(user_id, platform, from_user, context_token, role, content): + """持久化 IM Bot 对话历史,便于平台内查阅。""" + db = None + try: + db = SessionLocal() + _execute_raw(db, text( + "INSERT INTO im_bot_chat_log " + "(user_id, platform, from_user, context_token, role, content, created_at) " + "VALUES (:uid, :platform, :fu, :ctx, :role, :content, :created)" + ), { + "uid": user_id, "platform": platform, "fu": from_user, + "ctx": context_token, "role": role, "content": content, + "created": datetime.now(), + }) + except Exception: + pass + finally: + if db: + db.close() + + +# ============================================================ +# 镜像到 AI 聊天(标准 conversations/messages) +# 个人微信对话作为一个独立会话窗(默认名 "WeXin Bot")出现在 AI 聊天中, +# 从而复用 AI 聊天模块的全部功能(滚动翻阅、重命名、置顶、删除等)。 +# ============================================================ +WECHAT_CONV_TITLE = "WeXin Bot" + + +def _get_or_create_wechat_conv(db: Session, user_id: str) -> str: + """按 user_id 找/建 source='wechat_bot' 的会话,返回会话 id。""" + rows = _execute_raw(db, text( + "SELECT id FROM conversations WHERE user_id=:uid AND source='wechat_bot' " + "AND status<>'archived' ORDER BY updated_at DESC LIMIT 1" + ), {"uid": user_id}) + if rows: + return _row_to_dict(rows[0])["id"] + import uuid as _uuid + cid = str(_uuid.uuid4()) + now = datetime.now() + _execute_raw(db, text( + "INSERT INTO conversations " + "(id, user_id, title, status, pinned, knowledge_graph_count, source, created_at, updated_at) " + "VALUES (:id, :uid, :title, 'active', 0, 0, 'wechat_bot', :c, :u)" + ), {"id": cid, "uid": user_id, "title": WECHAT_CONV_TITLE, "c": now, "u": now}) + return cid + + +def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None): + import uuid as _uuid + _execute_raw(db, text( + "INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) " + "VALUES (:id, :cid, :role, :content, 0, :c)" + ), { + "id": str(_uuid.uuid4()), "cid": conv_id, "role": role, + "content": content, "c": created or datetime.now(), + }) + + +def _mirror_to_ai_chat(user_id: str, user_content: str, assistant_content: str): + """把一轮微信问答写入用户的 WeXin Bot AI 聊天会话。""" + if not user_id: + return + db = None + try: + db = SessionLocal() + cid = _get_or_create_wechat_conv(db, user_id) + now = datetime.now() + _append_ai_message(db, cid, "user", user_content, created=now) + # assistant 比 user 晚 50ms,保证排序严格递增,避免同秒时 ORDER BY 乱序 + _append_ai_message(db, cid, "assistant", assistant_content, + created=now + timedelta(milliseconds=50)) + _execute_raw(db, text("UPDATE conversations SET updated_at=:u WHERE id=:id"), + {"u": datetime.now(), "id": cid}) + except Exception: + pass + finally: + if db: + db.close() + + +# ============================================================ +# 后台接收线程(每用户独立) +# ============================================================ +def _mark_wechat_expired(account_id: int): + """发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。""" + db = None + try: + db = SessionLocal() + _execute_raw(db, text( + "UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, " + "updated_at=:ts WHERE id=:aid" + ), {"aid": account_id, "ts": datetime.now()}) + except Exception: + pass + finally: + if db: + db.close() + + +def _wechat_receiver_loop(account_id: int): + """长轮询个人微信消息 → 七虎AI 回复 → 发送。""" + cursor = "" + while True: + db = SessionLocal() + try: + row = _execute_raw( + db, + text("SELECT * FROM im_bot_wechat_account WHERE id = :aid"), + {"aid": account_id}, + ) + if not row: + return + d = _row_to_dict(row[0]) + if d.get("status") != "connected" or not d.get("bot_token"): + return + bot_token = d["bot_token"] + baseurl = d.get("baseurl") or ILINK_BASE + msgs, cursor = _wechat_get_updates(baseurl, bot_token, cursor) + owner_uid = d.get("user_id") + for m in msgs: + if m.get("message_type") == 2: + continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理 + content = _wechat_extract_text(m) + if not content: + continue + sender = _wechat_sender_id(m) + ctx = _wechat_context_token(m) + # 诊断:记录原始入站消息,定位字段名 / 第二轮不回复 + try: + with open("/tmp/wechat_inbound.log", "a", encoding="utf-8") as _f: + _f.write(datetime.now().isoformat() + " sender=" + repr(sender) + + " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n") + except Exception: + pass + # 先生成回复(此时历史尚未包含本条,避免重复) + reply = _qihua_reply(content, owner_uid) + ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply) + if ok: + # 仅发送成功才写对话历史(避免“发了但没收到”的误导记录) + if owner_uid: + _log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content) + _log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply) + # 同步镜像到 AI 聊天的 WeXin Bot 会话窗 + _mirror_to_ai_chat(owner_uid, content, reply) + else: + try: + with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: + _f.write(datetime.now().isoformat() + " SEND_FAIL ret=%s sender=%s\n" + % (ret, repr(sender))) + except Exception: + pass + if ret in (-2, -14): + # 会话/鉴权失效:标记需重扫并停止接收线程 + _mark_wechat_expired(d["id"]) + return + time.sleep(1) + except Exception: + time.sleep(5) + finally: + db.close() + + +def _start_wechat_receiver(account_id: int): + if account_id in _wechat_receiver_threads and _wechat_receiver_threads[account_id].is_alive(): + return + t = threading.Thread(target=_wechat_receiver_loop, args=(account_id,), daemon=True) + t.start() + _wechat_receiver_threads[account_id] = t + + +# ============================================================ +# 前台用户接口(按 user_id 隔离) +# ============================================================ +@router.get("/status") +async def wechat_status(user: User = Depends(get_current_user)): + """当前登录用户的个人微信连接状态""" + db = SessionLocal() + try: + row = _wechat_row_for_user(db, user.id) + if not row: + return {"success": True, "data": {"connected": False, "configured": False}} + d = _row_to_dict(row) + return {"success": True, "data": { + "connected": d.get("status") == "connected", + "status": d.get("status"), + "configured": True, + "baseurl": d.get("baseurl"), + "ilink_bot_id": d.get("ilink_bot_id"), + "username": d.get("username"), + "connected_at": str(d.get("connected_at")) if d.get("connected_at") else None, + }} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.post("/qrcode") +async def wechat_qrcode(user: User = Depends(get_current_user)): + """获取个人微信扫码登录二维码(iLink 协议,免 AppID)""" + try: + resp = requests.get( + ILINK_BASE + "/ilink/bot/get_bot_qrcode", + params={"bot_type": 3}, + timeout=20, + ) + if not resp.ok: + return {"success": False, "error": f"获取二维码失败 HTTP {resp.status_code}"} + j = resp.json() if resp.content else {} + qr_url = j.get("qrcode_img_content") or "" + qr_img = None + try: + import io as _io + import qrcode as _qrcode + _buf = _io.BytesIO() + _q = _qrcode.QRCode(box_size=10, border=2) + _q.add_data(qr_url) + _q.make(fit=True) + _img = _q.make_image(fill_color="black", back_color="white") + _img.save(_buf, format="PNG") + qr_img = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode() + except Exception: + qr_img = None + return {"success": True, "data": { + "qrcode": j.get("qrcode"), + "qrcode_img_content": qr_url, + "qrcode_img": qr_img, + }} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/qrcode/status") +async def wechat_qrcode_status( + qrcode: str = Query(...), + user: User = Depends(get_current_user), +): + """轮询扫码状态;confirmed 时保存该用户的 bot_token 并启动接收线程""" + try: + resp = requests.get( + ILINK_BASE + "/ilink/bot/get_qrcode_status", + params={"qrcode": qrcode}, + timeout=40, + ) + if not resp.ok: + return {"success": False, "error": f"查询状态失败 HTTP {resp.status_code}"} + j = resp.json() if resp.content else {} + data = j.get("data", j) if isinstance(j, dict) else j + status = data.get("status") + if status == "confirmed": + creds = data.get("credentials", {}) or {} + bot_token = data.get("bot_token") or creds.get("bot_token") + baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE + ilink_bot_id = data.get("ilink_bot_id") or creds.get("ilink_bot_id") + db = SessionLocal() + try: + row = _wechat_row_for_user(db, user.id) + if not row: + _execute_raw(db, text("""INSERT INTO im_bot_wechat_account + (user_id, username, bot_token, ilink_bot_id, baseurl, status, + connected_at, created_at, updated_at) + VALUES (:user_id, :username, :bot_token, :ilink_bot_id, :baseurl, + 'connected', :connected_at, :created_at, :updated_at)"""), + { + "user_id": user.id, "username": user.username, + "bot_token": bot_token, "ilink_bot_id": ilink_bot_id, + "baseurl": baseurl, + "connected_at": datetime.now(), + "created_at": datetime.now(), "updated_at": datetime.now(), + }) + else: + _execute_raw(db, text("""UPDATE im_bot_wechat_account SET + user_id=:user_id, username=:username, bot_token=:bot_token, + ilink_bot_id=:ilink_bot_id, baseurl=:baseurl, + status='connected', connected_at=:connected_at, updated_at=:updated_at + WHERE id=:id"""), + { + "user_id": user.id, "username": user.username, + "bot_token": bot_token, "ilink_bot_id": ilink_bot_id, + "baseurl": baseurl, "connected_at": datetime.now(), + "updated_at": datetime.now(), "id": _row_to_dict(row)["id"], + }) + row = _wechat_row_for_user(db, user.id) + aid = _row_to_dict(row)["id"] if row else 1 + _start_wechat_receiver(aid) + finally: + db.close() + return {"success": True, "data": {"status": "confirmed", "connected": True}} + return {"success": True, "data": {"status": status, "connected": False}} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/disconnect") +async def wechat_disconnect(user: User = Depends(get_current_user)): + """断开当前用户的个人微信连接(清除 bot_token)""" + db = SessionLocal() + try: + row = _wechat_row_for_user(db, user.id) + if row: + _execute_raw(db, text( + "UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', " + "updated_at=:updated_at WHERE id=:id" + ), {"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]}) + return {"success": True, "message": "已断开个人微信连接"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# ============================================================ +# 启动恢复:为所有已连接用户账号拉起接收线程 +# ============================================================ +@router.get("/history") +async def wechat_history( + user: User = Depends(get_current_user), + limit: int = Query(200), +): + """查询当前用户通过个人微信与七虎AI 的对话历史""" + db = SessionLocal() + try: + rows = _execute_raw( + db, + text("SELECT id, role, content, platform, from_user, created_at " + "FROM im_bot_chat_log WHERE user_id=:uid ORDER BY id DESC LIMIT :lim"), + {"uid": user.id, "lim": min(int(limit), 500)}, + ) + items = [_row_to_dict(r) for r in rows] + items.reverse() + return {"success": True, "data": {"messages": items, "total": len(items)}} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +def start_all_wechat_receivers(): + """在应用启动时调用,恢复此前已连接用户的个人微信接收线程。""" + db = SessionLocal() + try: + rows = _execute_raw( + db, + text("SELECT id FROM im_bot_wechat_account WHERE status='connected' AND user_id IS NOT NULL"), + ) + for r in rows: + _start_wechat_receiver(r._mapping["id"]) + if rows: + import logging + logging.getLogger("aiflow").info(f"[IM个人微信] 已恢复 {len(rows)} 个用户微信接收线程") + except Exception as e: + import logging + logging.getLogger("aiflow").warning(f"[IM个人微信] 启动恢复失败(不影响服务): {e}") + finally: + db.close() diff --git a/backend/python/app/api/portal/user_portal.py b/backend/python/app/api/portal/user_portal.py new file mode 100644 index 0000000..96acef1 --- /dev/null +++ b/backend/python/app/api/portal/user_portal.py @@ -0,0 +1,494 @@ +#!/usr/bin/env python3 +""" +User Portal Backend API - provides user-scoped endpoints (not admin). +Endpoints for: overview, API keys, billing/recharge, tenants, analytics. +""" +import json +import uuid +import hashlib +import secrets +from datetime import datetime, timedelta +from typing import Optional +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy import text +from sqlalchemy.orm import Session +from ...database import SessionLocal +from ..auth import get_current_user + +router = APIRouter() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +CST_TZ = None +from datetime import timezone, timedelta as td +CST_TZ = timezone(td(hours=8)) + +def _format_cst_time(dt): + if dt is None: + return None + if isinstance(dt, str): + return dt + if dt.tzinfo is None: + dt = dt.replace(tzinfo=CST_TZ) + return dt.strftime('%Y-%m-%d %H:%M:%S') + + +# ==================== Overview ==================== + +@router.get("/overview") +async def get_overview( + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """用户门户概览""" + user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)) + + # User stats + user_info = db.execute(text(""" + SELECT id, username, email, avatar, role, membership_level, membership_expire_at, created_at + FROM users WHERE id = :uid + """), {"uid": user_id}).fetchone() + + if not user_info: + raise HTTPException(status_code=404, detail="用户不存在") + + # Token usage this month + now = datetime.now() + month_start = now.replace(day=1, hour=0, minute=0, second=0) + token_stats = db.execute(text(""" + SELECT + COALESCE(SUM(total_tokens), 0) as total_tokens, + COALESCE(SUM(prompt_tokens), 0) as prompt_tokens, + COALESCE(SUM(completion_tokens), 0) as completion_tokens, + COUNT(*) as call_count + FROM token_usage_logs + WHERE user_id = :uid AND created_at >= :month_start + """), {"uid": user_id, "month_start": month_start}).fetchone() + + # API calls last 7 days + seven_days_ago = now - timedelta(days=7) + api_calls = db.execute(text(""" + SELECT DATE(created_at) as date, COUNT(*) as count, COALESCE(SUM(total_tokens), 0) as tokens + FROM token_usage_logs + WHERE user_id = :uid AND created_at >= :since + GROUP BY DATE(created_at) ORDER BY date + """), {"uid": user_id, "since": seven_days_ago}).fetchall() + + daily_data = [{"date": str(r.date), "calls": r.count, "tokens": r.tokens} for r in api_calls] + + # Tenants + tenants = db.execute(text(""" + SELECT t.id, t.name, t.slug, t.status, tm.role + FROM tenant_members tm + JOIN tenants t ON tm.tenant_id = t.id + WHERE tm.user_id = :uid AND tm.status = 'active' + """), {"uid": user_id}).fetchall() + + # Recent transactions + recent_txns = db.execute(text(""" + SELECT id, transaction_type, amount, tokens, description, status, created_at + FROM billing_transactions + WHERE user_id = :uid + ORDER BY created_at DESC LIMIT 5 + """), {"uid": user_id}).fetchall() + + # API keys count + api_key_count = db.execute(text(""" + SELECT COUNT(*) FROM api_keys WHERE user_id = :uid AND status = 'active' + """), {"uid": user_id}).scalar() or 0 + + return { + "code": 200, + "data": { + "user": { + "id": user_info.id, + "username": user_info.username, + "email": user_info.email, + "avatar": user_info.avatar, + "role": user_info.role, + "membership_level": user_info.membership_level, + "membership_expire_at": _format_cst_time(user_info.membership_expire_at), + "created_at": _format_cst_time(user_info.created_at), + }, + "token_stats": { + "total_tokens": token_stats.total_tokens if token_stats else 0, + "prompt_tokens": token_stats.prompt_tokens if token_stats else 0, + "completion_tokens": token_stats.completion_tokens if token_stats else 0, + "call_count": token_stats.call_count if token_stats else 0, + }, + "daily_usage": daily_data, + "tenants": [{"id": t.id, "name": t.name, "slug": t.slug, "status": t.status, "role": t.role} for t in tenants], + "recent_transactions": [{ + "id": r.id, "type": r.transaction_type, "amount": float(r.amount) if r.amount else 0, + "tokens": r.tokens, "description": r.description, "status": r.status, + "created_at": _format_cst_time(r.created_at) + } for r in recent_txns], + "api_key_count": api_key_count, + } + } + + +# ==================== API Keys ==================== + +@router.get("/api-keys") +async def list_api_keys( + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """用户API密钥列表""" + user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)) + + rows = db.execute(text(""" + SELECT id, name, api_key, status, created_at, last_used_at + FROM api_keys + WHERE user_id = :uid + ORDER BY created_at DESC + """), {"uid": user_id}).fetchall() + + return { + "code": 200, + "data": [{ + "id": r.id, + "key_name": r.name, + "key_prefix": r.api_key[:11] + "..." if r.api_key else "", + "status": r.status, + "created_at": _format_cst_time(r.created_at), + "last_used_at": _format_cst_time(r.last_used_at), + } for r in rows] + } + + +@router.post("/api-keys/create") +async def create_api_key( + key_name: str = Query(..., description="密钥名称"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建API密钥""" + user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)) + + # Generate API key + raw_key = secrets.token_hex(32) + key_prefix = "sk-" + raw_key[:8] + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + key_id = str(uuid.uuid4()) + + # Check if api_keys table exists, create if not + db.execute(text(""" + -- api_keys table already exists + """)) + + db.execute(text(""" + INSERT INTO api_keys (id, user_id, api_key, secret, name, status) + VALUES (:id, :uid, :prefix, :hash, :name, 'active') + """), { + "id": key_id, "uid": user_id, + "name": key_name, "prefix": key_prefix, "hash": key_hash + }) + db.commit() + + return { + "code": 200, + "data": { + "id": key_id, + "key_name": key_name, + "key_prefix": key_prefix, + "api_key": raw_key, + "message": "请妥善保存密钥,此为唯一一次显示完整密钥" + } + } + + +@router.delete("/api-keys/{key_id}") +async def revoke_api_key( + key_id: str, + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """撤销API密钥""" + user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)) + + result = db.execute(text(""" + UPDATE api_keys SET status = 'revoked' + WHERE id = :kid AND user_id = :uid + """), {"kid": key_id, "uid": user_id}) + db.commit() + + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="密钥不存在或无权操作") + + return {"code": 200, "data": {"message": "密钥已撤销"}} + + +# ==================== Billing / Recharge ==================== + +@router.get("/billing/packages") +async def list_packages( + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """可用充值套餐""" + rows = db.execute(text(""" + SELECT id, name, description, tokens, price, original_price, bonus_tokens, is_popular, status + FROM recharge_packages + WHERE status = 'active' + ORDER BY price ASC + """)).fetchall() + + return { + "code": 200, + "data": [{ + "id": r.id, "name": r.name, "description": r.description, + "tokens": r.tokens, "price": float(r.price) if r.price else 0, + "original_price": float(r.original_price) if r.original_price else None, + "bonus_tokens": r.bonus_tokens or 0, + "is_popular": bool(r.is_popular) if r.is_popular else False, + } for r in rows] + } + + +@router.get("/billing/transactions") +async def list_user_transactions( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """用户交易记录""" + user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)) + + total = db.execute(text(""" + SELECT COUNT(*) FROM billing_transactions WHERE user_id = :uid + """), {"uid": user_id}).scalar() or 0 + + offset = (page - 1) * page_size + rows = db.execute(text(""" + SELECT id, transaction_type, amount, tokens, balance_after, model_name, + description, status, created_at + FROM billing_transactions + WHERE user_id = :uid + ORDER BY created_at DESC LIMIT :limit OFFSET :offset + """), {"uid": user_id, "limit": page_size, "offset": offset}).fetchall() + + return { + "code": 200, + "data": { + "items": [{ + "id": r.id, "type": r.transaction_type, + "amount": float(r.amount) if r.amount else 0, + "tokens": r.tokens, "balance_after": r.balance_after, + "model_name": r.model_name, "description": r.description, + "status": r.status, "created_at": _format_cst_time(r.created_at) + } for r in rows], + "total": total, "page": page, "page_size": page_size + } + } + + +@router.post("/billing/recharge") +async def recharge( + package_id: str = Query(..., description="套餐ID"), + payment_method: str = Query("wechat", description="支付方式"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """充值购买""" + user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)) + + # Get package info + pkg = db.execute(text(""" + SELECT id, name, tokens, price FROM recharge_packages + WHERE id = :pid AND status = 'active' + """), {"pid": package_id}).fetchone() + + if not pkg: + raise HTTPException(status_code=404, detail="套餐不存在") + + # Create transaction + txn_id = str(uuid.uuid4()) + db.execute(text(""" + INSERT INTO billing_transactions (id, user_id, transaction_type, amount, tokens, description, metadata, status) + VALUES (:id, :uid, 'recharge', :amount, :tokens, :desc, :meta, 'pending') + """), { + "id": txn_id, "uid": user_id, + "amount": float(pkg.price), "tokens": pkg.tokens, + "desc": f"充值 - {pkg.name}", + "meta": json.dumps({"package_id": package_id, "payment_method": payment_method}) + }) + db.commit() + + return { + "code": 200, + "data": { + "transaction_id": txn_id, + "amount": float(pkg.price), + "tokens": pkg.tokens, + "payment_method": payment_method, + "status": "pending", + "message": "订单已创建,请完成支付" + } + } + + +# ==================== Tenants ==================== + +@router.get("/tenants") +async def list_user_tenants( + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """用户所属租户列表""" + user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)) + + rows = db.execute(text(""" + SELECT t.id, t.name, t.slug, t.description, t.status, t.max_members, + t.max_storage_mb, t.max_api_calls_per_day, tm.role, tm.joined_at, + (SELECT COUNT(*) FROM tenant_members tm2 WHERE tm2.tenant_id = t.id AND tm2.status = 'active') as member_count + FROM tenant_members tm + JOIN tenants t ON tm.tenant_id = t.id + WHERE tm.user_id = :uid AND tm.status = 'active' + ORDER BY tm.joined_at DESC + """), {"uid": user_id}).fetchall() + + return { + "code": 200, + "data": [{ + "id": r.id, "name": r.name, "slug": r.slug, + "description": r.description, "status": r.status, + "max_members": r.max_members, "max_storage_mb": r.max_storage_mb, + "max_api_calls_per_day": r.max_api_calls_per_day, + "role": r.role, "joined_at": _format_cst_time(r.joined_at), + "member_count": r.member_count + } for r in rows] + } + + +@router.post("/tenants/create") +async def create_tenant( + name: str = Query(..., description="租户名称"), + slug: str = Query(..., description="租户标识"), + description: str = Query("", description="描述"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建租户""" + user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)) + + # Check slug uniqueness + existing = db.execute(text("SELECT id FROM tenants WHERE slug = :slug"), {"slug": slug}).fetchone() + if existing: + raise HTTPException(status_code=400, detail="租户标识已存在") + + tenant_id = str(uuid.uuid4()) + member_id = str(uuid.uuid4()) + + db.execute(text(""" + INSERT INTO tenants (id, name, slug, description, status, max_members, max_storage_mb, max_api_calls_per_day) + VALUES (:id, :name, :slug, :desc, 'active', 10, 10240, 10000) + """), {"id": tenant_id, "name": name, "slug": slug, "desc": description}) + + db.execute(text(""" + INSERT INTO tenant_members (id, tenant_id, user_id, role, status) + VALUES (:mid, :tid, :uid, 'owner', 'active') + """), {"mid": member_id, "tid": tenant_id, "uid": user_id}) + + db.commit() + + return { + "code": 200, + "data": { + "tenant_id": tenant_id, + "name": name, + "slug": slug, + "message": "租户创建成功" + } + } + + +# ==================== Analytics ==================== + +@router.get("/analytics/usage") +async def get_usage_analytics( + days: int = Query(30, ge=1, le=365), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """个人用量分析""" + user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)) + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + + # Daily usage + daily = db.execute(text(""" + SELECT DATE(created_at) as date, + COUNT(*) as calls, + COALESCE(SUM(total_tokens), 0) as tokens, + COALESCE(SUM(prompt_tokens), 0) as prompt_tokens, + COALESCE(SUM(completion_tokens), 0) as completion_tokens + FROM token_usage_logs + WHERE user_id = :uid AND created_at >= :since + GROUP BY DATE(created_at) ORDER BY date + """), {"uid": user_id, "since": since}).fetchall() + + # Model distribution + models = db.execute(text(""" + SELECT model_name, COUNT(*) as calls, COALESCE(SUM(total_tokens), 0) as tokens + FROM token_usage_logs + WHERE user_id = :uid AND created_at >= :since AND model_name IS NOT NULL + GROUP BY model_name ORDER BY tokens DESC + """), {"uid": user_id, "since": since}).fetchall() + + # Total stats + total = db.execute(text(""" + SELECT COUNT(*) as calls, COALESCE(SUM(total_tokens), 0) as tokens + FROM token_usage_logs WHERE user_id = :uid AND created_at >= :since + """), {"uid": user_id, "since": since}).fetchone() + + return { + "code": 200, + "data": { + "daily": [{ + "date": str(r.date), "calls": r.calls, + "tokens": r.tokens, "prompt_tokens": r.prompt_tokens, + "completion_tokens": r.completion_tokens + } for r in daily], + "model_distribution": [{ + "model": r.model_name, "calls": r.calls, "tokens": r.tokens + } for r in models], + "total": { + "calls": total.calls if total else 0, + "tokens": total.tokens if total else 0 + }, + "days": days + } + } + + +@router.get("/analytics/trends") +async def get_usage_trends( + days: int = Query(7, ge=1, le=90), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """用量趋势""" + user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)) + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + + rows = db.execute(text(""" + SELECT DATE(created_at) as date, COUNT(*) as calls, + COALESCE(SUM(total_tokens), 0) as tokens + FROM token_usage_logs + WHERE user_id = :uid AND created_at >= :since + GROUP BY DATE(created_at) ORDER BY date + """), {"uid": user_id, "since": since}).fetchall() + + return { + "code": 200, + "data": [{ + "date": str(r.date), "calls": r.calls, "tokens": r.tokens + } for r in rows] + } diff --git a/backend/python/app/capability_registry.py b/backend/python/app/capability_registry.py new file mode 100644 index 0000000..a0ead83 --- /dev/null +++ b/backend/python/app/capability_registry.py @@ -0,0 +1,97 @@ +""" +能力(capability)注册中心 + +集中定义平台支持的全部模型能力,以及"每个能力对应的默认模型"的读写与解析。 +- CAPABILITY_TYPES: 全部能力(id / 中文标签 / 测试类型 / 描述) +- get_capability_defaults / set_capability_default: 读写 site_settings 中的 model_capability_defaults +- resolve_auto_model: 根据能力解析平台默认模型(供聊天 / 技能 / 应用 / IM Bot 自动匹配) +""" +from typing import Optional +from sqlalchemy.orm import Session +from .models import AIModel, SiteSetting +import json + + +# ============================================================ +# 平台支持的全部能力类型 +# test_type 用于模型连接测试时选择对应的 API 路径 +# ============================================================ +CAPABILITY_TYPES = [ + {"id": "text", "label": "文本对话", "test_type": "text", "desc": "文本生成与多轮对话"}, + {"id": "thinking", "label": "推理思考", "test_type": "text", "desc": "深度推理 / 思维链"}, + {"id": "vision", "label": "视觉理解", "test_type": "vision", "desc": "图片 / 多模态理解"}, + {"id": "tool", "label": "工具调用", "test_type": "text", "desc": "Function Calling 函数调用"}, + {"id": "image", "label": "图像生成", "test_type": "image", "desc": "文生图 / 图像生成"}, + {"id": "audio", "label": "语音合成", "test_type": "audio", "desc": "TTS 文字转语音"}, + {"id": "asr", "label": "语音识别", "test_type": "asr", "desc": "ASR 语音转文字"}, + {"id": "ocr", "label": "OCR识别", "test_type": "ocr", "desc": "图片文字识别提取"}, + {"id": "embedding", "label": "嵌入向量", "test_type": "embedding", "desc": "文本向量化 / 嵌入"}, + {"id": "rerank", "label": "重排序", "test_type": "rerank", "desc": "语义重排 / Rerank"}, + {"id": "video", "label": "视频生成", "test_type": "video", "desc": "文生视频 / 视频生成"}, +] + +# 文本类能力(对话 / 视觉 / 推理 / 工具 / OCR)在缺少专属默认模型时,回退到全局默认模型 +TEXT_FAMILY = {"text", "thinking", "vision", "tool", "ocr"} + +# 仅用于前端展示的固定配色 +CAPABILITY_COLORS = { + "text": "#5B5FED", "thinking": "#8B5CF6", "vision": "#219B5E", "tool": "#3B82F6", + "image": "#D9383A", "audio": "#E8922C", "asr": "#0EA5E9", "ocr": "#F59E0B", + "embedding": "#10B981", "rerank": "#EC4899", "video": "#EF4444", +} + +SETTING_KEY = "model_capability_defaults" + + +def get_capability_defaults(db: Session) -> dict: + """读取 {capability: model_id} 映射""" + row = db.query(SiteSetting).filter(SiteSetting.key == SETTING_KEY).first() + if not row or not row.value: + return {} + try: + data = json.loads(row.value) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def set_capability_default(db: Session, capability: str, model_id: Optional[str]) -> dict: + """设置某个能力的默认模型(model_id 为空则清除)""" + data = get_capability_defaults(db) + if model_id: + data[capability] = model_id + else: + data.pop(capability, None) + row = db.query(SiteSetting).filter(SiteSetting.key == SETTING_KEY).first() + if not row: + from uuid import uuid4 + row = SiteSetting(id=str(uuid4()), key=SETTING_KEY, value="{}") + db.add(row) + row.value = json.dumps(data, ensure_ascii=False) + db.commit() + return data + + +def resolve_auto_model(capability: str, db: Session) -> Optional[AIModel]: + """ + 根据能力解析平台默认模型: + 1. 优先使用后台"按能力默认模型"中配置的值 + 2. 文本类能力若未单独配置,回退到全局默认模型(is_default) + 3. 其余能力(embedding/rerank/asr 等)若未配置则返回 None + """ + data = get_capability_defaults(db) + mid = data.get(capability) + if mid: + m = db.query(AIModel).filter( + AIModel.id == mid, AIModel.is_enabled == True + ).first() + if m: + return m + # 文本类能力回退到全局默认模型 + if capability in TEXT_FAMILY: + m = db.query(AIModel).filter( + AIModel.is_default == True, AIModel.is_enabled == True + ).first() + if m: + return m + return None diff --git a/backend/python/app/casdoor_auth.py b/backend/python/app/casdoor_auth.py new file mode 100644 index 0000000..258e96f --- /dev/null +++ b/backend/python/app/casdoor_auth.py @@ -0,0 +1,214 @@ +""" +Casdoor SSO 集成模块 +================== +实现 Casdoor OIDC 单点登录集成, +支持 JWT + Casdoor 双认证模式。 + +使用方式: + from app.casdoor_auth import casdoor_router, get_casdoor_login_url, handle_casdoor_callback +""" + +import os +import logging +import httpx +from typing import Optional +from datetime import datetime +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from .database import get_db +from .models import User, gen_uuid +from .auth import create_access_token, hash_password + +logger = logging.getLogger("aiflow.casdoor") + +router = APIRouter() + +# Casdoor 配置 +CASDOOR_ENDPOINT = os.environ.get("CASDOOR_ENDPOINT", "http://casdoor:8000") +CASDOOR_CLIENT_ID = os.environ.get("CASDOOR_CLIENT_ID", "aiflow") +CASDOOR_CLIENT_SECRET = os.environ.get("CASDOOR_CLIENT_SECRET", "") +CASDOOR_ORG_NAME = os.environ.get("CASDOOR_ORG_NAME", "aiflow") +CASDOOR_APP_NAME = os.environ.get("CASDOOR_APP_NAME", "aiflow-app") +CASDOOR_REDIRECT_URI = os.environ.get( + "CASDOOR_REDIRECT_URI", + "http://210.16.161.218:12888/app/api/auth/casdoor/callback" +) +APP_FRONTEND_URL = os.environ.get("APP_ACCESS_URL", "http://210.16.161.218:12888") + + +def get_casdoor_login_url(state: str = "") -> str: + """获取 Casdoor 登录跳转URL""" + params = { + "client_id": CASDOOR_CLIENT_ID, + "response_type": "code", + "redirect_uri": CASDOOR_REDIRECT_URI, + "scope": "openid profile email", + "state": state or "aiflow", + } + return f"{CASDOOR_ENDPOINT}/login/oauth/authorize?{urlencode(params)}" + + +async def exchange_code_for_token(code: str) -> Optional[dict]: + """用授权码换取 access_token 和用户信息""" + try: + async with httpx.AsyncClient(timeout=30) as client: + # 获取 token + token_resp = await client.post( + f"{CASDOOR_ENDPOINT}/api/login/oauth/access_token", + json={ + "grant_type": "authorization_code", + "client_id": CASDOOR_CLIENT_ID, + "client_secret": CASDOOR_CLIENT_SECRET, + "code": code, + "redirect_uri": CASDOOR_REDIRECT_URI, + }, + ) + + if token_resp.status_code != 200: + logger.error(f"Casdoor token exchange failed: {token_resp.status_code} {token_resp.text}") + return None + + token_data = token_resp.json() + access_token = token_data.get("access_token") + if not access_token: + logger.error("No access_token in Casdoor response") + return None + + # 获取用户信息 + user_resp = await client.get( + f"{CASDOOR_ENDPOINT}/api/account", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + if user_resp.status_code != 200: + # 尝试备用端点 + user_resp = await client.get( + f"{CASDOOR_ENDPOINT}/api/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + if user_resp.status_code != 200: + logger.error(f"Casdoor userinfo failed: {user_resp.status_code}") + return {"access_token": access_token, "user": None} + + user_info = user_resp.json() + return {"access_token": access_token, "user": user_info} + + except Exception as e: + logger.error(f"Casdoor token exchange error: {e}") + return None + + +async def get_or_create_user(db: Session, casdoor_user: dict) -> User: + """根据 Casdoor 用户信息获取或创建本地用户""" + casdoor_id = casdoor_user.get("id", casdoor_user.get("name", "")) + casdoor_name = casdoor_user.get("name", casdoor_user.get("preferred_username", "")) + casdoor_email = casdoor_user.get("email", "") + casdoor_display = casdoor_user.get("displayName", casdoor_user.get("name", "")) + + # 通过 Casdoor ID 查找已有用户 + existing = db.query(User).filter(User.casdoor_id == casdoor_id).first() if hasattr(User, "casdoor_id") else None + + # 通过用户名查找 + if not existing and casdoor_name: + existing = db.query(User).filter(User.username == f"sso_{casdoor_name}").first() + if existing and hasattr(existing, "casdoor_id"): + existing.casdoor_id = casdoor_id + + if existing: + # 更新最后登录时间 + existing.last_login = datetime.now() + db.commit() + return existing + + # 创建新用户 + username = f"sso_{casdoor_name}" if casdoor_name else f"sso_{casdoor_id[:8]}" + # 避免用户名冲突 + base_username = username + counter = 1 + while db.query(User).filter(User.username == username).first(): + username = f"{base_username}_{counter}" + counter += 1 + + new_user = User( + id=gen_uuid(), + username=username, + email=casdoor_email or None, + password_hash=hash_password(casdoor_id + datetime.now().isoformat()), # 随机密码 + role="user", + status="active", + display_name=casdoor_display or username, + ) + + # 如果 User 模型有 casdoor_id 字段则设置 + if hasattr(new_user, "casdoor_id"): + new_user.casdoor_id = casdoor_id + + db.add(new_user) + db.commit() + db.refresh(new_user) + + logger.info(f"Created SSO user: {username} (casdoor_id={casdoor_id})") + return new_user + + +# ==================== API 路由 ==================== + +@router.get("/casdoor/login") +async def casdoor_login(state: str = ""): + """跳转到 Casdoor 登录页面""" + login_url = get_casdoor_login_url(state) + return RedirectResponse(url=login_url) + + +@router.get("/casdoor/callback") +async def casdoor_callback( + code: str, + state: str = "", + db: Session = Depends(get_db), +): + """Casdoor OAuth 回调""" + result = await exchange_code_for_token(code) + if not result or not result.get("user"): + # 重定向到前端登录页,带错误参数 + return RedirectResponse(url=f"{APP_FRONTEND_URL}/#/login?error=casdoor_failed") + + casdoor_user = result["user"] + user = await get_or_create_user(db, casdoor_user) + + # 生成平台 JWT token + token_data = {"sub": user.id, "username": user.username, "role": user.role} + access_token = create_access_token(token_data) + + # 重定向到前端,带 token + redirect_url = f"{APP_FRONTEND_URL}/#/login?token={access_token}&sso=casdoor" + return RedirectResponse(url=redirect_url) + + +@router.get("/casdoor/status") +async def casdoor_status(): + """检查 Casdoor SSO 是否可用""" + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(f"{CASDOOR_ENDPOINT}/api/health") + if resp.status_code == 200: + return {"enabled": True, "endpoint": CASDOOR_ENDPOINT} + except Exception: + pass + return {"enabled": False, "endpoint": CASDOOR_ENDPOINT} + + +# 配置说明: +# 1. 在 Casdoor 中创建应用,获取 Client ID 和 Client Secret +# 2. 设置环境变量: +# - CASDOOR_ENDPOINT=http://casdoor:8000 +# - CASDOOR_CLIENT_ID=your_client_id +# - CASDOOR_CLIENT_SECRET=your_client_secret +# - CASDOOR_REDIRECT_URI=http://your-domain/app/api/auth/casdoor/callback +# 3. 在 User 模型中添加 casdoor_id 字段 (可选): +# casdoor_id = Column(String(128), unique=True, nullable=True) diff --git a/backend/python/app/kb_router.py b/backend/python/app/kb_router.py new file mode 100644 index 0000000..ec82a3f --- /dev/null +++ b/backend/python/app/kb_router.py @@ -0,0 +1,267 @@ +""" +知识库路由层 +============ +将检索请求智能路由到本地BM25、向量检索、或外部KB服务(FastGPT/Dify/RAGFlow)。 +根据知识库配置自动选择最优检索策略。 + +使用方式: + from app.kb_router import kb_router_search + results = await kb_router_search(query, kb_id, top_k=5) +""" + +import os +import json +import logging +import httpx +from typing import List, Dict, Optional + +logger = logging.getLogger("aiflow.kb_router") + + +async def _get_external_services(db_session, enabled_only: bool = True) -> List[Dict]: + """获取已配置的外部KB服务""" + from sqlalchemy import text + try: + query = "SELECT * FROM external_kb_services" + if enabled_only: + query += " WHERE is_enabled = 1" + query += " ORDER BY created_at DESC" + result = db_session.execute(text(query)) + columns = result.keys() + return [dict(zip(columns, row)) for row in result.fetchall()] + except Exception as e: + logger.error(f"Failed to get external services: {e}") + return [] + + +async def _search_fastgpt(api_url: str, api_key: str, query: str, top_k: int = 5) -> List[Dict]: + """搜索 FastGPT 知识库""" + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{api_url.rstrip('/')}/api/v1/dataset/search", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "query": query, + "similarity": 0.5, + "limit": top_k, + }, + ) + if resp.status_code == 200: + data = resp.json() + results = [] + for item in data.get("data", {}).get("list", []): + results.append({ + "content": item.get("q", item.get("content", "")), + "score": float(item.get("score", 0.5)), + "source": f"FastGPT: {item.get('collectionId', '')}", + "metadata": item, + }) + return results + else: + logger.warning(f"FastGPT search returned {resp.status_code}") + return [] + except Exception as e: + logger.error(f"FastGPT search error: {e}") + return [] + + +async def _search_dify(api_url: str, api_key: str, query: str, top_k: int = 5) -> List[Dict]: + """搜索 Dify 知识库""" + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{api_url.rstrip('/')}/v1/datasets/retrieval", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "query": query, + "retrieval_mode": "semantic", + "top_k": top_k, + }, + ) + if resp.status_code == 200: + data = resp.json() + results = [] + for item in data.get("records", []): + segment = item.get("segment", {}) + results.append({ + "content": segment.get("content", ""), + "score": float(item.get("score", 0.5)), + "source": f"Dify: {segment.get('id', '')}", + "metadata": item, + }) + return results + else: + logger.warning(f"Dify search returned {resp.status_code}") + return [] + except Exception as e: + logger.error(f"Dify search error: {e}") + return [] + + +async def _search_ragflow(api_url: str, api_key: str, query: str, top_k: int = 5) -> List[Dict]: + """搜索 RAGFlow 知识库""" + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{api_url.rstrip('/')}/api/v1/retrieval", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "question": query, + "top_k": top_k, + }, + ) + if resp.status_code == 200: + data = resp.json() + results = [] + for item in data.get("data", {}).get("chunks", []): + results.append({ + "content": item.get("content", ""), + "score": float(item.get("similarity", 0.5)), + "source": f"RAGFlow: {item.get('document_id', '')}", + "metadata": item, + }) + return results + else: + logger.warning(f"RAGFlow search returned {resp.status_code}") + return [] + except Exception as e: + logger.error(f"RAGFlow search error: {e}") + return [] + + +async def _search_external(service: Dict, query: str, top_k: int = 5) -> List[Dict]: + """路由到对应的外部KB服务""" + service_type = service.get("service_type", "") + api_url = service.get("api_url", "") + api_key = service.get("api_key", "") + + if service_type == "fastgpt": + return await _search_fastgpt(api_url, api_key, query, top_k) + elif service_type == "dify": + return await _search_dify(api_url, api_key, query, top_k) + elif service_type == "ragflow": + return await _search_ragflow(api_url, api_key, query, top_k) + else: + logger.warning(f"Unknown external KB type: {service_type}") + return [] + + +async def kb_router_search( + query: str, + kb_id: Optional[str] = None, + top_k: int = 5, + bm25_results: Optional[List[Dict]] = None, + db_session=None, + use_vector: bool = True, + use_external: bool = True, +) -> List[Dict]: + """ + 知识库路由层 — 混合检索 + + 检索策略: + 1. 本地 BM25 检索 (MySQL, 已有) + 2. 向量语义检索 (PostgreSQL+pgvector, 新增) + 3. 外部KB检索 (FastGPT/Dify/RAGFlow, 按配置) + + 融合方式: Reciprocal Rank Fusion (RRF) + + Args: + query: 查询文本 + kb_id: 知识库ID + top_k: 返回结果数 + bm25_results: BM25检索结果 + db_session: 数据库会话(用于获取外部KB配置) + use_vector: 是否使用向量检索 + use_external: 是否使用外部KB检索 + + Returns: + 融合后的检索结果列表 + """ + all_results = [] + + # 1. BM25 结果 + if bm25_results: + for rank, item in enumerate(bm25_results): + all_results.append({ + "content": item.get("content", item.get("text", "")), + "score": item.get("score", 1.0 / (60 + rank + 1)), + "source": "local_bm25", + "rank": rank + 1, + "metadata": item.get("metadata", {}), + }) + + # 2. 向量检索 + if use_vector: + try: + from app.pgvector_search import vector_search + vec_results = await vector_search(query, kb_id, top_k) + for rank, item in enumerate(vec_results): + all_results.append({ + "content": item.get("content", ""), + "score": item.get("score", 0.5), + "source": "vector_pg", + "rank": rank + 1, + "metadata": item.get("metadata", {}), + }) + except ImportError: + logger.debug("pgvector_search not available, skipping vector search") + except Exception as e: + logger.warning(f"Vector search failed: {e}") + + # 3. 外部KB检索 + if use_external and db_session: + services = await _get_external_services(db_session) + for service in services: + if service.get("status") == "connected": + ext_results = await _search_external(service, query, top_k) + for rank, item in enumerate(ext_results): + all_results.append({ + "content": item.get("content", ""), + "score": item.get("score", 0.5), + "source": item.get("source", f"external_{service['service_type']}"), + "rank": rank + 1, + "metadata": item.get("metadata", {}), + }) + + if not all_results: + return [] + + # RRF 融合 + rrf_k = 60 + score_map = {} + for item in all_results: + content_key = item["content"][:200] # 用内容前200字符作为去重键 + rrf_score = 1.0 / (rrf_k + item.get("rank", 1)) + + if content_key in score_map: + score_map[content_key]["score"] += rrf_score + score_map[content_key]["sources"].append(item["source"]) + else: + score_map[content_key] = { + "content": item["content"], + "score": rrf_score, + "sources": [item["source"]], + "metadata": item.get("metadata", {}), + } + + # 按融合分数排序 + merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True) + + # 标准化分数到 0-1 + if merged: + max_score = merged[0]["score"] + for item in merged: + item["score"] = item["score"] / max_score if max_score > 0 else 0 + item["sources"] = list(set(item["sources"])) + + return merged[:top_k] diff --git a/backend/python/app/pgvector_search.py b/backend/python/app/pgvector_search.py new file mode 100644 index 0000000..a38654a --- /dev/null +++ b/backend/python/app/pgvector_search.py @@ -0,0 +1,360 @@ +""" +PGVector 向量检索模块 +==================== +使用 PostgreSQL + pgvector 实现语义向量检索, +与现有 MySQL BM25 检索配合,实现混合检索。 + +使用方式: + from app.pgvector_search import vector_search + results = await vector_search(query, kb_id, top_k=5) +""" + +import os +import logging +import asyncio +from typing import List, Dict, Optional +from datetime import datetime + +logger = logging.getLogger("aiflow.pgvector") + +# PostgreSQL 连接配置 +PG_HOST = os.environ.get("POSTGRES_HOST", "pgvector") +PG_PORT = int(os.environ.get("POSTGRES_PORT", "5432")) +PG_USER = os.environ.get("POSTGRES_USER", "aiflow") +PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "pwd2026") +PG_DB = os.environ.get("POSTGRES_DB", "aiflow_vectors") + +# 嵌入模型配置 +EMBEDDING_API_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn") +EMBEDDING_API_KEY = os.environ.get("SILICONFLOW_API_KEY", "") +EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "BAAI/bge-large-zh-v1.5") + +# 连接池 +_pg_pool = None +_pool_lock = asyncio.Lock() + + +async def get_pg_pool(): + """获取 PostgreSQL 异步连接池""" + global _pg_pool + if _pg_pool is not None: + return _pg_pool + + async with _pool_lock: + if _pg_pool is not None: + return _pg_pool + + try: + import asyncpg + except ImportError: + logger.warning("asyncpg not installed, vector search disabled") + return None + + try: + _pg_pool = await asyncpg.create_pool( + host=PG_HOST, + port=PG_PORT, + user=PG_USER, + password=PG_PASSWORD, + database=PG_DB, + min_size=2, + max_size=10, + command_timeout=30, + ) + logger.info(f"PostgreSQL connection pool created: {PG_HOST}:{PG_PORT}/{PG_DB}") + + # 初始化表结构 + async with _pg_pool.acquire() as conn: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS document_embeddings ( + id SERIAL PRIMARY KEY, + doc_id VARCHAR(64) NOT NULL, + kb_id VARCHAR(64) NOT NULL, + chunk_id VARCHAR(64), + content TEXT NOT NULL, + embedding vector(1024), + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_embeddings_kb + ON document_embeddings(kb_id); + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_embeddings_doc + ON document_embeddings(doc_id); + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_embeddings_vector + ON document_embeddings USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 100); + """) + logger.info("PGVector tables initialized") + + return _pg_pool + except Exception as e: + logger.error(f"Failed to create PostgreSQL pool: {e}") + _pg_pool = None + return None + + +async def get_embedding(text: str) -> Optional[List[float]]: + """调用嵌入API获取文本向量""" + if not EMBEDDING_API_KEY: + logger.warning("No embedding API key configured") + return None + + try: + import httpx + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{EMBEDDING_API_URL}/v1/embeddings", + headers={ + "Authorization": f"Bearer {EMBEDDING_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": EMBEDDING_MODEL, + "input": text[:8000], # 截断防止超长 + }, + ) + if resp.status_code == 200: + data = resp.json() + embedding = data["data"][0]["embedding"] + return embedding + else: + logger.error(f"Embedding API error: {resp.status_code} {resp.text[:200]}") + return None + except Exception as e: + logger.error(f"Embedding generation failed: {e}") + return None + + +async def vector_search( + query: str, + kb_id: Optional[str] = None, + top_k: int = 5, + threshold: float = 0.5, +) -> List[Dict]: + """ + 向量语义检索 + + Args: + query: 查询文本 + kb_id: 知识库ID (可选,不指定则搜索全部) + top_k: 返回结果数 + threshold: 相似度阈值 (0-1) + + Returns: + [{"doc_id": ..., "content": ..., "score": ..., "metadata": ...}, ...] + """ + pool = await get_pg_pool() + if pool is None: + return [] + + # 获取查询向量 + query_embedding = await get_embedding(query) + if query_embedding is None: + return [] + + try: + async with pool.acquire() as conn: + # 将向量转换为 PostgreSQL 格式 + embedding_str = f"[{','.join(str(x) for x in query_embedding)}]" + + if kb_id: + rows = await conn.fetch( + """ + SELECT doc_id, chunk_id, content, metadata, + 1 - (embedding <=> $1::vector) as score + FROM document_embeddings + WHERE kb_id = $2 + ORDER BY embedding <=> $1::vector + LIMIT $3; + """, + embedding_str, kb_id, top_k, + ) + else: + rows = await conn.fetch( + """ + SELECT doc_id, chunk_id, content, metadata, + 1 - (embedding <=> $1::vector) as score + FROM document_embeddings + ORDER BY embedding <=> $1::vector + LIMIT $2; + """, + embedding_str, top_k, + ) + + results = [] + for row in rows: + score = float(row["score"]) + if score >= threshold: + results.append({ + "doc_id": row["doc_id"], + "chunk_id": row["chunk_id"], + "content": row["content"], + "score": score, + "metadata": row["metadata"] if isinstance(row["metadata"], dict) else {}, + }) + + return results + + except Exception as e: + logger.error(f"Vector search failed: {e}") + return [] + + +async def index_document( + doc_id: str, + kb_id: str, + content: str, + chunk_id: Optional[str] = None, + metadata: Optional[Dict] = None, +) -> bool: + """ + 将文档内容索引到 PGVector + + Args: + doc_id: 文档ID + kb_id: 知识库ID + content: 文档内容 + chunk_id: 分块ID (可选) + metadata: 元数据 + + Returns: + 是否成功 + """ + pool = await get_pg_pool() + if pool is None: + return False + + # 分块处理(简单按长度分块) + chunks = [] + chunk_size = 500 + overlap = 50 + if len(content) <= chunk_size: + chunks = [(content, chunk_id or f"{doc_id}_0")] + else: + for i in range(0, len(content), chunk_size - overlap): + chunk = content[i:i + chunk_size] + if len(chunk.strip()) > 10: + chunks.append((chunk, f"{doc_id}_{i // (chunk_size - overlap)}")) + + success_count = 0 + for chunk_text, cid in chunks: + embedding = await get_embedding(chunk_text) + if embedding is None: + continue + + try: + async with pool.acquire() as conn: + embedding_str = f"[{','.join(str(x) for x in embedding)}]" + import json + await conn.execute( + """ + INSERT INTO document_embeddings (doc_id, kb_id, chunk_id, content, embedding, metadata) + VALUES ($1, $2, $3, $4, $5::vector, $6) + ON CONFLICT DO NOTHING; + """, + doc_id, kb_id, cid, chunk_text, embedding_str, + json.dumps(metadata or {}), + ) + success_count += 1 + except Exception as e: + logger.error(f"Failed to index chunk {cid}: {e}") + + logger.info(f"Indexed document {doc_id}: {success_count}/{len(chunks)} chunks") + return success_count > 0 + + +async def hybrid_search( + query: str, + kb_id: Optional[str] = None, + top_k: int = 5, + bm25_results: Optional[List[Dict]] = None, +) -> List[Dict]: + """ + 混合检索:BM25 + 向量检索融合 + + Args: + query: 查询文本 + kb_id: 知识库ID + top_k: 返回结果数 + bm25_results: BM25 检索结果(来自MySQL) + + Returns: + 融合后的检索结果 + """ + # 获取向量检索结果 + vec_results = await vector_search(query, kb_id, top_k) + + if not vec_results and not bm25_results: + return [] + + if not vec_results: + return bm25_results or [] + + if not bm25_results: + return vec_results + + # 融合:Reciprocal Rank Fusion (RRF) + rrf_k = 60 + score_map = {} + + for rank, item in enumerate(bm25_results): + doc_id = item.get("doc_id", item.get("id", "")) + score_map[doc_id] = { + "content": item.get("content", item.get("text", "")), + "score": 1.0 / (rrf_k + rank + 1), + "bm25_rank": rank + 1, + "metadata": item.get("metadata", {}), + } + + for rank, item in enumerate(vec_results): + doc_id = item["doc_id"] + if doc_id in score_map: + score_map[doc_id]["score"] += 1.0 / (rrf_k + rank + 1) + score_map[doc_id]["vec_score"] = item["score"] + score_map[doc_id]["vec_rank"] = rank + 1 + else: + score_map[doc_id] = { + "content": item["content"], + "score": 1.0 / (rrf_k + rank + 1), + "vec_score": item["score"], + "vec_rank": rank + 1, + "metadata": item.get("metadata", {}), + } + + # 按融合分数排序 + merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True) + return merged[:top_k] + + +async def delete_document_embeddings(doc_id: str) -> bool: + """删除文档的所有向量索引""" + pool = await get_pg_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + await conn.execute( + "DELETE FROM document_embeddings WHERE doc_id = $1;", + doc_id, + ) + logger.info(f"Deleted embeddings for document {doc_id}") + return True + except Exception as e: + logger.error(f"Failed to delete embeddings: {e}") + return False + + +async def close_pool(): + """关闭连接池""" + global _pg_pool + if _pg_pool is not None: + await _pg_pool.close() + _pg_pool = None + logger.info("PostgreSQL connection pool closed") diff --git a/backend/python/main.py b/backend/python/main.py index 663d5c8..6a86aa3 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -1,214 +1,245 @@ -""" -Qihu-AIFlow FastAPI 主应用 -完整后端API,监听18080端口,所有接口以 /app 为前缀 -""" -import sys -import os -import time -import uuid -import logging -from contextlib import asynccontextmanager -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from starlette.middleware.base import BaseHTTPMiddleware -from datetime import datetime - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution -from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs -from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD -from app.database import SessionLocal -from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel - -# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) -import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 - -# 初始化文件日志(按天轮转) -access_logger = setup_file_logging() -logger = logging.getLogger("aiflow") - - -class APITrackingMiddleware(BaseHTTPMiddleware): - """API调用统计中间件 - 记录请求耗时,标记慢请求""" - - async def dispatch(self, request: Request, call_next): - request_id = str(uuid.uuid4()) - start_time = time.time() - path = request.url.path - method = request.method - - # 跳过健康检查和静态资源 - skip_paths = ["/app/api/health", "/favicon.ico"] - should_skip = any(path.startswith(p) for p in skip_paths) - - try: - response = await call_next(request) - elapsed = time.time() - start_time - status_code = response.status_code - - if not should_skip: - access_logger.info( - f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" - ) - - # 异步记录API调用统计 - try: - db = SessionLocal() - try: - _record_api_call(db, method, path, status_code, elapsed) - finally: - db.close() - except Exception as e: - logger.warning(f"API统计记录失败: {e}") - - return response - except Exception as e: - elapsed = time.time() - start_time - if not should_skip: - access_logger.info( - f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" - ) - raise - - async def __call__(self, scope, receive, send): - if scope["type"] != "http": - await self.app(scope, receive, send) - return - await super().__call__(scope, receive, send) - - -def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): - """记录API调用到数据库统计表""" - # 简化路径以减少基数(去掉动态ID) - parts = path.split("/") - simplified_parts = [] - for part in parts: - # 将UUID样式的路径段替换为{id} - if len(part) == 36 and part.count("-") == 4: - simplified_parts.append("{id}") - # 将纯数字路径段替换为{id} - elif part.isdigit(): - simplified_parts.append("{id}") - else: - simplified_parts.append(part) - simplified_path = "/".join(simplified_parts) - - # 统计记录通过数据库实现,简化版记录到日志 - is_error = status_code >= 400 - is_slow = elapsed >= SLOW_REQUEST_THRESHOLD - - if is_slow: - logger.warning( - f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" - ) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - logger.info("Qihu-AIFlow v2.0.0 启动中...") - - # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) - try: - from app.services.evolution_service import EvolutionWatcherManager - from app.models import UserEvolutionConfig - db = SessionLocal() - try: - configs = db.query(UserEvolutionConfig).filter( - UserEvolutionConfig.watcher_status == "running" - ).all() - if configs: - watcher = EvolutionWatcherManager() - for cfg in configs: - try: - watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) - logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") - except Exception as e: - logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") - logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") - else: - logger.info("[Evolution] 无待恢复的进化配置") - finally: - db.close() - except Exception as e: - logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") - - # 启动Redis事件总线 - try: - from app.event_bus import get_event_bus - event_bus = get_event_bus() - event_bus.start(num_consumers=2) - logger.info("[EventBus] Redis事件总线已启动") - except Exception as e: - logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") - - logger.info("Qihu-AIFlow v2.0.0 启动完成") - - yield - - # 停止Redis事件总线 - try: - from app.event_bus import get_event_bus - get_event_bus().stop() - logger.info("[EventBus] Redis事件总线已停止") - except Exception: - pass - - # 优雅关闭:停止所有 Evolution Pipeline - try: - from app.services.evolution_service import EvolutionWatcherManager - EvolutionWatcherManager().shutdown_all() - logger.info("[Evolution] 所有Pipeline已停止") - except Exception: - pass - - logger.info("Qihu-AIFlow 正在关闭...") - - -app = FastAPI( - title="Qihu-AIFlow - 智能自动化PaaS平台", - version="1.0.0", - lifespan=lifespan, -) - -# API调用追踪中间件 -app.add_middleware(APITrackingMiddleware) - -# CORS配置 -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# 前台 API -app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) -app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) -app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"]) -app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) -app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) -app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) -app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) -app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) -app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) -app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) -app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) - -# 后台 API -app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) -app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"]) -app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) -app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) -app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) -app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) -app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"]) -app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"]) -app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"]) -app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"]) - - -@app.get("/app/api/health") -async def health_check(): - return {"status": "ok", "service": "Qihu-AIFlow"} +""" +Qihu-AIFlow FastAPI 主应用 +完整后端API,监听18080端口,所有接口以 /app 为前缀 +""" +import sys +import os +import time +import uuid +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution +from app.casdoor_auth import router as casdoor_router +from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin +from app.api.im_bot_user import router as im_bot_user_router +from app.api.portal import user_portal +from app.api.admin.developer_tools_admin import router as developer_tools_router +from app.api.admin.mcp_market_admin import router as mcp_market_router +from app.api.admin import asset_admin, achievement_admin +from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD +from app.database import SessionLocal +from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel + +# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) +import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 + +# 初始化文件日志(按天轮转) +access_logger = setup_file_logging() +logger = logging.getLogger("aiflow") + + +class APITrackingMiddleware(BaseHTTPMiddleware): + """API调用统计中间件 - 记录请求耗时,标记慢请求""" + + async def dispatch(self, request: Request, call_next): + request_id = str(uuid.uuid4()) + start_time = time.time() + path = request.url.path + method = request.method + + # 跳过健康检查和静态资源 + skip_paths = ["/app/api/health", "/favicon.ico"] + should_skip = any(path.startswith(p) for p in skip_paths) + + try: + response = await call_next(request) + elapsed = time.time() - start_time + status_code = response.status_code + + if not should_skip: + access_logger.info( + f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" + ) + + # 异步记录API调用统计 + try: + db = SessionLocal() + try: + _record_api_call(db, method, path, status_code, elapsed) + finally: + db.close() + except Exception as e: + logger.warning(f"API统计记录失败: {e}") + + return response + except Exception as e: + elapsed = time.time() - start_time + if not should_skip: + access_logger.info( + f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" + ) + raise + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + await super().__call__(scope, receive, send) + + +def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): + """记录API调用到数据库统计表""" + # 简化路径以减少基数(去掉动态ID) + parts = path.split("/") + simplified_parts = [] + for part in parts: + # 将UUID样式的路径段替换为{id} + if len(part) == 36 and part.count("-") == 4: + simplified_parts.append("{id}") + # 将纯数字路径段替换为{id} + elif part.isdigit(): + simplified_parts.append("{id}") + else: + simplified_parts.append(part) + simplified_path = "/".join(simplified_parts) + + # 统计记录通过数据库实现,简化版记录到日志 + is_error = status_code >= 400 + is_slow = elapsed >= SLOW_REQUEST_THRESHOLD + + if is_slow: + logger.warning( + f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Qihu-AIFlow v2.0.0 启动中...") + + # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) + try: + from app.services.evolution_service import EvolutionWatcherManager + from app.models import UserEvolutionConfig + db = SessionLocal() + try: + configs = db.query(UserEvolutionConfig).filter( + UserEvolutionConfig.watcher_status == "running" + ).all() + if configs: + watcher = EvolutionWatcherManager() + for cfg in configs: + try: + watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) + logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") + except Exception as e: + logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") + logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") + else: + logger.info("[Evolution] 无待恢复的进化配置") + finally: + db.close() + except Exception as e: + logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") + + # 启动Redis事件总线 + try: + from app.event_bus import get_event_bus + event_bus = get_event_bus() + event_bus.start(num_consumers=2) + logger.info("[EventBus] Redis事件总线已启动") + except Exception as e: + logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") + + logger.info("Qihu-AIFlow v2.0.0 启动完成") + + yield + + # 停止Redis事件总线 + try: + from app.event_bus import get_event_bus + get_event_bus().stop() + logger.info("[EventBus] Redis事件总线已停止") + except Exception: + pass + + # 优雅关闭:停止所有 Evolution Pipeline + try: + from app.services.evolution_service import EvolutionWatcherManager + EvolutionWatcherManager().shutdown_all() + logger.info("[Evolution] 所有Pipeline已停止") + except Exception: + pass + + logger.info("Qihu-AIFlow 正在关闭...") + + +app = FastAPI( + title="Qihu-AIFlow - 智能自动化PaaS平台", + version="1.0.0", + lifespan=lifespan, +) + +# API调用追踪中间件 +app.add_middleware(APITrackingMiddleware) + +# CORS配置 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 前台 API +app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) +app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) +app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"]) +app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) +app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) +app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) +app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) +app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) +app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) +app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) +app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) + +# 后台 API +app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) +app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"]) +app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) +app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) +app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) +app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) +app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"]) +app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"]) +app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"]) +app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"]) + +app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"]) +app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"]) +app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"]) +app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"]) +app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"]) +app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"]) +app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"]) +app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"]) +app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"]) +app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"]) +app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) +app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) +app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) +app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"]) +app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"]) +app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"]) +app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"]) +app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"]) +app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"]) +app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"]) +app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"]) +app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"]) +app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"]) +app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"]) + + +@app.get("/app/api/health") +async def health_check(): + return {"status": "ok", "service": "Qihu-AIFlow"} diff --git a/backend/python/platform_bridge/__init__.py b/backend/python/platform_bridge/__init__.py new file mode 100644 index 0000000..a0dd389 --- /dev/null +++ b/backend/python/platform_bridge/__init__.py @@ -0,0 +1,24 @@ +""" +Platform Bridge - Cloud-to-Local Agent Bridge +============================================== + +A Python package providing MCP server and sync client for bridging +Goose (local agent) with the Qihu-AIFlow cloud platform. + +Modules: +- mcp_server: MCP-compatible server for Goose (stdio transport) +- sync_client: Periodic sync manager for KB, license, usage +- __init__: Package interface +""" + +from .mcp_server import PlatformBridgeClient, PlatformBridgeMCPServer, main +from .sync_client import SyncManager, create_sync_manager + +__version__ = "1.0.0" +__all__ = [ + "PlatformBridgeClient", + "PlatformBridgeMCPServer", + "SyncManager", + "create_sync_manager", + "main", +] diff --git a/backend/python/platform_bridge/mcp_server.py b/backend/python/platform_bridge/mcp_server.py new file mode 100644 index 0000000..3288c34 --- /dev/null +++ b/backend/python/platform_bridge/mcp_server.py @@ -0,0 +1,308 @@ +""" +Platform Bridge MCP Server +=========================== +Goose MCP extension that bridges local Goose with the cloud platform. + +Provides tools: +- fetch_agent_config: Get agent configuration from platform +- sync_knowledge_base: Sync knowledge base to local +- report_usage: Report anonymized usage statistics +- validate_license: Validate offline license +- get_platform_status: Check platform health +""" +__version__ = "1.0.0" +__name__ = "platform_bridge" + +import os +import sys +import json +import time +import hashlib +import hmac +import urllib.request +import urllib.error +from typing import Any, Dict, List, Optional + + +class PlatformBridgeClient: + """Client for the cloud platform API""" + + def __init__(self, api_url: str = None, user_id: str = None, api_key: str = None): + self.api_url = api_url or os.environ.get( + "PLATFORM_API_URL", "http://210.16.161.218:18080" + ) + self.user_id = user_id or os.environ.get("PLATFORM_USER_ID", "local-agent") + self.api_key = api_key or os.environ.get("PLATFORM_API_KEY", "") + self.base_path = "/app/api/admin/privacy" + + def _request(self, method: str, path: str, params: dict = None) -> dict: + """Make an HTTP request to the platform API""" + url = f"{self.api_url}{path}" + if params: + query_parts = [] + for k, v in params.items(): + if v is not None: + query_parts.append(f"{k}={urllib.request.quote(str(v))}") + if query_parts: + url += "?" + "&".join(query_parts) + + req = urllib.request.Request(url, method=method) + if self.api_key: + req.add_header("Authorization", f"Bearer {self.api_key}") + req.add_header("Accept", "application/json") + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode()) + return data + except urllib.error.HTTPError as e: + return {"code": 1, "error": f"HTTP {e.code}: {e.reason}"} + except Exception as e: + return {"code": 1, "error": str(e)} + + def fetch_agent_config(self, agent_id: str = None) -> dict: + """Fetch agent configuration from platform""" + params = {"user_id": self.user_id} + if agent_id: + params["agent_id"] = agent_id + return self._request("GET", f"{self.base_path}/local-agents", params) + + def sync_knowledge_base(self, kb_id: int, direction: str = "pull") -> dict: + """Sync knowledge base between platform and local""" + return self._request("POST", f"{self.base_path}/kb-sync/trigger", { + "knowledge_base_id": kb_id, + "sync_direction": direction, + "user_id": self.user_id, + }) + + def report_usage(self, metrics: dict, report_type: str = "daily") -> dict: + """Submit anonymized usage report""" + return self._request("POST", f"{self.base_path}/usage/report", { + "metrics": json.dumps(metrics), + "report_type": report_type, + "user_id": self.user_id, + }) + + def validate_license(self, license_key: str, device_fingerprint: str = None) -> dict: + """Validate offline license""" + params = {"license_key": license_key, "user_id": self.user_id} + if device_fingerprint: + params["device_fingerprint"] = device_fingerprint + return self._request("POST", f"{self.base_path}/licenses/validate", params) + + def get_privacy_config(self) -> dict: + """Get current privacy configuration""" + return self._request("GET", f"{self.base_path}/config", {"user_id": self.user_id}) + + def get_bridge_config(self) -> dict: + """Get Platform Bridge configuration for export""" + return self._request("GET", f"{self.base_path}/bridge-config", {"user_id": self.user_id}) + + def refresh_license(self, license_id: int, extend_days: int = 7) -> dict: + """Refresh an existing license""" + return self._request("POST", f"{self.base_path}/licenses/{license_id}/refresh", { + "extend_days": extend_days, + }) + + def sync_all_knowledge_bases(self) -> List[dict]: + """Sync all configured knowledge bases""" + config = self.get_privacy_config() + if config.get("code") != 0: + return [{"error": "Failed to get config"}] + + data = config.get("data", {}) + kb_list = data.get("sync_knowledge_bases", []) + if isinstance(kb_list, str): + try: + kb_list = json.loads(kb_list) + except json.JSONDecodeError: + kb_list = [] + + results = [] + for kb_id in (kb_list or []): + result = self.sync_knowledge_base(kb_id) + results.append({"kb_id": kb_id, "result": result}) + + return results + + def check_health(self) -> dict: + """Check platform health and connectivity""" + start = time.time() + try: + result = self._request("GET", f"{self.base_path}/stats") + elapsed = time.time() - start + return { + "connected": True, + "latency_ms": round(elapsed * 1000), + "platform_version": "qihuaiflow-g5", + "stats": result.get("data", {}), + } + except Exception as e: + return { + "connected": False, + "error": str(e), + } + + +# ======================================================================== +# MCP Server Interface (_stdio transport) +# ======================================================================== +class PlatformBridgeMCPServer: + """MCP-compatible server using stdio transport""" + + def __init__(self): + self.client = PlatformBridgeClient() + self.tools = { + "fetch_agent_config": { + "name": "fetch_agent_config", + "description": "Fetch agent configuration from the cloud platform", + "parameters": { + "agent_id": {"type": "string", "description": "Optional agent ID", "required": False} + } + }, + "sync_knowledge_base": { + "name": "sync_knowledge_base", + "description": "Sync knowledge base documents from platform to local", + "parameters": { + "kb_id": {"type": "integer", "description": "Knowledge base ID", "required": True}, + "direction": {"type": "string", "description": "Sync direction: pull/push/bidirectional", "required": False} + } + }, + "report_usage": { + "name": "report_usage", + "description": "Report anonymized usage statistics to platform", + "parameters": { + "metrics": {"type": "object", "description": "Usage metrics JSON", "required": True}, + "report_type": {"type": "string", "description": "Report type: daily/weekly/monthly/realtime", "required": False} + } + }, + "validate_license": { + "name": "validate_license", + "description": "Validate offline license key", + "parameters": { + "license_key": {"type": "string", "description": "The license key to validate", "required": True}, + "device_fingerprint": {"type": "string", "description": "Device fingerprint for binding", "required": False} + } + }, + "get_platform_status": { + "name": "get_platform_status", + "description": "Check cloud platform connectivity and health", + "parameters": {} + }, + "get_agent_list": { + "name": "get_agent_list", + "description": "List available agents from the platform", + "parameters": {} + }, + "sync_all_kb": { + "name": "sync_all_kb", + "description": "Sync all configured knowledge bases", + "parameters": {} + }, + "get_privacy_config": { + "name": "get_privacy_config", + "description": "Get current privacy mode configuration", + "parameters": {} + } + } + + def _handle_request(self, request: dict) -> dict: + """Process an MCP tool request""" + tool_name = request.get("name", "") + args = request.get("arguments", {}) or {} + + handler_map = { + "fetch_agent_config": lambda: self.client.fetch_agent_config(args.get("agent_id")), + "sync_knowledge_base": lambda: self.client.sync_knowledge_base( + args["kb_id"], args.get("direction", "pull") + ), + "report_usage": lambda: self.client.report_usage( + args["metrics"], args.get("report_type", "daily") + ), + "validate_license": lambda: self.client.validate_license( + args["license_key"], args.get("device_fingerprint") + ), + "get_platform_status": lambda: self.client.check_health(), + "get_agent_list": lambda: self.client.fetch_agent_config(), + "sync_all_kb": lambda: self.client.sync_all_knowledge_bases(), + "get_privacy_config": lambda: self.client.get_privacy_config(), + } + + handler = handler_map.get(tool_name) + if not handler: + return {"error": f"Unknown tool: {tool_name}"} + + try: + result = handler() + return result if isinstance(result, dict) else {"result": result} + except Exception as e: + return {"error": str(e)} + + def handle_tools_list(self) -> dict: + """Return list of available tools (MCP tools/list)""" + return {"tools": list(self.tools.values())} + + def handle_tools_call(self, request: dict) -> dict: + """Handle a tool call (MCP tools/call)""" + tool_name = request.get("name", "") + args = request.get("arguments", {}) or {} + + result = self._handle_request({"name": tool_name, "arguments": args}) + + return { + "content": [ + { + "type": "text", + "text": json.dumps(result, ensure_ascii=False, indent=2) + } + ] + } + + def run(self): + """Run MCP server in stdio mode""" + print("Platform Bridge MCP Server v1.0", file=sys.stderr) + print(f"API URL: {self.client.api_url}", file=sys.stderr) + print(f"User ID: {self.client.user_id}", file=sys.stderr) + + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + request = json.loads(line) + method = request.get("method", "") + + if method == "tools/list": + response = self.handle_tools_list() + elif method == "tools/call": + response = self.handle_tools_call(request.get("params", {})) + elif method == "initialize": + response = { + "protocolVersion": "2024-11-05", + "serverInfo": {"name": "platform-bridge", "version": "1.0.0"}, + "capabilities": {"tools": {}} + } + else: + response = {"error": f"Unknown method: {method}"} + + if "id" in request: + response["id"] = request["id"] + response["jsonrpc"] = "2.0" + + print(json.dumps(response), flush=True) + except json.JSONDecodeError as e: + err_resp = {"jsonrpc": "2.0", "error": {"code": -32700, "message": f"Parse error: {e}"}} + print(json.dumps(err_resp), flush=True) + except Exception as e: + err_resp = {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}} + print(json.dumps(err_resp), flush=True) + + +def main(): + """Entry point for python -m platform_bridge.mcp_server""" + server = PlatformBridgeMCPServer() + server.run() + + +if __name__ == "__main__": + main() diff --git a/backend/python/platform_bridge/sync_client.py b/backend/python/platform_bridge/sync_client.py new file mode 100644 index 0000000..3902835 --- /dev/null +++ b/backend/python/platform_bridge/sync_client.py @@ -0,0 +1,133 @@ +""" +Platform Bridge - Cloud-to-Local Agent Bridge +============================================== +WorkBuddy-compatible sync client for local Goose agent. + +Provides: +- Periodic knowledge base sync +- Usage metrics collection and reporting +- License validation and refresh +- Platform health monitoring +""" +import os +import json +import time +import hashlib +from datetime import datetime, timedelta + +# Import the MCP server client +from .mcp_server import PlatformBridgeClient + + +class SyncManager: + """Manages periodic synchronization between local agent and cloud platform""" + + def __init__(self, client: PlatformBridgeClient = None): + self.client = client or PlatformBridgeClient() + self.sync_interval = 3600 # Default: 1 hour + self.last_sync = {} + self.last_license_check = None + self.last_usage_report = None + self.usage_buffer = [] + + def configure(self, config: dict): + """Configure sync parameters from privacy config""" + frequency_map = { + "realtime": 60, + "hourly": 3600, + "daily": 86400, + "manual": None, # No auto sync + } + freq = config.get("sync_frequency", "daily") + self.sync_interval = frequency_map.get(freq, 86400) + self.client.user_id = config.get("user_id", self.client.user_id) + + def should_sync_kb(self, kb_id: int) -> bool: + """Check if a KB needs syncing""" + last = self.last_sync.get(kb_id, 0) + return (time.time() - last) >= self.sync_interval + + def sync_kb(self, kb_id: int) -> dict: + """Sync a single knowledge base""" + result = self.client.sync_knowledge_base(kb_id) + self.last_sync[kb_id] = time.time() + return result + + def sync_all(self, kb_ids: list) -> list: + """Sync all configured knowledge bases""" + results = [] + for kb_id in kb_ids: + if self.should_sync_kb(kb_id): + result = self.sync_kb(kb_id) + results.append({"kb_id": kb_id, "result": result}) + return results + + def check_license(self, license_key: str) -> dict: + """Check if license is still valid""" + now = time.time() + if self.last_license_check and (now - self.last_license_check) < 3600: + return {"valid": True, "cached": True} + + result = self.client.validate_license(license_key) + self.last_license_check = now + return result + + def track_usage(self, event_type: str, metadata: dict = None): + """Buffer a usage event for reporting""" + self.usage_buffer.append({ + "type": event_type, + "timestamp": time.time(), + "metadata": metadata or {}, + }) + + def report_usage(self) -> dict: + """Submit buffered usage events""" + if not self.usage_buffer: + return {"reported": 0} + + events = self.usage_buffer[:] + self.usage_buffer = [] + + metrics = { + "event_count": len(events), + "event_types": {}, + "time_range": { + "start": min(e["timestamp"] for e in events), + "end": max(e["timestamp"] for e in events), + } + } + + for e in events: + t = e["type"] + metrics["event_types"][t] = metrics["event_types"].get(t, 0) + 1 + + result = self.client.report_usage(metrics) + self.last_usage_report = time.time() + result["reported"] = len(events) + return result + + def health_check(self) -> dict: + """Run a full health check""" + platform = self.client.check_health() + config = self.client.get_privacy_config() + + return { + "platform": platform, + "privacy_mode": config.get("data", {}).get("privacy_mode", "unknown"), + "pending_events": len(self.usage_buffer), + "last_license_check": self.last_license_check, + "last_usage_report": self.last_usage_report, + } + + +def create_sync_manager(api_url: str = None, user_id: str = None) -> SyncManager: + """Factory function to create a configured SyncManager""" + client = PlatformBridgeClient(api_url=api_url, user_id=user_id) + manager = SyncManager(client) + + # Load config + config_result = client.get_privacy_config() + if config_result.get("code") == 0: + manager.configure(config_result["data"]) + + return manager diff --git a/docker-compose.yml.bak b/docker-compose.yml.bak new file mode 100644 index 0000000..08ac6f4 --- /dev/null +++ b/docker-compose.yml.bak @@ -0,0 +1,112 @@ +# ============================================================ +# Qihu-AIFlow 智能自动化PaaS平台 - Docker Compose 配置 +# ============================================================ +# 部署方式: +# docker compose up -d +# 或: docker compose up -d --build (重建镜像) +# +# 端口说明: +# 前端 (Nginx): 宿主机 12888 -> 容器内 8080 +# 后端 (Uvicorn): 宿主机 18080 -> 容器内 18080 +# ============================================================ + +services: + aiflow: + build: . + container_name: qihu-aiflow + ports: + - "12888:8080" + - "18080:18080" + environment: + - MYSQL_HOST=mysql + - MYSQL_PORT=3306 + - MYSQL_USER=root + - MYSQL_PASSWORD=pwd2026 + - MYSQL_DATABASE=aiflow + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=pwd2026 + - SECRET_KEY=aiflow-secret-key-change-in-production-2026 + - APP_ACCESS_URL=http://210.16.161.218:12888 + - DEBUG=false + - SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz + - SILICONFLOW_BASE_URL=https://api.siliconflow.cn + - DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash + - SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz + - SILICONFLOW_BASE_URL=https://api.siliconflow.cn + - DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash + - SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz + - SILICONFLOW_BASE_URL=https://api.siliconflow.cn + - DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash + - SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz + - SILICONFLOW_BASE_URL=https://api.siliconflow.cn + - DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash + - HTTP_PROXY=http://172.18.0.1:7890 + - HTTPS_PROXY=http://172.18.0.1:7890 + - NO_PROXY=localhost,127.0.0.1,::1,210.16.161.218,172.17.0.0/16,172.18.0.0/16,172.19.0.0/16,172.20.0.0/16,10.0.0.0/8,192.168.0.0/16,.svc,.local,casdoor,qihu-aiflow-mysql,qihu-aiflow-redis,qihu-aiflow-pgvector,qihu-aiflow-casdoor,qihuyun.com,ai.qihuyun.com,docs.qihuyun.com,bot.qihuyun.com,siliconflow.cn,api.siliconflow.cn,deepseek.com,api.deepseek.com,volcengineapi.com,aliyun.com,aliyuncs.com,dysmsapi.aliyuncs.com,tencentcloudapi.com,baidubce.com,dashscope.com,qwen.ai,zhipuai.cn,moonshot.cn,minimax.chat,baichuan.com,chatglm.cn,xiaomimimo.com,token-plan-cn.xiaomimimo.com,mi.com,pypi.tuna.tsinghua.edu.cn,feishu.cn,open.feishu.cn,dingtalk.com,open.dingtalk.com,weixin.qq.com,developers.weixin.qq.com,qyapi.weixin.qq.com,ilinkai.weixin.qq.com,tencent.com + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + networks: + - aiflow-network + volumes: + - ./data:/app/data + logging: + driver: json-file + options: + max-size: "50m" + max-file: "10" + + mysql: + image: mysql:8.0 + container_name: qihu-aiflow-mysql + command: --ssl=0 + environment: + - MYSQL_ROOT_PASSWORD=pwd2026 + - MYSQL_DATABASE=aiflow + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + restart: unless-stopped + networks: + - aiflow-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-ppwd2026"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 30s + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + redis: + image: redis:7-alpine + container_name: qihu-aiflow-redis + command: redis-server --requirepass pwd2026 --appendonly yes + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + networks: + - aiflow-network + logging: + driver: json-file + options: + max-size: "20m" + max-file: "5" + +volumes: + mysql_data: + redis_data: + +networks: + aiflow-network: + driver: bridge diff --git a/docker-compose.yml.bak.build b/docker-compose.yml.bak.build new file mode 100644 index 0000000..2363a35 --- /dev/null +++ b/docker-compose.yml.bak.build @@ -0,0 +1,112 @@ +# ============================================================ +# Qihu-AIFlow 智能自动化PaaS平台 - Docker Compose 配置 +# ============================================================ +# 部署方式: +# docker compose up -d +# 或: docker compose up -d --build (重建镜像) +# +# 端口说明: +# 前端 (Nginx): 宿主机 12888 -> 容器内 8080 +# 后端 (Uvicorn): 宿主机 18080 -> 容器内 18080 +# ============================================================ + +services: + aiflow: + image: qihuaiflow:with-deps + container_name: qihu-aiflow + ports: + - "12888:8080" + - "18080:18080" + environment: + - MYSQL_HOST=mysql + - MYSQL_PORT=3306 + - MYSQL_USER=root + - MYSQL_PASSWORD=pwd2026 + - MYSQL_DATABASE=aiflow + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=pwd2026 + - SECRET_KEY=aiflow-secret-key-change-in-production-2026 + - APP_ACCESS_URL=http://210.16.161.218:12888 + - DEBUG=false + - SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz + - SILICONFLOW_BASE_URL=https://api.siliconflow.cn + - DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash + - SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz + - SILICONFLOW_BASE_URL=https://api.siliconflow.cn + - DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash + - SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz + - SILICONFLOW_BASE_URL=https://api.siliconflow.cn + - DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash + - SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz + - SILICONFLOW_BASE_URL=https://api.siliconflow.cn + - DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash + - HTTP_PROXY=http://172.18.0.1:7890 + - HTTPS_PROXY=http://172.18.0.1:7890 + - NO_PROXY=localhost,127.0.0.1,::1,210.16.161.218,172.17.0.0/16,172.18.0.0/16,172.19.0.0/16,172.20.0.0/16,10.0.0.0/8,192.168.0.0/16,.svc,.local,casdoor,qihu-aiflow-mysql,qihu-aiflow-redis,qihu-aiflow-pgvector,qihu-aiflow-casdoor,qihuyun.com,ai.qihuyun.com,docs.qihuyun.com,bot.qihuyun.com,siliconflow.cn,api.siliconflow.cn,deepseek.com,api.deepseek.com,volcengineapi.com,aliyun.com,aliyuncs.com,dysmsapi.aliyuncs.com,tencentcloudapi.com,baidubce.com,dashscope.com,qwen.ai,zhipuai.cn,moonshot.cn,minimax.chat,baichuan.com,chatglm.cn,xiaomimimo.com,token-plan-cn.xiaomimimo.com,mi.com,pypi.tuna.tsinghua.edu.cn,feishu.cn,open.feishu.cn,dingtalk.com,open.dingtalk.com,weixin.qq.com,developers.weixin.qq.com,qyapi.weixin.qq.com,ilinkai.weixin.qq.com,tencent.com + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + networks: + - aiflow-network + volumes: + - ./data:/app/data + logging: + driver: json-file + options: + max-size: "50m" + max-file: "10" + + mysql: + image: mysql:8.0 + container_name: qihu-aiflow-mysql + command: --ssl=0 + environment: + - MYSQL_ROOT_PASSWORD=pwd2026 + - MYSQL_DATABASE=aiflow + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + restart: unless-stopped + networks: + - aiflow-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-ppwd2026"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 30s + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + redis: + image: redis:7-alpine + container_name: qihu-aiflow-redis + command: redis-server --requirepass pwd2026 --appendonly yes + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + networks: + - aiflow-network + logging: + driver: json-file + options: + max-size: "20m" + max-file: "5" + +volumes: + mysql_data: + redis_data: + +networks: + aiflow-network: + driver: bridge diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 108a214..1856b6e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,125 +1,169 @@ -import React, { useState, useEffect, Suspense } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; -import type { User } from '@/types'; -import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; -import { authAPI } from '@/api'; - -// Layouts(保留为即时加载,构成应用外壳) -import FrontLayout from '@/components/layout/FrontLayout'; -import AdminLayout from '@/components/layout/AdminLayout'; - -// 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖, -// 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。 -const LandingPage = React.lazy(() => import('@/pages/front/LandingPage')); -const LoginPage = React.lazy(() => import('@/pages/front/LoginPage')); -const ChatPage = React.lazy(() => import('@/pages/front/ChatPage')); -const ChannelPage = React.lazy(() => import('@/pages/front/ChannelPage')); -const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage')); -const MarketPage = React.lazy(() => import('@/pages/front/MarketPage')); -const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage')); -const PricingPage = React.lazy(() => import('@/pages/front/PricingPage')); -const ProfilePage = React.lazy(() => import('@/pages/front/ProfilePage')); -const NotificationPage = React.lazy(() => import('@/pages/front/NotificationPage')); -const AssetsPage = React.lazy(() => import('@/pages/front/AssetsPage')); -const NovelPreviewPage = React.lazy(() => import('@/pages/front/NovelPreviewPage')); -const EvolutionDashboard = React.lazy(() => import('@/pages/front/EvolutionDashboard')); - -const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard')); -const AdminUsers = React.lazy(() => import('@/pages/admin/Users')); -const AdminModels = React.lazy(() => import('@/pages/admin/Models')); -const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings')); -const AdminPayment = React.lazy(() => import('@/pages/admin/Payment')); -const AdminServices = React.lazy(() => import('@/pages/admin/Services')); -const AdminMembership = React.lazy(() => import('@/pages/admin/Membership')); -const AdminAds = React.lazy(() => import('@/pages/admin/Ads')); -const AdminMarket = React.lazy(() => import('@/pages/admin/Market')); -const AdminLogs = React.lazy(() => import('@/pages/admin/Logs')); - -const pageFallback =
加载中...
; - -const App: React.FC = () => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - // 初始化:从localStorage恢复登录状态 - useEffect(() => { - const token = localStorage.getItem('aiflow_token'); - const cachedUser = localStorage.getItem('aiflow_user'); - if (token && cachedUser) { - try { - setUser(JSON.parse(cachedUser)); - } catch { - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - } - } - setLoading(false); - }, []); - - const login = (u: User) => { - setUser(u); - localStorage.setItem('aiflow_user', JSON.stringify(u)); - }; - const logout = () => { - setUser(null); - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - }; - const isAdmin = user?.role === 'admin'; - - if (loading) { - return
加载中...
; - } - - return ( - - - - {/* Login */} - : ) : - } /> - - {/* Root - Landing for guests, redirect for authenticated users */} - : ) : - } /> - - {/* Front Routes (authenticated only) */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Admin Routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - } /> - - - - ); -}; - -export default App; +import React, { useState, useEffect, Suspense } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import type { User } from '@/types'; +import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; +import { authAPI } from '@/api'; + +// Layouts(保留为即时加载,构成应用外壳) +import FrontLayout from '@/components/layout/FrontLayout'; +import AdminLayout from '@/components/layout/AdminLayout'; + +// 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖, +// 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。 +const LandingPage = React.lazy(() => import('@/pages/front/LandingPage')); +const LoginPage = React.lazy(() => import('@/pages/front/LoginPage')); +const ChatPage = React.lazy(() => import('@/pages/front/ChatPage')); +const ChannelPage = React.lazy(() => import('@/pages/front/ChannelPage')); +const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage')); +const MarketPage = React.lazy(() => import('@/pages/front/MarketPage')); +const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage')); +const PricingPage = React.lazy(() => import('@/pages/front/PricingPage')); +const ProfilePage = React.lazy(() => import('@/pages/front/ProfilePage')); +const NotificationPage = React.lazy(() => import('@/pages/front/NotificationPage')); +const AssetsPage = React.lazy(() => import('@/pages/front/AssetsPage')); +const NovelPreviewPage = React.lazy(() => import('@/pages/front/NovelPreviewPage')); +const EvolutionDashboard = React.lazy(() => import('@/pages/front/EvolutionDashboard')); + +const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard')); +const AdminUsers = React.lazy(() => import('@/pages/admin/Users')); +const AdminModels = React.lazy(() => import('@/pages/admin/Models')); +const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings')); +const AdminPayment = React.lazy(() => import('@/pages/admin/Payment')); +const AdminServices = React.lazy(() => import('@/pages/admin/Services')); +const AdminMembership = React.lazy(() => import('@/pages/admin/Membership')); +const AdminAds = React.lazy(() => import('@/pages/admin/Ads')); +const AdminMarket = React.lazy(() => import('@/pages/admin/Market')); +const AdminLogs = React.lazy(() => import('@/pages/admin/Logs')); +const AdminSkills = React.lazy(() => import('@/pages/admin/Skills')); +const AdminAgents = React.lazy(() => import('@/pages/admin/Agents')); +const AdminCodeExec = React.lazy(() => import('@/pages/admin/CodeExec')); +const DistributionBuilder = React.lazy(() => import('@/pages/admin/DistributionBuilder')); +const ImBots = React.lazy(() => import('@/pages/admin/ImBots')); +const PrivacySettings = React.lazy(() => import('@/pages/admin/PrivacySettings')); +const Promotion = React.lazy(() => import('@/pages/admin/Promotion')); +const Achievement = React.lazy(() => import('@/pages/admin/Achievement')); +const Tenant = React.lazy(() => import('@/pages/admin/Tenant')); +const Billing = React.lazy(() => import('@/pages/admin/Billing')); +const Analytics = React.lazy(() => import('@/pages/admin/Analytics')); +const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge')); +const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow')); +const AdminStorage = React.lazy(() => import('@/pages/admin/Storage')); +const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer')); +const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools')); +const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation')); +const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp')); +const AdminApps = React.lazy(() => import('@/pages/admin/Apps')); +const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter')); +const UserPortal = React.lazy(() => import('@/pages/UserPortal')); +const WechatBot = React.lazy(() => import('@/pages/front/WechatBot')); + +const pageFallback =
加载中...
; + +const App: React.FC = () => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // 初始化:从localStorage恢复登录状态 + useEffect(() => { + const token = localStorage.getItem('aiflow_token'); + const cachedUser = localStorage.getItem('aiflow_user'); + if (token && cachedUser) { + try { + setUser(JSON.parse(cachedUser)); + } catch { + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + } + } + setLoading(false); + }, []); + + const login = (u: User) => { + setUser(u); + localStorage.setItem('aiflow_user', JSON.stringify(u)); + }; + const logout = () => { + setUser(null); + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + }; + const isAdmin = user?.role === 'admin'; + + if (loading) { + return
加载中...
; + } + + return ( + + + + {/* Login */} + : ) : + } /> + + {/* Root - Landing for guests, redirect for authenticated users */} + : ) : + } /> + + {/* Front Routes (authenticated only) */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Admin Routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + + ); +}; + +export default App; diff --git a/frontend/src/pages/UserPortal.tsx b/frontend/src/pages/UserPortal.tsx new file mode 100644 index 0000000..5091662 --- /dev/null +++ b/frontend/src/pages/UserPortal.tsx @@ -0,0 +1,491 @@ +import { useState, useEffect, useCallback } from 'react'; +import { request, adminAPI } from '@/api'; + +// ==================== Types ==================== +interface UserOverview { + user: { id: string; username: string; email: string; avatar: string | null; role: string; membership_level: number; membership_expire_at: string | null; created_at: string; }; + token_stats: { total_tokens: number; prompt_tokens: number; completion_tokens: number; call_count: number; }; + daily_usage: { date: string; calls: number; tokens: number; }[]; + tenants: { id: string; name: string; slug: string; status: string; role: string; }[]; + recent_transactions: { id: string; type: string; amount: number; tokens: number; description: string; status: string; created_at: string; }[]; + api_key_count: number; +} +interface ApiKey { id: string; key_name: string; key_prefix: string; status: string; created_at: string; last_used_at: string | null; } +interface Package { id: string; name: string; description: string; tokens: number; price: number; original_price: number | null; bonus_tokens: number; is_popular: boolean; } +interface Tenant { id: string; name: string; slug: string; description: string; status: string; max_members: number; max_storage_mb: number; max_api_calls_per_day: number; role: string; joined_at: string; member_count: number; } +interface UsageData { daily: { date: string; calls: number; tokens: number; prompt_tokens: number; completion_tokens: number; }[]; model_distribution: { model: string; calls: number; tokens: number; }[]; total: { calls: number; tokens: number; }; days: number; } + +const API_BASE = '/app/api/portal'; + +// ==================== Component ==================== +export default function UserPortal() { + const [activeTab, setActiveTab] = useState('overview'); + const [loading, setLoading] = useState(false); + + const tabs = [ + { key: 'overview', label: '概览', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' }, + { key: 'apikeys', label: 'API密钥', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' }, + { key: 'recharge', label: '充值中心', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' }, + { key: 'tenants', label: '我的租户', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5' }, + { key: 'analytics', label: '用量分析', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' }, + ]; + + return ( +
+
+

用户门户

+ + {/* Tab Navigation */} +
+ {tabs.map(tab => ( + + ))} +
+ + {activeTab === 'overview' && } + {activeTab === 'apikeys' && } + {activeTab === 'recharge' && } + {activeTab === 'tenants' && } + {activeTab === 'analytics' && } +
+
+ ); +} + +// ==================== Overview Tab ==================== +function OverviewTab() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + request(`${API_BASE}/overview`).then(res => { + setData(res.data || res); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + if (loading) return
加载中...
; + if (!data) return
加载失败
; + + const cards = [ + { label: '本月Token用量', value: data.token_stats.total_tokens.toLocaleString(), color: '#58a6ff' }, + { label: 'API调用次数', value: data.token_stats.call_count.toLocaleString(), color: '#3fb950' }, + { label: 'API密钥数', value: data.api_key_count.toString(), color: '#bc8cff' }, + { label: '所属租户', value: data.tenants.length.toString(), color: '#f0883e' }, + ]; + + return ( +
+ {/* Stats Cards */} +
+ {cards.map((c, i) => ( +
+
{c.value}
+
{c.label}
+
+ ))} +
+ +
+ {/* Daily Usage Chart */} +
+

近7天用量

+ {data.daily_usage.length > 0 ? ( +
+ {data.daily_usage.map((d, i) => { + const maxTokens = Math.max(...data.daily_usage.map(x => x.tokens), 1); + const height = (d.tokens / maxTokens) * 100; + return ( +
+
+
{d.date.slice(5)}
+
+ ); + })} +
+ ) :
暂无数据
} +
+ + {/* Recent Transactions */} +
+

最近交易

+ {data.recent_transactions.length > 0 ? ( +
+ {data.recent_transactions.map(t => ( +
+
+
{t.description}
+
{t.created_at}
+
+
= 0 ? '#3fb950' : '#f85149', fontWeight: 600, fontSize: '14px' }}> + {t.amount >= 0 ? '+' : ''}¥{t.amount.toFixed(2)} +
+
+ ))} +
+ ) :
暂无交易
} +
+
+ + {/* Tenants */} + {data.tenants.length > 0 && ( +
+

我的租户

+
+ {data.tenants.map(t => ( +
+
{t.name}
+
角色: {t.role}
+
+ ))} +
+
+ )} +
+ ); +} + +// ==================== API Keys Tab ==================== +function ApiKeysTab() { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [newKeyName, setNewKeyName] = useState(''); + const [newKey, setNewKey] = useState(null); + + const load = useCallback(() => { + setLoading(true); + request(`${API_BASE}/api-keys`).then(res => { + setKeys(res.data || []); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const createKey = () => { + if (!newKeyName.trim()) return; + request(`${API_BASE}/api-keys/create?key_name=${encodeURIComponent(newKeyName)}`, { method: 'POST' }).then(res => { + setNewKey(res.data?.api_key || null); + setNewKeyName(''); + setShowCreate(false); + load(); + }); + }; + + const revokeKey = (id: string) => { + if (!confirm('确定撤销此密钥?')) return; + request(`${API_BASE}/api-keys/${id}`, { method: 'DELETE' }).then(() => load()); + }; + + return ( +
+
+

API 密钥管理

+ +
+ + {newKey && ( +
+
密钥已创建 - 请立即保存
+ {newKey} + +
+ )} + + {showCreate && ( +
+ setNewKeyName(e.target.value)} placeholder="密钥名称(如:生产环境)" + style={{ width: '300px', padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3', marginRight: '8px' }} /> + +
+ )} + + {loading ?
加载中...
: ( +
+ + + + + + + + + + + + {keys.length === 0 ? ( + + ) : keys.map(k => ( + + + + + + + + ))} + +
名称密钥前缀状态创建时间操作
暂无API密钥
{k.key_name}{k.key_prefix}... + + {k.status === 'active' ? '活跃' : '已撤销'} + + {k.created_at} + {k.status === 'active' && } +
+
+ )} +
+ ); +} + +// ==================== Recharge Tab ==================== +function RechargeTab() { + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + request(`${API_BASE}/billing/packages`).then(res => { + setPackages(res.data || []); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + if (loading) return
加载中...
; + + return ( +
+

充值中心

+
+ {packages.map(pkg => ( +
+ {pkg.is_popular &&
推荐
} +

{pkg.name}

+

{pkg.description}

+
+ ¥{pkg.price} + {pkg.original_price && ¥{pkg.original_price}} +
+
+ {(pkg.tokens / 10000).toFixed(0)} 万 Token{pkg.bonus_tokens > 0 && ` +${(pkg.bonus_tokens / 10000).toFixed(0)}万 赠送`} +
+ +
+ ))} +
+ +
+

交易记录

+ +
+
+ ); +} + +function TransactionsList() { + const [txns, setTxns] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + request(`${API_BASE}/billing/transactions`).then(res => { + setTxns(res.data?.items || []); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + if (loading) return
加载中...
; + + return ( +
+ + + {['描述', '类型', '金额', 'Token', '状态', '时间'].map(h => )} + + + {txns.length === 0 ? : txns.map(t => ( + + + + + + + + + ))} + +
{h}
暂无交易记录
{t.description}{t.type}= 0 ? '#3fb950' : '#f85149' }}>{t.amount >= 0 ? '+' : ''}¥{t.amount.toFixed(2)}{t.tokens?.toLocaleString() || '-'}{t.status}{t.created_at}
+
+ ); +} + +// ==================== Tenants Tab ==================== +function TenantsTab() { + const [tenants, setTenants] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + const [desc, setDesc] = useState(''); + + const load = useCallback(() => { + setLoading(true); + request(`${API_BASE}/tenants`).then(res => { + setTenants(res.data || []); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const create = () => { + if (!name.trim() || !slug.trim()) return; + request(`${API_BASE}/tenants/create?name=${encodeURIComponent(name)}&slug=${encodeURIComponent(slug)}&description=${encodeURIComponent(desc)}`, { method: 'POST' }).then(() => { + setShowCreate(false); setName(''); setSlug(''); setDesc(''); + load(); + }); + }; + + return ( +
+
+

我的租户

+ +
+ + {showCreate && ( +
+
+ setName(e.target.value)} placeholder="租户名称" style={{ padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }} /> + setSlug(e.target.value)} placeholder="标识(如:my-company)" style={{ padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }} /> +
+ setDesc(e.target.value)} placeholder="描述(选填)" style={{ width: '100%', padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3', marginBottom: '12px' }} /> + +
+ )} + + {loading ?
加载中...
: ( +
+ {tenants.length === 0 ?
暂无租户,点击上方创建
: tenants.map(t => ( +
+
+

{t.name}

+ {t.role} +
+

{t.description || '暂无描述'}

+
+ 成员: {t.member_count}/{t.max_members} + 存储: {(t.max_storage_mb / 1024).toFixed(0)}GB + API/日: {t.max_api_calls_per_day.toLocaleString()} +
+
+ ))} +
+ )} +
+ ); +} + +// ==================== Analytics Tab ==================== +function AnalyticsTab() { + const [data, setData] = useState(null); + const [days, setDays] = useState(30); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + request(`${API_BASE}/analytics/usage?days=${days}`).then(res => { + setData(res.data); + setLoading(false); + }).catch(() => setLoading(false)); + }, [days]); + + return ( +
+
+

用量分析

+ +
+ + {loading ?
加载中...
: data ? ( +
+ {/* Total Stats */} +
+
+
{data.total.calls.toLocaleString()}
+
API调用总数
+
+
+
{data.total.tokens.toLocaleString()}
+
Token消耗总数
+
+
+
{data.daily.length}
+
活跃天数
+
+
+ + {/* Daily Usage Chart */} +
+

每日用量趋势

+ {data.daily.length > 0 ? ( +
+ {data.daily.map((d, i) => { + const max = Math.max(...data.daily.map(x => x.tokens), 1); + const h = (d.tokens / max) * 100; + return ( +
+
+ {data.daily.length <= 14 &&
{d.date.slice(5)}
} +
+ ); + })} +
+ ) :
暂无数据
} +
+ + {/* Model Distribution */} +
+

模型使用分布

+ {data.model_distribution.length > 0 ? ( +
+ {data.model_distribution.map((m, i) => { + const maxTokens = Math.max(...data.model_distribution.map(x => x.tokens), 1); + const pct = (m.tokens / maxTokens) * 100; + const colors = ['#58a6ff', '#3fb950', '#bc8cff', '#f0883e', '#d29922']; + return ( +
+
{m.model}
+
+
+
+
{m.tokens.toLocaleString()} tokens
+
+ ); + })} +
+ ) :
暂无数据
} +
+
+ ) :
加载失败
} +
+ ); +} diff --git a/frontend/src/pages/admin/Achievement.tsx b/frontend/src/pages/admin/Achievement.tsx new file mode 100644 index 0000000..4d8751a --- /dev/null +++ b/frontend/src/pages/admin/Achievement.tsx @@ -0,0 +1,1288 @@ +// @ts-nocheck + +/** + + * Phase 4 剩余 - 成就系统管理页面 + + * 成就管理、用户进度、排行榜、奖励领取 + + */ + +import React, { useState, useEffect, useCallback } from 'react'; + +import { adminAPI } from '@/api'; + + + +// ==================== 类型定义 ==================== + +interface Achievement { + + id: string; + + name: string; + + description: string; + + icon: string; + + category: string; + + criteria_type: string; + + criteria_key: string | null; + + criteria_value: number; + + reward_type: string; + + reward_value: number; + + badge_icon: string | null; + + is_active: boolean; + + display_order: number; + + user_progress?: number; + + is_completed?: boolean; + + reward_claimed?: boolean; + + completed_at?: string | null; + +} + + + +interface AchievementStats { + + total_achievements: number; + + active_achievements: number; + + completed_achievements: number; + + rewards_claimed: number; + + total_points: number; + + by_category: Record; + +} + + + +const CATEGORY_LABELS: Record = { + + usage: '使用量', + + social: '社交', + + content: '内容创作', + + payment: '付费', + + special: '特殊', + +}; + + + +const CATEGORY_COLORS: Record = { + + usage: '#6C63FF', + + social: '#00D4AA', + + content: '#FFB800', + + payment: '#FF6B6B', + + special: '#C084FC', + +}; + + + +const REWARD_LABELS: Record = { + + points: '积分', + + badge: '徽章', + + discount: '折扣', + + feature: '功能解锁', + + credits: '额度', + +}; + + + +const CRITERIA_LABELS: Record = { + + message_count: '消息数量', + + conversation_count: '对话数量', + + token_usage: 'Token用量', + + referral_count: '推荐人数', + + kb_doc_count: '知识库文档数', + + agent_count: 'Agent数量', + + skill_count: '技能数量', + + payment_amount: '付费金额', + + days_active: '活跃天数', + + workflow_count: '工作流数量', + +}; + + + +// ==================== 主组件 ==================== + +const Achievement: React.FC = () => { + + const [activeTab, setActiveTab] = useState('overview'); + + const [loading, setLoading] = useState(false); + + const [error, setError] = useState(''); + + + + const [stats, setStats] = useState(null); + + const [achievements, setAchievements] = useState([]); + + const [userProgress, setUserProgress] = useState([]); + + const [leaderboard, setLeaderboard] = useState([]); + + const [categoryFilter, setCategoryFilter] = useState(''); + + + + // Modal states + + const [showCreateModal, setShowCreateModal] = useState(false); + + const [editingAch, setEditingAch] = useState(null); + + const [formData, setFormData] = useState({ + + name: '', + + description: '', + + category: 'usage', + + criteria_type: 'message_count', + + criteria_value: 100, + + reward_type: 'points', + + reward_value: 100, + + icon: '', + + }); + + + + const loadStats = useCallback(async () => { + + try { + + const allRes = await adminAPI.getAchievements(''); + + const items = allRes.data?.items || []; + + const active = items.filter((a: Achievement) => a.is_active); + + const completed = items.filter((a: Achievement) => a.is_completed); + + const claimed = items.filter((a: Achievement) => a.reward_claimed); + + const totalPoints = items + + .filter((a: Achievement) => a.is_completed) + + .reduce((sum: number, a: Achievement) => sum + (a.reward_value || 0), 0); + + const byCategory: Record = {}; + + items.forEach((a: Achievement) => { + + byCategory[a.category] = (byCategory[a.category] || 0) + 1; + + }); + + setStats({ + + total_achievements: items.length, + + active_achievements: active.length, + + completed_achievements: completed.length, + + rewards_claimed: claimed.length, + + total_points: totalPoints, + + by_category: byCategory, + + }); + + } catch (e: any) { + + console.error('Failed to load stats:', e); + + } + + }, []); + + + + const loadAchievements = useCallback(async () => { + + setLoading(true); + + setError(''); + + try { + + const params = categoryFilter ? `?category=${categoryFilter}` : ''; + + const res = await adminAPI.getAchievements(params); + + setAchievements(res.data?.items || []); + + } catch (e: any) { + + setError(e.message || '加载失败'); + + } finally { + + setLoading(false); + + } + + }, [categoryFilter]); + + + + const loadUserProgress = useCallback(async () => { + + setLoading(true); + + try { + + const res = await adminAPI.getUserAchievements(); + + setUserProgress(res.data?.items || []); + + } catch (e: any) { + + setError(e.message || '加载失败'); + + } finally { + + setLoading(false); + + } + + }, []); + + + + const loadLeaderboard = useCallback(async () => { + + setLoading(true); + + try { + + const res = await adminAPI.getAchievementLeaderboard('?period=all'); + + setLeaderboard(res.data?.items || []); + + } catch (e: any) { + + setError(e.message || '加载失败'); + + } finally { + + setLoading(false); + + } + + }, []); + + + + useEffect(() => { + + loadStats(); + + }, [loadStats]); + + + + useEffect(() => { + + if (activeTab === 'list') loadAchievements(); + + else if (activeTab === 'progress') loadUserProgress(); + + else if (activeTab === 'leaderboard') loadLeaderboard(); + + }, [activeTab, loadAchievements, loadUserProgress, loadLeaderboard]); + + + + const handleSave = async () => { + + if (!formData.name.trim()) { + + setError('成就名称不能为空'); + + return; + + } + + setLoading(true); + + try { + + const params = `?name=${encodeURIComponent(formData.name)}&category=${formData.category}&criteria_type=${formData.criteria_type}&criteria_value=${formData.criteria_value}&description=${encodeURIComponent(formData.description)}&reward_type=${formData.reward_type}&reward_value=${formData.reward_value}&icon=${encodeURIComponent(formData.icon)}`; + + if (editingAch) { + + const updateParams = `?name=${encodeURIComponent(formData.name)}&description=${encodeURIComponent(formData.description)}&reward_value=${formData.reward_value}`; + + await adminAPI.updateAchievement(editingAch.id, updateParams); + + } else { + + await adminAPI.createAchievement(params); + + } + + setShowCreateModal(false); + + setEditingAch(null); + + setFormData({ + + name: '', description: '', category: 'usage', criteria_type: 'message_count', + + criteria_value: 100, reward_type: 'points', reward_value: 100, icon: '', + + }); + + loadAchievements(); + + loadStats(); + + } catch (e: any) { + + setError(e.message || '保存失败'); + + } finally { + + setLoading(false); + + } + + }; + + + + const handleDelete = async (id: string) => { + + if (!confirm('确定要删除这个成就吗?')) return; + + try { + + await adminAPI.deleteAchievement(id); + + loadAchievements(); + + loadStats(); + + } catch (e: any) { + + setError(e.message || '删除失败'); + + } + + }; + + + + const handleToggle = async (ach: Achievement) => { + + try { + + const params = `?is_active=${!ach.is_active}`; + + await adminAPI.updateAchievement(ach.id, params); + + loadAchievements(); + + loadStats(); + + } catch (e: any) { + + setError(e.message || '操作失败'); + + } + + }; + + + + const handleClaim = async (id: string) => { + + try { + + await adminAPI.claimAchievementReward(id); + + loadUserProgress(); + + loadStats(); + + } catch (e: any) { + + setError(e.message || '领取失败'); + + } + + }; + + + + const handleCheckProgress = async () => { + + try { + + await adminAPI.checkAchievementProgress(); + + loadUserProgress(); + + loadStats(); + + } catch (e: any) { + + setError(e.message || '检查失败'); + + } + + }; + + + + const openEdit = (ach: Achievement) => { + + setEditingAch(ach); + + setFormData({ + + name: ach.name, + + description: ach.description || '', + + category: ach.category, + + criteria_type: ach.criteria_type, + + criteria_value: ach.criteria_value, + + reward_type: ach.reward_type, + + reward_value: ach.reward_value, + + icon: ach.icon || '', + + }); + + setShowCreateModal(true); + + }; + + + + const getProgressPercent = (current: number, target: number) => { + + if (target <= 0) return 0; + + return Math.min(100, Math.round((current / target) * 100)); + + }; + + + + // ==================== 渲染 ==================== + + return ( + +
+ + + + + +
+ +

成就系统

+ +
+ + {activeTab === 'list' && ( + + + + )} + + {activeTab === 'progress' && ( + + + + )} + +
+ +
+ + + + {error &&
{error}
} + + + + {/* Tabs */} + +
+ + + + + + + + + +
+ + + + {/* Overview Tab */} + + {activeTab === 'overview' && stats && ( + +
+ +
+ +
+ +
总成就数
+ +
{stats.total_achievements}
+ +
+ +
+ +
已激活
+ +
{stats.active_achievements}
+ +
+ +
+ +
已完成
+ +
{stats.completed_achievements}
+ +
+ +
+ +
奖励已领取
+ +
{stats.rewards_claimed}
+ +
+ +
+ +
累计积分
+ +
{stats.total_points}
+ +
+ +
+ + + +
+ +

分类分布

+ + {Object.entries(stats.by_category).map(([cat, count]) => { + + const pct = stats.total_achievements > 0 ? (count / stats.total_achievements) * 100 : 0; + + return ( + +
+ + {CATEGORY_LABELS[cat] || cat} + +
+ +
+ +
+ + {count} + +
+ + ); + + })} + + {Object.keys(stats.by_category).length === 0 && ( + +
暂无数据
+ + )} + +
+ +
+ + )} + + + + {/* List Tab */} + + {activeTab === 'list' && ( + +
+ +
+ + + +
+ + + + {loading ? ( + +
加载中...
+ + ) : achievements.length === 0 ? ( + +
暂无成就数据
+ + ) : ( + +
+ + {achievements.map((ach) => { + + const progressPct = getProgressPercent(ach.user_progress || 0, ach.criteria_value); + + const color = CATEGORY_COLORS[ach.category] || '#6C63FF'; + + return ( + +
+ +
+ + {ach.icon || CATEGORY_LABELS[ach.category]?.[0] || '★'} + +
+ +
{ach.name}
+ +
{ach.description || '暂无描述'}
+ +
+ + {CATEGORY_LABELS[ach.category] || ach.category} + + + + {CRITERIA_LABELS[ach.criteria_type] || ach.criteria_type}: {ach.criteria_value} + + + + + + {REWARD_LABELS[ach.reward_type] || ach.reward_type}: {ach.reward_value} + + + + {!ach.is_active && ( + + 已停用 + + )} + +
+ + {ach.user_progress !== undefined && ( + + <> + +
+ +
+ +
+ +
+ + {ach.user_progress || 0} / {ach.criteria_value} + + {progressPct}% + +
+ + + + )} + +
+ + + + + + + +
+ +
+ + ); + + })} + +
+ + )} + +
+ + )} + + + + {/* Progress Tab */} + + {activeTab === 'progress' && ( + +
+ + {loading ? ( + +
加载中...
+ + ) : userProgress.length === 0 ? ( + +
暂无进度数据
+ + ) : ( + +
+ + {userProgress.map((ach) => { + + const progressPct = getProgressPercent(ach.progress || 0, ach.criteria_value); + + const color = CATEGORY_COLORS[ach.category] || '#6C63FF'; + + return ( + +
+ +
+ + {ach.icon || CATEGORY_LABELS[ach.category]?.[0] || '★'} + +
+ +
{ach.name}
+ +
{ach.description || '暂无描述'}
+ +
+ + {CATEGORY_LABELS[ach.category] || ach.category} + + {ach.is_completed && ( + + 已完成 + + )} + + {ach.reward_claimed && ( + + 已领取 + + )} + +
+ +
+ +
+ +
+ +
+ + {ach.progress || 0} / {ach.criteria_value} + + {progressPct}% + +
+ + {ach.completed_at && ( + +
+ + 完成时间: {new Date(ach.completed_at).toLocaleString('zh-CN')} + +
+ + )} + +
+ + {ach.is_completed && !ach.reward_claimed && ( + + + + )} + + {ach.reward_claimed && ( + + 奖励已领取 + + )} + +
+ +
+ + ); + + })} + +
+ + )} + +
+ + )} + + + + {/* Leaderboard Tab */} + + {activeTab === 'leaderboard' && ( + +
+ + {loading ? ( + +
加载中...
+ + ) : leaderboard.length === 0 ? ( + +
+ +
排行榜暂无数据
+ +
用户完成成就后将出现在排行榜中
+ +
+ + ) : ( + + + + + + + + + + + + + + + + + + + + + + + + {leaderboard.map((entry, idx) => ( + + + + + + + + + + + + + + + + ))} + + + +
排名用户完成成就累计积分最近完成
+ + + + {idx + 1} + + + + {entry.username || entry.user_id || '匿名用户'}{entry.achievement_count || 0}{entry.total_points || 0} + + {entry.last_completed ? new Date(entry.last_completed).toLocaleDateString('zh-CN') : '-'} + +
+ + )} + +
+ + )} + + + + {/* Create/Edit Modal */} + + {showCreateModal && ( + +
setShowCreateModal(false)}> + +
e.stopPropagation()}> + +

{editingAch ? '编辑成就' : '创建成就'}

+ +
+ + + + setFormData({ ...formData, name: e.target.value })} placeholder="例如:对话达人" /> + +
+ +
+ + + +