"""备份任务 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, InfluxDBSourceConfigV1, InfluxDBSourceConfigV2, JobCreate, JobOut, JobRunResponse, JobUpdate, JobWithLastRun, MySQLSourceConfig, RunSummary, ) from app.scheduler.scheduler import remove_job, reschedule_job from app.utils.crypto import decrypt_dict, encrypt_dict # 脱敏字段:编辑时返回占位符,避免明文密码在网络传输 _SENSITIVE_KEYS = {"password", "token", "secret_key", "access_key"} def _redact_source(cfg: dict) -> dict: """敏感字段脱敏为 '***',但保留字段名以提示用户该字段已配置。""" out = {} for k, v in cfg.items(): if k in _SENSITIVE_KEYS: out[k] = "***" if v else "" else: out[k] = v return out 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() if type_ == "influxdb": ver = cfg.get("version") if ver == "1": return InfluxDBSourceConfigV1(**cfg).model_dump() if ver == "2": return InfluxDBSourceConfigV2(**cfg).model_dump() raise HTTPException(status_code=400, detail="InfluxDB config 缺少 version (1 或 2)") 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 # 解密 source_config 并脱敏 try: cfg = decrypt_dict(job.source_config_json) out.source_config_safe = _redact_source(cfg) except Exception: out.source_config_safe = {} 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) -> JobOut: 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) out = JobOut.model_validate(job) out.source_config_safe = _redact_source(src) return out @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) -> JobOut: 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 new_src: dict | None = None if payload.source_config is not None: new_src = _validate_source(job.type, payload.source_config) # 如果 password 为 "***" 占位符,保留原值 existing_src = decrypt_dict(job.source_config_json) for k in _SENSITIVE_KEYS: if ( k in new_src and k in existing_src and new_src[k] == "***" ): new_src[k] = existing_src[k] job.source_config_json = encrypt_dict(new_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) out = JobOut.model_validate(job) cfg = new_src if new_src is not None else decrypt_dict(job.source_config_json) out.source_config_safe = _redact_source(cfg) return out @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) -> JobOut: 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) out = JobOut.model_validate(job) out.source_config_safe = _redact_source(decrypt_dict(job.source_config_json)) return out