feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 6m31s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s

- 新增 /api/admin/songs/batch-reject.post 批量驳回接口(事务清排期/投票/扣接纳数/黑名单去重/异步通知)
- requireSongAdmin 三角色校验 + createApiError 统一错误包装
- scheduleDaysBefore/After 四字段排期可见范围限制(开放 API 与公开排期接口三处缓存路径 + DB 条件过滤)
- submissionNoteAutoModeration 基于黑名单 KEYWORD/LANGUAGE/GENRE 自动判定留言 publicStatus,命中自动关闭 submissionNotePublic
- 更新 songs/request.post 与 songs/[id]/update.put 接入自动审查逻辑
This commit is contained in:
2026-09-06 17:03:20 +08:00
parent 43e2a05666
commit bd8df1f026
53 changed files with 10486 additions and 245 deletions
+35
View File
@@ -0,0 +1,35 @@
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '~/utils/password-policy'
interface AdminPasswordViolation {
code: string
message: string
}
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
}
+15
View File
@@ -0,0 +1,15 @@
import { createError } from 'h3'
export function createApiError(
statusCode: number,
code: string,
message: string,
data?: Record<string, unknown>
) {
return createError({
statusCode,
statusMessage: code,
message,
data: { code, ...(data || {}) }
})
}
+99
View File
@@ -0,0 +1,99 @@
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 },
{ 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/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
}
+68
View File
@@ -0,0 +1,68 @@
import { createError, getQuery, readBody } from 'h3'
import { z } from 'zod'
import { deleteCardCodesByIds } from '~~/server/services/cardCodeDeleteService'
const MAX_DELETE_COUNT = 500
const idSchema = z.preprocess(
(value) => (typeof value === 'number' || typeof value === 'string' ? value : undefined),
z.coerce.number().int().positive()
)
const deleteCardCodesSchema = z.object({
id: idSchema.optional(),
ids: z.preprocess(
(value) => {
if (typeof value === 'string') {
return value.split(',').map((item) => item.trim()).filter(Boolean)
}
return value
},
z.array(idSchema).max(MAX_DELETE_COUNT, `单次最多删除 ${MAX_DELETE_COUNT} 个点歌券`).optional()
)
})
export const handleOpenCardCodeDelete = async (event: any) => {
const apiKey = event.context.apiKey
if (!apiKey) {
throw createError({ statusCode: 401, message: 'API认证失败' })
}
try {
const query = getQuery(event)
const body = await readBody(event).catch(() => ({})) ?? {}
const validatedData = deleteCardCodesSchema.parse({ ...query, ...body })
const rawIds = [
...(validatedData.id !== undefined ? [validatedData.id] : []),
...(validatedData.ids || [])
]
const ids = Array.from(new Set(rawIds))
if (!ids.length) {
throw createError({ statusCode: 400, message: '缺少有效点歌券ID' })
}
if (ids.length > MAX_DELETE_COUNT) {
throw createError({ statusCode: 400, message: `单次最多删除 ${MAX_DELETE_COUNT} 个点歌券` })
}
const deletedRows = await deleteCardCodesByIds(ids)
return {
success: true,
message: '点歌券删除成功',
data: {
cardCodes: deletedRows,
deleted: deletedRows.length,
requested: ids.length
}
}
} catch (err: any) {
if (err.statusCode) throw err
if (err.name === 'ZodError') {
throw createError({
statusCode: 400,
message: `请求参数验证失败:${(err.issues || err.errors).map((issue: any) => issue.message).join(', ')}`
})
}
console.error('开放API删除点歌券失败', err)
throw createError({ statusCode: 500, message: '删除点歌券失败' })
}
}
+83
View File
@@ -0,0 +1,83 @@
import { getHeader } from 'h3'
import { db, eq, and } from '~/drizzle/db'
import { authSessions } from '~/drizzle/schema'
import { parseUserAgent, type LoginMethod } from './user-agent-info'
import { getClientIP } from './ip-utils'
import { randomUUID } from 'node:crypto'
export interface CreateAuthSessionOptions {
userId: number
jti: string
loginMethod: LoginMethod
expiresAt?: Date
oauthProvider?: string
}
const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
export async function createAuthSession(
event: any,
options: CreateAuthSessionOptions
): Promise<string> {
const { userId, jti, loginMethod, oauthProvider } = options
const ua = getHeader(event, 'user-agent')
const { browser, operatingSystem, device } = parseUserAgent(ua)
const ip = getClientIP(event)
const now = new Date()
const expiresAt = options.expiresAt ?? new Date(now.getTime() + DEFAULT_SESSION_TTL_MS)
try {
let method = loginMethod
if (oauthProvider && method === 'oauth') {
method = `oauth:${oauthProvider}` as any
}
await db.insert(authSessions).values({
id: randomUUID(),
userId,
sessionToken: jti,
browser,
operatingSystem,
device,
loginMethod: method,
ipAddress: ip,
userAgent: ua ?? null,
createdAt: now,
lastActiveAt: now,
expiresAt,
isRevoked: false
})
} catch (error) {
console.warn('[AuthSession] 创建会话失败,继续无会话登录:', error)
}
return jti
}
export async function isSessionRevokedByJti(jti: string | undefined): Promise<boolean> {
if (!jti) return false
try {
const row = await db.query.authSessions.findFirst({
where: and(eq(authSessions.sessionToken, jti)),
columns: { isRevoked: true, expiresAt: true }
})
if (!row) return false
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return true
return Boolean(row.isRevoked)
} catch (error) {
console.warn('[AuthSession] 会话状态校验失败,默认放行:', error)
return false
}
}
export async function touchAuthSession(jti: string | undefined): Promise<void> {
if (!jti) return
try {
await db
.update(authSessions)
.set({ lastActiveAt: new Date() })
.where(eq(authSessions.sessionToken, jti))
} catch (_error) {
// ignore
}
}
+171
View File
@@ -0,0 +1,171 @@
import { randomInt, timingSafeEqual } from 'node:crypto'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { getRedisClient, buildRedisKey } from '~~/server/utils/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())
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 {
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 = 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 = 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)
}
export async function isEmailCodeDailyLimitReached(email: string): Promise<boolean> {
let client: Awaited<ReturnType<typeof getRedisClient>> | null = null
try {
client = 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 = 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)) {
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
}
export function cleanupExpiredEmailCodes(): void {
const now = getServerTimestamp()
for (const [email, entry] of emailCodes) {
if (now > entry.expiresAt) emailCodes.delete(email)
}
}
+36
View File
@@ -0,0 +1,36 @@
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
}
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)
})
}
+45
View File
@@ -0,0 +1,45 @@
import { and, isNotNull, eq, ne, sql } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { users, gradeClasses } from '~/drizzle/schema'
import { resolveGradeClassOptions, type GradeClassOption } from './grade-class-core'
export async function fetchGradeClassOptions(): Promise<GradeClassOption[]> {
const [configRows, userRows] = await Promise.all([
db.select({
grade: gradeClasses.grade,
class: gradeClasses.class
}).from(gradeClasses),
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)
}
export async function isGradeClassValid(grade: string, studentClass: string): Promise<boolean> {
const configHit = await db.query.gradeClasses.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(gradeClasses)
.where(and(ne(gradeClasses.grade, ''), ne(gradeClasses.class, '')))
if ((configCount[0]?.count || 0) > 0) return false
const userHit = await db.query.users.findFirst({
where: (t, { eq: eq_, and: and_ }) =>
and_(eq_(t.status, 'active'), eq_(t.grade, grade), eq_(t.class, studentClass)),
columns: { id: true }
})
return Boolean(userHit)
}
@@ -0,0 +1,72 @@
export const NOTIFICATION_TITLE_MAX_LENGTH = 200
export const NOTIFICATION_CONTENT_MAX_LENGTH = 20000
export const NOTIFICATION_SOURCES = {
SYSTEM: 'SYSTEM',
ADMIN_MANUAL: 'ADMIN_MANUAL'
} as const
export type NotificationSenderInput = {
id: number
name?: string | null
username?: string | null
}
export type NotificationSenderSnapshot = {
senderId: number | null
senderName: string | null
senderUsername: string | null
}
export const resolveNotificationSource = (sender: NotificationSenderInput | null) =>
sender ? NOTIFICATION_SOURCES.ADMIN_MANUAL : NOTIFICATION_SOURCES.SYSTEM
export const createNotificationSenderSnapshot = (
sender: NotificationSenderInput | null = null
): NotificationSenderSnapshot => ({
senderId: sender?.id ?? null,
senderName: sender?.name?.trim() || null,
senderUsername: sender?.username?.trim() || null
})
export const serializeNotificationSender = (snapshot: NotificationSenderSnapshot) =>
snapshot.senderId
? {
id: snapshot.senderId,
name: snapshot.senderName,
username: snapshot.senderUsername
}
: null
export const shouldCheckImportantNotification = (
authenticated: boolean,
userId?: number | null,
requirePasswordChange = false
): boolean =>
authenticated && Number.isInteger(userId) && Number(userId) > 0 && !requirePasswordChange
export const canSendSystemNotification = (role?: string): boolean =>
role === 'ADMIN' || role === 'SUPER_ADMIN'
export const resolveImportantFlag = (value: unknown): boolean | null => {
if (value === undefined) return false
return typeof value === 'boolean' ? value : null
}
export const shouldDeliverSystemNotification = (
important: boolean,
notificationsEnabled?: boolean
): boolean => important || notificationsEnabled !== false
export const shouldRetainNotificationHistory = (type: string, source: string): boolean =>
type === 'SYSTEM_NOTICE' && source === NOTIFICATION_SOURCES.ADMIN_MANUAL
export const createNotificationReadUpdate = (updatedAt = new Date()) => ({
read: true as const,
updatedAt
})
export const createNotificationUserDeleteUpdate = (updatedAt = new Date()) => ({
userDeleted: true as const,
read: true as const,
updatedAt
})
+33
View File
@@ -0,0 +1,33 @@
interface InitialPasswordUserState {
requirePasswordChange?: boolean | null
}
interface StoredPasswordState {
password?: string | null
passwordChangedAt?: Date | string | null
}
export interface PasswordSetupState {
hasSetPassword: boolean
needsInitialPasswordSetup: boolean
}
export function getPasswordSetupState(
storedPassword: StoredPasswordState,
requirePasswordChange: boolean
): PasswordSetupState {
const hasSetPassword = !!storedPassword.password
return {
hasSetPassword,
needsInitialPasswordSetup:
!storedPassword.passwordChangedAt && (requirePasswordChange || !hasSetPassword)
}
}
export function canSetInitialPassword(
user: InitialPasswordUserState,
storedPassword: StoredPasswordState
): boolean {
return getPasswordSetupState(storedPassword, !!user.requirePasswordChange)
.needsInitialPasswordSetup
}
+112
View File
@@ -0,0 +1,112 @@
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) {
await db
.insert(systemSettings)
.values({ id: 1, ...SYSTEM_SETTINGS_DEFAULTS, instanceId })
.onConflictDoUpdate({
target: systemSettings.id,
set: { instanceId, updatedAt: new Date() }
})
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
}
+9 -5
View File
@@ -38,30 +38,34 @@ export class JWTEnhanced {
}
/**
* 生成JWT token
* 生成JWT token(返回 token 字符串,兼容旧调用)
*/
static generateToken(userId: number, role: string): string {
return this.generateTokenWithJti(userId, role).token
}
/**
* 生成JWT token 并返回 jti(会话表关联需要)
*/
static generateTokenWithJti(userId: number, role: string): { token: string; jti: string } {
if (!this.JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is not set')
}
// 生成唯一的JWT ID
const jti = `${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
// JWT载荷
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
userId,
role,
jti
}
// 生成token
const token = jwt.sign(payload, this.JWT_SECRET, {
expiresIn: this.TOKEN_EXPIRES
})
console.log(`Generated JWT token for user ${userId}`)
return token
return { token, jti }
}
/**
+108
View File
@@ -0,0 +1,108 @@
export const NOTIFICATION_HISTORY_STATUSES = ['ALL', 'READ', 'UNREAD'] as const
export type NotificationHistoryStatus = (typeof NOTIFICATION_HISTORY_STATUSES)[number]
export const NOTIFICATION_HISTORY_TYPES = ['ALL', 'IMPORTANT', 'NORMAL'] as const
export const NOTIFICATION_HISTORY_SORT_ORDERS = ['DESC', 'ASC'] as const
export const NOTIFICATION_HISTORY_KEYWORD_MAX_LENGTH = 100
export type NotificationHistoryType = (typeof NOTIFICATION_HISTORY_TYPES)[number]
export type NotificationHistorySortOrder = (typeof NOTIFICATION_HISTORY_SORT_ORDERS)[number]
export const resolveNotificationHistoryFilters = (values: {
keyword?: unknown
type?: unknown
sender?: unknown
sortOrder?: unknown
}): {
keyword: string
type: NotificationHistoryType
senderId: number | null
sortOrder: NotificationHistorySortOrder
} | null => {
if (values.keyword !== undefined && typeof values.keyword !== 'string') return null
const keyword = typeof values.keyword === 'string' ? values.keyword.trim() : ''
if (keyword.length > NOTIFICATION_HISTORY_KEYWORD_MAX_LENGTH) return null
if (values.type !== undefined && typeof values.type !== 'string') return null
const type = typeof values.type === 'string' && values.type.trim()
? values.type.trim().toUpperCase()
: 'ALL'
if (!NOTIFICATION_HISTORY_TYPES.includes(type as NotificationHistoryType)) return null
let senderId: number | null = null
if (values.sender !== undefined && values.sender !== null && values.sender !== '') {
if (typeof values.sender !== 'string') return null
const parsedSenderId = Number(values.sender.trim())
if (!Number.isInteger(parsedSenderId) || parsedSenderId <= 0) return null
senderId = parsedSenderId
}
if (values.sortOrder !== undefined && typeof values.sortOrder !== 'string') return null
const sortOrder = typeof values.sortOrder === 'string' && values.sortOrder.trim()
? values.sortOrder.trim().toUpperCase()
: 'DESC'
if (!NOTIFICATION_HISTORY_SORT_ORDERS.includes(sortOrder as NotificationHistorySortOrder)) {
return null
}
return {
keyword,
type: type as NotificationHistoryType,
senderId,
sortOrder: sortOrder as NotificationHistorySortOrder
}
}
export const resolveNotificationHistoryStatus = (
value: unknown
): NotificationHistoryStatus | null => {
if (value === undefined || value === null || value === '') return 'ALL'
if (typeof value !== 'string') return null
const normalized = value.trim().toUpperCase()
return NOTIFICATION_HISTORY_STATUSES.includes(normalized as NotificationHistoryStatus)
? (normalized as NotificationHistoryStatus)
: null
}
export const resolveNotificationHistoryPagination = (
pageValue: unknown,
limitValue: unknown
) => {
const parsedPage = Number(pageValue)
const parsedLimit = Number(limitValue)
const page = Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1
const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20
return {
page,
limit,
offset: (page - 1) * limit
}
}
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const LEGACY_BATCH_PATTERN = /^legacy-([1-9]\d*)$/
export const resolveNotificationBatchReference = (value: unknown) => {
if (typeof value !== 'string') return null
const normalized = value.trim()
const legacyMatch = normalized.match(LEGACY_BATCH_PATTERN)
if (legacyMatch) {
return {
batchId: null,
notificationId: Number(legacyMatch[1])
}
}
if (!UUID_PATTERN.test(normalized)) return null
return {
batchId: normalized.toLowerCase(),
notificationId: null
}
}
+85
View File
@@ -0,0 +1,85 @@
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
}
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 }
}
+126
View File
@@ -0,0 +1,126 @@
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
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)
}
+141
View File
@@ -1,4 +1,12 @@
import { createError } from 'h3'
import { createHmac, randomUUID } from 'node:crypto'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { asc } from 'drizzle-orm'
import {
isSafeAggregateOAuthUrl,
getAggregateOAuthLoginTypesOrDefault
} from './oauth-providers'
export interface OAuthUserInfo {
id: string
@@ -250,6 +258,139 @@ const strategies: Record<string, OAuthStrategy> = {
google: googleStrategy
}
async function loadAggregateSettings() {
const row = await db
.select({
aggregateOAuthEnabled: systemSettings.aggregateOAuthEnabled,
aggregateOAuthAppId: systemSettings.aggregateOAuthAppId,
aggregateOAuthAppKey: systemSettings.aggregateOAuthAppKey,
aggregateOAuthLoginType: systemSettings.aggregateOAuthLoginType,
aggregateOAuthEndpoint: systemSettings.aggregateOAuthEndpoint
})
.from(systemSettings)
.orderBy(asc(systemSettings.id))
.limit(1)
return row[0] ?? null
}
function normalizeEndpoint(endpoint: string): string {
return endpoint.replace(/\/+$/, '')
}
function aggregateSign(params: Record<string, string>, appKey: string): string {
const sorted = Object.keys(params)
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== '')
.sort()
.map((k) => `${k}=${params[k]}`)
.join('&')
return createHmac('sha256', appKey).update(sorted).digest('hex')
}
const aggregateStrategy: OAuthStrategy = {
async getAuthorizeUrl(redirectUri: string, state: string) {
const settings = await loadAggregateSettings()
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
throw createError({ statusCode: 500, message: '聚合登录未配置' })
}
if (!isSafeAggregateOAuthUrl(settings.aggregateOAuthEndpoint)) {
throw createError({ statusCode: 500, message: '聚合登录端点不安全或格式错误' })
}
const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType)
const loginType = loginTypes[0] || 'qq'
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = randomUUID().replace(/-/g, '')
const baseParams: Record<string, string> = {
app_id: settings.aggregateOAuthAppId,
response_type: 'code',
redirect_uri: redirectUri,
state,
login_type: loginType,
timestamp,
nonce
}
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
const query = new URLSearchParams({ ...baseParams, sign }).toString()
return `${endpoint}/oauth/authorize?${query}`
},
async exchangeToken(code: string, redirectUri: string) {
const settings = await loadAggregateSettings()
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
throw createError({ statusCode: 500, message: '聚合登录未配置' })
}
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = randomUUID().replace(/-/g, '')
const baseParams: Record<string, string> = {
app_id: settings.aggregateOAuthAppId,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
timestamp,
nonce
}
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
try {
const tokenResponse = await $fetch<any>(`${endpoint}/oauth/token`, {
method: 'POST',
body: { ...baseParams, sign },
headers: { Accept: 'application/json' }
})
if (tokenResponse.error) {
throw new Error(tokenResponse.error_description || tokenResponse.error || '令牌请求失败')
}
const accessToken = tokenResponse.access_token || tokenResponse.accessToken
if (!accessToken) {
throw new Error('未能获取访问令牌')
}
return accessToken
} catch (e: any) {
console.error('Aggregate OAuth token exchange failed', e)
throw new Error(e.message || '令牌请求失败')
}
},
async getUserInfo(accessToken: string) {
const settings = await loadAggregateSettings()
if (!settings?.aggregateOAuthAppId || !settings?.aggregateOAuthAppKey || !settings?.aggregateOAuthEndpoint) {
throw createError({ statusCode: 500, message: '聚合登录未配置' })
}
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = randomUUID().replace(/-/g, '')
const baseParams: Record<string, string> = {
app_id: settings.aggregateOAuthAppId,
access_token: accessToken,
timestamp,
nonce
}
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
try {
const userInfo = await $fetch<any>(`${endpoint}/oauth/userinfo`, {
method: 'POST',
body: { ...baseParams, sign },
headers: { Accept: 'application/json' }
})
return {
id: String(userInfo.id || userInfo.openid || userInfo.sub || userInfo.userId || ''),
username: userInfo.username || userInfo.nickname || userInfo.name || String(userInfo.id || 'user'),
name: userInfo.name || userInfo.nickname || undefined,
email: userInfo.email || undefined,
avatar: userInfo.avatar || userInfo.avatar_url || userInfo.headimgurl || undefined
}
} catch (e: any) {
console.error('Aggregate OAuth user info failed', e)
throw new Error('获取用户信息失败')
}
}
}
strategies.aggregate = aggregateStrategy
export const getOAuthStrategy = (provider: string): OAuthStrategy => {
const strategy = strategies[provider]
if (!strategy) {
+6
View File
@@ -261,6 +261,12 @@ export const disconnectRedis = () => redisPool.disconnect()
export const getRedisClient = () => redisPool.getClient()
export const isRedisReady = () => redisPool.isReady()
export const getRedisStats = () => redisPool.getStats()
export const REDIS_KEY_PREFIX = 'voicehub'
export function buildRedisKey(...segments: string[]): string {
return [REDIS_KEY_PREFIX, ...segments].filter(Boolean).join(':')
}
export const executeRedisCommand = <T>(command: () => Promise<T>, fallback?: () => Promise<T>) =>
redisPool.executeCommand(command, fallback)
+15
View File
@@ -0,0 +1,15 @@
import { createApiError } from './apiError'
import { SERVER_ERROR_CODES } from '../config/constants'
const SONG_ADMIN_ROLES = ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN']
export function requireSongAdmin(event: any) {
const user = event.context?.user
if (!user) {
throw createApiError(401, SERVER_ERROR_CODES.AUTH_UNAUTHORIZED, '请先登录')
}
if (!SONG_ADMIN_ROLES.includes(user.role)) {
throw createApiError(403, SERVER_ERROR_CODES.AUTH_FORBIDDEN, '没有权限执行此操作')
}
return user
}
+52
View File
@@ -0,0 +1,52 @@
export interface ScheduleVisibilityRange {
startAt: Date | null
endAt: Date | null
}
export interface VisibilitySettingsLike {
scheduleDaysBeforeEnabled?: boolean | null
scheduleDaysBefore?: number | null
scheduleDaysAfterEnabled?: boolean | null
scheduleDaysAfter?: number | null
}
export function resolveScheduleVisibilityRange(
settings: VisibilitySettingsLike | null | undefined,
referenceDate = new Date()
): ScheduleVisibilityRange {
const today = new Date(
referenceDate.getFullYear(),
referenceDate.getMonth(),
referenceDate.getDate()
)
let startAt: Date | null = null
let endAt: Date | null = null
if (settings?.scheduleDaysBeforeEnabled) {
const days = Math.max(0, Number(settings.scheduleDaysBefore) || 0)
const d = new Date(today)
d.setDate(d.getDate() - days)
startAt = d
}
if (settings?.scheduleDaysAfterEnabled) {
const days = Math.max(0, Number(settings.scheduleDaysAfter) || 0)
const d = new Date(today)
d.setDate(d.getDate() + days + 1)
endAt = d
}
return { startAt, endAt }
}
export function isPlayDateInRange(
playDate: Date | string | null | undefined,
range: ScheduleVisibilityRange
): boolean {
if (!playDate) return true
const d = new Date(playDate)
if (range.startAt && d.getTime() < range.startAt.getTime()) return false
if (range.endAt && d.getTime() >= range.endAt.getTime()) return false
return true
}
+3
View File
@@ -0,0 +1,3 @@
export function getServerTimestamp(): number {
return Date.now()
}
@@ -0,0 +1,150 @@
import type { SongBlacklist } from '~/drizzle/schema'
export type BlacklistType = 'KEYWORD' | 'LANGUAGE' | 'GENRE' | 'SONG'
export interface ModerationContext {
title?: string | null
artist?: string | null
genre?: string | null
}
export interface ModerationResult {
notePublic: boolean
publicStatus: 'approved' | 'rejected' | null
rejectedReasons: string[]
matchedRules: { type: BlacklistType; value: string }[]
}
function escapeRegex(s: string): string {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function isCJK(text: string): boolean {
return /[\u4e00-\u9fa5]/.test(text)
}
function isLatin(text: string): boolean {
return /[A-Za-z]/.test(text)
}
function isKana(text: string): boolean {
return /[\u3040-\u30ff]/.test(text)
}
function isHangul(text: string): boolean {
return /[\uac00-\ud7af]/.test(text)
}
function isCyrillic(text: string): boolean {
return /[\u0400-\u04ff]/.test(text)
}
function isArabic(text: string): boolean {
return /[\u0600-\u06ff]/.test(text)
}
function detectLanguages(text: string): string[] {
const result: string[] = []
if (!text) return result
const normalized = text.toLowerCase()
if (isCJK(text)) result.push('zh', '中文', 'chinese', 'cjk')
if (isLatin(text)) result.push('en', '英文', 'english', 'latin')
if (isKana(text)) result.push('ja', '日文', 'japanese')
if (isHangul(text)) result.push('ko', '韩文', 'korean')
if (isCyrillic(text)) result.push('ru', '俄文', 'russian', 'cyrillic')
if (isArabic(text)) result.push('ar', '阿拉伯文', 'arabic')
return result
}
export function moderateSubmissionNote(
note: string | null,
wantPublic: boolean,
requiresApproval: boolean,
song: ModerationContext,
blacklistRows: SongBlacklist[]
): ModerationResult {
if (!note || !wantPublic) {
return {
notePublic: wantPublic && !!note,
publicStatus: null,
rejectedReasons: [],
matchedRules: []
}
}
const noteLower = note.toLowerCase()
const noteForKeyword = note.toLowerCase()
const title = (song.title || '').toLowerCase()
const artist = (song.artist || '').toLowerCase()
const genre = (song.genre || '').toLowerCase()
const rejectedReasons: string[] = []
const matchedRules: { type: BlacklistType; value: string }[] = []
const noteLanguages = detectLanguages(note)
const textLanguages = new Set([
...noteLanguages,
...detectLanguages(song.title || ''),
...detectLanguages(song.artist || ''),
...detectLanguages(song.genre || '')
])
for (const row of blacklistRows) {
const t = row.type as BlacklistType
const rawValue = row.value
if (!rawValue) continue
const value = rawValue.trim()
if (!value) continue
const valueLower = value.toLowerCase()
if (t === 'KEYWORD') {
// 命中 note / title / artist / genre 的其中一个都算
const haystacks = [noteForKeyword, title, artist, genre].filter(Boolean) as string[]
const exact = haystacks.some((h) => h.includes(valueLower))
const asRegex = (() => {
try {
const re = new RegExp(escapeRegex(value), 'i')
return haystacks.some((h) => re.test(h))
} catch (_) {
return false
}
})()
if (exact || asRegex) {
matchedRules.push({ type: t, value })
rejectedReasons.push(`命中敏感词:${value}`)
}
} else if (t === 'LANGUAGE') {
const v = valueLower
const checkList = [
v,
...v.split(/[,,\/|\s]+/).map((s) => s.trim()).filter(Boolean)
]
const hit = checkList.some((lang) => textLanguages.has(lang))
if (hit) {
matchedRules.push({ type: t, value })
rejectedReasons.push(`命中语言限制:${value}`)
}
} else if (t === 'GENRE') {
// 匹配流派:genre 字段或 note/title/artist 中的流派关键词
const combinedForGenre = `${genre} ${noteForKeyword} ${title} ${artist}`.trim()
if (!combinedForGenre) continue
const tokens = valueLower.split(/[,,\/|\s]+/).map((s) => s.trim()).filter(Boolean)
const hit = tokens.some((tok) => combinedForGenre.includes(tok))
if (hit) {
matchedRules.push({ type: t, value })
rejectedReasons.push(`命中流派限制:${value}`)
}
} else {
// SONG 不参与 note 审查
continue
}
}
const rejected = matchedRules.length > 0
return {
notePublic: rejected ? false : (requiresApproval ? false : wantPublic),
publicStatus: requiresApproval || matchedRules.length > 0 ? (rejected ? 'rejected' : 'approved') : null,
rejectedReasons,
matchedRules
}
}
+70
View File
@@ -0,0 +1,70 @@
export const SYSTEM_SETTINGS_DEFAULTS = {
siteTitle: '校园广播站点歌系统',
siteVersion: '1.7.1.0',
enableSubmissionLimit: false,
showBlacklistKeywords: false,
hideStudentInfo: true,
smtpEnabled: false,
smtpPort: 587,
smtpSecure: false,
smtpFromName: '校园广播站',
enableRequestTimeLimitation: false,
forceBlockAllRequests: false,
enableReplayRequests: false,
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
submissionNoteRequiresApproval: false,
telemetryEnabled: true,
showBeianIcon: true,
scheduleDaysDisplay: 7,
scheduleDaysEditable: 3,
scheduleDaysRequestable: 14,
scheduleDaysBeforeEnabled: false,
scheduleDaysBefore: 1,
scheduleDaysAfterEnabled: false,
scheduleDaysAfter: 1,
forcePasswordChangeOnFirstLogin: false,
enableSubmissionRestriction: false,
submissionRestrictionScope: 'all',
cardCodeEnabled: false,
cardCodeSubmissionRequired: false,
cardCodeVoteEnabled: false,
cardCodeReplayEnabled: false,
enableCardCodeRequests: false,
requireCardCodeForRequests: false,
enableCardCodeLimitBypass: false,
submissionRestrictionEnabled: false,
submissionRestrictionGradeEnabled: false,
submissionRestrictionClassEnabled: false,
submissionRestrictionGraduateEnabled: false,
submissionRestrictionWithdrawnEnabled: false,
captchaProvider: 'TURNSTILE',
captchaEnabled: false,
allowRegister: true,
registerRequireEmail: false,
registerRequireEmailVerify: false,
registerDefaultRole: 'USER',
registerRequiresApproval: false,
oauthRegisterRequiresApproval: false,
registerEmailRequired: false,
registerRequiresGradeClass: false,
oauthEnabled: false,
oauthAllowRegister: true,
oauthAllowLogin: true,
oauthAllowBind: true,
allowOAuthRegistration: false,
oauthProviders: '[]',
githubOAuthEnabled: false,
casdoorOAuthEnabled: false,
googleOAuthEnabled: false,
aggregateOAuthEnabled: false,
aggregateOAuthLoginType: 'qq',
customOAuthEnabled: false,
captchaMaxFailures: 3,
autoBackupEnabled: false,
autoBackupIntervalHours: 24,
autoBackupRetentionDays: 30,
statisticsCodeEnabled: false,
defaultTheme: 'System',
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
} as const
+71
View File
@@ -0,0 +1,71 @@
export interface UserAgentInfo {
browser: string | null
operatingSystem: string | null
device: string | null
}
const BROWSER_PATTERNS: Array<[RegExp, string]> = [
[/Edg\//i, 'Microsoft Edge'],
[/OPR\//i, 'Opera'],
[/Chrome\//i, 'Chrome'],
[/Safari\//i, 'Safari'],
[/Firefox\//i, 'Firefox'],
[/MSIE |Trident\//i, 'Internet Explorer']
]
const OS_PATTERNS: Array<[RegExp, string]> = [
[/Windows NT 10\.0/i, 'Windows 10/11'],
[/Windows NT 6\.3/i, 'Windows 8.1'],
[/Windows NT 6\.2/i, 'Windows 8'],
[/Windows NT 6\.1/i, 'Windows 7'],
[/Windows/i, 'Windows'],
[/Mac OS X ([\d_.]+)/i, 'macOS'],
[/iPhone OS ([\d_]+)/i, 'iOS'],
[/iPad.*CPU OS ([\d_]+)/i, 'iPadOS'],
[/Android ([\d.]+)/i, 'Android'],
[/Linux/i, 'Linux']
]
const DEVICE_PATTERNS: Array<[RegExp, string]> = [
[/iPhone/i, 'iPhone'],
[/iPad/i, 'iPad'],
[/Android.*Mobile/i, 'Android Phone'],
[/Android/i, 'Android Tablet'],
[/Windows Phone/i, 'Windows Phone'],
[/Macintosh/i, 'Mac'],
[/Windows/i, 'Windows PC'],
[/Linux/i, 'Linux PC']
]
export function parseUserAgent(userAgent: string | null | undefined): UserAgentInfo {
const ua = (userAgent ?? '').trim()
if (!ua) return { browser: null, operatingSystem: null, device: null }
let browser: string | null = null
for (const [regex, name] of BROWSER_PATTERNS) {
if (regex.test(ua)) {
browser = name
break
}
}
let operatingSystem: string | null = null
for (const [regex, name] of OS_PATTERNS) {
if (regex.test(ua)) {
operatingSystem = name
break
}
}
let device: string | null = null
for (const [regex, name] of DEVICE_PATTERNS) {
if (regex.test(ua)) {
device = name
break
}
}
return { browser, operatingSystem, device }
}
export type LoginMethod = 'password' | 'oauth' | 'webauthn' | 'totp_2fa' | 'email_2fa'
+45
View File
@@ -0,0 +1,45 @@
interface AvatarUserState {
avatarProvider?: string | null
avatarProviderUserId?: string | null
}
interface IdentityRow {
provider: string
providerUserId: string
avatar?: string | null
createdAt: Date | string
}
export interface AvatarSource {
provider: string | null
providerUserId: string | null
}
export function resolveAvatarSource(
user: AvatarUserState,
identities: IdentityRow[]
): AvatarSource | null {
if (user.avatarProvider) {
return {
provider: user.avatarProvider,
providerUserId: user.avatarProviderUserId ?? null
}
}
const sorted = [...identities].sort((a, b) => {
const at = new Date(a.createdAt).getTime()
const bt = new Date(b.createdAt).getTime()
return at - bt
})
for (const identity of sorted) {
if (identity.avatar) {
return {
provider: identity.provider,
providerUserId: identity.providerUserId
}
}
}
return null
}