feat: MC Report System - MySQL + Express + Vanilla JS SPA
This commit is contained in:
40
backend/captcha.js
Normal file
40
backend/captcha.js
Normal 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
217
backend/db.js
Normal 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
59
backend/mailer.js
Normal 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 };
|
||||
37
backend/middleware/auth.js
Normal file
37
backend/middleware/auth.js
Normal 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 };
|
||||
145
backend/middleware/security.js
Normal file
145
backend/middleware/security.js
Normal 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,
|
||||
};
|
||||
75
backend/middleware/upload.js
Normal file
75
backend/middleware/upload.js
Normal 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
203
backend/routes/auth.js
Normal 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
31
backend/routes/captcha.js
Normal 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;
|
||||
208
backend/routes/complaints.js
Normal file
208
backend/routes/complaints.js
Normal 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
77
backend/routes/export.js
Normal 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
95
backend/routes/install.js
Normal 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;
|
||||
46
backend/routes/notifications.js
Normal file
46
backend/routes/notifications.js
Normal 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;
|
||||
46
backend/routes/settings.js
Normal file
46
backend/routes/settings.js
Normal 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
262
backend/routes/tickets.js
Normal 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
23
backend/routes/uploads.js
Normal 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
48
backend/routes/users.js
Normal 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
125
backend/server.js
Normal 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
29
backend/webhook.js
Normal 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 };
|
||||
Reference in New Issue
Block a user