- game_uid is now an internal param: auto-generated server-side (U + 12 hex) on register/bind-identity/install/admin-create/external register; player-supplied game_uid ignored - Removed all player-facing UID inputs: register page, install page, identity bind modal, ticket-create 'your UID' field - Removed player-facing UID display: identity table column, ticket detail/tracking reporter UID - Removed '同一账号可绑定网易端 + 皮肤站身份' / '可绑定多个来源身份' copy - Kept target_game_uid (reported player) and admin panel UID management - settings-page skin-site placeholder updated
335 lines
18 KiB
JavaScript
335 lines
18 KiB
JavaScript
/*
|
|
* MC Report System
|
|
* Copyright (C) 2026 Sea Network Technology Studio
|
|
* Author: CangLan <admin@sea-studio.top>
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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();
|
|
|
|
// ============ 鉴权 ============
|
|
// 流程: 客户端用 ID + Secret 换取 SESSION(短期有效), 之后所有请求用
|
|
// Authorization: Bearer <SESSION>
|
|
// 无 SESSION / SESSION 无效或过期 / 客户端停用 → 一律 401
|
|
|
|
const SESSION_TTL_HOURS = 24;
|
|
|
|
function genClientId() {
|
|
// 16 位纯数字, 随机不递增
|
|
let id = '';
|
|
for (let i = 0; i < 16; i++) id += crypto.randomInt(0, 10);
|
|
return id;
|
|
}
|
|
|
|
function genSecret() {
|
|
// 32 位全随机字符串(hex = 32 chars)
|
|
return crypto.randomBytes(16).toString('hex');
|
|
}
|
|
|
|
async function createSession(clientId) {
|
|
const token = crypto.randomBytes(48).toString('hex');
|
|
await query(
|
|
'INSERT INTO api_sessions(client_id, session_token, expires_at) VALUES (?,?,DATE_ADD(NOW(), INTERVAL ? HOUR))',
|
|
[clientId, token, SESSION_TTL_HOURS]
|
|
);
|
|
return { session_token: token, expires_in: SESSION_TTL_HOURS * 3600 };
|
|
}
|
|
|
|
async function clientAuth(req, res, next) {
|
|
const h = req.headers.authorization;
|
|
if (!h || !h.startsWith('Bearer ')) return res.status(401).json({ error: '未授权: 缺少 Authorization: Bearer <SESSION>' });
|
|
const token = h.split(' ')[1];
|
|
try {
|
|
const row = await getRow(
|
|
`SELECT s.client_id, s.expires_at, c.active AS client_active
|
|
FROM api_sessions s JOIN api_clients c ON c.client_id = s.client_id
|
|
WHERE s.session_token = ? AND s.expires_at > NOW()`,
|
|
[token]
|
|
);
|
|
if (!row || !row.client_active) return res.status(401).json({ error: 'SESSION 无效或已过期' });
|
|
req.apiClient = { client_id: row.client_id };
|
|
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 = genClientId();
|
|
const secret = genSecret();
|
|
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 id = parseInt(req.params.id);
|
|
if (!id) return res.status(400).json({ error: '无效的ID' });
|
|
const { active } = req.body;
|
|
if (active === undefined || (typeof active !== 'boolean' && ![0,1,'0','1'].includes(active))) return res.status(400).json({ error: 'active 必须为布尔值' });
|
|
await query('UPDATE api_clients SET active = ? WHERE id = ?', [active ? 1 : 0, id]);
|
|
await logSystem('info', 'api', `更新外部API客户端 #${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: '无权限' });
|
|
const id = parseInt(req.params.id);
|
|
if (!id) return res.status(400).json({ error: '无效的ID' });
|
|
await query('DELETE FROM api_clients WHERE id = ?', [id]);
|
|
await logSystem('info', 'api', `删除外部API客户端 #${id}`);
|
|
res.json({ message: '已删除' });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ============ 换取 SESSION(ID + Secret → Bearer SESSION) ============
|
|
// 仅此一个接口用 ID+Secret 鉴权, 其余接口一律 Bearer SESSION
|
|
router.post('/auth/session', async (req, res) => {
|
|
try {
|
|
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' });
|
|
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: '客户端鉴权失败' });
|
|
}
|
|
// 清理该客户端旧 SESSION(单会话)
|
|
await query('DELETE FROM api_sessions WHERE client_id = ?', [cid]);
|
|
const sess = await createSession(cid);
|
|
await logSystem('info', 'api', `外部API客户端换取SESSION: ${client.name}`, { client_id: cid });
|
|
res.json({ ...sess, client_id: cid, message: `SESSION 有效期 ${SESSION_TTL_HOURS} 小时, 请用 Authorization: Bearer <session> 访问其余接口` });
|
|
} catch (e) { res.status(500).json({ error: '服务器错误' }); }
|
|
});
|
|
|
|
// ---- 以下接口: 外部鉴权(Bearer SESSION, 唯一方式) ----
|
|
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) 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();
|
|
// 游戏UID为内置参数: 不接收客户端输入, 由系统自动生成
|
|
const builtinUid = 'U' + crypto.randomBytes(6).toString('hex').toUpperCase();
|
|
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, builtinUid, verifyToken, source || '']);
|
|
try {
|
|
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
|
|
[r.insertId, source || '', game_name, builtinUid]);
|
|
} 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: builtinUid, 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) return res.status(400).json({ error: '必填字段不完整' });
|
|
if (String(title).length > 100) return res.status(400).json({ error: '标题过长(≤100)' });
|
|
if (String(reporter_game_name).length > 50) return res.status(400).json({ error: '游戏名过长' });
|
|
if (reason && String(reason).length > 500) return res.status(400).json({ error: '原因过长(≤500)' });
|
|
if (description && String(description).length > 5000) return res.status(400).json({ error: '内容过长(≤5000)' });
|
|
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}${reporter_game_uid?' (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 过滤) ============
|
|
const TICKET_TYPES = ['report', 'suggestion', 'appeal', 'result_appeal'];
|
|
const TICKET_STATUSES = ['pending', 'processing', 'awaiting_info', 'appealing', 'resolved', 'rejected', 'closed'];
|
|
const BAN_STATUSES = ['active', 'expired', 'appealed', 'lifted'];
|
|
|
|
router.get('/all-tickets', async (req, res) => {
|
|
const { type, status, server, page, limit } = req.query;
|
|
if (type && !TICKET_TYPES.includes(type)) return res.status(400).json({ error: '无效的 type' });
|
|
if (status && !TICKET_STATUSES.includes(status)) return res.status(400).json({ error: '无效的 status' });
|
|
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 = Math.max(parseInt(page) || 1, 1);
|
|
const lm = Math.min(Math.max(parseInt(limit) || 50, 1), 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 id = parseInt(req.params.id);
|
|
if (!id) return res.status(400).json({ error: '无效的ID' });
|
|
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [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', [id]);
|
|
t.attachments = await query('SELECT id, original_name, mime_type, size FROM attachments WHERE ticket_id = ?', [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;
|
|
if (status && !BAN_STATUSES.includes(status)) return res.status(400).json({ error: '无效的 status' });
|
|
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 = Math.max(parseInt(page) || 1, 1);
|
|
const lm = Math.min(Math.max(parseInt(limit) || 100, 1), 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 必填' });
|
|
if (player_name.length > 50) return res.status(400).json({ error: 'player_name 过长' });
|
|
if (type && !['ban','mute','warn','other'].includes(type)) return res.status(400).json({ error: '无效的 type' });
|
|
if (status && !BAN_STATUSES.includes(status)) return res.status(400).json({ error: '无效的 status' });
|
|
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;
|