feat: 权限细化 tickets:edit/tickets:delete + 多 Webhook 推送组
- tickets:write 拆分为 tickets:edit(处理工单)和 tickets:delete(删除工单) - 内置 operator 角色默认同时拥有两个权限 - 迁移脚本自动将已有角色的 tickets:write 替换为两个新权限 - 微信推送支持多个 webhook 推送组,每个可独立启用/禁用 - 每个推送组包含独立的运行参数(检查间隔、静默期) - 旧格式 wechat.webhook_url 自动迁移为新数组格式 - worker 推送逻辑适配多 webhook(遍历所有启用的推送组)
This commit is contained in:
parent
38129bdb67
commit
f507adfaa1
|
|
@ -1,6 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Card } from '@/components/ui'
|
import { Card } from '@/components/ui'
|
||||||
|
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
interface MonitorStatus {
|
interface MonitorStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
@ -16,40 +17,30 @@ 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; smtp_server: string; smtp_port: number; password: string }
|
mail: { address: string; imap_server: string; imap_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: { webhook_url: string }
|
wechat: { webhooks: WebhookConfig[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor'
|
type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor'
|
||||||
|
|
||||||
// 深比较两个对象,返回变化的字段路径
|
function generateId() {
|
||||||
function getChangedFields(original: unknown, current: unknown, prefix = ''): string[] {
|
return 'wh_' + Math.random().toString(36).slice(2, 10)
|
||||||
const changes: string[] = []
|
|
||||||
if (original === current) return changes
|
|
||||||
if (typeof original !== typeof current) { changes.push(prefix); return changes }
|
|
||||||
if (typeof original !== 'object' || original === null || current === null) {
|
|
||||||
if (original !== current) changes.push(prefix)
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
const orig = original as Record<string, unknown>
|
|
||||||
const curr = current as Record<string, unknown>
|
|
||||||
const allKeys = new Set([...Object.keys(orig), ...Object.keys(curr)])
|
|
||||||
for (const key of allKeys) {
|
|
||||||
const path = prefix ? `${prefix}.${key}` : key
|
|
||||||
if (JSON.stringify(orig[key]) !== JSON.stringify(curr[key])) {
|
|
||||||
changes.push(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查某个 section 是否有变化
|
|
||||||
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])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MonitorSettingsPage() {
|
export default function MonitorSettingsPage() {
|
||||||
|
|
@ -62,6 +53,8 @@ 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)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
|
@ -110,11 +103,23 @@ 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 handleTestWechat = async () => {
|
const handleTestWebhook = async (webhookId: string) => {
|
||||||
setSectionMessage(prev => ({ ...prev, wechat: { type: 'success', text: '正在测试微信推送...' } }))
|
if (!config) return
|
||||||
const res = await fetch('/api/monitor/test-wechat', { method: 'POST' })
|
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()
|
const data = await res.json()
|
||||||
setSectionMessage(prev => ({ ...prev, wechat: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
|
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) => {
|
||||||
|
|
@ -145,18 +150,61 @@ 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) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取某个字段是否被修改
|
// --- Webhook 管理 ---
|
||||||
const isFieldChanged = (path: string): boolean => {
|
|
||||||
if (!originalConfig || !config) return false
|
const addWebhook = () => {
|
||||||
const changes = getChangedFields(originalConfig, config)
|
if (!config) return
|
||||||
return changes.some(c => c === path || c.startsWith(path + '.'))
|
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 } })
|
||||||
|
// 自动展开新增的 webhook
|
||||||
|
setExpandedWebhooks(prev => new Set(prev).add(newWh.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 输入框样式:高亮修改的字段
|
const removeWebhook = (id: string) => {
|
||||||
const inputClass = (path: string) =>
|
if (!config) return
|
||||||
`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${isFieldChanged(path) ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}`
|
if (config.wechat.webhooks.length <= 1) return // 至少保留一个
|
||||||
|
setConfig({ ...config, wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } })
|
||||||
|
}
|
||||||
|
|
||||||
// Section 保存按钮组件(消息反馈由各 section 自行渲染,此处只渲染按钮)
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查某个 section 是否有变化
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框样式
|
||||||
|
const inputClass = (path: string, changed = false) =>
|
||||||
|
`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${changed ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}`
|
||||||
|
|
||||||
|
// Section 保存按钮组件
|
||||||
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
||||||
const changed = hasSectionChanged(originalConfig, config, section)
|
const changed = hasSectionChanged(originalConfig, config, section)
|
||||||
if (!changed) return null
|
if (!changed) return null
|
||||||
|
|
@ -201,7 +249,7 @@ export default function MonitorSettingsPage() {
|
||||||
{status?.lastCheck && <span className="text-sm text-slate-500">上次检查: {status.lastCheck}</span>}
|
{status?.lastCheck && <span className="text-sm text-slate-500">上次检查: {status.lastCheck}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计指标 - 可点击展开 */}
|
{/* 统计指标 */}
|
||||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||||
{[
|
{[
|
||||||
{ key: 'today' as const, label: '今日处理', value: status?.stats.todayProcessed || 0, color: 'text-blue-600 dark:text-blue-400' },
|
{ key: 'today' as const, label: '今日处理', value: status?.stats.todayProcessed || 0, color: 'text-blue-600 dark:text-blue-400' },
|
||||||
|
|
@ -225,7 +273,6 @@ export default function MonitorSettingsPage() {
|
||||||
{/* 展开面板 */}
|
{/* 展开面板 */}
|
||||||
{expandedPanel && (
|
{expandedPanel && (
|
||||||
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||||
{/* 今日处理详情 */}
|
|
||||||
{expandedPanel === 'today' && status?.details?.todayProcessed && (
|
{expandedPanel === 'today' && status?.details?.todayProcessed && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|
@ -249,7 +296,6 @@ export default function MonitorSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 累计统计详情 */}
|
|
||||||
{expandedPanel === 'total' && (
|
{expandedPanel === 'total' && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|
@ -277,7 +323,6 @@ export default function MonitorSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 错误详情 */}
|
|
||||||
{expandedPanel === 'errors' && status?.details?.recentErrors && (
|
{expandedPanel === 'errors' && status?.details?.recentErrors && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|
@ -308,7 +353,6 @@ export default function MonitorSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 进行中故障详情 */}
|
|
||||||
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
|
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|
@ -408,7 +452,7 @@ export default function MonitorSettingsPage() {
|
||||||
<label className="block text-sm font-medium mb-1">主题关键词(匹配任一即通过)</label>
|
<label className="block text-sm font-medium mb-1">主题关键词(匹配任一即通过)</label>
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
{config.filter.subject_keywords.map((kw, i) => (
|
{config.filter.subject_keywords.map((kw, i) => (
|
||||||
<span key={i} className={`inline-flex items-center gap-1 px-2 py-1 rounded text-sm ${isFieldChanged('filter.subject_keywords') ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'}`}>
|
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
{kw}
|
{kw}
|
||||||
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
|
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -427,29 +471,127 @@ export default function MonitorSettingsPage() {
|
||||||
<SectionSaveButton section="filter" />
|
<SectionSaveButton section="filter" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 微信推送 */}
|
{/* 微信推送 - 多 Webhook */}
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<h2 className="text-lg font-semibold mb-4">微信推送</h2>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Webhook 地址</label>
|
<h2 className="text-lg font-semibold">微信推送</h2>
|
||||||
<input type="text" value={config.wechat.webhook_url} onChange={e => setConfig({ ...config, wechat: { ...config.wechat, webhook_url: e.target.value } })} className={inputClass('wechat.webhook_url')} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">每个推送组可独立启用/禁用,并配置独立的运行参数</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
||||||
<button onClick={handleTestWechat} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm">测试微信推送</button>
|
<Plus size={14} />添加推送组
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{sectionMessage.wechat && (
|
{sectionMessage.wechat && (
|
||||||
<span className={`text-sm ${sectionMessage.wechat.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
<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}
|
{sectionMessage.wechat.text}
|
||||||
</span>
|
</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>
|
</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>
|
||||||
|
<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" />
|
<SectionSaveButton section="wechat" />
|
||||||
</Card>
|
</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>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">未在推送组中单独配置的参数将使用以下全局默认值</p>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">检查间隔(秒)</label>
|
<label className="block text-sm font-medium mb-1">检查间隔(秒)</label>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ const allPermissions = [
|
||||||
{ key: 'tickets:create', label: '手动建单' },
|
{ key: 'tickets:create', label: '手动建单' },
|
||||||
{ key: 'tickets:import', label: '导入工单' },
|
{ key: 'tickets:import', label: '导入工单' },
|
||||||
{ key: 'tickets:export', label: '导出工单' },
|
{ key: 'tickets:export', label: '导出工单' },
|
||||||
{ key: 'tickets:write', label: '编辑/删除工单' },
|
{ key: 'tickets:edit', label: '处理工单' },
|
||||||
|
{ key: 'tickets:delete', label: '删除工单' },
|
||||||
{ key: 'reports:read', label: '查看报告' },
|
{ key: 'reports:read', label: '查看报告' },
|
||||||
{ key: 'reports:download', label: '下载报告' },
|
{ key: 'reports:download', label: '下载报告' },
|
||||||
{ key: 'reports:create', label: '新建报告' },
|
{ key: 'reports:create', label: '新建报告' },
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const VALIDATORS: Record<string, (v: unknown) => boolean> = {
|
||||||
'mail.address': (v) => typeof v === 'string' && v.includes('@'),
|
'mail.address': (v) => typeof v === 'string' && v.includes('@'),
|
||||||
'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||||
'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||||
'wechat.webhook_url': (v) => typeof v === 'string' && (v === '' || v.startsWith('https://qyapi.weixin.qq.com/')),
|
'wechat.webhooks': (v) => Array.isArray(v) && v.length > 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,23 @@ export async function POST(request: NextRequest) {
|
||||||
initDatabase()
|
initDatabase()
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
const config = getMonitorConfig()
|
|
||||||
if (!config.wechat.webhook_url) return NextResponse.json({ success: false, error: 'Webhook URL 未配置' })
|
let webhookUrl: string | null = null
|
||||||
try {
|
try {
|
||||||
const response = await fetch(config.wechat.webhook_url, {
|
const body = await request.json()
|
||||||
|
webhookUrl = body?.webhook_url || null
|
||||||
|
} catch { /* 无 body */ }
|
||||||
|
|
||||||
|
// 如果指定了 URL,使用指定的;否则使用第一个启用的
|
||||||
|
if (!webhookUrl) {
|
||||||
|
const config = getMonitorConfig()
|
||||||
|
const enabled = config.wechat.webhooks.filter(wh => wh.enabled && wh.url)
|
||||||
|
if (enabled.length === 0) return NextResponse.json({ success: false, error: 'Webhook URL 未配置' })
|
||||||
|
webhookUrl = enabled[0].url
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }),
|
body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }),
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||||
initDatabase()
|
initDatabase()
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
if (!hasPermission(user, 'tickets:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
@ -134,7 +134,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
||||||
initDatabase()
|
initDatabase()
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
if (!hasPermission(user, 'tickets:delete')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export async function PUT(request: NextRequest) {
|
||||||
initDatabase()
|
initDatabase()
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
if (!hasPermission(user, 'tickets:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const updates: Array<{ id: number; fault_category?: string; current_status?: string; counted_in_sla?: number }> = body.updates || []
|
const updates: Array<{ id: number; fault_category?: string; current_status?: string; counted_in_sla?: number }> = body.updates || []
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,20 @@ export function initDatabase(): void {
|
||||||
db.exec("CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)")
|
db.exec("CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)")
|
||||||
} catch { /* 索引已存在 */ }
|
} catch { /* 索引已存在 */ }
|
||||||
|
|
||||||
|
// 迁移:tickets:write 拆分为 tickets:edit + tickets:delete
|
||||||
|
try {
|
||||||
|
const roleRows = db.prepare('SELECT id, permissions FROM roles').all() as { id: number; permissions: string }[]
|
||||||
|
for (const row of roleRows) {
|
||||||
|
try {
|
||||||
|
const perms: string[] = JSON.parse(row.permissions)
|
||||||
|
if (perms.includes('tickets:write') && !perms.includes('tickets:edit')) {
|
||||||
|
const newPerms = perms.flatMap(p => p === 'tickets:write' ? ['tickets:edit', 'tickets:delete'] : [p])
|
||||||
|
db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(newPerms), row.id)
|
||||||
|
}
|
||||||
|
} catch { /* JSON 解析失败跳过 */ }
|
||||||
|
}
|
||||||
|
} catch { /* 迁移失败则保持原样 */ }
|
||||||
|
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
|
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
|
||||||
|
|
@ -72,7 +86,7 @@ export function initDatabase(): void {
|
||||||
}
|
}
|
||||||
const roles = [
|
const roles = [
|
||||||
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
||||||
{ name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:create","tickets:import","tickets:export","tickets:write","reports:read","reports:download","reports:create"]' },
|
{ name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:create","tickets:import","tickets:export","tickets:edit","tickets:delete","reports:read","reports:download","reports:create"]' },
|
||||||
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","tickets:export","reports:read","reports:download"]' },
|
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","tickets:export","reports:read","reports:download"]' },
|
||||||
]
|
]
|
||||||
for (const r of roles) {
|
for (const r of roles) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// src/lib/monitor/settings-manager.ts
|
// src/lib/monitor/settings-manager.ts
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import type { MonitorConfig } from './types'
|
import type { MonitorConfig, WebhookConfig } from './types'
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-cbc'
|
const ALGORITHM = 'aes-256-cbc'
|
||||||
|
|
||||||
|
|
@ -54,6 +54,14 @@ export function setSetting(key: string, value: string, category = 'general'): vo
|
||||||
).run(key, value, category)
|
).run(key, value, category)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WEBHOOK: WebhookConfig = {
|
||||||
|
id: 'default',
|
||||||
|
name: '默认推送',
|
||||||
|
url: '',
|
||||||
|
enabled: true,
|
||||||
|
params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: MonitorConfig = {
|
const DEFAULT_CONFIG: MonitorConfig = {
|
||||||
monitor: { enabled: false, interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
monitor: { enabled: false, interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
||||||
mail: { address: 'gxp@qx002575.com', imap_server: 'imaphz.qiye.163.com', imap_port: 993, smtp_server: 'smtphz.qiye.163.com', smtp_port: 465, password: '' },
|
mail: { address: 'gxp@qx002575.com', imap_server: 'imaphz.qiye.163.com', imap_port: 993, smtp_server: 'smtphz.qiye.163.com', smtp_port: 465, password: '' },
|
||||||
|
|
@ -63,11 +71,36 @@ const DEFAULT_CONFIG: MonitorConfig = {
|
||||||
oem_repair_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM维修', exclude_keyword: 'OEM诊断' },
|
oem_repair_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM维修', exclude_keyword: 'OEM诊断' },
|
||||||
oem_diag_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM诊断', exclude_keyword: 'OEM维修' },
|
oem_diag_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM诊断', exclude_keyword: 'OEM维修' },
|
||||||
},
|
},
|
||||||
wechat: { webhook_url: '' },
|
wechat: { webhooks: [JSON.parse(JSON.stringify(DEFAULT_WEBHOOK))] },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateWebhookId(): string {
|
||||||
|
return 'wh_' + crypto.randomBytes(8).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将旧的 wechat.webhook_url 迁移为 wechat.webhooks 数组
|
||||||
|
function migrateWebhookSettings(db: ReturnType<typeof getDb>): void {
|
||||||
|
const legacyUrl = db.prepare("SELECT value FROM settings WHERE key = 'wechat.webhook_url'").get() as { value: string } | undefined
|
||||||
|
if (legacyUrl?.value) {
|
||||||
|
const webhook: WebhookConfig = {
|
||||||
|
id: generateWebhookId(),
|
||||||
|
name: '默认推送',
|
||||||
|
url: legacyUrl.value,
|
||||||
|
enabled: true,
|
||||||
|
params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
||||||
|
}
|
||||||
|
setSetting('wechat.webhooks', JSON.stringify([webhook]), 'wechat')
|
||||||
|
db.prepare("DELETE FROM settings WHERE key = 'wechat.webhook_url'").run()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMonitorConfig(): MonitorConfig {
|
export function getMonitorConfig(): MonitorConfig {
|
||||||
const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG))
|
const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG))
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
// 迁移旧格式
|
||||||
|
migrateWebhookSettings(db)
|
||||||
|
|
||||||
const mappings: Record<string, (v: string) => void> = {
|
const mappings: Record<string, (v: string) => void> = {
|
||||||
'monitor.enabled': (v) => { config.monitor.enabled = v === 'true' },
|
'monitor.enabled': (v) => { config.monitor.enabled = v === 'true' },
|
||||||
'monitor.interval_seconds': (v) => { config.monitor.interval_seconds = parseInt(v) || 60 },
|
'monitor.interval_seconds': (v) => { config.monitor.interval_seconds = parseInt(v) || 60 },
|
||||||
|
|
@ -84,9 +117,13 @@ export function getMonitorConfig(): MonitorConfig {
|
||||||
'filter.sender_email': (v) => { config.filter.sender_email = v },
|
'filter.sender_email': (v) => { config.filter.sender_email = v },
|
||||||
'filter.oem_repair_keywords': (v) => { try { config.filter.oem_repair_keywords = JSON.parse(v) } catch {} },
|
'filter.oem_repair_keywords': (v) => { try { config.filter.oem_repair_keywords = JSON.parse(v) } catch {} },
|
||||||
'filter.oem_diag_keywords': (v) => { try { config.filter.oem_diag_keywords = JSON.parse(v) } catch {} },
|
'filter.oem_diag_keywords': (v) => { try { config.filter.oem_diag_keywords = JSON.parse(v) } catch {} },
|
||||||
'wechat.webhook_url': (v) => { config.wechat.webhook_url = v },
|
'wechat.webhooks': (v) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(v)
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) config.wechat.webhooks = parsed
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const db = getDb()
|
|
||||||
const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'monitor.%' OR key LIKE 'mail.%' OR key LIKE 'filter.%' OR key LIKE 'wechat.%'").all() as { key: string; value: string }[]
|
const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'monitor.%' OR key LIKE 'mail.%' OR key LIKE 'filter.%' OR key LIKE 'wechat.%'").all() as { key: string; value: string }[]
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
mappings[row.key]?.(row.value)
|
mappings[row.key]?.(row.value)
|
||||||
|
|
@ -114,9 +151,11 @@ export function updateMonitorConfig(updates: Record<string, unknown>): void {
|
||||||
export function getMaskedConfig(): MonitorConfig {
|
export function getMaskedConfig(): MonitorConfig {
|
||||||
const config = getMonitorConfig()
|
const config = getMonitorConfig()
|
||||||
if (config.mail.password) config.mail.password = '••••••••'
|
if (config.mail.password) config.mail.password = '••••••••'
|
||||||
if (config.wechat.webhook_url) {
|
for (const wh of config.wechat.webhooks) {
|
||||||
const keyMatch = config.wechat.webhook_url.match(/key=([0-9a-f-]+)/)
|
if (wh.url) {
|
||||||
if (keyMatch) config.wechat.webhook_url = config.wechat.webhook_url.replace(keyMatch[1], '••••••••')
|
const keyMatch = wh.url.match(/key=([0-9a-f-]+)/)
|
||||||
|
if (keyMatch) wh.url = wh.url.replace(keyMatch[1], '••••••••')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,19 @@ export interface MonitorLog {
|
||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonitorConfig {
|
export interface MonitorConfig {
|
||||||
monitor: {
|
monitor: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
@ -75,7 +88,7 @@ export interface MonitorConfig {
|
||||||
oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }
|
oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }
|
||||||
}
|
}
|
||||||
wechat: {
|
wechat: {
|
||||||
webhook_url: string
|
webhooks: WebhookConfig[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { WeChatPusher } from './wechat-pusher'
|
||||||
import { AvailabilityEngine } from './availability-engine'
|
import { AvailabilityEngine } from './availability-engine'
|
||||||
import { ReminderEngine } from './reminder-engine'
|
import { ReminderEngine } from './reminder-engine'
|
||||||
import { getMonitorConfig, getSetting, setSetting } from './settings-manager'
|
import { getMonitorConfig, getSetting, setSetting } from './settings-manager'
|
||||||
import type { MonitorConfig } from './types'
|
import type { MonitorConfig, WebhookConfig } from './types'
|
||||||
import { formatBeijingTime } from './types'
|
import { formatBeijingTime } from './types'
|
||||||
|
|
||||||
const logger = {
|
const logger = {
|
||||||
|
|
@ -26,6 +26,29 @@ export class BackgroundWorker {
|
||||||
private lastTickTime = 0
|
private lastTickTime = 0
|
||||||
private db = getDb()
|
private db = getDb()
|
||||||
|
|
||||||
|
// 推送消息到所有启用的 webhook
|
||||||
|
private async pushToAllWebhooks(text: string, config: MonitorConfig): Promise<boolean> {
|
||||||
|
const enabledHooks = config.wechat.webhooks.filter(wh => wh.enabled && wh.url)
|
||||||
|
if (enabledHooks.length === 0) return false
|
||||||
|
let anySuccess = false
|
||||||
|
for (const wh of enabledHooks) {
|
||||||
|
const ok = await this.wechatPusher.pushText(text, wh.url)
|
||||||
|
if (ok) anySuccess = true
|
||||||
|
}
|
||||||
|
return anySuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定 webhook 的参数(如无匹配则用全局 monitor 参数)
|
||||||
|
private getWebhookParams(wh: WebhookConfig, config: MonitorConfig): MonitorConfig['monitor'] {
|
||||||
|
return {
|
||||||
|
enabled: config.monitor.enabled,
|
||||||
|
interval_seconds: wh.params?.interval_seconds ?? config.monitor.interval_seconds,
|
||||||
|
push_delay_ms: wh.params?.push_delay_ms ?? config.monitor.push_delay_ms,
|
||||||
|
silent_start_hour: wh.params?.silent_start_hour ?? config.monitor.silent_start_hour,
|
||||||
|
silent_end_hour: wh.params?.silent_end_hour ?? config.monitor.silent_end_hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.cronJob) return
|
if (this.cronJob) return
|
||||||
this.cronJob = cron.schedule('* * * * *', () => { this.tick() })
|
this.cronJob = cron.schedule('* * * * *', () => { this.tick() })
|
||||||
|
|
@ -70,14 +93,14 @@ export class BackgroundWorker {
|
||||||
// Step 4: 7 点 flush
|
// Step 4: 7 点 flush
|
||||||
if (this.reminderEngine.isTimeToFlush()) {
|
if (this.reminderEngine.isTimeToFlush()) {
|
||||||
await this.reminderEngine.flushPendingMessages(
|
await this.reminderEngine.flushPendingMessages(
|
||||||
(text) => this.wechatPusher.pushText(text, config.wechat.webhook_url),
|
(text) => this.pushToAllWebhooks(text, config),
|
||||||
(ip, sn) => this.ticketProcessor.getRackPosition(ip, sn)
|
(ip, sn) => this.ticketProcessor.getRackPosition(ip, sn)
|
||||||
)
|
)
|
||||||
this.reminderEngine.markFlushed()
|
this.reminderEngine.markFlushed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5-6: 检查提醒
|
// Step 5-6: 检查提醒
|
||||||
const pushText = (text: string) => this.wechatPusher.pushText(text, config.wechat.webhook_url)
|
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)
|
||||||
|
|
@ -126,7 +149,7 @@ export class BackgroundWorker {
|
||||||
|
|
||||||
// 推送微信
|
// 推送微信
|
||||||
const message = this.wechatPusher.formatAvailabilityMessage(deadlines, faultInfo, type === 'oem_diag', oemDeadline, faultInfo.order_number, rackPosition, faultInfo.fault_detail)
|
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)
|
await this.pushToAllWebhooks(message, config)
|
||||||
|
|
||||||
// 记录故障
|
// 记录故障
|
||||||
this.ticketProcessor.recordFault({
|
this.ticketProcessor.recordFault({
|
||||||
|
|
@ -259,7 +282,7 @@ export class BackgroundWorker {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先推送消息,成功后再更新数据库(防止推送失败导致通知丢失)
|
// 先推送消息,成功后再更新数据库(防止推送失败导致通知丢失)
|
||||||
const pushed = await this.wechatPusher.pushText(message, config.wechat.webhook_url)
|
const pushed = await this.pushToAllWebhooks(message, config)
|
||||||
if (pushed) {
|
if (pushed) {
|
||||||
this.db.prepare("UPDATE fault_records SET status = 'resolved', recovery_time = ?, duration_seconds = ? WHERE id = ?").run(recoveryTime, duration, fault.id)
|
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)
|
this.reminderEngine.cleanupForTicket(fault.order_number)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue