Browse Source

乐跑异常自动告警

Pchen0 5 hours ago
parent
commit
ce364462d5
2 changed files with 135 additions and 0 deletions
  1. 91 0
      lib/Lepao/Worker.js
  2. 44 0
      plugin/Email/emailTemplate.js

+ 91 - 0
lib/Lepao/Worker.js

@@ -57,6 +57,83 @@ class Worker {
 
     /* ================= 工具 ================= */
 
+    formatYmdLocal(d = new Date()) {
+        const yyyy = d.getFullYear()
+        const mm = String(d.getMonth() + 1).padStart(2, '0')
+        const dd = String(d.getDate()).padStart(2, '0')
+        return `${yyyy}-${mm}-${dd}`
+    }
+
+    simpleHash(str) {
+        const s = String(str || '')
+        let h = 0
+        for (let i = 0; i < s.length; i++) {
+            h = ((h << 5) - h + s.charCodeAt(i)) | 0
+        }
+        return (h >>> 0).toString(16)
+    }
+
+    isCommonLepaoErrorMessage(msg) {
+        const m = String(msg || '')
+        return (
+            m.includes('当天已乐跑成功') ||
+            m.includes('当前不在有效乐跑时间范围内') ||
+            m.includes('乐跑账号登录已过期')
+        )
+    }
+
+    async notifyAdminsUncommonLepaoError(payload) {
+        try {
+            const { account, traceId, taskType, reason, code, name } = payload || {}
+            if (!account || !reason) return
+            if (this.isCommonLepaoErrorMessage(reason)) return
+
+            const day = this.formatYmdLocal()
+            const msgHash = this.simpleHash(reason)
+            const rateKey = `lepao:adminWarn:${account}:${day}:${msgHash}`
+            const existed = await Redis.get(rateKey)
+            if (existed) return
+            await Redis.set(rateKey, '1', { EX: 86400 })
+
+            const adminSql = `
+                SELECT email
+                FROM users
+                WHERE email IS NOT NULL
+                  AND email <> ''
+                  AND (JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"'))
+            `
+            const adminRows = await db.query(adminSql)
+            if (!adminRows || adminRows.length === 0) {
+                this.logger.warn(`[lepaoAdminWarn][${traceId}] 未找到可通知的管理员邮箱`)
+                return
+            }
+
+            const emails = [...new Set(adminRows.map(r => r.email).filter(Boolean))]
+            for (const email of emails) {
+                await EmailTemplate.lepaoAdminWarning(email, {
+                    server: (() => {
+                        try {
+                            // config.json 可能在其他模块缓存读取,这里动态 require 一次避免循环依赖
+                            // eslint-disable-next-line global-require
+                            const cfg = require('../../config.json')
+                            return cfg?.server || ''
+                        } catch (_) {
+                            return ''
+                        }
+                    })(),
+                    account,
+                    name: name || '',
+                    taskType: taskType || 'lepao.startRun',
+                    traceId: traceId || '',
+                    reason,
+                    code: code || ''
+                })
+            }
+        } catch (e) {
+            this.logger.error(`[lepaoAdminWarn] 告警邮件发送失败:${e.message || e}`)
+        }
+    }
+
     api(path) {
         return this.baseUrl + path
     }
@@ -657,6 +734,20 @@ class Worker {
             } catch (err) {
                 this.logger.error(`[${traceId}] 乐跑流程失败:`, err)
 
+                // 非阻塞:非常见错误时通知管理员(过滤:当天已跑/不在时间/登录过期)
+                Promise.resolve().then(async () => {
+                    try {
+                        await this.notifyAdminsUncommonLepaoError({
+                            account: req?.account,
+                            name: userData?.name,
+                            traceId,
+                            taskType: 'lepao.startRun',
+                            reason: err?.message || '未知错误',
+                            code: err?.code
+                        })
+                    } catch (_) { }
+                })
+
                 // 若已扣减次数,则失败时返还(幂等)
                 try {
                     await this.handlers['lepao.refundCount']({

+ 44 - 0
plugin/Email/emailTemplate.js

@@ -759,6 +759,50 @@ class emailTemplate {
         )
     }
 
+    async lepaoAdminWarning(email, data) {
+        const time = new Date().getTime()
+        const safe = (v) => (v === undefined || v === null) ? '' : String(v)
+        const subject = `RunForge - 乐跑异常告警(${safe(data.server || '')})`
+        await sendEmail(email, subject,
+            `<html lang="zh-CN">
+            <head>
+                <meta charset="UTF-8">
+                <meta name="viewport" content="width=device-width, initial-scale=1.0">
+                <title>RunForge - 乐跑异常告警</title>
+                <style>
+                    body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin:0; padding:0; }
+                    .container { width: 86%; max-width: 760px; margin: 20px auto; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
+                    .head { text-align:center; color:#2c3e50; }
+                    .info { background-color:#ecf0f1; padding: 14px 16px; border-radius: 6px; margin: 18px 0; }
+                    .info p { margin: 8px 0; text-indent: 0; word-break: break-all; }
+                    .tag { display:inline-block; background:#ffe8e6; color:#c0392b; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
+                    .footer { font-size: 12px; text-align:center; color:#7f8c8d; margin-top: 30px; }
+                </style>
+            </head>
+            <body>
+                <div class="container">
+                    <div class="head">
+                        <h2>乐跑异常告警 <span class="tag">非常见错误</span></h2>
+                    </div>
+                    <p>尊敬的管理员:</p>
+                    <p>系统在自动乐跑流程中捕获到一条非常见错误(已自动过滤“当天已跑 / 不在时间 / 登录过期”三类常见错误)。请关注是否为接口变更、代理异常或程序缺陷。</p>
+                    <div class="info">
+                        <p><strong>服务器:</strong> ${safe(data.server)}</p>
+                        <p><strong>账号:</strong> ${safe(data.account)}</p>
+                        <p><strong>姓名:</strong> ${safe(data.name)}</p>
+                        <p><strong>任务:</strong> ${safe(data.taskType)}</p>
+                        <p><strong>TraceId:</strong> ${safe(data.traceId)}</p>
+                        <p><strong>时间:</strong> ${this.stramptoTime(time)}</p>
+                        <p><strong>错误信息:</strong> ${safe(data.reason)}</p>
+                        <p><strong>错误码:</strong> ${safe(data.code)}</p>
+                    </div>
+                    <p class="footer">Copyright © 2025 RunForge</p>
+                </div>
+            </body>
+            </html>`
+        )
+    }
+
     async sendCountRequestApproved(email, data) {
         await sendEmail(email, 'RunForge - 赠送次数审核通过提醒',
             `<html lang="zh-CN">