diff --git a/station-navigation/README.md b/station-navigation/README.md new file mode 100644 index 00000000..3257a16f --- /dev/null +++ b/station-navigation/README.md @@ -0,0 +1,35 @@ +# 羚牛氢能 · 加氢站导航 + +面向运维和司机调度的独立公共站点导航服务,默认本地端口为 `20804`。 + +它只调用一次上游加氢站目录接口,并通过正向字段白名单对外提供:名称、位置、省市区、地址与合作状态。车辆数据、加氢量及其他运营字段不会被加载或从这个服务的 API 返回。 + +## 本地运行 + +```bash +export OPEN_PLATFORM_APP_KEY='<32位AppKey>' +./start-local.sh +``` + +访问 。 + +## API + +- `GET /api/stations`:公开站点目录,不包含加氢量字段; +- `GET /api/health`:进程健康状态; +- 其他 `/api/*` 路由均返回 404。 + +站点目录在服务端缓存 2 分钟,并在服务启动后后台预热;因此正常页面打开直接返回缓存数据,最多延迟 2 分钟更新一次。 + +## ECS 部署 + +服务运行在 `20804`,与运营地图端口隔离。部署单元复用 +`/opt/lingniu-vehicle-map/env/vehicle-map.env` 中已有的 Open Platform 凭据,并通过 +`VEHICLE_MAP_ASSET_ROOT=/opt/lingniu-vehicle-map/current` 复用一致的品牌样式。 + +```bash +install -m 0644 deploy/lingniu-station-navigation.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now lingniu-station-navigation.service +deploy/install-release.sh +``` diff --git a/station-navigation/app.js b/station-navigation/app.js new file mode 100644 index 00000000..e038809e --- /dev/null +++ b/station-navigation/app.js @@ -0,0 +1,197 @@ +const state = { stations: [], query: '', searchText: '', locationFilter: { province: '', city: '', district: '' }, suggestions: [], activeSuggestion: -1, searchDebounce: null, stationFilter: 'all', selected: null, userLocation: null, map: null, markers: [], userMarker: null, pitch: true }; + +document.addEventListener('DOMContentLoaded', () => { initClock(); initMap(); loadStations(); }); +document.addEventListener('pointerdown', event => { if (!event.target?.closest?.('.map-explore-toolbar')) closeStationSearchSuggestions(); }); + +async function loadStations() { + setStatus('正在同步加氢站数据…'); + try { + const response = await fetch('/api/stations', { headers: { Accept: 'application/json' } }); + const payload = await response.json(); + if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`); + state.stations = payload.stations.filter(hasCoordinates); + updateSummary(payload.summary); + setStatus(`站点数据已同步 · ${formatNumber(payload.summary.totalStations)} 座加氢站 · ${payload.asOf}`); + render(); + } catch (error) { + console.error('station directory refresh failed', error); + setStatus('站点数据同步失败 · 将自动重试', true); + } +} + +function initMap() { + if (typeof AMap === 'undefined') { setStatus('地图组件加载失败', true); return; } + state.map = new AMap.Map('amapContainer', { zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30, mapStyle: 'amap://styles/light', showBuildingBlock: false, showLabel: true }); + state.map.on('complete', render); + state.map.on('zoomend', render); + state.map.on('moveend', render); +} + +function updateSummary(summary) { + const total = Number(summary.totalStations || 0), partner = Number(summary.cooperativeStations || 0), external = Math.max(0, total - partner); + setHTML('kpiTotal', `${formatNumber(total)} `); setHTML('kpiPartner', `${formatNumber(partner)} `); + setHTML('statusTotal', `${formatNumber(total)} `); setHTML('statusPartner', `${formatNumber(partner)} `); setHTML('statusExternal', `${formatNumber(external)} `); + document.getElementById('partnerSegment').style.width = `${total ? partner * 100 / total : 0}%`; + document.getElementById('externalSegment').style.width = `${total ? external * 100 / total : 0}%`; +} + +function stationMatchesQuery(station, query = state.query) { const needle = normalize(query); return !needle || [station.name, station.shortName, station.address].some(value => normalize(value).includes(needle)); } +function stationMatchesLocation(station) { const filter = state.locationFilter; return (!filter.province || station.province === filter.province) && (!filter.city || station.city === filter.city) && (!filter.district || stationDistrictName(station) === filter.district); } +function stationTypeScope() { return state.stations.filter(station => state.stationFilter !== 'partner' || station.cooperative); } +function filteredStations() { return stationTypeScope().filter(station => stationMatchesLocation(station) && stationMatchesQuery(station)); } +function stationSearchScore(station, query) { + const fields = [station.name, station.shortName, station.province, station.city, station.district, station.address].filter(Boolean); + for (const value of fields) { const text = normalize(value); if (text.includes(query)) return 3; const pinyin = toPinyin(value); if (pinyin.includes(query) || initials(pinyin).includes(query)) return 2; } + return 0; +} +function toPinyin(value) { const converter = globalThis.pinyinPro?.pinyin; return converter ? normalize(converter(String(value), { toneType: 'none', separator: ' ' })) : ''; } +function initials(value) { return String(value).split(/\s+/).map(word => word[0] || '').join(''); } +function normalize(value) { return String(value || '').trim().toLocaleLowerCase(); } +function hasCoordinates(station) { return Number.isFinite(Number(station.longitude)) && Number.isFinite(Number(station.latitude)); } +function coords(station) { return [Number(station.longitude), Number(station.latitude)]; } +function stationDistrictName(station) { if (station?.district) return String(station.district).trim(); let address = String(station?.address || '').trim(); for (const prefix of [station?.province, station?.city]) { const normalized = String(prefix || '').trim(); if (normalized) address = address.split(normalized).join(''); } return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || ''; } + +function render() { renderMarkers(); renderList(); } +function renderMarkers() { + if (!state.map || typeof AMap === 'undefined') return; + state.markers.forEach(marker => marker.remove()); state.markers = []; + const nodes = stationNodes(true); + nodes.forEach(node => { + const point = node.kind === 'station', content = document.createElement('div'); + content.className = 'province-info-badge-wrap'; + const label = point ? (node.cooperative ? 'H₂ · 合作' : '') : `${formatNumber(node.count)} 座`; + const subline = point ? node.adminPath : ''; + content.innerHTML = `
${escapeHTML(node.name)}${label ? `${escapeHTML(label)}` : ''}
${subline ? `
${escapeHTML(subline)}
` : ''}
`; + const marker = new AMap.Marker({ position: node.lnglat, content, offset: new AMap.Pixel(-30, -12), title: node.name, zIndex: point ? 20 : 10 }); + marker.on('click', () => { if (point) selectStation(node.station, true); else state.map.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }); marker.setMap(state.map); state.markers.push(marker); + }); + renderUserMarker(); +} +function stationHierarchyLevel(zoom = state.map?.getZoom?.() || 0) { + if (normalize(state.query)) return 'station'; + if (zoom < 7) return 'province'; + if (zoom < 11) return 'city'; + return 'station'; +} +function stationGroupName(station, level) { + const raw = (level === 'province' ? station.province : station.city) || '未标注区域'; + return level === 'province' ? String(raw).replace(/(壮族|回族|维吾尔)?自治区$|特别行政区$|省$|市$/, '') : String(raw).replace(/自治州$|地区$|林区$|[盟区县市]$/, ''); +} +function buildStationNodes(stations = filteredStations(), level = stationHierarchyLevel()) { + if (level === 'station') return stations.map(station => ({ id: `station-${station.id}`, kind: 'station', level, name: station.name || station.shortName || '未命名站点', lnglat: coords(station), station, count: 1, online: station.cooperative ? 1 : 0, adminPath: stationRegion(station), detail: [stationRegion(station), station.address].filter(Boolean).join(' · '), cooperative: Boolean(station.cooperative) })); + const grouped = new Map(); + for (const station of stations) { + const key = (level === 'province' ? station.province : `${station.province || ''}/${station.city || ''}`) || 'UNSPECIFIED'; + const group = grouped.get(key) || { kind: level === 'province' ? 'stationProvince' : 'stationCity', level, name: stationGroupName(station, level), lng: 0, lat: 0, count: 0, online: 0, stations: [] }; + group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1; group.online += station.cooperative ? 1 : 0; group.stations.push(station); grouped.set(key, group); + } + return [...grouped.values()].map((group, index) => ({ ...group, id: `station-${level}-${index}`, lnglat: [group.lng / group.count, group.lat / group.count], detail: group.stations.slice(0, 5).map(station => station.name).join('、') })); +} +function currentMapBounds() { const bounds = state.map?.getBounds?.(), sw = bounds?.getSouthWest?.(), ne = bounds?.getNorthEast?.(); if (!sw || !ne) return null; return { west: sw.getLng(), south: sw.getLat(), east: ne.getLng(), north: ne.getLat() }; } +function nodesInsideCurrentMapBounds(nodes) { const bounds = currentMapBounds(); if (!bounds) return nodes; return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north); } +function stationNodes(visibleOnly = false) { const nodes = buildStationNodes(); return visibleOnly && !normalize(state.query) ? nodesInsideCurrentMapBounds(nodes) : nodes; } +function stationViewportSummary() { const level = stationHierarchyLevel(), allNodes = stationNodes(false), visibleNodes = normalize(state.query) ? allNodes : nodesInsideCurrentMapBounds(allNodes); return { level, allNodes, visibleNodes }; } +function nextZoomForLevel(level) { return level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14; } + +function renderList() { + const box = document.getElementById('stationList'); if (!box) return; + const { level, allNodes, visibleNodes } = stationViewportSummary(); + const display = [...visibleNodes].sort(compareNodes); + document.getElementById('resultMeta').textContent = `视野 ${formatNumber(visibleNodes.reduce((sum, node) => sum + node.count, 0))} / ${formatNumber(filteredStations().length)}`; + document.getElementById('rankViewportMeta').textContent = `当前视野 ${formatNumber(visibleNodes.length)} · 全部 ${formatNumber(allNodes.length)}`; + document.getElementById('panelRankTitle').textContent = state.userLocation && level === 'station' ? `附近${state.stationFilter === 'partner' ? '合作' : ''}加氢站` : level === 'province' ? '加氢站省份 TOP 排名' : level === 'city' ? '加氢站城市 TOP 排名' : '加氢站 TOP 排名'; + if (!display.length) { box.innerHTML = '
未找到匹配站点
请尝试名称、地址、城市或拼音首字母
'; return; } + box.innerHTML = display.slice(0, 100).map((node, index) => { const distance = node.kind === 'station' ? stationDistance(node.station) : Number.POSITIVE_INFINITY, partnerTag = node.kind === 'station' && node.cooperative ? '合作' : ''; return ``; }).join(''); + box.querySelectorAll('[data-node-id]').forEach(button => button.addEventListener('click', () => { const node = display.find(item => item.id === button.dataset.nodeId); if (node?.kind === 'station') selectStation(node.station, true); else if (node) state.map?.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); })); +} +function compareNodes(left, right) { if (state.userLocation && left.kind === 'station' && right.kind === 'station') return stationDistance(left.station) - stationDistance(right.station); return right.count - left.count || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); } + +function fuzzySearchScore(value, query) { + const needle = normalize(query); if (!needle) return 4; + const text = normalize(value); if (text === needle) return 0; if (text.startsWith(needle)) return 1; if (text.includes(needle)) return 2; + const pinyin = toPinyin(value); if (pinyin === needle || pinyin.startsWith(needle) || initials(pinyin).startsWith(needle)) return 3; + return pinyin.includes(needle) || initials(pinyin).includes(needle) ? 4 : Number.POSITIVE_INFINITY; +} +function compactPath(parts) { return parts.filter(Boolean).join(' · '); } +function stationLocationOptions() { + const groups = new Map(); + const add = (level, station) => { + const province = station.province || '', city = station.city || '', district = station.district || ''; + const key = level === 'province' ? `province|${province}` : level === 'city' ? `city|${province}|${city}` : `district|${province}|${city}|${district}`; + if (!groups.has(key)) groups.set(key, { type: 'location', level, province, city, district, stations: [] }); + groups.get(key).stations.push(station); + }; + for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); } + return [...groups.values()].map(option => { + const target = option.level === 'province' ? option.province : option.level === 'city' ? option.city : option.district; + const longitude = option.stations.reduce((sum, station) => sum + Number(station.longitude), 0) / option.stations.length; + const latitude = option.stations.reduce((sum, station) => sum + Number(station.latitude), 0) / option.stations.length; + return { ...option, label: stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city'), path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] }; + }); +} +function stationLocationSuggestions(query) { + return stationLocationOptions().map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, query), fuzzySearchScore(option.path, query)) })) + .filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, normalize(query) ? 8 : 6); +} +function stationEntitySuggestions(query) { + if (!normalize(query)) return []; + return filteredStations().map(station => ({ type: 'station', station, score: stationSearchScore(station, query) })) + .filter(item => item.score > 0).sort((left, right) => right.score - left.score || String(left.station.name || '').localeCompare(String(right.station.name || ''), 'zh-CN')).slice(0, 5); +} +function renderStationSearchSuggestions() { + const box = document.getElementById('stationSearchSuggestions'); if (!box || box.hidden) return; + const locations = stationLocationSuggestions(state.searchText), stations = stationEntitySuggestions(state.searchText); + state.suggestions = [...locations, ...stations]; state.activeSuggestion = Math.min(state.activeSuggestion, state.suggestions.length - 1); + if (!state.suggestions.length) { box.innerHTML = '未找到匹配的位置或站点'; return; } + let html = ''; + if (locations.length) html += `${locations.map((item, index) => ``).join('')}`; + if (stations.length) html += `${stations.map((item, offset) => { const index = locations.length + offset, station = item.station; return ``; }).join('')}`; + box.innerHTML = html; +} +function openStationSearchSuggestions() { const box = document.getElementById('stationSearchSuggestions'); if (!box) return; box.hidden = false; renderStationSearchSuggestions(); } +function closeStationSearchSuggestions() { const box = document.getElementById('stationSearchSuggestions'); if (box) box.hidden = true; state.activeSuggestion = -1; } +function focusSearchResults() { + const stations = filteredStations(); if (!state.map || !stations.length) return; + const longitudes = stations.map(station => Number(station.longitude)), latitudes = stations.map(station => Number(station.latitude)); + const center = [(Math.min(...longitudes) + Math.max(...longitudes)) / 2, (Math.min(...latitudes) + Math.max(...latitudes)) / 2]; + const span = Math.max(Math.max(...longitudes) - Math.min(...longitudes), Math.max(...latitudes) - Math.min(...latitudes)); + const zoom = stations.length === 1 ? 14 : span < .1 ? 13 : span < .35 ? 11.5 : span < 1 ? 9.5 : 7; + state.map.setZoomAndCenter(zoom, center); +} +function handleStationSearchInput(value) { state.searchText = value; document.getElementById('clearSearch').hidden = !normalize(value); state.activeSuggestion = -1; openStationSearchSuggestions(); window.clearTimeout(state.searchDebounce); state.searchDebounce = window.setTimeout(() => { state.query = value; state.selected = null; closeDetail(); render(); if (normalize(value)) focusSearchResults(); }, 180); } +function selectStationSearchSuggestion(index) { + const item = state.suggestions[index]; if (!item) return; + if (item.type === 'location') { state.locationFilter = { province: item.province, city: item.city, district: item.district }; state.query = ''; state.searchText = ''; document.getElementById('stationSearch').value = ''; document.getElementById('clearSearch').hidden = true; state.map?.setZoomAndCenter(item.level === 'province' ? 7.2 : item.level === 'city' ? 10.5 : 13, item.lnglat); document.getElementById('selectedRegionHint').textContent = `视角: ${item.path}`; closeStationSearchSuggestions(); render(); return; } + closeStationSearchSuggestions(); selectStation(item.station, true); +} +function setStationFilter(filter) { if (!['all', 'partner'].includes(filter) || state.stationFilter === filter) return; state.stationFilter = filter; state.selected = null; closeDetail(); document.querySelectorAll('[data-station-filter]').forEach(button => button.classList.toggle('is-active', button.dataset.stationFilter === filter)); render(); renderStationSearchSuggestions(); } +function clearSearch() { window.clearTimeout(state.searchDebounce); document.getElementById('stationSearch').value = ''; state.query = ''; state.searchText = ''; state.locationFilter = { province: '', city: '', district: '' }; closeStationSearchSuggestions(); closeDetail(); resetMap(); render(); } +function handleSearchKeydown(event) { if (event.key === 'Escape') { closeStationSearchSuggestions(); event.currentTarget.blur(); return; } if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return; const box = document.getElementById('stationSearchSuggestions'); if (box?.hidden) openStationSearchSuggestions(); if (event.key === 'Enter' && state.activeSuggestion >= 0) { event.preventDefault(); selectStationSearchSuggestion(state.activeSuggestion); return; } if (event.key !== 'Enter') { event.preventDefault(); const direction = event.key === 'ArrowDown' ? 1 : -1; state.activeSuggestion = (state.activeSuggestion + direction + state.suggestions.length) % Math.max(state.suggestions.length, 1); renderStationSearchSuggestions(); } } + +function selectStation(station, fit = false) { + if (!station) return; state.selected = station; if (fit) state.map?.setZoomAndCenter(14.5, coords(station)); + document.getElementById('detailName').textContent = station.name || station.shortName || '未命名站点'; document.getElementById('detailRegion').textContent = stationRegion(station); + const cooperative = Boolean(station.cooperative), tag = document.getElementById('detailType'); tag.textContent = cooperative ? '合作站' : '外部站'; tag.classList.toggle('is-station', cooperative); + const distance = stationDistance(station); document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`; + document.getElementById('stationDetail').hidden = false; renderMarkers(); renderList(); +} +function detailField(label, value, full = false) { return `
${escapeHTML(label)}
${escapeHTML(value)}
`; } +function stationRegion(station) { return [station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]').filter((value, index, list) => index === 0 || value !== list[index - 1]).join(' · '); } +function closeDetail() { const detail = document.getElementById('stationDetail'); if (detail) detail.hidden = true; } + +function locateMe() { if (!navigator.geolocation) return locationFailed('当前设备不支持定位'); const button = document.getElementById('locateDeviceBtn'); button.disabled = true; button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = '定位中'; navigator.geolocation.getCurrentPosition(position => { state.userLocation = wgs84ToGcj02(Number(position.coords.longitude), Number(position.coords.latitude)); button.disabled = false; button.classList.add('is-active'); document.getElementById('locateDeviceLabel').textContent = '已定位'; state.map?.setZoomAndCenter(13, state.userLocation); document.getElementById('selectedRegionHint').textContent = '视角: 我的位置附近'; render(); }, error => locationFailed(error?.code === 1 ? '请允许位置权限' : '定位失败'), { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }); } +function locationFailed(label) { const button = document.getElementById('locateDeviceBtn'); button.disabled = false; button.classList.add('is-error'); document.getElementById('locateDeviceLabel').textContent = label; window.setTimeout(() => { button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = state.userLocation ? '已定位' : '定位'; }, 2600); } +function renderUserMarker() { if (!state.map || !state.userLocation || typeof AMap === 'undefined') return; if (state.userMarker) state.userMarker.remove(); const content = document.createElement('div'); content.className = 'user-location-marker'; state.userMarker = new AMap.Marker({ position: state.userLocation, content, offset: new AMap.Pixel(-9, -9), zIndex: 50 }); state.userMarker.setMap(state.map); } +function stationDistance(station) { return state.userLocation ? distanceKm(state.userLocation, coords(station)) : Number.POSITIVE_INFINITY; } +function distanceKm(origin, target) { const radians = value => value * Math.PI / 180, dLat = radians(target[1] - origin[1]), dLng = radians(target[0] - origin[0]), a = Math.sin(dLat / 2) ** 2 + Math.cos(radians(origin[1])) * Math.cos(radians(target[1])) * Math.sin(dLng / 2) ** 2; return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } +function resetMap() { state.map?.setZoomAndCenter(4.8, [108.948024, 34.263161]); state.map?.setPitch(30); document.getElementById('selectedRegionHint').textContent = '视角: 全国加氢站网络'; } +function toggleMapPitch() { state.pitch = !state.pitch; state.map?.setPitch(state.pitch ? 30 : 0); } +function setTheme(theme) { document.body.className = theme; document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); document.querySelector(theme === 'theme-white' ? '.theme-white-btn' : '.theme-dark-btn')?.classList.add('active'); state.map?.setMapStyle(theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'); } +function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => window.location.assign(`https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`), 140); } +function setStatus(text, error = false) { const status = document.getElementById('mapStatusText'); if (status) status.textContent = text; status?.parentElement?.classList.toggle('is-error', error); } +function setHTML(id, value) { const element = document.getElementById(id); if (element) element.innerHTML = value; } +function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits }); } +function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } +function escapeAttribute(value) { return escapeHTML(value).replace(/`/g, '`'); } +function initClock() { const clock = document.getElementById('clockTime'), update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); }; update(); window.setInterval(update, 1000); } +function wgs84ToGcj02(lng, lat) { if (lng < 72.004 || lng > 137.8347 || lat < .8293 || lat > 55.8271) return [lng, lat]; const transformLat = (x, y) => -100 + 2*x + 3*y + .2*y*y + .1*x*y + .2*Math.sqrt(Math.abs(x)) + (20*Math.sin(6*x*Math.PI)+20*Math.sin(2*x*Math.PI))*2/3 + (20*Math.sin(y*Math.PI)+40*Math.sin(y/3*Math.PI))*2/3 + (160*Math.sin(y/12*Math.PI)+320*Math.sin(y*Math.PI/30))*2/3; const transformLng = (x, y) => 300 + x + 2*y + .1*x*x + .1*x*y + .1*Math.sqrt(Math.abs(x)) + (20*Math.sin(6*x*Math.PI)+20*Math.sin(2*x*Math.PI))*2/3 + (20*Math.sin(x*Math.PI)+40*Math.sin(x/3*Math.PI))*2/3 + (150*Math.sin(x/12*Math.PI)+300*Math.sin(x/30*Math.PI))*2/3; const a = 6378245, ee = .00669342162296594323; let dLat = transformLat(lng-105, lat-35), dLng = transformLng(lng-105, lat-35); const radLat = lat / 180 * Math.PI, magic = 1 - ee * Math.sin(radLat) ** 2, sqrtMagic = Math.sqrt(magic); dLat = dLat * 180 / ((a * (1-ee)) / (magic * sqrtMagic) * Math.PI); dLng = dLng * 180 / (a / sqrtMagic * Math.cos(radLat) * Math.PI); return [lng + dLng, lat + dLat]; } diff --git a/station-navigation/deploy/install-release.sh b/station-navigation/deploy/install-release.sh new file mode 100755 index 00000000..ad10439f --- /dev/null +++ b/station-navigation/deploy/install-release.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +release_id=${1:?release id is required} +archive=${2:?release archive is required} +root=${STATION_NAVIGATION_ROOT:-/opt/lingniu-station-navigation} +service=${STATION_NAVIGATION_SERVICE:-lingniu-station-navigation.service} +base_url=${STATION_NAVIGATION_BASE_URL:-http://127.0.0.1:20804} + +case "$release_id" in + ''|*[!A-Za-z0-9._-]*) printf 'invalid release id: %s\n' "$release_id" >&2; exit 1 ;; +esac +test -f "$archive" + +if ! id station-navigation >/dev/null 2>&1; then + useradd --system --home-dir "$root" --shell /sbin/nologin station-navigation +fi +mkdir -p "$root/releases" +next="$root/releases/$release_id" +test ! -e "$next" +mkdir -p "$next" + +while IFS= read -r member; do + case "$member" in + /*|../*|*/../*|*/..) printf 'archive contains unsafe path: %s\n' "$member" >&2; exit 1 ;; + esac +done < <(tar -tzf "$archive") +tar --no-same-owner -xzf "$archive" -C "$next" +test -f "$next/server.py" +test -f "$next/index.html" +chown -R root:station-navigation "$next" +chmod -R u=rwX,g=rX,o= "$next" + +ln -s "$next" "$root/current.next" +python3 -c 'import os,sys; os.replace(sys.argv[1],sys.argv[2])' "$root/current.next" "$root/current" +systemctl restart "$service" + +for _ in $(seq 1 30); do + if systemctl is-active --quiet "$service" && curl -fsS "$base_url/api/health" | grep -q '"service":"station-navigation"'; then + curl -fsS "$base_url/" | grep -q 'id="dashboardTitle"' + curl -fsS "$base_url/api/stations" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["status"]=="ok" and d["summary"]["totalStations"]>400; forbidden={"hydrogenVolume","fuelingVolume","dailyHydrogenVolume"}; assert not (forbidden & set().union(*(x.keys() for x in d["stations"])))' + printf 'station_navigation_release_install=ok release=%s\n' "$release_id" + exit 0 + fi + sleep 1 +done + +systemctl status "$service" --no-pager >&2 || true +exit 1 diff --git a/station-navigation/deploy/lingniu-station-navigation.service b/station-navigation/deploy/lingniu-station-navigation.service new file mode 100644 index 00000000..b4ce327e --- /dev/null +++ b/station-navigation/deploy/lingniu-station-navigation.service @@ -0,0 +1,31 @@ +[Unit] +Description=Lingniu Public Hydrogen Station Navigation +After=network-online.target lingniu-vehicle-open-platform.service +Wants=network-online.target + +[Service] +Type=simple +User=station-navigation +Group=station-navigation +# The public process needs read-only access to the operations map's shared +# logo and stylesheet. Its own release remains owned by station-navigation. +SupplementaryGroups=vehicle-map +WorkingDirectory=/opt/lingniu-station-navigation/current +# Reuse the operations map's existing Open Platform credential; the public +# service still returns only the station-directory allowlist in server.py. +EnvironmentFile=/opt/lingniu-vehicle-map/env/vehicle-map.env +Environment=STATION_NAVIGATION_HOST=0.0.0.0 +Environment=STATION_NAVIGATION_PORT=20804 +Environment=STATION_NAVIGATION_CACHE_SECONDS=120 +Environment=VEHICLE_MAP_ASSET_ROOT=/opt/lingniu-vehicle-map/current +ExecStart=/usr/bin/python3 /opt/lingniu-station-navigation/current/server.py +Restart=always +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/station-navigation/index.html b/station-navigation/index.html new file mode 100644 index 00000000..69a6c06c --- /dev/null +++ b/station-navigation/index.html @@ -0,0 +1,64 @@ + + + + + + 羚牛氢能 - 加氢站导航 + + + + + + + + + +
+
+
+
羚牛氢能
+
+

加氢站导航

+
+
+
站点总数--
+
合作站点--
+
+
+
+ + +
+
+
+
+ +
+
+
正在同步加氢站数据…
+
+ +
+
+ 全部 0 +
+
+
+ + +
+
+
地图引擎: 羚牛氢能 GIS (AMap 3D Engine)站点数据服务 · 公开目录视角: 全国加氢站网络
+
+ +
+
+ + + + diff --git a/station-navigation/server.py b/station-navigation/server.py new file mode 100644 index 00000000..6831ed60 --- /dev/null +++ b/station-navigation/server.py @@ -0,0 +1,230 @@ +"""Public hydrogen station navigation service. + +This process deliberately has a smaller API boundary than the operations map: +it retrieves only station directory fields and never exposes vehicle records or +hydrogen-volume fields. +""" + +import hashlib +from http.server import HTTPServer, SimpleHTTPRequestHandler +import json +import os +from pathlib import Path +from socketserver import ThreadingMixIn +import threading +import time +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parent +# Production deliberately reuses the operations map's visual assets. Keeping +# this configurable lets the standalone release live under its own directory. +VEHICLE_MAP_ROOT = Path(os.getenv("VEHICLE_MAP_ASSET_ROOT", str(ROOT.parent / "vehicle-map"))) +HOST = os.getenv("STATION_NAVIGATION_HOST", "0.0.0.0") +PORT = int(os.getenv("STATION_NAVIGATION_PORT", "20804")) +OPEN_PLATFORM_BASE_URL = os.getenv("OPEN_PLATFORM_BASE_URL", "https://open.d.lnoneos.com").rstrip("/") +OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip() +UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15")) +STATION_CACHE_SECONDS = int(os.getenv("STATION_NAVIGATION_CACHE_SECONDS", "120")) +_cache_lock = threading.Lock() +_cache = {} + + +def _static_asset_version(): + digest = hashlib.sha256() + for filename in ("app.js", "styles.css"): + digest.update((ROOT / filename).read_bytes()) + digest.update((VEHICLE_MAP_ROOT / "styles.css").read_bytes()) + return digest.hexdigest()[:16] + + +STATIC_ASSET_VERSION = _static_asset_version() + + +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def _json_bytes(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + +def _post_open_platform(path, body): + if not OPEN_PLATFORM_APP_KEY: + raise RuntimeError("OPEN_PLATFORM_APP_KEY is not configured") + request = Request( + OPEN_PLATFORM_BASE_URL + path, + data=_json_bytes(body), + method="POST", + headers={ + "Authorization": "Bearer " + OPEN_PLATFORM_APP_KEY, + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "lingniu-station-navigation/1.0", + }, + ) + try: + with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response: + payload = json.load(response) + except HTTPError as exc: + detail = exc.read(2048).decode("utf-8", errors="replace") + raise RuntimeError(f"open platform returned HTTP {exc.code}: {detail}") from exc + except URLError as exc: + raise RuntimeError(f"open platform unavailable: {exc.reason}") from exc + if payload.get("code") != "SUCCESS": + raise RuntimeError(f"open platform rejected request: {payload.get('code')} {payload.get('message')}") + return payload.get("data") or [] + + +def _cached(key, ttl_seconds, loader): + now = time.time() + with _cache_lock: + cached = _cache.get(key) + if cached and now - cached[0] < ttl_seconds: + return cached[1] + value = loader() + with _cache_lock: + _cache[key] = (now, value) + return value + + +def _public_station(station): + """Positive allowlist: do not add operational or hydrogen fields here.""" + 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_station_directory(): + stations = _post_open_platform("/api/v1/hydrogen-stations/query", {}) + directory = [_public_station(station) for station in stations] + return { + "status": "ok", + "asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "summary": { + "totalStations": len(directory), + "cooperativeStations": sum(1 for station in directory if station.get("cooperative")), + }, + "stations": directory, + } + + +def _prewarm_station_directory_cache(): + """Fetch the public directory after startup so first navigation is warm.""" + try: + _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory) + except Exception as exc: # The request path will retry without blocking startup. + print(f"station navigation cache warmup deferred: {exc}") + + +class StationNavigationHandler(SimpleHTTPRequestHandler): + server_version = "LingniuStationNavigation/1.0" + + def do_GET(self): + path = urlparse(self.path).path + if path in {"/", "/index.html"}: + self._serve_index() + return + if path == "/api/health": + self._write_json(200, { + "status": "ok", + "service": "station-navigation", + "openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY), + }) + return + if path == "/api/stations": + try: + self._write_json(200, _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)) + except Exception as exc: + self.log_error("station directory refresh failed: %s", exc) + self._write_json(502, { + "status": "error", + "code": "UPSTREAM_UNAVAILABLE", + "message": "加氢站数据暂时不可用,请稍后重试", + }) + return + if path == "/assets/logo_light.svg": + self._serve_logo() + return + if path == "/assets/vehicle-map.css": + self._serve_vehicle_map_styles() + return + if path.startswith("/api/"): + self._write_json(404, {"status": "error", "code": "NOT_FOUND"}) + return + super().do_GET() + + def _serve_index(self): + try: + template = (ROOT / "index.html").read_text(encoding="utf-8") + body = template.replace("__ASSET_VERSION__", STATIC_ASSET_VERSION).encode("utf-8") + except OSError as exc: + self.log_error("index template unavailable: %s", exc) + self.send_error(500, "Station navigation is temporarily unavailable") + return + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(body) + + def _serve_logo(self): + try: + body = (VEHICLE_MAP_ROOT / "logo_light.svg").read_bytes() + except OSError: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "image/svg+xml") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "public, max-age=86400") + self.end_headers() + self.wfile.write(body) + + def _serve_vehicle_map_styles(self): + try: + body = (VEHICLE_MAP_ROOT / "styles.css").read_bytes() + except OSError: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "text/css; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(body) + + 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") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "strict-origin-when-cross-origin") + self.send_header("X-Frame-Options", "SAMEORIGIN") + super().end_headers() + + def _write_json(self, status, payload): + 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))) + self.end_headers() + self.wfile.write(body) + + +if __name__ == "__main__": + server = ThreadingHTTPServer((HOST, PORT), StationNavigationHandler) + threading.Thread(target=_prewarm_station_directory_cache, daemon=True).start() + print(f"Lingniu Station Navigation listening on http://{HOST}:{PORT}") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() diff --git a/station-navigation/start-local.sh b/station-navigation/start-local.sh new file mode 100755 index 00000000..511f6154 --- /dev/null +++ b/station-navigation/start-local.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +export STATION_NAVIGATION_HOST="${STATION_NAVIGATION_HOST:-127.0.0.1}" +export STATION_NAVIGATION_PORT="${STATION_NAVIGATION_PORT:-20804}" +exec python3 "$(dirname "$0")/server.py" diff --git a/station-navigation/styles.css b/station-navigation/styles.css new file mode 100644 index 00000000..9a1ee725 --- /dev/null +++ b/station-navigation/styles.css @@ -0,0 +1,20 @@ +/* Keep the public service visually identical to the main map shell. */ +.station-navigation-shell .header-kpi-group { display: flex; } +.station-navigation-shell .map-filter-chip { min-height: 27px; padding: 0 9px; border: 1px solid var(--glass-border); border-radius: 8px; background: var(--pill-bg); color: var(--text-muted); font-size: 10px; font-weight: 700; cursor: pointer; } +.station-navigation-shell .map-filter-chip.is-active { border-color: rgba(0,113,67,.24); background: rgba(0,113,67,.1); color: var(--accent-primary); } +.station-navigation-shell .filter-chip-rail { display: flex; gap: 6px; } +.station-navigation-shell .province-info-card.is-station-point { min-width: 92px; } +.station-navigation-shell .province-info-card.is-station-point .p-total { color: var(--accent-primary); } +.station-navigation-shell .province-info-card.is-external .p-total { color: var(--text-muted); } +.station-navigation-shell .rank-glass-row { cursor: pointer; } +.station-navigation-shell .rank-glass-row.is-selected { background: rgba(0,113,67,.09); border-color: rgba(0,113,67,.2); } +.station-navigation-shell .r-name { display: flex; min-width: 0; align-items: flex-start; gap: 5px; line-height: 1.35; } +.station-navigation-shell .station-list-name { min-width: 0; overflow-wrap: anywhere; white-space: normal; } +.station-navigation-shell .station-list-partner-tag { flex: 0 0 auto; margin-top: 1px; padding: 2px 5px; border-radius: 4px; background: rgba(0,113,67,.1); color: var(--accent-primary); font-size: 9px; font-weight: 700; line-height: 1; } +.station-navigation-shell .rank-empty { display: grid; min-height: 160px; place-items: center; color: var(--text-muted); font-size: 12px; text-align: center; } +.station-navigation-shell .map-detail-card .detail-grid { margin-bottom: 0; } +@media (max-width: 760px) { + .station-navigation-shell .header-kpi-group { display: flex; } + .station-navigation-shell .filter-chip-rail { max-width: 124px; overflow: hidden; } + .station-navigation-shell .map-filter-chip { padding: 0 7px; font-size: 9px; } +} diff --git a/station-navigation/tests/test_app.mjs b/station-navigation/tests/test_app.mjs new file mode 100644 index 00000000..333fa69e --- /dev/null +++ b/station-navigation/tests/test_app.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); +const sandbox = { + console, + document: { addEventListener() {}, getElementById() { return null; } }, + window: {}, + pinyinPro: { pinyin(value) { return value === '广州合作站' ? 'guang zhou he zuo zhan' : value; } }, + navigator: {}, +}; +sandbox.globalThis = sandbox; +vm.runInNewContext(source, sandbox, { filename: 'app.js' }); + +assert.equal(sandbox.stationSearchScore({ name: '广州合作站' }, 'gz'), 2); +assert.equal(sandbox.stationSearchScore({ address: '广东省广州市黄埔区' }, '黄埔'), 3); +assert.equal(sandbox.stationSearchScore({ name: '广州合作站' }, '北京'), 0); +assert.equal(sandbox.hasCoordinates({ longitude: 113.2, latitude: 23.1 }), true); +assert.equal(sandbox.hasCoordinates({ longitude: 'bad', latitude: 23.1 }), false); +vm.runInNewContext(` + state.stations = [ + { id: 'partner', name: '广州合作站', cooperative: true }, + { id: 'external', name: '广州外部站', cooperative: false } + ]; + globalThis.allStationIds = filteredStations().map(station => station.id); + state.stationFilter = 'partner'; + globalThis.partnerStationIds = filteredStations().map(station => station.id); +`, sandbox); +assert.equal(sandbox.allStationIds.join(','), 'partner,external'); +assert.equal(sandbox.partnerStationIds.join(','), 'partner'); +vm.runInNewContext(` + state.stations = [ + { id: 'gd-hp', name: '黄埔合作站', province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.3, latitude: 23.1, cooperative: true }, + { id: 'gd-nh', name: '南海合作站', province: '广东省', city: '佛山市', district: '南海区', longitude: 113.1, latitude: 23.0, cooperative: true }, + { id: 'zj-jx', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', district: '秀洲区', longitude: 120.7, latitude: 30.7, cooperative: true } + ]; + globalThis.hierarchy = [stationHierarchyLevel(4.8), stationHierarchyLevel(8), stationHierarchyLevel(13)]; + globalThis.provinceNodeCount = buildStationNodes(filteredStations(), 'province').length; + globalThis.cityNodeCount = buildStationNodes(filteredStations(), 'city').length; + globalThis.stationNodeCount = buildStationNodes(filteredStations(), 'station').length; + globalThis.locationOptionCount = stationLocationOptions().length; +`, sandbox); +assert.equal(sandbox.hierarchy.join(','), 'province,city,station'); +assert.equal(sandbox.provinceNodeCount, 2); +assert.equal(sandbox.cityNodeCount, 3); +assert.equal(sandbox.stationNodeCount, 3); +assert.equal(sandbox.locationOptionCount, 8); +vm.runInNewContext(` + globalThis.inferredDistrict = stationDistrictName({ province: '浙江省', city: '嘉兴市', address: '嘉兴市海盐县海盐经济开发区' }); + state.locationFilter = { province: '', city: '', district: '' }; + state.query = '广州'; + globalThis.queryMatches = stationMatchesQuery({ name: '广州合作站' }); + globalThis.queryMisses = stationMatchesQuery({ name: '杭州合作站' }); +`, sandbox); +assert.equal(sandbox.inferredDistrict, '海盐县'); +assert.equal(sandbox.queryMatches, true); +assert.equal(sandbox.queryMisses, false); +assert.ok(Math.abs(sandbox.distanceKm([113, 23], [114, 23]) - 102.4) < 1); +assert.match(source, /zoom < 11\) return 'city'/); +assert.match(source, /state\.userLocation && left\.kind === 'station'/); +assert.doesNotMatch(source, /rankSecondaryTab|setRank\(/); +assert.match(source, /stationSearchSuggestions/); +assert.match(source, /handleStationSearchInput/); +assert.match(source, /window\.setTimeout\(\(\) => \{ state\.query = value/); +assert.match(source, /focusSearchResults\(\)/); +assert.match(source, /visibleOnly && !normalize\(state\.query\)/); +assert.match(source, /station-list-partner-tag/); +assert.match(source, /node\.cooperative \? 'H₂ · 合作' : ''/); +assert.doesNotMatch(source, /在线 \$\{formatNumber\(node\.online\)\}/); +assert.doesNotMatch(source, /monthlyHydrogenKg|totalHydrogenKg|vehicle/); +assert.match(source, /uri\.amap\.com\/navigation/); +console.log('station navigation app tests: ok'); diff --git a/station-navigation/tests/test_server.py b/station-navigation/tests/test_server.py new file mode 100644 index 00000000..625dd4c8 --- /dev/null +++ b/station-navigation/tests/test_server.py @@ -0,0 +1,46 @@ +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location("station_navigation_server", Path(__file__).parents[1] / "server.py") +server = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(server) + + +class StationNavigationTest(unittest.TestCase): + def test_directory_is_a_positive_allowlist_without_hydrogen_data(self): + source = [{ + "id": "GD-1", "name": "广州合作站", "province": "广东省", "city": "广州市", + "district": "黄埔区", "address": "开源大道1号", "longitude": 113.2, "latitude": 23.1, + "cooperative": True, "monthlyHydrogenKg": 88.8, "totalHydrogenKg": 999.9, + "vehicleCount": 20, + }] + with patch.object(server, "_post_open_platform", return_value=source): + result = server._load_station_directory() + + self.assertEqual(result["summary"], {"totalStations": 1, "cooperativeStations": 1}) + station = result["stations"][0] + self.assertEqual(station["name"], "广州合作站") + self.assertNotIn("monthlyHydrogenKg", station) + self.assertNotIn("totalHydrogenKg", station) + self.assertNotIn("vehicleCount", station) + + def test_static_assets_are_fingerprinted(self): + self.assertRegex(server.STATIC_ASSET_VERSION, r"^[0-9a-f]{16}$") + + def test_default_cache_window_is_two_minutes(self): + self.assertEqual(server.STATION_CACHE_SECONDS, 120) + + def test_cache_reuses_directory_snapshot_within_window(self): + calls = [] + with patch.object(server, "_cache", {}): + first = server._cached("station-directory", 120, lambda: calls.append("load") or {"version": 1}) + second = server._cached("station-directory", 120, lambda: calls.append("load") or {"version": 2}) + self.assertEqual(calls, ["load"]) + self.assertEqual(first, second) + + +if __name__ == "__main__": + unittest.main() diff --git a/vehicle-map/README.md b/vehicle-map/README.md index ee538505..f8a00c7d 100644 --- a/vehicle-map/README.md +++ b/vehicle-map/README.md @@ -37,8 +37,8 @@ python3 server.py 本服务提供: - `GET /api/health`:进程及配置状态; -- `GET /api/dashboard`:面向前端的公开聚合数据,默认缓存5秒;加氢站缓存1小时。 +- `GET /api/dashboard`:面向前端的公开聚合数据,默认缓存 2 分钟;加氢站目录同样缓存 2 分钟。服务启动后会后台预热缓存,避免首位访问者等待上游数据请求。 -全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。 +全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每 15 秒请求一次,但服务端最多每 2 分钟刷新一次上游快照,以换取更快、更稳定的首屏加载。 车辆地图按缩放级别逐级下钻:全国视角(小于7级)按省聚合,7–9.5级按市聚合,9.5–12级按区县聚合,12级及以上显示当前视野内的单车真实位置。点击省、市、区县气泡会自动进入下一级。 diff --git a/vehicle-map/server.py b/vehicle-map/server.py index aa498558..5af61cd9 100644 --- a/vehicle-map/server.py +++ b/vehicle-map/server.py @@ -31,8 +31,8 @@ PORT = int(os.getenv("VEHICLE_MAP_PORT", "20800")) OPEN_PLATFORM_BASE_URL = os.getenv("OPEN_PLATFORM_BASE_URL", "https://open.d.lnoneos.com").rstrip("/") 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")) +DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "120")) +STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "120")) _cache_lock = threading.Lock() _cache = {} @@ -149,6 +149,18 @@ def _load_dashboard(): } +def _prewarm_dashboard_cache(): + """Warm the first dashboard snapshot after a process restart. + + A warm snapshot lets the first visitor receive the cached response instead + of waiting for the three upstream requests to complete. + """ + try: + _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard) + except Exception as exc: # A later request can retry; startup must stay available. + print(f"vehicle map cache warmup deferred: {exc}") + + class VehicleMapHandler(SimpleHTTPRequestHandler): server_version = "LingniuVehicleMap/1.0" @@ -215,6 +227,7 @@ class VehicleMapHandler(SimpleHTTPRequestHandler): if __name__ == "__main__": server = ThreadingHTTPServer((HOST, PORT), VehicleMapHandler) + threading.Thread(target=_prewarm_dashboard_cache, daemon=True).start() print(f"Lingniu Vehicle Map listening on http://{HOST}:{PORT}") try: server.serve_forever() diff --git a/vehicle-map/tests/test_server.py b/vehicle-map/tests/test_server.py index f46d2f9d..421eb558 100644 --- a/vehicle-map/tests/test_server.py +++ b/vehicle-map/tests/test_server.py @@ -47,5 +47,17 @@ class DashboardTest(unittest.TestCase): index_template = (Path(__file__).parents[1] / "index.html").read_text(encoding="utf-8") self.assertEqual(index_template.count("__ASSET_VERSION__"), 2) + def test_default_cache_window_is_two_minutes(self): + self.assertEqual(server.DASHBOARD_CACHE_SECONDS, 120) + self.assertEqual(server.STATION_CACHE_SECONDS, 120) + + def test_cache_reuses_dashboard_snapshot_within_window(self): + calls = [] + with patch.object(server, "_cache", {}): + first = server._cached("dashboard", 120, lambda: calls.append("load") or {"version": 1}) + second = server._cached("dashboard", 120, lambda: calls.append("load") or {"version": 2}) + self.assertEqual(calls, ["load"]) + self.assertEqual(first, second) + if __name__ == "__main__": unittest.main()