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
This commit is contained in:
227
central-server/apps/tile-cache/server.js
Normal file
227
central-server/apps/tile-cache/server.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 天地图瓦片缓存服务
|
||||
* - 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);
|
||||
});
|
||||
Reference in New Issue
Block a user