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 createError({ statusCode: 401, message: '需要登录才能点歌' }) } const body = await readBody(event) 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 = { daily: '每日', weekly: '每周', monthly: '每月' } const timeMap: Record = { 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 } }