diff --git a/frontend/app/api/system/update-announcement/route.ts b/frontend/app/api/system/update-announcement/route.ts
new file mode 100644
index 00000000..71d64015
--- /dev/null
+++ b/frontend/app/api/system/update-announcement/route.ts
@@ -0,0 +1,30 @@
+import { NextResponse } from "next/server";
+import { buildProxyExceptionResponse } from "@/lib/api-proxy";
+
+const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
+const BACKEND = API_BASE ? `${API_BASE}/api/system/update-announcement` : "";
+
+export async function GET() {
+ if (!API_BASE) {
+ return NextResponse.json(
+ { error: "POLYWEATHER_API_BASE_URL is not configured" },
+ { status: 500 },
+ );
+ }
+
+ try {
+ const res = await fetch(BACKEND, { cache: "no-store" });
+ const raw = await res.text();
+ return new NextResponse(raw, {
+ status: res.status,
+ headers: {
+ "Cache-Control": "no-store",
+ "Content-Type": "application/json",
+ },
+ });
+ } catch (error) {
+ return buildProxyExceptionResponse(error, {
+ publicMessage: "Failed to fetch update announcement",
+ });
+ }
+}
diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index 89babeca..c3ee89f7 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -61,6 +61,7 @@ import {
type FeedbackDraft,
} from "@/components/dashboard/scan-terminal/UserFeedbackModal";
import { UserFeedbackStatusButton } from "@/components/dashboard/scan-terminal/UserFeedbackStatusButton";
+import { UpdateAnnouncementButton } from "@/components/dashboard/scan-terminal/UpdateAnnouncementButton";
import {
mergeAccessStateWithAuthPayload,
type AuthProfilePayload,
@@ -951,6 +952,9 @@ function PolyWeatherTerminal({
+ {text.body} +
+ )} +配置 API 尚未就绪(需要后端支持)
) : (- 仅显示非敏感配置项。修改后立即影响当前后端进程;需要跨重启持久化的密钥请使用下方凭证轮换模块。 + 仅显示非敏感配置项。公告类配置写入 DB 持久化;其余短配置仍只影响当前后端进程。
diff --git a/frontend/middleware.ts b/frontend/middleware.ts index b125a592..3a7d0215 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -37,6 +37,7 @@ function isPublicApi(pathname: string) { pathname === "/api/payments/config" || pathname === "/api/scan/terminal" || pathname === "/api/system/status" || + pathname === "/api/system/update-announcement" || pathname === "/api/vitals" || /^\/api\/city\/[^/]+$/i.test(pathname) || /^\/api\/city\/[^/]+\/summary$/i.test(pathname) || diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 7bba6bfb..2bbf752c 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -437,6 +437,14 @@ class DBManager: updated_by TEXT ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS runtime_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT + ) + """) conn.execute(""" CREATE TABLE IF NOT EXISTS city_summary_cache ( city TEXT PRIMARY KEY, @@ -868,6 +876,93 @@ class DBManager: ) conn.commit() + def get_runtime_config_value(self, key: str, default: Optional[str] = None) -> Optional[str]: + normalized_key = str(key or "").strip() + if not normalized_key: + return default + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + """ + SELECT value + FROM runtime_config + WHERE key = ? + LIMIT 1 + """, + (normalized_key,), + ).fetchone() + if not row: + return default + return str(row["value"] or "") + + def get_runtime_config_metadata(self, key: str) -> Dict[str, Any]: + normalized_key = str(key or "").strip() + if not normalized_key: + return { + "key": "", + "configured": False, + "value": "", + "updated_at": "", + "updated_by": "", + "source": "runtime_config", + } + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + """ + SELECT key, value, updated_at, updated_by + FROM runtime_config + WHERE key = ? + LIMIT 1 + """, + (normalized_key,), + ).fetchone() + if not row: + return { + "key": normalized_key, + "configured": False, + "value": "", + "updated_at": "", + "updated_by": "", + "source": "runtime_config", + } + return { + "key": normalized_key, + "configured": True, + "value": str(row["value"] or ""), + "updated_at": str(row["updated_at"] or ""), + "updated_by": str(row["updated_by"] or ""), + "source": "runtime_config", + } + + def set_runtime_config( + self, + key: str, + value: str, + *, + updated_by: Optional[str] = None, + ) -> Dict[str, Any]: + normalized_key = str(key or "").strip() + if not normalized_key: + raise ValueError("runtime config key is required") + config_value = str(value or "").strip() + now = datetime.now().isoformat() + operator = str(updated_by or "").strip() + with self._get_connection() as conn: + conn.execute( + """ + INSERT INTO runtime_config (key, value, updated_at, updated_by) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by + """, + (normalized_key, config_value, now, operator), + ) + conn.commit() + return self.get_runtime_config_metadata(normalized_key) + @staticmethod def _mask_secret_value(value: str) -> str: text = str(value or "") diff --git a/tests/test_update_announcement.py b/tests/test_update_announcement.py new file mode 100644 index 00000000..2368b779 --- /dev/null +++ b/tests/test_update_announcement.py @@ -0,0 +1,63 @@ +from src.database.db_manager import DBManager + + +def test_runtime_config_round_trip(tmp_path): + db = DBManager(str(tmp_path / "polyweather.db")) + + meta = db.set_runtime_config( + "POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_ZH", + "观测采集更新", + updated_by="ops@example.com", + ) + + assert meta["key"] == "POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_ZH" + assert meta["value"] == "观测采集更新" + assert meta["updated_by"] == "ops@example.com" + assert meta["source"] == "runtime_config" + assert db.get_runtime_config_value("POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_ZH") == "观测采集更新" + + +def test_ops_update_announcement_uses_runtime_config_store(monkeypatch, tmp_path): + from web.services import ops_api + + db = DBManager(str(tmp_path / "polyweather.db")) + monkeypatch.setattr(ops_api, "DBManager", lambda: db) + monkeypatch.setattr(ops_api, "_require_ops", lambda request: {"email": "admin@polyweather.top"}) + + result = ops_api.update_ops_config( + object(), + "POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_EN", + "Runway observation collector now writes patches independently.", + ) + + assert result["ok"] is True + assert result["source"] == "runtime_config" + assert result["updated_by"] == "admin@polyweather.top" + assert ( + db.get_runtime_config_value("POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_EN") + == "Runway observation collector now writes patches independently." + ) + + +def test_public_update_announcement_returns_enabled_bilingual_payload(monkeypatch, tmp_path): + from web.services import system_api + + db = DBManager(str(tmp_path / "polyweather.db")) + monkeypatch.setattr(system_api, "DBManager", lambda: db) + db.set_runtime_config("POLYWEATHER_UPDATE_ANNOUNCEMENT_ENABLED", "true") + db.set_runtime_config("POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_ZH", "数据更新公告") + db.set_runtime_config("POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_ZH", "AMSC 观测采集已独立运行。") + db.set_runtime_config("POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_EN", "Data update") + db.set_runtime_config( + "POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_EN", + "The AMSC observation collector now runs independently.", + ) + + payload = system_api.get_public_update_announcement() + + assert payload["enabled"] is True + assert payload["zh"]["title"] == "数据更新公告" + assert payload["zh"]["body"] == "AMSC 观测采集已独立运行。" + assert payload["en"]["title"] == "Data update" + assert payload["en"]["body"] == "The AMSC observation collector now runs independently." + assert payload["updated_at"] diff --git a/web/routers/system.py b/web/routers/system.py index 3391d2a7..f3344390 100644 --- a/web/routers/system.py +++ b/web/routers/system.py @@ -8,6 +8,7 @@ from fastapi.responses import PlainTextResponse from web.services.dashboard_init_api import build_dashboard_init_payload from web.services.system_api import ( get_health_payload, + get_public_update_announcement, get_prometheus_metrics_response, get_system_cache_status, get_system_status_payload, @@ -27,6 +28,11 @@ async def system_status(): return await get_system_status_payload() +@router.get("/api/system/update-announcement") +async def system_update_announcement(): + return get_public_update_announcement() + + @router.get("/api/system/cache-status") async def system_cache_status(request: Request, cities: Optional[str] = None): return get_system_cache_status(request, cities=cities) diff --git a/web/services/ops_api.py b/web/services/ops_api.py index 1aa5ab5c..fa0cba11 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -1206,6 +1206,24 @@ _EDITABLE_CONFIG_KEYS: dict[str, str] = { "POLYWEATHER_PAYMENT_POINTS_PER_USDC": "积分兑换汇率 (积分/USDC)", "POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC": "积分最高抵扣金额 (USDC)", "POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS": "手动转账收款钱包地址", + "POLYWEATHER_UPDATE_ANNOUNCEMENT_ENABLED": "终端顶部更新公告开关,true/false", + "POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_ZH": "更新公告中文标题", + "POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_ZH": "更新公告中文正文", + "POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_EN": "Update announcement English title", + "POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_EN": "Update announcement English body", +} + +_RUNTIME_CONFIG_KEYS = { + "POLYWEATHER_UPDATE_ANNOUNCEMENT_ENABLED", + "POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_ZH", + "POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_ZH", + "POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_EN", + "POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_EN", +} + +_MULTILINE_CONFIG_KEYS = { + "POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_ZH", + "POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_EN", } _SENSITIVE_CONFIG_KEYS: dict[str, dict[str, str]] = { @@ -1216,32 +1234,80 @@ _SENSITIVE_CONFIG_KEYS: dict[str, dict[str, str]] = { } +def _runtime_config_payload(db: DBManager, key: str, description: str) -> dict[str, Any]: + metadata = db.get_runtime_config_metadata(key) + env_value = os.getenv(key) + has_runtime_value = bool(metadata.get("configured")) + return { + "key": key, + "value": str(metadata.get("value") if has_runtime_value else (env_value or "")), + "description": description, + "multiline": key in _MULTILINE_CONFIG_KEYS, + "persistent": True, + "updated_at": str(metadata.get("updated_at") or ""), + "updated_by": str(metadata.get("updated_by") or ""), + "source": "runtime_config" if has_runtime_value or env_value is None else "environment", + } + + def get_ops_config(request: Request) -> dict[str, Any]: _require_ops(request) - import os - configs: list[dict[str, str]] = [] + db: DBManager | None = None + configs: list[dict[str, Any]] = [] for key, desc in _EDITABLE_CONFIG_KEYS.items(): + if key in _RUNTIME_CONFIG_KEYS: + if db is None: + db = DBManager() + configs.append(_runtime_config_payload(db, key, desc)) + continue configs.append( { "key": key, "value": os.getenv(key) or "", "description": desc, + "multiline": False, + "persistent": False, + "source": "environment", } ) return {"configs": configs} def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]: - _require_ops(request) - import os + admin = _require_ops(request) or {} - if key not in _EDITABLE_CONFIG_KEYS: + normalized_key = str(key or "").strip() + if normalized_key not in _EDITABLE_CONFIG_KEYS: raise HTTPException( - status_code=400, detail=f"config key '{key}' is not editable" + status_code=400, detail=f"config key '{normalized_key}' is not editable" ) - os.environ[key] = str(value) - return {"key": key, "value": value, "ok": True} + if normalized_key in _RUNTIME_CONFIG_KEYS: + try: + config = DBManager().set_runtime_config( + normalized_key, + str(value or ""), + updated_by=str(admin.get("email") or ""), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + **config, + "description": _EDITABLE_CONFIG_KEYS[normalized_key], + "multiline": normalized_key in _MULTILINE_CONFIG_KEYS, + "persistent": True, + "ok": True, + } + os.environ[normalized_key] = str(value) + return { + "key": normalized_key, + "value": value, + "description": _EDITABLE_CONFIG_KEYS[normalized_key], + "multiline": False, + "persistent": False, + "source": "environment", + "ok": True, + } def _sensitive_config_payload(key: str) -> dict[str, Any]: diff --git a/web/services/system_api.py b/web/services/system_api.py index 318b0328..214e7233 100644 --- a/web/services/system_api.py +++ b/web/services/system_api.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import time from typing import Any, Dict, Optional @@ -10,15 +11,66 @@ from fastapi.concurrency import run_in_threadpool from fastapi.responses import PlainTextResponse from loguru import logger +from src.database.db_manager import DBManager from src.utils.metrics import export_prometheus_metrics from web.core import build_health_payload, build_system_status_payload import web.routes as legacy_routes +_ANNOUNCEMENT_ENABLED_KEY = "POLYWEATHER_UPDATE_ANNOUNCEMENT_ENABLED" +_ANNOUNCEMENT_TEXT_KEYS = { + "zh_title": "POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_ZH", + "zh_body": "POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_ZH", + "en_title": "POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_EN", + "en_body": "POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_EN", +} + def get_health_payload() -> Dict[str, Any]: return build_health_payload() +def _runtime_or_env_value(db: DBManager, key: str) -> tuple[str, str]: + metadata = db.get_runtime_config_metadata(key) + if metadata.get("configured"): + return str(metadata.get("value") or ""), str(metadata.get("updated_at") or "") + return str(os.getenv(key) or ""), "" + + +def _truthy_runtime_flag(value: str) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def get_public_update_announcement() -> Dict[str, Any]: + db = DBManager() + enabled_value, enabled_updated_at = _runtime_or_env_value(db, _ANNOUNCEMENT_ENABLED_KEY) + values: dict[str, str] = {} + updated_at_candidates = [enabled_updated_at] + for name, key in _ANNOUNCEMENT_TEXT_KEYS.items(): + value, updated_at = _runtime_or_env_value(db, key) + values[name] = value + if updated_at: + updated_at_candidates.append(updated_at) + + has_content = any( + values.get(name, "").strip() + for name in ("zh_title", "zh_body", "en_title", "en_body") + ) + enabled = _truthy_runtime_flag(enabled_value) and has_content + updated_at = max((item for item in updated_at_candidates if item), default="") + return { + "enabled": enabled, + "zh": { + "title": values.get("zh_title", ""), + "body": values.get("zh_body", ""), + }, + "en": { + "title": values.get("en_title", ""), + "body": values.get("en_body", ""), + }, + "updated_at": updated_at, + } + + async def get_system_status_payload() -> Dict[str, Any]: payload = await run_in_threadpool(build_system_status_payload) payload["realtime"] = await run_in_threadpool(_realtime_status_payload)