初次提交
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import { getCookie, getHeader, setCookie, getRequestURL, getRequestHeader } from 'h3'
|
||||
import { JWTEnhanced } from './jwt-enhanced'
|
||||
import { db, eq, users } from '~/drizzle/db'
|
||||
|
||||
interface AuthResult {
|
||||
success: boolean
|
||||
message: string
|
||||
user?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证管理员权限
|
||||
* @param event - Nuxt事件对象
|
||||
* @returns 验证结果
|
||||
*/
|
||||
export async function verifyAdminAuth(event: any): Promise<AuthResult> {
|
||||
try {
|
||||
// 首先检查认证中间件是否已经验证了用户
|
||||
if (event.context?.user) {
|
||||
const user = event.context.user
|
||||
// 检查用户角色
|
||||
if (user.role !== 'ADMIN' && user.role !== 'SUPER_ADMIN') {
|
||||
return {
|
||||
success: false,
|
||||
message: '权限不足,需要管理员权限'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '认证成功',
|
||||
user: user
|
||||
}
|
||||
}
|
||||
|
||||
// 如果context中没有用户信息,尝试从Authorization头或cookie获取token
|
||||
let token: string | null = null
|
||||
const authHeader = getHeader(event, 'authorization')
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
token = getCookie(event, 'auth-token') || null
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
message: '缺少认证令牌'
|
||||
}
|
||||
}
|
||||
|
||||
// 验证JWT token
|
||||
let decoded: any
|
||||
try {
|
||||
decoded = JWTEnhanced.verifyToken(token)
|
||||
} catch (jwtError) {
|
||||
console.error('JWT验证失败:', jwtError)
|
||||
return {
|
||||
success: false,
|
||||
message: '认证令牌无效或已过期'
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库获取用户信息
|
||||
const userResult = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
passwordChangedAt: users.passwordChangedAt
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, decoded.userId))
|
||||
.limit(1)
|
||||
const user = userResult[0]
|
||||
|
||||
if (!user) {
|
||||
console.error('获取用户信息失败: 用户不存在')
|
||||
return {
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查token是否在密码修改之前签发(强制旧token失效)
|
||||
if (user.passwordChangedAt && decoded.iat) {
|
||||
const passwordChangedTime = Math.floor(new Date(user.passwordChangedAt).getTime() / 1000)
|
||||
if (decoded.iat < passwordChangedTime) {
|
||||
// 主动清除Cookie
|
||||
const isSecure =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
|
||||
setCookie(event, 'auth-token', '', {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
path: '/'
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
message: '密码已修改,请重新登录'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户角色
|
||||
if (user.role !== 'ADMIN' && user.role !== 'SUPER_ADMIN') {
|
||||
return {
|
||||
success: false,
|
||||
message: '权限不足,需要管理员权限'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '认证成功',
|
||||
user: user
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('权限验证过程中出错:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: '权限验证失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证超级管理员权限
|
||||
* @param event - Nuxt事件对象
|
||||
* @returns 验证结果
|
||||
*/
|
||||
export async function verifySuperAdminAuth(event: any): Promise<AuthResult> {
|
||||
const authResult = await verifyAdminAuth(event)
|
||||
|
||||
if (!authResult.success) {
|
||||
return authResult
|
||||
}
|
||||
|
||||
if (authResult.user?.role !== 'SUPER_ADMIN') {
|
||||
return {
|
||||
success: false,
|
||||
message: '权限不足,需要超级管理员权限'
|
||||
}
|
||||
}
|
||||
|
||||
return authResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户权限(包括普通用户)
|
||||
* @param event - Nuxt事件对象
|
||||
* @returns 验证结果
|
||||
*/
|
||||
export async function verifyUserAuth(event: any): Promise<AuthResult> {
|
||||
try {
|
||||
// 首先检查认证中间件是否已经验证了用户
|
||||
if (event.context?.user) {
|
||||
return {
|
||||
success: true,
|
||||
message: '认证成功',
|
||||
user: event.context.user
|
||||
}
|
||||
}
|
||||
|
||||
// 如果context中没有用户信息,尝试从Authorization头或cookie获取token
|
||||
let token: string | null = null
|
||||
const authHeader = getHeader(event, 'authorization')
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
token = getCookie(event, 'auth-token') || null
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
message: '缺少认证令牌'
|
||||
}
|
||||
}
|
||||
|
||||
// 验证JWT token
|
||||
let decoded: any
|
||||
try {
|
||||
decoded = JWTEnhanced.verifyToken(token)
|
||||
} catch (jwtError) {
|
||||
console.error('JWT验证失败:', jwtError)
|
||||
return {
|
||||
success: false,
|
||||
message: '认证令牌无效或已过期'
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库获取用户信息
|
||||
const userResult = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
passwordChangedAt: users.passwordChangedAt
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, decoded.userId))
|
||||
.limit(1)
|
||||
const user = userResult[0]
|
||||
|
||||
if (!user) {
|
||||
console.error('获取用户信息失败: 用户不存在')
|
||||
return {
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查token是否在密码修改之前签发(强制旧token失效)
|
||||
if (user.passwordChangedAt && decoded.iat) {
|
||||
const passwordChangedTime = Math.floor(new Date(user.passwordChangedAt).getTime() / 1000)
|
||||
if (decoded.iat < passwordChangedTime) {
|
||||
// 主动清除Cookie
|
||||
const isSecure =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
|
||||
setCookie(event, 'auth-token', '', {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
path: '/'
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
message: '密码已修改,请重新登录'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '认证成功',
|
||||
user: user
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('权限验证过程中出错:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: '权限验证失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { executeRedisCommand, isRedisReady } from './redis'
|
||||
import { cacheService } from '../services/cacheService'
|
||||
|
||||
// 缓存策略:永久缓存,数据修改时主动失效
|
||||
|
||||
// 缓存键前缀
|
||||
export const CACHE_PREFIXES = {
|
||||
SYSTEM: 'sys:',
|
||||
USER: 'user:',
|
||||
SCHEDULE: 'schedule:',
|
||||
SONG: 'song:',
|
||||
STATS: 'stats:',
|
||||
AUTH: 'auth:',
|
||||
VOTE: 'vote:'
|
||||
} as const
|
||||
|
||||
// 统一缓存操作接口
|
||||
export class CacheHelper {
|
||||
private static cacheVersions = new Map<string, string>() // 缓存版本管理
|
||||
|
||||
/**
|
||||
* 获取缓存数据,优先从Redis获取,fallback到CacheService
|
||||
*/
|
||||
static async get<T>(key: string, fallbackToMemory = true): Promise<T | null> {
|
||||
try {
|
||||
// 优先尝试Redis
|
||||
if (isRedisReady()) {
|
||||
const result = await executeRedisCommand(
|
||||
async () => {
|
||||
const client = (await import('./redis')).getRedisClient()
|
||||
if (!client) return null
|
||||
const data = await client.get(key)
|
||||
return data ? JSON.parse(data) : null
|
||||
},
|
||||
async () => null
|
||||
)
|
||||
|
||||
if (result !== null) {
|
||||
return result as T
|
||||
}
|
||||
}
|
||||
|
||||
// Redis未命中或不可用,尝试内存缓存
|
||||
if (fallbackToMemory) {
|
||||
return await cacheService.getCache<T>(key)
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error(`[CacheHelper] 获取缓存失败 ${key}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存(永久缓存)
|
||||
*/
|
||||
static async set<T>(key: string, value: T, version?: string): Promise<boolean> {
|
||||
try {
|
||||
let redisSuccess = false
|
||||
let memorySuccess = false
|
||||
const currentVersion = version || this.generateVersion()
|
||||
|
||||
// 尝试写入Redis(永久缓存)
|
||||
if (isRedisReady()) {
|
||||
redisSuccess = await executeRedisCommand(
|
||||
async () => {
|
||||
const client = (await import('./redis')).getRedisClient()
|
||||
if (!client) return false
|
||||
await client.set(key, JSON.stringify(value))
|
||||
if (version) {
|
||||
await client.set(`${key}:version`, currentVersion)
|
||||
}
|
||||
return true
|
||||
},
|
||||
async () => false
|
||||
)
|
||||
}
|
||||
|
||||
// 不使用内存缓存的setCache方法,因为它是private且需要TTL
|
||||
// 对于永久缓存策略,只使用Redis,内存缓存作为fallback读取
|
||||
memorySuccess = true // 标记为成功,因为我们主要依赖Redis
|
||||
|
||||
// 更新版本记录
|
||||
if (version) {
|
||||
this.cacheVersions.set(key, currentVersion)
|
||||
}
|
||||
|
||||
return redisSuccess || memorySuccess
|
||||
} catch (error) {
|
||||
console.error(`[CacheHelper] 设置缓存失败 ${key}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存数据
|
||||
*/
|
||||
static async delete(key: string): Promise<boolean> {
|
||||
try {
|
||||
let redisSuccess = false
|
||||
let memorySuccess = false
|
||||
|
||||
// 从Redis删除
|
||||
if (isRedisReady()) {
|
||||
redisSuccess =
|
||||
(await executeRedisCommand(
|
||||
async () => {
|
||||
const client = (await import('./redis')).getRedisClient()
|
||||
if (!client) return false
|
||||
await client.del(key)
|
||||
return true
|
||||
},
|
||||
async () => false
|
||||
)) || false
|
||||
}
|
||||
|
||||
// 从内存缓存删除
|
||||
try {
|
||||
await cacheService.deleteCache(key)
|
||||
memorySuccess = true
|
||||
} catch (error) {
|
||||
console.error(`[CacheHelper] 内存缓存删除失败 ${key}:`, error)
|
||||
}
|
||||
|
||||
return redisSuccess || memorySuccess
|
||||
} catch (error) {
|
||||
console.error(`[CacheHelper] 删除缓存失败 ${key}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除缓存(支持模式匹配)
|
||||
*/
|
||||
static async deletePattern(pattern: string): Promise<boolean> {
|
||||
try {
|
||||
let redisSuccess = false
|
||||
let memorySuccess = false
|
||||
|
||||
// 从Redis批量删除
|
||||
if (isRedisReady()) {
|
||||
redisSuccess =
|
||||
(await executeRedisCommand(
|
||||
async () => {
|
||||
const client = (await import('./redis')).getRedisClient()
|
||||
if (!client) return false
|
||||
const keys = await client.keys(pattern)
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys)
|
||||
}
|
||||
return true
|
||||
},
|
||||
async () => false
|
||||
)) || false
|
||||
}
|
||||
|
||||
// 从内存缓存批量删除
|
||||
try {
|
||||
await cacheService.deleteCachePattern(pattern)
|
||||
memorySuccess = true
|
||||
} catch (error) {
|
||||
console.error(`[CacheHelper] 内存缓存批量删除失败 ${pattern}:`, error)
|
||||
}
|
||||
|
||||
return redisSuccess || memorySuccess
|
||||
} catch (error) {
|
||||
console.error(`[CacheHelper] 批量删除缓存失败 ${pattern}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或设置缓存(永久缓存)
|
||||
*/
|
||||
static async getOrSet<T>(key: string, loader: () => Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
// 先尝试获取缓存
|
||||
const cached = await this.get<T>(key)
|
||||
if (cached !== null) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 缓存未命中,执行loader函数
|
||||
const data = await loader()
|
||||
if (data !== null && data !== undefined) {
|
||||
// 异步设置缓存(永久缓存),不等待结果
|
||||
this.set(key, data).catch((error) => {
|
||||
console.error(`[CacheHelper] 异步设置缓存失败 ${key}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error(`[CacheHelper] getOrSet操作失败 ${key}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建缓存键
|
||||
*/
|
||||
static buildKey(prefix: string, ...parts: (string | number)[]): string {
|
||||
return prefix + parts.join(':')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存版本是否有效
|
||||
*/
|
||||
static async isVersionValid(key: string, expectedVersion?: string): Promise<boolean> {
|
||||
if (!expectedVersion) return true
|
||||
|
||||
try {
|
||||
if (isRedisReady()) {
|
||||
const storedVersion = await executeRedisCommand(
|
||||
async () => {
|
||||
const client = (await import('./redis')).getRedisClient()
|
||||
if (!client) return null
|
||||
return await client.get(`${key}:version`)
|
||||
},
|
||||
async () => null
|
||||
)
|
||||
return storedVersion === expectedVersion
|
||||
} else {
|
||||
const cached = await cacheService.getCache(key)
|
||||
return cached?.version === expectedVersion
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('版本检查失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量失效相关缓存
|
||||
*/
|
||||
static async invalidateRelated(patterns: string[]): Promise<void> {
|
||||
try {
|
||||
for (const pattern of patterns) {
|
||||
await this.deletePattern(pattern)
|
||||
console.log(`[CacheHelper] 已失效缓存模式: ${pattern}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CacheHelper] 批量失效缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
static async getStats(): Promise<{
|
||||
redis: { enabled: boolean; keyCount?: number }
|
||||
memory: { keyCount: number; memoryUsage: number }
|
||||
versions: { count: number }
|
||||
}> {
|
||||
const stats = {
|
||||
redis: { enabled: false as boolean, keyCount: undefined as number | undefined },
|
||||
memory: { keyCount: 0, memoryUsage: 0 },
|
||||
versions: { count: this.cacheVersions.size }
|
||||
}
|
||||
|
||||
// Redis统计
|
||||
if (isRedisReady()) {
|
||||
stats.redis.enabled = true
|
||||
try {
|
||||
const keyCount = await executeRedisCommand(async (client) => {
|
||||
const keys = await client.keys('*')
|
||||
return keys.length
|
||||
}, 0)
|
||||
stats.redis.keyCount = keyCount
|
||||
} catch (error) {
|
||||
console.error('获取Redis统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存统计
|
||||
try {
|
||||
const memoryStats = await cacheService.getCacheStats()
|
||||
stats.memory.keyCount = memoryStats.keyCount || 0
|
||||
stats.memory.memoryUsage = JSON.stringify(memoryStats).length
|
||||
} catch (error) {
|
||||
console.error('[CacheHelper] 获取内存缓存统计失败:', error)
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
private static generateVersion(): string {
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷函数导出
|
||||
export const cache = {
|
||||
get: CacheHelper.get.bind(CacheHelper),
|
||||
set: CacheHelper.set.bind(CacheHelper),
|
||||
delete: CacheHelper.delete.bind(CacheHelper),
|
||||
deletePattern: CacheHelper.deletePattern.bind(CacheHelper),
|
||||
getOrSet: CacheHelper.getOrSet.bind(CacheHelper),
|
||||
buildKey: CacheHelper.buildKey.bind(CacheHelper),
|
||||
getStats: CacheHelper.getStats.bind(CacheHelper)
|
||||
}
|
||||
|
||||
// 特定业务的缓存辅助函数(永久缓存)
|
||||
export const systemCache = {
|
||||
getConfig: () => cache.get(cache.buildKey(CACHE_PREFIXES.SYSTEM, 'config')),
|
||||
setConfig: (config: any) => cache.set(cache.buildKey(CACHE_PREFIXES.SYSTEM, 'config'), config),
|
||||
clearConfig: () => cache.delete(cache.buildKey(CACHE_PREFIXES.SYSTEM, 'config'))
|
||||
}
|
||||
|
||||
export const userCache = {
|
||||
getAuth: (userId: string) => cache.get(cache.buildKey(CACHE_PREFIXES.AUTH, userId)),
|
||||
setAuth: (userId: string, authData: any) =>
|
||||
cache.set(cache.buildKey(CACHE_PREFIXES.AUTH, userId), authData),
|
||||
clearAuth: (userId: string) => cache.delete(cache.buildKey(CACHE_PREFIXES.AUTH, userId)),
|
||||
clearAllAuth: () => cache.deletePattern(cache.buildKey(CACHE_PREFIXES.AUTH, '*'))
|
||||
}
|
||||
|
||||
export const scheduleCache = {
|
||||
getPublic: () => cache.get(cache.buildKey(CACHE_PREFIXES.SCHEDULE, 'public')),
|
||||
setPublic: (schedules: any) =>
|
||||
cache.set(cache.buildKey(CACHE_PREFIXES.SCHEDULE, 'public'), schedules),
|
||||
getSemester: (semesterId: string) =>
|
||||
cache.get(cache.buildKey(CACHE_PREFIXES.SCHEDULE, 'semester', semesterId)),
|
||||
setSemester: (semesterId: string, schedules: any) =>
|
||||
cache.set(cache.buildKey(CACHE_PREFIXES.SCHEDULE, 'semester', semesterId), schedules),
|
||||
clearAll: () => cache.deletePattern(cache.buildKey(CACHE_PREFIXES.SCHEDULE, '*')),
|
||||
|
||||
// 智能缓存失效 - 数据变更时自动更新相关缓存
|
||||
async invalidateOnScheduleChange(
|
||||
changeType: 'public' | 'semester' | 'all',
|
||||
semesterId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log(`[ScheduleCache] 排期数据变更,失效缓存类型: ${changeType}`)
|
||||
|
||||
switch (changeType) {
|
||||
case 'public':
|
||||
// 公共排期变更,清除公共排期缓存
|
||||
await cache.delete(cache.buildKey(CACHE_PREFIXES.SCHEDULE, 'public'))
|
||||
console.log('[ScheduleCache] 已清除公共排期缓存')
|
||||
break
|
||||
|
||||
case 'semester':
|
||||
// 学期排期变更,清除特定学期缓存
|
||||
if (semesterId) {
|
||||
await cache.delete(cache.buildKey(CACHE_PREFIXES.SCHEDULE, 'semester', semesterId))
|
||||
console.log(`[ScheduleCache] 已清除学期 ${semesterId} 排期缓存`)
|
||||
}
|
||||
break
|
||||
|
||||
case 'all':
|
||||
// 全部排期变更,清除所有排期缓存
|
||||
await cache.deletePattern(cache.buildKey(CACHE_PREFIXES.SCHEDULE, '*'))
|
||||
console.log('[ScheduleCache] 已清除所有排期缓存')
|
||||
break
|
||||
}
|
||||
|
||||
// 触发相关统计缓存失效
|
||||
await cache.deletePattern(cache.buildKey(CACHE_PREFIXES.STATS, 'schedule', '*'))
|
||||
console.log('[ScheduleCache] 已清除相关统计缓存')
|
||||
} catch (error) {
|
||||
console.error('[ScheduleCache] 缓存失效操作失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 预热缓存 - 在数据变更后主动加载新数据到缓存
|
||||
async warmupCache(
|
||||
loader: {
|
||||
loadPublic?: () => Promise<any>
|
||||
loadSemester?: (semesterId: string) => Promise<any>
|
||||
},
|
||||
semesterId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log('[ScheduleCache] 开始预热缓存')
|
||||
|
||||
// 预热公共排期缓存
|
||||
if (loader.loadPublic) {
|
||||
const publicSchedules = await loader.loadPublic()
|
||||
if (publicSchedules) {
|
||||
await this.setPublic(publicSchedules)
|
||||
console.log('[ScheduleCache] 公共排期缓存预热完成')
|
||||
}
|
||||
}
|
||||
|
||||
// 预热学期排期缓存
|
||||
if (loader.loadSemester && semesterId) {
|
||||
const semesterSchedules = await loader.loadSemester(semesterId)
|
||||
if (semesterSchedules) {
|
||||
await this.setSemester(semesterId, semesterSchedules)
|
||||
console.log(`[ScheduleCache] 学期 ${semesterId} 排期缓存预热完成`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ScheduleCache] 缓存预热失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { db, testConnection } from '~/drizzle/db'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
// 数据库健康检查
|
||||
export async function checkDatabaseHealth() {
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
|
||||
// 基本连接测试
|
||||
const isConnected = await testConnection()
|
||||
if (!isConnected) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
message: 'Database connection failed',
|
||||
responseTime: Date.now() - startTime
|
||||
}
|
||||
}
|
||||
|
||||
// 查询测试
|
||||
await db.execute(sql`SELECT 1 as test`)
|
||||
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
return {
|
||||
status: responseTime < 1000 ? 'healthy' : 'slow',
|
||||
message: responseTime < 1000 ? 'Database is healthy' : 'Database is responding slowly',
|
||||
responseTime
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : 'Unknown database error',
|
||||
responseTime: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 数据库连接重试机制
|
||||
export async function retryDatabaseOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
delay: number = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// 等待后重试
|
||||
await new Promise((resolve) => setTimeout(resolve, delay * attempt))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!
|
||||
}
|
||||
|
||||
// 数据库连接池状态检查
|
||||
export async function getConnectionPoolStatus() {
|
||||
try {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
setting::int as max_connections
|
||||
FROM pg_settings
|
||||
WHERE name = 'max_connections'
|
||||
`)
|
||||
|
||||
const activeConnections = await db.execute(sql`
|
||||
SELECT count(*) as active_connections
|
||||
FROM pg_stat_activity
|
||||
WHERE state = 'active'
|
||||
`)
|
||||
|
||||
const totalConnections = await db.execute(sql`
|
||||
SELECT count(*) as total_connections
|
||||
FROM pg_stat_activity
|
||||
`)
|
||||
|
||||
return {
|
||||
maxConnections: result[0]?.max_connections || 0,
|
||||
activeConnections: activeConnections[0]?.active_connections || 0,
|
||||
totalConnections: totalConnections[0]?.total_connections || 0,
|
||||
utilization: result[0]?.max_connections
|
||||
? (
|
||||
((totalConnections[0]?.total_connections || 0) / result[0].max_connections) *
|
||||
100
|
||||
).toFixed(2)
|
||||
: '0'
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get connection pool status: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 数据库性能指标
|
||||
export async function getDatabaseMetrics() {
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
|
||||
// 测试查询响应时间
|
||||
await db.execute(sql`SELECT 1`)
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
// 获取数据库统计信息
|
||||
const stats = await db.execute(sql`
|
||||
SELECT
|
||||
numbackends as active_connections,
|
||||
xact_commit as transactions_committed,
|
||||
xact_rollback as transactions_rolled_back,
|
||||
blks_read as blocks_read,
|
||||
blks_hit as blocks_hit
|
||||
FROM pg_stat_database
|
||||
WHERE datname = current_database()
|
||||
`)
|
||||
|
||||
const stat = stats[0] || {}
|
||||
|
||||
return {
|
||||
responseTime,
|
||||
activeConnections: stat.active_connections || 0,
|
||||
transactionsCommitted: stat.transactions_committed || 0,
|
||||
transactionsRolledBack: stat.transactions_rolled_back || 0,
|
||||
cacheHitRatio:
|
||||
stat.blocks_read && stat.blocks_hit
|
||||
? ((stat.blocks_hit / (stat.blocks_hit + stat.blocks_read)) * 100).toFixed(2)
|
||||
: '0'
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get database metrics: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 数据库备份状态检查(模拟)
|
||||
export async function checkBackupStatus() {
|
||||
// 这里可以集成实际的备份服务
|
||||
return {
|
||||
lastBackup: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
size: '0 MB' // 模拟数据
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { db, getConnectionStatus } from '~/drizzle/db'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 数据库管理器
|
||||
*/
|
||||
export class DatabaseManager {
|
||||
private static instance: DatabaseManager
|
||||
private healthCheckCache: { status: boolean; timestamp: number; latency: number } | null = null
|
||||
private readonly CACHE_TTL = 30000 // 30秒缓存
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): DatabaseManager {
|
||||
if (!DatabaseManager.instance) {
|
||||
DatabaseManager.instance = new DatabaseManager()
|
||||
}
|
||||
return DatabaseManager.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库健康检查(带缓存)
|
||||
*/
|
||||
async healthCheck(): Promise<{
|
||||
status: boolean
|
||||
latency: number
|
||||
timestamp: Date
|
||||
connectionStatus: string
|
||||
error?: string
|
||||
}> {
|
||||
const now = Date.now()
|
||||
|
||||
// 检查缓存
|
||||
if (this.healthCheckCache && now - this.healthCheckCache.timestamp < this.CACHE_TTL) {
|
||||
return {
|
||||
status: this.healthCheckCache.status,
|
||||
latency: this.healthCheckCache.latency,
|
||||
timestamp: new Date(this.healthCheckCache.timestamp),
|
||||
connectionStatus: 'cached'
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
// 获取连接状态
|
||||
const connectionStatus = await getConnectionStatus()
|
||||
|
||||
// 执行简单查询测试连接
|
||||
await db.execute(sql`SELECT 1 as health_check`)
|
||||
|
||||
const latency = Date.now() - startTime
|
||||
const result = {
|
||||
status: true,
|
||||
latency,
|
||||
timestamp: new Date(),
|
||||
connectionStatus: connectionStatus.status
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
this.healthCheckCache = {
|
||||
status: true,
|
||||
timestamp: now,
|
||||
latency
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const latency = Date.now() - startTime
|
||||
const result = {
|
||||
status: false,
|
||||
latency,
|
||||
timestamp: new Date(),
|
||||
connectionStatus: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
this.healthCheckCache = {
|
||||
status: false,
|
||||
timestamp: now,
|
||||
latency
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础数据库信息 - 简化版本适配 Neon Database
|
||||
*/
|
||||
async getBasicMetrics(): Promise<{
|
||||
databaseSize: string
|
||||
activeConnections: number
|
||||
serverless: boolean
|
||||
}> {
|
||||
try {
|
||||
// 获取数据库大小
|
||||
const sizeResult = await db.execute(sql`
|
||||
SELECT pg_size_pretty(pg_database_size(current_database())) as database_size
|
||||
`)
|
||||
|
||||
// 获取当前连接数(简化版)
|
||||
const connectionStats = await db.execute(sql`
|
||||
SELECT count(*) as active_connections
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database() AND state = 'active'
|
||||
`)
|
||||
|
||||
const sizeRow = sizeResult[0] as any
|
||||
const connectionRow = connectionStats[0] as any
|
||||
|
||||
return {
|
||||
databaseSize: sizeRow?.database_size || 'Unknown',
|
||||
activeConnections: parseInt(connectionRow?.active_connections) || 0,
|
||||
serverless: true // Neon Database 是无服务器架构
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DatabaseManager] Failed to get basic metrics:', error)
|
||||
throw new Error(
|
||||
`Failed to retrieve basic metrics: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态 - 适配 Neon Database 无服务器架构
|
||||
*/
|
||||
async getConnectionStatus(): Promise<{
|
||||
status: string
|
||||
activeConnections: number
|
||||
serverlessMode: boolean
|
||||
autoSuspend: boolean
|
||||
}> {
|
||||
try {
|
||||
const connectionStatus = await getConnectionStatus()
|
||||
|
||||
// 获取当前活跃连接数
|
||||
const connectionStats = await db.execute(sql`
|
||||
SELECT count(*) as active_connections
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database() AND state = 'active'
|
||||
`)
|
||||
|
||||
const connRow = connectionStats[0] as any
|
||||
|
||||
return {
|
||||
status: connectionStatus.status,
|
||||
activeConnections: parseInt(connRow?.active_connections) || 0,
|
||||
serverlessMode: true, // Neon Database 是无服务器架构
|
||||
autoSuspend: true // 支持自动暂停
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get connection status:', error)
|
||||
throw new Error('Failed to retrieve connection status')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量清理过期会话
|
||||
*/
|
||||
async cleanupExpiredSessions(): Promise<number> {
|
||||
try {
|
||||
const result = await db.execute(sql`
|
||||
DELETE FROM session
|
||||
WHERE expires_at < NOW()
|
||||
`)
|
||||
|
||||
// postgres-js returns count in the result array object properties
|
||||
return (result as any).count || 0
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup expired sessions:', error)
|
||||
throw new Error('Failed to cleanup expired sessions')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除健康检查缓存
|
||||
*/
|
||||
clearHealthCheckCache(): void {
|
||||
this.healthCheckCache = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库管理器状态
|
||||
*/
|
||||
getManagerStatus(): {
|
||||
cacheEnabled: boolean
|
||||
cacheTTL: number
|
||||
lastHealthCheck: Date | null
|
||||
} {
|
||||
return {
|
||||
cacheEnabled: true,
|
||||
cacheTTL: this.CACHE_TTL,
|
||||
lastHealthCheck: this.healthCheckCache ? new Date(this.healthCheckCache.timestamp) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const databaseManager = DatabaseManager.getInstance()
|
||||
@@ -0,0 +1,61 @@
|
||||
// 简单的地理位置检测工具
|
||||
// 通过请求 ip-api.com 或其他公共 IP 服务来判断服务器位置
|
||||
|
||||
export interface GeoLocation {
|
||||
country: string
|
||||
countryCode: string
|
||||
region: string
|
||||
city: string
|
||||
isInChina: boolean
|
||||
}
|
||||
|
||||
let cachedLocation: GeoLocation | null = null
|
||||
|
||||
export const getServerLocation = async (): Promise<GeoLocation> => {
|
||||
if (cachedLocation) {
|
||||
return cachedLocation
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 ip-api.com (免费,限制 45 req/min)
|
||||
// 或者使用其他类似服务。这里为了稳定性,可以尝试多个源,或者只在启动时检测一次。
|
||||
// 注意:在某些服务器环境(如 Vercel Edge),可能需要特定的 Header 来获取 IP,但这里我们需要的是服务器本身的出口 IP 位置。
|
||||
|
||||
// 尝试 ip-api.com
|
||||
const response: any = await $fetch(
|
||||
'http://ip-api.com/json/?fields=status,message,country,countryCode,regionName,city',
|
||||
{
|
||||
timeout: 5000
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status === 'success') {
|
||||
const isChina = response.countryCode === 'CN'
|
||||
cachedLocation = {
|
||||
country: response.country,
|
||||
countryCode: response.countryCode,
|
||||
region: response.regionName,
|
||||
city: response.city,
|
||||
isInChina: isChina
|
||||
}
|
||||
return cachedLocation
|
||||
}
|
||||
|
||||
throw new Error('IP API failed')
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[Geo] Failed to detect server location, defaulting to non-China (conservative strategy):',
|
||||
error
|
||||
)
|
||||
// 默认认为不在中国,使用保守策略(第三方 API 通常在全球可用性更好,或者说原生 API 在国外可能受限)
|
||||
// 或者根据用户需求,如果用户希望默认原生,可以改为 true。
|
||||
// 但通常原生 API (如网易/QQ) 在海外会有 IP 限制,所以默认 false 比较安全。
|
||||
return {
|
||||
country: 'Unknown',
|
||||
countryCode: 'XX',
|
||||
region: 'Unknown',
|
||||
city: 'Unknown',
|
||||
isInChina: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { getHeaders } from 'h3'
|
||||
|
||||
/**
|
||||
* 获取客户端IP地址
|
||||
* @param event H3Event对象
|
||||
* @returns 客户端IP地址
|
||||
*/
|
||||
export function getClientIP(event: H3Event): string {
|
||||
// 尝试从各种可能的头部获取真实IP
|
||||
const headers = getHeaders(event)
|
||||
|
||||
// 按优先级检查各种IP头部
|
||||
const ipHeaders = [
|
||||
'x-forwarded-for',
|
||||
'x-real-ip',
|
||||
'x-client-ip',
|
||||
'cf-connecting-ip', // Cloudflare
|
||||
'x-forwarded',
|
||||
'forwarded-for',
|
||||
'forwarded'
|
||||
]
|
||||
|
||||
for (const header of ipHeaders) {
|
||||
const value = headers[header]
|
||||
if (value) {
|
||||
// x-forwarded-for 可能包含多个IP,取第一个
|
||||
const ip = Array.isArray(value) ? value[0] : value
|
||||
const firstIP = ip.split(',')[0].trim()
|
||||
if (firstIP && isValidIP(firstIP)) {
|
||||
return firstIP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到,直接从连接信息获取
|
||||
|
||||
// 从连接信息获取IP(备用方法)
|
||||
const remoteAddress = event.node.req.socket?.remoteAddress
|
||||
if (remoteAddress) {
|
||||
// 移除IPv6映射的IPv4前缀
|
||||
const cleanIP = remoteAddress.replace(/^::ffff:/, '')
|
||||
if (isValidIP(cleanIP)) {
|
||||
return cleanIP
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证IP地址格式是否有效
|
||||
* @param ip IP地址字符串
|
||||
* @returns 是否为有效IP
|
||||
*/
|
||||
function isValidIP(ip: string): boolean {
|
||||
// 简单的IP格式验证
|
||||
const ipv4Regex =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
|
||||
const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||
|
||||
// 基本格式验证
|
||||
if (!ipv4Regex.test(ip) && !ipv6Regex.test(ip)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 排除明显无效的IP
|
||||
if (ip === '0.0.0.0' || ip === '::') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化IP地址用于邮件显示
|
||||
* @param ip IP地址
|
||||
* @returns 格式化后的IP地址字符串
|
||||
*/
|
||||
export function formatIPForEmail(ip: string): string {
|
||||
if (!ip || ip === 'unknown') {
|
||||
return '未知'
|
||||
}
|
||||
|
||||
// 如果是本地IP,显示为本地访问
|
||||
if (
|
||||
ip === '127.0.0.1' ||
|
||||
ip === '::1' ||
|
||||
ip.startsWith('192.168.') ||
|
||||
ip.startsWith('10.') ||
|
||||
ip.startsWith('172.')
|
||||
) {
|
||||
return `${ip} (本地网络)`
|
||||
}
|
||||
|
||||
return ip
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
// 简化的JWT载荷接口
|
||||
export interface JWTPayload {
|
||||
userId: number
|
||||
role: string
|
||||
jti: string // JWT ID,用于唯一标识token
|
||||
iat?: number
|
||||
exp?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化的JWT工具类 - 仅支持单一JWT token验证
|
||||
*/
|
||||
export class JWTEnhanced {
|
||||
private static readonly JWT_SECRET = process.env.JWT_SECRET as string
|
||||
private static readonly TOKEN_EXPIRES = '7d' // 7天有效期
|
||||
private static readonly REFRESH_THRESHOLD = 24 * 60 * 60 // 24小时(秒),剩余时间少于此值时自动续期
|
||||
|
||||
/**
|
||||
* 通用签名方法
|
||||
*/
|
||||
static sign(payload: object, options?: jwt.SignOptions): string {
|
||||
if (!this.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is not set')
|
||||
}
|
||||
return jwt.sign(payload, this.JWT_SECRET, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用验证方法
|
||||
*/
|
||||
static verify(token: string): any {
|
||||
if (!this.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is not set')
|
||||
}
|
||||
return jwt.verify(token, this.JWT_SECRET)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成JWT token
|
||||
*/
|
||||
static generateToken(userId: number, role: string): string {
|
||||
if (!this.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is not set')
|
||||
}
|
||||
|
||||
// 生成唯一的JWT ID
|
||||
const jti = `${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// JWT载荷
|
||||
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
|
||||
userId,
|
||||
role,
|
||||
jti
|
||||
}
|
||||
|
||||
// 生成token
|
||||
const token = jwt.sign(payload, this.JWT_SECRET, {
|
||||
expiresIn: this.TOKEN_EXPIRES
|
||||
})
|
||||
|
||||
console.log(`Generated JWT token for user ${userId}`)
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并解码token
|
||||
*/
|
||||
static verifyToken(token: string): JWTPayload {
|
||||
if (!this.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is not set')
|
||||
}
|
||||
|
||||
try {
|
||||
// 验证并解码token
|
||||
const decoded = jwt.verify(token, this.JWT_SECRET) as JWTPayload
|
||||
|
||||
// 验证必要字段
|
||||
if (!decoded.userId || !decoded.role || !decoded.jti) {
|
||||
throw new Error('Invalid token payload: missing required fields')
|
||||
}
|
||||
|
||||
return decoded
|
||||
} catch (error: any) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
throw new Error('Token has expired')
|
||||
}
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
throw new Error('Invalid token signature')
|
||||
}
|
||||
if (error.name === 'NotBeforeError') {
|
||||
throw new Error('Token not active yet')
|
||||
}
|
||||
throw new Error(`Token verification failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取token信息(不验证签名)
|
||||
*/
|
||||
static getTokenInfo(token: string): JWTPayload | null {
|
||||
try {
|
||||
const decoded = jwt.decode(token) as JWTPayload
|
||||
return decoded && decoded.userId ? decoded : null
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否已过期
|
||||
*/
|
||||
static isTokenExpired(token: string): boolean {
|
||||
const decoded = this.getTokenInfo(token)
|
||||
if (!decoded || !decoded.exp) return true
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return decoded.exp <= now
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取token剩余有效时间(秒)
|
||||
*/
|
||||
static getTokenRemainingTime(token: string): number {
|
||||
const decoded = this.getTokenInfo(token)
|
||||
if (!decoded || !decoded.exp) return 0
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return Math.max(0, decoded.exp - now)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否需要续期
|
||||
* @param token JWT token
|
||||
* @param thresholdSeconds 续期阈值(秒),默认24小时
|
||||
* @returns 是否需要续期
|
||||
*/
|
||||
static shouldRefreshToken(
|
||||
token: string,
|
||||
thresholdSeconds: number = this.REFRESH_THRESHOLD
|
||||
): boolean {
|
||||
const remainingTime = this.getTokenRemainingTime(token)
|
||||
return remainingTime > 0 && remainingTime <= thresholdSeconds
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新token(生成新的token)
|
||||
* @param oldToken 旧的token
|
||||
* @returns 新的token
|
||||
*/
|
||||
static refreshToken(oldToken: string): string {
|
||||
const decoded = this.verifyToken(oldToken)
|
||||
|
||||
// 使用原有的用户信息生成新token
|
||||
return this.generateToken(decoded.userId, decoded.role)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证token并自动续期(如果需要)
|
||||
* @param token JWT token
|
||||
* @returns { valid: boolean, payload?: JWTPayload, newToken?: string }
|
||||
*/
|
||||
static verifyAndRefresh(token: string): {
|
||||
valid: boolean
|
||||
payload?: JWTPayload
|
||||
newToken?: string
|
||||
} {
|
||||
try {
|
||||
const payload = this.verifyToken(token)
|
||||
|
||||
// 检查是否需要续期
|
||||
if (this.shouldRefreshToken(token)) {
|
||||
const newToken = this.refreshToken(token)
|
||||
console.log(`Token auto-refreshed for user ${payload.userId}`)
|
||||
return {
|
||||
valid: true,
|
||||
payload,
|
||||
newToken
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
payload
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
export default JWTEnhanced
|
||||
@@ -0,0 +1,395 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import type { WriteStream } from 'fs'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { LOG_CONSTANTS } from '../config/constants'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { apiLogs } from '~/drizzle/schema'
|
||||
import { lt, sql } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 日志管理器
|
||||
* 负责API日志的轮转、归档和清理
|
||||
*/
|
||||
export class LogManager {
|
||||
private static instance: LogManager
|
||||
private logStream: WriteStream | null = null
|
||||
private currentLogFile: string = ''
|
||||
private rotationTimer: NodeJS.Timeout | null = null
|
||||
private archiveTimer: NodeJS.Timeout | null = null
|
||||
|
||||
private constructor() {
|
||||
this.initializeLogRotation()
|
||||
}
|
||||
|
||||
static getInstance(): LogManager {
|
||||
if (!LogManager.instance) {
|
||||
LogManager.instance = new LogManager()
|
||||
}
|
||||
return LogManager.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入日志
|
||||
*/
|
||||
async writeLog(logEntry: {
|
||||
timestamp: Date
|
||||
method: string
|
||||
path: string
|
||||
statusCode: number
|
||||
responseTime: number
|
||||
apiKeyId?: number
|
||||
ipAddress?: string
|
||||
userAgent?: string
|
||||
requestBody?: string
|
||||
responseBody?: string
|
||||
errorMessage?: string
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const logLine =
|
||||
JSON.stringify({
|
||||
...logEntry,
|
||||
timestamp: logEntry.timestamp.toISOString()
|
||||
}) + '\n'
|
||||
|
||||
if (this.logStream) {
|
||||
this.logStream.write(logLine)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 写入日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行日志轮转
|
||||
*/
|
||||
async rotateLog(): Promise<void> {
|
||||
try {
|
||||
console.log('[Log Manager] 开始执行日志轮转')
|
||||
|
||||
// 关闭当前日志流
|
||||
if (this.logStream) {
|
||||
this.logStream.end()
|
||||
this.logStream = null
|
||||
}
|
||||
|
||||
// 创建新的日志文件
|
||||
await this.createCurrentLogFile()
|
||||
|
||||
// 清理过期的日志文件
|
||||
await this.cleanupOldLogs()
|
||||
|
||||
console.log('[Log Manager] 日志轮转完成')
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 日志轮转失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行日志归档
|
||||
*/
|
||||
async archiveLogs(): Promise<void> {
|
||||
try {
|
||||
console.log('[Log Manager] 开始执行日志归档')
|
||||
|
||||
const logDir = LOG_CONSTANTS.LOG_DIR
|
||||
const archiveDir = LOG_CONSTANTS.ARCHIVE_DIR
|
||||
const files = await fs.readdir(logDir)
|
||||
const archiveDate = new Date()
|
||||
archiveDate.setDate(archiveDate.getDate() - LOG_CONSTANTS.ARCHIVE_AFTER_DAYS)
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith('api-') && file.endsWith('.log')) {
|
||||
const filePath = path.join(logDir, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
|
||||
// 如果文件超过归档天数,移动到归档目录
|
||||
if (stats.mtime < archiveDate) {
|
||||
const archivePath = path.join(archiveDir, file)
|
||||
await fs.rename(filePath, archivePath)
|
||||
console.log(`[Log Manager] 归档日志文件: ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理数据库中的过期日志
|
||||
await this.cleanupDatabaseLogs()
|
||||
|
||||
console.log('[Log Manager] 日志归档完成')
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 日志归档失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志统计信息
|
||||
*/
|
||||
async getLogStats(): Promise<{
|
||||
currentLogFile: string
|
||||
logFileSize: number
|
||||
totalLogFiles: number
|
||||
archivedFiles: number
|
||||
oldestLog: Date | null
|
||||
newestLog: Date | null
|
||||
}> {
|
||||
try {
|
||||
const logDir = LOG_CONSTANTS.LOG_DIR
|
||||
const archiveDir = LOG_CONSTANTS.ARCHIVE_DIR
|
||||
|
||||
// 获取当前日志文件大小
|
||||
let logFileSize = 0
|
||||
try {
|
||||
const stats = await fs.stat(this.currentLogFile)
|
||||
logFileSize = stats.size
|
||||
} catch {
|
||||
// 文件不存在
|
||||
}
|
||||
|
||||
// 统计日志文件数量
|
||||
const logFiles = await fs.readdir(logDir)
|
||||
const totalLogFiles = logFiles.filter(
|
||||
(f) => f.startsWith('api-') && f.endsWith('.log')
|
||||
).length
|
||||
|
||||
// 统计归档文件数量
|
||||
let archivedFiles = 0
|
||||
try {
|
||||
const archiveFiles = await fs.readdir(archiveDir)
|
||||
archivedFiles = archiveFiles.filter(
|
||||
(f) => f.startsWith('api-') && f.endsWith('.log')
|
||||
).length
|
||||
} catch {
|
||||
// 归档目录不存在
|
||||
}
|
||||
|
||||
// 获取最新和最旧的日志时间(从数据库)
|
||||
const [oldestResult, newestResult] = await Promise.all([
|
||||
db
|
||||
.select({ createdAt: apiLogs.createdAt })
|
||||
.from(apiLogs)
|
||||
.orderBy(apiLogs.createdAt)
|
||||
.limit(1),
|
||||
db
|
||||
.select({ createdAt: apiLogs.createdAt })
|
||||
.from(apiLogs)
|
||||
.orderBy(sql`${apiLogs.createdAt} DESC`)
|
||||
.limit(1)
|
||||
])
|
||||
|
||||
return {
|
||||
currentLogFile: this.currentLogFile,
|
||||
logFileSize,
|
||||
totalLogFiles,
|
||||
archivedFiles,
|
||||
oldestLog: oldestResult[0]?.createdAt || null,
|
||||
newestLog: newestResult[0]?.createdAt || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 获取日志统计失败:', error)
|
||||
return {
|
||||
currentLogFile: this.currentLogFile,
|
||||
logFileSize: 0,
|
||||
totalLogFiles: 0,
|
||||
archivedFiles: 0,
|
||||
oldestLog: null,
|
||||
newestLog: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发日志轮转
|
||||
*/
|
||||
async manualRotate(): Promise<void> {
|
||||
await this.rotateLog()
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发日志归档
|
||||
*/
|
||||
async manualArchive(): Promise<void> {
|
||||
await this.archiveLogs()
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁日志管理器
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.logStream) {
|
||||
this.logStream.end()
|
||||
this.logStream = null
|
||||
}
|
||||
|
||||
if (this.rotationTimer) {
|
||||
clearInterval(this.rotationTimer)
|
||||
this.rotationTimer = null
|
||||
}
|
||||
|
||||
if (this.archiveTimer) {
|
||||
clearTimeout(this.archiveTimer)
|
||||
this.archiveTimer = null
|
||||
}
|
||||
|
||||
console.log('[Log Manager] 日志管理器已销毁')
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化日志轮转
|
||||
*/
|
||||
private async initializeLogRotation(): Promise<void> {
|
||||
try {
|
||||
// 确保日志目录存在
|
||||
await this.ensureLogDirectory()
|
||||
|
||||
// 创建当前日志文件
|
||||
await this.createCurrentLogFile()
|
||||
|
||||
// 设置定时轮转(每天凌晨执行)
|
||||
this.scheduleLogRotation()
|
||||
|
||||
// 设置定时归档(每周执行)
|
||||
this.scheduleLogArchive()
|
||||
|
||||
console.log('[Log Manager] 日志管理器初始化完成')
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 初始化失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保日志目录存在
|
||||
*/
|
||||
private async ensureLogDirectory(): Promise<void> {
|
||||
const logDir = LOG_CONSTANTS.LOG_DIR
|
||||
const archiveDir = LOG_CONSTANTS.ARCHIVE_DIR
|
||||
|
||||
try {
|
||||
await fs.access(logDir)
|
||||
} catch {
|
||||
await fs.mkdir(logDir, { recursive: true })
|
||||
console.log(`[Log Manager] 创建日志目录: ${logDir}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(archiveDir)
|
||||
} catch {
|
||||
await fs.mkdir(archiveDir, { recursive: true })
|
||||
console.log(`[Log Manager] 创建归档目录: ${archiveDir}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建当前日志文件
|
||||
*/
|
||||
private async createCurrentLogFile(): Promise<void> {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
this.currentLogFile = path.join(LOG_CONSTANTS.LOG_DIR, `api-${today}.log`)
|
||||
|
||||
// 关闭现有流
|
||||
if (this.logStream) {
|
||||
this.logStream.end()
|
||||
}
|
||||
|
||||
// 创建新的写入流
|
||||
this.logStream = createWriteStream(this.currentLogFile, { flags: 'a' })
|
||||
|
||||
console.log(`[Log Manager] 当前日志文件: ${this.currentLogFile}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的日志文件
|
||||
*/
|
||||
private async cleanupOldLogs(): Promise<void> {
|
||||
try {
|
||||
const logDir = LOG_CONSTANTS.LOG_DIR
|
||||
const files = await fs.readdir(logDir)
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - LOG_CONSTANTS.RETENTION_DAYS)
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith('api-') && file.endsWith('.log')) {
|
||||
const filePath = path.join(logDir, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
|
||||
if (stats.mtime < cutoffDate) {
|
||||
await fs.unlink(filePath)
|
||||
console.log(`[Log Manager] 删除过期日志文件: ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 清理过期日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理数据库中的过期日志
|
||||
*/
|
||||
private async cleanupDatabaseLogs(): Promise<void> {
|
||||
try {
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - LOG_CONSTANTS.DB_RETENTION_DAYS)
|
||||
|
||||
const result = await db.delete(apiLogs).where(lt(apiLogs.createdAt, cutoffDate))
|
||||
|
||||
console.log(`[Log Manager] 清理数据库日志记录: ${result.rowCount || 0} 条`)
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 清理数据库日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置定时日志轮转
|
||||
*/
|
||||
private scheduleLogRotation(): void {
|
||||
// 计算到下一个凌晨的毫秒数
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(now)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setHours(0, 0, 0, 0)
|
||||
const msUntilMidnight = tomorrow.getTime() - now.getTime()
|
||||
|
||||
// 设置首次执行
|
||||
setTimeout(() => {
|
||||
this.rotateLog()
|
||||
|
||||
// 设置每日定时执行
|
||||
this.rotationTimer = setInterval(
|
||||
() => {
|
||||
this.rotateLog()
|
||||
},
|
||||
24 * 60 * 60 * 1000
|
||||
) // 24小时
|
||||
}, msUntilMidnight)
|
||||
|
||||
console.log(`[Log Manager] 日志轮转定时器设置完成,首次执行时间: ${tomorrow.toISOString()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置定时日志归档
|
||||
*/
|
||||
private scheduleLogArchive(): void {
|
||||
// 每周日凌晨2点执行归档
|
||||
const scheduleArchive = () => {
|
||||
const now = new Date()
|
||||
const nextSunday = new Date(now)
|
||||
const daysUntilSunday = (7 - now.getDay()) % 7
|
||||
nextSunday.setDate(now.getDate() + (daysUntilSunday === 0 ? 7 : daysUntilSunday))
|
||||
nextSunday.setHours(2, 0, 0, 0)
|
||||
|
||||
const msUntilArchive = nextSunday.getTime() - now.getTime()
|
||||
|
||||
setTimeout(() => {
|
||||
this.archiveLogs()
|
||||
scheduleArchive() // 递归设置下次执行
|
||||
}, msUntilArchive)
|
||||
|
||||
console.log(`[Log Manager] 日志归档定时器设置完成,下次执行时间: ${nextSunday.toISOString()}`)
|
||||
}
|
||||
|
||||
scheduleArchive()
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const logManager = LogManager.getInstance()
|
||||
@@ -0,0 +1,48 @@
|
||||
export const sizeFormate = (limit: number) => {
|
||||
if (limit === null || limit === undefined) return ''
|
||||
let size = ''
|
||||
if (limit < 0.1 * 1024) {
|
||||
// 小于0.1KB,则转化成B
|
||||
size = limit.toFixed(2) + 'B'
|
||||
} else if (limit < 0.1 * 1024 * 1024) {
|
||||
// 小于0.1MB,则转化成KB
|
||||
size = (limit / 1024).toFixed(2) + 'KB'
|
||||
} else if (limit < 0.1 * 1024 * 1024 * 1024) {
|
||||
// 小于0.1GB,则转化成MB
|
||||
size = (limit / (1024 * 1024)).toFixed(2) + 'MB'
|
||||
} else {
|
||||
// 其他转化成GB
|
||||
size = (limit / (1024 * 1024 * 1024)).toFixed(2) + 'GB'
|
||||
}
|
||||
|
||||
const sizeStr = size + '' // 转成字符串
|
||||
const len = sizeStr.indexOf('.')
|
||||
const dec = sizeStr.substr(len + 1, 2)
|
||||
if (dec === '00') {
|
||||
// 当小数点后为00时 去掉小数部分
|
||||
return sizeStr.substring(0, len) + sizeStr.substr(len + 3, 2)
|
||||
}
|
||||
return sizeStr
|
||||
}
|
||||
|
||||
export const formatPlayTime = (time: number) => {
|
||||
let m: any = parseInt(String(time / 60))
|
||||
let s: any = parseInt(String(time % 60))
|
||||
m = m === 0 ? '00' : m < 10 ? '0' + m : m
|
||||
s = s === 0 ? '00' : s < 10 ? '0' + s : s
|
||||
return m + ':' + s
|
||||
}
|
||||
|
||||
export const decodeName = (str: string | null = '') => {
|
||||
if (!str) return ''
|
||||
// 简单的 HTML 实体解码
|
||||
const entities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'"
|
||||
}
|
||||
return str.replace(/&|<|>|"|'|'/g, (match) => entities[match] || match)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export const txHeaders = {
|
||||
'User-Agent': 'QQMusic 14090508(android 12)'
|
||||
}
|
||||
|
||||
export const createTxSearchBody = (str: string, page: number, limit: number) => {
|
||||
return {
|
||||
comm: {
|
||||
ct: '11',
|
||||
cv: '14090508',
|
||||
v: '14090508',
|
||||
tmeAppID: 'qqmusic',
|
||||
phonetype: 'EBG-AN10',
|
||||
deviceScore: '553.47',
|
||||
devicelevel: '50',
|
||||
newdevicelevel: '20',
|
||||
rom: 'HuaWei/EMOTION/EmotionUI_14.2.0',
|
||||
os_ver: '12',
|
||||
OpenUDID: '0',
|
||||
OpenUDID2: '0',
|
||||
QIMEI36: '0',
|
||||
udid: '0',
|
||||
chid: '0',
|
||||
aid: '0',
|
||||
oaid: '0',
|
||||
taid: '0',
|
||||
tid: '0',
|
||||
wid: '0',
|
||||
uid: '0',
|
||||
sid: '0',
|
||||
modeSwitch: '6',
|
||||
teenMode: '0',
|
||||
ui_mode: '2',
|
||||
nettype: '1020',
|
||||
v4ip: ''
|
||||
},
|
||||
req: {
|
||||
module: 'music.search.SearchCgiService',
|
||||
method: 'DoSearchForQQMusicMobile',
|
||||
param: {
|
||||
search_type: 0,
|
||||
query: str,
|
||||
page_num: page,
|
||||
num_per_page: limit,
|
||||
highlight: 0,
|
||||
nqc_flag: 0,
|
||||
multi_zhida: 0,
|
||||
cat: 2,
|
||||
grp: 1,
|
||||
sin: 0,
|
||||
sem: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const txRequest = async (url: string, body: any) => {
|
||||
try {
|
||||
const response = await $fetch(url, {
|
||||
method: 'POST',
|
||||
headers: txHeaders,
|
||||
body,
|
||||
responseType: 'json'
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('TX Request Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
publicEncrypt,
|
||||
randomBytes,
|
||||
createHash,
|
||||
constants
|
||||
} from 'node:crypto'
|
||||
|
||||
// --- 加密逻辑 ---
|
||||
const eapiKey = 'e82ckenh8dichen8'
|
||||
|
||||
const aesEncrypt = (buffer: Buffer, mode: string, key: string | Buffer, iv: string | Buffer) => {
|
||||
const cipher = createCipheriv(mode, key, iv)
|
||||
return Buffer.concat([cipher.update(buffer), cipher.final()])
|
||||
}
|
||||
|
||||
export const eapi = (url: string, object: any) => {
|
||||
const text = typeof object === 'object' ? JSON.stringify(object) : object
|
||||
const message = `nobody${url}use${text}md5forencrypt`
|
||||
const digest = createHash('md5').update(message).digest('hex')
|
||||
const data = `${url}-36cd479b6b5-${text}-36cd479b6b5-${digest}`
|
||||
|
||||
return {
|
||||
params: aesEncrypt(Buffer.from(data), 'aes-128-ecb', eapiKey, '').toString('hex').toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
// --- 请求逻辑 ---
|
||||
|
||||
export const wyEapiRequest = async (url: string, data: any) => {
|
||||
const form = eapi(url, data)
|
||||
|
||||
const headers = {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
origin: 'https://music.163.com',
|
||||
// 使用 LX Music 的默认 Cookie 或者空,视情况而定。LX Music 代码中有注释掉的 cookie,
|
||||
// 但 eapi 接口通常需要 cookie 才能正常工作,或者特定的 header。
|
||||
// 这里先尝试不带 cookie 或带基本 cookie。
|
||||
// 注意:lx-music 的 request.js 中有默认 header。
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
|
||||
// const body = new URLSearchParams(form).toString() // eapi 返回的是 { params: '...' }
|
||||
|
||||
try {
|
||||
const response = await $fetch('http://interface.music.163.com/eapi/batch', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: new URLSearchParams(form as any).toString(), // $fetch 自动处理 body,但这里明确一下
|
||||
responseType: 'json'
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('WY Eapi Request Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createError } from 'h3'
|
||||
|
||||
export interface OAuthUserInfo {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
name?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface OAuthStrategy {
|
||||
/**
|
||||
* 获取授权跳转 URL
|
||||
*/
|
||||
getAuthorizeUrl(redirectUri: string, state: string): string
|
||||
|
||||
/**
|
||||
* 使用 code 换取 access_token
|
||||
*/
|
||||
exchangeToken(code: string, redirectUri: string): Promise<string>
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
getUserInfo(accessToken: string): Promise<OAuthUserInfo>
|
||||
}
|
||||
|
||||
// GitHub 策略实现
|
||||
const githubStrategy: OAuthStrategy = {
|
||||
getAuthorizeUrl(redirectUri: string, state: string) {
|
||||
const clientId = process.env.GITHUB_CLIENT_ID
|
||||
if (!clientId)
|
||||
throw createError({ statusCode: 500, message: 'GitHub Client ID not configured' })
|
||||
|
||||
return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${encodeURIComponent(state)}`
|
||||
},
|
||||
|
||||
async exchangeToken(code: string, redirectUri: string) {
|
||||
const clientId = process.env.GITHUB_CLIENT_ID
|
||||
const clientSecret = process.env.GITHUB_CLIENT_SECRET
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw createError({ statusCode: 500, message: 'GitHub config missing' })
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await $fetch<any>('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
redirect_uri: redirectUri
|
||||
},
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
|
||||
if (tokenResponse.error) {
|
||||
console.error('GitHub Token Error:', tokenResponse.error)
|
||||
throw new Error('授权失败,请重试')
|
||||
}
|
||||
|
||||
if (!tokenResponse.access_token) {
|
||||
console.error('GitHub Token Response missing access_token')
|
||||
throw new Error('未能获取访问令牌')
|
||||
}
|
||||
return tokenResponse.access_token
|
||||
} catch (e: any) {
|
||||
console.error('GitHub token exchange failed', e.message || e)
|
||||
const errorMessage = e.data?.error_description || e.message || '令牌请求失败'
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
},
|
||||
|
||||
async getUserInfo(accessToken: string) {
|
||||
try {
|
||||
const userInfo = await $fetch<any>('https://api.github.com/user', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
})
|
||||
|
||||
return {
|
||||
id: String(userInfo.id),
|
||||
username: userInfo.login,
|
||||
name: userInfo.name,
|
||||
email: userInfo.email,
|
||||
avatar: userInfo.avatar_url
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('GitHub user info failed', e)
|
||||
throw new Error('获取用户信息失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Casdoor 策略实现
|
||||
const casdoorStrategy: OAuthStrategy = {
|
||||
getAuthorizeUrl(redirectUri: string, state: string) {
|
||||
const clientId = process.env.CASDOOR_CLIENT_ID
|
||||
const endpoint = process.env.CASDOOR_ENDPOINT
|
||||
if (!clientId || !endpoint)
|
||||
throw createError({ statusCode: 500, message: 'Casdoor config missing' })
|
||||
|
||||
return `${endpoint}/login/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=read&state=${encodeURIComponent(state)}`
|
||||
},
|
||||
|
||||
async exchangeToken(code: string, redirectUri: string) {
|
||||
const clientId = process.env.CASDOOR_CLIENT_ID
|
||||
const clientSecret = process.env.CASDOOR_CLIENT_SECRET
|
||||
const endpoint = process.env.CASDOOR_ENDPOINT
|
||||
|
||||
if (!clientId || !clientSecret || !endpoint) {
|
||||
throw createError({ statusCode: 500, message: 'Casdoor config missing' })
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await $fetch<any>(`${endpoint}/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri
|
||||
},
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
|
||||
if (tokenResponse.error) {
|
||||
console.error('Casdoor Token Error:', tokenResponse.error, tokenResponse.error_description)
|
||||
throw new Error(tokenResponse.error_description || '授权失败,请重试')
|
||||
}
|
||||
|
||||
if (!tokenResponse.access_token) {
|
||||
console.error('Casdoor Token Response missing access_token')
|
||||
throw new Error('未能获取访问令牌,请检查 Casdoor 配置')
|
||||
}
|
||||
return tokenResponse.access_token
|
||||
} catch (e: any) {
|
||||
console.error('Casdoor token exchange failed', e.message || e)
|
||||
const errorMessage = e.data?.error_description || e.message || '令牌请求失败'
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
},
|
||||
|
||||
async getUserInfo(accessToken: string) {
|
||||
const endpoint = process.env.CASDOOR_ENDPOINT
|
||||
if (!endpoint) throw createError({ statusCode: 500, message: 'Casdoor config missing' })
|
||||
|
||||
let userInfo: any = {}
|
||||
try {
|
||||
// Casdoor 用户信息端点通常是 /api/get-account 或 /api/userinfo
|
||||
// 标准 OIDC 使用 /userinfo
|
||||
try {
|
||||
userInfo = await $fetch(`${endpoint}/api/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
})
|
||||
} catch (e) {
|
||||
// 尝试备用端点
|
||||
userInfo = await $fetch(`${endpoint}/api/get-account`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(userInfo.id || userInfo.sub),
|
||||
username: userInfo.name || userInfo.preferred_username || userInfo.username,
|
||||
name: userInfo.displayName || userInfo.name,
|
||||
email: userInfo.email,
|
||||
avatar: userInfo.avatar
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Casdoor user info failed', e)
|
||||
throw new Error('获取用户信息失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Google 策略实现
|
||||
const googleStrategy: OAuthStrategy = {
|
||||
getAuthorizeUrl(redirectUri: string, state: string) {
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID
|
||||
if (!clientId)
|
||||
throw createError({ statusCode: 500, message: 'Google Client ID not configured' })
|
||||
|
||||
return `https://accounts.google.com/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=openid%20email%20profile&state=${encodeURIComponent(state)}`
|
||||
},
|
||||
|
||||
async exchangeToken(code: string, redirectUri: string) {
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID
|
||||
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw createError({ statusCode: 500, message: 'Google config missing' })
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await $fetch<any>('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri
|
||||
},
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
|
||||
if (tokenResponse.error) {
|
||||
console.error('Google Token Error:', tokenResponse.error)
|
||||
throw new Error('授权失败,请重试')
|
||||
}
|
||||
|
||||
if (!tokenResponse.access_token) {
|
||||
console.error('Google Token Response missing access_token')
|
||||
throw new Error('未能获取访问令牌')
|
||||
}
|
||||
return tokenResponse.access_token
|
||||
} catch (e: any) {
|
||||
console.error('Google token exchange failed', e.message || e)
|
||||
const errorMessage = e.data?.error_description || e.message || '令牌请求失败'
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
},
|
||||
|
||||
async getUserInfo(accessToken: string) {
|
||||
try {
|
||||
const userInfo = await $fetch<any>('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
})
|
||||
|
||||
return {
|
||||
id: userInfo.sub,
|
||||
username: userInfo.email, // Google doesn't have a username, use email
|
||||
name: userInfo.name,
|
||||
email: userInfo.email,
|
||||
avatar: userInfo.picture
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Google user info failed', e)
|
||||
throw new Error('获取用户信息失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略注册表
|
||||
const strategies: Record<string, OAuthStrategy> = {
|
||||
github: githubStrategy,
|
||||
casdoor: casdoorStrategy,
|
||||
google: googleStrategy
|
||||
}
|
||||
|
||||
export const getOAuthStrategy = (provider: string): OAuthStrategy => {
|
||||
const strategy = strategies[provider]
|
||||
if (!strategy) {
|
||||
throw createError({ statusCode: 400, message: `Unknown provider: ${provider}` })
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is required')
|
||||
}
|
||||
|
||||
export interface BindingTokenPayload {
|
||||
provider: string
|
||||
providerUserId: string
|
||||
providerUsername?: string
|
||||
// 如果有可靠的未认证用户会话ID,可以在此添加 sessionId
|
||||
}
|
||||
|
||||
export const generateBindingToken = (payload: BindingTokenPayload): string => {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '10m' })
|
||||
}
|
||||
|
||||
export const verifyBindingToken = (token: string): BindingTokenPayload => {
|
||||
return jwt.verify(token, JWT_SECRET) as BindingTokenPayload
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
const SECRET_KEY = process.env.OAUTH_STATE_SECRET
|
||||
|
||||
if (!SECRET_KEY) {
|
||||
throw new Error('OAUTH_STATE_SECRET environment variable is required')
|
||||
}
|
||||
|
||||
export interface OAuthState {
|
||||
target: string
|
||||
csrf: string
|
||||
timestamp: number
|
||||
provider?: string
|
||||
}
|
||||
|
||||
// 生成 OAuth 状态参数
|
||||
export const generateState = (
|
||||
targetOrigin: string,
|
||||
provider?: string
|
||||
): { state: string; csrf: string } => {
|
||||
const csrf = randomBytes(32).toString('hex')
|
||||
const payload: OAuthState = {
|
||||
target: targetOrigin,
|
||||
csrf,
|
||||
timestamp: Date.now(),
|
||||
provider
|
||||
}
|
||||
const json = JSON.stringify(payload)
|
||||
const state = CryptoJS.AES.encrypt(json, SECRET_KEY).toString()
|
||||
return { state, csrf }
|
||||
}
|
||||
|
||||
// 解析 OAuth 状态参数
|
||||
export const parseState = (
|
||||
stateStr: string,
|
||||
expectedOrigin?: string,
|
||||
expectedCsrf?: string
|
||||
): OAuthState | null => {
|
||||
try {
|
||||
const bytes = CryptoJS.AES.decrypt(stateStr, SECRET_KEY)
|
||||
const json = bytes.toString(CryptoJS.enc.Utf8)
|
||||
if (!json) return null
|
||||
|
||||
const payload = JSON.parse(json)
|
||||
|
||||
// 检查时间戳是否过期(例如:10分钟)
|
||||
if (Date.now() - payload.timestamp > 10 * 60 * 1000) {
|
||||
console.error('OAuth state expired')
|
||||
return null
|
||||
}
|
||||
|
||||
// 验证 target origin
|
||||
if (expectedOrigin && payload.target !== expectedOrigin) {
|
||||
console.error('OAuth state target mismatch')
|
||||
return null
|
||||
}
|
||||
|
||||
// 验证 CSRF
|
||||
if (!expectedCsrf || payload.csrf !== expectedCsrf) {
|
||||
console.error('OAuth state CSRF mismatch or missing cookie')
|
||||
return null
|
||||
}
|
||||
|
||||
return payload
|
||||
} catch (e) {
|
||||
console.error('Failed to parse OAuth state', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const getRedirectUri = (provider: string): string => {
|
||||
let redirectUri = process.env.OAUTH_REDIRECT_URI
|
||||
if (!redirectUri) {
|
||||
throw createError({ statusCode: 500, message: 'OAUTH_REDIRECT_URI not configured' })
|
||||
}
|
||||
|
||||
// 支持 [provider] 占位符
|
||||
redirectUri = redirectUri.replace('[provider]', provider)
|
||||
|
||||
// 兼容用户可能错误地将 "provider" 作为字面量填写的情况
|
||||
if (redirectUri.includes('/provider/callback')) {
|
||||
redirectUri = redirectUri.replace('/provider/callback', `/${provider}/callback`)
|
||||
}
|
||||
|
||||
return redirectUri
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { cache } from './cache-helpers'
|
||||
import crypto from 'crypto'
|
||||
import { CACHE_CONSTANTS } from '../config/constants'
|
||||
|
||||
/**
|
||||
* 开放API缓存管理器
|
||||
* 复用现有的缓存管理机制,提供统一的缓存策略
|
||||
*/
|
||||
export class OpenApiCacheManager {
|
||||
private static readonly CACHE_PREFIX = 'open:'
|
||||
|
||||
/**
|
||||
* 生成缓存键
|
||||
* @param endpoint API端点名称
|
||||
* @param params 查询参数
|
||||
* @returns 缓存键
|
||||
*/
|
||||
static generateCacheKey(endpoint: string, params: Record<string, any>): string {
|
||||
// 过滤掉空值和undefined
|
||||
const filteredParams = Object.fromEntries(
|
||||
Object.entries(params).filter(
|
||||
([_, value]) => value !== '' && value !== null && value !== undefined
|
||||
)
|
||||
)
|
||||
|
||||
// 对参数进行排序以确保一致性
|
||||
const sortedParams = Object.keys(filteredParams)
|
||||
.sort()
|
||||
.reduce(
|
||||
(result, key) => {
|
||||
result[key] = filteredParams[key]
|
||||
return result
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
|
||||
const queryHash = crypto.createHash('md5').update(JSON.stringify(sortedParams)).digest('hex')
|
||||
|
||||
return `${this.CACHE_PREFIX}${endpoint}:${queryHash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存数据
|
||||
* @param cacheKey 缓存键
|
||||
* @returns 缓存的数据或null
|
||||
*/
|
||||
static async get<T>(cacheKey: string): Promise<T | null> {
|
||||
try {
|
||||
const cachedData = await cache.get<T>(cacheKey)
|
||||
if (cachedData !== null) {
|
||||
console.log(`[Open API Cache] 缓存命中: ${cacheKey}`)
|
||||
return cachedData
|
||||
}
|
||||
console.log(`[Open API Cache] 缓存未命中: ${cacheKey}`)
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error(`[Open API Cache] 获取缓存失败 ${cacheKey}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存数据
|
||||
* @param cacheKey 缓存键
|
||||
* @param data 要缓存的数据
|
||||
* @param ttl 缓存时间(秒),默认使用配置的默认值
|
||||
* @returns 是否设置成功
|
||||
*/
|
||||
static async set<T>(cacheKey: string, data: T, ttl?: number): Promise<boolean> {
|
||||
try {
|
||||
const cacheTtl = ttl || CACHE_CONSTANTS.DEFAULT_TTL
|
||||
const success = await cache.set(cacheKey, data, cacheTtl)
|
||||
if (success) {
|
||||
console.log(`[Open API Cache] 设置缓存成功: ${cacheKey}, TTL: ${cacheTtl}s`)
|
||||
} else {
|
||||
console.warn(`[Open API Cache] 设置缓存失败: ${cacheKey}`)
|
||||
}
|
||||
return success
|
||||
} catch (error) {
|
||||
console.error(`[Open API Cache] 设置缓存异常 ${cacheKey}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除特定缓存
|
||||
* @param cacheKey 缓存键
|
||||
* @returns 是否删除成功
|
||||
*/
|
||||
static async delete(cacheKey: string): Promise<boolean> {
|
||||
try {
|
||||
const success = await cache.delete(cacheKey)
|
||||
if (success) {
|
||||
console.log(`[Open API Cache] 删除缓存成功: ${cacheKey}`)
|
||||
}
|
||||
return success
|
||||
} catch (error) {
|
||||
console.error(`[Open API Cache] 删除缓存失败 ${cacheKey}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有开放API缓存
|
||||
* @returns 是否清除成功
|
||||
*/
|
||||
static async clearAll(): Promise<boolean> {
|
||||
try {
|
||||
const pattern = `${this.CACHE_PREFIX}*`
|
||||
const success = await cache.deletePattern(pattern)
|
||||
if (success) {
|
||||
console.log(`[Open API Cache] 清除所有缓存成功: ${pattern}`)
|
||||
}
|
||||
return success
|
||||
} catch (error) {
|
||||
console.error(`[Open API Cache] 清除所有缓存失败:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除特定端点的所有缓存
|
||||
* @param endpoint 端点名称
|
||||
* @returns 是否清除成功
|
||||
*/
|
||||
static async clearEndpoint(endpoint: string): Promise<boolean> {
|
||||
try {
|
||||
const pattern = `${this.CACHE_PREFIX}${endpoint}:*`
|
||||
const success = await cache.deletePattern(pattern)
|
||||
if (success) {
|
||||
console.log(`[Open API Cache] 清除端点缓存成功: ${pattern}`)
|
||||
}
|
||||
return success
|
||||
} catch (error) {
|
||||
console.error(`[Open API Cache] 清除端点缓存失败 ${endpoint}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或设置缓存(缓存穿透保护)
|
||||
* @param cacheKey 缓存键
|
||||
* @param loader 数据加载函数
|
||||
* @param ttl 缓存时间(秒)
|
||||
* @returns 数据
|
||||
*/
|
||||
static async getOrSet<T>(
|
||||
cacheKey: string,
|
||||
loader: () => Promise<T>,
|
||||
ttl?: number
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
// 先尝试获取缓存
|
||||
const cached = await this.get<T>(cacheKey)
|
||||
if (cached !== null) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 缓存未命中,执行loader函数
|
||||
console.log(`[Open API Cache] 执行数据加载: ${cacheKey}`)
|
||||
const data = await loader()
|
||||
|
||||
if (data !== null && data !== undefined) {
|
||||
// 异步设置缓存,不等待结果
|
||||
this.set(cacheKey, data, ttl).catch((error) => {
|
||||
console.error(`[Open API Cache] 异步设置缓存失败 ${cacheKey}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error(`[Open API Cache] getOrSet操作失败 ${cacheKey}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能响应体截断
|
||||
* 对大响应体进行智能截断,保留重要信息
|
||||
* @param responseBody 响应体
|
||||
* @param maxLength 最大长度
|
||||
* @returns 截断后的响应体
|
||||
*/
|
||||
static truncateResponseBody(
|
||||
responseBody: any,
|
||||
maxLength: number = CACHE_CONSTANTS.MAX_RESPONSE_BODY_LENGTH
|
||||
): string | null {
|
||||
if (!responseBody) return null
|
||||
|
||||
try {
|
||||
const jsonString = JSON.stringify(responseBody)
|
||||
|
||||
if (jsonString.length <= maxLength) {
|
||||
return jsonString
|
||||
}
|
||||
|
||||
// 智能截断:保留开头和结尾的重要信息
|
||||
const halfLength = Math.floor(maxLength / 2) - 50 // 预留空间给省略标记
|
||||
const start = jsonString.substring(0, halfLength)
|
||||
const end = jsonString.substring(jsonString.length - halfLength)
|
||||
|
||||
return `${start}... [截断 ${jsonString.length - maxLength} 字符] ...${end}`
|
||||
} catch (error) {
|
||||
console.error('[Open API Cache] 响应体截断失败:', error)
|
||||
return String(responseBody).substring(0, maxLength)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
* @returns 缓存统计
|
||||
*/
|
||||
static async getStats(): Promise<{
|
||||
totalKeys: number
|
||||
endpoints: string[]
|
||||
}> {
|
||||
try {
|
||||
// 这里可以扩展实现更详细的统计信息
|
||||
// 目前返回基本信息
|
||||
return {
|
||||
totalKeys: 0, // 需要Redis支持来获取准确数量
|
||||
endpoints: ['songs', 'schedules'] // 当前支持的端点
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Open API Cache] 获取统计信息失败:', error)
|
||||
return {
|
||||
totalKeys: 0,
|
||||
endpoints: []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出便捷方法
|
||||
export const openApiCache = {
|
||||
generateKey: OpenApiCacheManager.generateCacheKey,
|
||||
get: OpenApiCacheManager.get,
|
||||
set: OpenApiCacheManager.set,
|
||||
delete: OpenApiCacheManager.delete,
|
||||
clearAll: OpenApiCacheManager.clearAll,
|
||||
clearEndpoint: OpenApiCacheManager.clearEndpoint,
|
||||
getOrSet: OpenApiCacheManager.getOrSet,
|
||||
truncateResponseBody: OpenApiCacheManager.truncateResponseBody,
|
||||
getStats: OpenApiCacheManager.getStats
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 硬编码的四个角色配置
|
||||
const ROLES = {
|
||||
USER: {
|
||||
displayName: '普通用户',
|
||||
description: '普通用户,只能访问主页功能',
|
||||
pages: [], // 不能访问后台页面
|
||||
canAccessAdmin: false
|
||||
},
|
||||
SONG_ADMIN: {
|
||||
displayName: '歌曲管理员',
|
||||
description: '负责歌曲和排期管理',
|
||||
pages: [
|
||||
'schedule', // 排期管理
|
||||
'print', // 打印排期
|
||||
'songs' // 歌曲管理
|
||||
],
|
||||
canAccessAdmin: true
|
||||
},
|
||||
ADMIN: {
|
||||
displayName: '管理员',
|
||||
description: '系统管理员,拥有大部分权限',
|
||||
pages: [
|
||||
'overview', // 数据概览
|
||||
'schedule', // 排期管理
|
||||
'print', // 打印排期
|
||||
'songs', // 歌曲管理
|
||||
'users', // 用户管理
|
||||
'data-analysis' // 数据分析
|
||||
],
|
||||
canAccessAdmin: true
|
||||
},
|
||||
SUPER_ADMIN: {
|
||||
displayName: '超级管理员',
|
||||
description: '拥有所有权限的超级管理员',
|
||||
pages: [
|
||||
'overview', // 数据概览
|
||||
'schedule', // 排期管理
|
||||
'print', // 打印排期
|
||||
'songs', // 歌曲管理
|
||||
'users', // 用户管理
|
||||
'notifications', // 通知管理
|
||||
'playtimes', // 播出时段
|
||||
'semesters', // 学期管理
|
||||
'blacklist', // 黑名单管理
|
||||
'site-config', // 站点配置
|
||||
'database', // 数据库操作
|
||||
'data-analysis' // 数据分析
|
||||
],
|
||||
canAccessAdmin: true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户是否可以访问后台
|
||||
function canAccessAdmin(user) {
|
||||
if (!user || !user.role) return false
|
||||
|
||||
const roleConfig = ROLES[user.role]
|
||||
if (!roleConfig) return false
|
||||
|
||||
return roleConfig.canAccessAdmin
|
||||
}
|
||||
|
||||
// 检查用户是否可以访问指定页面
|
||||
function canAccessPage(user, page) {
|
||||
if (!user || !user.role) return false
|
||||
|
||||
// 超级管理员可以访问所有页面
|
||||
if (user.role === 'SUPER_ADMIN') return true
|
||||
|
||||
const roleConfig = ROLES[user.role]
|
||||
if (!roleConfig) return false
|
||||
|
||||
return roleConfig.pages.includes(page)
|
||||
}
|
||||
|
||||
// 获取用户可访问的页面列表
|
||||
function getUserPages(user) {
|
||||
if (!user || !user.role) return []
|
||||
|
||||
const roleConfig = ROLES[user.role]
|
||||
if (!roleConfig) return []
|
||||
|
||||
return roleConfig.pages
|
||||
}
|
||||
|
||||
// 获取角色信息
|
||||
function getRoleInfo(roleName) {
|
||||
return ROLES[roleName] || null
|
||||
}
|
||||
|
||||
// 检查用户是否可以管理其他用户角色
|
||||
function canManageUserRole(currentUser, targetRole) {
|
||||
if (!currentUser || !currentUser.role) return false
|
||||
|
||||
// 只有超级管理员可以分配角色
|
||||
if (currentUser.role === 'SUPER_ADMIN') {
|
||||
// 超级管理员可以分配除自己以外的所有角色
|
||||
return ['USER', 'SONG_ADMIN', 'ADMIN'].includes(targetRole)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 导出所有函数和常量
|
||||
export { ROLES, canAccessAdmin, canAccessPage, getUserPages, getRoleInfo, canManageUserRole }
|
||||
@@ -0,0 +1,287 @@
|
||||
import type { RedisClientOptions, RedisClientType } from 'redis'
|
||||
import { createClient } from 'redis'
|
||||
|
||||
// Redis连接池管理类
|
||||
class RedisPool {
|
||||
private client: RedisClientType | null = null
|
||||
private isConnected = false
|
||||
private isConnecting = false
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private reconnectDelay = 1000 // 1秒
|
||||
private healthCheckInterval: NodeJS.Timeout | null = null
|
||||
private lastHealthCheck = 0
|
||||
private connectionStats = {
|
||||
totalConnections: 0,
|
||||
failedConnections: 0,
|
||||
lastConnectedAt: null as Date | null,
|
||||
lastFailedAt: null as Date | null
|
||||
}
|
||||
|
||||
// Redis配置
|
||||
private config: RedisClientOptions = {
|
||||
url: process.env.REDIS_URL,
|
||||
socket: {
|
||||
connectTimeout: 10000, // 10秒连接超时
|
||||
lazyConnect: true, // 延迟连接
|
||||
reconnectStrategy: (retries) => {
|
||||
if (retries >= this.maxReconnectAttempts) {
|
||||
console.error('[Redis] 达到最大重连次数,停止重连')
|
||||
return false
|
||||
}
|
||||
const delay = Math.min(this.reconnectDelay * Math.pow(2, retries), 30000)
|
||||
console.log(`[Redis] 第${retries + 1}次重连,延迟${delay}ms`)
|
||||
return delay
|
||||
}
|
||||
},
|
||||
// 启用离线队列,在断线时缓存命令
|
||||
enableOfflineQueue: false,
|
||||
// 确保使用UTF-8编码
|
||||
encoding: 'utf8'
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// 检查是否启用Redis
|
||||
if (!this.isRedisEnabled()) {
|
||||
console.log('[Redis] Redis未配置或已禁用,将使用数据库直接查询')
|
||||
return
|
||||
}
|
||||
|
||||
this.initializeClient()
|
||||
}
|
||||
|
||||
// 连接到Redis
|
||||
async connect(): Promise<boolean> {
|
||||
if (!this.isRedisEnabled() || !this.client) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.isConnected) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.isConnecting) {
|
||||
// 等待连接完成
|
||||
return new Promise((resolve) => {
|
||||
const checkConnection = () => {
|
||||
if (this.isConnected) {
|
||||
resolve(true)
|
||||
} else if (!this.isConnecting) {
|
||||
resolve(false)
|
||||
} else {
|
||||
setTimeout(checkConnection, 100)
|
||||
}
|
||||
}
|
||||
checkConnection()
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
this.isConnecting = true
|
||||
await this.client.connect()
|
||||
return this.isConnected
|
||||
} catch (error) {
|
||||
console.error('[Redis] 连接失败:', error)
|
||||
this.isConnecting = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client && this.isConnected) {
|
||||
try {
|
||||
this.stopHealthCheck()
|
||||
await this.client.quit()
|
||||
} catch (error) {
|
||||
console.error('[Redis] 断开连接时出错:', error)
|
||||
// 强制断开
|
||||
await this.client.disconnect()
|
||||
}
|
||||
}
|
||||
this.isConnected = false
|
||||
this.isConnecting = false
|
||||
}
|
||||
|
||||
// 获取Redis客户端
|
||||
getClient(): RedisClientType | null {
|
||||
return this.isConnected ? this.client : null
|
||||
}
|
||||
|
||||
// 检查连接状态
|
||||
isReady(): boolean {
|
||||
return this.isConnected && !!this.client
|
||||
}
|
||||
|
||||
// 获取连接统计信息
|
||||
getStats() {
|
||||
return {
|
||||
isConnected: this.isConnected,
|
||||
isConnecting: this.isConnecting,
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
lastHealthCheck: this.lastHealthCheck,
|
||||
...this.connectionStats,
|
||||
redisEnabled: this.isRedisEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
// 执行Redis命令(带错误处理)
|
||||
async executeCommand<T>(
|
||||
command: () => Promise<T>,
|
||||
fallback?: () => Promise<T>
|
||||
): Promise<T | null> {
|
||||
if (!this.isReady()) {
|
||||
if (fallback) {
|
||||
console.log('[Redis] Redis不可用,使用fallback')
|
||||
return await fallback()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return await command()
|
||||
} catch (error) {
|
||||
console.error('[Redis] 命令执行失败:', error)
|
||||
this.isConnected = false
|
||||
|
||||
if (fallback) {
|
||||
console.log('[Redis] 命令失败,使用fallback')
|
||||
return await fallback()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 检查Redis是否启用
|
||||
private isRedisEnabled(): boolean {
|
||||
return !!(process.env.REDIS_URL && process.env.REDIS_URL.trim())
|
||||
}
|
||||
|
||||
// 初始化Redis客户端
|
||||
private initializeClient() {
|
||||
try {
|
||||
this.client = createClient(this.config)
|
||||
this.setupEventHandlers()
|
||||
} catch (error) {
|
||||
console.error('[Redis] 初始化客户端失败:', error)
|
||||
this.client = null
|
||||
}
|
||||
}
|
||||
|
||||
// 设置事件处理器
|
||||
private setupEventHandlers() {
|
||||
if (!this.client) return
|
||||
|
||||
this.client.on('connect', () => {
|
||||
console.log('[Redis] 连接建立')
|
||||
this.isConnected = true
|
||||
this.isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
this.connectionStats.totalConnections++
|
||||
this.connectionStats.lastConnectedAt = new Date()
|
||||
})
|
||||
|
||||
this.client.on('ready', () => {
|
||||
console.log('[Redis] 客户端就绪')
|
||||
this.startHealthCheck()
|
||||
})
|
||||
|
||||
this.client.on('error', (error) => {
|
||||
console.error('[Redis] 连接错误:', error.message)
|
||||
this.isConnected = false
|
||||
this.connectionStats.failedConnections++
|
||||
this.connectionStats.lastFailedAt = new Date()
|
||||
})
|
||||
|
||||
this.client.on('end', () => {
|
||||
console.log('[Redis] 连接关闭')
|
||||
this.isConnected = false
|
||||
this.stopHealthCheck()
|
||||
})
|
||||
|
||||
this.client.on('reconnecting', () => {
|
||||
console.log('[Redis] 正在重连...')
|
||||
this.isConnecting = true
|
||||
this.reconnectAttempts++
|
||||
})
|
||||
}
|
||||
|
||||
// 健康检查
|
||||
private startHealthCheck() {
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval)
|
||||
}
|
||||
|
||||
this.healthCheckInterval = setInterval(async () => {
|
||||
await this.performHealthCheck()
|
||||
}, 30000) // 每30秒检查一次
|
||||
}
|
||||
|
||||
private stopHealthCheck() {
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval)
|
||||
this.healthCheckInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// 执行健康检查
|
||||
private async performHealthCheck(): Promise<boolean> {
|
||||
if (!this.client || !this.isConnected) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const start = Date.now()
|
||||
await this.client.ping()
|
||||
const responseTime = Date.now() - start
|
||||
this.lastHealthCheck = Date.now()
|
||||
|
||||
if (responseTime > 5000) {
|
||||
console.warn(`[Redis] 健康检查响应时间较慢: ${responseTime}ms`)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[Redis] 健康检查失败:', error)
|
||||
this.isConnected = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局Redis池实例
|
||||
const redisPool = new RedisPool()
|
||||
|
||||
// 导出Redis池和便捷方法
|
||||
export { redisPool }
|
||||
|
||||
// 便捷方法
|
||||
export const connectRedis = () => redisPool.connect()
|
||||
export const disconnectRedis = () => redisPool.disconnect()
|
||||
export const getRedisClient = () => redisPool.getClient()
|
||||
export const isRedisReady = () => redisPool.isReady()
|
||||
export const getRedisStats = () => redisPool.getStats()
|
||||
export const executeRedisCommand = <T>(command: () => Promise<T>, fallback?: () => Promise<T>) =>
|
||||
redisPool.executeCommand(command, fallback)
|
||||
|
||||
// 在应用启动时自动连接
|
||||
if (process.env.REDIS_URL) {
|
||||
redisPool.connect().then((connected) => {
|
||||
if (connected) {
|
||||
console.log('[Redis] 自动连接成功')
|
||||
} else {
|
||||
console.log('[Redis] 自动连接失败,将使用数据库直接查询')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 优雅关闭处理
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('[Redis] 收到SIGINT信号,正在关闭Redis连接...')
|
||||
await redisPool.disconnect()
|
||||
})
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('[Redis] 收到SIGTERM信号,正在关闭Redis连接...')
|
||||
await redisPool.disconnect()
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { systemSettings } from '~/drizzle/schema'
|
||||
|
||||
/**
|
||||
* 获取站点标题
|
||||
*/
|
||||
export async function getSiteTitle(): Promise<string> {
|
||||
try {
|
||||
const settingsResult = await db.select().from(systemSettings).limit(1)
|
||||
const settings = settingsResult[0]
|
||||
return settings?.siteTitle || process.env.NUXT_PUBLIC_SITE_TITLE || 'VoiceHub'
|
||||
} catch (error) {
|
||||
console.error('获取站点标题失败:', error)
|
||||
return 'VoiceHub'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 姓名脱敏函数,统一返回三个星号
|
||||
* @param name 原始姓名
|
||||
* @returns 脱敏后的姓名
|
||||
*/
|
||||
export function maskStudentName(name: string): string {
|
||||
if (!name) return name
|
||||
return '***'
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { maskStudentName } from './siteUtils'
|
||||
|
||||
/**
|
||||
* 可脱敏的用户/参与者接口
|
||||
*/
|
||||
export interface MaskableUser {
|
||||
id?: number | string
|
||||
name?: string
|
||||
displayName?: string
|
||||
grade?: string | null
|
||||
class?: string | null
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 可脱敏的歌曲信息接口
|
||||
*/
|
||||
export interface MaskableSong {
|
||||
requester?: string
|
||||
requesterGrade?: string | null
|
||||
requesterClass?: string | null
|
||||
collaborators?: MaskableUser[]
|
||||
replayRequesters?: MaskableUser[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个参与者(联合投稿人、重播申请人)进行脱敏
|
||||
* @param user 参与者对象
|
||||
*/
|
||||
export function maskUserInfo(user: MaskableUser) {
|
||||
if (user.name) user.name = maskStudentName(user.name)
|
||||
if (user.displayName) user.displayName = maskStudentName(user.displayName)
|
||||
user.grade = null
|
||||
user.class = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单首歌曲的投稿相关信息进行脱敏
|
||||
* @param song 歌曲对象
|
||||
*/
|
||||
export function maskSongInfo(song: MaskableSong) {
|
||||
// 脱敏主投稿人姓名
|
||||
if (song.requester) {
|
||||
song.requester = maskStudentName(song.requester)
|
||||
}
|
||||
|
||||
// 隐藏主投稿人年级和班级
|
||||
if (song.requesterGrade !== undefined) song.requesterGrade = null
|
||||
if (song.requesterClass !== undefined) song.requesterClass = null
|
||||
|
||||
// 批量脱敏联合投稿人
|
||||
if (song.collaborators && Array.isArray(song.collaborators)) {
|
||||
song.collaborators.forEach(maskUserInfo)
|
||||
}
|
||||
|
||||
// 批量脱敏重播申请人
|
||||
if (song.replayRequesters && Array.isArray(song.replayRequesters)) {
|
||||
song.replayRequesters.forEach(maskUserInfo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量脱敏歌曲列表(通常用于 index.get.ts)
|
||||
* @param songs 歌曲列表
|
||||
*/
|
||||
export function maskSongsInfo(songs: MaskableSong[]) {
|
||||
songs.forEach(maskSongInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共排期项接口
|
||||
*/
|
||||
export interface PublicScheduleItem {
|
||||
song?: MaskableSong
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量脱敏公共排期数据(通常用于 public.get.ts)
|
||||
* @param data 排期数据列表
|
||||
*/
|
||||
export function maskPublicScheduleData(data: PublicScheduleItem[]) {
|
||||
data.forEach(item => {
|
||||
if (item.song) {
|
||||
maskSongInfo(item.song)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { db, songs } from '~/drizzle/db'
|
||||
import { and, eq, gte, lte, count } from 'drizzle-orm'
|
||||
import {
|
||||
getBeijingEndOfDay,
|
||||
getBeijingEndOfWeek,
|
||||
getBeijingEndOfMonth,
|
||||
getBeijingStartOfDay,
|
||||
getBeijingStartOfWeek,
|
||||
getBeijingStartOfMonth
|
||||
} from '~/utils/timeUtils'
|
||||
|
||||
export type LimitType = 'daily' | 'weekly' | 'monthly'
|
||||
|
||||
/**
|
||||
* 获取指定限额类型的时间窗口(北京时间)
|
||||
* @param type 限额类型
|
||||
* @returns { start: Date, end: Date }
|
||||
*/
|
||||
export function getTimeRange(type: LimitType) {
|
||||
switch (type) {
|
||||
case 'weekly':
|
||||
return {
|
||||
start: getBeijingStartOfWeek(),
|
||||
end: getBeijingEndOfWeek()
|
||||
}
|
||||
case 'monthly':
|
||||
return {
|
||||
start: getBeijingStartOfMonth(),
|
||||
end: getBeijingEndOfMonth()
|
||||
}
|
||||
case 'daily':
|
||||
default:
|
||||
// 'daily' 是默认情况,为保证健壮性处理任何意外值。
|
||||
return {
|
||||
start: getBeijingStartOfDay(),
|
||||
end: getBeijingEndOfDay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否达到投稿限额
|
||||
* @param dbOrTx 数据库实例或事务
|
||||
* @param userId 用户ID
|
||||
* @param type 限额类型
|
||||
* @param limit 限额数量
|
||||
* @returns boolean true 表示已达到限额(不允许继续投稿),false 表示未达到
|
||||
*/
|
||||
export async function isLimitReached(
|
||||
dbOrTx: typeof db,
|
||||
userId: number,
|
||||
type: LimitType,
|
||||
limit: number
|
||||
): Promise<boolean> {
|
||||
const { start, end } = getTimeRange(type)
|
||||
|
||||
const [result] = await dbOrTx
|
||||
.select({ count: count() })
|
||||
.from(songs)
|
||||
.where(
|
||||
and(
|
||||
eq(songs.requesterId, userId),
|
||||
gte(songs.createdAt, start),
|
||||
lte(songs.createdAt, end)
|
||||
)
|
||||
)
|
||||
|
||||
return result.count >= limit
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export const twoFactorCodes = new Map<number, {
|
||||
code: string;
|
||||
expiresAt: number;
|
||||
attempts: number
|
||||
}>()
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 获取用户状态的中文显示文本
|
||||
* @param status 用户状态枚举值
|
||||
* @returns 中文显示文本
|
||||
*/
|
||||
export function getStatusText(status: string | null | undefined): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
active: '正常',
|
||||
withdrawn: '退学',
|
||||
graduate: '毕业生',
|
||||
};
|
||||
return status ? (statusMap[status] || status) : '未知';
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { H3Event, getRequestHeader, getRequestHost, getRequestProtocol, createError } from 'h3'
|
||||
|
||||
function parseAllowedOrigins(): string[] {
|
||||
const env = process.env.WEBAUTHN_ORIGIN
|
||||
if (!env) return []
|
||||
return env.split(',').map(o => o.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function isOriginAllowed(origin: string, allowedOrigins: string[]): boolean {
|
||||
if (allowedOrigins.length === 0) return true
|
||||
return allowedOrigins.includes(origin)
|
||||
}
|
||||
|
||||
export function getWebAuthnConfig(event: H3Event) {
|
||||
const host = getRequestHost(event)
|
||||
const protocol = getRequestProtocol(event, { xForwardedProto: true })
|
||||
const allowedOrigins = parseAllowedOrigins()
|
||||
|
||||
let rpID = process.env.WEBAUTHN_RP_ID
|
||||
|
||||
if (!rpID) {
|
||||
rpID = host.split(':')[0]
|
||||
}
|
||||
|
||||
let origin: string
|
||||
const requestOrigin = getRequestHeader(event, 'origin') || `${protocol}://${host}`
|
||||
|
||||
if (process.env.WEBAUTHN_ORIGIN) {
|
||||
if (isOriginAllowed(requestOrigin, allowedOrigins)) {
|
||||
origin = requestOrigin
|
||||
} else {
|
||||
console.error(`WebAuthn origin 不在允许列表中: ${requestOrigin}`)
|
||||
throw createError({ statusCode: 403, message: `不被允许的 Origin: ${requestOrigin}` })
|
||||
}
|
||||
} else {
|
||||
origin = requestOrigin
|
||||
}
|
||||
|
||||
return { rpID, origin, allowedOrigins }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getCookie, setCookie, deleteCookie, type H3Event } from 'h3'
|
||||
import { JWTEnhanced } from './jwt-enhanced'
|
||||
|
||||
const COOKIE_NAME = 'webauthn_challenge'
|
||||
|
||||
interface WebAuthnChallengePayload {
|
||||
challenge: string
|
||||
userId: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export function setWebAuthnChallenge(event: H3Event, challenge: string, userId: string) {
|
||||
const token = JWTEnhanced.sign({
|
||||
challenge,
|
||||
userId,
|
||||
type: 'webauthn_challenge'
|
||||
}, { expiresIn: '3m' })
|
||||
|
||||
setCookie(event, COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 180,
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
export function getWebAuthnChallenge(event: H3Event): { challenge: string; userId: string } | null {
|
||||
const token = getCookie(event, COOKIE_NAME)
|
||||
if (!token) return null
|
||||
|
||||
try {
|
||||
const payload = JWTEnhanced.verify(token) as WebAuthnChallengePayload
|
||||
if (payload.type !== 'webauthn_challenge') return null
|
||||
return { challenge: payload.challenge, userId: payload.userId }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWebAuthnChallenge(event: H3Event) {
|
||||
deleteCookie(event, COOKIE_NAME)
|
||||
}
|
||||
Reference in New Issue
Block a user