import { collaborationLogs, db, playTimes, requestTimes, schedules, semesters, cardCodes, songCollaborators, songs, users } from '~/drizzle/db' import { and, eq, gte, gt, inArray, lt, lte, or, sql } from 'drizzle-orm' import { createError } from 'h3' import { reconcileSongDurationOnSubmit } from '~~/server/services/durationValidationService' import { createApiError } from '~~/server/utils/apiError' import { createCollaborationInvitationNotification } from '~~/server/services/notificationService' import { isCardCodeLimitBypassActive, isLimitReached } from '~~/server/utils/submissionLimit' import { getClientIP } from '~~/server/utils/ip-utils' import { getBeijingTimeISOString } from '~/utils/timeUtils' import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper' import { getServerDate } from '~~/server/utils/serverTime' import { SERVER_ERROR_CODES, SONG_DURATION_MAX_SECONDS, SONG_DURATION_MIN_SECONDS, SUBMISSION_NOTE_STATUS } from '~~/server/config/constants' import { normalizeForMatch } from '~~/server/utils/song-name-normalize' import { resolveSubmissionRestrictionPolicy } from '~~/server/utils/submission-restriction-policy' import { z } from 'zod' type SongRequestUser = { id: number role: string } const songRequestBodySchema = z.object({ title: z.string().trim().min(1, '歌曲名称不能为空').max(200, '歌曲名称不能超过200个字符'), artist: z.string().trim().min(1, '艺术家不能为空').max(200, '艺术家不能超过200个字符'), cover: z .string() .trim() .max(1000, '封面地址不能超过1000个字符') // 封面必须是完整 http(s) 链接,防止音源返回 pic_id 等片段被误存导致封面无法显示 .refine((value) => value === '' || /^https?:\/\//i.test(value), '封面地址必须以 http(s):// 开头') .optional() .nullable(), musicPlatform: z.string().trim().max(50, '音乐平台标识不能超过50个字符').optional().nullable(), musicId: z.string().trim().max(200, '音乐 ID 不能超过200个字符').optional().nullable(), bilibiliCid: z.string().trim().max(100, 'Bilibili CID 不能超过100个字符').optional().nullable(), bilibiliPage: z.union([z.string(), z.number()]).optional().nullable(), playUrl: z.string().trim().max(2000, '播放链接不能超过2000个字符').optional().nullable(), durationSeconds: z.number().int().min(SONG_DURATION_MIN_SECONDS, '时长不能为负数').max(SONG_DURATION_MAX_SECONDS, '时长不能超过2小时').optional().nullable(), submissionNote: z.string().trim().max(300, '备注留言不能超过300个字符').optional().nullable(), submissionNotePublic: z.boolean().optional(), preferredPlayTimeId: z.preprocess( (value) => value === null || value === undefined || value === '' ? null : Number(value), z.number().int().positive('播出时段 ID 无效').nullable() ).optional(), cardCode: z.string().trim().max(100, 'CARD_CODE_TOO_LONG').optional().nullable(), collaborators: z.array(z.union([z.string(), z.number()])).max(20, '联合投稿人不能超过20个').optional() }) export async function requestSongForUser(event: any, user: SongRequestUser, body: any) { const parsedBody = songRequestBodySchema.safeParse(body || {}) if (!parsedBody.success) { const issues = parsedBody.error.issues || [] const cardCodeIssue = issues.find((issue) => String(issue.message).startsWith('CARD_CODE_')) if (cardCodeIssue) { throw createApiError(400, cardCodeIssue.message, 'Invalid request card') } throw createError({ statusCode: 400, message: issues.length ? `请求参数验证失败:${issues.map((issue) => issue.message).join(', ')}` : '请求参数验证失败' }) } const requestBody = parsedBody.data try { // 标准化后再比较,避免同一首歌因标点或空格差异绕过重复检查。 const normalizedTitle = normalizeForMatch(requestBody.title) const normalizedArtist = normalizeForMatch(requestBody.artist) const currentSemester = await getCurrentSemesterName() const systemSettingsData = await getSystemSettingsCached() const isAdmin = ['SUPER_ADMIN', 'ADMIN', 'SONG_ADMIN'].includes(user.role) const restrictionPolicy = resolveSubmissionRestrictionPolicy(systemSettingsData) const semesterRestrictionOn = restrictionPolicy.mode === 'semester' && !isAdmin const isBilibili = requestBody.musicPlatform === 'bilibili' || String(requestBody.musicId || '').startsWith('BV') || String(requestBody.musicId || '').startsWith('av') // 仅在重复投稿限制开启且未配置冷却时长时,沿用同一学期内同一首歌只能投稿一次的规则;管理员始终豁免。 if (isBilibili && requestBody.musicId && semesterRestrictionOn) { let fullMusicId = String(requestBody.musicId) const bvId = fullMusicId.split(':')[0] if (requestBody.bilibiliCid) { const musicIdParts = [bvId, requestBody.bilibiliCid] if (requestBody.bilibiliPage && Number(requestBody.bilibiliPage) > 1) { musicIdParts.push(String(requestBody.bilibiliPage)) } fullMusicId = musicIdParts.join(':') } 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) { throw createError({ statusCode: 400, message: `《${requestBody.title}》已经在列表中,不能重复投稿` }) } } else if (semesterRestrictionOn) { 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) { throw createError({ statusCode: 400, message: `《${requestBody.title}》已经在列表中,不能重复投稿` }) } } // 重复投稿限制:同一首歌 / 同一歌手在排期后 N 小时内不可再次投稿 if (restrictionPolicy.mode === 'window' && !isAdmin) { const now = getServerDate() const { sameSongHours, sameArtistHours, scope } = restrictionPolicy const maxHours = Math.max(sameSongHours || 0, sameArtistHours || 0) const cutoff = new Date(now.getTime() - maxHours * 3600000) // 按 scope 构建 where 子句(显式短路,避免依赖 and(undefined) 的版本行为) const scopeFilter = scope === 'self' ? eq(songs.requesterId, user.id) : undefined const whereClause = and( eq(schedules.isDraft, false), or( and(lte(schedules.playDate, now), gte(schedules.playDate, cutoff)), and( gt(schedules.playDate, now), or(gte(schedules.publishedAt, cutoff), gte(schedules.createdAt, cutoff)) ) ), ...(scopeFilter ? [scopeFilter] : []) ) const scheduledSongs = await db .select({ sPlayDate: schedules.playDate, sCreatedAt: schedules.createdAt, sPublishedAt: schedules.publishedAt, songTitle: songs.title, songArtist: songs.artist, songRequesterId: songs.requesterId }) .from(schedules) .innerJoin(songs, eq(schedules.songId, songs.id)) .where(whereClause) for (const scheduled of scheduledSongs) { const playDate = scheduled.sPlayDate instanceof Date ? scheduled.sPlayDate : new Date(scheduled.sPlayDate) const createdAt = scheduled.sCreatedAt instanceof Date ? scheduled.sCreatedAt : new Date(scheduled.sCreatedAt) const publishedAt = scheduled.sPublishedAt ? (scheduled.sPublishedAt instanceof Date ? scheduled.sPublishedAt : new Date(scheduled.sPublishedAt)) : null // 已播放歌曲:窗口从播放时间起算;未播放歌曲:窗口从排期时间起算 const windowStart = playDate.getTime() <= now.getTime() ? playDate.getTime() : (publishedAt || createdAt).getTime() const songWindowMs = (windowStart + (sameSongHours || 0) * 3600000) - now.getTime() const artistWindowMs = (windowStart + (sameArtistHours || 0) * 3600000) - now.getTime() const songWindowActive = (sameSongHours || 0) > 0 && songWindowMs > 0 const artistWindowActive = (sameArtistHours || 0) > 0 && artistWindowMs > 0 if (!songWindowActive && !artistWindowActive) continue const scheduledTitleNorm = normalizeForMatch(scheduled.songTitle || '') const scheduledArtistNorm = normalizeForMatch(scheduled.songArtist || '') if (songWindowActive && scheduledTitleNorm === normalizedTitle && scheduledArtistNorm === normalizedArtist) { throw createApiError( 400, SERVER_ERROR_CODES.SONG_RESTRICTION_SAME_SONG, '同一首歌在排期后一段时间内不能重复投稿', { params: [sameSongHours, requestBody.title] } ) } if (artistWindowActive && scheduledArtistNorm === normalizedArtist) { throw createApiError( 400, SERVER_ERROR_CODES.SONG_RESTRICTION_SAME_ARTIST, '同一歌手在排期后一段时间内不能重复投稿', { params: [sameArtistHours, scheduled.songArtist] } ) } } } if (systemSettingsData?.forceBlockAllRequests && !isAdmin) { throw createError({ statusCode: 403, message: '投稿功能已关闭' }) } let hitRequestTime: any = null if (systemSettingsData?.enableRequestTimeLimitation && !isAdmin) { 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' } if (effectiveLimit === 0) { throw createError({ statusCode: 403, message: '投稿功能已关闭' }) } } // 强制使用点歌券仅在点歌券功能启用时生效 if ( systemSettingsData?.requireCardCodeForRequests && systemSettingsData?.enableCardCodeRequests && !isAdmin ) { const providedCardCode = requestBody.cardCode ? requestBody.cardCode.trim().toUpperCase() : '' if (!providedCardCode) { throw createApiError( 403, 'CARD_CODE_REQUIRED_FOR_SITE', 'This site requires a valid request card to submit songs' ) } } const isCardCodeEnabled = systemSettingsData?.enableCardCodeRequests === true const excludeCardCodeRequestsFromLimit = isCardCodeLimitBypassActive(systemSettingsData) if (requestBody.cardCode && requestBody.cardCode.trim() && !isCardCodeEnabled && !isAdmin) { throw createApiError(400, 'CARD_CODE_DISABLED', 'Request card submissions are not enabled') } let preferredPlayTime = null if (requestBody.preferredPlayTimeId) { if (!systemSettingsData?.enablePlayTimeSelection) { throw createError({ statusCode: 400, message: '播出时段选择功能未启用' }) } const playTimeResult = await db .select() .from(playTimes) .where(and(eq(playTimes.id, requestBody.preferredPlayTimeId), eq(playTimes.enabled, true))) .limit(1) preferredPlayTime = playTimeResult[0] if (!preferredPlayTime) { throw createError({ statusCode: 400, message: '选择的播出时段不存在或未启用' }) } } const rawSubmissionNote = requestBody.submissionNote || '' const submissionNote = systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null // 公开留言审核:开关开启时投稿不立即公开,进入待审后由管理员通过 const noteRequiresApproval = systemSettingsData?.submissionNoteRequiresApproval === true const wantsPublic = submissionNote !== null ? requestBody.submissionNotePublic !== false : false const submissionNotePublic = noteRequiresApproval ? false : wantsPublic // 仅用户勾选公开的留言进入审核,私密留言只供管理员查看 const submissionNotePublicStatus = noteRequiresApproval && submissionNote !== null && wantsPublic ? SUBMISSION_NOTE_STATUS.PENDING : null const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = [] const song = await db.transaction(async (tx) => { let providedCardCodeId: number | null = null const providedCardCode = requestBody.cardCode ? requestBody.cardCode.trim().toUpperCase() : '' if (providedCardCode) { const codeRows = await tx .select() .from(cardCodes) .where(eq(cardCodes.code, providedCardCode)) .limit(1) const found = codeRows[0] if (!found || found.status !== 'AVAILABLE') { throw createApiError( 400, 'CARD_CODE_INVALID_OR_USED', 'Request card is invalid or already used' ) } const lockResult = await tx .update(cardCodes) .set({ status: 'LOCKED', lockedBy: user.id, lockedAt: new Date() }) .where(and(eq(cardCodes.id, found.id), eq(cardCodes.status, 'AVAILABLE'))) .returning() if (lockResult.length === 0) { throw createApiError( 400, 'CARD_CODE_LOCKED_OR_UNAVAILABLE', 'Request card is locked or unavailable' ) } providedCardCodeId = found.id } if ( systemSettingsData?.enableSubmissionLimit && !isAdmin && !(excludeCardCodeRequestsFromLimit && providedCardCodeId) && effectiveLimit && effectiveLimit > 0 && limitType ) { // 同一用户的限额检查必须串行,否则并发请求可能读取到相同计数并同时越过上限。 await tx .select({ id: users.id }) .from(users) .where(eq(users.id, user.id)) .for('update') if ( await isLimitReached(tx as any, user.id, limitType, effectiveLimit, { excludeCardCodeRequests: excludeCardCodeRequestsFromLimit }) ) { 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), latestRequestTime.expected > 0 ? lt(requestTimes.accepted, latestRequestTime.expected) : undefined ) ) .returning() if (updateResult.length === 0) { throw createError({ statusCode: 403, message: '当前时段投稿名额已满' }) } } let finalMusicId = requestBody.musicId ? String(requestBody.musicId) : null if (isBilibili) { const bvId = finalMusicId?.split(':')[0] if (bvId) { const musicIdParts = [bvId] if (requestBody.bilibiliCid) { musicIdParts.push(requestBody.bilibiliCid) if (requestBody.bilibiliPage && Number(requestBody.bilibiliPage) > 1) { musicIdParts.push(String(requestBody.bilibiliPage)) } } finalMusicId = musicIdParts.join(':') } } const songResult = await tx .insert(songs) .values({ title: requestBody.title, artist: requestBody.artist, requesterId: user.id, preferredPlayTimeId: preferredPlayTime?.id || null, semester: currentSemester, cover: requestBody.cover || null, musicPlatform: isBilibili ? 'bilibili' : requestBody.musicPlatform || null, musicId: finalMusicId, cardCodeId: providedCardCodeId || null, playUrl: requestBody.playUrl || null, durationSeconds: requestBody.durationSeconds || null, submissionNote, submissionNotePublic, submissionNotePublicStatus, hitRequestId: hitRequestTime?.id || null }) .returning() const newSong = songResult[0] if (!newSong) { throw createError({ statusCode: 500, message: '点歌失败,请稍后重试' }) } if ( requestBody.collaborators && Array.isArray(requestBody.collaborators) && requestBody.collaborators.length > 0 ) { const collaboratorIds = requestBody.collaborators.map((id: any) => Number(id)) as number[] const uniqueCollaboratorIds = [...new Set(collaboratorIds)].filter( (id) => !isNaN(id) && id !== user.id ) if (uniqueCollaboratorIds.length > 0) { const validUsers = await tx .select({ id: users.id }) .from(users) .where(inArray(users.id, uniqueCollaboratorIds)) const validUserIds = new Set(validUsers.map((item) => item.id)) for (const collaboratorId of uniqueCollaboratorIds) { if (!validUserIds.has(collaboratorId)) 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] if (!collab) continue await tx.insert(collaborationLogs).values({ collaboratorId: collab.id, action: 'INVITE', operatorId: user.id, ipAddress: getClientIP(event) }) notificationsToSend.push({ userId: collaboratorId, songId: newSong.id, songTitle: newSong.title }) } catch (err) { console.error(`邀请用户 ${collaboratorId} 失败:`, err) } } } } return newSong }) // 投稿后立即补齐或校验歌曲时长(不阻塞请求响应) const submitDuration = song.durationSeconds const submitPlatform = song.musicPlatform const submitMusicId = song.musicId if (submitPlatform && submitMusicId) { const durationValidationTask = (async () => { try { const decision = await reconcileSongDurationOnSubmit(song.id, submitPlatform, submitMusicId, submitDuration) if (decision.outcome === 'fill' || decision.outcome === 'clear') { await db.update(songs).set({ durationSeconds: decision.durationSeconds }).where(eq(songs.id, song.id)) if (decision.outcome === 'fill') { console.log(`[投稿时长] #${song.id} 已补齐时长 ${decision.durationSeconds}s`) } } } catch (err) { console.error(`[投稿时长] 后台任务 #${song.id} 异常:`, err) } })() if (typeof event.waitUntil === 'function') { event.waitUntil(durationValidationTask) } else { durationValidationTask.catch((err) => console.error(`[投稿时长] 后台任务 #${song.id} 异常:`, err)) } } 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 } }