Compare commits

..

9 Commits

Author SHA1 Message Date
aiyimickey bde6c77f11 feat: #21 login-page.tsx 添加 localadmin hidden 入口 (?method=local) 2026-07-15 16:03:34 +08:00
aiyimickey d62dcd2f82 docs: 更新 CLAUDE.md — 新增 useDirtyTracker Hook 文档 2026-07-13 17:20:42 +08:00
aiyimickey 47661c31f1 feat: 新增 useDirtyTracker Hook — 脏状态追踪
支持数组/表单'修改后才显示保存按钮'模式:
- isDirty(key, currentItem): 判断指定key是否相对上次保存有变更
- markClean(key, item): 标记为已保存,隐藏保存按钮
- removeItem(key): 清理已删除项的追踪记录
2026-07-13 16:41:10 +08:00
aiyimickey 7ac52a8790 fix: OIDC callback 302 redirect + switch user /logout + maxAge support
- handle-callback.ts: 成功时 302 redirect 首页而非返回 200 JSON
- handle-login.ts: 切换账号改用 Authelia /logout 清除 session
- oidc.ts: buildAuthorizeUrl 支持 maxAge 参数
- 新增 V2 认证模块 (handle-login/callback/logout, jwt-v2, jwt-edge, middleware-v2, types-v2)
2026-07-07 16:32:33 +08:00
aiyimickey 8de2298f99 feat: 登录页添加「使用其他账号登录」按钮 2026-07-03 12:12:31 +08:00
aiyimickey 2b42184c03 fix: 共享 WeChatPusher 错误消息不泄露 webhook URL 2026-07-02 16:45:47 +08:00
aiyimickey c6043db197 chore: 审计日志 schema 及登录页组件微调 2026-07-02 11:25:26 +08:00
aiyimickey 8f473c2b6c docs: 添加 README.md 2026-07-01 18:47:20 +08:00
aiyimickey 447c0fce90 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 共享库迁移完成,独立审查通过
2026-07-01 18:42:16 +08:00
30 changed files with 2400 additions and 0 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
node_modules/
.DS_Store

423
CLAUDE.md Normal file
View File

@ -0,0 +1,423 @@
# 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 张表 DDLservices, 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 种 variant3 种 sizeloading 状态)
├── Badge.tsx # 标签5 种 variant
├── Card.tsx # 卡片容器
├── Input.tsx # 输入框(含 label 和 error
├── Select.tsx # 下拉选择
├── Modal.tsx # 模态框backdrop 点击关闭body scroll 锁定)
├── Table.tsx # 表格styled theadtbody 由调用方提供)
├── Toast.tsx # 通知条(右下角固定定位)
├── Pagination.tsx # 分页(中文标签:"上一页"/"下一页"
├── hooks/ # React Hooks
│ ├── useDirtyTracker.ts # 脏状态追踪("修改后才显示保存按钮"模式)
│ └── index.ts # Barrel 导出
└── 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` cookieJWT 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` | forwardRefloading 时显示 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` | 样式化 theadtbody 由调用方提供 |
| Toast | `message`, `type: 'success'|'error'|'info'`, `onClose` | 右下角固定通知条 |
| Pagination | `page`, `totalPages`, `onPageChange` | 中文标签totalPages <= 1 时隐藏 |
| useDirtyTracker | `isDirty(key, item): boolean`, `markClean(key, item): void`, `removeItem(key): void` | 脏状态追踪 Hook"修改后才显示保存按钮"模式 |
| 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` | 统一导出入口,消费方一行导入 |

70
README.md Normal file
View File

