From 02918fa907f479f298ba09c2ce206048a9493747 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Thu, 2 Jul 2026 16:45:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E6=B6=88=E6=81=AF=E6=A0=BC=E5=BC=8F=E5=85=A8?= =?UTF-8?q?=E9=9D=A2=E5=AF=B9=E9=BD=90=20zabbix-01=20=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1-P5: 结单消息按 fault_type 分支,补齐机架位置/故障类型/故障时间/月度统计 - P6: OEM 维修档位标题补全「基于月度内累计故障时长计算」 - P7: OEM 诊断字段顺序调整(到期时间在故障信息之前) - P8: 每小时提醒显示「X小时Y分」 - P9/P10: 静默期积压消息格式对齐(动态剩余时间、机架位置、30min 区分) - 修复: isInSilentPeriod/isTimeToFlush 时区问题(UTC+8 偏移) - 修复: 先推送后更新 DB,防止推送失败通知丢失 - 修复: faultDetail 截断 500 字符防止超限 - 修复: tierPenaltyMap 提取为类级常量 --- src/lib/monitor/reminder-engine.ts | 75 +++++++++++++++++++++-------- src/lib/monitor/wechat-pusher.ts | 6 ++- src/lib/monitor/worker.ts | 77 +++++++++++++++++++++++++++--- 3 files changed, 129 insertions(+), 29 deletions(-) diff --git a/src/lib/monitor/reminder-engine.ts b/src/lib/monitor/reminder-engine.ts index 8eff76e..f93faa0 100644 --- a/src/lib/monitor/reminder-engine.ts +++ b/src/lib/monitor/reminder-engine.ts @@ -10,15 +10,20 @@ const logger = { export class ReminderEngine { private db = getDb() + private static readonly TIER_PENALTY_MAP: Record = { '第一档': '10%', '第二档': '25%', '第三档': '50%', '第四档': '100%' } + + /** 获取北京时间的小时数(兼容 UTC 容器环境) */ + private getBeijingHour(): number { + return new Date(new Date().getTime() + 8 * 3600_000).getUTCHours() + } isInSilentPeriod(config: MonitorConfig): boolean { - const hour = new Date().getHours() + const hour = this.getBeijingHour() return hour >= config.monitor.silent_start_hour && hour < config.monitor.silent_end_hour } isTimeToFlush(): boolean { - const now = new Date() - if (now.getHours() !== 7) return false + if (this.getBeijingHour() !== 7) return false // 检查今天是否已 flush 过(防止重复执行) const flushed = this.db.prepare("SELECT id FROM monitor_logs WHERE action = 'flush_complete' AND date(created_at, '+8 hours') = date('now', '+8 hours')").get() return !flushed @@ -106,7 +111,9 @@ export class ReminderEngine { } const rackH = getRack ? await getRack(r.server_ip, r.server_sn) : null const remainingHours = Math.floor(timeToDeadline / 3600) - let text = `⏰ OEM诊断工单提醒(剩余${remainingHours}小时)\n\n` + const remainingMinutes = Math.floor((timeToDeadline % 3600) / 60) + const timeStr = remainingHours > 0 ? `${remainingHours}小时${remainingMinutes}分` : `${remainingMinutes}分` + let text = `⏰ OEM诊断工单提醒(剩余${timeStr})\n\n` if (r.order_number) text += `工单号:${r.order_number}\n` text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n` if (rackH) text += `机架位置:${rackH}\n` @@ -119,7 +126,7 @@ export class ReminderEngine { } } - async flushPendingMessages(pushText: (text: string) => Promise): Promise { + async flushPendingMessages(pushText: (text: string) => Promise, getRack?: (ip: string | null, sn: string | null) => Promise): Promise { const pending = this.db.prepare('SELECT * FROM pending_messages').all() as { id: number; message_type: string; server_sn: string; payload: string }[] for (const p of pending) { try { @@ -128,11 +135,12 @@ export class ReminderEngine { this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id) continue } - // 重新计算剩余时间 + // 重新计算剩余时间(提升到 try 块作用域,供下方消息构建使用) + let remainingSec = 0 if (payload.deadline) { const deadline = new Date(payload.deadline.replace(' ', 'T') + '+08:00') - const remaining = (deadline.getTime() - Date.now()) / 1000 - if (remaining <= 0) { + remainingSec = (deadline.getTime() - Date.now()) / 1000 + if (remainingSec <= 0) { this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id) continue } @@ -140,17 +148,46 @@ export class ReminderEngine { // 构建消息 let text = '' if (p.message_type === 'oem_diag') { - text = `OEM诊断工单提醒\n\n` - if (payload.order_number) text += `工单号:${payload.order_number}\n` - text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间,请及时处理!` + // 获取机架位置 + let rackFlush: string | null = null + if (getRack) { + try { rackFlush = await getRack(payload.server_ip, payload.server_sn) } catch { /* ignore */ } + } + // 区分 diag_30min 和 diag_hourly(与 zabbix-01 flush 一致) + if (payload.type === 'diag_30min') { + text = `⚠️ OEM诊断工单即将到期(剩余30分钟)\n\n` + if (payload.order_number) text += `工单号:${payload.order_number}\n` + text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n` + if (rackFlush) text += `机架位置:${rackFlush}\n` + text += `到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间,请尽快处理!` + } else { + // diag_hourly(默认):动态计算剩余时间 + const remainingH = Math.floor(remainingSec / 3600) + const remainingM = Math.floor((remainingSec % 3600) / 60) + const timeStr = remainingH > 0 ? `${remainingH}小时${remainingM}分` : `${remainingM}分` + text = `⏰ OEM诊断工单提醒(剩余${timeStr})\n\n` + if (payload.order_number) text += `工单号:${payload.order_number}\n` + text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n` + if (rackFlush) text += `机架位置:${rackFlush}\n` + text += `到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间,请及时处理!` + } } else if (p.message_type === 'oem_repair' && payload.type === 'repair_before') { - // 重新计算剩余时间 - const tierPenaltyMap: Record = { '第一档': '10%', '第二档': '25%', '第三档': '50%', '第四档': '100%' } - const penalty = payload.tier_name ? tierPenaltyMap[payload.tier_name] || '' : '' + // 动态计算剩余时间(替代硬编码"1小时") + const remainingH = Math.floor(remainingSec / 3600) + const remainingM = Math.floor((remainingSec % 3600) / 60) + const timeStr = remainingH > 0 ? `${remainingH}小时${remainingM}分` : `${remainingM}分` + // 获取机架位置 + let rackFlush: string | null = null + if (getRack) { + try { rackFlush = await getRack(payload.server_ip, payload.server_sn) } catch { /* ignore */ } + } + const penalty = payload.tier_name ? ReminderEngine.TIER_PENALTY_MAP[payload.tier_name] || '' : '' const tierLabel = payload.tier_name === '第四档' ? '全额扣费(100%)' : `${payload.tier_name}(${penalty})` - text = `OEM维修工单超时提醒(距离${tierLabel}还剩1小时)\n\n` + text = `OEM维修工单超时提醒(距离${tierLabel}还剩${timeStr})\n\n` if (payload.order_number) text += `工单号:${payload.order_number}\n` - text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间,请及时处理!` + text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n` + if (rackFlush) text += `机架位置:${rackFlush}\n` + text += `${payload.tier_name || ''}到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间,请及时处理!` } if (text && await pushText(text)) { this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id) @@ -174,8 +211,7 @@ export class ReminderEngine { if (timeToDeadline <= 0 && r.notification_phase !== 'overdue') { const rackRO = getRack ? await getRack(r.server_ip, r.server_sn) : null // 超时提醒格式:第X档已超时,达到第Y档扣XX% - const tierPenaltyMap: Record = { '第一档': '10%', '第二档': '25%', '第三档': '50%', '第四档': '100%' } - const penalty = tierPenaltyMap[r.tier_name] || '' + const penalty = ReminderEngine.TIER_PENALTY_MAP[r.tier_name] || '' const isFinalTier = r.tier_name === '第四档' let text = isFinalTier ? `OEM维修工单超时提醒(${r.tier_name}已超时,已触发全额扣费(100%))\n\n` @@ -199,8 +235,7 @@ export class ReminderEngine { } const rackRB = getRack ? await getRack(r.server_ip, r.server_sn) : null // 提前1小时提醒格式:距离第X档(扣XX%)还剩1小时 - const tierPenaltyMap: Record = { '第一档': '10%', '第二档': '25%', '第三档': '50%', '第四档': '100%' } - const penalty = tierPenaltyMap[r.tier_name] || '' + const penalty = ReminderEngine.TIER_PENALTY_MAP[r.tier_name] || '' const isFinalTier = r.tier_name === '第四档' const tierLabel = isFinalTier ? '全额扣费(100%)' : `${r.tier_name}(${penalty})` let text = `OEM维修工单超时提醒(距离${tierLabel}还剩1小时)\n\n` diff --git a/src/lib/monitor/wechat-pusher.ts b/src/lib/monitor/wechat-pusher.ts index 717a66a..adb0583 100644 --- a/src/lib/monitor/wechat-pusher.ts +++ b/src/lib/monitor/wechat-pusher.ts @@ -49,12 +49,14 @@ export class WeChatPusher { msg += `服务器SN:${faultInfo.server_sn || '未知'}\n` msg += `机架位置:${rackPosition || '未知'}\n` if (faultInfo.fault_time) msg += `故障时间:${faultInfo.fault_time}\n` - if (faultDetail) msg += `故障信息:${faultDetail}\n` + const truncatedDetail = faultDetail ? faultDetail.slice(0, 500) : null if (isOemDiag && oemDeadline) { msg += `到期时间:${oemDeadline}\n` + if (truncatedDetail) msg += `故障信息:${truncatedDetail}\n` } else if (!isOemDiag) { - msg += '\n📊 服务可用性各档位截止时间:\n\n' + if (truncatedDetail) msg += `故障信息:${truncatedDetail}\n` + msg += '\n📊 服务可用性各档位截止时间(基于月度内累计故障时长计算):\n\n' const tierDesc: Record = { '第一档': '99%(含)以上(不扣费)', '第二档': '99%-97%(含)(扣10%)', diff --git a/src/lib/monitor/worker.ts b/src/lib/monitor/worker.ts index c73a9d9..3fd0916 100644 --- a/src/lib/monitor/worker.ts +++ b/src/lib/monitor/worker.ts @@ -69,7 +69,10 @@ export class BackgroundWorker { // Step 4: 7 点 flush if (this.reminderEngine.isTimeToFlush()) { - await this.reminderEngine.flushPendingMessages((text) => this.wechatPusher.pushText(text, config.wechat.webhook_url)) + await this.reminderEngine.flushPendingMessages( + (text) => this.wechatPusher.pushText(text, config.wechat.webhook_url), + (ip, sn) => this.ticketProcessor.getRackPosition(ip, sn) + ) this.reminderEngine.markFlushed() } @@ -190,20 +193,80 @@ export class BackgroundWorker { } private async checkRecoveryUpdates(config: MonitorConfig): Promise { - const ongoing = this.db.prepare("SELECT * FROM fault_records WHERE status = 'ongoing'").all() as { id: number; order_number: string | null; server_sn: string; server_ip: string | null; fault_time: string }[] + const ongoing = this.db.prepare("SELECT * FROM fault_records WHERE status = 'ongoing'").all() as { + id: number; order_number: string | null; server_sn: string; server_ip: string | null; + fault_time: string; fault_type: 'oem_diag' | 'oem_repair' | null + }[] for (const fault of ongoing) { if (!fault.order_number) continue + if (!fault.fault_time) continue const ticket = this.db.prepare('SELECT current_status, close_time FROM tickets WHERE id = ?').get(parseInt(fault.order_number)) as { current_status: string; close_time: string | null } | undefined if (ticket && ['resolved', 'closed'].includes(ticket.current_status)) { const recoveryTime = ticket.close_time || formatBeijingTime(new Date()) const faultDate = new Date(fault.fault_time.replace(' ', 'T') + '+08:00') const recoveryDate = new Date(recoveryTime.replace(' ', 'T') + '+08:00') const duration = Math.floor((recoveryDate.getTime() - faultDate.getTime()) / 1000) - this.db.prepare("UPDATE fault_records SET status = 'resolved', recovery_time = ?, duration_seconds = ? WHERE id = ?").run(recoveryTime, duration, fault.id) - this.reminderEngine.cleanupForTicket(fault.order_number) - // 发送结单提醒 - await this.wechatPusher.pushText(`✅ ${fault.order_number}工单已结单\n\n服务器IP:${fault.server_ip || '未知'}\n服务器SN:${fault.server_sn}\n结单时间:${recoveryTime}\n本次处理时长:${Math.floor(duration / 3600)}小时${Math.floor((duration % 3600) / 60)}分钟`, config.wechat.webhook_url) - logger.info(`Recovery detected: ${fault.order_number}`) + + // 获取机架位置 + const rackPosition = await this.ticketProcessor.getRackPosition(fault.server_ip, fault.server_sn) + + // 格式化处理时长 + const hours = Math.floor(duration / 3600) + const minutes = Math.floor((duration % 3600) / 60) + const durationStr = `${hours}小时${minutes}分钟` + + // 按故障类型构建结单消息 + let message: string + if (fault.fault_type === 'oem_diag') { + message = `✅ ${fault.order_number}工单已结单\n\n` + + `服务器IP:${fault.server_ip || '未知'}\n` + + `服务器SN:${fault.server_sn}\n` + + (rackPosition ? `机架位置:${rackPosition}\n` : '') + + `故障类型:OEM诊断\n` + + `故障时间:${fault.fault_time}\n` + + `结单时间:${recoveryTime}\n` + + `本次处理时长:${durationStr}` + } else if (fault.fault_type === 'oem_repair') { + const monthKey = fault.fault_time.substring(0, 7) + const monthCount = (this.db.prepare( + "SELECT COUNT(*) as cnt FROM fault_records WHERE server_sn = ? AND status = 'resolved' AND strftime('%Y-%m', fault_time) = ?" + ).get(fault.server_sn, monthKey) as { cnt: number } | undefined) || { cnt: 0 } + const monthTotal = (this.db.prepare( + "SELECT COALESCE(SUM(duration_seconds), 0) as total FROM fault_records WHERE server_sn = ? AND status = 'resolved' AND strftime('%Y-%m', fault_time) = ?" + ).get(fault.server_sn, monthKey) as { total: number } | undefined) || { total: 0 } + // 先累加秒数再转时分(避免分钟进位问题) + const totalSec = monthTotal.total + duration + const totalHours = Math.floor(totalSec / 3600) + const totalMinutes = Math.floor((totalSec % 3600) / 60) + + message = `✅ ${fault.order_number}工单已结单\n\n` + + `服务器IP:${fault.server_ip || '未知'}\n` + + `服务器SN:${fault.server_sn}\n` + + (rackPosition ? `机架位置:${rackPosition}\n` : '') + + `故障类型:OEM维修\n` + + `故障时间:${fault.fault_time}\n` + + `结单时间:${recoveryTime}\n` + + `本次处理时长:${durationStr}\n` + + `本月故障次数:${monthCount.cnt + 1}次\n` + + `本月总处理时长:${totalHours}小时${totalMinutes}分钟` + } else { + message = `✅ ${fault.order_number}工单已结单\n\n` + + `服务器IP:${fault.server_ip || '未知'}\n` + + `服务器SN:${fault.server_sn}\n` + + (rackPosition ? `机架位置:${rackPosition}\n` : '') + + `结单时间:${recoveryTime}\n` + + `本次处理时长:${durationStr}` + } + + // 先推送消息,成功后再更新数据库(防止推送失败导致通知丢失) + const pushed = await this.wechatPusher.pushText(message, config.wechat.webhook_url) + if (pushed) { + this.db.prepare("UPDATE fault_records SET status = 'resolved', recovery_time = ?, duration_seconds = ? WHERE id = ?").run(recoveryTime, duration, fault.id) + this.reminderEngine.cleanupForTicket(fault.order_number) + logger.info(`Recovery detected: ${fault.order_number}`) + } else { + logger.error(`Recovery push failed, will retry next tick: ${fault.order_number}`) + } } } }