feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+359
View File
@@ -0,0 +1,359 @@
import { db } from '~/drizzle/db'
import {
songs,
users,
songCollaborators,
collaborationLogs,
songReplayRequests
} from '~/drizzle/schema'
import { eq, or, and } from 'drizzle-orm'
import { createSubmissionNoteClearedNotification } from '~~/server/services/notificationService'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES, SUBMISSION_NOTE_STATUS } from '~~/server/config/constants'
import { getServerDate } from '~~/server/utils/serverTime'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
try {
// 验证请求方法
if (event.node.req.method !== 'PUT') {
throw createApiError(405, 'HTTP_METHOD_NOT_ALLOWED', 'Method Not Allowed')
}
// 获取已验证的用户信息(由中间件提供)
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
}
// 检查权限
if (!['ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createApiError(403, 'COMMON_INSUFFICIENT_PERMISSION', '权限不足')
}
// 获取歌曲ID
const songId = parseInt(getRouterParam(event, 'id'))
if (!songId) {
throw createApiError(400, SERVER_ERROR_CODES.SONG_INVALID_ID, 'Invalid song ID')
}
// 获取请求体
const body = await readBody(event)
const {
title,
artist,
requester,
semester,
musicPlatform,
musicId,
cover,
playUrl,
preferredPlayTimeId,
durationSeconds
} = body
const ipAddress = getClientIP(event)
// 验证必填字段
if (!title || !artist) {
throw createApiError(400, SERVER_ERROR_CODES.SONG_TITLE_ARTIST_REQUIRED, 'Title and artist are required')
}
// 校验时长范围(0秒~2小时,允许管理员输入任意合理值,后台会二次校验)
if (durationSeconds !== null && durationSeconds !== undefined) {
const d = Number(durationSeconds)
if (!Number.isFinite(d) || d < 0 || d > 7200) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'Invalid song duration (0s–2h)')
}
}
const existingSongResult = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
const existingSong = existingSongResult[0]
if (!existingSong) {
throw createApiError(404, 'SONG_NOT_FOUND', '歌曲不存在')
}
const updateData: Partial<typeof songs.$inferInsert> = {
title: title.trim(),
artist: artist.trim()
}
if (body.semester !== undefined) updateData.semester = body.semester || null
if (body.preferredPlayTimeId !== undefined)
updateData.preferredPlayTimeId = body.preferredPlayTimeId || null
if (body.musicPlatform !== undefined) updateData.musicPlatform = body.musicPlatform || null
if (body.musicId !== undefined) updateData.musicId = body.musicId || null
if (body.cover !== undefined) updateData.cover = body.cover || null
if (body.playUrl !== undefined) updateData.playUrl = body.playUrl || null
if (durationSeconds !== undefined) updateData.durationSeconds = durationSeconds ?? null
// 处理投稿人
if ('requester' in body) {
const requester = body.requester
// 只有在传递有效投稿人信息时才更新
if (requester && requester !== '' && requester !== 0) {
// 查找投稿人用户
const conditions = []
if (typeof requester === 'number') {
conditions.push(eq(users.id, requester))
}
if (typeof requester === 'string') {
conditions.push(eq(users.username, requester))
conditions.push(eq(users.name, requester))
}
const requesterUser = await db
.select()
.from(users)
.where(or(...conditions))
.limit(1)
if (requesterUser.length === 0) {
throw createApiError(404, 'SONG_REQUESTER_NOT_FOUND', '投稿人用户不存在')
}
// 设置投稿人
updateData.requesterId = requesterUser[0].id
}
// 如果投稿人为空值,则跳过处理,保持原有投稿人不变
}
const shouldClearSubmissionNote = body.clearSubmissionNote === true
const submissionNoteClearReason =
typeof body.submissionNoteClearReason === 'string'
? body.submissionNoteClearReason.trim()
: ''
const shouldNotifySubmissionNoteClear = body.notifyOnSubmissionNoteClear === true
if (shouldClearSubmissionNote) {
updateData.submissionNote = null
// 清空备注时同步撤销公开与审核状态
updateData.submissionNotePublic = false
updateData.submissionNotePublicStatus = null
} else if ('submissionNote' in body) {
updateData.submissionNote =
typeof body.submissionNote === 'string' && body.submissionNote.trim()
? body.submissionNote.trim()
: null
}
if ('submissionNotePublicStatus' in body) {
// 公开留言审核动作:approved=通过并公开;rejected=拒绝;null=撤销公开;其他值返回 400
const st = body.submissionNotePublicStatus
if (st === 'approved') {
updateData.submissionNotePublic = true
updateData.submissionNotePublicStatus = SUBMISSION_NOTE_STATUS.APPROVED
} else if (st === 'rejected') {
updateData.submissionNotePublic = false
updateData.submissionNotePublicStatus = SUBMISSION_NOTE_STATUS.REJECTED
} else if (st === null || st === undefined) {
updateData.submissionNotePublic = false
updateData.submissionNotePublicStatus = null
} else {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '无效的公开留言审核状态')
}
} else if ('submissionNotePublic' in body) {
// 兼容旧语义:置 true 视为通过审核,置 false 视为撤销公开
const notePublic = body.submissionNotePublic === true
updateData.submissionNotePublic = notePublic
updateData.submissionNotePublicStatus = notePublic ? SUBMISSION_NOTE_STATUS.APPROVED : null
}
const currentRequesterId = updateData.requesterId || existingSong.requesterId
// 如果指定了 replayRequestId 且本次携带备注可见性变更,则更新对应重播申请(条件更新,避免隐性重置)
let replaySet: Record<string, unknown> | null = null
if ('replayRequestId' in body) {
const replayRequestId = body.replayRequestId ? Number(body.replayRequestId) : null
const hasNoteVisibilityChange =
'submissionNotePublicStatus' in body || 'submissionNotePublic' in body
if (body.replayRequestId !== null && body.replayRequestId !== undefined &&
(!Number.isInteger(replayRequestId) || replayRequestId <= 0)) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '重播申请 ID 无效')
}
if (replayRequestId && hasNoteVisibilityChange) {
replaySet = { updatedAt: getServerDate() }
if ('submissionNotePublicStatus' in body) {
const st = body.submissionNotePublicStatus
if (st === 'approved') {
replaySet.submissionNotePublic = true
replaySet.submissionNotePublicStatus = SUBMISSION_NOTE_STATUS.APPROVED
} else if (st === 'rejected') {
replaySet.submissionNotePublic = false
replaySet.submissionNotePublicStatus = SUBMISSION_NOTE_STATUS.REJECTED
} else if (st === null || st === undefined) {
replaySet.submissionNotePublic = false
replaySet.submissionNotePublicStatus = null
} else {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '无效的公开留言审核状态')
}
} else {
const notePublic = body.submissionNotePublic === true
replaySet.submissionNotePublic = notePublic
replaySet.submissionNotePublicStatus = notePublic ? SUBMISSION_NOTE_STATUS.APPROVED : null
}
delete updateData.submissionNotePublic
delete updateData.submissionNotePublicStatus
}
}
// 歌曲更新与重播申请更新放入同一事务,保证一致性
const updatedSongResult = await db.transaction(async (tx) => {
if (replaySet) {
const replayRequestId = body.replayRequestId ? Number(body.replayRequestId) : null
const replayResult = await tx
.update(songReplayRequests)
.set(replaySet)
.where(
and(eq(songReplayRequests.id, replayRequestId), eq(songReplayRequests.songId, songId))
)
.returning({ id: songReplayRequests.id })
if (replayResult.length === 0) {
throw createApiError(404, SERVER_ERROR_CODES.COMMON_TARGET_NOT_FOUND, '重播申请不存在')
}
}
const result = await tx
.update(songs)
.set(updateData)
.where(eq(songs.id, songId))
.returning()
return result[0]
})
// 处理联合投稿人
if ('collaborators' in body && Array.isArray(body.collaborators)) {
// 获取现有联合投稿人
const existingCollaborators = await db
.select()
.from(songCollaborators)
.where(eq(songCollaborators.songId, songId))
const existingCollaboratorUserIds = existingCollaborators.map((c) => c.userId)
const newCollaboratorIds = [
...new Set(
body.collaborators
.map((id: number | string) => Number(id))
.filter((id: number) => Number.isInteger(id) && id > 0 && id !== currentRequesterId)
)
]
// 需要添加的
const toAdd = newCollaboratorIds.filter(
(id: number) => !existingCollaboratorUserIds.includes(id)
)
// 需要删除的
const toDelete = existingCollaborators.filter((c) => !newCollaboratorIds.includes(c.userId))
// 执行添加
for (const userId of toAdd) {
// 检查用户是否存在
const userExists = await db.select().from(users).where(eq(users.id, userId)).limit(1)
if (userExists.length > 0) {
const [newCollab] = await db
.insert(songCollaborators)
.values({
songId: songId,
userId: userId,
status: 'ACCEPTED'
})
.returning()
// 记录日志
await db.insert(collaborationLogs).values({
collaboratorId: newCollab.id,
action: 'ADMIN_ADD',
operatorId: user.id,
ipAddress
})
}
}
// 执行删除
for (const collab of toDelete) {
await db.delete(songCollaborators).where(eq(songCollaborators.id, collab.id))
// 记录日志
await db.insert(collaborationLogs).values({
collaboratorId: collab.id,
action: 'ADMIN_REMOVE',
operatorId: user.id,
ipAddress
})
}
}
if (
shouldClearSubmissionNote &&
existingSong.submissionNote &&
shouldNotifySubmissionNoteClear
) {
const latestCollaborators = await db
.select({
userId: songCollaborators.userId
})
.from(songCollaborators)
.where(eq(songCollaborators.songId, songId))
const notifyUserIds = [
currentRequesterId,
...latestCollaborators.map((collaborator) => collaborator.userId)
].filter((userId): userId is number => Number.isInteger(userId) && userId > 0)
createSubmissionNoteClearedNotification(
notifyUserIds,
{ title: updatedSongResult.title, artist: updatedSongResult.artist },
submissionNoteClearReason
).catch((error) => {
console.error('发送歌曲备注清空通知失败:', error)
})
}
// 获取完整的歌曲信息(包含投稿人)
const updatedSong = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist,
semester: songs.semester,
musicPlatform: songs.musicPlatform,
musicId: songs.musicId,
cover: songs.cover,
playUrl: songs.playUrl,
durationSeconds: songs.durationSeconds,
submissionNote: songs.submissionNote,
submissionNotePublic: songs.submissionNotePublic,
requesterId: songs.requesterId,
createdAt: songs.createdAt,
updatedAt: songs.updatedAt,
requester: {
id: users.id,
username: users.username,
name: users.name
}
})
.from(songs)
.leftJoin(users, eq(songs.requesterId, users.id))
.where(eq(songs.id, songId))
.limit(1)
return {
success: true,
song: updatedSong[0]
}
} catch (error) {
console.error('Update song error:', error)
// 业务错误(带 statusCode)直接透传
if (error?.statusCode) {
throw error
}
// 非业务错误(数据库连接等)包装为统一的 500 错误码
throw createApiError(
500,
SERVER_ERROR_CODES.AUTH_SYSTEM_ERROR,
error?.message || 'Internal server error'
)
}
})
+98
View File
@@ -0,0 +1,98 @@
import { createError, defineEventHandler, getRouterParam } from 'h3'
import { db } from '~/drizzle/db'
import { songs, users, votes } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
try {
// 检查用户认证
const user = event.context.user
if (!user) {
throw createApiError(401, 'SONG_LOGIN_REQUIRED_VIEW_VOTERS', '需要登录才能查看投票人员')
}
// 检查管理员权限
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createApiError(403, 'SONG_VOTERS_ADMIN_ONLY', '只有管理员才能查看投票人员列表')
}
// 获取歌曲ID
const songId = parseInt(getRouterParam(event, 'id') || '0')
if (!songId || isNaN(songId)) {
throw createApiError(400, 'SONG_INVALID_ID', '无效的歌曲ID')
}
// 检查歌曲是否存在
const songResult = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist
})
.from(songs)
.where(eq(songs.id, songId))
.limit(1)
const song = songResult[0]
if (!song) {
throw createApiError(404, 'SONG_NOT_FOUND', '歌曲不存在')
}
// 获取投票人员列表
const votesResult = await db
.select({
userId: votes.userId,
createdAt: votes.createdAt,
userName: users.name,
username: users.username,
grade: users.grade,
class: users.class
})
.from(votes)
.innerJoin(users, eq(votes.userId, users.id))
.where(eq(votes.songId, songId))
.orderBy(votes.createdAt)
// 处理用户名显示逻辑,总是显示年级和班级信息
const votersWithDisplayName = votesResult.map((vote) => {
let displayName = vote.userName || vote.username
// 如果有年级信息,添加年级和班级后缀
if (vote.grade) {
if (vote.class) {
displayName = `${displayName}(${vote.grade} ${vote.class})`
} else {
displayName = `${displayName}(${vote.grade})`
}
}
return {
id: vote.userId,
name: displayName,
votedAt: vote.createdAt
}
})
return {
song: {
id: song.id,
title: song.title,
artist: song.artist
},
voters: votersWithDisplayName,
totalVotes: votersWithDisplayName.length
}
} catch (error: any) {
console.error('获取投票人员列表失败:', error)
if (error.statusCode) {
throw error
}
throw createApiError(500, 'SONG_FETCH_VOTERS_FAILED', '获取投票人员列表失败')
}
})
+151
View File
@@ -0,0 +1,151 @@
import { db } from '~/drizzle/db'
import { songs, users } from '~/drizzle/schema'
import { and, eq, or } from 'drizzle-orm'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
export default defineEventHandler(async (event) => {
try {
// 验证请求方法
if (event.node.req.method !== 'POST') {
throw createApiError(405, 'HTTP_METHOD_NOT_ALLOWED', 'Method Not Allowed')
}
// 获取已验证的用户信息(由中间件提供)
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_UNAUTHORIZED_ACCESS', '未授权访问')
}
// 检查权限
if (!['ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createApiError(403, 'COMMON_INSUFFICIENT_PERMISSION', '权限不足')
}
// 获取请求体
const body = await readBody(event)
const {
title,
artist,
requester,
semester,
musicPlatform,
musicId,
cover,
playUrl,
durationSeconds,
preferredPlayTimeId
} = body
// 验证必填字段
if (!title || !artist) {
throw createApiError(400, SERVER_ERROR_CODES.SONG_TITLE_ARTIST_REQUIRED, 'Title and artist are required')
}
// 校验时长范围(30秒~1小时)
if (durationSeconds !== null && durationSeconds !== undefined) {
const d = Number(durationSeconds)
if (!Number.isFinite(d) || d < 30 || d > 3600) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'Invalid song duration (30s–1h)')
}
}
// 查找投稿人用户
let requesterId = null
if (requester) {
// 检查requester是否为数字ID
const parsedId = parseInt(requester)
let requesterUser = null
if (!isNaN(parsedId) && parsedId > 0) {
// 如果是有效的数字ID,直接按ID查找
const userResult = await db.select().from(users).where(eq(users.id, parsedId)).limit(1)
requesterUser = userResult[0]
} else {
// 如果不是数字ID,按用户名或姓名查找
const userResult = await db
.select()
.from(users)
.where(or(eq(users.username, String(requester)), eq(users.name, String(requester))))
.limit(1)
requesterUser = userResult[0]
}
requesterId = requesterUser?.id || null
}
// 检查歌曲是否已存在
const existingSongResult = await db
.select()
.from(songs)
.where(
and(
eq(songs.title, title.trim()),
eq(songs.artist, artist.trim()),
requesterId ? eq(songs.requesterId, requesterId) : eq(songs.requesterId, null)
)
)
.limit(1)
const existingSong = existingSongResult[0]
if (existingSong) {
throw createApiError(409, 'SONG_ALREADY_EXISTS', 'Song already exists')
}
// 创建歌曲
const newSongResult = await db
.insert(songs)
.values({
title: title.trim(),
artist: artist.trim(),
requesterId,
semester: semester || null,
preferredPlayTimeId: preferredPlayTimeId || null,
musicPlatform: musicPlatform || null,
musicId: musicId || null,
durationSeconds: durationSeconds ? Number(durationSeconds) : null,
cover: cover || null,
playUrl: playUrl || null
})
.returning()
const newSong = newSongResult[0]
// 获取投稿人信息
let requesterInfo = null
if (newSong.requesterId) {
const requesterResult = await db
.select({
id: users.id,
username: users.username,
name: users.name
})
.from(users)
.where(eq(users.id, newSong.requesterId))
.limit(1)
requesterInfo = requesterResult[0]
}
// 组合返回数据
const songWithRequester = {
...newSong,
requester: requesterInfo
}
return {
success: true,
song: songWithRequester
}
} catch (error) {
console.error('Add song error:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: error.message || 'Internal server error'
})
}
})
+168
View File
@@ -0,0 +1,168 @@
import { db, schedules, semesters, songs } from '~/drizzle/db'
import { and, eq, gte, gt, lte, or } from 'drizzle-orm'
import { createApiError } from '~~/server/utils/apiError'
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
import { getServerDate } from '~~/server/utils/serverTime'
import { normalizeForMatch } from '~~/server/utils/song-name-normalize'
import { resolveSubmissionRestrictionPolicy } from '~~/server/utils/submission-restriction-policy'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
type RestrictionCheck = {
blocked: boolean
reason: string | null
}
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createApiError(401, SERVER_ERROR_CODES.SONG_LOGIN_REQUIRED_VIEW_STATUS, '需要登录才能检查投稿限制')
}
const body = await readBody(event)
const isBatch = Array.isArray(body?.songs)
const candidates = (isBatch ? body.songs : [body]).slice(0, 20).map((song) => ({
title: typeof song?.title === 'string' ? song.title.trim() : '',
artist: typeof song?.artist === 'string' ? song.artist.trim() : '',
musicPlatform: typeof song?.musicPlatform === 'string' ? song.musicPlatform : '',
musicId:
typeof song?.musicId === 'string' || typeof song?.musicId === 'number'
? String(song.musicId)
: ''
}))
const respond = (checks: RestrictionCheck[]) =>
isBatch ? { checks } : checks[0] || { blocked: false, reason: null }
const allowedChecks = candidates.map(() => ({ blocked: false, reason: null }))
if (candidates.length === 0) {
return respond(allowedChecks)
}
const isAdmin = ['SUPER_ADMIN', 'ADMIN', 'SONG_ADMIN'].includes(user.role)
const settings = await getSystemSettingsCached()
const restrictionPolicy = resolveSubmissionRestrictionPolicy(settings)
if (restrictionPolicy.mode === 'none' || isAdmin) {
return respond(allowedChecks)
}
const { sameSongHours, sameArtistHours, scope } = restrictionPolicy
const normalizedCandidates = candidates.map((song) => ({
...song,
title: normalizeForMatch(song.title),
artist: normalizeForMatch(song.artist)
}))
// 开关开启但未配置冷却时长时,沿用旧规则:本学期已有的同一首歌不可再次投稿。
if (restrictionPolicy.mode === 'semester') {
const currentSemesterResult = await db
.select({ name: semesters.name })
.from(semesters)
.where(eq(semesters.isActive, true))
.limit(1)
const currentSemester = currentSemesterResult[0]
if (!currentSemester) {
return respond(allowedChecks)
}
const semesterSongs = await db
.select({ title: songs.title, artist: songs.artist })
.from(songs)
.where(eq(songs.semester, currentSemester.name))
const normalizedSemesterSongs = semesterSongs.map((song) => ({
title: normalizeForMatch(song.title || ''),
artist: normalizeForMatch(song.artist || '')
}))
const checks = normalizedCandidates.map((song) => {
if (!song.title || !song.artist) return { blocked: false, reason: null }
// Bilibili 的旧规则按视频分集 ID 判断,批量预检不以标题误判不同分集。
const hasBilibiliMusicId =
!!song.musicId &&
(song.musicPlatform === 'bilibili' ||
song.musicId.startsWith('BV') ||
song.musicId.startsWith('av'))
if (hasBilibiliMusicId) return { blocked: false, reason: null }
const alreadySubmitted = normalizedSemesterSongs.some((existingSong) =>
existingSong.title === song.title && existingSong.artist === song.artist
)
return { blocked: alreadySubmitted, reason: alreadySubmitted ? 'duplicateSong' : null }
})
return respond(checks)
}
const now = getServerDate()
const maxHours = Math.max(sameSongHours || 0, sameArtistHours || 0)
const cutoff = new Date(now.getTime() - maxHours * 3600000)
// 按 scope 构建 where 子句(显式短路,避免依赖 and(undefined) 的版本行为)
const scopeFilter = scope === 'self' ? eq(songs.requesterId, user.id) : undefined
const whereClause = and(
eq(schedules.isDraft, false),
or(
and(lte(schedules.playDate, now), gte(schedules.playDate, cutoff)),
and(
gt(schedules.playDate, now),
or(gte(schedules.publishedAt, cutoff), gte(schedules.createdAt, cutoff))
)
),
...(scopeFilter ? [scopeFilter] : [])
)
const scheduledSongs = await db
.select({
sPlayDate: schedules.playDate,
sCreatedAt: schedules.createdAt,
sPublishedAt: schedules.publishedAt,
songTitle: songs.title,
songArtist: songs.artist,
songRequesterId: songs.requesterId
})
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.where(whereClause)
const normalizedScheduledSongs = scheduledSongs.map((scheduled) => {
const playDate = scheduled.sPlayDate instanceof Date ? scheduled.sPlayDate : new Date(scheduled.sPlayDate)
const createdAt = scheduled.sCreatedAt instanceof Date ? scheduled.sCreatedAt : new Date(scheduled.sCreatedAt)
const publishedAt = scheduled.sPublishedAt
? (scheduled.sPublishedAt instanceof Date ? scheduled.sPublishedAt : new Date(scheduled.sPublishedAt))
: null
const windowStart = playDate.getTime() <= now.getTime()
? playDate.getTime()
: (publishedAt || createdAt).getTime()
const songWindowMs = (windowStart + (sameSongHours || 0) * 3600000) - now.getTime()
const artistWindowMs = (windowStart + (sameArtistHours || 0) * 3600000) - now.getTime()
const songWindowActive = (sameSongHours || 0) > 0 && songWindowMs > 0
const artistWindowActive = (sameArtistHours || 0) > 0 && artistWindowMs > 0
return {
title: normalizeForMatch(scheduled.songTitle || ''),
artist: normalizeForMatch(scheduled.songArtist || ''),
songWindowActive,
artistWindowActive
}
})
const checks = normalizedCandidates.map((song) => {
if (!song.title || !song.artist) return { blocked: false, reason: null }
const sameSong = normalizedScheduledSongs.some((scheduled) =>
scheduled.songWindowActive && scheduled.title === song.title && scheduled.artist === song.artist
)
if (sameSong) return { blocked: true, reason: 'sameSong' }
const sameArtist = normalizedScheduledSongs.some((scheduled) =>
scheduled.artistWindowActive && scheduled.artist === song.artist
)
return sameArtist ? { blocked: true, reason: 'sameArtist' } : { blocked: false, reason: null }
})
return respond(checks)
})
@@ -0,0 +1,77 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { db } from '~/drizzle/db'
import { songCollaborators, collaborationLogs, songs } from '~/drizzle/schema'
import { and, eq } from 'drizzle-orm'
import { createCollaborationResponseNotification } from '~~/server/services/notificationService'
import { createApiError } from '~~/server/utils/apiError'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_LOGIN_REQUIRED', '未登录')
}
const body = await readBody(event)
const { songId, accept } = body
if (!songId || typeof accept !== 'boolean') {
throw createApiError(400, 'COMMON_INVALID_PARAMS', '参数错误')
}
try {
// 查找联合投稿记录
const collabRecord = await db
.select()
.from(songCollaborators)
.where(
and(
eq(songCollaborators.songId, songId),
eq(songCollaborators.userId, user.id),
eq(songCollaborators.status, 'PENDING')
)
)
.limit(1)
.then((res) => res[0])
if (!collabRecord) {
throw createApiError(404, 'SONG_INVITATION_NOT_FOUND', '未找到待处理的邀请')
}
// 更新状态
const status = accept ? 'ACCEPTED' : 'REJECTED'
await db
.update(songCollaborators)
.set({
status,
updatedAt: new Date()
})
.where(eq(songCollaborators.id, collabRecord.id))
// 记录日志
await db.insert(collaborationLogs).values({
collaboratorId: collabRecord.id,
action: accept ? 'ACCEPT' : 'REJECT',
operatorId: user.id,
ipAddress: getClientIP(event)
})
// 获取歌曲信息以发送通知
const song = await db
.select()
.from(songs)
.where(eq(songs.id, songId))
.limit(1)
.then((res) => res[0])
if (song) {
await createCollaborationResponseNotification(user.id, song.requesterId, song.title, accept)
}
return { success: true }
} catch (error) {
console.error('处理联合投稿邀请失败:', error)
if (error.statusCode) throw error
throw createApiError(500, 'SONG_INVITATION_FAILED', '处理邀请失败')
}
})
+24
View File
@@ -0,0 +1,24 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { songs } from '~/drizzle/schema'
import { count, eq } from 'drizzle-orm'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const semester = query.semester as string
try {
const result = semester
? await db.select({ count: count() }).from(songs).where(eq(songs.semester, semester))
: await db.select({ count: count() }).from(songs)
const totalCount = result[0]?.count || 0
return {
count: totalCount
}
} catch (error) {
console.error('获取歌曲数量失败:', error)
throw createApiError(500, 'SONG_FETCH_COUNT_FAILED', '获取歌曲数量失败')
}
})
+198
View File
@@ -0,0 +1,198 @@
import { defineEventHandler, createError, readBody } from 'h3'
import { db } from '~/drizzle/db'
import { songs, semesters, songBlacklists } from '~/drizzle/schema'
import { eq, inArray, and, asc } from 'drizzle-orm'
import { createApiError } from '~~/server/utils/apiError'
import { getServerDate } from '~~/server/utils/serverTime'
import { backfillMissingSongDurations } from '~~/server/services/durationValidationService'
import { normalizeStoredDuration } from '~~/server/utils/song-duration-policy'
import { matchBlacklistGenre, matchBlacklistLanguage, resolveSongTypes } from '~~/server/utils/song-type-resolver'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createApiError(401, 'AUTH_LOGIN_REQUIRED', '需要登录')
}
const body = await readBody(event)
const { songIds } = body
if (!songIds || !Array.isArray(songIds) || songIds.length === 0) {
throw createApiError(400, 'SONG_SELECT_TO_IMPORT', '请选择要导入的歌曲')
}
// 获取当前活跃学期
const activeSemester = await db
.select()
.from(semesters)
.where(eq(semesters.isActive, true))
.limit(1)
.then((res) => res[0])
const currentSemesterName = activeSemester?.name
if (!currentSemesterName) {
throw createApiError(400, 'SONG_NO_ACTIVE_SEMESTER_IMPORT', '系统未设置当前活跃学期,无法导入歌曲。请联系管理员先设置活跃学期。')
}
// 获取原始歌曲数据,确保只能获取自己投稿的歌曲,并按创建时间排序
const originalSongs = await db
.select()
.from(songs)
.where(
and(
inArray(songs.id, songIds),
eq(songs.requesterId, user.id) // 强制检查所有权
)
)
.orderBy(asc(songs.createdAt)) // 按创建时间正序排列,保持原有顺序
if (originalSongs.length === 0) {
return { success: true, count: 0, results: { total: 0, success: 0, failed: 0, details: [] } }
}
// 获取黑名单
const blacklistItems = await db
.select()
.from(songBlacklists)
.where(eq(songBlacklists.isActive, true))
// 获取当前学期已存在的歌曲,用于排重
const existingSongs = await db
.select({
title: songs.title,
artist: songs.artist
})
.from(songs)
.where(eq(songs.semester, currentSemesterName))
const existingSet = new Set(
existingSongs.map((s) => `${s.title.toLowerCase().trim()}|${s.artist.toLowerCase().trim()}`)
)
const results = {
total: originalSongs.length,
success: 0,
failed: 0,
details: [] as string[]
}
const songsToInsert: (typeof songs.$inferInsert)[] = []
const now = getServerDate()
for (const song of originalSongs) {
const songKey = `${song.title.toLowerCase().trim()}|${song.artist.toLowerCase().trim()}`
// 1. 检查是否重复
if (existingSet.has(songKey)) {
results.failed++
results.details.push(`《${song.title}》: 当前学期已存在,跳过`)
continue
}
// 2. 检查黑名单
const songFullName = `${song.title} - ${song.artist || ''}`.toLowerCase()
let isBlocked = false
let blockReason = ''
// 语种/曲风懒解析:首次遇到类型项才请求音源,已被歌名/关键词拦截的歌曲不触发外部请求
let songTypes: Awaited<ReturnType<typeof resolveSongTypes>> = null
let typesResolved = false
for (const item of blacklistItems) {
if (item.type === 'SONG') {
if (songFullName.includes(item.value.toLowerCase())) {
isBlocked = true
blockReason = item.reason || '黑名单歌曲'
break
}
} else if (item.type === 'KEYWORD') {
if (songFullName.includes(item.value.toLowerCase())) {
isBlocked = true
blockReason = item.reason || '包含违规关键词'
break
}
} else if (item.type === 'LANGUAGE') {
// 解析失败或平台不支持时返回 null,类型黑名单放行
if (!typesResolved) {
songTypes = await resolveSongTypes(song.musicPlatform, song.musicId)
typesResolved = true
}
if (songTypes && matchBlacklistLanguage(item.value, songTypes.languages)) {
isBlocked = true
blockReason = item.reason || `语种「${item.value}」已被加入黑名单`
break
}
} else if (item.type === 'GENRE') {
if (!typesResolved) {
songTypes = await resolveSongTypes(song.musicPlatform, song.musicId)
typesResolved = true
}
if (songTypes && matchBlacklistGenre(item.value, songTypes.genres)) {
isBlocked = true
blockReason = item.reason || `曲风「${item.value}」已被加入黑名单`
break
}
}
}
if (isBlocked) {
results.failed++
results.details.push(`《${song.title}》: ${blockReason},跳过`)
continue
}
// 3. 准备插入数据
songsToInsert.push({
title: song.title,
artist: song.artist,
requesterId: user.id, // 强制使用当前登录用户的ID
played: false,
playedAt: null,
semester: currentSemesterName,
preferredPlayTimeId: null, // 重置偏好时间
cover: song.cover,
playUrl: song.playUrl,
musicPlatform: song.musicPlatform,
musicId: song.musicId,
durationSeconds: normalizeStoredDuration(song.durationSeconds),
createdAt: now,
updatedAt: now
})
// 将即将插入的歌曲也加入判重集合,防止同一次请求中有重复歌曲
existingSet.add(songKey)
}
// 批量插入
if (songsToInsert.length > 0) {
const insertedSongs = await db
.insert(songs)
.values(songsToInsert)
.returning({
id: songs.id,
musicPlatform: songs.musicPlatform,
musicId: songs.musicId,
durationSeconds: songs.durationSeconds
})
results.success = insertedSongs.length
// 往期投稿多数早于时长字段上线,缺失的时长放到响应后的后台任务补齐
const missingDurationSongs = insertedSongs.filter((song) => song.durationSeconds == null)
if (missingDurationSongs.length > 0) {
const durationTask = backfillMissingSongDurations(missingDurationSongs, '往期导入')
if (typeof event.waitUntil === 'function') {
event.waitUntil(durationTask)
} else {
durationTask.catch((error) => console.error('[往期导入] 后台补齐时长失败:', error))
}
}
}
return {
success: true,
count: results.success,
results
}
})
+511
View File
@@ -0,0 +1,511 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { client } from '~/drizzle/db'
import { formatDateTime } from '~/utils/timeUtils'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { SUBMISSION_NOTE_STATUS } from '~~/server/config/constants'
import {
maskSongsInfo,
stripAnonymousSongIdentifiers,
type MaskableSong
} from '~~/server/utils/studentMask'
interface SongResponse extends MaskableSong {
id: number
title: string
artist: string
requester: string
requesterId?: number
collaborators: any[]
voteCount: number
played: boolean
playedAt: Date | null
semester: string | null
createdAt: Date
updatedAt: Date
requestedAt: string
scheduled: boolean
cover: string | null
musicPlatform: string | null
musicId: string | null
cardCodeId?: number | null
durationSeconds?: number | null
playUrl: string | null
requesterGrade: string | null
requesterClass: string | null
replayRequested: boolean
replayRequestCount: number
isReplay: boolean
replayRequesters: any[]
voted?: boolean
preferredPlayTimeId?: number | null
preferredPlayTime?: any
scheduleDate?: string
schedulePlayed?: boolean
replayRequestStatus?: string
replayRequestUpdatedAt?: Date | string
replayRequestCooldownRemaining?: number
hasSubmissionNote?: boolean
submissionNote?: string | null
submissionNotePublic?: boolean
submissionNotePublicStatus?: string | null
replayRequestId?: number | null
}
const formatDisplayName = (
user: { name?: string | null; grade?: string | null; class?: string | null },
nameCount = 1,
gradeCount = 1
) => {
if (!user?.name) return '未知用户'
if (nameCount <= 1 || !user.grade) return user.name
if (gradeCount > 1 && user.class) return `${user.name}(${user.grade} ${user.class})`
return `${user.name}(${user.grade})`
}
const calculateReplayCooldown = (status?: string | null, updatedAt?: Date | string | null) => {
if (status !== 'REJECTED' || !updatedAt) return undefined
const cooldownMs = 24 * 60 * 60 * 1000
const remainingMs = cooldownMs - (getServerTimestamp() - new Date(updatedAt).getTime())
return remainingMs > 0 ? Math.ceil(remainingMs / (60 * 60 * 1000)) : 0
}
const isSchemaCompatibilityError = (error: unknown) => {
const value = error as { code?: string; cause?: { code?: string } } | null
const code = String(value?.code || value?.cause?.code || '')
return ['42P01', '42703'].includes(code)
}
const loadBasicSongs = async (
client: any,
semester: string,
grade: string,
search: string,
user: any,
isAdmin: boolean,
options: { scope?: string; sortBy?: string; sortOrder?: string } = {}
) => {
const conditions: string[] = []
const params: any[] = []
const addParam = (value: any) => {
params.push(value)
return `$${params.length}`
}
if (search) {
const value = addParam(`%${search}%`)
conditions.push(`(s.title ILIKE ${value} OR s.artist ILIKE ${value})`)
}
if (semester) conditions.push(`s.semester = ${addParam(semester)}`)
if (grade) conditions.push(`u.grade = ${addParam(grade)}`)
if (options.scope === 'mine' && user) conditions.push(`s."requesterId" = ${addParam(user.id)}`)
const orderColumn =
options.sortBy === 'title'
? 's.title'
: options.sortBy === 'artist'
? 's.artist'
: 's."createdAt"'
const orderDirection = options.sortOrder === 'asc' ? 'ASC' : 'DESC'
// 兜底查询只依赖初始版本就存在的 Song/User 字段。
// 可选迁移(排期、卡密、重播等)缺失时,歌曲列表仍应可用。
const rows = await client.unsafe(`
SELECT s.id, s.title, s.artist, s.played, s."playedAt", s.semester,
s."createdAt", s."updatedAt", s.cover, s."musicPlatform", s."musicId",
u.id AS "requesterId", u.name AS "requesterName", u.grade AS "requesterGrade", u.class AS "requesterClass",
COUNT(*) OVER()::int AS total
FROM "Song" s
LEFT JOIN "User" u ON u.id = s."requesterId"
${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
ORDER BY ${orderColumn} ${orderDirection}
`, params)
const hideStudentInfo = true
const songs = rows.map((row: any) => ({
id: Number(row.id), title: row.title, artist: row.artist,
requester: formatDisplayName({ name: row.requesterName, grade: row.requesterGrade, class: row.requesterClass }),
requesterId: row.requesterId ? Number(row.requesterId) : undefined,
requesterGrade: row.requesterGrade || null, requesterClass: row.requesterClass || null,
collaborators: [], voteCount: 0, played: row.played === true, playedAt: row.playedAt || null,
semester: row.semester || null, createdAt: row.createdAt, updatedAt: row.updatedAt,
requestedAt: formatDateTime(row.createdAt), scheduled: false,
cover: row.cover || null, musicPlatform: row.musicPlatform || null, musicId: row.musicId || null,
cardCodeId: null,
durationSeconds: null, playUrl: null,
replayRequested: false, replayRequestCount: 0, isReplay: false, replayRequesters: [],
hasSubmissionNote: false, submissionNote: null, submissionNotePublic: false,
preferredPlayTimeId: null,
})) as SongResponse[]
if (hideStudentInfo && !isAdmin) maskSongsInfo(songs)
if (!user) songs.forEach(stripAnonymousSongIdentifiers)
return { success: true, data: { songs, total: Number(rows[0]?.total || 0) } }
}
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const search = String(query.search || '').trim()
const semester = String(query.semester || '').trim()
const grade = String(query.grade || '').trim()
const scope = String(query.scope || '').trim()
const sortBy = String(query.sortBy || 'createdAt')
const sortOrder = String(query.sortOrder || 'desc') === 'asc' ? 'asc' : 'desc'
const user = event.context.user || null
const isAdmin = Boolean(user && ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role))
const params: any[] = [user?.id ?? null]
const conditions: string[] = []
const addParam = (value: any) => {
params.push(value)
return `$${params.length}`
}
if (search) {
const searchParam = addParam(`%${search}%`)
conditions.push(`(s.title ILIKE ${searchParam} OR s.artist ILIKE ${searchParam})`)
}
if (semester) conditions.push(`s.semester = ${addParam(semester)}`)
if (grade) conditions.push(`u.grade = ${addParam(grade)}`)
if (scope === 'mine' && user) conditions.push(`s.\"requesterId\" = $1`)
const orderColumn =
sortBy === 'title'
? 's.title'
: sortBy === 'artist'
? 's.artist'
: sortBy === 'votes'
? '\"voteCount\"'
: 's.\"createdAt\"'
const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const orderSql =
sortBy === 'votes'
? `${orderColumn} ${sortOrder}, s.\"createdAt\" ASC`
: `${orderColumn} ${sortOrder}`
const baseQuery = `
WITH
user_name_counts AS (
SELECT name, COUNT(*)::int AS name_count
FROM "User"
WHERE name IS NOT NULL
GROUP BY name
),
user_grade_counts AS (
SELECT name, grade, COUNT(*)::int AS grade_count
FROM "User"
WHERE name IS NOT NULL
GROUP BY name, grade
),
vote_counts AS (
SELECT "songId", COUNT(*)::int AS vote_count
FROM "Vote"
GROUP BY "songId"
),
current_user_votes AS (
SELECT "songId"
FROM "Vote"
WHERE $1::int IS NOT NULL AND "userId" = $1
GROUP BY "songId"
),
published_schedule AS (
SELECT DISTINCT ON ("songId") "songId", "playDate", played
FROM "Schedule"
WHERE "isDraft" = false
ORDER BY "songId", "playDate" DESC
),
replay_counts AS (
SELECT song_id, COUNT(*)::int AS replay_count
FROM song_replay_requests
WHERE status = 'PENDING'
GROUP BY song_id
),
current_user_replay AS (
SELECT DISTINCT ON (song_id) song_id, status, updated_at
FROM song_replay_requests
WHERE $1::int IS NOT NULL AND user_id = $1
ORDER BY song_id, created_at DESC
),
latest_replay AS (
SELECT DISTINCT ON (song_id) song_id, id
FROM song_replay_requests
WHERE status IN ('PENDING', 'FULFILLED')
ORDER BY song_id, created_at DESC
),
accepted_collaborators AS (
SELECT
sc.song_id,
jsonb_agg(
jsonb_build_object(
'id', u.id,
'name', u.name,
'grade', u.grade,
'class', u.class,
'nameCount', COALESCE(unc.name_count, 1),
'gradeCount', COALESCE(ugc.grade_count, 1)
)
ORDER BY sc.created_at
) AS collaborators
FROM song_collaborators sc
INNER JOIN "User" u ON u.id = sc.user_id
LEFT JOIN user_name_counts unc ON unc.name = u.name
LEFT JOIN user_grade_counts ugc
ON ugc.name = u.name AND ugc.grade IS NOT DISTINCT FROM u.grade
WHERE sc.status = 'ACCEPTED'
GROUP BY sc.song_id
),
ranked_replay_requesters AS (
SELECT
rr.song_id,
u.id,
u.name,
u.grade,
u.class,
COALESCE(unc.name_count, 1) AS name_count,
COALESCE(ugc.grade_count, 1) AS grade_count,
rr.created_at,
ROW_NUMBER() OVER (PARTITION BY rr.song_id ORDER BY rr.created_at DESC) AS position
FROM song_replay_requests rr
INNER JOIN "User" u ON u.id = rr.user_id
LEFT JOIN user_name_counts unc ON unc.name = u.name
LEFT JOIN user_grade_counts ugc
ON ugc.name = u.name AND ugc.grade IS NOT DISTINCT FROM u.grade
WHERE rr.status = 'PENDING'
),
replay_requesters AS (
SELECT
song_id,
jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'grade', grade,
'class', class,
'nameCount', name_count,
'gradeCount', grade_count,
'createdAt', created_at
)
ORDER BY created_at DESC
) FILTER (WHERE position <= 3) AS requesters
FROM ranked_replay_requesters
GROUP BY song_id
)
SELECT
s.id,
s.title,
s.artist,
s.played,
s."playedAt",
s.semester,
s."createdAt",
s."updatedAt",
s.cover,
s."musicPlatform",
s."musicId",
s."cardCodeId",
s."durationSeconds",
s."playUrl",
s."submissionNote",
s."submissionNotePublic",
s."submissionNotePublicStatus",
s."preferredPlayTimeId",
u.id AS "requesterId",
u.name AS "requesterName",
u.grade AS "requesterGrade",
u.class AS "requesterClass",
COALESCE(unc.name_count, 1) AS "requesterNameCount",
COALESCE(ugc.grade_count, 1) AS "requesterGradeCount",
pt.id AS "playTimeId",
pt.name AS "playTimeName",
pt."startTime" AS "playTimeStart",
pt."endTime" AS "playTimeEnd",
pt.enabled AS "playTimeEnabled",
COALESCE(vc.vote_count, 0) AS "voteCount",
(cuv."songId" IS NOT NULL) AS voted,
ps."playDate" AS "scheduleDate",
ps.played AS "schedulePlayed",
(ps."songId" IS NOT NULL) AS scheduled,
COALESCE(rc.replay_count, 0) AS "replayRequestCount",
cur.status AS "replayRequestStatus",
cur.updated_at AS "replayRequestUpdatedAt",
(cur.song_id IS NOT NULL) AS "replayRequested",
lr.id AS "replayRequestId",
COALESCE(ac.collaborators, '[]'::jsonb) AS collaborators,
COALESCE(rr.requesters, '[]'::jsonb) AS "replayRequesters",
COALESCE(
(SELECT "hideStudentInfo" FROM "SystemSettings" LIMIT 1),
true
) AS "hideStudentInfo",
COUNT(*) OVER()::int AS total
FROM "Song" s
LEFT JOIN "User" u ON u.id = s."requesterId"
LEFT JOIN user_name_counts unc ON unc.name = u.name
LEFT JOIN user_grade_counts ugc
ON ugc.name = u.name AND ugc.grade IS NOT DISTINCT FROM u.grade
LEFT JOIN "PlayTime" pt ON pt.id = s."preferredPlayTimeId"
LEFT JOIN vote_counts vc ON vc."songId" = s.id
LEFT JOIN current_user_votes cuv ON cuv."songId" = s.id
LEFT JOIN published_schedule ps ON ps."songId" = s.id
LEFT JOIN replay_counts rc ON rc.song_id = s.id
LEFT JOIN current_user_replay cur ON cur.song_id = s.id
LEFT JOIN latest_replay lr ON lr.song_id = s.id
LEFT JOIN accepted_collaborators ac ON ac.song_id = s.id
LEFT JOIN replay_requesters rr ON rr.song_id = s.id
${whereSql}
ORDER BY ${orderSql}
`
const baseRows = await client.unsafe(baseQuery, params)
const shouldHideStudentInfo = baseRows[0]?.hideStudentInfo ?? true
const formattedSongs: SongResponse[] = baseRows.map((row: any) => {
const collaborators = Array.isArray(row.collaborators)
? row.collaborators.map((collaborator: any) => ({
id: collaborator.id,
name: collaborator.name,
displayName: formatDisplayName(
collaborator,
Number(collaborator.nameCount),
Number(collaborator.gradeCount)
),
grade: collaborator.grade,
class: collaborator.class
}))
: []
const replayRequesters = Array.isArray(row.replayRequesters)
? row.replayRequesters.map((requester: any) => ({
id: requester.id,
name: requester.name || '未知用户',
displayName: formatDisplayName(
requester,
Number(requester.nameCount),
Number(requester.gradeCount)
),
grade: requester.grade,
class: requester.class,
createdAt: requester.createdAt
}))
: []
const isRequester = Boolean(user && Number(row.requesterId) === user.id)
// 公开留言审核:待审/已拒绝的不对普通用户公开(管理员与投稿人始终可见完整备注与状态)
const notePublic =
row.submissionNotePublic === true &&
row.submissionNotePublicStatus !== SUBMISSION_NOTE_STATUS.PENDING &&
row.submissionNotePublicStatus !== SUBMISSION_NOTE_STATUS.REJECTED
const canViewSubmissionNote =
Boolean(row.submissionNote) && (notePublic || Boolean(user && (isAdmin || isRequester)))
const replayRequestCount = Number(row.replayRequestCount || 0)
const song: SongResponse = {
id: Number(row.id),
title: row.title,
artist: row.artist,
requester: formatDisplayName(
{
name: row.requesterName,
grade: row.requesterGrade,
class: row.requesterClass
},
Number(row.requesterNameCount),
Number(row.requesterGradeCount)
),
requesterId: row.requesterId ? Number(row.requesterId) : undefined,
requesterGrade: row.requesterGrade || null,
requesterClass: row.requesterClass || null,
collaborators,
voteCount: Number(row.voteCount || 0),
played: row.played === true,
playedAt: row.playedAt || null,
semester: row.semester || null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
requestedAt: formatDateTime(row.createdAt),
scheduled: row.scheduled === true,
cover: row.cover || null,
musicPlatform: row.musicPlatform || null,
musicId: row.musicId || null,
cardCodeId: row.cardCodeId ? Number(row.cardCodeId) : null,
durationSeconds: row.durationSeconds ? Number(row.durationSeconds) : null,
playUrl: row.playUrl || null,
replayRequested: user ? row.replayRequested === true : false,
replayRequestCount,
isReplay: replayRequestCount > 0,
replayRequesters,
hasSubmissionNote: canViewSubmissionNote,
submissionNote: canViewSubmissionNote ? row.submissionNote : null,
submissionNotePublic: canViewSubmissionNote ? row.submissionNotePublic === true : false,
submissionNotePublicStatus: canViewSubmissionNote ? (row.submissionNotePublicStatus || null) : null,
replayRequestId: row.replayRequestId ? Number(row.replayRequestId) : null,
preferredPlayTimeId: row.preferredPlayTimeId ? Number(row.preferredPlayTimeId) : null
}
if (user) {
song.voted = row.voted === true
song.replayRequestStatus = row.replayRequestStatus || undefined
song.replayRequestUpdatedAt = row.replayRequestUpdatedAt || undefined
song.replayRequestCooldownRemaining = calculateReplayCooldown(
row.replayRequestStatus,
row.replayRequestUpdatedAt
)
}
if (row.scheduleDate) {
song.scheduleDate = formatDateTime(row.scheduleDate)
song.schedulePlayed = row.schedulePlayed === true
}
if (row.playTimeId) {
song.preferredPlayTime = {
id: Number(row.playTimeId),
name: row.playTimeName,
startTime: row.playTimeStart,
endTime: row.playTimeEnd,
enabled: row.playTimeEnabled === true
}
}
return song
})
if (shouldHideStudentInfo && !isAdmin) {
maskSongsInfo(formattedSongs)
}
if (!user) {
formattedSongs.forEach(stripAnonymousSongIdentifiers)
}
return {
success: true,
data: {
songs: formattedSongs,
total: baseRows.length > 0 ? Number(baseRows[0]?.total || 0) : 0
}
}
} catch (error: any) {
console.error('[Songs API] 获取歌曲列表失败:', error)
if (error.statusCode) throw error
if (isSchemaCompatibilityError(error)) {
try {
const fallbackQuery = getQuery(event)
const fallbackSearch = String(fallbackQuery.search || '').trim()
const fallbackSemester = String(fallbackQuery.semester || '').trim()
const fallbackGrade = String(fallbackQuery.grade || '').trim()
const fallbackScope = String(fallbackQuery.scope || '').trim()
const fallbackSortBy = String(fallbackQuery.sortBy || 'createdAt')
const fallbackSortOrder = String(fallbackQuery.sortOrder || 'desc')
const fallbackUser = event.context.user || null
const fallbackIsAdmin = Boolean(fallbackUser && ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(fallbackUser.role))
return await loadBasicSongs(client, fallbackSemester, fallbackGrade, fallbackSearch, fallbackUser, fallbackIsAdmin, {
scope: fallbackScope,
sortBy: fallbackSortBy,
sortOrder: fallbackSortOrder
})
} catch (fallbackError) {
console.error('[Songs API] 基础歌曲查询也失败:', fallbackError)
}
}
const isDbError = ['ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT'].some((code) =>
String(error?.code || error?.message || '').includes(code)
)
throw createError({
statusCode: isDbError ? 503 : 500,
message: isDbError ? '数据库连接暂时不可用,请稍后重试' : '获取歌曲列表失败,请稍后重试'
})
}
})
+468
View File
@@ -0,0 +1,468 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { client } from '~/drizzle/db'
import { formatDateTime, getBeijingTimeISOString } from '~/utils/timeUtils'
import { SUBMISSION_NOTE_STATUS } from '~~/server/config/constants'
import { maskPublicScheduleData,
stripAnonymousSongIdentifiersFromSchedules,
type PublicScheduleItem
} from '../../utils/studentMask'
import { verifyUserAuth } from '../../utils/auth'
const formatDisplayName = (
user: { name?: string | null; grade?: string | null; class?: string | null },
nameCount = 1,
gradeCount = 1
) => {
if (!user?.name) return '未知用户'
if (nameCount <= 1 || !user.grade) return user.name
if (gradeCount > 1 && user.class) return `${user.name}(${user.grade} ${user.class})`
return `${user.name}(${user.grade})`
}
const isSchemaCompatibilityError = (error: unknown) => {
const value = error as { code?: string; cause?: { code?: string } } | null
const code = String(value?.code || value?.cause?.code || '')
return ['42P01', '42703'].includes(code)
}
// 可选迁移尚未完成时,只依赖初始表结构返回基础排期,避免首页整体不可用。
const loadBasicSchedules = async (client: any, semester: string, user: any, isAdmin: boolean) => {
const params: any[] = []
const conditions: string[] = []
const scheduleDraftColumn = await client.unsafe(`
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'Schedule'
AND column_name = 'isDraft'
LIMIT 1
`)
if (scheduleDraftColumn.length > 0) conditions.push('sch."isDraft" = false')
if (semester) conditions.push('s.semester = $1')
if (semester) params.push(semester)
const rows = await client.unsafe(`
SELECT
sch.id,
to_char(sch."playDate", 'YYYY-MM-DD') AS "playDate",
sch.sequence,
sch.played AS "schedulePlayed",
sch."playTimeId",
s.id AS "songId",
s.title,
s.artist,
s.played AS "songPlayed",
s.cover,
s."musicPlatform",
s."musicId",
s.semester,
s."requesterId",
s."createdAt",
u.name AS "requesterName",
u.grade AS "requesterGrade",
u.class AS "requesterClass",
pt.id AS "playTimeRecordId",
pt.name AS "playTimeName",
pt."startTime" AS "playTimeStart",
pt."endTime" AS "playTimeEnd",
pt.enabled AS "playTimeEnabled",
COALESCE((SELECT "hideStudentInfo" FROM "SystemSettings" LIMIT 1), true) AS "hideStudentInfo"
FROM "Schedule" sch
INNER JOIN "Song" s ON s.id = sch."songId"
LEFT JOIN "User" u ON u.id = s."requesterId"
LEFT JOIN "PlayTime" pt ON pt.id = sch."playTimeId"
${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
ORDER BY sch."playDate", sch.sequence
`, params)
const hideStudentInfo = rows[0]?.hideStudentInfo ?? true
const schedules = rows.map((row: any) => ({
id: Number(row.id),
playDate: row.playDate,
sequence: Number(row.sequence || 1),
replayRequestId: null,
played: row.schedulePlayed === true,
playTimeId: row.playTimeId ? Number(row.playTimeId) : null,
playTime: row.playTimeRecordId ? {
id: Number(row.playTimeRecordId),
name: row.playTimeName,
startTime: row.playTimeStart,
endTime: row.playTimeEnd,
enabled: row.playTimeEnabled === true
} : null,
song: {
id: Number(row.songId),
title: row.title,
artist: row.artist,
requester: formatDisplayName({ name: row.requesterName, grade: row.requesterGrade, class: row.requesterClass }),
requesterGrade: row.requesterGrade || null,
requesterClass: row.requesterClass || null,
collaborators: [],
voteCount: 0,
played: row.songPlayed === true,
cover: row.cover || null,
cardCodeId: null,
usedCardCode: false,
musicPlatform: row.musicPlatform || null,
musicId: row.musicId || null,
durationSeconds: null,
playUrl: null,
semester: row.semester || null,
requestedAt: row.createdAt ? formatDateTime(row.createdAt) : null,
hasSubmissionNote: false,
submissionNote: null,
submissionNotePublic: false,
preferredPlayTimeId: null,
requesterId: row.requesterId ? Number(row.requesterId) : null,
replayRequestCount: 0,
replayRequesters: [],
isReplay: false
}
})) as PublicScheduleItem[]
if (hideStudentInfo && !isAdmin) maskPublicScheduleData(schedules)
if (!user) stripAnonymousSongIdentifiersFromSchedules(schedules)
return schedules
}
export default defineEventHandler(async (event) => {
let authenticatedUser: any = null
let authenticatedIsAdmin = false
try {
const query = getQuery(event)
const semester = String(query.semester || '').trim()
const authResult = event.context.authRejected
? { success: false, user: null }
: await verifyUserAuth(event)
const user = authResult.success ? authResult.user : null
const isAdmin = Boolean(user && ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role))
authenticatedUser = user
authenticatedIsAdmin = isAdmin
const params: any[] = []
const semesterCondition = semester ? 'AND s.semester = $1' : ''
if (semester) params.push(semester)
// 排期按 YYYY-MM-DD 存储,范围必须按北京时间自然日计算,不能使用当前时刻加减 24 小时。
const scheduleRangeResult = await client.unsafe(
`SELECT "scheduleDaysBeforeEnabled", "scheduleDaysBefore", "scheduleDaysAfterEnabled", "scheduleDaysAfter" FROM "SystemSettings" LIMIT 1`
)
const storedDaysBefore = Number(scheduleRangeResult[0]?.scheduleDaysBefore)
const storedDaysAfter = Number(scheduleRangeResult[0]?.scheduleDaysAfter)
// 兼容历史脏数据,异常值不能导致范围意外扩大或生成无效日期。
const daysBefore = Number.isInteger(storedDaysBefore) && storedDaysBefore >= 1 && storedDaysBefore <= 730 ? storedDaysBefore : 1
const daysAfter = Number.isInteger(storedDaysAfter) && storedDaysAfter >= 1 && storedDaysAfter <= 730 ? storedDaysAfter : 1
const beforeEnabled = scheduleRangeResult[0]?.scheduleDaysBeforeEnabled === true
const afterEnabled = scheduleRangeResult[0]?.scheduleDaysAfterEnabled === true
let dateRangeCondition = ''
if (!isAdmin && (beforeEnabled || afterEnabled)) {
const today = new Date(`${getBeijingTimeISOString().slice(0, 10)}T00:00:00.000Z`)
if (beforeEnabled) {
const minDate = new Date(today.getTime() - daysBefore * 86400000).toISOString()
dateRangeCondition += ' AND sch."playDate" >= $' + (params.length + 1)
params.push(minDate)
}
if (afterEnabled) {
const maxDate = new Date(today.getTime() + (daysAfter + 1) * 86400000).toISOString()
dateRangeCondition += ' AND sch."playDate" < $' + (params.length + 1)
params.push(maxDate)
}
}
const schedulesQuery = `
WITH
user_name_counts AS (
SELECT name, COUNT(*)::int AS name_count
FROM "User"
WHERE name IS NOT NULL
GROUP BY name
),
user_grade_counts AS (
SELECT name, grade, COUNT(*)::int AS grade_count
FROM "User"
WHERE name IS NOT NULL
GROUP BY name, grade
),
vote_counts AS (
SELECT "songId", COUNT(*)::int AS vote_count
FROM "Vote"
GROUP BY "songId"
),
accepted_collaborators AS (
SELECT
sc.song_id,
jsonb_agg(
jsonb_build_object(
'id', u.id,
'name', u.name,
'grade', u.grade,
'class', u.class,
'nameCount', COALESCE(unc.name_count, 1),
'gradeCount', COALESCE(ugc.grade_count, 1)
)
ORDER BY sc.created_at
) AS collaborators
FROM song_collaborators sc
INNER JOIN "User" u ON u.id = sc.user_id
LEFT JOIN user_name_counts unc ON unc.name = u.name
LEFT JOIN user_grade_counts ugc
ON ugc.name = u.name AND ugc.grade IS NOT DISTINCT FROM u.grade
WHERE sc.status = 'ACCEPTED'
GROUP BY sc.song_id
),
replay_counts AS (
SELECT song_id, COUNT(DISTINCT user_id)::int AS replay_count
FROM song_replay_requests
WHERE status IN ('PENDING', 'FULFILLED')
GROUP BY song_id
),
replay_metadata AS (
SELECT
rr.id,
rr.user_id,
rr.submission_note,
rr.submission_note_public,
rr.submission_note_public_status,
rr.preferred_play_time_id
FROM song_replay_requests rr
),
ranked_replay_requesters AS (
SELECT
rr.song_id,
u.id,
u.name,
u.grade,
u.class,
COALESCE(unc.name_count, 1) AS name_count,
COALESCE(ugc.grade_count, 1) AS grade_count,
rr.status,
rr.created_at,
ROW_NUMBER() OVER (PARTITION BY rr.song_id ORDER BY rr.created_at DESC) AS position
FROM (
-- insert-only 模型下同一用户可能有多条申请,每人只取最新一条
SELECT DISTINCT ON (song_id, user_id) song_id, user_id, status, created_at
FROM song_replay_requests
WHERE status IN ('PENDING', 'FULFILLED')
ORDER BY song_id, user_id, created_at DESC
) rr
INNER JOIN "User" u ON u.id = rr.user_id
LEFT JOIN user_name_counts unc ON unc.name = u.name
LEFT JOIN user_grade_counts ugc
ON ugc.name = u.name AND ugc.grade IS NOT DISTINCT FROM u.grade
),
replay_requesters AS (
SELECT
song_id,
jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'grade', grade,
'class', class,
'nameCount', name_count,
'gradeCount', grade_count,
'status', status,
'createdAt', created_at
)
ORDER BY created_at DESC
) FILTER (WHERE position <= 5) AS requesters
FROM ranked_replay_requesters
GROUP BY song_id
)
SELECT
sch.id,
to_char(sch."playDate", 'YYYY-MM-DD') AS "playDate",
sch.sequence,
sch.replay_request_id AS "replayRequestId",
sch.played AS "schedulePlayed",
sch."playTimeId",
s.id AS "songId",
s.title,
s.artist,
s.played AS "songPlayed",
s.cover,
s."musicPlatform",
s."musicId",
s."durationSeconds",
s."playUrl",
s.semester,
s."requesterId",
s."cardCodeId" IS NOT NULL AS "usedCardCode",
s."createdAt",
s."submissionNote",
s."submissionNotePublic",
CASE WHEN rm.id IS NOT NULL THEN rm.submission_note ELSE s."submissionNote" END AS "effectiveSubmissionNote",
CASE WHEN rm.id IS NOT NULL THEN COALESCE(rm.submission_note_public, false) ELSE s."submissionNotePublic" END AS "effectiveSubmissionNotePublic",
CASE WHEN rm.id IS NOT NULL THEN rm.submission_note_public_status ELSE s."submissionNotePublicStatus" END AS "effectiveSubmissionNotePublicStatus",
CASE WHEN rm.id IS NOT NULL THEN rm.preferred_play_time_id ELSE s."preferredPlayTimeId" END AS "effectivePlayTimeId",
rm.user_id AS "replayRequesterUserId",
u.name AS "requesterName",
u.grade AS "requesterGrade",
u.class AS "requesterClass",
COALESCE(unc.name_count, 1) AS "requesterNameCount",
COALESCE(ugc.grade_count, 1) AS "requesterGradeCount",
pt.id AS "playTimeRecordId",
pt.name AS "playTimeName",
pt."startTime" AS "playTimeStart",
pt."endTime" AS "playTimeEnd",
pt.enabled AS "playTimeEnabled",
COALESCE(vc.vote_count, 0) AS "voteCount",
COALESCE(ac.collaborators, '[]'::jsonb) AS collaborators,
COALESCE(rc.replay_count, 0) AS "replayRequestCount",
COALESCE(rr.requesters, '[]'::jsonb) AS "replayRequesters",
COALESCE(
(SELECT "hideStudentInfo" FROM "SystemSettings" LIMIT 1),
true
) AS "hideStudentInfo"
FROM "Schedule" sch
INNER JOIN "Song" s ON s.id = sch."songId"
LEFT JOIN "User" u ON u.id = s."requesterId"
LEFT JOIN user_name_counts unc ON unc.name = u.name
LEFT JOIN user_grade_counts ugc
ON ugc.name = u.name AND ugc.grade IS NOT DISTINCT FROM u.grade
LEFT JOIN "PlayTime" pt ON pt.id = sch."playTimeId"
LEFT JOIN vote_counts vc ON vc."songId" = s.id
LEFT JOIN accepted_collaborators ac ON ac.song_id = s.id
LEFT JOIN replay_counts rc ON rc.song_id = s.id
LEFT JOIN replay_requesters rr ON rr.song_id = s.id
LEFT JOIN replay_metadata rm ON rm.id = sch.replay_request_id
WHERE sch."isDraft" = false
${semesterCondition}
${dateRangeCondition}
ORDER BY sch."playDate", sch.sequence
`
const rows = await client.unsafe(schedulesQuery, params)
const shouldHideStudentInfo = rows[0]?.hideStudentInfo ?? true
const formattedSchedules = rows.map((row: any) => {
const collaborators = Array.isArray(row.collaborators)
? row.collaborators.map((collaborator: any) => ({
id: collaborator.id,
name: collaborator.name,
displayName: formatDisplayName(
collaborator,
Number(collaborator.nameCount),
Number(collaborator.gradeCount)
),
grade: collaborator.grade,
class: collaborator.class
}))
: []
const replayRequesters = Array.isArray(row.replayRequesters)
? row.replayRequesters.map((requester: any) => ({
id: requester.id,
name: requester.name || '未知用户',
displayName: formatDisplayName(
requester,
Number(requester.nameCount),
Number(requester.gradeCount)
),
grade: requester.grade,
class: requester.class,
status: requester.status
}))
: []
const linkedReplayRequestId = row.replayRequestId ? Number(row.replayRequestId) : null
const effectiveSubmissionNote = row.effectiveSubmissionNote
const effectiveSubmissionNotePublic = row.effectiveSubmissionNotePublic === true
// 公开留言审核:待审/已拒绝的不对普通用户公开(管理员与投稿人始终可见)
const effectiveNotePublic =
effectiveSubmissionNotePublic &&
row.effectiveSubmissionNotePublicStatus !== SUBMISSION_NOTE_STATUS.PENDING &&
row.effectiveSubmissionNotePublicStatus !== SUBMISSION_NOTE_STATUS.REJECTED
const effectivePlayTimeId = row.effectivePlayTimeId ? Number(row.effectivePlayTimeId) : null
// 备注可见性主体:绑定重播申请时为申请人,否则为歌曲投稿人
const noteOwnerId =
linkedReplayRequestId !== null && row.replayRequesterUserId
? Number(row.replayRequesterUserId)
: row.requesterId
? Number(row.requesterId)
: null
const isNoteOwner = Boolean(user && noteOwnerId !== null && noteOwnerId === user.id)
const canViewSubmissionNote =
Boolean(effectiveSubmissionNote) &&
(effectiveNotePublic || Boolean(user && (isAdmin || isNoteOwner)))
const replayRequestCount = Number(row.replayRequestCount || 0)
return {
id: Number(row.id),
// playDate 已在 SQL 层用 to_char 格式化为 YYYY-MM-DD,避免时区解析偏移
playDate: row.playDate,
sequence: Number(row.sequence || 1),
replayRequestId: linkedReplayRequestId,
played: row.schedulePlayed === true,
playTimeId: row.playTimeId ? Number(row.playTimeId) : null,
playTime: row.playTimeRecordId
? {
id: Number(row.playTimeRecordId),
name: row.playTimeName,
startTime: row.playTimeStart,
endTime: row.playTimeEnd,
enabled: row.playTimeEnabled === true
}
: null,
song: {
id: Number(row.songId),
title: row.title,
artist: row.artist,
requester: formatDisplayName(
{
name: row.requesterName,
grade: row.requesterGrade,
class: row.requesterClass
},
Number(row.requesterNameCount),
Number(row.requesterGradeCount)
),
requesterGrade: row.requesterGrade || null,
requesterClass: row.requesterClass || null,
collaborators,
voteCount: Number(row.voteCount || 0),
played: row.songPlayed === true,
cover: row.cover || null,
cardCodeId: null,
usedCardCode: row.usedCardCode === true,
musicPlatform: row.musicPlatform || null,
musicId: row.musicId || null,
durationSeconds: row.durationSeconds || null,
playUrl: row.playUrl || null,
semester: row.semester || null,
requestedAt: row.createdAt ? formatDateTime(row.createdAt) : null,
hasSubmissionNote: canViewSubmissionNote,
submissionNote: canViewSubmissionNote ? effectiveSubmissionNote : null,
submissionNotePublic: canViewSubmissionNote ? effectiveSubmissionNotePublic : false,
preferredPlayTimeId: effectivePlayTimeId,
requesterId: row.requesterId ? Number(row.requesterId) : null,
replayRequestCount,
replayRequesters,
isReplay: linkedReplayRequestId !== null
}
}
}) as PublicScheduleItem[]
if (shouldHideStudentInfo && !isAdmin) {
maskPublicScheduleData(formattedSchedules)
}
if (!user) {
stripAnonymousSongIdentifiersFromSchedules(formattedSchedules)
}
return formattedSchedules
} catch (error: any) {
console.error('获取公共排期失败:', error)
if (error.statusCode) throw error
if (isSchemaCompatibilityError(error)) {
try {
const fallbackSemester = String(getQuery(event).semester || '').trim()
return await loadBasicSchedules(client, fallbackSemester, authenticatedUser, authenticatedIsAdmin)
} catch (fallbackError) {
console.error('基础排期兜底查询失败:', fallbackError)
}
}
throw createError({
statusCode: 500,
message: error.message || '获取排期数据失败'
})
}
})
+40
View File
@@ -0,0 +1,40 @@
import { and, db, eq, songReplayRequests } from '~/drizzle/db'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
// 1. 检查用户认证
const user = event.context.user
if (!user) {
throw createApiError(401, 'SONG_LOGIN_REQUIRED_CANCEL_REPLAY', '需要登录才能取消重播申请')
}
// 2. 读取请求体
const body = await readBody(event)
const { songId } = body
if (!songId) {
throw createApiError(400, 'SONG_ID_REQUIRED', '歌曲ID不能为空')
}
// 3. 检查待处理申请是否存在且属于该用户(仅允许撤回 PENDING,历史申请可能已被排期引用)
const pendingCondition = and(
eq(songReplayRequests.songId, songId),
eq(songReplayRequests.userId, user.id),
eq(songReplayRequests.status, 'PENDING')
)
const existing = await db.select().from(songReplayRequests).where(pendingCondition).limit(1)
if (existing.length === 0) {
throw createApiError(404, 'SONG_REPLAY_NOT_FOUND_OR_FORBIDDEN', '重播申请不存在或无权取消')
}
// 4. 删除待处理申请记录
try {
await db.delete(songReplayRequests).where(pendingCondition)
return { success: true, message: '已取消重播申请' }
} catch (error: any) {
console.error('取消重播申请失败:', error)
throw createApiError(500, 'SONG_REPLAY_CANCEL_FAILED', '取消重播申请失败,请稍后再试')
}
})
+139
View File
@@ -0,0 +1,139 @@
import { and, db, desc, eq, songs, songReplayRequests, semesters, playTimes } from '~/drizzle/db'
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
import { createApiError } from '~~/server/utils/apiError'
import { SUBMISSION_NOTE_STATUS } from '~~/server/config/constants'
import { z } from 'zod'
const replayRequestSchema = z.object({
songId: z.number().int().gt(0, '歌曲ID无效'),
submissionNote: z.string().trim().max(300, '备注留言不能超过300个字符').optional().nullable(),
submissionNotePublic: z.boolean().optional(),
preferredPlayTimeId: z.number().int().gt(0, '播出时段 ID 无效').optional().nullable()
})
export default defineEventHandler(async (event) => {
// 1. 检查用户认证
const user = event.context.user
if (!user) {
throw createApiError(401, 'SONG_LOGIN_REQUIRED_REPLAY', '需要登录才能申请重播')
}
// 2. 读取请求体
const body = await readBody(event)
const parsedBody = replayRequestSchema.safeParse(body || {})
if (!parsedBody.success) {
const issueText = (parsedBody.error.issues || []).map((issue) => issue.message).join(', ')
throw createApiError(400, 'USER_REQUEST_VALIDATION_FAILED', `请求参数验证失败:${issueText}`, {
params: [issueText]
})
}
const { songId, preferredPlayTimeId } = parsedBody.data
// 3. 检查系统设置
const settings = await getSystemSettingsCached()
if (!settings?.enableReplayRequests) {
throw createApiError(403, 'SONG_REPLAY_DISABLED', '重播申请功能未开启')
}
let preferredPlayTime = null
if (preferredPlayTimeId) {
if (!settings.enablePlayTimeSelection) {
throw createApiError(400, 'SONG_REPLAY_PLAY_TIME_DISABLED', '播出时段选择功能未启用')
}
const playTimeResult = await db
.select()
.from(playTimes)
.where(and(eq(playTimes.id, preferredPlayTimeId), eq(playTimes.enabled, true)))
.limit(1)
preferredPlayTime = playTimeResult[0]
if (!preferredPlayTime) {
throw createApiError(400, 'SONG_REPLAY_PLAY_TIME_INVALID', '选择的播出时段不存在或未启用')
}
}
const rawSubmissionNote = parsedBody.data.submissionNote || ''
const submissionNote = settings.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
// 公开留言审核:开关开启时申请留言不立即公开,进入待审后由管理员通过
const noteRequiresApproval = settings.submissionNoteRequiresApproval === true
const wantsPublic = submissionNote !== null ? parsedBody.data.submissionNotePublic !== false : false
const submissionNotePublic = noteRequiresApproval ? false : wantsPublic
// 仅用户勾选公开的留言进入审核,私密留言只供管理员查看
const submissionNotePublicStatus =
noteRequiresApproval && submissionNote !== null && wantsPublic
? SUBMISSION_NOTE_STATUS.PENDING
: null
// 4. 检查歌曲和学期
const songResult = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createApiError(404, 'SONG_NOT_FOUND', '歌曲不存在')
}
if (!song.played) {
throw createApiError(400, 'SONG_NOT_PLAYED_NO_REPLAY', '该歌曲尚未播放,无法申请重播')
}
// 获取当前学期
const currentSemesterResult = await db
.select()
.from(semesters)
.where(eq(semesters.isActive, true))
.limit(1)
const currentSemester = currentSemesterResult[0]
// 验证学期
if (currentSemester) {
if (song.semester !== currentSemester.name) {
throw createApiError(400, 'SONG_REPLAY_CURRENT_SEMESTER_ONLY', '只能申请重播当前学期的歌曲')
}
} else {
throw createApiError(400, 'SONG_NO_ACTIVE_SEMESTER_REPLAY', '当前没有活跃学期,无法申请重播')
}
// 5. 检查重复申请和冷却期(insert-only:历史申请不覆写,每次申请新增一条记录)
const latestRequestResult = await db
.select()
.from(songReplayRequests)
.where(and(eq(songReplayRequests.songId, songId), eq(songReplayRequests.userId, user.id)))
.orderBy(desc(songReplayRequests.createdAt))
.limit(1)
const latestRequest = latestRequestResult[0]
if (latestRequest) {
if (latestRequest.status === 'PENDING') {
throw createApiError(400, 'SONG_REPLAY_ALREADY_REQUESTED', '您已经申请过重播该歌曲')
}
// REJECTED 或 FULFILLED 均需冷却 24 小时后才能再次申请
const COOLDOWN_HOURS = 24
const cooldownTime = COOLDOWN_HOURS * 60 * 60 * 1000
const timeSinceUpdate = Date.now() - new Date(latestRequest.updatedAt).getTime()
if (timeSinceUpdate < cooldownTime) {
const remainingHours = Math.ceil((cooldownTime - timeSinceUpdate) / (60 * 60 * 1000))
throw createApiError(429, 'SONG_REPLAY_COOLDOWN', `重播申请冷却中,还需等待 ${remainingHours} 小时`, { params: [remainingHours] })
}
}
// 6. 插入申请记录
try {
await db.insert(songReplayRequests).values({
songId,
userId: user.id,
preferredPlayTimeId: preferredPlayTime?.id || null,
submissionNote,
submissionNotePublic,
submissionNotePublicStatus
})
return { success: true, message: latestRequest ? '重新申请重播成功' : '申请重播成功' }
} catch (error: any) {
// 并发提交时命中待处理申请的部分唯一索引
if (error.code === '23505') {
throw createApiError(400, 'SONG_REPLAY_ALREADY_REQUESTED', '您已经申请过重播该歌曲')
}
throw error
}
})
+14
View File
@@ -0,0 +1,14 @@
import { requestSongForUser } from '~~/server/services/songRequestService'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createApiError(401, 'SONG_LOGIN_REQUIRED_REQUEST', '需要登录才能点歌')
}
const body = await readBody(event)
return await requestSongForUser(event, user, body)
})
+134
View File
@@ -0,0 +1,134 @@
import { db } from '~/drizzle/db'
import { requestTimes } from '~/drizzle/schema'
import { and, eq, gt, lte } from 'drizzle-orm'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
import { getSubmissionCount, isCardCodeLimitBypassActive } from '~~/server/utils/submissionLimit'
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
export default defineEventHandler(async (event) => {
// 检查用户认证
const user = event.context.user
if (!user) {
throw createApiError(401, SERVER_ERROR_CODES.SONG_LOGIN_REQUIRED_VIEW_STATUS, '需要登录才能查看投稿状态')
}
try {
// 获取系统设置
const systemSettingsData = await getSystemSettingsCached()
// 超级管理员、管理员、歌曲管理员不受投稿限制
const isAdmin = ['SUPER_ADMIN', 'ADMIN', 'SONG_ADMIN'].includes(user.role)
// 基础返回结构
const status: any = {
limitEnabled: systemSettingsData?.enableSubmissionLimit || false,
dailyLimit: null,
weeklyLimit: null,
monthlyLimit: null,
dailyUsed: 0,
weeklyUsed: 0,
monthlyUsed: 0,
dailyRemaining: null,
weeklyRemaining: null,
monthlyRemaining: null,
submissionClosed: !!(systemSettingsData?.forceBlockAllRequests && !isAdmin),
timeLimitationEnabled: systemSettingsData?.enableRequestTimeLimitation || false,
currentTimePeriod: null
}
// 检查投稿时段限制
if (status.timeLimitationEnabled) {
const currentTime = getBeijingTimeISOString()
const activeTimePeriod = await db
.select()
.from(requestTimes)
.where(
and(
lte(requestTimes.startTime, currentTime),
gt(requestTimes.endTime, currentTime),
eq(requestTimes.enabled, true)
)
)
.limit(1)
if (activeTimePeriod.length > 0) {
status.currentTimePeriod = activeTimePeriod[0]
} else if (!isAdmin) {
// 如果没有活跃时段且不是管理员,则视为投稿已关闭
status.submissionClosed = true
}
}
if (!status.limitEnabled) {
return status
}
// 确定生效的限额类型(三选一逻辑)
const dailyLimit = systemSettingsData.dailySubmissionLimit
const weeklyLimit = systemSettingsData.weeklySubmissionLimit
const monthlyLimit = systemSettingsData.monthlySubmissionLimit
let effectiveLimit: number | null = null
let limitType: 'daily' | 'weekly' | 'monthly' | null = null
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) {
status.dailyLimit = limitType === 'daily' ? effectiveLimit : null
status.weeklyLimit = limitType === 'weekly' ? effectiveLimit : null
status.monthlyLimit = limitType === 'monthly' ? effectiveLimit : null
status.dailyRemaining = 0
status.weeklyRemaining = 0
status.monthlyRemaining = 0
status.submissionClosed = !isAdmin // 管理员不受投稿关闭限制
return status
}
// 只计算生效的限额类型的使用量
let dailyUsed = 0
let weeklyUsed = 0
let monthlyUsed = 0
const excludeCardCodeRequests = isCardCodeLimitBypassActive(systemSettingsData)
if (limitType === 'daily' && effectiveLimit && effectiveLimit > 0) {
dailyUsed = await getSubmissionCount(db, user.id, 'daily', { excludeCardCodeRequests })
} else if (limitType === 'weekly' && effectiveLimit && effectiveLimit > 0) {
weeklyUsed = await getSubmissionCount(db, user.id, 'weekly', { excludeCardCodeRequests })
} else if (limitType === 'monthly' && effectiveLimit && effectiveLimit > 0) {
monthlyUsed = await getSubmissionCount(db, user.id, 'monthly', { excludeCardCodeRequests })
}
status.dailyLimit = limitType === 'daily' ? effectiveLimit : null
status.weeklyLimit = limitType === 'weekly' ? effectiveLimit : null
status.monthlyLimit = limitType === 'monthly' ? effectiveLimit : null
status.dailyUsed = dailyUsed
status.weeklyUsed = weeklyUsed
status.monthlyUsed = monthlyUsed
status.dailyRemaining =
limitType === 'daily' && effectiveLimit ? Math.max(0, effectiveLimit - dailyUsed) : null
status.weeklyRemaining =
limitType === 'weekly' && effectiveLimit ? Math.max(0, effectiveLimit - weeklyUsed) : null
status.monthlyRemaining =
limitType === 'monthly' && effectiveLimit ? Math.max(0, effectiveLimit - monthlyUsed) : null
return status
} catch (error) {
console.error('获取投稿状态失败:', error)
throw createApiError(500, 'SONG_FETCH_STATUS_FAILED', '获取投稿状态失败')
}
})
+172
View File
@@ -0,0 +1,172 @@
import { db } from '~/drizzle/db'
import { schedules, semesters, songs, votes } from '~/drizzle/schema'
import { and, count, eq } from 'drizzle-orm'
import { createSongVotedNotification } from '../../services/notificationService'
import {
isSongProtected,
getSongProtectRemainingSeconds,
recordSongVote,
recordUserVoteActivity
} from '~~/server/services/securityService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
// 检查用户认证
const user = event.context.user
if (!user) {
throw createApiError(401, 'SONG_LOGIN_REQUIRED_VOTE', '需要登录才能投票')
}
const body = await readBody(event)
const clientIP = getClientIP(event)
if (!body.songId) {
throw createApiError(400, 'SONG_ID_REQUIRED', '歌曲ID不能为空')
}
const isUnvote = body.unvote === true
try {
// 检查歌曲是否存在
const songResult = await db.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createApiError(404, 'SONG_NOT_FOUND', '歌曲不存在')
}
// 检查学期
const currentSemesterResult = await db
.select()
.from(semesters)
.where(eq(semesters.isActive, true))
.limit(1)
const currentSemester = currentSemesterResult[0]
if (!currentSemester) {
throw createApiError(400, 'SONG_NO_ACTIVE_SEMESTER_VOTE', '未设置活跃学期,无法进行投票操作')
}
if (song.semester !== currentSemester.name) {
throw createApiError(400, 'SONG_INACTIVE_SEMESTER_VOTE', '非活跃学期,无法进行投票操作')
}
// 检查歌曲是否已播放
if (song.played) {
throw createApiError(400, 'SONG_PLAYED_NO_VOTE', '该歌曲已播放,无法进行投票操作')
}
// 检查歌曲是否已排期(只检查已发布的排期,草稿不算)
const schedulesResult = await db
.select()
.from(schedules)
.where(and(eq(schedules.songId, body.songId), eq(schedules.isDraft, false)))
.limit(1)
if (schedulesResult.length > 0) {
throw createApiError(400, 'SONG_SCHEDULED_NO_VOTE', '该歌曲已排期,无法进行投票操作')
}
// 检查是否是自己的歌曲
if (song.requesterId === user.id) {
throw createApiError(400, 'SONG_CANNOT_SELF_VOTE', '不允许自己给自己投票')
}
if (isSongProtected(body.songId)) {
const remain = getSongProtectRemainingSeconds(body.songId)
throw createApiError(423, 'SONG_TEMP_PROTECTED_SECONDS', `该歌曲处于临时保护期,剩余 ${Math.max(remain, 1)} 秒`, { params: [Math.max(remain, 1)] })
}
// 检查用户是否已经投过票
const existingVoteResult = await db
.select()
.from(votes)
.where(and(eq(votes.songId, body.songId), eq(votes.userId, user.id)))
.limit(1)
const existingVote = existingVoteResult[0]
if (isUnvote) {
// 撤销投票逻辑
if (!existingVote) {
throw createApiError(400, 'SONG_VOTE_NOT_FOUND', '你尚未为这首歌投票,无法取消')
}
// 删除投票
await db.delete(votes).where(eq(votes.id, existingVote.id))
// 获取投票总数
const voteCountResult = await db
.select({ count: count() })
.from(votes)
.where(eq(votes.songId, body.songId))
const voteCount = voteCountResult[0].count
return {
success: true,
message: '取消投票成功',
song: {
id: song.id,
title: song.title,
artist: song.artist,
voteCount
}
}
} else {
// 正常投票逻辑
if (existingVote) {
throw createApiError(409, 'SONG_ALREADY_VOTED', '你已经为这首歌投过票了')
}
// 创建新的投票
const newVote = await db
.insert(votes)
.values({
songId: body.songId,
userId: user.id
})
.returning()
// 获取投票总数
const voteCountResult = await db
.select({ count: count() })
.from(votes)
.where(eq(votes.songId, body.songId))
const voteCount = voteCountResult[0].count
// 发送通知(异步,不阻塞响应)
if (song.requesterId !== user.id) {
createSongVotedNotification(body.songId, user.id).catch(() => {
// 发送通知失败不影响主流程
})
}
const protectedTriggered = recordSongVote(body.songId, clientIP, user.id)
recordUserVoteActivity(user.id, song.title)
return {
success: true,
message: '投票成功',
song: {
id: song.id,
title: song.title,
artist: song.artist,
voteCount
}
}
}
} catch (error: any) {
if (error.statusCode) {
throw error
} else if (error.code === '23505') {
throw createApiError(409, 'SONG_ALREADY_VOTED', '你已经为这首歌投过票了')
} else {
throw createApiError(500, 'SONG_OPERATION_FAILED', '操作失败,请稍后重试')
}
}
})
+186
View File
@@ -0,0 +1,186 @@
import { db } from '~/drizzle/db'
import {
schedules,
songs,
votes,
songCollaborators,
collaborationLogs,
requestTimes
} from '~/drizzle/schema'
import { and, eq, sql } from 'drizzle-orm'
import {
getTimeRange,
isCardCodeLimitBypassActive,
type LimitType
} from '~~/server/utils/submissionLimit'
import { releaseCardCodeAfterSongWithdrawal } from '~~/server/services/cardCodeLifecycleService'
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
import { createApiError } from '~~/server/utils/apiError'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查用户认证
const user = event.context.user
if (!user) {
throw createApiError(401, 'SONG_LOGIN_REQUIRED_WITHDRAW', '需要登录才能撤回歌曲')
}
const body = await readBody(event)
if (!body.songId) {
throw createApiError(400, 'SONG_ID_REQUIRED', '歌曲ID不能为空')
}
// 查找歌曲
const songResult = await db.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createApiError(404, 'SONG_NOT_FOUND', '歌曲不存在')
}
// 检查是否是用户自己的投稿或联合投稿
const isRequester = song.requesterId === user.id
let isCollaborator = false
let collaboratorRecord = null
if (!isRequester) {
const collabResult = await db
.select()
.from(songCollaborators)
.where(and(eq(songCollaborators.songId, song.id), eq(songCollaborators.userId, user.id)))
.limit(1)
if (collabResult.length > 0) {
isCollaborator = true
collaboratorRecord = collabResult[0]
}
}
if (
!isRequester &&
!isCollaborator &&
!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)
) {
throw createApiError(403, 'SONG_WITHDRAW_OWN_ONLY', '只能撤回自己的投稿或退出联合投稿')
}
// 检查歌曲是否已经播放
if (song.played) {
throw createApiError(400, 'SONG_PLAYED_CANNOT_WITHDRAW', '已播放的歌曲不能撤回')
}
// 检查歌曲是否已排期(只检查已发布的排期,草稿不算)
const scheduleResult = await db
.select()
.from(schedules)
.where(and(eq(schedules.songId, body.songId), eq(schedules.isDraft, false)))
.limit(1)
const schedule = scheduleResult[0]
if (schedule) {
throw createApiError(400, 'SONG_SCHEDULED_CANNOT_WITHDRAW', '已排期的歌曲不能撤回')
}
// 如果是联合投稿人撤回(退出)
if (
isCollaborator &&
!isRequester &&
!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)
) {
await db.delete(songCollaborators).where(eq(songCollaborators.id, collaboratorRecord.id))
// 记录日志
await db.insert(collaborationLogs).values({
collaboratorId: collaboratorRecord.id,
action: 'LEAVE',
operatorId: user.id,
ipAddress: getClientIP(event)
})
return {
message: '已成功退出联合投稿',
songId: body.songId,
action: 'leave'
}
}
// 如果是主投稿人撤回(删除歌曲)
// 获取系统设置以检查限制类型
const settings = await getSystemSettingsCached()
// 检查撤销的歌曲是否在当前限制期间内(用于返还配额)
let canReturnQuota = false
const cardCodeDidNotUseOrdinaryQuota = isCardCodeLimitBypassActive(settings) && !!song.cardCodeId
if (settings?.enableSubmissionLimit && !cardCodeDidNotUseOrdinaryQuota) {
let activeLimit: { type: LimitType; value: number | null } | null = null
if (settings.dailySubmissionLimit !== null && settings.dailySubmissionLimit !== undefined) {
activeLimit = { type: 'daily', value: settings.dailySubmissionLimit }
} else if (
settings.weeklySubmissionLimit !== null &&
settings.weeklySubmissionLimit !== undefined
) {
activeLimit = { type: 'weekly', value: settings.weeklySubmissionLimit }
} else if (
settings.monthlySubmissionLimit !== null &&
settings.monthlySubmissionLimit !== undefined
) {
activeLimit = { type: 'monthly', value: settings.monthlySubmissionLimit }
}
if (activeLimit?.value && activeLimit.value > 0) {
const { start, end } = getTimeRange(activeLimit.type)
canReturnQuota = song.createdAt >= start && song.createdAt <= end
}
}
// 投稿关联数据一起进入事务,避免撤回失败时只删掉一部分数据。
try {
await db.transaction(async (tx) => {
await tx.delete(songCollaborators).where(eq(songCollaborators.songId, body.songId))
await tx.delete(votes).where(eq(votes.songId, body.songId))
if (song.hitRequestId) {
await tx
.update(requestTimes)
.set({
accepted: sql`GREATEST(0, accepted - 1)`
})
.where(eq(requestTimes.id, song.hitRequestId))
console.log(`已减少投稿时段 ${song.hitRequestId} 的接纳数量`)
}
if (song.cardCodeId) {
const releaseResult = await releaseCardCodeAfterSongWithdrawal(tx, {
songId: song.id,
cardCodeId: song.cardCodeId,
operatorId: user.id
})
if (
!releaseResult.changed &&
['CONCURRENT_CHANGE', 'MISSING_CARD_CODE'].includes(String(releaseResult.reason || ''))
) {
throw createApiError(409, 'SONG_CARD_RELEASE_FAILED', '点歌券释放失败,撤回已终止')
}
}
// 删除歌曲(在事务内)
await tx.delete(songs).where(eq(songs.id, body.songId))
})
} catch (txErr: any) {
console.error('撤回事务失败:', txErr)
throw createError({
statusCode: txErr.statusCode || 500,
message: txErr.message || '撤回歌曲失败,请稍后重试'
})
}
return {
message: canReturnQuota ? '歌曲已成功撤回,投稿配额已返还' : '歌曲已成功撤回',
songId: body.songId,
quotaReturned: canReturnQuota
}
})