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) INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); ) 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 seedTemplates(db);
await migrateAdditions(db); await migrateAdditions(db);
} }
@@ -318,6 +342,32 @@ async function migrateAdditions(db) {
SELECT id, source, game_name, game_uid FROM users WHERE game_name != ''`); SELECT id, source, game_name, game_uid FROM users WHERE game_name != ''`);
} catch {} } 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 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 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_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 nodemailer = require('nodemailer');
const { getPool, query, getRow, getConfig } = require('./db'); const { getPool, query, getRow, getConfig } = require('./db');
const { logEmail, logSystem } = require('./logger');
async function getSmtpConfig() { async function getSmtpConfig() {
try { try {
@@ -56,16 +57,21 @@ function getTransporter(cfg) {
async function sendEmail(to, templateCode, vars) { async function sendEmail(to, templateCode, vars) {
try { try {
const cfg = await getSmtpConfig(); 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); 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 siteName = await getSiteName();
const allVars = { ...vars, site_name: siteName, site_url: await getSiteUrl() }; const allVars = { ...vars, site_name: siteName, site_url: await getSiteUrl() };
const { subject, body } = renderTemplate(template, allVars); const { subject, body } = renderTemplate(template, allVars);
const transporter = getTransporter(cfg); const transporter = getTransporter(cfg);
await transporter.sendMail({ from: `"${siteName}" <${cfg.from}>`, to, subject, html: body }); await transporter.sendMail({ from: `"${siteName}" <${cfg.from}>`, to, subject, html: body });
await logEmail(to, templateCode, subject, 'sent');
return true; 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 }; 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('/polls', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/polls'));
businessRouter.use('/features', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/features')); 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('/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.use('/external', require('./routes/external'));
businessRouter.get('/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`)); 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: err.message });
if (err.message?.includes('文件内容与声明类型不符')) return res.status(400).json({ error: '文件类型校验失败' }); if (err.message?.includes('文件内容与声明类型不符')) return res.status(400).json({ error: '文件类型校验失败' });
console.error(err); 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: '服务器内部错误' }); res.status(500).json({ error: '服务器内部错误' });
}); });

View File

@@ -1,4 +1,5 @@
const { query, getRow } = require('./db'); const { query, getRow } = require('./db');
const { logSystem } = require('./logger');
const EVENT_LABELS = { ticket_created:'工单创建', ticket_claimed:'工单认领', ticket_transferred:'工单转交', ticket_updated:'工单更新' }; 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) { for (const cfg of configs) {
const events = cfg.events || 'all'; const events = cfg.events || 'all';
if (events !== 'all' && !events.split(',').map(s=>s.trim()).includes(event)) continue; 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); const embed = buildEmbed(event, data);
try { try {
const ctrl = new AbortController(); const ctl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 10000); const t = setTimeout(() => ctl.abort(), 10000);
await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed), signal: ctrl.signal }); const resp = await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed), signal: ctl.signal });
clearTimeout(t); 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 {} } catch {}
} }

View File

@@ -301,6 +301,19 @@ Base URL: `http://<host>:3100/api`
--- ---
## 九·五、日志 Logs *(admin/owner)*
系统自动记录邮件发送结果、Webhook 调用、服务器错误,后台「系统日志」页可查,方便排查。
| 端点 | 说明 |
|------|------|
| GET /logs/emails?status=sent\|failed&email=&limit= | 邮件日志(收件人/模板/主题/状态/错误) |
| GET /logs/system?level=info\|warn\|error&source=&limit= | 系统日志(级别/来源/消息/详情) |
记录来源:mailer(SMTP 未配置/发送成功/失败)、webhook(拦截内网/非2xx/发送失败)、server(500 错误中间件)。日志写库失败不影响主流程,表自动迁移创建。
---
## 十、验证码与文件 ## 十、验证码与文件
| 端点 | 说明 | | 端点 | 说明 |

View File

@@ -74,6 +74,7 @@
<script src="js/pages/polls.js"></script> <script src="js/pages/polls.js"></script>
<script src="js/pages/servers.js"></script> <script src="js/pages/servers.js"></script>
<script src="js/pages/bans.js"></script> <script src="js/pages/bans.js"></script>
<script src="js/pages/logs.js"></script>
<script src="js/pages/features.js"></script> <script src="js/pages/features.js"></script>
<script src="js/pages/feature-detail.js"></script> <script src="js/pages/feature-detail.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>

View File

