"""Earth asset management APIs.""" from __future__ import annotations from pathlib import Path from typing import Any from uuid import uuid4 from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, Field from sqlalchemy import delete, func, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings as app_settings from app.core.security import decode_token, get_current_user, redis_client from app.db.session import get_db from app.models.collected_data import CollectedData from app.models.datasource import DataSource from app.models.datasource_config import DataSourceConfig from app.models.system_setting import SystemSetting from app.models.user import User from app.services.tv_streams import get_tv_settings_payload from app.services.earth_boundaries import ( EarthBoundaryBuildError, get_boundary_build_status, get_boundary_status, save_boundary_config, start_boundary_build_job, ) router = APIRouter() optional_bearer = HTTPBearer(auto_error=False) REPO_ROOT = Path(__file__).resolve().parents[4] EARTH_BRAND_ASSET_DIR = REPO_ROOT / "data" / "earth-brand" EARTH_BRAND_ASSET_URL_PREFIX = "/earth-brand-assets" EARTH_BRAND_CATEGORY = "earth_brand" EARTH_ABOUT_CATEGORY = "earth_about" SYSTEM_SETTINGS_CATEGORY = "system" MAX_EARTH_BRAND_ASSET_BYTES = 3 * 1024 * 1024 ALLOWED_EARTH_BRAND_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"} def _app_version_label() -> str: version = str(app_settings.VERSION or "").strip() or "0.0.0" return version if version.startswith("v") else f"v{version}" DEFAULT_EARTH_BRAND = { "logo_src": "/earth/assets/brand/earth-logo.png", "title_src": "/earth/assets/brand/title-zh.png", "title_text": "智能星球计划", "subtitle": "现实层宇宙全息感知系统", "description": "卫星 · 海底光缆 · 算力基础设施", "aria_label": "智能星球计划品牌标识", "title_alt": "智能星球计划", } DEFAULT_EARTH_ABOUT = { "logo_src": "/earth/assets/brand/lim-logo.png", "kicker": "About", "title": "智能星球计划", "version": _app_version_label(), "description": "面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。", "meta": [ {"label": "出品方", "value": "浙江大学临空智能媒体研究院"}, {"label": "策划人", "value": "方兴东、黄柳青"}, {"label": "产品兼开发者", "value": "钱坤、张鸽、齐鹏"}, ], } EARTH_ABOUT_LEGACY_PLANNER_VALUE = "黄柳青" class EarthBoundaryConfigPayload(BaseModel): config: dict[str, Any] = Field(default_factory=dict) class EarthBrandPayload(BaseModel): logo_src: str = Field(default=DEFAULT_EARTH_BRAND["logo_src"], max_length=1000) title_src: str = Field(default=DEFAULT_EARTH_BRAND["title_src"], max_length=1000) title_text: str = Field(default=DEFAULT_EARTH_BRAND["title_text"], max_length=120) subtitle: str = Field(default=DEFAULT_EARTH_BRAND["subtitle"], max_length=160) description: str = Field(default=DEFAULT_EARTH_BRAND["description"], max_length=200) aria_label: str = Field(default=DEFAULT_EARTH_BRAND["aria_label"], max_length=200) title_alt: str = Field(default=DEFAULT_EARTH_BRAND["title_alt"], max_length=200) class EarthAboutMetaItem(BaseModel): label: str = Field(default="", max_length=80) value: str = Field(default="", max_length=240) class EarthAboutPayload(BaseModel): logo_src: str = Field(default=DEFAULT_EARTH_ABOUT["logo_src"], max_length=1000) kicker: str = Field(default=DEFAULT_EARTH_ABOUT["kicker"], max_length=80) title: str = Field(default=DEFAULT_EARTH_ABOUT["title"], max_length=160) version: str = Field(default=DEFAULT_EARTH_ABOUT["version"], max_length=80) description: str = Field(default=DEFAULT_EARTH_ABOUT["description"], max_length=800) meta: list[EarthAboutMetaItem] = Field(default_factory=list) def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]: merged = DEFAULT_EARTH_BRAND.copy() if payload: for key in DEFAULT_EARTH_BRAND: value = payload.get(key) if value is not None: merged[key] = str(value).strip() if not merged["title_text"]: merged["title_text"] = DEFAULT_EARTH_BRAND["title_text"] if not merged["aria_label"]: merged["aria_label"] = merged["title_text"] if not merged["title_alt"]: merged["title_alt"] = merged["title_text"] return merged def _normalize_earth_about_payload(payload: dict[str, Any] | None) -> dict[str, Any]: merged: dict[str, Any] = { key: value for key, value in DEFAULT_EARTH_ABOUT.items() if key != "meta" } raw_meta = DEFAULT_EARTH_ABOUT["meta"] if payload: for key in ("logo_src", "kicker", "title", "description"): value = payload.get(key) if value is not None: merged[key] = str(value).strip() raw_meta = payload.get("meta") if isinstance(payload.get("meta"), list) else raw_meta merged["version"] = _app_version_label() for key, default_value in DEFAULT_EARTH_ABOUT.items(): if key == "meta": continue if not merged.get(key): merged[key] = default_value normalized_meta: list[dict[str, str]] = [] for item in raw_meta: if not isinstance(item, dict): continue label = str(item.get("label") or "").strip() value = str(item.get("value") or "").strip() if label == "策划人" and value == EARTH_ABOUT_LEGACY_PLANNER_VALUE: value = "方兴东、黄柳青" if label or value: normalized_meta.append({"label": label, "value": value}) if not normalized_meta: normalized_meta = [dict(item) for item in DEFAULT_EARTH_ABOUT["meta"]] merged["meta"] = normalized_meta return merged def _is_demo_mode_enabled(payload: Any) -> bool: return bool(payload.get("demo_mode")) if isinstance(payload, dict) else False async def _get_earth_brand_record(db: AsyncSession) -> SystemSetting | None: result = await db.execute( select(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY) ) return result.scalar_one_or_none() async def _get_earth_brand_payload(db: AsyncSession) -> dict[str, Any]: record = await _get_earth_brand_record(db) return { "brand": _normalize_earth_brand_payload(record.payload if record else None), "is_default": record is None, } async def _get_earth_about_record(db: AsyncSession) -> SystemSetting | None: result = await db.execute( select(SystemSetting).where(SystemSetting.category == EARTH_ABOUT_CATEGORY) ) return result.scalar_one_or_none() async def _get_earth_about_payload(db: AsyncSession) -> dict[str, Any]: record = await _get_earth_about_record(db) return { "about": _normalize_earth_about_payload(record.payload if record else None), "is_default": record is None, } async def _get_optional_current_user( credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer), db: AsyncSession = Depends(get_db), ) -> User | None: if credentials is None: return None token = credentials.credentials if redis_client.sismember("blacklisted_tokens", token): return None payload = decode_token(token) if payload is None or payload.get("type") != "access": return None user_id = payload.get("sub") if user_id is None: return None result = await db.execute( text( "SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id" ), {"id": int(user_id)}, ) row = result.fetchone() if row is None or not row[5]: return None user = User() user.id = row[0] user.username = row[1] user.email = row[2] user.password_hash = row[3] user.role = row[4] user.is_active = row[5] user.gatekeeper_groups = row[6] or [] return user @router.get("/brand") async def get_earth_brand(db: AsyncSession = Depends(get_db)): return await _get_earth_brand_payload(db) @router.put("/brand") async def update_earth_brand( payload: EarthBrandPayload, _current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): normalized = _normalize_earth_brand_payload(payload.model_dump()) record = await _get_earth_brand_record(db) if record is None: record = SystemSetting(category=EARTH_BRAND_CATEGORY, payload=normalized) db.add(record) else: record.payload = normalized await db.commit() await db.refresh(record) return {"status": "updated", "brand": _normalize_earth_brand_payload(record.payload), "is_default": False} @router.delete("/brand") @router.post("/brand/reset") async def reset_earth_brand( _current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): await db.execute(delete(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY)) await db.commit() return {"status": "reset", "brand": DEFAULT_EARTH_BRAND.copy(), "is_default": True} @router.post("/brand/assets") async def upload_earth_brand_asset( file: UploadFile = File(...), _current_user: User = Depends(get_current_user), ): original_name = file.filename or "" extension = Path(original_name).suffix.lower() if extension not in ALLOWED_EARTH_BRAND_EXTENSIONS: raise HTTPException( status_code=400, detail={ "code": "unsupported_file_type", "message": "Only png, jpg, jpeg, webp, and svg brand assets are supported.", }, ) content = await file.read(MAX_EARTH_BRAND_ASSET_BYTES + 1) if len(content) > MAX_EARTH_BRAND_ASSET_BYTES: raise HTTPException( status_code=400, detail={ "code": "file_too_large", "message": "Brand asset must be 3 MB or smaller.", }, ) EARTH_BRAND_ASSET_DIR.mkdir(parents=True, exist_ok=True) safe_name = f"{uuid4().hex}{extension}" destination = EARTH_BRAND_ASSET_DIR / safe_name destination.write_bytes(content) asset_url = f"{EARTH_BRAND_ASSET_URL_PREFIX}/{safe_name}" return {"url": asset_url, "filename": safe_name, "content_type": file.content_type} @router.get("/about") async def get_earth_about(db: AsyncSession = Depends(get_db)): return await _get_earth_about_payload(db) @router.put("/about") async def update_earth_about( payload: EarthAboutPayload, _current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): normalized = _normalize_earth_about_payload(payload.model_dump()) record = await _get_earth_about_record(db) if record is None: record = SystemSetting(category=EARTH_ABOUT_CATEGORY, payload=normalized) db.add(record) else: record.payload = normalized await db.commit() await db.refresh(record) return {"status": "updated", "about": _normalize_earth_about_payload(record.payload), "is_default": False} @router.delete("/about") async def reset_earth_about( _current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): await db.execute(delete(SystemSetting).where(SystemSetting.category == EARTH_ABOUT_CATEGORY)) await db.commit() return {"status": "reset", "about": _normalize_earth_about_payload(None), "is_default": True} @router.get("/oobe-status") async def get_earth_oobe_status( current_user: User | None = Depends(_get_optional_current_user), db: AsyncSession = Depends(get_db), ): current_count_result = await db.execute( select(func.count(CollectedData.id)).where(CollectedData.is_current.is_(True)) ) current_record_count = int(current_count_result.scalar() or 0) system_result = await db.execute( select(SystemSetting).where(SystemSetting.category == SYSTEM_SETTINGS_CATEGORY) ) system_record = system_result.scalar_one_or_none() demo_mode = _is_demo_mode_enabled(system_record.payload if system_record else None) datasource_count_result = await db.execute(select(func.count(DataSource.id))) datasource_count = int(datasource_count_result.scalar() or 0) active_datasource_count_result = await db.execute( select(func.count(DataSource.id)).where(DataSource.is_active.is_(True)) ) active_datasource_count = int(active_datasource_count_result.scalar() or 0) config_result = await db.execute(select(func.count(DataSourceConfig.id))) custom_config_count = int(config_result.scalar() or 0) tv_payload = await get_tv_settings_payload(db) tv_sources = tv_payload.get("sources") if isinstance(tv_payload, dict) else [] tv_source_count = len(tv_sources) if isinstance(tv_sources, list) else 0 boundary_status = get_boundary_status() has_core_layers = bool(boundary_status.get("ready") or boundary_status.get("available") or boundary_status.get("status") in {"ready", "built", "ok"}) has_collected_data = current_record_count > 0 ready = has_collected_data suggestions: list[str] = [] if demo_mode: suggestions.append("演示模式已开启") if not current_user: suggestions.append("登录控制台") if not has_collected_data: suggestions.append("触发数据源采集") if not custom_config_count: suggestions.append("确认采集器配置") if not has_core_layers: suggestions.append("构建或启用 Earth 图层") return { "ready": ready, "demo_mode": demo_mode, "authenticated": current_user is not None, "needs_login": current_user is None and not ready and not demo_mode, "has_collected_data": has_collected_data, "has_tv_sources": tv_source_count > 0, "has_core_layers": has_core_layers, "current_record_count": current_record_count, "datasource_count": datasource_count, "active_datasource_count": active_datasource_count, "custom_config_count": custom_config_count, "tv_source_count": tv_source_count, "suggestions": suggestions, "login_url": "/login?next=/datasources", "datasources_url": "/datasources", "collection_url": "/collection-management", } @router.get("/boundaries/status") async def get_earth_boundary_status(): return get_boundary_status() def _is_loopback_request(request: Request) -> bool: host = request.client.host if request.client else "" return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.") def _require_local_or_user(request: Request, user: User | None) -> None: if user is not None or _is_loopback_request(request): return raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required outside localhost", ) @router.put("/boundaries/config") async def update_earth_boundary_config( payload: EarthBoundaryConfigPayload, _current_user: User = Depends(get_current_user), ): try: return save_boundary_config(payload.config) except EarthBoundaryBuildError as exc: raise HTTPException( status_code=400, detail={"code": exc.code, "message": str(exc), "details": exc.details}, ) from exc @router.post("/boundaries/build") async def build_earth_boundary_assets( request: Request, current_user: User | None = Depends(_get_optional_current_user), ): _require_local_or_user(request, current_user) try: return await start_boundary_build_job() except EarthBoundaryBuildError as exc: raise HTTPException( status_code=400, detail={"code": exc.code, "message": str(exc), "details": exc.details}, ) from exc @router.get("/boundaries/build/status") async def get_earth_boundary_build_status(): return get_boundary_build_status()