diff --git a/package-lock.json b/package-lock.json index 1f6c215..c5ff0c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "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", @@ -3876,6 +3877,71 @@ "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", @@ -4032,6 +4098,12 @@ "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", @@ -4952,6 +5024,16 @@ "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", @@ -5465,6 +5547,15 @@ "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", @@ -5612,6 +5703,18 @@ "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", @@ -6471,12 +6574,40 @@ "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", diff --git a/package.json b/package.json index f378e41..d3ebfda 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "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", diff --git a/src/app/api/monitor/scan-emails/import/route.ts b/src/app/api/monitor/scan-emails/import/route.ts index 45ad502..5263030 100644 --- a/src/app/api/monitor/scan-emails/import/route.ts +++ b/src/app/api/monitor/scan-emails/import/route.ts @@ -5,6 +5,7 @@ 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 @@ -100,6 +101,16 @@ export async function POST(request: NextRequest) { 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({ diff --git a/src/app/api/monitor/scan-emails/route.ts b/src/app/api/monitor/scan-emails/route.ts index 5081727..d1ae5bd 100644 --- a/src/app/api/monitor/scan-emails/route.ts +++ b/src/app/api/monitor/scan-emails/route.ts @@ -5,11 +5,14 @@ import { getDb } from '@/lib/db' import { getCurrentUser } from '@/lib/auth' import { hasPermission } from '@/lib/permissions' import { getMonitorConfig } from '@/lib/monitor/settings-manager' -import { ImapFlow } from 'imapflow' +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' @@ -99,9 +102,9 @@ export async function POST(request: NextRequest) { const clientIP = getClientIP(request) - // 检查是否有正在运行的扫描 + // 检查是否有正在运行的扫描(包括取消中的扫描) const currentScan = getScanState() - if (currentScan?.status === 'running') { + if (currentScan?.status === 'running' || currentScan?.status === 'cancelling') { return NextResponse.json({ error: '扫描正在进行中,请稍后再试' }, { status: 409 }) } @@ -153,13 +156,16 @@ async function scanEmails( clientIP: string | null, userId: number ): Promise { - const client = new ImapFlow({ + // 使用 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, - secure: true, - auth: { user: config.mail.address, pass: config.mail.password }, - logger: false, - connectionTimeout: 15_000, + tls: true, + connTimeout: 15000, + authTimeout: 15000, }) // 整体超时控制 @@ -174,161 +180,309 @@ async function scanEmails( return false } - try { - await client.connect() - const lock = await client.getMailboxLock('INBOX') + // 将 IMAP 回调风格封装为 Promise + function imapConnect(): Promise { + return new Promise((resolve, reject) => { + imap.once('ready', () => resolve()) + imap.once('error', (err: Error) => reject(err)) + imap.connect() + }) + } - // 详情计数器 - const detailsCounter = { total: 0 } + function imapOpenBox(boxName: string, readOnly: boolean): Promise { + return new Promise((resolve, reject) => { + imap.openBox(boxName, readOnly, (err: Error | null, box: Imap.Box) => { + if (err) reject(err) + else resolve(box) + }) + }) + } - try { - // 搜索邮件 - const uids = await client.search({ since }, { uid: true }) - if (!uids || uids.length === 0) { - scanState.status = 'completed' - scanState.completedAt = formatBeijingTime() - return - } + function imapSearch(criteria: any[]): Promise { + 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) + }) + }) + } - scanState.stats.total = uids.length + // 获取单封邮件的原始内容(使用 UID 获取) + function fetchEmailRaw(uid: number): Promise { + 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 - // 处理每封邮件 - for (const uid of uids) { - // 检查取消标志 - if (isCancelRequested()) { - scanState.status = 'completed' - scanState.completedAt = formatBeijingTime() - scanState.error = '用户取消' - break - } - - // 检查超时 - if (checkTimeout()) { - break - } - - try { - // 使用 body: '' 获取完整邮件内容 - const msg = await client.fetchOne(uid, { uid: true, body: '' }, { uid: true }) - if (!msg) { - console.log(`[Scan] 邮件 ${uid} 获取失败,跳过`) - continue - } - - // 尝试多种方式获取邮件内容 - const rawSource = (msg as any).source || (msg as any).body || (msg as any).rfc822 - if (!rawSource) { - console.log(`[Scan] 邮件 ${uid} 无内容,跳过 (keys: ${Object.keys(msg).join(',')})`) - continue - } - - const parsed = await simpleParser(rawSource as Buffer) - const subject = parsed.subject || '' - const date = parsed.date ? formatBeijingTime(parsed.date) : formatBeijingTime() - const msgId = parsed.messageId || String(uid) - - // 提取工单号 - const orderNumber = extractOrderNumber(subject) - if (!orderNumber) { - scanState.stats.matched++ - addDetail(scanState, { - msg_id: msgId, - subject, - date, - order_number: null, - status: 'skipped', - error: '无法提取工单号', - }, detailsCounter) - continue - } - - // 检查工单是否已存在(tickets 表以 id 作为工单号) - 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 - } - - // 创建工单(使用 id 作为工单号) - const ticketId = parseInt(orderNumber) - - 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, `从邮件导入:${subject}`, userId) - - // 记录已处理邮件 - db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(msgId, subject) - - // 审计日志 - writeAuditLog({ - userId: userId, - apiKeyId: null, - action: 'import', - entityType: 'ticket', - entityId: ticketId, - details: { created: { ticket_no: orderNumber, source: 'email_scan' } }, - ipAddress: clientIP, + 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}`) + }) + }) + }) - 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) - } + 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 { + 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 = 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 = { '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) } - } finally { - lock.release() } - // 记录截断信息 if (scanState.detailsTruncated) { console.log(`[Scan] 详情列表已截断: 共 ${detailsCounter.total} 条,仅保存前 ${MAX_DETAILS} 条`) } - // 只有状态仍然是 running 时才设置为 completed(避免覆盖超时/取消状态) if (scanState.status === 'running') { scanState.status = 'completed' scanState.completedAt = formatBeijingTime() @@ -337,15 +491,125 @@ async function scanEmails( scanState.status = 'error' scanState.completedAt = formatBeijingTime() scanState.error = err instanceof Error ? err.message : '连接失败' - // 不再 re-throw,避免外层 catch 重复处理 } finally { - try { await client.logout() } catch {} + 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 { diff --git a/src/app/api/tickets/external/route.ts b/src/app/api/tickets/external/route.ts index a07c18d..b15065c 100644 --- a/src/app/api/tickets/external/route.ts +++ b/src/app/api/tickets/external/route.ts @@ -3,6 +3,7 @@ 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 || '' @@ -97,6 +98,16 @@ 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 : '创建失败' diff --git a/src/app/api/tickets/import/route.ts b/src/app/api/tickets/import/route.ts index f19801c..d2b6d54 100644 --- a/src/app/api/tickets/import/route.ts +++ b/src/app/api/tickets/import/route.ts @@ -5,6 +5,7 @@ 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)) { @@ -135,6 +136,11 @@ 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, diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts index ce5a611..d3ea825 100644 --- a/src/app/api/tickets/route.ts +++ b/src/app/api/tickets/route.ts @@ -4,6 +4,7 @@ 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 { @@ -167,6 +168,18 @@ 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 : '创建失败' diff --git a/src/lib/monitor/mail-monitor.ts b/src/lib/monitor/mail-monitor.ts index 04e6deb..5f86b49 100644 --- a/src/lib/monitor/mail-monitor.ts +++ b/src/lib/monitor/mail-monitor.ts @@ -3,6 +3,7 @@ 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 @@ -67,12 +68,36 @@ export class MailMonitor { const uids = await this.client.search({ since }, { uid: true }) if (!uids || uids.length === 0) return [] + // IMAP BODY 为空时的 POP3 fallback 状态(网易企业邮箱已知问题) + let pop3Map: Map = 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) continue - const parsed = await simpleParser((msg as any).source as Buffer) + 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) const emailInfo: EmailInfo = { msg_id: parsed.messageId || String(uid), subject: parsed.subject || '', diff --git a/src/lib/monitor/pop3-fetcher.ts b/src/lib/monitor/pop3-fetcher.ts new file mode 100644 index 0000000..fbb57c1 --- /dev/null +++ b/src/lib/monitor/pop3-fetcher.ts @@ -0,0 +1,237 @@ +// 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> { + 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): 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 { + 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 { + 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() + 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))) + } + })() + }) +} diff --git a/src/lib/monitor/ticket-notifier.ts b/src/lib/monitor/ticket-notifier.ts new file mode 100644 index 0000000..edec052 --- /dev/null +++ b/src/lib/monitor/ticket-notifier.ts @@ -0,0 +1,141 @@ +// 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() +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 { + // 注:assets-client.getRackPosition 内部有 encodeURIComponent,但【无】超时。 + // 这里用 Promise.race 加 5s 超时兜底(assets 不可达时 fetch 会挂到 Node 默认 TCP 超时 30-120s)。 + if (!ip && !sn) return null + try { + const timeout = new Promise(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 { + 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 { + 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 { + 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}`) + } +} diff --git a/src/lib/monitor/worker.ts b/src/lib/monitor/worker.ts index 0e18535..8f476ac 100644 --- a/src/lib/monitor/worker.ts +++ b/src/lib/monitor/worker.ts @@ -130,9 +130,10 @@ 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 || '' - this.ticketProcessor.createTicketFromEmail(faultInfo, type, config, content) + const createdOrderNo = this.ticketProcessor.createTicketFromEmail(faultInfo, type, config, content) + if (!createdOrderNo) continue // 获取机架位置 const rackPosition = await this.ticketProcessor.getRackPosition(faultInfo.server_ip, faultInfo.server_sn)