683 lines
24 KiB
Python
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()
|