Compare commits
No commits in common. "5152d7d4f2e85eded4a7239bb7bdb13e5da76d9e" and "d64c95089452b39feeadce61dcdeb4130e003e55" have entirely different histories.
5152d7d4f2
...
d64c950894
|
|
@ -14,7 +14,6 @@
|
|||
"cookie": "^1.0.2",
|
||||
"docx": "^9.1.1",
|
||||
"echarts": "^5.5.0",
|
||||
"imap-simple": "^5.1.0",
|
||||
"imapflow": "^1.4.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
|
|
@ -3877,71 +3876,6 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/imap": {
|
||||
"version": "0.8.19",
|
||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
||||
"integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==",
|
||||
"dependencies": {
|
||||
"readable-stream": "1.1.x",
|
||||
"utf7": ">=1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imap-simple": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/imap-simple/-/imap-simple-5.1.0.tgz",
|
||||
"integrity": "sha512-FLZm1v38C5ekN46l/9X5gBRNMQNVc5TSLYQ3Hsq3xBLvKwt1i5fcuShyth8MYMPuvId1R46oaPNrH92hFGHr/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "~0.4.13",
|
||||
"imap": "^0.8.18",
|
||||
"nodeify": "^1.0.0",
|
||||
"quoted-printable": "^1.0.0",
|
||||
"utf8": "^2.1.1",
|
||||
"uuencode": "0.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/imap-simple/node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imap/node_modules/isarray": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
||||
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imap/node_modules/readable-stream": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
||||
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.1",
|
||||
"isarray": "0.0.1",
|
||||
"string_decoder": "~0.10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/imap/node_modules/string_decoder": {
|
||||
"version": "0.10.31",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
||||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz",
|
||||
|
|
@ -4098,12 +4032,6 @@
|
|||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-1.0.1.tgz",
|
||||
"integrity": "sha512-mjWH5XxnhMA8cFnDchr6qRP9S/kLntKuEfIYku+PaN1CnS8v+OG9O/BKpRCVRJvpIkgAZm0Pf5Is3iSSOILlcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
|
|
@ -5024,16 +4952,6 @@
|
|||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/nodeify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodeify/-/nodeify-1.0.1.tgz",
|
||||
"integrity": "sha512-n7C2NyEze8GCo/z73KdbjRsBiLbv6eBn1FxwYKQ23IqGo7pQY3mhQan61Sv7eEDJCiyUjTVrVkXTzJCo1dW7Aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-promise": "~1.0.0",
|
||||
"promise": "~1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||
|
|
@ -5547,15 +5465,6 @@
|
|||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/promise/-/promise-1.3.0.tgz",
|
||||
"integrity": "sha512-R9WrbTF3EPkVtWjp7B7umQGVndpsi+rsDAfrR4xAALQpFLa/+2OriecLhawxzvii2gd9+DZFwROWDuUUaqS5yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-promise": "~1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -5703,18 +5612,6 @@
|
|||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quoted-printable": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
|
||||
"integrity": "sha512-cihC68OcGiQOjGiXuo5Jk6XHANTHl1K4JLk/xlEJRTIXfy19Sg6XzB95XonYgr+1rB88bCpr7WZE7D7AlZow4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utf8": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"quoted-printable": "bin/quoted-printable"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
|
|
@ -6574,40 +6471,12 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf7": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
|
||||
"integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==",
|
||||
"dependencies": {
|
||||
"semver": "~5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf7/node_modules/semver": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
|
||||
"integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/utf8": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz",
|
||||
"integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuencode": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/uuencode/-/uuencode-0.0.4.tgz",
|
||||
"integrity": "sha512-yEEhCuCi5wRV7Z5ZVf9iV2gWMvUZqKJhAs1ecFdKJ0qzbyaVelmsE3QjYAamehfp9FKLiZbKldd+jklG3O0LfA=="
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
"cookie": "^1.0.2",
|
||||
"docx": "^9.1.1",
|
||||
"echarts": "^5.5.0",
|
||||
"imap-simple": "^5.1.0",
|
||||
"imapflow": "^1.4.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card } from '@/components/ui'
|
||||
import { Plus, Trash2, ChevronDown, ChevronUp, Search, RefreshCw } from 'lucide-react'
|
||||
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
interface MonitorStatus {
|
||||
enabled: boolean
|
||||
|
|
@ -37,31 +37,6 @@ interface MonitorConfig {
|
|||
wechat: { webhooks: WebhookConfig[] }
|
||||
}
|
||||
|
||||
interface ScanState {
|
||||
status: 'idle' | 'running' | 'completed' | 'error' | 'cancelling'
|
||||
startedAt?: string
|
||||
completedAt?: string
|
||||
timeRange?: { value: number | null; unit: string }
|
||||
stats?: {
|
||||
total: number
|
||||
matched: number
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: number
|
||||
}
|
||||
details?: {
|
||||
msg_id: string
|
||||
subject: string
|
||||
date: string
|
||||
order_number: string | null
|
||||
status: 'imported' | 'skipped' | 'error'
|
||||
ticket_no?: string
|
||||
error?: string
|
||||
}[]
|
||||
detailsTruncated?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor'
|
||||
|
||||
function generateId() {
|
||||
|
|
@ -81,278 +56,22 @@ export default function MonitorSettingsPage() {
|
|||
const [expandedWebhooks, setExpandedWebhooks] = useState<Set<string>>(new Set())
|
||||
const [testingWebhook, setTestingWebhook] = useState<string | null>(null)
|
||||
|
||||
// 立即检查冷却状态
|
||||
const [triggerCooldown, setTriggerCooldown] = useState(0)
|
||||
const cooldownTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 邮箱工单检测状态
|
||||
const [scanState, setScanState] = useState<ScanState>({ status: 'idle' })
|
||||
const [scanTimeValue, setScanTimeValue] = useState<number>(7)
|
||||
const [scanTimeUnit, setScanTimeUnit] = useState<string>('day')
|
||||
const scanTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
// 从 scanState.status 派生 scanning 状态
|
||||
const scanning = scanState.status === 'running' || scanState.status === 'cancelling'
|
||||
|
||||
// 扫描历史状态
|
||||
const [scanHistory, setScanHistory] = useState<{
|
||||
items: Array<{
|
||||
id: number
|
||||
status: string
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
time_range_value: number | null
|
||||
time_range_unit: string | null
|
||||
total_count: number
|
||||
matched_count: number
|
||||
imported_count: number
|
||||
skipped_count: number
|
||||
error_count: number
|
||||
details_truncated: number
|
||||
error_message: string | null
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
}>
|
||||
pagination: { page: number; limit: number; total: number; totalPages: number }
|
||||
} | null>(null)
|
||||
const [historyPage, setHistoryPage] = useState(1)
|
||||
const [loadingHistory, setLoadingHistory] = useState(false)
|
||||
|
||||
// 刷新扫描历史
|
||||
const refreshScanHistory = useCallback(async (page = 1) => {
|
||||
setLoadingHistory(true)
|
||||
try {
|
||||
const res = await fetch(`/api/monitor/scan-history?page=${page}&limit=10`)
|
||||
const data = await res.json()
|
||||
setScanHistory(data)
|
||||
setHistoryPage(page)
|
||||
} catch (err) {
|
||||
console.error('[Scan] 历史加载失败:', err)
|
||||
} finally {
|
||||
setLoadingHistory(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 自动刷新定时器
|
||||
const statusTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const errorTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 页面可见性状态
|
||||
const isVisibleRef = useRef(true)
|
||||
|
||||
// 刷新运行状态
|
||||
const refreshStatus = useCallback(() => {
|
||||
fetch('/api/monitor/status')
|
||||
.then(r => r.json())
|
||||
.then(setStatus)
|
||||
.catch(err => console.error('[Monitor] 状态刷新失败:', err))
|
||||
}, [])
|
||||
|
||||
// 刷新错误列表(更频繁)- 使用相同的 API,但只更新错误部分
|
||||
const refreshErrors = useCallback(() => {
|
||||
fetch('/api/monitor/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// 安全地合并错误列表,避免路径依赖
|
||||
setStatus(prev => {
|
||||
if (!prev || !data) return prev
|
||||
return {
|
||||
...prev,
|
||||
stats: data.stats || prev.stats,
|
||||
details: {
|
||||
...prev.details,
|
||||
recentErrors: data.details?.recentErrors ?? prev.details?.recentErrors ?? [],
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(err => console.error('[Monitor] 错误列表刷新失败:', err))
|
||||
}, [])
|
||||
|
||||
// 刷新扫描状态
|
||||
const refreshScanState = useCallback(() => {
|
||||
fetch('/api/monitor/scan-status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setScanState(data)
|
||||
// 扫描结束时停止轮询(scanning 从 status 派生)
|
||||
if (data.status !== 'running' && data.status !== 'cancelling') {
|
||||
if (scanTimerRef.current) {
|
||||
clearInterval(scanTimerRef.current)
|
||||
scanTimerRef.current = null
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[Scan] 状态刷新失败:', err)
|
||||
// 网络异常时不停止轮询,等待下次重试
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 页面可见性变化
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
isVisibleRef.current = !document.hidden
|
||||
if (isVisibleRef.current) {
|
||||
// 页面恢复可见,立即刷新
|
||||
refreshStatus()
|
||||
refreshErrors()
|
||||
if (scanning) refreshScanState()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', handler)
|
||||
return () => document.removeEventListener('visibilitychange', handler)
|
||||
}, [scanning, refreshStatus, refreshErrors, refreshScanState])
|
||||
|
||||
// 自动刷新定时器
|
||||
useEffect(() => {
|
||||
// 运行状态:60 秒刷新
|
||||
statusTimerRef.current = setInterval(() => {
|
||||
if (isVisibleRef.current) refreshStatus()
|
||||
}, 60000)
|
||||
|
||||
// 错误列表:30 秒刷新
|
||||
errorTimerRef.current = setInterval(() => {
|
||||
if (isVisibleRef.current) refreshErrors()
|
||||
}, 30000)
|
||||
|
||||
return () => {
|
||||
if (statusTimerRef.current) clearInterval(statusTimerRef.current)
|
||||
if (errorTimerRef.current) clearInterval(errorTimerRef.current)
|
||||
}
|
||||
}, [refreshStatus, refreshErrors])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/monitor/status').then(r => r.json()),
|
||||
fetch('/api/monitor/settings').then(r => r.json()),
|
||||
fetch('/api/monitor/scan-status').then(r => r.json()),
|
||||
fetch('/api/monitor/scan-history?page=1&limit=10').then(r => r.json()),
|
||||
]).then(([s, c, scan, history]) => {
|
||||
]).then(([s, c]) => {
|
||||
setStatus(s)
|
||||
setConfig(c)
|
||||
setOriginalConfig(JSON.parse(JSON.stringify(c)))
|
||||
setScanState(scan)
|
||||
setScanHistory(history)
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// 立即检查处理
|
||||
const handleTrigger = async () => {
|
||||
if (triggerCooldown > 0) return
|
||||
|
||||
const res = await fetch('/api/monitor/trigger', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ type: 'success', text: data.message })
|
||||
setTimeout(refreshStatus, 2000)
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error })
|
||||
}
|
||||
|
||||
// 启动 30 秒冷却
|
||||
setTriggerCooldown(30)
|
||||
cooldownTimerRef.current = setInterval(() => {
|
||||
setTriggerCooldown(prev => {
|
||||
if (prev <= 1) {
|
||||
if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
const refreshStatus = () => {
|
||||
fetch('/api/monitor/status').then(r => r.json()).then(setStatus)
|
||||
}
|
||||
|
||||
// 清理冷却定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 启动扫描
|
||||
const handleStartScan = async () => {
|
||||
if (scanning) return
|
||||
|
||||
setScanState({ status: 'running' })
|
||||
|
||||
const res = await fetch('/api/monitor/scan-emails', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ timeRange: { value: scanTimeValue, unit: scanTimeUnit } }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (!data.success) {
|
||||
setMessage({ type: 'error', text: data.error })
|
||||
setScanState({ status: 'error', error: data.error })
|
||||
return
|
||||
}
|
||||
|
||||
// 启动轮询
|
||||
scanTimerRef.current = setInterval(refreshScanState, 3000)
|
||||
}
|
||||
|
||||
// 取消扫描
|
||||
const handleCancelScan = async () => {
|
||||
const res = await fetch('/api/monitor/scan-status', { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ type: 'success', text: data.message })
|
||||
setScanState(prev => ({ ...prev, status: 'cancelling' }))
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error })
|
||||
}
|
||||
}
|
||||
|
||||
// 导入跳过的邮件为工单
|
||||
const handleImportSkipped = async () => {
|
||||
if (!scanState.details || scanState.status !== 'completed') return
|
||||
|
||||
const skippedEmails = scanState.details.filter(d =>
|
||||
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
|
||||
)
|
||||
|
||||
if (skippedEmails.length === 0) {
|
||||
setMessage({ type: 'error', text: '没有可导入的邮件(已跳过的邮件需要有工单号)' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`确认导入 ${skippedEmails.length} 封邮件为工单?`)) return
|
||||
|
||||
setImporting(true)
|
||||
try {
|
||||
const res = await fetch('/api/monitor/scan-emails/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ emails: skippedEmails }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ type: 'success', text: data.message })
|
||||
// 刷新扫描状态
|
||||
refreshScanState()
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error })
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '导入失败' })
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理扫描定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scanTimerRef.current) clearInterval(scanTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 其他原有函数保持不变...
|
||||
const handleStart = async () => {
|
||||
await fetch('/api/monitor/start', { method: 'POST' })
|
||||
refreshStatus()
|
||||
|
|
@ -366,6 +85,17 @@ export default function MonitorSettingsPage() {
|
|||
setMessage({ type: 'success', text: '监控已停用' })
|
||||
}
|
||||
|
||||
const handleTrigger = async () => {
|
||||
const res = await fetch('/api/monitor/trigger', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ type: 'success', text: data.message })
|
||||
setTimeout(refreshStatus, 2000)
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestMail = async () => {
|
||||
setSectionMessage(prev => ({ ...prev, mail: { type: 'success', text: '正在测试邮箱连接...' } }))
|
||||
const res = await fetch('/api/monitor/test-mail', { method: 'POST' })
|
||||
|
|
@ -420,6 +150,8 @@ 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 = {
|
||||
|
|
@ -431,12 +163,13 @@ export default function MonitorSettingsPage() {
|
|||
}
|
||||
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) => {
|
||||
if (!config) return
|
||||
if (config.wechat.webhooks.length <= 1) return
|
||||
if (config.wechat.webhooks.length <= 1) return // 至少保留一个
|
||||
setConfig({ ...config, wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } })
|
||||
}
|
||||
|
||||
|
|
@ -461,14 +194,17 @@ export default function MonitorSettingsPage() {
|
|||
})
|
||||
}
|
||||
|
||||
// 检查某个 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
|
||||
|
|
@ -664,13 +400,7 @@ export default function MonitorSettingsPage() {
|
|||
|
||||
<div className="flex gap-2">
|
||||
{status?.enabled ? <button onClick={handleStop} className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm">停止监控</button> : <button onClick={handleStart} className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm">启动监控</button>}
|
||||
<button
|
||||
onClick={handleTrigger}
|
||||
disabled={triggerCooldown > 0}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{triggerCooldown > 0 ? `请等待 ${triggerCooldown} 秒` : '立即检查'}
|
||||
</button>
|
||||
<button onClick={handleTrigger} className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm">立即检查</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
@ -741,202 +471,6 @@ export default function MonitorSettingsPage() {
|
|||
<SectionSaveButton section="filter" />
|
||||
</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>
|
||||
|
||||
{/* 时间范围选择 */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">时间范围:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={scanTimeValue}
|
||||
onChange={e => setScanTimeValue(parseInt(e.target.value) || 1)}
|
||||
className="w-20 px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||||
min={1}
|
||||
max={365}
|
||||
disabled={scanning}
|
||||
/>
|
||||
<select
|
||||
value={scanTimeUnit}
|
||||
onChange={e => setScanTimeUnit(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||||
disabled={scanning}
|
||||
>
|
||||
<option value="minute">分钟</option>
|
||||
<option value="hour">小时</option>
|
||||
<option value="day">天</option>
|
||||
<option value="week">周</option>
|
||||
<option value="month">月</option>
|
||||
<option value="all">全部</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleStartScan}
|
||||
disabled={scanning}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||||
>
|
||||
{scanning ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
扫描中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search size={14} />
|
||||
开始扫描
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{scanning && (
|
||||
<button
|
||||
onClick={handleCancelScan}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm inline-flex items-center gap-2"
|
||||
>
|
||||
取消扫描
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 扫描进度 */}
|
||||
{scanState.status === 'running' && (
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-700 dark:text-blue-400">
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
扫描进行中...
|
||||
</div>
|
||||
{scanState.stats && (
|
||||
<div className="mt-2 text-xs text-slate-600 dark:text-slate-400">
|
||||
已扫描 {scanState.stats.total} 封,导入 {scanState.stats.imported} 封,跳过 {scanState.stats.skipped} 封
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 扫描取消中 */}
|
||||
{scanState.status === 'cancelling' && (
|
||||
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-400">
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
正在取消扫描...
|
||||
</div>
|
||||
{scanState.stats && (
|
||||
<div className="mt-2 text-xs text-slate-600 dark:text-slate-400">
|
||||
已扫描 {scanState.stats.total} 封,已处理 {scanState.stats.imported + scanState.stats.skipped} 封
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 扫描结果 */}
|
||||
{(scanState.status === 'completed' || scanState.status === 'cancelling') && scanState.stats && (
|
||||
<div className="space-y-4">
|
||||
{scanState.error && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
|
||||
{scanState.error}
|
||||
</div>
|
||||
)}
|
||||
{scanState.detailsTruncated && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
|
||||
详情列表已截断(最多显示 500 条),完整统计请查看服务器日志
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-5 gap-4 text-sm">
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<span className="text-slate-500">已扫描</span>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{scanState.stats.total}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<span className="text-slate-500">匹配工单号</span>
|
||||
<p className="text-xl font-bold text-blue-600">{scanState.stats.matched}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<span className="text-green-600">已导入</span>
|
||||
<p className="text-xl font-bold text-green-600">{scanState.stats.imported}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<span className="text-slate-500">已跳过</span>
|
||||
<p className="text-xl font-bold text-slate-600">{scanState.stats.skipped}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<span className="text-red-600">错误</span>
|
||||
<p className="text-xl font-bold text-red-600">{scanState.stats.errors}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细结果列表 */}
|
||||
{scanState.details && scanState.details.length > 0 && (
|
||||
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
|
||||
<tr>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">工单号</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">主题</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">状态</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scanState.details.map((item, i) => (
|
||||
<tr key={i} className="border-t border-slate-100 dark:border-slate-800">
|
||||
<td className="py-2 px-4 font-mono text-xs">{item.order_number || '-'}</td>
|
||||
<td className="py-2 px-4 text-xs max-w-xs truncate" title={item.subject}>{item.subject || '-'}</td>
|
||||
<td className="py-2 px-4">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
item.status === 'imported' ? 'bg-green-100 text-green-700' :
|
||||
item.status === 'skipped' ? 'bg-slate-100 text-slate-600' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{item.status === 'imported' ? '已导入' : item.status === 'skipped' ? '已跳过' : '错误'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-4 text-xs text-slate-500">{item.error || item.ticket_no || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导入按钮 */}
|
||||
{scanState.status === 'completed' && scanState.details && scanState.details.some(d =>
|
||||
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
|
||||
) && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleImportSkipped}
|
||||
disabled={importing}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm disabled:opacity-50 inline-flex items-center gap-2"
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
导入中...
|
||||
</>
|
||||
) : (
|
||||
'导入跳过的邮件为工单'
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-slate-500">
|
||||
可导入 {scanState.details.filter(d =>
|
||||
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
|
||||
).length} 封邮件
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 扫描错误 */}
|
||||
{scanState.status === 'error' && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-700 dark:text-red-400">
|
||||
扫描失败: {scanState.error || '未知错误'}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 微信推送 - 多 Webhook */}
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
|
@ -958,6 +492,7 @@ export default function MonitorSettingsPage() {
|
|||
<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)}
|
||||
|
|
@ -996,6 +531,7 @@ export default function MonitorSettingsPage() {
|
|||
)}
|
||||
</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>
|
||||
|
|
@ -1052,7 +588,7 @@ export default function MonitorSettingsPage() {
|
|||
<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>
|
||||
|
|
@ -1072,102 +608,6 @@ export default function MonitorSettingsPage() {
|
|||
</div>
|
||||
<SectionSaveButton section="monitor" />
|
||||
</Card>
|
||||
|
||||
{/* 扫描历史 */}
|
||||
<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={() => refreshScanHistory(1)}
|
||||
disabled={loadingHistory}
|
||||
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm inline-flex items-center gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={loadingHistory ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scanHistory && scanHistory.items.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">时间</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">范围</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">状态</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">扫描</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">导入</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">跳过</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">错误</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">操作人</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scanHistory.items.map((item) => (
|
||||
<tr key={item.id} className="border-t border-slate-100 dark:border-slate-800">
|
||||
<td className="py-2 px-4 text-xs">{item.started_at}</td>
|
||||
<td className="py-2 px-4 text-xs">
|
||||
{item.time_range_unit === 'all' ? '全部' : `${item.time_range_value || ''}${item.time_range_unit === 'minute' ? '分钟' : item.time_range_unit === 'hour' ? '小时' : item.time_range_unit === 'day' ? '天' : item.time_range_unit === 'week' ? '周' : '月'}`}
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
item.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
item.status === 'error' ? 'bg-red-100 text-red-700' :
|
||||
item.status === 'cancelling' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{item.status === 'completed' ? '完成' : item.status === 'error' ? '错误' : item.status === 'cancelling' ? '已取消' : item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-4 text-xs font-medium">{item.total_count}</td>
|
||||
<td className="py-2 px-4 text-xs text-green-600">{item.imported_count}</td>
|
||||
<td className="py-2 px-4 text-xs text-slate-500">{item.skipped_count}</td>
|
||||
<td className="py-2 px-4 text-xs text-red-600">{item.error_count}</td>
|
||||
<td className="py-2 px-4 text-xs text-slate-500">{item.created_by || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{scanHistory.pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">
|
||||
共 {scanHistory.pagination.total} 条记录
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => refreshScanHistory(historyPage - 1)}
|
||||
disabled={historyPage <= 1 || loadingHistory}
|
||||
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-slate-500">
|
||||
{historyPage} / {scanHistory.pagination.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => refreshScanHistory(historyPage + 1)}
|
||||
disabled={historyPage >= scanHistory.pagination.totalPages || loadingHistory}
|
||||
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 text-center py-4">
|
||||
{loadingHistory ? '加载中...' : '暂无扫描历史'}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
// src/app/api/monitor/scan-emails/import/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
|
||||
|
||||
interface ImportEmail {
|
||||
msg_id: string
|
||||
subject: string
|
||||
date: string
|
||||
order_number: string | null
|
||||
status: string
|
||||
ticket_no?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const clientIP = getClientIP(request)
|
||||
const body = await request.json()
|
||||
const { emails } = body as { emails: ImportEmail[] }
|
||||
|
||||
if (!emails || !Array.isArray(emails) || emails.length === 0) {
|
||||
return NextResponse.json({ error: '没有可导入的邮件' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
let errors = 0
|
||||
const results: { order_number: string; status: string; error?: string }[] = []
|
||||
|
||||
for (const email of emails) {
|
||||
if (!email.order_number) {
|
||||
skipped++
|
||||
results.push({
|
||||
order_number: '-',
|
||||
status: 'skipped',
|
||||
error: '无工单号',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const ticketId = parseInt(email.order_number)
|
||||
|
||||
// 检查工单是否已存在
|
||||
const existing = db.prepare('SELECT id FROM tickets WHERE id = ?').get(ticketId)
|
||||
if (existing) {
|
||||
skipped++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'skipped',
|
||||
error: '工单已存在',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已处理过
|
||||
const processed = db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(email.msg_id)
|
||||
if (processed) {
|
||||
skipped++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'skipped',
|
||||
error: '邮件已处理过',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建工单
|
||||
db.prepare(`
|
||||
INSERT INTO tickets (id, content, current_status, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, 'open', ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
|
||||
`).run(ticketId, `从邮件导入:${email.subject}`, user.id)
|
||||
|
||||
// 记录已处理邮件
|
||||
db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(email.msg_id, email.subject)
|
||||
|
||||
// 审计日志
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'import',
|
||||
entityType: 'ticket',
|
||||
entityId: ticketId,
|
||||
details: { created: { ticket_no: email.order_number, source: 'email_import' } },
|
||||
ipAddress: clientIP,
|
||||
})
|
||||
|
||||
imported++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'imported',
|
||||
})
|
||||
|
||||
void notifyTicketCreated({
|
||||
ticket_no: email.order_number,
|
||||
device_ip: null,
|
||||
device_sn: null,
|
||||
device_name: null,
|
||||
ticket_type: null,
|
||||
content: `从邮件导入:${email.subject}`,
|
||||
assign_time: null,
|
||||
}).catch(e => console.error('[scan-import] notify 失败:', e))
|
||||
} catch (err) {
|
||||
errors++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : '导入失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `导入完成:成功 ${imported} 封,跳过 ${skipped} 封,错误 ${errors} 封`,
|
||||
imported,
|
||||
skipped,
|
||||
errors,
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,644 +0,0 @@
|
|||
// src/app/api/monitor/scan-emails/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||
import Imap from 'imap'
|
||||
import { simpleParser } from 'mailparser'
|
||||
import * as cheerio from 'cheerio'
|
||||
import { formatBeijingTime } from '@/lib/monitor/types'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getScanState, setScanState, isCancelRequested, resetCancelFlag } from '@/lib/monitor/scan-state'
|
||||
import { fetchPOP3Emails, normalizeMessageId } from '@/lib/monitor/pop3-fetcher'
|
||||
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
|
||||
|
||||
interface ScanResult {
|
||||
status: 'running' | 'completed' | 'error' | 'cancelling'
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
timeRange: { value: number | null; unit: string }
|
||||
stats: {
|
||||
total: number
|
||||
matched: number
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: number
|
||||
}
|
||||
details: {
|
||||
msg_id: string
|
||||
subject: string
|
||||
date: string
|
||||
order_number: string | null
|
||||
status: 'imported' | 'skipped' | 'error'
|
||||
ticket_no?: string
|
||||
error?: string
|
||||
}[]
|
||||
detailsTruncated: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const VALID_UNITS = ['minute', 'hour', 'day', 'week', 'month', 'all']
|
||||
const MAX_VALUE = 365
|
||||
const MAX_DETAILS = 500
|
||||
const SCAN_TIMEOUT_MS = 5 * 60 * 1000 // 5 分钟超时
|
||||
|
||||
// 辅助函数:添加 detail 并追踪是否被截断
|
||||
function addDetail(
|
||||
scanState: ScanResult,
|
||||
detail: ScanResult['details'][0],
|
||||
counter: { total: number }
|
||||
): void {
|
||||
counter.total++
|
||||
if (scanState.details.length < MAX_DETAILS) {
|
||||
scanState.details.push(detail)
|
||||
} else {
|
||||
scanState.detailsTruncated = true
|
||||
}
|
||||
}
|
||||
|
||||
function extractOrderNumber(subject: string): string | null {
|
||||
const match = subject.match(/【服务器故障单】(\d+),/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function getDateRange(value: number | null, unit: string): Date {
|
||||
const now = new Date()
|
||||
if (!value || unit === 'all') {
|
||||
// 全部:搜索最近 30 天
|
||||
now.setDate(now.getDate() - 30)
|
||||
return now
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case 'minute':
|
||||
now.setMinutes(now.getMinutes() - value)
|
||||
break
|
||||
case 'hour':
|
||||
now.setHours(now.getHours() - value)
|
||||
break
|
||||
case 'day':
|
||||
now.setDate(now.getDate() - value)
|
||||
break
|
||||
case 'week':
|
||||
now.setDate(now.getDate() - value * 7)
|
||||
break
|
||||
case 'month':
|
||||
now.setMonth(now.getMonth() - value)
|
||||
break
|
||||
default:
|
||||
now.setDate(now.getDate() - 7)
|
||||
}
|
||||
return now
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
// 检查是否有正在运行的扫描(包括取消中的扫描)
|
||||
const currentScan = getScanState()
|
||||
if (currentScan?.status === 'running' || currentScan?.status === 'cancelling') {
|
||||
return NextResponse.json({ error: '扫描正在进行中,请稍后再试' }, { status: 409 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { value, unit } = body.timeRange || { value: 7, unit: 'day' }
|
||||
|
||||
// 输入验证
|
||||
if (unit !== 'all') {
|
||||
if (typeof value !== 'number' || value < 1 || value > MAX_VALUE) {
|
||||
return NextResponse.json({ error: `时间范围值必须在 1-${MAX_VALUE} 之间` }, { status: 400 })
|
||||
}
|
||||
}
|
||||
if (!VALID_UNITS.includes(unit)) {
|
||||
return NextResponse.json({ error: `无效的时间单位: ${unit}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const config = getMonitorConfig()
|
||||
const db = getDb()
|
||||
const since = getDateRange(value, unit)
|
||||
|
||||
// 初始化扫描结果
|
||||
const scanState: ScanResult = {
|
||||
status: 'running',
|
||||
startedAt: formatBeijingTime(),
|
||||
timeRange: { value, unit },
|
||||
stats: { total: 0, matched: 0, imported: 0, skipped: 0, errors: 0 },
|
||||
details: [],
|
||||
detailsTruncated: false,
|
||||
}
|
||||
setScanState(scanState)
|
||||
|
||||
// 重置取消标志
|
||||
resetCancelFlag()
|
||||
|
||||
// 异步执行扫描
|
||||
scanEmails(config, db, since, scanState, clientIP, user.id).catch(err => {
|
||||
// 内层已设置错误状态,此处不再重复设置
|
||||
console.error('[Scan] Unexpected error:', err)
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: '扫描已开始' })
|
||||
}
|
||||
|
||||
async function scanEmails(
|
||||
config: ReturnType<typeof getMonitorConfig>,
|
||||
db: ReturnType<typeof getDb>,
|
||||
since: Date,
|
||||
scanState: ScanResult,
|
||||
clientIP: string | null,
|
||||
userId: number
|
||||
): Promise<void> {
|
||||
// 使用 imap 库(与 Python 版本兼容)替代 imapflow
|
||||
// imapflow 对单部分 text/html 邮件的 source 获取有 bug
|
||||
const imap = new Imap({
|
||||
user: config.mail.address,
|
||||
password: config.mail.password,
|
||||
host: config.mail.imap_server,
|
||||
port: config.mail.imap_port,
|
||||
tls: true,
|
||||
connTimeout: 15000,
|
||||
authTimeout: 15000,
|
||||
})
|
||||
|
||||
// 整体超时控制
|
||||
const startTime = Date.now()
|
||||
const checkTimeout = (): boolean => {
|
||||
if (Date.now() - startTime > SCAN_TIMEOUT_MS) {
|
||||
scanState.status = 'error'
|
||||
scanState.completedAt = formatBeijingTime()
|
||||
scanState.error = `扫描超时(超过 ${SCAN_TIMEOUT_MS / 60000} 分钟)`
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 将 IMAP 回调风格封装为 Promise
|
||||
function imapConnect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
imap.once('ready', () => resolve())
|
||||
imap.once('error', (err: Error) => reject(err))
|
||||
imap.connect()
|
||||
})
|
||||
}
|
||||
|
||||
function imapOpenBox(boxName: string, readOnly: boolean): Promise<Imap.Box> {
|
||||
return new Promise((resolve, reject) => {
|
||||
imap.openBox(boxName, readOnly, (err: Error | null, box: Imap.Box) => {
|
||||
if (err) reject(err)
|
||||
else resolve(box)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function imapSearch(criteria: any[]): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// imap.search 返回 UID(imap.fetch 默认也使用 UID)
|
||||
imap.search(criteria, (err: Error | null, results: number[]) => {
|
||||
if (err) reject(err)
|
||||
else resolve(results)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 获取单封邮件的原始内容(使用 UID 获取)
|
||||
function fetchEmailRaw(uid: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`[Scan] fetchEmailRaw(uid=${uid}) 开始获取`)
|
||||
// imap.fetch 默认使用 UID,无需 uid: true
|
||||
const fetch = imap.fetch(uid, { bodies: '', markSeen: false })
|
||||
let emailBuffer = Buffer.alloc(0)
|
||||
let messageCount = 0
|
||||
let bodyCount = 0
|
||||
|
||||
fetch.on('message', (msg: any, seqno: number) => {
|
||||
messageCount++
|
||||
console.log(`[Scan] fetchEmailRaw(uid=${uid}) message 事件触发, seqno=${seqno}`)
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
bodyCount++
|
||||
console.log(`[Scan] fetchEmailRaw(uid=${uid}) body 事件触发, info=${JSON.stringify(info)}`)
|
||||
const chunks: Buffer[] = []
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk)
|
||||
console.log(`[Scan] fetchEmailRaw(uid=${uid}) 收到数据块, 大小=${chunk.length}`)
|
||||
})
|
||||
stream.on('end', () => {
|
||||
emailBuffer = Buffer.concat(chunks)
|
||||
console.log(`[Scan] fetchEmailRaw(uid=${uid}) body 结束, 总大小=${emailBuffer.length}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
fetch.once('end', () => {
|
||||
console.log(`[Scan] fetchEmailRaw(uid=${uid}) fetch 结束, messageCount=${messageCount}, bodyCount=${bodyCount}, 最终大小=${emailBuffer.length}`)
|
||||
resolve(emailBuffer)
|
||||
})
|
||||
fetch.once('error', (err: Error) => {
|
||||
console.error(`[Scan] fetchEmailRaw(uid=${uid}) fetch 错误:`, err.message)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 通过 IMAP ENVELOPE 获取邮件的 Message-ID(BODY 为空时 ENVELOPE 仍可用)
|
||||
// 用于与 POP3 邮件精确匹配,避免脆弱的位置对齐
|
||||
// 注意:只请求 envelope,不能带 bodies(BODY 为空时 attributes 事件不会触发)
|
||||
function fetchEmailMessageId(uid: number): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let messageId: string | null = null
|
||||
try {
|
||||
const fetch = imap.fetch(uid, { envelope: true } as any)
|
||||
fetch.on('message', (msg: any) => {
|
||||
msg.on('attributes', (attrs: any) => {
|
||||
messageId = attrs?.envelope?.messageId || null
|
||||
})
|
||||
})
|
||||
fetch.once('end', () => resolve(normalizeMessageId(messageId)))
|
||||
fetch.once('error', () => resolve(null))
|
||||
} catch {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const detailsCounter = { total: 0 }
|
||||
|
||||
try {
|
||||
await imapConnect()
|
||||
console.log('[Scan] IMAP 连接成功')
|
||||
await imapOpenBox('INBOX', true)
|
||||
|
||||
// 搜索邮件:将 since 日期转换为 IMAP SINCE 格式
|
||||
const sinceStr = since.toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
const uids = await imapSearch([['SINCE', sinceStr]])
|
||||
console.log(`[Scan] 搜索条件 SINCE "${sinceStr}",找到 ${uids.length} 封邮件`)
|
||||
|
||||
if (!uids || uids.length === 0) {
|
||||
scanState.status = 'completed'
|
||||
scanState.completedAt = formatBeijingTime()
|
||||
imap.end()
|
||||
saveScanHistory(scanState, userId)
|
||||
return
|
||||
}
|
||||
|
||||
scanState.stats.total = uids.length
|
||||
|
||||
// POP3 fallback 状态:IMAP 近期邮件 BODY 为空时自动切换
|
||||
// pop3Map: Message-ID → 原始邮件 Buffer;pop3Attempted: 是否已尝试(无论成败,避免重连风暴)
|
||||
let pop3Map: Map<string, Buffer> = new Map()
|
||||
let pop3Attempted = false
|
||||
|
||||
// 逐封处理邮件
|
||||
for (let idx = 0; idx < uids.length; idx++) {
|
||||
const uid = uids[idx]
|
||||
if (isCancelRequested()) {
|
||||
scanState.status = 'completed'
|
||||
scanState.completedAt = formatBeijingTime()
|
||||
scanState.error = '用户取消'
|
||||
break
|
||||
}
|
||||
if (checkTimeout()) break
|
||||
|
||||
try {
|
||||
// 获取完整邮件原始内容(RFC822 格式),优先 IMAP,失败时 POP3 fallback
|
||||
let rawEmail: Buffer | null = await fetchEmailRaw(uid)
|
||||
|
||||
// IMAP BODY 为空时,首次触发 POP3 fallback(一次性拉取所有邮件建立 Message-ID 索引)
|
||||
if ((!rawEmail || rawEmail.length === 0) && !pop3Attempted) {
|
||||
console.log(`[Scan] IMAP 返回空 (uid=${uid}),启用 POP3 fallback`)
|
||||
pop3Attempted = true // 无论成败都置位,避免每封空邮件都重连
|
||||
try {
|
||||
pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length)
|
||||
} catch (pop3Err) {
|
||||
console.error('[Scan] POP3 fallback 失败:', pop3Err instanceof Error ? pop3Err.message : pop3Err)
|
||||
}
|
||||
}
|
||||
|
||||
// 从 POP3 缓存中按 Message-ID 精确匹配(通过 IMAP ENVELOPE 获取当前 UID 的 Message-ID)
|
||||
if ((!rawEmail || rawEmail.length === 0) && pop3Map.size > 0) {
|
||||
const targetMsgId = await fetchEmailMessageId(uid)
|
||||
if (targetMsgId && pop3Map.has(targetMsgId)) {
|
||||
rawEmail = pop3Map.get(targetMsgId)!
|
||||
console.log(`[Scan] POP3 fallback 命中: uid=${uid}, msgId=${targetMsgId}, size=${rawEmail.length}`)
|
||||
} else {
|
||||
console.log(`[Scan] POP3 fallback 未命中: uid=${uid}, msgId=${targetMsgId || '(未获取到)'}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawEmail || rawEmail.length === 0) {
|
||||
console.log(`[Scan] 邮件 ${uid} 内容为空(IMAP + POP3 均失败),跳过`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用 mailparser 解析邮件
|
||||
const parsed = await simpleParser(rawEmail)
|
||||
const subject = parsed.subject || ''
|
||||
const from = parsed.from?.text || ''
|
||||
const date = parsed.date ? formatBeijingTime(parsed.date) : formatBeijingTime()
|
||||
const msgId = parsed.messageId || String(uid)
|
||||
|
||||
console.log(`[Scan] 邮件 ${uid}: subject="${subject}", from="${from}", 大小=${rawEmail.length}`)
|
||||
|
||||
// 邮件过滤:检查发件人是否匹配
|
||||
if (config.filter.sender_email) {
|
||||
const senderMatch = from.toLowerCase().includes(config.filter.sender_email.toLowerCase())
|
||||
if (!senderMatch) {
|
||||
console.log(`[Scan] 邮件 ${uid}: 发件人不匹配,跳过`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 邮件过滤:检查主题关键词是否匹配
|
||||
if (config.filter.subject_keywords && config.filter.subject_keywords.length > 0) {
|
||||
const keywordMatch = config.filter.subject_keywords.some(kw =>
|
||||
subject.toLowerCase().includes(kw.toLowerCase())
|
||||
)
|
||||
if (!keywordMatch) {
|
||||
console.log(`[Scan] 邮件 ${uid}: 主题关键词不匹配,跳过`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 提取工单号
|
||||
const orderNumber = extractOrderNumber(subject)
|
||||
if (!orderNumber) {
|
||||
scanState.stats.matched++
|
||||
addDetail(scanState, {
|
||||
msg_id: msgId, subject, date,
|
||||
order_number: null, status: 'skipped', error: '无法提取工单号',
|
||||
}, detailsCounter)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查工单是否已存在
|
||||
const existing = db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(orderNumber))
|
||||
if (existing) {
|
||||
scanState.stats.skipped++
|
||||
scanState.stats.matched++
|
||||
addDetail(scanState, {
|
||||
msg_id: msgId, subject, date,
|
||||
order_number: orderNumber, status: 'skipped', ticket_no: orderNumber, error: '工单已存在',
|
||||
}, detailsCounter)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已处理过
|
||||
const processed = db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(msgId)
|
||||
if (processed) {
|
||||
scanState.stats.skipped++
|
||||
scanState.stats.matched++
|
||||
addDetail(scanState, {
|
||||
msg_id: msgId, subject, date,
|
||||
order_number: orderNumber, status: 'skipped', error: '邮件已处理过',
|
||||
}, detailsCounter)
|
||||
continue
|
||||
}
|
||||
|
||||
// 从邮件 HTML 正文中提取故障信息(参考 Python 脚本的 extract_fault_info)
|
||||
const htmlContent = parsed.html || ''
|
||||
const ticketInfo = buildTicketContent(subject, orderNumber, htmlContent)
|
||||
|
||||
// 根据设备 IP 查询 assets 站点获取节点名称
|
||||
if (ticketInfo.device_ip) {
|
||||
try {
|
||||
const assetsUrl = process.env.ASSETS_URL || 'https://assets.tlyq.ai'
|
||||
const assetsApiKey = process.env.ASSETS_API_KEY || ''
|
||||
if (!assetsApiKey) {
|
||||
console.log(`[Scan] 跳过设备名称查询: ASSETS_API_KEY 未配置`)
|
||||
} else {
|
||||
const headers: Record<string, string> = { 'Authorization': `Bearer ${assetsApiKey}` }
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
try {
|
||||
const assetsResp = await fetch(`${assetsUrl}/api/assets?filter_business_ip=${encodeURIComponent(ticketInfo.device_ip)}`, { headers, signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
if (assetsResp.ok) {
|
||||
const assetsData = await assetsResp.json()
|
||||
if (assetsData.data && assetsData.data.length > 0) {
|
||||
ticketInfo.device_name = assetsData.data[0].node_name || null
|
||||
console.log(`[Scan] 设备 IP ${ticketInfo.device_ip} → 节点名称: ${ticketInfo.device_name}`)
|
||||
}
|
||||
} else {
|
||||
console.error(`[Scan] assets API 返回 ${assetsResp.status} (IP: ${ticketInfo.device_ip})`)
|
||||
}
|
||||
} catch (fetchErr) {
|
||||
clearTimeout(timeout)
|
||||
throw fetchErr
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Scan] 查询设备名称失败 (IP: ${ticketInfo.device_ip}): ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建工单,填入提取的字段
|
||||
const ticketId = parseInt(orderNumber)
|
||||
db.prepare(`
|
||||
INSERT INTO tickets (id, content, device_ip, device_sn, device_name, ticket_type, assign_time, current_status, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
|
||||
`).run(ticketId, ticketInfo.content, ticketInfo.device_ip, ticketInfo.device_sn, ticketInfo.device_name, ticketInfo.ticket_type, ticketInfo.assign_time, userId)
|
||||
|
||||
db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(msgId, subject)
|
||||
|
||||
writeAuditLog({
|
||||
userId, apiKeyId: null, action: 'import', entityType: 'ticket', entityId: ticketId,
|
||||
details: { created: { ticket_no: orderNumber, source: 'email_scan' } },
|
||||
ipAddress: clientIP,
|
||||
})
|
||||
|
||||
void notifyTicketCreated({
|
||||
ticket_no: orderNumber,
|
||||
device_ip: ticketInfo.device_ip,
|
||||
device_sn: ticketInfo.device_sn,
|
||||
device_name: ticketInfo.device_name,
|
||||
ticket_type: ticketInfo.ticket_type,
|
||||
content: ticketInfo.content,
|
||||
assign_time: ticketInfo.assign_time,
|
||||
}).catch(e => console.error('[scan-emails] notify 失败:', e))
|
||||
|
||||
scanState.stats.imported++
|
||||
scanState.stats.matched++
|
||||
addDetail(scanState, {
|
||||
msg_id: msgId, subject, date,
|
||||
order_number: orderNumber, status: 'imported', ticket_no: orderNumber,
|
||||
}, detailsCounter)
|
||||
} catch (err) {
|
||||
scanState.stats.errors++
|
||||
addDetail(scanState, {
|
||||
msg_id: String(uid), subject: '', date: '',
|
||||
order_number: null, status: 'error',
|
||||
error: err instanceof Error ? err.message : '处理失败',
|
||||
}, detailsCounter)
|
||||
}
|
||||
}
|
||||
|
||||
if (scanState.detailsTruncated) {
|
||||
console.log(`[Scan] 详情列表已截断: 共 ${detailsCounter.total} 条,仅保存前 ${MAX_DETAILS} 条`)
|
||||
}
|
||||
|
||||
if (scanState.status === 'running') {
|
||||
scanState.status = 'completed'
|
||||
scanState.completedAt = formatBeijingTime()
|
||||
}
|
||||
} catch (err) {
|
||||
scanState.status = 'error'
|
||||
scanState.completedAt = formatBeijingTime()
|
||||
scanState.error = err instanceof Error ? err.message : '连接失败'
|
||||
} finally {
|
||||
try { imap.end() } catch {}
|
||||
}
|
||||
|
||||
saveScanHistory(scanState, userId)
|
||||
}
|
||||
|
||||
// 提取的邮件信息结构
|
||||
interface ExtractedTicketInfo {
|
||||
content: string
|
||||
device_ip: string | null
|
||||
device_sn: string | null
|
||||
device_name: string | null
|
||||
ticket_type: string | null
|
||||
fault_time: string | null
|
||||
fault_info: string | null
|
||||
auth_note: string | null
|
||||
assign_time: string | null
|
||||
}
|
||||
|
||||
// 从邮件 HTML 正文中提取故障信息,参考 Python 脚本的 extract_fault_info
|
||||
function buildTicketContent(subject: string, orderNumber: string, htmlContent: string): ExtractedTicketInfo {
|
||||
const result: ExtractedTicketInfo = {
|
||||
content: '',
|
||||
device_ip: null,
|
||||
device_sn: null,
|
||||
device_name: null,
|
||||
ticket_type: null,
|
||||
fault_time: null,
|
||||
fault_info: null,
|
||||
auth_note: null,
|
||||
assign_time: null,
|
||||
}
|
||||
|
||||
// 提取工单类型
|
||||
if (subject.includes('OEM诊断')) {
|
||||
result.ticket_type = 'OEM诊断'
|
||||
} else if (subject.includes('OEM维修')) {
|
||||
result.ticket_type = 'OEM维修'
|
||||
}
|
||||
|
||||
// 使用 cheerio 解析 HTML 表格
|
||||
if (htmlContent) {
|
||||
const $ = cheerio.load(htmlContent)
|
||||
|
||||
// 先定位包含"故障信息"的目标表格,避免混入无关表格
|
||||
let targetTable = $('table').filter((_, table) => {
|
||||
return $(table).text().includes('故障信息')
|
||||
}).first()
|
||||
|
||||
// 如果没找到包含"故障信息"的表格,使用第一个表格
|
||||
if (targetTable.length === 0) {
|
||||
targetTable = $('table').first()
|
||||
}
|
||||
|
||||
// 遍历目标表格的行,提取键值对
|
||||
targetTable.find('tr').each((_, row) => {
|
||||
const cells = $(row).find('td')
|
||||
if (cells.length >= 2) {
|
||||
const key = $(cells[0]).text().trim()
|
||||
const value = $(cells[1]).text().trim()
|
||||
|
||||
switch (key) {
|
||||
case '故障发生时间:':
|
||||
case '故障发生时间':
|
||||
result.fault_time = value
|
||||
result.assign_time = value
|
||||
break
|
||||
case '服务器SN:':
|
||||
case '服务器SN':
|
||||
result.device_sn = value
|
||||
break
|
||||
case '服务器IP地址:':
|
||||
case '服务器IP地址':
|
||||
case '服务器IP:':
|
||||
case '服务器IP':
|
||||
result.device_ip = value
|
||||
break
|
||||
case '故障信息:':
|
||||
case '故障信息':
|
||||
result.fault_info = value
|
||||
break
|
||||
case '授权说明:':
|
||||
case '授权说明':
|
||||
result.auth_note = value
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback:如果表格提取失败,使用正则从纯文本中提取
|
||||
if (!result.fault_info) {
|
||||
const textContent = $.text().replace(/\s+/g, ' ')
|
||||
const faultMatch = textContent.match(/故障信息[::\s]*(.+?)(?=授权说明|$)/i)
|
||||
if (faultMatch) {
|
||||
result.fault_info = faultMatch[1].trim()
|
||||
}
|
||||
}
|
||||
if (!result.device_ip) {
|
||||
const textContent = $.text().replace(/\s+/g, ' ')
|
||||
const ipMatch = textContent.match(/(?:服务器IP地址|服务器IP|IP)[::\s]*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/)
|
||||
if (ipMatch) {
|
||||
result.device_ip = ipMatch[1]
|
||||
}
|
||||
}
|
||||
if (!result.device_sn) {
|
||||
const textContent = $.text().replace(/\s+/g, ' ')
|
||||
const snMatch = textContent.match(/服务器SN[::\s]*([A-Za-z0-9]+)/)
|
||||
if (snMatch) {
|
||||
result.device_sn = snMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// content 只包含故障信息
|
||||
result.content = result.fault_info || null
|
||||
return result
|
||||
}
|
||||
|
||||
// 保存扫描历史到数据库
|
||||
function saveScanHistory(scanState: ScanResult, userId: number): void {
|
||||
try {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
INSERT INTO scan_history (
|
||||
status, started_at, completed_at,
|
||||
time_range_value, time_range_unit,
|
||||
total_count, matched_count, imported_count, skipped_count, error_count,
|
||||
details_truncated, error_message, details_json, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
scanState.status,
|
||||
scanState.startedAt,
|
||||
scanState.completedAt || null,
|
||||
scanState.timeRange.value,
|
||||
scanState.timeRange.unit,
|
||||
scanState.stats.total,
|
||||
scanState.stats.matched,
|
||||
scanState.stats.imported,
|
||||
scanState.stats.skipped,
|
||||
scanState.stats.errors,
|
||||
scanState.detailsTruncated ? 1 : 0,
|
||||
scanState.error || null,
|
||||
JSON.stringify(scanState.details),
|
||||
userId
|
||||
)
|
||||
console.log(`[Scan] 扫描历史已保存: 状态=${scanState.status}, 导入=${scanState.stats.imported}`)
|
||||
} catch (err) {
|
||||
console.error('[Scan] 保存扫描历史失败:', err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
// src/app/api/monitor/scan-history/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
interface ScanHistoryItem {
|
||||
id: number
|
||||
status: string
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
time_range_value: number | null
|
||||
time_range_unit: string | null
|
||||
total_count: number
|
||||
matched_count: number
|
||||
imported_count: number
|
||||
skipped_count: number
|
||||
error_count: number
|
||||
details_truncated: number
|
||||
error_message: string | null
|
||||
details_json: string | null
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:read')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 获取总数
|
||||
const countResult = db.prepare('SELECT COUNT(*) as total FROM scan_history').get() as { total: number }
|
||||
const total = countResult.total
|
||||
|
||||
// 获取列表
|
||||
const items = db.prepare(`
|
||||
SELECT * FROM scan_history
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(limit, offset) as ScanHistoryItem[]
|
||||
|
||||
// 解析 details_json
|
||||
const itemsWithDetails = items.map(item => ({
|
||||
...item,
|
||||
details: item.details_json ? JSON.parse(item.details_json) : [],
|
||||
details_json: undefined,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
items: itemsWithDetails,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE 清理旧历史(保留最近 N 天)
|
||||
export async function DELETE(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const keepDays = body.keepDays || 30
|
||||
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
DELETE FROM scan_history
|
||||
WHERE created_at < datetime('now', '+8 hours', ? || ' days')
|
||||
`).run(-keepDays)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deleted: result.changes,
|
||||
message: `已清理 ${keepDays} 天前的扫描历史`,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
// src/app/api/monitor/scan-status/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getClientIP, writeAuditLog } from '@/lib/audit'
|
||||
import { getScanState, requestCancel } from '@/lib/monitor/scan-state'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:read')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const state = getScanState()
|
||||
return NextResponse.json(state || { status: 'idle' })
|
||||
}
|
||||
|
||||
// DELETE 请求用于取消扫描
|
||||
export async function DELETE(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const state = getScanState()
|
||||
if (state?.status !== 'running') {
|
||||
return NextResponse.json({ error: '没有正在运行的扫描任务' }, { status: 400 })
|
||||
}
|
||||
|
||||
const clientIP = getClientIP(request)
|
||||
requestCancel()
|
||||
state.status = 'cancelling'
|
||||
|
||||
// 审计日志
|
||||
writeAuditLog({
|
||||
userId: user.id || null,
|
||||
apiKeyId: null,
|
||||
action: 'cancel',
|
||||
entityType: 'scan',
|
||||
entityId: null,
|
||||
details: { scan: { requestedBy: user.username } },
|
||||
ipAddress: clientIP,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: '取消请求已发送,扫描将在处理完当前邮件后停止' })
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ import { getDb } from '@/lib/db'
|
|||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { verifyApiKey } from '@/lib/auth'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
|
||||
|
||||
function verifyEnvApiKey(key: string): boolean {
|
||||
const allowed = process.env.ALLOWED_API_KEYS || ''
|
||||
|
|
@ -98,16 +97,6 @@ export async function POST(request: NextRequest) {
|
|||
ipAddress: getClientIP(request)
|
||||
})
|
||||
|
||||
void notifyTicketCreated({
|
||||
ticket_no: ticketNo,
|
||||
device_ip: body.device_ip || null,
|
||||
device_sn: body.device_sn || null,
|
||||
device_name: body.device_name || null,
|
||||
ticket_type: body.ticket_type || null,
|
||||
content: body.content || null,
|
||||
assign_time: body.assign_time || null,
|
||||
}).catch(e => console.error('[external] notify 失败:', e))
|
||||
|
||||
return NextResponse.json({ ticket, created: true }, { status: 201 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { getCurrentUser } from '@/lib/auth'
|
|||
import { hasPermission } from '@/lib/permissions'
|
||||
import { parseExcelTickets } from '@/lib/excel'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { notifyBatchSummary } from '@/lib/monitor/ticket-notifier'
|
||||
|
||||
function validateTicketNo(ticketNo: string): string | null {
|
||||
if (!/^\d{14}$/.test(ticketNo)) {
|
||||
|
|
@ -136,11 +135,6 @@ export async function POST(request: NextRequest) {
|
|||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
// 事务提交后汇总推送(后台,不阻塞响应)
|
||||
if (imported.length > 0) {
|
||||
void notifyBatchSummary(imported).catch(e => console.error('[import] notify 失败:', e))
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
imported: imported.length,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { initDatabase } from '@/lib/db-schema'
|
|||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
|
@ -168,18 +167,6 @@ export async function POST(request: NextRequest) {
|
|||
})
|
||||
|
||||
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(result.lastInsertRowid)
|
||||
|
||||
// fire-and-forget 微信推送,不阻塞响应
|
||||
void notifyTicketCreated({
|
||||
ticket_no: ticketNo,
|
||||
device_ip: body.device_ip || null,
|
||||
device_sn: body.device_sn || null,
|
||||
device_name: body.device_name || null,
|
||||
ticket_type: body.ticket_type || null,
|
||||
content: body.content || null,
|
||||
assign_time: body.assign_time || null,
|
||||
}).catch(e => console.error('[tickets] notify 失败:', e))
|
||||
|
||||
return NextResponse.json({ ticket }, { status: 201 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// issue-ai/src/lib/audit.ts — 站点审计包装器(引用共享库)
|
||||
import { getDb } from './db'
|
||||
import { writeAuditLog as sharedWriteAuditLog, diffObjects } from '@shared/lib/audit/write-audit-log'
|
||||
import { writeAuditLog as sharedWriteAuditLog, diffObjects, getClientIP } from '@shared/lib/audit/write-audit-log'
|
||||
import type { AuditLogEntry, AuditStore } from '@shared/lib/audit/write-audit-log'
|
||||
|
||||
// 创建 issue-ai 的 store 适配器
|
||||
|
|
@ -19,13 +19,5 @@ export function writeAuditLog(opts: AuditLogEntry): void {
|
|||
sharedWriteAuditLog(store, opts)
|
||||
}
|
||||
|
||||
// 直接定义 getClientIP,避免 Next.js tree-shaking 导致 re-export 丢失
|
||||
// (shared 库的 re-export 在 standalone 构建中可能被优化掉)
|
||||
export function getClientIP(request: Request): string | null {
|
||||
const forwarded = request.headers.get('x-forwarded-for')
|
||||
if (forwarded) return forwarded.split(',')[0].trim()
|
||||
return request.headers.get('x-real-ip') ?? null
|
||||
}
|
||||
|
||||
// re-export 共享工具函数
|
||||
export { diffObjects }
|
||||
export { diffObjects, getClientIP }
|
||||
|
|
|
|||
|
|
@ -166,25 +166,4 @@ export function initDatabase(): void {
|
|||
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
||||
)`)
|
||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_monitor_logs_msg ON monitor_logs(email_msg_id)") } catch { /* 索引已存在 */ }
|
||||
|
||||
// scan_history — 扫描历史记录
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS scan_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
time_range_value INTEGER,
|
||||
time_range_unit TEXT,
|
||||
total_count INTEGER DEFAULT 0,
|
||||
matched_count INTEGER DEFAULT 0,
|
||||
imported_count INTEGER DEFAULT 0,
|
||||
skipped_count INTEGER DEFAULT 0,
|
||||
error_count INTEGER DEFAULT 0,
|
||||
details_truncated INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
details_json TEXT,
|
||||
created_by INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
||||
)`)
|
||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_scan_history_created_at ON scan_history(created_at)") } catch { /* 索引已存在 */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { ImapFlow } from 'imapflow'
|
|||
import { simpleParser } from 'mailparser'
|
||||
import * as cheerio from 'cheerio'
|
||||
import type { EmailInfo, FaultInfo, MonitorConfig } from './types'
|
||||
import { fetchPOP3Emails, normalizeMessageId } from './pop3-fetcher'
|
||||
|
||||
export class MailMonitor {
|
||||
private client: ImapFlow | null = null
|
||||
|
|
@ -19,13 +18,8 @@ export class MailMonitor {
|
|||
logger: false,
|
||||
connectionTimeout: 15_000,
|
||||
})
|
||||
try {
|
||||
await this.client.connect()
|
||||
this.retryCount = 0
|
||||
} catch (e) {
|
||||
this.client = null
|
||||
throw e
|
||||
}
|
||||
await this.client.connect()
|
||||
this.retryCount = 0
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
|
|
@ -62,42 +56,18 @@ export class MailMonitor {
|
|||
if (!this.client) return []
|
||||
const lock = await this.client.getMailboxLock('INBOX')
|
||||
try {
|
||||
// 搜索最近 7 天的邮件(避免遗漏昨天未处理的邮件),通过 processed_emails 表去重
|
||||
const since = new Date()
|
||||
since.setDate(since.getDate() - 7)
|
||||
const today = new Date()
|
||||
const since = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
// 不使用 unseen 条件(IMAP 标记不可靠),依赖 processed_emails 表去重
|
||||
const uids = await this.client.search({ since }, { uid: true })
|
||||
if (!uids || uids.length === 0) return []
|
||||
|
||||
// IMAP BODY 为空时的 POP3 fallback 状态(网易企业邮箱已知问题)
|
||||
let pop3Map: Map<string, Buffer> = new Map()
|
||||
let pop3Attempted = false
|
||||
|
||||
const emails: EmailInfo[] = []
|
||||
for (const uid of uids) {
|
||||
try {
|
||||
let source: Buffer | null = null
|
||||
const msg = await this.client.fetchOne(uid, { source: true, uid: true }, { uid: true })
|
||||
if (msg && 'source' in msg && (msg as any).source) {
|
||||
source = (msg as any).source as Buffer
|
||||
}
|
||||
// IMAP source 为空 → 首次触发 POP3 fallback(一次性拉取建索引)
|
||||
if (!source && !pop3Attempted) {
|
||||
pop3Attempted = true
|
||||
try {
|
||||
pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length)
|
||||
} catch (e) {
|
||||
console.error(`[Worker] POP3 fallback 失败: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
// 通过 imapflow ENVELOPE 取 Message-ID,归一化后与 POP3 Map 精确匹配
|
||||
if (!source && pop3Map.size > 0) {
|
||||
const envMsg = await this.client.fetchOne(uid, { envelope: true, uid: true }, { uid: true })
|
||||
const mid = normalizeMessageId((envMsg as any)?.envelope?.messageId || null)
|
||||
if (mid && pop3Map.has(mid)) source = pop3Map.get(mid)!
|
||||
}
|
||||
if (!source) continue
|
||||
|
||||
const parsed = await simpleParser(source)
|
||||
if (!msg || !('source' in msg) || !(msg as any).source) continue
|
||||
const parsed = await simpleParser((msg as any).source as Buffer)
|
||||
const emailInfo: EmailInfo = {
|
||||
msg_id: parsed.messageId || String(uid),
|
||||
subject: parsed.subject || '',
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
// src/lib/monitor/pop3-fetcher.ts
|
||||
// 从 scan-emails 抽取的共享 POP3 获取模块。
|
||||
// 用途:IMAP 服务器返回空 BODY 时的 fallback(网易企业邮箱 imaphz.qiye.163.com 已知问题)。
|
||||
import * as tls from 'tls'
|
||||
|
||||
// 规范化 Message-ID:去除尖括号和首尾空白,统一为可比较的键
|
||||
export function normalizeMessageId(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null
|
||||
const m = raw.match(/<([^>]+)>/)
|
||||
const id = (m ? m[1] : raw).trim()
|
||||
return id || null
|
||||
}
|
||||
|
||||
// 从原始邮件 Buffer 的头部提取 Message-ID(正则,避免完整解析开销)
|
||||
export function extractMessageIdFromRaw(buf: Buffer): string | null {
|
||||
// 只在头部区域查找(第一个空行之前)
|
||||
const headerEnd = buf.indexOf('\r\n\r\n', 0, 'latin1')
|
||||
const headerRegion = (headerEnd !== -1 ? buf.subarray(0, headerEnd) : buf).toString('latin1')
|
||||
const m = headerRegion.match(/^Message-I[dD]:\s*(.+)$/im)
|
||||
return m ? normalizeMessageId(m[1]) : null
|
||||
}
|
||||
|
||||
// POP3 邮件获取(IMAP 返回空 BODY 时的 fallback)
|
||||
// 返回 Map<规范化 Message-ID, 原始邮件 Buffer>,通过 Message-ID 精确匹配,避免脆弱的位置对齐
|
||||
export async function fetchPOP3Emails(
|
||||
mailAddress: string,
|
||||
mailPassword: string,
|
||||
count: number
|
||||
): Promise<Map<string, Buffer>> {
|
||||
const POP3_HOST = 'pophz.qiye.163.com'
|
||||
const POP3_PORT = 995
|
||||
const POP3_CMD_TIMEOUT = 30000
|
||||
const POP3_MAX_RESPONSE = 50 * 1024 * 1024 // 单封邮件响应上限 50MB,防内存耗尽
|
||||
|
||||
// 校验凭据不含 CRLF,防止 POP3 命令注入
|
||||
if (/[\r\n]/.test(mailAddress) || /[\r\n]/.test(mailPassword)) {
|
||||
throw new Error('POP3 凭据包含非法字符(CRLF)')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`[Scan] POP3: 连接 ${POP3_HOST}:${POP3_PORT}, 获取最近 ${count} 封`)
|
||||
// 网易企业邮箱持有受信任 CA 证书,启用完整证书 + SNI 校验
|
||||
const socket = tls.connect(POP3_PORT, POP3_HOST, { servername: POP3_HOST })
|
||||
|
||||
let settled = false
|
||||
let cmdResolve: ((v: Buffer) => void) | null = null
|
||||
let cmdReject: ((e: Error) => void) | null = null
|
||||
let cmdTimeout: NodeJS.Timeout | null = null
|
||||
let isMultiline = false
|
||||
let chunks: Buffer[] = []
|
||||
let bufLen = 0
|
||||
|
||||
function resetCmd(): void {
|
||||
cmdResolve = null
|
||||
cmdReject = null
|
||||
if (cmdTimeout) { clearTimeout(cmdTimeout); cmdTimeout = null }
|
||||
isMultiline = false
|
||||
chunks = []
|
||||
bufLen = 0
|
||||
}
|
||||
|
||||
// 统一收尾:确保 socket 关闭且 Promise 只 settle 一次
|
||||
function finish(err: Error | null, value?: Map<string, Buffer>): void {
|
||||
if (settled) return
|
||||
settled = true
|
||||
try { socket.destroy() } catch {}
|
||||
if (err) reject(err)
|
||||
else resolve(value || new Map())
|
||||
}
|
||||
|
||||
// 索引 Buffer 中的子串位置(避免字符串往返)
|
||||
function indexOfCRLFDotCRLF(buf: Buffer): number {
|
||||
return buf.indexOf('\r\n.\r\n', 0, 'latin1')
|
||||
}
|
||||
|
||||
socket.on('data', (data: Buffer) => {
|
||||
if (settled) return
|
||||
chunks.push(data)
|
||||
bufLen += data.length
|
||||
|
||||
if (bufLen > POP3_MAX_RESPONSE) {
|
||||
// 超限直接销毁连接放弃本次 fallback,避免脏连接续用导致后续响应错位
|
||||
finish(new Error('POP3 响应超过大小上限'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!cmdResolve) return
|
||||
const buf = Buffer.concat(chunks)
|
||||
|
||||
if (isMultiline) {
|
||||
// 单行错误响应(-ERR ...\r\n)在多行模式下也需即时识别,避免白等超时
|
||||
if (buf.length >= 4 && buf.subarray(0, 4).toString('latin1') === '-ERR') {
|
||||
if (buf.indexOf('\r\n', 0, 'latin1') !== -1) {
|
||||
const r = cmdResolve
|
||||
resetCmd()
|
||||
r(buf)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 多行响应以 \r\n.\r\n 结束
|
||||
if (indexOfCRLFDotCRLF(buf) !== -1) {
|
||||
const r = cmdResolve
|
||||
resetCmd()
|
||||
r(buf)
|
||||
}
|
||||
} else {
|
||||
// 单行响应以 \r\n 结束
|
||||
if (buf.indexOf('\r\n', 0, 'latin1') !== -1) {
|
||||
const r = cmdResolve
|
||||
resetCmd()
|
||||
r(buf)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function sendCmd(cmd: string, multiline = false): Promise<Buffer> {
|
||||
return new Promise((res, rej) => {
|
||||
resetCmd()
|
||||
cmdResolve = res
|
||||
cmdReject = rej
|
||||
isMultiline = multiline
|
||||
cmdTimeout = setTimeout(() => {
|
||||
const r = cmdReject
|
||||
resetCmd()
|
||||
if (r) r(new Error(`POP3 命令超时: ${cmd.split(' ')[0]}`))
|
||||
}, POP3_CMD_TIMEOUT)
|
||||
socket.write(cmd + '\r\n')
|
||||
})
|
||||
}
|
||||
|
||||
// 等待 greeting(带超时,防止服务器静默导致整个扫描永久挂起)
|
||||
function waitGreeting(): Promise<Buffer> {
|
||||
return new Promise((res, rej) => {
|
||||
resetCmd()
|
||||
cmdResolve = res
|
||||
cmdReject = rej
|
||||
isMultiline = false
|
||||
cmdTimeout = setTimeout(() => {
|
||||
const r = cmdReject
|
||||
resetCmd()
|
||||
if (r) r(new Error('POP3 greeting 超时'))
|
||||
}, POP3_CMD_TIMEOUT)
|
||||
})
|
||||
}
|
||||
|
||||
socket.on('error', (err: Error) => {
|
||||
const rej = cmdReject
|
||||
resetCmd()
|
||||
if (rej) rej(err)
|
||||
else finish(err)
|
||||
})
|
||||
|
||||
// 服务器主动关闭连接时,拒绝任何 pending 命令,避免永久挂起
|
||||
socket.on('close', () => {
|
||||
const rej = cmdReject
|
||||
resetCmd()
|
||||
if (rej) rej(new Error('POP3 连接被关闭'))
|
||||
else finish(new Error('POP3 连接被关闭'))
|
||||
})
|
||||
|
||||
// 从原始邮件 Buffer 提取正文(去掉 POP3 状态行 + 结束标记 + dot-stuffing 反填充)
|
||||
function extractEmailBody(resp: Buffer): Buffer {
|
||||
const headerEnd = resp.indexOf('\r\n', 0, 'latin1')
|
||||
const bodyEnd = indexOfCRLFDotCRLF(resp)
|
||||
const start = headerEnd + 2
|
||||
const end = bodyEnd !== -1 ? bodyEnd : resp.length
|
||||
let body = resp.subarray(start, end)
|
||||
// dot-stuffing 反填充:行首 "\r\n.." → "\r\n.",开头 ".." → "."(RFC 1939)
|
||||
const text = body.toString('latin1')
|
||||
const unstuffed = text.replace(/\r\n\.\./g, '\r\n.').replace(/^\.\./, '.')
|
||||
body = Buffer.from(unstuffed, 'latin1')
|
||||
return body
|
||||
}
|
||||
|
||||
// POP3 协议流程
|
||||
;(async () => {
|
||||
try {
|
||||
const greeting = await waitGreeting()
|
||||
if (!greeting.toString('latin1').startsWith('+OK')) {
|
||||
throw new Error(`POP3 greeting 异常: ${greeting.toString('latin1').substring(0, 80)}`)
|
||||
}
|
||||
|
||||
// 登录
|
||||
let resp = await sendCmd(`USER ${mailAddress}`)
|
||||
if (!resp.toString('latin1').startsWith('+OK')) throw new Error('POP3 USER 失败')
|
||||
|
||||
resp = await sendCmd(`PASS ${mailPassword}`)
|
||||
if (!resp.toString('latin1').startsWith('+OK')) throw new Error('POP3 PASS 失败')
|
||||
console.log('[Scan] POP3: 登录成功')
|
||||
|
||||
// STAT 获取总数
|
||||
resp = await sendCmd('STAT')
|
||||
const totalMatch = resp.toString('latin1').match(/\+OK (\d+)/)
|
||||
const total = totalMatch ? parseInt(totalMatch[1]) : 0
|
||||
console.log(`[Scan] POP3: 共 ${total} 封邮件`)
|
||||
|
||||
if (total === 0) {
|
||||
await sendCmd('QUIT').catch(() => {})
|
||||
finish(null, new Map())
|
||||
return
|
||||
}
|
||||
|
||||
// RETR 最后 count 封(POP3 seqno 升序 = 时间升序,与 IMAP UID 顺序一致)
|
||||
// 用 Message-ID 作为键,与 IMAP ENVELOPE 精确匹配,避免位置错位
|
||||
const fetchCount = Math.min(count, total)
|
||||
const emailMap = new Map<string, Buffer>()
|
||||
let okCount = 0
|
||||
for (let i = total - fetchCount + 1; i <= total; i++) {
|
||||
try {
|
||||
resp = await sendCmd(`RETR ${i}`, true)
|
||||
if (resp.toString('latin1', 0, 3) === '+OK') {
|
||||
const body = extractEmailBody(resp)
|
||||
const msgId = extractMessageIdFromRaw(body)
|
||||
if (msgId) {
|
||||
emailMap.set(msgId, body)
|
||||
okCount++
|
||||
} else {
|
||||
console.error(`[Scan] POP3 RETR ${i}: 无法提取 Message-ID,跳过`)
|
||||
}
|
||||
} else {
|
||||
console.error(`[Scan] POP3 RETR ${i} 失败: ${resp.toString('latin1').substring(0, 80)}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Scan] POP3 RETR ${i} 异常:`, e instanceof Error ? e.message : e)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Scan] POP3: 成功获取 ${okCount}/${fetchCount} 封(按 Message-ID 索引 ${emailMap.size} 封)`)
|
||||
await sendCmd('QUIT').catch(() => {})
|
||||
finish(null, emailMap)
|
||||
} catch (e) {
|
||||
console.error('[Scan] POP3 流程失败:', e instanceof Error ? e.message : e)
|
||||
finish(e instanceof Error ? e : new Error(String(e)))
|
||||
}
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
// src/lib/monitor/scan-state.ts
|
||||
// 全局扫描状态管理(与 scan-emails 和 scan-status 路由共享)
|
||||
|
||||
export interface ScanResult {
|
||||
status: 'running' | 'completed' | 'error' | 'cancelling'
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
timeRange: { value: number | null; unit: string }
|
||||
stats: {
|
||||
total: number
|
||||
matched: number
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: number
|
||||
}
|
||||
details: {
|
||||
msg_id: string
|
||||
subject: string
|
||||
date: string
|
||||
order_number: string | null
|
||||
status: 'imported' | 'skipped' | 'error'
|
||||
ticket_no?: string
|
||||
error?: string
|
||||
}[]
|
||||
detailsTruncated: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 模块级变量存储扫描状态
|
||||
let scanState: ScanResult | null = null
|
||||
|
||||
// 取消标志
|
||||
let cancelRequested = false
|
||||
|
||||
export function getScanState(): ScanResult | null {
|
||||
return scanState
|
||||
}
|
||||
|
||||
export function setScanState(state: ScanResult | null): void {
|
||||
scanState = state
|
||||
}
|
||||
|
||||
export function isCancelRequested(): boolean {
|
||||
return cancelRequested
|
||||
}
|
||||
|
||||
export function requestCancel(): void {
|
||||
cancelRequested = true
|
||||
}
|
||||
|
||||
export function resetCancelFlag(): void {
|
||||
cancelRequested = false
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
// src/lib/monitor/ticket-notifier.ts
|
||||
import { getMonitorConfig } from './settings-manager'
|
||||
import { WeChatPusher } from './wechat-pusher'
|
||||
import { AvailabilityEngine } from './availability-engine'
|
||||
import { getRackPosition } from '@/lib/assets-client'
|
||||
import { formatBeijingTime } from './types'
|
||||
|
||||
export interface TicketNotifyInput {
|
||||
ticket_no: string
|
||||
device_ip: string | null
|
||||
device_sn: string | null
|
||||
device_name: string | null
|
||||
ticket_type: string | null
|
||||
content: string | null
|
||||
assign_time: string | null
|
||||
}
|
||||
|
||||
const WEBHOOK_GAP_MS = 300
|
||||
const RATE_WINDOW_MS = 60_000
|
||||
const RATE_MAX = 15
|
||||
|
||||
const recentPushed = new Map<string, number>()
|
||||
let windowStart = 0
|
||||
let windowCount = 0
|
||||
|
||||
function isValidAssignTime(t: string | null): boolean {
|
||||
return !!t && /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(t)
|
||||
}
|
||||
|
||||
function trunc(s: string | null, n: number): string | null {
|
||||
if (!s) return s
|
||||
return s.length > n ? s.slice(0, n) : s
|
||||
}
|
||||
|
||||
function formatBasicMessage(t: TicketNotifyInput): string {
|
||||
let msg = '【新建工单】\n\n'
|
||||
msg += `工单号:${t.ticket_no}\n`
|
||||
if (t.device_ip) msg += `服务器IP:${trunc(t.device_ip, 64)}\n`
|
||||
if (t.device_sn) msg += `服务器SN:${trunc(t.device_sn, 64)}\n`
|
||||
if (t.device_name) msg += `设备名称:${trunc(t.device_name, 128)}\n`
|
||||
if (t.ticket_type) msg += `工单类型:${trunc(t.ticket_type, 32)}\n`
|
||||
if (t.assign_time) msg += `派单时间:${t.assign_time}\n`
|
||||
if (t.content) msg += `工单内容:${trunc(t.content, 500)}\n`
|
||||
return msg.trim()
|
||||
}
|
||||
|
||||
async function getRackPositionSafe(ip: string | null, sn: string | null): Promise<string | null> {
|
||||
// 注:assets-client.getRackPosition 内部有 encodeURIComponent,但【无】超时。
|
||||
// 这里用 Promise.race 加 5s 超时兜底(assets 不可达时 fetch 会挂到 Node 默认 TCP 超时 30-120s)。
|
||||
if (!ip && !sn) return null
|
||||
try {
|
||||
const timeout = new Promise<null>(resolve => setTimeout(() => resolve(null), 5000))
|
||||
return await Promise.race([getRackPosition(ip, sn), timeout])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function allowPush(ticketNo: string): boolean {
|
||||
const now = Date.now()
|
||||
const last = recentPushed.get(ticketNo)
|
||||
if (last && now - last < RATE_WINDOW_MS) return false
|
||||
if (now - windowStart >= RATE_WINDOW_MS) { windowStart = now; windowCount = 0 }
|
||||
if (windowCount >= RATE_MAX) {
|
||||
console.error(`[Notifier] 限频丢弃推送: ${ticketNo}(1分钟内超 ${RATE_MAX} 条)`)
|
||||
return false
|
||||
}
|
||||
windowCount++
|
||||
recentPushed.set(ticketNo, now)
|
||||
for (const [k, ts] of recentPushed) { if (now - ts >= RATE_WINDOW_MS) recentPushed.delete(k) }
|
||||
return true
|
||||
}
|
||||
|
||||
// 读取当前启用的 webhook(动态,后加的自动生效)
|
||||
function getEnabledHooks(): { enabled: boolean; url: string }[] {
|
||||
const config = getMonitorConfig()
|
||||
return config.wechat.webhooks.filter(wh => wh.enabled && wh.url)
|
||||
}
|
||||
|
||||
// 依次推送到给定 webhook 列表,webhook 间加固定小间隔节流
|
||||
async function pushHooks(hooks: { url: string }[], message: string): Promise<void> {
|
||||
const pusher = new WeChatPusher()
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
await pusher.pushText(message, hooks[i].url)
|
||||
if (i < hooks.length - 1) await new Promise(r => setTimeout(r, WEBHOOK_GAP_MS))
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyTicketCreated(t: TicketNotifyInput): Promise<void> {
|
||||
try {
|
||||
// 先检查是否有启用的 webhook:无则直接返回,不消耗限频计数
|
||||
const hooks = getEnabledHooks()
|
||||
if (hooks.length === 0) return
|
||||
if (!allowPush(t.ticket_no)) return
|
||||
const rack = await getRackPositionSafe(t.device_ip, t.device_sn)
|
||||
const faultInfo = {
|
||||
// server_ip/server_sn 进入消息前截断(formatAvailabilityMessage 为唯一真源不可改,故在此截断)
|
||||
server_ip: t.device_ip ? t.device_ip.slice(0, 64) : null,
|
||||
server_sn: t.device_sn ? t.device_sn.slice(0, 64) : null,
|
||||
fault_time: isValidAssignTime(t.assign_time) ? t.assign_time!.replace('T', ' ').slice(0, 19) : null,
|
||||
order_number: t.ticket_no, fault_detail: t.content,
|
||||
}
|
||||
let message: string
|
||||
const normTime = faultInfo.fault_time
|
||||
if (t.ticket_type === 'OEM诊断' && normTime) {
|
||||
// 仅 OEM 分支才需要可用性引擎与推送器
|
||||
const engine = new AvailabilityEngine()
|
||||
const pusher = new WeChatPusher()
|
||||
const monthKey = normTime.slice(0, 7)
|
||||
const oemDeadline = engine.calculateOemDiagDeadline(monthKey, normTime)
|
||||
message = pusher.formatAvailabilityMessage({}, faultInfo, true, oemDeadline, t.ticket_no, rack, t.content ? t.content.slice(0, 500) : null)
|
||||
} else if (t.ticket_type === 'OEM维修' && t.device_sn && normTime) {
|
||||
const engine = new AvailabilityEngine()
|
||||
const pusher = new WeChatPusher()
|
||||
const monthKey = normTime.slice(0, 7)
|
||||
// 可用性计算用原始 device_sn(未截断),保证匹配 fault_records
|
||||
const deadlines = engine.calculateTierDeadlines(monthKey, t.device_sn, normTime)
|
||||
message = pusher.formatAvailabilityMessage(deadlines, faultInfo, false, null, t.ticket_no, rack, t.content ? t.content.slice(0, 500) : null)
|
||||
} else {
|
||||
message = formatBasicMessage(t)
|
||||
}
|
||||
await pushHooks(hooks, message)
|
||||
} catch (e) {
|
||||
console.error(`[Notifier] notifyTicketCreated 失败 (${t.ticket_no}): ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyBatchSummary(ticketNos: string[]): Promise<void> {
|
||||
try {
|
||||
if (!ticketNos || ticketNos.length === 0) return
|
||||
const hooks = getEnabledHooks()
|
||||
if (hooks.length === 0) return
|
||||
const shown = ticketNos.slice(0, 20)
|
||||
let list = shown.join('、')
|
||||
if (ticketNos.length > 20) list += `等 ${ticketNos.length} 张`
|
||||
const message = `【批量导入工单】\n\n本次导入 ${ticketNos.length} 张工单:\n${list}\n导入时间:${formatBeijingTime()}`
|
||||
await pushHooks(hooks, message)
|
||||
} catch (e) {
|
||||
console.error(`[Notifier] notifyBatchSummary 失败: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -130,10 +130,9 @@ export class BackgroundWorker {
|
|||
const faultInfo = email.html ? this.mailMonitor.extractFaultInfo(email.html, tableData) : null
|
||||
if (!faultInfo?.order_number || !faultInfo?.fault_time) continue
|
||||
|
||||
// 创建工单(返回 null = 工单已存在/未创建,跳过后续推送与记录,防止与 notifier 双推)
|
||||
// 创建工单
|
||||
const content = faultInfo.fault_detail || ''
|
||||
const createdOrderNo = this.ticketProcessor.createTicketFromEmail(faultInfo, type, config, content)
|
||||
if (!createdOrderNo) continue
|
||||
this.ticketProcessor.createTicketFromEmail(faultInfo, type, config, content)
|
||||
|
||||
// 获取机架位置
|
||||
const rackPosition = await this.ticketProcessor.getRackPosition(faultInfo.server_ip, faultInfo.server_sn)
|
||||
|
|
|
|||
Loading…
Reference in New Issue