feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs } from '~/drizzle/schema'
|
||||
import { inArray, and, eq, gte, lte } from 'drizzle-orm'
|
||||
import { createSongSelectedNotification, createReplaySongSelectedNotification } from '~~/server/services/notificationService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import {
|
||||
redeemCardCodeForSchedule,
|
||||
restoreCardCodeAfterScheduleRemoval
|
||||
} from '~~/server/services/cardCodeLifecycleService'
|
||||
import {
|
||||
fulfillReplayRequestsForSchedule,
|
||||
restoreReplayRequestsToPending
|
||||
} from '~~/server/utils/scheduleReplayBinding'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户认证和权限
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '未授权访问'
|
||||
})
|
||||
}
|
||||
|
||||
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有歌曲管理员及以上权限才能发布排期'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
// 验证必要参数
|
||||
if (!body.playDate || !body.songs || !Array.isArray(body.songs)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '参数无效:需要 playDate 和 songs 数组'
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleItems: Array<{ songId: number; sequence: number; replayRequestId: number | null }> =
|
||||
(body.songs as any[]).map((item: any, index: number) => {
|
||||
const songId = Number(item?.songId)
|
||||
const sequence = Number(item?.sequence)
|
||||
if (
|
||||
!Number.isInteger(songId) ||
|
||||
songId <= 0 ||
|
||||
!Number.isInteger(sequence) ||
|
||||
sequence <= 0
|
||||
) {
|
||||
throw createError({ statusCode: 400, message: `第 ${index + 1} 条排期数据无效` })
|
||||
}
|
||||
// 管理员拖拽时显式选择的重播申请,发布时优先绑定
|
||||
const replayRequestId = Number(item?.replayRequestId)
|
||||
return {
|
||||
songId,
|
||||
sequence,
|
||||
replayRequestId: Number.isInteger(replayRequestId) && replayRequestId > 0 ? replayRequestId : null
|
||||
}
|
||||
})
|
||||
|
||||
const clientIP = getClientIP(event)
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 解析日期
|
||||
const inputDateStr =
|
||||
typeof body.playDate === 'string' ? body.playDate : body.playDate.toISOString()
|
||||
// 确保使用当天的开始时间(UTC)
|
||||
const playDate = new Date(inputDateStr + 'T00:00:00.000Z')
|
||||
const startOfDay = new Date(inputDateStr + 'T00:00:00.000Z')
|
||||
const endOfDay = new Date(inputDateStr + 'T23:59:59.999Z')
|
||||
const playTimeId = body.playTimeId ? parseInt(body.playTimeId) : null
|
||||
|
||||
// 获取所有涉及的歌曲详情(用于通知)
|
||||
const songIds = scheduleItems.map((item) => item.songId)
|
||||
if (songIds.length === 0) {
|
||||
// 如果列表为空,说明是清空排期,直接执行删除逻辑即可
|
||||
}
|
||||
|
||||
const songDetails =
|
||||
songIds.length > 0 ? await db.select().from(songs).where(inArray(songs.id, songIds)) : []
|
||||
|
||||
const songMap = new Map(songDetails.map((s: any) => [s.id, s]))
|
||||
const missingSongIds = Array.from(new Set(songIds)).filter((songId) => !songMap.has(songId))
|
||||
if (missingSongIds.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: `歌曲不存在:${missingSongIds.join(', ')}`
|
||||
})
|
||||
}
|
||||
|
||||
// 需要发送通知的列表
|
||||
const notificationsToSend: Array<{
|
||||
requesterId: number
|
||||
songId: number
|
||||
songInfo: { title: string; artist: string; playDate: Date }
|
||||
}> = []
|
||||
const replayNotificationsToSend: Array<{
|
||||
userId: number
|
||||
songId: number
|
||||
songInfo: { title: string; artist: string; playDate: Date }
|
||||
scheduleId: number
|
||||
}> = []
|
||||
|
||||
// 开始事务
|
||||
await db.transaction(async (tx) => {
|
||||
// 1. 构建查询条件:指定日期 + (可选)指定时段
|
||||
const whereConditions = [
|
||||
gte(schedules.playDate, startOfDay),
|
||||
lte(schedules.playDate, endOfDay)
|
||||
]
|
||||
|
||||
if (playTimeId) {
|
||||
whereConditions.push(eq(schedules.playTimeId, playTimeId))
|
||||
}
|
||||
|
||||
// 2. 查找全库内已发布的排期(用于避免重复发送通知)
|
||||
const newSongIds = new Set<number>(scheduleItems.map((item) => item.songId))
|
||||
const newSongIdsArray = Array.from(newSongIds)
|
||||
|
||||
const globalExistingPublished =
|
||||
newSongIdsArray.length > 0
|
||||
? await tx
|
||||
.select({ songId: schedules.songId })
|
||||
.from(schedules)
|
||||
.where(and(eq(schedules.isDraft, false), inArray(schedules.songId, newSongIdsArray)))
|
||||
: []
|
||||
|
||||
const existingPublishedSongIds = new Set(globalExistingPublished.map((s) => s.songId))
|
||||
|
||||
const schedulesToDelete = await tx
|
||||
.select({
|
||||
songId: schedules.songId,
|
||||
isDraft: schedules.isDraft,
|
||||
replayRequestId: schedules.replayRequestId,
|
||||
cardCodeId: songs.cardCodeId
|
||||
})
|
||||
.from(schedules)
|
||||
.leftJoin(songs, eq(schedules.songId, songs.id))
|
||||
.where(and(...whereConditions))
|
||||
|
||||
// 记录被删除的已发布排期的重播绑定,同歌重发时回写,避免重播标识丢失
|
||||
const previousReplayBindings = new Map<number, number>()
|
||||
for (const scheduleToDelete of schedulesToDelete) {
|
||||
if (!scheduleToDelete.isDraft && scheduleToDelete.replayRequestId) {
|
||||
previousReplayBindings.set(scheduleToDelete.songId, scheduleToDelete.replayRequestId)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 删除该时间段内的所有排期(包括草稿和已发布)
|
||||
await tx.delete(schedules).where(and(...whereConditions))
|
||||
|
||||
// 记录本次删除了哪些歌的排期,如果新排期里没有它们,并且全局也没别的正式排期了,需要恢复 PENDING
|
||||
const deletedSongIds = new Set(
|
||||
schedulesToDelete.filter((s) => !s.isDraft).map((s) => s.songId)
|
||||
)
|
||||
const songsToRestore = Array.from(deletedSongIds).filter((id) => !newSongIds.has(id))
|
||||
|
||||
if (songsToRestore.length > 0) {
|
||||
const otherPublished = await tx
|
||||
.select({ songId: schedules.songId })
|
||||
.from(schedules)
|
||||
.where(and(eq(schedules.isDraft, false), inArray(schedules.songId, songsToRestore)))
|
||||
|
||||
const songsWithOtherSchedules = new Set(otherPublished.map((s) => s.songId))
|
||||
const finalRestoreIds = songsToRestore.filter((id) => !songsWithOtherSchedules.has(id))
|
||||
|
||||
if (finalRestoreIds.length > 0) {
|
||||
const finalRestoreIdSet = new Set(finalRestoreIds)
|
||||
const restoreCardCodeMap = new Map<number, number>()
|
||||
for (const deletedSchedule of schedulesToDelete) {
|
||||
if (
|
||||
!deletedSchedule.isDraft &&
|
||||
deletedSchedule.cardCodeId &&
|
||||
finalRestoreIdSet.has(deletedSchedule.songId)
|
||||
) {
|
||||
restoreCardCodeMap.set(deletedSchedule.songId, deletedSchedule.cardCodeId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [songId, cardCodeId] of restoreCardCodeMap) {
|
||||
const restoreResult = await restoreCardCodeAfterScheduleRemoval(tx, {
|
||||
songId,
|
||||
cardCodeId,
|
||||
operatorId: user.id
|
||||
})
|
||||
if (
|
||||
!restoreResult.changed &&
|
||||
['CONCURRENT_CHANGE', 'MISSING_CARD_CODE'].includes(
|
||||
String(restoreResult.reason || '')
|
||||
)
|
||||
) {
|
||||
throw createError({ statusCode: 409, message: '点歌券返还失败,发布排期已终止' })
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复重播申请(每人仅恢复最新一条,避免违反部分唯一索引)
|
||||
await restoreReplayRequestsToPending({
|
||||
tx,
|
||||
songIds: finalRestoreIds,
|
||||
at: getServerDate()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 插入新的排期并处理通知
|
||||
const publishedAt = getServerDate()
|
||||
|
||||
for (const item of scheduleItems) {
|
||||
const song = songMap.get(item.songId)!
|
||||
|
||||
// 插入排期
|
||||
const insertedSchedules = await tx
|
||||
.insert(schedules)
|
||||
.values({
|
||||
songId: item.songId,
|
||||
playDate: playDate,
|
||||
sequence: item.sequence,
|
||||
playTimeId: playTimeId,
|
||||
isDraft: false, // 直接发布
|
||||
publishedAt: publishedAt,
|
||||
updatedAt: publishedAt
|
||||
})
|
||||
.returning({ id: schedules.id })
|
||||
|
||||
const insertedSchedule = insertedSchedules[0]
|
||||
if (!insertedSchedule) {
|
||||
throw createError({ statusCode: 500, message: '创建排期失败' })
|
||||
}
|
||||
|
||||
const replayBinding = await fulfillReplayRequestsForSchedule({
|
||||
tx,
|
||||
songId: song.id,
|
||||
scheduleId: insertedSchedule.id,
|
||||
at: publishedAt,
|
||||
preferredRequestId: item.replayRequestId || undefined,
|
||||
fallbackRequestId: previousReplayBindings.get(song.id) || undefined
|
||||
})
|
||||
|
||||
if (replayBinding) {
|
||||
for (const replayUserId of replayBinding.replayRequesterIds) {
|
||||
replayNotificationsToSend.push({
|
||||
userId: replayUserId,
|
||||
songId: song.id,
|
||||
songInfo: { title: song.title, artist: song.artist, playDate: playDate },
|
||||
scheduleId: insertedSchedule.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果该歌曲之前未在此时间段发布过,则发送通知
|
||||
if (!existingPublishedSongIds.has(item.songId)) {
|
||||
notificationsToSend.push({
|
||||
requesterId: song.requesterId,
|
||||
songId: song.id,
|
||||
songInfo: {
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
playDate: playDate
|
||||
}
|
||||
})
|
||||
existingPublishedSongIds.add(item.songId)
|
||||
}
|
||||
|
||||
await redeemCardCodeForSchedule(tx, {
|
||||
songId: song.id,
|
||||
cardCodeId: song.cardCodeId,
|
||||
operatorId: user.id,
|
||||
at: publishedAt
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 发送重播安排通知
|
||||
if (replayNotificationsToSend.length > 0) {
|
||||
event.waitUntil(
|
||||
Promise.allSettled(
|
||||
replayNotificationsToSend.map((n) =>
|
||||
createReplaySongSelectedNotification(n.userId, n.songId, n.songInfo, n.scheduleId)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 由运行时托管后台通知,避免 Serverless 在响应结束后中止任务。
|
||||
if (notificationsToSend.length > 0) {
|
||||
event.waitUntil(
|
||||
Promise.allSettled(
|
||||
notificationsToSend.map((n) =>
|
||||
createSongSelectedNotification(n.requesterId, n.songId, n.songInfo)
|
||||
)
|
||||
).then((results) => {
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled').length
|
||||
console.log(
|
||||
`[Notification] 批量发布通知发送完成: ${successCount}/${notificationsToSend.length} 成功`
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[Performance] 批量发布排期耗时: ${Date.now() - startTime}ms`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '排期发布成功'
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('批量发布排期失败:', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
userId: user.id,
|
||||
ip: clientIP
|
||||
})
|
||||
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '发布排期失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { and, eq, gte, lte } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs } from '~/drizzle/schema'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '需要歌曲管理员及以上权限'
|
||||
})
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const clientIP = getClientIP(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
const fromDate = typeof body?.fromDate === 'string' ? body.fromDate.trim() : ''
|
||||
const toDate = typeof body?.toDate === 'string' ? body.toDate.trim() : ''
|
||||
const overwriteDrafts = body?.overwriteDrafts === true
|
||||
|
||||
if (!fromDate || !toDate) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少来源日期或目标日期'
|
||||
})
|
||||
}
|
||||
|
||||
if (fromDate === toDate) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '目标日期不能与当前日期相同'
|
||||
})
|
||||
}
|
||||
|
||||
const fromStart = new Date(`${fromDate}T00:00:00.000Z`)
|
||||
const fromEnd = new Date(`${fromDate}T23:59:59.999Z`)
|
||||
const toPlayDate = new Date(`${toDate}T00:00:00.000Z`)
|
||||
const toStart = new Date(`${toDate}T00:00:00.000Z`)
|
||||
const toEnd = new Date(`${toDate}T23:59:59.999Z`)
|
||||
|
||||
if (
|
||||
Number.isNaN(fromStart.getTime()) ||
|
||||
Number.isNaN(fromEnd.getTime()) ||
|
||||
Number.isNaN(toPlayDate.getTime()) ||
|
||||
Number.isNaN(toStart.getTime()) ||
|
||||
Number.isNaN(toEnd.getTime()) ||
|
||||
fromStart.toISOString().split('T')[0] !== fromDate ||
|
||||
toPlayDate.toISOString().split('T')[0] !== toDate
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '日期无效,请使用 YYYY-MM-DD 格式并确保日期有效'
|
||||
})
|
||||
}
|
||||
|
||||
const copyResult = await db.transaction(async (tx) => {
|
||||
// 检查目标日期是否已有排期(含草稿)
|
||||
const [existingCount, draftCount] = await Promise.all([
|
||||
tx
|
||||
.select({ id: schedules.id })
|
||||
.from(schedules)
|
||||
.where(and(gte(schedules.playDate, toStart), lte(schedules.playDate, toEnd), eq(schedules.isDraft, false)))
|
||||
.limit(1),
|
||||
tx
|
||||
.select({ id: schedules.id })
|
||||
.from(schedules)
|
||||
.where(and(gte(schedules.playDate, toStart), lte(schedules.playDate, toEnd), eq(schedules.isDraft, true)))
|
||||
.limit(999)
|
||||
])
|
||||
|
||||
const hasExistingSchedule = existingCount.length > 0
|
||||
const overwritingDrafts = overwriteDrafts && draftCount.length > 0
|
||||
|
||||
// 有已发布排期不允许复制
|
||||
if (hasExistingSchedule) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '目标日期已存在已发布排期,无法复制。请先清空目标日期的排期。'
|
||||
})
|
||||
}
|
||||
|
||||
// 有草稿但未请求覆盖,返回信息供前端询问用户
|
||||
if (draftCount.length > 0 && !overwritingDrafts) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '目标日期已存在草稿,请确认是否覆盖。',
|
||||
data: { draftCount: draftCount.length, requiresConfirmation: true }
|
||||
})
|
||||
}
|
||||
|
||||
let replacedDrafts = []
|
||||
|
||||
// 清除目标日期草稿(如用户确认覆盖)
|
||||
if (overwritingDrafts) {
|
||||
replacedDrafts = await tx
|
||||
.select()
|
||||
.from(schedules)
|
||||
.where(and(gte(schedules.playDate, toStart), lte(schedules.playDate, toEnd), eq(schedules.isDraft, true)))
|
||||
await tx.delete(schedules).where(and(gte(schedules.playDate, toStart), lte(schedules.playDate, toEnd)))
|
||||
}
|
||||
|
||||
// 查询来源日期的所有排期(含草稿和已发布),按 sequence 排序
|
||||
const sourceSchedules = await tx
|
||||
.select({
|
||||
songId: schedules.songId,
|
||||
sequence: schedules.sequence,
|
||||
playTimeId: schedules.playTimeId
|
||||
})
|
||||
.from(schedules)
|
||||
.innerJoin(songs, eq(schedules.songId, songs.id))
|
||||
.where(and(gte(schedules.playDate, fromStart), lte(schedules.playDate, fromEnd)))
|
||||
.orderBy(schedules.sequence)
|
||||
|
||||
if (sourceSchedules.length === 0) {
|
||||
return { copiedCount: 0, createdScheduleIds: [], replacedDrafts }
|
||||
}
|
||||
|
||||
let copiedCount = 0
|
||||
const createdScheduleIds = []
|
||||
const now = getServerDate()
|
||||
|
||||
for (const source of sourceSchedules) {
|
||||
const inserted = await tx
|
||||
.insert(schedules)
|
||||
.values({
|
||||
songId: source.songId,
|
||||
playDate: toPlayDate,
|
||||
sequence: source.sequence,
|
||||
playTimeId: source.playTimeId,
|
||||
isDraft: true, // 复制的排期默认保存为草稿,管理员审核后发布
|
||||
publishedAt: null,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning({ id: schedules.id })
|
||||
|
||||
copiedCount++
|
||||
if (inserted[0]) createdScheduleIds.push(inserted[0].id)
|
||||
}
|
||||
|
||||
return { copiedCount, createdScheduleIds, replacedDrafts }
|
||||
})
|
||||
|
||||
console.log(`[Performance] 复制排期耗时: ${Date.now() - startTime}ms`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fromDate,
|
||||
toDate,
|
||||
copiedCount: copyResult.copiedCount ?? 0,
|
||||
createdScheduleIds: copyResult.createdScheduleIds ?? [],
|
||||
replacedDrafts: copyResult.replacedDrafts ?? [],
|
||||
draftCount: copyResult.draftCount,
|
||||
overwriteDrafts: copyResult.overwriteDrafts
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { playTimes, schedules, songs, songReplayRequests } from '~/drizzle/schema'
|
||||
import { and, desc, eq, gte, lte } from 'drizzle-orm'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
|
||||
// 输入验证函数
|
||||
function validateInput(body: any) {
|
||||
const errors: string[] = []
|
||||
|
||||
// 验证songId
|
||||
if (!body.songId) {
|
||||
errors.push('歌曲ID不能为空')
|
||||
} else if (typeof body.songId !== 'number' || body.songId <= 0) {
|
||||
errors.push('歌曲ID必须是正整数')
|
||||
}
|
||||
|
||||
// 验证playDate
|
||||
if (!body.playDate) {
|
||||
errors.push('播放日期不能为空')
|
||||
} else {
|
||||
const dateStr = typeof body.playDate === 'string' ? body.playDate : body.playDate.toString()
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
|
||||
if (!dateRegex.test(dateStr)) {
|
||||
errors.push('播放日期格式必须为YYYY-MM-DD')
|
||||
} else {
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) {
|
||||
errors.push('播放日期格式无效')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证sequence(可选)
|
||||
if (body.sequence !== undefined && body.sequence !== null) {
|
||||
if (typeof body.sequence !== 'number' || body.sequence <= 0) {
|
||||
errors.push('序号必须是正整数')
|
||||
}
|
||||
}
|
||||
|
||||
// 验证playTimeId(可选)
|
||||
if (body.playTimeId !== undefined && body.playTimeId !== null) {
|
||||
if (typeof body.playTimeId !== 'number' || body.playTimeId <= 0) {
|
||||
errors.push('播放时间ID必须是正整数')
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const startTime = Date.now()
|
||||
const clientIP = getClientIP(event)
|
||||
|
||||
try {
|
||||
// 检查用户认证和权限
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '未授权访问'
|
||||
})
|
||||
}
|
||||
|
||||
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
// 输入验证
|
||||
const validationErrors = validateInput(body)
|
||||
if (validationErrors.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: validationErrors.join('; ')
|
||||
})
|
||||
}
|
||||
|
||||
// 使用数据库事务确保数据一致性
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// 检查歌曲是否存在
|
||||
const songResult = await tx.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
|
||||
|
||||
const song = songResult[0]
|
||||
|
||||
if (!song) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '歌曲不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查播放时间ID是否有效(如果提供)
|
||||
if (body.playTimeId) {
|
||||
const playTimeResult = await tx
|
||||
.select()
|
||||
.from(playTimes)
|
||||
.where(eq(playTimes.id, body.playTimeId))
|
||||
.limit(1)
|
||||
|
||||
if (playTimeResult.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '播放时间不存在'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 校验重播申请绑定(草稿仅记录绑定关系,不修改申请状态)
|
||||
let replayRequestId: number | null = null
|
||||
const requestedReplayId = Number(body.replayRequestId)
|
||||
if (body.replayRequestId != null && Number.isInteger(requestedReplayId) && requestedReplayId > 0) {
|
||||
const replayRequestResult = await tx
|
||||
.select({ id: songReplayRequests.id, songId: songReplayRequests.songId, status: songReplayRequests.status })
|
||||
.from(songReplayRequests)
|
||||
.where(eq(songReplayRequests.id, requestedReplayId))
|
||||
.limit(1)
|
||||
|
||||
const replayRequest = replayRequestResult[0]
|
||||
if (!replayRequest || replayRequest.songId !== song.id || replayRequest.status !== 'PENDING') {
|
||||
throw createApiError(400, 'SONG_REPLAY_INVALID_REQUEST', '重播申请无效或已被处理')
|
||||
}
|
||||
|
||||
replayRequestId = replayRequest.id
|
||||
}
|
||||
|
||||
// 获取序号,如果未提供则查找当天最大序号+1
|
||||
let sequence = body.sequence || 1
|
||||
|
||||
if (!body.sequence) {
|
||||
// 解析输入的日期字符串
|
||||
const inputDateStr =
|
||||
typeof body.playDate === 'string' ? body.playDate : body.playDate.toISOString()
|
||||
|
||||
// 创建当天的开始和结束时间
|
||||
const startOfDay = new Date(inputDateStr + 'T00:00:00.000Z')
|
||||
const endOfDay = new Date(inputDateStr + 'T23:59:59.999Z')
|
||||
|
||||
// 查找当天的所有排期和草稿(包括草稿状态的)
|
||||
const sameDaySchedules = await tx
|
||||
.select()
|
||||
.from(schedules)
|
||||
.where(and(gte(schedules.playDate, startOfDay), lte(schedules.playDate, endOfDay)))
|
||||
.orderBy(desc(schedules.sequence))
|
||||
.limit(1)
|
||||
|
||||
const latestSchedule = sameDaySchedules[0]
|
||||
if (latestSchedule) sequence = (latestSchedule.sequence || 0) + 1
|
||||
}
|
||||
|
||||
// 解析输入的日期字符串,确保日期正确
|
||||
const inputDateStr =
|
||||
typeof body.playDate === 'string' ? body.playDate : body.playDate.toISOString()
|
||||
|
||||
// 直接使用输入的日期字符串,添加时间部分以避免时区问题
|
||||
const playDate = new Date(inputDateStr + 'T00:00:00.000Z')
|
||||
|
||||
// 创建草稿排期
|
||||
const scheduleResult = await tx
|
||||
.insert(schedules)
|
||||
.values({
|
||||
songId: body.songId,
|
||||
playDate: playDate,
|
||||
sequence: sequence,
|
||||
playTimeId: body.playTimeId || null,
|
||||
replayRequestId,
|
||||
isDraft: true, // 标记为草稿
|
||||
publishedAt: null // 草稿状态下没有发布时间
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
schedule: scheduleResult[0],
|
||||
song: {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
requesterId: song.requesterId,
|
||||
cardCodeId: song.cardCodeId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const responseData = {
|
||||
...result.schedule,
|
||||
song: result.song,
|
||||
isDraft: true,
|
||||
publishedAt: null,
|
||||
message: '排期草稿保存成功'
|
||||
}
|
||||
|
||||
console.log(`[Performance] 保存排期草稿耗时: ${Date.now() - startTime}ms`)
|
||||
|
||||
return responseData
|
||||
} catch (error: any) {
|
||||
console.error('保存排期草稿失败:', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
userId: event.context.user?.id,
|
||||
ip: clientIP,
|
||||
duration: Date.now() - startTime
|
||||
})
|
||||
|
||||
// 根据错误类型返回适当的错误信息
|
||||
if (error.statusCode) {
|
||||
throw error // 重新抛出已经格式化的错误
|
||||
}
|
||||
|
||||
// 对于未知错误,返回具体的错误信息
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || '服务器内部错误,请稍后重试'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,459 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
playTimes,
|
||||
schedules,
|
||||
songs,
|
||||
users,
|
||||
votes,
|
||||
songCollaborators,
|
||||
songReplayRequests
|
||||
} from '~/drizzle/schema'
|
||||
import { and, asc, count, countDistinct, eq, gte, lt, inArray, desc } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户身份验证和权限
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '未授权访问'
|
||||
})
|
||||
}
|
||||
|
||||
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有歌曲管理员及以上权限才能访问完整排期数据'
|
||||
})
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const { date, playTimeId, includeDrafts = 'true' } = query
|
||||
|
||||
try {
|
||||
// 构建查询条件
|
||||
const conditions = []
|
||||
|
||||
// 日期过滤
|
||||
if (date) {
|
||||
const targetDate = new Date(date as string)
|
||||
const startOfDay = new Date(
|
||||
targetDate.getFullYear(),
|
||||
targetDate.getMonth(),
|
||||
targetDate.getDate()
|
||||
)
|
||||
const endOfDay = new Date(
|
||||
targetDate.getFullYear(),
|
||||
targetDate.getMonth(),
|
||||
targetDate.getDate() + 1
|
||||
)
|
||||
|
||||
conditions.push(and(gte(schedules.playDate, startOfDay), lt(schedules.playDate, endOfDay)))
|
||||
}
|
||||
|
||||
// 播放时间过滤
|
||||
if (playTimeId) {
|
||||
conditions.push(eq(schedules.playTimeId, parseInt(playTimeId as string)))
|
||||
}
|
||||
|
||||
// 草稿包含过滤 - 默认包含草稿和已发布的排期
|
||||
if (includeDrafts === 'false') {
|
||||
conditions.push(eq(schedules.isDraft, false))
|
||||
} else if (includeDrafts === 'only') {
|
||||
conditions.push(eq(schedules.isDraft, true))
|
||||
}
|
||||
// 如果 includeDrafts === 'true' 或未指定,则包含两者(无需过滤)
|
||||
|
||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
// 获取完整的排期数据,包括草稿
|
||||
const schedulesData = await db
|
||||
.select({
|
||||
id: schedules.id,
|
||||
createdAt: schedules.createdAt,
|
||||
updatedAt: schedules.updatedAt,
|
||||
playDate: schedules.playDate,
|
||||
sequence: schedules.sequence,
|
||||
played: schedules.played,
|
||||
playTimeId: schedules.playTimeId,
|
||||
isDraft: schedules.isDraft,
|
||||
publishedAt: schedules.publishedAt,
|
||||
replayRequestId: schedules.replayRequestId,
|
||||
songId: schedules.songId,
|
||||
songTitle: songs.title,
|
||||
songArtist: songs.artist,
|
||||
songRequesterId: songs.requesterId,
|
||||
songPlayed: songs.played,
|
||||
songCover: songs.cover,
|
||||
songMusicPlatform: songs.musicPlatform,
|
||||
songMusicId: songs.musicId,
|
||||
songDurationSeconds: songs.durationSeconds,
|
||||
songCardCodeId: songs.cardCodeId,
|
||||
songSemester: songs.semester,
|
||||
songCreatedAt: songs.createdAt,
|
||||
requesterName: users.name,
|
||||
requesterGrade: users.grade,
|
||||
requesterClass: users.class,
|
||||
playTimeName: playTimes.name,
|
||||
playTimeStartTime: playTimes.startTime,
|
||||
playTimeEndTime: playTimes.endTime,
|
||||
playTimeEnabled: playTimes.enabled
|
||||
})
|
||||
.from(schedules)
|
||||
.innerJoin(songs, eq(schedules.songId, songs.id))
|
||||
.leftJoin(users, eq(songs.requesterId, users.id))
|
||||
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
|
||||
.where(whereCondition)
|
||||
.orderBy(asc(schedules.playDate), asc(schedules.sequence), asc(schedules.createdAt))
|
||||
|
||||
// 获取所有用户用于姓名消歧
|
||||
const allUsers = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
grade: users.grade,
|
||||
class: users.class
|
||||
})
|
||||
.from(users)
|
||||
|
||||
// 创建姓名到用户的映射用于消歧
|
||||
const nameToUsers = new Map()
|
||||
allUsers.forEach((user) => {
|
||||
if (user.name) {
|
||||
if (!nameToUsers.has(user.name)) {
|
||||
nameToUsers.set(user.name, [])
|
||||
}
|
||||
nameToUsers.get(user.name).push(user)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取每首歌的投票数
|
||||
const songIds = schedulesData.map((s) => s.songId)
|
||||
const voteCounts =
|
||||
songIds.length > 0
|
||||
? await Promise.all(
|
||||
songIds.map(async (songId) => {
|
||||
const voteCountResult = await db
|
||||
.select({ count: count() })
|
||||
.from(votes)
|
||||
.where(eq(votes.songId, songId))
|
||||
return { songId, count: voteCountResult[0].count }
|
||||
})
|
||||
)
|
||||
: []
|
||||
|
||||
const voteCountMap = new Map(voteCounts.map((v) => [v.songId, v.count]))
|
||||
|
||||
// 获取联合投稿人信息
|
||||
const collaboratorsMap = new Map()
|
||||
|
||||
if (songIds.length > 0) {
|
||||
const collaboratorsData = await db
|
||||
.select({
|
||||
songId: songCollaborators.songId,
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
grade: users.grade,
|
||||
class: users.class
|
||||
}
|
||||
})
|
||||
.from(songCollaborators)
|
||||
.leftJoin(users, eq(songCollaborators.userId, users.id))
|
||||
.where(
|
||||
and(inArray(songCollaborators.songId, songIds), eq(songCollaborators.status, 'ACCEPTED'))
|
||||
)
|
||||
|
||||
collaboratorsData.forEach((c) => {
|
||||
if (!collaboratorsMap.has(c.songId)) {
|
||||
collaboratorsMap.set(c.songId, [])
|
||||
}
|
||||
if (c.user) {
|
||||
collaboratorsMap.get(c.songId).push(c.user)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取重播申请信息
|
||||
const replayRequestCountsMap = new Map()
|
||||
const replayRequestersMap = new Map()
|
||||
|
||||
if (songIds.length > 0) {
|
||||
// 获取每首歌的重播申请数量(统计 PENDING 和 FULFILLED 状态,按申请人去重)
|
||||
const replayCountsData = await db
|
||||
.select({
|
||||
songId: songReplayRequests.songId,
|
||||
count: countDistinct(songReplayRequests.userId)
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.where(
|
||||
and(
|
||||
inArray(songReplayRequests.songId, songIds),
|
||||
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
|
||||
)
|
||||
)
|
||||
.groupBy(songReplayRequests.songId)
|
||||
|
||||
replayCountsData.forEach((r) => {
|
||||
replayRequestCountsMap.set(r.songId, r.count)
|
||||
})
|
||||
|
||||
// 获取重播申请人列表(前5个)
|
||||
const replayRequestersData = await db
|
||||
.select({
|
||||
songId: songReplayRequests.songId,
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
grade: users.grade,
|
||||
class: users.class
|
||||
},
|
||||
status: songReplayRequests.status,
|
||||
createdAt: songReplayRequests.createdAt,
|
||||
preferredPlayTimeId: songReplayRequests.preferredPlayTimeId,
|
||||
submissionNote: songReplayRequests.submissionNote,
|
||||
submissionNotePublic: songReplayRequests.submissionNotePublic
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.innerJoin(users, eq(songReplayRequests.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(songReplayRequests.songId, songIds),
|
||||
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
|
||||
)
|
||||
)
|
||||
.orderBy(desc(songReplayRequests.createdAt))
|
||||
|
||||
// 每首歌按用户去重(保留最新一条),只保留前5个申请人
|
||||
const seenRequesterIds = new Map()
|
||||
replayRequestersData.forEach((r) => {
|
||||
if (!replayRequestersMap.has(r.songId)) {
|
||||
replayRequestersMap.set(r.songId, [])
|
||||
seenRequesterIds.set(r.songId, new Set())
|
||||
}
|
||||
const seen = seenRequesterIds.get(r.songId)
|
||||
if (seen.has(r.user.id)) {
|
||||
return
|
||||
}
|
||||
if (replayRequestersMap.get(r.songId).length < 5) {
|
||||
seen.add(r.user.id)
|
||||
replayRequestersMap.get(r.songId).push({
|
||||
id: r.user.id,
|
||||
name: r.user.name || '未知用户',
|
||||
grade: r.user.grade,
|
||||
class: r.user.class,
|
||||
status: r.status
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取重播申请元数据(按 ID 索引,展示时排期按 replayRequestId 取对应记录)
|
||||
const replayMetadataByIdMap = new Map<number, { submissionNote: string | null; submissionNotePublic: boolean; submissionNotePublicStatus: string | null; preferredPlayTimeId: number | null }>()
|
||||
if (songIds.length > 0) {
|
||||
const replayMetaData = await db
|
||||
.select()
|
||||
.from(songReplayRequests)
|
||||
.where(
|
||||
and(
|
||||
inArray(songReplayRequests.songId, songIds),
|
||||
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
|
||||
)
|
||||
)
|
||||
|
||||
for (const row of replayMetaData) {
|
||||
replayMetadataByIdMap.set(row.id, {
|
||||
submissionNote: row.submissionNote,
|
||||
submissionNotePublic: row.submissionNotePublic,
|
||||
submissionNotePublicStatus: row.submissionNotePublicStatus,
|
||||
preferredPlayTimeId: row.preferredPlayTimeId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化响应数据
|
||||
const formattedSchedules = schedulesData.map((schedule) => {
|
||||
const dateOnly = schedule.playDate
|
||||
|
||||
// 处理投稿人姓名消歧
|
||||
let requesterName = schedule.requesterName || 'Unknown User'
|
||||
|
||||
const sameNameUsers = nameToUsers.get(requesterName)
|
||||
if (sameNameUsers && sameNameUsers.length > 1) {
|
||||
if (schedule.requesterGrade) {
|
||||
const sameGradeUsers = sameNameUsers.filter(
|
||||
(u: { id: number; name: string | null; grade: string | null; class: string | null }) =>
|
||||
u.grade === schedule.requesterGrade
|
||||
)
|
||||
|
||||
if (sameGradeUsers.length > 1 && schedule.requesterClass) {
|
||||
requesterName = `${requesterName}(${schedule.requesterGrade} ${schedule.requesterClass})`
|
||||
} else {
|
||||
requesterName = `${requesterName}(${schedule.requesterGrade})`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:格式化显示名称 (用于联合投稿人)
|
||||
const formatDisplayName = (userObj: any) => {
|
||||
if (!userObj || !userObj.name) return '未知用户'
|
||||
let displayName = userObj.name
|
||||
|
||||
const sameNameUsers = nameToUsers.get(displayName)
|
||||
if (sameNameUsers && sameNameUsers.length > 1) {
|
||||
if (userObj.grade) {
|
||||
const sameGradeUsers = sameNameUsers.filter((u: any) => u.grade === userObj.grade)
|
||||
if (sameGradeUsers.length > 1 && userObj.class) {
|
||||
displayName = `${displayName}(${userObj.grade} ${userObj.class})`
|
||||
} else {
|
||||
displayName = `${displayName}(${userObj.grade})`
|
||||
}
|
||||
}
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
|
||||
const collaborators = collaboratorsMap.get(schedule.songId) || []
|
||||
const formattedCollaborators = collaborators.map((c: any) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
displayName: formatDisplayName(c),
|
||||
grade: c.grade,
|
||||
class: c.class
|
||||
}))
|
||||
|
||||
// 获取重播申请信息
|
||||
const linkedReplayRequestId = schedule.replayRequestId || null
|
||||
const replayRequestCount = linkedReplayRequestId
|
||||
? replayRequestCountsMap.get(schedule.songId) || 0
|
||||
: 0
|
||||
const replayRequesters = linkedReplayRequestId ? replayRequestersMap.get(schedule.songId) || [] : []
|
||||
const formattedReplayRequesters = replayRequesters.map((r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
displayName: formatDisplayName(r),
|
||||
grade: r.grade,
|
||||
class: r.class,
|
||||
status: r.status
|
||||
}))
|
||||
|
||||
// 只有正式绑定了重播申请的排期才按重播展示;草稿排期不提前暴露申请信息
|
||||
const isReplaySong = linkedReplayRequestId !== null
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
createdAt: schedule.createdAt,
|
||||
updatedAt: schedule.updatedAt,
|
||||
playDate: dateOnly.toISOString().split('T')[0],
|
||||
sequence: schedule.sequence || 1,
|
||||
replayRequestId: linkedReplayRequestId,
|
||||
played: schedule.played || false,
|
||||
playTimeId: schedule.playTimeId,
|
||||
// 草稿状态信息
|
||||
isDraft: schedule.isDraft || false,
|
||||
publishedAt: schedule.publishedAt,
|
||||
status: schedule.isDraft ? 'draft' : 'published',
|
||||
playTime: schedule.playTimeName
|
||||
? {
|
||||
id: schedule.playTimeId,
|
||||
name: schedule.playTimeName,
|
||||
startTime: schedule.playTimeStartTime,
|
||||
endTime: schedule.playTimeEndTime,
|
||||
enabled: schedule.playTimeEnabled
|
||||
}
|
||||
: null,
|
||||
song: (() => {
|
||||
const replayMeta = linkedReplayRequestId ? replayMetadataByIdMap.get(linkedReplayRequestId) : null
|
||||
const hasReplayMeta = !!replayMeta
|
||||
|
||||
return {
|
||||
id: schedule.songId,
|
||||
title: schedule.songTitle,
|
||||
artist: schedule.songArtist,
|
||||
requester: requesterName,
|
||||
requesterId: schedule.songRequesterId,
|
||||
requesterGrade: schedule.requesterGrade || null,
|
||||
requesterClass: schedule.requesterClass || null,
|
||||
collaborators: formattedCollaborators,
|
||||
voteCount: voteCountMap.get(schedule.songId) || 0,
|
||||
played: schedule.songPlayed || false,
|
||||
cover: schedule.songCover || null,
|
||||
cardCodeId: schedule.songCardCodeId || null,
|
||||
musicPlatform: schedule.songMusicPlatform || null,
|
||||
musicId: schedule.songMusicId || null,
|
||||
durationSeconds: schedule.songDurationSeconds || null,
|
||||
semester: schedule.songSemester || null,
|
||||
createdAt: schedule.songCreatedAt,
|
||||
submissionNote: hasReplayMeta ? replayMeta.submissionNote : null,
|
||||
submissionNotePublic: hasReplayMeta ? replayMeta.submissionNotePublic : false,
|
||||
submissionNotePublicStatus: hasReplayMeta ? (replayMeta.submissionNotePublicStatus || null) : null,
|
||||
hasSubmissionNote: hasReplayMeta && !!replayMeta.submissionNote,
|
||||
preferredPlayTimeId: hasReplayMeta ? replayMeta.preferredPlayTimeId : null,
|
||||
// 重播申请信息
|
||||
replayRequestCount: isReplaySong ? replayRequestCount : 0,
|
||||
replayRequesters: isReplaySong ? formattedReplayRequesters : [],
|
||||
isReplay: isReplaySong
|
||||
}
|
||||
})()
|
||||
}
|
||||
})
|
||||
|
||||
// 按日期分组以便更好地组织
|
||||
const groupedByDate = {}
|
||||
formattedSchedules.forEach((schedule) => {
|
||||
const date = schedule.playDate
|
||||
if (!groupedByDate[date]) {
|
||||
groupedByDate[date] = {
|
||||
date,
|
||||
schedules: [],
|
||||
totalCount: 0,
|
||||
draftCount: 0,
|
||||
publishedCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
groupedByDate[date].schedules.push(schedule)
|
||||
groupedByDate[date].totalCount++
|
||||
|
||||
if (schedule.isDraft) {
|
||||
groupedByDate[date].draftCount++
|
||||
} else {
|
||||
groupedByDate[date].publishedCount++
|
||||
}
|
||||
})
|
||||
|
||||
// 转换为数组并按日期排序
|
||||
const dateGroups = Object.values(groupedByDate).sort(
|
||||
(a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
)
|
||||
|
||||
// 汇总统计
|
||||
const summary = {
|
||||
totalSchedules: formattedSchedules.length,
|
||||
draftCount: formattedSchedules.filter((s) => s.isDraft).length,
|
||||
publishedCount: formattedSchedules.filter((s) => !s.isDraft).length,
|
||||
dateRange: {
|
||||
startDate: dateGroups.length > 0 ? dateGroups[0].date : null,
|
||||
endDate: dateGroups.length > 0 ? dateGroups[dateGroups.length - 1].date : null
|
||||
},
|
||||
playTimeFilter: playTimeId ? parseInt(playTimeId as string) : null,
|
||||
includeDrafts: includeDrafts as string
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
summary,
|
||||
schedules: formattedSchedules,
|
||||
groupedByDate: dateGroups
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取完整排期数据失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || '获取完整排期数据失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs } from '~/drizzle/schema'
|
||||
import { createSystemNotification } from '~~/server/services/notificationService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '需要歌曲管理员及以上权限'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const fromDate = typeof body?.fromDate === 'string' ? body.fromDate.trim() : ''
|
||||
const toDate = typeof body?.toDate === 'string' ? body.toDate.trim() : ''
|
||||
|
||||
if (fromDate === toDate) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '目标日期不能与当前日期相同'
|
||||
})
|
||||
}
|
||||
|
||||
const fromStart = new Date(`${fromDate}T00:00:00.000Z`)
|
||||
const fromEnd = new Date(`${fromDate}T23:59:59.999Z`)
|
||||
const toPlayDate = new Date(`${toDate}T00:00:00.000Z`)
|
||||
const toStart = new Date(`${toDate}T00:00:00.000Z`)
|
||||
const toEnd = new Date(`${toDate}T23:59:59.999Z`)
|
||||
|
||||
if (
|
||||
Number.isNaN(fromStart.getTime()) ||
|
||||
Number.isNaN(fromEnd.getTime()) ||
|
||||
Number.isNaN(toPlayDate.getTime()) ||
|
||||
Number.isNaN(toStart.getTime()) ||
|
||||
Number.isNaN(toEnd.getTime()) ||
|
||||
fromStart.toISOString().split('T')[0] !== fromDate ||
|
||||
toPlayDate.toISOString().split('T')[0] !== toDate
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '日期无效,请使用 YYYY-MM-DD 格式并确保日期有效'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const moveResult = await db.transaction(async (tx) => {
|
||||
const existingOnToDate = await tx
|
||||
.select({ id: schedules.id })
|
||||
.from(schedules)
|
||||
.where(and(gte(schedules.playDate, toStart), lte(schedules.playDate, toEnd)))
|
||||
.limit(1)
|
||||
|
||||
if (existingOnToDate.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '目标日期已存在排期,无法迁移。请先清空目标日期的排期。'
|
||||
})
|
||||
}
|
||||
|
||||
const sourceSchedules = await tx
|
||||
.select({
|
||||
id: schedules.id,
|
||||
songId: schedules.songId,
|
||||
requesterId: songs.requesterId,
|
||||
songTitle: songs.title
|
||||
})
|
||||
.from(schedules)
|
||||
.innerJoin(songs, eq(schedules.songId, songs.id))
|
||||
.where(and(gte(schedules.playDate, fromStart), lte(schedules.playDate, fromEnd)))
|
||||
|
||||
if (sourceSchedules.length === 0) {
|
||||
return {
|
||||
movedCount: 0,
|
||||
movedSongs: []
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleIds = sourceSchedules.map((item) => item.id)
|
||||
const updateTime = getServerDate()
|
||||
const movedSchedules = await tx
|
||||
.update(schedules)
|
||||
.set({
|
||||
playDate: toPlayDate,
|
||||
updatedAt: updateTime
|
||||
})
|
||||
.where(inArray(schedules.id, scheduleIds))
|
||||
.returning({
|
||||
id: schedules.id
|
||||
})
|
||||
|
||||
return {
|
||||
movedCount: movedSchedules.length,
|
||||
movedSongs: sourceSchedules
|
||||
}
|
||||
})
|
||||
|
||||
const notificationsToSend = moveResult.movedSongs.map((item) => {
|
||||
const message = `您投稿的歌曲《${item.songTitle}》原定于 ${fromDate} 播放,已调整至 ${toDate}。`
|
||||
return createSystemNotification(item.requesterId, '排期调整通知', message)
|
||||
})
|
||||
|
||||
if (notificationsToSend.length > 0) {
|
||||
Promise.allSettled(notificationsToSend).catch((error) => {
|
||||
console.error('[Notification] 发送排期迁移通知失败:', error)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fromDate,
|
||||
toDate,
|
||||
movedCount: moveResult.movedCount
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 409) {
|
||||
throw error
|
||||
}
|
||||
console.error('迁移排期日期失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || '迁移排期日期失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs } from '~/drizzle/schema'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { createSongSelectedNotification, createReplaySongSelectedNotification } from '~~/server/services/notificationService'
|
||||
import { redeemCardCodeForSchedule } from '~~/server/services/cardCodeLifecycleService'
|
||||
import { fulfillReplayRequestsForSchedule } from '~~/server/utils/scheduleReplayBinding'
|
||||
import { getBeijingTimestamp } from '~/utils/timeUtils'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户认证和权限
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '未授权访问'
|
||||
})
|
||||
}
|
||||
|
||||
if (!['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有歌曲管理员及以上权限才能发布排期'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body.scheduleId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '排期ID不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查草稿是否存在
|
||||
const draftResult = await db
|
||||
.select({
|
||||
id: schedules.id,
|
||||
songId: schedules.songId,
|
||||
playDate: schedules.playDate,
|
||||
sequence: schedules.sequence,
|
||||
playTimeId: schedules.playTimeId,
|
||||
isDraft: schedules.isDraft,
|
||||
publishedAt: schedules.publishedAt,
|
||||
replayRequestId: schedules.replayRequestId,
|
||||
song: {
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist,
|
||||
requesterId: songs.requesterId,
|
||||
cardCodeId: songs.cardCodeId
|
||||
}
|
||||
})
|
||||
.from(schedules)
|
||||
.innerJoin(songs, eq(schedules.songId, songs.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schedules.id, body.scheduleId),
|
||||
eq(schedules.isDraft, true) // 确保是草稿状态
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const draft = draftResult[0]
|
||||
|
||||
if (!draft) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '找不到指定的排期草稿'
|
||||
})
|
||||
}
|
||||
|
||||
// 使用事务包装更新排期和重播申请状态操作,保证原子性
|
||||
const publishResult = await db.transaction(async (tx) => {
|
||||
// 更新草稿为已发布状态
|
||||
const publishedAt = new Date(getBeijingTimestamp())
|
||||
|
||||
const publishResult = await tx
|
||||
.update(schedules)
|
||||
.set({
|
||||
isDraft: false,
|
||||
publishedAt: publishedAt,
|
||||
updatedAt: publishedAt
|
||||
})
|
||||
.where(and(eq(schedules.id, body.scheduleId), eq(schedules.isDraft, true)))
|
||||
.returning()
|
||||
|
||||
const schedule = publishResult[0]
|
||||
|
||||
if (!schedule) {
|
||||
throw createError({ statusCode: 409, message: '排期已发布或状态已变化,请刷新后重试' })
|
||||
}
|
||||
|
||||
// 检查该歌曲是否已经有其他已发布的排期
|
||||
const existingPublished = await tx
|
||||
.select({ id: schedules.id })
|
||||
.from(schedules)
|
||||
.where(
|
||||
and(
|
||||
eq(schedules.songId, draft.song.id),
|
||||
eq(schedules.isDraft, false),
|
||||
ne(schedules.id, body.scheduleId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const shouldNotify = existingPublished.length === 0
|
||||
|
||||
// 履行该歌曲全部待处理重播申请,优先绑定草稿指定的申请
|
||||
let replayRequesterIds: number[] = []
|
||||
const replayBinding = await fulfillReplayRequestsForSchedule({
|
||||
tx,
|
||||
songId: draft.song.id,
|
||||
scheduleId: body.scheduleId,
|
||||
at: publishedAt,
|
||||
preferredRequestId: draft.replayRequestId || undefined,
|
||||
fallbackRequestId: draft.replayRequestId || undefined
|
||||
})
|
||||
|
||||
if (replayBinding) {
|
||||
replayRequesterIds = replayBinding.replayRequesterIds
|
||||
console.log(`发布排期:重播申请 #${replayBinding.replayRequestId} 绑定到排期 #${body.scheduleId}`)
|
||||
}
|
||||
|
||||
if (existingPublished.length > 0) {
|
||||
console.log(`歌曲 ${draft.song.id} 已有其他正式排期,不再重复发送通知`)
|
||||
}
|
||||
|
||||
await redeemCardCodeForSchedule(tx, {
|
||||
songId: draft.song.id,
|
||||
cardCodeId: draft.song.cardCodeId,
|
||||
operatorId: user.id,
|
||||
at: publishedAt
|
||||
})
|
||||
|
||||
return { schedule, shouldNotify, replayRequesterIds, replayRequestId: replayBinding?.replayRequestId ?? null }
|
||||
})
|
||||
|
||||
// 发送重播申请已安排通知
|
||||
if (publishResult.replayRequesterIds?.length > 0) {
|
||||
for (const replayUserId of publishResult.replayRequesterIds) {
|
||||
try {
|
||||
await createReplaySongSelectedNotification(replayUserId, draft.song.id, {
|
||||
title: draft.song.title,
|
||||
artist: draft.song.artist,
|
||||
playDate: publishResult.schedule.playDate
|
||||
}, publishResult.schedule.id)
|
||||
} catch (error) {
|
||||
console.error(`发送重播安排通知给用户 ${replayUserId} 失败:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let notificationSent = true
|
||||
if (publishResult.shouldNotify) {
|
||||
try {
|
||||
await createSongSelectedNotification(draft.song.requesterId, draft.song.id, {
|
||||
title: draft.song.title,
|
||||
artist: draft.song.artist,
|
||||
playDate: publishResult.schedule.playDate
|
||||
})
|
||||
} catch (notificationError) {
|
||||
notificationSent = false
|
||||
console.error('排期已发布,但发送歌曲入选通知失败:', notificationError)
|
||||
}
|
||||
}
|
||||
|
||||
const publishedSchedule = publishResult.schedule
|
||||
|
||||
return {
|
||||
...draft,
|
||||
...publishedSchedule,
|
||||
song: draft.song,
|
||||
isDraft: false,
|
||||
publishedAt: publishedSchedule.publishedAt,
|
||||
// 绑定在 fulfill 中回写,.returning() 快照早于绑定,需显式覆盖
|
||||
replayRequestId: publishResult.replayRequestId,
|
||||
message: publishResult.shouldNotify
|
||||
? notificationSent
|
||||
? '排期发布成功,通知已发送'
|
||||
: '排期发布成功,但通知发送失败'
|
||||
: '排期发布成功'
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('发布排期失败:', error)
|
||||
if (error.statusCode) throw error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || '发布排期失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules } from '~/drizzle/schema'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
|
||||
// 回滚辅助接口:仅删除指定批次创建的排期,并可恢复被覆盖的草稿
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireSongAdmin(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
const scheduleIds = Array.isArray(body?.scheduleIds)
|
||||
? body.scheduleIds.map(Number).filter((id) => Number.isInteger(id) && id > 0)
|
||||
: []
|
||||
const restoreSchedules = Array.isArray(body?.restoreSchedules) ? body.restoreSchedules : []
|
||||
if ((scheduleIds.length === 0 && restoreSchedules.length === 0) || scheduleIds.length > 1000 || restoreSchedules.length > 1000) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'scheduleIds 或 restoreSchedules 必须包含 1-1000 条记录')
|
||||
}
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const deleted = await tx
|
||||
.delete(schedules)
|
||||
.where(and(inArray(schedules.id, scheduleIds), eq(schedules.isDraft, true), isNull(schedules.publishedAt)))
|
||||
.returning({ id: schedules.id })
|
||||
if (restoreSchedules.length > 0) {
|
||||
await tx.insert(schedules).values(restoreSchedules.map((schedule) => ({
|
||||
id: Number(schedule.id),
|
||||
createdAt: new Date(schedule.createdAt),
|
||||
updatedAt: new Date(schedule.updatedAt),
|
||||
songId: Number(schedule.songId),
|
||||
playDate: new Date(schedule.playDate),
|
||||
played: Boolean(schedule.played),
|
||||
sequence: Number(schedule.sequence),
|
||||
playTimeId: schedule.playTimeId == null ? null : Number(schedule.playTimeId),
|
||||
isDraft: true,
|
||||
publishedAt: null,
|
||||
replayRequestId: schedule.replayRequestId == null ? null : Number(schedule.replayRequestId)
|
||||
})))
|
||||
}
|
||||
return deleted
|
||||
})
|
||||
|
||||
return { success: true, removed: result.length, restored: restoreSchedules.length }
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { db, eq, ne, schedules, songs, and } from '~/drizzle/db'
|
||||
import { restoreCardCodeAfterScheduleRemoval } from '~~/server/services/cardCodeLifecycleService'
|
||||
import { restoreReplayRequestsToPending } from '~~/server/utils/scheduleReplayBinding'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '需要歌曲管理员及以上权限'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
const { scheduleId } = body
|
||||
|
||||
if (!scheduleId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少排期 ID'
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleIdNumber = Number(scheduleId)
|
||||
|
||||
console.log(`准备删除排期 ID=${scheduleIdNumber}`)
|
||||
|
||||
// 先检查排期是否存在,并获取歌曲ID
|
||||
const existingSchedule = await db
|
||||
.select({
|
||||
id: schedules.id,
|
||||
songId: schedules.songId,
|
||||
isDraft: schedules.isDraft,
|
||||
cardCodeId: songs.cardCodeId,
|
||||
songTitle: songs.title,
|
||||
songArtist: songs.artist
|
||||
})
|
||||
.from(schedules)
|
||||
.leftJoin(songs, eq(schedules.songId, songs.id))
|
||||
.where(eq(schedules.id, scheduleIdNumber))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!existingSchedule) {
|
||||
console.log(`排期不存在 ID=${scheduleIdNumber}`)
|
||||
return {
|
||||
success: false,
|
||||
message: '排期不存在或已被删除'
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`找到排期 ID=${scheduleIdNumber}, 歌曲=${existingSchedule.songTitle || '未知歌曲'}`)
|
||||
|
||||
// 使用事务包装删除和重播状态更新操作
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// 删除排期
|
||||
const deletedSchedule = await tx
|
||||
.delete(schedules)
|
||||
.where(eq(schedules.id, scheduleIdNumber))
|
||||
.returning()
|
||||
|
||||
console.log(`成功删除排期 ID=${scheduleIdNumber}`)
|
||||
|
||||
// 恢复该歌曲的重播申请状态为 PENDING
|
||||
// 只有当该歌曲没有其他正式排期(非草稿)时,才恢复重播申请状态
|
||||
if (existingSchedule.songId) {
|
||||
const otherSchedules = await tx
|
||||
.select({ id: schedules.id })
|
||||
.from(schedules)
|
||||
.where(
|
||||
and(
|
||||
eq(schedules.songId, existingSchedule.songId),
|
||||
eq(schedules.isDraft, false),
|
||||
ne(schedules.id, scheduleIdNumber)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (otherSchedules.length === 0) {
|
||||
if (!existingSchedule.isDraft && existingSchedule.cardCodeId) {
|
||||
const restoreResult = await restoreCardCodeAfterScheduleRemoval(tx, {
|
||||
songId: existingSchedule.songId,
|
||||
cardCodeId: existingSchedule.cardCodeId,
|
||||
operatorId: user.id
|
||||
})
|
||||
if (
|
||||
!restoreResult.changed &&
|
||||
['CONCURRENT_CHANGE', 'MISSING_CARD_CODE'].includes(
|
||||
String(restoreResult.reason || '')
|
||||
)
|
||||
) {
|
||||
throw createError({ statusCode: 409, message: '点歌券返还失败,移除排期已终止' })
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复重播申请(每人仅恢复最新一条,避免违反部分唯一索引)
|
||||
const restoredCount = await restoreReplayRequestsToPending({
|
||||
tx,
|
||||
songIds: [existingSchedule.songId],
|
||||
at: getServerDate()
|
||||
})
|
||||
|
||||
if (restoredCount > 0) {
|
||||
console.log(`恢复了 ${restoredCount} 个重播申请状态为 PENDING`)
|
||||
}
|
||||
} else {
|
||||
console.log(`该歌曲仍有其他正式排期,不恢复重播申请状态`)
|
||||
}
|
||||
}
|
||||
|
||||
return deletedSchedule
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
schedule: result
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('移除排期失败:', error)
|
||||
|
||||
// 处理数据库特定错误
|
||||
if (error.message?.includes('not found') || error.message?.includes('does not exist')) {
|
||||
return {
|
||||
success: false,
|
||||
message: '排期不存在或已被删除'
|
||||
}
|
||||
}
|
||||
|
||||
// 确保返回一个成功=false的响应,而不是抛出错误
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '移除排期失败',
|
||||
error: error.code || 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { db, eq } from '~/drizzle/db'
|
||||
import { schedules } from '~/drizzle/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 验证用户认证和权限
|
||||
const user = event.context.user
|
||||
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '需要歌曲管理员及以上权限'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
const scheduleUpdates = body.schedules
|
||||
|
||||
if (!Array.isArray(scheduleUpdates) || scheduleUpdates.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少排期数据'
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedUpdates = scheduleUpdates.map((item: any, index: number) => {
|
||||
const id = Number(item?.id)
|
||||
const sequence = Number(item?.sequence)
|
||||
if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(sequence) || sequence <= 0) {
|
||||
throw createError({ statusCode: 400, message: `第 ${index + 1} 条排期数据无效` })
|
||||
}
|
||||
return { id, sequence }
|
||||
})
|
||||
|
||||
// 批量更新排期顺序
|
||||
const results = await db.transaction(async (tx) => {
|
||||
const updatedRows = await Promise.all(
|
||||
normalizedUpdates.map((item) =>
|
||||
tx
|
||||
.update(schedules)
|
||||
.set({ sequence: item.sequence })
|
||||
.where(eq(schedules.id, item.id))
|
||||
.returning({ id: schedules.id })
|
||||
)
|
||||
)
|
||||
|
||||
if (updatedRows.some((rows) => rows.length === 0)) {
|
||||
throw createError({ statusCode: 404, message: '部分排期不存在,请刷新后重试' })
|
||||
}
|
||||
|
||||
return updatedRows
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: results.length
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('更新排期顺序失败:', error)
|
||||
|
||||
if (error.statusCode) throw error
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || '更新排期顺序失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { scheduleSongPool } from '~/drizzle/schema'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
import { fetchPoolCount } from '~~/server/utils/scheduleSongPool'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireSongAdmin(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const songIdsRaw = query.songIds
|
||||
|
||||
let songIds: number[] = []
|
||||
if (typeof songIdsRaw === 'string') {
|
||||
songIds = songIdsRaw.split(',').map((s) => Number.parseInt(s.trim(), 10)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
} else if (Array.isArray(songIdsRaw)) {
|
||||
songIds = songIdsRaw.map((s) => Number.parseInt(String(s).trim(), 10)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
}
|
||||
|
||||
if (songIds.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'songIds 必须为非空数组或逗号分隔字符串')
|
||||
}
|
||||
if (songIds.length > 1000) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'songIds 数量不能超过 1000')
|
||||
}
|
||||
|
||||
const result = await db.delete(scheduleSongPool).where(inArray(scheduleSongPool.songId, songIds)).returning()
|
||||
return { ok: true, removed: result.length, total: await fetchPoolCount() }
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, scheduleSongPool, users, votes } from '~/drizzle/schema'
|
||||
import { inArray, count } from 'drizzle-orm'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireSongAdmin(event)
|
||||
|
||||
const isSuperAdmin = event.context.user?.role === 'SUPER_ADMIN'
|
||||
|
||||
const poolRows = await db.select().from(scheduleSongPool).orderBy(scheduleSongPool.createdAt)
|
||||
|
||||
if (poolRows.length === 0) {
|
||||
return { pool: [], count: 0 }
|
||||
}
|
||||
|
||||
const poolSongIds = poolRows.map((row) => row.songId)
|
||||
const songsRows = await db.select().from(songs).where(inArray(songs.id, poolSongIds))
|
||||
const songsMap = new Map(songsRows.map((s) => [s.id, s]))
|
||||
|
||||
// 批量加载投稿人信息
|
||||
const requesterIds = [...new Set(songsRows.map((s) => s.requesterId).filter(Boolean))]
|
||||
const requesterMap = new Map()
|
||||
if (requesterIds.length > 0) {
|
||||
const userRows = await db.select().from(users).where(inArray(users.id, requesterIds))
|
||||
userRows.forEach((u) => requesterMap.set(u.id, u))
|
||||
}
|
||||
|
||||
// 批量加载添加者信息
|
||||
const addedByIds = [...new Set(poolRows.map((row) => row.addedBy).filter(Boolean))]
|
||||
const addedByUsersMap = new Map()
|
||||
if (addedByIds.length > 0) {
|
||||
const userRows = await db.select().from(users).where(inArray(users.id, addedByIds))
|
||||
userRows.forEach((u) => addedByUsersMap.set(u.id, u))
|
||||
}
|
||||
|
||||
// 批量加载投票数
|
||||
const voteCountMap = new Map()
|
||||
if (poolSongIds.length > 0) {
|
||||
const voteCountsResult = await db
|
||||
.select({ songId: votes.songId, count: count() })
|
||||
.from(votes)
|
||||
.where(inArray(votes.songId, poolSongIds))
|
||||
.groupBy(votes.songId)
|
||||
voteCountsResult.forEach((v) => voteCountMap.set(v.songId, v.count))
|
||||
}
|
||||
|
||||
const pool = poolRows.map((row) => {
|
||||
const song = songsMap.get(row.songId)
|
||||
if (!song) return null
|
||||
const requesterUser = song.requesterId ? requesterMap.get(song.requesterId) : null
|
||||
const addedByUser = row.addedBy ? addedByUsersMap.get(row.addedBy) : null
|
||||
return {
|
||||
poolId: row.id,
|
||||
songId: row.songId,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
durationSeconds: song.durationSeconds,
|
||||
played: song.played === true,
|
||||
requester: requesterUser ? (requesterUser.name || requesterUser.username || 'Unknown User') : 'Unknown User',
|
||||
requesterId: song.requesterId,
|
||||
requesterGrade: requesterUser?.grade || null,
|
||||
requesterClass: requesterUser?.class || null,
|
||||
preferredPlayTimeId: song.preferredPlayTimeId,
|
||||
cover: song.cover,
|
||||
semester: song.semester,
|
||||
musicId: song.musicId,
|
||||
musicPlatform: song.musicPlatform,
|
||||
voteCount: voteCountMap.get(row.songId) || 0,
|
||||
cardCodeId: song.cardCodeId,
|
||||
usedCardCode: song.cardCodeId != null,
|
||||
hasSubmissionNote: song.submissionNote ? true : false,
|
||||
submissionNote: isSuperAdmin ? song.submissionNote : undefined,
|
||||
addedBy: row.addedBy,
|
||||
addedByName: addedByUser?.name || addedByUser?.username || null,
|
||||
createdAt: row.createdAt
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
return { pool, count: pool.length } // 有效记录数(已过滤掉歌曲被删除的孤立记录)
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, scheduleSongPool } from '~/drizzle/schema'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { getServerDate } from '~~/server/utils/serverTime'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
import { fetchPoolCount } from '~~/server/utils/scheduleSongPool'
|
||||
import { fetchSongDuration } from '~~/server/utils/songDurationFetcher'
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireSongAdmin(event)
|
||||
const user = event.context.user as any
|
||||
const body = await readBody(event)
|
||||
if (!Array.isArray(body.songIds) || body.songIds.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'songIds 必须为非空数组')
|
||||
}
|
||||
const songIds = body.songIds.map(Number).filter((n) => Number.isInteger(n) && n > 0)
|
||||
if (songIds.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'songIds 全部无效')
|
||||
}
|
||||
if (songIds.length > 1000) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'songIds 数量不能超过 1000')
|
||||
}
|
||||
const now = getServerDate()
|
||||
// 批量查询歌曲,避免 N+1
|
||||
const songsRows = await db.select().from(songs).where(inArray(songs.id, songIds))
|
||||
// 时长补全放到响应后的后台任务,避免批量加入时串行等待外部平台
|
||||
const missingDurationSongs = songsRows.filter(
|
||||
(s) => s.durationSeconds == null && s.musicPlatform && s.musicId
|
||||
)
|
||||
const frontendDurationMap = new Map(
|
||||
(Array.isArray(body.songDurations) ? body.songDurations : [])
|
||||
.map((item) => [Number(item?.songId), Number(item?.durationSeconds)])
|
||||
.filter(([songId, duration]) => Number.isInteger(songId) && Number.isFinite(duration))
|
||||
)
|
||||
const durationTolerance = 3
|
||||
const clientDurationUpdates = missingDurationSongs
|
||||
.map((song) => {
|
||||
const duration = frontendDurationMap.get(song.id)
|
||||
return Number.isInteger(duration) && duration >= 30 && duration <= 3600
|
||||
? { song, duration }
|
||||
: null
|
||||
})
|
||||
.filter(Boolean)
|
||||
await Promise.all(
|
||||
clientDurationUpdates.map(({ song, duration }) =>
|
||||
db.update(songs).set({ durationSeconds: duration }).where(eq(songs.id, song.id))
|
||||
)
|
||||
)
|
||||
if (missingDurationSongs.length > 0) {
|
||||
const durationTask = (async () => {
|
||||
for (const song of missingDurationSongs.filter(
|
||||
(item) => !clientDurationUpdates.some((update) => update.song.id === item.id)
|
||||
)) {
|
||||
const duration = await fetchSongDuration(song.musicPlatform, song.musicId)
|
||||
if (duration != null) {
|
||||
const frontendDuration = frontendDurationMap.get(song.id)
|
||||
if (frontendDuration != null && Math.abs(duration - frontendDuration) > durationTolerance) {
|
||||
console.warn(`[备选池] 歌曲 #${song.id} 前端时长 ${frontendDuration}s 与后端获取 ${duration}s 不一致,以后端为准`)
|
||||
}
|
||||
await db.update(songs).set({ durationSeconds: duration }).where(eq(songs.id, song.id))
|
||||
}
|
||||
}
|
||||
})()
|
||||
if (typeof event.waitUntil === 'function') {
|
||||
event.waitUntil(durationTask)
|
||||
} else {
|
||||
durationTask.catch((error) => console.error('[备选池] 后台补全时长失败:', error))
|
||||
}
|
||||
}
|
||||
const refreshedMap = new Map(songsRows.map((s) => [s.id, s]))
|
||||
// 构建待插入值和跳过列表
|
||||
const insertValues = []
|
||||
const skipped = []
|
||||
for (const songId of songIds) {
|
||||
const song = refreshedMap.get(songId)
|
||||
if (!song) {
|
||||
skipped.push({ songId, reason: '歌曲不存在(无法加入备选池)' })
|
||||
continue
|
||||
}
|
||||
insertValues.push({ songId, title: song.title, artist: song.artist, createdAt: now, addedBy: user.id })
|
||||
}
|
||||
// 批量插入
|
||||
const added = []
|
||||
if (insertValues.length > 0) {
|
||||
const inserted = await db
|
||||
.insert(scheduleSongPool)
|
||||
.values(insertValues.map((v) => ({ songId: v.songId, createdAt: v.createdAt, addedBy: v.addedBy })))
|
||||
.onConflictDoNothing({ target: [scheduleSongPool.songId] })
|
||||
.returning()
|
||||
const insertedIds = new Set(inserted.map((r) => r.songId))
|
||||
for (const v of insertValues) {
|
||||
if (insertedIds.has(v.songId)) {
|
||||
added.push({ songId: v.songId, title: v.title, artist: v.artist })
|
||||
} else {
|
||||
skipped.push({ songId: v.songId, reason: '已存在于备选池' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return { added, skipped, total: await fetchPoolCount() }
|
||||
})
|
||||
Reference in New Issue
Block a user