refactor(scheduler): 整理代码格式与导入,新增调度器配置与handler注册框架

本次提交包含多类改进:
1.  清理冗余空行、修复导入格式与类型检查占位
2.  新增external_systems与scheduler上下文的handler注册框架
3.  补全调度器相关配置项与环境变量覆盖逻辑
4.  新增配置校验与敏感字段脱敏逻辑
5.  简化协议方法声明格式
This commit is contained in:
Kris 2026-06-22 21:22:07 +08:00
parent 3ad986e033
commit 2a401450cb
9 changed files with 212 additions and 22 deletions

View File

@ -8,7 +8,7 @@ from typing import Any
import tomli
import tomli_w
from pydantic import BaseModel, Field, PrivateAttr
from pydantic import BaseModel, Field, PrivateAttr, model_validator
from yuxi.utils.logging_config import logger
@ -55,6 +55,48 @@ class Config(BaseModel):
ssrf_blocked_domains: list[str] = Field(default_factory=list, description="SSRF 域名黑名单")
ssrf_dns_pinned_ttl: int = Field(default=300, description="SSRF DNS 缓存时间(秒)")
# === 定时任务调度配置(扁平字段,对齐既有模式,见设计方案 §13 ===
scheduler_enabled: bool = Field(default=True, description="定时任务总开关")
scheduler_tick_interval_seconds: int = Field(default=60, description="tick 频率(秒)")
scheduler_max_consecutive_errors: int = Field(default=5, description="死信阈值")
scheduler_task_timeout_seconds: int = Field(default=600, description="单任务超时(秒)")
scheduler_max_payload_size_kb: int = Field(default=64, description="payload 大小上限KB")
scheduler_max_active_tasks_per_owner: int = Field(default=100, description="业务方任务数上限")
scheduler_stagger_max_seconds: int = Field(default=300, description="整点抖动上限(秒)")
scheduler_run_log_retention_days: int = Field(default=90, description="run_logs 保留天数")
scheduler_idempotency_retention_hours: int = Field(default=24, description="幂等记录保留小时数")
scheduler_min_cron_interval_minutes: int = Field(default=10, description="cron 最小间隔(分钟)")
scheduler_backoff_schedule: list[int] = Field(
default_factory=lambda: [10, 30, 120, 600, 3600],
description="指数退避序列(秒)",
)
# === Web 搜索配置(扁平字段,见设计方案 §10.1.1 ===
web_search_provider_chain: list[str] = Field(
default_factory=lambda: ["searxng", "tavily"],
description="Web 搜索 Provider 优先级链",
)
web_search_searxng_base_url: str = Field(default="http://searxng:8080", description="SearXNG 服务地址")
web_search_searxng_timeout: int = Field(default=10, description="SearXNG 调用超时(秒)")
web_search_searxng_engines: list[str] = Field(default_factory=list, description="SearXNG 指定引擎列表(空=使用默认)")
web_search_searxng_language: str = Field(default="auto", description="SearXNG 搜索语言")
web_search_searxng_safesearch: int = Field(default=0, description="SearXNG 安全搜索级别0/1/2")
web_search_tavily_api_key: str = Field(default="", description="Tavily API Key空=读 TAVILY_API_KEY 环境变量)")
web_search_tavily_timeout: int = Field(default=10, description="Tavily 调用超时(秒)")
web_search_default_max_results: int = Field(default=5, description="Web 搜索默认结果数")
web_search_rate_limit_per_minute: int = Field(default=20, description="单 agent Web 搜索每分钟调用上限")
# === Web 抓取配置(扁平字段,见设计方案 §10.1.2 ===
crawl4ai_base_url: str = Field(default="http://crawl4ai:11235", description="Crawl4AI 服务地址")
crawl4ai_timeout: int = Field(default=30, description="Crawl4AI 单页抓取超时(秒)")
crawl4ai_api_token: str = Field(default="", description="Crawl4AI API Token预留 JWT 认证)")
web_crawl_default_max_depth: int = Field(default=2, description="Web 抓取默认递归深度")
web_crawl_default_max_pages: int = Field(default=20, description="Web 抓取默认最大页数")
web_crawl_default_concurrency: int = Field(default=5, description="Web 抓取默认并发数")
web_crawl_domain_whitelist: list[str] = Field(default_factory=list, description="Web 抓取域名白名单(空=允许所有公网域名)")
web_crawl_respect_robots: bool = Field(default=True, description="Web 抓取是否尊重 robots.txt")
web_crawl_rate_limit_per_minute: int = Field(default=10, description="单 agent Web 抓取每分钟调用上限")
_config_file: Path | None = PrivateAttr(default=None)
_user_modified_fields: set[str] = PrivateAttr(default_factory=set)
@ -121,6 +163,93 @@ class Config(BaseModel):
if ssrf_env:
self.ssrf_allowed_domains = [d.strip() for d in ssrf_env.split(",") if d.strip()]
# === scheduler 环境变量覆盖(对齐既有三段式写法,见设计方案 §13 ===
self.scheduler_enabled = (
os.getenv("SCHEDULER_ENABLED", "true").lower() in ("true", "1", "yes")
)
self.scheduler_tick_interval_seconds = int(
os.getenv("SCHEDULER_TICK_INTERVAL_SECONDS") or self.scheduler_tick_interval_seconds or 60
)
self.scheduler_max_consecutive_errors = int(
os.getenv("SCHEDULER_MAX_CONSECUTIVE_ERRORS") or self.scheduler_max_consecutive_errors or 5
)
self.scheduler_task_timeout_seconds = int(
os.getenv("SCHEDULER_TASK_TIMEOUT_SECONDS") or self.scheduler_task_timeout_seconds or 600
)
self.scheduler_max_payload_size_kb = int(
os.getenv("SCHEDULER_MAX_PAYLOAD_SIZE_KB") or self.scheduler_max_payload_size_kb or 64
)
self.scheduler_max_active_tasks_per_owner = int(
os.getenv("SCHEDULER_MAX_ACTIVE_TASKS_PER_OWNER")
or self.scheduler_max_active_tasks_per_owner
or 100
)
self.scheduler_stagger_max_seconds = int(
os.getenv("SCHEDULER_STAGGER_MAX_SECONDS") or self.scheduler_stagger_max_seconds or 300
)
self.scheduler_run_log_retention_days = int(
os.getenv("SCHEDULER_RUN_LOG_RETENTION_DAYS") or self.scheduler_run_log_retention_days or 90
)
self.scheduler_idempotency_retention_hours = int(
os.getenv("SCHEDULER_IDEMPOTENCY_RETENTION_HOURS")
or self.scheduler_idempotency_retention_hours
or 24
)
self.scheduler_min_cron_interval_minutes = int(
os.getenv("SCHEDULER_MIN_CRON_INTERVAL_MINUTES")
or self.scheduler_min_cron_interval_minutes
or 10
)
# === Web 搜索环境变量覆盖 ===
self.web_search_searxng_base_url = (
os.getenv("WEB_SEARCH_SEARXNG_BASE_URL") or self.web_search_searxng_base_url
)
self.web_search_searxng_timeout = int(
os.getenv("WEB_SEARCH_SEARXNG_TIMEOUT") or self.web_search_searxng_timeout
)
self.web_search_searxng_language = (
os.getenv("WEB_SEARCH_SEARXNG_LANGUAGE") or self.web_search_searxng_language
)
self.web_search_searxng_safesearch = int(
os.getenv("WEB_SEARCH_SEARXNG_SAFESEARCH") or self.web_search_searxng_safesearch
)
self.web_search_tavily_api_key = (
os.getenv("TAVILY_API_KEY") or self.web_search_tavily_api_key
)
self.web_search_tavily_timeout = int(
os.getenv("WEB_SEARCH_TAVILY_TIMEOUT") or self.web_search_tavily_timeout
)
# === Web 抓取环境变量覆盖 ===
self.crawl4ai_base_url = (
os.getenv("CRAWL4AI_BASE_URL") or self.crawl4ai_base_url
)
self.crawl4ai_timeout = int(
os.getenv("CRAWL4AI_TIMEOUT") or self.crawl4ai_timeout
)
self.crawl4ai_api_token = (
os.getenv("CRAWL4AI_API_TOKEN") or self.crawl4ai_api_token
)
@model_validator(mode="after")
def _validate_web_search_crawl_config(self):
"""校验 web_search 与 web_crawl 配置合法性。"""
valid_providers = {"searxng", "tavily"}
for provider in self.web_search_provider_chain:
if provider not in valid_providers:
raise ValueError(
f"web_search_provider_chain 含未知 Provider 名: {provider}"
f"合法值: {valid_providers}"
)
if not 1 <= self.web_search_default_max_results <= 10:
raise ValueError("web_search_default_max_results 必须在 [1, 10] 范围内")
if not 1 <= self.web_crawl_default_max_depth <= 3:
raise ValueError("web_crawl_default_max_depth 必须在 [1, 3] 范围内")
if not 1 <= self.web_crawl_default_max_pages <= 100:
raise ValueError("web_crawl_default_max_pages 必须在 [1, 100] 范围内")
return self
def save(self) -> None:
if not self._config_file:
logger.warning("Config file path not set")
@ -146,6 +275,11 @@ class Config(BaseModel):
def dump_config(self) -> dict[str, Any]:
config_dict = self.model_dump()
# 敏感字段脱敏
sensitive_keys = {"web_search_tavily_api_key", "crawl4ai_api_token"}
for key in sensitive_keys:
if key in config_dict and config_dict[key]:
config_dict[key] = "***"
fields_info = {}
for field_name, field_info in Config.model_fields.items():
if field_info.exclude:

