From f507adfaa1c1dcac03c97433b94da487ed031124 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Thu, 2 Jul 2026 17:39:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9D=83=E9=99=90=E7=BB=86=E5=8C=96=20?= =?UTF-8?q?tickets:edit/tickets:delete=20+=20=E5=A4=9A=20Webhook=20?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tickets:write 拆分为 tickets:edit(处理工单)和 tickets:delete(删除工单) - 内置 operator 角色默认同时拥有两个权限 - 迁移脚本自动将已有角色的 tickets:write 替换为两个新权限 - 微信推送支持多个 webhook 推送组,每个可独立启用/禁用 - 每个推送组包含独立的运行参数(检查间隔、静默期) - 旧格式 wechat.webhook_url 自动迁移为新数组格式 - worker 推送逻辑适配多 webhook(遍历所有启用的推送组) --- src/app/(app)/settings/monitor/page.tsx | 264 +++++++++++++++++------ src/app/(app)/settings/roles/page.tsx | 3 +- src/app/api/monitor/settings/route.ts | 2 +- src/app/api/monitor/test-wechat/route.ts | 19 +- src/app/api/tickets/[id]/route.ts | 4 +- src/app/api/tickets/batch/route.ts | 2 +- src/lib/db-schema.ts | 16 +- src/lib/monitor/settings-manager.ts | 53 ++++- src/lib/monitor/types.ts | 15 +- src/lib/monitor/worker.ts | 33 ++- 10 files changed, 328 insertions(+), 83 deletions(-) diff --git a/src/app/(app)/settings/monitor/page.tsx b/src/app/(app)/settings/monitor/page.tsx index 88bc43e..ce5d286 100644 --- a/src/app/(app)/settings/monitor/page.tsx +++ b/src/app/(app)/settings/monitor/page.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect } from 'react' import { Card } from '@/components/ui' +import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react' interface MonitorStatus { 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 { 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 } 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' -// 深比较两个对象,返回变化的字段路径 -function getChangedFields(original: unknown, current: unknown, prefix = ''): string[] { - 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 - const curr = current as Record - 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]) +function generateId() { + return 'wh_' + Math.random().toString(36).slice(2, 10) } export default function MonitorSettingsPage() { @@ -62,6 +53,8 @@ export default function MonitorSettingsPage() { const [newKeyword, setNewKeyword] = useState('') const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null) const [sectionMessage, setSectionMessage] = useState>>({}) + const [expandedWebhooks, setExpandedWebhooks] = useState>(new Set()) + const [testingWebhook, setTestingWebhook] = useState(null) useEffect(() => { 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 } })) } - const handleTestWechat = async () => { - setSectionMessage(prev => ({ ...prev, wechat: { type: 'success', text: '正在测试微信推送...' } })) - const res = await fetch('/api/monitor/test-wechat', { method: 'POST' }) + 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() - 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) => { @@ -145,18 +150,61 @@ export default function MonitorSettingsPage() { setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } }) } - // 获取某个字段是否被修改 - const isFieldChanged = (path: string): boolean => { - if (!originalConfig || !config) return false - const changes = getChangedFields(originalConfig, config) - return changes.some(c => c === path || c.startsWith(path + '.')) + // --- 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({ ...config, wechat: { ...config.wechat, webhooks } }) + // 自动展开新增的 webhook + setExpandedWebhooks(prev => new Set(prev).add(newWh.id)) } - // 输入框样式:高亮修改的字段 - const inputClass = (path: string) => - `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'}` + 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) } }) + } - // Section 保存按钮组件(消息反馈由各 section 自行渲染,此处只渲染按钮) + const updateWebhook = (id: string, updates: Partial) => { + if (!config) return + const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, ...updates } : w) + setConfig({ ...config, wechat: { ...config.wechat, webhooks } }) + } + + const updateWebhookParams = (id: string, paramUpdates: Partial) => { + if (!config) return + const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, params: { ...w.params, ...paramUpdates } } : w) + setConfig({ ...config, wechat: { ...config.wechat, webhooks } }) + } + + const toggleWebhookExpand = (id: string) => { + setExpandedWebhooks(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + // 检查某个 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 changed = hasSectionChanged(originalConfig, config, section) if (!changed) return null @@ -201,7 +249,7 @@ export default function MonitorSettingsPage() { {status?.lastCheck && 上次检查: {status.lastCheck}} - {/* 统计指标 - 可点击展开 */} + {/* 统计指标 */}
{[ { 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 === 'today' && status?.details?.todayProcessed && (
@@ -249,7 +296,6 @@ export default function MonitorSettingsPage() {
)} - {/* 累计统计详情 */} {expandedPanel === 'total' && (
@@ -277,7 +323,6 @@ export default function MonitorSettingsPage() {
)} - {/* 错误详情 */} {expandedPanel === 'errors' && status?.details?.recentErrors && (
@@ -308,7 +353,6 @@ export default function MonitorSettingsPage() {
)} - {/* 进行中故障详情 */} {expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
@@ -408,7 +452,7 @@ export default function MonitorSettingsPage() {
{config.filter.subject_keywords.map((kw, i) => ( - + {kw} @@ -427,29 +471,127 @@ export default function MonitorSettingsPage() { - {/* 微信推送 */} + {/* 微信推送 - 多 Webhook */} -

微信推送

-
+
- - 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=..." /> -
-
- - {sectionMessage.wechat && ( - - {sectionMessage.wechat.text} - - )} +

微信推送

+

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

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

运行参数

+

全局运行参数

+

未在推送组中单独配置的参数将使用以下全局默认值

diff --git a/src/app/(app)/settings/roles/page.tsx b/src/app/(app)/settings/roles/page.tsx index e96b034..0bc3539 100644 --- a/src/app/(app)/settings/roles/page.tsx +++ b/src/app/(app)/settings/roles/page.tsx @@ -18,7 +18,8 @@ const allPermissions = [ { key: 'tickets:create', label: '手动建单' }, { key: 'tickets:import', label: '导入工单' }, { key: 'tickets:export', label: '导出工单' }, - { key: 'tickets:write', label: '编辑/删除工单' }, + { key: 'tickets:edit', label: '处理工单' }, + { key: 'tickets:delete', label: '删除工单' }, { key: 'reports:read', label: '查看报告' }, { key: 'reports:download', label: '下载报告' }, { key: 'reports:create', label: '新建报告' }, diff --git a/src/app/api/monitor/settings/route.ts b/src/app/api/monitor/settings/route.ts index b025a9e..54e088b 100644 --- a/src/app/api/monitor/settings/route.ts +++ b/src/app/api/monitor/settings/route.ts @@ -15,7 +15,7 @@ const VALIDATORS: Record boolean> = { 'mail.address': (v) => typeof v === 'string' && v.includes('@'), 'mail.imap_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) { diff --git a/src/app/api/monitor/test-wechat/route.ts b/src/app/api/monitor/test-wechat/route.ts index 614b299..4cabda7 100644 --- a/src/app/api/monitor/test-wechat/route.ts +++ b/src/app/api/monitor/test-wechat/route.ts @@ -10,10 +10,23 @@ export async function POST(request: NextRequest) { initDatabase() const user = await getCurrentUser() 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 { - 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', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }), diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts index ab95008..9254264 100644 --- a/src/app/api/tickets/[id]/route.ts +++ b/src/app/api/tickets/[id]/route.ts @@ -42,7 +42,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ initDatabase() const user = await getCurrentUser() 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 body = await request.json() @@ -134,7 +134,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis initDatabase() const user = await getCurrentUser() 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 db = getDb() diff --git a/src/app/api/tickets/batch/route.ts b/src/app/api/tickets/batch/route.ts index 39d55a7..e623288 100644 --- a/src/app/api/tickets/batch/route.ts +++ b/src/app/api/tickets/batch/route.ts @@ -10,7 +10,7 @@ export async function PUT(request: NextRequest) { initDatabase() const user = await getCurrentUser() 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 updates: Array<{ id: number; fault_category?: string; current_status?: string; counted_in_sla?: number }> = body.updates || [] diff --git a/src/lib/db-schema.ts b/src/lib/db-schema.ts index bbc22bc..d07d7f4 100644 --- a/src/lib/db-schema.ts +++ b/src/lib/db-schema.ts @@ -58,6 +58,20 @@ export function initDatabase(): void { db.exec("CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)") } 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') if (!existing) { const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123' @@ -72,7 +86,7 @@ export function initDatabase(): void { } const roles = [ { 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"]' }, ] for (const r of roles) { diff --git a/src/lib/monitor/settings-manager.ts b/src/lib/monitor/settings-manager.ts index 61e02aa..812c2cd 100644 --- a/src/lib/monitor/settings-manager.ts +++ b/src/lib/monitor/settings-manager.ts @@ -1,7 +1,7 @@ // src/lib/monitor/settings-manager.ts import { getDb } from '@/lib/db' import crypto from 'crypto' -import type { MonitorConfig } from './types' +import type { MonitorConfig, WebhookConfig } from './types' const ALGORITHM = 'aes-256-cbc' @@ -54,6 +54,14 @@ export function setSetting(key: string, value: string, category = 'general'): vo ).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 = { 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: '' }, @@ -63,11 +71,36 @@ const DEFAULT_CONFIG: MonitorConfig = { oem_repair_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): 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 { const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)) + const db = getDb() + + // 迁移旧格式 + migrateWebhookSettings(db) + const mappings: Record void> = { 'monitor.enabled': (v) => { config.monitor.enabled = v === 'true' }, '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.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 {} }, - '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 }[] for (const row of rows) { mappings[row.key]?.(row.value) @@ -114,9 +151,11 @@ export function updateMonitorConfig(updates: Record): void { export function getMaskedConfig(): MonitorConfig { const config = getMonitorConfig() if (config.mail.password) config.mail.password = '••••••••' - if (config.wechat.webhook_url) { - const keyMatch = config.wechat.webhook_url.match(/key=([0-9a-f-]+)/) - if (keyMatch) config.wechat.webhook_url = config.wechat.webhook_url.replace(keyMatch[1], '••••••••') + for (const wh of config.wechat.webhooks) { + if (wh.url) { + const keyMatch = wh.url.match(/key=([0-9a-f-]+)/) + if (keyMatch) wh.url = wh.url.replace(keyMatch[1], '••••••••') + } } return config } diff --git a/src/lib/monitor/types.ts b/src/lib/monitor/types.ts index bb02f9c..638ec2c 100644 --- a/src/lib/monitor/types.ts +++ b/src/lib/monitor/types.ts @@ -52,6 +52,19 @@ export interface MonitorLog { 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 { monitor: { enabled: boolean @@ -75,7 +88,7 @@ export interface MonitorConfig { oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } } wechat: { - webhook_url: string + webhooks: WebhookConfig[] } } diff --git a/src/lib/monitor/worker.ts b/src/lib/monitor/worker.ts index 3fd0916..0e18535 100644 --- a/src/lib/monitor/worker.ts +++ b/src/lib/monitor/worker.ts @@ -7,7 +7,7 @@ import { WeChatPusher } from './wechat-pusher' import { AvailabilityEngine } from './availability-engine' import { ReminderEngine } from './reminder-engine' import { getMonitorConfig, getSetting, setSetting } from './settings-manager' -import type { MonitorConfig } from './types' +import type { MonitorConfig, WebhookConfig } from './types' import { formatBeijingTime } from './types' const logger = { @@ -26,6 +26,29 @@ export class BackgroundWorker { private lastTickTime = 0 private db = getDb() + // 推送消息到所有启用的 webhook + private async pushToAllWebhooks(text: string, config: MonitorConfig): Promise { + 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 { if (this.cronJob) return this.cronJob = cron.schedule('* * * * *', () => { this.tick() }) @@ -70,14 +93,14 @@ export class BackgroundWorker { // Step 4: 7 点 flush if (this.reminderEngine.isTimeToFlush()) { 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) ) this.reminderEngine.markFlushed() } // 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) await this.reminderEngine.checkOemDiagReminders(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) - await this.wechatPusher.pushText(message, config.wechat.webhook_url) + await this.pushToAllWebhooks(message, config) // 记录故障 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) { 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)