feat(map): add cached public station navigation
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
"""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"))
|
||||
_cache_lock = threading.Lock()
|
||||
_cache = {}
|
||||
|
||||
|
||||
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 _cached(key, ttl_seconds, loader):
|
||||
now = time.time()
|
||||
with _cache_lock:
|
||||
cached = _cache.get(key)
|
||||
if cached and now - cached[0] < ttl_seconds:
|
||||
return cached[1]
|
||||
value = loader()
|
||||
with _cache_lock:
|
||||
_cache[key] = (now, value)
|
||||
return value
|
||||
|
||||
|
||||
def _public_station(station):
|
||||
"""Positive allowlist: do not add operational or hydrogen fields here."""
|
||||
fields = (
|
||||
"id", "name", "shortName", "province", "city", "district", "address",
|
||||
"longitude", "latitude", "cooperative",
|
||||
)
|
||||
return {field: station.get(field) for field in fields if station.get(field) is not None}
|
||||
|
||||
|
||||
def _load_station_directory():
|
||||
stations = _post_open_platform("/api/v1/hydrogen-stations/query", {})
|
||||
directory = [_public_station(station) for station in stations]
|
||||
return {
|
||||
"status": "ok",
|
||||
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
"summary": {
|
||||
"totalStations": len(directory),
|
||||
"cooperativeStations": sum(1 for station in directory if station.get("cooperative")),
|
||||
},
|
||||
"stations": directory,
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
self._write_json(200, _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
|
||||
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()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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()
|
||||
Reference in New Issue
Block a user