From b7b2b34a1f25d093daad7873c9d4bce8bdc13934 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Wed, 24 Jun 2026 15:43:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97=20?= =?UTF-8?q?+=20=E9=82=AE=E4=BB=B6=E7=9B=91=E6=8E=A7=20+=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=88=A0=E9=99=A4=E5=B7=A5=E5=8D=95=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 33 + CLAUDE.md | 34 +- Dockerfile | 13 +- __tests__/monitor/availability-engine.test.ts | 47 ++ entrypoint.sh | 24 + package-lock.json | 736 +++++++++++++++++- package.json | 5 + scripts/migrate-monitor-data.ts | 53 ++ scripts/monitor-worker.ts | 46 ++ src/app/(app)/settings/audit-logs/page.tsx | 511 +++++++++--- src/app/(app)/settings/monitor/page.tsx | 471 +++++++++++ src/app/(app)/settings/page.tsx | 3 +- src/app/(app)/tickets/[id]/page.tsx | 13 +- src/app/api/audit-logs/cleanup/route.ts | 64 +- src/app/api/monitor/settings/route.ts | 56 ++ src/app/api/monitor/start/route.ts | 16 + src/app/api/monitor/status/route.ts | 88 +++ src/app/api/monitor/stop/route.ts | 16 + src/app/api/monitor/test-mail/route.ts | 30 + src/app/api/monitor/test-wechat/route.ts | 31 + src/app/api/monitor/trigger/route.ts | 22 + src/app/api/tickets/[id]/route.ts | 2 +- src/app/api/tickets/field-values/route.ts | 2 +- src/components/layout/Sidebar.tsx | 3 +- src/components/ui/Badge.tsx | 19 + src/components/ui/Table.tsx | 55 ++ src/components/ui/index.tsx | 4 +- src/lib/assets-client.ts | 31 + src/lib/audit.ts | 14 +- src/lib/db-schema.ts | 73 ++ src/lib/monitor/availability-engine.ts | 67 ++ src/lib/monitor/mail-monitor.ts | 160 ++++ src/lib/monitor/reminder-engine.ts | 207 +++++ src/lib/monitor/settings-manager.ts | 122 +++ src/lib/monitor/ticket-processor.ts | 85 ++ src/lib/monitor/types.ts | 122 +++ src/lib/monitor/wechat-pusher.ts | 76 ++ src/lib/monitor/worker.ts | 210 +++++ src/types/mailparser.d.ts | 16 + tsconfig.worker.json | 22 + 40 files changed, 3466 insertions(+), 136 deletions(-) create mode 100644 __tests__/monitor/availability-engine.test.ts create mode 100644 entrypoint.sh create mode 100644 scripts/migrate-monitor-data.ts create mode 100644 scripts/monitor-worker.ts create mode 100644 src/app/(app)/settings/monitor/page.tsx create mode 100644 src/app/api/monitor/settings/route.ts create mode 100644 src/app/api/monitor/start/route.ts create mode 100644 src/app/api/monitor/status/route.ts create mode 100644 src/app/api/monitor/stop/route.ts create mode 100644 src/app/api/monitor/test-mail/route.ts create mode 100644 src/app/api/monitor/test-wechat/route.ts create mode 100644 src/app/api/monitor/trigger/route.ts create mode 100644 src/components/ui/Badge.tsx create mode 100644 src/components/ui/Table.tsx create mode 100644 src/lib/monitor/availability-engine.ts create mode 100644 src/lib/monitor/mail-monitor.ts create mode 100644 src/lib/monitor/reminder-engine.ts create mode 100644 src/lib/monitor/settings-manager.ts create mode 100644 src/lib/monitor/ticket-processor.ts create mode 100644 src/lib/monitor/types.ts create mode 100644 src/lib/monitor/wechat-pusher.ts create mode 100644 src/lib/monitor/worker.ts create mode 100644 src/types/mailparser.d.ts create mode 100644 tsconfig.worker.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a697bf3..8da8290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # 变更日志 +## 2026-06-24 + +- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 7 个表 9 个列 + +## 2026-06-23(审计日志) + +- [新增] 审计日志详情可视化:字段变更用表格对比,message/keys 格式分别用文本和列表展示 +- [新增] 保留天数可编辑:默认隐藏保存按钮,修改值后蓝色高亮显示,保存成功/失败有颜色反馈 +- [优化] 审计日志列表详情列:显示操作摘要(邮件监控/微信推送/配置修改等),不再显示"-" +- [优化] 表格列宽统一:时间/用户/操作/对象列防止换行,与 assets-ai 风格一致 +- [修复] 监控设置页"已保存"提示重复:SectionSaveButton 移除冗余消息渲染 +- [修复] 导出文件名使用北京时间(修复凌晨时区偏移) + +## 2026-06-23(邮件监控集成) + +- [新增] 邮件监控系统:将 zabbix-01 上的 mail_monitor.py 完整迁移到 issue-ai,Node.js 实现 +- [新增] Background Worker:独立进程运行(entrypoint.sh),node-cron 定时调度,与 Next.js Server 同时启动 +- [新增] IMAP 邮件监控:自动连接邮箱、过滤故障邮件、解析 HTML 表格、提取故障信息 +- [新增] 自动工单创建:从邮件解析结果自动创建工单,关联设备信息 +- [新增] 微信 webhook 推送:故障通知、可用性档位提醒、工单结单提醒 +- [新增] OEM 诊断提醒:每小时提醒、30 分钟特别提醒、超时提醒 +- [新增] OEM 维修提醒:每档前 1 小时提醒、每档超时提醒、全部超出提醒 +- [新增] 静默期管理:0:00-7:00 不发定时提醒,7 点自动 flush 积压消息 +- [新增] 可用性计算:五档位(1%/3%/5%/10%),月度累计故障时长 +- [新增] 6 张数据库表:settings、fault_records、reminder_state、processed_emails、pending_messages、monitor_logs +- [新增] 设置页面(/settings/monitor):邮箱配置、邮件过滤、微信推送、运行参数 +- [新增] 分模块保存按钮 + 高亮修改字段 + 错误详情可点击展开 +- [新增] 运行状态面板:今日处理、累计、错误、进行中故障均可点击展开详情 +- [新增] 监控 API:status/start/stop/trigger/settings/test-mail/test-wechat(admin 权限) +- [新增] 审计日志集成:所有监控操作记录到 audit_logs +- [新增] 数据迁移脚本:从 zabbix-01 迁移历史故障数据 +- [新增] Docker 集成:entrypoint.sh 包装脚本,builder 阶段编译 Worker 模块 + ## 2026-05-18 - [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航 diff --git a/CLAUDE.md b/CLAUDE.md index 466afeb..0c4159c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,16 @@ npm run import # 导入工单 | `src/lib/excel.ts` | Excel 解析与导出 | | `src/lib/pdf.ts` | PDF 生成(puppeteer) | | `src/lib/audit.ts` | 审计日志(writeAuditLog、diffObjects、getClientIP) | +| `src/lib/monitor/types.ts` | 邮件监控:类型定义 | +| `src/lib/monitor/settings-manager.ts` | 邮件监控:配置管理(AES 加密) | +| `src/lib/monitor/worker.ts` | 邮件监控:Worker 主类(cron 调度、主循环) | +| `src/lib/monitor/mail-monitor.ts` | 邮件监控:IMAP 连接、邮件过滤、解析 | +| `src/lib/monitor/ticket-processor.ts` | 邮件监控:工单创建、故障记录 | +| `src/lib/monitor/wechat-pusher.ts` | 邮件监控:微信 webhook 推送 | +| `src/lib/monitor/availability-engine.ts` | 邮件监控:可用性档位计算 | +| `src/lib/monitor/reminder-engine.ts` | 邮件监控:OEM 诊断/维修提醒 | +| `scripts/monitor-worker.ts` | 邮件监控:Worker 独立入口脚本 | +| `scripts/entrypoint.sh` | Docker 包装脚本(同时启动 Server + Worker) | | `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput | | `src/types/report.ts` | ReportType / ReportData | @@ -67,6 +77,12 @@ npm run import # 导入工单 | `ticket_steps` | 工单时间线(关联 ticket_id) | | `reports` | 报告记录 | | `audit_logs` | 审计日志 | +| `settings` | 键值对配置(含邮件监控配置) | +| `fault_records` | 邮件监控:故障记录 | +| `reminder_state` | 邮件监控:提醒状态 | +| `processed_emails` | 邮件监控:已处理邮件去重 | +| `pending_messages` | 邮件监控:静默期积压队列 | +| `monitor_logs` | 邮件监控:运行日志 | ### tickets 表核心字段 @@ -137,6 +153,19 @@ npm run import # 导入工单 | GET/POST | `/api/roles` | 角色列表/创建 | | GET/PUT/DELETE | `/api/roles/[id]` | 单个角色操作 | +### 邮件监控(admin 权限) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/monitor/status` | 运行状态 + 健康检查 + 详情 | +| POST | `/api/monitor/start` | 启用监控 | +| POST | `/api/monitor/stop` | 停用监控 | +| POST | `/api/monitor/trigger` | 手动触发检查(30 秒冷却) | +| GET | `/api/monitor/settings` | 获取配置(密码脱敏) | +| PUT | `/api/monitor/settings` | 更新配置(支持分模块保存) | +| POST | `/api/monitor/test-mail` | 测试 IMAP 连接 | +| POST | `/api/monitor/test-wechat` | 测试微信 webhook | + --- ## 认证机制 @@ -258,9 +287,10 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai ipAddress: getClientIP(request) }) ``` -- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。两处必须遵守: +- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。必须遵守: 1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` - 2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 CREATE TABLE 的 DEFAULT 值、UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。 + 2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。 + 3. **INSERT 时间列**:数据库表的 DEFAULT 已统一为 `datetime('now', '+8 hours')`,INSERT 时可省略时间列。如需显式设置,使用 `datetime('now', '+8 hours')`。 --- diff --git a/Dockerfile b/Dockerfile index cae7bcc..4d00f6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,8 @@ RUN --mount=type=cache,target=/root/.npm,id=issue-npm \ npm ci COPY . . RUN npm run build +# 编译 Worker 入口脚本 + monitor 模块 + db 模块为 JS(使用专用 tsconfig 解析 @/ 路径别名) +RUN npx tsc -p tsconfig.worker.json && npx tsc-alias -p tsconfig.worker.json # runner 阶段:使用 Debian(glibc)以支持 better-sqlite3 等 glibc 原生模块 FROM node:20-slim AS runner @@ -40,6 +42,15 @@ RUN npm install --omit=dev && \ node_modules/@next/swc-linux-x64-musl COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public +# 复制编译后的 Worker 文件(非 .ts 源码) +COPY --from=builder /app/.next/standalone/scripts/monitor-worker.js /app/scripts/ +COPY --from=builder /app/.next/standalone/src/lib/monitor /app/src/lib/monitor +COPY --from=builder /app/.next/standalone/src/lib/db.js /app/src/lib/ +COPY --from=builder /app/.next/standalone/src/lib/db-schema.js /app/src/lib/ +COPY --from=builder /app/.next/standalone/src/lib/assets-client.js /app/src/lib/ RUN mkdir -p /app/data /app/uploads /app/reports EXPOSE 3000 -CMD ["node", "server.js"] +# 使用 entrypoint.sh 同时启动 Next.js Server 和 Monitor Worker +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh +CMD ["/app/entrypoint.sh"] diff --git a/__tests__/monitor/availability-engine.test.ts b/__tests__/monitor/availability-engine.test.ts new file mode 100644 index 0000000..d23f641 --- /dev/null +++ b/__tests__/monitor/availability-engine.test.ts @@ -0,0 +1,47 @@ +// __tests__/monitor/availability-engine.test.ts +import { AvailabilityEngine } from '../../src/lib/monitor/availability-engine' + +// Mock getDb +jest.mock('../../src/lib/db', () => ({ + getDb: () => ({ + prepare: () => ({ + get: () => ({ total: 0 }), + all: () => [], + run: () => {}, + }), + }), +})) + +describe('AvailabilityEngine', () => { + const engine = new AvailabilityEngine() + + it('should calculate days in month correctly', () => { + expect(engine.getDaysInMonth('2026-03')).toBe(31) + expect(engine.getDaysInMonth('2026-02')).toBe(28) + expect(engine.getDaysInMonth('2026-04')).toBe(30) + }) + + it('should calculate OEM diag deadline (first tier only)', () => { + const deadline = engine.calculateOemDiagDeadline('2026-03', '2026-03-10 16:44:01') + expect(deadline).toBeTruthy() + // 31 days = 2678400 seconds, 1% = 26784 seconds ≈ 7.44 hours + const fault = new Date('2026-03-10T16:44:01+08:00') + const deadlineStr = deadline as string + const deadlineDate = new Date(deadlineStr.replace(' ', 'T') + '+08:00') + const diffHours = (deadlineDate.getTime() - fault.getTime()) / (1000 * 3600) + expect(diffHours).toBeCloseTo(7.44, 1) + }) + + it('should calculate tier deadlines', () => { + const deadlines = engine.calculateTierDeadlines('2026-03', 'SN123', '2026-03-10 16:44:01') + expect(deadlines['第一档']).toBeTruthy() + expect(deadlines['第一档'].deadline).not.toBe('已超出') + expect(deadlines['第一档'].hours).toBeGreaterThan(0) + }) + + it('should format deadline in UTC+8', () => { + const deadline = engine.calculateOemDiagDeadline('2026-03', '2026-03-10 16:44:01') + // 应该是 UTC+8 格式,不是 UTC + expect(deadline).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) + }) +}) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..280c1bd --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# entrypoint.sh — 同时启动 Next.js Server 和 Monitor Worker + +node /app/server.js & +SERVER_PID=$! + +cd /app && node scripts/monitor-worker.js & +WORKER_PID=$! + +cleanup() { + echo "Stopping Server (PID=$SERVER_PID) and Worker (PID=$WORKER_PID)..." + kill $SERVER_PID 2>/dev/null + kill $WORKER_PID 2>/dev/null + wait $SERVER_PID 2>/dev/null + wait $WORKER_PID 2>/dev/null + echo "Shutdown complete." +} +trap cleanup SIGTERM SIGINT + +# 等待任一进程退出(需要 Bash 4.3+) +wait -n $SERVER_PID $WORKER_PID +EXIT_CODE=$? +cleanup +exit $EXIT_CODE diff --git a/package-lock.json b/package-lock.json index 66a79ca..cc1ea03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,18 @@ "dependencies": { "bcryptjs": "^2.4.3", "better-sqlite3": "^11.8.1", + "cheerio": "^1.2.0", "cookie": "^1.0.2", "docx": "^9.1.1", "echarts": "^5.5.0", + "imapflow": "^1.4.2", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", "ldapts": "^8.1.7", "lucide-react": "^1.8.0", + "mailparser": "^3.9.11", "next": "^15.1.0", + "node-cron": "^4.5.0", "puppeteer": "^23.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -1419,6 +1423,12 @@ "node": ">= 10" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@puppeteer/browsers": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz", @@ -1467,6 +1477,22 @@ "streamx": "^2.15.0" } }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1924,6 +1950,17 @@ "@types/node": "*" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.12", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.12.tgz", + "integrity": "sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.8", + "libqp": "2.1.1" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2024,6 +2061,15 @@ "node": ">=4" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/b4a": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", @@ -2219,6 +2265,12 @@ "node": ">= 6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -2313,6 +2365,48 @@ "node": ">=0.8" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -2460,6 +2554,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2650,6 +2772,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -2721,6 +2852,61 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2752,6 +2938,28 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -2775,6 +2983,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3304,6 +3524,65 @@ "minimalistic-assert": "^1.0.1" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-to-text": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", + "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^7.1.5", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3330,6 +3609,18 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -3360,6 +3651,39 @@ "node": ">= 4" } }, + "node_modules/imapflow": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz", + "integrity": "sha512-73CGfb5+W0FkZ5CY4GfSdsoXyQ+17wdKkpMN2vwJHdLtOOFQWxv0ilG7KYY79XHBYg5njjqxXYB2FPw5Tl81zQ==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.12", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.3.8", + "libqp": "2.1.1", + "nodemailer": "9.0.1", + "pino": "10.3.1", + "socks": "2.8.9" + } + }, + "node_modules/imapflow/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -3414,9 +3738,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -3605,6 +3929,15 @@ "node": ">=20" } }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3619,6 +3952,46 @@ "node": ">= 0.8.0" } }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz", + "integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -3907,6 +4280,25 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4011,6 +4403,40 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mailparser": { + "version": "3.9.11", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.11.tgz", + "integrity": "sha512-N1LPZwp1yVblJQukv5NAedlkHeA+PmZYdPCfk0Uwohn8JFOM8fYoUGF978qwlQcd3AlUleuzK0fu/EFAHPM9tg==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.12", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "10.0.0", + "iconv-lite": "0.7.2", + "libmime": "5.3.8", + "linkify-it": "5.0.1", + "nodemailer": "9.0.1", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, + "node_modules/mailparser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -4222,6 +4648,36 @@ "node": ">=10" } }, + "node_modules/node-cron": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.5.0.tgz", + "integrity": "sha512-4Trh+kjvbXokyJkwQumvD5YAgeJfgHLR/sKyu71uSmxfCR5QMO1hldpvmFZOICN5pLgNY+J5Y8+ar3XKo5/4tQ==", + "license": "ISC", + "engines": { + "node": ">=20" + } + }, + "node_modules/nodemailer": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4231,6 +4687,15 @@ "node": ">=0.10.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4358,6 +4823,68 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4378,6 +4905,15 @@ "node": ">=8" } }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -4390,6 +4926,43 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -4481,6 +5054,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -4552,6 +5141,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/puppeteer": { "version": "23.11.1", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-23.11.1.tgz", @@ -4591,6 +5189,12 @@ "node": ">=18" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -4685,6 +5289,15 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/recharts": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", @@ -4765,6 +5378,21 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -4780,6 +5408,18 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -4922,12 +5562,12 @@ } }, "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -4949,6 +5589,15 @@ "node": ">= 14" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4968,6 +5617,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -5151,6 +5809,24 @@ "b4a": "^1.6.4" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -5163,6 +5839,15 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5234,6 +5919,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/unbzip2-stream": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", @@ -5244,6 +5935,15 @@ "through": "^2.3.8" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -5289,6 +5989,28 @@ "d3-timer": "^3.0.1" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index bd57457..ea8efd3 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,18 @@ "dependencies": { "bcryptjs": "^2.4.3", "better-sqlite3": "^11.8.1", + "cheerio": "^1.2.0", "cookie": "^1.0.2", "docx": "^9.1.1", "echarts": "^5.5.0", + "imapflow": "^1.4.2", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", "ldapts": "^8.1.7", "lucide-react": "^1.8.0", + "mailparser": "^3.9.11", "next": "^15.1.0", + "node-cron": "^4.5.0", "puppeteer": "^23.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -38,6 +42,7 @@ "eslint": "^10.2.1", "postcss": "^8.4.49", "tailwindcss": "^4.0.0", + "tsc-alias": "^1.8.10", "tsx": "^4.19.0", "typescript": "^5.6.3" } diff --git a/scripts/migrate-monitor-data.ts b/scripts/migrate-monitor-data.ts new file mode 100644 index 0000000..4cc0894 --- /dev/null +++ b/scripts/migrate-monitor-data.ts @@ -0,0 +1,53 @@ +// scripts/migrate-monitor-data.ts +// 从 zabbix-01 拉取历史数据并迁移到 issue-ai SQLite +// 用法: ssh zabbix-01 "cat /opt/scripts/.../server_availability_data.json" | tsx scripts/migrate-monitor-data.ts + +import { execSync } from 'child_process' +import { getDb } from '../src/lib/db' +import { initDatabase } from '../src/lib/db-schema' + +initDatabase() +const db = getDb() + +interface FaultRecord { fault_time: string; fault_type: string; order_number: string; recovery_time: string | null; duration: number | null; status: string } +interface ServerData { server_ip: string; fault_records: FaultRecord[]; total_fault_duration: number; ongoing_faults: string[] } + +// 拉取 server_availability_data.json +console.log('从 zabbix-01 拉取数据...') +const availData = JSON.parse(execSync('ssh zabbix-01 "cat /opt/scripts/mail_monitor_wechat/data/server_availability_data.json"', { encoding: 'utf-8' })) + +let migrated = 0, matched = 0, skipped = 0, errors = 0 + +for (const [monthKey, servers] of Object.entries(availData) as [string, Record][]) { + for (const [sn, serverData] of Object.entries(servers)) { + for (const record of serverData.fault_records) { + try { + // 检查工单是否存在 + if (record.order_number) { + const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(record.order_number)) + if (!ticket) { skipped++; continue } + matched++ + } + // 检查是否已存在 + const existing = db.prepare('SELECT id FROM fault_records WHERE server_sn = ? AND fault_time = ?').get(sn, record.fault_time) + if (existing) { skipped++; continue } + + const status = record.status === 'resolved' ? 'resolved' : 'ongoing' + db.prepare(` + INSERT INTO fault_records (server_sn, server_ip, order_number, fault_type, fault_time, recovery_time, duration_seconds, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(sn, serverData.server_ip, record.order_number, record.fault_type, record.fault_time, record.recovery_time, record.duration, status) + migrated++ + } catch (e) { + errors++ + console.error(`迁移失败: ${sn} - ${record.fault_time}: ${e}`) + } + } + } +} + +console.log(`\n迁移完成:`) +console.log(` 成功: ${migrated}`) +console.log(` 匹配工单: ${matched}`) +console.log(` 跳过: ${skipped}`) +console.log(` 错误: ${errors}`) diff --git a/scripts/monitor-worker.ts b/scripts/monitor-worker.ts new file mode 100644 index 0000000..b396b49 --- /dev/null +++ b/scripts/monitor-worker.ts @@ -0,0 +1,46 @@ +// scripts/monitor-worker.ts +const path = require('path') +const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), 'data', 'issue.db') +process.env.DATABASE_PATH = dbPath + +const { getDb } = require('../src/lib/db') +const { initDatabase } = require('../src/lib/db-schema') +const { BackgroundWorker } = require('../src/lib/monitor/worker') +const { setSetting, getSetting } = require('../src/lib/monitor/settings-manager') + +initDatabase() +const db = getDb() + +// 生成加密密钥(首次启动) +if (!getSetting('monitor.encryption_key')) { + const crypto = require('crypto') + const newKey = crypto.randomBytes(32).toString('hex') + setSetting('monitor.encryption_key', newKey, 'security') + console.log('[Worker] 已生成加密密钥') +} + +const worker = new BackgroundWorker() +if (getSetting('monitor.enabled') === 'true') { + worker.start() + console.log('[Worker] 邮件监控已启动') +} else { + console.log('[Worker] 邮件监控未启用,等待 API 启用') +} + +// 保持进程存活,定期检查监控状态是否变更 +const CHECK_INTERVAL = 30_000 // 30秒 +setInterval(() => { + if (getSetting('monitor.enabled') === 'true' && !worker.cronJob) { + worker.start() + console.log('[Worker] 检测到监控已启用,自动启动') + } else if (getSetting('monitor.enabled') !== 'true' && worker.cronJob) { + worker.stop() + console.log('[Worker] 检测到监控已禁用,自动停止') + } +}, CHECK_INTERVAL) + +process.on('SIGTERM', () => { + console.log('[Worker] 收到 SIGTERM,正在停止...') + worker.stop() + process.exit(0) +}) diff --git a/src/app/(app)/settings/audit-logs/page.tsx b/src/app/(app)/settings/audit-logs/page.tsx index 0a4fc57..46e1179 100644 --- a/src/app/(app)/settings/audit-logs/page.tsx +++ b/src/app/(app)/settings/audit-logs/page.tsx @@ -1,6 +1,9 @@ 'use client' import { useState, useEffect } from 'react' -import { Card, Button, Table, Badge, Modal } from '@/components/ui' +import Table, { Column } from '@/components/ui/Table' +import Button from '@/components/ui/Button' +import Modal from '@/components/ui/Modal' +import Badge from '@/components/ui/Badge' interface AuditLog { id: number @@ -16,15 +19,15 @@ interface AuditLog { api_key_name: string | null } -const ACTION_LABELS: Record = { - create: { label: '创建', color: 'green' }, - update: { label: '更新', color: 'blue' }, - delete: { label: '删除', color: 'red' }, - batch_update: { label: '批量更新', color: 'blue' }, - import: { label: '导入', color: 'blue' }, - export: { label: '导出', color: 'gray' }, - login: { label: '登录', color: 'gray' }, - logout: { label: '登出', color: 'gray' }, +const ACTION_LABELS: Record = { + create: { label: '创建', variant: 'success' }, + update: { label: '更新', variant: 'info' }, + delete: { label: '删除', variant: 'danger' }, + batch_update: { label: '批量更新', variant: 'info' }, + import: { label: '导入', variant: 'info' }, + export: { label: '导出', variant: 'default' }, + login: { label: '登录', variant: 'default' }, + logout: { label: '登出', variant: 'default' }, } const ENTITY_LABELS: Record = { @@ -50,6 +53,14 @@ export default function AuditLogsPage() { const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') + // 保留天数设置 + const [retentionSaved, setRetentionSaved] = useState(180) + const [retentionInput, setRetentionInput] = useState('180') + const [retentionSaving, setRetentionSaving] = useState(false) + const [retentionSavedFeedback, setRetentionSavedFeedback] = useState(false) + const [retentionError, setRetentionError] = useState(false) + const retentionDirty = retentionInput !== String(retentionSaved) + // 详情 Modal const [detailLog, setDetailLog] = useState(null) @@ -76,6 +87,49 @@ export default function AuditLogsPage() { useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo]) + async function fetchRetentionDays() { + try { + const res = await fetch('/api/audit-logs/cleanup') + if (res.ok) { + const data = await res.json() + setRetentionSaved(data.days) + setRetentionInput(String(data.days)) + } + } catch { /* ignore */ } + } + + async function saveRetentionDays() { + const days = parseInt(retentionInput, 10) + if (isNaN(days) || days < 30 || days > 365) { + setRetentionError(true) + setTimeout(() => setRetentionError(false), 2000) + return + } + setRetentionSaving(true) + setRetentionError(false) + try { + const res = await fetch('/api/audit-logs/cleanup', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ days }) + }) + if (res.ok) { + setRetentionSaved(days) + setRetentionSavedFeedback(true) + setTimeout(() => setRetentionSavedFeedback(false), 2000) + } else { + setRetentionError(true) + setTimeout(() => setRetentionError(false), 2000) + } + } catch { + setRetentionError(true) + setTimeout(() => setRetentionError(false), 2000) + } + setRetentionSaving(false) + } + + useEffect(() => { fetchRetentionDays() }, []) + function resetFilters() { setActionFilter('') setEntityFilter('') @@ -107,7 +161,9 @@ export default function AuditLogsPage() { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv` + const now = new Date() + const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + a.download = `audit-logs-${dateStr}.csv` a.click() URL.revokeObjectURL(url) } @@ -126,118 +182,368 @@ export default function AuditLogsPage() { if (details.export) return `导出 ${details.export.count} 条` if (details.batch_update) return `批量更新 ${details.batch_update.count} 条` if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新` + if (details.message) return details.message + if (details.keys) return details.keys.slice(0, 2).join(', ') + (details.keys.length > 2 ? '...' : '') + if (details.username) return `用户 ${details.username} 登录` + if (details.retention_days !== undefined) return `保留 ${details.retention_days} 天` + if (details.cleanup) return `清理 ${details.cleanup.deleted} 条` return '-' } catch { return '-' } } + // 字段名汉化映射 + const FIELD_LABELS: Record = { + name: '名称', status: '状态', type: '类型', category: '分类', + title: '标题', priority: '优先级', assignee: '负责人', reporter: '报告人', + description: '描述', notes: '备注', email: '邮箱', role: '角色', + username: '用户名', permissions: '权限', count: '数量', + } + + function formatValue(val: unknown): string { + if (val === null || val === undefined) return '-' + if (typeof val === 'object') return JSON.stringify(val) + return String(val) + } + + function renderDetails(details: Record) { + // 字段变更(assets-ai 格式) + if (details.changes && typeof details.changes === 'object') { + const changes = details.changes as Record + return ( +
+

