feat: MC Report System - MySQL + Express + Vanilla JS SPA

This commit is contained in:
2026-07-12 01:23:19 +08:00
commit 247a4e851d
52 changed files with 5710 additions and 0 deletions

40
backend/captcha.js Normal file
View File

@@ -0,0 +1,40 @@
const crypto = require('crypto');
const { getDb } = require('./db');
function generate() {
const db = getDb();
db.prepare("DELETE FROM captchas WHERE datetime(created_at) < datetime('now','localtime','-5 minutes')").run();
const id = crypto.randomUUID();
const ops = ['+', '-', '\u00d7'];
const op = ops[Math.floor(Math.random() * ops.length)];
let a, b, answer;
if (op === '\u00d7') {
a = Math.floor(Math.random() * 9) + 1;
b = Math.floor(Math.random() * 9) + 1;
} else {
a = Math.floor(Math.random() * 20) + 1;
b = Math.floor(Math.random() * 10) + 1;
if (op === '-' && a < b) [a, b] = [b, a];
}
if (op === '+') answer = a + b;
else if (op === '-') answer = a - b;
else answer = a * b;
const question = `${a} ${op} ${b} = ?`;
db.prepare('INSERT INTO captchas(id, question, answer) VALUES (?,?,?)').run(id, question, String(answer));
return { id, question };
}
function verify(id, answer) {
const db = getDb();
const row = db.prepare("SELECT * FROM captchas WHERE id = ? AND datetime(created_at) > datetime('now','localtime','-5 minutes')").get(id);
if (!row) return false;
db.prepare('DELETE FROM captchas WHERE id = ?').run(id);
return String(answer).trim() === row.answer;
}
module.exports = { generate, verify };

217
backend/db.js Normal file
View File