View File

@ -0,0 +1,14 @@
"""external_systems 的定时任务 handler 集合。
本子包存放 external_systems 模块接入 scheduler 限界上下文的 handler 实现
每个 handler 实现 ``yuxi.scheduler.core.contracts.TaskHandler`` 协议
``external_systems/scheduler.py`` ``register_scheduler_handlers``
统一注册到 worker 进程级 ``HandlerRegistry``
依赖方向仅依赖 ``scheduler/core/contracts````TaskHandler`` 协议+
``storage/postgres/manager``db 会话+ 本模块的 use_cases / framework/runtime
不依赖 ``scheduler`` use_cases / adapters / infrastructure
会话隔离handler 通过构造函数注入 ``session_factory``每次 ``execute``
创建独立 db 会话避免 worker 进程长生命周期会话导致的连接失效问题
"""

View File

@ -0,0 +1,45 @@
"""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 yuxi.scheduler.framework.runtime import HandlerRegistry
from yuxi.storage.postgres.manager import pg_manager
__all__ = ["register_scheduler_handlers"]
def register_scheduler_handlers(registry: HandlerRegistry) -> None:
"""向 scheduler 注册 external_systems 模块的定时任务 handler。
worker 启动钩子在装配阶段统一调用新增 handler 时在此函数内追加
``registry.register(...)`` 即可
Args:
registry: worker 进程级 handler 注册表
"""
session_factory = pg_manager.get_async_session_context
# 在此追加 external_systems 的定时任务 handler 注册
# registry.register(
# XxxHandler(session_factory=session_factory),
# )
_ = session_factory # 暂无 handler占位避免未使用告警

View File

@ -21,14 +21,22 @@ from typing import TYPE_CHECKING, Any
from yuxi.scheduler.core.models import (
DailyStat as DailyStatDC,
)
from yuxi.scheduler.core.models import (
HandlerSummary as HandlerSummaryDC,
)
from yuxi.scheduler.core.models import (
ScheduledTask as ScheduledTaskDC,
)
from yuxi.scheduler.core.models import (
ScheduledTaskRunLog as ScheduledTaskRunLogDC,
)
if TYPE_CHECKING:
from yuxi.storage.postgres.models_scheduler import (
ScheduledTask as ScheduledTaskORM,
)
from yuxi.storage.postgres.models_scheduler import (
ScheduledTaskRunLog as ScheduledTaskRunLogORM,
)

View File

@ -23,6 +23,8 @@ from yuxi.repositories.scheduled.scheduled_task_repository import (
)
from yuxi.scheduler.core.models import (
HandlerSummary as HandlerSummaryDC,
)
from yuxi.scheduler.core.models import (
ScheduledTask as ScheduledTaskDC,
)

View File

@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING:
from yuxi.scheduler.core.contracts import UnitOfWork
pass
class SqlAlchemyUnitOfWork:

View File

@ -37,7 +37,6 @@ from yuxi.scheduler.core.models import (
ScheduledTaskRunLog,
)
# ─── 仓储端口 ────────────────────────────────────────────────────────

View File

@ -184,9 +184,7 @@ class SchedulerServicePort(Protocol):
"""
...
async def list_all_run_logs(
self, input_dto: ListAllRunLogsInput
) -> ListRunLogsOutput:
async def list_all_run_logs(self, input_dto: ListAllRunLogsInput) -> ListRunLogsOutput:
"""跨任务列出执行日志FR-ST-09
``task_id`` 非空时按任务维度查询等价于 ``list_run_logs````task_id``
@ -195,9 +193,7 @@ class SchedulerServicePort(Protocol):
"""
...
async def list_daily_stats(
self, input_dto: ListDailyStatsInput
) -> ListDailyStatsOutput:
async def list_daily_stats(self, input_dto: ListDailyStatsInput) -> ListDailyStatsOutput:
"""列出日聚合统计FR-ST-09
按日期范围 + handler 维度查询日聚合统计返回含 ``total_count`` /
@ -264,9 +260,7 @@ class SchedulerServicePort(Protocol):
# 任务回收站asyncDTO 入参)
# ------------------------------------------------------------------
async def list_deleted_tasks(
self, input_dto: ListDeletedTasksInput
) -> ListTasksOutput:
async def list_deleted_tasks(self, input_dto: ListDeletedTasksInput) -> ListTasksOutput:
"""列出已删除任务回收站FR-ST-05
返回软删除的任务列表 ``deleted_at`` 降序排序支持时间范围过滤与分页

