feat(map): derive station regions from GPS

This commit is contained in:
lingniu
2026-08-12 12:03:09 +08:00
parent f707e6ad68
commit 4c547757a7
8 changed files with 228 additions and 38 deletions
+159 -6
View File
@@ -10,7 +10,7 @@ import threading
import time
from socketserver import ThreadingMixIn
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
from urllib.request import Request, urlopen
@@ -33,8 +33,17 @@ 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")
))
_cache_lock = threading.Lock()
_cache = {}
_cache_load_locks = {}
_station_geocode_cache_lock = threading.Lock()
_station_geocode_cache = None
_station_geocode_cache_dirty = False
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
@@ -79,20 +88,153 @@ def _cached(key, ttl_seconds, loader):
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
load_lock = _cache_load_locks.setdefault(key, threading.Lock())
# Startup prewarming and the first browser request can arrive together.
# Coalesce them so a cold geocode cache is filled only once.
with load_lock:
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 _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):
if not AMAP_REGEOCODE_KEY:
raise RuntimeError("AMAP_REGEOCODE_KEY is not configured")
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):
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": ""}
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 _stations_with_gps_regions(stations):
global _station_geocode_cache_dirty
def enrich(station):
item = dict(station)
region = _station_region_from_gps(item)
# 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
with ThreadPoolExecutor(max_workers=8) as executor:
enriched = list(executor.map(enrich, stations))
with _station_geocode_cache_lock:
if _station_geocode_cache_dirty:
_persist_station_geocode_cache()
_station_geocode_cache_dirty = False
return enriched
def _load_stations():
return _cached(
"stations",
STATION_CACHE_SECONDS,
lambda: _post_open_platform("/api/v1/hydrogen-stations/query", {}),
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")
directory = [{field: station.get(field) for field in fields if station.get(field) is not None} 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 _load_dashboard():
today = time.strftime("%Y-%m-%d", time.localtime())
with ThreadPoolExecutor(max_workers=3) as executor:
@@ -188,6 +330,17 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
"message": "地图数据暂时不可用,请稍后重试",
})
return
if path == "/api/stations":
try:
self._write_json(200, _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
super().do_GET()
def _serve_index(self):