Files
Henry_Du 4ce4649aaf
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s
初次提交
2026-09-06 08:19:02 +08:00

467 lines
15 KiB
TypeScript
Raw Permalink 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,
semesters,
songCollaborators,
songs,
systemSettings
} from '~/drizzle/db'
import { and, eq, gt, gte, lt, lte, sql } from 'drizzle-orm'
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
import { isLimitReached } from '~~/server/utils/submissionLimit'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
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'
// 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 submissionNotePublic =
submissionNote !== null ? body.submissionNotePublic !== false : false
// 准备发送通知的列表
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: '当前时段投稿名额已满' })
}
}
// 创建歌曲
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 songResult = await tx
.insert(songs)
.values({
title: body.title,
artist: body.artist,
requesterId: user.id,
preferredPlayTimeId: preferredPlayTime?.id || null,
semester: currentSemester, // 使用外部获取的学期名称
cover: body.cover || null,
musicPlatform: isBilibili ? 'bilibili' : (body.musicPlatform || null),
musicId: finalMusicId,
playUrl: body.playUrl || null,
submissionNote,
submissionNotePublic,
hitRequestId: hitRequestTime?.id || 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
}
}