const express = require('express'); const bcrypt = require('bcryptjs'); const crypto = require('crypto'); const { v4: uuid } = require('uuid'); const { query, getRow } = require('../db'); const { authenticate } = require('../middleware/auth'); const { sendEmail } = require('../mailer'); const { logSystem } = require('../logger'); const router = express.Router(); // ============ 鉴权 ============ // 外部 API 唯一鉴权方式: ID + Secret(api_clients 表) // 无凭据 / 凭据错误 / 客户端停用 → 一律 401(不允许匿名访问) async function clientAuth(req, res, next) { const cid = req.headers['x-api-client-id']; const secret = req.headers['x-api-secret']; if (!cid || !secret) return res.status(401).json({ error: '未授权: 缺少 x-api-client-id / x-api-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 res.status(401).json({ error: '客户端鉴权失败' }); } req.apiClient = client; next(); } catch (e) { res.status(500).json({ error: '鉴权服务异常' }); } } // 服务器 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 }); } }); // ---- 以下接口: 外部鉴权(client_id + secret, 唯一方式) ---- router.use(clientAuth); // ============ 外部注册(需客户端鉴权) ============ router.post('/auth/register', async (req, res) => { try { const { username, password, email, game_name, game_uid, source } = req.body; if (!username || !password || !email || !game_name || !game_uid) return res.status(400).json({ error: '所有字段必填' }); if (password.length < 6) return res.status(400).json({ error: '密码至少6位' }); if (await getRow('SELECT id FROM users WHERE username = ?', [username])) return res.status(400).json({ error: '注册失败,请检查输入信息' }); if (await getRow('SELECT id FROM users WHERE email = ?', [email])) return res.status(400).json({ error: '注册失败,请检查输入信息' }); const hashed = bcrypt.hashSync(password, 10); const verifyToken = uuid(); const r = await query('INSERT INTO users(username,password,email,game_name,game_uid,verify_token,verify_expires,source) VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 24 HOUR),?)', [username, hashed, email, game_name, game_uid, verifyToken, source || 'skin']); try { await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)', [r.insertId, source || 'skin', game_name, game_uid]); } catch {} const site = await getRow("SELECT v FROM settings WHERE k='site_url'"); const sent = await sendEmail(email, 'verify_email', { username, game_name, game_uid, verify_link: `${site?.v||'http://localhost:3100'}#/verify?token=${verifyToken}` }); if (!sent) { await query('UPDATE users SET email_verified=1,active=1,verify_token=NULL WHERE username=?', [username]); return res.status(201).json({ message: '注册成功!已自动激活,请登录' }); } res.status(201).json({ message: '注册成功,请查收验证邮件' }); } catch (e) { res.status(500).json({ error: '服务器错误' }); } }); // ============ 工单: 提交 ============ // 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 }); } }); // ============ 工单列表(带 server 过滤) ============ router.get('/all-tickets', async (req, res) => { 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}`; res.json(await query(q, p)); }); router.get('/all-tickets/:id', async (req, res) => { const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]); if (!t) return res.status(404).json({ error: 'Not found' }); t.responses = await query('SELECT content, is_staff, created_at FROM responses WHERE ticket_id = ? ORDER BY created_at ASC', [req.params.id]); t.attachments = await query('SELECT id, original_name, mime_type, size FROM attachments WHERE ticket_id = ?', [req.params.id]); 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 { 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 != ''`; 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 { 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;