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 @@
+
+
+
+
+ 正在打开高德地图
+ 正在准备导航路线,请稍候
+
+
+
+
-
-
diff --git a/vehicle-map/server.py b/vehicle-map/server.py
index 7df0d764..aa498558 100644
--- a/vehicle-map/server.py
+++ b/vehicle-map/server.py
@@ -1,10 +1,7 @@
"""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
@@ -36,134 +33,10 @@ 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
@@ -220,29 +93,6 @@ def _load_stations():
)
-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:
@@ -262,7 +112,7 @@ def _load_dashboard():
for row in mileage_rows
if row.get("vin") and row.get("status") == "NORMAL"
}
- # The operations card is a daily activity view: every vehicle that reported
+ # The daily activity view places every vehicle that reported
# today is placed in exactly one bucket using its latest selected speed.
active_vehicles = [item for item in vehicles if item.get("activeToday")]
driving = sum(1 for item in active_vehicles if float(item.get("speedKmh") or 0) > 3)
@@ -312,32 +162,9 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
"status": "ok",
"service": "vehicle-map",
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
- "authProvider": AUTH_PROVIDER,
- })
- return
- 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)
@@ -346,54 +173,11 @@ 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 _serve_index(self):
try:
template = (ROOT / "index.html").read_text(encoding="utf-8")
diff --git a/vehicle-map/styles.css b/vehicle-map/styles.css
index 34d92650..a1f409aa 100644
--- a/vehicle-map/styles.css
+++ b/vehicle-map/styles.css
@@ -290,80 +290,6 @@ 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;
}
@@ -795,6 +721,55 @@ body {
height: 100%;
}
+.navigation-launch-overlay {
+ position: absolute;
+ z-index: 28;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 22px;
+ background: rgba(15, 23, 42, 0.14);
+ backdrop-filter: blur(1.5px);
+ -webkit-backdrop-filter: blur(1.5px);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.18s ease;
+}
+
+.navigation-launch-overlay[hidden] { display: none; }
+.navigation-launch-overlay.is-visible { opacity: 1; pointer-events: auto; }
+
+.navigation-launch-panel {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ max-width: min(280px, calc(100vw - 48px));
+ padding: 13px 15px;
+ border: 1px solid rgba(255, 255, 255, 0.64);
+ border-radius: 16px;
+ background: color-mix(in srgb, var(--glass-bg) 94%, white);
+ box-shadow: 0 18px 42px rgba(15, 23, 42, 0.2);
+ transform: translateY(8px) scale(0.97);
+ transition: transform 0.22s cubic-bezier(.2, .75, .25, 1);
+}
+
+.navigation-launch-overlay.is-visible .navigation-launch-panel { transform: translateY(0) scale(1); }
+.navigation-launch-panel strong, .navigation-launch-panel span { display: block; }
+.navigation-launch-panel strong { color: var(--text-main); font-size: 12px; line-height: 1.35; }
+.navigation-launch-panel div > span { margin-top: 2px; color: var(--text-muted); font-size: 10px; line-height: 1.45; }
+
+.navigation-launch-spinner {
+ width: 20px;
+ height: 20px;
+ flex: 0 0 20px;
+ border: 2px solid rgba(0, 113, 67, 0.18);
+ border-top-color: var(--accent-primary);
+ border-radius: 50%;
+ animation: navigation-spin 0.78s linear infinite;
+}
+
+@keyframes navigation-spin { to { transform: rotate(360deg); } }
+
.map-detail-card {
position: absolute;
left: 14px;
@@ -910,6 +885,17 @@ body {
.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-navigate-btn.is-launching { transform: scale(0.92); background: rgba(0, 113, 67, 0.17); }
+.detail-navigate-btn.is-launching svg { animation: navigation-pulse 0.7s ease-in-out infinite alternate; }
+
+@keyframes navigation-pulse { to { transform: scale(1.15); } }
+
+@media (prefers-reduced-motion: reduce) {
+ .navigation-launch-overlay,
+ .navigation-launch-panel { transition: none; }
+ .navigation-launch-spinner,
+ .detail-navigate-btn.is-launching svg { animation: none; }
+}
.detail-grid {
display: grid;
@@ -1462,16 +1448,11 @@ body {
}
.segment-btn,
- .bw-btn,
- .access-trigger {
+ .bw-btn {
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 b2ccf73c..0ddd81c4 100644
--- a/vehicle-map/tests/test_app.mjs
+++ b/vehicle-map/tests/test_app.mjs
@@ -138,6 +138,22 @@ assert.ok(stationNavigationUrl.startsWith('https://uri.amap.com/navigation?to=11
assert.match(stationNavigationUrl, /%E5%B9%BF%E5%B7%9E/);
assert.match(stationNavigationUrl, /coordinate=gaode&callnative=1$/);
assert.equal(navigationUrlForEntity('vehicle', { plateNumber: '无位置车辆' }), null);
+const launchClassSet = new Set();
+const launchOverlay = { hidden: true, classList: { add(name) { launchClassSet.add(name); }, remove(name) { launchClassSet.delete(name); } } };
+const launchButton = { disabled: false, dataset: { navigationUrl: stationNavigationUrl }, classList: { add(name) { launchClassSet.add('button:' + name); }, remove(name) { launchClassSet.delete('button:' + name); }, contains(name) { return launchClassSet.has('button:' + name); } } };
+const launchTimers = [];
+document.getElementById = id => id === 'navigationLaunchOverlay' ? launchOverlay : id === 'detailNavigateBtn' ? launchButton : null;
+window.requestAnimationFrame = callback => callback();
+window.setTimeout = (callback, delay) => { launchTimers.push({ callback, delay }); return launchTimers.length; };
+window.clearTimeout = () => {};
+window.location = { assign() {} };
+selectedEntity = { mode: 'station', entity: dashboard.stations[0] };
+navigateToSelectedEntity();
+assert.equal(launchOverlay.hidden, false);
+assert.ok(launchClassSet.has('is-visible'));
+assert.ok(launchClassSet.has('button:is-launching'));
+assert.equal(launchButton.disabled, true);
+assert.deepEqual(launchTimers.map(timer => timer.delay), [140, 4000]);
const stationView = stationViewportSummary();
assert.equal(stationView.level, 'station');
assert.equal(stationView.visibleNodes.length, 2);
@@ -170,5 +186,7 @@ const sandbox = {
setInterval() {},
clearInterval() {}
};
+assert.match(source, /navigationLaunchOverlay/);
+assert.match(source, /正在打开高德地图/);
vm.runInNewContext(`${source}\n${checks}`, sandbox, { filename: 'app.js' });
console.log('vehicle hierarchy aggregation tests: ok');
diff --git a/vehicle-map/tests/test_server.py b/vehicle-map/tests/test_server.py
index 1296319c..f46d2f9d 100644
--- a/vehicle-map/tests/test_server.py
+++ b/vehicle-map/tests/test_server.py
@@ -1,9 +1,5 @@
import importlib.util
-import hashlib
-import hmac
-import json
from pathlib import Path
-import time
import unittest
from unittest.mock import patch
@@ -14,7 +10,7 @@ SPEC.loader.exec_module(server)
class DashboardTest(unittest.TestCase):
- def test_dashboard_aggregates_authorized_vehicle_and_station_data(self):
+ def test_dashboard_aggregates_public_vehicle_and_station_data(self):
def fake_post(path, body):
if path.endswith("realtime/query"):
return [
@@ -45,55 +41,11 @@ 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_static_asset_version_is_a_content_fingerprint(self):
self.assertEqual(len(server.STATIC_ASSET_VERSION), 16)
self.assertRegex(server.STATIC_ASSET_VERSION, r"^[0-9a-f]{16}$")
index_template = (Path(__file__).parents[1] / "index.html").read_text(encoding="utf-8")
self.assertEqual(index_template.count("__ASSET_VERSION__"), 2)
- 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()