Files
Distributed-Prometheus/central-server/apps/tile-cache/server.js
jack ab1515dffb refactor: config/apps 目录重组、文档重构、架构图收窄
- 中央:config/(prometheus,alertmanager,grafana)、apps/(tile-cache,topology-editor)
- 边缘:config/(vmagent,blackbox,targets)、apps/(onvif-exporter)
- env: TRAEFIK_PROVIDER、prometheus/env.example 详细说明
- 文档:README/doc 重构,EDGE_CACHE 合并到 EDGE_AGENT_CONFIG
- targets.csv 更新流程说明,ARCHITECTURE 图收窄

Made-with: Cursor
2026-02-28 22:05:43 -05:00

228 lines
7.4 KiB
JavaScript

/**
* 天地图瓦片缓存服务
* - GET /vec/:z/:x/:y GET /cva/:z/:x/:y 返回瓦片(先读缓存,未命中或已过期则向天地图请求并写入缓存)
* - 缓存老化:超过 CACHE_TTL_DAYS 天的瓦片视为过期,会重新拉取
* - GET /api/cache/status 返回缓存统计与 TTL 配置
*/
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const PORT = parseInt(process.env.PORT || '4090', 10);
const TIANDITU_TK = (process.env.TIANDITU_TK || '').trim();
const CACHE_DIR = path.resolve(process.env.CACHE_DIR || path.join(__dirname, 'cache'));
const CACHE_TTL_DAYS = Math.max(1, parseInt(process.env.CACHE_TTL_DAYS || '7', 10));
const CACHE_TTL_MS = CACHE_TTL_DAYS * 24 * 60 * 60 * 1000;
const UPSTREAM_TIMEOUT_MS = Math.max(5000, parseInt(process.env.UPSTREAM_TIMEOUT_MS || '15000', 10));
const WMTS_BASE = 'https://t0.tianditu.gov.cn';
function log(msg, detail) {
const d = detail != null ? ' ' + JSON.stringify(detail) : '';
console.log('[tile-cache] ' + msg + d);
}
function logErr(msg, err) {
console.error('[tile-cache] ' + msg, err && err.message ? err.message : err);
}
function ensureCacheDir(layer) {
const dir = path.join(CACHE_DIR, layer);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
function tilePath(layer, z, x, y) {
return path.join(CACHE_DIR, layer, String(z), String(x), String(y) + '.png');
}
function serveTile(filePath, res) {
const stream = fs.createReadStream(filePath);
stream.on('error', () => {
res.statusCode = 404;
res.end();
});
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
stream.pipe(res);
}
function fetchFromTianditu(layer, z, x, y, cb) {
if (!TIANDITU_TK) {
logErr('fetchFromTianditu: TIANDITU_TK not set', null);
cb(new Error('TIANDITU_TK not set'));
return;
}
let done = false;
function once(err, buf) {
if (done) return;
done = true;
cb(err, buf);
}
const subpath = layer === 'vec' ? 'vec_w' : 'cva_w';
const url = `${WMTS_BASE}/${subpath}/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=${layer === 'vec' ? 'vec' : 'cva'}&STYLE=default&TILEMATRIXSET=w&TILEMATRIX=${z}&TILEROW=${y}&TILECOL=${x}&FORMAT=tiles&tk=${encodeURIComponent(TIANDITU_TK)}`;
log('upstream request', { layer, z, x, y });
const req = https.get(url, (proxyRes) => {
if (proxyRes.statusCode !== 200) {
log('upstream non-200', { status: proxyRes.statusCode, layer, z, x, y });
once(new Error(`Upstream ${proxyRes.statusCode}`));
return;
}
const chunks = [];
proxyRes.on('data', (chunk) => chunks.push(chunk));
proxyRes.on('end', () => {
log('upstream end', { layer, z, x, y, size: chunks.reduce((a, c) => a + c.length, 0) });
once(null, Buffer.concat(chunks));
});
proxyRes.on('error', (e) => {
logErr('upstream stream error', e);
once(e);
});
});
req.on('error', (e) => {
logErr('upstream request error', e);
once(e);
});
req.setTimeout(UPSTREAM_TIMEOUT_MS, () => {
log('upstream timeout', { layer, z, x, y });
req.destroy();
once(new Error('Upstream timeout'));
});
}
function handleTile(layer, z, x, y, res) {
const zNum = parseInt(z, 10);
const xNum = parseInt(x, 10);
const yNum = parseInt(y, 10);
if (isNaN(zNum) || isNaN(xNum) || isNaN(yNum) || zNum < 0 || zNum > 20) {
res.statusCode = 400;
res.end('Bad tile');
return;
}
const filePath = tilePath(layer, z, x, y);
if (fs.existsSync(filePath)) {
try {
const stat = fs.statSync(filePath);
const ageMs = Date.now() - stat.mtime.getTime();
if (ageMs < CACHE_TTL_MS) {
log('cache hit', { layer, z, x, y });
serveTile(filePath, res);
return;
}
} catch (e) {}
}
log('cache miss', { layer, z, x, y });
fetchFromTianditu(layer, z, x, y, (err, buf) => {
try {
if (err) {
log('tile 502', { layer, z, x, y, err: err && err.message });
res.statusCode = 502;
res.end(err.message || 'Upstream error');
return;
}
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
try {
fs.writeFileSync(filePath, buf);
} catch (e) {
logErr('write cache error', e);
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.end(buf);
log('tile sent', { layer, z, x, y });
} catch (e) {
logErr('handleTile response error', e);
if (!res.headersSent) {
res.statusCode = 500;
res.end('Internal error');
}
}
});
}
function countCached(dir, acc) {
if (!fs.existsSync(dir)) return acc;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) countCached(p, acc);
else acc.count++;
}
return acc;
}
const server = http.createServer((req, res) => {
log('request', { method: req.method, url: req.url });
try {
const urlStr = (req.url && req.url.length) ? req.url : '/';
const u = new URL(urlStr, 'http://localhost');
const p = u.pathname;
if (req.method === 'GET' && p === '/health') {
res.setHeader('Content-Type', 'text/plain');
res.end('ok');
return;
}
if (req.method === 'GET' && /^\/vec\/(\d+)\/(\d+)\/(\d+)$/.test(p)) {
const [, z, x, y] = p.match(/^\/vec\/(\d+)\/(\d+)\/(\d+)$/);
handleTile('vec', z, x, y, res);
return;
}
if (req.method === 'GET' && /^\/cva\/(\d+)\/(\d+)\/(\d+)$/.test(p)) {
const [, z, x, y] = p.match(/^\/cva\/(\d+)\/(\d+)\/(\d+)$/);
handleTile('cva', z, x, y, res);
return;
}
if (req.method === 'GET' && (p === '/api/cache/status' || p === '/api/cache/status/')) {
let count = 0;
try {
count = countCached(CACHE_DIR, { count: 0 }).count;
} catch (e) {
logErr('countCached error', e);
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ ok: true, cachedTiles: count, cacheTtlDays: CACHE_TTL_DAYS }));
return;
}
res.statusCode = 404;
res.end('Not found');
} catch (e) {
logErr('Request handler error', e);
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/plain');
res.end('Internal error');
}
}
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`Tile cache listening on http://0.0.0.0:${PORT}`);
if (!TIANDITU_TK) console.warn('TIANDITU_TK not set; tile requests will fail.');
console.log(`Cache dir: ${CACHE_DIR}, TTL: ${CACHE_TTL_DAYS} days, upstream timeout: ${UPSTREAM_TIMEOUT_MS}ms`);
// 启动时请求一块瓦片并写日志,确认容器能否访问天地图
if (!TIANDITU_TK) {
log('startup probe skipped (TIANDITU_TK not set)', null);
return;
}
const probeZ = 3, probeX = 1, probeY = 2;
log('startup probe requesting vec/' + probeZ + '/' + probeX + '/' + probeY, null);
fetchFromTianditu('vec', String(probeZ), String(probeX), String(probeY), (err, buf) => {
if (err) {
logErr('startup probe failed', err);
return;
}
log('startup probe ok', { layer: 'vec', z: probeZ, x: probeX, y: probeY, size: buf ? buf.length : 0 });
});
});
process.on('uncaughtException', (err) => {
logErr('uncaughtException', err);
});
process.on('unhandledRejection', (reason, p) => {
console.error('[tile-cache] unhandledRejection', reason);
});