View File

@ -20,7 +20,7 @@
from __future__ import annotations
import uuid
from datetime import UTC, date, datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo
@ -556,9 +556,7 @@ class SchedulerService(SchedulerServicePort):
)
return to_run_log_output(log)
async def list_all_run_logs(
self, input_dto: ListAllRunLogsInput
) -> ListRunLogsOutput:
async def list_all_run_logs(self, input_dto: ListAllRunLogsInput) -> ListRunLogsOutput:
"""跨任务列出执行日志FR-ST-09
``task_id`` 非空时按任务维度查询``task_id`` 为空时按 ``status`` 跨任务
@ -627,9 +625,7 @@ class SchedulerService(SchedulerServicePort):
)
return to_list_upcoming_output(tasks)
async def list_daily_stats(
self, input_dto: ListDailyStatsInput
) -> ListDailyStatsOutput:
async def list_daily_stats(self, input_dto: ListDailyStatsInput) -> ListDailyStatsOutput:
"""列出日聚合统计FR-ST-09
解析 ``start_date`` / ``end_date`` ``date``校验范围 90
@ -1029,9 +1025,7 @@ class SchedulerService(SchedulerServicePort):
# 任务回收站
# ==================================================================
async def list_deleted_tasks(
self, input_dto: ListDeletedTasksInput
) -> ListTasksOutput:
async def list_deleted_tasks(self, input_dto: ListDeletedTasksInput) -> ListTasksOutput:
"""列出已删除任务回收站FR-ST-05
``list_deleted`` 不返回总数``total`` 取当前页条数客户端可按