Files
BYW-VoiceHub/server/api/songs/request.post.ts
T
Henry_Du bd8df1f026
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 6m31s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s
feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
- 新增 /api/admin/songs/batch-reject.post 批量驳回接口(事务清排期/投票/扣接纳数/黑名单去重/异步通知)
- requireSongAdmin 三角色校验 + createApiError 统一错误包装
- scheduleDaysBefore/After 四字段排期可见范围限制(开放 API 与公开排期接口三处缓存路径 + DB 条件过滤)
- submissionNoteAutoModeration 基于黑名单 KEYWORD/LANGUAGE/GENRE 自动判定留言 publicStatus,命中自动关闭 submissionNotePublic
- 更新 songs/request.post 与 songs/[id]/update.put 接入自动审查逻辑
2026-09-06 17:03:20 +08:00

540 lines
18 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,
semesters,
songBlacklists,
songCollaborators,
songs,
systemSettings
} from '~/drizzle/db'
import { and, eq, gt, gte, inArray, lt, lte, sql } from 'drizzle-orm'
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
import { isLimitReached } from '~~/server/utils/submissionLimit'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
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'
// 加载自动审查需要的黑名单(KEYWORD / LANGUAGE / GENRE)
let moderationBlacklists: any[] = []
try {
moderationBlacklists = await db
.select()
.from(songBlacklists)
.where(inArray(songBlacklists.type, ['KEYWORD', 'LANGUAGE', 'GENRE']))
} catch (err) {
console.warn('[Request API] 加载自动审查黑名单失败,将跳过自动审查:', err)
moderationBlacklists = []
}
// 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 rawSubmissionNotePublic = submissionNote !== null ? body.submissionNotePublic !== false : false
const requiresApproval = Boolean(systemSettingsData?.submissionNoteRequiresApproval)
let finalSubmissionNotePublic = rawSubmissionNotePublic
let submissionNotePublicStatus: string | null = null
if (submissionNote && rawSubmissionNotePublic && requiresApproval) {
const mod = moderateSubmissionNote(
submissionNote,
rawSubmissionNotePublic,
requiresApproval,
{ title: body.title, artist: body.artist, genre: body.genre || null },
moderationBlacklists
)
finalSubmissionNotePublic = mod.notePublic
submissionNotePublicStatus = mod.publicStatus
if (mod.rejectedReasons.length > 0) {
console.log(
`[Request API] 投稿留言自动审查未通过,歌曲=${body.title} - ${body.artist},原因:`,
mod.rejectedReasons
)
}
} else if (submissionNote) {
// 没开自动审查,也记录一下状态
submissionNotePublicStatus = null
}
const submissionNotePublic = finalSubmissionNotePublic
// 准备发送通知的列表
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,
submissionNotePublicStatus,
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
}
}