From 357dd490e9f9e8eaa09ac2ccb624ebe652366e15 Mon Sep 17 00:00:00 2001 From: lingniu Date: Tue, 11 Aug 2026 19:24:32 +0800 Subject: [PATCH] refactor(map): remove access gate --- vehicle-map/.env.example | 12 -- vehicle-map/README.md | 25 +-- vehicle-map/app.js | 220 +++++--------------------- vehicle-map/deploy/install-release.sh | 2 +- vehicle-map/index.html | 32 ++-- vehicle-map/server.py | 220 +------------------------- vehicle-map/styles.css | 141 +++++++---------- vehicle-map/tests/test_app.mjs | 18 +++ vehicle-map/tests/test_server.py | 50 +----- 9 files changed, 140 insertions(+), 580 deletions(-) diff --git a/vehicle-map/.env.example b/vehicle-map/.env.example index 84193119..4969dfa3 100644 --- a/vehicle-map/.env.example +++ b/vehicle-map/.env.example @@ -5,15 +5,3 @@ 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 a0735699..ee538505 100644 --- a/vehicle-map/README.md +++ b/vehicle-map/README.md @@ -19,27 +19,12 @@ python3 server.py 默认地址:`http://127.0.0.1:20800`。 -请通过上述 HTTP 地址访问,不能直接双击打开 `index.html`:页面需要同源调用本服务的公开站点与授权 API。 +请通过上述 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`。 +- `GET /api/dashboard` 公开返回车辆信息、站点基础信息及加氢量。 +- 不保存访问码、不创建浏览器会话,也不区分公开或受保护的数据接口。 ## 接口依赖 @@ -52,7 +37,7 @@ VEHICLE_MAP_COOKIE_SECURE=true 本服务提供: - `GET /api/health`:进程及配置状态; -- `GET /api/dashboard`:面向前端的聚合数据,默认缓存5秒;加氢站缓存1小时。 +- `GET /api/dashboard`:面向前端的公开聚合数据,默认缓存5秒;加氢站缓存1小时。 全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。 diff --git a/vehicle-map/app.js b/vehicle-map/app.js index bc014f25..9223d52c 100644 --- a/vehicle-map/app.js +++ b/vehicle-map/app.js @@ -23,14 +23,11 @@ const i18n = { detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线', detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标', detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航', + navigationLaunchingTitle: '正在打开高德地图', navigationLaunchingDescription: '正在准备导航路线,请稍候', valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试', locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试', searchUniversal: '搜索位置、站点、车牌或 VIN', searchLocations: '位置', searchEntities: '业务对象', searchEmpty: '没有匹配的地点或对象', - suggestionLocation: '地点', suggestionVehicle: '车辆', suggestionStation: '加氢站', detailMore: '展开全部信息', detailLess: '收起详细信息', - publicStationTitle: '加氢站网络', publicDataStatus: '公共站点目录 · 位置、地址与合作状态', publicDataSource: '公共站点网络', - authEntry: '运营授权', authGranted: '已授权', authTitle: '进入运营数据', authDescription: '车辆全量信息与加氢量仅向获得授权的用户展示。', - authCodeLabel: '访问码', authCodePlaceholder: '输入访问码', authSubmit: '验证并进入', authCancel: '暂不授权', authRequired: '运营数据需要授权访问', authExpiry: '本次授权有效期为 30 分钟,到期后将自动恢复为公开站点视图。', - authDenied: '访问码无效,请重试', authUnavailable: '授权服务暂不可用,请稍后重试', authHydrogenRequired: '授权后查看加氢量', publicHydrogenHidden: '运营数据受保护', authLogout: '退出授权' + suggestionLocation: '地点', suggestionVehicle: '车辆', suggestionStation: '加氢站', detailMore: '展开全部信息', detailLess: '收起详细信息' }, en: { vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network', @@ -54,14 +51,11 @@ const i18n = { 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', detailNavigate: 'Navigate', + navigationLaunchingTitle: 'Opening AMap', navigationLaunchingDescription: 'Preparing your route. Just a moment.', 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', - 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' + suggestionLocation: 'Location', suggestionVehicle: 'Vehicle', suggestionStation: 'H₂ station', detailMore: 'Show all details', detailLess: 'Collapse details' } }; @@ -116,8 +110,8 @@ let vehicleExploreCatalog = { ready: false, loading: null, options: [], province let exploreSuggestionItems = []; let activeExploreSuggestionIndex = -1; let detailExpanded = false; +let navigationLaunchTimer = null; 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(['北京', '天津', '上海', '重庆']); @@ -125,66 +119,19 @@ const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']); document.addEventListener('DOMContentLoaded', () => { initClock(); initAMapInstance(); - initializeDataAccess(); + loadDashboard(); + refreshTimer = window.setInterval(loadDashboard, 15000); }); document.addEventListener('pointerdown', event => { if (!event.target?.closest?.('.map-explore-toolbar')) closeExploreSuggestions(); }); -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() { +async function loadDashboard() { 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); @@ -193,7 +140,7 @@ async function loadOperationalDashboard() { else refreshStationViewport(); setDataState('ready'); } catch (error) { - console.error('operational dashboard refresh failed', error); + console.error('dashboard refresh failed', error); setDataState('error'); } } @@ -205,97 +152,6 @@ 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(); } @@ -864,9 +720,7 @@ 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' } - : operationalAccessGranted() - ? { label: dict.kpiMonthlyHydrogen, value: summary.monthlyHydrogenKg, unit: 'kg' } - : { label: dict.publicHydrogenHidden, value: null, unit: '' }; + : { label: dict.kpiMonthlyHydrogen, value: summary.monthlyHydrogenKg, unit: 'kg' }; document.getElementById('kpiActivityLabel').textContent = activity.label; document.getElementById('kpiDailyDist').innerHTML = activity.value == null ? `—${activity.unit ? ` ${activity.unit}` : ''}` @@ -884,17 +738,14 @@ 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}座外部站点${accessHint} · ${asOf}` - : `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations${accessHint} · ${asOf}`; + ? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点 · ${asOf}` + : `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations · ${asOf}`; } const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level, lang) : regionSummaryText(regionSummary, lang); return lang === 'zh' - ? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${regionText} · ${asOf}` - : `Open platform synced · ${summary.totalVehicles} authorized vehicles · ${regionText} · ${asOf}`; + ? `开放平台已同步 · ${summary.totalVehicles}辆车辆 · ${regionText} · ${asOf}` + : `Open platform synced · ${summary.totalVehicles} vehicles · ${regionText} · ${asOf}`; } function modeKpiLabels(dict = i18n[currentLang], mode = currentMode) { @@ -1390,9 +1241,31 @@ function updateDetailNavigation(mode, entity) { button.title = label; } +function resetNavigationLaunch() { + const overlay = document.getElementById('navigationLaunchOverlay'); + const button = document.getElementById('detailNavigateBtn'); + overlay?.classList.remove('is-visible'); + window.setTimeout(() => { if (overlay) overlay.hidden = true; }, 180); + button?.classList.remove('is-launching'); + if (button && button.dataset.navigationUrl) button.disabled = false; +} + function navigateToSelectedEntity() { const url = selectedEntity && navigationUrlForEntity(selectedEntity.mode, selectedEntity.entity); - if (url) window.location.assign(url); + if (!url) return; + const overlay = document.getElementById('navigationLaunchOverlay'); + const button = document.getElementById('detailNavigateBtn'); + if (button?.classList.contains('is-launching')) return; + if (navigationLaunchTimer) window.clearTimeout(navigationLaunchTimer); + if (overlay) { + overlay.hidden = false; + window.requestAnimationFrame(() => overlay.classList.add('is-visible')); + } + button?.classList.add('is-launching'); + if (button) button.disabled = true; + // Paint the acknowledgement before Safari hands control to the AMap URL scheme. + navigationLaunchTimer = window.setTimeout(() => window.location.assign(url), 140); + window.setTimeout(resetNavigationLaunch, 4000); } function showEntityDetails(mode, entity) { @@ -1423,11 +1296,11 @@ function showEntityDetails(mode, entity) { detailField(dict.detailAddress, detailValue(entity.address), true), detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true) ]; - const operationalFields = operationalAccessGranted() ? [ + const hydrogenFields = [ 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), - ] : []; - grid.innerHTML = [...publicFields, ...operationalFields].join(''); + ]; + grid.innerHTML = [...publicFields, ...hydrogenFields].join(''); } else { title.textContent = entity.plateNumber || entity.vin || dict.detailVehicle; subtitle.textContent = entity.vin || ''; @@ -1615,25 +1488,17 @@ function updateRankingControls() { document.getElementById('rankPrimaryTab').textContent = currentMode === 'vehicle' ? dict.rankByFleet : dict.rankByStations; document.getElementById('rankSecondaryTab').textContent = currentMode === 'vehicle' ? dict.rankByDist - : operationalAccessGranted() ? dict.rankByHydrogen : dict.authHydrogenRequired; + : dict.rankByHydrogen; 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, { skipAuthCheck = false } = {}) { - if (mode === 'vehicle' && !skipAuthCheck && !operationalAccessGranted()) { - openAccessDialog('vehicle', i18n[currentLang].authRequired); - return; - } +function switchMode(mode) { currentMode = mode; filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = ''; closeExploreSuggestions(); @@ -1672,7 +1537,6 @@ 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 6cd8ba4a..eaf4d015 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/public/stations" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400' + 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 and d["summary"]["totalVehicles"]>1000' 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 4822837c..0cabe105 100644 --- a/vehicle-map/index.html +++ b/vehicle-map/index.html @@ -95,8 +95,6 @@ - -
19:04:00
@@ -144,6 +142,16 @@
+ +