feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 || {}) }
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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: '权限验证失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: '删除点歌券失败' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { db, testConnection } from '~/drizzle/db'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
// 数据库健康检查
|
||||
export async function checkDatabaseHealth() {
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
|
||||
// 基本连接测试
|
||||
const isConnected = await testConnection()
|
||||
if (!isConnected) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
message: 'Database connection failed',
|
||||
responseTime: Date.now() - startTime
|
||||
}
|
||||
}
|
||||
|
||||
// 查询测试
|
||||
await db.execute(sql`SELECT 1 as test`)
|
||||
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
return {
|
||||
status: responseTime < 1000 ? 'healthy' : 'slow',
|
||||
message: responseTime < 1000 ? 'Database is healthy' : 'Database is responding slowly',
|
||||
responseTime
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : 'Unknown database error',
|
||||
responseTime: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 数据库连接重试机制
|
||||
export async function retryDatabaseOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
delay: number = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// 等待后重试
|
||||
await new Promise((resolve) => setTimeout(resolve, delay * attempt))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!
|
||||
}
|
||||
|
||||
// 数据库连接池状态检查
|
||||
export async function getConnectionPoolStatus() {
|
||||
try {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
setting::int as max_connections
|
||||
FROM pg_settings
|
||||
WHERE name = 'max_connections'
|
||||
`)
|
||||
|
||||
const activeConnections = await db.execute(sql`
|
||||
SELECT count(*) as active_connections
|
||||
FROM pg_stat_activity
|
||||
WHERE state = 'active'
|
||||
`)
|
||||
|
||||
const totalConnections = await db.execute(sql`
|
||||
SELECT count(*) as total_connections
|
||||
FROM pg_stat_activity
|
||||
`)
|
||||
|
||||
return {
|
||||
maxConnections: result[0]?.max_connections || 0,
|
||||
activeConnections: activeConnections[0]?.active_connections || 0,
|
||||
totalConnections: totalConnections[0]?.total_connections || 0,
|
||||
utilization: result[0]?.max_connections
|
||||
? (
|
||||
((totalConnections[0]?.total_connections || 0) / result[0].max_connections) *
|
||||
100
|
||||
).toFixed(2)
|
||||
: '0'
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get connection pool status: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 数据库性能指标
|
||||
export async function getDatabaseMetrics() {
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
|
||||
// 测试查询响应时间
|
||||
await db.execute(sql`SELECT 1`)
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
// 获取数据库统计信息
|
||||
const stats = await db.execute(sql`
|
||||
SELECT
|
||||
numbackends as active_connections,
|
||||
xact_commit as transactions_committed,
|
||||
xact_rollback as transactions_rolled_back,
|
||||
blks_read as blocks_read,
|
||||
blks_hit as blocks_hit
|
||||
FROM pg_stat_database
|
||||
WHERE datname = current_database()
|
||||
`)
|
||||
|
||||
const stat = stats[0] || {}
|
||||
|
||||
return {
|
||||
responseTime,
|
||||
activeConnections: stat.active_connections || 0,
|
||||
transactionsCommitted: stat.transactions_committed || 0,
|
||||
transactionsRolledBack: stat.transactions_rolled_back || 0,
|
||||
cacheHitRatio:
|
||||
stat.blocks_read && stat.blocks_hit
|
||||
? ((stat.blocks_hit / (stat.blocks_hit + stat.blocks_read)) * 100).toFixed(2)
|
||||
: '0'
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get database metrics: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 数据库备份状态检查(模拟)
|
||||
export async function checkBackupStatus() {
|
||||
// 这里可以集成实际的备份服务
|
||||
return {
|
||||
lastBackup: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
size: '0 MB' // 模拟数据
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// 简单的地理位置检测工具
|
||||
// 通过请求 ip-api.com 或其他公共 IP 服务来判断服务器位置
|
||||
|
||||
export interface GeoLocation {
|
||||
country: string
|
||||
countryCode: string
|
||||
region: string
|
||||
city: string
|
||||
isInChina: boolean
|
||||
}
|
||||
|
||||
let cachedLocation: GeoLocation | null = null
|
||||
|
||||
export const getServerLocation = async (): Promise<GeoLocation> => {
|
||||
if (cachedLocation) {
|
||||
return cachedLocation
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 ip-api.com (免费,限制 45 req/min)
|
||||
// 或者使用其他类似服务。这里为了稳定性,可以尝试多个源,或者只在启动时检测一次。
|
||||
// 注意:在某些服务器环境(如 Vercel Edge),可能需要特定的 Header 来获取 IP,但这里我们需要的是服务器本身的出口 IP 位置。
|
||||
|
||||
// 尝试 ip-api.com
|
||||
const response: any = await $fetch(
|
||||
'http://ip-api.com/json/?fields=status,message,country,countryCode,regionName,city',
|
||||
{
|
||||
timeout: 5000
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status === 'success') {
|
||||
const isChina = response.countryCode === 'CN'
|
||||
cachedLocation = {
|
||||
country: response.country,
|
||||
countryCode: response.countryCode,
|
||||
region: response.regionName,
|
||||
city: response.city,
|
||||
isInChina: isChina
|
||||
}
|
||||
return cachedLocation
|
||||
}
|
||||
|
||||
throw new Error('IP API failed')
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[Geo] Failed to detect server location, defaulting to non-China (conservative strategy):',
|
||||
error
|
||||
)
|
||||
// 默认认为不在中国,使用保守策略(第三方 API 通常在全球可用性更好,或者说原生 API 在国外可能受限)
|
||||
// 或者根据用户需求,如果用户希望默认原生,可以改为 true。
|
||||
// 但通常原生 API (如网易/QQ) 在海外会有 IP 限制,所以默认 false 比较安全。
|
||||
return {
|
||||
country: 'Unknown',
|
||||
countryCode: 'XX',
|
||||
region: 'Unknown',
|
||||
city: 'Unknown',
|
||||
isInChina: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,395 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import type { WriteStream } from 'fs'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { LOG_CONSTANTS } from '../config/constants'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { apiLogs } from '~/drizzle/schema'
|
||||
import { lt, sql } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 日志管理器
|
||||
* 负责API日志的轮转、归档和清理
|
||||
*/
|
||||
export class LogManager {
|
||||
private static instance: LogManager
|
||||
private logStream: WriteStream | null = null
|
||||
private currentLogFile: string = ''
|
||||
private rotationTimer: NodeJS.Timeout | null = null
|
||||
private archiveTimer: NodeJS.Timeout | null = null
|
||||
|
||||
private constructor() {
|
||||
this.initializeLogRotation()
|
||||
}
|
||||
|
||||
static getInstance(): LogManager {
|
||||
if (!LogManager.instance) {
|
||||
LogManager.instance = new LogManager()
|
||||
}
|
||||
return LogManager.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入日志
|
||||
*/
|
||||
async writeLog(logEntry: {
|
||||
timestamp: Date
|
||||
method: string
|
||||
path: string
|
||||
statusCode: number
|
||||
responseTime: number
|
||||
apiKeyId?: number
|
||||
ipAddress?: string
|
||||
userAgent?: string
|
||||
requestBody?: string
|
||||
responseBody?: string
|
||||
errorMessage?: string
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const logLine =
|
||||
JSON.stringify({
|
||||
...logEntry,
|
||||
timestamp: logEntry.timestamp.toISOString()
|
||||
}) + '\n'
|
||||
|
||||
if (this.logStream) {
|
||||
this.logStream.write(logLine)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 写入日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行日志轮转
|
||||
*/
|
||||
async rotateLog(): Promise<void> {
|
||||
try {
|
||||
console.log('[Log Manager] 开始执行日志轮转')
|
||||
|
||||
// 关闭当前日志流
|
||||
if (this.logStream) {
|
||||
this.logStream.end()
|
||||
this.logStream = null
|
||||
}
|
||||
|
||||
// 创建新的日志文件
|
||||
await this.createCurrentLogFile()
|
||||
|
||||
// 清理过期的日志文件
|
||||
await this.cleanupOldLogs()
|
||||
|
||||
console.log('[Log Manager] 日志轮转完成')
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 日志轮转失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行日志归档
|
||||
*/
|
||||
async archiveLogs(): Promise<void> {
|
||||
try {
|
||||
console.log('[Log Manager] 开始执行日志归档')
|
||||
|
||||
const logDir = LOG_CONSTANTS.LOG_DIR
|
||||
const archiveDir = LOG_CONSTANTS.ARCHIVE_DIR
|
||||
const files = await fs.readdir(logDir)
|
||||
const archiveDate = new Date()
|
||||
archiveDate.setDate(archiveDate.getDate() - LOG_CONSTANTS.ARCHIVE_AFTER_DAYS)
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith('api-') && file.endsWith('.log')) {
|
||||
const filePath = path.join(logDir, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
|
||||
// 如果文件超过归档天数,移动到归档目录
|
||||
if (stats.mtime < archiveDate) {
|
||||
const archivePath = path.join(archiveDir, file)
|
||||
await fs.rename(filePath, archivePath)
|
||||
console.log(`[Log Manager] 归档日志文件: ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理数据库中的过期日志
|
||||
await this.cleanupDatabaseLogs()
|
||||
|
||||
console.log('[Log Manager] 日志归档完成')
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 日志归档失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志统计信息
|
||||
*/
|
||||
async getLogStats(): Promise<{
|
||||
currentLogFile: string
|
||||
logFileSize: number
|
||||
totalLogFiles: number
|
||||
archivedFiles: number
|
||||
oldestLog: Date | null
|
||||
newestLog: Date | null
|
||||
}> {
|
||||
try {
|
||||
const logDir = LOG_CONSTANTS.LOG_DIR
|
||||
const archiveDir = LOG_CONSTANTS.ARCHIVE_DIR
|
||||
|
||||
// 获取当前日志文件大小
|
||||
let logFileSize = 0
|
||||
try {
|
||||
const stats = await fs.stat(this.currentLogFile)
|
||||
logFileSize = stats.size
|
||||
} catch {
|
||||
// 文件不存在
|
||||
}
|
||||
|
||||
// 统计日志文件数量
|
||||
const logFiles = await fs.readdir(logDir)
|
||||
const totalLogFiles = logFiles.filter(
|
||||
(f) => f.startsWith('api-') && f.endsWith('.log')
|
||||
).length
|
||||
|
||||
// 统计归档文件数量
|
||||
let archivedFiles = 0
|
||||
try {
|
||||
const archiveFiles = await fs.readdir(archiveDir)
|
||||
archivedFiles = archiveFiles.filter(
|
||||
(f) => f.startsWith('api-') && f.endsWith('.log')
|
||||
).length
|
||||
} catch {
|
||||
// 归档目录不存在
|
||||
}
|
||||
|
||||
// 获取最新和最旧的日志时间(从数据库)
|
||||
const [oldestResult, newestResult] = await Promise.all([
|
||||
db
|
||||
.select({ createdAt: apiLogs.createdAt })
|
||||
.from(apiLogs)
|
||||
.orderBy(apiLogs.createdAt)
|
||||
.limit(1),
|
||||
db
|
||||
.select({ createdAt: apiLogs.createdAt })
|
||||
.from(apiLogs)
|
||||
.orderBy(sql`${apiLogs.createdAt} DESC`)
|
||||
.limit(1)
|
||||
])
|
||||
|
||||
return {
|
||||
currentLogFile: this.currentLogFile,
|
||||
logFileSize,
|
||||
totalLogFiles,
|
||||
archivedFiles,
|
||||
oldestLog: oldestResult[0]?.createdAt || null,
|
||||
newestLog: newestResult[0]?.createdAt || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 获取日志统计失败:', error)
|
||||
return {
|
||||
currentLogFile: this.currentLogFile,
|
||||
logFileSize: 0,
|
||||
totalLogFiles: 0,
|
||||
archivedFiles: 0,
|
||||
oldestLog: null,
|
||||
newestLog: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发日志轮转
|
||||
*/
|
||||
async manualRotate(): Promise<void> {
|
||||
await this.rotateLog()
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发日志归档
|
||||
*/
|
||||
async manualArchive(): Promise<void> {
|
||||
await this.archiveLogs()
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁日志管理器
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.logStream) {
|
||||
this.logStream.end()
|
||||
this.logStream = null
|
||||
}
|
||||
|
||||
if (this.rotationTimer) {
|
||||
clearInterval(this.rotationTimer)
|
||||
this.rotationTimer = null
|
||||
}
|
||||
|
||||
if (this.archiveTimer) {
|
||||
clearTimeout(this.archiveTimer)
|
||||
this.archiveTimer = null
|
||||
}
|
||||
|
||||
console.log('[Log Manager] 日志管理器已销毁')
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化日志轮转
|
||||
*/
|
||||
private async initializeLogRotation(): Promise<void> {
|
||||
try {
|
||||
// 确保日志目录存在
|
||||
await this.ensureLogDirectory()
|
||||
|
||||
// 创建当前日志文件
|
||||
await this.createCurrentLogFile()
|
||||
|
||||
// 设置定时轮转(每天凌晨执行)
|
||||
this.scheduleLogRotation()
|
||||
|
||||
// 设置定时归档(每周执行)
|
||||
this.scheduleLogArchive()
|
||||
|
||||
console.log('[Log Manager] 日志管理器初始化完成')
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 初始化失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保日志目录存在
|
||||
*/
|
||||
private async ensureLogDirectory(): Promise<void> {
|
||||
const logDir = LOG_CONSTANTS.LOG_DIR
|
||||
const archiveDir = LOG_CONSTANTS.ARCHIVE_DIR
|
||||
|
||||
try {
|
||||
await fs.access(logDir)
|
||||
} catch {
|
||||
await fs.mkdir(logDir, { recursive: true })
|
||||
console.log(`[Log Manager] 创建日志目录: ${logDir}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(archiveDir)
|
||||
} catch {
|
||||
await fs.mkdir(archiveDir, { recursive: true })
|
||||
console.log(`[Log Manager] 创建归档目录: ${archiveDir}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建当前日志文件
|
||||
*/
|
||||
private async createCurrentLogFile(): Promise<void> {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
this.currentLogFile = path.join(LOG_CONSTANTS.LOG_DIR, `api-${today}.log`)
|
||||
|
||||
// 关闭现有流
|
||||
if (this.logStream) {
|
||||
this.logStream.end()
|
||||
}
|
||||
|
||||
// 创建新的写入流
|
||||
this.logStream = createWriteStream(this.currentLogFile, { flags: 'a' })
|
||||
|
||||
console.log(`[Log Manager] 当前日志文件: ${this.currentLogFile}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的日志文件
|
||||
*/
|
||||
private async cleanupOldLogs(): Promise<void> {
|
||||
try {
|
||||
const logDir = LOG_CONSTANTS.LOG_DIR
|
||||
const files = await fs.readdir(logDir)
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - LOG_CONSTANTS.RETENTION_DAYS)
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith('api-') && file.endsWith('.log')) {
|
||||
const filePath = path.join(logDir, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
|
||||
if (stats.mtime < cutoffDate) {
|
||||
await fs.unlink(filePath)
|
||||
console.log(`[Log Manager] 删除过期日志文件: ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 清理过期日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理数据库中的过期日志
|
||||
*/
|
||||
private async cleanupDatabaseLogs(): Promise<void> {
|
||||
try {
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - LOG_CONSTANTS.DB_RETENTION_DAYS)
|
||||
|
||||
const result = await db.delete(apiLogs).where(lt(apiLogs.createdAt, cutoffDate))
|
||||
|
||||
console.log(`[Log Manager] 清理数据库日志记录: ${result.rowCount || 0} 条`)
|
||||
} catch (error) {
|
||||
console.error('[Log Manager] 清理数据库日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置定时日志轮转
|
||||
*/
|
||||
private scheduleLogRotation(): void {
|
||||
// 计算到下一个凌晨的毫秒数
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(now)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setHours(0, 0, 0, 0)
|
||||
const msUntilMidnight = tomorrow.getTime() - now.getTime()
|
||||
|
||||
// 设置首次执行
|
||||
setTimeout(() => {
|
||||
this.rotateLog()
|
||||
|
||||
// 设置每日定时执行
|
||||
this.rotationTimer = setInterval(
|
||||
() => {
|
||||
this.rotateLog()
|
||||
},
|
||||
24 * 60 * 60 * 1000
|
||||
) // 24小时
|
||||
}, msUntilMidnight)
|
||||
|
||||
console.log(`[Log Manager] 日志轮转定时器设置完成,首次执行时间: ${tomorrow.toISOString()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置定时日志归档
|
||||
*/
|
||||
private scheduleLogArchive(): void {
|
||||
// 每周日凌晨2点执行归档
|
||||
const scheduleArchive = () => {
|
||||
const now = new Date()
|
||||
const nextSunday = new Date(now)
|
||||
const daysUntilSunday = (7 - now.getDay()) % 7
|
||||
nextSunday.setDate(now.getDate() + (daysUntilSunday === 0 ? 7 : daysUntilSunday))
|
||||
nextSunday.setHours(2, 0, 0, 0)
|
||||
|
||||
const msUntilArchive = nextSunday.getTime() - now.getTime()
|
||||
|
||||
setTimeout(() => {
|
||||
this.archiveLogs()
|
||||
scheduleArchive() // 递归设置下次执行
|
||||
}, msUntilArchive)
|
||||
|
||||
console.log(`[Log Manager] 日志归档定时器设置完成,下次执行时间: ${nextSunday.toISOString()}`)
|
||||
}
|
||||
|
||||
scheduleArchive()
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const logManager = LogManager.getInstance()
|
||||
@@ -0,0 +1,48 @@
|
||||
export const sizeFormate = (limit: number) => {
|
||||
if (limit === null || limit === undefined) return ''
|
||||
let size = ''
|
||||
if (limit < 0.1 * 1024) {
|
||||
// 小于0.1KB,则转化成B
|
||||
size = limit.toFixed(2) + 'B'
|
||||
} else if (limit < 0.1 * 1024 * 1024) {
|
||||
// 小于0.1MB,则转化成KB
|
||||
size = (limit / 1024).toFixed(2) + 'KB'
|
||||
} else if (limit < 0.1 * 1024 * 1024 * 1024) {
|
||||
// 小于0.1GB,则转化成MB
|
||||
size = (limit / (1024 * 1024)).toFixed(2) + 'MB'
|
||||
} else {
|
||||
// 其他转化成GB
|
||||
size = (limit / (1024 * 1024 * 1024)).toFixed(2) + 'GB'
|
||||
}
|
||||
|
||||
const sizeStr = size + '' // 转成字符串
|
||||
const len = sizeStr.indexOf('.')
|
||||
const dec = sizeStr.substr(len + 1, 2)
|
||||
if (dec === '00') {
|
||||
// 当小数点后为00时 去掉小数部分
|
||||
return sizeStr.substring(0, len) + sizeStr.substr(len + 3, 2)
|
||||
}
|
||||
return sizeStr
|
||||
}
|
||||
|
||||
export const formatPlayTime = (time: number) => {
|
||||
let m: any = parseInt(String(time / 60))
|
||||
let s: any = parseInt(String(time % 60))
|
||||
m = m === 0 ? '00' : m < 10 ? '0' + m : m
|
||||
s = s === 0 ? '00' : s < 10 ? '0' + s : s
|
||||
return m + ':' + s
|
||||
}
|
||||
|
||||
export const decodeName = (str: string | null = '') => {
|
||||
if (!str) return ''
|
||||
// 简单的 HTML 实体解码
|
||||
const entities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'"
|
||||
}
|
||||
return str.replace(/&|<|>|"|'|'/g, (match) => entities[match] || match)
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
publicEncrypt,
|
||||
randomBytes,
|
||||
createHash,
|
||||
constants
|
||||
} from 'node:crypto'
|
||||
|
||||
// --- 加密逻辑 ---
|
||||
const eapiKey = 'e82ckenh8dichen8'
|
||||
|
||||
const aesEncrypt = (buffer: Buffer, mode: string, key: string | Buffer, iv: string | Buffer) => {
|
||||
const cipher = createCipheriv(mode, key, iv)
|
||||
return Buffer.concat([cipher.update(buffer), cipher.final()])
|
||||
}
|
||||
|
||||
export const eapi = (url: string, object: any) => {
|
||||
const text = typeof object === 'object' ? JSON.stringify(object) : object
|
||||
const message = `nobody${url}use${text}md5forencrypt`
|
||||
const digest = createHash('md5').update(message).digest('hex')
|
||||
const data = `${url}-36cd479b6b5-${text}-36cd479b6b5-${digest}`
|
||||
|
||||
return {
|
||||
params: aesEncrypt(Buffer.from(data), 'aes-128-ecb', eapiKey, '').toString('hex').toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
// --- 请求逻辑 ---
|
||||
|
||||
export const wyEapiRequest = async (url: string, data: any) => {
|
||||
const form = eapi(url, data)
|
||||
|
||||
const headers = {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||
origin: 'https://music.163.com',
|
||||
// 使用 LX Music 的默认 Cookie 或者空,视情况而定。LX Music 代码中有注释掉的 cookie,
|
||||
// 但 eapi 接口通常需要 cookie 才能正常工作,或者特定的 header。
|
||||
// 这里先尝试不带 cookie 或带基本 cookie。
|
||||
// 注意:lx-music 的 request.js 中有默认 header。
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
|
||||
// const body = new URLSearchParams(form).toString() // eapi 返回的是 { params: '...' }
|
||||
|
||||
try {
|
||||
const response = await $fetch('http://interface.music.163.com/eapi/batch', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: new URLSearchParams(form as any).toString(), // $fetch 自动处理 body,但这里明确一下
|
||||
responseType: 'json'
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('WY Eapi Request Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(/&/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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 无效' })
|
||||
}
|
||||
}
|
||||
@@ -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, '账号状态异常,无法执行此操作')
|
||||
}
|
||||
}
|
||||
@@ -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?.()
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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$
|
||||
`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 '***'
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// 权威实现收敛到 app/utils/song-name-normalize(含繁→简归一化),此处仅保持原导入路径可用
|
||||
export { normalizeForMatch } from '~~/app/utils/song-name-normalize'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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)!
|
||||
}
|
||||
}
|
||||
@@ -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) : '未知';
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { H3Event, getRequestHeader, getRequestHost, getRequestProtocol, createError } from 'h3'
|
||||
|
||||
function parseAllowedOrigins(): string[] {
|
||||
const env = process.env.WEBAUTHN_ORIGIN
|
||||
if (!env) return []
|
||||
return env.split(',').map(o => o.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function isOriginAllowed(origin: string, allowedOrigins: string[]): boolean {
|
||||
if (allowedOrigins.length === 0) return true
|
||||
return allowedOrigins.includes(origin)
|
||||
}
|
||||
|
||||
export function getWebAuthnConfig(event: H3Event) {
|
||||
const host = getRequestHost(event)
|
||||
const protocol = getRequestProtocol(event, { xForwardedProto: true })
|
||||
const allowedOrigins = parseAllowedOrigins()
|
||||
|
||||
let rpID = process.env.WEBAUTHN_RP_ID
|
||||
|
||||
if (!rpID) {
|
||||
rpID = host.split(':')[0]
|
||||
}
|
||||
|
||||
let origin: string
|
||||
const requestOrigin = getRequestHeader(event, 'origin') || `${protocol}://${host}`
|
||||
|
||||
if (process.env.WEBAUTHN_ORIGIN) {
|
||||
if (isOriginAllowed(requestOrigin, allowedOrigins)) {
|
||||
origin = requestOrigin
|
||||
} else {
|
||||
console.error(`WebAuthn origin 不在允许列表中: ${requestOrigin}`)
|
||||
throw createError({ statusCode: 403, message: `不被允许的 Origin: ${requestOrigin}` })
|
||||
}
|
||||
} else {
|
||||
origin = requestOrigin
|
||||
}
|
||||
|
||||
return { rpID, origin, allowedOrigins }
|
||||
}
|
||||
@@ -0,0 +1,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: '/' })
|
||||
}
|
||||
Reference in New Issue
Block a user