"""定时任务调度相关的 PostgreSQL 数据模型。 表名统一以 `scheduled_task_` 为前缀;属于调度限界上下文(scheduler bounded context),与 `external_systems` / `channel` 等上下文隔离,不跨上下文 import 实现类。字段类型复用 `JSON().with_variant(JSONB, "postgresql")` 以兼容多方言。 本模块仅承载调度域自身的持久化模型: - `scheduled_tasks`:任务定义与状态机 - `scheduled_task_run_logs`:每次执行的审计日志 - `scheduled_task_run_log_daily`:按天 + handler_name 维度的执行统计聚合 (借鉴 xxl-job `xxl_job_logreport` 分离设计,统计查询走聚合表避免扫描明细) - `scheduled_task_idempotency`:Admin API 写操作幂等去重 注意:`task_id` 在子表中为字符串引用而非外键(PRD §6.1.6),允许 `at` 类型 任务删除后日志保留以维持审计轨迹。所有表统一包含审计字段 (created_by/updated_by/created_at/updated_at/is_deleted/deleted_at), 支持软删除与操作追溯,与 `external_systems` / `channel` 等上下文保持一致。 """ from __future__ import annotations from typing import Any from sqlalchemy import ( DATE, JSON, Boolean, CheckConstraint, Column, DateTime, Index, Integer, String, Text, UniqueConstraint, text, ) from sqlalchemy.dialects.postgresql import JSONB from yuxi.storage.postgres.models_business import Base from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive JSON_VALUE = JSON().with_variant(JSONB, "postgresql") class ScheduledTask(Base): """定时任务定义。 一条记录对应一次任务注册;`next_run_at` 由调度器在创建/执行后按 `schedule_kind`(cron / at)计算。`status` 表示任务状态机当前所处状态 (active / paused / dead_letter),与 `enabled` 共同决定是否被 tick 扫描。 """ __tablename__ = "scheduled_tasks" # 主键 id = Column(Integer, primary_key=True, autoincrement=True) # 业务标识 task_id = Column( String(64), nullable=False, comment="业务 UUID;system 任务使用约定前缀如 system:run_log_cleanup;唯一性由部分唯一索引 uq_scheduled_tasks_task_id_active 保障(is_deleted=0),软删除后允许重建", ) handler_name = Column( String(128), nullable=False, index=True, comment="引用已注册 handler(非外键,worker 执行时校验)" ) # 归属范围(v1 仅作数据隔离字段,不引入 business_admin 角色) owner_scope = Column(String(32), nullable=False, default="system", comment="任务归属范围:system / business") owner_id = Column(String(128), nullable=False, comment="业务方 ID,如 channel_type:account_id") # 调度配置 schedule_kind = Column(String(16), nullable=False, default="cron", comment="调度类型:cron / at") cron_expression = Column(String(64), nullable=True, comment="5 字段标准 cron 表达式;cron 类型必填") run_at = Column( DateTime, nullable=True, comment="一次性执行时间(UTC,与 tz 无关);at 类型必填且必须未来时间。at 类型创建时 next_run_at 同步设为此值,执行后 next_run_at 置 NULL", ) tz = Column( String(64), nullable=False, default="Asia/Shanghai", comment="IANA 时区,仅用于 cron 下一次执行时间计算;run_at 始终以 UTC 存储,不受 tz 影响", ) payload = Column(JSON_VALUE, nullable=False, default=dict, comment="handler 入参(≤64KB)") # 运行时控制 enabled = Column(Boolean, nullable=False, default=True, comment="是否启用;dead_letter 时为 False") delete_after_run = Column( Boolean, nullable=False, default=False, comment="执行成功后是否删除任务记录;DB 默认 False,at 类型由应用层显式设置 True", ) block_strategy = Column( String(32), nullable=False, default="discard_later", comment="阻塞处理策略:discard_later(v1)/ serial_execution(v2)/ " "cover_early(v2);借鉴 xxl-job executor_block_strategy", ) stagger_seconds = Column(Integer, nullable=True, comment="整点抖动秒数;仅 cron 整点模式计算,按 task_id 哈希固定") # 状态机 consecutive_errors = Column( Integer, nullable=False, default=0, comment="连续失败次数;成功执行后归零,达阈值进入死信" ) status = Column( String(32), nullable=False, default="active", index=True, comment="任务状态:active / paused / dead_letter" ) # 执行轨迹 last_run_at = Column(DateTime, nullable=True, comment="上次执行开始时间") next_run_at = Column( DateTime, nullable=True, comment="下次执行时间(UTC)。cron 类型由调度器按 cron 表达式计算;at 类型创建时=run_at,执行后置 NULL;失败退避时按退避序列计算", ) last_error = Column(Text, nullable=True, comment="最近一次错误信息") # 审计字段 created_by = Column(String(64), nullable=True) updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) is_deleted = Column(Integer, nullable=False, default=0, index=True) deleted_at = Column(DateTime, nullable=True) __table_args__ = ( # A1: cron/at 互斥约束 —— cron 必填 cron_expression,at 必填 run_at CheckConstraint( "(schedule_kind = 'cron' AND cron_expression IS NOT NULL) OR (schedule_kind = 'at' AND run_at IS NOT NULL)", name="ck_scheduled_tasks_schedule_kind_fields", ), # B1: tick 扫描索引 —— WHERE enabled=True AND status='active' AND next_run_at <= ? Index("ix_scheduled_tasks_tick_scan", "enabled", "status", "next_run_at"), Index("ix_scheduled_tasks_owner", "owner_scope", "owner_id"), # A2: task_id 部分唯一索引 —— 软删除后允许重建同名 task_id Index( "uq_scheduled_tasks_task_id_active", "task_id", unique=True, postgresql_where=text("is_deleted = 0"), ), ) def to_dict(self) -> dict[str, Any]: """序列化为 API 响应字典。""" return { "id": self.id, "task_id": self.task_id, "handler_name": self.handler_name, "owner_scope": self.owner_scope, "owner_id": self.owner_id, "schedule_kind": self.schedule_kind, "cron_expression": self.cron_expression, "run_at": format_utc_datetime(self.run_at) if self.run_at else None, "tz": self.tz, "payload": self.payload or {}, "enabled": bool(self.enabled), "delete_after_run": bool(self.delete_after_run), "block_strategy": self.block_strategy, "stagger_seconds": self.stagger_seconds, "consecutive_errors": self.consecutive_errors, "status": self.status, "last_run_at": format_utc_datetime(self.last_run_at) if self.last_run_at else None, "next_run_at": format_utc_datetime(self.next_run_at) if self.next_run_at else None, "last_error": self.last_error, "created_by": self.created_by, "updated_by": self.updated_by, "created_at": format_utc_datetime(self.created_at), "updated_at": format_utc_datetime(self.updated_at), "is_deleted": self.is_deleted, "deleted_at": format_utc_datetime(self.deleted_at) if self.deleted_at else None, } class ScheduledTaskRunLog(Base): """定时任务单次执行日志。 每次 tick 或手动触发生成一条 `running` 记录,执行结束后更新为 `success` / `failure` / `timeout`。阻塞丢弃场景(`block_strategy= discard_later` 且上次未完成)写 `skipped`,不计入日聚合统计。 `run_id` 全局唯一,用于幂等控制与 ARQ `_job_id` 关联。`task_id` 为 字符串引用非外键,任务删除后日志保留。 """ __tablename__ = "scheduled_task_run_logs" # 主键 id = Column(Integer, primary_key=True, autoincrement=True) # 关联(字符串引用,非外键) task_id = Column(String(64), nullable=False, comment="关联 scheduled_tasks.task_id;非外键,任务删除后日志保留") run_id = Column( String(64), nullable=False, unique=True, comment="单次执行唯一标识(UUID),用于幂等控制与 ARQ _job_id 关联" ) # 执行上下文 triggered_by = Column( String(16), nullable=False, default="auto", comment="触发方式:auto(tick)/ manual(Admin API)" ) status = Column( String(16), nullable=False, default="running", index=True, comment="执行状态:running / success / failure / timeout / skipped(阻塞丢弃,不计入聚合)", ) # 执行结果 error_message = Column(Text, nullable=True, comment="失败或超时时的错误信息") output = Column(JSON_VALUE, nullable=True, comment="handler 返回的 output") # 时间轨迹 started_at = Column(DateTime, nullable=False, default=utc_now_naive, comment="执行开始时间") finished_at = Column(DateTime, nullable=True, comment="执行结束时间;running 状态时为 NULL") # 审计字段 created_by = Column(String(64), nullable=True) updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) is_deleted = Column(Integer, nullable=False, default=0, index=True) deleted_at = Column(DateTime, nullable=True) __table_args__ = ( Index("ix_scheduled_task_run_logs_task_started", "task_id", "started_at"), Index("ix_scheduled_task_run_logs_started", "started_at"), ) def to_dict(self) -> dict[str, Any]: """序列化为 API 响应字典。""" return { "id": self.id, "task_id": self.task_id, "run_id": self.run_id, "triggered_by": self.triggered_by, "status": self.status, "error_message": self.error_message, "output": self.output, "started_at": format_utc_datetime(self.started_at), "finished_at": format_utc_datetime(self.finished_at) if self.finished_at else None, "created_by": self.created_by, "updated_by": self.updated_by, "created_at": format_utc_datetime(self.created_at), "updated_at": format_utc_datetime(self.updated_at), "is_deleted": self.is_deleted, "deleted_at": format_utc_datetime(self.deleted_at) if self.deleted_at else None, } class ScheduledTaskRunLogDaily(Base): """定时任务执行日志日聚合统计。 借鉴 xxl-job `xxl_job_logreport` 分离设计:明细日志(`scheduled_task_run_logs`) 写多读少,按时间范围聚合查询慢;本表按天 + handler_name 维度预聚合,统计 查询走本表(90 天 × handler 数 ≈ 几百行),性能提升 2-3 个数量级。 写入时机:每次任务执行完成(success/failure/timeout)或进入死信时,由 仓储层原子 upsert(`ON CONFLICT (stat_date, handler_name) DO UPDATE`)。 `skipped` 状态(阻塞丢弃)不计入聚合。保留 1 年,由 `run_log_cleanup` handler 顺带清理。 """ __tablename__ = "scheduled_task_run_log_daily" # 主键 id = Column(Integer, primary_key=True, autoincrement=True) # 聚合维度 stat_date = Column(DATE, nullable=False, comment="统计日期(按 tz=Asia/Shanghai 的自然日切分)") handler_name = Column( String(128), nullable=False, index=True, comment="handler 名称;与 scheduled_tasks.handler_name 引用一致", ) # 聚合计数 success_count = Column(Integer, nullable=False, default=0, comment="当日成功次数") failure_count = Column(Integer, nullable=False, default=0, comment="当日失败次数") timeout_count = Column(Integer, nullable=False, default=0, comment="当日超时次数") dead_letter_count = Column(Integer, nullable=False, default=0, comment="当日进入死信的次数") # 审计字段 created_by = Column(String(64), nullable=True) updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) is_deleted = Column(Integer, nullable=False, default=0, index=True) deleted_at = Column(DateTime, nullable=True) __table_args__ = ( UniqueConstraint("stat_date", "handler_name", name="uq_scheduled_task_run_log_daily_date_handler"), ) def to_dict(self) -> dict[str, Any]: """序列化为 API 响应字典。""" return { "id": self.id, "stat_date": self.stat_date.isoformat() if self.stat_date else None, "handler_name": self.handler_name, "success_count": self.success_count, "failure_count": self.failure_count, "timeout_count": self.timeout_count, "dead_letter_count": self.dead_letter_count, "created_by": self.created_by, "updated_by": self.updated_by, "created_at": format_utc_datetime(self.created_at), "updated_at": format_utc_datetime(self.updated_at), "is_deleted": self.is_deleted, "deleted_at": format_utc_datetime(self.deleted_at) if self.deleted_at else None, } class ScheduledTaskIdempotency(Base): """Admin API 写操作幂等记录。 客户端通过 `Idempotency-Key` 请求头传入唯一 key,服务端利用 `idempotency_key` UNIQUE 约束实现首次写入抢占;重复请求回放 `response_body`。记录保留 24 小时后由 `idempotency_cleanup` handler 清理。 """ __tablename__ = "scheduled_task_idempotency" # 主键 id = Column(Integer, primary_key=True, autoincrement=True) # 幂等键 idempotency_key = Column( String(128), nullable=False, unique=True, comment="客户端传入的 Idempotency-Key,建议 UUID" ) # 关联(字符串引用,非外键) task_id = Column(String(64), nullable=False, comment="关联 scheduled_tasks.task_id;非外键") # 操作信息 operation = Column( String(32), nullable=False, comment="操作类型:create / update / delete / pause / resume / trigger" ) response_body = Column(JSON_VALUE, nullable=True, comment="首次请求的响应体;用于重复请求重放") # 审计字段 created_by = Column(String(64), nullable=True) updated_by = Column(String(64), nullable=True) created_at = Column( DateTime, nullable=False, default=utc_now_naive, index=True, comment="创建时间;用于清理过期记录" ) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) is_deleted = Column(Integer, nullable=False, default=0, index=True) deleted_at = Column(DateTime, nullable=True) def to_dict(self) -> dict[str, Any]: """序列化为 API 响应字典。""" return { "id": self.id, "idempotency_key": self.idempotency_key, "task_id": self.task_id, "operation": self.operation, "response_body": self.response_body, "created_by": self.created_by, "updated_by": self.updated_by, "created_at": format_utc_datetime(self.created_at), "updated_at": format_utc_datetime(self.updated_at), "is_deleted": self.is_deleted, "deleted_at": format_utc_datetime(self.deleted_at) if self.deleted_at else None, }