Files
planet/backend/app/api/v1/earth.py
linkong 899e3bce43
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
release: bump version to 0.71.0
2026-06-11 16:47:24 +08:00

683 lines
24 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, Form, HTTPException, Query, 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_news import (
get_earth_news_sources_payload,
reset_earth_news_sources_payload,
save_earth_news_sources_payload,
test_news_source_config,
)
from app.services.earth_news_manual import (
broadcast_manual_news_changed,
create_manual_news_group,
delete_manual_news_item,
get_news_record_or_404,
import_manual_news_items,
list_news_groups,
list_news_records,
parse_manual_news_import_upload,
rename_manual_news_group,
reprocess_manual_news_item,
serialize_news_record,
upsert_manual_news_item,
)
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)
class EarthNewsSourcesPayload(BaseModel):
cache_version: int | None = None
source_tags: list[dict[str, Any]] = Field(default_factory=list)
categories: list[dict[str, Any]] = Field(default_factory=list)
item_tag_rules: list[dict[str, Any]] = Field(default_factory=list)
sources: list[dict[str, Any]] = Field(default_factory=list)
health: dict[str, Any] = Field(default_factory=dict)
class EarthNewsSourceTestPayload(BaseModel):
source: dict[str, Any] = Field(default_factory=dict)
class EarthNewsManualItemPayload(BaseModel):
title: str = Field(default="", max_length=500)
summary: str = Field(default="", max_length=1200)
content: str = Field(default="", max_length=12000)
url: str = Field(default="", max_length=2000)
source: str = Field(default="", max_length=255)
region: str = Field(default="global", max_length=80)
published_at: str | None = None
category: str = Field(default="other", max_length=80)
tags: list[str] = Field(default_factory=list)
location: dict[str, Any] | None = None
homepage_url: str = Field(default="", max_length=2000)
content_language: str = Field(default="", max_length=32)
group_id: str | None = Field(default=None, max_length=120)
class EarthNewsManualGroupPayload(BaseModel):
name: str = Field(default="", max_length=120)
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("/news-sources")
async def get_earth_news_sources(db: AsyncSession = Depends(get_db)):
return await get_earth_news_sources_payload(db)
@router.put("/news-sources")
async def update_earth_news_sources(
payload: EarthNewsSourcesPayload,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await save_earth_news_sources_payload(db, payload.model_dump())
@router.delete("/news-sources")
@router.post("/news-sources/reset")
async def reset_earth_news_sources(
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await reset_earth_news_sources_payload(db)
@router.post("/news-sources/test")
async def test_earth_news_source(
payload: EarthNewsSourceTestPayload,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await test_news_source_config(payload.source, db=db)
@router.get("/news-groups")
async def list_earth_news_groups_admin(
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_news_groups(db)
@router.post("/news-groups")
async def create_earth_news_group_admin(
payload: EarthNewsManualGroupPayload,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
group = await create_manual_news_group(db, payload.name)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await db.commit()
return {"status": "ok", "group": group}
@router.put("/news-groups/{group_id:path}")
async def rename_earth_news_group_admin(
group_id: str,
payload: EarthNewsManualGroupPayload,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
group = await rename_manual_news_group(db, group_id, payload.name)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await db.commit()
await broadcast_manual_news_changed()
return {"status": "ok", "group": group}
@router.get("/news-items")
async def list_earth_news_items_admin(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
source_type: str | None = Query(None),
region: str | None = Query(None),
category: str | None = Query(None),
status_filter: str | None = Query(None, alias="status"),
group_id: str | None = Query(None),
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_news_records(
db,
page=page,
page_size=page_size,
source_type=source_type,
region=region,
category=category,
status_filter=status_filter,
group_id=group_id,
)
@router.post("/news-items")
async def create_earth_news_item_admin(
payload: EarthNewsManualItemPayload,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
result = await upsert_manual_news_item(db, payload.model_dump())
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
await db.commit()
await broadcast_manual_news_changed()
return {"status": "ok", "created": result.created, "queued": result.queued, "item": serialize_news_record(result.item)}
@router.post("/news-items/import")
async def import_earth_news_items_admin(
file: UploadFile = File(...),
group_id: str | None = Form(default=None),
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
payload = await parse_manual_news_import_upload(await file.read())
result = await import_manual_news_items(db, payload, group_id=group_id)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
await db.commit()
await broadcast_manual_news_changed()
return {"status": "ok", **result}
@router.put("/news-items/{item_id:path}")
async def update_earth_news_item_admin(
item_id: str,
payload: EarthNewsManualItemPayload,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
existing = await get_news_record_or_404(db, item_id)
if existing is None:
raise HTTPException(status_code=404, detail="News item not found.")
try:
result = await upsert_manual_news_item(
db,
payload.model_dump(),
item_id_override=item_id,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
await db.commit()
await broadcast_manual_news_changed()
return {"status": "ok", "created": result.created, "queued": result.queued, "item": serialize_news_record(result.item)}
@router.delete("/news-items/{item_id:path}")
async def delete_earth_news_item_admin(
item_id: str,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
deleted = await delete_manual_news_item(db, item_id)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="News item not found.")
await db.commit()
await broadcast_manual_news_changed()
return {"status": "deleted", "id": item_id}
@router.post("/news-items/{item_id:path}/reprocess")
async def reprocess_earth_news_item_admin(
item_id: str,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
existing = await get_news_record_or_404(db, item_id)
if existing is None:
raise HTTPException(status_code=404, detail="News item not found.")
try:
queued = await reprocess_manual_news_item(db, item_id)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
await db.commit()
await broadcast_manual_news_changed()
return {"status": "queued" if queued else "not_queued", "queued": queued, "id": item_id}
@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()