refactor: P1 共享库迁移
- 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 独立审查通过
This commit is contained in:
parent
091ef426db
commit
d39d2e65b3
|
|
@ -17,3 +17,4 @@ db-backups/
|
|||
.playwright-mcp/
|
||||
*.tsbuildinfo
|
||||
.env.local
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<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
|
||||
// 保持原有调用方式: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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
if (!webhookUrl) { logger.error('Webhook URL not configured'); return false }
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ msgtype: 'text', text: { content: text } }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (!response.ok) { logger.error(`Webhook HTTP ${response.status}`); return false }
|
||||
const result = await response.json() as { errcode?: number; errmsg?: string }
|
||||
if (result.errcode === 0) { logger.info('WeChat message sent'); return true }
|
||||
logger.error(`WeChat error: ${result.errmsg}`)
|
||||
return false
|
||||
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<string, { deadline: string; hours: number }>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<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))
|
||||
}
|
||||
|
||||
// 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)$).*)'],
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in New Issue