diff --git a/backend/db.js b/backend/db.js index 0bd9a05..97a0b17 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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 {} diff --git a/backend/logger.js b/backend/logger.js new file mode 100644 index 0000000..ce5b61c --- /dev/null +++ b/backend/logger.js @@ -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 }; diff --git a/backend/mailer.js b/backend/mailer.js index eb14fcf..649a1a5 100644 --- a/backend/mailer.js +++ b/backend/mailer.js @@ -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 }; diff --git a/backend/routes/logs.js b/backend/routes/logs.js new file mode 100644 index 0000000..3f6c731 --- /dev/null +++ b/backend/routes/logs.js @@ -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; diff --git a/backend/server.js b/backend/server.js index a263a10..b7197cb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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: '服务器内部错误' }); }); diff --git a/backend/webhook.js b/backend/webhook.js index 5156a6a..a76f0d3 100644 --- a/backend/webhook.js +++ b/backend/webhook.js @@ -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 {} } diff --git a/docs/API.md b/docs/API.md index 18ae826..2b6ca19 100644 --- a/docs/API.md +++ b/docs/API.md @@ -301,6 +301,19 @@ Base URL: `http://: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 错误中间件)。日志写库失败不影响主流程,表自动迁移创建。 + +--- + ## 十、验证码与文件 | 端点 | 说明 | diff --git a/public/index.html b/public/index.html index 8d5111c..365a54e 100644 --- a/public/index.html +++ b/public/index.html @@ -74,6 +74,7 @@ + diff --git a/public/js/app.js b/public/js/app.js index 544e945..57db167 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -11,6 +11,7 @@ const App = { { id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner'] }, { id: 'templates', label: '邮件模板', icon: 'fa-envelope', 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'] }, ], @@ -99,6 +100,7 @@ const App = { case 'feature': this.renderMain('更新详情', FeatureDetail, param); break; case 'servers': this.renderMain('服务器管理', ServersPage, param); break; case 'bans': this.renderMain('封禁列表', BansPage, param); break; + case 'logs': this.renderMain('系统日志', LogsPage, param); break; default: this.renderMain('控制台', Dashboard, param); } }, @@ -178,6 +180,7 @@ window.FeaturesPage = FeaturesPage; window.FeatureDetail = FeatureDetail; window.ServersPage = ServersPage; window.BansPage = BansPage; +window.LogsPage = LogsPage; window.LoginPage = LoginPage; window.RegisterPage = RegisterPage; window.HomePage = HomePage; diff --git a/public/js/pages/logs.js b/public/js/pages/logs.js new file mode 100644 index 0000000..9619da5 --- /dev/null +++ b/public/js/pages/logs.js @@ -0,0 +1,86 @@ +const LogsPage = { + tab: 'system', + + async render() { return '
'; }, + + async mount() { + document.getElementById('page-title').textContent = '系统日志'; + document.getElementById('page-actions').innerHTML = ``; + const ct = document.getElementById('page-content'); + ct.innerHTML = ` +
+ + +
+
+ + + +
+
+ `; + 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 = `
${e.message}
`; } + }, + + renderList(ct, rows, isEmail) { + if (isEmail) { + ct.innerHTML = rows.length === 0 + ? '

暂无邮件日志

' + : `
${rows.map(r => ` + + + + + + + + + `).join('')}
#收件人模板主题状态错误时间
${r.id}${U.esc(r.to_email)}${U.esc(r.template_code||'-')}${U.esc(r.subject||'')}${r.status==='sent'?'成功':'失败'}${U.esc(r.error||'-')}${U.date(r.created_at)}
`; + } else { + const lv = { info:'信息', warn:'警告', error:'错误' }; + ct.innerHTML = rows.length === 0 + ? '

暂无系统日志

' + : `
${rows.map(r => ` + + + + + + + + `).join('')}
#级别来源消息详情时间
${r.id}${lv[r.level]||r.level}${U.esc(r.source)}${U.esc(r.message)}${U.esc(r.details||'-')}${U.date(r.created_at)}
`; + } + } +};