Compare commits

..

No commits in common. "248ccaeb66c7cf4be7b870e900a0f839dcce3f52" and "d39d2e65b300083951cc48eb5e3ff58e10ad2478" have entirely different histories.

18 changed files with 127 additions and 508 deletions

View File

@ -1,8 +1,8 @@
# issue-ai 环境变量(本地开发)
DATABASE_PATH=./data/issue.db
JWT_SECRET=dev-jwt-secret-local
COOKIE_DOMAIN=
JWT_SECRET=your-secret-key-change-in-production
ADMIN_PASSWORD=admin123
NODE_ENV=development
# ⚠️ 仅限本地开发环境(自签名证书),生产环境禁止设置此变量
NODE_TLS_REJECT_UNAUTHORIZED=0
# LDAP 配置
@ -10,12 +10,16 @@ LDAP_URL=ldap://localhost:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
# OIDC 配置
AUTHELIA_URL=http://127.0.0.1:6180
OIDC_CLIENT_ID=issue-oidc
OIDC_CLIENT_SECRET=<见 Authelia 配置>
OIDC_REDIRECT_URI=http://127.0.0.1:6176/api/auth/callback
# 跨服务调用
# assets-ai API 配置
ASSETS_API_URL=http://localhost:6177/api
ASSETS_API_KEY=your-assets-api-key
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177
# 允许调用 issue-ai API 的 Key逗号分隔支持多个由 issue-ai 管理界面生成
ALLOWED_API_KEYS=your-issue-api-key
# OIDC 配置SSO 统一认证)
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=issue-oidc
OIDC_CLIENT_SECRET=change-me-to-hashed-secret
OIDC_REDIRECT_URI=http://localhost:6176/api/auth/callback

View File

@ -1,21 +1,5 @@
# 变更日志
## 2026-07-02
- [优化] 企业微信推送消息格式全面对齐 zabbix-01 脚本10 项修复)
- 结单消息按故障类型区分OEM诊断/OEM维修/其他),补齐机架位置、故障类型、故障时间
- OEM维修结单消息新增本月故障次数和本月总处理时长
- 新故障通知OEM维修档位标题补全「基于月度内累计故障时长计算」
- 新故障通知OEM诊断字段顺序调整到期时间在故障信息之前
- 每小时提醒显示「X小时Y分」精度
- 静默期积压消息格式对齐动态剩余时间、机架位置、diag_30min/diag_hourly 区分
- [修复] `isInSilentPeriod()` / `isTimeToFlush()` 时区问题:新增 `getBeijingHour()` 使用 UTC+8 偏移,兼容 UTC 容器环境
- [修复] 先推送后更新数据库,防止推送失败导致结单通知永久丢失
- [修复] 共享库 `WeChatPusher` 错误消息不泄露 webhook URL
- [修复] `faultDetail` 截断 500 字符,防止消息超企业微信 API 限制
- [修复] `tierPenaltyMap` 提取为类级常量 `TIER_PENALTY_MAP`,消除 3 处重复定义
- [修复] `deploy-ai.sh` Docker 构建前临时替换 shared 符号链接为实际目录,构建后恢复
## 2026-06-30
- [新增] SSO 统一认证:集成 Authelia OIDC支持统一认证登录

View File

@ -1,15 +0,0 @@
# issue-ai 生产环境配置(模板)
DATABASE_PATH=/app/data/issue.db
JWT_SECRET=__JWT_SECRET__
COOKIE_DOMAIN=.tlyq.ai
NODE_ENV=production
NODE_TLS_REJECT_UNAUTHORIZED=0
LDAP_URL=ldap://lldap:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=issue-oidc
OIDC_CLIENT_SECRET=__OIDC_CLIENT_SECRET__
OIDC_REDIRECT_URI=https://issue.tlyq.ai/api/auth/callback
ASSETS_API_URL=http://assets-ai:3000/api
NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai

View File

