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,全部通过。
这个提交包含在:
+180
@@ -574,6 +574,133 @@ class WebConsole {
|
||||
res.json({ ok: result.ok, message: result.message });
|
||||
});
|
||||
|
||||
// -------- SimpCore 开放 API(只读查询,全部按需手动触发)--------
|
||||
//
|
||||
// 官方文档里的接口远不止「邦国领土」那一类,这里把公开信息完整暴露给控制台。
|
||||
// 注意限流是按 Key × 接口算的,因此客户端侧有 5 分钟缓存,前端也不做轮询。
|
||||
|
||||
app.get('/api/simp/status', (req, res) => {
|
||||
res.json({ ok: true, data: this.dataService.apiSnapshot() });
|
||||
});
|
||||
|
||||
app.get('/api/simp/health', (req, res) => {
|
||||
this._simp(res, async () => ({ reachable: await this.dataService.apiClient.healthCheck() }));
|
||||
});
|
||||
|
||||
app.get('/api/simp/unions', (req, res) => {
|
||||
this._simp(res, async () => {
|
||||
const unions = await this.dataService.apiClient.getAllUnions();
|
||||
const search = String((req.query && req.query.search) || '').trim().toLowerCase();
|
||||
let list = Array.from(unions.values());
|
||||
if (search) {
|
||||
list = list.filter((union) => union.name.toLowerCase().includes(search)
|
||||
|| (union.leader || '').toLowerCase().includes(search));
|
||||
}
|
||||
list.sort((a, b) => b.chunkCount - a.chunkCount);
|
||||
return list;
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/simp/unions/:key', (req, res) => {
|
||||
this._simp(res, async () => {
|
||||
const client = this.dataService.apiClient;
|
||||
const union = await client.getUnion(req.params.key);
|
||||
if (!union) return null;
|
||||
return Object.assign({}, union, {
|
||||
territory: await client.getUnionTerritoryStats(req.params.key)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/simp/unions/:key/members', (req, res) => {
|
||||
this._simp(res, () => this.dataService.apiClient.getUnionMembers(req.params.key));
|
||||
});
|
||||
|
||||
app.get('/api/simp/unions/:key/tech', (req, res) => {
|
||||
this._simp(res, () => this.dataService.apiClient.getUnionTech(req.params.key));
|
||||
});
|
||||
|
||||
// 捐献记录只有 player_uuid,这里补上玩家名(先用成员表,查不到再退到全服玩家表)
|
||||
app.get('/api/simp/unions/:key/contributions', (req, res) => {
|
||||
this._simp(res, async () => {
|
||||
const client = this.dataService.apiClient;
|
||||
const limit = toPositiveInt(req.query && req.query.limit, 100);
|
||||
const contributions = await client.getUnionContributions(req.params.key, limit);
|
||||
if (contributions.length === 0) return contributions;
|
||||
const names = new Map();
|
||||
const members = await client.getUnionMembers(req.params.key);
|
||||
for (const member of members) if (member.uuid) names.set(member.uuid, member.name);
|
||||
const unknown = [...new Set(contributions.map((c) => c.uuid).filter((uuid) => uuid && !names.has(uuid)))];
|
||||
if (unknown.length > 0) {
|
||||
const all = await client.getPlayerNameMap();
|
||||
for (const [uuid, name] of all) if (!names.has(uuid)) names.set(uuid, name);
|
||||
}
|
||||
return contributions.map((c) => Object.assign({}, c, { name: names.get(c.uuid) || '' }));
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/simp/unions/:key/territory', (req, res) => {
|
||||
this._simp(res, () => this.dataService.apiClient.getUnionTerritoryStats(req.params.key));
|
||||
});
|
||||
|
||||
app.get('/api/simp/players', (req, res) => {
|
||||
this._simp(res, async () => {
|
||||
const players = await this.dataService.apiClient.getPlayers();
|
||||
const search = String((req.query && req.query.search) || '').trim().toLowerCase();
|
||||
const filtered = search
|
||||
? players.filter((p) => p.name.toLowerCase().includes(search)
|
||||
|| (p.unionName || '').toLowerCase().includes(search))
|
||||
: players;
|
||||
const limit = toPositiveInt(req.query && req.query.limit, 300);
|
||||
return filtered.slice(0, limit);
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/simp/players/:key', (req, res) => {
|
||||
this._simp(res, async () => {
|
||||
const client = this.dataService.apiClient;
|
||||
const player = await client.getPlayer(req.params.key);
|
||||
if (!player) return null;
|
||||
return Object.assign({}, player, { union: await client.getPlayerUnion(req.params.key) });
|
||||
});
|
||||
});
|
||||
|
||||
// 全服数据:buffs / diplomacy / bans / logs / announcements
|
||||
app.get('/api/simp/global/:kind', (req, res) => {
|
||||
const kind = String(req.params.kind || '');
|
||||
const limit = toPositiveInt(req.query && req.query.limit, 100);
|
||||
const handlers = {
|
||||
buffs: () => this.dataService.apiClient.getBuffs(),
|
||||
diplomacy: () => this.dataService.apiClient.getDiplomacy(),
|
||||
bans: () => this.dataService.apiClient.getBans(limit),
|
||||
logs: () => this.dataService.apiClient.getLogs(limit),
|
||||
announcements: () => this.dataService.apiClient.getAnnouncements(limit)
|
||||
};
|
||||
const handler = handlers[kind];
|
||||
if (!handler) {
|
||||
res.status(400).json({ ok: false, message: '不支持的全服数据类型:' + kind });
|
||||
return;
|
||||
}
|
||||
this._simp(res, handler);
|
||||
});
|
||||
|
||||
// -------- SimpCore API-V2(ServerOpenAPI,独立密钥体系,配额很小)--------
|
||||
app.get('/api/simp/v2/online', (req, res) => {
|
||||
this._simpV2(res, () => this.dataService.apiV2Client.getOnlinePlayers());
|
||||
});
|
||||
|
||||
app.get('/api/simp/v2/equipment/:name', (req, res) => {
|
||||
this._simpV2(res, () => this.dataService.apiV2Client.getPlayerEquipment(req.params.name));
|
||||
});
|
||||
|
||||
app.get('/api/simp/v2/time', (req, res) => {
|
||||
this._simpV2(res, () => this.dataService.apiV2Client.getGameTime());
|
||||
});
|
||||
|
||||
app.get('/api/simp/v2/dragon', (req, res) => {
|
||||
this._simpV2(res, () => this.dataService.apiV2Client.getDragon());
|
||||
});
|
||||
|
||||
// -------- 单机器人兼容接口(等价于默认机器人的 /api/bots/:id/...)--------
|
||||
app.post('/api/task/start', async (req, res) => {
|
||||
try {
|
||||
@@ -641,6 +768,59 @@ class WebConsole {
|
||||
res.status(status).json({ ok: false, message: err && err.message ? err.message : String(err) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一包装 SimpCore API 查询:把「未启用 / 未配 Key / 上游错误」翻译成可读提示。
|
||||
*
|
||||
* @param {import('express').Response} res
|
||||
* @param {() => Promise<*>} handler
|
||||
*/
|
||||
_simp(res, handler) {
|
||||
if (!this.dataService.apiClient.hasApiKey()) {
|
||||
res.status(400).json({ ok: false, message: '未配置 SimpCore API Key(配置 → 数据 → API Key,或在 .env 设置 SIMP_API_KEY)' });
|
||||
return;
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(handler)
|
||||
.then((data) => {
|
||||
const error = this.dataService.apiClient.lastError();
|
||||
if (data === null || (Array.isArray(data) && data.length === 0 && error)) {
|
||||
res.status(502).json({ ok: false, message: error || '查询无结果', data: data === null ? null : data });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, data, status: this.dataService.apiClient.snapshot() });
|
||||
})
|
||||
.catch((err) => this._error(res, err));
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一包装 API-V2 查询。V2 需要单独申请的密钥,未配置时给出明确提示,
|
||||
* 避免用户以为是同一个 Key 没生效。
|
||||
*
|
||||
* @param {import('express').Response} res
|
||||
* @param {() => Promise<*>} handler
|
||||
*/
|
||||
_simpV2(res, handler) {
|
||||
if (!this.dataService.apiV2Client.hasApiKey()) {
|
||||
res.status(400).json({
|
||||
ok: false,
|
||||
message: '未配置 SimpCore API-V2 Key。注意 API 与 API-V2 是两套独立密钥体系,'
|
||||
+ '需要在密钥中心分别申请(配置 → 数据 → API-V2 Key,或在 .env 设置 SIMP_API_V2_KEY)'
|
||||
});
|
||||
return;
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(handler)
|
||||
.then((data) => {
|
||||
const error = this.dataService.apiV2Client.lastError();
|
||||
if (data === null || (Array.isArray(data) && data.length === 0 && error)) {
|
||||
res.status(502).json({ ok: false, message: error || '查询无结果', data: data === null ? null : data });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, data, status: this.dataService.apiV2Client.snapshot() });
|
||||
})
|
||||
.catch((err) => this._error(res, err));
|
||||
}
|
||||
|
||||
/** 兼容不同形态的机器人池:取设置快照 */
|
||||
_settingSnapshot(entry) {
|
||||
if (typeof this.pool.settingSnapshot === 'function') return this.pool.settingSnapshot(entry.id);
|
||||
|
||||
在新工单中引用
屏蔽一个用户