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:
@@ -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
28
backend/logger.js
Normal 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 };
|
||||
@@ -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
37
backend/routes/logs.js
Normal 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;
|
||||
@@ -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: '服务器内部错误' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user