271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
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',
|
||
});
|
||
}
|
||
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,
|
||
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,
|
||
active TINYINT(1) NOT NULL DEFAULT 1,
|
||
created_by INT,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
INDEX idx_active (active)
|
||
) 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,
|
||
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('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 {}
|
||
}
|
||
|
||
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 };
|