Files
insar-management-system-v2/backend/app/routers/dependencies.py
T

1037 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Shared dependencies, guards, and helper utilities for all routers.
"""
from __future__ import annotations
import asyncio
import json
import os
import tempfile
import time
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from fastapi import Depends, File, HTTPException, Request, UploadFile
from geoalchemy2.functions import ST_Intersects
from shapely.geometry import mapping, shape
from shapely.ops import unary_union
from sqlalchemy import delete, inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..auth_service import (
ROLE_ADMIN,
SESSION_COOKIE_NAME,
add_audit_log,
get_user_by_session_token,
)
from ..config import read_int_env, settings
# Trusted proxy IPs whose X-Forwarded-For header is accepted.
# Defaults to localhost (nginx on the same host). Override via TRUSTED_PROXY_IPS env var
# (comma-separated, e.g. "127.0.0.1,10.0.0.1").
_TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
ip.strip()
for ip in (settings.TRUSTED_PROXY_IPS or "127.0.0.1").split(",")
if ip.strip()
)
from ..database import get_db
from ..license_service import check_license
from ..models import AuthRateLimitORM, AuthUserORM, DinsarTaskBatchORM, DinsarTaskItemORM, PsTaskBatchORM, PsTaskItemORM
# ---------------------------------------------------------------------------
# Path classification constants
# ---------------------------------------------------------------------------
_LICENSE_EXEMPT_PATHS = {
"/api/license/status",
"/api/license/upload",
"/api/license/refresh",
"/api/health",
"/api/auth/login",
"/api/auth/logout",
"/api/auth/me",
}
READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
READ_SAFE_POST_PATHS = {
"/api/radar-data/search",
}
PUBLIC_AUTH_PATHS = {
"/api/license/status",
"/api/health",
"/api/auth/login",
"/api/auth/logout",
}
HIGH_RISK_WRITE_PATH_PREFIXES = (
"/api/auth/users",
"/api/license/upload",
"/api/license/refresh",
"/api/workflow/runs",
"/api/task-batches/",
"/api/tools/",
"/api/unpack/run",
"/api/monitor/",
"/api/scan-data",
"/api/scan-dinsar-results",
"/api/dinsar-results/",
"/api/dinsar-production",
"/api/sbas-insar-production",
"/api/ai/",
"/api/idl/launch-workbench",
"/api/idl/worker/",
"/api/hazard-points/scan",
"/api/radar-data/",
)
# ---------------------------------------------------------------------------
# Login throttle constants & locks
# ---------------------------------------------------------------------------
AUTH_LOGIN_MAX_FAILURES = read_int_env("AUTH_LOGIN_MAX_FAILURES", 5, minimum=1, maximum=100)
AUTH_LOGIN_WINDOW_SECONDS = read_int_env("AUTH_LOGIN_WINDOW_SECONDS", 900, minimum=5, maximum=86400)
AUTH_LOGIN_LOCK_SECONDS = read_int_env("AUTH_LOGIN_LOCK_SECONDS", 900, minimum=5, maximum=86400)
LOGIN_THROTTLE_CLEANUP_INTERVAL_SECONDS = read_int_env(
"AUTH_LOGIN_CLEANUP_INTERVAL_SECONDS",
300,
minimum=30,
maximum=3600,
)
_LOGIN_THROTTLE_LOCK = asyncio.Lock()
_LOGIN_THROTTLE_LAST_CLEANUP_MONO = 0.0
_LICENSE_UPLOAD_LOCK = asyncio.Lock()
MAX_LICENSE_UPLOAD_BYTES = read_int_env(
"MAX_LICENSE_UPLOAD_BYTES",
1024 * 1024,
minimum=1024,
maximum=20 * 1024 * 1024,
)
# ---------------------------------------------------------------------------
# Statistics cache
# ---------------------------------------------------------------------------
STATS_CACHE_TTL_SECONDS = read_int_env(
"STATS_CACHE_TTL_SECONDS",
120,
minimum=0,
maximum=3600,
)
_STATS_CACHE_LOCK = asyncio.Lock()
_STATS_CACHE_DATA: Optional[Dict[str, Any]] = None
_STATS_CACHE_EXPIRES_AT = 0.0
_STATS_CACHE_GENERATED_AT_UTC: Optional[str] = None
# ---------------------------------------------------------------------------
# AOI token store
# ---------------------------------------------------------------------------
_AOI_TOKEN_STORE: Dict[str, Dict[str, Any]] = {}
_AOI_TOKEN_LOCK = asyncio.Lock()
AOI_TOKEN_TTL_SECONDS = read_int_env(
"AOI_TOKEN_TTL_SECONDS",
1800,
minimum=60,
maximum=24 * 3600,
)
AOI_UPLOAD_MAX_FILES = read_int_env(
"AOI_UPLOAD_MAX_FILES",
10,
minimum=1,
maximum=200,
)
AOI_UPLOAD_MAX_SINGLE_FILE_BYTES = read_int_env(
"AOI_UPLOAD_MAX_SINGLE_FILE_BYTES",
20 * 1024 * 1024,
minimum=1024,
maximum=500 * 1024 * 1024,
)
AOI_UPLOAD_MAX_TOTAL_BYTES = max(
read_int_env(
"AOI_UPLOAD_MAX_TOTAL_BYTES",
100 * 1024 * 1024,
minimum=1024,
maximum=2 * 1024 * 1024 * 1024,
),
AOI_UPLOAD_MAX_SINGLE_FILE_BYTES,
)
AOI_UPLOAD_STREAM_CHUNK_BYTES = 1024 * 1024
_SHAPEFILE_READ_LOCK = asyncio.Lock()
# ---------------------------------------------------------------------------
# Region index caches
# ---------------------------------------------------------------------------
_REGION_CHILDREN_CACHE: Optional[Dict[str, List[Dict[str, Any]]]] = None
_REGION_BY_ID_CACHE: Optional[Dict[str, Dict[str, Any]]] = None
_REGION_GEOMETRY_BY_ID_CACHE: Optional[Dict[str, List[Dict[str, Any]]]] = None
# ---------------------------------------------------------------------------
# Export/root-dir path validation
# ---------------------------------------------------------------------------
import logging as _logging
_dep_logger = _logging.getLogger(__name__)
# Optional whitelist of allowed export directories.
# Set ALLOWED_EXPORT_DIRS in .env as comma-separated paths to restrict exports.
_ALLOWED_EXPORT_DIRS_RAW = (settings.ALLOWED_EXPORT_DIRS or "").strip()
ALLOWED_EXPORT_DIRS: Optional[List[str]] = None
if _ALLOWED_EXPORT_DIRS_RAW:
ALLOWED_EXPORT_DIRS = [
os.path.normpath(p.strip()) for p in _ALLOWED_EXPORT_DIRS_RAW.split(",") if p.strip()
]
_SYSTEM_DIRS_WIN = {
os.path.normpath(p)
for p in ["C:\\Windows", "C:\\Program Files", "C:\\Program Files (x86)", "C:\\ProgramData"]
}
_SYSTEM_DIRS_UNIX = {"/bin", "/sbin", "/usr", "/etc", "/lib", "/lib64", "/boot", "/proc", "/sys"}
def _is_system_directory(path: str) -> bool:
"""Check if path points to a system directory."""
normed = os.path.normpath(path)
for sd in _SYSTEM_DIRS_WIN | _SYSTEM_DIRS_UNIX:
if normed == sd or normed.startswith(sd + os.sep):
return True
return False
def _validate_export_path(path: str, param_name: str = "target_dir") -> str:
"""
Validate an export/destination path:
- Rejects path traversal (..)
- Rejects system directories
- Optionally checks against ALLOWED_EXPORT_DIRS whitelist
Returns the normalized path.
"""
if not path or not path.strip():
raise HTTPException(status_code=400, detail=f"{param_name} 不能为空")
normed = os.path.normpath(path.strip())
if ".." in normed.split(os.sep):
raise HTTPException(status_code=400, detail=f"{param_name} 包含非法路径遍历")
if _is_system_directory(normed):
raise HTTPException(status_code=400, detail=f"{param_name} 指向系统目录,操作被拒绝")
if ALLOWED_EXPORT_DIRS is not None:
if not any(normed.startswith(allowed) for allowed in ALLOWED_EXPORT_DIRS):
raise HTTPException(status_code=400, detail=f"{param_name} 不在允许的导出目录内")
return normed
def _validate_root_dir(path: str, param_name: str = "root_dir") -> str:
"""
Validate a root directory parameter:
- Rejects path traversal (..)
- Rejects system directories
- Verifies directory exists
Returns the normalized path.
"""
if not path or not path.strip():
raise HTTPException(status_code=400, detail=f"{param_name} 不能为空")
normed = os.path.normpath(path.strip())
if ".." in normed.split(os.sep):
raise HTTPException(status_code=400, detail=f"{param_name} 包含非法路径遍历")
if _is_system_directory(normed):
raise HTTPException(status_code=400, detail=f"{param_name} 指向系统目录,操作被拒绝")
if not os.path.isdir(normed):
raise HTTPException(status_code=400, detail=f"{param_name} 目录不存在")
return normed
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
def _normalize_request_path(path: str) -> str:
normalized = (path or "").rstrip("/")
return normalized or "/"
def _format_size_limit(size_bytes: int) -> str:
if size_bytes >= 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
if size_bytes >= 1024:
return f"{size_bytes / 1024:.1f} KB"
return f"{size_bytes} B"
def _is_public_auth_path(path: str) -> bool:
return _normalize_request_path(path) in PUBLIC_AUTH_PATHS
def _is_read_safe_post_path(path: str, method: str) -> bool:
return (method or "").upper() == "POST" and _normalize_request_path(path) in READ_SAFE_POST_PATHS
def _is_read_only_operation(path: str, method: str) -> bool:
upper_method = (method or "").upper()
if upper_method in READ_ONLY_METHODS:
return True
return _is_read_safe_post_path(path, upper_method)
def _is_high_risk_write_path(path: str, method: str) -> bool:
normalized = _normalize_request_path(path)
upper_method = (method or "").upper()
if _is_read_only_operation(normalized, upper_method):
return False
return any(normalized.startswith(prefix) for prefix in HIGH_RISK_WRITE_PATH_PREFIXES)
def _get_client_ip(request: Request) -> Optional[str]:
direct_ip = request.client.host if request.client else None
if direct_ip in _TRUSTED_PROXY_IPS:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return direct_ip
# ---------------------------------------------------------------------------
# Audit log helper
# ---------------------------------------------------------------------------
async def _add_operation_audit_log(
db: AsyncSession,
request: Request,
action: str,
resource: str,
detail: Optional[Dict[str, Any]] = None,
user: Optional[AuthUserORM] = None,
user_id: Optional[int] = None,
username: Optional[str] = None,
) -> None:
actor = user or getattr(request.state, "current_user", None)
resolved_user_id = user_id
resolved_username = username
if actor is not None:
if resolved_user_id is None:
try:
identity = sa_inspect(actor).identity
if identity and len(identity) > 0:
resolved_user_id = int(identity[0])
except Exception:
try:
resolved_user_id = actor.id
except Exception:
resolved_user_id = None
if resolved_username is None:
try:
actor_state_dict = sa_inspect(actor).dict
resolved_username = actor_state_dict.get("username")
except Exception:
try:
resolved_username = actor.username
except Exception:
resolved_username = None
await add_audit_log(
db,
action=action,
user=None,
user_id=resolved_user_id,
username=resolved_username,
resource=resource,
detail=detail or None,
ip_address=_get_client_ip(request),
)
# ---------------------------------------------------------------------------
# License guard
# ---------------------------------------------------------------------------
def _require_license(request: Request):
"""
授权校验:未授权时拒绝所有 API。
使用精确路径集合匹配,防止 endswith 绕过攻击。
"""
path = (request.url.path or "").rstrip("/") or "/"
if path in _LICENSE_EXEMPT_PATHS:
return
result = check_license()
if not result.get("ok"):
raise HTTPException(status_code=403, detail=f"License required: {result.get('reason')}")
# ---------------------------------------------------------------------------
# Auth guards
# ---------------------------------------------------------------------------
async def _require_auth(request: Request, db: AsyncSession = Depends(get_db)):
path = _normalize_request_path(request.url.path)
if _is_public_auth_path(path):
return
method = request.method.upper()
token = request.cookies.get(SESSION_COOKIE_NAME)
user = await get_user_by_session_token(db, token)
if not user:
if _is_high_risk_write_path(path, method):
await add_audit_log(
db,
action="write_auth_required",
resource=path,
detail={"method": method},
ip_address=_get_client_ip(request),
)
await db.commit()
raise HTTPException(status_code=401, detail="Authentication required.")
if (not _is_read_only_operation(path, method)) and user.role != ROLE_ADMIN:
if _is_high_risk_write_path(path, method):
await add_audit_log(
db,
action="write_blocked_readonly",
user=user,
resource=path,
detail={"method": method, "role": user.role},
ip_address=_get_client_ip(request),
)
await db.commit()
raise HTTPException(status_code=403, detail="Read-only account cannot perform this operation.")
if _is_high_risk_write_path(path, method):
await add_audit_log(
db,
action="write_access_granted",
user=user,
resource=path,
detail={"method": method},
ip_address=_get_client_ip(request),
)
await db.commit()
refreshed_user = await get_user_by_session_token(db, token)
if not refreshed_user:
raise HTTPException(status_code=401, detail="Authentication required.")
user = refreshed_user
request.state.current_user = user
async def _get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> AuthUserORM:
user = getattr(request.state, "current_user", None)
if user:
return user
token = request.cookies.get(SESSION_COOKIE_NAME)
user = await get_user_by_session_token(db, token)
if not user:
raise HTTPException(status_code=401, detail="Authentication required.")
request.state.current_user = user
return user
async def _get_optional_session_user(
request: Request,
db: AsyncSession,
) -> Optional[AuthUserORM]:
cached_user = getattr(request.state, "current_user", None)
if cached_user is not None:
return cached_user
token = request.cookies.get(SESSION_COOKIE_NAME)
user = await get_user_by_session_token(db, token)
if user is not None:
request.state.current_user = user
return user
async def _require_admin(current_user: AuthUserORM = Depends(_get_current_user)) -> AuthUserORM:
if current_user.role != ROLE_ADMIN:
raise HTTPException(status_code=403, detail="Admin role required.")
return current_user
# ---------------------------------------------------------------------------
# Login throttle helpers
# ---------------------------------------------------------------------------
def _build_login_throttle_key(username: str, client_ip: Optional[str]) -> str:
# Throttle by username only to prevent IP-switching bypass
normalized_username = (username or "").strip().lower() or "<empty>"
return f"user:{normalized_username}"
def _remaining_seconds(deadline: datetime, now: datetime) -> int:
remaining = max(0.0, (deadline - now).total_seconds())
rounded = int(remaining)
if rounded < remaining:
rounded += 1
return max(1, rounded)
def _login_throttle_retention_seconds() -> int:
return max(AUTH_LOGIN_WINDOW_SECONDS, AUTH_LOGIN_LOCK_SECONDS) * 2
async def _maybe_cleanup_login_throttle_records(db: AsyncSession) -> None:
global _LOGIN_THROTTLE_LAST_CLEANUP_MONO
if LOGIN_THROTTLE_CLEANUP_INTERVAL_SECONDS <= 0:
return
now_mono = time.monotonic()
if (
_LOGIN_THROTTLE_LAST_CLEANUP_MONO > 0
and now_mono - _LOGIN_THROTTLE_LAST_CLEANUP_MONO < LOGIN_THROTTLE_CLEANUP_INTERVAL_SECONDS
):
return
now = datetime.now(timezone.utc).replace(tzinfo=None)
cutoff = now - timedelta(seconds=_login_throttle_retention_seconds())
await db.execute(
delete(AuthRateLimitORM).where(
AuthRateLimitORM.updated_at < cutoff,
(AuthRateLimitORM.locked_until.is_(None))
| (AuthRateLimitORM.locked_until <= now),
)
)
_LOGIN_THROTTLE_LAST_CLEANUP_MONO = now_mono
async def _get_login_retry_after_seconds(throttle_key: str, db: AsyncSession) -> int:
async with _LOGIN_THROTTLE_LOCK:
await _maybe_cleanup_login_throttle_records(db)
now = datetime.now(timezone.utc).replace(tzinfo=None)
result = await db.execute(
select(AuthRateLimitORM).where(AuthRateLimitORM.throttle_key == throttle_key)
)
record = result.scalar_one_or_none()
if not record or not record.locked_until:
return 0
if record.locked_until <= now:
return 0
return _remaining_seconds(record.locked_until, now)
async def _record_login_failure(throttle_key: str, db: AsyncSession) -> int:
async with _LOGIN_THROTTLE_LOCK:
await _maybe_cleanup_login_throttle_records(db)
now = datetime.now(timezone.utc).replace(tzinfo=None)
failure_cutoff = now - timedelta(seconds=AUTH_LOGIN_WINDOW_SECONDS)
result = await db.execute(
select(AuthRateLimitORM).where(AuthRateLimitORM.throttle_key == throttle_key)
)
record = result.scalar_one_or_none()
if record is None:
record = AuthRateLimitORM(throttle_key=throttle_key, failure_timestamps=[])
db.add(record)
timestamps = [ts for ts in (record.failure_timestamps or []) if ts > failure_cutoff.timestamp()]
timestamps.append(now.timestamp())
if len(timestamps) >= AUTH_LOGIN_MAX_FAILURES:
locked_until = now + timedelta(seconds=AUTH_LOGIN_LOCK_SECONDS)
record.locked_until = locked_until
record.failure_timestamps = []
await db.flush()
return _remaining_seconds(locked_until, now)
record.failure_timestamps = timestamps
record.locked_until = None
await db.flush()
return 0
async def _clear_login_failure_state(throttle_key: str, db: AsyncSession) -> None:
async with _LOGIN_THROTTLE_LOCK:
result = await db.execute(
select(AuthRateLimitORM).where(AuthRateLimitORM.throttle_key == throttle_key)
)
record = result.scalar_one_or_none()
if record is not None:
record.failure_timestamps = []
record.locked_until = None
await db.flush()
# ---------------------------------------------------------------------------
# Batch summary helpers
# ---------------------------------------------------------------------------
async def _refresh_dinsar_batch_summary(db: AsyncSession, batch_id: str) -> None:
from sqlalchemy import func
total_res = await db.execute(
select(func.count(DinsarTaskItemORM.id)).where(DinsarTaskItemORM.batch_id == batch_id)
)
completed_res = await db.execute(
select(func.count(DinsarTaskItemORM.id)).where(
DinsarTaskItemORM.batch_id == batch_id,
func.upper(func.coalesce(DinsarTaskItemORM.status, "")) == "COMPLETED",
)
)
total = total_res.scalar_one() or 0
completed = completed_res.scalar_one() or 0
status = "PENDING"
if total > 0 and completed == total:
status = "COMPLETED"
elif completed > 0:
status = "IN_PROGRESS"
await db.execute(
DinsarTaskBatchORM.__table__.update()
.where(DinsarTaskBatchORM.batch_id == batch_id)
.values(total_items=total, completed_items=completed, status=status)
)
async def _refresh_ps_batch_summary(db: AsyncSession, batch_id: str) -> None:
from sqlalchemy import func
total_res = await db.execute(
select(func.count(PsTaskItemORM.id)).where(PsTaskItemORM.batch_id == batch_id)
)
completed_res = await db.execute(
select(func.count(PsTaskItemORM.id)).where(
PsTaskItemORM.batch_id == batch_id,
func.upper(func.coalesce(PsTaskItemORM.status, "")) == "COMPLETED",
)
)
total = total_res.scalar_one() or 0
completed = completed_res.scalar_one() or 0
status = "PENDING"
if total > 0 and completed == total:
status = "COMPLETED"
elif completed > 0:
status = "IN_PROGRESS"
await db.execute(
PsTaskBatchORM.__table__.update()
.where(PsTaskBatchORM.batch_id == batch_id)
.values(total_items=total, completed_items=completed, status=status)
)
# ---------------------------------------------------------------------------
# AOI helpers
# ---------------------------------------------------------------------------
def _infer_region_level(tree_id: str) -> str:
depth = len((tree_id or "").split("-"))
if depth == 1:
return "country"
if depth == 2:
return "province"
if depth == 3:
return "city"
if depth == 4:
return "district"
return "unknown"
def _normalize_region_node(raw: Dict[str, Any]) -> Optional[Dict[str, Any]]:
tree_id = str(raw.get("treeID") or "").strip()
if not tree_id:
return None
parent_raw = raw.get("parent")
parent_tree_id = str(parent_raw).strip() if parent_raw is not None else None
if parent_tree_id == "":
parent_tree_id = None
name = str(raw.get("name") or tree_id).strip()
return {
"tree_id": tree_id,
"parent_tree_id": parent_tree_id,
"name": name,
"level": _infer_region_level(tree_id),
}
def _load_region_index() -> None:
global _REGION_CHILDREN_CACHE, _REGION_BY_ID_CACHE
if _REGION_CHILDREN_CACHE is not None and _REGION_BY_ID_CACHE is not None:
return
index_path = Path(settings.AOI_REGION_INDEX_FILE or "")
if not index_path.is_file():
raise HTTPException(
status_code=503,
detail=f"行政区索引文件不存在,请检查 AOI_REGION_INDEX_FILE: {index_path}",
)
try:
raw_data = json.loads(index_path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=500, detail=f"加载行政区索引失败: {exc}") from exc
if not isinstance(raw_data, list):
raise HTTPException(status_code=500, detail="行政区索引文件格式错误:应为 JSON 数组。")
children_cache: Dict[str, List[Dict[str, Any]]] = {}
node_cache: Dict[str, Dict[str, Any]] = {}
for item in raw_data:
if not isinstance(item, dict):
continue
normalized = _normalize_region_node(item)
if not normalized:
continue
tree_id = normalized["tree_id"]
parent_tree_id = normalized["parent_tree_id"]
node_cache[tree_id] = normalized
if parent_tree_id:
children_cache.setdefault(parent_tree_id, []).append(normalized)
for parent_tree_id in children_cache:
children_cache[parent_tree_id].sort(key=lambda row: row["tree_id"])
_REGION_CHILDREN_CACHE = children_cache
_REGION_BY_ID_CACHE = node_cache
def _load_region_geometry_index() -> None:
global _REGION_GEOMETRY_BY_ID_CACHE
if _REGION_GEOMETRY_BY_ID_CACHE is not None:
return
geometry_path = Path(settings.AOI_REGION_GEOJSON_FILE or "")
if not geometry_path.is_file():
raise HTTPException(
status_code=503,
detail=(
"行政区边界文件不存在,请准备标准 GeoJSONFeatureCollection),"
f"并配置 AOI_REGION_GEOJSON_FILE: {geometry_path}"
),
)
try:
raw_data = json.loads(geometry_path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=500, detail=f"加载行政区边界数据失败: {exc}") from exc
if isinstance(raw_data, dict) and raw_data.get("type") == "FeatureCollection":
features = raw_data.get("features", [])
elif isinstance(raw_data, list):
features = raw_data
else:
raise HTTPException(status_code=500, detail="行政区边界文件格式错误:应为 FeatureCollection 或 Feature 数组。")
feature_index: Dict[str, List[Dict[str, Any]]] = {}
for feature in features:
if not isinstance(feature, dict):
continue
if feature.get("type") != "Feature":
continue
properties = feature.get("properties") or {}
tree_id = str(
properties.get("treeID")
or properties.get("tree_id")
or properties.get("treeId")
or ""
).strip()
if not tree_id:
continue
feature_index.setdefault(tree_id, []).append(feature)
if not feature_index:
raise HTTPException(
status_code=500,
detail="行政区边界文件中未找到可用 treeID 字段(支持 treeID/tree_id/treeId)。",
)
_REGION_GEOMETRY_BY_ID_CACHE = feature_index
def _normalize_geojson_to_feature_collection(payload: Any) -> Dict[str, Any]:
if not payload:
raise HTTPException(status_code=400, detail="AOI GeoJSON 不能为空。")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="AOI GeoJSON 必须是对象。")
payload_type = payload.get("type")
if payload_type == "FeatureCollection":
features = payload.get("features") or []
if not isinstance(features, list):
raise HTTPException(status_code=400, detail="FeatureCollection.features 必须是数组。")
return {"type": "FeatureCollection", "features": features}
if payload_type == "Feature":
return {"type": "FeatureCollection", "features": [payload]}
if payload_type in {"Polygon", "MultiPolygon", "LineString", "MultiLineString", "Point", "MultiPoint"}:
return {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": payload}]}
raise HTTPException(status_code=400, detail=f"不支持的 AOI GeoJSON 类型: {payload_type}")
def _feature_collection_to_union_geometry(feature_collection: Dict[str, Any]):
features = feature_collection.get("features") or []
geometries = []
for feature in features:
if not isinstance(feature, dict):
continue
geometry = feature.get("geometry")
if not geometry:
continue
try:
geom = shape(geometry)
except Exception:
continue
if geom.is_empty:
continue
geometries.append(geom)
if not geometries:
raise HTTPException(status_code=400, detail="AOI GeoJSON 中未解析到有效几何。")
merged = unary_union(geometries)
if merged.is_empty:
raise HTTPException(status_code=400, detail="AOI 几何为空,无法用于筛选。")
return merged
def _parse_aoi_geojson_form_value(aoi_geojson: Optional[str]) -> Optional[Tuple[str, Dict[str, Any]]]:
if not aoi_geojson:
return None
try:
payload = json.loads(aoi_geojson)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"AOI GeoJSON 解析失败: {exc}") from exc
feature_collection = _normalize_geojson_to_feature_collection(payload)
merged_geometry = _feature_collection_to_union_geometry(feature_collection)
return merged_geometry.wkt, feature_collection
def _read_aoi_shapefile_with_restore_shx(shp_path: str):
import geopandas as gpd
previous_restore_shx = os.environ.get("SHAPE_RESTORE_SHX")
os.environ["SHAPE_RESTORE_SHX"] = "YES"
try:
return gpd.read_file(shp_path, engine="pyogrio")
finally:
if previous_restore_shx is None:
os.environ.pop("SHAPE_RESTORE_SHX", None)
else:
os.environ["SHAPE_RESTORE_SHX"] = previous_restore_shx
async def _parse_aoi_from_files(files: Optional[List[UploadFile]]) -> Optional[Tuple[str, Dict[str, Any]]]:
if not files:
return None
valid_files = [file for file in files if file and file.filename and str(file.filename).strip()]
if not valid_files:
return None
if len(valid_files) > AOI_UPLOAD_MAX_FILES:
raise HTTPException(
status_code=400,
detail=f"AOI 上传文件数量超限,最多允许 {AOI_UPLOAD_MAX_FILES} 个文件。",
)
with tempfile.TemporaryDirectory() as temp_dir:
shp_path: Optional[str] = None
geojson_payload: Optional[Dict[str, Any]] = None
total_bytes = 0
for index, file in enumerate(valid_files):
raw_name = str(file.filename).strip()
base_name = os.path.basename(raw_name) or f"upload_{index}"
dest_name = f"{index:02d}_{base_name}"
dest_path = os.path.join(temp_dir, dest_name)
file_bytes = 0
try:
with open(dest_path, "wb") as buffer:
while True:
chunk = await file.read(AOI_UPLOAD_STREAM_CHUNK_BYTES)
if not chunk:
break
chunk_size = len(chunk)
file_bytes += chunk_size
total_bytes += chunk_size
if file_bytes > AOI_UPLOAD_MAX_SINGLE_FILE_BYTES:
raise HTTPException(
status_code=400,
detail=(
f"AOI 文件 {base_name} 超过单文件大小限制("
f"{_format_size_limit(AOI_UPLOAD_MAX_SINGLE_FILE_BYTES)})。"
),
)
if total_bytes > AOI_UPLOAD_MAX_TOTAL_BYTES:
raise HTTPException(
status_code=400,
detail=(
"AOI 上传文件总大小超限("
f"{_format_size_limit(AOI_UPLOAD_MAX_TOTAL_BYTES)})。"
),
)
buffer.write(chunk)
finally:
await file.close()
lower_dest = dest_path.lower()
if lower_dest.endswith(".shp"):
shp_path = dest_path
elif lower_dest.endswith(".geojson") or lower_dest.endswith(".json"):
try:
geojson_payload = json.loads(Path(dest_path).read_text(encoding="utf-8"))
except UnicodeDecodeError:
geojson_payload = json.loads(Path(dest_path).read_text(encoding="gbk"))
if shp_path:
try:
async with _SHAPEFILE_READ_LOCK:
gdf = await asyncio.to_thread(_read_aoi_shapefile_with_restore_shx, shp_path)
except Exception as exc:
raise HTTPException(
status_code=400,
detail=(
"AOI Shapefile 读取失败。系统已尝试自动恢复缺失的 .shx 索引;"
f"请确认已上传 .shp/.dbf/.prj/.shx 或可恢复的标准 Shapefile。原始错误: {exc}"
),
) from exc
if gdf.crs and gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(epsg=4326)
feature_collection = json.loads(gdf.to_json())
merged_geometry = _feature_collection_to_union_geometry(feature_collection)
return merged_geometry.wkt, feature_collection
if geojson_payload:
feature_collection = _normalize_geojson_to_feature_collection(geojson_payload)
merged_geometry = _feature_collection_to_union_geometry(feature_collection)
return merged_geometry.wkt, feature_collection
return None
def _cleanup_expired_aoi_tokens(now: float) -> None:
expired_tokens = [
token
for token, payload in _AOI_TOKEN_STORE.items()
if float(payload.get("expires_at", 0.0)) <= now
]
for token in expired_tokens:
_AOI_TOKEN_STORE.pop(token, None)
async def _store_aoi_token(aoi_wkt: str, feature_collection: Dict[str, Any]) -> str:
token = uuid.uuid4().hex
now = time.monotonic()
async with _AOI_TOKEN_LOCK:
_cleanup_expired_aoi_tokens(now)
_AOI_TOKEN_STORE[token] = {
"aoi_wkt": aoi_wkt,
"aoi_geojson": feature_collection,
"expires_at": now + AOI_TOKEN_TTL_SECONDS,
}
return token
async def _get_aoi_from_token(aoi_token: Optional[str]) -> Optional[Tuple[str, Dict[str, Any]]]:
if not aoi_token:
return None
normalized_token = str(aoi_token).strip()
if not normalized_token:
return None
now = time.monotonic()
async with _AOI_TOKEN_LOCK:
_cleanup_expired_aoi_tokens(now)
payload = _AOI_TOKEN_STORE.get(normalized_token)
if not payload:
return None
payload["expires_at"] = now + AOI_TOKEN_TTL_SECONDS
return payload.get("aoi_wkt"), payload.get("aoi_geojson")
def _resolve_region_aoi_payload(tree_id: str) -> Dict[str, Any]:
normalized_tree_id = (tree_id or "").strip()
if not normalized_tree_id:
raise HTTPException(status_code=400, detail="tree_id 不能为空。")
_load_region_index()
_load_region_geometry_index()
node = (_REGION_BY_ID_CACHE or {}).get(normalized_tree_id)
if not node:
raise HTTPException(status_code=404, detail=f"未找到行政区: {normalized_tree_id}")
geometry_index = _REGION_GEOMETRY_BY_ID_CACHE or {}
def _collect_features_by_tree_id(target_tree_id: str) -> Tuple[List[Dict[str, Any]], str]:
exact_features = list(geometry_index.get(target_tree_id) or [])
if exact_features:
return exact_features, "exact"
descendant_features: List[Dict[str, Any]] = []
prefix = f"{target_tree_id}-"
for feature_tree_id, feature_list in geometry_index.items():
if feature_tree_id.startswith(prefix):
descendant_features.extend(feature_list)
if descendant_features:
return descendant_features, "descendants"
return [], "none"
matched_tree_id = normalized_tree_id
features, source = _collect_features_by_tree_id(normalized_tree_id)
if not features:
current_tree_id = node.get("parent_tree_id")
while current_tree_id:
ancestor_features, ancestor_source = _collect_features_by_tree_id(current_tree_id)
if ancestor_features:
features = ancestor_features
matched_tree_id = current_tree_id
source = f"ancestor_{ancestor_source}"
break
current_node = (_REGION_BY_ID_CACHE or {}).get(current_tree_id)
current_tree_id = current_node.get("parent_tree_id") if current_node else None
if not features:
raise HTTPException(
status_code=404,
detail=(
f"行政区 {normalized_tree_id} 未匹配到边界数据。"
"请检查 AOI_REGION_GEOJSON_FILE 中 features[*].properties.treeID。"
),
)
raw_feature_collection = {
"type": "FeatureCollection",
"features": features,
}
merged_geometry = _feature_collection_to_union_geometry(raw_feature_collection)
merged_feature_collection = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"treeID": normalized_tree_id,
"matched_treeID": matched_tree_id,
"name": node.get("name"),
"level": node.get("level"),
"source": source,
},
"geometry": mapping(merged_geometry),
}
],
}
return {
"tree_id": normalized_tree_id,
"name": node.get("name"),
"level": node.get("level"),
"source": source,
"matched_tree_id": matched_tree_id,
"feature_count": len(features),
"aoi_geojson": merged_feature_collection,
}