Files
taiji-AI-PAD/services/mcp-server/app/periodic_billing.py
T
2026-03-10 06:40:38 +00:00

400 lines
15 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.
"""
周期性计费任务模块
功能:
1. 定时更新运行中 Agent 的计费日志(agent_billing_records 表)
2. 增量扣款:只扣除上次计费后新增的费用,避免重复扣款
3. 余额不足时自动停止 Agent
为什么需要周期性统计:
- Agent 可能长时间运行,需要定期更新计费记录以便用户查看实时消费
- 避免 Agent 结束回调失败导致计费记录不准确
- 及时发现余额不足的用户并停止其 Agent,防止欠费
EU计算(Agent运行时长):
- 公式:EU = ceil(duration_seconds / 10)
- 即:1 EU = 10秒运行时间,不足10秒按1 EU计算
成本计算:
- 平台 Agent: 按模板固定价格计费($/小时)
- 自定义 Agent: 按资源使用量(CPU/内存)计费
执行周期:每小时执行一次(BILLING_INTERVAL_SECONDS = 3600)
"""
import asyncio
import logging
import math
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Optional
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from database import AsyncSessionLocal
from models import AgentBillingRecord, User, Balance, TenantCustomAgentQuota, Agent
from app.billing import (
calculate_platform_agent_cost,
calculate_agent_cost_by_resources,
deduct_balance,
get_available_balance,
_parse_cpu_to_cores,
_parse_memory_to_gb,
)
logger = logging.getLogger(__name__)
# 计费周期配置
BILLING_INTERVAL_SECONDS = 3600 # 每小时执行一次
QUOTA_CHECK_INTERVAL_HOURS = 24 # 配额一致性检查周期:每24小时一次
VM_EU_PER_HOUR = Decimal("0.5") # VM计算 0.5 EU/hour
# 上次配额检查时间
_last_quota_check: Optional[datetime] = None
def _calculate_eu(duration_seconds: int) -> int:
"""计算EU:1 EU = 10秒,不足10秒按1 EU计算"""
return math.ceil(duration_seconds / 10)
async def stop_user_agents(user_id: str, db: AsyncSession) -> list:
"""
停止用户所有运行中的Agent(余额不足时调用)
Args:
user_id: 用户ID
db: 数据库会话
Returns:
停止的Agent列表
"""
from app.agent_manager_client import get_agent_manager_client, AgentManagerError
stopped_agents = []
now = datetime.utcnow()
# 查询该用户所有运行中的Agent
result = await db.execute(
select(AgentBillingRecord).where(
and_(
AgentBillingRecord.user_id == user_id,
AgentBillingRecord.end_time == None
)
)
)
user_agents = result.scalars().all()
client = get_agent_manager_client()
for record in user_agents:
try:
# 调用 Agent Manager 停止 Pod
await client.delete_agent(record.agent_name)
# 更新计费记录为已结束
record.end_time = now
if record.start_time:
duration = (now - record.start_time).total_seconds()
record.duration_seconds = int(duration)
record.eu_consumed = _calculate_eu(int(duration))
stopped_agents.append(record.agent_name)
logger.info(f"余额不足,已停止Agent: {record.agent_name}, 用户: {user_id}")
except AgentManagerError as e:
logger.error(f"停止Agent失败: {record.agent_name}, 错误: {e.message}")
except Exception as e:
logger.error(f"停止Agent异常: {record.agent_name}, 错误: {str(e)}")
return stopped_agents
async def check_quota_consistency(db: AsyncSession) -> dict:
"""
检查配额一致性(假用量检测)
对比配额表中的使用量与实际运行的Agent,识别假用量问题。
如果发现不一致,记录警告日志,运维人员可以运行 fix_fake_quota.py 修复。
Returns:
检查结果统计
"""
logger.info("🔍 开始配额一致性检查(假用量检测)")
try:
# 查询所有配额记录
result = await db.execute(select(TenantCustomAgentQuota))
quotas = result.scalars().all()
inconsistencies = []
total_fake_cpu = 0.0
total_fake_memory = 0.0
for quota in quotas:
tenant_id = str(quota.tenant_id)
# 查询配额显示的使用量
quota_cpu = float(quota.cpu_used or 0)
quota_memory = float(quota.memory_used or 0)
quota_count = quota.agent_count or 0
# 查询实际运行的Agent
agents_result = await db.execute(
select(Agent)
.where(Agent.owner_id == tenant_id)
.where(Agent.type == 'custom')
.where(Agent.status == 'active')
)
agents = agents_result.scalars().all()
# 计算实际使用量
real_cpu = sum(float(a.cpu or 0) for a in agents)
real_memory = sum(float(a.memory or 0) for a in agents)
real_count = len(agents)
# 计算差异(允许小误差 0.01)
cpu_diff = quota_cpu - real_cpu
memory_diff = quota_memory - real_memory
count_diff = quota_count - real_count
if abs(cpu_diff) > 0.01 or abs(memory_diff) > 0.01 or count_diff != 0:
inconsistencies.append({
'tenant_id': tenant_id,
'quota': {'cpu': quota_cpu, 'memory': quota_memory, 'count': quota_count},
'real': {'cpu': real_cpu, 'memory': real_memory, 'count': real_count},
'diff': {'cpu': cpu_diff, 'memory': memory_diff, 'count': count_diff}
})
total_fake_cpu += cpu_diff
total_fake_memory += memory_diff
logger.warning(
f"⚠️ 配额不一致: 租户={tenant_id}, "
f"配额显示=[CPU:{quota_cpu:.2f}核, 内存:{quota_memory:.2f}GB, Agent:{quota_count}个], "
f"实际运行=[CPU:{real_cpu:.2f}核, 内存:{real_memory:.2f}GB, Agent:{real_count}个], "
f"假用量=[CPU:{cpu_diff:.2f}核, 内存:{memory_diff:.2f}GB, Agent:{count_diff}个]"
)
if inconsistencies:
logger.error(
f"❌ 发现 {len(inconsistencies)} 个租户存在配额不一致(假用量)!\n"
f" 假用量汇总: CPU={total_fake_cpu:.2f}核, 内存={total_fake_memory:.2f}GB\n"
f" 🔧 请运行修复脚本: python fix_fake_quota.py"
)
else:
logger.info("✅ 配额一致性检查通过,无假用量问题")
return {
'total_tenants': len(quotas),
'inconsistent_tenants': len(inconsistencies),
'total_fake_cpu': total_fake_cpu,
'total_fake_memory': total_fake_memory,
'details': inconsistencies
}
except Exception as e:
logger.error(f"配额一致性检查失败: {e}")
return {'error': str(e)}
async def update_running_agent_billing(db: AsyncSession) -> dict:
"""
更新所有运行中 Agent 的计费记录
功能:
1. 更新EU消耗和成本
2. 增量扣款
3. 余额不足时自动停止Agent
Returns:
统计信息
"""
stats = {
"processed": 0,
"platform_agents": 0,
"custom_agents": 0,
"total_eu_consumed": Decimal("0"),
"total_cost": Decimal("0"),
"failed": 0,
"errors": [],
"stopped_agents": [], # 因余额不足而停止的Agent
}
try:
# 查询所有运行中的 Agent(end_time 为空)
result = await db.execute(
select(AgentBillingRecord).where(
AgentBillingRecord.end_time == None
)
)
running_agents = result.scalars().all()
now = datetime.utcnow()
for record in running_agents:
try:
# 跳过没有开始时间的记录
if record.start_time is None:
logger.warning(f"Agent {record.agent_name} 缺少 start_time,跳过计费")
stats["failed"] += 1
stats["errors"].append(f"Agent {record.agent_name}: start_time 为空")
continue
# 计算从开始到现在的运行时长
duration = (now - record.start_time).total_seconds()
duration_seconds = int(duration)
# 计算 EU 消耗
eu_consumed = _calculate_eu(duration_seconds)
# 计算成本
if record.is_platform_agent:
cost = calculate_platform_agent_cost(record.agent_type, duration_seconds)
stats["platform_agents"] += 1
else:
cpu_cores = _parse_cpu_to_cores(record.cpu_used) if record.cpu_used else 0.1
memory_gb = _parse_memory_to_gb(record.memory_used) if record.memory_used else 0.125
cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, duration_seconds)
stats["custom_agents"] += 1
# 计算本次周期需要扣除的增量
previous_cost = Decimal(str(record.cost or 0))
cost_increment = cost - previous_cost
# 更新记录
record.duration_seconds = duration_seconds
record.eu_consumed = eu_consumed
record.cost = float(cost)
# 如果有增量,进行扣款
if cost_increment > 0:
success, message = await deduct_balance(
str(record.user_id), cost_increment, db,
f"Agent周期计费: {record.agent_name}"
)
if not success:
logger.warning(
f"周期计费扣款失败: {message}, "
f"用户: {record.user_id}, Agent: {record.agent_name}"
)
# 检查用户余额,如果透支则停止该用户的所有Agent
balance, credit_limit, available = await get_available_balance(
str(record.user_id), db
)
if available < 0:
logger.warning(
f"用户 {record.user_id} 余额不足 (可用: {available}),将停止所有Agent"
)
stopped = await stop_user_agents(str(record.user_id), db)
stats["stopped_agents"].extend(stopped)
stats["processed"] += 1
stats["total_eu_consumed"] += Decimal(str(eu_consumed))
stats["total_cost"] += cost
except Exception as e:
stats["failed"] += 1
stats["errors"].append(f"Agent {record.agent_name}: {str(e)}")
logger.error(f"处理 Agent {record.agent_name} 计费失败: {e}")
await db.commit()
except Exception as e:
logger.error(f"周期性计费任务失败: {e}")
stats["errors"].append(f"全局错误: {str(e)}")
await db.rollback()
return stats
async def periodic_billing_task():
"""
周期性计费任务(后台运行)
每小时执行一次,更新所有运行中 Agent 的计费记录
每24小时执行一次配额一致性检查(假用量检测)
"""
global _last_quota_check
logger.info("周期性计费任务启动")
while True:
try:
async with AsyncSessionLocal() as db:
# 1. 执行计费更新(每小时)
stats = await update_running_agent_billing(db)
logger.info(
f"周期性计费完成: "
f"处理 {stats['processed']} 个Agent, "
f"平台Agent {stats['platform_agents']}, "
f"自定义Agent {stats['custom_agents']}, "
f"总EU消耗 {stats['total_eu_consumed']}, "
f"总成本 {stats['total_cost']}, "
f"失败 {stats['failed']}, "
f"因余额不足停止 {len(stats.get('stopped_agents', []))} 个Agent"
)
if stats["errors"]:
logger.warning(f"计费错误: {stats['errors']}")
# 2. 配额一致性检查(每24小时一次)
now = datetime.utcnow()
should_check_quota = (
_last_quota_check is None or
(now - _last_quota_check).total_seconds() >= QUOTA_CHECK_INTERVAL_HOURS * 3600
)
if should_check_quota:
logger.info(f"⏰ 触发配额一致性检查(距上次检查: {(now - _last_quota_check).total_seconds() / 3600:.1f}小时)" if _last_quota_check else "⏰ 首次执行配额一致性检查")
quota_stats = await check_quota_consistency(db)
_last_quota_check = now
# 如果发现假用量,记录到统计中
if quota_stats.get('inconsistent_tenants', 0) > 0:
stats['quota_check'] = quota_stats
except Exception as e:
logger.error(f"周期性计费任务异常: {e}")
# 等待下一个计费周期
await asyncio.sleep(BILLING_INTERVAL_SECONDS)
async def run_billing_now() -> dict:
"""
立即执行一次计费更新(手动触发)
Returns:
统计信息
"""
async with AsyncSessionLocal() as db:
return await update_running_agent_billing(db)
# 用于存储后台任务引用
_billing_task: Optional[asyncio.Task] = None
def start_periodic_billing():
"""启动周期性计费任务"""
global _billing_task
if _billing_task is None or _billing_task.done():
_billing_task = asyncio.create_task(periodic_billing_task())
logger.info("周期性计费任务已启动")
else:
logger.warning("周期性计费任务已在运行中")
def stop_periodic_billing():
"""停止周期性计费任务"""
global _billing_task
if _billing_task and not _billing_task.done():
_billing_task.cancel()
logger.info("周期性计费任务已停止")
_billing_task = None