@ -0,0 +1,70 @@
# shared — TLYQ 共享标准库
tlyq.ai 各站点共享的标准库,消除代码重复,统一基础设施实现。
## 目录结构
```
shared/
├── lib/
│ ├── auth/ # 认证标准库
│ │ ├── config.ts # config 工厂createAuthConfig
│ │ ├── jwt.ts # JWT 签发/验证
│ │ ├── ldap.ts # LDAP 认证
│ │ ├── middleware.ts # middleware 工厂createMiddleware
│ │ ├── oidc.ts # OIDC 客户端
│ │ ├── types.ts # 类型定义
│ │ └── user-sync.ts # OIDC 用户同步
│ ├── audit/ # 审计日志标准库
│ │ ├── audit-schema.ts # 表结构常量
│ │ └── write-audit-log.ts # writeAuditLog + diffObjects + getClientIP
│ ├── wechat/ # 企业微信推送
│ │ ├── wechat-pusher.ts # WeChatPusher 类
│ │ └── message-formatter.ts # 消息格式化工具
│ ├── alert/ # 告警管理
│ │ ├── alert-manager.ts # AlertManager去重/冷却/免打扰)
│ │ ├── health-checker.ts # HealthCheckerHTTP/Docker
│ │ └── types.ts # 类型定义
│ └── db/ # 数据库 schema
│ └── alert-schema.ts # CREATE TABLE 常量
└── ui/ # 共享 UI 组件
└── login-page.tsx # 统一登录页
```
## 使用方式
### 1. 创建 symlink
```bash
ln -sf ../shared {站点目录}/shared
```
### 2. 配置 tsconfig
```json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["./shared/*"]
}
}
}
```
### 3. 引用
```typescript
import { createAuthConfig } from '@shared/lib/auth/config'
import { createMiddleware } from '@shared/lib/auth/middleware'
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
import { WeChatPusher } from '@shared/lib/wechat/wechat-pusher'
```
## 设计原则
- 零业务依赖:纯基础设施,不含站点特有逻辑
- 配置驱动:所有环境变量通过参数传入,不硬编码
- 接口稳定:包装器保持各站点原有调用方式不变
- Edge Runtime 兼容middleware 仅用 atob不用 Node.js crypto

View File

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

View File

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

57
lib/alert/types.ts Normal file
View File

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

18
lib/audit/audit-schema.ts Normal file
View File

