feat: system logs - email log + system log with admin UI

- db: email_logs (sent/failed) + system_logs (info/warn/error) tables,
  auto-migrated for existing installs
- backend/logger.js: logEmail/logSystem with try/catch (never breaks flow)
- mailer: log send result (SMTP unconfigured/sent/failed + error msg)
- webhook: log blocked-internal, non-2xx response, send failure
- server: error middleware records 500 errors to system_logs
- routes/logs.js: GET /logs/emails + /logs/system (admin/owner, filters)
- UI: 系统日志 page (owner/admin) with system/email tabs + filters
- API docs updated
This commit is contained in:
2026-08-17 05:14:41 +08:00
parent 8d6c3c4a8d
commit a11df22600
10 changed files with 241 additions and 8 deletions

View File

@@ -288,6 +288,30 @@ async function initSchema(connection) {
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('email_logs', `CREATE TABLE email_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
to_email VARCHAR(100) NOT NULL,
template_code VARCHAR(50) NOT NULL DEFAULT '',
subject VARCHAR(255) NOT NULL DEFAULT '',
status ENUM('sent','failed') NOT NULL,
error TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_email (to_email),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await createIfNotExists('system_logs', `CREATE TABLE system_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
level ENUM('info','warn','error') NOT NULL DEFAULT 'info',
source VARCHAR(50) NOT NULL DEFAULT '',
message TEXT NOT NULL,
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_level (level),
INDEX idx_source (source),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await seedTemplates(db);
await migrateAdditions(db);
}
@@ -318,6 +342,32 @@ async function migrateAdditions(db) {
SELECT id, source, game_name, game_uid FROM users WHERE game_name != ''`);
} catch {}
// 日志表(邮件日志 + 系统日志)
try {
await db.execute(`CREATE TABLE IF NOT EXISTS email_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
to_email VARCHAR(100) NOT NULL,
template_code VARCHAR(50) NOT NULL DEFAULT '',
subject VARCHAR(255) NOT NULL DEFAULT '',
status ENUM('sent','failed') NOT NULL,
error TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_email (to_email),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await db.execute(`CREATE TABLE IF NOT EXISTS system_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
level ENUM('info','warn','error') NOT NULL DEFAULT 'info',
source VARCHAR(50) NOT NULL DEFAULT '',
message TEXT NOT NULL,
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_level (level),
INDEX idx_source (source),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
} 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 {}

28
backend/logger.js Normal file
View File

@@ -0,0 +1,28 @@
// 日志模块: 邮件日志 + 系统日志, 写入数据库供后台查看
// 所有写库操作 try/catch 包裹, 日志失败绝不影响主流程
const { query } = require('./db');
// 系统日志: level = info | warn | error
async function logSystem(level, source, message, details) {
try {
await query(
'INSERT INTO system_logs(level, source, message, details) VALUES (?,?,?,?)',
[level || 'info', String(source || '').slice(0, 50), String(message || '').slice(0, 2000),
details ? JSON.stringify(details).slice(0, 4000) : null]
);
} catch { /* 日志失败忽略 */ }
}
// 邮件日志: status = sent | failed
async function logEmail(to, templateCode, subject, status, error) {
try {
await query(
'INSERT INTO email_logs(to_email, template_code, subject, status, error) VALUES (?,?,?,?,?)',
[String(to || '').slice(0, 100), String(templateCode || '').slice(0, 50),
String(subject || '').slice(0, 255), status === 'sent' ? 'sent' : 'failed',
error ? String(error).slice(0, 2000) : null]
);
} catch { /* 日志失败忽略 */ }
}
module.exports = { logSystem, logEmail };

View File

@@ -1,5 +1,6 @@
const nodemailer = require('nodemailer');
const { getPool, query, getRow, getConfig } = require('./db');
const { logEmail, logSystem } = require('./logger');
async function getSmtpConfig() {
try {
@@ -56,16 +57,21 @@ function getTransporter(cfg) {
async function sendEmail(to, templateCode, vars) {
try {
const cfg = await getSmtpConfig();
if (!cfg.host || !cfg.user) { console.log('[Mailer] SMTP未配置'); return false; }
if (!cfg.host || !cfg.user) { console.log('[Mailer] SMTP未配置'); await logSystem('warn', 'mailer', 'SMTP未配置邮件未发送', { to, template: templateCode }); return false; }
const template = await getTemplate(templateCode);
if (!template) { console.log('[Mailer] 模板不存在:', templateCode); return false; }
if (!template) { console.log('[Mailer] 模板不存在:', templateCode); await logEmail(to, templateCode, '', 'failed', '模板不存在: ' + templateCode); return false; }
const siteName = await getSiteName();
const allVars = { ...vars, site_name: siteName, site_url: await getSiteUrl() };
const { subject, body } = renderTemplate(template, allVars);
const transporter = getTransporter(cfg);
await transporter.sendMail({ from: `"${siteName}" <${cfg.from}>`, to, subject, html: body });
await logEmail(to, templateCode, subject, 'sent');
return true;
} catch (err) { console.error('[Mailer]', err.message); return false; }
} catch (err) {
console.error('[Mailer]', err.message);
await logEmail(to, templateCode, '', 'failed', err.message);
return false;
}
}
module.exports = { sendEmail, renderTemplate, getTemplate, getSmtpConfig, getSiteName, getSiteUrl };

37
backend/routes/logs.js Normal file
View File

@@ -0,0 +1,37 @@
const express = require('express');
const { query } = require('../db');
const { authenticate, requireRole } = require('../middleware/auth');
const router = express.Router();
// ---- 邮件日志(admin/owner 可查) ----
router.get('/emails', authenticate, requireRole('owner', 'admin'), async (req, res) => {
try {
const { status, email, limit } = req.query;
let q = 'SELECT * FROM email_logs WHERE 1=1';
const p = [];
if (status === 'sent' || status === 'failed') { q += ' AND status = ?'; p.push(status); }
if (email) { q += ' AND to_email LIKE ?'; p.push(`%${email}%`); }
q += ' ORDER BY id DESC LIMIT ?';
p.push(Math.min(parseInt(limit) || 100, 500));
const rows = await query(q, p);
res.json(rows);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ---- 系统日志(admin/owner 可查) ----
router.get('/system', authenticate, requireRole('owner', 'admin'), async (req, res) => {
try {
const { level, source, limit } = req.query;
let q = 'SELECT * FROM system_logs WHERE 1=1';
const p = [];
if (['info', 'warn', 'error'].includes(level)) { q += ' AND level = ?'; p.push(level); }
if (source) { q += ' AND source LIKE ?'; p.push(`%${source}%`); }
q += ' ORDER BY id DESC LIMIT ?';
p.push(Math.min(parseInt(limit) || 100, 500));
const rows = await query(q, p);
res.json(rows);
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

View File

@@ -128,6 +128,7 @@ function loadBusinessRoutes() {
businessRouter.use('/polls', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/polls'));
businessRouter.use('/features', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/features'));
businessRouter.use('/bans', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/bans'));
businessRouter.use('/logs', methodGuard(['GET']), generalLimiter, require('./routes/logs'));
businessRouter.use('/external', require('./routes/external'));
businessRouter.get('/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`));
@@ -197,6 +198,7 @@ app.use((err, req, res, next) => {
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);
try { require('./logger').logSystem('error', 'server', err.message || '服务器内部错误', { path: req.path, method: req.method, stack: String(err.stack).slice(0, 1000) }); } catch {}
res.status(500).json({ error: '服务器内部错误' });
});

