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

@@ -1,24 +1,103 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const { v4: uuid } = require('uuid');
const { query, getRow, getConfig, getPool } = require('../db');
const { query, getRow, getConfig } = require('../db');
const { generateToken, authenticate } = require('../middleware/auth');
const { sendEmail } = require('../mailer');
const { logSystem } = require('../logger');
const router = express.Router();
// ============ 鉴权 ============
// 支持三种方式(免用户登录):
// 1. x-external-key: 旧版单 key(config.external_api_key)
// 2. x-api-client-id + x-api-secret: api_clients 表多客户端(推荐)
// 3. Authorization: Bearer <jwt>: 用户登录态(插件用)
function getExternalKey() {
try { return getConfig()?.external_api_key || process.env.EXTERNAL_API_KEY || ''; } catch { return process.env.EXTERNAL_API_KEY || ''; }
}
function externalAuth(req, res, next) {
const required = getExternalKey();
if (!required) return next();
if (req.headers['x-external-key'] === required) return next();
return res.status(401).json({ error: '未授权' });
async function clientAuth(req, res, next) {
// 方式1: 旧版单 key
const legacy = getExternalKey();
if (legacy && req.headers['x-external-key'] === legacy) return next();
// 方式2: client_id + secret
const cid = req.headers['x-api-client-id'];
const secret = req.headers['x-api-secret'];
if (cid && secret) {
try {
const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]);
if (client && client.active && bcrypt.compareSync(secret, client.secret_hash)) return next();
} catch {}
return res.status(401).json({ error: '客户端鉴权失败' });
}
// 方式3: 用户 JWT(交给 authenticate 处理, 这里放行)
if (req.headers['authorization']?.startsWith('Bearer ')) return next();
// 方式1 配置了 key 但没带 → 拒绝(避免裸奔)
if (legacy) return res.status(401).json({ error: '未授权' });
// 什么都没配置: 允许(兼容旧部署)
return next();
}
// ---- Auth (no external key needed) ----
// 服务器 alias 解析: '分组/服务器' 或 alias 或 server_name
async function resolveServer(q) {
if (!q) return { group_name: '', server_name: '', alias: '' };
if (q.includes('/')) {
const [g, s] = q.split('/');
return { group_name: g.trim(), server_name: s.trim(), alias: '' };
}
const row = await getRow('SELECT group_name, server_name, alias FROM server_groups WHERE alias = ? OR server_name = ? LIMIT 1', [q, q]);
if (row) return { group_name: row.group_name, server_name: row.server_name, alias: row.alias };
return { group_name: '', server_name: q, alias: '' };
}
// ============ 客户端管理(owner) ============
router.post('/clients', authenticate, async (req, res) => {
try {
if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' });
const { name } = req.body;
if (!name) return res.status(400).json({ error: '名称必填' });
const clientId = 'c_' + crypto.randomBytes(12).toString('hex');
const secret = 's_' + crypto.randomBytes(24).toString('hex');
await query('INSERT INTO api_clients(name, client_id, secret_hash) VALUES (?,?,?)', [name, clientId, bcrypt.hashSync(secret, 10)]);
await logSystem('info', 'api', `创建外部API客户端: ${name}`, { client_id: clientId });
res.json({ client_id: clientId, secret, message: '请立即保存 secret(仅显示一次)' });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.get('/clients', authenticate, async (req, res) => {
try {
if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' });
const rows = await query('SELECT id, name, client_id, active, created_at FROM api_clients ORDER BY id DESC');
res.json(rows);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.put('/clients/:id', authenticate, async (req, res) => {
try {
if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' });
const { active } = req.body;
await query('UPDATE api_clients SET active = ? WHERE id = ?', [active ? 1 : 0, req.params.id]);
await logSystem('info', 'api', `更新外部API客户端 #${req.params.id}`, { active: !!active });
res.json({ message: '已更新' });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/clients/:id', authenticate, async (req, res) => {
try {
if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' });
await query('DELETE FROM api_clients WHERE id = ?', [req.params.id]);
await logSystem('info', 'api', `删除外部API客户端 #${req.params.id}`);
res.json({ message: '已删除' });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ============ Auth (no external key needed) ============
router.post('/auth/register', async (req, res) => {
try {
const { username, password, email, game_name, game_uid, source } = req.body;
@@ -58,48 +137,72 @@ router.post('/auth/login', async (req, res) => {
} catch (e) { res.status(500).json({ error: '服务器错误' }); }
});
// ---- Plugin API (JWT auth) ----
router.use(externalAuth);
// ---- 以下接口: 免用户登录(client_id+secret / x-external-key / JWT) ----
router.use(clientAuth);
// ============ 工单: 提交 ============
// server: '分组/服务器名' 或 alias 或 server_name(可选)
router.post('/tickets', async (req, res) => {
try {
const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, is_admin_complaint, server } = req.body;
if (!type || !['report','suggestion','appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' });
if (!title || !reporter_game_name || !reporter_game_uid) return res.status(400).json({ error: '必填字段不完整' });
if (type === 'report' && !reason) return res.status(400).json({ error: '请填写举报原因' });
if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' });
if (type === 'appeal' && (!reason || !description)) return res.status(400).json({ error: '请填写完整申诉信息' });
const countRow = await getRow("SELECT COUNT(*) as c FROM tickets WHERE (reporter_game_name = ? OR reporter_game_uid = ?) AND status IN ('pending','processing','awaiting_info','appealing')", [reporter_game_name, reporter_game_uid]);
if (countRow.c >= 5) return res.status(400).json({ error: '待处理工单已达上限' });
const sv = await resolveServer(server);
const trackingToken = uuid();
const r = await query(`INSERT INTO tickets(type,title,reporter_game_name,reporter_game_uid,target_game_name,target_game_uid,reason,description,tracking_token,is_admin_complaint,server_name) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
[type, title, reporter_game_name, reporter_game_uid, target_game_name||'', target_game_uid||'', reason||'', description||'', trackingToken, is_admin_complaint?1:0, sv.server_name]);
await query("INSERT INTO responses(ticket_id,content,is_staff) VALUES (?,?,0)", [r.insertId, `游戏内提交\n类型: ${type}\n提交人: ${reporter_game_name} (UID: ${reporter_game_uid})${sv.server_name?'\n服务器: '+sv.server_name:''}`]);
res.status(201).json({ id: r.insertId, tracking_token: trackingToken, server: sv.server_name });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ============ 工单: 追踪(token 即凭据, 提交人可查) ============
router.get('/tickets/track', async (req, res) => {
try {
const { token } = req.query;
if (!token) return res.status(400).json({ error: '缺少 tracking_token' });
const t = await getRow(`SELECT id, type, title, status, priority, reporter_game_name, reporter_game_uid,
target_game_name, target_game_uid, reason, description, assigned_to, claim_note, server_name, created_at, updated_at
FROM tickets WHERE tracking_token = ?`, [token]);
if (!t) return res.status(404).json({ error: '工单不存在' });
t.responses = await query('SELECT content, is_staff, created_at FROM responses WHERE ticket_id = ? ORDER BY created_at ASC', [t.id]);
res.json(t);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// 我的工单(JWT 用户)
router.get('/my-tickets', authenticate, async (req, res) => {
const { limit } = req.query;
const rows = await query(`SELECT id, type, title, status, priority, created_at, updated_at FROM tickets WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?`, [req.user.id, Math.min(parseInt(limit)||10, 50)]);
const rows = await query(`SELECT id, type, title, status, priority, server_name, created_at, updated_at FROM tickets WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?`, [req.user.id, Math.min(parseInt(limit)||10, 50)]);
res.json({ list: rows });
});
router.get('/my-tickets/:id', authenticate, async (req, res) => {
const t = await getRow(`SELECT id, type, title, status, priority, reporter_game_name, reporter_game_uid,
target_game_name, target_game_uid, reason, description, assigned_to, claim_note, created_at, updated_at
target_game_name, target_game_uid, reason, description, assigned_to, claim_note, server_name, created_at, updated_at
FROM tickets WHERE id = ? AND user_id = ?`, [req.params.id, req.user.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
res.json(t);
});
router.post('/tickets', async (req, res) => {
const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, is_admin_complaint } = req.body;
if (!type || !['report','suggestion','appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' });
if (!title || !reporter_game_name || !reporter_game_uid) return res.status(400).json({ error: '必填字段不完整' });
if (type === 'report' && !reason) return res.status(400).json({ error: '请填写举报原因' });
if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' });
if (type === 'appeal' && (!reason || !description)) return res.status(400).json({ error: '请填写完整申诉信息' });
const countRow = await getRow("SELECT COUNT(*) as c FROM tickets WHERE (reporter_game_name = ? OR reporter_game_uid = ?) AND status IN ('pending','processing','awaiting_info','appealing')", [reporter_game_name, reporter_game_uid]);
if (countRow.c >= 5) return res.status(400).json({ error: '待处理工单已达上限' });
const trackingToken = uuid();
const [r] = await query(`INSERT INTO tickets(type,title,reporter_game_name,reporter_game_uid,target_game_name,target_game_uid,reason,description,tracking_token,is_admin_complaint) VALUES (?,?,?,?,?,?,?,?,?,?)`,
[type, title, reporter_game_name, reporter_game_uid, target_game_name||'', target_game_uid||'', reason||'', description||'', trackingToken, is_admin_complaint?1:0]);
await query("INSERT INTO responses(ticket_id,content,is_staff) VALUES (?,?,0)", [r.insertId, `游戏内提交\n类型: ${type}\n提交人: ${reporter_game_name} (UID: ${reporter_game_uid})`]);
res.status(201).json({ id: r.insertId, tracking_token: trackingToken });
});
// ---- Java Server API (x-external-key only) ----
// ============ 工单列表(带 server 过滤) ============
router.get('/all-tickets', async (req, res) => {
const { type, status, page, limit } = req.query;
let q = `SELECT id, type, title, status, priority, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, assigned_to, claim_note, created_at, updated_at FROM tickets WHERE 1=1`;
const { type, status, server, page, limit } = req.query;
let q = `SELECT id, type, title, status, priority, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, assigned_to, claim_note, server_name, created_at, updated_at FROM tickets WHERE 1=1`;
const p = [];
if (type) { q += ' AND type = ?'; p.push(type); }
if (status) { q += ' AND status = ?'; p.push(status); }
if (server) {
const sv = await resolveServer(server);
q += ' AND server_name = ?'; p.push(sv.server_name);
}
const pg = parseInt(page) || 1;
const lm = Math.min(parseInt(limit) || 50, 200);
q += ` ORDER BY updated_at DESC LIMIT ${(pg-1)*lm}, ${lm}`;
@@ -114,23 +217,72 @@ router.get('/all-tickets/:id', async (req, res) => {
res.json(t);
});
// ============ 封禁 ============
// 拉取封禁: ?server=alias|server_name|分组/服务器&status=active|expired|appealed|lifted&player=&limit=
router.get('/bans', async (req, res) => {
const { server, status, player, page, limit } = req.query;
let q = 'SELECT id, player_name, player_uid, source, reason, type, duration, server_name, status, created_at, expires_at FROM bans WHERE 1=1';
const p = [];
if (server) {
const sv = await resolveServer(server);
q += ' AND server_name = ?'; p.push(sv.server_name);
}
if (status) { q += ' AND status = ?'; p.push(status); }
if (player) { q += ' AND (player_name = ? OR player_uid = ?)'; p.push(player, player); }
const pg = parseInt(page) || 1;
const lm = Math.min(parseInt(limit) || 100, 500);
q += ` ORDER BY created_at DESC LIMIT ${(pg-1)*lm}, ${lm}`;
res.json(await query(q, p));
});
// 新增封禁(可带 server)
router.post('/bans', async (req, res) => {
try {
const { player_name, player_uid, source, reason, type, duration, expires_at, server, status } = req.body;
if (!player_name) return res.status(400).json({ error: 'player_name 必填' });
const sv = await resolveServer(server);
const r = await query(`INSERT INTO bans(player_name, player_uid, source, reason, type, duration, server_name, status, expires_at) VALUES (?,?,?,?,?,?,?,?,?)`,
[player_name, player_uid||'', source||'', reason||'', ['ban','mute','warn','other'].includes(type)?type:'ban', duration||'', sv.server_name, ['active','expired','appealed','lifted'].includes(status)?status:'active', expires_at||null]);
await logSystem('info', 'api', `外部创建封禁: ${player_name}`, { server: sv.server_name, type: type||'ban' });
res.status(201).json({ id: r.insertId });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ============ 被举报人统计(带 server 过滤) ============
router.get('/reported-players', async (req, res) => {
const rows = await query(`SELECT target_game_name, target_game_uid, COUNT(*) as total,
const { server } = req.query;
let q = `SELECT target_game_name, target_game_uid, COUNT(*) as total,
SUM(CASE WHEN status IN ('pending','processing','awaiting_info','appealing') THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as resolved,
MAX(created_at) as last_report
FROM tickets WHERE type='report' AND target_game_name IS NOT NULL AND target_game_name != ''
GROUP BY target_game_name, target_game_uid ORDER BY total DESC LIMIT 500`);
FROM tickets WHERE type='report' AND target_game_name IS NOT NULL AND target_game_name != ''`;
const p = [];
if (server) {
const sv = await resolveServer(server);
q += ' AND server_name = ?'; p.push(sv.server_name);
}
q += ' GROUP BY target_game_name, target_game_uid ORDER BY total DESC LIMIT 500';
res.json(await query(q, p));
});
// ============ 服务器列表(含 alias) ============
router.get('/servers', async (req, res) => {
const rows = await query('SELECT group_name, server_name, alias FROM server_groups ORDER BY group_name, server_name');
res.json(rows);
});
router.get('/stats', async (req, res) => {
const [[{c:total}], byType, byStatus] = await Promise.all([
query('SELECT COUNT(*) as c FROM tickets'),
query('SELECT type, COUNT(*) as c FROM tickets GROUP BY type'),
query('SELECT status, COUNT(*) as c FROM tickets GROUP BY status'),
]);
res.json({ total, byType, byStatus });
const { server } = req.query;
let q = 'SELECT COUNT(*) as c FROM tickets';
const p = [];
if (server) {
const sv = await resolveServer(server);
q += ' WHERE server_name = ?'; p.push(sv.server_name);
}
const total = await getRow(q, p);
const byType = await query('SELECT type, COUNT(*) as c FROM tickets ' + (server ? 'WHERE server_name = ? ' : '') + 'GROUP BY type', p);
const byStatus = await query('SELECT status, COUNT(*) as c FROM tickets ' + (server ? 'WHERE server_name = ? ' : '') + 'GROUP BY status', p);
res.json({ total: total.c, byType, byStatus });
});
module.exports = router;

View File

@@ -9,12 +9,27 @@ router.get('/groups', authenticate, async (req, res) => {
});
router.post('/groups', authenticate, requireRole('owner'), async (req, res) => {
const { group_name, server_name } = req.body;
const { group_name, server_name, alias } = req.body;
if (!group_name || !server_name) return res.status(400).json({ error: '分组名和子服名为必填' });
await query('INSERT IGNORE INTO server_groups(group_name, server_name) VALUES (?,?)', [group_name, server_name]);
if (alias) {
const dup = await getRow('SELECT id FROM server_groups WHERE alias = ?', [alias]);
if (dup) return res.status(400).json({ error: '该外部别名已被使用' });
}
await query('INSERT IGNORE INTO server_groups(group_name, server_name, alias) VALUES (?,?,?)', [group_name, server_name, alias || '']);
res.status(201).json({ message: '已添加' });
});
router.put('/groups/:id', authenticate, requireRole('owner'), async (req, res) => {
const { group_name, server_name, alias } = req.body;
if (!group_name || !server_name) return res.status(400).json({ error: '分组名和子服名为必填' });
if (alias) {
const dup = await getRow('SELECT id FROM server_groups WHERE alias = ? AND id != ?', [alias, req.params.id]);
if (dup) return res.status(400).json({ error: '该外部别名已被使用' });
}
await query('UPDATE server_groups SET group_name = ?, server_name = ?, alias = ? WHERE id = ?', [group_name, server_name, alias || '', req.params.id]);
res.json({ message: '已更新' });
});
router.delete('/groups/:id', authenticate, requireRole('owner'), async (req, res) => {
await query('DELETE FROM server_groups WHERE id = ?', [req.params.id]);
res.json({ message: '已删除' });