forked from xiaohei/taiji-AI-PAD
4544 lines
169 KiB
Python
4544 lines
169 KiB
Python
"""
|
||
超级管理员API路由
|
||
"""
|
||
|
||
from datetime import datetime, timedelta
|
||
from typing import List, Optional, Dict, Any
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||
from sqlalchemy import select, func, and_, desc, or_
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
import uuid
|
||
import structlog
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
from database import get_db
|
||
from models import (
|
||
User, Channel, Agent, ResourceAllocation,
|
||
BillingRecord, Application, ModelProvider,
|
||
ChannelProviderAccess, ProviderApplication,
|
||
ChannelCustomAgentQuota, ResourceApplication, PlatformAgentQuota,
|
||
PlatformAgentTemplateConfig, AgentBillingRecord, ModelBillingRecord,
|
||
TenantCustomAgentQuota, TenantModelKey, Balance
|
||
)
|
||
from app.auth import require_auth, get_password_hash
|
||
from app.schemas import (
|
||
SuccessResponse,
|
||
CreateAdminRequest,
|
||
CreateChannelRequest,
|
||
UpdateChannelRequest,
|
||
UpdateAgentConfigRequest,
|
||
ChannelInfo,
|
||
ChannelResourceAllocation,
|
||
ApplicationInfo,
|
||
ReviewApplicationRequest,
|
||
AdminBillingResponse,
|
||
ReviewProviderApplicationRequest,
|
||
UpdateChannelCommissionRequest,
|
||
ReviewResourceApplicationRequest,
|
||
)
|
||
from app.permissions import has_permission, get_role_permissions
|
||
|
||
router = APIRouter(prefix="/api/admin", tags=["超级管理员"])
|
||
|
||
|
||
def _format_cpu_usage(cpu_str: str) -> str:
|
||
"""将 CPU 使用量从 nanocores 转换为 millicores
|
||
|
||
例如: "830511n" -> "0.83m"
|
||
"""
|
||
if not cpu_str or cpu_str == "0":
|
||
return "0m"
|
||
|
||
if cpu_str.endswith("n"):
|
||
try:
|
||
nanocores = float(cpu_str[:-1])
|
||
millicores = nanocores / 1_000_000
|
||
if millicores < 1:
|
||
return f"{millicores:.2f}m"
|
||
return f"{millicores:.0f}m"
|
||
except ValueError:
|
||
return cpu_str
|
||
elif cpu_str.endswith("m"):
|
||
return cpu_str # 已经是 millicores
|
||
|
||
return cpu_str
|
||
|
||
|
||
def _format_memory_usage(memory_str: str) -> str:
|
||
"""将内存使用量从 KiB 转换为 MiB
|
||
|
||
例如: "41416Ki" -> "40.4Mi"
|
||
"""
|
||
if not memory_str or memory_str == "0":
|
||
return "0Mi"
|
||
|
||
if memory_str.endswith("Ki"):
|
||
try:
|
||
kib = float(memory_str[:-2])
|
||
mib = kib / 1024
|
||
if mib < 1:
|
||
return f"{mib:.2f}Mi"
|
||
return f"{mib:.1f}Mi"
|
||
except ValueError:
|
||
return memory_str
|
||
elif memory_str.endswith("Mi"):
|
||
return memory_str # 已经是 MiB
|
||
elif memory_str.endswith("Gi"):
|
||
return memory_str # 已经是 GiB
|
||
|
||
return memory_str
|
||
|
||
|
||
def _get_role(principal: dict) -> str:
|
||
"""从principal获取角色"""
|
||
return principal.get("claims", {}).get("role", "")
|
||
|
||
|
||
def _verify_permission(principal: dict, permission: str):
|
||
"""验证是否拥有指定权限"""
|
||
role = _get_role(principal)
|
||
if not has_permission(role, permission):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail=f"需要权限: {permission}"
|
||
)
|
||
|
||
|
||
def _verify_super_admin_permission(principal: dict):
|
||
"""验证超级管理员权限(仅 super_admin 可管理管理员)"""
|
||
role = _get_role(principal)
|
||
if role != "super_admin":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="需要超级管理员权限"
|
||
)
|
||
|
||
|
||
def _verify_write_permission(principal: dict):
|
||
"""验证写入权限(使用权限系统)
|
||
|
||
检查是否有任何 manage:* 权限,包括:
|
||
- super_admin: 拥有所有权限
|
||
- billing_admin: 拥有 manage:tenants, manage:resources, manage:billing 等
|
||
- channel_admin: 拥有 manage:tenants, manage:resources, manage:billing 等
|
||
"""
|
||
role = _get_role(principal)
|
||
# super_admin 拥有所有权限
|
||
if role == "super_admin":
|
||
return
|
||
|
||
# 检查是否有任何 manage:* 权限
|
||
from app.permissions import ROLE_PERMISSIONS
|
||
role_perms = ROLE_PERMISSIONS.get(role, [])
|
||
has_manage_permission = any(perm.startswith("manage:") for perm in role_perms)
|
||
|
||
if not has_manage_permission:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="需要写入权限(manage:* 权限)"
|
||
)
|
||
|
||
|
||
def _verify_read_permission(principal: dict):
|
||
"""验证读取权限(使用权限系统)
|
||
|
||
检查是否有任何 view:* 权限,包括:
|
||
- super_admin: 拥有所有权限
|
||
- billing_admin: 拥有 view:overview, view:tenants 等
|
||
- operations_admin: 拥有 view:overview, view:tenants 等
|
||
- channel_admin: 拥有 view:overview, view:tenants 等
|
||
"""
|
||
role = _get_role(principal)
|
||
# super_admin 拥有所有权限
|
||
if role == "super_admin":
|
||
return
|
||
|
||
# 检查是否有任何 view:* 权限
|
||
from app.permissions import ROLE_PERMISSIONS
|
||
role_perms = ROLE_PERMISSIONS.get(role, [])
|
||
has_view_permission = any(perm.startswith("view:") for perm in role_perms)
|
||
|
||
if not has_view_permission:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="需要管理员权限(view:* 权限)"
|
||
)
|
||
|
||
|
||
def _get_channel_id(principal: dict) -> Optional[uuid.UUID]:
|
||
"""获取当前用户的渠道ID(如果是渠道下的管理员)"""
|
||
role = _get_role(principal)
|
||
if role in ["billing_admin", "operations_admin", "channel_admin"]:
|
||
channel_id_str = principal.get("claims", {}).get("channelId")
|
||
if channel_id_str:
|
||
try:
|
||
return uuid.UUID(channel_id_str)
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _filter_by_channel(query, principal: dict, channel_id_column):
|
||
"""根据用户角色过滤查询,渠道下的管理员只能看到自己渠道的数据"""
|
||
role = _get_role(principal)
|
||
if role in ["billing_admin", "operations_admin"]:
|
||
channel_id = _get_channel_id(principal)
|
||
if channel_id:
|
||
return query.where(channel_id_column == channel_id)
|
||
return query
|
||
|
||
|
||
# ============= 管理员管理 =============
|
||
|
||
@router.get("/admins", response_model=SuccessResponse)
|
||
async def list_admins(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取管理员列表(仅超级管理员可用)
|
||
仅返回活跃状态的管理员
|
||
"""
|
||
_verify_super_admin_permission(principal)
|
||
|
||
# 查询所有活跃的管理员角色用户
|
||
result = await db.execute(
|
||
select(User).where(
|
||
User.role.in_(["billing_admin", "operations_admin"]),
|
||
User.status == "active" # 仅返回活跃管理员
|
||
)
|
||
)
|
||
admins = result.scalars().all()
|
||
|
||
data = [
|
||
{
|
||
"id": str(admin.id),
|
||
"name": admin.name or admin.full_name,
|
||
"email": admin.email,
|
||
"role": admin.role,
|
||
"status": admin.status,
|
||
"createdAt": admin.created_at.isoformat() if admin.created_at else None,
|
||
}
|
||
for admin in admins
|
||
]
|
||
|
||
return SuccessResponse(data={"admins": data})
|
||
|
||
|
||
@router.post("/admins/create", response_model=SuccessResponse)
|
||
async def create_admin(
|
||
req: CreateAdminRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
创建管理员(超级管理员可用)
|
||
|
||
可创建的角色:
|
||
- billing_admin: 计费管理员(渠道下的计费管理员,完整写入权限,可管理该渠道下的租户、计费操作)
|
||
- operations_admin: 运维管理员(渠道下的运维管理员,只读权限,仅查看和监控该渠道的数据)
|
||
|
||
注意:
|
||
- 超级管理员创建管理员时,如果提供了channel_id,则创建该渠道下的管理员
|
||
- 如果不提供channel_id,则创建全局管理员(不推荐,建议所有管理员都关联到渠道)
|
||
"""
|
||
role = _get_role(principal)
|
||
|
||
# 只有超级管理员可以创建管理员
|
||
if role != "super_admin":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="需要超级管理员权限"
|
||
)
|
||
|
||
# 检查邮箱是否已存在
|
||
result = await db.execute(
|
||
select(User).where(User.email == req.email)
|
||
)
|
||
if result.scalar_one_or_none():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="邮箱已被使用"
|
||
)
|
||
|
||
# 如果提供了channel_id,验证渠道存在
|
||
channel_id = None
|
||
if req.channelId:
|
||
channel_id = uuid.UUID(req.channelId)
|
||
result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_id)
|
||
)
|
||
channel = result.scalar_one_or_none()
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 创建管理员用户前,记录当前用户数量(用于安全检查)
|
||
user_count_before = await db.execute(select(User))
|
||
user_count_before = len(user_count_before.scalars().all())
|
||
logger.info(f"创建管理员前,当前用户数量: {user_count_before}")
|
||
|
||
# 创建管理员用户
|
||
password_hash = get_password_hash(req.password)
|
||
admin = User(
|
||
name=req.name,
|
||
email=req.email,
|
||
password_hash=password_hash,
|
||
hashed_password=password_hash, # 兼容
|
||
username=req.email.split("@")[0],
|
||
full_name=req.name,
|
||
role=req.role,
|
||
channel_id=channel_id, # 关联到渠道
|
||
status="active",
|
||
credit_limit=0,
|
||
)
|
||
|
||
db.add(admin)
|
||
await db.commit()
|
||
await db.refresh(admin)
|
||
|
||
# 创建后验证用户数量(安全检查)
|
||
user_count_after = await db.execute(select(User))
|
||
user_count_after = len(user_count_after.scalars().all())
|
||
logger.info(f"创建管理员后,当前用户数量: {user_count_after}")
|
||
|
||
# 如果用户数量异常减少,记录警告
|
||
if user_count_after < user_count_before:
|
||
logger.error(
|
||
f"⚠️ 警告:创建管理员后用户数量异常减少!"
|
||
f"创建前: {user_count_before}, 创建后: {user_count_after}, 减少: {user_count_before - user_count_after}"
|
||
)
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"id": str(admin.id),
|
||
"name": admin.name,
|
||
"email": admin.email,
|
||
"role": admin.role,
|
||
"channelId": str(channel_id) if channel_id else None,
|
||
},
|
||
message=f"管理员创建成功"
|
||
)
|
||
|
||
|
||
@router.delete("/admins/{admin_id}", response_model=SuccessResponse)
|
||
async def delete_admin(
|
||
admin_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
删除管理员(仅超级管理员可用,软删除)
|
||
"""
|
||
_verify_super_admin_permission(principal)
|
||
|
||
# 验证管理员存在
|
||
result = await db.execute(
|
||
select(User).where(User.id == admin_id)
|
||
)
|
||
admin = result.scalar_one_or_none()
|
||
|
||
if not admin:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="管理员不存在"
|
||
)
|
||
|
||
# 检查是否是管理员角色
|
||
if admin.role not in ["billing_admin", "operations_admin"]:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="只能删除管理员角色的用户"
|
||
)
|
||
|
||
# 软删除:标记为不活跃
|
||
admin.status = "inactive"
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
data={"id": str(admin.id)},
|
||
message="管理员已删除"
|
||
)
|
||
|
||
|
||
# ============= 概览 =============
|
||
|
||
@router.get("/dashboard/recent-logins", response_model=SuccessResponse)
|
||
async def get_recent_logins(
|
||
limit: int = Query(10, ge=1, le=50, description="返回数量,最多50条"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取最近登录的租户(所有管理员可查看)
|
||
|
||
返回最近登录的租户列表,按登录时间倒序排列
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 查询最近登录的租户(role='user')
|
||
tenants_result = await db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.role == "user",
|
||
User.last_login_at.isnot(None)
|
||
)
|
||
)
|
||
.order_by(desc(User.last_login_at))
|
||
.limit(limit)
|
||
)
|
||
tenants = tenants_result.scalars().all()
|
||
|
||
# 获取渠道名称映射(用于租户数据)
|
||
channel_ids = list(set([tenant.channel_id for tenant in tenants if tenant.channel_id]))
|
||
channel_map = {}
|
||
if channel_ids:
|
||
channels_info = await db.execute(
|
||
select(Channel).where(Channel.id.in_(channel_ids))
|
||
)
|
||
for ch in channels_info.scalars():
|
||
channel_map[ch.id] = ch.name
|
||
|
||
# 构建租户数据
|
||
recent_tenants = [
|
||
{
|
||
"id": str(tenant.id),
|
||
"name": tenant.name,
|
||
"email": tenant.email,
|
||
"channelId": str(tenant.channel_id) if tenant.channel_id else None,
|
||
"channelName": channel_map.get(tenant.channel_id, "未知渠道") if tenant.channel_id else None,
|
||
"lastLoginAt": tenant.last_login_at.isoformat() if tenant.last_login_at else None,
|
||
"status": tenant.status,
|
||
}
|
||
for tenant in tenants
|
||
]
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"recentTenants": recent_tenants
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/dashboard/stats", response_model=SuccessResponse)
|
||
async def get_admin_dashboard_stats(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取平台全局统计(所有管理员可查看)
|
||
|
||
返回数据包括:
|
||
- 渠道总数、租户总数
|
||
- Agent 总数(平台端 + 自定义)
|
||
- 平台端 Agent 数量和资源使用
|
||
- 自定义 Agent 数量和资源使用
|
||
- 总调用次数、总收入
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 渠道总数
|
||
channels_count = await db.execute(select(func.count(Channel.id)))
|
||
total_channels = channels_count.scalar() or 0
|
||
|
||
# 租户总数
|
||
tenants_count = await db.execute(
|
||
select(func.count(User.id)).where(User.role == "user")
|
||
)
|
||
total_tenants = tenants_count.scalar() or 0
|
||
|
||
# ========== Agent 统计(平台端 + 自定义)==========
|
||
|
||
# 平台端 Agent 统计
|
||
platform_agents_result = await db.execute(
|
||
select(Agent).where(
|
||
and_(
|
||
Agent.type == "platform",
|
||
Agent.status != "inactive"
|
||
)
|
||
)
|
||
)
|
||
platform_agents = platform_agents_result.scalars().all()
|
||
platform_agents_count = len(platform_agents)
|
||
platform_cpu = sum(float(agent.cpu or 0) for agent in platform_agents)
|
||
platform_memory = sum(float(agent.memory or 0) for agent in platform_agents)
|
||
|
||
# 自定义 Agent 统计
|
||
custom_agents_result = await db.execute(
|
||
select(Agent).where(
|
||
and_(
|
||
Agent.type == "custom",
|
||
Agent.status != "inactive"
|
||
)
|
||
)
|
||
)
|
||
custom_agents = custom_agents_result.scalars().all()
|
||
custom_agents_count = len(custom_agents)
|
||
custom_cpu = sum(float(agent.cpu or 0) for agent in custom_agents)
|
||
custom_memory = sum(float(agent.memory or 0) for agent in custom_agents)
|
||
|
||
# 总 Agent 数
|
||
total_agents = platform_agents_count + custom_agents_count
|
||
|
||
# 总调用次数 - 从 AgentBillingRecord 和 ModelBillingRecord 统计
|
||
agent_calls_count = await db.execute(select(func.count(AgentBillingRecord.id)))
|
||
agent_calls = agent_calls_count.scalar() or 0
|
||
|
||
model_calls_count = await db.execute(select(func.count(ModelBillingRecord.id)))
|
||
model_calls = model_calls_count.scalar() or 0
|
||
|
||
total_calls = agent_calls + model_calls
|
||
|
||
# 总收入 - 从 AgentBillingRecord 和 ModelBillingRecord 统计
|
||
# AgentBillingRecord.cost 是 Agent 使用费用
|
||
agent_revenue = await db.execute(select(func.sum(AgentBillingRecord.cost)))
|
||
agent_total = float(agent_revenue.scalar() or 0)
|
||
|
||
# ModelBillingRecord.total_cost 是模型调用费用
|
||
model_revenue = await db.execute(select(func.sum(ModelBillingRecord.total_cost)))
|
||
model_total = float(model_revenue.scalar() or 0)
|
||
|
||
total_revenue = agent_total + model_total
|
||
|
||
# 从 Agent Manager 获取 K8s 中实际运行的平台端 Agent 资源统计
|
||
k8s_agents_count = 0
|
||
k8s_total_cpu = 0.0
|
||
k8s_total_memory = 0.0
|
||
|
||
try:
|
||
from app.agent_manager_client import get_agent_manager_client, AgentManagerError
|
||
client = get_agent_manager_client()
|
||
|
||
# 获取所有运行中的 Agent
|
||
k8s_agents_result = await client.list_agents()
|
||
k8s_agents = k8s_agents_result.agents # 从 AgentListResult 中获取 agents 列表
|
||
k8s_agents_count = len(k8s_agents)
|
||
|
||
# 获取每个 Agent 的资源配置
|
||
for agent in k8s_agents:
|
||
agent_name = agent.get("name")
|
||
if agent_name:
|
||
try:
|
||
metrics = await client.get_agent_metrics(agent_name)
|
||
# 使用新的属性访问器获取资源使用量
|
||
# cpu_usage_millicores 返回毫核(如 50m -> 50.0)
|
||
k8s_total_cpu += metrics.cpu_usage_millicores / 1000 # 转换为核
|
||
# memory_usage_mb 返回 MB
|
||
k8s_total_memory += metrics.memory_usage_mb / 1024 # 转换为 GB
|
||
except Exception as e:
|
||
logger.warning(f"获取 Agent {agent_name} 资源指标失败: {e}")
|
||
|
||
# 如果 K8s 数据可用,使用 K8s 数据更新平台端统计
|
||
if k8s_agents_count > 0:
|
||
platform_agents_count = k8s_agents_count
|
||
platform_cpu = k8s_total_cpu
|
||
platform_memory = k8s_total_memory
|
||
|
||
except Exception as e:
|
||
logger.warning(f"连接 Agent Manager 失败,使用数据库统计: {e}")
|
||
|
||
# 计算总资源使用(平台端 + 自定义)
|
||
total_cpu = platform_cpu + custom_cpu
|
||
total_memory = platform_memory + custom_memory
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"totalChannels": total_channels,
|
||
"totalTenants": total_tenants,
|
||
"totalAgents": total_agents,
|
||
"totalCalls": total_calls,
|
||
"totalRevenue": round(total_revenue, 2),
|
||
# 总资源分配(平台端 + 自定义)
|
||
"totalAllocatedCpu": round(total_cpu, 2),
|
||
"totalAllocatedMemory": round(total_memory, 2),
|
||
# 平台端 Agent 统计
|
||
"platformAgents": {
|
||
"count": platform_agents_count,
|
||
"cpu": round(platform_cpu, 2),
|
||
"memory": round(platform_memory, 2),
|
||
},
|
||
# 自定义 Agent 统计
|
||
"customAgents": {
|
||
"count": custom_agents_count,
|
||
"cpu": round(custom_cpu, 2),
|
||
"memory": round(custom_memory, 2),
|
||
},
|
||
}
|
||
)
|
||
|
||
|
||
# ============= 渠道管理 =============
|
||
|
||
@router.get("/tenants", response_model=SuccessResponse)
|
||
async def get_admin_tenants(
|
||
channel_id: str = Query(..., description="渠道ID(必填)"),
|
||
status: Optional[str] = Query(None, pattern="^(active|inactive|suspended)$", description="筛选状态"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取租户列表
|
||
|
||
Args:
|
||
channel_id: 必填,指定渠道的租户
|
||
status: 可选,筛选租户状态
|
||
|
||
注意:
|
||
- 所有管理员都必须指定渠道ID才能查看租户
|
||
- 超级管理员可以指定任意渠道
|
||
- 其他管理员只能查看自己所属渠道的租户
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
role = _get_role(principal)
|
||
|
||
# 验证渠道ID
|
||
try:
|
||
target_channel_id = uuid.UUID(channel_id)
|
||
except ValueError:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="无效的渠道ID格式"
|
||
)
|
||
|
||
# 验证渠道存在
|
||
channel_result = await db.execute(
|
||
select(Channel).where(Channel.id == target_channel_id)
|
||
)
|
||
channel = channel_result.scalar_one_or_none()
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 非超级管理员只能查看自己渠道的租户
|
||
if role != "super_admin":
|
||
user_channel_id = _get_channel_id(principal)
|
||
if not user_channel_id or user_channel_id != target_channel_id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="只能查看自己渠道的租户"
|
||
)
|
||
|
||
# 构建查询
|
||
query = select(User).where(
|
||
and_(
|
||
User.role == "user",
|
||
User.channel_id == target_channel_id
|
||
)
|
||
)
|
||
|
||
# 状态筛选
|
||
if status:
|
||
query = query.where(User.status == status)
|
||
|
||
result = await db.execute(query)
|
||
tenants = result.scalars().all()
|
||
|
||
# 获取所有租户的余额(从 Balance 表)
|
||
tenant_ids = [tenant.id for tenant in tenants]
|
||
balance_result = await db.execute(
|
||
select(Balance).where(Balance.user_id.in_(tenant_ids))
|
||
)
|
||
balances = {b.user_id: float(b.eu_balance) for b in balance_result.scalars().all()}
|
||
|
||
data = [
|
||
{
|
||
"id": str(tenant.id),
|
||
"name": tenant.name,
|
||
"email": tenant.email,
|
||
"channelId": str(tenant.channel_id) if tenant.channel_id else None,
|
||
"channelName": channel.name,
|
||
"subscriptionTier": tenant.subscription_tier,
|
||
"balance": balances.get(tenant.id, 0.0), # 从 Balance 表获取余额
|
||
"creditLimit": float(tenant.credit_limit or 0),
|
||
"status": tenant.status,
|
||
"permissions": tenant.permissions if hasattr(tenant, 'permissions') else [],
|
||
"createdAt": tenant.created_at.isoformat() if tenant.created_at else None,
|
||
}
|
||
for tenant in tenants
|
||
]
|
||
|
||
return SuccessResponse(data={"tenants": data, "total": len(data), "channelId": channel_id, "channelName": channel.name})
|
||
|
||
|
||
@router.get("/channels", response_model=SuccessResponse)
|
||
async def list_channels(
|
||
include_inactive: bool = Query(False, description="是否包含已删除(inactive)的渠道"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取渠道列表(所有管理员可查看)
|
||
|
||
Args:
|
||
include_inactive: 是否包含已删除的渠道,默认False只返回活跃的
|
||
|
||
注意:
|
||
- 超级管理员可以看到所有渠道
|
||
- 计费管理员和运维管理员只能看到自己关联的渠道
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 构建查询
|
||
query = select(Channel)
|
||
|
||
# 如果不是超级管理员,只返回自己渠道的数据
|
||
role = _get_role(principal)
|
||
if role in ["billing_admin", "operations_admin"]:
|
||
channel_id = _get_channel_id(principal)
|
||
if channel_id:
|
||
query = query.where(Channel.id == channel_id)
|
||
else:
|
||
# 如果没有channel_id,返回空列表
|
||
query = query.where(Channel.id == None)
|
||
|
||
# 默认只返回活跃的渠道,除非明确请求包含已删除的
|
||
if not include_inactive:
|
||
query = query.where(Channel.status != "inactive")
|
||
|
||
result = await db.execute(query)
|
||
channels = result.scalars().all()
|
||
|
||
data = []
|
||
for channel in channels:
|
||
# 统计该渠道下的租户数量
|
||
tenant_count_result = await db.execute(
|
||
select(func.count(User.id)).where(
|
||
and_(
|
||
User.channel_id == channel.id,
|
||
User.role == "user"
|
||
)
|
||
)
|
||
)
|
||
tenant_count = tenant_count_result.scalar() or 0
|
||
|
||
# 统计该渠道分配的资源(CPU和内存)
|
||
# 查询该渠道的Agent资源分配
|
||
allocations_result = await db.execute(
|
||
select(ResourceAllocation).where(
|
||
and_(
|
||
ResourceAllocation.target_id == channel.id,
|
||
ResourceAllocation.target_type == "channel",
|
||
ResourceAllocation.resource_type == "agent"
|
||
)
|
||
)
|
||
)
|
||
allocations = allocations_result.scalars().all()
|
||
|
||
# 累加该渠道分配的Agent的CPU和内存
|
||
total_cpu = 0.0
|
||
total_memory = 0.0
|
||
|
||
for alloc in allocations:
|
||
# 查询Agent的资源配置
|
||
# resource_id 可能是 UUID 字符串或模板名称(如 'code-reviewer')
|
||
agent = None
|
||
|
||
# 首先尝试按 UUID 查询
|
||
try:
|
||
resource_uuid = uuid.UUID(alloc.resource_id)
|
||
agent_result = await db.execute(
|
||
select(Agent).where(Agent.id == resource_uuid)
|
||
)
|
||
agent = agent_result.scalar_one_or_none()
|
||
except (ValueError, TypeError):
|
||
# 如果不是有效的 UUID,按名称查询
|
||
pass
|
||
|
||
# 如果按 UUID 没找到,尝试按名称查询
|
||
if not agent:
|
||
agent_result = await db.execute(
|
||
select(Agent).where(Agent.name == alloc.resource_id)
|
||
)
|
||
agent = agent_result.scalar_one_or_none()
|
||
|
||
# 如果仍然没找到,检查是否是平台 Agent 模板
|
||
if not agent and alloc.resource_id in TEMPLATE_RESOURCE_CONFIG:
|
||
resource_config = TEMPLATE_RESOURCE_CONFIG[alloc.resource_id]
|
||
# 使用模板的默认资源配置
|
||
quantity = alloc.quantity or 1
|
||
# 解析 CPU (如 "500m" -> 0.5 核)
|
||
cpu_limit = resource_config.get("cpuLimit", "0")
|
||
if isinstance(cpu_limit, str) and cpu_limit.endswith("m"):
|
||
total_cpu += float(cpu_limit[:-1]) / 1000 * quantity
|
||
elif cpu_limit:
|
||
try:
|
||
total_cpu += float(cpu_limit) * quantity
|
||
except ValueError:
|
||
pass
|
||
# 解析内存 (如 "512Mi" -> 0.5 GB)
|
||
memory_limit = resource_config.get("memoryLimit", "0")
|
||
if isinstance(memory_limit, str):
|
||
if memory_limit.endswith("Mi"):
|
||
total_memory += float(memory_limit[:-2]) / 1024 * quantity
|
||
elif memory_limit.endswith("Gi"):
|
||
total_memory += float(memory_limit[:-2]) * quantity
|
||
continue
|
||
|
||
if agent:
|
||
quantity = alloc.quantity or 1
|
||
total_cpu += float(agent.cpu or 0) * quantity
|
||
total_memory += float(agent.memory or 0) * quantity
|
||
|
||
data.append({
|
||
"id": str(channel.id),
|
||
"name": channel.name,
|
||
"email": channel.email,
|
||
"commissionRate": float(channel.commission_rate) if channel.commission_rate is not None else 0.0,
|
||
"channelCredit": float(channel.channel_credit) if channel.channel_credit is not None else 0.0,
|
||
"customAgentCpu": float(channel.custom_agent_cpu) if channel.custom_agent_cpu is not None else 2.0,
|
||
"customAgentMemory": float(channel.custom_agent_memory) if channel.custom_agent_memory is not None else 4.0,
|
||
"status": channel.status,
|
||
"createdAt": channel.created_at.isoformat(),
|
||
"tenantCount": tenant_count, # 新增:渠道下租户总数
|
||
"totalAllocatedCpu": round(total_cpu, 2), # 新增:分配的总CPU
|
||
"totalAllocatedMemory": round(total_memory, 2), # 新增:分配的总内存
|
||
})
|
||
|
||
return SuccessResponse(data={"channels": data})
|
||
|
||
|
||
@router.post("/channels/create", response_model=SuccessResponse)
|
||
async def create_channel(
|
||
req: CreateChannelRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
创建渠道(super_admin 和 billing_admin 可用)
|
||
|
||
同时在 LiteLLM 中创建对应的 team,用于管理该渠道下租户的模型访问权限。
|
||
|
||
注意:如果 LiteLLM team 创建失败,渠道创建也会失败(事务回滚)。
|
||
"""
|
||
_verify_write_permission(principal)
|
||
|
||
# 检查邮箱是否已存在
|
||
result = await db.execute(
|
||
select(Channel).where(Channel.email == req.email)
|
||
)
|
||
if result.scalar_one_or_none():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="邮箱已被使用"
|
||
)
|
||
|
||
# 先在 LiteLLM 中创建 team(在创建渠道之前)
|
||
# 这样如果 LiteLLM 失败,渠道不会被创建
|
||
litellm_team_id = None
|
||
try:
|
||
from app.litellm_client import get_litellm_client, LiteLLMClientError
|
||
litellm_client = get_litellm_client()
|
||
|
||
# 生成临时 channel_id 用于 team_alias(实际 ID 在创建渠道后才有)
|
||
temp_channel_id = uuid.uuid4()
|
||
|
||
team = await litellm_client.create_team(
|
||
team_alias=f"channel-{temp_channel_id}",
|
||
metadata={
|
||
"channel_name": req.name,
|
||
"channel_email": req.email,
|
||
"temp_channel_id": str(temp_channel_id),
|
||
}
|
||
)
|
||
|
||
litellm_team_id = team.team_id
|
||
logger.info(f"LiteLLM team 创建成功: {team.team_id}")
|
||
|
||
except LiteLLMClientError as e:
|
||
# LiteLLM 创建失败,渠道创建也失败
|
||
logger.error(f"创建渠道 {req.name} 时 LiteLLM team 创建失败: {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"LiteLLM team 创建失败,渠道创建已取消: {str(e)}"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"创建渠道 {req.name} 时 LiteLLM 连接失败: {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail=f"无法连接 LiteLLM Gateway,渠道创建已取消: {str(e)}"
|
||
)
|
||
|
||
# LiteLLM team 创建成功后,创建渠道
|
||
password_hash = get_password_hash(req.password)
|
||
channel = Channel(
|
||
name=req.name,
|
||
email=req.email,
|
||
password_hash=password_hash,
|
||
commission_rate=req.commissionRate,
|
||
channel_credit=0,
|
||
custom_agent_cpu=2,
|
||
custom_agent_memory=4,
|
||
status="active",
|
||
litellm_team_id=litellm_team_id, # 直接设置 LiteLLM team_id
|
||
)
|
||
|
||
try:
|
||
db.add(channel)
|
||
await db.commit()
|
||
await db.refresh(channel)
|
||
|
||
# 更新 LiteLLM team 的 metadata,添加实际的 channel_id
|
||
try:
|
||
await litellm_client.update_team(
|
||
team_id=litellm_team_id,
|
||
metadata={
|
||
"channel_id": str(channel.id),
|
||
"channel_name": channel.name,
|
||
"channel_email": channel.email,
|
||
}
|
||
)
|
||
except Exception as e:
|
||
# 更新 metadata 失败不影响渠道创建
|
||
logger.warning(f"更新 LiteLLM team metadata 失败: {e}")
|
||
|
||
logger.info(f"渠道 {channel.name} 创建成功,LiteLLM team_id: {litellm_team_id}")
|
||
|
||
except Exception as e:
|
||
# 渠道创建失败,需要清理已创建的 LiteLLM team
|
||
logger.error(f"渠道创建失败,正在清理 LiteLLM team: {e}")
|
||
try:
|
||
await litellm_client.delete_team(litellm_team_id)
|
||
logger.info(f"已清理 LiteLLM team: {litellm_team_id}")
|
||
except Exception as cleanup_error:
|
||
logger.error(f"清理 LiteLLM team 失败: {cleanup_error}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"渠道创建失败: {str(e)}"
|
||
)
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"id": str(channel.id),
|
||
"name": channel.name,
|
||
"email": channel.email,
|
||
"litellmTeamId": litellm_team_id,
|
||
},
|
||
message="渠道创建成功"
|
||
)
|
||
|
||
|
||
@router.put("/channels/{channel_id}", response_model=SuccessResponse)
|
||
async def update_channel(
|
||
channel_id: str,
|
||
req: UpdateChannelRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
更新渠道信息(super_admin 和 billing_admin 可用)
|
||
"""
|
||
_verify_write_permission(principal)
|
||
|
||
# 验证渠道存在
|
||
result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_id)
|
||
)
|
||
channel = result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 如果更新邮箱,检查邮箱是否已被其他渠道使用
|
||
if req.email and req.email != channel.email:
|
||
email_check = await db.execute(
|
||
select(Channel).where(
|
||
and_(
|
||
Channel.email == req.email,
|
||
Channel.id != channel_id
|
||
)
|
||
)
|
||
)
|
||
if email_check.scalar_one_or_none():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="邮箱已被其他渠道使用"
|
||
)
|
||
channel.email = req.email
|
||
|
||
# 更新其他字段
|
||
if req.name is not None:
|
||
channel.name = req.name
|
||
if req.commissionRate is not None:
|
||
channel.commission_rate = req.commissionRate
|
||
if req.status is not None:
|
||
channel.status = req.status
|
||
|
||
await db.commit()
|
||
await db.refresh(channel)
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"id": str(channel.id),
|
||
"name": channel.name,
|
||
"email": channel.email,
|
||
"commissionRate": float(channel.commission_rate) if channel.commission_rate is not None else 0.0,
|
||
"status": channel.status,
|
||
},
|
||
message="渠道信息更新成功"
|
||
)
|
||
|
||
|
||
@router.delete("/channels/{channel_id}", response_model=SuccessResponse)
|
||
async def delete_channel(
|
||
channel_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
删除渠道(软删除,super_admin 和 billing_admin 可用)
|
||
|
||
同时删除 LiteLLM 中对应的 team。
|
||
"""
|
||
_verify_write_permission(principal)
|
||
|
||
# 验证渠道存在
|
||
result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_id)
|
||
)
|
||
channel = result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 检查是否有关联的租户
|
||
tenants_result = await db.execute(
|
||
select(func.count(User.id)).where(
|
||
and_(
|
||
User.channel_id == channel_id,
|
||
User.role == "user",
|
||
User.status == "active"
|
||
)
|
||
)
|
||
)
|
||
tenant_count = tenants_result.scalar() or 0
|
||
|
||
if tenant_count > 0:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"渠道下有 {tenant_count} 个活跃租户,无法删除。请先移除或停用所有租户。"
|
||
)
|
||
|
||
# 删除 LiteLLM 中对应的 team
|
||
litellm_error = None
|
||
if channel.litellm_team_id:
|
||
try:
|
||
from app.litellm_client import get_litellm_client, LiteLLMClientError
|
||
litellm_client = get_litellm_client()
|
||
|
||
await litellm_client.delete_team(channel.litellm_team_id)
|
||
logger.info(f"渠道 {channel.name} 的 LiteLLM team {channel.litellm_team_id} 已删除")
|
||
|
||
except LiteLLMClientError as e:
|
||
litellm_error = str(e)
|
||
logger.warning(f"删除渠道 {channel.name} 时 LiteLLM team 删除失败: {e}")
|
||
except Exception as e:
|
||
litellm_error = str(e)
|
||
logger.warning(f"删除渠道 {channel.name} 时 LiteLLM 连接失败: {e}")
|
||
|
||
# 软删除:标记为不活跃
|
||
channel.status = "inactive"
|
||
await db.commit()
|
||
|
||
response_data = {"id": str(channel.id)}
|
||
if litellm_error:
|
||
response_data["litellmWarning"] = f"LiteLLM team 删除失败: {litellm_error}"
|
||
|
||
return SuccessResponse(
|
||
data=response_data,
|
||
message="渠道已删除"
|
||
)
|
||
|
||
|
||
@router.get("/channels/{channel_id}/resources", response_model=SuccessResponse)
|
||
async def get_channel_resources(
|
||
channel_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取渠道资源配置(所有管理员可查看)
|
||
|
||
返回该渠道已分配的模型供应商、Agent配额、自定义Agent资源和授信额度
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 验证渠道
|
||
channel_uuid = uuid.UUID(channel_id)
|
||
result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_uuid)
|
||
)
|
||
channel = result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 查询资源分配
|
||
allocations_result = await db.execute(
|
||
select(ResourceAllocation).where(
|
||
and_(
|
||
ResourceAllocation.target_id == channel_uuid,
|
||
ResourceAllocation.target_type == "channel"
|
||
)
|
||
)
|
||
)
|
||
allocations = allocations_result.scalars().all()
|
||
|
||
# 分类整理资源
|
||
models = []
|
||
agents = []
|
||
|
||
for alloc in allocations:
|
||
if alloc.resource_type == "model":
|
||
models.append(str(alloc.resource_id))
|
||
elif alloc.resource_type == "agent":
|
||
agents.append({
|
||
"agentId": str(alloc.resource_id),
|
||
"quantity": alloc.quantity or 1
|
||
})
|
||
|
||
# 构建响应
|
||
custom_agent_resources = None
|
||
if channel.custom_agent_cpu or channel.custom_agent_memory:
|
||
custom_agent_resources = {
|
||
"cpu": float(channel.custom_agent_cpu or 0),
|
||
"memory": float(channel.custom_agent_memory or 0)
|
||
}
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"models": models,
|
||
"agents": agents,
|
||
"customAgentResources": custom_agent_resources,
|
||
"channelCredit": float(channel.channel_credit or 0),
|
||
"commissionRate": float(channel.commission_rate or 0) if hasattr(channel, 'commission_rate') else None
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/channels/{channel_id}/allocated-resources", response_model=SuccessResponse)
|
||
async def get_channel_allocated_resources(
|
||
channel_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取渠道资源分配详情(所有管理员可查看)
|
||
|
||
返回该渠道被分配的所有资源详情,包括:
|
||
- 自定义Agent配额(CPU、内存配额及使用情况)
|
||
- 平台Agent配额(每个模板的Pod配额及使用情况)
|
||
- 模型供应商(已分配的供应商列表及其模型)
|
||
- 模型详情(每个模型的RPM/TPM限制)
|
||
- 汇总统计
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 验证渠道ID格式
|
||
try:
|
||
channel_uuid = uuid.UUID(channel_id)
|
||
except (ValueError, TypeError):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="无效的渠道ID格式"
|
||
)
|
||
|
||
# 查询渠道基本信息
|
||
result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_uuid)
|
||
)
|
||
channel = result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# ========== 1. 查询自定义Agent配额 ==========
|
||
custom_agent_quota = None
|
||
channel_quota_result = await db.execute(
|
||
select(ChannelCustomAgentQuota).where(
|
||
ChannelCustomAgentQuota.channel_id == channel_uuid
|
||
)
|
||
)
|
||
channel_quota = channel_quota_result.scalar_one_or_none()
|
||
|
||
if channel_quota:
|
||
cpu_quota = float(channel_quota.cpu_quota or 0)
|
||
memory_quota = float(channel_quota.memory_quota or 0)
|
||
cpu_allocated = float(channel_quota.cpu_allocated or 0)
|
||
memory_allocated = float(channel_quota.memory_allocated or 0)
|
||
|
||
custom_agent_quota = {
|
||
"cpuQuota": cpu_quota,
|
||
"memoryQuota": memory_quota,
|
||
"cpuAllocatedToTenants": cpu_allocated,
|
||
"memoryAllocatedToTenants": memory_allocated,
|
||
"cpuAvailable": max(0, cpu_quota - cpu_allocated),
|
||
"memoryAvailable": max(0, memory_quota - memory_allocated)
|
||
}
|
||
elif channel.custom_agent_cpu or channel.custom_agent_memory:
|
||
# 兼容旧数据:使用 Channel 表中的字段
|
||
cpu_quota = float(channel.custom_agent_cpu or 0)
|
||
memory_quota = float(channel.custom_agent_memory or 0)
|
||
custom_agent_quota = {
|
||
"cpuQuota": cpu_quota,
|
||
"memoryQuota": memory_quota,
|
||
"cpuAllocatedToTenants": 0,
|
||
"memoryAllocatedToTenants": 0,
|
||
"cpuAvailable": cpu_quota,
|
||
"memoryAvailable": memory_quota
|
||
}
|
||
|
||
# ========== 2. 查询平台Agent配额 ==========
|
||
platform_agents = []
|
||
platform_quota_result = await db.execute(
|
||
select(PlatformAgentQuota).where(
|
||
and_(
|
||
PlatformAgentQuota.target_id == channel_uuid,
|
||
PlatformAgentQuota.target_type == "channel"
|
||
)
|
||
)
|
||
)
|
||
channel_platform_quotas = platform_quota_result.scalars().all()
|
||
|
||
# 查询分配给租户的平台Agent配额(用于计算已分配量)
|
||
# 先获取渠道下的所有租户
|
||
tenants_result = await db.execute(
|
||
select(User.id).where(User.channel_id == channel_uuid)
|
||
)
|
||
tenant_ids = [t[0] for t in tenants_result.all()]
|
||
|
||
# 按模板统计分配给租户的配额
|
||
tenant_quota_by_template = {}
|
||
if tenant_ids:
|
||
tenant_quota_result = await db.execute(
|
||
select(PlatformAgentQuota).where(
|
||
and_(
|
||
PlatformAgentQuota.target_id.in_(tenant_ids),
|
||
PlatformAgentQuota.target_type == "tenant"
|
||
)
|
||
)
|
||
)
|
||
tenant_quotas = tenant_quota_result.scalars().all()
|
||
for tq in tenant_quotas:
|
||
template = tq.template_name
|
||
if template not in tenant_quota_by_template:
|
||
tenant_quota_by_template[template] = {"podAllocated": 0, "podUsed": 0}
|
||
tenant_quota_by_template[template]["podAllocated"] += (tq.pod_quota or 0)
|
||
tenant_quota_by_template[template]["podUsed"] += (tq.pod_used or 0)
|
||
|
||
# 获取模板配置
|
||
template_configs = {}
|
||
config_result = await db.execute(select(PlatformAgentTemplateConfig))
|
||
for config in config_result.scalars().all():
|
||
template_configs[config.template_name] = config
|
||
|
||
total_platform_pod_quota = 0
|
||
total_platform_pod_used = 0
|
||
|
||
for quota in channel_platform_quotas:
|
||
template_name = quota.template_name
|
||
pod_quota = quota.pod_quota or 0
|
||
pod_used = quota.pod_used or 0
|
||
|
||
# 获取分配给租户的数量
|
||
tenant_stats = tenant_quota_by_template.get(template_name, {"podAllocated": 0, "podUsed": 0})
|
||
pod_allocated_to_tenants = tenant_stats["podAllocated"]
|
||
|
||
# 获取模板配置
|
||
config = template_configs.get(template_name)
|
||
cpu_per_pod = config.cpu_request if config else "100m"
|
||
memory_per_pod = config.memory_request if config else "256Mi"
|
||
display_name = config.display_name if config else template_name
|
||
|
||
total_platform_pod_quota += pod_quota
|
||
total_platform_pod_used += pod_used
|
||
|
||
platform_agents.append({
|
||
"templateName": template_name,
|
||
"templateDisplayName": display_name,
|
||
"podQuota": pod_quota,
|
||
"podUsed": pod_used,
|
||
"podAllocatedToTenants": pod_allocated_to_tenants,
|
||
"podAvailable": max(0, pod_quota - pod_allocated_to_tenants),
|
||
"cpuPerPod": cpu_per_pod,
|
||
"memoryPerPod": memory_per_pod,
|
||
"allocatedAt": quota.allocated_at.isoformat() if quota.allocated_at else None
|
||
})
|
||
|
||
# ========== 3. 查询模型供应商和模型 ==========
|
||
# 查询分配给渠道的模型资源
|
||
allocations_result = await db.execute(
|
||
select(ResourceAllocation).where(
|
||
and_(
|
||
ResourceAllocation.target_id == channel_uuid,
|
||
ResourceAllocation.target_type == "channel",
|
||
ResourceAllocation.resource_type == "model"
|
||
)
|
||
)
|
||
)
|
||
allocations = allocations_result.scalars().all()
|
||
|
||
# 收集所有模型名称
|
||
model_names = [str(alloc.resource_id) for alloc in allocations]
|
||
|
||
# 查询所有模型供应商
|
||
providers_result = await db.execute(select(ModelProvider))
|
||
all_providers = providers_result.scalars().all()
|
||
|
||
# 构建供应商->模型映射
|
||
provider_models_map = {} # provider_id -> [models]
|
||
model_to_provider = {} # model_name -> provider_info
|
||
|
||
for provider in all_providers:
|
||
if provider.supported_models:
|
||
for model in provider.supported_models:
|
||
model_to_provider[model] = {
|
||
"providerId": str(provider.id),
|
||
"providerName": provider.name,
|
||
"providerType": provider.provider,
|
||
"status": provider.status or "active",
|
||
"rpm": provider.rpm or 0,
|
||
"tpm": provider.tpm or 0
|
||
}
|
||
|
||
# 统计每个模型分配给租户的数量
|
||
model_allocation_count = {}
|
||
if tenant_ids:
|
||
tenant_model_result = await db.execute(
|
||
select(TenantModelKey.model_name, func.count(TenantModelKey.id).label("count")).where(
|
||
and_(
|
||
TenantModelKey.tenant_id.in_(tenant_ids),
|
||
TenantModelKey.status == "active"
|
||
)
|
||
).group_by(TenantModelKey.model_name)
|
||
)
|
||
for row in tenant_model_result.all():
|
||
model_allocation_count[row[0]] = row[1]
|
||
|
||
# 构建模型供应商列表和模型列表
|
||
model_providers = {} # provider_id -> provider_info with models
|
||
models_list = []
|
||
|
||
for model_name in model_names:
|
||
provider_info = model_to_provider.get(model_name)
|
||
|
||
if provider_info:
|
||
provider_id = provider_info["providerId"]
|
||
if provider_id not in model_providers:
|
||
model_providers[provider_id] = {
|
||
"providerId": provider_id,
|
||
"providerName": provider_info["providerName"],
|
||
"providerType": provider_info["providerType"],
|
||
"status": provider_info["status"],
|
||
"models": []
|
||
}
|
||
|
||
model_providers[provider_id]["models"].append({
|
||
"modelName": model_name,
|
||
"rpmLimit": provider_info["rpm"],
|
||
"tpmLimit": provider_info["tpm"],
|
||
"maxBudget": None,
|
||
"budgetDuration": "monthly"
|
||
})
|
||
|
||
models_list.append({
|
||
"modelName": model_name,
|
||
"providerName": provider_info["providerName"] if provider_info else None,
|
||
"rpmLimit": provider_info["rpm"] if provider_info else 0,
|
||
"tpmLimit": provider_info["tpm"] if provider_info else 0,
|
||
"maxBudget": None,
|
||
"budgetDuration": "monthly",
|
||
"allocatedToTenants": model_allocation_count.get(model_name, 0)
|
||
})
|
||
|
||
# ========== 4. 统计有资源分配的租户数 ==========
|
||
tenants_with_resources = 0
|
||
if tenant_ids:
|
||
# 有自定义Agent配额的租户
|
||
custom_quota_result = await db.execute(
|
||
select(func.count(TenantCustomAgentQuota.id)).where(
|
||
TenantCustomAgentQuota.tenant_id.in_(tenant_ids)
|
||
)
|
||
)
|
||
|
||
# 有平台Agent配额的租户
|
||
platform_quota_count_result = await db.execute(
|
||
select(func.count(func.distinct(PlatformAgentQuota.target_id))).where(
|
||
and_(
|
||
PlatformAgentQuota.target_id.in_(tenant_ids),
|
||
PlatformAgentQuota.target_type == "tenant"
|
||
)
|
||
)
|
||
)
|
||
|
||
# 有模型Key的租户
|
||
model_key_count_result = await db.execute(
|
||
select(func.count(func.distinct(TenantModelKey.tenant_id))).where(
|
||
and_(
|
||
TenantModelKey.tenant_id.in_(tenant_ids),
|
||
TenantModelKey.status == "active"
|
||
)
|
||
)
|
||
)
|
||
|
||
# 取最大值作为有资源的租户数
|
||
tenants_with_resources = max(
|
||
custom_quota_result.scalar() or 0,
|
||
platform_quota_count_result.scalar() or 0,
|
||
model_key_count_result.scalar() or 0
|
||
)
|
||
|
||
# ========== 5. 构建响应 ==========
|
||
response_data = {
|
||
"channelId": str(channel.id),
|
||
"channelName": channel.name,
|
||
"channelEmail": channel.email,
|
||
"channelStatus": channel.status or "active",
|
||
"createdAt": channel.created_at.isoformat() if channel.created_at else None,
|
||
"channelCredit": float(channel.channel_credit or 0),
|
||
"commissionRate": float(channel.commission_rate or 0),
|
||
"customAgentQuota": custom_agent_quota,
|
||
"platformAgents": platform_agents,
|
||
"modelProviders": list(model_providers.values()),
|
||
"models": models_list,
|
||
"summary": {
|
||
"totalCustomAgentCpuQuota": custom_agent_quota["cpuQuota"] if custom_agent_quota else 0,
|
||
"totalCustomAgentMemoryQuota": custom_agent_quota["memoryQuota"] if custom_agent_quota else 0,
|
||
"totalPlatformAgentPodQuota": total_platform_pod_quota,
|
||
"totalPlatformAgentPodUsed": total_platform_pod_used,
|
||
"totalModelProviders": len(model_providers),
|
||
"totalModels": len(models_list),
|
||
"totalTenantsWithResources": tenants_with_resources
|
||
}
|
||
}
|
||
|
||
return SuccessResponse(
|
||
data=response_data,
|
||
message="获取渠道资源分配详情成功"
|
||
)
|
||
|
||
|
||
@router.put("/channels/{channel_id}/resources", response_model=SuccessResponse)
|
||
async def allocate_channel_resources(
|
||
channel_id: str,
|
||
req: ChannelResourceAllocation,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
统一管理渠道资源(super_admin 和 billing_admin 可用)
|
||
|
||
前端传入的 models 可能是:
|
||
1. 供应商 ID(UUID)- 需要查询供应商的 supported_models
|
||
2. 模型名称(如 "taiji/gpt-4o")- 直接使用
|
||
|
||
同时在 LiteLLM 中更新 Team 的 models 列表,使渠道下的租户能够使用这些模型。
|
||
|
||
注意:如果 LiteLLM Team 更新失败,资源分配也会失败(事务回滚)。
|
||
"""
|
||
_verify_write_permission(principal)
|
||
|
||
# 验证渠道
|
||
result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_id)
|
||
)
|
||
channel = result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 检查渠道是否有 LiteLLM Team
|
||
if not channel.litellm_team_id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="渠道没有关联的 LiteLLM Team,请先重新创建渠道或联系管理员"
|
||
)
|
||
|
||
# 解析 models 列表:可能是供应商 ID 或模型名称
|
||
# 收集所有模型名称(用于 LiteLLM)
|
||
all_model_names = []
|
||
provider_ids = [] # 记录供应商 ID(用于本地数据库)
|
||
|
||
for model_id in req.models:
|
||
# 尝试解析为 UUID(供应商 ID)
|
||
try:
|
||
provider_uuid = uuid.UUID(model_id)
|
||
# 查询供应商
|
||
provider_result = await db.execute(
|
||
select(ModelProvider).where(ModelProvider.id == provider_uuid)
|
||
)
|
||
provider = provider_result.scalar_one_or_none()
|
||
|
||
if provider:
|
||
# 是供应商 ID,获取其 supported_models
|
||
provider_ids.append(model_id)
|
||
if provider.supported_models:
|
||
all_model_names.extend(provider.supported_models)
|
||
logger.info(f"供应商 {provider.name} 的模型: {provider.supported_models}")
|
||
else:
|
||
# UUID 格式但不是供应商,当作模型名称处理
|
||
all_model_names.append(model_id)
|
||
except (ValueError, TypeError):
|
||
# 不是 UUID,当作模型名称处理
|
||
all_model_names.append(model_id)
|
||
|
||
# 去重
|
||
all_model_names = list(set(all_model_names))
|
||
|
||
logger.info(f"渠道 {channel.name} 分配模型: 供应商 IDs={provider_ids}, 模型名称={all_model_names}")
|
||
|
||
# 在 LiteLLM 中更新 Team 的 models 列表
|
||
if all_model_names:
|
||
try:
|
||
from app.litellm_client import get_litellm_client, LiteLLMClientError
|
||
litellm_client = get_litellm_client()
|
||
|
||
await litellm_client.update_team(
|
||
team_id=channel.litellm_team_id,
|
||
models=all_model_names,
|
||
metadata={
|
||
"channel_id": str(channel.id),
|
||
"channel_name": channel.name,
|
||
"channel_email": channel.email,
|
||
"models_count": len(all_model_names),
|
||
"provider_ids": provider_ids,
|
||
}
|
||
)
|
||
|
||
logger.info(f"渠道 {channel.name} 的 LiteLLM Team 模型列表已更新: {all_model_names}")
|
||
|
||
except LiteLLMClientError as e:
|
||
logger.error(f"更新渠道 {channel.name} 的 LiteLLM Team 失败: {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"LiteLLM Team 更新失败,资源分配已取消: {str(e)}"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"更新渠道 {channel.name} 的 LiteLLM 连接失败: {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail=f"无法连接 LiteLLM Gateway,资源分配已取消: {str(e)}"
|
||
)
|
||
|
||
# LiteLLM 更新成功后,更新本地数据库
|
||
|
||
# 分配模型资源(追加模式:只添加新模型,保留已有模型)
|
||
# 存储每个模型名称,而不是供应商 ID
|
||
# 这样渠道给租户分配模型时才能正确验证权限
|
||
from sqlalchemy import delete
|
||
for model_name in all_model_names:
|
||
# 检查是否已存在该模型的分配
|
||
existing_model_result = await db.execute(
|
||
select(ResourceAllocation).where(
|
||
and_(
|
||
ResourceAllocation.target_id == channel_id,
|
||
ResourceAllocation.target_type == "channel",
|
||
ResourceAllocation.resource_type == "model",
|
||
ResourceAllocation.resource_id == model_name
|
||
)
|
||
)
|
||
)
|
||
existing_model = existing_model_result.scalar_one_or_none()
|
||
|
||
if not existing_model:
|
||
# 只有不存在时才添加新分配
|
||
allocation = ResourceAllocation(
|
||
target_id=channel_id,
|
||
target_type="channel",
|
||
resource_type="model",
|
||
resource_id=model_name, # 存储模型名称(如 gpt-4o),而不是供应商 ID
|
||
)
|
||
db.add(allocation)
|
||
logger.info(f"追加模型分配给渠道 {channel.name}: model={model_name}")
|
||
else:
|
||
logger.info(f"模型 {model_name} 已分配给渠道 {channel.name},跳过重复分配")
|
||
|
||
# Bug 修复:同时创建 ChannelProviderAccess 记录,确保渠道能看到分配的供应商
|
||
# 追加模式:只添加新的供应商访问权限,保留已有的
|
||
user_id = principal.get("claims", {}).get("user_id") or principal.get("sub")
|
||
for provider_id in provider_ids:
|
||
try:
|
||
provider_uuid = uuid.UUID(provider_id)
|
||
|
||
# 检查是否已存在该供应商的访问权限
|
||
existing_access_result = await db.execute(
|
||
select(ChannelProviderAccess).where(
|
||
and_(
|
||
ChannelProviderAccess.channel_id == channel_id,
|
||
ChannelProviderAccess.provider_id == provider_uuid
|
||
)
|
||
)
|
||
)
|
||
existing_access = existing_access_result.scalar_one_or_none()
|
||
|
||
if existing_access:
|
||
# 已存在,跳过重复分配
|
||
logger.info(f"供应商 {provider_id} 的访问权限已存在于渠道 {channel.name},跳过重复分配")
|
||
continue
|
||
|
||
# 查询供应商信息
|
||
provider_result = await db.execute(
|
||
select(ModelProvider).where(ModelProvider.id == provider_uuid)
|
||
)
|
||
provider = provider_result.scalar_one_or_none()
|
||
|
||
if provider:
|
||
# 创建 ChannelProviderAccess 记录
|
||
access = ChannelProviderAccess(
|
||
channel_id=channel_id,
|
||
provider_id=provider_uuid,
|
||
status="active",
|
||
rpm_limit=provider.rpm,
|
||
tpm_limit=provider.tpm,
|
||
approved_by=uuid.UUID(user_id) if user_id else None,
|
||
approved_at=datetime.utcnow(),
|
||
)
|
||
db.add(access)
|
||
logger.info(f"追加渠道 {channel.name} 的供应商访问权限: {provider.name}")
|
||
except (ValueError, TypeError) as e:
|
||
logger.warning(f"无法为供应商 {provider_id} 创建访问权限: {e}")
|
||
|
||
# 分配Agent资源(追加模式:在现有配额基础上增加)
|
||
# 不再删除现有配额,改为追加
|
||
|
||
user_id_for_allocation = principal.get("claims", {}).get("user_id") or principal.get("sub")
|
||
for agent_alloc in req.agents:
|
||
# 1. 查找现有的平台 Agent 配额
|
||
existing_quota_result = await db.execute(
|
||
select(PlatformAgentQuota).where(
|
||
and_(
|
||
PlatformAgentQuota.target_id == channel_id,
|
||
PlatformAgentQuota.target_type == "channel",
|
||
PlatformAgentQuota.template_name == agent_alloc.agentId
|
||
)
|
||
)
|
||
)
|
||
existing_quota = existing_quota_result.scalar_one_or_none()
|
||
|
||
if existing_quota:
|
||
# 追加模式:在现有配额基础上增加
|
||
existing_quota.pod_quota = (existing_quota.pod_quota or 0) + agent_alloc.quantity
|
||
existing_quota.allocated_at = datetime.utcnow()
|
||
if user_id_for_allocation:
|
||
existing_quota.allocated_by = uuid.UUID(user_id_for_allocation)
|
||
logger.info(
|
||
f"追加平台 Agent 配额给渠道 {channel.name}: "
|
||
f"template={agent_alloc.agentId}, 追加量={agent_alloc.quantity}, 新配额={existing_quota.pod_quota}"
|
||
)
|
||
else:
|
||
# 创建新配额记录
|
||
quota = PlatformAgentQuota(
|
||
target_id=channel_id,
|
||
target_type="channel",
|
||
template_name=agent_alloc.agentId, # agentId 是模板名称
|
||
pod_quota=agent_alloc.quantity,
|
||
pod_used=0,
|
||
allocated_by=uuid.UUID(user_id_for_allocation) if user_id_for_allocation else None,
|
||
allocated_at=datetime.utcnow(),
|
||
)
|
||
db.add(quota)
|
||
logger.info(
|
||
f"创建平台 Agent 配额给渠道 {channel.name}: "
|
||
f"template={agent_alloc.agentId}, quota={agent_alloc.quantity}"
|
||
)
|
||
|
||
# 2. 更新或创建 ResourceAllocation 记录(兼容旧逻辑)
|
||
# 先查找是否已存在
|
||
existing_alloc_result = await db.execute(
|
||
select(ResourceAllocation).where(
|
||
and_(
|
||
ResourceAllocation.target_id == channel_id,
|
||
ResourceAllocation.target_type == "channel",
|
||
ResourceAllocation.resource_type == "agent",
|
||
ResourceAllocation.resource_id == agent_alloc.agentId
|
||
)
|
||
)
|
||
)
|
||
existing_alloc = existing_alloc_result.scalar_one_or_none()
|
||
|
||
if existing_alloc:
|
||
# 追加模式
|
||
existing_alloc.quantity = (existing_alloc.quantity or 0) + agent_alloc.quantity
|
||
else:
|
||
# 创建新记录
|
||
allocation = ResourceAllocation(
|
||
target_id=channel_id,
|
||
target_type="channel",
|
||
resource_type="agent",
|
||
resource_id=agent_alloc.agentId,
|
||
quantity=agent_alloc.quantity,
|
||
)
|
||
db.add(allocation)
|
||
|
||
# 更新自定义Agent资源(旧格式,保留兼容)
|
||
# Bug 修复:customAgentResources 也需要创建 ChannelCustomAgentQuota 记录
|
||
# 追加模式:在现有配额基础上增加
|
||
if req.customAgentResources:
|
||
# 查找或创建渠道配额记录
|
||
channel_quota_result = await db.execute(
|
||
select(ChannelCustomAgentQuota).where(
|
||
ChannelCustomAgentQuota.channel_id == channel_id
|
||
)
|
||
)
|
||
channel_quota = channel_quota_result.scalar_one_or_none()
|
||
|
||
if channel_quota:
|
||
# 追加模式:在现有配额基础上增加
|
||
new_cpu_quota = float(channel_quota.cpu_quota or 0) + req.customAgentResources.cpu
|
||
new_memory_quota = float(channel_quota.memory_quota or 0) + req.customAgentResources.memory
|
||
channel_quota.cpu_quota = new_cpu_quota
|
||
channel_quota.memory_quota = new_memory_quota
|
||
# 同步更新渠道表字段
|
||
channel.custom_agent_cpu = new_cpu_quota
|
||
channel.custom_agent_memory = new_memory_quota
|
||
channel.custom_agent_cpu_quota = new_cpu_quota
|
||
channel.custom_agent_memory_quota = new_memory_quota
|
||
else:
|
||
# 创建新配额记录
|
||
channel_quota = ChannelCustomAgentQuota(
|
||
channel_id=channel_id,
|
||
cpu_quota=req.customAgentResources.cpu,
|
||
memory_quota=req.customAgentResources.memory,
|
||
cpu_allocated=0,
|
||
memory_allocated=0,
|
||
)
|
||
db.add(channel_quota)
|
||
# 同步更新渠道表字段
|
||
channel.custom_agent_cpu = req.customAgentResources.cpu
|
||
channel.custom_agent_memory = req.customAgentResources.memory
|
||
channel.custom_agent_cpu_quota = req.customAgentResources.cpu
|
||
channel.custom_agent_memory_quota = req.customAgentResources.memory
|
||
|
||
# 更新自定义 Agent 配额(新格式)
|
||
# 追加模式:在现有配额基础上增加
|
||
if req.customAgentQuota:
|
||
# 查找或创建渠道配额记录
|
||
channel_quota_result = await db.execute(
|
||
select(ChannelCustomAgentQuota).where(
|
||
ChannelCustomAgentQuota.channel_id == channel_id
|
||
)
|
||
)
|
||
channel_quota = channel_quota_result.scalar_one_or_none()
|
||
|
||
if channel_quota:
|
||
# 追加模式:在现有配额基础上增加
|
||
new_cpu_quota = float(channel_quota.cpu_quota or 0) + req.customAgentQuota.cpuQuota
|
||
new_memory_quota = float(channel_quota.memory_quota or 0) + req.customAgentQuota.memoryQuota
|
||
channel_quota.cpu_quota = new_cpu_quota
|
||
channel_quota.memory_quota = new_memory_quota
|
||
# 更新渠道表中的配额字段
|
||
channel.custom_agent_cpu_quota = new_cpu_quota
|
||
channel.custom_agent_memory_quota = new_memory_quota
|
||
else:
|
||
# 创建新配额记录
|
||
channel_quota = ChannelCustomAgentQuota(
|
||
channel_id=channel_id,
|
||
cpu_quota=req.customAgentQuota.cpuQuota,
|
||
memory_quota=req.customAgentQuota.memoryQuota,
|
||
cpu_allocated=0,
|
||
memory_allocated=0,
|
||
)
|
||
db.add(channel_quota)
|
||
# 更新渠道表中的配额字段
|
||
channel.custom_agent_cpu_quota = req.customAgentQuota.cpuQuota
|
||
channel.custom_agent_memory_quota = req.customAgentQuota.memoryQuota
|
||
|
||
channel.channel_credit = req.channelCredit
|
||
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"channelId": str(channel.id),
|
||
"channelName": channel.name,
|
||
"litellmTeamId": channel.litellm_team_id,
|
||
"models": req.models, # 原始输入(供应商 ID 或模型名称)
|
||
"resolvedModels": all_model_names, # 解析后的模型名称列表(用于 LiteLLM)
|
||
"providerIds": provider_ids, # 供应商 ID 列表
|
||
},
|
||
message="渠道资源分配成功"
|
||
)
|
||
|
||
|
||
@router.put("/channels/{channel_id}/commission", response_model=SuccessResponse)
|
||
async def update_channel_commission(
|
||
channel_id: str,
|
||
req: UpdateChannelCommissionRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
更新渠道佣金比例(super_admin 和 billing_admin 可用)
|
||
|
||
佣金比例范围:0-1(0表示无佣金,1表示100%佣金)
|
||
"""
|
||
_verify_write_permission(principal)
|
||
|
||
# 验证渠道
|
||
channel_uuid = uuid.UUID(channel_id)
|
||
result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_uuid)
|
||
)
|
||
channel = result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 更新佣金比例
|
||
channel.commission_rate = req.commissionRate
|
||
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"channelId": str(channel.id),
|
||
"commissionRate": float(req.commissionRate)
|
||
},
|
||
message="渠道佣金比例已更新"
|
||
)
|
||
|
||
|
||
# ============= 资源分配统计 =============
|
||
|
||
@router.get("/resources/allocation-stats", response_model=SuccessResponse)
|
||
async def get_resource_allocation_stats(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取详细的资源分配统计(所有管理员可查看)
|
||
|
||
返回数据包括:
|
||
- 平台端 Agent 统计(从 agent-manager/K8s 获取,总数、资源使用、健康状态分布)
|
||
- 自定义 Agent 统计(从数据库获取,总数、资源使用、健康状态分布)
|
||
- 渠道配额分配统计
|
||
- 租户配额使用统计
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
from models import TenantCustomAgentQuota
|
||
|
||
# ========== 平台端 Agent 统计(优先从 agent-manager 获取)==========
|
||
platform_stats = {
|
||
"count": 0,
|
||
"cpu": 0.0,
|
||
"memory": 0.0,
|
||
"healthStatus": {
|
||
"healthy": 0,
|
||
"warning": 0,
|
||
"critical": 0,
|
||
"unknown": 0,
|
||
},
|
||
"byStatus": {
|
||
"running": 0,
|
||
"pending": 0,
|
||
"error": 0,
|
||
},
|
||
}
|
||
|
||
# 尝试从 agent-manager 获取 K8s Agent
|
||
try:
|
||
from app.agent_manager_client import get_agent_manager_client
|
||
client = get_agent_manager_client()
|
||
k8s_agents_result = await client.list_agents()
|
||
k8s_agents = k8s_agents_result.agents # 从 AgentListResult 中获取 agents 列表
|
||
|
||
platform_stats["count"] = len(k8s_agents)
|
||
|
||
for agent in k8s_agents:
|
||
agent_name = agent.get("name", "")
|
||
k8s_status = agent.get("status", "unknown")
|
||
|
||
# 统计状态
|
||
if k8s_status == "Running":
|
||
platform_stats["byStatus"]["running"] += 1
|
||
platform_stats["healthStatus"]["healthy"] += 1
|
||
elif k8s_status in ["Pending", "ContainerCreating"]:
|
||
platform_stats["byStatus"]["pending"] += 1
|
||
platform_stats["healthStatus"]["warning"] += 1
|
||
elif k8s_status in ["Failed", "Error", "CrashLoopBackOff"]:
|
||
platform_stats["byStatus"]["error"] += 1
|
||
platform_stats["healthStatus"]["critical"] += 1
|
||
else:
|
||
platform_stats["healthStatus"]["unknown"] += 1
|
||
|
||
# 获取资源配置
|
||
try:
|
||
metrics = await client.get_agent_metrics(agent_name)
|
||
# 使用新的属性访问器获取资源使用量
|
||
platform_stats["cpu"] += metrics.cpu_usage_millicores / 1000 # 转换为核
|
||
platform_stats["memory"] += metrics.memory_usage_mb / 1024 # 转换为 GB
|
||
except Exception as e:
|
||
logger.debug(f"获取 Agent {agent_name} 资源指标失败: {e}")
|
||
|
||
platform_stats["cpu"] = round(platform_stats["cpu"], 2)
|
||
platform_stats["memory"] = round(platform_stats["memory"], 2)
|
||
|
||
except Exception as e:
|
||
logger.warning(f"连接 agent-manager 失败,使用数据库统计: {e}")
|
||
# 回退到数据库查询
|
||
platform_agents_result = await db.execute(
|
||
select(Agent).where(
|
||
and_(
|
||
Agent.type == "platform",
|
||
Agent.status != "inactive"
|
||
)
|
||
)
|
||
)
|
||
platform_agents = platform_agents_result.scalars().all()
|
||
|
||
platform_stats = {
|
||
"count": len(platform_agents),
|
||
"cpu": round(sum(float(a.cpu or 0) for a in platform_agents), 2),
|
||
"memory": round(sum(float(a.memory or 0) for a in platform_agents), 2),
|
||
"healthStatus": {
|
||
"healthy": len([a for a in platform_agents if a.health_status == "healthy"]),
|
||
"warning": len([a for a in platform_agents if a.health_status == "warning"]),
|
||
"critical": len([a for a in platform_agents if a.health_status == "critical"]),
|
||
"unknown": len([a for a in platform_agents if not a.health_status or a.health_status == "unknown"]),
|
||
},
|
||
"byStatus": {
|
||
"running": len([a for a in platform_agents if a.status in ["active", "running"]]),
|
||
"pending": len([a for a in platform_agents if a.status == "pending"]),
|
||
"error": len([a for a in platform_agents if a.status == "error"]),
|
||
},
|
||
}
|
||
|
||
# ========== 自定义 Agent 统计(从数据库获取)==========
|
||
custom_agents_result = await db.execute(
|
||
select(Agent).where(
|
||
and_(
|
||
Agent.type == "custom",
|
||
Agent.status != "inactive"
|
||
)
|
||
)
|
||
)
|
||
custom_agents = custom_agents_result.scalars().all()
|
||
|
||
custom_stats = {
|
||
"count": len(custom_agents),
|
||
"cpu": round(sum(float(a.cpu or 0) for a in custom_agents), 2),
|
||
"memory": round(sum(float(a.memory or 0) for a in custom_agents), 2),
|
||
"healthStatus": {
|
||
"healthy": len([a for a in custom_agents if a.health_status == "healthy"]),
|
||
"warning": len([a for a in custom_agents if a.health_status == "warning"]),
|
||
"critical": len([a for a in custom_agents if a.health_status == "critical"]),
|
||
"unknown": len([a for a in custom_agents if not a.health_status or a.health_status == "unknown"]),
|
||
},
|
||
"byStatus": {
|
||
"active": len([a for a in custom_agents if a.status == "active"]),
|
||
"error": len([a for a in custom_agents if a.status == "error"]),
|
||
},
|
||
}
|
||
|
||
# ========== 渠道配额分配统计 ==========
|
||
channel_quotas_result = await db.execute(
|
||
select(ChannelCustomAgentQuota)
|
||
)
|
||
channel_quotas = channel_quotas_result.scalars().all()
|
||
|
||
channel_quota_stats = {
|
||
"totalChannels": len(channel_quotas),
|
||
"totalCpuQuota": round(sum(float(q.cpu_quota or 0) for q in channel_quotas), 2),
|
||
"totalMemoryQuota": round(sum(float(q.memory_quota or 0) for q in channel_quotas), 2),
|
||
"totalCpuAllocated": round(sum(float(q.cpu_allocated or 0) for q in channel_quotas), 2),
|
||
"totalMemoryAllocated": round(sum(float(q.memory_allocated or 0) for q in channel_quotas), 2),
|
||
}
|
||
|
||
# ========== 租户配额使用统计 ==========
|
||
tenant_quotas_result = await db.execute(
|
||
select(TenantCustomAgentQuota)
|
||
)
|
||
tenant_quotas = tenant_quotas_result.scalars().all()
|
||
|
||
tenant_quota_stats = {
|
||
"totalTenants": len(tenant_quotas),
|
||
"totalCpuQuota": round(sum(float(q.cpu_quota or 0) for q in tenant_quotas), 2),
|
||
"totalMemoryQuota": round(sum(float(q.memory_quota or 0) for q in tenant_quotas), 2),
|
||
"totalCpuUsed": round(sum(float(q.cpu_used or 0) for q in tenant_quotas), 2),
|
||
"totalMemoryUsed": round(sum(float(q.memory_used or 0) for q in tenant_quotas), 2),
|
||
"totalAgentCount": sum(q.agent_count or 0 for q in tenant_quotas),
|
||
}
|
||
|
||
# ========== 总计 ==========
|
||
total_stats = {
|
||
"totalAgents": platform_stats["count"] + custom_stats["count"],
|
||
"totalCpu": round(platform_stats["cpu"] + custom_stats["cpu"], 2),
|
||
"totalMemory": round(platform_stats["memory"] + custom_stats["memory"], 2),
|
||
}
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"platformAgents": platform_stats,
|
||
"customAgents": custom_stats,
|
||
"channelQuotas": channel_quota_stats,
|
||
"tenantQuotas": tenant_quota_stats,
|
||
"total": total_stats,
|
||
}
|
||
)
|
||
|
||
|
||
# ============= 申请审批 =============
|
||
|
||
@router.get("/channels/applications", response_model=SuccessResponse)
|
||
async def list_applications(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取所有渠道申请(所有管理员可查看)
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
result = await db.execute(
|
||
select(Application, Channel)
|
||
.join(Channel, Application.channel_id == Channel.id)
|
||
.order_by(desc(Application.created_at))
|
||
)
|
||
|
||
data = []
|
||
for app, channel in result.all():
|
||
details = {}
|
||
if app.type == "model":
|
||
details = {
|
||
"modelName": app.model_name,
|
||
"rpm": app.rpm,
|
||
"tpm": app.tpm,
|
||
}
|
||
else:
|
||
details = {
|
||
"agentType": app.agent_type,
|
||
"quantity": app.quantity,
|
||
}
|
||
|
||
data.append({
|
||
"id": str(app.id),
|
||
"channelId": str(app.channel_id),
|
||
"channelName": channel.name,
|
||
"type": app.type,
|
||
"details": details,
|
||
"reason": app.reason or "",
|
||
"status": app.status,
|
||
"createdAt": app.created_at.isoformat(),
|
||
})
|
||
|
||
return SuccessResponse(data={"data": data})
|
||
|
||
|
||
@router.put("/channels/applications/{application_id}/review", response_model=SuccessResponse)
|
||
async def review_application(
|
||
application_id: str,
|
||
req: ReviewApplicationRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
审批申请(super_admin 和 billing_admin 可用)
|
||
"""
|
||
_verify_write_permission(principal)
|
||
user_id = principal.get("user_id")
|
||
|
||
# 查询申请
|
||
result = await db.execute(
|
||
select(Application).where(Application.id == application_id)
|
||
)
|
||
application = result.scalar_one_or_none()
|
||
|
||
if not application:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="申请不存在"
|
||
)
|
||
|
||
if application.status != "pending":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="该申请已被处理"
|
||
)
|
||
|
||
# 更新申请状态
|
||
application.status = "approved" if req.approved else "rejected"
|
||
application.reviewed_by = user_id
|
||
application.review_reason = req.reason
|
||
application.reviewed_at = datetime.utcnow()
|
||
|
||
# 如果批准,自动分配资源
|
||
if req.approved:
|
||
if application.type == "model":
|
||
# 查找模型供应商
|
||
result = await db.execute(
|
||
select(ModelProvider).where(ModelProvider.name == application.model_name)
|
||
)
|
||
model_provider = result.scalar_one_or_none()
|
||
|
||
if model_provider:
|
||
allocation = ResourceAllocation(
|
||
target_id=str(application.channel_id),
|
||
target_type="channel",
|
||
resource_type="model",
|
||
resource_id=str(model_provider.id),
|
||
rpm=application.rpm,
|
||
tpm=application.tpm,
|
||
)
|
||
db.add(allocation)
|
||
|
||
elif application.type == "agent":
|
||
# 查找Agent(简化处理,实际应该根据agent_type查找)
|
||
result = await db.execute(
|
||
select(Agent).where(Agent.name == application.agent_type).limit(1)
|
||
)
|
||
agent = result.scalar_one_or_none()
|
||
|
||
if agent:
|
||
allocation = ResourceAllocation(
|
||
target_id=str(application.channel_id),
|
||
target_type="channel",
|
||
resource_type="agent",
|
||
resource_id=str(agent.id),
|
||
quantity=application.quantity,
|
||
)
|
||
db.add(allocation)
|
||
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
message=f"申请已{'批准' if req.approved else '拒绝'}"
|
||
)
|
||
|
||
|
||
# ============= 资源管理 =============
|
||
|
||
@router.get("/resources/litellm-models", response_model=SuccessResponse)
|
||
async def list_litellm_models(
|
||
principal: dict = Depends(require_auth),
|
||
):
|
||
"""
|
||
获取 LiteLLM 中配置的所有模型列表
|
||
|
||
从 LiteLLM Gateway 获取所有可用的模型,供管理员选择分配给渠道/租户。
|
||
|
||
权限说明:
|
||
- 超级管理员:可查看所有模型
|
||
- 计费管理员、运维管理员:可查看所有模型(用于资源分配)
|
||
|
||
返回数据:
|
||
- models: 模型列表,每个模型包含 id(模型名称)、provider(供应商)等信息
|
||
- total: 模型总数
|
||
- providers: 按供应商分组的模型统计
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
try:
|
||
from app.litellm_client import get_litellm_client, LiteLLMClientError
|
||
litellm_client = get_litellm_client()
|
||
|
||
# 从 LiteLLM 获取模型列表
|
||
raw_models = await litellm_client.list_models()
|
||
|
||
# 解析模型信息,提取供应商
|
||
models = []
|
||
provider_counts = {}
|
||
|
||
for model in raw_models:
|
||
model_id = model.get("id", "")
|
||
|
||
# 解析供应商(从模型 ID 中提取)
|
||
# 格式可能是: "gpt-4", "azure/gpt-4", "openrouter/openai/gpt-4", "taiji/gpt-4" 等
|
||
parts = model_id.split("/")
|
||
if len(parts) >= 2:
|
||
provider = parts[0]
|
||
else:
|
||
# 没有前缀的模型,根据名称推断供应商
|
||
if model_id.startswith("gpt-") or model_id.startswith("o1") or model_id.startswith("o3") or model_id.startswith("o4") or model_id.startswith("dall-e") or model_id.startswith("text-embedding"):
|
||
provider = "openai"
|
||
elif model_id.startswith("claude"):
|
||
provider = "anthropic"
|
||
elif model_id.startswith("gemini"):
|
||
provider = "google"
|
||
elif model_id.startswith("deepseek"):
|
||
provider = "deepseek"
|
||
else:
|
||
provider = "other"
|
||
|
||
# 统计供应商
|
||
provider_counts[provider] = provider_counts.get(provider, 0) + 1
|
||
|
||
models.append({
|
||
"id": model_id,
|
||
"name": model_id,
|
||
"provider": provider,
|
||
"object": model.get("object", "model"),
|
||
"ownedBy": model.get("owned_by", ""),
|
||
})
|
||
|
||
# 按供应商分组统计
|
||
providers_summary = [
|
||
{"provider": provider, "count": count}
|
||
for provider, count in sorted(provider_counts.items(), key=lambda x: -x[1])
|
||
]
|
||
|
||
logger.info(f"从 LiteLLM 获取到 {len(models)} 个模型")
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"models": models,
|
||
"total": len(models),
|
||
"providers": providers_summary,
|
||
}
|
||
)
|
||
|
||
except LiteLLMClientError as e:
|
||
logger.error(f"获取 LiteLLM 模型列表失败: {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail=f"无法连接 LiteLLM Gateway: {str(e)}"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"获取 LiteLLM 模型列表时发生错误: {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"获取模型列表失败: {str(e)}"
|
||
)
|
||
|
||
|
||
@router.get("/resources/models", response_model=SuccessResponse)
|
||
async def list_model_providers(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取所有模型供应商
|
||
|
||
权限说明:
|
||
- 超级管理员:可查看所有供应商
|
||
- 计费管理员、运维管理员:可查看所有供应商(用于审批申请)
|
||
- 供应商管理员:可查看所有供应商
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
result = await db.execute(select(ModelProvider))
|
||
providers = result.scalars().all()
|
||
|
||
data = [
|
||
{
|
||
"id": str(provider.id),
|
||
"name": provider.name,
|
||
"provider": provider.provider,
|
||
"apiUrl": provider.api_url,
|
||
"supportedModels": provider.supported_models,
|
||
"rpm": provider.rpm,
|
||
"tpm": provider.tpm,
|
||
"status": provider.status,
|
||
"isActive": provider.is_active,
|
||
}
|
||
for provider in providers
|
||
]
|
||
|
||
return SuccessResponse(data={"providers": data})
|
||
|
||
|
||
@router.get("/resources/agents", response_model=SuccessResponse)
|
||
async def list_all_agents(
|
||
include_inactive: bool = Query(False, description="是否包含已删除(inactive)的Agent"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取所有Agent资源(平台端 + 自定义)
|
||
|
||
平台端 Agent 从 agent-manager (K8s) 获取
|
||
自定义 Agent 从本地数据库获取
|
||
|
||
Args:
|
||
include_inactive: 是否包含已删除的Agent,默认False只返回活跃的
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
items = []
|
||
k8s_agents = []
|
||
|
||
# 1. 从 agent-manager 获取 K8s 中运行的平台端 Agent
|
||
try:
|
||
from app.agent_manager_client import get_agent_manager_client
|
||
client = get_agent_manager_client()
|
||
k8s_agents_result = await client.list_agents()
|
||
k8s_agents = k8s_agents_result.agents # 从 AgentListResult 中获取 agents 列表
|
||
logger.info(f"从 agent-manager 获取到 {len(k8s_agents)} 个 Agent")
|
||
|
||
for agent in k8s_agents:
|
||
# 解析资源配置
|
||
cpu_usage_str = "0"
|
||
memory_usage_str = "0"
|
||
cpu_value = 0.0
|
||
memory_value = 0.0
|
||
try:
|
||
metrics = await client.get_agent_metrics(agent.get("name", ""))
|
||
# 使用新的属性访问器
|
||
cpu_usage_str = metrics.cpu_usage
|
||
memory_usage_str = metrics.memory_usage
|
||
cpu_value = metrics.cpu_usage_millicores / 1000 # 转换为核
|
||
memory_value = metrics.memory_usage_mb / 1024 # 转换为 GB
|
||
except Exception as e:
|
||
logger.warning(f"获取 Agent {agent.get('name')} 资源指标失败: {e}")
|
||
|
||
items.append({
|
||
"id": agent.get("name", ""),
|
||
"name": agent.get("name", ""),
|
||
"type": "platform",
|
||
"description": agent.get("template", "K8s Agent"),
|
||
"category": "k8s",
|
||
"status": agent.get("status", "unknown"),
|
||
"healthStatus": "healthy" if agent.get("status") == "Running" else "unknown",
|
||
"cpu": round(cpu_value, 2),
|
||
"memory": round(memory_value, 2),
|
||
"maxInstances": 1,
|
||
"cpuRequest": agent.get("cpu_request", "100m"),
|
||
"cpuLimit": cpu_usage_str, # 使用 cpu_usage 作为显示值
|
||
"memoryRequest": agent.get("memory_request", "128Mi"),
|
||
"memoryLimit": memory_usage_str, # 使用 memory_usage 作为显示值
|
||
"podName": agent.get("pod_name", ""),
|
||
"podIp": agent.get("pod_ip", ""),
|
||
"namespace": agent.get("namespace", "ai-agents"),
|
||
"template": agent.get("template", ""),
|
||
"createdAt": agent.get("created_at"),
|
||
"source": "k8s",
|
||
})
|
||
except Exception as e:
|
||
logger.warning(f"连接 agent-manager 失败: {e}")
|
||
|
||
# 2. 从本地数据库获取自定义 Agent
|
||
if include_inactive:
|
||
result = await db.execute(select(Agent))
|
||
else:
|
||
result = await db.execute(
|
||
select(Agent).where(Agent.status != "inactive")
|
||
)
|
||
db_agents = result.scalars().all()
|
||
|
||
for agent in db_agents:
|
||
# 检查是否已经从 K8s 获取过(避免重复)
|
||
if any(item.get("name") == agent.name for item in items):
|
||
continue
|
||
|
||
items.append({
|
||
"id": str(agent.id),
|
||
"name": agent.name,
|
||
"type": agent.type or "custom",
|
||
"description": agent.description,
|
||
"category": agent.category,
|
||
"status": agent.status,
|
||
"healthStatus": agent.health_status or "unknown",
|
||
"cpu": float(agent.cpu or 0),
|
||
"memory": float(agent.memory or 0),
|
||
"maxInstances": agent.max_instances or 100,
|
||
"cpuRequest": agent.cpu_request,
|
||
"cpuLimit": agent.cpu_limit,
|
||
"memoryRequest": agent.memory_request,
|
||
"memoryLimit": agent.memory_limit,
|
||
"totalExecutions": agent.total_executions or 0,
|
||
"successRate": float(agent.success_rate or 0),
|
||
"createdAt": agent.created_at.isoformat() if agent.created_at else None,
|
||
"source": "database",
|
||
})
|
||
|
||
# 统计信息
|
||
platform_count = sum(1 for item in items if item.get("type") == "platform")
|
||
custom_count = sum(1 for item in items if item.get("type") == "custom")
|
||
total_cpu = sum(float(item.get("cpu", 0)) for item in items)
|
||
total_memory = sum(float(item.get("memory", 0)) for item in items)
|
||
|
||
return SuccessResponse(data={
|
||
"agents": items,
|
||
"summary": {
|
||
"total": len(items),
|
||
"platformAgents": platform_count,
|
||
"customAgents": custom_count,
|
||
"totalCpu": round(total_cpu, 2),
|
||
"totalMemory": round(total_memory, 2),
|
||
"k8sAgentsCount": len(k8s_agents),
|
||
"dbAgentsCount": len(db_agents),
|
||
}
|
||
})
|
||
|
||
|
||
@router.delete("/resources/agents/{agent_id}", response_model=SuccessResponse)
|
||
async def delete_agent_resource(
|
||
agent_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
删除Agent资源(软删除,super_admin 和 billing_admin 可用)
|
||
"""
|
||
_verify_write_permission(principal)
|
||
|
||
# 验证Agent存在
|
||
result = await db.execute(
|
||
select(Agent).where(Agent.id == agent_id)
|
||
)
|
||
agent = result.scalar_one_or_none()
|
||
|
||
if not agent:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Agent资源不存在"
|
||
)
|
||
|
||
# 软删除:标记为不活跃
|
||
agent.status = "inactive"
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
data={"id": str(agent.id), "name": agent.name},
|
||
message="Agent资源已删除"
|
||
)
|
||
|
||
|
||
@router.put("/resources/agents/{agent_id}/config", response_model=SuccessResponse)
|
||
async def update_agent_config(
|
||
agent_id: str,
|
||
req: UpdateAgentConfigRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
更新Agent资源配置(super_admin 和 billing_admin 可用)
|
||
|
||
支持两种格式:
|
||
1. 旧格式(数值):cpu=2.0, memory=4.0
|
||
2. K8s格式(字符串):cpu_request="100m", memory_limit="512Mi"
|
||
|
||
注意:如果 Agent 有关联的 K8s Pod,更新资源配置需要重新创建 Pod 才能生效。
|
||
"""
|
||
_verify_write_permission(principal)
|
||
|
||
# 验证Agent存在
|
||
result = await db.execute(
|
||
select(Agent).where(Agent.id == agent_id)
|
||
)
|
||
agent = result.scalar_one_or_none()
|
||
|
||
if not agent:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Agent资源不存在"
|
||
)
|
||
|
||
# 更新旧格式配置
|
||
if req.cpu is not None:
|
||
agent.cpu = req.cpu
|
||
if req.memory is not None:
|
||
agent.memory = req.memory
|
||
if req.maxInstances is not None:
|
||
agent.max_instances = req.maxInstances
|
||
|
||
# 更新 K8s 格式配置
|
||
if req.cpu_request is not None:
|
||
agent.cpu_request = req.cpu_request
|
||
if req.cpu_limit is not None:
|
||
agent.cpu_limit = req.cpu_limit
|
||
if req.memory_request is not None:
|
||
agent.memory_request = req.memory_request
|
||
if req.memory_limit is not None:
|
||
agent.memory_limit = req.memory_limit
|
||
|
||
await db.commit()
|
||
await db.refresh(agent)
|
||
|
||
# 构建响应数据
|
||
response_data = {
|
||
"id": str(agent.id),
|
||
"name": agent.name,
|
||
"cpu": float(agent.cpu) if agent.cpu else None,
|
||
"memory": float(agent.memory) if agent.memory else None,
|
||
"maxInstances": agent.max_instances,
|
||
# K8s 格式
|
||
"cpu_request": agent.cpu_request,
|
||
"cpu_limit": agent.cpu_limit,
|
||
"memory_request": agent.memory_request,
|
||
"memory_limit": agent.memory_limit,
|
||
}
|
||
|
||
# 如果有 Pod,提示需要重新创建
|
||
message = "Agent资源配置更新成功"
|
||
if agent.pod_name:
|
||
message += "。注意:已运行的 Pod 需要重新创建才能应用新的资源配置。"
|
||
|
||
return SuccessResponse(
|
||
data=response_data,
|
||
message=message
|
||
)
|
||
|
||
|
||
# ============= 监控 =============
|
||
|
||
@router.get("/monitoring/agents", response_model=SuccessResponse)
|
||
async def monitor_agents(
|
||
agent_type: Optional[str] = Query(None, pattern="^(platform|custom)$", description="筛选 Agent 类型"),
|
||
health_status: Optional[str] = Query(None, pattern="^(healthy|warning|critical|unknown)$", description="筛选健康状态"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
监控 Agent 健康状态和性能指标(所有管理员可查看)
|
||
|
||
同时监控平台端 Agent(从 agent-manager/K8s 获取)和自定义 Agent(从数据库获取)。
|
||
|
||
Args:
|
||
agent_type: 可选,筛选 Agent 类型(platform/custom)
|
||
health_status: 可选,筛选健康状态(healthy/warning/critical/unknown)
|
||
|
||
返回数据包括:
|
||
- agents: Agent 列表(包含类型、健康状态、性能指标等)
|
||
- summary: 汇总统计(按类型和健康状态分组)
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
data = []
|
||
k8s_agent_names = set() # 用于去重
|
||
|
||
# ========== 1. 从 agent-manager 获取 K8s 中运行的平台端 Agent ==========
|
||
if agent_type is None or agent_type == "platform":
|
||
try:
|
||
from app.agent_manager_client import get_agent_manager_client
|
||
client = get_agent_manager_client()
|
||
k8s_agents_result = await client.list_agents()
|
||
k8s_agents = k8s_agents_result.agents # 从 AgentListResult 中获取 agents 列表
|
||
logger.info(f"监控: 从 agent-manager 获取到 {len(k8s_agents)} 个平台端 Agent")
|
||
|
||
for agent in k8s_agents:
|
||
agent_name = agent.get("name", "")
|
||
k8s_agent_names.add(agent_name)
|
||
|
||
# 解析资源配置和使用率
|
||
# 实时使用量(从 metrics-server 获取)
|
||
cpu_usage_current_str = "0"
|
||
memory_usage_current_str = "0"
|
||
# 资源限制(从 Pod spec 获取)
|
||
cpu_limit_str = "0"
|
||
memory_limit_str = "0"
|
||
cpu_request_str = "0"
|
||
memory_request_str = "0"
|
||
# 数值(用于统计)
|
||
cpu_value = 0.0
|
||
memory_value = 0.0
|
||
# 使用率百分比
|
||
cpu_utilization = None
|
||
memory_utilization = None
|
||
# metrics 时间戳
|
||
metrics_timestamp = None
|
||
has_realtime_metrics = False
|
||
|
||
try:
|
||
metrics = await client.get_agent_metrics(agent_name)
|
||
# 资源限制(从 Pod spec 获取)
|
||
cpu_limit_str = metrics.cpu_limit
|
||
memory_limit_str = metrics.memory_limit
|
||
cpu_request_str = metrics.cpu_request
|
||
memory_request_str = metrics.memory_request
|
||
|
||
# 实时使用量(从 metrics-server 获取)
|
||
if metrics.has_realtime_metrics:
|
||
has_realtime_metrics = True
|
||
cpu_usage_current_str = metrics.cpu_usage_current
|
||
memory_usage_current_str = metrics.memory_usage_current
|
||
# 使用实时数据计算数值
|
||
cpu_value = metrics.cpu_usage_current_millicores / 1000 # 转换为核
|
||
memory_value = metrics.memory_usage_current_mb / 1024 # 转换为 GB
|
||
# 使用率百分比
|
||
cpu_utilization = metrics.cpu_utilization_percent
|
||
memory_utilization = metrics.memory_utilization_percent
|
||
metrics_timestamp = metrics.timestamp
|
||
else:
|
||
# 没有实时数据,使用 limits 作为显示值
|
||
cpu_usage_current_str = cpu_limit_str
|
||
memory_usage_current_str = memory_limit_str
|
||
cpu_value = metrics.cpu_limit_millicores / 1000
|
||
memory_value = metrics.memory_limit_mb / 1024
|
||
except Exception as e:
|
||
logger.debug(f"获取 Agent {agent_name} 资源指标失败: {e}")
|
||
|
||
# 根据 K8s 状态判断健康状态
|
||
k8s_status = agent.get("status", "unknown")
|
||
if k8s_status == "Running":
|
||
agent_health_status = "healthy"
|
||
elif k8s_status in ["Pending", "ContainerCreating"]:
|
||
agent_health_status = "warning"
|
||
elif k8s_status in ["Failed", "Error", "CrashLoopBackOff"]:
|
||
agent_health_status = "critical"
|
||
else:
|
||
agent_health_status = "unknown"
|
||
|
||
# 如果有健康状态筛选,检查是否匹配
|
||
if health_status and agent_health_status != health_status:
|
||
continue
|
||
|
||
data.append({
|
||
"id": agent_name,
|
||
"name": agent_name,
|
||
"type": "platform",
|
||
"status": k8s_status.lower() if k8s_status else "unknown",
|
||
"healthStatus": agent_health_status,
|
||
"lastHealthCheck": metrics_timestamp,
|
||
"healthMessage": f"K8s Pod 状态: {k8s_status}",
|
||
"totalExecutions": 0,
|
||
"successRate": 0.0,
|
||
"avgExecutionTime": 0.0,
|
||
"cpu": round(cpu_value, 2),
|
||
"memory": round(memory_value, 2),
|
||
# 实时使用量(从 metrics-server 获取,已转换为易读格式)
|
||
"cpuUsage": _format_cpu_usage(cpu_usage_current_str),
|
||
"memoryUsage": _format_memory_usage(memory_usage_current_str),
|
||
# 资源限制(从 Pod spec 获取)
|
||
"cpuLimit": cpu_limit_str,
|
||
"memoryLimit": memory_limit_str,
|
||
"cpuRequest": cpu_request_str,
|
||
"memoryRequest": memory_request_str,
|
||
# 使用率百分比
|
||
"cpuUtilization": round(cpu_utilization, 2) if cpu_utilization is not None else None,
|
||
"memoryUtilization": round(memory_utilization, 2) if memory_utilization is not None else None,
|
||
# 是否有实时 metrics 数据
|
||
"hasRealtimeMetrics": has_realtime_metrics,
|
||
"metricsTimestamp": metrics_timestamp,
|
||
# K8s 信息
|
||
"podName": agent.get("pod_name", ""),
|
||
"podIp": agent.get("pod_ip", ""),
|
||
"namespace": agent.get("namespace", "ai-agents"),
|
||
"k8sStatus": k8s_status,
|
||
"template": agent.get("template", ""),
|
||
"createdAt": agent.get("created_at"),
|
||
"source": "k8s",
|
||
})
|
||
except Exception as e:
|
||
logger.warning(f"监控: 连接 agent-manager 失败: {e}")
|
||
|
||
# ========== 2. 从数据库获取自定义 Agent ==========
|
||
if agent_type is None or agent_type == "custom":
|
||
# 构建查询 - 查询所有活跃的自定义 Agent
|
||
query = select(Agent).where(
|
||
and_(
|
||
Agent.status != "inactive",
|
||
Agent.type == "custom"
|
||
)
|
||
)
|
||
|
||
# 按健康状态筛选
|
||
if health_status:
|
||
query = query.where(Agent.health_status == health_status)
|
||
|
||
result = await db.execute(query)
|
||
db_agents = result.scalars().all()
|
||
|
||
for agent in db_agents:
|
||
# 跳过已从 K8s 获取的 Agent(避免重复)
|
||
if agent.name in k8s_agent_names:
|
||
continue
|
||
|
||
data.append({
|
||
"id": str(agent.id),
|
||
"name": agent.name,
|
||
"type": "custom",
|
||
"status": agent.status,
|
||
"healthStatus": agent.health_status or "unknown",
|
||
"lastHealthCheck": agent.last_health_check.isoformat() if agent.last_health_check else None,
|
||
"healthMessage": agent.health_message,
|
||
"totalExecutions": agent.total_executions or 0,
|
||
"successRate": float(agent.success_rate or 0),
|
||
"avgExecutionTime": float(agent.avg_execution_time or 0),
|
||
"cpu": float(agent.cpu or 0),
|
||
"memory": float(agent.memory or 0),
|
||
"cpuUsage": 0.0,
|
||
"memoryUsage": 0.0,
|
||
# 自定义 Agent 特有字段
|
||
"imageUrl": agent.image_url,
|
||
"runtimeType": agent.runtime_type,
|
||
# 所有者信息
|
||
"ownerId": str(agent.owner_id) if agent.owner_id else None,
|
||
"source": "database",
|
||
})
|
||
|
||
# ========== 3. 如果筛选平台端但没有从 K8s 获取到,也查询数据库中的平台端 Agent ==========
|
||
if agent_type == "platform" and len(data) == 0:
|
||
query = select(Agent).where(
|
||
and_(
|
||
Agent.status != "inactive",
|
||
Agent.type == "platform"
|
||
)
|
||
)
|
||
if health_status:
|
||
query = query.where(Agent.health_status == health_status)
|
||
|
||
result = await db.execute(query)
|
||
db_platform_agents = result.scalars().all()
|
||
|
||
for agent in db_platform_agents:
|
||
if agent.name in k8s_agent_names:
|
||
continue
|
||
|
||
data.append({
|
||
"id": str(agent.id),
|
||
"name": agent.name,
|
||
"type": "platform",
|
||
"status": agent.status,
|
||
"healthStatus": agent.health_status or "unknown",
|
||
"lastHealthCheck": agent.last_health_check.isoformat() if agent.last_health_check else None,
|
||
"healthMessage": agent.health_message,
|
||
"totalExecutions": agent.total_executions or 0,
|
||
"successRate": float(agent.success_rate or 0),
|
||
"avgExecutionTime": float(agent.avg_execution_time or 0),
|
||
"cpu": float(agent.cpu or 0),
|
||
"memory": float(agent.memory or 0),
|
||
"cpuUsage": 0.0,
|
||
"memoryUsage": 0.0,
|
||
"podName": agent.pod_name,
|
||
"k8sStatus": agent.k8s_status,
|
||
"source": "database",
|
||
})
|
||
|
||
# ========== 4. 计算汇总统计 ==========
|
||
platform_agents = [a for a in data if a.get("type") == "platform"]
|
||
custom_agents = [a for a in data if a.get("type") == "custom"]
|
||
|
||
summary = {
|
||
"total": len(data),
|
||
"byType": {
|
||
"platform": len(platform_agents),
|
||
"custom": len(custom_agents),
|
||
},
|
||
"byHealthStatus": {
|
||
"healthy": len([a for a in data if a.get("healthStatus") == "healthy"]),
|
||
"warning": len([a for a in data if a.get("healthStatus") == "warning"]),
|
||
"critical": len([a for a in data if a.get("healthStatus") == "critical"]),
|
||
"unknown": len([a for a in data if a.get("healthStatus") == "unknown" or not a.get("healthStatus")]),
|
||
},
|
||
"byStatus": {
|
||
"running": len([a for a in data if a.get("status") in ["running", "active"]]),
|
||
"pending": len([a for a in data if a.get("status") in ["pending", "containercreating"]]),
|
||
"error": len([a for a in data if a.get("status") in ["error", "failed", "crashloopbackoff"]]),
|
||
"other": len([a for a in data if a.get("status") not in ["running", "active", "pending", "containercreating", "error", "failed", "crashloopbackoff", "inactive"]]),
|
||
},
|
||
# 资源统计
|
||
"totalCpu": round(sum(float(a.get("cpu", 0)) for a in data), 2),
|
||
"totalMemory": round(sum(float(a.get("memory", 0)) for a in data), 2),
|
||
}
|
||
|
||
return SuccessResponse(data={
|
||
"agents": data,
|
||
"summary": summary,
|
||
})
|
||
|
||
|
||
# ============= 周期性计费管理 =============
|
||
|
||
@router.post("/billing/trigger-periodic", response_model=SuccessResponse)
|
||
async def trigger_periodic_billing(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
手动触发周期性计费更新(super_admin/billing_admin 可用)
|
||
|
||
用于:
|
||
1. 调试计费逻辑
|
||
2. 紧急情况下手动更新EU消耗
|
||
"""
|
||
claims = principal.get("claims", {})
|
||
role = claims.get("role", "")
|
||
if role not in ["super_admin", "billing_admin"]:
|
||
raise HTTPException(status_code=403, detail="需要 super_admin 或 billing_admin 权限")
|
||
|
||
from app.periodic_billing import run_billing_now
|
||
|
||
stats = await run_billing_now()
|
||
|
||
return SuccessResponse(
|
||
message="周期性计费已手动触发",
|
||
data={
|
||
"processed": stats["processed"],
|
||
"platformAgents": stats["platform_agents"],
|
||
"customAgents": stats["custom_agents"],
|
||
"totalEuConsumed": float(stats["total_eu_consumed"]),
|
||
"totalCost": float(stats["total_cost"]),
|
||
"failed": stats["failed"],
|
||
"errors": stats["errors"][:10] if stats["errors"] else [], # 最多返回10个错误
|
||
"stoppedAgents": stats.get("stopped_agents", []), # 因余额不足停止的Agent
|
||
}
|
||
)
|
||
|
||
|
||
@router.get("/billing/running-agents", response_model=SuccessResponse)
|
||
async def get_running_agents_billing(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取所有运行中Agent的计费状态(super_admin/billing_admin 可用)
|
||
"""
|
||
claims = principal.get("claims", {})
|
||
role = claims.get("role", "")
|
||
if role not in ["super_admin", "billing_admin"]:
|
||
raise HTTPException(status_code=403, detail="需要 super_admin 或 billing_admin 权限")
|
||
|
||
# 查询所有运行中的 Agent
|
||
result = await db.execute(
|
||
select(AgentBillingRecord).where(
|
||
AgentBillingRecord.end_time == None
|
||
)
|
||
)
|
||
running_agents = result.scalars().all()
|
||
|
||
now = datetime.utcnow()
|
||
agents_data = []
|
||
|
||
for record in running_agents:
|
||
duration = (now - record.start_time).total_seconds() if record.start_time else 0
|
||
agents_data.append({
|
||
"agentName": record.agent_name,
|
||
"agentType": record.agent_type,
|
||
"isPlatformAgent": record.is_platform_agent,
|
||
"userId": str(record.user_id),
|
||
"startTime": record.start_time.isoformat() if record.start_time else None,
|
||
"runningSeconds": int(duration),
|
||
"currentEuConsumed": record.eu_consumed or 0,
|
||
"currentCost": float(record.cost or 0),
|
||
"cpuUsed": record.cpu_used,
|
||
"memoryUsed": record.memory_used,
|
||
})
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"total": len(agents_data),
|
||
"agents": agents_data
|
||
}
|
||
)
|
||
|
||
|
||
# ============= 计费(三维度) =============
|
||
|
||
@router.get("/billing/overview", response_model=SuccessResponse)
|
||
async def get_billing_overview(
|
||
startTime: Optional[str] = Query(None, description="开始时间(ISO 8601格式),不传则默认为当前时间前1个月"),
|
||
endTime: Optional[str] = Query(None, description="结束时间(ISO 8601格式),不传则默认为当前时间"),
|
||
channelName: Optional[str] = Query(None),
|
||
tenantName: Optional[str] = Query(None),
|
||
minCalls: Optional[int] = Query(None),
|
||
maxCalls: Optional[int] = Query(None),
|
||
export: Optional[str] = Query(None, pattern="^(excel|csv|pdf)$"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取三维度计费统计(所有管理员可查看)
|
||
|
||
从 AgentBillingRecord 和 ModelBillingRecord 表查询计费数据,
|
||
返回渠道维度、租户维度和调用记录三个维度的统计。
|
||
|
||
时间参数:
|
||
- 不传参数时,默认查询最近1个月(endTime=当前时间,startTime=当前时间-30天)
|
||
- 传参时按照传入的时间范围查询,格式为 ISO 8601(如:2026-02-10T09:43:48.048Z)
|
||
|
||
EU 计算规则:1 EU = 10 秒运行时间(向上取整)
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 处理时间参数:不传则使用默认值(最近1个月)
|
||
if endTime is None:
|
||
end_dt = datetime.utcnow()
|
||
else:
|
||
end_dt = datetime.fromisoformat(endTime.replace("Z", "+00:00"))
|
||
|
||
if startTime is None:
|
||
start_dt = end_dt - timedelta(days=30)
|
||
else:
|
||
start_dt = datetime.fromisoformat(startTime.replace("Z", "+00:00"))
|
||
|
||
# 转换为 naive datetime(移除时区信息)
|
||
if start_dt.tzinfo is not None:
|
||
start_dt = start_dt.replace(tzinfo=None)
|
||
if end_dt.tzinfo is not None:
|
||
end_dt = end_dt.replace(tzinfo=None)
|
||
|
||
# ========== 渠道统计(从 AgentBillingRecord 和 ModelBillingRecord 聚合) ==========
|
||
|
||
# 1. 从 AgentBillingRecord 统计 Agent 使用
|
||
agent_channel_stats = await db.execute(
|
||
select(
|
||
Channel.id,
|
||
Channel.name,
|
||
func.count(AgentBillingRecord.id).label("calls"),
|
||
func.sum(AgentBillingRecord.eu_consumed).label("total_eu"),
|
||
func.sum(AgentBillingRecord.cost).label("total_cost"),
|
||
)
|
||
.select_from(AgentBillingRecord)
|
||
.join(Channel, AgentBillingRecord.channel_id == Channel.id)
|
||
.where(
|
||
and_(
|
||
AgentBillingRecord.start_time >= start_dt,
|
||
AgentBillingRecord.start_time <= end_dt,
|
||
)
|
||
)
|
||
.group_by(Channel.id, Channel.name)
|
||
)
|
||
agent_channel_data = {str(row.id): {"name": row.name, "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in agent_channel_stats.all()}
|
||
|
||
# 2. 从 ModelBillingRecord 统计模型调用
|
||
model_channel_stats = await db.execute(
|
||
select(
|
||
Channel.id,
|
||
Channel.name,
|
||
func.count(ModelBillingRecord.id).label("calls"),
|
||
func.sum(ModelBillingRecord.eu_consumed).label("total_eu"),
|
||
func.sum(ModelBillingRecord.total_cost).label("total_cost"),
|
||
)
|
||
.select_from(ModelBillingRecord)
|
||
.join(Channel, ModelBillingRecord.channel_id == Channel.id)
|
||
.where(
|
||
and_(
|
||
ModelBillingRecord.start_time >= start_dt,
|
||
ModelBillingRecord.start_time <= end_dt,
|
||
)
|
||
)
|
||
.group_by(Channel.id, Channel.name)
|
||
)
|
||
model_channel_data = {str(row.id): {"name": row.name, "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in model_channel_stats.all()}
|
||
|
||
# 3. 合并渠道统计
|
||
all_channel_ids = set(agent_channel_data.keys()) | set(model_channel_data.keys())
|
||
channel_stats = []
|
||
for channel_id in all_channel_ids:
|
||
agent_data = agent_channel_data.get(channel_id, {"name": "", "calls": 0, "eu": 0, "cost": 0})
|
||
model_data = model_channel_data.get(channel_id, {"name": "", "calls": 0, "eu": 0, "cost": 0})
|
||
channel_name = agent_data["name"] or model_data["name"]
|
||
total_calls = agent_data["calls"] + model_data["calls"]
|
||
total_eu = agent_data["eu"] + model_data["eu"]
|
||
total_cost = agent_data["cost"] + model_data["cost"]
|
||
|
||
# 应用筛选条件
|
||
if channelName and channelName.lower() not in channel_name.lower():
|
||
continue
|
||
if minCalls is not None and total_calls < minCalls:
|
||
continue
|
||
if maxCalls is not None and total_calls > maxCalls:
|
||
continue
|
||
|
||
channel_stats.append({
|
||
"channelId": channel_id,
|
||
"channelName": channel_name,
|
||
"calls": total_calls,
|
||
"totalEU": round(total_eu, 2),
|
||
"totalCost": round(total_cost, 4),
|
||
})
|
||
|
||
# ========== 租户统计(从 AgentBillingRecord 和 ModelBillingRecord 聚合) ==========
|
||
|
||
# 1. 从 AgentBillingRecord 统计
|
||
agent_tenant_stats = await db.execute(
|
||
select(
|
||
User.id,
|
||
User.name,
|
||
Channel.name.label("channel_name"),
|
||
func.count(AgentBillingRecord.id).label("calls"),
|
||
func.sum(AgentBillingRecord.eu_consumed).label("total_eu"),
|
||
func.sum(AgentBillingRecord.cost).label("total_cost"),
|
||
)
|
||
.select_from(AgentBillingRecord)
|
||
.join(User, AgentBillingRecord.user_id == User.id)
|
||
.outerjoin(Channel, User.channel_id == Channel.id)
|
||
.where(
|
||
and_(
|
||
AgentBillingRecord.start_time >= start_dt,
|
||
AgentBillingRecord.start_time <= end_dt,
|
||
)
|
||
)
|
||
.group_by(User.id, User.name, Channel.name)
|
||
)
|
||
agent_tenant_data = {str(row.id): {"name": row.name, "channel_name": row.channel_name or "无渠道", "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in agent_tenant_stats.all()}
|
||
|
||
# 2. 从 ModelBillingRecord 统计
|
||
model_tenant_stats = await db.execute(
|
||
select(
|
||
User.id,
|
||
User.name,
|
||
Channel.name.label("channel_name"),
|
||
func.count(ModelBillingRecord.id).label("calls"),
|
||
func.sum(ModelBillingRecord.eu_consumed).label("total_eu"),
|
||
func.sum(ModelBillingRecord.total_cost).label("total_cost"),
|
||
)
|
||
.select_from(ModelBillingRecord)
|
||
.join(User, ModelBillingRecord.tenant_id == User.id)
|
||
.outerjoin(Channel, User.channel_id == Channel.id)
|
||
.where(
|
||
and_(
|
||
ModelBillingRecord.start_time >= start_dt,
|
||
ModelBillingRecord.start_time <= end_dt,
|
||
)
|
||
)
|
||
.group_by(User.id, User.name, Channel.name)
|
||
)
|
||
model_tenant_data = {str(row.id): {"name": row.name, "channel_name": row.channel_name or "无渠道", "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in model_tenant_stats.all()}
|
||
|
||
# 3. 合并租户统计
|
||
all_tenant_ids = set(agent_tenant_data.keys()) | set(model_tenant_data.keys())
|
||
tenant_stats = []
|
||
for tenant_id in all_tenant_ids:
|
||
agent_data = agent_tenant_data.get(tenant_id, {"name": "", "channel_name": "无渠道", "calls": 0, "eu": 0, "cost": 0})
|
||
model_data = model_tenant_data.get(tenant_id, {"name": "", "channel_name": "无渠道", "calls": 0, "eu": 0, "cost": 0})
|
||
tenant_name_val = agent_data["name"] or model_data["name"]
|
||
channel_name_val = agent_data["channel_name"] or model_data["channel_name"]
|
||
total_calls = agent_data["calls"] + model_data["calls"]
|
||
total_eu = agent_data["eu"] + model_data["eu"]
|
||
total_cost = agent_data["cost"] + model_data["cost"]
|
||
|
||
# 应用筛选条件
|
||
if tenantName and tenantName.lower() not in tenant_name_val.lower():
|
||
continue
|
||
if minCalls is not None and total_calls < minCalls:
|
||
continue
|
||
if maxCalls is not None and total_calls > maxCalls:
|
||
continue
|
||
|
||
# 计算平均消费
|
||
avg_cost = total_cost / total_calls if total_calls > 0 else 0
|
||
|
||
tenant_stats.append({
|
||
"tenantId": tenant_id,
|
||
"tenantName": tenant_name_val,
|
||
"channelName": channel_name_val,
|
||
"calls": total_calls,
|
||
"totalEU": round(total_eu, 2),
|
||
"totalCost": round(total_cost, 4),
|
||
"avgCost": round(avg_cost, 4),
|
||
})
|
||
|
||
# ========== 调用记录(合并 AgentBillingRecord 和 ModelBillingRecord) ==========
|
||
call_records = []
|
||
|
||
# 1. 从 AgentBillingRecord 获取记录
|
||
agent_records_result = await db.execute(
|
||
select(AgentBillingRecord, Channel.name.label("channel_name"), User.name.label("user_name"))
|
||
.outerjoin(Channel, AgentBillingRecord.channel_id == Channel.id)
|
||
.join(User, AgentBillingRecord.user_id == User.id)
|
||
.where(
|
||
and_(
|
||
AgentBillingRecord.start_time >= start_dt,
|
||
AgentBillingRecord.start_time <= end_dt,
|
||
)
|
||
)
|
||
.order_by(desc(AgentBillingRecord.start_time))
|
||
.limit(50)
|
||
)
|
||
|
||
for record, channel_name, user_name in agent_records_result.all():
|
||
call_records.append({
|
||
"id": str(record.id),
|
||
"type": "agent",
|
||
"timestamp": record.start_time.isoformat() if record.start_time else None,
|
||
"channelName": channel_name or "无渠道",
|
||
"tenantName": user_name,
|
||
"agentName": record.agent_name or "unknown", # ✅ 提供默认值
|
||
"modelName": record.model_name or "N/A", # ✅ 提供默认值
|
||
"duration": record.duration_seconds or 0,
|
||
"eu": record.eu_consumed or 0,
|
||
"cost": float(record.cost or 0),
|
||
})
|
||
|
||
# 2. 从 ModelBillingRecord 获取记录
|
||
model_records_result = await db.execute(
|
||
select(ModelBillingRecord, Channel.name.label("channel_name"), User.name.label("user_name"))
|
||
.outerjoin(Channel, ModelBillingRecord.channel_id == Channel.id)
|
||
.join(User, ModelBillingRecord.tenant_id == User.id)
|
||
.where(
|
||
and_(
|
||
ModelBillingRecord.start_time >= start_dt,
|
||
ModelBillingRecord.start_time <= end_dt,
|
||
)
|
||
)
|
||
.order_by(desc(ModelBillingRecord.start_time))
|
||
.limit(50)
|
||
)
|
||
|
||
for record, channel_name, user_name in model_records_result.all():
|
||
# 计算 duration(从 response_time_ms 转换为秒)
|
||
duration = (record.response_time_ms or 0) / 1000
|
||
call_records.append({
|
||
"id": str(record.id),
|
||
"type": "model",
|
||
"timestamp": record.start_time.isoformat() if record.start_time else record.created_at.isoformat(),
|
||
"channelName": channel_name or "无渠道",
|
||
"tenantName": user_name,
|
||
"agentName": None,
|
||
"modelName": record.model_name,
|
||
"duration": round(duration, 2),
|
||
"eu": float(record.eu_consumed or 0),
|
||
"cost": float(record.total_cost or 0),
|
||
"inputTokens": record.input_tokens,
|
||
"outputTokens": record.output_tokens,
|
||
"totalTokens": record.total_tokens,
|
||
})
|
||
|
||
# 3. 按时间排序并限制数量
|
||
call_records.sort(key=lambda x: x["timestamp"] or "", reverse=True)
|
||
call_records = call_records[:100]
|
||
|
||
# 如果是导出请求
|
||
if export:
|
||
# TODO: 实现实际的文件生成逻辑
|
||
# 1. 使用 openpyxl/pandas 生成 Excel 文件
|
||
# 2. 使用 csv 模块生成 CSV 文件
|
||
# 3. 使用 reportlab 生成 PDF 文件
|
||
# 4. 将文件保存到 /var/exports 或云存储
|
||
# 5. 返回实际的下载URL
|
||
|
||
# 当前为示例实现,返回占位URL
|
||
timestamp = datetime.utcnow().strftime('%Y%m%d%H%M%S')
|
||
filename = f"billing_{timestamp}.{export}"
|
||
|
||
# 实际应该调用导出服务生成文件
|
||
# from app.export_service import generate_billing_export
|
||
# file_path = await generate_billing_export(
|
||
# channel_stats, tenant_stats, call_records, export_format=export
|
||
# )
|
||
# file_url = f"{settings.export_base_url}/{file_path}"
|
||
|
||
file_url = f"/api/admin/billing/exports/{filename}"
|
||
expires_at = (datetime.utcnow() + timedelta(hours=24)).isoformat()
|
||
|
||
logger.info(f"生成计费导出文件请求: {filename}, 格式: {export}")
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"fileUrl": file_url,
|
||
"format": export,
|
||
"expiresAt": expires_at,
|
||
"message": "导出功能开发中,当前返回占位URL"
|
||
}
|
||
)
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"channelStats": channel_stats,
|
||
"tenantStats": tenant_stats,
|
||
"callRecords": call_records,
|
||
}
|
||
)
|
||
|
||
|
||
# ============= 供应商申请审批 =============
|
||
|
||
@router.get("/providers/applications", response_model=SuccessResponse)
|
||
async def list_provider_applications(
|
||
status_filter: Optional[str] = Query(None, alias="status", pattern="^(pending|approved|rejected)$"),
|
||
channel_id: Optional[str] = Query(None),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取所有渠道的供应商申请列表(管理员视图)
|
||
|
||
权限说明:
|
||
- 超级管理员:可查看所有渠道的供应商申请(拥有所有供应商权限)
|
||
- 计费管理员、运维管理员:可查看所有渠道的供应商申请(用于审批)
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 构建查询
|
||
query = select(ProviderApplication, Channel, ModelProvider).join(
|
||
Channel, ProviderApplication.channel_id == Channel.id
|
||
).join(
|
||
ModelProvider, ProviderApplication.provider_id == ModelProvider.id
|
||
)
|
||
|
||
if status_filter:
|
||
query = query.where(ProviderApplication.status == status_filter)
|
||
|
||
if channel_id:
|
||
query = query.where(ProviderApplication.channel_id == channel_id)
|
||
|
||
query = query.order_by(desc(ProviderApplication.created_at))
|
||
|
||
result = await db.execute(query)
|
||
|
||
data = []
|
||
for app, channel, provider in result.all():
|
||
data.append({
|
||
"id": str(app.id),
|
||
"channelId": str(app.channel_id),
|
||
"channelName": channel.name,
|
||
"providerId": str(app.provider_id),
|
||
"providerName": provider.name,
|
||
"providerType": provider.provider,
|
||
"requestedRpm": app.requested_rpm,
|
||
"requestedTpm": app.requested_tpm,
|
||
"reason": app.reason,
|
||
"status": app.status,
|
||
"createdAt": app.created_at.isoformat(),
|
||
"reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None,
|
||
"reviewReason": app.review_reason,
|
||
})
|
||
|
||
return SuccessResponse(data={"applications": data})
|
||
|
||
|
||
@router.put("/providers/applications/{application_id}/review", response_model=SuccessResponse)
|
||
async def review_provider_application(
|
||
application_id: str,
|
||
req: ReviewProviderApplicationRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
审批供应商使用申请
|
||
|
||
权限说明:
|
||
- 超级管理员:可审批所有渠道的供应商申请(拥有所有供应商权限)
|
||
- 计费管理员:可审批自己渠道的供应商申请
|
||
|
||
批准后会自动创建渠道供应商授权记录
|
||
"""
|
||
_verify_write_permission(principal)
|
||
user_id = principal.get("user_id")
|
||
|
||
# 查询申请
|
||
result = await db.execute(
|
||
select(ProviderApplication).where(ProviderApplication.id == application_id)
|
||
)
|
||
application = result.scalar_one_or_none()
|
||
|
||
if not application:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="申请不存在"
|
||
)
|
||
|
||
if application.status != "pending":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="该申请已被处理"
|
||
)
|
||
|
||
# 获取供应商信息
|
||
provider_result = await db.execute(
|
||
select(ModelProvider).where(ModelProvider.id == application.provider_id)
|
||
)
|
||
provider = provider_result.scalar_one_or_none()
|
||
|
||
if not provider:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="供应商不存在"
|
||
)
|
||
|
||
# 更新申请状态
|
||
application.status = "approved" if req.approved else "rejected"
|
||
application.reviewed_by = user_id
|
||
application.review_reason = req.reason
|
||
application.reviewed_at = datetime.utcnow()
|
||
|
||
# 如果批准,创建渠道供应商授权记录并更新 LiteLLM team
|
||
if req.approved:
|
||
# 获取渠道信息
|
||
channel_result = await db.execute(
|
||
select(Channel).where(Channel.id == application.channel_id)
|
||
)
|
||
channel = channel_result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 检查是否已存在授权
|
||
existing_result = await db.execute(
|
||
select(ChannelProviderAccess).where(
|
||
and_(
|
||
ChannelProviderAccess.channel_id == application.channel_id,
|
||
ChannelProviderAccess.provider_id == application.provider_id
|
||
)
|
||
)
|
||
)
|
||
existing = existing_result.scalar_one_or_none()
|
||
|
||
if existing:
|
||
# 更新现有授权
|
||
existing.status = "active"
|
||
existing.rpm_limit = req.rpmLimit or application.requested_rpm or provider.rpm
|
||
existing.tpm_limit = req.tpmLimit or application.requested_tpm or provider.tpm
|
||
existing.approved_by = user_id
|
||
existing.approved_at = datetime.utcnow()
|
||
else:
|
||
# 创建新授权
|
||
access = ChannelProviderAccess(
|
||
channel_id=application.channel_id,
|
||
provider_id=application.provider_id,
|
||
status="active",
|
||
rpm_limit=req.rpmLimit or application.requested_rpm or provider.rpm,
|
||
tpm_limit=req.tpmLimit or application.requested_tpm or provider.tpm,
|
||
approved_by=user_id,
|
||
approved_at=datetime.utcnow(),
|
||
)
|
||
db.add(access)
|
||
|
||
# 更新 LiteLLM team 的 models 列表(添加该供应商的所有模型)
|
||
if channel.litellm_team_id and provider.supported_models:
|
||
try:
|
||
from app.litellm_client import get_litellm_client, LiteLLMClientError
|
||
litellm_client = get_litellm_client()
|
||
|
||
# 获取渠道当前已分配的模型列表
|
||
current_models_result = await db.execute(
|
||
select(ResourceAllocation).where(
|
||
and_(
|
||
ResourceAllocation.target_id == application.channel_id,
|
||
ResourceAllocation.target_type == "channel",
|
||
ResourceAllocation.resource_type == "model"
|
||
)
|
||
)
|
||
)
|
||
current_models = {alloc.resource_id for alloc in current_models_result.scalars().all()}
|
||
|
||
# 添加供应商的模型(去重)
|
||
new_models = set(provider.supported_models)
|
||
all_models = list(current_models | new_models)
|
||
|
||
# 更新 LiteLLM team
|
||
await litellm_client.update_team(
|
||
team_id=channel.litellm_team_id,
|
||
models=all_models,
|
||
metadata={
|
||
"channel_id": str(channel.id),
|
||
"channel_name": channel.name,
|
||
"channel_email": channel.email,
|
||
"models_count": len(all_models),
|
||
"provider_ids": [str(application.provider_id)],
|
||
}
|
||
)
|
||
|
||
logger.info(f"渠道 {channel.name} 的 LiteLLM Team 模型列表已更新(批准供应商申请): {all_models}")
|
||
|
||
# 同时更新 ResourceAllocation 记录(追加模式)
|
||
for model_name in new_models:
|
||
if model_name not in current_models:
|
||
allocation = ResourceAllocation(
|
||
target_id=application.channel_id,
|
||
target_type="channel",
|
||
resource_type="model",
|
||
resource_id=model_name,
|
||
)
|
||
db.add(allocation)
|
||
logger.info(f"追加模型分配给渠道 {channel.name}: model={model_name}(来自供应商申请)")
|
||
|
||
except LiteLLMClientError as e:
|
||
logger.error(f"更新渠道 {channel.name} 的 LiteLLM Team 失败(批准供应商申请): {e}")
|
||
await db.rollback()
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"LiteLLM Team 更新失败,供应商申请审批已取消: {str(e)}"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"更新渠道 {channel.name} 的 LiteLLM 连接失败(批准供应商申请): {e}")
|
||
await db.rollback()
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail=f"无法连接 LiteLLM Gateway,供应商申请审批已取消: {str(e)}"
|
||
)
|
||
elif not channel.litellm_team_id:
|
||
# 渠道没有关联的 LiteLLM Team,无法批准申请
|
||
await db.rollback()
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="渠道没有关联的 LiteLLM Team,无法批准供应商申请。请先重新创建渠道或联系管理员"
|
||
)
|
||
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
message=f"供应商使用申请已{'批准' if req.approved else '拒绝'}"
|
||
)
|
||
|
||
|
||
@router.get("/providers/access", response_model=SuccessResponse)
|
||
async def list_all_provider_access(
|
||
channel_id: Optional[str] = Query(None),
|
||
provider_id: Optional[str] = Query(None),
|
||
status_filter: Optional[str] = Query(None, alias="status", pattern="^(active|suspended|expired)$"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取所有渠道供应商授权列表(管理员视图)
|
||
|
||
权限说明:
|
||
- 超级管理员:可查看所有渠道的供应商授权(拥有所有供应商权限)
|
||
- 计费管理员、运维管理员:可查看所有渠道的供应商授权
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 构建查询
|
||
query = select(ChannelProviderAccess, Channel, ModelProvider).join(
|
||
Channel, ChannelProviderAccess.channel_id == Channel.id
|
||
).join(
|
||
ModelProvider, ChannelProviderAccess.provider_id == ModelProvider.id
|
||
)
|
||
|
||
if channel_id:
|
||
query = query.where(ChannelProviderAccess.channel_id == channel_id)
|
||
|
||
if provider_id:
|
||
query = query.where(ChannelProviderAccess.provider_id == provider_id)
|
||
|
||
if status_filter:
|
||
query = query.where(ChannelProviderAccess.status == status_filter)
|
||
|
||
query = query.order_by(desc(ChannelProviderAccess.created_at))
|
||
|
||
result = await db.execute(query)
|
||
|
||
data = []
|
||
for access, channel, provider in result.all():
|
||
data.append({
|
||
"id": str(access.id),
|
||
"channelId": str(access.channel_id),
|
||
"channelName": channel.name,
|
||
"providerId": str(access.provider_id),
|
||
"providerName": provider.name,
|
||
"providerType": provider.provider,
|
||
"status": access.status,
|
||
"rpmLimit": access.rpm_limit,
|
||
"tpmLimit": access.tpm_limit,
|
||
"approvedAt": access.approved_at.isoformat() if access.approved_at else None,
|
||
"expiresAt": access.expires_at.isoformat() if access.expires_at else None,
|
||
"createdAt": access.created_at.isoformat(),
|
||
})
|
||
|
||
return SuccessResponse(data={"accessList": data})
|
||
|
||
|
||
@router.put("/providers/access/{access_id}", response_model=SuccessResponse)
|
||
async def update_provider_access(
|
||
access_id: str,
|
||
status_update: Optional[str] = Query(None, alias="status", pattern="^(active|suspended|expired)$"),
|
||
rpm_limit: Optional[int] = Query(None, ge=0),
|
||
tpm_limit: Optional[int] = Query(None, ge=0),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
更新渠道供应商授权
|
||
|
||
权限说明:
|
||
- 超级管理员:可更新所有渠道的供应商授权(拥有所有供应商权限)
|
||
- 计费管理员:可更新自己渠道的供应商授权
|
||
|
||
可用于暂停、恢复或更新渠道的供应商授权限制
|
||
"""
|
||
_verify_write_permission(principal)
|
||
|
||
# 查询授权
|
||
result = await db.execute(
|
||
select(ChannelProviderAccess).where(ChannelProviderAccess.id == access_id)
|
||
)
|
||
access = result.scalar_one_or_none()
|
||
|
||
if not access:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="授权记录不存在"
|
||
)
|
||
|
||
# 更新字段
|
||
if status_update:
|
||
access.status = status_update
|
||
if rpm_limit is not None:
|
||
access.rpm_limit = rpm_limit
|
||
if tpm_limit is not None:
|
||
access.tpm_limit = tpm_limit
|
||
|
||
await db.commit()
|
||
|
||
return SuccessResponse(message="授权信息已更新")
|
||
|
||
|
||
@router.delete("/providers/access/{access_id}", response_model=SuccessResponse)
|
||
async def revoke_provider_access(
|
||
access_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
撤销渠道供应商授权
|
||
|
||
权限说明:
|
||
- 超级管理员:可撤销所有渠道的供应商授权(拥有所有供应商权限)
|
||
- 计费管理员:可撤销自己渠道的供应商授权
|
||
|
||
将授权状态设为suspended
|
||
"""
|
||
_verify_write_permission(principal)
|
||
|
||
# 查询授权
|
||
result = await db.execute(
|
||
select(ChannelProviderAccess).where(ChannelProviderAccess.id == access_id)
|
||
)
|
||
access = result.scalar_one_or_none()
|
||
|
||
if not access:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="授权记录不存在"
|
||
)
|
||
|
||
# 软删除:标记为suspended
|
||
access.status = "suspended"
|
||
await db.commit()
|
||
|
||
return SuccessResponse(message="授权已撤销")
|
||
|
||
|
||
# ============= 渠道管理员 =============
|
||
|
||
@router.get("/channels/{channel_id}/admins", response_model=SuccessResponse)
|
||
async def get_channel_admins(
|
||
channel_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取渠道管理员列表
|
||
|
||
返回指定渠道下的所有管理员用户(包括channel_admin、billing_admin、operations_admin)
|
||
|
||
注意:
|
||
- 超级管理员可以查看任何渠道的管理员
|
||
- 计费管理员和运维管理员只能查看自己渠道的管理员
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 验证渠道存在
|
||
channel_uuid = uuid.UUID(channel_id)
|
||
result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_uuid)
|
||
)
|
||
channel = result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 如果是渠道下的管理员,验证只能查看自己渠道的数据
|
||
role = _get_role(principal)
|
||
if role in ["billing_admin", "operations_admin"]:
|
||
user_channel_id = _get_channel_id(principal)
|
||
if user_channel_id and user_channel_id != channel_uuid:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="只能查看自己渠道的管理员"
|
||
)
|
||
|
||
# 查询渠道下的所有管理员(channel_admin、billing_admin、operations_admin)
|
||
result = await db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.channel_id == channel_uuid,
|
||
User.role.in_(["channel_admin", "billing_admin", "operations_admin"]),
|
||
User.status == "active"
|
||
)
|
||
)
|
||
)
|
||
admins = result.scalars().all()
|
||
|
||
data = [
|
||
{
|
||
"id": str(admin.id),
|
||
"name": admin.name or admin.full_name,
|
||
"email": admin.email,
|
||
"role": admin.role,
|
||
"status": admin.status,
|
||
"createdAt": admin.created_at.isoformat() if admin.created_at else None,
|
||
}
|
||
for admin in admins
|
||
]
|
||
|
||
return SuccessResponse(data={
|
||
"channelId": str(channel.id),
|
||
"channelName": channel.name,
|
||
"admins": data
|
||
})
|
||
|
||
|
||
@router.get("/roles", response_model=SuccessResponse)
|
||
async def get_admin_roles(
|
||
principal: dict = Depends(require_auth),
|
||
):
|
||
"""
|
||
获取可用角色列表
|
||
|
||
返回系统中所有可用的角色类型及其描述
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
roles = [
|
||
{
|
||
"id": "super_admin",
|
||
"name": "超级管理员",
|
||
"description": "拥有系统所有权限",
|
||
"permissions": ["*"]
|
||
},
|
||
{
|
||
"id": "billing_admin",
|
||
"name": "计费管理员",
|
||
"description": "完整写入权限,可创建渠道、管理租户、计费操作",
|
||
"permissions": ["read:*", "write:channels", "write:tenants", "write:billing"]
|
||
},
|
||
{
|
||
"id": "operations_admin",
|
||
"name": "运维管理员",
|
||
"description": "只读权限,仅查看和监控",
|
||
"permissions": ["read:*"]
|
||
},
|
||
{
|
||
"id": "channel_admin",
|
||
"name": "渠道管理员",
|
||
"description": "渠道内部管理权限",
|
||
"permissions": ["read:channel", "write:tenants", "read:billing"]
|
||
},
|
||
{
|
||
"id": "user",
|
||
"name": "普通用户",
|
||
"description": "标准用户权限",
|
||
"permissions": ["read:self", "use:agents"]
|
||
}
|
||
]
|
||
|
||
return SuccessResponse(data={"roles": roles})
|
||
|
||
|
||
# ============= 平台 Agent 资源申请审批 =============
|
||
|
||
# 模板资源配置建议
|
||
TEMPLATE_RESOURCE_CONFIG: Dict[str, Dict[str, str]] = {
|
||
"echo_agent": {
|
||
"cpuRequest": "100m",
|
||
"cpuLimit": "500m",
|
||
"memoryRequest": "128Mi",
|
||
"memoryLimit": "512Mi",
|
||
},
|
||
"chat_agent": {
|
||
"cpuRequest": "200m",
|
||
"cpuLimit": "1000m",
|
||
"memoryRequest": "256Mi",
|
||
"memoryLimit": "1Gi",
|
||
},
|
||
"code_agent": {
|
||
"cpuRequest": "500m",
|
||
"cpuLimit": "2000m",
|
||
"memoryRequest": "512Mi",
|
||
"memoryLimit": "2Gi",
|
||
},
|
||
"search_agent": {
|
||
"cpuRequest": "200m",
|
||
"cpuLimit": "1000m",
|
||
"memoryRequest": "256Mi",
|
||
"memoryLimit": "1Gi",
|
||
},
|
||
"jina_search_agent": {
|
||
"cpuRequest": "500m",
|
||
"cpuLimit": "2000m",
|
||
"memoryRequest": "1Gi",
|
||
"memoryLimit": "4Gi",
|
||
},
|
||
"mysql_agent": {
|
||
"cpuRequest": "100m",
|
||
"cpuLimit": "500m",
|
||
"memoryRequest": "128Mi",
|
||
"memoryLimit": "512Mi",
|
||
},
|
||
"postgresql_agent": {
|
||
"cpuRequest": "100m",
|
||
"cpuLimit": "500m",
|
||
"memoryRequest": "128Mi",
|
||
"memoryLimit": "512Mi",
|
||
},
|
||
}
|
||
|
||
|
||
async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession] = None) -> List[Dict[str, Any]]:
|
||
"""从 Agent Manager 获取平台 Agent 模板列表
|
||
|
||
Args:
|
||
db: 数据库会话,用于读取管理员配置。如果提供,将从数据库读取配置并覆盖默认值。
|
||
"""
|
||
# 从数据库获取管理员配置
|
||
db_configs: Dict[str, PlatformAgentTemplateConfig] = {}
|
||
if db:
|
||
try:
|
||
config_result = await db.execute(select(PlatformAgentTemplateConfig))
|
||
db_configs = {c.template_name: c for c in config_result.scalars().all()}
|
||
logger.debug(f"admin: 从数据库加载了 {len(db_configs)} 个模板配置")
|
||
except Exception as e:
|
||
logger.warning(f"admin: 从数据库加载模板配置失败: {e}")
|
||
|
||
try:
|
||
from app.agent_manager_client import get_agent_manager_client, AgentManagerError
|
||
client = get_agent_manager_client()
|
||
templates = await client.list_platform_templates()
|
||
|
||
result = []
|
||
for template in templates:
|
||
template_name = template.template
|
||
default_resource_config = TEMPLATE_RESOURCE_CONFIG.get(template_name, {})
|
||
|
||
# 优先使用 Agent Manager 返回的 displayName 和 description
|
||
agent_manager_display_name = template.display_name
|
||
agent_manager_description = template.description
|
||
agent_manager_category = template.category
|
||
|
||
# 优先使用数据库中的管理员配置,否则使用 Agent Manager 返回值
|
||
db_config = db_configs.get(template_name)
|
||
if db_config:
|
||
# 使用数据库配置覆盖默认值
|
||
result.append({
|
||
"name": template_name,
|
||
"displayName": db_config.display_name or agent_manager_display_name or template_name,
|
||
"description": db_config.description or agent_manager_description or f"{template_name} Agent",
|
||
"category": agent_manager_category or "general",
|
||
"version": "1.0.0",
|
||
"port": template.port,
|
||
"envInfo": template.env_info,
|
||
"cpuRequest": db_config.cpu_request or default_resource_config.get("cpuRequest", "100m"),
|
||
"cpuLimit": db_config.cpu_limit or default_resource_config.get("cpuLimit", "500m"),
|
||
"memoryRequest": db_config.memory_request or default_resource_config.get("memoryRequest", "128Mi"),
|
||
"memoryLimit": db_config.memory_limit or default_resource_config.get("memoryLimit", "512Mi"),
|
||
"maxPods": db_config.max_pods if db_config.max_pods is not None else 10,
|
||
"isEnabled": db_config.is_enabled if db_config.is_enabled is not None else True,
|
||
"status": "available" if (db_config.is_enabled is None or db_config.is_enabled) else "disabled",
|
||
})
|
||
else:
|
||
# 使用 Agent Manager 返回值和默认资源配置
|
||
result.append({
|
||
"name": template_name,
|
||
"displayName": agent_manager_display_name or template_name,
|
||
"description": agent_manager_description or f"{template_name} Agent",
|
||
"category": agent_manager_category or "general",
|
||
"version": "1.0.0",
|
||
"port": template.port,
|
||
"envInfo": template.env_info,
|
||
"cpuRequest": default_resource_config.get("cpuRequest", "100m"),
|
||
"cpuLimit": default_resource_config.get("cpuLimit", "500m"),
|
||
"memoryRequest": default_resource_config.get("memoryRequest", "128Mi"),
|
||
"memoryLimit": default_resource_config.get("memoryLimit", "512Mi"),
|
||
"maxPods": 10,
|
||
"isEnabled": True,
|
||
"status": "available",
|
||
})
|
||
|
||
logger.info("admin: 从 Agent Manager 获取平台模板成功", count=len(result))
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error("admin: 从 Agent Manager 获取平台模板失败", error=str(e))
|
||
# Agent Manager 不可用时,返回空列表
|
||
return []
|
||
|
||
|
||
async def _validate_template_exists(template_name: str) -> bool:
|
||
"""验证模板是否存在"""
|
||
try:
|
||
from app.agent_manager_client import get_agent_manager_client
|
||
client = get_agent_manager_client()
|
||
templates = await client.list_platform_templates()
|
||
return any(t.template == template_name for t in templates)
|
||
except Exception as e:
|
||
logger.error("admin: 验证模板失败", error=str(e))
|
||
return False
|
||
|
||
|
||
@router.get("/platform-agents/templates", response_model=SuccessResponse)
|
||
async def list_platform_agent_templates(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取平台 Agent 模板列表
|
||
|
||
从 Agent Manager 动态获取所有可用的平台 Agent 模板信息。
|
||
如果管理员通过配置接口修改了模板配置,将返回修改后的配置值。
|
||
|
||
权限:view:resources (所有管理员)
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 传递数据库会话以读取管理员配置
|
||
templates = await _get_platform_templates_from_agent_manager(db)
|
||
|
||
return SuccessResponse(data={"templates": templates})
|
||
|
||
|
||
@router.get("/applications/platform-agents", response_model=SuccessResponse)
|
||
async def list_platform_agent_applications(
|
||
status_filter: Optional[str] = Query(None, alias="status", pattern="^(pending|approved|rejected)$"),
|
||
channel_id: Optional[str] = Query(None),
|
||
limit: int = Query(10, ge=1, le=50, description="返回数量,默认10条,最多50条"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
获取平台 Agent 申请列表(管理员视图)
|
||
|
||
默认优先返回未审批(pending)的申请,如果未审批数量不足则用已审批的填充。
|
||
如果指定了 status 参数,则只返回该状态的申请。
|
||
|
||
权限:view:applications (所有管理员)
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
data = []
|
||
|
||
# 如果指定了状态筛选,直接按该状态查询
|
||
if status_filter:
|
||
query = select(ResourceApplication, Channel).join(
|
||
Channel, ResourceApplication.channel_id == Channel.id
|
||
).where(
|
||
and_(
|
||
ResourceApplication.resource_type == "platform_agent",
|
||
ResourceApplication.status == status_filter
|
||
)
|
||
)
|
||
|
||
if channel_id:
|
||
query = query.where(ResourceApplication.channel_id == channel_id)
|
||
|
||
query = query.order_by(desc(ResourceApplication.created_at)).limit(limit)
|
||
|
||
result = await db.execute(query)
|
||
|
||
for app, channel in result.all():
|
||
data.append({
|
||
"id": str(app.id),
|
||
"channelId": str(app.channel_id),
|
||
"channelName": channel.name,
|
||
"resourceType": app.resource_type,
|
||
"templateName": app.template_name,
|
||
"templateDisplayName": app.template_name,
|
||
"requestedPodQuota": app.requested_pod_quota,
|
||
"approvedPodQuota": app.approved_pod_quota,
|
||
"reason": app.reason,
|
||
"status": app.status,
|
||
"reviewReason": app.review_reason,
|
||
"reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None,
|
||
"createdAt": app.created_at.isoformat(),
|
||
})
|
||
else:
|
||
# 未指定状态筛选时,优先返回 pending 状态的申请
|
||
# 1. 先查询 pending 状态的申请(最多 limit 条)
|
||
pending_query = select(ResourceApplication, Channel).join(
|
||
Channel, ResourceApplication.channel_id == Channel.id
|
||
).where(
|
||
and_(
|
||
ResourceApplication.resource_type == "platform_agent",
|
||
ResourceApplication.status == "pending"
|
||
)
|
||
)
|
||
|
||
if channel_id:
|
||
pending_query = pending_query.where(ResourceApplication.channel_id == channel_id)
|
||
|
||
pending_query = pending_query.order_by(desc(ResourceApplication.created_at)).limit(limit)
|
||
|
||
pending_result = await db.execute(pending_query)
|
||
pending_apps = pending_result.all()
|
||
|
||
for app, channel in pending_apps:
|
||
data.append({
|
||
"id": str(app.id),
|
||
"channelId": str(app.channel_id),
|
||
"channelName": channel.name,
|
||
"resourceType": app.resource_type,
|
||
"templateName": app.template_name,
|
||
"templateDisplayName": app.template_name,
|
||
"requestedPodQuota": app.requested_pod_quota,
|
||
"approvedPodQuota": app.approved_pod_quota,
|
||
"reason": app.reason,
|
||
"status": app.status,
|
||
"reviewReason": app.review_reason,
|
||
"reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None,
|
||
"createdAt": app.created_at.isoformat(),
|
||
})
|
||
|
||
# 2. 如果 pending 数量不足 limit,用已审批的(approved/rejected)填充
|
||
remaining = limit - len(data)
|
||
if remaining > 0:
|
||
reviewed_query = select(ResourceApplication, Channel).join(
|
||
Channel, ResourceApplication.channel_id == Channel.id
|
||
).where(
|
||
and_(
|
||
ResourceApplication.resource_type == "platform_agent",
|
||
ResourceApplication.status.in_(["approved", "rejected"])
|
||
)
|
||
)
|
||
|
||
if channel_id:
|
||
reviewed_query = reviewed_query.where(ResourceApplication.channel_id == channel_id)
|
||
|
||
reviewed_query = reviewed_query.order_by(desc(ResourceApplication.created_at)).limit(remaining)
|
||
|
||
reviewed_result = await db.execute(reviewed_query)
|
||
|
||
for app, channel in reviewed_result.all():
|
||
data.append({
|
||
"id": str(app.id),
|
||
"channelId": str(app.channel_id),
|
||
"channelName": channel.name,
|
||
"resourceType": app.resource_type,
|
||
"templateName": app.template_name,
|
||
"templateDisplayName": app.template_name,
|
||
"requestedPodQuota": app.requested_pod_quota,
|
||
"approvedPodQuota": app.approved_pod_quota,
|
||
"reason": app.reason,
|
||
"status": app.status,
|
||
"reviewReason": app.review_reason,
|
||
"reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None,
|
||
"createdAt": app.created_at.isoformat(),
|
||
})
|
||
|
||
return SuccessResponse(data={"applications": data})
|
||
|
||
|
||
@router.put("/applications/platform-agents/{application_id}/review", response_model=SuccessResponse)
|
||
async def review_platform_agent_application(
|
||
application_id: str,
|
||
req: ReviewResourceApplicationRequest,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
审批平台 Agent 申请
|
||
|
||
批准后会自动创建渠道的平台 Agent 配额记录。
|
||
|
||
支持两种请求格式:
|
||
1. 新格式(前端使用): {"action": "approve", "podQuota": 5, "reviewReason": "申请已批准"}
|
||
2. 旧格式: {"approved": true, "approvedPodQuota": 5, "reason": "申请已批准"}
|
||
|
||
权限:manage:applications (super_admin, billing_admin)
|
||
"""
|
||
_verify_write_permission(principal)
|
||
user_id = principal.get("claims", {}).get("sub")
|
||
|
||
# 查询申请
|
||
result = await db.execute(
|
||
select(ResourceApplication).where(
|
||
and_(
|
||
ResourceApplication.id == application_id,
|
||
ResourceApplication.resource_type == "platform_agent"
|
||
)
|
||
)
|
||
)
|
||
application = result.scalar_one_or_none()
|
||
|
||
if not application:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="申请不存在"
|
||
)
|
||
|
||
if application.status != "pending":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="该申请已被处理"
|
||
)
|
||
|
||
# 使用 schema 方法获取值(支持新旧两种格式)
|
||
try:
|
||
is_approved = req.get_approved()
|
||
except ValueError as e:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=str(e)
|
||
)
|
||
|
||
review_reason = req.get_reason()
|
||
pod_quota = req.get_pod_quota()
|
||
|
||
# 更新申请状态
|
||
application.status = "approved" if is_approved else "rejected"
|
||
application.reviewed_by = user_id
|
||
application.review_reason = review_reason
|
||
application.reviewed_at = datetime.utcnow()
|
||
|
||
# 如果批准,创建渠道的平台 Agent 配额记录
|
||
if is_approved:
|
||
approved_quota = pod_quota or application.requested_pod_quota
|
||
application.approved_pod_quota = approved_quota
|
||
|
||
# 检查是否已存在配额记录
|
||
existing_result = await db.execute(
|
||
select(PlatformAgentQuota).where(
|
||
and_(
|
||
PlatformAgentQuota.target_id == application.channel_id,
|
||
PlatformAgentQuota.target_type == "channel",
|
||
PlatformAgentQuota.template_name == application.template_name
|
||
)
|
||
)
|
||
)
|
||
existing = existing_result.scalar_one_or_none()
|
||
|
||
if existing:
|
||
# 更新现有配额(累加)
|
||
existing.pod_quota = existing.pod_quota + approved_quota
|
||
else:
|
||
# 创建新配额记录
|
||
quota = PlatformAgentQuota(
|
||
target_id=application.channel_id,
|
||
target_type="channel",
|
||
template_name=application.template_name,
|
||
pod_quota=approved_quota,
|
||
pod_used=0,
|
||
allocated_by=user_id,
|
||
allocated_at=datetime.utcnow(),
|
||
)
|
||
db.add(quota)
|
||
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
message=f"平台 Agent 申请已{'批准' if is_approved else '拒绝'}"
|
||
)
|
||
|
||
|
||
@router.get("/platform-agents/allocations", response_model=SuccessResponse)
|
||
async def list_platform_agent_allocations(
|
||
channel_id: Optional[str] = Query(None),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
查看平台 Agent 分配情况
|
||
|
||
返回所有渠道的平台 Agent 配额分配情况。
|
||
|
||
权限:view:resources (所有管理员)
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
# 构建查询
|
||
query = select(PlatformAgentQuota, Channel).outerjoin(
|
||
Channel, and_(
|
||
PlatformAgentQuota.target_id == Channel.id,
|
||
PlatformAgentQuota.target_type == "channel"
|
||
)
|
||
).where(
|
||
PlatformAgentQuota.target_type == "channel"
|
||
)
|
||
|
||
if channel_id:
|
||
query = query.where(PlatformAgentQuota.target_id == channel_id)
|
||
|
||
result = await db.execute(query)
|
||
|
||
# 查询所有模板配置,用于获取管理员设置的CPU和内存
|
||
template_configs_result = await db.execute(select(PlatformAgentTemplateConfig))
|
||
template_configs = {config.template_name: config for config in template_configs_result.scalars().all()}
|
||
|
||
data = []
|
||
for quota, channel in result.all():
|
||
# 从模板配置中获取管理员设置的CPU和内存限制
|
||
template_config = template_configs.get(quota.template_name)
|
||
cpu_limit = template_config.cpu_limit if template_config and template_config.cpu_limit else "100m"
|
||
memory_limit = template_config.memory_limit if template_config and template_config.memory_limit else "256Mi"
|
||
|
||
data.append({
|
||
"id": str(quota.id),
|
||
"channelId": str(quota.target_id),
|
||
"channelName": channel.name if channel else "未知",
|
||
"templateName": quota.template_name,
|
||
"templateDisplayName": quota.template_name,
|
||
"podQuota": quota.pod_quota,
|
||
"podUsed": quota.pod_used,
|
||
"podRemaining": quota.pod_quota - quota.pod_used,
|
||
"cpuLimit": cpu_limit,
|
||
"memoryLimit": memory_limit,
|
||
"allocatedAt": quota.allocated_at.isoformat() if quota.allocated_at else None,
|
||
})
|
||
|
||
return SuccessResponse(data={"allocations": data})
|
||
|
||
|
||
@router.post("/platform-agents/allocate", response_model=SuccessResponse)
|
||
async def allocate_platform_agent_to_channel(
|
||
channel_id: str = Query(..., description="渠道 ID"),
|
||
template_name: str = Query(..., description="模板名称"),
|
||
pod_quota: int = Query(..., ge=1, description="Pod 配额"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
直接给渠道分配平台 Agent 配额(无需申请审批)
|
||
|
||
管理员可以直接给渠道分配平台 Agent 配额,无需渠道提交申请。
|
||
|
||
权限:manage:resources (super_admin, billing_admin)
|
||
"""
|
||
_verify_write_permission(principal)
|
||
user_id = principal.get("claims", {}).get("sub")
|
||
|
||
# 验证渠道存在
|
||
channel_uuid = uuid.UUID(channel_id)
|
||
channel_result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_uuid)
|
||
)
|
||
channel = channel_result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 验证模板存在
|
||
template_exists = await _validate_template_exists(template_name)
|
||
if not template_exists:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"平台 Agent 模板 '{template_name}' 不存在"
|
||
)
|
||
|
||
# 查找或创建配额记录
|
||
quota_result = await db.execute(
|
||
select(PlatformAgentQuota).where(
|
||
and_(
|
||
PlatformAgentQuota.target_id == channel_uuid,
|
||
PlatformAgentQuota.target_type == "channel",
|
||
PlatformAgentQuota.template_name == template_name
|
||
)
|
||
)
|
||
)
|
||
quota = quota_result.scalar_one_or_none()
|
||
|
||
if quota:
|
||
# 追加模式:在现有配额基础上增加
|
||
old_quota = quota.pod_quota or 0
|
||
quota.pod_quota = old_quota + pod_quota
|
||
quota.allocated_by = user_id
|
||
quota.allocated_at = datetime.utcnow()
|
||
message = f"平台 Agent 配额已追加({old_quota} + {pod_quota} = {quota.pod_quota})"
|
||
logger.info(
|
||
f"追加平台 Agent 配额给渠道 {channel.name}: "
|
||
f"template={template_name}, 追加量={pod_quota}, 新配额={quota.pod_quota}"
|
||
)
|
||
else:
|
||
# 创建新配额记录
|
||
quota = PlatformAgentQuota(
|
||
target_id=channel_uuid,
|
||
target_type="channel",
|
||
template_name=template_name,
|
||
pod_quota=pod_quota,
|
||
pod_used=0,
|
||
allocated_by=user_id,
|
||
allocated_at=datetime.utcnow(),
|
||
)
|
||
db.add(quota)
|
||
message = "平台 Agent 配额分配成功"
|
||
logger.info(
|
||
f"创建平台 Agent 配额给渠道 {channel.name}: "
|
||
f"template={template_name}, quota={pod_quota}"
|
||
)
|
||
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"channelId": str(channel_uuid),
|
||
"channelName": channel.name,
|
||
"templateName": template_name,
|
||
"templateDisplayName": template_name,
|
||
"podQuota": pod_quota,
|
||
},
|
||
message=message
|
||
)
|
||
|
||
|
||
@router.delete("/platform-agents/allocate", response_model=SuccessResponse)
|
||
async def revoke_platform_agent_from_channel(
|
||
channel_id: str = Query(..., description="渠道 ID"),
|
||
template_name: str = Query(..., description="模板名称"),
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
撤销渠道的平台 Agent 配额
|
||
|
||
管理员可以撤销渠道的平台 Agent 配额。
|
||
|
||
注意:如果渠道已将配额分配给租户,需要先回收租户配额。
|
||
|
||
权限:manage:resources (super_admin, billing_admin)
|
||
"""
|
||
_verify_write_permission(principal)
|
||
|
||
# 验证渠道存在
|
||
channel_uuid = uuid.UUID(channel_id)
|
||
channel_result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_uuid)
|
||
)
|
||
channel = channel_result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 查找配额记录
|
||
quota_result = await db.execute(
|
||
select(PlatformAgentQuota).where(
|
||
and_(
|
||
PlatformAgentQuota.target_id == channel_uuid,
|
||
PlatformAgentQuota.target_type == "channel",
|
||
PlatformAgentQuota.template_name == template_name
|
||
)
|
||
)
|
||
)
|
||
quota = quota_result.scalar_one_or_none()
|
||
|
||
if not quota:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="配额记录不存在"
|
||
)
|
||
|
||
# 检查是否有正在使用的配额
|
||
if quota.pod_used > 0:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"渠道正在使用 {quota.pod_used} 个 Pod,无法撤销配额"
|
||
)
|
||
|
||
# 删除配额记录
|
||
await db.delete(quota)
|
||
await db.commit()
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"channelId": str(channel_uuid),
|
||
"channelName": channel.name,
|
||
"templateName": template_name,
|
||
},
|
||
message="平台 Agent 配额已撤销"
|
||
)
|
||
|
||
|
||
@router.get("/platform-agents/status", response_model=SuccessResponse)
|
||
async def get_platform_agents_status(
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
查看平台 Agent 运行状态
|
||
|
||
从 agent-manager 获取所有平台 Agent 的运行状态。
|
||
|
||
权限:view:resources (所有管理员)
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
agents_data = []
|
||
|
||
try:
|
||
from app.agent_manager_client import get_agent_manager_client
|
||
client = get_agent_manager_client()
|
||
k8s_agents_result = await client.list_agents()
|
||
k8s_agents = k8s_agents_result.agents # 从 AgentListResult 中获取 agents 列表
|
||
|
||
for agent in k8s_agents:
|
||
agent_name = agent.get("name", "")
|
||
k8s_status = agent.get("status", "unknown")
|
||
|
||
# 获取资源使用情况
|
||
# 实时使用量(从 metrics-server 获取)
|
||
cpu_usage_current = "0"
|
||
memory_usage_current = "0"
|
||
# 资源限制(从 Pod spec 获取)
|
||
cpu_limit = "0"
|
||
memory_limit = "0"
|
||
# 是否有实时 metrics 数据
|
||
has_realtime_metrics = False
|
||
metrics_timestamp = None
|
||
|
||
try:
|
||
metrics = await client.get_agent_metrics(agent_name)
|
||
# 资源限制
|
||
cpu_limit = metrics.cpu_limit
|
||
memory_limit = metrics.memory_limit
|
||
|
||
# 实时使用量(需要 metrics-server)
|
||
if metrics.has_realtime_metrics:
|
||
has_realtime_metrics = True
|
||
cpu_usage_current = metrics.cpu_usage_current
|
||
memory_usage_current = metrics.memory_usage_current
|
||
metrics_timestamp = metrics.timestamp
|
||
else:
|
||
# 没有实时数据时,显示 N/A
|
||
cpu_usage_current = "N/A"
|
||
memory_usage_current = "N/A"
|
||
except Exception as e:
|
||
logger.debug(f"获取 Agent {agent_name} 资源指标失败: {e}")
|
||
|
||
agents_data.append({
|
||
"name": agent_name,
|
||
"template": agent.get("template", ""),
|
||
"status": k8s_status,
|
||
"podName": agent.get("pod_name", ""),
|
||
"podIp": agent.get("pod_ip", ""),
|
||
"namespace": agent.get("namespace", "ai-agents"),
|
||
# 实时 CPU/内存使用量(从 metrics-server 获取)
|
||
"cpuUsage": _format_cpu_usage(cpu_usage_current) if cpu_usage_current != "N/A" else "N/A",
|
||
"memoryUsage": _format_memory_usage(memory_usage_current) if memory_usage_current != "N/A" else "N/A",
|
||
# 资源限制(从 Pod spec 获取)
|
||
"cpuLimit": cpu_limit,
|
||
"memoryLimit": memory_limit,
|
||
# 是否有实时 metrics 数据
|
||
"hasRealtimeMetrics": has_realtime_metrics,
|
||
"metricsTimestamp": metrics_timestamp,
|
||
"createdAt": agent.get("created_at"),
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.warning(f"连接 agent-manager 失败: {e}")
|
||
# 回退到数据库查询
|
||
result = await db.execute(
|
||
select(Agent).where(
|
||
and_(
|
||
Agent.type == "platform",
|
||
Agent.status != "inactive"
|
||
)
|
||
)
|
||
)
|
||
db_agents = result.scalars().all()
|
||
|
||
for agent in db_agents:
|
||
agents_data.append({
|
||
"name": agent.name,
|
||
"template": agent.template,
|
||
"status": agent.k8s_status or agent.status,
|
||
"podName": agent.pod_name,
|
||
"podIp": agent.pod_ip,
|
||
"namespace": agent.k8s_namespace,
|
||
"cpuUsage": 0.0,
|
||
"memoryUsage": 0.0,
|
||
"createdAt": agent.created_at.isoformat() if agent.created_at else None,
|
||
})
|
||
|
||
# 统计
|
||
summary = {
|
||
"total": len(agents_data),
|
||
"running": len([a for a in agents_data if a["status"] == "Running"]),
|
||
"pending": len([a for a in agents_data if a["status"] in ["Pending", "ContainerCreating"]]),
|
||
"error": len([a for a in agents_data if a["status"] in ["Failed", "Error", "CrashLoopBackOff"]]),
|
||
}
|
||
|
||
return SuccessResponse(data={
|
||
"agents": agents_data,
|
||
"summary": summary,
|
||
})
|
||
|
||
|
||
# ============= 渠道租户资源分配查看 =============
|
||
|
||
@router.get("/channels/{channel_id}/tenants/resources", response_model=SuccessResponse)
|
||
async def get_channel_tenants_resources(
|
||
channel_id: str,
|
||
principal: dict = Depends(require_auth),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
查看渠道下所有租户被分配的资源
|
||
|
||
返回渠道下每个租户的:
|
||
- 自定义 Agent 配额(cpu_quota, memory_quota, cpu_used, memory_used, agent_count)
|
||
- 平台 Agent 配额(各模板的 pod_quota, pod_used)
|
||
- 模型配额(各模型的 rpm, tpm)
|
||
|
||
权限:view:tenants (super_admin, billing_admin, operations_admin, channel_admin)
|
||
|
||
渠道管理员只能查看自己渠道的数据
|
||
"""
|
||
_verify_read_permission(principal)
|
||
|
||
role = _get_role(principal)
|
||
channel_admin_channel_id = _get_channel_id(principal)
|
||
|
||
# 验证渠道ID格式
|
||
try:
|
||
channel_uuid = uuid.UUID(channel_id)
|
||
except ValueError:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="无效的渠道ID格式"
|
||
)
|
||
|
||
# 渠道管理员只能查看自己渠道的数据
|
||
if role in ["channel_admin", "billing_admin", "operations_admin"] and channel_admin_channel_id:
|
||
if channel_uuid != channel_admin_channel_id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="只能查看所属渠道的租户资源"
|
||
)
|
||
|
||
# 验证渠道存在
|
||
channel_result = await db.execute(
|
||
select(Channel).where(Channel.id == channel_uuid)
|
||
)
|
||
channel = channel_result.scalar_one_or_none()
|
||
|
||
if not channel:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="渠道不存在"
|
||
)
|
||
|
||
# 获取渠道下所有租户
|
||
tenants_result = await db.execute(
|
||
select(User).where(User.channel_id == channel_uuid)
|
||
)
|
||
tenants = tenants_result.scalars().all()
|
||
|
||
# 构建租户资源数据
|
||
tenants_resources = []
|
||
|
||
for tenant in tenants:
|
||
tenant_data = {
|
||
"tenantId": str(tenant.id),
|
||
"tenantName": tenant.name,
|
||
"tenantEmail": tenant.email,
|
||
"status": tenant.status,
|
||
"createdAt": tenant.created_at.isoformat() if tenant.created_at else None,
|
||
}
|
||
|
||
# 1. 获取自定义 Agent 配额
|
||
custom_quota_result = await db.execute(
|
||
select(TenantCustomAgentQuota).where(
|
||
TenantCustomAgentQuota.tenant_id == tenant.id
|
||
)
|
||
)
|
||
custom_quota = custom_quota_result.scalar_one_or_none()
|
||
|
||
if custom_quota:
|
||
tenant_data["customAgentQuota"] = {
|
||
"cpuQuota": float(custom_quota.cpu_quota),
|
||
"memoryQuota": float(custom_quota.memory_quota),
|
||
"cpuUsed": float(custom_quota.cpu_used),
|
||
"memoryUsed": float(custom_quota.memory_used),
|
||
"agentCount": custom_quota.agent_count,
|
||
}
|
||
else:
|
||
tenant_data["customAgentQuota"] = None
|
||
|
||
# 2. 获取平台 Agent 配额
|
||
platform_quotas_result = await db.execute(
|
||
select(PlatformAgentQuota).where(
|
||
and_(
|
||
PlatformAgentQuota.target_id == tenant.id,
|
||
PlatformAgentQuota.target_type == "tenant"
|
||
)
|
||
)
|
||
)
|
||
platform_quotas = platform_quotas_result.scalars().all()
|
||
|
||
tenant_data["platformAgents"] = [
|
||
{
|
||
"templateName": pq.template_name,
|
||
"podQuota": pq.pod_quota,
|
||
"podUsed": pq.pod_used,
|
||
"cpuPerPod": pq.cpu_per_pod,
|
||
"memoryPerPod": pq.memory_per_pod,
|
||
}
|
||
for pq in platform_quotas
|
||
]
|
||
|
||
# 3. 获取模型配额(从 TenantModelKey 获取)
|
||
model_keys_result = await db.execute(
|
||
select(TenantModelKey).where(
|
||
and_(
|
||
TenantModelKey.tenant_id == tenant.id,
|
||
TenantModelKey.status == "active"
|
||
)
|
||
)
|
||
)
|
||
model_keys = model_keys_result.scalars().all()
|
||
|
||
tenant_data["models"] = [
|
||
{
|
||
"modelName": mk.model_name,
|
||
"rpmLimit": mk.rpm_limit,
|
||
"tpmLimit": mk.tpm_limit,
|
||
"maxBudget": float(mk.max_budget) if mk.max_budget else None,
|
||
"budgetDuration": mk.budget_duration,
|
||
}
|
||
for mk in model_keys
|
||
]
|
||
|
||
tenants_resources.append(tenant_data)
|
||
|
||
# 汇总统计
|
||
summary = {
|
||
"totalTenants": len(tenants),
|
||
"tenantsWithCustomAgents": len([t for t in tenants_resources if t["customAgentQuota"]]),
|
||
"tenantsWithPlatformAgents": len([t for t in tenants_resources if t["platformAgents"]]),
|
||
"tenantsWithModels": len([t for t in tenants_resources if t["models"]]),
|
||
"totalCustomAgentCpuQuota": sum(
|
||
t["customAgentQuota"]["cpuQuota"] for t in tenants_resources if t["customAgentQuota"]
|
||
),
|
||
"totalCustomAgentMemoryQuota": sum(
|
||
t["customAgentQuota"]["memoryQuota"] for t in tenants_resources if t["customAgentQuota"]
|
||
),
|
||
"totalCustomAgentCpuUsed": sum(
|
||
t["customAgentQuota"]["cpuUsed"] for t in tenants_resources if t["customAgentQuota"]
|
||
),
|
||
"totalCustomAgentMemoryUsed": sum(
|
||
t["customAgentQuota"]["memoryUsed"] for t in tenants_resources if t["customAgentQuota"]
|
||
),
|
||
"totalPlatformAgentPodQuota": sum(
|
||
sum(pa["podQuota"] for pa in t["platformAgents"]) for t in tenants_resources
|
||
),
|
||
"totalPlatformAgentPodUsed": sum(
|
||
sum(pa["podUsed"] for pa in t["platformAgents"]) for t in tenants_resources
|
||
),
|
||
}
|
||
|
||
return SuccessResponse(
|
||
data={
|
||
"channelId": str(channel_uuid),
|
||
"channelName": channel.name,
|
||
"tenants": tenants_resources,
|
||
"summary": summary,
|
||
},
|
||
message="获取渠道租户资源分配成功"
|
||
)
|
||
|