feat: 审计日志 + 邮件监控 + 修复删除工单 bug

This commit is contained in:
gitadmin 2026-06-24 15:43:18 +08:00
parent 1c1555e417
commit b7b2b34a1f
40 changed files with 3466 additions and 136 deletions

View File

@ -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-aiNode.js 实现
- [新增] Background Worker独立进程运行entrypoint.shnode-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邮箱配置、邮件过滤、微信推送、运行参数
- [新增] 分模块保存按钮 + 高亮修改字段 + 错误详情可点击展开
- [新增] 运行状态面板:今日处理、累计、错误、进行中故障均可点击展开详情
- [新增] 监控 APIstatus/start/stop/trigger/settings/test-mail/test-wechatadmin 权限)
- [新增] 审计日志集成:所有监控操作记录到 audit_logs
- [新增] 数据迁移脚本:从 zabbix-01 迁移历史故障数据
- [新增] Docker 集成entrypoint.sh 包装脚本builder 阶段编译 Worker 模块
## 2026-05-18
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航

View File

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

View File

@ -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 阶段:使用 Debianglibc以支持 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"]

View File

@ -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}$/)
})
})

24
entrypoint.sh Normal file
View File

@ -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

736
package-lock.json generated
View File

@ -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",

View File

@ -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"
}

View File

