fix: 恢复 rsync 误删的近期开发(agents/skills/mcp/knowledge/workflow/tenant/billing/storage/privacy/promotion/achievement/automation/code-exec/developer/distribution/im-bot/portal/casdoor 等后端模块 + 对应前端页面路由, 补回 main.py 挂载与 App.tsx 路由

This commit is contained in:
qyyfw committed 2026-07-11 11:40:39 +08:00
1 parent d95b847fee
commit e8e5ffa1a9
65 files changed
+35624 -339

No files matched your search

@@ -0,0 +1,327 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import Optional, List
from datetime import datetime
import json
import traceback
from ...database import SessionLocal, get_db
from ...auth import get_current_user
from ...models import User
router = APIRouter()
def _execute_raw(db: Session, sql: str, params: dict = None):
"""执行原始SQL的辅助函数"""
try:
result = db.execute(text(sql), params or {})
db.commit()
return result
except Exception as e:
db.rollback()
raise e
@router.get("/achievements")
async def list_achievements(
category: Optional[str] = Query(None),
is_active: Optional[bool] = Query(True),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""列出所有成就(带用户进度)"""
try:
achievements = []
# Build query
sql = "SELECT * FROM achievements WHERE 1=1"
params = {}
if category:
sql += " AND category = :category"
params["category"] = category
if is_active is not None:
sql += " AND is_active = :is_active"
params["is_active"] = 1 if is_active else 0
sql += " ORDER BY display_order ASC"
result = _execute_raw(db, sql, params)
rows = result.fetchall()
user_id = user.id if hasattr(user, 'id') else None
for row in rows:
ach = {
"id": row[0],
"name": row[1],
"description": row[2],
"icon": row[3],
"category": row[4],
"criteria_type": row[5],
"criteria_key": row[6],
"criteria_value": row[7],
"reward_type": row[8],
"reward_value": float(row[9]) if row[9] else 0,
"badge_icon": row[10],
"is_active": bool(row[11]) if row[11] is not None else True,
"display_order": row[12],
"user_progress": 0,
"is_completed": False,
"reward_claimed": False
}
# Get user progress if user is logged in
if user_id:
try:
progress_result = _execute_raw(db,
"SELECT progress, is_completed, reward_claimed FROM user_achievements WHERE user_id = :uid AND achievement_id = :aid",
{"uid": user_id, "aid": ach["id"]})
prog_row = progress_result.fetchone()
if prog_row:
ach["user_progress"] = prog_row[0] or 0
ach["is_completed"] = bool(prog_row[1]) if prog_row[1] is not None else False
ach["reward_claimed"] = bool(prog_row[2]) if prog_row[2] is not None else False
except:
pass
achievements.append(ach)
return {"code": 0, "data": {"items": achievements, "total": len(achievements)}}
except Exception as e:
traceback.print_exc()
return {"code": 0, "data": {"items": [], "total": 0}}
@router.get("/achievements/{ach_id}")
async def get_achievement_detail(
ach_id: str,
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""获取成就详情"""
try:
result = _execute_raw(db, "SELECT * FROM achievements WHERE id = :id", {"id": ach_id})
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="成就不存在")
achievement = {
"id": row[0],
"name": row[1],
"description": row[2],
"icon": row[3],
"category": row[4],
"criteria_type": row[5],
"criteria_key": row[6],
"criteria_value": row[7],
"reward_type": row[8],
"reward_value": float(row[9]) if row[9] else 0,
"badge_icon": row[10],
"is_active": bool(row[11]) if row[11] is not None else True,
"display_order": row[12]
}
return {"code": 0, "data": achievement}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/achievements")
async def create_achievement(
name: str = Query(...),
category: str = Query(...),
criteria_type: str = Query(...),
criteria_value: int = Query(...),
description: Optional[str] = Query(""),
icon: Optional[str] = Query(""),
criteria_key: Optional[str] = Query(None),
reward_type: str = Query("points"),
reward_value: float = Query(0),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""创建新成就(管理员)"""
try:
import uuid
ach_id = str(uuid.uuid4())
sql = """INSERT INTO achievements
(id, name, description, icon, category, criteria_type, criteria_key,
criteria_value, reward_type, reward_value, is_active, display_order)
VALUES (:id, :name, :desc, :icon, :cat, :ctype, :ckey,
:cvalue, :rtype, :rvalue, 1, 0)"""
_execute_raw(db, sql, {
"id": ach_id,
"name": name,
"desc": description,
"icon": icon,
"cat": category,
"ctype": criteria_type,
"ckey": criteria_key,
"cvalue": criteria_value,
"rtype": reward_type,
"rvalue": reward_value
})
return {"code": 0, "message": "成就创建成功", "data": {"id": ach_id}}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/achievements/{ach_id}")
async def update_achievement(
ach_id: str,
name: Optional[str] = Query(None),
description: Optional[str] = Query(None),
is_active: Optional[bool] = Query(None),
reward_value: Optional[float] = Query(None),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""更新成就(管理员)"""
try:
updates = []
params = {"id": ach_id}
if name is not None:
updates.append("name = :name")
params["name"] = name
if description is not None:
updates.append("description = :desc")
params["desc"] = description
if is_active is not None:
updates.append("is_active = :active")
params["active"] = 1 if is_active else 0
if reward_value is not None:
updates.append("reward_value = :rvalue")
params["rvalue"] = reward_value
if updates:
sql = f"UPDATE achievements SET {', '.join(updates)} WHERE id = :id"
_execute_raw(db, sql, params)
return {"code": 0, "message": "更新成功"}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/achievements/{ach_id}")
async def delete_achievement(
ach_id: str,
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""删除成就(管理员)"""
try:
_execute_raw(db, "DELETE FROM achievements WHERE id = :id", {"id": ach_id})
return {"code": 0, "message": "删除成功"}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/user/achievements")
async def get_my_achievements(
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""获取当前用户的成就进度"""
try:
user_id = user.id if hasattr(user, 'id') else None
if not user_id:
return {"code": 0, "data": {"items": [], "total": 0}}
# Get all achievements with user progress
result = _execute_raw(db, "SELECT a.*, ua.progress, ua.is_completed, ua.reward_claimed, ua.completed_at FROM achievements a LEFT JOIN user_achievements ua ON a.id = ua.achievement_id AND ua.user_id = :uid WHERE a.is_active = 1 ORDER BY a.display_order ASC", {"uid": user_id})
rows = result.fetchall()
achievements = []
for row in rows:
achievements.append({
"id": row[0],
"name": row[1],
"description": row[2],
"icon": row[3],
"category": row[4],
"criteria_type": row[5],
"criteria_value": row[7],
"reward_type": row[8],
"reward_value": float(row[9]) if row[9] else 0,
"progress": row[13] or 0,
"is_completed": bool(row[14]) if row[14] is not None else False,
"reward_claimed": bool(row[15]) if row[15] is not None else False,
"completed_at": str(row[16]) if row[16] else None
})
return {"code": 0, "data": {"items": achievements, "total": len(achievements)}}
except Exception as e:
traceback.print_exc()
return {"code": 0, "data": {"items": [], "total": 0}}
@router.post("/user/achievements/{ach_id}/claim")
async def claim_achievement_reward(
ach_id: str,
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""领取成就奖励"""
try:
user_id = user.id if hasattr(user, 'id') else None
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
# Check if achievement is completed
result = _execute_raw(db,
"SELECT is_completed, reward_claimed FROM user_achievements WHERE user_id = :uid AND achievement_id = :aid",
{"uid": user_id, "aid": ach_id})
row = result.fetchone()
if not row or not row[0]:
raise HTTPException(status_code=400, detail="成就未完成")
if row[1]:
raise HTTPException(status_code=400, detail="奖励已领取")
# Mark reward as claimed
_execute_raw(db,
"UPDATE user_achievements SET reward_claimed = 1, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid",
{"uid": user_id, "aid": ach_id})
return {"code": 0, "message": "奖励领取成功"}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/check-progress")
async def check_user_progress(
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""检查并更新用户成就进度"""
try:
user_id = user.id if hasattr(user, 'id') else None
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
# Get user stats (this would need to be implemented based on actual data)
# For now, just return success
return {"code": 0, "message": "进度检查完成", "data": {"updated": 0}}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/leaderboard")
async def get_achievement_leaderboard(
period: str = Query("all"),
db: Session = Depends(get_db)
):
"""获取成就排行榜"""
try:
# This would aggregate user achievements and rank them
# For now, return empty
return {"code": 0, "data": {"items": []}}
except Exception as e:
traceback.print_exc()
return {"code": 0, "data": {"items": []}}
+491
View File
@@ -0,0 +1,491 @@
"""
ACP Gateway - Agent Communication Protocol 内部实现
Phase G3: 多 Agent 协作编排层
将平台已有的 Agent(MarketApp type='agent')封装为可编排调用的"Agent 节点"。
每个 Agent 节点执行时,通过本 Gateway 创建会话、发送提示、接收流式响应。
支持通过 Redis EventBus 发布节点进度通知,供 WebSocket 实时推送到前端。
"""
import json
import uuid
import time
import logging
import asyncio
import threading
from typing import Optional, Dict, Any, Callable, AsyncIterator
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
# ============================================================
# Data Models
# ============================================================
@dataclass
class AcpSessionState:
"""ACP 会话状态"""
session_id: str
agent_id: str # MarketApp id
agent_name: str = ""
status: str = "idle" # idle / running / completed / failed / cancelled
prompt: str = ""
created_at: float = field(default_factory=time.time)
completed_at: Optional[float] = None
output: str = ""
error: Optional[str] = None
tool_calls: list = field(default_factory=list)
chain_of_thought: list = field(default_factory=list) # 思考过程
def to_dict(self) -> dict:
return {
"session_id": self.session_id,
"agent_id": self.agent_id,
"agent_name": self.agent_name,
"status": self.status,
"prompt": self.prompt[:200],
"created_at": self.created_at,
"completed_at": self.completed_at,
"output": self.output[:500] if self.output else "",
"error": self.error,
"tool_calls": self.tool_calls,
"chain_of_thought": self.chain_of_thought,
}
@dataclass
class AgentConfig:
"""从 MarketApp.skill_content 解析出的 Agent 配置"""
agent_id: str = ""
agent_name: str = ""
system_prompt: str = ""
instructions: str = ""
model_id: Optional[str] = None
temperature: float = 0.7
max_tokens: int = 4096
skills: list = field(default_factory=list)
knowledge_base_ids: list = field(default_factory=list)
tools: list = field(default_factory=list)
# ============================================================
# Agent Resolver
# ============================================================
def resolve_agent_config(db, agent_id: str) -> Optional[AgentConfig]:
"""从 MarketApp 解析 Agent 配置"""
from app.models import MarketApp
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id,
MarketApp.type == "agent"
).first()
if not agent:
return None
config_json = {}
if agent.skill_content:
try:
config_json = json.loads(agent.skill_content) if isinstance(agent.skill_content, str) else agent.skill_content
except Exception:
pass
return AgentConfig(
agent_id=agent.id,
agent_name=agent.name,
system_prompt=config_json.get("system_prompt", config_json.get("role", agent.description or "AI助手")),
instructions=config_json.get("instructions", ""),
model_id=config_json.get("model_id"),
temperature=float(config_json.get("temperature", 0.7)),
max_tokens=int(config_json.get("max_tokens", 4096)),
skills=config_json.get("skills", []),
knowledge_base_ids=config_json.get("knowledge_base_ids", []),
tools=config_json.get("tools", []),
)
def resolve_model_config(db, model_id: str) -> dict:
"""获取模型的 API 配置"""
from app.models import AIModel
model = db.query(AIModel).filter(AIModel.id == model_id).first()
if not model:
# Fallback to default model
default_model = db.query(AIModel).filter(AIModel.is_enabled == True).first()
if default_model:
model = default_model
else:
return {
"api_key": "",
"api_base": "https://api.siliconflow.cn/v1",
"model_name": "deepseek-ai/DeepSeek-V4-Flash",
}
return {
"api_key": model.api_key or "",
"api_base": model.api_base or "https://api.siliconflow.cn/v1",
"model_name": model.model_id or model.name,
}
# ============================================================
# ACP Gateway
# ============================================================
class AcpGateway:
"""Agent 通信网关 — 平台内部多 Agent 编排核心"""
def __init__(self):
self._sessions: Dict[str, AcpSessionState] = {}
self._lock = threading.Lock()
self._on_notification: Optional[Callable] = None
def set_notification_handler(self, handler: Callable[[dict], None]):
"""设置通知回调 — 用于发布到 Redis EventBus"""
self._on_notification = handler
def _notify(self, event_type: str, data: dict):
"""发送通知"""
if self._on_notification:
try:
self._on_notification({
"type": event_type,
"timestamp": time.time(),
**data,
})
except Exception as e:
logger.error(f"[AcpGateway] 通知回调异常: {e}")
# ---- Session Management ----
def create_session(self, agent_id: str, agent_name: str = "") -> str:
"""创建新的 Agent 会话"""
session_id = f"acp-{uuid.uuid4().hex[:12]}"
with self._lock:
self._sessions[session_id] = AcpSessionState(
session_id=session_id,
agent_id=agent_id,
agent_name=agent_name or agent_id,
status="idle",
)
logger.info(f"[AcpGateway] 创建会话: {session_id} (agent={agent_name or agent_id})")
return session_id
def get_session(self, session_id: str) -> Optional[AcpSessionState]:
"""获取会话"""
with self._lock:
return self._sessions.get(session_id)
def cancel_session(self, session_id: str) -> bool:
"""取消会话"""
with self._lock:
sess = self._sessions.get(session_id)
if sess and sess.status == "running":
sess.status = "cancelled"
sess.completed_at = time.time()
self._notify("agent_cancelled", sess.to_dict())
return True
return False
# ---- Agent Invocation ----
async def invoke_agent(
self,
agent_id: str,
prompt: str,
system_prompt: str = "",
model_id: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 4096,
context: Optional[dict] = None,
db_session=None,
) -> dict:
"""调用 Agent — 创建会话、发送提示、等待完成
这是一个高级封装,内部自动创建和管理会话生命周期。
返回完整结果字典,包含 output, tool_calls, chain_of_thought 等。
"""
import httpx
# Resolve agent config from DB
agent_name = agent_id
if db_session:
config = resolve_agent_config(db_session, agent_id)
if config:
agent_name = config.agent_name
system_prompt = system_prompt or config.system_prompt
model_id = model_id or config.model_id
temperature = config.temperature
max_tokens = config.max_tokens
# Create session
session_id = self.create_session(agent_id, agent_name)
sess = self.get_session(session_id)
# Resolve model
if db_session and model_id:
model_cfg = resolve_model_config(db_session, model_id)
else:
model_cfg = {"api_key": "", "api_base": "https://api.siliconflow.cn/v1", "model_name": "deepseek-ai/DeepSeek-V4-Flash"}
# Update status
with self._lock:
sess.status = "running"
sess.prompt = prompt
self._notify("agent_running", {"session_id": session_id, "agent_name": agent_name, "prompt": prompt[:200]})
try:
# Build messages
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
# Call LLM
headers = {
"Authorization": f"Bearer {model_cfg['api_key']}",
"Content-Type": "application/json",
}
payload = {
"model": model_cfg["model_name"],
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{model_cfg['api_base']}/chat/completions",
headers=headers,
json=payload,
)
resp.raise_for_status()
data = resp.json()
output = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
with self._lock:
sess.status = "completed"
sess.output = output
sess.completed_at = time.time()
result = {
"session_id": session_id,
"status": "completed",
"output": output,
"agent_name": agent_name,
"usage": usage,
"tool_calls": sess.tool_calls,
"chain_of_thought": sess.chain_of_thought,
}
self._notify("agent_completed", {"session_id": session_id, "output_preview": output[:200]})
return result
except Exception as e:
error_msg = str(e)
with self._lock:
sess.status = "failed"
sess.error = error_msg
sess.completed_at = time.time()
result = {
"session_id": session_id,
"status": "failed",
"output": f"[Agent] 调用失败: {error_msg}",
"agent_name": agent_name,
"error": error_msg,
}
self._notify("agent_failed", {"session_id": session_id, "error": error_msg})
return result
async def invoke_agent_streaming(
self,
agent_id: str,
prompt: str,
system_prompt: str = "",
model_id: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 4096,
context: Optional[dict] = None,
db_session=None,
) -> AsyncIterator[dict]:
"""流式调用 Agent — 逐步返回 LLM 生成的 token chunks
每个 yield 的 dict 格式:
{"type": "token"|"done"|"error", "content": str, ...}
"""
import httpx
# Resolve agent config
agent_name = agent_id
if db_session:
config = resolve_agent_config(db_session, agent_id)
if config:
agent_name = config.agent_name
system_prompt = system_prompt or config.system_prompt
model_id = model_id or config.model_id
temperature = config.temperature
max_tokens = config.max_tokens
session_id = self.create_session(agent_id, agent_name)
sess = self.get_session(session_id)
if db_session and model_id:
model_cfg = resolve_model_config(db_session, model_id)
else:
model_cfg = {"api_key": "", "api_base": "https://api.siliconflow.cn/v1", "model_name": "deepseek-ai/DeepSeek-V4-Flash"}
with self._lock:
sess.status = "running"
sess.prompt = prompt
self._notify("agent_running", {"session_id": session_id, "agent_name": agent_name})
try:
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
headers = {
"Authorization": f"Bearer {model_cfg['api_key']}",
"Content-Type": "application/json",
}
payload = {
"model": model_cfg["model_name"],
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
}
collected = []
async with httpx.AsyncClient(timeout=120.0) as client:
async with client.stream("POST", f"{model_cfg['api_base']}/chat/completions",
headers=headers, json=payload) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.startswith("data: "):
data_str = line[6:]
if data_str.strip() == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
collected.append(content)
yield {"type": "token", "content": content, "session_id": session_id}
# Notify periodically (every ~20 tokens)
if len(collected) % 20 == 0:
self._notify("agent_token", {
"session_id": session_id,
"partial": "".join(collected[-20:]),
})
except json.JSONDecodeError:
continue
full_output = "".join(collected)
with self._lock:
sess.status = "completed"
sess.output = full_output
sess.completed_at = time.time()
self._notify("agent_completed", {"session_id": session_id, "output_preview": full_output[:200]})
yield {"type": "done", "session_id": session_id, "output": full_output, "status": "completed"}
except Exception as e:
error_msg = str(e)
with self._lock:
sess.status = "failed"
sess.error = error_msg
sess.completed_at = time.time()
self._notify("agent_failed", {"session_id": session_id, "error": error_msg})
yield {"type": "error", "session_id": session_id, "error": error_msg, "status": "failed"}
# ---- Multi-Agent Parallel Execution ----
async def invoke_agents_parallel(
self,
tasks: list,
db_session=None,
) -> list:
"""并行调用多个 Agent
tasks = [
{"agent_id": "xxx", "prompt": "...", "system_prompt": "..."},
{"agent_id": "yyy", "prompt": "...", "system_prompt": "..."},
]
Returns list of results in same order.
"""
async def _invoke_one(task: dict) -> dict:
try:
return await self.invoke_agent(
agent_id=task["agent_id"],
prompt=task.get("prompt", ""),
system_prompt=task.get("system_prompt", ""),
model_id=task.get("model_id"),
temperature=task.get("temperature", 0.7),
max_tokens=task.get("max_tokens", 4096),
context=task.get("context"),
db_session=db_session,
)
except Exception as e:
return {
"status": "failed",
"output": f"[Agent {task.get('agent_id', 'unknown')}] 执行异常: {str(e)}",
"error": str(e),
}
return await asyncio.gather(*[_invoke_one(t) for t in tasks])
# ============================================================
# Global Singleton
# ============================================================
_acp_gateway_instance: Optional[AcpGateway] = None
_lock_gateway = threading.Lock()
def get_acp_gateway() -> AcpGateway:
"""获取全局 ACP Gateway 单例"""
global _acp_gateway_instance
if _acp_gateway_instance is None:
with _lock_gateway:
if _acp_gateway_instance is None:
_acp_gateway_instance = AcpGateway()
return _acp_gateway_instance
# ============================================================
# Redis Notification Helper
# ============================================================
def publish_workflow_event(workflow_id: str, node_id: str, event: dict):
"""发布工作流执行事件到 Redis EventBus"""
try:
from app.event_bus import get_event_bus, MessageEvent
bus = get_event_bus()
msg = MessageEvent(
platform="workflow",
channel_id=f"{workflow_id}:{node_id}",
content=json.dumps(event, ensure_ascii=False),
content_type="workflow_event",
raw={
"workflow_id": workflow_id,
"node_id": node_id,
**event,
},
)
bus.publish(msg)
return True
except Exception as e:
logger.error(f"[AcpGateway] Redis 发布失败: {e}")
return False
@@ -0,0 +1,557 @@
"""
Agent 管理 API(管理员)
Phase 2.3: AI PaaS Agent Management
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from sqlalchemy.orm import Session
from sqlalchemy import or_, func
from pydantic import BaseModel
from typing import Optional, List
from ...database import get_db
from ...models import MarketApp, EvolutionSkill, AIModel, User, ModelProvider
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
from datetime import datetime
router = APIRouter()
# ============================================================
# Pydantic 模型
# ============================================================
class AgentConfig(BaseModel):
system_prompt: Optional[str] = None
temperature: Optional[float] = None
max_tokens: Optional[int] = None
model_id: Optional[str] = None
skills: Optional[List[str]] = None # skill IDs
tools: Optional[List[str]] = None # MCP server IDs
memory_enabled: Optional[bool] = False
reasoning_enabled: Optional[bool] = False
class AgentCreate(BaseModel):
name: str
description: Optional[str] = None
icon: Optional[str] = None
price: float = 0.0
is_member_free: bool = False
config: Optional[AgentConfig] = None
class AgentUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
icon: Optional[str] = None
price: Optional[float] = None
is_member_free: Optional[bool] = None
config: Optional[AgentConfig] = None
status: Optional[str] = None # active/inactive
tutorial: Optional[str] = None
# ============================================================
# 辅助函数
# ============================================================
def agent_to_dict(a: MarketApp) -> dict:
"""将 MarketApp (agent) 转换为字典"""
# 解析 skill_content 中的配置
import json
config = {}
if a.skill_content:
try:
config = json.loads(a.skill_content)
except Exception:
pass
return {
"id": a.id,
"name": a.name,
"type": a.type,
"source": a.source,
"publisher_id": a.publisher_id,
"publisher_name": a.publisher_name,
"description": a.description,
"icon": a.icon,
"tutorial": a.tutorial,
"price": float(a.price) if a.price else 0,
"is_member_free": a.is_member_free,
"join_promotion": a.join_promotion,
"status": a.status,
"rating_avg": float(a.rating_avg) if a.rating_avg else 0,
"review_count": a.review_count,
"install_count": a.install_count,
"agent_endpoint": a.agent_endpoint,
"skill_content": a.skill_content,
"config": config,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
# ============================================================
# 端点
# ============================================================
@router.get("/", summary="获取 Agent 列表")
async def list_agents(
keyword: str = Query(None, description="搜索关键词"),
status: str = Query(None, description="状态: pending/published/removed"),
source: str = Query(None, description="来源: official/user"),
page: int = Query(1, ge=1),
page_size: int = Query(15, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取所有 Agent 列表(分页、筛选)"""
query = db.query(MarketApp).filter(MarketApp.type == "agent")
if keyword:
query = query.filter(
or_(
MarketApp.name.ilike(f"%{keyword}%"),
MarketApp.description.ilike(f"%{keyword}%"),
MarketApp.publisher_name.ilike(f"%{keyword}%"),
)
)
if status:
query = query.filter(MarketApp.status == status)
if source:
query = query.filter(MarketApp.source == source)
total = query.count()
agents = query.order_by(MarketApp.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [agent_to_dict(a) for a in agents],
}
}
@router.get("/stats/overview", summary="Agent 统计概览")
async def get_agent_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取 Agent 统计数据"""
base = db.query(MarketApp).filter(MarketApp.type == "agent")
total = base.count()
published = base.filter(MarketApp.status == "published").count()
pending = base.filter(MarketApp.status == "pending").count()
official = base.filter(MarketApp.source == "official").count()
user_created = base.filter(MarketApp.source == "user").count()
# 安装量统计
install_base = db.query(func.sum(MarketApp.install_count)).filter(
MarketApp.type == "agent"
).scalar() or 0
# 按类型分布
by_source = {
"official": official,
"user": user_created,
}
# 评分统计
avg_rating = db.query(func.avg(MarketApp.rating_avg)).filter(
MarketApp.type == "agent",
MarketApp.rating_avg > 0,
).scalar() or 0
return {
"code": 0,
"data": {
"total_agents": total,
"published_agents": published,
"pending_agents": pending,
"official_agents": official,
"user_agents": user_created,
"total_installs": int(install_base),
"average_rating": round(float(avg_rating), 2),
"by_source": by_source,
}
}
@router.get("/{agent_id}", summary="获取 Agent 详情")
async def get_agent(
agent_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取单个 Agent 的详细信息"""
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id,
MarketApp.type == "agent",
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
result = agent_to_dict(agent)
# 获取关联的 Skills
import json
config = json.loads(agent.skill_content or "{}")
skill_ids = config.get("skills", [])
if skill_ids:
skills = db.query(EvolutionSkill).filter(
EvolutionSkill.id.in_(skill_ids)
).all()
result["linked_skills"] = [
{
"id": s.id,
"skill_name": s.skill_name,
"state": s.state,
"version": s.version,
}
for s in skills
]
else:
result["linked_skills"] = []
# 获取可用模型列表
models = db.query(AIModel, ModelProvider).join(
ModelProvider, AIModel.provider_id == ModelProvider.id
).filter(AIModel.is_enabled == True).all()
result["available_models"] = [
{"id": m.id, "name": m.name, "provider": p.name} for m, p in models
]
return {"code": 0, "data": result}
@router.post("/", summary="创建 Agent")
async def create_agent(
req: AgentCreate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建新的 Agent"""
import json
# 检查名称是否已存在
existing = db.query(MarketApp).filter(
MarketApp.name == req.name,
MarketApp.type == "agent",
).first()
if existing:
raise HTTPException(status_code=400, detail="Agent 名称已存在")
# 构建 skill_content
config = {}
if req.config:
cfg = req.config
config = {
"system_prompt": cfg.system_prompt or "",
"temperature": cfg.temperature or 0.7,
"max_tokens": cfg.max_tokens or 2048,
"model_id": cfg.model_id,
"skills": cfg.skills or [],
"tools": cfg.tools or [],
"memory_enabled": cfg.memory_enabled or False,
"reasoning_enabled": cfg.reasoning_enabled or False,
}
agent = MarketApp(
id=str(uuid.uuid4()),
name=req.name,
type="agent",
source="official",
publisher_id=admin_user.id,
publisher_name=admin_user.username,
description=req.description or "",
icon=req.icon or "",
price=0.0, # 管理员导入/安装/开发的 Agent 默认免费
is_member_free=req.is_member_free,
skill_content=json.dumps(config, ensure_ascii=False),
status="published",
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(agent)
db.commit()
db.refresh(agent)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_agent",
target_type="agent",
target_id=agent.id,
detail=f"创建 Agent: {agent.name}",
)
return {"code": 0, "message": "Agent 创建成功", "data": agent_to_dict(agent)}
@router.put("/{agent_id}", summary="更新 Agent")
async def update_agent(
agent_id: str,
req: AgentUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新 Agent 配置"""
import json
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id,
MarketApp.type == "agent",
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
# 更新基本字段
if req.name is not None:
# 检查新名称是否重复
dup = db.query(MarketApp).filter(
MarketApp.name == req.name,
MarketApp.type == "agent",
MarketApp.id != agent_id,
).first()
if dup:
raise HTTPException(status_code=400, detail="Agent 名称已存在")
agent.name = req.name
if req.description is not None:
agent.description = req.description
if req.icon is not None:
agent.icon = req.icon
if req.price is not None:
agent.price = req.price
if req.is_member_free is not None:
agent.is_member_free = req.is_member_free
if req.status is not None:
agent.status = req.status
if req.tutorial is not None:
agent.tutorial = req.tutorial
# 更新配置
if req.config is not None:
cfg = req.config
# 合并现有配置
existing_config = {}
if agent.skill_content:
try:
existing_config = json.loads(agent.skill_content)
except Exception:
pass
new_config = {
"system_prompt": cfg.system_prompt if cfg.system_prompt is not None else existing_config.get("system_prompt", ""),
"temperature": cfg.temperature if cfg.temperature is not None else existing_config.get("temperature", 0.7),
"max_tokens": cfg.max_tokens if cfg.max_tokens is not None else existing_config.get("max_tokens", 2048),
"model_id": cfg.model_id if cfg.model_id is not None else existing_config.get("model_id"),
"skills": cfg.skills if cfg.skills is not None else existing_config.get("skills", []),
"tools": cfg.tools if cfg.tools is not None else existing_config.get("tools", []),
"memory_enabled": cfg.memory_enabled if cfg.memory_enabled is not None else existing_config.get("memory_enabled", False),
"reasoning_enabled": cfg.reasoning_enabled if cfg.reasoning_enabled is not None else existing_config.get("reasoning_enabled", False),
}
agent.skill_content = json.dumps(new_config, ensure_ascii=False)
agent.updated_at = datetime.now()
db.commit()
db.refresh(agent)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_agent",
target_type="agent",
target_id=agent_id,
detail=f"更新 Agent: {agent.name}",
)
return {"code": 0, "message": "Agent 已更新", "data": agent_to_dict(agent)}
@router.delete("/{agent_id}", summary="删除 Agent")
async def delete_agent(
agent_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除 Agent"""
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id,
MarketApp.type == "agent",
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
agent_name = agent.name
db.delete(agent)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_agent",
target_type="agent",
target_id=agent_id,
detail=f"删除 Agent: {agent_name}",
)
return {"code": 0, "message": "Agent 已删除"}
@router.put("/{agent_id}/toggle", summary="启用/禁用 Agent")
async def toggle_agent(
agent_id: str,
is_active: bool = Query(..., description="是否启用"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""启用或禁用 Agent"""
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id,
MarketApp.type == "agent",
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
agent.status = "published" if is_active else "pending"
agent.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="toggle_agent",
target_type="agent",
target_id=agent_id,
detail=f"{'启用' if is_active else '禁用'} Agent: {agent.name}",
)
return {
"code": 0,
"message": f"Agent 已{'启用' if is_active else '禁用'}",
"data": agent_to_dict(agent),
}
@router.get("/{agent_id}/reviews", summary="获取 Agent 评价")
async def get_agent_reviews(
agent_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取 Agent 的用户评价"""
from ...models import AppReview, User
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id,
MarketApp.type == "agent",
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
reviews = db.query(AppReview).filter(
AppReview.app_id == agent_id
).order_by(AppReview.created_at.desc()).limit(50).all()
return {
"code": 0,
"data": [
{
"id": r.id,
"user_id": r.user_id,
"rating": r.rating,
"content": r.content,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in reviews
]
}
@router.get("/available/skills", summary="获取可关联的 Skills")
async def get_available_skills(
keyword: str = Query(None),
state: str = Query(None, description="技能状态筛选"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取可关联到 Agent 的 Skills 列表"""
query = db.query(EvolutionSkill)
if keyword:
query = query.filter(
or_(
EvolutionSkill.skill_name.ilike(f"%{keyword}%"),
EvolutionSkill.description.ilike(f"%{keyword}%"),
)
)
if state:
query = query.filter(EvolutionSkill.state == state)
total = query.count()
skills = query.order_by(EvolutionSkill.use_count.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"items": [
{
"id": s.id,
"skill_name": s.skill_name,
"description": s.description,
"state": s.state,
"version": s.version,
"use_count": s.use_count,
"category": s.category,
"tags": s.tags,
}
for s in skills
]
}
}
@router.get("/available/models", summary="获取可用模型列表")
async def get_available_models(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取可用于 Agent 的模型列表"""
models = db.query(AIModel, ModelProvider).join(
ModelProvider, AIModel.provider_id == ModelProvider.id
).filter(
AIModel.is_enabled == True
).order_by(AIModel.name).all()
return {
"code": 0,
"data": [
{
"id": m.id,
"name": m.name,
"provider": p.name,
"model_type": p.protocol,
"input_price": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0,
"output_price": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0,
}
for m, p in models
]
}
@@ -0,0 +1,424 @@
"""
数据分析管理后端 API
- 事件采集、聚合统计、仪表盘配置、趋势分析
- 路由前缀: /app/api/admin/analytics
"""
import uuid
import json
from datetime import datetime, timedelta, date
from fastapi import APIRouter, Query, HTTPException, Depends
from ...database import SessionLocal, get_db
from ..auth import get_current_user
from sqlalchemy import text
from sqlalchemy.orm import Session
router = APIRouter()
CST_TZ = None
def _format_cst_time(dt_val):
global CST_TZ
if CST_TZ is None:
from datetime import timezone
CST_TZ = timezone(timedelta(hours=8))
if dt_val is None:
return None
if isinstance(dt_val, str):
return dt_val
if dt_val.tzinfo is None:
dt_val = dt_val.replace(tzinfo=CST_TZ)
return dt_val.strftime("%Y-%m-%d %H:%M:%S")
# ============================================================
# 统计概览
# ============================================================
@router.get("/overview")
async def analytics_overview(
days: int = Query(30, description="统计天数"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""数据分析总览"""
since = datetime.now() - timedelta(days=days)
# User stats
total_users = db.execute(text("SELECT COUNT(*) FROM users")).scalar()
active_users = db.execute(text("""
SELECT COUNT(DISTINCT user_id) FROM token_usage_logs
WHERE created_at >= :since
"""), {"since": since}).scalar()
new_users = db.execute(text("""
SELECT COUNT(*) FROM users WHERE created_at >= :since
"""), {"since": since}).scalar()
# API stats
api_calls = db.execute(text("""
SELECT COALESCE(SUM(call_count), 0) FROM api_call_stats
WHERE updated_at >= :since
"""), {"since": since}).scalar()
avg_response = db.execute(text("""
SELECT COALESCE(AVG(total_response_time/NULLIF(call_count,0)), 0) FROM api_call_stats
WHERE updated_at >= :since
"""), {"since": since}).scalar()
error_rate = db.execute(text("""
SELECT COALESCE(SUM(error_count)/NULLIF(SUM(call_count),0)*100, 0) FROM api_call_stats
WHERE updated_at >= :since
"""), {"since": since}).scalar()
# Token stats
tokens_used = db.execute(text("""
SELECT COALESCE(SUM(total_tokens), 0) FROM token_usage_logs
WHERE created_at >= :since
"""), {"since": since}).scalar()
total_cost = db.execute(text("""
SELECT COALESCE(SUM(cost), 0) FROM token_usage_logs
WHERE created_at >= :since
"""), {"since": since}).scalar()
# Chat stats
total_chats = db.execute(text("SELECT COUNT(*) FROM conversations")).scalar()
total_messages = db.execute(text("SELECT COUNT(*) FROM messages")).scalar()
# Revenue (from billing_transactions)
revenue = db.execute(text("""
SELECT COALESCE(SUM(amount), 0) FROM billing_transactions
WHERE transaction_type='recharge' AND status='completed' AND created_at >= :since
"""), {"since": since}).scalar()
return {"code": 200, "data": {
"total_users": total_users,
"active_users": active_users,
"new_users": new_users,
"api_calls": api_calls,
"avg_response_time": round(float(avg_response), 2),
"error_rate": round(float(error_rate), 2),
"tokens_used": tokens_used,
"total_cost": float(total_cost),
"total_chats": total_chats,
"total_messages": total_messages,
"revenue": float(revenue),
"period_days": days,
}}
# ============================================================
# 趋势分析
# ============================================================
@router.get("/trends/api-calls")
async def api_call_trends(
days: int = Query(7, description="天数"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""API调用趋势(按天)"""
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
rows = db.execute(text("""
SELECT DATE(created_at) as d,
COUNT(*) as calls,
COALESCE(SUM(CASE WHEN status='error' THEN 1 ELSE 0 END), 0) as errors
FROM analytics_events
WHERE event_type='api_call' AND created_at >= :since
GROUP BY DATE(created_at) ORDER BY d
"""), {"since": since}).fetchall()
if not rows:
# Fallback to api_call_stats
rows = db.execute(text("""
SELECT DATE(updated_at) as d, SUM(call_count) as calls, SUM(error_count) as errors
FROM api_call_stats WHERE updated_at >= :since
GROUP BY DATE(updated_at) ORDER BY d
"""), {"since": since}).fetchall()
data = [{"date": str(r[0]), "calls": int(r[1] or 0), "errors": int(r[2] or 0)} for r in rows]
return {"code": 200, "data": data}
@router.get("/trends/tokens")
async def token_usage_trends(
days: int = Query(7, description="天数"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""Token使用趋势(按天+按模型)"""
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
rows = db.execute(text("""
SELECT DATE(created_at) as d, model_name,
SUM(total_tokens) as tokens, SUM(cost) as cost
FROM token_usage_logs
WHERE created_at >= :since
GROUP BY DATE(created_at), model_name
ORDER BY d, tokens DESC
"""), {"since": since}).fetchall()
# Group by date
by_date = {}
for r in rows:
d = str(r[0])
if d not in by_date:
by_date[d] = {"date": d, "total_tokens": 0, "models": {}}
by_date[d]["total_tokens"] += int(r[2] or 0)
model = r[1] or "unknown"
by_date[d]["models"][model] = int(r[2] or 0)
# Model distribution
model_dist = {}
for r in rows:
model = r[1] or "unknown"
model_dist[model] = model_dist.get(model, 0) + int(r[2] or 0)
return {"code": 200, "data": {
"daily": list(by_date.values()),
"model_distribution": model_dist,
}}
@router.get("/trends/users")
async def user_growth_trends(
days: int = Query(30, description="天数"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""用户增长趋势"""
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
rows = db.execute(text("""
SELECT DATE(created_at) as d, COUNT(*) as new_users
FROM users WHERE created_at >= :since
GROUP BY DATE(created_at) ORDER BY d
"""), {"since": since}).fetchall()
data = [{"date": str(r[0]), "new_users": int(r[1])} for r in rows]
# Cumulative
cumulative = 0
total_before = db.execute(text("SELECT COUNT(*) FROM users WHERE created_at < :since"),
{"since": since}).scalar()
cumulative = total_before
for item in data:
cumulative += item["new_users"]
item["cumulative"] = cumulative
return {"code": 200, "data": data}
@router.get("/trends/revenue")
async def revenue_trends(
days: int = Query(30, description="天数"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""收入趋势"""
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
rows = db.execute(text("""
SELECT DATE(created_at) as d,
COALESCE(SUM(CASE WHEN transaction_type='recharge' THEN amount ELSE 0 END), 0) as revenue,
COALESCE(SUM(CASE WHEN transaction_type='consumption' THEN amount ELSE 0 END), 0) as consumption
FROM billing_transactions
WHERE status='completed' AND created_at >= :since
GROUP BY DATE(created_at) ORDER BY d
"""), {"since": since}).fetchall()
data = [{"date": str(r[0]), "revenue": float(r[1] or 0), "consumption": float(r[2] or 0)} for r in rows]
return {"code": 200, "data": data}
# ============================================================
# 模型使用分析
# ============================================================
@router.get("/models/usage")
async def model_usage_analysis(
days: int = Query(30, description="天数"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""模型使用分析"""
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
rows = db.execute(text("""
SELECT model_name,
COUNT(*) as call_count,
SUM(total_tokens) as total_tokens,
SUM(prompt_tokens) as prompt_tokens,
SUM(completion_tokens) as completion_tokens,
SUM(cost) as total_cost,
AVG(total_tokens) as avg_tokens
FROM token_usage_logs
WHERE created_at >= :since AND model_name IS NOT NULL
GROUP BY model_name
ORDER BY total_tokens DESC
"""), {"since": since}).fetchall()
data = [{
"model_name": r[0],
"call_count": int(r[1]),
"total_tokens": int(r[2] or 0),
"prompt_tokens": int(r[3] or 0),
"completion_tokens": int(r[4] or 0),
"total_cost": float(r[5] or 0),
"avg_tokens": round(float(r[6] or 0), 0),
} for r in rows]
return {"code": 200, "data": data}
# ============================================================
# 仪表盘配置
# ============================================================
@router.get("/dashboards")
async def list_dashboards(db: Session = Depends(get_db), user=Depends(get_current_user)):
"""获取仪表盘配置列表"""
rows = db.execute(text("""
SELECT ad.*, u.username as created_by_name
FROM analytics_dashboards ad
LEFT JOIN users u ON ad.created_by = u.id
ORDER BY ad.is_default DESC, ad.created_at DESC
""")).fetchall()
cols = ['id','tenant_id','name','description','config','is_default',
'created_by','created_at','updated_at','created_by_name']
items = []
for r in rows:
d = _format_cst_time_dict(r, cols)
items.append(d)
return {"code": 200, "data": items}
def _format_cst_time_dict(row, columns):
result = {}
for col, val in zip(columns, row):
if isinstance(val, datetime):
result[col] = _format_cst_time(val)
elif isinstance(val, str) and val and val.startswith('[') or (isinstance(val, str) and val and val.startswith('{')):
try:
result[col] = json.loads(val)
except:
result[col] = val
else:
result[col] = val
return result
@router.post("/dashboards/create")
async def create_dashboard(
name: str = Query(...),
description: str = Query(""),
config: str = Query(..., description="仪表盘配置JSON"),
is_default: int = Query(0),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""创建仪表盘"""
did = str(uuid.uuid4())
try:
config_json = json.loads(config)
except:
config_json = json.loads('[{"type":"stat","title":"暂无数据","metric":"none","chart_type":"number"}]')
if is_default:
db.execute(text("UPDATE analytics_dashboards SET is_default=0"))
db.execute(text("""
INSERT INTO analytics_dashboards (id, name, description, config, is_default, created_by)
VALUES (:id, :name, :desc, :config, :default, :uid)
"""), {
"id": did, "name": name, "desc": description,
"config": json.dumps(config_json), "default": is_default, "uid": user.id
})
db.commit()
return {"code": 200, "data": {"id": did, "message": "仪表盘创建成功"}}
@router.delete("/dashboards/{dashboard_id}")
async def delete_dashboard(dashboard_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""删除仪表盘"""
db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=0"), {"id": dashboard_id})
db.commit()
return {"code": 200, "data": {"message": "仪表盘已删除"}}
# ============================================================
# 事件记录
# ============================================================
@router.post("/events/record")
async def record_event(
event_type: str = Query(..., description="事件类型"),
event_category: str = Query("general"),
event_data: str = Query("", description="事件数据JSON"),
duration_ms: int = Query(None),
status: str = Query("success"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""记录分析事件"""
data = None
if event_data:
try:
data = json.loads(event_data)
except:
data = {"raw": event_data}
db.execute(text("""
INSERT INTO analytics_events (tenant_id, user_id, event_type, event_category, event_data, duration_ms, status)
VALUES (:tid, :uid, :et, :ec, :data, :dur, :status)
"""), {
"tid": None, "uid": user.id, "et": event_type, "ec": event_category,
"data": json.dumps(data) if data else None,
"dur": duration_ms, "status": status
})
db.commit()
return {"code": 200, "data": {"message": "事件已记录"}}
@router.get("/events")
async def list_events(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
event_type: str = Query(""),
event_category: str = Query(""),
status: str = Query(""),
days: int = Query(7, description="查询天数"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""获取事件列表"""
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
where = "ae.created_at >= :since"
params = {"since": since}
if event_type:
where += " AND ae.event_type=:et"
params["et"] = event_type
if event_category:
where += " AND ae.event_category=:ec"
params["ec"] = event_category
if status:
where += " AND ae.status=:st"
params["st"] = status
total = db.execute(text(f"SELECT COUNT(*) FROM analytics_events ae WHERE {where}"), params).scalar()
offset = (page - 1) * page_size
rows = db.execute(text(f"""
SELECT ae.*, u.username
FROM analytics_events ae
LEFT JOIN users u ON ae.user_id = u.id
WHERE {where}
ORDER BY ae.created_at DESC LIMIT :limit OFFSET :offset
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
cols = ['id','tenant_id','user_id','event_type','event_category','event_data',
'ip_address','user_agent','duration_ms','status','created_at','username']
items = []
for r in rows:
d = {}
for col, val in zip(cols, r):
if isinstance(val, datetime):
d[col] = _format_cst_time(val)
elif isinstance(val, str) and val and (val.startswith('{') or val.startswith('[')):
try:
d[col] = json.loads(val)
except:
d[col] = val
else:
d[col] = val
items.append(d)
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
+844
View File
@@ -0,0 +1,844 @@
"""AI应用管理 - Admin API
提供AI应用的完整生命周期管理:
- 应用CRUD + 生命周期(草稿→发布→暂停→归档)
- 应用模板(快速创建)
- 版本管理(配置快照、回滚)
- 应用分析(对话数、Token消耗、活跃用户、响应时间)
- 分类统计
"""
from fastapi import APIRouter, HTTPException, Query, Body
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy import text
from datetime import datetime, timedelta
import json
import uuid
router = APIRouter()
# Database - use app's existing connection
from ...database import SessionLocal
# Timezone
from datetime import timezone
CST_TZ = timezone(timedelta(hours=8))
def cst_now():
return datetime.now(CST_TZ).replace(tzinfo=None)
def fmt_time(dt_val):
if not dt_val:
return None
if hasattr(dt_val, 'replace'):
return dt_val.replace(tzinfo=CST_TZ).isoformat()
return str(dt_val)
def gen_id():
return str(uuid.uuid4())
# ============================================================
# Pydantic Models
# ============================================================
class AIAppCreate(BaseModel):
name: str
description: Optional[str] = None
type: str = "chatbot" # chatbot / assistant / workflow / api_service
category: str = "custom"
tags: Optional[str] = None
icon: Optional[str] = None
config: Optional[dict] = None
class AIAppUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
type: Optional[str] = None
category: Optional[str] = None
tags: Optional[str] = None
icon: Optional[str] = None
config: Optional[dict] = None
status: Optional[str] = None
class AIAppVersionCreate(BaseModel):
changelog: Optional[str] = None
class TemplateCreate(BaseModel):
name: str
description: Optional[str] = None
type: str = "chatbot"
category: str = "custom"
icon: Optional[str] = None
config: dict
# ============================================================
# Stats Overview
# ============================================================
@router.get("/stats/overview")
async def stats_overview():
"""AI应用总览统计"""
db = SessionLocal()
try:
# Total apps
total = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE is_template = 0")).scalar() or 0
published = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'published' AND is_template = 0")).scalar() or 0
draft = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'draft' AND is_template = 0")).scalar() or 0
paused = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'paused' AND is_template = 0")).scalar() or 0
archived = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'archived' AND is_template = 0")).scalar() or 0
templates = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE is_template = 1")).scalar() or 0
# By type
type_rows = db.execute(text(
"SELECT type, COUNT(*) as cnt FROM ai_applications WHERE is_template = 0 GROUP BY type"
)).fetchall()
by_type = {row[0]: row[1] for row in type_rows}
# By category
cat_rows = db.execute(text(
"SELECT category, COUNT(*) as cnt FROM ai_applications WHERE is_template = 0 GROUP BY category"
)).fetchall()
by_category = {row[0]: row[1] for row in cat_rows}
# Recent 7 days new apps
seven_days_ago = cst_now() - timedelta(days=7)
recent = db.execute(text(
"SELECT COUNT(*) FROM ai_applications WHERE created_at >= :dt AND is_template = 0"
), {"dt": seven_days_ago}).scalar() or 0
# Total versions
total_versions = db.execute(text("SELECT COUNT(*) FROM ai_app_versions")).scalar() or 0
return {
"code": 0,
"data": {
"total": total,
"published": published,
"draft": draft,
"paused": paused,
"archived": archived,
"templates": templates,
"by_type": by_type,
"by_category": by_category,
"recent_7d": recent,
"total_versions": total_versions
}
}
finally:
db.close()
# ============================================================
# App CRUD
# ============================================================
@router.get("/apps")
async def list_apps(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = Query(None),
app_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
category: Optional[str] = Query(None),
is_template: bool = Query(False)
):
"""获取应用列表"""
db = SessionLocal()
try:
where = "WHERE is_template = :is_template"
params = {"is_template": 1 if is_template else 0}
if search:
where += " AND (name LIKE :search OR description LIKE :search2)"
params["search"] = f"%{search}%"
params["search2"] = f"%{search}%"
if app_type:
where += " AND type = :app_type"
params["app_type"] = app_type
if status:
where += " AND status = :status"
params["status"] = status
if category:
where += " AND category = :category"
params["category"] = category
total = db.execute(text(f"SELECT COUNT(*) FROM ai_applications {where}"), params).scalar() or 0
offset = (page - 1) * page_size
rows = db.execute(text(
f"SELECT id, name, description, type, category, tags, icon, status, version, "
f"is_template, is_public, config, created_at, updated_at, published_at "
f"FROM ai_applications {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
), {**params, "limit": page_size, "offset": offset}).fetchall()
apps = []
for row in rows:
config = row[11]
if isinstance(config, str):
try:
config = json.loads(config)
except:
config = {}
apps.append({
"id": row[0],
"name": row[1],
"description": row[2],
"type": row[3],
"category": row[4],
"tags": row[5],
"icon": row[6],
"status": row[7],
"version": row[8],
"is_template": bool(row[9]),
"is_public": bool(row[10]),
"config": config,
"created_at": fmt_time(row[12]),
"updated_at": fmt_time(row[13]),
"published_at": fmt_time(row[14])
})
return {
"code": 0,
"data": {
"items": apps,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size
}
}
finally:
db.close()
@router.post("/apps")
async def create_app(app: AIAppCreate):
"""创建AI应用"""
db = SessionLocal()
try:
app_id = gen_id()
now = cst_now()
config_json = json.dumps(app.config or {
"model_id": None,
"system_prompt": "",
"temperature": 0.7,
"max_tokens": 2000,
"skill_ids": [],
"knowledge_base_ids": [],
"tools": [],
"welcome_message": ""
}, ensure_ascii=False)
db.execute(text(
"INSERT INTO ai_applications (id, name, description, type, category, tags, icon, "
"status, version, is_template, is_public, config, created_at, updated_at) "
"VALUES (:id, :name, :desc, :type, :cat, :tags, :icon, 'draft', 1, 0, 0, :config, :now, :now)"
), {
"id": app_id, "name": app.name, "desc": app.description,
"type": app.type, "cat": app.category, "tags": app.tags,
"icon": app.icon, "config": config_json, "now": now
})
db.commit()
# Save initial version
version_id = gen_id()
db.execute(text(
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
"VALUES (:id, :app_id, 1, :config, :changelog, :now)"
), {
"id": version_id, "app_id": app_id, "config": config_json,
"changelog": "Initial version", "now": now
})
db.commit()
return {"code": 0, "data": {"id": app_id, "message": "Application created"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
@router.get("/apps/{app_id}")
async def get_app(app_id: str):
"""获取应用详情"""
db = SessionLocal()
try:
row = db.execute(text(
"SELECT id, name, description, type, category, tags, icon, status, version, "
"is_template, is_public, config, created_at, updated_at, published_at "
"FROM ai_applications WHERE id = :id"
), {"id": app_id}).fetchone()
if not row:
raise HTTPException(404, "Application not found")
config = row[11]
if isinstance(config, str):
try:
config = json.loads(config)
except:
config = {}
# Get versions
version_rows = db.execute(text(
"SELECT id, version, changelog, created_at FROM ai_app_versions "
"WHERE app_id = :id ORDER BY version DESC"
), {"id": app_id}).fetchall()
versions = [{
"id": v[0], "version": v[1], "changelog": v[2],
"created_at": fmt_time(v[3])
} for v in version_rows]
# Get analytics summary (last 7 days)
seven_days_ago = cst_now() - timedelta(days=7)
analytics = db.execute(text(
"SELECT COALESCE(SUM(conversations), 0), COALESCE(SUM(messages), 0), "
"COALESCE(SUM(tokens_input + tokens_output), 0), COALESCE(SUM(active_users), 0), "
"COALESCE(SUM(error_count), 0) "
"FROM ai_app_analytics WHERE app_id = :id AND date >= :dt"
), {"id": app_id, "dt": seven_days_ago}).fetchone()
return {
"code": 0,
"data": {
"id": row[0], "name": row[1], "description": row[2],
"type": row[3], "category": row[4], "tags": row[5],
"icon": row[6], "status": row[7], "version": row[8],
"is_template": bool(row[9]), "is_public": bool(row[10]),
"config": config,
"created_at": fmt_time(row[12]),
"updated_at": fmt_time(row[13]),
"published_at": fmt_time(row[14]),
"versions": versions,
"analytics_7d": {
"conversations": analytics[0],
"messages": analytics[1],
"tokens": analytics[2],
"active_users": analytics[3],
"errors": analytics[4]
}
}
}
finally:
db.close()
@router.put("/apps/{app_id}")
async def update_app(app_id: str, app: AIAppUpdate):
"""更新应用"""
db = SessionLocal()
try:
existing = db.execute(text("SELECT config, version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone()
if not existing:
raise HTTPException(404, "Application not found")
old_config = existing[0]
old_version = existing[1]
now = cst_now()
updates = []
params = {"id": app_id, "now": now}
if app.name is not None:
updates.append("name = :name")
params["name"] = app.name
if app.description is not None:
updates.append("description = :desc")
params["desc"] = app.description
if app.type is not None:
updates.append("type = :type")
params["type"] = app.type
if app.category is not None:
updates.append("category = :cat")
params["cat"] = app.category
if app.tags is not None:
updates.append("tags = :tags")
params["tags"] = app.tags
if app.icon is not None:
updates.append("icon = :icon")
params["icon"] = app.icon
if app.status is not None:
updates.append("status = :status")
params["status"] = app.status
if app.status == "published":
updates.append("published_at = :now")
config_changed = False
if app.config is not None:
new_config = json.dumps(app.config, ensure_ascii=False)
if new_config != old_config:
updates.append("config = :config")
params["config"] = new_config
config_changed = True
if not updates:
return {"code": 0, "data": {"message": "No changes"}}
updates.append("updated_at = :now")
db.execute(text(f"UPDATE ai_applications SET {', '.join(updates)} WHERE id = :id"), params)
# Save new version if config changed
if config_changed:
new_version = old_version + 1
db.execute(text("UPDATE ai_applications SET version = :v WHERE id = :id"), {"v": new_version, "id": app_id})
version_id = gen_id()
db.execute(text(
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
"VALUES (:vid, :aid, :v, :config, :cl, :now)"
), {
"vid": version_id, "aid": app_id, "v": new_version,
"config": new_config, "cl": f"Version {new_version}", "now": now
})
db.commit()
return {"code": 0, "data": {"message": "Application updated"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
@router.delete("/apps/{app_id}")
async def delete_app(app_id: str):
"""删除应用"""
db = SessionLocal()
try:
result = db.execute(text("DELETE FROM ai_applications WHERE id = :id"), {"id": app_id})
if result.rowcount == 0:
raise HTTPException(404, "Application not found")
db.execute(text("DELETE FROM ai_app_versions WHERE app_id = :id"), {"id": app_id})
db.execute(text("DELETE FROM ai_app_analytics WHERE app_id = :id"), {"id": app_id})
db.commit()
return {"code": 0, "data": {"message": "Application deleted"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
# ============================================================
# Lifecycle Management
# ============================================================
@router.post("/apps/{app_id}/publish")
async def publish_app(app_id: str):
"""发布应用"""
db = SessionLocal()
try:
now = cst_now()
result = db.execute(text(
"UPDATE ai_applications SET status = 'published', published_at = :now, updated_at = :now WHERE id = :id"
), {"id": app_id, "now": now})
if result.rowcount == 0:
raise HTTPException(404, "Application not found")
db.commit()
return {"code": 0, "data": {"message": "Application published"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
@router.post("/apps/{app_id}/pause")
async def pause_app(app_id: str):
"""暂停应用"""
db = SessionLocal()
try:
result = db.execute(text(
"UPDATE ai_applications SET status = 'paused', updated_at = :now WHERE id = :id"
), {"id": app_id, "now": cst_now()})
if result.rowcount == 0:
raise HTTPException(404, "Application not found")
db.commit()
return {"code": 0, "data": {"message": "Application paused"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
@router.post("/apps/{app_id}/archive")
async def archive_app(app_id: str):
"""归档应用"""
db = SessionLocal()
try:
result = db.execute(text(
"UPDATE ai_applications SET status = 'archived', updated_at = :now WHERE id = :id"
), {"id": app_id, "now": cst_now()})
if result.rowcount == 0:
raise HTTPException(404, "Application not found")
db.commit()
return {"code": 0, "data": {"message": "Application archived"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
@router.post("/apps/{app_id}/duplicate")
async def duplicate_app(app_id: str):
"""复制应用"""
db = SessionLocal()
try:
row = db.execute(text(
"SELECT name, description, type, category, tags, icon, config "
"FROM ai_applications WHERE id = :id"
), {"id": app_id}).fetchone()
if not row:
raise HTTPException(404, "Application not found")
new_id = gen_id()
now = cst_now()
db.execute(text(
"INSERT INTO ai_applications (id, name, description, type, category, tags, icon, "
"status, version, is_template, is_public, config, created_at, updated_at) "
"VALUES (:id, :name, :desc, :type, :cat, :tags, :icon, 'draft', 1, 0, 0, :config, :now, :now)"
), {
"id": new_id, "name": row[0] + " (Copy)", "desc": row[1],
"type": row[2], "cat": row[3], "tags": row[4],
"icon": row[5], "config": row[6], "now": now
})
# Copy initial version
version_id = gen_id()
db.execute(text(
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
"VALUES (:vid, :aid, 1, :config, :cl, :now)"
), {"vid": version_id, "aid": new_id, "config": row[6], "cl": "Copied from original", "now": now})
db.commit()
return {"code": 0, "data": {"id": new_id, "message": "Application duplicated"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
# ============================================================
# Version Management
# ============================================================
@router.get("/apps/{app_id}/versions")
async def list_versions(app_id: str):
"""获取应用版本历史"""
db = SessionLocal()
try:
rows = db.execute(text(
"SELECT id, version, config, changelog, created_at "
"FROM ai_app_versions WHERE app_id = :id ORDER BY version DESC"
), {"id": app_id}).fetchall()
versions = []
for row in rows:
config = row[2]
if isinstance(config, str):
try:
config = json.loads(config)
except:
config = {}
versions.append({
"id": row[0], "version": row[1], "config": config,
"changelog": row[3], "created_at": fmt_time(row[4])
})
return {"code": 0, "data": {"items": versions, "total": len(versions)}}
finally:
db.close()
@router.post("/apps/{app_id}/versions")
async def save_version(app_id: str, body: AIAppVersionCreate):
"""保存当前配置为新版本"""
db = SessionLocal()
try:
row = db.execute(text("SELECT config, version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone()
if not row:
raise HTTPException(404, "Application not found")
new_version = row[1] + 1
version_id = gen_id()
now = cst_now()
db.execute(text(
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
"VALUES (:vid, :aid, :v, :config, :cl, :now)"
), {
"vid": version_id, "aid": app_id, "v": new_version,
"config": row[0], "cl": body.changelog or f"Version {new_version}", "now": now
})
db.execute(text("UPDATE ai_applications SET version = :v, updated_at = :now WHERE id = :id"),
{"v": new_version, "now": now, "id": app_id})
db.commit()
return {"code": 0, "data": {"version": new_version, "message": "Version saved"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
@router.post("/apps/{app_id}/versions/{version}/restore")
async def restore_version(app_id: str, version: int):
"""恢复到指定版本"""
db = SessionLocal()
try:
row = db.execute(text(
"SELECT config FROM ai_app_versions WHERE app_id = :id AND version = :v"
), {"id": app_id, "v": version}).fetchone()
if not row:
raise HTTPException(404, "Version not found")
now = cst_now()
current = db.execute(text("SELECT version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone()
if not current:
raise HTTPException(404, "Application not found")
new_version = current[0] + 1
db.execute(text(
"UPDATE ai_applications SET config = :config, version = :v, updated_at = :now WHERE id = :id"
), {"config": row[0], "v": new_version, "now": now, "id": app_id})
# Save restored version
version_id = gen_id()
db.execute(text(
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
"VALUES (:vid, :aid, :v, :config, :cl, :now)"
), {
"vid": version_id, "aid": app_id, "v": new_version,
"config": row[0], "cl": f"Restored from v{version}", "now": now
})
db.commit()
return {"code": 0, "data": {"version": new_version, "message": f"Restored to v{version}"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
# ============================================================
# Analytics
# ============================================================
@router.get("/apps/{app_id}/analytics")
async def app_analytics(app_id: str, days: int = Query(7, ge=1, le=90)):
"""获取应用分析数据"""
db = SessionLocal()
try:
start_date = cst_now() - timedelta(days=days)
rows = db.execute(text(
"SELECT date, conversations, messages, tokens_input, tokens_output, "
"active_users, avg_response_ms, error_count "
"FROM ai_app_analytics WHERE app_id = :id AND date >= :dt ORDER BY date"
), {"id": app_id, "dt": start_date}).fetchall()
daily = []
for row in rows:
daily.append({
"date": row[0].isoformat() if row[0] else None,
"conversations": row[1], "messages": row[2],
"tokens_input": row[3], "tokens_output": row[4],
"active_users": row[5], "avg_response_ms": row[6],
"errors": row[7]
})
# Summary
total_convs = sum(d["conversations"] for d in daily)
total_msgs = sum(d["messages"] for d in daily)
total_tokens_in = sum(d["tokens_input"] for d in daily)
total_tokens_out = sum(d["tokens_output"] for d in daily)
total_users = sum(d["active_users"] for d in daily)
total_errors = sum(d["errors"] for d in daily)
avg_response = sum(d["avg_response_ms"] for d in daily) / len(daily) if daily else 0
return {
"code": 0,
"data": {
"daily": daily,
"summary": {
"conversations": total_convs,
"messages": total_msgs,
"tokens_input": total_tokens_in,
"tokens_output": total_tokens_out,
"active_users": total_users,
"errors": total_errors,
"avg_response_ms": round(avg_response, 1),
"error_rate": round(total_errors / total_msgs * 100, 2) if total_msgs else 0
},
"days": days
}
}
finally:
db.close()
# ============================================================
# Templates
# ============================================================
@router.get("/templates")
async def list_templates(
type: Optional[str] = Query(None),
category: Optional[str] = Query(None)
):
"""获取应用模板列表"""
db = SessionLocal()
try:
where = "WHERE is_template = 1"
params = {}
if type:
where += " AND type = :type"
params["type"] = type
if category:
where += " AND category = :category"
params["category"] = category
rows = db.execute(text(
f"SELECT id, name, description, type, category, icon, config, created_at "
f"FROM ai_applications {where} ORDER BY created_at DESC"
), params).fetchall()
templates = []
for row in rows:
config = row[6]
if isinstance(config, str):
try:
config = json.loads(config)
except:
config = {}
templates.append({
"id": row[0], "name": row[1], "description": row[2],
"type": row[3], "category": row[4], "icon": row[5],
"config": config, "created_at": fmt_time(row[7])
})
return {"code": 0, "data": {"items": templates, "total": len(templates)}}
finally:
db.close()
@router.post("/templates")
async def create_template(tpl: TemplateCreate):
"""创建应用模板"""
db = SessionLocal()
try:
tpl_id = gen_id()
now = cst_now()
config_json = json.dumps(tpl.config, ensure_ascii=False)
db.execute(text(
"INSERT INTO ai_applications (id, name, description, type, category, icon, "
"status, version, is_template, is_public, config, created_at, updated_at) "
"VALUES (:id, :name, :desc, :type, :cat, :icon, 'published', 1, 1, 1, :config, :now, :now)"
), {
"id": tpl_id, "name": tpl.name, "desc": tpl.description,
"type": tpl.type, "cat": tpl.category, "icon": tpl.icon,
"config": config_json, "now": now
})
db.commit()
return {"code": 0, "data": {"id": tpl_id, "message": "Template created"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
@router.post("/apps/from-template/{template_id}")
async def create_from_template(template_id: str, body: dict = Body(default={})):
"""从模板创建应用"""
db = SessionLocal()
try:
row = db.execute(text(
"SELECT name, description, type, category, config FROM ai_applications WHERE id = :id AND is_template = 1"
), {"id": template_id}).fetchone()
if not row:
raise HTTPException(404, "Template not found")
new_id = gen_id()
now = cst_now()
custom_name = body.get("name", row[0] + " (Instance)")
db.execute(text(
"INSERT INTO ai_applications (id, name, description, type, category, tags, icon, "
"status, version, is_template, is_public, config, created_at, updated_at) "
"VALUES (:id, :name, :desc, :type, :cat, NULL, NULL, 'draft', 1, 0, 0, :config, :now, :now)"
), {
"id": new_id, "name": custom_name, "desc": row[1],
"type": row[2], "cat": row[3], "config": row[4], "now": now
})
version_id = gen_id()
db.execute(text(
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
"VALUES (:vid, :aid, 1, :config, :cl, :now)"
), {"vid": version_id, "aid": new_id, "config": row[4], "cl": "Created from template", "now": now})
db.commit()
return {"code": 0, "data": {"id": new_id, "message": "Application created from template"}}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
# ============================================================
# Categories
# ============================================================
@router.get("/categories")
async def list_categories():
"""获取应用分类统计"""
db = SessionLocal()
try:
rows = db.execute(text(
"SELECT category, COUNT(*) as cnt, "
"SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END) as published "
"FROM ai_applications WHERE is_template = 0 "
"GROUP BY category ORDER BY cnt DESC"
)).fetchall()
categories = []
category_names = {
"customer_service": "客服助手",
"data_analysis": "数据分析",
"coding": "编程辅助",
"writing": "内容创作",
"education": "教育培训",
"marketing": "营销推广",
"hr": "人力资源",
"finance": "财务法务",
"custom": "自定义"
}
for row in rows:
categories.append({
"key": row[0],
"name": category_names.get(row[0], row[0]),
"count": row[1],
"published": row[2]
})
return {"code": 0, "data": {"items": categories}}
finally:
db.close()
+357
View File
@@ -0,0 +1,357 @@
"""
资产中心管理 API(管理员)
Phase 3.8: Asset Center Enhancement - Admin asset management, analytics, activity log
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_, func, desc, text
from typing import Optional
import json
from datetime import datetime, timedelta
from ...database import get_db, SessionLocal
from ...models import Asset, AssetCategory, UserStorageQuota, User
from ...auth import get_current_admin
from ...middleware.log_service import LogService
router = APIRouter()
CATEGORY_LABELS = {
"file": "文件", "novel": "小说", "image": "图片",
"video": "视频", "audio": "音频", "custom": "自定义", "doc": "文档",
}
def _execute_raw(db, sql, params=None):
result = db.execute(text(sql), params or {})
try:
rows = result.fetchall()
return [dict(row._mapping) if hasattr(row, '_mapping') else dict(row) for row in rows]
except:
return []
def _asset_to_dict(a):
return {
"id": a.id,
"user_id": a.user_id,
"name": a.name,
"category": a.category,
"sub_category": a.sub_category,
"file_type": a.file_type,
"size": a.size or 0,
"description": a.description,
"cover_url": a.cover_url,
"url": a.url,
"status": a.status,
"created_at": a.created_at.strftime('%Y-%m-%d %H:%M:%S') if a.created_at else None,
"updated_at": a.updated_at.strftime('%Y-%m-%d %H:%M:%S') if a.updated_at else None,
}
# ============================================================
# Stats / Overview
# ============================================================
@router.get("/stats", summary="资产中心总览")
async def get_asset_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取资产中心总览数据"""
# Total assets
total_assets = _execute_raw(db, "SELECT COUNT(*) as cnt FROM assets WHERE status = 'active'")
total = total_assets[0]["cnt"] if total_assets else 0
# Total size
total_size = _execute_raw(db, "SELECT COALESCE(SUM(size), 0) as sz FROM assets WHERE status = 'active'")
size = total_size[0]["sz"] if total_size else 0
# By category
cat_stats = _execute_raw(db, """
SELECT category, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz
FROM assets WHERE status = 'active'
GROUP BY category
""")
# By file type
type_stats = _execute_raw(db, """
SELECT COALESCE(file_type, 'unknown') as ft, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz
FROM assets WHERE status = 'active'
GROUP BY file_type
ORDER BY cnt DESC
LIMIT 10
""")
# Top users by storage
top_users = _execute_raw(db, """
SELECT a.user_id, u.username, u.avatar,
COUNT(*) as asset_count, COALESCE(SUM(a.size), 0) as total_size
FROM assets a
LEFT JOIN users u ON a.user_id = u.id
WHERE a.status = 'active'
GROUP BY a.user_id, u.username, u.avatar
ORDER BY total_size DESC
LIMIT 10
""")
# Storage quota summary
quota_stats = _execute_raw(db, """
SELECT
COUNT(*) as total_users,
COALESCE(SUM(quota_bytes), 0) as total_quota,
COALESCE(SUM(used_bytes), 0) as total_used
FROM user_storage_quotas
""")
# Recent 7 days trend
trend = []
for i in range(6, -1, -1):
date = (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d')
new_uploads = _execute_raw(db, """
SELECT COUNT(*) as cnt FROM assets
WHERE DATE(created_at) = :d AND status = 'active'
""", {"d": date})
trend.append({
"date": date,
"new_uploads": new_uploads[0]["cnt"] if new_uploads else 0,
})
# Categories list
categories = _execute_raw(db, """
SELECT type, COUNT(*) as cnt FROM asset_categories GROUP BY type
""")
return {
"code": 0,
"data": {
"total_assets": total,
"total_size": size,
"total_size_formatted": _format_size(size),
"by_category": [{
"category": r["category"],
"label": CATEGORY_LABELS.get(r["category"], r["category"]),
"count": r["cnt"],
"size": r["sz"],
"size_formatted": _format_size(r["sz"]),
} for r in cat_stats],
"by_file_type": [{
"file_type": r["ft"],
"count": r["cnt"],
"size": r["sz"],
"size_formatted": _format_size(r["sz"]),
} for r in type_stats],
"top_users": [{
"user_id": r["user_id"],
"username": r["username"] or "Unknown",
"avatar": r["avatar"],
"asset_count": r["asset_count"],
"total_size": r["total_size"],
"total_size_formatted": _format_size(r["total_size"]),
} for r in top_users],
"quota_summary": {
"total_users": quota_stats[0]["total_users"] if quota_stats else 0,
"total_quota": quota_stats[0]["total_quota"] if quota_stats else 0,
"total_used": quota_stats[0]["total_used"] if quota_stats else 0,
"total_quota_formatted": _format_size(quota_stats[0]["total_quota"]) if quota_stats else "0",
"total_used_formatted": _format_size(quota_stats[0]["total_used"]) if quota_stats else "0",
"usage_percent": round((quota_stats[0]["total_used"] / quota_stats[0]["total_quota"] * 100), 2) if quota_stats and quota_stats[0]["total_quota"] > 0 else 0,
},
"trend_7d": trend,
"categories": [{"type": r["type"], "count": r["cnt"]} for r in categories],
}
}
def _format_size(bytes_val):
if not bytes_val or bytes_val == 0:
return "0 B"
if bytes_val < 1024:
return f"{bytes_val} B"
if bytes_val < 1024 * 1024:
return f"{bytes_val / 1024:.1f} KB"
if bytes_val < 1024 * 1024 * 1024:
return f"{bytes_val / (1024 * 1024):.1f} MB"
return f"{bytes_val / (1024 * 1024 * 1024):.2f} GB"
# ============================================================
# Asset list & management
# ============================================================
@router.get("/", summary="List all assets")
async def list_all_assets(
user_id: str = Query(None),
category: str = Query(None),
file_type: str = Query(None),
keyword: str = Query(None),
status: str = Query("active"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""List all user assets"""
where_clauses = []
params = {}
if status:
where_clauses.append("a.status = :status")
params["status"] = status
if user_id:
where_clauses.append("a.user_id = :uid")
params["uid"] = user_id
if category:
where_clauses.append("a.category = :cat")
params["cat"] = category
if file_type:
where_clauses.append("a.file_type = :ft")
params["ft"] = file_type
if keyword:
where_clauses.append("(a.name LIKE :kw OR a.description LIKE :kw)")
params["kw"] = "%" + keyword + "%"
where_sql = " AND ".join(where_clauses)
where_clause = "WHERE " + where_sql if where_sql else ""
count_sql = "SELECT COUNT(*) as cnt FROM assets a " + where_clause
count_rows = _execute_raw(db, count_sql, params)
total = count_rows[0]["cnt"] if count_rows else 0
offset = (page - 1) * page_size
list_sql = "SELECT a.id, a.user_id, a.name, a.category, a.sub_category, a.file_type, a.size, a.description, a.cover_url, a.url, a.status, a.created_at, a.updated_at, u.username, u.avatar FROM assets a LEFT JOIN users u ON a.user_id = u.id " + where_clause + " ORDER BY a.created_at DESC LIMIT :limit OFFSET :offset"
list_params = dict(params)
list_params["limit"] = page_size
list_params["offset"] = offset
rows = _execute_raw(db, list_sql, list_params)
items = []
for r in rows:
items.append({
"id": r["id"], "user_id": r["user_id"], "name": r["name"],
"category": r["category"], "sub_category": r["sub_category"],
"file_type": r["file_type"], "size": r["size"] or 0,
"size_formatted": _format_size(r["size"] or 0),
"description": r["description"], "cover_url": r["cover_url"],
"url": r["url"], "status": r["status"],
"username": r["username"] or "Unknown", "avatar": r["avatar"],
"created_at": r["created_at"].strftime("%Y-%m-%d %H:%M:%S") if r["created_at"] else None,
"updated_at": r["updated_at"].strftime("%Y-%m-%d %H:%M:%S") if r["updated_at"] else None,
})
return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}}
@router.delete("/{asset_id}", summary="管理员删除资产")
async def admin_delete_asset(
asset_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""管理员删除用户资产"""
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(status_code=404, detail="资产不存在")
asset_name = asset.name
asset.status = "deleted"
asset.updated_at = datetime.now()
db.commit()
LogService.log_action(db, "admin", admin_user["id"], "delete_asset", f"Deleted asset: {asset_name}")
return {"code": 0, "message": "资产已删除"}
@router.get("/activity", summary="最近资产活动")
async def get_asset_activity(
limit: int = Query(30, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取最近的资产活动"""
activities = _execute_raw(db, """
SELECT a.id, a.name, a.category, a.file_type, a.size, a.status,
a.user_id, u.username, u.avatar,
a.created_at, a.updated_at
FROM assets a
LEFT JOIN users u ON a.user_id = u.id
ORDER BY a.updated_at DESC
LIMIT :lim
""", {"lim": limit})
return {
"code": 0,
"data": [{
"id": r["id"],
"name": r["name"],
"category": r["category"],
"category_label": CATEGORY_LABELS.get(r["category"], r["category"]),
"file_type": r["file_type"],
"size": r["size"] or 0,
"size_formatted": _format_size(r["size"] or 0),
"status": r["status"],
"user_id": r["user_id"],
"username": r["username"] or "Unknown",
"avatar": r["avatar"],
"created_at": r["created_at"].strftime('%Y-%m-%d %H:%M:%S') if r["created_at"] else None,
"updated_at": r["updated_at"].strftime('%Y-%m-%d %H:%M:%S') if r["updated_at"] else None,
} for r in activities]
}
@router.get("/users/{user_id}/detail", summary="用户资产详情")
async def get_user_asset_detail(
user_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取指定用户的资产详情"""
# User info
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# Asset stats
stats = _execute_raw(db, """
SELECT category, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz
FROM assets WHERE user_id = :uid AND status = 'active'
GROUP BY category
""", {"uid": user_id})
total = _execute_raw(db, """
SELECT COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz
FROM assets WHERE user_id = :uid AND status = 'active'
""", {"uid": user_id})
# Quota
quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first()
return {
"code": 0,
"data": {
"user": {
"id": user.id,
"username": user.username,
"avatar": user.avatar,
"membership_level": user.membership_level,
},
"total_assets": total[0]["cnt"] if total else 0,
"total_size": total[0]["sz"] if total else 0,
"total_size_formatted": _format_size(total[0]["sz"]) if total else "0 B",
"by_category": [{
"category": r["category"],
"label": CATEGORY_LABELS.get(r["category"], r["category"]),
"count": r["cnt"],
"size": r["sz"],
"size_formatted": _format_size(r["sz"]),
} for r in stats],
"quota": {
"quota_bytes": quota.quota_bytes if quota else 0,
"used_bytes": quota.used_bytes if quota else 0,
"quota_formatted": _format_size(quota.quota_bytes) if quota else "0 B",
"used_formatted": _format_size(quota.used_bytes) if quota else "0 B",
"usage_percent": round((quota.used_bytes / quota.quota_bytes * 100), 2) if quota and quota.quota_bytes > 0 else 0,
} if quota else None,
}
}
@@ -0,0 +1,662 @@
"""
自动化任务调度管理 API
Phase 3.2: 支持定时任务(cron)和条件触发(webhook/event)的自动化调度
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Body, BackgroundTasks
from sqlalchemy import or_, func, desc, asc, text
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
import uuid
import json
import logging
import asyncio
from ...database import get_db
from ...models import (
gen_uuid,
)
from ...auth import get_current_admin
from ...middleware.log_service import LogService
router = APIRouter()
logger = logging.getLogger("aiflow")
# ============================================================
# Pydantic Models
# ============================================================
class AutomationTaskCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: Optional[str] = ""
trigger_type: str = Field(default="cron", description="cron / webhook / event")
cron_expression: Optional[str] = None # e.g. "*/5 * * * *"
event_condition: Optional[Dict[str, Any]] = None # for event/webhook triggers
action_type: str = Field(default="ai_pipeline", description="ai_pipeline / webhook / notification / skill_execute")
action_config: Dict[str, Any] = Field(default_factory=dict)
is_enabled: bool = True
max_retries: int = Field(default=3, ge=0, le=10)
timeout_seconds: int = Field(default=300, ge=10, le=3600)
class AutomationTaskUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
trigger_type: Optional[str] = None
cron_expression: Optional[str] = None
event_condition: Optional[Dict[str, Any]] = None
action_type: Optional[str] = None
action_config: Optional[Dict[str, Any]] = None
is_enabled: Optional[bool] = None
max_retries: Optional[int] = Field(None, ge=0, le=10)
timeout_seconds: Optional[int] = Field(None, ge=10, le=3600)
# ============================================================
# Helper Functions
# ============================================================
TRIGGER_TYPE_MAP = {
"cron": "定时触发",
"webhook": "Webhook",
"event": "事件触发",
}
ACTION_TYPE_MAP = {
"ai_pipeline": "AI流水线",
"webhook": "Webhook回调",
"notification": "发送通知",
"skill_execute": "技能执行",
}
def task_to_dict(task) -> dict:
"""将AutomatedTask模型转换为字典"""
result = {
"id": task.id,
"name": task.name,
"description": getattr(task, "description", "") or "",
"trigger_type": task.trigger_type,
"trigger_type_label": TRIGGER_TYPE_MAP.get(task.trigger_type, task.trigger_type),
"cron_expression": getattr(task, "cron_expression", "") or "",
"action_type": task.action_type,
"action_type_label": ACTION_TYPE_MAP.get(task.action_type, task.action_type),
"is_enabled": task.is_enabled,
"status": task.status or "idle",
"max_retries": getattr(task, "max_retries", 3),
"timeout_seconds": getattr(task, "timeout_seconds", 300),
"last_run_at": task.last_run_at.isoformat() if task.last_run_at else None,
"next_run_at": task.next_run_at.isoformat() if task.next_run_at else None,
"total_runs": getattr(task, "total_runs", 0),
"success_runs": getattr(task, "success_runs", 0),
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
}
# Action config
action_config = getattr(task, "action_config", None)
if action_config:
try:
result["action_config"] = json.loads(action_config) if isinstance(action_config, str) else action_config
except Exception:
result["action_config"] = {}
else:
result["action_config"] = {}
# Event condition
event_condition = getattr(task, "event_condition", None)
if event_condition:
try:
result["event_condition"] = json.loads(event_condition) if isinstance(event_condition, str) else event_condition
except Exception:
result["event_condition"] = {}
else:
result["event_condition"] = {}
return result
def log_to_dict(log) -> dict:
"""将TaskExecutionLog转换为字典"""
return {
"id": log.id,
"task_id": log.task_id,
"task_name": getattr(log, "task_name", ""),
"status": log.status,
"trigger_type": getattr(log, "trigger_type", ""),
"started_at": log.started_at.isoformat() if log.started_at else None,
"finished_at": log.finished_at.isoformat() if log.finished_at else None,
"duration_ms": getattr(log, "duration_ms", 0),
"result_summary": getattr(log, "result_summary", "") or "",
"error_message": getattr(log, "error_message", "") or "",
"created_at": log.created_at.isoformat() if log.created_at else None,
}
def calculate_next_run(cron_expression: str) -> Optional[datetime]:
"""计算cron表达式的下次执行时间(简化版)"""
if not cron_expression:
return None
parts = cron_expression.strip().split()
if len(parts) != 5:
return None
now = datetime.now()
try:
minute, hour, day, month, weekday = parts
# Handle wildcards
next_run = now.replace(second=0, microsecond=0)
if minute != "*":
target_min = int(minute)
if target_min > now.minute:
next_run = next_run.replace(minute=target_min)
else:
next_run = next_run.replace(minute=target_min) + timedelta(hours=1)
if hour != "*":
target_hour = int(hour)
if target_hour < next_run.hour or (target_hour == next_run.hour and next_run.minute <= now.minute):
next_run = next_run.replace(hour=target_hour, minute=int(minute) if minute != "*" else 0) + timedelta(days=1)
else:
next_run = next_run.replace(hour=target_hour)
# Handle */N patterns for minute
if minute.startswith("*/"):
interval = int(minute[2:])
next_minute = ((now.minute // interval) + 1) * interval
if next_minute >= 60:
next_run = next_run.replace(minute=0) + timedelta(hours=1)
else:
next_run = next_run.replace(minute=next_minute)
if hour != "*":
next_run = next_run.replace(hour=int(hour))
# Handle */N patterns for hour
if hour.startswith("*/"):
interval = int(hour[2:])
next_hour = ((now.hour // interval) + 1) * interval
if next_hour >= 24:
next_run = next_run.replace(hour=0, minute=int(minute) if minute != "*" and not minute.startswith("*/") else 0) + timedelta(days=1)
else:
next_run = next_run.replace(hour=next_hour, minute=int(minute) if minute != "*" and not minute.startswith("*/") else 0)
if next_run <= now:
next_run += timedelta(minutes=1)
return next_run
except Exception as e:
logger.warning(f"Failed to calculate next run for '{cron_expression}': {e}")
return None
# ============================================================
# API Endpoints
# ============================================================
@router.get("/stats/overview", summary="自动化任务统计概览")
async def get_automation_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取自动化任务的统计信息"""
from ...models import AutomatedTask, TaskExecutionLog
total_tasks = db.query(func.count(AutomatedTask.id)).scalar() or 0
enabled_tasks = db.query(func.count(AutomatedTask.id)).filter(
AutomatedTask.is_enabled == True
).scalar() or 0
running_tasks = db.query(func.count(AutomatedTask.id)).filter(
AutomatedTask.status == "running"
).scalar() or 0
# By trigger type
by_trigger = {}
triggers = db.query(AutomatedTask.trigger_type, func.count(AutomatedTask.id)).group_by(
AutomatedTask.trigger_type
).all()
for t, c in triggers:
by_trigger[t] = c
# Recent execution stats (last 24h)
since = datetime.now() - timedelta(hours=24)
total_executions = db.query(func.count(TaskExecutionLog.id)).filter(
TaskExecutionLog.created_at >= since
).scalar() or 0
success_executions = db.query(func.count(TaskExecutionLog.id)).filter(
TaskExecutionLog.created_at >= since,
TaskExecutionLog.status == "success"
).scalar() or 0
return {
"code": 0,
"data": {
"total_tasks": total_tasks,
"enabled_tasks": enabled_tasks,
"running_tasks": running_tasks,
"by_trigger": by_trigger,
"recent_executions": total_executions,
"recent_success": success_executions,
},
"message": "ok"
}
@router.get("/", summary="获取自动化任务列表")
async def list_automation_tasks(
trigger_type: str = Query(None),
is_enabled: bool = Query(None),
keyword: str = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取自动化任务列表"""
from ...models import AutomatedTask
query = db.query(AutomatedTask)
if trigger_type:
query = query.filter(AutomatedTask.trigger_type == trigger_type)
if is_enabled is not None:
query = query.filter(AutomatedTask.is_enabled == is_enabled)
if keyword:
keyword_filter = or_(
AutomatedTask.name.ilike(f"%{keyword}%"),
AutomatedTask.description.ilike(f"%{keyword}%"),
)
query = query.filter(keyword_filter)
total = query.count()
items = query.order_by(desc(AutomatedTask.updated_at)) \
.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"items": [task_to_dict(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
},
"message": "ok"
}
@router.get("/{task_id}", summary="获取任务详情")
async def get_automation_task(
task_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取单个自动化任务详情"""
from ...models import AutomatedTask
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return {
"code": 0,
"data": task_to_dict(task),
"message": "ok"
}
@router.post("/", summary="创建自动化任务")
async def create_automation_task(
req: AutomationTaskCreate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建新的自动化任务"""
from ...models import AutomatedTask
# 检查名称重复
existing = db.query(AutomatedTask).filter(AutomatedTask.name == req.name).first()
if existing:
raise HTTPException(status_code=400, detail="任务名称已存在")
# 验证cron表达式格式
if req.trigger_type == "cron" and req.cron_expression:
parts = req.cron_expression.strip().split()
if len(parts) != 5:
raise HTTPException(status_code=400, detail="Cron表达式格式错误,需要5个字段")
next_run = None
if req.trigger_type == "cron" and req.cron_expression:
next_run = calculate_next_run(req.cron_expression)
task = AutomatedTask(
id=gen_uuid(),
name=req.name,
description=req.description or "",
trigger_type=req.trigger_type,
cron_expression=req.cron_expression,
action_type=req.action_type,
action_config=req.action_config,
is_enabled=req.is_enabled,
status="idle",
max_retries=req.max_retries,
timeout_seconds=req.timeout_seconds,
next_run_at=next_run,
event_condition=req.event_condition,
total_runs=0,
success_runs=0,
)
db.add(task)
db.commit()
db.refresh(task)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_automation_task",
target_type="automated_task",
target_id=task.id,
detail=f"创建了自动化任务: {task.name}"
)
return {
"code": 0,
"data": task_to_dict(task),
"message": "创建成功"
}
@router.put("/{task_id}", summary="更新自动化任务")
async def update_automation_task(
task_id: str,
req: AutomationTaskUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新自动化任务"""
from ...models import AutomatedTask
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# 名称重复检查
if req.name is not None and req.name != task.name:
existing = db.query(AutomatedTask).filter(
AutomatedTask.name == req.name,
AutomatedTask.id != task_id
).first()
if existing:
raise HTTPException(status_code=400, detail="任务名称已存在")
update_fields = ["name", "description", "trigger_type", "cron_expression",
"action_type", "is_enabled", "max_retries", "timeout_seconds"]
for field in update_fields:
value = getattr(req, field, None)
if value is not None:
setattr(task, field, value)
if req.action_config is not None:
task.action_config = req.action_config
if req.event_condition is not None:
task.event_condition = req.event_condition
# 重新计算下次执行时间
if req.cron_expression is not None or req.trigger_type is not None:
cron = req.cron_expression if req.cron_expression is not None else task.cron_expression
trigger = req.trigger_type if req.trigger_type is not None else task.trigger_type
if trigger == "cron" and cron:
task.next_run_at = calculate_next_run(cron)
task.updated_at = datetime.now()
db.commit()
db.refresh(task)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_automation_task",
target_type="automated_task",
target_id=task.id,
detail=f"更新了自动化任务: {task.name}"
)
return {
"code": 0,
"data": task_to_dict(task),
"message": "更新成功"
}
@router.delete("/{task_id}", summary="删除自动化任务")
async def delete_automation_task(
task_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除自动化任务"""
from ...models import AutomatedTask
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
name = task.name
db.delete(task)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_automation_task",
target_type="automated_task",
target_id=task_id,
detail=f"删除了自动化任务: {name}"
)
return {
"code": 0,
"data": None,
"message": f"已删除任务: {name}"
}
@router.put("/{task_id}/toggle", summary="启用/禁用自动化任务")
async def toggle_automation_task(
task_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""切换任务启用/禁用状态"""
from ...models import AutomatedTask
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
task.is_enabled = not task.is_enabled
if not task.is_enabled:
task.next_run_at = None
elif task.trigger_type == "cron" and task.cron_expression:
task.next_run_at = calculate_next_run(task.cron_expression)
task.updated_at = datetime.now()
db.commit()
db.refresh(task)
action = "启用" if task.is_enabled else "禁用"
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="toggle_automation_task",
target_type="automated_task",
target_id=task.id,
detail=f"{action}了自动化任务: {task.name}"
)
return {
"code": 0,
"data": task_to_dict(task),
"message": f"已{action}"
}
@router.post("/{task_id}/execute", summary="手动执行任务")
async def execute_task_now(
task_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""立即手动执行一个自动化任务"""
from ...models import AutomatedTask, TaskExecutionLog
task = db.query(AutomatedTask).filter(AutomatedTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status == "running":
raise HTTPException(status_code=400, detail="任务正在执行中")
# 创建执行日志
exec_log = TaskExecutionLog(
id=gen_uuid(),
task_id=task.id,
task_name=task.name,
status="running",
trigger_type="manual",
started_at=datetime.now(),
)
db.add(exec_log)
# 更新任务状态
task.status = "running"
task.total_runs = (task.total_runs or 0) + 1
db.commit()
db.refresh(exec_log)
# 异步执行(简化版: 直接模拟执行)
import time
start = time.time()
try:
action_type = task.action_type
action_config = task.action_config or {}
# 模拟执行不同类型的操作
result_summary = ""
if action_type == "ai_pipeline":
result_summary = f"AI流水线执行完成: 处理了 {action_config.get('pipeline_name', 'default')} 管道"
elif action_type == "webhook":
url = action_config.get("url", "")
result_summary = f"Webhook调用完成: {url}"
elif action_type == "notification":
result_summary = f"通知发送完成: {action_config.get('channel', 'system')}"
elif action_type == "skill_execute":
result_summary = f"技能执行完成: {action_config.get('skill_name', 'unknown')}"
else:
result_summary = f"任务执行完成: {action_type}"
duration = int((time.time() - start) * 1000)
# 更新执行日志
exec_log.status = "success"
exec_log.finished_at = datetime.now()
exec_log.duration_ms = duration
exec_log.result_summary = result_summary
# 更新任务
task.status = "idle"
task.last_run_at = datetime.now()
task.success_runs = (task.success_runs or 0) + 1
if task.trigger_type == "cron" and task.cron_expression:
task.next_run_at = calculate_next_run(task.cron_expression)
db.commit()
return {
"code": 0,
"data": log_to_dict(exec_log),
"message": "执行成功"
}
except Exception as e:
duration = int((time.time() - start) * 1000)
exec_log.status = "failed"
exec_log.finished_at = datetime.now()
exec_log.duration_ms = duration
exec_log.error_message = str(e)
task.status = "idle"
db.commit()
raise HTTPException(status_code=500, detail=f"执行失败: {str(e)}")
@router.get("/{task_id}/history", summary="获取任务执行历史")
async def get_task_history(
task_id: str,
status: str = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取任务的执行历史记录"""
from ...models import TaskExecutionLog
query = db.query(TaskExecutionLog).filter(TaskExecutionLog.task_id == task_id)
if status:
query = query.filter(TaskExecutionLog.status == status)
total = query.count()
items = query.order_by(desc(TaskExecutionLog.created_at)) \
.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"items": [log_to_dict(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
},
"message": "ok"
}
@router.get("/history/all", summary="获取所有执行历史")
async def get_all_history(
status: str = Query(None),
task_id: str = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取所有任务的执行历史"""
from ...models import TaskExecutionLog
query = db.query(TaskExecutionLog)
if status:
query = query.filter(TaskExecutionLog.status == status)
if task_id:
query = query.filter(TaskExecutionLog.task_id == task_id)
total = query.count()
items = query.order_by(desc(TaskExecutionLog.created_at)) \
.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"items": [log_to_dict(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
},
"message": "ok"
}
@@ -0,0 +1,462 @@
"""
计费与支付管理后端 API
- 计费规则、充值套餐、交易记录、账单管理、支付配置
- 路由前缀: /app/api/admin/billing
"""
import uuid
import json
from datetime import datetime, timedelta
from fastapi import APIRouter, Query, HTTPException, Depends
from ...database import SessionLocal, get_db
from ..auth import get_current_user
from sqlalchemy import text
from sqlalchemy.orm import Session
router = APIRouter()
CST_TZ = None
def _format_cst_time(dt_val):
global CST_TZ
if CST_TZ is None:
from datetime import timezone
CST_TZ = timezone(timedelta(hours=8))
if dt_val is None:
return None
if isinstance(dt_val, str):
return dt_val
if dt_val.tzinfo is None:
dt_val = dt_val.replace(tzinfo=CST_TZ)
return dt_val.strftime("%Y-%m-%d %H:%M:%S")
def _row_to_dict(row, columns):
return {col: _format_cst_time(val) if isinstance(val, datetime) else val for col, val in zip(columns, row)}
# ============================================================
# 计费规则
# ============================================================
@router.get("/rules")
async def list_billing_rules(
status: str = Query("", description="状态筛选"),
billing_type: str = Query("", description="计费类型筛选"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""获取计费规则列表"""
where = "1=1"
params = {}
if status:
where += " AND status=:status"
params["status"] = status
if billing_type:
where += " AND billing_type=:bt"
params["bt"] = billing_type
total = db.execute(text(f"SELECT COUNT(*) FROM billing_rules WHERE {where}"), params).scalar()
offset = (page - 1) * page_size
rows = db.execute(text(f"""
SELECT * FROM billing_rules WHERE {where}
ORDER BY created_at DESC LIMIT :limit OFFSET :offset
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
cols = ['id','name','description','billing_type','model_id','model_name','rate','currency',
'min_charge','free_quota','tiered_pricing','status','created_at','updated_at']
items = [_row_to_dict(r, cols) for r in rows]
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
@router.post("/rules/create")
async def create_billing_rule(
name: str = Query(...),
description: str = Query(""),
billing_type: str = Query("per_token"),
model_id: str = Query(""),
model_name: str = Query(""),
rate: float = Query(0.0),
min_charge: float = Query(0.01),
free_quota: int = Query(0),
tiered_pricing: str = Query("", description="阶梯定价JSON"),
status: str = Query("active"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""创建计费规则"""
rid = str(uuid.uuid4())
tp = None
if tiered_pricing:
try:
tp = json.loads(tiered_pricing)
except:
tp = None
db.execute(text("""
INSERT INTO billing_rules (id, name, description, billing_type, model_id, model_name, rate,
min_charge, free_quota, tiered_pricing, status)
VALUES (:id, :name, :desc, :bt, :mid, :mname, :rate, :mc, :fq, :tp, :status)
"""), {
"id": rid, "name": name, "desc": description, "bt": billing_type,
"mid": model_id or None, "mname": model_name or None, "rate": rate,
"mc": min_charge, "fq": free_quota, "tp": json.dumps(tp) if tp else None,
"status": status
})
db.commit()
return {"code": 200, "data": {"id": rid, "message": "计费规则创建成功"}}
@router.put("/rules/{rule_id}")
async def update_billing_rule(
rule_id: str,
name: str = Query(None),
description: str = Query(None),
rate: float = Query(None),
min_charge: float = Query(None),
free_quota: int = Query(None),
status: str = Query(None),
tiered_pricing: str = Query(None),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""更新计费规则"""
sets = []
params = {"id": rule_id}
for field, val in [("name", name), ("description", description), ("rate", rate),
("min_charge", min_charge), ("free_quota", free_quota), ("status", status)]:
if val is not None:
sets.append(f"{field}=:{field}")
params[field] = val
if tiered_pricing is not None:
sets.append("tiered_pricing=:tp")
try:
params["tp"] = json.dumps(json.loads(tiered_pricing))
except:
params["tp"] = None
if not sets:
return {"code": 200, "data": {"message": "无更新"}}
db.execute(text(f"UPDATE billing_rules SET {','.join(sets)} WHERE id=:id"), params)
db.commit()
return {"code": 200, "data": {"message": "更新成功"}}
@router.delete("/rules/{rule_id}")
async def delete_billing_rule(rule_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""删除计费规则"""
db.execute(text("UPDATE billing_rules SET status='inactive' WHERE id=:id"), {"id": rule_id})
db.commit()
return {"code": 200, "data": {"message": "规则已停用"}}
# ============================================================
# 充值套餐
# ============================================================
@router.get("/packages")
async def list_packages(
status: str = Query(""),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""获取充值套餐列表"""
where = "1=1"
params = {}
if status:
where += " AND status=:status"
params["status"] = status
rows = db.execute(text(f"""
SELECT * FROM recharge_packages WHERE {where}
ORDER BY sort_order ASC, price ASC
"""), params).fetchall()
cols = ['id','name','description','tokens','bonus_tokens','price','original_price',
'currency','valid_days','is_popular','sort_order','status','created_at','updated_at']
items = [_row_to_dict(r, cols) for r in rows]
return {"code": 200, "data": items}
@router.post("/packages/create")
async def create_package(
name: str = Query(...),
description: str = Query(""),
tokens: int = Query(...),
bonus_tokens: int = Query(0),
price: float = Query(...),
original_price: float = Query(0),
valid_days: int = Query(365),
is_popular: int = Query(0),
sort_order: int = Query(0),
status: str = Query("active"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""创建充值套餐"""
pid = str(uuid.uuid4())
db.execute(text("""
INSERT INTO recharge_packages (id, name, description, tokens, bonus_tokens, price, original_price,
valid_days, is_popular, sort_order, status)
VALUES (:id, :name, :desc, :tokens, :bonus, :price, :oprice, :vd, :pop, :sort, :status)
"""), {
"id": pid, "name": name, "desc": description, "tokens": tokens, "bonus": bonus_tokens,
"price": price, "oprice": original_price or None, "vd": valid_days,
"pop": is_popular, "sort": sort_order, "status": status
})
db.commit()
return {"code": 200, "data": {"id": pid, "message": "套餐创建成功"}}
@router.put("/packages/{package_id}")
async def update_package(
package_id: str,
name: str = Query(None),
price: float = Query(None),
tokens: int = Query(None),
bonus_tokens: int = Query(None),
status: str = Query(None),
is_popular: int = Query(None),
sort_order: int = Query(None),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""更新充值套餐"""
sets = []
params = {"id": package_id}
for field, val in [("name", name), ("price", price), ("tokens", tokens),
("bonus_tokens", bonus_tokens), ("status", status),
("is_popular", is_popular), ("sort_order", sort_order)]:
if val is not None:
sets.append(f"{field}=:{field}")
params[field] = val
if not sets:
return {"code": 200, "data": {"message": "无更新"}}
db.execute(text(f"UPDATE recharge_packages SET {','.join(sets)} WHERE id=:id"), params)
db.commit()
return {"code": 200, "data": {"message": "更新成功"}}
@router.delete("/packages/{package_id}")
async def delete_package(package_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""删除充值套餐"""
db.execute(text("UPDATE recharge_packages SET status='inactive' WHERE id=:id"), {"id": package_id})
db.commit()
return {"code": 200, "data": {"message": "套餐已停用"}}
# ============================================================
# 交易记录
# ============================================================
@router.get("/transactions")
async def list_transactions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str = Query("", description="用户筛选"),
transaction_type: str = Query("", description="类型筛选"),
start_date: str = Query("", description="开始日期"),
end_date: str = Query("", description="结束日期"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""获取交易记录"""
where = "1=1"
params = {}
if user_id:
where += " AND bt.user_id=:uid"
params["uid"] = user_id
if transaction_type:
where += " AND bt.transaction_type=:tt"
params["tt"] = transaction_type
if start_date:
where += " AND bt.created_at>=:sd"
params["sd"] = start_date
if end_date:
where += " AND bt.created_at<=:ed"
params["ed"] = end_date + " 23:59:59"
total = db.execute(text(f"SELECT COUNT(*) FROM billing_transactions bt WHERE {where}"), params).scalar()
offset = (page - 1) * page_size
rows = db.execute(text(f"""
SELECT bt.*, u.username
FROM billing_transactions bt
LEFT JOIN users u ON bt.user_id = u.id
WHERE {where}
ORDER BY bt.created_at DESC LIMIT :limit OFFSET :offset
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
cols = ['id','tenant_id','user_id','order_id','transaction_type','amount','tokens',
'balance_after','model_id','model_name','description','metadata','status',
'created_at','username']
items = [_row_to_dict(r, cols) for r in rows]
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
# ============================================================
# 账单管理
# ============================================================
@router.get("/invoices")
async def list_invoices(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str = Query(""),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""获取账单列表"""
where = "1=1"
params = {}
if status:
where += " AND status=:status"
params["status"] = status
total = db.execute(text(f"SELECT COUNT(*) FROM invoices WHERE {where}"), params).scalar()
offset = (page - 1) * page_size
rows = db.execute(text(f"""
SELECT i.*, u.username
FROM invoices i
LEFT JOIN users u ON i.user_id = u.id
WHERE {where}
ORDER BY i.created_at DESC LIMIT :limit OFFSET :offset
"""), {**params, "limit": page_size, "offset": offset}).fetchall()
cols = ['id','invoice_number','tenant_id','user_id','period_start','period_end',
'subtotal','discount','tax','total','items','status','issued_at','paid_at',
'due_at','payment_method','created_at','updated_at','username']
items = [_row_to_dict(r, cols) for r in rows]
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
@router.post("/invoices/generate")
async def generate_invoice(
user_id: str = Query(..., description="用户ID"),
period_start: str = Query(..., description="周期开始"),
period_end: str = Query(..., description="周期结束"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""生成账单"""
# Calculate usage from token_usage_logs
usage = db.execute(text("""
SELECT model_name, SUM(total_tokens) as tokens, SUM(cost) as total_cost
FROM token_usage_logs
WHERE user_id=:uid AND created_at>=:ps AND created_at<=:pe
GROUP BY model_name
"""), {"uid": user_id, "ps": period_start, "pe": period_end}).fetchall()
if not usage:
return {"code": 200, "data": {"message": "该周期内无使用记录"}}
items = []
subtotal = 0
for row in usage:
model_name = row[0] or "未知模型"
tokens = row[1] or 0
cost = float(row[2] or 0)
items.append({"name": model_name, "quantity": tokens, "unit_price": cost / tokens if tokens > 0 else 0, "amount": cost})
subtotal += cost
invoice_number = f"INV-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
iid = str(uuid.uuid4())
db.execute(text("""
INSERT INTO invoices (id, invoice_number, user_id, period_start, period_end, subtotal, total, items, status)
VALUES (:id, :num, :uid, :ps, :pe, :sub, :total, :items, 'issued')
"""), {
"id": iid, "num": invoice_number, "uid": user_id,
"ps": period_start, "pe": period_end, "sub": subtotal,
"total": subtotal, "items": json.dumps(items)
})
db.commit()
return {"code": 200, "data": {"id": iid, "invoice_number": invoice_number, "total": subtotal, "items": items}}
# ============================================================
# 支付配置 (扩展现有 payment_configs)
# ============================================================
@router.get("/payment-configs")
async def list_payment_configs(db: Session = Depends(get_db), user=Depends(get_current_user)):
"""获取支付配置列表"""
rows = db.execute(text("SELECT * FROM payment_configs ORDER BY created_at DESC")).fetchall()
cols = ['id','type','app_id','merchant_id','api_key','cert_path','gateway_url','status',
'created_at','updated_at','api_v3_key','cert_serial_no','alipay_public_key',
'app_private_key','notify_url','return_url']
items = []
for r in rows:
d = _row_to_dict(r, cols)
# Mask sensitive fields
d['api_key'] = '***' if d.get('api_key') else None
d['api_v3_key'] = '***' if d.get('api_v3_key') else None
d['app_private_key'] = '***' if d.get('app_private_key') else None
items.append(d)
return {"code": 200, "data": items}
@router.put("/payment-configs/{config_id}")
async def update_payment_config(
config_id: str,
status: str = Query(None),
app_id: str = Query(None),
merchant_id: str = Query(None),
gateway_url: str = Query(None),
notify_url: str = Query(None),
return_url: str = Query(None),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""更新支付配置"""
sets = []
params = {"id": config_id}
for field, val in [("status", status), ("app_id", app_id), ("merchant_id", merchant_id),
("gateway_url", gateway_url), ("notify_url", notify_url), ("return_url", return_url)]:
if val is not None:
sets.append(f"{field}=:{field}")
params[field] = val
if not sets:
return {"code": 200, "data": {"message": "无更新"}}
db.execute(text(f"UPDATE payment_configs SET {','.join(sets)} WHERE id=:id"), params)
db.commit()
return {"code": 200, "data": {"message": "支付配置已更新"}}
# ============================================================
# 统计
# ============================================================
@router.get("/stats/overview")
async def billing_stats(db: Session = Depends(get_db), user=Depends(get_current_user)):
"""计费统计概览"""
total_revenue = db.execute(text("""
SELECT COALESCE(SUM(amount), 0) FROM billing_transactions
WHERE transaction_type='recharge' AND status='completed'
""")).scalar() or 0
total_consumption = db.execute(text("""
SELECT COALESCE(SUM(amount), 0) FROM billing_transactions
WHERE transaction_type='consumption' AND status='completed'
""")).scalar() or 0
total_transactions = db.execute(text("SELECT COUNT(*) FROM billing_transactions")).scalar()
pending_invoices = db.execute(text("SELECT COUNT(*) FROM invoices WHERE status IN ('issued','overdue')")).scalar()
paid_invoices = db.execute(text("SELECT COUNT(*) FROM invoices WHERE status='paid'")).scalar()
active_rules = db.execute(text("SELECT COUNT(*) FROM billing_rules WHERE status='active'")).scalar()
active_packages = db.execute(text("SELECT COUNT(*) FROM recharge_packages WHERE status='active'")).scalar()
# Revenue by day (last 7 days)
revenue_7d = db.execute(text("""
SELECT DATE(created_at) as d, COALESCE(SUM(amount), 0) as rev
FROM billing_transactions
WHERE transaction_type='recharge' AND status='completed'
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY DATE(created_at) ORDER BY d
""")).fetchall()
daily_revenue = [{"date": str(r[0]), "revenue": float(r[1])} for r in revenue_7d]
return {"code": 200, "data": {
"total_revenue": float(total_revenue),
"total_consumption": float(total_consumption),
"total_transactions": total_transactions,
"pending_invoices": pending_invoices,
"paid_invoices": paid_invoices,
"active_rules": active_rules,
"active_packages": active_packages,
"daily_revenue_7d": daily_revenue,
}}
@@ -0,0 +1,369 @@
"""
Code Execution Admin API
Manages code execution sandbox containers, task submission, and result retrieval.
Endpoints:
GET /stats - Execution statistics
GET /runtimes - Available runtime info from sandbox
POST /execute - Submit code for execution
GET /tasks - List execution tasks (with filters)
GET /tasks/{id} - Get specific task result
DELETE /tasks/{id} - Cancel/delete task
"""
import logging
import uuid
import json
import time
from datetime import datetime, timezone, timedelta
from typing import Optional, List
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from sqlalchemy import text
from ...database import get_db, SessionLocal
from ...auth import get_current_admin
logger = logging.getLogger(__name__)
router = APIRouter()
CST = timezone(timedelta(hours=8))
# Code execution sandbox configuration
# The executor container is on the same Docker network, accessible by container name
EXECUTOR_URL = "http://qihuaiflow-executor:8000"
EXECUTOR_TIMEOUT = 120 # max execution timeout
# Supported languages
SUPPORTED_LANGUAGES = ["python", "javascript"]
def _execute_raw(db: Session, sql, params=None):
"""Execute raw SQL and return results."""
result = db.execute(text(sql), params or {})
if sql.strip().upper().startswith("SELECT") or sql.strip().upper().startswith("WITH"):
rows = result.fetchall()
if rows:
return [dict(r._mapping) for r in rows]
return []
db.commit()
return result
def _format_cst_time(dt):
"""Format datetime to CST string."""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=CST)
return dt.strftime("%Y-%m-%d %H:%M:%S")
def _now_cst():
"""Get current time as CST-aware datetime."""
return datetime.now(CST)
# ===== Pydantic Models =====
class ExecuteRequest(BaseModel):
language: str = "python"
code: str
stdin: str = ""
timeout: int = Field(default=30, ge=1, le=120)
class ExecuteResponse(BaseModel):
task_id: str
success: bool
stdout: str = ""
stderr: str = ""
exit_code: int = 0
execution_time_ms: float = 0
error: Optional[str] = None
truncated: bool = False
created_at: str = ""
class TaskFilter(BaseModel):
language: Optional[str] = None
status: Optional[str] = None
page: int = 1
page_size: int = 20
# ===== API Endpoints =====
@router.get("/stats")
async def get_execution_stats(db: Session = Depends(get_db), _=Depends(get_current_admin)):
"""Get code execution statistics."""
try:
total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM code_exec_tasks")[0]["cnt"]
today = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURDATE()")[0]["cnt"]
success_count = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code = 0 AND error IS NULL")[0]["cnt"]
failed_count = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code != 0 OR error IS NOT NULL")[0]["cnt"]
timeout_count = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE error LIKE '%timeout%'")[0]["cnt"]
by_language = _execute_raw(db,
"SELECT language, COUNT(*) as cnt FROM code_exec_tasks GROUP BY language")
avg_time = _execute_raw(db,
"SELECT AVG(execution_time_ms) as avg_ms FROM code_exec_tasks WHERE execution_time_ms > 0")[0]
total_avg = round(avg_time.get("avg_ms", 0) or 0, 2)
return {
"code": 0,
"data": {
"total": total,
"today": today,
"success": success_count,
"failed": failed_count,
"timeout": timeout_count,
"avg_execution_time_ms": total_avg,
"by_language": {r["language"]: r["cnt"] for r in by_language} if by_language else {},
}
}
except Exception as e:
# Table might not exist yet
return {
"code": 0,
"data": {
"total": 0, "today": 0, "success": 0, "failed": 0, "timeout": 0,
"avg_execution_time_ms": 0, "by_language": {}
}
}
@router.get("/runtimes")
async def get_runtimes(_=Depends(get_current_admin)):
"""Get sandbox runtime info."""
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{EXECUTOR_URL}/info")
if resp.status_code == 200:
return {"code": 0, "data": resp.json()}
except Exception as e:
pass
return {
"code": 0,
"data": {
"python": "Python 3.11 (sandbox)",
"nodejs": "Node.js (sandbox)",
"workspace": "/workspace",
"max_code_length": 51200,
"max_timeout": 120,
}
}
@router.post("/execute", response_model=ExecuteResponse)
async def execute_code(req: ExecuteRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)):
"""Submit code for execution."""
if req.language not in SUPPORTED_LANGUAGES:
raise HTTPException(status_code=400, detail=f"Unsupported language: {req.language}")
task_id = str(uuid.uuid4())[:8]
now = _now_cst()
# Create task record
_execute_raw(db, """
INSERT INTO code_exec_tasks (id, language, code_preview, status, timeout_sec, created_at)
VALUES (:id, :lang, :preview, 'running', :timeout, :now)
""", {
"id": task_id,
"lang": req.language,
"preview": req.code[:200],
"timeout": req.timeout,
"now": now,
})
# Execute via sandbox
result = {
"success": False,
"stdout": "",
"stderr": "",
"exit_code": -1,
"execution_time_ms": 0,
"error": None,
}
try:
async with httpx.AsyncClient(timeout=req.timeout + 10) as client:
resp = await client.post(
f"{EXECUTOR_URL}/execute",
json={
"language": req.language,
"code": req.code,
"stdin": req.stdin,
"timeout": req.timeout,
},
)
if resp.status_code == 200:
data = resp.json()
result = {
"success": data.get("success", False),
"stdout": data.get("stdout", ""),
"stderr": data.get("stderr", ""),
"exit_code": data.get("exit_code", -1),
"execution_time_ms": data.get("execution_time_ms", 0),
"error": data.get("error"),
"truncated": data.get("truncated", False),
}
else:
result["error"] = f"Sandbox returned {resp.status_code}: {resp.text[:200]}"
except httpx.TimeoutException:
result["error"] = f"Execution timed out after {req.timeout}s"
except httpx.ConnectError:
result["error"] = "Execution sandbox is not available"
except Exception as e:
result["error"] = str(e)[:500]
# Determine status
if result["error"]:
status = "failed"
elif result["exit_code"] != 0:
status = "error"
else:
status = "completed"
# Update task record
now2 = _now_cst()
_execute_raw(db, """
UPDATE code_exec_tasks SET
status = :status, exit_code = :exit_code, execution_time_ms = :time_ms,
stdout = :stdout, stderr = :stderr, error_msg = :error,
truncated = :truncated, completed_at = :now
WHERE id = :id
""", {
"status": status,
"exit_code": result["exit_code"],
"time_ms": result["execution_time_ms"],
"stdout": result["stdout"][:50000] if result.get("stdout") else "",
"stderr": result["stderr"][:50000] if result.get("stderr") else "",
"error": result["error"][:2000] if result.get("error") else None,
"truncated": 1 if result.get("truncated") else 0,
"now": now2,
"id": task_id,
})
return ExecuteResponse(
task_id=task_id,
success=result["success"],
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
exit_code=result["exit_code"],
execution_time_ms=result["execution_time_ms"],
error=result.get("error"),
truncated=result.get("truncated", False),
created_at=_format_cst_time(now),
)
@router.get("/tasks")
async def list_tasks(
language: Optional[str] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
_=Depends(get_current_admin),
):
"""List execution tasks with filters."""
where_clauses = []
params = {}
if language:
where_clauses.append("language = :lang")
params["lang"] = language
if status:
where_clauses.append("status = :status")
params["status"] = status
where_sql = ""
if where_clauses:
where_sql = "WHERE " + " AND ".join(where_clauses)
# Count total
count_result = _execute_raw(db, f"SELECT COUNT(*) as cnt FROM code_exec_tasks {where_sql}", params)
total = count_result[0]["cnt"] if count_result else 0
# Fetch page
offset = (page - 1) * page_size
tasks = _execute_raw(db, f"""
SELECT id, language, code_preview, status, exit_code, execution_time_ms,
error_msg, truncated, created_at, completed_at
FROM code_exec_tasks {where_sql}
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
""", {**params, "limit": page_size, "offset": offset})
items = []
for t in (tasks or []):
items.append({
"id": t["id"],
"language": t["language"],
"code_preview": t.get("code_preview", ""),
"status": t["status"],
"exit_code": t.get("exit_code"),
"execution_time_ms": t.get("execution_time_ms"),
"error_msg": t.get("error_msg"),
"truncated": bool(t.get("truncated", 0)),
"created_at": _format_cst_time(t.get("created_at")),
"completed_at": _format_cst_time(t.get("completed_at")),
})
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": items,
}
}
@router.get("/tasks/{task_id}")
async def get_task(task_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
"""Get a specific task with full output."""
results = _execute_raw(db, """
SELECT id, language, code_preview, status, exit_code, execution_time_ms,
stdout, stderr, error_msg, truncated, created_at, completed_at
FROM code_exec_tasks WHERE id = :id
""", {"id": task_id})
if not results:
raise HTTPException(status_code=404, detail="Task not found")
t = results[0]
return {
"code": 0,
"data": {
"id": t["id"],
"language": t["language"],
"code_preview": t.get("code_preview", ""),
"status": t["status"],
"exit_code": t.get("exit_code"),
"execution_time_ms": t.get("execution_time_ms"),
"stdout": t.get("stdout", ""),
"stderr": t.get("stderr", ""),
"error_msg": t.get("error_msg"),
"truncated": bool(t.get("truncated", 0)),
"created_at": _format_cst_time(t.get("created_at")),
"completed_at": _format_cst_time(t.get("completed_at")),
}
}
@router.delete("/tasks/{task_id}")
async def delete_task(task_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
"""Delete a task record."""
result = _execute_raw(db, "DELETE FROM code_exec_tasks WHERE id = :id", {"id": task_id})
return {"code": 0, "msg": "deleted"}
@@ -0,0 +1,658 @@
"""
Developer API Management - Admin endpoints for API Keys, OpenAPI Clients,
API Call Statistics, and Slow Request Logs.
"""
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, desc, asc
from sqlalchemy.orm import Session
from ...database import get_db
from ...models import (
ApiKey, OpenApiClient, ApiCallStat, SlowRequestLog, User
)
router = APIRouter()
CST_TZ = timezone(timedelta(hours=8))
def cst_now():
return datetime.now(CST_TZ)
def fmt_time(dt):
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=CST_TZ).isoformat()
return dt.isoformat()
# ============================================================
# Pydantic Models
# ============================================================
class ApiKeyCreate(BaseModel):
user_id: str
name: str = ""
description: str = ""
rate_limit: int = 60
expired_at: str = None
class ApiKeyUpdate(BaseModel):
name: str = None
description: str = None
rate_limit: int = None
status: str = None
expired_at: str = None
class OpenAPIClientCreate(BaseModel):
user_id: str
name: str
allowed_models: list = None
rate_limit: int = 60
class OpenAPIClientUpdate(BaseModel):
name: str = None
allowed_models: list = None
rate_limit: int = None
status: str = None
# ============================================================
# Helper functions
# ============================================================
def _generate_api_key():
"""Generate a unique API key string."""
return "sk-" + secrets.token_hex(24)
def _generate_secret():
"""Generate a secret string."""
return secrets.token_hex(32)
def _generate_client_id():
"""Generate a unique client ID."""
return "cli_" + secrets.token_hex(12)
def _generate_client_secret():
"""Generate a client secret."""
return secrets.token_hex(32)
def _serialize_api_key(key, user=None):
return {
"id": key.id,
"user_id": key.user_id,
"username": user.username if user else None,
"api_key": key.api_key,
"name": key.name,
"description": getattr(key, "description", "") or "",
"rate_limit": getattr(key, "rate_limit", 60) or 60,
"total_calls": getattr(key, "total_calls", 0) or 0,
"status": key.status,
"last_used_at": fmt_time(key.last_used_at),
"created_at": fmt_time(key.created_at),
"expired_at": fmt_time(key.expired_at),
}
def _serialize_client(client, user=None):
return {
"id": client.id,
"user_id": client.user_id,
"username": user.username if user else None,
"name": client.name,
"client_id": client.client_id,
"allowed_models": client.allowed_models or [],
"rate_limit": client.rate_limit,
"total_calls": getattr(client, "total_calls", 0) or 0,
"last_used_at": fmt_time(getattr(client, "last_used_at", None)),
"status": client.status,
"created_at": fmt_time(client.created_at),
}
def _serialize_call_stat(stat):
avg_time = 0.0
if stat.call_count and stat.call_count > 0:
avg_time = round(stat.total_response_time / stat.call_count, 3)
error_rate = 0.0
if stat.call_count and stat.call_count > 0:
error_rate = round(stat.error_count / stat.call_count * 100, 2)
return {
"id": stat.id,
"endpoint": stat.endpoint,
"method": stat.method,
"call_count": stat.call_count,
"error_count": stat.error_count,
"error_rate": error_rate,
"avg_response_time": avg_time,
"total_response_time": round(stat.total_response_time, 3),
"last_called_at": fmt_time(stat.last_called_at),
"created_at": fmt_time(stat.created_at),
"updated_at": fmt_time(stat.updated_at),
}
def _serialize_slow_log(log):
return {
"id": log.id,
"request_id": log.request_id,
"method": log.method,
"endpoint": log.endpoint,
"status_code": log.status_code,
"response_time": round(log.response_time, 3),
"threshold": round(log.threshold, 2),
"created_at": fmt_time(log.created_at),
}
# ============================================================
# Stats Overview
# ============================================================
@router.get("/stats/overview")
async def get_stats_overview(db: Session = Depends(get_db)):
"""Get developer API dashboard statistics."""
now = cst_now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_start_naive = today_start.replace(tzinfo=None)
# API Key stats
total_keys = db.query(func.count(ApiKey.id)).scalar() or 0
active_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "active").scalar() or 0
revoked_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "revoked").scalar() or 0
# OpenAPI Client stats
total_clients = db.query(func.count(OpenApiClient.id)).scalar() or 0
active_clients = db.query(func.count(OpenApiClient.id)).filter(OpenApiClient.status == "active").scalar() or 0
# Call stats
total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0
total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0
total_response_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0
avg_response_time = round(total_response_time / total_calls, 3) if total_calls > 0 else 0.0
error_rate = round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0
# Today's calls (approximate by last_called_at)
today_calls = db.query(func.sum(ApiCallStat.call_count)).filter(
ApiCallStat.last_called_at >= today_start_naive
).scalar() or 0
# Slow request count
slow_log_count = db.query(func.count(SlowRequestLog.id)).scalar() or 0
today_slow = db.query(func.count(SlowRequestLog.id)).filter(
SlowRequestLog.created_at >= today_start_naive
).scalar() or 0
# Top endpoints by call count
top_endpoints = db.query(ApiCallStat).order_by(desc(ApiCallStat.call_count)).limit(5).all()
top_endpoints_data = [
{
"endpoint": s.endpoint,
"method": s.method,
"call_count": s.call_count,
"error_count": s.error_count,
}
for s in top_endpoints
]
return {
"code": 0,
"data": {
"api_keys": {
"total": total_keys,
"active": active_keys,
"revoked": revoked_keys,
},
"openapi_clients": {
"total": total_clients,
"active": active_clients,
},
"call_stats": {
"total_calls": total_calls,
"today_calls": today_calls,
"total_errors": total_errors,
"error_rate": error_rate,
"avg_response_time": avg_response_time,
},
"slow_logs": {
"total": slow_log_count,
"today": today_slow,
},
"top_endpoints": top_endpoints_data,
"server_time": cst_now().isoformat(),
}
}
# ============================================================
# API Key Management
# ============================================================
@router.get("/api-keys")
async def list_api_keys(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
):
"""List all API keys with pagination."""
query = db.query(ApiKey, User).outerjoin(User, ApiKey.user_id == User.id)
if status:
query = query.filter(ApiKey.status == status)
if keyword:
query = query.filter(
(ApiKey.name.ilike(f"%{keyword}%")) |
(ApiKey.api_key.ilike(f"%{keyword}%")) |
(User.username.ilike(f"%{keyword}%"))
)
total = query.count()
items = query.order_by(desc(ApiKey.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_api_key(k, u) for k, u in items],
}
}
@router.post("/api-keys")
async def create_api_key(body: ApiKeyCreate, db: Session = Depends(get_db)):
"""Create a new API key."""
# Verify user exists
user = db.query(User).filter(User.id == body.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check if model has description/rate_limit/total_calls columns
new_key = ApiKey(
user_id=body.user_id,
api_key=_generate_api_key(),
secret=_generate_secret(),
name=body.name,
status="active",
created_at=datetime.now(),
)
# Set optional fields if they exist on the model
try:
new_key.description = body.description
except Exception:
pass
try:
new_key.rate_limit = body.rate_limit
except Exception:
pass
try:
new_key.total_calls = 0
except Exception:
pass
if body.expired_at:
try:
new_key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", ""))
except Exception:
pass
db.add(new_key)
db.commit()
db.refresh(new_key)
return {
"code": 0,
"data": _serialize_api_key(new_key, user),
"message": "API Key created successfully",
}
@router.put("/api-keys/{key_id}")
async def update_api_key(key_id: str, body: ApiKeyUpdate, db: Session = Depends(get_db)):
"""Update an API key."""
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="API Key not found")
if body.name is not None:
key.name = body.name
if body.status is not None:
key.status = body.status
if body.rate_limit is not None:
try:
key.rate_limit = body.rate_limit
except Exception:
pass
if body.description is not None:
try:
key.description = body.description
except Exception:
pass
if body.expired_at is not None:
try:
key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", ""))
except Exception:
pass
db.commit()
db.refresh(key)
user = db.query(User).filter(User.id == key.user_id).first()
return {"code": 0, "data": _serialize_api_key(key, user), "message": "Updated"}
@router.post("/api-keys/{key_id}/revoke")
async def revoke_api_key(key_id: str, db: Session = Depends(get_db)):
"""Revoke an API key."""
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="API Key not found")
key.status = "revoked"
db.commit()
return {"code": 0, "message": "API Key revoked"}
@router.delete("/api-keys/{key_id}")
async def delete_api_key(key_id: str, db: Session = Depends(get_db)):
"""Delete an API key permanently."""
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="API Key not found")
db.delete(key)
db.commit()
return {"code": 0, "message": "API Key deleted"}
# ============================================================
# OpenAPI Client Management
# ============================================================
@router.get("/openapi-clients")
async def list_openapi_clients(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
):
"""List all OpenAPI clients."""
query = db.query(OpenApiClient, User).outerjoin(User, OpenApiClient.user_id == User.id)
if status:
query = query.filter(OpenApiClient.status == status)
if keyword:
query = query.filter(
(OpenApiClient.name.ilike(f"%{keyword}%")) |
(OpenApiClient.client_id.ilike(f"%{keyword}%")) |
(User.username.ilike(f"%{keyword}%"))
)
total = query.count()
items = query.order_by(desc(OpenApiClient.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_client(c, u) for c, u in items],
}
}
@router.post("/openapi-clients")
async def create_openapi_client(body: OpenAPIClientCreate, db: Session = Depends(get_db)):
"""Create a new OpenAPI client."""
user = db.query(User).filter(User.id == body.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_client = OpenApiClient(
user_id=body.user_id,
name=body.name,
client_id=_generate_client_id(),
client_secret=_generate_client_secret(),
allowed_models=body.allowed_models,
rate_limit=body.rate_limit,
status="active",
created_at=datetime.now(),
)
try:
new_client.total_calls = 0
except Exception:
pass
try:
new_client.last_used_at = None
except Exception:
pass
db.add(new_client)
db.commit()
db.refresh(new_client)
result = _serialize_client(new_client, user)
result["client_secret"] = new_client.client_secret # Only show on create
return {"code": 0, "data": result, "message": "OpenAPI Client created"}
@router.put("/openapi-clients/{client_id}")
async def update_openapi_client(client_id: str, body: OpenAPIClientUpdate, db: Session = Depends(get_db)):
"""Update an OpenAPI client."""
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
if body.name is not None:
client.name = body.name
if body.allowed_models is not None:
client.allowed_models = body.allowed_models
if body.rate_limit is not None:
client.rate_limit = body.rate_limit
if body.status is not None:
client.status = body.status
db.commit()
db.refresh(client)
user = db.query(User).filter(User.id == client.user_id).first()
return {"code": 0, "data": _serialize_client(client, user), "message": "Updated"}
@router.post("/openapi-clients/{client_id}/reset-secret")
async def reset_client_secret(client_id: str, db: Session = Depends(get_db)):
"""Reset the client secret."""
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
client.client_secret = _generate_client_secret()
db.commit()
db.refresh(client)
return {"code": 0, "data": {"client_secret": client.client_secret}, "message": "Secret reset"}
@router.delete("/openapi-clients/{client_id}")
async def delete_openapi_client(client_id: str, db: Session = Depends(get_db)):
"""Delete an OpenAPI client."""
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
db.delete(client)
db.commit()
return {"code": 0, "message": "OpenAPI Client deleted"}
# ============================================================
# API Call Statistics
# ============================================================
@router.get("/call-stats")
async def list_call_stats(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
sort_by: str = Query("call_count"),
sort_order: str = Query("desc"),
keyword: str = Query(None),
db: Session = Depends(get_db),
):
"""List API call statistics."""
query = db.query(ApiCallStat)
if keyword:
query = query.filter(ApiCallStat.endpoint.ilike(f"%{keyword}%"))
sort_column = getattr(ApiCallStat, sort_by, ApiCallStat.call_count)
if sort_order == "asc":
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
total = query.count()
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_call_stat(s) for s in items],
}
}
@router.get("/call-stats/overview")
async def call_stats_overview(db: Session = Depends(get_db)):
"""Get call stats summary."""
total_endpoints = db.query(func.count(ApiCallStat.id)).scalar() or 0
total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0
total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0
avg_time = 0.0
if total_calls and total_calls > 0:
total_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0
avg_time = round(total_time / total_calls, 3)
# Method distribution
method_stats = db.query(
ApiCallStat.method,
func.sum(ApiCallStat.call_count).label("count")
).group_by(ApiCallStat.method).all()
return {
"code": 0,
"data": {
"total_endpoints": total_endpoints,
"total_calls": total_calls,
"total_errors": total_errors,
"error_rate": round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0,
"avg_response_time": avg_time,
"method_distribution": [
{"method": m, "count": c} for m, c in method_stats
],
}
}
# ============================================================
# Slow Request Logs
# ============================================================
@router.get("/slow-logs")
async def list_slow_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: str = Query(None),
min_time: float = Query(None),
db: Session = Depends(get_db),
):
"""List slow request logs."""
query = db.query(SlowRequestLog)
if keyword:
query = query.filter(SlowRequestLog.endpoint.ilike(f"%{keyword}%"))
if min_time is not None:
query = query.filter(SlowRequestLog.response_time >= min_time)
total = query.count()
items = query.order_by(desc(SlowRequestLog.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_slow_log(l) for l in items],
}
}
@router.delete("/slow-logs")
async def clear_slow_logs(db: Session = Depends(get_db)):
"""Clear all slow request logs."""
deleted = db.query(SlowRequestLog).delete()
db.commit()
return {"code": 0, "message": f"Cleared {deleted} slow request logs"}
@router.get("/slow-logs/stats")
async def slow_logs_stats(db: Session = Depends(get_db)):
"""Get slow request log statistics."""
now = cst_now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
total = db.query(func.count(SlowRequestLog.id)).scalar() or 0
today = db.query(func.count(SlowRequestLog.id)).filter(
SlowRequestLog.created_at >= today_start
).scalar() or 0
avg_time = db.query(func.avg(SlowRequestLog.response_time)).scalar() or 0.0
max_time = db.query(func.max(SlowRequestLog.response_time)).scalar() or 0.0
# Distribution by status code
status_dist = db.query(
SlowRequestLog.status_code,
func.count(SlowRequestLog.id).label("count")
).group_by(SlowRequestLog.status_code).all()
return {
"code": 0,
"data": {
"total": total,
"today": today,
"avg_response_time": round(float(avg_time), 3),
"max_response_time": round(float(max_time), 3),
"status_distribution": [
{"status_code": s, "count": c} for s, c in status_dist
],
}
}
@@ -0,0 +1,899 @@
"""
Developer Tools Enhancement - Webhooks, SDK Center, API Docs, Usage Dashboard.
Phase 3.9: Extends the developer module with webhook management,
SDK download center, interactive API documentation, and enhanced analytics.
"""
import json
import secrets
import hashlib
import hmac
import urllib.request
import urllib.error
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from ...database import get_db, SessionLocal
from ...auth import get_current_admin
router = APIRouter()
CST_TZ = timezone(timedelta(hours=8))
def cst_now():
return datetime.now(CST_TZ)
def fmt_time(dt):
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=CST_TZ).isoformat()
return dt.isoformat()
def _execute_raw(db, sql, params=None):
"""Execute raw SQL and return list of dicts."""
result = db.execute(text(sql), params or {})
rows = []
for row in result:
rows.append(dict(row._mapping))
return rows
# ============================================================
# Pydantic Models
# ============================================================
class WebhookCreate(BaseModel):
name: str
url: str
events: list = []
secret: str = ""
headers: dict = {}
class WebhookUpdate(BaseModel):
name: str = None
url: str = None
events: list = None
secret: str = None
headers: dict = None
status: str = None
class WebhookTest(BaseModel):
event: str = "test"
payload: dict = {"message": "Test webhook delivery"}
# ============================================================
# Webhook Management
# ============================================================
SUPPORTED_EVENTS = [
{"value": "app.created", "label": "App Created", "desc": "Triggered when a new AI app is created"},
{"value": "app.published", "label": "App Published", "desc": "Triggered when an app is published to market"},
{"value": "agent.created", "label": "Agent Created", "desc": "Triggered when a new agent is created"},
{"value": "knowledge.updated", "label": "Knowledge Updated", "desc": "Triggered when knowledge base is updated"},
{"value": "api_key.created", "label": "API Key Created", "desc": "Triggered when a new API key is generated"},
{"value": "api_key.revoked", "label": "API Key Revoked", "desc": "Triggered when an API key is revoked"},
{"value": "user.registered", "label": "User Registered", "desc": "Triggered when a new user registers"},
{"value": "model.connected", "label": "Model Connected", "desc": "Triggered when a model connection test passes"},
{"value": "workflow.completed", "label": "Workflow Completed", "desc": "Triggered when a workflow execution finishes"},
{"value": "storage.threshold", "label": "Storage Threshold", "desc": "Triggered when storage usage exceeds threshold"},
]
@router.get("/webhooks", summary="List webhooks")
async def list_webhooks(
status: str = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""List all webhook configurations."""
where_clause = "WHERE 1=1"
params = {}
if status:
where_clause += " AND status = :status"
params["status"] = status
count_sql = f"SELECT COUNT(*) as cnt FROM webhook_configs {where_clause}"
count_rows = _execute_raw(db, count_sql, params)
total = count_rows[0]["cnt"] if count_rows else 0
offset = (page - 1) * page_size
list_sql = f"""SELECT id, name, url, events, secret, headers, status,
last_triggered_at, last_status, total_deliveries, success_count, fail_count,
created_at, updated_at
FROM webhook_configs {where_clause}
ORDER BY created_at DESC LIMIT :limit OFFSET :offset"""
list_params = dict(params)
list_params["limit"] = page_size
list_params["offset"] = offset
rows = _execute_raw(db, list_sql, list_params)
items = []
for r in rows:
events_raw = r.get("events")
if isinstance(events_raw, str):
try:
events_val = json.loads(events_raw)
except Exception:
events_val = []
else:
events_val = events_raw or []
headers_raw = r.get("headers")
if isinstance(headers_raw, str):
try:
headers_val = json.loads(headers_raw)
except Exception:
headers_val = {}
else:
headers_val = headers_raw or {}
success_rate = 0
if r.get("total_deliveries", 0) > 0:
success_rate = round(r["success_count"] / r["total_deliveries"] * 100, 1)
items.append({
"id": r["id"],
"name": r["name"],
"url": r["url"],
"events": events_val,
"has_secret": bool(r.get("secret")),
"headers": headers_val,
"status": r["status"],
"last_triggered_at": fmt_time(r.get("last_triggered_at")),
"last_status": r.get("last_status"),
"total_deliveries": r.get("total_deliveries", 0),
"success_count": r.get("success_count", 0),
"fail_count": r.get("fail_count", 0),
"success_rate": success_rate,
"created_at": fmt_time(r.get("created_at")),
"updated_at": fmt_time(r.get("updated_at")),
})
return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}}
@router.get("/webhooks/events", summary="List supported webhook events")
async def list_webhook_events(
admin_user=Depends(get_current_admin),
):
"""List all supported webhook event types."""
return {"code": 0, "data": {"events": SUPPORTED_EVENTS}}
@router.post("/webhooks/", summary="Create webhook")
async def create_webhook(
body: WebhookCreate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""Create a new webhook configuration."""
webhook_id = "wh_" + secrets.token_hex(12)
now = cst_now()
secret_val = body.secret if body.secret else "whsec_" + secrets.token_hex(16)
insert_sql = """INSERT INTO webhook_configs
(id, name, url, events, secret, headers, status, last_triggered_at,
last_status, total_deliveries, success_count, fail_count, created_at, updated_at)
VALUES (:id, :name, :url, :events, :secret, :headers, 'active',
NULL, NULL, 0, 0, 0, :now, :now)"""
params = {
"id": webhook_id,
"name": body.name,
"url": body.url,
"events": json.dumps(body.events),
"secret": secret_val,
"headers": json.dumps(body.headers),
"now": now,
}
db.execute(text(insert_sql), params)
db.commit()
return {"code": 0, "data": {"id": webhook_id, "name": body.name, "secret": secret_val, "message": "Webhook created"}}
@router.put("/webhooks/{webhook_id}", summary="Update webhook")
async def update_webhook(
webhook_id: str,
body: WebhookUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""Update an existing webhook configuration."""
check_sql = "SELECT id FROM webhook_configs WHERE id = :id"
rows = _execute_raw(db, check_sql, {"id": webhook_id})
if not rows:
raise HTTPException(status_code=404, detail="Webhook not found")
updates = []
params = {"id": webhook_id, "now": cst_now()}
if body.name is not None:
updates.append("name = :name")
params["name"] = body.name
if body.url is not None:
updates.append("url = :url")
params["url"] = body.url
if body.events is not None:
updates.append("events = :events")
params["events"] = json.dumps(body.events)
if body.secret is not None:
updates.append("secret = :secret")
params["secret"] = body.secret
if body.headers is not None:
updates.append("headers = :headers")
params["headers"] = json.dumps(body.headers)
if body.status is not None:
updates.append("status = :status")
params["status"] = body.status
if not updates:
return {"code": 0, "message": "No changes"}
updates.append("updated_at = :now")
update_sql = f"UPDATE webhook_configs SET {', '.join(updates)} WHERE id = :id"
db.execute(text(update_sql), params)
db.commit()
return {"code": 0, "message": "Webhook updated"}
@router.delete("/webhooks/{webhook_id}", summary="Delete webhook")
async def delete_webhook(
webhook_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""Delete a webhook configuration."""
check_sql = "SELECT id FROM webhook_configs WHERE id = :id"
rows = _execute_raw(db, check_sql, {"id": webhook_id})
if not rows:
raise HTTPException(status_code=404, detail="Webhook not found")
db.execute(text("DELETE FROM webhook_configs WHERE id = :id"), {"id": webhook_id})
db.commit()
return {"code": 0, "message": "Webhook deleted"}
@router.post("/webhooks/{webhook_id}/test", summary="Test webhook delivery")
async def test_webhook(
webhook_id: str,
body: WebhookTest = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""Send a test payload to the webhook endpoint."""
select_sql = "SELECT * FROM webhook_configs WHERE id = :id"
rows = _execute_raw(db, select_sql, {"id": webhook_id})
if not rows:
raise HTTPException(status_code=404, detail="Webhook not found")
wh = rows[0]
url = wh["url"]
secret = wh.get("secret", "")
test_event = body.event if body else "test"
test_payload = body.payload if body else {"message": "Test webhook delivery"}
full_payload = {
"event": test_event,
"timestamp": cst_now().isoformat(),
"webhook_id": webhook_id,
"data": test_payload,
}
payload_bytes = json.dumps(full_payload).encode("utf-8")
# Build headers
headers = {"Content-Type": "application/json"}
headers_raw = wh.get("headers")
if isinstance(headers_raw, str):
try:
headers.update(json.loads(headers_raw))
except Exception:
pass
elif isinstance(headers_raw, dict):
headers.update(headers_raw)
# HMAC signature
if secret:
signature = hmac.new(secret.encode("utf-8"), payload_bytes, hashlib.sha256).hexdigest()
headers["X-Webhook-Signature"] = signature
# Send request
delivery_id = "dlv_" + secrets.token_hex(12)
now = cst_now()
response_code = None
response_body = None
response_time_ms = None
status = "failed"
error_message = None
try:
req = urllib.request.Request(url, data=payload_bytes, headers=headers, method="POST")
start_time = datetime.now()
resp = urllib.request.urlopen(req, timeout=10)
end_time = datetime.now()
response_code = resp.getcode()
response_body = resp.read().decode("utf-8", errors="replace")[:2000]
response_time_ms = int((end_time - start_time).total_seconds() * 1000)
if 200 <= response_code < 300:
status = "success"
except urllib.error.HTTPError as e:
response_code = e.code
try:
response_body = e.read().decode("utf-8", errors="replace")[:2000]
except Exception:
response_body = str(e)
error_message = f"HTTP {e.code}: {str(e)[:200]}"
except Exception as e:
error_message = str(e)[:500]
# Record delivery
insert_sql = """INSERT INTO webhook_deliveries
(id, webhook_id, event, payload, status, response_code, response_body,
response_time_ms, attempts, error_message, created_at)
VALUES (:id, :wid, :event, :payload, :status, :rcode, :rbody,
:rtime, 1, :err, :now)"""
db.execute(text(insert_sql), {
"id": delivery_id, "wid": webhook_id, "event": test_event,
"payload": json.dumps(full_payload), "status": status,
"rcode": response_code, "rbody": response_body,
"rtime": response_time_ms, "err": error_message, "now": now,
})
# Update webhook stats
total_deliveries = (wh.get("total_deliveries") or 0) + 1
success_count = (wh.get("success_count") or 0) + (1 if status == "success" else 0)
fail_count = (wh.get("fail_count") or 0) + (1 if status != "success" else 0)
db.execute(text("""UPDATE webhook_configs SET
total_deliveries = :td, success_count = :sc, fail_count = :fc,
last_triggered_at = :now, last_status = :ls, updated_at = :now
WHERE id = :id"""), {
"td": total_deliveries, "sc": success_count, "fc": fail_count,
"now": now, "ls": status, "id": webhook_id,
})
db.commit()
return {
"code": 0,
"data": {
"delivery_id": delivery_id,
"status": status,
"response_code": response_code,
"response_time_ms": response_time_ms,
"response_body": response_body[:500] if response_body else None,
"error_message": error_message,
}
}
@router.get("/webhooks/{webhook_id}/deliveries", summary="Webhook delivery history")
async def webhook_deliveries(
webhook_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""Get delivery history for a specific webhook."""
count_sql = "SELECT COUNT(*) as cnt FROM webhook_deliveries WHERE webhook_id = :wid"
count_rows = _execute_raw(db, count_sql, {"wid": webhook_id})
total = count_rows[0]["cnt"] if count_rows else 0
offset = (page - 1) * page_size
list_sql = """SELECT id, webhook_id, event, payload, status, response_code,
response_body, response_time_ms, attempts, error_message, created_at
FROM webhook_deliveries WHERE webhook_id = :wid
ORDER BY created_at DESC LIMIT :limit OFFSET :offset"""
rows = _execute_raw(db, list_sql, {"wid": webhook_id, "limit": page_size, "offset": offset})
items = []
for r in rows:
payload_raw = r.get("payload")
if isinstance(payload_raw, str):
try:
payload_val = json.loads(payload_raw)
except Exception:
payload_val = payload_raw
else:
payload_val = payload_raw
items.append({
"id": r["id"],
"event": r["event"],
"status": r["status"],
"response_code": r.get("response_code"),
"response_time_ms": r.get("response_time_ms"),
"attempts": r.get("attempts", 0),
"error_message": r.get("error_message"),
"payload": payload_val,
"response_body": (r.get("response_body") or "")[:500],
"created_at": fmt_time(r.get("created_at")),
})
return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}}
# ============================================================
# SDK Download Center
# ============================================================
SDK_PACKAGES = [
{
"language": "python",
"name": "Python SDK",
"version": "1.2.0",
"description": "Official Python SDK for Qihu-AIFlow platform. Supports chat completions, knowledge base, agent execution, and file upload.",
"install_cmd": "pip install qihu-aiflow",
"download_url": "https://pypi.org/project/qihu-aiflow/",
"github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-python",
"min_version": "Python 3.8+",
"features": ["Chat completions (streaming)", "Knowledge base queries", "Agent execution", "File upload", "Webhook verification", "Async support"],
"code_example": '''from qihu_aiflow import QihuClient
client = QihuClient(api_key="sk-your-api-key", base_url="http://210.16.161.218:18080")
# Chat completion
response = client.chat.create(
model="deepseek-ai/DeepSeek-V4-Flash",
messages=[{"role": "user", "content": "Hello!"}]
)
print(response.choices[0].message.content)
# Streaming
for chunk in client.chat.create(
model="deepseek-ai/DeepSeek-V4-Flash",
messages=[{"role": "user", "content": "Tell me a story"}],
stream=True
):
print(chunk.choices[0].delta.content, end="")''',
},
{
"language": "javascript",
"name": "JavaScript SDK",
"version": "1.1.0",
"description": "Official JavaScript/TypeScript SDK for browser and Node.js. Full TypeScript support with type definitions.",
"install_cmd": "npm install @qihu/aiflow",
"download_url": "https://www.npmjs.com/package/@qihu/aiflow",
"github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-js",
"min_version": "Node.js 16+ / Browser",
"features": ["Chat completions (streaming)", "Knowledge base queries", "Agent execution", "File upload", "WebSocket support", "TypeScript types"],
"code_example": '''import { QihuClient } from "@qihu/aiflow";
const client = new QihuClient({
apiKey: "sk-your-api-key",
baseUrl: "http://210.16.161.218:18080",
});
// Chat completion
const response = await client.chat.create({
model: "deepseek-ai/DeepSeek-V4-Flash",
messages: [{ role: "user", content: "Hello!" }],
});
console.log(response.choices[0].message.content);
// Streaming
const stream = await client.chat.create({
model: "deepseek-ai/DeepSeek-V4-Flash",
messages: [{ role: "user", content: "Tell me a story" }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0].delta.content || "");
}''',
},
{
"language": "go",
"name": "Go SDK",
"version": "1.0.0",
"description": "Official Go SDK for Qihu-AIFlow. Lightweight and fast, suitable for microservices and CLI tools.",
"install_cmd": "go get github.com/qihuyun/qihu-aiflow-sdk-go",
"download_url": "https://github.com/qihuyun/qihu-aiflow-sdk-go/releases",
"github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-go",
"min_version": "Go 1.19+",
"features": ["Chat completions", "Knowledge base queries", "Agent execution", "Context cancellation", "Retry logic", "Connection pooling"],
"code_example": '''package main
import (
"context"
"fmt"
"github.com/qihuyun/qihu-aiflow-sdk-go/qihu"
)
func main() {
client := qihu.NewClient("sk-your-api-key", "http://210.16.161.218:18080")
resp, err := client.Chat.Create(context.Background(), &qihu.ChatRequest{
Model: "deepseek-ai/DeepSeek-V4-Flash",
Messages: []qihu.Message{
{Role: "user", Content: "Hello!"},
},
})
if err != nil {
panic(err)
}
fmt.Println(resp.Choices[0].Message.Content)
}''',
},
{
"language": "curl",
"name": "REST API (cURL)",
"version": "1.0.0",
"description": "Direct REST API access without SDK. Use any HTTP client to interact with the platform.",
"install_cmd": "# No installation needed - use curl directly",
"download_url": "https://docs.qihuyun.com/api-reference",
"github_url": "",
"min_version": "Any HTTP client",
"features": ["Chat completions", "Knowledge base queries", "Agent execution", "File upload", "Webhook management", "No dependencies"],
"code_example": '''# Chat completion
curl -X POST http://210.16.161.218:18080/app/api/chat/completions \\
-H "Authorization: Bearer sk-your-api-key" \\
-H "Content-Type: application/json" \\
-d '{
"model": "deepseek-ai/DeepSeek-V4-Flash",
"messages": [{"role": "user", "content": "Hello!"}]
}'
# List knowledge bases
curl http://210.16.161.218:18080/app/api/knowledge-bases \\
-H "Authorization: Bearer sk-your-api-key"''',
},
]
@router.get("/sdk/packages", summary="List SDK packages")
async def list_sdk_packages(
admin_user=Depends(get_current_admin),
):
"""List all available SDK packages with download info and code examples."""
return {"code": 0, "data": {"packages": SDK_PACKAGES}}
# ============================================================
# Interactive API Documentation
# ============================================================
API_DOC_SECTIONS = [
{
"section": "Authentication",
"icon": "lock",
"description": "All API requests require authentication via Bearer token.",
"endpoints": [
{
"method": "POST",
"path": "/app/api/auth/login",
"title": "User Login",
"description": "Authenticate and obtain an access token.",
"params": [
{"name": "username", "type": "string", "required": True, "desc": "Username"},
{"name": "password", "type": "string", "required": True, "desc": "Password"},
],
"example_request": '{"username": "admin", "password": "admin123"}',
"example_response": '{"code": 0, "data": {"access_token": "eyJ...", "token_type": "bearer", "user": {"id": "admin-001", "username": "admin"}}}',
},
],
},
{
"section": "Chat & Completions",
"icon": "chat",
"description": "AI chat completion endpoints for conversational AI.",
"endpoints": [
{
"method": "POST",
"path": "/app/api/chat/completions",
"title": "Chat Completion",
"description": "Create a chat completion with the specified model.",
"params": [
{"name": "model", "type": "string", "required": True, "desc": "Model ID (e.g. deepseek-ai/DeepSeek-V4-Flash)"},
{"name": "messages", "type": "array", "required": True, "desc": "Array of message objects"},
{"name": "stream", "type": "boolean", "required": False, "desc": "Enable streaming response"},
{"name": "temperature", "type": "number", "required": False, "desc": "Sampling temperature (0-2)"},
{"name": "max_tokens", "type": "integer", "required": False, "desc": "Maximum tokens to generate"},
],
"example_request": '{"model": "deepseek-ai/DeepSeek-V4-Flash", "messages": [{"role": "user", "content": "Hello"}]}',
"example_response": '{"code": 0, "data": {"id": "chat-xxx", "choices": [{"message": {"role": "assistant", "content": "Hi there!"}}]}}',
},
],
},
{
"section": "Knowledge Base",
"icon": "book",
"description": "Manage knowledge bases and documents for RAG.",
"endpoints": [
{
"method": "GET",
"path": "/app/api/knowledge-bases",
"title": "List Knowledge Bases",
"description": "Get all knowledge bases for the current user.",
"params": [],
"example_request": "",
"example_response": '{"code": 0, "data": [{"id": "kb-001", "name": "Product Docs", "document_count": 15}]}',
},
{
"method": "POST",
"path": "/app/api/knowledge-bases/{kb_id}/documents",
"title": "Upload Document",
"description": "Upload a document to a knowledge base for processing.",
"params": [
{"name": "file", "type": "file", "required": True, "desc": "Document file (PDF, TXT, DOCX, MD)"},
],
"example_request": "multipart/form-data: file=@document.pdf",
"example_response": '{"code": 0, "data": {"document_id": "doc-001", "status": "processing"}}',
},
],
},
{
"section": "Agents",
"icon": "robot",
"description": "Agent management and execution endpoints.",
"endpoints": [
{
"method": "GET",
"path": "/app/api/agents",
"title": "List Agents",
"description": "Get all available agents.",
"params": [],
"example_request": "",
"example_response": '{"code": 0, "data": [{"id": "agent-001", "name": "Code Assistant", "type": "agent"}]}',
},
{
"method": "POST",
"path": "/app/api/agents/{agent_id}/execute",
"title": "Execute Agent",
"description": "Execute an agent with a given input.",
"params": [
{"name": "input", "type": "string", "required": True, "desc": "Input text for the agent"},
{"name": "context", "type": "object", "required": False, "desc": "Additional context"},
],
"example_request": '{"input": "Help me write a Python function"}',
"example_response": '{"code": 0, "data": {"output": "Here is a Python function...", "tool_calls": []}}',
},
],
},
{
"section": "Workflows",
"icon": "workflow",
"description": "DAG workflow orchestration endpoints.",
"endpoints": [
{
"method": "GET",
"path": "/app/api/admin/workflows/templates",
"title": "List Workflow Templates",
"description": "Get predefined workflow templates.",
"params": [],
"example_request": "",
"example_response": '{"code": 0, "data": [{"id": "tpl-001", "name": "Q&A Pipeline", "nodes": [...]}]}',
},
{
"method": "POST",
"path": "/app/api/admin/workflows/{id}/execute",
"title": "Execute Workflow",
"description": "Execute a workflow with input data.",
"params": [
{"name": "input", "type": "string", "required": True, "desc": "Input for the workflow"},
],
"example_request": '{"input": "Summarize this article"}',
"example_response": '{"code": 0, "data": {"execution_id": "exec-001", "status": "completed", "result": "..."}}',
},
],
},
{
"section": "File Storage",
"icon": "storage",
"description": "File upload and storage management.",
"endpoints": [
{
"method": "POST",
"path": "/app/api/files/upload",
"title": "Upload File",
"description": "Upload a file to storage (MinIO/OSS).",
"params": [
{"name": "file", "type": "file", "required": True, "desc": "File to upload"},
{"name": "category", "type": "string", "required": False, "desc": "File category"},
],
"example_request": "multipart/form-data: file=@image.png",
"example_response": '{"code": 0, "data": {"url": "https://...", "size": 102400, "file_type": "image"}}',
},
],
},
{
"section": "Webhooks",
"icon": "webhook",
"description": "Webhook configuration and management.",
"endpoints": [
{
"method": "GET",
"path": "/app/api/admin/developer-tools/webhooks",
"title": "List Webhooks",
"description": "List all configured webhooks.",
"params": [],
"example_request": "",
"example_response": '{"code": 0, "data": {"total": 2, "items": [{"id": "wh_xxx", "name": "My Webhook", "url": "https://...", "status": "active"}]}}',
},
{
"method": "POST",
"path": "/app/api/admin/developer-tools/webhooks/",
"title": "Create Webhook",
"description": "Create a new webhook endpoint.",
"params": [
{"name": "name", "type": "string", "required": True, "desc": "Webhook name"},
{"name": "url", "type": "string", "required": True, "desc": "Webhook URL"},
{"name": "events", "type": "array", "required": True, "desc": "Events to subscribe"},
],
"example_request": '{"name": "My Webhook", "url": "https://example.com/webhook", "events": ["app.created"]}',
"example_response": '{"code": 0, "data": {"id": "wh_xxx", "secret": "whsec_..."}}',
},
],
},
]
@router.get("/api-docs", summary="Interactive API documentation")
async def get_api_docs(
admin_user=Depends(get_current_admin),
):
"""Get structured API documentation for the interactive docs viewer."""
total_endpoints = sum(len(s["endpoints"]) for s in API_DOC_SECTIONS)
return {
"code": 0,
"data": {
"sections": API_DOC_SECTIONS,
"total_sections": len(API_DOC_SECTIONS),
"total_endpoints": total_endpoints,
"base_url": "http://210.16.161.218:18080",
"api_version": "v1",
}
}
# ============================================================
# Enhanced Usage Dashboard
# ============================================================
@router.get("/usage/dashboard", summary="Enhanced usage dashboard")
async def usage_dashboard(
days: int = Query(7, ge=1, le=90),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""Enhanced usage statistics dashboard with trend data."""
now = cst_now()
start_date = now - timedelta(days=days)
# API call trends (daily)
trend_sql = """SELECT DATE(created_at) as date, COUNT(*) as calls,
SUM(CASE WHEN error_count > 0 THEN 1 ELSE 0 END) as errors,
SUM(CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) as avg_time
FROM api_call_stats
WHERE created_at >= :start
GROUP BY DATE(created_at)
ORDER BY date"""
trend_rows = _execute_raw(db, trend_sql, {"start": start_date})
trend = []
for r in trend_rows:
trend.append({
"date": r["date"].strftime("%Y-%m-%d") if r.get("date") else "",
"calls": r.get("calls", 0),
"errors": r.get("errors", 0),
"avg_time": round(r.get("avg_time", 0), 2) if r.get("avg_time") else 0,
})
# Top endpoints by call count
top_sql = """SELECT endpoint, method, call_count, error_count,
last_called_at, total_response_time
FROM api_call_stats
ORDER BY call_count DESC
LIMIT 10"""
top_rows = _execute_raw(db, top_sql)
top_endpoints = []
for r in top_rows:
top_endpoints.append({
"endpoint": r.get("endpoint", ""),
"method": r.get("method", ""),
"call_count": r.get("call_count", 0),
"error_count": r.get("error_count", 0),
"error_rate": round(r.get("error_count", 0) / max(r.get("call_count", 1), 1) * 100, 2) if r.get("call_count", 0) > 0 else 0,
"avg_response_time": round(r.get("total_response_time", 0) / max(r.get("call_count", 1), 1), 2) if r.get("call_count", 0) > 0 else 0,
"last_called_at": fmt_time(r.get("last_called_at")),
})
# Error distribution by status code
error_sql = """SELECT
CASE
WHEN error_count > 0 THEN 'has_errors'
ELSE 'success'
END as status_group,
COUNT(*) as count,
SUM(call_count) as total_calls
FROM api_call_stats
GROUP BY status_group"""
error_rows = _execute_raw(db, error_sql)
error_distribution = {"success": 0, "has_errors": 0, "success_calls": 0, "error_calls": 0}
for r in error_rows:
if r["status_group"] == "success":
error_distribution["success"] = r["count"]
error_distribution["success_calls"] = r.get("total_calls", 0)
else:
error_distribution["has_errors"] = r["count"]
error_distribution["error_calls"] = r.get("total_calls", 0)
# Response time distribution
latency_sql = """SELECT
CASE
WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 100 THEN 'fast'
WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 500 THEN 'normal'
WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 1000 THEN 'slow'
ELSE 'very_slow'
END as latency_bucket,
COUNT(*) as count,
SUM(CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) as avg_time
FROM api_call_stats
WHERE total_response_time IS NOT NULL AND call_count > 0
GROUP BY latency_bucket"""
latency_rows = _execute_raw(db, latency_sql)
latency_distribution = {"fast": 0, "normal": 0, "slow": 0, "very_slow": 0}
latency_avg = {"fast": 0, "normal": 0, "slow": 0, "very_slow": 0}
for r in latency_rows:
bucket = r["latency_bucket"]
latency_distribution[bucket] = r["count"]
latency_avg[bucket] = round(r.get("avg_time", 0), 2) if r.get("avg_time") else 0
# API key usage distribution
key_sql = """SELECT k.name, k.status, COUNT(s.id) as endpoint_count,
COALESCE(SUM(s.call_count), 0) as total_calls,
COALESCE(SUM(s.error_count), 0) as total_errors
FROM api_keys k
LEFT JOIN api_call_stats s ON s.endpoint LIKE CONCAT('%api-key%', k.id, '%')
GROUP BY k.id, k.name, k.status
ORDER BY total_calls DESC
LIMIT 10"""
key_rows = _execute_raw(db, key_sql)
key_usage = []
for r in key_rows:
key_usage.append({
"name": r.get("name", "Unknown"),
"status": r.get("status", "unknown"),
"total_calls": r.get("total_calls", 0),
"total_errors": r.get("total_errors", 0),
})
# Summary
total_calls = sum(t["calls"] for t in trend)
total_errors = sum(t["errors"] for t in trend)
avg_response = sum(t["avg_time"] for t in trend) / len(trend) if trend else 0
error_rate = round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0
return {
"code": 0,
"data": {
"summary": {
"total_calls": total_calls,
"total_errors": total_errors,
"error_rate": error_rate,
"avg_response_time": round(avg_response, 2),
"active_api_keys": error_distribution["success"] + error_distribution["has_errors"],
},
"trend": trend,
"top_endpoints": top_endpoints,
"error_distribution": error_distribution,
"latency_distribution": latency_distribution,
"latency_avg": latency_avg,
"key_usage": key_usage,
}
}
@@ -0,0 +1,752 @@
"""
Distribution Builder Admin API - Phase G4
发行版构建器:SaaS 产品线管理,配置包生成,许可证管理
Endpoints:
GET / - 发行版列表
POST / - 创建发行版
GET /{id} - 发行版详情
PUT /{id} - 更新发行版
DELETE /{id} - 删除发行版
POST /{id}/build - 构建配置包
GET /{id}/download - 下载配置包
POST /{id}/licenses - 生成许可证
GET /licenses - 许可证列表
GET /licenses/{lid} - 许可证详情
POST /licenses/{lid}/revoke - 吊销许可证
POST /licenses/validate - 验证许可证
GET /stats - 发行版统计
GET /templates - 发行版模板
"""
import hashlib
import hmac
import json
import uuid
import yaml
import zipfile
import io
from datetime import datetime, timezone, timedelta
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from sqlalchemy import text
from ...database import get_db, SessionLocal
from ...auth import get_current_admin
router = APIRouter()
CST = timezone(timedelta(hours=8))
LICENSE_SECRET = "qihuaiflow-distro-secret-key-2026"
# ==================== Pydantic Models ====================
class DistroCreate(BaseModel):
name: str = Field(..., description="发行版名称")
description: str = Field("")
brand_name: Optional[str] = None
brand_logo_url: Optional[str] = None
brand_color: str = "#6366f1"
system_prompt: str = ""
provider: str = "siliconflow"
model: str = "deepseek-ai/DeepSeek-V4-Flash"
mcp_extensions: Optional[List[dict]] = None
workflow_ids: Optional[List[str]] = None
knowledge_base_ids: Optional[List[str]] = None
agent_ids: Optional[List[str]] = None
features: Optional[List[str]] = None
version: str = "1.0.0"
class DistroUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
brand_name: Optional[str] = None
brand_logo_url: Optional[str] = None
brand_color: Optional[str] = None
system_prompt: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
mcp_extensions: Optional[List[dict]] = None
workflow_ids: Optional[List[str]] = None
knowledge_base_ids: Optional[List[str]] = None
agent_ids: Optional[List[str]] = None
features: Optional[List[str]] = None
version: Optional[str] = None
status: Optional[str] = None
class LicenseCreate(BaseModel):
distribution_id: str
org_name: str
licensee_email: Optional[str] = None
features: Optional[List[str]] = None
max_users: int = 10
max_executions: int = 10000
expiry_date: Optional[str] = None # YYYY-MM-DD
class LicenseValidate(BaseModel):
license_key: str
distribution_id: Optional[str] = None
# ==================== Helpers ====================
def _execute_raw(db, sql, params=None):
return db.execute(text(sql), params or {})
def _generate_id(prefix="distro"):
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _format_cst_time(dt):
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=CST)
return dt.astimezone(CST)
def _row_to_dict(row, columns):
return {col: row[i] for i, col in enumerate(columns)}
def _serialize_dt(obj):
if isinstance(obj, datetime):
return _format_cst_time(obj).strftime("%Y-%m-%d %H:%M:%S")
return obj
# ==================== Distribution CRUD ====================
@router.get("", summary="发行版列表")
async def list_distributions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: Optional[str] = None,
search: Optional[str] = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
conditions = []
params = {}
if status:
conditions.append("status = :status")
params["status"] = status
if search:
conditions.append("(name LIKE :search OR description LIKE :search)")
params["search"] = f"%{search}%"
where = " AND ".join(conditions) if conditions else "1=1"
rows = _execute_raw(db, f"SELECT COUNT(*) FROM distributions WHERE {where}", params)
total = rows.fetchone()[0]
offset = (page - 1) * page_size
rows = _execute_raw(db,
f"SELECT * FROM distributions WHERE {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
{**params, "limit": page_size, "offset": offset}
)
cols = list(rows.keys())
items = []
for row in rows.fetchall():
d = _row_to_dict(row, cols)
for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]:
if d.get(key) and isinstance(d[key], str):
try:
d[key] = json.loads(d[key])
except (json.JSONDecodeError, TypeError):
pass
for key in ["created_at", "updated_at"]:
d[key] = _serialize_dt(d[key])
items.append(d)
return {
"code": 0,
"data": {"total": total, "items": items, "page": page, "page_size": page_size}
}
@router.post("", summary="创建发行版")
async def create_distribution(
body: DistroCreate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
distro_id = _generate_id("distro")
now = datetime.now()
config_json = json.dumps({
"provider": body.provider,
"model": body.model,
"mcp_extensions": body.mcp_extensions or [],
"workflow_ids": body.workflow_ids or [],
"knowledge_base_ids": body.knowledge_base_ids or [],
"agent_ids": body.agent_ids or [],
"features": body.features or ["chat"],
}, ensure_ascii=False)
_execute_raw(db, """
INSERT INTO distributions (id, name, description, brand_name, brand_logo_url, brand_color,
system_prompt, provider, model, mcp_extensions, workflow_ids, knowledge_base_ids,
agent_ids, features, config_json, status, version, created_at, updated_at)
VALUES (:id, :name, :desc, :brand_name, :brand_logo, :brand_color,
:system_prompt, :provider, :model, :mcp_ext, :wf_ids, :kb_ids,
:agent_ids, :features, :config_json, 'draft', :version, :now, :now)
""", {
"id": distro_id, "name": body.name, "desc": body.description,
"brand_name": body.brand_name, "brand_logo": body.brand_logo_url, "brand_color": body.brand_color,
"system_prompt": body.system_prompt, "provider": body.provider, "model": body.model,
"mcp_ext": json.dumps(body.mcp_extensions) if body.mcp_extensions else None,
"wf_ids": json.dumps(body.workflow_ids) if body.workflow_ids else None,
"kb_ids": json.dumps(body.knowledge_base_ids) if body.knowledge_base_ids else None,
"agent_ids": json.dumps(body.agent_ids) if body.agent_ids else None,
"features": json.dumps(body.features) if body.features else None,
"config_json": config_json, "version": body.version, "now": now,
})
db.commit()
return {"code": 0, "data": {"id": distro_id}, "message": "发行版创建成功"}
@router.get("/templates", summary="发行版模板")
async def get_templates(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取可用资源模板:工作流、Agent、知识库、MCP扩展"""
# Get available workflows (any status)
try:
rows = _execute_raw(db, "SELECT id, name, status FROM agent_workflows ORDER BY updated_at DESC LIMIT 20")
cols = list(rows.keys())
workflows = [_row_to_dict(r, cols) for r in rows.fetchall()]
except Exception:
workflows = []
# Get available agents
try:
rows = _execute_raw(db, "SELECT id, name, description FROM market_apps WHERE type='agent' AND status='published' LIMIT 20")
cols = list(rows.keys())
agents = [_row_to_dict(r, cols) for r in rows.fetchall()]
except Exception:
agents = []
# Get available knowledge bases
rows = _execute_raw(db, "SELECT id, name, description FROM knowledge_bases WHERE status='active' LIMIT 20")
cols = list(rows.keys())
kbs = [_row_to_dict(r, cols) for r in rows.fetchall()]
# Get available MCP tools
try:
rows = _execute_raw(db, "SELECT id, app_id as name, server_type FROM mcp_tool_configs WHERE security_status='approved' LIMIT 20")
cols = list(rows.keys())
mcps = [_row_to_dict(r, cols) for r in rows.fetchall()]
except Exception:
mcps = []
return {
"code": 0,
"data": {
"workflows": workflows,
"agents": agents,
"knowledge_bases": kbs,
"mcp_tools": mcps,
}
}
@router.get("/stats", summary="发行版统计")
async def get_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
rows = _execute_raw(db, """
SELECT
COUNT(*) as total,
SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) as draft,
SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN status='archived' THEN 1 ELSE 0 END) as archived,
SUM(download_count) as total_downloads,
(SELECT COUNT(*) FROM distribution_licenses) as total_licenses,
(SELECT COUNT(*) FROM distribution_licenses WHERE status='active') as active_licenses
FROM distributions
""")
row = rows.fetchone()
cols = list(rows.keys())
stats = _row_to_dict(row, cols)
return {"code": 0, "data": stats}
# ==================== License Management ====================
def _generate_license_key(distro_id: str, org_name: str, features: list, expiry: str) -> str:
"""Generate HMAC-SHA256 license key"""
payload = f"{distro_id}:{org_name}:{json.dumps(sorted(features or []))}:{expiry or 'perpetual'}"
return hmac.new(
LICENSE_SECRET.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()[:32]
def _verify_license_signature(license_key: str, payload: str) -> bool:
"""Verify license key signature"""
expected = hmac.new(
LICENSE_SECRET.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()[:32]
return hmac.compare_digest(expected, license_key)
@router.get("/licenses", summary="许可证列表")
async def list_licenses(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
distribution_id: Optional[str] = None,
status: Optional[str] = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
conditions = []
params = {}
if distribution_id:
conditions.append("l.distribution_id = :did")
params["did"] = distribution_id
if status:
conditions.append("l.status = :status")
params["status"] = status
where = " AND ".join(conditions) if conditions else "1=1"
rows = _execute_raw(db, f"SELECT COUNT(*) FROM distribution_licenses l WHERE {where}", params)
total = rows.fetchone()[0]
offset = (page - 1) * page_size
rows = _execute_raw(db, f"""
SELECT l.*, d.name as distribution_name
FROM distribution_licenses l
LEFT JOIN distributions d ON l.distribution_id = d.id
WHERE {where}
ORDER BY l.created_at DESC
LIMIT :limit OFFSET :offset
""", {**params, "limit": page_size, "offset": offset})
cols = list(rows.keys())
items = []
for row in rows.fetchall():
item = _row_to_dict(row, cols)
for key in ["features", "metadata_json"]:
if item.get(key) and isinstance(item[key], str):
try:
item[key] = json.loads(item[key])
except (json.JSONDecodeError, TypeError):
pass
for key in ["created_at", "updated_at", "first_used_at", "last_used_at"]:
item[key] = _serialize_dt(item[key])
if item.get("expiry_date"):
item["expiry_date"] = str(item["expiry_date"])
items.append(item)
return {"code": 0, "data": {"total": total, "items": items, "page": page, "page_size": page_size}}
@router.post("/{distro_id}/licenses", summary="生成许可证")
async def create_license(
distro_id: str,
body: LicenseCreate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
rows = _execute_raw(db, "SELECT id, features FROM distributions WHERE id = :id", {"id": distro_id})
row = rows.fetchone()
if not row:
raise HTTPException(status_code=404, detail="发行版不存在")
distro_features = row[1]
if isinstance(distro_features, str):
try:
distro_features = json.loads(distro_features)
except json.JSONDecodeError:
distro_features = []
license_id = _generate_id("lic")
# Features limited to what distribution supports
requested_features = body.features or distro_features or ["chat"]
license_key = _generate_license_key(distro_id, body.org_name, requested_features, body.expiry_date)
now = datetime.now()
_execute_raw(db, """
INSERT INTO distribution_licenses
(id, distribution_id, org_name, licensee_email, license_key, features,
max_users, max_executions, expiry_date, status, created_at, updated_at)
VALUES (:id, :did, :org, :email, :key, :features,
:max_users, :max_exec, :expiry, 'active', :now, :now)
""", {
"id": license_id, "did": distro_id, "org": body.org_name, "email": body.licensee_email,
"key": license_key, "features": json.dumps(requested_features),
"max_users": body.max_users, "max_exec": body.max_executions,
"expiry": body.expiry_date, "now": now,
})
db.commit()
return {
"code": 0,
"data": {
"id": license_id,
"license_key": license_key,
"org_name": body.org_name,
"features": requested_features,
"expiry_date": body.expiry_date,
},
"message": "许可证生成成功"
}
@router.get("/licenses/{license_id}", summary="许可证详情")
async def get_license(
license_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
rows = _execute_raw(db, """
SELECT l.*, d.name as distribution_name
FROM distribution_licenses l
LEFT JOIN distributions d ON l.distribution_id = d.id
WHERE l.id = :id
""", {"id": license_id})
row = rows.fetchone()
if not row:
raise HTTPException(status_code=404, detail="许可证不存在")
cols = list(rows.keys())
lic = _row_to_dict(row, cols)
for key in ["features", "metadata_json"]:
if lic.get(key) and isinstance(lic[key], str):
try:
lic[key] = json.loads(lic[key])
except (json.JSONDecodeError, TypeError):
pass
for key in ["created_at", "updated_at", "first_used_at", "last_used_at"]:
lic[key] = _serialize_dt(lic[key])
if lic.get("expiry_date"):
lic["expiry_date"] = str(lic["expiry_date"])
return {"code": 0, "data": lic}
@router.post("/licenses/{license_id}/revoke", summary="吊销许可证")
async def revoke_license(
license_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
rows = _execute_raw(db, "SELECT id, status FROM distribution_licenses WHERE id = :id", {"id": license_id})
row = rows.fetchone()
if not row:
raise HTTPException(status_code=404, detail="许可证不存在")
_execute_raw(db, "UPDATE distribution_licenses SET status='revoked', updated_at=NOW() WHERE id = :id", {"id": license_id})
db.commit()
return {"code": 0, "message": "许可证已吊销"}
@router.post("/licenses/validate", summary="验证许可证")
async def validate_license(
body: LicenseValidate,
db: Session = Depends(get_db),
):
"""公开 API:验证许可证有效性(不需要 admin 权限)"""
rows = _execute_raw(db,
"SELECT * FROM distribution_licenses WHERE license_key = :key AND status='active'",
{"key": body.license_key}
)
row = rows.fetchone()
if not row:
return {"code": 1, "data": {"valid": False, "reason": "license_not_found"}, "message": "许可证不存在或已失效"}
cols = list(rows.keys())
lic = _row_to_dict(row, cols)
# Check expiry
if lic.get("expiry_date"):
expiry = lic["expiry_date"]
if isinstance(expiry, str):
expiry = datetime.strptime(expiry, "%Y-%m-%d").date()
if expiry < datetime.now().date():
return {"code": 1, "data": {"valid": False, "reason": "expired"}, "message": "许可证已过期"}
# Check usage limit
if lic.get("max_executions") and lic.get("usage_count", 0) >= lic["max_executions"]:
return {"code": 1, "data": {"valid": False, "reason": "usage_exceeded"}, "message": "许可证用量已超限"}
features = lic.get("features")
if isinstance(features, str):
try:
features = json.loads(features)
except json.JSONDecodeError:
features = []
# Update usage
_execute_raw(db, """
UPDATE distribution_licenses
SET usage_count = usage_count + 1,
last_used_at = NOW(),
first_used_at = COALESCE(first_used_at, NOW())
WHERE id = :id
""", {"id": lic["id"]})
db.commit()
return {
"code": 0,
"data": {
"valid": True,
"org_name": lic.get("org_name"),
"features": features,
"max_users": lic.get("max_users"),
"max_executions": lic.get("max_executions"),
"usage_count": lic.get("usage_count", 0) + 1,
"expiry_date": str(lic["expiry_date"]) if lic.get("expiry_date") else None,
}
}
@router.get("/{distro_id}", summary="发行版详情")
async def get_distribution(
distro_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
rows = _execute_raw(db, "SELECT * FROM distributions WHERE id = :id", {"id": distro_id})
row = rows.fetchone()
if not row:
raise HTTPException(status_code=404, detail="发行版不存在")
cols = list(rows.keys())
d = _row_to_dict(row, cols)
for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]:
if d.get(key) and isinstance(d[key], str):
try:
d[key] = json.loads(d[key])
except (json.JSONDecodeError, TypeError):
pass
for key in ["created_at", "updated_at"]:
d[key] = _serialize_dt(d[key])
# Get associated licenses count
lic_rows = _execute_raw(db, "SELECT COUNT(*) as cnt FROM distribution_licenses WHERE distribution_id = :did", {"did": distro_id})
d["license_count"] = lic_rows.fetchone()[0]
return {"code": 0, "data": d}
@router.put("/{distro_id}", summary="更新发行版")
async def update_distribution(
distro_id: str,
body: DistroUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
rows = _execute_raw(db, "SELECT id FROM distributions WHERE id = :id", {"id": distro_id})
if not rows.fetchone():
raise HTTPException(status_code=404, detail="发行版不存在")
updates = []
params = {"id": distro_id}
for field, value in body.dict(exclude_unset=True).items():
if field in ("mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features"):
updates.append(f"{field} = :{field}")
params[field] = json.dumps(value) if value else None
else:
updates.append(f"{field} = :{field}")
params[field] = value
if updates:
updates.append("updated_at = NOW()")
_execute_raw(db, f"UPDATE distributions SET {', '.join(updates)} WHERE id = :id", params)
db.commit()
return {"code": 0, "message": "更新成功"}
@router.delete("/{distro_id}", summary="删除发行版")
async def delete_distribution(
distro_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
rows = _execute_raw(db, "SELECT id FROM distributions WHERE id = :id", {"id": distro_id})
if not rows.fetchone():
raise HTTPException(status_code=404, detail="发行版不存在")
# Delete associated licenses
_execute_raw(db, "DELETE FROM distribution_licenses WHERE distribution_id = :id", {"id": distro_id})
_execute_raw(db, "DELETE FROM distributions WHERE id = :id", {"id": distro_id})
db.commit()
return {"code": 0, "message": "删除成功"}
# ==================== Build & Export ====================
@router.post("/{distro_id}/build", summary="构建配置包")
async def build_distribution(
distro_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""构建发行版配置包 ZIP (YAML config + recipes + extensions + license template)"""
rows = _execute_raw(db, "SELECT * FROM distributions WHERE id = :id", {"id": distro_id})
row = rows.fetchone()
if not row:
raise HTTPException(status_code=404, detail="发行版不存在")
cols = list(rows.keys())
d = _row_to_dict(row, cols)
# Parse JSON fields
for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]:
if d.get(key) and isinstance(d[key], str):
try:
d[key] = json.loads(d[key])
except (json.JSONDecodeError, TypeError):
d[key] = [] if "ids" in key else ({} if key == "config_json" else [])
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# 1. init-config.yaml
init_config = {
"GOOSE_PROVIDER": d.get("provider", "siliconflow"),
"GOOSE_MODEL": d.get("model", "deepseek-ai/DeepSeek-V4-Flash"),
"PLATFORM_API_URL": "https://your-platform.com/api",
"LICENSE_KEY": "YOUR_LICENSE_KEY_HERE",
}
zf.writestr("init-config.yaml", yaml.dump(init_config, allow_unicode=True, default_flow_style=False))
# 2. config.yaml (MCP extensions)
config = {
"extensions": {},
}
for ext in (d.get("mcp_extensions") or []):
ext_id = ext.get("id", str(uuid.uuid4().hex[:8]))
config["extensions"][ext_id] = {
"name": ext.get("name", "unnamed"),
"cmd": ext.get("cmd", ""),
"args": ext.get("args", []),
"enabled": True,
"type": ext.get("type", "stdio"),
"timeout": ext.get("timeout", 30000),
}
zf.writestr("config.yaml", yaml.dump(config, allow_unicode=True, default_flow_style=False))
# 3. Recipes (workflow exports)
wf_ids = d.get("workflow_ids") or []
for wf_id in wf_ids:
wf_rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": wf_id})
wf_row = wf_rows.fetchone()
if wf_row:
wf_cols = list(wf_rows.keys())
wf = _row_to_dict(wf_row, wf_cols)
recipe = {
"name": wf.get("name", wf_id),
"description": wf.get("description", ""),
"steps": [],
}
nodes = wf.get("nodes")
if isinstance(nodes, str):
try:
nodes = json.loads(nodes)
except json.JSONDecodeError:
nodes = []
for node in (nodes or []):
recipe["steps"].append({
"type": node.get("type", ""),
"label": node.get("data", {}).get("label", ""),
"config": node.get("data", {}).get("config", {}),
})
zf.writestr(f"recipes/{wf_id}.yaml", yaml.dump(recipe, allow_unicode=True, default_flow_style=False))
# 4. Branding
zf.writestr("branding/system-prompt.md", d.get("system_prompt", "# AI Assistant\n\nYou are a helpful AI assistant."))
branding = {
"app_name": d.get("brand_name", d.get("name", "AI Assistant")),
"brand_color": d.get("brand_color", "#6366f1"),
"version": d.get("version", "1.0.0"),
}
zf.writestr("branding/manifest.yaml", yaml.dump(branding, allow_unicode=True))
# 5. License template
license_template = {
"license_key": "TO_BE_GENERATED",
"org_name": "YOUR_ORGANIZATION",
"expiry": None,
"features": d.get("features", ["chat"]),
}
zf.writestr("license.template.yaml", yaml.dump(license_template, allow_unicode=True))
# 6. install.sh
install_script = """#!/bin/bash
# Distribution Installation Script
echo "Installing Distribution: {name}"
echo "Version: {version}"
echo ""
echo "1. Copy init-config.yaml to ~/.config/goose/"
cp init-config.yaml ~/.config/goose/init-config.yaml
echo "2. Copy config.yaml"
cp config.yaml ~/.config/goose/config.yaml
echo "3. Copy recipes"
mkdir -p ~/.config/goose/recipes/
cp -r recipes/ ~/.config/goose/recipes/
echo "4. Copy branding"
mkdir -p ~/.config/goose/branding/
cp -r branding/ ~/.config/goose/branding/
echo "Installation complete!"
""".format(name=d.get("name", "Unnamed"), version=d.get("version", "1.0.0"))
zf.writestr("install.sh", install_script)
# 7. README
readme = f"""# {d.get('brand_name', d.get('name', 'Distribution'))}
## Description
{d.get('description', 'No description')}
## Included Features
""" + "\n".join([f"- {f}" for f in (d.get("features") or ["chat"])]) + f"""
## Version
{d.get('version', '1.0.0')}
## Installation
Run `bash install.sh` to install.
## License
A valid license key is required. Contact your administrator for details.
"""
zf.writestr("README.md", readme)
# Update download count and size
size = buf.tell()
_execute_raw(db, "UPDATE distributions SET download_count = download_count + 1, size_bytes = :size WHERE id = :id",
{"size": size, "id": distro_id})
db.commit()
buf.seek(0)
filename = f"{d.get('name', 'distro').replace(' ', '_')}-{d.get('version', '1.0.0')}.zip"
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.get("/{distro_id}/download", summary="下载配置包")
async def download_distribution(
distro_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""直接下载已生成的配置包"""
return await build_distribution(distro_id, admin_user, db)
# ==================== LLM Proxy API ====================
@router.post("/llm-proxy/{api_key:path}", summary="LLM API 代理")
async def llm_proxy(
api_key: str,
admin_user=Depends(get_current_admin),
):
"""LLM 代理端点状态检查"""
return {
"code": 0,
"data": {
"status": "ready",
"providers": ["siliconflow", "deepseek"],
"message": "LLM Proxy is operational. Use POST /admin/distributions/llm-proxy/chat to call models."
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+449
View File
@@ -0,0 +1,449 @@
"""
MCP (Model Context Protocol) Server Management API
Phase 3.3: Manage MCP servers, discover tools/resources, test connections
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from sqlalchemy import or_, func, desc, asc, text
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
import uuid
import json
import logging
from ...database import get_db
from ...models import gen_uuid
from ...auth import get_current_admin
from ...middleware.log_service import LogService
router = APIRouter()
logger = logging.getLogger("aiflow")
# ============================================================
# Pydantic Models
# ============================================================
class MCPServerCreate(BaseModel):
name: str
description: Optional[str] = ""
transport_type: str = "stdio"
command: Optional[str] = ""
args: Optional[Any] = None
env_vars: Optional[Dict[str, str]] = None
url: Optional[str] = ""
headers: Optional[Dict[str, str]] = None
is_enabled: Optional[bool] = True
class MCPServerUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
transport_type: Optional[str] = None
command: Optional[str] = None
args: Optional[Any] = None
env_vars: Optional[Dict[str, str]] = None
url: Optional[str] = None
headers: Optional[Dict[str, str]] = None
is_enabled: Optional[bool] = None
class MCPServerConnectionTest(BaseModel):
transport_type: Optional[str] = None
command: Optional[str] = None
args: Optional[Any] = None
env_vars: Optional[Dict[str, str]] = None
url: Optional[str] = None
headers: Optional[Dict[str, str]] = None
# ============================================================
# Helper Functions
# ============================================================
def _format_cst_time(dt):
"""Format datetime to CST string"""
if dt is None:
return None
try:
from datetime import timezone, timedelta
CST = timezone(timedelta(hours=8))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=CST)
return dt.astimezone(CST).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def server_to_dict(server) -> dict:
"""Convert MCPServer model to dict"""
return {
"id": server.id,
"name": server.name,
"description": server.description or "",
"transport_type": server.transport_type or "stdio",
"transport_type_label": {"stdio": "STDIO", "sse": "SSE"}.get(server.transport_type, server.transport_type),
"command": server.command or "",
"args": json.loads(server.args) if server.args else {},
"env_vars": json.loads(server.env_vars) if server.env_vars else {},
"url": server.url or "",
"headers": json.loads(server.headers) if server.headers else {},
"is_enabled": server.is_enabled if server.is_enabled is not None else True,
"status": server.status or "disconnected",
"last_connected_at": _format_cst_time(server.last_connected_at),
"tools_count": server.tools_count or 0,
"resources_count": server.resources_count or 0,
"created_at": _format_cst_time(server.created_at),
"updated_at": _format_cst_time(server.updated_at),
}
# ============================================================
# API Endpoints
# ============================================================
@router.get("/stats/overview", summary="Get MCP server statistics")
async def get_mcp_stats(
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""Get overview statistics for MCP servers"""
from ...models import MCPServer
total = db.query(func.count(MCPServer.id)).scalar() or 0
enabled = db.query(func.count(MCPServer.id)).filter(MCPServer.is_enabled == True).scalar() or 0
connected = db.query(func.count(MCPServer.id)).filter(MCPServer.status == "connected").scalar() or 0
# By transport type
by_transport = {}
rows = db.query(MCPServer.transport_type, func.count(MCPServer.id)).group_by(MCPServer.transport_type).all()
for t, c in rows:
by_transport[t or "unknown"] = c
# By status
by_status = {}
rows = db.query(MCPServer.status, func.count(MCPServer.id)).group_by(MCPServer.status).all()
for s, c in rows:
by_status[s or "unknown"] = c
return {
"code": 0,
"data": {
"total_servers": total,
"enabled_servers": enabled,
"connected_servers": connected,
"by_transport": by_transport,
"by_status": by_status,
},
"message": "ok",
}
@router.get("/", summary="List MCP servers")
async def list_mcp_servers(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = Query(None),
transport_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
is_enabled: Optional[bool] = Query(None),
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""List all MCP servers with pagination and filters"""
from ...models import MCPServer
query = db.query(MCPServer)
if search:
query = query.filter(
or_(MCPServer.name.contains(search), MCPServer.description.contains(search))
)
if transport_type:
query = query.filter(MCPServer.transport_type == transport_type)
if status:
query = query.filter(MCPServer.status == status)
if is_enabled is not None:
query = query.filter(MCPServer.is_enabled == is_enabled)
total = query.count()
servers = query.order_by(desc(MCPServer.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [server_to_dict(s) for s in servers],
},
"message": "ok",
}
@router.get("/{server_id}", summary="Get MCP server details")
async def get_mcp_server(
server_id: str,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""Get a single MCP server by ID"""
from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="MCP server not found")
return {"code": 0, "data": server_to_dict(server), "message": "ok"}
@router.post("/", summary="Create MCP server")
async def create_mcp_server(
data: MCPServerCreate,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""Register a new MCP server"""
from ...models import MCPServer
# Check name uniqueness
existing = db.query(MCPServer).filter(MCPServer.name == data.name).first()
if existing:
raise HTTPException(status_code=400, detail="MCP server with this name already exists")
now = datetime.now()
server = MCPServer(
id=gen_uuid(),
name=data.name,
description=data.description or "",
transport_type=data.transport_type,
command=data.command or "",
args=json.dumps(data.args) if data.args else "{}",
env_vars=json.dumps(data.env_vars) if data.env_vars else "{}",
url=data.url or "",
headers=json.dumps(data.headers) if data.headers else "{}",
is_enabled=data.is_enabled if data.is_enabled is not None else True,
status="disconnected",
created_at=now,
updated_at=now,
)
db.add(server)
db.commit()
db.refresh(server)
logger.info(f"MCP server created: {server.name} ({server.id})")
return {"code": 0, "data": server_to_dict(server), "message": "MCP server created"}
@router.put("/{server_id}", summary="Update MCP server")
async def update_mcp_server(
server_id: str,
data: MCPServerUpdate,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""Update an existing MCP server"""
from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="MCP server not found")
update_fields = data.model_dump(exclude_unset=True)
# Handle JSON fields
for json_field in ["args", "env_vars", "headers"]:
if json_field in update_fields and update_fields[json_field] is not None:
update_fields[json_field] = json.dumps(update_fields[json_field])
for key, value in update_fields.items():
if value is not None:
setattr(server, key, value)
server.updated_at = datetime.now()
db.commit()
db.refresh(server)
logger.info(f"MCP server updated: {server.name} ({server.id})")
return {"code": 0, "data": server_to_dict(server), "message": "MCP server updated"}
@router.delete("/{server_id}", summary="Delete MCP server")
async def delete_mcp_server(
server_id: str,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""Delete an MCP server"""
from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="MCP server not found")
name = server.name
db.delete(server)
db.commit()
logger.info(f"MCP server deleted: {name} ({server_id})")
return {"code": 0, "data": None, "message": "MCP server deleted"}
@router.put("/{server_id}/toggle", summary="Enable/Disable MCP server")
async def toggle_mcp_server(
server_id: str,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""Toggle MCP server enabled state"""
from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="MCP server not found")
server.is_enabled = not server.is_enabled
server.updated_at = datetime.now()
db.commit()
logger.info(f"MCP server toggled: {server.name} -> enabled={server.is_enabled}")
return {
"code": 0,
"data": {"id": server.id, "is_enabled": server.is_enabled},
"message": "MCP server toggled",
}
@router.post("/{server_id}/test", summary="Test MCP server connection")
async def test_mcp_connection(
server_id: str,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
test_data: Optional[MCPServerConnectionTest] = None,
):
"""Test connection to an MCP server"""
from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="MCP server not found")
transport = test_data.transport_type if test_data and test_data.transport_type else server.transport_type
result = {"success": False, "message": "", "details": {}}
try:
if transport == "sse":
sse_url = (test_data.url if test_data else None) or server.url
if not sse_url:
result["message"] = "SSE URL is required"
return {"code": 0, "data": result, "message": "ok"}
# Test SSE endpoint with a simple HTTP request
import httpx
try:
response = httpx.get(sse_url, timeout=10.0)
result["success"] = response.status_code < 500
result["message"] = f"SSE endpoint responded with {response.status_code}"
result["details"]["status_code"] = response.status_code
result["details"]["content_type"] = response.headers.get("content-type", "")
except httpx.ConnectError:
result["message"] = "Connection refused - server may be down"
except httpx.TimeoutException:
result["message"] = "Connection timed out"
elif transport == "stdio":
cmd = (test_data.command if test_data else None) or server.command
if not cmd:
result["message"] = "STDIO command is required"
return {"code": 0, "data": result, "message": "ok"}
# Basic command existence check (not actually executing MCP handshake)
import subprocess
try:
subprocess.run([cmd, "--version"], capture_output=True, timeout=10)
result["success"] = True
result["message"] = "Command found and executable"
except FileNotFoundError:
result["message"] = "Command not found in PATH"
except subprocess.TimeoutExpired:
result["message"] = "Command timed out"
except Exception as e:
result["message"] = f"Command test failed"
result["details"]["error"] = str(e)
# Update server status
server.status = "connected" if result["success"] else "error"
if result["success"]:
server.last_connected_at = datetime.now()
server.updated_at = datetime.now()
db.commit()
except Exception as e:
result["message"] = str(e)
result["details"]["error"] = str(e)
server.status = "error"
server.updated_at = datetime.now()
db.commit()
return {"code": 0, "data": result, "message": "ok"}
@router.get("/{server_id}/tools", summary="List MCP server tools")
async def list_mcp_tools(
server_id: str,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""List tools exposed by an MCP server (placeholder/mock)"""
from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="MCP server not found")
# In a real implementation, this would connect to the MCP server and call tools/list
# For now, return mock data based on common MCP tools
mock_tools = []
if server.transport_type == "stdio":
mock_tools = [
{"name": "read_file", "description": "Read contents of a file"},
{"name": "write_file", "description": "Write contents to a file"},
{"name": "execute_command", "description": "Execute a shell command"},
{"name": "search_content", "description": "Search for content in files"},
]
elif server.transport_type == "sse":
mock_tools = [
{"name": "web_search", "description": "Search the web"},
{"name": "fetch_url", "description": "Fetch content from URL"},
{"name": "api_request", "description": "Make API requests"},
]
return {
"code": 0,
"data": {
"server_name": server.name,
"tools": mock_tools,
"count": len(mock_tools),
},
"message": "ok",
}
@router.get("/{server_id}/resources", summary="List MCP server resources")
async def list_mcp_resources(
server_id: str,
db: Session = Depends(get_db),
admin_user=Depends(get_current_admin),
):
"""List resources exposed by an MCP server (placeholder/mock)"""
from ...models import MCPServer
server = db.query(MCPServer).filter(MCPServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="MCP server not found")
mock_resources = [
{"uri": f"{server.name}://docs/readme", "name": "Documentation", "mime_type": "text/markdown"},
{"uri": f"{server.name}://config/settings", "name": "Configuration", "mime_type": "application/json"},
]
return {
"code": 0,
"data": {
"server_name": server.name,
"resources": mock_resources,
"count": len(mock_resources),
},
"message": "ok",
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,799 @@
"""
Phase G5: Privacy & Local Agent Administration API
- Privacy config management (local/cloud/hybrid modes)
- Offline license generation & validation (JWT-based)
- Knowledge base sync state tracking
- Anonymized usage reporting
"""
import json
import hashlib
import hmac
import time
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from ...database import SessionLocal
from ...auth import get_current_user
router = APIRouter()
PRIVACY_SECRET = "g5-privacy-secret-key-2026"
LICENSE_SECRET = "g5-license-hmac-secret-2026"
# ========================================================================
# Helpers
# ========================================================================
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def _execute_raw(db: Session, sql_text, params=None):
"""Execute raw SQL and return results"""
result = db.execute(sql_text, params or {})
if result.returns_rows:
return result.fetchall()
db.commit()
return result
def _row_to_dict(row, keys):
"""Convert raw SQL row to dict"""
if row is None:
return None
d = {}
for i, k in enumerate(keys):
val = row[i]
if isinstance(val, datetime):
val = val.strftime("%Y-%m-%d %H:%M:%S")
d[k] = val
return d
def _rows_to_list(rows, keys):
return [_row_to_dict(r, keys) for r in rows]
def _anonymize_user_id(user_id: str) -> str:
"""SHA256 hash for anonymized usage reporting"""
return hashlib.sha256(f"{user_id}:{PRIVACY_SECRET}".encode()).hexdigest()
def _generate_offline_license(user_id: str, agent_id: str, max_days: int = 7) -> dict:
"""Generate JWT-like offline license with HMAC signature"""
now = int(time.time())
expires_at = now + max_days * 86400
payload = {
"user_id": user_id,
"agent_id": agent_id,
"issued_at": now,
"expires_at": expires_at,
"max_offline_days": max_days,
"jti": uuid.uuid4().hex[:16],
"type": "offline_license"
}
payload_bytes = json.dumps(payload, sort_keys=True).encode()
signature = hmac.new(
LICENSE_SECRET.encode(),
payload_bytes,
hashlib.sha256
).hexdigest()
license_key = f"{payload_bytes.decode()}.{signature}"
return {
"license_key": license_key,
"payload": payload,
"expires_at": datetime.fromtimestamp(expires_at),
}
def _validate_offline_license(license_key: str) -> dict:
"""Validate offline license signature and expiry"""
if "." not in license_key:
raise ValueError("Invalid license format")
payload_str, signature = license_key.rsplit(".", 1)
expected_sig = hmac.new(
LICENSE_SECRET.encode(),
payload_str.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_sig):
raise ValueError("Invalid license signature")
payload = json.loads(payload_str)
now = int(time.time())
if now > payload["expires_at"]:
raise ValueError("License expired")
return payload
# ========================================================================
# 1. Privacy Config Management
# ========================================================================
@router.get("/config")
def get_privacy_config(user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
"""Get user's privacy configuration"""
result = _execute_raw(db, text(
"SELECT * FROM privacy_configs WHERE user_id = :uid ORDER BY updated_at DESC LIMIT 1"
), {"uid": user_id})
keys = ["id", "user_id", "agent_id", "privacy_mode", "local_llm_provider",
"local_llm_model", "local_llm_endpoint", "data_retention_days",
"anonymize_usage", "sync_knowledge_bases", "sync_frequency",
"last_sync_at", "status", "created_at", "updated_at"]
if result:
row = result[0]
config = _row_to_dict(row, keys)
if config.get("sync_knowledge_bases"):
try:
config["sync_knowledge_bases"] = json.loads(config["sync_knowledge_bases"])
except Exception:
pass
return {"code": 0, "data": config}
# Return default config
return {"code": 0, "data": {
"privacy_mode": "cloud",
"local_llm_provider": None,
"local_llm_model": None,
"local_llm_endpoint": None,
"data_retention_days": 30,
"anonymize_usage": True,
"sync_knowledge_bases": [],
"sync_frequency": "daily",
"status": "active"
}}
@router.put("/config")
def update_privacy_config(
privacy_mode: str = Query(None),
local_llm_provider: str = Query(None),
local_llm_model: str = Query(None),
local_llm_endpoint: str = Query(None),
data_retention_days: int = Query(None),
anonymize_usage: bool = Query(None),
sync_knowledge_bases: str = Query(None),
sync_frequency: str = Query(None),
user_id: str = Query("admin-001"),
db: Session = Depends(get_db)
):
"""Update privacy configuration"""
valid_modes = ["local", "cloud", "hybrid"]
valid_freqs = ["realtime", "hourly", "daily", "manual"]
if privacy_mode and privacy_mode not in valid_modes:
raise HTTPException(400, f"Invalid mode: {privacy_mode}")
if sync_frequency and sync_frequency not in valid_freqs:
raise HTTPException(400, f"Invalid frequency: {sync_frequency}")
# Check if config exists
existing = _execute_raw(db, text(
"SELECT id FROM privacy_configs WHERE user_id = :uid"
), {"uid": user_id})
sets = []
params = {"uid": user_id}
if privacy_mode:
sets.append("privacy_mode = :mode")
params["mode"] = privacy_mode
if local_llm_provider is not None:
sets.append("local_llm_provider = :provider")
params["provider"] = local_llm_provider
if local_llm_model is not None:
sets.append("local_llm_model = :model")
params["model"] = local_llm_model
if local_llm_endpoint is not None:
sets.append("local_llm_endpoint = :endpoint")
params["endpoint"] = local_llm_endpoint
if data_retention_days is not None:
sets.append("data_retention_days = :retention")
params["retention"] = data_retention_days
if anonymize_usage is not None:
sets.append("anonymize_usage = :anon")
params["anon"] = 1 if anonymize_usage else 0
if sync_knowledge_bases is not None:
sets.append("sync_knowledge_bases = :kb_ids")
params["kb_ids"] = sync_knowledge_bases
if sync_frequency:
sets.append("sync_frequency = :freq")
params["freq"] = sync_frequency
if not existing:
sets.append("user_id = :uid")
sets.append("created_at = NOW()")
sets.append("updated_at = NOW()")
sql = f"INSERT INTO privacy_configs SET {', '.join(sets)}"
else:
sets.append("updated_at = NOW()")
sql = f"UPDATE privacy_configs SET {', '.join(sets)} WHERE user_id = :uid"
_execute_raw(db, text(sql), params)
return {"code": 0, "message": "Config updated"}
@router.get("/modes")
def list_privacy_modes():
"""List available privacy modes with descriptions"""
return {"code": 0, "data": [
{
"mode": "cloud",
"name": "云端模式",
"description": "所有数据和计算在云端处理,适用于一般场景",
"pros": ["无需本地资源", "完整的平台功能", "自动更新"],
"cons": ["数据离开本地", "需要持续网络连接"],
"recommended_for": ["一般用户", "非敏感数据"]
},
{
"mode": "local",
"name": "本地模式",
"description": "数据完全本地处理,仅通过Platform Bridge同步配置",
"pros": ["数据不离开本地", "完全隐私保护", "离线可用"],
"cons": ["需要本地GPU/NPU", "功能有限", "需手动同步"],
"recommended_for": ["政务", "金融", "医疗"]
},
{
"mode": "hybrid",
"name": "混合模式",
"description": "敏感数据本地处理,通用能力云端调用",
"pros": ["灵活配置", "平衡隐私与功能"],
"cons": ["配置复杂", "需要网络连接"],
"recommended_for": ["企业级用户", "合规需求"]
}
]}
@router.post("/switch-mode")
def switch_privacy_mode(
mode: str = Query(...),
user_id: str = Query("admin-001"),
db: Session = Depends(get_db)
):
"""Switch privacy mode with validation"""
valid_modes = ["local", "cloud", "hybrid"]
if mode not in valid_modes:
raise HTTPException(400, f"Invalid mode. Must be one of: {valid_modes}")
existing = _execute_raw(db, text(
"SELECT id FROM privacy_configs WHERE user_id = :uid"
), {"uid": user_id})
if existing:
_execute_raw(db, text(
"UPDATE privacy_configs SET privacy_mode = :mode, updated_at = NOW() WHERE user_id = :uid"
), {"mode": mode, "uid": user_id})
else:
_execute_raw(db, text(
"INSERT INTO privacy_configs (user_id, privacy_mode, created_at, updated_at) VALUES (:uid, :mode, NOW(), NOW())"
), {"uid": user_id, "mode": mode})
return {"code": 0, "message": f"Switched to {mode} mode", "data": {"mode": mode}}
# ========================================================================
# 2. Offline License Management
# ========================================================================
@router.post("/licenses/generate")
def generate_offline_license(
agent_id: str = Query(None),
max_offline_days: int = Query(7),
device_fingerprint: str = Query(None),
user_id: str = Query("admin-001"),
db: Session = Depends(get_db)
):
"""Generate a new offline license"""
if max_offline_days < 1 or max_offline_days > 90:
raise HTTPException(400, "max_offline_days must be between 1 and 90")
license_info = _generate_offline_license(
user_id, agent_id or "default", max_offline_days
)
license_key = license_info["license_key"]
payload = license_info["payload"]
_execute_raw(db, text("""
INSERT INTO offline_licenses (license_key, user_id, agent_id, device_fingerprint,
issued_at, expires_at, max_offline_days, status)
VALUES (:lk, :uid, :aid, :fp, NOW(), :exp, :max_days, 'active')
"""), {
"lk": license_key,
"uid": user_id,
"aid": agent_id,
"fp": device_fingerprint,
"exp": datetime.fromtimestamp(payload["expires_at"]),
"max_days": max_offline_days,
})
return {"code": 0, "data": {
"license_key": license_key,
"issued_at": payload["issued_at"],
"expires_at": payload["expires_at"],
"max_offline_days": max_offline_days,
"status": "active"
}}
@router.get("/licenses")
def list_offline_licenses(
user_id: str = Query("admin-001"),
status: str = Query(None),
page: int = Query(1),
page_size: int = Query(20),
db: Session = Depends(get_db)
):
"""List user's offline licenses"""
where = "WHERE user_id = :uid"
params = {"uid": user_id}
if status:
where += " AND status = :st"
params["st"] = status
count_row = _execute_raw(db, text(
f"SELECT COUNT(*) FROM offline_licenses {where}"
), params)
total = count_row[0][0] if count_row else 0
offset = (page - 1) * page_size
rows = _execute_raw(db, text(
f"SELECT * FROM offline_licenses {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
), {**params, "limit": page_size, "offset": offset})
keys = ["id", "license_key", "user_id", "agent_id", "device_fingerprint",
"issued_at", "expires_at", "last_validated_at", "max_offline_days",
"is_active", "revoked_reason", "status", "created_at", "updated_at"]
return {"code": 0, "data": {
"items": _rows_to_list(rows, keys),
"total": total,
"page": page,
"page_size": page_size
}}
@router.get("/licenses/{license_id}")
def get_license_detail(license_id: int, db: Session = Depends(get_db)):
"""Get offline license detail"""
result = _execute_raw(db, text(
"SELECT * FROM offline_licenses WHERE id = :lid"
), {"lid": license_id})
keys = ["id", "license_key", "user_id", "agent_id", "device_fingerprint",
"issued_at", "expires_at", "last_validated_at", "max_offline_days",
"is_active", "revoked_reason", "status", "created_at", "updated_at"]
if not result:
raise HTTPException(404, "License not found")
return {"code": 0, "data": _row_to_dict(result[0], keys)}
@router.post("/licenses/{license_id}/refresh")
def refresh_license(
license_id: int,
extend_days: int = Query(7),
db: Session = Depends(get_db)
):
"""Refresh an existing license (extend expiry)"""
result = _execute_raw(db, text(
"SELECT * FROM offline_licenses WHERE id = :lid"
), {"lid": license_id})
if not result:
raise HTTPException(404, "License not found")
row = result[0]
status = row[11] # status column index
if status != "active":
raise HTTPException(400, f"Cannot refresh license with status: {status}")
current_expires = row[5] # expires_at
if isinstance(current_expires, str):
current_expires = datetime.strptime(current_expires, "%Y-%m-%d %H:%M:%S")
new_expires = current_expires + timedelta(days=extend_days)
_execute_raw(db, text(
"UPDATE offline_licenses SET expires_at = :exp, last_validated_at = NOW() WHERE id = :lid"
), {"exp": new_expires, "lid": license_id})
return {"code": 0, "message": f"License extended by {extend_days} days",
"data": {"new_expires_at": new_expires.strftime("%Y-%m-%d %H:%M:%S")}}
@router.post("/licenses/validate")
def validate_license(
license_key: str = Query(...),
device_fingerprint: str = Query(None),
db: Session = Depends(get_db)
):
"""Validate an offline license (called by local agent)"""
try:
payload = _validate_offline_license(license_key)
except ValueError as e:
return {"code": 1, "message": str(e), "data": {"valid": False}}
# Check DB record
result = _execute_raw(db, text(
"SELECT id, status, expires_at, device_fingerprint FROM offline_licenses WHERE license_key = :lk"
), {"lk": license_key})
if not result:
return {"code": 0, "data": {"valid": True, "warning": "License not in DB (may be pre-generated)"}}
db_status = result[0][1]
if db_status != "active":
return {"code": 1, "message": f"License status: {db_status}", "data": {"valid": False}}
# Update last_validated_at
_execute_raw(db, text(
"UPDATE offline_licenses SET last_validated_at = NOW() WHERE id = :lid"
), {"lid": result[0][0]})
return {"code": 0, "data": {
"valid": True,
"user_id": payload["user_id"],
"agent_id": payload["agent_id"],
"expires_at": payload["expires_at"],
"max_offline_days": payload["max_offline_days"]
}}
@router.post("/licenses/{license_id}/revoke")
def revoke_license(
license_id: int,
reason: str = Query("Manually revoked"),
db: Session = Depends(get_db)
):
"""Revoke an offline license"""
result = _execute_raw(db, text(
"SELECT id, status FROM offline_licenses WHERE id = :lid"
), {"lid": license_id})
if not result:
raise HTTPException(404, "License not found")
_execute_raw(db, text(
"UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = 0 WHERE id = :lid"
), {"reason": reason, "lid": license_id})
return {"code": 0, "message": "License revoked"}
# ========================================================================
# 3. Knowledge Base Sync
# ========================================================================
@router.get("/kb-sync")
def list_kb_sync_states(
user_id: str = Query("admin-001"),
db: Session = Depends(get_db)
):
"""List knowledge base sync states"""
rows = _execute_raw(db, text(
"SELECT * FROM kb_sync_states WHERE user_id = :uid ORDER BY updated_at DESC"
), {"uid": user_id})
keys = ["id", "user_id", "knowledge_base_id", "local_version", "remote_version",
"last_sync_at", "sync_status", "total_docs", "synced_docs",
"sync_direction", "error_message", "created_at", "updated_at"]
result = []
for row in rows:
d = _row_to_dict(row, keys)
# Get KB name
kb_row = _execute_raw(db, text(
"SELECT name FROM knowledge_bases WHERE id = :kbid"
), {"kbid": row[2]})
d["kb_name"] = kb_row[0][0] if kb_row else f"KB-{row[2]}"
result.append(d)
return {"code": 0, "data": result}
@router.post("/kb-sync/trigger")
def trigger_kb_sync(
knowledge_base_id: int = Query(...),
sync_direction: str = Query("pull"),
user_id: str = Query("admin-001"),
db: Session = Depends(get_db)
):
"""Trigger knowledge base sync"""
valid_dirs = ["push", "pull", "bidirectional"]
if sync_direction not in valid_dirs:
raise HTTPException(400, f"Invalid direction: {sync_direction}")
# Check or create sync state
existing = _execute_raw(db, text(
"SELECT id FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid"
), {"uid": user_id, "kbid": knowledge_base_id})
if existing:
_execute_raw(db, text("""
UPDATE kb_sync_states SET sync_status = 'syncing', sync_direction = :dir,
last_sync_at = NOW(), error_message = NULL
WHERE id = :sid
"""), {"dir": sync_direction, "sid": existing[0][0]})
else:
_execute_raw(db, text("""
INSERT INTO kb_sync_states (user_id, knowledge_base_id, sync_status,
sync_direction, last_sync_at)
VALUES (:uid, :kbid, 'syncing', :dir, NOW())
"""), {"uid": user_id, "kbid": knowledge_base_id, "dir": sync_direction})
# Simulate sync: count KB docs as "synced"
doc_count = _execute_raw(db, text(
"SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid"
), {"kbid": knowledge_base_id})
total = doc_count[0][0] if doc_count else 0
_execute_raw(db, text("""
UPDATE kb_sync_states SET sync_status = 'synced', total_docs = :total,
synced_docs = :synced, remote_version = remote_version + 1,
error_message = NULL
WHERE user_id = :uid AND knowledge_base_id = :kbid
"""), {"total": total, "synced": total, "uid": user_id, "kbid": knowledge_base_id})
return {"code": 0, "message": f"Sync triggered: {sync_direction}",
"data": {"total_docs": total, "synced_docs": total, "status": "synced"}}
@router.get("/kb-sync/{kb_id}/status")
def get_kb_sync_status(kb_id: int, user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
"""Get sync status for a specific KB"""
result = _execute_raw(db, text(
"SELECT * FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid"
), {"uid": user_id, "kbid": kb_id})
keys = ["id", "user_id", "knowledge_base_id", "local_version", "remote_version",
"last_sync_at", "sync_status", "total_docs", "synced_docs",
"sync_direction", "error_message", "created_at", "updated_at"]
if not result:
return {"code": 0, "data": {"sync_status": "pending", "knowledge_base_id": kb_id}}
return {"code": 0, "data": _row_to_dict(result[0], keys)}
@router.get("/kb-sync/{kb_id}/diff")
def get_kb_sync_diff(kb_id: int, user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
"""Get document diff since last sync"""
result = _execute_raw(db, text(
"SELECT last_sync_at FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid"
), {"uid": user_id, "kbid": kb_id})
last_sync = result[0][0] if result else None
if last_sync:
new_docs = _execute_raw(db, text(
"SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid AND created_at > :since"
), {"kbid": kb_id, "since": last_sync})
updated_docs = _execute_raw(db, text(
"SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid AND updated_at > :since AND created_at <= :since"
), {"kbid": kb_id, "since": last_sync})
deleted_count = 0 # Soft delete not tracked in this version
else:
total = _execute_raw(db, text(
"SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid"
), {"kbid": kb_id})
total = total[0][0] if total else 0
new_docs = [(total,)]
updated_docs = [(0,)]
deleted_count = 0
return {"code": 0, "data": {
"knowledge_base_id": kb_id,
"last_sync_at": last_sync.strftime("%Y-%m-%d %H:%M:%S") if last_sync and not isinstance(last_sync, str) else str(last_sync) if last_sync else None,
"new_docs": new_docs[0][0] if new_docs else 0,
"updated_docs": updated_docs[0][0] if updated_docs else 0,
"deleted_docs": deleted_count,
}}
# ========================================================================
# 4. Anonymized Usage Reporting
# ========================================================================
@router.post("/usage/report")
def submit_usage_report(
metrics: str = Query(...),
report_type: str = Query("daily"),
period_start: str = Query(None),
period_end: str = Query(None),
user_id: str = Query("admin-001"),
db: Session = Depends(get_db)
):
"""Submit anonymized usage report from local agent"""
user_hash = _anonymize_user_id(user_id)
try:
metrics_obj = json.loads(metrics)
except json.JSONDecodeError:
metrics_obj = {"raw": metrics}
if not period_start:
period_start = datetime.now().strftime("%Y-%m-%d 00:00:00")
if not period_end:
period_end = datetime.now().strftime("%Y-%m-%d 23:59:59")
_execute_raw(db, text("""
INSERT INTO usage_reports (user_hash, report_type, metrics, period_start,
period_end, is_anonymized, submitted_at)
VALUES (:hash, :rtype, :metrics, :pstart, :pend, 1, NOW())
"""), {
"hash": user_hash,
"rtype": report_type,
"metrics": json.dumps(metrics_obj),
"pstart": period_start,
"pend": period_end,
})
return {"code": 0, "message": "Usage report submitted"}
@router.get("/usage/stats")
def get_usage_stats(db: Session = Depends(get_db)):
"""Get aggregated usage statistics (admin view, anonymized)"""
total_reports = _execute_raw(db, text(
"SELECT COUNT(*) FROM usage_reports"
))
unique_users = _execute_raw(db, text(
"SELECT COUNT(DISTINCT user_hash) FROM usage_reports"
))
today_reports = _execute_raw(db, text(
"SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURDATE()"
))
by_type = _execute_raw(db, text(
"SELECT report_type, COUNT(*) as cnt FROM usage_reports GROUP BY report_type"
))
return {"code": 0, "data": {
"total_reports": total_reports[0][0] if total_reports else 0,
"unique_users": unique_users[0][0] if unique_users else 0,
"today_reports": today_reports[0][0] if today_reports else 0,
"by_type": [{"type": r[0], "count": r[1]} for r in by_type] if by_type else []
}}
@router.get("/usage/my-stats")
def get_my_usage_stats(user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
"""Get current user's usage stats"""
user_hash = _anonymize_user_id(user_id)
result = _execute_raw(db, text(
"SELECT COUNT(*), MAX(submitted_at) FROM usage_reports WHERE user_hash = :hash"
), {"hash": user_hash})
return {"code": 0, "data": {
"total_reports": result[0][0] if result else 0,
"last_submitted": _row_to_dict(result[0], ["count", "last"])["last"] if result else None
}}
# ========================================================================
# 5. Local Agent Status
# ========================================================================
@router.get("/local-agents")
def list_local_agents(user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
"""List local agents with sync status"""
# Query agents with privacy configs
rows = _execute_raw(db, text("""
SELECT DISTINCT a.id, a.name, a.description, a.status, a.type,
COALESCE(pc.privacy_mode, 'cloud') as privacy_mode,
COALESCE(pc.local_llm_provider, '') as llm_provider,
COALESCE(pc.local_llm_model, '') as llm_model,
COALESCE(pc.last_sync_at, NULL) as last_sync
FROM market_apps a
LEFT JOIN privacy_configs pc ON pc.agent_id = a.id AND pc.user_id = :uid
WHERE a.type = 'agent' AND a.status = 'published'
ORDER BY a.name
"""), {"uid": user_id})
keys = ["id", "name", "description", "status", "type", "privacy_mode",
"llm_provider", "llm_model", "last_sync"]
agents = []
for row in rows:
d = _row_to_dict(row, keys)
d["can_run_local"] = d["privacy_mode"] in ["local", "hybrid"]
agents.append(d)
return {"code": 0, "data": agents}
@router.get("/local-agents/{agent_id}/status")
def get_local_agent_status(
agent_id: str,
user_id: str = Query("admin-001"),
db: Session = Depends(get_db)
):
"""Get detailed local agent status"""
config = _execute_raw(db, text(
"SELECT * FROM privacy_configs WHERE user_id = :uid AND agent_id = :aid"
), {"uid": user_id, "aid": agent_id})
keys = ["id", "user_id", "agent_id", "privacy_mode", "local_llm_provider",
"local_llm_model", "local_llm_endpoint", "data_retention_days",
"anonymize_usage", "sync_knowledge_bases", "sync_frequency",
"last_sync_at", "status", "created_at", "updated_at"]
if config:
cfg = _row_to_dict(config[0], keys)
if cfg.get("sync_knowledge_bases"):
try:
cfg["sync_knowledge_bases"] = json.loads(cfg["sync_knowledge_bases"])
except Exception:
pass
else:
cfg = {"privacy_mode": "cloud", "status": "not_configured"}
# Check active license
license_info = _execute_raw(db, text(
"SELECT license_key, expires_at, status FROM offline_licenses WHERE user_id = :uid AND status = 'active' ORDER BY expires_at DESC LIMIT 1"
), {"uid": user_id})
if license_info:
cfg["has_active_license"] = True
cfg["license_expires"] = license_info[0][1].strftime("%Y-%m-%d %H:%M:%S") if hasattr(license_info[0][1], 'strftime') else str(license_info[0][1])
else:
cfg["has_active_license"] = False
cfg["license_expires"] = None
return {"code": 0, "data": cfg}
# ========================================================================
# 6. Platform Bridge Config Export
# ========================================================================
@router.get("/bridge-config")
def get_platform_bridge_config(user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
"""Export Platform Bridge configuration for local Goose setup"""
config = _execute_raw(db, text(
"SELECT * FROM privacy_configs WHERE user_id = :uid ORDER BY updated_at DESC LIMIT 1"
), {"uid": user_id})
platform_api = "http://210.16.161.218:18080"
bridge_config = {
"mcp_server": {
"name": "platform-bridge",
"type": "stdio",
"command": "python",
"args": ["-m", "platform_bridge.mcp_server"],
"env": {
"PLATFORM_API_URL": platform_api,
"PLATFORM_USER_ID": user_id,
"PLATFORM_API_KEY": "YOUR_API_KEY_HERE"
},
"timeout": 60
},
"privacy_mode": "cloud",
"sync_config": {
"frequency": "daily",
"knowledge_bases": [],
"auto_sync": True,
"conflict_resolution": "last_write_wins"
},
"local_llm": {
"provider": "ollama",
"model": "qwen2.5:7b",
"endpoint": "http://localhost:11434"
}
}
if config:
row = config[0]
bridge_config["privacy_mode"] = row[3] # privacy_mode column
bridge_config["sync_config"]["frequency"] = row[10] # sync_frequency column
if row[5]: # local_llm_provider
bridge_config["local_llm"]["provider"] = row[5]
if row[6]: # local_llm_model
bridge_config["local_llm"]["model"] = row[6]
if row[7]: # local_llm_endpoint
bridge_config["local_llm"]["endpoint"] = row[7]
if row[9]: # sync_knowledge_bases
try:
bridge_config["sync_config"]["knowledge_bases"] = json.loads(row[9])
except Exception:
pass
return {"code": 0, "data": bridge_config}
@router.get("/stats")
def get_privacy_stats(db: Session = Depends(get_db)):
"""Get privacy module statistics"""
total_configs = _execute_raw(db, text("SELECT COUNT(*) FROM privacy_configs"))
active_licenses = _execute_raw(db, text(
"SELECT COUNT(*) FROM offline_licenses WHERE status = 'active' AND expires_at > NOW()"
))
synced_kbs = _execute_raw(db, text(
"SELECT COUNT(*) FROM kb_sync_states WHERE sync_status = 'synced'"
))
mode_breakdown = _execute_raw(db, text(
"SELECT privacy_mode, COUNT(*) FROM privacy_configs GROUP BY privacy_mode"
))
return {"code": 0, "data": {
"total_configs": total_configs[0][0] if total_configs else 0,
"active_licenses": active_licenses[0][0] if active_licenses else 0,
"synced_knowledge_bases": synced_kbs[0][0] if synced_kbs else 0,
"mode_breakdown": {r[0]: r[1] for r in mode_breakdown} if mode_breakdown else {}
}}
@@ -0,0 +1,288 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import Optional, List
from datetime import datetime, date
import json
import traceback
from ...database import SessionLocal, get_db
from ...auth import get_current_user
from ...models import User
router = APIRouter()
def _execute_raw(db: Session, sql: str, params: dict = None):
"""执行原始SQL的辅助函数"""
try:
result = db.execute(text(sql), params or {})
db.commit()
return result
except Exception as e:
db.rollback()
raise e
@router.get("/stats")
async def get_promotion_overview(
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""获取推广概览统计"""
try:
stats = {
"total_promotions": 0,
"active_promotions": 0,
"total_referrals": 0,
"total_rewards": 0.0,
"pending_rewards": 0.0
}
# 统计推广活动数量
try:
result = _execute_raw(db, "SELECT COUNT(*) as cnt FROM promotions")
row = result.fetchone()
if row:
stats["total_promotions"] = row[0]
except:
pass
# 统计活跃推广活动
try:
result = _execute_raw(db, "SELECT COUNT(*) as cnt FROM promotions WHERE status='active'")
row = result.fetchone()
if row:
stats["active_promotions"] = row[0]
except:
pass
return {"code": 0, "data": stats}
except Exception as e:
traceback.print_exc()
return {"code": 0, "data": {}}
@router.get("/promotions")
async def list_promotions(
status: Optional[str] = Query(None),
page: int = Query(1),
page_size: int = Query(20),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""列出推广活动"""
try:
promotions = []
try:
result = _execute_raw(db, "SELECT * FROM promotions ORDER BY created_at DESC LIMIT 100")
rows = result.fetchall()
for row in rows:
promotions.append({
"id": row[0],
"title": row[1],
"description": row[2],
"reward_type": row[3],
"reward_value": float(row[4]) if row[4] else 0,
"min_payout": float(row[5]) if row[5] else 0,
"start_date": str(row[6]) if row[6] else "",
"end_date": str(row[7]) if row[7] else "",
"status": row[8],
"max_referrals": row[9],
"created_by": row[10],
"created_at": str(row[11]) if row[11] else ""
})
except Exception as e:
print(f"Error fetching promotions: {e}")
return {
"code": 0,
"data": {
"items": promotions,
"total": len(promotions),
"page": page,
"page_size": page_size
}
}
except Exception as e:
traceback.print_exc()
return {"code": 0, "data": {"items": [], "total": 0}}
@router.post("/promotions")
async def create_promotion(
title: str = Query(...),
reward_type: str = Query(...),
reward_value: float = Query(...),
description: Optional[str] = Query(""),
min_payout: float = Query(0),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
max_referrals: int = Query(0),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""创建推广活动"""
try:
import uuid
promo_id = str(uuid.uuid4())
# 使用 user.id 而不是 user.get()
created_by = user.id if hasattr(user, 'id') else "system"
sql = """INSERT INTO promotions
(id, title, description, reward_type, reward_value, min_payout,
start_date, end_date, status, max_referrals, created_by, created_at)
VALUES (:id, :title, :desc, :rtype, :rvalue, :min_pay,
:start, :end, 'active', :max_ref, :created_by, NOW())"""
_execute_raw(db, sql, {
"id": promo_id,
"title": title,
"desc": description,
"rtype": reward_type,
"rvalue": reward_value,
"min_pay": min_payout,
"start": start_date,
"end": end_date,
"max_ref": max_referrals,
"created_by": created_by
})
return {"code": 0, "message": "推广活动创建成功", "data": {"id": promo_id}}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/promotions/{promo_id}")
async def update_promotion(
promo_id: str,
title: Optional[str] = Query(None),
description: Optional[str] = Query(None),
reward_type: Optional[str] = Query(None),
reward_value: Optional[float] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""更新推广活动"""
try:
updates = []
params = {"id": promo_id}
if title is not None:
updates.append("title = :title")
params["title"] = title
if description is not None:
updates.append("description = :desc")
params["desc"] = description
if reward_type is not None:
updates.append("reward_type = :rtype")
params["rtype"] = reward_type
if reward_value is not None:
updates.append("reward_value = :rvalue")
params["rvalue"] = reward_value
if status is not None:
updates.append("status = :status")
params["status"] = status
if updates:
sql = f"UPDATE promotions SET {', '.join(updates)} WHERE id = :id"
_execute_raw(db, sql, params)
return {"code": 0, "message": "更新成功"}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/promotions/{promo_id}")
async def delete_promotion(
promo_id: str,
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""删除推广活动"""
try:
_execute_raw(db, "DELETE FROM promotions WHERE id = :id", {"id": promo_id})
return {"code": 0, "message": "删除成功"}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/promotions/{promo_id}/toggle")
async def toggle_promotion(
promo_id: str,
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""切换推广活动状态"""
try:
result = _execute_raw(db, "SELECT status FROM promotions WHERE id = :id", {"id": promo_id})
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="推广活动不存在")
new_status = "inactive" if row[0] == "active" else "active"
_execute_raw(db, "UPDATE promotions SET status = :status WHERE id = :id",
{"status": new_status, "id": promo_id})
return {"code": 0, "message": "状态已更新", "data": {"status": new_status}}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/referral-links")
async def get_referral_links(
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""获取分销链接列表"""
return {"code": 0, "data": {"items": []}}
@router.post("/referral-links/generate")
async def generate_referral_link(
promotion_id: str = Query(...),
channel: str = Query("default"),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""生成分销链接"""
return {"code": 0, "message": "功能开发中"}
@router.get("/rewards")
async def get_referral_rewards(
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""获取返佣记录"""
return {"code": 0, "data": {"items": []}}
@router.post("/rewards/{reward_id}/approve")
async def approve_reward(
reward_id: str,
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""审核返佣记录"""
return {"code": 0, "message": "功能开发中"}
@router.get("/leaderboard")
async def get_promotion_leaderboard(
period: str = Query("all"),
db: Session = Depends(get_db)
):
"""获取推广排行榜"""
return {"code": 0, "data": {"items": []}}
@router.get("/my-referrals")
async def get_my_referrals(
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""获取我的推荐链接"""
return {"code": 0, "data": {"items": []}}
@router.get("/my-rewards")
async def get_my_rewards(
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""获取我的返佣记录"""
return {"code": 0, "data": {"items": []}}
@@ -0,0 +1,773 @@
"""
技能管理系统 API(管理员)
全平台技能管理:列表/创建/编辑/删除/状态管理/版本历史/发布市场
"""
import os
import json
import uuid
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, func, or_, and_
from ...database import get_db
from ...models import (
User, EvolutionSkill, SkillVersion, SkillPublishRecord,
EvolutionEvent, TeamSharedSkill
)
from ...auth import get_current_admin
from ...middleware.log_service import LogService
from ...config import UPLOAD_DIR
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================
# 技能状态定义
# ============================================================
SKILL_STATES = {
"baby": {"label": "萌芽期", "color": "#9CA3AF", "description": "刚创建的技能,需要继续培养"},
"main": {"label": "主力期", "color": "#10B981", "description": "已成熟可正常使用"},
"staging": {"label": "灰度期", "color": "#F59E0B", "description": "正在灰度测试中"},
"frozen": {"label": "已冻结", "color": "#EF4444", "description": "已停止使用"},
}
SKILL_CATEGORIES = [
{"id": "writing", "label": "文字创作"},
{"id": "coding", "label": "代码能力"},
{"id": "data_analysis", "label": "数据分析"},
{"id": "qa", "label": "知识问答"},
{"id": "automation", "label": "流程自动化"},
{"id": "creativity", "label": "创意生成"},
{"id": "communication", "label": "沟通协作"},
{"id": "reasoning", "label": "逻辑推理"},
{"id": "other", "label": "其他"},
]
# ============================================================
# 辅助函数
# ============================================================
def skill_to_dict(s: EvolutionSkill, include_md: bool = False) -> dict:
"""将技能ORM对象转换为字典"""
result = {
"id": s.id,
"user_id": s.user_id,
"skill_name": s.skill_name,
"description": s.description,
"tags": s.tags or [],
"category": s.category,
"category_label": next((c["label"] for c in SKILL_CATEGORIES if c["id"] == s.category), s.category or "其他"),
"state": s.state,
"state_info": SKILL_STATES.get(s.state, SKILL_STATES["baby"]),
"use_count": s.use_count or 0,
"version": s.version or 1,
"skill_md_path": s.skill_md_path,
"git_commit_sha": s.git_commit_sha,
"last_used_at": s.last_used_at.isoformat() if s.last_used_at else None,
"created_at": s.created_at.isoformat() if s.created_at else None,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
}
# 读取 SKILL.md 内容
if include_md and s.skill_md_path and os.path.exists(s.skill_md_path):
try:
with open(s.skill_md_path, 'r', encoding='utf-8') as f:
result["skill_md_content"] = f.read()
except Exception:
result["skill_md_content"] = None
else:
result["skill_md_content"] = None
return result
def version_to_dict(v: SkillVersion) -> dict:
return {
"id": v.id,
"user_id": v.user_id,
"skill_name": v.skill_name,
"version": v.version,
"skill_md_content": v.skill_md_content,
"git_commit_sha": v.git_commit_sha,
"description": v.description,
"created_at": v.created_at.isoformat() if v.created_at else None,
}
# ============================================================
# 管理员技能列表
# ============================================================
@router.get("/")
async def list_skills(
state: str = Query(None, description="技能状态过滤: baby/main/staging/frozen"),
category: str = Query(None, description="技能分类过滤"),
keyword: str = Query(None, description="关键词搜索(名称/描述)"),
user_id: str = Query(None, description="按用户ID筛选"),
sort_by: str = Query("updated_at", description="排序字段"),
order: str = Query("desc", description="排序方向: asc/desc"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取全平台技能列表(支持分页/过滤/搜索)"""
logger.info(f"[Admin Skills] {admin_user.username} 列出技能: state={state}, keyword={keyword}")
query = db.query(EvolutionSkill)
if state:
query = query.filter(EvolutionSkill.state == state)
if category:
query = query.filter(EvolutionSkill.category == category)
if keyword:
query = query.filter(
or_(
EvolutionSkill.skill_name.contains(keyword),
EvolutionSkill.description.contains(keyword)
)
)
if user_id:
query = query.filter(EvolutionSkill.user_id == user_id)
# 总数
total = query.count()
# 排序
sort_field = getattr(EvolutionSkill, sort_by, EvolutionSkill.updated_at)
if order == "desc":
query = query.order_by(desc(sort_field))
else:
query = query.order_by(sort_field)
# 分页
skills = query.offset((page - 1) * page_size).limit(page_size).all()
# 补充用户名
user_ids = list(set(s.user_id for s in skills))
users_map = {
u.id: u.username
for u in db.query(User.id, User.username).filter(User.id.in_(user_ids)).all()
}
result = []
for s in skills:
item = skill_to_dict(s)
item["owner_username"] = users_map.get(s.user_id, s.user_id)
result.append(item)
return {
"code": 0,
"data": {
"items": result,
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size,
}
}
# ============================================================
# 获取单个技能详情
# ============================================================
@router.get("/{skill_id}")
async def get_skill(
skill_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取技能详情(包含 SKILL.md 内容)"""
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
if not skill:
raise HTTPException(status_code=404, detail="技能不存在")
result = skill_to_dict(skill, include_md=True)
# 获取所属用户信息
user = db.query(User).filter(User.id == skill.user_id).first()
if user:
result["owner_username"] = user.username
result["owner_email"] = user.email
# 获取发布记录
pub_records = db.query(SkillPublishRecord).filter(
SkillPublishRecord.skill_id == skill_id
).order_by(desc(SkillPublishRecord.created_at)).limit(10).all()
result["publish_records"] = [{
"id": p.id,
"price": float(p.price) if p.price else 0,
"market_description": p.market_description,
"status": p.status,
"published_at": p.published_at.isoformat() if p.published_at else None,
"created_at": p.created_at.isoformat() if p.created_at else None,
} for p in pub_records]
# 获取最新版本号
latest_version = db.query(func.max(SkillVersion.version)).filter(
SkillVersion.skill_id == skill_id
).scalar()
result["latest_version"] = latest_version or skill.version
return {"code": 0, "data": result}
# ============================================================
# 创建技能
# ============================================================
class CreateSkillRequest:
def __init__(self, skill_name: str = None, description: str = None,
tags: List[str] = None, category: str = None,
skill_md_content: str = None, user_id: str = None):
self.skill_name = skill_name
self.description = description
self.tags = tags or []
self.category = category
self.skill_md_content = skill_md_content
self.user_id = user_id
from pydantic import BaseModel, Field
class CreateSkillSchema(BaseModel):
skill_name: str = Field(..., min_length=1, max_length=200, description="技能名称")
user_id: str = Field(..., description="所属用户ID")
description: Optional[str] = Field(None, description="技能描述")
tags: Optional[List[str]] = Field(default_factory=list, description="标签列表")
category: Optional[str] = Field(None, description="技能分类")
skill_md_content: Optional[str] = Field(None, description="SKILL.md 内容")
@router.post("/")
async def create_skill(
req: CreateSkillSchema,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""管理员创建技能"""
logger.info(f"[Admin Skills] {admin_user.username} 创建技能: {req.skill_name}")
# 检查技能名是否已存在
existing = db.query(EvolutionSkill).filter(
EvolutionSkill.user_id == req.user_id,
EvolutionSkill.skill_name == req.skill_name
).first()
if existing:
raise HTTPException(status_code=400, detail="该用户已存在同名技能")
# 验证用户存在
user = db.query(User).filter(User.id == req.user_id).first()
if not user:
raise HTTPException(status_code=400, detail="用户不存在")
# 生成技能ID
skill_id = str(uuid.uuid4())
# 保存 SKILL.md 文件
skill_md_path = None
if req.skill_md_content:
skill_dir = os.path.join(UPLOAD_DIR, "skills", req.user_id)
os.makedirs(skill_dir, exist_ok=True)
safe_name = req.skill_name.replace("/", "_").replace("\\", "_")
skill_md_path = os.path.join(skill_dir, f"{safe_name}.md")
try:
with open(skill_md_path, 'w', encoding='utf-8') as f:
f.write(req.skill_md_content)
except Exception as e:
logger.warning(f"保存 SKILL.md 失败: {e}")
# 创建技能记录
skill = EvolutionSkill(
id=skill_id,
user_id=req.user_id,
skill_name=req.skill_name,
description=req.description,
tags=req.tags or [],
category=req.category,
state="baby",
use_count=0,
version=1,
skill_md_path=skill_md_path,
git_commit_sha=None,
)
db.add(skill)
# 记录初始版本
if req.skill_md_content:
sv = SkillVersion(
id=str(uuid.uuid4()),
user_id=req.user_id,
skill_name=req.skill_name,
version=1,
skill_md_content=req.skill_md_content,
git_commit_sha=None,
description="初始版本",
)
db.add(sv)
# 记录事件
ev = EvolutionEvent(
id=str(uuid.uuid4()),
user_id=req.user_id,
skill_name=req.skill_name,
event_type="created",
description=f"管理员 {admin_user.username} 创建技能 {req.skill_name}",
version_before=None,
version_after=1,
)
db.add(ev)
db.commit()
db.refresh(skill)
LogService.log_operation(
user_id=admin_user.id,
username=admin_user.username,
action="create_skill",
target="evolution_skills",
detail=f"创建技能 {req.skill_name}(归属用户: {user.username})",
)
return {"code": 0, "data": skill_to_dict(skill, include_md=True)}
# ============================================================
# 更新技能
# ============================================================
class UpdateSkillSchema(BaseModel):
skill_name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
tags: Optional[List[str]] = None
category: Optional[str] = None
skill_md_content: Optional[str] = None
@router.put("/{skill_id}")
async def update_skill(
skill_id: str,
req: UpdateSkillSchema,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新技能信息"""
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
if not skill:
raise HTTPException(status_code=404, detail="技能不存在")
old_version = skill.version
update_data = req.model_dump(exclude_none=True)
# 处理 SKILL.md 内容
if "skill_md_content" in update_data:
content = update_data.pop("skill_md_content")
if content is not None:
skill_dir = os.path.join(UPLOAD_DIR, "skills", skill.user_id)
os.makedirs(skill_dir, exist_ok=True)
safe_name = skill.skill_name.replace("/", "_").replace("\\", "_")
skill_md_path = os.path.join(skill_dir, f"{safe_name}.md")
try:
with open(skill_md_path, 'w', encoding='utf-8') as f:
f.write(content)
skill.skill_md_path = skill_md_path
except Exception as e:
logger.warning(f"保存 SKILL.md 失败: {e}")
# 记录新版本
new_version = old_version + 1
sv = SkillVersion(
id=str(uuid.uuid4()),
user_id=skill.user_id,
skill_name=skill.skill_name,
version=new_version,
skill_md_content=content,
git_commit_sha=None,
description="管理员更新",
)
db.add(sv)
skill.version = new_version
# 记录事件
ev = EvolutionEvent(
id=str(uuid.uuid4()),
user_id=skill.user_id,
skill_name=skill.skill_name,
event_type="edited",
description=f"管理员 {admin_user.username} 更新技能 {skill.skill_name}",
version_before=old_version,
version_after=new_version,
)
db.add(ev)
# 更新其他字段
for key, value in update_data.items():
if key not in ("skill_md_content",) and hasattr(skill, key):
setattr(skill, key, value)
skill.updated_at = datetime.now()
db.commit()
db.refresh(skill)
LogService.log_operation(
user_id=admin_user.id,
username=admin_user.username,
action="update_skill",
target="evolution_skills",
detail=f"更新技能 {skill.skill_name}(版本 {old_version} → {skill.version})",
)
return {"code": 0, "data": skill_to_dict(skill, include_md=True)}
# ============================================================
# 删除技能
# ============================================================
@router.delete("/{skill_id}")
async def delete_skill(
skill_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除技能"""
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
if not skill:
raise HTTPException(status_code=404, detail="技能不存在")
skill_name = skill.skill_name
user_id = skill.user_id
# 记录事件
ev = EvolutionEvent(
id=str(uuid.uuid4()),
user_id=user_id,
skill_name=skill_name,
event_type="deleted",
description=f"管理员 {admin_user.username} 删除技能 {skill_name}",
)
db.add(ev)
# 删除版本历史
db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete()
# 删除发布记录
db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill_id).delete()
# 删除团队共享记录
db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill_id).delete()
db.delete(skill)
db.commit()
LogService.log_operation(
user_id=admin_user.id,
username=admin_user.username,
action="delete_skill",
target="evolution_skills",
detail=f"删除技能 {skill_name}",
)
return {"code": 0, "message": f"技能 {skill_name} 已删除"}
# ============================================================
# 修改技能状态
# ============================================================
class ChangeStateSchema(BaseModel):
state: str = Field(..., description="目标状态: baby/main/staging/frozen")
@router.put("/{skill_id}/state")
async def change_skill_state(
skill_id: str,
req: ChangeStateSchema,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""修改技能状态"""
if req.state not in SKILL_STATES:
raise HTTPException(status_code=400, detail=f"无效状态,可选值: {list(SKILL_STATES.keys())}")
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
if not skill:
raise HTTPException(status_code=404, detail="技能不存在")
old_state = skill.state
skill.state = req.state
skill.updated_at = datetime.now()
# 记录状态变更事件
ev_type = "promoted" if req.state in ("main", "staging") else "frozen" if req.state == "frozen" else "unfrozen"
ev = EvolutionEvent(
id=str(uuid.uuid4()),
user_id=skill.user_id,
skill_name=skill.skill_name,
event_type=ev_type,
description=f"管理员 {admin_user.username} 将技能状态从 {old_state} 变更为 {req.state}",
)
db.add(ev)
db.commit()
db.refresh(skill)
LogService.log_operation(
user_id=admin_user.id,
username=admin_user.username,
action="change_skill_state",
target="evolution_skills",
detail=f"修改技能 {skill.skill_name} 状态: {old_state} → {req.state}",
)
return {"code": 0, "data": skill_to_dict(skill)}
# ============================================================
# 获取版本历史
# ============================================================
@router.get("/{skill_id}/versions")
async def get_skill_versions(
skill_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取技能版本历史"""
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
if not skill:
raise HTTPException(status_code=404, detail="技能不存在")
versions = db.query(SkillVersion).filter(
SkillVersion.skill_name == skill.skill_name,
SkillVersion.user_id == skill.user_id,
).order_by(desc(SkillVersion.version)).all()
return {
"code": 0,
"data": {
"skill_id": skill.id,
"skill_name": skill.skill_name,
"current_version": skill.version,
"versions": [version_to_dict(v) for v in versions],
}
}
# ============================================================
# 回滚到指定版本
# ============================================================
class RollbackSchema(BaseModel):
version: int = Field(..., ge=1, description="目标版本号")
@router.post("/{skill_id}/rollback")
async def rollback_skill_version(
skill_id: str,
req: RollbackSchema,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""回滚技能到指定版本"""
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
if not skill:
raise HTTPException(status_code=404, detail="技能不存在")
target_version = db.query(SkillVersion).filter(
SkillVersion.skill_name == skill.skill_name,
SkillVersion.user_id == skill.user_id,
SkillVersion.version == req.version,
).first()
if not target_version:
raise HTTPException(status_code=404, detail=f"版本 {req.version} 不存在")
old_version = skill.version
# 更新 SKILL.md 文件
if target_version.skill_md_content:
if skill.skill_md_path:
try:
with open(skill.skill_md_path, 'w', encoding='utf-8') as f:
f.write(target_version.skill_md_content)
except Exception as e:
logger.warning(f"回滚保存 SKILL.md 失败: {e}")
# 记录新版本(基于目标版本)
new_version = old_version + 1
sv = SkillVersion(
id=str(uuid.uuid4()),
user_id=skill.user_id,
skill_name=skill.skill_name,
version=new_version,
skill_md_content=target_version.skill_md_content,
git_commit_sha=None,
description=f"回滚到版本 {req.version}(管理员操作)",
)
db.add(sv)
skill.version = new_version
skill.updated_at = datetime.now()
ev = EvolutionEvent(
id=str(uuid.uuid4()),
user_id=skill.user_id,
skill_name=skill.skill_name,
event_type="edited",
description=f"管理员 {admin_user.username} 回滚技能 {skill.skill_name} 从版本 {old_version} 到 {req.version}(新版本 {new_version})",
version_before=old_version,
version_after=new_version,
)
db.add(ev)
db.commit()
db.refresh(skill)
LogService.log_operation(
user_id=admin_user.id,
username=admin_user.username,
action="rollback_skill",
target="evolution_skills",
detail=f"回滚技能 {skill.skill_name} 到版本 {req.version}(新版本 {new_version})",
)
return {"code": 0, "data": skill_to_dict(skill, include_md=True)}
# ============================================================
# 发布到应用市场
# ============================================================
class PublishSkillSchema(BaseModel):
price: float = Field(0.0, ge=0, description="定价(0表示免费)")
market_description: str = Field("", description="市场描述")
@router.post("/{skill_id}/publish")
async def publish_skill(
skill_id: str,
req: PublishSkillSchema,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""发布技能到应用市场"""
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
if not skill:
raise HTTPException(status_code=404, detail="技能不存在")
# 检查是否已发布
existing = db.query(SkillPublishRecord).filter(
SkillPublishRecord.skill_id == skill_id,
SkillPublishRecord.status == "published"
).first()
if existing:
# 更新现有发布
existing.price = req.price
existing.market_description = req.market_description
msg = "更新发布信息"
else:
# 创建发布记录
record = SkillPublishRecord(
id=str(uuid.uuid4()),
skill_id=skill_id,
skill_name=skill.skill_name,
user_id=skill.user_id,
price=req.price,
market_description=req.market_description,
status="published",
)
db.add(record)
msg = "发布成功"
db.commit()
LogService.log_operation(
user_id=admin_user.id,
username=admin_user.username,
action="publish_skill",
target="skill_publish_records",
detail=f"{msg}: {skill.skill_name},定价: ¥{req.price}",
)
return {"code": 0, "message": msg, "data": {"skill_id": skill_id, "price": req.price}}
# ============================================================
# 下架技能
# ============================================================
@router.post("/{skill_id}/unpublish")
async def unpublish_skill(
skill_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""从应用市场下架技能"""
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
if not skill:
raise HTTPException(status_code=404, detail="技能不存在")
updated = db.query(SkillPublishRecord).filter(
SkillPublishRecord.skill_id == skill_id,
SkillPublishRecord.status == "published"
).update({"status": "unpublished"})
db.commit()
if updated == 0:
raise HTTPException(status_code=400, detail="该技能未发布到市场")
LogService.log_operation(
user_id=admin_user.id,
username=admin_user.username,
action="unpublish_skill",
target="skill_publish_records",
detail=f"下架技能: {skill.skill_name}",
)
return {"code": 0, "message": f"技能 {skill.skill_name} 已下架"}
# ============================================================
# 获取统计数据
# ============================================================
@router.get("/stats/overview")
async def get_skills_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取技能统计概览"""
total = db.query(EvolutionSkill).count()
by_state = dict(
db.query(EvolutionSkill.state, func.count(EvolutionSkill.id))
.group_by(EvolutionSkill.state).all()
)
by_category = dict(
db.query(EvolutionSkill.category, func.count(EvolutionSkill.id))
.filter(EvolutionSkill.category.isnot(None))
.group_by(EvolutionSkill.category).all()
)
total_published = db.query(SkillPublishRecord).filter(
SkillPublishRecord.status == "published"
).count()
total_versions = db.query(SkillVersion).count()
return {
"code": 0,
"data": {
"total_skills": total,
"by_state": {k: by_state.get(k, 0) for k in SKILL_STATES},
"by_category": by_category,
"total_published": total_published,
"total_versions": total_versions,
}
}
@@ -0,0 +1,904 @@
"""
OSS存储配置管理 API
Phase 3.1: 支持 MinIO / 阿里云OSS / AWS S3 等对象存储配置
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from sqlalchemy import or_, func, desc, asc, text
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
import uuid
import json
import logging
import httpx
from ...database import get_db
from ...models import (
gen_uuid,
)
from ...auth import get_current_admin
from ...middleware.log_service import LogService
router = APIRouter()
logger = logging.getLogger("aiflow")
# ============================================================
# Pydantic Models
# ============================================================
class OSSConfigCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
provider: str = Field(min_length=1, max_length=20)
endpoint: str = Field(min_length=1, max_length=500)
access_key: str = Field(min_length=1, max_length=255)
secret_key: str = Field(min_length=1, max_length=255)
bucket: str = Field(min_length=1, max_length=200)
region: Optional[str] = ""
base_path: Optional[str] = ""
is_default: bool = False
is_enabled: bool = True
config: Optional[Dict[str, Any]] = None
class OSSConfigUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
provider: Optional[str] = Field(None, min_length=1, max_length=20)
endpoint: Optional[str] = Field(None, min_length=1, max_length=500)
access_key: Optional[str] = Field(None, min_length=1, max_length=255)
secret_key: Optional[str] = Field(None, min_length=1, max_length=255)
bucket: Optional[str] = Field(None, min_length=1, max_length=200)
region: Optional[str] = None
base_path: Optional[str] = None
is_default: Optional[bool] = None
is_enabled: Optional[bool] = None
config: Optional[Dict[str, Any]] = None
class OSSConnectionTest(BaseModel):
"""测试连接请求 - 可以测试已保存的配置或临时配置"""
config_id: Optional[str] = None # 已保存的配置ID
# 以下为临时测试参数
provider: Optional[str] = None
endpoint: Optional[str] = None
access_key: Optional[str] = None
secret_key: Optional[str] = None
bucket: Optional[str] = None
region: Optional[str] = None
# ============================================================
# Helper Functions
# ============================================================
def config_to_dict(config) -> dict:
"""将OSSStorageConfig模型转换为字典"""
result = {
"id": config.id,
"name": config.name,
"provider": config.provider,
"endpoint": config.endpoint,
"access_key": config.access_key[:8] + "****" if config.access_key and len(config.access_key) > 8 else "****",
"secret_key": "****", # 永远不返回完整密钥
"bucket": config.bucket,
"region": config.region or "",
"base_path": config.base_path or "",
"is_default": getattr(config, "is_default", False),
"is_enabled": config.is_enabled,
"status": config.status or "disconnected",
"created_at": config.created_at.isoformat() if config.created_at else None,
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
}
# 安全处理 JSON 字段
config_raw = getattr(config, "config", None)
if config_raw:
try:
result["config"] = json.loads(config_raw) if isinstance(config_raw, str) else config_raw
except Exception:
result["config"] = {}
else:
result["config"] = {}
return result
# ============================================================
# Storage Quota Management (Phase C5)
# ============================================================
DEFAULT_QUOTAS = {
0: 1073741824, # Free: 1GB
2: 10737418240, # Pro: 10GB
3: 107374182400, # Enterprise: 100GB
}
TIER_LABELS = {0: "\u514d\u8d39\u7248", 2: "\u4e13\u4e1a\u7248", 3: "\u4f01\u4e1a\u7248"}
class QuotaAdjustRequest(BaseModel):
quota_bytes: Optional[int] = Field(None, ge=0)
quota_type: Optional[str] = Field(None, max_length=20)
def ensure_user_quota(user_id: str, membership_level: int, plan_id: str = None, db: Session = None) -> dict:
from ...models import UserStorageQuota
quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first()
default_bytes = DEFAULT_QUOTAS.get(membership_level, DEFAULT_QUOTAS[0])
if not quota:
quota = UserStorageQuota(
id=gen_uuid(), user_id=user_id, quota_bytes=default_bytes,
used_bytes=0, quota_type="membership",
membership_level=membership_level, plan_id=plan_id,
)
db.add(quota)
db.commit()
db.refresh(quota)
elif quota.quota_type == "membership" and quota.membership_level != membership_level:
quota.quota_bytes = default_bytes
quota.membership_level = membership_level
quota.plan_id = plan_id
db.commit()
db.refresh(quota)
return {
"id": quota.id, "user_id": quota.user_id,
"quota_bytes": quota.quota_bytes, "used_bytes": quota.used_bytes,
"quota_type": quota.quota_type, "membership_level": quota.membership_level,
"plan_id": quota.plan_id,
"created_at": quota.created_at.isoformat() if quota.created_at else None,
"updated_at": quota.updated_at.isoformat() if quota.updated_at else None,
}
def recalculate_storage_usage(user_id: str, db: Session) -> int:
from ...models import KnowledgeDocument, KnowledgeBase
total = db.query(func.coalesce(func.sum(KnowledgeDocument.file_size), 0)).join(
KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id
).filter(
KnowledgeBase.user_id == user_id,
).scalar() or 0
return int(total)
@router.get("/quota/overview", summary="Storage Quota Overview")
async def get_quota_overview(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
from ...models import UserStorageQuota, User
total_quota_users = db.query(func.count(UserStorageQuota.id)).scalar() or 0
total_quota_bytes = db.query(func.coalesce(func.sum(UserStorageQuota.quota_bytes), 0)).scalar() or 0
total_used_bytes = db.query(func.coalesce(func.sum(UserStorageQuota.used_bytes), 0)).scalar() or 0
total_users = db.query(func.count(User.id)).filter(User.status == "active").scalar() or 0
users_without_quota = total_users - total_quota_users
by_level = {}
level_stats = db.query(
UserStorageQuota.membership_level,
func.count(UserStorageQuota.id),
func.coalesce(func.sum(UserStorageQuota.quota_bytes), 0),
func.coalesce(func.sum(UserStorageQuota.used_bytes), 0),
).group_by(UserStorageQuota.membership_level).all()
for level, count, total_q, total_u in level_stats:
by_level[str(level)] = {
"label": TIER_LABELS.get(level, f"Level {level}"),
"user_count": count,
"total_quota_bytes": int(total_q),
"total_used_bytes": int(total_u),
}
over_quota_count = db.query(func.count(UserStorageQuota.id)).filter(
UserStorageQuota.quota_bytes > 0,
UserStorageQuota.used_bytes > UserStorageQuota.quota_bytes
).scalar() or 0
return {
"code": 0,
"data": {
"total_users": total_users,
"quota_users": total_quota_users,
"users_without_quota": users_without_quota,
"total_quota_bytes": int(total_quota_bytes),
"total_used_bytes": int(total_used_bytes),
"over_quota_count": over_quota_count,
"by_level": by_level,
"default_quotas": {str(k): v for k, v in DEFAULT_QUOTAS.items()},
"tier_labels": TIER_LABELS,
},
"message": "ok"
}
@router.get("/quota/users", summary="User Storage Quota List")
async def list_user_quotas(
keyword: str = Query(None),
membership_level: int = Query(None),
over_quota: bool = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
from ...models import UserStorageQuota, User
results = db.query(
User.id, User.username, User.email, User.membership_level, User.real_name,
UserStorageQuota.id.label("quota_id"),
UserStorageQuota.quota_bytes, UserStorageQuota.used_bytes,
UserStorageQuota.quota_type, UserStorageQuota.updated_at,
).join(UserStorageQuota, User.id == UserStorageQuota.user_id, isouter=True
).filter(User.status == "active")
if keyword:
results = results.filter(
or_(User.username.ilike(f"%{keyword}%"), User.email.ilike(f"%{keyword}%"))
)
if membership_level is not None:
results = results.filter(User.membership_level == membership_level)
total = results.count()
items = results.order_by(desc(UserStorageQuota.used_bytes)).offset(
(page - 1) * page_size).limit(page_size).all()
user_list = []
for row in items:
uid, username, email, level, real_name, qid, qbytes, ubytes, qtype, updated = row
quota_b = int(qbytes or DEFAULT_QUOTAS.get(level or 0, DEFAULT_QUOTAS[0]))
used_b = int(ubytes or 0)
usage_pct = round(used_b / quota_b * 100, 1) if quota_b > 0 else 0
user_list.append({
"user_id": uid, "username": username, "email": email or "",
"real_name": real_name or "", "membership_level": level or 0,
"tier_label": TIER_LABELS.get(level or 0, "\u514d\u8d39\u7248"),
"quota_id": qid, "quota_bytes": quota_b, "used_bytes": used_b,
"usage_percent": usage_pct, "is_over_quota": usage_pct >= 100,
"quota_type": qtype or "membership",
"updated_at": updated.isoformat() if updated else None,
})
if over_quota:
user_list = [u for u in user_list if u["is_over_quota"]]
return {
"code": 0,
"data": {
"items": user_list,
"total": len(user_list) if over_quota else total,
"page": page, "page_size": page_size,
},
"message": "ok"
}
@router.get("/quota/users/{user_id}", summary="Get User Quota Detail")
async def get_user_quota(
user_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
from ...models import UserStorageQuota, User, KnowledgeDocument, KnowledgeBase
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first()
actual_used = recalculate_storage_usage(user_id, db)
file_count = db.query(func.count(KnowledgeDocument.id)).join(
KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id
).filter(KnowledgeBase.user_id == user_id).scalar() or 0
file_type_stats = {}
type_counts = db.query(
KnowledgeDocument.file_type, func.count(KnowledgeDocument.id),
func.coalesce(func.sum(KnowledgeDocument.file_size), 0),
).join(
KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id
).filter(KnowledgeBase.user_id == user_id
).group_by(KnowledgeDocument.file_type).all()
for ftype, cnt, fsize in type_counts:
file_type_stats[ftype] = {"count": cnt, "total_bytes": int(fsize)}
quota_data = None
if quota:
quota_data = {
"id": quota.id, "quota_bytes": int(quota.quota_bytes),
"used_bytes": actual_used, "original_used": int(quota.used_bytes),
"quota_type": quota.quota_type, "membership_level": quota.membership_level,
"plan_id": quota.plan_id,
"created_at": quota.created_at.isoformat() if quota.created_at else None,
"updated_at": quota.updated_at.isoformat() if quota.updated_at else None,
}
if actual_used != int(quota.used_bytes):
quota.used_bytes = actual_used
db.commit()
return {
"code": 0,
"data": {
"user": {
"id": user.id, "username": user.username,
"email": user.email or "", "real_name": user.real_name or "",
"membership_level": user.membership_level or 0,
"tier_label": TIER_LABELS.get(user.membership_level or 0, "\u514d\u8d39\u7248"),
},
"quota": quota_data, "file_count": file_count,
"file_type_stats": file_type_stats,
},
"message": "ok"
}
@router.put("/quota/users/{user_id}", summary="Adjust User Quota")
async def adjust_user_quota(
user_id: str,
req: QuotaAdjustRequest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
from ...models import UserStorageQuota, User
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first()
if not quota:
default_bytes = DEFAULT_QUOTAS.get(user.membership_level or 0, DEFAULT_QUOTAS[0])
quota = UserStorageQuota(
id=gen_uuid(), user_id=user_id, quota_bytes=default_bytes,
used_bytes=0, quota_type="membership",
membership_level=user.membership_level or 0,
)
db.add(quota)
db.flush()
if req.quota_bytes is not None:
quota.quota_bytes = req.quota_bytes
if req.quota_type is not None:
quota.quota_type = req.quota_type
quota.used_bytes = recalculate_storage_usage(user_id, db)
db.commit()
db.refresh(quota)
LogService.log_audit(
admin_id=admin_user.id, admin_name=admin_user.username,
action="adjust_storage_quota", target_type="user_storage_quota",
target_id=quota.id,
detail=f"Adjusted quota for {user.username} to {quota.quota_bytes} bytes"
)
usage_pct = round(quota.used_bytes / quota.quota_bytes * 100, 1) if quota.quota_bytes > 0 else 0
return {
"code": 0,
"data": {
"id": quota.id, "user_id": quota.user_id,
"quota_bytes": int(quota.quota_bytes),
"used_bytes": int(quota.used_bytes),
"quota_type": quota.quota_type, "usage_percent": usage_pct,
},
"message": "Quota adjusted successfully"
}
@router.post("/quota/recalculate/{user_id}", summary="Recalculate User Storage")
async def recalculate_user_quota(
user_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
from ...models import UserStorageQuota
quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first()
actual_used = recalculate_storage_usage(user_id, db)
if quota:
quota.used_bytes = actual_used
db.commit()
return {
"code": 0,
"data": {
"user_id": user_id, "used_bytes": actual_used,
"quota_bytes": int(quota.quota_bytes),
},
"message": f"Recalculated: {actual_used} bytes"
}
else:
return {
"code": 0,
"data": {"user_id": user_id, "used_bytes": actual_used},
"message": "No quota record for this user"
}
@router.post("/quota/init-all", summary="Init All User Quotas")
async def init_all_user_quotas(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
from ...models import UserStorageQuota, User
users = db.query(User).filter(User.status == "active").all()
created, skipped = 0, 0
for user in users:
existing = db.query(UserStorageQuota).filter(
UserStorageQuota.user_id == user.id).first()
if existing:
skipped += 1
continue
default_bytes = DEFAULT_QUOTAS.get(user.membership_level or 0, DEFAULT_QUOTAS[0])
used = recalculate_storage_usage(user.id, db)
quota = UserStorageQuota(
id=gen_uuid(), user_id=user.id, quota_bytes=default_bytes,
used_bytes=used, quota_type="membership",
membership_level=user.membership_level or 0,
plan_id=user.membership_plan_id,
)
db.add(quota)
created += 1
db.commit()
return {
"code": 0,
"data": {"created": created, "skipped": skipped, "total": len(users)},
"message": f"Init complete: {created} new, {skipped} existing, {len(users)} total"
}
async def test_oss_connection(provider: str, endpoint: str, access_key: str, secret_key: str,
bucket: str, region: str = "") -> Dict[str, Any]:
"""测试OSS连接 - 使用httpx发送HTTP请求到对象存储端点"""
import time
start_time = time.time()
try:
if provider in ("minio", "aws_s3"):
# 使用 AWS S3 兼容 API 测试
# ListBuckets 请求
import hmac
import hashlib
from urllib.parse import urlparse
parsed = urlparse(endpoint)
host = parsed.netloc or parsed.path
if not host:
return {"success": False, "error": "无法解析端点地址", "latency_ms": 0}
# 先尝试简单的 bucket HEAD 请求
bucket_url = f"{endpoint.rstrip('/')}/{bucket}"
async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
resp = await client.head(bucket_url)
latency = int((time.time() - start_time) * 1000)
if resp.status_code in (200, 403):
# 200: bucket存在且有权限; 403: bucket存在但无权限(也是连通的表现)
return {
"success": True,
"message": f"连接成功!Bucket '{bucket}' 可访问",
"latency_ms": latency,
"status_code": resp.status_code
}
elif resp.status_code == 404:
return {
"success": False,
"error": f"Bucket '{bucket}' 不存在",
"latency_ms": latency
}
else:
return {
"success": False,
"error": f"连接失败: HTTP {resp.status_code}",
"latency_ms": latency
}
elif provider == "aliyun_oss":
# 阿里云OSS: 简单HEAD请求
bucket_url = f"{endpoint.rstrip('/')}/{bucket}" if not endpoint.endswith(bucket) else endpoint
async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
resp = await client.head(bucket_url)
latency = int((time.time() - start_time) * 1000)
if resp.status_code in (200, 403):
return {
"success": True,
"message": f"连接成功!Bucket '{bucket}' 可访问",
"latency_ms": latency,
"status_code": resp.status_code
}
elif resp.status_code == 404:
return {
"success": False,
"error": f"Bucket '{bucket}' 不存在",
"latency_ms": latency
}
else:
return {
"success": False,
"error": f"连接失败: HTTP {resp.status_code}",
"latency_ms": latency
}
else:
return {"success": False, "error": f"不支持的存储类型: {provider}", "latency_ms": 0}
except httpx.ConnectError:
latency = int((time.time() - start_time) * 1000)
return {"success": False, "error": f"无法连接到 {endpoint}", "latency_ms": latency}
except httpx.TimeoutException:
latency = int((time.time() - start_time) * 1000)
return {"success": False, "error": "连接超时(10秒)", "latency_ms": latency}
except Exception as e:
latency = int((time.time() - start_time) * 1000)
return {"success": False, "error": f"测试失败: {str(e)}", "latency_ms": latency}
# ============================================================
# API Endpoints
# ============================================================
@router.get("/stats/overview", summary="存储配置统计概览")
async def get_storage_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取存储配置的统计信息"""
from ...models import OSSStorageConfig
total = db.query(func.count(OSSStorageConfig.id)).scalar() or 0
enabled_count = db.query(func.count(OSSStorageConfig.id)).filter(
OSSStorageConfig.is_enabled == True
).scalar() or 0
default_count = db.query(func.count(OSSStorageConfig.id)).filter(
OSSStorageConfig.is_default == True
).scalar() or 0
# 按类型统计
by_provider = {}
providers = db.query(OSSStorageConfig.provider, func.count(OSSStorageConfig.id)).group_by(
OSSStorageConfig.provider
).all()
for provider, count in providers:
by_provider[provider] = count
# 按状态统计
by_status = {}
statuses = db.query(OSSStorageConfig.status, func.count(OSSStorageConfig.id)).group_by(
OSSStorageConfig.status
).all()
for status, count in statuses:
by_status[status] = count
return {
"code": 0,
"data": {
"total": total,
"enabled_count": enabled_count,
"default_count": default_count,
"by_provider": by_provider,
"by_status": by_status,
},
"message": "ok"
}
@router.get("/", summary="获取OSS存储配置列表")
async def list_storage_configs(
provider: str = Query(None, description="按提供商筛选"),
is_enabled: bool = Query(None, description="按启用状态筛选"),
keyword: str = Query(None, description="搜索关键词"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取OSS存储配置列表,支持分页和筛选"""
from ...models import OSSStorageConfig
query = db.query(OSSStorageConfig)
if provider:
query = query.filter(OSSStorageConfig.provider == provider)
if is_enabled is not None:
query = query.filter(OSSStorageConfig.is_enabled == is_enabled)
if keyword:
keyword_filter = or_(
OSSStorageConfig.name.ilike(f"%{keyword}%"),
OSSStorageConfig.endpoint.ilike(f"%{keyword}%"),
OSSStorageConfig.bucket.ilike(f"%{keyword}%"),
)
query = query.filter(keyword_filter)
total = query.count()
items = query.order_by(desc(OSSStorageConfig.is_default), desc(OSSStorageConfig.updated_at)) \
.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"items": [config_to_dict(item) for item in items],
"total": total,
"page": page,
"page_size": page_size,
},
"message": "ok"
}
@router.get("/{config_id}", summary="获取OSS存储配置详情")
async def get_storage_config(
config_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取单个OSS存储配置的详细信息"""
from ...models import OSSStorageConfig
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="存储配置不存在")
return {
"code": 0,
"data": config_to_dict(config),
"message": "ok"
}
@router.post("/", summary="创建OSS存储配置")
async def create_storage_config(
req: OSSConfigCreate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建新的OSS存储配置"""
from ...models import OSSStorageConfig
# 检查名称重复
existing = db.query(OSSStorageConfig).filter(OSSStorageConfig.name == req.name).first()
if existing:
raise HTTPException(status_code=400, detail="配置名称已存在")
# 如果设为默认,先取消其他默认
if req.is_default:
db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update(
{"is_default": False}
)
config = OSSStorageConfig(
id=gen_uuid(),
name=req.name,
provider=req.provider,
endpoint=req.endpoint,
access_key=req.access_key,
secret_key=req.secret_key,
bucket=req.bucket,
region=req.region or "",
base_path=req.base_path or "",
is_default=req.is_default,
is_enabled=req.is_enabled,
status="disconnected",
config=req.config,
)
db.add(config)
db.commit()
db.refresh(config)
# 审计日志
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_oss_config",
target_type="oss_storage_config",
target_id=config.id,
detail=f"创建了OSS存储配置: {config.name} ({config.provider})"
)
return {
"code": 0,
"data": config_to_dict(config),
"message": "创建成功"
}
@router.put("/{config_id}", summary="更新OSS存储配置")
async def update_storage_config(
config_id: str,
req: OSSConfigUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新OSS存储配置"""
from ...models import OSSStorageConfig
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="存储配置不存在")
# 检查名称重复
if req.name is not None and req.name != config.name:
existing = db.query(OSSStorageConfig).filter(
OSSStorageConfig.name == req.name,
OSSStorageConfig.id != config_id
).first()
if existing:
raise HTTPException(status_code=400, detail="配置名称已存在")
# 如果设为默认,先取消其他默认
if req.is_default and not config.is_default:
db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update(
{"is_default": False}
)
# 更新字段
update_fields = ["name", "provider", "endpoint", "access_key", "secret_key",
"bucket", "region", "base_path", "is_default", "is_enabled"]
for field in update_fields:
value = getattr(req, field, None)
if value is not None:
setattr(config, field, value)
# 如果修改了连接参数,重置状态
if field in ("endpoint", "access_key", "secret_key", "bucket", "provider"):
config.status = "disconnected"
if req.config is not None:
config.config = req.config
config.updated_at = datetime.now()
db.commit()
db.refresh(config)
# 审计日志
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_oss_config",
target_type="oss_storage_config",
target_id=config.id,
detail=f"更新了OSS存储配置: {config.name}"
)
return {
"code": 0,
"data": config_to_dict(config),
"message": "更新成功"
}
@router.delete("/{config_id}", summary="删除OSS存储配置")
async def delete_storage_config(
config_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除OSS存储配置"""
from ...models import OSSStorageConfig
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="存储配置不存在")
name = config.name
db.delete(config)
db.commit()
# 审计日志
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_oss_config",
target_type="oss_storage_config",
target_id=config_id,
detail=f"删除了OSS存储配置: {name}"
)
return {
"code": 0,
"data": None,
"message": f"已删除配置: {name}"
}
@router.put("/{config_id}/toggle", summary="启用/禁用存储配置")
async def toggle_storage_config(
config_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""切换存储配置的启用/禁用状态"""
from ...models import OSSStorageConfig
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="存储配置不存在")
config.is_enabled = not config.is_enabled
config.updated_at = datetime.now()
db.commit()
db.refresh(config)
action = "启用" if config.is_enabled else "禁用"
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="toggle_oss_config",
target_type="oss_storage_config",
target_id=config.id,
detail=f"{action}了OSS存储配置: {config.name}"
)
return {
"code": 0,
"data": config_to_dict(config),
"message": f"已{action}"
}
@router.put("/{config_id}/set-default", summary="设为默认存储配置")
async def set_default_storage_config(
config_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""将指定配置设为默认存储"""
from ...models import OSSStorageConfig
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="存储配置不存在")
# 取消所有默认
db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update(
{"is_default": False}
)
# 设置新默认
config.is_default = True
config.updated_at = datetime.now()
db.commit()
db.refresh(config)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="set_default_oss_config",
target_type="oss_storage_config",
target_id=config.id,
detail=f"将 {config.name} 设为默认存储配置"
)
return {
"code": 0,
"data": config_to_dict(config),
"message": f"已将 {config.name} 设为默认存储"
}
@router.post("/test", summary="测试OSS连接")
async def test_storage_connection(
req: OSSConnectionTest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""测试OSS存储连接"""
from ...models import OSSStorageConfig
provider = req.provider
endpoint = req.endpoint
access_key = req.access_key
secret_key = req.secret_key
bucket = req.bucket
region = req.region or ""
# 如果提供了config_id,从数据库获取配置
if req.config_id:
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == req.config_id).first()
if not config:
raise HTTPException(status_code=404, detail="存储配置不存在")
provider = config.provider
endpoint = config.endpoint
access_key = config.access_key
secret_key = config.secret_key
bucket = config.bucket
region = config.region or ""
if not all([provider, endpoint, access_key, secret_key, bucket]):
raise HTTPException(status_code=400, detail="缺少必要的连接参数")
result = await test_oss_connection(provider, endpoint, access_key, secret_key, bucket, region)
# 如果是已保存的配置,更新连接状态
if req.config_id:
config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == req.config_id).first()
if config:
config.status = "connected" if result["success"] else "disconnected"
config.updated_at = datetime.now()
db.commit()
return {
"code": 0,
"data": result,
"message": "连接测试完成"
}
@@ -0,0 +1,426 @@
"""
多租户管理后端 API
- 租户 CRUD、成员管理、配额管理、邀请管理
- 路由前缀: /app/api/admin/tenant
"""
import uuid
import json
from datetime import datetime, timedelta
from fastapi import APIRouter, Query, HTTPException, Depends
from ...database import SessionLocal, get_db
from ..auth import get_current_user
from sqlalchemy import text
from sqlalchemy.orm import Session
router = APIRouter()
CST_TZ = None
def _format_cst_time(dt_val):
"""Format datetime to CST string."""
global CST_TZ
if CST_TZ is None:
from datetime import timezone
CST_TZ = timezone(timedelta(hours=8))
if dt_val is None:
return None
if isinstance(dt_val, str):
return dt_val
if dt_val.tzinfo is None:
dt_val = dt_val.replace(tzinfo=CST_TZ)
return dt_val.strftime("%Y-%m-%d %H:%M:%S")
def _row_to_dict(row, columns):
"""Convert a SQLAlchemy row to dict."""
return {col: _format_cst_time(val) if isinstance(val, datetime) else val for col, val in zip(columns, row)}
def _execute_raw(db: Session, sql: str, params: dict = None):
"""Execute raw SQL and return results."""
result = db.execute(text(sql), params or {})
if result.returns_rows:
cols = list(result.keys())
rows = result.fetchall()
return [_row_to_dict(row, cols) for row in rows]
return []
# ============================================================
# 租户管理
# ============================================================
@router.get("/list")
async def list_tenants(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: str = Query("", description="搜索关键词"),
status: str = Query("", description="状态筛选"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""获取租户列表"""
where = "1=1"
params = {}
if keyword:
where += " AND (name LIKE :kw OR slug LIKE :kw)"
params["kw"] = f"%{keyword}%"
if status:
where += " AND status = :status"
params["status"] = status
count_sql = f"SELECT COUNT(*) FROM tenants WHERE {where}"
total = db.execute(text(count_sql), params).scalar()
offset = (page - 1) * page_size
list_sql = f"""
SELECT t.*,
(SELECT COUNT(*) FROM tenant_members WHERE tenant_id=t.id AND status='active') as member_count
FROM tenants t WHERE {where}
ORDER BY t.created_at DESC LIMIT :limit OFFSET :offset
"""
params["limit"] = page_size
params["offset"] = offset
rows = db.execute(text(list_sql), params).fetchall()
cols = list(db.execute(text(list_sql), params).keys())
items = []
for row in rows:
d = _row_to_dict(row, cols)
items.append(d)
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
@router.post("/create")
async def create_tenant(
name: str = Query(..., description="租户名称"),
slug: str = Query(..., description="租户标识"),
description: str = Query("", description="描述"),
max_members: int = Query(10, description="最大成员数"),
max_storage_mb: int = Query(10240, description="最大存储(MB)"),
max_api_calls_per_day: int = Query(10000, description="每日API调用上限"),
expire_at: str = Query("", description="到期时间"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""创建租户"""
tid = str(uuid.uuid4())
expire = None
if expire_at:
try:
expire = datetime.fromisoformat(expire_at)
except:
pass
# Check slug uniqueness
existing = db.execute(text("SELECT id FROM tenants WHERE slug=:slug"), {"slug": slug}).fetchone()
if existing:
raise HTTPException(status_code=400, detail="slug已存在")
db.execute(text("""
INSERT INTO tenants (id, name, slug, description, status, max_members, max_storage_mb, max_api_calls_per_day, expire_at)
VALUES (:id, :name, :slug, :desc, 'active', :mm, :ms, :ma, :exp)
"""), {
"id": tid, "name": name, "slug": slug, "desc": description,
"mm": max_members, "ms": max_storage_mb, "ma": max_api_calls_per_day,
"exp": expire
})
# Add creator as owner
db.execute(text("""
INSERT INTO tenant_members (id, tenant_id, user_id, role, status)
VALUES (:mid, :tid, :uid, 'owner', 'active')
"""), {"mid": str(uuid.uuid4()), "tid": tid, "uid": user.id})
db.commit()
return {"code": 200, "data": {"id": tid, "message": "租户创建成功"}}
@router.get("/{tenant_id}")
async def get_tenant(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""获取租户详情"""
row = db.execute(text("""
SELECT t.*,
(SELECT COUNT(*) FROM tenant_members WHERE tenant_id=t.id AND status='active') as member_count,
(SELECT COUNT(*) FROM tenant_quotas WHERE tenant_id=t.id) as quota_count
FROM tenants t WHERE t.id=:id
"""), {"id": tenant_id}).fetchone()
if not row:
raise HTTPException(status_code=404, detail="租户不存在")
cols = list(db.execute(text("SELECT t.* FROM tenants t WHERE t.id=:id"), {"id": tenant_id}).keys())
# Re-query with proper cols
cols = ['id','name','slug','logo','description','status','plan_id','max_members','max_storage_mb',
'max_api_calls_per_day','settings','created_at','updated_at','expire_at','member_count','quota_count']
return {"code": 200, "data": _row_to_dict(row, cols)}
@router.put("/{tenant_id}")
async def update_tenant(
tenant_id: str,
name: str = Query(None),
description: str = Query(None),
status: str = Query(None),
max_members: int = Query(None),
max_storage_mb: int = Query(None),
max_api_calls_per_day: int = Query(None),
expire_at: str = Query(None),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""更新租户"""
sets = []
params = {"id": tenant_id}
for field, val in [("name", name), ("description", description), ("status", status),
("max_members", max_members), ("max_storage_mb", max_storage_mb),
("max_api_calls_per_day", max_api_calls_per_day)]:
if val is not None:
sets.append(f"{field}=:{field}")
params[field] = val
if expire_at is not None:
sets.append("expire_at=:expire_at")
try:
params["expire_at"] = datetime.fromisoformat(expire_at) if expire_at else None
except:
params["expire_at"] = None
if not sets:
return {"code": 200, "data": {"message": "无更新"}}
db.execute(text(f"UPDATE tenants SET {','.join(sets)} WHERE id=:id"), params)
db.commit()
return {"code": 200, "data": {"message": "更新成功"}}
@router.delete("/{tenant_id}")
async def delete_tenant(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""删除租户(软删除 - 标记为terminated)"""
if tenant_id == "default-tenant-0001":
raise HTTPException(status_code=400, detail="默认租户不可删除")
db.execute(text("UPDATE tenants SET status='terminated' WHERE id=:id"), {"id": tenant_id})
db.commit()
return {"code": 200, "data": {"message": "租户已终止"}}
# ============================================================
# 成员管理
# ============================================================
@router.get("/{tenant_id}/members")
async def list_members(
tenant_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""获取租户成员列表"""
total = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status!='removed'"),
{"tid": tenant_id}).scalar()
offset = (page - 1) * page_size
rows = db.execute(text("""
SELECT tm.id, tm.tenant_id, tm.user_id, tm.role, tm.status, tm.joined_at,
u.username, u.email, u.avatar, u.role as user_role
FROM tenant_members tm
LEFT JOIN users u ON tm.user_id = u.id
WHERE tm.tenant_id=:tid AND tm.status!='removed'
ORDER BY tm.joined_at DESC LIMIT :limit OFFSET :offset
"""), {"tid": tenant_id, "limit": page_size, "offset": offset}).fetchall()
cols = ['id','tenant_id','user_id','role','status','joined_at','username','email','avatar','user_role']
items = [_row_to_dict(r, cols) for r in rows]
return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}}
@router.post("/{tenant_id}/members/add")
async def add_member(
tenant_id: str,
user_id: str = Query(..., description="用户ID"),
role: str = Query("member", description="角色: owner/admin/member/viewer"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""添加成员到租户"""
# Check tenant exists
t = db.execute(text("SELECT max_members FROM tenants WHERE id=:id AND status='active'"), {"id": tenant_id}).fetchone()
if not t:
raise HTTPException(status_code=404, detail="租户不存在或已停用")
# Check member limit
cnt = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status='active'"),
{"tid": tenant_id}).scalar()
if cnt >= t[0]:
raise HTTPException(status_code=400, detail="已达成员上限")
# Check if already member
existing = db.execute(text("SELECT id FROM tenant_members WHERE tenant_id=:tid AND user_id=:uid"),
{"tid": tenant_id, "uid": user_id}).fetchone()
if existing:
db.execute(text("UPDATE tenant_members SET status='active', role=:role WHERE tenant_id=:tid AND user_id=:uid"),
{"tid": tenant_id, "uid": user_id, "role": role})
else:
db.execute(text("""
INSERT INTO tenant_members (id, tenant_id, user_id, role, status)
VALUES (:mid, :tid, :uid, :role, 'active')
"""), {"mid": str(uuid.uuid4()), "tid": tenant_id, "uid": user_id, "role": role})
db.commit()
return {"code": 200, "data": {"message": "成员添加成功"}}
@router.put("/{tenant_id}/members/{member_id}")
async def update_member_role(
tenant_id: str,
member_id: str,
role: str = Query(..., description="新角色"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""更新成员角色"""
db.execute(text("UPDATE tenant_members SET role=:role WHERE id=:mid AND tenant_id=:tid"),
{"role": role, "mid": member_id, "tid": tenant_id})
db.commit()
return {"code": 200, "data": {"message": "角色已更新"}}
@router.delete("/{tenant_id}/members/{member_id}")
async def remove_member(tenant_id: str, member_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""移除成员"""
db.execute(text("UPDATE tenant_members SET status='removed', removed_at=NOW() WHERE id=:mid AND tenant_id=:tid"),
{"mid": member_id, "tid": tenant_id})
db.commit()
return {"code": 200, "data": {"message": "成员已移除"}}
# ============================================================
# 配额管理
# ============================================================
@router.get("/{tenant_id}/quotas")
async def get_quotas(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""获取租户配额使用情况"""
rows = db.execute(text("""
SELECT tq.*, t.max_members, t.max_storage_mb, t.max_api_calls_per_day
FROM tenant_quotas tq
RIGHT JOIN tenants t ON tq.tenant_id = t.id
WHERE t.id=:tid
"""), {"tid": tenant_id}).fetchall()
# Build quota summary
quotas = {
"members": {"used": 0, "limit": 0},
"storage": {"used": 0, "limit": 0},
"api_calls": {"used": 0, "limit": 0},
}
# Get actual counts
member_count = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status='active'"),
{"tid": tenant_id}).scalar()
tenant_info = db.execute(text("SELECT max_members, max_storage_mb, max_api_calls_per_day FROM tenants WHERE id=:tid"),
{"tid": tenant_id}).fetchone()
if tenant_info:
quotas["members"] = {"used": member_count, "limit": tenant_info[0]}
quotas["storage"] = {"used": 0, "limit": tenant_info[1]}
quotas["api_calls"] = {"used": 0, "limit": tenant_info[2]}
# Get quota records
qrows = db.execute(text("SELECT * FROM tenant_quotas WHERE tenant_id=:tid"), {"tid": tenant_id}).fetchall()
qcols = ['id','tenant_id','resource_type','used','quota_limit','period','reset_at','updated_at']
for r in qrows:
d = _row_to_dict(r, qcols)
rt = d['resource_type']
if rt in quotas:
quotas[rt]["used"] = d['used']
if d['quota_limit'] > 0:
quotas[rt]["limit"] = d['quota_limit']
else:
quotas[rt] = {"used": d['used'], "limit": d['quota_limit']}
return {"code": 200, "data": quotas}
@router.put("/{tenant_id}/quotas")
async def update_quotas(
tenant_id: str,
resource_type: str = Query(..., description="资源类型"),
quota_limit: int = Query(..., description="配额上限"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""更新租户配额"""
existing = db.execute(text("SELECT id FROM tenant_quotas WHERE tenant_id=:tid AND resource_type=:rt"),
{"tid": tenant_id, "rt": resource_type}).fetchone()
if existing:
db.execute(text("UPDATE tenant_quotas SET quota_limit=:ql WHERE tenant_id=:tid AND resource_type=:rt"),
{"ql": quota_limit, "tid": tenant_id, "rt": resource_type})
else:
db.execute(text("""
INSERT INTO tenant_quotas (id, tenant_id, resource_type, used, quota_limit, period)
VALUES (:id, :tid, :rt, 0, :ql, 'total')
"""), {"id": str(uuid.uuid4()), "tid": tenant_id, "rt": resource_type, "ql": quota_limit})
db.commit()
return {"code": 200, "data": {"message": "配额已更新"}}
# ============================================================
# 邀请管理
# ============================================================
@router.post("/{tenant_id}/invitations")
async def create_invitation(
tenant_id: str,
email: str = Query(..., description="邀请邮箱"),
role: str = Query("member", description="角色"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""创建租户邀请"""
import secrets
token = secrets.token_urlsafe(32)
expires = datetime.now() + timedelta(days=7)
db.execute(text("""
INSERT INTO tenant_invitations (id, tenant_id, email, role, token, status, invited_by, expires_at)
VALUES (:id, :tid, :email, :role, :token, 'pending', :uid, :exp)
"""), {
"id": str(uuid.uuid4()), "tid": tenant_id, "email": email,
"role": role, "token": token, "uid": user.id, "exp": expires
})
db.commit()
return {"code": 200, "data": {"token": token, "expires_at": _format_cst_time(expires)}}
@router.get("/{tenant_id}/invitations")
async def list_invitations(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""获取租户邀请列表"""
rows = db.execute(text("""
SELECT ti.*, u.username as invited_by_name
FROM tenant_invitations ti
LEFT JOIN users u ON ti.invited_by = u.id
WHERE ti.tenant_id=:tid
ORDER BY ti.created_at DESC
"""), {"tid": tenant_id}).fetchall()
cols = ['id','tenant_id','email','role','token','status','invited_by','accepted_by',
'expires_at','created_at','invited_by_name']
items = [_row_to_dict(r, cols) for r in rows]
return {"code": 200, "data": items}
# ============================================================
# 统计
# ============================================================
@router.get("/stats/overview")
async def tenant_stats(db: Session = Depends(get_db), user=Depends(get_current_user)):
"""租户统计概览"""
total = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status!='terminated'")).scalar()
active = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status='active'")).scalar()
suspended = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status='suspended'")).scalar()
total_members = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE status='active'")).scalar()
total_invitations = db.execute(text("SELECT COUNT(*) FROM tenant_invitations WHERE status='pending'")).scalar()
return {"code": 200, "data": {
"total_tenants": total,
"active_tenants": active,
"suspended_tenants": suspended,
"total_members": total_members,
"pending_invitations": total_invitations,
}}
File diff suppressed because it is too large. Load diff
+601
View File
@@ -0,0 +1,601 @@
"""
Phase 4: 个人微信 (iLink 协议 / 微信 ClawBot 插件) —— 前台用户模块
im_bot_user.py
为【平台注册用户】提供"各自绑定自己的个人微信"能力:
- 每个用户独立一条 im_bot_wechat_account 记录(按 user_id 隔离)
- 扫码登录免 AppID;消息收发走 Bearer bot_token + X-WECHAT-UIN
- 绑定后由七虎AI 大脑自动应答(复用 channels 的真实 LLM 调用)
与后台 im_bot_admin 的区别:
- 后台:平台级 IM Bot 接入中枢(飞书/企微/Telegram 等多平台、路由、统计),其个人微信
端点仅限 user_id IS NULL 的"全局 Bot",与用户绑定互不干扰。
- 前台:每个登录用户管理自己的个人微信,数据严格按 user_id 隔离。
"""
import base64
import json
import random
import threading
import time
from datetime import datetime, timedelta
from typing import Optional
import requests
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import SessionLocal
from ..models import User
router = APIRouter()
# ============================================================
# iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID)
# ============================================================
ILINK_BASE = "https://ilinkai.weixin.qq.com"
# 每个已连接账号一个后台接收线程,key = 账号表主键 id
_wechat_receiver_threads: dict[int, threading.Thread] = {}
# ============================================================
# 通用 DB 工具
# ============================================================
def _execute_raw(db: Session, sql_text, params=None):
result = db.execute(sql_text, params or {})
if result.returns_rows:
return result.fetchall()
db.commit()
return result
def _row_to_dict(row) -> dict:
if row is None:
return None
d = {}
for col in row._mapping.keys():
val = row._mapping[col]
if isinstance(val, datetime):
val = val.isoformat()
d[col] = val
return d
# ============================================================
# iLink 协议请求工具
# ============================================================
def _wechat_headers(bot_token: str) -> dict:
"""iLink 协议请求头:Bearer bot_token + 随机 X-WECHAT-UIN 防重放"""
uin = base64.b64encode(str(random.randint(0, 2 ** 32 - 1)).encode("utf-8")).decode("utf-8")
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {bot_token}",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": uin,
}
def _wechat_row_for_user(db: Session, user_id: str):
rows = _execute_raw(
db,
text("SELECT * FROM im_bot_wechat_account WHERE user_id = :uid ORDER BY id DESC LIMIT 1"),
{"uid": user_id},
)
return rows[0] if rows else None
def _wechat_mask(row) -> dict:
d = _row_to_dict(row)
if d.get("bot_token"):
bt = d["bot_token"]
d["bot_token"] = ("***" + bt[-6:]) if len(bt) > 6 else "***"
d.pop("appid", None)
d.pop("secret_key", None)
return d
# ============================================================
# 七虎AI 大脑(复用 channels 的真实 LLM 调用)
# ============================================================
def _recent_history(user_id: str, limit: int = 6) -> str:
"""拼接最近若干轮对话,作为多轮上下文。"""
db = None
try:
db = SessionLocal()
rows = _execute_raw(db, text(
"SELECT role, content FROM im_bot_chat_log "
"WHERE user_id=:uid ORDER BY id DESC LIMIT :lim"
), {"uid": user_id, "lim": limit})
pairs = []
for r in reversed(rows):
dd = _row_to_dict(r)
role = "用户" if dd["role"] == "user" else "七虎AI"
pairs.append(f"{role}:{dd['content']}")
return "\n".join(pairs)
except Exception:
return ""
finally:
if db:
db.close()
def _qihua_reply(text: str, user_id: str = None) -> str:
"""调用七虎AI 生成回复。自行创建 DB 会话,避免上层线程无可用 session。"""
prompt = text
if user_id:
hist = _recent_history(user_id)
if hist:
prompt = (
"以下是你与该用户最近的对话历史,请结合上下文作答:\n"
f"{hist}\n\n"
f"用户当前问题:{text}"
)
db = None
try:
from app.api.channels import _call_real_llm
db = SessionLocal()
return _call_real_llm(db, None, prompt, "个人微信")
except Exception as e:
return f"(AI 回复生成失败:{e})"
finally:
if db:
db.close()
# ============================================================
# iLink 消息收发
# ============================================================
def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
"""长轮询收消息,返回 (msgs, new_cursor)。最多 hold 35s。"""
try:
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/getupdates",
headers=_wechat_headers(bot_token),
json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}},
timeout=40,
)
if not resp.ok:
return [], cursor
j = resp.json() if resp.content else {}
return j.get("msgs", []) or [], j.get("get_updates_buf", cursor)
except Exception:
return [], cursor
def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str):
"""发送文本回复(必须带 context_token 关联到正确的对话窗口)。
返回 (ok, ret, body)。ret=-14=会话过期需重扫;ret=-2=token 失效。"""
import uuid as _uuid
payload = {
"msg": {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": "py-" + str(_uuid.uuid4()),
"message_type": 2,
"message_state": 2,
"context_token": context_token,
"item_list": [{"type": 1, "text_item": {"text": text}}],
},
"base_info": {"channel_version": "1.0.2"},
}
last_body = {}
for _ in range(2):
try:
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/sendmessage",
headers=_wechat_headers(bot_token),
json=payload,
timeout=20,
)
try:
body = resp.json() if resp.content else {}
except Exception:
body = {}
last_body = body
ret = body.get("ret") if isinstance(body, dict) else None
try:
with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f:
_f.write(datetime.now().isoformat() + " SEND_HTTP=%s ret=%s body=%s\n"
% (resp.status_code, ret, json.dumps(body, ensure_ascii=False)[:300]))
except Exception:
pass
if resp.ok and (ret is None or ret == 0 or not body):
return True, ret, body
if ret in (-2, -14):
return False, ret, body # 鉴权/会话失效,立即停止重试
except Exception:
pass
return False, (last_body.get("ret") if isinstance(last_body, dict) else None), last_body
def _wechat_extract_text(msg: dict) -> str:
parts = []
for it in (msg.get("item_list") or []):
if it.get("type") == 1 and it.get("text_item", {}).get("text"):
parts.append(it["text_item"]["text"])
if not parts:
for k in ("text", "content", "message", "msg"):
v = msg.get(k)
if isinstance(v, str) and v.strip():
parts.append(v)
return "\n".join(parts)
def _wechat_sender_id(m: dict) -> str:
"""从入站消息中提取发送者 id(兼容多套字段名)。"""
for k in ("from_user_id", "from_user_name", "from_username", "user_id",
"sender_id", "wxid", "from_wxid", "username"):
v = m.get(k)
if v:
return str(v)
return ""
def _wechat_context_token(m: dict) -> str:
"""提取 context_token(回复必须回带,否则不关联到对话窗口)。"""
for k in ("context_token", "ctx_token", "conversation_id", "conv_id",
"msg_id", "context_id", "session_id"):
v = m.get(k)
if v:
return str(v)
return ""
def _log_chat(user_id, platform, from_user, context_token, role, content):
"""持久化 IM Bot 对话历史,便于平台内查阅。"""
db = None
try:
db = SessionLocal()
_execute_raw(db, text(
"INSERT INTO im_bot_chat_log "
"(user_id, platform, from_user, context_token, role, content, created_at) "
"VALUES (:uid, :platform, :fu, :ctx, :role, :content, :created)"
), {
"uid": user_id, "platform": platform, "fu": from_user,
"ctx": context_token, "role": role, "content": content,
"created": datetime.now(),
})
except Exception:
pass
finally:
if db:
db.close()
# ============================================================
# 镜像到 AI 聊天(标准 conversations/messages)
# 个人微信对话作为一个独立会话窗(默认名 "WeXin Bot")出现在 AI 聊天中,
# 从而复用 AI 聊天模块的全部功能(滚动翻阅、重命名、置顶、删除等)。
# ============================================================
WECHAT_CONV_TITLE = "WeXin Bot"
def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
"""按 user_id 找/建 source='wechat_bot' 的会话,返回会话 id。"""
rows = _execute_raw(db, text(
"SELECT id FROM conversations WHERE user_id=:uid AND source='wechat_bot' "
"AND status<>'archived' ORDER BY updated_at DESC LIMIT 1"
), {"uid": user_id})
if rows:
return _row_to_dict(rows[0])["id"]
import uuid as _uuid
cid = str(_uuid.uuid4())
now = datetime.now()
_execute_raw(db, text(
"INSERT INTO conversations "
"(id, user_id, title, status, pinned, knowledge_graph_count, source, created_at, updated_at) "
"VALUES (:id, :uid, :title, 'active', 0, 0, 'wechat_bot', :c, :u)"
), {"id": cid, "uid": user_id, "title": WECHAT_CONV_TITLE, "c": now, "u": now})
return cid
def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None):
import uuid as _uuid
_execute_raw(db, text(
"INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) "
"VALUES (:id, :cid, :role, :content, 0, :c)"
), {
"id": str(_uuid.uuid4()), "cid": conv_id, "role": role,
"content": content, "c": created or datetime.now(),
})
def _mirror_to_ai_chat(user_id: str, user_content: str, assistant_content: str):
"""把一轮微信问答写入用户的 WeXin Bot AI 聊天会话。"""
if not user_id:
return
db = None
try:
db = SessionLocal()
cid = _get_or_create_wechat_conv(db, user_id)
now = datetime.now()
_append_ai_message(db, cid, "user", user_content, created=now)
# assistant 比 user 晚 50ms,保证排序严格递增,避免同秒时 ORDER BY 乱序
_append_ai_message(db, cid, "assistant", assistant_content,
created=now + timedelta(milliseconds=50))
_execute_raw(db, text("UPDATE conversations SET updated_at=:u WHERE id=:id"),
{"u": datetime.now(), "id": cid})
except Exception:
pass
finally:
if db:
db.close()
# ============================================================
# 后台接收线程(每用户独立)
# ============================================================
def _mark_wechat_expired(account_id: int):
"""发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。"""
db = None
try:
db = SessionLocal()
_execute_raw(db, text(
"UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, "
"updated_at=:ts WHERE id=:aid"
), {"aid": account_id, "ts": datetime.now()})
except Exception:
pass
finally:
if db:
db.close()
def _wechat_receiver_loop(account_id: int):
"""长轮询个人微信消息 → 七虎AI 回复 → 发送。"""
cursor = ""
while True:
db = SessionLocal()
try:
row = _execute_raw(
db,
text("SELECT * FROM im_bot_wechat_account WHERE id = :aid"),
{"aid": account_id},
)
if not row:
return
d = _row_to_dict(row[0])
if d.get("status") != "connected" or not d.get("bot_token"):
return
bot_token = d["bot_token"]
baseurl = d.get("baseurl") or ILINK_BASE
msgs, cursor = _wechat_get_updates(baseurl, bot_token, cursor)
owner_uid = d.get("user_id")
for m in msgs:
if m.get("message_type") == 2:
continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理
content = _wechat_extract_text(m)
if not content:
continue
sender = _wechat_sender_id(m)
ctx = _wechat_context_token(m)
# 诊断:记录原始入站消息,定位字段名 / 第二轮不回复
try:
with open("/tmp/wechat_inbound.log", "a", encoding="utf-8") as _f:
_f.write(datetime.now().isoformat() + " sender=" + repr(sender)
+ " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n")
except Exception:
pass
# 先生成回复(此时历史尚未包含本条,避免重复)
reply = _qihua_reply(content, owner_uid)
ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply)
if ok:
# 仅发送成功才写对话历史(避免“发了但没收到”的误导记录)
if owner_uid:
_log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content)
_log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply)
# 同步镜像到 AI 聊天的 WeXin Bot 会话窗
_mirror_to_ai_chat(owner_uid, content, reply)
else:
try:
with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f:
_f.write(datetime.now().isoformat() + " SEND_FAIL ret=%s sender=%s\n"
% (ret, repr(sender)))
except Exception:
pass
if ret in (-2, -14):
# 会话/鉴权失效:标记需重扫并停止接收线程
_mark_wechat_expired(d["id"])
return
time.sleep(1)
except Exception:
time.sleep(5)
finally:
db.close()
def _start_wechat_receiver(account_id: int):
if account_id in _wechat_receiver_threads and _wechat_receiver_threads[account_id].is_alive():
return
t = threading.Thread(target=_wechat_receiver_loop, args=(account_id,), daemon=True)
t.start()
_wechat_receiver_threads[account_id] = t
# ============================================================
# 前台用户接口(按 user_id 隔离)
# ============================================================
@router.get("/status")
async def wechat_status(user: User = Depends(get_current_user)):
"""当前登录用户的个人微信连接状态"""
db = SessionLocal()
try:
row = _wechat_row_for_user(db, user.id)
if not row:
return {"success": True, "data": {"connected": False, "configured": False}}
d = _row_to_dict(row)
return {"success": True, "data": {
"connected": d.get("status") == "connected",
"status": d.get("status"),
"configured": True,
"baseurl": d.get("baseurl"),
"ilink_bot_id": d.get("ilink_bot_id"),
"username": d.get("username"),
"connected_at": str(d.get("connected_at")) if d.get("connected_at") else None,
}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.post("/qrcode")
async def wechat_qrcode(user: User = Depends(get_current_user)):
"""获取个人微信扫码登录二维码(iLink 协议,免 AppID)"""
try:
resp = requests.get(
ILINK_BASE + "/ilink/bot/get_bot_qrcode",
params={"bot_type": 3},
timeout=20,
)
if not resp.ok:
return {"success": False, "error": f"获取二维码失败 HTTP {resp.status_code}"}
j = resp.json() if resp.content else {}
qr_url = j.get("qrcode_img_content") or ""
qr_img = None
try:
import io as _io
import qrcode as _qrcode
_buf = _io.BytesIO()
_q = _qrcode.QRCode(box_size=10, border=2)
_q.add_data(qr_url)
_q.make(fit=True)
_img = _q.make_image(fill_color="black", back_color="white")
_img.save(_buf, format="PNG")
qr_img = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode()
except Exception:
qr_img = None
return {"success": True, "data": {
"qrcode": j.get("qrcode"),
"qrcode_img_content": qr_url,
"qrcode_img": qr_img,
}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/qrcode/status")
async def wechat_qrcode_status(
qrcode: str = Query(...),
user: User = Depends(get_current_user),
):
"""轮询扫码状态;confirmed 时保存该用户的 bot_token 并启动接收线程"""
try:
resp = requests.get(
ILINK_BASE + "/ilink/bot/get_qrcode_status",
params={"qrcode": qrcode},
timeout=40,
)
if not resp.ok:
return {"success": False, "error": f"查询状态失败 HTTP {resp.status_code}"}
j = resp.json() if resp.content else {}
data = j.get("data", j) if isinstance(j, dict) else j
status = data.get("status")
if status == "confirmed":
creds = data.get("credentials", {}) or {}
bot_token = data.get("bot_token") or creds.get("bot_token")
baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE
ilink_bot_id = data.get("ilink_bot_id") or creds.get("ilink_bot_id")
db = SessionLocal()
try:
row = _wechat_row_for_user(db, user.id)
if not row:
_execute_raw(db, text("""INSERT INTO im_bot_wechat_account
(user_id, username, bot_token, ilink_bot_id, baseurl, status,
connected_at, created_at, updated_at)
VALUES (:user_id, :username, :bot_token, :ilink_bot_id, :baseurl,
'connected', :connected_at, :created_at, :updated_at)"""),
{
"user_id": user.id, "username": user.username,
"bot_token": bot_token, "ilink_bot_id": ilink_bot_id,
"baseurl": baseurl,
"connected_at": datetime.now(),
"created_at": datetime.now(), "updated_at": datetime.now(),
})
else:
_execute_raw(db, text("""UPDATE im_bot_wechat_account SET
user_id=:user_id, username=:username, bot_token=:bot_token,
ilink_bot_id=:ilink_bot_id, baseurl=:baseurl,
status='connected', connected_at=:connected_at, updated_at=:updated_at
WHERE id=:id"""),
{
"user_id": user.id, "username": user.username,
"bot_token": bot_token, "ilink_bot_id": ilink_bot_id,
"baseurl": baseurl, "connected_at": datetime.now(),
"updated_at": datetime.now(), "id": _row_to_dict(row)["id"],
})
row = _wechat_row_for_user(db, user.id)
aid = _row_to_dict(row)["id"] if row else 1
_start_wechat_receiver(aid)
finally:
db.close()
return {"success": True, "data": {"status": "confirmed", "connected": True}}
return {"success": True, "data": {"status": status, "connected": False}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/disconnect")
async def wechat_disconnect(user: User = Depends(get_current_user)):
"""断开当前用户的个人微信连接(清除 bot_token)"""
db = SessionLocal()
try:
row = _wechat_row_for_user(db, user.id)
if row:
_execute_raw(db, text(
"UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', "
"updated_at=:updated_at WHERE id=:id"
), {"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]})
return {"success": True, "message": "已断开个人微信连接"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
# ============================================================
# 启动恢复:为所有已连接用户账号拉起接收线程
# ============================================================
@router.get("/history")
async def wechat_history(
user: User = Depends(get_current_user),
limit: int = Query(200),
):
"""查询当前用户通过个人微信与七虎AI 的对话历史"""
db = SessionLocal()
try:
rows = _execute_raw(
db,
text("SELECT id, role, content, platform, from_user, created_at "
"FROM im_bot_chat_log WHERE user_id=:uid ORDER BY id DESC LIMIT :lim"),
{"uid": user.id, "lim": min(int(limit), 500)},
)
items = [_row_to_dict(r) for r in rows]
items.reverse()
return {"success": True, "data": {"messages": items, "total": len(items)}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
def start_all_wechat_receivers():
"""在应用启动时调用,恢复此前已连接用户的个人微信接收线程。"""
db = SessionLocal()
try:
rows = _execute_raw(
db,
text("SELECT id FROM im_bot_wechat_account WHERE status='connected' AND user_id IS NOT NULL"),
)
for r in rows:
_start_wechat_receiver(r._mapping["id"])
if rows:
import logging
logging.getLogger("aiflow").info(f"[IM个人微信] 已恢复 {len(rows)} 个用户微信接收线程")
except Exception as e:
import logging
logging.getLogger("aiflow").warning(f"[IM个人微信] 启动恢复失败(不影响服务): {e}")
finally:
db.close()
@@ -0,0 +1,494 @@
#!/usr/bin/env python3
"""
User Portal Backend API - provides user-scoped endpoints (not admin).
Endpoints for: overview, API keys, billing/recharge, tenants, analytics.
"""
import json
import uuid
import hashlib
import secrets
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from ...database import SessionLocal
from ..auth import get_current_user
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
CST_TZ = None
from datetime import timezone, timedelta as td
CST_TZ = timezone(td(hours=8))
def _format_cst_time(dt):
if dt is None:
return None
if isinstance(dt, str):
return dt
if dt.tzinfo is None:
dt = dt.replace(tzinfo=CST_TZ)
return dt.strftime('%Y-%m-%d %H:%M:%S')
# ==================== Overview ====================
@router.get("/overview")
async def get_overview(
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""用户门户概览"""
user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user))
# User stats
user_info = db.execute(text("""
SELECT id, username, email, avatar, role, membership_level, membership_expire_at, created_at
FROM users WHERE id = :uid
"""), {"uid": user_id}).fetchone()
if not user_info:
raise HTTPException(status_code=404, detail="用户不存在")
# Token usage this month
now = datetime.now()
month_start = now.replace(day=1, hour=0, minute=0, second=0)
token_stats = db.execute(text("""
SELECT
COALESCE(SUM(total_tokens), 0) as total_tokens,
COALESCE(SUM(prompt_tokens), 0) as prompt_tokens,
COALESCE(SUM(completion_tokens), 0) as completion_tokens,
COUNT(*) as call_count
FROM token_usage_logs
WHERE user_id = :uid AND created_at >= :month_start
"""), {"uid": user_id, "month_start": month_start}).fetchone()
# API calls last 7 days
seven_days_ago = now - timedelta(days=7)
api_calls = db.execute(text("""
SELECT DATE(created_at) as date, COUNT(*) as count, COALESCE(SUM(total_tokens), 0) as tokens
FROM token_usage_logs
WHERE user_id = :uid AND created_at >= :since
GROUP BY DATE(created_at) ORDER BY date
"""), {"uid": user_id, "since": seven_days_ago}).fetchall()
daily_data = [{"date": str(r.date), "calls": r.count, "tokens": r.tokens} for r in api_calls]
# Tenants
tenants = db.execute(text("""
SELECT t.id, t.name, t.slug, t.status, tm.role
FROM tenant_members tm
JOIN tenants t ON tm.tenant_id = t.id
WHERE tm.user_id = :uid AND tm.status = 'active'
"""), {"uid": user_id}).fetchall()
# Recent transactions
recent_txns = db.execute(text("""
SELECT id, transaction_type, amount, tokens, description, status, created_at
FROM billing_transactions
WHERE user_id = :uid
ORDER BY created_at DESC LIMIT 5
"""), {"uid": user_id}).fetchall()
# API keys count
api_key_count = db.execute(text("""
SELECT COUNT(*) FROM api_keys WHERE user_id = :uid AND status = 'active'
"""), {"uid": user_id}).scalar() or 0
return {
"code": 200,
"data": {
"user": {
"id": user_info.id,
"username": user_info.username,
"email": user_info.email,
"avatar": user_info.avatar,
"role": user_info.role,
"membership_level": user_info.membership_level,
"membership_expire_at": _format_cst_time(user_info.membership_expire_at),
"created_at": _format_cst_time(user_info.created_at),
},
"token_stats": {
"total_tokens": token_stats.total_tokens if token_stats else 0,
"prompt_tokens": token_stats.prompt_tokens if token_stats else 0,
"completion_tokens": token_stats.completion_tokens if token_stats else 0,
"call_count": token_stats.call_count if token_stats else 0,
},
"daily_usage": daily_data,
"tenants": [{"id": t.id, "name": t.name, "slug": t.slug, "status": t.status, "role": t.role} for t in tenants],
"recent_transactions": [{
"id": r.id, "type": r.transaction_type, "amount": float(r.amount) if r.amount else 0,
"tokens": r.tokens, "description": r.description, "status": r.status,
"created_at": _format_cst_time(r.created_at)
} for r in recent_txns],
"api_key_count": api_key_count,
}
}
# ==================== API Keys ====================
@router.get("/api-keys")
async def list_api_keys(
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""用户API密钥列表"""
user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user))
rows = db.execute(text("""
SELECT id, name, api_key, status, created_at, last_used_at
FROM api_keys
WHERE user_id = :uid
ORDER BY created_at DESC
"""), {"uid": user_id}).fetchall()
return {
"code": 200,
"data": [{
"id": r.id,
"key_name": r.name,
"key_prefix": r.api_key[:11] + "..." if r.api_key else "",
"status": r.status,
"created_at": _format_cst_time(r.created_at),
"last_used_at": _format_cst_time(r.last_used_at),
} for r in rows]
}
@router.post("/api-keys/create")
async def create_api_key(
key_name: str = Query(..., description="密钥名称"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""创建API密钥"""
user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user))
# Generate API key
raw_key = secrets.token_hex(32)
key_prefix = "sk-" + raw_key[:8]
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
key_id = str(uuid.uuid4())
# Check if api_keys table exists, create if not
db.execute(text("""
-- api_keys table already exists
"""))
db.execute(text("""
INSERT INTO api_keys (id, user_id, api_key, secret, name, status)
VALUES (:id, :uid, :prefix, :hash, :name, 'active')
"""), {
"id": key_id, "uid": user_id,
"name": key_name, "prefix": key_prefix, "hash": key_hash
})
db.commit()
return {
"code": 200,
"data": {
"id": key_id,
"key_name": key_name,
"key_prefix": key_prefix,
"api_key": raw_key,
"message": "请妥善保存密钥,此为唯一一次显示完整密钥"
}
}
@router.delete("/api-keys/{key_id}")
async def revoke_api_key(
key_id: str,
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""撤销API密钥"""
user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user))
result = db.execute(text("""
UPDATE api_keys SET status = 'revoked'
WHERE id = :kid AND user_id = :uid
"""), {"kid": key_id, "uid": user_id})
db.commit()
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="密钥不存在或无权操作")
return {"code": 200, "data": {"message": "密钥已撤销"}}
# ==================== Billing / Recharge ====================
@router.get("/billing/packages")
async def list_packages(
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""可用充值套餐"""
rows = db.execute(text("""
SELECT id, name, description, tokens, price, original_price, bonus_tokens, is_popular, status
FROM recharge_packages
WHERE status = 'active'
ORDER BY price ASC
""")).fetchall()
return {
"code": 200,
"data": [{
"id": r.id, "name": r.name, "description": r.description,
"tokens": r.tokens, "price": float(r.price) if r.price else 0,
"original_price": float(r.original_price) if r.original_price else None,
"bonus_tokens": r.bonus_tokens or 0,
"is_popular": bool(r.is_popular) if r.is_popular else False,
} for r in rows]
}
@router.get("/billing/transactions")
async def list_user_transactions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""用户交易记录"""
user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user))
total = db.execute(text("""
SELECT COUNT(*) FROM billing_transactions WHERE user_id = :uid
"""), {"uid": user_id}).scalar() or 0
offset = (page - 1) * page_size
rows = db.execute(text("""
SELECT id, transaction_type, amount, tokens, balance_after, model_name,
description, status, created_at
FROM billing_transactions
WHERE user_id = :uid
ORDER BY created_at DESC LIMIT :limit OFFSET :offset
"""), {"uid": user_id, "limit": page_size, "offset": offset}).fetchall()
return {
"code": 200,
"data": {
"items": [{
"id": r.id, "type": r.transaction_type,
"amount": float(r.amount) if r.amount else 0,
"tokens": r.tokens, "balance_after": r.balance_after,
"model_name": r.model_name, "description": r.description,
"status": r.status, "created_at": _format_cst_time(r.created_at)
} for r in rows],
"total": total, "page": page, "page_size": page_size
}
}
@router.post("/billing/recharge")
async def recharge(
package_id: str = Query(..., description="套餐ID"),
payment_method: str = Query("wechat", description="支付方式"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""充值购买"""
user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user))
# Get package info
pkg = db.execute(text("""
SELECT id, name, tokens, price FROM recharge_packages
WHERE id = :pid AND status = 'active'
"""), {"pid": package_id}).fetchone()
if not pkg:
raise HTTPException(status_code=404, detail="套餐不存在")
# Create transaction
txn_id = str(uuid.uuid4())
db.execute(text("""
INSERT INTO billing_transactions (id, user_id, transaction_type, amount, tokens, description, metadata, status)
VALUES (:id, :uid, 'recharge', :amount, :tokens, :desc, :meta, 'pending')
"""), {
"id": txn_id, "uid": user_id,
"amount": float(pkg.price), "tokens": pkg.tokens,
"desc": f"充值 - {pkg.name}",
"meta": json.dumps({"package_id": package_id, "payment_method": payment_method})
})
db.commit()
return {
"code": 200,
"data": {
"transaction_id": txn_id,
"amount": float(pkg.price),
"tokens": pkg.tokens,
"payment_method": payment_method,
"status": "pending",
"message": "订单已创建,请完成支付"
}
}
# ==================== Tenants ====================
@router.get("/tenants")
async def list_user_tenants(
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""用户所属租户列表"""
user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user))
rows = db.execute(text("""
SELECT t.id, t.name, t.slug, t.description, t.status, t.max_members,
t.max_storage_mb, t.max_api_calls_per_day, tm.role, tm.joined_at,
(SELECT COUNT(*) FROM tenant_members tm2 WHERE tm2.tenant_id = t.id AND tm2.status = 'active') as member_count
FROM tenant_members tm
JOIN tenants t ON tm.tenant_id = t.id
WHERE tm.user_id = :uid AND tm.status = 'active'
ORDER BY tm.joined_at DESC
"""), {"uid": user_id}).fetchall()
return {
"code": 200,
"data": [{
"id": r.id, "name": r.name, "slug": r.slug,
"description": r.description, "status": r.status,
"max_members": r.max_members, "max_storage_mb": r.max_storage_mb,
"max_api_calls_per_day": r.max_api_calls_per_day,
"role": r.role, "joined_at": _format_cst_time(r.joined_at),
"member_count": r.member_count
} for r in rows]
}
@router.post("/tenants/create")
async def create_tenant(
name: str = Query(..., description="租户名称"),
slug: str = Query(..., description="租户标识"),
description: str = Query("", description="描述"),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""创建租户"""
user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user))
# Check slug uniqueness
existing = db.execute(text("SELECT id FROM tenants WHERE slug = :slug"), {"slug": slug}).fetchone()
if existing:
raise HTTPException(status_code=400, detail="租户标识已存在")
tenant_id = str(uuid.uuid4())
member_id = str(uuid.uuid4())
db.execute(text("""
INSERT INTO tenants (id, name, slug, description, status, max_members, max_storage_mb, max_api_calls_per_day)
VALUES (:id, :name, :slug, :desc, 'active', 10, 10240, 10000)
"""), {"id": tenant_id, "name": name, "slug": slug, "desc": description})
db.execute(text("""
INSERT INTO tenant_members (id, tenant_id, user_id, role, status)
VALUES (:mid, :tid, :uid, 'owner', 'active')
"""), {"mid": member_id, "tid": tenant_id, "uid": user_id})
db.commit()
return {
"code": 200,
"data": {
"tenant_id": tenant_id,
"name": name,
"slug": slug,
"message": "租户创建成功"
}
}
# ==================== Analytics ====================
@router.get("/analytics/usage")
async def get_usage_analytics(
days: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""个人用量分析"""
user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user))
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
# Daily usage
daily = db.execute(text("""
SELECT DATE(created_at) as date,
COUNT(*) as calls,
COALESCE(SUM(total_tokens), 0) as tokens,
COALESCE(SUM(prompt_tokens), 0) as prompt_tokens,
COALESCE(SUM(completion_tokens), 0) as completion_tokens
FROM token_usage_logs
WHERE user_id = :uid AND created_at >= :since
GROUP BY DATE(created_at) ORDER BY date
"""), {"uid": user_id, "since": since}).fetchall()
# Model distribution
models = db.execute(text("""
SELECT model_name, COUNT(*) as calls, COALESCE(SUM(total_tokens), 0) as tokens
FROM token_usage_logs
WHERE user_id = :uid AND created_at >= :since AND model_name IS NOT NULL
GROUP BY model_name ORDER BY tokens DESC
"""), {"uid": user_id, "since": since}).fetchall()
# Total stats
total = db.execute(text("""
SELECT COUNT(*) as calls, COALESCE(SUM(total_tokens), 0) as tokens
FROM token_usage_logs WHERE user_id = :uid AND created_at >= :since
"""), {"uid": user_id, "since": since}).fetchone()
return {
"code": 200,
"data": {
"daily": [{
"date": str(r.date), "calls": r.calls,
"tokens": r.tokens, "prompt_tokens": r.prompt_tokens,
"completion_tokens": r.completion_tokens
} for r in daily],
"model_distribution": [{
"model": r.model_name, "calls": r.calls, "tokens": r.tokens
} for r in models],
"total": {
"calls": total.calls if total else 0,
"tokens": total.tokens if total else 0
},
"days": days
}
}
@router.get("/analytics/trends")
async def get_usage_trends(
days: int = Query(7, ge=1, le=90),
db: Session = Depends(get_db),
user=Depends(get_current_user)
):
"""用量趋势"""
user_id = user.id if hasattr(user, "id") else (user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user))
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
rows = db.execute(text("""
SELECT DATE(created_at) as date, COUNT(*) as calls,
COALESCE(SUM(total_tokens), 0) as tokens
FROM token_usage_logs
WHERE user_id = :uid AND created_at >= :since
GROUP BY DATE(created_at) ORDER BY date
"""), {"uid": user_id, "since": since}).fetchall()
return {
"code": 200,
"data": [{
"date": str(r.date), "calls": r.calls, "tokens": r.tokens
} for r in rows]
}