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

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 };