Files
taiji-AI-PAD/services/mcp-server/app/routes/admin.py
T
2026-05-05 14:13:59 +08:00

4544 lines
169 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
超级管理员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="获取渠道租户资源分配成功"
)