feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '~/utils/password-policy'
interface AdminPasswordViolation {
code: string
message: string
}
/**
* 管理员重置/设置临时密码的基础校验。
* 临时密码配合 forceReset 强制用户登录后自行修改,故不要求完整复杂度,
* 但仍需拦截空密码、过短易爆破密码、超长密码及超出 bcrypt 72 字节有效范围的输入。
*/
export function getAdminPasswordViolation(password: unknown): AdminPasswordViolation | null {
if (typeof password !== 'string' || !password) {
return { code: 'AUTH_NEW_PASSWORD_REQUIRED', message: '新密码不能为空' }
}
if (password.length < PASSWORD_MIN_LENGTH) {
return {
code: 'AUTH_PASSWORD_TOO_SHORT',
message: `密码长度不能少于${PASSWORD_MIN_LENGTH}个字符`
}
}
if (password.length > PASSWORD_MAX_LENGTH) {
return {
code: 'AUTH_PASSWORD_TOO_LONG',
message: `密码长度不能超过${PASSWORD_MAX_LENGTH}位`
}
}
if (new TextEncoder().encode(password).length > 72) {
return {
code: 'AUTH_PASSWORD_TOO_MANY_BYTES',
message: '密码有效长度不能超过72字节'
}
}
return null
}
+33
View File
@@ -0,0 +1,33 @@
import { createError } from 'h3'
/**
* 创建带「机器可读错误码」的 API 错误。
*
* 统一了此前分散在各处的 `createCardCodeError` 形态:
* - `statusMessage` 存放稳定错误码(如 `CARD_CODE_INVALID_OR_USED`),
* - `data.code` 同步携带该码,供客户端按 code 本地化,
* - `message` 为默认文案:本项目服务端统一以简体中文书写,仅用于服务端日志,
* 以及客户端在 `serverErrors` 词典取不到对应 code 时的兜底展示。
*
* 语言解耦以 `code` 为准:客户端 `useServerErrors().localize(err)` 优先按 `code` 查
* `serverErrors` 词典(含中英文),未命中才回退到 `message`。因此界面显示的语言由词典决定,
* `message` 本身用哪种语言不影响多语言展示。
*
* @param statusCode HTTP 状态码
* @param code 稳定错误码(建议取自 SERVER_ERROR_CODES)
* @param message 默认文案(简体中文;服务端日志与客户端兜底用)
* @param data 附加数据;`params` 数组会在客户端作为占位符 `{0}`、`{1}` 的替换值
*/
export function createApiError(
statusCode: number,
code: string,
message: string,
data?: Record<string, unknown>
) {
return createError({
statusCode,
statusMessage: code,
message,
data: { code, ...(data || {}) }
})
}
+62
View File
@@ -0,0 +1,62 @@
import crypto from 'crypto'
import { promisify } from 'util'
const API_KEY_PREFIX = 'vhub_'
const API_KEY_BYTES = 16
const HASH_SALT_BYTES = 16
const HASH_DERIVED_LENGTH = 64
const scryptAsync = promisify(crypto.scrypt)
export function generateApiKey(): string {
const randomBytes = crypto.randomBytes(API_KEY_BYTES).toString('hex')
return API_KEY_PREFIX + randomBytes
}
export async function hashApiKey(apiKey: string): Promise<string> {
const salt = crypto.randomBytes(HASH_SALT_BYTES).toString('hex')
const derivedKey = toHex(await scryptAsync(apiKey, salt, HASH_DERIVED_LENGTH))
return `${salt}:${derivedKey}`
}
export async function verifyApiKey(apiKey: string, storedHash: string): Promise<boolean> {
if (!apiKey || !storedHash) {
return false
}
const parts = storedHash.split(':')
if (parts.length !== 2) {
return false
}
const [salt, expectedDerivedKey] = parts
if (!salt || !expectedDerivedKey) {
return false
}
const derivedKey = toHex(await scryptAsync(apiKey, salt, HASH_DERIVED_LENGTH))
return safeEqualHex(derivedKey, expectedDerivedKey)
}
function toHex(value: unknown): string {
if (Buffer.isBuffer(value)) {
return value.toString('hex')
}
if (value instanceof Uint8Array) {
return Buffer.from(value).toString('hex')
}
return Buffer.from(String(value)).toString('hex')
}
function safeEqualHex(left: string, right: string): boolean {
if (left.length !== right.length) {
return false
}
try {
return crypto.timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'))
} catch {
return false
}
}
+101
View File
@@ -0,0 +1,101 @@
type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
interface RoutePolicy {
path: string
methods: readonly HttpMethod[]
}
const GET_METHODS = ['GET', 'HEAD'] as const
const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'] as const
export const PASSWORD_CHANGE_ALLOWED_ROUTES: readonly RoutePolicy[] = [
{ path: '/api/auth/verify', methods: GET_METHODS },
{ path: '/api/auth/logout', methods: ['POST'] },
{ path: '/api/auth/change-password', methods: ['POST'] },
{ path: '/api/auth/set-initial-password', methods: ['POST'] },
{ path: '/api/auth/2fa/verify', methods: ['POST'] },
{ path: '/api/auth/2fa/send-email', methods: ['POST'] },
{ path: '/api/site-config', methods: GET_METHODS }
]
export const PUBLIC_API_EXACT_ROUTES: readonly RoutePolicy[] = [
{ path: '/api/auth/login', methods: ['POST'] },
{ path: '/api/auth/register', methods: ['POST'] },
{ path: '/api/auth/email-code', methods: ['POST'] },
{ path: '/api/auth/grade-class-options', methods: GET_METHODS },
// 携带 binding-token(而非登录态 token)访问,必须放行给接口自身校验
{ path: '/api/auth/oauth-register-options', methods: GET_METHODS },
{ path: '/api/auth/bind', methods: ['POST'] },
{ path: '/api/auth/oauth-register', methods: ['POST'] },
{ path: '/api/auth/2fa/verify', methods: ['POST'] },
{ path: '/api/auth/2fa/send-email', methods: ['POST'] },
{ path: '/api/auth/forgot-password', methods: ['POST'] },
{ path: '/api/auth/reset-password', methods: ['POST'] },
{ path: '/api/auth/captcha', methods: GET_METHODS },
{ path: '/api/semesters/current', methods: GET_METHODS },
{ path: '/api/play-times', methods: GET_METHODS },
{ path: '/api/schedules/public', methods: GET_METHODS },
{ path: '/api/songs/count', methods: GET_METHODS },
{ path: '/api/songs/public', methods: GET_METHODS },
{ path: '/api/site-config', methods: GET_METHODS },
{ path: '/api/system/location', methods: GET_METHODS },
{ path: '/api/auth/webauthn/login', methods: ['POST'] },
{ path: '/api/music/resolve-url', methods: ['POST'] },
{ path: '/api/music/state', methods: ['POST'] },
{ path: '/api/music/websocket', methods: GET_METHODS },
{ path: '/api/sys/time', methods: GET_METHODS }
]
export const PUBLIC_API_PREFIX_ROUTES: readonly RoutePolicy[] = [
{ path: '/api/proxy/', methods: GET_METHODS },
{ path: '/api/bilibili/', methods: GET_METHODS },
{ path: '/api/api-enhanced/', methods: [...GET_METHODS, ...WRITE_METHODS] },
{ path: '/api/native-api/', methods: [...GET_METHODS, 'POST'] },
{ path: '/api/open/', methods: [...GET_METHODS, ...WRITE_METHODS] },
{ path: '/api/auth/webauthn/login/', methods: ['POST'] }
]
function matchesMethod(policy: RoutePolicy, method: string): boolean {
return policy.methods.includes(method.toUpperCase() as HttpMethod)
}
export function isPublicApiPath(pathname: string, method: string): boolean {
return (
PUBLIC_API_EXACT_ROUTES.some(
(policy) => pathname === policy.path && matchesMethod(policy, method)
) ||
PUBLIC_API_PREFIX_ROUTES.some(
(policy) => pathname.startsWith(policy.path) && matchesMethod(policy, method)
)
)
}
export function isAllowedDuringPasswordChange(pathname: string, method: string): boolean {
return PASSWORD_CHANGE_ALLOWED_ROUTES.some(
(policy) => pathname === policy.path && matchesMethod(policy, method)
)
}
export function shouldBlockDuringPasswordChange(
pathname: string,
method: string,
requirePasswordChange: boolean
): boolean {
return (
requirePasswordChange &&
!isPublicApiPath(pathname, method) &&
!isAllowedDuringPasswordChange(pathname, method)
)
}
export function shouldBypassPublicApiAuthentication(
pathname: string,
method: string,
hasToken: boolean
): boolean {
return isPublicApiPath(pathname, method) && !hasToken
}
export function canBindOAuthIdentity(requirePasswordChange: boolean): boolean {
return !requirePasswordChange
}
+161
View File
@@ -0,0 +1,161 @@
import { getHeader } from 'h3'
import { randomUUID } from 'node:crypto'
import { authSessions, db, eq } from '~/drizzle/db'
import { and, isNull, ne } from 'drizzle-orm'
import type { H3Event } from 'h3'
import { getClientIP } from '~~/server/utils/ip-utils'
import { getServerDate } from '~~/server/utils/serverTime'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
export const AUTH_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
function getSessionExpiry() {
const expiresAt = getServerDate()
expiresAt.setTime(expiresAt.getTime() + AUTH_SESSION_MAX_AGE_SECONDS * 1000)
return expiresAt
}
function getDateFromTimestamp(timestamp: number) {
const date = getServerDate()
date.setTime(timestamp)
return date
}
type SessionUser = { id: number; role: string; tokenVersion?: number }
/**
* 判断登录会话存储是否因迁移缺失而不可用。
* 会话表不可用时禁止签发或接受无法撤销的登录令牌。
*/
export function isAuthSessionStorageError(error: unknown) {
const value = error as { code?: string; message?: string; cause?: { code?: string; message?: string } } | null
const code = String(value?.code || value?.cause?.code || '')
const message = String(value?.message || value?.cause?.message || error || '')
return ['42P01', '42703'].includes(code) && /auth_sessions/i.test(message)
}
function readUserAgent(event: H3Event) {
return (getHeader(event, 'user-agent') || '').slice(0, 500)
}
export function parseUserAgent(userAgent: string) {
const browser = /Edg\/?([\d.]+)/i.test(userAgent)
? 'Edge'
: /OPR\/?([\d.]+)/i.test(userAgent)
? 'Opera'
: /Chrome\/?([\d.]+)/i.test(userAgent)
? 'Chrome'
: /Firefox\/?([\d.]+)/i.test(userAgent)
? 'Firefox'
: /Safari\/?([\d.]+)/i.test(userAgent)
? 'Safari'
: 'Unknown browser'
const operatingSystem = /Windows NT/i.test(userAgent)
? 'Windows'
: /Mac OS X/i.test(userAgent)
? 'macOS'
: /Android/i.test(userAgent)
? 'Android'
: /iPhone|iPad|iPod/i.test(userAgent)
? 'iOS'
: /Linux/i.test(userAgent)
? 'Linux'
: 'Unknown OS'
const device = /iPad|Tablet/i.test(userAgent)
? 'Tablet'
: /Mobile|iPhone|Android/i.test(userAgent)
? 'Mobile'
: 'Desktop'
return { browser, operatingSystem, device }
}
export async function createAuthSession(event: H3Event, user: SessionUser, loginMethod = 'password') {
const id = randomUUID()
const now = getServerDate()
const userAgent = readUserAgent(event)
const metadata = parseUserAgent(userAgent)
try {
await db.insert(authSessions).values({
id,
userId: user.id,
createdAt: now,
lastActiveAt: now,
expiresAt: getSessionExpiry(),
ipAddress: getClientIP(event),
userAgent,
...metadata,
loginMethod
})
} catch (error) {
if (!isAuthSessionStorageError(error)) throw error
console.error('[Auth] auth_sessions 表不可用,拒绝签发无法撤销的登录令牌:', error)
throw createApiError(
503,
SERVER_ERROR_CODES.AUTH_DATABASE_UNAVAILABLE,
'登录会话存储暂时不可用,请先完成数据库迁移'
)
}
return {
id,
token: JWTEnhanced.generateToken(user.id, user.role, user.tokenVersion ?? 0, id)
}
}
export async function ensureAuthSession(event: H3Event, payload: { userId: number; jti: string; exp?: number }) {
const existing = await db.query.authSessions.findFirst({ where: eq(authSessions.id, payload.jti) })
if (existing) return existing
const now = getServerDate()
const userAgent = readUserAgent(event)
const metadata = parseUserAgent(userAgent)
const expiresAt = payload.exp ? getDateFromTimestamp(payload.exp * 1000) : getSessionExpiry()
await db.insert(authSessions).values({
id: payload.jti,
userId: payload.userId,
createdAt: now,
lastActiveAt: now,
expiresAt,
ipAddress: getClientIP(event),
userAgent,
...metadata,
loginMethod: 'legacy'
}).onConflictDoNothing()
return db.query.authSessions.findFirst({ where: eq(authSessions.id, payload.jti) })
}
export async function touchAuthSession(sessionId: string, extendExpiry = false) {
const update = {
lastActiveAt: getServerDate(),
...(extendExpiry ? { expiresAt: getSessionExpiry() } : {})
}
await db.update(authSessions)
.set(update)
.where(and(eq(authSessions.id, sessionId), isNull(authSessions.revokedAt)))
}
export async function revokeAuthSession(userId: number, sessionId: string, reason = 'user') {
const result = await db.update(authSessions)
.set({ revokedAt: getServerDate(), revokedReason: reason })
.where(and(eq(authSessions.id, sessionId), eq(authSessions.userId, userId), isNull(authSessions.revokedAt)))
.returning({ id: authSessions.id })
return result.length > 0
}
export async function revokeOtherAuthSessions(userId: number, currentSessionId: string | null, reason = 'logout_others') {
const conditions = [eq(authSessions.userId, userId), isNull(authSessions.revokedAt)]
if (currentSessionId) conditions.push(ne(authSessions.id, currentSessionId))
const result = await db.update(authSessions)
.set({ revokedAt: getServerDate(), revokedReason: reason })
.where(and(...conditions))
.returning({ id: authSessions.id })
return result.length
}
export function sessionExpiryIsActive(expiresAt: Date) {
return expiresAt.getTime() > getServerDate().getTime()
}
+272
View File
@@ -0,0 +1,272 @@
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,
tokenVersion: users.tokenVersion
})
.from(users)
.where(eq(users.id, decoded.userId))
.limit(1)
const user = userResult[0]
if (!user) {
console.error('获取用户信息失败: 用户不存在')
return {
success: false,
message: '用户不存在'
}
}
if ((decoded.tokenVersion ?? 0) !== user.tokenVersion) {
return {
success: false,
message: '登录状态已失效,请重新登录'
}
}
// 兼容迁移前签发的令牌,继续检查密码修改时间。
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,
tokenVersion: users.tokenVersion
})
.from(users)
.where(eq(users.id, decoded.userId))
.limit(1)
const user = userResult[0]
if (!user) {
console.error('获取用户信息失败: 用户不存在')
return {
success: false,
message: '用户不存在'
}
}
if ((decoded.tokenVersion ?? 0) !== user.tokenVersion) {
return {
success: false,
message: '登录状态已失效,请重新登录'
}
}
// 兼容迁移前签发的令牌,继续检查密码修改时间。
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: '权限验证失败'
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import crypto from 'crypto'
const MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
36, 20, 34, 44, 52
]
function getMixinKey(orig: string) {
return MIXIN_KEY_ENC_TAB.map((n) => orig[n])
.join('')
.slice(0, 32)
}
/**
* 获取 Bilibili WBI 签名所需的密钥
*/
export async function getBilibiliWbiKeys() {
const resp: any = await $fetch('https://api.bilibili.com/x/web-interface/nav', {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
})
if (!resp.data?.wbi_img) {
throw new Error(`Failed to fetch wbi_img from bilibili: ${resp.message || resp.code}`)
}
const img_url = resp.data.wbi_img.img_url
const sub_url = resp.data.wbi_img.sub_url
const img_key = img_url.substring(img_url.lastIndexOf('/') + 1, img_url.lastIndexOf('.'))
const sub_key = sub_url.substring(sub_url.lastIndexOf('/') + 1, sub_url.lastIndexOf('.'))
return getMixinKey(img_key + sub_key)
}
/**
* 使用 WBI 密钥对请求参数进行签名
*/
export function signBilibiliWbiRequest(params: Record<string, string | number>, wbiKey: string) {
const signedParams = { ...params, wts: Math.round(Date.now() / 1000) }
const query = Object.keys(signedParams)
.sort()
.map((key) => {
const value = signedParams[key].toString()
const encodedValue = encodeURIComponent(value).replace(/[!'()*]/g, '')
return `${key}=${encodedValue}`
})
.join('&')
const w_rid = crypto.createHash('md5').update(query + wbiKey).digest('hex')
return `${query}&w_rid=${w_rid}`
}
+50
View File
@@ -0,0 +1,50 @@
/**
* 图形验证码校验与销毁
* 统一使用 captchaStore(Redis 或内存 Map),与 /api/auth/captcha 生成接口共享存储。
*
* 注意:生成端写入的 key 形如 `captcha:<id>`,本文件必须使用相同前缀,
* 否则会出现"验证码输对了仍不通过"的问题。
*/
import {
delStore,
getAndDelStore,
getStore,
hashStateCode,
setStore,
verifyStateCode
} from './captchaStore'
const keyOf = (id: string) => `captcha:${id}`
// 与生成端保持一致:5 分钟有效
const CAPTCHA_TTL_SECONDS = 300
/** 保存验证码(保留以兼容直接调用此函数的旧代码) */
export async function saveCaptcha(id: string, text: string) {
const key = keyOf(id)
await setStore(key, hashStateCode(key, String(text).toLowerCase()), CAPTCHA_TTL_SECONDS)
}
/** 只校验验证码是否正确,不删除 */
export async function verifyCaptcha(captchaId: string, userInput: string): Promise<boolean> {
if (!captchaId || !userInput) return false
const stored = await getStore(keyOf(captchaId))
if (!stored) return false
return verifyStateCode(keyOf(captchaId), String(userInput).toLowerCase(), stored)
}
/** 仅删除验证码,不校验 */
export async function consumeCaptcha(captchaId: string): Promise<void> {
if (!captchaId) return
await delStore(keyOf(captchaId))
}
/** 校验并立即销毁验证码(无论成功与否均销毁) */
export async function verifyAndConsumeCaptcha(
captchaId: string,
userInput: string
): Promise<boolean> {
if (!captchaId || !userInput) return false
const stored = await getAndDelStore(keyOf(captchaId))
if (!stored) return false
return verifyStateCode(keyOf(captchaId), String(userInput).toLowerCase(), stored)
}
+201
View File
@@ -0,0 +1,201 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import type { RedisClientType } from 'redis'
import { buildRedisKey, getRedisClient, isRedisConfigured } from './redis'
import { getServerTimestamp } from './serverTime'
interface MemoryRecord {
value: string
expiresAt: number
}
export class RedisStateUnavailableError extends Error {
statusCode = 503
constructor() {
super('分布式短期状态服务暂不可用,请稍后重试')
this.name = 'RedisStateUnavailableError'
}
}
const memoryStore = new Map<string, MemoryRecord>()
const MAX_MEMORY_STORE_SIZE = 10000
const keyOf = (key: string) => buildRedisKey('state', key)
const cleanupMemoryStore = () => {
const now = getServerTimestamp()
for (const [key, record] of memoryStore.entries()) {
if (now >= record.expiresAt) memoryStore.delete(key)
}
}
const ensureMemoryStoreCapacity = (key: string) => {
cleanupMemoryStore()
if (memoryStore.has(key) || memoryStore.size < MAX_MEMORY_STORE_SIZE) return
const oldestKey = memoryStore.keys().next().value
if (oldestKey !== undefined) memoryStore.delete(oldestKey)
}
const requireRedis = async () => {
const redis = await getRedisClient()
if (!redis) throw new RedisStateUnavailableError()
return redis
}
const executeRedisState = async <T>(
operation: (redis: RedisClientType) => Promise<T>
): Promise<T> => {
try {
const redis = await requireRedis()
return await operation(redis)
} catch (error) {
if (error instanceof RedisStateUnavailableError) throw error
throw new RedisStateUnavailableError()
}
}
const INCREMENT_WITH_EXPIRY_SCRIPT = `
local value = redis.call('INCR', KEYS[1])
if value == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return value
`
const GET_AND_DELETE_SCRIPT = `
local value = redis.call('GET', KEYS[1])
if value then redis.call('DEL', KEYS[1]) end
return value
`
const COMPARE_AND_DELETE_SCRIPT = `
local value = redis.call('GET', KEYS[1])
if value and value == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1
end
return 0
`
/** 设置短期状态。配置 Redis 后,Redis 故障不会悄悄切换到另一份内存状态。 */
export async function setStore(key: string, value: string, ttlSeconds: number) {
if (isRedisConfigured()) {
await executeRedisState((redis) =>
redis.set(keyOf(key), value, { EX: Math.max(1, Math.floor(ttlSeconds)) })
)
return
}
ensureMemoryStoreCapacity(key)
memoryStore.set(key, {
value,
expiresAt: getServerTimestamp() + Math.max(1, ttlSeconds) * 1000
})
}
export async function getStore(key: string): Promise<string | null> {
if (isRedisConfigured()) {
return await executeRedisState((redis) => redis.get(keyOf(key)))
}
cleanupMemoryStore()
const record = memoryStore.get(key)
return record ? record.value : null
}
export async function delStore(key: string) {
if (isRedisConfigured()) {
await executeRedisState((redis) => redis.del(keyOf(key)))
return
}
memoryStore.delete(key)
}
/** 递增键值并仅在第一次递增时设置过期时间。 */
export async function incrStore(key: string, ttlSeconds: number): Promise<number> {
if (isRedisConfigured()) {
const result = await executeRedisState((redis) =>
redis.eval(INCREMENT_WITH_EXPIRY_SCRIPT, {
keys: [keyOf(key)],
arguments: [String(Math.max(1, Math.floor(ttlSeconds)))]
})
)
return Number(result)
}
ensureMemoryStoreCapacity(key)
const existing = memoryStore.get(key)
const current = Number(existing?.value || 0) + 1
memoryStore.set(key, {
value: String(current),
expiresAt: existing?.expiresAt ?? getServerTimestamp() + Math.max(1, ttlSeconds) * 1000
})
return current
}
/** 原子读取并删除键,用于一次性验证码。 */
export async function getAndDelStore(key: string): Promise<string | null> {
if (isRedisConfigured()) {
const result = await executeRedisState((redis) =>
redis.eval(GET_AND_DELETE_SCRIPT, {
keys: [keyOf(key)],
arguments: []
})
)
return result === null ? null : String(result)
}
cleanupMemoryStore()
const record = memoryStore.get(key)
memoryStore.delete(key)
return record ? record.value : null
}
/** 仅当值未被其他请求改变时删除,用于防止一次性验证码并发重复消费。 */
export async function delStoreIfValue(key: string, expectedValue: string): Promise<boolean> {
if (isRedisConfigured()) {
const result = await executeRedisState((redis) =>
redis.eval(COMPARE_AND_DELETE_SCRIPT, {
keys: [keyOf(key)],
arguments: [expectedValue]
})
)
return Number(result) === 1
}
cleanupMemoryStore()
const record = memoryStore.get(key)
if (!record || record.value !== expectedValue) return false
memoryStore.delete(key)
return true
}
/** 安全解析短期状态 JSON,拒绝旧明文、数组和损坏数据。 */
export function parseStoreJson<T extends object>(value: string | null): T | null {
if (!value) return null
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
return parsed as T
} catch {
return null
}
}
const getCodeSecret = () => {
const secret = process.env.JWT_SECRET?.trim()
if (!secret) throw new Error('JWT_SECRET environment variable is not set')
return secret
}
export function hashStateCode(scope: string, code: string) {
return createHmac('sha256', getCodeSecret()).update(`${scope}:${code}`).digest('hex')
}
export function verifyStateCode(scope: string, code: string, expectedHash: string) {
const actual = Buffer.from(hashStateCode(scope, code), 'utf8')
const expected = Buffer.from(expectedHash, 'utf8')
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
+68
View File
@@ -0,0 +1,68 @@
import { createError, getQuery, readBody } from 'h3'
import { z } from 'zod'
import { deleteCardCodesByIds } from '~~/server/services/cardCodeDeleteService'
const MAX_DELETE_COUNT = 500
const idSchema = z.preprocess(
(value) => (typeof value === 'number' || typeof value === 'string' ? value : undefined),
z.coerce.number().int().positive()
)
const deleteCardCodesSchema = z.object({
id: idSchema.optional(),
ids: z.preprocess(
(value) => {
if (typeof value === 'string') {
return value.split(',').map((item) => item.trim()).filter(Boolean)
}
return value
},
z.array(idSchema).max(MAX_DELETE_COUNT, `单次最多删除 ${MAX_DELETE_COUNT} 个点歌券`).optional()
)
})
export const handleOpenCardCodeDelete = async (event: any) => {
const apiKey = event.context.apiKey
if (!apiKey) {
throw createError({ statusCode: 401, message: 'API认证失败' })
}
try {
const query = getQuery(event)
const body = await readBody(event).catch(() => ({})) ?? {}
const validatedData = deleteCardCodesSchema.parse({ ...query, ...body })
const rawIds = [
...(validatedData.id !== undefined ? [validatedData.id] : []),
...(validatedData.ids || [])
]
const ids = Array.from(new Set(rawIds))
if (!ids.length) {
throw createError({ statusCode: 400, message: '缺少有效点歌券ID' })
}
if (ids.length > MAX_DELETE_COUNT) {
throw createError({ statusCode: 400, message: `单次最多删除 ${MAX_DELETE_COUNT} 个点歌券` })
}
const deletedRows = await deleteCardCodesByIds(ids)
return {
success: true,
message: '点歌券删除成功',
data: {
cardCodes: deletedRows,
deleted: deletedRows.length,
requested: ids.length
}
}
} catch (err: any) {
if (err.statusCode) throw err
if (err.name === 'ZodError') {
throw createError({
statusCode: 400,
message: `请求参数验证失败:${(err.issues || err.errors).map((issue: any) => issue.message).join(', ')}`
})
}
console.error('开放API删除点歌券失败', err)
throw createError({ statusCode: 500, message: '删除点歌券失败' })
}
}
+151
View File
@@ -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' // 模拟数据
}
}
+241
View File
@@ -0,0 +1,241 @@
import { db, getConnectionStatus } from '~/drizzle/db'
import { sql } from 'drizzle-orm'
import {
getConnectionPoolStatus,
getDatabaseMetrics
} from './database-health'
import { getServerTimestamp } from './serverTime'
/**
* 数据库管理器
*/
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 = getServerTimestamp()
// 检查缓存
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 { database_size?: string } | undefined
const connectionRow = connectionStats[0] as { active_connections?: number | string } | undefined
return {
databaseSize: sizeRow?.database_size || 'Unknown',
activeConnections:
Number.parseInt(String(connectionRow?.active_connections ?? '0'), 10) || 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<{
connected: boolean
status: string
activeConnections: number
serverlessMode: boolean
autoSuspend: boolean
error: string | null
}> {
try {
const connectionStatus = await getConnectionStatus()
// 用最小查询判断真实连通性,避免底层连接对象状态或统计查询误判。
await db.execute(sql`SELECT 1 as connection_check`)
// 获取当前活跃连接数
let activeConnections = 0
try {
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 { active_connections?: number | string } | undefined
activeConnections = Number.parseInt(String(connRow?.active_connections ?? '0'), 10) || 0
} catch (metricsError) {
console.warn('Failed to get active connection count:', metricsError)
}
return {
connected: true,
status: connectionStatus.status || 'connected',
activeConnections,
serverlessMode: true, // Neon Database 是无服务器架构
autoSuspend: true, // 支持自动暂停
error: null
}
} catch (error) {
console.error('Failed to get connection status:', error)
return {
connected: false,
status: 'error',
activeConnections: 0,
serverlessMode: true,
autoSuspend: true,
error: error instanceof Error ? error.message : 'Failed to retrieve connection status'
}
}
}
/**
* 获取连接池状态
*/
async getConnectionPoolStatus() {
return await getConnectionPoolStatus()
}
/**
* 获取数据库性能指标
*/
async getPerformanceMetrics() {
return await getDatabaseMetrics()
}
/**
* 批量清理过期会话
*/
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
const deleteResult = result as { count?: number | string }
return Number.parseInt(String(deleteResult.count ?? '0'), 10) || 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()
+181
View File
@@ -0,0 +1,181 @@
import { randomInt, timingSafeEqual } from 'node:crypto'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { getRedisClient, buildRedisKey } from '~~/server/utils/redis'
// 注册邮箱验证码:5 分钟有效,60 秒重发冷却,同一邮箱每日最多 5 次;
// 优先存 Redis,Redis 不可用时回退进程内存。
const EMAIL_CODE_TTL_MS = 5 * 60 * 1000
const EMAIL_CODE_COOLDOWN_MS = 60 * 1000
const EMAIL_CODE_MAX_ATTEMPTS = 5
const EMAIL_CODE_DAILY_LIMIT = 5
const EMAIL_CODE_DAILY_WINDOW_MS = 24 * 60 * 60 * 1000
type EmailCodeEntry = {
code: string
expiresAt: number
createdAt: number
attempts: number
}
const emailCodes = new Map<string, EmailCodeEntry>()
const dailySendCounts = new Map<string, { count: number; dayKey: string }>()
const normalizeEmail = (email: string) => email.trim().toLowerCase()
const getDayKey = () => {
const now = new Date(getServerTimestamp())
return `${now.getUTCFullYear()}-${now.getUTCMonth() + 1}-${now.getUTCDate()}`
}
const codeRedisKey = (email: string) => buildRedisKey('email-code', 'code', normalizeEmail(email))
const attemptsRedisKey = (email: string) => buildRedisKey('email-code', 'attempts', normalizeEmail(email))
const cooldownRedisKey = (email: string) => buildRedisKey('email-code', 'cooldown', normalizeEmail(email))
const dailyRedisKey = (email: string) => buildRedisKey('email-code', 'daily', normalizeEmail(email), getDayKey())
// 校验并消费验证码(Redis Lua 原子:比较 + 失败计数 + 超限作废)
const VERIFY_EMAIL_CODE_SCRIPT = `
local stored = redis.call('GET', KEYS[1])
if not stored then
return { 0, 0 }
end
local attempts = redis.call('INCR', KEYS[2])
if attempts == 1 then
redis.call('EXPIRE', KEYS[2], 300)
end
if ARGV[1] == stored then
redis.call('DEL', KEYS[1], KEYS[2])
return { 1, attempts }
end
if attempts >= tonumber(ARGV[2]) then
redis.call('DEL', KEYS[1], KEYS[2])
return { 2, attempts }
end
return { 0, attempts }
`
const safeCodeEqual = (a: string, b: string): boolean => {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}
export function generateEmailCode(): string {
// 密码学安全随机 6 位数字
return String(randomInt(100000, 1000000))
}
// 发送成功后才落码;覆盖同邮箱旧码(重发即换新);同时推进每日发送计数与重发冷却
export async function storeEmailCode(email: string, code: string): Promise<void> {
const normalized = normalizeEmail(email)
const now = getServerTimestamp()
emailCodes.set(normalized, {
code,
expiresAt: now + EMAIL_CODE_TTL_MS,
createdAt: now,
attempts: 0
})
// 内存回退:以日键计数,跨天重置
const dayKey = getDayKey()
const record = dailySendCounts.get(normalized)
if (!record || record.dayKey !== dayKey) {
dailySendCounts.set(normalized, { count: 1, dayKey })
} else {
record.count++
}
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
try {
client = await getRedisClient()
if (!client) return
const ttlSeconds = Math.ceil(EMAIL_CODE_TTL_MS / 1000)
await client.set(codeRedisKey(email), code, { EX: ttlSeconds })
await client.set(cooldownRedisKey(email), '1', {
EX: Math.ceil(EMAIL_CODE_COOLDOWN_MS / 1000)
})
const dailyKey = dailyRedisKey(email)
const dailyCount = await client.incr(dailyKey)
if (dailyCount === 1) {
await client.expire(dailyKey, Math.ceil(EMAIL_CODE_DAILY_WINDOW_MS / 1000))
}
} catch (error) {
console.error('[EmailCode] Redis 写入失败,已回退内存存储:', error)
}
}
export async function isEmailCodeCooldownActive(email: string): Promise<boolean> {
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
try {
client = await getRedisClient()
if (client) {
return Boolean(await client.exists(cooldownRedisKey(email)))
}
} catch (error) {
console.error('[EmailCode] Redis 读取失败,已回退内存判断:', error)
}
const entry = emailCodes.get(normalizeEmail(email))
return Boolean(entry && getServerTimestamp() - entry.createdAt < EMAIL_CODE_COOLDOWN_MS)
}
// 同一邮箱每日发送上限(发码前检查;计数在 storeEmailCode 成功时推进)
export async function isEmailCodeDailyLimitReached(email: string): Promise<boolean> {
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
try {
client = await getRedisClient()
if (client) {
const count = await client.get(dailyRedisKey(email))
return Number(count || 0) >= EMAIL_CODE_DAILY_LIMIT
}
} catch (error) {
console.error('[EmailCode] Redis 读取失败,已回退内存判断:', error)
}
const normalized = normalizeEmail(email)
const dayKey = getDayKey()
const record = dailySendCounts.get(normalized)
return Boolean(record && record.dayKey === dayKey && record.count >= EMAIL_CODE_DAILY_LIMIT)
}
// 校验并消费验证码(一次性;输错计数,超过上限作废防暴力枚举)
export async function verifyEmailCode(email: string, code: string): Promise<boolean> {
const trimmedCode = code.trim()
let client
try {
client = await getRedisClient()
if (client) {
const result = await client.eval(VERIFY_EMAIL_CODE_SCRIPT, {
keys: [codeRedisKey(email), attemptsRedisKey(email)],
arguments: [trimmedCode, String(EMAIL_CODE_MAX_ATTEMPTS)]
})
if (Array.isArray(result)) {
// 1=成功并已消费;2=失败次数超限已作废;0=验证码不存在或未匹配
return Number(result[0]) === 1
}
}
} catch (error) {
console.error('[EmailCode] Redis 校验失败,已回退内存校验:', error)
}
const normalized = normalizeEmail(email)
const entry = emailCodes.get(normalized)
if (!entry) return false
if (getServerTimestamp() > entry.expiresAt) {
emailCodes.delete(normalized)
return false
}
if (safeCodeEqual(entry.code, trimmedCode)) {
emailCodes.delete(normalized)
return true
}
entry.attempts++
if (entry.attempts >= EMAIL_CODE_MAX_ATTEMPTS) {
emailCodes.delete(normalized)
}
return false
}
// 惰性清理过期条目(发码时顺带执行,防公开接口慢性增长;Redis 路径由 EXPIRE 自动清理)
export function cleanupExpiredEmailCodes(): void {
const now = getServerTimestamp()
for (const [email, entry] of emailCodes) {
if (now > entry.expiresAt) emailCodes.delete(email)
}
}
+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
}
}
}
+39
View File
@@ -0,0 +1,39 @@
// 年级班级选项的纯函数核心(无 db 依赖,便于单测)
export const smartSort = (a: string, b: string) => {
const gradeOrder: Record<string, number> = {
'初一': 1, '初二': 2, '初三': 3,
'高一': 4, '高二': 5, '高三': 6,
'大一': 7, '大二': 8, '大三': 9, '大四': 10,
'教师': 99, '教职工': 99
}
const weightA = gradeOrder[a]
const weightB = gradeOrder[b]
if (weightA !== undefined && weightB !== undefined) return weightA - weightB
if (weightA !== undefined) return -1
if (weightB !== undefined) return 1
return a.localeCompare(b, 'zh-CN', { numeric: true })
}
export interface GradeClassOption {
grade: string
class: string
}
// 配置优先,配置为空时回退到用户提取;统一过滤空值与 smartSort 排序
export const resolveGradeClassOptions = (
configItems: GradeClassOption[],
fallbackItems: GradeClassOption[]
): GradeClassOption[] => {
const source = configItems.length > 0 ? configItems : fallbackItems
return source
.filter((item): item is GradeClassOption => Boolean(item.grade?.trim()) && Boolean(item.class?.trim()))
.sort((a, b) => {
const gradeResult = smartSort(a.grade, b.grade)
return gradeResult || smartSort(a.class, b.class)
})
}
+49
View File
@@ -0,0 +1,49 @@
import { and, isNotNull, eq, ne, sql } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { users, gradeClass } from '~/drizzle/schema'
import { resolveGradeClassOptions, type GradeClassOption } from './grade-class-core'
// 年级班级选项:优先读管理员配置表,未配置时从 active 用户中提取(注册表单与 OAuth 注册共用)
export async function fetchGradeClassOptions(): Promise<GradeClassOption[]> {
const [configRows, userRows] = await Promise.all([
db.select({
grade: gradeClass.grade,
class: gradeClass.class
}).from(gradeClass),
db
.selectDistinct({
grade: users.grade,
class: users.class
})
.from(users)
.where(and(eq(users.status, 'active'), isNotNull(users.grade), isNotNull(users.class)))
])
return resolveGradeClassOptions(configRows, userRows)
}
// 组合合法性校验:与选项数据源严格一致——配置非空时仅接受配置组合;
// 配置为空时才回退到现有 active 用户组合
export async function isGradeClassValid(grade: string, studentClass: string): Promise<boolean> {
const configHit = await db.query.gradeClass.findFirst({
where: (t, { eq: eq_, and: and_ }) => and_(eq_(t.grade, grade), eq_(t.class, studentClass)),
columns: { id: true }
})
if (configHit) return true
// 配置表存在有效行时不再接受用户存量组合(与注册表单选项一致,防绕过配置优先)
const configCount = await db
.select({ count: () => sql<number>`count(*)::int` })
.from(gradeClass)
.where(and(ne(gradeClass.grade, ''), ne(gradeClass.class, '')))
if ((configCount[0]?.count || 0) > 0) return false
const userHit = await db.query.users.findFirst({
where: (t, { eq: eq_, and: and_ }) =>
and_(eq_(t.status, 'active'), eq_(t.grade, grade), eq_(t.class, studentClass)),
columns: { id: true }
})
return Boolean(userHit)
}
@@ -0,0 +1,72 @@
export const NOTIFICATION_TITLE_MAX_LENGTH = 200
export const NOTIFICATION_CONTENT_MAX_LENGTH = 20000
export const NOTIFICATION_SOURCES = {
SYSTEM: 'SYSTEM',
ADMIN_MANUAL: 'ADMIN_MANUAL'
} as const
export type NotificationSenderInput = {
id: number
name?: string | null
username?: string | null
}
export type NotificationSenderSnapshot = {
senderId: number | null
senderName: string | null
senderUsername: string | null
}
export const resolveNotificationSource = (sender: NotificationSenderInput | null) =>
sender ? NOTIFICATION_SOURCES.ADMIN_MANUAL : NOTIFICATION_SOURCES.SYSTEM
export const createNotificationSenderSnapshot = (
sender: NotificationSenderInput | null = null
): NotificationSenderSnapshot => ({
senderId: sender?.id ?? null,
senderName: sender?.name?.trim() || null,
senderUsername: sender?.username?.trim() || null
})
export const serializeNotificationSender = (snapshot: NotificationSenderSnapshot) =>
snapshot.senderId
? {
id: snapshot.senderId,
name: snapshot.senderName,
username: snapshot.senderUsername
}
: null
export const shouldCheckImportantNotification = (
authenticated: boolean,
userId?: number | null,
requirePasswordChange = false
): boolean =>
authenticated && Number.isInteger(userId) && Number(userId) > 0 && !requirePasswordChange
export const canSendSystemNotification = (role?: string): boolean =>
role === 'ADMIN' || role === 'SUPER_ADMIN'
export const resolveImportantFlag = (value: unknown): boolean | null => {
if (value === undefined) return false
return typeof value === 'boolean' ? value : null
}
export const shouldDeliverSystemNotification = (
important: boolean,
notificationsEnabled?: boolean
): boolean => important || notificationsEnabled !== false
export const shouldRetainNotificationHistory = (type: string, source: string): boolean =>
type === 'SYSTEM_NOTICE' && source === NOTIFICATION_SOURCES.ADMIN_MANUAL
export const createNotificationReadUpdate = (updatedAt = new Date()) => ({
read: true as const,
updatedAt
})
export const createNotificationUserDeleteUpdate = (updatedAt = new Date()) => ({
userDeleted: true as const,
read: true as const,
updatedAt
})
+33
View File
@@ -0,0 +1,33 @@
interface InitialPasswordUserState {
requirePasswordChange?: boolean | null
}
interface StoredPasswordState {
password?: string | null
passwordChangedAt?: Date | string | null
}
export interface PasswordSetupState {
hasSetPassword: boolean
needsInitialPasswordSetup: boolean
}
export function getPasswordSetupState(
storedPassword: StoredPasswordState,
requirePasswordChange: boolean
): PasswordSetupState {
const hasSetPassword = !!storedPassword.password
return {
hasSetPassword,
needsInitialPasswordSetup:
!storedPassword.passwordChangedAt && (requirePasswordChange || !hasSetPassword)
}
}
export function canSetInitialPassword(
user: InitialPasswordUserState,
storedPassword: StoredPasswordState
): boolean {
return getPasswordSetupState(storedPassword, !!user.requirePasswordChange)
.needsInitialPasswordSetup
}
+114
View File
@@ -0,0 +1,114 @@
import { randomUUID } from 'node:crypto'
import { asc, eq, sql } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { SYSTEM_SETTINGS_DEFAULTS } from './system-settings-defaults'
import { getServerTimestamp } from './serverTime'
export interface InstanceIdInfo {
instanceId: string
persisted: boolean
}
let cachedInstanceIdInfo: InstanceIdInfo | null = null
let cachedInstanceIdExpiresAt = 0
let pendingInstanceIdPromise: Promise<string> | null = null
let pendingInstanceIdInfoPromise: Promise<InstanceIdInfo> | null = null
const TEMPORARY_INSTANCE_ID_CACHE_TTL_MS = 5 * 60 * 1000
const loadOrCreateInstanceId = async (): Promise<string> => {
const settingsResult = await db
.select({ id: systemSettings.id, instanceId: systemSettings.instanceId })
.from(systemSettings)
.orderBy(asc(systemSettings.id))
.limit(1)
const settings = settingsResult[0]
if (settings?.instanceId) {
return settings.instanceId
}
const instanceId = randomUUID()
if (!settings) {
// 强制使用单行记录(id=1),避免并发冷启动插入时产生重复数据
await db
.insert(systemSettings)
.values({ id: 1, ...SYSTEM_SETTINGS_DEFAULTS, instanceId })
.onConflictDoUpdate({
target: systemSettings.id,
set: { instanceId, updatedAt: new Date() }
})
// 手动指定 serial 主键后重置序列,避免后续自动插入时发生唯一约束冲突
await db.execute(sql`
SELECT setval(pg_get_serial_sequence('"SystemSettings"', 'id'), (SELECT MAX(id) FROM "SystemSettings"))
`)
} else {
await db
.update(systemSettings)
.set({ instanceId, updatedAt: new Date() })
.where(eq(systemSettings.id, settings.id))
}
return instanceId
}
const getCachedInstanceIdInfo = (): InstanceIdInfo | null => {
if (!cachedInstanceIdInfo) {
return null
}
if (cachedInstanceIdInfo.persisted || cachedInstanceIdExpiresAt > getServerTimestamp()) {
return cachedInstanceIdInfo
}
return null
}
export const getInstanceIdInfo = async (): Promise<InstanceIdInfo> => {
const cached = getCachedInstanceIdInfo()
if (cached) {
return cached
}
if (!pendingInstanceIdInfoPromise) {
pendingInstanceIdInfoPromise = loadOrCreateInstanceId()
.then((instanceId) => {
const info = { instanceId, persisted: true }
cachedInstanceIdInfo = info
cachedInstanceIdExpiresAt = Number.POSITIVE_INFINITY
return info
})
.catch((error) => {
const fallbackInstanceId = cachedInstanceIdInfo?.persisted === false
? cachedInstanceIdInfo.instanceId
: randomUUID()
const info = { instanceId: fallbackInstanceId, persisted: false }
cachedInstanceIdInfo = info
cachedInstanceIdExpiresAt = getServerTimestamp() + TEMPORARY_INSTANCE_ID_CACHE_TTL_MS
console.warn('[Instance ID] Failed to persist instance ID, using temporary fallback value:', error)
return info
})
.finally(() => {
pendingInstanceIdInfoPromise = null
})
}
return pendingInstanceIdInfoPromise
}
export const getInstanceId = async (): Promise<string> => {
if (!pendingInstanceIdPromise) {
pendingInstanceIdPromise = getInstanceIdInfo()
.then(({ instanceId }) => instanceId)
.finally(() => {
pendingInstanceIdPromise = null
})
}
return pendingInstanceIdPromise
}
+149
View File
@@ -0,0 +1,149 @@
import type { H3Event } from 'h3'
import { getHeaders } from 'h3'
import { isIP } from 'node:net'
const SUPPORTED_CLIENT_IP_HEADERS = new Set([
'eo-connecting-ip',
'eo-client-ip',
'x-edgeone-client-ip',
'edgeone-client-ip',
'cf-connecting-ip',
'true-client-ip',
'fastly-client-ip',
'fly-client-ip',
'x-nf-client-connection-ip',
'x-vercel-forwarded-for',
'x-azure-clientip',
'x-appengine-user-ip',
'x-cluster-client-ip',
'x-real-ip',
'x-client-ip',
'x-real-client-ip',
'x-forwarded-client-ip',
'x-original-forwarded-for',
'x-forwarded-for',
'x-forwarded',
'forwarded-for',
'forwarded'
])
// 未配置 TRUSTED_CLIENT_IP_HEADERS 时的回退头部列表,覆盖主流平台
const FALLBACK_CLIENT_IP_HEADERS = Object.freeze([
'x-forwarded-for',
'x-real-ip',
'x-vercel-forwarded-for',
'cf-connecting-ip',
'true-client-ip'
])
function getTrustedClientIPHeaders() {
const configured = String(process.env.TRUSTED_CLIENT_IP_HEADERS || '')
.split(',')
.map((header) => header.trim().toLowerCase())
.filter((header) => SUPPORTED_CLIENT_IP_HEADERS.has(header))
return configured.length > 0
? [...new Set(configured)]
: FALLBACK_CLIENT_IP_HEADERS
}
/**
* 获取客户端IP地址
* @param event H3Event对象
* @returns 客户端IP地址
*/
export function getClientIP(event: H3Event): string {
const headers = getHeaders(event)
// 只有部署者明确声明受信头部时才读取转发头,避免客户端直连源站时伪造 IP。
const ipHeaders = getTrustedClientIPHeaders()
for (const header of ipHeaders) {
const value = headers[header]
if (value) {
const values = Array.isArray(value) ? value : [value]
for (const item of values) {
for (const candidate of String(item).split(',')) {
const ip = normalizeIPCandidate(candidate)
if (ip) return ip
}
}
}
}
// 如果没有找到,直接从连接信息获取
// 从连接信息获取IP(备用方法)
const remoteAddress = event.node.req.socket?.remoteAddress
if (remoteAddress) {
// 移除IPv6映射的IPv4前缀
const cleanIP = normalizeIPCandidate(remoteAddress)
if (cleanIP) {
return cleanIP
}
}
return 'unknown'
}
function normalizeIPCandidate(value: string): string | null {
let candidate = value.trim().replace(/^"|"$/g, '')
const forwardedMatch = candidate.match(/^for\s*=\s*(.+)$/i)
if (forwardedMatch) {
const forwardedValue = forwardedMatch[1] || ''
candidate = (forwardedValue.split(';').shift() || '').trim().replace(/^"|"$/g, '')
}
if (candidate.startsWith('[')) {
const closingBracket = candidate.indexOf(']')
if (closingBracket > 0) candidate = candidate.slice(1, closingBracket)
} else if (isIP(candidate) === 0 && /^\d{1,3}(?:\.\d{1,3}){3}:\d+$/.test(candidate)) {
candidate = candidate.slice(0, candidate.lastIndexOf(':'))
}
const cleanIP = candidate.replace(/^::ffff:/i, '')
return isValidIP(cleanIP) ? cleanIP : null
}
/**
* 验证IP地址格式是否有效
* @param ip IP地址字符串
* @returns 是否为有效IP
*/
function isValidIP(ip: string): boolean {
// Node 原生解析器支持压缩 IPv6,避免大量合法地址被归入 unknown 共享限流桶。
if (isIP(ip) === 0) {
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
}
+210
View File
@@ -0,0 +1,210 @@
import jwt from 'jsonwebtoken'
import { randomUUID } from 'node:crypto'
// 简化的JWT载荷接口
export interface JWTPayload {
userId: number
role: string
jti: string // JWT ID,用于唯一标识token
tokenVersion?: number
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 hasCurrentTokenVersion(
payload: { tokenVersion?: unknown } | null | undefined,
currentTokenVersion: number
): boolean {
return (
typeof payload?.tokenVersion === 'number' &&
Number.isInteger(payload.tokenVersion) &&
payload.tokenVersion === currentTokenVersion
)
}
/**
* 通用签名方法
*/
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, tokenVersion = 0, sessionId?: string): string {
if (!this.JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is not set')
}
// 生成唯一的JWT ID
const jti = sessionId || randomUUID()
// JWT载荷
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
userId,
role,
jti,
tokenVersion
}
// 生成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, decoded.tokenVersion ?? 0, decoded.jti)
}
/**
* 验证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()
+48
View File
@@ -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)
}
+267
View File
@@ -0,0 +1,267 @@
import crypto from 'node:crypto'
export const txHeaders = {
'User-Agent': 'QQMusic 14090508(android 12)'
}
export const TX_MUSICU_URL = 'https://u.y.qq.com/cgi-bin/musicu.fcg'
const QQ_MID_PREFIX_RE = /^qqmid:/i
const QQ_LEGACY_ID_RE = /^\d+$/
const TX_DETAIL_CACHE_TTL = 6 * 60 * 60 * 1000
// 签名算法常量
const PART_1_INDEXES = [23, 14, 6, 36, 16, 40, 7, 19]
const PART_2_INDEXES = [16, 1, 32, 12, 19, 27, 8, 5]
const SCRAMBLE_VALUES = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179]
function pickHashByIdx(hash: string, indexes: number[]) {
return indexes.map((idx) => hash[idx]).join('')
}
function base64Encode(data: Buffer | string) {
return Buffer.from(data)
.toString('base64')
.replace(/[\\/+=]/g, '')
}
/**
* zzcSign 签名算法
* 模拟官方客户端签名,抗封锁能力更强
*/
export async function zzcSign(text: string) {
const hash = crypto.createHash('sha1').update(text).digest('hex')
const part1 = pickHashByIdx(hash, PART_1_INDEXES)
const part2 = pickHashByIdx(hash, PART_2_INDEXES)
const part3 = SCRAMBLE_VALUES.map((value, i) => value ^ parseInt(hash.slice(i * 2, i * 2 + 2), 16))
const b64Part = base64Encode(Buffer.from(part3)).replace(/[\\/+=]/g, '')
return `zzc${part1}${b64Part}${part2}`.toLowerCase()
}
/**
* 使用签名请求 QQ 音乐 API
* 相比普通 musicu.fcg,musics.fcg?sign=... 的签名请求更稳定
*/
export const txSignedRequest = async (body: any, options: { signal?: AbortSignal } = {}) => {
const sign = await zzcSign(JSON.stringify(body))
try {
const response = await $fetch(`https://u.y.qq.com/cgi-bin/musics.fcg?sign=${sign}`, {
method: 'POST',
headers: txHeaders,
body,
responseType: 'json',
signal: options.signal
})
return response
} catch (error) {
console.error('TX Signed Request Error:', error)
throw error
}
}
type TxIdType = 'legacy-id' | 'mid'
interface TxNormalizedMusicId {
rawMusicId: string
normalizedMusicId: string
idType: TxIdType
}
interface TxSongPlayableInfo extends TxNormalizedMusicId {
songmid: string
songId?: string
strMediaMid?: string
}
const txSongDetailCache = new Map<
string,
{
expiresAt: number
value: TxSongPlayableInfo
}
>()
/**
* PC 客户端版 searchid:32 位大写十六进制 GUID + 5 位补零随机数(37 字符)
*/
const getTxSearchId = () => {
let guid = ''
for (let i = 0; i < 32; i++) guid += Math.floor(Math.random() * 16).toString(16)
return guid.toUpperCase() + String(Math.floor(Math.random() * 100000)).padStart(5, '0')
}
export const createTxSearchBody = (str: string, page: number, limit: number) => {
return {
comm: {
_channelid: '0',
_os_version: '6.2.9200-2',
ct: '19',
cv: '2151',
guid: '1F70E520B2EAA7D25E11760783C53CA9',
patch: '118',
psrf_access_token_expiresAt: 0,
psrf_qqaccess_token: '',
psrf_qqopenid: '',
psrf_qqunionid: '',
tmeAppID: 'qqmusic',
tmeLoginType: 0,
uin: '0',
wid: '7223299733393904640'
},
'music.search.SearchCgiService': {
module: 'music.search.SearchCgiService',
method: 'DoSearchForQQMusicDesktop',
param: {
grp: 1,
num_per_page: limit,
page_num: page,
query: str,
remoteplace: 'txt.newclient.top',
search_type: 0,
searchid: getTxSearchId()
}
}
}
}
export const normalizeTxMusicId = (musicId: string | number): TxNormalizedMusicId => {
const rawMusicId = String(musicId ?? '').trim()
const normalizedMusicId = rawMusicId.replace(QQ_MID_PREFIX_RE, '').trim()
if (!normalizedMusicId) {
throw createError({ statusCode: 400, message: '缺少 QQ 音乐ID' })
}
return {
rawMusicId,
normalizedMusicId,
idType: QQ_LEGACY_ID_RE.test(normalizedMusicId) ? 'legacy-id' : 'mid'
}
}
export const upgradeTxAudioUrl = (url: string) => {
return url.startsWith('http://') ? url.replace('http://', 'https://') : url
}
export const createTxSongDetailBody = (musicId: TxNormalizedMusicId) => {
const param =
musicId.idType === 'legacy-id'
? { song_type: 0, song_id: Number(musicId.normalizedMusicId) }
: { song_type: 0, song_mid: musicId.normalizedMusicId }
return {
comm: {
ct: '19',
cv: '1859',
uin: '0'
},
req: {
module: 'music.pf_song_detail_svr',
method: 'get_song_detail_yqq',
param
}
}
}
const getCachedTxSongDetail = (key: string) => {
const cached = txSongDetailCache.get(key)
if (!cached) return null
if (cached.expiresAt <= Date.now()) {
txSongDetailCache.delete(key)
return null
}
return cached.value
}
const setCachedTxSongDetail = (key: string, value: TxSongPlayableInfo) => {
if (txSongDetailCache.size >= 1000) {
const firstKey = txSongDetailCache.keys().next().value
if (firstKey !== undefined) {
txSongDetailCache.delete(firstKey)
}
}
txSongDetailCache.set(key, {
expiresAt: Date.now() + TX_DETAIL_CACHE_TTL,
value
})
}
export const getTxSongPlayableInfo = async (
musicId: string | number
): Promise<TxSongPlayableInfo> => {
const normalized = normalizeTxMusicId(musicId)
const cacheKey = `${normalized.idType}:${normalized.normalizedMusicId}`
const cached = getCachedTxSongDetail(cacheKey)
if (cached) return cached
// 旧 QQ 数字 ID 只在播放时转 MID,避免批量改历史数据。
let result: any
try {
result = await txRequest(TX_MUSICU_URL, createTxSongDetailBody(normalized))
} catch (error) {
if (normalized.idType === 'mid') {
const value = {
...normalized,
songmid: normalized.normalizedMusicId
}
setCachedTxSongDetail(cacheKey, value)
return value
}
throw error
}
if (!result || result.code !== 0 || result.req?.code !== 0) {
if (normalized.idType === 'mid') {
const value = {
...normalized,
songmid: normalized.normalizedMusicId
}
setCachedTxSongDetail(cacheKey, value)
return value
}
throw createError({ statusCode: 502, message: 'QQ 音乐详情接口异常' })
}
const trackInfo = result.req?.data?.track_info
const songmid = trackInfo?.mid || normalized.normalizedMusicId
if (!songmid) {
throw createError({ statusCode: 502, message: 'QQ 音乐详情缺少 MID' })
}
const value = {
...normalized,
songmid,
songId: String(trackInfo?.id || normalized.normalizedMusicId),
strMediaMid: trackInfo?.file?.media_mid
}
setCachedTxSongDetail(cacheKey, value)
setCachedTxSongDetail(`mid:${songmid}`, {
...value,
normalizedMusicId: songmid,
idType: 'mid'
})
return value
}
export const txRequest = async (
url: string,
body: any,
options: { signal?: AbortSignal } = {}
) => {
try {
const response = await $fetch(url, {
method: 'POST',
headers: txHeaders,
body,
responseType: 'json',
signal: options.signal
})
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
}
}
+108
View File
@@ -0,0 +1,108 @@
export const NOTIFICATION_HISTORY_STATUSES = ['ALL', 'READ', 'UNREAD'] as const
export type NotificationHistoryStatus = (typeof NOTIFICATION_HISTORY_STATUSES)[number]
export const NOTIFICATION_HISTORY_TYPES = ['ALL', 'IMPORTANT', 'NORMAL'] as const
export const NOTIFICATION_HISTORY_SORT_ORDERS = ['DESC', 'ASC'] as const
export const NOTIFICATION_HISTORY_KEYWORD_MAX_LENGTH = 100
export type NotificationHistoryType = (typeof NOTIFICATION_HISTORY_TYPES)[number]
export type NotificationHistorySortOrder = (typeof NOTIFICATION_HISTORY_SORT_ORDERS)[number]
export const resolveNotificationHistoryFilters = (values: {
keyword?: unknown
type?: unknown
sender?: unknown
sortOrder?: unknown
}): {
keyword: string
type: NotificationHistoryType
senderId: number | null
sortOrder: NotificationHistorySortOrder
} | null => {
if (values.keyword !== undefined && typeof values.keyword !== 'string') return null
const keyword = typeof values.keyword === 'string' ? values.keyword.trim() : ''
if (keyword.length > NOTIFICATION_HISTORY_KEYWORD_MAX_LENGTH) return null
if (values.type !== undefined && typeof values.type !== 'string') return null
const type = typeof values.type === 'string' && values.type.trim()
? values.type.trim().toUpperCase()
: 'ALL'
if (!NOTIFICATION_HISTORY_TYPES.includes(type as NotificationHistoryType)) return null
let senderId: number | null = null
if (values.sender !== undefined && values.sender !== null && values.sender !== '') {
if (typeof values.sender !== 'string') return null
const parsedSenderId = Number(values.sender.trim())
if (!Number.isInteger(parsedSenderId) || parsedSenderId <= 0) return null
senderId = parsedSenderId
}
if (values.sortOrder !== undefined && typeof values.sortOrder !== 'string') return null
const sortOrder = typeof values.sortOrder === 'string' && values.sortOrder.trim()
? values.sortOrder.trim().toUpperCase()
: 'DESC'
if (!NOTIFICATION_HISTORY_SORT_ORDERS.includes(sortOrder as NotificationHistorySortOrder)) {
return null
}
return {
keyword,
type: type as NotificationHistoryType,
senderId,
sortOrder: sortOrder as NotificationHistorySortOrder
}
}
export const resolveNotificationHistoryStatus = (
value: unknown
): NotificationHistoryStatus | null => {
if (value === undefined || value === null || value === '') return 'ALL'
if (typeof value !== 'string') return null
const normalized = value.trim().toUpperCase()
return NOTIFICATION_HISTORY_STATUSES.includes(normalized as NotificationHistoryStatus)
? (normalized as NotificationHistoryStatus)
: null
}
export const resolveNotificationHistoryPagination = (
pageValue: unknown,
limitValue: unknown
) => {
const parsedPage = Number(pageValue)
const parsedLimit = Number(limitValue)
const page = Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1
const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20
return {
page,
limit,
offset: (page - 1) * limit
}
}
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const LEGACY_BATCH_PATTERN = /^legacy-([1-9]\d*)$/
export const resolveNotificationBatchReference = (value: unknown) => {
if (typeof value !== 'string') return null
const normalized = value.trim()
const legacyMatch = normalized.match(LEGACY_BATCH_PATTERN)
if (legacyMatch) {
return {
batchId: null,
notificationId: Number(legacyMatch[1])
}
}
if (!UUID_PATTERN.test(normalized)) return null
return {
batchId: normalized.toLowerCase(),
notificationId: null
}
}
+86
View File
@@ -0,0 +1,86 @@
import { db, eq, userIdentities, users } from '~/drizzle/db'
import { getBeijingTime } from '~/utils/timeUtils'
import { resolveAvatarSource } from '~~/server/utils/user-avatar'
type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
export interface OAuthIdentityPayload {
provider: string
providerUserId: string
providerUsername?: string | null
avatar?: string | null
}
export interface ExistingOAuthIdentity {
id: number
avatar: string | null
}
// 统一 OAuth 身份绑定后的头像写入与自动补选,避免多段绑定流程逻辑漂移
export async function syncOAuthIdentityAvatar(
tx: DbTransaction,
user: { id: number; avatarProvider?: string | null; avatarProviderUserId?: string | null },
existing: ExistingOAuthIdentity | null | undefined,
payload: OAuthIdentityPayload
): Promise<{ created: boolean; avatarSourceSet: boolean }> {
const currentIdentities = await tx.query.userIdentities.findMany({
where: (t, { eq: eqUserId }) => eqUserId(t.userId, user.id),
columns: {
id: true,
provider: true,
providerUserId: true,
providerUsername: true,
avatar: true,
createdAt: true
}
})
const currentAvatarSource = resolveAvatarSource(user, currentIdentities)
let created = false
if (existing) {
if (payload.avatar && existing.avatar !== payload.avatar) {
await tx
.update(userIdentities)
.set({ avatar: payload.avatar })
.where(eq(userIdentities.id, existing.id))
}
} else {
await tx.insert(userIdentities).values({
userId: user.id,
provider: payload.provider,
providerUserId: payload.providerUserId,
providerUsername: payload.providerUsername,
avatar: payload.avatar || null,
createdAt: getBeijingTime()
})
created = true
}
let avatarSourceSet = false
if (!currentAvatarSource) {
const updatedIdentities = await tx.query.userIdentities.findMany({
where: (t, { eq: eqUserId }) => eqUserId(t.userId, user.id),
columns: {
id: true,
provider: true,
providerUserId: true,
providerUsername: true,
avatar: true,
createdAt: true
}
})
const nextAvatarSource = resolveAvatarSource(user, updatedIdentities)
if (nextAvatarSource) {
await tx
.update(users)
.set({
avatarProvider: nextAvatarSource.provider,
avatarProviderUserId: nextAvatarSource.providerUserId
})
.where(eq(users.id, user.id))
avatarSourceSet = true
}
}
return { created, avatarSourceSet }
}
+127
View File
@@ -0,0 +1,127 @@
export type OAuthProvider = 'github' | 'casdoor' | 'google' | 'oauth2' | 'aggregate'
export const AGGREGATE_OAUTH_LOGIN_TYPES = [
'qq',
'wx',
'alipay',
'sina',
'baidu',
'douyin',
'huawei',
'xiaomi',
'gitee',
'gitea',
'bilibili',
'kuaishou'
] as const
export type AggregateOAuthLoginType = (typeof AGGREGATE_OAUTH_LOGIN_TYPES)[number]
const isPrivateIPv4 = (hostname: string): boolean => {
const parts = hostname.split('.').map(Number)
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return false
}
const [first, second] = parts
return (
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
)
}
const isPrivateIPv6 = (hostname: string): boolean => {
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase()
if (!normalized.includes(':')) return false
if (normalized.startsWith('::ffff:')) {
return isPrivateIPv4(normalized.slice('::ffff:'.length))
}
return (
normalized === '::1' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
/^fe[89ab]/.test(normalized)
)
}
const isInternalHostname = (hostname: string): boolean => {
const normalized = hostname.toLowerCase()
if (normalized.includes(':')) return isPrivateIPv6(normalized)
if (isPrivateIPv4(normalized)) return true
return (
normalized === 'localhost' ||
!normalized.includes('.') ||
normalized.endsWith('.localhost') ||
normalized.endsWith('.local') ||
normalized.endsWith('.lan') ||
normalized.endsWith('.internal') ||
normalized.endsWith('.home.arpa')
)
}
export const isSafeAggregateOAuthUrl = (value: string): boolean => {
try {
const url = new URL(value)
if (url.username || url.password) return false
if (url.protocol === 'https:') return true
if (url.protocol !== 'http:') return false
// AppKey 会出现在查询串中,公网 HTTP 会泄露密钥;可信内网部署仍允许显式使用 HTTP。
return isInternalHostname(url.hostname)
} catch {
return false
}
}
export const normalizeAggregateOAuthLoginTypes = (value: unknown): AggregateOAuthLoginType[] => {
let values: unknown[] = []
if (Array.isArray(value)) {
values = value
} else if (typeof value === 'string' && value.trim()) {
const normalized = value.trim()
try {
const parsed = JSON.parse(normalized)
values = Array.isArray(parsed) ? parsed : [parsed]
} catch {
values = normalized.split(',')
}
}
return [
...new Set(
values
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim().toLowerCase())
.filter((item): item is AggregateOAuthLoginType =>
AGGREGATE_OAUTH_LOGIN_TYPES.includes(item as AggregateOAuthLoginType)
)
)
]
}
export const getAggregateOAuthLoginTypesOrDefault = (value: unknown): AggregateOAuthLoginType[] => {
const normalized = normalizeAggregateOAuthLoginTypes(value)
const hasConfiguredValue =
(Array.isArray(value) && value.length > 0) ||
(typeof value === 'string' && value.trim().length > 0)
return normalized.length > 0 || hasConfiguredValue ? normalized : ['qq']
}
export const SUPPORTED_OAUTH_PROVIDERS: OAuthProvider[] = [
'github',
'casdoor',
'google',
'oauth2',
'aggregate'
]
export const isSupportedOAuthProvider = (provider: string): provider is OAuthProvider => {
return SUPPORTED_OAUTH_PROVIDERS.includes(provider as OAuthProvider)
}
+616
View File
@@ -0,0 +1,616 @@
import { createError } from 'h3'
import type { ProviderRuntimeConfig } from '~~/server/services/oauthConfigService'
import { isSafeAggregateOAuthUrl } from '~~/server/utils/oauth-providers'
const getObjectByPath = (source: any, path?: string): any => {
if (!source || !path) return undefined
return path
.split('.')
.reduce((acc, key) => (acc !== null && acc !== undefined ? acc[key] : undefined), source)
}
const firstStringValue = (values: any[]): string | undefined => {
for (const value of values) {
if (value === null || value === undefined) continue
if (typeof value === 'string' && value.trim()) return value.trim()
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
}
return undefined
}
const getAggregateProviderMessage = (value: any): string | undefined => {
const message = firstStringValue([value, value?.msg, value?.message, value?.error_description])
if (!message) return undefined
// CDN/WAF 挑战页不是聚合协议的业务错误,避免把整段 HTML 当成供应商消息记录。
if (/<html|just a moment|cloudflare|cf-chl-|enable javascript/i.test(message)) {
return undefined
}
return message
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.replace(
/["']?\b(app(?:id|key)|client_(?:id|secret))\b["']?\s*[:=:]\s*["']?[^\s,;"'&]+["']?/gi,
'$1=[已隐藏]'
)
.trim()
.slice(0, 200)
}
const isAggregateSecurityChallenge = (value: any): boolean => {
const message = firstStringValue([value, value?.msg, value?.message, value?.error_description])
return !!message && /<html|just a moment|cloudflare|cf-chl-|enable javascript/i.test(message)
}
const AGGREGATE_REQUEST_HEADERS = {
Accept: 'application/json',
// 部分聚合站点的 CDN 会将无 User-Agent 的服务端请求送入挑战页。
'User-Agent':
'Mozilla/5.0 (compatible; VoiceHub OAuth/1.0; +https://github.com/laoshuikaixue/VoiceHub)',
'Cache-Control': 'no-cache'
}
const fetchAggregateEndpoint = async (url: string): Promise<any> => {
return $fetch<any>(url, { headers: AGGREGATE_REQUEST_HEADERS })
}
const getAggregateAuthorizeErrorMessage = (providerMessage?: string): string =>
providerMessage ? `聚合登陆授权地址获取失败:${providerMessage}` : '聚合登陆授权地址获取失败'
const isAggregateSuccessCode = (value: unknown): boolean =>
value === 0 || (typeof value === 'string' && value.trim() === '0')
const normalizeAggregateEndpoint = (endpoint?: string): string => {
const value = endpoint?.trim()
if (!value) {
throw createError({ statusCode: 500, message: '聚合登录接口地址未配置,请在管理员后台完成配置' })
}
try {
const url = new URL(value)
if (!isSafeAggregateOAuthUrl(url.toString())) {
throw new Error('unsupported protocol')
}
url.search = ''
url.hash = ''
url.pathname = url.pathname.replace(/\/$/, '')
return url.toString().replace(/\/$/, '')
} catch (e: any) {
if (e?.statusCode) throw e
throw createError({ statusCode: 500, message: '聚合登陆接口地址配置错误' })
}
}
export interface OAuthUserInfo {
id: string
username: string
email?: string
name?: string
avatar?: string
}
export interface OAuthStrategy {
/**
* 获取授权跳转 URL
*/
getAuthorizeUrl(
redirectUri: string,
state: string,
config?: ProviderRuntimeConfig
): string | Promise<string>
/**
* 使用 code 换取 access_token
*/
exchangeToken(code: string, redirectUri: string, config?: ProviderRuntimeConfig): Promise<string>
/**
* 获取用户信息
*/
getUserInfo(accessToken: string, config?: ProviderRuntimeConfig): Promise<OAuthUserInfo>
}
// GitHub 策略实现
const githubStrategy: OAuthStrategy = {
getAuthorizeUrl(redirectUri: string, state: string, config?: ProviderRuntimeConfig) {
const clientId = config?.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, config?: ProviderRuntimeConfig) {
const clientId = config?.clientId || process.env.GITHUB_CLIENT_ID
const clientSecret = config?.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, config?: ProviderRuntimeConfig) {
const clientId = config?.clientId || process.env.CASDOOR_CLIENT_ID
const endpoint = config?.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, config?: ProviderRuntimeConfig) {
const clientId = config?.clientId || process.env.CASDOOR_CLIENT_ID
const clientSecret = config?.clientSecret || process.env.CASDOOR_CLIENT_SECRET
const endpoint = config?.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, config?: ProviderRuntimeConfig) {
const endpoint = config?.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, config?: ProviderRuntimeConfig) {
const clientId = config?.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, config?: ProviderRuntimeConfig) {
const clientId = config?.clientId || process.env.GOOGLE_CLIENT_ID
const clientSecret = config?.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 aggregateOAuthStrategy: OAuthStrategy = {
async getAuthorizeUrl(redirectUri: string, state: string, config?: ProviderRuntimeConfig) {
const appid = config?.clientId
const appkey = config?.clientSecret
const loginType = config?.loginType?.trim() || 'qq'
const endpoint = normalizeAggregateEndpoint(config?.endpoint)
if (!appid || !appkey) {
throw createError({ statusCode: 500, message: '聚合登陆配置缺失' })
}
const params = new URLSearchParams({
act: 'login',
appid,
appkey,
type: loginType,
redirect_uri: redirectUri,
state
})
let loginResponse: any
try {
loginResponse = await fetchAggregateEndpoint(`${endpoint}?${params.toString()}`)
} catch (e: any) {
const responseBody = e?.data || e?.response?._data
const providerMessage = getAggregateProviderMessage(responseBody)
// AppKey 位于协议规定的查询串中,避免记录可能包含完整请求 URL 的 FetchError。
console.error('聚合登陆授权地址请求失败', {
statusCode: e?.response?.status || e?.statusCode || e?.status || 'unknown',
providerMessage,
securityChallenge: isAggregateSecurityChallenge(responseBody)
})
if (isAggregateSecurityChallenge(responseBody)) {
throw createError({
statusCode: 502,
message:
'聚合登录供应商的安全防护拦截了服务器请求,请将服务器出口 IP 加白或更换供应商接口地址'
})
}
throw createError({
statusCode: 502,
message: getAggregateAuthorizeErrorMessage(providerMessage)
})
}
if (!isAggregateSuccessCode(loginResponse?.code) || !loginResponse?.url) {
if (isAggregateSecurityChallenge(loginResponse)) {
throw createError({
statusCode: 502,
message:
'聚合登录供应商的安全防护拦截了服务器请求,请将服务器出口 IP 加白或更换供应商接口地址'
})
}
const providerMessage = getAggregateProviderMessage(loginResponse)
// 上游返回的失败原因需落日志,便于管理员排查未开通/配置错误等问题
console.error('聚合登陆授权地址获取失败', { providerMessage })
throw createError({
statusCode: 502,
message: getAggregateAuthorizeErrorMessage(providerMessage)
})
}
try {
const authorizeUrl = new URL(loginResponse.url)
if (!isSafeAggregateOAuthUrl(authorizeUrl.toString())) {
throw new Error('unsupported protocol')
}
return authorizeUrl.toString()
} catch {
throw createError({ statusCode: 502, message: '聚合登陆返回了无效的授权地址' })
}
},
async exchangeToken(code: string, redirectUri: string, config?: ProviderRuntimeConfig) {
const appid = config?.clientId
const appkey = config?.clientSecret
const loginType = config?.loginType?.trim() || 'qq'
const endpoint = normalizeAggregateEndpoint(config?.endpoint)
if (!appid || !appkey) {
throw createError({ statusCode: 500, message: '聚合登陆配置缺失' })
}
const params = new URLSearchParams({
act: 'callback',
appid,
appkey,
type: loginType,
code
})
let tokenResponse: any
try {
tokenResponse = await fetchAggregateEndpoint(`${endpoint}?${params.toString()}`)
} catch (e: any) {
// AppKey 位于协议规定的查询串中,避免记录或继续抛出完整请求 URL。
const responseBody = e?.data || e?.response?._data
const providerMessage = getAggregateProviderMessage(responseBody)
console.error('聚合登陆回调请求失败', {
statusCode: e?.response?.status || e?.statusCode || e?.status || 'unknown',
providerMessage,
securityChallenge: isAggregateSecurityChallenge(responseBody)
})
if (isAggregateSecurityChallenge(responseBody)) {
throw new Error(
'聚合登录供应商的安全防护拦截了服务器请求,请将服务器出口 IP 加白或更换供应商接口地址',
{ cause: e }
)
}
throw new Error(providerMessage ? `令牌请求失败:${providerMessage}` : '令牌请求失败', {
cause: e
})
}
if (!isAggregateSuccessCode(tokenResponse?.code)) {
if (isAggregateSecurityChallenge(tokenResponse)) {
throw new Error(
'聚合登录供应商的安全防护拦截了服务器请求,请将服务器出口 IP 加白或更换供应商接口地址'
)
}
throw new Error(getAggregateProviderMessage(tokenResponse) || '授权失败,请重试')
}
if (!tokenResponse?.social_uid) {
throw new Error('聚合登陆响应缺少用户唯一标识')
}
return Buffer.from(JSON.stringify(tokenResponse), 'utf8').toString('base64url')
},
async getUserInfo(accessToken: string) {
try {
const userInfo = JSON.parse(Buffer.from(accessToken, 'base64url').toString('utf8'))
const id = firstStringValue([userInfo.social_uid])
if (!id) {
throw new Error('用户信息中缺少 social_uid')
}
return {
id,
username: firstStringValue([userInfo.nickname, userInfo.social_uid]) || id,
name: firstStringValue([userInfo.nickname]),
avatar: firstStringValue([userInfo.faceimg])
}
} catch (e: any) {
console.error('聚合登陆 user info failed', e)
throw new Error(e?.message || '获取用户信息失败')
}
}
}
const customOAuth2Strategy: OAuthStrategy = {
getAuthorizeUrl(redirectUri: string, state: string, config?: ProviderRuntimeConfig) {
const authorizeUrl = config?.authorizeUrl
const clientId = config?.clientId
const scope = config?.scope?.trim() || 'openid profile email'
if (!authorizeUrl || !clientId) {
throw createError({ statusCode: 500, message: 'Custom OAuth2 config missing' })
}
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope,
state
})
return `${authorizeUrl}${authorizeUrl.includes('?') ? '&' : '?'}${params.toString()}`
},
async exchangeToken(code: string, redirectUri: string, config?: ProviderRuntimeConfig) {
const tokenUrl = config?.tokenUrl
const clientId = config?.clientId
const clientSecret = config?.clientSecret
if (!tokenUrl || !clientId || !clientSecret) {
throw createError({ statusCode: 500, message: 'Custom OAuth2 config missing' })
}
try {
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri
})
const tokenResponse = await $fetch<any>(tokenUrl, {
method: 'POST',
body,
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
}
})
if (tokenResponse?.error) {
throw new Error(
tokenResponse.error_description || tokenResponse.error || '授权失败,请重试'
)
}
if (!tokenResponse?.access_token) {
throw new Error('未能获取访问令牌')
}
return tokenResponse.access_token
} catch (e: any) {
console.error('Custom OAuth2 token exchange failed', e.message || e)
const errorMessage = e?.data?.error_description || e?.message || '令牌请求失败'
throw new Error(errorMessage)
}
},
async getUserInfo(accessToken: string, config?: ProviderRuntimeConfig) {
const userInfoUrl = config?.userInfoUrl
if (!userInfoUrl) {
throw createError({ statusCode: 500, message: 'Custom OAuth2 config missing' })
}
try {
const userInfo = await $fetch<any>(userInfoUrl, {
headers: { Authorization: `Bearer ${accessToken}` }
})
const id = firstStringValue([
getObjectByPath(userInfo, config?.userIdField),
getObjectByPath(userInfo, 'id'),
getObjectByPath(userInfo, 'sub')
])
if (!id) {
throw new Error('用户信息中缺少唯一标识字段,请配置用户 ID 字段')
}
const username = firstStringValue([
getObjectByPath(userInfo, config?.usernameField),
getObjectByPath(userInfo, 'username'),
getObjectByPath(userInfo, 'preferred_username'),
getObjectByPath(userInfo, 'email'),
getObjectByPath(userInfo, 'name'),
id
])
return {
id,
username: username || id,
name: firstStringValue([
getObjectByPath(userInfo, config?.nameField),
getObjectByPath(userInfo, 'name')
]),
email: firstStringValue([
getObjectByPath(userInfo, config?.emailField),
getObjectByPath(userInfo, 'email')
]),
avatar: firstStringValue([
getObjectByPath(userInfo, config?.avatarField),
getObjectByPath(userInfo, 'avatar'),
getObjectByPath(userInfo, 'picture')
])
}
} catch (e: any) {
console.error('Custom OAuth2 user info failed', e)
throw new Error(e?.message || '获取用户信息失败')
}
}
}
// 策略注册表
const strategies: Record<string, OAuthStrategy> = {
github: githubStrategy,
casdoor: casdoorStrategy,
google: googleStrategy,
oauth2: customOAuth2Strategy,
aggregate: aggregateOAuthStrategy
}
export const getOAuthStrategy = (provider: string): OAuthStrategy => {
const strategy = strategies[provider]
if (!strategy) {
throw createError({ statusCode: 400, message: `Unknown provider: ${provider}` })
}
return strategy
}
+23
View File
@@ -0,0 +1,23 @@
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
avatar?: 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
}
+307
View File
@@ -0,0 +1,307 @@
import CryptoJS from 'crypto-js'
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { createError } from 'h3'
export interface OAuthState {
target: string
csrf: string
timestamp: number
provider?: string
returnTo?: string
loginType?: string
}
export const getSafeOAuthReturnPath = (value: unknown): string | undefined => {
const path = Array.isArray(value) ? value[0] : value
return typeof path === 'string' &&
path.startsWith('/') &&
!path.startsWith('//') &&
!path.startsWith('/\\')
? path
: undefined
}
const normalizePort = (url: URL): string => {
if (url.port) return url.port
if (url.protocol === 'https:') return '443'
if (url.protocol === 'http:') return '80'
return ''
}
const OAUTH_STATE_COOKIE_PREFIX = 'b64url:'
export interface OAuthStateCookieNames {
csrf: string
fullState: string
compactState: string
}
export const LEGACY_OAUTH_STATE_COOKIE_NAMES: OAuthStateCookieNames = {
csrf: 'oauth_csrf',
fullState: 'oauth_full_state',
compactState: 'oauth_compact_state'
}
export const getOAuthStateCookieNames = (compactState?: string): OAuthStateCookieNames => {
if (!compactState) return LEGACY_OAUTH_STATE_COOKIE_NAMES
const suffix = createHash('sha256').update(compactState).digest('hex').slice(0, 24)
return {
csrf: `oauth_csrf_${suffix}`,
fullState: `oauth_full_state_${suffix}`,
compactState: `oauth_compact_state_${suffix}`
}
}
// AES state 是标准 Base64,Cookie 中只需转换为 URL 安全格式以避免 +、/、= 被改写。
export const encodeOAuthStateCookie = (state: string): string =>
`${OAUTH_STATE_COOKIE_PREFIX}${Buffer.from(state, 'base64').toString('base64url')}`
export const decodeOAuthStateCookie = (state: string): string => {
if (!state.startsWith(OAUTH_STATE_COOKIE_PREFIX)) {
return state
}
const encodedState = state.slice(OAUTH_STATE_COOKIE_PREFIX.length)
if (!encodedState || !/^[A-Za-z0-9_-]+$/.test(encodedState)) {
return ''
}
try {
const decodedState = Buffer.from(encodedState, 'base64url')
if (decodedState.toString('base64url') !== encodedState) {
return ''
}
return decodedState.toString('base64')
} catch {
return ''
}
}
// 生成 OAuth 状态参数
export const generateState = (
targetOrigin: string,
provider?: string,
secretKey?: string,
returnTo?: string,
loginType?: string
): { state: string; csrf: string } => {
if (!secretKey) {
throw createError({
statusCode: 500,
message: 'OAuth State 密钥未配置,请在管理员后台配置 OAuth State 密钥'
})
}
const csrf = randomBytes(32).toString('hex')
const payload: OAuthState = {
target: targetOrigin,
csrf,
timestamp: Date.now(),
provider,
returnTo: getSafeOAuthReturnPath(returnTo),
loginType
}
const json = JSON.stringify(payload)
const state = CryptoJS.AES.encrypt(json, secretKey).toString()
return { state, csrf }
}
// 聚合登录服务会把 state 截断为 100 字符,因此 Broker 只接收携带源站的可验签短路由信息。
// 完整状态保存在 HttpOnly Cookie 中,回调时仍会校验 Origin、CSRF 和过期时间。
export const generateCompactOAuthState = (targetOrigin: string, secretKey?: string): string => {
if (!secretKey) {
throw createError({ statusCode: 500, message: 'OAuth State 密钥未配置' })
}
const target = new URL(targetOrigin).origin
const encodedTarget = Buffer.from(target, 'utf8').toString('base64url')
const nonce = randomBytes(8).toString('base64url')
const payload = `v1.${encodedTarget}.${nonce}`
const signature = createHmac('sha256', secretKey)
.update(payload)
.digest()
.subarray(0, 12)
.toString('base64url')
const state = `${payload}.${signature}`
if (state.length > 100) {
throw createError({
statusCode: 400,
message: '当前站点地址过长,无法兼容聚合登录的 state 长度限制'
})
}
return state
}
// 校验紧凑 state 的 HMAC 签名,阻止伪造的 state 进入后续流程。
export const verifyCompactOAuthState = (state: string, secretKey?: string): boolean => {
if (!secretKey) {
throw createError({ statusCode: 500, message: 'OAuth State 密钥未配置' })
}
const lastDot = state.lastIndexOf('.')
if (lastDot <= 0 || !state.startsWith('v1.')) return false
try {
const payload = state.slice(0, lastDot)
const expected = createHmac('sha256', secretKey).update(payload).digest().subarray(0, 12)
const actual = Buffer.from(state.slice(lastDot + 1), 'base64url')
return actual.length === expected.length && timingSafeEqual(actual, expected)
} catch {
return false
}
}
// 解析 OAuth 状态参数
export const parseState = (
stateStr: string,
expectedOrigin?: string,
expectedCsrf?: string,
secretKey?: string
): OAuthState | null => {
if (!secretKey) {
throw createError({
statusCode: 500,
message: 'OAuth State 密钥未配置,请在管理员后台配置 OAuth State 密钥'
})
}
// 修复 base64 中的 '+' 在 URL 传输中被错误解析为空格的问题
// 某些 OAuth 提供商或浏览器环境可能会导致 URL 查询参数中的 '+' 号变成空格
const sanitizedStateStr = stateStr.replace(/ /g, '+')
try {
const bytes = CryptoJS.AES.decrypt(sanitizedStateStr, secretKey)
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 - 在Codespaces等环境中,两次请求可能使用不同的host
// 所以我们只验证protocol相同,但允许不同的subdomain
if (expectedOrigin && payload.target) {
try {
const expectedUrl = new URL(expectedOrigin)
const payloadUrl = new URL(payload.target)
// 如果protocol不匹配,拒绝
if (expectedUrl.protocol !== payloadUrl.protocol) {
console.error(
`OAuth state protocol mismatch: ${expectedUrl.protocol} vs ${payloadUrl.protocol}`
)
return null
}
// 判断是否为相同的源站
// 使用 hostname 避免端口导致误判,端口单独比较
const expectedHost = expectedUrl.hostname
const payloadHost = payloadUrl.hostname
const expectedPort = normalizePort(expectedUrl)
const payloadPort = normalizePort(payloadUrl)
// hostname 相同时也要校验端口一致,避免同 host 不同端口被接受
if (expectedHost === payloadHost) {
if (expectedPort !== payloadPort) {
console.error(`OAuth state port mismatch: ${expectedPort} vs ${payloadPort}`)
return null
}
} else {
// host 不同 - 检查是否为已知的需要兼容的环境
// 仅在 *.github.dev(Codespaces 环境)中允许不同的子域名
const isExpectedGitHubDev =
expectedHost.endsWith('.github.dev') ||
expectedHost === 'localhost' ||
expectedHost === '127.0.0.1'
const isPayloadGitHubDev =
payloadHost.endsWith('.github.dev') ||
payloadHost === 'localhost' ||
payloadHost === '127.0.0.1'
if (isExpectedGitHubDev && isPayloadGitHubDev) {
// 在 Codespaces 等环境中,允许子域名变化(例如端口号变化导致的 subdomain 变化)
// 但仍需要验证基础域名相同(.github.dev)
const expectedParts = expectedHost.split('.')
const payloadParts = payloadHost.split('.')
// 对于 localhost, 要求完全相同
if (expectedHost === 'localhost' || payloadHost === 'localhost') {
if (expectedHost !== payloadHost || expectedPort !== payloadPort) {
console.error(
`OAuth state host/port mismatch (localhost): ${expectedHost}:${expectedPort} vs ${payloadHost}:${payloadPort}`
)
return null
}
} else if (expectedHost === '127.0.0.1' || payloadHost === '127.0.0.1') {
// 对于 127.0.0.1,也要求完全相同
if (expectedHost !== payloadHost || expectedPort !== payloadPort) {
console.error(
`OAuth state host/port mismatch (127.0.0.1): ${expectedHost}:${expectedPort} vs ${payloadHost}:${payloadPort}`
)
return null
}
} else {
// 对于 *.github.dev,允许不同的子域名,但要求基础部分相同
// 例如:crispy-funicular-q77p479p56r9hx7vr-3000.app.github.dev
// 和 crispy-funicular-q77p479p56r9hx7vr.app.github.dev 都可以通过
const expectedBase = expectedParts.slice(-3).join('.')
const payloadBase = payloadParts.slice(-3).join('.')
if (expectedBase !== payloadBase) {
console.error(`OAuth state domain mismatch: ${expectedBase} vs ${payloadBase}`)
return null
}
}
} else {
// 对于非白名单环境,要求 host 完全相同
console.error(`OAuth state host mismatch: ${expectedHost} vs ${payloadHost}`)
return null
}
}
} catch (e) {
console.error('Failed to parse OAuth state URLs', e)
return null
}
}
// 验证 CSRF
if (!expectedCsrf || payload.csrf !== expectedCsrf) {
console.error('OAuth state CSRF mismatch or missing cookie')
return null
}
payload.returnTo = getSafeOAuthReturnPath(payload.returnTo)
return payload
} catch {
// 解密失败通常来自密钥不一致或客户端状态损坏,不应作为服务端异常上报。
console.warn('OAuth state 解密失败:state 已损坏、被截断或 OAuth State 密钥不一致')
return null
}
}
export const getRedirectUri = (provider: string, redirectUriTemplate?: string): string => {
let redirectUri = redirectUriTemplate
if (!redirectUri) {
throw createError({
statusCode: 500,
message: 'OAuth 重定向 URI 未配置,请在管理员后台配置 OAuth 重定向 URI'
})
}
// 支持 [provider] 占位符
redirectUri = redirectUri.replace('[provider]', provider)
// 兼容用户可能错误地将 "provider" 作为字面量填写的情况
if (redirectUri.includes('/provider/callback')) {
redirectUri = redirectUri.replace('/provider/callback', `/${provider}/callback`)
}
return redirectUri
}
+114
View File
@@ -0,0 +1,114 @@
// 硬编码的四个角色配置
const ROLES = {
USER: {
displayName: '普通用户',
description: '普通用户,只能访问主页功能',
pages: [], // 不能访问后台页面
canAccessAdmin: false
},
SONG_ADMIN: {
displayName: '歌曲管理员',
description: '负责歌曲和排期管理',
pages: [
'overview', // 数据概览
'schedule', // 排期管理
'print', // 打印排期
'songs', // 歌曲管理
'playtimes', // 播出时段
'request-times', // 投稿时段
'semesters', // 学期管理
'data-analysis' // 数据分析
],
canAccessAdmin: true
},
ADMIN: {
displayName: '管理员',
description: '系统管理员,拥有大部分权限',
pages: [
'overview', // 数据概览
'schedule', // 排期管理
'print', // 打印排期
'songs', // 歌曲管理
'users', // 用户管理
'playtimes', // 播出时段
'request-times', // 投稿时段
'semesters', // 学期管理
'data-analysis' // 数据分析
],
canAccessAdmin: true
},
SUPER_ADMIN: {
displayName: '超级管理员',
description: '拥有所有权限的超级管理员',
pages: [
'overview', // 数据概览
'schedule', // 排期管理
'print', // 打印排期
'songs', // 歌曲管理
'users', // 用户管理
'notifications', // 通知管理
'playtimes', // 播出时段
'request-times', // 投稿时段
'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 }
+269
View File
@@ -0,0 +1,269 @@
const MAX_REPLY_DEPTH = 8
export const buildQqCommentRequestParam = ({
topid,
cursor,
page,
pageSize,
type
}: {
topid: string
cursor: string
page: number
pageSize: number
type: 'hot' | 'latest'
}) => ({
BizType: 1,
BizId: topid,
LastCommentSeqNo: cursor,
PageSize: pageSize,
PageNum: page,
PicEnable: 1,
...(type === 'hot'
? { HotType: 1, WithAirborne: 0 }
: { HashTagID: '', SelfSeeEnable: 1, AudioEnable: 1 })
})
const optionalString = (value: unknown): string => {
if (typeof value !== 'string') return ''
return value.trim()
}
// QQ 评论正文的换行以字面量 \n 两个字符返回,需还原为真实换行
const unescapeLiteralNewlines = (text: string) => text.replace(/\\r\\n|\\n|\\r/g, '\n')
const normalizeImageUrl = (value: unknown): string => {
const text = optionalString(value).replace(/&amp;/gi, '&')
if (!text) return ''
if (text.startsWith('//')) return `https:${text}`
if (text.startsWith('http://')) return `https://${text.slice(7)}`
return /^https:\/\//i.test(text) ? text : ''
}
const collectImageUrls = (value: unknown): string[] => {
const values = Array.isArray(value) ? value : [value]
const urls = values
.map((item) => {
if (typeof item === 'string') return normalizeImageUrl(item)
if (!item || typeof item !== 'object') return ''
const image = item as Record<string, unknown>
return normalizeImageUrl(
image.url || image.picurl || image.picUrl || image.imageurl || image.imageUrl
)
})
.filter(Boolean)
return [...new Set(urls)]
}
const getCommentId = (item: Record<string, unknown>): string =>
optionalString(item.CmId ?? item.commentId ?? item.commentid ?? item.id)
const getRootCommentId = (item: Record<string, unknown>): string => {
const root =
item.RootCmId ??
item.RootCommentId ??
item.rootCommentId ??
item.rootcommentid ??
item.rootid ??
item.RootComment ??
item.rootComment
if (root && typeof root === 'object') return getCommentId(root as Record<string, unknown>)
return optionalString(root)
}
const getParentCommentId = (item: Record<string, unknown>): string =>
(() => {
const parent =
item.ParentCmId ??
item.ParentCommentId ??
item.parentCommentId ??
item.parentcommentid ??
item.parentid ??
item.ParentComment
if (parent && typeof parent === 'object') return getCommentId(parent)
return optionalString(parent)
})()
const getNestedReplies = (item: Record<string, unknown>): Array<Record<string, unknown>> => {
const replies = [
...(Array.isArray(item.RepliedComments) ? item.RepliedComments : []),
...(Array.isArray(item.SubComments) ? item.SubComments : []),
...(Array.isArray(item.SubCmListV1) ? item.SubCmListV1 : [])
]
const seen = new Set<string>()
return replies.filter((reply) => {
if (!reply || typeof reply !== 'object') return false
const id = getCommentId(reply)
if (id && seen.has(id)) return false
if (id) seen.add(id)
return true
})
}
const normalizeTimestamp = (value: unknown): number => {
const timestamp = Number(value)
if (!Number.isFinite(timestamp) || timestamp <= 0) return 0
return timestamp >= 1_000_000_000_000 ? timestamp : timestamp * 1000
}
export interface QqCommentItem {
commentId: string
content: string
time: number
likedCount: number
liked: boolean
replyCount: number
images: string[]
emojis: string[]
replies: QqCommentItem[]
user: {
nickname: string
avatarUrl: string
location: string
}
}
export const normalizeQqComment = (
raw: unknown,
parentIds = new Set<string>(),
depth = 0
): QqCommentItem | null => {
if (!raw || typeof raw !== 'object' || depth > MAX_REPLY_DEPTH) return null
const item = raw as Record<string, unknown>
const commentId = getCommentId(item)
const content = unescapeLiteralNewlines(
optionalString(item.Content ?? item.content ?? item.middlecommentcontent ?? item.commentcontent)
)
if (!commentId || !content || parentIds.has(commentId)) return null
const nextParentIds = new Set(parentIds)
nextParentIds.add(commentId)
const replyIds = new Set<string>()
const replies = getNestedReplies(item)
.map((reply) => normalizeQqComment(reply, nextParentIds, depth + 1))
.filter((reply): reply is QqCommentItem => {
if (!reply || replyIds.has(reply.commentId)) return false
replyIds.add(reply.commentId)
return true
})
const userSource = item.userinfo || item.user
const user =
userSource && typeof userSource === 'object'
? (userSource as Record<string, unknown>)
: {}
return {
commentId,
content,
time: normalizeTimestamp(item.PubTime ?? item.time),
likedCount: Number(item.PraiseNum ?? item.praisenum ?? item.likedCount ?? 0) || 0,
liked: Boolean(item.IsPraised ?? item.liked),
replyCount: Number(item.ReplyCnt ?? item.replyCount ?? replies.length) || replies.length,
images: collectImageUrls(item.Pic ?? item.pic ?? item.images),
emojis: collectImageUrls(item.EmoPic ?? item.emoPic),
replies,
user: {
nickname:
optionalString(item.Nick ?? item.nick ?? item.nickname ?? user.nickname) || 'QQ 音乐用户',
avatarUrl: normalizeImageUrl(item.Avatar ?? item.avatar ?? item.avatarurl ?? user.avatarUrl),
location: optionalString(item.Location ?? item.location ?? user.location)
}
}
}
const collectReplyIds = (items: QqCommentItem[], result = new Set<string>()): Set<string> => {
for (const item of items) {
for (const reply of item.replies) {
result.add(reply.commentId)
collectReplyIds(reply.replies, result)
}
}
return result
}
export const normalizeQqCommentList = (rawItems: unknown): QqCommentItem[] => {
if (!Array.isArray(rawItems)) return []
const candidates = rawItems
.map((raw) => {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const item = normalizeQqComment(source)
if (!item) return null
return {
item,
rootId: getRootCommentId(source),
parentId: getParentCommentId(source)
}
})
.filter((candidate): candidate is { item: QqCommentItem; rootId: string; parentId: string } =>
Boolean(candidate)
)
const normalizedById = new Map<string, QqCommentItem>()
for (const { item } of candidates) {
if (!normalizedById.has(item.commentId)) normalizedById.set(item.commentId, item)
}
const explicitReplyIds = new Set<string>()
for (const { item, rootId, parentId } of candidates) {
const targetId = rootId || parentId
if (!targetId || targetId === item.commentId) continue
explicitReplyIds.add(item.commentId)
const target = normalizedById.get(targetId)
if (!target || target.commentId === item.commentId) continue
if (collectReplyIds([item]).has(target.commentId)) continue
const existingReplyIds = collectReplyIds([target])
if (!existingReplyIds.has(item.commentId)) {
target.replies.push(item)
target.replyCount = Math.max(target.replyCount, target.replies.length)
}
}
const normalized = [...normalizedById.values()]
const nestedReplyIds = collectReplyIds(normalized)
const rootIds = new Set<string>()
return normalized.filter((item) => {
if (
explicitReplyIds.has(item.commentId) ||
nestedReplyIds.has(item.commentId) ||
rootIds.has(item.commentId)
) {
return false
}
rootIds.add(item.commentId)
return true
})
}
export const normalizeQqCommentPage = (rawItems: unknown) => {
const items = Array.isArray(rawItems) ? rawItems : []
const ids = new Set(
items
.filter((item) => item && typeof item === 'object')
.map((item) => getCommentId(item as Record<string, unknown>))
.filter(Boolean)
)
const orphanReplies = items
.map((raw) => {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const rootCommentId = getRootCommentId(source) || getParentCommentId(source)
const commentId = getCommentId(source)
if (!rootCommentId || rootCommentId === commentId || ids.has(rootCommentId)) return null
const item = normalizeQqComment(source)
return item ? { ...item, rootCommentId } : null
})
.filter((item): item is QqCommentItem & { rootCommentId: string } => Boolean(item))
return {
comments: normalizeQqCommentList(items),
orphanReplies
}
}
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
import { getServerTimestamp } from './serverTime'
// 简易的内存限流器实现,带有最大容量限制防止内存溢出
// 如果项目扩展为多实例/Serverless,建议替换为 Redis 或基于数据库的实现
interface RateLimitRecord {
count: number
resetTime: number
}
const MAX_STORE_SIZE = 10000 // 最大允许存储的IP记录数
const REDIS_ERROR_LOG_INTERVAL_MS = 60 * 1000
const store = new Map<string, RateLimitRecord>()
let lastRedisErrorLogTime = 0
const DISTRIBUTED_RATE_LIMIT_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
local ttl = redis.call('PTTL', KEYS[1])
if count == 1 or ttl < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
ttl = tonumber(ARGV[1])
end
return { count, ttl }
`
/**
* 检查并增加限流计数
* @param key 限流的唯一键 (如 IP、用户名等)
* @param limit 在指定时间窗口内允许的最大请求数
* @param windowMs 时间窗口大小(毫秒)
* @returns { isAllowed: boolean, remaining: number, resetTime: number }
*/
export function checkRateLimit(
key: string,
limit: number,
windowMs: number
): { isAllowed: boolean; remaining: number; resetTime: number } {
const now = getServerTimestamp()
let record = store.get(key)
if (!record || now > record.resetTime) {
// 防止内存溢出:如果达到上限,清理部分旧数据
if (store.size >= MAX_STORE_SIZE) {
cleanupRateLimits(true)
}
// 记录不存在或已过期,重置计数
record = {
count: 1,
resetTime: now + windowMs
}
store.set(key, record)
return { isAllowed: true, remaining: limit - 1, resetTime: record.resetTime }
}
// 记录未过期,增加计数
record.count++
const remaining = Math.max(0, limit - record.count)
const isAllowed = record.count <= limit
return { isAllowed, remaining, resetTime: record.resetTime }
}
/**
* 优先使用 Redis 执行跨实例限流,Redis 不可用时回退到当前进程内存。
* 适用于需要在多实例或 Serverless 环境中保持一致计数的敏感接口。
*/
export async function checkDistributedRateLimit(
key: string,
limit: number,
windowMs: number
): Promise<{ isAllowed: boolean; remaining: number; resetTime: number }> {
try {
const redisModule = await import('./redis')
const client = await redisModule.getRedisClient()
if (client) {
const redisKey = redisModule.buildRedisKey('ratelimit', key)
const result = await client.eval(DISTRIBUTED_RATE_LIMIT_SCRIPT, {
keys: [redisKey],
arguments: [String(windowMs)]
})
if (!Array.isArray(result) || result.length < 2) {
throw new Error('Redis 限流脚本返回了无效结果')
}
const count = Number(result[0])
const ttl = Math.max(0, Number(result[1]))
if (!Number.isFinite(count) || !Number.isFinite(ttl)) {
throw new Error('Redis 限流脚本返回了无效计数')
}
return {
isAllowed: count <= limit,
remaining: Math.max(0, limit - count),
resetTime: getServerTimestamp() + ttl
}
}
if (redisModule.isRedisConfigured()) {
throw new Error('Redis 分布式限流服务当前不可用')
}
} catch (error) {
const now = Date.now()
if (now - lastRedisErrorLogTime >= REDIS_ERROR_LOG_INTERVAL_MS) {
console.error('[RateLimit] Redis 限流失败,已回退到内存限流', error)
lastRedisErrorLogTime = now
}
}
return checkRateLimit(key, limit, windowMs)
}
/**
* 清理过期的限流记录
* @param force 如果为 true,在没有过期数据时也会强制删除最旧的记录
*/
export function cleanupRateLimits(force = false) {
const now = getServerTimestamp()
let deletedCount = 0
for (const [key, record] of store.entries()) {
if (now > record.resetTime) {
store.delete(key)
deletedCount++
}
}
// 如果强制清理且没有过期数据可删,则一次性删除 10% 的最早插入记录
// 避免高并发下 Map 始终满载导致频繁触发清理逻辑
if (force && deletedCount === 0 && store.size > 0) {
const itemsToDelete = Math.ceil(MAX_STORE_SIZE * 0.1)
const keys = store.keys()
for (let i = 0; i < itemsToDelete; i++) {
const key = keys.next().value
if (key !== undefined) {
store.delete(key)
} else {
break
}
}
}
}
// 尝试设置定时清理器,但在 Serverless 环境中可能不会按预期工作,所以主要依赖惰性清理
if (typeof setInterval !== 'undefined') {
setInterval(() => cleanupRateLimits(false), 60 * 60 * 1000)
}
+105
View File
@@ -0,0 +1,105 @@
import type { RedisClientType } from 'redis'
import { createClient } from 'redis'
const DEFAULT_KEY_PREFIX = 'voicehub:v2:'
const CONNECT_TIMEOUT_MS = 5000
const MAX_CONNECT_RETRIES = 2
let client: RedisClientType | null = null
let connectPromise: Promise<RedisClientType | null> | null = null
let lastError: string | null = null
let lastConnectedAt: Date | null = null
export const isRedisConfigured = () => Boolean(process.env.REDIS_URL?.trim())
export const getRedisKeyPrefix = () => {
const configuredPrefix = process.env.REDIS_KEY_PREFIX?.trim()
const prefix = configuredPrefix || DEFAULT_KEY_PREFIX
return prefix.endsWith(':') ? prefix : `${prefix}:`
}
export const buildRedisKey = (...parts: Array<string | number>) =>
`${getRedisKeyPrefix()}${parts.map((part) => String(part)).join(':')}`
const createRedisClient = () => {
const redisClient = createClient({
url: process.env.REDIS_URL,
socket: {
connectTimeout: CONNECT_TIMEOUT_MS,
reconnectStrategy: (retries) =>
retries >= MAX_CONNECT_RETRIES
? new Error('Redis 短期状态服务连接重试次数已用尽')
: Math.min(250 * 2 ** retries, 5000)
},
disableOfflineQueue: true
})
redisClient.on('ready', () => {
lastError = null
lastConnectedAt = new Date()
console.log('[Redis] 分布式短期状态服务已就绪')
})
redisClient.on('error', (error) => {
lastError = error.message
console.error('[Redis] 连接错误:', error.message)
})
redisClient.on('reconnecting', () => {
console.warn('[Redis] 正在重新连接短期状态服务')
})
return redisClient as RedisClientType
}
export async function getRedisClient(): Promise<RedisClientType | null> {
if (!isRedisConfigured()) return null
if (client?.isReady) return client
if (connectPromise) return await connectPromise
if (!client || !client.isOpen) {
client = createRedisClient()
}
connectPromise = (async () => {
try {
if (!client!.isOpen) {
await client!.connect()
}
return client!.isReady ? client : null
} catch (error: any) {
lastError = error?.message || String(error)
console.error('[Redis] 连接失败:', lastError)
if (client?.isOpen) client.destroy()
client = null
return null
} finally {
connectPromise = null
}
})()
return await connectPromise
}
export const isRedisReady = () => Boolean(client?.isReady)
export async function disconnectRedis(): Promise<void> {
if (!client?.isOpen) return
try {
await client.quit()
} catch {
client.destroy()
} finally {
client = null
connectPromise = null
}
}
export const getRedisStats = () => ({
configured: isRedisConfigured(),
connected: Boolean(client?.isReady),
keyPrefix: getRedisKeyPrefix(),
lastConnectedAt,
lastError
})
+35
View File
@@ -0,0 +1,35 @@
// 注册相关校验纯函数(本地注册与 OAuth 注册共用)
export const REMARK_MAX_LENGTH = 200
const hasText = (value: unknown): boolean => typeof value === 'string' && value.trim().length > 0
// 年级与班级必须成对填写,或全部留空
export const validateGradeClassPair = (
grade: unknown,
studentClass: unknown
): { code: string; message: string } | null => {
if (hasText(grade) !== hasText(studentClass)) {
return { code: 'AUTH_GRADE_CLASS_TOGETHER', message: '年级和班级需要同时选择,或全部留空' }
}
return null
}
// 站点开启"注册必须选择年级班级"时:年级或班级任一缺失即不满足必填要求
const isGradeClassMissing = (grade: unknown, studentClass: unknown): boolean => {
return !hasText(grade) || !hasText(studentClass)
}
// 注册年级班级判定统一入口:必填开启时任一缺失即拒绝,否则要求成对填写或全部留空
export const resolveGradeClassError = (
grade: unknown,
studentClass: unknown,
required = false
): { code: string; message: string } | null => {
if (required && isGradeClassMissing(grade, studentClass)) {
return { code: 'AUTH_GRADE_CLASS_REQUIRED', message: '注册时必须选择年级和班级' }
}
return validateGradeClassPair(grade, studentClass)
}
+44
View File
@@ -0,0 +1,44 @@
import { db, users } from '~/drizzle/db'
import { inArray } from 'drizzle-orm'
import { createBatchSystemNotifications } from '~~/server/services/notificationService'
import { SmtpService } from '~~/server/services/smtpService'
// 注册通知:待审核时站内通知所有管理员;有邮箱时邮件通知注册结果(异步,失败不影响主流程)
export async function notifyRegistration(
userId: number,
username: string,
name: string,
email: string,
requiresApproval: boolean
) {
try {
if (requiresApproval) {
const adminResult = await db
.select({ id: users.id })
.from(users)
.where(inArray(users.role, ['ADMIN', 'SUPER_ADMIN']))
const adminIds = adminResult.map((u) => u.id)
if (adminIds.length > 0) {
await createBatchSystemNotifications(
adminIds,
'新用户注册待审核',
`用户「${name}」(用户名:${username})提交了注册申请,请前往用户管理审核。`,
false
)
}
}
if (email) {
const smtpService = SmtpService.getInstance()
if (await smtpService.ensureInitialized()) {
await smtpService.renderAndSend(email, 'register', {
title: requiresApproval ? '注册申请已提交' : '注册成功',
message: requiresApproval
? `${name},您的注册申请已提交,请耐心等待管理员审核。`
: `${name},恭喜您注册成功,欢迎使用 VoiceHub!`
})
}
}
} catch (error) {
console.error('注册通知发送失败:', error)
}
}
+99
View File
@@ -0,0 +1,99 @@
import type { H3Event } from 'h3'
import { createError, getRequestHeaders, getRequestURL } from 'h3'
const getFirstForwardedValue = (value: string | undefined): string =>
value?.split(',')[0]?.trim() || ''
/**
* 安全地获取请求的协议(http 或 https)
* 正确处理 x-forwarded-proto 可能包含多个值的情况
* @param event H3Event
* @returns 'http' | 'https'
*/
export function getSafeRequestProtocol(event: H3Event): 'http' | 'https' {
const headers = getRequestHeaders(event)
const forwardedProto = getFirstForwardedValue(headers['x-forwarded-proto']?.toString())
const requestProto = getRequestURL(event).protocol.replace(/:$/, '').toLowerCase()
const normalizedForwardedProto = forwardedProto.toLowerCase().replace(/:$/, '')
// 非法的转发协议值不可信,忽略并回退到请求自身协议,避免误伤全局认证等调用方
if (normalizedForwardedProto && !['http', 'https'].includes(normalizedForwardedProto)) {
return requestProto === 'https' ? 'https' : 'http'
}
return (normalizedForwardedProto || requestProto) === 'https' ? 'https' : 'http'
}
/**
* 判断请求是否安全(即是否为 https)
* @param event H3Event
* @returns boolean
*/
export function isSecureRequest(event: H3Event): boolean {
return getSafeRequestProtocol(event) === 'https'
}
export interface OriginInfo {
origin: string
protocol: string
hostname: string
port: string
explicitPort: string
}
export function normalizeOrigin(value: string, fallbackProtocol: string): OriginInfo {
const normalizedValue = value.includes('://') ? value : `${fallbackProtocol}//${value}`
const url = new URL(normalizedValue)
const protocol = url.protocol
return {
origin: url.origin,
protocol,
hostname: url.hostname,
port: url.port || (protocol === 'https:' ? '443' : '80'),
explicitPort: url.port
}
}
export function isTrustedOrigin(source: OriginInfo, trusted: OriginInfo): boolean {
// 反代未透传外部协议时,允许同主机 HTTPS 访问按 HTTP 配置的来源
return (
source.hostname === trusted.hostname &&
source.explicitPort === trusted.explicitPort &&
(source.protocol === trusted.protocol ||
(source.protocol === 'https:' && trusted.protocol === 'http:'))
)
}
// 不带方括号的裸 IPv6 地址需补齐方括号才能通过 URL 解析
const normalizeHostForUrl = (host: string): string =>
host.includes(':') && !host.startsWith('[') && /^[0-9a-f:]+$/i.test(host) ? `[${host}]` : host
/**
* 获取请求的 Origin (包含协议和主机名)
* @param event H3Event
* @returns string 例如: https://example.com
*/
export function getRequestOrigin(event: H3Event): string {
const protocol = getSafeRequestProtocol(event)
const headers = getRequestHeaders(event)
const forwardedHost = getFirstForwardedValue(headers['x-forwarded-host']?.toString())
const host = forwardedHost || headers['host']?.toString() || getRequestURL(event).host
try {
const requestUrl = new URL(`${protocol}://${normalizeHostForUrl(host)}`)
if (
requestUrl.origin === 'null' ||
requestUrl.username ||
requestUrl.password ||
requestUrl.pathname !== '/' ||
requestUrl.search ||
requestUrl.hash
) {
throw new Error('invalid host')
}
return requestUrl.origin
} catch {
throw createError({ statusCode: 400, message: '请求 Host 无效' })
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { H3Event } from 'h3'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
export function requireSongAdmin(event: H3Event): void {
const user = event.context.user
if (!user) {
throw createApiError(401, SERVER_ERROR_CODES.AUTH_UNAUTHORIZED, '未授权访问')
}
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有歌曲管理员及以上权限才能执行此操作')
}
if (user.status !== 'active') {
throw createApiError(403, SERVER_ERROR_CODES.AUTH_ACCOUNT_CURRENTLY_UNAVAILABLE, '账号状态异常,无法执行此操作')
}
}
+71
View File
@@ -0,0 +1,71 @@
import { eq } from 'drizzle-orm'
import { scheduleSongPool, songs, users } from '~/drizzle/schema'
import { getServerDate } from '~~/server/utils/serverTime'
/**
* 恢复单条备选池记录(含 songId + addedBy 外键映射校验)
* 返回 void;歌曲不存在时记录警告并跳过
*/
export async function restoreScheduleSongPoolRecord(
tx: any,
record: any,
songIdMapping: Map<number, number>,
userIdMapping: Map<number, number>,
stats: { warnings: string[]; created?: number },
onInserted?: () => void
): Promise<void> {
let validPoolSongId = record.songId
const mappedPoolSongId = songIdMapping.get(record.songId)
if (mappedPoolSongId) {
validPoolSongId = mappedPoolSongId
} else {
const songExists = await tx
.select()
.from(songs)
.where(eq(songs.id, record.songId))
.limit(1)
if (songExists.length === 0) {
console.warn(`备选池记录的歌曲ID ${record.songId} 不存在,跳过此记录`)
stats.warnings.push(`备选池歌曲ID ${record.songId} 不存在`)
return
}
// mapping 未命中但歌曲存在时,使用数据库查到的 ID(merge 模式下可能已被重新分配)
validPoolSongId = songExists[0].id
}
let validPoolAddedBy = record.addedBy || null
if (validPoolAddedBy) {
const mappedAddedBy = userIdMapping.get(validPoolAddedBy)
if (mappedAddedBy) {
validPoolAddedBy = mappedAddedBy
} else {
const userExists = await tx
.select()
.from(users)
.where(eq(users.id, validPoolAddedBy))
.limit(1)
if (userExists.length === 0) {
validPoolAddedBy = null
}
}
}
let createdAt = getServerDate()
if (record.createdAt) {
const d = new Date(record.createdAt)
if (!Number.isNaN(d.getTime())) {
createdAt = d
} else {
console.warn(`备选池记录 createdAt "${record.createdAt}" 非法,使用当前时间`)
}
}
const poolData = {
songId: validPoolSongId,
addedBy: validPoolAddedBy,
createdAt
}
const inserted = await tx.insert(scheduleSongPool).values(poolData).onConflictDoNothing({ target: [scheduleSongPool.songId] }).returning()
if (inserted.length > 0) onInserted?.()
}
+125
View File
@@ -0,0 +1,125 @@
import { createHash, createHmac } from 'crypto'
/** S3 请求超时(毫秒) */
const S3_TIMEOUT = 120_000
/** AWS Signature V4 签名工具 */
export function sha256(data: string): string {
return createHash('sha256').update(data).digest('hex')
}
export function hmacSha256(key: Buffer | string, data: string): Buffer {
return createHmac('sha256', key).update(data).digest()
}
export function getSignatureKey(key: string, dateStamp: string, region: string, service: string): Buffer {
const kDate = hmacSha256(`AWS4${key}`, dateStamp)
const kRegion = hmacSha256(kDate, region)
const kService = hmacSha256(kRegion, service)
return hmacSha256(kService, 'aws4_request')
}
/** 上传文件到 S3 兼容存储 */
export async function uploadToS3(
endpoint: string,
bucket: string,
region: string,
accessKey: string,
secretKey: string,
key: string,
body: string | Buffer,
contentType: string = 'application/json'
): Promise<void> {
const url = new URL(endpoint)
const host = url.host
const service = 's3'
const now = new Date()
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
const dateStamp = amzDate.slice(0, 8)
const payloadHash = sha256(typeof body === 'string' ? body : body.toString())
const contentLength = Buffer.byteLength(typeof body === 'string' ? body : body).toString()
// 构建规范请求
const canonicalUri = `/${bucket}/${key}`
const canonicalQuerystring = ''
const canonicalHeaders = `content-type:${contentType}\nhost:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'
const canonicalRequest = `PUT\n${canonicalUri}\n${canonicalQuerystring}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`
// 构建待签名字符串
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${sha256(canonicalRequest)}`
// 计算签名
const signingKey = getSignatureKey(secretKey, dateStamp, region, service)
const signature = hmacSha256(signingKey, stringToSign).toString('hex')
// 构建 Authorization 头
const authorization = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
// 发送请求
const response = await fetch(`${endpoint.replace(/\/$/, '')}/${bucket}/${key}`, {
method: 'PUT',
headers: {
'Content-Type': contentType,
'Content-Length': contentLength,
'Host': host,
'x-amz-content-sha256': payloadHash,
'x-amz-date': amzDate,
'Authorization': authorization
},
body: typeof body === 'string' ? body : new Uint8Array(body),
signal: AbortSignal.timeout(S3_TIMEOUT)
})
if (!response.ok) {
const errorBody = await response.text().catch(() => '')
throw new Error(`S3 上传失败: ${response.status} ${response.statusText} - ${errorBody}`)
}
}
/** 从 S3 兼容存储删除文件 */
export async function deleteFromS3(
endpoint: string,
bucket: string,
region: string,
accessKey: string,
secretKey: string,
key: string
): Promise<void> {
const url = new URL(endpoint)
const host = url.host
const service = 's3'
const now = new Date()
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
const dateStamp = amzDate.slice(0, 8)
const payloadHash = sha256('')
const canonicalUri = `/${bucket}/${key}`
const canonicalHeaders = `host:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
const canonicalRequest = `DELETE\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${sha256(canonicalRequest)}`
const signingKey = getSignatureKey(secretKey, dateStamp, region, service)
const signature = hmacSha256(signingKey, stringToSign).toString('hex')
const authorization = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
const response = await fetch(`${endpoint.replace(/\/$/, '')}/${bucket}/${key}`, {
method: 'DELETE',
headers: {
'Host': host,
'x-amz-content-sha256': payloadHash,
'x-amz-date': amzDate,
'Authorization': authorization
},
signal: AbortSignal.timeout(S3_TIMEOUT)
})
if (!response.ok && response.status !== 404) {
const errorBody = await response.text().catch(() => '')
throw new Error(`S3 删除失败: ${response.status} ${response.statusText} - ${errorBody}`)
}
}
+129
View File
@@ -0,0 +1,129 @@
import { and, eq, sql } from 'drizzle-orm'
import { schedules, songReplayRequests } from '~/drizzle/schema'
interface FulfillReplayRequestsParams {
tx: any
songId: number
scheduleId: number
at: Date
/** 草稿排期显式绑定的申请 ID;未提供或已失效时回退到最早的待处理申请 */
preferredRequestId?: number | null
/** 重发排期前的旧绑定;无任何待处理申请时回写该绑定,避免重播标识丢失 */
fallbackRequestId?: number | null
}
interface FulfilledReplayRequests {
/** 排期最终绑定的申请 ID */
replayRequestId: number
/** 本次被履行的全部申请人(去重),用于发送通知 */
replayRequesterIds: number[]
}
interface RestoreReplayRequestsParams {
tx: any
songIds: number[]
at: Date
}
/**
* 发布排期时履行该歌曲的全部待处理重播申请,并把排期绑定到其中一条申请上。
* 歌曲被安排播放即视为所有申请人的诉求都已满足,避免残留永远无人处理的 PENDING 记录。
* 采用单条 UPDATE + RETURNING,避免先查后改窗口内新增的申请被履行却漏发通知。
* 若没有任何待处理申请:优先回写 fallbackRequestId(重发排期场景),否则清除失效绑定并返回 null。
*/
export async function fulfillReplayRequestsForSchedule({
tx,
songId,
scheduleId,
at,
preferredRequestId,
fallbackRequestId
}: FulfillReplayRequestsParams): Promise<FulfilledReplayRequests | null> {
const fulfilledRequests: Array<{ id: number; userId: number; createdAt: Date }> = await tx
.update(songReplayRequests)
.set({
status: 'FULFILLED',
updatedAt: at
})
.where(and(eq(songReplayRequests.songId, songId), eq(songReplayRequests.status, 'PENDING')))
.returning({
id: songReplayRequests.id,
userId: songReplayRequests.userId,
createdAt: songReplayRequests.createdAt
})
// 按先到先得排序,便于回退绑定最早的申请
fulfilledRequests.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
const earliestRequest = fulfilledRequests[0]
if (!earliestRequest) {
// 无待处理申请:重发排期时保留原绑定,否则清除草稿残留的失效绑定
const restoredBinding = fallbackRequestId || null
await tx
.update(schedules)
.set({ replayRequestId: restoredBinding, updatedAt: at })
.where(eq(schedules.id, scheduleId))
return restoredBinding ? { replayRequestId: restoredBinding, replayRequesterIds: [] } : null
}
// 优先使用草稿显式绑定的申请,否则绑定最早的待处理申请
const boundRequest =
(preferredRequestId &&
fulfilledRequests.find((request) => request.id === preferredRequestId)) ||
earliestRequest
await tx
.update(schedules)
.set({
replayRequestId: boundRequest.id,
updatedAt: at
})
.where(eq(schedules.id, scheduleId))
return {
replayRequestId: boundRequest.id,
replayRequesterIds: [...new Set(fulfilledRequests.map((request) => request.userId))]
}
}
/**
* 排期被删除或歌曲撤销已播放后,把重播申请恢复为待处理。
* insert-only 模型下同一 (song_id, user_id) 可能累积多条已履行记录,
* 仅恢复每人最新一条,且该用户已有待处理申请时跳过,避免违反部分唯一索引。
* 返回实际恢复的申请数量。
*/
export async function restoreReplayRequestsToPending({
tx,
songIds,
at
}: RestoreReplayRequestsParams): Promise<number> {
const validSongIds = songIds.filter((id) => Number.isInteger(id) && id > 0)
if (validSongIds.length === 0) {
return 0
}
// drizzle 原生 SQL 模板不能序列化 JS 数组和 Date(列为 timestamp 无时区),逐个参数化并传 ISO 字符串
const songIdList = sql.join(
validSongIds.map((id) => sql`${id}`),
sql`, `
)
const restored = await tx.execute(sql`
UPDATE song_replay_requests AS r
SET status = 'PENDING', updated_at = ${at.toISOString()}
WHERE r.id IN (
SELECT DISTINCT ON (song_id, user_id) id
FROM song_replay_requests
WHERE song_id IN (${songIdList}) AND status = 'FULFILLED'
ORDER BY song_id, user_id, created_at DESC
)
AND NOT EXISTS (
SELECT 1 FROM song_replay_requests p
WHERE p.song_id = r.song_id AND p.user_id = r.user_id AND p.status = 'PENDING'
)
RETURNING r.id
`)
return Array.isArray(restored) ? restored.length : Number(restored?.count || 0)
}
+13
View File
@@ -0,0 +1,13 @@
import { db } from '~/drizzle/db'
import { scheduleSongPool, songs } from '~/drizzle/schema'
import { eq, count } from 'drizzle-orm'
// 返回备选池有效记录数(innerJoin 排除歌曲已删除的孤立记录)
export const fetchPoolCount = async () => {
const result = await db
.select({ count: count() })
.from(scheduleSongPool)
.innerJoin(songs, eq(scheduleSongPool.songId, songs.id))
.limit(1)
return result[0]?.count || 0
}
+30
View File
@@ -0,0 +1,30 @@
import { db } from '~/drizzle/db'
import { sql } from 'drizzle-orm'
// 恢复显式 id 数据后同步所有自增序列,避免后续插入主键冲突(幂等)
export async function syncAllSequences(): Promise<void> {
await db.execute(sql`
DO $repair$
DECLARE
r record;
seqname text;
maxid bigint;
BEGIN
FOR r IN
SELECT c.relname AS tbl, a.attname AS col
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
WHERE n.nspname = 'public' AND c.relkind = 'r'
AND a.attnum > 0 AND NOT a.attisdropped
AND pg_get_expr(ad.adbin, ad.adrelid) LIKE 'nextval(%'
LOOP
seqname := pg_get_serial_sequence(quote_ident(r.tbl), r.col);
IF seqname IS NULL THEN CONTINUE; END IF;
EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.col, r.tbl) INTO maxid;
EXECUTE format('SELECT setval(%L, %s)', seqname, GREATEST(maxid, 1));
END LOOP;
END $repair$
`)
}
+18
View File
@@ -0,0 +1,18 @@
/**
* 服务器时间工具
*/
/** 获取服务端时间戳 (毫秒) */
export function getServerTimestamp(): number {
return Date.now()
}
/** 获取服务端 Date 对象 */
export function getServerDate(): Date {
return new Date()
}
// 绑定到 globalThis,供 isomorphic 代码(如 app/utils/timeUtils.ts)安全调用
if (typeof globalThis !== 'undefined') {
;(globalThis as any).getServerTimestamp = getServerTimestamp
}
+24
View File
@@ -0,0 +1,24 @@
import { getSystemSettingsCached } from './system-settings-helper'
/**
* 获取站点标题
*/
export async function getSiteTitle(): Promise<string> {
try {
const settings = await getSystemSettingsCached()
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 '***'
}
+39
View File
@@ -0,0 +1,39 @@
import { SONG_DURATION_MAX_SECONDS, SONG_DURATION_MIN_SECONDS } from '../config/constants.ts'
/** 时长差异容差(秒),差异不超过此值视为一致 */
export const DURATION_TOLERANCE_SECONDS = 5
export type SongDurationOutcome = 'keep' | 'clear' | 'fill' | 'nocheck'
export interface SongDurationDecision {
outcome: SongDurationOutcome
durationSeconds: number | null
}
/** 归一化已有时长:非法值或越出合法区间视为无时长 */
export function normalizeStoredDuration(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
if (!Number.isFinite(parsed)) return null
const seconds = Math.floor(parsed)
if (seconds < SONG_DURATION_MIN_SECONDS || seconds > SONG_DURATION_MAX_SECONDS) return null
return seconds
}
/** 时长决策:库中无值且平台可查则补齐,有值但与平台不符则清空 */
export function decideDurationOutcome(
storedDurationSeconds: number | null,
actualDurationSeconds: number | null,
toleranceSeconds: number = DURATION_TOLERANCE_SECONDS
): SongDurationDecision {
if (actualDurationSeconds === null) {
return { outcome: 'nocheck', durationSeconds: storedDurationSeconds }
}
if (storedDurationSeconds === null) {
return { outcome: 'fill', durationSeconds: actualDurationSeconds }
}
if (Math.abs(actualDurationSeconds - storedDurationSeconds) > toleranceSeconds) {
return { outcome: 'clear', durationSeconds: null }
}
return { outcome: 'keep', durationSeconds: storedDurationSeconds }
}
+2
View File
@@ -0,0 +1,2 @@
// 权威实现收敛到 app/utils/song-name-normalize(含繁→简归一化),此处仅保持原导入路径可用
export { normalizeForMatch } from '~~/app/utils/song-name-normalize'
+221
View File
@@ -0,0 +1,221 @@
/**
* 歌曲类型(语种/曲风)解析工具
* 语种/曲风仅在网易云与 QQ 音源可判定:网易云取歌曲百科(song_wiki_info),
* QQ 取详情接口 info.lan / info.genre 官网分类名(track_info 数值码兜底)
* 其他平台或无 musicId 的投稿一律返回 null(不参与类型黑名单匹配)
*/
import {
QQ_LANGUAGE_CODE_MAP,
QQ_GENRE_NAME_MAP,
BLACKLIST_LANGUAGE_VALUES,
BLACKLIST_GENRE_VALUES
} from '../config/constants.ts'
import {
createTxSongDetailBody,
normalizeTxMusicId,
txRequest,
TX_MUSICU_URL
} from './native_tx.ts'
export type SongTypes = {
languages: string[]
genres: string[]
}
// 主要语种集合(不含「其他」),用于「其他」黑名单值的兜底匹配
const KNOWN_LANGUAGES = new Set(
BLACKLIST_LANGUAGE_VALUES.filter((value) => value !== '其他')
)
// 主要曲风一级分类集合(不含「其他」)
const KNOWN_GENRES = new Set(
BLACKLIST_GENRE_VALUES.filter((value) => value !== '其他')
)
// 网易云语种同义词归一(存储值口径见 constants 的 BLACKLIST_LANGUAGE_VALUES)
const LANGUAGE_ALIASES: Record<string, string> = {
国语: '华语',
普通话: '华语'
}
// 语种文本拆分(网易云多语种以「、」等分隔,如「英语、韩语」)
export const normalizeLanguageLabels = (text: unknown): string[] => {
if (!text) return []
return String(text)
.split(/[、,,;;/\s]+/)
.map((part) => part.trim())
.filter(Boolean)
.map((part) => LANGUAGE_ALIASES[part] ?? part)
}
// 解析网易云歌曲百科响应(data.blocks[].rnData.blocks[].blockInfo.wikiSubElementVos)
export const parseNeteaseWikiTypes = (payload: any): SongTypes => {
const result: SongTypes = { languages: [], genres: [] }
const blocks = payload?.data?.blocks
if (!Array.isArray(blocks)) return result
for (const outer of blocks) {
const innerBlocks = outer?.rnData?.blocks
if (!Array.isArray(innerBlocks)) continue
for (const inner of innerBlocks) {
const items = inner?.blockInfo?.wikiSubElementVos
if (!Array.isArray(items)) continue
for (const item of items) {
if (item?.title === '语种' && typeof item.content === 'string') {
result.languages.push(...normalizeLanguageLabels(item.content))
} else if (item?.title === '曲风' && Array.isArray(item.wikiSubMetaVos)) {
for (const meta of item.wikiSubMetaVos) {
if (typeof meta?.text === 'string' && meta.text.trim()) {
result.genres.push(meta.text.trim())
}
}
}
}
}
}
return { languages: [...new Set(result.languages)], genres: [...new Set(result.genres)] }
}
// QQ 语种数字码 → 黑名单语种值(未收录码返回 null)
export const mapQqLanguage = (code: unknown): string | null => {
const normalized = Number(code)
if (!Number.isInteger(normalized)) return null
return QQ_LANGUAGE_CODE_MAP[normalized] ?? null
}
// 解析 QQ 歌曲详情响应(data.info.lan / data.info.genre 官网分类名 + track_info 数值码兜底)
// 流派英文名仅映射候选曲风,已判定但未收录的流派名归「其他」;无法判定返回空数组(放行)
export const parseTencentSongTypes = (payload: any): SongTypes => {
const result: SongTypes = { languages: [], genres: [] }
const data = payload?.req?.data ?? payload?.data
if (!data) return result
// 语种:info.lan 官网中文分类名优先,缺失时回退 track_info.language 数值码
const lanValues = Array.isArray(data.info?.lan?.content)
? data.info.lan.content
.map((item: any) => normalizeLanguageLabels(typeof item?.value === 'string' ? item.value.trim() : ''))
.flat()
: []
if (lanValues.length > 0) {
result.languages = lanValues
} else {
const language = mapQqLanguage(data.track_info?.language)
const rawCode = data.track_info?.language
// 已判定为整数但未收录的语种码归入「其他」;缺失/非整数视为无法判定
if (language) {
result.languages = [language]
} else if (
rawCode !== null &&
rawCode !== undefined &&
rawCode !== '' &&
Number.isInteger(Number(rawCode))
) {
result.languages = ['其他']
}
}
// 曲风:info.genre 官网英文分类名
const genreValues = Array.isArray(data.info?.genre?.content) ? data.info.genre.content : []
for (const item of genreValues) {
if (typeof item?.value !== 'string' || !item.value.trim()) continue
const mapped = QQ_GENRE_NAME_MAP[item.value.trim()]
result.genres.push(mapped || '其他')
}
return { languages: [...new Set(result.languages)], genres: [...new Set(result.genres)] }
}
// 网易云曲风为「一级-二级」结构,取一级分类
const genrePrimary = (genre: string): string => String(genre || '').split('-')[0]?.trim() ?? ''
// 曲风匹配:「其他」命中已判定但一级分类不在候选列表内的曲风;其余按一级分类匹配
export const matchBlacklistGenre = (value: string, genres: string[]): boolean => {
const target = String(value || '').trim()
if (!target) return false
if (target === '其他') {
return genres.some((genre) => {
const primary = genrePrimary(genre)
return !!primary && !KNOWN_GENRES.has(primary)
})
}
return genres.some((genre) => {
const primary = genrePrimary(genre)
return !!primary && (primary === target || primary.includes(target))
})
}
// 语种匹配:「其他」命中已判定但不属于主要候选值的语种(小语种);其余按精确匹配
export const matchBlacklistLanguage = (value: string, languages: string[]): boolean => {
const target = String(value || '').trim()
if (!target) return false
if (target === '其他') {
return languages.some((lang) => !!lang && !KNOWN_LANGUAGES.has(lang))
}
return languages.includes(target)
}
// 网易云增强 API 模块懒加载与配置初始化(模块级防并发)
let neteaseApiPromise: Promise<any> | null = null
let ncmConfigReady = false
let ncmConfigPromise: Promise<void> | null = null
const getNeteaseApi = async (): Promise<any> => {
if (!neteaseApiPromise) {
neteaseApiPromise = import('@neteasecloudmusicapienhanced/api').then((mod) => mod.default || mod)
}
return neteaseApiPromise
}
const ensureNcmConfig = async (): Promise<void> => {
if (ncmConfigReady) return
if (!ncmConfigPromise) {
ncmConfigPromise = import('@neteasecloudmusicapienhanced/api/generateConfig.js')
.then((mod) => (mod.default || mod)())
.then(() => {
ncmConfigReady = true
})
.catch((error: unknown) => {
console.error('[song-type-resolver] xeapi 配置初始化失败:', error)
})
.finally(() => {
ncmConfigPromise = null
})
}
await ncmConfigPromise
}
const fetchNeteaseTypes = async (songId: string): Promise<SongTypes | null> => {
try {
const api: any = await getNeteaseApi()
if (typeof api.song_wiki_info !== 'function') return null
await ensureNcmConfig()
const result: any = await api.song_wiki_info({ id: songId })
const payload = result?.body ?? result
if (payload?.code !== 200) return null
return parseNeteaseWikiTypes(payload)
} catch (error: any) {
console.warn('[song-type-resolver] 网易云百科获取失败:', error?.message || error)
return null
}
}
const fetchTencentTypes = async (musicId: string): Promise<SongTypes | null> => {
try {
const normalized = normalizeTxMusicId(musicId)
const result: any = await txRequest(TX_MUSICU_URL, createTxSongDetailBody(normalized))
return parseTencentSongTypes(result)
} catch (error: any) {
console.warn('[song-type-resolver] QQ音乐详情获取失败:', error?.message || error)
return null
}
}
// 解析歌曲类型;平台不支持、无 musicId 或外部接口失败时返回 null(类型黑名单放行)
export const resolveSongTypes = async (
musicPlatform: string | null | undefined,
musicId: string | null | undefined
): Promise<SongTypes | null> => {
const platform = String(musicPlatform || '').trim()
const id = String(musicId || '').trim()
if (!id || (platform !== 'netease' && platform !== 'tencent')) return null
return platform === 'netease' ? fetchNeteaseTypes(id) : fetchTencentTypes(id)
}
+81
View File
@@ -0,0 +1,81 @@
import {
createTxSongDetailBody,
normalizeTxMusicId,
txHeaders
} from '~~/server/utils/native_tx'
/**
* 按平台获取歌曲时长(秒)
* 返回 null 表示获取失败
*/
export async function fetchSongDuration(platform: string, musicId: string): Promise<number | null> {
if (!platform || !musicId) return null
try {
if (platform === 'netease' || platform === 'netease-podcast') {
const response: any = await $fetch('/api/api-enhanced/netease/song/url/v1', {
params: { id: musicId, level: 'standard' },
timeout: 10000
})
const timeMs = response?.data?.[0]?.time
if (typeof timeMs === 'number' && timeMs > 0) {
const seconds = Math.floor(timeMs / 1000)
// 网易云固定返回 30 秒时通常是试听片段,不作为歌曲完整时长。
if (seconds >= 30 && seconds <= 3600 && seconds !== 30) return seconds
}
return null
}
if (platform === 'tencent') {
const normalized = normalizeTxMusicId(musicId)
const result: any = await $fetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'POST',
headers: txHeaders,
body: createTxSongDetailBody(normalized),
responseType: 'json',
timeout: 10000
})
const interval = result?.req?.data?.track_info?.interval
if (typeof interval === 'number' && interval > 0) {
const seconds = Math.floor(interval)
if (seconds >= 30 && seconds <= 3600) return seconds
}
return null
}
if (platform === 'migu') {
const info: any = await $fetch(
`https://app.c.nf.migu.cn/resource/song/by-contentids/v2.0?contentId=${encodeURIComponent(musicId)}`,
{ timeout: 10000 }
)
const duration = info?.data?.[0]?.duration
if (typeof duration === 'number' && duration > 0) {
const seconds = Math.floor(duration)
if (seconds >= 30 && seconds <= 3600) return seconds
}
return null
}
if (platform === 'bilibili') {
const parts = String(musicId).split(':')
const bvid = parts[0]
const page = parts.length > 2 ? Number(parts[2]) : 1
if (parts.length > 2 && (!Number.isInteger(page) || page < 1)) return null
const viewResp: any = await $fetch('https://api.bilibili.com/x/web-interface/view', {
params: { bvid },
headers: { Cookie: 'buvid3=0', Referer: 'https://www.bilibili.com/' },
timeout: 10000
})
const pageIdx = page > 1 ? Math.max(0, page - 1) : 0
const duration = viewResp?.data?.pages?.[pageIdx]?.duration
if (typeof duration === 'number' && duration > 0) {
const seconds = Math.floor(duration)
if (seconds >= 30 && seconds <= 3600) return seconds
}
return null
}
} catch {
// 外部接口失败时静默降级
}
return null
}
+104
View File
@@ -0,0 +1,104 @@
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)
}
})
}
/** 移除匿名响应中的内部用户和点歌券标识,避免通过 ID 关联被脱敏的数据。 */
export function stripAnonymousSongIdentifiers(song: MaskableSong) {
delete song.requesterId
delete song.cardCodeId
song.collaborators?.forEach((user) => delete user.id)
song.replayRequesters?.forEach((user) => delete user.id)
}
export function stripAnonymousSongIdentifiersFromSchedules(data: PublicScheduleItem[]) {
data.forEach((item) => {
if (item.song) stripAnonymousSongIdentifiers(item.song)
})
}
@@ -0,0 +1,48 @@
export type SubmissionRestrictionMode = 'none' | 'semester' | 'window'
export type SubmissionRestrictionScope = 'self' | 'all'
export interface SubmissionRestrictionPolicy {
mode: SubmissionRestrictionMode
sameSongHours: number | null
sameArtistHours: number | null
scope: SubmissionRestrictionScope
}
export interface SubmissionRestrictionSettings {
enableSubmissionRestriction?: boolean | null
sameSongRestrictionHours?: number | string | null
sameArtistRestrictionHours?: number | string | null
submissionRestrictionScope?: string | null
}
/** 归一化限制时长:非正整数一律视为未配置 */
export function normalizeRestrictionHours(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
if (!Number.isFinite(parsed)) return null
const hours = Math.floor(parsed)
return hours >= 1 ? hours : null
}
/**
* 重复投稿限制口径(投稿与预检共用,两处的判定必须完全一致):
* - 开关关闭:不做任何重复投稿限制,普通用户可重复投稿已排期或已播放的歌曲
* - 开关开启且未配置时长:沿用本学期同一首歌不可重复投稿的规则
* - 开关开启且配置时长:按排期后的冷却窗口判定同一首歌 / 同一歌手
*/
export function resolveSubmissionRestrictionPolicy(
settings: SubmissionRestrictionSettings | null | undefined
): SubmissionRestrictionPolicy {
const sameSongHours = normalizeRestrictionHours(settings?.sameSongRestrictionHours)
const sameArtistHours = normalizeRestrictionHours(settings?.sameArtistRestrictionHours)
const scope: SubmissionRestrictionScope =
settings?.submissionRestrictionScope === 'self' ? 'self' : 'all'
if (!settings?.enableSubmissionRestriction) {
return { mode: 'none', sameSongHours, sameArtistHours, scope }
}
const mode: SubmissionRestrictionMode = sameSongHours || sameArtistHours ? 'window' : 'semester'
return { mode, sameSongHours, sameArtistHours, scope }
}
+110
View File
@@ -0,0 +1,110 @@
import { db, songs } from '~/drizzle/db'
import { and, count, eq, gte, isNull, lte } from 'drizzle-orm'
import {
getBeijingEndOfDay,
getBeijingEndOfWeek,
getBeijingEndOfMonth,
getBeijingStartOfDay,
getBeijingStartOfWeek,
getBeijingStartOfMonth
} from '~/utils/timeUtils'
export type LimitType = 'daily' | 'weekly' | 'monthly'
type SubmissionCountOptions = {
excludeCardCodeRequests?: boolean
}
type CardCodeLimitSettings = {
enableSubmissionLimit?: boolean | null
enableCardCodeRequests?: boolean | null
requireCardCodeForRequests?: boolean | null
enableCardCodeLimitBypass?: boolean | null
}
export function isCardCodeLimitBypassActive(
settings: CardCodeLimitSettings | null | undefined
): boolean {
return !!(
settings?.enableSubmissionLimit &&
settings.enableCardCodeLimitBypass &&
settings.enableCardCodeRequests === true
)
}
/**
* 获取指定限额类型的时间窗口(北京时间)
* @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 options 投稿统计选项
* @returns 投稿数量
*/
export async function getSubmissionCount(
dbOrTx: typeof db,
userId: number,
type: LimitType,
options: SubmissionCountOptions = {}
): Promise<number> {
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),
options.excludeCardCodeRequests ? isNull(songs.cardCodeId) : undefined
)
)
return result?.count || 0
}
/**
* 检查用户是否达到投稿限额
* @param dbOrTx 数据库实例或事务
* @param userId 用户ID
* @param type 限额类型
* @param limit 限额数量
* @param options 投稿统计选项
* @returns boolean true 表示已达到限额(不允许继续投稿),false 表示未达到
*/
export async function isLimitReached(
dbOrTx: typeof db,
userId: number,
type: LimitType,
limit: number,
options: SubmissionCountOptions = {}
): Promise<boolean> {
return (await getSubmissionCount(dbOrTx, userId, type, options)) >= limit
}
+161
View File
@@ -0,0 +1,161 @@
import { MUSIC_SOURCE_PLATFORMS } from '~~/server/config/constants'
export const normalizeScheduleVisibilitySettings = (settings) => {
for (const field of [
'scheduleDaysBeforeEnabled',
'scheduleDaysBefore',
'scheduleDaysAfterEnabled',
'scheduleDaysAfter'
]) {
if (Object.prototype.hasOwnProperty.call(settings, field) && settings[field] === null) {
delete settings[field]
}
}
return settings
}
export const SYSTEM_SETTINGS_DEFAULTS = {
telemetryEnabled: true,
enablePlayTimeSelection: false,
siteTitle: 'VoiceHub',
siteLogoUrl: '/favicon.ico',
schoolLogoHomeUrl: null,
schoolLogoPrintUrl: null,
siteDescription: '校园广播站点歌系统 - 让你的声音被听见',
submissionGuidelines: `1. 投稿时无需加入书名号
2. 除DJ外,其他类型歌曲均接收(包括小语种)
3. 禁止投递含有违规内容的歌曲
4. 点播的歌曲将由管理员进行审核
5. 审核通过后将安排在播放时段播出
6. 提交即表明我已阅读投稿须知并已知该歌曲有概率无法播出
7. 本系统仅提供音乐搜索和播放管理功能,不存储任何音乐文件。所有音乐内容均来自第三方音乐平台,版权归原平台及版权方所有。用户点歌时请确保遵守相关音乐平台的服务条款,尊重音乐作品版权。我们鼓励用户支持正版音乐,在官方平台购买和收听喜爱的音乐作品。
8. 最终解释权归广播站所有`,
icpNumber: null,
gonganNumber: null,
showBeianIcon: false,
enableSubmissionLimit: false,
dailySubmissionLimit: null,
weeklySubmissionLimit: null,
monthlySubmissionLimit: null,
scheduleDaysBeforeEnabled: false,
scheduleDaysBefore: 1,
scheduleDaysAfterEnabled: false,
scheduleDaysAfter: 1,
showBlacklistKeywords: false,
hideStudentInfo: true,
enableReplayRequests: false,
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
// 卡密点歌相关
enableCardCodeRequests: false,
requireCardCodeForRequests: false,
enableCardCodeLimitBypass: false,
// 重复投稿限制
enableSubmissionRestriction: false,
submissionRestrictionScope: 'all',
sameSongRestrictionHours: null,
sameArtistRestrictionHours: null,
enableRequestTimeLimitation: false,
forceBlockAllRequests: false,
forcePasswordChangeOnFirstLogin: false,
smtpEnabled: false,
smtpHost: null,
smtpPort: 587,
smtpSecure: false,
smtpUsername: null,
smtpPassword: null,
smtpFromEmail: null,
smtpFromName: '校园广播站',
allowOAuthRegistration: false,
allowRegister: false,
registerRequiresApproval: true,
oauthRegisterRequiresApproval: true,
registerEmailRequired: false,
registerRequiresGradeClass: false,
submissionNoteRequiresApproval: false,
captchaEnabled: false, // 默认关闭图形验证码
captchaMaxFailures: 3, //触发阈值
captchaProvider: 'graphic', // 默认使用图形验证码
turnstileSiteKey: null,
turnstileSecretKey: null,
// 自动备份
autoBackupEnabled: false,
autoBackupConfig: null,
// 站点统计代码(任意统计平台 HTML/JS 片段)
statisticsCodeEnabled: false,
statisticsCode: null,
// 主题管理
defaultTheme: 'System',
enabledThemes: JSON.stringify(['System', 'ClassicDark', 'ClassicLight', 'ModernLight']),
// 平台管理
enabledPlatforms: JSON.stringify([...MUSIC_SOURCE_PLATFORMS]),
platformOrder: JSON.stringify([...MUSIC_SOURCE_PLATFORMS]),
}
export const PUBLIC_SETTINGS_FIELDS = [
'siteTitle',
'siteLogoUrl',
'schoolLogoHomeUrl',
'schoolLogoPrintUrl',
'siteDescription',
'submissionGuidelines',
'icpNumber',
'gonganNumber',
'showBeianIcon',
'enablePlayTimeSelection',
'enableSubmissionLimit',
'dailySubmissionLimit',
'weeklySubmissionLimit',
'monthlySubmissionLimit',
'showBlacklistKeywords',
'hideStudentInfo',
'enableReplayRequests',
'enableSubmissionRestriction',
'enableCollaborativeSubmission',
'enableSubmissionRemarks',
'enableCardCodeRequests',
'requireCardCodeForRequests',
'enableCardCodeLimitBypass',
'enableRequestTimeLimitation',
'forceBlockAllRequests',
'forcePasswordChangeOnFirstLogin',
'smtpEnabled',
'allowOAuthRegistration',
'allowRegister',
'registerRequiresApproval',
'oauthRegisterRequiresApproval',
'registerEmailRequired',
'registerRequiresGradeClass',
'submissionNoteRequiresApproval',
'githubOAuthEnabled',
'casdoorOAuthEnabled',
'googleOAuthEnabled',
'aggregateOAuthEnabled',
'aggregateOAuthLoginType',
'customOAuthEnabled',
'customOAuthDisplayName',
'captchaEnabled',
'captchaMaxFailures',
'captchaProvider',
'turnstileSiteKey',
'enabledPlatforms',
'platformOrder',
'defaultTheme',
'enabledThemes'
]
export const filterPublicSettings = (data: any) => {
if (!data) {
return {}
}
const result: Record<string, any> = {}
for (const key of PUBLIC_SETTINGS_FIELDS) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
result[key] = data[key]
}
}
return result
}
//为兼容旧代码,导出别名
export { SYSTEM_SETTINGS_DEFAULTS as defaultSystemSettings }
+40
View File
@@ -0,0 +1,40 @@
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import type { SystemSettings } from '~/drizzle/schema'
/**
* 统一读取系统设置,读取失败时返回 null,由调用方按安全默认值降级。
* 说明:随主分支 minimize-Redis 重构,此处直接查询数据库,不再经过缓存层。
*/
export async function getSystemSettingsCached(): Promise<SystemSettings | null> {
try {
const [settings] = await db.select().from(systemSettings).limit(1)
return settings ?? null
} catch (error) {
console.warn('[SystemSettings] 读取系统设置失败:', error)
return null
}
}
export async function getForcePasswordChangeOnFirstLogin(): Promise<boolean> {
const settings = await getSystemSettingsCached()
return settings && typeof settings.forcePasswordChangeOnFirstLogin === 'boolean'
? settings.forcePasswordChangeOnFirstLogin
: false
}
export function computeRequirePasswordChange(
user: { forcePasswordChange?: boolean | null; passwordChangedAt?: Date | string | null },
forcePasswordChangeOnFirstLogin: boolean
): boolean {
return !!user.forcePasswordChange || (forcePasswordChangeOnFirstLogin && !user.passwordChangedAt)
}
export async function resolveRequirePasswordChange(user: {
forcePasswordChange?: boolean | null
passwordChangedAt?: Date | string | null
}): Promise<boolean> {
if (user.forcePasswordChange) return true
if (user.passwordChangedAt) return false
return computeRequirePasswordChange(user, await getForcePasswordChangeOnFirstLogin())
}
+52
View File
@@ -0,0 +1,52 @@
import { asc } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { SYSTEM_SETTINGS_DEFAULTS } from './system-settings-defaults'
import { getServerTimestamp } from './serverTime'
let telemetryEnabledCache: boolean | null = null
let telemetryEnabledCacheExpiresAt = 0
let pendingTelemetryEnabledPromise: Promise<boolean> | null = null
const TELEMETRY_CACHE_TTL_MS = 5 * 60 * 1000
const readTelemetryEnabled = async (): Promise<boolean> => {
const settingsResult = await db
.select({ telemetryEnabled: systemSettings.telemetryEnabled })
.from(systemSettings)
.orderBy(asc(systemSettings.id))
.limit(1)
return settingsResult[0]?.telemetryEnabled ?? SYSTEM_SETTINGS_DEFAULTS.telemetryEnabled
}
export const setTelemetryEnabledCache = (enabled: boolean) => {
telemetryEnabledCache = enabled
telemetryEnabledCacheExpiresAt = getServerTimestamp() + TELEMETRY_CACHE_TTL_MS
}
export const isTelemetryEnabledCached = () => telemetryEnabledCache === true
export const isTelemetryEnabled = async (): Promise<boolean> => {
if (telemetryEnabledCache !== null && telemetryEnabledCacheExpiresAt > getServerTimestamp()) {
return telemetryEnabledCache
}
if (!pendingTelemetryEnabledPromise) {
pendingTelemetryEnabledPromise = readTelemetryEnabled()
.then((enabled) => {
setTelemetryEnabledCache(enabled)
return enabled
})
.catch((error) => {
console.warn('[Telemetry] Failed to resolve telemetry setting, telemetry disabled:', error)
setTelemetryEnabledCache(false)
return false
})
.finally(() => {
pendingTelemetryEnabledPromise = null
})
}
return pendingTelemetryEnabledPromise
}
+39
View File
@@ -0,0 +1,39 @@
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES, THEMES, DEFAULT_THEMES } from '~~/server/config/constants'
export const parseThemeArray = (value: unknown, fallback = DEFAULT_THEMES): string[] => {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value
if (!Array.isArray(parsed)) return [...fallback]
const themes = [...new Set(parsed.filter((theme): theme is string => typeof theme === 'string' && THEMES.includes(theme as typeof THEMES[number])))]
return themes.length > 0 ? themes : [...fallback]
} catch {
return [...fallback]
}
}
export const validateThemeConfig = (defaultTheme: unknown, enabledThemes: unknown) => {
if (typeof defaultTheme !== 'string' || !THEMES.includes(defaultTheme as typeof THEMES[number])) {
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_DEFAULT, '默认主题无效')
}
if (typeof enabledThemes !== 'string') {
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_LIST, '启用的主题列表格式无效')
}
let parsed: unknown
try {
parsed = JSON.parse(enabledThemes)
} catch {
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_LIST, '启用的主题列表格式无效')
}
if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((theme) => typeof theme !== 'string' || !THEMES.includes(theme as typeof THEMES[number]))) {
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_LIST, '启用的主题列表无效')
}
const themes = [...new Set(parsed)] as string[]
if (!themes.includes(defaultTheme)) {
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_DEFAULT, '默认主题必须处于启用状态')
}
if (themes.includes('System') && (!themes.includes('ClassicDark') || !themes.includes('ClassicLight'))) {
throw createApiError(400, SERVER_ERROR_CODES.THEME_SYSTEM_REQUIRES_CLASSIC, '跟随系统时必须同时启用经典深色和经典浅色')
}
return themes
}
+3
View File
@@ -0,0 +1,3 @@
// 权威实现收敛到 app/utils/user-archive,此处仅保持原导入路径可用
export { ARCHIVED_USER_STATUSES, isArchivedStatus, resolveArchivedFilter } from '~~/app/utils/user-archive'
export type { ArchivedFilter } from '~~/app/utils/user-archive'
+72
View File
@@ -0,0 +1,72 @@
// 头像来源解析:优先读取用户选中的身份,未选择时按创建顺序取第一个可用头像
export interface AvatarIdentity {
id?: number
provider: string
providerUserId: string
providerUsername?: string | null
avatar?: string | null
createdAt?: Date | string | number | null
}
export interface AvatarSource {
provider: string
providerUserId: string
url: string
}
export function getIdentityAvatarUrl(
identity: AvatarIdentity | null | undefined
): string | null {
if (!identity) return null
if (identity.provider === 'webauthn' || identity.provider === 'totp') return null
const avatar = typeof identity.avatar === 'string' ? identity.avatar.trim() : ''
if (avatar && /^https?:\/\//i.test(avatar)) return avatar
// 兼容迁移前只存了 GitHub 用户名的身份(需校验格式,防止特殊字符拼进 URL)
const username =
typeof identity.providerUsername === 'string' ? identity.providerUsername.trim() : ''
if (identity.provider === 'github' && username && /^[a-zA-Z0-9-]{1,39}$/.test(username)) {
return `https://github.com/${username}.png`
}
return null
}
export function resolveAvatarSource(
user: { avatarProvider?: string | null; avatarProviderUserId?: string | null },
identities: AvatarIdentity[]
): AvatarSource | null {
const candidates = identities
.filter((identity) => getIdentityAvatarUrl(identity))
.sort((a, b) => {
const timeDiff = Number(new Date(a.createdAt || 0)) - Number(new Date(b.createdAt || 0))
if (timeDiff !== 0) return timeDiff
return (a.id ?? 0) - (b.id ?? 0)
})
if (candidates.length === 0) return null
if (user?.avatarProvider && user?.avatarProviderUserId) {
const selected = candidates.find(
(identity) =>
identity.provider === user.avatarProvider &&
identity.providerUserId === user.avatarProviderUserId
)
if (selected) {
return {
provider: selected.provider,
providerUserId: selected.providerUserId,
url: getIdentityAvatarUrl(selected)!
}
}
}
const first = candidates[0]
return {
provider: first.provider,
providerUserId: first.providerUserId,
url: getIdentityAvatarUrl(first)!
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* 获取用户状态的中文显示文本
* @param status 用户状态枚举值
* @returns 中文显示文本
*/
export function getStatusText(status: string | null | undefined): string {
const statusMap: Record<string, string> = {
active: '正常',
pending: '待审核',
rejected: '已拒绝',
withdrawn: '退学',
graduate: '毕业生',
};
return status ? (statusMap[status] || status) : '未知';
}
+40
View File
@@ -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 }
}
+46
View File
@@ -0,0 +1,46 @@
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, { path: '/' })
}