feat: 接齐 SimpCore 开放 API 全量接口并新增 API 数据浏览弹层
以前只用了「邦国领土」这一类接口,控制台也只能看到一行「已启用/未启用」, 大量服务端公开信息拿得到却看不到。现在按官方文档 https://open.bgjq.top/api-docs.html 补齐。 新增 - SimpApiClient 从 7 个接口补到 16 个:/info/players、/info/unions/{key}/members、 /tech、/contributions、/info/buffs、/info/diplomacy、/info/bans、/info/logs、 /info/announcements - SimpApiV2Client(新):/players、/players/{name}/equipment、/time、/dragon。 文档明确 API 与 API-V2 是两套独立、互不通用的密钥体系,因此单独实例与配置 (apiV2Enabled / apiV2Key,或环境变量 SIMP_API_V2_KEY);V2 配额很小 (末影龙 1 次/分钟),一律按需查询、不做轮询 - 控制台「查看 API 数据」弹层,五个页签: 概览(两套密钥状态/请求数/缓存/健康检查)· 邦国(27 个,点开看成员/科技/捐献/领土统计) · 玩家(1600+ 可搜索,点开看详情与所属邦国)· 全服(加成/外交/封禁/日志/公告) · 实时 V2(在线玩家/游戏时间/末影龙/装备) - /api/simp/* 一整套只读代理接口,统一处理「未配 Key / 上游错误」并给出可读提示 修复(解析字段名以实测响应为准) - 捐献:响应只有 player_uuid 没有玩家名 -> 先用成员表补全、查不到再退到全服玩家表; 时间字段是 contributed_at;补充 reason(用途)列;amount 可为负(邦国支出) - 公告:message 是 Minecraft 聊天组件 JSON 字符串 -> 新增 componentToText() 压平为纯文本 - 封禁:实际字段 banned_player_name / banned_by_player_name / banned_at / unbanned - 日志:实际字段 username / action / timestamp - 玩家表:上下线时间是 last_join_time / last_quit_time - 王城坐标:服务端可能返回 null,原来 Number(null) 会算成 0, 伪装成「王城在世界原点 (0,0)」;现在显式判空返回 null 变更 - 快捷发送去掉 /spawn 与 /list,只留 /u return、/u claimmode、/u restore confirm 测试 326 -> 336,全部通过。
这个提交包含在:
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const {
|
||||
SimpApiClient,
|
||||
SimpApiV2Client,
|
||||
BASE_URL,
|
||||
BASE_URL_V2,
|
||||
componentToText,
|
||||
normalizeLimit,
|
||||
parseUnionSummary,
|
||||
parseTerritoryChunk
|
||||
} = require('../src/core/simpApiClient');
|
||||
|
||||
test('componentToText 把公告的组件 JSON 压平成纯文本', () => {
|
||||
const raw = '{"color":"gold","extra":[{"color":"aqua","text":"wans7891"},'
|
||||
+ '{"color":"yellow","text":" 在坐标 (-8109, -8976)"},'
|
||||
+ '{"color":"gold","text":" 创立了新的邦国 "},'
|
||||
+ '{"color":"aqua","text":"「雅典维亚城邦」"}]}';
|
||||
assert.strictEqual(componentToText(raw), 'wans7891 在坐标 (-8109, -8976) 创立了新的邦国 「雅典维亚城邦」');
|
||||
// 嵌套 extra
|
||||
const nested = '{"text":"公告: ","extra":[{"text":"外层","extra":[{"text":"内层"}]}]}';
|
||||
assert.strictEqual(componentToText(nested), '公告: 外层内层');
|
||||
});
|
||||
|
||||
test('componentToText 对非 JSON 文本原样返回', () => {
|
||||
assert.strictEqual(componentToText('普通公告'), '普通公告');
|
||||
assert.strictEqual(componentToText(''), '');
|
||||
assert.strictEqual(componentToText(null), '');
|
||||
});
|
||||
|
||||
test('normalizeLimit 钳制到 1..1000', () => {
|
||||
assert.strictEqual(normalizeLimit(0), 1);
|
||||
assert.strictEqual(normalizeLimit(50), 50);
|
||||
assert.strictEqual(normalizeLimit(99999), 1000);
|
||||
assert.strictEqual(normalizeLimit('abc'), 100, '非法值回退到文档默认值 100');
|
||||
assert.strictEqual(normalizeLimit(undefined), 100);
|
||||
});
|
||||
|
||||
test('parseUnionSummary 解析邦国摘要并按方块坐标换算王城区块', () => {
|
||||
const union = parseUnionSummary({
|
||||
id: 12,
|
||||
name: '养老院',
|
||||
leader_name: 'LouieMAIN',
|
||||
member_count: 6,
|
||||
territory_chunk_count: 28853,
|
||||
territory_block_area: 7386368,
|
||||
special_output_material: 'TERRACOTTA',
|
||||
capital_x: 6763.4,
|
||||
capital_z: 11929.2
|
||||
});
|
||||
assert.strictEqual(union.name, '养老院');
|
||||
assert.strictEqual(union.leader, 'LouieMAIN');
|
||||
assert.strictEqual(union.memberCount, 6);
|
||||
assert.strictEqual(union.chunkCount, 28853);
|
||||
assert.strictEqual(union.specialOutput, 'TERRACOTTA');
|
||||
// 方块坐标 ÷ 16 才是区块坐标
|
||||
assert.deepStrictEqual(union.capital, { x: 422, z: 745 });
|
||||
});
|
||||
|
||||
test('parseUnionSummary 缺王城坐标时返回 null 而不是 NaN', () => {
|
||||
const union = parseUnionSummary({ name: '没有王城的邦国', capital_x: null, capital_z: null });
|
||||
assert.strictEqual(union.capital, null);
|
||||
assert.strictEqual(parseUnionSummary({}), null, '没有邦国名应视为无效');
|
||||
assert.strictEqual(parseUnionSummary(null), null);
|
||||
});
|
||||
|
||||
test('parseTerritoryChunk 解析区块并拒绝坐标缺失的行', () => {
|
||||
const chunk = parseTerritoryChunk({
|
||||
x: 1,
|
||||
z: -2,
|
||||
union_id: 12,
|
||||
union_name: '养老院',
|
||||
loyalty: 100,
|
||||
last_claimed_at: '2025-08-08T13:43:21.323+08:00'
|
||||
});
|
||||
assert.deepStrictEqual(chunk, {
|
||||
x: 1,
|
||||
z: -2,
|
||||
unionId: 12,
|
||||
unionName: '养老院',
|
||||
loyalty: 100,
|
||||
lastClaimedAt: '2025-08-08T13:43:21.323+08:00'
|
||||
});
|
||||
assert.strictEqual(parseTerritoryChunk({ x: null, z: 1 }), null);
|
||||
assert.strictEqual(parseTerritoryChunk({}), null);
|
||||
assert.strictEqual(parseTerritoryChunk(null), null);
|
||||
});
|
||||
|
||||
test('客户端在未配置 Key 时把错误写清楚,而不是静默失败', async () => {
|
||||
const client = new SimpApiClient(null);
|
||||
assert.strictEqual(client.hasApiKey(), false);
|
||||
assert.strictEqual(await client.getUnionTerritoryStats('x'), null);
|
||||
assert.strictEqual(client.lastError(), '未配置 API Key');
|
||||
|
||||
const v2 = new SimpApiV2Client(null);
|
||||
// 列表类接口在无 Key 时返回空列表,但 lastError 必须说明原因
|
||||
assert.deepStrictEqual(await v2.getOnlinePlayers(), []);
|
||||
assert.strictEqual(v2.lastError(), '未配置 API-V2 Key');
|
||||
assert.strictEqual(await v2.getDragon(), null);
|
||||
});
|
||||
|
||||
test('setApiKey 变更时清空缓存,避免旧 Key 的响应串味', () => {
|
||||
const client = new SimpApiClient(null);
|
||||
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
|
||||
client.setApiKey('first');
|
||||
assert.strictEqual(client.cache.size, 0, '第一次设置也应从空 Key 状态切换');
|
||||
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
|
||||
client.setApiKey('second');
|
||||
assert.strictEqual(client.cache.size, 0, '换 Key 必须清缓存');
|
||||
// 相同 Key 重复设置不应清缓存(否则每次配置下发都会白打接口)
|
||||
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
|
||||
client.setApiKey('second');
|
||||
assert.strictEqual(client.cache.size, 1);
|
||||
});
|
||||
|
||||
test('API 与 API-V2 是两个独立的基础地址与密钥', () => {
|
||||
const v1 = new SimpApiClient(null, {});
|
||||
const v2 = new SimpApiV2Client(null, {});
|
||||
assert.strictEqual(v1.baseUrl, BASE_URL);
|
||||
assert.strictEqual(v2.baseUrl, BASE_URL_V2);
|
||||
assert.notStrictEqual(v1.baseUrl, v2.baseUrl);
|
||||
v1.setApiKey('key-v1');
|
||||
assert.strictEqual(v1.hasApiKey(), true);
|
||||
assert.strictEqual(v2.hasApiKey(), false, 'V1 的 Key 不应影响 V2');
|
||||
});
|
||||
|
||||
test('snapshot 暴露用量与缓存统计供控制台展示', () => {
|
||||
const client = new SimpApiClient(null);
|
||||
const snap = client.snapshot();
|
||||
assert.strictEqual(snap.hasKey, false);
|
||||
assert.strictEqual(snap.requestCount, 0);
|
||||
assert.strictEqual(snap.cacheHits, 0);
|
||||
assert.strictEqual(snap.cachedPaths, 0);
|
||||
assert.strictEqual(snap.baseUrl, BASE_URL);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户