"""备份任务 CRUD API。""" import asyncio from datetime import datetime from fastapi import APIRouter, BackgroundTasks, HTTPException from pydantic import ValidationError from app.core.executor import run_backup from app.deps import AdminUser, CurrentUser, DBSession from app.models.job import Job from app.models.run import Run from app.models.storage import Storage from app.schemas.job import ( DirectorySourceConfig, JobCreate, JobOut, JobRunResponse, JobUpdate, JobWithLastRun, MySQLSourceConfig, RunSummary, ) from app.scheduler.scheduler import remove_job, reschedule_job from app.utils.crypto import encrypt_dict router = APIRouter(prefix="/jobs", tags=["jobs"]) def _validate_source(type_: str, cfg: dict) -> dict: if type_ == "mysql": return MySQLSourceConfig(**cfg).model_dump() if type_ == "directory": return DirectorySourceConfig(**cfg).model_dump() raise HTTPException(status_code=400, detail=f"Unsupported job type: {type_}") def _job_to_with_last_run(job: Job, db) -> JobWithLastRun: last = ( db.query(Run) .filter(Run.job_id == job.id) .order_by(Run.id.desc()) .first() ) out = JobWithLastRun.model_validate(job) out.last_run = RunSummary.model_validate(last) if last else None return out @router.get("", response_model=list[JobWithLastRun]) def list_jobs(db: DBSession, _: CurrentUser) -> list[JobWithLastRun]: jobs = db.query(Job).order_by(Job.id).all() return [_job_to_with_last_run(j, db) for j in jobs] @router.post("", response_model=JobOut, status_code=201) def create_job(payload: JobCreate, db: DBSession, _: AdminUser) -> Job: if db.query(Job).filter(Job.name == payload.name).first(): raise HTTPException(status_code=400, detail="名称已存在") if not db.get(Storage, payload.storage_id): raise HTTPException(status_code=400, detail="存储目标不存在") src = _validate_source(payload.type, payload.source_config) job = Job( name=payload.name, type=payload.type, source_config_json=encrypt_dict(src), storage_id=payload.storage_id, cron_expression=payload.cron_expression, enabled=payload.enabled, retention_count=payload.retention_count, retention_days=payload.retention_days, description=payload.description, ) db.add(job) db.commit() db.refresh(job) reschedule_job(job.id) return job @router.get("/{job_id}", response_model=JobWithLastRun) def get_job(job_id: int, db: DBSession, _: CurrentUser) -> JobWithLastRun: job = db.get(Job, job_id) if not job: raise HTTPException(status_code=404, detail="Not found") return _job_to_with_last_run(job, db) @router.put("/{job_id}", response_model=JobOut) def update_job(job_id: int, payload: JobUpdate, db: DBSession, _: AdminUser) -> Job: job = db.get(Job, job_id) if not job: raise HTTPException(status_code=404, detail="Not found") if payload.name is not None: if db.query(Job).filter(Job.name == payload.name, Job.id != job_id).first(): raise HTTPException(status_code=400, detail="名称已存在") job.name = payload.name if payload.source_config is not None: src = _validate_source(job.type, payload.source_config) job.source_config_json = encrypt_dict(src) if payload.storage_id is not None: if not db.get(Storage, payload.storage_id): raise HTTPException(status_code=400, detail="存储目标不存在") job.storage_id = payload.storage_id if payload.cron_expression is not None: job.cron_expression = payload.cron_expression or None if payload.enabled is not None: job.enabled = payload.enabled if payload.retention_count is not None: job.retention_count = payload.retention_count if payload.retention_days is not None: job.retention_days = payload.retention_days if payload.description is not None: job.description = payload.description db.commit() db.refresh(job) reschedule_job(job.id) return job @router.delete("/{job_id}", status_code=204) def delete_job(job_id: int, db: DBSession, _: AdminUser) -> None: job = db.get(Job, job_id) if not job: raise HTTPException(status_code=404, detail="Not found") remove_job(job.id) db.delete(job) db.commit() @router.post("/{job_id}/run", response_model=JobRunResponse) async def run_job(job_id: int, db: DBSession, _: CurrentUser) -> JobRunResponse: job = db.get(Job, job_id) if not job: raise HTTPException(status_code=404, detail="Not found") run = Run( job_id=job.id, status="pending", trigger="manual", started_at=datetime.utcnow(), ) db.add(run) db.commit() db.refresh(run) run_id = run.id # 后台启动 asyncio.create_task(run_backup(run_id, trigger="manual")) return JobRunResponse(run_id=run_id, status=run.status) @router.post("/{job_id}/toggle", response_model=JobOut) def toggle_job(job_id: int, db: DBSession, _: AdminUser) -> Job: job = db.get(Job, job_id) if not job: raise HTTPException(status_code=404, detail="Not found") job.enabled = not job.enabled db.commit() db.refresh(job) reschedule_job(job.id) return job