feat(map): add protected operations access

This commit is contained in:
lingniu
2026-08-11 18:30:16 +08:00
parent f535b1570b
commit d4593c44cd
9 changed files with 738 additions and 37 deletions
+12
View File
@@ -5,3 +5,15 @@ OPEN_PLATFORM_APP_KEY=replace-with-32-character-app-key
UPSTREAM_TIMEOUT_SECONDS=15 UPSTREAM_TIMEOUT_SECONDS=15
DASHBOARD_CACHE_SECONDS=5 DASHBOARD_CACHE_SECONDS=5
STATION_CACHE_SECONDS=3600 STATION_CACHE_SECONDS=3600
# Public station map: name, location, address and cooperation status only.
# Operations data (all vehicles and hydrogen volumes) requires this server-only code.
VEHICLE_MAP_AUTH_PROVIDER=local
VEHICLE_MAP_ACCESS_CODE=replace-with-an-access-code
VEHICLE_MAP_SESSION_SECRET=replace-with-at-least-32-random-characters
# An operations session expires after 30 minutes.
VEHICLE_MAP_SESSION_TTL_SECONDS=1800
# true behind the HTTPS production reverse proxy; false only for localhost development.
VEHICLE_MAP_COOKIE_SECURE=true
# For future unified authentication: set provider to auth-center and configure its introspection endpoint.
VEHICLE_MAP_AUTH_CENTER_INTROSPECTION_URL=
VEHICLE_MAP_AUTH_CENTER_CLIENT_TOKEN=
+22
View File
@@ -19,6 +19,28 @@ python3 server.py
默认地址:`http://127.0.0.1:20800` 默认地址:`http://127.0.0.1:20800`
请通过上述 HTTP 地址访问,不能直接双击打开 `index.html`:页面需要同源调用本服务的公开站点与授权 API。
## 公开与授权数据边界
- `GET /api/public/stations` 无需授权,仅返回站点名称、坐标、省市区、地址与合作状态;加氢量字段不在响应中。
- `GET /api/dashboard` 需要 `operations:read` 会话,返回全部车辆信息及加氢站加氢量。
- `POST /api/session` 接收访问码,服务端验证后写入短期 HttpOnly 会话 Cookie;浏览器可保存该会话,访问码不会下发或存入前端。
- `DELETE /api/session` 清除当前浏览器的运营会话。
当前默认 `VEHICLE_MAP_AUTH_PROVIDER=local`,生产环境在 `/opt/lingniu-vehicle-map/env/vehicle-map.env` 设置:
```bash
VEHICLE_MAP_ACCESS_CODE=<访问码>
VEHICLE_MAP_SESSION_SECRET=<至少32位随机密钥>
VEHICLE_MAP_SESSION_TTL_SECONDS=1800
VEHICLE_MAP_COOKIE_SECURE=true
```
授权会话有效期为 30 分钟;到期后自动回到仅展示公开加氢站目录的状态。
后续接入统一鉴权中心时,将 `VEHICLE_MAP_AUTH_PROVIDER` 改为 `auth-center` 并配置 `VEHICLE_MAP_AUTH_CENTER_INTROSPECTION_URL`;前端流程与受保护接口不需要调整。鉴权中心适配器的约定为接收 `{accessCode, audience:"vehicle-map"}`,返回 `{active, subject, scopes}`,且 scopes 需要包含 `operations:read`
## 接口依赖 ## 接口依赖
- `POST /api/v1/vehicles/realtime/query`:全部授权车辆实时位置与状态; - `POST /api/v1/vehicles/realtime/query`:全部授权车辆实时位置与状态;
+263 -20
View File
@@ -22,11 +22,15 @@ const i18n = {
filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站', filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站',
detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线', detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线',
detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标', detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标',
detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航',
valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试', valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试',
locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试', locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试',
searchUniversal: '搜索位置、站点、车牌或 VIN', searchLocations: '位置', searchEntities: '业务对象', searchEmpty: '没有匹配的地点或对象', searchUniversal: '搜索位置、站点、车牌或 VIN', searchLocations: '位置', searchEntities: '业务对象', searchEmpty: '没有匹配的地点或对象',
suggestionLocation: '地点', suggestionVehicle: '车辆', suggestionStation: '加氢站', detailMore: '展开全部信息', detailLess: '收起详细信息' suggestionLocation: '地点', suggestionVehicle: '车辆', suggestionStation: '加氢站', detailMore: '展开全部信息', detailLess: '收起详细信息',
publicStationTitle: '加氢站网络', publicDataStatus: '公共站点目录 · 位置、地址与合作状态', publicDataSource: '公共站点网络',
authEntry: '运营授权', authGranted: '已授权', authTitle: '进入运营数据', authDescription: '车辆全量信息与加氢量仅向获得授权的用户展示。',
authCodeLabel: '访问码', authCodePlaceholder: '输入访问码', authSubmit: '验证并进入', authCancel: '暂不授权', authRequired: '运营数据需要授权访问', authExpiry: '本次授权有效期为 30 分钟,到期后将自动恢复为公开站点视图。',
authDenied: '访问码无效,请重试', authUnavailable: '授权服务暂不可用,请稍后重试', authHydrogenRequired: '授权后查看加氢量', publicHydrogenHidden: '运营数据受保护', authLogout: '退出授权'
}, },
en: { en: {
vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network', vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network',
@@ -49,11 +53,15 @@ const i18n = {
filterResult: 'Filtered', searchVehicle: 'Search plate / VIN', searchStation: 'Search station / address', nearbyVehicles: 'Nearby vehicles', nearbyStations: 'Nearby H₂ stations', filterResult: 'Filtered', searchVehicle: 'Search plate / VIN', searchStation: 'Search station / address', nearbyVehicles: 'Nearby vehicles', nearbyStations: 'Nearby H₂ stations',
detailVehicle: 'Vehicle', detailStation: 'H₂ station', detailPlate: 'Plate', detailVin: 'VIN', detailStatus: 'Status', detailActive: 'Active today', detailVehicle: 'Vehicle', detailStation: 'H₂ station', detailPlate: 'Plate', detailVin: 'VIN', detailStatus: 'Status', detailActive: 'Active today',
detailSpeed: 'Speed', detailDailyMileage: 'Today mileage', detailTotalMileage: 'Total mileage', detailLocation: 'Live location', detailCoordinate: 'Coordinates', detailSpeed: 'Speed', detailDailyMileage: 'Today mileage', detailTotalMileage: 'Total mileage', detailLocation: 'Live location', detailCoordinate: 'Coordinates',
detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailNavigate: 'Navigate',
valueYes: 'Yes', valueNo: 'No', valueUnavailable: 'Unavailable', locatePermissionHint: 'Location access is blocked. Allow it in your browser and try again.', valueYes: 'Yes', valueNo: 'No', valueUnavailable: 'Unavailable', locatePermissionHint: 'Location access is blocked. Allow it in your browser and try again.',
locateUnavailableHint: 'Your location is temporarily unavailable. Please try again.', locateTimeoutHint: 'Location timed out. Move to an open area and try again.', locateUnavailableHint: 'Your location is temporarily unavailable. Please try again.', locateTimeoutHint: 'Location timed out. Move to an open area and try again.',
searchUniversal: 'Search location, station, plate, or VIN', searchLocations: 'Locations', searchEntities: 'Results', searchEmpty: 'No matching location or entity', searchUniversal: 'Search location, station, plate, or VIN', searchLocations: 'Locations', searchEntities: 'Results', searchEmpty: 'No matching location or entity',
suggestionLocation: 'Location', suggestionVehicle: 'Vehicle', suggestionStation: 'H₂ station', detailMore: 'Show all details', detailLess: 'Collapse details' suggestionLocation: 'Location', suggestionVehicle: 'Vehicle', suggestionStation: 'H₂ station', detailMore: 'Show all details', detailLess: 'Collapse details',
publicStationTitle: 'H₂ Station Network', publicDataStatus: 'Public station directory · location, address and partner status', publicDataSource: 'Public station network',
authEntry: 'Operations access', authGranted: 'Access granted', authTitle: 'Enter operations data', authDescription: 'Vehicle information and hydrogen volumes require authorized access.',
authCodeLabel: 'Access code', authCodePlaceholder: 'Enter access code', authSubmit: 'Verify and continue', authCancel: 'Not now', authRequired: 'Operations data requires authorized access', authExpiry: 'This authorization lasts 30 minutes. When it expires, the public station view is restored.',
authDenied: 'The access code is invalid. Try again.', authUnavailable: 'Authorization is temporarily unavailable. Try again later.', authHydrogenRequired: 'Authorize to view hydrogen volume', publicHydrogenHidden: 'Operations data protected', authLogout: 'Sign out'
} }
}; };
@@ -88,7 +96,7 @@ let map = null;
let markerList = []; let markerList = [];
let infoWindowList = []; let infoWindowList = [];
let is3DPitch = true; let is3DPitch = true;
let currentMode = 'vehicle'; let currentMode = 'station';
let currentTheme = 'theme-white'; let currentTheme = 'theme-white';
let currentLang = 'zh'; let currentLang = 'zh';
let currentRankType = 'primary'; let currentRankType = 'primary';
@@ -109,6 +117,7 @@ let exploreSuggestionItems = [];
let activeExploreSuggestionIndex = -1; let activeExploreSuggestionIndex = -1;
let detailExpanded = false; let detailExpanded = false;
const filterState = { province: '', city: '', district: '', query: '' }; const filterState = { province: '', city: '', district: '', query: '' };
let accessState = { authorized: false, principal: null, pendingMode: null };
const REGION_ZOOM = { city: 7, district: 9.5, vehicle: 12 }; const REGION_ZOOM = { city: 7, district: 9.5, vehicle: 12 };
const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']); const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']);
@@ -116,27 +125,75 @@ const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']);
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initClock(); initClock();
initAMapInstance(); initAMapInstance();
loadDashboard(); initializeDataAccess();
refreshTimer = window.setInterval(loadDashboard, 15000);
}); });
document.addEventListener('pointerdown', event => { document.addEventListener('pointerdown', event => {
if (!event.target?.closest?.('.map-explore-toolbar')) closeExploreSuggestions(); if (!event.target?.closest?.('.map-explore-toolbar')) closeExploreSuggestions();
}); });
async function loadDashboard() { async function initializeDataAccess() {
await loadSession();
await loadPublicStations();
if (accessState.authorized) loadOperationalDashboard();
refreshTimer = window.setInterval(() => {
if (accessState.authorized) loadOperationalDashboard();
else loadPublicStations();
}, 15000);
}
async function loadSession() {
try {
const response = await fetch('/api/session', { headers: { Accept: 'application/json' } });
const payload = await response.json();
accessState.authorized = Boolean(payload.authorized);
accessState.principal = payload.principal || null;
} catch (error) {
accessState.authorized = false;
accessState.principal = null;
}
updateAccessUI();
}
async function loadPublicStations() {
setDataState('loading');
try {
const response = await fetch('/api/public/stations', { headers: { Accept: 'application/json' } });
const payload = await response.json();
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
if (!accessState.authorized || currentMode === 'station') dashboard = payload;
void syncFilterControls(payload);
updateDashboardUI();
if (currentMode === 'station') refreshStationViewport();
setDataState('ready');
} catch (error) {
console.error('public station refresh failed', error);
setDataState('error');
}
}
async function loadOperationalDashboard() {
setDataState('loading'); setDataState('loading');
try { try {
const response = await fetch('/api/dashboard', { headers: { Accept: 'application/json' } }); const response = await fetch('/api/dashboard', { headers: { Accept: 'application/json' } });
const payload = await response.json(); const payload = await response.json();
if (response.status === 401) {
accessState.authorized = false;
accessState.principal = null;
updateAccessUI();
if (currentMode === 'vehicle') switchMode('station');
await loadPublicStations();
return;
}
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`); if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
dashboard = payload; dashboard = payload;
void syncFilterControls(payload); void syncFilterControls(payload);
updateDashboardUI(); updateDashboardUI();
refreshVehicleRegionNodes(payload); if (currentMode === 'vehicle') refreshVehicleRegionNodes(payload);
else refreshStationViewport();
setDataState('ready'); setDataState('ready');
} catch (error) { } catch (error) {
console.error('dashboard refresh failed', error); console.error('operational dashboard refresh failed', error);
setDataState('error'); setDataState('error');
} }
} }
@@ -148,6 +205,97 @@ function setDataState(state) {
if (state === 'error') el.textContent = currentLang === 'zh' ? '数据同步失败 · 将自动重试' : 'Data sync failed · Retrying'; if (state === 'error') el.textContent = currentLang === 'zh' ? '数据同步失败 · 将自动重试' : 'Data sync failed · Retrying';
} }
function operationalAccessGranted() {
return Boolean(accessState.authorized);
}
function updateAccessUI() {
const dict = i18n[currentLang];
const trigger = document.getElementById('accessTrigger');
if (trigger) {
trigger.classList.toggle('is-authorized', operationalAccessGranted());
trigger.textContent = operationalAccessGranted() ? dict.authGranted : dict.authEntry;
trigger.setAttribute('aria-label', operationalAccessGranted() ? dict.authLogout : dict.authEntry);
trigger.title = operationalAccessGranted() ? dict.authLogout : dict.authEntry;
}
document.getElementById('btnModeVehicle')?.classList.toggle('is-locked', !operationalAccessGranted());
}
function openAccessDialog(targetMode = 'vehicle', message = '') {
accessState.pendingMode = targetMode;
const dict = i18n[currentLang];
const dialog = document.getElementById('accessDialog');
const hint = document.getElementById('accessDialogHint');
const input = document.getElementById('accessCodeInput');
if (hint) hint.textContent = message || dict.authDescription;
if (input) input.value = '';
if (dialog?.showModal) dialog.showModal();
else dialog?.setAttribute('open', '');
window.setTimeout(() => input?.focus(), 0);
}
function closeAccessDialog() {
const dialog = document.getElementById('accessDialog');
if (dialog?.close) dialog.close();
else dialog?.removeAttribute('open');
const error = document.getElementById('accessDialogError');
if (error) { error.hidden = true; error.textContent = ''; }
}
async function authorizeOperationsAccess() {
const dict = i18n[currentLang];
const input = document.getElementById('accessCodeInput');
const submit = document.getElementById('accessSubmitBtn');
const error = document.getElementById('accessDialogError');
const accessCode = input?.value?.trim();
if (!accessCode) {
if (error) { error.hidden = false; error.textContent = dict.authDenied; }
input?.focus();
return;
}
if (submit) submit.disabled = true;
if (error) error.hidden = true;
try {
const response = await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ accessCode }),
});
const payload = await response.json();
if (!response.ok || !payload.authorized) {
if (error) { error.hidden = false; error.textContent = response.status === 503 ? dict.authUnavailable : dict.authDenied; }
return;
}
accessState.authorized = true;
accessState.principal = payload.principal || null;
updateAccessUI();
closeAccessDialog();
await loadOperationalDashboard();
const targetMode = accessState.pendingMode || 'vehicle';
accessState.pendingMode = null;
if (targetMode === 'vehicle') switchMode('vehicle', { skipAuthCheck: true });
else updateDashboardUI();
} catch (requestError) {
if (error) { error.hidden = false; error.textContent = dict.authUnavailable; }
} finally {
if (submit) submit.disabled = false;
}
}
async function toggleOperationsAccess() {
if (!operationalAccessGranted()) {
openAccessDialog('vehicle');
return;
}
try { await fetch('/api/session', { method: 'DELETE', headers: { Accept: 'application/json' } }); } catch (error) { /* local state still resets safely */ }
accessState.authorized = false;
accessState.principal = null;
accessState.pendingMode = null;
updateAccessUI();
if (currentMode === 'vehicle') switchMode('station', { skipAuthCheck: true });
await loadPublicStations();
}
function normalizeSearch(value) { function normalizeSearch(value) {
return String(value || '').trim().toLocaleLowerCase(); return String(value || '').trim().toLocaleLowerCase();
} }
@@ -181,6 +329,10 @@ function selectedVehicleFilterGroup() {
return null; return null;
} }
function hasPinnedVehicleDistrictFilter() {
return currentMode === 'vehicle' && Boolean(filterState.district) && !normalizeSearch(filterState.query);
}
function filteredVehicles(vehicles = dashboard?.vehicles || []) { function filteredVehicles(vehicles = dashboard?.vehicles || []) {
const group = selectedVehicleFilterGroup(); const group = selectedVehicleFilterGroup();
const allowedVins = group ? new Set(group.vehicles.map(vehicle => vehicle.vin)) : null; const allowedVins = group ? new Set(group.vehicles.map(vehicle => vehicle.vin)) : null;
@@ -572,10 +724,40 @@ function focusCoordinates(items, zoom) {
map.setZoomAndCenter(zoom, [center[0] / located.length, center[1] / located.length]); map.setZoomAndCenter(zoom, [center[0] / located.length, center[1] / located.length]);
} }
function focusVehicleSet(vehicles) {
const points = locatedVehicles(vehicles).map(vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)));
if (!map || !points.length) return;
if (points.length === 1) {
map.setZoomAndCenter(14, points[0]);
return;
}
const longitudes = points.map(point => point[0]);
const latitudes = points.map(point => point[1]);
const minLng = Math.min(...longitudes); const maxLng = Math.max(...longitudes);
const minLat = Math.min(...latitudes); const maxLat = Math.max(...latitudes);
const center = [(minLng + maxLng) / 2, (minLat + maxLat) / 2];
const latitudeScale = Math.cos(center[1] * Math.PI / 180);
const spanMeters = Math.max((maxLat - minLat) * 111320, (maxLng - minLng) * 111320 * latitudeScale, 400);
const size = map.getSize?.();
const width = Number(size?.getWidth?.()) || 900;
const height = Number(size?.getHeight?.()) || 560;
// Reserve room for the toolbar, detail card and map controls on every viewport.
const usablePixels = Math.max(180, Math.min(width * 0.72, height * 0.68));
const metersPerPixel = spanMeters / usablePixels;
const zoom = Math.max(7, Math.min(14, Math.log2((156543.03392 * latitudeScale) / metersPerPixel)));
map.setZoomAndCenter(zoom, center);
}
function focusCurrentRegion(level) { function focusCurrentRegion(level) {
if (!map) return; if (!map) return;
if (currentMode === 'vehicle') { if (currentMode === 'vehicle') {
const group = selectedVehicleFilterGroup(); const group = selectedVehicleFilterGroup();
if (level === 'district' && hasPinnedVehicleDistrictFilter() && locatedVehicles(filteredVehicles()).length) {
// District borders can be elongated. Fit the actual filtered fleet rather
// than centering on the administrative centroid, which can hide a valid car.
focusVehicleSet(filteredVehicles());
return;
}
if (group?.lnglat) map.setZoomAndCenter(level === 'province' ? 7.4 : level === 'city' ? 10 : 12.5, group.lnglat); if (group?.lnglat) map.setZoomAndCenter(level === 'province' ? 7.4 : level === 'city' ? 10 : 12.5, group.lnglat);
return; return;
} }
@@ -682,10 +864,12 @@ function updateDashboardUI() {
document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} <small>${countUnit(active, currentMode, dict)} (${activeRate}%)</small>`; document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} <small>${countUnit(active, currentMode, dict)} (${activeRate}%)</small>`;
const activity = currentMode === 'vehicle' const activity = currentMode === 'vehicle'
? { label: dict.kpiDailyMileage, value: summary.todayMileageKm, unit: 'km' } ? { label: dict.kpiDailyMileage, value: summary.todayMileageKm, unit: 'km' }
: { label: dict.kpiMonthlyHydrogen, value: summary.monthlyHydrogenKg, unit: 'kg' }; : operationalAccessGranted()
? { label: dict.kpiMonthlyHydrogen, value: summary.monthlyHydrogenKg, unit: 'kg' }
: { label: dict.publicHydrogenHidden, value: null, unit: '' };
document.getElementById('kpiActivityLabel').textContent = activity.label; document.getElementById('kpiActivityLabel').textContent = activity.label;
document.getElementById('kpiDailyDist').innerHTML = activity.value == null document.getElementById('kpiDailyDist').innerHTML = activity.value == null
? `— <small>${activity.unit}</small>` ? `${activity.unit ? ` <small>${activity.unit}</small>` : ''}`
: `${formatNumber(activity.value, 1)} <small>${activity.unit}</small>`; : `${formatNumber(activity.value, 1)} <small>${activity.unit}</small>`;
const regionSummary = buildVehicleRegionNodes(); const regionSummary = buildVehicleRegionNodes();
document.getElementById('mapStatusText').textContent = mapStatusSummaryText(summary, regionSummary, dashboard.asOf); document.getElementById('mapStatusText').textContent = mapStatusSummaryText(summary, regionSummary, dashboard.asOf);
@@ -700,9 +884,12 @@ function mapStatusSummaryText(summary, regionSummary, asOf, mode = currentMode,
if (mode === 'station') { if (mode === 'station') {
const cooperative = Number(summary.cooperativeStations || 0); const cooperative = Number(summary.cooperativeStations || 0);
const external = Math.max(0, Number(summary.totalStations || 0) - cooperative); const external = Math.max(0, Number(summary.totalStations || 0) - cooperative);
const accessHint = operationalAccessGranted()
? ''
: lang === 'zh' ? ' · 站点目录公开展示' : ' · public station directory';
return lang === 'zh' return lang === 'zh'
? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点 · ${asOf}` ? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点${accessHint} · ${asOf}`
: `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations · ${asOf}`; : `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations${accessHint} · ${asOf}`;
} }
const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level, lang) : regionSummaryText(regionSummary, lang); const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level, lang) : regionSummaryText(regionSummary, lang);
return lang === 'zh' return lang === 'zh'
@@ -818,6 +1005,7 @@ function stationAdministrativePath(station, lang = currentLang) {
} }
function hierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) { function hierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) {
if (hasPinnedVehicleDistrictFilter()) return 'vehicle';
if (zoom < REGION_ZOOM.city) return 'province'; if (zoom < REGION_ZOOM.city) return 'province';
if (zoom < REGION_ZOOM.district) return 'city'; if (zoom < REGION_ZOOM.district) return 'city';
if (zoom < REGION_ZOOM.vehicle) return 'district'; if (zoom < REGION_ZOOM.vehicle) return 'district';
@@ -1005,7 +1193,7 @@ function vehiclePointNodes(vehicles = filteredVehicles()) {
return locatedVehicles(vehicles).map(vehicle => { return locatedVehicles(vehicles).map(vehicle => {
const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)); const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude));
return { vehicle, lnglat }; return { vehicle, lnglat };
}).filter(({ lnglat }) => !bounds || (lnglat[0] >= bounds.west && lnglat[0] <= bounds.east && lnglat[1] >= bounds.south && lnglat[1] <= bounds.north)) }).filter(({ lnglat }) => hasPinnedVehicleDistrictFilter() || !bounds || (lnglat[0] >= bounds.west && lnglat[0] <= bounds.east && lnglat[1] >= bounds.south && lnglat[1] <= bounds.north))
.map(({ vehicle, lnglat }) => ({ .map(({ vehicle, lnglat }) => ({
id: `vehicle-point-${vehicle.vin}`, kind: 'vehiclePoint', level: 'vehicle', id: `vehicle-point-${vehicle.vin}`, kind: 'vehiclePoint', level: 'vehicle',
nameZh: vehicle.plateNumber || vehicle.vin, nameEn: vehicle.plateNumber || vehicle.vin, nameZh: vehicle.plateNumber || vehicle.vin, nameEn: vehicle.plateNumber || vehicle.vin,
@@ -1171,6 +1359,42 @@ function vehicleStatusLabel(vehicle) {
return status; return status;
} }
function navigationCoordinates(mode, entity) {
const longitude = Number(entity?.longitude);
const latitude = Number(entity?.latitude);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
// Vehicle telemetry is WGS-84 and is converted before rendering on AMap.
// Station assets already use AMap (GCJ-02) coordinates.
return mode === 'vehicle' ? wgs84ToGcj02(longitude, latitude) : [longitude, latitude];
}
function navigationUrlForEntity(mode, entity) {
const coordinates = navigationCoordinates(mode, entity);
if (!coordinates?.every(Number.isFinite)) return null;
const name = mode === 'station'
? entity?.name || entity?.shortName || i18n[currentLang].detailStation
: entity?.plateNumber || entity?.vin || i18n[currentLang].detailVehicle;
const [longitude, latitude] = coordinates.map(value => Number(value).toFixed(6));
return `https://uri.amap.com/navigation?to=${longitude},${latitude},${encodeURIComponent(name)}&mode=car&coordinate=gaode&callnative=1`;
}
function updateDetailNavigation(mode, entity) {
const button = document.getElementById('detailNavigateBtn');
if (!button) return;
const url = navigationUrlForEntity(mode, entity);
const label = i18n[currentLang].detailNavigate;
button.hidden = !url;
button.disabled = !url;
button.dataset.navigationUrl = url || '';
button.setAttribute('aria-label', label);
button.title = label;
}
function navigateToSelectedEntity() {
const url = selectedEntity && navigationUrlForEntity(selectedEntity.mode, selectedEntity.entity);
if (url) window.location.assign(url);
}
function showEntityDetails(mode, entity) { function showEntityDetails(mode, entity) {
if (!entity) return; if (!entity) return;
const dict = i18n[currentLang]; const dict = i18n[currentLang];
@@ -1193,14 +1417,17 @@ function showEntityDetails(mode, entity) {
title.textContent = entity.name || entity.shortName || dict.detailStation; title.textContent = entity.name || entity.shortName || dict.detailStation;
subtitle.textContent = entity.shortName && entity.shortName !== entity.name ? entity.shortName : stationAdministrativePath(entity); subtitle.textContent = entity.shortName && entity.shortName !== entity.name ? entity.shortName : stationAdministrativePath(entity);
const admin = [stationAdministrativePath(entity), stationDistrictName(entity)].filter(Boolean).filter((value, index, items) => index === 0 || !items[0].includes(value)).join(' · '); const admin = [stationAdministrativePath(entity), stationDistrictName(entity)].filter(Boolean).filter((value, index, items) => index === 0 || !items[0].includes(value)).join(' · ');
grid.innerHTML = [ const publicFields = [
detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal), detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal),
detailField(dict.detailAdmin, detailValue(admin)), detailField(dict.detailAdmin, detailValue(admin)),
detailField(dict.detailAddress, detailValue(entity.address), true), detailField(dict.detailAddress, detailValue(entity.address), true),
detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true)
];
const operationalFields = operationalAccessGranted() ? [
detailField(dict.detailMonthlyHydrogen, entity.monthlyHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.monthlyHydrogenKg, 1)} kg`), detailField(dict.detailMonthlyHydrogen, entity.monthlyHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.monthlyHydrogenKg, 1)} kg`),
detailField(dict.detailTotalHydrogen, entity.totalHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.totalHydrogenKg, 1)} kg`, false, true), detailField(dict.detailTotalHydrogen, entity.totalHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.totalHydrogenKg, 1)} kg`, false, true),
detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true) ] : [];
].join(''); grid.innerHTML = [...publicFields, ...operationalFields].join('');
} else { } else {
title.textContent = entity.plateNumber || entity.vin || dict.detailVehicle; title.textContent = entity.plateNumber || entity.vin || dict.detailVehicle;
subtitle.textContent = entity.vin || ''; subtitle.textContent = entity.vin || '';
@@ -1219,6 +1446,7 @@ function showEntityDetails(mode, entity) {
} }
expand.hidden = false; expand.hidden = false;
expand.textContent = dict.detailMore; expand.textContent = dict.detailMore;
updateDetailNavigation(mode, entity);
card.hidden = false; card.hidden = false;
renderAMapMarkers(); renderAMapMarkers();
renderRankingList(currentRankType); renderRankingList(currentRankType);
@@ -1385,17 +1613,27 @@ function updateRankingControls() {
document.getElementById('panelRankTitle').textContent = title; document.getElementById('panelRankTitle').textContent = title;
if (currentMode === 'vehicle') updateStationViewportMeta(0, 0); if (currentMode === 'vehicle') updateStationViewportMeta(0, 0);
document.getElementById('rankPrimaryTab').textContent = currentMode === 'vehicle' ? dict.rankByFleet : dict.rankByStations; document.getElementById('rankPrimaryTab').textContent = currentMode === 'vehicle' ? dict.rankByFleet : dict.rankByStations;
document.getElementById('rankSecondaryTab').textContent = currentMode === 'vehicle' ? dict.rankByDist : dict.rankByHydrogen; document.getElementById('rankSecondaryTab').textContent = currentMode === 'vehicle'
? dict.rankByDist
: operationalAccessGranted() ? dict.rankByHydrogen : dict.authHydrogenRequired;
document.querySelectorAll('.gtab').forEach(button => button.classList.toggle('active', button.id === (currentRankType === 'primary' ? 'rankPrimaryTab' : 'rankSecondaryTab'))); document.querySelectorAll('.gtab').forEach(button => button.classList.toggle('active', button.id === (currentRankType === 'primary' ? 'rankPrimaryTab' : 'rankSecondaryTab')));
} }
function switchRankTab(type) { function switchRankTab(type) {
if (currentMode === 'station' && type === 'secondary' && !operationalAccessGranted()) {
openAccessDialog('station', i18n[currentLang].authHydrogenRequired);
return;
}
currentRankType = type; currentRankType = type;
updateRankingControls(); updateRankingControls();
renderRankingList(type); renderRankingList(type);
} }
function switchMode(mode) { function switchMode(mode, { skipAuthCheck = false } = {}) {
if (mode === 'vehicle' && !skipAuthCheck && !operationalAccessGranted()) {
openAccessDialog('vehicle', i18n[currentLang].authRequired);
return;
}
currentMode = mode; currentMode = mode;
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = ''; filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
closeExploreSuggestions(); closeExploreSuggestions();
@@ -1409,6 +1647,7 @@ function switchMode(mode) {
updateLocateButton(userLocation ? 'active' : 'idle'); updateLocateButton(userLocation ? 'active' : 'idle');
updateDashboardUI(); updateDashboardUI();
if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard); if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard);
else refreshStationViewport();
} }
function setLanguage(lang) { function setLanguage(lang) {
@@ -1420,6 +1659,9 @@ function setLanguage(lang) {
document.querySelectorAll('[data-i18n]').forEach(element => { document.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n'); if (dict[key]) element.textContent = dict[key]; const key = element.getAttribute('data-i18n'); if (dict[key]) element.textContent = dict[key];
}); });
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
const key = element.getAttribute('data-i18n-placeholder'); if (dict[key]) element.placeholder = dict[key];
});
const brandLogo = document.getElementById('brandLogo'); const brandLogo = document.getElementById('brandLogo');
const brandLogoEnglish = document.getElementById('brandLogoEnglish'); const brandLogoEnglish = document.getElementById('brandLogoEnglish');
if (brandLogo) { if (brandLogo) {
@@ -1430,6 +1672,7 @@ function setLanguage(lang) {
} }
void syncFilterControls(dashboard); void syncFilterControls(dashboard);
updateLocateButton(userLocation ? 'active' : 'idle'); updateLocateButton(userLocation ? 'active' : 'idle');
updateAccessUI();
updateDashboardUI(); updateDashboardUI();
if (selectedEntity?.entity) showEntityDetails(selectedEntity.mode, selectedEntity.entity); if (selectedEntity?.entity) showEntityDetails(selectedEntity.mode, selectedEntity.entity);
} }
+1 -1
View File
@@ -40,7 +40,7 @@ systemctl restart "$service"
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if systemctl is-active --quiet "$service" && curl -fsS "$base_url/api/health" >/dev/null; then if systemctl is-active --quiet "$service" && curl -fsS "$base_url/api/health" >/dev/null; then
curl -fsS "$base_url/" | grep -q 'id="dashboardTitle"' curl -fsS "$base_url/" | grep -q 'id="dashboardTitle"'
curl -fsS "$base_url/api/dashboard" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400' curl -fsS "$base_url/api/public/stations" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400'
printf 'vehicle_map_release_install=ok release=%s\n' "$release_id" printf 'vehicle_map_release_install=ok release=%s\n' "$release_id"
exit 0 exit 0
fi fi
+33 -5
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>羚牛氢能 - 车辆网络 | Lingniu H2 Executive Cockpit</title> <title>羚牛氢能 - 车辆网络 | Lingniu H2 Executive Cockpit</title>
<!-- Google Fonts: Inter & JetBrains Mono --> <!-- Google Fonts: Inter & JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -61,8 +61,8 @@
<!-- Header Controls: Mode Switcher, Theme Switcher & Language Switcher --> <!-- Header Controls: Mode Switcher, Theme Switcher & Language Switcher -->
<div class="header-controls"> <div class="header-controls">
<div class="glass-segmented-control"> <div class="glass-segmented-control">
<button class="segment-btn active" id="btnModeVehicle" onclick="switchMode('vehicle')" data-i18n="btnVehicle">车辆</button> <button class="segment-btn active" id="btnModeStation" onclick="switchMode('station')" data-i18n="btnStation">加氢站</button>
<button class="segment-btn" id="btnModeStation" onclick="switchMode('station')" data-i18n="btnStation">加氢站</button> <button class="segment-btn is-locked" id="btnModeVehicle" onclick="switchMode('vehicle')" data-i18n="btnVehicle">车辆</button>
</div> </div>
<!-- Bilingual ZH / EN Switcher --> <!-- Bilingual ZH / EN Switcher -->
@@ -95,6 +95,8 @@
</button> </button>
</div> </div>
<button class="access-trigger" id="accessTrigger" type="button" onclick="toggleOperationsAccess()" data-i18n="authEntry">运营授权</button>
<div class="time-widget" id="clockTime">19:04:00</div> <div class="time-widget" id="clockTime">19:04:00</div>
</div> </div>
</header> </header>
@@ -150,7 +152,12 @@
<h2 id="detailTitle"></h2> <h2 id="detailTitle"></h2>
<p id="detailSubtitle"></p> <p id="detailSubtitle"></p>
</div> </div>
<button class="detail-close-btn" type="button" onclick="closeEntityDetails()" aria-label="关闭详情">×</button> <div class="detail-card-actions">
<button class="detail-navigate-btn" id="detailNavigateBtn" type="button" onclick="navigateToSelectedEntity()" aria-label="导航" title="导航" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.7 3.3 14.4 20.1a1.2 1.2 0 0 1-2.2.1l-2.6-5.8-5.8-2.6a1.2 1.2 0 0 1 .1-2.2l16.8-6.3Z"></path><path d="m9.5 14.5 4.9-4.9"></path></svg>
</button>
<button class="detail-close-btn" type="button" onclick="closeEntityDetails()" aria-label="关闭详情">×</button>
</div>
</div> </div>
<dl class="detail-grid" id="detailGrid"></dl> <dl class="detail-grid" id="detailGrid"></dl>
<button class="detail-expand-btn" id="detailExpandBtn" type="button" onclick="toggleEntityDetails()" hidden>展开详情</button> <button class="detail-expand-btn" id="detailExpandBtn" type="button" onclick="toggleEntityDetails()" hidden>展开详情</button>
@@ -228,7 +235,28 @@
</div> </div>
<script src="node_modules/pinyin-pro/dist/index.js?v=3.28.2"></script> <dialog class="access-dialog" id="accessDialog" aria-labelledby="accessDialogTitle">
<form class="access-dialog-card" method="dialog" onsubmit="event.preventDefault(); authorizeOperationsAccess();">
<div class="access-dialog-icon" aria-hidden="true"></div>
<div class="access-dialog-heading">
<h2 id="accessDialogTitle" data-i18n="authTitle">进入运营数据</h2>
<p id="accessDialogHint" data-i18n="authDescription">车辆全量信息与加氢量仅向获得授权的用户展示。</p>
</div>
<p class="access-session-note" data-i18n="authExpiry">本次授权有效期为 30 分钟,到期后将自动恢复为公开站点视图。</p>
<label class="access-code-field" for="accessCodeInput">
<span data-i18n="authCodeLabel">访问码</span>
<input id="accessCodeInput" type="password" autocomplete="current-password" enterkeyhint="done" data-i18n-placeholder="authCodePlaceholder" placeholder="输入访问码">
</label>
<p class="access-dialog-error" id="accessDialogError" role="alert" hidden></p>
<div class="access-dialog-actions">
<button type="button" class="access-cancel-btn" onclick="closeAccessDialog()" data-i18n="authCancel">暂不授权</button>
<button type="submit" class="access-submit-btn" id="accessSubmitBtn" data-i18n="authSubmit">验证并进入</button>
</div>
</form>
</dialog>
<!-- 生产发布包不携带 node_modules;固定版本的 CDN 让中文地点保持拼音/首字母搜索能力。 -->
<script src="https://cdn.jsdelivr.net/npm/pinyin-pro@3.28.2/dist/index.js" crossorigin="anonymous"></script>
<script src="app.js?v=20260812113000"></script> <script src="app.js?v=20260812113000"></script>
</body> </body>
</html> </html>
+233 -10
View File
@@ -1,6 +1,10 @@
"""Vehicle Map static server and server-side proxy for the vehicle open platform.""" """Vehicle Map static server and server-side proxy for the vehicle open platform."""
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import base64
import binascii
import hashlib
import hmac
from http.server import HTTPServer, SimpleHTTPRequestHandler from http.server import HTTPServer, SimpleHTTPRequestHandler
import json import json
import os import os
@@ -9,6 +13,7 @@ import threading
import time import time
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@@ -20,11 +25,134 @@ OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15")) UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "5")) DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "5"))
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600")) STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600"))
AUTH_PROVIDER = os.getenv("VEHICLE_MAP_AUTH_PROVIDER", "local").strip().lower()
LOCAL_ACCESS_CODE = os.getenv("VEHICLE_MAP_ACCESS_CODE", "").strip()
SESSION_SECRET = os.getenv("VEHICLE_MAP_SESSION_SECRET", "").encode("utf-8")
SESSION_TTL_SECONDS = int(os.getenv("VEHICLE_MAP_SESSION_TTL_SECONDS", "1800"))
COOKIE_SECURE = os.getenv("VEHICLE_MAP_COOKIE_SECURE", "true").strip().lower() not in {"0", "false", "no"}
AUTH_CENTER_INTROSPECTION_URL = os.getenv("VEHICLE_MAP_AUTH_CENTER_INTROSPECTION_URL", "").strip()
AUTH_CENTER_CLIENT_TOKEN = os.getenv("VEHICLE_MAP_AUTH_CENTER_CLIENT_TOKEN", "").strip()
OPERATIONS_READ_SCOPE = "operations:read"
SESSION_COOKIE_NAME = "ln_map_session"
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
_cache = {} _cache = {}
class AuthenticationError(RuntimeError):
"""The request does not carry a valid operations-read session."""
class AuthenticationUnavailable(RuntimeError):
"""The selected authentication provider is not configured."""
class AccessAuthorizer:
"""Small provider boundary: replace this class when the auth center contract is ready."""
def authorize_access_code(self, access_code):
raise NotImplementedError
class LocalAccessCodeAuthorizer(AccessAuthorizer):
def authorize_access_code(self, access_code):
if not LOCAL_ACCESS_CODE or not SESSION_SECRET:
raise AuthenticationUnavailable("local access-code authentication is not configured")
if not hmac.compare_digest(str(access_code or ""), LOCAL_ACCESS_CODE):
raise AuthenticationError("invalid access code")
return {"subject": "local-access-code", "scopes": [OPERATIONS_READ_SCOPE]}
class AuthCenterAccessCodeAuthorizer(AccessAuthorizer):
"""Adapter for an auth-center access-code introspection endpoint.
The endpoint contract is deliberately small: it receives accessCode and audience,
and returns {active, subject, scopes}. No frontend change is needed when enabled.
"""
def authorize_access_code(self, access_code):
if not AUTH_CENTER_INTROSPECTION_URL:
raise AuthenticationUnavailable("auth-center introspection URL is not configured")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if AUTH_CENTER_CLIENT_TOKEN:
headers["Authorization"] = "Bearer " + AUTH_CENTER_CLIENT_TOKEN
request = Request(
AUTH_CENTER_INTROSPECTION_URL,
data=_json_bytes({"accessCode": str(access_code or ""), "audience": "vehicle-map"}),
method="POST",
headers=headers,
)
try:
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
payload = json.load(response)
except (HTTPError, URLError, ValueError) as exc:
raise AuthenticationUnavailable("auth-center is unavailable") from exc
scopes = payload.get("scopes") or []
if not payload.get("active") or OPERATIONS_READ_SCOPE not in scopes:
raise AuthenticationError("access code does not grant operations read")
return {"subject": str(payload.get("subject") or "auth-center"), "scopes": scopes}
def _authorizer():
if AUTH_PROVIDER == "local":
return LocalAccessCodeAuthorizer()
if AUTH_PROVIDER in {"auth-center", "auth_center"}:
return AuthCenterAccessCodeAuthorizer()
raise AuthenticationUnavailable("unknown authentication provider")
def _b64encode(value):
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def _b64decode(value):
padded = str(value) + "=" * (-len(str(value)) % 4)
return base64.urlsafe_b64decode(padded.encode("ascii"))
def _create_session(principal):
if not SESSION_SECRET:
raise AuthenticationUnavailable("session signing secret is not configured")
payload = {
# Incrementing the session format invalidates earlier long-lived sessions.
"v": 2,
"sub": principal["subject"],
"scopes": principal.get("scopes") or [],
"exp": int(time.time()) + max(300, SESSION_TTL_SECONDS),
}
encoded = _b64encode(_json_bytes(payload))
signature = _b64encode(hmac.new(SESSION_SECRET, encoded.encode("ascii"), hashlib.sha256).digest())
return encoded + "." + signature
def _read_session(cookie_header):
cookies = {}
for item in str(cookie_header or "").split(";"):
if "=" in item:
key, value = item.strip().split("=", 1)
cookies[key] = value
token = cookies.get(SESSION_COOKIE_NAME, "")
try:
encoded, signature = token.split(".", 1)
expected = _b64encode(hmac.new(SESSION_SECRET, encoded.encode("ascii"), hashlib.sha256).digest())
if not SESSION_SECRET or not hmac.compare_digest(signature, expected):
return None
payload = json.loads(_b64decode(encoded))
if payload.get("v") != 2 or int(payload.get("exp", 0)) <= int(time.time()):
return None
if OPERATIONS_READ_SCOPE not in (payload.get("scopes") or []):
return None
return {"subject": str(payload.get("sub") or ""), "scopes": payload["scopes"]}
except (ValueError, TypeError, UnicodeDecodeError, binascii.Error, json.JSONDecodeError):
return None
def _session_cookie(value, max_age):
secure = "; Secure" if COOKIE_SECURE else ""
return f"{SESSION_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Lax{secure}"
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True daemon_threads = True
allow_reuse_address = True allow_reuse_address = True
@@ -73,6 +201,37 @@ def _cached(key, ttl_seconds, loader):
return value return value
def _load_stations():
return _cached(
"stations",
STATION_CACHE_SECONDS,
lambda: _post_open_platform("/api/v1/hydrogen-stations/query", {}),
)
def _public_station(station):
"""Public station directory contract. Keep this a positive field allowlist."""
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_public_stations():
stations = _load_stations()
return {
"status": "ok",
"access": {"level": "public", "stations": "directory", "vehicles": "restricted", "hydrogen": "restricted"},
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"summary": {
"totalStations": len(stations),
"cooperativeStations": sum(1 for station in stations if station.get("cooperative")),
},
"stations": [_public_station(station) for station in stations],
}
def _load_dashboard(): def _load_dashboard():
today = time.strftime("%Y-%m-%d", time.localtime()) today = time.strftime("%Y-%m-%d", time.localtime())
with ThreadPoolExecutor(max_workers=3) as executor: with ThreadPoolExecutor(max_workers=3) as executor:
@@ -82,12 +241,7 @@ def _load_dashboard():
mileage_future = executor.submit( mileage_future = executor.submit(
_post_open_platform, "/api/v1/vehicles/mileage/query", {"date": today} _post_open_platform, "/api/v1/vehicles/mileage/query", {"date": today}
) )
stations_future = executor.submit( stations_future = executor.submit(_load_stations)
_cached,
"stations",
STATION_CACHE_SECONDS,
lambda: _post_open_platform("/api/v1/hydrogen-stations/query", {}),
)
vehicles = realtime_future.result() vehicles = realtime_future.result()
mileage_rows = mileage_future.result() mileage_rows = mileage_future.result()
stations = stations_future.result() stations = stations_future.result()
@@ -138,14 +292,38 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
server_version = "LingniuVehicleMap/1.0" server_version = "LingniuVehicleMap/1.0"
def do_GET(self): def do_GET(self):
if self.path == "/api/health": path = urlparse(self.path).path
if path == "/api/health":
self._write_json(200, { self._write_json(200, {
"status": "ok", "status": "ok",
"service": "vehicle-map", "service": "vehicle-map",
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY), "openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
"authProvider": AUTH_PROVIDER,
}) })
return return
if self.path == "/api/dashboard": if path == "/api/public/stations":
try:
payload = _cached("public-stations", DASHBOARD_CACHE_SECONDS, _load_public_stations)
self._write_json(200, payload)
except Exception as exc: # keep upstream details server-side only
self.log_error("public station refresh failed: %s", exc)
self._write_json(502, {
"status": "error",
"code": "UPSTREAM_UNAVAILABLE",
"message": "加氢站目录暂时不可用,请稍后重试",
})
return
if path == "/api/session":
principal = _read_session(self.headers.get("Cookie"))
self._write_json(200, {
"status": "ok",
"authorized": bool(principal),
"principal": principal,
})
return
if path == "/api/dashboard":
if not self._require_operations_access():
return
try: try:
payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard) payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
self._write_json(200, payload) self._write_json(200, payload)
@@ -154,11 +332,54 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
self._write_json(502, { self._write_json(502, {
"status": "error", "status": "error",
"code": "UPSTREAM_UNAVAILABLE", "code": "UPSTREAM_UNAVAILABLE",
"message": "车辆数据暂时不可用,请稍后重试", "message": "运营数据暂时不可用,请稍后重试",
}) })
return return
super().do_GET() super().do_GET()
def do_POST(self):
path = urlparse(self.path).path
if path != "/api/session":
self._write_json(404, {"status": "error", "code": "NOT_FOUND"})
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
if content_length < 1 or content_length > 4096:
raise ValueError("invalid request length")
payload = json.loads(self.rfile.read(content_length))
principal = _authorizer().authorize_access_code(payload.get("accessCode"))
session = _create_session(principal)
except AuthenticationError:
self._write_json(401, {"status": "error", "code": "ACCESS_DENIED", "message": "访问码无效"})
return
except AuthenticationUnavailable:
self._write_json(503, {"status": "error", "code": "AUTH_UNAVAILABLE", "message": "授权服务暂不可用"})
return
except (ValueError, TypeError, json.JSONDecodeError):
self._write_json(400, {"status": "error", "code": "INVALID_REQUEST", "message": "访问码格式不正确"})
return
self._write_json(200, {
"status": "ok",
"authorized": True,
"principal": principal,
}, headers={"Set-Cookie": _session_cookie(session, max(300, SESSION_TTL_SECONDS))})
def do_DELETE(self):
if urlparse(self.path).path != "/api/session":
self._write_json(404, {"status": "error", "code": "NOT_FOUND"})
return
self._write_json(200, {"status": "ok", "authorized": False}, headers={"Set-Cookie": _session_cookie("", 0)})
def _require_operations_access(self):
if _read_session(self.headers.get("Cookie")):
return True
self._write_json(401, {
"status": "error",
"code": "AUTH_REQUIRED",
"message": "运营数据需要授权访问",
})
return False
def end_headers(self): def end_headers(self):
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")): if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
self.send_header("Cache-Control", "no-cache") self.send_header("Cache-Control", "no-cache")
@@ -167,12 +388,14 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
self.send_header("X-Frame-Options", "SAMEORIGIN") self.send_header("X-Frame-Options", "SAMEORIGIN")
super().end_headers() super().end_headers()
def _write_json(self, status, payload): def _write_json(self, status, payload, headers=None):
body = _json_bytes(payload) body = _json_bytes(payload)
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
+99 -1
View File
@@ -290,6 +290,80 @@ body {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
} }
.segment-btn.is-locked::after {
content: '⌁';
margin-left: 1px;
color: var(--text-sub);
font-size: 10px;
}
.access-trigger {
min-height: 28px;
padding: 0 10px;
border: 1px solid rgba(0, 113, 67, 0.18);
border-radius: 8px;
background: rgba(0, 113, 67, 0.07);
color: var(--accent-primary);
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
.access-trigger:hover { border-color: rgba(0, 113, 67, 0.34); background: rgba(0, 113, 67, 0.12); transform: translateY(-1px); }
.access-trigger.is-authorized { border-color: rgba(2, 132, 199, 0.25); background: rgba(2, 132, 199, 0.09); color: var(--accent-blue); }
.access-dialog {
position: fixed;
inset: 0;
margin: auto;
width: min(390px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: 0;
border: 0;
border-radius: 20px;
background: transparent;
color: var(--text-main);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.3);
}
.access-dialog::backdrop { background: rgba(15, 23, 42, 0.42); backdrop-filter: blur(5px); }
.access-dialog-card {
display: grid;
gap: 16px;
padding: 22px;
border: 1px solid var(--glass-border);
border-radius: inherit;
background: color-mix(in srgb, var(--glass-bg) 98%, white);
}
.access-dialog-icon {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 12px;
background: rgba(0, 113, 67, 0.1);
color: var(--accent-primary);
font-size: 21px;
font-weight: 800;
}
.access-dialog-heading h2 { margin: 0; font-size: 18px; letter-spacing: -0.02em; }
.access-dialog-heading p { margin: 6px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.6; }
.access-session-note { margin: -7px 0 0; padding: 9px 10px; border-radius: 10px; background: rgba(2, 132, 199, 0.08); color: #0369a1; font-size: 11px; line-height: 1.55; }
.access-code-field { display: grid; gap: 7px; color: var(--text-muted); font-size: 11px; font-weight: 700; }
.access-code-field input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--glass-border); border-radius: 10px; outline: 0; background: var(--pill-bg); color: var(--text-main); font: 13px/1.2 'JetBrains Mono', monospace; box-sizing: border-box; }
.access-code-field input:focus { border-color: rgba(2, 132, 199, 0.5); box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.1); }
.access-dialog-error { margin: -6px 0 0; color: #dc2626; font-size: 11px; }
.access-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.access-cancel-btn, .access-submit-btn { min-height: 36px; padding: 0 13px; border-radius: 9px; font-size: 12px; font-weight: 700; cursor: pointer; }
.access-cancel-btn { border: 1px solid var(--glass-border); background: transparent; color: var(--text-muted); }
.access-submit-btn { border: 1px solid var(--accent-primary); background: var(--accent-primary); color: white; }
.access-submit-btn:disabled { opacity: 0.58; cursor: wait; }
.bw-svg { .bw-svg {
stroke: currentColor; stroke: currentColor;
} }
@@ -766,6 +840,7 @@ body {
} }
.detail-title-wrap { min-width: 0; } .detail-title-wrap { min-width: 0; }
.detail-card-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.detail-type-pill { .detail-type-pill {
display: inline-flex; display: inline-flex;
@@ -818,6 +893,24 @@ body {
cursor: pointer; cursor: pointer;
} }
.detail-navigate-btn {
display: grid;
width: 26px;
height: 26px;
flex: 0 0 26px;
place-items: center;
border: 1px solid rgba(0, 113, 67, 0.16);
border-radius: 50%;
background: rgba(0, 113, 67, 0.09);
color: var(--accent-primary);
cursor: pointer;
transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
.detail-navigate-btn:hover { transform: translateY(-1px); border-color: rgba(0, 113, 67, 0.34); background: rgba(0, 113, 67, 0.15); }
.detail-navigate-btn:disabled { cursor: default; opacity: 0.45; }
.detail-navigate-btn svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.9; }
.detail-grid { .detail-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1369,11 +1462,16 @@ body {
} }
.segment-btn, .segment-btn,
.bw-btn { .bw-btn,
.access-trigger {
padding: 4px 8px; padding: 4px 8px;
font-size: 10px; font-size: 10px;
} }
.access-trigger { min-height: 26px; }
.access-dialog-card { padding: 18px; }
.time-widget { .time-widget {
display: none; display: none;
} }
+28
View File
@@ -80,6 +80,29 @@ assert.equal(points.length, 2);
assert.ok(points.every(node => node.kind === 'vehiclePoint')); assert.ok(points.every(node => node.kind === 'vehiclePoint'));
assert.equal(points.find(node => node.nameZh === '粤A00001').online, 1); assert.equal(points.find(node => node.nameZh === '粤A00001').online, 1);
// A selected district is a semantic filter, not a viewport filter. Its full
// fleet must survive a camera fit even when a valid vehicle sits outside the
// initial district-centroid viewport.
currentMode = 'vehicle';
dashboard = {
vehicles: [
{ vin: 'VIN-BY-1', plateNumber: '粤A10001', activeToday: true, locationAvailable: true, longitude: 113.22, latitude: 23.18 },
{ vin: 'VIN-BY-2', plateNumber: '粤A10002', activeToday: true, locationAvailable: true, longitude: 113.41, latitude: 23.38 }
]
};
vehicleFilterGroups.districts = [{ adcode: '440111', vehicles: dashboard.vehicles }];
filterState.district = '440111';
map = {
getZoom: () => 11.6,
getBounds: () => ({
getSouthWest: () => ({ getLng: () => 113.1, getLat: () => 23.1 }),
getNorthEast: () => ({ getLng: () => 113.3, getLat: () => 23.3 })
})
};
assert.equal(hierarchyLevelForZoom(), 'vehicle');
assert.equal(vehiclePointNodes().length, 2);
filterState.district = '';
dashboard = { dashboard = {
stations: [ stations: [
{ id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', address: '广东省广州市黄埔区开源大道1号', longitude: 113.2, latitude: 23.1, cooperative: true, totalHydrogenKg: 20 }, { id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', address: '广东省广州市黄埔区开源大道1号', longitude: 113.2, latitude: 23.1, cooperative: true, totalHydrogenKg: 20 },
@@ -110,6 +133,11 @@ map = {
assert.equal(stationNodes(false).length, 3); assert.equal(stationNodes(false).length, 3);
assert.equal(stationNodes(true).length, 2); assert.equal(stationNodes(true).length, 2);
assert.deepEqual(stationNodes(true).map(node => node.name).sort(), ['佛山外部站', '广州合作站']); assert.deepEqual(stationNodes(true).map(node => node.name).sort(), ['佛山外部站', '广州合作站']);
const stationNavigationUrl = navigationUrlForEntity('station', dashboard.stations[0]);
assert.ok(stationNavigationUrl.startsWith('https://uri.amap.com/navigation?to=113.200000,23.100000,'));
assert.match(stationNavigationUrl, /%E5%B9%BF%E5%B7%9E/);
assert.match(stationNavigationUrl, /coordinate=gaode&callnative=1$/);
assert.equal(navigationUrlForEntity('vehicle', { plateNumber: '无位置车辆' }), null);
const stationView = stationViewportSummary(); const stationView = stationViewportSummary();
assert.equal(stationView.level, 'station'); assert.equal(stationView.level, 'station');
assert.equal(stationView.visibleNodes.length, 2); assert.equal(stationView.visibleNodes.length, 2);
+47
View File
@@ -1,5 +1,9 @@
import importlib.util import importlib.util
import hashlib
import hmac
import json
from pathlib import Path from pathlib import Path
import time
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -41,6 +45,49 @@ class DashboardTest(unittest.TestCase):
self.assertEqual(result["vehicles"][0]["dailyMileageKm"], 12.345) self.assertEqual(result["vehicles"][0]["dailyMileageKm"], 12.345)
self.assertEqual(result["vehicles"][2]["dailyMileageKm"], 0) self.assertEqual(result["vehicles"][2]["dailyMileageKm"], 0)
def test_public_station_directory_uses_an_allowlist(self):
stations = [{
"id": "GD-1", "name": "广州合作站", "shortName": "合作站", "province": "广东省",
"city": "广州市", "district": "黄埔区", "address": "开源大道1号", "longitude": 113.2,
"latitude": 23.1, "cooperative": True, "monthlyHydrogenKg": 88.8,
"totalHydrogenKg": 999.9, "internalOwner": "must-not-leak",
}]
with patch.object(server, "_post_open_platform", return_value=stations):
with patch.object(server, "_cache", {}):
result = server._load_public_stations()
station = result["stations"][0]
self.assertEqual(result["access"]["level"], "public")
self.assertEqual(station["name"], "广州合作站")
self.assertTrue(station["cooperative"])
self.assertNotIn("monthlyHydrogenKg", station)
self.assertNotIn("totalHydrogenKg", station)
self.assertNotIn("internalOwner", station)
def test_signed_operations_session_requires_the_operations_scope(self):
with patch.object(server, "LOCAL_ACCESS_CODE", "LN-map-test-code"), patch.object(server, "SESSION_SECRET", b"test-session-secret"), patch.object(server, "SESSION_TTL_SECONDS", 600):
principal = server._authorizer().authorize_access_code("LN-map-test-code")
token = server._create_session(principal)
session = server._read_session(f"{server.SESSION_COOKIE_NAME}={token}")
self.assertEqual(session["subject"], "local-access-code")
self.assertIn(server.OPERATIONS_READ_SCOPE, session["scopes"])
self.assertIsNone(server._read_session(f"{server.SESSION_COOKIE_NAME}={token}tampered"))
legacy_payload = {"v": 1, "sub": "legacy", "scopes": [server.OPERATIONS_READ_SCOPE], "exp": 4_102_444_800}
legacy_encoded = server._b64encode(server._json_bytes(legacy_payload))
legacy_signature = server._b64encode(hmac.new(server.SESSION_SECRET, legacy_encoded.encode("ascii"), hashlib.sha256).digest())
self.assertIsNone(server._read_session(f"{server.SESSION_COOKIE_NAME}={legacy_encoded}.{legacy_signature}"))
def test_default_operations_session_expires_after_thirty_minutes(self):
self.assertEqual(server.SESSION_TTL_SECONDS, 1800)
with patch.object(server, "SESSION_SECRET", b"test-session-secret"), patch.object(server, "SESSION_TTL_SECONDS", 1800):
token = server._create_session({"subject": "test", "scopes": [server.OPERATIONS_READ_SCOPE]})
encoded, _ = token.split(".", 1)
payload = json.loads(server._b64decode(encoded))
self.assertGreaterEqual(payload["exp"], int(time.time()) + 1798)
self.assertLessEqual(payload["exp"], int(time.time()) + 1801)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()