@@ -0,0 +1,217 @@
const mysql = require('mysql2/promise');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const CONFIG_PATH = path.join(__dirname, '..', 'data', 'config.json');
let pool = null;
function getConfig() {
if (fs.existsSync(CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
}
return null;
}
function saveConfig(cfg) {
const dir = path.dirname(CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf-8');
}
function isInstalled() {
return fs.existsSync(CONFIG_PATH);
}
async function getPool() {
if (!pool) {
const cfg = getConfig();
if (!cfg) throw new Error('SYSTEM_NOT_INSTALLED');
pool = mysql.createPool({
host: cfg.db_host || 'localhost',
port: cfg.db_port || 3306,
user: cfg.db_user,
password: cfg.db_pass,
database: cfg.db_name,
waitForConnections: true,
connectionLimit: 10,
charset: 'utf8mb4',
timezone: '+08:00',
});
}
return pool;
}
async function query(sql, params = []) {
const p = await getPool();
const [rows, fields] = await p.execute(sql, params);
return rows;
}
async function getRow(sql, params = []) {
const rows = await query(sql, params);
return rows[0] || null;
}
async function initSchema(connection) {
const db = connection || await getPool();
const createIfNotExists = async (tableName, sql) => {
const [rows] = await db.execute(`SELECT COUNT(*) as c FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?`, [tableName]);
if (rows[0].c > 0) return; // Table exists
await db.execute(sql);
};
await createIfNotExists('users', `CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
email VARCHAR(100) NOT NULL,
game_name VARCHAR(50) NOT NULL,
game_uid VARCHAR(50) NOT NULL,
role ENUM('owner','admin','player') NOT NULL DEFAULT 'player',
active TINYINT(1) NOT NULL DEFAULT 0,
email_verified TINYINT(1) NOT NULL DEFAULT 0,
verify_token VARCHAR(255),
verify_expires DATETIME,
reset_token VARCHAR(255),
reset_expires DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('tickets', `CREATE TABLE tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
type ENUM('report','suggestion','appeal') NOT NULL,
title VARCHAR(100) NOT NULL,
status ENUM('pending','processing','awaiting_info','resolved','rejected','closed') NOT NULL DEFAULT 'pending',
priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium',
reporter_game_name VARCHAR(50) NOT NULL,
reporter_game_uid VARCHAR(50) NOT NULL,
target_game_name VARCHAR(50),
target_game_uid VARCHAR(50),
reason VARCHAR(500),
description TEXT,
assigned_to INT,
claim_note TEXT,
claimed_at DATETIME,
tracking_token VARCHAR(255) UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_type (type),
INDEX idx_status (status),
INDEX idx_user (user_id),
INDEX idx_tracking (tracking_token),
INDEX idx_assigned (assigned_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('ticket_transfers', `CREATE TABLE ticket_transfers (
id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id INT NOT NULL,
from_user_id INT NOT NULL,
to_user_id INT NOT NULL,
reason TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ticket (ticket_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('attachments', `CREATE TABLE attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id INT NOT NULL,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ticket (ticket_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('responses', `CREATE TABLE responses (
id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id INT NOT NULL,
user_id INT,
content TEXT NOT NULL,
is_staff TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ticket (ticket_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('email_templates', `CREATE TABLE email_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
subject VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
description TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('notification_configs', `CREATE TABLE notification_configs (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type ENUM('webhook','email') NOT NULL DEFAULT 'webhook',
webhook_url TEXT,
events VARCHAR(500) NOT NULL DEFAULT 'all',
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('settings', `CREATE TABLE settings (
k VARCHAR(100) PRIMARY KEY,
v TEXT NOT NULL,
label VARCHAR(255),
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('captchas', `CREATE TABLE captchas (
id VARCHAR(255) PRIMARY KEY,
question VARCHAR(100) NOT NULL,
answer VARCHAR(20) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('audit_logs', `CREATE TABLE audit_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
username VARCHAR(50),
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(50),
entity_id INT,
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await seedTemplates(db);
}
async function seedTemplates(db) {
const [rows] = await db.execute('SELECT COUNT(*) as c FROM email_templates');
if (rows[0].c > 0) return;
const temps = [
['verify_email', '邮箱验证', '【{{site_name}}】请验证您的邮箱',
'<p>您好 <strong>{{username}}</strong>(游戏名: {{game_name}}UID: {{game_uid}}</p><p>感谢注册!请点击以下链接验证邮箱:</p><p><a href="{{verify_link}}">{{verify_link}}</a></p><p>链接有效期24小时。</p><p>— {{site_name}}</p>',
'变量: {{site_name}}, {{username}}, {{game_name}}, {{game_uid}}, {{verify_link}}'],
['ticket_created', '工单创建通知', '【{{site_name}}】{{ticket_type}}已提交 #{{ticket_id}}',
'<p><strong>{{reporter_game_name}}</strong>UID: {{reporter_game_uid}}),您的{{ticket_type}}已提交。</p><p>工单编号: <strong>#{{ticket_id}}</strong></p><p>标题: {{ticket_title}}</p><p><a href="{{tracking_link}}">查看工单</a></p><p>— {{site_name}}</p>',
'变量: {{site_name}}, {{reporter_game_name}}, {{ticket_type}}, {{ticket_id}}, {{ticket_title}}, {{tracking_link}}'],
['ticket_updated', '工单状态更新', '【{{site_name}}】工单 #{{ticket_id}} 状态更新',
'<p>工单 <strong>#{{ticket_id}}</strong> 状态已更新为 <strong>{{ticket_status}}</strong>。</p><p>标题: {{ticket_title}}</p><p><a href="{{tracking_link}}">查看详情</a></p><p>— {{site_name}}</p>',
'变量: {{site_name}}, {{ticket_id}}, {{ticket_title}}, {{ticket_status}}, {{tracking_link}}'],
['ticket_claimed', '工单被认领', '【{{site_name}}】工单 #{{ticket_id}} 已被认领',
'<p>工单 <strong>#{{ticket_id}}</strong>{{ticket_title}})已被 <strong>{{assigned_to}}</strong> 认领。</p><p>— {{site_name}}</p>',
'变量: {{site_name}}, {{ticket_id}}, {{ticket_title}}, {{assigned_to}}'],
['ticket_transferred', '工单已转交', '【{{site_name}}】工单 #{{ticket_id}} 已转交',
'<p>工单 <strong>#{{ticket_id}}</strong>{{ticket_title}})已由 {{from_user}} 转交给 {{to_user}}。</p><p>— {{site_name}}</p>',
'变量: {{site_name}}, {{ticket_id}}, {{ticket_title}}, {{from_user}}, {{to_user}}'],
];
for (const t of temps) {
await db.execute('INSERT INTO email_templates(code, name, subject, body, description) VALUES (?,?,?,?,?)', t);
}
}
module.exports = { getPool, query, getRow, initSchema, isInstalled, getConfig, saveConfig, CONFIG_PATH };

59
backend/mailer.js Normal file
View File

@@ -0,0 +1,59 @@
const nodemailer = require('nodemailer');
const { getPool, query, getRow, getConfig } = require('./db');
async function getSmtpConfig() {
try {
const rows = await query("SELECT k, v FROM settings WHERE k LIKE 'smtp_%'");
const cfg = {};
for (const r of rows) cfg[r.k] = r.v;
return {
host: cfg.smtp_host || '',
port: parseInt(cfg.smtp_port) || 587,
secure: cfg.smtp_secure === '1',
user: cfg.smtp_user || '',
pass: cfg.smtp_pass || '',
from: cfg.smtp_from || 'noreply@example.com',
};
} catch { return { host: '', port: 587, secure: false, user: '', pass: '', from: '' }; }
}
async function getSiteName() {
const row = await getRow("SELECT v FROM settings WHERE k = 'site_name'");
return row ? row.v : 'MC举报系统';
}
async function getSiteUrl() {
const row = await getRow("SELECT v FROM settings WHERE k = 'site_url'");
return row ? row.v : 'http://localhost:3100';
}
async function getTemplate(code) {
return getRow('SELECT * FROM email_templates WHERE code = ?', [code]);
}
function renderTemplate(template, vars) {
let { subject, body } = template;
for (const [k, v] of Object.entries(vars)) {
const re = new RegExp(`\\{\\{${k}\\}\\}`, 'g');
subject = subject.replace(re, v || '');
body = body.replace(re, v || '');
}
return { subject, body };
}
async function sendEmail(to, templateCode, vars) {
try {
const cfg = await getSmtpConfig();
if (!cfg.host || !cfg.user) { console.log('[Mailer] SMTP未配置'); return false; }
const template = await getTemplate(templateCode);
if (!template) { console.log('[Mailer] 模板不存在:', templateCode); return false; }
const allVars = { ...vars, site_name: await getSiteName(), site_url: await getSiteUrl() };
const { subject, body } = renderTemplate(template, allVars);
const transporter = nodemailer.createTransport({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: { user: cfg.user, pass: cfg.pass } });
await transporter.sendMail({ from: `"${await getSiteName()}" <${cfg.from}>`, to, subject, html: body });
return true;
} catch (err) { console.error('[Mailer]', err.message); return false; }
}
module.exports = { sendEmail, renderTemplate, getTemplate, getSmtpConfig, getSiteName, getSiteUrl };

View File

@@ -0,0 +1,37 @@
const jwt = require('jsonwebtoken');
const { getConfig } = require('../db');
function getSecret() {
try {
const cfg = getConfig();
return cfg?.jwt_secret || process.env.JWT_SECRET || 'mc-dev-secret';
} catch { return process.env.JWT_SECRET || 'mc-dev-secret'; }
}
function generateToken(user) {
return jwt.sign({ id: user.id, username: user.username, role: user.role, game_name: user.game_name, game_uid: user.game_uid, email: user.email }, getSecret(), { expiresIn: '72h' });
}
function verifyToken(token) { return jwt.verify(token, getSecret()); }
function authenticate(req, res, next) {
const h = req.headers.authorization;
if (!h || !h.startsWith('Bearer ')) return res.status(401).json({ error: '请先登录' });
try { req.user = verifyToken(h.split(' ')[1]); next(); } catch { return res.status(401).json({ error: '登录已过期' }); }
}
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: '请先登录' });
if (!roles.includes(req.user.role)) return res.status(403).json({ error: '权限不足' });
next();
};
}
function optionalAuth(req, res, next) {
const h = req.headers.authorization;
if (h && h.startsWith('Bearer ')) { try { req.user = verifyToken(h.split(' ')[1]); } catch {} }
next();
}
module.exports = { generateToken, verifyToken, authenticate, requireRole, optionalAuth };

View File

@@ -0,0 +1,145 @@
const xss = require('xss');
const rateLimit = require('express-rate-limit');
const fs = require('fs');
const path = require('path');
const CONFIG_PATH = path.join(__dirname, '..', '..', 'data', 'config.json');
function readApiKey() {
try {
if (fs.existsSync(CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')).api_key || null;
}
} catch {}
return null;
}
let cachedApiKey = null;
function getApiKey() {
if (cachedApiKey === null) cachedApiKey = readApiKey();
return cachedApiKey;
}
function clearApiKeyCache() { cachedApiKey = null; }
const xssOptions = {
whiteList: {},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script', 'style', 'xml', 'iframe', 'object', 'embed'],
};
function sanitize(value) {
if (typeof value !== 'string') return value;
return xss(value, xssOptions).trim();
}
function sanitizeBody(req, res, next) {
if (req.body) {
for (const key of Object.keys(req.body)) {
if (typeof req.body[key] === 'string') {
req.body[key] = sanitize(req.body[key]);
}
}
}
next();
}
function validateLengths(rules) {
return (req, res, next) => {
for (const [field, maxLen] of Object.entries(rules)) {
const val = req.body[field];
if (val && typeof val === 'string' && val.length > maxLen) {
return res.status(400).json({ error: `${field} 长度不能超过 ${maxLen} 个字符` });
}
}
next();
};
}
const loginLimiter = rateLimit({
windowMs: 60 * 1000,
max: 8,
message: { error: '登录尝试过于频繁请1分钟后再试' },
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
const registerLimiter = rateLimit({
windowMs: 60 * 1000,
max: 3,
message: { error: '注册请求过于频繁请1分钟后再试' },
standardHeaders: true,
legacyHeaders: false,
});
const ticketLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
message: { error: '提交过于频繁,请稍后再试' },
standardHeaders: true,
legacyHeaders: false,
});
const ticketAnonLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: { error: '匿名提交过于频繁,请登录后再试或稍后重试' },
standardHeaders: true,
legacyHeaders: false,
});
const uploadLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: { error: '上传请求过于频繁' },
standardHeaders: true,
legacyHeaders: false,
});
const generalLimiter = rateLimit({
windowMs: 60 * 1000,
max: 300,
message: { error: '请求过于频繁' },
standardHeaders: true,
legacyHeaders: false,
});
const captchaLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
message: { error: '验证码请求过于频繁' },
});
function apiKeyGuard(req, res, next) {
const p = req.originalUrl;
if (p === '/api/health' || p.startsWith('/api/install')) return next();
const key = getApiKey();
if (!key) return next();
if (req.headers['x-api-key'] !== key) return res.status(401).json({ error: '无效的 API 密钥' });
next();
}
function methodGuard(allowed) {
return (req, res, next) => {
if (!allowed.includes(req.method)) {
return res.status(405).json({ error: `不允许 ${req.method} 方法` });
}
next();
};
}
module.exports = {
sanitize,
sanitizeBody,
validateLengths,
loginLimiter,
registerLimiter,
ticketLimiter,
ticketAnonLimiter,
uploadLimiter,
generalLimiter,
captchaLimiter,
apiKeyGuard,
methodGuard,
clearApiKeyCache,
};

View File

@@ -0,0 +1,75 @@
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
const UPLOAD_DIR = path.join(__dirname, '..', '..', 'data', 'uploads');
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const ALLOWED_MIME = {
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
'image/gif': ['.gif'],
'image/webp': ['.webp'],
'video/mp4': ['.mp4'],
'video/webm': ['.webm'],
};
const ALLOWED_EXT = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.webm'];
function validateMagicBytes(buffer, mime) {
const head = buffer.slice(0, 12);
const sigs = {
'image/jpeg': () => head[0] === 0xFF && head[1] === 0xD8,
'image/png': () => head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4E && head[3] === 0x47,
'image/gif': () => head.toString('ascii', 0, 6) === 'GIF89a' || head.toString('ascii', 0, 6) === 'GIF87a',
'image/webp': () => head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP',
'video/mp4': () => buffer.includes(Buffer.from('ftyp')),
'video/webm': () => head[0] === 0x1A && head[1] === 0x45 && head[2] === 0xDF && head[3] === 0xA3,
};
if (!sigs[mime]) return false;
try { return sigs[mime](); } catch { return false; }
}
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, crypto.randomUUID() + ext);
},
});
function fileFilter(req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (!ALLOWED_EXT.includes(ext)) {
return cb(new Error('不支持的文件类型,仅允许: ' + ALLOWED_EXT.join(', ')));
}
cb(null, true);
}
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 50 * 1024 * 1024,
files: 5,
},
});
function finalizeUpload(req, res, next) {
if (!req.files || req.files.length === 0) return next();
for (const file of req.files) {
const buf = fs.readFileSync(file.path);
if (!ALLOWED_MIME[file.mimetype]) {
fs.unlinkSync(file.path);
return res.status(400).json({ error: `不支持的文件类型: ${file.mimetype}` });
}
if (!validateMagicBytes(buf, file.mimetype)) {
fs.unlinkSync(file.path);
return res.status(400).json({ error: '文件内容与声明类型不符,可能是恶意文件' });
}
}
next();
}
module.exports = { upload, finalizeUpload, validateMagicBytes, UPLOAD_DIR, ALLOWED_MIME };

