Files
BYW-VoiceHub/server/api/admin/schedule/bulk-publish.post.ts
T

325 lines
11 KiB
TypeScript

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 || '发布排期失败'
})
}
})