feat: 全路径建单微信推送 + IMAP BODY 空 POP3 fallback

问题背景:
1. 网易企业邮箱 IMAP 对部分邮件返回空 BODY,worker 与手动扫描均无法获取正文
2. 6 处建单入口中仅 worker 有推送(且因取不到邮件而瘫痪),导入工单后无微信通知

改动:
- 新增 pop3-fetcher.ts:共享 POP3 获取模块(Message-ID 精确匹配,
  含 CRLF 防护、TLS 证书校验、50MB 上限、dot-stuffing、超时等加固)
- 新增 ticket-notifier.ts:共享推送模块,fire-and-forget 不阻塞建单,
  动态读 webhook、字段截断、assign_time 护栏、限频去重、5s 超时兜底
- mail-monitor.ts:worker fetchUnread 加 POP3 fallback,恢复自动建单
- worker.ts:检查 createTicketFromEmail 返回值,防与 notifier 双推
- 6 处建单入口接入推送:手动建单/外部API/手动扫描/手动扫描导入(单条)、
  Excel 批量(事务后汇总单条)、worker(保持内联推送)
- scan-emails 内联 POP3 重构为引用共享模块

经三轮多独立审查员审查(设计+实现+集成)+ LOW 修复复审,全部零阻塞。
This commit is contained in:
gitadmin 2026-07-11 17:31:56 +08:00
parent c2715d78d3
commit 5152d7d4f2
11 changed files with 998 additions and 157 deletions

131
package-lock.json generated
View File

