refactor: sync running-container source back to repo for reproducible docker compose build; remove dead code (orphan pipeline.py + 104 stale files from iterative dev) and drop unused app/ mirror dir

This commit is contained in:
qyyfw committed 2026-07-11 06:29:32 +08:00
1 parent 1346fbbd4b
commit 5d7f32db0a
138 files changed
+12846 -84248

No files matched your search

@@ -1,327 +0,0 @@
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": []}}
-491
View File
@@ -1,491 +0,0 @@
"""
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
@@ -1,557 +0,0 @@
"""
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
]
}
@@ -1,424 +0,0 @@
"""
数据分析管理后端 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}}
-844
View File
@@ -1,844 +0,0 @@
"""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()
-357
View File
@@ -1,357 +0,0 @@
"""
资产中心管理 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,
}
}
@@ -1,662 +0,0 @@
"""
自动化任务调度管理 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"
}
@@ -1,462 +0,0 @@
"""
计费与支付管理后端 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,
}}
@@ -1,369 +0,0 @@
"""
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"}
+6 -36
View File
@@ -10,13 +10,10 @@ from ...models import (
)
from ...auth import get_current_admin
from ...middleware.log_service import LogService
from datetime import datetime, date, timezone, timedelta
from datetime import datetime, date
router = APIRouter()
# CST timezone (UTC+8)
CST_TZ = timezone(timedelta(hours=8))
@router.get("/stats")
async def get_dashboard_stats(
@@ -24,11 +21,7 @@ async def get_dashboard_stats(
db: Session = Depends(get_db),
):
"""获取管理员仪表盘统计数据"""
# Use CST timezone for "today" since container uses UTC but users are in CST
now_cst = datetime.now(CST_TZ)
today_cst = now_cst.date()
# Convert CST today start to UTC for DB comparison
today_start_utc = datetime(now_cst.year, now_cst.month, now_cst.day, 0, 0, 0, tzinfo=CST_TZ).astimezone(timezone.utc).replace(tzinfo=None)
today = date.today()
active_users = db.query(func.count(User.id)).filter(
User.status == "active"
@@ -38,13 +31,12 @@ async def get_dashboard_stats(
AIModel.is_enabled == True
).scalar() or 0
# Use range comparison instead of func.date() for timezone-correct results
today_orders = db.query(func.count(Order.id)).filter(
Order.created_at >= today_start_utc
func.date(Order.created_at) == today
).scalar() or 0
today_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).filter(
TokenUsageLog.created_at >= today_start_utc
func.date(TokenUsageLog.created_at) == today
).scalar() or 0
total_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).scalar() or 0
@@ -55,7 +47,6 @@ async def get_dashboard_stats(
"today_orders": today_orders,
"today_tokens": int(today_tokens),
"total_tokens": int(total_tokens),
"server_time": now_cst.isoformat(),
}
@@ -78,22 +69,12 @@ async def get_recent_logs(
"target_id": log.target_id,
"detail": log.detail,
"ip": log.ip,
"created_at": _format_cst(log.created_at),
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
]
def _format_cst(dt_value):
"""Format datetime to CST ISO string.
DB values are written by cst_now() as CST time (stored naive by MySQL).
No UTC conversion needed - treat naive values as CST directly.
"""
if dt_value is None:
return None
# DB naive values are already CST (written by cst_now()), just attach tz
return dt_value.replace(tzinfo=CST_TZ).isoformat()
@router.get("/recent-audit")
async def get_recent_audit(
admin_user=Depends(get_current_admin),
@@ -112,18 +93,7 @@ async def get_recent_audit(
"target_type": log.target_type,
"target_id": log.target_id,
"detail": log.detail,
"created_at": _format_cst(log.created_at),
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
]
def _format_cst(dt_value):
"""Format datetime to CST ISO string.
DB values are written by cst_now() as CST time (stored naive by MySQL).
No UTC conversion needed - treat naive values as CST directly.
"""
if dt_value is None:
return None
# DB naive values are already CST (written by cst_now()), just attach tz
return dt_value.replace(tzinfo=CST_TZ).isoformat()
@@ -1,658 +0,0 @@
"""
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
],
}
}
@@ -1,899 +0,0 @@
"""
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,
}
}
@@ -1,752 +0,0 @@
"""
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
-449
View File
@@ -1,449 +0,0 @@
"""
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
+39 -572
View File
@@ -1,55 +1,18 @@
"""
会员订阅体系管理 API(管理员)— 增强版:权益细化与权限挂钩
会员订阅体系管理 API(管理员)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from ...database import get_db, SessionLocal
from ...models import MembershipPlan, MembershipSubscription, User, RiskRule
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
import json
from datetime import datetime, date
from datetime import datetime
router = APIRouter()
def _format_plan(p):
"""格式化会员计划数据"""
return {
"id": p.id,
"name": p.name,
"level": p.level,
"duration_type": p.duration_type,
"price": float(p.price) if p.price else 0,
"daily_free_tokens": p.daily_free_tokens or 0,
"features": p.features or [],
"free_app_ids": p.free_app_ids or [],
"free_model_ids": p.free_model_ids or [],
"is_active": p.is_active,
# 配额字段
"max_agents": getattr(p, 'max_agents', 5) or 5,
"max_workflows": getattr(p, 'max_workflows', 3) or 3,
"max_knowledge_bases": getattr(p, 'max_knowledge_bases', 2) or 2,
"max_storage_mb": getattr(p, 'max_storage_mb', 1024) or 1024,
"api_calls_per_day": getattr(p, 'api_calls_per_day', 100) or 100,
"api_calls_per_min": getattr(p, 'api_calls_per_min', 10) or 10,
# 权限字段
"can_publish_apps": bool(getattr(p, 'can_publish_apps', 0)),
"can_create_api_keys": bool(getattr(p, 'can_create_api_keys', 0)),
"can_use_workflow": bool(getattr(p, 'can_use_workflow', 0)),
"can_use_mcp": bool(getattr(p, 'can_use_mcp', 0)),
"can_use_code_exec": bool(getattr(p, 'can_use_code_exec', 0)),
"can_use_privacy_mode": bool(getattr(p, 'can_use_privacy_mode', 0)),
"can_use_dag": bool(getattr(p, 'can_use_dag', 0)),
"allowed_model_tiers": getattr(p, 'allowed_model_tiers', None) or ["free"],
"permissions": getattr(p, 'permissions', None) or {},
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
# ============================================================
# 会员计划 CRUD
# ============================================================
@@ -59,9 +22,25 @@ async def get_plans(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取会员计划列表(含权益配置)"""
"""获取会员计划列表"""
plans = db.query(MembershipPlan).order_by(MembershipPlan.level).all()
return [_format_plan(p) for p in plans]
return [
{
"id": p.id,
"name": p.name,
"level": p.level,
"duration_type": p.duration_type,
"price": float(p.price) if p.price else 0,
"daily_free_tokens": p.daily_free_tokens,
"features": p.features,
"free_app_ids": p.free_app_ids,
"free_model_ids": p.free_model_ids,
"is_active": p.is_active,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
for p in plans
]
@router.post("/plans")
@@ -74,28 +53,11 @@ async def create_plan(
features: list = None,
free_app_ids: list = None,
free_model_ids: list = None,
# 配额参数
max_agents: int = 5,
max_workflows: int = 3,
max_knowledge_bases: int = 2,
max_storage_mb: int = 1024,
api_calls_per_day: int = 100,
api_calls_per_min: int = 10,
# 权限参数
can_publish_apps: bool = False,
can_create_api_keys: bool = False,
can_use_workflow: bool = False,
can_use_mcp: bool = False,
can_use_code_exec: bool = False,
can_use_privacy_mode: bool = False,
can_use_dag: bool = False,
allowed_model_tiers: list = None,
permissions: dict = None,
is_active: bool = True,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建会员计划(含权益配置)"""
"""创建会员计划"""
plan = MembershipPlan(
id=str(uuid.uuid4()),
name=name,
@@ -108,24 +70,6 @@ async def create_plan(
free_model_ids=free_model_ids,
is_active=is_active,
)
# 设置配额字段
plan.max_agents = max_agents
plan.max_workflows = max_workflows
plan.max_knowledge_bases = max_knowledge_bases
plan.max_storage_mb = max_storage_mb
plan.api_calls_per_day = api_calls_per_day
plan.api_calls_per_min = api_calls_per_min
# 设置权限字段
plan.can_publish_apps = can_publish_apps
plan.can_create_api_keys = can_create_api_keys
plan.can_use_workflow = can_use_workflow
plan.can_use_mcp = can_use_mcp
plan.can_use_code_exec = can_use_code_exec
plan.can_use_privacy_mode = can_use_privacy_mode
plan.can_use_dag = can_use_dag
plan.allowed_model_tiers = allowed_model_tiers or ["free"]
plan.permissions = permissions or {}
db.add(plan)
db.commit()
@@ -135,7 +79,7 @@ async def create_plan(
action="create_membership_plan",
target_type="membership_plan",
target_id=plan.id,
detail=f"创建会员计划 {name} (level={level})",
detail=f"创建会员计划 {name}",
)
return {"message": "会员计划已创建", "id": plan.id}
@@ -152,33 +96,15 @@ async def update_plan(
features: list = None,
free_app_ids: list = None,
free_model_ids: list = None,
# 配额参数
max_agents: int = None,
max_workflows: int = None,
max_knowledge_bases: int = None,
max_storage_mb: int = None,
api_calls_per_day: int = None,
api_calls_per_min: int = None,
# 权限参数
can_publish_apps: bool = None,
can_create_api_keys: bool = None,
can_use_workflow: bool = None,
can_use_mcp: bool = None,
can_use_code_exec: bool = None,
can_use_privacy_mode: bool = None,
can_use_dag: bool = None,
allowed_model_tiers: list = None,
permissions: dict = None,
is_active: bool = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新会员计划(含权益配置)"""
"""更新会员计划"""
plan = db.query(MembershipPlan).filter(MembershipPlan.id == id).first()
if not plan:
raise HTTPException(status_code=404, detail="会员计划不存在")
# 基础字段
if name is not None:
plan.name = name
if level is not None:
@@ -197,41 +123,6 @@ async def update_plan(
plan.free_model_ids = free_model_ids
if is_active is not None:
plan.is_active = is_active
# 配额字段
if max_agents is not None:
plan.max_agents = max_agents
if max_workflows is not None:
plan.max_workflows = max_workflows
if max_knowledge_bases is not None:
plan.max_knowledge_bases = max_knowledge_bases
if max_storage_mb is not None:
plan.max_storage_mb = max_storage_mb
if api_calls_per_day is not None:
plan.api_calls_per_day = api_calls_per_day
if api_calls_per_min is not None:
plan.api_calls_per_min = api_calls_per_min
# 权限字段
if can_publish_apps is not None:
plan.can_publish_apps = can_publish_apps
if can_create_api_keys is not None:
plan.can_create_api_keys = can_create_api_keys
if can_use_workflow is not None:
plan.can_use_workflow = can_use_workflow
if can_use_mcp is not None:
plan.can_use_mcp = can_use_mcp
if can_use_code_exec is not None:
plan.can_use_code_exec = can_use_code_exec
if can_use_privacy_mode is not None:
plan.can_use_privacy_mode = can_use_privacy_mode
if can_use_dag is not None:
plan.can_use_dag = can_use_dag
if allowed_model_tiers is not None:
plan.allowed_model_tiers = allowed_model_tiers
if permissions is not None:
plan.permissions = permissions
plan.updated_at = datetime.now()
db.commit()
@@ -241,7 +132,7 @@ async def update_plan(
action="update_membership_plan",
target_type="membership_plan",
target_id=id,
detail=f"更新会员计划 {plan.name} (含权益配置)",
detail=f"更新会员计划 {plan.name}",
)
return {"message": "会员计划已更新"}
@@ -258,11 +149,6 @@ async def delete_plan(
if not plan:
raise HTTPException(status_code=404, detail="会员计划不存在")
# 检查是否有用户正在使用此计划
user_count = db.query(User).filter(User.membership_plan_id == id).count()
if user_count > 0:
raise HTTPException(status_code=400, detail=f"有 {user_count} 个用户正在使用此计划,无法删除")
db.delete(plan)
db.commit()
@@ -278,352 +164,6 @@ async def delete_plan(
return {"message": "会员计划已删除"}
# ============================================================
# 会员权益对比(前端对比表用)
# ============================================================
@router.get("/benefits-comparison")
async def get_benefits_comparison(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取会员权益对比表数据"""
plans = db.query(MembershipPlan).filter(
MembershipPlan.is_active == True
).order_by(MembershipPlan.level).all()
# 定义对比维度
dimensions = [
{"key": "daily_free_tokens", "label": "每日免费Token", "type": "number"},
{"key": "max_agents", "label": "最大Agent数量", "type": "number"},
{"key": "max_workflows", "label": "最大工作流数量", "type": "number"},
{"key": "max_knowledge_bases", "label": "最大知识库数量", "type": "number"},
{"key": "max_storage_mb", "label": "存储配额(MB)", "type": "number"},
{"key": "api_calls_per_day", "label": "每日API调用限额", "type": "number"},
{"key": "api_calls_per_min", "label": "每分钟API调用限额", "type": "number"},
{"key": "can_publish_apps", "label": "发布应用到市场", "type": "boolean"},
{"key": "can_create_api_keys", "label": "创建开发者API密钥", "type": "boolean"},
{"key": "can_use_workflow", "label": "使用工作流编排", "type": "boolean"},
{"key": "can_use_mcp", "label": "使用MCP工具", "type": "boolean"},
{"key": "can_use_code_exec", "label": "使用代码执行", "type": "boolean"},
{"key": "can_use_privacy_mode", "label": "使用隐私模式", "type": "boolean"},
{"key": "can_use_dag", "label": "使用DAG多Agent", "type": "boolean"},
{"key": "allowed_model_tiers", "label": "可用模型层级", "type": "list"},
]
return {
"dimensions": dimensions,
"plans": [_format_plan(p) for p in plans],
}
# ============================================================
# 全局会员配置管理
# ============================================================
@router.get("/global-configs")
async def get_global_configs(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取全局会员配置"""
result = db.execute(text("SELECT id, config_key, config_value, config_type, description FROM membership_global_configs ORDER BY config_key"))
configs = []
for row in result:
val = row[2]
if row[3] == 'boolean':
val = val == 'true'
elif row[3] == 'number':
try:
val = int(val)
except:
val = 0
elif row[3] == 'json':
try:
val = json.loads(val)
except:
pass
configs.append({
"id": row[0],
"key": row[1],
"value": val,
"type": row[3],
"description": row[4] or "",
})
return configs
@router.put("/global-configs/{key}")
async def update_global_config(
key: str,
value: str = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新全局会员配置"""
result = db.execute(text("SELECT id, config_type FROM membership_global_configs WHERE config_key = :key"), {"key": key})
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="配置项不存在")
config_id, config_type = row[0], row[1]
store_value = value
if config_type == 'boolean':
store_value = 'true' if value in ('true', '1', 'True') else 'false'
db.execute(text("UPDATE membership_global_configs SET config_value = :val WHERE id = :id"), {"val": store_value, "id": config_id})
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_membership_config",
target_type="membership_config",
target_id=config_id,
detail=f"更新会员配置 {key}={store_value}",
)
return {"message": "配置已更新"}
# ============================================================
# 用户会员管理
# ============================================================
@router.get("/user-memberships")
async def get_user_memberships(
user_id: str = Query(None),
plan_id: str = Query(None),
status: str = Query(None, description="会员状态: active/expired"),
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),
):
"""获取用户会员信息列表"""
query = db.query(User).filter(User.role == 'user')
if user_id:
query = query.filter(User.id == user_id)
users_q = query
if plan_id:
users_q = users_q.filter(User.membership_plan_id == plan_id)
total = users_q.count()
users = users_q.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
items = []
for u in users:
plan = db.query(MembershipPlan).filter(MembershipPlan.id == u.membership_plan_id).first() if u.membership_plan_id else None
# 判断状态
now = datetime.now()
if u.membership_expire_at and u.membership_expire_at < now:
member_status = "expired"
elif u.membership_level > 0:
member_status = "active"
else:
member_status = "free"
if status and member_status != status:
continue
items.append({
"user_id": u.id,
"username": u.username,
"email": u.email,
"membership_plan_id": u.membership_plan_id,
"membership_plan_name": plan.name if plan else "免费版",
"membership_level": u.membership_level or 0,
"membership_expire_at": u.membership_expire_at.isoformat() if u.membership_expire_at else None,
"auto_renew": bool(u.auto_renew),
"member_status": member_status,
"created_at": u.created_at.isoformat() if u.created_at else None,
})
return {"total": total, "page": page, "page_size": page_size, "items": items}
@router.put("/users/{user_id}/assign-plan")
async def assign_plan_to_user(
user_id: str,
plan_id: str,
duration_days: int = Query(30, description="订阅时长(天)"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""为用户分配会员计划(管理员手动分配)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
plan = db.query(MembershipPlan).filter(MembershipPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="会员计划不存在")
from datetime import timedelta
now = datetime.now()
# 如果用户当前会员未到期,从到期时间续期
start_at = now
if user.membership_expire_at and user.membership_expire_at > now:
start_at = user.membership_expire_at
end_at = start_at + timedelta(days=duration_days)
user.membership_plan_id = plan_id
user.membership_level = plan.level
user.membership_expire_at = end_at
# 创建订阅记录
sub = MembershipSubscription(
id=str(uuid.uuid4()),
user_id=user_id,
plan_id=plan_id,
start_at=start_at,
end_at=end_at,
status="active",
)
db.add(sub)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="assign_membership_plan",
target_type="user",
target_id=user_id,
detail=f"为用户 {user.username} 分配计划 {plan.name} ({duration_days}天)",
)
return {"message": f"已为用户 {user.username} 分配 {plan.name} 计划", "expire_at": end_at.isoformat()}
@router.put("/users/{user_id}/revoke")
async def revoke_membership(
user_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""撤销用户会员(降级为免费版)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
old_plan = user.membership_plan_id
user.membership_plan_id = None
user.membership_level = 0
user.membership_expire_at = None
user.auto_renew = False
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="revoke_membership",
target_type="user",
target_id=user_id,
detail=f"撤销用户 {user.username} 的会员 (原计划ID: {old_plan})",
)
return {"message": f"已撤销用户 {user.username} 的会员"}
# ============================================================
# 用户配额使用情况
# ============================================================
@router.get("/users/{user_id}/usage")
async def get_user_usage(
user_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取用户资源使用情况"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 获取用户计划
plan = None
if user.membership_plan_id:
plan = db.query(MembershipPlan).filter(MembershipPlan.id == user.membership_plan_id).first()
# 统计用户实际资源使用
from ...models import Agent, KnowledgeBase
agents_count = db.query(Agent).filter(Agent.owner_id == user_id).count() if hasattr(Agent, 'owner_id') else 0
# 使用原生SQL统计
try:
kb_result = db.execute(text("SELECT COUNT(*) FROM knowledge_bases WHERE owner_id = :uid"), {"uid": user_id})
kb_count = kb_result.fetchone()[0]
except:
kb_count = 0
try:
agent_result = db.execute(text("SELECT COUNT(*) FROM agents WHERE owner_id = :uid"), {"uid": user_id})
agent_count = agent_result.fetchone()[0]
except:
agent_count = 0
try:
wf_result = db.execute(text("SELECT COUNT(*) FROM workflows WHERE owner_id = :uid"), {"uid": user_id})
wf_count = wf_result.fetchone()[0]
except:
wf_count = 0
# 获取今日API使用量
today = date.today()
try:
usage_result = db.execute(text("SELECT api_calls_total, tokens_used FROM user_api_usage WHERE user_id = :uid AND usage_date = :d"), {"uid": user_id, "d": today})
usage_row = usage_result.fetchone()
api_calls_today = usage_row[0] if usage_row else 0
tokens_today = usage_row[1] if usage_row else 0
except:
api_calls_today = 0
tokens_today = 0
# 获取存储使用量
try:
storage_result = db.execute(text("SELECT storage_used_mb FROM user_resource_usage WHERE user_id = :uid"), {"uid": user_id})
storage_row = storage_result.fetchone()
storage_used = float(storage_row[0]) if storage_row else 0.0
except:
storage_used = 0.0
plan_limits = {}
if plan:
plan_limits = {
"max_agents": getattr(plan, 'max_agents', 5) or 5,
"max_workflows": getattr(plan, 'max_workflows', 3) or 3,
"max_knowledge_bases": getattr(plan, 'max_knowledge_bases', 2) or 2,
"max_storage_mb": getattr(plan, 'max_storage_mb', 1024) or 1024,
"api_calls_per_day": getattr(plan, 'api_calls_per_day', 100) or 100,
"daily_free_tokens": plan.daily_free_tokens or 0,
}
return {
"user_id": user_id,
"username": user.username,
"plan_name": plan.name if plan else "免费版",
"plan_level": user.membership_level or 0,
"usage": {
"agents_count": agent_count,
"workflows_count": wf_count,
"knowledge_bases_count": kb_count,
"storage_used_mb": storage_used,
"api_calls_today": api_calls_today,
"tokens_today": tokens_today,
},
"limits": plan_limits,
"over_limit": {
"agents": agent_count > plan_limits.get("max_agents", 999) if plan_limits else False,
"workflows": wf_count > plan_limits.get("max_workflows", 999) if plan_limits else False,
"knowledge_bases": kb_count > plan_limits.get("max_knowledge_bases", 999) if plan_limits else False,
"storage": storage_used > plan_limits.get("max_storage_mb", 999999) if plan_limits else False,
"api_calls": api_calls_today > plan_limits.get("api_calls_per_day", 999999) if plan_limits else False,
},
}
# ============================================================
# 用户订阅管理
# ============================================================
@@ -650,98 +190,24 @@ async def get_subscriptions(
MembershipSubscription.created_at.desc()
).offset((page - 1) * page_size).limit(page_size).all()
# 关联用户名和计划名
items = []
for s in subscriptions:
u = db.query(User).filter(User.id == s.user_id).first()
p = db.query(MembershipPlan).filter(MembershipPlan.id == s.plan_id).first()
items.append({
"id": s.id,
"user_id": s.user_id,
"username": u.username if u else "",
"plan_id": s.plan_id,
"plan_name": p.name if p else "",
"order_id": s.order_id,
"start_at": s.start_at.isoformat() if s.start_at else None,
"end_at": s.end_at.isoformat() if s.end_at else None,
"auto_renew": s.auto_renew,
"status": s.status,
"created_at": s.created_at.isoformat() if s.created_at else None,
})
return {
"total": total,
"page": page,
"page_size": page_size,
"items": items,
}
# ============================================================
# 会员统计概览
# ============================================================
@router.get("/stats")
async def get_membership_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取会员统计概览"""
total_users = db.query(User).filter(User.role == 'user').count()
# 各等级用户数
level_stats = {}
for level in range(4):
count = db.query(User).filter(User.membership_level == level).count()
level_stats[f"level_{level}"] = count
# 活跃会员(未到期)
now = datetime.now()
active_members = db.query(User).filter(
User.membership_level > 0,
User.membership_expire_at > now
).count()
# 已过期会员
expired_members = db.query(User).filter(
User.membership_level > 0,
User.membership_expire_at <= now
).count()
# 自动续费用户数
auto_renew_count = db.query(User).filter(
User.auto_renew == True,
User.membership_level > 0
).count()
# 各计划订阅数
plan_stats = []
plans = db.query(MembershipPlan).order_by(MembershipPlan.level).all()
for plan in plans:
count = db.query(User).filter(User.membership_plan_id == plan.id).count()
plan_stats.append({
"plan_id": plan.id,
"plan_name": plan.name,
"level": plan.level,
"user_count": count,
"is_active": plan.is_active,
})
# 今日新增订阅
today_start = datetime(now.year, now.month, now.day)
today_new_subs = db.query(MembershipSubscription).filter(
MembershipSubscription.created_at >= today_start
).count()
return {
"total_users": total_users,
"active_members": active_members,
"expired_members": expired_members,
"free_users": level_stats.get("level_0", 0),
"auto_renew_count": auto_renew_count,
"level_stats": level_stats,
"plan_stats": plan_stats,
"today_new_subs": today_new_subs,
"items": [
{
"id": s.id,
"user_id": s.user_id,
"plan_id": s.plan_id,
"order_id": s.order_id,
"start_at": s.start_at.isoformat() if s.start_at else None,
"end_at": s.end_at.isoformat() if s.end_at else None,
"auto_renew": s.auto_renew,
"status": s.status,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
for s in subscriptions
],
}
@@ -758,6 +224,7 @@ async def get_automation_rules(
rules = db.query(RiskRule).order_by(RiskRule.created_at.desc()).all()
if not rules:
# 返回默认规则
return {
"rules": [],
"defaults": {
File diff suppressed because it is too large. Load diff
@@ -1,799 +0,0 @@
"""
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 {}
}}
@@ -1,288 +0,0 @@
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": []}}
File diff suppressed because it is too large. Load diff
@@ -1,773 +0,0 @@
"""
技能管理系统 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,
}
}
@@ -1,904 +0,0 @@
"""
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": "连接测试完成"
}
@@ -1,426 +0,0 @@
"""
多租户管理后端 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
+7 -12
View File
@@ -226,14 +226,13 @@ PLATFORM_SLACK = {
PLATFORM_CONFIGS = {
"feishu": PLATFORM_FEISHU,
"wechat": PLATFORM_WECHAT,
"personal_wechat": PLATFORM_WECHAT,
"dingtalk": PLATFORM_DINGTALK,
"whatsapp": PLATFORM_WHATSAPP,
"telegram": PLATFORM_TELEGRAM,
"slack": PLATFORM_SLACK,
}
VALID_PLATFORMS = ("feishu", "wechat", "personal_wechat", "wecom", "dingtalk", "whatsapp", "telegram", "slack")
VALID_PLATFORMS = ("feishu", "wechat", "wecom", "dingtalk", "whatsapp", "telegram", "slack")
SENSITIVE_FIELDS = {"app_secret", "secret", "access_token", "bot_token", "signing_secret", "token",
"api_key", "password", "encrypt_key", "verification_token", "app_token",
@@ -424,21 +423,19 @@ def get_platform_configs():
@router.post("/authorize/scan")
def scan_authorize(
platform: str = Query(None),
platform: str = Query(...),
body: dict = Body({}),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""扫码授权流程 - 返回授权二维码(微信平台统一调用ClawBot API)"""
if not platform:
platform = body.get("platform", "")
if platform not in VALID_PLATFORMS:
raise HTTPException(status_code=400, detail="不支持的平台类型")
platform_name = PLATFORM_CONFIGS.get(platform, {}).get("name", platform)
# 微信平台:调用微信ClawBot API获取授权二维码
if platform in ("wechat", "personal_wechat"):
if platform == "wechat":
# 获取AppID和Secret(优先从请求体 > 已保存频道配置 > 环境变量)
appid = ""
secret = ""
@@ -450,7 +447,7 @@ def scan_authorize(
# 查找已保存的微信频道
existing_channel = db.query(Channel).filter(
and_(Channel.platform.in_(["wechat", "personal_wechat"]), Channel.owner_id == current_user.id)
and_(Channel.platform == "wechat", Channel.owner_id == current_user.id)
).first()
# 2. 从已保存的频道配置获取
@@ -618,15 +615,13 @@ def scan_authorize_status(
current_user: User = Depends(get_current_user),
):
"""查询扫码授权状态(微信平台调用ClawBot API轮询)"""
if not platform:
platform = body.get("platform", "")
if platform not in VALID_PLATFORMS:
raise HTTPException(status_code=400, detail="不支持的平台类型")
# 微信平台:调用ClawBot API轮询扫码状态
if platform in ("wechat", "personal_wechat"):
if platform == "wechat":
channel = db.query(Channel).filter(
and_(Channel.platform.in_(["wechat", "personal_wechat"]), Channel.owner_id == current_user.id)
and_(Channel.platform == "wechat", Channel.owner_id == current_user.id)
).first()
qrcode_token = channel.oauth_state if channel else None
@@ -1349,7 +1344,7 @@ def channel_reset(
raise HTTPException(status_code=404, detail="频道不存在")
# 微信平台:调用ClawBot API重置通道
if channel.platform in ("wechat", "personal_wechat"):
if channel.platform == "wechat":
# 获取AppID和Secret用于签名
appid = os.environ.get("WECHAT_APPID", "")
secret = os.environ.get("WECHAT_SECRET", "")
File diff suppressed because it is too large. Load diff
-601
View File
@@ -1,601 +0,0 @@
"""
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()
File diff suppressed because it is too large. Load diff
@@ -1,494 +0,0 @@
#!/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]
}
+8 -23
View File
@@ -4,7 +4,7 @@ from sqlalchemy import and_, desc, asc, func, extract
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, date, timedelta, timezone
from datetime import datetime, date, timedelta
from ..database import get_db
from ..models import (
@@ -16,9 +16,6 @@ from ..middleware.log_service import LogService
router = APIRouter()
# CST timezone (UTC+8)
CST_TZ = timezone(timedelta(hours=8))
class RechargeRequest(BaseModel):
amount: int = Field(ge=1)
@@ -35,15 +32,12 @@ def get_overview(
TokenUsageLog.user_id == current_user.id
).scalar()
# 今日使用中应用数 - 使用CST时区的"今天"
now_cst = datetime.now(CST_TZ)
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
# Convert to UTC for DB comparison since container uses UTC timestamps
today_start_utc = today_start.astimezone(timezone.utc).replace(tzinfo=None)
# 今日使用中应用数
today_start = datetime.combine(date.today(), datetime.min.time())
today_apps = db.query(func.count(func.distinct(TokenUsageLog.model_id))).filter(
and_(
TokenUsageLog.user_id == current_user.id,
TokenUsageLog.created_at >= today_start_utc,
TokenUsageLog.created_at >= today_start,
)
).scalar() or 0
@@ -52,7 +46,9 @@ def get_overview(
TokenBalance.user_id == current_user.id
).first()
server_time = datetime.now(CST_TZ).isoformat()
from datetime import timezone, timedelta
shanghai_tz = timezone(timedelta(hours=8))
server_time = datetime.now(shanghai_tz).isoformat()
return {
"code": 0,
@@ -173,7 +169,7 @@ def get_token_logs(
"completion_tokens": log_item.completion_tokens or 0,
"total_tokens": log_item.total_tokens or 0,
"conversation_id": log_item.conversation_id,
"created_at": _format_cst_time(log_item.created_at),
"created_at": log_item.created_at.isoformat() if log_item.created_at else None,
})
return {
@@ -249,14 +245,3 @@ def recharge(
},
"message": "充值成功",
}
def _format_cst_time(dt_value):
"""Format datetime to CST ISO string.
DB values are written by cst_now() as CST time (stored naive by MySQL).
No UTC conversion needed - treat naive values as CST directly.
"""
if dt_value is None:
return None
# DB naive values are already CST (written by cst_now()), just attach tz
return dt_value.replace(tzinfo=CST_TZ).isoformat()
-97
View File
@@ -1,97 +0,0 @@
"""
能力(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
-214
View File
@@ -1,214 +0,0 @@
"""
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)
-267
View File
@@ -1,267 +0,0 @@
"""
知识库路由层
============
将检索请求智能路由到本地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]
-164
View File
@@ -65,9 +65,6 @@ class ApiKey(Base):
last_used_at = Column(DateTime, default=None)
created_at = Column(DateTime, default=datetime.now)
expired_at = Column(DateTime, default=None)
description = Column(String(500), default=None, comment="密钥描述")
rate_limit = Column(Integer, default=60, comment="速率限制(请求/分钟)")
total_calls = Column(BigInteger, default=0, comment="总调用次数")
# ============================================================
@@ -84,8 +81,6 @@ class Conversation(Base):
status = Column(String(10), nullable=False, default="active") # active / archived
pinned = Column(Boolean, default=False)
knowledge_graph_count = Column(Integer, default=0)
knowledge_graph_base_id = Column(String(64), default=None, comment="已生成的知识图谱知识库ID")
source = Column(String(32), default=None, comment="对话来源,如 wechat_bot")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -170,12 +165,6 @@ class KnowledgeBase(Base):
knowledge_graph_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
is_official = Column(Boolean, default=False)
status = Column(String(20), default="active")
external_service_id = Column(String(36), default=None)
external_id = Column(String(100), default=None)
external_sync_status = Column(String(20), default="none")
external_config = Column(JSON, default=None)
class KnowledgeDirectory(Base):
@@ -207,28 +196,6 @@ class KnowledgeDocument(Base):
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class KnowledgeDocumentChunk(Base):
"""知识库文档分段表 — Stage C.1"""
__tablename__ = "knowledge_document_chunks"
id = Column(String(36), primary_key=True, default=gen_uuid)
document_id = Column(String(36), ForeignKey("knowledge_documents.id"), nullable=False)
knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False)
chunk_index = Column(Integer, nullable=False, default=0, comment="分段序号")
content = Column(Text, nullable=False, comment="分段文本内容")
start_char = Column(Integer, default=0, comment="起始字符位置")
end_char = Column(Integer, default=0, comment="结束字符位置")
chunk_metadata = Column(JSON, default=None, comment="分段元数据(标题、关键词等)")
token_count = Column(Integer, default=0, comment="分段token数估算")
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (
Index("idx_kdc_doc_id", "document_id"),
Index("idx_kdc_kb_id", "knowledge_base_id"),
)
class KnowledgeGraphNode(Base):
__tablename__ = "knowledge_graph_nodes"
@@ -349,23 +316,6 @@ class MembershipPlan(Base):
features = Column(JSON, default=None)
free_app_ids = Column(JSON, default=None)
free_model_ids = Column(JSON, default=None)
# 配额字段
max_agents = Column(Integer, default=5)
max_workflows = Column(Integer, default=3)
max_knowledge_bases = Column(Integer, default=2)
max_storage_mb = Column(Integer, default=1024)
api_calls_per_day = Column(Integer, default=100)
api_calls_per_min = Column(Integer, default=10)
# 权限字段
can_publish_apps = Column(Boolean, default=False)
can_create_api_keys = Column(Boolean, default=False)
can_use_workflow = Column(Boolean, default=False)
can_use_mcp = Column(Boolean, default=False)
can_use_code_exec = Column(Boolean, default=False)
can_use_privacy_mode = Column(Boolean, default=False)
can_use_dag = Column(Boolean, default=False)
allowed_model_tiers = Column(JSON, default=None)
permissions = Column(JSON, default=None)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -505,7 +455,6 @@ class AIModel(Base):
id = Column(String(36), primary_key=True, default=gen_uuid)
provider_id = Column(String(36), ForeignKey("model_providers.id"), nullable=False)
name = Column(String(100), nullable=False)
display_name = Column(String(200), default=None, comment="display name")
api_key = Column(String(255), default=None)
endpoint = Column(String(500), default=None)
capabilities = Column(JSON, default=None)
@@ -628,7 +577,6 @@ class EmailConfig(Base):
password = Column(String(200), nullable=False)
from_address = Column(String(200), nullable=False)
use_tls = Column(Boolean, default=True)
use_ssl = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -794,8 +742,6 @@ class OpenApiClient(Base):
rate_limit = Column(Integer, default=60)
status = Column(String(10), nullable=False, default="active")
created_at = Column(DateTime, default=datetime.now)
total_calls = Column(BigInteger, default=0, comment="总调用次数")
last_used_at = Column(DateTime, default=None, comment="最后使用时间")
# ============================================================
@@ -989,113 +935,3 @@ class TeamSharedSkill(Base):
__table_args__ = (
UniqueConstraint("team_id", "skill_id", name="uk_tss_team_skill"),
)
class ExternalKBService(Base):
"""外部知识库服务配置"""
__tablename__ = "external_kb_services"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(200), nullable=False)
service_type = Column(String(20), nullable=False)
api_url = Column(String(500), nullable=False)
api_key = Column(String(255), default="")
config = Column(JSON, default=None)
is_enabled = Column(Boolean, default=True)
status = Column(String(20), default="disconnected")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class OSSStorageConfig(Base):
__tablename__ = "oss_storage_configs"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(200), nullable=False)
provider = Column(String(20), nullable=False)
endpoint = Column(String(500), nullable=False)
access_key = Column(String(255), nullable=False)
secret_key = Column(String(255), nullable=False)
bucket = Column(String(200), nullable=False)
region = Column(String(100), default="")
base_path = Column(String(300), default="")
is_default = Column(Boolean, default=False)
is_enabled = Column(Boolean, default=True)
status = Column(String(20), default="disconnected")
config = Column(JSON, default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class UserStorageQuota(Base):
__tablename__ = "user_storage_quotas"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), nullable=False, unique=True)
quota_bytes = Column(BigInteger, nullable=False, default=1073741824)
used_bytes = Column(BigInteger, nullable=False, default=0)
quota_type = Column(String(20), nullable=False, default="membership")
membership_level = Column(Integer, default=0)
plan_id = Column(String(36), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class AutomatedTask(Base):
__tablename__ = "automated_tasks"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(200), nullable=False)
description = Column(Text, default="")
trigger_type = Column(String(20), nullable=False)
cron_expression = Column(String(100), default=None)
event_condition = Column(JSON, default=None)
action_type = Column(String(30), nullable=False)
action_config = Column(JSON, default=None)
is_enabled = Column(Boolean, default=True)
status = Column(String(20), default="idle")
max_retries = Column(Integer, default=3)
timeout_seconds = Column(Integer, default=300)
last_run_at = Column(DateTime, default=None)
next_run_at = Column(DateTime, default=None)
total_runs = Column(Integer, default=0)
success_runs = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class TaskExecutionLog(Base):
__tablename__ = "task_execution_logs"
id = Column(String(36), primary_key=True, default=gen_uuid)
task_id = Column(String(36), nullable=False)
task_name = Column(String(200), default="")
status = Column(String(20), default="running")
trigger_type = Column(String(20), default="manual")
started_at = Column(DateTime, default=None)
finished_at = Column(DateTime, default=None)
duration_ms = Column(Integer, default=0)
result_summary = Column(Text, default="")
error_message = Column(Text, default="")
created_at = Column(DateTime, default=datetime.now)
class MCPServer(Base):
__tablename__ = "mcp_servers"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(100), nullable=False)
description = Column(Text, default="")
transport_type = Column(String(20), default="stdio")
command = Column(String(500), default="")
args = Column(Text, default="")
env_vars = Column(Text, default="")
url = Column(String(500), default="")
headers = Column(Text, default="")
is_enabled = Column(Boolean, default=True)
status = Column(String(20), default="disconnected")
last_connected_at = Column(DateTime, default=None)
tools_count = Column(Integer, default=0)
resources_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
-360
View File
@@ -1,360 +0,0 @@
"""
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")
-347
View File
@@ -1,347 +0,0 @@
"""
Pipeline 消息处理管道 - 参照AstrBot Stage链式设计,同步版兼容FastAPI
消息处理阶段可插拔,链式调用
"""
from abc import ABC, abstractmethod
from typing import Any
import logging
import time
logger = logging.getLogger(__name__)
class StageResult:
"""阶段处理结果"""
def __init__(self, success: bool = True, data: dict = None, error: str = None, stop: bool = False):
self.success = success
self.data = data or {}
self.error = error
self.stop = stop # 是否停止后续阶段
class PipelineStage(ABC):
"""管道阶段基类"""
def __init__(self, name: str):
self.name = name
@abstractmethod
def process(self, context: dict) -> StageResult:
"""处理消息上下文,返回StageResult"""
...
# === 具体阶段实现 ===
class WakingCheckStage(PipelineStage):
"""唤醒检查:检查是否需要AI响应"""
def __init__(self):
super().__init__("waking_check")
def process(self, context: dict) -> StageResult:
message = context.get("message", "")
platform = context.get("platform", "")
config = context.get("channel_config", {})
bot_prefix = config.get("bot_prefix", "@bot")
is_private = context.get("is_private", True)
is_at_bot = bot_prefix.lower() in message.lower() if bot_prefix else False
if is_private or is_at_bot or message.strip().startswith("/"):
return StageResult(success=True, data={"should_respond": True})
# 群聊需要@或唤醒词
if not is_private and not is_at_bot:
return StageResult(success=True, data={"should_respond": False}, stop=True)
return StageResult(success=True, data={"should_respond": True})
class RateLimitCheckStage(PipelineStage):
"""速率限制检查"""
def __init__(self, max_per_minute: int = 30):
super().__init__("rate_limit_check")
self.max_per_minute = max_per_minute
self._counter = {}
def process(self, context: dict) -> StageResult:
user_id = context.get("sender_id", "unknown")
now = time.time()
key = f"{user_id}:{int(now // 60)}"
count = self._counter.get(key, 0)
if count >= self.max_per_minute:
return StageResult(success=False, error="请求过于频繁,请稍后再试", stop=True)
self._counter[key] = count + 1
# 清理旧计数器
keys_to_del = [k for k in self._counter if int(k.split(":")[-1]) < int(now // 60) - 1]
for k in keys_to_del:
del self._counter[k]
return StageResult(success=True)
class ContentSafetyCheckStage(PipelineStage):
"""内容安全检查"""
def __init__(self):
super().__init__("content_safety_check")
self._blocked_words = [] # 可从配置加载
def process(self, context: dict) -> StageResult:
message = context.get("message", "")
for word in self._blocked_words:
if word in message:
return StageResult(success=False, error="消息包含违规内容", stop=True)
return StageResult(success=True)
class UnsupportedStage(PipelineStage):
"""业务逻辑处理阶段基类 - 子类实现具体处理"""
def __init__(self):
super().__init__("process")
def process(self, context: dict) -> StageResult:
# 此阶段由channels.py中的实际AI处理逻辑填充
return StageResult(success=True, data={"processed": True})
class ResponseBuildStage(PipelineStage):
"""响应构建阶段"""
def __init__(self):
super().__init__("respond")
def process(self, context: dict) -> StageResult:
ai_reply = context.get("ai_reply", "")
if not ai_reply:
return StageResult(success=True, data={"reply": ""})
return StageResult(success=True, data={"reply": ai_reply})
# === Pipeline 管道调度器 ===
class PipelineScheduler:
"""管道调度器:按顺序执行各个Stage"""
def __init__(self, stages: list[PipelineStage] = None):
self.stages = stages if stages is not None else self._default_stages()
@staticmethod
def _default_stages() -> list[PipelineStage]:
return [
WakingCheckStage(),
RateLimitCheckStage(max_per_minute=30),
ContentSafetyCheckStage(),
UnsupportedStage(),
ResponseBuildStage(),
]
def add_stage(self, stage: PipelineStage, position: int = None):
"""插入新阶段"""
if position is not None:
self.stages.insert(position, stage)
else:
self.stages.append(stage)
def remove_stage(self, stage_name: str):
"""移除阶段"""
self.stages = [s for s in self.stages if s.name != stage_name]
def execute(self, context: dict) -> dict:
"""执行完整的管道处理"""
context["_pipeline_log"] = []
for stage in self.stages:
try:
result = stage.process(context)
context["_pipeline_log"].append({
"stage": stage.name,
"success": result.success,
"error": result.error,
"stop": result.stop,
})
if not result.success and result.stop:
return {
"success": False,
"error": result.error,
"stage": stage.name,
"pipeline_log": context["_pipeline_log"],
}
if result.stop and result.success:
# 正常停止(如waking_check判断不需要响应)
return {
"success": True,
"data": result.data,
"stage": stage.name,
"pipeline_log": context["_pipeline_log"],
}
context.update(result.data)
except Exception as e:
logger.error(f"[Pipeline] Stage {stage.name} 异常: {e}")
return {
"success": False,
"error": f"管道处理异常({stage.name}): {str(e)}",
"pipeline_log": context["_pipeline_log"],
}
return {
"success": True,
"data": {"reply": context.get("reply", context.get("ai_reply", ""))},
"pipeline_log": context["_pipeline_log"],
}
# ============================================================
# 单元测试
# ============================================================
if __name__ == "__main__":
print("=" * 60)
print("Pipeline 管道模块 单元测试")
print("=" * 60)
# 测试1: Pipeline 创建和执行
print("\n[测试1] Pipeline 创建和执行")
pipeline = PipelineScheduler()
context = {
"message": "你好,帮我查询天气",
"platform": "feishu",
"sender_id": "user_001",
"is_private": True,
"channel_config": {"bot_prefix": "@bot"},
"ai_reply": "你好!今天天气晴好,气温22-30℃。",
}
result = pipeline.execute(context)
assert result["success"] is True, f"Pipeline执行应该成功: {result}"
assert "pipeline_log" in result, "应该有pipeline_log"
# 验证所有5个阶段都执行了
stage_names = [log["stage"] for log in result["pipeline_log"]]
assert len(stage_names) == 5, f"应该执行5个阶段,实际执行了{len(stage_names)}个"
assert stage_names == ["waking_check", "rate_limit_check", "content_safety_check", "process", "respond"], \
f"阶段顺序不对: {stage_names}"
assert result["data"]["reply"] == "你好!今天天气晴好,气温22-30℃。"
print(f" PASS: 所有5个阶段按顺序执行,回复={result['data']['reply'][:30]}...")
# 测试2: 唤醒检查阶段 - 私聊
print("\n[测试2] 唤醒检查阶段 - 私聊消息")
waking = WakingCheckStage()
ctx = {
"message": "你好",
"platform": "telegram",
"is_private": True,
"channel_config": {"bot_prefix": "@bot"},
}
result = waking.process(ctx)
assert result.success is True, "私聊应该唤醒"
assert result.data["should_respond"] is True, "私聊应该响应"
print(f" PASS: 私聊消息正确唤醒, should_respond={result.data['should_respond']}")
# 测试3: 唤醒检查阶段 - 群聊 @bot
print("\n[测试3] 唤醒检查阶段 - 群聊@bot")
ctx = {
"message": "@bot 帮我查一下数据",
"platform": "wecom",
"is_private": False,
"channel_config": {"bot_prefix": "@bot"},
}
result = waking.process(ctx)
assert result.success is True, "群聊@bot应该唤醒"
assert result.data["should_respond"] is True, "群聊@bot应该响应"
print(f" PASS: 群聊@bot正确唤醒, should_respond={result.data['should_respond']}")
# 测试4: 唤醒检查阶段 - 群聊未@bot (应停止)
print("\n[测试4] 唤醒检查阶段 - 群聊未@bot(应停止管道)")
ctx = {
"message": "大家今天天气不错",
"platform": "wecom",
"is_private": False,
"channel_config": {"bot_prefix": "@bot"},
}
result = waking.process(ctx)
assert result.success is True, "处理应该成功"
assert result.data["should_respond"] is False, "群聊未@bot不应该响应"
assert result.stop is True, "应该停止管道"
print(f" PASS: 群聊未@bot正确停止管道, should_respond={result.data['should_respond']}, stop={result.stop}")
# 测试5: 速率限制
print("\n[测试5] 速率限制检查")
rate_limiter = RateLimitCheckStage(max_per_minute=3)
# 前3次应该成功
for i in range(3):
ctx = {"sender_id": "user_test"}
result = rate_limiter.process(ctx)
assert result.success is True, f"第{i+1}次请求应该通过速率限制"
print(" PASS: 前3次请求通过速率限制")
# 第4次应该失败
ctx = {"sender_id": "user_test"}
result = rate_limiter.process(ctx)
assert result.success is False, "第4次请求应该被限流"
assert "频繁" in result.error, f"错误信息应包含'频繁': {result.error}"
assert result.stop is True, "限流应该停止管道"
print(f" PASS: 第4次请求被限流, error={result.error}")
# 测试6: 完整管道链 - 群聊未唤醒场景
print("\n[测试6] 完整管道链 - 群聊未唤醒(应被waking_check停止)")
pipeline = PipelineScheduler()
context = {
"message": "今天天气真好啊",
"platform": "wecom",
"sender_id": "user_002",
"is_private": False,
"channel_config": {"bot_prefix": "@bot"},
}
result = pipeline.execute(context)
assert result["success"] is True, "管道应该正常完成"
assert result["stage"] == "waking_check", f"应在waking_check阶段停止,实际: {result['stage']}"
assert len(result["pipeline_log"]) == 1, f"应该只执行了1个阶段,实际: {len(result['pipeline_log'])}"
print(f" PASS: 群聊未唤醒场景正确在waking_check阶段停止")
# 测试7: 内容安全检查 - 无违规词
print("\n[测试7] 内容安全检查 - 无违规词")
safety = ContentSafetyCheckStage()
ctx = {"message": "正常的聊天内容"}
result = safety.process(ctx)
assert result.success is True, "正常内容应该通过检查"
print(f" PASS: 正常内容通过安全检查")
# 测试8: 异常处理 - Stage抛出异常
print("\n[测试8] Pipeline异常处理")
class BrokenStage(PipelineStage):
def __init__(self):
super().__init__("broken")
def process(self, context):
raise RuntimeError("模拟阶段异常")
pipeline2 = PipelineScheduler(stages=[BrokenStage()])
result = pipeline2.execute({"message": "test"})
assert result["success"] is False, "异常应该导致管道失败"
assert "broken" in result["error"], f"错误信息应包含阶段名: {result['error']}"
print(f" PASS: 阶段异常被正确捕获, error={result['error']}")
# 测试9: Pipeline 添加和移除阶段
print("\n[测试9] Pipeline 阶段增删")
pipeline3 = PipelineScheduler(stages=[])
assert len(pipeline3.stages) == 0, "初始应无阶段"
test_stage = WakingCheckStage()
pipeline3.add_stage(test_stage)
assert len(pipeline3.stages) == 1, "添加后应有1个阶段"
pipeline3.add_stage(RateLimitCheckStage(), position=0)
assert pipeline3.stages[0].name == "rate_limit_check", "插入到位置0应为rate_limit_check"
pipeline3.remove_stage("waking_check")
assert len(pipeline3.stages) == 1, "移除后应有1个阶段"
assert pipeline3.stages[0].name == "rate_limit_check", "剩余应为rate_limit_check"
print(" PASS: 阶段增删操作正确")
print("\n" + "=" * 60)
print("所有测试通过!")
print("=" * 60)
File diff suppressed because it is too large. Load diff
+2 -274
View File
@@ -1,486 +1,214 @@
"""
Qihu-AIFlow FastAPI 主应用
完整后端API,监听18080端口,所有接口以 /app 为前缀
"""
import sys
import os
import time
import uuid
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.casdoor_auth import router as casdoor_router
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin , tenant_admin, billing_admin, analytics_admin
from app.api.im_bot_user import router as im_bot_user_router
from app.api.portal import user_portal
from app.api.admin.developer_tools_admin import router as developer_tools_router
from app.api.admin.mcp_market_admin import router as mcp_market_router
from app.api.admin import asset_admin, achievement_admin
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.database import SessionLocal
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册
# 初始化文件日志(按天轮转)
access_logger = setup_file_logging()
logger = logging.getLogger("aiflow")
class APITrackingMiddleware(BaseHTTPMiddleware):
"""API调用统计中间件 - 记录请求耗时,标记慢请求"""
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
start_time = time.time()
path = request.url.path
method = request.method
# 跳过健康检查和静态资源
skip_paths = ["/app/api/health", "/favicon.ico"]
should_skip = any(path.startswith(p) for p in skip_paths)
try:
response = await call_next(request)
elapsed = time.time() - start_time
status_code = response.status_code
if not should_skip:
access_logger.info(
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
)
# 异步记录API调用统计
try:
db = SessionLocal()
try:
_record_api_call(db, method, path, status_code, elapsed)
finally:
db.close()
except Exception as e:
logger.warning(f"API统计记录失败: {e}")
return response
except Exception as e:
elapsed = time.time() - start_time
if not should_skip:
access_logger.info(
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
)
raise
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
"""记录API调用到数据库统计表"""
# 简化路径以减少基数(去掉动态ID)
parts = path.split("/")
simplified_parts = []
for part in parts:
# 将UUID样式的路径段替换为{id}
if len(part) == 36 and part.count("-") == 4:
simplified_parts.append("{id}")
# 将纯数字路径段替换为{id}
elif part.isdigit():
simplified_parts.append("{id}")
else:
simplified_parts.append(part)
simplified_path = "/".join(simplified_parts)
# 统计记录通过数据库实现,简化版记录到日志
is_error = status_code >= 400
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
if is_slow:
logger.warning(
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Qihu-AIFlow v2.0.0 启动中...")
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
try:
from app.services.evolution_service import EvolutionWatcherManager
from app.models import UserEvolutionConfig
db = SessionLocal()
try:
configs = db.query(UserEvolutionConfig).filter(
UserEvolutionConfig.watcher_status == "running"
).all()
if configs:
watcher = EvolutionWatcherManager()
for cfg in configs:
try:
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
except Exception as e:
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
else:
logger.info("[Evolution] 无待恢复的进化配置")
finally:
db.close()
except Exception as e:
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
# 启动Redis事件总线
try:
from app.event_bus import get_event_bus
event_bus = get_event_bus()
event_bus.start(num_consumers=2)
logger.info("[EventBus] Redis事件总线已启动")
except Exception as e:
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
# 恢复用户个人微信(iLink)接收线程
try:
from app.api.im_bot_user import start_all_wechat_receivers
start_all_wechat_receivers()
logger.info("[IM个人微信] 用户微信接收线程已恢复")
except Exception as e:
logger.warning(f"[IM个人微信] 启动恢复异常(不影响服务): {e}")
logger.info("Qihu-AIFlow v2.0.0 启动完成")
logger.info("Qihu-AIFlow v2.0.0 启动完成")
yield
# 停止Redis事件总线
try:
from app.event_bus import get_event_bus
get_event_bus().stop()
logger.info("[EventBus] Redis事件总线已停止")
except Exception:
pass
# 优雅关闭:停止所有 Evolution Pipeline
try:
from app.services.evolution_service import EvolutionWatcherManager
EvolutionWatcherManager().shutdown_all()
logger.info("[Evolution] 所有Pipeline已停止")
except Exception:
pass
logger.info("Qihu-AIFlow 正在关闭...")
app = FastAPI(
title="Qihu-AIFlow - 智能自动化PaaS平台",
version="1.0.0",
lifespan=lifespan,
)
# API调用追踪中间件
app.add_middleware(APITrackingMiddleware)
# CORS配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 前台 API
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"])
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
# 后台 API
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])
app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])
app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
@app.get("/metrics")
async def prometheus_metrics():
"""Prometheus metrics endpoint"""
import psutil
metrics = []
try:
metrics.append(f"aiflow_cpu_percent {psutil.cpu_percent()}")
mem = psutil.virtual_memory()
metrics.append(f"aiflow_memory_used_bytes {mem.used}")
metrics.append(f"aiflow_memory_total_bytes {mem.total}")
metrics.append(f"aiflow_memory_percent {mem.percent}")
disk = psutil.disk_usage('/')
metrics.append(f"aiflow_disk_used_bytes {disk.used}")
metrics.append(f"aiflow_disk_total_bytes {disk.total}")
metrics.append(f"aiflow_process_count {len(psutil.pids())}")
except Exception:
pass
return PlainTextResponse("\n".join(metrics), media_type="text/plain")
@app.get("/app/api/health")
async def health_check():
return {"status": "ok", "service": "Qihu-AIFlow"}
@@ -1,24 +0,0 @@
"""
Platform Bridge - Cloud-to-Local Agent Bridge
==============================================
A Python package providing MCP server and sync client for bridging
Goose (local agent) with the Qihu-AIFlow cloud platform.
Modules:
- mcp_server: MCP-compatible server for Goose (stdio transport)
- sync_client: Periodic sync manager for KB, license, usage
- __init__: Package interface
"""
from .mcp_server import PlatformBridgeClient, PlatformBridgeMCPServer, main
from .sync_client import SyncManager, create_sync_manager
__version__ = "1.0.0"
__all__ = [
"PlatformBridgeClient",
"PlatformBridgeMCPServer",
"SyncManager",
"create_sync_manager",
"main",
]
@@ -1,308 +0,0 @@
"""
Platform Bridge MCP Server
===========================
Goose MCP extension that bridges local Goose with the cloud platform.
Provides tools:
- fetch_agent_config: Get agent configuration from platform
- sync_knowledge_base: Sync knowledge base to local
- report_usage: Report anonymized usage statistics
- validate_license: Validate offline license
- get_platform_status: Check platform health
"""
__version__ = "1.0.0"
__name__ = "platform_bridge"
import os
import sys
import json
import time
import hashlib
import hmac
import urllib.request
import urllib.error
from typing import Any, Dict, List, Optional
class PlatformBridgeClient:
"""Client for the cloud platform API"""
def __init__(self, api_url: str = None, user_id: str = None, api_key: str = None):
self.api_url = api_url or os.environ.get(
"PLATFORM_API_URL", "http://210.16.161.218:18080"
)
self.user_id = user_id or os.environ.get("PLATFORM_USER_ID", "local-agent")
self.api_key = api_key or os.environ.get("PLATFORM_API_KEY", "")
self.base_path = "/app/api/admin/privacy"
def _request(self, method: str, path: str, params: dict = None) -> dict:
"""Make an HTTP request to the platform API"""
url = f"{self.api_url}{path}"
if params:
query_parts = []
for k, v in params.items():
if v is not None:
query_parts.append(f"{k}={urllib.request.quote(str(v))}")
if query_parts:
url += "?" + "&".join(query_parts)
req = urllib.request.Request(url, method=method)
if self.api_key:
req.add_header("Authorization", f"Bearer {self.api_key}")
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
return data
except urllib.error.HTTPError as e:
return {"code": 1, "error": f"HTTP {e.code}: {e.reason}"}
except Exception as e:
return {"code": 1, "error": str(e)}
def fetch_agent_config(self, agent_id: str = None) -> dict:
"""Fetch agent configuration from platform"""
params = {"user_id": self.user_id}
if agent_id:
params["agent_id"] = agent_id
return self._request("GET", f"{self.base_path}/local-agents", params)
def sync_knowledge_base(self, kb_id: int, direction: str = "pull") -> dict:
"""Sync knowledge base between platform and local"""
return self._request("POST", f"{self.base_path}/kb-sync/trigger", {
"knowledge_base_id": kb_id,
"sync_direction": direction,
"user_id": self.user_id,
})
def report_usage(self, metrics: dict, report_type: str = "daily") -> dict:
"""Submit anonymized usage report"""
return self._request("POST", f"{self.base_path}/usage/report", {
"metrics": json.dumps(metrics),
"report_type": report_type,
"user_id": self.user_id,
})
def validate_license(self, license_key: str, device_fingerprint: str = None) -> dict:
"""Validate offline license"""
params = {"license_key": license_key, "user_id": self.user_id}
if device_fingerprint:
params["device_fingerprint"] = device_fingerprint
return self._request("POST", f"{self.base_path}/licenses/validate", params)
def get_privacy_config(self) -> dict:
"""Get current privacy configuration"""
return self._request("GET", f"{self.base_path}/config", {"user_id": self.user_id})
def get_bridge_config(self) -> dict:
"""Get Platform Bridge configuration for export"""
return self._request("GET", f"{self.base_path}/bridge-config", {"user_id": self.user_id})
def refresh_license(self, license_id: int, extend_days: int = 7) -> dict:
"""Refresh an existing license"""
return self._request("POST", f"{self.base_path}/licenses/{license_id}/refresh", {
"extend_days": extend_days,
})
def sync_all_knowledge_bases(self) -> List[dict]:
"""Sync all configured knowledge bases"""
config = self.get_privacy_config()
if config.get("code") != 0:
return [{"error": "Failed to get config"}]
data = config.get("data", {})
kb_list = data.get("sync_knowledge_bases", [])
if isinstance(kb_list, str):
try:
kb_list = json.loads(kb_list)
except json.JSONDecodeError:
kb_list = []
results = []
for kb_id in (kb_list or []):
result = self.sync_knowledge_base(kb_id)
results.append({"kb_id": kb_id, "result": result})
return results
def check_health(self) -> dict:
"""Check platform health and connectivity"""
start = time.time()
try:
result = self._request("GET", f"{self.base_path}/stats")
elapsed = time.time() - start
return {
"connected": True,
"latency_ms": round(elapsed * 1000),
"platform_version": "qihuaiflow-g5",
"stats": result.get("data", {}),
}
except Exception as e:
return {
"connected": False,
"error": str(e),
}
# ========================================================================
# MCP Server Interface (_stdio transport)
# ========================================================================
class PlatformBridgeMCPServer:
"""MCP-compatible server using stdio transport"""
def __init__(self):
self.client = PlatformBridgeClient()
self.tools = {
"fetch_agent_config": {
"name": "fetch_agent_config",
"description": "Fetch agent configuration from the cloud platform",
"parameters": {
"agent_id": {"type": "string", "description": "Optional agent ID", "required": False}
}
},
"sync_knowledge_base": {
"name": "sync_knowledge_base",
"description": "Sync knowledge base documents from platform to local",
"parameters": {
"kb_id": {"type": "integer", "description": "Knowledge base ID", "required": True},
"direction": {"type": "string", "description": "Sync direction: pull/push/bidirectional", "required": False}
}
},
"report_usage": {
"name": "report_usage",
"description": "Report anonymized usage statistics to platform",
"parameters": {
"metrics": {"type": "object", "description": "Usage metrics JSON", "required": True},
"report_type": {"type": "string", "description": "Report type: daily/weekly/monthly/realtime", "required": False}
}
},
"validate_license": {
"name": "validate_license",
"description": "Validate offline license key",
"parameters": {
"license_key": {"type": "string", "description": "The license key to validate", "required": True},
"device_fingerprint": {"type": "string", "description": "Device fingerprint for binding", "required": False}
}
},
"get_platform_status": {
"name": "get_platform_status",
"description": "Check cloud platform connectivity and health",
"parameters": {}
},
"get_agent_list": {
"name": "get_agent_list",
"description": "List available agents from the platform",
"parameters": {}
},
"sync_all_kb": {
"name": "sync_all_kb",
"description": "Sync all configured knowledge bases",
"parameters": {}
},
"get_privacy_config": {
"name": "get_privacy_config",
"description": "Get current privacy mode configuration",
"parameters": {}
}
}
def _handle_request(self, request: dict) -> dict:
"""Process an MCP tool request"""
tool_name = request.get("name", "")
args = request.get("arguments", {}) or {}
handler_map = {
"fetch_agent_config": lambda: self.client.fetch_agent_config(args.get("agent_id")),
"sync_knowledge_base": lambda: self.client.sync_knowledge_base(
args["kb_id"], args.get("direction", "pull")
),
"report_usage": lambda: self.client.report_usage(
args["metrics"], args.get("report_type", "daily")
),
"validate_license": lambda: self.client.validate_license(
args["license_key"], args.get("device_fingerprint")
),
"get_platform_status": lambda: self.client.check_health(),
"get_agent_list": lambda: self.client.fetch_agent_config(),
"sync_all_kb": lambda: self.client.sync_all_knowledge_bases(),
"get_privacy_config": lambda: self.client.get_privacy_config(),
}
handler = handler_map.get(tool_name)
if not handler:
return {"error": f"Unknown tool: {tool_name}"}
try:
result = handler()
return result if isinstance(result, dict) else {"result": result}
except Exception as e:
return {"error": str(e)}
def handle_tools_list(self) -> dict:
"""Return list of available tools (MCP tools/list)"""
return {"tools": list(self.tools.values())}
def handle_tools_call(self, request: dict) -> dict:
"""Handle a tool call (MCP tools/call)"""
tool_name = request.get("name", "")
args = request.get("arguments", {}) or {}
result = self._handle_request({"name": tool_name, "arguments": args})
return {
"content": [
{
"type": "text",
"text": json.dumps(result, ensure_ascii=False, indent=2)
}
]
}
def run(self):
"""Run MCP server in stdio mode"""
print("Platform Bridge MCP Server v1.0", file=sys.stderr)
print(f"API URL: {self.client.api_url}", file=sys.stderr)
print(f"User ID: {self.client.user_id}", file=sys.stderr)
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
method = request.get("method", "")
if method == "tools/list":
response = self.handle_tools_list()
elif method == "tools/call":
response = self.handle_tools_call(request.get("params", {}))
elif method == "initialize":
response = {
"protocolVersion": "2024-11-05",
"serverInfo": {"name": "platform-bridge", "version": "1.0.0"},
"capabilities": {"tools": {}}
}
else:
response = {"error": f"Unknown method: {method}"}
if "id" in request:
response["id"] = request["id"]
response["jsonrpc"] = "2.0"
print(json.dumps(response), flush=True)
except json.JSONDecodeError as e:
err_resp = {"jsonrpc": "2.0", "error": {"code": -32700, "message": f"Parse error: {e}"}}
print(json.dumps(err_resp), flush=True)
except Exception as e:
err_resp = {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}}
print(json.dumps(err_resp), flush=True)
def main():
"""Entry point for python -m platform_bridge.mcp_server"""
server = PlatformBridgeMCPServer()
server.run()
if __name__ == "__main__":
main()
@@ -1,133 +0,0 @@
"""
Platform Bridge - Cloud-to-Local Agent Bridge
==============================================
WorkBuddy-compatible sync client for local Goose agent.
Provides:
- Periodic knowledge base sync
- Usage metrics collection and reporting
- License validation and refresh
- Platform health monitoring
"""
import os
import json
import time
import hashlib
from datetime import datetime, timedelta
# Import the MCP server client
from .mcp_server import PlatformBridgeClient
class SyncManager:
"""Manages periodic synchronization between local agent and cloud platform"""
def __init__(self, client: PlatformBridgeClient = None):
self.client = client or PlatformBridgeClient()
self.sync_interval = 3600 # Default: 1 hour
self.last_sync = {}
self.last_license_check = None
self.last_usage_report = None
self.usage_buffer = []
def configure(self, config: dict):
"""Configure sync parameters from privacy config"""
frequency_map = {
"realtime": 60,
"hourly": 3600,
"daily": 86400,
"manual": None, # No auto sync
}
freq = config.get("sync_frequency", "daily")
self.sync_interval = frequency_map.get(freq, 86400)
self.client.user_id = config.get("user_id", self.client.user_id)
def should_sync_kb(self, kb_id: int) -> bool:
"""Check if a KB needs syncing"""
last = self.last_sync.get(kb_id, 0)
return (time.time() - last) >= self.sync_interval
def sync_kb(self, kb_id: int) -> dict:
"""Sync a single knowledge base"""
result = self.client.sync_knowledge_base(kb_id)
self.last_sync[kb_id] = time.time()
return result
def sync_all(self, kb_ids: list) -> list:
"""Sync all configured knowledge bases"""
results = []
for kb_id in kb_ids:
if self.should_sync_kb(kb_id):
result = self.sync_kb(kb_id)
results.append({"kb_id": kb_id, "result": result})
return results
def check_license(self, license_key: str) -> dict:
"""Check if license is still valid"""
now = time.time()
if self.last_license_check and (now - self.last_license_check) < 3600:
return {"valid": True, "cached": True}
result = self.client.validate_license(license_key)
self.last_license_check = now
return result
def track_usage(self, event_type: str, metadata: dict = None):
"""Buffer a usage event for reporting"""
self.usage_buffer.append({
"type": event_type,
"timestamp": time.time(),
"metadata": metadata or {},
})
def report_usage(self) -> dict:
"""Submit buffered usage events"""
if not self.usage_buffer:
return {"reported": 0}
events = self.usage_buffer[:]
self.usage_buffer = []
metrics = {
"event_count": len(events),
"event_types": {},
"time_range": {
"start": min(e["timestamp"] for e in events),
"end": max(e["timestamp"] for e in events),
}
}
for e in events:
t = e["type"]
metrics["event_types"][t] = metrics["event_types"].get(t, 0) + 1
result = self.client.report_usage(metrics)
self.last_usage_report = time.time()
result["reported"] = len(events)
return result
def health_check(self) -> dict:
"""Run a full health check"""
platform = self.client.check_health()
config = self.client.get_privacy_config()
return {
"platform": platform,
"privacy_mode": config.get("data", {}).get("privacy_mode", "unknown"),
"pending_events": len(self.usage_buffer),
"last_license_check": self.last_license_check,
"last_usage_report": self.last_usage_report,
}
def create_sync_manager(api_url: str = None, user_id: str = None) -> SyncManager:
"""Factory function to create a configured SyncManager"""
client = PlatformBridgeClient(api_url=api_url, user_id=user_id)
manager = SyncManager(client)
# Load config
config_result = client.get_privacy_config()
if config_result.get("code") == 0:
manager.configure(config_result["data"])
return manager
File diff suppressed because it is too large. Load diff