feat: external API for QQ bot - client id+secret auth, ticket tracking, bans, per-server data

- db: api_clients table + server_groups.alias + tickets/bans server_name (auto-migrate)
- external.js: client_id+secret auth (multi-client, bcrypt, show-once secret),
  legacy x-external-key compat, JWT pass-through
  - POST /tickets (optional server), GET /tickets/track?token= (full lifecycle)
  - GET/POST /bans, reported-players/stats/all-tickets with server filter
  - GET /servers (alias list), client CRUD (owner/admin, audit logged)
  - resolveServer: alias | 分组/子服 | server_name
- polls.js: server_groups CRUD with alias (add/edit, dup check)
- servers page: alias display + edit; new 外部API page: client mgmt + quick docs
- API.md: full external API section (auth matrix, server param syntax)
- verified: 37 checks incl. resolveServer sandbox (alias/group/fallback)
This commit is contained in:
2026-08-19 19:12:34 +08:00
parent a11df22600
commit 257cd14051
8 changed files with 393 additions and 54 deletions

View File

@@ -69,6 +69,7 @@
<script src="js/pages/users.js"></script>
<script src="js/pages/templates.js"></script>
<script src="js/pages/notifications.js"></script>
<script src="js/pages/external-api.js"></script>
<script src="js/pages/settings-page.js"></script>
<script src="js/pages/export-page.js"></script>
<script src="js/pages/polls.js"></script>

View File

