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:
gitadmin 2026-07-01 18:42:49 +08:00
parent 091ef426db
commit d39d2e65b3
7 changed files with 61 additions and 226 deletions

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ db-backups/
.playwright-mcp/
*.tsbuildinfo
.env.local
.DS_Store

1
shared Symbolic link
View File

@ -0,0 +1 @@
../shared

View File

@ -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
// 创建 issue-ai 的 store 适配器
const store: AuditStore = {
exec: (sql: string) => {
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)
}
// 写入审计日志(显式设置 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
)
getDb().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
// 保持原有调用方式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 }

View File

@ -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
const payload = verifyJwt(token, JWT_SECRET)
if (!payload) return null
return {
username: payload.username,
displayName: payload.displayName,
iat: payload.iat,
exp: payload.exp,
username: payload.username as string,
displayName: (payload.displayName || payload.username) as string,
iat: payload.iat as number,
exp: payload.exp as number,
}
} catch { return null }
}
// 保持原有签名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,
}

View File

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

View File

@ -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()
// 无效 keyAPI 路由返回 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)$).*)'],

View File

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