chore: initialize insar management system v2
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import (
|
||||
ai,
|
||||
aoi,
|
||||
auth,
|
||||
dinsar,
|
||||
dinsar_products,
|
||||
dinsar_production,
|
||||
hazard,
|
||||
health,
|
||||
idl,
|
||||
license,
|
||||
logs,
|
||||
monitor,
|
||||
orbit,
|
||||
pairing,
|
||||
ps_products,
|
||||
radar,
|
||||
root_registry,
|
||||
stats,
|
||||
task_batches,
|
||||
tasks,
|
||||
timeseries_production,
|
||||
tools,
|
||||
unpack,
|
||||
water,
|
||||
workflow,
|
||||
)
|
||||
|
||||
|
||||
def include_all_routers(router: APIRouter) -> None:
|
||||
"""将所有子路由注册到给定的 APIRouter。"""
|
||||
router.include_router(health.router)
|
||||
router.include_router(auth.router)
|
||||
router.include_router(license.router)
|
||||
router.include_router(tasks.router)
|
||||
router.include_router(workflow.router)
|
||||
router.include_router(task_batches.router)
|
||||
router.include_router(tools.router)
|
||||
router.include_router(unpack.router)
|
||||
router.include_router(monitor.router)
|
||||
router.include_router(orbit.router)
|
||||
router.include_router(root_registry.router)
|
||||
router.include_router(radar.router)
|
||||
router.include_router(aoi.router)
|
||||
router.include_router(pairing.router)
|
||||
router.include_router(dinsar.router)
|
||||
router.include_router(dinsar_products.router)
|
||||
router.include_router(dinsar_production.router)
|
||||
router.include_router(timeseries_production.router)
|
||||
router.include_router(ps_products.router)
|
||||
router.include_router(ai.router)
|
||||
router.include_router(stats.router)
|
||||
router.include_router(idl.router)
|
||||
router.include_router(hazard.router)
|
||||
router.include_router(water.router)
|
||||
router.include_router(logs.router)
|
||||
@@ -0,0 +1,402 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from typing import List, Optional
|
||||
|
||||
from ..ai_service import (
|
||||
analyze_map_with_vlm,
|
||||
get_model_info,
|
||||
is_model_trained,
|
||||
predict_quality,
|
||||
train_quality_model,
|
||||
)
|
||||
from ..database import get_db
|
||||
from ..config import read_int_env, settings
|
||||
from ..models import (
|
||||
AuthUserORM,
|
||||
AiDiagnosisORM,
|
||||
AiDiagnosisCreate,
|
||||
AiDiagnosisResponse,
|
||||
AiDiagnosisListResponse,
|
||||
)
|
||||
from ..services.dinsar_read_service import dinsar_read_service
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.task_service import task_service
|
||||
from ..services.ai_prompts import get_prompt_template, list_prompt_templates
|
||||
from .dependencies import _require_admin
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
AI_ANALYZE_MAP_MAX_IMAGES = read_int_env(
|
||||
"AI_ANALYZE_MAP_MAX_IMAGES",
|
||||
4,
|
||||
minimum=1,
|
||||
maximum=50,
|
||||
)
|
||||
AI_ANALYZE_MAP_MAX_IMAGE_BASE64_CHARS = read_int_env(
|
||||
"AI_ANALYZE_MAP_MAX_IMAGE_BASE64_CHARS",
|
||||
12 * 1024 * 1024,
|
||||
minimum=1024,
|
||||
maximum=100 * 1024 * 1024,
|
||||
)
|
||||
AI_ANALYZE_MAP_PROMPT_MAX_CHARS = read_int_env(
|
||||
"AI_ANALYZE_MAP_PROMPT_MAX_CHARS",
|
||||
8000,
|
||||
minimum=32,
|
||||
maximum=200000,
|
||||
)
|
||||
|
||||
|
||||
class MapAnalysisRequest(BaseModel):
|
||||
images: List[str] = Field(default_factory=list) # Base64 strings
|
||||
prompt: Optional[str] = None
|
||||
|
||||
@field_validator("images", mode="before")
|
||||
@classmethod
|
||||
def _validate_images(cls, value):
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("images must be a list.")
|
||||
if len(value) == 0:
|
||||
raise ValueError("images must not be empty.")
|
||||
if len(value) > AI_ANALYZE_MAP_MAX_IMAGES:
|
||||
raise ValueError(
|
||||
f"images exceeds max count ({AI_ANALYZE_MAP_MAX_IMAGES})."
|
||||
)
|
||||
|
||||
normalized: List[str] = []
|
||||
for raw in value:
|
||||
img = str(raw or "").strip()
|
||||
if not img:
|
||||
continue
|
||||
if len(img) > AI_ANALYZE_MAP_MAX_IMAGE_BASE64_CHARS:
|
||||
raise ValueError(
|
||||
"one image exceeds max base64 length "
|
||||
f"({AI_ANALYZE_MAP_MAX_IMAGE_BASE64_CHARS} chars)."
|
||||
)
|
||||
normalized.append(img)
|
||||
|
||||
if not normalized:
|
||||
raise ValueError("images must contain at least one non-empty base64 string.")
|
||||
return normalized
|
||||
|
||||
@field_validator("prompt", mode="before")
|
||||
@classmethod
|
||||
def _validate_prompt(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) > AI_ANALYZE_MAP_PROMPT_MAX_CHARS:
|
||||
raise ValueError(
|
||||
f"prompt exceeds max length ({AI_ANALYZE_MAP_PROMPT_MAX_CHARS} chars)."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
@router.post("/ai/train", status_code=202)
|
||||
async def train_ai_model(background_tasks: BackgroundTasks, admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
使用已标记的数据训练AI模型。
|
||||
"""
|
||||
try:
|
||||
task_id = await task_service.create_task("AI_TRAIN", "AI 模型训练")
|
||||
await job_queue_service.create_job("AI_TRAIN", payload={}, task_id=task_id)
|
||||
return {"message": "AI模型训练任务已进入队列", "task_id": task_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("AI 模型训练任务创建失败")
|
||||
raise HTTPException(status_code=500, detail="AI 模型训练任务创建失败,请查看后端日志")
|
||||
|
||||
|
||||
@router.post("/ai/predict-all", status_code=202)
|
||||
async def predict_all_quality(background_tasks: BackgroundTasks, admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
使用训练好的模型预测所有结果的质量。
|
||||
"""
|
||||
try:
|
||||
task_id = await task_service.create_task("AI_PREDICT", "AI 质量预测")
|
||||
await job_queue_service.create_job("AI_PREDICT", payload={}, task_id=task_id)
|
||||
return {"message": "AI质量预测任务已进入队列", "task_id": task_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("AI 质量预测任务创建失败")
|
||||
raise HTTPException(status_code=500, detail="AI 质量预测任务创建失败,请查看后端日志")
|
||||
|
||||
|
||||
@router.get("/ai/status")
|
||||
async def get_ai_status(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取AI模型状态和标记统计,并检测 Ollama 连通性。
|
||||
"""
|
||||
counts = await dinsar_read_service.get_ai_status_counts(db)
|
||||
|
||||
ollama_online = False
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=1.0) as client:
|
||||
ollama_base = settings.OLLAMA_BASE_URL
|
||||
response = await client.get(f"{ollama_base.rstrip('/')}/api/tags")
|
||||
ollama_online = response.status_code == 200
|
||||
except Exception:
|
||||
ollama_online = False
|
||||
|
||||
return {
|
||||
"is_model_trained": is_model_trained(),
|
||||
"model_info": get_model_info(),
|
||||
"labeled_count": counts["labeled_count"],
|
||||
"good_count": counts["good_count"],
|
||||
"bad_count": counts["bad_count"],
|
||||
"ollama_online": ollama_online
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ai/warmup")
|
||||
async def warmup_ai_endpoint(background_tasks: BackgroundTasks, admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
发起 AI 模型预热任务,提前将 VLM 加载至显存。
|
||||
"""
|
||||
try:
|
||||
task_id = await task_service.create_task("AI_WARMUP", "AI 模型预热")
|
||||
await job_queue_service.create_job("AI_WARMUP", payload={}, task_id=task_id)
|
||||
return {"task_id": task_id}
|
||||
except Exception as e:
|
||||
logger.exception("AI 模型预热任务创建失败")
|
||||
raise HTTPException(status_code=500, detail="AI 模型预热任务创建失败,请查看后端日志")
|
||||
|
||||
|
||||
@router.post("/ai/analyze-map")
|
||||
async def analyze_map_endpoint(request: MapAnalysisRequest, admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
接收地图截图并调用本地 VLM 进行分析。
|
||||
"""
|
||||
try:
|
||||
analysis = await analyze_map_with_vlm(request.images, request.prompt)
|
||||
return {"analysis": analysis}
|
||||
except Exception as e:
|
||||
logger.exception("AI 地图分析失败")
|
||||
raise HTTPException(status_code=500, detail="AI 分析失败,请查看后端日志")
|
||||
|
||||
|
||||
@router.post("/ai/analyze-result/{result_id}", status_code=202)
|
||||
async def analyze_dinsar_result_endpoint(
|
||||
result_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""
|
||||
异步自动分析指定的 D-InSAR 结果。
|
||||
"""
|
||||
try:
|
||||
task_id = await task_service.create_task("AI_ANALYZE", f"AI 诊断 (结果 ID: {result_id})", params={"result_id": result_id})
|
||||
payload = {"result_id": result_id}
|
||||
await job_queue_service.create_job("AI_ANALYZE", payload=payload, task_id=task_id)
|
||||
return {"message": "AI 智能诊断任务已进入队列", "task_id": task_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
|
||||
# ============ 新版 AI 诊断 RESTful API ============
|
||||
|
||||
|
||||
@router.get("/ai/prompt-templates")
|
||||
async def get_prompt_templates():
|
||||
"""
|
||||
获取所有可用的 Prompt 模板列表。
|
||||
"""
|
||||
return list_prompt_templates()
|
||||
|
||||
|
||||
@router.post("/ai/diagnosis", status_code=202)
|
||||
async def create_diagnosis(
|
||||
request: AiDiagnosisCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""
|
||||
创建 AI 诊断任务(异步执行)。
|
||||
|
||||
简化流程:
|
||||
1. 验证 result_id 存在
|
||||
2. 创建 SystemTask 和 SystemJob
|
||||
3. 在 job handler 中创建诊断记录
|
||||
"""
|
||||
try:
|
||||
logger.info(f"收到 AI 诊断请求: result_id={request.result_id}, model={request.model_name}")
|
||||
|
||||
# 验证 result_id 存在
|
||||
logger.info("正在验证 result_id...")
|
||||
record = await dinsar_read_service.get_compat_record(
|
||||
db,
|
||||
compat_result_id=request.result_id,
|
||||
)
|
||||
if record is None:
|
||||
logger.warning(f"D-InSAR 结果 ID {request.result_id} 不存在")
|
||||
raise HTTPException(status_code=404, detail=f"D-InSAR 结果 ID {request.result_id} 不存在")
|
||||
|
||||
result_name = record.display_name
|
||||
logger.info(f"找到 D-InSAR 结果: {result_name}")
|
||||
|
||||
# 获取 Prompt 文本
|
||||
logger.info("正在获取 Prompt 文本...")
|
||||
if request.custom_prompt:
|
||||
prompt_text = request.custom_prompt
|
||||
else:
|
||||
try:
|
||||
prompt_text = get_prompt_template(request.prompt_template)
|
||||
except ValueError as e:
|
||||
logger.error(f"Prompt 模板错误: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
logger.info(f"Prompt 文本长度: {len(prompt_text)}")
|
||||
|
||||
# 创建后台任务
|
||||
logger.info("正在创建 SystemTask...")
|
||||
task_id = await task_service.create_task(
|
||||
"AI_DIAGNOSIS",
|
||||
f"AI 诊断 - {result_name}",
|
||||
params={
|
||||
"result_id": request.result_id,
|
||||
"product_id": record.product.product_id,
|
||||
"model_name": request.model_name,
|
||||
"prompt_template": request.prompt_template,
|
||||
}
|
||||
)
|
||||
logger.info(f"SystemTask 已创建: {task_id}")
|
||||
|
||||
logger.info("正在创建 SystemJob...")
|
||||
payload = {
|
||||
"result_id": request.result_id,
|
||||
"product_id": record.product.product_id,
|
||||
"model_name": request.model_name,
|
||||
"prompt_template": request.prompt_template,
|
||||
"prompt_text": prompt_text,
|
||||
}
|
||||
await job_queue_service.create_job("AI_DIAGNOSIS", payload=payload, task_id=task_id)
|
||||
logger.info("SystemJob 已创建")
|
||||
|
||||
logger.info(f"AI 诊断任务已创建: result_id={request.result_id}, task_id={task_id}")
|
||||
|
||||
return {
|
||||
"message": "AI 诊断任务已创建",
|
||||
"task_id": task_id,
|
||||
"result_id": request.result_id,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error(f"创建任务失败 (ValueError): {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"创建任务失败 (Exception): {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="AI 诊断任务创建失败,请查看后端日志")
|
||||
|
||||
|
||||
@router.get("/ai/diagnosis", response_model=AiDiagnosisListResponse)
|
||||
async def list_diagnoses(
|
||||
result_id: Optional[int] = None,
|
||||
task_id: Optional[str] = None,
|
||||
risk_level: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
查询 AI 诊断记录列表(支持分页和过滤)。
|
||||
|
||||
- result_id: 按 D-InSAR 结果 ID 过滤
|
||||
- task_id: 按任务 ID 过滤
|
||||
- risk_level: 按风险等级过滤(low/medium/high/critical)
|
||||
- page: 页码(从 1 开始)
|
||||
- page_size: 每页数量(1-100)
|
||||
"""
|
||||
if page < 1:
|
||||
raise HTTPException(status_code=400, detail="page 必须 >= 1")
|
||||
if page_size < 1 or page_size > 100:
|
||||
raise HTTPException(status_code=400, detail="page_size 必须在 1-100 之间")
|
||||
|
||||
# 构建查询
|
||||
query = select(AiDiagnosisORM)
|
||||
|
||||
if result_id is not None:
|
||||
query = query.where(AiDiagnosisORM.result_id == result_id)
|
||||
if task_id is not None:
|
||||
query = query.where(AiDiagnosisORM.task_id == task_id)
|
||||
if risk_level is not None:
|
||||
query = query.where(AiDiagnosisORM.risk_level == risk_level)
|
||||
|
||||
# 总数查询
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
# 分页查询(按创建时间倒序)
|
||||
query = query.order_by(desc(AiDiagnosisORM.created_at))
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return AiDiagnosisListResponse(
|
||||
items=[AiDiagnosisResponse.model_validate(item) for item in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ai/diagnosis/{diagnosis_id}", response_model=AiDiagnosisResponse)
|
||||
async def get_diagnosis(
|
||||
diagnosis_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取单个 AI 诊断记录详情。
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AiDiagnosisORM).where(AiDiagnosisORM.id == diagnosis_id)
|
||||
)
|
||||
diagnosis = result.scalar_one_or_none()
|
||||
|
||||
if not diagnosis:
|
||||
raise HTTPException(status_code=404, detail=f"诊断记录 ID {diagnosis_id} 不存在")
|
||||
|
||||
return diagnosis
|
||||
|
||||
|
||||
@router.delete("/ai/diagnosis/{diagnosis_id}", status_code=204)
|
||||
async def delete_diagnosis(
|
||||
diagnosis_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""
|
||||
删除 AI 诊断记录。
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AiDiagnosisORM).where(AiDiagnosisORM.id == diagnosis_id)
|
||||
)
|
||||
diagnosis = result.scalar_one_or_none()
|
||||
|
||||
if not diagnosis:
|
||||
raise HTTPException(status_code=404, detail=f"诊断记录 ID {diagnosis_id} 不存在")
|
||||
|
||||
await db.delete(diagnosis)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"AI 诊断记录已删除: diagnosis_id={diagnosis_id}")
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..config import settings
|
||||
from . import dependencies as deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/aoi/regions/children")
|
||||
async def list_aoi_region_children(parent_tree_id: Optional[str] = "1"):
|
||||
deps._load_region_index()
|
||||
parent = (parent_tree_id or "1").strip() or "1"
|
||||
children = (deps._REGION_CHILDREN_CACHE or {}).get(parent, [])
|
||||
return {
|
||||
"parent_tree_id": parent,
|
||||
"children": children,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/aoi/regions/{tree_id}/geometry")
|
||||
async def get_aoi_region_geometry(tree_id: str):
|
||||
return deps._resolve_region_aoi_payload(tree_id)
|
||||
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from ..auth_service import (
|
||||
ROLE_ADMIN,
|
||||
VALID_ROLES,
|
||||
SESSION_COOKIE_NAME,
|
||||
add_audit_log,
|
||||
authenticate_user,
|
||||
create_session,
|
||||
create_user,
|
||||
get_cookie_options,
|
||||
get_user_by_session_token,
|
||||
list_users,
|
||||
revoke_session,
|
||||
to_user_payload,
|
||||
update_user,
|
||||
)
|
||||
from ..database import get_db
|
||||
from ..models import AuthAuditLogInfo, AuthAuditLogORM, AuthSessionORM, AuthUserInfo, AuthUserORM
|
||||
from .dependencies import (
|
||||
_add_operation_audit_log,
|
||||
_build_login_throttle_key,
|
||||
_clear_login_failure_state,
|
||||
_get_client_ip,
|
||||
_get_current_user,
|
||||
_get_login_retry_after_seconds,
|
||||
_record_login_failure,
|
||||
_require_admin,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "viewer"
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/auth/login")
|
||||
async def auth_login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
client_ip = _get_client_ip(request)
|
||||
submitted_username = (payload.username or "").strip()
|
||||
throttle_key = _build_login_throttle_key(submitted_username, client_ip)
|
||||
|
||||
retry_after = await _get_login_retry_after_seconds(throttle_key, db)
|
||||
if retry_after > 0:
|
||||
await add_audit_log(
|
||||
db,
|
||||
action="login_blocked_rate_limit",
|
||||
resource="auth/login",
|
||||
detail={"username": submitted_username, "retry_after_seconds": retry_after},
|
||||
ip_address=client_ip,
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Too many failed login attempts. Try again in {retry_after} seconds.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
user = await authenticate_user(db, payload.username, payload.password)
|
||||
if not user:
|
||||
lock_retry_after = await _record_login_failure(throttle_key, db)
|
||||
audit_action = "login_failed"
|
||||
error_status = 401
|
||||
error_detail = "Invalid username or password."
|
||||
audit_detail = {"username": submitted_username}
|
||||
|
||||
if lock_retry_after > 0:
|
||||
audit_action = "login_rate_limited"
|
||||
error_status = 429
|
||||
error_detail = f"Too many failed login attempts. Try again in {lock_retry_after} seconds."
|
||||
audit_detail["retry_after_seconds"] = lock_retry_after
|
||||
|
||||
await add_audit_log(
|
||||
db,
|
||||
action=audit_action,
|
||||
resource="auth/login",
|
||||
detail=audit_detail,
|
||||
ip_address=client_ip,
|
||||
)
|
||||
await db.commit()
|
||||
if lock_retry_after > 0:
|
||||
raise HTTPException(
|
||||
status_code=error_status,
|
||||
detail=error_detail,
|
||||
headers={"Retry-After": str(lock_retry_after)},
|
||||
)
|
||||
raise HTTPException(status_code=error_status, detail=error_detail)
|
||||
|
||||
await _clear_login_failure_state(throttle_key, db)
|
||||
|
||||
token, expires_at = await create_session(
|
||||
db,
|
||||
user,
|
||||
ip_address=client_ip,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
await add_audit_log(
|
||||
db,
|
||||
action="login_success",
|
||||
user=user,
|
||||
resource="auth/login",
|
||||
ip_address=client_ip,
|
||||
)
|
||||
user_payload = to_user_payload(user)
|
||||
await db.commit()
|
||||
|
||||
cookie_opts = get_cookie_options()
|
||||
response.set_cookie(value=token, **cookie_opts)
|
||||
return {
|
||||
"message": "Login successful.",
|
||||
"user": user_payload,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
async def auth_logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
user = await get_user_by_session_token(db, token) if token else None
|
||||
|
||||
revoked = await revoke_session(db, token)
|
||||
if user:
|
||||
await add_audit_log(
|
||||
db,
|
||||
action="logout",
|
||||
user=user,
|
||||
resource="auth/logout",
|
||||
ip_address=_get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||
return {"message": "Logged out.", "revoked": revoked}
|
||||
|
||||
|
||||
@router.get("/auth/me", response_model=AuthUserInfo)
|
||||
async def auth_me(current_user: AuthUserORM = Depends(_get_current_user)):
|
||||
return AuthUserInfo.model_validate(current_user)
|
||||
|
||||
|
||||
@router.get("/auth/users", response_model=List[AuthUserInfo])
|
||||
async def auth_list_users(
|
||||
_admin: AuthUserORM = Depends(_require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
users = await list_users(db)
|
||||
return [AuthUserInfo.model_validate(user) for user in users]
|
||||
|
||||
|
||||
@router.get("/auth/audit-logs", response_model=List[AuthAuditLogInfo])
|
||||
async def auth_list_audit_logs(
|
||||
limit: int = 200,
|
||||
_admin: AuthUserORM = Depends(_require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
limit = max(1, min(limit, 1000))
|
||||
result = await db.execute(
|
||||
select(AuthAuditLogORM)
|
||||
.order_by(AuthAuditLogORM.created_at.desc(), AuthAuditLogORM.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
return [AuthAuditLogInfo.model_validate(item) for item in logs]
|
||||
|
||||
|
||||
@router.post("/auth/users", response_model=AuthUserInfo, status_code=201)
|
||||
async def auth_create_user(
|
||||
payload: CreateUserRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
if payload.role not in VALID_ROLES:
|
||||
raise HTTPException(status_code=422, detail=f"role must be one of: {sorted(VALID_ROLES)}")
|
||||
try:
|
||||
user = await create_user(
|
||||
db,
|
||||
username=payload.username,
|
||||
password=payload.password,
|
||||
role=payload.role,
|
||||
is_active=payload.is_active,
|
||||
created_by=admin_user.username,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
await add_audit_log(
|
||||
db,
|
||||
action="user_created",
|
||||
user=admin_user,
|
||||
resource=f"auth/users/{user.id}",
|
||||
detail={"username": user.username, "role": user.role, "is_active": user.is_active},
|
||||
ip_address=_get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return AuthUserInfo.model_validate(user)
|
||||
|
||||
|
||||
@router.patch("/auth/users/{user_id}", response_model=AuthUserInfo)
|
||||
async def auth_update_user(
|
||||
user_id: int,
|
||||
payload: UpdateUserRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
user_result = await db.execute(
|
||||
select(AuthUserORM).where(AuthUserORM.id == user_id)
|
||||
)
|
||||
target_user = user_result.scalar_one_or_none()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found.")
|
||||
|
||||
if payload.role is not None and payload.role not in VALID_ROLES:
|
||||
raise HTTPException(status_code=422, detail=f"role must be one of: {sorted(VALID_ROLES)}")
|
||||
|
||||
if target_user.id == admin_user.id:
|
||||
if payload.is_active is False:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate current admin account.")
|
||||
if payload.role is not None and payload.role != ROLE_ADMIN:
|
||||
raise HTTPException(status_code=400, detail="Cannot remove admin role from current account.")
|
||||
|
||||
try:
|
||||
updated = await update_user(
|
||||
db,
|
||||
target_user,
|
||||
role=payload.role,
|
||||
is_active=payload.is_active,
|
||||
password=payload.password,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
await add_audit_log(
|
||||
db,
|
||||
action="user_updated",
|
||||
user=admin_user,
|
||||
resource=f"auth/users/{updated.id}",
|
||||
detail={
|
||||
"role": updated.role,
|
||||
"is_active": updated.is_active,
|
||||
"password_updated": payload.password is not None,
|
||||
},
|
||||
ip_address=_get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(updated)
|
||||
return AuthUserInfo.model_validate(updated)
|
||||
|
||||
|
||||
@router.post("/auth/cleanup-sessions")
|
||||
async def auth_cleanup_sessions(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
delete(AuthSessionORM).where(
|
||||
(AuthSessionORM.expires_at <= now) | (AuthSessionORM.is_revoked == True)
|
||||
)
|
||||
)
|
||||
deleted_count = result.rowcount or 0
|
||||
await add_audit_log(
|
||||
db,
|
||||
action="sessions_cleanup",
|
||||
user=admin_user,
|
||||
resource="auth/cleanup-sessions",
|
||||
detail={"deleted_count": deleted_count},
|
||||
ip_address=_get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": f"已清理 {deleted_count} 条过期/已撤销会话。", "deleted_count": deleted_count}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
||||
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="未找到任何匹配的结果记录")
|
||||
|
||||
file_paths = []
|
||||
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:
|
||||
file_paths.append(file_path)
|
||||
|
||||
if not file_paths:
|
||||
raise HTTPException(status_code=400, detail="选中的结果没有关联的文件路径")
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
export_result = await asyncio.to_thread(
|
||||
export_dinsar_results, file_paths, 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
|
||||
@@ -0,0 +1,216 @@
|
||||
"""D-InSAR multi-engine production routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .dependencies import _get_current_user, _require_admin
|
||||
from ..config import read_int_env, settings
|
||||
from ..models import AuthUserORM
|
||||
from ..services import envi_service as _envi_svc
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.task_service import task_service
|
||||
|
||||
router = APIRouter(prefix="/dinsar-production", tags=["dinsar-production"])
|
||||
|
||||
DINSAR_PRODUCTION_JOB_MAX_ATTEMPTS = read_int_env(
|
||||
"DINSAR_PRODUCTION_JOB_MAX_ATTEMPTS",
|
||||
6,
|
||||
minimum=1,
|
||||
maximum=20,
|
||||
)
|
||||
ISCE2_PRODUCTION_JOB_MAX_ATTEMPTS = read_int_env(
|
||||
"ISCE2_PRODUCTION_JOB_MAX_ATTEMPTS",
|
||||
1,
|
||||
minimum=1,
|
||||
maximum=10,
|
||||
)
|
||||
|
||||
|
||||
class RunJobRequest(BaseModel):
|
||||
engine_code: str = Field(..., description="Engine code: sarscape / isce2 / landsar")
|
||||
profile: str = Field(..., description="Engine profile, for example custom6 / lt1_stripmap")
|
||||
root_dir: str = Field(..., description="Windows root directory")
|
||||
num_to_process: int = Field(default=0, ge=0, description="How many tasks to process; 0 means all")
|
||||
timeout_seconds: Optional[int] = Field(default=None, ge=60)
|
||||
extra: Dict[str, Any] = Field(default_factory=dict, description="Engine-specific parameters")
|
||||
|
||||
|
||||
class WslCheckRequest(BaseModel):
|
||||
distro: Optional[str] = Field(default=None, description="Optional WSL distro override")
|
||||
smoke_test: bool = Field(default=False)
|
||||
|
||||
|
||||
def _get_registry():
|
||||
from ..dinsar_engines import registry
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
@router.get("/engines")
|
||||
async def list_engines():
|
||||
registry = _get_registry()
|
||||
result = []
|
||||
for engine in registry.list_engines():
|
||||
availability = await asyncio.to_thread(engine.check_available)
|
||||
result.append(
|
||||
{
|
||||
**engine.to_dict(),
|
||||
"status": availability.status,
|
||||
"available": availability.available,
|
||||
"message": availability.message,
|
||||
"checks": availability.checks,
|
||||
}
|
||||
)
|
||||
return {"engines": result}
|
||||
|
||||
|
||||
@router.get("/engines/{engine_code}")
|
||||
async def get_engine_detail(engine_code: str):
|
||||
registry = _get_registry()
|
||||
engine = registry.get_engine(engine_code)
|
||||
if not engine:
|
||||
raise HTTPException(status_code=404, detail=f"Engine '{engine_code}' not found.")
|
||||
|
||||
availability = await asyncio.to_thread(engine.check_available)
|
||||
return {
|
||||
**engine.to_dict(),
|
||||
"status": availability.status,
|
||||
"available": availability.available,
|
||||
"message": availability.message,
|
||||
"checks": availability.checks,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/wsl-check")
|
||||
async def run_wsl_check(
|
||||
req: WslCheckRequest,
|
||||
current_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
from ..services.wsl_service import check_wsl_environment
|
||||
|
||||
distro = req.distro or settings.ISCE2_WSL_DISTRO
|
||||
report = await asyncio.to_thread(
|
||||
check_wsl_environment,
|
||||
distro=distro,
|
||||
python_cmd=settings.ISCE2_PYTHON,
|
||||
stripmap_app_path=settings.ISCE2_STRIPMAP_APP,
|
||||
pipeline_script_path=settings.ISCE2_PIPELINE_SCRIPT,
|
||||
dem_path_win=settings.ISCE2_DEM_PATH,
|
||||
orbit_dir_win=settings.ORBIT_POOL_ISCE2,
|
||||
output_dir_win=settings.ISCE2_OUTPUT_ROOT,
|
||||
smoke_test=req.smoke_test,
|
||||
)
|
||||
return report.to_dict()
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def submit_run(
|
||||
req: RunJobRequest,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
registry = _get_registry()
|
||||
engine = registry.get_engine(req.engine_code)
|
||||
if not engine:
|
||||
raise HTTPException(status_code=400, detail=f"Engine '{req.engine_code}' not found.")
|
||||
|
||||
availability = await asyncio.to_thread(engine.check_available)
|
||||
if not availability.available:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Engine '{req.engine_code}' is unavailable: {availability.message}",
|
||||
)
|
||||
|
||||
valid_profiles = {profile.code for profile in engine.get_profiles()}
|
||||
if req.profile not in valid_profiles:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Engine '{req.engine_code}' does not support profile '{req.profile}'. "
|
||||
f"Available profiles: {sorted(valid_profiles)}"
|
||||
),
|
||||
)
|
||||
|
||||
validation_summary = None
|
||||
if req.engine_code == "isce2" and hasattr(engine, "validate_root_dir"):
|
||||
try:
|
||||
validation_summary = await asyncio.to_thread(
|
||||
engine.validate_root_dir,
|
||||
req.root_dir,
|
||||
req.num_to_process,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
payload = {
|
||||
"engine_code": req.engine_code,
|
||||
"profile": req.profile,
|
||||
"root_dir": req.root_dir,
|
||||
"num_to_process": req.num_to_process,
|
||||
"timeout_seconds": req.timeout_seconds,
|
||||
"extra": dict(req.extra or {}),
|
||||
}
|
||||
|
||||
from ..services.job_handlers import JOB_TYPE_IDL_RUN_DINSAR, JOB_TYPE_ISCE2_RUN
|
||||
|
||||
if req.engine_code == "sarscape":
|
||||
payload["mode"] = "custom" if req.profile == "custom6" else "metatask"
|
||||
job_type = JOB_TYPE_IDL_RUN_DINSAR
|
||||
max_attempts = DINSAR_PRODUCTION_JOB_MAX_ATTEMPTS
|
||||
elif req.engine_code == "isce2":
|
||||
if hasattr(engine, "normalize_extra"):
|
||||
try:
|
||||
payload["extra"] = engine.normalize_extra(payload["extra"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
job_type = JOB_TYPE_ISCE2_RUN
|
||||
max_attempts = ISCE2_PRODUCTION_JOB_MAX_ATTEMPTS
|
||||
if validation_summary is not None:
|
||||
payload["extra"].update(
|
||||
{
|
||||
"__validated_task_count": validation_summary.get("task_count", 0),
|
||||
"__validated_mode": validation_summary.get("mode", ""),
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Engine '{req.engine_code}' does not support queued production.",
|
||||
)
|
||||
|
||||
task_name = f"D-InSAR production: {req.engine_code}/{req.profile}"
|
||||
try:
|
||||
task_id = await task_service.create_task(
|
||||
task_type=job_type,
|
||||
task_name=task_name,
|
||||
params=payload,
|
||||
)
|
||||
job_id = await job_queue_service.create_job(
|
||||
job_type=job_type,
|
||||
payload=payload,
|
||||
task_id=task_id,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
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
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"job_type": job_type,
|
||||
"engine_code": req.engine_code,
|
||||
"profile": req.profile,
|
||||
"selected_task_count": validation_summary.get("task_count", 1) if validation_summary else 1,
|
||||
"message": "Task queued.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def list_runs(limit: int = 20):
|
||||
runs = await asyncio.to_thread(_envi_svc.list_recent_runs, limit)
|
||||
return {"runs": runs, "total": len(runs)}
|
||||
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.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/{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}/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")
|
||||
return FileResponse(preview_path, media_type="image/webp")
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..models import AuthUserORM, HazardPoint, HazardPointORM
|
||||
from ..services.data_service import data_service
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.task_service import task_service
|
||||
from .dependencies import _require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_hazard_shp_path() -> str:
|
||||
base_dir = settings.HAZARD_POINTS_DIR
|
||||
filename = settings.HAZARD_POINTS_FILENAME
|
||||
if not base_dir or not filename:
|
||||
raise ValueError("HAZARD_POINTS_DIR and HAZARD_POINTS_FILENAME must be configured.")
|
||||
if str(base_dir).lower().endswith(".shp"):
|
||||
raise ValueError("HAZARD_POINTS_DIR must be a directory, not a .shp path.")
|
||||
if not os.path.isdir(str(base_dir)):
|
||||
raise FileNotFoundError(f"Hazard points directory not found: {base_dir}")
|
||||
shp_path = os.path.join(str(base_dir), str(filename))
|
||||
if not shp_path.lower().endswith(".shp"):
|
||||
raise ValueError(f"HAZARD_POINTS_FILENAME must end with .shp: {filename}")
|
||||
if not os.path.isfile(shp_path):
|
||||
raise FileNotFoundError(f"Hazard points Shapefile not found: {shp_path}")
|
||||
return shp_path
|
||||
|
||||
|
||||
@router.post("/hazard-points/scan", status_code=202)
|
||||
async def scan_hazard_points_endpoint(background_tasks: BackgroundTasks, admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
扫描预定义路径下的地质灾害点 Shapefile。
|
||||
"""
|
||||
try:
|
||||
shp_path = _resolve_hazard_shp_path()
|
||||
task_id = await task_service.create_task("SCAN_HAZARD", "灾害点数据同步")
|
||||
await job_queue_service.create_job("SCAN_HAZARD", payload={}, task_id=task_id)
|
||||
return {"message": "灾害点同步任务已进入队列", "task_id": task_id}
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/hazard-points", response_model=List[HazardPoint])
|
||||
async def get_hazard_points_endpoint(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取所有地质灾害点。
|
||||
"""
|
||||
result = await db.execute(select(HazardPointORM))
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..auth_service import ROLE_ADMIN
|
||||
from ..database import get_db
|
||||
from ..services.health_service import get_health_status, _check_dinsar_engines
|
||||
from .dependencies import _get_optional_session_user, _require_admin
|
||||
from ..models import AuthUserORM
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def get_health(
|
||||
request: Request,
|
||||
full: bool = Query(False),
|
||||
refresh: bool = Query(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
运维自检接口(无需授权)。
|
||||
"""
|
||||
session_user = await _get_optional_session_user(request, db)
|
||||
include_details = bool(full and session_user and session_user.role == ROLE_ADMIN)
|
||||
return await get_health_status(
|
||||
include_details=include_details,
|
||||
full=include_details,
|
||||
refresh=refresh,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health/dinsar-engines")
|
||||
async def get_dinsar_engines_health(
|
||||
current_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""D-InSAR 引擎专项健康检查(管理员)。"""
|
||||
return await _check_dinsar_engines()
|
||||
@@ -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
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import read_int_env, settings
|
||||
from ..database import get_db
|
||||
from ..license_service import check_license
|
||||
from ..models import AuthUserORM
|
||||
from .dependencies import (
|
||||
_add_operation_audit_log,
|
||||
_get_optional_session_user,
|
||||
_require_admin,
|
||||
_LICENSE_UPLOAD_LOCK,
|
||||
MAX_LICENSE_UPLOAD_BYTES,
|
||||
)
|
||||
from ..auth_service import ROLE_ADMIN
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_license_status(raw_status: Dict[str, Any], include_details: bool) -> Dict[str, Any]:
|
||||
public_payload = {
|
||||
"ok": bool(raw_status.get("ok")),
|
||||
"reason": raw_status.get("reason"),
|
||||
"expires_at": raw_status.get("expires_at"),
|
||||
"issued_to": raw_status.get("issued_to"),
|
||||
}
|
||||
if include_details:
|
||||
return {
|
||||
**public_payload,
|
||||
"fingerprint": raw_status.get("fingerprint"),
|
||||
"license_path": raw_status.get("license_path"),
|
||||
}
|
||||
return public_payload
|
||||
|
||||
|
||||
@router.get("/license/status")
|
||||
async def license_status(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
授权状态查询(无需授权)。
|
||||
默认返回脱敏字段;管理员会话可看到额外调试字段。
|
||||
"""
|
||||
status = check_license()
|
||||
session_user = await _get_optional_session_user(request, db)
|
||||
include_details = bool(session_user and session_user.role == ROLE_ADMIN)
|
||||
return _serialize_license_status(status, include_details=include_details)
|
||||
|
||||
|
||||
@router.post("/license/upload")
|
||||
async def license_upload(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""
|
||||
上传授权文件(覆盖后立即生效)。
|
||||
"""
|
||||
filename = (file.filename or "").strip()
|
||||
if not filename.lower().endswith('.lic'):
|
||||
raise HTTPException(status_code=400, detail='License file must end with .lic')
|
||||
|
||||
license_path = settings.LICENSE_PATH
|
||||
if not license_path:
|
||||
license_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "license", "license.lic")
|
||||
license_path = os.path.abspath(license_path)
|
||||
|
||||
license_dir = os.path.dirname(license_path)
|
||||
os.makedirs(license_dir, exist_ok=True)
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="License file is empty")
|
||||
if len(content) > MAX_LICENSE_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"License file too large (max {MAX_LICENSE_UPLOAD_BYTES} bytes)",
|
||||
)
|
||||
|
||||
upload_status: Optional[Dict[str, Any]] = None
|
||||
upload_error: Optional[str] = None
|
||||
tmp_path: Optional[str] = None
|
||||
async with _LICENSE_UPLOAD_LOCK:
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
delete=False,
|
||||
dir=license_dir,
|
||||
prefix="license_upload_",
|
||||
suffix=".lic.tmp",
|
||||
) as temp_file:
|
||||
temp_file.write(content)
|
||||
temp_file.flush()
|
||||
tmp_path = temp_file.name
|
||||
|
||||
upload_status = check_license(license_path=tmp_path)
|
||||
if not upload_status.get("ok"):
|
||||
upload_error = upload_status.get("reason") or "license validation failed"
|
||||
else:
|
||||
os.replace(tmp_path, license_path)
|
||||
tmp_path = None
|
||||
upload_status = check_license()
|
||||
if not upload_status.get("ok"):
|
||||
upload_error = upload_status.get("reason") or "license validation failed after activation"
|
||||
except Exception as exc:
|
||||
upload_error = str(exc)
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if upload_error:
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=request,
|
||||
action="license_upload_failed",
|
||||
user=admin_user,
|
||||
resource="license/upload",
|
||||
detail={"filename": filename, "reason": upload_error},
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=400, detail=f"License invalid: {upload_error}")
|
||||
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=request,
|
||||
action="license_uploaded",
|
||||
user=admin_user,
|
||||
resource="license/upload",
|
||||
detail={"filename": filename, "size": len(content)},
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "License uploaded", "status": upload_status}
|
||||
|
||||
|
||||
@router.post("/license/refresh")
|
||||
async def license_refresh(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""
|
||||
刷新授权状态。
|
||||
"""
|
||||
status = check_license()
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=request,
|
||||
action="license_refreshed",
|
||||
user=admin_user,
|
||||
resource="license/refresh",
|
||||
detail={"license_ok": bool(status.get("ok")), "reason": status.get("reason")},
|
||||
)
|
||||
await db.commit()
|
||||
return status
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
日志管理 API
|
||||
|
||||
提供日志文件的列表、查看、删除功能。
|
||||
"""
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import settings
|
||||
from ..models import AuthUserORM
|
||||
from .dependencies import _get_current_user
|
||||
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
# 日志根目录
|
||||
LOG_ROOT = Path(settings.PROJECT_ROOT) / "logs"
|
||||
|
||||
|
||||
class LogFileInfo(BaseModel):
|
||||
"""日志文件信息"""
|
||||
name: str
|
||||
path: str
|
||||
size: int
|
||||
modified_at: str
|
||||
type: str # app, task, error
|
||||
|
||||
|
||||
class LogContentResponse(BaseModel):
|
||||
"""日志内容响应"""
|
||||
file_name: str
|
||||
total_lines: int
|
||||
content: str
|
||||
offset: int
|
||||
limit: int
|
||||
|
||||
|
||||
def _get_log_type(file_path: Path) -> str:
|
||||
"""根据路径判断日志类型"""
|
||||
path_str = str(file_path)
|
||||
if "logs/app" in path_str or "logs\\app" in path_str:
|
||||
return "app"
|
||||
elif "logs/error" in path_str or "logs\\error" in path_str:
|
||||
return "error"
|
||||
elif "logs/tasks" in path_str or "logs\\tasks" in path_str:
|
||||
return "task"
|
||||
return "other"
|
||||
|
||||
|
||||
def _is_safe_path(file_path: str) -> bool:
|
||||
"""检查路径是否安全(防止路径遍历攻击)"""
|
||||
try:
|
||||
requested_path = (LOG_ROOT / file_path).resolve()
|
||||
return requested_path.is_relative_to(LOG_ROOT)
|
||||
except (ValueError, RuntimeError):
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[LogFileInfo])
|
||||
async def list_logs(
|
||||
log_type: Optional[str] = Query(None, description="日志类型: app, task, error"),
|
||||
current_user: AuthUserORM = Depends(_get_current_user)
|
||||
):
|
||||
"""
|
||||
列出所有日志文件
|
||||
|
||||
- 支持按类型过滤
|
||||
- 返回文件名、大小、修改时间
|
||||
"""
|
||||
if not LOG_ROOT.exists():
|
||||
return []
|
||||
|
||||
log_files = []
|
||||
|
||||
# 遍历日志目录
|
||||
for log_file in LOG_ROOT.rglob("*.log"):
|
||||
if log_file.is_file() and log_file.name != ".gitkeep":
|
||||
file_type = _get_log_type(log_file)
|
||||
|
||||
# 类型过滤
|
||||
if log_type and file_type != log_type:
|
||||
continue
|
||||
|
||||
# 获取相对路径
|
||||
relative_path = log_file.relative_to(LOG_ROOT)
|
||||
|
||||
log_files.append(LogFileInfo(
|
||||
name=log_file.name,
|
||||
path=str(relative_path).replace("\\", "/"),
|
||||
size=log_file.stat().st_size,
|
||||
modified_at=datetime.fromtimestamp(log_file.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
type=file_type
|
||||
))
|
||||
|
||||
# 按修改时间倒序排序
|
||||
log_files.sort(key=lambda x: x.modified_at, reverse=True)
|
||||
|
||||
return log_files
|
||||
|
||||
|
||||
@router.get("/content/{log_path:path}", response_model=LogContentResponse)
|
||||
async def get_log_content(
|
||||
log_path: str,
|
||||
offset: int = Query(0, ge=0, description="起始行号"),
|
||||
limit: int = Query(1000, ge=1, le=10000, description="读取行数"),
|
||||
current_user: AuthUserORM = Depends(_get_current_user)
|
||||
):
|
||||
"""
|
||||
读取日志内容
|
||||
|
||||
- 支持分页读取(大文件)
|
||||
- offset: 起始行号(从 0 开始)
|
||||
- limit: 读取行数(最多 10000 行)
|
||||
"""
|
||||
# 安全检查
|
||||
if not _is_safe_path(log_path):
|
||||
raise HTTPException(status_code=400, detail="非法的文件路径")
|
||||
|
||||
log_file = LOG_ROOT / log_path
|
||||
|
||||
if not log_file.exists():
|
||||
raise HTTPException(status_code=404, detail="日志文件不存在")
|
||||
|
||||
if not log_file.is_file():
|
||||
raise HTTPException(status_code=400, detail="不是有效的文件")
|
||||
|
||||
try:
|
||||
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
||||
# Count total lines without loading all into memory
|
||||
total_lines = sum(1 for _ in f)
|
||||
|
||||
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
||||
# Skip to offset using islice, then read 'limit' lines
|
||||
content_lines = list(itertools.islice(f, offset, offset + limit))
|
||||
|
||||
return LogContentResponse(
|
||||
file_name=log_file.name,
|
||||
total_lines=total_lines,
|
||||
content="".join(content_lines),
|
||||
offset=offset,
|
||||
limit=limit
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("读取日志失败: %s", log_file.name)
|
||||
raise HTTPException(status_code=500, detail="读取日志失败,请查看后端日志")
|
||||
|
||||
|
||||
@router.delete("/{log_path:path}")
|
||||
async def delete_log(
|
||||
log_path: str,
|
||||
current_user: AuthUserORM = Depends(_get_current_user)
|
||||
):
|
||||
"""
|
||||
删除日志文件
|
||||
|
||||
- 仅管理员可以删除
|
||||
- 需要确认操作
|
||||
"""
|
||||
# 权限检查
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以删除日志")
|
||||
|
||||
# 安全检查
|
||||
if not _is_safe_path(log_path):
|
||||
raise HTTPException(status_code=400, detail="非法的文件路径")
|
||||
|
||||
log_file = LOG_ROOT / log_path
|
||||
|
||||
if not log_file.exists():
|
||||
raise HTTPException(status_code=404, detail="日志文件不存在")
|
||||
|
||||
if not log_file.is_file():
|
||||
raise HTTPException(status_code=400, detail="不是有效的文件")
|
||||
|
||||
try:
|
||||
log_file.unlink()
|
||||
return {"message": f"日志文件 {log_file.name} 已删除"}
|
||||
except Exception as e:
|
||||
logger.exception("删除日志失败: %s", log_file.name)
|
||||
raise HTTPException(status_code=500, detail="删除日志失败,请查看后端日志")
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from ..config import read_int_env
|
||||
from ..database import get_db
|
||||
from ..models import AuthUserORM, SystemTaskORM, TaskLogORM
|
||||
from ..scheduler import MONITOR_CONFIG
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.task_service import task_service
|
||||
from .dependencies import _require_admin
|
||||
|
||||
router = APIRouter()
|
||||
MONITOR_ALLOWED_TARGETS = {"radar", "orbit", "dinsar", "gf3"}
|
||||
MONITOR_LOG_DEFAULT_LIMIT = read_int_env(
|
||||
"MONITOR_LOG_DEFAULT_LIMIT",
|
||||
50,
|
||||
minimum=1,
|
||||
maximum=2000,
|
||||
)
|
||||
MONITOR_LOG_MAX_LIMIT = read_int_env(
|
||||
"MONITOR_LOG_MAX_LIMIT",
|
||||
200,
|
||||
minimum=1,
|
||||
maximum=5000,
|
||||
)
|
||||
MONITOR_LOG_MAX_OFFSET = read_int_env(
|
||||
"MONITOR_LOG_MAX_OFFSET",
|
||||
500000,
|
||||
minimum=0,
|
||||
maximum=20000000,
|
||||
)
|
||||
|
||||
|
||||
class MonitorConfig(BaseModel):
|
||||
radar_dirs: List[str] = []
|
||||
orbit_dir: Optional[str] = None
|
||||
dinsar_dirs: List[str] = []
|
||||
gf3_source_dirs: List[str] = []
|
||||
gf3_storage_dirs: List[str] = []
|
||||
# Manual-only: config is read from .env
|
||||
|
||||
|
||||
@router.post("/monitor/config")
|
||||
async def update_monitor_config(config: MonitorConfig):
|
||||
"""
|
||||
更新数据监控配置(雷达、精轨和Dinsar结果)。
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Monitor config is read-only. Edit .env and restart the backend."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/monitor/run-now")
|
||||
async def run_monitor_now(target: Optional[str] = None, background_tasks: BackgroundTasks = None, admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
手动触发一次监控任务(扫描所有配置的目录)。
|
||||
target: 'radar', 'orbit', 'dinsar' 或 None (全部)
|
||||
"""
|
||||
normalized_target = (target or "").strip().lower() or None
|
||||
if normalized_target and normalized_target not in MONITOR_ALLOWED_TARGETS:
|
||||
allowed = ", ".join(sorted(MONITOR_ALLOWED_TARGETS))
|
||||
raise HTTPException(status_code=400, detail=f"Invalid target: {target}. Allowed: {allowed}.")
|
||||
|
||||
radar_dirs = MONITOR_CONFIG.get("radar_dirs") or []
|
||||
orbit_dir = MONITOR_CONFIG.get("orbit_dir") or ""
|
||||
dinsar_dirs = MONITOR_CONFIG.get("dinsar_dirs") or []
|
||||
gf3_storage_dirs = MONITOR_CONFIG.get("gf3_storage_dirs") or []
|
||||
|
||||
if normalized_target == "radar" and not radar_dirs:
|
||||
raise HTTPException(status_code=400, detail="MONITOR_RADAR_DIRS is not configured.")
|
||||
if normalized_target == "orbit" and not orbit_dir:
|
||||
raise HTTPException(status_code=400, detail="MONITOR_ORBIT_DIR is not configured.")
|
||||
if normalized_target == "dinsar" and not dinsar_dirs:
|
||||
raise HTTPException(status_code=400, detail="MONITOR_DINSAR_DIRS is not configured.")
|
||||
if normalized_target == "gf3" and not gf3_storage_dirs:
|
||||
raise HTTPException(status_code=400, detail="GF3_STORAGE_DIRS is not configured.")
|
||||
if normalized_target is None and not (radar_dirs or orbit_dir or dinsar_dirs):
|
||||
raise HTTPException(status_code=400, detail="Monitor paths are not configured in .env.")
|
||||
|
||||
task_type = "SCAN_DATA" if normalized_target in ["radar", "orbit", "gf3", None] else "SCAN_DINSAR"
|
||||
task_name = f"手动触发扫描 ({normalized_target or '全部'})"
|
||||
|
||||
try:
|
||||
task_id = await task_service.create_task(task_type, task_name)
|
||||
if task_type == "SCAN_DINSAR":
|
||||
payload = {"dirs": dinsar_dirs}
|
||||
job_type = "SCAN_DINSAR"
|
||||
elif normalized_target == "gf3":
|
||||
# GF3 扫描:用 gf3_storage_dirs 作为 radar_dirs
|
||||
payload = {
|
||||
"radar_dirs": gf3_storage_dirs,
|
||||
"target": "radar",
|
||||
}
|
||||
job_type = "SCAN_DATA"
|
||||
else:
|
||||
payload = {
|
||||
"use_monitor_config": True,
|
||||
"target": normalized_target,
|
||||
}
|
||||
job_type = "SCAN_DATA"
|
||||
await job_queue_service.create_job(job_type, payload=payload, task_id=task_id)
|
||||
|
||||
return {
|
||||
"message": f"已触发{normalized_target or '全部'}手动扫描任务(已进入队列)",
|
||||
"task_id": task_id
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/monitor/gf3-process")
|
||||
async def run_gf3_batch_process(admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
批量 GF3 L1A→L2 处理:扫描 GF3_SOURCE_DIRS,过滤已处理的,逐个处理并自动入库。
|
||||
"""
|
||||
gf3_source_dirs = MONITOR_CONFIG.get("gf3_source_dirs") or []
|
||||
if not gf3_source_dirs:
|
||||
raise HTTPException(status_code=400, detail="GF3_SOURCE_DIRS is not configured.")
|
||||
|
||||
task_type = "GF3_BATCH_PROCESS"
|
||||
task_name = "GF3 批量 L1A→L2 处理"
|
||||
|
||||
try:
|
||||
task_id = await task_service.create_task(task_type, task_name)
|
||||
await job_queue_service.create_job(
|
||||
"GF3_BATCH_PROCESS",
|
||||
payload={"source_dirs": gf3_source_dirs},
|
||||
task_id=task_id,
|
||||
)
|
||||
return {
|
||||
"message": "GF3 批量处理任务已提交",
|
||||
"task_id": task_id,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/monitor/status")
|
||||
async def get_monitor_status():
|
||||
"""
|
||||
获取当前监控状态。
|
||||
"""
|
||||
return MONITOR_CONFIG
|
||||
|
||||
|
||||
@router.get("/monitor/logs")
|
||||
async def get_monitor_logs(
|
||||
limit: int = MONITOR_LOG_DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取最新的监控日志 (来自任务日志表)。
|
||||
"""
|
||||
safe_limit = min(MONITOR_LOG_MAX_LIMIT, max(1, int(limit or MONITOR_LOG_DEFAULT_LIMIT)))
|
||||
safe_offset = min(MONITOR_LOG_MAX_OFFSET, max(0, int(offset or 0)))
|
||||
result = await db.execute(
|
||||
select(TaskLogORM, SystemTaskORM.task_type, SystemTaskORM.task_name)
|
||||
.join(SystemTaskORM, TaskLogORM.task_id == SystemTaskORM.task_id)
|
||||
.order_by(TaskLogORM.timestamp.desc())
|
||||
.offset(safe_offset)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
logs = []
|
||||
for log, task_type, task_name in reversed(rows):
|
||||
ts = log.timestamp.strftime('%H:%M:%S') if log.timestamp else '--:--:--'
|
||||
label = task_type or "TASK"
|
||||
if task_name:
|
||||
label = f"{label}:{task_name}"
|
||||
logs.append(f"[{ts}] [{log.log_level}] {label} - {log.message}")
|
||||
|
||||
return {
|
||||
"limit": safe_limit,
|
||||
"offset": safe_offset,
|
||||
"count": len(logs),
|
||||
"logs": logs,
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Precise orbit management APIs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..models.orm import AuthUserORM, RadarDataORM
|
||||
from ..routers.dependencies import _get_current_user as get_current_user, _require_admin as require_admin
|
||||
from ..services.orbit_converter import (
|
||||
check_orbit_consistency,
|
||||
get_orbit_pool_inventory,
|
||||
organize_orbit_dir,
|
||||
quarantine_bad_orbits,
|
||||
repair_orbit_pools,
|
||||
scan_orbit_dir,
|
||||
summarize_source_orbit_gaps,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/orbit", tags=["orbit"])
|
||||
|
||||
|
||||
class OrbitPoolActionRequest(BaseModel):
|
||||
repair: bool = False
|
||||
quarantine_bad: bool = False
|
||||
|
||||
|
||||
async def _build_orbit_database_stats(
|
||||
db: AsyncSession,
|
||||
pool_inventory: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
total_radar_count = (
|
||||
await db.execute(select(func.count(RadarDataORM.id)))
|
||||
).scalar_one()
|
||||
with_orbit_data_count = (
|
||||
await db.execute(
|
||||
select(func.count(RadarDataORM.id)).where(RadarDataORM.has_orbit_data == True)
|
||||
)
|
||||
).scalar_one()
|
||||
without_orbit_data_count = total_radar_count - with_orbit_data_count
|
||||
has_orbit_but_missing_path_count = (
|
||||
await db.execute(
|
||||
select(func.count(RadarDataORM.id)).where(
|
||||
RadarDataORM.has_orbit_data == True,
|
||||
or_(RadarDataORM.orbit_file_path.is_(None), RadarDataORM.orbit_file_path == ""),
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
without_orbit_but_path_present_count = (
|
||||
await db.execute(
|
||||
select(func.count(RadarDataORM.id)).where(
|
||||
RadarDataORM.has_orbit_data == False,
|
||||
RadarDataORM.orbit_file_path.is_not(None),
|
||||
RadarDataORM.orbit_file_path != "",
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
path_rows = await db.execute(
|
||||
select(RadarDataORM.orbit_file_path)
|
||||
.where(RadarDataORM.orbit_file_path.is_not(None), RadarDataORM.orbit_file_path != "")
|
||||
.distinct()
|
||||
)
|
||||
distinct_paths = [item for item in path_rows.scalars().all() if item]
|
||||
|
||||
db_existing_path_count = 0
|
||||
db_missing_path_count = 0
|
||||
db_path_errors: list[str] = []
|
||||
for path in distinct_paths:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
db_existing_path_count += 1
|
||||
else:
|
||||
db_missing_path_count += 1
|
||||
except OSError as exc:
|
||||
db_missing_path_count += 1
|
||||
if len(db_path_errors) < 10:
|
||||
db_path_errors.append(f"{path}: {exc}")
|
||||
|
||||
db_expected_rows = await db.execute(
|
||||
select(RadarDataORM.satellite, RadarDataORM.imaging_date).where(RadarDataORM.has_orbit_data == True)
|
||||
)
|
||||
db_expected_stems = {
|
||||
f"{satellite}_GpsData_GAS_C_{imaging_date}"
|
||||
for satellite, imaging_date in db_expected_rows.all()
|
||||
if satellite and imaging_date
|
||||
}
|
||||
|
||||
envi_stems = set(pool_inventory["envi"]["files"].keys())
|
||||
isce2_stems = set(pool_inventory["isce2"]["files"].keys())
|
||||
missing_in_envi = sorted(db_expected_stems - envi_stems)
|
||||
missing_in_isce2 = sorted(db_expected_stems - isce2_stems)
|
||||
|
||||
return {
|
||||
"total_radar_count": int(total_radar_count or 0),
|
||||
"with_orbit_data_count": int(with_orbit_data_count or 0),
|
||||
"without_orbit_data_count": int(without_orbit_data_count or 0),
|
||||
"has_orbit_but_missing_path_count": int(has_orbit_but_missing_path_count or 0),
|
||||
"without_orbit_but_path_present_count": int(without_orbit_but_path_present_count or 0),
|
||||
"distinct_orbit_path_count": len(distinct_paths),
|
||||
"db_existing_path_count": db_existing_path_count,
|
||||
"db_missing_path_count": db_missing_path_count,
|
||||
"db_expected_stem_count": len(db_expected_stems),
|
||||
"stems_missing_in_envi_count": len(missing_in_envi),
|
||||
"stems_missing_in_isce2_count": len(missing_in_isce2),
|
||||
"sample_missing_in_envi": missing_in_envi[:20],
|
||||
"sample_missing_in_isce2": missing_in_isce2[:20],
|
||||
"path_errors": db_path_errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_orbit_status(
|
||||
current_user: AuthUserORM = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return source, pool, consistency, and database orbit status."""
|
||||
source_dir = settings.MONITOR_ORBIT_DIR
|
||||
source_stats = await asyncio.to_thread(scan_orbit_dir, source_dir)
|
||||
pool_inventory = await asyncio.to_thread(
|
||||
get_orbit_pool_inventory,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
True,
|
||||
)
|
||||
consistency = await asyncio.to_thread(
|
||||
check_orbit_consistency,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
)
|
||||
source_gap_summary = await asyncio.to_thread(
|
||||
summarize_source_orbit_gaps,
|
||||
source_dir,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
settings.ORBIT_QUARANTINE_DIR,
|
||||
)
|
||||
database_stats = await _build_orbit_database_stats(db, pool_inventory)
|
||||
|
||||
return {
|
||||
"orbit_root": source_stats.orbit_root,
|
||||
"root_loose": source_stats.root_loose,
|
||||
"by_satellite": source_stats.by_satellite,
|
||||
"converted": source_stats.converted,
|
||||
"total_source": source_stats.total_source,
|
||||
"supported_formats": source_stats.supported_formats,
|
||||
"source": {
|
||||
"path": source_stats.orbit_root,
|
||||
"root_loose": source_stats.root_loose,
|
||||
"by_satellite": source_stats.by_satellite,
|
||||
"converted": source_stats.converted,
|
||||
"total_source": source_stats.total_source,
|
||||
"supported_formats": source_stats.supported_formats,
|
||||
"duplicate_count": source_stats.duplicate_count,
|
||||
"errors": source_stats.errors,
|
||||
"quarantine_path": source_gap_summary["quarantine_path"],
|
||||
"source_without_isce2_count": source_gap_summary["source_without_isce2_count"],
|
||||
"source_without_envi_count": source_gap_summary["source_without_envi_count"],
|
||||
"envi_without_source_count": source_gap_summary["envi_without_source_count"],
|
||||
"isce2_without_source_count": source_gap_summary["isce2_without_source_count"],
|
||||
"suspect_bad_count": source_gap_summary["suspect_bad_count"],
|
||||
"suspect_bad_samples": source_gap_summary["suspect_bad_samples"],
|
||||
"source_without_envi_samples": source_gap_summary["source_without_envi_samples"],
|
||||
"envi_without_source_samples": source_gap_summary["envi_without_source_samples"],
|
||||
"isce2_without_source_samples": source_gap_summary["isce2_without_source_samples"],
|
||||
},
|
||||
"source_gaps": source_gap_summary,
|
||||
"pools": {
|
||||
"envi": {
|
||||
"path": settings.ORBIT_POOL_ENVI,
|
||||
"total": pool_inventory["envi"]["total"],
|
||||
"by_satellite": pool_inventory["envi"]["by_satellite"],
|
||||
"duplicate_count": pool_inventory["envi"]["duplicate_count"],
|
||||
"errors": pool_inventory["envi"]["errors"],
|
||||
},
|
||||
"isce2": {
|
||||
"path": settings.ORBIT_POOL_ISCE2,
|
||||
"total": pool_inventory["isce2"]["total"],
|
||||
"duplicate_count": pool_inventory["isce2"]["duplicate_count"],
|
||||
"errors": pool_inventory["isce2"]["errors"],
|
||||
},
|
||||
"landsar": {
|
||||
"path": settings.ORBIT_POOL_LANDSAR,
|
||||
},
|
||||
},
|
||||
"consistency": consistency,
|
||||
"database": database_stats,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sync-pools")
|
||||
async def sync_orbit_pool_action(
|
||||
payload: Optional[OrbitPoolActionRequest] = None,
|
||||
current_user: AuthUserORM = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Check pool consistency, or repair missing entries when repair=true.
|
||||
"""
|
||||
if payload and payload.quarantine_bad:
|
||||
return await asyncio.to_thread(
|
||||
quarantine_bad_orbits,
|
||||
settings.MONITOR_ORBIT_DIR,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
settings.ORBIT_QUARANTINE_DIR,
|
||||
)
|
||||
if payload and payload.repair:
|
||||
return await asyncio.to_thread(
|
||||
repair_orbit_pools,
|
||||
settings.MONITOR_ORBIT_DIR,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
settings.ORBIT_POOL_LANDSAR,
|
||||
)
|
||||
return await asyncio.to_thread(
|
||||
check_orbit_consistency,
|
||||
settings.ORBIT_POOL_ENVI,
|
||||
settings.ORBIT_POOL_ISCE2,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/organize")
|
||||
async def organize_orbits(
|
||||
current_user: AuthUserORM = Depends(require_admin),
|
||||
):
|
||||
"""Move loose ENVI orbit TXT files into satellite subdirectories."""
|
||||
envi_pool = settings.ORBIT_POOL_ENVI
|
||||
result = await asyncio.to_thread(organize_orbit_dir, envi_pool)
|
||||
return {
|
||||
"orbit_root": envi_pool,
|
||||
"moved_count": len(result["moved"]),
|
||||
"skipped_count": len(result["skipped"]),
|
||||
"error_count": len(result["errors"]),
|
||||
"moved": result["moved"],
|
||||
"skipped": result["skipped"],
|
||||
"errors": result["errors"],
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
AuthUserORM,
|
||||
PairingNetworkEdgeORM,
|
||||
PairingNetworkRunORM,
|
||||
PairingRequest,
|
||||
PairingResponse,
|
||||
PsRequest,
|
||||
RadarData,
|
||||
)
|
||||
from ..services.pairing_cache_service import pairing_cache_service
|
||||
from ..services.spatial_service import spatial_service
|
||||
from .dependencies import (
|
||||
_parse_aoi_from_files,
|
||||
_parse_aoi_geojson_form_value,
|
||||
_require_admin,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_pairing_request_from_form(
|
||||
time_baseline_min: int = Form(1),
|
||||
time_baseline_max: int = Form(90),
|
||||
overlap_threshold: float = Form(0.5),
|
||||
spatial_baseline_max_meters: int = Form(3000),
|
||||
coverage_diversity_penalty: float = Form(0.3),
|
||||
require_same_imaging_mode: bool = Form(True),
|
||||
require_same_polarization: bool = Form(True),
|
||||
aoi_overlap_threshold: Optional[float] = Form(None),
|
||||
start_date: Optional[str] = Form(None),
|
||||
# === 新增参数 ===
|
||||
master_date_from: Optional[str] = Form(None),
|
||||
master_date_to: Optional[str] = Form(None),
|
||||
slave_date_from: Optional[str] = Form(None),
|
||||
slave_date_to: Optional[str] = Form(None),
|
||||
strategy: str = Form("all"),
|
||||
num_connections: int = Form(1),
|
||||
reference_image_id: Optional[int] = Form(None),
|
||||
allowed_satellites: Optional[str] = Form(None), # JSON string
|
||||
cross_satellite_pairing: bool = Form(False),
|
||||
) -> PairingRequest:
|
||||
# Parse allowed_satellites from JSON string
|
||||
satellites_list = None
|
||||
if allowed_satellites:
|
||||
try:
|
||||
import json
|
||||
satellites_list = json.loads(allowed_satellites)
|
||||
except Exception:
|
||||
satellites_list = None
|
||||
|
||||
return PairingRequest(
|
||||
time_baseline_min=time_baseline_min,
|
||||
time_baseline_max=time_baseline_max,
|
||||
overlap_threshold=overlap_threshold,
|
||||
spatial_baseline_max_meters=spatial_baseline_max_meters,
|
||||
coverage_diversity_penalty=coverage_diversity_penalty,
|
||||
require_same_imaging_mode=require_same_imaging_mode,
|
||||
require_same_polarization=require_same_polarization,
|
||||
aoi_overlap_threshold=aoi_overlap_threshold,
|
||||
start_date=start_date,
|
||||
master_date_from=master_date_from,
|
||||
master_date_to=master_date_to,
|
||||
slave_date_from=slave_date_from,
|
||||
slave_date_to=slave_date_to,
|
||||
strategy=strategy,
|
||||
num_connections=num_connections,
|
||||
reference_image_id=reference_image_id,
|
||||
allowed_satellites=satellites_list,
|
||||
cross_satellite_pairing=cross_satellite_pairing,
|
||||
)
|
||||
|
||||
|
||||
def get_ps_request_from_form(
|
||||
initial_overlap_threshold: float = Form(0.3),
|
||||
final_overlap_threshold: float = Form(0.95)
|
||||
) -> PsRequest:
|
||||
return PsRequest(
|
||||
initial_overlap_threshold=initial_overlap_threshold,
|
||||
final_overlap_threshold=final_overlap_threshold,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pairing/health")
|
||||
async def get_pairing_health_endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
_ = current_user
|
||||
return await pairing_cache_service.get_admin_summary(db)
|
||||
|
||||
|
||||
@router.post("/pairing/rebuild-cache")
|
||||
async def rebuild_pairing_cache_endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
_ = current_user
|
||||
return await pairing_cache_service.rebuild_metric_cache(db, commit=True)
|
||||
|
||||
|
||||
@router.post("/pairing/reconcile-dirty")
|
||||
async def reconcile_dirty_pairing_endpoint(
|
||||
force_full: bool = Query(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
_ = current_user
|
||||
return await pairing_cache_service.reconcile_dirty_scenes(
|
||||
db,
|
||||
force_full=force_full,
|
||||
commit=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pairing/networks/{network_run_id}")
|
||||
async def get_pairing_network_run_endpoint(
|
||||
network_run_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
_ = current_user
|
||||
run_result = await db.execute(
|
||||
select(PairingNetworkRunORM).where(PairingNetworkRunORM.network_run_id == network_run_id)
|
||||
)
|
||||
run = run_result.scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Pairing network run not found.")
|
||||
|
||||
edge_result = await db.execute(
|
||||
select(PairingNetworkEdgeORM)
|
||||
.where(PairingNetworkEdgeORM.network_run_ref_id == run.id)
|
||||
.order_by(PairingNetworkEdgeORM.edge_rank.asc(), PairingNetworkEdgeORM.id.asc())
|
||||
)
|
||||
edges = edge_result.scalars().all()
|
||||
return {
|
||||
"network_run_id": run.network_run_id,
|
||||
"strategy": run.strategy,
|
||||
"policy_version": run.policy_version,
|
||||
"request_hash": run.request_hash,
|
||||
"request_params_json": run.request_params_json,
|
||||
"aoi_source": run.aoi_source,
|
||||
"aoi_hash": run.aoi_hash,
|
||||
"aoi_summary_json": run.aoi_summary_json,
|
||||
"candidate_count": int(run.candidate_count or 0),
|
||||
"selected_edge_count": int(run.selected_edge_count or 0),
|
||||
"warning_count": int(run.warning_count or 0),
|
||||
"status": run.status,
|
||||
"fallback_used": bool(run.fallback_used),
|
||||
"created_by": run.created_by,
|
||||
"created_at": run.created_at,
|
||||
"updated_at": run.updated_at,
|
||||
"edges": [
|
||||
{
|
||||
"id": edge.id,
|
||||
"metric_cache_ref_id": edge.metric_cache_ref_id,
|
||||
"edge_rank": int(edge.edge_rank or 0),
|
||||
"selection_reason": edge.selection_reason,
|
||||
"selection_score": edge.selection_score,
|
||||
"selection_meta_json": edge.selection_meta_json,
|
||||
"is_reference_edge": bool(edge.is_reference_edge),
|
||||
"created_at": edge.created_at,
|
||||
}
|
||||
for edge in edges
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/find-pairs", response_model=PairingResponse)
|
||||
async def find_pairs_endpoint(
|
||||
params: PairingRequest = Depends(get_pairing_request_from_form),
|
||||
files: Optional[List[UploadFile]] = File(None),
|
||||
aoi_geojson: Optional[str] = Form(None),
|
||||
require_orbit_data: bool = Form(True),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
根据参数查找干涉对,数据源为数据库。
|
||||
"""
|
||||
try:
|
||||
resolved_aoi = await _parse_aoi_from_files(files)
|
||||
if resolved_aoi is None:
|
||||
resolved_aoi = _parse_aoi_geojson_form_value(aoi_geojson)
|
||||
|
||||
if files and resolved_aoi is None:
|
||||
raise HTTPException(status_code=400, detail="上传文件中必须包含 .shp 或 GeoJSON。")
|
||||
|
||||
aoi_wkt = resolved_aoi[0] if resolved_aoi else None
|
||||
response_aoi_geojson = resolved_aoi[1] if resolved_aoi else None
|
||||
pairs, runtime_warnings, pairing_metadata = await spatial_service.find_dinsar_pairs(
|
||||
db,
|
||||
params,
|
||||
aoi_wkt=aoi_wkt,
|
||||
require_orbit_data=require_orbit_data,
|
||||
)
|
||||
return PairingResponse(
|
||||
pairs=pairs,
|
||||
aoi_geojson=response_aoi_geojson,
|
||||
warnings=runtime_warnings,
|
||||
fallback_used=bool(pairing_metadata.get("fallback_used")),
|
||||
degraded=bool(pairing_metadata.get("degraded")),
|
||||
policy_version=pairing_metadata.get("policy_version"),
|
||||
network_run_id=pairing_metadata.get("network_run_id"),
|
||||
candidate_count=int(pairing_metadata.get("candidate_count") or 0),
|
||||
selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0),
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
logger.exception("处理 AOI 或查找干涉对时发生错误")
|
||||
raise HTTPException(status_code=500, detail="处理 AOI 或查找干涉对时发生错误,请查看后端日志")
|
||||
|
||||
|
||||
@router.post("/find-ps-timeseries", response_model=Dict[str, List[RadarData]])
|
||||
async def find_ps_timeseries_endpoint(
|
||||
params: PsRequest = Depends(get_ps_request_from_form),
|
||||
files: Optional[List[UploadFile]] = File(None),
|
||||
aoi_geojson: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
为 PS-InSAR 查找时序影像栈。
|
||||
"""
|
||||
if not files and not aoi_geojson:
|
||||
raise HTTPException(status_code=400, detail="必须提供 AOI(SHP 或 GeoJSON)。")
|
||||
|
||||
try:
|
||||
resolved_aoi = await _parse_aoi_from_files(files)
|
||||
if resolved_aoi is None:
|
||||
resolved_aoi = _parse_aoi_geojson_form_value(aoi_geojson)
|
||||
|
||||
if resolved_aoi is None:
|
||||
raise HTTPException(status_code=400, detail="未解析到有效 AOI,请检查 SHP 或 GeoJSON。")
|
||||
|
||||
aoi_wkt = resolved_aoi[0]
|
||||
ps_stacks = await spatial_service.find_ps_timeseries_data(db, params, aoi_wkt=aoi_wkt)
|
||||
return ps_stacks
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
raise HTTPException(status_code=500, detail=f"处理 AOI 或查找时序影像时发生严重错误: {e}")
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import 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 ..database import get_db
|
||||
from ..models import AuthUserORM
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.psinsar_catalog_service import (
|
||||
JOB_TYPE_REBUILD_PSINSAR_CATALOG,
|
||||
TASK_TYPE_REBUILD_PSINSAR_CATALOG,
|
||||
psinsar_catalog_service,
|
||||
)
|
||||
from ..services.task_service import task_service
|
||||
from .dependencies import (
|
||||
_add_operation_audit_log,
|
||||
_get_current_user,
|
||||
_require_admin,
|
||||
_validate_export_path,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PsinsarCatalogRebuildRequest(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()
|
||||
return path or None
|
||||
|
||||
|
||||
@router.get("/ps-products/catalog-status")
|
||||
async def get_psinsar_catalog_status(
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
return await psinsar_catalog_service.get_catalog_status(db)
|
||||
|
||||
|
||||
@router.post("/ps-products/rebuild", status_code=202)
|
||||
async def queue_psinsar_catalog_rebuild(
|
||||
request: PsinsarCatalogRebuildRequest,
|
||||
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_PSINSAR_CATALOG,
|
||||
"PS-InSAR 结果目录重建",
|
||||
params={
|
||||
"publish_root": publish_root,
|
||||
"full_rebuild": request.full_rebuild,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
await job_queue_service.create_job(
|
||||
JOB_TYPE_REBUILD_PSINSAR_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="psinsar_catalog_rebuild_queued",
|
||||
resource="ps-products/rebuild",
|
||||
detail={
|
||||
"task_id": task_id,
|
||||
"full_rebuild": request.full_rebuild,
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"message": "PS-InSAR 结果目录重建任务已入队",
|
||||
"task_id": task_id,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ps-products")
|
||||
async def list_psinsar_products(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
status: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
return await psinsar_catalog_service.list_products(
|
||||
db,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
status=status,
|
||||
query=query,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ps-products/{product_db_id}")
|
||||
async def get_psinsar_product_detail(
|
||||
product_db_id: int,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
detail = await psinsar_catalog_service.get_product_detail(db, product_db_id=product_db_id)
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=404, detail="PS-InSAR product not found")
|
||||
return detail
|
||||
|
||||
|
||||
@router.get("/ps-products/{product_db_id}/preview")
|
||||
async def get_psinsar_product_preview(
|
||||
product_db_id: int,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
detail = await psinsar_catalog_service.get_product_detail(db, product_db_id=product_db_id)
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=404, detail="PS-InSAR 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")
|
||||
return FileResponse(preview_path, media_type="image/png")
|
||||
@@ -0,0 +1,709 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from geoalchemy2.functions import ST_Intersects
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from ..config import read_int_env, settings
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
AuthUserORM,
|
||||
RadarData,
|
||||
RadarDataORM,
|
||||
RadarDataPage,
|
||||
RadarPreviewStatusInfo,
|
||||
ScanRequest,
|
||||
)
|
||||
from ..services.data_service import data_service
|
||||
from ..services.image_service import image_service
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.task_service import task_service
|
||||
from .dependencies import (
|
||||
_add_operation_audit_log,
|
||||
_get_aoi_from_token,
|
||||
_parse_aoi_from_files,
|
||||
_parse_aoi_geojson_form_value,
|
||||
_require_admin,
|
||||
_resolve_region_aoi_payload,
|
||||
_store_aoi_token,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
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,
|
||||
)
|
||||
RADAR_SEARCH_OPTIONS_MAX_VALUES = read_int_env(
|
||||
"RADAR_SEARCH_OPTIONS_MAX_VALUES",
|
||||
5000,
|
||||
minimum=100,
|
||||
maximum=500000,
|
||||
)
|
||||
RADAR_IMAGING_DATES_MAX_VALUES = read_int_env(
|
||||
"RADAR_IMAGING_DATES_MAX_VALUES",
|
||||
5000,
|
||||
minimum=100,
|
||||
maximum=500000,
|
||||
)
|
||||
|
||||
|
||||
class RadarDataSearchPageResponse(BaseModel):
|
||||
items: List[RadarData]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
has_more: bool
|
||||
aoi_token: Optional[str] = None
|
||||
normalized_aoi_geojson: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class RadarDataSearchOptionsResponse(BaseModel):
|
||||
satellite: List[str]
|
||||
satellite_mode: List[str]
|
||||
receiving_station: List[str]
|
||||
imaging_mode: List[str]
|
||||
orbit_circle: List[str]
|
||||
acquisition_time_utc: List[str]
|
||||
product_type: List[str]
|
||||
polarization: List[str]
|
||||
product_level: List[str]
|
||||
product_unique_id: List[str]
|
||||
orbit_direction: List[str]
|
||||
imaging_dates: List[str]
|
||||
|
||||
|
||||
async def _query_distinct_non_empty_values(
|
||||
db: AsyncSession,
|
||||
column,
|
||||
max_values: int = RADAR_SEARCH_OPTIONS_MAX_VALUES,
|
||||
satellite_filter: Optional[List[str]] = None,
|
||||
) -> List[str]:
|
||||
safe_max_values = min(RADAR_SEARCH_OPTIONS_MAX_VALUES, max(1, int(max_values or RADAR_SEARCH_OPTIONS_MAX_VALUES)))
|
||||
stmt = (
|
||||
select(column)
|
||||
.where(column.is_not(None), column != "")
|
||||
)
|
||||
if satellite_filter:
|
||||
stmt = stmt.where(RadarDataORM.satellite.in_(satellite_filter))
|
||||
stmt = stmt.distinct().order_by(column.asc()).limit(safe_max_values)
|
||||
result = await db.execute(stmt)
|
||||
return [value for value in result.scalars().all() if isinstance(value, str) and value.strip()]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 _normalize_optional_text(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _validate_imaging_date(value: Optional[str], field_name: str) -> Optional[str]:
|
||||
normalized = _normalize_optional_text(value)
|
||||
if not normalized:
|
||||
return None
|
||||
try:
|
||||
datetime.strptime(normalized, "%Y-%m-%d")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{field_name} 格式错误,应为 YYYY-MM-DD。",
|
||||
) from exc
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_file_mtime(path: str) -> float:
|
||||
try:
|
||||
return max(os.path.getmtime(path), os.path.getctime(path))
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _radar_preview_paths(record: RadarDataORM) -> Tuple[str, str]:
|
||||
unique_id = record.unique_id or record.file_path
|
||||
raw_cache_path = data_service.get_radar_raw_cache_path(unique_id, record.file_path)
|
||||
geo_cache_path = data_service.get_radar_geo_cache_path(unique_id, record.file_path)
|
||||
return raw_cache_path, geo_cache_path
|
||||
|
||||
|
||||
def _build_radar_preview_status(
|
||||
record: RadarDataORM,
|
||||
source_found: bool,
|
||||
has_geo_cache: bool,
|
||||
has_raw_cache: bool,
|
||||
message: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> RadarPreviewStatusInfo:
|
||||
if has_geo_cache:
|
||||
status = "READY"
|
||||
default_message = "地理纠正预览缓存可用。"
|
||||
elif has_raw_cache:
|
||||
status = "FAILED"
|
||||
default_message = "地理纠正缓存不可用,已回退到原图缓存。"
|
||||
elif source_found:
|
||||
status = "FAILED"
|
||||
default_message = "已找到源图,但地理纠正缓存尚未可用。"
|
||||
else:
|
||||
status = "NONE"
|
||||
default_message = "未找到可用源图,无法生成预览缓存。"
|
||||
|
||||
return RadarPreviewStatusInfo(
|
||||
radar_id=record.id,
|
||||
status=status,
|
||||
cache_version=record.preview_cache_version or settings.RADAR_GEO_CACHE_VERSION,
|
||||
cache_updated_at=record.preview_cache_updated_at,
|
||||
has_geo_cache=has_geo_cache,
|
||||
has_raw_cache=has_raw_cache,
|
||||
source_found=source_found,
|
||||
fallback_in_use=(not has_geo_cache and has_raw_cache),
|
||||
message=message or default_message,
|
||||
error=error if error is not None else record.preview_cache_error,
|
||||
)
|
||||
|
||||
|
||||
async def _build_radar_preview_cache(
|
||||
record: RadarDataORM,
|
||||
db: AsyncSession,
|
||||
force: bool = False,
|
||||
) -> RadarPreviewStatusInfo:
|
||||
raw_cache_path, geo_cache_path = _radar_preview_paths(record)
|
||||
has_geo_cache = os.path.exists(geo_cache_path)
|
||||
has_raw_cache = os.path.exists(raw_cache_path)
|
||||
|
||||
preview_source = await asyncio.to_thread(data_service.find_radar_preview_source, record.file_path)
|
||||
source_found = bool(preview_source)
|
||||
geo_error: Optional[str] = None
|
||||
raw_error: Optional[str] = None
|
||||
performed_build = False
|
||||
previous_state = (
|
||||
record.preview_cache_status,
|
||||
record.preview_cache_version,
|
||||
record.preview_cache_path,
|
||||
record.preview_cache_error,
|
||||
)
|
||||
|
||||
if source_found:
|
||||
source_mtime = _safe_file_mtime(preview_source or "")
|
||||
geo_mtime = _safe_file_mtime(geo_cache_path)
|
||||
raw_mtime = _safe_file_mtime(raw_cache_path)
|
||||
|
||||
skip_rebuild_after_failed = (
|
||||
(not force)
|
||||
and (record.preview_cache_status or "NONE") == "FAILED"
|
||||
and (record.preview_cache_version or "") == settings.RADAR_GEO_CACHE_VERSION
|
||||
and has_raw_cache
|
||||
)
|
||||
|
||||
need_geo_rebuild = (
|
||||
force
|
||||
or (not has_geo_cache)
|
||||
or geo_mtime < source_mtime
|
||||
or (record.preview_cache_version or "") != settings.RADAR_GEO_CACHE_VERSION
|
||||
)
|
||||
if need_geo_rebuild and not skip_rebuild_after_failed:
|
||||
coverage_polygon = data_service._normalize_coverage_polygon(record.coverage_polygon)
|
||||
if not coverage_polygon:
|
||||
geo_error = "invalid_coverage_polygon"
|
||||
has_geo_cache = False
|
||||
else:
|
||||
try:
|
||||
bbox = (
|
||||
float(record.min_lon),
|
||||
float(record.min_lat),
|
||||
float(record.max_lon),
|
||||
float(record.max_lat),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
bbox = None
|
||||
|
||||
if not bbox:
|
||||
geo_error = "invalid_bbox"
|
||||
has_geo_cache = False
|
||||
else:
|
||||
source_corner_mapping = await asyncio.to_thread(
|
||||
data_service.get_radar_source_corner_mapping,
|
||||
record.file_path,
|
||||
)
|
||||
ok_geo, geo_error = await asyncio.to_thread(
|
||||
image_service.create_geocorrected_radar_cached_image,
|
||||
preview_source,
|
||||
geo_cache_path,
|
||||
coverage_polygon,
|
||||
bbox,
|
||||
source_corner_mapping,
|
||||
(settings.RADAR_THUMBNAIL_MAX_SIZE, settings.RADAR_THUMBNAIL_MAX_SIZE),
|
||||
settings.RADAR_GEO_CACHE_QUALITY,
|
||||
)
|
||||
performed_build = True
|
||||
has_geo_cache = bool(ok_geo and os.path.exists(geo_cache_path))
|
||||
|
||||
need_raw_rebuild = force or (not has_raw_cache) or raw_mtime < source_mtime
|
||||
if need_raw_rebuild:
|
||||
ok_raw = await asyncio.to_thread(
|
||||
image_service.create_radar_cached_image,
|
||||
preview_source,
|
||||
raw_cache_path,
|
||||
(settings.RADAR_THUMBNAIL_MAX_SIZE, settings.RADAR_THUMBNAIL_MAX_SIZE),
|
||||
)
|
||||
performed_build = True
|
||||
has_raw_cache = bool(ok_raw and os.path.exists(raw_cache_path))
|
||||
if not ok_raw:
|
||||
raw_error = "raw_cache_build_failed"
|
||||
|
||||
if has_geo_cache:
|
||||
record.preview_cache_status = "READY"
|
||||
record.preview_cache_path = geo_cache_path
|
||||
record.preview_cache_error = None
|
||||
elif source_found:
|
||||
record.preview_cache_status = "FAILED"
|
||||
record.preview_cache_path = None
|
||||
record.preview_cache_error = geo_error or raw_error or "geo_cache_not_ready"
|
||||
else:
|
||||
record.preview_cache_status = "NONE"
|
||||
record.preview_cache_path = None
|
||||
record.preview_cache_error = "preview_source_not_found"
|
||||
|
||||
record.preview_cache_version = settings.RADAR_GEO_CACHE_VERSION
|
||||
new_state = (
|
||||
record.preview_cache_status,
|
||||
record.preview_cache_version,
|
||||
record.preview_cache_path,
|
||||
record.preview_cache_error,
|
||||
)
|
||||
state_changed = new_state != previous_state
|
||||
if performed_build or state_changed:
|
||||
record.preview_cache_updated_at = datetime.utcnow()
|
||||
|
||||
status_obj = _build_radar_preview_status(
|
||||
record=record,
|
||||
source_found=source_found,
|
||||
has_geo_cache=has_geo_cache,
|
||||
has_raw_cache=has_raw_cache,
|
||||
error=record.preview_cache_error,
|
||||
)
|
||||
if performed_build or state_changed:
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
return status_obj
|
||||
|
||||
|
||||
async def _get_cached_radar_preview(data_id: int, db: AsyncSession):
|
||||
result = await db.execute(select(RadarDataORM).where(RadarDataORM.id == data_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail=f"ID为 {data_id} 的源数据不存在。")
|
||||
|
||||
raw_cache_path, geo_cache_path = _radar_preview_paths(record)
|
||||
if os.path.exists(geo_cache_path):
|
||||
return FileResponse(
|
||||
geo_cache_path,
|
||||
media_type="image/webp",
|
||||
headers={"Cache-Control": "public, max-age=31536000"},
|
||||
)
|
||||
|
||||
if settings.RADAR_PREVIEW_BUILD_ON_DEMAND:
|
||||
build_status = await _build_radar_preview_cache(record, db, force=False)
|
||||
if build_status.has_geo_cache and os.path.exists(geo_cache_path):
|
||||
return FileResponse(
|
||||
geo_cache_path,
|
||||
media_type="image/webp",
|
||||
headers={"Cache-Control": "public, max-age=31536000"},
|
||||
)
|
||||
|
||||
if os.path.exists(raw_cache_path):
|
||||
return FileResponse(
|
||||
raw_cache_path,
|
||||
media_type="image/webp",
|
||||
headers={"Cache-Control": "public, max-age=31536000"},
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail="未找到可用源影像缓存,请先执行数据扫描或重建缓存。")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/scan-data", status_code=202)
|
||||
async def scan_data_endpoint(
|
||||
scan_request: ScanRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""
|
||||
触发数据扫描的API端点,支持雷达、精轨和 D-InSAR 结果。
|
||||
"""
|
||||
try:
|
||||
params = {
|
||||
"radar_dirs": scan_request.radar_data_directories,
|
||||
"orbit_dir": scan_request.orbit_data_directory,
|
||||
"dinsar_dirs": scan_request.dinsar_results_directories
|
||||
}
|
||||
task_id = await task_service.create_task("SCAN_DATA", "系统数据同步扫描", params=params)
|
||||
|
||||
payload = {
|
||||
"use_monitor_config": False,
|
||||
"radar_dirs": scan_request.radar_data_directories,
|
||||
"orbit_dir": scan_request.orbit_data_directory,
|
||||
"dinsar_dirs": scan_request.dinsar_results_directories,
|
||||
}
|
||||
await job_queue_service.create_job("SCAN_DATA", payload=payload, task_id=task_id)
|
||||
return {"message": "数据扫描任务已进入队列", "task_id": task_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("数据扫描任务创建失败")
|
||||
raise HTTPException(status_code=500, detail="数据扫描任务创建失败,请查看后端日志")
|
||||
|
||||
|
||||
@router.get("/radar-data/search/options", response_model=RadarDataSearchOptionsResponse)
|
||||
async def get_radar_data_search_options_endpoint(
|
||||
satellite: Optional[List[str]] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _apply_list_query_statement_timeout(db)
|
||||
sat_filter = [s for s in (satellite or []) if s.strip()] or None
|
||||
return RadarDataSearchOptionsResponse(
|
||||
satellite=await _query_distinct_non_empty_values(db, RadarDataORM.satellite),
|
||||
satellite_mode=await _query_distinct_non_empty_values(db, RadarDataORM.satellite_mode, satellite_filter=sat_filter),
|
||||
receiving_station=await _query_distinct_non_empty_values(db, RadarDataORM.receiving_station, satellite_filter=sat_filter),
|
||||
imaging_mode=await _query_distinct_non_empty_values(db, RadarDataORM.imaging_mode, satellite_filter=sat_filter),
|
||||
orbit_circle=await _query_distinct_non_empty_values(db, RadarDataORM.orbit_circle, satellite_filter=sat_filter),
|
||||
acquisition_time_utc=await _query_distinct_non_empty_values(db, RadarDataORM.acquisition_time_utc, satellite_filter=sat_filter),
|
||||
product_type=await _query_distinct_non_empty_values(db, RadarDataORM.product_type, satellite_filter=sat_filter),
|
||||
polarization=await _query_distinct_non_empty_values(db, RadarDataORM.polarization, satellite_filter=sat_filter),
|
||||
product_level=await _query_distinct_non_empty_values(db, RadarDataORM.product_level, satellite_filter=sat_filter),
|
||||
product_unique_id=await _query_distinct_non_empty_values(db, RadarDataORM.product_unique_id, satellite_filter=sat_filter),
|
||||
orbit_direction=await _query_distinct_non_empty_values(db, RadarDataORM.orbit_direction, satellite_filter=sat_filter),
|
||||
imaging_dates=sorted(
|
||||
await _query_distinct_non_empty_values(db, RadarDataORM.imaging_date, satellite_filter=sat_filter),
|
||||
reverse=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/radar-data/search", response_model=RadarDataSearchPageResponse)
|
||||
async def search_radar_data_endpoint(
|
||||
limit: int = Form(500),
|
||||
offset: int = Form(0),
|
||||
satellite: Optional[str] = Form(None),
|
||||
satellite_mode: Optional[str] = Form(None),
|
||||
receiving_station: Optional[str] = Form(None),
|
||||
imaging_mode: Optional[str] = Form(None),
|
||||
orbit_circle: Optional[str] = Form(None),
|
||||
acquisition_time_utc: Optional[str] = Form(None),
|
||||
product_type: Optional[str] = Form(None),
|
||||
polarization: Optional[str] = Form(None),
|
||||
product_level: Optional[str] = Form(None),
|
||||
product_unique_id: Optional[str] = Form(None),
|
||||
orbit_direction: Optional[str] = Form(None),
|
||||
has_orbit_data: Optional[bool] = Form(None),
|
||||
is_envi_processed: Optional[bool] = Form(None),
|
||||
imaging_date_from: Optional[str] = Form(None),
|
||||
imaging_date_to: Optional[str] = Form(None),
|
||||
region_tree_id: Optional[str] = Form(None),
|
||||
aoi_token: Optional[str] = Form(None),
|
||||
aoi_geojson: Optional[str] = Form(None),
|
||||
files: Optional[List[UploadFile]] = File(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
limit, offset = _normalize_list_pagination(limit, offset)
|
||||
await _apply_list_query_statement_timeout(db)
|
||||
|
||||
n_satellite_raw = _normalize_optional_text(satellite)
|
||||
n_satellite_list: Optional[List[str]] = None
|
||||
if n_satellite_raw and "," in n_satellite_raw:
|
||||
n_satellite_list = [s.strip() for s in n_satellite_raw.split(",") if s.strip()]
|
||||
n_satellite_mode = _normalize_optional_text(satellite_mode)
|
||||
n_receiving_station = _normalize_optional_text(receiving_station)
|
||||
n_imaging_mode = _normalize_optional_text(imaging_mode)
|
||||
n_orbit_circle = _normalize_optional_text(orbit_circle)
|
||||
n_acquisition_time = _normalize_optional_text(acquisition_time_utc)
|
||||
n_product_type = _normalize_optional_text(product_type)
|
||||
n_polarization = _normalize_optional_text(polarization)
|
||||
n_product_level = _normalize_optional_text(product_level)
|
||||
n_product_unique_id = _normalize_optional_text(product_unique_id)
|
||||
n_orbit_direction = _normalize_optional_text(orbit_direction)
|
||||
n_date_from = _validate_imaging_date(imaging_date_from, "imaging_date_from")
|
||||
n_date_to = _validate_imaging_date(imaging_date_to, "imaging_date_to")
|
||||
if n_date_from and n_date_to and n_date_from > n_date_to:
|
||||
raise HTTPException(status_code=400, detail="imaging_date_from 不能晚于 imaging_date_to。")
|
||||
|
||||
resolved_aoi_wkt: Optional[str] = None
|
||||
resolved_aoi_geojson: Optional[Dict[str, Any]] = None
|
||||
resolved_aoi_token: Optional[str] = None
|
||||
n_region_tree_id = _normalize_optional_text(region_tree_id)
|
||||
|
||||
if files:
|
||||
parsed_aoi = await _parse_aoi_from_files(files)
|
||||
if parsed_aoi is None:
|
||||
raise HTTPException(status_code=400, detail="上传文件中必须包含 .shp 或 GeoJSON。")
|
||||
resolved_aoi_wkt, resolved_aoi_geojson = parsed_aoi
|
||||
elif aoi_geojson:
|
||||
parsed_aoi = _parse_aoi_geojson_form_value(aoi_geojson)
|
||||
if parsed_aoi:
|
||||
resolved_aoi_wkt, resolved_aoi_geojson = parsed_aoi
|
||||
elif n_region_tree_id:
|
||||
region_payload = _resolve_region_aoi_payload(n_region_tree_id)
|
||||
parsed_aoi = _parse_aoi_geojson_form_value(json.dumps(region_payload.get("aoi_geojson")))
|
||||
if parsed_aoi:
|
||||
resolved_aoi_wkt, resolved_aoi_geojson = parsed_aoi
|
||||
else:
|
||||
parsed_aoi = await _get_aoi_from_token(aoi_token)
|
||||
if parsed_aoi:
|
||||
resolved_aoi_wkt, resolved_aoi_geojson = parsed_aoi
|
||||
resolved_aoi_token = _normalize_optional_text(aoi_token)
|
||||
|
||||
if resolved_aoi_wkt and resolved_aoi_geojson and not resolved_aoi_token:
|
||||
resolved_aoi_token = await _store_aoi_token(resolved_aoi_wkt, resolved_aoi_geojson)
|
||||
|
||||
filters = []
|
||||
if n_satellite_list:
|
||||
filters.append(RadarDataORM.satellite.in_(n_satellite_list))
|
||||
elif n_satellite_raw:
|
||||
filters.append(RadarDataORM.satellite.ilike(f"%{n_satellite_raw}%"))
|
||||
if n_satellite_mode:
|
||||
filters.append(RadarDataORM.satellite_mode.ilike(f"%{n_satellite_mode}%"))
|
||||
if n_receiving_station:
|
||||
filters.append(RadarDataORM.receiving_station.ilike(f"%{n_receiving_station}%"))
|
||||
if n_imaging_mode:
|
||||
filters.append(RadarDataORM.imaging_mode.ilike(f"%{n_imaging_mode}%"))
|
||||
if n_orbit_circle:
|
||||
filters.append(RadarDataORM.orbit_circle.ilike(f"%{n_orbit_circle}%"))
|
||||
if n_acquisition_time:
|
||||
filters.append(RadarDataORM.acquisition_time_utc.ilike(f"%{n_acquisition_time}%"))
|
||||
if n_product_type:
|
||||
filters.append(RadarDataORM.product_type.ilike(f"%{n_product_type}%"))
|
||||
if n_polarization:
|
||||
filters.append(RadarDataORM.polarization.ilike(f"%{n_polarization}%"))
|
||||
if n_product_level:
|
||||
filters.append(RadarDataORM.product_level.ilike(f"%{n_product_level}%"))
|
||||
if n_product_unique_id:
|
||||
filters.append(RadarDataORM.product_unique_id.ilike(f"%{n_product_unique_id}%"))
|
||||
if n_orbit_direction:
|
||||
filters.append(RadarDataORM.orbit_direction.ilike(f"%{n_orbit_direction}%"))
|
||||
if has_orbit_data is not None:
|
||||
filters.append(RadarDataORM.has_orbit_data == has_orbit_data)
|
||||
if is_envi_processed is not None:
|
||||
filters.append(RadarDataORM.is_envi_processed == is_envi_processed)
|
||||
if n_date_from:
|
||||
filters.append(RadarDataORM.imaging_date >= n_date_from)
|
||||
if n_date_to:
|
||||
filters.append(RadarDataORM.imaging_date <= n_date_to)
|
||||
if resolved_aoi_wkt:
|
||||
aoi_geom = func.ST_GeomFromText(resolved_aoi_wkt, 4326)
|
||||
filters.append(ST_Intersects(RadarDataORM.geom, aoi_geom))
|
||||
|
||||
total_stmt = select(func.count(RadarDataORM.id))
|
||||
data_stmt = (
|
||||
select(RadarDataORM)
|
||||
.order_by(RadarDataORM.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
for condition in filters:
|
||||
total_stmt = total_stmt.where(condition)
|
||||
data_stmt = data_stmt.where(condition)
|
||||
|
||||
total_res = await db.execute(total_stmt)
|
||||
total = int(total_res.scalar_one() or 0)
|
||||
result = await db.execute(data_stmt)
|
||||
items = result.scalars().all()
|
||||
|
||||
return RadarDataSearchPageResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
has_more=(offset + len(items)) < total,
|
||||
aoi_token=resolved_aoi_token,
|
||||
normalized_aoi_geojson=resolved_aoi_geojson,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/radar-data", response_model=RadarDataPage)
|
||||
async def get_all_data_endpoint(
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
从数据库获取所有雷达数据的API端点。
|
||||
"""
|
||||
from sqlalchemy.orm import defer
|
||||
limit, offset = _normalize_list_pagination(limit, offset)
|
||||
await _apply_list_query_statement_timeout(db)
|
||||
total_res = await db.execute(select(func.count(RadarDataORM.id)))
|
||||
total = int(total_res.scalar_one() or 0)
|
||||
result = await db.execute(
|
||||
select(RadarDataORM)
|
||||
.options(defer(RadarDataORM.geom)) # 延迟加载 geom 字段
|
||||
.order_by(RadarDataORM.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return RadarDataPage(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
has_more=(offset + len(items)) < total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/radar-data/imaging-dates")
|
||||
async def list_radar_imaging_dates(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
返回所有可用的成像日期(去重),用于配对筛选。
|
||||
"""
|
||||
await _apply_list_query_statement_timeout(db)
|
||||
result = await db.execute(
|
||||
select(RadarDataORM.imaging_date)
|
||||
.where(RadarDataORM.imaging_date.is_not(None))
|
||||
.distinct()
|
||||
.limit(RADAR_IMAGING_DATES_MAX_VALUES)
|
||||
)
|
||||
dates = sorted(
|
||||
[value for value in result.scalars().all() if value],
|
||||
reverse=True,
|
||||
)
|
||||
return {"dates": dates}
|
||||
|
||||
@router.get("/radar-data/available-satellites")
|
||||
async def list_available_satellites(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
返回数据库中所有可用的卫星列表(去重),用于多卫星配对。
|
||||
"""
|
||||
await _apply_list_query_statement_timeout(db)
|
||||
result = await db.execute(
|
||||
select(RadarDataORM.satellite)
|
||||
.where(RadarDataORM.satellite.is_not(None))
|
||||
.where(RadarDataORM.satellite != "")
|
||||
.distinct()
|
||||
)
|
||||
satellites = sorted([value for value in result.scalars().all() if value])
|
||||
return {"satellites": satellites}
|
||||
|
||||
|
||||
@router.get("/radar-data/{data_id}/thumb")
|
||||
async def get_radar_data_thumb_endpoint(data_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取源雷达数据的预览缓存图(WebP)。
|
||||
"""
|
||||
return await _get_cached_radar_preview(data_id, db)
|
||||
|
||||
|
||||
@router.get("/radar-data/{data_id}/preview-status", response_model=RadarPreviewStatusInfo)
|
||||
async def get_radar_preview_status_endpoint(data_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(RadarDataORM).where(RadarDataORM.id == data_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail=f"ID为 {data_id} 的源数据不存在。")
|
||||
|
||||
raw_cache_path, geo_cache_path = _radar_preview_paths(record)
|
||||
has_geo_cache = os.path.exists(geo_cache_path)
|
||||
has_raw_cache = os.path.exists(raw_cache_path)
|
||||
source_found = bool(await asyncio.to_thread(data_service.find_radar_preview_source, record.file_path))
|
||||
return _build_radar_preview_status(
|
||||
record=record,
|
||||
source_found=source_found,
|
||||
has_geo_cache=has_geo_cache,
|
||||
has_raw_cache=has_raw_cache,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/radar-data/{data_id}/rebuild-preview-cache", response_model=RadarPreviewStatusInfo)
|
||||
async def rebuild_radar_preview_cache_endpoint(
|
||||
data_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
admin_user_id = admin_user.id
|
||||
admin_username = admin_user.username
|
||||
|
||||
result = await db.execute(select(RadarDataORM).where(RadarDataORM.id == data_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail=f"ID为 {data_id} 的源数据不存在。")
|
||||
|
||||
status = await _build_radar_preview_cache(record, db, force=True)
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=request,
|
||||
action="radar_preview_rebuild",
|
||||
resource=f"/api/radar-data/{data_id}/rebuild-preview-cache",
|
||||
detail={
|
||||
"status": status.status,
|
||||
"has_geo_cache": status.has_geo_cache,
|
||||
"has_raw_cache": status.has_raw_cache,
|
||||
},
|
||||
user_id=admin_user_id,
|
||||
username=admin_username,
|
||||
)
|
||||
await db.commit()
|
||||
return status
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import AuthUserORM
|
||||
from ..services.manifest_inventory_service import manifest_inventory_service
|
||||
from ..services.root_registry_service import root_registry_service
|
||||
from .dependencies import _get_current_user, _require_admin
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/managed-roots")
|
||||
async def get_managed_roots(
|
||||
include_disabled: bool = False,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
return await root_registry_service.get_status(db, include_disabled=include_disabled)
|
||||
|
||||
|
||||
@router.get("/managed-roots/summary")
|
||||
async def get_managed_roots_summary(
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
return await root_registry_service.get_summary(db)
|
||||
|
||||
|
||||
@router.get("/managed-roots/{root_id}/inventory")
|
||||
async def get_managed_root_inventory(
|
||||
root_id: int,
|
||||
include_removed: bool = False,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
try:
|
||||
return await manifest_inventory_service.list_inventory(
|
||||
db,
|
||||
root_id=root_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
include_removed=include_removed,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/managed-roots/rescan-manifests")
|
||||
async def rescan_manifest_roots(
|
||||
root_id: int | None = None,
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin_user
|
||||
return await manifest_inventory_service.sync_manifest_roots(db, root_id=root_id)
|
||||
@@ -0,0 +1,549 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re as _re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from ..auth_service import ROLE_ADMIN
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
AuthUserORM,
|
||||
RadarDataORM,
|
||||
SARSceneGeoORM,
|
||||
)
|
||||
from ..services.data_service import data_service
|
||||
from ..services.dinsar_read_service import dinsar_read_service
|
||||
from ..services.pairing_state_service import pairing_state_service
|
||||
from ..utils import find_xml_file
|
||||
from . import dependencies as _deps
|
||||
from .dependencies import _get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/statistics")
|
||||
async def get_statistics(
|
||||
fresh: bool = False,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取关于Dinsar结果和源数据的统计信息。
|
||||
"""
|
||||
if fresh and current_user.role != ROLE_ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only admin can force refresh statistics.")
|
||||
|
||||
now_mono = time.monotonic()
|
||||
if _deps.STATS_CACHE_TTL_SECONDS > 0 and not fresh:
|
||||
async with _deps._STATS_CACHE_LOCK:
|
||||
if _deps._STATS_CACHE_DATA is not None and now_mono < _deps._STATS_CACHE_EXPIRES_AT:
|
||||
return {
|
||||
**_deps._STATS_CACHE_DATA,
|
||||
"cache_meta": {
|
||||
"enabled": True,
|
||||
"hit": True,
|
||||
"ttl_seconds": _deps.STATS_CACHE_TTL_SECONDS,
|
||||
"generated_at": _deps._STATS_CACHE_GENERATED_AT_UTC,
|
||||
},
|
||||
}
|
||||
|
||||
# 1. D-InSAR 结果统计(catalog 主读模型)
|
||||
dinsar_records = await dinsar_read_service.list_catalog_records(db)
|
||||
dinsar_total_count = len(dinsar_records)
|
||||
dinsar_cache_consistency = {
|
||||
"db_marked_cached_count": 0,
|
||||
"cache_file_exists_count": 0,
|
||||
"db_cached_and_file_exists_count": 0,
|
||||
"db_cached_but_file_missing_count": 0,
|
||||
"db_uncached_but_file_exists_count": 0,
|
||||
"db_uncached_and_file_missing_count": 0,
|
||||
"manifest_entries_count": 0,
|
||||
"manifest_missing_file_count": 0,
|
||||
}
|
||||
try:
|
||||
for record in dinsar_records:
|
||||
preview_path = str(record.product.preview_path or "").strip()
|
||||
manifest_path = str(record.product.manifest_path or "").strip()
|
||||
preview_exists = bool(preview_path and os.path.exists(preview_path))
|
||||
fallback_exists = bool(record.image_path and os.path.exists(record.image_path))
|
||||
|
||||
if preview_path:
|
||||
dinsar_cache_consistency["db_marked_cached_count"] += 1
|
||||
if preview_exists:
|
||||
dinsar_cache_consistency["cache_file_exists_count"] += 1
|
||||
if preview_path and preview_exists:
|
||||
dinsar_cache_consistency["db_cached_and_file_exists_count"] += 1
|
||||
elif preview_path and (not preview_exists):
|
||||
dinsar_cache_consistency["db_cached_but_file_missing_count"] += 1
|
||||
elif fallback_exists:
|
||||
dinsar_cache_consistency["db_uncached_but_file_exists_count"] += 1
|
||||
else:
|
||||
dinsar_cache_consistency["db_uncached_and_file_missing_count"] += 1
|
||||
|
||||
if manifest_path:
|
||||
dinsar_cache_consistency["manifest_entries_count"] += 1
|
||||
if not os.path.exists(manifest_path):
|
||||
dinsar_cache_consistency["manifest_missing_file_count"] += 1
|
||||
except Exception as e:
|
||||
dinsar_cache_consistency["error"] = str(e)
|
||||
|
||||
dinsar_cached_count = dinsar_cache_consistency["db_marked_cached_count"]
|
||||
|
||||
# 2. 源数据统计
|
||||
source_data_total_count = 0
|
||||
envi_processed_count = 0
|
||||
with_orbit_data_count = 0
|
||||
by_satellite: Dict[str, Any] = {}
|
||||
source_preview_consistency = {
|
||||
"total_records_count": 0,
|
||||
"geo_cache_exists_count": 0,
|
||||
"raw_cache_exists_count": 0,
|
||||
"preview_exists_count": 0,
|
||||
"preview_missing_count": 0,
|
||||
"db_ready_count": 0,
|
||||
"db_ready_and_cache_exists_count": 0,
|
||||
"db_ready_but_cache_missing_count": 0,
|
||||
}
|
||||
source_xml_consistency = {
|
||||
"total_records_count": 0,
|
||||
"xml_detected_count": 0,
|
||||
"xml_missing_count": 0,
|
||||
"xml_parsed_ok_count": 0,
|
||||
"xml_detected_but_unparsed_count": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
source_data_total_count_res = await db.execute(select(func.count(RadarDataORM.id)))
|
||||
source_data_total_count = source_data_total_count_res.scalar_one()
|
||||
|
||||
if source_data_total_count > 0:
|
||||
envi_processed_count_res = await db.execute(select(func.count(RadarDataORM.id)).where(RadarDataORM.is_envi_processed == True))
|
||||
envi_processed_count = envi_processed_count_res.scalar_one()
|
||||
|
||||
with_orbit_data_count_res = await db.execute(select(func.count(RadarDataORM.id)).where(RadarDataORM.has_orbit_data == True))
|
||||
with_orbit_data_count = with_orbit_data_count_res.scalar_one()
|
||||
|
||||
by_satellite_res = await db.execute(select(RadarDataORM.satellite, func.count(RadarDataORM.id)).group_by(RadarDataORM.satellite))
|
||||
by_satellite = {sat: count for sat, count in by_satellite_res.all()}
|
||||
|
||||
source_rows_res = await db.execute(
|
||||
select(
|
||||
RadarDataORM.unique_id,
|
||||
RadarDataORM.file_path,
|
||||
RadarDataORM.preview_cache_status,
|
||||
RadarDataORM.scene_center_lon,
|
||||
RadarDataORM.scene_center_lat,
|
||||
RadarDataORM.acquisition_time_utc,
|
||||
RadarDataORM.satellite_mode,
|
||||
RadarDataORM.receiving_station,
|
||||
RadarDataORM.product_level,
|
||||
RadarDataORM.product_unique_id,
|
||||
)
|
||||
)
|
||||
source_rows = source_rows_res.all()
|
||||
source_preview_consistency["total_records_count"] = len(source_rows)
|
||||
source_xml_consistency["total_records_count"] = len(source_rows)
|
||||
|
||||
for (
|
||||
unique_id,
|
||||
file_path,
|
||||
preview_cache_status,
|
||||
scene_center_lon,
|
||||
scene_center_lat,
|
||||
acquisition_time_utc,
|
||||
satellite_mode,
|
||||
receiving_station,
|
||||
product_level,
|
||||
product_unique_id,
|
||||
) in source_rows:
|
||||
if not file_path:
|
||||
source_preview_consistency["preview_missing_count"] += 1
|
||||
source_xml_consistency["xml_missing_count"] += 1
|
||||
continue
|
||||
|
||||
cache_key = unique_id or file_path
|
||||
raw_cache_path = data_service.get_radar_raw_cache_path(cache_key, file_path)
|
||||
geo_cache_path = data_service.get_radar_geo_cache_path(cache_key, file_path)
|
||||
has_raw_cache = os.path.exists(raw_cache_path)
|
||||
has_geo_cache = os.path.exists(geo_cache_path)
|
||||
|
||||
if has_geo_cache:
|
||||
source_preview_consistency["geo_cache_exists_count"] += 1
|
||||
if has_raw_cache:
|
||||
source_preview_consistency["raw_cache_exists_count"] += 1
|
||||
|
||||
has_any_preview_cache = has_geo_cache or has_raw_cache
|
||||
if has_any_preview_cache:
|
||||
source_preview_consistency["preview_exists_count"] += 1
|
||||
else:
|
||||
source_preview_consistency["preview_missing_count"] += 1
|
||||
|
||||
status = (preview_cache_status or "NONE").upper()
|
||||
if status == "READY":
|
||||
source_preview_consistency["db_ready_count"] += 1
|
||||
if has_any_preview_cache:
|
||||
source_preview_consistency["db_ready_and_cache_exists_count"] += 1
|
||||
else:
|
||||
source_preview_consistency["db_ready_but_cache_missing_count"] += 1
|
||||
|
||||
scene_dir = file_path if os.path.isdir(file_path) else os.path.dirname(file_path)
|
||||
xml_path = find_xml_file(scene_dir) if scene_dir else None
|
||||
has_xml = bool(xml_path and os.path.exists(xml_path))
|
||||
if has_xml:
|
||||
source_xml_consistency["xml_detected_count"] += 1
|
||||
parsed_ok = any(
|
||||
value is not None and value != ""
|
||||
for value in [
|
||||
scene_center_lon,
|
||||
scene_center_lat,
|
||||
acquisition_time_utc,
|
||||
satellite_mode,
|
||||
receiving_station,
|
||||
product_level,
|
||||
product_unique_id,
|
||||
]
|
||||
)
|
||||
if parsed_ok:
|
||||
source_xml_consistency["xml_parsed_ok_count"] += 1
|
||||
else:
|
||||
source_xml_consistency["xml_detected_but_unparsed_count"] += 1
|
||||
else:
|
||||
source_xml_consistency["xml_missing_count"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("统计源数据时发生错误 (可能是表不存在): %s", e)
|
||||
source_preview_consistency["error"] = str(e)
|
||||
source_xml_consistency["error"] = str(e)
|
||||
|
||||
# 4. AI 质量统计
|
||||
labeled_good_count = sum(1 for record in dinsar_records if record.product.user_label == 1)
|
||||
labeled_bad_count = sum(1 for record in dinsar_records if record.product.user_label == 0)
|
||||
|
||||
unlabeled_count = dinsar_total_count - labeled_good_count - labeled_bad_count
|
||||
|
||||
# 5. AI 预测统计
|
||||
ai_good_count = sum(
|
||||
1 for record in dinsar_records
|
||||
if record.product.ai_score is not None and record.product.ai_score >= 0.7
|
||||
)
|
||||
ai_bad_count = sum(
|
||||
1 for record in dinsar_records
|
||||
if record.product.ai_score is not None and record.product.ai_score < 0.4
|
||||
)
|
||||
ai_medium_count = sum(
|
||||
1 for record in dinsar_records
|
||||
if record.product.ai_score is not None and 0.4 <= record.product.ai_score < 0.7
|
||||
)
|
||||
ai_unpredicted_count = sum(
|
||||
1 for record in dinsar_records
|
||||
if record.product.ai_score is None
|
||||
)
|
||||
|
||||
# 6. IDL 处理统计(读取 runs/*.json)
|
||||
idl_processing_stats: Dict[str, Any] = {
|
||||
"by_workflow_success": {},
|
||||
"avg_duration_by_workflow": {},
|
||||
}
|
||||
try:
|
||||
all_runs = data_service.envi_service_list_runs_all() if hasattr(data_service, "envi_service_list_runs_all") else []
|
||||
# 直接读取 runs 目录
|
||||
from ..services.envi_service import list_recent_runs as _list_runs
|
||||
all_runs = _list_runs(limit=500)
|
||||
wf_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: {"success": 0, "failed": 0})
|
||||
wf_durations: Dict[str, list] = defaultdict(list)
|
||||
for run in all_runs:
|
||||
wf = run.get("workflow", "unknown")
|
||||
status = run.get("status", "")
|
||||
dur = run.get("duration_seconds")
|
||||
if status == "success":
|
||||
wf_counts[wf]["success"] += 1
|
||||
elif status == "failed":
|
||||
wf_counts[wf]["failed"] += 1
|
||||
if dur is not None:
|
||||
try:
|
||||
wf_durations[wf].append(float(dur))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
idl_processing_stats["by_workflow_success"] = {k: dict(v) for k, v in wf_counts.items()}
|
||||
idl_processing_stats["avg_duration_by_workflow"] = {
|
||||
k: round(sum(v) / len(v), 1) for k, v in wf_durations.items() if v
|
||||
}
|
||||
except Exception as _e:
|
||||
idl_processing_stats["error"] = str(_e)
|
||||
|
||||
# 8. 水体地理编码一致性检测
|
||||
water_geo_consistency: Dict[str, Any] = {
|
||||
"water_results_dir": settings.WATER_RESULTS_DIR,
|
||||
"dir_scanned_count": 0,
|
||||
"geo_db_exists_count": 0,
|
||||
"matched_in_db_count": 0,
|
||||
"unregistered_count": 0,
|
||||
"registered_but_missing_count": 0,
|
||||
}
|
||||
try:
|
||||
import re as _re2
|
||||
water_dir = settings.WATER_RESULTS_DIR
|
||||
_uid_re = _re2.compile(r"_(\d{7,})$")
|
||||
|
||||
# 从 DB 拉取所有 product_unique_id -> radar_data_id 映射
|
||||
uid_rows = await db.execute(
|
||||
select(RadarDataORM.product_unique_id, RadarDataORM.id)
|
||||
.where(RadarDataORM.product_unique_id.isnot(None))
|
||||
)
|
||||
uid_to_radar_id: Dict[str, int] = {uid: rid for uid, rid in uid_rows.all() if uid}
|
||||
|
||||
# 从 DB 拉取所有 DONE 的 radar_data_id 集合
|
||||
done_rows = await db.execute(
|
||||
select(SARSceneGeoORM.radar_data_id, SARSceneGeoORM.geo_path)
|
||||
.where(SARSceneGeoORM.status == "DONE")
|
||||
)
|
||||
done_radar_ids: Dict[int, str] = {rid: gp for rid, gp in done_rows.all()}
|
||||
|
||||
if os.path.isdir(water_dir):
|
||||
for entry in os.scandir(water_dir):
|
||||
if not entry.is_dir() or not entry.name.startswith("scene_"):
|
||||
continue
|
||||
water_geo_consistency["dir_scanned_count"] += 1
|
||||
|
||||
# 检查目录内是否有 *_geo_db 文件
|
||||
geo_db_path = None
|
||||
for f in os.scandir(entry.path):
|
||||
if f.name.endswith("_geo_db") and not f.name.endswith(".hdr") and not f.name.endswith(".sml"):
|
||||
geo_db_path = f.path
|
||||
break
|
||||
if not geo_db_path:
|
||||
continue
|
||||
water_geo_consistency["geo_db_exists_count"] += 1
|
||||
|
||||
# 解析 product_unique_id
|
||||
m = _uid_re.search(entry.name)
|
||||
if not m:
|
||||
continue
|
||||
uid = m.group(1).lstrip("0") or m.group(1)
|
||||
radar_id = uid_to_radar_id.get(m.group(1)) or uid_to_radar_id.get(uid)
|
||||
if not radar_id:
|
||||
continue
|
||||
water_geo_consistency["matched_in_db_count"] += 1
|
||||
|
||||
if radar_id not in done_radar_ids:
|
||||
water_geo_consistency["unregistered_count"] += 1
|
||||
|
||||
# 反向检查:DB DONE 但 geo_db 文件不存在
|
||||
for radar_id, geo_path in done_radar_ids.items():
|
||||
if geo_path and not os.path.exists(geo_path):
|
||||
water_geo_consistency["registered_but_missing_count"] += 1
|
||||
|
||||
except Exception as _e:
|
||||
water_geo_consistency["error"] = str(_e)
|
||||
|
||||
# 7. D-InSAR 结果按月统计(从 name 字段解析主影像日期)
|
||||
dinsar_by_month: list = []
|
||||
try:
|
||||
_month_counts: Dict[str, int] = defaultdict(int)
|
||||
_date_re = _re.compile(r"(\d{8})")
|
||||
for record in dinsar_records:
|
||||
name = (
|
||||
record.product.task_alias
|
||||
or record.product.display_name
|
||||
or record.product.task_name
|
||||
or record.display_name
|
||||
)
|
||||
if not name:
|
||||
continue
|
||||
dates = _date_re.findall(name)
|
||||
if not dates:
|
||||
continue
|
||||
master_date = dates[0]
|
||||
_month_counts[f"{master_date[:4]}-{master_date[4:6]}"] += 1
|
||||
dinsar_by_month = [
|
||||
{"month": k, "count": v}
|
||||
for k, v in sorted(_month_counts.items())
|
||||
]
|
||||
except Exception as _e:
|
||||
dinsar_by_month = []
|
||||
|
||||
pairing_consistency: Dict[str, Any] = {
|
||||
"metric_cache_count": 0,
|
||||
"network_run_count": 0,
|
||||
"network_edge_count": 0,
|
||||
"dirty_scene_count": 0,
|
||||
"cache_status": None,
|
||||
"needs_rebuild": None,
|
||||
"duplicate_reverse_pair_count": 0,
|
||||
"invalid_orientation_count": 0,
|
||||
"network_edge_orphan_count": 0,
|
||||
"task_orphan_count": 0,
|
||||
"result_trace_missing_count": 0,
|
||||
"result_trace_orphan_count": 0,
|
||||
"result_trace_pair_mismatch_count": 0,
|
||||
}
|
||||
try:
|
||||
pairing_status = await pairing_state_service.get_pairing_system_status(db)
|
||||
pairing_consistency["metric_cache_count"] = int(pairing_status.get("pair_count") or 0)
|
||||
pairing_consistency["network_run_count"] = int(pairing_status.get("network_run_count") or 0)
|
||||
pairing_consistency["network_edge_count"] = int(pairing_status.get("network_edge_count") or 0)
|
||||
pairing_consistency["dirty_scene_count"] = int(pairing_status.get("dirty_scene_count") or 0)
|
||||
pairing_consistency["cache_status"] = pairing_status.get("status")
|
||||
pairing_consistency["needs_rebuild"] = bool(pairing_status.get("needs_rebuild"))
|
||||
pairing_consistency["duplicate_reverse_pair_count"] = int(
|
||||
pairing_status.get("duplicate_reverse_pair_count") or 0
|
||||
)
|
||||
pairing_consistency["network_edge_orphan_count"] = int(
|
||||
pairing_status.get("orphan_edge_count") or 0
|
||||
)
|
||||
|
||||
invalid_orientation_result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM pairing_metric_cache
|
||||
WHERE
|
||||
master_imaging_date IS NULL
|
||||
OR slave_imaging_date IS NULL
|
||||
OR master_scene_uid IS NULL
|
||||
OR slave_scene_uid IS NULL
|
||||
OR master_imaging_date > slave_imaging_date
|
||||
OR (
|
||||
master_imaging_date = slave_imaging_date
|
||||
AND (
|
||||
master_scene_uid > slave_scene_uid
|
||||
OR (
|
||||
master_scene_uid = slave_scene_uid
|
||||
AND master_scene_ref_id > slave_scene_ref_id
|
||||
)
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
pairing_consistency["invalid_orientation_count"] = int(
|
||||
invalid_orientation_result.scalar_one() or 0
|
||||
)
|
||||
|
||||
result_trace_missing_result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM result_products
|
||||
WHERE catalog_name = 'dinsar'
|
||||
AND (
|
||||
COALESCE(pair_uid, '') = ''
|
||||
OR COALESCE(network_run_id, '') = ''
|
||||
OR network_edge_id IS NULL
|
||||
OR COALESCE(policy_version, '') = ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
pairing_consistency["result_trace_missing_count"] = int(
|
||||
result_trace_missing_result.scalar_one() or 0
|
||||
)
|
||||
|
||||
result_trace_orphan_result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM result_products rp
|
||||
LEFT JOIN pairing_network_runs pnr
|
||||
ON pnr.network_run_id = rp.network_run_id
|
||||
LEFT JOIN pairing_network_edges pne
|
||||
ON pne.id = rp.network_edge_id
|
||||
AND pne.network_run_ref_id = pnr.id
|
||||
WHERE rp.catalog_name = 'dinsar'
|
||||
AND COALESCE(rp.pair_uid, '') <> ''
|
||||
AND COALESCE(rp.network_run_id, '') <> ''
|
||||
AND rp.network_edge_id IS NOT NULL
|
||||
AND (pnr.id IS NULL OR pne.id IS NULL)
|
||||
"""
|
||||
)
|
||||
)
|
||||
pairing_consistency["result_trace_orphan_count"] = int(
|
||||
result_trace_orphan_result.scalar_one() or 0
|
||||
)
|
||||
|
||||
result_trace_pair_mismatch_result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM result_products rp
|
||||
JOIN pairing_network_runs pnr
|
||||
ON pnr.network_run_id = rp.network_run_id
|
||||
JOIN pairing_network_edges pne
|
||||
ON pne.id = rp.network_edge_id
|
||||
AND pne.network_run_ref_id = pnr.id
|
||||
JOIN pairing_metric_cache pmc
|
||||
ON pmc.id = pne.metric_cache_ref_id
|
||||
WHERE rp.catalog_name = 'dinsar'
|
||||
AND COALESCE(rp.pair_uid, '') <> ''
|
||||
AND COALESCE(pmc.pair_uid, '') <> ''
|
||||
AND rp.pair_uid <> pmc.pair_uid
|
||||
"""
|
||||
)
|
||||
)
|
||||
pairing_consistency["result_trace_pair_mismatch_count"] = int(
|
||||
result_trace_pair_mismatch_result.scalar_one() or 0
|
||||
)
|
||||
except Exception as _e:
|
||||
pairing_consistency["error"] = str(_e)
|
||||
|
||||
stats_payload = {
|
||||
"dinsar_results_overview": {
|
||||
"total_count": dinsar_total_count,
|
||||
"cached_count": dinsar_cached_count,
|
||||
"uncached_count": dinsar_total_count - dinsar_cached_count,
|
||||
},
|
||||
"dinsar_cache_consistency": dinsar_cache_consistency,
|
||||
"source_data_overview": {
|
||||
"total_count": source_data_total_count,
|
||||
"envi_processed_count": envi_processed_count,
|
||||
"with_orbit_data_count": with_orbit_data_count,
|
||||
},
|
||||
"source_preview_consistency": source_preview_consistency,
|
||||
"source_xml_consistency": source_xml_consistency,
|
||||
"water_geo_consistency": water_geo_consistency,
|
||||
"pairing_consistency": pairing_consistency,
|
||||
"by_satellite": by_satellite,
|
||||
"idl_processing_stats": idl_processing_stats,
|
||||
"dinsar_by_month": dinsar_by_month,
|
||||
"ai_quality_overview": {
|
||||
"good_count": labeled_good_count,
|
||||
"bad_count": labeled_bad_count,
|
||||
"unlabeled_count": unlabeled_count
|
||||
},
|
||||
"ai_prediction_overview": {
|
||||
"good_count": ai_good_count,
|
||||
"bad_count": ai_bad_count,
|
||||
"medium_count": ai_medium_count,
|
||||
"unpredicted_count": ai_unpredicted_count
|
||||
}
|
||||
}
|
||||
|
||||
generated_at = datetime.utcnow().isoformat() + "Z"
|
||||
if _deps.STATS_CACHE_TTL_SECONDS > 0:
|
||||
async with _deps._STATS_CACHE_LOCK:
|
||||
_deps._STATS_CACHE_DATA = stats_payload
|
||||
_deps._STATS_CACHE_EXPIRES_AT = time.monotonic() + _deps.STATS_CACHE_TTL_SECONDS
|
||||
_deps._STATS_CACHE_GENERATED_AT_UTC = generated_at
|
||||
|
||||
return {
|
||||
**stats_payload,
|
||||
"cache_meta": {
|
||||
"enabled": _deps.STATS_CACHE_TTL_SECONDS > 0,
|
||||
"hit": False,
|
||||
"ttl_seconds": _deps.STATS_CACHE_TTL_SECONDS,
|
||||
"generated_at": generated_at,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from ..config import read_int_env
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
AuthUserORM,
|
||||
DinsarTaskBatch,
|
||||
DinsarTaskBatchORM,
|
||||
DinsarTaskItem,
|
||||
DinsarTaskItemORM,
|
||||
PsTaskBatch,
|
||||
PsTaskBatchORM,
|
||||
PsTaskItem,
|
||||
PsTaskItemORM,
|
||||
RadarData,
|
||||
RadarPair,
|
||||
)
|
||||
from .dependencies import (
|
||||
_add_operation_audit_log,
|
||||
_refresh_dinsar_batch_summary,
|
||||
_refresh_ps_batch_summary,
|
||||
_require_admin,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_BATCH_ITEM_STATUSES = {"PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"}
|
||||
TASK_BATCH_MAX_ITEMS = read_int_env(
|
||||
"TASK_BATCH_MAX_ITEMS",
|
||||
5000,
|
||||
minimum=1,
|
||||
maximum=200000,
|
||||
)
|
||||
BATCH_TEXT_MAX_LENGTH = read_int_env(
|
||||
"TASK_BATCH_TEXT_MAX_LENGTH",
|
||||
256,
|
||||
minimum=16,
|
||||
maximum=2000,
|
||||
)
|
||||
BATCH_REMARK_MAX_LENGTH = read_int_env(
|
||||
"TASK_BATCH_REMARK_MAX_LENGTH",
|
||||
2000,
|
||||
minimum=32,
|
||||
maximum=20000,
|
||||
)
|
||||
TASK_BATCH_LIST_DEFAULT_LIMIT = read_int_env(
|
||||
"TASK_BATCH_LIST_DEFAULT_LIMIT",
|
||||
200,
|
||||
minimum=1,
|
||||
maximum=5000,
|
||||
)
|
||||
TASK_BATCH_LIST_MAX_LIMIT = read_int_env(
|
||||
"TASK_BATCH_LIST_MAX_LIMIT",
|
||||
1000,
|
||||
minimum=1,
|
||||
maximum=20000,
|
||||
)
|
||||
TASK_BATCH_LIST_MAX_OFFSET = read_int_env(
|
||||
"TASK_BATCH_LIST_MAX_OFFSET",
|
||||
500000,
|
||||
minimum=0,
|
||||
maximum=20000000,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_batch_item_status(status: Optional[str]) -> Optional[str]:
|
||||
if status is None:
|
||||
return None
|
||||
normalized = str(status).strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
if normalized not in ALLOWED_BATCH_ITEM_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status: {status}. Allowed: {sorted(ALLOWED_BATCH_ITEM_STATUSES)}",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_list_pagination(limit: int, offset: int) -> tuple[int, int]:
|
||||
safe_limit = min(TASK_BATCH_LIST_MAX_LIMIT, max(1, int(limit or TASK_BATCH_LIST_DEFAULT_LIMIT)))
|
||||
safe_offset = min(TASK_BATCH_LIST_MAX_OFFSET, max(0, int(offset or 0)))
|
||||
return safe_limit, safe_offset
|
||||
|
||||
|
||||
class DinsarBatchCreateRequest(BaseModel):
|
||||
name: Optional[str] = Field(default=None, max_length=BATCH_TEXT_MAX_LENGTH)
|
||||
pairs: List[RadarPair]
|
||||
|
||||
@field_validator("pairs")
|
||||
@classmethod
|
||||
def _validate_pairs_size(cls, value: List[RadarPair]) -> List[RadarPair]:
|
||||
if len(value) > TASK_BATCH_MAX_ITEMS:
|
||||
raise ValueError(
|
||||
f"pairs exceeds max item count ({TASK_BATCH_MAX_ITEMS})."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class PsBatchCreateRequest(BaseModel):
|
||||
name: Optional[str] = Field(default=None, max_length=BATCH_TEXT_MAX_LENGTH)
|
||||
direction: Optional[str] = Field(default=None, max_length=BATCH_TEXT_MAX_LENGTH)
|
||||
stack: List[RadarData]
|
||||
|
||||
@field_validator("stack")
|
||||
@classmethod
|
||||
def _validate_stack_size(cls, value: List[RadarData]) -> List[RadarData]:
|
||||
if len(value) > TASK_BATCH_MAX_ITEMS:
|
||||
raise ValueError(
|
||||
f"stack exceeds max item count ({TASK_BATCH_MAX_ITEMS})."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class BatchItemUpdateRequest(BaseModel):
|
||||
status: Optional[str] = None
|
||||
remark: Optional[str] = Field(default=None, max_length=BATCH_REMARK_MAX_LENGTH)
|
||||
|
||||
|
||||
@router.post("/task-batches/dinsar", response_model=DinsarTaskBatch)
|
||||
async def create_dinsar_batch_endpoint(
|
||||
request: DinsarBatchCreateRequest,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
if not request.pairs:
|
||||
raise HTTPException(status_code=400, detail="No pairs provided.")
|
||||
|
||||
batch_id = str(uuid.uuid4())
|
||||
batch_name = request.name or f"DINSAR_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}"
|
||||
batch = DinsarTaskBatchORM(
|
||||
batch_id=batch_id,
|
||||
name=batch_name,
|
||||
status="PENDING",
|
||||
total_items=len(request.pairs),
|
||||
completed_items=0,
|
||||
)
|
||||
db.add(batch)
|
||||
|
||||
for pair in request.pairs:
|
||||
master = pair.master
|
||||
slave = pair.slave
|
||||
item = DinsarTaskItemORM(
|
||||
batch_id=batch_id,
|
||||
task_name=pair.task_name,
|
||||
task_alias=pair.task_alias or pair.task_name,
|
||||
pair_key=pair.pair_key,
|
||||
scene_pair_uid=pair.pair_uid,
|
||||
network_run_id=pair.network_run_id,
|
||||
network_edge_id=pair.network_edge_id,
|
||||
policy_version=pair.policy_version,
|
||||
selection_strategy=pair.selection_strategy,
|
||||
master_path=master.file_path,
|
||||
slave_path=slave.file_path,
|
||||
master_satellite=master.satellite,
|
||||
master_imaging_date=master.imaging_date,
|
||||
master_imaging_mode=master.imaging_mode,
|
||||
master_polarization=master.polarization,
|
||||
slave_satellite=slave.satellite,
|
||||
slave_imaging_date=slave.imaging_date,
|
||||
slave_imaging_mode=slave.imaging_mode,
|
||||
slave_polarization=slave.polarization,
|
||||
time_baseline_days=pair.time_baseline_days,
|
||||
spatial_baseline_meters=pair.spatial_baseline_meters,
|
||||
status="PENDING",
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=http_request,
|
||||
action="batch_created",
|
||||
resource=f"task-batches/dinsar/{batch_id}",
|
||||
detail={
|
||||
"batch_name": batch_name,
|
||||
"items": len(request.pairs),
|
||||
"network_run_ids": sorted(
|
||||
{
|
||||
str(pair.network_run_id)
|
||||
for pair in request.pairs
|
||||
if pair.network_run_id
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(batch)
|
||||
return DinsarTaskBatch.model_validate(batch)
|
||||
|
||||
|
||||
@router.get("/task-batches/dinsar", response_model=List[DinsarTaskBatch])
|
||||
async def list_dinsar_batches_endpoint(
|
||||
limit: int = TASK_BATCH_LIST_DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
safe_limit, safe_offset = _normalize_list_pagination(limit, offset)
|
||||
result = await db.execute(
|
||||
select(DinsarTaskBatchORM)
|
||||
.order_by(DinsarTaskBatchORM.created_at.desc())
|
||||
.offset(safe_offset)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
return [DinsarTaskBatch.model_validate(b) for b in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/task-batches/dinsar/{batch_id}/items", response_model=List[DinsarTaskItem])
|
||||
async def list_dinsar_batch_items_endpoint(
|
||||
batch_id: str,
|
||||
limit: int = TASK_BATCH_LIST_DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
safe_limit, safe_offset = _normalize_list_pagination(limit, offset)
|
||||
result = await db.execute(
|
||||
select(DinsarTaskItemORM).where(DinsarTaskItemORM.batch_id == batch_id)
|
||||
.order_by(DinsarTaskItemORM.id.asc())
|
||||
.offset(safe_offset)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
return [DinsarTaskItem.model_validate(i) for i in result.scalars().all()]
|
||||
|
||||
|
||||
@router.patch("/task-batches/dinsar/{batch_id}/complete-all", response_model=DinsarTaskBatch)
|
||||
async def complete_dinsar_batch_endpoint(
|
||||
batch_id: str,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
await db.execute(
|
||||
DinsarTaskItemORM.__table__.update()
|
||||
.where(DinsarTaskItemORM.batch_id == batch_id)
|
||||
.values(status="COMPLETED")
|
||||
)
|
||||
await _refresh_dinsar_batch_summary(db, batch_id)
|
||||
await db.commit()
|
||||
batch = await db.execute(select(DinsarTaskBatchORM).where(DinsarTaskBatchORM.batch_id == batch_id))
|
||||
batch_obj = batch.scalar_one_or_none()
|
||||
if not batch_obj:
|
||||
raise HTTPException(status_code=404, detail="Batch not found.")
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=http_request,
|
||||
action="batch_marked_complete",
|
||||
resource=f"task-batches/dinsar/{batch_id}",
|
||||
detail={"status": "COMPLETED", "items_completed": batch_obj.completed_items},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(batch_obj)
|
||||
return DinsarTaskBatch.model_validate(batch_obj)
|
||||
|
||||
|
||||
@router.patch("/task-batches/dinsar/items/{item_id}", response_model=DinsarTaskItem)
|
||||
async def update_dinsar_item_endpoint(
|
||||
item_id: int,
|
||||
request: BatchItemUpdateRequest,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
result = await db.execute(select(DinsarTaskItemORM).where(DinsarTaskItemORM.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found.")
|
||||
|
||||
normalized_status = _normalize_batch_item_status(request.status)
|
||||
if normalized_status is not None:
|
||||
item.status = normalized_status
|
||||
if request.remark is not None:
|
||||
item.remark = request.remark
|
||||
|
||||
await _refresh_dinsar_batch_summary(db, item.batch_id)
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=http_request,
|
||||
action="batch_item_updated",
|
||||
resource=f"task-batches/dinsar/items/{item_id}",
|
||||
detail={"batch_id": item.batch_id, "status": item.status},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return DinsarTaskItem.model_validate(item)
|
||||
|
||||
|
||||
@router.post("/task-batches/ps", response_model=PsTaskBatch)
|
||||
async def create_ps_batch_endpoint(
|
||||
request: PsBatchCreateRequest,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
if not request.stack:
|
||||
raise HTTPException(status_code=400, detail="No PS items provided.")
|
||||
|
||||
batch_id = str(uuid.uuid4())
|
||||
batch_name = request.name or f"PS_{(request.direction or 'STACK')}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}"
|
||||
batch = PsTaskBatchORM(
|
||||
batch_id=batch_id,
|
||||
name=batch_name,
|
||||
direction=request.direction,
|
||||
status="PENDING",
|
||||
total_items=len(request.stack),
|
||||
completed_items=0,
|
||||
)
|
||||
db.add(batch)
|
||||
|
||||
for img in request.stack:
|
||||
item = PsTaskItemORM(
|
||||
batch_id=batch_id,
|
||||
file_path=img.file_path,
|
||||
satellite=img.satellite,
|
||||
imaging_date=img.imaging_date,
|
||||
polarization=img.polarization,
|
||||
has_orbit_data=bool(img.has_orbit_data),
|
||||
status="PENDING",
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=http_request,
|
||||
action="batch_created",
|
||||
resource=f"task-batches/ps/{batch_id}",
|
||||
detail={"batch_name": batch_name, "items": len(request.stack), "direction": request.direction},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(batch)
|
||||
return PsTaskBatch.model_validate(batch)
|
||||
|
||||
|
||||
@router.get("/task-batches/ps", response_model=List[PsTaskBatch])
|
||||
async def list_ps_batches_endpoint(
|
||||
limit: int = TASK_BATCH_LIST_DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
safe_limit, safe_offset = _normalize_list_pagination(limit, offset)
|
||||
result = await db.execute(
|
||||
select(PsTaskBatchORM)
|
||||
.order_by(PsTaskBatchORM.created_at.desc())
|
||||
.offset(safe_offset)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
return [PsTaskBatch.model_validate(b) for b in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/task-batches/ps/{batch_id}/items", response_model=List[PsTaskItem])
|
||||
async def list_ps_batch_items_endpoint(
|
||||
batch_id: str,
|
||||
limit: int = TASK_BATCH_LIST_DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
safe_limit, safe_offset = _normalize_list_pagination(limit, offset)
|
||||
result = await db.execute(
|
||||
select(PsTaskItemORM).where(PsTaskItemORM.batch_id == batch_id)
|
||||
.order_by(PsTaskItemORM.id.asc())
|
||||
.offset(safe_offset)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
return [PsTaskItem.model_validate(i) for i in result.scalars().all()]
|
||||
|
||||
|
||||
@router.patch("/task-batches/ps/{batch_id}/complete-all", response_model=PsTaskBatch)
|
||||
async def complete_ps_batch_endpoint(
|
||||
batch_id: str,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
await db.execute(
|
||||
PsTaskItemORM.__table__.update()
|
||||
.where(PsTaskItemORM.batch_id == batch_id)
|
||||
.values(status="COMPLETED")
|
||||
)
|
||||
await _refresh_ps_batch_summary(db, batch_id)
|
||||
await db.commit()
|
||||
batch = await db.execute(select(PsTaskBatchORM).where(PsTaskBatchORM.batch_id == batch_id))
|
||||
batch_obj = batch.scalar_one_or_none()
|
||||
if not batch_obj:
|
||||
raise HTTPException(status_code=404, detail="Batch not found.")
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=http_request,
|
||||
action="batch_marked_complete",
|
||||
resource=f"task-batches/ps/{batch_id}",
|
||||
detail={"status": "COMPLETED", "items_completed": batch_obj.completed_items},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(batch_obj)
|
||||
return PsTaskBatch.model_validate(batch_obj)
|
||||
|
||||
|
||||
@router.patch("/task-batches/ps/items/{item_id}", response_model=PsTaskItem)
|
||||
async def update_ps_item_endpoint(
|
||||
item_id: int,
|
||||
request: BatchItemUpdateRequest,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
result = await db.execute(select(PsTaskItemORM).where(PsTaskItemORM.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found.")
|
||||
|
||||
normalized_status = _normalize_batch_item_status(request.status)
|
||||
if normalized_status is not None:
|
||||
item.status = normalized_status
|
||||
if request.remark is not None:
|
||||
item.remark = request.remark
|
||||
|
||||
await _refresh_ps_batch_summary(db, item.batch_id)
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=http_request,
|
||||
action="batch_item_updated",
|
||||
resource=f"task-batches/ps/items/{item_id}",
|
||||
detail={"batch_id": item.batch_id, "status": item.status},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return PsTaskItem.model_validate(item)
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth_utils import verify_password
|
||||
from ..models import AuthUserORM, TaskInfo
|
||||
from ..services.task_service import (
|
||||
TASK_ACTIVE_DEFAULT_LIMIT,
|
||||
TASK_ACTIVE_MAX_LIMIT,
|
||||
TASK_LOG_DEFAULT_LIMIT,
|
||||
TASK_LOG_MAX_LIMIT,
|
||||
TASK_QUERY_MAX_OFFSET,
|
||||
task_service,
|
||||
)
|
||||
from ..auth_service import SESSION_COOKIE_NAME, get_user_by_session_token
|
||||
from ..database import AsyncSessionLocal
|
||||
from .dependencies import _require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ForceCancelRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@router.get("/tasks/active", response_model=List[TaskInfo])
|
||||
async def get_active_tasks(limit: int = TASK_ACTIVE_DEFAULT_LIMIT, offset: int = 0):
|
||||
"""获取当前正在运行的所有后台任务"""
|
||||
safe_limit = min(TASK_ACTIVE_MAX_LIMIT, max(1, int(limit or TASK_ACTIVE_DEFAULT_LIMIT)))
|
||||
safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
|
||||
orm_tasks = await task_service.get_active_tasks(limit=safe_limit, offset=safe_offset)
|
||||
return [TaskInfo.model_validate(t) for t in orm_tasks]
|
||||
|
||||
|
||||
@router.get("/tasks/active/stream")
|
||||
async def stream_active_tasks(request: Request):
|
||||
"""SSE 端点:每 3s 推送一次活跃任务列表,替代前端轮询。"""
|
||||
# Authenticate via Cookie at connection establishment
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if token and AsyncSessionLocal:
|
||||
async with AsyncSessionLocal() as db:
|
||||
user = await get_user_by_session_token(db, token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required.")
|
||||
elif not token:
|
||||
raise HTTPException(status_code=401, detail="Authentication required.")
|
||||
|
||||
async def event_generator():
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
orm_tasks = await task_service.get_active_tasks(
|
||||
limit=TASK_ACTIVE_MAX_LIMIT, offset=0
|
||||
)
|
||||
tasks_data = [TaskInfo.model_validate(t).model_dump() for t in orm_tasks]
|
||||
yield f"data: {json.dumps(tasks_data)}\n\n"
|
||||
except Exception:
|
||||
yield "data: []\n\n"
|
||||
await asyncio.sleep(3)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=Optional[TaskInfo])
|
||||
async def get_task_status(task_id: str):
|
||||
"""获取特定任务的状态"""
|
||||
task = await task_service.get_task(task_id)
|
||||
if task:
|
||||
return TaskInfo.model_validate(task)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/logs")
|
||||
async def get_task_logs(task_id: str, limit: int = TASK_LOG_DEFAULT_LIMIT, offset: int = 0):
|
||||
"""获取指定任务的日志(支持 limit/offset)。"""
|
||||
task = await task_service.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务未找到")
|
||||
|
||||
safe_limit = min(TASK_LOG_MAX_LIMIT, max(1, int(limit or TASK_LOG_DEFAULT_LIMIT)))
|
||||
safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
|
||||
logs = await task_service.get_logs(task_id, limit=safe_limit, offset=safe_offset)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"limit": safe_limit,
|
||||
"offset": safe_offset,
|
||||
"count": len(logs),
|
||||
"logs": [
|
||||
{
|
||||
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
|
||||
"level": log.log_level,
|
||||
"message": log.message,
|
||||
}
|
||||
for log in logs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/force-cancel")
|
||||
async def force_cancel_task(
|
||||
task_id: str,
|
||||
body: ForceCancelRequest,
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""管理员输入密码后强制取消任务(解锁前端)"""
|
||||
if not verify_password(body.password, admin_user.password_hash):
|
||||
raise HTTPException(status_code=403, detail="密码错误")
|
||||
task = await task_service.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务未找到")
|
||||
if task.status not in ("PENDING", "RUNNING"):
|
||||
raise HTTPException(status_code=400, detail="任务已结束,无需取消")
|
||||
await task_service.update_task(task_id, status="CANCELLED", message="管理员强制取消")
|
||||
return {"message": "任务已强制取消", "task_id": task_id}
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import AuthUserORM
|
||||
from ..services.timeseries_service import timeseries_service
|
||||
from .dependencies import _get_current_user, _require_admin
|
||||
|
||||
|
||||
router = APIRouter(prefix="/timeseries-production", tags=["timeseries-production"])
|
||||
|
||||
|
||||
class TimeseriesRunCreateRequest(BaseModel):
|
||||
batch_id: str = Field(..., description="PS batch id")
|
||||
run_name: Optional[str] = Field(default=None, max_length=255)
|
||||
reference_date: Optional[str] = Field(default=None, pattern=r"^\d{8}$|^$")
|
||||
water_mask_mode: str = Field(default="synthetic_fallback", max_length=64)
|
||||
notes: Optional[str] = Field(default=None, max_length=1000)
|
||||
|
||||
@field_validator("batch_id", mode="before")
|
||||
@classmethod
|
||||
def _validate_batch_id(cls, value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError("batch_id is required")
|
||||
return text
|
||||
|
||||
|
||||
@router.post("/runs", status_code=202)
|
||||
async def create_timeseries_run(
|
||||
request: TimeseriesRunCreateRequest,
|
||||
current_user: AuthUserORM = Depends(_require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
return await timeseries_service.create_run(
|
||||
batch_id=request.batch_id,
|
||||
run_name=request.run_name,
|
||||
reference_date=request.reference_date,
|
||||
water_mask_mode=request.water_mask_mode,
|
||||
notes=request.notes,
|
||||
created_by=getattr(current_user, "username", None),
|
||||
db=db,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def list_timeseries_runs(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
return await timeseries_service.list_runs(db, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}")
|
||||
async def get_timeseries_run_detail(
|
||||
run_id: str,
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
detail = await timeseries_service.get_run_detail(db, run_id=run_id)
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=404, detail="Timeseries run not found")
|
||||
return detail
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import read_int_env
|
||||
from ..database import get_db
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.task_service import TASK_LOG_DEFAULT_LIMIT, TASK_LOG_MAX_LIMIT, TASK_QUERY_MAX_OFFSET, task_service
|
||||
from .dependencies import _add_operation_audit_log, _validate_export_path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
COPY_BATCH_ALLOWED_STATUSES = {"PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"}
|
||||
COPY_BATCH_TEXT_MAX_LENGTH = read_int_env(
|
||||
"COPY_BATCH_TEXT_MAX_LENGTH",
|
||||
2048,
|
||||
minimum=64,
|
||||
maximum=32767,
|
||||
)
|
||||
COPY_BATCH_MAX_STATUS_COUNT = read_int_env(
|
||||
"COPY_BATCH_MAX_STATUS_COUNT",
|
||||
8,
|
||||
minimum=1,
|
||||
maximum=64,
|
||||
)
|
||||
|
||||
|
||||
class CopyBatchRequest(BaseModel):
|
||||
batch_id: str = Field(max_length=COPY_BATCH_TEXT_MAX_LENGTH)
|
||||
dest_dir: str = Field(max_length=COPY_BATCH_TEXT_MAX_LENGTH)
|
||||
copy_statuses: Optional[List[str]] = None
|
||||
|
||||
@field_validator("batch_id", "dest_dir", mode="before")
|
||||
@classmethod
|
||||
def _normalize_required_text(cls, value):
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("Field must not be empty.")
|
||||
return normalized
|
||||
|
||||
@field_validator("copy_statuses", mode="before")
|
||||
@classmethod
|
||||
def _validate_copy_statuses_length(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("copy_statuses must be a list.")
|
||||
if len(value) > COPY_BATCH_MAX_STATUS_COUNT:
|
||||
raise ValueError(
|
||||
f"copy_statuses exceeds max count ({COPY_BATCH_MAX_STATUS_COUNT})."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_copy_batch_statuses(copy_statuses: Optional[List[str]]) -> List[str]:
|
||||
if not copy_statuses:
|
||||
return ["COMPLETED"]
|
||||
|
||||
normalized: List[str] = []
|
||||
for raw in copy_statuses:
|
||||
status = (raw or "").strip().upper()
|
||||
if not status:
|
||||
continue
|
||||
if status not in COPY_BATCH_ALLOWED_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid copy status: {status}. Allowed: {sorted(COPY_BATCH_ALLOWED_STATUSES)}",
|
||||
)
|
||||
if status not in normalized:
|
||||
normalized.append(status)
|
||||
|
||||
return normalized or ["COMPLETED"]
|
||||
|
||||
|
||||
@router.post("/tools/copy-ps-stack")
|
||||
async def copy_ps_stack_endpoint(
|
||||
request: CopyBatchRequest,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Start PS-InSAR copy task from a batch.
|
||||
"""
|
||||
_validate_export_path(request.dest_dir, "dest_dir")
|
||||
try:
|
||||
copy_statuses = _normalize_copy_batch_statuses(request.copy_statuses)
|
||||
params = {
|
||||
"dest_dir": request.dest_dir,
|
||||
"file_type": "PS_STACK",
|
||||
"batch_id": request.batch_id,
|
||||
"copy_statuses": copy_statuses,
|
||||
}
|
||||
task_id = await task_service.create_task("COPY_DATA", f"PS数据分发: {request.dest_dir}", params=params)
|
||||
|
||||
payload = {
|
||||
"file_type": "PS_STACK",
|
||||
"dest_dir": request.dest_dir,
|
||||
"batch_id": request.batch_id,
|
||||
"copy_statuses": copy_statuses,
|
||||
}
|
||||
await job_queue_service.create_job("COPY_DATA", payload=payload, task_id=task_id)
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=http_request,
|
||||
action="task_queued",
|
||||
resource="tools/copy-ps-stack",
|
||||
detail={
|
||||
"task_id": task_id,
|
||||
"batch_id": request.batch_id,
|
||||
"dest_dir": request.dest_dir,
|
||||
"copy_statuses": copy_statuses,
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "PS-InSAR复制任务已进入队列", "task_id": task_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/tools/copy-dinsar-pairs")
|
||||
async def copy_dinsar_pairs_endpoint(
|
||||
request: CopyBatchRequest,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Start D-InSAR copy task from a batch.
|
||||
"""
|
||||
_validate_export_path(request.dest_dir, "dest_dir")
|
||||
try:
|
||||
copy_statuses = _normalize_copy_batch_statuses(request.copy_statuses)
|
||||
params = {
|
||||
"dest_dir": request.dest_dir,
|
||||
"file_type": "DINSAR_PAIRS",
|
||||
"batch_id": request.batch_id,
|
||||
"copy_statuses": copy_statuses,
|
||||
}
|
||||
task_id = await task_service.create_task("COPY_DATA", f"D-InSAR 数据分发: {request.dest_dir}", params=params)
|
||||
|
||||
payload = {
|
||||
"file_type": "DINSAR_PAIRS",
|
||||
"dest_dir": request.dest_dir,
|
||||
"batch_id": request.batch_id,
|
||||
"copy_statuses": copy_statuses,
|
||||
}
|
||||
await job_queue_service.create_job("COPY_DATA", payload=payload, task_id=task_id)
|
||||
await _add_operation_audit_log(
|
||||
db,
|
||||
request=http_request,
|
||||
action="task_queued",
|
||||
resource="tools/copy-dinsar-pairs",
|
||||
detail={
|
||||
"task_id": task_id,
|
||||
"batch_id": request.batch_id,
|
||||
"dest_dir": request.dest_dir,
|
||||
"copy_statuses": copy_statuses,
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "D-InSAR复制任务已进入队列", "task_id": task_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tools/copy-status/{task_id}")
|
||||
async def get_copy_status_endpoint(
|
||||
task_id: str,
|
||||
limit: int = TASK_LOG_DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""
|
||||
获取复制任务的状态和日志。
|
||||
"""
|
||||
task = await task_service.get_task(task_id)
|
||||
safe_limit = min(TASK_LOG_MAX_LIMIT, max(1, int(limit or TASK_LOG_DEFAULT_LIMIT)))
|
||||
safe_offset = min(TASK_QUERY_MAX_OFFSET, max(0, int(offset or 0)))
|
||||
logs = await task_service.get_logs(task_id, limit=safe_limit, offset=safe_offset)
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务未找到")
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": task.status,
|
||||
"progress": task.progress,
|
||||
"message": task.message,
|
||||
"limit": safe_limit,
|
||||
"offset": safe_offset,
|
||||
"logs": [f"[{l.timestamp.strftime('%H:%M:%S')}] [{l.log_level}] {l.message}" for l in logs]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import AuthUserORM
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.task_service import task_service
|
||||
from ..services.unpack_service import get_unpack_config
|
||||
from .dependencies import _require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/unpack/config")
|
||||
async def get_unpack_config_endpoint():
|
||||
"""
|
||||
获取解包配置 (来源 .env)。
|
||||
"""
|
||||
return get_unpack_config()
|
||||
|
||||
|
||||
@router.post("/unpack/run", status_code=202)
|
||||
async def run_unpack_endpoint(background_tasks: BackgroundTasks, admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
触发一次解包任务。
|
||||
"""
|
||||
config = get_unpack_config()
|
||||
if not config.get("source_dirs"):
|
||||
raise HTTPException(status_code=400, detail="UNPACK_SOURCE_DIRS is not configured.")
|
||||
|
||||
try:
|
||||
task_id = await task_service.create_task(
|
||||
"UNPACK_ARCHIVES",
|
||||
"Archive unpack",
|
||||
params=config,
|
||||
)
|
||||
await job_queue_service.create_job("UNPACK_ARCHIVES", payload=config, task_id=task_id)
|
||||
return {"message": "Unpack task queued", "task_id": task_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
@@ -0,0 +1,993 @@
|
||||
"""Water body monitoring v2 router — SARscape-based pipeline."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re as _re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..models import AuthUserORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, GF3ProcessingORM
|
||||
from ..services.job_handlers import JOB_TYPE_WATER_GEOCODE, JOB_TYPE_WATER_FLOOD, JOB_TYPE_WATER_DETECT, JOB_TYPE_GF3_PROCESS
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.task_service import task_service
|
||||
from .dependencies import _require_admin, _get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_WATER_JOB_MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class GeocodeRequest(BaseModel):
|
||||
radar_data_id: int = Field(..., description="RadarDataORM 主键")
|
||||
|
||||
|
||||
class FloodDetectRequest(BaseModel):
|
||||
pre_scene_id: int = Field(..., description="灾前 SARSceneGeoORM 主键")
|
||||
post_scene_id: int = Field(..., description="灾后 SARSceneGeoORM 主键")
|
||||
refine: bool = Field(default=False, description="是否启用 MRF 精化")
|
||||
|
||||
|
||||
class WaterPairRequest(BaseModel):
|
||||
pre_start: Optional[str] = Field(default=None, description="灾前开始日期 YYYYMMDD")
|
||||
pre_end: Optional[str] = Field(default=None, description="灾前结束日期 YYYYMMDD")
|
||||
post_start: Optional[str] = Field(default=None, description="灾后开始日期 YYYYMMDD")
|
||||
post_end: Optional[str] = Field(default=None, description="灾后结束日期 YYYYMMDD")
|
||||
overlap_threshold: float = Field(default=0.3, ge=0.0, le=1.0, description="最小重叠比例")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _overlap_ratio(poly_a, poly_b) -> float:
|
||||
"""计算两个 polygon 的重叠比例(相对于较小的那个)。
|
||||
支持 GeoJSON dict 或原始坐标数组 [[lon,lat], ...] 两种格式。
|
||||
"""
|
||||
try:
|
||||
from shapely.geometry import shape, Polygon
|
||||
|
||||
def _to_geom(poly):
|
||||
if isinstance(poly, str):
|
||||
import json
|
||||
poly = json.loads(poly)
|
||||
# 原始坐标数组格式:[[lon, lat], ...]
|
||||
if isinstance(poly, list):
|
||||
return Polygon(poly)
|
||||
# GeoJSON dict 格式
|
||||
return shape(poly)
|
||||
|
||||
a = _to_geom(poly_a)
|
||||
b = _to_geom(poly_b)
|
||||
if not a.is_valid or not b.is_valid:
|
||||
return 0.0
|
||||
inter = a.intersection(b).area
|
||||
smaller = min(a.area, b.area)
|
||||
return inter / smaller if smaller > 0 else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
async def _queue_water_job(
|
||||
job_type: str,
|
||||
task_type: str,
|
||||
task_name: str,
|
||||
payload: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
task_id = await task_service.create_task(
|
||||
task_type=task_type,
|
||||
task_name=task_name,
|
||||
params=payload,
|
||||
)
|
||||
job_id = await job_queue_service.create_job(
|
||||
job_type=job_type,
|
||||
payload=payload,
|
||||
task_id=task_id,
|
||||
max_attempts=_WATER_JOB_MAX_ATTEMPTS,
|
||||
)
|
||||
return {"task_id": task_id, "job_id": job_id, "job_type": job_type, "message": "Job queued."}
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(status_code=409 if "冲突" in msg else 400, detail=msg) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/water/geocode", status_code=202)
|
||||
async def submit_geocode(
|
||||
req: GeocodeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""提交单景 SAR 地理编码任务(多视 + 地理编码 + 辐射定标)。"""
|
||||
radar = await db.get(RadarDataORM, req.radar_data_id)
|
||||
if not radar:
|
||||
raise HTTPException(status_code=404, detail=f"RadarData id={req.radar_data_id} 不存在")
|
||||
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM)
|
||||
.where(SARSceneGeoORM.radar_data_id == req.radar_data_id)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
scene = result.scalar_one_or_none()
|
||||
if scene and scene.status in ("PENDING", "RUNNING"):
|
||||
raise HTTPException(status_code=409, detail="该场景已有进行中的地理编码任务")
|
||||
if not scene:
|
||||
scene = SARSceneGeoORM(radar_data_id=req.radar_data_id, status="PENDING")
|
||||
db.add(scene)
|
||||
await db.flush()
|
||||
else:
|
||||
scene.status = "PENDING"
|
||||
scene.error_msg = None
|
||||
await db.flush()
|
||||
scene_id = scene.id
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
return await _queue_water_job(
|
||||
job_type=JOB_TYPE_WATER_GEOCODE,
|
||||
task_type=f"WATER_GEOCODE_{scene_id}",
|
||||
task_name=f"水体地理编码 radar_id={req.radar_data_id}",
|
||||
payload={"scene_id": scene_id, "radar_data_id": req.radar_data_id},
|
||||
)
|
||||
except HTTPException:
|
||||
# job 入队失败,回滚 scene 状态避免卡在 PENDING
|
||||
async with db.begin():
|
||||
s = await db.get(SARSceneGeoORM, scene_id)
|
||||
if s and s.status == "PENDING":
|
||||
s.status = "FAILED"
|
||||
s.error_msg = "任务入队失败"
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/water/scenes/{scene_id}/reset", status_code=200)
|
||||
async def reset_scene_status(
|
||||
scene_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""将卡住的场景状态重置为 FAILED,允许重新提交。"""
|
||||
scene = await db.get(SARSceneGeoORM, scene_id)
|
||||
if not scene:
|
||||
raise HTTPException(status_code=404, detail=f"场景 id={scene_id} 不存在")
|
||||
if scene.status not in ("PENDING", "RUNNING"):
|
||||
raise HTTPException(status_code=400, detail=f"场景当前状态为 {scene.status},无需重置")
|
||||
scene.status = "FAILED"
|
||||
scene.error_msg = "手动重置(任务已取消)"
|
||||
await db.commit()
|
||||
return {"id": scene_id, "status": "FAILED", "message": "已重置"}
|
||||
|
||||
|
||||
@router.get("/water/scenes/done-ids")
|
||||
async def list_done_scene_radar_ids(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""返回所有 status=DONE 的场景对应的 radar_data_id 列表,用于前端标注。"""
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM.radar_data_id).where(SARSceneGeoORM.status == "DONE")
|
||||
)
|
||||
return {"ids": [row for (row,) in result.all()]}
|
||||
|
||||
|
||||
@router.get("/water/scenes/active-ids")
|
||||
async def list_active_scene_radar_ids(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""返回所有 status=PENDING/RUNNING 的场景对应的 radar_data_id 列表,用于前端标注。"""
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM.radar_data_id).where(
|
||||
SARSceneGeoORM.status.in_(["PENDING", "RUNNING"])
|
||||
)
|
||||
)
|
||||
return {"ids": [row for (row,) in result.all()]}
|
||||
|
||||
|
||||
@router.get("/water/scenes")
|
||||
async def list_scenes(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""列出所有单景地理编码记录(分页)。"""
|
||||
from sqlalchemy import func
|
||||
total_result = await db.execute(select(func.count()).select_from(SARSceneGeoORM))
|
||||
total = total_result.scalar_one()
|
||||
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM, RadarDataORM)
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.order_by(SARSceneGeoORM.id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for scene, radar in rows:
|
||||
items.append({
|
||||
"id": scene.id,
|
||||
"radar_data_id": scene.radar_data_id,
|
||||
"satellite": radar.satellite,
|
||||
"imaging_date": radar.imaging_date,
|
||||
"geo_path": scene.geo_path,
|
||||
"pixel_size_m": scene.pixel_size_m,
|
||||
"status": scene.status,
|
||||
"error_msg": scene.error_msg,
|
||||
"created_at": scene.created_at.isoformat() if scene.created_at else None,
|
||||
"coverage_polygon": radar.coverage_polygon,
|
||||
"min_lat": radar.min_lat,
|
||||
"max_lat": radar.max_lat,
|
||||
"min_lon": radar.min_lon,
|
||||
"max_lon": radar.max_lon,
|
||||
})
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
@router.delete("/water/scenes/cleanup", status_code=200)
|
||||
async def cleanup_failed_scenes(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""删除所有 FAILED 状态的场景记录。"""
|
||||
from sqlalchemy import delete as sql_delete
|
||||
result = await db.execute(
|
||||
sql_delete(SARSceneGeoORM).where(SARSceneGeoORM.status == "FAILED")
|
||||
)
|
||||
await db.commit()
|
||||
return {"deleted": result.rowcount}
|
||||
|
||||
|
||||
@router.post("/water/flood-detect", status_code=202)
|
||||
async def submit_flood_detect(
|
||||
req: FloodDetectRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""提交洪涝检测任务(灾前 + 灾后两景配对)。"""
|
||||
pre = await db.get(SARSceneGeoORM, req.pre_scene_id)
|
||||
post = await db.get(SARSceneGeoORM, req.post_scene_id)
|
||||
if not pre:
|
||||
raise HTTPException(status_code=404, detail=f"灾前场景 id={req.pre_scene_id} 不存在")
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail=f"灾后场景 id={req.post_scene_id} 不存在")
|
||||
if pre.status != "DONE":
|
||||
raise HTTPException(status_code=400, detail=f"灾前场景尚未完成地理编码 (status={pre.status})")
|
||||
if post.status != "DONE":
|
||||
raise HTTPException(status_code=400, detail=f"灾后场景尚未完成地理编码 (status={post.status})")
|
||||
|
||||
result = await db.execute(
|
||||
select(FloodDetectionORM).where(
|
||||
FloodDetectionORM.pre_scene_id == req.pre_scene_id,
|
||||
FloodDetectionORM.post_scene_id == req.post_scene_id,
|
||||
).with_for_update(skip_locked=True)
|
||||
)
|
||||
det = result.scalar_one_or_none()
|
||||
if det and det.status in ("PENDING", "RUNNING"):
|
||||
raise HTTPException(status_code=409, detail="该配对已有进行中的洪涝检测任务")
|
||||
if not det:
|
||||
det = FloodDetectionORM(
|
||||
pre_scene_id=req.pre_scene_id,
|
||||
post_scene_id=req.post_scene_id,
|
||||
status="PENDING",
|
||||
)
|
||||
db.add(det)
|
||||
await db.flush()
|
||||
else:
|
||||
det.status = "PENDING"
|
||||
det.error_msg = None
|
||||
await db.flush()
|
||||
det_id = det.id
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
return await _queue_water_job(
|
||||
job_type=JOB_TYPE_WATER_FLOOD,
|
||||
task_type=f"WATER_FLOOD_{det_id}",
|
||||
task_name=f"洪涝检测 pre={req.pre_scene_id} post={req.post_scene_id}",
|
||||
payload={"detection_id": det_id, "refine": req.refine},
|
||||
)
|
||||
except HTTPException:
|
||||
async with db.begin():
|
||||
d = await db.get(FloodDetectionORM, det_id)
|
||||
if d and d.status == "PENDING":
|
||||
d.status = "FAILED"
|
||||
d.error_msg = "任务入队失败"
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/water/flood-events")
|
||||
async def list_flood_events(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""列出所有洪涝检测结果。"""
|
||||
result = await db.execute(
|
||||
select(FloodDetectionORM)
|
||||
.options(
|
||||
selectinload(FloodDetectionORM.pre_scene).selectinload(SARSceneGeoORM.radar_data),
|
||||
selectinload(FloodDetectionORM.post_scene).selectinload(SARSceneGeoORM.radar_data),
|
||||
)
|
||||
.order_by(FloodDetectionORM.id.desc())
|
||||
)
|
||||
dets = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for det in dets:
|
||||
pre_rd = det.pre_scene.radar_data if det.pre_scene else None
|
||||
post_rd = det.post_scene.radar_data if det.post_scene else None
|
||||
items.append({
|
||||
"id": det.id,
|
||||
"pre_scene_id": det.pre_scene_id,
|
||||
"post_scene_id": det.post_scene_id,
|
||||
"pre_imaging_date": pre_rd.imaging_date if pre_rd else None,
|
||||
"post_imaging_date": post_rd.imaging_date if post_rd else None,
|
||||
"pre_satellite": pre_rd.satellite if pre_rd else None,
|
||||
"post_satellite": post_rd.satellite if post_rd else None,
|
||||
"pre_geo_path": det.pre_scene.geo_path if det.pre_scene else None,
|
||||
"post_geo_path": det.post_scene.geo_path if det.post_scene else None,
|
||||
"classified_path": det.classified_path,
|
||||
"flood_area_km2": det.flood_area_km2,
|
||||
"stable_water_area_km2": det.stable_water_area_km2,
|
||||
"status": det.status,
|
||||
"error_msg": det.error_msg,
|
||||
"created_at": det.created_at.isoformat() if det.created_at else None,
|
||||
"updated_at": det.updated_at.isoformat() if det.updated_at else None,
|
||||
})
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flood event map preview helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _open_envi_rasterio(path: str):
|
||||
"""打开 ENVI 格式栅格(路径可能无扩展名)。优先尝试裸路径,再尝试加 .bin/.img。"""
|
||||
import rasterio
|
||||
# 统一用正斜杠(Windows rasterio 也接受)
|
||||
path = path.replace("\\", "/")
|
||||
# 如果路径本身可以直接打开(ENVI 无扩展名文件)
|
||||
try:
|
||||
return rasterio.open(path)
|
||||
except Exception:
|
||||
pass
|
||||
# 尝试常见扩展名
|
||||
for ext in (".bin", ".img", ".tif", ".tiff"):
|
||||
try:
|
||||
return rasterio.open(path + ext)
|
||||
except Exception:
|
||||
pass
|
||||
raise FileNotFoundError(f"无法打开栅格文件: {path}")
|
||||
|
||||
|
||||
def _raster_to_png_bytes(path: str, colormap: dict) -> tuple[bytes, list]:
|
||||
"""将单波段分类栅格渲染为 RGBA PNG,返回 (png_bytes, [min_lat, min_lon, max_lat, max_lon])。"""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
with _open_envi_rasterio(path) as ds:
|
||||
data = ds.read(1)
|
||||
bounds = ds.bounds
|
||||
geo_bounds = [bounds.bottom, bounds.left, bounds.top, bounds.right]
|
||||
|
||||
h, w = data.shape
|
||||
rgba = np.zeros((h, w, 4), dtype=np.uint8)
|
||||
for val, color in colormap.items():
|
||||
mask = data == val
|
||||
rgba[mask] = color
|
||||
img = Image.fromarray(rgba, "RGBA")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue(), geo_bounds
|
||||
|
||||
|
||||
def _geo_raster_to_png_bytes(path: str) -> tuple[bytes, list]:
|
||||
"""将地理编码后的 SAR 强度图渲染为灰度 PNG(百分位拉伸)。"""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
with _open_envi_rasterio(path) as ds:
|
||||
data = ds.read(1).astype(np.float32)
|
||||
nodata = ds.nodata
|
||||
bounds = ds.bounds
|
||||
geo_bounds = [bounds.bottom, bounds.left, bounds.top, bounds.right]
|
||||
|
||||
# 构建 nodata 掩膜:优先用 ds.nodata,否则用 nan/inf
|
||||
if nodata is not None:
|
||||
nodata_mask = (data == nodata) | ~np.isfinite(data)
|
||||
else:
|
||||
nodata_mask = ~np.isfinite(data)
|
||||
|
||||
valid = data[~nodata_mask]
|
||||
if valid.size == 0:
|
||||
data_norm = np.zeros_like(data, dtype=np.uint8)
|
||||
else:
|
||||
p2, p98 = np.percentile(valid, 2), np.percentile(valid, 98)
|
||||
clipped = np.clip(data, p2, p98)
|
||||
data_norm = ((clipped - p2) / max(p98 - p2, 1e-9) * 255).astype(np.uint8)
|
||||
|
||||
# 组装 RGBA:nodata 区域 alpha=0,其余 alpha=200
|
||||
rgba = np.stack([data_norm, data_norm, data_norm, np.full_like(data_norm, 200)], axis=-1)
|
||||
rgba[nodata_mask, 3] = 0
|
||||
img = Image.fromarray(rgba, "RGBA")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue(), geo_bounds
|
||||
|
||||
|
||||
# 分类结果色表:值 → RGBA
|
||||
_FLOOD_COLORMAP = {
|
||||
1: (24, 144, 255, 200), # 稳定水体 — 蓝
|
||||
2: (255, 77, 79, 220), # 洪涝 — 红
|
||||
3: (250, 173, 20, 180), # 高散射 — 黄
|
||||
4: (80, 80, 80, 80), # 非水体 — 灰(半透明)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/water/flood-events/{event_id}/preview/classified")
|
||||
async def flood_event_classified_preview(
|
||||
event_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""返回洪涝分类结果的 PNG 预览图 + 地理范围(JSON)。"""
|
||||
from fastapi.responses import JSONResponse
|
||||
import base64
|
||||
|
||||
det = await db.get(FloodDetectionORM, event_id)
|
||||
if not det or not det.classified_path:
|
||||
raise HTTPException(status_code=404, detail="分类结果文件不存在")
|
||||
path = det.classified_path.replace("\\", "/")
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="请求的文件不存在")
|
||||
try:
|
||||
import asyncio
|
||||
png_bytes, geo_bounds = await asyncio.to_thread(
|
||||
_raster_to_png_bytes, path, _FLOOD_COLORMAP
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"渲染失败: {e}")
|
||||
return JSONResponse({
|
||||
"image_b64": base64.b64encode(png_bytes).decode(),
|
||||
"bounds": geo_bounds, # [min_lat, min_lon, max_lat, max_lon]
|
||||
"legend": {
|
||||
"稳定水体": "#1890ff",
|
||||
"洪涝": "#ff4d4f",
|
||||
"高散射": "#faad14",
|
||||
"非水体": "#505050",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@router.get("/water/flood-events/{event_id}/preview/pre")
|
||||
async def flood_event_pre_preview(
|
||||
event_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""返回灾前地理编码影像的灰度 PNG 预览 + 地理范围。"""
|
||||
from fastapi.responses import JSONResponse
|
||||
import base64
|
||||
|
||||
det = await db.get(FloodDetectionORM, event_id)
|
||||
if not det:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
pre = await db.get(SARSceneGeoORM, det.pre_scene_id)
|
||||
if not pre or not pre.geo_path:
|
||||
raise HTTPException(status_code=404, detail="灾前场景路径不存在")
|
||||
path = pre.geo_path.replace("\\", "/")
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="请求的文件不存在")
|
||||
try:
|
||||
import asyncio
|
||||
png_bytes, geo_bounds = await asyncio.to_thread(_geo_raster_to_png_bytes, path)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"渲染失败: {e}")
|
||||
return JSONResponse({
|
||||
"image_b64": base64.b64encode(png_bytes).decode(),
|
||||
"bounds": geo_bounds,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/water/flood-events/{event_id}/preview/post")
|
||||
async def flood_event_post_preview(
|
||||
event_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""返回灾后地理编码影像的灰度 PNG 预览 + 地理范围。"""
|
||||
from fastapi.responses import JSONResponse
|
||||
import base64
|
||||
|
||||
det = await db.get(FloodDetectionORM, event_id)
|
||||
if not det:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
post = await db.get(SARSceneGeoORM, det.post_scene_id)
|
||||
if not post or not post.geo_path:
|
||||
raise HTTPException(status_code=404, detail="灾后场景路径不存在")
|
||||
path = post.geo_path.replace("\\", "/")
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="请求的文件不存在")
|
||||
try:
|
||||
import asyncio
|
||||
png_bytes, geo_bounds = await asyncio.to_thread(_geo_raster_to_png_bytes, path)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"渲染失败: {e}")
|
||||
return JSONResponse({
|
||||
"image_b64": base64.b64encode(png_bytes).decode(),
|
||||
"bounds": geo_bounds,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/water/find-pairs")
|
||||
async def find_water_pairs(
|
||||
req: WaterPairRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""按时间范围查找满足重叠条件的灾前/灾后场景配对。"""
|
||||
# 查询灾前候选(status=DONE,日期在范围内)
|
||||
pre_filters = [SARSceneGeoORM.status == "DONE"]
|
||||
if req.pre_start:
|
||||
pre_filters.append(RadarDataORM.imaging_date >= req.pre_start)
|
||||
if req.pre_end:
|
||||
pre_filters.append(RadarDataORM.imaging_date <= req.pre_end)
|
||||
pre_result = await db.execute(
|
||||
select(SARSceneGeoORM, RadarDataORM)
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(*pre_filters)
|
||||
)
|
||||
pre_scenes = pre_result.all()
|
||||
|
||||
# 查询灾后候选
|
||||
post_filters = [SARSceneGeoORM.status == "DONE"]
|
||||
if req.post_start:
|
||||
post_filters.append(RadarDataORM.imaging_date >= req.post_start)
|
||||
if req.post_end:
|
||||
post_filters.append(RadarDataORM.imaging_date <= req.post_end)
|
||||
post_result = await db.execute(
|
||||
select(SARSceneGeoORM, RadarDataORM)
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(*post_filters)
|
||||
)
|
||||
post_scenes = post_result.all()
|
||||
|
||||
# 计算所有候选配对的重叠率
|
||||
candidates = []
|
||||
for pre_scene, pre_radar in pre_scenes:
|
||||
for post_scene, post_radar in post_scenes:
|
||||
# 同一景不能自配
|
||||
if pre_scene.id == post_scene.id:
|
||||
continue
|
||||
ratio = 0.0
|
||||
if pre_radar.coverage_polygon and post_radar.coverage_polygon:
|
||||
ratio = _overlap_ratio(pre_radar.coverage_polygon, post_radar.coverage_polygon)
|
||||
if ratio < req.overlap_threshold:
|
||||
continue
|
||||
try:
|
||||
pre_date = datetime.strptime(pre_radar.imaging_date, "%Y%m%d")
|
||||
post_date = datetime.strptime(post_radar.imaging_date, "%Y%m%d")
|
||||
time_diff = abs((post_date - pre_date).days)
|
||||
except Exception:
|
||||
time_diff = None
|
||||
candidates.append({
|
||||
"pre": {
|
||||
"id": pre_scene.id,
|
||||
"imaging_date": pre_radar.imaging_date,
|
||||
"satellite": pre_radar.satellite,
|
||||
"geo_path": pre_scene.geo_path,
|
||||
},
|
||||
"post": {
|
||||
"id": post_scene.id,
|
||||
"imaging_date": post_radar.imaging_date,
|
||||
"satellite": post_radar.satellite,
|
||||
"geo_path": post_scene.geo_path,
|
||||
},
|
||||
"overlap_ratio": round(ratio, 4),
|
||||
"time_diff_days": time_diff,
|
||||
})
|
||||
|
||||
# 按重叠率降序,贪心去重:每个 pre/post 场景只出现一次
|
||||
candidates.sort(key=lambda x: x["overlap_ratio"], reverse=True)
|
||||
used_pre, used_post = set(), set()
|
||||
pairs = []
|
||||
for c in candidates:
|
||||
pid, qid = c["pre"]["id"], c["post"]["id"]
|
||||
if pid in used_pre or qid in used_post:
|
||||
continue
|
||||
used_pre.add(pid)
|
||||
used_post.add(qid)
|
||||
pairs.append(c)
|
||||
|
||||
pairs.sort(key=lambda x: x["overlap_ratio"], reverse=True)
|
||||
return {"pairs": pairs, "total": len(pairs)}
|
||||
|
||||
|
||||
_UID_RE = _re.compile(r"_(\d{7,})$")
|
||||
|
||||
|
||||
@router.post("/water/sync-from-disk")
|
||||
async def sync_water_scenes_from_disk(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""扫描 WATER_RESULTS_DIR,把有 geo_db 但未入库的场景补录为 DONE。"""
|
||||
water_dir = settings.WATER_RESULTS_DIR
|
||||
if not os.path.isdir(water_dir):
|
||||
raise HTTPException(status_code=400, detail=f"WATER_RESULTS_DIR 不存在: {water_dir}")
|
||||
|
||||
# product_unique_id -> radar_data_id
|
||||
uid_rows = await db.execute(
|
||||
select(RadarDataORM.product_unique_id, RadarDataORM.id)
|
||||
.where(RadarDataORM.product_unique_id.isnot(None))
|
||||
)
|
||||
uid_to_radar_id: Dict[str, int] = {uid: rid for uid, rid in uid_rows.all() if uid}
|
||||
|
||||
# 已有 DONE 记录的 radar_data_id
|
||||
done_rows = await db.execute(
|
||||
select(SARSceneGeoORM.radar_data_id).where(SARSceneGeoORM.status == "DONE")
|
||||
)
|
||||
done_set = {rid for (rid,) in done_rows.all()}
|
||||
|
||||
inserted = 0
|
||||
skipped_no_geo_db = 0
|
||||
skipped_no_match = 0
|
||||
skipped_already_done = 0
|
||||
|
||||
for entry in os.scandir(water_dir):
|
||||
if not entry.is_dir() or not entry.name.startswith("scene_"):
|
||||
continue
|
||||
|
||||
# 找 *_geo_db 文件
|
||||
geo_db_path = None
|
||||
for f in os.scandir(entry.path):
|
||||
if f.name.endswith("_geo_db") and not f.name.endswith(".hdr") and not f.name.endswith(".sml"):
|
||||
geo_db_path = f.path
|
||||
break
|
||||
if not geo_db_path:
|
||||
skipped_no_geo_db += 1
|
||||
continue
|
||||
|
||||
m = _UID_RE.search(entry.name)
|
||||
if not m:
|
||||
skipped_no_match += 1
|
||||
continue
|
||||
raw_uid = m.group(1)
|
||||
stripped_uid = raw_uid.lstrip("0") or raw_uid
|
||||
radar_id = uid_to_radar_id.get(raw_uid) or uid_to_radar_id.get(stripped_uid)
|
||||
if not radar_id:
|
||||
skipped_no_match += 1
|
||||
continue
|
||||
|
||||
if radar_id in done_set:
|
||||
skipped_already_done += 1
|
||||
continue
|
||||
|
||||
# 检查是否已有非 DONE 记录(upsert)
|
||||
existing = await db.execute(
|
||||
select(SARSceneGeoORM).where(SARSceneGeoORM.radar_data_id == radar_id)
|
||||
)
|
||||
scene = existing.scalar_one_or_none()
|
||||
if scene:
|
||||
scene.status = "DONE"
|
||||
scene.geo_path = geo_db_path
|
||||
else:
|
||||
scene = SARSceneGeoORM(
|
||||
radar_data_id=radar_id,
|
||||
status="DONE",
|
||||
geo_path=geo_db_path,
|
||||
)
|
||||
db.add(scene)
|
||||
inserted += 1
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"inserted": inserted,
|
||||
"skipped_no_geo_db": skipped_no_geo_db,
|
||||
"skipped_no_match": skipped_no_match,
|
||||
"skipped_already_done": skipped_already_done,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Water body detection (Otsu + DEM + morphology)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _WaterDetectReq(BaseModel):
|
||||
scene_id: Optional[int] = Field(default=None, description="SARSceneGeoORM 主键(可选)")
|
||||
input_path: Optional[str] = Field(default=None, description="直接指定 GeoTIFF 路径(可选)")
|
||||
|
||||
|
||||
@router.post("/water/detect", status_code=202)
|
||||
async def submit_water_detect(
|
||||
req: _WaterDetectReq,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""提交水体检测任务(Otsu 自适应阈值 + DEM/坡度约束 + 形态学 + 连通分量过滤)。"""
|
||||
input_path = req.input_path
|
||||
scene_id = req.scene_id
|
||||
|
||||
if scene_id:
|
||||
scene = await db.get(SARSceneGeoORM, scene_id)
|
||||
if not scene:
|
||||
raise HTTPException(status_code=404, detail=f"SARSceneGeoORM id={scene_id} 不存在")
|
||||
if not scene.geo_path:
|
||||
raise HTTPException(status_code=400, detail="该场景尚未完成地理编码")
|
||||
# geo_path is ENVI format; try to find a GeoTIFF equivalent or use the path
|
||||
input_path = scene.geo_path
|
||||
|
||||
if not input_path:
|
||||
raise HTTPException(status_code=400, detail="必须提供 scene_id 或 input_path")
|
||||
|
||||
det = WaterDetectionORM(
|
||||
scene_id=scene_id,
|
||||
input_path=input_path,
|
||||
status="PENDING",
|
||||
)
|
||||
db.add(det)
|
||||
await db.flush()
|
||||
detection_id = det.id
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
return await _queue_water_job(
|
||||
job_type=JOB_TYPE_WATER_DETECT,
|
||||
task_type=f"WATER_DETECT_{detection_id}",
|
||||
task_name=f"水体检测 id={detection_id}",
|
||||
payload={"detection_id": detection_id},
|
||||
)
|
||||
except HTTPException:
|
||||
async with db.begin():
|
||||
d = await db.get(WaterDetectionORM, detection_id)
|
||||
if d and d.status == "PENDING":
|
||||
d.status = "FAILED"
|
||||
d.error_msg = "任务入队失败"
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/water/detections")
|
||||
async def list_water_detections(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
status: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""列出水体检测结果(分页)。"""
|
||||
from sqlalchemy import func
|
||||
|
||||
q = select(func.count()).select_from(WaterDetectionORM)
|
||||
if status:
|
||||
q = q.where(WaterDetectionORM.status == status)
|
||||
total = (await db.execute(q)).scalar_one()
|
||||
|
||||
q2 = select(WaterDetectionORM).order_by(WaterDetectionORM.id.desc()).limit(limit).offset(offset)
|
||||
if status:
|
||||
q2 = q2.where(WaterDetectionORM.status == status)
|
||||
rows = (await db.execute(q2)).scalars().all()
|
||||
|
||||
items = []
|
||||
for det in rows:
|
||||
items.append({
|
||||
"id": det.id,
|
||||
"scene_id": det.scene_id,
|
||||
"input_path": det.input_path,
|
||||
"output_path": det.output_path,
|
||||
"water_area_km2": det.water_area_km2,
|
||||
"water_pixel_count": det.water_pixel_count,
|
||||
"otsu_threshold_db": det.otsu_threshold_db,
|
||||
"status": det.status,
|
||||
"error_msg": det.error_msg,
|
||||
"created_at": det.created_at.isoformat() if det.created_at else None,
|
||||
"updated_at": det.updated_at.isoformat() if det.updated_at else None,
|
||||
})
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
@router.get("/water/detections/{detection_id}")
|
||||
async def get_water_detection(
|
||||
detection_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""获取单个水体检测详情。"""
|
||||
det = await db.get(WaterDetectionORM, detection_id)
|
||||
if not det:
|
||||
raise HTTPException(status_code=404, detail=f"检测记录 id={detection_id} 不存在")
|
||||
return {
|
||||
"id": det.id,
|
||||
"scene_id": det.scene_id,
|
||||
"input_path": det.input_path,
|
||||
"output_path": det.output_path,
|
||||
"water_area_km2": det.water_area_km2,
|
||||
"water_pixel_count": det.water_pixel_count,
|
||||
"otsu_threshold_db": det.otsu_threshold_db,
|
||||
"status": det.status,
|
||||
"error_msg": det.error_msg,
|
||||
"created_at": det.created_at.isoformat() if det.created_at else None,
|
||||
"updated_at": det.updated_at.isoformat() if det.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Water detection result preview (binary mask -> PNG)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/water/detections/{detection_id}/preview")
|
||||
async def get_water_detection_preview(
|
||||
detection_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""返回水体检测结果的 PNG 预览及地理范围。"""
|
||||
det = await db.get(WaterDetectionORM, detection_id)
|
||||
if not det:
|
||||
raise HTTPException(status_code=404, detail=f"检测记录 id={detection_id} 不存在")
|
||||
if not det.output_path or not os.path.isfile(det.output_path):
|
||||
raise HTTPException(status_code=404, detail="输出文件不存在")
|
||||
|
||||
import rasterio
|
||||
import io
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
with rasterio.open(det.output_path) as src:
|
||||
data = src.read(1)
|
||||
transform = src.transform
|
||||
h, w = data.shape
|
||||
min_lon = transform.c
|
||||
max_lon = transform.c + w * transform.a
|
||||
max_lat = transform.f
|
||||
min_lat = transform.f + h * transform.e
|
||||
|
||||
# Create RGBA image: water=blue semi-transparent, non-water=transparent
|
||||
rgba = np.zeros((data.shape[0], data.shape[1], 4), dtype=np.uint8)
|
||||
water_mask = data > 0
|
||||
rgba[water_mask] = [24, 144, 255, 160] # #1890ff with alpha
|
||||
|
||||
img = Image.fromarray(rgba, "RGBA")
|
||||
# Downsample if large
|
||||
max_dim = 1024
|
||||
if max(w, h) > max_dim:
|
||||
ratio = max_dim / max(w, h)
|
||||
img = img.resize((int(w * ratio), int(h * ratio)), Image.NEAREST)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
import base64
|
||||
png_b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
return {
|
||||
"png_base64": png_b64,
|
||||
"bounds": {
|
||||
"min_lon": min_lon, "min_lat": min_lat,
|
||||
"max_lon": max_lon, "max_lat": max_lat,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GF3 L1A -> L2 processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _GF3ProcessReq(BaseModel):
|
||||
input_dir: str = Field(..., description="GF3 L1A 数据目录路径")
|
||||
resolution: float = Field(default=0.0002, ge=0.00001, le=0.01, description="输出分辨率(度)")
|
||||
|
||||
|
||||
@router.post("/water/gf3-process", status_code=202)
|
||||
async def submit_gf3_process(
|
||||
req: _GF3ProcessReq,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: AuthUserORM = Depends(_require_admin),
|
||||
):
|
||||
"""提交 GF3 L1A→L2 处理任务(辐射定标 + RPC 几何校正)。"""
|
||||
if not os.path.exists(req.input_dir):
|
||||
raise HTTPException(status_code=400, detail=f"输入路径不存在: {req.input_dir}")
|
||||
|
||||
output_dir = os.path.join(settings.GF3_STORAGE_DIRS, f"gf3_{os.path.basename(req.input_dir)}")
|
||||
|
||||
proc = GF3ProcessingORM(
|
||||
input_dir=req.input_dir,
|
||||
output_dir=output_dir,
|
||||
resolution=req.resolution,
|
||||
status="PENDING",
|
||||
)
|
||||
db.add(proc)
|
||||
await db.flush()
|
||||
processing_id = proc.id
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
return await _queue_water_job(
|
||||
job_type=JOB_TYPE_GF3_PROCESS,
|
||||
task_type=f"GF3_PROCESS_{processing_id}",
|
||||
task_name=f"GF3 处理 id={processing_id}",
|
||||
payload={"processing_id": processing_id},
|
||||
)
|
||||
except HTTPException:
|
||||
async with db.begin():
|
||||
p = await db.get(GF3ProcessingORM, processing_id)
|
||||
if p and p.status == "PENDING":
|
||||
p.status = "FAILED"
|
||||
p.error_msg = "任务入队失败"
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/water/gf3-results")
|
||||
async def list_gf3_results(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""列出 GF3 处理结果(分页)。"""
|
||||
from sqlalchemy import func
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count()).select_from(GF3ProcessingORM)
|
||||
)).scalar_one()
|
||||
|
||||
rows = (await db.execute(
|
||||
select(GF3ProcessingORM).order_by(GF3ProcessingORM.id.desc()).limit(limit).offset(offset)
|
||||
)).scalars().all()
|
||||
|
||||
items = []
|
||||
for proc in rows:
|
||||
items.append({
|
||||
"id": proc.id,
|
||||
"input_dir": proc.input_dir,
|
||||
"output_dir": proc.output_dir,
|
||||
"polarizations": proc.polarizations,
|
||||
"l2_paths": proc.l2_paths,
|
||||
"resolution": proc.resolution,
|
||||
"status": proc.status,
|
||||
"error_msg": proc.error_msg,
|
||||
"created_at": proc.created_at.isoformat() if proc.created_at else None,
|
||||
"updated_at": proc.updated_at.isoformat() if proc.updated_at else None,
|
||||
})
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
@router.get("/water/gf3-results/{result_id}")
|
||||
async def get_gf3_result(
|
||||
result_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthUserORM = Depends(_get_current_user),
|
||||
):
|
||||
"""获取单个 GF3 处理结果详情。"""
|
||||
proc = await db.get(GF3ProcessingORM, result_id)
|
||||
if not proc:
|
||||
raise HTTPException(status_code=404, detail=f"GF3 处理记录 id={result_id} 不存在")
|
||||
return {
|
||||
"id": proc.id,
|
||||
"input_dir": proc.input_dir,
|
||||
"output_dir": proc.output_dir,
|
||||
"polarizations": proc.polarizations,
|
||||
"l2_paths": proc.l2_paths,
|
||||
"resolution": proc.resolution,
|
||||
"status": proc.status,
|
||||
"error_msg": proc.error_msg,
|
||||
"created_at": proc.created_at.isoformat() if proc.created_at else None,
|
||||
"updated_at": proc.updated_at.isoformat() if proc.updated_at else None,
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from ..config import read_int_env
|
||||
from ..database import get_db
|
||||
from ..models import AuthUserORM, WorkflowRunORM, WorkflowStepORM
|
||||
from ..services.workflow_service import workflow_service
|
||||
from .dependencies import _require_admin
|
||||
|
||||
router = APIRouter()
|
||||
WORKFLOW_MAX_STEPS = read_int_env(
|
||||
"WORKFLOW_MAX_STEPS",
|
||||
200,
|
||||
minimum=1,
|
||||
maximum=5000,
|
||||
)
|
||||
WORKFLOW_MAX_DEPENDS = read_int_env(
|
||||
"WORKFLOW_MAX_DEPENDS",
|
||||
32,
|
||||
minimum=0,
|
||||
maximum=500,
|
||||
)
|
||||
WORKFLOW_TEXT_MAX_LENGTH = read_int_env(
|
||||
"WORKFLOW_TEXT_MAX_LENGTH",
|
||||
128,
|
||||
minimum=16,
|
||||
maximum=1024,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowStepCreate(BaseModel):
|
||||
step_id: str = Field(max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
step_name: Optional[str] = Field(default=None, max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
job_type: str = Field(max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
payload: Optional[dict] = None
|
||||
depends_on: List[str] = Field(default_factory=list)
|
||||
optional: bool = False
|
||||
task_id: Optional[str] = Field(default=None, max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
|
||||
@field_validator("step_id", "job_type", mode="before")
|
||||
@classmethod
|
||||
def _normalize_required_text(cls, value):
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("Field must not be empty.")
|
||||
return normalized
|
||||
|
||||
@field_validator("step_name", "task_id", mode="before")
|
||||
@classmethod
|
||||
def _normalize_optional_text(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
return normalized or None
|
||||
|
||||
@field_validator("depends_on", mode="before")
|
||||
@classmethod
|
||||
def _validate_depends_on(cls, value):
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("depends_on must be a list.")
|
||||
if len(value) > WORKFLOW_MAX_DEPENDS:
|
||||
raise ValueError(f"depends_on exceeds max count ({WORKFLOW_MAX_DEPENDS}).")
|
||||
|
||||
normalized: List[str] = []
|
||||
for raw in value:
|
||||
dep = str(raw or "").strip()
|
||||
if not dep:
|
||||
continue
|
||||
if len(dep) > WORKFLOW_TEXT_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"depends_on item is too long (max {WORKFLOW_TEXT_MAX_LENGTH} chars)."
|
||||
)
|
||||
if dep not in normalized:
|
||||
normalized.append(dep)
|
||||
return normalized
|
||||
|
||||
|
||||
class WorkflowRunCreate(BaseModel):
|
||||
workflow_name: str = Field(max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
steps: List[WorkflowStepCreate]
|
||||
params: Optional[dict] = None
|
||||
tags: Optional[dict] = None
|
||||
created_by: Optional[str] = Field(default=None, max_length=WORKFLOW_TEXT_MAX_LENGTH)
|
||||
|
||||
@field_validator("workflow_name", mode="before")
|
||||
@classmethod
|
||||
def _normalize_workflow_name(cls, value):
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("workflow_name must not be empty.")
|
||||
return normalized
|
||||
|
||||
@field_validator("created_by", mode="before")
|
||||
@classmethod
|
||||
def _normalize_created_by(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
return normalized or None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_steps(self):
|
||||
if not self.steps:
|
||||
raise ValueError("steps must not be empty.")
|
||||
if len(self.steps) > WORKFLOW_MAX_STEPS:
|
||||
raise ValueError(f"steps exceeds max count ({WORKFLOW_MAX_STEPS}).")
|
||||
|
||||
step_ids = [step.step_id for step in self.steps]
|
||||
if len(step_ids) != len(set(step_ids)):
|
||||
raise ValueError("step_id values must be unique within one workflow run.")
|
||||
|
||||
step_id_set = set(step_ids)
|
||||
graph = {}
|
||||
for step in self.steps:
|
||||
graph[step.step_id] = list(step.depends_on or [])
|
||||
for dep in step.depends_on:
|
||||
if dep not in step_id_set:
|
||||
raise ValueError(
|
||||
f"Step '{step.step_id}' depends on unknown step_id '{dep}'."
|
||||
)
|
||||
if dep == step.step_id:
|
||||
raise ValueError(
|
||||
f"Step '{step.step_id}' cannot depend on itself."
|
||||
)
|
||||
|
||||
# Detect dependency cycles early to avoid runs stuck in PENDING forever.
|
||||
visited = set()
|
||||
in_stack = set()
|
||||
|
||||
def _dfs(node: str) -> None:
|
||||
if node in in_stack:
|
||||
raise ValueError(f"Workflow dependency cycle detected at step '{node}'.")
|
||||
if node in visited:
|
||||
return
|
||||
in_stack.add(node)
|
||||
for dep in graph.get(node, []):
|
||||
_dfs(dep)
|
||||
in_stack.remove(node)
|
||||
visited.add(node)
|
||||
|
||||
for step_id in step_ids:
|
||||
_dfs(step_id)
|
||||
return self
|
||||
|
||||
|
||||
@router.post("/workflow/runs")
|
||||
async def create_workflow_run(request: WorkflowRunCreate, admin_user: AuthUserORM = Depends(_require_admin)):
|
||||
"""
|
||||
创建一个工作流实例并自动入队可执行步骤。
|
||||
"""
|
||||
steps_payload = [
|
||||
{
|
||||
"step_id": s.step_id,
|
||||
"step_name": s.step_name or s.step_id,
|
||||
"job_type": s.job_type,
|
||||
"payload": s.payload or {},
|
||||
"depends_on": s.depends_on or [],
|
||||
"optional": s.optional,
|
||||
"task_id": s.task_id,
|
||||
}
|
||||
for s in request.steps
|
||||
]
|
||||
try:
|
||||
run_id = await workflow_service.create_run(
|
||||
workflow_name=request.workflow_name,
|
||||
steps=steps_payload,
|
||||
params=request.params,
|
||||
tags=request.tags,
|
||||
created_by=request.created_by,
|
||||
)
|
||||
return {"run_id": run_id}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/workflow/runs/{run_id}")
|
||||
async def get_workflow_run(run_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取工作流运行状态与步骤列表。
|
||||
"""
|
||||
run_res = await db.execute(
|
||||
select(WorkflowRunORM).where(WorkflowRunORM.run_id == run_id)
|
||||
)
|
||||
run = run_res.scalar_one_or_none()
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Workflow run not found.")
|
||||
|
||||
steps_res = await db.execute(
|
||||
select(WorkflowStepORM).where(WorkflowStepORM.run_id == run_id).order_by(WorkflowStepORM.id)
|
||||
)
|
||||
steps = steps_res.scalars().all()
|
||||
|
||||
return {
|
||||
"run": {
|
||||
"run_id": run.run_id,
|
||||
"workflow_name": run.workflow_name,
|
||||
"status": run.status,
|
||||
"params": run.params,
|
||||
"tags": run.tags,
|
||||
"created_by": run.created_by,
|
||||
"created_at": run.created_at,
|
||||
"updated_at": run.updated_at,
|
||||
"started_at": run.started_at,
|
||||
"ended_at": run.ended_at,
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"step_id": s.step_id,
|
||||
"step_name": s.step_name,
|
||||
"status": s.status,
|
||||
"depends_on": s.depends_on,
|
||||
"params": s.params,
|
||||
"outputs": s.outputs,
|
||||
"error": s.error,
|
||||
"created_at": s.created_at,
|
||||
"updated_at": s.updated_at,
|
||||
"started_at": s.started_at,
|
||||
"ended_at": s.ended_at,
|
||||
}
|
||||
for s in steps
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user