初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+256
View File
@@ -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: '权限验证失败'
}
}
}
+399
View File
@@ -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()
+61
View File
@@ -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
}
}
}
+97
View File
@@ -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
}
+196
View File
@@ -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
+395
View File
@@ -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> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&apos;': "'",
'&#039;': "'"
}
return str.replace(/&amp;|&lt;|&gt;|&quot;|&apos;|&#039;/g, (match) => entities[match] || match)
}
+69
View File
@@ -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
}
}
+59
View File
@@ -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
}
+22
View File
@@ -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
}
+87
View File
@@ -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
}
+105
View File
@@ -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 }
+287
View File
@@ -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()
})
+26
View File
@@ -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 '***'
}
+89
View File
@@ -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
}>()
+13
View File
@@ -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)
}