@ -0,0 +1,18 @@
// 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,
api_key_id INTEGER,
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);
`

View File

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

55
lib/auth/config.ts Normal file
View File

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

234
lib/auth/handle-callback.ts Normal file
View File

@ -0,0 +1,234 @@
// shared/lib/auth/handle-callback.ts — OIDC callback 工厂(步骤 1a 新增)
// 统一 OIDC 回调流程token 交换 → userinfo 获取 → 用户同步 → JWT 签发 → cookie 设置 → 审计
import { NextResponse, type NextRequest } from 'next/server'
import { signJwtV2 } from './jwt-v2'
import type { OidcCallbackUserInfo } from './types-v2'
interface OidcConfig {
autheliaUrl: string
clientId: string
clientSecret: string
redirectUri: string
}
export interface HandleOidcCallbackConfig {
oidc: OidcConfig
jwtSecret: string
cookieDomain: string
/** 从本地 DB 查找已存在的用户 */
getUser: (username: string) => OidcCallbackUserInfo | null | Promise<OidcCallbackUserInfo | null>
/** 创建新用户(首次登录),返回含 role 的用户信息 */
createUser?: (username: string, displayName: string, email: string) => OidcCallbackUserInfo | Promise<OidcCallbackUserInfo>
/** 更新用户信息displayName、email 变更时) */
updateUser?: (username: string, displayName: string, email: string) => void | Promise<void>
/** 审计日志回调 */
onAuditLog?: (userId: number, username: string, request: NextRequest) => void | Promise<void>
}
/** 从 URL 解析 baseUrlredirectUri 去除路径部分) */
function baseUrlFromRedirectUri(redirectUri: string): string {
try {
const url = new URL(redirectUri)
return `${url.protocol}//${url.host}`
} catch {
return redirectUri.split('/api/')[0]
}
}
/** 构建错误响应redirect 到 /login?error= 并清除 OIDC 临时 cookietry/finally 确保) */
function loginError(baseUrl: string, request: NextRequest): NextResponse {
const response = NextResponse.redirect(new URL(`/login?error=login_failed`, baseUrl))
for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) {
if (request.cookies.get(name)) response.cookies.delete(name)
}
return response
}
/** OIDC 回调处理。成功返回 200 JSON + Set-Cookie失败返回 redirect 到 /login?error= */
export async function handleOidcCallback(
request: NextRequest,
config: HandleOidcCallbackConfig
): Promise<NextResponse> {
const baseUrl = baseUrlFromRedirectUri(config.oidc.redirectUri)
try {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const error = url.searchParams.get('error')
if (error) {
console.error(`OIDC error: ${error}`)
return loginError(baseUrl, request)
}
if (!code || !state) {
return loginError(baseUrl, request)
}
// 1. 验证 state
const savedState = request.cookies.get('oidc_state')?.value
if (state !== savedState) {
console.error('OIDC state mismatch')
return loginError(baseUrl, request)
}
// 2. 获取 code_verifier
const codeVerifier = request.cookies.get('oidc_code_verifier')?.value
if (!codeVerifier) {
console.error('OIDC code_verifier missing')
return loginError(baseUrl, request)
}
// 3. 交换 token
// client_secret_postsecret 放入 POST bodyAuthelia 不支持 client_secret_basic
const tokenRes = await fetch(`${config.oidc.autheliaUrl}/api/oidc/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.oidc.redirectUri,
client_id: config.oidc.clientId,
client_secret: config.oidc.clientSecret,
code_verifier: codeVerifier,
}),
})
if (!tokenRes.ok) {
console.error(`Token exchange failed: ${tokenRes.status}`)
return loginError(baseUrl, request)
}
const tokenData = await tokenRes.json()
const accessToken = tokenData.access_token as string
const idToken = tokenData.id_token as string | undefined
// 4. 验证 id_token nonce严格三阶段生成→id_token 解码→比对,任一缺失/不匹配→拒绝)
if (idToken) {
const idParts = idToken.split('.')
if (idParts.length !== 3) {
console.error('OIDC id_token malformed')
return loginError(baseUrl, request)
}
try {
const idPayload = JSON.parse(Buffer.from(idParts[1], 'base64url').toString())
const savedNonce = request.cookies.get('oidc_nonce')?.value
if (!savedNonce) {
console.error('OIDC nonce missing in cookie')
return loginError(baseUrl, request)
}
if (!idPayload.nonce) {
console.error('OIDC nonce missing in id_token')
return loginError(baseUrl, request)
}
if (idPayload.nonce !== savedNonce) {
console.error('OIDC nonce mismatch')
return loginError(baseUrl, request)
}
// 验证 aud设计方案 §3.3,裸 fetch 替代 openid-client 需手动校验)
if (!idPayload.aud) {
console.error('OIDC aud missing in id_token')
return loginError(baseUrl, request)
}
const audList = Array.isArray(idPayload.aud) ? idPayload.aud : [idPayload.aud]
if (!audList.includes(config.oidc.clientId)) {
console.error('OIDC aud mismatch')
return loginError(baseUrl, request)
}
} catch {
console.error('OIDC id_token parse failed')
return loginError(baseUrl, request)
}
}
// 5. 获取 userinfo
const userinfoRes = await fetch(`${config.oidc.autheliaUrl}/api/oidc/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!userinfoRes.ok) {
console.error(`Userinfo fetch failed: ${userinfoRes.status}`)
return loginError(baseUrl, request)
}
const userinfo = await userinfoRes.json()
const username = userinfo.preferred_username as string
const displayName = (userinfo.name as string) || username
const email = (userinfo.email as string) || ''
// 6. sub 一致性检查id_token sub 必须与 userinfo sub 一致)
if (idToken && userinfo.sub) {
try {
const idParts = idToken.split('.')
const idPayload = JSON.parse(Buffer.from(idParts[1], 'base64url').toString())
if (idPayload.sub && idPayload.sub !== userinfo.sub) {
console.error('OIDC sub mismatch')
return loginError(baseUrl, request)
}
} catch { /* sub check failure is non-fatal if id_token is unavailable */ }
}
// 7. 用户同步(查找或创建)
let user: OidcCallbackUserInfo | null = await Promise.resolve(config.getUser(username))
const isNew = !user
if (!user && config.createUser) {
user = await Promise.resolve(config.createUser(username, displayName, email))
}
if (!user) {
console.error(`User ${username} not found and createUser not provided`)
return loginError(baseUrl, request)
}
// 8. 更新用户信息(如果 displayName 或 email 变更)
if (!isNew && config.updateUser) {
try {
await Promise.resolve(config.updateUser(username, displayName, email))
} catch (e) {
console.error('updateUser failed:', e)
// 不阻塞登录
}
}
// 9. 签发 JWT不含 role
const token = signJwtV2({
secret: config.jwtSecret,
payload: { username, displayName },
iss: config.oidc.autheliaUrl,
})
// 10. 签发 cookie + 302 跳转首页OIDC 回调是浏览器导航,必须 redirect 而非 JSON
const response = NextResponse.redirect(new URL('/', baseUrl))
response.cookies.set('tlyq_session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
domain: config.cookieDomain,
path: '/',
maxAge: 604800,
})
// 11. 清理 OIDC 临时 cookie
for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) {
response.cookies.delete(name)
}
// 12. 审计日志
if (config.onAuditLog) {
try {
await Promise.resolve(config.onAuditLog(user.id, username, request))
} catch (e) {
console.error('onAuditLog failed:', e)
// 不阻塞登录
}
}
return response
} catch (e) {
console.error('handleOidcCallback error:', e)
return loginError(baseUrl, request)
}
}

