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; let _configCache = null; let _configMtime = 0; function getConfig() { try { const stat = fs.statSync(CONFIG_PATH); if (_configCache && stat.mtimeMs === _configMtime) return _configCache; _configCache = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); _configMtime = stat.mtimeMs; return _configCache; } catch { 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', }); await migrateAdditions(pool); } 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 UNIQUE, 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, source ENUM('netease','skin') NOT NULL DEFAULT 'netease', 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','result_appeal') NOT NULL, title VARCHAR(100) NOT NULL, status ENUM('pending','processing','awaiting_info','appealing','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, is_admin_complaint TINYINT(1) NOT NULL DEFAULT 0, parent_ticket_id INT, 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('polls', `CREATE TABLE polls ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, options JSON NOT NULL, group_name VARCHAR(100) NOT NULL DEFAULT '', server_name VARCHAR(100) NOT NULL DEFAULT '', start_time DATETIME, end_time DATETIME, active TINYINT(1) NOT NULL DEFAULT 1, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_active (active), INDEX idx_group_server (group_name, server_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await createIfNotExists('poll_votes', `CREATE TABLE poll_votes ( id INT AUTO_INCREMENT PRIMARY KEY, poll_id INT NOT NULL, user_id INT NOT NULL, option_index INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_poll_user (poll_id, user_id), INDEX idx_poll (poll_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await createIfNotExists('feature_items', `CREATE TABLE feature_items ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, group_name VARCHAR(100) NOT NULL DEFAULT '', server_name VARCHAR(100) NOT NULL DEFAULT '', status ENUM('pending','planned','done') NOT NULL DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await createIfNotExists('feature_votes', `CREATE TABLE feature_votes ( id INT AUTO_INCREMENT PRIMARY KEY, item_id INT NOT NULL, user_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_item_user (item_id, user_id), INDEX idx_item (item_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await createIfNotExists('feature_comments', `CREATE TABLE feature_comments ( id INT AUTO_INCREMENT PRIMARY KEY, item_id INT NOT NULL, user_id INT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_item (item_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await createIfNotExists('server_groups', `CREATE TABLE server_groups ( id INT AUTO_INCREMENT PRIMARY KEY, group_name VARCHAR(100) NOT NULL, server_name VARCHAR(100) NOT NULL, UNIQUE KEY uk_group_server (group_name, server_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await createIfNotExists('user_servers', `CREATE TABLE user_servers ( user_id INT NOT NULL, group_name VARCHAR(100) NOT NULL, server_name VARCHAR(100) NOT NULL, UNIQUE KEY uk_user_server (user_id, group_name, server_name), INDEX idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await createIfNotExists('bans', `CREATE TABLE bans ( id INT AUTO_INCREMENT PRIMARY KEY, player_name VARCHAR(50) NOT NULL, player_uid VARCHAR(50), source VARCHAR(10) DEFAULT '', reason TEXT, type ENUM('ban','mute','warn','other') NOT NULL DEFAULT 'ban', duration VARCHAR(20) DEFAULT '', ticket_id INT, created_by INT, status ENUM('active','expired','appealed','lifted') NOT NULL DEFAULT 'active', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME ) 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); await migrateAdditions(db); } async function migrateAdditions(db) { try { await db.execute("ALTER TABLE tickets ADD COLUMN is_admin_complaint TINYINT(1) NOT NULL DEFAULT 0"); } catch {} try { await db.execute("ALTER TABLE tickets ADD COLUMN parent_ticket_id INT"); } catch {} try { await db.execute("ALTER TABLE tickets MODIFY type ENUM('report','suggestion','appeal','result_appeal') NOT NULL"); } catch {} try { await db.execute("ALTER TABLE tickets MODIFY status ENUM('pending','processing','awaiting_info','appealing','resolved','rejected','closed') NOT NULL"); } catch {} try { await db.execute("ALTER TABLE users ADD COLUMN source ENUM('netease','skin') NOT NULL DEFAULT 'netease'"); } catch {} try { await db.execute(`CREATE TABLE IF NOT EXISTS user_servers (user_id INT NOT NULL, group_name VARCHAR(100) NOT NULL, server_name VARCHAR(100) NOT NULL, UNIQUE KEY uk_user_server (user_id, group_name, server_name), INDEX idx_user (user_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} try { await db.execute(`CREATE TABLE IF NOT EXISTS bans (id INT AUTO_INCREMENT PRIMARY KEY, player_name VARCHAR(50) NOT NULL, player_uid VARCHAR(50), source VARCHAR(10) DEFAULT '', reason TEXT, type ENUM('ban','mute','warn','other') NOT NULL DEFAULT 'ban', duration VARCHAR(20) DEFAULT '', ticket_id INT, created_by INT, status ENUM('active','expired','appealed','lifted') NOT NULL DEFAULT 'active', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} try { await db.execute("ALTER TABLE bans ADD COLUMN source VARCHAR(10) DEFAULT ''"); } catch {} try { await db.execute(`CREATE TABLE IF NOT EXISTS polls (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, options JSON NOT NULL, group_name VARCHAR(100) NOT NULL DEFAULT '', server_name VARCHAR(100) NOT NULL DEFAULT '', start_time DATETIME, end_time DATETIME, active TINYINT(1) NOT NULL DEFAULT 1, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_active (active), INDEX idx_group_server (group_name, server_name)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} try { await db.execute(`CREATE TABLE IF NOT EXISTS poll_votes (id INT AUTO_INCREMENT PRIMARY KEY, poll_id INT NOT NULL, user_id INT NOT NULL, option_index INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_poll_user (poll_id, user_id), INDEX idx_poll (poll_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} try { await db.execute(`CREATE TABLE IF NOT EXISTS feature_items (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, group_name VARCHAR(100) NOT NULL DEFAULT '', server_name VARCHAR(100) NOT NULL DEFAULT '', status ENUM('pending','planned','done') NOT NULL DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} try { await db.execute(`CREATE TABLE IF NOT EXISTS feature_votes (id INT AUTO_INCREMENT PRIMARY KEY, item_id INT NOT NULL, user_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_item_user (item_id, user_id), INDEX idx_item (item_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} try { await db.execute(`CREATE TABLE IF NOT EXISTS feature_comments (id INT AUTO_INCREMENT PRIMARY KEY, item_id INT NOT NULL, user_id INT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_item (item_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} try { await db.execute(`CREATE TABLE IF NOT EXISTS server_groups (id INT AUTO_INCREMENT PRIMARY KEY, group_name VARCHAR(100) NOT NULL, server_name VARCHAR(100) NOT NULL, UNIQUE KEY uk_group_server (group_name, server_name)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); } catch {} } 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}}】请验证您的邮箱', '
您好 {{username}}(游戏名: {{game_name}},UID: {{game_uid}}),
感谢注册!请点击以下链接验证邮箱:
链接有效期24小时。
— {{site_name}}
', '变量: {{site_name}}, {{username}}, {{game_name}}, {{game_uid}}, {{verify_link}}'], ['email_code', '邮箱验证码', '【{{site_name}}】您的邮箱验证码', '您好,
您的邮箱验证码为:
{{code}}
验证码10分钟内有效。如非本人操作,请忽略此邮件。
— {{site_name}}
', '变量: {{site_name}}, {{code}}'], ['ticket_created', '工单创建通知', '【{{site_name}}】{{ticket_type}}已提交 #{{ticket_id}}', '{{reporter_game_name}}(UID: {{reporter_game_uid}}),您的{{ticket_type}}已提交。
工单编号: #{{ticket_id}}
标题: {{ticket_title}}
— {{site_name}}
', '变量: {{site_name}}, {{reporter_game_name}}, {{ticket_type}}, {{ticket_id}}, {{ticket_title}}, {{tracking_link}}'], ['ticket_updated', '工单状态更新', '【{{site_name}}】工单 #{{ticket_id}} 状态更新', '工单 #{{ticket_id}} 状态已更新为 {{ticket_status}}。
标题: {{ticket_title}}
— {{site_name}}
', '变量: {{site_name}}, {{ticket_id}}, {{ticket_title}}, {{ticket_status}}, {{tracking_link}}'], ['ticket_claimed', '工单被认领', '【{{site_name}}】工单 #{{ticket_id}} 已被认领', '工单 #{{ticket_id}}({{ticket_title}})已被 {{assigned_to}} 认领。
— {{site_name}}
', '变量: {{site_name}}, {{ticket_id}}, {{ticket_title}}, {{assigned_to}}'], ['ticket_transferred', '工单已转交', '【{{site_name}}】工单 #{{ticket_id}} 已转交', '工单 #{{ticket_id}}({{ticket_title}})已由 {{from_user}} 转交给 {{to_user}}。
— {{site_name}}
', '变量: {{site_name}}, {{ticket_id}}, {{ticket_title}}, {{from_user}}, {{to_user}}'], ['reset_password', '密码重置', '【{{site_name}}】密码重置请求', '您好 {{username}},
我们收到了您的密码重置请求。请点击以下链接设置新密码:
链接1小时内有效。如非本人操作,请忽略此邮件。
— {{site_name}}
', '变量: {{site_name}}, {{username}}, {{reset_link}}'], ]; 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 };