release: bump version to 0.69.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
linkong
2026-06-03 17:27:00 +08:00
parent 06aca980d0
commit acbbfdf9e2
57 changed files with 5907 additions and 316 deletions

View File

@@ -21,6 +21,12 @@ 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_boundaries import (
EarthBoundaryBuildError,
get_boundary_build_status,
@@ -100,6 +106,19 @@ class EarthAboutPayload(BaseModel):
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)
def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]:
merged = DEFAULT_EARTH_BRAND.copy()
if payload:
@@ -324,6 +343,38 @@ async def reset_earth_about(
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("/oobe-status")
async def get_earth_oobe_status(
current_user: User | None = Depends(_get_optional_current_user),

View File

@@ -90,7 +90,7 @@ async def get_interactables_geojson(
return interactables_to_geojson(items)
payload = await get_or_build_layer_payload(
key=earth_layer_cache.key("interactables", layer=layer or "all"),
key=earth_layer_cache.key("interactables", interactable_layer=layer or "all"),
policy=INTERACTABLE_CACHE_POLICY,
builder=build_payload,
response=response,

View File

@@ -1,16 +1,92 @@
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.services.earth_news import get_earth_news_payload
from app.services.earth_news import (
ALLOWED_NEWS_CATEGORY_KEYS,
SUPPORTED_NEWS_LOCALES,
REGION_ANCHORS,
get_earth_news_payload,
)
router = APIRouter()
def _parse_categories(raw: str | None) -> set[str] | None:
if raw is None or not raw.strip():
return None
requested = {item.strip().lower() for item in raw.split(",") if item.strip()}
invalid = sorted(requested - set(ALLOWED_NEWS_CATEGORY_KEYS))
if invalid:
raise HTTPException(
status_code=422,
detail={
"message": "Unsupported news categories.",
"invalid_categories": invalid,
"allowed_categories": list(ALLOWED_NEWS_CATEGORY_KEYS),
},
)
return requested or None
def _parse_source_ids(raw: str | None) -> set[str] | None:
if raw is None or not raw.strip():
return None
return {item.strip() for item in raw.split(",") if item.strip()} or None
def _parse_limit(raw: int | None) -> int:
if raw is None:
return 12
if raw < 1:
raise HTTPException(status_code=422, detail={"message": "News limit must be greater than 0."})
return min(raw, 100)
def _parse_locale(raw: str | None) -> str:
if raw is None or not raw.strip():
return "zh-CN"
requested = raw.strip()
if requested not in SUPPORTED_NEWS_LOCALES:
raise HTTPException(
status_code=422,
detail={
"message": "Unsupported news locale.",
"invalid_locale": requested,
"allowed_locales": sorted(SUPPORTED_NEWS_LOCALES),
},
)
return requested
@router.get("/earth-feed")
async def get_earth_feed(
lat: float | None = Query(None, description="Current Earth view center latitude"),
lon: float | None = Query(None, description="Current Earth view center longitude"),
region: str | None = Query(None, description="Explicit Earth news region for UE/client integrations"),
categories: str | None = Query(None, description="Comma-separated news category keys"),
sources: str | None = Query(None, description="Comma-separated news source ids"),
limit: int | None = Query(None, description="Maximum news items to return, capped at 100"),
locale: str | None = Query(None, description="Display locale, zh-CN or en-US"),
db: AsyncSession = Depends(get_db),
):
return await get_earth_news_payload(lat=lat, lon=lon, db=db)
normalized_region = region.strip().lower() if isinstance(region, str) and region.strip() else None
if normalized_region is not None and normalized_region not in REGION_ANCHORS:
raise HTTPException(
status_code=422,
detail={
"message": "Unsupported news region.",
"invalid_region": normalized_region,
"allowed_regions": list(REGION_ANCHORS.keys()),
},
)
return await get_earth_news_payload(
lat=lat,
lon=lon,
region=normalized_region,
categories=_parse_categories(categories),
source_ids=_parse_source_ids(sources),
limit=_parse_limit(limit),
locale=_parse_locale(locale),
db=db,
)

View File

@@ -1,16 +1,17 @@
from __future__ import annotations
import os
import secrets
import subprocess
import sys
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import ROOT_DIR
from app.core.config import ROOT_DIR, settings
from app.core.security import get_current_user
from app.db.session import get_db
from app.models.user import User
@@ -37,6 +38,9 @@ from app.services.system_logs import (
normalize_log_level,
read_database_log_snapshot,
read_log_snapshot,
read_observability_group_events,
read_observability_groups,
read_observability_raw_events,
)
from app.services.earth_layer_cache import earth_layer_cache
@@ -108,12 +112,34 @@ class EarthClientLogEventCreate(BaseModel):
url: str | None = None
module: str | None = None
detail: str | None = None
fingerprint: str | None = None
occurrence_count: int = 1
metadata: dict[str, object] | None = None
class EarthClientLogEventResponse(BaseModel):
accepted: bool
source_id: str
level: str
fingerprint: str | None = None
class ServiceLogEventCreate(BaseModel):
source: str = "ai-provider"
service: str = "ai-provider"
module: str | None = None
category: str | None = None
event: str = "service.runtime_log"
level: str = "error"
message: str
fingerprint: str | None = None
occurrence_count: int = 1
request_id: str | None = None
trace_id: str | None = None
task_id: str | None = None
source_id: int | str | None = None
provider: str | None = None
context: dict[str, object] | None = None
async def ingest_client_log_event(
@@ -136,6 +162,9 @@ async def ingest_client_log_event(
"url": payload.url or "",
"module": payload.module or "",
"detail": payload.detail or "",
"fingerprint": payload.fingerprint or "",
"occurrence_count": max(1, int(payload.occurrence_count or 1)),
"metadata": payload.metadata or {},
},
)
await record_system_log(
@@ -151,9 +180,36 @@ async def ingest_client_log_event(
"detail": payload.detail or "",
"module": payload.module or "",
"client_ip": request.client.host if request.client else "",
"metadata": payload.metadata or {},
},
fingerprint=payload.fingerprint,
occurrence_count=max(1, int(payload.occurrence_count or 1)),
)
return EarthClientLogEventResponse(accepted=True, source_id=source_id, level=normalized_level)
return EarthClientLogEventResponse(accepted=True, source_id=source_id, level=normalized_level, fingerprint=payload.fingerprint)
def require_observability_ingest_token(
authorization: str | None,
ingest_token: str | None,
) -> None:
expected_token = settings.OBSERVABILITY_INGEST_TOKEN.strip()
if not expected_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Observability service ingestion is not configured",
)
provided = ""
if ingest_token:
provided = ingest_token.strip()
elif authorization:
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer":
provided = token.strip()
if not provided or not secrets.compare_digest(provided, expected_token):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid observability ingestion token",
)
class EarthLayerCacheStatusResponse(BaseModel):
@@ -378,6 +434,118 @@ async def get_system_log_sources(
}
@router.get("/logs/observability/groups")
async def get_observability_log_groups(
limit: int = DEFAULT_LOG_LINE_LIMIT,
level: str = "all",
levels: str | None = Query(None, description="Comma-separated log levels"),
start_date: str | None = Query(None, description="Filter logs from this date (YYYY-MM-DD)"),
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
search: str | None = Query(None, description="Case-insensitive substring search"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_super_admin(current_user)
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}")
normalized_start_date = validate_log_date(start_date, "start_date")
normalized_end_date = validate_log_date(end_date, "end_date")
if normalized_start_date and normalized_end_date and normalized_start_date > normalized_end_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date must be earlier than or equal to end_date")
return await read_observability_groups(
limit=limit,
level=level,
levels=levels,
start_date=normalized_start_date,
end_date=normalized_end_date,
search=search,
db=db,
)
@router.get("/logs/observability/groups/{fingerprint}/events")
async def get_observability_group_events(
fingerprint: str,
limit: int = DEFAULT_LOG_LINE_LIMIT,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_super_admin(current_user)
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}")
payload = await read_observability_group_events(fingerprint, limit=limit, db=db)
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Observability group not found")
return payload
@router.get("/logs/observability/raw")
async def get_observability_raw_events(
limit: int = DEFAULT_LOG_LINE_LIMIT,
level: str = "all",
levels: str | None = Query(None, description="Comma-separated log levels"),
start_date: str | None = Query(None, description="Filter logs from this date (YYYY-MM-DD)"),
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
search: str | None = Query(None, description="Case-insensitive substring search"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_super_admin(current_user)
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}")
normalized_start_date = validate_log_date(start_date, "start_date")
normalized_end_date = validate_log_date(end_date, "end_date")
return await read_observability_raw_events(
limit=limit,
level=level,
levels=levels,
start_date=normalized_start_date,
end_date=normalized_end_date,
search=search,
db=db,
)
@router.post("/logs/service", response_model=EarthClientLogEventResponse)
async def ingest_service_log(
payload: ServiceLogEventCreate,
authorization: str | None = Header(default=None),
ingest_token: str | None = Header(default=None, alias="X-Planet-Observability-Token"),
):
require_observability_ingest_token(authorization, ingest_token)
normalized_level = normalize_log_level(payload.level)
source = (payload.source or "ai-provider").strip() or "ai-provider"
context = dict(payload.context or {})
if payload.request_id:
context["request_id"] = payload.request_id
if payload.trace_id:
context["trace_id"] = payload.trace_id
if payload.task_id:
context["task_id"] = payload.task_id
if payload.source_id is not None:
context["source_id"] = payload.source_id
if payload.provider:
context["provider"] = payload.provider
await record_system_log(
source=source,
service=(payload.service or source).strip() or source,
module=payload.module or source,
event=(payload.event or "service.runtime_log").strip() or "service.runtime_log",
level=normalized_level,
message=payload.message,
category=payload.category or "service-runtime",
context=context,
fingerprint=payload.fingerprint,
occurrence_count=max(1, int(payload.occurrence_count or 1)),
)
return EarthClientLogEventResponse(
accepted=True,
source_id=source,
level=normalized_level,
fingerprint=payload.fingerprint,
)
@router.get("/logs/{source_id}", response_model=SystemLogSnapshotResponse)
async def get_system_log_snapshot(
source_id: str,

View File

@@ -1,3 +1,4 @@
import re
from urllib.parse import quote, urljoin
import httpx
@@ -10,6 +11,26 @@ from app.services.tv_streams import get_public_tv_payload, is_allowed_tv_proxy_u
router = APIRouter()
_HLS_URI_ATTRIBUTE_RE = re.compile(r'URI="([^"]+)"')
def _proxied_tv_url(url: str) -> str:
return f"/api/v1/tv/proxy?url={quote(url, safe='')}"
def _rewrite_hls_uri_attributes(line: str, *, base_url: str) -> str:
def replace(match: re.Match[str]) -> str:
uri = match.group(1)
absolute_url = urljoin(base_url, uri)
return f'URI="{_proxied_tv_url(absolute_url)}"'
return _HLS_URI_ATTRIBUTE_RE.sub(replace, line)
def _should_strip_hls_metadata_line(line: str) -> bool:
normalized = line.strip().upper()
return normalized.startswith("#EXT-X-MEDIA:") and "TYPE=SUBTITLES" in normalized
@router.get("/streams")
async def list_public_tv_streams(
@@ -56,11 +77,16 @@ async def proxy_tv_stream(
rewritten_lines: list[str] = []
for line in manifest_text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
if not stripped:
rewritten_lines.append(line)
continue
if stripped.startswith("#"):
if _should_strip_hls_metadata_line(line):
continue
rewritten_lines.append(_rewrite_hls_uri_attributes(line, base_url=response_url))
continue
absolute_url = urljoin(response_url, stripped)
rewritten_lines.append(f"/api/v1/tv/proxy?url={quote(absolute_url, safe='')}")
rewritten_lines.append(_proxied_tv_url(absolute_url))
return Response(
content="\n".join(rewritten_lines),
media_type="application/vnd.apple.mpegurl",