mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
1479 lines
58 KiB
Python
1479 lines
58 KiB
Python
"""
|
||
系统维护定时任务调度器
|
||
|
||
包含以下任务:
|
||
- 统计聚合:每天凌晨聚合前一天的统计数据
|
||
- Provider 签到:每天凌晨执行所有已配置 Provider 的签到
|
||
- 使用记录清理:分级清理策略(压缩、清空、删除)
|
||
- 审计日志清理:定期清理过期的审计日志
|
||
- 连接池监控:定期检查数据库连接池状态
|
||
- Pending 状态清理:清理异常的 Pending 状态记录
|
||
- Gemini 文件映射清理:清理过期的 Gemini 文件→Key 映射
|
||
- 请求候选记录清理:定期清理过期的 request_candidates 记录
|
||
- 数据库表维护:定期 VACUUM ANALYZE 防止表和索引膨胀
|
||
|
||
使用 APScheduler 进行任务调度,支持时区配置。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import os
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from typing import Any
|
||
|
||
from sqlalchemy import delete, literal_column, text
|
||
|
||
from src.clients.http_client import HTTPClientPool
|
||
from src.config.settings import config
|
||
from src.core.logger import logger
|
||
from src.database import create_session
|
||
from src.models.database import AuditLog, Provider, RequestCandidate, Usage
|
||
from src.services.provider_ops.service import ProviderOpsService
|
||
from src.services.system.config import SystemConfigService
|
||
from src.services.system.scheduler import get_scheduler
|
||
from src.services.system.stats_aggregator import StatsAggregatorService
|
||
from src.services.user.apikey import ApiKeyService
|
||
from src.services.wallet import WalletDailyUsageLedgerService
|
||
from src.utils.compression import compress_json
|
||
|
||
|
||
class MaintenanceScheduler:
|
||
"""系统维护任务调度器"""
|
||
|
||
# 签到任务的 job_id
|
||
CHECKIN_JOB_ID = "provider_checkin"
|
||
|
||
def __init__(self) -> None:
|
||
self.running = False
|
||
self._interval_tasks = []
|
||
self._stats_aggregation_lock = asyncio.Lock()
|
||
self._wallet_daily_usage_lock = asyncio.Lock()
|
||
self._startup_task: asyncio.Task[Any] | None = None
|
||
self._registered_job_ids: list[str] = []
|
||
|
||
@staticmethod
|
||
def _get_http_client_idle_cleanup_interval_minutes() -> int:
|
||
"""获取 HTTP 客户端空闲清理调度间隔(分钟)。"""
|
||
raw = os.getenv("HTTP_CLIENT_IDLE_CLEANUP_INTERVAL_MINUTES", "5")
|
||
try:
|
||
minutes = int(raw)
|
||
except ValueError:
|
||
logger.warning(
|
||
"环境变量 HTTP_CLIENT_IDLE_CLEANUP_INTERVAL_MINUTES 非法: {}, 使用默认值 5",
|
||
raw,
|
||
)
|
||
return 5
|
||
return max(1, minutes)
|
||
|
||
@staticmethod
|
||
def _has_python_startup_tasks() -> bool:
|
||
return bool(
|
||
config.pending_cleanup_python_enabled or config.antigravity_ua_refresh_python_enabled
|
||
)
|
||
|
||
def _get_checkin_time(self) -> tuple[int, int]:
|
||
"""获取签到任务的执行时间
|
||
|
||
Returns:
|
||
(hour, minute) 元组
|
||
"""
|
||
db = create_session()
|
||
try:
|
||
time_str = SystemConfigService.get_config(db, "provider_checkin_time", "01:05")
|
||
return self._parse_time_string(time_str)
|
||
finally:
|
||
db.close()
|
||
|
||
@staticmethod
|
||
def _parse_time_string(time_str: str) -> tuple[int, int]:
|
||
"""解析时间字符串为 (hour, minute) 元组
|
||
|
||
Args:
|
||
time_str: HH:MM 格式的时间字符串
|
||
|
||
Returns:
|
||
(hour, minute) 元组,解析失败返回默认值 (1, 5)
|
||
"""
|
||
try:
|
||
if not time_str or ":" not in time_str:
|
||
return (1, 5)
|
||
parts = time_str.split(":")
|
||
hour = int(parts[0])
|
||
minute = int(parts[1])
|
||
# 验证范围
|
||
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
||
return (hour, minute)
|
||
return (1, 5)
|
||
except (ValueError, IndexError):
|
||
return (1, 5)
|
||
|
||
def update_checkin_time(self, time_str: str) -> bool:
|
||
"""更新签到任务的执行时间
|
||
|
||
Args:
|
||
time_str: HH:MM 格式的时间字符串
|
||
|
||
Returns:
|
||
是否成功更新
|
||
"""
|
||
hour, minute = self._parse_time_string(time_str)
|
||
|
||
scheduler = get_scheduler()
|
||
success = scheduler.reschedule_cron_job(
|
||
self.CHECKIN_JOB_ID,
|
||
hour=hour,
|
||
minute=minute,
|
||
)
|
||
|
||
if success:
|
||
logger.info(f"Provider 签到任务时间已更新为: {hour:02d}:{minute:02d}")
|
||
|
||
return success
|
||
|
||
def get_checkin_job_info(self) -> dict | None:
|
||
"""获取签到任务的信息
|
||
|
||
Returns:
|
||
任务信息字典
|
||
"""
|
||
scheduler = get_scheduler()
|
||
return scheduler.get_job_info(self.CHECKIN_JOB_ID)
|
||
|
||
async def start(self) -> Any:
|
||
"""启动调度器"""
|
||
if self.running:
|
||
logger.warning("Maintenance scheduler already running")
|
||
return
|
||
|
||
self.running = True
|
||
self._registered_job_ids.clear()
|
||
logger.info("系统维护调度器已启动")
|
||
|
||
scheduler = get_scheduler()
|
||
|
||
def _add_cron(job_id: str, **kwargs: Any) -> None:
|
||
scheduler.add_cron_job(job_id=job_id, **kwargs)
|
||
self._registered_job_ids.append(job_id)
|
||
|
||
def _add_interval(job_id: str, **kwargs: Any) -> None:
|
||
scheduler.add_interval_job(job_id=job_id, **kwargs)
|
||
self._registered_job_ids.append(job_id)
|
||
|
||
# 注册定时任务
|
||
if config.stats_aggregation_python_enabled:
|
||
# 统计聚合任务 - UTC 00:05 执行
|
||
_add_cron(
|
||
"stats_aggregation",
|
||
func=self._scheduled_stats_aggregation,
|
||
hour=0,
|
||
minute=5,
|
||
name="统计数据聚合",
|
||
timezone="UTC",
|
||
)
|
||
else:
|
||
logger.info("每日统计聚合已切到 Rust gateway,Python 调度器跳过注册")
|
||
if config.stats_hourly_aggregation_python_enabled:
|
||
# 小时统计聚合任务 - 每小时 05 分执行(UTC)
|
||
_add_cron(
|
||
"stats_hourly_aggregation",
|
||
func=self._scheduled_hourly_stats_aggregation,
|
||
hour="*",
|
||
minute=5,
|
||
name="统计小时数据聚合",
|
||
timezone="UTC",
|
||
)
|
||
else:
|
||
logger.info("小时统计聚合已切到 Rust gateway,Python 调度器跳过注册")
|
||
if config.wallet_daily_usage_aggregation_python_enabled:
|
||
_add_cron(
|
||
"wallet_daily_usage_aggregation",
|
||
func=self._scheduled_wallet_daily_usage_aggregation,
|
||
hour=0,
|
||
minute=10,
|
||
name="钱包每日消费汇总",
|
||
)
|
||
else:
|
||
logger.info("钱包每日消费汇总已切到 Rust gateway,Python 调度器跳过注册")
|
||
if config.usage_cleanup_python_enabled:
|
||
# 清理任务 - 凌晨 3 点执行
|
||
_add_cron(
|
||
"usage_cleanup",
|
||
func=self._scheduled_cleanup,
|
||
hour=3,
|
||
minute=0,
|
||
name="使用记录清理",
|
||
)
|
||
else:
|
||
logger.info("使用记录清理已切到 Rust gateway,Python 调度器跳过注册")
|
||
|
||
if config.pool_monitor_python_enabled:
|
||
# 连接池监控 - 每 5 分钟
|
||
_add_interval(
|
||
"pool_monitor",
|
||
func=self._scheduled_monitor,
|
||
minutes=5,
|
||
name="连接池监控",
|
||
)
|
||
else:
|
||
logger.info("连接池监控已切到 Rust gateway,Python 调度器跳过注册")
|
||
|
||
if config.http_client_idle_cleanup_python_enabled:
|
||
# HTTP 代理/Tunnel 客户端空闲清理 - 默认每 5 分钟
|
||
_add_interval(
|
||
"http_client_idle_cleanup",
|
||
func=self._scheduled_http_client_idle_cleanup,
|
||
minutes=self._get_http_client_idle_cleanup_interval_minutes(),
|
||
name="HTTP客户端空闲清理",
|
||
)
|
||
else:
|
||
logger.info("HTTP 客户端空闲清理默认跳过 Python 调度注册")
|
||
|
||
if config.pending_cleanup_python_enabled:
|
||
# Pending 状态清理 - 每 5 分钟
|
||
_add_interval(
|
||
"pending_cleanup",
|
||
func=self._scheduled_pending_cleanup,
|
||
minutes=5,
|
||
name="Pending状态清理",
|
||
)
|
||
else:
|
||
logger.info("Pending 状态清理已切到 Rust gateway,Python 调度器跳过注册")
|
||
|
||
if config.audit_cleanup_python_enabled:
|
||
# 审计日志清理 - 凌晨 4 点执行
|
||
_add_cron(
|
||
"audit_cleanup",
|
||
func=self._scheduled_audit_cleanup,
|
||
hour=4,
|
||
minute=0,
|
||
name="审计日志清理",
|
||
)
|
||
else:
|
||
logger.info("审计日志清理已切到 Rust gateway,Python 调度器跳过注册")
|
||
|
||
if config.gemini_file_mapping_cleanup_python_enabled:
|
||
# Gemini 文件映射清理 - 每小时执行
|
||
_add_interval(
|
||
"gemini_file_mapping_cleanup",
|
||
func=self._scheduled_gemini_file_mapping_cleanup,
|
||
hours=1,
|
||
name="Gemini文件映射清理",
|
||
)
|
||
else:
|
||
logger.info("Gemini 文件映射清理已切到 Rust gateway,Python 调度器跳过注册")
|
||
|
||
if config.request_candidate_cleanup_python_enabled:
|
||
# 请求候选记录清理 - 凌晨 3:30 执行
|
||
_add_cron(
|
||
"candidate_cleanup",
|
||
func=self._scheduled_candidate_cleanup,
|
||
hour=3,
|
||
minute=30,
|
||
name="请求候选记录清理",
|
||
)
|
||
else:
|
||
logger.info("请求候选记录清理已切到 Rust gateway,Python 调度器跳过注册")
|
||
|
||
if config.db_maintenance_python_enabled:
|
||
# 数据库表维护 - 每周日凌晨 5 点执行 VACUUM ANALYZE
|
||
_add_cron(
|
||
"db_maintenance",
|
||
func=self._scheduled_db_maintenance,
|
||
day_of_week="sun",
|
||
hour=5,
|
||
minute=0,
|
||
name="数据库表维护",
|
||
)
|
||
else:
|
||
logger.info("数据库维护已切到 Rust gateway,Python 调度器跳过注册")
|
||
|
||
if config.antigravity_ua_refresh_python_enabled:
|
||
# Antigravity User-Agent 版本刷新 - 每 6 小时
|
||
_add_interval(
|
||
"antigravity_ua_refresh",
|
||
func=self._scheduled_antigravity_ua_refresh,
|
||
hours=6,
|
||
name="Antigravity UA版本刷新",
|
||
)
|
||
else:
|
||
logger.info("Antigravity UA 版本刷新默认跳过 Python 调度注册")
|
||
|
||
if config.provider_checkin_python_enabled:
|
||
# Provider 签到任务 - 根据配置时间执行
|
||
checkin_hour, checkin_minute = self._get_checkin_time()
|
||
_add_cron(
|
||
self.CHECKIN_JOB_ID,
|
||
func=self._scheduled_provider_checkin,
|
||
hour=checkin_hour,
|
||
minute=checkin_minute,
|
||
name="Provider签到",
|
||
)
|
||
else:
|
||
logger.info("Provider 签到已切到 Rust gateway,Python 调度器跳过注册")
|
||
|
||
# 启动时执行一次初始化任务
|
||
if config.maintenance_startup_tasks_enabled and self._has_python_startup_tasks():
|
||
from src.utils.async_utils import safe_create_task
|
||
|
||
self._startup_task = safe_create_task(self._run_startup_tasks())
|
||
elif config.maintenance_startup_tasks_enabled:
|
||
self._startup_task = None
|
||
logger.info("维护调度器启动任务已无剩余 Python owner,跳过创建")
|
||
else:
|
||
self._startup_task = None
|
||
logger.info("维护调度器启动任务已禁用(MAINTENANCE_STARTUP_TASKS_ENABLED=false)")
|
||
|
||
async def _run_startup_tasks(self) -> None:
|
||
"""启动时执行的初始化任务"""
|
||
# 延迟执行,等待系统完全启动(Redis 连接、其他后台任务稳定)
|
||
# 增加延迟时间避免与 UsageQueueConsumer 等后台任务竞争数据库连接
|
||
await asyncio.sleep(10)
|
||
|
||
if config.antigravity_ua_refresh_python_enabled:
|
||
# 刷新 Antigravity User-Agent 版本号(不阻塞其他启动任务)
|
||
try:
|
||
from src.services.provider.adapters.antigravity.client import refresh_user_agent
|
||
|
||
await refresh_user_agent()
|
||
except Exception as e:
|
||
logger.debug("启动时刷新 Antigravity UA 版本失败(不影响运行): {}", e)
|
||
else:
|
||
logger.info("启动时 Antigravity UA 刷新默认跳过")
|
||
|
||
if config.pending_cleanup_python_enabled:
|
||
try:
|
||
logger.info("启动时清理残留的 pending/streaming 请求...")
|
||
await self._perform_pending_cleanup()
|
||
except Exception as e:
|
||
logger.exception(f"启动时 pending 清理执行出错: {e}")
|
||
else:
|
||
logger.info("启动时 pending 清理已切到 Rust gateway,Python 宿主跳过执行")
|
||
async def stop(self) -> Any:
|
||
"""停止调度器"""
|
||
if not self.running:
|
||
return
|
||
|
||
self.running = False
|
||
|
||
if self._startup_task and not self._startup_task.done():
|
||
self._startup_task.cancel()
|
||
try:
|
||
await self._startup_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
self._startup_task = None
|
||
|
||
scheduler = get_scheduler()
|
||
for job_id in self._registered_job_ids:
|
||
scheduler.remove_job(job_id)
|
||
self._registered_job_ids.clear()
|
||
|
||
logger.info("系统维护调度器已停止")
|
||
|
||
# ========== 任务函数(APScheduler 直接调用异步函数) ==========
|
||
|
||
async def _scheduled_stats_aggregation(self, backfill: bool = False) -> None:
|
||
"""统计聚合任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
await self._perform_stats_aggregation(backfill=backfill)
|
||
|
||
async def _scheduled_wallet_daily_usage_aggregation(self) -> None:
|
||
"""钱包每日消费汇总任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
await self._perform_wallet_daily_usage_aggregation()
|
||
|
||
async def _scheduled_hourly_stats_aggregation(self) -> None:
|
||
"""小时统计聚合任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
await self._perform_hourly_stats_aggregation()
|
||
|
||
async def _scheduled_cleanup(self) -> None:
|
||
"""清理任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
await self._perform_cleanup()
|
||
|
||
async def _scheduled_monitor(self) -> None:
|
||
"""监控任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
try:
|
||
from src.database import log_pool_status
|
||
|
||
log_pool_status()
|
||
except Exception as e:
|
||
logger.exception("连接池监控任务出错: {}", e)
|
||
|
||
async def _scheduled_http_client_idle_cleanup(self) -> None:
|
||
"""HTTP 客户端空闲清理任务(定时调用)。"""
|
||
if not self.running:
|
||
return
|
||
try:
|
||
stats = await HTTPClientPool.cleanup_idle_clients()
|
||
if stats.get("proxy_closed", 0) or stats.get("tunnel_closed", 0):
|
||
logger.info(
|
||
"HTTP 客户端空闲清理释放连接: proxy={}, tunnel={}",
|
||
stats.get("proxy_closed", 0),
|
||
stats.get("tunnel_closed", 0),
|
||
)
|
||
except Exception as e:
|
||
logger.exception("HTTP 客户端空闲清理任务出错: {}", e)
|
||
|
||
async def _scheduled_pending_cleanup(self) -> None:
|
||
"""Pending 清理任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
await self._perform_pending_cleanup()
|
||
|
||
async def _scheduled_audit_cleanup(self) -> None:
|
||
"""审计日志清理任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
await self._perform_audit_cleanup()
|
||
|
||
async def _scheduled_candidate_cleanup(self) -> None:
|
||
"""请求候选记录清理任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
await self._perform_candidate_cleanup()
|
||
|
||
async def _scheduled_db_maintenance(self) -> None:
|
||
"""数据库表维护任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
await self._perform_db_maintenance()
|
||
|
||
async def _scheduled_gemini_file_mapping_cleanup(self) -> None:
|
||
"""Gemini 文件映射清理任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
await self._perform_gemini_file_mapping_cleanup()
|
||
|
||
async def _scheduled_antigravity_ua_refresh(self) -> None:
|
||
"""Antigravity User-Agent 版本刷新(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
try:
|
||
from src.services.provider.adapters.antigravity.client import refresh_user_agent
|
||
|
||
await refresh_user_agent()
|
||
except Exception as e:
|
||
logger.debug("定时刷新 Antigravity UA 版本失败: {}", e)
|
||
|
||
async def _scheduled_provider_checkin(self) -> None:
|
||
"""Provider 签到任务(定时调用)"""
|
||
if not self.running:
|
||
return
|
||
await self._perform_provider_checkin()
|
||
|
||
# ========== 实际任务实现 ==========
|
||
|
||
async def _perform_stats_aggregation(self, backfill: bool = False) -> None:
|
||
"""执行统计聚合任务
|
||
|
||
Args:
|
||
backfill: 是否回填历史数据(启动时检查缺失的日期)
|
||
"""
|
||
if self._stats_aggregation_lock.locked():
|
||
logger.info("统计聚合任务正在运行,跳过本次触发")
|
||
return
|
||
|
||
async with self._stats_aggregation_lock:
|
||
db = create_session()
|
||
try:
|
||
# 检查是否启用统计聚合
|
||
if not SystemConfigService.get_config(db, "enable_stats_aggregation", True):
|
||
logger.info("统计聚合已禁用,跳过聚合任务")
|
||
return
|
||
|
||
logger.info("开始执行统计数据聚合...")
|
||
|
||
from src.models.database import StatsDaily
|
||
from src.models.database import User as DBUser
|
||
|
||
# 使用 UTC 日期,定时任务在 UTC 00:05 触发,聚合 UTC 昨天
|
||
now_utc = datetime.now(timezone.utc)
|
||
today_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
if backfill:
|
||
# 启动时检查并回填缺失的日期
|
||
from src.models.database import StatsSummary
|
||
|
||
summary = db.query(StatsSummary).first()
|
||
if not summary:
|
||
# 首次运行,回填所有历史数据
|
||
logger.info("检测到首次运行,开始回填历史统计数据...")
|
||
days_to_backfill = SystemConfigService.get_config(
|
||
db, "stats_backfill_days", 365
|
||
)
|
||
count = StatsAggregatorService.backfill_historical_data(
|
||
db, days=days_to_backfill
|
||
)
|
||
logger.info(f"历史数据回填完成,共 {count} 天")
|
||
return
|
||
|
||
# 非首次运行,检查最近是否有缺失的日期需要回填
|
||
from src.models.database import StatsDailyModel, StatsDailyProvider
|
||
|
||
yesterday_utc_date = today_utc.date() - timedelta(days=1)
|
||
max_backfill_days: int = (
|
||
SystemConfigService.get_config(db, "max_stats_backfill_days", 30) or 30
|
||
)
|
||
|
||
# 计算回填检查的起始日期
|
||
check_start_date = yesterday_utc_date - timedelta(days=max_backfill_days - 1)
|
||
check_start_dt = datetime.combine(
|
||
check_start_date, datetime.min.time(), tzinfo=timezone.utc
|
||
)
|
||
|
||
# 单次查询获取三张统计表中已有数据的日期(UNION ALL 合并)
|
||
existing_daily_dates: set[date] = set()
|
||
existing_model_dates: set[date] = set()
|
||
existing_provider_dates: set[date] = set()
|
||
|
||
q_daily = db.query(
|
||
StatsDaily.date.label("dt"),
|
||
literal_column("'daily'").label("src"),
|
||
).filter(StatsDaily.date >= check_start_dt)
|
||
q_model = (
|
||
db.query(
|
||
StatsDailyModel.date.label("dt"),
|
||
literal_column("'model'").label("src"),
|
||
)
|
||
.filter(StatsDailyModel.date >= check_start_dt)
|
||
.distinct()
|
||
)
|
||
q_provider = (
|
||
db.query(
|
||
StatsDailyProvider.date.label("dt"),
|
||
literal_column("'provider'").label("src"),
|
||
)
|
||
.filter(StatsDailyProvider.date >= check_start_dt)
|
||
.distinct()
|
||
)
|
||
combined = q_daily.union_all(q_model).union_all(q_provider).all()
|
||
|
||
for stat_date, src in combined:
|
||
if stat_date.tzinfo is None:
|
||
stat_date = stat_date.replace(tzinfo=timezone.utc)
|
||
d = stat_date.date()
|
||
if src == "daily":
|
||
existing_daily_dates.add(d)
|
||
elif src == "model":
|
||
existing_model_dates.add(d)
|
||
else:
|
||
existing_provider_dates.add(d)
|
||
|
||
# 找出需要回填的日期
|
||
all_dates = set()
|
||
current = check_start_date
|
||
while current <= yesterday_utc_date:
|
||
all_dates.add(current)
|
||
current += timedelta(days=1)
|
||
|
||
# 需要回填 StatsDaily 的日期
|
||
missing_daily_dates = all_dates - existing_daily_dates
|
||
# 需要回填 StatsDailyModel 的日期
|
||
missing_model_dates = all_dates - existing_model_dates
|
||
# 需要回填 StatsDailyProvider 的日期
|
||
missing_provider_dates = all_dates - existing_provider_dates
|
||
# 合并所有需要处理的日期
|
||
dates_to_process = (
|
||
missing_daily_dates | missing_model_dates | missing_provider_dates
|
||
)
|
||
|
||
if dates_to_process:
|
||
sorted_dates = sorted(dates_to_process)
|
||
logger.info(
|
||
f"检测到 {len(dates_to_process)} 天的统计数据需要回填 "
|
||
f"(StatsDaily 缺失 {len(missing_daily_dates)} 天, "
|
||
f"StatsDailyModel 缺失 {len(missing_model_dates)} 天, "
|
||
f"StatsDailyProvider 缺失 {len(missing_provider_dates)} 天)"
|
||
)
|
||
|
||
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
|
||
user_ids = [user_id for (user_id,) in users]
|
||
|
||
failed_dates = 0
|
||
for current_date in sorted_dates:
|
||
try:
|
||
current_date_utc = datetime.combine(
|
||
current_date, datetime.min.time(), tzinfo=timezone.utc
|
||
)
|
||
StatsAggregatorService.aggregate_daily_stats_bundle(
|
||
db, current_date_utc, user_ids=user_ids
|
||
)
|
||
db.expunge_all()
|
||
except Exception as e:
|
||
failed_dates += 1
|
||
logger.warning(f"回填日期 {current_date} 失败: {e}")
|
||
try:
|
||
db.rollback()
|
||
except Exception as rollback_err:
|
||
logger.error(f"回滚失败: {rollback_err}")
|
||
|
||
StatsAggregatorService.update_summary(db)
|
||
|
||
if failed_dates > 0:
|
||
logger.warning(
|
||
f"回填完成,共处理 {len(dates_to_process)} 天,"
|
||
f"失败: {failed_dates} 天"
|
||
)
|
||
else:
|
||
logger.info(f"缺失数据回填完成,共处理 {len(dates_to_process)} 天")
|
||
else:
|
||
logger.info("统计数据已是最新,无需回填")
|
||
return
|
||
|
||
# 定时任务:聚合昨天 (UTC) 的数据
|
||
yesterday_utc = today_utc - timedelta(days=1)
|
||
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
|
||
user_ids = [user_id for (user_id,) in users]
|
||
|
||
StatsAggregatorService.aggregate_daily_stats_bundle(
|
||
db, yesterday_utc, user_ids=user_ids
|
||
)
|
||
|
||
StatsAggregatorService.update_summary(db)
|
||
|
||
logger.info("统计数据聚合完成")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"统计聚合任务执行失败: {e}")
|
||
try:
|
||
db.rollback()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
db.close()
|
||
|
||
async def _perform_wallet_daily_usage_aggregation(self) -> None:
|
||
if self._wallet_daily_usage_lock.locked():
|
||
logger.info("钱包每日消费汇总任务正在运行,跳过本次触发")
|
||
return
|
||
|
||
async with self._wallet_daily_usage_lock:
|
||
|
||
def _do() -> None:
|
||
db = create_session()
|
||
try:
|
||
logger.info("开始执行钱包每日消费汇总...")
|
||
billing_today = WalletDailyUsageLedgerService.get_today_billing_date()
|
||
billing_yesterday = billing_today - timedelta(days=1)
|
||
affected = WalletDailyUsageLedgerService.aggregate_day(db, billing_yesterday)
|
||
logger.info(
|
||
"钱包每日消费汇总完成: date={}, wallets={}",
|
||
billing_yesterday.isoformat(),
|
||
affected,
|
||
)
|
||
except Exception as e:
|
||
logger.exception("钱包每日消费汇总任务执行失败: {}", e)
|
||
try:
|
||
db.rollback()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
db.close()
|
||
|
||
await asyncio.to_thread(_do)
|
||
|
||
async def _perform_hourly_stats_aggregation(self) -> None:
|
||
"""执行小时统计聚合任务"""
|
||
|
||
def _do() -> None:
|
||
db = create_session()
|
||
try:
|
||
if not SystemConfigService.get_config(db, "enable_stats_aggregation", True):
|
||
logger.info("统计聚合已禁用,跳过小时聚合任务")
|
||
return
|
||
|
||
now_utc = datetime.now(timezone.utc)
|
||
last_hour = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||
StatsAggregatorService.aggregate_hourly_stats_bundle(db, last_hour)
|
||
logger.info("小时统计聚合完成: {}", last_hour.isoformat())
|
||
except Exception as e:
|
||
logger.exception("小时统计聚合任务执行失败: {}", e)
|
||
try:
|
||
db.rollback()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
db.close()
|
||
|
||
await asyncio.to_thread(_do)
|
||
|
||
async def _perform_pending_cleanup(self) -> None:
|
||
"""执行 pending 状态清理"""
|
||
|
||
def _do_pending_cleanup() -> int:
|
||
db = create_session()
|
||
try:
|
||
from src.services.usage.service import UsageService
|
||
|
||
timeout_minutes = SystemConfigService.get_config(
|
||
db, "pending_request_timeout_minutes", 10
|
||
)
|
||
# pending 清理涉及 candidate 表关联查询,限制批次大小以控制内存
|
||
batch_size = min(
|
||
max(SystemConfigService.get_config(db, "cleanup_batch_size", 1000), 1),
|
||
200,
|
||
)
|
||
return UsageService.cleanup_stale_pending_requests(
|
||
db,
|
||
timeout_minutes=timeout_minutes,
|
||
batch_size=batch_size,
|
||
)
|
||
except Exception as e:
|
||
logger.exception(f"清理 pending 请求失败: {e}")
|
||
db.rollback()
|
||
return 0
|
||
finally:
|
||
db.close()
|
||
|
||
loop = asyncio.get_running_loop()
|
||
cleaned_count = await loop.run_in_executor(None, _do_pending_cleanup)
|
||
if cleaned_count > 0:
|
||
logger.info(f"清理了 {cleaned_count} 条超时的 pending/streaming 请求")
|
||
|
||
async def _perform_audit_cleanup(self) -> None:
|
||
"""执行审计日志清理任务"""
|
||
|
||
def _do_audit_cleanup() -> int:
|
||
db = create_session()
|
||
try:
|
||
if not SystemConfigService.get_config(db, "enable_auto_cleanup", True):
|
||
logger.info("自动清理已禁用,跳过审计日志清理")
|
||
return 0
|
||
|
||
audit_retention_days = max(
|
||
SystemConfigService.get_config(db, "audit_log_retention_days", 30),
|
||
7,
|
||
)
|
||
batch_size = SystemConfigService.get_config(db, "cleanup_batch_size", 1000)
|
||
cutoff_time = datetime.now(timezone.utc) - timedelta(days=audit_retention_days)
|
||
|
||
logger.info(f"开始清理 {audit_retention_days} 天前的审计日志...")
|
||
|
||
total_deleted = 0
|
||
while True:
|
||
batch_db = create_session()
|
||
try:
|
||
records_to_delete = (
|
||
batch_db.query(AuditLog.id)
|
||
.filter(AuditLog.created_at < cutoff_time)
|
||
.limit(batch_size)
|
||
.all()
|
||
)
|
||
|
||
if not records_to_delete:
|
||
break
|
||
|
||
record_ids = [r.id for r in records_to_delete]
|
||
|
||
result = batch_db.execute(
|
||
delete(AuditLog)
|
||
.where(AuditLog.id.in_(record_ids))
|
||
.execution_options(synchronize_session=False)
|
||
)
|
||
|
||
rows_deleted = result.rowcount
|
||
batch_db.commit()
|
||
|
||
total_deleted += rows_deleted
|
||
logger.debug(f"已删除 {rows_deleted} 条审计日志,累计 {total_deleted} 条")
|
||
except Exception as e:
|
||
logger.exception(f"删除审计日志批次失败: {e}")
|
||
try:
|
||
batch_db.rollback()
|
||
except Exception:
|
||
pass
|
||
break
|
||
finally:
|
||
batch_db.close()
|
||
|
||
return total_deleted
|
||
except Exception as e:
|
||
logger.exception(f"审计日志清理失败: {e}")
|
||
return 0
|
||
finally:
|
||
db.close()
|
||
|
||
loop = asyncio.get_running_loop()
|
||
total_deleted = await loop.run_in_executor(None, _do_audit_cleanup)
|
||
if total_deleted > 0:
|
||
logger.info(f"审计日志清理完成,共删除 {total_deleted} 条记录")
|
||
else:
|
||
logger.info("无需清理的审计日志")
|
||
|
||
async def _perform_gemini_file_mapping_cleanup(self) -> None:
|
||
"""清理过期的 Gemini 文件映射记录"""
|
||
|
||
def _do() -> None:
|
||
db = create_session()
|
||
try:
|
||
from src.services.gemini_files_mapping import cleanup_expired_mappings
|
||
|
||
deleted_count = cleanup_expired_mappings(db)
|
||
|
||
if deleted_count > 0:
|
||
logger.info(f"清理了 {deleted_count} 条过期的 Gemini 文件映射")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Gemini 文件映射清理失败: {e}")
|
||
try:
|
||
db.rollback()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
db.close()
|
||
|
||
await asyncio.to_thread(_do)
|
||
|
||
async def _perform_provider_checkin(self) -> None:
|
||
"""执行 Provider 签到任务
|
||
|
||
遍历所有已配置 provider_ops 的 Provider,触发签到。
|
||
签到会在余额查询时一起执行(先签到再查询余额)。
|
||
"""
|
||
|
||
def _load_provider_ids() -> list[str]:
|
||
db = create_session()
|
||
try:
|
||
if not SystemConfigService.get_config(db, "enable_provider_checkin", True):
|
||
return []
|
||
providers = (
|
||
db.query(Provider.id, Provider.config)
|
||
.filter(Provider.is_active.is_(True))
|
||
.all()
|
||
)
|
||
return [p.id for p in providers if p.config and p.config.get("provider_ops")]
|
||
finally:
|
||
db.close()
|
||
|
||
try:
|
||
provider_ids = await asyncio.to_thread(_load_provider_ids)
|
||
if not provider_ids:
|
||
logger.info("无已配置的 Provider,跳过签到任务")
|
||
return
|
||
|
||
logger.info(f"开始执行 Provider 签到,共 {len(provider_ids)} 个...")
|
||
|
||
# 使用信号量限制并发,避免同时发起过多请求
|
||
concurrency = 3 # 签到任务并发数
|
||
semaphore = asyncio.Semaphore(concurrency)
|
||
|
||
async def _checkin_provider(provider_id: str) -> tuple[str, bool, str]:
|
||
"""执行单个 Provider 的签到"""
|
||
async with semaphore:
|
||
task_db = create_session()
|
||
try:
|
||
service = ProviderOpsService(task_db)
|
||
# 触发余额查询(会先执行签到)
|
||
result = await service.query_balance(provider_id)
|
||
# 检查签到结果
|
||
checkin_success = None
|
||
checkin_message = ""
|
||
if result.data and hasattr(result.data, "extra") and result.data.extra:
|
||
checkin_success = result.data.extra.get("checkin_success")
|
||
checkin_message = result.data.extra.get("checkin_message", "")
|
||
if checkin_success is True:
|
||
return provider_id, True, checkin_message
|
||
elif checkin_success is False:
|
||
return provider_id, False, checkin_message
|
||
else:
|
||
# None 表示未执行签到(可能没配置 Cookie)
|
||
return provider_id, False, "未执行签到"
|
||
except Exception as e:
|
||
logger.warning(f"Provider {provider_id} 签到失败: {e}")
|
||
return provider_id, False, str(e)
|
||
finally:
|
||
try:
|
||
task_db.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# 并行执行签到
|
||
tasks = [_checkin_provider(pid) for pid in provider_ids]
|
||
results = await asyncio.gather(*tasks)
|
||
|
||
# 统计结果
|
||
success_count = sum(1 for _, success, _ in results if success)
|
||
logger.info(f"Provider 签到完成: {success_count}/{len(provider_ids)} 成功")
|
||
|
||
# 记录详细结果
|
||
for provider_id, success, message in results:
|
||
if success:
|
||
logger.debug(f" - {provider_id}: 签到成功 - {message}")
|
||
elif message != "未执行签到":
|
||
logger.debug(f" - {provider_id}: 签到失败 - {message}")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"Provider 签到任务执行失败: {e}")
|
||
|
||
async def _perform_candidate_cleanup(self) -> None:
|
||
"""清理过期的 request_candidates 记录"""
|
||
|
||
def _do_candidate_cleanup() -> int:
|
||
db = create_session()
|
||
try:
|
||
if not SystemConfigService.get_config(db, "enable_auto_cleanup", True):
|
||
logger.info("自动清理已禁用,跳过候选记录清理")
|
||
return 0
|
||
|
||
retention_days = max(
|
||
SystemConfigService.get_config(
|
||
db,
|
||
"request_candidates_retention_days",
|
||
SystemConfigService.get_config(db, "detail_log_retention_days", 7),
|
||
),
|
||
3,
|
||
)
|
||
batch_size = max(
|
||
SystemConfigService.get_config(
|
||
db,
|
||
"request_candidates_cleanup_batch_size",
|
||
SystemConfigService.get_config(db, "cleanup_batch_size", 1000),
|
||
),
|
||
1,
|
||
)
|
||
cutoff_time = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||
|
||
logger.info(
|
||
"开始清理 {} 天前的请求候选记录,batch_size={}",
|
||
retention_days,
|
||
batch_size,
|
||
)
|
||
except Exception as e:
|
||
logger.exception(f"候选记录清理配置读取失败: {e}")
|
||
return 0
|
||
finally:
|
||
db.close()
|
||
|
||
total_deleted = 0
|
||
while True:
|
||
batch_db = create_session()
|
||
try:
|
||
records_to_delete = (
|
||
batch_db.query(RequestCandidate.id)
|
||
.filter(RequestCandidate.created_at < cutoff_time)
|
||
.order_by(RequestCandidate.created_at.asc(), RequestCandidate.id.asc())
|
||
.limit(batch_size)
|
||
.all()
|
||
)
|
||
|
||
if not records_to_delete:
|
||
break
|
||
|
||
record_ids = [r.id for r in records_to_delete]
|
||
|
||
result = batch_db.execute(
|
||
delete(RequestCandidate)
|
||
.where(RequestCandidate.id.in_(record_ids))
|
||
.execution_options(synchronize_session=False)
|
||
)
|
||
|
||
rows_deleted = result.rowcount
|
||
batch_db.commit()
|
||
|
||
total_deleted += rows_deleted
|
||
logger.debug(f"已删除 {rows_deleted} 条候选记录,累计 {total_deleted} 条")
|
||
except Exception as e:
|
||
logger.exception(f"删除候选记录批次失败: {e}")
|
||
try:
|
||
batch_db.rollback()
|
||
except Exception:
|
||
pass
|
||
break
|
||
finally:
|
||
batch_db.close()
|
||
|
||
return total_deleted
|
||
|
||
loop = asyncio.get_running_loop()
|
||
total_deleted = await loop.run_in_executor(None, _do_candidate_cleanup)
|
||
if total_deleted > 0:
|
||
logger.info(f"请求候选记录清理完成,共删除 {total_deleted} 条记录")
|
||
else:
|
||
logger.info("无需清理的候选记录")
|
||
|
||
async def _perform_db_maintenance(self) -> None:
|
||
"""执行数据库表维护(VACUUM ANALYZE)
|
||
|
||
对大表执行 VACUUM ANALYZE,防止表和索引膨胀。
|
||
VACUUM 不能在事务内执行,需要使用 autocommit 连接。
|
||
使用线程池执行,避免阻塞事件循环。
|
||
"""
|
||
db = create_session()
|
||
try:
|
||
if not SystemConfigService.get_config(db, "enable_db_maintenance", True):
|
||
logger.info("数据库维护已禁用,跳过")
|
||
return
|
||
except Exception as e:
|
||
logger.exception(f"读取数据库维护配置失败: {e}")
|
||
return
|
||
finally:
|
||
db.close()
|
||
|
||
tables = ["usage", "request_candidates", "audit_logs"]
|
||
|
||
logger.info(f"开始数据库表维护(VACUUM ANALYZE),目标表: {', '.join(tables)}")
|
||
|
||
from src.database.database import _ensure_engine
|
||
|
||
engine = _ensure_engine()
|
||
|
||
def _vacuum_table(table_name: str) -> tuple[str, bool, str]:
|
||
"""在线程池中执行 VACUUM ANALYZE(同步阻塞操作)"""
|
||
try:
|
||
with engine.connect() as raw_conn:
|
||
conn = raw_conn.execution_options(isolation_level="AUTOCOMMIT")
|
||
conn.execute(text(f"VACUUM ANALYZE {table_name}"))
|
||
return table_name, True, ""
|
||
except Exception as e:
|
||
return table_name, False, str(e)
|
||
|
||
loop = asyncio.get_running_loop()
|
||
for table in tables:
|
||
table_name, success, error = await loop.run_in_executor(None, _vacuum_table, table)
|
||
if success:
|
||
logger.info(f"VACUUM ANALYZE {table_name} 完成")
|
||
else:
|
||
logger.warning(f"VACUUM ANALYZE {table_name} 失败: {error}")
|
||
|
||
logger.info("数据库表维护完成")
|
||
|
||
async def _perform_cleanup(self) -> None:
|
||
"""执行清理任务(在线程池中运行,避免阻塞事件循环)"""
|
||
|
||
def _do_cleanup() -> None:
|
||
db = create_session()
|
||
try:
|
||
if not SystemConfigService.get_config(db, "enable_auto_cleanup", True):
|
||
logger.info("自动清理已禁用,跳过清理任务")
|
||
return
|
||
|
||
logger.info("开始执行使用记录分级清理...")
|
||
|
||
detail_retention = SystemConfigService.get_config(
|
||
db, "detail_log_retention_days", 7
|
||
)
|
||
compressed_retention = SystemConfigService.get_config(
|
||
db, "compressed_log_retention_days", 30
|
||
)
|
||
header_retention = SystemConfigService.get_config(db, "header_retention_days", 90)
|
||
log_retention = SystemConfigService.get_config(db, "log_retention_days", 365)
|
||
batch_size = SystemConfigService.get_config(db, "cleanup_batch_size", 1000)
|
||
auto_delete = SystemConfigService.get_config(db, "auto_delete_expired_keys", False)
|
||
except Exception as e:
|
||
logger.exception(f"清理任务配置读取失败: {e}")
|
||
return
|
||
finally:
|
||
db.close()
|
||
|
||
try:
|
||
now = datetime.now(timezone.utc)
|
||
|
||
detail_cutoff = now - timedelta(days=detail_retention)
|
||
compressed_cutoff = now - timedelta(days=compressed_retention)
|
||
header_cutoff = now - timedelta(days=header_retention)
|
||
log_cutoff = now - timedelta(days=log_retention)
|
||
|
||
# 先删最老的整行,再按窗口处理剩余记录,避免同一行在一轮里被重复改写。
|
||
records_deleted = self._delete_old_records(log_cutoff, batch_size)
|
||
header_cleaned = self._cleanup_header_fields(
|
||
header_cutoff, batch_size, newer_than=log_cutoff
|
||
)
|
||
body_cleaned = self._cleanup_stale_body_fields(
|
||
compressed_cutoff, batch_size, newer_than=log_cutoff
|
||
)
|
||
# 仅压缩 7-30 天窗口内的 body;更老记录直接清空 body,不再先压缩再清理。
|
||
body_compressed = self._cleanup_body_fields(
|
||
detail_cutoff, batch_size, newer_than=compressed_cutoff
|
||
)
|
||
|
||
# 5. 清理过期的API Keys
|
||
keys_db = create_session()
|
||
try:
|
||
keys_cleaned = ApiKeyService.cleanup_expired_keys(
|
||
keys_db, auto_delete=auto_delete
|
||
)
|
||
except Exception as e:
|
||
logger.exception(f"清理过期 Keys 失败: {e}")
|
||
keys_cleaned = 0
|
||
finally:
|
||
keys_db.close()
|
||
|
||
logger.info(
|
||
f"清理完成: 压缩 {body_compressed} 条, "
|
||
f"清理body {body_cleaned} 条, "
|
||
f"清理header {header_cleaned} 条, "
|
||
f"删除记录 {records_deleted} 条, "
|
||
f"清理过期Keys {keys_cleaned} 条"
|
||
)
|
||
except Exception as e:
|
||
logger.exception(f"清理任务执行失败: {e}")
|
||
|
||
loop = asyncio.get_running_loop()
|
||
await loop.run_in_executor(None, _do_cleanup)
|
||
|
||
def _cleanup_body_fields(
|
||
self,
|
||
cutoff_time: datetime,
|
||
batch_size: int,
|
||
*,
|
||
newer_than: datetime | None = None,
|
||
) -> int:
|
||
"""压缩 request_body 和 response_body 字段到压缩字段
|
||
|
||
仅处理指定时间窗口内仍保留原始 body 的记录,避免对更老记录重复写放大。
|
||
"""
|
||
from sqlalchemy import null, update
|
||
|
||
total_compressed = 0
|
||
no_progress_count = 0
|
||
memory_safe_batch_size = max(1, min(batch_size, 25))
|
||
|
||
if newer_than is not None and newer_than >= cutoff_time:
|
||
logger.warning(
|
||
"压缩 body 字段跳过: 无效时间窗口 newer_than={} cutoff_time={}",
|
||
newer_than,
|
||
cutoff_time,
|
||
)
|
||
return 0
|
||
|
||
while True:
|
||
batch_db = create_session()
|
||
try:
|
||
query = batch_db.query(
|
||
Usage.id,
|
||
Usage.request_body,
|
||
Usage.response_body,
|
||
Usage.provider_request_body,
|
||
Usage.client_response_body,
|
||
).filter(Usage.created_at < cutoff_time)
|
||
if newer_than is not None:
|
||
query = query.filter(Usage.created_at >= newer_than)
|
||
records = (
|
||
query.filter(
|
||
(Usage.request_body.isnot(None))
|
||
| (Usage.response_body.isnot(None))
|
||
| (Usage.provider_request_body.isnot(None))
|
||
| (Usage.client_response_body.isnot(None))
|
||
)
|
||
.order_by(Usage.created_at.asc(), Usage.id.asc())
|
||
.limit(memory_safe_batch_size)
|
||
.all()
|
||
)
|
||
|
||
if not records:
|
||
break
|
||
|
||
batch_success = 0
|
||
batch_progress = False
|
||
for record in records:
|
||
result = batch_db.execute(
|
||
update(Usage)
|
||
.where(Usage.id == record.id)
|
||
.values(
|
||
request_body=null(),
|
||
response_body=null(),
|
||
provider_request_body=null(),
|
||
client_response_body=null(),
|
||
request_body_compressed=(
|
||
compress_json(record.request_body) if record.request_body else None
|
||
),
|
||
response_body_compressed=(
|
||
compress_json(record.response_body)
|
||
if record.response_body
|
||
else None
|
||
),
|
||
provider_request_body_compressed=(
|
||
compress_json(record.provider_request_body)
|
||
if record.provider_request_body
|
||
else None
|
||
),
|
||
client_response_body_compressed=(
|
||
compress_json(record.client_response_body)
|
||
if record.client_response_body
|
||
else None
|
||
),
|
||
)
|
||
.execution_options(synchronize_session=False)
|
||
)
|
||
if result.rowcount > 0:
|
||
batch_success += 1
|
||
batch_progress = True
|
||
|
||
batch_db.commit()
|
||
except Exception as e:
|
||
logger.warning("压缩 body 批次失败: {}", e)
|
||
try:
|
||
batch_db.rollback()
|
||
except Exception:
|
||
pass
|
||
break
|
||
finally:
|
||
batch_db.close()
|
||
|
||
if not batch_progress:
|
||
no_progress_count += 1
|
||
if no_progress_count >= 3:
|
||
logger.error(
|
||
f"压缩 body 字段连续 {no_progress_count} 批无进展," "终止循环以避免死循环"
|
||
)
|
||
break
|
||
else:
|
||
no_progress_count = 0
|
||
|
||
total_compressed += batch_success
|
||
if batch_success > 0:
|
||
logger.debug(
|
||
f"已压缩 {batch_success} 条记录的 body 字段,累计 {total_compressed} 条"
|
||
)
|
||
|
||
return total_compressed
|
||
|
||
def _cleanup_stale_body_fields(
|
||
self,
|
||
cutoff_time: datetime,
|
||
batch_size: int,
|
||
*,
|
||
newer_than: datetime | None = None,
|
||
) -> int:
|
||
"""清理已超过压缩保留期的 body 字段
|
||
|
||
直接清空 raw/compressed body,避免更老记录先压缩再马上被清掉。
|
||
"""
|
||
from sqlalchemy import null, update
|
||
|
||
total_cleaned = 0
|
||
|
||
if newer_than is not None and newer_than >= cutoff_time:
|
||
logger.warning(
|
||
"清理 body 字段跳过: 无效时间窗口 newer_than={} cutoff_time={}",
|
||
newer_than,
|
||
cutoff_time,
|
||
)
|
||
return 0
|
||
|
||
while True:
|
||
batch_db = create_session()
|
||
try:
|
||
query = batch_db.query(Usage.id).filter(Usage.created_at < cutoff_time)
|
||
if newer_than is not None:
|
||
query = query.filter(Usage.created_at >= newer_than)
|
||
records_to_clean = (
|
||
query.filter(
|
||
(Usage.request_body.isnot(None))
|
||
| (Usage.response_body.isnot(None))
|
||
| (Usage.provider_request_body.isnot(None))
|
||
| (Usage.client_response_body.isnot(None))
|
||
| (Usage.request_body_compressed.isnot(None))
|
||
| (Usage.response_body_compressed.isnot(None))
|
||
| (Usage.provider_request_body_compressed.isnot(None))
|
||
| (Usage.client_response_body_compressed.isnot(None))
|
||
)
|
||
.order_by(Usage.created_at.asc(), Usage.id.asc())
|
||
.limit(batch_size)
|
||
.all()
|
||
)
|
||
|
||
if not records_to_clean:
|
||
break
|
||
|
||
record_ids = [r.id for r in records_to_clean]
|
||
|
||
result = batch_db.execute(
|
||
update(Usage)
|
||
.where(Usage.id.in_(record_ids))
|
||
.values(
|
||
request_body=null(),
|
||
response_body=null(),
|
||
provider_request_body=null(),
|
||
client_response_body=null(),
|
||
request_body_compressed=null(),
|
||
response_body_compressed=null(),
|
||
provider_request_body_compressed=null(),
|
||
client_response_body_compressed=null(),
|
||
)
|
||
)
|
||
|
||
rows_updated = result.rowcount
|
||
batch_db.commit()
|
||
|
||
if rows_updated == 0:
|
||
logger.warning("清理 body 字段: rowcount=0,可能存在问题")
|
||
break
|
||
|
||
total_cleaned += rows_updated
|
||
logger.debug(f"已清理 {rows_updated} 条记录的 body 字段,累计 {total_cleaned} 条")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"清理 body 字段失败: {e}")
|
||
try:
|
||
batch_db.rollback()
|
||
except Exception:
|
||
pass
|
||
break
|
||
finally:
|
||
batch_db.close()
|
||
|
||
return total_cleaned
|
||
|
||
def _cleanup_header_fields(
|
||
self,
|
||
cutoff_time: datetime,
|
||
batch_size: int,
|
||
*,
|
||
newer_than: datetime | None = None,
|
||
) -> int:
|
||
"""清理 request_headers, response_headers 和 provider_request_headers 字段
|
||
|
||
每批使用短生命周期 session(同步方法,在线程池中调用)
|
||
"""
|
||
from sqlalchemy import null, update
|
||
|
||
total_cleaned = 0
|
||
|
||
if newer_than is not None and newer_than >= cutoff_time:
|
||
logger.warning(
|
||
"清理 header 字段跳过: 无效时间窗口 newer_than={} cutoff_time={}",
|
||
newer_than,
|
||
cutoff_time,
|
||
)
|
||
return 0
|
||
|
||
while True:
|
||
batch_db = create_session()
|
||
try:
|
||
query = batch_db.query(Usage.id).filter(Usage.created_at < cutoff_time)
|
||
if newer_than is not None:
|
||
query = query.filter(Usage.created_at >= newer_than)
|
||
records_to_clean = (
|
||
query.filter(
|
||
(Usage.request_headers.isnot(None))
|
||
| (Usage.response_headers.isnot(None))
|
||
| (Usage.provider_request_headers.isnot(None))
|
||
| (Usage.client_response_headers.isnot(None))
|
||
)
|
||
.order_by(Usage.created_at.asc(), Usage.id.asc())
|
||
.limit(batch_size)
|
||
.all()
|
||
)
|
||
|
||
if not records_to_clean:
|
||
break
|
||
|
||
record_ids = [r.id for r in records_to_clean]
|
||
|
||
result = batch_db.execute(
|
||
update(Usage)
|
||
.where(Usage.id.in_(record_ids))
|
||
.values(
|
||
request_headers=null(),
|
||
response_headers=null(),
|
||
provider_request_headers=null(),
|
||
client_response_headers=null(),
|
||
)
|
||
)
|
||
|
||
rows_updated = result.rowcount
|
||
batch_db.commit()
|
||
|
||
if rows_updated == 0:
|
||
logger.warning("清理 header 字段: rowcount=0,可能存在问题")
|
||
break
|
||
|
||
total_cleaned += rows_updated
|
||
logger.debug(f"已清理 {rows_updated} 条记录的 header 字段,累计 {total_cleaned} 条")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"清理 header 字段失败: {e}")
|
||
try:
|
||
batch_db.rollback()
|
||
except Exception:
|
||
pass
|
||
break
|
||
finally:
|
||
batch_db.close()
|
||
|
||
return total_cleaned
|
||
|
||
def _delete_old_records(self, cutoff_time: datetime, batch_size: int) -> int:
|
||
"""删除过期的完整记录(同步方法,在线程池中调用)"""
|
||
total_deleted = 0
|
||
|
||
while True:
|
||
batch_db = create_session()
|
||
try:
|
||
records_to_delete = (
|
||
batch_db.query(Usage.id)
|
||
.filter(Usage.created_at < cutoff_time)
|
||
.order_by(Usage.created_at.asc(), Usage.id.asc())
|
||
.limit(batch_size)
|
||
.all()
|
||
)
|
||
|
||
if not records_to_delete:
|
||
break
|
||
|
||
record_ids = [r.id for r in records_to_delete]
|
||
|
||
result = batch_db.execute(
|
||
delete(Usage)
|
||
.where(Usage.id.in_(record_ids))
|
||
.execution_options(synchronize_session=False)
|
||
)
|
||
|
||
rows_deleted = result.rowcount
|
||
batch_db.commit()
|
||
|
||
total_deleted += rows_deleted
|
||
logger.debug(f"已删除 {rows_deleted} 条过期记录,累计 {total_deleted} 条")
|
||
|
||
except Exception as e:
|
||
logger.exception(f"删除过期记录失败: {e}")
|
||
try:
|
||
batch_db.rollback()
|
||
except Exception:
|
||
pass
|
||
break
|
||
finally:
|
||
batch_db.close()
|
||
|
||
return total_deleted
|
||
|
||
|
||
def should_start_python_maintenance_scheduler() -> bool:
|
||
return bool(
|
||
config.audit_cleanup_python_enabled
|
||
or config.antigravity_ua_refresh_python_enabled
|
||
or config.db_maintenance_python_enabled
|
||
or config.gemini_file_mapping_cleanup_python_enabled
|
||
or config.http_client_idle_cleanup_python_enabled
|
||
or config.pending_cleanup_python_enabled
|
||
or config.pool_monitor_python_enabled
|
||
or config.provider_checkin_python_enabled
|
||
or config.request_candidate_cleanup_python_enabled
|
||
or config.stats_aggregation_python_enabled
|
||
or config.stats_hourly_aggregation_python_enabled
|
||
or config.usage_cleanup_python_enabled
|
||
or config.wallet_daily_usage_aggregation_python_enabled
|
||
)
|
||
|
||
|
||
# 全局单例
|
||
_maintenance_scheduler = None
|
||
|
||
|
||
def get_maintenance_scheduler() -> MaintenanceScheduler:
|
||
"""获取维护调度器单例"""
|
||
global _maintenance_scheduler
|
||
if _maintenance_scheduler is None:
|
||
_maintenance_scheduler = MaintenanceScheduler()
|
||
return _maintenance_scheduler
|