feat(map): add cached public station navigation
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
# 羚牛氢能 · 加氢站导航
|
||||||
|
|
||||||
|
面向运维和司机调度的独立公共站点导航服务,默认本地端口为 `20804`。
|
||||||
|
|
||||||
|
它只调用一次上游加氢站目录接口,并通过正向字段白名单对外提供:名称、位置、省市区、地址与合作状态。车辆数据、加氢量及其他运营字段不会被加载或从这个服务的 API 返回。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPEN_PLATFORM_APP_KEY='<32位AppKey>'
|
||||||
|
./start-local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 <http://127.0.0.1:20804/>。
|
||||||
|
|
||||||
|
## 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 <release-id> <release.tgz>
|
||||||
|
```
|
||||||
@@ -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)} <small>座</small>`); setHTML('kpiPartner', `${formatNumber(partner)} <small>座</small>`);
|
||||||
|
setHTML('statusTotal', `${formatNumber(total)} <small>座</small>`); setHTML('statusPartner', `${formatNumber(partner)} <small>座</small>`); setHTML('statusExternal', `${formatNumber(external)} <small>座</small>`);
|
||||||
|
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 = `<div class="province-info-card${point ? ` is-station-point${node.cooperative ? '' : ' is-external'}${state.selected?.id === node.station.id ? ' is-selected' : ''}` : ''}"><div class="p-head"><span class="p-name">${escapeHTML(node.name)}</span>${label ? `<span class="p-total">${escapeHTML(label)}</span>` : ''}</div>${subline ? `<div class="p-sub">${escapeHTML(subline)}</div>` : ''}</div>`;
|
||||||
|
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 = '<div class="rank-empty">未找到匹配站点<br>请尝试名称、地址、城市或拼音首字母</div>'; 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 ? '<span class="station-list-partner-tag">合作</span>' : ''; return `<button type="button" class="rank-glass-row${node.kind === 'station' && state.selected?.id === node.station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span><span class="r-val">${Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : `${formatNumber(node.count)} 座`}</span></button>`; }).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 = '<span class="suggestion-empty">未找到匹配的位置或站点</span>'; return; }
|
||||||
|
let html = '';
|
||||||
|
if (locations.length) html += `<span class="suggestion-section-label">位置</span>${locations.map((item, index) => `<button type="button" class="explore-suggestion${index === state.activeSuggestion ? ' is-active' : ''}" role="option" onclick="selectStationSearchSuggestion(${index})"><span class="suggestion-symbol is-location">⌖</span><span class="suggestion-copy"><strong>${escapeHTML(item.label)}</strong><small>${escapeHTML(item.path)}</small></span><span class="suggestion-kind">地点</span></button>`).join('')}`;
|
||||||
|
if (stations.length) html += `<span class="suggestion-section-label">加氢站</span>${stations.map((item, offset) => { const index = locations.length + offset, station = item.station; return `<button type="button" class="explore-suggestion${index === state.activeSuggestion ? ' is-active' : ''}" role="option" onclick="selectStationSearchSuggestion(${index})"><span class="suggestion-symbol">H₂</span><span class="suggestion-copy"><strong>${escapeHTML(station.name || station.shortName || '未命名站点')}</strong><small>${escapeHTML(stationRegion(station) || station.address || '')}</small></span><span class="suggestion-kind">加氢站</span></button>`; }).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 `<div class="detail-field${full ? ' is-full' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`; }
|
||||||
|
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]; }
|
||||||
Executable
+49
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<title>羚牛氢能 - 加氢站导航</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/assets/vehicle-map.css?v=__ASSET_VERSION__">
|
||||||
|
<link rel="stylesheet" href="styles.css?v=__ASSET_VERSION__">
|
||||||
|
<script>window._AMapSecurityConfig = { securityJsCode: '0b54a41143bec162788d01deba851340' };</script>
|
||||||
|
<script src="https://webapi.amap.com/maps?v=2.0&key=1868920ac8ff6b6f88dbe9fa2609c183&plugin=AMap.Scale,AMap.ToolBar,AMap.Marker"></script>
|
||||||
|
</head>
|
||||||
|
<body class="theme-white">
|
||||||
|
<div class="liquid-cockpit-wrapper station-navigation-shell">
|
||||||
|
<header class="liquid-header floating-glass">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="brand-block"><img class="brand-logo-svg" src="/assets/logo_light.svg" width="150" height="36" alt="羚牛氢能"></div>
|
||||||
|
<div class="brand-divider"></div>
|
||||||
|
<div class="cockpit-title-wrap"><h1 class="cockpit-title">加氢站导航</h1></div>
|
||||||
|
</div>
|
||||||
|
<div class="header-kpi-group">
|
||||||
|
<div class="kpi-glass-capsule"><span class="capsule-lbl">站点总数</span><span class="capsule-val" id="kpiTotal">-- <small>座</small></span></div>
|
||||||
|
<div class="kpi-glass-capsule primary-capsule"><span class="capsule-lbl">合作站点</span><span class="capsule-val" id="kpiPartner">-- <small>座</small></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="header-controls">
|
||||||
|
<div class="theme-switcher-bw">
|
||||||
|
<button class="bw-btn theme-dark-btn" title="深色模式" onclick="setTheme('theme-dark')"><svg class="bw-svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg><span>深色</span></button>
|
||||||
|
<button class="bw-btn theme-white-btn active" title="浅色模式" onclick="setTheme('theme-white')"><svg class="bw-svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="5"></circle><path d="M12 1v2m0 18v2M1 12h2m18 0h2M4.2 4.2l1.4 1.4m12.8 12.8 1.4 1.4m0-15.6-1.4 1.4M5.6 18.4l-1.4 1.4"></path></svg><span>浅色</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="time-widget" id="clockTime"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="cockpit-main-grid">
|
||||||
|
<section class="map-spatial-container floating-glass">
|
||||||
|
<div class="map-top-bar"><div class="status-glass-pill"><span class="pulse-ring"></span><span id="mapStatusText">正在同步加氢站数据…</span></div></div>
|
||||||
|
<div class="map-explore-toolbar" aria-label="地图搜索与定位">
|
||||||
|
<button class="locate-btn" id="locateDeviceBtn" type="button" onclick="locateMe()" aria-label="定位附近站点"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3"></path><circle cx="12" cy="12" r="8"></circle></svg><span id="locateDeviceLabel">定位</span></button>
|
||||||
|
<div class="explore-search-shell"><label class="entity-search-field" for="stationSearch"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.6-3.6"></path></svg><input id="stationSearch" type="search" autocomplete="off" placeholder="搜索位置、站点或地址" aria-label="搜索位置、站点或地址" oninput="handleStationSearchInput(this.value)" onfocus="openStationSearchSuggestions()" onkeydown="handleSearchKeydown(event)"><button class="search-clear-btn" id="clearSearch" type="button" onclick="clearSearch()" aria-label="清除搜索" hidden>×</button></label><div class="explore-suggestions" id="stationSearchSuggestions" role="listbox" aria-label="搜索建议" hidden></div></div>
|
||||||
|
<div class="filter-chip-rail" id="filterChipRail" role="group" aria-label="站点类型筛选"><button class="map-filter-chip is-active" type="button" data-station-filter="all" onclick="setStationFilter('all')">全部站点</button><button class="map-filter-chip" type="button" data-station-filter="partner" onclick="setStationFilter('partner')">合作站</button></div>
|
||||||
|
<span class="filter-result-meta" id="resultMeta" aria-live="polite">全部 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="amap-canvas-box">
|
||||||
|
<div id="amapContainer"></div>
|
||||||
|
<div class="navigation-launch-overlay" id="navigationOverlay" role="status" aria-live="polite" hidden><div class="navigation-launch-panel"><span class="navigation-launch-spinner"></span><div><strong>正在打开高德地图</strong><span>正在准备导航路线,请稍候</span></div></div></div>
|
||||||
|
<article class="map-detail-card" id="stationDetail" aria-live="polite" hidden>
|
||||||
|
<div class="detail-card-accent is-station"></div><div class="detail-card-header"><div class="detail-title-wrap"><span class="detail-type-pill is-station" id="detailType">合作站</span><h2 id="detailName">—</h2><p id="detailRegion"></p></div><div class="detail-card-actions"><button class="detail-navigate-btn" id="navigateButton" type="button" onclick="navigateToStation()" aria-label="导航"><svg viewBox="0 0 24 24"><path d="M20.7 3.3 14.4 20.1a1.2 1.2 0 0 1-2.2.1l-2.6-5.8-5.8-2.6a1.2 1.2 0 0 1 .1-2.2l16.8-6.3Z"></path><path d="m9.5 14.5 4.9-4.9"></path></svg></button><button class="detail-close-btn" type="button" onclick="closeDetail()" aria-label="关闭详情">×</button></div></div><dl class="detail-grid" id="detailFields"></dl>
|
||||||
|
</article>
|
||||||
|
<div class="map-action-controls"><button class="glass-btn" onclick="resetMap()">复位视角</button><button class="glass-btn" id="pitchButton" onclick="toggleMapPitch()">3D/2D 视角</button><button class="glass-btn" onclick="clearSearch()">全国视图</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="map-bottom-info"><span>地图引擎: 羚牛氢能 GIS (AMap 3D Engine)</span><span>站点数据服务 · 公开目录</span><span id="selectedRegionHint">视角: 全国加氢站网络</span></div>
|
||||||
|
</section>
|
||||||
|
<aside class="sidebar-operations">
|
||||||
|
<div class="glass-panel sidebar-card"><div class="panel-header"><span class="panel-title">加氢站类型分布</span><span class="panel-tag">STATUS</span></div><div class="status-glass-grid3"><div class="glass-cell"><div class="cell-num" id="statusTotal">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-total"></span>全部站点</div></div><div class="glass-cell"><div class="cell-num" id="statusPartner">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-mint"></span>合作站</div></div><div class="glass-cell"><div class="cell-num" id="statusExternal">-- <small>座</small></div><div class="cell-label"><span class="status-dot dot-blue"></span>外部站</div></div></div><div class="liquid-progress-bar"><div class="seg seg-running" id="partnerSegment"></div><div class="seg seg-stopped" id="externalSegment"></div></div></div>
|
||||||
|
<div class="glass-panel sidebar-card flex-fill-auto"><div class="panel-header"><div class="panel-heading-stack"><span class="panel-title" id="panelRankTitle">加氢站省份 TOP 排名</span><span class="viewport-meta" id="rankViewportMeta"></span></div><div class="glass-tab-control"><button class="gtab active" id="rankPrimaryTab">按站点数</button></div></div><div class="liquid-ranking-list" id="stationList"></div></div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pinyin-pro@3.28.2/dist/index.js" crossorigin="anonymous"></script>
|
||||||
|
<script src="app.js?v=__ASSET_VERSION__"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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()
|
||||||
Executable
+6
@@ -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"
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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()
|
||||||
@@ -37,8 +37,8 @@ python3 server.py
|
|||||||
本服务提供:
|
本服务提供:
|
||||||
|
|
||||||
- `GET /api/health`:进程及配置状态;
|
- `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级及以上显示当前视野内的单车真实位置。点击省、市、区县气泡会自动进入下一级。
|
车辆地图按缩放级别逐级下钻:全国视角(小于7级)按省聚合,7–9.5级按市聚合,9.5–12级按区县聚合,12级及以上显示当前视野内的单车真实位置。点击省、市、区县气泡会自动进入下一级。
|
||||||
|
|||||||
+15
-2
@@ -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_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()
|
OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
|
||||||
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
|
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
|
||||||
DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "5"))
|
DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "120"))
|
||||||
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600"))
|
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "120"))
|
||||||
_cache_lock = threading.Lock()
|
_cache_lock = threading.Lock()
|
||||||
_cache = {}
|
_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):
|
class VehicleMapHandler(SimpleHTTPRequestHandler):
|
||||||
server_version = "LingniuVehicleMap/1.0"
|
server_version = "LingniuVehicleMap/1.0"
|
||||||
|
|
||||||
@@ -215,6 +227,7 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
server = ThreadingHTTPServer((HOST, PORT), VehicleMapHandler)
|
server = ThreadingHTTPServer((HOST, PORT), VehicleMapHandler)
|
||||||
|
threading.Thread(target=_prewarm_dashboard_cache, daemon=True).start()
|
||||||
print(f"Lingniu Vehicle Map listening on http://{HOST}:{PORT}")
|
print(f"Lingniu Vehicle Map listening on http://{HOST}:{PORT}")
|
||||||
try:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|||||||
@@ -47,5 +47,17 @@ class DashboardTest(unittest.TestCase):
|
|||||||
index_template = (Path(__file__).parents[1] / "index.html").read_text(encoding="utf-8")
|
index_template = (Path(__file__).parents[1] / "index.html").read_text(encoding="utf-8")
|
||||||
self.assertEqual(index_template.count("__ASSET_VERSION__"), 2)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user