Files
BYW-VoiceHub/server/services/songRequestService.ts
T

646 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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),
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<number>(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
}
}