feat(map): derive station regions from GPS
This commit is contained in:
+159
-6
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user