333 lines
13 KiB
JavaScript
333 lines
13 KiB
JavaScript
/**
|
||
* ═══════════════════════════════════════════════════════════
|
||
* 📧 铸渊运维守卫 · 通知系统
|
||
* ═══════════════════════════════════════════════════════════
|
||
*
|
||
* 编号: ZY-OPS-NTF-001
|
||
* 签发: 铸渊 · ICE-GL-ZY001
|
||
* 版权: 国作登字-2026-A-00037559
|
||
*
|
||
* 通知通道:
|
||
* 1. QQ邮箱 SMTP(主通道)
|
||
* 2. 本地文件日志(始终启用)
|
||
* 3. 工单推送到前端(通过 HTTP API)
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const nodemailer = require('nodemailer');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const memory = require('./memory');
|
||
|
||
// ── 全局开关 + 冷却 ──────────────────────────
|
||
//
|
||
// OPS_EMAIL_ENABLED:
|
||
// - 默认 true。设为 'false'/'0' 时所有邮件函数立即 no-op(kill switch)。
|
||
// - 用法:`pm2 set ops-agent:OPS_EMAIL_ENABLED false && pm2 restart ops-agent`
|
||
//
|
||
// OPS_ALERT_COOLDOWN_MINUTES:
|
||
// - 默认 360(6 小时)。同一 (service+severity) 的告警与工单邮件在该间隔内只发一次,
|
||
// 避免高频持续故障(例如 MCP 连接超时)刷屏。
|
||
|
||
const COOLDOWN_MINUTES = (() => {
|
||
const v = parseInt(process.env.OPS_ALERT_COOLDOWN_MINUTES || '360', 10);
|
||
return Number.isFinite(v) && v >= 0 ? v : 360;
|
||
})();
|
||
|
||
const _lastSentAt = new Map(); // key -> epoch ms
|
||
|
||
function isEmailEnabled() {
|
||
// ⚠️ D72 (2026-05-03) 冰朔决策:默认禁用所有邮件告警。
|
||
// 服务器持续给冰朔发"大脑服务器异常"邮件,已成为噪音。
|
||
// 如需重新启用:显式设置 OPS_EMAIL_ENABLED=true (大小写不敏感)。
|
||
// 之前默认是 true,本次翻转默认为 false。
|
||
const v = process.env.OPS_EMAIL_ENABLED;
|
||
if (v === undefined || v === null || v === '') return false;
|
||
const norm = String(v).trim().toLowerCase();
|
||
return norm === 'true' || norm === '1' || norm === 'yes' || norm === 'on';
|
||
}
|
||
|
||
function cooldownRemainingMs(key) {
|
||
const last = _lastSentAt.get(key);
|
||
if (!last) return 0;
|
||
const elapsed = Date.now() - last;
|
||
const windowMs = COOLDOWN_MINUTES * 60 * 1000;
|
||
return elapsed >= windowMs ? 0 : windowMs - elapsed;
|
||
}
|
||
|
||
function markSent(key) {
|
||
_lastSentAt.set(key, Date.now());
|
||
}
|
||
|
||
// ── SMTP 传输器(懒初始化) ──────────────
|
||
|
||
let _transporter = null;
|
||
|
||
function getSmtpTransporter() {
|
||
if (_transporter) return _transporter;
|
||
|
||
const smtpUser = process.env.ZY_SMTP_USER;
|
||
const smtpPass = process.env.ZY_SMTP_PASS;
|
||
|
||
if (!smtpUser || !smtpPass) {
|
||
return null;
|
||
}
|
||
|
||
_transporter = nodemailer.createTransport({
|
||
host: 'smtp.qq.com',
|
||
port: 465,
|
||
secure: true,
|
||
auth: {
|
||
user: smtpUser,
|
||
pass: smtpPass
|
||
},
|
||
tls: {
|
||
rejectUnauthorized: true
|
||
}
|
||
});
|
||
|
||
return _transporter;
|
||
}
|
||
|
||
// ── 发送邮件告警 ────────────────────────────
|
||
|
||
async function sendAlertEmail(subject, htmlBody) {
|
||
if (!isEmailEnabled()) {
|
||
console.log('[Notifier] OPS_EMAIL_ENABLED=false,跳过邮件发送');
|
||
return { sent: false, reason: 'disabled' };
|
||
}
|
||
|
||
const transporter = getSmtpTransporter();
|
||
if (!transporter) {
|
||
console.log('[Notifier] SMTP 未配置,跳过邮件发送');
|
||
return { sent: false, reason: 'smtp_not_configured' };
|
||
}
|
||
|
||
const smtpUser = process.env.ZY_SMTP_USER;
|
||
const notifyTo = process.env.OPS_NOTIFY_EMAIL || smtpUser;
|
||
|
||
try {
|
||
const info = await transporter.sendMail({
|
||
from: `"铸渊运维守卫" <${smtpUser}>`,
|
||
to: notifyTo,
|
||
subject: `🛡️ ${subject}`,
|
||
html: htmlBody
|
||
});
|
||
console.log(`[Notifier] 邮件已发送: ${info.messageId}`);
|
||
return { sent: true, messageId: info.messageId };
|
||
} catch (err) {
|
||
console.error(`[Notifier] 邮件发送失败: ${err.message}`);
|
||
return { sent: false, reason: err.message };
|
||
}
|
||
}
|
||
|
||
// ── 生成告警邮件 HTML ──────────────────────
|
||
|
||
function buildAlertHTML(title, issues, checkResult) {
|
||
const issueRows = issues.map(i => `
|
||
<tr>
|
||
<td style="padding:8px;border-bottom:1px solid #1a2332;color:${
|
||
i.severity === 'critical' ? '#ff4444' :
|
||
i.severity === 'high' ? '#ff8800' :
|
||
'#ffcc00'
|
||
}">${i.severity?.toUpperCase() || 'UNKNOWN'}</td>
|
||
<td style="padding:8px;border-bottom:1px solid #1a2332;color:#e0e6ed">${i.service}</td>
|
||
<td style="padding:8px;border-bottom:1px solid #1a2332;color:#8899aa">${i.error || i.status}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
return `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<body style="margin:0;padding:0;background:#050810;font-family:'Segoe UI',sans-serif">
|
||
<div style="max-width:600px;margin:20px auto;background:#0a0f1a;border:1px solid #1a2332;border-radius:8px;overflow:hidden">
|
||
<div style="background:linear-gradient(135deg,#0a1628,#1a0a28);padding:20px;text-align:center">
|
||
<h1 style="color:#22d3ee;margin:0;font-size:18px">🛡️ 铸渊运维守卫 · 告警通知</h1>
|
||
<p style="color:#667788;margin:5px 0 0;font-size:12px">${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}</p>
|
||
</div>
|
||
<div style="padding:20px">
|
||
<h2 style="color:#ff8800;font-size:16px;margin:0 0 15px">${title}</h2>
|
||
<table style="width:100%;border-collapse:collapse">
|
||
<thead>
|
||
<tr style="border-bottom:2px solid #1a2332">
|
||
<th style="padding:8px;text-align:left;color:#667788;font-size:12px">严重度</th>
|
||
<th style="padding:8px;text-align:left;color:#667788;font-size:12px">服务</th>
|
||
<th style="padding:8px;text-align:left;color:#667788;font-size:12px">问题</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${issueRows}
|
||
</tbody>
|
||
</table>
|
||
${checkResult?.summary ? `<p style="color:#8899aa;margin:15px 0 0;font-size:13px">${checkResult.summary}</p>` : ''}
|
||
</div>
|
||
<div style="padding:15px 20px;background:#060a14;text-align:center">
|
||
<p style="color:#445566;margin:0;font-size:11px">铸渊运维守卫 · ZY-OPS-001 · 光湖实验室</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// ── 告警通知(巡检发现问题时调用) ──────────
|
||
|
||
async function alertOnIssues(checkResult) {
|
||
if (!checkResult.issues || checkResult.issues.length === 0) return;
|
||
|
||
const criticalIssues = checkResult.issues.filter(i => i.severity === 'critical' || i.severity === 'high');
|
||
if (criticalIssues.length === 0) return; // 只对高/紧急级别发邮件
|
||
|
||
// 冷却:剔除最近已通知过的 (service+severity)
|
||
const freshIssues = [];
|
||
const skipped = [];
|
||
for (const i of criticalIssues) {
|
||
const key = `alert:${i.service || 'unknown'}:${i.severity || 'unknown'}`;
|
||
const remaining = cooldownRemainingMs(key);
|
||
if (remaining > 0) {
|
||
skipped.push({ key, remainingMinutes: Math.ceil(remaining / 60000) });
|
||
} else {
|
||
freshIssues.push({ issue: i, key });
|
||
}
|
||
}
|
||
|
||
if (skipped.length > 0) {
|
||
console.log(`[Notifier] 冷却中跳过 ${skipped.length} 个告警: ${skipped.map(s => `${s.key}(${s.remainingMinutes}m)`).join(', ')}`);
|
||
}
|
||
|
||
if (freshIssues.length === 0) {
|
||
return { sent: false, reason: 'cooldown', skipped: skipped.length };
|
||
}
|
||
|
||
const issuesToSend = freshIssues.map(f => f.issue);
|
||
const title = `发现 ${issuesToSend.length} 个严重问题`;
|
||
const html = buildAlertHTML(title, issuesToSend, checkResult);
|
||
|
||
// 发邮件
|
||
const emailResult = await sendAlertEmail(title, html);
|
||
|
||
// 仅在真正发出去时记录冷却时间戳,避免 SMTP 失败导致永久静默
|
||
if (emailResult && emailResult.sent) {
|
||
for (const f of freshIssues) markSent(f.key);
|
||
}
|
||
|
||
// 记录事件
|
||
memory.logEvent('alert', {
|
||
title,
|
||
issueCount: issuesToSend.length,
|
||
skippedByCooldown: skipped.length,
|
||
emailSent: emailResult.sent,
|
||
summary: checkResult.summary
|
||
});
|
||
|
||
return emailResult;
|
||
}
|
||
|
||
// ── 工单升级通知 ────────────────────────────
|
||
|
||
async function notifyTicketCreated(ticket) {
|
||
// 冷却兜底:同一 (service+severity) 在窗口内只发一次工单邮件
|
||
const cooldownKey = `ticket:${ticket.relatedService || ticket.title || 'unknown'}:${ticket.severity || 'unknown'}`;
|
||
const remaining = cooldownRemainingMs(cooldownKey);
|
||
if (remaining > 0) {
|
||
console.log(`[Notifier] 工单邮件冷却中(${Math.ceil(remaining / 60000)}分钟剩余),跳过 ${ticket.id}`);
|
||
return { sent: false, reason: 'cooldown' };
|
||
}
|
||
|
||
const html = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<body style="margin:0;padding:0;background:#050810;font-family:'Segoe UI',sans-serif">
|
||
<div style="max-width:600px;margin:20px auto;background:#0a0f1a;border:1px solid #1a2332;border-radius:8px;overflow:hidden">
|
||
<div style="background:linear-gradient(135deg,#0a1628,#1a0a28);padding:20px;text-align:center">
|
||
<h1 style="color:#22d3ee;margin:0;font-size:18px">🎫 运维工单 · ${ticket.id}</h1>
|
||
</div>
|
||
<div style="padding:20px">
|
||
<h2 style="color:#e0e6ed;font-size:16px;margin:0 0 10px">${ticket.title}</h2>
|
||
<p style="color:#8899aa;margin:5px 0"><b>严重度:</b> <span style="color:${
|
||
ticket.severity === 'critical' ? '#ff4444' :
|
||
ticket.severity === 'high' ? '#ff8800' : '#ffcc00'
|
||
}">${ticket.severity}</span></p>
|
||
<p style="color:#8899aa;margin:5px 0"><b>处理方向:</b> ${ticket.direction}</p>
|
||
<p style="color:#8899aa;margin:5px 0"><b>诊断:</b> ${ticket.diagnosis}</p>
|
||
${ticket.suggestedFix ? `<p style="color:#8899aa;margin:5px 0"><b>建议:</b> ${ticket.suggestedFix}</p>` : ''}
|
||
<p style="color:#8899aa;margin:5px 0"><b>自动修复尝试:</b> ${ticket.autoRepairAttempts}次(均未成功)</p>
|
||
</div>
|
||
<div style="padding:15px 20px;background:#060a14;text-align:center">
|
||
<p style="color:#445566;margin:0;font-size:11px">铸渊运维守卫 · 自动生成工单</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
const result = await sendAlertEmail(`新工单 ${ticket.id}: ${ticket.title}`, html);
|
||
if (result && result.sent) markSent(cooldownKey);
|
||
return result;
|
||
}
|
||
|
||
// ── 每日报告邮件 ────────────────────────────
|
||
|
||
async function sendDailyReport(report) {
|
||
const serviceRows = (report.services || []).map(s => `
|
||
<tr>
|
||
<td style="padding:6px 8px;border-bottom:1px solid #1a2332;color:#e0e6ed">${s.name}</td>
|
||
<td style="padding:6px 8px;border-bottom:1px solid #1a2332;color:${s.status === 'online' ? '#22d3ee' : '#ff4444'}">${s.status}</td>
|
||
<td style="padding:6px 8px;border-bottom:1px solid #1a2332;color:#8899aa">${s.latency || '-'}ms</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
const html = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<body style="margin:0;padding:0;background:#050810;font-family:'Segoe UI',sans-serif">
|
||
<div style="max-width:600px;margin:20px auto;background:#0a0f1a;border:1px solid #1a2332;border-radius:8px;overflow:hidden">
|
||
<div style="background:linear-gradient(135deg,#0a1628,#1a0a28);padding:20px;text-align:center">
|
||
<h1 style="color:#22d3ee;margin:0;font-size:18px">📊 光湖每日健康报告</h1>
|
||
<p style="color:#667788;margin:5px 0 0;font-size:12px">${report.report_id}</p>
|
||
</div>
|
||
<div style="padding:20px">
|
||
<h3 style="color:#e0e6ed;margin:0 0 10px">服务状态</h3>
|
||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<thead><tr style="border-bottom:2px solid #1a2332">
|
||
<th style="padding:6px 8px;text-align:left;color:#667788">服务</th>
|
||
<th style="padding:6px 8px;text-align:left;color:#667788">状态</th>
|
||
<th style="padding:6px 8px;text-align:left;color:#667788">延迟</th>
|
||
</tr></thead>
|
||
<tbody>${serviceRows}</tbody>
|
||
</table>
|
||
${report.resources ? `
|
||
<h3 style="color:#e0e6ed;margin:15px 0 10px">系统资源</h3>
|
||
<p style="color:#8899aa;font-size:13px">
|
||
内存: ${report.resources.memory.used_pct}% ·
|
||
磁盘: ${report.resources.disk.used_pct}% ·
|
||
负载: ${report.resources.load?.[0]?.toFixed(2) || '-'} ·
|
||
运行: ${report.resources.uptime_hours}h
|
||
</p>` : ''}
|
||
<p style="color:#22d3ee;margin:15px 0 0;font-size:14px">${report.summary}</p>
|
||
${report.recommendations ? `
|
||
<h3 style="color:#e0e6ed;margin:15px 0 10px">建议</h3>
|
||
<ul style="color:#8899aa;font-size:13px;padding-left:20px">
|
||
${report.recommendations.map(r => `<li>${r}</li>`).join('')}
|
||
</ul>` : ''}
|
||
</div>
|
||
<div style="padding:15px 20px;background:#060a14;text-align:center">
|
||
<p style="color:#445566;margin:0;font-size:11px">铸渊运维守卫 · 每日自动报告</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
return sendAlertEmail(`每日报告 ${report.report_id}`, html);
|
||
}
|
||
|
||
module.exports = {
|
||
sendAlertEmail,
|
||
alertOnIssues,
|
||
notifyTicketCreated,
|
||
sendDailyReport,
|
||
getSmtpTransporter,
|
||
isEmailEnabled,
|
||
// 供测试使用
|
||
_resetCooldownForTests: () => _lastSentAt.clear()
|
||
};
|