chore: initialize insar management system v2

This commit is contained in:
2026-04-14 13:16:01 +08:00
commit ecc72ec9cd
361 changed files with 2142522 additions and 0 deletions
+60
View File
@@ -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)
+402
View File
@@ -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}")
+26
View File
@@ -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)
+309
View File
@@ -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
+413
View File
@@ -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
+216
View File
@@ -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)}
+261
View File
@@ -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")
+58
View File
@@ -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()
+39
View File
@@ -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()
+332
View File
@@ -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
+162
View File
@@ -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
+189
View File
@@ -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="删除日志失败,请查看后端日志")
+186
View File
@@ -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,
}
+244
View File
@@ -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"],
}
+252
View File
@@ -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}")
+145
View File
@@ -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")
+709
View File
@@ -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
+64
View File
@@ -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)
+549
View File
@@ -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,
},
}
+434
View File
@@ -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)
+129
View File
@@ -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
+194
View File
@@ -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]
}
+42
View File
@@ -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))
+993
View File
@@ -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,
}
+230
View File
@@ -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
],
}