Files
lingniu-vehicle-ingest/station-navigation/server.py
T

274 lines
10 KiB
Python

"""Public hydrogen station navigation service.
This process deliberately has a smaller API boundary than the operations map:
it retrieves only station directory fields and never exposes vehicle records or
hydrogen-volume fields.
"""
import hashlib
from http.server import HTTPServer, SimpleHTTPRequestHandler
import json
import os
from pathlib import Path
from socketserver import ThreadingMixIn
import threading
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parent
# Production deliberately reuses the operations map's visual assets. Keeping
# this configurable lets the standalone release live under its own directory.
VEHICLE_MAP_ROOT = Path(os.getenv("VEHICLE_MAP_ASSET_ROOT", str(ROOT.parent / "vehicle-map")))
HOST = os.getenv("STATION_NAVIGATION_HOST", "0.0.0.0")
PORT = int(os.getenv("STATION_NAVIGATION_PORT", "20804"))
OPEN_PLATFORM_BASE_URL = os.getenv("OPEN_PLATFORM_BASE_URL", "https://open.d.lnoneos.com").rstrip("/")
OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
STATION_CACHE_SECONDS = int(os.getenv("STATION_NAVIGATION_CACHE_SECONDS", "120"))
VEHICLE_MAP_INTERNAL_BASE_URL = os.getenv("VEHICLE_MAP_INTERNAL_BASE_URL", "http://127.0.0.1:20800").rstrip("/")
_cache_lock = threading.Lock()
_cache = {}
_cache_load_locks = {}
_cache_refreshing = set()
def _static_asset_version():
digest = hashlib.sha256()
for filename in ("app.js", "styles.css"):
digest.update((ROOT / filename).read_bytes())
digest.update((VEHICLE_MAP_ROOT / "styles.css").read_bytes())
return digest.hexdigest()[:16]
STATIC_ASSET_VERSION = _static_asset_version()
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
allow_reuse_address = True
def _json_bytes(value):
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
def _post_open_platform(path, body):
if not OPEN_PLATFORM_APP_KEY:
raise RuntimeError("OPEN_PLATFORM_APP_KEY is not configured")
request = Request(
OPEN_PLATFORM_BASE_URL + path,
data=_json_bytes(body),
method="POST",
headers={
"Authorization": "Bearer " + OPEN_PLATFORM_APP_KEY,
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "lingniu-station-navigation/1.0",
},
)
try:
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
payload = json.load(response)
except HTTPError as exc:
detail = exc.read(2048).decode("utf-8", errors="replace")
raise RuntimeError(f"open platform returned HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise RuntimeError(f"open platform unavailable: {exc.reason}") from exc
if payload.get("code") != "SUCCESS":
raise RuntimeError(f"open platform rejected request: {payload.get('code')} {payload.get('message')}")
return payload.get("data") or []
def _refresh_cached_value(key, loader):
try:
value = loader()
except Exception as exc:
print("{} cache background refresh deferred: {}".format(key, exc))
else:
with _cache_lock:
_cache[key] = (time.time(), value)
finally:
with _cache_lock:
_cache_refreshing.discard(key)
def _start_cache_refresh(key, loader):
threading.Thread(target=_refresh_cached_value, args=(key, loader), daemon=True).start()
def _cached(key, ttl_seconds, loader):
now = time.time()
refresh_in_background = False
with _cache_lock:
cached = _cache.get(key)
if cached and now - cached[0] < ttl_seconds:
return cached[1]
if cached:
if key not in _cache_refreshing:
_cache_refreshing.add(key)
refresh_in_background = True
stale_value = cached[1]
else:
stale_value = None
load_lock = _cache_load_locks.setdefault(key, threading.Lock())
if cached:
if refresh_in_background:
_start_cache_refresh(key, loader)
return stale_value
with load_lock:
with _cache_lock:
cached = _cache.get(key)
if cached:
return _cached(key, ttl_seconds, loader)
value = loader()
with _cache_lock:
_cache[key] = (time.time(), value)
return value
def _load_station_directory():
"""Use the operations map's GPS-resolved public station directory.
The map service owns reverse-geocoding and its persistent cache, keeping
the public navigation view and the operations view on identical boundaries.
"""
request = Request(
VEHICLE_MAP_INTERNAL_BASE_URL + "/api/stations",
headers={"Accept": "application/json", "User-Agent": "lingniu-station-navigation/1.0"},
)
try:
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
payload = json.load(response)
except (HTTPError, URLError, ValueError) as exc:
raise RuntimeError("vehicle map station directory unavailable: {}".format(exc)) from exc
if payload.get("status") != "ok" or not isinstance(payload.get("stations"), list):
raise RuntimeError("vehicle map returned an invalid station directory")
return payload
def _prewarm_station_directory_cache():
"""Fetch the public directory after startup so first navigation is warm."""
try:
_cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)
except Exception as exc: # The request path will retry without blocking startup.
print(f"station navigation cache warmup deferred: {exc}")
class StationNavigationHandler(SimpleHTTPRequestHandler):
server_version = "LingniuStationNavigation/1.0"
def do_GET(self):
path = urlparse(self.path).path
if path in {"/", "/index.html"}:
self._serve_index()
return
if path == "/api/health":
self._write_json(200, {
"status": "ok",
"service": "station-navigation",
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
})
return
if path == "/api/stations":
try:
payload = _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)
except Exception as exc:
self.log_error("station directory refresh failed: %s", exc)
self._write_json(502, {
"status": "error",
"code": "UPSTREAM_UNAVAILABLE",
"message": "加氢站数据暂时不可用,请稍后重试",
})
return
self._write_json(200, payload)
return
if path == "/assets/logo_light.svg":
self._serve_logo()
return
if path == "/assets/vehicle-map.css":
self._serve_vehicle_map_styles()
return
if path.startswith("/api/"):
self._write_json(404, {"status": "error", "code": "NOT_FOUND"})
return
super().do_GET()
def _serve_index(self):
try:
template = (ROOT / "index.html").read_text(encoding="utf-8")
body = template.replace("__ASSET_VERSION__", STATIC_ASSET_VERSION).encode("utf-8")
except OSError as exc:
self.log_error("index template unavailable: %s", exc)
self.send_error(500, "Station navigation is temporarily unavailable")
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
def _serve_logo(self):
try:
body = (VEHICLE_MAP_ROOT / "logo_light.svg").read_bytes()
except OSError:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "image/svg+xml")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "public, max-age=86400")
self.end_headers()
self.wfile.write(body)
def _serve_vehicle_map_styles(self):
try:
body = (VEHICLE_MAP_ROOT / "styles.css").read_bytes()
except OSError:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "text/css; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body)
def end_headers(self):
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
self.send_header("Cache-Control", "no-cache")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
self.send_header("X-Frame-Options", "SAMEORIGIN")
super().end_headers()
def _write_json(self, status, payload):
body = _json_bytes(payload)
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
self.log_message("client disconnected before JSON response completed")
if __name__ == "__main__":
# Python 3.6 on ECS predates SimpleHTTPRequestHandler(directory=...).
# Resolve static assets from this release directory before serving.
os.chdir(str(ROOT))
server = ThreadingHTTPServer((HOST, PORT), StationNavigationHandler)
threading.Thread(target=_prewarm_station_directory_cache, daemon=True).start()
print(f"Lingniu Station Navigation listening on http://{HOST}:{PORT}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()