(null);
+ const [sensitiveCheckedAt, setSensitiveCheckedAt] = useState("");
const load = async () => {
try {
@@ -90,6 +91,7 @@ export function ConfigPageClient() {
setSensitiveSaving(true);
setSensitiveResult("");
setSensitiveHealth(null);
+ setSensitiveCheckedAt("");
try {
const res = await fetch("/api/ops/sensitive-config", {
method: "PUT",
@@ -106,6 +108,7 @@ export function ConfigPageClient() {
}
setSensitiveEditing((prev) => { const n = { ...prev }; delete n[key]; return n; });
setSensitiveHealth(data.health ?? null);
+ setSensitiveCheckedAt(new Date().toLocaleString("zh-CN", { hour12: false }));
setSensitiveResult(`${key} 已轮换`);
} else {
setSensitiveResult(`轮换失败: ${await res.text().catch(() => "")}`);
@@ -214,8 +217,11 @@ export function ConfigPageClient() {
setSensitiveEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
@@ -232,6 +238,9 @@ export function ConfigPageClient() {
+
+ 在 ops 页面粘贴真实的 $$ sessionId 即可;这里不是 Docker .env,不需要把 $$ 写成 $$$$。
+
))}
@@ -244,6 +253,7 @@ export function ConfigPageClient() {
{sensitiveHealth && (
AMSC 健康检查:{sensitiveHealth.ok ? "通过" : "失败"}
+ {sensitiveCheckedAt ? ` · 最近检查 ${sensitiveCheckedAt}` : ""}
{typeof sensitiveHealth.points === "number" ? ` · 跑道点 ${sensitiveHealth.points}` : ""}
{sensitiveHealth.observation_time_local ? ` · 观测 ${sensitiveHealth.observation_time_local}` : ""}
{sensitiveHealth.error ? ` · ${sensitiveHealth.error}` : ""}
diff --git a/tests/test_deployment_runtime_config.py b/tests/test_deployment_runtime_config.py
index beb646d8..f239e015 100644
--- a/tests/test_deployment_runtime_config.py
+++ b/tests/test_deployment_runtime_config.py
@@ -74,7 +74,8 @@ def test_deploy_script_retries_startup_smoke_checks():
assert "smoke_check()" in script
assert 'smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5' in script
- assert 'smoke_check "local cities" "http://127.0.0.1:8000/api/cities" 10 6 3' in script
+ assert 'warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1"' in script
+ assert 'smoke_check "local cities" "http://127.0.0.1:8000/api/cities" 10 6 3' not in script
assert 'smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5' in script
assert 'smoke_check "frontend" "https://www.polyweather.top/" 15 3 5' in script
diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py
index b4a59b9c..ad0efd31 100644
--- a/tests/test_web_observability.py
+++ b/tests/test_web_observability.py
@@ -11,6 +11,7 @@ import web.routes as routes
import web.services.ops_api as ops_api
import web.scan_terminal_cache as scan_terminal_cache
import web.scan_terminal_service as scan_terminal_service
+import web.services.city_api as city_api
import web.services.city_runtime as city_runtime
from web.scan_terminal_cache import scan_terminal_cache_key
from src.database.runtime_state import TruthRecordRepository
@@ -132,6 +133,30 @@ def test_cities_endpoint_includes_new_wunderground_cities():
}.issubset(names)
+def test_cities_endpoint_does_not_block_on_recent_deb_index(monkeypatch):
+ monkeypatch.setattr(city_api, "_RECENT_DEB_CACHE", None, raising=False)
+ monkeypatch.setattr(city_api, "_RECENT_DEB_CACHE_TS", 0.0, raising=False)
+ monkeypatch.setattr(city_api, "_RECENT_DEB_REFRESHING", False, raising=False)
+ monkeypatch.setattr(city_api, "_get_recent_deb_cache", lambda: None, raising=False)
+ monkeypatch.setattr(city_api, "_start_recent_deb_refresh", lambda: None, raising=False)
+
+ def fail_recent_index():
+ raise AssertionError("recent DEB stats must not run in the default city-list request")
+
+ monkeypatch.setattr(
+ city_api.legacy_routes,
+ "_build_recent_deb_performance_index",
+ fail_recent_index,
+ )
+
+ response = client.get("/api/cities")
+
+ assert response.status_code == 200
+ denver = next(item for item in response.json()["cities"] if item["name"] == "denver")
+ assert denver["deb_recent_tier"] == "other"
+ assert denver["deb_recent_sample_count"] == 0
+
+
def test_payment_runtime_endpoint_returns_shape():
response = client.get('/api/payments/runtime')
assert response.status_code == 200
diff --git a/web/services/city_api.py b/web/services/city_api.py
index 1ea469a3..7a3e8c3b 100644
--- a/web/services/city_api.py
+++ b/web/services/city_api.py
@@ -2,6 +2,9 @@
from __future__ import annotations
+import os
+import threading
+import time
from typing import Any, Dict, Optional
from fastapi import HTTPException, Request
@@ -10,6 +13,15 @@ from loguru import logger
import web.routes as legacy_routes
+_RECENT_DEB_CACHE: Optional[Dict[str, Dict[str, object]]] = None
+_RECENT_DEB_CACHE_TS = 0.0
+_RECENT_DEB_REFRESHING = False
+_RECENT_DEB_LOCK = threading.Lock()
+_RECENT_DEB_CACHE_TTL_SEC = max(
+ 60,
+ int(os.getenv("POLYWEATHER_CITIES_DEB_RECENT_CACHE_TTL_SEC", "300") or "300"),
+)
+
async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Dict[str, Any]:
return await run_in_threadpool(
@@ -19,13 +31,69 @@ async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Di
)
-def _build_cities_payload() -> Dict[str, Any]:
+def _default_deb_recent() -> Dict[str, object]:
+ return {
+ "tier": "other",
+ "hit_rate": None,
+ "sample_count": 0,
+ "mae": None,
+ "last_date": None,
+ }
+
+
+def _refresh_recent_deb_cache() -> Dict[str, Dict[str, object]]:
+ global _RECENT_DEB_CACHE, _RECENT_DEB_CACHE_TS, _RECENT_DEB_REFRESHING
+
+ try:
+ index = legacy_routes._build_recent_deb_performance_index()
+ with _RECENT_DEB_LOCK:
+ _RECENT_DEB_CACHE = index
+ _RECENT_DEB_CACHE_TS = time.time()
+ return index
+ except Exception as exc:
+ logger.warning(f"Recent DEB performance cache refresh failed: {exc}")
+ with _RECENT_DEB_LOCK:
+ return _RECENT_DEB_CACHE or {}
+ finally:
+ with _RECENT_DEB_LOCK:
+ _RECENT_DEB_REFRESHING = False
+
+
+def _get_recent_deb_cache() -> Optional[Dict[str, Dict[str, object]]]:
+ with _RECENT_DEB_LOCK:
+ if (
+ _RECENT_DEB_CACHE is not None
+ and time.time() - _RECENT_DEB_CACHE_TS < _RECENT_DEB_CACHE_TTL_SEC
+ ):
+ return _RECENT_DEB_CACHE
+ return None
+
+
+def _start_recent_deb_refresh() -> None:
+ global _RECENT_DEB_REFRESHING
+
+ with _RECENT_DEB_LOCK:
+ if _RECENT_DEB_REFRESHING:
+ return
+ _RECENT_DEB_REFRESHING = True
+
+ thread = threading.Thread(
+ target=_refresh_recent_deb_cache,
+ name="cities-recent-deb-refresh",
+ daemon=True,
+ )
+ thread.start()
+
+
+def _build_cities_payload(
+ deb_recent_index: Optional[Dict[str, Dict[str, object]]] = None,
+) -> Dict[str, Any]:
out = []
- deb_recent_index = legacy_routes._build_recent_deb_performance_index()
+ deb_recent_index = deb_recent_index or {}
for name, info in legacy_routes.CITIES.items():
risk = legacy_routes.CITY_RISK_PROFILES.get(name, {})
city_meta = legacy_routes.CITY_REGISTRY.get(name, {}) or {}
- deb_recent = deb_recent_index.get(name, {})
+ deb_recent = deb_recent_index.get(name, _default_deb_recent())
settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar"
provider = legacy_routes.get_country_network_provider(name)
out.append(
@@ -60,9 +128,19 @@ def _build_cities_payload() -> Dict[str, Any]:
return {"cities": out}
-async def list_cities_payload(_request: Request) -> Dict[str, Any]:
+async def list_cities_payload(request: Request) -> Dict[str, Any]:
try:
- return await run_in_threadpool(_build_cities_payload)
+ refresh_recent = str(
+ request.query_params.get("refresh_deb_recent") or "",
+ ).strip().lower() in {"1", "true", "yes"}
+ if refresh_recent:
+ deb_recent_index = await run_in_threadpool(_refresh_recent_deb_cache)
+ else:
+ deb_recent_index = _get_recent_deb_cache()
+ if deb_recent_index is None:
+ _start_recent_deb_refresh()
+ deb_recent_index = {}
+ return await run_in_threadpool(_build_cities_payload, deb_recent_index)
except Exception as exc:
logger.error(f"Error in list_cities: {exc}")
raise HTTPException(status_code=500, detail=str(exc)) from exc