499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
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: '当前时段投稿名额已满' })
|
||
}
|
||
}
|
||
|
||
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 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: body.cover || null,
|
||
coverUrl: body.cover || null,
|
||
musicPlatform: isBilibili ? 'bilibili' : (body.musicPlatform || null),
|
||
musicId: finalMusicId,
|
||
kgHash: body.musicPlatform === 'kugou' ? body.musicId : null,
|
||
playUrl: body.playUrl || null,
|
||
submissionNote,
|
||
submissionNotePublic,
|
||
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
|
||
}
|
||
}
|