- 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 前统一规范化
543 lines
18 KiB
TypeScript
543 lines
18 KiB
TypeScript
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
|
||
}
|
||
}
|