from __future__ import annotations import mimetypes import os from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse from pydantic import BaseModel, field_validator from sqlalchemy.ext.asyncio import AsyncSession from ..config import read_int_env from ..database import get_db from ..models import AuthUserORM from ..services.job_queue_service import job_queue_service from ..services.result_catalog_service import ( JOB_TYPE_PUBLISH_DINSAR_PRODUCTS, JOB_TYPE_REBUILD_DINSAR_CATALOG, TASK_TYPE_PUBLISH_DINSAR_PRODUCTS, TASK_TYPE_REBUILD_DINSAR_CATALOG, result_catalog_service, ) from ..services.dinsar_intermediate_cleanup_service import dinsar_intermediate_cleanup_service from ..services.task_service import task_service from .dependencies import ( _add_operation_audit_log, _get_current_user, _require_admin, _validate_export_path, _validate_root_dir, ) router = APIRouter() MAX_SCAN_DIRECTORY_COUNT = read_int_env( "MAX_SCAN_DIRECTORY_COUNT", 64, minimum=1, maximum=500, ) MAX_SCAN_PATH_LENGTH = read_int_env( "MAX_SCAN_PATH_LENGTH", 2048, minimum=64, maximum=32767, ) class DinsarProductPublishRequest(BaseModel): source_directories: List[str] publish_root: Optional[str] = None rebuild_catalog: bool = True @field_validator("source_directories", mode="before") @classmethod def _validate_source_directories(cls, value): if value is None or not isinstance(value, list): raise ValueError("source_directories must be a list") normalized: List[str] = [] for raw in value: path = str(raw or "").strip() if not path: continue if len(path) > MAX_SCAN_PATH_LENGTH: raise ValueError( f"source_directories contains a path longer than {MAX_SCAN_PATH_LENGTH} characters." ) if path not in normalized: normalized.append(path) if not normalized: raise ValueError("source_directories cannot be empty") if len(normalized) > MAX_SCAN_DIRECTORY_COUNT: raise ValueError( f"source_directories exceeds max directory count ({MAX_SCAN_DIRECTORY_COUNT})." ) return normalized @field_validator("publish_root", mode="before") @classmethod def _validate_publish_root(cls, value): if value is None: return None path = str(value).strip() if not path: return None if len(path) > MAX_SCAN_PATH_LENGTH: raise ValueError(f"publish_root exceeds max length ({MAX_SCAN_PATH_LENGTH})") return path class DinsarCatalogRebuildRequest(BaseModel): publish_root: Optional[str] = None full_rebuild: bool = True @field_validator("publish_root", mode="before") @classmethod def _validate_publish_root(cls, value): if value is None: return None path = str(value).strip() if not path: return None if len(path) > MAX_SCAN_PATH_LENGTH: raise ValueError(f"publish_root exceeds max length ({MAX_SCAN_PATH_LENGTH})") return path @router.get("/dinsar-products/catalog-status") async def get_dinsar_catalog_status( current_user: AuthUserORM = Depends(_get_current_user), db: AsyncSession = Depends(get_db), ): _ = current_user return await result_catalog_service.get_catalog_status(db) @router.post("/dinsar-products/publish", status_code=202) async def queue_dinsar_product_publish( request: DinsarProductPublishRequest, http_request: Request, db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), ): _ = admin_user source_dirs = [_validate_root_dir(path, "source_directories") for path in request.source_directories] publish_root = request.publish_root if publish_root: publish_root = _validate_export_path(publish_root, "publish_root") task_id = await task_service.create_task( TASK_TYPE_PUBLISH_DINSAR_PRODUCTS, "D-InSAR 结果发布", params={ "source_directories": source_dirs, "publish_root": publish_root, "rebuild_catalog": request.rebuild_catalog, }, db=db, ) await job_queue_service.create_job( JOB_TYPE_PUBLISH_DINSAR_PRODUCTS, payload={ "source_directories": source_dirs, "publish_root": publish_root, "rebuild_catalog": request.rebuild_catalog, }, task_id=task_id, db=db, ) await _add_operation_audit_log( db, request=http_request, action="dinsar_products_publish_queued", resource="dinsar-products/publish", detail={ "task_id": task_id, "directory_count": len(source_dirs), "rebuild_catalog": request.rebuild_catalog, }, ) await db.commit() return { "message": "D-InSAR 结果发布任务已入队", "task_id": task_id, } @router.post("/dinsar-products/rebuild", status_code=202) async def queue_dinsar_catalog_rebuild( request: DinsarCatalogRebuildRequest, http_request: Request, db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), ): _ = admin_user publish_root = request.publish_root if publish_root: publish_root = _validate_export_path(publish_root, "publish_root") task_id = await task_service.create_task( TASK_TYPE_REBUILD_DINSAR_CATALOG, "D-InSAR 结果目录重建", params={ "publish_root": publish_root, "full_rebuild": request.full_rebuild, }, db=db, ) await job_queue_service.create_job( JOB_TYPE_REBUILD_DINSAR_CATALOG, payload={ "publish_root": publish_root, "full_rebuild": request.full_rebuild, }, task_id=task_id, db=db, ) await _add_operation_audit_log( db, request=http_request, action="dinsar_catalog_rebuild_queued", resource="dinsar-products/rebuild", detail={ "task_id": task_id, "full_rebuild": request.full_rebuild, }, ) await db.commit() return { "message": "D-InSAR 结果目录重建任务已入队", "task_id": task_id, } @router.get("/dinsar-products") async def list_dinsar_products( limit: int = 100, offset: int = 0, engine_code: Optional[str] = None, status: Optional[str] = None, query: Optional[str] = None, current_user: AuthUserORM = Depends(_get_current_user), db: AsyncSession = Depends(get_db), ): _ = current_user return await result_catalog_service.list_products( db, limit=limit, offset=offset, engine_code=engine_code, status=status, query=query, ) @router.get("/dinsar-products/pairs") async def list_dinsar_product_pairs( limit: int = 100, offset: int = 0, engine_code: Optional[str] = None, status: Optional[str] = None, query: Optional[str] = None, include_legacy: bool = False, current_user: AuthUserORM = Depends(_get_current_user), db: AsyncSession = Depends(get_db), ): _ = current_user return await result_catalog_service.list_product_pairs( db, limit=limit, offset=offset, engine_code=engine_code, status=status, query=query, include_legacy=include_legacy, ) @router.get("/dinsar-products/pairs/{pair_key}/cleanup-intermediates/plan") async def get_dinsar_pair_intermediate_cleanup_plan( pair_key: str, current_user: AuthUserORM = Depends(_get_current_user), db: AsyncSession = Depends(get_db), ): _ = current_user plan = await dinsar_intermediate_cleanup_service.build_pair_plan(db, pair_key=pair_key) if int(plan.get("product_count") or 0) == 0: raise HTTPException(status_code=404, detail="No D-InSAR products found for pair_key") return plan @router.get("/dinsar-products/{product_db_id}") async def get_dinsar_product_detail( product_db_id: int, current_user: AuthUserORM = Depends(_get_current_user), db: AsyncSession = Depends(get_db), ): _ = current_user detail = await result_catalog_service.get_product_detail(db, product_db_id=product_db_id) if detail is None: raise HTTPException(status_code=404, detail="Result product not found") return detail @router.get("/dinsar-products/{product_db_id}/cleanup-intermediates/plan") async def get_dinsar_product_intermediate_cleanup_plan( product_db_id: int, current_user: AuthUserORM = Depends(_get_current_user), db: AsyncSession = Depends(get_db), ): _ = current_user plan = await dinsar_intermediate_cleanup_service.build_product_plan(db, product_db_id=product_db_id) if plan is None: raise HTTPException(status_code=404, detail="Result product not found") return plan @router.get("/dinsar-products/{product_db_id}/preview") async def get_dinsar_product_preview( product_db_id: int, current_user: AuthUserORM = Depends(_get_current_user), db: AsyncSession = Depends(get_db), ): _ = current_user detail = await result_catalog_service.get_product_detail(db, product_db_id=product_db_id) if detail is None: raise HTTPException(status_code=404, detail="Result product not found") preview_path = str(detail.get("preview_path") or "").strip() if not preview_path or not os.path.isfile(preview_path): raise HTTPException(status_code=404, detail="Preview not found") media_type = mimetypes.guess_type(preview_path)[0] or "application/octet-stream" return FileResponse( preview_path, media_type=media_type, headers={"Cache-Control": "public, max-age=31536000, immutable"}, )