fix: 恢复 rsync 误删的近期开发(agents/skills/mcp/knowledge/workflow/tenant/billing/storage/privacy/promotion/achievement/automation/code-exec/developer/distribution/im-bot/portal/casdoor 等后端模块 + 对应前端页面路由, 补回 main.py 挂载与 App.tsx 路由
This commit is contained in:
1 parent
d95b847fee
commit
e8e5ffa1a9
65 files changed
+35624
-339
No files matched your search
@@ -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": []}}
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 {}
|
||||
}}
|
||||
@@ -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": []}}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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": "连接测试完成"
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user