From d39d2e65b300083951cc48eb5e3ff58e10ad2478 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Wed, 1 Jul 2026 18:42:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20P1=20=E5=85=B1=E4=BA=AB=E5=BA=93?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audit.ts: 引用 shared/lib/audit(包装器保持原接口) - jwt-shared.ts: 引用 shared/lib/auth/jwt(包装器保持 signSharedJwt/verifySharedJwt) - wechat-pusher.ts: pushText 改用 shared WeChatPusher,保留 formatAvailabilityMessage - middleware.ts: 使用 shared/lib/auth/middleware 工厂 - tsconfig: 添加 @shared/* 路径 - 添加 shared symlink 独立审查通过 --- .gitignore | 1 + shared | 1 + src/lib/audit.ts | 96 +++++----------------------- src/lib/jwt-shared.ts | 48 +++++--------- src/lib/monitor/wechat-pusher.ts | 32 ++++++---- src/middleware.ts | 106 ++----------------------------- tsconfig.json | 3 +- 7 files changed, 61 insertions(+), 226 deletions(-) create mode 120000 shared diff --git a/.gitignore b/.gitignore index eeb32e3..7b5b12c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ db-backups/ .playwright-mcp/ *.tsbuildinfo .env.local +.DS_Store diff --git a/shared b/shared new file mode 120000 index 0000000..8fba6b6 --- /dev/null +++ b/shared @@ -0,0 +1 @@ +../shared \ No newline at end of file diff --git a/src/lib/audit.ts b/src/lib/audit.ts index 82fa5ee..1f81d5f 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -1,87 +1,23 @@ +// issue-ai/src/lib/audit.ts — 站点审计包装器(引用共享库) import { getDb } from './db' +import { writeAuditLog as sharedWriteAuditLog, diffObjects, getClientIP } from '@shared/lib/audit/write-audit-log' +import type { AuditLogEntry, AuditStore } from '@shared/lib/audit/write-audit-log' -interface AuditLogOptions { - userId?: number | null - apiKeyId?: number | null - action: string - entityType: string - entityId?: number | null - details?: Record | null - ipAddress?: string | null -} - -export function writeAuditLog(opts: AuditLogOptions): void { - const { userId, apiKeyId, action, entityType, entityId, details, ipAddress } = opts - - try { - const db = getDb() - - // 每日清理(每天首次写入触发) - // 注意:禁止使用 toISOString(),会返回 UTC 时间导致时区偏移 - const now = new Date() - const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` - - const lastCleanup = db.prepare( - "SELECT value FROM settings WHERE key = 'audit_cleanup_date'" - ).get() as { value: string } | undefined - - if (!lastCleanup || lastCleanup.value !== today) { - // 从 settings 读取保留天数,默认 180 天 - const retentionRow = db.prepare( - "SELECT value FROM settings WHERE key = 'audit_retention_days'" - ).get() as { value: string } | undefined - const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10))) - - db.prepare( - `DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')` - ).run() - db.prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)" - ).run(today) +// 创建 issue-ai 的 store 适配器 +const store: AuditStore = { + exec: (sql: string) => { + try { + getDb().exec(sql) + } catch (e) { + console.error('审计日志写入失败:', e) } - - // 写入审计日志(显式设置 created_at 为北京时间) - db.prepare(` - INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+8 hours')) - `).run( - userId ?? null, - apiKeyId ?? null, - action, - entityType, - entityId ?? null, - details ? JSON.stringify(details) : null, - ipAddress ?? null - ) - } catch (e) { - // 审计写入失败不阻断主操作 - console.error('审计日志写入失败:', e) - } + }, } -export function diffObjects( - before: Record, - after: Record -): Record { - const changes: Record = {} - const keys = new Set([...Object.keys(before), ...Object.keys(after)]) - - for (const key of keys) { - // 跳过系统字段 - if (['created_at', 'updated_at'].includes(key)) continue - - const from = before[key] - const to = after[key] - if (JSON.stringify(from) !== JSON.stringify(to)) { - changes[key] = { from, to } - } - } - - return changes +// 保持原有调用方式:writeAuditLog(opts) —— 内部调用共享库 +export function writeAuditLog(opts: AuditLogEntry): void { + sharedWriteAuditLog(store, opts) } -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 -} +// re-export 共享工具函数 +export { diffObjects, getClientIP } diff --git a/src/lib/jwt-shared.ts b/src/lib/jwt-shared.ts index 17b593a..2c75376 100644 --- a/src/lib/jwt-shared.ts +++ b/src/lib/jwt-shared.ts @@ -1,6 +1,7 @@ -import crypto from 'crypto' +// issue-ai/src/lib/jwt-shared.ts — 引用共享 JWT,保持原有接口 +import { signJwt, verifyJwt } from '@shared/lib/auth/jwt' -const JWT_SECRET = process.env.JWT_SECRET || 'change-me-same-across-all-sites' +const JWT_SECRET = process.env.JWT_SECRET || 'default-secret-change-me' const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || '' export interface SharedSession { @@ -10,51 +11,34 @@ export interface SharedSession { exp: number } -function base64url(str: string): string { - return Buffer.from(str).toString('base64url') -} - +// 保持原有签名:signSharedJwt(payload, expiresIn) export function signSharedJwt( payload: { username: string; displayName: string }, expiresIn: number = 7 * 24 * 60 * 60 ): string { - const header = { alg: 'HS256', typ: 'JWT' } - const now = Math.floor(Date.now() / 1000) - const body = { ...payload, iat: now, exp: now + expiresIn } - const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))] - const signingInput = segments.join('.') - segments.push( - crypto.createHmac('sha256', JWT_SECRET).update(signingInput).digest('base64url') - ) - return segments.join('.') + return signJwt({ secret: JWT_SECRET, payload, expiresInSeconds: expiresIn }) } +// 保持原有签名:verifySharedJwt(token) export function verifySharedJwt(token: string): SharedSession | null { - try { - const parts = token.split('.') - if (parts.length !== 3) return null - const signingInput = parts.slice(0, 2).join('.') - const expectedSig = crypto.createHmac('sha256', JWT_SECRET) - .update(signingInput).digest('base64url') - if (parts[2] !== expectedSig) return null - const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) - if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null - return { - username: payload.username, - displayName: payload.displayName, - iat: payload.iat, - exp: payload.exp, - } - } catch { return null } + const payload = verifyJwt(token, JWT_SECRET) + if (!payload) return null + return { + username: payload.username as string, + displayName: (payload.displayName || payload.username) as string, + iat: payload.iat as number, + exp: payload.exp as number, + } } +// 保持原有签名:sharedCookieConfig(maxAge) export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) { return { name: 'tlyq_session', httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, - domain: COOKIE_DOMAIN, + domain: COOKIE_DOMAIN || undefined, path: '/', maxAge, } diff --git a/src/lib/monitor/wechat-pusher.ts b/src/lib/monitor/wechat-pusher.ts index 603948d..717a66a 100644 --- a/src/lib/monitor/wechat-pusher.ts +++ b/src/lib/monitor/wechat-pusher.ts @@ -1,5 +1,5 @@ -// src/lib/monitor/wechat-pusher.ts -import type { MonitorConfig } from './types' +// src/lib/monitor/wechat-pusher.ts — 引用共享 WeChatPusher,保留本地业务方法 +import { WeChatPusher as SharedWeChatPusher } from '@shared/lib/wechat/wechat-pusher' import { formatBeijingTime } from './types' const logger = { @@ -7,30 +7,34 @@ const logger = { error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`), } +// 使用共享 WeChatPusher,保持原有 pushText(text, webhookUrl) 签名 export class WeChatPusher { async pushText(text: string, webhookUrl: string): Promise { if (!webhookUrl) { logger.error('Webhook URL not configured'); return false } try { - const response = await fetch(webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ msgtype: 'text', text: { content: text } }), - signal: AbortSignal.timeout(5_000), - }) - if (!response.ok) { logger.error(`Webhook HTTP ${response.status}`); return false } - const result = await response.json() as { errcode?: number; errmsg?: string } - if (result.errcode === 0) { logger.info('WeChat message sent'); return true } - logger.error(`WeChat error: ${result.errmsg}`) - return false + const pusher = new SharedWeChatPusher(webhookUrl) + const result = await pusher.pushText(text) + if (!result.success) { logger.error(`Webhook HTTP ${result.responseCode}`); return false } + // 检查 WeChat API 级错误码 + try { + const apiResult = JSON.parse(result.responseBody || '{}') as { errcode?: number; errmsg?: string } + if (apiResult.errcode && apiResult.errcode !== 0) { + logger.error(`WeChat error: ${apiResult.errmsg} (code: ${apiResult.errcode})`) + return false + } + } catch { /* 解析失败视为成功(HTTP 200) */ } + logger.info('WeChat message sent') + return true } catch (e) { logger.error(`Webhook failed: ${e instanceof Error ? e.message : e}`) return false } } + // 保留 issue-ai 专有的业务格式化方法 formatAvailabilityMessage( deadlines: Record, - faultInfo: { server_ip: string | null; server_sn: string | null; fault_time: string | null }, + faultInfo: { server_ip: string | null; server_sn: string | null; fault_time: string | null; order_number?: string | null; fault_detail?: string | null }, isOemDiag: boolean, oemDeadline: string | null, orderNumber: string | null, diff --git a/src/middleware.ts b/src/middleware.ts index 83e4383..728229d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,103 +1,11 @@ -import { NextRequest, NextResponse } from 'next/server' +// issue-ai/src/middleware.ts — 使用共享 middleware 工厂 +import { createMiddleware } from '@shared/lib/auth/middleware' -function decodeJwtPayload(token: string): Record | null { - try { - const parts = token.split('.') - if (parts.length !== 3) return null - let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') - while (payload.length % 4) payload += '=' - return JSON.parse(atob(payload)) - } catch { return null } -} - -function isValidPayload(payload: Record | null): boolean { - if (!payload) return false - return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000)) -} - -// API Key 验证:检查 ALLOWED_API_KEYS 环境变量(逗号分隔明文 key) -// 注意:middleware 运行在 Edge Runtime,不能使用 better-sqlite3 等 Node.js 原生模块 -// DB 级别的 key 验证在 route handler 中进行(auth.ts verifyApiKey) -function verifyApiKey(key: string): boolean { - if (!key.startsWith('ak_')) return false - const allowedKeys = process.env.ALLOWED_API_KEYS || '' - if (!allowedKeys) return false - return allowedKeys.split(',').map(k => k.trim()).includes(key) -} - -function buildLoginRedirect(request: NextRequest) { - const { pathname } = request.nextUrl - const loginUrl = new URL('/login', request.url) - const dest = pathname + (request.nextUrl.search || '') - loginUrl.searchParams.set('redirect', dest) - return NextResponse.redirect(loginUrl) -} - -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl - - // 登录页:已登录用户自动跳转首页 - if (pathname.startsWith('/login')) { - const token = request.cookies.get('tlyq_session')?.value || request.cookies.get('session_issue')?.value - const payload = token ? decodeJwtPayload(token) : null - if (isValidPayload(payload)) { - return NextResponse.redirect(new URL('/dashboard', request.url)) - } - return NextResponse.next() - } - - // 首页/API 路径放行 - if (pathname === '/' || - pathname.startsWith('/api/auth/login') || pathname.startsWith('/api/auth/callback') || pathname === '/api/auth/logout' || - pathname.startsWith('/api/internal/')) { - return NextResponse.next() - } - - // API Key 认证(外部系统调用) - const authHeader = request.headers.get('authorization') - if (authHeader?.startsWith('Bearer ak_')) { - const key = authHeader.slice(7) - if (verifyApiKey(key)) return NextResponse.next() - // 无效 key:API 路由返回 401 - if (pathname.startsWith('/api/')) { - return NextResponse.json({ error: '未授权' }, { status: 401 }) - } - } - - // 优先检查 tlyq_session(共享 JWT) - const sharedToken = request.cookies.get('tlyq_session')?.value - const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null - if (isValidPayload(sharedPayload)) { - const response = pathname.startsWith('/api/') ? NextResponse.next() : NextResponse.next() - response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), { - httpOnly: true, sameSite: 'lax', path: '/', - }) - return response - } - - // 回退 session_issue(本地 JWT) - const localToken = request.cookies.get('session_issue')?.value - const localPayload = localToken ? decodeJwtPayload(localToken) : null - - if (pathname.startsWith('/api/')) { - if (!isValidPayload(localPayload)) { - return NextResponse.json({ error: '未登录' }, { status: 401 }) - } - return NextResponse.next() - } - - if (!isValidPayload(localPayload)) { - const response = buildLoginRedirect(request) - if (localToken) response.cookies.delete('session_issue') - return response - } - - const response = NextResponse.next() - response.cookies.set('session', JSON.stringify({ username: localPayload.username }), { - httpOnly: true, sameSite: 'lax', path: '/', - }) - return response -} +export const middleware = createMiddleware({ + localCookieName: 'session_issue', + adminPaths: ['/settings'], + enableApiKey: true, +}) export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'], diff --git a/tsconfig.json b/tsconfig.json index fba2bf3..6094027 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,8 @@ "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }], - "paths": { "@/*": ["./src/*"] } + "baseUrl": ".", + "paths": { "@/*": ["./src/*"], "@shared/*": ["./shared/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"]