diff --git a/README.md b/README.md index 5fcfe37..f9b3b69 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ npm start | `MC_AUTH` | `offline`(离线服)或 `microsoft`(正版) | | `MC_PASSWORD` | 服务器登录 / 注册密码,供 `{password}` 占位符使用 | | `WEB_HOST` / `WEB_PORT` | 控制台监听地址与端口 | -| `WEB_TOKEN` | 控制台访问令牌;非本机访问**必须**设置 | +| `WEB_TOKEN` | 控制台访问令牌;非本机访问**必须**设置(页面静态资源不校验,`/api` 与 WebSocket 握手校验) | | `DYNMAP_BASE` / `DYNMAP_WORLD` | 地图数据源根地址与世界目录名 | | `SIMP_API_KEY` | SimpCore 开放 API Key(可选) | diff --git a/src/index.js b/src/index.js index 558f52a..60748fe 100644 --- a/src/index.js +++ b/src/index.js @@ -81,7 +81,7 @@ async function main() { config = normalizeConfig(next, config); configStore.save(config); botManager.applyConfig(config); - controller.config = controller.config ? Object.assign({}, controller.config, config) : config; + controller.config = config; dataService.config = config; dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds); dataService.reschedule(config.refreshIntervalSeconds); diff --git a/src/web/public/app.js b/src/web/public/app.js index 8123f96..80e7aa9 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -643,16 +643,39 @@ function hexToRgba(hex, alpha) { } let mapLoading = false; -async function loadMap() { +/** 上次拉取「全部邦国」地图数据的时间戳(全量数据体积大,需限频) */ +let lastFullFetchAt = 0; +/** 全量地图数据的最短拉取间隔:默认 5 分钟 */ +const FULL_REFRESH_INTERVAL_MS = 5 * 60 * 1000; + +/** + * 拉取地图数据。 + * + * `full=1` 会带上全部邦国的区块(实测 11.5 万区块约 1.5MB JSON), + * 因此只在「用户勾选全部邦国」「手动刷新」「超过 5 分钟」时才拉全量, + * 其余定时刷新只拉己方领土,并复用上一次的全量数据,避免每 20 秒传 1.5MB。 + * + * @param {boolean} [forceFull] 强制拉取全量数据 + */ +async function loadMap(forceFull) { if (mapLoading) return; mapLoading = true; try { - const full = $('chkFull').checked ? '1' : '0'; - const res = await api('/api/map?full=' + full); - mapView.data = res.data; - if (!mapView.centered && res.data.playerChunk) { - mapView.centerX = res.data.playerChunk.x; - mapView.centerZ = res.data.playerChunk.z; + const cached = mapView.data; + const needFull = $('chkFull').checked + && (forceFull === true || !cached || !cached.otherChunks + || Date.now() - lastFullFetchAt > FULL_REFRESH_INTERVAL_MS); + const res = await api('/api/map?full=' + (needFull ? '1' : '0')); + const next = res.data; + if (needFull) { + lastFullFetchAt = Date.now(); + } else if (cached && cached.otherChunks) { + next.otherChunks = cached.otherChunks; + } + mapView.data = next; + if (!mapView.centered && next.playerChunk) { + mapView.centerX = next.playerChunk.x; + mapView.centerZ = next.playerChunk.z; mapView.centered = true; } drawMap(); @@ -728,6 +751,7 @@ function bindEvents() { try { await api('/api/map/refresh', { method: 'POST' }); toast('已触发地图刷新'); + loadMap(true); } catch (err) { toast(err.message, true); } @@ -738,7 +762,7 @@ function bindEvents() { try { const res = await api('/api/api/fetch-all', { method: 'POST' }); toast(res.message); - loadMap(); + loadMap(true); } catch (err) { toast('拉取失败:' + err.message, true); } @@ -757,7 +781,7 @@ function bindEvents() { renderConfig(); }); - $('chkFull').addEventListener('change', loadMap); + $('chkFull').addEventListener('change', () => loadMap(true)); $('chkFlying').addEventListener('change', drawMap); $('chkGrid').addEventListener('change', drawMap); @@ -845,7 +869,7 @@ async function main() { await loadMap(); connectWs(); // 地图数据每 20 秒自动刷新一次(服务端 markers 刷新间隔通常为 30 秒) - setInterval(loadMap, 20000); + setInterval(() => loadMap(), 20000); // 相对时间("x 秒前")需要定期重绘 setInterval(() => { if (lastStatus) renderStatus(lastStatus); }, 5000); } diff --git a/src/web/server.js b/src/web/server.js index d754634..5382aef 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -135,6 +135,10 @@ class WebConsole { const app = this.app; app.disable('x-powered-by'); app.use(express.json({ limit: '1mb' })); + // 静态资源(控制台页面本身)不需要令牌:浏览器加载 index.html 时带的 ?token= 不会 + // 附加到后续 style.css / app.js 的子请求上,若一并拦截会导致页面样式与脚本 404/401。 + // 页面本身不含任何敏感信息,真正需要保护的是下面的 /api 接口。 + app.use(express.static(path.join(__dirname, 'public'))); app.use((req, res, next) => { if (!this._authorized(req)) { res.status(401).json({ ok: false, message: '未授权:请在 URL 上带 ?token= 或提供 X-Auth-Token 头' }); @@ -142,7 +146,6 @@ class WebConsole { } next(); }); - app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/status', (req, res) => { res.json({ ok: true, data: this.statusPayload() }); diff --git a/test/webServer.test.js b/test/webServer.test.js index 252b4f4..33120e4 100644 --- a/test/webServer.test.js +++ b/test/webServer.test.js @@ -142,7 +142,7 @@ test('HTTP 接口:/api/status、/api/config、/api/map 可访问', async () => } }); -test('HTTP 接口:配置令牌后拒绝无令牌请求', async () => { +test('HTTP 接口:配置令牌后拒绝无令牌的 API 请求,但静态资源仍可加载', async () => { const { webConsole } = setupConsole(); webConsole.token = 'secret-token'; await webConsole.start(); @@ -156,6 +156,14 @@ test('HTTP 接口:配置令牌后拒绝无令牌请求', async () => { const viaHeader = await fetch(base + '/api/status', { headers: { 'X-Auth-Token': 'secret-token' } }); assert.strictEqual(viaHeader.status, 200); + + // 静态资源不带令牌也必须能加载,否则浏览器里控制台页面会缺样式 / 缺脚本 + const html = await fetch(base + '/'); + assert.strictEqual(html.status, 200); + const css = await fetch(base + '/style.css'); + assert.strictEqual(css.status, 200); + const js = await fetch(base + '/app.js'); + assert.strictEqual(js.status, 200); } finally { await webConsole.stop(); }