feat: 迁移自定义功能至 1.5.9.9 版本

包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
2026-09-12 19:26:42 +08:00
parent 34f0679117
commit 1106aeea6c
161 changed files with 18963 additions and 27900 deletions
+38 -31
View File
@@ -1,53 +1,46 @@
import { db } from '~/drizzle/db'
import { songs, users } from '~/drizzle/schema'
import { and, eq, or } from 'drizzle-orm'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { cacheService } from '~~/server/services/cacheService'
import { normalizeCoverUrl } from '~~/server/utils/url'
export default defineEventHandler(async (event) => {
try {
// 验证请求方法
if (event.node.req.method !== 'POST') {
throw createApiError(405, 'HTTP_METHOD_NOT_ALLOWED', 'Method Not Allowed')
throw createError({
statusCode: 405,
statusMessage: 'Method Not Allowed'
})
}
// 获取已验证的用户信息(由中间件提供)
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
throw createError({
statusCode: 401,
statusMessage: '未授权访问'
})
}
// 检查权限
if (!['ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createApiError(403, 'COMMON_INSUFFICIENT_PERMISSION', '权限不足')
throw createError({
statusCode: 403,
statusMessage: '权限不足'
})
}
// 获取请求体
const body = await readBody(event)
const {
title,
artist,
requester,
semester,
musicPlatform,
musicId,
cover,
playUrl,
durationSeconds,
preferredPlayTimeId
} = body
const { title, artist, requester, semester, musicPlatform, musicId, cover, playUrl } = body
// 验证必填字段
if (!title || !artist) {
throw createApiError(400, SERVER_ERROR_CODES.SONG_TITLE_ARTIST_REQUIRED, 'Title and artist are required')
}
// 校验时长范围(30秒~1小时)
if (durationSeconds !== null && durationSeconds !== undefined) {
const d = Number(durationSeconds)
if (!Number.isFinite(d) || d < 30 || d > 3600) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'Invalid song duration (30s–1h)')
}
throw createError({
statusCode: 400,
statusMessage: 'Title and artist are required'
})
}
// 查找投稿人用户
@@ -89,22 +82,28 @@ export default defineEventHandler(async (event) => {
const existingSong = existingSongResult[0]
if (existingSong) {
throw createApiError(409, 'SONG_ALREADY_EXISTS', 'Song already exists')
throw createError({
statusCode: 409,
statusMessage: 'Song already exists'
})
}
const normalizedCover = normalizeCoverUrl(cover, `add-song:${title || ''}`)
// 创建歌曲
const newSongResult = await db
.insert(songs)
.values({
title: title.trim(),
artist: artist.trim(),
singer: artist.trim(),
requesterId,
semester: semester || null,
preferredPlayTimeId: preferredPlayTimeId || null,
musicPlatform: musicPlatform || null,
musicId: musicId || null,
durationSeconds: durationSeconds ? Number(durationSeconds) : null,
cover: cover || null,
kgHash: musicPlatform === 'kugou' ? musicId : null,
cover: normalizedCover,
coverUrl: normalizedCover,
playUrl: playUrl || null
})
.returning()
@@ -132,6 +131,14 @@ export default defineEventHandler(async (event) => {
requester: requesterInfo
}
// 清除歌曲相关缓存
try {
await cacheService.clearSongsCache()
console.log('[Cache] 歌曲缓存已清除(添加歌曲)')
} catch (error) {
console.error('清除歌曲缓存失败:', error)
}
return {
success: true,
song: songWithRequester
@@ -145,7 +152,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
message: error.message || 'Internal server error'
statusMessage: error.message || 'Internal server error'
})
}
})
+532 -4
View File
@@ -1,14 +1,542 @@
import { requestSongForUser } from '~~/server/services/songRequestService'
import { createApiError } from '~~/server/utils/apiError'
import {
collaborationLogs,
db,
playTimes,
requestTimes,
semesters,
songBlacklists,
songCollaborators,
songs,
systemSettings
} from '~/drizzle/db'
import { and, eq, gt, gte, inArray, lt, lte, sql } from 'drizzle-orm'
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
import { isLimitReached } from '~~/server/utils/submissionLimit'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
import { normalizeCoverUrl } from '~~/server/utils/url'
export default defineEventHandler(async (event) => {
// 检查用户认证
const user = event.context.user
if (!user) {
throw createApiError(401, 'SONG_LOGIN_REQUIRED_REQUEST', '需要登录才能点歌')
throw createError({
statusCode: 401,
message: '需要登录才能点歌'
})
}
const body = await readBody(event)
return await requestSongForUser(event, user, body)
if (!body.title || !body.artist) {
throw createError({
statusCode: 400,
message: '歌曲名称和艺术家不能为空'
})
}
try {
// 标准化字符串用于精确匹配
const normalizeForMatch = (str: string): string => {
return str
.toLowerCase()
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
.replace(/[&&]/g, 'and')
.replace(/[feat\.?|ft\.?]/gi, '')
.trim()
}
const normalizedTitle = normalizeForMatch(body.title)
const normalizedArtist = normalizeForMatch(body.artist)
// 获取当前学期
const currentSemester = await getCurrentSemesterName()
// 哔哩哔哩平台判定逻辑
const isBilibili =
body.musicPlatform === 'bilibili' ||
String(body.musicId || '').startsWith('BV') ||
String(body.musicId || '').startsWith('av')
// 对于哔哩哔哩平台,如果有 musicId,使用 musicId 进行精确匹配
if (isBilibili && body.musicId) {
// 构建完整的 musicId(包含分P信息)
let fullMusicId = String(body.musicId)
const bvId = fullMusicId.split(':')[0]
if (body.bilibiliCid) {
const musicIdParts = [bvId, body.bilibiliCid]
if (body.bilibiliPage && Number(body.bilibiliPage) > 1) {
musicIdParts.push(String(body.bilibiliPage))
}
fullMusicId = musicIdParts.join(':')
}
// 检查是否已有相同 musicId 的歌曲
const existingSongs = await db
.select({
id: songs.id,
musicId: songs.musicId,
played: songs.played
})
.from(songs)
.where(
and(
eq(songs.semester, currentSemester),
eq(songs.musicPlatform, 'bilibili'),
eq(songs.musicId, fullMusicId)
)
)
if (existingSongs.length > 0) {
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const hasUnplayedDuplicate = existingSongs.some((s) => !s.played)
if (!isSuperAdmin || hasUnplayedDuplicate) {
throw createError({
statusCode: 400,
message: `《${body.title}》已经在列表中,不能重复投稿`
})
}
}
} else {
// 对于其他平台,使用标题和艺术家进行匹配
const allSongs = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist,
semester: songs.semester,
played: songs.played
})
.from(songs)
.where(eq(songs.semester, currentSemester))
const matchingSongs = allSongs.filter((song) => {
const songTitle = normalizeForMatch(song.title)
const songArtist = normalizeForMatch(song.artist)
return songTitle === normalizedTitle && songArtist === normalizedArtist
})
if (matchingSongs.length > 0) {
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const hasUnplayedDuplicate = matchingSongs.some((s) => !s.played)
if (!isSuperAdmin || hasUnplayedDuplicate) {
throw createError({
statusCode: 400,
message: `《${body.title}》已经在列表中,不能重复投稿`
})
}
}
}
// 检查投稿限额(管理员不受限制)
const systemSettingsResult = await db.select().from(systemSettings).limit(1)
const systemSettingsData = systemSettingsResult[0]
const isAdmin = user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
// 加载自动审查需要的黑名单(KEYWORD / LANGUAGE / GENRE)
let moderationBlacklists: any[] = []
try {
moderationBlacklists = await db
.select()
.from(songBlacklists)
.where(inArray(songBlacklists.type, ['KEYWORD', 'LANGUAGE', 'GENRE']))
} catch (err) {
console.warn('[Request API] 加载自动审查黑名单失败,将跳过自动审查:', err)
moderationBlacklists = []
}
// 1. 检查强制关闭
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
throw createError({
statusCode: 403,
message: '投稿功能已关闭'
})
}
// 2. 检查投稿时段限制
let hitRequestTime: any = null
if (systemSettingsData?.enableRequestTimeLimitation && !isAdmin) {
// 使用 ISO 字符串格式进行比较,确保数据库能够正确处理
const currentTime = getBeijingTimeISOString()
const hitRequestTimeResult = await db
.select()
.from(requestTimes)
.where(
and(
lte(requestTimes.startTime, currentTime),
gt(requestTimes.endTime, currentTime),
eq(requestTimes.enabled, true)
)
)
.limit(1)
hitRequestTime = hitRequestTimeResult[0]
if (!hitRequestTime) {
throw createError({
statusCode: 403,
message: '当前不在投稿开放时段'
})
}
if (hitRequestTime.expected > 0 && hitRequestTime.accepted >= hitRequestTime.expected) {
throw createError({
statusCode: 403,
message: `当前时段投稿名额已满(${hitRequestTime.accepted}/${hitRequestTime.expected})`
})
}
}
// 确定生效的限额类型(三选一逻辑)
let effectiveLimit: number | null = null
let limitType: 'daily' | 'weekly' | 'monthly' | null = null
if (systemSettingsData?.enableSubmissionLimit && !isAdmin) {
const dailyLimit = systemSettingsData.dailySubmissionLimit
const weeklyLimit = systemSettingsData.weeklySubmissionLimit
const monthlyLimit = systemSettingsData.monthlySubmissionLimit
if (dailyLimit !== null && dailyLimit !== undefined) {
effectiveLimit = dailyLimit
limitType = 'daily'
} else if (weeklyLimit !== null && weeklyLimit !== undefined) {
effectiveLimit = weeklyLimit
limitType = 'weekly'
} else if (monthlyLimit !== null && monthlyLimit !== undefined) {
effectiveLimit = monthlyLimit
limitType = 'monthly'
}
// 如果生效的限额为0,则关闭投稿
if (effectiveLimit === 0) {
throw createError({
statusCode: 403,
message: '投稿功能已关闭'
})
}
// 如果有生效的限额且大于0,检查使用量
// 注意:这里仅作初步检查,严格检查在事务内进行
}
// 检查期望的播出时段是否存在
let preferredPlayTime = null
if (body.preferredPlayTimeId) {
// 检查系统设置是否允许选择播出时段
if (!systemSettingsData?.enablePlayTimeSelection) {
throw createError({
statusCode: 400,
message: '播出时段选择功能未启用'
})
}
// 检查播出时段是否存在且已启用
const playTimeResult = await db
.select()
.from(playTimes)
.where(and(eq(playTimes.id, body.preferredPlayTimeId), eq(playTimes.enabled, true)))
.limit(1)
preferredPlayTime = playTimeResult[0]
if (!preferredPlayTime) {
throw createError({
statusCode: 400,
message: '选择的播出时段不存在或未启用'
})
}
}
const rawSubmissionNote = typeof body.submissionNote === 'string' ? body.submissionNote.trim() : ''
if (rawSubmissionNote.length > 300) {
throw createError({
statusCode: 400,
message: '备注留言不能超过300个字符'
})
}
const submissionNote =
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
const rawSubmissionNotePublic = submissionNote !== null ? body.submissionNotePublic !== false : false
const requiresApproval = Boolean(systemSettingsData?.submissionNoteRequiresApproval)
let finalSubmissionNotePublic = rawSubmissionNotePublic
let submissionNotePublicStatus: string | null = null
if (submissionNote && rawSubmissionNotePublic && requiresApproval) {
const mod = moderateSubmissionNote(
submissionNote,
rawSubmissionNotePublic,
requiresApproval,
{ title: body.title, artist: body.artist, genre: body.genre || null },
moderationBlacklists
)
finalSubmissionNotePublic = mod.notePublic
submissionNotePublicStatus = mod.publicStatus
if (mod.rejectedReasons.length > 0) {
console.log(
`[Request API] 投稿留言自动审查未通过,歌曲=${body.title} - ${body.artist},原因:`,
mod.rejectedReasons
)
}
} else if (submissionNote) {
// 没开自动审查,也记录一下状态
submissionNotePublicStatus = null
}
const submissionNotePublic = finalSubmissionNotePublic
// 准备发送通知的列表
const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = []
// 创建歌曲和更新状态(使用事务)
const song = await db.transaction(async (tx) => {
// 检查投稿限额(在事务内进行,防止并发绕过限制)
if (
systemSettingsData?.enableSubmissionLimit &&
!isAdmin &&
effectiveLimit &&
effectiveLimit > 0 &&
limitType
) {
if (await isLimitReached(tx, user.id, limitType, effectiveLimit)) {
const labelMap: Record<string, string> = { daily: '每日', weekly: '每周', monthly: '每月' }
const timeMap: Record<string, string> = { daily: '今日', weekly: '本周', monthly: '本月' }
throw createError({
statusCode: 400,
message: `${labelMap[limitType]}投稿限额为${effectiveLimit}首,您${timeMap[limitType]}已达到限额`
})
}
}
// 如果有时段限制,再次检查并更新已接纳数量
if (hitRequestTime) {
const latestRequestTimeResult = await tx
.select()
.from(requestTimes)
.where(eq(requestTimes.id, hitRequestTime.id))
.limit(1)
const latestRequestTime = latestRequestTimeResult[0]
if (!latestRequestTime || !latestRequestTime.enabled) {
throw createError({ statusCode: 403, message: '投稿时段已失效' })
}
// 使用原子更新确保并发安全
const updateResult = await tx
.update(requestTimes)
.set({
accepted: sql`${requestTimes.accepted} + 1`
})
.where(
and(
eq(requestTimes.id, hitRequestTime.id),
// 如果有限额,确保更新时未超限(针对 expected > 0 的情况)
latestRequestTime.expected > 0
? lt(requestTimes.accepted, latestRequestTime.expected)
: undefined
)
)
.returning()
if (updateResult.length === 0) {
throw createError({ statusCode: 403, message: '当前时段投稿名额已满' })
}
}
if (!body.expectedPlayDate || body.expectedPlaySequence === undefined || body.expectedPlaySequence === null) {
throw createError({ statusCode: 400, message: '请选择期望播出日期与期望播放序号' })
}
// 检查 expectedPlaySequence 是否已被占用
if (body.expectedPlayDate && body.expectedPlaySequence !== undefined && body.expectedPlaySequence !== null) {
const expectedSequence = Number(body.expectedPlaySequence)
const occupiedCheck = await tx
.select({ id: songs.id })
.from(songs)
.where(
and(
eq(songs.expectedPlayDate, body.expectedPlayDate),
eq(songs.expectedPlaySequence, expectedSequence),
eq(songs.played, false) // 只有未播放的才算被占用
)
)
.limit(1)
if (occupiedCheck.length > 0) {
throw createError({
statusCode: 400,
message: `日期 ${body.expectedPlayDate} 的第 ${expectedSequence} 序号已被占用,请选择其他序号`
})
}
}
// 创建歌曲
let finalMusicId = body.musicId ? String(body.musicId) : null
// 如果是 Bilibili 平台,处理 musicId 格式
if (isBilibili) {
const bvId = finalMusicId?.split(':')[0]
if (bvId) {
const musicIdParts = [bvId]
if (body.bilibiliCid) {
musicIdParts.push(body.bilibiliCid)
if (body.bilibiliPage && Number(body.bilibiliPage) > 1) {
musicIdParts.push(String(body.bilibiliPage))
}
}
finalMusicId = musicIdParts.join(':')
}
}
const normalizedCover = normalizeCoverUrl(body.cover, `request-song:${body.title || ''}`)
const songResult = await tx
.insert(songs)
.values({
title: body.title,
artist: body.artist,
singer: body.artist,
requesterId: user.id,
preferredPlayTimeId: preferredPlayTime?.id || null,
semester: currentSemester, // 使用外部获取的学期名称
cover: normalizedCover,
coverUrl: normalizedCover,
musicPlatform: isBilibili ? 'bilibili' : (body.musicPlatform || null),
musicId: finalMusicId,
kgHash: body.musicPlatform === 'kugou' ? body.musicId : null,
playUrl: body.playUrl || null,
submissionNote,
submissionNotePublic,
submissionNotePublicStatus,
hitRequestId: hitRequestTime?.id || null,
expectedPlayDate: body.expectedPlayDate || null,
expectedPlaySequence: body.expectedPlaySequence !== undefined ? Number(body.expectedPlaySequence) : null
})
.returning()
const newSong = songResult[0]
// 处理联合投稿人
if (
body.collaborators &&
Array.isArray(body.collaborators) &&
body.collaborators.length > 0
) {
// 去重
const uniqueCollaboratorIds = [...new Set(body.collaborators.map((id: any) => Number(id)))]
for (const collaboratorId of uniqueCollaboratorIds) {
// 跳过自己或无效ID
if (isNaN(collaboratorId) || collaboratorId === user.id) continue
try {
// 检查是否已经是联合投稿人
const existingCollab = await tx
.select()
.from(songCollaborators)
.where(
and(
eq(songCollaborators.songId, newSong.id),
eq(songCollaborators.userId, collaboratorId)
)
)
.limit(1)
if (existingCollab.length > 0) continue
// 创建联合投稿记录
const collabResult = await tx
.insert(songCollaborators)
.values({
songId: newSong.id,
userId: collaboratorId,
status: 'PENDING'
})
.returning()
const collab = collabResult[0]
// 记录审计日志
await tx.insert(collaborationLogs).values({
collaboratorId: collab.id,
action: 'INVITE',
operatorId: user.id,
ipAddress:
(event.node.req.headers['x-forwarded-for'] as string) ||
event.node.req.socket.remoteAddress
})
// 添加到通知列表,事务结束后发送
notificationsToSend.push({
userId: collaboratorId,
songId: newSong.id,
songTitle: newSong.title
})
} catch (err) {
console.error(`邀请用户 ${collaboratorId} 失败:`, err)
}
}
}
return newSong
})
// 事务提交成功后,发送通知
// 即使通知发送失败,也不影响点歌结果
for (const notification of notificationsToSend) {
try {
await createCollaborationInvitationNotification(
user.id,
notification.userId,
notification.songId,
notification.songTitle
)
} catch (error) {
console.error(`发送邀请通知给用户 ${notification.userId} 失败:`, error)
}
}
return song
} catch (error: any) {
console.error('点歌失败:', error)
if (error.statusCode) {
throw error
} else if (error.message === '未设置活跃学期') {
throw createError({
statusCode: 400,
message: '系统未设置当前活跃学期,请联系管理员'
})
} else {
throw createError({
statusCode: 500,
message: '点歌失败,请稍后重试'
})
}
}
})
// 获取当前学期名称
async function getCurrentSemesterName() {
try {
// 获取当前活跃的学期
const currentSemesterResult = await db
.select()
.from(semesters)
.where(eq(semesters.isActive, true))
.limit(1)
const currentSemester = currentSemesterResult[0]
if (currentSemester) {
return currentSemester.name
}
// 如果没有活跃学期,抛出错误
throw new Error('未设置活跃学期')
} catch (error) {
console.error('获取当前学期失败:', error)
throw error
}
}