442 lines
16 KiB
Python
442 lines
16 KiB
Python
"""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()
|