chore: initialize insar management system v2
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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
|
||||
Reference in New Issue
Block a user