issue-ai/src/lib/monitor/worker.ts

274 lines
13 KiB
TypeScript
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.

// src/lib/monitor/worker.ts
import { getDb } from '@/lib/db'
import cron from 'node-cron'
import { MailMonitor } from './mail-monitor'
import { TicketProcessor } from './ticket-processor'
import { WeChatPusher } from './wechat-pusher'
import { AvailabilityEngine } from './availability-engine'
import { ReminderEngine } from './reminder-engine'
import { getMonitorConfig, getSetting, setSetting } from './settings-manager'
import type { MonitorConfig } from './types'
import { formatBeijingTime } from './types'
const logger = {
info: (msg: string) => console.log(`[Worker] ${formatBeijingTime()} INFO ${msg}`),
error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`),
}
export class BackgroundWorker {
private mailMonitor = new MailMonitor()
private ticketProcessor = new TicketProcessor()
private wechatPusher = new WeChatPusher()
private availabilityEngine = new AvailabilityEngine()
private reminderEngine = new ReminderEngine()
private processingPromise: Promise<void> | null = null
cronJob: any = null
private lastTickTime = 0
private db = getDb()
start(): void {
if (this.cronJob) return
this.cronJob = cron.schedule('* * * * *', () => { this.tick() })
logger.info('Cron job started (every 60 seconds)')
}
stop(): void {
if (this.cronJob) {
this.cronJob.stop()
this.cronJob = null
}
this.mailMonitor.disconnect()
logger.info('Worker stopped')
}
async tick(): Promise<void> {
if (this.processingPromise) return
this.processingPromise = this.doTick()
try { await this.processingPromise }
finally { this.processingPromise = null }
}
private async doTick(): Promise<void> {
const startTime = Date.now()
try {
const config = getMonitorConfig()
if (!config.monitor.enabled) return
// 检查是否被 API 手动触发
const triggerRequested = getSetting('monitor.trigger_requested') === 'true'
if (triggerRequested) {
setSetting('monitor.trigger_requested', 'false', 'monitor')
// 立即执行,忽略 interval_seconds
} else if (Date.now() - this.lastTickTime < config.monitor.interval_seconds * 1000) {
return
}
this.lastTickTime = Date.now()
// Step 3: 检查恢复
await this.checkRecoveryUpdates(config)
// Step 4: 7 点 flush
if (this.reminderEngine.isTimeToFlush()) {
await this.reminderEngine.flushPendingMessages(
(text) => this.wechatPusher.pushText(text, config.wechat.webhook_url),
(ip, sn) => this.ticketProcessor.getRackPosition(ip, sn)
)
this.reminderEngine.markFlushed()
}
// Step 5-6: 检查提醒
const pushText = (text: string) => this.wechatPusher.pushText(text, config.wechat.webhook_url)
const getRack = (ip: string | null, sn: string | null) => this.ticketProcessor.getRackPosition(ip, sn)
await this.reminderEngine.checkOemDiagReminders(pushText, config.monitor.push_delay_ms, getRack, config)
await this.reminderEngine.checkOemRepairReminders(pushText, config.monitor.push_delay_ms, getRack, config)
// Step 7: IMAP 连接
if (!this.mailMonitor.isConnected()) {
try { await this.mailMonitor.connect(config.mail) }
catch (e) { await this.mailMonitor.reconnect(config.mail) }
}
// Step 8-9: 搜索并处理邮件
const emails = await this.mailMonitor.fetchUnread(config)
let processed = 0, errors = 0
for (const email of emails) {
try {
// 去重检查
const existing = this.db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(email.msg_id)
if (existing) continue
// 过滤
const { pass, type } = this.mailMonitor.filterEmail(email, config.filter)
if (!pass || !type) continue
// 提取表格和故障信息
const tableData = email.html ? this.mailMonitor.extractTable(email.html) : null
const faultInfo = email.html ? this.mailMonitor.extractFaultInfo(email.html, tableData) : null
if (!faultInfo?.order_number || !faultInfo?.fault_time) continue
// 创建工单
const content = faultInfo.fault_detail || ''
this.ticketProcessor.createTicketFromEmail(faultInfo, type, config, content)
// 获取机架位置
const rackPosition = await this.ticketProcessor.getRackPosition(faultInfo.server_ip, faultInfo.server_sn)
// 计算可用性
const monthKey = faultInfo.fault_time.slice(0, 7)
let deadlines: Record<string, { deadline: string; hours: number }> = {}
let oemDeadline: string | null = null
if (type === 'oem_repair') {
deadlines = this.availabilityEngine.calculateTierDeadlines(monthKey, faultInfo.server_sn!, faultInfo.fault_time)
} else {
oemDeadline = this.availabilityEngine.calculateOemDiagDeadline(monthKey, faultInfo.fault_time)
}
// 推送微信
const message = this.wechatPusher.formatAvailabilityMessage(deadlines, faultInfo, type === 'oem_diag', oemDeadline, faultInfo.order_number, rackPosition, faultInfo.fault_detail)
await this.wechatPusher.pushText(message, config.wechat.webhook_url)
// 记录故障
this.ticketProcessor.recordFault({
server_sn: faultInfo.server_sn!, server_ip: faultInfo.server_ip, order_number: faultInfo.order_number,
fault_type: type, fault_time: faultInfo.fault_time!, fault_detail: faultInfo.fault_detail, status: 'ongoing',
recovery_time: null, duration_seconds: null,
})
// 创建提醒
if (type === 'oem_diag' && oemDeadline) {
this.reminderEngine.createReminder({
server_sn: faultInfo.server_sn!, server_ip: faultInfo.server_ip, order_number: faultInfo.order_number,
reminder_type: 'oem_diag', deadline_time: oemDeadline, deadline_hours: null,
fault_time: faultInfo.fault_time, tier_name: null, notification_phase: 'none', notified_at: null,
})
} else if (type === 'oem_repair') {
for (const [tierName, tierData] of Object.entries(deadlines)) {
this.reminderEngine.createReminder({
server_sn: faultInfo.server_sn!, server_ip: faultInfo.server_ip, order_number: faultInfo.order_number,
reminder_type: 'oem_repair', deadline_time: tierData.deadline, deadline_hours: tierData.hours,
fault_time: faultInfo.fault_time, tier_name: tierName, notification_phase: 'none', notified_at: null,
})
}
}
// 标记已处理
this.db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(email.msg_id, email.subject)
processed++
} catch (e) {
errors++
logger.error(`Email error: ${e instanceof Error ? e.message : e}`)
}
}
// NOOP 保活
await this.mailMonitor.keepAlive()
// 日志清理(每天北京时间 3 点)
const nowBeijing = formatBeijingTime()
const hour = parseInt(nowBeijing.slice(11, 13))
const minute = parseInt(nowBeijing.slice(14, 16))
if (hour === 3 && minute === 0) {
this.db.prepare("DELETE FROM monitor_logs WHERE created_at < datetime('now', '+8 hours', '-90 days')").run()
this.db.prepare("DELETE FROM processed_emails WHERE processed_at < datetime('now', '+8 hours', '-90 days')").run()
this.db.prepare("DELETE FROM pending_messages WHERE created_at < datetime('now', '+8 hours', '-24 hours')").run()
}
// 记录 tick
const duration = Date.now() - startTime
this.db.prepare("INSERT INTO monitor_logs (action, details) VALUES (?, ?)").run(
'tick_complete', JSON.stringify({ processed, errors, duration, timestamp: formatBeijingTime() })
)
if (processed > 0) logger.info(`Tick complete: ${processed} processed, ${errors} errors, ${duration}ms`)
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e)
logger.error(`Tick error: ${errorMsg}`)
// 将错误写入数据库,便于状态 API 检测
try {
this.db.prepare("INSERT INTO monitor_logs (action, details) VALUES (?, ?)").run(
'error', JSON.stringify({ error: errorMsg, timestamp: formatBeijingTime() })
)
} catch { /* 忽略日志写入失败 */ }
}
}
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; 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)
// 获取机架位置
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}`)
}
}
}
}
}