feat: 企业微信推送消息格式全面对齐 zabbix-01 脚本

- P1-P5: 结单消息按 fault_type 分支,补齐机架位置/故障类型/故障时间/月度统计
- P6: OEM 维修档位标题补全「基于月度内累计故障时长计算」
- P7: OEM 诊断字段顺序调整(到期时间在故障信息之前)
- P8: 每小时提醒显示「X小时Y分」
- P9/P10: 静默期积压消息格式对齐(动态剩余时间、机架位置、30min 区分)
- 修复: isInSilentPeriod/isTimeToFlush 时区问题(UTC+8 偏移)
- 修复: 先推送后更新 DB,防止推送失败通知丢失
- 修复: faultDetail 截断 500 字符防止超限
- 修复: tierPenaltyMap 提取为类级常量
This commit is contained in:
gitadmin 2026-07-02 16:45:43 +08:00
parent d39d2e65b3
commit 02918fa907
3 changed files with 129 additions and 29 deletions

View File

@ -10,15 +10,20 @@ const logger = {
export class ReminderEngine {
private db = getDb()
private static readonly TIER_PENALTY_MAP: Record<string, string> = { '第一档': '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<boolean>): Promise<void> {
async flushPendingMessages(pushText: (text: string) => Promise<boolean>, getRack?: (ip: string | null, sn: string | null) => Promise<string | null>): Promise<void> {
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<string, string> = { '第一档': '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<string, string> = { '第一档': '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<string, string> = { '第一档': '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`

View File

@ -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<string, string> = {
'第一档': '99%(含)以上(不扣费)',
'第二档': '99%-97%扣10%',

View File

@ -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<void> {
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}`)
}
}
}
}