guanghulab/ops-agent/notifier.js
Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

333 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ═══════════════════════════════════════════════════════════
* 📧 铸渊运维守卫 · 通知系统
* ═══════════════════════════════════════════════════════════
*
* 编号: 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-opkill switch
// - 用法:`pm2 set ops-agent:OPS_EMAIL_ENABLED false && pm2 restart ops-agent`
//
// OPS_ALERT_COOLDOWN_MINUTES:
// - 默认 3606 小时)。同一 (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()
};