From 8c204717cd728aeea9dc9d7ba2be9ef625d4aebc Mon Sep 17 00:00:00 2001 From: linkong Date: Thu, 4 Jun 2026 17:16:23 +0800 Subject: [PATCH] release: bump version to 0.70.0 --- VERSION | 2 +- backend/app/api/v1/auth.py | 28 +- backend/app/api/v1/datasource_config.py | 16 +- backend/app/api/v1/datasources.py | 23 +- backend/app/api/v1/layers.py | 2 +- backend/app/api/v1/settings.py | 36 +-- backend/app/api/v1/users.py | 17 +- backend/app/api/v1/vessels.py | 6 +- backend/app/api/v1/visualization.py | 129 ++++----- backend/app/api/v1/websocket.py | 5 +- backend/app/core/enums.py | 225 +++++++++++++++ backend/app/db/session.py | 17 ++ backend/app/models/alert.py | 18 +- backend/app/models/bgp_anomaly.py | 3 +- backend/app/models/bgp_incident.py | 3 +- backend/app/models/data_snapshot.py | 3 +- backend/app/models/datasource_mapping.py | 3 +- backend/app/models/playground_message.py | 5 +- backend/app/models/task.py | 7 +- backend/app/models/user.py | 3 +- backend/app/models/vessel.py | 64 ++++- backend/app/schemas/ai.py | 7 +- backend/app/schemas/user.py | 7 +- backend/app/services/bgp_detectors.py | 11 +- backend/app/services/bgp_incidents.py | 5 +- backend/app/services/collectors/base.py | 18 +- backend/app/services/collectors/vessel_ais.py | 3 +- backend/app/services/data_jobs.py | 23 +- .../app/services/datasource_connectivity.py | 3 +- backend/app/services/docs_gatekeeper.py | 10 +- backend/app/services/earth_layer_adapters.py | 4 +- backend/app/services/earth_news.py | 179 +++++------- .../app/services/earth_news_classification.py | 258 ++++++++++++++++++ backend/app/services/earth_news_store.py | 68 ++++- backend/app/services/email.py | 16 +- backend/app/services/otp.py | 4 +- .../app/services/playground_chat_service.py | 75 +++-- backend/app/services/scheduler.py | 15 +- .../services/situational_alert_ai_brief.py | 9 +- backend/app/services/system_control.py | 3 +- backend/app/services/system_logs.py | 11 +- .../app/services/vessel_ais_aggregation.py | 124 ++++++++- backend/tests/test_earth_news.py | 84 +++++- backend/tests/test_enum_contracts.py | 74 +++++ backend/tests/test_vessels.py | 130 ++++++--- docs/CHANGELOG.md | 18 ++ .../earth-vessel-ais-aggregation-plan.md | 8 +- ...earth-vessel-rendering-performance-plan.md | 9 +- docs/plans/earth-vessel-tracking-plan.md | 5 +- docs/technical/en/README.md | 1 + docs/technical/en/backend-collectors.md | 26 +- docs/technical/en/backend-enum-contracts.md | 50 ++++ ...asource-collector-settings-connectivity.md | 4 +- docs/technical/en/earth-frontend-context.md | 10 +- .../en/earth-layer-style-reference.md | 4 +- docs/technical/en/earth-news-sources.md | 23 ++ docs/technical/en/earth-render-layer-order.md | 6 +- docs/technical/en/platform-data-flows.md | 6 +- docs/technical/zh/README.md | 1 + docs/technical/zh/backend-collectors.md | 26 +- docs/technical/zh/backend-enum-contracts.md | 50 ++++ ...asource-collector-settings-connectivity.md | 4 +- docs/technical/zh/earth-frontend-context.md | 9 +- .../zh/earth-layer-style-reference.md | 4 +- docs/technical/zh/earth-news-sources.md | 23 ++ docs/technical/zh/earth-render-layer-order.md | 2 +- docs/technical/zh/platform-data-flows.md | 6 +- docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/news-panel.css | 95 +++++-- frontend/public/earth/js/constants.js | 2 +- frontend/public/earth/js/main.js | 3 +- frontend/public/earth/js/news-locale.js | 19 ++ frontend/public/earth/js/news.js | 77 +++++- frontend/public/earth/js/vessels.js | 205 +------------- frontend/src/pages/Docs/docs-content.ts | 4 + pyproject.toml | 2 +- uv.lock | 2 +- 78 files changed, 1762 insertions(+), 703 deletions(-) create mode 100644 backend/app/core/enums.py create mode 100644 backend/app/services/earth_news_classification.py create mode 100644 backend/tests/test_enum_contracts.py create mode 100644 docs/technical/en/backend-enum-contracts.md create mode 100644 docs/technical/zh/backend-enum-contracts.md diff --git a/VERSION b/VERSION index 106d4ac0..534b316a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.69.0 +0.70.0 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index ebd23d8e..e2c5f1fe 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from app.core.config import settings +from app.core.enums import OtpPurpose, UserRole from app.core.logging import get_logger from app.core.security import ( create_access_token, @@ -170,7 +171,7 @@ async def get_me(current_user: User = Depends(get_current_user)): } -async def _send_code_or_raise(db: AsyncSession, email: str, code: str, purpose: str) -> None: +async def _send_code_or_raise(db: AsyncSession, email: str, code: str, purpose: OtpPurpose) -> None: try: await send_verification_email(db, to=email, code=code, purpose=purpose) except EmailNotConfiguredError as exc: @@ -207,7 +208,7 @@ async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)): username=payload.username, email=payload.email, password_hash=get_password_hash(payload.password), - role="viewer", + role=UserRole.VIEWER.value, is_active=True, email_verified=False, ) @@ -215,13 +216,13 @@ async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)): await db.commit() try: - code = otp.issue_code(payload.email, "register") + code = otp.issue_code(payload.email, OtpPurpose.REGISTER) except otp.OtpResendRateLimited as exc: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds}, ) from exc - await _send_code_or_raise(db, payload.email, code, "register") + await _send_code_or_raise(db, payload.email, code, OtpPurpose.REGISTER) return {"status": "pending_verification", "email": payload.email} @@ -266,7 +267,7 @@ async def resend_code(payload: ResendCodeRequest, db: AsyncSession = Depends(get if user is None: # Avoid email enumeration; pretend success. return {"status": "ok"} - if payload.purpose == "register" and user.email_verified: + if payload.purpose is OtpPurpose.REGISTER and user.email_verified: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"code": "ALREADY_VERIFIED"}, @@ -289,12 +290,17 @@ async def forgot_password(payload: ForgotPasswordRequest, db: AsyncSession = Dep # Don't leak whether an email is registered. return {"status": "ok"} try: - code = otp.issue_code(payload.email, "reset_password") + code = otp.issue_code(payload.email, OtpPurpose.RESET_PASSWORD) except otp.OtpResendRateLimited: # Silently accept; the user can retry after the cooldown. return {"status": "ok"} try: - await send_verification_email(db, to=payload.email, code=code, purpose="reset_password") + await send_verification_email( + db, + to=payload.email, + code=code, + purpose=OtpPurpose.RESET_PASSWORD, + ) except EmailNotConfiguredError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -304,7 +310,11 @@ async def forgot_password(payload: ForgotPasswordRequest, db: AsyncSession = Dep logger.warning_event( "SMTP send failed", event="auth.email.send_failed", - context={"email": payload.email, "purpose": "reset_password", "error": str(exc)}, + context={ + "email": payload.email, + "purpose": OtpPurpose.RESET_PASSWORD.value, + "error": str(exc), + }, ) return {"status": "ok"} @@ -318,7 +328,7 @@ async def reset_password(payload: ResetPasswordRequest, db: AsyncSession = Depen detail={"code": "OTP_INVALID"}, ) try: - otp.verify_code(payload.email, "reset_password", payload.code) + otp.verify_code(payload.email, OtpPurpose.RESET_PASSWORD, payload.code) except otp.OtpExpired as exc: raise HTTPException( status_code=status.HTTP_410_GONE, diff --git a/backend/app/api/v1/datasource_config.py b/backend/app/api/v1/datasource_config.py index 3f7ce76d..958a868d 100644 --- a/backend/app/api/v1/datasource_config.py +++ b/backend/app/api/v1/datasource_config.py @@ -13,6 +13,7 @@ import httpx from app.core.target_schema_registry import get_target_schema, list_target_schemas from app.core.datasource_defaults import DEFAULT_DATASOURCES +from app.core.enums import AuthType, MappingValidationStatus, UserRole from app.db.session import get_db from app.models.user import User from app.models.datasource_config import DataSourceConfig @@ -34,7 +35,6 @@ from app.services.datasource_mapping import ( ) from app.services.custom_datasource_runtime import ( CustomDatasourceRuntimeError, - fetch_rest_payload, get_custom_stream_status, run_mapped_rest_config, run_mapped_websocket_config, @@ -42,8 +42,6 @@ from app.services.custom_datasource_runtime import ( stop_custom_stream, test_websocket_config, ) - -DATASOURCE_MAPPING_PROMPT_KEY = "datasource.mapping" from app.services.datasource_connectivity import ( _resolve_aisstream_api_key, _resolve_spacetrack_credentials_with_override, @@ -57,7 +55,8 @@ from app.services.persistent_logs import record_audit_log router = APIRouter() -SECRET_REVEAL_ROLES = {"admin", "super_admin"} +DATASOURCE_MAPPING_PROMPT_KEY = "datasource.mapping" +SECRET_REVEAL_ROLES = {UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value} def _user_role_value(user: User) -> str: @@ -124,7 +123,7 @@ class DataSourceConfigCreate(BaseModel): description: Optional[str] = None source_type: str = Field(..., description="rest, websocket, http, api, database") endpoint: str = Field(..., max_length=500) - auth_type: str = Field(default="none", description="none, bearer, api_key, basic") + auth_type: AuthType = Field(default=AuthType.NONE, description="none, bearer, api_key, basic") auth_config: dict = Field(default={}) headers: dict = Field(default={}) config: dict = Field(default={"timeout": 30, "retry": 3}) @@ -135,7 +134,7 @@ class DataSourceConfigUpdate(BaseModel): description: Optional[str] = None source_type: Optional[str] = None endpoint: Optional[str] = Field(None, max_length=500) - auth_type: Optional[str] = None + auth_type: Optional[AuthType] = None auth_config: Optional[dict] = None headers: Optional[dict] = None config: Optional[dict] = None @@ -210,7 +209,7 @@ class MappingTemplateCreate(BaseModel): mapping_json: dict sample_payload: Any | None = None sample_payload_hash: Optional[str] = None - validation_status: str = Field(default="draft", pattern="^(draft|valid|invalid)$") + validation_status: MappingValidationStatus = MappingValidationStatus.DRAFT is_active: bool = False @@ -219,7 +218,7 @@ class MappingTemplateUpdate(BaseModel): mapping_json: Optional[dict] = None sample_payload: Any | None = None sample_payload_hash: Optional[str] = None - validation_status: Optional[str] = Field(default=None, pattern="^(draft|valid|invalid)$") + validation_status: Optional[MappingValidationStatus] = None is_active: Optional[bool] = None @@ -918,6 +917,7 @@ async def get_datasource_target_schemas( @router.post("/mappings/propose") async def propose_datasource_mapping( payload: MappingProposeRequest, + db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ai_client: AIProviderClient = Depends(get_ai_provider_client), ): diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 0fbb2c49..305612b9 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -7,6 +7,7 @@ from sqlalchemy import func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.core.logging import get_logger +from app.core.enums import JobStatus, SnapshotStatus from app.core.time import to_iso8601_utc from app.core.security import get_current_user from app.core.data_sources import get_data_sources_config @@ -581,7 +582,7 @@ async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[Col result = await db.execute( select(CollectionTask) .where(CollectionTask.datasource_id == datasource_id) - .where(CollectionTask.status == "running") + .where(CollectionTask.status == JobStatus.RUNNING.value) .order_by(CollectionTask.started_at.desc()) .limit(1) ) @@ -609,8 +610,8 @@ async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[Col f"Marked failed automatically after stale running timeout " f"({STALE_RUNNING_TASK_TIMEOUT_MINUTES}m)" ) - task.status = "failed" - task.phase = "failed" + task.status = JobStatus.FAILED.value + task.phase = JobStatus.FAILED.value task.completed_at = now task.error_message = f"{existing_error}\n{stale_reason}".strip() if existing_error else stale_reason await db.commit() @@ -647,7 +648,7 @@ async def rollback_orphaned_running_task( ) if snapshot is not None: - snapshot.status = "cancelled" + snapshot.status = SnapshotStatus.CANCELLED.value snapshot.is_current = False snapshot.completed_at = datetime.now(timezone.utc) summary = dict(snapshot.summary or {}) @@ -670,13 +671,13 @@ async def rollback_orphaned_running_task( {"snapshot_id": snapshot.parent_snapshot_id}, ) - running_task.status = "cancelled" - running_task.phase = "cancelled" + running_task.status = JobStatus.CANCELLED.value + running_task.phase = JobStatus.CANCELLED.value running_task.completed_at = datetime.now(timezone.utc) existing_error = (running_task.error_message or "").strip() cancel_reason = "Cancelled after backend restart because the running task handle was lost; incomplete writes rolled back" running_task.error_message = f"{existing_error}\n{cancel_reason}".strip() if existing_error else cancel_reason - datasource.last_status = "cancelled" + datasource.last_status = JobStatus.CANCELLED.value datasource.last_run_at = datetime.now(timezone.utc) await db.commit() @@ -711,7 +712,7 @@ async def fail_and_rollback_stale_running_task( ) if snapshot is not None: - snapshot.status = "failed" + snapshot.status = SnapshotStatus.FAILED.value snapshot.is_current = False snapshot.completed_at = datetime.now(timezone.utc) summary = dict(snapshot.summary or {}) @@ -739,11 +740,11 @@ async def fail_and_rollback_stale_running_task( f"Marked failed automatically after stale running timeout " f"({STALE_RUNNING_TASK_TIMEOUT_MINUTES}m); incomplete writes rolled back" ) - running_task.status = "failed" - running_task.phase = "failed" + running_task.status = JobStatus.FAILED.value + running_task.phase = JobStatus.FAILED.value running_task.completed_at = datetime.now(timezone.utc) running_task.error_message = f"{existing_error}\n{stale_reason}".strip() if existing_error else stale_reason - datasource.last_status = "failed" + datasource.last_status = JobStatus.FAILED.value datasource.last_run_at = datetime.now(timezone.utc) await db.commit() diff --git a/backend/app/api/v1/layers.py b/backend/app/api/v1/layers.py index 991ff5a2..57891d85 100644 --- a/backend/app/api/v1/layers.py +++ b/backend/app/api/v1/layers.py @@ -105,7 +105,7 @@ def _parse_layer_bbox(bbox: str) -> tuple[float, float, float, float]: @router.get("/vessels/snapshot") async def get_vessel_layer_snapshot( bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"), - zoom: int = Query(..., ge=1, le=20), + zoom: float = Query(..., ge=1, le=20), limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1), vessel_type: Optional[str] = Query(None, alias="type"), since_minutes: int = Query(60, ge=1, le=1440), diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 988e3e81..c1dd55aa 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -12,6 +12,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.logging import get_logger +from app.core.enums import ProviderApi, TVSourceType, UserRole from app.core.security import get_current_user from app.core.time import to_iso8601_utc from app.core.config import settings as app_settings @@ -73,7 +74,7 @@ router = APIRouter() logger = get_logger(__name__, service="api") AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5 AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test" -SECRET_REVEAL_ROLES = {"admin", "super_admin"} +SECRET_REVEAL_ROLES = {UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value} DEFAULT_SETTINGS = { "system": { @@ -236,7 +237,7 @@ class TVStreamSourceUpdate(BaseModel): provider: str = Field(default="Unknown", max_length=100) region: str = Field(default="Global", max_length=100) language: str = Field(default="und", max_length=32) - source_type: str = Field(default="iframe", pattern="^(iframe|hls|video|external|youtube)$") + source_type: TVSourceType = TVSourceType.IFRAME embed_url: str = "" stream_url: str = "" homepage_url: str = "" @@ -279,7 +280,7 @@ class AIProviderIntegrationUpdate(BaseModel): service_token: Optional[str] = None default_provider: Optional[str] = None provider: str = Field(default="minimax", max_length=80) - provider_api: str = Field(default="anthropic-messages", max_length=80) + provider_api: ProviderApi = ProviderApi.ANTHROPIC_MESSAGES base_url: str = Field(default="", max_length=500) model: str = Field(default="", max_length=200) api_key: Optional[str] = None @@ -423,7 +424,7 @@ def _get_provider_preset(provider: str) -> dict: except ValueError: return { "provider": provider, - "provider_api": "openai-completions", + "provider_api": ProviderApi.OPENAI_COMPLETIONS.value, "base_url": "", "model": "", "models": [], @@ -487,12 +488,12 @@ def _provider_defaults(provider: str) -> dict: preset = _get_provider_preset(provider) return { "provider": provider, - "provider_api": preset.get("provider_api") or "openai-completions", + "provider_api": preset.get("provider_api") or ProviderApi.OPENAI_COMPLETIONS.value, "base_url": preset.get("base_url") or "", "model": preset.get("model") or "", "api_key": "", "max_tokens": ( - 1200 if preset.get("provider_api") == "anthropic-messages" else 4096 + 1200 if preset.get("provider_api") == ProviderApi.ANTHROPIC_MESSAGES.value else 4096 ), "anthropic_version": "2023-06-01", "model_provider_apis": preset.get("model_provider_apis") or {}, @@ -665,7 +666,7 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict: ), "llm_config": { "provider": default_provider, - "provider_api": provider_config.get("provider_api") or "anthropic-messages", + "provider_api": provider_config.get("provider_api") or ProviderApi.ANTHROPIC_MESSAGES.value, "base_url": provider_config.get("base_url") or "", "model": provider_config.get("model") or "", "api_key": api_key, @@ -781,7 +782,10 @@ def _contains_model(model_ids: list[str], model: str) -> bool: async def _check_ai_provider_lightweight(llm_config: dict, timeout_seconds: int) -> dict: provider = _normalize_provider_id(llm_config.get("provider") or "") - configured_api = str(llm_config.get("provider_api") or "").strip() or "openai-completions" + configured_api = ( + str(llm_config.get("provider_api") or "").strip() + or ProviderApi.OPENAI_COMPLETIONS.value + ) model = str(llm_config.get("model") or "").strip() base_url = str(llm_config.get("base_url") or "").strip().rstrip("/") api_key = str(llm_config.get("api_key") or "").strip() @@ -802,7 +806,7 @@ async def _check_ai_provider_lightweight(llm_config: dict, timeout_seconds: int) "message": "当前 provider/base_url/model 未完整配置。", "mode": "lightweight_config", } - if provider_api != "ollama-generate" and not api_key: + if provider_api != ProviderApi.OLLAMA_GENERATE.value and not api_key: return { "success": False, "connected": False, @@ -813,13 +817,13 @@ async def _check_ai_provider_lightweight(llm_config: dict, timeout_seconds: int) if provider == "opencode-go": url = _join_provider_url(base_url, "/models") headers = {"Authorization": f"Bearer {api_key}"} - elif provider_api == "ollama-generate": + elif provider_api == ProviderApi.OLLAMA_GENERATE.value: url = _join_provider_url(base_url, "/api/tags") headers: dict[str, str] = {} - elif provider_api == "openai-completions": + elif provider_api == ProviderApi.OPENAI_COMPLETIONS.value: url = _join_provider_url(base_url, "/models") headers = {"Authorization": f"Bearer {api_key}"} - elif provider_api == "anthropic-messages": + elif provider_api == ProviderApi.ANTHROPIC_MESSAGES.value: url = _join_provider_url(base_url, "/models") headers = { "x-api-key": api_key, @@ -1165,7 +1169,7 @@ async def serialize_external_integrations(db: AsyncSession) -> dict: api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config) providers_payload[provider_id] = { "provider": provider_id, - "provider_api": provider_config.get("provider_api") or "openai-completions", + "provider_api": provider_config.get("provider_api") or ProviderApi.OPENAI_COMPLETIONS.value, "base_url": provider_config.get("base_url") or "", "model": provider_config.get("model") or "", "api_key": _mask_secret(api_key, api_key_source), @@ -1215,7 +1219,7 @@ async def serialize_external_integrations(db: AsyncSession) -> dict: "service_token": _mask_secret(*_resolve_service_token(normalized_ai)), "default_provider": default_provider, "provider": default_provider, - "provider_api": display_llm_config.get("provider_api") or "anthropic-messages", + "provider_api": display_llm_config.get("provider_api") or ProviderApi.ANTHROPIC_MESSAGES.value, "base_url": display_llm_config.get("base_url") or "https://api.minimaxi.com/anthropic", "model": display_llm_config.get("model") or "MiniMax-M2.7", "api_key": display_llm_config.get("api_key") or _mask_secret(None), @@ -1458,7 +1462,7 @@ async def update_smtp_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - if current_user.role not in ("admin", "super_admin"): + if current_user.role not in (UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value): raise HTTPException(status_code=403, detail="Only administrators can change SMTP settings") current = await get_setting_payload(db, "smtp") merged = _build_smtp_payload(current, payload) @@ -1472,7 +1476,7 @@ async def test_smtp_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - if current_user.role not in ("admin", "super_admin"): + if current_user.role not in (UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value): raise HTTPException(status_code=403, detail="Only administrators can test SMTP settings") from app.services.email import EmailError, send_email diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index fdcfca95..cc174126 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text +from app.core.enums import UserRole from app.core.security import get_current_user, get_password_hash from app.db.session import get_db from app.models.user import User @@ -13,6 +14,8 @@ from app.schemas.user import UserCreate, UserUpdate router = APIRouter() VALID_GATEKEEPER_GROUPS = {"docs_user", "docs_developer", "docs_admin"} +ADMIN_ROLES = [UserRole.SUPER_ADMIN.value, UserRole.ADMIN.value] +SUPER_ADMIN_ROLES = [UserRole.SUPER_ADMIN.value] def check_permission(current_user: User, required_roles: List[str]) -> bool: @@ -32,7 +35,7 @@ async def list_users( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - if not check_permission(current_user, ["super_admin", "admin"]): + if not check_permission(current_user, ADMIN_ROLES): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions", @@ -91,7 +94,7 @@ async def get_user( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - if not check_permission(current_user, ["super_admin", "admin"]) and current_user.id != user_id: + if not check_permission(current_user, ADMIN_ROLES) and current_user.id != user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions", @@ -128,7 +131,7 @@ async def create_user( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - if not check_permission(current_user, ["super_admin"]): + if not check_permission(current_user, SUPER_ADMIN_ROLES): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only super_admin can create users", @@ -196,18 +199,18 @@ async def update_user( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - if not check_permission(current_user, ["super_admin", "admin"]) and current_user.id != user_id: + if not check_permission(current_user, ADMIN_ROLES) and current_user.id != user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions", ) - if not check_permission(current_user, ["super_admin"]) and user_data.role is not None: + if not check_permission(current_user, SUPER_ADMIN_ROLES) and user_data.role is not None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only super_admin can change user role", ) - if not check_permission(current_user, ["super_admin"]) and user_data.gatekeeper_groups is not None: + if not check_permission(current_user, SUPER_ADMIN_ROLES) and user_data.gatekeeper_groups is not None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only super_admin can change Gatekeeper groups", @@ -260,7 +263,7 @@ async def delete_user( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - if not check_permission(current_user, ["super_admin"]): + if not check_permission(current_user, SUPER_ADMIN_ROLES): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only super_admin can delete users", diff --git a/backend/app/api/v1/vessels.py b/backend/app/api/v1/vessels.py index 2779e440..5dc6ded0 100644 --- a/backend/app/api/v1/vessels.py +++ b/backend/app/api/v1/vessels.py @@ -1,4 +1,4 @@ -"""Bounded vessel snapshot APIs for viewport-first consumers.""" +"""Bounded vessel snapshot APIs backed by the latest vessel state table.""" from typing import Optional @@ -14,8 +14,8 @@ router = APIRouter() @router.get("/snapshot") async def get_vessel_snapshot( - bbox: Optional[str] = Query(None, description="Viewport bbox as lon_min,lat_min,lon_max,lat_max"), - zoom: int = Query(..., ge=1, le=20, description="Current map zoom level"), + bbox: Optional[str] = Query(None, description="Snapshot bbox as lon_min,lat_min,lon_max,lat_max"), + zoom: float = Query(..., ge=1, le=20, description="Current map zoom level"), type: Optional[str] = Query( None, description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other", diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 10ba8942..12c2a4a8 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -18,6 +18,7 @@ from sqlalchemy import select, func from typing import List, Dict, Any, Optional from app.core.collected_data_fields import get_record_field +from app.core.enums import BGPStatus from app.core.satellite_tle import build_tle_lines_from_elements from app.core.time import to_iso8601_utc from app.db.session import get_db @@ -25,7 +26,7 @@ from app.models.bgp_anomaly import BGPAnomaly from app.models.bgp_incident import BGPIncident from app.models.bgp_observation import BGPObservation from app.models.collected_data import CollectedData -from app.models.vessel import AISSourceHealth, VesselPosition, VesselStatic +from app.models.vessel import AISSourceHealth, VesselCurrentState, VesselPosition, VesselStatic from app.services.bgp_collectors import build_bgp_collector_coverage from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance from app.services.compute_center_locations import ( @@ -47,11 +48,10 @@ from app.services.location.llm_fallback import ( from app.services.persistent_logs import record_system_log from app.services.vessel_ais_aggregation import ( build_field_conflict_candidates, - count_unique_raw_vessel_mmsi, get_aggregated_vessel, get_aggregated_vessel_track, get_aggregated_vessels, - get_aggregated_vessels_snapshot, + get_current_vessels_snapshot, get_vessel_conflict_records, get_vessel_raw_observations, MAX_SNAPSHOT_LIMIT, @@ -75,7 +75,6 @@ TERRAIN_TILE_BATCH_MAX_ITEMS = 128 TERRAIN_TILE_BATCH_CONCURRENCY = 16 _terrain_tile_cache: OrderedDict[tuple[int, int, int], tuple[bytes, str, dict[str, str]]] = OrderedDict() VESSEL_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE) -VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED = True SECONDS_PER_MINUTE = 60 BYTES_PER_MIB = 1024 * 1024 CABLE_CACHE_FRESH_SECONDS = 6 * 60 * SECONDS_PER_MINUTE @@ -839,9 +838,14 @@ def convert_aggregated_vessels_to_geojson(vessels: List[dict[str, Any]]) -> Dict continue source_summary = {} for source, summary in (vessel.get("source_summary") or {}).items(): + latest_observed_at = summary.get("latest_observed_at") source_summary[source] = { **summary, - "latest_observed_at": to_iso8601_utc(summary.get("latest_observed_at")), + "latest_observed_at": ( + to_iso8601_utc(latest_observed_at) + if isinstance(latest_observed_at, datetime) + else latest_observed_at + ), } props = { "mmsi": vessel["mmsi"], @@ -1075,7 +1079,7 @@ async def build_vessel_snapshot_response( db: AsyncSession, *, bbox: tuple[float, float, float, float] | None, - zoom: int | None, + zoom: float | None, type_filter: str | None, limit: int | None, since_minutes: int = 60, @@ -2340,58 +2344,35 @@ async def _load_raw_vessel_snapshot_features( observed_since: datetime, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: if bbox is None: - aggregated_vessels = await get_aggregated_vessels( - db, - limit=limit, - observed_since=observed_since, - ) - else: - aggregated_vessels = await get_aggregated_vessels_snapshot( - db, - bbox=bbox, - limit=limit, - observed_since=observed_since, - ) - raw_geojson = convert_aggregated_vessels_to_geojson(aggregated_vessels) - raw_features = raw_geojson.get("features", []) - features = raw_features - legacy_features: list[dict[str, Any]] = [] - legacy_fallback_used = False - if not raw_features and VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED: - legacy_features = await _load_legacy_vessel_snapshot_features( - db, - bbox=bbox, - limit=limit, - ) - features, _merge_diagnostics = _merge_vessel_features(raw_features, legacy_features) - legacy_fallback_used = bool(legacy_features) - + return [], { + "source": "vessel_current_state", + "current_state_count": 0, + "final_unique_mmsi": 0, + } + current_vessels = await get_current_vessels_snapshot( + db, + bbox=bbox, + limit=limit, + observed_since=observed_since, + ) + features = convert_aggregated_vessels_to_geojson(current_vessels).get("features", []) + unique_mmsi = len( + { + key + for key in (_feature_mmsi_key(feature) for feature in features) + if key is not None + } + ) return features, { - "raw_feature_count": len(raw_features), - "raw_unique_mmsi": len( - { - key - for key in (_feature_mmsi_key(feature) for feature in raw_features) - if key is not None - } - ), - "legacy_feature_count": len(legacy_features), - "legacy_backfilled_mmsi": len( - { - key - for key in (_feature_mmsi_key(feature) for feature in legacy_features) - if key is not None - } - ), - "legacy_fallback_enabled": VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED, - "legacy_fallback_used": legacy_fallback_used, - "final_unique_mmsi": len( - { - key - for key in (_feature_mmsi_key(feature) for feature in features) - if key is not None - } - ), + "source": "vessel_current_state", + "current_state_count": len(features), + "final_unique_mmsi": unique_mmsi, + "raw_feature_count": 0, + "raw_unique_mmsi": 0, + "legacy_feature_count": 0, + "legacy_backfilled_mmsi": 0, + "legacy_fallback_enabled": False, + "legacy_fallback_used": False, } @router.get("/vessels/custom-supplements") @@ -2759,10 +2740,10 @@ async def _build_visualization_geo_summary(db: AsyncSession) -> dict[str, Any]: compute_center_count = supercomputer_count + gpu_cluster_count active_incident_result = await db.execute( - select(func.count(BGPIncident.id)).where(BGPIncident.status == "active"), + select(func.count(BGPIncident.id)).where(BGPIncident.status == BGPStatus.ACTIVE.value), ) active_anomaly_result = await db.execute( - select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == "active"), + select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == BGPStatus.ACTIVE.value), ) active_incident_count = int(active_incident_result.scalar() or 0) active_anomaly_count = int(active_anomaly_result.scalar() or 0) @@ -2783,21 +2764,14 @@ async def _build_visualization_geo_summary(db: AsyncSession) -> dict[str, Any]: ) else: bgp_collector_count = int(bgp_collector_scalar or 0) - raw_unique_window_hours = 24 - raw_unique_mmsi = await count_unique_raw_vessel_mmsi( - db, - observed_since=datetime.now(UTC) - timedelta(hours=raw_unique_window_hours), + vessel_current_window_minutes = 60 + vessel_current_result = await db.execute( + select(func.count(VesselCurrentState.mmsi)).where( + VesselCurrentState.observed_at + >= datetime.now(UTC) - timedelta(minutes=vessel_current_window_minutes) + ) ) - legacy_unique_result = await db.execute( - select(func.count(func.distinct(VesselPosition.mmsi))) - ) - legacy_unique_mmsi = int(legacy_unique_result.scalar() or 0) - legacy_fallback_active = ( - VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED - and raw_unique_mmsi == 0 - and legacy_unique_mmsi > 0 - ) - vessel_count = legacy_unique_mmsi if legacy_fallback_active else raw_unique_mmsi + vessel_count = int(vessel_current_result.scalar() or 0) aisstream_health = await db.get(AISSourceHealth, "aisstream_vessels") return { @@ -2808,11 +2782,10 @@ async def _build_visualization_geo_summary(db: AsyncSession) -> dict[str, Any]: "satellite_count": satellite_count, "compute_center_count": compute_center_count, "vessel_count": vessel_count, - "vessel_count_source": "legacy_fallback" if legacy_fallback_active else "raw_recent", - "vessel_legacy_fallback_enabled": VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED, - "vessel_raw_unique_mmsi": raw_unique_mmsi, - "vessel_raw_unique_window_hours": raw_unique_window_hours, - "vessel_legacy_unique_mmsi": legacy_unique_mmsi, + "vessel_count_source": "vessel_current_state", + "vessel_current_window_minutes": vessel_current_window_minutes, + "vessel_raw_unique_mmsi": 0, + "vessel_legacy_unique_mmsi": 0, "aisstream_connection_state": aisstream_health.connection_state if aisstream_health else None, "aisstream_last_seen_at": to_iso8601_utc(aisstream_health.last_seen_at) if aisstream_health else None, "aisstream_message_rate": aisstream_health.message_rate if aisstream_health else None, diff --git a/backend/app/api/v1/websocket.py b/backend/app/api/v1/websocket.py index a84fc640..4fa22eee 100644 --- a/backend/app/api/v1/websocket.py +++ b/backend/app/api/v1/websocket.py @@ -9,6 +9,7 @@ from jose import jwt, JWTError from sqlalchemy import text from app.core.config import settings +from app.core.enums import UserRole from app.core.logging import get_logger from app.core.time import to_iso8601_utc from app.core.websocket.manager import manager @@ -96,7 +97,7 @@ async def websocket_endpoint( "earth_news", EARTH_UPDATES_CHANNEL, ] - if user_role == "super_admin": + if user_role == UserRole.SUPER_ADMIN.value: supported_channels = [*supported_channels, LOG_TAIL_CHANNEL] await manager.connect(websocket, user_id) @@ -138,7 +139,7 @@ async def websocket_endpoint( if channel and channel not in channels: channels = [*channels, channel] if LOG_TAIL_CHANNEL in channels: - if user_role != "super_admin": + if user_role != UserRole.SUPER_ADMIN.value: await websocket.send_json( { "type": "subscription_error", diff --git a/backend/app/core/enums.py b/backend/app/core/enums.py new file mode 100644 index 00000000..5e849600 --- /dev/null +++ b/backend/app/core/enums.py @@ -0,0 +1,225 @@ +"""Stable backend protocol enums. + +Database columns and JSON payloads continue to store the enum string values. +Configurable identifiers, user-authored values, and open-ended taxonomies do +not belong in this module. +""" + +from __future__ import annotations + +import logging +from enum import StrEnum +from typing import TypeVar + +logger = logging.getLogger(__name__) + +EnumT = TypeVar("EnumT", bound=StrEnum) + + +def parse_enum(enum_type: type[EnumT], value: object, default: EnumT) -> EnumT: + """Parse an external value without breaking reads of legacy data.""" + + if value is None or str(value).strip() == "": + return default + if isinstance(value, enum_type): + return value + try: + return enum_type(str(value).strip().lower()) + except (TypeError, ValueError): + logger.warning( + "Unknown %s value %r; falling back to %s", + enum_type.__name__, + value, + default.value, + ) + return default + + +class NewsImportanceLevel(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class BreakingLevel(StrEnum): + NONE = "none" + WATCH = "watch" + BREAKING = "breaking" + CRITICAL = "critical" + + +class BreakingScope(StrEnum): + REGIONAL = "regional" + GLOBAL = "global" + + +class BreakingSource(StrEnum): + RULES = "rules" + AI = "ai" + MANUAL = "manual" + MULTI_SOURCE = "multi_source" + + +class NewsSourceType(StrEnum): + RSS = "rss" + ATOM = "atom" + AGGREGATED = "aggregated" + REFERENCE = "reference" + + +class NewsEnrichmentStatus(StrEnum): + PENDING = "pending" + QUEUED = "queued" + ATTEMPTED = "attempted" + SUCCESS = "success" + CONTENT_ONLY = "content_only" + LOCATION_ONLY = "location_only" + UNAVAILABLE = "unavailable" + PROVIDER_ERROR = "provider_error" + PARSE_ERROR = "parse_error" + NO_RESULT = "no_result" + + +class NewsMarketImpact(StrEnum): + NONE = "none" + SECTOR = "sector" + NATIONAL = "national" + GLOBAL = "global" + + +class NewsTaggingSource(StrEnum): + RULES = "rules" + AI = "ai" + MANUAL = "manual" + + +class JobType(StrEnum): + COLLECT = "collect" + CLEAR_DATA = "clear_data" + CLEAR_CACHE = "clear_cache" + EARTH_REFRESH = "earth_refresh" + + +class JobStatus(StrEnum): + QUEUED = "queued" + RUNNING = "running" + CANCELLING = "cancelling" + SUCCESS = "success" + FAILED = "failed" + CANCELLED = "cancelled" + + +class RollbackPolicy(StrEnum): + KEEP_COMMITTED_BATCHES = "keep_committed_batches" + + +class MappingValidationStatus(StrEnum): + DRAFT = "draft" + VALID = "valid" + INVALID = "invalid" + + +class SnapshotStatus(StrEnum): + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + CANCELLED = "cancelled" + + +class DatasourceRunStatus(StrEnum): + RUNNING = "running" + NOT_RUN = "not_run" + COLLECTED = "collected" + UNCOLLECTED = "uncollected" + + +class ProviderApi(StrEnum): + ANTHROPIC_MESSAGES = "anthropic-messages" + OPENAI_COMPLETIONS = "openai-completions" + OLLAMA_GENERATE = "ollama-generate" + + +class PlaygroundMessageRole(StrEnum): + SYSTEM = "system" + USER = "user" + ASSISTANT = "assistant" + TOOL = "tool" + + +class PlaygroundMessageKind(StrEnum): + MESSAGE = "message" + THINKING = "thinking" + ERROR = "error" + STATUS = "status" + + +class PlaygroundMessageStatus(StrEnum): + PENDING = "pending" + THINKING = "thinking" + ANSWERING = "answering" + DONE = "done" + FAILED = "failed" + CANCELLED = "cancelled" + ERROR = "error" + STOPPED = "stopped" + + +class OtpPurpose(StrEnum): + REGISTER = "register" + VERIFY_EMAIL = "verify_email" + RESET_PASSWORD = "reset_password" + + +class UserRole(StrEnum): + VIEWER = "viewer" + ADMIN = "admin" + SUPER_ADMIN = "super_admin" + + +class AlertSeverity(StrEnum): + CRITICAL = "critical" + WARNING = "warning" + INFO = "info" + + +class AlertStatus(StrEnum): + ACTIVE = "active" + ACKNOWLEDGED = "acknowledged" + RESOLVED = "resolved" + + +class BGPStatus(StrEnum): + ACTIVE = "active" + ACKNOWLEDGED = "acknowledged" + RESOLVED = "resolved" + + +class LogLevel(StrEnum): + ALL = "all" + ERROR = "error" + WARNING = "warning" + INFO = "info" + DEBUG = "debug" + + +class ConnectionState(StrEnum): + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + ERROR = "error" + + +class AuthType(StrEnum): + NONE = "none" + BEARER = "bearer" + API_KEY = "api_key" + BASIC = "basic" + + +class TVSourceType(StrEnum): + IFRAME = "iframe" + HLS = "hls" + VIDEO = "video" + EXTERNAL = "external" + YOUTUBE = "youtube" diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 8bd528a0..57aecbb4 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -626,6 +626,7 @@ async def init_db(): "bgp_collector_locations", "vessel_static", "vessel_position", + "vessel_current_state", "ais_raw_observations", "ais_source_health", "compute_center_locations", @@ -786,6 +787,22 @@ async def init_db(): """ ) ) + await conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_vessel_current_bbox + ON vessel_current_state (lon, lat) + """ + ) + ) + await conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_vessel_current_observed + ON vessel_current_state (observed_at DESC) + """ + ) + ) await conn.execute( text( """ diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py index 9c141e4c..90b3ea8d 100644 --- a/backend/app/models/alert.py +++ b/backend/app/models/alert.py @@ -1,26 +1,12 @@ from datetime import datetime -from enum import Enum -from typing import Optional -from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Enum as SQLEnum -from sqlalchemy.orm import relationship +from sqlalchemy import Column, Integer, String, DateTime, Text, Enum as SQLEnum +from app.core.enums import AlertSeverity, AlertStatus from app.core.time import to_iso8601_utc from app.db.session import Base -class AlertSeverity(str, Enum): - CRITICAL = "critical" - WARNING = "warning" - INFO = "info" - - -class AlertStatus(str, Enum): - ACTIVE = "active" - ACKNOWLEDGED = "acknowledged" - RESOLVED = "resolved" - - class Alert(Base): __tablename__ = "alerts" diff --git a/backend/app/models/bgp_anomaly.py b/backend/app/models/bgp_anomaly.py index 013aa8fa..b54d987e 100644 --- a/backend/app/models/bgp_anomaly.py +++ b/backend/app/models/bgp_anomaly.py @@ -4,6 +4,7 @@ from datetime import datetime from sqlalchemy import Column, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text +from app.core.enums import BGPStatus from app.core.time import to_iso8601_utc from app.db.session import Base @@ -17,7 +18,7 @@ class BGPAnomaly(Base): source = Column(String(100), nullable=False, index=True) anomaly_type = Column(String(50), nullable=False, index=True) severity = Column(String(20), nullable=False, index=True) - status = Column(String(20), nullable=False, default="active", index=True) + status = Column(String(20), nullable=False, default=BGPStatus.ACTIVE.value, index=True) entity_key = Column(String(255), nullable=False, index=True) prefix = Column(String(64), nullable=True, index=True) origin_asn = Column(Integer, nullable=True, index=True) diff --git a/backend/app/models/bgp_incident.py b/backend/app/models/bgp_incident.py index 4e901c7a..8e3e573f 100644 --- a/backend/app/models/bgp_incident.py +++ b/backend/app/models/bgp_incident.py @@ -4,6 +4,7 @@ from datetime import datetime from sqlalchemy import Column, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text +from app.core.enums import BGPStatus from app.core.time import to_iso8601_utc from app.db.session import Base @@ -20,7 +21,7 @@ class BGPIncident(Base): title = Column(String(255), nullable=False) summary = Column(Text, nullable=False) severity = Column(String(20), nullable=False, index=True) - status = Column(String(20), nullable=False, default="active", index=True) + status = Column(String(20), nullable=False, default=BGPStatus.ACTIVE.value, index=True) confidence = Column(Float, nullable=False, default=0.5) started_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, index=True) ended_at = Column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/models/data_snapshot.py b/backend/app/models/data_snapshot.py index f70b4f12..fd226d39 100644 --- a/backend/app/models/data_snapshot.py +++ b/backend/app/models/data_snapshot.py @@ -1,6 +1,7 @@ from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String from sqlalchemy.sql import func +from app.core.enums import SnapshotStatus from app.db.session import Base @@ -16,7 +17,7 @@ class DataSnapshot(Base): started_at = Column(DateTime(timezone=True), server_default=func.now()) completed_at = Column(DateTime(timezone=True), nullable=True) record_count = Column(Integer, default=0) - status = Column(String(20), nullable=False, default="running") + status = Column(String(20), nullable=False, default=SnapshotStatus.RUNNING.value) is_current = Column(Boolean, default=True, index=True) parent_snapshot_id = Column(Integer, ForeignKey("data_snapshots.id"), nullable=True, index=True) summary = Column(JSON, default={}) diff --git a/backend/app/models/datasource_mapping.py b/backend/app/models/datasource_mapping.py index eee2c269..6c7ddf3a 100644 --- a/backend/app/models/datasource_mapping.py +++ b/backend/app/models/datasource_mapping.py @@ -3,6 +3,7 @@ from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String from sqlalchemy.sql import func +from app.core.enums import MappingValidationStatus from app.db.session import Base @@ -19,7 +20,7 @@ class DataSourceMappingTemplate(Base): target_schema = Column(String(80), nullable=False, index=True) mapping_json = Column(JSON, nullable=False, default={}) sample_payload_hash = Column(String(64), nullable=True) - validation_status = Column(String(30), nullable=False, default="draft") + validation_status = Column(String(30), nullable=False, default=MappingValidationStatus.DRAFT.value) version = Column(Integer, nullable=False, default=1) is_active = Column(Boolean, nullable=False, default=False, index=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/playground_message.py b/backend/app/models/playground_message.py index ce85de8c..ed454137 100644 --- a/backend/app/models/playground_message.py +++ b/backend/app/models/playground_message.py @@ -1,6 +1,7 @@ from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.sql import func +from app.core.enums import PlaygroundMessageKind, PlaygroundMessageStatus from app.db.session import Base @@ -13,8 +14,8 @@ class PlaygroundMessage(Base): user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) parent_message_id = Column(Integer, ForeignKey("playground_messages.id", ondelete="SET NULL"), nullable=True) role = Column(String(20), nullable=False) - kind = Column(String(20), nullable=False, default="message") - status = Column(String(20), nullable=False, default="done") + kind = Column(String(20), nullable=False, default=PlaygroundMessageKind.MESSAGE.value) + status = Column(String(20), nullable=False, default=PlaygroundMessageStatus.DONE.value) title = Column(String(255), nullable=True) content = Column(Text, nullable=False, default="") thinking_content = Column(Text, nullable=False, default="") diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 6ccb836b..f35ca540 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -3,6 +3,7 @@ from sqlalchemy import BigInteger, Column, DateTime, Float, Integer, JSON, String, Text from sqlalchemy.sql import func +from app.core.enums import JobStatus, JobType, RollbackPolicy from app.db.session import Base @@ -12,9 +13,9 @@ class CollectionTask(Base): id = Column(Integer, primary_key=True, autoincrement=True) datasource_id = Column(Integer, nullable=False, index=True) source = Column(String(100), nullable=True, index=True) - task_type = Column(String(30), nullable=False, default="collect", index=True) + task_type = Column(String(30), nullable=False, default=JobType.COLLECT.value, index=True) status = Column(String(20), nullable=False) # queued, running, cancelling, success, failed, cancelled - phase = Column(String(30), default="queued") + phase = Column(String(30), default=JobStatus.QUEUED.value) phase_progress = Column(Float) phase_message = Column(String(255)) phase_current = Column(BigInteger) @@ -27,7 +28,7 @@ class CollectionTask(Base): progress = Column(Float, default=0.0) # Progress percentage (0-100) error_message = Column(Text) payload = Column(JSON, default=dict) - rollback_policy = Column(String(40), nullable=False, default="keep_committed_batches") + rollback_policy = Column(String(40), nullable=False, default=RollbackPolicy.KEEP_COMMITTED_BATCHES.value) dedupe_key = Column(String(180), nullable=True, index=True) worker_id = Column(String(120), nullable=True, index=True) locked_at = Column(DateTime(timezone=True), nullable=True, index=True) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index e06407b7..3b188b17 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,6 +1,7 @@ from sqlalchemy import Boolean, Column, DateTime, Integer, JSON, String from sqlalchemy.sql import func +from app.core.enums import UserRole from app.db.session import Base @@ -11,7 +12,7 @@ class User(Base): username = Column(String(50), unique=True, index=True, nullable=False) email = Column(String(255), unique=True, index=True, nullable=False) password_hash = Column(String(255), nullable=False) - role = Column(String(20), default="viewer") + role = Column(String(20), default=UserRole.VIEWER.value) gatekeeper_groups = Column(JSON, default=list) is_active = Column(Boolean, default=True) email_verified = Column(Boolean, default=False, nullable=False) diff --git a/backend/app/models/vessel.py b/backend/app/models/vessel.py index 35e93b1c..70992eb6 100644 --- a/backend/app/models/vessel.py +++ b/backend/app/models/vessel.py @@ -3,6 +3,7 @@ from sqlalchemy import BigInteger, Column, DateTime, Float, Index, Integer, JSON, SmallInteger, String from sqlalchemy.sql import func +from app.core.enums import ConnectionState from app.core.time import to_iso8601_utc from app.db.session import Base @@ -75,6 +76,67 @@ class VesselPosition(Base): } +class VesselCurrentState(Base): + """Latest renderable state for one vessel, independent from AIS history.""" + + __tablename__ = "vessel_current_state" + + mmsi = Column(BigInteger, primary_key=True) + lat = Column(Float, nullable=False) + lon = Column(Float, nullable=False) + sog = Column(Float, nullable=True) + cog = Column(Float, nullable=True) + heading = Column(SmallInteger, nullable=True) + nav_status = Column(SmallInteger, nullable=True, index=True) + name = Column(String(128), nullable=True) + callsign = Column(String(16), nullable=True) + vessel_type = Column(SmallInteger, nullable=True, index=True) + vessel_type_name = Column(String(64), nullable=True, index=True) + flag = Column(String(4), nullable=True, index=True) + length = Column(Float, nullable=True) + width = Column(Float, nullable=True) + draught = Column(Float, nullable=True) + imo = Column(BigInteger, nullable=True) + source = Column(String(100), nullable=False, index=True) + observed_at = Column(DateTime(timezone=True), nullable=False) + field_sources = Column(JSON, default=dict) + selected_reasons = Column(JSON, default=dict) + source_summary = Column(JSON, default=dict) + quality_flags = Column(JSON, default=list) + updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + __table_args__ = ( + Index("idx_vessel_current_bbox", "lon", "lat"), + Index("idx_vessel_current_observed", "observed_at"), + ) + + def to_dict(self) -> dict: + return { + "mmsi": self.mmsi, + "lat": self.lat, + "lon": self.lon, + "sog": self.sog, + "cog": self.cog, + "heading": self.heading, + "nav_status": self.nav_status, + "name": self.name, + "callsign": self.callsign, + "vessel_type": self.vessel_type, + "vessel_type_name": self.vessel_type_name, + "flag": self.flag, + "length": self.length, + "width": self.width, + "draught": self.draught, + "imo": self.imo, + "source": self.source, + "received_at": self.observed_at, + "field_sources": self.field_sources or {}, + "selected_reasons": self.selected_reasons or {}, + "source_summary": self.source_summary or {}, + "quality_flags": self.quality_flags or [], + } + + class AISRawObservation(Base): """Source-level AIS fact before aggregation and conflict resolution.""" @@ -165,7 +227,7 @@ class AISSourceHealth(Base): __tablename__ = "ais_source_health" source = Column(String(100), primary_key=True) - connection_state = Column(String(32), nullable=False, default="disconnected", index=True) + connection_state = Column(String(32), nullable=False, default=ConnectionState.DISCONNECTED.value, index=True) last_seen_at = Column(DateTime(timezone=True), nullable=True, index=True) last_success_at = Column(DateTime(timezone=True), nullable=True, index=True) last_error = Column(String(500), nullable=True) diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py index 037354f3..938a6820 100644 --- a/backend/app/schemas/ai.py +++ b/backend/app/schemas/ai.py @@ -2,6 +2,7 @@ from typing import Any from pydantic import BaseModel, Field +from app.core.enums import PlaygroundMessageKind, PlaygroundMessageRole, PlaygroundMessageStatus class AIContentBlock(BaseModel): type: str @@ -110,9 +111,9 @@ class PlaygroundSessionUpsertRequest(BaseModel): class PlaygroundMessageRecord(BaseModel): id: str - role: str - kind: str = "message" - status: str = "done" + role: PlaygroundMessageRole + kind: PlaygroundMessageKind = PlaygroundMessageKind.MESSAGE + status: PlaygroundMessageStatus = PlaygroundMessageStatus.DONE title: str | None = None content: str = "" thinking_content: str = "" diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index eaa0c272..34cd43f5 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -3,6 +3,7 @@ from typing import Optional from pydantic import BaseModel, EmailStr, Field +from app.core.enums import OtpPurpose, UserRole class UserBase(BaseModel): username: str @@ -11,13 +12,13 @@ class UserBase(BaseModel): class UserCreate(UserBase): password: str = Field(..., min_length=8) - role: str = "viewer" + role: UserRole = UserRole.VIEWER gatekeeper_groups: list[str] = Field(default_factory=list) class UserUpdate(BaseModel): email: Optional[EmailStr] = None - role: Optional[str] = None + role: Optional[UserRole] = None gatekeeper_groups: Optional[list[str]] = None is_active: Optional[bool] = None @@ -59,7 +60,7 @@ class VerifyEmailRequest(BaseModel): class ResendCodeRequest(BaseModel): email: EmailStr - purpose: str = Field(default="register", pattern="^(register|verify_email|reset_password)$") + purpose: OtpPurpose = OtpPurpose.REGISTER class ForgotPasswordRequest(BaseModel): diff --git a/backend/app/services/bgp_detectors.py b/backend/app/services/bgp_detectors.py index c1263ff8..f501ed69 100644 --- a/backend/app/services/bgp_detectors.py +++ b/backend/app/services/bgp_detectors.py @@ -6,6 +6,7 @@ from collections import Counter, defaultdict from datetime import UTC, datetime from typing import Any +from app.core.enums import BGPStatus from app.models.bgp_anomaly import BGPAnomaly @@ -127,7 +128,7 @@ def detect_origin_change_anomalies( source=source, anomaly_type=anomaly_type, severity=severity, - status="active", + status=BGPStatus.ACTIVE.value, entity_key=f"{anomaly_type}:{prefix}:{new_origin}", prefix=prefix, origin_asn=sorted(historic)[0] if historic else None, @@ -197,7 +198,7 @@ def detect_more_specific_burst_anomalies( source=source, anomaly_type="more_specific_burst", severity="high", - status="active", + status=BGPStatus.ACTIVE.value, entity_key=f"more_specific_burst:{root_prefix}:{len(unique_prefixes)}:{len(related_collectors)}", prefix=sample.get("prefix"), origin_asn=sample.get("origin_asn"), @@ -267,7 +268,7 @@ def detect_mass_withdrawal_anomalies( source=source, anomaly_type="mass_withdrawal", severity=severity, - status="active", + status=BGPStatus.ACTIVE.value, entity_key=f"mass_withdrawal:{prefix}:{origin_asn}:{len(related_collectors)}:{count}", prefix=prefix, origin_asn=origin_asn, @@ -354,7 +355,7 @@ def detect_route_leak_anomalies( source=source, anomaly_type="route_leak_candidate", severity="high" if max_path_length >= dominant_length + 3 else "medium", - status="active", + status=BGPStatus.ACTIVE.value, entity_key=f"route_leak_candidate:{prefix}:{max_path_length}:{len(related_collectors)}", prefix=prefix, origin_asn=sample_metadata.get("origin_asn"), @@ -435,7 +436,7 @@ def detect_path_flap_anomalies( source=source, anomaly_type="path_flap", severity=severity, - status="active", + status=BGPStatus.ACTIVE.value, entity_key=f"path_flap:{prefix}:{transitions}:{len(distinct_paths)}", prefix=prefix, origin_asn=sample_metadata.get("origin_asn"), diff --git a/backend/app/services/bgp_incidents.py b/backend/app/services/bgp_incidents.py index 91e454f1..af15c2ce 100644 --- a/backend/app/services/bgp_incidents.py +++ b/backend/app/services/bgp_incidents.py @@ -9,6 +9,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.collected_data_fields import get_record_field +from app.core.enums import BGPStatus from app.models.bgp_anomaly import BGPAnomaly from app.models.bgp_incident import BGPIncident from app.models.collected_data import CollectedData @@ -290,7 +291,7 @@ async def create_bgp_incidents_for_anomalies( existing.title = title existing.summary = summary existing.severity = severity - existing.status = "active" + existing.status = BGPStatus.ACTIVE.value existing.confidence = confidence existing.started_at = primary.started_at or existing.started_at or datetime.now(UTC) existing.ended_at = None @@ -313,7 +314,7 @@ async def create_bgp_incidents_for_anomalies( title=title, summary=summary, severity=severity, - status="active", + status=BGPStatus.ACTIVE.value, confidence=confidence, started_at=primary.started_at or datetime.now(UTC), affected_prefixes=prefixes, diff --git a/backend/app/services/collectors/base.py b/backend/app/services/collectors/base.py index ea9983eb..81627290 100644 --- a/backend/app/services/collectors/base.py +++ b/backend/app/services/collectors/base.py @@ -12,11 +12,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.collected_data_fields import build_dynamic_metadata, get_record_field from app.core.countries import normalize_country +from app.core.enums import JobStatus, SnapshotStatus from app.core.logging import get_logger from app.core.time import to_iso8601_utc from app.core.websocket.broadcaster import broadcaster from app.services.business_logs import emit_business_log, exception_context -from app.services.earth_layer_adapters import get_earth_update_layers_for_source logger = get_logger(__name__, service="collector") @@ -238,7 +238,7 @@ class BaseCollector(ABC): snapshot = await db.get(DataSnapshot, snapshot_id) if snapshot: parent_snapshot_id = snapshot.parent_snapshot_id - snapshot.status = "cancelled" + snapshot.status = SnapshotStatus.CANCELLED.value snapshot.is_current = False snapshot.completed_at = datetime.now(UTC) summary = dict(snapshot.summary or {}) @@ -308,7 +308,7 @@ class BaseCollector(ABC): task.datasource_id = datasource_id task.source = task.source or self.name task.task_type = task.task_type or "collect" - task.status = "running" + task.status = JobStatus.RUNNING.value task.phase = "queued" task.started_at = task.started_at or start_time task.completed_at = None @@ -393,7 +393,7 @@ class BaseCollector(ABC): }, ) - task.status = "success" + task.status = JobStatus.SUCCESS.value task.phase = "completed" task.phase_progress = 100.0 task.phase_message = "采集完成" @@ -428,7 +428,7 @@ class BaseCollector(ABC): } except asyncio.CancelledError: await db.rollback() - task.status = "cancelled" + task.status = JobStatus.CANCELLED.value task.phase = "cancelled" task.phase_message = "采集已取消" task.error_message = "Collection cancelled by operator and rolled back" @@ -456,7 +456,7 @@ class BaseCollector(ABC): raise except Exception as e: await db.rollback() - task.status = "failed" + task.status = JobStatus.FAILED.value task.phase = "failed" task.phase_message = str(e) task.error_message = str(e) @@ -464,7 +464,7 @@ class BaseCollector(ABC): if snapshot_id is not None: snapshot = await db.get(DataSnapshot, snapshot_id) if snapshot: - snapshot.status = "failed" + snapshot.status = SnapshotStatus.FAILED.value snapshot.completed_at = datetime.now(UTC) snapshot.summary = {"error": str(e)} await db.commit() @@ -509,7 +509,7 @@ class BaseCollector(ABC): if snapshot: snapshot.record_count = 0 snapshot.summary = {"created": 0, "updated": 0, "unchanged": 0} - snapshot.status = "success" + snapshot.status = SnapshotStatus.SUCCESS.value snapshot.completed_at = datetime.now(UTC) await db.commit() return 0 @@ -642,7 +642,7 @@ class BaseCollector(ABC): snapshot = await db.get(DataSnapshot, snapshot_id) if snapshot: snapshot.record_count = records_added - snapshot.status = "success" + snapshot.status = SnapshotStatus.SUCCESS.value snapshot.completed_at = datetime.now(UTC) snapshot.summary = { "created": created_count, diff --git a/backend/app/services/collectors/vessel_ais.py b/backend/app/services/collectors/vessel_ais.py index 2bf0d2e3..52c931f6 100644 --- a/backend/app/services/collectors/vessel_ais.py +++ b/backend/app/services/collectors/vessel_ais.py @@ -6,6 +6,7 @@ from typing import Any import httpx from sqlalchemy.ext.asyncio import AsyncSession +from app.core.enums import SnapshotStatus from app.core.time import to_iso8601_utc from app.core.websocket.broadcaster import broadcaster from app.services.barentswatch import ( @@ -125,7 +126,7 @@ class VesselAISCollector(BaseCollector): snapshot = await db.get(DataSnapshot, snapshot_id) if snapshot: snapshot.record_count = records_added - snapshot.status = "success" + snapshot.status = SnapshotStatus.SUCCESS.value snapshot.completed_at = now snapshot.summary = { "created": records_added, diff --git a/backend/app/services/data_jobs.py b/backend/app/services/data_jobs.py index 58a2a778..a10ab057 100644 --- a/backend/app/services/data_jobs.py +++ b/backend/app/services/data_jobs.py @@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.cache import cache from app.core.config import settings +from app.core.enums import JobStatus, JobType, RollbackPolicy from app.core.logging import get_logger from app.core.time import to_iso8601_utc from app.core.websocket.broadcaster import broadcaster @@ -36,17 +37,17 @@ from app.services.scheduler import sync_datasource_job logger = get_logger(__name__) -JOB_TYPE_COLLECT = "collect" -JOB_TYPE_CLEAR_DATA = "clear_data" -JOB_TYPE_CLEAR_CACHE = "clear_cache" -JOB_TYPE_EARTH_REFRESH = "earth_refresh" +JOB_TYPE_COLLECT = JobType.COLLECT.value +JOB_TYPE_CLEAR_DATA = JobType.CLEAR_DATA.value +JOB_TYPE_CLEAR_CACHE = JobType.CLEAR_CACHE.value +JOB_TYPE_EARTH_REFRESH = JobType.EARTH_REFRESH.value -JOB_STATUS_QUEUED = "queued" -JOB_STATUS_RUNNING = "running" -JOB_STATUS_CANCELLING = "cancelling" -JOB_STATUS_SUCCESS = "success" -JOB_STATUS_FAILED = "failed" -JOB_STATUS_CANCELLED = "cancelled" +JOB_STATUS_QUEUED = JobStatus.QUEUED.value +JOB_STATUS_RUNNING = JobStatus.RUNNING.value +JOB_STATUS_CANCELLING = JobStatus.CANCELLING.value +JOB_STATUS_SUCCESS = JobStatus.SUCCESS.value +JOB_STATUS_FAILED = JobStatus.FAILED.value +JOB_STATUS_CANCELLED = JobStatus.CANCELLED.value ACTIVE_JOB_STATUSES = (JOB_STATUS_QUEUED, JOB_STATUS_RUNNING, JOB_STATUS_CANCELLING) TERMINAL_JOB_STATUSES = (JOB_STATUS_SUCCESS, JOB_STATUS_FAILED, JOB_STATUS_CANCELLED) @@ -80,7 +81,7 @@ async def enqueue_datasource_job( task_type: str, *, payload: dict[str, Any] | None = None, - rollback_policy: str = "keep_committed_batches", + rollback_policy: str = RollbackPolicy.KEEP_COMMITTED_BATCHES.value, dedupe_key: str | None = None, ) -> CollectionTask: if dedupe_key: diff --git a/backend/app/services/datasource_connectivity.py b/backend/app/services/datasource_connectivity.py index f6c7fb3a..db18342d 100644 --- a/backend/app/services/datasource_connectivity.py +++ b/backend/app/services/datasource_connectivity.py @@ -13,6 +13,7 @@ from sqlalchemy import func, select from app.core.data_sources import get_data_sources_config from app.core.datasource_defaults import DEFAULT_DATASOURCES +from app.core.enums import JobStatus from app.models.collected_data import CollectedData from app.models.datasource import DataSource from app.models.datasource_config import DataSourceConfig @@ -398,7 +399,7 @@ async def has_collected_data(db, source: str) -> bool: datasource_result = await db.execute(select(DataSource).where(DataSource.source == source)) datasource = datasource_result.scalar_one_or_none() - return bool(datasource and datasource.last_status == "success") + return bool(datasource and datasource.last_status == JobStatus.SUCCESS.value) async def get_builtin_connection_status( diff --git a/backend/app/services/docs_gatekeeper.py b/backend/app/services/docs_gatekeeper.py index abad92e0..8640d098 100644 --- a/backend/app/services/docs_gatekeeper.py +++ b/backend/app/services/docs_gatekeeper.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Literal +from app.core.enums import UserRole from app.models.user import User DocsAccess = Literal["public", "docs_user", "docs_developer", "docs_admin"] @@ -43,7 +44,9 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = ( DocsMetadata("earth-satellite-footprint-policy.md", "earth-satellite-footprint-policy", "docs_developer", "Earth", 13, "智能星球卫星覆盖策略", "Intelligent Planet Satellite Footprint Policy"), DocsMetadata("earth-bgp-context.md", "earth-bgp-context", "docs_developer", "Earth", 14, "BGP 态势上下文", "BGP Context"), DocsMetadata("earth-interactable-usage.md", "earth-interactable-usage", "docs_developer", "Earth", 16, "智能星球可交互图标接入", "Intelligent Planet Interactable Usage"), + DocsMetadata("earth-interactable-clustering.md", "earth-interactable-clustering", "docs_developer", "Earth", 17, "智能星球可交互图标聚类策略", "Intelligent Planet Interactable Clustering"), DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 17, "智能星球工具栏与浮层协同", "Intelligent Planet Toolbar and Overlay Coordination"), + DocsMetadata("earth-news-sources.md", "earth-news-sources", "docs_developer", "Earth", 19, "智能星球新闻源配置", "Intelligent Planet News Source Configuration"), DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"), DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"), DocsMetadata("tactile-ui-components.md", "tactile-ui-components", "docs_developer", "Frontend", 24, "Tactile UI 组件库", "Tactile UI Components"), @@ -52,7 +55,8 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = ( DocsMetadata("datasource-collector-settings-connectivity.md", "datasource-collector-settings-connectivity", "docs_developer", "Backend", 32, "数据源、采集器设置与连接验证", "Datasource Collector Settings and Connectivity"), DocsMetadata("backend-datasources-api-performance.md", "backend-datasources-api-performance", "docs_developer", "Backend", 33, "数据源 API 性能", "Datasource API Performance"), DocsMetadata("data-job-earth-sync-architecture.md", "data-job-earth-sync-architecture", "docs_developer", "Backend", 34, "数据作业与 Outbox 技术架构", "Data Jobs and Outbox Architecture"), - DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 35, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"), + DocsMetadata("backend-enum-contracts.md", "backend-enum-contracts", "docs_developer", "Backend", 35, "后端枚举与字符串兼容契约", "Backend Enum and String Compatibility Contract"), + DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 36, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"), DocsMetadata("earth-news-live-streams-collector-format.md", "earth-news-live-streams-collector-format", "docs_developer", "Backend", 36, "新闻直播采集格式", "News Live Streams Collector Format"), DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Backend", 37, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"), DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"), @@ -69,9 +73,9 @@ def get_user_gatekeeper_groups(user: User | None) -> set[str]: return set() role = user.role.value if hasattr(user.role, "value") else str(user.role or "") - if role == "super_admin": + if role == UserRole.SUPER_ADMIN.value: return {"docs_user", "docs_developer", "docs_admin"} - if role == "admin": + if role == UserRole.ADMIN.value: return {"docs_user", "docs_developer", "docs_admin"} groups = set() diff --git a/backend/app/services/earth_layer_adapters.py b/backend/app/services/earth_layer_adapters.py index 89685d4f..078e6cd7 100644 --- a/backend/app/services/earth_layer_adapters.py +++ b/backend/app/services/earth_layer_adapters.py @@ -20,8 +20,8 @@ class EarthLayerAdapter: EARTH_LAYER_ADAPTERS: tuple[EarthLayerAdapter, ...] = ( EarthLayerAdapter( - sources=frozenset({"barentswatch_vessels", "aisstream_vessels", "vessel_static", "vessel_position", "ais_raw_observations", "ais_source_health"}), - tables=frozenset({"vessel_static", "vessel_position", "ais_raw_observations", "ais_source_health"}), + sources=frozenset({"barentswatch_vessels", "aisstream_vessels", "vessel_static", "vessel_position", "vessel_current_state", "ais_raw_observations", "ais_source_health"}), + tables=frozenset({"vessel_static", "vessel_position", "vessel_current_state", "ais_raw_observations", "ais_source_health"}), layers=("vessels",), cache_patterns=("vessels*", "summary*"), derived_models=("ais_raw_observations", "ais_conflict_records", "ais_source_health"), diff --git a/backend/app/services/earth_news.py b/backend/app/services/earth_news.py index 6495a8b7..ed0977c4 100644 --- a/backend/app/services/earth_news.py +++ b/backend/app/services/earth_news.py @@ -20,10 +20,27 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.countries import COUNTRY_VARIANTS_MAP, get_country_centroid, normalize_country +from app.core.enums import ( + BreakingLevel, + BreakingScope, + BreakingSource, + NewsEnrichmentStatus, + NewsImportanceLevel, + NewsMarketImpact, + NewsSourceType, + NewsTaggingSource, +) from app.models.system_setting import SystemSetting from app.ai_tasks.prompts import EffectiveAIPrompt, get_effective_prompt from app.schemas.ai import SituationalAnalysisRequest from app.services.ai_client import AIProviderClient +from app.services.earth_news_classification import ( + apply_news_classification as _apply_news_classification, + breaking_sort_rank as _breaking_sort_rank, + highest_breaking_level as _highest_breaking_level, + normalize_breaking_level as _normalize_breaking_level_enum, + normalize_breaking_scope as _normalize_breaking_scope_enum, +) from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder @@ -69,7 +86,7 @@ class NewsFeedEndpoint: id: str name: str url: str - type: str = "rss" + type: str = NewsSourceType.RSS.value region: str = "" enabled: bool = True default_category: str = "other" @@ -85,7 +102,7 @@ class NewsFeedSource: feed_url: str homepage_url: str feed_directory_url: str = "" - source_type: str = "rss" + source_type: str = NewsSourceType.RSS.value feed_urls: tuple[str, ...] = () feeds: tuple[NewsFeedEndpoint, ...] = () priority: int = 100 @@ -120,7 +137,7 @@ class ParsedNewsItem: published_at: datetime | None content_language: str = "en" localizations: dict[str, dict[str, str]] = field(default_factory=dict) - enrichment_status: str = "pending" + enrichment_status: str = NewsEnrichmentStatus.PENDING.value enrichment_error: str | None = None enriched_at: datetime | None = None target_location: NewsTargetLocation | None = None @@ -132,16 +149,22 @@ class ParsedNewsItem: location_patch: dict[str, Any] | None = None source_tags: list[str] = field(default_factory=list) feed_id: str = "" - feed_type: str = "rss" + feed_type: str = NewsSourceType.RSS.value feed_default_category: str = "other" category: str = "other" item_tags: list[str] = field(default_factory=list) - tagging_source: str = "rules" + tagging_source: str = NewsTaggingSource.RULES.value tagging_confidence: float = 0.0 importance_score: int = 0 - importance_level: str = "low" + importance_level: str = NewsImportanceLevel.LOW.value importance_reasons: list[str] = field(default_factory=list) - market_impact: str = "none" + market_impact: str = NewsMarketImpact.NONE.value + breaking_level: str = BreakingLevel.NONE.value + breaking_scope: str = BreakingScope.REGIONAL.value + breaking_reasons: list[str] = field(default_factory=list) + breaking_source: str = BreakingSource.RULES.value + breaking_confidence: float = 0.0 + breaking_expires_at: datetime | None = None @dataclass @@ -1863,34 +1886,12 @@ def _parse_feed_entries( return items -def _contains_keyword(text: str, keyword: str) -> bool: - keyword_text = str(keyword or "").strip().lower() - if not keyword_text: - return False - if re.search(r"[\u4e00-\u9fff]", keyword_text): - return keyword_text in text - return re.search(rf"(? str: + return _normalize_breaking_level_enum(value).value -def _score_category(text: str, title_text: str, category: dict[str, Any]) -> int: - score = 0 - keywords = category.get("keywords") if isinstance(category.get("keywords"), list) else [] - for keyword in keywords: - if _contains_keyword(title_text, keyword): - score += 3 - elif _contains_keyword(text, keyword): - score += 1 - return score - - -def _importance_level(score: int) -> str: - if score >= 80: - return "critical" - if score >= 60: - return "high" - if score >= 35: - return "medium" - return "low" +def _normalize_breaking_scope(value: str | None) -> str: + return _normalize_breaking_scope_enum(value).value def apply_news_classification( @@ -1901,74 +1902,7 @@ def apply_news_classification( config_payload: dict[str, Any] | None = None, ) -> ParsedNewsItem: config = normalize_earth_news_sources_payload(config_payload) - title_text = item.title.lower() - combined_text = f"{item.title} {item.summary} {item.source} {item.feed_name}".lower() - - feed_default_category = (feed.default_category if feed else item.feed_default_category) or source.default_category or "other" - best_key = feed_default_category - best_score = 0 - second_score = 0 - for category in config["categories"]: - if not isinstance(category, dict) or category.get("enabled") is False: - continue - score = _score_category(combined_text, title_text, category) - if score > best_score: - second_score = best_score - best_score = score - best_key = str(category.get("key") or "other") - elif score > second_score: - second_score = score - - item_tags: list[str] = [] - for rule in config["item_tag_rules"]: - if not isinstance(rule, dict): - continue - keywords = rule.get("keywords") if isinstance(rule.get("keywords"), list) else [] - if any(_contains_keyword(combined_text, keyword) for keyword in keywords): - tag_key = str(rule.get("key") or "").strip() - if tag_key and tag_key not in item_tags: - item_tags.append(tag_key) - if best_score < 3 and rule.get("category"): - best_key = str(rule["category"]) - best_score = 3 - - confidence = round(best_score / (best_score + second_score + 1), 2) if best_score else 0.35 - if best_score < 3 and feed_default_category: - best_key = feed_default_category - confidence = 0.45 - - importance_score = max(0, min(100, 18 + source.importance_weight + best_score * 6)) - reasons: list[str] = [] - source_tag_set = set(source.source_tags) - if "official_data" in source_tag_set: - importance_score += 20 - reasons.append("官方数据源") - if "press_release" in source_tag_set: - importance_score = max(0, importance_score - 12) - reasons.append("企业公告基础权重较低") - ecommerce_terms = ("网上零售额", "电商物流指数", "gmv", "订单量", "物流指数", "履约", "直播电商", "跨境电商") - if any(_contains_keyword(combined_text, term) for term in ecommerce_terms): - importance_score += 25 - reasons.append("命中电商数据指标") - major_platforms = ("amazon", "shopify", "walmart", "alibaba", "jd.com", "pinduoduo", "tiktok shop", "shein", "阿里", "京东", "拼多多", "抖音") - if any(_contains_keyword(combined_text, term) for term in major_platforms): - importance_score += 15 - reasons.append("涉及大型平台") - if any(term in combined_text for term in ("同比", "环比", "%", "billion", "million", "增长", "下降")): - importance_score += 10 - reasons.append("包含量化指标") - - importance_score = max(0, min(100, importance_score)) - item.category = best_key or "other" - item.item_tags = item_tags - item.tagging_source = "rules" - item.tagging_confidence = confidence - item.importance_score = importance_score - item.importance_level = _importance_level(importance_score) - item.importance_reasons = reasons or ["按来源权重和分类规则计算"] - item.market_impact = "global" if "global" in source_tag_set else "national" if {"china", "us"} & source_tag_set else "sector" - item.source_tags = list(source.source_tags) - return item + return _apply_news_classification(item, source, feed=feed, config=config) def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]: @@ -2020,6 +1954,10 @@ def _serialize_enriched_at(value: datetime | None) -> str | None: return value.isoformat().replace("+00:00", "Z") if value else None +def _serialize_breaking_expires_at(value: datetime | None) -> str | None: + return value.isoformat().replace("+00:00", "Z") if value else None + + def _content_patch(item: ParsedNewsItem) -> dict[str, Any]: return { "content_language": item.content_language, @@ -2047,6 +1985,12 @@ def _news_meta_patch(item: ParsedNewsItem) -> dict[str, Any]: "importance_level": item.importance_level, "importance_reasons": list(item.importance_reasons or []), "market_impact": item.market_impact, + "breaking_level": _normalize_breaking_level(item.breaking_level), + "breaking_scope": _normalize_breaking_scope(item.breaking_scope), + "breaking_reasons": list(item.breaking_reasons or []), + "breaking_source": item.breaking_source, + "breaking_confidence": item.breaking_confidence, + "breaking_expires_at": _serialize_breaking_expires_at(item.breaking_expires_at), } @@ -2142,6 +2086,12 @@ def build_target_location_job_payload(item: ParsedNewsItem) -> dict[str, Any]: "importance_level": item.importance_level, "importance_reasons": list(item.importance_reasons or []), "market_impact": item.market_impact, + "breaking_level": _normalize_breaking_level(item.breaking_level), + "breaking_scope": _normalize_breaking_scope(item.breaking_scope), + "breaking_reasons": list(item.breaking_reasons or []), + "breaking_source": item.breaking_source, + "breaking_confidence": item.breaking_confidence, + "breaking_expires_at": _serialize_breaking_expires_at(item.breaking_expires_at), } @@ -2173,6 +2123,12 @@ def parsed_news_item_from_job_payload(payload: dict[str, Any]) -> ParsedNewsItem importance_level=str(payload.get("importance_level") or "low"), importance_reasons=list(payload.get("importance_reasons") or []), market_impact=str(payload.get("market_impact") or "none"), + breaking_level=_normalize_breaking_level(str(payload.get("breaking_level") or "none")), + breaking_scope=_normalize_breaking_scope(str(payload.get("breaking_scope") or "regional")), + breaking_reasons=list(payload.get("breaking_reasons") or []), + breaking_source=str(payload.get("breaking_source") or "rules"), + breaking_confidence=float(payload.get("breaking_confidence") or 0), + breaking_expires_at=_parse_datetime(_coerce_str(payload.get("breaking_expires_at"))), ) @@ -2218,6 +2174,12 @@ def _serialize_item(item: ParsedNewsItem, *, active_region: str, locale: str = D "importance_level": item.importance_level, "importance_reasons": list(item.importance_reasons or []), "market_impact": item.market_impact, + "breaking_level": _normalize_breaking_level(item.breaking_level), + "breaking_scope": _normalize_breaking_scope(item.breaking_scope), + "breaking_reasons": list(item.breaking_reasons or []), + "breaking_source": item.breaking_source, + "breaking_confidence": item.breaking_confidence, + "breaking_expires_at": _serialize_breaking_expires_at(item.breaking_expires_at), } @@ -2239,6 +2201,9 @@ def _build_payload( ) -> dict[str, Any]: profile = get_region_profile(active_region) timestamp = generated_at or datetime.now(UTC) + visible_items = list(items) + cruise_visible_items = list(cruise_items if cruise_items is not None else items) + highest_breaking_level = _highest_breaking_level(visible_items + cruise_visible_items) return { "generated_at": timestamp.isoformat().replace("+00:00", "Z"), "focus": { @@ -2256,11 +2221,13 @@ def _build_payload( "sources": sorted(source_ids or []), "limit": limit, "locale": locale, + "has_breaking": highest_breaking_level != "none", + "highest_breaking_level": highest_breaking_level, }, - "items": [_serialize_item(item, active_region=active_region, locale=locale) for item in items], + "items": [_serialize_item(item, active_region=active_region, locale=locale) for item in visible_items], "cruise_items": [ _serialize_item(item, active_region=active_region, locale=locale) - for item in (cruise_items if cruise_items is not None else items) + for item in cruise_visible_items ], "errors": errors, "stale": stale, @@ -2282,7 +2249,11 @@ def _rank_and_trim_items( return sorted( deduped.values(), key=lambda item: ( - False if active_region == "global" else item.feed_region != active_region, + -_breaking_sort_rank(item), + False + if active_region == "global" + or (_breaking_sort_rank(item) > 0 and _normalize_breaking_scope(item.breaking_scope) == "global") + else item.feed_region != active_region, item.published_at is None, -(item.published_at.timestamp() if item.published_at else 0), item.feed_name, diff --git a/backend/app/services/earth_news_classification.py b/backend/app/services/earth_news_classification.py new file mode 100644 index 00000000..fd887b48 --- /dev/null +++ b/backend/app/services/earth_news_classification.py @@ -0,0 +1,258 @@ +"""Classification, importance, and breaking-news policy for Earth news.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import re +from typing import Any, Protocol + +from app.core.enums import ( + BreakingLevel, + BreakingScope, + BreakingSource, + NewsImportanceLevel, + NewsMarketImpact, + NewsTaggingSource, + parse_enum, +) + + +class NewsItemLike(Protocol): + title: str + summary: str + source: str + feed_name: str + published_at: datetime | None + feed_default_category: str + category: str + item_tags: list[str] + tagging_source: str + tagging_confidence: float + importance_score: int + importance_level: str + importance_reasons: list[str] + market_impact: str + source_tags: list[str] + breaking_level: str + breaking_scope: str + breaking_reasons: list[str] + breaking_source: str + breaking_confidence: float + breaking_expires_at: datetime | None + + +class NewsSourceLike(Protocol): + default_category: str + importance_weight: int + source_tags: tuple[str, ...] + + +class NewsFeedLike(Protocol): + default_category: str + + +@dataclass(frozen=True) +class BreakingRule: + level: BreakingLevel + scope: BreakingScope + reason: str + keywords: tuple[str, ...] + + +IMPORTANCE_THRESHOLDS: tuple[tuple[int, NewsImportanceLevel], ...] = ( + (80, NewsImportanceLevel.CRITICAL), + (60, NewsImportanceLevel.HIGH), + (35, NewsImportanceLevel.MEDIUM), + (0, NewsImportanceLevel.LOW), +) + +BREAKING_LEVEL_RANK: dict[BreakingLevel, int] = { + BreakingLevel.NONE: 0, + BreakingLevel.WATCH: 1, + BreakingLevel.BREAKING: 2, + BreakingLevel.CRITICAL: 3, +} + +BREAKING_TTL: dict[BreakingLevel, timedelta] = { + BreakingLevel.WATCH: timedelta(hours=6), + BreakingLevel.BREAKING: timedelta(hours=12), + BreakingLevel.CRITICAL: timedelta(hours=24), +} + +BREAKING_RULES: tuple[BreakingRule, ...] = ( + BreakingRule(BreakingLevel.CRITICAL, BreakingScope.GLOBAL, "核事故或核风险", ("nuclear accident", "nuclear emergency", "radiation leak", "核事故", "核泄漏", "辐射泄漏")), + BreakingRule(BreakingLevel.CRITICAL, BreakingScope.REGIONAL, "重大军事冲突升级", ("airstrike", "missile strike", "invasion", "martial law", "空袭", "导弹袭击", "入侵", "戒严")), + BreakingRule(BreakingLevel.BREAKING, BreakingScope.REGIONAL, "战争或安全事件", ("war escalates", "terror attack", "coup", "hostage", "战争升级", "恐袭", "政变", "人质")), + BreakingRule(BreakingLevel.BREAKING, BreakingScope.REGIONAL, "重大灾害应急", ("major earthquake", "tsunami", "volcanic eruption", "state of emergency", "强震", "海啸", "火山喷发", "紧急状态")), + BreakingRule(BreakingLevel.BREAKING, BreakingScope.GLOBAL, "金融市场异常", ("market halt", "trading halt", "flash crash", "bank run", "金融熔断", "交易暂停", "银行挤兑")), + BreakingRule(BreakingLevel.WATCH, BreakingScope.GLOBAL, "大规模网络安全事件", ("massive cyberattack", "ransomware attack", "data breach", "大规模网络攻击", "勒索软件", "数据泄露")), + BreakingRule(BreakingLevel.WATCH, BreakingScope.REGIONAL, "航天或卫星事故", ("rocket explosion", "satellite collision", "space station emergency", "火箭爆炸", "卫星碰撞", "空间站事故")), +) + + +def contains_keyword(text: str, keyword: str) -> bool: + keyword_text = str(keyword or "").strip().lower() + if not keyword_text: + return False + if re.search(r"[\u4e00-\u9fff]", keyword_text): + return keyword_text in text + return re.search(rf"(? int: + score = 0 + keywords = category.get("keywords") if isinstance(category.get("keywords"), list) else [] + for keyword in keywords: + if contains_keyword(title_text, keyword): + score += 3 + elif contains_keyword(text, keyword): + score += 1 + return score + + +def importance_level(score: int) -> NewsImportanceLevel: + normalized_score = max(0, min(100, int(score))) + for threshold, level in IMPORTANCE_THRESHOLDS: + if normalized_score >= threshold: + return level + return NewsImportanceLevel.LOW + + +def normalize_breaking_level(value: object) -> BreakingLevel: + return parse_enum(BreakingLevel, value, BreakingLevel.NONE) + + +def normalize_breaking_scope(value: object) -> BreakingScope: + return parse_enum(BreakingScope, value, BreakingScope.REGIONAL) + + +def breaking_expires_at(level: object, published_at: datetime | None) -> datetime | None: + normalized = normalize_breaking_level(level) + if normalized is BreakingLevel.NONE: + return None + base = published_at or datetime.now(UTC) + base = base.replace(tzinfo=UTC) if base.tzinfo is None else base.astimezone(UTC) + return base + BREAKING_TTL[normalized] + + +def is_breaking_active(item: NewsItemLike, *, now: datetime | None = None) -> bool: + if normalize_breaking_level(item.breaking_level) is BreakingLevel.NONE: + return False + expires_at = item.breaking_expires_at + if expires_at is None: + return True + expires_at = expires_at.replace(tzinfo=UTC) if expires_at.tzinfo is None else expires_at.astimezone(UTC) + return expires_at > (now or datetime.now(UTC)) + + +def breaking_sort_rank(item: NewsItemLike) -> int: + if not is_breaking_active(item): + return 0 + return BREAKING_LEVEL_RANK[normalize_breaking_level(item.breaking_level)] + + +def highest_breaking_level(items: list[NewsItemLike]) -> BreakingLevel: + active = [normalize_breaking_level(item.breaking_level) for item in items if is_breaking_active(item)] + return max(active, key=BREAKING_LEVEL_RANK.get) if active else BreakingLevel.NONE + + +def apply_breaking_rules(item: NewsItemLike) -> None: + combined_text = f"{item.title} {item.summary} {item.source} {item.feed_name}".lower() + best_level = BreakingLevel.NONE + best_scope = BreakingScope.REGIONAL + reasons: list[str] = [] + confidence = 0.0 + for rule in BREAKING_RULES: + if not any(contains_keyword(combined_text, keyword) for keyword in rule.keywords): + continue + if BREAKING_LEVEL_RANK[rule.level] > BREAKING_LEVEL_RANK[best_level]: + best_level = rule.level + best_scope = rule.scope + if rule.reason not in reasons: + reasons.append(rule.reason) + confidence = max(confidence, 0.72 if rule.level is BreakingLevel.CRITICAL else 0.64 if rule.level is BreakingLevel.BREAKING else 0.52) + + item.breaking_level = best_level.value + item.breaking_scope = (best_scope if best_level is not BreakingLevel.NONE else BreakingScope.REGIONAL).value + item.breaking_reasons = reasons + item.breaking_source = BreakingSource.RULES.value + item.breaking_confidence = round(confidence, 2) + item.breaking_expires_at = breaking_expires_at(best_level, item.published_at) + + +def apply_news_classification( + item: NewsItemLike, + source: NewsSourceLike, + *, + feed: NewsFeedLike | None, + config: dict[str, Any], +) -> NewsItemLike: + title_text = item.title.lower() + combined_text = f"{item.title} {item.summary} {item.source} {item.feed_name}".lower() + feed_default_category = (feed.default_category if feed else item.feed_default_category) or source.default_category or "other" + best_key = feed_default_category + best_score = second_score = 0 + for category in config["categories"]: + if not isinstance(category, dict) or category.get("enabled") is False: + continue + score = score_category(combined_text, title_text, category) + if score > best_score: + second_score, best_score = best_score, score + best_key = str(category.get("key") or "other") + elif score > second_score: + second_score = score + + item_tags: list[str] = [] + for rule in config["item_tag_rules"]: + if not isinstance(rule, dict): + continue + keywords = rule.get("keywords") if isinstance(rule.get("keywords"), list) else [] + if any(contains_keyword(combined_text, keyword) for keyword in keywords): + tag_key = str(rule.get("key") or "").strip() + if tag_key and tag_key not in item_tags: + item_tags.append(tag_key) + if best_score < 3 and rule.get("category"): + best_key, best_score = str(rule["category"]), 3 + + confidence = round(best_score / (best_score + second_score + 1), 2) if best_score else 0.35 + if best_score < 3 and feed_default_category: + best_key, confidence = feed_default_category, 0.45 + + score = max(0, min(100, 18 + source.importance_weight + best_score * 6)) + reasons: list[str] = [] + source_tags = set(source.source_tags) + if "official_data" in source_tags: + score += 20 + reasons.append("官方数据源") + if "press_release" in source_tags: + score = max(0, score - 12) + reasons.append("企业公告基础权重较低") + if any(contains_keyword(combined_text, term) for term in ("网上零售额", "电商物流指数", "gmv", "订单量", "物流指数", "履约", "直播电商", "跨境电商")): + score += 25 + reasons.append("命中电商数据指标") + if any(contains_keyword(combined_text, term) for term in ("amazon", "shopify", "walmart", "alibaba", "jd.com", "pinduoduo", "tiktok shop", "shein", "阿里", "京东", "拼多多", "抖音")): + score += 15 + reasons.append("涉及大型平台") + if any(term in combined_text for term in ("同比", "环比", "%", "billion", "million", "增长", "下降")): + score += 10 + reasons.append("包含量化指标") + + score = max(0, min(100, score)) + item.category = best_key or "other" + item.item_tags = item_tags + item.tagging_source = NewsTaggingSource.RULES.value + item.tagging_confidence = confidence + item.importance_score = score + item.importance_level = importance_level(score).value + item.importance_reasons = reasons or ["按来源权重和分类规则计算"] + item.market_impact = ( + NewsMarketImpact.GLOBAL.value + if "global" in source_tags + else NewsMarketImpact.NATIONAL.value + if {"china", "us"} & source_tags + else NewsMarketImpact.SECTOR.value + ) + item.source_tags = list(source.source_tags) + apply_breaking_rules(item) + return item diff --git a/backend/app/services/earth_news_store.py b/backend/app/services/earth_news_store.py index 5264cce4..4a2e48b0 100644 --- a/backend/app/services/earth_news_store.py +++ b/backend/app/services/earth_news_store.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime from typing import Any -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.earth_news import EarthNewsItem @@ -13,6 +13,11 @@ from app.services.earth_news import ( build_anchor_location_patch, _news_meta_patch, ) +from app.services.earth_news_classification import ( + breaking_sort_rank, + normalize_breaking_level, + normalize_breaking_scope, +) def _coerce_datetime(value: datetime | None) -> datetime | None: @@ -23,6 +28,17 @@ def _coerce_datetime(value: datetime | None) -> datetime | None: return value.astimezone(UTC) +def _coerce_meta_datetime(value: Any) -> datetime | None: + if isinstance(value, datetime): + return _coerce_datetime(value) + if not isinstance(value, str) or not value.strip(): + return None + try: + return _coerce_datetime(datetime.fromisoformat(value.replace("Z", "+00:00"))) + except ValueError: + return None + + def _location_patch_from_record(record: EarthNewsItem) -> dict[str, Any]: return { "latitude": record.latitude, @@ -64,10 +80,32 @@ def record_to_parsed_news_item(record: EarthNewsItem) -> ParsedNewsItem: importance_level=str(news_meta.get("importance_level") or "low"), importance_reasons=list(news_meta.get("importance_reasons") or []), market_impact=str(news_meta.get("market_impact") or "none"), + breaking_level=normalize_breaking_level(news_meta.get("breaking_level")).value, + breaking_scope=normalize_breaking_scope(news_meta.get("breaking_scope")).value, + breaking_reasons=list(news_meta.get("breaking_reasons") or []), + breaking_source=str(news_meta.get("breaking_source") or "rules"), + breaking_confidence=float(news_meta.get("breaking_confidence") or 0), + breaking_expires_at=_coerce_meta_datetime(news_meta.get("breaking_expires_at")), ) return apply_enrichment_patch_to_item(item, _location_patch_from_record(record)) +def _sort_parsed_news_items(items: list[ParsedNewsItem], *, active_region: str) -> list[ParsedNewsItem]: + return sorted( + items, + key=lambda item: ( + -breaking_sort_rank(item), + False + if active_region == "global" + or (breaking_sort_rank(item) > 0 and normalize_breaking_scope(item.breaking_scope).value == "global") + else item.feed_region != active_region, + item.published_at is None, + -(item.published_at.timestamp() if item.published_at else 0), + item.feed_name, + ), + ) + + def _query_sort_key(active_region: str): if active_region == "global": return ( @@ -139,14 +177,20 @@ async def list_earth_news_items( categories: set[str] | None = None, source_ids: set[str] | None = None, ) -> list[ParsedNewsItem]: - query_limit = limit if source_ids else min(max(limit * 4, limit), 100) + query_limit = limit if source_ids else min(max(limit * 8, limit), 200) query = ( select(EarthNewsItem) .order_by(*_query_sort_key(active_region)) .limit(query_limit) ) if active_region != "global": - query = query.where(EarthNewsItem.region.in_({"global", active_region})) + news_meta = EarthNewsItem.location_meta.op("->")("news_meta") + query = query.where( + or_( + EarthNewsItem.region.in_({"global", active_region}), + news_meta.op("->>")("breaking_scope") == "global", + ) + ) category_clause = _category_filter_clause(categories) if category_clause is not None: query = query.where(category_clause) @@ -155,11 +199,11 @@ async def list_earth_news_items( query = query.where(source_clause) result = await db.execute(query) records = list(result.scalars().all()) - if not source_ids: - records = _diversify_records_by_source(records, limit=limit) - else: - records = records[:limit] - return [record_to_parsed_news_item(record) for record in records] + items = _sort_parsed_news_items( + [record_to_parsed_news_item(record) for record in records], + active_region=active_region, + ) + return items[:limit] async def list_earth_news_cruise_items( @@ -177,7 +221,7 @@ async def list_earth_news_cruise_items( EarthNewsItem.last_seen_at.desc(), EarthNewsItem.feed_name.asc(), ) - .limit(limit) + .limit(min(max(limit * 4, limit), 200)) ) category_clause = _category_filter_clause(categories) if category_clause is not None: @@ -186,7 +230,11 @@ async def list_earth_news_cruise_items( if source_clause is not None: query = query.where(source_clause) result = await db.execute(query) - return [record_to_parsed_news_item(record) for record in result.scalars().all()] + items = _sort_parsed_news_items( + [record_to_parsed_news_item(record) for record in result.scalars().all()], + active_region="global", + ) + return items[:limit] async def get_earth_news_freshness( diff --git a/backend/app/services/email.py b/backend/app/services/email.py index 6db4fe32..c744c68a 100644 --- a/backend/app/services/email.py +++ b/backend/app/services/email.py @@ -9,12 +9,12 @@ yet to keep behavior obvious after settings changes). from __future__ import annotations from email.message import EmailMessage -from typing import Literal, Optional +from typing import Optional import aiosmtplib from sqlalchemy.ext.asyncio import AsyncSession -OtpPurpose = Literal["register", "verify_email", "reset_password"] +from app.core.enums import OtpPurpose class EmailError(Exception): @@ -81,15 +81,15 @@ async def send_email( _SUBJECTS: dict[OtpPurpose, str] = { - "register": "Confirm your Planet account", - "verify_email": "Verify your Planet email", - "reset_password": "Reset your Planet password", + OtpPurpose.REGISTER: "Confirm your Planet account", + OtpPurpose.VERIFY_EMAIL: "Verify your Planet email", + OtpPurpose.RESET_PASSWORD: "Reset your Planet password", } _HEADLINES: dict[OtpPurpose, str] = { - "register": "Welcome to Planet — confirm your email to activate your account.", - "verify_email": "Confirm your new email address to keep your Planet account active.", - "reset_password": "Use this code to set a new password for your Planet account.", + OtpPurpose.REGISTER: "Welcome to Planet — confirm your email to activate your account.", + OtpPurpose.VERIFY_EMAIL: "Confirm your new email address to keep your Planet account active.", + OtpPurpose.RESET_PASSWORD: "Use this code to set a new password for your Planet account.", } diff --git a/backend/app/services/otp.py b/backend/app/services/otp.py index 4d9cafcf..e144809e 100644 --- a/backend/app/services/otp.py +++ b/backend/app/services/otp.py @@ -9,14 +9,12 @@ from __future__ import annotations import json import secrets -from typing import Literal import bcrypt +from app.core.enums import OtpPurpose from app.core.security import redis_client -OtpPurpose = Literal["register", "verify_email", "reset_password"] - CODE_TTL_SECONDS = 600 # 10 minutes RESEND_COOLDOWN_SECONDS = 60 MAX_ATTEMPTS = 5 diff --git a/backend/app/services/playground_chat_service.py b/backend/app/services/playground_chat_service.py index ee877cf3..41674896 100644 --- a/backend/app/services/playground_chat_service.py +++ b/backend/app/services/playground_chat_service.py @@ -7,9 +7,14 @@ from time import perf_counter from uuid import uuid4 from fastapi import HTTPException, status -from sqlalchemy import func, select, update +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.enums import ( + PlaygroundMessageKind, + PlaygroundMessageRole, + PlaygroundMessageStatus, +) from app.core.logging import get_logger from app.db.session import async_session_factory from app.models.playground_message import PlaygroundMessage @@ -21,7 +26,6 @@ from app.schemas.ai import ( PlaygroundMessageRecord, PlaygroundMessageResendRequest, PlaygroundMessageStopRequest, - PlaygroundSessionResponse, PlaygroundSessionState, PlaygroundSessionUpsertRequest, PlaygroundThreadResponse, @@ -37,6 +41,13 @@ STREAM_CHUNK_SIZE = 24 STREAM_INTERVAL_SECONDS = 0.08 THINKING_PREVIEW_SECONDS = 2.6 ORPHANED_RUN_MESSAGE = "后台生成任务已中断,请点击上一条用户消息的重试按钮重新生成。" +ACTIVE_MESSAGE_STATUSES = frozenset( + { + PlaygroundMessageStatus.PENDING.value, + PlaygroundMessageStatus.THINKING.value, + PlaygroundMessageStatus.ANSWERING.value, + } +) class _ActiveRun: @@ -93,7 +104,11 @@ async def _require_visible_message( result = await db.execute(select(PlaygroundMessage).where(*conditions)) message = result.scalar_one_or_none() if message is None: - detail = "User message not found" if role == "user" else "Playground message not found" + detail = ( + "User message not found" + if role == PlaygroundMessageRole.USER.value + else "Playground message not found" + ) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) return message @@ -108,7 +123,7 @@ def _message_to_record(message: PlaygroundMessage, parent_public_id: str | None content=message.content or "", thinking_content=message.thinking_content or "", meta=list(message.meta or []), - markdown=message.role != "system", + markdown=message.role != PlaygroundMessageRole.SYSTEM.value, provider=message.provider, model=message.model, request_id=message.request_id, @@ -197,11 +212,11 @@ async def _reconcile_orphaned_active_messages( ) -> list[PlaygroundMessage]: changed = False for item in messages: - if item.status not in {"pending", "thinking", "answering"}: + if item.status not in ACTIVE_MESSAGE_STATUSES: continue if item.public_id in _ACTIVE_RUNS: continue - item.status = "error" + item.status = PlaygroundMessageStatus.ERROR.value item.content = item.content or ORPHANED_RUN_MESSAGE orphan_meta = "错误: 后台任务已中断" if orphan_meta not in (item.meta or []): @@ -330,9 +345,9 @@ async def create_turn( public_id=uuid4().hex, session_id=session.id, user_id=user_id, - role="user", - kind="message", - status="done", + role=PlaygroundMessageRole.USER.value, + kind=PlaygroundMessageKind.MESSAGE.value, + status=PlaygroundMessageStatus.DONE.value, title=payload.selected_preset_key, content=payload.input, meta=[payload.title], @@ -343,9 +358,9 @@ async def create_turn( session_id=session.id, user_id=user_id, parent_message_id=None, - role="assistant", - kind="thinking", - status="pending", + role=PlaygroundMessageRole.ASSISTANT.value, + kind=PlaygroundMessageKind.THINKING.value, + status=PlaygroundMessageStatus.PENDING.value, title="AI 回应", content="", thinking_content="", @@ -397,9 +412,9 @@ async def _create_assistant_retry_turn( session_id=session.id, user_id=user_id, parent_message_id=user_message.id, - role="assistant", - kind="thinking", - status="pending", + role=PlaygroundMessageRole.ASSISTANT.value, + kind=PlaygroundMessageKind.THINKING.value, + status=PlaygroundMessageStatus.PENDING.value, title="AI 回应", content="", thinking_content="", @@ -439,7 +454,7 @@ async def stop_message( session = await _require_session(db, user_id=user_id, session_key=payload.session_key) message = await _require_visible_message(db, user_id=user_id, public_id=payload.message_id) - if message.status not in {"pending", "thinking", "answering"}: + if message.status not in ACTIVE_MESSAGE_STATUSES: return await _build_action_response(db, session=session) active_run = _ACTIVE_RUNS.get(message.public_id) @@ -447,7 +462,7 @@ async def stop_message( active_run.stop_requested.set() active_run.task.cancel() - message.status = "stopped" + message.status = PlaygroundMessageStatus.STOPPED.value if "已手动停止生成" not in (message.meta or []): message.meta = [*(message.meta or []), "已手动停止生成"] await db.flush() @@ -469,7 +484,7 @@ async def resend_turn( db, user_id=user_id, public_id=payload.user_message_id, - role="user", + role=PlaygroundMessageRole.USER.value, ) later_messages = await db.execute( @@ -481,7 +496,7 @@ async def resend_turn( ) for item in later_messages.scalars().all(): item.is_visible = False - if item.status in {"pending", "thinking", "answering"}: + if item.status in ACTIVE_MESSAGE_STATUSES: active_run = _ACTIVE_RUNS.get(item.public_id) if active_run is not None: active_run.stop_requested.set() @@ -520,7 +535,7 @@ async def edit_user_message( db, user_id=user_id, public_id=payload.user_message_id, - role="user", + role=PlaygroundMessageRole.USER.value, ) user_message.content = payload.content.strip() @@ -566,12 +581,12 @@ def _build_conversation_history(messages: Sequence[PlaygroundMessage], current_u for item in messages: if item.id >= current_user_message_id: break - if item.role == "system": + if item.role == PlaygroundMessageRole.SYSTEM.value: continue history.append( { "role": item.role, - "kind": item.kind or "message", + "kind": item.kind or PlaygroundMessageKind.MESSAGE.value, "title": item.title, "content": item.content or "", } @@ -668,7 +683,11 @@ async def _run_assistant_message( assistant_message = await _mark_message_state( db, message_id=assistant_message_id, - status="thinking" if analysis.thinking_blocks else "answering", + status=( + PlaygroundMessageStatus.THINKING.value + if analysis.thinking_blocks + else PlaygroundMessageStatus.ANSWERING.value + ), title=f"{analysis.provider} / {analysis.model}", provider=analysis.provider, model=analysis.model, @@ -706,7 +725,7 @@ async def _run_assistant_message( await _mark_message_state( db, message_id=assistant_message_id, - status="answering", + status=PlaygroundMessageStatus.ANSWERING.value, content=content[:cursor], ) await db.commit() @@ -717,7 +736,7 @@ async def _run_assistant_message( assistant_message = await _mark_message_state( db, message_id=assistant_message_id, - status="done", + status=PlaygroundMessageStatus.DONE.value, content=content, meta=[ f"Request ID: {request_id}", @@ -762,8 +781,8 @@ async def _run_assistant_message( async with async_session_factory() as db: result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id)) message = result.scalar_one_or_none() - if message is not None and message.status in {"pending", "thinking", "answering"}: - message.status = "stopped" + if message is not None and message.status in ACTIVE_MESSAGE_STATUSES: + message.status = PlaygroundMessageStatus.STOPPED.value if "已手动停止生成" not in (message.meta or []): message.meta = [*(message.meta or []), "已手动停止生成"] await db.flush() @@ -795,7 +814,7 @@ async def _run_assistant_message( result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id)) message = result.scalar_one_or_none() if message is not None: - message.status = "error" + message.status = PlaygroundMessageStatus.ERROR.value message.content = message.content or f"分析失败:{error_message}" message.meta = [ *(message.meta or []), diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index a8205486..056ad827 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -8,6 +8,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from sqlalchemy import select +from app.core.enums import JobStatus from app.core.logging import get_logger from app.db.session import async_session_factory from app.core.time import to_iso8601_utc @@ -140,7 +141,7 @@ async def run_collector_task(collector_name: str): select(CollectionTask) .where( CollectionTask.datasource_id == datasource.id, - CollectionTask.status == "running", + CollectionTask.status == JobStatus.RUNNING.value, ) .order_by(CollectionTask.started_at.desc(), CollectionTask.id.desc()) .limit(1) @@ -184,7 +185,7 @@ async def run_collector_task(collector_name: str): f"Marked failed automatically after stale running timeout " f"({RUNNING_TASK_GUARD_TIMEOUT_MINUTES}m) in scheduler guard" ) - existing_running.status = "failed" + existing_running.status = JobStatus.FAILED.value existing_running.phase = "failed" existing_running.completed_at = now existing_running.error_message = ( @@ -243,7 +244,7 @@ async def run_collector_task(collector_name: str): return datasource.last_run_at = datetime.now(UTC) datasource.last_status = task_result.get("status") - if datasource.last_status == "success": + if datasource.last_status == JobStatus.SUCCESS.value: effective_candidate = await get_builtin_effective_candidate(db, datasource_source) checksum, _credential_context = await build_builtin_connectivity_checksum( datasource_source, @@ -284,7 +285,7 @@ async def run_collector_task(collector_name: str): await db.rollback() datasource = await db.get(DataSource, datasource_id) datasource.last_run_at = datetime.now(UTC) - datasource.last_status = "cancelled" + datasource.last_status = JobStatus.CANCELLED.value await db.commit() logger.warning_event( "Collector cancelled by operator", @@ -306,7 +307,7 @@ async def run_collector_task(collector_name: str): await db.rollback() datasource = await db.get(DataSource, datasource_id) datasource.last_run_at = datetime.now(UTC) - datasource.last_status = "failed" + datasource.last_status = JobStatus.FAILED.value await db.commit() logger.exception_event( "Collector failed", @@ -335,7 +336,7 @@ async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int: async with async_session_factory() as db: result = await db.execute( select(CollectionTask).where( - CollectionTask.status == "running", + CollectionTask.status == JobStatus.RUNNING.value, CollectionTask.started_at.is_not(None), CollectionTask.started_at < cutoff, ) @@ -343,7 +344,7 @@ async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int: stale_tasks = result.scalars().all() for task in stale_tasks: - task.status = "failed" + task.status = JobStatus.FAILED.value task.phase = "failed" task.completed_at = datetime.now(UTC) existing_error = (task.error_message or "").strip() diff --git a/backend/app/services/situational_alert_ai_brief.py b/backend/app/services/situational_alert_ai_brief.py index 2046e6fc..1ea102b2 100644 --- a/backend/app/services/situational_alert_ai_brief.py +++ b/backend/app/services/situational_alert_ai_brief.py @@ -6,6 +6,7 @@ from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.enums import BGPStatus from app.models.alert import Alert, AlertSeverity, AlertStatus from app.models.bgp_anomaly import BGPAnomaly from app.models.bgp_incident import BGPIncident @@ -49,11 +50,11 @@ async def build_situational_alert_brief_request( total_incidents_result = await db.execute(select(func.count(BGPIncident.id))) active_incidents_result = await db.execute( - select(func.count(BGPIncident.id)).where(BGPIncident.status == "active") + select(func.count(BGPIncident.id)).where(BGPIncident.status == BGPStatus.ACTIVE.value) ) bgp_severity_result = await db.execute( select(BGPIncident.severity, func.count(BGPIncident.id)) - .where(BGPIncident.status == "active") + .where(BGPIncident.status == BGPStatus.ACTIVE.value) .group_by(BGPIncident.severity) ) bgp_region_counter: Counter[str] = Counter() @@ -65,11 +66,11 @@ async def build_situational_alert_brief_request( total_anomalies_result = await db.execute(select(func.count(BGPAnomaly.id))) active_anomalies_result = await db.execute( - select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == "active") + select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == BGPStatus.ACTIVE.value) ) anomaly_type_result = await db.execute( select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id)) - .where(BGPAnomaly.status == "active") + .where(BGPAnomaly.status == BGPStatus.ACTIVE.value) .group_by(BGPAnomaly.anomaly_type) .order_by(func.count(BGPAnomaly.id).desc()) .limit(6) diff --git a/backend/app/services/system_control.py b/backend/app/services/system_control.py index 45de6a3e..45dc6428 100644 --- a/backend/app/services/system_control.py +++ b/backend/app/services/system_control.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any from app.core.config import ROOT_DIR +from app.core.enums import UserRole from app.core.security import redis_client SYSTEM_TASK_TTL_SECONDS = 24 * 60 * 60 @@ -47,7 +48,7 @@ def normalize_user_role(role: Any) -> str: def require_super_admin(user_role: Any) -> bool: - return normalize_user_role(user_role) == "super_admin" + return normalize_user_role(user_role) == UserRole.SUPER_ADMIN.value def build_task_id(prefix: str = "restart") -> str: diff --git a/backend/app/services/system_logs.py b/backend/app/services/system_logs.py index 97655340..973c59d9 100644 --- a/backend/app/services/system_logs.py +++ b/backend/app/services/system_logs.py @@ -13,6 +13,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any +from app.core.enums import LogLevel from app.core.security import redis_client from app.models.system_log import AuditLog, ObservabilityEvent, ObservabilityEventGroup, SystemLog from sqlalchemy import select @@ -24,11 +25,11 @@ BUFFER_LOG_LIMIT = 1000 BUFFER_LOG_TTL_SECONDS = 7 * 24 * 60 * 60 LOG_BUFFER_KEY_PREFIX = "planet:system_logs" -LOG_LEVEL_ERROR = "error" -LOG_LEVEL_WARNING = "warning" -LOG_LEVEL_INFO = "info" -LOG_LEVEL_DEBUG = "debug" -LOG_LEVEL_ALL = "all" +LOG_LEVEL_ERROR = LogLevel.ERROR.value +LOG_LEVEL_WARNING = LogLevel.WARNING.value +LOG_LEVEL_INFO = LogLevel.INFO.value +LOG_LEVEL_DEBUG = LogLevel.DEBUG.value +LOG_LEVEL_ALL = LogLevel.ALL.value SUPPORTED_LOG_LEVELS = { LOG_LEVEL_ALL, diff --git a/backend/app/services/vessel_ais_aggregation.py b/backend/app/services/vessel_ais_aggregation.py index bb6f2c60..3298fca2 100644 --- a/backend/app/services/vessel_ais_aggregation.py +++ b/backend/app/services/vessel_ais_aggregation.py @@ -9,7 +9,12 @@ from sqlalchemy import select from sqlalchemy import Float from sqlalchemy.ext.asyncio import AsyncSession -from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth +from app.models.vessel import ( + AISConflictRecord, + AISRawObservation, + AISSourceHealth, + VesselCurrentState, +) from app.services.vessel_aggregation_strategy import ( DEFAULT_STRATEGY, load_strategy, @@ -44,6 +49,17 @@ CONFLICT_FIELDS = ( "width", "draught", ) +CURRENT_STATE_STATIC_FIELDS = ( + "name", + "callsign", + "vessel_type", + "vessel_type_name", + "flag", + "length", + "width", + "draught", + "imo", +) def _json_default(value: Any) -> Any: @@ -489,9 +505,115 @@ async def record_vessel_ais_observation( quality_flags=quality_flags or [], ) db.add(observation) + await upsert_vessel_current_state( + db, + source=source, + normalized_payload=normalized_json, + observed_at=observed_at, + quality_flags=quality_flags or [], + ) return observation +async def upsert_vessel_current_state( + db: AsyncSession, + *, + source: str, + normalized_payload: dict[str, Any], + observed_at: datetime, + quality_flags: list[str] | None = None, +) -> VesselCurrentState | None: + """Keep one latest renderable row per MMSI while preserving useful static fields.""" + + if not _has_valid_position(normalized_payload): + return None + mmsi = int(normalized_payload["mmsi"]) + current = await db.get(VesselCurrentState, mmsi) + if current is not None and current.observed_at is not None: + current_observed_at = _coerce_datetime(current.observed_at) + if current_observed_at is not None and observed_at < current_observed_at: + return current + + if current is None: + current = VesselCurrentState(mmsi=mmsi) + db.add(current) + + current.lat = float(normalized_payload["lat"]) + current.lon = float(normalized_payload["lon"]) + current.source = source + current.observed_at = observed_at + current.updated_at = datetime.now(UTC) + updated_fields: set[str] = {"lat", "lon"} + for field in DYNAMIC_FIELDS: + if field in {"lat", "lon"}: + continue + value = _payload_value(normalized_payload, field) + if value is not None: + setattr(current, field, value) + updated_fields.add(field) + field_sources = dict(current.field_sources or {}) + for field in CURRENT_STATE_STATIC_FIELDS: + value = _payload_value(normalized_payload, field) + if value is None: + continue + existing_source = field_sources.get(field) + existing_value = getattr(current, field, None) + if ( + existing_value in (None, "") + or _strategy_source_rank(source, DEFAULT_STRATEGY) + >= _strategy_source_rank(str(existing_source or ""), DEFAULT_STRATEGY) + ): + setattr(current, field, value) + updated_fields.add(field) + + current.vessel_type_name = current.vessel_type_name or normalize_vessel_type_name( + current.vessel_type + ) + selected_reasons = dict(current.selected_reasons or {}) + for field in updated_fields: + field_sources[field] = source + selected_reasons[field] = ( + "newest_observation" if field in DYNAMIC_FIELDS else "source_priority" + ) + current.field_sources = field_sources + current.selected_reasons = selected_reasons + current.source_summary = { + **dict(current.source_summary or {}), + source: { + "latest_observed_at": observed_at.isoformat(), + }, + } + current.quality_flags = sorted(set((current.quality_flags or []) + (quality_flags or []))) + return current + + +async def get_current_vessels_snapshot( + db: AsyncSession, + *, + bbox: tuple[float, float, float, float], + limit: int = 1000, + observed_since: datetime, +) -> list[dict[str, Any]]: + """Read the bounded latest-state table used by Earth rendering.""" + + safe_limit = min(max(int(limit or 1000), 1), MAX_SNAPSHOT_LIMIT) + lon_min, lat_min, lon_max, lat_max = bbox + stmt = ( + select(VesselCurrentState) + .where(VesselCurrentState.observed_at >= observed_since) + .where(VesselCurrentState.lon >= lon_min) + .where(VesselCurrentState.lon <= lon_max) + .where(VesselCurrentState.lat >= lat_min) + .where(VesselCurrentState.lat <= lat_max) + .order_by(VesselCurrentState.observed_at.desc(), VesselCurrentState.mmsi.asc()) + .limit(safe_limit) + ) + result = await db.execute(stmt) + if not hasattr(result, "scalars"): + return [] + return [item.to_dict() for item in result.scalars().all()] + + async def aggregate_vessel_observations( db: AsyncSession, observations: Iterable[AISRawObservation], diff --git a/backend/tests/test_earth_news.py b/backend/tests/test_earth_news.py index 7edffb17..e7e38de6 100644 --- a/backend/tests/test_earth_news.py +++ b/backend/tests/test_earth_news.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from types import SimpleNamespace import pytest @@ -15,6 +15,7 @@ from app.services.earth_news import ( _enrich_items_with_target_locations, _extract_target_location_from_text, _parse_feed_entries, + _rank_and_trim_items, _serialize_item, get_earth_news_payload, test_news_source_config as run_news_source_config_test, @@ -50,6 +51,85 @@ def test_serialize_item_includes_region_anchor_for_cruise(): assert payload["published_at"] == "2026-04-23T02:30:00Z" +def test_serialize_item_includes_breaking_fields(): + item = ParsedNewsItem( + id="breaking:test", + title="Major market halt", + summary="Trading halt after flash crash", + url="https://example.com/breaking", + source="Example Source", + feed_name="Example Feed", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 15, 2, 0, tzinfo=UTC), + breaking_level="critical", + breaking_scope="global", + breaking_reasons=["重大金融市场异常"], + breaking_source="rules", + breaking_confidence=0.72, + breaking_expires_at=datetime(2026, 5, 16, 2, 0, tzinfo=UTC), + ) + + payload = _serialize_item(item, active_region="europe") + + assert payload["breaking_level"] == "critical" + assert payload["breaking_scope"] == "global" + assert payload["breaking_reasons"] == ["重大金融市场异常"] + assert payload["breaking_source"] == "rules" + assert payload["breaking_confidence"] == 0.72 + assert payload["breaking_expires_at"] == "2026-05-16T02:00:00Z" + + +def test_rank_and_trim_items_prioritizes_active_breaking(): + older_breaking = ParsedNewsItem( + id="global:critical", + title="Nuclear accident reported", + summary="A nuclear accident has been reported.", + url="https://example.com/critical", + source="Global Source", + feed_name="Global Feed", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime.now(UTC) - timedelta(hours=2), + breaking_level="critical", + breaking_scope="global", + breaking_expires_at=datetime.now(UTC) + timedelta(hours=6), + ) + newer_regular = ParsedNewsItem( + id="europe:regular", + title="Regular Europe story", + summary="A newer regular story.", + url="https://example.com/regular", + source="Europe Source", + feed_name="Europe Feed", + feed_region="europe", + homepage_url="https://example.com", + published_at=datetime.now(UTC), + ) + expired_breaking = ParsedNewsItem( + id="europe:expired", + title="Expired breaking", + summary="Expired breaking story.", + url="https://example.com/expired", + source="Europe Source", + feed_name="Europe Feed", + feed_region="europe", + homepage_url="https://example.com", + published_at=datetime.now(UTC) + timedelta(minutes=1), + breaking_level="critical", + breaking_scope="regional", + breaking_expires_at=datetime.now(UTC) - timedelta(minutes=1), + ) + + ranked = _rank_and_trim_items( + [newer_regular, expired_breaking, older_breaking], + active_region="europe", + limit=3, + ) + + assert [item.id for item in ranked] == ["global:critical", "europe:expired", "europe:regular"] + + def test_serialize_item_falls_back_to_global_anchor(): item = ParsedNewsItem( id="custom:test", @@ -1019,6 +1099,8 @@ async def test_earth_news_payload_passes_region_and_category_filters_to_store(mo "sources": [], "limit": 12, "locale": "zh-CN", + "has_breaking": False, + "highest_breaking_level": "none", } assert payload["items"][0]["category"] == "business" diff --git a/backend/tests/test_enum_contracts.py b/backend/tests/test_enum_contracts.py new file mode 100644 index 00000000..f65f6e73 --- /dev/null +++ b/backend/tests/test_enum_contracts.py @@ -0,0 +1,74 @@ +"""Compatibility contracts for stable backend protocol enums.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +from app.core.enums import ( + BreakingLevel, + BreakingScope, + JobStatus, + NewsImportanceLevel, + NewsSourceType, + PlaygroundMessageKind, + PlaygroundMessageStatus, + UserRole, + parse_enum, +) +from app.services.earth_news_classification import ( + BREAKING_LEVEL_RANK, + BREAKING_TTL, + breaking_sort_rank, + importance_level, +) + + +def test_protocol_enum_values_remain_api_compatible() -> None: + assert [item.value for item in NewsImportanceLevel] == ["low", "medium", "high", "critical"] + assert [item.value for item in BreakingLevel] == ["none", "watch", "breaking", "critical"] + assert [item.value for item in BreakingScope] == ["regional", "global"] + assert [item.value for item in NewsSourceType] == ["rss", "atom", "aggregated", "reference"] + assert [item.value for item in UserRole] == ["viewer", "admin", "super_admin"] + assert JobStatus.RUNNING.value == "running" + assert PlaygroundMessageKind.THINKING.value == "thinking" + assert PlaygroundMessageStatus.ERROR.value == "error" + assert PlaygroundMessageStatus.STOPPED.value == "stopped" + + +def test_parse_enum_accepts_legacy_strings_and_safely_falls_back(caplog) -> None: + assert parse_enum(JobStatus, "RUNNING", JobStatus.FAILED) is JobStatus.RUNNING + assert parse_enum(JobStatus, None, JobStatus.QUEUED) is JobStatus.QUEUED + assert parse_enum(JobStatus, "legacy-unknown", JobStatus.FAILED) is JobStatus.FAILED + assert "legacy-unknown" in caplog.text + + +def test_importance_level_boundaries() -> None: + expected = { + 34: NewsImportanceLevel.LOW, + 35: NewsImportanceLevel.MEDIUM, + 59: NewsImportanceLevel.MEDIUM, + 60: NewsImportanceLevel.HIGH, + 79: NewsImportanceLevel.HIGH, + 80: NewsImportanceLevel.CRITICAL, + } + assert {score: importance_level(score) for score in expected} == expected + + +def test_breaking_rank_and_ttl_contracts() -> None: + assert BREAKING_LEVEL_RANK[BreakingLevel.CRITICAL] > BREAKING_LEVEL_RANK[BreakingLevel.BREAKING] + assert BREAKING_TTL[BreakingLevel.WATCH] == timedelta(hours=6) + assert BREAKING_TTL[BreakingLevel.BREAKING] == timedelta(hours=12) + assert BREAKING_TTL[BreakingLevel.CRITICAL] == timedelta(hours=24) + + now = datetime.now(UTC) + active = SimpleNamespace( + breaking_level=BreakingLevel.BREAKING.value, + breaking_expires_at=now + timedelta(minutes=1), + ) + expired = SimpleNamespace( + breaking_level=BreakingLevel.CRITICAL.value, + breaking_expires_at=now - timedelta(minutes=1), + ) + assert breaking_sort_rank(active) == BREAKING_LEVEL_RANK[BreakingLevel.BREAKING] + assert breaking_sort_rank(expired) == 0 diff --git a/backend/tests/test_vessels.py b/backend/tests/test_vessels.py index bb660e95..7476a5f7 100644 --- a/backend/tests/test_vessels.py +++ b/backend/tests/test_vessels.py @@ -8,7 +8,7 @@ from app.api.v1 import visualization from app.api.v1.visualization import convert_vessels_to_geojson from app.db.session import get_db from app.main import app -from app.models.vessel import AISRawObservation, VesselPosition, VesselStatic +from app.models.vessel import AISRawObservation, VesselCurrentState, VesselPosition, VesselStatic from app.services import barentswatch from app.services.collectors.aisstream import AISStreamCollector from app.services.collectors.vessel_ais import VesselAISCollector @@ -17,6 +17,7 @@ from app.services.vessel_ais_aggregation import ( build_field_conflict_candidates, build_observation_hash, record_vessel_ais_observation, + upsert_vessel_current_state, ) @@ -109,6 +110,58 @@ async def test_record_vessel_ais_observation_skips_existing_hash(): assert db.added == [] +@pytest.mark.asyncio +async def test_upsert_vessel_current_state_keeps_latest_position_and_static_fields(): + current = VesselCurrentState( + mmsi=257123000, + lat=59.91, + lon=10.73, + name="OSLO TRADER", + source="barentswatch_vessels", + observed_at=datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc), + field_sources={"name": "aisstream_vessels"}, + ) + + class _Session: + async def get(self, _model, _mmsi): + return current + + def add(self, _item): + raise AssertionError("existing current state should be updated") + + db = _Session() + result = await upsert_vessel_current_state( + db, + source="aisstream_vessels", + normalized_payload={"mmsi": 257123000, "lat": 59.92, "lon": 10.74, "sog": 12.4}, + observed_at=datetime(2026, 4, 30, 12, 1, tzinfo=timezone.utc), + ) + + assert result is current + assert current.lat == pytest.approx(59.92) + assert current.lon == pytest.approx(10.74) + assert current.name == "OSLO TRADER" + assert current.source == "aisstream_vessels" + + await upsert_vessel_current_state( + db, + source="barentswatch_vessels", + normalized_payload={"mmsi": 257123000, "lat": 59.93, "lon": 10.75, "name": "LOW PRIORITY"}, + observed_at=datetime(2026, 4, 30, 12, 2, tzinfo=timezone.utc), + ) + assert current.lat == pytest.approx(59.93) + assert current.name == "OSLO TRADER" + + await upsert_vessel_current_state( + db, + source="barentswatch_vessels", + normalized_payload={"mmsi": 257123000, "lat": 1, "lon": 2, "name": "OLD"}, + observed_at=datetime(2026, 4, 30, 11, 59, tzinfo=timezone.utc), + ) + assert current.lat == pytest.approx(59.93) + assert current.name == "OSLO TRADER" + + def test_build_field_conflict_candidates_from_raw_observations(): observations = [ AISRawObservation( @@ -527,7 +580,7 @@ async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch): now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) monkeypatch.setattr( visualization, - "get_aggregated_vessels_snapshot", + "get_current_vessels_snapshot", AsyncMock( return_value=[ { @@ -573,6 +626,36 @@ async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch): app.dependency_overrides.clear() +@pytest.mark.asyncio +async def test_vessel_snapshot_accepts_fractional_zoom(monkeypatch): + monkeypatch.setattr( + visualization, + "get_current_vessels_snapshot", + AsyncMock(return_value=[]), + ) + + async def override_get_db(): + yield object() + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/vessels/snapshot", + params={ + "bbox": "-180,-85.05112878,180,85.05112878", + "zoom": 1.6, + "limit": 3000, + }, + ) + + assert response.status_code == 200 + assert response.json()["count"] == 0 + finally: + app.dependency_overrides.clear() + + @pytest.mark.asyncio async def test_legacy_vessels_geojson_route_is_not_registered(): transport = ASGITransport(app=app) @@ -597,7 +680,7 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch): now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) captured = {} - async def fake_get_aggregated_vessels_snapshot(db, *, bbox, limit, observed_since): + async def fake_get_current_vessels_snapshot(db, *, bbox, limit, observed_since): captured["bbox"] = bbox captured["limit"] = limit captured["observed_since"] = observed_since @@ -624,8 +707,8 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch): monkeypatch.setattr( visualization, - "get_aggregated_vessels_snapshot", - fake_get_aggregated_vessels_snapshot, + "get_current_vessels_snapshot", + fake_get_current_vessels_snapshot, ) class _Result: @@ -661,6 +744,8 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch): assert captured["bbox"] == (10.0, 59.0, 11.0, 60.0) assert captured["limit"] == 5000 assert data["diagnostics"]["bbox_applied"] is True + assert data["diagnostics"]["source"] == "vessel_current_state" + assert data["diagnostics"]["current_state_count"] == 2 assert data["diagnostics"]["legacy_feature_count"] == 0 assert data["diagnostics"]["legacy_backfilled_mmsi"] == 0 finally: @@ -668,34 +753,12 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch): @pytest.mark.asyncio -async def test_vessel_snapshot_uses_legacy_fallback_when_raw_window_is_empty(monkeypatch): - now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) +async def test_vessel_snapshot_does_not_fallback_to_history_when_current_state_is_empty(monkeypatch): monkeypatch.setattr( visualization, - "get_aggregated_vessels_snapshot", + "get_current_vessels_snapshot", AsyncMock(return_value=[]), ) - monkeypatch.setattr( - visualization, - "_load_legacy_vessel_snapshot_features", - AsyncMock( - return_value=[ - { - "type": "Feature", - "id": 257123000, - "geometry": {"type": "Point", "coordinates": [10.73, 59.91]}, - "properties": { - "mmsi": 257123000, - "name": "OSLO TRADER", - "vessel_type": 70, - "vessel_type_name": "Cargo", - "received_at": now.isoformat(), - }, - } - ] - ), - ) - result = await visualization.build_vessel_snapshot_response( object(), bbox=(10.0, 59.0, 11.0, 60.0), @@ -705,12 +768,11 @@ async def test_vessel_snapshot_uses_legacy_fallback_when_raw_window_is_empty(mon since_minutes=60, ) - assert result["count"] == 1 - assert result["features"][0]["properties"]["name"] == "OSLO TRADER" + assert result["count"] == 0 assert result["diagnostics"]["raw_feature_count"] == 0 - assert result["diagnostics"]["legacy_feature_count"] == 1 - assert result["diagnostics"]["legacy_backfilled_mmsi"] == 1 - assert result["diagnostics"]["legacy_fallback_used"] is True + assert result["diagnostics"]["legacy_feature_count"] == 0 + assert result["diagnostics"]["legacy_backfilled_mmsi"] == 0 + assert result["diagnostics"]["legacy_fallback_used"] is False @pytest.mark.asyncio diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ebf88407..51339d19 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,24 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.70.0] — 2026-06-04 + +Released: 2026-06-04 + +### Highlights +- 新增后端枚举契约治理,将稳定协议状态集中到 `app/core/enums.py`,同时保持数据库和 API 的小写字符串兼容。 +- 改进 Earth 新闻分类、重要度与 Breaking 插队链路,并补齐中英文新闻源与枚举契约文档。 +- 将 Earth 船只展示改为 `vessel_current_state` 当前状态快照,保留 AIS 原始历史用于轨迹和态势分析。 +- 清理错误的船只视口刷新/订阅思路,恢复全球船只显示,并让性能优化集中到批量渲染、关闭动态聚类和减少 hover/rebuild 开销。 + +### Added / Fixed / Improved +- `earth_news_classification.py` 集中管理新闻类型、重要度和 Breaking 规则,避免抓取编排层重复判断。 +- `/api/v1/vessels/snapshot` 支持全球当前状态读取和小数 zoom,诊断信息明确返回 `vessel_current_state` 来源。 +- 船只前端使用全球 bbox + `limit=3000`,不再随相机视口重复请求或建立多视口 WebSocket 订阅。 +- 中英文技术文档同步更新船只、采集器、数据流、渲染层级、样式参数和历史计划状态。 + +--- + ## [0.69.0] — 2026-06-03 Released: 2026-06-03 diff --git a/docs/plans/earth-vessel-ais-aggregation-plan.md b/docs/plans/earth-vessel-ais-aggregation-plan.md index 14c0b41f..873bcd53 100644 --- a/docs/plans/earth-vessel-ais-aggregation-plan.md +++ b/docs/plans/earth-vessel-ais-aggregation-plan.md @@ -1,6 +1,6 @@ # AIS 多源采集、冲突记录与聚合接口计划 -**状态**:v0-v3 已实现,v3.1-v3.4 为 v4/v5 前置稳定化任务,v4 / v5 已落最小可用子集 +**状态**:v0-v3 已实现,v3.1-v3.4 为 v4/v5 前置稳定化任务,v4 / v5 已落最小可用子集;`0.70.0` 起 Earth 展示路径已转向 `vessel_current_state` 当前状态表和全球快照,不再由 raw observation 聚合结果直接驱动首屏显示。 **创建日期**:2026-04-30 **核心原则**:采集器只写原始观测;去重、合并、冲突解释放在聚合接口中完成 @@ -344,11 +344,11 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图 3. 聚合结果返回 `source_summary`,展示每艘船的来源、观测数量、最新观测时间、传输模式和消息类型。 4. 保留 `field_sources` 和 `selected_reasons`,用于解释动态字段来自实时流、静态字段来自可用非空来源。 5. 船名标准化会读取 AISStream `MetaData.ShipName`;船型展示会从 `vessel_type_name` 和 AIS 数字 `vessel_type` 共同归一化,保证 marker 颜色、详情卡、hover 和搜索结果一致。 -6. 当前实现已转向 `/api/v1/vessels/snapshot`:必须带 bbox / zoom,默认 `limit=1000`,最大 `limit=5000`,不再支持旧 `/geo/vessels` 全量返回。 +6. 当前实现已转向 `/api/v1/vessels/snapshot`:必须带 bbox / zoom,Earth 运行时使用全球 bbox 和 `limit=3000`,后端最大 `limit=5000`,不再支持旧 `/geo/vessels` 全量返回。 -### v3.1 — 聚合完整性修复(已被受控 fallback 取代) +### v3.1 — 聚合完整性修复(已被当前状态表取代) -原目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。当前实现已经移除旧 `/geo/vessels` 路由,船只入口统一为 `/api/v1/vessels/snapshot`。snapshot 优先读取 `ais_raw_observations` 聚合结果;当当前 raw 窗口为空时,才受控回退到 `vessel_position + vessel_static` 最新点,并通过 `diagnostics.legacy_fallback_used` 标记。 +原目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。当前实现已经移除旧 `/geo/vessels` 路由,船只入口统一为 `/api/v1/vessels/snapshot`。snapshot 现在读取 `vessel_current_state` 当前状态表;`ais_raw_observations` 继续保留给轨迹、审计和态势分析,不再由展示接口临时扫描聚合。 因此以下旧 `/geo/vessels` 全量 merge 要求作废,保留在文档中只作为历史决策记录: diff --git a/docs/plans/earth-vessel-rendering-performance-plan.md b/docs/plans/earth-vessel-rendering-performance-plan.md index 44a59f9b..37956069 100644 --- a/docs/plans/earth-vessel-rendering-performance-plan.md +++ b/docs/plans/earth-vessel-rendering-performance-plan.md @@ -9,8 +9,9 @@ - hover / locked 不再放大成世界尺寸 Sprite,而是在原点位叠加同尺寸单点 glow overlay。 - picking 改为屏幕空间命中,拖动和惯性期间跳过 hover picking。 - 普通态关闭 glow,交互态才显示 glow,降低 overdraw 并让默认地图更干净。 +- `0.70.0` 起船只继续走全球当前状态快照:`vessel_current_state` + 全球 bbox + `limit=3000`。上一轮“按当前镜头 bbox 刷新/订阅”的方向已撤回,性能优化集中在批量渲染、关闭动态聚类和减少 picking / rebuild 开销。 -后续如果需要全球 AIS 或更高船只密度,再评估是否从分桶 `Points` 升级到真正 instanced quad 或视口 bbox / LOD。 +后续如果需要更高船只密度,再评估是否从分桶 `Points` 升级到真正 instanced quad 或服务端 LOD;不要把普通旋转/缩放重新接回视口驱动请求。 ## 背景 @@ -214,9 +215,9 @@ hover、locked、dimmed 可通过更新少量 instance attribute 实现,不再 当接入全球 AIS 或船只数量显著增加时,再做数据层优化。 -### 1. 请求视口范围 +### 1. 当前状态快照 -前端请求 `/api/v1/vessels/snapshot` 时必须带上当前视口 `bbox`、`zoom` 和受控 `limit`,减少无关船只。旧 `/api/v1/visualization/geo/vessels` 路由已移除。 +前端请求 `/api/v1/vessels/snapshot` 时仍必须带 `bbox`、`zoom` 和受控 `limit`,但 Earth 运行时传全球 bbox 和 `limit=3000`,不使用当前镜头视口驱动刷新。旧 `/api/v1/visualization/geo/vessels` 路由已移除。 ### 2. 后端排序策略 @@ -224,7 +225,7 @@ hover、locked、dimmed 可通过更新少量 instance attribute 实现,不再 - 数据新鲜度 - 船型优先级 -- 当前视口相关性 +- 全球当前状态可读性 - 是否正在航行 ### 3. 远景聚合 diff --git a/docs/plans/earth-vessel-tracking-plan.md b/docs/plans/earth-vessel-tracking-plan.md index 7480fcf8..37149061 100644 --- a/docs/plans/earth-vessel-tracking-plan.md +++ b/docs/plans/earth-vessel-tracking-plan.md @@ -1,6 +1,7 @@ # 实时船只监控系统 — 实施计划 **状态**:历史计划;实时 AIS 与聚合接口已由 [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md) 接管 +**更新**:`0.70.0` 起 Earth 船只展示使用 `vessel_current_state` 全球当前状态快照;本文中按当前视口 bbox 驱动显示的 LOD 设想仅作为历史记录。 **创建日期**:2026-04-27 **优先数据源**:BarentsWatch AIS(免费但需要 OAuth client credentials)→ AISStream realtime;AISHub / MarineTraffic 保留为付费备选 @@ -194,9 +195,9 @@ GeoJSON Feature 格式: |---------|---------| | > 400 | 默认渲染当前接口返回的全部船只;如性能不足,再引入可配置 LOD 上限 | | 200–400 | 默认渲染当前接口返回的全部船只;如性能不足,再引入可配置 LOD 上限 | -| < 200 | 渲染当前视口 bbox 内全部船只 | +| < 200 | 历史设想:渲染当前视口 bbox 内全部船只;当前实现仍使用全球当前状态快照 | -前端根据相机位置动态计算 bbox,附加到 API 请求中。 +该视口驱动方案已撤回。当前前端使用全球 bbox 请求 `/api/v1/vessels/snapshot`,旋转和缩放不触发船只重拉。 #### 2.4 图层集成 diff --git a/docs/technical/en/README.md b/docs/technical/en/README.md index a578e64c..2dd1d2c2 100644 --- a/docs/technical/en/README.md +++ b/docs/technical/en/README.md @@ -38,6 +38,7 @@ This is the current Intelligent Planet documentation entry point. Docs are organ - [Datasource Collector Settings and Connectivity](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md): datasource catalog, collector settings, connectivity validation, and credentials - [Datasource API Performance](/home/ray/dev/linkong/planet/docs/technical/en/backend-datasources-api-performance.md): DataSources list API performance and caching - [Data Jobs and Outbox Architecture](/home/ray/dev/linkong/planet/docs/technical/en/data-job-earth-sync-architecture.md): PostgreSQL job queue, outbox, listener, and Kafka / Spark evolution boundaries +- [Backend Enum and String Compatibility Contract](/home/ray/dev/linkong/planet/docs/technical/en/backend-enum-contracts.md): stable protocol states, historical string compatibility, and Earth news decision boundaries - [Shared Location Resolution Pipeline Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-development.md): location resolver / pipeline interfaces, registries, and extension points - [News Live Streams Collector Format](/home/ray/dev/linkong/planet/docs/technical/en/earth-news-live-streams-collector-format.md): news, live stream, and media collection payload conventions - [Docs Gatekeeper Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/docs-gatekeeper-development.md): backend Docs catalog, Markdown content loading, and Gatekeeper permission groups diff --git a/docs/technical/en/backend-collectors.md b/docs/technical/en/backend-collectors.md index 6f8da6d0..ad805e91 100644 --- a/docs/technical/en/backend-collectors.md +++ b/docs/technical/en/backend-collectors.md @@ -93,7 +93,9 @@ The CelesTrak TLE collector prefers the complete `active` catalog. If CelesTrak | BarentsWatch AIS | vessel | AIS vessel positions, speed, heading, MMSI, and related fields | Collector settings | | AISStream Vessels | vessel_ais | AIS WebSocket realtime stream, written to the raw observation layer and displayed through aggregation | Collector settings | -AIS vessel collectors use a different persistence path from regular `CollectedData` collectors. BarentsWatch, AISStream, and custom `vessel_ais` sources write into the AIS raw observation layer first, then the aggregation service merges those observations into the GeoJSON and detail payloads used by the Earth vessel layer. This preserves source, transport, field conflicts, and observation time instead of letting one realtime source overwrite the final display table. +AIS vessel collectors use a different persistence path from regular `CollectedData` collectors. BarentsWatch, AISStream, and custom `vessel_ais` sources write into the AIS raw observation layer and also upsert `vessel_current_state`: one row per MMSI with the latest position, speed, course, navigation state, vessel type, name, and source metadata. This preserves raw observation history for tracks, audit, and situational analysis while letting the Earth vessel layer read the current-state table instead of scanning historical AIS rows. + +`vessel_current_state` only lets newer observations overwrite dynamic position fields; static fields such as name and vessel type are merged by non-empty value and source priority. Earth snapshots return only vessels inside the freshness window, keeping high-frequency AIS history out of the display path. Earth boundaries are no longer data collectors. They are Earth static rendering assets: the Earth Assets settings panel owns source configuration, and `/api/v1/earth/boundaries/*` builds `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`. When no high-precision PMTiles artifact is available locally, the frontend uses the bundled low-precision GeoJSON fallback and does not write boundary records to `CollectedData`. @@ -339,32 +341,18 @@ AIS observations do not directly replace final vessel records. They are first sa - Dynamic fields such as position, speed, and course are selected by freshness and source priority. - Static fields prefer non-empty values; conflicting candidates are recorded for detail and diagnostics views. -Earth vessel rendering now consumes the bounded snapshot endpoint and realtime delta channel: +Earth vessel rendering now consumes the current-state snapshot endpoint: ```http -GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/vessels/snapshot?bbox=-180,-85.05112878,180,85.05112878&zoom=12&limit=3000 GET /api/v1/visualization/vessels/{mmsi} GET /api/v1/visualization/vessels/{mmsi}/track GET /api/v1/visualization/vessels/{mmsi}/conflicts ``` -`/api/v1/vessels/snapshot` requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`. It prefers aggregated `ais_raw_observations`; when the current raw window is empty, it can fall back to the latest legacy `vessel_position` / `vessel_static` rows and marks that path with `diagnostics.legacy_fallback_used`. The old `/api/v1/visualization/geo/vessels` route has been removed. +`/api/v1/vessels/snapshot` requires `bbox` and `zoom`, and caps `limit` at `5000`. The Earth frontend uses a global bbox for current state and does not refetch on camera viewport changes. The endpoint reads `vessel_current_state` and reports `diagnostics.source = "vessel_current_state"`. The old `/api/v1/visualization/geo/vessels` route has been removed. -Realtime deltas are sent through the `/ws` `vessels` channel. Clients must subscribe with the current viewport: - -```json -{ - "type": "subscribe", - "data": { - "channel": "vessels", - "bbox": [120.8, 30.7, 122.1, 31.8], - "zoom": 12, - "limit": 1000 - } -} -``` - -The backend stores lightweight subscription filters per connection and only sends vessel updates that match the subscriber bbox. Collector broadcasts enter a 1-second throttle queue; within each flush window, only the latest update per MMSI is retained. +High-frequency AIS updates must not become per-delta full-layer rebuilds. If Earth uses the `/ws` `vessels` channel, it should send low-frequency reload/dirty hints and let the frontend merge snapshot refreshes. Tracks and conflicts still read historical facts through the single-vessel APIs. ### Layer APIs And Global Stats diff --git a/docs/technical/en/backend-enum-contracts.md b/docs/technical/en/backend-enum-contracts.md new file mode 100644 index 00000000..8b01a923 --- /dev/null +++ b/docs/technical/en/backend-enum-contracts.md @@ -0,0 +1,50 @@ +# Backend Enum and String Compatibility Contract + +Planet keeps finite, stable protocol states in `backend/app/core/enums.py`. Databases and APIs continue to store and return lowercase strings. Enums provide internal type safety, validation, and deduplication without requiring a migration to database enum types. + +## Selection Rules + +A value should become an enum only when it is finite and stable, invalid outside the known set, and compared, sorted, or branched on by multiple modules. Typical examples are job states, AI Playground message roles and states, user roles, alert states, log levels, news importance, and Breaking states. + +The following must remain configurable strings: + +- news categories and tags; +- provider, model, datasource, collector, news source, and Feed identifiers; +- extensible incident and anomaly types; and +- user-authored and free-form text. + +## Boundary Conversion + +Services should use `StrEnum` internally. Database writes and external responses use `.value`, preserving existing strings such as `JobStatus.RUNNING.value == "running"`. + +Use the compatibility parser when reading historical database values, JSON, or external input: + +```python +status = parse_enum(JobStatus, raw_status, JobStatus.FAILED) +``` + +Known historical strings normalize to enum members. Empty values use the explicit default. Unknown values emit a warning and safely fall back instead of breaking historical reads. Pydantic request fields may use enums directly so invalid protocol values return `422`. Ordinary String and JSON database columns must not be converted to SQLAlchemy Enum. + +## Earth News Decisions + +Earth news decisions live in `backend/app/services/earth_news_classification.py`: + +- category answers what the story is and remains configurable; +- importance answers whether it has long-term value; and +- Breaking answers whether it must temporarily jump ahead. + +Importance levels are fixed: + +| Level | Score | +|---|---:| +| `low` | 0–34 | +| `medium` | 35–59 | +| `high` | 60–79 | +| `critical` | 80–100 | + +Breaking rules use the typed `BreakingRule` structure. Levels, scopes, sources, and TTLs are managed by the public classification module. `earth_news.py` remains responsible for fetching, parsing, orchestration, and serialization. + +## Regression Prevention + +When adding or changing protocol states, check `app/core/enums.py` first and do not redefine duplicate `Literal` aliases, status sets, or normalization helpers in business modules. Add enum value and boundary contracts to `tests/test_enum_contracts.py`, and preserve API strings and database representation. + diff --git a/docs/technical/en/datasource-collector-settings-connectivity.md b/docs/technical/en/datasource-collector-settings-connectivity.md index 0874fb1f..7ef486d4 100644 --- a/docs/technical/en/datasource-collector-settings-connectivity.md +++ b/docs/technical/en/datasource-collector-settings-connectivity.md @@ -318,10 +318,10 @@ Connectivity validation and actual collection are separate actions. A banner suc The new vessel list entry point is no longer the legacy `/api/v1/visualization/geo/vessels` route. Earth initial state should call: ```http -GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/vessels/snapshot?bbox=-180,-85.05112878,180,85.05112878&zoom=12&limit=3000 ``` -That endpoint prefers local aggregated `ais_raw_observations`; when the current raw window is empty, it can fall back to the latest legacy `vessel_position` / `vessel_static` rows and exposes that through `diagnostics.legacy_fallback_used`. Realtime updates use the `/ws` `vessels` channel; subscriptions must include `bbox`, `zoom`, and `limit`. The server filters updates per connection and merges collector broadcasts every second, keeping only the latest position per MMSI. +That endpoint reads `vessel_current_state`, returning the latest point per MMSI inside the freshness window. Raw `ais_raw_observations` remain available for tracks, audit, and situational analysis, but the display endpoint no longer scans and aggregates history on the fly. Earth sends a global bbox rather than the current camera viewport. If the `/ws` `vessels` channel is connected, it should act as a reload/dirty hint for merged refreshes, not as a per-AIS-delta full-layer rebuild path. ## Custom REST / WebSocket Mapping Runtime diff --git a/docs/technical/en/earth-frontend-context.md b/docs/technical/en/earth-frontend-context.md index 8f1ecbf4..241c9d55 100644 --- a/docs/technical/en/earth-frontend-context.md +++ b/docs/technical/en/earth-frontend-context.md @@ -179,9 +179,15 @@ The `earth:compute-center-location-saved` reconciliation pipeline is deliberatel ### AIS Vessel Layer -The vessel layer now uses `/api/v1/vessels/snapshot` for the initial viewport snapshot and the `/ws` `vessels` channel for realtime deltas. Snapshot requests must include `bbox`, `zoom`, and a bounded `limit`; the backend defaults to `limit=1000` and caps it at `5000`. WebSocket subscriptions must include the same viewport fields so the server can filter updates per connection. +The vessel layer uses `/api/v1/vessels/snapshot` for a global current-state snapshot. Earth sends the global bbox `[-180,-85.05112878,180,85.05112878]`, the current `zoom`, and `limit=3000` when the layer opens. It does not repeatedly refetch by camera viewport, and globe rotation or zoom does not reconnect a vessel WebSocket subscription. -The legacy `/api/v1/visualization/geo/vessels` route has been removed. Frontend code should fetch a snapshot for the current viewport when the layer opens, then subscribe to `vessels` deltas. After map pan or zoom, reload the snapshot and send a fresh vessels subscription. The backend only falls back to legacy `vessel_position` / `vessel_static` rows when the current raw window is empty; frontend code can detect that state through `diagnostics.legacy_fallback_used`. +The backend snapshot endpoint still requires `bbox`, but the Earth runtime treats it as the global current-state entry point. It reads `vessel_current_state`, returning one latest point per MMSI within the freshness window, and no longer scans or aggregates historical `ais_raw_observations` for the display layer. Raw AIS history remains available for tracks, audit, and situational analysis. + +Vessel markers are rendered through `createInteractableLayer()` as batched `THREE.Points`, with `cluster` and `avoidance` explicitly disabled. Dense waterways may overlap. Dragging or inertial rotation skips hover picking; normal hover uses screen-space nearest-point picking. Do not reconnect vessels to dynamic screen clustering or per-frame Points rebuilds, because those interaction costs are what make a 3000-marker layer feel heavy. + +If the `/ws` `vessels` channel is used by Earth, it should be a low-frequency reload/dirty hint only. Do not create multiple viewport subscriptions, and do not turn every AIS delta into a full layer rebuild. + +The legacy `/api/v1/visualization/geo/vessels` route has been removed. Frontend code should keep using `PATHS.vesselsApi` and can verify the current-state path through `diagnostics.source == "vessel_current_state"`. The new layer API family is `/api/v1/layers/*`, which separates map rendering payloads from aggregate panel statistics. Layer requests must include `bbox`, `zoom`, and a bounded `limit`; responses include `visible_count`, `returned_count`, and `diagnostics`, where `degraded`, `truncated`, and `limit_clamped` are the frontend signals for fallback UI. Right-side aggregate panels should not sum the layer response. They should read `/api/v1/data-products` or `/api/v1/data-products/{product_id}/status`, because those statistics stay global and do not change with the viewport. diff --git a/docs/technical/en/earth-layer-style-reference.md b/docs/technical/en/earth-layer-style-reference.md index 525fb46c..592ad7b1 100644 --- a/docs/technical/en/earth-layer-style-reference.md +++ b/docs/technical/en/earth-layer-style-reference.md @@ -193,13 +193,13 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor | Vessel renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | Normal marker and interactive overlay | | Vessel track renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | Below vessel markers | | Vessel point pixel size | local `VESSEL_POINT_SIZE` | `34` | Shared size for normal markers and hover / locked overlays | -| Default vessel render cap | `VESSEL_CONFIG.maxRenderedMarkers` | `0` | `0` means the frontend does not clip by default; positive values send `limit` and clip markers | +| Default vessel render cap | `VESSEL_CONFIG.maxRenderedMarkers` | `3000` | Default `limit` sent to `/api/v1/vessels/snapshot` for the global current-state snapshot; the backend cap remains `5000` | | Vessel texture canvas size | local `VESSEL_ATLAS_CELL_SIZE` | `128` | Canvas point texture | | Course bucket count | local `VESSEL_COURSE_BINS` | `32` | Moving vessels are bucketed by COG to reduce draw calls while preserving direction | | Vessel hover picking throttle | local `VESSEL_HOVER_PICK_INTERVAL_MS` | `100` | `main.js` hover picking | | Vessel screen hit radius | local `VESSEL_POINTER_RADIUS_PX` | `22` | `main.js` screen-space picking | -AIS vessel markers use batched `THREE.Points`, not one `THREE.Sprite` per vessel. Moving vessels stay triangular, anchored or slow vessels stay circular, and hover / locked states add a same-size glow overlay. Vessel type color and info-card type text must come from the same normalized result: `vessels.js` reads both backend `vessel_type_name` and AIS numeric `vessel_type`, derives the color-driving `type`, then exposes `vessel_type_display` for the info card, hover summary, and search results. +AIS vessel markers use batched `THREE.Points`, not one `THREE.Sprite` per vessel. Moving vessels stay triangular, anchored or slow vessels stay circular, and hover / locked states add a same-size glow overlay. Vessels explicitly disable `cluster` and `avoidance`; dense areas may overlap and must not be reattached to dynamic screen clustering. Vessel type color and info-card type text must come from the same normalized result: `vessels.js` reads both backend `vessel_type_name` and AIS numeric `vessel_type`, derives the color-driving `type`, then exposes `vessel_type_display` for the info card, hover summary, and search results. ## Compute Centers diff --git a/docs/technical/en/earth-news-sources.md b/docs/technical/en/earth-news-sources.md index 657fa4d6..51cb74ab 100644 --- a/docs/technical/en/earth-news-sources.md +++ b/docs/technical/en/earth-news-sources.md @@ -80,6 +80,8 @@ Each item includes: Official data, e-commerce metrics, major platforms, and numeric business signals increase importance. Press releases start with a lower baseline and rise only when they match stronger platform, amount, M&A, or regulatory signals. +Importance levels are fixed: `low` 0–34, `medium` 35–59, `high` 60–79, and `critical` 80–100. Category, importance, and Breaking decisions are centralized in `earth_news_classification.py`. Category keys and tags remain configurable, while importance and Breaking protocol states use shared enums. Databases and APIs continue to store compatible lowercase strings. + ## Configuration and Cache `GET /api/v1/earth/news-sources` returns the default or saved configuration. `PUT /api/v1/earth/news-sources` saves it, increments `cache_version`, and clears the process region cache. `POST /api/v1/earth/news-sources/reset` restores defaults. `POST /api/v1/earth/news-sources/test` tests one RSS/Atom/Aggregated source without writing news items. @@ -107,6 +109,27 @@ The Web Earth category chips only store the current browser preference; changing Source testing only proves that a specific RSS/Atom/XML feed can be parsed. It does not mean those items have already been written to the news table or are visible in the current region/category view. Saving or resetting news sources increments the configuration version and clears cache; if an enabled feed has no recent stored items, the next `earth-feed` request supplements from RSS so newly enabled sources such as 36Kr and Ebrun are not masked by fresh Google News rows. +## Breaking News Insertion + +The news system keeps three separate decisions: + +- **Category**: what the story is about, such as business, military, or disaster. +- **Importance**: whether the story has long-term value, stored as `importance_score / importance_level`. +- **Breaking**: whether the story must temporarily jump ahead, stored in `location_meta.news_meta.breaking_*`. + +Breaking metadata does not add physical columns. It remains in `location_meta.news_meta`: + +- `breaking_level`: `none / watch / breaking / critical`. +- `breaking_scope`: `regional / global`. +- `breaking_reasons`: rule or operator reasons. +- `breaking_source`: `rules / ai / manual / multi_source`. +- `breaking_confidence`: 0 to 1. +- `breaking_expires_at`: expiration timestamp. + +The backend performs the ordering, so Web and UE clients do not need to reorder items. Active `critical`, `breaking`, and `watch` items appear before normal items in that order. Once expired, the item falls back to normal ordering without being deleted or changing its long-term importance score. + +`breaking_scope = global` bypasses the active region and can appear in every regional feed. `regional` breaking follows the normal active-region-plus-global rule. The response `filters` includes `has_breaking` and `highest_breaking_level`, which the Earth client uses to apply restrained panel and card styling. + ## Connectivity Monitoring `POST /api/v1/earth/news-sources/test` tests one source and writes the result to `earth_news_sources.health[source_id]`. Normal RSS/Atom fetches update the same health map. diff --git a/docs/technical/en/earth-render-layer-order.md b/docs/technical/en/earth-render-layer-order.md index 5696541b..5b542531 100644 --- a/docs/technical/en/earth-render-layer-order.md +++ b/docs/technical/en/earth-render-layer-order.md @@ -27,8 +27,10 @@ Note: the layer control panel order and the registration / startup load order ar | 2.29 | Country border hover glow | `country-boundaries.js` | Hover radius + glow offset | `depthTest: false`, raycast disabled | Additive glow to reinforce border edge and terrain hover visibility. | | 2.3 | Country border hover line | `country-boundaries.js` | `hoverAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | `depthTest: false`, raycast disabled | Neon red-orange hover line; aligned with the normal borders and HD texture shell to avoid ghosting or floating; China and Taiwan share the same highlight group. | | 3 | Satellite footprint fill / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested; Iridium adapter fill / ring use the same renderOrder | Footprint above land / texture / terrain and country borders, below compute centers and satellites. | -| 3-5 | BGP markers and overlays | `bgp.js` | Each marker's own renderOrder | BGP picking path | Preserves existing BGP visual level. | -| 4.5 | Compute centers | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | Compute center picking path | Surface facilities, below satellites. | +| 3-4.5 | BGP collectors, event rings, and event markers | `bgp.js`, `interactable.js` | Collector markers use `BGP_COLLECTOR_RENDER_ORDER = 4.4`; event markers use `BGP_EVENT_RENDER_ORDER = 4.5`; overlays remain in BGP-owned groups | Screen-space Interactable picking; BGP collectors/events may use collision avoidance where appropriate | BGP collectors share the surface-facility band with vessels; BGP events share the compute-center band. | +| 4.3 | AIS vessel track lines | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`; `CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | Follows vessel selection, not independently picked | Recent track for the selected vessel, below vessel markers. | +| 4.4 | AIS vessel markers | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`; business radius `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`; normal markers are bucketed `THREE.Points`, hover / locked states are single-point overlays | `depthTest: true`; `main.js` uses screen-space picking for front-facing markers; `cluster: false` and `avoidance: false` | Moving vessels use triangular point textures, anchored / slow vessels use dots. Dense waterways may overlap and do not participate in dynamic screen clustering, avoiding Points rebuilds during rotation. | +| 4.5 | Compute centers | `compute-centers.js`, `interactable.js` | `COMPUTE_CENTER_RENDER_ORDER` | Screen-space Interactable picking, with surface-icon collision handling | Surface facilities, below satellites. | | 5 | Satellite background dot | `satellites.js` | Fixed renderOrder; by default TLE/SGP4 altitude is compressed to `CONFIG.earthRadius + 4..25`; with real altitude disabled or propagation failed, uses `fallbackAltitudeOffset = 8` | Screen-space satellite picking | Below satellite dots. | | 6 | Satellite dots | `satellites.js` | Same compressed / fallback height as satellite backdrop dots | Screen-space satellite picking | Satellite dots above footprints and compute centers. | | 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and fixes the lock-time globe pose to draw a closed inertial orbit; returns to same-sphere mode when real altitude is disabled | Satellite overlay path | Used for selected/locked satellite emphasis. | diff --git a/docs/technical/en/platform-data-flows.md b/docs/technical/en/platform-data-flows.md index 2e4883db..8aa5a0aa 100644 --- a/docs/technical/en/platform-data-flows.md +++ b/docs/technical/en/platform-data-flows.md @@ -118,12 +118,12 @@ sequenceDiagram ## Vessels -Vessel data shows AIS vessels, navigation state, vessel-type legend, and source health. Earth rendering uses position snapshots and static vessel information; it should not render every raw AIS observation. +Vessel data shows AIS vessels, navigation state, vessel-type legend, and source health. Earth rendering uses the `vessel_current_state` current-state snapshot; it should not render every raw AIS observation. - **Collection entry**: AIS sources, BarentsWatch vessels. - **Fact table**: `collected_data` or AIS raw observation tables. -- **Derived tables**: `vessel_static`, `vessel_position`, `ais_raw_observations`, `ais_source_health`. -- **API**: vessel visualization API returns current vessel markers and detail fields. +- **Derived tables**: `vessel_current_state`, `vessel_static`, `vessel_position`, `ais_raw_observations`, `ais_source_health`. +- **API**: `/api/v1/vessels/snapshot` returns current vessel markers and detail fields; the Earth frontend uses a global bbox for the global current-state view. - **Delete semantics**: deleting any vessel source lets owned projections broadcast `vessels` with `clear_then_reload`. - **Common failure**: the count panel changes but vessels remain. Summary and layer data are separate; Earth should clear objects based on layer updates. diff --git a/docs/technical/zh/README.md b/docs/technical/zh/README.md index ca2d2fb4..6449503f 100644 --- a/docs/technical/zh/README.md +++ b/docs/technical/zh/README.md @@ -38,6 +38,7 @@ - [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md):数据源目录、采集器设置、连接验证和凭证链路 - [数据源 API 性能](/home/ray/dev/linkong/planet/docs/technical/zh/backend-datasources-api-performance.md):DataSources 列表接口性能和缓存策略 - [数据作业与 Outbox 技术架构](/home/ray/dev/linkong/planet/docs/technical/zh/data-job-earth-sync-architecture.md):PostgreSQL 作业队列、outbox、listener 和 Kafka / Spark 演进边界 +- [后端枚举与字符串兼容契约](/home/ray/dev/linkong/planet/docs/technical/zh/backend-enum-contracts.md):稳定协议状态、历史字符串兼容和 Earth 新闻判定边界 - [通用位置估算管线开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-development.md):location resolver / pipeline 的接口、注册表和扩展方式 - [新闻直播采集格式](/home/ray/dev/linkong/planet/docs/technical/zh/earth-news-live-streams-collector-format.md):新闻、直播和媒体采集 payload 约定 - [Docs Gatekeeper 开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/docs-gatekeeper-development.md):后端文档目录、正文读取和 Gatekeeper 权限组实现 diff --git a/docs/technical/zh/backend-collectors.md b/docs/technical/zh/backend-collectors.md index 93678b7a..44b87c66 100644 --- a/docs/technical/zh/backend-collectors.md +++ b/docs/technical/zh/backend-collectors.md @@ -94,7 +94,9 @@ CelesTrak TLE 采集优先拉取完整 `active` 目录。如果 CelesTrak 返回 | BarentsWatch AIS | vessel | 船只位置、航速、航向、MMSI 等 AIS 数据 | 依采集器配置 | | AISStream Vessels | vessel_ais | AIS WebSocket 实时流,写入原始观测层并由聚合接口展示 | 依采集器配置 | -AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同。BarentsWatch、AISStream 和自定义 `vessel_ais` 源都会进入 AIS 原始观测层,随后由聚合服务合并成 Earth 船只图层使用的 GeoJSON 和详情数据。这样做可以保留来源、传输方式、字段冲突和观测时间,避免某个实时源直接覆盖最终展示表。 +AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同。BarentsWatch、AISStream 和自定义 `vessel_ais` 源都会进入 AIS 原始观测层,同时 upsert `vessel_current_state` 当前状态表:每个 MMSI 只保留一行最新位置、航速、航向、状态、船型、名称和来源元数据。这样既保留原始观测历史用于轨迹、审计和态势分析,又让 Earth 船只图层读取当前状态表,不在展示接口里扫描历史 AIS 记录。 + +`vessel_current_state` 的动态位置只允许被更新观测时间更晚的数据覆盖;名称、船型等静态字段按非空和来源优先策略合并。Earth snapshot 默认只返回有效窗口内的当前船只,避免高频 AIS 历史数据影响地球渲染性能。 智能星球国界不再属于采集器体系。它是智能星球静态渲染资产,由控制台 `运维与配置 -> 智能星球内容 -> 国界精度` 维护源配置,并由 `/api/v1/earth/boundaries/*` 构建 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`。本地没有高精 PMTiles 时,前端会使用仓库内置的低精度 GeoJSON 作为 fallback,不会向 `CollectedData` 写入国界记录。 @@ -369,32 +371,18 @@ AIS 观测写入后不会直接替换最终船只记录,而是先保存为 raw - 位置、速度、航向等动态字段会按 freshness 和来源优先级选择。 - 静态字段优先保留非空值;冲突候选会记录到详情接口,便于排查多源差异。 -Earth 船只展示现在使用受控快照接口和实时增量通道: +Earth 船只展示现在使用当前状态快照接口: ```http -GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/vessels/snapshot?bbox=-180,-85.05112878,180,85.05112878&zoom=12&limit=3000 GET /api/v1/visualization/vessels/{mmsi} GET /api/v1/visualization/vessels/{mmsi}/track GET /api/v1/visualization/vessels/{mmsi}/conflicts ``` -`/api/v1/vessels/snapshot` 必须携带 `bbox` 和 `zoom`,默认 `limit=1000`,最大 `limit=5000`。它优先消费 `ais_raw_observations` 聚合结果;当当前 raw 窗口为空时,会受控回退到 legacy `vessel_position` / `vessel_static` 最新点,并在 `diagnostics.legacy_fallback_used` 中标明。旧 `/api/v1/visualization/geo/vessels` 路由已移除。 +`/api/v1/vessels/snapshot` 必须携带 `bbox` 和 `zoom`,后端最大 `limit=5000`。Earth 前端使用全球 bbox 读取当前状态,不随相机视口变化反复请求。接口消费 `vessel_current_state`,并在 `diagnostics.source` 返回 `vessel_current_state`;旧 `/api/v1/visualization/geo/vessels` 路由已移除。 -实时增量通过 `/ws` 的 `vessels` channel 推送。客户端订阅时必须带当前视口: - -```json -{ - "type": "subscribe", - "data": { - "channel": "vessels", - "bbox": [120.8, 30.7, 122.1, 31.8], - "zoom": 12, - "limit": 1000 - } -} -``` - -后端按连接保存轻量订阅条件,只向 bbox 命中的连接发送船只更新。collector 广播会先进入 1 秒节流队列,同一 MMSI 在一个 flush 周期内只保留最新位置,避免高频实时流拖垮 WebSocket。 +高频 AIS 更新不要直接推送成每条 delta 的整层重建。`/ws` 的 `vessels` channel 如用于 Earth,应广播低频 reload/dirty 提示,由前端合并刷新 snapshot;轨迹和冲突详情仍按单船接口读取历史事实。 ### 图层接口与全量统计分离 diff --git a/docs/technical/zh/backend-enum-contracts.md b/docs/technical/zh/backend-enum-contracts.md new file mode 100644 index 00000000..aa31fa7c --- /dev/null +++ b/docs/technical/zh/backend-enum-contracts.md @@ -0,0 +1,50 @@ +# 后端枚举与字符串兼容契约 + +Planet 后端使用 `backend/app/core/enums.py` 统一维护有限、稳定、会参与协议判断的状态值。数据库和 API 仍保存、输出小写字符串;枚举用于代码内部的类型安全、校验和去重,不要求数据库迁移为 SQL Enum。 + +## 使用准则 + +适合枚举的值必须有限且稳定,非法值应被拒绝或安全回退,并且多个模块会比较、排序或分支处理该值。典型示例包括任务状态、AI Playground 消息角色和状态、用户角色、告警状态、日志级别、新闻重要度和 Breaking 状态。 + +以下值必须保持可配置字符串: + +- 新闻分类与标签。 +- Provider、模型、数据源、collector、新闻源和 Feed 标识。 +- 可扩展的 incident/anomaly 类型。 +- 用户输入和自由文本。 + +## 边界转换 + +服务内部优先使用 `StrEnum`。写入数据库或输出外部协议时使用 `.value`,继续得到现有字符串,例如 `JobStatus.RUNNING.value == "running"`。 + +读取历史数据库、JSON 或外部输入时使用: + +```python +status = parse_enum(JobStatus, raw_status, JobStatus.FAILED) +``` + +合法历史字符串会归一为枚举;空值使用明确默认值;未知值记录 warning 并安全回退,不阻断历史数据读取。Pydantic 请求字段可以直接使用枚举,让非法协议值返回 `422`。普通 String/JSON 数据库列不改成 SQLAlchemy Enum。 + +## Earth 新闻判定 + +Earth 新闻判定集中在 `backend/app/services/earth_news_classification.py`: + +- 分类回答“新闻是什么”,分类 key 仍是可配置字符串。 +- 重要度回答“长期是否值得关注”。 +- Breaking 回答“短时间内是否必须插队”。 + +重要度等级固定为: + +| 等级 | 分数 | +|---|---:| +| `low` | 0–34 | +| `medium` | 35–59 | +| `high` | 60–79 | +| `critical` | 80–100 | + +Breaking 规则使用带类型的 `BreakingRule`;等级、范围、来源和 TTL 由公开分类模块统一管理。`earth_news.py` 只负责抓取、解析、编排与序列化。 + +## 防回退检查 + +新增或修改协议状态时,先检查 `app/core/enums.py`,不要在业务模块重复定义 `Literal`、状态集合或 normalize helper。枚举值与边界行为应补充到 `tests/test_enum_contracts.py`,并保证 API 字符串和数据库表示不变。 + diff --git a/docs/technical/zh/datasource-collector-settings-connectivity.md b/docs/technical/zh/datasource-collector-settings-connectivity.md index d156c366..b1eefdf6 100644 --- a/docs/technical/zh/datasource-collector-settings-connectivity.md +++ b/docs/technical/zh/datasource-collector-settings-connectivity.md @@ -320,10 +320,10 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations` 新版本不再使用 legacy `/api/v1/visualization/geo/vessels` 作为船只列表入口。Earth 初始状态应调用: ```http -GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/vessels/snapshot?bbox=-180,-85.05112878,180,85.05112878&zoom=12&limit=3000 ``` -该接口优先查询本地 `ais_raw_observations` 聚合结果;当当前 raw 窗口为空时,会受控回退到 legacy `vessel_position` / `vessel_static` 最新点,并通过 `diagnostics.legacy_fallback_used` 暴露。实时更新走 `/ws` 的 `vessels` channel,订阅时必须提供 `bbox`、`zoom` 和 `limit`。服务端按连接过滤 bbox,并对 collector 广播做 1 秒合并,同一 MMSI 只推送最新位置。 +该接口查询 `vessel_current_state` 当前状态表,只返回有效窗口内每个 MMSI 的最新点;原始 `ais_raw_observations` 继续保留给轨迹、审计和态势分析,但不再由展示接口临时扫描聚合。Earth 前端统一传全球 bbox,不随当前镜头视口反复请求。实时更新如接入 `/ws` 的 `vessels` channel,应作为 reload/dirty 提示触发合并刷新,不能把每条 AIS delta 直接变成整层重建。 ## 自定义 REST / WebSocket 映射运行时 diff --git a/docs/technical/zh/earth-frontend-context.md b/docs/technical/zh/earth-frontend-context.md index 6d33a38a..ec49c675 100644 --- a/docs/technical/zh/earth-frontend-context.md +++ b/docs/technical/zh/earth-frontend-context.md @@ -312,9 +312,8 @@ AIS 船只图层入口: 船只图层当前负责: -- 请求 `/api/v1/vessels/snapshot` 获取当前视口初始快照;请求必须携带 `bbox`、`zoom`,并传入受控 `limit` -- 通过 `/ws` 的 `vessels` channel 订阅后续增量;订阅 payload 同样必须携带当前视口 `bbox`、`zoom`、`limit` -- 将聚合后的 AIS GeoJSON 转为地球局部坐标 marker 数据;后端默认 `limit=1000`,最大 `limit=5000` +- 请求 `/api/v1/vessels/snapshot` 获取全局当前状态快照;Earth 前端统一传全球 bbox、当前 `zoom` 和 `limit=3000` +- 将 `vessel_current_state` 当前状态 GeoJSON 转为地球局部坐标 marker 数据;历史 AIS 原始观测只用于轨迹、审计和态势分析 - 通过 `createInteractableLayer()` 注册 Interactable 图标层 - 用按航向分桶的 `THREE.Points` 批量渲染普通船只 marker - 按船型映射颜色;`vessels.js` 会用 `vessel_type_name` 和 AIS `vessel_type` 数字共同归一化船型 @@ -330,6 +329,8 @@ AIS 船只图层入口: - moving 船只按 `VESSEL_COURSE_BINS` 做航向分桶。 - 每个批次是一组 `THREE.PointsMaterial`,位置和颜色写入 `BufferGeometry` attribute。 - 普通态不带 glow;hover / locked 时才在相同点位叠加带 glow 的单点 overlay。 +- `cluster: false` 且 `avoidance: false`,密集海域允许重叠,不参与动态屏幕聚类。 +- 地球旋转和缩放不会重新请求船只,也不会按当前镜头 bbox 重连 WebSocket。 方向标准以 AIS `course / cog` 为准:从正北开始顺时针。普通态和交互态都通过同一套 canvas 旋转规则生成纹理,避免 hover 后箭头方向和原 marker 不一致。 @@ -337,7 +338,7 @@ AIS 船只图层入口: AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但船型通常来自低频 `ShipStaticData.Type`。后端会把 `MetaData.ShipName` 补进船名,并将类型码映射为 Cargo / Tanker / Passenger / Fishing / Military;仍缺失的船型需要等待静态 AIS 消息或后续船舶资料 enrichment,不能在前端凭颜色之外的信息臆造细分类。 -旧 `/api/v1/visualization/geo/vessels` 路由已移除。前端打开船只图层时应先按当前视口拉一次 `/api/v1/vessels/snapshot`,再用 WebSocket 接收同一视口内的 upsert 增量;地图拖动或缩放后应重新拉取 snapshot 并重发 vessels 订阅。后端只在当前 raw 窗口为空时受控回退到 legacy `vessel_position` / `vessel_static`,前端可通过 `diagnostics.legacy_fallback_used` 识别该状态。 +旧 `/api/v1/visualization/geo/vessels` 路由已移除。前端打开船只图层时只应拉取一次全局 `/api/v1/vessels/snapshot`;API 参数里的 bbox 是后端接口约束,Earth 运行时传全球范围,不表示当前镜头视口。`/ws` 的 `vessels` channel 如启用,只作为低频 reload/dirty 提示,不能把每条 AIS delta 直接变成整层重建。后端通过 `diagnostics.source == "vessel_current_state"` 暴露当前状态链路。 新的图层接口族是 `/api/v1/layers/*`,用于把地图渲染数据和聚合面板统计分开。地图层请求必须带 `bbox`、`zoom` 和受控 `limit`,响应会返回 `visible_count`、`returned_count` 和 `diagnostics`,其中 `degraded/truncated/limit_clamped` 用于前端提示降级。右侧聚合统计不要从图层响应累加,应读取 `/api/v1/data-products` 或 `/api/v1/data-products/{product_id}/status`,因为这些统计保持全量/全局口径,不随当前视口变化。 diff --git a/docs/technical/zh/earth-layer-style-reference.md b/docs/technical/zh/earth-layer-style-reference.md index 558d71aa..93dc06f9 100644 --- a/docs/technical/zh/earth-layer-style-reference.md +++ b/docs/technical/zh/earth-layer-style-reference.md @@ -205,7 +205,7 @@ | 船只 renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | 普通 marker 和交互 overlay | | 船只轨迹 renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | 低于船只 marker | | 船只点像素尺寸 | local `VESSEL_POINT_SIZE` | `34` | 普通 marker 与 hover / locked overlay 共享尺寸 | -| 船只默认渲染上限 | `VESSEL_CONFIG.maxRenderedMarkers` | `0` | `0` 表示不在前端默认裁剪;正数才会给接口传 `limit` 并裁剪 marker | +| 船只默认渲染上限 | `VESSEL_CONFIG.maxRenderedMarkers` | `3000` | 前端请求全局当前状态快照时传给 `/api/v1/vessels/snapshot` 的默认 `limit`;后端上限仍为 `5000` | | 船只纹理画布尺寸 | local `VESSEL_ATLAS_CELL_SIZE` | `128` | canvas 点纹理 | | 航向分桶数 | local `VESSEL_COURSE_BINS` | `32` | moving 船只按 COG 分桶,降低 draw call 同时保留方向 | | 船只 hover 拾取节流 | local `VESSEL_HOVER_PICK_INTERVAL_MS` | `100` | `main.js` hover picking | @@ -216,7 +216,7 @@ | locked 船只透明度 | inline | `1` | locked overlay | | 船型颜色 | `VESSEL_CONFIG.colors.*` | cargo / tanker / passenger / fishing / military / other | `PointsMaterial.vertexColors` 和 overlay texture | -AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。航行船只保持三角形,停泊或低速船只保持圆点;普通态不带 glow,hover / locked 时在同一屏幕尺寸上叠加带 glow 的单点 overlay。AIS 航向按 `course / cog` 从正北顺时针解释,普通态和交互态必须使用同一套 canvas 旋转规则。 +AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。航行船只保持三角形,停泊或低速船只保持圆点;普通态不带 glow,hover / locked 时在同一屏幕尺寸上叠加带 glow 的单点 overlay。AIS 航向按 `course / cog` 从正北顺时针解释,普通态和交互态必须使用同一套 canvas 旋转规则。船只显式关闭 `cluster` 和 `avoidance`,密集区域可以重叠,不能重新接入动态屏幕聚类。 船型颜色和详情卡船型文本必须来自同一套归一化结果:`vessels.js` 同时读取后端 `vessel_type_name` 和 AIS 数字 `vessel_type`,先得到颜色用的 `type`,再生成 `vessel_type_display` 给详情卡、hover 和搜索使用。 diff --git a/docs/technical/zh/earth-news-sources.md b/docs/technical/zh/earth-news-sources.md index 8918d976..4b54d8e6 100644 --- a/docs/technical/zh/earth-news-sources.md +++ b/docs/technical/zh/earth-news-sources.md @@ -80,6 +80,8 @@ Earth 态势新闻使用 `/api/v1/news/earth-feed` 输出给前端。新闻源 官方数据源、电商指标、平台型公司、量化指标会提高重要度;企业公告基础权重较低,只有命中大平台、金额、并购、监管等信号时提升。 +重要度等级固定为:`low` 0–34、`medium` 35–59、`high` 60–79、`critical` 80–100。分类、重要度和 Breaking 的计算集中在 `earth_news_classification.py`;分类 key 和标签仍可配置,重要度与 Breaking 协议状态使用统一枚举,数据库和 API 继续保存兼容的小写字符串。 + ## 配置与缓存 `GET /api/v1/earth/news-sources` 返回默认或已保存配置。`PUT /api/v1/earth/news-sources` 保存配置并递增 `cache_version`,同时清理进程内 region cache。`POST /api/v1/earth/news-sources/reset` 恢复默认源。`POST /api/v1/earth/news-sources/test` 只测试单个 RSS/Atom/Aggregated 源,不写入新闻表。 @@ -107,6 +109,27 @@ Web 星球端的新闻类型按钮只保存当前浏览器的显示偏好;偏 源测试只证明当前 RSS/Atom/XML 能解析到条目,不等于这些条目已经入库展示。展示链路还会检查区域、类型过滤和数据库新鲜度。保存或重置新闻源会递增配置版本并清理缓存;如果当前启用的 Feed 子项在库里没有近期条目,下一次 `earth-feed` 请求会补抓,避免新启用的 36氪、亿邦被旧 Google News 缓存挡住。 +## Breaking News 插队 + +新闻体系里有三套互不替代的判断: + +- **分类**:新闻是什么,例如商业、军事、灾害。 +- **重要度**:长期是否值得关注,写入 `importance_score / importance_level`。 +- **Breaking**:短时间内是否必须插队,写入 `location_meta.news_meta.breaking_*`。 + +Breaking 不新增表字段,继续保存在 `location_meta.news_meta`: + +- `breaking_level`:`none / watch / breaking / critical`。 +- `breaking_scope`:`regional / global`。 +- `breaking_reasons`:触发原因。 +- `breaking_source`:`rules / ai / manual / multi_source`。 +- `breaking_confidence`:0 到 1。 +- `breaking_expires_at`:过期时间。 + +排序由服务端完成,客户端和 UE 不需要自己重排。未过期的 `critical`、`breaking`、`watch` 会依次排在普通新闻前面;过期后只回到普通排序,不删除新闻,也不改变长期重要度。 + +`breaking_scope = global` 的新闻会无视当前区域,进入所有区域的 feed;`regional` 只遵守当前区域加 `global` 的普通区域规则。接口响应的 `filters` 会返回 `has_breaking` 和 `highest_breaking_level`,星球端据此给新闻面板和卡片加克制的背景/边框状态。 + ## 连通性监测 `POST /api/v1/earth/news-sources/test` 会测试单个源并把结果写入 `earth_news_sources.health[source_id]`。实际 RSS/Atom 抓取也会更新同一份健康状态。 diff --git a/docs/technical/zh/earth-render-layer-order.md b/docs/technical/zh/earth-render-layer-order.md index 10424c82..9e703d9f 100644 --- a/docs/technical/zh/earth-render-layer-order.md +++ b/docs/technical/zh/earth-render-layer-order.md @@ -30,7 +30,7 @@ | 3 | 卫星 footprint 填充 / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested;Iridium adapter 的 fill / ring 也使用同一 renderOrder | Footprint 在 land / texture / terrain 和国界线之上,但在算力中心和卫星之下。 | | 3-4.5 | BGP 观测站、事件扩散圈和事件 marker | `bgp.js`, `interactable.js` | BGP 观测站和事件 marker 均使用 `Interactable` 批量 `THREE.Points`;事件 marker 使用 `BGP_EVENT_RENDER_ORDER = 4.5`;观测站主图标使用 `BGP_COLLECTOR_RENDER_ORDER = 4.4` 和 `BGP_CONFIG.collectorAltitudeOffset = 0.2`;事件 overlay 进入 `bgp-event-overlay-layer`;观测站 halo 和覆盖扇形进入 `bgp-collector-radar-layer` | BGP 事件和观测站都通过 `Interactable` 屏幕空间 picking,并参与同坐标避让 | BGP 观测站主图标与船只同层;BGP 事件与算力中心同层;向外扩散圈、观测站雷达/覆盖动画继续由 BGP 业务逻辑驱动。 | | 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`;`CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 | -| 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`,hover / locked 为单点 `THREE.Points` overlay | `depthTest: true`;`main.js` 使用屏幕空间 picking,只取正面 marker;参与 Interactable 同坐标避让 | 航行船只用三角点纹理,停泊/低速用圆点;普通态无 glow,交互态叠加同尺寸 glow;低于算力中心 `4.5`。 | +| 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`,hover / locked 为单点 `THREE.Points` overlay | `depthTest: true`;`main.js` 使用屏幕空间 picking,只取正面 marker;`cluster: false` 且 `avoidance: false` | 航行船只用三角点纹理,停泊/低速用圆点;密集海域允许重叠,不参与动态屏幕聚类,避免旋转时重建 Points;低于算力中心 `4.5`。 | | 4.5 | 算力中心 | `compute-centers.js`, `interactable.js` | 使用 `COMPUTE_CENTER_RENDER_ORDER` 并由 `Interactable` 绘制 | 通过 `Interactable` 屏幕空间 picking,参与同坐标避让 | 地表设施层,保持在卫星下方。登陆点已下沉到海缆层。 | | 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder;默认按 TLE/SGP4 真实高度压缩到 `CONFIG.earthRadius + 4..25`,关闭真实高度或传播失败时回退到 `fallbackAltitudeOffset = 8` | 屏幕空间卫星拾取 | 位于卫星点下方。 | | 6 | 卫星点 | `satellites.js` | 与卫星背景点使用同一压缩高度 / fallback 高度 | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 | diff --git a/docs/technical/zh/platform-data-flows.md b/docs/technical/zh/platform-data-flows.md index f35e4410..a3151735 100644 --- a/docs/technical/zh/platform-data-flows.md +++ b/docs/technical/zh/platform-data-flows.md @@ -118,12 +118,12 @@ sequenceDiagram ## 船舶链路 -船舶数据用于展示 AIS 船只、航行状态、船型图例和源健康。Earth 渲染使用位置快照和静态船舶信息,不应依赖原始 AIS 记录逐条渲染。 +船舶数据用于展示 AIS 船只、航行状态、船型图例和源健康。Earth 渲染使用 `vessel_current_state` 当前状态快照,不应依赖原始 AIS 记录逐条渲染。 - **采集入口**:AIS sources、BarentsWatch vessels。 - **事实表**:`collected_data` 或 AIS 原始观测表。 -- **派生表**:`vessel_static`、`vessel_position`、`ais_raw_observations`、`ais_source_health`。 -- **接口**:vessels visualization API 返回当前船只 marker 和必要详情。 +- **派生表**:`vessel_current_state`、`vessel_static`、`vessel_position`、`ais_raw_observations`、`ais_source_health`。 +- **接口**:`/api/v1/vessels/snapshot` 返回当前船只 marker 和必要详情;Earth 前端使用全球 bbox 请求全局当前状态。 - **删除语义**:删除任一船舶 source 后,owned 派生表变化会广播 `vessels` 的 `clear_then_reload`。 - **常见异常**:数量面板变化但船只仍在,多半是 summary 和图层数据分离,前端应以 layer update 为准清空对象。 diff --git a/docs/version-history.md b/docs/version-history.md index 0d0656e3..aeadb9b1 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.69.0` +- `dev` 当前开发分支历史推导到:`0.70.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.70.0` | feature | `dev` | `pending` | 新增后端枚举契约治理、Earth 新闻分类/Breaking 链路和船只当前状态快照,清理错误视口刷新逻辑并同步双语文档 | | `0.69.0` | feature | `dev` | `pending` | 新增 Earth 新闻源治理、新闻类型服务端过滤、观测日志 fingerprint 聚合和 TV/HLS 播放恢复改进 | | `0.68.1` | bugfix | `dev` | `pending` | 修复 CelesTrak fallback group/cache 恢复链路,并让数据源任务日志可按 task_id / datasource_id 搜索 | | `0.68.0` | feature | `dev` | `pending` | 新增数据源任务队列实时指标、AIS 大表分批删除和智能星球可插拔聚类策略,并让新设备启动前同步前端依赖 | diff --git a/frontend/package.json b/frontend/package.json index ffda52c5..3d2d7e23 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.69.0", + "version": "0.70.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/news-panel.css b/frontend/public/earth/css/news-panel.css index 883a548e..599d114e 100644 --- a/frontend/public/earth/css/news-panel.css +++ b/frontend/public/earth/css/news-panel.css @@ -14,12 +14,12 @@ border-radius: 0; padding: 0 calc(18px * var(--hud-scale)); display: grid; - grid-template-columns: auto minmax(0, 1fr); + grid-template-columns: minmax(calc(112px * var(--hud-scale)), calc(152px * var(--hud-scale))) minmax(0, 1fr); align-items: center; gap: calc(12px * var(--hud-scale)); color: var(--hud-text); background: - linear-gradient(90deg, transparent 0%, rgba(17, 31, 53, 0.82) 11%, rgba(7, 17, 31, 0.78) 89%, transparent 100%), + linear-gradient(90deg, transparent 0%, rgba(17, 31, 53, 0.28) 4%, rgba(17, 31, 53, 0.82) 12%, rgba(7, 17, 31, 0.78) 88%, rgba(7, 17, 31, 0.26) 96%, transparent 100%), radial-gradient(circle at 50% -80%, rgba(145, 186, 255, 0.18), transparent 58%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), @@ -30,6 +30,8 @@ cursor: pointer; z-index: 24; overflow: hidden; + -webkit-mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.84) 0%, #000 3%, #000 97%, rgba(0, 0, 0, 0.84) 100%); + mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.84) 0%, #000 3%, #000 97%, rgba(0, 0, 0, 0.84) 100%); transition: opacity 0.18s ease, transform 0.24s ease; } @@ -40,41 +42,30 @@ .earth-news-ticker::before, .earth-news-ticker::after { - content: ""; - position: absolute; - top: -1px; - bottom: -1px; - width: calc(96px * var(--hud-scale)); - pointer-events: none; - z-index: 2; -} - -.earth-news-ticker::before { - left: 0; - background: linear-gradient(90deg, rgba(0, 0, 0, 0), rgba(8, 18, 32, 0.08)); -} - -.earth-news-ticker::after { - right: 0; - background: linear-gradient(270deg, rgba(0, 0, 0, 0), rgba(8, 18, 32, 0.08)); + content: none; } .earth-news-ticker__region { position: relative; z-index: 3; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; color: var(--hud-accent-strong); font-size: calc(0.66rem * var(--hud-scale)); font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; + text-align: left; } .earth-news-ticker__viewport { min-width: 0; overflow: hidden; - -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%); - mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%); + -webkit-mask-image: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.22) 4%, rgba(0, 0, 0, 0.72) 11%, #000 18%, #000 82%, rgba(0, 0, 0, 0.72) 89%, rgba(0, 0, 0, 0.22) 96%, transparent 100%); + mask-image: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.22) 4%, rgba(0, 0, 0, 0.72) 11%, #000 18%, #000 82%, rgba(0, 0, 0, 0.72) 89%, rgba(0, 0, 0, 0.22) 96%, transparent 100%); } .earth-news-ticker__track { @@ -490,6 +481,54 @@ 0 0 18px rgba(255, 184, 77, 0.12); } +.earth-news-hud.has-breaking-watch, +.earth-news-ticker.has-breaking-watch, +.earth-mobile-news-board.has-breaking-watch { + box-shadow: + 0 0 0 1px rgba(255, 213, 128, 0.08) inset, + 0 18px 46px rgba(156, 106, 20, 0.12); +} + +.earth-news-hud.has-breaking-breaking, +.earth-news-ticker.has-breaking-breaking, +.earth-mobile-news-board.has-breaking-breaking { + box-shadow: + 0 0 0 1px rgba(255, 166, 102, 0.14) inset, + 0 18px 48px rgba(185, 89, 32, 0.16); +} + +.earth-news-hud.has-breaking-critical, +.earth-news-ticker.has-breaking-critical, +.earth-mobile-news-board.has-breaking-critical { + box-shadow: + 0 0 0 1px rgba(255, 118, 118, 0.18) inset, + 0 18px 52px rgba(172, 43, 43, 0.18); +} + +.news-story-card--breaking-watch { + border-color: rgba(255, 213, 128, 0.28); + background: + linear-gradient(180deg, rgba(255, 248, 220, 0.08), rgba(255, 184, 77, 0.05)); +} + +.news-story-card--breaking-breaking { + border-color: rgba(255, 166, 102, 0.36); + background: + linear-gradient(180deg, rgba(255, 205, 150, 0.1), rgba(255, 135, 80, 0.07)); +} + +.news-story-card--breaking-critical { + border-color: rgba(255, 118, 118, 0.42); + background: + linear-gradient(180deg, rgba(255, 155, 155, 0.11), rgba(205, 58, 58, 0.08)); +} + +.news-story-card--breaking-global { + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.05) inset, + 0 0 18px rgba(255, 168, 96, 0.1); +} + .news-story-meta, .news-story-tags { display: flex; @@ -548,6 +587,12 @@ background: rgba(255, 255, 255, 0.04); } +.news-story-tag--breaking { + color: rgba(255, 232, 205, 0.96); + border: 1px solid rgba(255, 184, 96, 0.24); + background: rgba(255, 151, 78, 0.11); +} + .news-board-empty { color: var(--hud-text-muted); font-size: calc(0.82rem * var(--hud-scale)); @@ -555,6 +600,14 @@ padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale)); } +@media (max-width: 1180px) { + .earth-news-ticker { + --news-ticker-width: min(calc(760px * var(--hud-scale)), 66vw); + grid-template-columns: minmax(calc(86px * var(--hud-scale)), calc(118px * var(--hud-scale))) minmax(0, 1fr); + padding-inline: calc(14px * var(--hud-scale)); + } +} + .layout-mode-mobile .earth-news-ticker, .layout-mode-mobile .earth-news-hud { display: none !important; diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 4dd77982..94190a34 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -280,7 +280,7 @@ export const PATHS = { export const VESSEL_CONFIG = { altitudeOffset: 0.2, - maxRenderedMarkers: 0, + maxRenderedMarkers: 3000, marker: { baseScale: 7.5, baseOpacity: 0.88, diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index b42d7bd5..9f7cc8df 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -3896,7 +3896,8 @@ async function ensureVesselsEnabled() { if (!earth) return 0; vesselsEnabled = true; - const result = await loadVessels(scene, earth); + const zoom = getZoomLevel(); + const result = await loadVessels(scene, earth, { zoom }); toggleVessels(true); startVesselRealtime(earth, { onUpdate: ({ totalCount }) => { diff --git a/frontend/public/earth/js/news-locale.js b/frontend/public/earth/js/news-locale.js index c2eed4dd..3a7760f7 100644 --- a/frontend/public/earth/js/news-locale.js +++ b/frontend/public/earth/js/news-locale.js @@ -31,6 +31,17 @@ const CATEGORY_LABELS = { other: "其他", }; +const BREAKING_LEVEL_LABELS = { + watch: "关注", + breaking: "突发", + critical: "严重突发", +}; + +const BREAKING_SCOPE_LABELS = { + regional: "区域", + global: "全球", +}; + const SOURCE_TYPE_LABELS = { rss: "RSS", atom: "Atom", @@ -131,6 +142,14 @@ export function getNewsCategoryLabel(category) { return CATEGORY_LABELS[category] || category || CATEGORY_LABELS.other; } +export function getNewsBreakingLabel(level, scope = "regional") { + const normalizedLevel = normalizeText(level).toLowerCase(); + if (!normalizedLevel || normalizedLevel === "none") return ""; + const levelLabel = BREAKING_LEVEL_LABELS[normalizedLevel] || level; + const scopeLabel = BREAKING_SCOPE_LABELS[normalizeText(scope).toLowerCase()] || BREAKING_SCOPE_LABELS.regional; + return `${scopeLabel}${levelLabel}`; +} + export function getNewsSourceTypeLabel(sourceType) { const normalized = normalizeText(sourceType).toLowerCase(); return SOURCE_TYPE_LABELS[normalized] || sourceType || "RSS"; diff --git a/frontend/public/earth/js/news.js b/frontend/public/earth/js/news.js index fcaa6d68..16d7a29b 100644 --- a/frontend/public/earth/js/news.js +++ b/frontend/public/earth/js/news.js @@ -3,6 +3,7 @@ import { getNewsDisplaySummary, getNewsDisplayTitle, getNewsCategoryLabel, + getNewsBreakingLabel, getNewsEnrichmentStatusLabel, getNewsFetchChannelLabel, getNewsRegionLabel, @@ -371,6 +372,54 @@ function getDisplayableNewsItems(items) { : []; } +function normalizeBreakingLevel(level) { + const normalized = String(level ?? "").trim().toLowerCase(); + return ["watch", "breaking", "critical"].includes(normalized) ? normalized : "none"; +} + +function normalizeBreakingScope(scope) { + const normalized = String(scope ?? "").trim().toLowerCase(); + return normalized === "global" ? "global" : "regional"; +} + +function isBreakingActive(item) { + const level = normalizeBreakingLevel(item?.breaking_level); + if (level === "none") return false; + const expiresAt = item?.breaking_expires_at ? new Date(item.breaking_expires_at) : null; + return !expiresAt || Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() > Date.now(); +} + +function getHighestBreakingLevel(items) { + const ranks = { none: 0, watch: 1, breaking: 2, critical: 3 }; + return (Array.isArray(items) ? items : []).reduce((highest, item) => { + if (!isBreakingActive(item)) return highest; + const level = normalizeBreakingLevel(item?.breaking_level); + return ranks[level] > ranks[highest] ? level : highest; + }, "none"); +} + +function applyNewsBreakingShellState(nextPayload) { + const filtersLevel = normalizeBreakingLevel(nextPayload?.filters?.highest_breaking_level); + const computedLevel = getHighestBreakingLevel([ + ...(Array.isArray(nextPayload?.items) ? nextPayload.items : []), + ...(Array.isArray(nextPayload?.cruise_items) ? nextPayload.cruise_items : []), + ]); + const level = filtersLevel !== "none" ? filtersLevel : computedLevel; + document + .querySelectorAll(".earth-news-hud, .earth-news-ticker, .earth-mobile-news-board") + .forEach((element) => { + if (!(element instanceof HTMLElement)) return; + element.classList.remove( + "has-breaking-watch", + "has-breaking-breaking", + "has-breaking-critical", + ); + if (level !== "none") { + element.classList.add(`has-breaking-${level}`); + } + }); +} + function normalizeNewsSourceType(value) { const normalized = String(value ?? "").trim().toLowerCase(); return normalized || ""; @@ -662,6 +711,7 @@ function renderPayload(nextPayload) { renderTicker(nextPayload); syncFilterSummaries(nextPayload); + applyNewsBreakingShellState(nextPayload); if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) { if (document.body.classList.contains("layout-mode-mobile")) { @@ -729,19 +779,36 @@ function renderPayload(nextPayload) { board.innerHTML = displayItems .map((item) => { - const cardClass = item.is_focus_match - ? "news-story-card news-story-card--focus" - : "news-story-card"; + const breakingLevel = isBreakingActive(item) ? normalizeBreakingLevel(item.breaking_level) : "none"; + const breakingScope = normalizeBreakingScope(item.breaking_scope); + const cardClass = [ + "news-story-card", + item.is_focus_match ? "news-story-card--focus" : "", + breakingLevel !== "none" ? `news-story-card--breaking-${breakingLevel}` : "", + breakingLevel !== "none" && breakingScope === "global" ? "news-story-card--breaking-global" : "", + ].filter(Boolean).join(" "); const title = getNewsDisplayTitle(item); const summaryText = getNewsDisplaySummary(item); const leadText = summaryText || title; const regionLabel = item.display_region || getNewsRegionLabel(item.region); const categoryLabel = getNewsCategoryLabel(item.category); const statusLabel = getNewsEnrichmentStatusLabel(item); + const breakingLabel = getNewsBreakingLabel(breakingLevel, breakingScope); const sourceDescriptor = getNewsSourceDescriptor(item, sourcesByName, sourcesById); const summary = title && title !== leadText ? `
${escapeNewsHtml(title)}
` : ""; + const tagHtml = breakingLevel !== "none" + ? ` + ${escapeNewsHtml(breakingLabel)} + ${escapeNewsHtml(categoryLabel)} + ${escapeNewsHtml(regionLabel)} + ` + : ` + ${escapeNewsHtml(categoryLabel)} + ${escapeNewsHtml(regionLabel)} + ${escapeNewsHtml(statusLabel)} + `; return `
@@ -752,9 +819,7 @@ function renderPayload(nextPayload) {
${escapeNewsHtml(leadText)}
${summary}
`; diff --git a/frontend/public/earth/js/vessels.js b/frontend/public/earth/js/vessels.js index 7337e0ff..ec6c738c 100644 --- a/frontend/public/earth/js/vessels.js +++ b/frontend/public/earth/js/vessels.js @@ -2,20 +2,12 @@ import * as THREE from "three"; import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js"; import { createInteractableLayer } from "./interactable.js"; -import { - canAttemptEarthRealtime, - getEarthRealtimeCooldownMs, - getEarthRealtimeUrl, - recordEarthRealtimeFailure, - recordEarthRealtimeOpen, -} from "./realtime.js"; import { latLonToVector3 } from "./utils.js"; let showVessels = false; let activeTrackLine = null; -let vesselStreamSocket = null; -let vesselStreamReconnectTimer = null; let vesselDataByKey = new Map(); +let vesselSnapshotGeneration = 0; let vesselRealtimeStats = { connected: false, updates: 0, @@ -70,48 +62,6 @@ function markerDataToDedupeKey(item) { ].join(":"); } -function buildVesselFeatureFromDelta(item) { - const lat = Number(item?.lat ?? item?.latitude); - const lon = Number(item?.lon ?? item?.lng ?? item?.longitude); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; - return { - type: "Feature", - id: item.mmsi, - geometry: { - type: "Point", - coordinates: [lon, lat], - }, - properties: { - ...item, - mmsi: item.mmsi, - mmsi_display: item.mmsi_display || (item.mmsi !== undefined && item.mmsi !== null ? String(item.mmsi) : undefined), - }, - }; -} - -function rebuildVesselLayerFromCache(earth) { - if (!earth) return; - vesselIconLayer.setData(Array.from(vesselDataByKey.values())); - vesselIconLayer.attach(earth); - vesselIconLayer.setVisible(showVessels); -} - -function applyVesselDeltas(earth, vessels = []) { - let changed = false; - vessels.forEach((item) => { - const feature = buildVesselFeatureFromDelta(item); - if (!feature) return; - const marker = buildVesselMarkerData(feature); - if (!marker) return; - vesselDataByKey.set(markerDataToDedupeKey(marker), marker); - changed = true; - }); - if (changed) { - rebuildVesselLayerFromCache(earth); - } - return changed; -} - function normalizeVesselType(value, code) { const type = String(value || "").trim().toLowerCase(); const numericCode = Number(code); @@ -258,18 +208,13 @@ const vesselIconLayer = createInteractableLayer({ vessel_kind: item.type, baseScale: VESSEL_CONFIG.marker.baseScale, }), - cluster: { - strategy: "dynamic-screen", - enabled: true, - maxMarkersPerDot: 10, - }, + cluster: false, }); -const DEFAULT_VESSEL_VIEWPORT = { - bbox: [-10, 50, 35, 75], +const GLOBAL_VESSEL_VIEWPORT = { + bbox: [-180, -85.05112878, 180, 85.05112878], zoom: 4, }; -const MAX_VESSEL_SUBSCRIPTION_BBOX_AREA = 2500; export function getVesselMarkers() { return vesselIconLayer.getMarkers(); @@ -324,25 +269,25 @@ export function clearVesselData(earth) { } export async function loadVessels(_scene, earth, options = {}) { - const params = new URLSearchParams(); + const requestGeneration = ++vesselSnapshotGeneration; const requestedLimit = Number(options.limit ?? VESSEL_CONFIG.maxRenderedMarkers); const bbox = Array.isArray(options.bbox) && options.bbox.length === 4 ? options.bbox - : DEFAULT_VESSEL_VIEWPORT.bbox; + : GLOBAL_VESSEL_VIEWPORT.bbox; const zoom = Number.isFinite(Number(options.zoom)) ? Number(options.zoom) - : DEFAULT_VESSEL_VIEWPORT.zoom; - params.set("bbox", bbox.join(",")); - params.set("zoom", String(zoom)); + : GLOBAL_VESSEL_VIEWPORT.zoom; + const params = new URLSearchParams({ bbox: bbox.join(","), zoom: String(zoom) }); if (Number.isFinite(requestedLimit) && requestedLimit > 0) { params.set("limit", String(requestedLimit)); } const response = await fetch(`${PATHS.vesselsApi}?${params.toString()}`, { cache: "no-store" }); - if (!response.ok) { - throw new Error(`Vessels HTTP ${response.status}`); - } + if (!response.ok) throw new Error(`Vessels HTTP ${response.status}`); const payload = await response.json(); const features = Array.isArray(payload?.features) ? payload.features : []; + if (requestGeneration !== vesselSnapshotGeneration) { + return { totalCount: getVesselCount(), stats: {} }; + } clearVesselData(earth); let markerData = dedupeVesselFeatures(features); @@ -361,132 +306,12 @@ export async function loadVessels(_scene, earth, options = {}) { }; } -function normalizeVesselViewportOptions(options = {}) { - const bbox = Array.isArray(options.bbox) && options.bbox.length === 4 - ? options.bbox.map(Number) - : DEFAULT_VESSEL_VIEWPORT.bbox; - const [lonA, latA, lonB, latB] = bbox; - const normalizedBbox = [ - Math.max(-180, Math.min(lonA, lonB)), - Math.max(-90, Math.min(latA, latB)), - Math.min(180, Math.max(lonA, lonB)), - Math.min(90, Math.max(latA, latB)), - ]; - const area = (normalizedBbox[2] - normalizedBbox[0]) * (normalizedBbox[3] - normalizedBbox[1]); - const safeBbox = Number.isFinite(area) && area <= MAX_VESSEL_SUBSCRIPTION_BBOX_AREA - ? normalizedBbox - : DEFAULT_VESSEL_VIEWPORT.bbox; - const zoom = Number.isFinite(Number(options.zoom)) - ? Number(options.zoom) - : DEFAULT_VESSEL_VIEWPORT.zoom; - return { - bbox: safeBbox, - zoom: Math.max(1, Math.min(20, Math.round(zoom))), - limit: options.limit ?? VESSEL_CONFIG.maxRenderedMarkers, - }; -} - -export function startVesselRealtime(earth, { onUpdate, bbox, zoom, limit } = {}) { - if (vesselStreamSocket || vesselStreamReconnectTimer || typeof WebSocket === "undefined") return; - const subscriptionOptions = normalizeVesselViewportOptions({ bbox, zoom, limit }); - const connect = () => { - if (!showVessels || vesselStreamSocket) return; - if (!canAttemptEarthRealtime()) { - vesselStreamReconnectTimer = window.setTimeout(connect, Math.max(3000, getEarthRealtimeCooldownMs())); - return; - } - const socket = new WebSocket(getEarthRealtimeUrl()); - vesselStreamSocket = socket; - socket.onopen = () => { - socket.__planetOpened = true; - recordEarthRealtimeOpen(); - vesselRealtimeStats = { - ...vesselRealtimeStats, - connected: true, - }; - onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() }); - socket.send(JSON.stringify({ - type: "subscribe", - data: { - channel: "vessels", - bbox: subscriptionOptions.bbox, - zoom: subscriptionOptions.zoom, - limit: subscriptionOptions.limit, - }, - })); - }; - socket.onmessage = (event) => { - let message; - try { - message = JSON.parse(event.data); - } catch { - return; - } - if (message.type === "heartbeat" && message.data?.action === "ping") { - socket.send(JSON.stringify({ type: "heartbeat" })); - return; - } - if (message.type !== "data_frame" || message.channel !== "vessels") return; - const payload = message.payload || {}; - if (payload.action === "reload") { - loadVessels(null, earth) - .then((result) => { - vesselRealtimeStats = { - ...vesselRealtimeStats, - connected: true, - updates: vesselRealtimeStats.updates + 1, - lastUpdateAt: new Date(), - lastBatchSize: 0, - }; - onUpdate?.({ totalCount: result?.totalCount ?? getVesselCount(), payload, stream: getVesselRealtimeStats() }); - }) - .catch(() => {}); - return; - } - if (payload.action !== "upsert" || !Array.isArray(payload.vessels)) return; - if (applyVesselDeltas(earth, payload.vessels)) { - vesselRealtimeStats = { - connected: true, - updates: vesselRealtimeStats.updates + 1, - lastUpdateAt: new Date(), - lastBatchSize: payload.vessels.length, - }; - onUpdate?.({ totalCount: getVesselCount(), payload, stream: getVesselRealtimeStats() }); - } - }; - socket.onclose = () => { - if (vesselStreamSocket === socket) { - vesselStreamSocket = null; - } - vesselRealtimeStats = { - ...vesselRealtimeStats, - connected: false, - }; - onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() }); - if (showVessels) { - if (!socket.__planetOpened) { - recordEarthRealtimeFailure(); - } - vesselStreamReconnectTimer = window.setTimeout(connect, Math.max(3000, getEarthRealtimeCooldownMs())); - } - }; - socket.onerror = () => { - socket.close(); - }; - }; - connect(); +export function startVesselRealtime(_earth, { onUpdate } = {}) { + onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() }); } export function stopVesselRealtime() { - if (vesselStreamReconnectTimer) { - window.clearTimeout(vesselStreamReconnectTimer); - vesselStreamReconnectTimer = null; - } - if (vesselStreamSocket) { - const socket = vesselStreamSocket; - vesselStreamSocket = null; - socket.close(); - } + vesselSnapshotGeneration += 1; vesselRealtimeStats = { connected: false, updates: 0, diff --git a/frontend/src/pages/Docs/docs-content.ts b/frontend/src/pages/Docs/docs-content.ts index c931fff4..d838b7ee 100644 --- a/frontend/src/pages/Docs/docs-content.ts +++ b/frontend/src/pages/Docs/docs-content.ts @@ -147,6 +147,10 @@ export const DOCS_METADATA: Record = { zh: { title: '数据采集系统', group: 'Backend', order: 30 }, en: { title: 'Data Collectors', group: 'Backend', order: 30 }, }, + 'backend-enum-contracts.md': { + zh: { title: '后端枚举与字符串兼容契约', group: 'Backend', order: 35 }, + en: { title: 'Backend Enum and String Compatibility Contract', group: 'Backend', order: 35 }, + }, 'backend-system-service-control.md': { zh: { title: '系统服务控制', group: 'Backend', order: 31 }, en: { title: 'System Service Control', group: 'Backend', order: 31 }, diff --git a/pyproject.toml b/pyproject.toml index 152453d1..3cfe6384 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.69.0" +version = "0.70.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index a176aa3a..ef626c2d 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.69.0" +version = "0.70.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },