feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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', '获取投票人员列表失败')
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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', '处理邀请失败')
|
||||
}
|
||||
})
|
||||
@@ -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', '获取歌曲数量失败')
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 ? '数据库连接暂时不可用,请稍后重试' : '获取歌曲列表失败,请稍后重试'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '获取排期数据失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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', '取消重播申请失败,请稍后再试')
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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', '获取投稿状态失败')
|
||||
}
|
||||
})
|
||||
@@ -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', '操作失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user