@@ -9,6 +9,7 @@ const App = {
{ id: 'polls', label: '投票', icon: 'fa-poll', roles: ['owner'] },
{ id: 'features', label: '更新内容', icon: 'fa-lightbulb', roles: ['owner'] },
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] },
{ id: 'external-api', label: '外部API', icon: 'fa-key', roles: ['owner'] },
{ id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner'] },
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] },
{ id: 'logs', label: '系统日志', icon: 'fa-clipboard-list', roles: ['owner','admin'] },
@@ -92,6 +93,7 @@ const App = {
case 'tracking': this.renderMain('追踪工单', TrackingPage, param); break;
case 'users': this.renderMain('用户管理', UsersPage, param); break;
case 'notifications': this.renderMain('通知配置', NotificationsPage, param); break;
case 'external-api': this.renderMain('外部API', ExternalApiPage, param); break;
case 'templates': this.renderMain('邮件模板', TemplatesPage, param); break;
case 'settings': this.renderMain('系统设置', SettingsPage, param); break;
case 'export': this.renderMain('数据导出', ExportPage, param); break;
@@ -173,6 +175,7 @@ window.UnclaimedPage = UnclaimedPage;
window.UsersPage = UsersPage;
window.TemplatesPage = TemplatesPage;
window.NotificationsPage = NotificationsPage;
window.ExternalApiPage = ExternalApiPage;
window.SettingsPage = SettingsPage;
window.ExportPage = ExportPage;
window.PollsPage = PollsPage;

View File

@@ -0,0 +1,81 @@
const ExternalApiPage = {
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
async mount() {
document.getElementById('page-title').textContent = '外部API';
document.getElementById('page-actions').innerHTML = '<button class="btn btn-p btn-sm" onclick="ExternalApiPage.showCreate()"><i class="fas fa-plus"></i> 创建客户端</button>';
await this.load();
},
async load() {
const ct = document.getElementById('page-content');
try {
const clients = await API.get('/external/clients');
ct.innerHTML = `
<div class="card"><div class="card-h"><i class="fas fa-key"></i> API 客户端</div><div class="card-b">
${clients.length === 0 ? '<div class="empty"><i class="fas fa-key"></i><p>暂无客户端</p></div>' : `<div class="table-wrap"><table><thead><tr><th>#</th><th>名称</th><th>Client ID</th><th>状态</th><th>创建时间</th><th>操作</th></tr></thead><tbody>${clients.map(c => `
<tr>
<td>${c.id}</td>
<td>${U.esc(c.name)}</td>
<td><code style="font-size:11px">${U.esc(c.client_id)}</code></td>
<td>${c.active ? '<span class="badge bg-resolved">启用</span>' : '<span class="badge bg-rejected">停用</span>'}</td>
<td class="ts">${U.date(c.created_at)}</td>
<td>
<button class="btn btn-o btn-sm" onclick="ExternalApiPage.toggle(${c.id},${c.active?0:1})">${c.active?'停用':'启用'}</button>
<button class="btn btn-d btn-sm" onclick="ExternalApiPage.del(${c.id})"><i class="fas fa-trash"></i></button>
</td>
</tr>`).join('')}</tbody></table></div>`}
<div class="alert alert-i" style="margin-top:12px"><b>鉴权方式:</b> 请求头 <code>x-api-client-id</code> + <code>x-api-secret</code>(推荐);旧版单 key 仍可用 <code>x-external-key</code>。secret 仅创建时显示一次,请立即保存。</div>
</div></div>
<div class="card"><div class="card-h"><i class="fas fa-book"></i> 接口速览(免用户登录, 需上述鉴权头)</div><div class="card-b"><div class="table-wrap"><table><thead><tr><th>方法</th><th>路径</th><th>说明</th></tr></thead><tbody>
<tr><td>POST</td><td><code>/api/external/tickets</code></td><td>提交工单(举报/建议/申诉),返回 id + tracking_token;可带 <code>server</code>(别名或「分组/子服」)</td></tr>
<tr><td>GET</td><td><code>/api/external/tickets/track?token=xxx</code></td><td>按追踪码查工单状态 + 回复,从提交到结束全程可查</td></tr>
<tr><td>GET</td><td><code>/api/external/all-tickets?server=&status=&type=&page=&limit=</code></td><td>工单列表(可按子服过滤)</td></tr>
<tr><td>GET</td><td><code>/api/external/all-tickets/:id</code></td><td>工单详情(含回复/附件)</td></tr>
<tr><td>GET</td><td><code>/api/external/bans?server=&status=&player=</code></td><td>拉取封禁列表</td></tr>
<tr><td>POST</td><td><code>/api/external/bans</code></td><td>新增封禁(可带 server)</td></tr>
<tr><td>GET</td><td><code>/api/external/reported-players?server=</code></td><td>被举报人统计</td></tr>
<tr><td>GET</td><td><code>/api/external/servers</code></td><td>服务器列表(含别名)</td></tr>
<tr><td>GET</td><td><code>/api/external/stats?server=</code></td><td>工单统计</td></tr>
<tr><td>POST</td><td><code>/api/external/clients</code></td><td>创建客户端(需登录态, 本页操作)</td></tr>
</tbody></table></div>
<p class="help" style="margin-top:8px">完整请求/响应示例见 <code>docs/API.md</code> 外部API章节。</p></div></div>
`;
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
},
async showCreate() {
U.modal('创建 API 客户端', `
<form id="apikey-form">
<div class="form-group"><label>名称 *</label><input id="ak-name" required placeholder="如QQ官方机器人"></div>
<div class="modal-f"><button type="button" class="btn btn-o" data-action="App:closeModal">取消</button><button type="submit" class="btn btn-p">创建</button></div>
</form>
`);
document.getElementById('apikey-form').onsubmit = async e => {
e.preventDefault();
try {
const r = await API.post('/external/clients', { name: document.getElementById('ak-name').value });
App.closeModal();
U.modal('客户端已创建', `
<p>请立即保存以下凭据(secret 只显示一次):</p>
<div class="alert alert-i" style="word-break:break-all;font-family:monospace;font-size:12px">
<div>Client ID: <b>${U.esc(r.client_id)}</b></div>
<div style="margin-top:6px">Secret: <b>${U.esc(r.secret)}</b></div>
</div>
<div class="modal-f"><button type="button" class="btn btn-p" data-action="App:closeModal">我已保存</button></div>
`);
this.load();
} catch(ex){alert(ex.message);}
};
},
async toggle(id, active) {
try { await API.put(`/external/clients/${id}`, { active: !!active }); this.load(); } catch(ex){alert(ex.message);}
},
async del(id) {
if (!await U.confirm('确认删除该客户端?')) return;
try { await API.delete(`/external/clients/${id}`); this.load(); } catch(ex){alert(ex.message);}
}
};

View File

@@ -29,7 +29,8 @@ const ServersPage = {
${servers.map(s => `
<div style="display:flex;align-items:center;gap:6px;background:var(--g1);padding:6px 12px;border-radius:var(--r);font-size:13px">
<i class="fas fa-server ts tm"></i> ${U.esc(s.server_name)}
${Auth.isOwner() ? `<button class="btn btn-d btn-sm" style="padding:2px 6px;font-size:10px" onclick="ServersPage.del(${s.id})"><i class="fas fa-times"></i></button>` : ''}
${s.alias ? `<span class="badge bg-admin" title="外部别名">${U.esc(s.alias)}</span>` : ''}
${Auth.isOwner() ? `<button class="btn btn-o btn-sm" style="padding:2px 6px;font-size:10px" onclick="ServersPage.showEdit(${s.id})"><i class="fas fa-edit"></i></button><button class="btn btn-d btn-sm" style="padding:2px 6px;font-size:10px" onclick="ServersPage.del(${s.id})"><i class="fas fa-times"></i></button>` : ''}
</div>
`).join('')}
</div>
@@ -43,14 +44,49 @@ const ServersPage = {
<form id="add-srv-form">
<div class="grid-2"><div class="form-group"><label>分组名 *</label><input id="as-group" required placeholder="如:网易服务器"></div>
<div class="form-group"><label>子服名 *</label><input id="as-server" required placeholder="如:生存服"></div></div>
<div class="form-group"><label>外部别名(可选)</label><input id="as-alias" placeholder="如survival —— 供外部API按别名拉取该子服数据"><span class="help">外部 API 可用别名或「分组/子服名」定位此服务器</span></div>
<div class="modal-f"><button type="button" class="btn btn-o" data-action="App:closeModal">取消</button><button type="submit" class="btn btn-p">添加</button></div>
</form>
`);
document.getElementById('add-srv-form').onsubmit = async e => {
e.preventDefault();
try { await API.post('/polls/groups', { group_name: document.getElementById('as-group').value, server_name: document.getElementById('as-server').value }); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
try {
await API.post('/polls/groups', {
group_name: document.getElementById('as-group').value,
server_name: document.getElementById('as-server').value,
alias: document.getElementById('as-alias').value.trim(),
});
App.closeModal(); this.load();
} catch(ex){alert(ex.message);}
};
},
async showEdit(id) {
try {
const groups = await API.get('/polls/groups');
const s = groups.find(x => x.id === id);
if (!s) return alert('未找到');
U.modal('编辑服务器', `
<form id="edit-srv-form">
<div class="grid-2"><div class="form-group"><label>分组名 *</label><input id="es-group" required value="${U.esc(s.group_name)}"></div>
<div class="form-group"><label>子服名 *</label><input id="es-server" required value="${U.esc(s.server_name)}"></div></div>
<div class="form-group"><label>外部别名(可选)</label><input id="es-alias" value="${U.esc(s.alias||'')}" placeholder="如survival"><span class="help">外部 API 可用别名或「分组/子服名」定位此服务器</span></div>
<div class="modal-f"><button type="button" class="btn btn-o" data-action="App:closeModal">取消</button><button type="submit" class="btn btn-p">保存</button></div>
</form>
`);
document.getElementById('edit-srv-form').onsubmit = async e => {
e.preventDefault();
try {
await API.put(`/polls/groups/${id}`, {
group_name: document.getElementById('es-group').value,
server_name: document.getElementById('es-server').value,
alias: document.getElementById('es-alias').value.trim(),
});
App.closeModal(); this.load();
} catch(ex){alert(ex.message);}
};
} catch (ex) { alert(ex.message); }
},
async del(id) { if (!await U.confirm('确认删除?')) return; try { await API.req('DELETE', '/polls/groups/'+id); this.load(); } catch(ex){alert(ex.message);} }
};