64
lib/auth/handle-login.ts Normal file
View File

@ -0,0 +1,64 @@
// shared/lib/auth/handle-login.ts — OIDC 登录工厂(步骤 1a 新增)
// 生成 PKCE + state + nonce → 构建 Authelia 授权 URL → 设置临时 cookie → 302 跳转
import { NextResponse } from 'next/server'
import { generatePkce, generateState, buildAuthorizeUrl, type OidcClientConfig } from './oidc'
interface HandleOidcLoginConfig {
autheliaUrl: string
clientId: string
clientSecret: string
redirectUri: string
/** 强制重新认证("使用其他账号登录",对应 OIDC prompt=login */
switchUser?: boolean
}
const cookieOpts = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: 300, // 5 分钟
} as const
/** OIDC 登录入口。生成 PKCE + nonce → 设置临时 cookie → 重定向到 Authelia */
export async function handleOidcLogin(config: HandleOidcLoginConfig): Promise<NextResponse> {
// 切换账号:通过 Authelia /logout 页面清除 authelia_session
// Authelia 4.38 不支持 /api/oidc/end_session404且 prompt=login&max_age=0
// 均被忽略(仍然跳转同意页面)。唯一可靠方式:先到 Authelia /logout 清除 session
// 用户确认后再回跳本页重新发起 OIDC 授权(此时无 session → Authelia 显示登录页)
if (config.switchUser) {
// 注意rd 参数必须用不带 ?switch=1 的 login/oidc URL否则无限循环
const returnUrl = `${config.redirectUri.replace('/api/auth/callback', '')}/api/auth/login/oidc`
const logoutUrl = `${config.autheliaUrl}/logout?rd=${encodeURIComponent(returnUrl)}`
const response = NextResponse.redirect(logoutUrl)
response.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
response.cookies.set('session', '', { maxAge: 0, path: '/' })
return response
}
const { codeVerifier, codeChallenge } = generatePkce()
const state = generateState()
const nonce = generateState()
const oidcConfig: OidcClientConfig = {
autheliaUrl: config.autheliaUrl,
clientId: config.clientId,
clientSecret: config.clientSecret,
redirectUri: config.redirectUri,
}
const authorizeUrl = buildAuthorizeUrl(oidcConfig, {
codeChallenge,
state,
nonce,
})
const response = NextResponse.redirect(authorizeUrl)
// OIDC 临时 cookie5 分钟生命周期)
response.cookies.set('oidc_code_verifier', codeVerifier, cookieOpts)
response.cookies.set('oidc_state', state, cookieOpts)
response.cookies.set('oidc_nonce', nonce, cookieOpts)
return response
}

35
lib/auth/handle-logout.ts Normal file
View File

