ForcePilot/backend/package/yuxi/external_systems/infrastructure/scheduler.py
Kris 00a5577aac chore: 整理并合并近期多模块迭代变更
本次提交包含了大量跨模块的功能增强、bug修复与代码优化:
1.  清理冗余空行与导入格式,统一代码风格
2.  新增集成超时异常类,替换原生TimeoutError避免穿透核心层
3.  扩展审计日志DTO与用例,新增变更字段追踪能力
4.  完善各类仓储接口,新增批量操作、时间范围过滤支持
5.  重构健康检查返回格式,统一使用status字段替代冗余的reachable/healthy
6.  扩展仪表盘与各类服务端口,新增待办统计、系统dashboard等能力
7.  优化批量操作与事务处理,新增保存点支持隔离失败操作
8.  修复配额管理阈值计算逻辑,支持自定义告警阈值
9.  统一认证类型错误提示,优化插件注册校验逻辑
10. 扩展SOAP适配器预览能力,兼容bytes类型输入
11. 新增批量恢复、批量硬删除回收站资源的接口与DTO
12. 优化测试用例断言逻辑,兼容前端操作符别名与字段名差异
2026-07-11 21:38:08 +08:00

989 lines
37 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.

"""external_systems 模块的定时任务 handler 注册入口。
本模块是 external_systems 限界上下文接入 scheduler 限界上下文的唯一登记点,
对齐 scheduler 上下文设计方案 §14.3 的"业务域 container 主动注册"模式。
与 ``infrastructure/container.py`` 同级,遵循 external_systems 的分层约定:
装配逻辑集中在 ``infrastructure/``,包根不出现装配代码。
对外暴露:
- ``register_scheduler_handlers(registry)``:业务模块注册函数,由 worker
启动钩子(``services/run_worker.py`` 的 ``_worker_startup``)统一调用。
内部实例化本模块的 handler 并调用 ``registry.register(handler)``。
边界规范:
- 本模块是 external_systems 接入 scheduler 的**唯一装配点**
- handler 的依赖(如 ``session_factory``)在函数内部构造,不暴露给装配层
- 新增 handler 时,在本函数内追加 ``registry.register(...)`` 即可,无需改
``run_worker.py``(前提:本模块已登记在 ``_BUSINESS_HANDLER_REGISTRARS``
"""
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.config.app import config as app_config
from yuxi.external_systems.adapters.persistence import create_repositories
from yuxi.external_systems.framework.runtime import AlertManagerImpl
from yuxi.scheduler.core.contracts import TaskContext, TaskResult
from yuxi.scheduler.framework.runtime import HandlerRegistry
from yuxi.storage.postgres.manager import pg_manager
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
__all__ = ["register_scheduler_handlers"]
# 默认事件保留天数,与 ExternalWebhookSubscription.retention_days 默认值对齐
_DEFAULT_RETENTION_DAYS = 30
# 默认审计日志保留天数,与 app_config.ext_system_audit_log_retention_days 对齐
_DEFAULT_AUDIT_LOG_RETENTION_DAYS = 90
class WebhookEventCleanupHandler:
"""清理超过保留期的 Webhook 事件。
通过 scheduled_tasks 表配置周期触发,按 retention_days 软删除
ext_webhook_events 表中过期的原始事件记录。
payload 参数:
retention_days: 保留天数,未传入时使用构造函数默认值。
依赖注入:
session_factory: 调用返回 AsyncSession 的异步上下文管理器工厂,
通常为 pg_manager.get_async_session_context。每次执行创建独立
会话,避免长生命周期会话问题。
default_retention_days: 默认保留天数,未在 payload 指定时使用。
"""
name = "external_systems.webhook_event_cleanup"
description = "清理超过保留期的 Webhook 事件"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
*,
default_retention_days: int = _DEFAULT_RETENTION_DAYS,
) -> None:
self._session_factory = session_factory
self._default_retention_days = default_retention_days
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行 Webhook 事件保留期清理。
从 payload 读取 retention_days计算 cutoff 后委托仓储执行软删除。
异常隔离:捕获所有异常并转换为 TaskResult(success=False),不向调度域传播。
"""
retention_days = int(ctx.payload.get("retention_days", self._default_retention_days))
cutoff = utc_now_naive() - timedelta(days=retention_days)
try:
async with self._session_factory() as db:
repos = create_repositories(db)
deleted_count = await repos.webhook_event.delete_old_events(
cutoff,
updated_by="scheduler",
)
except Exception as exc:
logger.exception(
"webhook_event_cleanup_failed",
extra={
"retention_days": retention_days,
"cutoff": cutoff,
},
)
return TaskResult(success=False, error=str(exc))
if deleted_count > 0:
logger.info(
"webhook_event_cleanup",
extra={
"deleted_count": deleted_count,
"retention_days": retention_days,
"cutoff": cutoff,
},
)
return TaskResult(
success=True,
output={
"deleted_count": deleted_count,
"retention_days": retention_days,
},
)
class PendingWebhookEventConsumerHandler:
"""定时消费 pending 状态的 Webhook 事件。
通过 ``scheduled_tasks`` 表配置周期触发,扫描 ``processing_status=pending``
的事件并调用 ``webhook_event_handler.consume_pending_event`` 完成过滤/
转换/分派闭环。单事件失败不影响其他事件。
payload 参数:
batch_size: 每次扫描最大事件数,默认 100。
"""
name = "external_systems.webhook_pending_consumer"
description = "定时消费 pending 状态的 Webhook 事件"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
*,
default_batch_size: int = 100,
) -> None:
self._session_factory = session_factory
self._default_batch_size = default_batch_size
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行 pending 事件消费。
异常隔离:整体异常转换为 TaskResult(success=False);单事件失败记录
到 errors 列表并继续处理其他事件。
"""
batch_size = int(ctx.payload.get("batch_size", self._default_batch_size))
consumed_count = 0
failed_count = 0
errors: list[dict[str, Any]] = []
try:
async with self._session_factory() as db:
from yuxi.external_systems.infrastructure.container import (
create_use_cases_from_db,
)
use_cases = create_use_cases_from_db(db)
repos = create_repositories(db)
pending_events = await repos.webhook_event.list_pending(limit=batch_size)
for event in pending_events:
try:
if event.id is None:
failed_count += 1
logger.warning(
"webhook_pending_consumer_skip_missing_id",
extra={"subscription_slug": event.subscription_slug},
)
continue
await use_cases.webhook_event_handler.consume_pending_event(
event.id,
)
consumed_count += 1
except Exception as exc: # noqa: BLE001 - 单事件失败不影响其他
failed_count += 1
error_info = {
"event_id": event.id,
"error": str(exc),
}
errors.append(error_info)
logger.warning(
"webhook_pending_consumer_single_failed",
extra=error_info,
)
except Exception as exc:
logger.exception(
"webhook_pending_consumer_failed",
extra={"batch_size": batch_size},
)
return TaskResult(success=False, error=str(exc))
logger.info(
"webhook_pending_consumer_completed",
extra={
"batch_size": batch_size,
"consumed_count": consumed_count,
"failed_count": failed_count,
},
)
return TaskResult(
success=True,
output={
"batch_size": batch_size,
"consumed_count": consumed_count,
"failed_count": failed_count,
"errors": errors,
},
)
class ProcessingTimeoutRecoveryHandler:
"""将 processing 状态超时的 Webhook 事件重置为 pending。
通过 ``scheduled_tasks`` 表配置周期触发,扫描 ``processing_status=processing``
且 ``last_processed_at`` 早于超时阈值的事件,将其状态恢复为 ``pending``
供 ``PendingWebhookEventConsumerHandler`` 下一次调度时消费。
payload 参数:
timeout_seconds: 超时阈值(秒),默认 300。
batch_size: 每次扫描最大事件数,默认 100。
"""
name = "external_systems.webhook_processing_recovery"
description = "将 processing 状态超时的 Webhook 事件重置为 pending"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
*,
default_timeout_seconds: int = 300,
default_batch_size: int = 100,
) -> None:
self._session_factory = session_factory
self._default_timeout_seconds = default_timeout_seconds
self._default_batch_size = default_batch_size
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行 processing 超时恢复。
异常隔离:整体异常转换为 TaskResult(success=False);单事件失败记录
到 errors 列表并继续处理其他事件。
"""
timeout_seconds = int(ctx.payload.get("timeout_seconds", self._default_timeout_seconds))
batch_size = int(ctx.payload.get("batch_size", self._default_batch_size))
recovered_count = 0
failed_count = 0
errors: list[dict[str, Any]] = []
try:
async with self._session_factory() as db:
repos = create_repositories(db)
stuck_events = await repos.webhook_event.list_processing_stuck(
timeout_seconds,
limit=batch_size,
)
for event in stuck_events:
try:
if event.id is None:
failed_count += 1
logger.warning(
"webhook_processing_recovery_skip_missing_id",
extra={"subscription_slug": event.subscription_slug},
)
continue
await repos.webhook_event.update_processing(
event.id,
status="pending",
error=f"processing 超时(>{timeout_seconds}s由调度器恢复为 pending",
increment_attempts=False,
commit=True,
)
recovered_count += 1
except Exception as exc: # noqa: BLE001 - 单事件失败不影响其他
failed_count += 1
error_info = {
"event_id": event.id,
"error": str(exc),
}
errors.append(error_info)
logger.warning(
"webhook_processing_recovery_single_failed",
extra=error_info,
)
except Exception as exc:
logger.exception(
"webhook_processing_recovery_failed",
extra={
"timeout_seconds": timeout_seconds,
"batch_size": batch_size,
},
)
return TaskResult(success=False, error=str(exc))
logger.info(
"webhook_processing_recovery_completed",
extra={
"timeout_seconds": timeout_seconds,
"batch_size": batch_size,
"recovered_count": recovered_count,
"failed_count": failed_count,
},
)
return TaskResult(
success=True,
output={
"timeout_seconds": timeout_seconds,
"batch_size": batch_size,
"recovered_count": recovered_count,
"failed_count": failed_count,
"errors": errors,
},
)
class WebhookRenewalHandler:
"""自动续期即将过期的 Webhook 订阅。
通过 scheduled_tasks 表配置周期触发,扫描 ``expires_at`` 在阈值内且
``auto_renew=True``、``status=active`` 的订阅,延长其过期时间并记录结果。
payload 参数:
threshold_days: 提前扫描天数,默认 7 天。
当前实现为平台侧续期(按 ``retention_days`` 延长过期时间)。外部系统侧
真实续期逻辑需后续按 adapter 类型扩展。
"""
name = "external_systems.webhook_renewal"
description = "自动续期即将过期的 Webhook 订阅"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
self._session_factory = session_factory
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行续期扫描与续期。"""
threshold_days = int(ctx.payload.get("threshold_days", 7))
before = utc_now_naive() + timedelta(days=threshold_days)
renewed_count = 0
failed_count = 0
try:
async with self._session_factory() as db:
repos = create_repositories(db)
expiring = await repos.webhook_subscription.list_expiring(before=before)
for subscription in expiring:
try:
if subscription.id is None:
failed_count += 1
logger.warning(
"webhook_renewal_skip_missing_id",
extra={"subscription_slug": subscription.slug},
)
continue
# TODO: 按系统 adapter 类型调用外部系统真实续期接口;
# 当前为平台侧续期占位,默认视为外部续期成功。
external_renewed = True
external_error: str | None = None
if not external_renewed:
await repos.webhook_subscription.update_renewal_result(
subscription.id,
success=False,
error=external_error or "外部系统续期未实现或失败",
)
failed_count += 1
continue
new_expires_at = utc_now_naive() + timedelta(days=subscription.retention_days)
await repos.webhook_subscription.update_renewal_result(
subscription.id,
success=True,
new_expires_at=new_expires_at,
)
renewed_count += 1
except Exception: # noqa: BLE001 - 单个订阅续期失败不影响其他
failed_count += 1
logger.exception(
"webhook_renewal_single_failed",
extra={"subscription_slug": subscription.slug},
)
except Exception as exc:
logger.exception("webhook_renewal_failed", extra={"threshold_days": threshold_days})
return TaskResult(success=False, error=str(exc))
logger.info(
"webhook_renewal_completed",
extra={
"threshold_days": threshold_days,
"renewed_count": renewed_count,
"failed_count": failed_count,
},
)
return TaskResult(
success=True,
output={
"threshold_days": threshold_days,
"renewed_count": renewed_count,
"failed_count": failed_count,
},
)
class SecretRotationHandler:
"""检查到期密钥轮换策略并执行轮换/告警。
通过 scheduled_tasks 表配置周期触发,调用 ``SecretRotationService``
的 ``scheduled_rotation`` 方法完成到期策略扫描、auto 模式轮换、
manual 模式告警以及已过期策略 critical 告警。
"""
name = "external_systems.secret_rotation"
description = "检查到期密钥轮换策略并执行轮换/告警"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
self._session_factory = session_factory
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行密钥轮换调度。
异常隔离:捕获所有异常并转换为 TaskResult(success=False),不向调度域传播。
"""
_ = ctx # 本 handler 无需 payload 参数
try:
async with self._session_factory() as db:
# 延迟导入 create_use_cases_from_db避免在模块加载阶段触发
# infrastructure container 的装配container 依赖 app_config
# 在本地非容器测试环境中可能不存在)。
from yuxi.external_systems.infrastructure.container import (
create_use_cases_from_db,
)
use_cases = create_use_cases_from_db(db)
output = await use_cases.secret_rotation_service.scheduled_rotation()
except Exception as exc:
logger.exception("secret_rotation_failed")
return TaskResult(success=False, error=str(exc))
return TaskResult(
success=True,
output=output.model_dump(mode="json"),
)
def _compute_next_window(
quota_window: str,
window_start: datetime | None,
window_end: datetime | None,
now: datetime,
) -> tuple[datetime, datetime]:
"""根据配额窗口类型计算下一窗口起止时间。
规则:
- rolling_24h: 以当前时间为起点,向后滚动 24h。
- daily/hourly/minute: 以上一窗口结束时间为起点,按固定步长推进。
- custom: 保持上一窗口长度;无法推断时回退 1 天。
"""
if quota_window == "rolling_24h":
return now, now + timedelta(hours=24)
last_end = window_end or now
if quota_window == "daily":
delta = timedelta(days=1)
elif quota_window == "hourly":
delta = timedelta(hours=1)
elif quota_window == "minute":
delta = timedelta(minutes=1)
elif window_start is not None and window_end is not None:
delta = window_end - window_start
if delta.total_seconds() <= 0:
delta = timedelta(days=1)
else:
delta = timedelta(days=1)
return last_end, last_end + delta
class QuotaThresholdAlertHandler:
"""扫描达到告警/临界阈值的外部系统配额并触发告警。
payload 参数:
limit: 每次扫描最大记录数,默认 100。
"""
name = "external_systems.quota_threshold_alert"
description = "扫描达到告警/临界阈值的外部系统配额并触发告警"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
*,
default_limit: int = 100,
) -> None:
self._session_factory = session_factory
self._default_limit = default_limit
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行配额阈值告警扫描。
先查询 critical 阈值记录(高优先级),再查询 warning 阈值记录并
排除已触达 critical 的记录,避免同一配额同时产生两条不同严重级别告警。
"""
limit = int(ctx.payload.get("limit", self._default_limit))
try:
async with self._session_factory() as db:
repos = create_repositories(db)
alert_manager = AlertManagerImpl(repos.alert)
critical_quotas = await repos.quota_usage.list_near_critical(limit=limit)
critical_ids = {q.id for q in critical_quotas if q.id is not None}
for quota in critical_quotas:
await self._fire_alert(alert_manager, quota, "critical")
warning_quotas = await repos.quota_usage.list_near_warning(limit=limit)
warning_count = 0
for quota in warning_quotas:
if quota.id in critical_ids:
continue
await self._fire_alert(alert_manager, quota, "warning")
warning_count += 1
except Exception as exc:
logger.exception("quota_threshold_alert_failed")
return TaskResult(success=False, error=str(exc))
return TaskResult(
success=True,
output={
"critical_count": len(critical_quotas),
"warning_count": warning_count,
},
)
async def _fire_alert(
self,
alert_manager: AlertManagerImpl,
quota: Any,
severity: str,
) -> None:
"""为单个配额触发告警。"""
title = f"配额接近上限: {quota.quota_name}" if severity == "warning" else f"配额即将耗尽: {quota.quota_name}"
description = (
f"system_id={quota.system_id}, env={quota.env_key}, "
f"key={quota.quota_key}, 已用 {quota.usage_ratio * 100:.1f}%"
)
await alert_manager.fire(
quota.system_id,
quota.env_key,
"quota_near_limit",
severity=severity, # type: ignore[arg-type]
title=title,
description=description,
resource_type="quota_usage",
resource_id=quota.quota_key,
metric_snapshot={
"limit_value": quota.limit_value,
"used_value": quota.used_value,
"usage_ratio": quota.usage_ratio,
"warning_threshold": quota.warning_threshold,
"critical_threshold": quota.critical_threshold,
},
)
class QuotaWindowResetHandler:
"""自动重置已到期外部系统配额窗口。
payload 参数:
limit: 每次扫描最大记录数,默认 100。
"""
name = "external_systems.quota_window_reset"
description = "自动重置已到期的外部系统配额窗口"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
*,
default_limit: int = 100,
) -> None:
self._session_factory = session_factory
self._default_limit = default_limit
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行配额窗口自动重置。
查询 ``window_end <= now`` 的配额记录,按窗口类型计算下一窗口并
将 ``used_value`` 清零。单条失败不影响后续记录。
"""
limit = int(ctx.payload.get("limit", self._default_limit))
now = utc_now_naive()
try:
async with self._session_factory() as db:
repos = create_repositories(db)
quotas = await repos.quota_usage.list(expiring_before=now, limit=limit)
reset_count = 0
failed_count = 0
errors: list[str] = []
for quota in quotas:
try:
window_start, window_end = _compute_next_window(
quota.quota_window,
quota.window_start,
quota.window_end,
now,
)
await repos.quota_usage.reset_window(
quota.system_id,
quota.env_key,
quota.quota_key,
window_start=window_start,
window_end=window_end,
used_value=0,
updated_by="scheduler",
commit=True,
)
reset_count += 1
except Exception as exc: # noqa: BLE001
failed_count += 1
error_msg = f"{quota.system_id}/{quota.env_key}/{quota.quota_key}: {exc}"
errors.append(error_msg)
logger.warning(
"quota_window_reset_failed",
extra={
"system_id": quota.system_id,
"env_key": quota.env_key,
"quota_key": quota.quota_key,
"error": error_msg,
},
)
except Exception as exc:
logger.exception("quota_window_reset_failed")
return TaskResult(success=False, error=str(exc))
return TaskResult(
success=True,
output={
"reset_count": reset_count,
"failed_count": failed_count,
"errors": errors,
},
)
class AuditLogRetentionHandler:
"""清理超过保留期的外部系统审计日志。
通过 ``scheduled_tasks`` 表配置周期触发,软删除 ``ext_system_audit_logs``
表中早于保留期的记录。对齐 channels 模块的 ``ChannelAuditLogRetentionHandler``。
payload 参数:
retention_days: 保留天数,默认取 app_config.ext_system_audit_log_retention_days。
"""
name = "external_systems.audit_log_retention"
description = "清理超过保留期的外部系统审计日志"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
*,
default_retention_days: int = _DEFAULT_AUDIT_LOG_RETENTION_DAYS,
) -> None:
self._session_factory = session_factory
self._default_retention_days = default_retention_days
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行审计日志保留期清理。
异常隔离:捕获所有异常并转换为 TaskResult(success=False),不向调度域传播。
"""
retention_days = int(ctx.payload.get("retention_days", self._default_retention_days))
cutoff = utc_now_naive() - timedelta(days=retention_days)
try:
async with self._session_factory() as db:
repos = create_repositories(db)
deleted_count = await repos.audit_log.delete_old_logs(
cutoff,
updated_by="scheduler",
)
except Exception as exc:
logger.exception(
"ext_audit_log_retention_failed",
extra={
"retention_days": retention_days,
"cutoff": cutoff,
},
)
return TaskResult(success=False, error=str(exc))
if deleted_count > 0:
logger.info(
"ext_audit_log_retention",
extra={
"deleted_count": deleted_count,
"retention_days": retention_days,
"cutoff": cutoff,
},
)
return TaskResult(
success=True,
output={
"deleted_count": deleted_count,
"retention_days": retention_days,
},
)
# 回收站定时清理默认保留天数(软删除超过此天数的记录物理删除)
_DEFAULT_TRASH_RETENTION_DAYS = 90
class TrashPurgeHandler:
"""定时清理回收站中超过保留期的软删除资源。
通过 ``scheduled_tasks`` 表配置周期触发,调用 ``TrashService.purge`` 物理删除
超过 ``older_than_days`` 天的软删除记录。对齐手动清理POST /trash/purge
的语义,复用同一 service 实现保证事务边界 / 审计 / 事件一致。
payload 参数:
older_than_days: 保留天数,默认 90。
"""
name = "external_systems.trash_purge"
description = "定时清理回收站中超过保留期的软删除资源"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
*,
default_older_than_days: int = _DEFAULT_TRASH_RETENTION_DAYS,
) -> None:
self._session_factory = session_factory
self._default_older_than_days = default_older_than_days
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行回收站定时清理。
异常隔离:捕获所有异常并转换为 TaskResult(success=False),不向调度域传播。
"""
older_than_days = int(ctx.payload.get("older_than_days", self._default_older_than_days))
try:
async with self._session_factory() as db:
# 延迟导入:避免模块加载阶段触发 infrastructure container 装配
from yuxi.external_systems.infrastructure.container import (
create_use_cases_from_db,
)
from yuxi.external_systems.use_cases.dto.trash import PurgeTrashInput
use_cases = create_use_cases_from_db(db)
output = await use_cases.trash_service.purge(
PurgeTrashInput(
older_than_days=older_than_days,
purged_by="scheduler",
)
)
except Exception as exc:
logger.exception(
"trash_purge_failed",
extra={"older_than_days": older_than_days},
)
return TaskResult(success=False, error=str(exc))
if output.purged_count > 0:
logger.info(
"trash_purge_completed",
extra={
"purged_count": output.purged_count,
"by_type": output.by_type,
"older_than_days": older_than_days,
"cutoff_at": output.cutoff_at.isoformat(),
},
)
return TaskResult(
success=True,
output=output.model_dump(mode="json"),
)
class HealthCheckSchedulerHandler:
"""定时健康检查探测。
通过 ``scheduled_tasks`` 表配置周期触发,扫描所有启用的外部系统,
对每个系统调用 ``tool_service.trigger_system_health_check`` 执行探测。
单个系统失败不影响其他系统。
payload 参数:
batch_size: 每次扫描最大系统数,默认 50。
"""
name = "external_systems.health_check"
description = "定时健康检查探测"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
*,
default_batch_size: int = 50,
) -> None:
self._session_factory = session_factory
self._default_batch_size = default_batch_size
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行定时健康检查。
异常隔离:捕获所有异常并转换为 TaskResult(success=False),不向调度域传播。
单个系统失败记录到 errors 列表,不影响整体任务成功。
"""
batch_size = int(ctx.payload.get("batch_size", self._default_batch_size))
checked_count = 0
failed_count = 0
errors: list[dict[str, Any]] = []
try:
async with self._session_factory() as db:
from yuxi.external_systems.infrastructure.container import (
create_use_cases_from_db,
)
from yuxi.external_systems.use_cases.dto.health_check import (
TriggerSystemHealthCheckInput,
)
repos = create_repositories(db)
use_cases = create_use_cases_from_db(db)
system_ids = await repos.system.list_ids(
enabled=True,
sort_by="created_at",
sort_order="asc",
)
for system_id in system_ids[:batch_size]:
try:
await use_cases.tool_service.trigger_system_health_check(
TriggerSystemHealthCheckInput(
system_id=system_id,
triggered_by="scheduler",
),
)
checked_count += 1
except Exception as exc: # noqa: BLE001 - 单系统失败不影响其他
failed_count += 1
errors.append({"system_id": system_id, "error": str(exc)})
logger.warning(
"health_check_single_failed",
extra={"system_id": system_id, "error": str(exc)},
)
except Exception as exc:
logger.exception("health_check_scheduler_failed")
return TaskResult(success=False, error=str(exc))
logger.info(
"health_check_scheduler_completed",
extra={
"checked_count": checked_count,
"failed_count": failed_count,
"batch_size": batch_size,
},
)
return TaskResult(
success=True,
output={
"checked_count": checked_count,
"failed_count": failed_count,
"batch_size": batch_size,
"errors": errors,
},
)
class TestRegressionSchedulerHandler:
"""定时执行到期的测试用例回归。
通过 ``scheduled_tasks`` 表配置周期触发,调用 ``TestRegressionService``
的 ``scheduled_regression`` 方法执行所有到期用例。执行结果中的单条异常
已由 service 层隔离,本 handler 仅做整体异常转换。
payload 参数:
当前无需 payload 参数(服务层内部决定到期用例)。
"""
name = "external_systems.test_regression"
description = "定时执行到期的外部系统工具测试用例回归"
def __init__(
self,
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
self._session_factory = session_factory
async def execute(self, ctx: TaskContext) -> TaskResult:
"""执行到期测试用例回归。
异常隔离:捕获所有异常并转换为 TaskResult(success=False),不向调度域传播。
"""
_ = ctx
try:
async with self._session_factory() as db:
from yuxi.external_systems.infrastructure.container import (
create_use_cases_from_db,
)
use_cases = create_use_cases_from_db(db)
output = await use_cases.test_regression_service.scheduled_regression()
except Exception as exc:
logger.exception("test_regression_scheduler_failed")
return TaskResult(success=False, error=str(exc))
return TaskResult(
success=True,
output=output.model_dump(mode="json"),
)
def register_scheduler_handlers(registry: HandlerRegistry) -> None:
"""向 scheduler 注册 external_systems 模块的定时任务 handler。
由 worker 启动钩子在装配阶段统一调用。新增 handler 时在此函数内追加
``registry.register(...)`` 即可。
Args:
registry: worker 进程级 handler 注册表。
"""
registry.register(
WebhookEventCleanupHandler(
session_factory=pg_manager.get_async_session_context,
),
)
registry.register(
PendingWebhookEventConsumerHandler(
session_factory=pg_manager.get_async_session_context,
),
)
registry.register(
ProcessingTimeoutRecoveryHandler(
session_factory=pg_manager.get_async_session_context,
),
)
registry.register(
QuotaThresholdAlertHandler(
session_factory=pg_manager.get_async_session_context,
),
)
registry.register(
QuotaWindowResetHandler(
session_factory=pg_manager.get_async_session_context,
),
)
registry.register(
WebhookRenewalHandler(
session_factory=pg_manager.get_async_session_context,
),
)
registry.register(
SecretRotationHandler(
session_factory=pg_manager.get_async_session_context,
),
)
registry.register(
AuditLogRetentionHandler(
session_factory=pg_manager.get_async_session_context,
default_retention_days=getattr(
app_config, "ext_system_audit_log_retention_days", _DEFAULT_AUDIT_LOG_RETENTION_DAYS
),
),
)
registry.register(
TrashPurgeHandler(
session_factory=pg_manager.get_async_session_context,
),
)
registry.register(
HealthCheckSchedulerHandler(
session_factory=pg_manager.get_async_session_context,
),
)
registry.register(
TestRegressionSchedulerHandler(
session_factory=pg_manager.get_async_session_context,
),
)