From 447c0fce909c7debfc9381b48b97d0a2aafa2dfc Mon Sep 17 00:00:00 2001 From: aiyimickey <39365912+aiyimickey@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:42:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=B1=E4=BA=AB=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=BA=93=EF=BC=88auth/audit/wechat/alert/ui=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared/lib/auth/: JWT、OIDC、LDAP、config 工厂、middleware 工厂 - shared/lib/audit/: 审计日志标准库 - shared/lib/wechat/: WeChatPusher + 消息格式化 - shared/lib/alert/: AlertManager + HealthChecker + 类型定义 - shared/lib/db/: 数据库 schema 常量 - shared/ui/: 统一登录页组件 P1 共享库迁移完成,独立审查通过 --- .gitignore | 1 + CLAUDE.md | 419 ++++++++++++++++++++++++++++++++ lib/alert/alert-manager.ts | 97 ++++++++ lib/alert/health-checker.ts | 97 ++++++++ lib/alert/types.ts | 57 +++++ lib/audit/audit-schema.ts | 17 ++ lib/audit/write-audit-log.ts | 66 +++++ lib/auth/config.ts | 55 +++++ lib/auth/jwt.ts | 57 +++++ lib/auth/ldap.ts | 46 ++++ lib/auth/middleware.ts | 169 +++++++++++++ lib/auth/oidc.ts | 95 ++++++++ lib/auth/types.ts | 35 +++ lib/auth/user-sync.ts | 24 ++ lib/db/alert-schema.ts | 99 ++++++++ lib/wechat/message-formatter.ts | 27 ++ lib/wechat/wechat-pusher.ts | 59 +++++ package-lock.json | 102 ++++++++ package.json | 1 + ui/login-page.tsx | 108 ++++++++ 20 files changed, 1631 insertions(+) create mode 100644 CLAUDE.md create mode 100644 lib/alert/alert-manager.ts create mode 100644 lib/alert/health-checker.ts create mode 100644 lib/alert/types.ts create mode 100644 lib/audit/audit-schema.ts create mode 100644 lib/audit/write-audit-log.ts create mode 100644 lib/auth/config.ts create mode 100644 lib/auth/jwt.ts create mode 100644 lib/auth/ldap.ts create mode 100644 lib/auth/middleware.ts create mode 100644 lib/auth/oidc.ts create mode 100644 lib/auth/types.ts create mode 100644 lib/auth/user-sync.ts create mode 100644 lib/db/alert-schema.ts create mode 100644 lib/wechat/message-formatter.ts create mode 100644 lib/wechat/wechat-pusher.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 ui/login-page.tsx diff --git a/.gitignore b/.gitignore index c2658d7..2752eb9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3cf2f50 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,419 @@ +# CLAUDE.md — shared 共享库 + +## 概述 + +shared 是 tlyq.ai 项目群的共享库,包含后端认证、告警、审计、企业微信推送等标准模块,以及前端 React UI 组件库。所有模块遵循零业务依赖、配置驱动、TypeScript 优先的设计原则。 + +--- + +## 目录结构 + +``` +shared/ +├── lib/ # 后端服务端库(无 barrel,按路径直接导入) +│ ├── auth/ # 认证标准库 +│ │ ├── types.ts # 类型定义(AuthConfig, SessionPayload, OidcUserinfo 等) +│ │ ├── jwt.ts # JWT 签名/验证(零依赖,Node crypto) +│ │ ├── ldap.ts # LDAP 认证(ldapts 客户端) +│ │ ├── oidc.ts # OIDC PKCE 流程(openid-client) +│ │ ├── middleware.ts # Next.js 路由守卫工厂 +│ │ └── user-sync.ts # OIDC 用户同步到本地 SQLite +│ ├── alert/ # 告警引擎 +│ │ ├── types.ts # 类型定义(AlertLevel, ServiceStatus, CheckResult 等) +│ │ ├── alert-manager.ts # AlertManager 告警决策引擎 +│ │ └── health-checker.ts # HealthChecker 健康检查引擎 +│ ├── audit/ # 审计日志 +│ │ ├── audit-schema.ts # audit_logs 表 DDL +│ │ └── write-audit-log.ts # writeAuditLog 函数 +│ ├── db/ # 数据库 Schema 定义 +│ │ └── alert-schema.ts # 5 张表 DDL(services, alert_channels 等) +│ └── wechat/ # 企业微信 Webhook +│ ├── message-formatter.ts # 消息格式化 +│ └── wechat-pusher.ts # WeChatPusher 推送客户端 +└── ui/ # 前端 React 组件库(@tlyq/shared-ui) + ├── package.json # 发布为 @tlyq/shared-ui v1.0.0 + ├── index.tsx # Barrel 导出(9 个组件) + ├── Button.tsx # 按钮(4 种 variant,3 种 size,loading 状态) + ├── Badge.tsx # 标签(5 种 variant) + ├── Card.tsx # 卡片容器 + ├── Input.tsx # 输入框(含 label 和 error) + ├── Select.tsx # 下拉选择 + ├── Modal.tsx # 模态框(backdrop 点击关闭,body scroll 锁定) + ├── Table.tsx # 表格(styled thead,tbody 由调用方提供) + ├── Toast.tsx # 通知条(右下角固定定位) + ├── Pagination.tsx # 分页(中文标签:"上一页"/"下一页") + └── login-page.tsx # 统一登录页(SSO + LDAP 切换,不在 barrel 中导出) +``` + +--- + +## 设计原则 + +### 零业务依赖 + +共享库不包含任何站点特定的业务逻辑。所有需要外部状态(数据库、配置)的模块通过 TypeScript 接口注入依赖,不耦合具体实现。 + +### 配置驱动 + +模块行为通过配置对象控制,而非硬编码。例如: +- `createMiddleware(config)` 通过 `AuthConfig` 配置公开路径、管理路径、cookie 名称 +- `AlertManager` 通过 `CooldownProvider` 接口获取冷却状态 +- `HealthChecker` 通过 `registerChecker()` 注册检查器 + +### TypeScript 优先 + +所有模块使用 TypeScript 编写,导出完整类型定义。消费方通过 `tsconfig.json` 的 `paths` 映射导入。 + +--- + +## 各模块详解 + +### lib/auth/ — 认证标准库 + +统一认证栈:OIDC SSO + LDAP 本地登录 + localadmin 应急用户 + 共享 JWT session。 + +#### types.ts + +```typescript +interface AuthConfig { + jwtSecret: string + cookieDomain: string + cookieName?: string // 默认 'tlyq_session' + cookieMaxAge?: number // 默认 7 天(秒) + publicPaths?: string[] // 公开路径(不需要认证) + adminPaths?: string[] // 管理路径(需要 admin 角色) + ldapUrl?: string + ldapBaseDn?: string + autheliaUrl?: string + oidcClientId?: string + oidcClientSecret?: string + oidcRedirectUri?: string +} + +interface SessionPayload { + sub: string // 用户 ID 或 username + username: string + role: string + exp: number + iat: number +} + +interface OidcUserinfo { + sub: string + preferred_username: string + email?: string + name?: string + groups?: string[] +} + +interface LdapAuthResult { + success: boolean + username?: string + displayName?: string + email?: string + isAdmin?: boolean + error?: string +} +``` + +#### jwt.ts + +```typescript +signJwt(options: { payload: SessionPayload, secret: string, expiresIn?: number }): string +verifyJwt(token: string, secret: string): SessionPayload | null +sharedCookieConfig(domain: string, maxAge?: number): CookieConfig +``` + +零外部依赖,使用 Node.js `crypto` 模块实现 HS256 JWT 签名和验证。 + +#### ldap.ts + +```typescript +ldapAuth(config: { url: string, baseDn: string }, username: string, password: string): Promise +checkAdminGroup(config: { url: string, baseDn: string }, username: string): Promise +``` + +使用 `ldapts` 客户端进行 LDAP bind 认证和管理员组成员检查。 + +#### oidc.ts + +```typescript +discoverOidcConfig(url: string): Promise +generatePkce(): { codeVerifier: string, codeChallenge: string } +generateState(): string +buildAuthorizeUrl(config: OidcConfig, params: { clientId: string, redirectUri: string, state: string, codeChallenge: string }): string +exchangeCodeForToken(config: OidcConfig, code: string, verifier: string): Promise +getUserinfo(url: string, accessToken: string): Promise +``` + +完整的 OIDC 授权码 + PKCE 流程,基于 Authelia 实现。 + +#### middleware.ts + +```typescript +createMiddleware(config: AuthConfig): NextMiddleware +``` + +Next.js 路由守卫工厂。验证 `tlyq_session` cookie(JWT payload 解码,检查过期时间)。公开路径放行,管理路径检查 admin 角色,未认证请求重定向到 `/login`。 + +#### user-sync.ts + +```typescript +syncOidcUser(store: UserSyncStore, userinfo: OidcUserinfo): Promise<{ id: number, role: string, isNew: boolean }> +``` + +将 OIDC userinfo 同步到本地 SQLite users 表。`UserSyncStore` 是依赖注入接口,不耦合具体数据库驱动。 + +--- + +### lib/alert/ — 告警引擎 + +告警决策和健康检查的核心引擎。 + +#### types.ts + +```typescript +type AlertLevel = 'critical' | 'warning' | 'info' +type ServiceStatus = 'normal' | 'abnormal' | 'unknown' +type CheckType = 'http' | 'docker' | 'ssh' +type AlertType = 'alert' | 'recovery' | 'flapping' + +interface HealthCheckConfig { + type: CheckType + url?: string // HTTP 检查 + expectedStatus?: number + expectedBody?: string + containerName?: string // Docker 检查 + command?: string // SSH 检查 +} + +interface CheckResult { + success: boolean + responseTime?: number + errorMessage?: string +} + +interface AlertDecision { + shouldAlert: boolean + reason?: string + suppressedBy?: 'quiet_period' | 'cooldown' | 'flapping' +} + +interface AlertChannelConfig { + id: number + levelCritical: boolean + levelWarning: boolean + levelInfo: boolean + quietEnabled: boolean + quietStart?: string + quietEnd?: string + quietBypassCritical: boolean + cooldownMinutes: number +} +``` + +#### alert-manager.ts + +```typescript +class AlertManager { + constructor(cooldownProvider: CooldownProvider) + evaluate(channel: AlertChannelConfig, serviceId: number, level: AlertLevel, now?: Date): AlertDecision + isQuietPeriod(channel: AlertChannelConfig, now?: Date): boolean + isCoolingDown(channelId: number, serviceId: number, level: AlertLevel, cooldownMinutes: number): boolean + recordAlert(channelId: number, serviceId: number, level: AlertLevel): void +} +``` + +三阶段告警决策:级别匹配 → 免打扰检查(支持跨午夜) → 冷却窗口检查。 + +`CooldownProvider` 是依赖注入接口: + +```typescript +interface CooldownProvider { + getCooldown(key: string): number | null // 返回 Unix 时间戳 + setCooldown(key: string, timestamp: number): void +} +``` + +#### health-checker.ts + +```typescript +class HealthChecker { + registerChecker(type: CheckType, handler: Checker): void + check(checks: HealthCheckConfig[], timeoutMs: number): Promise +} + +class HttpChecker implements Checker { ... } +class DockerChecker implements Checker { ... } +``` + +策略模式:通过 `registerChecker()` 注册检查器,新检查类型(如 SSH)可无缝扩展。内部使用 `Promise.allSettled` 并发执行。 + +--- + +### lib/audit/ — 审计日志 + +#### audit-schema.ts + +```typescript +const AUDIT_LOGS_TABLE_SQL: string // audit_logs 表 DDL +``` + +#### write-audit-log.ts + +```typescript +interface AuditLogEntry { + userId?: number + username?: string + action: string + entityType: string + entityId?: number + details?: Record + ipAddress?: string +} + +interface AuditStore { + exec(sql: string): void +} + +function writeAuditLog(store: AuditStore, entry: AuditLogEntry): void +``` + +使用 SQL 字符串拼接 + 单引号转义(非参数化查询),这是已知的设计选择。 + +--- + +### lib/db/ — 数据库 Schema + +#### alert-schema.ts + +```typescript +const SERVICES_TABLE_SQL: string +const ALERT_CHANNELS_TABLE_SQL: string +const ALERT_SETTINGS_TABLE_SQL: string +const STATUS_HISTORY_TABLE_SQL: string +const ALERT_HISTORY_TABLE_SQL: string +const ALL_TABLES_SQL: string // 以上 5 个的拼接 +``` + +定义监控相关的 5 张核心表,供各站点初始化数据库使用。 + +--- + +### lib/wechat/ — 企业微信 Webhook + +#### message-formatter.ts + +```typescript +formatAvailabilityMessage(params: { serviceName: string, status: string, ... }): string +formatAlertLevel(level: AlertLevel): string // 返回带 emoji 的级别标签 +``` + +#### wechat-pusher.ts + +```typescript +class WeChatPusher { + constructor(webhookUrl: string) + pushMarkdown(title: string, content: string): Promise + pushText(content: string): Promise +} + +class WeChatPusherCompat { + // 兼容 issue-ai 的旧接口(返回 boolean) + pushMarkdown(title: string, content: string): Promise +} +``` + +5 秒超时,markdown 格式推送。`WeChatPusherCompat` 为迁移提供向后兼容。 + +--- + +### ui/ — React 组件库 + +所有组件均为 `'use client'`(Next.js App Router 兼容),使用 Tailwind CSS 工具类,支持 `dark:` 暗色模式。 + +**导出方式**: + +```typescript +import { Button, Card, Badge, Table, Modal, Input, Select, Toast, Pagination } from '@shared/ui' +import LoginPage from '@shared/ui/login-page' // 单独导入,不在 barrel 中 +``` + +| 组件 | 核心 Props | 说明 | +|------|-----------|------| +| Button | `variant: 'primary'|'secondary'|'danger'|'ghost'`, `size: 'sm'|'md'|'lg'`, `loading: boolean` | forwardRef,loading 时显示 SVG spinner | +| Badge | `variant: 'default'|'success'|'warning'|'danger'|'info'` | 药丸形状标签 | +| Card | `children`, `className?` | 白色/暗色圆角容器 | +| Input | `label?`, `error?` | 带标签和错误提示的输入框 | +| Select | `label?`, `options: {value, label}[]` | 下拉选择器 | +| Modal | `open: boolean`, `onClose`, `title?`, `maxWidth?` | 无 portal 模态框,backdrop 点击关闭,锁定 body scroll | +| Table | `headers: string[]`, `children: ReactNode` | 样式化 thead,tbody 由调用方提供 | +| Toast | `message`, `type: 'success'|'error'|'info'`, `onClose` | 右下角固定通知条 | +| Pagination | `page`, `totalPages`, `onPageChange` | 中文标签,totalPages <= 1 时隐藏 | +| LoginPage | `siteName: string`, `title?: string` | 完整登录页,支持 SSO 和 LDAP 切换 | + +--- + +## 如何导入 + +### tsconfig.json 配置 + +在消费方项目的 `tsconfig.json` 中添加路径映射: + +```json +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"], + "@shared/*": ["./shared/*"] + } + } +} +``` + +### 符号链接 + +在消费方项目根目录创建符号链接: + +```bash +cd /Users/niuniu/programs/docker/monitor-ai +ln -s ../shared shared +``` + +### 导入示例 + +```typescript +// 后端认证 +import { signJwt, verifyJwt } from '@shared/lib/auth/jwt' +import { ldapAuth } from '@shared/lib/auth/ldap' +import { discoverOidcConfig, buildAuthorizeUrl } from '@shared/lib/auth/oidc' + +// 告警引擎 +import { AlertManager } from '@shared/lib/alert/alert-manager' +import { HealthChecker, HttpChecker, DockerChecker } from '@shared/lib/alert/health-checker' + +// 审计日志 +import { writeAuditLog } from '@shared/lib/audit/write-audit-log' + +// 数据库 Schema +import { ALL_TABLES_SQL } from '@shared/lib/db/alert-schema' + +// 企业微信 +import { WeChatPusher } from '@shared/lib/wechat/wechat-pusher' + +// UI 组件 +import { Button, Card, Badge, Table, Modal, Input, Select, Toast, Pagination } from '@shared/ui' +import LoginPage from '@shared/ui/login-page' +``` + +--- + +## 设计模式 + +| 模式 | 应用位置 | 说明 | +|------|---------|------| +| 依赖注入 | `CooldownProvider`, `UserSyncStore`, `AuditStore`, `Checker` 接口 | 通过接口注入外部状态,不耦合具体数据库驱动 | +| 策略模式 | `HealthChecker.registerChecker()` | 可插拔检查器,新检查类型无需修改引擎 | +| 工厂模式 | `createMiddleware(config)` | 返回配置化的 Next.js middleware | +| 适配器模式 | `WeChatPusherCompat` | 包装 `WeChatPusher` 提供旧接口兼容 | +| Barrel 导出 | `ui/index.tsx` | 统一导出入口,消费方一行导入 | diff --git a/lib/alert/alert-manager.ts b/lib/alert/alert-manager.ts new file mode 100644 index 0000000..b8c9341 --- /dev/null +++ b/lib/alert/alert-manager.ts @@ -0,0 +1,97 @@ +// shared/lib/alert/alert-manager.ts — 告警管理引擎 +import type { AlertLevel, AlertChannelConfig, AlertDecision } from './types' + +export interface CooldownProvider { + getLastAlertTime(channelId: number, serviceId: number, level: string): Promise + setLastAlertTime(channelId: number, serviceId: number, level: string, timestamp: number): Promise +} + +export interface AlertPushChannel { + push(title: string, content: string, level: string): Promise<{ success: boolean }> +} + +export class AlertManager { + private cooldownProvider: CooldownProvider + + constructor(cooldownProvider: CooldownProvider) { + this.cooldownProvider = cooldownProvider + } + + // 评估是否向某 channel 推送告警 + async evaluate( + channel: AlertChannelConfig, + serviceId: number, + level: AlertLevel, + now: Date = new Date() + ): Promise { + // 1. 级别匹配 + if (!this.levelMatches(channel, level)) { + return { shouldPush: false, reason: `level ${level} not enabled for channel ${channel.name}` } + } + + // 2. 免打扰时段 + if (this.isQuietPeriod(channel, now) && !(level === 'critical' && channel.quietBypassCritical)) { + return { shouldPush: false, reason: `quiet period for channel ${channel.name}` } + } + + // 3. 冷却检查 + const cooling = await this.isCoolingDown(channel.id, serviceId, level, channel.cooldownMinutes, now) + if (cooling) { + return { shouldPush: false, reason: `cooldown for channel ${channel.name}` } + } + + return { shouldPush: true } + } + + // 级别匹配检查 + private levelMatches(channel: AlertChannelConfig, level: AlertLevel): boolean { + switch (level) { + case 'critical': return channel.levelCritical + case 'warning': return channel.levelWarning + case 'info': return channel.levelInfo + } + } + + // 免打扰时段判定(支持跨日时段) + isQuietPeriod(channel: AlertChannelConfig, now: Date): boolean { + if (!channel.quietEnabled) return false + + const [sh, sm] = channel.quietStart.split(':').map(Number) + const [eh, em] = channel.quietEnd.split(':').map(Number) + const current = now.getHours() * 60 + now.getMinutes() + const start = sh * 60 + sm + const end = eh * 60 + em + + if (start <= end) { + // 同日时段,如 02:00 - 06:00 + return current >= start && current < end + } else { + // 跨日时段,如 23:00 - 07:00 + return current >= start || current < end + } + } + + // 冷却检查 + async isCoolingDown( + channelId: number, + serviceId: number, + level: AlertLevel, + cooldownMinutes: number, + now: Date = new Date() + ): Promise { + const lastTime = await this.cooldownProvider.getLastAlertTime(channelId, serviceId, level) + if (!lastTime) return false + const elapsed = now.getTime() - lastTime + return elapsed < cooldownMinutes * 60 * 1000 + } + + // 记录告警时间 + async recordAlert( + channelId: number, + serviceId: number, + level: AlertLevel, + timestamp: number = Date.now() + ): Promise { + await this.cooldownProvider.setLastAlertTime(channelId, serviceId, level, timestamp) + } +} diff --git a/lib/alert/health-checker.ts b/lib/alert/health-checker.ts new file mode 100644 index 0000000..a094ab1 --- /dev/null +++ b/lib/alert/health-checker.ts @@ -0,0 +1,97 @@ +// shared/lib/alert/health-checker.ts — 健康检查引擎 +import type { CheckType, HealthCheckConfig, CheckResult } from './types' + +export interface Checker { + check(config: HealthCheckConfig): Promise +} + +export class HealthChecker { + private checkers = new Map() + + registerChecker(type: CheckType, handler: Checker): void { + this.checkers.set(type, handler) + } + + // 并发检查所有维度 + async check(checks: HealthCheckConfig[], timeoutMs: number): Promise { + const results = await Promise.allSettled( + checks.map(config => this.checkOne(config, timeoutMs)) + ) + return results.map(r => (r.status === 'fulfilled' ? r.value : { + success: false, + status: 'unknown' as const, + error: 'Checker execution failed', + latency: timeoutMs, + checkType: 'http' as const, + })) + } + + private async checkOne(config: HealthCheckConfig, timeoutMs: number): Promise { + const checker = this.checkers.get(config.type) + if (!checker) { + return { success: false, status: 'unknown', error: `Unknown check type: ${config.type}`, latency: 0, checkType: config.type } + } + return checker.check(config) + } +} + +// 内置 HTTP 检查器 +export class HttpChecker implements Checker { + async check(config: HealthCheckConfig): Promise { + const start = Date.now() + try { + const res = await fetch(config.url!, { + signal: AbortSignal.timeout(5000), + headers: { Accept: 'text/html,application/json' }, + }) + const latency = Date.now() - start + + if (config.expectedStatus && res.status !== config.expectedStatus) { + return { success: false, status: 'abnormal', error: `Expected ${config.expectedStatus}, got ${res.status}`, latency, checkType: 'http' } + } + + if (config.expectedBody) { + const body = await res.text() + if (!body.includes(config.expectedBody)) { + return { success: false, status: 'abnormal', error: `Body does not contain "${config.expectedBody}"`, latency, checkType: 'http' } + } + } + + return { success: true, status: 'normal', latency, checkType: 'http' } + } catch (err) { + return { success: false, status: 'abnormal', error: String(err), latency: Date.now() - start, checkType: 'http' } + } + } +} + +// 内置 Docker 检查器(同机部署,需要 docker.sock 只读挂载) +export class DockerChecker implements Checker { + async check(config: HealthCheckConfig): Promise { + const start = Date.now() + try { + const { execFileSync } = await import('child_process') + const containerName = config.containerName! + const stdout = execFileSync('docker', ['ps', '--filter', `name=${containerName}`, '--format', '{{.Status}}'], { + encoding: 'utf-8', + timeout: 5000, + }).trim() + + const latency = Date.now() - start + if (!stdout) { + return { success: false, status: 'abnormal', error: `Container ${containerName} not found or stopped`, latency, checkType: 'docker' } + } + if (stdout.startsWith('Up')) { + return { success: true, status: 'normal', latency, checkType: 'docker' } + } + return { success: false, status: 'abnormal', error: `Container ${containerName} status: ${stdout}`, latency, checkType: 'docker' } + } catch (err) { + const latency = Date.now() - start + // 区分 daemon 不可达 vs 其他错误 + const errStr = String(err) + if (errStr.includes('Cannot connect') || errStr.includes('Is the docker daemon')) { + return { success: false, status: 'unknown', error: 'Docker daemon unreachable', latency, checkType: 'docker' } + } + return { success: false, status: 'abnormal', error: errStr, latency, checkType: 'docker' } + } + } +} diff --git a/lib/alert/types.ts b/lib/alert/types.ts new file mode 100644 index 0000000..38c33c5 --- /dev/null +++ b/lib/alert/types.ts @@ -0,0 +1,57 @@ +// shared/lib/alert/types.ts — 告警类型定义 +export type AlertLevel = 'critical' | 'warning' | 'info' +export type ServiceStatus = 'normal' | 'abnormal' | 'unknown' +export type CheckType = 'http' | 'docker' | 'ssh' +export type AlertType = 'alert' | 'recovery' | 'flapping' + +export interface HealthCheckConfig { + type: CheckType + url?: string + expectedStatus?: number + expectedBody?: string + containerName?: string + sshHost?: string + sshCommand?: string +} + +export interface MonitoredService { + id: number + name: string + category: 'container' | 'endpoint' | 'custom' + alertLevel: AlertLevel + checks: HealthCheckConfig[] + checkInterval: number + checkTimeout: number + enabled: boolean + currentStatus: ServiceStatus + statusSince: string +} + +export interface CheckResult { + success: boolean + status: ServiceStatus + error?: string + latency: number + checkType: CheckType +} + +export interface AlertDecision { + shouldPush: boolean + reason?: string +} + +export interface AlertChannelConfig { + id: number + name: string + channelType: 'wechat' | 'email' + webhookUrl: string + enabled: boolean + levelCritical: boolean + levelWarning: boolean + levelInfo: boolean + quietEnabled: boolean + quietStart: string + quietEnd: string + quietBypassCritical: boolean + cooldownMinutes: number +} diff --git a/lib/audit/audit-schema.ts b/lib/audit/audit-schema.ts new file mode 100644 index 0000000..e0cb831 --- /dev/null +++ b/lib/audit/audit-schema.ts @@ -0,0 +1,17 @@ +// shared/lib/audit/audit-schema.ts — audit_logs 表结构常量 +export const AUDIT_LOGS_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + username TEXT, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + details TEXT, + ip_address TEXT, + created_at TEXT DEFAULT (datetime('now', '+8 hours')) +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_time ON audit_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(username, created_at); +` diff --git a/lib/audit/write-audit-log.ts b/lib/audit/write-audit-log.ts new file mode 100644 index 0000000..e6cbf47 --- /dev/null +++ b/lib/audit/write-audit-log.ts @@ -0,0 +1,66 @@ +// shared/lib/audit/write-audit-log.ts — 通用审计日志写入函数 +export interface AuditLogEntry { + userId?: number | null + username?: string | null + apiKeyId?: number | null + action: string + entityType: string + entityId?: number | null + details?: Record | null + ipAddress?: string | null +} + +export interface AuditStore { + exec(sql: string): void +} + +// 写入审计日志(通用版本,调用方传入 store 执行 SQL) +export function writeAuditLog(store: AuditStore, entry: AuditLogEntry): void { + const userId = entry.userId ?? 'NULL' + const username = entry.username ? `'${entry.username.replace(/'/g, "''")}'` : 'NULL' + const apiKeyId = entry.apiKeyId ?? 'NULL' + const action = `'${entry.action.replace(/'/g, "''")}'` + const entityType = `'${entry.entityType.replace(/'/g, "''")}'` + const entityId = entry.entityId ?? 'NULL' + const details = entry.details ? `'${JSON.stringify(entry.details).replace(/'/g, "''")}'` : 'NULL' + const ipAddress = entry.ipAddress ? `'${entry.ipAddress.replace(/'/g, "''")}'` : 'NULL' + + const sql = `INSERT INTO audit_logs (user_id, username, api_key_id, action, entity_type, entity_id, details, ip_address, created_at) + VALUES (${userId}, ${username}, ${apiKeyId}, ${action}, ${entityType}, ${entityId}, ${details}, ${ipAddress}, datetime('now', '+8 hours'))` + + try { + store.exec(sql) + } catch (e) { + // 审计写入失败不阻断主操作 + console.error('审计日志写入失败:', e) + } +} + +// 对象差异对比(用于审计详情) +export function diffObjects( + before: Record, + after: Record, +): Record { + const changes: Record = {} + const keys = new Set([...Object.keys(before), ...Object.keys(after)]) + + for (const key of keys) { + // 跳过系统字段 + if (['created_at', 'updated_at'].includes(key)) continue + + const from = before[key] + const to = after[key] + if (JSON.stringify(from) !== JSON.stringify(to)) { + changes[key] = { from, to } + } + } + + return changes +} + +// 获取客户端 IP +export function getClientIP(request: Request): string | null { + const forwarded = request.headers.get('x-forwarded-for') + if (forwarded) return forwarded.split(',')[0].trim() + return request.headers.get('x-real-ip') ?? null +} diff --git a/lib/auth/config.ts b/lib/auth/config.ts new file mode 100644 index 0000000..ceb3d1c --- /dev/null +++ b/lib/auth/config.ts @@ -0,0 +1,55 @@ +// shared/lib/auth/config.ts — 认证配置工厂(各站点统一使用) +import type { AuthConfig } from './types' + +export interface SiteAuthConfig { + /** 站点名称,用于日志和调试 */ + siteName: string + /** 本地 cookie 名(如 'session_issue'),不设则不使用本地 cookie 回退 */ + localCookieName?: string + /** 需要 admin 权限的路径前缀,默认 ['/admin'] */ + adminPaths?: string[] + /** 公开路径(不需要认证),默认 ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico'] */ + publicPaths?: string[] + /** OIDC client ID,默认 `${siteName}-oidc`,可手动覆盖 */ + oidcClientId?: string + /** OIDC client secret,默认从 OIDC_CLIENT_SECRET 环境变量读取 */ + oidcClientSecret?: string + /** OIDC redirect URI,默认从 OIDC_REDIRECT_URI 环境变量读取 */ + oidcRedirectUri?: string + /** 启用 API Key 认证(读取 ALLOWED_API_KEYS 环境变量),默认 false */ + enableApiKey?: boolean +} + +/** + * 创建统一的认证配置 + * + * @example + * ```ts + * // src/lib/auth-config.ts + * import { createAuthConfig } from '@shared/lib/auth/config' + * export const authConfig = createAuthConfig({ + * siteName: 'issue-ai', + * localCookieName: 'session_issue', + * adminPaths: ['/settings'], + * enableApiKey: true, + * }) + * ``` + */ +export function createAuthConfig(site: SiteAuthConfig): AuthConfig & { + localCookieName?: string + enableApiKey?: boolean +} { + return { + jwtSecret: process.env.JWT_SECRET || 'default-secret-change-me', + cookieDomain: process.env.COOKIE_DOMAIN || '', + autheliaUrl: process.env.AUTHELIA_URL || 'https://127.0.0.1:6180', + oidcClientId: site.oidcClientId || process.env.OIDC_CLIENT_ID || `${site.siteName}-oidc`, + oidcClientSecret: site.oidcClientSecret || process.env.OIDC_CLIENT_SECRET || '', + oidcRedirectUri: site.oidcRedirectUri || process.env.OIDC_REDIRECT_URI || '', + ldapUrl: process.env.LDAP_URL || 'ldap://localhost:3890', + publicPaths: site.publicPaths || ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico'], + adminPaths: site.adminPaths || ['/admin'], + localCookieName: site.localCookieName, + enableApiKey: site.enableApiKey || false, + } +} diff --git a/lib/auth/jwt.ts b/lib/auth/jwt.ts new file mode 100644 index 0000000..46ba8ef --- /dev/null +++ b/lib/auth/jwt.ts @@ -0,0 +1,57 @@ +// shared/lib/auth/jwt.ts — 共享 JWT 签发/验证(HS256,跨站点 cookie) +import crypto from 'crypto' + +export interface SignJwtOptions { + secret: string + payload: Record + expiresInSeconds?: number +} + +export function signJwt({ secret, payload, expiresInSeconds = 604800 }: SignJwtOptions): string { + const header = { alg: 'HS256', typ: 'JWT' } + const now = Math.floor(Date.now() / 1000) + const body = { ...payload, iat: now, exp: now + expiresInSeconds } + + const encoded = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url') + const signature = crypto + .createHmac('sha256', secret) + .update(`${encoded(header)}.${encoded(body)}`) + .digest('base64url') + + return `${encoded(header)}.${encoded(body)}.${signature}` +} + +export function verifyJwt(token: string, secret: string): Record | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) + if (payload.exp && payload.exp * 1000 < Date.now()) return null + + const signature = crypto + .createHmac('sha256', secret) + .update(`${parts[0]}.${parts[1]}`) + .digest('base64url') + + return signature === parts[2] ? payload : null + } catch { + return null + } +} + +// 签发共享 tlyq_session cookie 配置 +export function sharedCookieConfig(domain: string, maxAge = 604800): Record { + return { + name: 'tlyq_session', + value: '', + options: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + domain, + path: '/', + maxAge, + }, + } +} diff --git a/lib/auth/ldap.ts b/lib/auth/ldap.ts new file mode 100644 index 0000000..d2b5e56 --- /dev/null +++ b/lib/auth/ldap.ts @@ -0,0 +1,46 @@ +// shared/lib/auth/ldap.ts — LLDAP 认证 +import { Client } from 'ldapts' +import type { LdapAuthResult } from './types' + +export interface LdapConfig { + url: string + baseDn: string + adminDn?: string + adminPassword?: string + adminGroup?: string +} + +// LDAP bind 认证用户 +export async function ldapAuth(config: LdapConfig, username: string, password: string): Promise { + const client = new Client({ url: config.url, timeout: 5000 }) + try { + const userDn = `uid=${username},ou=people,${config.baseDn}` + await client.bind(userDn, password) + return { success: true, username } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'LDAP auth failed' + return { success: false, error: message } + } finally { + await client.unbind() + } +} + +// 检查用户是否在 admin 组中 +export async function checkAdminGroup(config: LdapConfig, username: string): Promise { + if (!config.adminDn || !config.adminPassword || !config.adminGroup) return false + const client = new Client({ url: config.url, timeout: 5000 }) + try { + await client.bind(config.adminDn, config.adminPassword) + const { searchEntries } = await client.search(`cn=${config.adminGroup},ou=groups,${config.baseDn}`, { + scope: 'sub', + filter: `(member=uid=${username},ou=people,${config.baseDn})`, + attributes: ['cn'], + sizeLimit: 1, + }) + return searchEntries.length > 0 + } catch { + return false + } finally { + await client.unbind() + } +} diff --git a/lib/auth/middleware.ts b/lib/auth/middleware.ts new file mode 100644 index 0000000..f7cabe1 --- /dev/null +++ b/lib/auth/middleware.ts @@ -0,0 +1,169 @@ +// shared/lib/auth/middleware.ts — 通用 middleware 工厂(Edge Runtime 兼容) +// 不使用 Node.js crypto,仅用 atob 解码 JWT payload +import { NextResponse, type NextRequest } from 'next/server' + +export interface MiddlewareConfig { + /** 公开路径(不需要认证),默认 ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico'] */ + publicPaths?: string[] + /** 需要 admin 权限的路径前缀,默认 ['/admin'] */ + adminPaths?: string[] + /** 本地 cookie 名(如 'session_issue'),用于回退验证 */ + localCookieName?: string + /** 启用 API Key 认证(读取 ALLOWED_API_KEYS 环境变量),默认 false */ + enableApiKey?: boolean + /** 管理员角色名,默认 'admin' */ + adminRole?: string +} + +// Edge Runtime 兼容:用 atob 解码 JWT payload,不验证签名 +function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') + while (payload.length % 4) payload += '=' + return JSON.parse(atob(payload)) + } catch { return null } +} + +function isValidPayload(payload: Record | null): boolean { + if (!payload) return false + return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000)) +} + +function noCache(response: NextResponse): NextResponse { + response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate') + return response +} + +/** + * 创建通用 middleware + * + * @example + * ```ts + * // src/middleware.ts — 使用默认配置 + * export { middleware, config } from '@shared/lib/auth/middleware' + * ``` + * + * @example + * ```ts + * // src/middleware.ts — 自定义配置 + * import { createMiddleware } from '@shared/lib/auth/middleware' + * export const middleware = createMiddleware({ + * localCookieName: 'session_issue', + * adminPaths: ['/settings'], + * enableApiKey: true, + * }) + * export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] } + * ``` + */ +export function createMiddleware(cfg: MiddlewareConfig = {}) { + const publicPaths = cfg.publicPaths || ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico'] + const adminPaths = cfg.adminPaths || ['/admin'] + const localCookieName = cfg.localCookieName + const enableApiKey = cfg.enableApiKey || false + const adminRole = cfg.adminRole || 'admin' + + return function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + // 公开路径放行 + if (publicPaths.some(p => pathname.startsWith(p))) { + // 登录页:已登录用户自动跳转首页 + if (pathname === '/login') { + const token = request.cookies.get('tlyq_session')?.value + || (localCookieName ? request.cookies.get(localCookieName)?.value : undefined) + const payload = token ? decodeJwtPayload(token) : null + if (isValidPayload(payload)) { + return NextResponse.redirect(new URL('/', request.url)) + } + } + return NextResponse.next() + } + + // API Key 认证(可选) + if (enableApiKey) { + const authHeader = request.headers.get('authorization') + if (authHeader?.startsWith('Bearer ak_')) { + const key = authHeader.slice(7) + const allowedKeys = process.env.ALLOWED_API_KEYS || '' + if (allowedKeys && allowedKeys.split(',').map(k => k.trim()).includes(key)) { + return NextResponse.next() + } + if (pathname.startsWith('/api/')) { + return NextResponse.json({ error: '无效的 API Key' }, { status: 401 }) + } + } + } + + // 验证 tlyq_session(共享 JWT) + const sharedToken = request.cookies.get('tlyq_session')?.value + const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null + + if (isValidPayload(sharedPayload)) { + // 管理路径检查 admin 权限 + if (adminPaths.some(p => pathname.startsWith(p))) { + const role = sharedPayload?.role as string + if (role !== adminRole && role !== 'admin') { + return new NextResponse('Forbidden', { status: 403 }) + } + } + + // 设置 session cookie(兼容旧代码) + if (sharedPayload) { + const response = pathname.startsWith('/api/') ? NextResponse.next() : noCache(NextResponse.next()) + response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), { + httpOnly: true, sameSite: 'lax', path: '/', + }) + return response + } + } + + // 回退本地 cookie(可选) + if (localCookieName) { + const localToken = request.cookies.get(localCookieName)?.value + const localPayload = localToken ? decodeJwtPayload(localToken) : null + + if (isValidPayload(localPayload)) { + // 管理路径检查 admin 权限 + if (adminPaths.some(p => pathname.startsWith(p))) { + const role = localPayload?.role as string + if (role !== adminRole && role !== 'admin') { + return new NextResponse('Forbidden', { status: 403 }) + } + } + + if (pathname.startsWith('/api/')) { + return NextResponse.next() + } + if (localPayload) { + const response = noCache(NextResponse.next()) + response.cookies.set('session', JSON.stringify({ username: localPayload.username }), { + httpOnly: true, sameSite: 'lax', path: '/', + }) + return response + } + } + + // 清除无效的本地 cookie + if (localToken) { + const loginUrl = new URL('/login', request.url) + const response = NextResponse.redirect(loginUrl) + response.cookies.delete(localCookieName) + if (sharedToken) response.cookies.delete('tlyq_session') + return response + } + } + + // 未认证:API 返回 401,页面重定向登录 + if (pathname.startsWith('/api/')) { + return NextResponse.json({ error: '未登录' }, { status: 401 }) + } + return noCache(NextResponse.redirect(new URL('/login', request.url))) + } +} + +// 默认导出(无配置,适用于简单站点) +export const middleware = createMiddleware() + +export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] } diff --git a/lib/auth/oidc.ts b/lib/auth/oidc.ts new file mode 100644 index 0000000..a13ee53 --- /dev/null +++ b/lib/auth/oidc.ts @@ -0,0 +1,95 @@ +// shared/lib/auth/oidc.ts — OIDC 客户端(Authelia discovery + PKCE flow) +import crypto from 'crypto' +import type { OidcUserinfo } from './types' + +export interface OidcClientConfig { + autheliaUrl: string + clientId: string + clientSecret: string + redirectUri: string +} + +// OpenID Connect Discovery +export async function discoverOidcConfig(autheliaUrl: string) { + const res = await fetch(`${autheliaUrl}/.well-known/openid-configuration`, { + headers: { Accept: 'application/json' }, + }) + if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`) + return res.json() as Promise<{ authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string; end_session_endpoint?: string }> +} + +// 生成 PKCE 参数 +export function generatePkce(): { codeVerifier: string; codeChallenge: string } { + const codeVerifier = crypto.randomBytes(32).toString('base64url') + const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url') + return { codeVerifier, codeChallenge } +} + +// 生成 state 和 nonce +export function generateState(): string { + return crypto.randomBytes(32).toString('base64url') +} + +// 构建 OIDC authorize URL +export function buildAuthorizeUrl(config: OidcClientConfig, params: { + codeChallenge: string + state: string + nonce: string + prompt?: string +}): string { + const { autheliaUrl, clientId, redirectUri } = config + const url = new URL(`${autheliaUrl}/api/oidc/authorization`) + url.searchParams.set('client_id', clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('response_type', 'code') + url.searchParams.set('scope', 'openid profile email') + url.searchParams.set('state', params.state) + url.searchParams.set('nonce', params.nonce) + url.searchParams.set('code_challenge', params.codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + if (params.prompt) url.searchParams.set('prompt', params.prompt) + return url.toString() +} + +// code 换取 token +export async function exchangeCodeForToken(config: OidcClientConfig, code: string, codeVerifier: string) { + const { autheliaUrl, clientId, clientSecret, redirectUri } = config + const oidcConfig = await discoverOidcConfig(autheliaUrl) + + const res = await fetch(oidcConfig.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + code_verifier: codeVerifier, + }), + }) + + if (!res.ok) return { success: false as const, error: `Token exchange failed: ${res.status}` } + const data = await res.json() + + // nonce 在 ID token 的 claims 中,不在 token endpoint 响应中 + let nonce: string | undefined + if (data.id_token) { + try { + const payload = JSON.parse(Buffer.from(data.id_token.split('.')[1], 'base64url').toString()) + nonce = payload.nonce + } catch { /* 解析失败则 nonce 为 undefined */ } + } + + return { success: true as const, accessToken: data.access_token, idToken: data.id_token, nonce } +} + +// 获取 userinfo +export async function getUserinfo(autheliaUrl: string, accessToken: string): Promise { + const oidcConfig = await discoverOidcConfig(autheliaUrl) + const res = await fetch(oidcConfig.userinfo_endpoint, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!res.ok) throw new Error(`Userinfo fetch failed: ${res.status}`) + return res.json() +} diff --git a/lib/auth/types.ts b/lib/auth/types.ts new file mode 100644 index 0000000..0ffb083 --- /dev/null +++ b/lib/auth/types.ts @@ -0,0 +1,35 @@ +// shared/lib/auth/types.ts — 认证标准库类型定义 +export interface AuthConfig { + jwtSecret: string + cookieDomain: string + autheliaUrl: string + oidcClientId: string + oidcClientSecret: string + oidcRedirectUri: string + ldapUrl?: string + publicPaths?: string[] + adminPaths?: string[] +} + +export interface SessionPayload { + username: string + displayName?: string + role?: string + iat?: number + exp?: number +} + +export interface OidcUserinfo { + sub: string + preferred_username: string + name?: string + email?: string + email_verified?: boolean +} + +export interface LdapAuthResult { + success: boolean + username?: string + displayName?: string + error?: string +} diff --git a/lib/auth/user-sync.ts b/lib/auth/user-sync.ts new file mode 100644 index 0000000..3bf27ec --- /dev/null +++ b/lib/auth/user-sync.ts @@ -0,0 +1,24 @@ +// shared/lib/auth/user-sync.ts — 用户同步工具(OIDC userinfo → 本地 SQLite) +import type { OidcUserinfo } from './types' + +export interface UserSyncStore { + getUser(username: string): { id: number; role: string } | null + createUser(username: string, displayName: string, email: string): { id: number; role: string } + updateUser(username: string, displayName: string, email: string): void +} + +// 首次 OIDC 登录时自动创建本地用户记录,已存在则更新信息 +export function syncOidcUser(store: UserSyncStore, userinfo: OidcUserinfo): { id: number; role: string; isNew: boolean } { + const username = userinfo.preferred_username + const displayName = userinfo.name || username + const email = userinfo.email || '' + + const existing = store.getUser(username) + if (existing) { + store.updateUser(username, displayName, email) + return { id: existing.id, role: existing.role, isNew: false } + } + + const created = store.createUser(username, displayName, email) + return { id: created.id, role: created.role || 'viewer', isNew: true } +} diff --git a/lib/db/alert-schema.ts b/lib/db/alert-schema.ts new file mode 100644 index 0000000..d1b38b3 --- /dev/null +++ b/lib/db/alert-schema.ts @@ -0,0 +1,99 @@ +// shared/lib/db/alert-schema.ts — 数据库表结构常量 +// 供 monitor-ai 及未来其他项目引用 + +export const SERVICES_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'endpoint', + alert_level TEXT NOT NULL DEFAULT 'warning', + checks TEXT NOT NULL DEFAULT '[]', + check_interval INTEGER DEFAULT 30, + check_timeout INTEGER DEFAULT 10, + enabled INTEGER DEFAULT 1, + current_status TEXT DEFAULT 'unknown', + status_since TEXT, + display_order INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now', '+8 hours')), + updated_at TEXT DEFAULT (datetime('now', '+8 hours')) +); +` + +export const ALERT_CHANNELS_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS alert_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + channel_type TEXT DEFAULT 'wechat', + webhook_url TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + level_critical INTEGER DEFAULT 1, + level_warning INTEGER DEFAULT 1, + level_info INTEGER DEFAULT 0, + quiet_enabled INTEGER DEFAULT 0, + quiet_start TEXT DEFAULT '23:00', + quiet_end TEXT DEFAULT '07:00', + quiet_bypass_critical INTEGER DEFAULT 1, + cooldown_minutes INTEGER DEFAULT 5, + created_at TEXT DEFAULT (datetime('now', '+8 hours')), + updated_at TEXT DEFAULT (datetime('now', '+8 hours')) +); +` + +export const ALERT_SETTINGS_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS alert_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + description TEXT, + updated_at TEXT DEFAULT (datetime('now', '+8 hours')) +); +` + +export const STATUS_HISTORY_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS status_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL REFERENCES services(id), + from_status TEXT NOT NULL, + to_status TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + duration_seconds INTEGER, + error_message TEXT, + failed_checks TEXT, + truncated_by_restart INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now', '+8 hours')) +); + +CREATE INDEX IF NOT EXISTS idx_status_history_service ON status_history(service_id, started_at); +CREATE INDEX IF NOT EXISTS idx_status_history_open ON status_history(ended_at) WHERE ended_at IS NULL; +` + +export const ALERT_HISTORY_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS alert_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER REFERENCES alert_channels(id), + service_id INTEGER REFERENCES services(id), + status_event_id INTEGER REFERENCES status_history(id), + alert_type TEXT NOT NULL DEFAULT 'alert', + level TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + sent_at TEXT NOT NULL, + success INTEGER DEFAULT 0, + response_code INTEGER, + response_body TEXT, + created_at TEXT DEFAULT (datetime('now', '+8 hours')) +); + +CREATE INDEX IF NOT EXISTS idx_alert_history_time ON alert_history(sent_at); +CREATE INDEX IF NOT EXISTS idx_alert_history_channel ON alert_history(channel_id, sent_at); +CREATE INDEX IF NOT EXISTS idx_alert_history_service ON alert_history(service_id, sent_at); +CREATE INDEX IF NOT EXISTS idx_alert_history_event ON alert_history(status_event_id); +` + +export const ALL_TABLES_SQL = [ + SERVICES_TABLE_SQL, + ALERT_CHANNELS_TABLE_SQL, + ALERT_SETTINGS_TABLE_SQL, + STATUS_HISTORY_TABLE_SQL, + ALERT_HISTORY_TABLE_SQL, +].join('\n') diff --git a/lib/wechat/message-formatter.ts b/lib/wechat/message-formatter.ts new file mode 100644 index 0000000..4135684 --- /dev/null +++ b/lib/wechat/message-formatter.ts @@ -0,0 +1,27 @@ +// shared/lib/wechat/message-formatter.ts — 消息格式化工具 +// 从 issue-ai 提取的纯函数,不依赖任何外部状态 + +// 格式化可用性消息(issue-ai 邮件监控使用) +export function formatAvailabilityMessage(params: { + ticketNo: string + ip: string + sn: string + rackPosition: string + faultTime: string + deadline: string +}): string { + return [ + `工单号: ${params.ticketNo}`, + `IP: ${params.ip}`, + `SN: ${params.sn}`, + `机架位置: ${params.rackPosition}`, + `故障时间: ${params.faultTime}`, + `可用性截止: ${params.deadline}`, + ].join('\n') +} + +// 格式化告警级别标签 +export function formatAlertLevel(level: 'critical' | 'warning' | 'info'): string { + const emoji = { critical: '🔴', warning: '🟡', info: '🔵' } + return `${emoji[level]} ${level}` +} diff --git a/lib/wechat/wechat-pusher.ts b/lib/wechat/wechat-pusher.ts new file mode 100644 index 0000000..96d9b1d --- /dev/null +++ b/lib/wechat/wechat-pusher.ts @@ -0,0 +1,59 @@ +// shared/lib/wechat/wechat-pusher.ts — 企业微信 Webhook 通用客户端 +export interface PushResult { + success: boolean + responseCode: number | null + responseBody: string | null + error?: string +} + +export class WeChatPusher { + private webhookUrl: string + + constructor(webhookUrl: string) { + this.webhookUrl = webhookUrl + } + + // 推送 markdown 消息 + async pushMarkdown(title: string, content: string): Promise { + return this.push({ + msgtype: 'markdown', + markdown: { content: `## ${title}\n${content}` }, + }) + } + + // 推送纯文本消息 + async pushText(content: string): Promise { + return this.push({ msgtype: 'text', text: { content } }) + } + + // 通用推送 + private async push(message: Record): Promise { + try { + const res = await fetch(this.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message), + signal: AbortSignal.timeout(5000), + }) + const body = await res.text().catch(() => null) + return { success: res.ok, responseCode: res.status, responseBody: body } + } catch (err) { + return { success: false, responseCode: null, responseBody: null, error: String(err) } + } + } +} + +// 兼容适配器(降低 issue-ai 迁移成本) +export class WeChatPusherCompat { + private pusher: WeChatPusher + + constructor(webhookUrl: string) { + this.pusher = new WeChatPusher(webhookUrl) + } + + // 兼容旧接口:返回 Promise + async pushText(text: string, _webhookUrl?: string): Promise { + const result = await this.pusher.pushText(text) + return result.success + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1ffdcbb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,102 @@ +{ + "name": "shared", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shared", + "dependencies": { + "ldapts": "^8.1.8", + "lucide-react": "^1.8.0", + "openid-client": "^6.8.4", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/ldapts": { + "version": "8.1.8", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.8.tgz", + "integrity": "sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==", + "license": "MIT", + "dependencies": { + "strict-event-emitter-types": "2.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/lucide-react": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.22.0.tgz", + "integrity": "sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..008d758 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{"name":"shared","private":true,"dependencies":{"ldapts":"^8.1.8","lucide-react":"^1.8.0","openid-client":"^6.8.4","react":"^19.0.0","react-dom":"^19.0.0"}} \ No newline at end of file diff --git a/ui/login-page.tsx b/ui/login-page.tsx new file mode 100644 index 0000000..164d0d4 --- /dev/null +++ b/ui/login-page.tsx @@ -0,0 +1,108 @@ +'use client' +// shared/ui/login-page.tsx — 统一登录页组件(与 issue-ai 登录页风格一致) +import { useState } from 'react' + +interface LoginPageProps { + siteName: string // 站点名称,如 "IT 工单跟踪系统"、"告警监控中心" + title?: string // 副标题(可选),如 "monitor-ai" +} + +export default function LoginPage({ siteName, title }: LoginPageProps) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [showLdapForm, setShowLdapForm] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); setError(''); setLoading(true) + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }) + const data = await res.json() + if (!res.ok) { setError(data.error || '登录失败'); return } + const params = new URLSearchParams(window.location.search) + const redirect = params.get('redirect') + const dest = (redirect && redirect.startsWith('/')) ? redirect : '/' + window.location.href = dest + } catch { setError('网络错误') } finally { setLoading(false) } + } + + function handleSsoLogin() { + window.location.href = '/api/auth/login/oidc' + } + + return ( +
+
+

{siteName}

+ {title &&

{title}

} + {!title &&
} + + {error && ( +
{error}
+ )} + + {!showLdapForm ? ( + <> + +

通过 SSO 统一身份认证

+

+ +

+ + ) : ( + <> +
+
+ + setUsername(e.target.value)} + placeholder="请输入用户名" + className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + /> +
+
+ + setPassword(e.target.value)} + placeholder="请输入密码" + className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + /> +
+ +
+

+ +

+ + )} +
+
+ ) +}