@ -1,7 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Card } from '@/components/ui'
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
interface MonitorStatus {
enabled: boolean
@ -17,30 +16,40 @@ 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: { webhooks: WebhookConfig[] }
wechat: { webhook_url: string }
}
type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor'
function generateId() {
return 'wh_' + Math.random().toString(36).slice(2, 10)
// 深比较两个对象,返回变化的字段路径
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<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() {
@ -53,8 +62,6 @@ export default function MonitorSettingsPage() {
const [newKeyword, setNewKeyword] = useState('')
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
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(() => {
Promise.all([
@ -103,23 +110,11 @@ export default function MonitorSettingsPage() {
setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
}
const handleTestWebhook = async (webhookId: string) => {
if (!config) return
const wh = config.wechat.webhooks.find(w => w.id === webhookId)
if (!wh || !wh.url) return
setTestingWebhook(webhookId)
const res = await fetch('/api/monitor/test-wechat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ webhook_url: wh.url }),
})
const handleTestWechat = async () => {
setSectionMessage(prev => ({ ...prev, wechat: { type: 'success', text: '正在测试微信推送...' } }))
const res = await fetch('/api/monitor/test-wechat', { method: 'POST' })
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)
setSectionMessage(prev => ({ ...prev, wechat: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
}
const handleSectionSave = async (section: SectionKey) => {
@ -150,61 +145,18 @@ export default function MonitorSettingsPage() {
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
}
// --- 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 isFieldChanged = (path: string): boolean => {
if (!originalConfig || !config) return false
const changes = getChangedFields(originalConfig, config)
return changes.some(c => c === path || c.startsWith(path + '.'))
}
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 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 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 保存按钮组件
// Section 保存按钮组件(消息反馈由各 section 自行渲染,此处只渲染按钮)
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
const changed = hasSectionChanged(originalConfig, config, section)
if (!changed) return null
@ -249,7 +201,7 @@ export default function MonitorSettingsPage() {
{status?.lastCheck && <span className="text-sm text-slate-500">: {status.lastCheck}</span>}
</div>
{/* 统计指标 */}
{/* 统计指标 - 可点击展开 */}
<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' },
@ -273,6 +225,7 @@ export default function MonitorSettingsPage() {
{/* 展开面板 */}
{expandedPanel && (
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
{/* 今日处理详情 */}
{expandedPanel === 'today' && status?.details?.todayProcessed && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
@ -296,6 +249,7 @@ export default function MonitorSettingsPage() {
</div>
)}
{/* 累计统计详情 */}
{expandedPanel === 'total' && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
@ -323,6 +277,7 @@ export default function MonitorSettingsPage() {
</div>
)}
{/* 错误详情 */}
{expandedPanel === 'errors' && status?.details?.recentErrors && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
@ -353,6 +308,7 @@ export default function MonitorSettingsPage() {
</div>
)}
{/* 进行中故障详情 */}
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
@ -452,7 +408,7 @@ export default function MonitorSettingsPage() {
<label className="block text-sm font-medium mb-1"></label>
<div className="flex flex-wrap gap-2 mb-2">
{config.filter.subject_keywords.map((kw, i) => (
<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">
<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'}`}>
{kw}
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
</span>
@ -471,127 +427,29 @@ export default function MonitorSettingsPage() {
<SectionSaveButton section="filter" />
</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">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
<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>
<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=..." />
</div>
<div className="flex items-center gap-3">
<button onClick={handleTestWechat} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm"></button>
{sectionMessage.wechat && (
<span className={`text-sm ${sectionMessage.wechat.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{sectionMessage.wechat.text}
</span>
)}
</div>
))}
</div>
<SectionSaveButton section="wechat" />
</Card>
{/* 全局运行参数(保持向后兼容) */}
{/* 运行参数 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">使</p>
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1"></label>

View File

@ -18,8 +18,7 @@ const allPermissions = [
{ key: 'tickets:create', label: '手动建单' },
{ key: 'tickets:import', label: '导入工单' },
{ key: 'tickets:export', label: '导出工单' },
{ key: 'tickets:edit', label: '处理工单' },
{ key: 'tickets:delete', label: '删除工单' },
{ key: 'tickets:write', label: '编辑/删除工单' },
{ key: 'reports:read', label: '查看报告' },
{ key: 'reports:download', label: '下载报告' },
{ key: 'reports:create', label: '新建报告' },

View File

@ -37,11 +37,6 @@ export default function LoginPage() {
</button>
<p className="text-center text-xs text-slate-400 mb-3"> SSO </p>
<p className="text-center mb-2">
<button onClick={() => { window.location.href = '/api/auth/login/oidc?switch=1' }} className="text-xs text-slate-400 hover:text-slate-600 underline">
使
</button>
</p>
<p className="text-center">
<button onClick={() => setShowLdapForm(true)} className="text-xs text-slate-400 hover:text-slate-600 underline">
使 LDAP

View File

@ -15,7 +15,7 @@ const VALIDATORS: Record<string, (v: unknown) => 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.webhooks': (v) => Array.isArray(v) && v.length > 0,
'wechat.webhook_url': (v) => typeof v === 'string' && (v === '' || v.startsWith('https://qyapi.weixin.qq.com/')),
}
export async function GET(request: NextRequest) {

View File

@ -10,23 +10,10 @@ export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
let webhookUrl: string | null = null
try {
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
}
if (!config.wechat.webhook_url) return NextResponse.json({ success: false, error: 'Webhook URL 未配置' })
try {
const response = await fetch(webhookUrl, {
const response = await fetch(config.wechat.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }),

View File

@ -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:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (!hasPermission(user, 'tickets:write')) 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:delete')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const db = getDb()

View File

@ -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:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (!hasPermission(user, 'tickets:write')) 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 || []

View File

@ -22,10 +22,7 @@ const settingsItems = [
]
function hasAnyAdminPerm(permissions: string[]): boolean {
return permissions.includes('*') || permissions.some(p =>
p.startsWith('users:') || p.startsWith('roles:') || p.startsWith('api-keys:') ||
p.startsWith('audit-logs:') || p.startsWith('monitor:')
)
return permissions.includes('*') || permissions.some(p => p.startsWith('users:') || p.startsWith('roles:') || p.startsWith('api-keys:'))
}
export default function Sidebar() {

View File

@ -58,20 +58,6 @@ 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'
@ -86,7 +72,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:edit","tickets:delete","reports:read","reports:download","reports:create"]' },
{ name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:create","tickets:import","tickets:export","tickets:write","reports:read","reports:download","reports:create"]' },
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","tickets:export","reports:read","reports:download"]' },
]
for (const r of roles) {

View File

@ -10,20 +10,15 @@ const logger = {
export class ReminderEngine {
private db = getDb()
private static readonly TIER_PENALTY_MAP: Record<string, string> = { '第一档': '10%', '第二档': '25%', '第三档': '50%', '第四档': '100%' }
/** 获取北京时间的小时数(兼容 UTC 容器环境) */
private getBeijingHour(): number {
return new Date(new Date().getTime() + 8 * 3600_000).getUTCHours()
}
isInSilentPeriod(config: MonitorConfig): boolean {
const hour = this.getBeijingHour()
const hour = new Date().getHours()
return hour >= config.monitor.silent_start_hour && hour < config.monitor.silent_end_hour
}
isTimeToFlush(): boolean {
if (this.getBeijingHour() !== 7) return false
const now = new Date()
if (now.getHours() !== 7) return false
// 检查今天是否已 flush 过(防止重复执行)
const flushed = this.db.prepare("SELECT id FROM monitor_logs WHERE action = 'flush_complete' AND date(created_at, '+8 hours') = date('now', '+8 hours')").get()
return !flushed
@ -111,9 +106,7 @@ export class ReminderEngine {
}
const rackH = getRack ? await getRack(r.server_ip, r.server_sn) : null
const remainingHours = Math.floor(timeToDeadline / 3600)
const remainingMinutes = Math.floor((timeToDeadline % 3600) / 60)
const timeStr = remainingHours > 0 ? `${remainingHours}小时${remainingMinutes}` : `${remainingMinutes}`
let text = `⏰ OEM诊断工单提醒剩余${timeStr}\n\n`
let text = `⏰ OEM诊断工单提醒剩余${remainingHours}小时)\n\n`
if (r.order_number) text += `工单号:${r.order_number}\n`
text += `服务器IP${r.server_ip || '未知'}\n服务器SN${r.server_sn}\n`
if (rackH) text += `机架位置:${rackH}\n`
@ -126,7 +119,7 @@ export class ReminderEngine {
}
}
async flushPendingMessages(pushText: (text: string) => Promise<boolean>, getRack?: (ip: string | null, sn: string | null) => Promise<string | null>): Promise<void> {
async flushPendingMessages(pushText: (text: string) => Promise<boolean>): Promise<void> {
const pending = this.db.prepare('SELECT * FROM pending_messages').all() as { id: number; message_type: string; server_sn: string; payload: string }[]
for (const p of pending) {
try {
@ -135,12 +128,11 @@ export class ReminderEngine {
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
continue
}
// 重新计算剩余时间(提升到 try 块作用域,供下方消息构建使用)
let remainingSec = 0
// 重新计算剩余时间
if (payload.deadline) {
const deadline = new Date(payload.deadline.replace(' ', 'T') + '+08:00')
remainingSec = (deadline.getTime() - Date.now()) / 1000
if (remainingSec <= 0) {
const remaining = (deadline.getTime() - Date.now()) / 1000
if (remaining <= 0) {
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
continue
}
@ -148,46 +140,17 @@ export class ReminderEngine {
// 构建消息
let text = ''
if (p.message_type === 'oem_diag') {
// 获取机架位置
let rackFlush: string | null = null
if (getRack) {
try { rackFlush = await getRack(payload.server_ip, payload.server_sn) } catch { /* ignore */ }
}
// 区分 diag_30min 和 diag_hourly与 zabbix-01 flush 一致)
if (payload.type === 'diag_30min') {
text = `⚠️ OEM诊断工单即将到期剩余30分钟\n\n`
text = `OEM诊断工单提醒\n\n`
if (payload.order_number) text += `工单号:${payload.order_number}\n`
text += `服务器IP${payload.server_ip || '未知'}\n服务器SN${payload.server_sn}\n`
if (rackFlush) text += `机架位置:${rackFlush}\n`
text += `到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间请尽快处理`
} else {
// diag_hourly默认动态计算剩余时间
const remainingH = Math.floor(remainingSec / 3600)
const remainingM = Math.floor((remainingSec % 3600) / 60)
const timeStr = remainingH > 0 ? `${remainingH}小时${remainingM}` : `${remainingM}`
text = `⏰ OEM诊断工单提醒剩余${timeStr}\n\n`
if (payload.order_number) text += `工单号:${payload.order_number}\n`
text += `服务器IP${payload.server_ip || '未知'}\n服务器SN${payload.server_sn}\n`
if (rackFlush) text += `机架位置:${rackFlush}\n`
text += `到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间请及时处理`
}
text += `服务器IP${payload.server_ip || '未知'}\n服务器SN${payload.server_sn}\n到期时间${payload.deadline}\n\n服务器尚未录入恢复时间请及时处理`
} else if (p.message_type === 'oem_repair' && payload.type === 'repair_before') {
// 动态计算剩余时间(替代硬编码"1小时"
const remainingH = Math.floor(remainingSec / 3600)
const remainingM = Math.floor((remainingSec % 3600) / 60)
const timeStr = remainingH > 0 ? `${remainingH}小时${remainingM}` : `${remainingM}`
// 获取机架位置
let rackFlush: string | null = null
if (getRack) {
try { rackFlush = await getRack(payload.server_ip, payload.server_sn) } catch { /* ignore */ }
}
const penalty = payload.tier_name ? ReminderEngine.TIER_PENALTY_MAP[payload.tier_name] || '' : ''
// 重新计算剩余时间
const tierPenaltyMap: Record<string, string> = { '第一档': '10%', '第二档': '25%', '第三档': '50%', '第四档': '100%' }
const penalty = payload.tier_name ? tierPenaltyMap[payload.tier_name] || '' : ''
const tierLabel = payload.tier_name === '第四档' ? '全额扣费100%' : `${payload.tier_name}${penalty}`
text = `OEM维修工单超时提醒距离${tierLabel}还剩${timeStr}\n\n`
text = `OEM维修工单超时提醒距离${tierLabel}还剩1小时\n\n`
if (payload.order_number) text += `工单号:${payload.order_number}\n`
text += `服务器IP${payload.server_ip || '未知'}\n服务器SN${payload.server_sn}\n`
if (rackFlush) text += `机架位置:${rackFlush}\n`
text += `${payload.tier_name || ''}到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间请及时处理`
text += `服务器IP${payload.server_ip || '未知'}\n服务器SN${payload.server_sn}\n到期时间${payload.deadline}\n\n服务器尚未录入恢复时间请及时处理`
}
if (text && await pushText(text)) {
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
@ -211,7 +174,8 @@ export class ReminderEngine {
if (timeToDeadline <= 0 && r.notification_phase !== 'overdue') {
const rackRO = getRack ? await getRack(r.server_ip, r.server_sn) : null
// 超时提醒格式第X档已超时达到第Y档扣XX%
const penalty = ReminderEngine.TIER_PENALTY_MAP[r.tier_name] || ''
const tierPenaltyMap: Record<string, string> = { '第一档': '10%', '第二档': '25%', '第三档': '50%', '第四档': '100%' }
const penalty = tierPenaltyMap[r.tier_name] || ''
const isFinalTier = r.tier_name === '第四档'
let text = isFinalTier
? `OEM维修工单超时提醒${r.tier_name}已超时已触发全额扣费100%\n\n`
@ -235,7 +199,8 @@ export class ReminderEngine {
}
const rackRB = getRack ? await getRack(r.server_ip, r.server_sn) : null
// 提前1小时提醒格式距离第X档扣XX%还剩1小时
const penalty = ReminderEngine.TIER_PENALTY_MAP[r.tier_name] || ''
const tierPenaltyMap: Record<string, string> = { '第一档': '10%', '第二档': '25%', '第三档': '50%', '第四档': '100%' }
const penalty = tierPenaltyMap[r.tier_name] || ''
const isFinalTier = r.tier_name === '第四档'
const tierLabel = isFinalTier ? '全额扣费100%' : `${r.tier_name}${penalty}`
let text = `OEM维修工单超时提醒距离${tierLabel}还剩1小时\n\n`

View File

@ -1,7 +1,7 @@
// src/lib/monitor/settings-manager.ts
import { getDb } from '@/lib/db'
import crypto from 'crypto'
import type { MonitorConfig, WebhookConfig } from './types'
import type { MonitorConfig } from './types'
const ALGORITHM = 'aes-256-cbc'
@ -54,14 +54,6 @@ 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: '' },
@ -71,36 +63,11 @@ 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: { 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()
}
wechat: { webhook_url: '' },
}
export function getMonitorConfig(): MonitorConfig {
const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG))
const db = getDb()
// 迁移旧格式
migrateWebhookSettings(db)
const mappings: Record<string, (v: string) => void> = {
'monitor.enabled': (v) => { config.monitor.enabled = v === 'true' },
'monitor.interval_seconds': (v) => { config.monitor.interval_seconds = parseInt(v) || 60 },
@ -117,13 +84,9 @@ 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.webhooks': (v) => {
try {
const parsed = JSON.parse(v)
if (Array.isArray(parsed) && parsed.length > 0) config.wechat.webhooks = parsed
} catch {}
},
'wechat.webhook_url': (v) => { config.wechat.webhook_url = v },
}
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)
@ -151,11 +114,9 @@ export function updateMonitorConfig(updates: Record<string, unknown>): void {
export function getMaskedConfig(): MonitorConfig {
const config = getMonitorConfig()
if (config.mail.password) config.mail.password = '••••••••'
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], '••••••••')
}
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], '••••••••')
}
return config
}

View File

@ -52,19 +52,6 @@ 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
@ -88,7 +75,7 @@ export interface MonitorConfig {
oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }
}
wechat: {
webhooks: WebhookConfig[]
webhook_url: string
}
}

View File

@ -49,14 +49,12 @@ export class WeChatPusher {
msg += `服务器SN${faultInfo.server_sn || '未知'}\n`
msg += `机架位置:${rackPosition || '未知'}\n`
if (faultInfo.fault_time) msg += `故障时间:${faultInfo.fault_time}\n`
const truncatedDetail = faultDetail ? faultDetail.slice(0, 500) : null
if (faultDetail) msg += `故障信息:${faultDetail}\n`
if (isOemDiag && oemDeadline) {
msg += `到期时间:${oemDeadline}\n`
if (truncatedDetail) msg += `故障信息:${truncatedDetail}\n`
} else if (!isOemDiag) {
if (truncatedDetail) msg += `故障信息:${truncatedDetail}\n`
msg += '\n📊 服务可用性各档位截止时间(基于月度内累计故障时长计算):\n\n'
msg += '\n📊 服务可用性各档位截止时间:\n\n'
const tierDesc: Record<string, string> = {
'第一档': '99%(含)以上(不扣费)',
'第二档': '99%-97%扣10%',

View File

@ -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, WebhookConfig } from './types'
import type { MonitorConfig } from './types'
import { formatBeijingTime } from './types'
const logger = {
@ -26,29 +26,6 @@ export class BackgroundWorker {
private lastTickTime = 0
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 {
if (this.cronJob) return
this.cronJob = cron.schedule('* * * * *', () => { this.tick() })
@ -92,15 +69,12 @@ export class BackgroundWorker {
// Step 4: 7 点 flush
if (this.reminderEngine.isTimeToFlush()) {
await this.reminderEngine.flushPendingMessages(
(text) => this.pushToAllWebhooks(text, config),
(ip, sn) => this.ticketProcessor.getRackPosition(ip, sn)
)
await this.reminderEngine.flushPendingMessages((text) => this.wechatPusher.pushText(text, config.wechat.webhook_url))
this.reminderEngine.markFlushed()
}
// Step 5-6: 检查提醒
const pushText = (text: string) => this.pushToAllWebhooks(text, config)
const pushText = (text: string) => this.wechatPusher.pushText(text, config.wechat.webhook_url)
const getRack = (ip: string | null, sn: string | null) => this.ticketProcessor.getRackPosition(ip, sn)
await this.reminderEngine.checkOemDiagReminders(pushText, config.monitor.push_delay_ms, getRack, config)
await this.reminderEngine.checkOemRepairReminders(pushText, config.monitor.push_delay_ms, getRack, config)
@ -149,7 +123,7 @@ export class BackgroundWorker {
// 推送微信
const message = this.wechatPusher.formatAvailabilityMessage(deadlines, faultInfo, type === 'oem_diag', oemDeadline, faultInfo.order_number, rackPosition, faultInfo.fault_detail)
await this.pushToAllWebhooks(message, config)
await this.wechatPusher.pushText(message, config.wechat.webhook_url)
// 记录故障
this.ticketProcessor.recordFault({
@ -216,80 +190,20 @@ 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
}[]
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 }[]
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)
// 发送结单提醒
await this.wechatPusher.pushText(`${fault.order_number}工单已结单\n\n服务器IP${fault.server_ip || '未知'}\n服务器SN${fault.server_sn}\n结单时间${recoveryTime}\n本次处理时长${Math.floor(duration / 3600)}小时${Math.floor((duration % 3600) / 60)}分钟`, config.wechat.webhook_url)
logger.info(`Recovery detected: ${fault.order_number}`)
} else {
logger.error(`Recovery push failed, will retry next tick: ${fault.order_number}`)
}
}
}
}

View File

@ -1,10 +1,9 @@
// issue-ai/src/middleware.ts — 使用共享 middleware 工厂
// 注意:移除 adminPathssettings 页面的权限由各 API 自行检查
// middleware 仅负责认证(登录检查),不负责角色鉴权
import { createMiddleware } from '@shared/lib/auth/middleware'
export const middleware = createMiddleware({
localCookieName: 'session_issue',
adminPaths: ['/settings'],
enableApiKey: true,
})