📋 修改了以下字段:

+ + + + + + + + + + {Object.entries(changes).map(([key, val]) => ( + + + + + + ))} + +
字段旧值新值
{FIELD_LABELS[key] || key}{formatValue(val.from)}{formatValue(val.to)}
+
+ ) + } + + // 新建记录(assets-ai 格式) + if (details.created && typeof details.created === 'object') { + const data = details.created as Record + return ( +
+

🟢 新建记录:

+
+ {Object.entries(data).map(([key, val]) => ( +
+ {FIELD_LABELS[key] || key}: + {formatValue(val)} +
+ ))} +
+
+ ) + } + + // 删除记录(assets-ai 格式) + if (details.deleted && typeof details.deleted === 'object') { + const data = details.deleted as Record + return ( +
+

🔴 删除记录:

+
+ {Object.entries(data).map(([key, val]) => ( +
+ {FIELD_LABELS[key] || key}: + {formatValue(val)} +
+ ))} +
+
+ ) + } + + // 批量更新(assets-ai 格式) + if (details.batch_update && typeof details.batch_update === 'object') { + const data = details.batch_update as Record + return ( +
+

📦 批量更新 {String(data.count)} 条记录

+ {data.ids ?

涉及 ID:{String(data.ids)}

: null} +
+ ) + } + + // 导入(assets-ai 格式) + if (details.import && typeof details.import === 'object') { + const data = details.import as Record + return ( +
+

