import logging import time from datetime import datetime from yuxi.channel.cron.schedule import ( compute_next_run_at_ms, is_recurring_top_of_hour_cron_expr, resolve_default_cron_stagger_ms, ) from yuxi.channel.cron.types import ( CronDeliveryTarget, CronFailureAlert, CronJob, ScheduleKind, ) logger = logging.getLogger(__name__) CRON_FIELD_COUNT = 5 CRON_FIELD_RANGES = [ (0, 59), (0, 23), (1, 31), (1, 12), (0, 7), ] def _validate_cron_expr_format(expr: str) -> str | None: parts = expr.strip().split() if len(parts) != CRON_FIELD_COUNT: return f"cron 表达式应包含 {CRON_FIELD_COUNT} 个字段,实际 {len(parts)} 个: {expr}" for i, (field, (lo, hi)) in enumerate(zip(parts, CRON_FIELD_RANGES)): for segment in field.split(","): step = 1 if "/" in segment: segment, step_str = segment.split("/", 1) try: step = int(step_str) except ValueError: return f"cron 表达式字段 {i} 步长无效: {step_str}" if step < 1: return f"cron 表达式字段 {i} 步长必须 >= 1: {step}" if segment == "*": continue if "-" in segment: try: low_str, high_str = segment.split("-", 1) low = int(low_str) high = int(high_str) except ValueError: return f"cron 表达式字段 {i} 范围格式无效: {segment}" if low < lo or high > hi or low > high: return f"cron 表达式字段 {i} 范围 {low}-{high} 超出 [{lo}, {hi}]" else: try: val = int(segment) except ValueError: return f"cron 表达式字段 {i} 值无效: {segment}" if val < lo or val > hi: return f"cron 表达式字段 {i} 值 {val} 超出 [{lo}, {hi}]" return None def _validate_at_value(value: str) -> str | None: try: at_ms = int(value) if at_ms <= 0: return f"AT 调度值必须为正整数 (毫秒时间戳): {value}" return None except (ValueError, TypeError): pass try: datetime.fromisoformat(value) return None except (ValueError, TypeError): return f"AT 调度值格式无效 (需为毫秒时间戳或 ISO 8601): {value}" def _validate_every_value(every_ms: int | None, anchor_ms: int | None) -> str | None: if every_ms is None: return "EVERY 调度必须设置 every_ms" if every_ms <= 0: return f"EVERY 调度的 every_ms 必须 > 0: {every_ms}" if anchor_ms is not None and anchor_ms < 0: return f"EVERY 调度的 anchor_ms 不能为负数: {anchor_ms}" return None def _validate_delivery_target(target: CronDeliveryTarget) -> str | None: if not target.channel or not target.channel.strip(): return f"投递目标缺少 channel: {target.to_dict()}" if not target.target_id or not target.target_id.strip(): return f"投递目标缺少 target_id: {target.to_dict()}" return None def normalize_job(job: CronJob) -> list[str]: warnings: list[str] = [] if not job.id or not job.id.strip(): warnings.append("CronJob.id 不能为空") job.id = job.id or f"unnamed_{int(time.time() * 1000)}" if not job.name: job.name = job.id warnings.append(f"未设置 name,已使用 id 作为名称: {job.name}") if job.error_threshold < 1: job.error_threshold = 5 warnings.append(f"error_threshold 必须 >= 1,已重置为 {job.error_threshold}") if job.max_runs < 0: job.max_runs = 0 warnings.append(f"max_runs 不能为负数,已重置为 0 (无限制)") if job.stagger_ms < 0: job.stagger_ms = 0 warnings.append(f"stagger_ms 不能为负数,已重置为 0") if job.schedule_kind == ScheduleKind.CRON: err = _validate_cron_expr_format(job.schedule_value) if err: warnings.append(err) if job.stagger_ms == 0 and job.schedule_value: default_stagger = resolve_default_cron_stagger_ms(job.schedule_value) if default_stagger > 0: job.stagger_ms = default_stagger warnings.append(f"整点 CRON 表达式未设置 stagger,已自动设置 {default_stagger}ms 错峰") elif job.schedule_kind == ScheduleKind.AT: err = _validate_at_value(job.schedule_value) if err: warnings.append(err) if job.stagger_ms > 0: warnings.append("AT 调度不支持 stagger,已忽略") job.stagger_ms = 0 elif job.schedule_kind == ScheduleKind.EVERY: err = _validate_every_value(job.every_ms, job.anchor_ms) if err: warnings.append(err) for i, target in enumerate(job.delivery): err = _validate_delivery_target(target) if err: warnings.append(f"投递目标 [{i}]: {err}") if job.failure_alert.threshold < 1: job.failure_alert.threshold = 5 warnings.append(f"告警阈值必须 >= 1,已重置为 {job.failure_alert.threshold}") if job.failure_alert.cooldown_ms < 0: job.failure_alert.cooldown_ms = 600_000 warnings.append(f"告警冷却时间不能为负数,已重置为 {job.failure_alert.cooldown_ms}ms") try: next_at = compute_next_run_at_ms(job) if next_at is not None: job.state.next_run_at_ms = next_at except Exception as e: warnings.append(f"无法计算下次执行时间: {e}") if warnings: logger.warning("CronJob '%s' 规范化产生 %d 条警告: %s", job.id, len(warnings), warnings) return warnings