Files
lingniu-vehicle-ingest/vehicle-map/server.py
T

522 lines
20 KiB
Python

"""Vehicle Map static server and server-side proxy for the vehicle open platform."""
from concurrent.futures import ThreadPoolExecutor
import hashlib
from http.server import HTTPServer, SimpleHTTPRequestHandler
import json
import os
from pathlib import Path
import threading
import time
from socketserver import ThreadingMixIn
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urlparse
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parent
def _static_asset_version():
"""Content fingerprint used to bypass upstream static-asset caches on release."""
digest = hashlib.sha256()
for filename in ("app.js", "styles.css"):
digest.update((ROOT / filename).read_bytes())
return digest.hexdigest()[:16]
STATIC_ASSET_VERSION = _static_asset_version()
HOST = os.getenv("VEHICLE_MAP_HOST", "0.0.0.0")
PORT = int(os.getenv("VEHICLE_MAP_PORT", "20800"))
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"))
DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "120"))
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "120"))
AMAP_REGEOCODE_KEY = os.getenv("AMAP_REGEOCODE_KEY", "").strip()
STATION_GEOCODE_CACHE_SECONDS = int(os.getenv("STATION_GEOCODE_CACHE_SECONDS", "604800"))
STATION_GEOCODE_CACHE_PATH = Path(os.getenv(
"STATION_GEOCODE_CACHE_PATH", str(ROOT / ".station-geocode-cache.json")
))
AMAP_REGEOCODE_MIN_INTERVAL_SECONDS = float(os.getenv("AMAP_REGEOCODE_MIN_INTERVAL_SECONDS", "0.25"))
_cache_lock = threading.Lock()
_cache = {}
_cache_load_locks = {}
_cache_refreshing = set()
_station_geocode_cache_lock = threading.Lock()
_station_geocode_cache = None
_station_geocode_cache_dirty = False
_station_geocode_rate_lock = threading.Lock()
_station_geocode_next_request_at = 0.0
_station_geocode_refresh_lock = threading.Lock()
_station_geocode_refresh_pending = {}
_station_geocode_refresh_running = False
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-vehicle-map/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):
thread = threading.Thread(target=_refresh_cached_value, args=(key, loader), daemon=True)
thread.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
# Cold starts still need one synchronous load. Coalesce startup prewarming
# and the first browser request so only one upstream request is issued.
with load_lock:
now = time.time()
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 _station_coordinate_key(station):
try:
return "{:.6f},{:.6f}".format(float(station.get("longitude")), float(station.get("latitude")))
except (TypeError, ValueError):
return ""
def _component_value(value):
if isinstance(value, list):
return str(value[0]).strip() if value else ""
return str(value or "").strip()
def _load_station_geocode_cache():
global _station_geocode_cache
with _station_geocode_cache_lock:
if _station_geocode_cache is not None:
return _station_geocode_cache
try:
with STATION_GEOCODE_CACHE_PATH.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
_station_geocode_cache = payload if isinstance(payload, dict) else {}
except (OSError, ValueError, TypeError):
_station_geocode_cache = {}
return _station_geocode_cache
def _persist_station_geocode_cache():
try:
STATION_GEOCODE_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = STATION_GEOCODE_CACHE_PATH.with_suffix(STATION_GEOCODE_CACHE_PATH.suffix + ".tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(_station_geocode_cache, handle, ensure_ascii=False, separators=(",", ":"))
os.replace(str(temporary), str(STATION_GEOCODE_CACHE_PATH))
except OSError as exc:
print("station geocode cache write deferred: {}".format(exc))
def _reverse_geocode_station(longitude, latitude):
global _station_geocode_next_request_at
if not AMAP_REGEOCODE_KEY:
raise RuntimeError("AMAP_REGEOCODE_KEY is not configured")
with _station_geocode_rate_lock:
now = time.monotonic()
wait_seconds = max(0.0, _station_geocode_next_request_at - now)
_station_geocode_next_request_at = max(now, _station_geocode_next_request_at) + AMAP_REGEOCODE_MIN_INTERVAL_SECONDS
if wait_seconds:
time.sleep(wait_seconds)
query = urlencode({
"key": AMAP_REGEOCODE_KEY,
"location": "{:.6f},{:.6f}".format(longitude, latitude),
"extensions": "base",
"radius": "1000",
"batch": "false",
})
request = Request(
"https://restapi.amap.com/v3/geocode/regeo?" + query,
headers={"Accept": "application/json", "User-Agent": "lingniu-station-geocoder/1.0"},
)
try:
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
payload = json.load(response)
except (HTTPError, URLError, ValueError) as exc:
raise RuntimeError("AMap reverse geocode unavailable: {}".format(exc)) from exc
if payload.get("status") != "1":
raise RuntimeError("AMap reverse geocode rejected request: {} {}".format(payload.get("infocode"), payload.get("info")))
component = (payload.get("regeocode") or {}).get("addressComponent") or {}
province = _component_value(component.get("province"))
city = _component_value(component.get("city")) or province
district = _component_value(component.get("district"))
if not province:
raise RuntimeError("AMap reverse geocode returned no province")
return {"province": province, "city": city, "district": district, "adcode": _component_value(component.get("adcode"))}
def _station_region_from_gps(station, now=None, refresh_queue=None):
global _station_geocode_cache_dirty
coordinate_key = _station_coordinate_key(station)
if not coordinate_key:
return {"province": "", "city": "", "district": ""}
now = time.time() if now is None else now
cache = _load_station_geocode_cache()
cached = cache.get(coordinate_key) or {}
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
return cached.get("region") or {"province": "", "city": "", "district": ""}
cached_region = cached.get("region")
if isinstance(cached_region, dict):
if refresh_queue is not None:
refresh_queue.append(station)
return cached_region
try:
longitude, latitude = (float(value) for value in coordinate_key.split(","))
region = _reverse_geocode_station(longitude, latitude)
except Exception as exc:
print("station reverse geocode deferred for {}: {}".format(coordinate_key, exc))
return cached.get("region") or {"province": "", "city": "", "district": ""}
with _station_geocode_cache_lock:
cache[coordinate_key] = {"updatedAt": now, "region": region}
_station_geocode_cache_dirty = True
return region
def _refresh_station_region(station):
coordinate_key = _station_coordinate_key(station)
if not coordinate_key:
return False
now = time.time()
cache = _load_station_geocode_cache()
with _station_geocode_cache_lock:
cached = cache.get(coordinate_key) or {}
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
return False
try:
longitude, latitude = (float(value) for value in coordinate_key.split(","))
region = _reverse_geocode_station(longitude, latitude)
except Exception as exc:
print("station reverse geocode refresh deferred for {}: {}".format(coordinate_key, exc))
return False
with _station_geocode_cache_lock:
cache[coordinate_key] = {"updatedAt": now, "region": region}
return True
def _refresh_station_regions(stations):
with ThreadPoolExecutor(max_workers=8) as executor:
changed = any(list(executor.map(_refresh_station_region, stations)))
if changed:
with _station_geocode_cache_lock:
_persist_station_geocode_cache()
def _drain_station_region_refreshes():
global _station_geocode_refresh_running
restart = False
try:
while True:
with _station_geocode_refresh_lock:
batch = list(_station_geocode_refresh_pending.values())
_station_geocode_refresh_pending.clear()
if not batch:
return
_refresh_station_regions(batch)
except Exception as exc:
print("station reverse geocode background refresh deferred: {}".format(exc))
finally:
with _station_geocode_refresh_lock:
_station_geocode_refresh_running = False
restart = bool(_station_geocode_refresh_pending)
if restart:
_schedule_station_region_refresh([])
def _schedule_station_region_refresh(stations):
global _station_geocode_refresh_running
start_worker = False
with _station_geocode_refresh_lock:
for station in stations:
coordinate_key = _station_coordinate_key(station)
if coordinate_key:
_station_geocode_refresh_pending[coordinate_key] = station
if _station_geocode_refresh_pending and not _station_geocode_refresh_running:
_station_geocode_refresh_running = True
start_worker = True
if start_worker:
threading.Thread(target=_drain_station_region_refreshes, daemon=True).start()
def _stations_with_gps_regions(stations):
global _station_geocode_cache_dirty
def enrich(station):
item = dict(station)
refresh_queue = []
region = _station_region_from_gps(item, refresh_queue=refresh_queue)
# Never fall back to data-table administrative fields. The raw address
# remains untouched and is the only directly displayed location text.
item.update({key: region.get(key, "") for key in ("province", "city", "district")})
return item, refresh_queue[0] if refresh_queue else None
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(enrich, stations))
enriched = [item for item, _ in results]
stale_stations = [station for _, station in results if station is not None]
with _station_geocode_cache_lock:
if _station_geocode_cache_dirty:
_persist_station_geocode_cache()
_station_geocode_cache_dirty = False
if stale_stations:
_schedule_station_region_refresh(stale_stations)
return enriched
def _load_stations():
return _cached(
"stations",
STATION_CACHE_SECONDS,
lambda: _stations_with_gps_regions(_post_open_platform("/api/v1/hydrogen-stations/query", {})),
)
def _public_station_directory():
stations = _load_stations()
fields = ("id", "name", "shortName", "province", "city", "district", "address", "longitude", "latitude", "cooperative")
partner_fields = ("contactPerson", "contactPhone", "unitPrice")
directory = []
for station in stations:
item = {field: station.get(field) for field in fields if station.get(field) is not None}
if item.get("cooperative"):
item.update({field: station.get(field) for field in partner_fields if station.get(field) not in (None, "", 0)})
directory.append(item)
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 _load_dashboard():
today = time.strftime("%Y-%m-%d", time.localtime())
with ThreadPoolExecutor(max_workers=3) as executor:
realtime_future = executor.submit(
_post_open_platform, "/api/v1/vehicles/realtime/query", {}
)
mileage_future = executor.submit(
_post_open_platform, "/api/v1/vehicles/mileage/query", {"date": today}
)
stations_future = executor.submit(_load_stations)
vehicles = realtime_future.result()
mileage_rows = mileage_future.result()
stations = stations_future.result()
mileage_by_vin = {
row.get("vin"): row
for row in mileage_rows
if row.get("vin") and row.get("status") == "NORMAL"
}
# The daily activity view places every vehicle that reported
# today is placed in exactly one bucket using its latest selected speed.
active_vehicles = [item for item in vehicles if item.get("activeToday")]
driving = sum(1 for item in active_vehicles if float(item.get("speedKmh") or 0) > 3)
idle = len(active_vehicles) - driving
offline = sum(1 for item in vehicles if item.get("motionStatus") == "offline")
active_today = len(active_vehicles)
daily_mileage = round(
sum(float(row.get("dailyMileageKm") or 0) for row in mileage_rows), 3
)
monthly_hydrogen = round(
sum(float(station.get("monthlyHydrogenKg") or 0) for station in stations), 3
)
for vehicle in vehicles:
mileage = mileage_by_vin.get(vehicle.get("vin"), {})
vehicle["dailyMileageKm"] = mileage.get("dailyMileageKm", 0)
return {
"status": "ok",
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"date": today,
"summary": {
"totalVehicles": len(vehicles),
"onlineVehicles": active_today,
"drivingVehicles": driving,
"idleVehicles": idle,
"offlineVehicles": offline,
"todayMileageKm": daily_mileage,
"monthlyHydrogenKg": monthly_hydrogen,
"totalStations": len(stations),
"cooperativeStations": sum(1 for item in stations if item.get("cooperative")),
},
"vehicles": vehicles,
"stations": stations,
}
def _prewarm_dashboard_cache():
"""Warm the first dashboard snapshot after a process restart.
A warm snapshot lets the first visitor receive the cached response instead
of waiting for the three upstream requests to complete.
"""
try:
_cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
except Exception as exc: # A later request can retry; startup must stay available.
print(f"vehicle map cache warmup deferred: {exc}")
class VehicleMapHandler(SimpleHTTPRequestHandler):
server_version = "LingniuVehicleMap/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": "vehicle-map",
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
})
return
if path == "/api/dashboard":
try:
payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
except Exception as exc: # keep upstream details server-side only
self.log_error("dashboard refresh failed: %s", exc)
self._write_json(502, {
"status": "error",
"code": "UPSTREAM_UNAVAILABLE",
"message": "地图数据暂时不可用,请稍后重试",
})
return
self._write_json(200, payload)
return
if path == "/api/stations":
try:
payload = _public_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
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, "Map application 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 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, headers=None):
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)))
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
self.log_message("client disconnected before JSON response completed")
if __name__ == "__main__":
server = ThreadingHTTPServer((HOST, PORT), VehicleMapHandler)
threading.Thread(target=_prewarm_dashboard_cache, daemon=True).start()
print(f"Lingniu Vehicle Map listening on http://{HOST}:{PORT}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()