203
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,203 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { v4: uuid } = require('uuid');
const { query, getRow } = require('../db');
const { generateToken, authenticate } = require('../middleware/auth');
const { sendEmail } = require('../mailer');
const { verify: verifyCaptcha } = require('./captcha');
const { validateLengths } = require('../middleware/security');
const router = express.Router();
async function getSiteUrl() {
const row = await getRow("SELECT v FROM settings WHERE k = 'site_url'");
return row?.v || 'http://localhost:3100';
}
router.post('/register', validateLengths({
username: 20, password: 100, email: 100, game_name: 50, game_uid: 50,
}), async (req, res) => {
try {
const { username, password, email, game_name, game_uid, captcha_id, captcha_answer } = req.body;
if (!username || !password || !email || !game_name || !game_uid) {
return res.status(400).json({ error: '所有字段均为必填' });
}
if (username.length < 2 || username.length > 20) {
return res.status(400).json({ error: '用户名需2-20个字符' });
}
if (password.length < 6) {
return res.status(400).json({ error: '密码至少6位' });
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: '邮箱格式不正确' });
}
if (captcha_id && captcha_answer) {
const captchaOk = await verifyCaptcha(captcha_id, captcha_answer);
if (!captchaOk) {
return res.status(400).json({ error: '人机验证失败' });
}
}
const existingUser = await getRow('SELECT id FROM users WHERE username = ?', [username]);
if (existingUser) {
return res.status(400).json({ error: '用户名已存在' });
}
const existingEmail = await getRow('SELECT id FROM users WHERE email = ?', [email]);
if (existingEmail) {
return res.status(400).json({ error: '邮箱已被注册' });
}
const hashed = await bcrypt.hash(password, 10);
const verifyToken = uuid();
const verifyExpires = new Date(Date.now() + 24 * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19);
await query(
'INSERT INTO users(username, password, email, game_name, game_uid, active, email_verified, verify_token, verify_expires) VALUES (?,?,?,?,?,?,?,?,?)',
[username, hashed, email, game_name, game_uid, 0, 0, verifyToken, verifyExpires]
);
const siteUrl = await getSiteUrl();
const sent = await sendEmail(email, 'verify_email', {
username, game_name, game_uid,
verify_link: `${siteUrl}#/verify?token=${verifyToken}`,
});
if (!sent) {
await query('UPDATE users SET email_verified = 1, active = 1, verify_token = NULL, verify_expires = NULL WHERE username = ?', [username]);
return res.status(201).json({ message: '注册成功SMTP未配置账号已自动激活请直接登录' });
}
res.status(201).json({ message: '注册成功,请查收验证邮件' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/verify-email', async (req, res) => {
try {
const { token } = req.query;
if (!token) return res.status(400).json({ error: '无效的验证链接' });
const user = await getRow('SELECT * FROM users WHERE verify_token = ? AND verify_expires > NOW()', [token]);
if (!user) return res.status(400).json({ error: '验证链接无效或已过期' });
await query('UPDATE users SET email_verified = 1, active = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
await query(
'INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)',
[user.id, user.username, 'verify_email', 'user', user.id, '邮箱验证通过']
);
res.json({ message: '邮箱验证成功,现在可以登录了' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' });
const user = await getRow('SELECT * FROM users WHERE username = ?', [username]);
if (!user) return res.status(401).json({ error: '用户名或密码错误' });
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(401).json({ error: '用户名或密码错误' });
if (!user.active) return res.status(403).json({ error: '账号未激活,请先验证邮箱' });
const token = generateToken(user);
await query(
'INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)',
[user.id, user.username, 'login', 'user', user.id, '登录系统']
);
res.json({
token,
user: {
id: user.id,
username: user.username,
email: user.email,
game_name: user.game_name,
game_uid: user.game_uid,
role: user.role,
},
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/me', authenticate, async (req, res) => {
try {
const user = await getRow(
'SELECT id, username, email, game_name, game_uid, role, email_verified, created_at FROM users WHERE id = ?',
[req.user.id]
);
if (!user) return res.status(404).json({ error: '用户不存在' });
res.json(user);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.put('/profile', authenticate, async (req, res) => {
try {
const fields = {};
const values = [];
if (req.body.game_name) {
fields.game_name = req.body.game_name;
}
if (req.body.game_uid) {
fields.game_uid = req.body.game_uid;
}
if (req.body.email) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) {
return res.status(400).json({ error: '邮箱格式不正确' });
}
fields.email = req.body.email;
}
if (Object.keys(fields).length === 0) {
return res.status(400).json({ error: '无更新内容' });
}
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
await query(`UPDATE users SET ${sets} WHERE id = ?`, [...Object.values(fields), req.user.id]);
const updated = await getRow(
'SELECT id, username, email, game_name, game_uid, role FROM users WHERE id = ?',
[req.user.id]
);
const token = generateToken(updated);
res.json({ message: '更新成功', token, user: updated });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.put('/password', authenticate, async (req, res) => {
try {
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword) {
return res.status(400).json({ error: '请提供新旧密码' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: '新密码至少6位' });
}
const user = await getRow('SELECT * FROM users WHERE id = ?', [req.user.id]);
const valid = await bcrypt.compare(oldPassword, user.password);
if (!valid) return res.status(400).json({ error: '旧密码错误' });
const hashed = await bcrypt.hash(newPassword, 10);
await query('UPDATE users SET password = ? WHERE id = ?', [hashed, req.user.id]);
res.json({ message: '密码修改成功' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

31
backend/routes/captcha.js Normal file
View File

@@ -0,0 +1,31 @@
const express = require('express');
const crypto = require('crypto');
const { query } = require('../db');
const router = express.Router();
router.get('/', async (req, res) => {
try {
await query("DELETE FROM captchas WHERE created_at < NOW() - INTERVAL 5 MINUTE");
const id = crypto.randomUUID();
const ops = ['+', '-', '\u00d7'];
const op = ops[Math.floor(Math.random() * ops.length)];
let a, b;
if (op === '\u00d7') { a = Math.floor(Math.random() * 9) + 1; b = Math.floor(Math.random() * 9) + 1; }
else { a = Math.floor(Math.random() * 20) + 1; b = Math.floor(Math.random() * 10) + 1; if (op === '-' && a < b) [a, b] = [b, a]; }
const answer = op === '+' ? a + b : op === '-' ? a - b : a * b;
const question = `${a} ${op} ${b} = ?`;
await query('INSERT INTO captchas(id, question, answer) VALUES (?,?,?)', [id, question, String(answer)]);
res.json({ id, question });
} catch (e) { res.status(500).json({ error: e.message }); }
});
async function verify(id, answer) {
const rows = await query("SELECT * FROM captchas WHERE id = ? AND created_at > NOW() - INTERVAL 5 MINUTE", [id]);
if (!rows.length) return false;
await query('DELETE FROM captchas WHERE id = ?', [id]);
return String(answer).trim() === rows[0].answer;
}
module.exports = router;
module.exports.verify = verify;

View File

@@ -0,0 +1,208 @@
const express = require('express');
const { getDb } = require('../db');
const { authenticate, requireRole, optionalAuth } = require('../middleware/auth');
const router = express.Router();
router.get('/', optionalAuth, (req, res) => {
const db = getDb();
let query = `SELECT c.*,
p.display_name as planner_name, i.display_name as implementer_name, r.display_name as reviewer_name
FROM complaints c
LEFT JOIN users p ON c.assigned_planner_id = p.id
LEFT JOIN users i ON c.assigned_implementer_id = i.id
LEFT JOIN users r ON c.assigned_reviewer_id = r.id`;
let conditions = [];
let params = [];
const { status, priority, category, search } = req.query;
if (!req.user) {
conditions.push("c.status != 'pending'");
}
if (status) {
conditions.push('c.status = ?');
params.push(status);
}
if (priority) {
conditions.push('c.priority = ?');
params.push(priority);
}
if (category) {
conditions.push('c.category = ?');
params.push(category);
}
if (search) {
conditions.push("(c.title LIKE ? OR c.description LIKE ? OR c.player_name LIKE ?)");
const s = `%${search}%`;
params.push(s, s, s);
}
if (req.user && req.user.role === 'planner') {
conditions.push('(c.assigned_planner_id = ? OR c.assigned_planner_id IS NULL)');
params.push(req.user.id);
}
if (req.user && req.user.role === 'implementer') {
conditions.push('c.assigned_implementer_id = ?');
params.push(req.user.id);
}
if (req.user && req.user.role === 'reviewer') {
conditions.push('c.assigned_reviewer_id = ?');
params.push(req.user.id);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY c.updated_at DESC';
const complaints = db.prepare(query).all(...params);
res.json(complaints);
});
router.get('/stats', authenticate, (req, res) => {
const db = getDb();
const total = db.prepare('SELECT COUNT(*) as count FROM complaints').get();
const byStatus = db.prepare('SELECT status, COUNT(*) as count FROM complaints GROUP BY status').all();
const byPriority = db.prepare('SELECT priority, COUNT(*) as count FROM complaints GROUP BY priority').all();
const today = db.prepare("SELECT COUNT(*) as count FROM complaints WHERE date(created_at) = date('now','localtime')").get();
let myStats = {};
if (['planner', 'implementer', 'reviewer'].includes(req.user.role)) {
const roleMap = { planner: 'assigned_planner_id', implementer: 'assigned_implementer_id', reviewer: 'assigned_reviewer_id' };
const col = roleMap[req.user.role];
myStats.total = db.prepare(`SELECT COUNT(*) as count FROM complaints WHERE ${col} = ?`).get(req.user.id).count;
myStats.pending = db.prepare(`SELECT COUNT(*) as count FROM complaints WHERE ${col} = ? AND status NOT IN ('resolved','closed','rejected')`).get(req.user.id).count;
myStats.completed = db.prepare(`SELECT COUNT(*) as count FROM complaints WHERE ${col} = ? AND status IN ('resolved','closed')`).get(req.user.id).count;
}
res.json({ total: total.count, byStatus, byPriority, today: today.count, myStats });
});
router.get('/:id', optionalAuth, (req, res) => {
const db = getDb();
const complaint = db.prepare(`SELECT c.*,
p.display_name as planner_name, i.display_name as implementer_name, r.display_name as reviewer_name
FROM complaints c
LEFT JOIN users p ON c.assigned_planner_id = p.id
LEFT JOIN users i ON c.assigned_implementer_id = i.id
LEFT JOIN users r ON c.assigned_reviewer_id = r.id
WHERE c.id = ?`).get(req.params.id);
if (!complaint) return res.status(404).json({ error: '投诉不存在' });
const responses = db.prepare(`SELECT r.*, u.display_name as user_name, u.role as user_role
FROM responses r LEFT JOIN users u ON r.user_id = u.id
WHERE r.complaint_id = ? ORDER BY r.created_at ASC`).all(req.params.id);
complaint.responses = responses;
if (complaint.plan_id) {
const plan = db.prepare(`SELECT p.*, u.display_name as planner_name FROM plans p
LEFT JOIN users u ON p.planner_id = u.id WHERE p.id = ?`).get(complaint.plan_id);
complaint.plan = plan;
}
if (complaint.implementation_id) {
const impl = db.prepare(`SELECT i.*, u.display_name as implementer_name FROM implementations i
LEFT JOIN users u ON i.implementer_id = u.id WHERE i.id = ?`).get(complaint.implementation_id);
complaint.implementation = impl;
}
if (complaint.review_id) {
const review = db.prepare(`SELECT r.*, u.display_name as reviewer_name FROM reviews r
LEFT JOIN users u ON r.reviewer_id = u.id WHERE r.id = ?`).get(complaint.review_id);
complaint.review = review;
}
res.json(complaint);
});
router.post('/', optionalAuth, (req, res) => {
const { title, description, category, priority, player_name, player_email, player_contact } = req.body;
if (!title || !description || !player_name) {
return res.status(400).json({ error: '标题、描述和玩家名称为必填项' });
}
const db = getDb();
const result = db.prepare(`INSERT INTO complaints(title, description, category, priority, player_name, player_email, player_contact, status)
VALUES (?,?,?,?,?,?,?,'pending')`).run(
title, description, category || 'other', priority || 'medium', player_name, player_email || '', player_contact || ''
);
const complaintId = result.lastInsertRowid;
db.prepare(`INSERT INTO responses(complaint_id, user_id, response_type, content) VALUES (?,?,?,?)`).run(
complaintId, req.user?.id || null, 'system', '投诉已提交,等待处理'
);
db.prepare(`INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)`).run(
req.user?.id || null, req.user?.username || player_name, 'create', 'complaint', complaintId,
`投诉已创建: ${title}`
);
res.status(201).json({ id: complaintId, message: '投诉提交成功' });
});
router.put('/:id', authenticate, (req, res) => {
const db = getDb();
const complaint = db.prepare('SELECT * FROM complaints WHERE id = ?').get(req.params.id);
if (!complaint) return res.status(404).json({ error: '投诉不存在' });
const allowedFields = {};
if (req.body.title) allowedFields.title = req.body.title;
if (req.body.description) allowedFields.description = req.body.description;
if (req.body.category) allowedFields.category = req.body.category;
if (req.body.priority) allowedFields.priority = req.body.priority;
if (req.body.status) allowedFields.status = req.body.status;
if (Object.keys(allowedFields).length === 0) {
return res.status(400).json({ error: '没有可更新的字段' });
}
const assignments = {};
if (req.body.assigned_planner_id !== undefined) assignments.assigned_planner_id = req.body.assigned_planner_id;
if (req.body.assigned_implementer_id !== undefined) assignments.assigned_implementer_id = req.body.assigned_implementer_id;
if (req.body.assigned_reviewer_id !== undefined) assignments.assigned_reviewer_id = req.body.assigned_reviewer_id;
const allFields = { ...allowedFields, ...assignments };
const setClauses = Object.keys(allFields).map(k => `${k} = ?`).join(', ');
const values = Object.values(allFields);
db.prepare(`UPDATE complaints SET ${setClauses}, updated_at = datetime('now','localtime') WHERE id = ?`).run(...values, req.params.id);
if (req.body.status && req.body.status !== complaint.status) {
db.prepare(`INSERT INTO responses(complaint_id, user_id, response_type, content) VALUES (?,?,?,?)`).run(
req.params.id, req.user.id, 'system', `状态变更为: ${getStatusLabel(req.body.status)}`
);
db.prepare(`INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)`).run(
req.user.id, req.user.username, 'update_status', 'complaint', req.params.id,
`状态从 ${complaint.status} 变更为 ${req.body.status}`
);
}
db.prepare(`INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)`).run(
req.user.id, req.user.username, 'update', 'complaint', req.params.id, '更新投诉信息'
);
res.json({ message: '更新成功' });
});
router.post('/:id/response', authenticate, (req, res) => {
const { content, response_type } = req.body;
if (!content) return res.status(400).json({ error: '内容不能为空' });
const db = getDb();
const complaint = db.prepare('SELECT * FROM complaints WHERE id = ?').get(req.params.id);
if (!complaint) return res.status(404).json({ error: '投诉不存在' });
db.prepare(`INSERT INTO responses(complaint_id, user_id, response_type, content) VALUES (?,?,?,?)`).run(
req.params.id, req.user.id, response_type || 'comment', content
);
db.prepare(`UPDATE complaints SET updated_at = datetime('now','localtime') WHERE id = ?`).run(req.params.id);
res.status(201).json({ message: '回复添加成功' });
});
function getStatusLabel(status) {
const labels = {
pending: '待处理', under_review: '审查中', planned: '已计划',
in_progress: '实施中', in_review: '审核中', resolved: '已解决',
rejected: '已驳回', closed: '已关闭'
};
return labels[status] || status;
}
module.exports = router;

77
backend/routes/export.js Normal file
View File

@@ -0,0 +1,77 @@
const express = require('express');
const { query } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
const router = express.Router();
const SL = { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' };
const TL = { report:'举报', suggestion:'建议', appeal:'申诉' };
function toCSV(headers, rows, filename) {
let csv = '\uFEFF' + headers.join(',') + '\r\n';
for (const r of rows) {
csv += headers.map(h => { const v = String(r[h] ?? ''); return /[,"\n\r]/.test(v) ? '"' + v.replace(/"/g,'""') + '"' : v; }).join(',') + '\r\n';
}
return { csv, filename };
}
router.get('/tickets', authenticate, requireRole('owner'), async (req, res) => {
const { type, status, start_date, end_date } = req.query;
let q = `SELECT id as '编号', type as '类型', title as '标题', reporter_game_name as '提交者游戏名', reporter_game_uid as '提交者UID', target_game_name as '对象游戏名', target_game_uid as '对象UID', reason as '原因', description as '描述', status as '状态', priority as '优先级', created_at as '创建时间', updated_at as '更新时间' FROM tickets WHERE 1=1`;
const p = [];
if (type) { q += ' AND type = ?'; p.push(type); }
if (status) { q += ' AND status = ?'; p.push(status); }
if (start_date) { q += ' AND DATE(created_at) >= ?'; p.push(start_date); }
if (end_date) { q += ' AND DATE(created_at) <= ?'; p.push(end_date); }
q += ' ORDER BY created_at DESC';
const rows = await query(q, p);
if (req.query.format === 'csv') {
for (const r of rows) { r['类型'] = TL[r['类型']]||r['类型']; r['状态'] = SL[r['状态']]||r['状态']; }
const { csv, filename } = toCSV(['编号','类型','标题','提交者游戏名','提交者UID','对象游戏名','对象UID','原因','描述','状态','优先级','创建时间','更新时间'], rows, `tickets_${new Date().toISOString().slice(0,10)}.csv`);
res.setHeader('Content-Type','text/csv; charset=utf-8');
res.setHeader('Content-Disposition','attachment; filename='+filename);
return res.send(csv);
}
res.json(rows);
});
router.get('/reported-players', authenticate, requireRole('owner'), async (req, res) => {
const rows = await query(`SELECT target_game_name as '被举报人游戏名', target_game_uid as '被举报人UID', COUNT(*) as '被举报次数', SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as '已处理', SUM(CASE WHEN status IN ('pending','processing','awaiting_info') THEN 1 ELSE 0 END) as '处理中', SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as '已驳回', MAX(created_at) as '最近举报时间', GROUP_CONCAT(reason SEPARATOR ' | ') as '常见原因' 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 COUNT(*) DESC`);
if (req.query.format === 'csv') {
const { csv, filename } = toCSV(['被举报人游戏名','被举报人UID','被举报次数','已处理','处理中','已驳回','最近举报时间','常见原因'], rows, `reported_players_${new Date().toISOString().slice(0,10)}.csv`);
res.setHeader('Content-Type','text/csv; charset=utf-8');
res.setHeader('Content-Disposition','attachment; filename='+filename);
return res.send(csv);
}
res.json(rows);
});
router.get('/audit-logs', authenticate, requireRole('owner'), async (req, res) => {
const { start_date, end_date } = req.query;
let q = `SELECT id as '编号', username as '用户', action as '操作', entity_type as '对象', entity_id as '对象ID', details as '详情', created_at as '时间' FROM audit_logs WHERE 1=1`;
const p = [];
if (start_date) { q += ' AND DATE(created_at) >= ?'; p.push(start_date); }
if (end_date) { q += ' AND DATE(created_at) <= ?'; p.push(end_date); }
q += ' ORDER BY created_at DESC LIMIT 5000';
const rows = await query(q, p);
if (req.query.format === 'csv') {
const { csv, filename } = toCSV(['编号','用户','操作','对象','对象ID','详情','时间'], rows, `audit_${new Date().toISOString().slice(0,10)}.csv`);
res.setHeader('Content-Type','text/csv; charset=utf-8');
res.setHeader('Content-Disposition','attachment; filename='+filename);
return res.send(csv);
}
res.json(rows);
});
router.get('/dashboard', authenticate, requireRole('owner'), async (req, res) => {
const [[{c:total}], byType, byStatus, [{c:today}], [{c:usersTotal}], monthly] = 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'),
query("SELECT COUNT(*) as c FROM tickets WHERE DATE(created_at) = CURDATE()"),
query('SELECT COUNT(*) as c FROM users'),
query("SELECT DATE_FORMAT(created_at,'%Y-%m') as m, COUNT(*) as c FROM tickets GROUP BY m ORDER BY m DESC LIMIT 12"),
]);
res.json({ total, byType, byStatus, today, usersTotal, monthly });
});
module.exports = router;

95
backend/routes/install.js Normal file
View File

@@ -0,0 +1,95 @@
const express = require('express');
const mysql = require('mysql2/promise');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const { isInstalled, saveConfig, getConfig, initSchema } = require('../db');
const router = express.Router();
router.get('/status', (req, res) => {
res.json({ installed: isInstalled() });
});
router.post('/check-db', async (req, res) => {
const { host, port, user, password, database } = req.body;
try {
const conn = await mysql.createConnection({
host: host || 'localhost',
port: parseInt(port) || 3306,
user,
password,
charset: 'utf8mb4',
multipleStatements: true,
});
const [rows] = await conn.query(`SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?`, [database]);
if (rows.length === 0) {
await conn.query(`CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
}
await conn.end();
res.json({ ok: true });
} catch (e) {
res.status(400).json({ error: '数据库连接失败: ' + e.message });
}
});
router.post('/complete', async (req, res) => {
if (isInstalled()) return res.status(400).json({ error: '系统已安装' });
const { db_host, db_port, db_user, db_pass, db_name,
site_name, site_url, admin_username, admin_password, admin_email,
admin_game_name, admin_game_uid } = req.body;
if (!db_host || !db_user || !db_name || !admin_username || !admin_password || !admin_email) {
return res.status(400).json({ error: '必填字段不完整' });
}
try {
const conn = await mysql.createConnection({
host: db_host,
port: parseInt(db_port) || 3306,
user: db_user,
password: db_pass || '',
charset: 'utf8mb4',
multipleStatements: true,
});
const [dbs] = await conn.query(`SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?`, [db_name]);
if (dbs.length === 0) {
await conn.query(`CREATE DATABASE \`${db_name}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
}
await conn.query(`USE \`${db_name}\``);
await initSchema(conn);
const hashed = bcrypt.hashSync(admin_password, 10);
await conn.query(
'INSERT INTO users(username, password, email, game_name, game_uid, role, active, email_verified) VALUES (?,?,?,?,?,?,1,1)',
[admin_username, hashed, admin_email, admin_game_name || admin_username, admin_game_uid || 'OWNER001', 'owner']
);
const jwtSecret = crypto.randomBytes(32).toString('hex');
const apiKey = crypto.randomBytes(24).toString('hex');
for (const [k, v, l] of [
['site_name', site_name || 'MC举报系统', '站点名称'],
['site_url', site_url || 'http://localhost:3100', '站点地址'],
]) {
await conn.query("INSERT INTO settings(k, v, label) VALUES (?,?,?) ON DUPLICATE KEY UPDATE v=VALUES(v)", [k, v, l]);
}
await conn.end();
saveConfig({
db_host, db_port: parseInt(db_port) || 3306,
db_user, db_pass: db_pass || '', db_name,
jwt_secret: jwtSecret,
api_key: apiKey,
});
res.json({ ok: true, message: '安装完成,请重新启动服务器' });
} catch (e) {
res.status(500).json({ error: '安装失败: ' + e.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,46 @@
const express = require('express');
const { query, getRow } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
const { sendWebhook } = require('../webhook');
const router = express.Router();
router.get('/', authenticate, requireRole('owner','admin'), async (req, res) => {
res.json(await query('SELECT * FROM notification_configs ORDER BY id'));
});
router.post('/', authenticate, requireRole('owner','admin'), async (req, res) => {
const { name, type, webhook_url, events } = req.body;
if (!name||!type) return res.status(400).json({ error: '名称和类型为必填' });
if (type==='webhook' && !webhook_url) return res.status(400).json({ error: 'Webhook地址必填' });
const r = await query('INSERT INTO notification_configs(name, type, webhook_url, events) VALUES (?,?,?,?)', [name, type, webhook_url||'', events||'all']);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'create_notification', 'notification_config', r.insertId, `创建通知: ${name}`]);
res.status(201).json({ id: r.insertId, message: '创建成功' });
});
router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
const cfg = await getRow('SELECT * FROM notification_configs WHERE id = ?', [req.params.id]);
if (!cfg) return res.status(404).json({ error: '配置不存在' });
const fields = {};
if (req.body.name) fields.name = req.body.name;
if (req.body.webhook_url !== undefined) fields.webhook_url = req.body.webhook_url;
if (req.body.events) fields.events = req.body.events;
if (req.body.active !== undefined) fields.active = req.body.active?1:0;
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
const sets = Object.keys(fields).map(k=>`${k}=?`).join(',');
await query(`UPDATE notification_configs SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]);
res.json({ message: '更新成功' });
});
router.delete('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
await query('DELETE FROM notification_configs WHERE id = ?', [req.params.id]);
res.json({ message: '已删除' });
});
router.post('/:id/test', authenticate, requireRole('owner','admin'), async (req, res) => {
const cfg = await getRow('SELECT * FROM notification_configs WHERE id = ?', [req.params.id]);
if (!cfg||!cfg.webhook_url) return res.status(404).json({ error: '配置不存在' });
sendWebhook('ticket_created', { ticket_id:'TEST', ticket_title:'测试通知', ticket_type:'测试', reporter_game_name:'TestPlayer', reporter_game_uid:'TEST001', ticket_reason:'这是一条测试消息' }).then(() => res.json({ message:'测试消息已发送' })).catch(e => res.status(500).json({ error:e.message }));
});
module.exports = router;

View File

@@ -0,0 +1,46 @@
const express = require('express');
const { query, getRow } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/email-templates', authenticate, requireRole('owner','admin'), async (req, res) => {
res.json(await query('SELECT * FROM email_templates ORDER BY id'));
});
router.get('/email-templates/:code', authenticate, requireRole('owner','admin'), async (req, res) => {
const t = await getRow('SELECT * FROM email_templates WHERE code = ?', [req.params.code]);
if (!t) return res.status(404).json({ error: '模板不存在' });
res.json(t);
});
router.put('/email-templates/:code', authenticate, requireRole('owner','admin'), async (req, res) => {
const { subject, body } = req.body;
if (!subject||!body) return res.status(400).json({ error: '标题和内容不能为空' });
const t = await getRow('SELECT * FROM email_templates WHERE code = ?', [req.params.code]);
if (!t) return res.status(404).json({ error: '模板不存在' });
await query('UPDATE email_templates SET subject = ?, body = ? WHERE code = ?', [subject, body, req.params.code]);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_email_template', 'email_template', t.id, `更新模板: ${t.name}`]);
res.json({ message: '模板更新成功' });
});
router.get('/settings', authenticate, requireRole('owner','admin'), async (req, res) => {
const rows = await query('SELECT k, v, label FROM settings ORDER BY k');
const map = {};
for (const r of rows) map[r.k] = { value: r.v, label: r.label };
res.json(map);
});
router.put('/settings', authenticate, requireRole('owner','admin'), async (req, res) => {
const updates = req.body;
if (!updates||typeof updates!=='object') return res.status(400).json({ error: '无效数据' });
for (const [key, val] of Object.entries(updates)) {
const v = typeof val === 'object' ? val.value : String(val);
const l = typeof val === 'object' ? val.label : null;
await query("INSERT INTO settings(k, v, label) VALUES (?,?,?) ON DUPLICATE KEY UPDATE v = VALUES(v)", [key, v, l]);
}
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_settings', 'settings', 0, '更新系统设置']);
res.json({ message: '设置已保存' });
});
module.exports = router;

262
backend/routes/tickets.js Normal file
View File

@@ -0,0 +1,262 @@
const express = require('express');
const { v4: uuid } = require('uuid');
const { getPool, query, getRow } = require('../db');
const { authenticate, requireRole, optionalAuth } = require('../middleware/auth');
const { upload, finalizeUpload } = require('../middleware/upload');
const { sendEmail } = require('../mailer');
const { sendWebhook } = require('../webhook');
const { validateLengths } = require('../middleware/security');
const router = express.Router();
const SL = { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' };
const TL = { report:'举报', suggestion:'建议', appeal:'申诉' };
const SC = { pending:'#f59e0b', processing:'#7c3aed', awaiting_info:'#d97706', resolved:'#059669', rejected:'#dc2626', closed:'#6b7280' };
const SO = { processing:1, awaiting_info:2, pending:3, resolved:4, rejected:5, closed:6 };
async function siteUrl() { const r = await getRow("SELECT v FROM settings WHERE k='site_url'"); return r?.v || 'http://localhost:3100'; }
router.get('/', authenticate, async (req, res) => {
let q = `SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name
FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users a ON t.assigned_to = a.id`;
const conds = [], params = [];
if (req.user.role === 'player') { conds.push('t.user_id = ?'); params.push(req.user.id); }
else if (req.user.role === 'admin') { conds.push("(t.assigned_to IS NULL OR t.assigned_to = ?) AND t.type != 'suggestion'"); params.push(req.user.id); }
if (req.query.type) { conds.push('t.type = ?'); params.push(req.query.type); }
if (req.query.status) { conds.push('t.status = ?'); params.push(req.query.status); }
if (req.query.priority) { conds.push('t.priority = ?'); params.push(req.query.priority); }
if (req.query.claimed === '1') conds.push('t.assigned_to IS NOT NULL');
if (req.query.claimed === '0') conds.push('t.assigned_to IS NULL');
if (req.query.search) { const s = `%${req.query.search}%`; conds.push("(t.title LIKE ? OR t.reporter_game_name LIKE ? OR t.reporter_game_uid LIKE ? OR t.target_game_name LIKE ? OR t.target_game_uid LIKE ?)"); params.push(s,s,s,s,s); }
if (conds.length) q += ' WHERE ' + conds.join(' AND ');
q += ` ORDER BY CASE t.status ${Object.entries(SO).map(([k,v])=>`WHEN '${k}' THEN ${v}`).join(' ')} ELSE 9 END, t.updated_at DESC LIMIT 100`;
try { res.json(await query(q, params)); } catch(e) { res.status(500).json({ error: e.message }); }
});
router.get('/stats', authenticate, async (req, res) => {
const role = req.user.role;
const sql = role === 'player' ? 'SELECT type, status, COUNT(*) as count FROM tickets WHERE user_id = ? GROUP BY type, status' : 'SELECT type, status, COUNT(*) as count FROM tickets GROUP BY type, status';
const params = role === 'player' ? [req.user.id] : [];
const rows = await query(sql, params);
const bs = {}; let total = 0;
for (const r of rows) { bs[r.status] = (bs[r.status]||0) + r.count; total += r.count; }
res.json({ total, byStatus: bs });
});
router.get('/unclaimed', authenticate, requireRole('owner','admin'), async (req, res) => {
let q = `SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name
FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users a ON t.assigned_to = a.id WHERE t.assigned_to IS NULL`;
const params = [];
if (req.user.role === 'admin') { q += " AND t.type != 'suggestion'"; }
if (req.query.type) { q += ' AND t.type = ?'; params.push(req.query.type); }
q += ` ORDER BY CASE t.status ${Object.entries(SO).map(([k,v])=>`WHEN '${k}' THEN ${v}`).join(' ')} ELSE 9 END, t.created_at ASC LIMIT 200`;
res.json(await query(q, params));
});
router.get('/reported-players', authenticate, requireRole('owner','admin'), async (req, res) => {
const rows = await query(`SELECT target_game_name as name, target_game_uid as uid,
COUNT(*) as total_reports,
SUM(CASE WHEN status IN ('pending','processing','awaiting_info') THEN 1 ELSE 0 END) as active_count,
SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as resolved_count,
SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as rejected_count,
MAX(created_at) as last_report,
GROUP_CONCAT(reason SEPARATOR ' | ') as reasons
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_reports DESC, last_report DESC`);
res.json(rows);
});
router.get('/tracking', async (req, res) => {
const token = req.query.token;
if (!token) return res.status(400).json({ error: '缺少追踪标识' });
const rows = await query(`SELECT t.*, a.game_name as assignee_name FROM tickets t LEFT JOIN users a ON t.assigned_to = a.id WHERE t.tracking_token = ? ORDER BY t.created_at DESC`, [token]);
if (!rows.length) return res.status(404).json({ error: '未找到相关工单' });
res.json(rows);
});
router.get('/:id', authenticate, async (req, res) => {
const t = await getRow(`SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name, a.username as assignee_username
FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users a ON t.assigned_to = a.id WHERE t.id = ?`, [req.params.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
if (req.user.role === 'player' && t.user_id !== req.user.id) return res.status(403).json({ error: '无权查看' });
t.responses = await query(`SELECT r.*, u.username, u.role FROM responses r LEFT JOIN users u ON r.user_id = u.id WHERE r.ticket_id = ? ORDER BY r.created_at ASC`, [req.params.id]);
t.attachments = await query('SELECT * FROM attachments WHERE ticket_id = ?', [req.params.id]);
t.transfers = await query(`SELECT tf.*, fu.username as from_username, fu.game_name as from_game_name, tu.username as to_username, tu.game_name as to_game_name
FROM ticket_transfers tf LEFT JOIN users fu ON tf.from_user_id = fu.id LEFT JOIN users tu ON tf.to_user_id = tu.id WHERE tf.ticket_id = ? ORDER BY tf.created_at ASC`, [req.params.id]);
res.json(t);
});
router.post('/', optionalAuth, upload.array('files', 5), finalizeUpload, validateLengths({
title:100, reporter_game_name:50, reporter_game_uid:50, target_game_name:50, target_game_uid:50, reason:300, description:5000,
}), async (req, res) => {
const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description } = req.body;
if (!type || !['report','suggestion','appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' });
if (!title) return res.status(400).json({ error: '标题不能为空' });
const rgn = req.user?.game_name || reporter_game_name;
const rgu = req.user?.game_uid || reporter_game_uid;
if (!rgn || !rgu) return res.status(400).json({ error: '请填写游戏名称和UID' });
if (type === 'report') { if (!target_game_name && !target_game_uid) return res.status(400).json({ error: '举报需至少填写对方游戏名或UID之一' }); if (!reason) return res.status(400).json({ error: '请填写举报原因' }); }
if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' });
if (type === 'appeal') { if (!reason) return res.status(400).json({ error: '请填写申诉理由' }); if (!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')", [rgn, rgu]);
if (countRow.c >= 5) return res.status(400).json({ error: '您的待处理/处理中工单已达上限(5个),请等待已有工单处理完毕后再提交' });
const trackingToken = uuid();
const pool = await getPool();
const conn = await pool.getConnection();
let ticketId;
try {
await conn.beginTransaction();
const [r] = await conn.execute(`INSERT INTO tickets(type,title,user_id,reporter_game_name,reporter_game_uid,
target_game_name,target_game_uid,reason,description,tracking_token) VALUES (?,?,?,?,?,?,?,?,?,?)`,
[type, title, req.user?.id||null, rgn, rgu,
type==='report'?(target_game_name||''):null, type==='report'?(target_game_uid||''):null,
(type==='report'||type==='appeal')?reason:null, (type==='suggestion'||type==='appeal')?description:'', trackingToken]);
ticketId = r.insertId;
if (req.files?.length) {
for (const f of req.files) {
await conn.execute('INSERT INTO attachments(ticket_id, original_name, stored_name, mime_type, size) VALUES (?,?,?,?,?)', [ticketId, f.originalname, f.filename, f.mimetype, f.size]);
}
}
const respContent = type === 'report'
? `举报已提交\n举报人: ${rgn} (UID: ${rgu})\n被举报人: ${target_game_name||'无'} (UID: ${target_game_uid||'无'})\n原因: ${reason}${description?'\n详情: '+description:''}`
: type === 'appeal'
? `申诉已提交\n申诉人: ${rgn} (UID: ${rgu})\n标题: ${title}\n申诉理由: ${reason}\n详细内容: ${description}`
: `建议已提交\n提交人: ${rgn} (UID: ${rgu})\n标题: ${title}\n内容: ${description}`;
await conn.execute("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,0)", [ticketId, req.user?.id||null, respContent]);
await conn.execute("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)",
[req.user?.id||null, req.user?.username||rgn, `create_${type}`, 'ticket', ticketId, `提交${TL[type]}: ${title}`]);
await conn.commit();
} catch (e) { await conn.rollback(); conn.release(); return res.status(500).json({ error: e.message }); }
conn.release();
const emailVars = { reporter_game_name: rgn, reporter_game_uid: rgu, ticket_type: TL[type], ticket_id: ticketId, ticket_title: title, ticket_reason: reason||'', ticket_description: description||'', tracking_link: `${await siteUrl()}#/ticket/${ticketId}`, date: new Date().toLocaleString('zh-CN') };
if (req.user?.email) sendEmail(req.user.email, 'ticket_created', emailVars).catch(()=>{});
sendWebhook('ticket_created', { ...emailVars, type }).catch(()=>{});
res.setHeader('Set-Cookie', `tracking_token=${trackingToken}; Path=/; SameSite=Lax; Max-Age=${365*24*3600}`);
res.status(201).json({ id: ticketId, tracking_token: trackingToken, message: '提交成功' });
});
router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
const fields = {};
if (req.body.status) fields.status = req.body.status;
if (req.body.priority) fields.priority = req.body.priority;
if (req.body.claim_note !== undefined) fields.claim_note = req.body.claim_note;
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
await query(`UPDATE tickets SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]);
if (req.body.status && req.body.status !== t.status) {
await query("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [req.params.id, req.user.id, `状态变更为: ${SL[req.body.status]||req.body.status}`]);
await notifyStatusChange(t, req.body.status);
}
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_ticket', 'ticket', req.params.id, '更新工单']);
res.json({ message: '更新成功' });
});
router.post('/:id/claim', authenticate, requireRole('owner','admin'), async (req, res) => {
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
if (t.assigned_to && t.assigned_to !== req.user.id && t.status !== 'pending') return res.status(400).json({ error: '该工单已被他人认领' });
if (t.type === 'suggestion' && req.user.role !== 'owner') return res.status(403).json({ error: '建议工单仅服主可处理' });
const claimNote = req.body.claim_note || '';
const initialReply = req.body.initial_reply || '';
await query("UPDATE tickets SET assigned_to = ?, status = 'processing', claim_note = ?, claimed_at = NOW() WHERE id = ?", [req.user.id, claimNote, req.params.id]);
const claimMsg = initialReply ? `【接单】${initialReply}${claimNote?'\n\n处理备注: '+claimNote:''}` : `【接单】已认领此工单${claimNote?'\n处理备注: '+claimNote:''}`;
await query("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [req.params.id, req.user.id, claimMsg]);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'claim_ticket', 'ticket', req.params.id, '认领工单']);
const user = await getRow('SELECT username, game_name FROM users WHERE id = ?', [req.user.id]);
await sendEmailForTicket(t, 'ticket_claimed', { assigned_to: user?.game_name||req.user.username, claim_note: claimNote });
sendWebhook('ticket_claimed', { ticket_id: t.id, ticket_title: t.title, assigned_to: user?.game_name||req.user.username, claim_note: claimNote }).catch(()=>{});
res.json({ message: '认领成功' });
});
router.post('/:id/transfer', authenticate, requireRole('owner','admin'), async (req, res) => {
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
if (t.assigned_to !== req.user.id && req.user.role !== 'owner') return res.status(403).json({ error: '只能转交自己负责的工单' });
const { to_user_id, reason } = req.body;
if (!to_user_id) return res.status(400).json({ error: '请选择转交目标' });
const toUser = await getRow("SELECT * FROM users WHERE id = ? AND role IN ('owner','admin')", [to_user_id]);
if (!toUser) return res.status(400).json({ error: '目标用户无效' });
if (to_user_id === req.user.id) return res.status(400).json({ error: '不能转交给自己' });
const pool = await getPool();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.execute('INSERT INTO ticket_transfers(ticket_id, from_user_id, to_user_id, reason) VALUES (?,?,?,?)', [req.params.id, req.user.id, to_user_id, reason||'']);
await conn.execute('UPDATE tickets SET assigned_to = ? WHERE id = ?', [to_user_id, req.params.id]);
await conn.execute("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [req.params.id, req.user.id, `【转单】工单已转交给 ${toUser.game_name||toUser.username}${reason?'\n原因: '+reason:''}`]);
await conn.execute("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'transfer_ticket', 'ticket', req.params.id, `转交工单给 ${toUser.username}`]);
await conn.commit();
} catch (e) { await conn.rollback(); conn.release(); return res.status(500).json({ error: e.message }); }
conn.release();
const fromUser = await getRow('SELECT game_name FROM users WHERE id = ?', [req.user.id]);
await sendEmailForTicket(t, 'ticket_transferred', { from_user: fromUser?.game_name||req.user.username, to_user: toUser.game_name||toUser.username, transfer_reason: reason||'', claim_note: t.claim_note||'无' });
sendWebhook('ticket_transferred', { ticket_id: t.id, ticket_title: t.title, from_user: fromUser?.game_name, to_user: toUser.game_name, reason: reason||'' }).catch(()=>{});
res.json({ message: '转交成功' });
});
router.post('/:id/response', authenticate, async (req, res) => {
const { content } = req.body;
if (!content) return res.status(400).json({ error: '内容不能为空' });
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!t) return res.status(404).json({ error: '工单不存在' });
if (req.user.role === 'player' && t.user_id !== req.user.id) return res.status(403).json({ error: '无权限' });
const isStaff = req.user.role === 'owner' || req.user.role === 'admin';
await query("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,?)", [req.params.id, req.user.id, content, isStaff?1:0]);
await query('UPDATE tickets SET updated_at = NOW() WHERE id = ?', [req.params.id]);
if (isStaff && t.user_id) {
await sendEmailForTicket(t, 'ticket_updated', { update_note: content });
sendWebhook('ticket_updated', { ticket_id: t.id, ticket_title: t.title, responder: req.user.username, note: content }).catch(()=>{});
}
res.status(201).json({ message: '回复成功' });
});
async function notifyStatusChange(ticket, newStatus) {
if (ticket.user_id) {
await sendEmailForTicket(ticket, 'ticket_updated', { ticket_status: SL[newStatus]||newStatus, ticket_status_color: SC[newStatus]||'#6b7280' });
}
sendWebhook('ticket_updated', { ticket_id: ticket.id, ticket_title: ticket.title, new_status: SL[newStatus]||newStatus, type: ticket.type }).catch(()=>{});
}
async function sendEmailForTicket(ticket, templateCode, extraVars = {}) {
const assignee = ticket.assigned_to ? await getRow('SELECT game_name, username FROM users WHERE id = ?', [ticket.assigned_to]) : null;
const vars = {
ticket_id: ticket.id, ticket_title: ticket.title, ticket_type: TL[ticket.type]||ticket.type,
ticket_status: SL[ticket.status]||ticket.status, ticket_status_color: SC[ticket.status]||'#6b7280',
reporter_game_name: ticket.reporter_game_name, reporter_game_uid: ticket.reporter_game_uid,
target_game_name: ticket.target_game_name||'', target_game_uid: ticket.target_game_uid||'',
ticket_reason: ticket.reason||'', ticket_description: ticket.description||'',
assigned_to: assignee?.game_name||assignee?.username||'未分配', claim_note: ticket.claim_note||'',
tracking_link: `${await siteUrl()}#/ticket/${ticket.id}`, date: new Date().toLocaleString('zh-CN'), ...extraVars,
};
if (ticket.user_id) {
const user = await getRow('SELECT email, username, game_name, game_uid FROM users WHERE id = ?', [ticket.user_id]);
if (user?.email) { vars.username = user.username; vars.game_name = user.game_name; vars.game_uid = user.game_uid; sendEmail(user.email, templateCode, vars).catch(()=>{}); }
}
if (ticket.assigned_to && templateCode !== 'ticket_created') {
const a = await getRow('SELECT email FROM users WHERE id = ?', [ticket.assigned_to]);
if (a?.email) sendEmail(a.email, templateCode, vars).catch(()=>{});
}
}
module.exports = router;

23
backend/routes/uploads.js Normal file
View File

@@ -0,0 +1,23 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const { getRow } = require('../db');
const { authenticate } = require('../middleware/auth');
const { UPLOAD_DIR } = require('../middleware/upload');
const router = express.Router();
router.get('/:storedName', authenticate, async (req, res) => {
const att = await getRow('SELECT * FROM attachments WHERE stored_name = ?', [req.params.storedName]);
if (!att) return res.status(404).json({ error: '文件不存在' });
const ticket = await getRow('SELECT * FROM tickets WHERE id = ?', [att.ticket_id]);
if (req.user.role === 'player' && ticket?.user_id !== req.user.id) return res.status(403).json({ error: '无权限' });
const filePath = path.join(UPLOAD_DIR, att.stored_name);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: '文件不存在' });
res.setHeader('Content-Type', att.mime_type);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(att.original_name)}"`);
res.setHeader('Cache-Control', 'private, max-age=86400');
fs.createReadStream(filePath).pipe(res);
});
module.exports = router;

48
backend/routes/users.js Normal file
View File

@@ -0,0 +1,48 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { query, getRow } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/', authenticate, requireRole('owner','admin'), async (req, res) => {
res.json(await query('SELECT id, username, email, game_name, game_uid, role, active, email_verified, created_at FROM users ORDER BY created_at DESC'));
});
router.get('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
const u = await getRow('SELECT id, username, email, game_name, game_uid, role, active, email_verified, created_at FROM users WHERE id = ?', [req.params.id]);
if (!u) return res.status(404).json({ error: '用户不存在' });
res.json(u);
});
router.post('/', authenticate, requireRole('owner','admin'), async (req, res) => {
const { username, password, email, game_name, game_uid, role } = req.body;
if (!username||!password||!email||!game_name||!game_uid||!role) return res.status(400).json({ error: '所有字段必填' });
if (!['owner','admin','player'].includes(role)) 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: '用户名已存在' });
const hashed = bcrypt.hashSync(password, 10);
const r = await query('INSERT INTO users(username, password, email, game_name, game_uid, role, active, email_verified) VALUES (?,?,?,?,?,?,1,1)', [username, hashed, email, game_name, game_uid, role]);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'create_user', 'user', r.insertId, `创建用户 ${username}`]);
res.status(201).json({ id: r.insertId, message: '创建成功' });
});
router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
const u = await getRow('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (!u) return res.status(404).json({ error: '用户不存在' });
if (u.role === 'owner' && req.user.role !== 'owner') return res.status(403).json({ error: '无权修改服主' });
const fields = {};
if (req.body.email) fields.email = req.body.email;
if (req.body.game_name) fields.game_name = req.body.game_name;
if (req.body.game_uid) fields.game_uid = req.body.game_uid;
if (req.body.role) { if (!['owner','admin','player'].includes(req.body.role)) return res.status(400).json({ error: '无效角色' }); fields.role = req.body.role; }
if (req.body.active !== undefined) fields.active = req.body.active ? 1 : 0;
if (req.body.password) { if (req.body.password.length < 6) return res.status(400).json({ error: '密码至少6位' }); fields.password = bcrypt.hashSync(req.body.password, 10); }
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
await query(`UPDATE users SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]);
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_user', 'user', req.params.id, '更新用户']);
res.json({ message: '更新成功' });
});
module.exports = router;

125
backend/server.js Normal file
View File

@@ -0,0 +1,125 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const fs = require('fs');
const path = require('path');
const { isInstalled, getConfig } = require('./db');
const {
sanitizeBody, generalLimiter, loginLimiter, registerLimiter,
ticketLimiter, captchaLimiter, apiKeyGuard, methodGuard
} = require('./middleware/security');
const app = express();
app.set('trust proxy', 1);
const PORT = process.env.PORT || 3100;
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
let cachedHtml = null;
let htmlTimestamp = 0;
function getHtmlWithKey() {
const stat = fs.statSync(HTML_PATH);
const mtime = stat.mtimeMs;
if (cachedHtml && htmlTimestamp >= mtime) return cachedHtml;
let html = fs.readFileSync(HTML_PATH, 'utf-8');
const cfg = getConfig();
const key = cfg?.api_key || '';
const redirect = isInstalled() ? '' : '<script>location.hash="#/install"</script>';
html = html.replace('</head>', `<script>window.__API_KEY__=${JSON.stringify(key)}</script>${redirect}</head>`);
cachedHtml = html;
htmlTimestamp = mtime;
return html;
}
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"],
styleSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"],
fontSrc: ["'self'", "https://cdnjs.cloudflare.com"],
imgSrc: ["'self'", "data:", "blob:"],
mediaSrc: ["'self'"],
connectSrc: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
app.use(cors({
origin: (origin, cb) => {
const allowed = process.env.CORS_ORIGIN;
if (!allowed || allowed === '*') return cb(null, true);
if (allowed === origin) return cb(null, true);
if (!allowed) {
const host = origin || '';
if (host.startsWith('http://localhost:') || host.startsWith('https://localhost:')) return cb(null, true);
}
cb(new Error('Not allowed by CORS'));
},
credentials: true,
methods: ['GET','POST','PUT','DELETE'],
allowedHeaders: ['Content-Type','Authorization','x-api-key'],
}));
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(sanitizeBody);
app.use(generalLimiter);
app.use('/api', apiKeyGuard);
const staticOpts = { maxAge: '1h', setHeaders: (res, p) => { if (p.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); } };
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), staticOpts));
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), staticOpts));
const installRoutes = require('./routes/install');
app.use('/api/install', installRoutes);
if (isInstalled()) {
const authRoutes = require('./routes/auth');
app.use('/api/auth/login', methodGuard(['POST']), loginLimiter);
app.use('/api/auth/register', methodGuard(['POST']), registerLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/captcha', methodGuard(['GET']), captchaLimiter, require('./routes/captcha'));
app.use('/api/tickets', methodGuard(['GET','POST','PUT']), ticketLimiter, require('./routes/tickets'));
app.use('/api/reports', methodGuard(['GET','POST','PUT']), ticketLimiter, require('./routes/tickets'));
app.use('/api/users', methodGuard(['GET','POST','PUT']), generalLimiter, require('./routes/users'));
app.use('/api/settings', methodGuard(['GET','PUT']), generalLimiter, require('./routes/settings'));
app.use('/api/export', methodGuard(['GET']), generalLimiter, require('./routes/export'));
app.use('/api/uploads', methodGuard(['GET']), generalLimiter, require('./routes/uploads'));
app.use('/api/notifications', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/notifications'));
app.get('/api/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`));
}
app.get('/api/health', (req, res) => res.json({ status: 'ok', installed: isInstalled() }));
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'no-cache');
res.type('html').send(getHtmlWithKey());
});
app.get('*', (req, res) => {
if (req.path.startsWith('/api')) return res.status(404).json({ error: '接口不存在' });
res.setHeader('Cache-Control', 'no-cache');
res.type('html').send(getHtmlWithKey());
});
app.use((err, req, res, next) => {
if (err.message === 'SYSTEM_NOT_INSTALLED') return res.status(503).json({ error: '系统未安装' });
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: '文件大小超过限制(50MB)' });
if (err.message?.includes('不支持的文件类型')) return res.status(400).json({ error: err.message });
if (err.message?.includes('文件内容与声明类型不符')) return res.status(400).json({ error: '文件类型校验失败' });
console.error(err);
res.status(500).json({ error: '服务器内部错误' });
});
if (isInstalled()) console.log(`Server running at http://localhost:${PORT}`);
else console.log(`System not installed. Visit http://localhost:${PORT}/#/install`);
app.listen(PORT);

29
backend/webhook.js Normal file
View File

@@ -0,0 +1,29 @@
const { query, getRow } = require('./db');
const EVENT_LABELS = { ticket_created:'工单创建', ticket_claimed:'工单认领', ticket_transferred:'工单转交', ticket_updated:'工单更新' };
async function sendWebhook(event, data) {
try {
const configs = await query("SELECT * FROM notification_configs WHERE type = 'webhook' AND active = 1");
for (const cfg of configs) {
const events = cfg.events || 'all';
if (events !== 'all' && !events.split(',').map(s=>s.trim()).includes(event)) continue;
const embed = buildEmbed(event, data);
try { await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed) }); } catch {}
}
} catch {}
}
function buildEmbed(event, data) {
const row = data._siteName ? { v: data._siteName } : null;
const siteName = row?.v || 'MC举报系统';
const colors = { ticket_created:0x4f46e5, ticket_claimed:0x10b981, ticket_transferred:0xf59e0b, ticket_updated:0x3b82f6 };
const embed = { username: siteName, embeds:[{ title:EVENT_LABELS[event]||event, color:colors[event]||0x4f46e5, timestamp:new Date().toISOString(), fields:[] }] };
if (event==='ticket_created') { embed.embeds[0].description=`**#${data.ticket_id}** - ${data.ticket_title||''}`; embed.embeds[0].fields.push({name:'类型',value:data.ticket_type||data.type||'?',inline:true},{name:'提交者',value:`${data.reporter_game_name||'?'} (UID:${data.reporter_game_uid||'?'})`,inline:true}); }
else if (event==='ticket_claimed') { embed.embeds[0].description=`**#${data.ticket_id}** - ${data.ticket_title}`; embed.embeds[0].fields.push({name:'认领人',value:data.assigned_to||'?',inline:true}); }
else if (event==='ticket_transferred') { embed.embeds[0].description=`**#${data.ticket_id}** - ${data.ticket_title}`; embed.embeds[0].fields.push({name:'转出',value:data.from_user||'?',inline:true},{name:'转入',value:data.to_user||'?',inline:true}); }
else if (event==='ticket_updated') { embed.embeds[0].description=`**#${data.ticket_id}** - ${data.ticket_title}`; if(data.new_status)embed.embeds[0].fields.push({name:'新状态',value:data.new_status,inline:true}); }
return embed;
}
module.exports = { sendWebhook };