@ -14,6 +14,7 @@
"cookie": "^1.0.2", "cookie": "^1.0.2",
"docx": "^9.1.1", "docx": "^9.1.1",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"imap-simple": "^5.1.0",
"imapflow": "^1.4.2", "imapflow": "^1.4.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
@ -3876,6 +3877,71 @@
"node": ">= 4" "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": { "node_modules/imapflow": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz", "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz",
@ -4032,6 +4098,12 @@
"node": ">=0.12.0" "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": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@ -4952,6 +5024,16 @@
"node": ">=20" "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": { "node_modules/nodemailer": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
@ -5465,6 +5547,15 @@
"node": ">=0.4.0" "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": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -5612,6 +5703,18 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT" "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": { "node_modules/rc": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@ -6471,12 +6574,40 @@
"punycode": "^2.1.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "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": { "node_modules/victory-vendor": {
"version": "36.9.2", "version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",

View File

@ -17,6 +17,7 @@
"cookie": "^1.0.2", "cookie": "^1.0.2",
"docx": "^9.1.1", "docx": "^9.1.1",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"imap-simple": "^5.1.0",
"imapflow": "^1.4.2", "imapflow": "^1.4.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",

View File

@ -5,6 +5,7 @@ import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit' import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
interface ImportEmail { interface ImportEmail {
msg_id: string msg_id: string
@ -100,6 +101,16 @@ export async function POST(request: NextRequest) {
order_number: email.order_number, order_number: email.order_number,
status: 'imported', 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) { } catch (err) {
errors++ errors++
results.push({ results.push({

View File

@ -5,11 +5,14 @@ import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { getMonitorConfig } from '@/lib/monitor/settings-manager' import { getMonitorConfig } from '@/lib/monitor/settings-manager'
import { ImapFlow } from 'imapflow' import Imap from 'imap'
import { simpleParser } from 'mailparser' import { simpleParser } from 'mailparser'
import * as cheerio from 'cheerio'
import { formatBeijingTime } from '@/lib/monitor/types' import { formatBeijingTime } from '@/lib/monitor/types'
import { writeAuditLog, getClientIP } from '@/lib/audit' import { writeAuditLog, getClientIP } from '@/lib/audit'
import { getScanState, setScanState, isCancelRequested, resetCancelFlag } from '@/lib/monitor/scan-state' 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 { interface ScanResult {
status: 'running' | 'completed' | 'error' | 'cancelling' status: 'running' | 'completed' | 'error' | 'cancelling'
@ -99,9 +102,9 @@ export async function POST(request: NextRequest) {
const clientIP = getClientIP(request) const clientIP = getClientIP(request)
// 检查是否有正在运行的扫描 // 检查是否有正在运行的扫描(包括取消中的扫描)
const currentScan = getScanState() const currentScan = getScanState()
if (currentScan?.status === 'running') { if (currentScan?.status === 'running' || currentScan?.status === 'cancelling') {
return NextResponse.json({ error: '扫描正在进行中,请稍后再试' }, { status: 409 }) return NextResponse.json({ error: '扫描正在进行中,请稍后再试' }, { status: 409 })
} }
@ -153,13 +156,16 @@ async function scanEmails(
clientIP: string | null, clientIP: string | null,
userId: number userId: number
): Promise<void> { ): Promise<void> {
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, host: config.mail.imap_server,
port: config.mail.imap_port, port: config.mail.imap_port,
secure: true, tls: true,
auth: { user: config.mail.address, pass: config.mail.password }, connTimeout: 15000,
logger: false, authTimeout: 15000,
connectionTimeout: 15_000,
}) })
// 整体超时控制 // 整体超时控制
@ -174,161 +180,309 @@ async function scanEmails(
return false return false
} }
try { // 将 IMAP 回调风格封装为 Promise
await client.connect() function imapConnect(): Promise<void> {
const lock = await client.getMailboxLock('INBOX') 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> {
const detailsCounter = { total: 0 } return new Promise((resolve, reject) => {
imap.openBox(boxName, readOnly, (err: Error | null, box: Imap.Box) => {
if (err) reject(err)
else resolve(box)
})
})
}
try { function imapSearch(criteria: any[]): Promise<number[]> {
// 搜索邮件 return new Promise((resolve, reject) => {
const uids = await client.search({ since }, { uid: true }) // imap.search 返回 UIDimap.fetch 默认也使用 UID
if (!uids || uids.length === 0) { imap.search(criteria, (err: Error | null, results: number[]) => {
scanState.status = 'completed' if (err) reject(err)
scanState.completedAt = formatBeijingTime() else resolve(results)
return })
} })
}
scanState.stats.total = uids.length // 获取单封邮件的原始内容(使用 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) => {
for (const uid of uids) { messageCount++
// 检查取消标志 console.log(`[Scan] fetchEmailRaw(uid=${uid}) message 事件触发, seqno=${seqno}`)
if (isCancelRequested()) { msg.on('body', (stream: any, info: any) => {
scanState.status = 'completed' bodyCount++
scanState.completedAt = formatBeijingTime() console.log(`[Scan] fetchEmailRaw(uid=${uid}) body 事件触发, info=${JSON.stringify(info)}`)
scanState.error = '用户取消' const chunks: Buffer[] = []
break stream.on('data', (chunk: Buffer) => {
} chunks.push(chunk)
console.log(`[Scan] fetchEmailRaw(uid=${uid}) 收到数据块, 大小=${chunk.length}`)
// 检查超时
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,
}) })
stream.on('end', () => {
emailBuffer = Buffer.concat(chunks)
console.log(`[Scan] fetchEmailRaw(uid=${uid}) body 结束, 总大小=${emailBuffer.length}`)
})
})
})
scanState.stats.imported++ fetch.once('end', () => {
scanState.stats.matched++ console.log(`[Scan] fetchEmailRaw(uid=${uid}) fetch 结束, messageCount=${messageCount}, bodyCount=${bodyCount}, 最终大小=${emailBuffer.length}`)
addDetail(scanState, { resolve(emailBuffer)
msg_id: msgId, })
subject, fetch.once('error', (err: Error) => {
date, console.error(`[Scan] fetchEmailRaw(uid=${uid}) fetch 错误:`, err.message)
order_number: orderNumber, reject(err)
status: 'imported', })
ticket_no: orderNumber, })
}, detailsCounter) }
} catch (err) {
scanState.stats.errors++ // 通过 IMAP ENVELOPE 获取邮件的 Message-IDBODY 为空时 ENVELOPE 仍可用)
addDetail(scanState, { // 用于与 POP3 邮件精确匹配,避免脆弱的位置对齐
msg_id: String(uid), // 注意:只请求 envelope不能带 bodiesBODY 为空时 attributes 事件不会触发)
subject: '', function fetchEmailMessageId(uid: number): Promise<string | null> {
date: '', return new Promise((resolve) => {
order_number: null, let messageId: string | null = null
status: 'error', try {
error: err instanceof Error ? err.message : '处理失败', const fetch = imap.fetch(uid, { envelope: true } as any)
}, detailsCounter) 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 → 原始邮件 Bufferpop3Attempted: 是否已尝试(无论成败,避免重连风暴)
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)
} }
} finally {
lock.release()
} }
// 记录截断信息
if (scanState.detailsTruncated) { if (scanState.detailsTruncated) {
console.log(`[Scan] 详情列表已截断: 共 ${detailsCounter.total} 条,仅保存前 ${MAX_DETAILS}`) console.log(`[Scan] 详情列表已截断: 共 ${detailsCounter.total} 条,仅保存前 ${MAX_DETAILS}`)
} }
// 只有状态仍然是 running 时才设置为 completed避免覆盖超时/取消状态)
if (scanState.status === 'running') { if (scanState.status === 'running') {
scanState.status = 'completed' scanState.status = 'completed'
scanState.completedAt = formatBeijingTime() scanState.completedAt = formatBeijingTime()
@ -337,15 +491,125 @@ async function scanEmails(
scanState.status = 'error' scanState.status = 'error'
scanState.completedAt = formatBeijingTime() scanState.completedAt = formatBeijingTime()
scanState.error = err instanceof Error ? err.message : '连接失败' scanState.error = err instanceof Error ? err.message : '连接失败'
// 不再 re-throw避免外层 catch 重复处理
} finally { } finally {
try { await client.logout() } catch {} try { imap.end() } catch {}
} }
// 保存扫描历史到数据库
saveScanHistory(scanState, userId) 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 { function saveScanHistory(scanState: ScanResult, userId: number): void {
try { try {

View File

@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
import { verifyApiKey } from '@/lib/auth' import { verifyApiKey } from '@/lib/auth'
import { writeAuditLog, getClientIP } from '@/lib/audit' import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
function verifyEnvApiKey(key: string): boolean { function verifyEnvApiKey(key: string): boolean {
const allowed = process.env.ALLOWED_API_KEYS || '' const allowed = process.env.ALLOWED_API_KEYS || ''
@ -97,6 +98,16 @@ export async function POST(request: NextRequest) {
ipAddress: getClientIP(request) 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 }) return NextResponse.json({ ticket, created: true }, { status: 201 })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '创建失败' const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -5,6 +5,7 @@ import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { parseExcelTickets } from '@/lib/excel' import { parseExcelTickets } from '@/lib/excel'
import { writeAuditLog, getClientIP } from '@/lib/audit' import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyBatchSummary } from '@/lib/monitor/ticket-notifier'
function validateTicketNo(ticketNo: string): string | null { function validateTicketNo(ticketNo: string): string | null {
if (!/^\d{14}$/.test(ticketNo)) { if (!/^\d{14}$/.test(ticketNo)) {
@ -135,6 +136,11 @@ export async function POST(request: NextRequest) {
ipAddress: getClientIP(request), ipAddress: getClientIP(request),
}) })
// 事务提交后汇总推送(后台,不阻塞响应)
if (imported.length > 0) {
void notifyBatchSummary(imported).catch(e => console.error('[import] notify 失败:', e))
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
imported: imported.length, imported: imported.length,

View File

@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit' import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@ -167,6 +168,18 @@ export async function POST(request: NextRequest) {
}) })
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(result.lastInsertRowid) 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 }) return NextResponse.json({ ticket }, { status: 201 })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '创建失败' const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -3,6 +3,7 @@ import { ImapFlow } from 'imapflow'
import { simpleParser } from 'mailparser' import { simpleParser } from 'mailparser'
import * as cheerio from 'cheerio' import * as cheerio from 'cheerio'
import type { EmailInfo, FaultInfo, MonitorConfig } from './types' import type { EmailInfo, FaultInfo, MonitorConfig } from './types'
import { fetchPOP3Emails, normalizeMessageId } from './pop3-fetcher'
export class MailMonitor { export class MailMonitor {
private client: ImapFlow | null = null private client: ImapFlow | null = null
@ -67,12 +68,36 @@ export class MailMonitor {
const uids = await this.client.search({ since }, { uid: true }) const uids = await this.client.search({ since }, { uid: true })
if (!uids || uids.length === 0) return [] if (!uids || uids.length === 0) return []
// IMAP BODY 为空时的 POP3 fallback 状态(网易企业邮箱已知问题)
let pop3Map: Map<string, Buffer> = new Map()
let pop3Attempted = false
const emails: EmailInfo[] = [] const emails: EmailInfo[] = []
for (const uid of uids) { for (const uid of uids) {
try { try {
let source: Buffer | null = null
const msg = await this.client.fetchOne(uid, { source: true, uid: true }, { uid: true }) const msg = await this.client.fetchOne(uid, { source: true, uid: true }, { uid: true })
if (!msg || !('source' in msg) || !(msg as any).source) continue if (msg && 'source' in msg && (msg as any).source) {
const parsed = await simpleParser((msg as any).source as Buffer) 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 = { const emailInfo: EmailInfo = {
msg_id: parsed.messageId || String(uid), msg_id: parsed.messageId || String(uid),
subject: parsed.subject || '', subject: parsed.subject || '',

View File

@ -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<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)))
}
})()
})
}

View File

@ -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<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}`)
}
}

View File

@ -130,9 +130,10 @@ export class BackgroundWorker {
const faultInfo = email.html ? this.mailMonitor.extractFaultInfo(email.html, tableData) : null const faultInfo = email.html ? this.mailMonitor.extractFaultInfo(email.html, tableData) : null
if (!faultInfo?.order_number || !faultInfo?.fault_time) continue if (!faultInfo?.order_number || !faultInfo?.fault_time) continue
// 创建工单 // 创建工单(返回 null = 工单已存在/未创建,跳过后续推送与记录,防止与 notifier 双推)
const content = faultInfo.fault_detail || '' 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) const rackPosition = await this.ticketProcessor.getRackPosition(faultInfo.server_ip, faultInfo.server_sn)