@@ -11,6 +11,7 @@ const App = {
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] }, { id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] },
{ id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner'] }, { id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner'] },
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] }, { id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner'] },
{ id: 'logs', label: '系统日志', icon: 'fa-clipboard-list', roles: ['owner','admin'] },
{ id: 'export', label: '数据导出', icon: 'fa-download', roles: ['owner'] }, { id: 'export', label: '数据导出', icon: 'fa-download', roles: ['owner'] },
], ],
@@ -99,6 +100,7 @@ const App = {
case 'feature': this.renderMain('更新详情', FeatureDetail, param); break; case 'feature': this.renderMain('更新详情', FeatureDetail, param); break;
case 'servers': this.renderMain('服务器管理', ServersPage, param); break; case 'servers': this.renderMain('服务器管理', ServersPage, param); break;
case 'bans': this.renderMain('封禁列表', BansPage, param); break; case 'bans': this.renderMain('封禁列表', BansPage, param); break;
case 'logs': this.renderMain('系统日志', LogsPage, param); break;
default: this.renderMain('控制台', Dashboard, param); default: this.renderMain('控制台', Dashboard, param);
} }
}, },
@@ -178,6 +180,7 @@ window.FeaturesPage = FeaturesPage;
window.FeatureDetail = FeatureDetail; window.FeatureDetail = FeatureDetail;
window.ServersPage = ServersPage; window.ServersPage = ServersPage;
window.BansPage = BansPage; window.BansPage = BansPage;
window.LogsPage = LogsPage;
window.LoginPage = LoginPage; window.LoginPage = LoginPage;
window.RegisterPage = RegisterPage; window.RegisterPage = RegisterPage;
window.HomePage = HomePage; window.HomePage = HomePage;

86
public/js/pages/logs.js Normal file
View File

@@ -0,0 +1,86 @@
const LogsPage = {
tab: 'system',
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
async mount() {
document.getElementById('page-title').textContent = '系统日志';
document.getElementById('page-actions').innerHTML = `<button class="btn btn-o btn-sm" onclick="LogsPage.refresh()"><i class="fas fa-rotate"></i> 刷新</button>`;
const ct = document.getElementById('page-content');
ct.innerHTML = `
<div class="tab-nav" id="logs-tabs">
<button data-tab="system" class="active"><i class="fas fa-server"></i> 系统日志</button>
<button data-tab="email"><i class="fas fa-envelope"></i> 邮件日志</button>
</div>
<div class="filter-bar">
<select id="lg-level" onchange="LogsPage.applyFilter()">
<option value="">全部级别</option><option value="info">信息</option><option value="warn">警告</option><option value="error">错误</option>
</select>
<input id="lg-source" placeholder="来源关键字" onkeyup="if(event.key==='Enter')LogsPage.applyFilter()">
<button class="btn btn-o btn-sm" onclick="LogsPage.applyFilter()"><i class="fas fa-filter"></i></button>
</div>
<div id="logs-list"></div>
`;
document.querySelectorAll('#logs-tabs button').forEach(b => {
b.onclick = () => {
document.querySelectorAll('#logs-tabs button').forEach(x => x.classList.remove('active'));
b.classList.add('active');
this.tab = b.dataset.tab;
this.load();
};
});
await this.load();
},
refresh() { this.load(); },
async applyFilter() {
this.load();
},
async load() {
const list = document.getElementById('logs-list');
U.loading(list);
const level = document.getElementById('lg-level')?.value || '';
const src = document.getElementById('lg-source')?.value.trim() || '';
const isEmail = this.tab === 'email';
try {
const params = new URLSearchParams();
if (level) params.set('level', level);
if (src) params.set(isEmail ? 'email' : 'source', src);
const qs = params.toString();
const rows = await API.get('/logs/' + (isEmail ? 'emails' : 'system') + (qs ? '?' + qs : ''));
this.renderList(list, rows, isEmail);
} catch (e) { list.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
},
renderList(ct, rows, isEmail) {
if (isEmail) {
ct.innerHTML = rows.length === 0
? '<div class="empty"><i class="fas fa-envelope-open"></i><p>暂无邮件日志</p></div>'
: `<div class="card table-wrap"><table><thead><tr><th>#</th><th>收件人</th><th>模板</th><th>主题</th><th>状态</th><th>错误</th><th>时间</th></tr></thead><tbody>${rows.map(r => `
<tr>
<td>${r.id}</td>
<td>${U.esc(r.to_email)}</td>
<td><span class="badge bg-admin">${U.esc(r.template_code||'-')}</span></td>
<td class="ts">${U.esc(r.subject||'')}</td>
<td>${r.status==='sent'?'<span class="badge bg-resolved">成功</span>':'<span class="badge bg-rejected">失败</span>'}</td>
<td class="ts" style="max-width:260px;word-break:break-all">${U.esc(r.error||'-')}</td>
<td class="ts">${U.date(r.created_at)}</td>
</tr>`).join('')}</tbody></table></div>`;
} else {
const lv = { info:'<span class="badge bg-resolved">信息</span>', warn:'<span class="badge bg-medium">警告</span>', error:'<span class="badge bg-rejected">错误</span>' };
ct.innerHTML = rows.length === 0
? '<div class="empty"><i class="fas fa-clipboard"></i><p>暂无系统日志</p></div>'
: `<div class="card table-wrap"><table><thead><tr><th>#</th><th>级别</th><th>来源</th><th>消息</th><th>详情</th><th>时间</th></tr></thead><tbody>${rows.map(r => `
<tr>
<td>${r.id}</td>
<td>${lv[r.level]||r.level}</td>
<td><span class="badge bg-admin">${U.esc(r.source)}</span></td>
<td>${U.esc(r.message)}</td>
<td class="ts" style="max-width:280px;word-break:break-all">${U.esc(r.details||'-')}</td>
<td class="ts">${U.date(r.created_at)}</td>
</tr>`).join('')}</tbody></table></div>`;
}
}
};