📥 导入操作

+
+ 新增:{String(data.created)} 条 + 更新:{String(data.updated)} 条 +
+
+ ) + } + + // 导出(assets-ai 格式) + if (details.export && typeof details.export === 'object') { + const data = details.export as Record + return ( +
+

📤 导出 {String(data.count)} 条记录

+
+ ) + } + + // issue-ai 特有格式:操作说明 + if (details.message && typeof details.message === 'string') { + return ( +
+

📝 操作说明:

+

{details.message}

+
+ ) + } + + // issue-ai 特有格式:配置修改 + if (details.keys && Array.isArray(details.keys)) { + return ( +
+

⚙️ 修改了以下配置:

+
    + {(details.keys as string[]).map((key, i) => ( +
  • {key}
  • + ))} +
+
+ ) + } + + // 登录信息 + if (details.username && typeof details.username === 'string') { + return ( +
+

👤 登录信息:

+
+
+ 用户名: + {details.username} +
+ {details.role && ( +
+ 角色: + {String(details.role)} +
+ )} + {details.displayName && ( +
+ 显示名: + {String(details.displayName)} +
+ )} +
+
+ ) + } + + // 保留天数设置 + if (details.retention_days !== undefined) { + return ( +
+

⚙️ 保留天数设置:

+

日志保留期限调整为 {String(details.retention_days)}

+
+ ) + } + + // 清理操作 + if (details.cleanup) { + const data = details.cleanup as Record + return ( +
+

🧹 清理操作:

+

已清理 {String(data.deleted)} 条过期日志

+
+ ) + } + + // 兜底:格式化 JSON + return ( +
+        {JSON.stringify(details, null, 2)}
+      
+ ) + } + + const columns = [ + { + key: 'created_at', + title: '时间', + width: '170px', + render: (r) => {r.created_at} + }, + { + key: 'username', + title: '用户', + width: '100px', + render: (r) => ( +
+ {r.username || '-'} + {r.api_key_id && [API]} +
+ ) + }, + { + key: 'action', + title: '操作', + width: '70px', + render: (r) => { + const info = ACTION_LABELS[r.action] || { label: r.action, variant: 'default' as const } + return {info.label} + } + }, + { + key: 'entity_type', + title: '对象', + width: '80px', + render: (r) => {ENTITY_LABELS[r.entity_type] || r.entity_type} + }, + { + key: 'details', + title: '详情', + render: (r) => ( + + ) + }, + { + key: 'ip_address', + title: 'IP', + width: '120px', + render: (r) => {r.ip_address || '-'} + }, + ] + + // 保留天数按钮样式 + const retentionBtnClass = retentionSaved + ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + : retentionError + ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' + : '' + return (

审计日志

-

系统操作与事件记录,保留 180 天

+

系统操作与事件记录

- +
+ {/* 保留天数设置 */} +
+ 保留天数: + setRetentionInput(e.target.value)} + className="w-20 px-2 py-1 text-sm border border-slate-200 dark:border-slate-700 rounded bg-white dark:bg-slate-900" + /> + (30-365 天,默认 180 天) + {retentionSavedFeedback ? ( + ✓ 已保存 + ) : retentionError ? ( + ✗ 保存失败 + ) : retentionDirty ? ( + + ) : null} +
+ {/* 过滤器 */} - -
- +
+ - + - { setDateFrom(e.target.value); setPage(1) }} - className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900" - /> - ~ - { setDateTo(e.target.value); setPage(1) }} - className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900" - /> + { setDateFrom(e.target.value); setPage(1) }} + className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900" + /> + ~ + { setDateTo(e.target.value); setPage(1) }} + className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900" + /> -
- - - -
- - +
+ + +
- + + +
{/* 表格 */} - - {loading ? ( -
加载中...
- ) : ( - - {logs.map((log) => ( - - - - - - - - - ))} - {logs.length === 0 && ( - - - - )} -
{log.created_at} -
- {log.username || '-'} - {log.api_key_id && [API]} -
-
- - {ACTION_LABELS[log.action]?.label || log.action} - - {ENTITY_LABELS[log.entity_type] || log.entity_type} - - {log.ip_address || '-'}
暂无数据
- )} -
+ {loading ? ( +
加载中...
+ ) : ( + String(r.id)} /> + )} {/* 分页 */} {total > pageSize && (
{detailLog.details && ( -
- 详情: -
-                  {JSON.stringify(JSON.parse(detailLog.details), null, 2)}
-                
+
+ {renderDetails(JSON.parse(detailLog.details))}
)}
diff --git a/src/app/(app)/settings/monitor/page.tsx b/src/app/(app)/settings/monitor/page.tsx new file mode 100644 index 0000000..88bc43e --- /dev/null +++ b/src/app/(app)/settings/monitor/page.tsx @@ -0,0 +1,471 @@ +'use client' +import { useState, useEffect, useRef } from 'react' +import { Card } from '@/components/ui' + +interface MonitorStatus { + enabled: boolean + status: string + workerHealth: string + lastCheck: string | null + stats: { totalProcessed: number; todayProcessed: number; errors: number; lastError: string | null; lastErrorTime: string | null } + ongoingFaults: number + details: { + todayProcessed: { ticket_no?: string; error?: string; created_at: string }[] + ongoingFaults: { id: number; server_sn: string; server_ip: string | null; order_number: string | null; fault_type: string | null; fault_time: string; fault_detail: string | null }[] + recentErrors: { error?: string; details?: string; created_at: string }[] + } +} + +interface MonitorConfig { + monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number } + mail: { address: string; imap_server: string; imap_port: number; smtp_server: string; smtp_port: number; password: string } + filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } } + wechat: { webhook_url: string } +} + +type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor' + +// 深比较两个对象,返回变化的字段路径 +function getChangedFields(original: unknown, current: unknown, prefix = ''): string[] { + const changes: string[] = [] + if (original === current) return changes + if (typeof original !== typeof current) { changes.push(prefix); return changes } + if (typeof original !== 'object' || original === null || current === null) { + if (original !== current) changes.push(prefix) + return changes + } + const orig = original as Record + const curr = current as Record + const allKeys = new Set([...Object.keys(orig), ...Object.keys(curr)]) + for (const key of allKeys) { + const path = prefix ? `${prefix}.${key}` : key + if (JSON.stringify(orig[key]) !== JSON.stringify(curr[key])) { + changes.push(path) + } + } + return changes +} + +// 检查某个 section 是否有变化 +function hasSectionChanged(original: MonitorConfig | null, current: MonitorConfig | null, section: SectionKey): boolean { + if (!original || !current) return false + return JSON.stringify(original[section]) !== JSON.stringify(current[section]) +} + +export default function MonitorSettingsPage() { + const [status, setStatus] = useState(null) + const [config, setConfig] = useState(null) + const [originalConfig, setOriginalConfig] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(null) + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + const [newKeyword, setNewKeyword] = useState('') + const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null) + const [sectionMessage, setSectionMessage] = useState>>({}) + + useEffect(() => { + Promise.all([ + fetch('/api/monitor/status').then(r => r.json()), + fetch('/api/monitor/settings').then(r => r.json()), + ]).then(([s, c]) => { + setStatus(s) + setConfig(c) + setOriginalConfig(JSON.parse(JSON.stringify(c))) + setLoading(false) + }).catch(() => setLoading(false)) + }, []) + + const refreshStatus = () => { + fetch('/api/monitor/status').then(r => r.json()).then(setStatus) + } + + const handleStart = async () => { + await fetch('/api/monitor/start', { method: 'POST' }) + refreshStatus() + setMessage({ type: 'success', text: '监控已启用' }) + } + + const handleStop = async () => { + if (!confirm('确认停用邮件监控?')) return + await fetch('/api/monitor/stop', { method: 'POST' }) + refreshStatus() + 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' }) + const data = await res.json() + setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } })) + } + + const handleTestWechat = async () => { + setSectionMessage(prev => ({ ...prev, wechat: { type: 'success', text: '正在测试微信推送...' } })) + const res = await fetch('/api/monitor/test-wechat', { method: 'POST' }) + const data = await res.json() + setSectionMessage(prev => ({ ...prev, wechat: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } })) + } + + const handleSectionSave = async (section: SectionKey) => { + if (!config) return + setSaving(section) + setSectionMessage(prev => ({ ...prev, [section]: undefined })) + const sectionData = { [section]: config[section] } + const res = await fetch('/api/monitor/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sectionData) }) + const data = await res.json() + setSaving(null) + if (data.success) { + setOriginalConfig(prev => prev ? { ...prev, [section]: JSON.parse(JSON.stringify(config[section])) } : prev) + setSectionMessage(prev => ({ ...prev, [section]: { type: 'success', text: '已保存' } })) + } else { + setSectionMessage(prev => ({ ...prev, [section]: { type: 'error', text: data.error } })) + } + setTimeout(() => setSectionMessage(prev => ({ ...prev, [section]: undefined })), 3000) + } + + const addKeyword = () => { + if (!config || !newKeyword.trim()) return + setConfig({ ...config, filter: { ...config.filter, subject_keywords: [...config.filter.subject_keywords, newKeyword.trim()] } }) + setNewKeyword('') + } + + const removeKeyword = (index: number) => { + if (!config) return + setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } }) + } + + // 获取某个字段是否被修改 + const isFieldChanged = (path: string): boolean => { + if (!originalConfig || !config) return false + const changes = getChangedFields(originalConfig, config) + return changes.some(c => c === path || c.startsWith(path + '.')) + } + + // 输入框样式:高亮修改的字段 + const inputClass = (path: string) => + `w-full px-3 py-2 border rounded-lg text-sm transition-colors ${isFieldChanged(path) ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}` + + // Section 保存按钮组件(消息反馈由各 section 自行渲染,此处只渲染按钮) + const SectionSaveButton = ({ section }: { section: SectionKey }) => { + const changed = hasSectionChanged(originalConfig, config, section) + if (!changed) return null + return ( +
+ +
+ ) + } + + if (loading) return
加载中...
+ if (!config) return
加载失败
+ + return ( +
+
+

邮件监控设置

+

配置邮件监控、微信推送与提醒规则

+
+ + {message && ( +
+ {message.text} + +
+ )} + + {/* 运行状态 */} + +

运行状态

+
+ + + {status?.workerHealth === 'healthy' ? '运行中' : status?.workerHealth === 'error' ? '运行异常' : status?.workerHealth === 'unresponsive' ? '无响应' : '已停止'} + + {status?.lastCheck && 上次检查: {status.lastCheck}} +
+ + {/* 统计指标 - 可点击展开 */} +
+ {[ + { key: 'today' as const, label: '今日处理', value: status?.stats.todayProcessed || 0, color: 'text-blue-600 dark:text-blue-400' }, + { key: 'total' as const, label: '累计', value: status?.stats.totalProcessed || 0, color: 'text-slate-700 dark:text-slate-300' }, + { key: 'errors' as const, label: '错误', value: status?.stats.errors || 0, color: 'text-red-600 dark:text-red-400' }, + { key: 'ongoing' as const, label: '进行中故障', value: status?.ongoingFaults || 0, color: 'text-amber-600 dark:text-amber-400' }, + ].map(item => ( +
+ {item.label}: + +
+ ))} +
+ + {/* 展开面板 */} + {expandedPanel && ( +
+ {/* 今日处理详情 */} + {expandedPanel === 'today' && status?.details?.todayProcessed && ( +
+
+ 今日处理记录 + +
+ {status.details.todayProcessed.length === 0 ? ( +

今日暂无处理记录

+ ) : ( +
+ {status.details.todayProcessed.map((item, i) => ( +
+ + {item.ticket_no ? `工单 ${item.ticket_no}` : item.error || '处理完成'} + + {item.created_at} +
+ ))} +
+ )} +
+ )} + + {/* 累计统计详情 */} + {expandedPanel === 'total' && ( +
+
+ 累计统计 + +
+
+
+ 总处理工单 +

{status?.stats.totalProcessed || 0}

+
+
+ 今日处理 +

{status?.stats.todayProcessed || 0}

+
+
+ 总错误数 +

{status?.stats.errors || 0}

+
+
+ 进行中故障 +

{status?.ongoingFaults || 0}

+
+
+
+ )} + + {/* 错误详情 */} + {expandedPanel === 'errors' && status?.details?.recentErrors && ( +
+
+ 最近错误记录 + +
+ {status.details.recentErrors.length === 0 ? ( +

暂无错误记录

+ ) : ( +
+ {status.details.recentErrors.map((item, i) => ( +
+
+ 错误 + {item.created_at} +
+
+                          {(() => {
+                            const errStr = item.error || item.details || JSON.stringify(item)
+                            try { return JSON.stringify(JSON.parse(errStr), null, 2) }
+                            catch { return errStr }
+                          })()}
+                        
+
+ ))} +
+ )} +
+ )} + + {/* 进行中故障详情 */} + {expandedPanel === 'ongoing' && status?.details?.ongoingFaults && ( +
+
+ 进行中故障列表 + +
+ {status.details.ongoingFaults.length === 0 ? ( +

暂无进行中的故障

+ ) : ( +
+
+ + + + + + + + + + + + {status.details.ongoingFaults.map((fault, i) => ( + + + + + + + + + ))} + +
工单号服务器IPSN类型故障时间故障信息
{fault.order_number || '-'}{fault.server_ip || '未知'}{fault.server_sn} + + {fault.fault_type === 'oem_diag' ? '诊断' : fault.fault_type === 'oem_repair' ? '维修' : '未知'} + + {fault.fault_time}{fault.fault_detail || '-'}
+
+ )} +
+ )} + + )} + +
+ {status?.enabled ? : } + +
+ + + {/* 邮箱配置 */} + +

邮箱配置

+
+
+ + setConfig({ ...config, mail: { ...config.mail, address: e.target.value } })} className={inputClass('mail.address')} /> +
+
+ + setConfig({ ...config, mail: { ...config.mail, password: e.target.value } })} className={inputClass('mail.password')} placeholder="••••••••" /> +
+
+ + setConfig({ ...config, mail: { ...config.mail, imap_server: e.target.value } })} className={inputClass('mail.imap_server')} /> +
+
+ + setConfig({ ...config, mail: { ...config.mail, imap_port: parseInt(e.target.value) || 993 } })} className={inputClass('mail.imap_port')} /> +
+
+ + setConfig({ ...config, mail: { ...config.mail, smtp_server: e.target.value } })} className={inputClass('mail.smtp_server')} /> +
+
+ + setConfig({ ...config, mail: { ...config.mail, smtp_port: parseInt(e.target.value) || 465 } })} className={inputClass('mail.smtp_port')} /> +
+
+
+ + {sectionMessage.mail && ( + + {sectionMessage.mail.text} + + )} +
+ +
+ + {/* 邮件过滤 */} + +

