feat: 共享标准库(auth/audit/wechat/alert/ui)
- 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 共享库迁移完成,独立审查通过
This commit is contained in:
parent
83b26021b2
commit
447c0fce90
|
|
@ -1 +1,2 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -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<LdapAuthResult>
|
||||||
|
checkAdminGroup(config: { url: string, baseDn: string }, username: string): Promise<boolean>
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 `ldapts` 客户端进行 LDAP bind 认证和管理员组成员检查。
|
||||||
|
|
||||||
|
#### oidc.ts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
discoverOidcConfig(url: string): Promise<OidcConfig>
|
||||||
|
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<TokenSet>
|
||||||
|
getUserinfo(url: string, accessToken: string): Promise<OidcUserinfo>
|
||||||
|
```
|
||||||
|
|
||||||
|
完整的 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<CheckResult[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
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<boolean>
|
||||||
|
pushText(content: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
class WeChatPusherCompat {
|
||||||
|
// 兼容 issue-ai 的旧接口(返回 boolean)
|
||||||
|
pushMarkdown(title: string, content: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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` | 统一导出入口,消费方一行导入 |
|
||||||
|
|
@ -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<number | null>
|
||||||
|
setLastAlertTime(channelId: number, serviceId: number, level: string, timestamp: number): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AlertDecision> {
|
||||||
|
// 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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await this.cooldownProvider.setLastAlertTime(channelId, serviceId, level, timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
// shared/lib/alert/health-checker.ts — 健康检查引擎
|
||||||
|
import type { CheckType, HealthCheckConfig, CheckResult } from './types'
|
||||||
|
|
||||||
|
export interface Checker {
|
||||||
|
check(config: HealthCheckConfig): Promise<CheckResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealthChecker {
|
||||||
|
private checkers = new Map<CheckType, Checker>()
|
||||||
|
|
||||||
|
registerChecker(type: CheckType, handler: Checker): void {
|
||||||
|
this.checkers.set(type, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发检查所有维度
|
||||||
|
async check(checks: HealthCheckConfig[], timeoutMs: number): Promise<CheckResult[]> {
|
||||||
|
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<CheckResult> {
|
||||||
|
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<CheckResult> {
|
||||||
|
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<CheckResult> {
|
||||||
|
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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
`
|
||||||
|
|
@ -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<string, unknown> | 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<string, unknown>,
|
||||||
|
after: Record<string, unknown>,
|
||||||
|
): Record<string, { from: unknown; to: unknown }> {
|
||||||
|
const changes: Record<string, { from: unknown; to: unknown }> = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
// shared/lib/auth/jwt.ts — 共享 JWT 签发/验证(HS256,跨站点 cookie)
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
export interface SignJwtOptions {
|
||||||
|
secret: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
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<string, unknown> | 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<string, unknown> {
|
||||||
|
return {
|
||||||
|
name: 'tlyq_session',
|
||||||
|
value: '',
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
domain,
|
||||||
|
path: '/',
|
||||||
|
maxAge,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<LdapAuthResult> {
|
||||||
|
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<boolean> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string, unknown> | 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<string, unknown> | 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).*)'] }
|
||||||
|
|
@ -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<OidcUserinfo> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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}`
|
||||||
|
}
|
||||||
|
|
@ -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<PushResult> {
|
||||||
|
return this.push({
|
||||||
|
msgtype: 'markdown',
|
||||||
|
markdown: { content: `## ${title}\n${content}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送纯文本消息
|
||||||
|
async pushText(content: string): Promise<PushResult> {
|
||||||
|
return this.push({ msgtype: 'text', text: { content } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用推送
|
||||||
|
private async push(message: Record<string, unknown>): Promise<PushResult> {
|
||||||
|
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<boolean>
|
||||||
|
async pushText(text: string, _webhookUrl?: string): Promise<boolean> {
|
||||||
|
const result = await this.pusher.pushText(text)
|
||||||
|
return result.success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"}}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
|
||||||
|
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-center mb-1 text-slate-900 dark:text-white">{siteName}</h1>
|
||||||
|
{title && <p className="text-center text-xs text-slate-400 mb-5">{title}</p>}
|
||||||
|
{!title && <div className="mb-5" />}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showLdapForm ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleSsoLogin}
|
||||||
|
className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors duration-200 mb-3"
|
||||||
|
>
|
||||||
|
统一认证登录
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-xs text-slate-400 mb-3">通过 SSO 统一身份认证</p>
|
||||||
|
<p className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLdapForm(true)}
|
||||||
|
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 underline"
|
||||||
|
>
|
||||||
|
使用 LDAP 直接登录
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
||||||
|
<input
|
||||||
|
type="text" value={username} onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">密码</label>
|
||||||
|
<input
|
||||||
|
type="password" value={password} onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit" disabled={loading}
|
||||||
|
className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowLdapForm(false); setError('') }}
|
||||||
|
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 underline"
|
||||||
|
>
|
||||||
|
返回统一认证登录
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue