Files
BYW-VoiceHub/server/api/songs/request.post.ts
T
Henry_Du d818819fff
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 1m24s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s
feat: 实现主题、黑名单、排程等14项功能迭代
- style(theme): 修复浅色模式不生效问题,为 ClassicLight 和 ModernLight 增加独立差异化 CSS 变量定义(主色/圆角/阴影/背景对比度)
- feat(blacklist): 后端放开 LANGUAGE/GENRE 类型校验;check API 新增语种(CJK/拉丁/假名/谚文/西里尔/阿拉伯)和曲风关键词匹配逻辑;前端 BlacklistManager UI 增加 语种/曲风 四类型切换、类型筛选、图标与标签颜色映射、输入提示
- fix(DataAnalysisPanel): 活跃用户列表容器加 max-h-[400px] + overflow-y-auto + 底部渐变遮罩;tooltip 加 max-height + truncate 防止超高;头像失败回退首字母
- feat(admin): 新增 user-avatar.ts 工具(OAuth→Gravatar→DiceBear 三级兜底);active-users/realtime/users 列表返回 avatarUrl;DataAnalysisPanel/UserManager 展示圆形头像;权限分级 ROLE_LEVELS,SUPER_ADMIN 全权限,ADMIN 仅可管理 ≤ USER 级用户的密码与角色,前端按钮联动禁用
- fix+style(schedule): ScheduleManager 标签页 sticky min-h 防抖、间距调优、表单分组、移动端 padding 统一
- feat(schedule): 自动排期新增「按投稿人数优先覆盖」策略(算法:requester + collaborators 总数降序),支持 CustomSelect 切换;新增 localStorage voicehub:schedule:auto-config 持久化 7 项配置(策略/时段/年级/排序/标签/搜索/学期)500ms debounce
- fix(schedule): songs/index.get 新增 usePool 参数,从 schedule_song_pool(status=PENDING,targetPlayDate IS NULL) 取歌并去重,排除已排期 songs.played=false;schedule.post 和 draft-bulk 分配后更新池状态为 ASSIGNED 避免重复分配
- feat(player): ScheduleManager 新增「构建播放队列」按钮,按 playDate→时段→sequence 排序后经 useAudioPlayer.playSong 整列载入并 toast 反馈
- fix(db): db.ts 检测 NETLIFY/VERCEL serverless 环境使用最小池 max=1 idle_timeout=1 keep_alive=false,Node Server 保持 max=10
- feat(site-config): site-config.get.ts 与 useSiteConfig.js 导出 statisticsCode 和 statisticsCodeEnabled;index.vue 挂载后解析 HTML 动态重插 script 元素确保第三方统计执行
- fix(schedule-printer): ScheduleItemPrint 封面 aspect-square object-cover;print-fix.css 打印时强制 48px×48px contain、去除圆角阴影
- fix(songRequest): 新增 server/utils/url.ts normalizeCoverUrl(trim/去零宽空格/相对协议补 https/非 http 丢弃/URL.parse 兜底);request.post/add.post/import.post 三处写入 cover/coverUrl 前统一规范化
2026-09-12 08:40:59 +08:00

543 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'
import { normalizeCoverUrl } from '~~/server/utils/url'
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 normalizedCover = normalizeCoverUrl(body.cover, `request-song:${body.title || ''}`)
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: normalizedCover,
coverUrl: normalizedCover,
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
}
}