邮件过滤

+
+
+ +
+ {config.filter.subject_keywords.map((kw, i) => ( + + {kw} + + + ))} +
+
+ setNewKeyword(e.target.value)} onKeyDown={e => e.key === 'Enter' && addKeyword()} className="flex-1 px-3 py-2 border rounded-lg text-sm" placeholder="输入关键词后回车" /> + +
+
+
+ + setConfig({ ...config, filter: { ...config.filter, sender_email: e.target.value } })} className={inputClass('filter.sender_email')} /> +
+
+ +
+ + {/* 微信推送 */} + +

微信推送

+
+
+ + setConfig({ ...config, wechat: { ...config.wechat, webhook_url: e.target.value } })} className={inputClass('wechat.webhook_url')} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." /> +
+
+ + {sectionMessage.wechat && ( + + {sectionMessage.wechat.text} + + )} +
+
+ +
+ + {/* 运行参数 */} + +

运行参数

+
+
+ + setConfig({ ...config, monitor: { ...config.monitor, interval_seconds: parseInt(e.target.value) || 60 } })} className={inputClass('monitor.interval_seconds')} min={10} max={3600} /> +
+
+ + setConfig({ ...config, monitor: { ...config.monitor, silent_start_hour: parseInt(e.target.value) || 0 } })} className={inputClass('monitor.silent_start_hour')} min={0} max={23} /> +
+
+ + setConfig({ ...config, monitor: { ...config.monitor, silent_end_hour: parseInt(e.target.value) || 7 } })} className={inputClass('monitor.silent_end_hour')} min={0} max={23} /> +
+
+ +
+ + ) +} diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index bbaea84..ad4bc22 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -1,10 +1,11 @@ import Link from 'next/link' import { Card } from '@/components/ui' -import { Users, Shield, Settings as SettingsIcon } from 'lucide-react' +import { Users, Shield, Settings as SettingsIcon, Mail } from 'lucide-react' const sections = [ { href: '/settings/users', label: '用户管理', description: '管理系统用户与账号', icon: Users }, { href: '/settings/roles', label: '角色权限', description: '配置角色与权限', icon: Shield }, + { href: '/settings/monitor', label: '邮件监控', description: '邮件监控与微信推送配置', icon: Mail }, ] export default function SettingsPage() { diff --git a/src/app/(app)/tickets/[id]/page.tsx b/src/app/(app)/tickets/[id]/page.tsx index 968606f..015f56b 100644 --- a/src/app/(app)/tickets/[id]/page.tsx +++ b/src/app/(app)/tickets/[id]/page.tsx @@ -65,8 +65,17 @@ export default function TicketDetailPage() { async function handleDelete() { if (!confirm('确定要删除此工单吗?')) return - const res = await fetch(`/api/tickets/${ticket.id}`, { method: 'DELETE' }) - if (res.ok) handleBack() + try { + const res = await fetch(`/api/tickets/${ticket.id}`, { method: 'DELETE' }) + const data = await res.json() + if (res.ok) { + handleBack() + } else { + alert(data.error || '删除失败') + } + } catch (e) { + alert('删除失败:网络错误') + } } const isPending = (ticket.current_status === 'open' || ticket.current_status === 'in_progress') && !ticket.close_time const isAdmin = currentUser?.role === 'admin' diff --git a/src/app/api/audit-logs/cleanup/route.ts b/src/app/api/audit-logs/cleanup/route.ts index 463d451..f92c5c8 100644 --- a/src/app/api/audit-logs/cleanup/route.ts +++ b/src/app/api/audit-logs/cleanup/route.ts @@ -4,12 +4,56 @@ import { getCurrentUser } from '@/lib/auth' import { initDatabase } from '@/lib/db-schema' import { writeAuditLog, getClientIP } from '@/lib/audit' +// 获取保留天数配置 +export async function GET() { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const db = getDb() + const row = db.prepare( + "SELECT value FROM settings WHERE key = 'audit_retention_days'" + ).get() as { value: string } | undefined + + return NextResponse.json({ days: parseInt(row?.value || '180', 10) }) +} + +// 更新保留天数配置 +export async function PUT(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + if (user.role !== 'admin') { + if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + } + + const db = getDb() + const body = await request.json() + const days = Math.max(30, Math.min(365, parseInt(body.days, 10))) + + db.prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_retention_days', ?)" + ).run(String(days)) + + writeAuditLog({ + userId: user.id, + action: 'update', + entityType: 'audit_log', + details: { retention_days: days }, + ipAddress: getClientIP(request) + }) + + return NextResponse.json({ days }) +} + export async function DELETE(request: NextRequest) { initDatabase() const user = await getCurrentUser() if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) - // 检查权限 if (user.role !== 'admin') { if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) { return NextResponse.json({ error: '权限不足' }, { status: 403 }) @@ -18,23 +62,23 @@ export async function DELETE(request: NextRequest) { const db = getDb() - // 清理 180 天前的日志 + // 从 settings 读取保留天数 + const retentionRow = db.prepare( + "SELECT value FROM settings WHERE key = 'audit_retention_days'" + ).get() as { value: string } | undefined + const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10))) + const result = db.prepare( - "DELETE FROM audit_logs WHERE created_at < datetime('now', '-180 days', '+8 hours')" + `DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')` ).run() - // 自审计:记录清理操作 writeAuditLog({ userId: user.id, action: 'cleanup', entityType: 'audit_log', - details: { - cleanup: { - deleted: result.changes - } - }, + details: { cleanup: { deleted: result.changes, retention_days: retentionDays } }, ipAddress: getClientIP(request) }) - return NextResponse.json({ deleted: result.changes }) + return NextResponse.json({ deleted: result.changes, retention_days: retentionDays }) } diff --git a/src/app/api/monitor/settings/route.ts b/src/app/api/monitor/settings/route.ts new file mode 100644 index 0000000..b025a9e --- /dev/null +++ b/src/app/api/monitor/settings/route.ts @@ -0,0 +1,56 @@ +// src/app/api/monitor/settings/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 { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager' +import { writeAuditLog, getClientIP } from '@/lib/audit' + +const VALIDATORS: Record boolean> = { + 'monitor.enabled': (v) => typeof v === 'boolean', + 'monitor.interval_seconds': (v) => typeof v === 'number' && v >= 10 && v <= 3600, + 'monitor.push_delay_ms': (v) => typeof v === 'number' && v >= 0 && v <= 10000, + 'monitor.silent_start_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23, + 'monitor.silent_end_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23, + 'mail.address': (v) => typeof v === 'string' && v.includes('@'), + 'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535, + 'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535, + 'wechat.webhook_url': (v) => typeof v === 'string' && (v === '' || v.startsWith('https://qyapi.weixin.qq.com/')), +} + +export async function GET(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user || !hasPermission(user, 'monitor:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + return NextResponse.json(getMaskedConfig()) +} + +export async function PUT(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + const body = await request.json() + // 保留未修改的密码 + if (body.mail?.password === '••••••••' || body.mail?.password === undefined) { + delete body.mail?.password + } + // 先展平嵌套对象 + const flat: Record = {} + for (const [section, values] of Object.entries(body)) { + if (typeof values === 'object' && values !== null) { + for (const [key, value] of Object.entries(values as Record)) { + flat[`${section}.${key}`] = value + } + } else { + flat[section] = values + } + } + // 展平后验证 + for (const [key, value] of Object.entries(flat)) { + const validator = VALIDATORS[key] + if (validator && !validator(value)) return NextResponse.json({ error: `参数 ${key} 验证失败` }, { status: 400 }) + } + updateMonitorConfig(flat) + writeAuditLog({ userId: user.id, action: 'update', entityType: 'monitor_config', details: { keys: Object.keys(flat) }, ipAddress: getClientIP(request) }) + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/monitor/start/route.ts b/src/app/api/monitor/start/route.ts new file mode 100644 index 0000000..55ecb7a --- /dev/null +++ b/src/app/api/monitor/start/route.ts @@ -0,0 +1,16 @@ +// src/app/api/monitor/start/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 { setSetting } from '@/lib/monitor/settings-manager' +import { writeAuditLog, getClientIP } from '@/lib/audit' + +export async function POST(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + setSetting('monitor.enabled', 'true', 'monitor') + writeAuditLog({ userId: user.id, action: 'enable', entityType: 'monitor', details: { message: '启用邮件监控' }, ipAddress: getClientIP(request) }) + return NextResponse.json({ success: true, message: '监控已启用,Worker 将在下一个 tick 启动' }) +} diff --git a/src/app/api/monitor/status/route.ts b/src/app/api/monitor/status/route.ts new file mode 100644 index 0000000..f811e84 --- /dev/null +++ b/src/app/api/monitor/status/route.ts @@ -0,0 +1,88 @@ +// src/app/api/monitor/status/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' +import { initDatabase } from '@/lib/db-schema' +import { getCurrentUser } from '@/lib/auth' +import { hasPermission } from '@/lib/permissions' +import { getSetting } from '@/lib/monitor/settings-manager' + +export async function GET(request: NextRequest) { + try { + initDatabase() + const user = await getCurrentUser() + if (!user || !hasPermission(user, 'monitor:read')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + const db = getDb() + const enabled = getSetting('monitor.enabled') === 'true' + const lastTick = db.prepare("SELECT created_at, details FROM monitor_logs WHERE action = 'tick_complete' ORDER BY id DESC LIMIT 1").get() as { created_at: string; details: string } | undefined + + // 使用 UTC+8 日期(数据库中 created_at 是 UTC+8) + const now = new Date() + const todayStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} 00:00:00` + const todayProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ?").get(todayStart) as { c: number }).c + const totalProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created'").get() as { c: number }).c + const errorCount = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'error'").get() as { c: number }).c + const ongoingFaultsCount = (db.prepare("SELECT COUNT(*) as c FROM fault_records WHERE status = 'ongoing'").get() as { c: number }).c + + // 获取最近的活动(tick_complete 或 error) + const lastActivity = db.prepare("SELECT created_at, action FROM monitor_logs WHERE action IN ('tick_complete', 'error') ORDER BY id DESC LIMIT 1").get() as { created_at: string; action: string } | undefined + + let workerHealth = 'unknown' + if (enabled && lastActivity) { + const elapsed = Date.now() - new Date(lastActivity.created_at.replace(' ', 'T') + '+08:00').getTime() + if (elapsed < 180_000) { + workerHealth = lastActivity.action === 'error' ? 'error' : 'healthy' + } else { + workerHealth = 'unresponsive' + } + } else if (!enabled) { + workerHealth = 'stopped' + } + + const lastError = db.prepare("SELECT details, created_at FROM monitor_logs WHERE action = 'error' ORDER BY id DESC LIMIT 1").get() as { details: string; created_at: string } | undefined + + // 今日处理详情:从 monitor_logs 获取今天的工单创建记录 + const todayProcessedDetails = db.prepare( + "SELECT details, created_at FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ? ORDER BY id DESC LIMIT 20" + ).all(todayStart) as { details: string; created_at: string }[] + + // 进行中故障详情 + const ongoingFaultsDetails = db.prepare( + "SELECT * FROM fault_records WHERE status = 'ongoing' ORDER BY fault_time DESC LIMIT 20" + ).all() as Record[] + + // 最近错误列表 + const recentErrors = db.prepare( + "SELECT details, created_at FROM monitor_logs WHERE action = 'error' ORDER BY id DESC LIMIT 10" + ).all() as { details: string; created_at: string }[] + + return NextResponse.json({ + enabled, + status: enabled ? 'running' : 'stopped', + workerHealth, + lastCheck: lastTick?.created_at || null, + stats: { + totalProcessed, + todayProcessed, + errors: errorCount, + lastError: lastError?.details || null, + lastErrorTime: lastError?.created_at || null, + }, + ongoingFaults: ongoingFaultsCount, + details: { + todayProcessed: todayProcessedDetails.map(d => { + try { return { ...JSON.parse(d.details), created_at: d.created_at } } + catch { return { details: d.details, created_at: d.created_at } } + }), + ongoingFaults: ongoingFaultsDetails, + recentErrors: recentErrors.map(d => { + try { return { ...JSON.parse(d.details), created_at: d.created_at } } + catch { return { details: d.details, created_at: d.created_at } } + }), + }, + }) + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : 'Internal error' }, { status: 500 }) + } +} diff --git a/src/app/api/monitor/stop/route.ts b/src/app/api/monitor/stop/route.ts new file mode 100644 index 0000000..b1f88d9 --- /dev/null +++ b/src/app/api/monitor/stop/route.ts @@ -0,0 +1,16 @@ +// src/app/api/monitor/stop/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 { setSetting } from '@/lib/monitor/settings-manager' +import { writeAuditLog, getClientIP } from '@/lib/audit' + +export async function POST(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + setSetting('monitor.enabled', 'false', 'monitor') + writeAuditLog({ userId: user.id, action: 'disable', entityType: 'monitor', details: { message: '停用邮件监控' }, ipAddress: getClientIP(request) }) + return NextResponse.json({ success: true, message: '监控已停用' }) +} diff --git a/src/app/api/monitor/test-mail/route.ts b/src/app/api/monitor/test-mail/route.ts new file mode 100644 index 0000000..6464fcd --- /dev/null +++ b/src/app/api/monitor/test-mail/route.ts @@ -0,0 +1,30 @@ +// src/app/api/monitor/test-mail/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 { getMonitorConfig } from '@/lib/monitor/settings-manager' +import { ImapFlow } from 'imapflow' + +export async function POST(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + const config = getMonitorConfig() + 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: 10_000, + }) + try { + await client.connect() + const lock = await client.getMailboxLock('INBOX') + lock.release() + await client.logout() + return NextResponse.json({ success: true, message: 'IMAP 连接成功,INBOX 选择正常' }) + } catch (e) { + return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '连接失败' }) + } finally { + try { await client.logout() } catch {} + } +} diff --git a/src/app/api/monitor/test-wechat/route.ts b/src/app/api/monitor/test-wechat/route.ts new file mode 100644 index 0000000..614b299 --- /dev/null +++ b/src/app/api/monitor/test-wechat/route.ts @@ -0,0 +1,31 @@ +// src/app/api/monitor/test-wechat/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 { getMonitorConfig } from '@/lib/monitor/settings-manager' +import { writeAuditLog, getClientIP } from '@/lib/audit' + +export async function POST(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + const config = getMonitorConfig() + if (!config.wechat.webhook_url) return NextResponse.json({ success: false, error: 'Webhook URL 未配置' }) + try { + const response = await fetch(config.wechat.webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }), + signal: AbortSignal.timeout(5_000), + }) + const result = await response.json() as { errcode?: number; errmsg?: string } + if (result.errcode === 0) { + writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送成功' }, ipAddress: getClientIP(request) }) + return NextResponse.json({ success: true, message: '测试消息发送成功' }) + } + return NextResponse.json({ success: false, error: result.errmsg || '发送失败' }) + } catch (e) { + return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '发送失败' }) + } +} diff --git a/src/app/api/monitor/trigger/route.ts b/src/app/api/monitor/trigger/route.ts new file mode 100644 index 0000000..e9e089c --- /dev/null +++ b/src/app/api/monitor/trigger/route.ts @@ -0,0 +1,22 @@ +// src/app/api/monitor/trigger/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 { setSetting } from '@/lib/monitor/settings-manager' +import { writeAuditLog, getClientIP } from '@/lib/audit' + +let lastTriggerTime = 0 +const TRIGGER_COOLDOWN = 30_000 + +export async function POST(request: NextRequest) { + initDatabase() + const user = await getCurrentUser() + if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + if (Date.now() - lastTriggerTime < TRIGGER_COOLDOWN) return NextResponse.json({ error: '请等待 30 秒后再试' }, { status: 429 }) + lastTriggerTime = Date.now() + // 写入触发标记,Worker 在下一个 tick 检测到后立即执行 + setSetting('monitor.trigger_requested', 'true', 'monitor') + writeAuditLog({ userId: user.id, action: 'trigger', entityType: 'monitor', details: { message: '手动触发邮件检查' }, ipAddress: getClientIP(request) }) + return NextResponse.json({ success: true, message: '已触发,Worker 将在 1-2 秒内执行' }) +} diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts index 30843cf..ab95008 100644 --- a/src/app/api/tickets/[id]/route.ts +++ b/src/app/api/tickets/[id]/route.ts @@ -143,7 +143,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis if (!existing) return NextResponse.json({ error: '工单不存在' }, { status: 404 }) // 获取工单快照用于审计日志 - const snapshot = db.prepare('SELECT id, ticket_no, device_ip, current_status FROM tickets WHERE id = ?').get(id) as Record | undefined + const snapshot = db.prepare('SELECT id, device_ip, current_status FROM tickets WHERE id = ?').get(id) as Record | undefined // 非管理员不可删除已办工单,非创建人不可删除待办工单 const completedStatuses = ['resolved', 'closed'] diff --git a/src/app/api/tickets/field-values/route.ts b/src/app/api/tickets/field-values/route.ts index 958a707..3abf5f8 100644 --- a/src/app/api/tickets/field-values/route.ts +++ b/src/app/api/tickets/field-values/route.ts @@ -16,7 +16,7 @@ export async function GET(request: NextRequest) { const result: Record = {} for (const field of fields) { - const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'ticket_no', 'fault_subcategory'] + const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'id', 'fault_subcategory'] if (!allowed.includes(field)) continue const rows = db.prepare(`SELECT DISTINCT ${field} FROM tickets WHERE ${field} IS NOT NULL AND ${field} != '' ORDER BY ${field}`).all() as Record[] result[field] = rows.map(r => r[field]) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 5a3c699..dfcffc9 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List } from 'lucide-react' +import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List, Mail } from 'lucide-react' const navItems = [ { href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null }, @@ -18,6 +18,7 @@ const settingsItems = [ { href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles:read' }, { href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' }, { href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' }, + { href: '/settings/monitor', label: '邮件监控', icon: Mail, perm: 'monitor:read' }, ] function hasAnyAdminPerm(permissions: string[]): boolean { diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..0ec2106 --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from 'react' +type Variant = 'default' | 'success' | 'warning' | 'danger' | 'info' +// 兼容旧 API: blue→info, green→success, gray→default, red→danger, yellow→warning +type LegacyColor = 'blue' | 'green' | 'gray' | 'red' | 'yellow' +const legacyMap: Record = { + blue: 'info', green: 'success', gray: 'default', red: 'danger', yellow: 'warning', +} +interface BadgeProps { children: ReactNode; variant?: Variant; color?: LegacyColor } +const vs: Record = { + default: 'bg-slate-100 dark:bg-slate-800 dark:text-slate-300 text-slate-700', + success: 'bg-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 text-emerald-700', + warning: 'bg-amber-100 dark:bg-amber-500/10 dark:text-amber-400 text-amber-700', + danger: 'bg-red-100 dark:bg-red-500/10 dark:text-red-400 text-red-700', + info: 'bg-blue-100 dark:bg-blue-500/10 dark:text-blue-400 text-blue-700', +} +export default function Badge({ children, variant = 'default', color }: BadgeProps) { + const v = color ? vs[legacyMap[color]] : vs[variant] + return {children} +} diff --git a/src/components/ui/Table.tsx b/src/components/ui/Table.tsx new file mode 100644 index 0000000..732bcb6 --- /dev/null +++ b/src/components/ui/Table.tsx @@ -0,0 +1,55 @@ +import { ReactNode } from 'react' + +export interface Column { + key: string + title: string + width?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + render?: (record: any, index: number) => ReactNode +} + +interface TableProps { + columns: Column[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rowKey: (record: any) => string +} + +export default function Table({ columns, data, rowKey }: TableProps) { + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {data.map((record, index) => ( + + {columns.map((col) => ( + + ))} + + ))} + {data.length === 0 && ( + + + + )} + +
+ {col.title} +
+ {col.render ? col.render(record, index) : String(record[col.key] ?? '')} +
暂无数据
+
+ ) +} diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index 6e3f3ad..cac724c 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -14,8 +14,8 @@ export function Select({ label, options, className = '', ...props }: SelectProps return (
{label && }
) } -interface TableProps { headers: string[]; children: ReactNode } -export function Table({ headers, children }: TableProps) { +interface TableHeadersProps { headers: string[]; children: ReactNode } +export function Table({ headers, children }: TableHeadersProps) { return (
{headers.map((h) => ())}{children}
{h}
) } diff --git a/src/lib/assets-client.ts b/src/lib/assets-client.ts index 0e10e5f..39b5416 100644 --- a/src/lib/assets-client.ts +++ b/src/lib/assets-client.ts @@ -11,6 +11,7 @@ export interface Asset { manufacturer: string device_model: string status: string + rack_position: string | null } async function assetsFetch(path: string, options?: RequestInit) { @@ -106,3 +107,33 @@ export async function getActiveDevices(deviceType?: string): Promise { return allAssets } + +export async function getRackPosition(ip: string | null, sn: string | null): Promise { + try { + // 优先通过 IP 查询 + if (ip) { + const data = await assetsFetch(`/assets?search=${encodeURIComponent(ip)}&pageSize=10`) + if (data.data && Array.isArray(data.data)) { + for (const asset of data.data) { + if (asset.business_ip === ip || asset.hdm_ip === ip) { + return asset.rack_position || null + } + } + } + } + // 回退通过 SN 查询 + if (sn) { + const data = await assetsFetch(`/assets?search=${encodeURIComponent(sn)}&pageSize=10`) + if (data.data && Array.isArray(data.data)) { + for (const asset of data.data) { + if (asset.serial_number === sn) { + return asset.rack_position || null + } + } + } + } + return null + } catch { + return null + } +} diff --git a/src/lib/audit.ts b/src/lib/audit.ts index 6635c90..82fa5ee 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -26,18 +26,24 @@ export function writeAuditLog(opts: AuditLogOptions): void { ).get() as { value: string } | undefined if (!lastCleanup || lastCleanup.value !== today) { + // 从 settings 读取保留天数,默认 180 天 + const retentionRow = db.prepare( + "SELECT value FROM settings WHERE key = 'audit_retention_days'" + ).get() as { value: string } | undefined + const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10))) + db.prepare( - "DELETE FROM audit_logs WHERE created_at < datetime('now', '-180 days', '+8 hours')" + `DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')` ).run() db.prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)" ).run(today) } - // 写入审计日志 + // 写入审计日志(显式设置 created_at 为北京时间) db.prepare(` - INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+8 hours')) `).run( userId ?? null, apiKeyId ?? null, diff --git a/src/lib/db-schema.ts b/src/lib/db-schema.ts index 96f8c60..bbc22bc 100644 --- a/src/lib/db-schema.ts +++ b/src/lib/db-schema.ts @@ -79,4 +79,77 @@ export function initDatabase(): void { const ex = db.prepare('SELECT id FROM roles WHERE name = ?').get(r.name) if (!ex) db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(r.name, r.display_name, r.permissions) } + + // === 邮件监控系统表 === + + // settings 表迁移:新增列(表已存在,只有 key + value) + try { db.exec("ALTER TABLE settings ADD COLUMN category TEXT NOT NULL DEFAULT 'general'") } catch { /* 列已存在 */ } + try { db.exec("ALTER TABLE settings ADD COLUMN description TEXT") } catch { /* 列已存在 */ } + try { db.exec("ALTER TABLE settings ADD COLUMN updated_at TEXT") } catch { /* 列已存在 */ } + try { db.exec("ALTER TABLE settings ADD COLUMN updated_by INTEGER") } catch { /* 列已存在 */ } + + // fault_records — 故障记录 + db.exec(`CREATE TABLE IF NOT EXISTS fault_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_sn TEXT NOT NULL, + server_ip TEXT, + order_number TEXT, + fault_type TEXT, + fault_time TEXT NOT NULL, + recovery_time TEXT, + duration_seconds INTEGER, + status TEXT DEFAULT 'ongoing', + fault_detail TEXT, + created_at TEXT DEFAULT (datetime('now', '+8 hours')), + UNIQUE(server_sn, fault_time) + )`) + try { db.exec("CREATE INDEX IF NOT EXISTS idx_fault_records_sn ON fault_records(server_sn)") } catch { /* 索引已存在 */ } + try { db.exec("CREATE INDEX IF NOT EXISTS idx_fault_records_month ON fault_records(strftime('%Y-%m', fault_time))") } catch { /* 索引已存在 */ } + try { db.exec("CREATE INDEX IF NOT EXISTS idx_fault_records_status ON fault_records(status)") } catch { /* 索引已存在 */ } + + // reminder_state — 提醒状态 + db.exec(`CREATE TABLE IF NOT EXISTS reminder_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_sn TEXT NOT NULL, + server_ip TEXT, + order_number TEXT, + reminder_type TEXT NOT NULL, + deadline_time TEXT, + deadline_hours REAL, + fault_time TEXT, + tier_name TEXT, + notification_phase TEXT DEFAULT 'none', + notified_at TEXT, + created_at TEXT DEFAULT (datetime('now', '+8 hours')) + )`) + try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_reminder_unique ON reminder_state(server_sn, reminder_type, COALESCE(tier_name, ''), fault_time)") } catch { /* 索引已存在 */ } + try { db.exec("CREATE INDEX IF NOT EXISTS idx_reminder_state_sn ON reminder_state(server_sn)") } catch { /* 索引已存在 */ } + + // processed_emails — 邮件去重 + db.exec(`CREATE TABLE IF NOT EXISTS processed_emails ( + msg_id TEXT PRIMARY KEY, + subject TEXT, + processed_at TEXT DEFAULT (datetime('now', '+8 hours')) + )`) + try { db.exec("CREATE INDEX IF NOT EXISTS idx_processed_emails_date ON processed_emails(processed_at)") } catch { /* 索引已存在 */ } + + // pending_messages — 静默期积压队列 + db.exec(`CREATE TABLE IF NOT EXISTS pending_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_type TEXT NOT NULL, + server_sn TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now', '+8 hours')) + )`) + + // monitor_logs — 监控日志 + db.exec(`CREATE TABLE IF NOT EXISTS monitor_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email_msg_id TEXT, + email_subject TEXT, + action TEXT, + details TEXT, + 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 { /* 索引已存在 */ } } diff --git a/src/lib/monitor/availability-engine.ts b/src/lib/monitor/availability-engine.ts new file mode 100644 index 0000000..d17133a --- /dev/null +++ b/src/lib/monitor/availability-engine.ts @@ -0,0 +1,67 @@ +// src/lib/monitor/availability-engine.ts +import { getDb } from '@/lib/db' +import type { TierDeadline } from './types' +import { formatBeijingTime } from './types' + +// Python calendar.monthrange 的 Node.js 实现 +function monthrange(year: number, month: number): [number, number] { + const daysInMonth = new Date(year, month, 0).getDate() + const firstDayOfWeek = new Date(year, month - 1, 1).getDay() + return [firstDayOfWeek, daysInMonth] +} + +const TIERS = { + '第一档': { max_rate: 0.01, description: '99%(含)以上(不扣费)' }, + '第二档': { max_rate: 0.03, description: '99%-97%(含)(扣10%)' }, + '第三档': { max_rate: 0.05, description: '97%-95%(含)(扣25%)' }, + '第四档': { max_rate: 0.10, description: '95%-90%(含)(扣50%)' }, + '第五档': { max_rate: 0.10, description: '90%(不含)以下(扣100%)' }, +} + +export class AvailabilityEngine { + private db = getDb() + + getDaysInMonth(monthKey: string): number { + const [y, m] = monthKey.split('-').map(Number) + return monthrange(y, m)[1] + } + + getMonthlyFaultDuration(monthKey: string, serverSn: string): number { + const row = this.db.prepare( + "SELECT COALESCE(SUM(duration_seconds), 0) as total FROM fault_records WHERE server_sn = ? AND status = 'resolved' AND strftime('%Y-%m', fault_time) = ?" + ).get(serverSn, monthKey) as { total: number } | undefined + return row?.total ?? 0 + } + + calculateTierDeadlines(monthKey: string, serverSn: string, faultTime: string): Record { + const daysInMonth = this.getDaysInMonth(monthKey) + const monthTotalSeconds = daysInMonth * 24 * 3600 + const totalDuration = this.getMonthlyFaultDuration(monthKey, serverSn) + const faultDate = new Date(faultTime.replace(' ', 'T') + '+08:00') + const deadlines: Record = {} + + for (const [tierName, tier] of Object.entries(TIERS)) { + const maxDuration = monthTotalSeconds * tier.max_rate + const remaining = maxDuration - totalDuration + if (remaining > 0) { + const deadline = new Date(faultDate.getTime() + remaining * 1000) + deadlines[tierName] = { + deadline: formatBeijingTime(deadline), + hours: Math.round(remaining / 3600 * 100) / 100, + } + } else { + deadlines[tierName] = { deadline: '已超出', hours: 0 } + } + } + return deadlines + } + + calculateOemDiagDeadline(monthKey: string, faultTime: string): string | null { + const daysInMonth = this.getDaysInMonth(monthKey) + const monthTotalSeconds = daysInMonth * 24 * 3600 + const firstTierDuration = monthTotalSeconds * 0.01 + const faultDate = new Date(faultTime.replace(' ', 'T') + '+08:00') + const deadline = new Date(faultDate.getTime() + firstTierDuration * 1000) + return formatBeijingTime(deadline) + } +} diff --git a/src/lib/monitor/mail-monitor.ts b/src/lib/monitor/mail-monitor.ts new file mode 100644 index 0000000..64f7436 --- /dev/null +++ b/src/lib/monitor/mail-monitor.ts @@ -0,0 +1,160 @@ +// src/lib/monitor/mail-monitor.ts +import { ImapFlow } from 'imapflow' +import { simpleParser } from 'mailparser' +import * as cheerio from 'cheerio' +import type { EmailInfo, FaultInfo, MonitorConfig } from './types' + +export class MailMonitor { + private client: ImapFlow | null = null + private retryCount = 0 + private readonly RETRY_DELAYS = [5_000, 15_000, 45_000] + + async connect(config: MonitorConfig['mail']): Promise { + this.client = new ImapFlow({ + host: config.imap_server, + port: config.imap_port, + secure: true, + auth: { user: config.address, pass: config.password }, + logger: false, + connectionTimeout: 15_000, + }) + await this.client.connect() + this.retryCount = 0 + } + + async disconnect(): Promise { + if (this.client) { + try { await this.client.logout() } catch {} + this.client = null + } + } + + isConnected(): boolean { + return this.client !== null + } + + async reconnect(config: MonitorConfig['mail']): Promise { + await this.disconnect() + const delay = this.RETRY_DELAYS[Math.min(this.retryCount, this.RETRY_DELAYS.length - 1)] + this.retryCount++ + await new Promise(r => setTimeout(r, delay)) + await this.connect(config) + } + + async keepAlive(): Promise { + if (!this.client) return false + try { + await this.client.noop() + return true + } catch { + this.client = null + return false + } + } + + async fetchUnread(config: MonitorConfig): Promise { + 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 表去重 + const uids = await this.client.search({ since }, { uid: true }) + if (!uids || uids.length === 0) return [] + + const emails: EmailInfo[] = [] + for (const uid of uids) { + try { + 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) + const emailInfo: EmailInfo = { + msg_id: parsed.messageId || String(uid), + subject: parsed.subject || '', + sender: parsed.from?.text || '', + date: parsed.date?.toISOString() || '', + html: parsed.html || null, + } + emails.push(emailInfo) + } catch {} + } + return emails + } finally { + lock.release() + } + } + + filterEmail(email: EmailInfo, config: MonitorConfig['filter']): { pass: boolean; type: 'oem_diag' | 'oem_repair' | null } { + // 主题关键词过滤 + const subjectMatch = config.subject_keywords.some(kw => email.subject.includes(kw)) + if (!subjectMatch) return { pass: false, type: null } + // 发件人过滤 + if (!email.sender.includes(config.sender_email)) return { pass: false, type: null } + // OEM 类型识别 + const repair = config.oem_repair_keywords + const diag = config.oem_diag_keywords + const isRepair = email.subject.includes(repair.base_keyword) && email.subject.includes(repair.type_keyword) && !email.subject.includes(repair.exclude_keyword) + const isDiag = email.subject.includes(diag.base_keyword) && email.subject.includes(diag.type_keyword) && !email.subject.includes(diag.exclude_keyword) + if (isRepair) return { pass: true, type: 'oem_repair' } + if (isDiag) return { pass: true, type: 'oem_diag' } + return { pass: false, type: null } + } + + extractTable(html: string): string[][] | null { + const $ = cheerio.load(html) + const tables = $('table') + if (tables.length === 0) return null + let targetTable: any = null + tables.each((_, table) => { + if ($(table).text().includes('故障信息')) { + targetTable = table + return false + } + }) + if (!targetTable) targetTable = tables[0]! + const rows: string[][] = [] + $(targetTable).find('tr').each((_, row) => { + const cells: string[] = [] + $(row).find('th, td').each((_, cell) => { + cells.push($(cell).text().trim()) + }) + if (cells.length > 0) rows.push(cells) + }) + return rows.length > 0 ? rows : null + } + + extractFaultInfo(html: string, tableData: string[][] | null): FaultInfo { + const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ') + const patterns = { + fault_time: [/故障(?:发生)?时间[::\s]*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/, /(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/], + server_sn: [/服务器SN[::\s]*([A-Za-z0-9]+)/, /SN[::\s]*([A-Za-z0-9]+)/, /([A-Za-z0-9]{15,25})/], + server_ip: [/服务器IP[::\s]*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/, /IP[::\s]*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/, /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/], + } + const result: FaultInfo = { fault_time: null, server_sn: null, server_ip: null, order_number: null, fault_detail: null } + for (const [key, regexes] of Object.entries(patterns)) { + for (const regex of regexes) { + const match = text.match(regex) + if (match) { result[key as keyof FaultInfo] = match[1] as never; break } + } + } + // 从主题提取工单号 + const orderMatch = html.match(/【服务器故障单】(\d+),/) + if (orderMatch) result.order_number = orderMatch[1] + // 从表格提取故障详情 + if (tableData) { + for (const row of tableData) { + const idx = row.findIndex(cell => cell.includes('故障信息')) + if (idx >= 0 && idx + 1 < row.length) { + result.fault_detail = row[idx + 1] + break + } + } + } + return result + } + + extractOrderNumber(subject: string): string | null { + const match = subject.match(/【服务器故障单】(\d+),/) + return match?.[1] ?? null + } +} diff --git a/src/lib/monitor/reminder-engine.ts b/src/lib/monitor/reminder-engine.ts new file mode 100644 index 0000000..8b5a764 --- /dev/null +++ b/src/lib/monitor/reminder-engine.ts @@ -0,0 +1,207 @@ +// src/lib/monitor/reminder-engine.ts +import { getDb } from '@/lib/db' +import type { MonitorConfig, ReminderState } from './types' +import { formatBeijingTime } from './types' + +const logger = { + info: (msg: string) => console.log(`[Worker] ${formatBeijingTime()} INFO ${msg}`), + error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`), +} + +export class ReminderEngine { + private db = getDb() + + isInSilentPeriod(config: MonitorConfig): boolean { + const hour = new Date().getHours() + return hour >= config.monitor.silent_start_hour && hour < config.monitor.silent_end_hour + } + + isTimeToFlush(): boolean { + const now = new Date() + if (now.getHours() !== 7) return false + // 检查今天是否已 flush 过(防止重复执行) + const flushed = this.db.prepare("SELECT id FROM monitor_logs WHERE action = 'flush_complete' AND date(created_at, '+8 hours') = date('now', '+8 hours')").get() + return !flushed + } + + markFlushed(): void { + this.db.prepare("INSERT INTO monitor_logs (action, details) VALUES ('flush_complete', ?)").run(formatBeijingTime()) + } + + createReminder(reminder: Omit): void { + try { + this.db.prepare(` + INSERT OR IGNORE INTO reminder_state (server_sn, server_ip, order_number, reminder_type, deadline_time, deadline_hours, fault_time, tier_name, notification_phase) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(reminder.server_sn, reminder.server_ip, reminder.order_number, reminder.reminder_type, reminder.deadline_time, reminder.deadline_hours, reminder.fault_time, reminder.tier_name, reminder.notification_phase || 'none') + } catch (e) { + logger.error(`Create reminder failed: ${e instanceof Error ? e.message : e}`) + } + } + + addPendingMessage(messageType: 'oem_diag' | 'oem_repair', serverSn: string, payload: object): void { + // 去重:删除同 server_sn + message_type + tier_name 的旧记录 + const tierName = (payload as Record).tier_name + if (tierName) { + this.db.prepare("DELETE FROM pending_messages WHERE server_sn = ? AND message_type = ? AND json_extract(payload, '$.tier_name') = ?").run(serverSn, messageType, tierName) + } else { + this.db.prepare("DELETE FROM pending_messages WHERE server_sn = ? AND message_type = ?").run(serverSn, messageType) + } + this.db.prepare("INSERT INTO pending_messages (message_type, server_sn, payload) VALUES (?, ?, ?)").run(messageType, serverSn, JSON.stringify(payload)) + } + + async checkOemDiagReminders(pushText: (text: string) => Promise, pushDelay: number, getRack?: (ip: string | null, sn: string | null) => Promise, config?: MonitorConfig): Promise { + const reminders = this.db.prepare("SELECT * FROM reminder_state WHERE reminder_type = 'oem_diag' AND notification_phase != 'completed'").all() as ReminderState[] + const now = new Date() + const inSilent = config ? this.isInSilentPeriod(config) : this.isInSilentPeriod({ monitor: { silent_start_hour: 0, silent_end_hour: 7 } } as MonitorConfig) + + for (const r of reminders) { + if (!r.deadline_time || !r.fault_time) continue + const deadline = new Date(r.deadline_time.replace(' ', 'T') + '+08:00') + const faultDate = new Date(r.fault_time.replace(' ', 'T') + '+08:00') + const timeToDeadline = (deadline.getTime() - now.getTime()) / 1000 + const timeSinceFault = (now.getTime() - faultDate.getTime()) / 1000 + + // 超时提醒(只发一次) + if (timeToDeadline <= 0 && r.notification_phase !== 'overdue') { + const rack = getRack ? await getRack(r.server_ip, r.server_sn) : null + let text = '⚠️ OEM诊断工单已超时\n\n' + if (r.order_number) text += `工单号:${r.order_number}\n` + text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n` + if (rack) text += `机架位置:${rack}\n` + text += `到期时间:${r.deadline_time}\n\n服务器尚未录入恢复时间,请尽快处理!` + if (await pushText(text)) { + this.db.prepare("UPDATE reminder_state SET notification_phase = 'overdue', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id) + } + continue + } + if (timeToDeadline <= 0) continue + + // 故障未满 1 小时不提醒 + if (timeSinceFault < 3600) continue + + // 剩余 30 分钟特别提醒 + if (timeToDeadline <= 1800) { + if (inSilent) { + this.addPendingMessage('oem_diag', r.server_sn, { type: 'diag_30min', server_ip: r.server_ip, server_sn: r.server_sn, fault_time: r.fault_time, deadline: r.deadline_time, order_number: r.order_number }) + continue + } + const rack30 = getRack ? await getRack(r.server_ip, r.server_sn) : null + let text = '⚠️ OEM诊断工单即将到期(剩余30分钟)\n\n' + if (r.order_number) text += `工单号:${r.order_number}\n` + text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n` + if (rack30) text += `机架位置:${rack30}\n` + text += `到期时间:${r.deadline_time}\n\n服务器尚未录入恢复时间,请尽快处理!` + if (await pushText(text)) { + this.db.prepare("UPDATE reminder_state SET notification_phase = 'before', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id) + } + continue + } + + // 每小时提醒 + if (r.notification_phase === 'none' || (r.notified_at && (now.getTime() - new Date(r.notified_at.replace(' ', 'T') + '+08:00').getTime()) >= 3600000)) { + if (inSilent) { + this.addPendingMessage('oem_diag', r.server_sn, { type: 'diag_hourly', server_ip: r.server_ip, server_sn: r.server_sn, fault_time: r.fault_time, deadline: r.deadline_time, order_number: r.order_number }) + continue + } + const rackH = getRack ? await getRack(r.server_ip, r.server_sn) : null + const remainingHours = Math.floor(timeToDeadline / 3600) + let text = `⏰ OEM诊断工单提醒(剩余${remainingHours}小时)\n\n` + if (r.order_number) text += `工单号:${r.order_number}\n` + text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n` + if (rackH) text += `机架位置:${rackH}\n` + text += `到期时间:${r.deadline_time}\n服务器尚未录入恢复时间,请及时处理!` + if (await pushText(text)) { + this.db.prepare("UPDATE reminder_state SET notification_phase = 'before', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id) + } + await new Promise(resolve => setTimeout(resolve, pushDelay)) + } + } + } + + async flushPendingMessages(pushText: (text: string) => Promise): Promise { + const pending = this.db.prepare('SELECT * FROM pending_messages').all() as { id: number; message_type: string; server_sn: string; payload: string }[] + for (const p of pending) { + try { + const payload = JSON.parse(p.payload) + if (payload.type === 'repair_overdue' || payload.type === 'repair_all_overdue' || payload.type === 'diag_overdue') { + this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id) + continue + } + // 重新计算剩余时间 + if (payload.deadline) { + const deadline = new Date(payload.deadline.replace(' ', 'T') + '+08:00') + const remaining = (deadline.getTime() - Date.now()) / 1000 + if (remaining <= 0) { + this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id) + continue + } + } + // 构建消息 + let text = '' + if (p.message_type === 'oem_diag') { + text = `⏰ OEM诊断工单提醒\n\n` + if (payload.order_number) text += `工单号:${payload.order_number}\n` + text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n到期时间:${payload.deadline}\n服务器尚未录入恢复时间,请及时处理!` + } else if (p.message_type === 'oem_repair' && payload.type === 'repair_before') { + text = `⏰ OEM维修工单提醒\n\n` + if (payload.order_number) text += `工单号:${payload.order_number}\n` + text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n到期时间:${payload.deadline}\n服务器尚未录入恢复时间,请及时处理!` + } + if (text && await pushText(text)) { + this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id) + } + } catch (e) { + logger.error(`Flush pending failed: ${e instanceof Error ? e.message : e}`) + } + } + } + + async checkOemRepairReminders(pushText: (text: string) => Promise, pushDelay: number, getRack?: (ip: string | null, sn: string | null) => Promise, config?: MonitorConfig): Promise { + const reminders = this.db.prepare("SELECT * FROM reminder_state WHERE reminder_type = 'oem_repair' AND notification_phase != 'completed'").all() as ReminderState[] + const now = new Date() + const inSilent = config ? this.isInSilentPeriod(config) : this.isInSilentPeriod({ monitor: { silent_start_hour: 0, silent_end_hour: 7 } } as MonitorConfig) + + for (const r of reminders) { + if (!r.deadline_time || !r.tier_name) continue + const deadline = new Date(r.deadline_time.replace(' ', 'T') + '+08:00') + const timeToDeadline = (deadline.getTime() - now.getTime()) / 1000 + + if (timeToDeadline <= 0 && r.notification_phase !== 'overdue') { + const rackRO = getRack ? await getRack(r.server_ip, r.server_sn) : null + let text = `⚠️ OEM维修工单已触发${r.tier_name}超时\n\n` + if (r.order_number) text += `工单号:${r.order_number}\n` + text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n` + if (rackRO) text += `机架位置:${rackRO}\n` + text += `${r.tier_name}到期时间:${r.deadline_time}\n\n服务器尚未录入恢复时间,请尽快处理!` + if (await pushText(text)) { + this.db.prepare("UPDATE reminder_state SET notification_phase = 'overdue', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id) + } + await new Promise(resolve => setTimeout(resolve, pushDelay)) + continue + } + if (timeToDeadline <= 0) continue + + if (timeToDeadline <= 3600 && r.notification_phase === 'none') { + if (inSilent) { + this.addPendingMessage('oem_repair', r.server_sn, { type: 'repair_before', tier_name: r.tier_name, server_ip: r.server_ip, server_sn: r.server_sn, fault_time: r.fault_time, deadline: r.deadline_time, order_number: r.order_number }) + continue + } + const rackRB = getRack ? await getRack(r.server_ip, r.server_sn) : null + let text = `⏰ OEM维修工单提醒(距离${r.tier_name}超时还剩1小时)\n\n` + if (r.order_number) text += `工单号:${r.order_number}\n` + text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n` + if (rackRB) text += `机架位置:${rackRB}\n` + text += `${r.tier_name}到期时间:${r.deadline_time}\n服务器尚未录入恢复时间,请及时处理!` + if (await pushText(text)) { + this.db.prepare("UPDATE reminder_state SET notification_phase = 'before', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id) + } + await new Promise(resolve => setTimeout(resolve, pushDelay)) + } + } + } + + cleanupForTicket(orderNumber: string): void { + this.db.prepare("DELETE FROM reminder_state WHERE order_number = ?").run(orderNumber) + } +} diff --git a/src/lib/monitor/settings-manager.ts b/src/lib/monitor/settings-manager.ts new file mode 100644 index 0000000..61e02aa --- /dev/null +++ b/src/lib/monitor/settings-manager.ts @@ -0,0 +1,122 @@ +// src/lib/monitor/settings-manager.ts +import { getDb } from '@/lib/db' +import crypto from 'crypto' +import type { MonitorConfig } from './types' + +const ALGORITHM = 'aes-256-cbc' + +function getEncryptionKey(): string { + const db = getDb() + const row = db.prepare("SELECT value FROM settings WHERE key = 'monitor.encryption_key'").get() as { value: string } | undefined + if (row) return row.value + // 首次启动:生成密钥 + const newKey = crypto.randomBytes(32).toString('hex') + db.prepare("INSERT OR IGNORE INTO settings (key, value, category) VALUES ('monitor.encryption_key', ?, 'security')").run(newKey) + return newKey +} + +export function encryptValue(plaintext: string): string { + if (!plaintext) return '' + const key = Buffer.from(getEncryptionKey(), 'hex') + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + let encrypted = cipher.update(plaintext, 'utf8', 'hex') + encrypted += cipher.final('hex') + return iv.toString('hex') + ':' + encrypted +} + +export function decryptValue(ciphertext: string): string { + if (!ciphertext) return '' + try { + const key = Buffer.from(getEncryptionKey(), 'hex') + const [ivHex, encrypted] = ciphertext.split(':') + const iv = Buffer.from(ivHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted + } catch { + console.error('[Settings] 密码解密失败,可能密钥不匹配') + return '' + } +} + +export function getSetting(key: string): string | null { + const db = getDb() + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined + return row?.value ?? null +} + +export function setSetting(key: string, value: string, category = 'general'): void { + const db = getDb() + db.prepare( + "INSERT INTO settings (key, value, category, updated_at) VALUES (?, ?, ?, datetime('now', '+8 hours')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + ).run(key, value, category) +} + +const DEFAULT_CONFIG: MonitorConfig = { + monitor: { enabled: false, interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 }, + mail: { address: 'gxp@qx002575.com', imap_server: 'imaphz.qiye.163.com', imap_port: 993, smtp_server: 'smtphz.qiye.163.com', smtp_port: 465, password: '' }, + filter: { + subject_keywords: ['服务器故障单'], + sender_email: 'tencent_dcops@tencent.com', + oem_repair_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM维修', exclude_keyword: 'OEM诊断' }, + oem_diag_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM诊断', exclude_keyword: 'OEM维修' }, + }, + wechat: { webhook_url: '' }, +} + +export function getMonitorConfig(): MonitorConfig { + const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)) + const mappings: Record void> = { + 'monitor.enabled': (v) => { config.monitor.enabled = v === 'true' }, + 'monitor.interval_seconds': (v) => { config.monitor.interval_seconds = parseInt(v) || 60 }, + 'monitor.push_delay_ms': (v) => { config.monitor.push_delay_ms = parseInt(v) || 2000 }, + 'monitor.silent_start_hour': (v) => { config.monitor.silent_start_hour = parseInt(v) || 0 }, + 'monitor.silent_end_hour': (v) => { config.monitor.silent_end_hour = parseInt(v) || 7 }, + 'mail.address': (v) => { config.mail.address = v }, + 'mail.imap_server': (v) => { config.mail.imap_server = v }, + 'mail.imap_port': (v) => { config.mail.imap_port = parseInt(v) || 993 }, + 'mail.smtp_server': (v) => { config.mail.smtp_server = v }, + 'mail.smtp_port': (v) => { config.mail.smtp_port = parseInt(v) || 465 }, + 'mail.password': (v) => { config.mail.password = v ? decryptValue(v) : '' }, + 'filter.subject_keywords': (v) => { try { config.filter.subject_keywords = JSON.parse(v) } catch {} }, + 'filter.sender_email': (v) => { config.filter.sender_email = v }, + 'filter.oem_repair_keywords': (v) => { try { config.filter.oem_repair_keywords = JSON.parse(v) } catch {} }, + 'filter.oem_diag_keywords': (v) => { try { config.filter.oem_diag_keywords = JSON.parse(v) } catch {} }, + 'wechat.webhook_url': (v) => { config.wechat.webhook_url = v }, + } + const db = getDb() + const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'monitor.%' OR key LIKE 'mail.%' OR key LIKE 'filter.%' OR key LIKE 'wechat.%'").all() as { key: string; value: string }[] + for (const row of rows) { + mappings[row.key]?.(row.value) + } + return config +} + +export function updateMonitorConfig(updates: Record): void { + const encryptedFields = new Set(['mail.password']) + for (const [key, value] of Object.entries(updates)) { + if (key === 'monitor.encryption_key') continue // 不允许修改密钥 + let stringValue: string + if (typeof value === 'boolean') stringValue = String(value) + else if (typeof value === 'number') stringValue = String(value) + else if (typeof value === 'object') stringValue = JSON.stringify(value) + else stringValue = String(value ?? '') + if (encryptedFields.has(key) && stringValue && !stringValue.match(/^[0-9a-f]{32}:/)) { + stringValue = encryptValue(stringValue) + } + const category = key.split('.')[0] + setSetting(key, stringValue, category) + } +} + +export function getMaskedConfig(): MonitorConfig { + const config = getMonitorConfig() + if (config.mail.password) config.mail.password = '••••••••' + if (config.wechat.webhook_url) { + const keyMatch = config.wechat.webhook_url.match(/key=([0-9a-f-]+)/) + if (keyMatch) config.wechat.webhook_url = config.wechat.webhook_url.replace(keyMatch[1], '••••••••') + } + return config +} diff --git a/src/lib/monitor/ticket-processor.ts b/src/lib/monitor/ticket-processor.ts new file mode 100644 index 0000000..8bf0041 --- /dev/null +++ b/src/lib/monitor/ticket-processor.ts @@ -0,0 +1,85 @@ +// src/lib/monitor/ticket-processor.ts +import { getDb } from '@/lib/db' +import { getRackPosition } from '@/lib/assets-client' +import type { TicketCreateInput, FaultInfo, FaultRecord, MonitorConfig } from './types' +import { formatBeijingTime } from './types' + +const logger = { + info: (msg: string) => console.log(`[Worker] ${formatBeijingTime()} INFO ${msg}`), + error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`), +} + +export class TicketProcessor { + private db = getDb() + + createTicket(input: TicketCreateInput): boolean { + try { + const existing = this.db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(input.ticket_no)) + if (existing) return false + + this.db.prepare(` + INSERT INTO tickets (id, device_ip, device_sn, device_name, content, assign_time, + ticket_type, fault_category, fault_subcategory, responsibility, current_status, counted_in_sla) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + parseInt(input.ticket_no), + input.device_ip, input.device_sn, input.device_name, input.content, + input.assign_time, input.ticket_type, input.fault_category, + input.fault_subcategory, input.responsibility, input.current_status, + input.counted_in_sla, + ) + logger.info(`Ticket created: ${input.ticket_no}`) + // 注意:Worker 创建的工单不记录 ticket_steps(与外部 API 行为一致) + return true + } catch (e) { + logger.error(`Ticket creation failed: ${e instanceof Error ? e.message : e}`) + return false + } + } + + async getRackPosition(ip: string | null, sn: string | null): Promise { + return getRackPosition(ip, sn) + } + + recordFault(fault: FaultRecord): void { + try { + const existing = this.db.prepare('SELECT id FROM fault_records WHERE server_sn = ? AND fault_time = ?').get(fault.server_sn, fault.fault_time) + if (existing) return + + this.db.prepare(` + INSERT INTO fault_records (server_sn, server_ip, order_number, fault_type, fault_time, fault_detail, status) + VALUES (?, ?, ?, ?, ?, ?, 'ongoing') + `).run(fault.server_sn, fault.server_ip, fault.order_number, fault.fault_type, fault.fault_time, fault.fault_detail) + logger.info(`Fault recorded: ${fault.server_sn} - ${fault.fault_time}`) + } catch (e) { + logger.error(`Fault recording failed: ${e instanceof Error ? e.message : e}`) + } + } + + createTicketFromEmail( + faultInfo: FaultInfo, + type: 'oem_diag' | 'oem_repair', + config: MonitorConfig, + content: string, + ): string | null { + if (!faultInfo.order_number || !faultInfo.fault_time) return null + + const ticketType = type === 'oem_diag' ? 'OEM诊断' : 'OEM维修' + const input: TicketCreateInput = { + ticket_no: faultInfo.order_number, + device_ip: faultInfo.server_ip, + device_sn: faultInfo.server_sn, + device_name: null, + content, + assign_time: faultInfo.fault_time, + ticket_type: ticketType, + fault_category: ticketType, + fault_subcategory: null, + responsibility: null, + current_status: 'open', + counted_in_sla: 1, + } + const created = this.createTicket(input) + return created ? faultInfo.order_number : null + } +} diff --git a/src/lib/monitor/types.ts b/src/lib/monitor/types.ts new file mode 100644 index 0000000..bb02f9c --- /dev/null +++ b/src/lib/monitor/types.ts @@ -0,0 +1,122 @@ +// src/lib/monitor/types.ts + +export interface FaultRecord { + id?: number + server_sn: string + server_ip: string | null + order_number: string | null + fault_type: 'oem_diag' | 'oem_repair' | null + fault_time: string + recovery_time: string | null + duration_seconds: number | null + status: 'ongoing' | 'resolved' + fault_detail: string | null + created_at?: string +} + +export interface ReminderState { + id?: number + server_sn: string + server_ip: string | null + order_number: string | null + reminder_type: 'oem_diag' | 'oem_repair' + deadline_time: string | null + deadline_hours: number | null + fault_time: string | null + tier_name: string | null + notification_phase: 'none' | 'before' | 'overdue' | 'completed' + notified_at: string | null + created_at?: string +} + +export interface ProcessedEmail { + msg_id: string + subject: string | null + processed_at?: string +} + +export interface PendingMessage { + id?: number + message_type: 'oem_diag' | 'oem_repair' + server_sn: string + payload: string // JSON + created_at?: string +} + +export interface MonitorLog { + id?: number + email_msg_id: string | null + email_subject: string | null + action: string + details: string | null + created_at?: string +} + +export interface MonitorConfig { + monitor: { + enabled: boolean + interval_seconds: number + push_delay_ms: number + silent_start_hour: number + silent_end_hour: number + } + mail: { + address: string + imap_server: string + imap_port: number + smtp_server: string + smtp_port: number + password: string + } + filter: { + subject_keywords: string[] + sender_email: string + oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } + oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } + } + wechat: { + webhook_url: string + } +} + +export interface EmailInfo { + msg_id: string + subject: string + sender: string + date: string + html: string | null +} + +export interface FaultInfo { + fault_time: string | null + server_sn: string | null + server_ip: string | null + order_number: string | null + fault_detail: string | null +} + +export interface TicketCreateInput { + ticket_no: string + device_ip: string | null + device_sn: string | null + device_name: string | null + content: string | null + assign_time: string + ticket_type: string | null + fault_category: string | null + fault_subcategory: string | null + responsibility: string | null + current_status: string + counted_in_sla: number +} + +export interface TierDeadline { + deadline: string + hours: number +} + +// UTC+8 北京时间格式化(禁止使用 toISOString) +export function formatBeijingTime(date: Date = new Date()): string { + const d = new Date(date.getTime() + 8 * 3600_000) + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')}` +} diff --git a/src/lib/monitor/wechat-pusher.ts b/src/lib/monitor/wechat-pusher.ts new file mode 100644 index 0000000..603948d --- /dev/null +++ b/src/lib/monitor/wechat-pusher.ts @@ -0,0 +1,76 @@ +// src/lib/monitor/wechat-pusher.ts +import type { MonitorConfig } from './types' +import { formatBeijingTime } from './types' + +const logger = { + info: (msg: string) => console.log(`[Worker] ${formatBeijingTime()} INFO ${msg}`), + error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`), +} + +export class WeChatPusher { + async pushText(text: string, webhookUrl: string): Promise { + if (!webhookUrl) { logger.error('Webhook URL not configured'); return false } + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ msgtype: 'text', text: { content: text } }), + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok) { logger.error(`Webhook HTTP ${response.status}`); return false } + const result = await response.json() as { errcode?: number; errmsg?: string } + if (result.errcode === 0) { logger.info('WeChat message sent'); return true } + logger.error(`WeChat error: ${result.errmsg}`) + return false + } catch (e) { + logger.error(`Webhook failed: ${e instanceof Error ? e.message : e}`) + return false + } + } + + formatAvailabilityMessage( + deadlines: Record, + faultInfo: { server_ip: string | null; server_sn: string | null; fault_time: string | null }, + isOemDiag: boolean, + oemDeadline: string | null, + orderNumber: string | null, + rackPosition: string | null, + faultDetail: string | null, + ): string { + let msg = '' + const typeLabel = isOemDiag ? 'OEM诊断' : 'OEM维修' + msg += `【服务器故障单】 - ${typeLabel}\n\n` + if (orderNumber) msg += `工单号:${orderNumber}\n` + msg += `服务器IP:${faultInfo.server_ip || '未知'}\n` + msg += `服务器SN:${faultInfo.server_sn || '未知'}\n` + msg += `机架位置:${rackPosition || '未知'}\n` + if (faultInfo.fault_time) msg += `故障时间:${faultInfo.fault_time}\n` + if (faultDetail) msg += `故障信息:${faultDetail}\n` + + if (isOemDiag && oemDeadline) { + msg += `到期时间:${oemDeadline}\n` + } else if (!isOemDiag) { + msg += '\n📊 服务可用性各档位截止时间:\n\n' + const tierDesc: Record = { + '第一档': '99%(含)以上(不扣费)', + '第二档': '99%-97%(含)(扣10%)', + '第三档': '97%-95%(含)(扣25%)', + '第四档': '95%-90%(含)(扣50%)', + '第五档': '90%(不含)以下(扣100%)', + } + const emojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣'] + let i = 0 + for (const [tier, desc] of Object.entries(tierDesc)) { + const d = deadlines[tier] + if (d) { + msg += `${emojis[i]} ${desc}\n` + msg += ` 截止时间:${d.deadline}` + if (d.hours > 0) msg += `(${d.hours}小时)` + msg += '\n\n' + } + i++ + } + } + return msg.trim() + } +} diff --git a/src/lib/monitor/worker.ts b/src/lib/monitor/worker.ts new file mode 100644 index 0000000..c73a9d9 --- /dev/null +++ b/src/lib/monitor/worker.ts @@ -0,0 +1,210 @@ +// src/lib/monitor/worker.ts +import { getDb } from '@/lib/db' +import cron from 'node-cron' +import { MailMonitor } from './mail-monitor' +import { TicketProcessor } from './ticket-processor' +import { WeChatPusher } from './wechat-pusher' +import { AvailabilityEngine } from './availability-engine' +import { ReminderEngine } from './reminder-engine' +import { getMonitorConfig, getSetting, setSetting } from './settings-manager' +import type { MonitorConfig } from './types' +import { formatBeijingTime } from './types' + +const logger = { + info: (msg: string) => console.log(`[Worker] ${formatBeijingTime()} INFO ${msg}`), + error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`), +} + +export class BackgroundWorker { + private mailMonitor = new MailMonitor() + private ticketProcessor = new TicketProcessor() + private wechatPusher = new WeChatPusher() + private availabilityEngine = new AvailabilityEngine() + private reminderEngine = new ReminderEngine() + private processingPromise: Promise | null = null + cronJob: any = null + private lastTickTime = 0 + private db = getDb() + + start(): void { + if (this.cronJob) return + this.cronJob = cron.schedule('* * * * *', () => { this.tick() }) + logger.info('Cron job started (every 60 seconds)') + } + + stop(): void { + if (this.cronJob) { + this.cronJob.stop() + this.cronJob = null + } + this.mailMonitor.disconnect() + logger.info('Worker stopped') + } + + async tick(): Promise { + if (this.processingPromise) return + this.processingPromise = this.doTick() + try { await this.processingPromise } + finally { this.processingPromise = null } + } + + private async doTick(): Promise { + const startTime = Date.now() + try { + const config = getMonitorConfig() + if (!config.monitor.enabled) return + + // 检查是否被 API 手动触发 + const triggerRequested = getSetting('monitor.trigger_requested') === 'true' + if (triggerRequested) { + setSetting('monitor.trigger_requested', 'false', 'monitor') + // 立即执行,忽略 interval_seconds + } else if (Date.now() - this.lastTickTime < config.monitor.interval_seconds * 1000) { + return + } + this.lastTickTime = Date.now() + + // Step 3: 检查恢复 + await this.checkRecoveryUpdates(config) + + // Step 4: 7 点 flush + if (this.reminderEngine.isTimeToFlush()) { + await this.reminderEngine.flushPendingMessages((text) => this.wechatPusher.pushText(text, config.wechat.webhook_url)) + this.reminderEngine.markFlushed() + } + + // Step 5-6: 检查提醒 + const pushText = (text: string) => this.wechatPusher.pushText(text, config.wechat.webhook_url) + const getRack = (ip: string | null, sn: string | null) => this.ticketProcessor.getRackPosition(ip, sn) + await this.reminderEngine.checkOemDiagReminders(pushText, config.monitor.push_delay_ms, getRack, config) + await this.reminderEngine.checkOemRepairReminders(pushText, config.monitor.push_delay_ms, getRack, config) + + // Step 7: IMAP 连接 + if (!this.mailMonitor.isConnected()) { + try { await this.mailMonitor.connect(config.mail) } + catch (e) { await this.mailMonitor.reconnect(config.mail) } + } + + // Step 8-9: 搜索并处理邮件 + const emails = await this.mailMonitor.fetchUnread(config) + let processed = 0, errors = 0 + + for (const email of emails) { + try { + // 去重检查 + const existing = this.db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(email.msg_id) + if (existing) continue + + // 过滤 + const { pass, type } = this.mailMonitor.filterEmail(email, config.filter) + if (!pass || !type) continue + + // 提取表格和故障信息 + const tableData = email.html ? this.mailMonitor.extractTable(email.html) : null + const faultInfo = email.html ? this.mailMonitor.extractFaultInfo(email.html, tableData) : null + if (!faultInfo?.order_number || !faultInfo?.fault_time) continue + + // 创建工单 + const content = faultInfo.fault_detail || '' + this.ticketProcessor.createTicketFromEmail(faultInfo, type, config, content) + + // 获取机架位置 + const rackPosition = await this.ticketProcessor.getRackPosition(faultInfo.server_ip, faultInfo.server_sn) + + // 计算可用性 + const monthKey = faultInfo.fault_time.slice(0, 7) + let deadlines: Record = {} + let oemDeadline: string | null = null + if (type === 'oem_repair') { + deadlines = this.availabilityEngine.calculateTierDeadlines(monthKey, faultInfo.server_sn!, faultInfo.fault_time) + } else { + oemDeadline = this.availabilityEngine.calculateOemDiagDeadline(monthKey, faultInfo.fault_time) + } + + // 推送微信 + const message = this.wechatPusher.formatAvailabilityMessage(deadlines, faultInfo, type === 'oem_diag', oemDeadline, faultInfo.order_number, rackPosition, faultInfo.fault_detail) + await this.wechatPusher.pushText(message, config.wechat.webhook_url) + + // 记录故障 + this.ticketProcessor.recordFault({ + server_sn: faultInfo.server_sn!, server_ip: faultInfo.server_ip, order_number: faultInfo.order_number, + fault_type: type, fault_time: faultInfo.fault_time!, fault_detail: faultInfo.fault_detail, status: 'ongoing', + recovery_time: null, duration_seconds: null, + }) + + // 创建提醒 + if (type === 'oem_diag' && oemDeadline) { + this.reminderEngine.createReminder({ + server_sn: faultInfo.server_sn!, server_ip: faultInfo.server_ip, order_number: faultInfo.order_number, + reminder_type: 'oem_diag', deadline_time: oemDeadline, deadline_hours: null, + fault_time: faultInfo.fault_time, tier_name: null, notification_phase: 'none', notified_at: null, + }) + } else if (type === 'oem_repair') { + for (const [tierName, tierData] of Object.entries(deadlines)) { + this.reminderEngine.createReminder({ + server_sn: faultInfo.server_sn!, server_ip: faultInfo.server_ip, order_number: faultInfo.order_number, + reminder_type: 'oem_repair', deadline_time: tierData.deadline, deadline_hours: tierData.hours, + fault_time: faultInfo.fault_time, tier_name: tierName, notification_phase: 'none', notified_at: null, + }) + } + } + + // 标记已处理 + this.db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(email.msg_id, email.subject) + processed++ + } catch (e) { + errors++ + logger.error(`Email error: ${e instanceof Error ? e.message : e}`) + } + } + + // NOOP 保活 + await this.mailMonitor.keepAlive() + + // 日志清理(每天北京时间 3 点) + const nowBeijing = formatBeijingTime() + const hour = parseInt(nowBeijing.slice(11, 13)) + const minute = parseInt(nowBeijing.slice(14, 16)) + if (hour === 3 && minute === 0) { + this.db.prepare("DELETE FROM monitor_logs WHERE created_at < datetime('now', '+8 hours', '-90 days')").run() + this.db.prepare("DELETE FROM processed_emails WHERE processed_at < datetime('now', '+8 hours', '-90 days')").run() + this.db.prepare("DELETE FROM pending_messages WHERE created_at < datetime('now', '+8 hours', '-24 hours')").run() + } + + // 记录 tick + const duration = Date.now() - startTime + this.db.prepare("INSERT INTO monitor_logs (action, details) VALUES (?, ?)").run( + 'tick_complete', JSON.stringify({ processed, errors, duration, timestamp: formatBeijingTime() }) + ) + if (processed > 0) logger.info(`Tick complete: ${processed} processed, ${errors} errors, ${duration}ms`) + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e) + logger.error(`Tick error: ${errorMsg}`) + // 将错误写入数据库,便于状态 API 检测 + try { + this.db.prepare("INSERT INTO monitor_logs (action, details) VALUES (?, ?)").run( + 'error', JSON.stringify({ error: errorMsg, timestamp: formatBeijingTime() }) + ) + } catch { /* 忽略日志写入失败 */ } + } + } + + private async checkRecoveryUpdates(config: MonitorConfig): Promise { + const ongoing = this.db.prepare("SELECT * FROM fault_records WHERE status = 'ongoing'").all() as { id: number; order_number: string | null; server_sn: string; server_ip: string | null; fault_time: string }[] + for (const fault of ongoing) { + if (!fault.order_number) continue + const ticket = this.db.prepare('SELECT current_status, close_time FROM tickets WHERE id = ?').get(parseInt(fault.order_number)) as { current_status: string; close_time: string | null } | undefined + if (ticket && ['resolved', 'closed'].includes(ticket.current_status)) { + const recoveryTime = ticket.close_time || formatBeijingTime(new Date()) + const faultDate = new Date(fault.fault_time.replace(' ', 'T') + '+08:00') + const recoveryDate = new Date(recoveryTime.replace(' ', 'T') + '+08:00') + const duration = Math.floor((recoveryDate.getTime() - faultDate.getTime()) / 1000) + this.db.prepare("UPDATE fault_records SET status = 'resolved', recovery_time = ?, duration_seconds = ? WHERE id = ?").run(recoveryTime, duration, fault.id) + this.reminderEngine.cleanupForTicket(fault.order_number) + // 发送结单提醒 + await this.wechatPusher.pushText(`✅ ${fault.order_number}工单已结单\n\n服务器IP:${fault.server_ip || '未知'}\n服务器SN:${fault.server_sn}\n结单时间:${recoveryTime}\n本次处理时长:${Math.floor(duration / 3600)}小时${Math.floor((duration % 3600) / 60)}分钟`, config.wechat.webhook_url) + logger.info(`Recovery detected: ${fault.order_number}`) + } + } + } +} diff --git a/src/types/mailparser.d.ts b/src/types/mailparser.d.ts new file mode 100644 index 0000000..1749efd --- /dev/null +++ b/src/types/mailparser.d.ts @@ -0,0 +1,16 @@ +declare module 'mailparser' { + import { Readable } from 'stream' + + interface ParsedMail { + messageId?: string + subject?: string + from?: { text: string } + date?: Date + html?: string | false + text?: string + } + + function simpleParser(source: Buffer | string | Readable): Promise + + export { simpleParser, ParsedMail } +} diff --git a/tsconfig.worker.json b/tsconfig.worker.json new file mode 100644 index 0000000..4acaf3c --- /dev/null +++ b/tsconfig.worker.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": ".next/standalone", + "declaration": false, + "noEmit": false, + "skipLibCheck": true, + "strict": false, + "noImplicitAny": false, + "module": "commonjs", + "moduleResolution": "node", + "target": "es2020" + }, + "include": [ + "scripts/monitor-worker.ts", + "src/lib/monitor/**/*.ts", + "src/lib/db.ts", + "src/lib/db-schema.ts", + "src/lib/assets-client.ts" + ], + "exclude": ["node_modules"] +}