@ -0,0 +1,35 @@
// shared/lib/auth/handle-logout.ts — 登出工厂(步骤 1a 新增)
// 清除 tlyq_session cookie → 返回 200 JSON
import { NextResponse, type NextRequest } from 'next/server'
interface HandleOidcLogoutConfig {
cookieDomain: string
}
/** 登出:清除 tlyq_session 和 OIDC 临时 cookie → 返回 200 JSON */
export function handleOidcLogout(_req: NextRequest, config: HandleOidcLogoutConfig): NextResponse {
const response = NextResponse.json({ success: true })
// 清除 tlyq_session
response.cookies.set('tlyq_session', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
domain: config.cookieDomain,
path: '/',
maxAge: 0,
})
// 清除 OIDC 临时 cookie
for (const name of ['oidc_code_verifier', 'oidc_state', 'oidc_nonce']) {
response.cookies.set(name, '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: 0,
})
}
return response
}

62
lib/auth/jwt-edge.ts Normal file
View File

@ -0,0 +1,62 @@
// shared/lib/auth/jwt-edge.ts — V2 Edge Runtime JWT 验证(步骤 1a 新增)
// 使用 Web Crypto APIEdge Runtime 兼容3 参数强制校验 iss 匹配
import type { SessionPayloadV2 } from './types-v2'
/** Base64url 解码为 Uint8Array */
function base64urlToBytes(str: string): Uint8Array {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const binary = atob(base64 + padding)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/** 将 ArrayBuffer 转为 hex 字符串 */
function bufferToHex(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
/** 验证 JWTEdge Runtime3 参数。expectedIss = '*' 时跳过 iss 校验(迁移模式) */
export async function verifyJwtEdge(
token: string,
secret: string,
expectedIss: string
): Promise<SessionPayloadV2 | null> {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
// 解码 payload
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as SessionPayloadV2
// 过期检查
if (payload.exp && payload.exp * 1000 < Date.now()) return null
// iss 校验(* 为迁移模式,跳过校验,兼容旧 token 不含 iss
if (expectedIss !== '*' && payload.iss !== expectedIss) return null
// 验签HMAC-SHA256
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
const data = encoder.encode(`${parts[0]}.${parts[1]}`)
const signature = base64urlToBytes(parts[2])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const valid = await crypto.subtle.verify('HMAC', key, signature as any, data)
return valid ? payload : null
} catch {
return null
}
}

60
lib/auth/jwt-v2.ts Normal file
View File

@ -0,0 +1,60 @@
// shared/lib/auth/jwt-v2.ts — V2 JWT 签发/验证(步骤 1a 新增,旧 jwt.ts 不动)
// 与 V1 的关键差异signJwtV2 强制注入 issverifyJwtV2 强制校验 iss 匹配
import crypto from 'crypto'
import type { SessionPayloadV2 } from './types-v2'
interface SignJwtV2Options {
secret: string
payload: { username: string; displayName?: string }
/** 签发者标识OA LDAP 用 "oa.tlyq.ai"OIDC 用 autheliaUrl */
iss: string
expiresInSeconds?: number
}
/** 签发 JWTV2强制注入 iss */
export function signJwtV2({ secret, payload, iss, expiresInSeconds = 604800 }: SignJwtV2Options): string {
const header = { alg: 'HS256', typ: 'JWT' }
const now = Math.floor(Date.now() / 1000)
const body = { ...payload, iss, 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}`
}
/** 验证 JWTV23 参数强制校验 iss 匹配 */
export function verifyJwtV2(token: string, secret: string, expectedIss: string): SessionPayloadV2 | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as SessionPayloadV2
if (payload.exp && payload.exp * 1000 < Date.now()) return null
if (payload.iss !== expectedIss) return null
const signature = crypto
.createHmac('sha256', secret)
.update(`${parts[0]}.${parts[1]}`)
.digest('base64url')
return signature === parts[2] ? payload : null
} catch {
return null
}
}
/** 从 token 解码提取 iss不验签用于 Layer 2/3 自引用校验:先提取 iss → 再 verifyJwtV2 */
export function extractIss(token: string): string | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as SessionPayloadV2
return payload.iss || null
} catch {
return null
}
}

57
lib/auth/jwt.ts Normal file
View File

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

46
lib/auth/ldap.ts Normal file
View File

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

125
lib/auth/middleware-v2.ts Normal file
View File

@ -0,0 +1,125 @@
// shared/lib/auth/middleware-v2.ts — V2 middleware 工厂(步骤 1a 新增,旧 middleware.ts 不动)
// 与 V1 的关键差异:单 cookie 模型、Edge 验签+iss 校验、无 admin 路径检查(鉴权下沉到 layout/API
import { NextResponse, type NextRequest } from 'next/server'
import { verifyJwtEdge } from './jwt-edge'
import type { MiddlewareV2Config } from './types-v2'
// Edge Runtime 兼容的 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
}
/** 创建 V2 middleware单 cookie 模型Edge 验签) */
export function createMiddlewareV2(cfg: MiddlewareV2Config) {
const publicPaths = cfg.publicPaths || ['/login', '/api/auth', '/api/health', '/_next', '/favicon.ico']
const allowedIssuers = cfg.allowedIssuers
const enableApiKey = cfg.enableApiKey || false
return async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 1. 公开路径放行
if (publicPaths.some(p => pathname.startsWith(p))) {
if (pathname === '/login') {
const token = request.cookies.get('tlyq_session')?.value
const payload = token ? decodeJwtPayload(token) : null
if (isValidPayload(payload)) {
return NextResponse.redirect(new URL('/', request.nextUrl.origin))
}
}
return NextResponse.next()
}
// 2. 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 })
}
}
}
// 3. 验证 tlyq_session
const token = request.cookies.get('tlyq_session')?.value
if (!token) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
return noCache(NextResponse.redirect(new URL('/login', request.nextUrl.origin)))
}
// 4. 提取 iss → 验签Edge Runtime
const payload = decodeJwtPayload(token)
if (!payload || !isValidPayload(payload)) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '登录已过期' }, { status: 401 })
}
const loginUrl = new URL('/login', request.nextUrl.origin)
const response = NextResponse.redirect(loginUrl)
response.cookies.delete('tlyq_session')
return response
}
const iss = payload.iss as string | undefined
// allowedIssuers 白名单校验("*" 跳过,全量迁移后改为具体值)
if (allowedIssuers.length === 1 && allowedIssuers[0] === '*') {
// 迁移模式:跳过白名单校验,仅验签名+过期
} else if (!iss || !allowedIssuers.includes(iss)) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '无效的签发来源' }, { status: 401 })
}
return noCache(NextResponse.redirect(new URL('/login', request.nextUrl.origin)))
}
// 5. 验签(使用提取出的 iss 进行自引用校验)
// 迁移模式allowedIssuers = ["*"]):跳过 iss 校验,仅验签名+过期
// 全量迁移后allowedIssuers 为具体白名单):强制 iss 校验
const jwtSecret = cfg.jwtSecret
const isMigrationMode = allowedIssuers.length === 1 && allowedIssuers[0] === '*'
const expectedIss = isMigrationMode ? (iss || '*') : (iss || '')
const verified = await verifyJwtEdge(token, jwtSecret, expectedIss)
if (!verified) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '登录已过期' }, { status: 401 })
}
const loginUrl = new URL('/login', request.nextUrl.origin)
const response = NextResponse.redirect(loginUrl)
response.cookies.delete('tlyq_session')
return response
}
// 6. 放行(设置 session cookie 供客户端展示用httpOnly:false仅含 username
const response = noCache(NextResponse.next())
response.cookies.set('session', JSON.stringify({ username: verified.username }), {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
})
return response
}
}

152
lib/auth/middleware.ts Normal file
View File

@ -0,0 +1,152 @@
// 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 })
}
}
}
// 优先验证本地 cookie含 role由本站 OIDC callback 签发)
// 回退到 tlyq_session共享 JWT不含 role仅证明身份
const sharedToken = request.cookies.get('tlyq_session')?.value
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
const localToken = localCookieName ? request.cookies.get(localCookieName)?.value : undefined
const localPayload = localToken ? decodeJwtPayload(localToken) : null
// 1. 本地 cookie 优先(含 role可做 admin 检查)
if (isValidPayload(localPayload)) {
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()
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
}
// 2. 回退到 tlyq_session共享 JWT不含 role只验证身份
if (isValidPayload(sharedPayload)) {
// 注意tlyq_session 不含 roleadmin 检查在各站点 callback 中通过查本地 DB 完成
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
}
// 未认证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).*)'] }

97
lib/auth/oidc.ts Normal file
View File

@ -0,0 +1,97 @@
// 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
maxAge?: number
}): 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)
if (params.maxAge !== undefined) url.searchParams.set('max_age', String(params.maxAge))
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()
}

29
lib/auth/types-v2.ts Normal file
View File

@ -0,0 +1,29 @@
// shared/lib/auth/types-v2.ts — V2 类型定义(步骤 1a 新增,旧 types.ts 不动)
// 与 V1 的关键差异SessionPayloadV2 含 iss不含 role认证与鉴权分离
export interface SessionPayloadV2 {
username: string
displayName?: string
/** JWT 签发者标识OA LDAP 用 "oa.tlyq.ai"OIDC 用 autheliaUrl */
iss: string
iat: number
exp: number
}
/** handleOidcCallback 中 getUser/createUser 返回的用户信息role 不写入 JWT */
export interface OidcCallbackUserInfo {
id: number
role: string
}
/** createMiddlewareV2 的配置 */
export interface MiddlewareV2Config {
jwtSecret: string
cookieDomain: string
/** 允许的 iss 值白名单。迁移期间可设为 ["*"](跳过白名单校验,仅验签名+过期),全量迁移后改为具体值 */
allowedIssuers: string[]
publicPaths?: string[]
enableApiKey?: boolean
/** 管理员角色名,默认 'admin' */
adminRole?: string
}

35
lib/auth/types.ts Normal file
View File

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

24
lib/auth/user-sync.ts Normal file
View File

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

99
lib/db/alert-schema.ts Normal file
View File

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

View File

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

View File

@ -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: 'Network request failed' }
}
}
}
// 兼容适配器(降低 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
}
}

102
package-lock.json generated Normal file
View File

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

1
package.json Normal file
View File

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

1
ui/hooks/index.ts Normal file
View File

@ -0,0 +1 @@
export { useDirtyTracker } from './useDirtyTracker'

View File

@ -0,0 +1,46 @@
'use client'
import { useState, useRef } from 'react'
/**
* Hook "修改后才显示保存按钮"
*
* @example
* const { isDirty, markClean, removeItem } = useDirtyTracker()
* {items.map(item => (
* <div key={item.id}>
* <input onChange={...} />
* {isDirty(item.id, item) && <button onClick={save}></button>}
* </div>
* ))}
* // 保存成功后: markClean(id, savedItem)
* // 删除时: removeItem(id)
*/
export function useDirtyTracker() {
const savedMap = useRef<Map<string, string>>(new Map())
/**
* key markClean
* markClean key dirty
*/
const isDirty = (key: string, current: unknown): boolean => {
const saved = savedMap.current.get(key)
if (saved === undefined) return true // 从未保存过 → dirty
return saved !== JSON.stringify(current)
}
/**
* key "已保存" dirty
*/
const markClean = (key: string, item: unknown): void => {
savedMap.current.set(key, JSON.stringify(item))
}
/**
* key
*/
const removeItem = (key: string): void => {
savedMap.current.delete(key)
}
return { isDirty, markClean, removeItem }
}

161
ui/login-page.tsx Normal file
View File

@ -0,0 +1,161 @@
'use client'
// shared/ui/login-page.tsx — 统一登录页组件
// 支持 SSO、LDAP 和 localadmin通过 ?method=local URL 参数激活)三种登录方式
import { useState, useEffect } from 'react'
interface LoginPageProps {
siteName: string // 站点名称,如 "IT 工单跟踪系统"、"告警监控中心"
title?: string // 副标题(可选),如 "monitor-ai"
redirectPath?: string // 登录后跳转路径,默认 "/"
}
export default function LoginPage({ siteName, title, redirectPath = '/' }: LoginPageProps) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [showLdapForm, setShowLdapForm] = useState(false)
const [showLocalAdmin, setShowLocalAdmin] = useState(false)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.get('method') === 'local') setShowLocalAdmin(true)
}, [])
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 : redirectPath
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 && !showLocalAdmin ? (
<>
<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 mb-2">
<button
onClick={() => { window.location.href = '/api/auth/login/oidc?switch=1' }}
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 underline"
>
使
</button>
</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>
</>
) : showLocalAdmin ? (
<>
<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={() => { window.location.href = '/login' }}
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 underline"
>
</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>
)
}