diff --git a/src/app/(app)/settings/monitor/page.tsx b/src/app/(app)/settings/monitor/page.tsx index ce5d286..53e66c7 100644 --- a/src/app/(app)/settings/monitor/page.tsx +++ b/src/app/(app)/settings/monitor/page.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Card } from '@/components/ui' -import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react' +import { Plus, Trash2, ChevronDown, ChevronUp, Search, RefreshCw } from 'lucide-react' interface MonitorStatus { enabled: boolean @@ -37,6 +37,31 @@ 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() { @@ -56,22 +81,240 @@ export default function MonitorSettingsPage() { const [expandedWebhooks, setExpandedWebhooks] = useState>(new Set()) const [testingWebhook, setTestingWebhook] = useState(null) + // 立即检查冷却状态 + const [triggerCooldown, setTriggerCooldown] = useState(0) + const cooldownTimerRef = useRef(null) + + // 邮箱工单检测状态 + const [scanState, setScanState] = useState({ status: 'idle' }) + const [scanTimeValue, setScanTimeValue] = useState(7) + const [scanTimeUnit, setScanTimeUnit] = useState('day') + const scanTimerRef = useRef(null) + + // 从 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(null) + const errorTimerRef = useRef(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()), - ]).then(([s, c]) => { + 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]) => { setStatus(s) setConfig(c) setOriginalConfig(JSON.parse(JSON.stringify(c))) + setScanState(scan) + setScanHistory(history) setLoading(false) }).catch(() => setLoading(false)) }, []) - const refreshStatus = () => { - fetch('/api/monitor/status').then(r => r.json()).then(setStatus) + // 立即检查处理 + 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) } + // 清理冷却定时器 + 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 }) + } + } + + // 清理扫描定时器 + useEffect(() => { + return () => { + if (scanTimerRef.current) clearInterval(scanTimerRef.current) + } + }, []) + + // 其他原有函数保持不变... const handleStart = async () => { await fetch('/api/monitor/start', { method: 'POST' }) refreshStatus() @@ -85,17 +328,6 @@ 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' }) @@ -150,8 +382,6 @@ 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 = { @@ -163,13 +393,12 @@ 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) } }) } @@ -194,17 +423,14 @@ 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 @@ -400,7 +626,13 @@ export default function MonitorSettingsPage() {
{status?.enabled ? : } - +
@@ -471,6 +703,175 @@ export default function MonitorSettingsPage() { + {/* 邮箱工单检测(新模块) */} + +

邮箱工单检测

+

扫描邮箱中的工单邮件,自动导入未存在的工单

+ + {/* 时间范围选择 */} +
+ 时间范围: + 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} + /> + + + {scanning && ( + + )} +
+ + {/* 扫描进度 */} + {scanState.status === 'running' && ( +
+
+ + 扫描进行中... +
+ {scanState.stats && ( +
+ 已扫描 {scanState.stats.total} 封,导入 {scanState.stats.imported} 封,跳过 {scanState.stats.skipped} 封 +
+ )} +
+ )} + + {/* 扫描取消中 */} + {scanState.status === 'cancelling' && ( +
+
+ + 正在取消扫描... +
+ {scanState.stats && ( +
+ 已扫描 {scanState.stats.total} 封,已处理 {scanState.stats.imported + scanState.stats.skipped} 封 +
+ )} +
+ )} + + {/* 扫描结果 */} + {(scanState.status === 'completed' || scanState.status === 'cancelling') && scanState.stats && ( +
+ {scanState.error && ( +
+ {scanState.error} +
+ )} + {scanState.detailsTruncated && ( +
+ 详情列表已截断(最多显示 500 条),完整统计请查看服务器日志 +
+ )} +
+
+ 已扫描 +

{scanState.stats.total}

+
+
+ 匹配工单号 +

{scanState.stats.matched}

+
+
+ 已导入 +

{scanState.stats.imported}

+
+
+ 已跳过 +

{scanState.stats.skipped}

+
+
+ 错误 +

{scanState.stats.errors}

+
+
+ + {/* 详细结果列表 */} + {scanState.details && scanState.details.length > 0 && ( +
+
+ + + + + + + + + + + {scanState.details.map((item, i) => ( + + + + + + + ))} + +
工单号主题状态说明
{item.order_number || '-'}{item.subject || '-'} + + {item.status === 'imported' ? '已导入' : item.status === 'skipped' ? '已跳过' : '错误'} + + {item.error || item.ticket_no || '-'}
+
+
+ )} +
+ )} + + {/* 扫描错误 */} + {scanState.status === 'error' && ( +
+ 扫描失败: {scanState.error || '未知错误'} +
+ )} +
+ {/* 微信推送 - 多 Webhook */}
@@ -492,7 +893,6 @@ export default function MonitorSettingsPage() {
{config.wechat.webhooks.map((wh, index) => (
- {/* 标题行 */}
- {/* 展开的详情 */} {expandedWebhooks.has(wh.id) && (
@@ -588,7 +987,7 @@ export default function MonitorSettingsPage() { - {/* 全局运行参数(保持向后兼容) */} + {/* 全局运行参数 */}

全局运行参数

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

@@ -608,6 +1007,102 @@ export default function MonitorSettingsPage() {
+ + {/* 扫描历史 */} + +
+
+

扫描历史

+

查看历史扫描记录

+
+ +
+ + {scanHistory && scanHistory.items.length > 0 ? ( +
+
+ + + + + + + + + + + + + + + {scanHistory.items.map((item) => ( + + + + + + + + + + + ))} + +
时间范围状态扫描导入跳过错误操作人
{item.started_at} + {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' ? '周' : '月'}`} + + + {item.status === 'completed' ? '完成' : item.status === 'error' ? '错误' : item.status === 'cancelling' ? '已取消' : item.status} + + {item.total_count}{item.imported_count}{item.skipped_count}{item.error_count}{item.created_by || '-'}
+
+ + {/* 分页 */} + {scanHistory.pagination.totalPages > 1 && ( +
+ + 共 {scanHistory.pagination.total} 条记录 + +
+ + + {historyPage} / {scanHistory.pagination.totalPages} + + +
+
+ )} +
+ ) : ( +

+ {loadingHistory ? '加载中...' : '暂无扫描历史'} +

+ )} +
) } diff --git a/src/app/api/monitor/scan-emails/route.ts b/src/app/api/monitor/scan-emails/route.ts new file mode 100644 index 0000000..13a584d --- /dev/null +++ b/src/app/api/monitor/scan-emails/route.ts @@ -0,0 +1,369 @@ +// 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 { ImapFlow } from 'imapflow' +import { simpleParser } from 'mailparser' +import { formatBeijingTime } from '@/lib/monitor/types' +import { writeAuditLog, getClientIP } from '@/lib/audit' +import { getScanState, setScanState, isCancelRequested, resetCancelFlag } from '@/lib/monitor/scan-state' + +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') { + 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, + db: ReturnType, + since: Date, + scanState: ScanResult, + clientIP: string | null, + userId: number +): Promise { + const client = new ImapFlow({ + 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, + }) + + // 整体超时控制 + 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 + } + + try { + await client.connect() + const lock = await client.getMailboxLock('INBOX') + + // 详情计数器 + const detailsCounter = { total: 0 } + + try { + // 搜索邮件 + const uids = await client.search({ since }, { uid: true }) + if (!uids || uids.length === 0) { + scanState.status = 'completed' + scanState.completedAt = formatBeijingTime() + return + } + + scanState.stats.total = uids.length + + // 处理每封邮件 + for (const uid of uids) { + // 检查取消标志 + if (isCancelRequested()) { + scanState.status = 'completed' + scanState.completedAt = formatBeijingTime() + scanState.error = '用户取消' + break + } + + // 检查超时 + if (checkTimeout()) { + break + } + + try { + const msg = await 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) + 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, + }) + + 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() + } + } catch (err) { + scanState.status = 'error' + scanState.completedAt = formatBeijingTime() + scanState.error = err instanceof Error ? err.message : '连接失败' + // 不再 re-throw,避免外层 catch 重复处理 + } finally { + try { await client.logout() } catch {} + } + + // 保存扫描历史到数据库 + saveScanHistory(scanState, userId) +} + +// 保存扫描历史到数据库 +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) + } +} diff --git a/src/app/api/monitor/scan-history/route.ts b/src/app/api/monitor/scan-history/route.ts new file mode 100644 index 0000000..6196809 --- /dev/null +++ b/src/app/api/monitor/scan-history/route.ts @@ -0,0 +1,92 @@ +// 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} 天前的扫描历史`, + }) +} diff --git a/src/app/api/monitor/scan-status/route.ts b/src/app/api/monitor/scan-status/route.ts new file mode 100644 index 0000000..dd2f2c8 --- /dev/null +++ b/src/app/api/monitor/scan-status/route.ts @@ -0,0 +1,49 @@ +// 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: '取消请求已发送,扫描将在处理完当前邮件后停止' }) +} diff --git a/src/lib/db-schema.ts b/src/lib/db-schema.ts index d07d7f4..0b59bee 100644 --- a/src/lib/db-schema.ts +++ b/src/lib/db-schema.ts @@ -166,4 +166,25 @@ 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 { /* 索引已存在 */ } } diff --git a/src/lib/monitor/mail-monitor.ts b/src/lib/monitor/mail-monitor.ts index 64f7436..04e6deb 100644 --- a/src/lib/monitor/mail-monitor.ts +++ b/src/lib/monitor/mail-monitor.ts @@ -18,8 +18,13 @@ export class MailMonitor { logger: false, connectionTimeout: 15_000, }) - await this.client.connect() - this.retryCount = 0 + try { + await this.client.connect() + this.retryCount = 0 + } catch (e) { + this.client = null + throw e + } } async disconnect(): Promise { @@ -56,9 +61,9 @@ export class MailMonitor { if (!this.client) return [] const lock = await this.client.getMailboxLock('INBOX') try { - const today = new Date() - const since = new Date(today.getFullYear(), today.getMonth(), today.getDate()) - // 不使用 unseen 条件(IMAP 标记不可靠),依赖 processed_emails 表去重 + // 搜索最近 7 天的邮件(避免遗漏昨天未处理的邮件),通过 processed_emails 表去重 + const since = new Date() + since.setDate(since.getDate() - 7) const uids = await this.client.search({ since }, { uid: true }) if (!uids || uids.length === 0) return [] diff --git a/src/lib/monitor/scan-state.ts b/src/lib/monitor/scan-state.ts new file mode 100644 index 0000000..f3c782d --- /dev/null +++ b/src/lib/monitor/scan-state.ts @@ -0,0 +1,53 @@ +// 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 +}