from __future__ import annotations import asyncio import os import re as _re from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from .. import idl_service from ..config import read_int_env from ..database import get_db from .dependencies import _require_admin, _get_current_user, _validate_export_path, _validate_root_dir from ..models import AuthUserORM from ..services import envi_service from ..services.job_queue_service import job_queue_service from ..services.result_catalog_service import result_catalog_service from ..services.task_service import task_service router = APIRouter() IDL_JOB_MAX_ATTEMPTS = read_int_env( "IDL_JOB_MAX_ATTEMPTS", 6, minimum=1, maximum=30, ) # --------------------------------------------------------------------------- # Request models # --------------------------------------------------------------------------- class ImportJobRequest(BaseModel): root_dir: str num_to_process: Optional[int] = Field(default=0, ge=0) timeout_seconds: Optional[int] = Field(default=None, ge=60) class DinsarJobRequest(BaseModel): root_dir: str num_to_process: Optional[int] = Field(default=0, ge=0) timeout_seconds: Optional[int] = Field(default=None, ge=60) mode: str = Field(default="metatask", pattern=r"^(metatask|custom)$") class InspectRequest(BaseModel): root_dir: str class ExtractDispRequest(BaseModel): root_dir: str dest_dir: Optional[str] = None def _normalize_existing_dir(path: Optional[str]) -> Optional[str]: text = str(path or "").strip() if not text: return None normalized = os.path.normpath(os.path.abspath(text)) if not os.path.isdir(normalized): return None return normalized def _dedupe_publish_roots(*paths: Optional[str]) -> list[str]: ordered: list[str] = [] for raw_path in paths: normalized = _normalize_existing_dir(raw_path) if not normalized: continue if any( normalized == existing or normalized.startswith(existing + os.sep) for existing in ordered ): continue ordered = [ existing for existing in ordered if not existing.startswith(normalized + os.sep) ] ordered.append(normalized) return ordered # --------------------------------------------------------------------------- # Job queue helper # --------------------------------------------------------------------------- async def _queue_envi_job( *, job_type: str, task_type: str, default_task_name: str, payload: Dict[str, Any], ) -> Dict[str, Any]: try: task_id = await task_service.create_task( task_type=task_type, task_name=default_task_name, params=payload, ) job_id = await job_queue_service.create_job( job_type=job_type, payload=payload, task_id=task_id, max_attempts=IDL_JOB_MAX_ATTEMPTS, ) return { "task_id": task_id, "job_id": job_id, "job_type": job_type, "message": "ENVI job queued.", } except ValueError as exc: message = str(exc) status_code = 409 if "任务冲突" in message else 400 raise HTTPException(status_code=status_code, detail=message) from exc # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("/idl/status") async def get_status_endpoint(): return envi_service.get_status() @router.post("/idl/launch-workbench") async def launch_workbench_endpoint( admin_user: AuthUserORM = Depends(_require_admin), ): _ = admin_user success, message = idl_service.launch_idl_workbench() if not success: raise HTTPException(status_code=500, detail=message) return {"message": message} @router.post("/idl/inspect/import") async def inspect_import_endpoint( request: InspectRequest, admin_user: AuthUserORM = Depends(_require_admin), ): _ = admin_user _validate_root_dir(request.root_dir) return envi_service.inspect_import(request.root_dir) @router.post("/idl/inspect/dinsar") async def inspect_dinsar_endpoint( request: InspectRequest, admin_user: AuthUserORM = Depends(_require_admin), ): _ = admin_user _validate_root_dir(request.root_dir) return envi_service.inspect_dinsar(request.root_dir) @router.post("/idl/jobs/import") async def run_import_job_endpoint( request: ImportJobRequest, admin_user: AuthUserORM = Depends(_require_admin), ): _ = admin_user _validate_root_dir(request.root_dir) return await _queue_envi_job( job_type="IDL_RUN_IMPORT", task_type="IDL_IMPORT", default_task_name="ENVI Batch Import", payload={ "root_dir": request.root_dir, "num_to_process": request.num_to_process or 0, "timeout_seconds": request.timeout_seconds, }, ) @router.post("/idl/jobs/dinsar") async def run_dinsar_job_endpoint( request: DinsarJobRequest, admin_user: AuthUserORM = Depends(_require_admin), ): _ = admin_user _validate_root_dir(request.root_dir) return await _queue_envi_job( job_type="IDL_RUN_DINSAR", task_type="IDL_DINSAR", default_task_name="ENVI D-InSAR Workflow", payload={ "root_dir": request.root_dir, "num_to_process": request.num_to_process or 0, "timeout_seconds": request.timeout_seconds, "mode": request.mode, }, ) @router.get("/idl/jobs/recent") async def list_recent_runs_endpoint(limit: int = 20): return {"runs": envi_service.list_recent_runs(limit=limit)} _RUN_ID_RE = _re.compile(r"^[\w\-]{4,80}$") _LOG_MAX_BYTES = 200 * 1024 # 200 KB @router.delete("/idl/jobs/{run_id}") async def delete_run_endpoint( run_id: str, admin_user: AuthUserORM = Depends(_require_admin), ): """Delete a run record and its associated log file.""" _ = admin_user if not _RUN_ID_RE.match(run_id): raise HTTPException(status_code=400, detail="无效的 run_id 格式") deleted = [] for ext in (".json", ".log"): candidates = [ os.path.join(envi_service.RUNTIME_DIR, "runs", f"{run_id}{ext}"), os.path.join(envi_service.RUNTIME_DIR, f"{run_id}{ext}"), ] for path in candidates: if os.path.isfile(path): os.unlink(path) deleted.append(os.path.basename(path)) if not deleted: raise HTTPException(status_code=404, detail="未找到该运行记录") return {"message": f"已删除: {', '.join(deleted)}"} @router.get("/idl/jobs/{run_id}/log") async def get_job_log_endpoint( run_id: str, current_user: AuthUserORM = Depends(_get_current_user), ): _ = current_user if not _RUN_ID_RE.match(run_id): raise HTTPException(status_code=400, detail="无效的 run_id 格式") log_path = os.path.join(envi_service.RUNTIME_DIR, f"{run_id}.log") if not os.path.isfile(log_path): raise HTTPException(status_code=404, detail="日志文件不存在") size_bytes = os.path.getsize(log_path) truncated = size_bytes > _LOG_MAX_BYTES with open(log_path, "r", encoding="utf-8", errors="replace") as fp: if truncated: fp.seek(size_bytes - _LOG_MAX_BYTES) content = "...[日志已截断,仅显示末尾 200KB]...\n" + fp.read() else: content = fp.read() return {"run_id": run_id, "content": content, "size_bytes": size_bytes, "truncated": truncated} @router.get("/idl/task-overview") async def get_task_overview_endpoint( root_dir: str, current_user: AuthUserORM = Depends(_get_current_user), ): _ = current_user _validate_root_dir(root_dir) try: result = await asyncio.to_thread(envi_service.get_task_overview, root_dir) except (ValueError, OSError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return result @router.post("/idl/extract-disp") async def extract_disp_endpoint( request: ExtractDispRequest, admin_user: AuthUserORM = Depends(_require_admin), db: AsyncSession = Depends(get_db), ): _ = admin_user _validate_root_dir(request.root_dir) if request.dest_dir: _validate_export_path(request.dest_dir, "dest_dir") try: result = await asyncio.to_thread( envi_service.extract_disp_results, request.root_dir, request.dest_dir ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc publish_roots = _dedupe_publish_roots(result.get("target_dir")) catalog_status: Dict[str, Any] = { "attempted": False, "status": "skipped", "source_directories": publish_roots, "message": "catalog publish skipped", } if publish_roots: try: catalog_status["attempted"] = True publish_result = await result_catalog_service.publish_from_sources( db, publish_roots, ) rebuild_result = None if int(publish_result.get("processed", 0) or 0) > 0: rebuild_result = await result_catalog_service.rebuild_catalog( db, full_rebuild=True, ) catalog_status = { "attempted": True, "status": "ok", "source_directories": publish_roots, "publish": publish_result, "rebuild": rebuild_result, "message": ( "catalog published and rebuilt" if rebuild_result is not None else "catalog publish finished with no rebuild needed" ), } except Exception as exc: await db.rollback() catalog_status = { "attempted": True, "status": "error", "source_directories": publish_roots, "message": str(exc), } result["catalog"] = catalog_status return result