fix: keep chart runway history current

This commit is contained in:
2569718930@qq.com
2026-06-06 13:37:00 +08:00
parent 2a6c826f50
commit 2766b6463c
2 changed files with 138 additions and 0 deletions
+72
View File
@@ -853,6 +853,78 @@ def test_city_detail_batch_chart_scope_returns_only_chart_fields(monkeypatch):
assert "ai_analysis" not in detail assert "ai_analysis" not in detail
def test_chart_scope_overlays_collector_runway_history_from_db(monkeypatch):
monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(city_api.legacy_routes, "_normalize_city_or_404", lambda name: name.strip().lower())
monkeypatch.setattr(
city_api.legacy_routes,
"_city_cache_is_fresh",
lambda entry, ttl: True,
)
monkeypatch.setattr(
city_api.legacy_routes,
"_overlay_latest_wunderground_current",
lambda city, payload: payload,
)
class FakeCache:
def get_city_cache(self, kind, city):
assert kind == "full"
return {
"payload": {
"name": city,
"display_name": city.title(),
"local_date": "2026-06-06",
"local_time": "13:28",
"temp_symbol": "°C",
"risk": {"icao": "ZSPD"},
"current": {
"temp": 25.0,
"settlement_source": "metar",
"settlement_source_label": "METAR",
},
"hourly": {"times": ["13:00"], "temps": [25.0]},
"forecast": {"today_high": 26.0, "daily": []},
"multi_model": {},
"deb": {"prediction": 26.0},
"probabilities": {"mu": 26.0, "distribution": []},
"runway_plate_history": {
"35R/17L": [{"time": "2026-06-06T05:21:00+00:00", "temp": 24.2}]
},
}
}
def get_runway_obs_recent(self, icao, minutes=60):
assert icao == "ZSPD"
assert minutes == 36 * 60
return [
{
"runway": "35R/17L",
"tdz_temp": 24.2,
"mid_temp": None,
"end_temp": 24.0,
"target_runway_max": 24.2,
"otime_utc": "2026-06-06T05:21:00+00:00",
},
{
"runway": "35R/17L",
"tdz_temp": 24.8,
"mid_temp": None,
"end_temp": 24.6,
"target_runway_max": 24.8,
"otime_utc": "2026-06-06T05:28:00+00:00",
},
]
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache())
response = client.get("/api/cities/detail-batch?cities=Shanghai&resolution=1m&scope=chart")
assert response.status_code == 200
history = response.json()["details"]["shanghai"]["runway_plate_history"]["35R/17L"]
assert history[-1] == {"time": "2026-06-06T05:28:00+00:00", "temp": 24.8}
def test_chart_detail_payload_uses_threadpool_and_reuses_short_cache(monkeypatch): def test_chart_detail_payload_uses_threadpool_and_reuses_short_cache(monkeypatch):
import asyncio import asyncio
+66
View File
@@ -6,6 +6,7 @@ import os
import asyncio import asyncio
import threading import threading
import time import time
from copy import deepcopy
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
@@ -13,6 +14,7 @@ from fastapi.concurrency import run_in_threadpool
from loguru import logger from loguru import logger
import web.routes as legacy_routes import web.routes as legacy_routes
from web.analysis_service import _runway_history_temp_for_city
from web.services.request_timing import ServerTimingRecorder from web.services.request_timing import ServerTimingRecorder
_RECENT_DEB_CACHE: Optional[Dict[str, Dict[str, object]]] = None _RECENT_DEB_CACHE: Optional[Dict[str, Dict[str, object]]] = None
@@ -70,6 +72,65 @@ async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Di
) )
def _overlay_cached_runway_history_from_db(city: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict) or not payload:
return payload
normalized_city = str(city or payload.get("name") or payload.get("city") or "").strip().lower()
if not normalized_city:
return payload
risk = payload.get("risk") if isinstance(payload.get("risk"), dict) else {}
city_risk = legacy_routes.CITY_RISK_PROFILES.get(normalized_city, {}) or {}
city_meta = legacy_routes.CITY_REGISTRY.get(normalized_city, {}) or {}
icao = str(
risk.get("icao")
or city_risk.get("icao")
or city_meta.get("icao")
or ""
).strip().upper()
if not icao:
return payload
try:
rows = legacy_routes._CACHE_DB.get_runway_obs_recent(icao, minutes=36 * 60)
except Exception as exc:
logger.debug("chart runway DB overlay skipped city={} icao={}: {}", normalized_city, icao, exc)
return payload
if not rows:
return payload
use_fahrenheit = (
"F" in str(payload.get("temp_symbol") or "").upper()
or bool((legacy_routes.CITIES.get(normalized_city, {}) or {}).get("f"))
)
runway_history: Dict[str, List[Dict[str, Any]]] = {}
for row in rows:
if not isinstance(row, dict):
continue
runway = str(row.get("runway") or "").strip().upper()
time_val = row.get("otime_utc") or row.get("created_at")
if not runway or not time_val:
continue
temp_val = _runway_history_temp_for_city(normalized_city, row)
if temp_val is None:
continue
if use_fahrenheit:
temp_val = temp_val * 9.0 / 5.0 + 32.0
runway_history.setdefault(runway, []).append(
{
"time": str(time_val),
"temp": round(float(temp_val), 1),
}
)
if not runway_history:
return payload
next_payload = deepcopy(payload)
next_payload["runway_plate_history"] = runway_history
return next_payload
async def _refresh_city_full_cache_singleflight(city: str, force_refresh: bool) -> Dict[str, Any]: async def _refresh_city_full_cache_singleflight(city: str, force_refresh: bool) -> Dict[str, Any]:
key = f"{city}:{bool(force_refresh)}" key = f"{city}:{bool(force_refresh)}"
async with _CITY_FULL_REFRESH_LOCK: async with _CITY_FULL_REFRESH_LOCK:
@@ -165,6 +226,11 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A
if payload: if payload:
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC): if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC):
_start_city_full_stale_refresh(city) _start_city_full_stale_refresh(city)
payload = await run_in_threadpool(
_overlay_cached_runway_history_from_db,
city,
payload,
)
return await _overlay_cached_wunderground(city, payload) return await _overlay_cached_wunderground(city, payload)
return { return {