feat: 统一结单推送 + close_time 口径 + 微信推送独立页
- 结单推送从 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
This commit is contained in:
parent
7ea37afc75
commit
629924c342
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Card } from '@/components/ui'
|
import { Card } from '@/components/ui'
|
||||||
import { Plus, Trash2, ChevronDown, ChevronUp, Search, RefreshCw } from 'lucide-react'
|
import { Search, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
interface MonitorStatus {
|
interface MonitorStatus {
|
||||||
enabled: boolean
|
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 {
|
interface MonitorConfig {
|
||||||
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
|
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 }
|
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 } }
|
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 {
|
interface ScanState {
|
||||||
|
|
@ -62,11 +48,7 @@ interface ScanState {
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor'
|
type SectionKey = 'mail' | 'filter' | 'monitor'
|
||||||
|
|
||||||
function generateId() {
|
|
||||||
return 'wh_' + Math.random().toString(36).slice(2, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MonitorSettingsPage() {
|
export default function MonitorSettingsPage() {
|
||||||
const [status, setStatus] = useState<MonitorStatus | null>(null)
|
const [status, setStatus] = useState<MonitorStatus | null>(null)
|
||||||
|
|
@ -78,9 +60,6 @@ export default function MonitorSettingsPage() {
|
||||||
const [newKeyword, setNewKeyword] = useState('')
|
const [newKeyword, setNewKeyword] = useState('')
|
||||||
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
|
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
|
||||||
const [sectionMessage, setSectionMessage] = useState<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
|
const [sectionMessage, setSectionMessage] = useState<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
|
||||||
const [expandedWebhooks, setExpandedWebhooks] = useState<Set<string>>(new Set())
|
|
||||||
const [testingWebhook, setTestingWebhook] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// 立即检查冷却状态
|
// 立即检查冷却状态
|
||||||
const [triggerCooldown, setTriggerCooldown] = useState(0)
|
const [triggerCooldown, setTriggerCooldown] = useState(0)
|
||||||
const cooldownTimerRef = useRef<NodeJS.Timeout | null>(null)
|
const cooldownTimerRef = useRef<NodeJS.Timeout | null>(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 } }))
|
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) => {
|
const handleSectionSave = async (section: SectionKey) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
setSaving(section)
|
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) } })
|
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<WebhookConfig>) => {
|
|
||||||
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<WebhookConfig['params']>) => {
|
|
||||||
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 {
|
function hasSectionChanged(original: MonitorConfig | null, current: MonitorConfig | null, section: SectionKey): boolean {
|
||||||
if (!original || !current) return false
|
if (!original || !current) return false
|
||||||
return JSON.stringify(original[section]) !== JSON.stringify(current[section])
|
return JSON.stringify(original[section]) !== JSON.stringify(current[section])
|
||||||
|
|
@ -945,121 +864,6 @@ export default function MonitorSettingsPage() {
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 微信推送 - 多 Webhook */}
|
|
||||||
<Card className="p-5">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">微信推送</h2>
|
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">每个推送组可独立启用/禁用,并配置独立的运行参数</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={addWebhook} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm font-medium">
|
|
||||||
<Plus size={14} />添加推送组
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sectionMessage.wechat && (
|
|
||||||
<div className={`mb-4 p-3 rounded-lg text-sm ${sectionMessage.wechat.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
|
|
||||||
{sectionMessage.wechat.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{config.wechat.webhooks.map((wh, index) => (
|
|
||||||
<div key={wh.id} className={`border rounded-lg transition-colors ${wh.enabled ? 'border-slate-200 dark:border-slate-700' : 'border-slate-200 dark:border-slate-700 opacity-60'}`}>
|
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleWebhookExpand(wh.id)}
|
|
||||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
|
||||||
>
|
|
||||||
{expandedWebhooks.has(wh.id) ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
||||||
</button>
|
|
||||||
<span className="text-sm font-medium text-slate-500 dark:text-slate-400 w-8">#{index + 1}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wh.name}
|
|
||||||
onChange={e => 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="推送组名称"
|
|
||||||
/>
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={wh.enabled}
|
|
||||||
onChange={e => updateWebhook(wh.id, { enabled: e.target.checked })}
|
|
||||||
className="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div className="w-9 h-5 bg-slate-200 peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-slate-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-slate-600 peer-checked:bg-blue-500"></div>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={() => handleTestWebhook(wh.id)}
|
|
||||||
disabled={!wh.url || testingWebhook === wh.id}
|
|
||||||
className="px-3 py-1 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-xs disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{testingWebhook === wh.id ? '测试中...' : '测试'}
|
|
||||||
</button>
|
|
||||||
{config.wechat.webhooks.length > 1 && (
|
|
||||||
<button onClick={() => removeWebhook(wh.id)} className="p-1 text-slate-400 hover:text-red-500 rounded" title="删除">
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expandedWebhooks.has(wh.id) && (
|
|
||||||
<div className="px-4 pb-4 space-y-4 border-t border-slate-100 dark:border-slate-700/50 pt-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Webhook 地址</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={wh.url}
|
|
||||||
onChange={e => 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=..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">运行参数</label>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-slate-500 mb-1">检查间隔(秒)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={wh.params.interval_seconds}
|
|
||||||
onChange={e => 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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-slate-500 mb-1">静默期开始(时)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={wh.params.silent_start_hour}
|
|
||||||
onChange={e => 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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-slate-500 mb-1">静默期结束(时)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={wh.params.silent_end_hour}
|
|
||||||
onChange={e => 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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionSaveButton section="wechat" />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 全局运行参数 */}
|
{/* 全局运行参数 */}
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<h2 className="text-lg font-semibold mb-4">全局运行参数</h2>
|
<h2 className="text-lg font-semibold mb-4">全局运行参数</h2>
|
||||||
|
|
|
||||||
|
|
@ -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<Set<string>>(new Set())
|
||||||
|
const [testingWebhook, setTestingWebhook] = useState<string | null>(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<WebhookConfig>) => {
|
||||||
|
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<WebhookConfig['params']>) => {
|
||||||
|
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 <div className="p-6 text-slate-500 dark:text-slate-400">加载中...</div>
|
||||||
|
if (!config) return <div className="p-6 text-slate-500 dark:text-slate-400">加载失败</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">微信推送配置</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">管理企业微信 Webhook 推送,每个推送组可独立启用/禁用并配置运行参数</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 全局消息提示 */}
|
||||||
|
{message && (
|
||||||
|
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
|
||||||
|
{message.text}
|
||||||
|
<button onClick={() => setMessage(null)} className="float-right font-bold">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Webhook 列表 */}
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">推送组</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">每个推送组可独立启用/禁用,并配置独立的运行参数</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={addWebhook}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Plus size={14} />添加推送组
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{config.wechat.webhooks.map((wh, index) => (
|
||||||
|
<div
|
||||||
|
key={wh.id}
|
||||||
|
className={`border rounded-lg transition-colors ${wh.enabled ? 'border-slate-200 dark:border-slate-700' : 'border-slate-200 dark:border-slate-700 opacity-60'}`}
|
||||||
|
>
|
||||||
|
{/* 主行:序号 + 名称 + 启停 + 测试 + 删除 */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleWebhookExpand(wh.id)}
|
||||||
|
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
{expandedWebhooks.has(wh.id) ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-medium text-slate-500 dark:text-slate-400 w-8">#{index + 1}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={wh.name}
|
||||||
|
onChange={e => 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 */}
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wh.enabled}
|
||||||
|
onChange={e => updateWebhook(wh.id, { enabled: e.target.checked })}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-9 h-5 bg-slate-200 peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-slate-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-slate-600 peer-checked:bg-blue-500"></div>
|
||||||
|
</label>
|
||||||
|
{/* 测试按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleTestWebhook(wh.id)}
|
||||||
|
disabled={!wh.url || testingWebhook === wh.id}
|
||||||
|
className="px-3 py-1 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-xs disabled:opacity-50 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
{testingWebhook === wh.id ? '测试中...' : '测试'}
|
||||||
|
</button>
|
||||||
|
{/* 删除按钮(至少保留 1 个) */}
|
||||||
|
{config.wechat.webhooks.length > 1 && (
|
||||||
|
<button onClick={() => removeWebhook(wh.id)} className="p-1 text-slate-400 hover:text-red-500 rounded" title="删除">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开的高级参数 */}
|
||||||
|
{expandedWebhooks.has(wh.id) && (
|
||||||
|
<div className="px-4 pb-4 space-y-4 border-t border-slate-100 dark:border-slate-700/50 pt-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">Webhook 地址</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={wh.url}
|
||||||
|
onChange={e => 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=..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">运行参数</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">检查间隔(秒)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={wh.params.interval_seconds}
|
||||||
|
onChange={e => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">推送延迟(毫秒)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={wh.params.push_delay_ms}
|
||||||
|
onChange={e => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">静默期开始(时)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={wh.params.silent_start_hour}
|
||||||
|
onChange={e => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">静默期结束(时)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={wh.params.silent_end_hour}
|
||||||
|
onChange={e => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 保存按钮 */}
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{saving ? '保存中...' : '保存设置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getAssetByIp } from '@/lib/assets-client'
|
import { getAssetByIp } from '@/lib/assets-client'
|
||||||
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
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 }> }) {
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
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 })
|
return NextResponse.json({ ticket })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '更新失败'
|
const msg = e instanceof Error ? e.message : '更新失败'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
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) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -25,6 +27,11 @@ export async function PUT(request: NextRequest) {
|
||||||
|
|
||||||
for (const item of updates) {
|
for (const item of updates) {
|
||||||
if (!item.id) continue
|
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 fields: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
|
|
||||||
|
|
@ -42,6 +49,17 @@ export async function PUT(request: NextRequest) {
|
||||||
values.push(item.id)
|
values.push(item.id)
|
||||||
|
|
||||||
const result = db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
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
|
updated += result.changes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
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 = [
|
const navItems = [
|
||||||
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null },
|
{ 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/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' },
|
||||||
{ href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' },
|
{ href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' },
|
||||||
{ href: '/settings/monitor', label: '邮件监控', icon: Mail, perm: 'monitor:read' },
|
{ href: '/settings/monitor', label: '邮件监控', icon: Mail, perm: 'monitor:read' },
|
||||||
|
{ href: '/settings/wechat', label: '微信推送', icon: Bell, perm: 'monitor:read' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function hasAnyAdminPerm(permissions: string[]): boolean {
|
function hasAnyAdminPerm(permissions: string[]): boolean {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { getMonitorConfig } from './settings-manager'
|
||||||
import { WeChatPusher } from './wechat-pusher'
|
import { WeChatPusher } from './wechat-pusher'
|
||||||
import { AvailabilityEngine } from './availability-engine'
|
import { AvailabilityEngine } from './availability-engine'
|
||||||
import { getRackPosition } from '@/lib/assets-client'
|
import { getRackPosition } from '@/lib/assets-client'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
import { formatBeijingTime } from './types'
|
import { formatBeijingTime } from './types'
|
||||||
|
|
||||||
export interface TicketNotifyInput {
|
export interface TicketNotifyInput {
|
||||||
|
|
@ -44,6 +45,83 @@ function formatBasicMessage(t: TicketNotifyInput): string {
|
||||||
return msg.trim()
|
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<string | null> {
|
async function getRackPositionSafe(ip: string | null, sn: string | null): Promise<string | null> {
|
||||||
// 注:assets-client.getRackPosition 内部有 encodeURIComponent,但【无】超时。
|
// 注:assets-client.getRackPosition 内部有 encodeURIComponent,但【无】超时。
|
||||||
// 这里用 Promise.race 加 5s 超时兜底(assets 不可达时 fetch 会挂到 Node 默认 TCP 超时 30-120s)。
|
// 这里用 Promise.race 加 5s 超时兜底(assets 不可达时 fetch 会挂到 Node 默认 TCP 超时 30-120s)。
|
||||||
|
|
@ -139,3 +217,54 @@ export async function notifyBatchSummary(ticketNos: string[]): Promise<void> {
|
||||||
console.error(`[Notifier] notifyBatchSummary 失败: ${e instanceof Error ? e.message : e}`)
|
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<void> {
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,7 @@ export class BackgroundWorker {
|
||||||
}
|
}
|
||||||
this.lastTickTime = Date.now()
|
this.lastTickTime = Date.now()
|
||||||
|
|
||||||
// Step 3: 检查恢复
|
// Step 3: 7 点 flush
|
||||||
await this.checkRecoveryUpdates(config)
|
|
||||||
|
|
||||||
// Step 4: 7 点 flush
|
|
||||||
if (this.reminderEngine.isTimeToFlush()) {
|
if (this.reminderEngine.isTimeToFlush()) {
|
||||||
await this.reminderEngine.flushPendingMessages(
|
await this.reminderEngine.flushPendingMessages(
|
||||||
(text) => this.pushToAllWebhooks(text, config),
|
(text) => this.pushToAllWebhooks(text, config),
|
||||||
|
|
@ -99,19 +96,19 @@ export class BackgroundWorker {
|
||||||
this.reminderEngine.markFlushed()
|
this.reminderEngine.markFlushed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5-6: 检查提醒
|
// Step 4-5: 检查提醒
|
||||||
const pushText = (text: string) => this.pushToAllWebhooks(text, config)
|
const pushText = (text: string) => this.pushToAllWebhooks(text, config)
|
||||||
const getRack = (ip: string | null, sn: string | null) => this.ticketProcessor.getRackPosition(ip, sn)
|
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.checkOemDiagReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
||||||
await this.reminderEngine.checkOemRepairReminders(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()) {
|
if (!this.mailMonitor.isConnected()) {
|
||||||
try { await this.mailMonitor.connect(config.mail) }
|
try { await this.mailMonitor.connect(config.mail) }
|
||||||
catch (e) { await this.mailMonitor.reconnect(config.mail) }
|
catch (e) { await this.mailMonitor.reconnect(config.mail) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8-9: 搜索并处理邮件
|
// Step 7-8: 搜索并处理邮件
|
||||||
const emails = await this.mailMonitor.fetchUnread(config)
|
const emails = await this.mailMonitor.fetchUnread(config)
|
||||||
let processed = 0, errors = 0
|
let processed = 0, errors = 0
|
||||||
|
|
||||||
|
|
@ -216,82 +213,4 @@ 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; 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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,22 +205,19 @@ export async function collectMonthlyReportData(
|
||||||
const storageFaults = storageFaultTickets.map(toFaultEntry)
|
const storageFaults = storageFaultTickets.map(toFaultEntry)
|
||||||
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
|
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
|
||||||
|
|
||||||
// 7. 第四章:服务可用性说明(已结单工单按实际时长,进行中工单按本月部分)
|
// 7. 第四章:服务可用性说明(进行中工单暂不计入,完整时长归入结单月后统计)
|
||||||
const ipDurationMap = new Map<string, number>()
|
const ipDurationMap = new Map<string, number>()
|
||||||
const ipHasOngoing = new Map<string, boolean>()
|
const ipHasOngoing = new Map<string, boolean>()
|
||||||
for (const t of tickets) {
|
for (const t of tickets) {
|
||||||
if (t.fault_category === '无故障') continue
|
if (t.fault_category === '无故障') continue
|
||||||
const dur = ipDurationMap.get(t.device_ip) || 0
|
const dur = ipDurationMap.get(t.device_ip) || 0
|
||||||
if (t.isOngoing) {
|
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)
|
ipHasOngoing.set(t.device_ip, true)
|
||||||
} else {
|
continue
|
||||||
ipDurationMap.set(t.device_ip, dur + t.duration_minutes)
|
|
||||||
}
|
}
|
||||||
|
// 已结单工单:用完整 duration_minutes 归入结单月
|
||||||
|
ipDurationMap.set(t.device_ip, dur + t.duration_minutes)
|
||||||
}
|
}
|
||||||
const chapter4: Chapter4Entry[] = []
|
const chapter4: Chapter4Entry[] = []
|
||||||
for (const [ip, totalDuration] of ipDurationMap) {
|
for (const [ip, totalDuration] of ipDurationMap) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue