Use static terminal update announcements

This commit is contained in:
2569718930@qq.com
2026-06-07 00:32:11 +08:00
parent 3951735259
commit fb4a144fcf
10 changed files with 91 additions and 462 deletions
@@ -1,30 +0,0 @@
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",
});
}
}
@@ -4,22 +4,52 @@ import { Megaphone, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
type AnnouncementText = {
title?: string;
body?: string;
title: string;
body: string;
};
type UpdateAnnouncementPayload = {
enabled?: boolean;
zh?: AnnouncementText;
en?: AnnouncementText;
updated_at?: string;
type StaticUpdateAnnouncement = {
id: string;
publishedAt: string;
expiresAt: string;
zh: AnnouncementText;
en: AnnouncementText;
};
type UpdateAnnouncementButtonProps = {
isEn: boolean;
};
function pickAnnouncementText(payload: UpdateAnnouncementPayload, isEn: boolean) {
const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [
{
id: "feedback-status-2026-06",
publishedAt: "2026-06-07T00:00:00+08:00",
expiresAt: "2026-07-15T00:00:00+08:00",
zh: {
title: "更新公告:终端新增公告与反馈状态",
body:
"PolyWeather 天气决策台新增“更新公告”入口,后续产品更新、数据源调整和重要说明会在这里同步。\n\n" +
"用户反馈系统也已升级:提交反馈时会自动附带相关图表上下文,用户可以在终端右上角通知入口和账户页查看自己反馈的处理状态,包括已收到、已确认、处理中、已解决和已关闭。\n\n" +
"我们也在考虑对真实、可复现、有建设性价值的反馈加入积分或 Pro 天数激励。",
},
en: {
title: "Update: announcements and feedback status are now live",
body:
"PolyWeather Terminal now has an update announcement entry. Future product updates, data-source changes, and important notes will be shared here.\n\n" +
"The feedback system has also been upgraded. When users submit feedback, PolyWeather automatically attaches the relevant chart context. Users can now track their own feedback status from the terminal notification entry and the account page: received, confirmed, in progress, resolved, or closed.\n\n" +
"We are also considering small rewards such as points or Pro days for real, reproducible, and constructive feedback.",
},
},
];
function isActiveAnnouncement(item: StaticUpdateAnnouncement, now = Date.now()) {
const expiresAt = Date.parse(item.expiresAt);
if (!Number.isFinite(expiresAt) || expiresAt <= now) return false;
const publishedAt = Date.parse(item.publishedAt);
return !Number.isFinite(publishedAt) || publishedAt <= now;
}
function pickAnnouncementText(payload: StaticUpdateAnnouncement, isEn: boolean) {
const primary = isEn ? payload.en : payload.zh;
const fallback = isEn ? payload.zh : payload.en;
return {
@@ -42,30 +72,9 @@ function formatUpdatedAt(value: string | undefined, isEn: boolean) {
}
export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps) {
const [announcement, setAnnouncement] = useState<UpdateAnnouncementPayload | null>(null);
const [open, setOpen] = useState(false);
const shellRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
async function loadAnnouncement() {
try {
const res = await fetch("/api/system/update-announcement", { cache: "no-store" });
if (!res.ok) return;
const data = (await res.json()) as UpdateAnnouncementPayload;
if (!cancelled) {
setAnnouncement(data?.enabled ? data : null);
}
} catch {
if (!cancelled) setAnnouncement(null);
}
}
void loadAnnouncement();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
@@ -77,13 +86,17 @@ export function UpdateAnnouncementButton({ isEn }: UpdateAnnouncementButtonProps
return () => window.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
const announcement = useMemo(
() => STATIC_UPDATE_ANNOUNCEMENTS.find((item) => isActiveAnnouncement(item)) ?? null,
[],
);
const text = useMemo(
() => (announcement ? pickAnnouncementText(announcement, isEn) : { title: "", body: "" }),
[announcement, isEn],
);
const updatedAt = useMemo(
() => formatUpdatedAt(announcement?.updated_at, isEn),
[announcement?.updated_at, isEn],
() => formatUpdatedAt(announcement?.publishedAt, isEn),
[announcement?.publishedAt, isEn],
);
if (!announcement || (!text.title && !text.body)) return null;
@@ -38,10 +38,9 @@ export function runTests() {
const middlewareSource = fs.readFileSync(path.join(projectRoot, "middleware.ts"), "utf8");
assert(fs.existsSync(componentPath), "terminal must have a compact update announcement component");
assert(fs.existsSync(nextRoutePath), "frontend must proxy the public update announcement API");
assert(!fs.existsSync(nextRoutePath), "update announcements should not depend on an admin-managed API proxy");
const componentSource = fs.readFileSync(componentPath, "utf8");
const routeSource = fs.readFileSync(nextRoutePath, "utf8");
assert(
dashboardSource.includes("UpdateAnnouncementButton") &&
@@ -50,43 +49,41 @@ export function runTests() {
"terminal header must render a bilingual update announcement entry beside the dashboard title",
);
assert(
componentSource.includes("/api/system/update-announcement") &&
componentSource.includes("STATIC_UPDATE_ANNOUNCEMENTS") &&
componentSource.includes("expiresAt") &&
componentSource.includes("Date.now()") &&
componentSource.includes("Megaphone") &&
componentSource.includes("zh") &&
componentSource.includes("en") &&
!componentSource.includes("fetch(") &&
!componentSource.includes("/api/system/update-announcement") &&
!componentSource.includes("setInterval("),
"announcement component must fetch the public announcement once, support zh/en content, and avoid aggressive polling",
"announcement component must use hardcoded zh/en release notes with an expiry time and no backend polling",
);
assert(
routeSource.includes(`${"api/system/update-announcement"}`) &&
routeSource.includes("cache: \"no-store\""),
"Next.js announcement proxy must call the backend public endpoint without caching stale admin content",
!middlewareSource.includes("/api/system/update-announcement"),
"middleware should not keep a public announcement API entry after announcements move into frontend code",
);
assert(
middlewareSource.includes('pathname === "/api/system/update-announcement"'),
"update announcement proxy must stay public because it only returns non-sensitive release notes",
!opsConfigSource.includes("公告类配置") &&
!opsConfigSource.includes("multiline") &&
!opsConfigSource.includes("<textarea"),
"ops config page should not expose update announcement editing controls",
);
assert(
opsConfigSource.includes("multiline") &&
opsConfigSource.includes("<textarea") &&
opsConfigSource.includes("DB 持久化"),
"ops config page must support persistent multiline announcement fields",
!opsApiSource.includes("POLYWEATHER_UPDATE_ANNOUNCEMENT") &&
!opsApiSource.includes("_RUNTIME_CONFIG_KEYS"),
"ops API must not expose editable update announcement keys",
);
assert(
opsApiSource.includes("POLYWEATHER_UPDATE_ANNOUNCEMENT_TITLE_ZH") &&
opsApiSource.includes("POLYWEATHER_UPDATE_ANNOUNCEMENT_BODY_EN") &&
opsApiSource.includes("_RUNTIME_CONFIG_KEYS"),
"ops API must expose editable zh/en announcement keys through the runtime config store",
!systemApiSource.includes("get_public_update_announcement") &&
!systemRouterSource.includes("/api/system/update-announcement"),
"backend must not expose a runtime update announcement endpoint",
);
assert(
systemApiSource.includes("get_public_update_announcement") &&
systemRouterSource.includes("/api/system/update-announcement"),
"backend must expose a public read-only update announcement endpoint",
);
assert(
dbSource.includes("CREATE TABLE IF NOT EXISTS runtime_config") &&
dbSource.includes("set_runtime_config") &&
dbSource.includes("get_runtime_config_value"),
"database manager must persist non-sensitive runtime config independently from runtime secrets",
!dbSource.includes("CREATE TABLE IF NOT EXISTS runtime_config") &&
!dbSource.includes("set_runtime_config") &&
!dbSource.includes("get_runtime_config_value"),
"database manager should not keep a runtime_config table only for update announcements",
);
}
@@ -9,11 +9,6 @@ type EditableConfig = {
key: string;
value: string;
description: string;
multiline?: boolean;
persistent?: boolean;
source?: string;
updated_at?: string;
updated_by?: string;
};
type SensitiveConfig = {
@@ -78,13 +73,8 @@ export function ConfigPageClient() {
body: JSON.stringify({ key, value: newVal }),
});
if (res.ok) {
const data = (await res.json().catch(() => null)) as Partial<EditableConfig> | null;
setResult(`${key} 已更新`);
setConfigs((prev) => prev.map((c) => (
c.key === key
? { ...c, ...(data ?? {}), value: String(data?.value ?? newVal) }
: c
)));
setConfigs((prev) => prev.map((c) => (c.key === key ? { ...c, value: newVal } : c)));
setEditing((prev) => { const n = { ...prev }; delete n[key]; return n; });
} else {
setResult(`保存失败: ${await res.text().catch(() => "")}`);
@@ -149,89 +139,28 @@ export function ConfigPageClient() {
<p className="text-slate-500 text-sm"> API </p>
) : (
<div className="space-y-3">
{configs.map((cfg) => {
const currentValue = editing[cfg.key] ?? cfg.value;
const dirty = editing[cfg.key] != null && editing[cfg.key] !== cfg.value;
const persistent = Boolean(cfg.persistent || cfg.source === "runtime_config");
const sourceLabel = persistent
? "DB 持久化"
: cfg.source === "environment"
? "环境变量"
: cfg.source || "当前进程";
if (persistent) {
return (
<div key={cfg.key} className="rounded-lg border border-white/5 bg-white/5 px-4 py-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-white text-sm font-medium">{cfg.key}</div>
<span className="rounded-full bg-cyan-400/10 px-2 py-0.5 text-[11px] text-cyan-300">
{sourceLabel}
</span>
</div>
<div className="text-slate-500 text-xs mt-1">{cfg.description}</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
<span> <span className="font-mono text-slate-300">{cfg.updated_by || "-"}</span></span>
<span> <span className="font-mono text-slate-300">{cfg.updated_at || "-"}</span></span>
</div>
</div>
<div className="flex w-full flex-col gap-2 xl:w-[560px]">
{cfg.multiline ? (
<textarea
value={currentValue}
rows={4}
spellCheck={false}
onChange={(e) => setEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
className="min-h-24 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm leading-5 text-white outline-none focus:border-cyan-400/50"
/>
) : (
<input
value={currentValue}
onChange={(e) => setEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white font-mono outline-none focus:border-cyan-400/50"
/>
)}
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={saving || !dirty}
onClick={() => handleSave(cfg.key)}
className="gap-1"
>
<Save className="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div key={cfg.key} className="flex items-center gap-3 rounded-lg border border-white/5 bg-white/5 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-medium">{cfg.key}</div>
<div className="text-slate-500 text-xs mt-0.5">{cfg.description}</div>
</div>
<input
value={currentValue}
onChange={(e) => setEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-24 rounded-lg border border-white/10 bg-black/30 px-3 py-1.5 text-sm text-white font-mono text-center outline-none focus:border-cyan-400/50"
/>
<Button
size="sm"
variant="outline"
disabled={saving || !dirty}
onClick={() => handleSave(cfg.key)}
className="gap-1"
>
<Save className="h-3 w-3" />
</Button>
{configs.map((cfg) => (
<div key={cfg.key} className="flex items-center gap-3 rounded-lg border border-white/5 bg-white/5 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-medium">{cfg.key}</div>
<div className="text-slate-500 text-xs mt-0.5">{cfg.description}</div>
</div>
);
})}
<input
value={editing[cfg.key] ?? cfg.value}
onChange={(e) => setEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-24 rounded-lg border border-white/10 bg-black/30 px-3 py-1.5 text-sm text-white font-mono text-center outline-none focus:border-cyan-400/50"
/>
<Button
size="sm"
variant="outline"
disabled={saving || editing[cfg.key] === cfg.value || editing[cfg.key] == null}
onClick={() => handleSave(cfg.key)}
className="gap-1"
>
<Save className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{result && (
@@ -240,7 +169,7 @@ export function ConfigPageClient() {
</p>
)}
<p className="mt-4 text-xs text-slate-500">
DB
使
</p>
</CardContent>
</Card>
-1
View File
@@ -37,7 +37,6 @@ 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) ||
-95
View File
@@ -437,14 +437,6 @@ 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,
@@ -876,93 +868,6 @@ 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 "")
-63
View File
@@ -1,63 +0,0 @@
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"]
-6
View File
@@ -8,7 +8,6 @@ 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,
@@ -28,11 +27,6 @@ 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)
+1 -64
View File
@@ -1206,24 +1206,6 @@ _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]] = {
@@ -1234,78 +1216,33 @@ _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)
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]:
admin = _require_ops(request) or {}
_require_ops(request)
normalized_key = str(key or "").strip()
if normalized_key not in _EDITABLE_CONFIG_KEYS:
raise HTTPException(
status_code=400, detail=f"config key '{normalized_key}' is not editable"
)
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,
}
-52
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import os
import time
from typing import Any, Dict, Optional
@@ -11,66 +10,15 @@ 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)