Files
taiji-AI-PAD/services/mcp-server/app/routes/channel.py
T
2026-01-07 14:46:15 +00:00

3367 lines
114 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
from database import get_db
from models import (
User, Channel, Agent, ResourceAllocation,
BillingRecord, RechargeRecord, Application, ModelProvider,
ChannelProviderAccess, ProviderApplication, TenantCustomAgentQuota,
ChannelCustomAgentQuota, ResourceApplication, PlatformAgentQuota,
AgentBillingRecord, PlatformAgentTemplateConfig, TenantModelKey
)
from app.auth import require_auth, get_password_hash
from app.permissions import has_permission
from app.schemas import (
SuccessResponse,
TenantCreateRequest,
AllocateResourcesRequest,
UpdateBillingRequest,
RechargeTenantRequest,
RechargeTenantResponse,
SetCreditLimitRequest,
SetCreditLimitResponse,
ResourceApplicationRequest,
ChannelBillingResponse,
ApplyProviderRequest,
UpdateTenantStatusRequest,
UpdateTenantPermissionsRequest,
CreateAdminRequest,
ChangeTenantPasswordRequest,
ApplyPlatformAgentRequest,
AllocatePlatformAgentRequest,
)
from app.agent_manager_client import get_agent_manager_client, AgentManagerError
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/api/channel", tags=["渠道合作伙伴"])
def _get_role(principal: dict) -> str:
"""从principal获取角色"""
return principal.get("claims", {}).get("role", "")
def _verify_permission(principal: dict, permission: str):
"""验证是否拥有指定权限(使用统一权限系统)"""
role = _get_role(principal)
# super_admin 拥有所有权限
if role == "super_admin":
return
if not has_permission(role, permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"需要权限: {permission}"
)
def _get_channel_id(principal: dict) -> Optional[uuid.UUID]:
"""获取当前用户的渠道ID(如果是渠道下的管理员)"""
role = _get_role(principal)
# 超级管理员没有固定的channel_id,可以访问所有渠道
if role == "super_admin":
return None
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
# ============= 租户管理 =============
@router.get("/tenants", response_model=SuccessResponse)
async def list_tenants(
channel_id: Optional[str] = Query(None, description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取渠道下的租户列表
权限:view:tenants (channel_admin, billing_admin, operations_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数
- 其他管理员自动使用自己所属的渠道
"""
_verify_permission(principal, "view:tenants")
user_channel_id = _get_channel_id(principal)
role = _get_role(principal)
# 确定目标渠道ID
if role == "super_admin":
# 超级管理员必须提供 channel_id
if not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
target_channel_id = uuid.UUID(channel_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
target_channel_id = user_channel_id
else:
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="渠道不存在"
)
# 查询渠道下的租户
result = await db.execute(
select(User).where(User.channel_id == target_channel_id)
)
tenants = result.scalars().all()
data = [
{
"id": str(tenant.id),
"name": tenant.name,
"email": tenant.email,
"subscriptionTier": tenant.subscription_tier,
"balance": float(tenant.balance),
"creditLimit": float(tenant.credit_limit),
"status": tenant.status,
"createdAt": tenant.created_at.isoformat(),
}
for tenant in tenants
]
return SuccessResponse(data={"tenants": data, "channelId": str(target_channel_id), "channelName": channel.name})
@router.post("/tenants/create", response_model=SuccessResponse)
async def create_tenant(
req: TenantCreateRequest,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
创建租户
权限:manage:tenants (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channelId 参数
- 其他管理员自动使用自己所属的渠道
"""
_verify_permission(principal, "manage:tenants")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
# 超级管理员必须提供 channelId
if not hasattr(req, 'channelId') or not req.channelId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员创建租户时必须提供 channelId 参数"
)
try:
channel_id = uuid.UUID(req.channelId)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 检查邮箱是否已存在
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="邮箱已被使用"
)
# 创建租户
password_hash = get_password_hash(req.password)
tenant = 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="user",
channel_id=channel_id,
subscription_tier=req.subscriptionTier,
status="active",
balance=0,
credit_limit=0,
)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return SuccessResponse(
data={
"id": str(tenant.id),
"name": tenant.name,
"email": tenant.email,
},
message="租户创建成功"
)
@router.put("/tenants/{tenant_id}/resources", response_model=SuccessResponse)
async def allocate_tenant_resources(
tenant_id: str,
req: AllocateResourcesRequest,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
为租户分配资源
支持分配:
- 平台端 Agent(agents 字段)
- 模型资源(models 字段)
- 自定义 Agent 配额(customAgentQuota 字段)
权限:manage:resources (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能操作该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "manage:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 获取租户所属渠道(用于验证配额)
tenant_channel_id = tenant.channel_id
# 删除现有资源分配
await db.execute(
select(ResourceAllocation).where(
and_(
ResourceAllocation.target_id == tenant_id,
ResourceAllocation.target_type == "tenant"
)
)
)
# 分配Agent资源
for agent_alloc in req.agents:
allocation = ResourceAllocation(
target_id=tenant_id,
target_type="tenant",
resource_type="agent",
resource_id=agent_alloc.agentId,
quantity=agent_alloc.quantity,
)
db.add(allocation)
# 分配模型资源
for model_alloc in req.models:
# 查找模型供应商
result = await db.execute(
select(ModelProvider).where(ModelProvider.name == model_alloc.modelName)
)
model_provider = result.scalar_one_or_none()
if model_provider:
allocation = ResourceAllocation(
target_id=tenant_id,
target_type="tenant",
resource_type="model",
resource_id=str(model_provider.id),
rpm=model_alloc.rpm,
tpm=model_alloc.tpm,
)
db.add(allocation)
# 分配自定义 Agent 配额
if req.customAgentQuota:
# 获取渠道的配额上限
channel_quota_result = await db.execute(
select(ChannelCustomAgentQuota).where(
ChannelCustomAgentQuota.channel_id == tenant_channel_id
)
)
channel_quota = channel_quota_result.scalar_one_or_none()
if channel_quota:
# 计算渠道已分配给其他租户的配额
other_tenants_quota_result = await db.execute(
select(
func.sum(TenantCustomAgentQuota.cpu_quota).label("total_cpu"),
func.sum(TenantCustomAgentQuota.memory_quota).label("total_memory")
)
.select_from(TenantCustomAgentQuota)
.join(User, TenantCustomAgentQuota.tenant_id == User.id)
.where(
and_(
User.channel_id == tenant_channel_id,
TenantCustomAgentQuota.tenant_id != tenant_id
)
)
)
other_quota = other_tenants_quota_result.first()
other_cpu = float(other_quota.total_cpu or 0) if other_quota else 0
other_memory = float(other_quota.total_memory or 0) if other_quota else 0
# 检查是否超过渠道配额
remaining_cpu = float(channel_quota.cpu_quota) - other_cpu
remaining_memory = float(channel_quota.memory_quota) - other_memory
if req.customAgentQuota.cpuQuota > remaining_cpu:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"CPU 配额超出渠道剩余配额。渠道剩余: {remaining_cpu:.2f} 核,请求: {req.customAgentQuota.cpuQuota:.2f} 核"
)
if req.customAgentQuota.memoryQuota > remaining_memory:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"内存配额超出渠道剩余配额。渠道剩余: {remaining_memory:.2f} GB,请求: {req.customAgentQuota.memoryQuota:.2f} GB"
)
# 查找或创建租户配额记录
tenant_quota_result = await db.execute(
select(TenantCustomAgentQuota).where(
TenantCustomAgentQuota.tenant_id == tenant_id
)
)
tenant_quota = tenant_quota_result.scalar_one_or_none()
if tenant_quota:
# 更新现有配额
tenant_quota.cpu_quota = req.customAgentQuota.cpuQuota
tenant_quota.memory_quota = req.customAgentQuota.memoryQuota
else:
# 创建新配额记录
tenant_quota = TenantCustomAgentQuota(
tenant_id=tenant_id,
cpu_quota=req.customAgentQuota.cpuQuota,
memory_quota=req.customAgentQuota.memoryQuota,
cpu_used=0,
memory_used=0,
agent_count=0,
)
db.add(tenant_quota)
# 更新渠道已分配量
if channel_quota:
# 重新计算渠道已分配总量
all_tenants_quota_result = await db.execute(
select(
func.sum(TenantCustomAgentQuota.cpu_quota).label("total_cpu"),
func.sum(TenantCustomAgentQuota.memory_quota).label("total_memory")
)
.select_from(TenantCustomAgentQuota)
.join(User, TenantCustomAgentQuota.tenant_id == User.id)
.where(User.channel_id == tenant_channel_id)
)
all_quota = all_tenants_quota_result.first()
# 加上当前分配的配额(因为可能是新创建的)
channel_quota.cpu_allocated = float(all_quota.total_cpu or 0) if all_quota else req.customAgentQuota.cpuQuota
channel_quota.memory_allocated = float(all_quota.total_memory or 0) if all_quota else req.customAgentQuota.memoryQuota
await db.commit()
return SuccessResponse(message="资源分配成功")
@router.put("/tenants/{tenant_id}/billing", response_model=SuccessResponse)
async def update_tenant_billing(
tenant_id: str,
req: UpdateBillingRequest,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
更新租户计费设置
权限:manage:billing (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能操作该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "manage:billing")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 更新计费设置
tenant.subscription_tier = req.subscriptionTier
tenant.discount = req.discount
await db.commit()
return SuccessResponse(message="计费设置更新成功")
@router.post("/tenants/{tenant_id}/recharge", response_model=SuccessResponse)
async def recharge_tenant(
tenant_id: str,
req: RechargeTenantRequest,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
为租户充值
权限:manage:billing (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能操作该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "manage:billing")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 更新余额
tenant.balance = float(tenant.balance) + req.amount
# 创建充值记录
recharge = RechargeRecord(
user_id=tenant_id,
channel_id=channel_id,
amount=req.amount,
payment_method="channel_recharge",
status="success",
order_id=f"CH{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:8]}",
completed_at=datetime.utcnow(),
)
db.add(recharge)
await db.commit()
await db.refresh(tenant)
return SuccessResponse(
data={
"tenantId": str(tenant.id),
"newBalance": float(tenant.balance),
"rechargeAmount": req.amount,
}
)
@router.put("/tenants/{tenant_id}/credit", response_model=SuccessResponse)
async def set_tenant_credit_limit(
tenant_id: str,
req: SetCreditLimitRequest,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
设置租户授信额度
权限:manage:billing (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能操作该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "manage:billing")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 更新授信额度
tenant.credit_limit = req.creditLimit
await db.commit()
return SuccessResponse(
data={
"tenantId": str(tenant.id),
"creditLimit": float(req.creditLimit),
}
)
@router.delete("/tenants/{tenant_id}", response_model=SuccessResponse)
async def delete_tenant(
tenant_id: str,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
删除租户(软删除)
将租户状态标记为inactive,保留数据但禁止使用
权限:manage:tenants (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能操作该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "manage:tenants")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 检查租户是否还有余额
if float(tenant.balance) > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"租户还有余额 {float(tenant.balance):.2f},请先处理余额后再删除"
)
# 软删除:标记为不活跃
tenant.status = "inactive"
await db.commit()
return SuccessResponse(
data={
"id": str(tenant.id),
"name": tenant.name,
},
message="租户已删除"
)
@router.put("/tenants/{tenant_id}/status", response_model=SuccessResponse)
async def update_tenant_status(
tenant_id: str,
req: UpdateTenantStatusRequest,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
更新租户状态
可设置状态:
- active: 正常使用
- inactive: 已停用(软删除)
- suspended: 暂停使用(临时停用,可恢复)
权限:manage:tenants (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能操作该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "manage:tenants")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
old_status = tenant.status
tenant.status = req.status
await db.commit()
return SuccessResponse(
data={
"tenantId": str(tenant.id),
"name": tenant.name,
"oldStatus": old_status,
"newStatus": req.status,
},
message=f"租户状态已更新为 {req.status}"
)
@router.put("/tenants/{tenant_id}/permissions", response_model=SuccessResponse)
async def update_tenant_permissions(
tenant_id: str,
req: UpdateTenantPermissionsRequest,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
更新租户权限
可配置的权限列表包括:
- use:platform_agents - 使用平台Agent
- use:custom_agents - 使用自定义Agent
- create:agents - 创建Agent
- read:billing - 查看计费信息
- export:data - 导出数据
权限:manage:tenants (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能操作该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "manage:tenants")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 有效权限列表
valid_permissions = {
"use:platform_agents",
"use:custom_agents",
"create:agents",
"read:billing",
"export:data",
}
# 验证权限
invalid_permissions = set(req.permissions) - valid_permissions
if invalid_permissions:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的权限: {', '.join(invalid_permissions)}"
)
# 更新用户的permissions字段(假设User模型有permissions JSON字段)
# 如果没有该字段,可以存储在metadata或创建新表
tenant.permissions = req.permissions
await db.commit()
return SuccessResponse(
data={
"tenantId": str(tenant.id),
"name": tenant.name,
"permissions": req.permissions,
},
message="租户权限已更新"
)
@router.put("/tenants/{tenant_id}/password", response_model=SuccessResponse)
async def change_tenant_password(
tenant_id: str,
req: ChangeTenantPasswordRequest,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
管理员为租户重置密码
权限:manage:tenants (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能操作该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "manage:tenants")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 更新密码
new_hash = get_password_hash(req.newPassword)
tenant.password_hash = new_hash
tenant.hashed_password = new_hash # 兼容旧字段
await db.commit()
return SuccessResponse(
data={
"tenantId": str(tenant.id),
"name": tenant.name,
},
message="租户密码已重置"
)
# ============= 租户配额查询 =============
@router.get("/tenants/{tenant_id}/custom-agent-quota", response_model=SuccessResponse)
async def get_tenant_custom_agent_quota(
tenant_id: str,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取租户的自定义 Agent 配额使用情况
返回数据:
- cpuQuota: CPU 配额上限(核心数)
- memoryQuota: 内存配额上限(GB)
- cpuUsed: 已使用 CPU(核心数)
- memoryUsed: 已使用内存(GB)
- cpuRemaining: 剩余 CPU(核心数)
- memoryRemaining: 剩余内存(GB)
- agentCount: 已创建的自定义 Agent 数量
权限:view:resources (channel_admin, billing_admin, operations_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能查看该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "view:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 获取租户配额
quota_result = await db.execute(
select(TenantCustomAgentQuota).where(
TenantCustomAgentQuota.tenant_id == tenant_id
)
)
quota = quota_result.scalar_one_or_none()
if not quota:
# 如果没有配额记录,返回默认值
return SuccessResponse(
data={
"tenantId": tenant_id,
"tenantName": tenant.name,
"cpuQuota": 0,
"memoryQuota": 0,
"cpuUsed": 0,
"memoryUsed": 0,
"cpuRemaining": 0,
"memoryRemaining": 0,
"agentCount": 0,
}
)
cpu_quota = float(quota.cpu_quota or 0)
memory_quota = float(quota.memory_quota or 0)
cpu_used = float(quota.cpu_used or 0)
memory_used = float(quota.memory_used or 0)
return SuccessResponse(
data={
"tenantId": tenant_id,
"tenantName": tenant.name,
"cpuQuota": cpu_quota,
"memoryQuota": memory_quota,
"cpuUsed": cpu_used,
"memoryUsed": memory_used,
"cpuRemaining": max(0, cpu_quota - cpu_used),
"memoryRemaining": max(0, memory_quota - memory_used),
"agentCount": quota.agent_count or 0,
}
)
# ============= 租户模型分配(LiteLLM 集成)=============
@router.put("/tenants/{tenant_id}/models", response_model=SuccessResponse)
async def allocate_model_to_tenant(
tenant_id: str,
model_name: str = Query(..., description="模型名称,如 azure/gpt-4"),
rpm_limit: int = Query(60, ge=0, description="每分钟请求数限制"),
tpm_limit: int = Query(10000, ge=0, description="每分钟 Token 数限制"),
max_budget: float = Query(100.0, ge=0, description="最大预算"),
budget_duration: str = Query("monthly", pattern="^(monthly|total)$", description="预算周期"),
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
分配模型给租户(创建 LiteLLM Key)
在 LiteLLM 中为租户创建 API Key,绑定指定的模型和配额。
租户的 Agent 启动时会使用此 Key 访问模型。
权限:manage:resources (channel_admin, billing_admin, super_admin)
注意:
- 渠道必须先拥有该模型的权限(通过 ResourceAllocation 分配)
- 每个租户每个模型只能有一个 Key
- 超级管理员必须提供 channel_id 参数
"""
_verify_permission(principal, "manage:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
tenant_result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 获取渠道信息
channel_result = await db.execute(
select(Channel).where(Channel.id == channel_id)
)
channel = channel_result.scalar_one_or_none()
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="渠道不存在"
)
# 验证渠道是否有该模型的权限
model_allocation_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
)
)
)
model_allocation = model_allocation_result.scalar_one_or_none()
if not model_allocation:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"渠道没有模型 '{model_name}' 的权限"
)
# 检查租户是否已有该模型的 Key
existing_key_result = await db.execute(
select(TenantModelKey).where(
and_(
TenantModelKey.tenant_id == tenant_id,
TenantModelKey.model_name == model_name
)
)
)
existing_key = existing_key_result.scalar_one_or_none()
if existing_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"租户已有模型 '{model_name}' 的 Key,请使用更新配额接口"
)
# 检查渠道是否有 LiteLLM team_id
if not channel.litellm_team_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="渠道尚未关联 LiteLLM team,请联系管理员"
)
# 在 LiteLLM 中创建 Key
try:
from app.litellm_client import get_litellm_client, LiteLLMClientError
litellm_client = get_litellm_client()
key = await litellm_client.generate_key(
team_id=channel.litellm_team_id,
models=[model_name],
rpm_limit=rpm_limit,
tpm_limit=tpm_limit,
max_budget=max_budget,
budget_duration=budget_duration,
key_name=f"tenant-{tenant_id}-{model_name}",
metadata={
"tenant_id": str(tenant_id),
"tenant_name": tenant.name,
"channel_id": str(channel_id),
"channel_name": channel.name,
"model": model_name,
}
)
# 加密存储 Key
encrypted_key = litellm_client.encrypt_key(key.key)
# 保存到数据库
tenant_key = TenantModelKey(
tenant_id=tenant_id,
channel_id=channel_id,
model_name=model_name,
litellm_key_id=key.key,
litellm_key_hash=encrypted_key,
rpm_limit=rpm_limit,
tpm_limit=tpm_limit,
max_budget=max_budget,
budget_duration=budget_duration,
status="active",
)
db.add(tenant_key)
# 同时记录到 ResourceAllocation
tenant_allocation = ResourceAllocation(
target_id=tenant_id,
target_type="tenant",
resource_type="model",
resource_id=model_name,
rpm=rpm_limit,
tpm=tpm_limit,
)
db.add(tenant_allocation)
await db.commit()
logger.info(
f"为租户 {tenant.name} 分配模型 {model_name} 成功",
extra={"tenant_id": tenant_id, "model": model_name}
)
return SuccessResponse(
data={
"tenantId": str(tenant_id),
"tenantName": tenant.name,
"modelName": model_name,
"rpmLimit": rpm_limit,
"tpmLimit": tpm_limit,
"maxBudget": max_budget,
"budgetDuration": budget_duration,
"status": "active",
},
message=f"模型 '{model_name}' 分配成功"
)
except LiteLLMClientError as e:
logger.error(f"LiteLLM Key 创建失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"LiteLLM Key 创建失败: {str(e)}"
)
except Exception as e:
logger.error(f"模型分配失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"模型分配失败: {str(e)}"
)
@router.delete("/tenants/{tenant_id}/models/{model_name}", response_model=SuccessResponse)
async def revoke_model_from_tenant(
tenant_id: str,
model_name: str,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
取消租户的模型分配(删除 LiteLLM Key)
删除租户在 LiteLLM 中的 API Key,租户将无法再使用该模型。
权限:manage:resources (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数
- 删除后租户正在运行的 Agent 将无法继续使用该模型
"""
_verify_permission(principal, "manage:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
tenant_result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 查找租户的模型 Key
key_result = await db.execute(
select(TenantModelKey).where(
and_(
TenantModelKey.tenant_id == tenant_id,
TenantModelKey.model_name == model_name
)
)
)
tenant_key = key_result.scalar_one_or_none()
if not tenant_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"租户没有模型 '{model_name}' 的分配记录"
)
# 在 LiteLLM 中删除 Key
litellm_error = None
try:
from app.litellm_client import get_litellm_client, LiteLLMClientError
litellm_client = get_litellm_client()
await litellm_client.delete_key(tenant_key.litellm_key_id)
logger.info(f"LiteLLM Key 删除成功: {tenant_key.litellm_key_id[:20]}...")
except LiteLLMClientError as e:
litellm_error = str(e)
logger.warning(f"LiteLLM Key 删除失败: {e}")
except Exception as e:
litellm_error = str(e)
logger.warning(f"LiteLLM 连接失败: {e}")
# 删除数据库记录
await db.delete(tenant_key)
# 删除 ResourceAllocation 记录
allocation_result = await db.execute(
select(ResourceAllocation).where(
and_(
ResourceAllocation.target_id == tenant_id,
ResourceAllocation.target_type == "tenant",
ResourceAllocation.resource_type == "model",
ResourceAllocation.resource_id == model_name
)
)
)
allocation = allocation_result.scalar_one_or_none()
if allocation:
await db.delete(allocation)
await db.commit()
response_data = {
"tenantId": str(tenant_id),
"tenantName": tenant.name,
"modelName": model_name,
}
if litellm_error:
response_data["litellmWarning"] = f"LiteLLM Key 删除失败: {litellm_error}"
return SuccessResponse(
data=response_data,
message=f"模型 '{model_name}' 分配已取消"
)
@router.put("/tenants/{tenant_id}/models/{model_name}/quota", response_model=SuccessResponse)
async def update_tenant_model_quota(
tenant_id: str,
model_name: str,
rpm_limit: Optional[int] = Query(None, ge=0, description="每分钟请求数限制"),
tpm_limit: Optional[int] = Query(None, ge=0, description="每分钟 Token 数限制"),
max_budget: Optional[float] = Query(None, ge=0, description="最大预算"),
budget_duration: Optional[str] = Query(None, pattern="^(monthly|total)$", description="预算周期"),
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
更新租户的模型配额(更新 LiteLLM Key)
更新租户在 LiteLLM 中的 API Key 配额,包括 RPM、TPM 和预算限制。
更新后立即生效,无需重启任何服务。
权限:manage:resources (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数
- 只更新提供的参数,未提供的参数保持不变
"""
_verify_permission(principal, "manage:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
tenant_result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 查找租户的模型 Key
key_result = await db.execute(
select(TenantModelKey).where(
and_(
TenantModelKey.tenant_id == tenant_id,
TenantModelKey.model_name == model_name
)
)
)
tenant_key = key_result.scalar_one_or_none()
if not tenant_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"租户没有模型 '{model_name}' 的分配记录"
)
# 在 LiteLLM 中更新 Key
try:
from app.litellm_client import get_litellm_client, LiteLLMClientError
litellm_client = get_litellm_client()
await litellm_client.update_key(
key=tenant_key.litellm_key_id,
rpm_limit=rpm_limit,
tpm_limit=tpm_limit,
max_budget=max_budget,
budget_duration=budget_duration,
)
# 更新数据库记录
if rpm_limit is not None:
tenant_key.rpm_limit = rpm_limit
if tpm_limit is not None:
tenant_key.tpm_limit = tpm_limit
if max_budget is not None:
tenant_key.max_budget = max_budget
if budget_duration is not None:
tenant_key.budget_duration = budget_duration
# 同时更新 ResourceAllocation
allocation_result = await db.execute(
select(ResourceAllocation).where(
and_(
ResourceAllocation.target_id == tenant_id,
ResourceAllocation.target_type == "tenant",
ResourceAllocation.resource_type == "model",
ResourceAllocation.resource_id == model_name
)
)
)
allocation = allocation_result.scalar_one_or_none()
if allocation:
if rpm_limit is not None:
allocation.rpm = rpm_limit
if tpm_limit is not None:
allocation.tpm = tpm_limit
await db.commit()
logger.info(
f"租户 {tenant.name} 的模型 {model_name} 配额更新成功",
extra={"tenant_id": tenant_id, "model": model_name}
)
return SuccessResponse(
data={
"tenantId": str(tenant_id),
"tenantName": tenant.name,
"modelName": model_name,
"rpmLimit": tenant_key.rpm_limit,
"tpmLimit": tenant_key.tpm_limit,
"maxBudget": float(tenant_key.max_budget) if tenant_key.max_budget else None,
"budgetDuration": tenant_key.budget_duration,
"status": tenant_key.status,
},
message="模型配额更新成功,立即生效"
)
except LiteLLMClientError as e:
logger.error(f"LiteLLM Key 更新失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"LiteLLM Key 更新失败: {str(e)}"
)
except Exception as e:
logger.error(f"配额更新失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"配额更新失败: {str(e)}"
)
@router.get("/tenants/{tenant_id}/models", response_model=SuccessResponse)
async def get_tenant_models(
tenant_id: str,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取租户的模型分配列表
返回租户已分配的所有模型及其配额信息。
权限:view:resources (channel_admin, billing_admin, operations_admin, super_admin)
"""
_verify_permission(principal, "view:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
tenant_result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 获取租户的所有模型 Key
keys_result = await db.execute(
select(TenantModelKey).where(
TenantModelKey.tenant_id == tenant_id
)
)
keys = keys_result.scalars().all()
data = []
for key in keys:
data.append({
"modelName": key.model_name,
"rpmLimit": key.rpm_limit,
"tpmLimit": key.tpm_limit,
"maxBudget": float(key.max_budget) if key.max_budget else None,
"budgetDuration": key.budget_duration,
"status": key.status,
"createdAt": key.created_at.isoformat() if key.created_at else None,
"updatedAt": key.updated_at.isoformat() if key.updated_at else None,
})
return SuccessResponse(
data={
"tenantId": str(tenant_id),
"tenantName": tenant.name,
"models": data,
}
)
# ============= 管理员管理 =============
@router.post("/admins/create", response_model=SuccessResponse)
async def create_channel_admin(
req: CreateAdminRequest,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
创建渠道下的管理员(渠道管理员可用)
可创建的角色:
- billing_admin: 计费管理员(渠道下的计费管理员,完整写入权限,可管理该渠道下的租户、计费操作)
- operations_admin: 运维管理员(渠道下的运维管理员,只读权限,仅查看和监控该渠道的数据)
注意:
- 渠道管理员创建管理员时,channel_id会自动设置为当前渠道
- 请求中的channelId字段会被忽略,使用当前渠道的ID
权限:manage:admins (channel_admin)
"""
_verify_permission(principal, "manage:admins")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
# 超级管理员可以通过请求参数指定channelId
if role == "super_admin" and hasattr(req, 'channelId') and req.channelId:
try:
channel_id = uuid.UUID(req.channelId)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID"
)
elif not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID,超级管理员创建管理员时需要提供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="渠道不存在"
)
# 检查邮箱是否已存在
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="邮箱已被使用"
)
# 创建管理员用户(自动关联到当前渠道)
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",
balance=0,
credit_limit=0,
)
db.add(admin)
await db.commit()
await db.refresh(admin)
return SuccessResponse(
data={
"id": str(admin.id),
"name": admin.name,
"email": admin.email,
"role": admin.role,
"channelId": str(channel_id),
},
message=f"管理员创建成功"
)
@router.get("/admins", response_model=SuccessResponse)
async def list_channel_admins(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取渠道下的管理员列表(渠道管理员可用)
权限:view:admins (channel_admin)
"""
_verify_permission(principal, "view:admins")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
# 超级管理员可以查看所有管理员,其他角色只能查看自己渠道的管理员
if role == "super_admin":
result = await db.execute(
select(User).where(
and_(
User.role.in_(["channel_admin", "billing_admin", "operations_admin"]),
User.status == "active"
)
)
)
elif channel_id:
result = await db.execute(
select(User).where(
and_(
User.channel_id == channel_id,
User.role.in_(["billing_admin", "operations_admin", "channel_admin"]),
User.status == "active"
)
)
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
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("/resources/apply", response_model=SuccessResponse)
async def apply_for_resources(
req: ResourceApplicationRequest,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
申请资源(模型或Agent)
权限:view:applications (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:applications")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
# 超级管理员申请资源时需要提供channelId参数(通过请求体)
if role == "super_admin" and hasattr(req, 'channelId') and req.channelId:
try:
channel_id = uuid.UUID(req.channelId)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID"
)
elif not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID,超级管理员申请资源时需要提供channelId参数"
)
# 创建申请
application = Application(
channel_id=channel_id,
type=req.type,
model_name=req.modelName,
rpm=req.rpm,
tpm=req.tpm,
agent_type=req.agentType,
quantity=req.quantity,
reason=req.reason,
status="pending",
)
db.add(application)
await db.commit()
await db.refresh(application)
return SuccessResponse(
data={"id": str(application.id), "status": "pending"},
message="申请已提交,等待审批"
)
# ============= 计费统计 =============
@router.get("/billing/stats", response_model=SuccessResponse)
async def get_channel_billing_stats(
startTime: str = Query(...),
endTime: str = Query(...),
tenantName: Optional[str] = Query(None),
minCalls: Optional[int] = Query(None),
maxCalls: Optional[int] = Query(None),
export: Optional[str] = Query(None, pattern="^(excel|csv|pdf)$"),
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取渠道计费统计
权限:view:billing (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:billing")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 解析时间(移除时区信息,使用 naive datetime)
try:
start_dt = datetime.fromisoformat(startTime.replace("Z", "+00:00")).replace(tzinfo=None)
end_dt = datetime.fromisoformat(endTime.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的时间格式,请使用 ISO 8601 格式"
)
# 查询渠道下的租户
tenants_result = await db.execute(
select(User).where(User.channel_id == channel_id)
)
tenants = {str(t.id): t for t in tenants_result.scalars().all()}
tenant_ids = list(tenants.keys())
# 租户统计
tenant_stats_result = await db.execute(
select(
BillingRecord.tenant_id,
func.count(BillingRecord.id).label("calls"),
func.sum(BillingRecord.eu).label("total_eu"),
func.sum(BillingRecord.cost).label("total_cost"),
)
.where(
and_(
BillingRecord.tenant_id.in_(tenant_ids),
BillingRecord.timestamp >= start_dt,
BillingRecord.timestamp <= end_dt,
)
)
.group_by(BillingRecord.tenant_id)
)
tenant_stats = []
for row in tenant_stats_result.all():
tenant = tenants.get(str(row.tenant_id))
if tenant:
tenant_stats.append({
"tenantId": str(row.tenant_id),
"tenantName": tenant.name,
"calls": row.calls,
"totalEU": float(row.total_eu or 0),
"totalCost": float(row.total_cost or 0),
})
# 调用记录
records_result = await db.execute(
select(BillingRecord)
.where(
and_(
BillingRecord.tenant_id.in_(tenant_ids),
BillingRecord.timestamp >= start_dt,
BillingRecord.timestamp <= end_dt,
)
)
.order_by(desc(BillingRecord.timestamp))
.limit(100)
)
call_records = []
for record in records_result.scalars().all():
tenant = tenants.get(str(record.tenant_id))
if tenant:
call_records.append({
"id": str(record.id),
"timestamp": record.timestamp.isoformat(),
"tenantName": tenant.name,
"agentName": record.agent_name,
"duration": record.duration,
"eu": record.eu,
"cost": float(record.cost),
})
# 如果是导出请求
if export:
file_url = f"https://exports.taiji-ai.com/{channel_id}/{export}/billing_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.{export}"
expires_at = (datetime.utcnow() + timedelta(hours=24)).isoformat()
return SuccessResponse(
data={
"fileUrl": file_url,
"format": export,
"expiresAt": expires_at,
}
)
return SuccessResponse(
data={
"tenantStats": tenant_stats,
"callRecords": call_records,
}
)
# ============= 供应商管理 =============
@router.get("/providers", response_model=SuccessResponse)
async def list_available_providers(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取所有可用的模型供应商列表(渠道管理员视图)
返回所有活跃的供应商,并标注该渠道是否已获得授权使用
权限:view:resources (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:resources")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
# 超级管理员可以查看所有供应商,其他角色需要channel_id
if role == "super_admin":
channel_id = None # 超级管理员不需要channel_id限制
elif not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 获取所有活跃的供应商
providers_result = await db.execute(
select(ModelProvider).where(ModelProvider.is_active == True)
)
providers = providers_result.scalars().all()
# 获取该渠道的供应商授权
access_result = await db.execute(
select(ChannelProviderAccess).where(
ChannelProviderAccess.channel_id == channel_id
)
)
access_map = {str(a.provider_id): a for a in access_result.scalars().all()}
# 获取该渠道的待审批申请
pending_result = await db.execute(
select(ProviderApplication).where(
and_(
ProviderApplication.channel_id == channel_id,
ProviderApplication.status == "pending"
)
)
)
pending_apps = {str(a.provider_id) for a in pending_result.scalars().all()}
data = []
for provider in providers:
provider_id = str(provider.id)
access = access_map.get(provider_id)
item = {
"id": provider_id,
"name": provider.name,
"provider": provider.provider,
"supportedModels": provider.supported_models,
"rpm": provider.rpm,
"tpm": provider.tpm,
"status": provider.status,
"hasAccess": access is not None and access.status == "active",
"accessStatus": access.status if access else None,
"rpmLimit": access.rpm_limit if access else None,
"tpmLimit": access.tpm_limit if access else None,
"pendingApplication": provider_id in pending_apps,
}
data.append(item)
return SuccessResponse(data={"providers": data})
@router.post("/providers/apply", response_model=SuccessResponse)
async def apply_for_provider(
req: ApplyProviderRequest,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
申请使用模型供应商
渠道管理员可以申请使用某个模型供应商,需要管理员审批后才能使用
权限:view:applications (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:applications")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
# 超级管理员申请供应商时需要提供channelId参数(通过请求体)
if role == "super_admin" and hasattr(req, 'channelId') and req.channelId:
try:
channel_id = uuid.UUID(req.channelId)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID"
)
elif not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID,超级管理员申请供应商时需要提供channelId参数"
)
# 验证 providerId 是否是有效的 UUID
try:
provider_uuid = uuid.UUID(req.providerId)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的供应商ID格式,必须是有效的UUID"
)
# 检查供应商是否存在
provider_result = await db.execute(
select(ModelProvider).where(
and_(
ModelProvider.id == provider_uuid,
ModelProvider.is_active == True
)
)
)
provider = provider_result.scalar_one_or_none()
if not provider:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="供应商不存在或已停用"
)
# 检查是否已有授权
access_result = await db.execute(
select(ChannelProviderAccess).where(
and_(
ChannelProviderAccess.channel_id == channel_id,
ChannelProviderAccess.provider_id == req.providerId,
ChannelProviderAccess.status == "active"
)
)
)
if access_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="您已获得该供应商的使用授权"
)
# 检查是否有待审批的申请
pending_result = await db.execute(
select(ProviderApplication).where(
and_(
ProviderApplication.channel_id == channel_id,
ProviderApplication.provider_id == req.providerId,
ProviderApplication.status == "pending"
)
)
)
if pending_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="您已有一个待审批的申请"
)
# 创建申请
application = ProviderApplication(
channel_id=channel_id,
provider_id=req.providerId,
requested_rpm=req.requestedRpm,
requested_tpm=req.requestedTpm,
reason=req.reason,
status="pending",
)
db.add(application)
await db.commit()
await db.refresh(application)
return SuccessResponse(
data={
"id": str(application.id),
"providerId": req.providerId,
"providerName": provider.name,
"status": "pending",
},
message="申请已提交,等待管理员审批"
)
@router.get("/providers/applications", response_model=SuccessResponse)
async def list_provider_applications(
status_filter: Optional[str] = Query(None, alias="status", pattern="^(pending|approved|rejected)$"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取渠道的供应商申请列表
权限:view:applications (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:applications")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
# 超级管理员可以查看所有申请,其他角色只能查看自己渠道的申请
if role == "super_admin":
query = select(ProviderApplication)
elif channel_id:
query = select(ProviderApplication).where(
ProviderApplication.channel_id == channel_id
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 构建查询(如果还没有构建)
if role != "super_admin" and not channel_id:
query = select(ProviderApplication).where(
ProviderApplication.channel_id == channel_id
)
if status_filter:
query = query.where(ProviderApplication.status == status_filter)
query = query.order_by(desc(ProviderApplication.created_at))
result = await db.execute(query)
applications = result.scalars().all()
# 获取供应商信息
provider_ids = [str(a.provider_id) for a in applications]
providers_result = await db.execute(
select(ModelProvider).where(ModelProvider.id.in_(provider_ids))
)
providers_map = {str(p.id): p for p in providers_result.scalars().all()}
data = []
for app in applications:
provider = providers_map.get(str(app.provider_id))
data.append({
"id": str(app.id),
"providerId": str(app.provider_id),
"providerName": provider.name if provider else "未知",
"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.get("/providers/access", response_model=SuccessResponse)
async def list_provider_access(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取渠道已授权的供应商列表
权限:view:resources (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:resources")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
# 超级管理员可以查看所有供应商,其他角色需要channel_id
if role == "super_admin":
channel_id = None # 超级管理员不需要channel_id限制
elif not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 获取授权列表
result = await db.execute(
select(ChannelProviderAccess).where(
ChannelProviderAccess.channel_id == channel_id
)
)
access_list = result.scalars().all()
# 获取供应商信息
provider_ids = [str(a.provider_id) for a in access_list]
providers_result = await db.execute(
select(ModelProvider).where(ModelProvider.id.in_(provider_ids))
)
providers_map = {str(p.id): p for p in providers_result.scalars().all()}
data = []
for access in access_list:
provider = providers_map.get(str(access.provider_id))
data.append({
"id": str(access.id),
"providerId": str(access.provider_id),
"providerName": provider.name if provider else "未知",
"provider": provider.provider if provider else "unknown",
"supportedModels": provider.supported_models if provider else [],
"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,
})
return SuccessResponse(data={"accessList": data})
# ============= 平台 Agent 资源申请 =============
# 模板显示名称和描述映射(用于前端展示)
TEMPLATE_DISPLAY_INFO = {
"echo_agent": {
"displayName": "Echo 测试服务",
"description": "简单的 Echo 服务,用于测试和调试",
"category": "testing",
},
"chat_agent": {
"displayName": "聊天对话服务",
"description": "智能聊天对话 Agent,支持多轮对话",
"category": "assistant",
},
"code_agent": {
"displayName": "代码执行服务",
"description": "代码生成和执行 Agent,支持多种编程语言",
"category": "development",
},
"search_agent": {
"displayName": "通用搜索服务",
"description": "通用搜索 Agent,支持多种搜索引擎",
"category": "search",
},
"jina_search_agent": {
"displayName": "Jina 语义搜索服务",
"description": "基于 Jina AI 的语义搜索 Agent",
"category": "search",
},
"mysql_agent": {
"displayName": "MySQL 数据库客户端",
"description": "MySQL 数据库查询和管理 Agent",
"category": "database",
},
"postgresql_agent": {
"displayName": "PostgreSQL 数据库客户端",
"description": "PostgreSQL 数据库查询和管理 Agent",
"category": "database",
},
}
# 模板资源配置建议
TEMPLATE_RESOURCE_CONFIG = {
"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: AsyncSession = None) -> Dict[str, Dict[str, Any]]:
"""
从 Agent Manager 获取平台模板列表,并合并管理员配置
Args:
db: 数据库会话,用于获取管理员配置。如果为 None,则不获取管理员配置
Returns:
模板字典,key 为模板名称,value 为模板信息
"""
try:
client = get_agent_manager_client()
templates = await client.list_platform_templates()
# 获取管理员配置(如果提供了数据库会话)
admin_configs = {}
if db:
config_result = await db.execute(
select(PlatformAgentTemplateConfig).where(
PlatformAgentTemplateConfig.is_enabled == True
)
)
for config in config_result.scalars().all():
admin_configs[config.template_name] = config
result = {}
for t in templates:
template_name = t.template
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
admin_config = admin_configs.get(template_name)
# 如果有管理员配置,使用管理员配置的值;否则返回 null/0
if admin_config:
resource_config = {
"cpuRequest": admin_config.cpu_request,
"cpuLimit": admin_config.cpu_limit,
"memoryRequest": admin_config.memory_request,
"memoryLimit": admin_config.memory_limit,
"maxPods": admin_config.max_pods or 0,
"isConfigured": True,
}
# 如果管理员配置了显示名称和描述,使用管理员配置的
display_name = admin_config.display_name or display_info.get("displayName", template_name)
description = admin_config.description or display_info.get("description", f"{template_name} Agent")
else:
# 未配置时返回 null/0,表示管理员尚未配置
resource_config = {
"cpuRequest": None,
"cpuLimit": None,
"memoryRequest": None,
"memoryLimit": None,
"maxPods": 0,
"isConfigured": False,
}
display_name = display_info.get("displayName", template_name)
description = display_info.get("description", f"{template_name} Agent")
result[template_name] = {
"name": template_name,
"displayName": display_name,
"description": description,
"category": display_info.get("category", "general"),
"version": "1.0.0",
"port": t.port,
"envInfo": t.env_info,
"status": "available" if (admin_config and admin_config.is_enabled) else "not_configured",
**resource_config,
}
return result
except AgentManagerError as e:
logger.error("failed_to_get_platform_templates", error=str(e))
# 返回空字典,让调用方处理
return {}
except Exception as e:
logger.error("unexpected_error_getting_templates", error=str(e))
return {}
async def _get_custom_templates_from_agent_manager() -> Dict[str, Dict[str, Any]]:
"""
从 Agent Manager 获取自定义模板列表
Returns:
模板字典,key 为模板名称,value 为模板信息
"""
try:
client = get_agent_manager_client()
templates = await client.list_custom_templates()
result = {}
for t in templates:
template_name = t.template
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
resource_config = TEMPLATE_RESOURCE_CONFIG.get(template_name, {
"cpuRequest": "100m",
"cpuLimit": "500m",
"memoryRequest": "128Mi",
"memoryLimit": "512Mi"
})
result[template_name] = {
"name": template_name,
"displayName": display_info.get("displayName", template_name),
"description": display_info.get("description", f"{template_name} Agent"),
"category": display_info.get("category", "general"),
"version": "1.0.0",
"port": t.port,
"envInfo": t.env_info,
"requiredEnvVars": t.env_info.get("required", {}),
"optionalEnvVars": t.env_info.get("optional", {}),
"status": "available",
**resource_config,
}
return result
except AgentManagerError as e:
logger.error("failed_to_get_custom_templates", error=str(e))
return {}
except Exception as e:
logger.error("unexpected_error_getting_custom_templates", error=str(e))
return {}
@router.get("/available-platform-agents", response_model=SuccessResponse)
async def list_available_platform_agents(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
查看所有可用的平台 Agent 模板
渠道管理员可以查看平台上所有可用的 Agent 模板,
并查看自己是否已获得使用权限和配额情况。
模板数据从 Agent Manager 动态获取。
权限:view:resources (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:resources")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
if role != "super_admin" and not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 从 Agent Manager 获取平台模板(传递数据库会话以获取管理员配置)
platform_templates = await _get_platform_templates_from_agent_manager(db)
if not platform_templates:
logger.warning("no_platform_templates_available", channel_id=str(channel_id))
return SuccessResponse(
data={"templates": [], "warning": "无法从 Agent Manager 获取模板列表"}
)
# 获取渠道已有的平台 Agent 配额
quota_result = await db.execute(
select(PlatformAgentQuota).where(
and_(
PlatformAgentQuota.target_id == channel_id,
PlatformAgentQuota.target_type == "channel"
)
)
)
quota_map = {q.template_name: q for q in quota_result.scalars().all()}
# 获取待审批的申请
pending_result = await db.execute(
select(ResourceApplication).where(
and_(
ResourceApplication.channel_id == channel_id,
ResourceApplication.resource_type == "platform_agent",
ResourceApplication.status == "pending"
)
)
)
pending_apps = {a.template_name for a in pending_result.scalars().all()}
data = []
for template_name, template in platform_templates.items():
quota = quota_map.get(template_name)
item = {
**template,
"hasAccess": quota is not None,
"podQuota": quota.pod_quota if quota else 0,
"podUsed": quota.pod_used if quota else 0,
"podRemaining": (quota.pod_quota - quota.pod_used) if quota else 0,
"pendingApplication": template_name in pending_apps,
}
data.append(item)
return SuccessResponse(data={"templates": data})
@router.post("/applications/platform-agents", response_model=SuccessResponse)
async def apply_for_platform_agent(
req: ApplyPlatformAgentRequest,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
申请平台 Agent
渠道管理员可以申请使用某个平台 Agent 模板,需要管理员审批后才能使用。
权限:view:applications (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:applications")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
if role != "super_admin" and not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 从 Agent Manager 获取平台模板,验证模板是否存在
platform_templates = await _get_platform_templates_from_agent_manager()
if req.templateName not in platform_templates:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"平台 Agent 模板 '{req.templateName}' 不存在"
)
# 检查是否有待审批的申请
pending_result = await db.execute(
select(ResourceApplication).where(
and_(
ResourceApplication.channel_id == channel_id,
ResourceApplication.resource_type == "platform_agent",
ResourceApplication.template_name == req.templateName,
ResourceApplication.status == "pending"
)
)
)
if pending_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="您已有一个待审批的申请"
)
# 创建申请
application = ResourceApplication(
channel_id=channel_id,
resource_type="platform_agent",
template_name=req.templateName,
requested_pod_quota=req.requestedPodQuota,
reason=req.reason,
status="pending",
)
db.add(application)
await db.commit()
await db.refresh(application)
template = platform_templates[req.templateName]
return SuccessResponse(
data={
"id": str(application.id),
"templateName": req.templateName,
"templateDisplayName": template.get("displayName", req.templateName),
"requestedPodQuota": req.requestedPodQuota,
"status": "pending",
},
message="申请已提交,等待管理员审批"
)
@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)$"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
查看渠道的平台 Agent 申请列表
权限:view:applications (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:applications")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
if role == "super_admin":
query = select(ResourceApplication).where(
ResourceApplication.resource_type == "platform_agent"
)
elif channel_id:
query = select(ResourceApplication).where(
and_(
ResourceApplication.channel_id == channel_id,
ResourceApplication.resource_type == "platform_agent"
)
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
if status_filter:
query = query.where(ResourceApplication.status == status_filter)
query = query.order_by(desc(ResourceApplication.created_at))
result = await db.execute(query)
applications = result.scalars().all()
# 获取渠道信息
channel_ids = list(set(str(a.channel_id) for a in applications))
channels_result = await db.execute(
select(Channel).where(Channel.id.in_(channel_ids))
)
channels_map = {str(c.id): c for c in channels_result.scalars().all()}
data = []
for app in applications:
channel = channels_map.get(str(app.channel_id))
# 使用 TEMPLATE_DISPLAY_INFO 获取显示名称
display_info = TEMPLATE_DISPLAY_INFO.get(app.template_name, {})
data.append({
"id": str(app.id),
"channelId": str(app.channel_id),
"channelName": channel.name if channel else "未知",
"resourceType": app.resource_type,
"templateName": app.template_name,
"templateDisplayName": display_info.get("displayName", 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})
# ============= 平台 Agent 配额管理 =============
@router.get("/platform-agents", response_model=SuccessResponse)
async def list_channel_platform_agent_quotas(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
查看渠道的平台 Agent 配额
返回渠道已获得的所有平台 Agent 配额信息。
权限:view:resources (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:resources")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
if role != "super_admin" and not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 获取渠道的平台 Agent 配额
result = await db.execute(
select(PlatformAgentQuota).where(
and_(
PlatformAgentQuota.target_id == channel_id,
PlatformAgentQuota.target_type == "channel"
)
)
)
quotas = result.scalars().all()
data = []
for quota in quotas:
# 使用 TEMPLATE_DISPLAY_INFO 获取显示名称
display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {})
data.append({
"templateName": quota.template_name,
"templateDisplayName": display_info.get("displayName", quota.template_name),
"podQuota": quota.pod_quota,
"podUsed": quota.pod_used,
"podRemaining": quota.pod_quota - quota.pod_used,
"allocatedAt": quota.allocated_at.isoformat() if quota.allocated_at else None,
})
return SuccessResponse(data={"quotas": data})
@router.post("/tenants/{tenant_id}/platform-agents", response_model=SuccessResponse)
async def allocate_platform_agent_to_tenant(
tenant_id: str,
req: AllocatePlatformAgentRequest,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
分配平台 Agent 给租户
渠道管理员可以将自己的平台 Agent 配额分配给租户。
权限:manage:resources (channel_admin, billing_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能操作该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "manage:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
tenant_result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 从 Agent Manager 获取平台模板,验证模板是否存在
platform_templates = await _get_platform_templates_from_agent_manager()
if req.templateName not in platform_templates:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"平台 Agent 模板 '{req.templateName}' 不存在"
)
# 获取渠道的配额
channel_quota_result = await db.execute(
select(PlatformAgentQuota).where(
and_(
PlatformAgentQuota.target_id == channel_id,
PlatformAgentQuota.target_type == "channel",
PlatformAgentQuota.template_name == req.templateName
)
)
)
channel_quota = channel_quota_result.scalar_one_or_none()
if not channel_quota:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"渠道没有 '{req.templateName}' 的配额"
)
# 计算已分配给其他租户的配额
other_tenants_quota_result = await db.execute(
select(func.sum(PlatformAgentQuota.pod_quota).label("total"))
.select_from(PlatformAgentQuota)
.join(User, PlatformAgentQuota.target_id == User.id)
.where(
and_(
User.channel_id == channel_id,
PlatformAgentQuota.target_type == "tenant",
PlatformAgentQuota.template_name == req.templateName,
PlatformAgentQuota.target_id != tenant_id
)
)
)
other_quota = other_tenants_quota_result.scalar() or 0
# 检查是否超过渠道配额
remaining = channel_quota.pod_quota - other_quota
if req.podQuota > remaining:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"配额超出渠道剩余配额。渠道剩余: {remaining},请求: {req.podQuota}"
)
# 查找或创建租户配额记录
tenant_quota_result = await db.execute(
select(PlatformAgentQuota).where(
and_(
PlatformAgentQuota.target_id == tenant_id,
PlatformAgentQuota.target_type == "tenant",
PlatformAgentQuota.template_name == req.templateName
)
)
)
tenant_quota = tenant_quota_result.scalar_one_or_none()
# 计算配额变化量(用于更新渠道的 pod_used)
old_tenant_quota = tenant_quota.pod_quota if tenant_quota else 0
quota_delta = req.podQuota - old_tenant_quota
if tenant_quota:
tenant_quota.pod_quota = req.podQuota
else:
# 注意:allocated_by 设置为 None,因为渠道管理员的 JWT sub 字段是渠道 ID 而非用户 ID
# 如果需要记录分配人,应该在 JWT 中添加实际的用户 ID 字段
tenant_quota = PlatformAgentQuota(
target_id=tenant_id,
target_type="tenant",
template_name=req.templateName,
pod_quota=req.podQuota,
pod_used=0,
allocated_by=None, # 渠道管理员的 sub 是渠道 ID,不是用户 ID
allocated_at=datetime.utcnow(),
)
db.add(tenant_quota)
# Bug 修复:更新渠道的 pod_used(分配给租户的配额视为渠道已使用的配额)
channel_quota.pod_used = (channel_quota.pod_used or 0) + quota_delta
await db.commit()
# 使用已获取的模板信息
template = platform_templates.get(req.templateName, {})
return SuccessResponse(
data={
"tenantId": tenant_id,
"tenantName": tenant.name,
"templateName": req.templateName,
"templateDisplayName": template.get("displayName", req.templateName),
"podQuota": req.podQuota,
},
message="平台 Agent 配额分配成功"
)
@router.get("/tenants/{tenant_id}/platform-agents/usage", response_model=SuccessResponse)
async def get_tenant_platform_agent_usage(
tenant_id: str,
channel_id_param: Optional[str] = Query(None, alias="channel_id", description="渠道ID(超级管理员必填)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
查看租户的平台 Agent 使用情况
权限:view:resources (channel_admin, billing_admin, operations_admin, super_admin)
注意:
- 超级管理员必须提供 channel_id 参数,且只能查看该渠道下的租户
- 其他管理员自动使用所属渠道
"""
_verify_permission(principal, "view:resources")
role = _get_role(principal)
user_channel_id = _get_channel_id(principal)
# 确定目标渠道ID
if role == "super_admin":
if not channel_id_param:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="超级管理员必须提供 channel_id 参数"
)
try:
channel_id = uuid.UUID(channel_id_param)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的渠道ID格式"
)
elif user_channel_id:
channel_id = user_channel_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
tenant_result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.channel_id == channel_id
)
)
)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
# 获取租户的平台 Agent 配额
result = await db.execute(
select(PlatformAgentQuota).where(
and_(
PlatformAgentQuota.target_id == tenant_id,
PlatformAgentQuota.target_type == "tenant"
)
)
)
quotas = result.scalars().all()
data = []
for quota in quotas:
# 使用 TEMPLATE_DISPLAY_INFO 获取显示名称
display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {})
data.append({
"templateName": quota.template_name,
"templateDisplayName": display_info.get("displayName", quota.template_name),
"podQuota": quota.pod_quota,
"podUsed": quota.pod_used,
"podRemaining": quota.pod_quota - quota.pod_used,
"allocatedAt": quota.allocated_at.isoformat() if quota.allocated_at else None,
})
return SuccessResponse(
data={
"tenantId": tenant_id,
"tenantName": tenant.name,
"quotas": data,
}
)
# ============= Agent 计费统计 =============
@router.get("/agent-billing/stats", response_model=SuccessResponse)
async def get_channel_agent_billing_stats(
startTime: str = Query(..., description="开始时间 (ISO 8601)"),
endTime: str = Query(..., description="结束时间 (ISO 8601)"),
agentType: Optional[str] = Query(None, description="Agent 类型: platform/custom"),
templateName: Optional[str] = Query(None, description="模板名称(平台 Agent)"),
tenantId: Optional[str] = Query(None, description="租户 ID"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取渠道的 Agent 计费统计
返回渠道下所有租户的 Agent 使用费用统计,支持按类型、模板、租户筛选。
权限:view:billing (channel_admin, billing_admin, operations_admin)
"""
from app.billing import get_channel_agent_billing_stats
_verify_permission(principal, "view:billing")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
if role != "super_admin" and not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 解析时间(移除时区信息,使用 naive datetime)
try:
start_dt = datetime.fromisoformat(startTime.replace("Z", "+00:00")).replace(tzinfo=None)
end_dt = datetime.fromisoformat(endTime.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的时间格式,请使用 ISO 8601 格式"
)
# 获取统计数据
stats = await get_channel_agent_billing_stats(
channel_id=str(channel_id),
start_date=start_dt,
end_date=end_dt,
db=db,
)
return SuccessResponse(data=stats)
@router.get("/agent-billing/history", response_model=SuccessResponse)
async def get_channel_agent_billing_history(
startTime: str = Query(..., description="开始时间 (ISO 8601)"),
endTime: str = Query(..., description="结束时间 (ISO 8601)"),
agentType: Optional[str] = Query(None, description="Agent 类型: platform/custom"),
templateName: Optional[str] = Query(None, description="模板名称"),
tenantId: Optional[str] = Query(None, description="租户 ID"),
page: int = Query(1, ge=1, description="页码"),
pageSize: int = Query(20, ge=1, le=100, description="每页数量"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取渠道的 Agent 计费历史记录
返回渠道下所有租户的 Agent 计费详细记录,支持分页和筛选。
权限:view:billing (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:billing")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
if role != "super_admin" and not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 解析时间(移除时区信息,使用 naive datetime)
try:
start_dt = datetime.fromisoformat(startTime.replace("Z", "+00:00")).replace(tzinfo=None)
end_dt = datetime.fromisoformat(endTime.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的时间格式,请使用 ISO 8601 格式"
)
# 获取渠道下的租户
tenants_result = await db.execute(
select(User).where(User.channel_id == channel_id)
)
tenants = {str(t.id): t for t in tenants_result.scalars().all()}
tenant_ids = list(tenants.keys())
# 如果指定了租户 ID,验证是否属于该渠道
if tenantId:
if tenantId not in tenant_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="租户不存在或不属于该渠道"
)
tenant_ids = [tenantId]
# 构建查询 - 使用 AgentBillingRecord
query = select(AgentBillingRecord).where(
and_(
AgentBillingRecord.user_id.in_(tenant_ids),
AgentBillingRecord.period_start >= start_dt,
AgentBillingRecord.period_start <= end_dt,
)
)
if agentType:
query = query.where(AgentBillingRecord.agent_type == agentType)
if templateName:
query = query.where(AgentBillingRecord.template_name == templateName)
# 获取总数
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 分页查询
query = query.order_by(desc(AgentBillingRecord.period_start))
query = query.offset((page - 1) * pageSize).limit(pageSize)
result = await db.execute(query)
records = result.scalars().all()
data = []
for record in records:
tenant = tenants.get(str(record.user_id))
data.append({
"id": str(record.id),
"timestamp": record.period_start.isoformat() if record.period_start else None,
"tenantId": str(record.user_id),
"tenantName": tenant.name if tenant else "未知",
"agentType": record.agent_type,
"templateName": record.template_name,
"agentName": record.agent_name,
"durationSeconds": record.duration_seconds,
"cpuSeconds": record.cpu_seconds,
"memoryGbSeconds": record.memory_gb_seconds,
"requestCount": record.request_count,
"cost": float(record.cost),
"periodStart": record.period_start.isoformat() if record.period_start else None,
"periodEnd": record.period_end.isoformat() if record.period_end else None,
})
return SuccessResponse(
data={
"records": data,
"pagination": {
"page": page,
"pageSize": pageSize,
"total": total,
"totalPages": (total + pageSize - 1) // pageSize,
}
}
)
@router.get("/agent-billing/tenant-summary", response_model=SuccessResponse)
async def get_channel_tenant_agent_billing_summary(
startTime: str = Query(..., description="开始时间 (ISO 8601)"),
endTime: str = Query(..., description="结束时间 (ISO 8601)"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取渠道下各租户的 Agent 计费汇总
返回每个租户的 Agent 使用费用汇总,便于渠道管理员了解各租户的使用情况。
权限:view:billing (channel_admin, billing_admin, operations_admin)
"""
_verify_permission(principal, "view:billing")
role = _get_role(principal)
channel_id = _get_channel_id(principal)
if role != "super_admin" and not channel_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法获取渠道ID"
)
# 解析时间(移除时区信息,使用 naive datetime)
try:
start_dt = datetime.fromisoformat(startTime.replace("Z", "+00:00")).replace(tzinfo=None)
end_dt = datetime.fromisoformat(endTime.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的时间格式,请使用 ISO 8601 格式"
)
# 获取渠道下的租户
tenants_result = await db.execute(
select(User).where(User.channel_id == channel_id)
)
tenants = {str(t.id): t for t in tenants_result.scalars().all()}
tenant_ids = list(tenants.keys())
# 按租户统计 - 使用 AgentBillingRecord
tenant_stats_result = await db.execute(
select(
AgentBillingRecord.user_id,
AgentBillingRecord.agent_type,
func.count(AgentBillingRecord.id).label("count"),
func.sum(AgentBillingRecord.duration_seconds).label("total_duration"),
func.sum(AgentBillingRecord.request_count).label("total_requests"),
func.sum(AgentBillingRecord.cost).label("total_cost"),
)
.where(
and_(
AgentBillingRecord.user_id.in_(tenant_ids),
AgentBillingRecord.period_start >= start_dt,
AgentBillingRecord.period_start <= end_dt,
)
)
.group_by(AgentBillingRecord.user_id, AgentBillingRecord.agent_type)
)
# 整理数据
tenant_data = {}
for row in tenant_stats_result.all():
tenant_id = str(row.user_id)
if tenant_id not in tenant_data:
tenant = tenants.get(tenant_id)
tenant_data[tenant_id] = {
"tenantId": tenant_id,
"tenantName": tenant.name if tenant else "未知",
"platformAgent": {
"count": 0,
"totalDuration": 0,
"totalRequests": 0,
"totalCost": 0,
},
"customAgent": {
"count": 0,
"totalDuration": 0,
"totalRequests": 0,
"totalCost": 0,
},
"total": {
"count": 0,
"totalDuration": 0,
"totalRequests": 0,
"totalCost": 0,
},
}
agent_type_key = "platformAgent" if row.agent_type == "platform" else "customAgent"
tenant_data[tenant_id][agent_type_key] = {
"count": row.count,
"totalDuration": row.total_duration or 0,
"totalRequests": int(row.total_requests or 0),
"totalCost": float(row.total_cost or 0),
}
# 更新总计
tenant_data[tenant_id]["total"]["count"] += row.count
tenant_data[tenant_id]["total"]["totalDuration"] += row.total_duration or 0
tenant_data[tenant_id]["total"]["totalRequests"] += int(row.total_requests or 0)
tenant_data[tenant_id]["total"]["totalCost"] += float(row.total_cost or 0)
# 计算渠道总计
channel_total = {
"platformAgent": {"count": 0, "totalDuration": 0, "totalRequests": 0, "totalCost": 0},
"customAgent": {"count": 0, "totalDuration": 0, "totalRequests": 0, "totalCost": 0},
"total": {"count": 0, "totalDuration": 0, "totalRequests": 0, "totalCost": 0},
}
for tenant_stats in tenant_data.values():
for key in ["platformAgent", "customAgent", "total"]:
channel_total[key]["count"] += tenant_stats[key]["count"]
channel_total[key]["totalDuration"] += tenant_stats[key]["totalDuration"]
channel_total[key]["totalRequests"] += tenant_stats[key]["totalRequests"]
channel_total[key]["totalCost"] += tenant_stats[key]["totalCost"]
return SuccessResponse(
data={
"tenants": list(tenant_data.values()),
"channelTotal": channel_total,
"period": {
"startTime": start_dt.isoformat(),
"endTime": end_dt.isoformat(),
}
}
)