/** * 天地图瓦片缓存服务 * - 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); });