from __future__ import annotations import logging import mimetypes import os from typing import List, Optional from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Request from fastapi.responses import FileResponse from pydantic import BaseModel, field_validator from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from ..database import get_db from ..config import read_int_env, settings, split_env_paths from ..models import AuthUserORM, DinsarResult, DinsarResultPage from ..services.dinsar_read_service import DinsarCatalogReadRecord, dinsar_read_service from ..services.dinsar_export import export_dinsar_results from ..services.job_queue_service import job_queue_service from ..services.task_service import task_service from .dependencies import _add_operation_audit_log, _require_admin, _validate_export_path router = APIRouter() logger = logging.getLogger(__name__) 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, ) LIST_QUERY_MAX_LIMIT = read_int_env( "LIST_QUERY_MAX_LIMIT", 2000, minimum=1, maximum=100000, ) LIST_QUERY_MAX_OFFSET = read_int_env( "LIST_QUERY_MAX_OFFSET", 200000, minimum=0, maximum=20000000, ) LIST_QUERY_MAX_WINDOW = read_int_env( "LIST_QUERY_MAX_WINDOW", 202000, minimum=1, maximum=50000000, ) LIST_QUERY_TIMEOUT_MS = read_int_env( "LIST_QUERY_TIMEOUT_MS", 20000, minimum=1000, maximum=300000, ) class DinsarScanRequest(BaseModel): results_directories: List[str] @field_validator("results_directories", mode="before") @classmethod def _normalize_results_directories(cls, value): if value is None: return [] if not isinstance(value, list): raise ValueError("results_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"results_directories contains a path longer than {MAX_SCAN_PATH_LENGTH} characters." ) if path not in normalized: normalized.append(path) if len(normalized) > MAX_SCAN_DIRECTORY_COUNT: raise ValueError( f"results_directories exceeds max directory count ({MAX_SCAN_DIRECTORY_COUNT})." ) return normalized def _get_default_dinsar_scan_dirs() -> List[str]: normalized: List[str] = [] for item in split_env_paths(settings.MONITOR_DINSAR_DIRS): path = str(item or "").strip() if path and path not in normalized: normalized.append(path) return normalized def _is_postgresql_session(db: AsyncSession) -> bool: try: bind = db.get_bind() dialect_name = getattr(getattr(bind, "dialect", None), "name", "") return (dialect_name or "").lower() == "postgresql" except Exception: return False """ try: scan_dirs = list(request.results_directories) if request else [] if not scan_dirs: scan_dirs = _get_default_dinsar_scan_dirs() if not scan_dirs: raise HTTPException(status_code=400, detail="未提供结果目录,且 MONITOR_DINSAR_DIRS 未配置。") bind = db.get_bind() dialect_name = getattr(getattr(bind, "dialect", None), "name", "") return (dialect_name or "").lower() == "postgresql" except Exception: return False """ async def _apply_list_query_statement_timeout(db: AsyncSession) -> None: timeout_ms = int(LIST_QUERY_TIMEOUT_MS) if timeout_ms <= 0: return if not _is_postgresql_session(db): return try: # PostgreSQL SET/SET LOCAL does not support bind parameters in this form. timeout_ms = int(timeout_ms) await db.execute(text(f"SET LOCAL statement_timeout = {timeout_ms}")) except Exception as exc: logger.warning("Failed to apply list query statement_timeout=%sms: %s", timeout_ms, exc) def _normalize_list_pagination(limit: int, offset: int) -> tuple[int, int]: safe_limit = min(LIST_QUERY_MAX_LIMIT, max(1, int(limit or 1))) safe_offset = min(LIST_QUERY_MAX_OFFSET, max(0, int(offset or 0))) if safe_limit + safe_offset > LIST_QUERY_MAX_WINDOW: safe_offset = max(0, LIST_QUERY_MAX_WINDOW - safe_limit) return safe_limit, safe_offset def _build_dinsar_result_payload(record: DinsarCatalogReadRecord) -> DinsarResult: compat_row = record.compat_row product = record.product preview_path = dinsar_read_service.resolve_preview_path(product, compat_row) min_lon = product.min_lon if product.min_lon is not None else getattr(compat_row, "min_lon", None) min_lat = product.min_lat if product.min_lat is not None else getattr(compat_row, "min_lat", None) max_lon = product.max_lon if product.max_lon is not None else getattr(compat_row, "max_lon", None) max_lat = product.max_lat if product.max_lat is not None else getattr(compat_row, "max_lat", None) file_path = ( str(product.primary_asset_path or "").strip() or str(product.source_primary_path or "").strip() or (str(compat_row.file_path).strip() if compat_row is not None and compat_row.file_path else "") or str(product.manifest_path or "").strip() ) return DinsarResult( id=int(compat_row.id if compat_row is not None else product.id), product_id=product.product_id, compat_result_id=int(compat_row.id) if compat_row is not None else None, name=record.display_name, task_name=product.task_name, task_alias=product.task_alias, pair_key=product.pair_key, pair_uid=product.pair_uid, network_run_id=product.network_run_id, network_edge_id=product.network_edge_id, policy_version=product.policy_version, selection_strategy=product.selection_strategy, file_path=file_path, min_lon=float(min_lon or 0.0), min_lat=float(min_lat or 0.0), max_lon=float(max_lon or 0.0), max_lat=float(max_lat or 0.0), coverage_polygon=product.coverage_polygon, is_cached=bool(preview_path and os.path.exists(preview_path)), ai_score=product.ai_score, user_label=product.user_label, ai_report=getattr(compat_row, "ai_report", None), ) async def _get_cached_image(result_id: int, db: AsyncSession): """ 获取Dinsar结果的可视化图像。 使用 FileResponse 以支持浏览器缓存 (ETag, Last-Modified) 和断点续传。 """ record = await dinsar_read_service.get_compat_record(db, compat_result_id=result_id) if record is None: raise HTTPException(status_code=404, detail=f"ID为 {result_id} 的结果不存在。") target_path = dinsar_read_service.resolve_preview_path(record.product, record.compat_row) if target_path and os.path.exists(target_path): media_type = mimetypes.guess_type(target_path)[0] or "application/octet-stream" return FileResponse( target_path, media_type=media_type, headers={"Cache-Control": "public, max-age=31536000"}, ) compat_row = record.compat_row if compat_row is not None and compat_row.is_cached: compat_row.is_cached = False db.add(compat_row) await db.commit() raise HTTPException( status_code=503, detail="数据不一致:缓存文件丢失。状态已重置,请重新扫描以修复。" ) raise HTTPException( status_code=202, detail="缓存等待生成。请运行扫描程序。" ) @router.post("/scan-dinsar-results", status_code=202) async def scan_dinsar_results_endpoint( background_tasks: BackgroundTasks, http_request: Request, db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), request: Optional[DinsarScanRequest] = None, ): """ 触发Dinsar结果扫描的API端点,支持多个目录。 """ try: scan_dirs = list(request.results_directories) if request else [] if not scan_dirs: scan_dirs = _get_default_dinsar_scan_dirs() if not scan_dirs: raise HTTPException(status_code=400, detail="未提供结果目录,且 MONITOR_DINSAR_DIRS 未配置。") task_id = await task_service.create_task("SCAN_DINSAR", "D-InSAR 统一扫描", params={"dirs": scan_dirs}) payload = {"dirs": scan_dirs} await job_queue_service.create_job("SCAN_DINSAR", payload=payload, task_id=task_id) await _add_operation_audit_log( db, request=http_request, action="task_queued", resource="scan-dinsar-results", detail={"task_id": task_id, "directory_count": len(scan_dirs)}, ) await db.commit() return {"message": "D-InSAR 统一扫描任务已进入队列", "task_id": task_id} except ValueError as e: raise HTTPException(status_code=409, detail=str(e)) except Exception as e: logger.exception("D-InSAR 结果扫描失败") raise HTTPException(status_code=500, detail="D-InSAR 结果扫描失败,请查看后端日志") @router.get("/dinsar-results", response_model=DinsarResultPage) async def get_all_dinsar_results_endpoint( limit: int = 500, offset: int = 0, db: AsyncSession = Depends(get_db), ): """ 从数据库获取所有Dinsar结果的元数据。 """ limit, offset = _normalize_list_pagination(limit, offset) await _apply_list_query_statement_timeout(db) total = await dinsar_read_service.count_compat_records(db) records = await dinsar_read_service.list_compat_records( db, limit=limit, offset=offset, ) return DinsarResultPage( items=[_build_dinsar_result_payload(record) for record in records], total=total, limit=limit, offset=offset, has_more=(offset + len(records)) < total, ) @router.get("/dinsar-results/{result_id}/thumb") async def get_dinsar_thumb_endpoint(result_id: int, db: AsyncSession = Depends(get_db)): """获取指定ID的Dinsar结果的可视化图像。""" return await _get_cached_image(result_id, db) @router.get("/dinsar-results/{result_id}/full") async def get_dinsar_full_endpoint(result_id: int, db: AsyncSession = Depends(get_db)): """获取指定ID的Dinsar结果的可视化图像 (现在与缩略图一致)。""" return await _get_cached_image(result_id, db) @router.post("/dinsar-results/{result_id}/label") async def label_dinsar_result( result_id: int, http_request: Request, label: Optional[int] = Form(None), # 0: Bad, 1: Good, None: Clear db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), ): """ 设置或清除Dinsar结果的用户标签。 """ record = await dinsar_read_service.get_compat_record(db, compat_result_id=result_id) if record is None: raise HTTPException(status_code=404, detail="Result not found") record.product.user_label = label if record.compat_row is not None: record.compat_row.user_label = label await _add_operation_audit_log( db, request=http_request, action="dinsar_label_updated", resource=f"dinsar-results/{result_id}/label", detail={"label": label}, ) await db.commit() return { "message": "Label updated", "id": result_id, "product_id": record.product.product_id, "new_label": label, } class DinsarExportRequest(BaseModel): result_ids: List[int] target_dir: str @field_validator("target_dir", mode="before") @classmethod def _validate_target_dir(cls, value): v = str(value or "").strip() if not v: raise ValueError("target_dir is required") if len(v) > MAX_SCAN_PATH_LENGTH: raise ValueError(f"target_dir exceeds max length ({MAX_SCAN_PATH_LENGTH})") return v @field_validator("result_ids", mode="before") @classmethod def _validate_result_ids(cls, value): if not value or not isinstance(value, list) or len(value) == 0: raise ValueError("result_ids must be a non-empty list") if len(value) > 500: raise ValueError("result_ids exceeds max count (500)") return value @router.post("/dinsar-results/export") async def export_dinsar_results_endpoint( request: DinsarExportRequest, http_request: Request, db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), ): """ 将选中的 D-InSAR 结果文件复制到用户指定的目录。 支持本地路径和 UNC 路径。 """ _validate_export_path(request.target_dir, "target_dir") records = await dinsar_read_service.list_compat_records_by_ids( db, compat_result_ids=request.result_ids, ) if not records: raise HTTPException(status_code=404, detail="未找到任何匹配的结果记录") export_items = [] for record in records: compat_row = record.compat_row file_path = ( str(record.product.primary_asset_path or "").strip() or str(record.product.source_primary_path or "").strip() or (str(compat_row.file_path).strip() if compat_row is not None and compat_row.file_path else "") ) if file_path: export_items.append( { "source": file_path, "task_alias": str(record.product.task_alias or "").strip(), "task_name": str(record.product.task_name or "").strip(), "display_name": str(record.display_name or "").strip(), "product_id": str(record.product.product_id or "").strip(), } ) if not export_items: raise HTTPException(status_code=400, detail="选中的结果没有关联的文件路径") try: import asyncio export_result = await asyncio.to_thread( export_dinsar_results, export_items, request.target_dir ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error("导出 D-InSAR 结果失败: %s", e, exc_info=True) raise HTTPException(status_code=500, detail=f"导出失败: {e}") await _add_operation_audit_log( db, request=http_request, action="dinsar_results_exported", resource="dinsar-results/export", detail={ "result_ids": request.result_ids, "target_dir": request.target_dir, "copied": export_result["copied"], "failed": export_result["failed"], }, ) await db.commit() return export_result