@ -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<string, ServerData>][]) {
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}`)

46
scripts/monitor-worker.ts Normal file
View File

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

View File

@ -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<string, { label: string; color: 'green' | 'blue' | 'red' | 'gray' }> = {
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<string, { label: string; variant: 'success' | 'info' | 'danger' | 'default' }> = {
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<string, string> = {
@ -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<AuditLog | null>(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<string, string> = {
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<string, unknown>) {
// 字段变更assets-ai 格式)
if (details.changes && typeof details.changes === 'object') {
const changes = details.changes as Record<string, { from: unknown; to: unknown }>
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📋 </p>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-slate-100 dark:bg-slate-700">
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{Object.entries(changes).map(([key, val]) => (
<tr key={key} className="border-t border-slate-200 dark:border-slate-700">
<td className="px-3 py-2 text-slate-500">{FIELD_LABELS[key] || key}</td>
<td className="px-3 py-2 text-slate-400 line-through">{formatValue(val.from)}</td>
<td className="px-3 py-2 text-green-600 dark:text-green-400 font-medium">{formatValue(val.to)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// 新建记录assets-ai 格式)
if (details.created && typeof details.created === 'object') {
const data = details.created as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-green-600 dark:text-green-400 mb-2">🟢 </p>
<div className="grid grid-cols-2 gap-2">
{Object.entries(data).map(([key, val]) => (
<div key={key} className="flex gap-2 text-sm">
<span className="text-slate-500">{FIELD_LABELS[key] || key}</span>
<span>{formatValue(val)}</span>
</div>
))}
</div>
</div>
)
}
// 删除记录assets-ai 格式)
if (details.deleted && typeof details.deleted === 'object') {
const data = details.deleted as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-red-600 dark:text-red-400 mb-2">🔴 </p>
<div className="grid grid-cols-2 gap-2">
{Object.entries(data).map(([key, val]) => (
<div key={key} className="flex gap-2 text-sm">
<span className="text-slate-500">{FIELD_LABELS[key] || key}</span>
<span>{formatValue(val)}</span>
</div>
))}
</div>
</div>
)
}
// 批量更新assets-ai 格式)
if (details.batch_update && typeof details.batch_update === 'object') {
const data = details.batch_update as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📦 {String(data.count)} </p>
{data.ids ? <p className="text-sm text-slate-500"> ID{String(data.ids)}</p> : null}
</div>
)
}
// 导入assets-ai 格式)
if (details.import && typeof details.import === 'object') {
const data = details.import as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📥 </p>
<div className="flex gap-4 text-sm">
<span>{String(data.created)} </span>
<span>{String(data.updated)} </span>
</div>
</div>
)
}
// 导出assets-ai 格式)
if (details.export && typeof details.export === 'object') {
const data = details.export as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">📤 {String(data.count)} </p>
</div>
)
}
// issue-ai 特有格式:操作说明
if (details.message && typeof details.message === 'string') {
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📝 </p>
<p className="text-sm text-slate-600 dark:text-slate-400">{details.message}</p>
</div>
)
}
// issue-ai 特有格式:配置修改
if (details.keys && Array.isArray(details.keys)) {
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2"> </p>
<ul className="list-disc list-inside space-y-1">
{(details.keys as string[]).map((key, i) => (
<li key={i} className="text-sm text-slate-600 dark:text-slate-400 font-mono">{key}</li>
))}
</ul>
</div>
)
}
// 登录信息
if (details.username && typeof details.username === 'string') {
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">👤 </p>
<div className="grid grid-cols-2 gap-2">
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{details.username}</span>
</div>
{details.role && (
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{String(details.role)}</span>
</div>
)}
{details.displayName && (
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{String(details.displayName)}</span>
</div>
)}
</div>
</div>
)
}
// 保留天数设置
if (details.retention_days !== undefined) {
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2"> </p>
<p className="text-sm text-slate-600 dark:text-slate-400"> <span className="font-medium">{String(details.retention_days)}</span> </p>
</div>
)
}
// 清理操作
if (details.cleanup) {
const data = details.cleanup as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">🧹 </p>
<p className="text-sm text-slate-600 dark:text-slate-400"> {String(data.deleted)} </p>
</div>
)
}
// 兜底:格式化 JSON
return (
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
{JSON.stringify(details, null, 2)}
</pre>
)
}
const columns = [
{
key: 'created_at',
title: '时间',
width: '170px',
render: (r) => <span className="text-sm whitespace-nowrap">{r.created_at}</span>
},
{
key: 'username',
title: '用户',
width: '100px',
render: (r) => (
<div className="flex items-center gap-1 whitespace-nowrap">
<span>{r.username || '-'}</span>
{r.api_key_id && <Badge variant="warning">[API]</Badge>}
</div>
)
},
{
key: 'action',
title: '操作',
width: '70px',
render: (r) => {
const info = ACTION_LABELS[r.action] || { label: r.action, variant: 'default' as const }
return <span className="whitespace-nowrap"><Badge variant={info.variant}>{info.label}</Badge></span>
}
},
{
key: 'entity_type',
title: '对象',
width: '80px',
render: (r) => <span className="whitespace-nowrap">{ENTITY_LABELS[r.entity_type] || r.entity_type}</span>
},
{
key: 'details',
title: '详情',
render: (r) => (
<button
onClick={() => setDetailLog(r)}
className="text-left text-sm text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 truncate max-w-[200px]"
>
{formatDetails(r)}
</button>
)
},
{
key: 'ip_address',
title: 'IP',
width: '120px',
render: (r) => <span className="text-xs font-mono">{r.ip_address || '-'}</span>
},
]
// 保留天数按钮样式
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"> 180 </p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleExport}></Button>
<Button size="sm" variant="secondary" onClick={handleExport}></Button>
</div>
</div>
{/* 保留天数设置 */}
<div className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg text-sm">
<span className="text-slate-600 dark:text-slate-400"></span>
<input
type="number"
min={30}
max={365}
value={retentionInput}
onChange={(e) => 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"
/>
<span className="text-xs text-slate-400">30-365 180 </span>
{retentionSavedFeedback ? (
<span className="text-xs font-medium text-green-600 dark:text-green-400"> </span>
) : retentionError ? (
<span className="text-xs font-medium text-red-600 dark:text-red-400"> </span>
) : retentionDirty ? (
<button
className="px-3 py-1 text-xs font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
onClick={saveRetentionDays}
disabled={retentionSaving}
>
{retentionSaving ? '保存中...' : '保存'}
</button>
) : null}
</div>
{/* 过滤器 */}
<Card className="p-4">
<div className="flex flex-wrap items-center gap-3">
<select
value={actionFilter}
onChange={(e) => { setActionFilter(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"
>
<option value=""></option>
{Object.entries(ACTION_LABELS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<div className="flex flex-wrap items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
<select
value={actionFilter}
onChange={(e) => { setActionFilter(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"
>
<option value=""></option>
{Object.entries(ACTION_LABELS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<select
value={entityFilter}
onChange={(e) => { setEntityFilter(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"
>
<option value=""></option>
{Object.entries(ENTITY_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<select
value={entityFilter}
onChange={(e) => { setEntityFilter(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"
>
<option value=""></option>
{Object.entries(ENTITY_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<input
type="date"
value={dateFrom}
onChange={(e) => { 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"
/>
<span className="text-slate-400">~</span>
<input
type="date"
value={dateTo}
onChange={(e) => { 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"
/>
<input
type="date"
value={dateFrom}
onChange={(e) => { 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"
/>
<span className="text-slate-400">~</span>
<input
type="date"
value={dateTo}
onChange={(e) => { 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"
/>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => setQuickDate(7)}>7</Button>
<Button size="sm" variant="ghost" onClick={() => setQuickDate(30)}>30</Button>
<Button size="sm" variant="ghost" onClick={() => setQuickDate(180)}>180</Button>
</div>
<Button size="sm" variant="ghost" onClick={resetFilters}></Button>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => setQuickDate(7)}>7</Button>
<Button size="sm" variant="ghost" onClick={() => setQuickDate(30)}>30</Button>
<Button size="sm" variant="ghost" onClick={() => setQuickDate(180)}>180</Button>
</div>
</Card>
<Button size="sm" variant="ghost" onClick={resetFilters}></Button>
</div>
{/* 表格 */}
<Card>
{loading ? (
<div className="py-12 text-center text-slate-500 dark:text-slate-400">...</div>
) : (
<Table headers={['时间', '用户', '操作', '对象', '详情', 'IP']}>
{logs.map((log) => (
<tr key={log.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td className="px-4 py-3 text-sm">{log.created_at}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<span>{log.username || '-'}</span>
{log.api_key_id && <Badge variant="warning">[API]</Badge>}
</div>
</td>
<td className="px-4 py-3">
<Badge variant={ACTION_LABELS[log.action]?.color === 'green' ? 'success' : ACTION_LABELS[log.action]?.color === 'blue' ? 'info' : ACTION_LABELS[log.action]?.color === 'red' ? 'danger' : 'default'}>
{ACTION_LABELS[log.action]?.label || log.action}
</Badge>
</td>
<td className="px-4 py-3">{ENTITY_LABELS[log.entity_type] || log.entity_type}</td>
<td className="px-4 py-3">
<button
onClick={() => setDetailLog(log)}
className="text-left text-sm text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 truncate max-w-[200px]"
>
{formatDetails(log)}
</button>
</td>
<td className="px-4 py-3 text-xs font-mono">{log.ip_address || '-'}</td>
</tr>
))}
{logs.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-slate-500"></td>
</tr>
)}
</Table>
)}
</Card>
{loading ? (
<div className="py-12 text-center text-slate-500 dark:text-slate-400">...</div>
) : (
<Table columns={columns as any} data={logs as any} rowKey={(r: any) => String(r.id)} />
)}
{/* 分页 */}
{total > pageSize && (
<div className="flex items-center justify-center gap-2">
<Button
size="sm"
variant="secondary"
variant="ghost"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
>
@ -248,7 +554,7 @@ export default function AuditLogsPage() {
</span>
<Button
size="sm"
variant="secondary"
variant="ghost"
disabled={page >= Math.ceil(total / pageSize)}
onClick={() => setPage(p => p + 1)}
>
@ -273,11 +579,11 @@ export default function AuditLogsPage() {
<div>
<span className="text-slate-500"></span>
<span>{detailLog.username || '-'}</span>
{detailLog.api_key_id && <Badge variant="warning" className="ml-1">[API]</Badge>}
{detailLog.api_key_id && <span className="ml-1"><Badge variant="warning">[API]</Badge></span>}
</div>
<div>
<span className="text-slate-500"></span>
<Badge variant={ACTION_LABELS[detailLog.action]?.color === 'green' ? 'success' : ACTION_LABELS[detailLog.action]?.color === 'blue' ? 'info' : ACTION_LABELS[detailLog.action]?.color === 'red' ? 'danger' : 'default'}>
<Badge variant={ACTION_LABELS[detailLog.action]?.variant || 'default'}>
{ACTION_LABELS[detailLog.action]?.label || detailLog.action}
</Badge>
</div>
@ -293,11 +599,8 @@ export default function AuditLogsPage() {
</div>
{detailLog.details && (
<div>
<span className="text-sm text-slate-500"></span>
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
{JSON.stringify(JSON.parse(detailLog.details), null, 2)}
</pre>
<div className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
{renderDetails(JSON.parse(detailLog.details))}
</div>
)}
</div>

View File

@ -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<string, unknown>
const curr = current as Record<string, unknown>
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<MonitorStatus | null>(null)
const [config, setConfig] = useState<MonitorConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<MonitorConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState<SectionKey | null>(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<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
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 (
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => handleSectionSave(section)}
disabled={saving === section}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-sm font-medium"
>
{saving === section ? '保存中...' : '保存设置'}
</button>
</div>
)
}
if (loading) return <div className="p-6">...</div>
if (!config) return <div className="p-6"></div>
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
{message && (
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
{message.text}
<button onClick={() => setMessage(null)} className="float-right font-bold">×</button>
</div>
)}
{/* 运行状态 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="flex items-center gap-4 mb-4">
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium ${status?.workerHealth === 'healthy' ? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400' : status?.workerHealth === 'error' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : status?.workerHealth === 'unresponsive' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}`}>
<span className={`w-2 h-2 rounded-full ${status?.workerHealth === 'healthy' ? 'bg-green-500' : status?.workerHealth === 'error' || status?.workerHealth === 'unresponsive' ? 'bg-red-500' : 'bg-slate-400'}`} />
{status?.workerHealth === 'healthy' ? '运行中' : status?.workerHealth === 'error' ? '运行异常' : status?.workerHealth === 'unresponsive' ? '无响应' : '已停止'}
</span>
{status?.lastCheck && <span className="text-sm text-slate-500">: {status.lastCheck}</span>}
</div>
{/* 统计指标 - 可点击展开 */}
<div className="grid grid-cols-4 gap-4 mb-4">
{[
{ 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 => (
<div key={item.key}>
<span className="text-slate-500 text-sm">{item.label}: </span>
<button
onClick={() => setExpandedPanel(expandedPanel === item.key ? null : item.key)}
className={`font-medium text-sm cursor-pointer hover:opacity-70 underline decoration-dotted underline-offset-2 transition-opacity ${item.color}`}
>
{item.value}
<span className="ml-1 text-xs">{expandedPanel === item.key ? '▲' : '▼'}</span>
</button>
</div>
))}
</div>
{/* 展开面板 */}
{expandedPanel && (
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
{/* 今日处理详情 */}
{expandedPanel === 'today' && status?.details?.todayProcessed && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
{status.details.todayProcessed.length === 0 ? (
<p className="text-sm text-slate-500"></p>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{status.details.todayProcessed.map((item, i) => (
<div key={i} className="flex items-center justify-between text-sm py-1 border-b border-slate-100 dark:border-slate-800 last:border-0">
<span className="text-slate-700 dark:text-slate-300">
{item.ticket_no ? `工单 ${item.ticket_no}` : item.error || '处理完成'}
</span>
<span className="text-slate-400 text-xs">{item.created_at}</span>
</div>
))}
</div>
)}
</div>
)}
{/* 累计统计详情 */}
{expandedPanel === 'total' && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{status?.stats.totalProcessed || 0}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-blue-600">{status?.stats.todayProcessed || 0}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-red-600">{status?.stats.errors || 0}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-amber-600">{status?.ongoingFaults || 0}</p>
</div>
</div>
</div>
)}
{/* 错误详情 */}
{expandedPanel === 'errors' && status?.details?.recentErrors && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-red-700 dark:text-red-400"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
{status.details.recentErrors.length === 0 ? (
<p className="text-sm text-slate-500"></p>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{status.details.recentErrors.map((item, i) => (
<div key={i} className="p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
<div className="flex items-center justify-between mb-1">
<span className="text-red-700 dark:text-red-400 font-medium"></span>
<span className="text-slate-400">{item.created_at}</span>
</div>
<pre className="text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
{(() => {
const errStr = item.error || item.details || JSON.stringify(item)
try { return JSON.stringify(JSON.parse(errStr), null, 2) }
catch { return errStr }
})()}
</pre>
</div>
))}
</div>
)}
</div>
)}
{/* 进行中故障详情 */}
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-amber-700 dark:text-amber-400"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
{status.details.ongoingFaults.length === 0 ? (
<p className="text-sm text-slate-500"></p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 text-left">
<th className="py-2 pr-4 text-slate-500 font-medium"></th>
<th className="py-2 pr-4 text-slate-500 font-medium">IP</th>
<th className="py-2 pr-4 text-slate-500 font-medium">SN</th>
<th className="py-2 pr-4 text-slate-500 font-medium"></th>
<th className="py-2 pr-4 text-slate-500 font-medium"></th>
<th className="py-2 text-slate-500 font-medium"></th>
</tr>
</thead>
<tbody>
{status.details.ongoingFaults.map((fault, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800 last:border-0">
<td className="py-2 pr-4 font-mono text-xs">{fault.order_number || '-'}</td>
<td className="py-2 pr-4">{fault.server_ip || '未知'}</td>
<td className="py-2 pr-4 font-mono text-xs">{fault.server_sn}</td>
<td className="py-2 pr-4">
<span className={`px-2 py-0.5 rounded text-xs ${fault.fault_type === 'oem_diag' ? 'bg-blue-100 text-blue-700' : fault.fault_type === 'oem_repair' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'}`}>
{fault.fault_type === 'oem_diag' ? '诊断' : fault.fault_type === 'oem_repair' ? '维修' : '未知'}
</span>
</td>
<td className="py-2 pr-4 text-xs">{fault.fault_time}</td>
<td className="py-2 text-xs max-w-xs truncate" title={fault.fault_detail || ''}>{fault.fault_detail || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)}
<div className="flex gap-2">
{status?.enabled ? <button onClick={handleStop} className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm"></button> : <button onClick={handleStart} className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm"></button>}
<button onClick={handleTrigger} className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm"></button>
</div>
</Card>
{/* 邮箱配置 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="text" value={config.mail.address} onChange={e => setConfig({ ...config, mail: { ...config.mail, address: e.target.value } })} className={inputClass('mail.address')} />
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="password" value={config.mail.password} onChange={e => setConfig({ ...config, mail: { ...config.mail, password: e.target.value } })} className={inputClass('mail.password')} placeholder="••••••••" />
</div>
<div>
<label className="block text-sm font-medium mb-1">IMAP </label>
<input type="text" value={config.mail.imap_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_server: e.target.value } })} className={inputClass('mail.imap_server')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">IMAP </label>
<input type="number" value={config.mail.imap_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_port: parseInt(e.target.value) || 993 } })} className={inputClass('mail.imap_port')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">SMTP </label>
<input type="text" value={config.mail.smtp_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_server: e.target.value } })} className={inputClass('mail.smtp_server')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">SMTP </label>
<input type="number" value={config.mail.smtp_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_port: parseInt(e.target.value) || 465 } })} className={inputClass('mail.smtp_port')} />
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button onClick={handleTestMail} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm"></button>
{sectionMessage.mail && (
<span className={`text-sm ${sectionMessage.mail.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{sectionMessage.mail.text}
</span>
)}
</div>
<SectionSaveButton section="mail" />
</Card>
{/* 邮件过滤 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<div className="flex flex-wrap gap-2 mb-2">
{config.filter.subject_keywords.map((kw, i) => (
<span key={i} className={`inline-flex items-center gap-1 px-2 py-1 rounded text-sm ${isFieldChanged('filter.subject_keywords') ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'}`}>
{kw}
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
</span>
))}
</div>
<div className="flex gap-2">
<input type="text" value={newKeyword} onChange={e => setNewKeyword(e.target.value)} onKeyDown={e => e.key === 'Enter' && addKeyword()} className="flex-1 px-3 py-2 border rounded-lg text-sm" placeholder="输入关键词后回车" />
<button onClick={addKeyword} className="px-3 py-2 bg-blue-500 text-white rounded-lg text-sm"></button>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="text" value={config.filter.sender_email} onChange={e => setConfig({ ...config, filter: { ...config.filter, sender_email: e.target.value } })} className={inputClass('filter.sender_email')} />
</div>
</div>
<SectionSaveButton section="filter" />
</Card>
{/* 微信推送 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Webhook </label>
<input type="text" value={config.wechat.webhook_url} onChange={e => 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=..." />
</div>
<div className="flex items-center gap-3">
<button onClick={handleTestWechat} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm"></button>
{sectionMessage.wechat && (
<span className={`text-sm ${sectionMessage.wechat.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{sectionMessage.wechat.text}
</span>
)}
</div>
</div>
<SectionSaveButton section="wechat" />
</Card>
{/* 运行参数 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="number" value={config.monitor.interval_seconds} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, interval_seconds: parseInt(e.target.value) || 60 } })} className={inputClass('monitor.interval_seconds')} min={10} max={3600} />
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="number" value={config.monitor.silent_start_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_start_hour: parseInt(e.target.value) || 0 } })} className={inputClass('monitor.silent_start_hour')} min={0} max={23} />
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="number" value={config.monitor.silent_end_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_end_hour: parseInt(e.target.value) || 7 } })} className={inputClass('monitor.silent_end_hour')} min={0} max={23} />
</div>
</div>
<SectionSaveButton section="monitor" />
</Card>
</div>
)
}

View File

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

View File

@ -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'

View File

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

View File

@ -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<string, (v: unknown) => 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<string, unknown> = {}
for (const [section, values] of Object.entries(body)) {
if (typeof values === 'object' && values !== null) {
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
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 })
}

View File

@ -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 启动' })
}

View File

@ -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<string, unknown>[]
// 最近错误列表
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 })
}
}

View File

@ -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: '监控已停用' })
}

View File

@ -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 {}
}
}

View File

@ -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 : '发送失败' })
}
}

View File

@ -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 秒内执行' })
}

View File

@ -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<string, unknown> | undefined
const snapshot = db.prepare('SELECT id, device_ip, current_status FROM tickets WHERE id = ?').get(id) as Record<string, unknown> | undefined
// 非管理员不可删除已办工单,非创建人不可删除待办工单
const completedStatuses = ['resolved', 'closed']

View File

@ -16,7 +16,7 @@ export async function GET(request: NextRequest) {
const result: Record<string, string[]> = {}
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<string, string>[]
result[field] = rows.map(r => r[field])

View File

@ -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 {

View File

@ -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<LegacyColor, Variant> = {
blue: 'info', green: 'success', gray: 'default', red: 'danger', yellow: 'warning',
}
interface BadgeProps { children: ReactNode; variant?: Variant; color?: LegacyColor }
const vs: Record<Variant, string> = {
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 <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${v}`}>{children}</span>
}

View File

@ -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 (
<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr>
{columns.map((col) => (
<th
key={col.key}
className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300"
style={col.width ? { width: col.width } : undefined}
>
{col.title}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{data.map((record, index) => (
<tr key={rowKey(record)} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
{columns.map((col) => (
<td key={col.key} className="px-4 py-3">
{col.render ? col.render(record, index) : String(record[col.key] ?? '')}
</td>
))}
</tr>
))}
{data.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center text-slate-500"></td>
</tr>
)}
</tbody>
</table>
</div>
)
}

View File

@ -14,8 +14,8 @@ export function Select({ label, options, className = '', ...props }: SelectProps
return (<div className="space-y-1">{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}<select className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors text-sm ${className}`} {...props}>{options.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select></div>)
}
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 (<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700"><table className="w-full text-sm"><thead className="bg-slate-50 dark:bg-slate-800"><tr>{headers.map((h) => (<th key={h} className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300">{h}</th>))}</tr></thead><tbody className="divide-y divide-slate-200 dark:divide-slate-700">{children}</tbody></table></div>)
}

View File

@ -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<Asset[]> {
return allAssets
}
export async function getRackPosition(ip: string | null, sn: string | null): Promise<string | null> {
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
}
}

View File

@ -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,

View File

@ -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 { /* 索引已存在 */ }
}

View File

@ -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<string, TierDeadline> {
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<string, TierDeadline> = {}
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)
}
}

View File

@ -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<void> {
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<void> {
if (this.client) {
try { await this.client.logout() } catch {}
this.client = null
}
}
isConnected(): boolean {
return this.client !== null
}
async reconnect(config: MonitorConfig['mail']): Promise<void> {
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<boolean> {
if (!this.client) return false
try {
await this.client.noop()
return true
} catch {
this.client = null
return false
}
}
async fetchUnread(config: MonitorConfig): Promise<EmailInfo[]> {
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
}
}

View File

@ -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<ReminderState, 'id' | 'created_at'>): 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<string, unknown>).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<boolean>, pushDelay: number, getRack?: (ip: string | null, sn: string | null) => Promise<string | null>, config?: MonitorConfig): Promise<void> {
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<boolean>): Promise<void> {
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<boolean>, pushDelay: number, getRack?: (ip: string | null, sn: string | null) => Promise<string | null>, config?: MonitorConfig): Promise<void> {
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)
}
}

View File

@ -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<string, (v: string) => 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<string, unknown>): 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
}

View File

@ -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<string | null> {
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
}
}

122
src/lib/monitor/types.ts Normal file
View File

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

View File

@ -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<boolean> {
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<string, { deadline: string; hours: number }>,
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<string, string> = {
'第一档': '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()
}
}

210
src/lib/monitor/worker.ts Normal file
View File

@ -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<void> | 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<void> {
if (this.processingPromise) return
this.processingPromise = this.doTick()
try { await this.processingPromise }
finally { this.processingPromise = null }
}
private async doTick(): Promise<void> {
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<string, { deadline: string; hours: number }> = {}
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<void> {
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}`)
}
}
}
}

16
src/types/mailparser.d.ts vendored Normal file
View File

@ -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<ParsedMail>
export { simpleParser, ParsedMail }
}

22
tsconfig.worker.json Normal file
View File

@ -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"]
}