"""自动化作业接口。 设计上「先能看、再能开」:查看需要 ``automation:view``(运营也有),改配置与 手动触发需要 ``automation:manage``(仅超管)。作业默认关闭,打开后由进程内 调度器按间隔执行。 """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy.orm import Session from ..database import get_db from ..deps import require_permission from ..models import AutomationJob, AutomationRun, User from ..services.audit_service import record_audit from ..services.automation import ( ensure_jobs, registered_jobs, run_job_now, scheduler_status, ) router = APIRouter(prefix="/api/automation", tags=["自动化"]) MAX_BATCH_SIZE = 2000 MIN_INTERVAL_SECONDS = 300 class JobUpdate(BaseModel): """作业配置更新;只传要改的字段。""" enabled: bool | None = None interval_seconds: int | None = Field(default=None, ge=MIN_INTERVAL_SECONDS) batch_size: int | None = Field(default=None, ge=1, le=MAX_BATCH_SIZE) concurrency: int | None = Field(default=None, ge=1, le=16) def _job_out(job: AutomationJob) -> dict[str, Any]: return { "key": job.key, "title": job.title, "enabled": bool(job.enabled), "interval_seconds": int(job.interval_seconds or 0), "batch_size": int(job.batch_size or 0), "concurrency": int(job.concurrency or 0), "last_run_at": job.last_run_at.isoformat() if job.last_run_at else None, "next_run_at": job.next_run_at.isoformat() if job.next_run_at else None, "last_status": job.last_status or "", "last_message": job.last_message or "", "registered": job.key in registered_jobs(), } def _run_out(run: AutomationRun) -> dict[str, Any]: return { "id": run.id, "job_key": run.job_key, "started_at": run.started_at.isoformat() if run.started_at else None, "finished_at": run.finished_at.isoformat() if run.finished_at else None, "status": run.status or "", "scanned": int(run.scanned or 0), "valid": int(run.valid or 0), "invalid": int(run.invalid or 0), "skipped": int(run.skipped or 0), "message": run.message or "", "detail": run.detail or None, } @router.get("/jobs") def list_jobs( db: Session = Depends(get_db), current: User = Depends(require_permission("automation:view")), ): """列出作业配置、最近一次结果与调度器状态。""" ensure_jobs(db) jobs = db.query(AutomationJob).order_by(AutomationJob.key.asc()).all() latest: dict[str, AutomationRun] = {} for run in ( db.query(AutomationRun).order_by(AutomationRun.id.desc()).limit(200).all() ): latest.setdefault(run.job_key, run) return { "scheduler": scheduler_status(), "jobs": [ { **_job_out(job), "latest_run": _run_out(latest[job.key]) if job.key in latest else None, } for job in jobs ], } @router.patch("/jobs/{key}") def update_job( key: str, payload: JobUpdate, db: Session = Depends(get_db), current: User = Depends(require_permission("automation:manage")), ): """改开关 / 间隔 / 批量 / 并发。打开开关时排定下一轮时间。""" ensure_jobs(db) job = db.query(AutomationJob).filter(AutomationJob.key == key).first() if job is None: raise HTTPException(status_code=404, detail="作业不存在") changed: dict[str, Any] = {} if payload.enabled is not None and bool(payload.enabled) != bool(job.enabled): job.enabled = bool(payload.enabled) changed["enabled"] = job.enabled # 刚打开时排一轮:不早于一个 tick,避免打开瞬间就压满 CPU。 job.next_run_at = datetime.now(UTC) + _tick_delta() if job.enabled else None if payload.interval_seconds is not None: changed["interval_seconds"] = payload.interval_seconds job.interval_seconds = payload.interval_seconds if payload.batch_size is not None: changed["batch_size"] = payload.batch_size job.batch_size = payload.batch_size if payload.concurrency is not None: changed["concurrency"] = payload.concurrency job.concurrency = payload.concurrency if changed: job.updated_at = datetime.now(UTC) record_audit( db, current, action="automation:update", target=key, detail=changed, ) db.commit() return _job_out(job) @router.post("/jobs/{key}/run") def run_job( key: str, db: Session = Depends(get_db), current: User = Depends(require_permission("automation:manage")), ): """立刻跑一轮(不看 enabled)。用于正式打开开关前先试跑。""" ensure_jobs(db) try: outcome = run_job_now(db, key) except KeyError: raise HTTPException(status_code=404, detail="作业不存在") from None record_audit( db, current, action="automation:run", target=key, detail={ "status": outcome.get("status"), "scanned": outcome.get("scanned"), "valid": outcome.get("valid"), "invalid": outcome.get("invalid"), "message": outcome.get("message"), }, ) db.commit() return outcome @router.get("/runs") def list_runs( job_key: str | None = Query(default=None), limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db), current: User = Depends(require_permission("automation:view")), ): """运行历史,最近的在前。""" query = db.query(AutomationRun) if job_key: query = query.filter(AutomationRun.job_key == job_key) runs = query.order_by(AutomationRun.id.desc()).limit(limit).all() return {"items": [_run_out(run) for run in runs]} def _tick_delta(): from datetime import timedelta from ..services.automation.scheduler import TICK_SECONDS return timedelta(seconds=TICK_SECONDS)