From e00405df8a5feb06c7da76aea7fddb4cb1dbcbe6 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 30 May 2026 20:10:14 +0800 Subject: [PATCH] Tighten AMSC ops health check --- .../ops/health/HealthPageClient.tsx | 20 +++- tests/test_ops_amsc_health.py | 66 +++++++++++ web/services/ops_api.py | 106 +++++++++++++----- 3 files changed, 165 insertions(+), 27 deletions(-) create mode 100644 tests/test_ops_amsc_health.py diff --git a/frontend/components/ops/health/HealthPageClient.tsx b/frontend/components/ops/health/HealthPageClient.tsx index 5e4008e7..caff731c 100644 --- a/frontend/components/ops/health/HealthPageClient.tsx +++ b/frontend/components/ops/health/HealthPageClient.tsx @@ -10,7 +10,16 @@ import { ResponsiveContainer, Cell, } from "recharts"; -type ServiceResult = { ok: boolean; status?: number; latency_ms?: number; error?: string }; +type ServiceResult = { + ok: boolean; + status?: number; + latency_ms?: number; + error?: string; + credential_configured?: boolean; + points?: number; + observation_time_local?: string; + sample_city?: string; +}; type HealthPayload = { ok: boolean; checked_at: string; services: Record }; const LABELS: Record = { @@ -135,6 +144,15 @@ export function HealthPageClient() { {svc.status ? HTTP {svc.status} : null} + {(svc.credential_configured != null || svc.points != null || svc.observation_time_local) && ( +
+ {svc.credential_configured != null && ( + {svc.credential_configured ? "凭证已配置" : "凭证未配置"} + )} + {svc.points != null && 跑道点 {svc.points}} + {svc.observation_time_local && {svc.observation_time_local}} +
+ )} ))} diff --git a/tests/test_ops_amsc_health.py b/tests/test_ops_amsc_health.py new file mode 100644 index 00000000..03f66257 --- /dev/null +++ b/tests/test_ops_amsc_health.py @@ -0,0 +1,66 @@ +from web.services import ops_api + + +def test_ops_amsc_health_uses_configured_session_header(monkeypatch): + captured = {} + + payload = { + "code": 200, + "data": { + "35R/17L": { + "RNO": "35R/17L", + "OTIME": "2026-05-30 20:03:00", + "TDZ_TEMP": "23.6", + "MID_TEMP": "-", + "END_TEMP": "23.4", + } + }, + } + + class FakeResponse: + ok = True + status_code = 200 + content = b"{}" + + def json(self): + return payload + + def fake_get(url, **kwargs): + captured["url"] = url + captured["headers"] = kwargs.get("headers") or {} + return FakeResponse() + + session_id = "9153$$example-session" + monkeypatch.setenv("AMSC_AWOS_BASE_URL", "https://example.test/getWindPlate") + monkeypatch.setenv("POLYWEATHER_AMSC_SESSION_ID", session_id) + monkeypatch.delenv("POLYWEATHER_AMSC_COOKIE", raising=False) + monkeypatch.setattr(ops_api._requests, "get", fake_get) + + result = ops_api._check_amsc_awos_health(timeout=1) + + assert result["ok"] is True + assert result["credential_configured"] is True + assert result["points"] == 1 + assert captured["url"].endswith("?cccc=ZSPD") + assert captured["headers"]["sessionId"] == session_id + assert captured["headers"]["app"] == "AMS" + + +def test_ops_amsc_health_rejects_empty_success_response(monkeypatch): + class FakeResponse: + ok = True + status_code = 200 + content = b"{}" + + def json(self): + return {"code": 200, "data": {}} + + monkeypatch.setenv("AMSC_AWOS_BASE_URL", "https://example.test/getWindPlate") + monkeypatch.setenv("POLYWEATHER_AMSC_SESSION_ID", "session") + monkeypatch.setattr(ops_api._requests, "get", lambda *args, **kwargs: FakeResponse()) + + result = ops_api._check_amsc_awos_health(timeout=1) + + assert result["ok"] is False + assert result["status"] == 200 + assert result["error"] == "empty_or_unauthorized_response" diff --git a/web/services/ops_api.py b/web/services/ops_api.py index 10794205..704d2f7c 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -510,6 +510,84 @@ def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]: return {"key": key, "value": value, "ok": True} +def _build_amsc_awos_headers() -> dict[str, str]: + headers = { + "Accept": "application/json, text/plain, */*", + "Referer": os.getenv("AMSC_AWOS_REFERER", "https://www.amsc.net.cn/"), + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36" + ), + } + cookie = str(os.getenv("POLYWEATHER_AMSC_COOKIE") or "").strip() + session_id = str(os.getenv("POLYWEATHER_AMSC_SESSION_ID") or "").strip() + if cookie: + headers["Cookie"] = cookie + elif session_id: + headers["sessionId"] = session_id + headers["app"] = "AMS" + return headers + + +def _check_amsc_awos_health(timeout: int = 8) -> dict[str, Any]: + import time as _time + + from src.data_collection.amsc_awos_sources import _amsc_parse_wind_plate_payload + + amsc_base = str(os.getenv("AMSC_AWOS_BASE_URL") or "").strip() + if not amsc_base: + return {"ok": False, "error": "not configured"} + + credential_configured = bool( + str(os.getenv("POLYWEATHER_AMSC_COOKIE") or "").strip() + or str(os.getenv("POLYWEATHER_AMSC_SESSION_ID") or "").strip() + ) + try: + t0 = _time.perf_counter() + response = _requests.get( + f"{amsc_base}?cccc=ZSPD", + timeout=timeout, + verify=False, + headers=_build_amsc_awos_headers(), + ) + latency_ms = round((_time.perf_counter() - t0) * 1000) + try: + payload = response.json() if response.content else {} + except ValueError: + payload = {} + parsed = _amsc_parse_wind_plate_payload( + payload if isinstance(payload, dict) else {}, + city_key="shanghai", + icao="ZSPD", + ) + points = ( + ((parsed or {}).get("runway_obs") or {}).get("point_temperatures") + if isinstance(parsed, dict) + else [] + ) + point_count = len(points or []) + ok = bool(response.ok and parsed and point_count > 0) + result: dict[str, Any] = { + "ok": ok, + "status": response.status_code, + "latency_ms": latency_ms, + "credential_configured": credential_configured, + "points": point_count, + } + if isinstance(parsed, dict): + result["sample_city"] = "shanghai" + result["observation_time_local"] = parsed.get("observation_time_local") + if not ok: + result["error"] = "empty_or_unauthorized_response" + return result + except Exception as exc: + return { + "ok": False, + "credential_configured": credential_configured, + "error": str(exc)[:100], + } + + # ── Subscriptions ─────────────────────────────────────────────────── @@ -1070,32 +1148,8 @@ def get_ops_health_check(request: Request) -> dict[str, Any]: except Exception as e: results["amos"] = {"ok": False, "error": str(e)[:100]} - # AMSC AWOS (China mainland airports) — matches actual source SSL + URL pattern - amsc_base = str(os.getenv("AMSC_AWOS_BASE_URL") or "").strip() - if amsc_base: - try: - t0 = _time.perf_counter() - r = _r.get( - f"{amsc_base}?cccc=ZSPD", - timeout=timeout, - verify=False, - headers={ - "Accept": "application/json, text/plain, */*", - "Referer": os.getenv( - "AMSC_AWOS_REFERER", "https://www.amsc.net.cn/" - ), - "User-Agent": "PolyWeather/1.0", - }, - ) - results["amsc_awos"] = { - "ok": r.ok, - "status": r.status_code, - "latency_ms": round((_time.perf_counter() - t0) * 1000), - } - except Exception as e: - results["amsc_awos"] = {"ok": False, "error": str(e)[:100]} - else: - results["amsc_awos"] = {"ok": False, "error": "not configured"} + # AMSC AWOS (China mainland airports) + results["amsc_awos"] = _check_amsc_awos_health(timeout=timeout) # NOAA WRH (US settlement verification) try: