From 629924c342315d294faf133baecb28e9233a5bc3 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Mon, 13 Jul 2026 15:46:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E7=BB=93=E5=8D=95?= =?UTF-8?q?=E6=8E=A8=E9=80=81=20+=20close=5Ftime=20=E5=8F=A3=E5=BE=84=20+?= =?UTF-8?q?=20=E5=BE=AE=E4=BF=A1=E6=8E=A8=E9=80=81=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 结单推送从 worker 轮询上移到 API 层(PUT [id]/batch 状态跳变触发) - 新增 notifyTicketResolved(结单)与 notifyTicketCreated(建单)并列构成完整通知层 - 移除 worker.checkRecoveryUpdates,副作用迁移至 resolution-side-effects - 月度统计改为 close_time 归组,修复月报第四章跨月重复计算 - 微信推送独立为 /settings/wechat 页面,前端与邮件监控完全解耦 - 方案: docs/superpowers/specs/2026-07-13-unified-resolution-notification-design.md - 计划: docs/superpowers/plans/2026-07-13-unified-resolution-notification-plan.md --- src/app/(app)/settings/monitor/page.tsx | 200 +------------- src/app/(app)/settings/wechat/page.tsx | 287 +++++++++++++++++++++ src/app/api/tickets/[id]/route.ts | 13 + src/app/api/tickets/batch/route.ts | 18 ++ src/components/layout/Sidebar.tsx | 3 +- src/lib/monitor/resolution-side-effects.ts | 34 +++ src/lib/monitor/ticket-notifier.ts | 129 +++++++++ src/lib/monitor/worker.ts | 89 +------ src/lib/monthly-report.ts | 13 +- 9 files changed, 494 insertions(+), 292 deletions(-) create mode 100644 src/app/(app)/settings/wechat/page.tsx create mode 100644 src/lib/monitor/resolution-side-effects.ts diff --git a/src/app/(app)/settings/monitor/page.tsx b/src/app/(app)/settings/monitor/page.tsx index 7cb2946..4ca152d 100644 --- a/src/app/(app)/settings/monitor/page.tsx +++ b/src/app/(app)/settings/monitor/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useRef } from 'react' import { Card } from '@/components/ui' -import { Plus, Trash2, ChevronDown, ChevronUp, Search, RefreshCw } from 'lucide-react' +import { Search, RefreshCw } from 'lucide-react' interface MonitorStatus { enabled: boolean @@ -17,24 +17,10 @@ interface MonitorStatus { } } -interface WebhookConfig { - id: string - name: string - url: string - enabled: boolean - params: { - interval_seconds: number - push_delay_ms: number - silent_start_hour: number - silent_end_hour: number - } -} - interface MonitorConfig { monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number } mail: { address: string; imap_server: string; imap_port: number; pop3_server: string; pop3_port: number; smtp_server: string; smtp_port: number; password: string } filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } } - wechat: { webhooks: WebhookConfig[] } } interface ScanState { @@ -62,11 +48,7 @@ interface ScanState { error?: string } -type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor' - -function generateId() { - return 'wh_' + Math.random().toString(36).slice(2, 10) -} +type SectionKey = 'mail' | 'filter' | 'monitor' export default function MonitorSettingsPage() { const [status, setStatus] = useState(null) @@ -78,9 +60,6 @@ export default function MonitorSettingsPage() { const [newKeyword, setNewKeyword] = useState('') const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null) const [sectionMessage, setSectionMessage] = useState>>({}) - const [expandedWebhooks, setExpandedWebhooks] = useState>(new Set()) - const [testingWebhook, setTestingWebhook] = useState(null) - // 立即检查冷却状态 const [triggerCooldown, setTriggerCooldown] = useState(0) const cooldownTimerRef = useRef(null) @@ -373,25 +352,6 @@ export default function MonitorSettingsPage() { setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } })) } - const handleTestWebhook = async (webhookId: string) => { - if (!config) return - const wh = config.wechat.webhooks.find(w => w.id === webhookId) - if (!wh || !wh.url) return - setTestingWebhook(webhookId) - const res = await fetch('/api/monitor/test-wechat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ webhook_url: wh.url }), - }) - const data = await res.json() - setTestingWebhook(null) - setSectionMessage(prev => ({ - ...prev, - wechat: { type: data.success ? 'success' : 'error', text: data.success ? `「${wh.name}」测试成功` : `「${wh.name}」${data.error}` }, - })) - setTimeout(() => setSectionMessage(prev => ({ ...prev, wechat: undefined })), 5000) - } - const handleSectionSave = async (section: SectionKey) => { if (!config) return setSaving(section) @@ -420,47 +380,6 @@ export default function MonitorSettingsPage() { setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } }) } - const addWebhook = () => { - if (!config) return - const newWh: WebhookConfig = { - id: generateId(), - name: `推送 ${config.wechat.webhooks.length + 1}`, - url: '', - enabled: true, - params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 }, - } - const webhooks = [...config.wechat.webhooks, newWh] - setConfig({ ...config, wechat: { ...config.wechat, webhooks } }) - setExpandedWebhooks(prev => new Set(prev).add(newWh.id)) - } - - const removeWebhook = (id: string) => { - if (!config) return - if (config.wechat.webhooks.length <= 1) return - setConfig({ ...config, wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } }) - } - - const updateWebhook = (id: string, updates: Partial) => { - if (!config) return - const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, ...updates } : w) - setConfig({ ...config, wechat: { ...config.wechat, webhooks } }) - } - - const updateWebhookParams = (id: string, paramUpdates: Partial) => { - if (!config) return - const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, params: { ...w.params, ...paramUpdates } } : w) - setConfig({ ...config, wechat: { ...config.wechat, webhooks } }) - } - - const toggleWebhookExpand = (id: string) => { - setExpandedWebhooks(prev => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - } - function hasSectionChanged(original: MonitorConfig | null, current: MonitorConfig | null, section: SectionKey): boolean { if (!original || !current) return false return JSON.stringify(original[section]) !== JSON.stringify(current[section]) @@ -945,121 +864,6 @@ export default function MonitorSettingsPage() { )} - {/* 微信推送 - 多 Webhook */} - -
-
-

微信推送

-

每个推送组可独立启用/禁用,并配置独立的运行参数

-
- -
- - {sectionMessage.wechat && ( -
- {sectionMessage.wechat.text} -
- )} - -
- {config.wechat.webhooks.map((wh, index) => ( -
-
- - #{index + 1} - updateWebhook(wh.id, { name: e.target.value })} - className="flex-1 px-2 py-1 text-sm border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-400 rounded text-slate-900 dark:text-slate-100 font-medium" - placeholder="推送组名称" - /> - - - {config.wechat.webhooks.length > 1 && ( - - )} -
- - {expandedWebhooks.has(wh.id) && ( -
-
- - updateWebhook(wh.id, { url: e.target.value })} - className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm" - placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." - /> -
-
- -
-
- - updateWebhookParams(wh.id, { interval_seconds: parseInt(e.target.value) || 60 })} - className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm" - min={10} max={3600} - /> -
-
- - updateWebhookParams(wh.id, { silent_start_hour: parseInt(e.target.value) || 0 })} - className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm" - min={0} max={23} - /> -
-
- - updateWebhookParams(wh.id, { silent_end_hour: parseInt(e.target.value) || 7 })} - className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm" - min={0} max={23} - /> -
-
-
-
- )} -
- ))} -
- - -
- {/* 全局运行参数 */}

全局运行参数

diff --git a/src/app/(app)/settings/wechat/page.tsx b/src/app/(app)/settings/wechat/page.tsx new file mode 100644 index 0000000..8414548 --- /dev/null +++ b/src/app/(app)/settings/wechat/page.tsx @@ -0,0 +1,287 @@ +'use client' +import { useState, useEffect } from 'react' +import { Card } from '@/components/ui' +import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react' + +interface WebhookConfig { + id: string + name: string + url: string + enabled: boolean + params: { + interval_seconds: number + push_delay_ms: number + silent_start_hour: number + silent_end_hour: number + } +} + +function generateId() { + return 'wh_' + Math.random().toString(36).slice(2, 10) +} + +export default function WechatSettingsPage() { + const [config, setConfig] = useState<{ wechat: { webhooks: WebhookConfig[] } } | null>(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + const [expandedWebhooks, setExpandedWebhooks] = useState>(new Set()) + const [testingWebhook, setTestingWebhook] = useState(null) + + // 加载配置 + useEffect(() => { + fetch('/api/monitor/settings') + .then(r => { + if (!r.ok) throw new Error('加载失败') + return r.json() + }) + .then(data => setConfig({ wechat: data.wechat })) + .catch(err => setMessage({ type: 'error', text: '加载配置失败: ' + err.message })) + .finally(() => setLoading(false)) + }, []) + + // 保存 + const handleSave = async () => { + if (!config) return + setSaving(true) + setMessage(null) + try { + const res = await fetch('/api/monitor/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wechat: config.wechat }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || '保存失败') + setMessage({ type: 'success', text: '保存成功' }) + setTimeout(() => setMessage(null), 3000) + } catch (err) { + setMessage({ type: 'error', text: '保存失败: ' + (err instanceof Error ? err.message : String(err)) }) + } finally { + setSaving(false) + } + } + + // 测试 webhook + const handleTestWebhook = async (webhookId: string) => { + if (!config) return + const wh = config.wechat.webhooks.find(w => w.id === webhookId) + if (!wh || !wh.url) return + setTestingWebhook(webhookId) + const res = await fetch('/api/monitor/test-wechat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ webhook_url: wh.url }), + }) + const data = await res.json() + setTestingWebhook(null) + setMessage({ type: data.success ? 'success' : 'error', text: data.success ? `「${wh.name}」测试成功` : `「${wh.name}」${data.error}` }) + setTimeout(() => setMessage(null), 5000) + } + + // 新增 webhook + const addWebhook = () => { + if (!config) return + const newWh: WebhookConfig = { + id: generateId(), + name: `推送 ${config.wechat.webhooks.length + 1}`, + url: '', + enabled: true, + params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 }, + } + const webhooks = [...config.wechat.webhooks, newWh] + setConfig({ wechat: { ...config.wechat, webhooks } }) + setExpandedWebhooks(prev => new Set(prev).add(newWh.id)) + } + + // 删除 webhook(至少保留 1 个) + const removeWebhook = (id: string) => { + if (!config) return + if (config.wechat.webhooks.length <= 1) return + setConfig({ wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } }) + } + + // 更新 webhook 基本字段 + const updateWebhook = (id: string, updates: Partial) => { + if (!config) return + const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, ...updates } : w) + setConfig({ wechat: { ...config.wechat, webhooks } }) + } + + // 更新 webhook 高级参数 + const updateWebhookParams = (id: string, paramUpdates: Partial) => { + if (!config) return + const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, params: { ...w.params, ...paramUpdates } } : w) + setConfig({ wechat: { ...config.wechat, webhooks } }) + } + + // 展开/折叠 webhook + const toggleWebhookExpand = (id: string) => { + setExpandedWebhooks(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + if (loading) return
加载中...
+ if (!config) return
加载失败
+ + return ( +
+ {/* 页面标题 */} +
+

微信推送配置

+

管理企业微信 Webhook 推送,每个推送组可独立启用/禁用并配置运行参数

+
+ + {/* 全局消息提示 */} + {message && ( +
+ {message.text} + +
+ )} + + {/* Webhook 列表 */} + +
+
+

推送组

+

每个推送组可独立启用/禁用,并配置独立的运行参数

+
+ +
+ +
+ {config.wechat.webhooks.map((wh, index) => ( +
+ {/* 主行:序号 + 名称 + 启停 + 测试 + 删除 */} +
+ + #{index + 1} + updateWebhook(wh.id, { name: e.target.value })} + className="flex-1 px-2 py-1 text-sm border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-400 rounded text-slate-900 dark:text-slate-100 font-medium" + placeholder="推送组名称" + /> + {/* 启用/停用 toggle */} + + {/* 测试按钮 */} + + {/* 删除按钮(至少保留 1 个) */} + {config.wechat.webhooks.length > 1 && ( + + )} +
+ + {/* 展开的高级参数 */} + {expandedWebhooks.has(wh.id) && ( +
+
+ + updateWebhook(wh.id, { url: e.target.value })} + className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500" + placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." + /> +
+
+ +
+
+ + updateWebhookParams(wh.id, { interval_seconds: parseInt(e.target.value) || 60 })} + className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500" + min={10} max={3600} + /> +
+
+ + updateWebhookParams(wh.id, { push_delay_ms: parseInt(e.target.value) || 2000 })} + className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500" + min={0} max={30000} + /> +
+
+ + updateWebhookParams(wh.id, { silent_start_hour: parseInt(e.target.value) || 0 })} + className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500" + min={0} max={23} + /> +
+
+ + updateWebhookParams(wh.id, { silent_end_hour: parseInt(e.target.value) || 7 })} + className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500" + min={0} max={23} + /> +
+
+
+
+ )} +
+ ))} +
+ + {/* 保存按钮 */} +
+ +
+
+
+ ) +} diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts index 9254264..fce45c5 100644 --- a/src/app/api/tickets/[id]/route.ts +++ b/src/app/api/tickets/[id]/route.ts @@ -5,6 +5,8 @@ import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' import { getAssetByIp } from '@/lib/assets-client' import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit' +import { notifyTicketResolved } from '@/lib/monitor/ticket-notifier' +import { applyResolutionSideEffects } from '@/lib/monitor/resolution-side-effects' export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { @@ -122,6 +124,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ }) } + // 结单推送:状态从非终态 → resolved/closed 时触发 + const wasTerminal = ['resolved', 'closed'].includes(existing.current_status as string) + const nowTerminal = body.current_status && ['resolved', 'closed'].includes(body.current_status) + if (!wasTerminal && nowTerminal) { + const ticketId = Number(id) + applyResolutionSideEffects(ticketId) // 同步:回写 fault_records + 清理 reminder + void notifyTicketResolved(ticketId).catch(e => + console.error(`[API] 结单推送失败 (ticket ${ticketId}):`, e) + ) + } + return NextResponse.json({ ticket }) } catch (e) { const msg = e instanceof Error ? e.message : '更新失败' diff --git a/src/app/api/tickets/batch/route.ts b/src/app/api/tickets/batch/route.ts index e623288..3a7bf47 100644 --- a/src/app/api/tickets/batch/route.ts +++ b/src/app/api/tickets/batch/route.ts @@ -4,6 +4,8 @@ import { initDatabase } from '@/lib/db-schema' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' import { writeAuditLog, getClientIP } from '@/lib/audit' +import { notifyTicketResolved } from '@/lib/monitor/ticket-notifier' +import { applyResolutionSideEffects } from '@/lib/monitor/resolution-side-effects' export async function PUT(request: NextRequest) { try { @@ -25,6 +27,11 @@ export async function PUT(request: NextRequest) { for (const item of updates) { if (!item.id) continue + + // 读取当前状态(用于状态跳变检测) + const existing = db.prepare('SELECT current_status FROM tickets WHERE id = ?').get(item.id) as { current_status: string } | undefined + if (!existing) continue + const fields: string[] = [] const values: unknown[] = [] @@ -42,6 +49,17 @@ export async function PUT(request: NextRequest) { values.push(item.id) const result = db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values) + + // 结单推送:状态从非终态 → resolved/closed 时触发 + const wasTerminal = ['resolved', 'closed'].includes(existing.current_status) + const nowTerminal = item.current_status && ['resolved', 'closed'].includes(item.current_status) + if (result.changes > 0 && !wasTerminal && nowTerminal) { + applyResolutionSideEffects(item.id) + void notifyTicketResolved(item.id).catch(e => + console.error(`[API:batch] 结单推送失败 (ticket ${item.id}):`, e) + ) + } + updated += result.changes } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index eaaf184..c715a28 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List, Mail } from 'lucide-react' +import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List, Mail, Bell } from 'lucide-react' const navItems = [ { href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null }, @@ -19,6 +19,7 @@ const settingsItems = [ { href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' }, { href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' }, { href: '/settings/monitor', label: '邮件监控', icon: Mail, perm: 'monitor:read' }, + { href: '/settings/wechat', label: '微信推送', icon: Bell, perm: 'monitor:read' }, ] function hasAnyAdminPerm(permissions: string[]): boolean { diff --git a/src/lib/monitor/resolution-side-effects.ts b/src/lib/monitor/resolution-side-effects.ts new file mode 100644 index 0000000..48f91ec --- /dev/null +++ b/src/lib/monitor/resolution-side-effects.ts @@ -0,0 +1,34 @@ +// src/lib/monitor/resolution-side-effects.ts +import { getDb } from '@/lib/db' + +/** + * 结单副作用:回写 fault_records + 清理 reminder_state。 + * 同步执行(better-sqlite3),对手动/外部/导入工单(无 fault_records)是安全 no-op。 + * 必须在 notifyTicketResolved 之前调用(写 DB 不依赖推送结果)。 + */ +export function applyResolutionSideEffects(ticketId: number): void { + const db = getDb() + const ticket = db.prepare(` + SELECT ticket_no, close_time, assign_time, device_sn + FROM tickets WHERE id = ? + `).get(ticketId) as { + ticket_no: string; close_time: string | null; assign_time: string; device_sn: string + } | undefined + if (!ticket || !ticket.close_time) return + + // 1. 回写 fault_records(若存在 ongoing 记录) + const fault = db.prepare( + "SELECT id FROM fault_records WHERE order_number = ? AND status = 'ongoing'" + ).get(ticket.ticket_no) as { id: number } | undefined + if (fault) { + const assignMs = new Date(ticket.assign_time.replace(' ', 'T') + '+08:00').getTime() + const closeMs = new Date(ticket.close_time.replace(' ', 'T') + '+08:00').getTime() + const durationSec = Math.floor((closeMs - assignMs) / 1000) + db.prepare( + "UPDATE fault_records SET status = 'resolved', recovery_time = ?, duration_seconds = ? WHERE id = ?" + ).run(ticket.close_time, durationSec, fault.id) + } + + // 2. 清理 reminder_state(无对应记录时是安全 no-op) + db.prepare("DELETE FROM reminder_state WHERE order_number = ?").run(ticket.ticket_no) +} diff --git a/src/lib/monitor/ticket-notifier.ts b/src/lib/monitor/ticket-notifier.ts index edec052..5435620 100644 --- a/src/lib/monitor/ticket-notifier.ts +++ b/src/lib/monitor/ticket-notifier.ts @@ -3,6 +3,7 @@ import { getMonitorConfig } from './settings-manager' import { WeChatPusher } from './wechat-pusher' import { AvailabilityEngine } from './availability-engine' import { getRackPosition } from '@/lib/assets-client' +import { getDb } from '@/lib/db' import { formatBeijingTime } from './types' export interface TicketNotifyInput { @@ -44,6 +45,83 @@ function formatBasicMessage(t: TicketNotifyInput): string { return msg.trim() } +/** 格式化处理时长为 "X小时Y分钟"(从分钟数转换) */ +function formatDuration(minutes: number): string { + const h = Math.floor(minutes / 60) + const m = minutes % 60 + return `${h}小时${m}分钟` +} + +/** 构建 OEM 诊断结单消息 */ +function buildOemDiagResolvedMessage(ticket: ResolvedTicketInfo, rack: string | null): string { + let msg = `✅ ${ticket.ticket_no}工单已结单\n\n` + msg += `服务器IP:${ticket.device_ip || '未知'}\n` + msg += `服务器SN:${ticket.device_sn || '未知'}\n` + if (rack) msg += `机架位置:${rack}\n` + msg += `故障类型:OEM诊断\n` + if (ticket.assign_time) msg += `故障时间:${ticket.assign_time}\n` + if (ticket.close_time) msg += `结单时间:${ticket.close_time}\n` + msg += `本次处理时长:${formatDuration(ticket.duration_minutes || 0)}` + return msg +} + +/** 构建 OEM 维修结单消息(月度统计按 close_time 月 + tickets 表 + isRealFault 过滤) */ +function buildOemRepairResolvedMessage(ticket: ResolvedTicketInfo, rack: string | null): string { + const db = getDb() + const closeMonth = ticket.close_time ? ticket.close_time.substring(0, 7) : null + + let monthCount = 0 + let monthTotalMin = 0 + if (closeMonth && ticket.device_sn) { + const countRow = db.prepare(` + SELECT COUNT(*) as cnt FROM tickets + WHERE device_sn = ? AND current_status IN ('resolved','closed') + AND fault_category IS NOT NULL AND fault_category NOT IN ('无故障','误判') + AND strftime('%Y-%m', close_time) = ? + `).get(ticket.device_sn, closeMonth) as { cnt: number } | undefined + monthCount = countRow?.cnt ?? 0 + + const durationRow = db.prepare(` + SELECT COALESCE(SUM(duration_minutes), 0) as total_min FROM tickets + WHERE device_sn = ? AND current_status IN ('resolved','closed') + AND fault_category IS NOT NULL AND fault_category NOT IN ('无故障','误判') + AND strftime('%Y-%m', close_time) = ? + `).get(ticket.device_sn, closeMonth) as { total_min: number } | undefined + monthTotalMin = durationRow?.total_min ?? 0 + } + + const thisDurationMin = ticket.duration_minutes || 0 + const totalMin = monthTotalMin + thisDurationMin + const totalHours = Math.floor(totalMin / 60) + const totalMinutes = Math.floor(totalMin % 60) + + let msg = `✅ ${ticket.ticket_no}工单已结单\n\n` + msg += `服务器IP:${ticket.device_ip || '未知'}\n` + msg += `服务器SN:${ticket.device_sn || '未知'}\n` + if (rack) msg += `机架位置:${rack}\n` + msg += `故障类型:OEM维修\n` + if (ticket.assign_time) msg += `故障时间:${ticket.assign_time}\n` + if (ticket.close_time) msg += `结单时间:${ticket.close_time}\n` + msg += `本次处理时长:${formatDuration(thisDurationMin)}\n` + msg += `本月故障次数:${monthCount + 1}次\n` + msg += `本月总处理时长:${totalHours}小时${totalMinutes}分钟` + return msg +} + +/** 构建基础结单消息(非 OEM 工单) */ +function buildBasicResolvedMessage(ticket: ResolvedTicketInfo, rack: string | null): string { + let msg = `✅ ${ticket.ticket_no}工单已结单\n\n` + msg += `服务器IP:${ticket.device_ip || '未知'}\n` + msg += `服务器SN:${ticket.device_sn || '未知'}\n` + if (ticket.device_name) msg += `设备名称:${trunc(ticket.device_name, 128)}\n` + if (rack) msg += `机架位置:${rack}\n` + if (ticket.fault_category) msg += `故障大类:${ticket.fault_category}\n` + if (ticket.assign_time) msg += `故障时间:${ticket.assign_time}\n` + if (ticket.close_time) msg += `结单时间:${ticket.close_time}\n` + msg += `本次处理时长:${formatDuration(ticket.duration_minutes || 0)}` + return msg +} + async function getRackPositionSafe(ip: string | null, sn: string | null): Promise { // 注:assets-client.getRackPosition 内部有 encodeURIComponent,但【无】超时。 // 这里用 Promise.race 加 5s 超时兜底(assets 不可达时 fetch 会挂到 Node 默认 TCP 超时 30-120s)。 @@ -139,3 +217,54 @@ export async function notifyBatchSummary(ticketNos: string[]): Promise { console.error(`[Notifier] notifyBatchSummary 失败: ${e instanceof Error ? e.message : e}`) } } + +interface ResolvedTicketInfo { + ticket_no: string + device_ip: string | null + device_sn: string | null + device_name: string | null + ticket_type: string | null + fault_category: string | null + assign_time: string | null + close_time: string | null + duration_minutes: number | null +} + +/** + * 结单/恢复推送。当工单状态从非终态变为 resolved/closed 时调用。 + * fire-and-forget,全程 try/catch。 + */ +export async function notifyTicketResolved(ticketId: number): Promise { + try { + const db = getDb() + const ticket = db.prepare(` + SELECT ticket_no, device_ip, device_sn, device_name, ticket_type, + fault_category, assign_time, close_time, duration_minutes + FROM tickets WHERE id = ? + `).get(ticketId) as ResolvedTicketInfo | undefined + if (!ticket) return + + // 只推送"真实故障"结单 + if (!ticket.fault_category + || ticket.fault_category === '无故障' + || ticket.fault_category === '误判') return + + const hooks = getEnabledHooks() + if (hooks.length === 0) return + + const rack = await getRackPositionSafe(ticket.device_ip, ticket.device_sn) + + let message: string + if (ticket.ticket_type === 'OEM诊断') { + message = buildOemDiagResolvedMessage(ticket, rack) + } else if (ticket.ticket_type === 'OEM维修') { + message = buildOemRepairResolvedMessage(ticket, rack) + } else { + message = buildBasicResolvedMessage(ticket, rack) + } + + await pushHooks(hooks, message) + } catch (e) { + console.error(`[Notifier] notifyTicketResolved 失败 (id=${ticketId}): ${e instanceof Error ? e.message : e}`) + } +} diff --git a/src/lib/monitor/worker.ts b/src/lib/monitor/worker.ts index 8f476ac..694b448 100644 --- a/src/lib/monitor/worker.ts +++ b/src/lib/monitor/worker.ts @@ -87,10 +87,7 @@ export class BackgroundWorker { } this.lastTickTime = Date.now() - // Step 3: 检查恢复 - await this.checkRecoveryUpdates(config) - - // Step 4: 7 点 flush + // Step 3: 7 点 flush if (this.reminderEngine.isTimeToFlush()) { await this.reminderEngine.flushPendingMessages( (text) => this.pushToAllWebhooks(text, config), @@ -99,19 +96,19 @@ export class BackgroundWorker { this.reminderEngine.markFlushed() } - // Step 5-6: 检查提醒 + // Step 4-5: 检查提醒 const pushText = (text: string) => this.pushToAllWebhooks(text, config) 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 连接 + // Step 6: IMAP 连接 if (!this.mailMonitor.isConnected()) { try { await this.mailMonitor.connect(config.mail) } catch (e) { await this.mailMonitor.reconnect(config.mail) } } - // Step 8-9: 搜索并处理邮件 + // Step 7-8: 搜索并处理邮件 const emails = await this.mailMonitor.fetchUnread(config) let processed = 0, errors = 0 @@ -216,82 +213,4 @@ 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; 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.pushToAllWebhooks(message, config) - 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}`) - } - } - } - } } diff --git a/src/lib/monthly-report.ts b/src/lib/monthly-report.ts index 1961e08..5a3724d 100644 --- a/src/lib/monthly-report.ts +++ b/src/lib/monthly-report.ts @@ -205,22 +205,19 @@ export async function collectMonthlyReportData( const storageFaults = storageFaultTickets.map(toFaultEntry) const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry) - // 7. 第四章:服务可用性说明(已结单工单按实际时长,进行中工单按本月部分) + // 7. 第四章:服务可用性说明(进行中工单暂不计入,完整时长归入结单月后统计) const ipDurationMap = new Map() const ipHasOngoing = new Map() for (const t of tickets) { if (t.fault_category === '无故障') continue const dur = ipDurationMap.get(t.device_ip) || 0 if (t.isOngoing) { - // 进行中工单:计算本月内部分(从 max(assign_date, periodStart) 到月末最后一天) - const assignDate = t.assign_time.slice(0, 10) - const effectiveStart = assignDate > periodStart ? assignDate : periodStart - const affectedDays = daysBetween(effectiveStart, periodEnd) + 1 - ipDurationMap.set(t.device_ip, dur + affectedDays * 24 * 60) + // 进行中工单:暂不计入可用性时长,完整时长归入结单月后统计 ipHasOngoing.set(t.device_ip, true) - } else { - ipDurationMap.set(t.device_ip, dur + t.duration_minutes) + continue } + // 已结单工单:用完整 duration_minutes 归入结单月 + ipDurationMap.set(t.device_ip, dur + t.duration_minutes) } const chapter4: Chapter4Entry[] = [] for (const [ip, totalDuration] of ipDurationMap) {