Use static terminal update announcements
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user