初次提交
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user