""" 资源管控模块 提供请求级别的资源管控、速率限制和配额检查 注意:计费数据已迁移到新表: - AgentBillingRecord: Agent 运行时计费 - ModelBillingRecord: 模型调用计费(LiteLLM) 旧的 BillingRecord 表已废弃,不再使用。 """ from datetime import datetime, timedelta from decimal import Decimal from typing import Dict, Optional, Tuple from sqlalchemy import select, func, and_, update from sqlalchemy.ext.asyncio import AsyncSession from fastapi import HTTPException, status import structlog from models import User, Channel, ResourceUsage, AgentBillingRecord from app.quota_manager import check_user_balance_quota, check_channel_quota from app.db_utils import with_retry logger = structlog.get_logger() # 速率限制窗口(秒) RATE_LIMIT_WINDOW = 60 # 1分钟窗口 class ResourceController: """资源控制器 - 统一管理资源配额和速率限制""" def __init__(self): self._rate_limit_cache: Dict[str, Dict] = {} async def check_and_enforce( self, user_id: str, resource_type: str, # 'tool', 'agent', 'model' resource_id: Optional[str] = None, estimated_cost: Optional[Decimal] = None, db: AsyncSession = None ) -> Tuple[bool, Optional[str], Dict]: """ 检查并执行资源管控 Args: user_id: 用户ID resource_type: 资源类型 resource_id: 资源ID estimated_cost: 预估成本 db: 数据库会话 Returns: (是否允许, 拒绝原因, 详细信息) """ # 1. 检查用户余额配额 balance_ok, alert_type, balance_info = await check_user_balance_quota(user_id, db) if not balance_ok: logger.warning( "resource_control_balance_failed", user_id=user_id, alert_type=alert_type, balance_info=balance_info ) return False, "insufficient_balance", { "message": "账户余额不足", "alert_type": alert_type, **balance_info } # 2. 检查速率限制(RPM) rpm_ok, rpm_reason, rpm_info = await self._check_rate_limit( user_id, resource_type, db ) if not rpm_ok: logger.warning( "resource_control_rate_limit_exceeded", user_id=user_id, resource_type=resource_type, rpm_info=rpm_info ) return False, rpm_reason, { "message": "超过速率限制", **rpm_info } # 3. 检查渠道配额(如果用户属于某个渠道) result = await db.execute( select(User).where(User.id == user_id) ) user = result.scalar_one_or_none() if user and user.channel_id: channel_ok, channel_alert, channel_info = await check_channel_quota( str(user.channel_id), db ) if not channel_ok or channel_alert == "quota_critical": logger.warning( "resource_control_channel_quota_exceeded", user_id=user_id, channel_id=str(user.channel_id), channel_info=channel_info ) return False, "channel_quota_exceeded", { "message": "渠道配额不足", **channel_info } # 4. 检查预估成本是否会导致余额不足 if estimated_cost and estimated_cost > 0: available = Decimal(str(balance_info.get("available", 0))) if estimated_cost > available: logger.warning( "resource_control_insufficient_for_cost", user_id=user_id, estimated_cost=float(estimated_cost), available=float(available) ) return False, "insufficient_funds_for_operation", { "message": "余额不足以支付本次操作", "estimated_cost": float(estimated_cost), "available": float(available) } # 所有检查通过 logger.info( "resource_control_passed", user_id=user_id, resource_type=resource_type, resource_id=resource_id ) return True, None, { "allowed": True, "balance_info": balance_info, "rpm_info": rpm_info } async def _check_rate_limit( self, user_id: str, resource_type: str, db: AsyncSession ) -> Tuple[bool, Optional[str], Dict]: """ 检查速率限制(每分钟请求数 RPM) Args: user_id: 用户ID resource_type: 资源类型 db: 数据库会话 Returns: (是否允许, 拒绝原因, 速率信息) """ # 获取用户配置的RPM限制 result = await db.execute( select(User).where(User.id == user_id) ) user = result.scalar_one_or_none() if not user: return False, "user_not_found", {"message": "用户不存在"} # 默认RPM限制(可从用户配置或系统配置获取) rpm_limit = getattr(user, 'rpm_limit', 60) # 默认60 RPM # 计算当前窗口的起始时间 now = datetime.utcnow() window_start = now - timedelta(seconds=RATE_LIMIT_WINDOW) # 查询当前窗口内的API调用次数 result = await db.execute( select(func.count(ResourceUsage.id)) .where( and_( ResourceUsage.user_id == user_id, ResourceUsage.period_start >= window_start, ResourceUsage.period_start <= now ) ) ) current_rpm = result.scalar() or 0 # 判断是否超过限制 if current_rpm >= rpm_limit: return False, "rate_limit_exceeded", { "current_rpm": current_rpm, "rpm_limit": rpm_limit, "window_seconds": RATE_LIMIT_WINDOW, "retry_after": RATE_LIMIT_WINDOW } return True, None, { "current_rpm": current_rpm, "rpm_limit": rpm_limit, "remaining": rpm_limit - current_rpm, "window_seconds": RATE_LIMIT_WINDOW } async def record_resource_consumption( self, user_id: str, resource_type: str, resource_id: Optional[str], cost: Decimal, execution_time_ms: float, cpu_usage: float = 0.0, memory_usage: float = 0.0, network_io: float = 0.0, db: AsyncSession = None ) -> None: """ 记录资源消耗 使用行锁和重试机制防止并发更新丢失。 Args: user_id: 用户ID resource_type: 资源类型 resource_id: 资源ID cost: 成本 execution_time_ms: 执行时间(毫秒) cpu_usage: CPU使用率 memory_usage: 内存使用(MB) network_io: 网络IO(KB) db: 数据库会话 """ async def _do_record(): now = datetime.utcnow() # 记录到ResourceUsage表(每小时聚合) period_start = now.replace(minute=0, second=0, microsecond=0) period_end = period_start + timedelta(hours=1) # 计算增量值 cpu_increment = cpu_usage * (execution_time_ms / 1000.0) memory_increment = memory_usage * (execution_time_ms / 1000.0) network_increment = int(network_io * 1024) # 使用行锁保护并发更新(FOR UPDATE) result = await db.execute( select(ResourceUsage) .where( and_( ResourceUsage.user_id == user_id, ResourceUsage.period_start == period_start, ResourceUsage.granularity == "hourly" ) ) .with_for_update() # 行锁 ) usage = result.scalar_one_or_none() if usage: # 使用原子更新语句,而不是 ORM 属性修改 await db.execute( update(ResourceUsage) .where(ResourceUsage.id == usage.id) .values( cpu_seconds=ResourceUsage.cpu_seconds + cpu_increment, memory_mb_seconds=ResourceUsage.memory_mb_seconds + memory_increment, network_bytes=ResourceUsage.network_bytes + network_increment, api_calls=ResourceUsage.api_calls + 1 ) ) else: # 创建新记录 usage = ResourceUsage( user_id=user_id, agent_id=resource_id if resource_type == "agent" else None, cpu_seconds=cpu_increment, memory_mb_seconds=memory_increment, network_bytes=network_increment, storage_bytes=0, api_calls=1, period_start=period_start, period_end=period_end, granularity="hourly" ) db.add(usage) # 记录到 AgentBillingRecord 表(新计费表) # 注意:这里只记录 Agent 类型的资源消耗 # 模型调用计费由 LiteLLM Callback 处理,存入 ModelBillingRecord if resource_type == "agent": # 查询用户的 channel_id user_result = await db.execute( select(User).where(User.id == user_id) ) user = user_result.scalar_one_or_none() user_channel_id = user.channel_id if user else None # ✅ 确保 agent_name 不为空字符串 agent_name = resource_id if resource_id else "unknown" billing_record = AgentBillingRecord( user_id=user_id, channel_id=user_channel_id, # 从用户表获取 channel_id agent_name=agent_name, # ✅ 使用验证后的名称 agent_type="custom", # 默认为自定义 Agent is_platform_agent=False, duration_seconds=int(execution_time_ms / 1000), cpu_seconds=cpu_usage * (execution_time_ms / 1000.0), memory_gb_seconds=memory_usage / 1024 * (execution_time_ms / 1000.0), request_count=1, cost=cost, eu_consumed=float(cost), # EU = Cost(1 EU = 1 美元) start_time=now, period_start=now, ) db.add(billing_record) await db.commit() logger.info( "resource_consumption_recorded", user_id=user_id, resource_type=resource_type, cost=float(cost), execution_time_ms=execution_time_ms ) try: # 使用重试机制处理数据库冲突 await with_retry(_do_record, max_retries=3) except Exception as e: logger.error( "resource_consumption_recording_failed", user_id=user_id, error=str(e), exc_info=True ) await db.rollback() # 全局资源控制器实例 resource_controller = ResourceController() async def enforce_resource_control( user_id: str, resource_type: str, resource_id: Optional[str] = None, estimated_cost: Optional[Decimal] = None, db: AsyncSession = None ) -> Dict: """ 执行资源管控检查(供路由使用) Args: user_id: 用户ID resource_type: 资源类型 resource_id: 资源ID estimated_cost: 预估成本 db: 数据库会话 Returns: 资源管控信息字典 Raises: HTTPException: 如果资源管控检查失败 """ allowed, reason, info = await resource_controller.check_and_enforce( user_id=user_id, resource_type=resource_type, resource_id=resource_id, estimated_cost=estimated_cost, db=db ) if not allowed: # 根据不同的拒绝原因返回不同的HTTP状态码 status_code_map = { "insufficient_balance": status.HTTP_402_PAYMENT_REQUIRED, "rate_limit_exceeded": status.HTTP_429_TOO_MANY_REQUESTS, "channel_quota_exceeded": status.HTTP_403_FORBIDDEN, "insufficient_funds_for_operation": status.HTTP_402_PAYMENT_REQUIRED, "user_not_found": status.HTTP_404_NOT_FOUND, } status_code = status_code_map.get(reason, status.HTTP_403_FORBIDDEN) raise HTTPException( status_code=status_code, detail={ "error": reason, "message": info.get("message", "资源管控检查失败"), **info } ) return info