diff --git a/vehicle-map/.env.example b/vehicle-map/.env.example index 4969dfa3..84193119 100644 --- a/vehicle-map/.env.example +++ b/vehicle-map/.env.example @@ -5,3 +5,15 @@ OPEN_PLATFORM_APP_KEY=replace-with-32-character-app-key UPSTREAM_TIMEOUT_SECONDS=15 DASHBOARD_CACHE_SECONDS=5 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= diff --git a/vehicle-map/README.md b/vehicle-map/README.md index d9691009..a0735699 100644 --- a/vehicle-map/README.md +++ b/vehicle-map/README.md @@ -19,6 +19,28 @@ python3 server.py 默认地址:`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`:全部授权车辆实时位置与状态; diff --git a/vehicle-map/app.js b/vehicle-map/app.js index 4e99dfbb..bc014f25 100644 --- a/vehicle-map/app.js +++ b/vehicle-map/app.js @@ -22,11 +22,15 @@ const i18n = { filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站', detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线', detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标', - detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', + detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航', valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试', locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试', 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: { 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', 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', - 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.', 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', - 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 infoWindowList = []; let is3DPitch = true; -let currentMode = 'vehicle'; +let currentMode = 'station'; let currentTheme = 'theme-white'; let currentLang = 'zh'; let currentRankType = 'primary'; @@ -109,6 +117,7 @@ let exploreSuggestionItems = []; let activeExploreSuggestionIndex = -1; let detailExpanded = false; 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 MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']); @@ -116,27 +125,75 @@ const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']); document.addEventListener('DOMContentLoaded', () => { initClock(); initAMapInstance(); - loadDashboard(); - refreshTimer = window.setInterval(loadDashboard, 15000); + initializeDataAccess(); }); document.addEventListener('pointerdown', event => { 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'); try { const response = await fetch('/api/dashboard', { headers: { Accept: 'application/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}`); dashboard = payload; void syncFilterControls(payload); updateDashboardUI(); - refreshVehicleRegionNodes(payload); + if (currentMode === 'vehicle') refreshVehicleRegionNodes(payload); + else refreshStationViewport(); setDataState('ready'); } catch (error) { - console.error('dashboard refresh failed', error); + console.error('operational dashboard refresh failed', error); setDataState('error'); } } @@ -148,6 +205,97 @@ function setDataState(state) { 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) { return String(value || '').trim().toLocaleLowerCase(); } @@ -181,6 +329,10 @@ function selectedVehicleFilterGroup() { return null; } +function hasPinnedVehicleDistrictFilter() { + return currentMode === 'vehicle' && Boolean(filterState.district) && !normalizeSearch(filterState.query); +} + function filteredVehicles(vehicles = dashboard?.vehicles || []) { const group = selectedVehicleFilterGroup(); 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]); } +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) { if (!map) return; if (currentMode === 'vehicle') { 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); return; } @@ -682,10 +864,12 @@ function updateDashboardUI() { document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} ${countUnit(active, currentMode, dict)} (${activeRate}%)`; const activity = currentMode === 'vehicle' ? { 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('kpiDailyDist').innerHTML = activity.value == null - ? `— ${activity.unit}` + ? `—${activity.unit ? ` ${activity.unit}` : ''}` : `${formatNumber(activity.value, 1)} ${activity.unit}`; const regionSummary = buildVehicleRegionNodes(); document.getElementById('mapStatusText').textContent = mapStatusSummaryText(summary, regionSummary, dashboard.asOf); @@ -700,9 +884,12 @@ function mapStatusSummaryText(summary, regionSummary, asOf, mode = currentMode, if (mode === 'station') { const cooperative = Number(summary.cooperativeStations || 0); const external = Math.max(0, Number(summary.totalStations || 0) - cooperative); + const accessHint = operationalAccessGranted() + ? '' + : lang === 'zh' ? ' · 站点目录公开展示' : ' · public station directory'; return lang === 'zh' - ? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点 · ${asOf}` - : `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations · ${asOf}`; + ? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点${accessHint} · ${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); return lang === 'zh' @@ -818,6 +1005,7 @@ function stationAdministrativePath(station, lang = currentLang) { } function hierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) { + if (hasPinnedVehicleDistrictFilter()) return 'vehicle'; if (zoom < REGION_ZOOM.city) return 'province'; if (zoom < REGION_ZOOM.district) return 'city'; if (zoom < REGION_ZOOM.vehicle) return 'district'; @@ -1005,7 +1193,7 @@ function vehiclePointNodes(vehicles = filteredVehicles()) { return locatedVehicles(vehicles).map(vehicle => { const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)); 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 }) => ({ id: `vehicle-point-${vehicle.vin}`, kind: 'vehiclePoint', level: 'vehicle', nameZh: vehicle.plateNumber || vehicle.vin, nameEn: vehicle.plateNumber || vehicle.vin, @@ -1171,6 +1359,42 @@ function vehicleStatusLabel(vehicle) { 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) { if (!entity) return; const dict = i18n[currentLang]; @@ -1193,14 +1417,17 @@ function showEntityDetails(mode, entity) { title.textContent = entity.name || entity.shortName || dict.detailStation; 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(' · '); - grid.innerHTML = [ + const publicFields = [ detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal), detailField(dict.detailAdmin, detailValue(admin)), 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.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 { title.textContent = entity.plateNumber || entity.vin || dict.detailVehicle; subtitle.textContent = entity.vin || ''; @@ -1219,6 +1446,7 @@ function showEntityDetails(mode, entity) { } expand.hidden = false; expand.textContent = dict.detailMore; + updateDetailNavigation(mode, entity); card.hidden = false; renderAMapMarkers(); renderRankingList(currentRankType); @@ -1385,17 +1613,27 @@ function updateRankingControls() { document.getElementById('panelRankTitle').textContent = title; if (currentMode === 'vehicle') updateStationViewportMeta(0, 0); 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'))); } function switchRankTab(type) { + if (currentMode === 'station' && type === 'secondary' && !operationalAccessGranted()) { + openAccessDialog('station', i18n[currentLang].authHydrogenRequired); + return; + } currentRankType = type; updateRankingControls(); renderRankingList(type); } -function switchMode(mode) { +function switchMode(mode, { skipAuthCheck = false } = {}) { + if (mode === 'vehicle' && !skipAuthCheck && !operationalAccessGranted()) { + openAccessDialog('vehicle', i18n[currentLang].authRequired); + return; + } currentMode = mode; filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = ''; closeExploreSuggestions(); @@ -1409,6 +1647,7 @@ function switchMode(mode) { updateLocateButton(userLocation ? 'active' : 'idle'); updateDashboardUI(); if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard); + else refreshStationViewport(); } function setLanguage(lang) { @@ -1420,6 +1659,9 @@ function setLanguage(lang) { document.querySelectorAll('[data-i18n]').forEach(element => { 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 brandLogoEnglish = document.getElementById('brandLogoEnglish'); if (brandLogo) { @@ -1430,6 +1672,7 @@ function setLanguage(lang) { } void syncFilterControls(dashboard); updateLocateButton(userLocation ? 'active' : 'idle'); + updateAccessUI(); updateDashboardUI(); if (selectedEntity?.entity) showEntityDetails(selectedEntity.mode, selectedEntity.entity); } diff --git a/vehicle-map/deploy/install-release.sh b/vehicle-map/deploy/install-release.sh index c522db80..6cd8ba4a 100644 --- a/vehicle-map/deploy/install-release.sh +++ b/vehicle-map/deploy/install-release.sh @@ -40,7 +40,7 @@ systemctl restart "$service" for _ in $(seq 1 30); do 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/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" exit 0 fi diff --git a/vehicle-map/index.html b/vehicle-map/index.html index 27c20f52..e2768866 100644 --- a/vehicle-map/index.html +++ b/vehicle-map/index.html @@ -2,7 +2,7 @@ - + 羚牛氢能 - 车辆网络 | Lingniu H2 Executive Cockpit @@ -61,8 +61,8 @@
- - + +
@@ -95,6 +95,8 @@
+ +
19:04:00
@@ -150,7 +152,12 @@

- +
+ + +
@@ -228,7 +235,28 @@ - + +
+ +
+

进入运营数据

+

车辆全量信息与加氢量仅向获得授权的用户展示。

+
+

本次授权有效期为 30 分钟,到期后将自动恢复为公开站点视图。

+ + +
+ + +
+
+
+ + + diff --git a/vehicle-map/server.py b/vehicle-map/server.py index 407e848b..d248811f 100644 --- a/vehicle-map/server.py +++ b/vehicle-map/server.py @@ -1,6 +1,10 @@ """Vehicle Map static server and server-side proxy for the vehicle open platform.""" from concurrent.futures import ThreadPoolExecutor +import base64 +import binascii +import hashlib +import hmac from http.server import HTTPServer, SimpleHTTPRequestHandler import json import os @@ -9,6 +13,7 @@ import threading import time from socketserver import ThreadingMixIn from urllib.error import HTTPError, URLError +from urllib.parse import urlparse 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")) DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "5")) 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 = {} +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): daemon_threads = True allow_reuse_address = True @@ -73,6 +201,37 @@ def _cached(key, ttl_seconds, loader): 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(): today = time.strftime("%Y-%m-%d", time.localtime()) with ThreadPoolExecutor(max_workers=3) as executor: @@ -82,12 +241,7 @@ def _load_dashboard(): mileage_future = executor.submit( _post_open_platform, "/api/v1/vehicles/mileage/query", {"date": today} ) - stations_future = executor.submit( - _cached, - "stations", - STATION_CACHE_SECONDS, - lambda: _post_open_platform("/api/v1/hydrogen-stations/query", {}), - ) + stations_future = executor.submit(_load_stations) vehicles = realtime_future.result() mileage_rows = mileage_future.result() stations = stations_future.result() @@ -138,14 +292,38 @@ class VehicleMapHandler(SimpleHTTPRequestHandler): server_version = "LingniuVehicleMap/1.0" def do_GET(self): - if self.path == "/api/health": + path = urlparse(self.path).path + if path == "/api/health": self._write_json(200, { "status": "ok", "service": "vehicle-map", "openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY), + "authProvider": AUTH_PROVIDER, }) 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: payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard) self._write_json(200, payload) @@ -154,11 +332,54 @@ class VehicleMapHandler(SimpleHTTPRequestHandler): self._write_json(502, { "status": "error", "code": "UPSTREAM_UNAVAILABLE", - "message": "车辆数据暂时不可用,请稍后重试", + "message": "运营数据暂时不可用,请稍后重试", }) return 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): if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")): self.send_header("Cache-Control", "no-cache") @@ -167,12 +388,14 @@ class VehicleMapHandler(SimpleHTTPRequestHandler): self.send_header("X-Frame-Options", "SAMEORIGIN") super().end_headers() - def _write_json(self, status, payload): + 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() self.wfile.write(body) diff --git a/vehicle-map/styles.css b/vehicle-map/styles.css index 1c156372..34d92650 100644 --- a/vehicle-map/styles.css +++ b/vehicle-map/styles.css @@ -290,6 +290,80 @@ body { 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 { stroke: currentColor; } @@ -766,6 +840,7 @@ body { } .detail-title-wrap { min-width: 0; } +.detail-card-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; } .detail-type-pill { display: inline-flex; @@ -818,6 +893,24 @@ body { 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 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1369,11 +1462,16 @@ body { } .segment-btn, - .bw-btn { + .bw-btn, + .access-trigger { padding: 4px 8px; font-size: 10px; } + .access-trigger { min-height: 26px; } + + .access-dialog-card { padding: 18px; } + .time-widget { display: none; } diff --git a/vehicle-map/tests/test_app.mjs b/vehicle-map/tests/test_app.mjs index 7c587f31..b2ccf73c 100644 --- a/vehicle-map/tests/test_app.mjs +++ b/vehicle-map/tests/test_app.mjs @@ -80,6 +80,29 @@ assert.equal(points.length, 2); assert.ok(points.every(node => node.kind === 'vehiclePoint')); 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 = { stations: [ { 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(true).length, 2); 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(); assert.equal(stationView.level, 'station'); assert.equal(stationView.visibleNodes.length, 2); diff --git a/vehicle-map/tests/test_server.py b/vehicle-map/tests/test_server.py index abc70e38..3661bb50 100644 --- a/vehicle-map/tests/test_server.py +++ b/vehicle-map/tests/test_server.py @@ -1,5 +1,9 @@ import importlib.util +import hashlib +import hmac +import json from pathlib import Path +import time import unittest 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"][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__": unittest.main()