View File

@@ -1,4 +1,5 @@
const { query, getRow } = require('./db');
const { logSystem } = require('./logger');
const EVENT_LABELS = { ticket_created:'工单创建', ticket_claimed:'工单认领', ticket_transferred:'工单转交', ticket_updated:'工单更新' };
@@ -24,14 +25,20 @@ async function sendWebhook(event, data) {
for (const cfg of configs) {
const events = cfg.events || 'all';
if (events !== 'all' && !events.split(',').map(s=>s.trim()).includes(event)) continue;
if (cfg.webhook_url && await isPrivateUrl(cfg.webhook_url)) { console.error('[Webhook] blocked internal URL:', cfg.name); continue; }
if (cfg.webhook_url && await isPrivateUrl(cfg.webhook_url)) { console.error('[Webhook] blocked internal URL:', cfg.name); await logSystem('warn', 'webhook', `拦截内网地址: ${cfg.name}`, { url: cfg.webhook_url }); continue; }
const embed = buildEmbed(event, data);
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 10000);
await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed), signal: ctrl.signal });
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 10000);
const resp = await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed), signal: ctl.signal });
clearTimeout(t);
} catch (e) { console.error('[Webhook]', cfg.name, e.message); }
if (resp.status < 200 || resp.status >= 300) {
await logSystem('warn', 'webhook', `Webhook ${cfg.name} 返回 ${resp.status}`, { event, url: cfg.webhook_url });
}
} catch (e) {
console.error('[Webhook]', cfg.name, e.message);
await logSystem('error', 'webhook', `Webhook ${cfg.name} 发送失败: ${e.message}`, { event, url: cfg.webhook_url });
}
}
} catch {}
}