feat: 迁移自定义功能至 1.5.9.9 版本

包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
2026-09-12 19:26:42 +08:00
parent 34f0679117
commit 1106aeea6c
161 changed files with 18963 additions and 27900 deletions
+57 -200
View File
@@ -1,17 +1,9 @@
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 { playTimes, schedules, songs, songReplayRequests } from '~/drizzle/schema'
import { and, eq, gte, lte, inArray } from 'drizzle-orm'
import { createSongSelectedNotification } from '~~/server/services/notificationService'
import { cacheService } from '~~/server/services/cacheService'
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) => {
// 检查用户认证和权限
@@ -41,27 +33,6 @@ export default defineEventHandler(async (event) => {
})
}
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()
@@ -76,7 +47,7 @@ export default defineEventHandler(async (event) => {
const playTimeId = body.playTimeId ? parseInt(body.playTimeId) : null
// 获取所有涉及的歌曲详情(用于通知)
const songIds = scheduleItems.map((item) => item.songId)
const songIds = body.songs.map((s: any) => s.songId)
if (songIds.length === 0) {
// 如果列表为空,说明是清空排期,直接执行删除逻辑即可
}
@@ -85,13 +56,6 @@ export default defineEventHandler(async (event) => {
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<{
@@ -99,12 +63,6 @@ export default defineEventHandler(async (event) => {
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) => {
@@ -118,142 +76,43 @@ export default defineEventHandler(async (event) => {
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
// 2. 查找该时间段内已发布的排期(用于避免重复发送通知)
const existingPublished = await tx
.select({
songId: schedules.songId,
isDraft: schedules.isDraft,
replayRequestId: schedules.replayRequestId,
cardCodeId: songs.cardCodeId
songId: schedules.songId
})
.from(schedules)
.leftJoin(songs, eq(schedules.songId, songs.id))
.where(and(...whereConditions))
.where(and(...whereConditions, eq(schedules.isDraft, false)))
// 记录被删除的已发布排期的重播绑定,同歌重发时回写,避免重播标识丢失
const previousReplayBindings = new Map<number, number>()
for (const scheduleToDelete of schedulesToDelete) {
if (!scheduleToDelete.isDraft && scheduleToDelete.replayRequestId) {
previousReplayBindings.set(scheduleToDelete.songId, scheduleToDelete.replayRequestId)
}
}
const existingPublishedSongIds = new Set(existingPublished.map((s) => s.songId))
// 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()
const publishedAt = new Date()
for (const item of scheduleItems) {
const song = songMap.get(item.songId)!
for (const item of body.songs) {
const song = songMap.get(item.songId)
if (!song) {
console.warn(`找不到歌曲 ID: ${item.songId},跳过排期`)
continue
}
// 插入排期
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
await tx.insert(schedules).values({
songId: item.songId,
playDate: playDate,
sequence: item.sequence,
playTimeId: playTimeId,
isDraft: false, // 直接发布
publishedAt: publishedAt,
updatedAt: publishedAt
})
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,
@@ -263,44 +122,42 @@ export default defineEventHandler(async (event) => {
playDate: playDate
}
})
existingPublishedSongIds.add(item.songId)
}
await redeemCardCodeForSchedule(tx, {
songId: song.id,
cardCodeId: song.cardCodeId,
operatorId: user.id,
at: publishedAt
})
// 更新重播申请状态
await tx
.update(songReplayRequests)
.set({ status: 'FULFILLED' })
.where(
and(eq(songReplayRequests.songId, song.id), eq(songReplayRequests.status, 'PENDING'))
)
}
}
})
// 发送重播安排通知
if (replayNotificationsToSend.length > 0) {
event.waitUntil(
Promise.allSettled(
replayNotificationsToSend.map((n) =>
createReplaySongSelectedNotification(n.userId, n.songId, n.songInfo, n.scheduleId)
)
)
)
// 事务成功提交后,清除缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log(`[Cache] 排期缓存和歌曲列表缓存已清除(批量发布排期)`)
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
// 由运行时托管后台通知,避免 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} 成功`
)
})
// 异步发送通知(不阻塞响应)
Promise.allSettled(
notificationsToSend.map((n) =>
createSongSelectedNotification(n.requesterId, n.songId, n.songInfo, clientIP)
)
}
)
.then((results) => {
const successCount = results.filter((r) => r.status === 'fulfilled').length
console.log(
`[Notification] 批量发布通知发送完成: ${successCount}/${notificationsToSend.length} 成功`
)
})
.catch((err) => {
console.error('[Notification] 批量发送通知时发生未捕获错误:', err)
})
console.log(`[Performance] 批量发布排期耗时: ${Date.now() - startTime}ms`)
@@ -317,7 +174,7 @@ export default defineEventHandler(async (event) => {
})
throw createError({
statusCode: error.statusCode || 500,
statusCode: 500,
message: error.message || '发布排期失败'
})
}
@@ -0,0 +1,107 @@
import { db } from '~/drizzle/db'
import { schedules, songReplayRequests, scheduleSongPool } from '~/drizzle/schema'
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
function isValidDateString(dateStr: unknown) {
if (typeof dateStr !== 'string') return false
const trimmed = dateStr.trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return false
const date = new Date(trimmed + 'T00:00:00.000Z')
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === trimmed
}
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 playDate = body?.playDate
const playTimeId = body?.playTimeId
const songIds = body?.songIds
if (!isValidDateString(playDate)) {
throw createError({ statusCode: 400, message: '播放日期格式必须为YYYY-MM-DD' })
}
if (!Array.isArray(songIds) || !songIds.every((id) => typeof id === 'number' && id > 0)) {
throw createError({ statusCode: 400, message: '歌曲ID列表不能为空且必须为正整数数组' })
}
const playTimeFilterEnabled = playTimeId !== undefined && playTimeId !== null
if (playTimeFilterEnabled && (typeof playTimeId !== 'number' || playTimeId <= 0)) {
throw createError({ statusCode: 400, message: '播放时间ID必须是正整数' })
}
const startOfDay = new Date(playDate + 'T00:00:00.000Z')
const endOfDay = new Date(playDate + 'T23:59:59.999Z')
const result = await db.transaction(async (tx) => {
const whereBase = and(gte(schedules.playDate, startOfDay), lte(schedules.playDate, endOfDay))
const whereCondition = playTimeFilterEnabled
? and(whereBase, eq(schedules.playTimeId, playTimeId))
: whereBase
const existingSchedules = await tx
.select({ id: schedules.id, songId: schedules.songId })
.from(schedules)
.where(whereCondition)
const existingSongIds = existingSchedules.map((s) => s.songId).filter(Boolean) as number[]
await tx.delete(schedules).where(whereCondition)
if (existingSongIds.length > 0) {
await tx
.update(songReplayRequests)
.set({ status: 'PENDING', updatedAt: new Date() })
.where(
and(inArray(songReplayRequests.songId, existingSongIds), eq(songReplayRequests.status, 'FULFILLED'))
)
}
if (songIds.length === 0) {
return { deletedCount: existingSchedules.length, insertedCount: 0 }
}
const rows = songIds.map((songId, index) => ({
songId,
playDate: startOfDay,
sequence: index + 1,
playTimeId: playTimeFilterEnabled ? playTimeId : null,
isDraft: true,
publishedAt: null
}))
const inserted = await tx.insert(schedules).values(rows).returning({ id: schedules.id })
if (songIds.length > 0) {
await tx
.update(scheduleSongPool)
.set({
status: 'ASSIGNED',
targetPlayDate: startOfDay
})
.where(
and(
inArray(scheduleSongPool.songId, songIds),
eq(scheduleSongPool.status, 'PENDING')
)
)
}
return { deletedCount: existingSchedules.length, insertedCount: inserted.length }
})
await Promise.allSettled([cacheService.clearSchedulesCache(), cacheService.clearSongsCache()])
return {
success: true,
...result
}
})
+46 -20
View File
@@ -1,8 +1,8 @@
import { db } from '~/drizzle/db'
import { playTimes, schedules, songs, songReplayRequests } from '~/drizzle/schema'
import { and, desc, eq, gte, lte } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { createApiError } from '~~/server/utils/apiError'
// 输入验证函数
function validateInput(body: any) {
@@ -111,22 +111,39 @@ export default defineEventHandler(async (event) => {
}
}
// 校验重播申请绑定(草稿仅记录绑定关系,不修改申请状态)
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 existingScheduleResult = await tx
.select()
.from(schedules)
.where(eq(schedules.songId, body.songId))
.limit(1)
const replayRequest = replayRequestResult[0]
if (!replayRequest || replayRequest.songId !== song.id || replayRequest.status !== 'PENDING') {
throw createApiError(400, 'SONG_REPLAY_INVALID_REQUEST', '重播申请无效或已被处理')
const existingSchedule = existingScheduleResult[0]
if (existingSchedule) {
// 删除现有排期或草稿
await tx.delete(schedules).where(eq(schedules.id, existingSchedule.id))
// 恢复该歌曲的重播申请状态为 PENDING
// 只恢复状态为 FULFILLED 的申请
const updatedRequests = await tx
.update(songReplayRequests)
.set({
status: 'PENDING',
updatedAt: new Date()
})
.where(
and(
eq(songReplayRequests.songId, body.songId),
eq(songReplayRequests.status, 'FULFILLED')
)
)
.returning()
if (updatedRequests.length > 0) {
console.log(
`恢复了 ${updatedRequests.length} 个重播申请状态为 PENDING(草稿保存时删除旧排期)`
)
}
replayRequestId = replayRequest.id
}
// 获取序号,如果未提供则查找当天最大序号+1
@@ -149,8 +166,9 @@ export default defineEventHandler(async (event) => {
.orderBy(desc(schedules.sequence))
.limit(1)
const latestSchedule = sameDaySchedules[0]
if (latestSchedule) sequence = (latestSchedule.sequence || 0) + 1
if (sameDaySchedules.length > 0) {
sequence = (sameDaySchedules[0].sequence || 0) + 1
}
}
// 解析输入的日期字符串,确保日期正确
@@ -168,7 +186,6 @@ export default defineEventHandler(async (event) => {
playDate: playDate,
sequence: sequence,
playTimeId: body.playTimeId || null,
replayRequestId,
isDraft: true, // 标记为草稿
publishedAt: null // 草稿状态下没有发布时间
})
@@ -180,12 +197,21 @@ export default defineEventHandler(async (event) => {
id: song.id,
title: song.title,
artist: song.artist,
requesterId: song.requesterId,
cardCodeId: song.cardCodeId
requesterId: song.requesterId
}
}
})
// 清除相关缓存(在事务外执行,避免影响事务性能)
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(保存草稿)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
// 缓存清除失败不应该影响主要操作的成功
}
const responseData = {
...result.schedule,
song: result.song,
+52 -114
View File
@@ -8,7 +8,7 @@ import {
songCollaborators,
songReplayRequests
} from '~/drizzle/schema'
import { and, asc, count, countDistinct, eq, gte, lt, inArray, desc } from 'drizzle-orm'
import { and, asc, count, eq, gte, lt, inArray, desc } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户身份验证和权限
@@ -79,7 +79,6 @@ export default defineEventHandler(async (event) => {
playTimeId: schedules.playTimeId,
isDraft: schedules.isDraft,
publishedAt: schedules.publishedAt,
replayRequestId: schedules.replayRequestId,
songId: schedules.songId,
songTitle: songs.title,
songArtist: songs.artist,
@@ -88,8 +87,6 @@ export default defineEventHandler(async (event) => {
songCover: songs.cover,
songMusicPlatform: songs.musicPlatform,
songMusicId: songs.musicId,
songDurationSeconds: songs.durationSeconds,
songCardCodeId: songs.cardCodeId,
songSemester: songs.semester,
songCreatedAt: songs.createdAt,
requesterName: users.name,
@@ -180,11 +177,11 @@ export default defineEventHandler(async (event) => {
const replayRequestersMap = new Map()
if (songIds.length > 0) {
// 获取每首歌的重播申请数量(统计 PENDING 和 FULFILLED 状态,按申请人去重)
// 获取每首歌的重播申请数量(统计 PENDING 和 FULFILLED 状态)
const replayCountsData = await db
.select({
songId: songReplayRequests.songId,
count: countDistinct(songReplayRequests.userId)
count: count(songReplayRequests.id)
})
.from(songReplayRequests)
.where(
@@ -210,10 +207,7 @@ export default defineEventHandler(async (event) => {
class: users.class
},
status: songReplayRequests.status,
createdAt: songReplayRequests.createdAt,
preferredPlayTimeId: songReplayRequests.preferredPlayTimeId,
submissionNote: songReplayRequests.submissionNote,
submissionNotePublic: songReplayRequests.submissionNotePublic
createdAt: songReplayRequests.createdAt
})
.from(songReplayRequests)
.innerJoin(users, eq(songReplayRequests.userId, users.id))
@@ -225,19 +219,12 @@ export default defineEventHandler(async (event) => {
)
.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
}
// 只保留前5个
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 || '未知用户',
@@ -249,69 +236,36 @@ export default defineEventHandler(async (event) => {
})
}
// 获取重播申请元数据(按 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 requesterClassParts: string[] = []
if (schedule.requesterGrade) requesterClassParts.push(String(schedule.requesterGrade))
if (schedule.requesterClass) requesterClassParts.push(String(schedule.requesterClass))
const requesterClassInfo = requesterClassParts.join(' ').trim()
if (requesterClassInfo && requesterName) {
requesterName = `${requesterClassInfo} ${requesterName}`
} else if (requesterClassInfo) {
requesterName = requesterClassInfo
}
// 辅助函数:格式化显示名称 (用于联合投稿人)
const formatDisplayName = (userObj: any) => {
if (!userObj || !userObj.name) return '未知用户'
let displayName = userObj.name
const formatClassInfo = (userObj: any) => {
if (!userObj) return ''
const parts: string[] = []
if (userObj.grade) parts.push(String(userObj.grade))
if (userObj.class) parts.push(String(userObj.class))
return parts.join(' ').trim()
}
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 formatDisplayName = (userObj: any) => {
if (!userObj) return '未知用户'
const name = userObj.name ? String(userObj.name).trim() : ''
const classInfo = formatClassInfo(userObj)
if (!name) return classInfo || '未知用户'
return classInfo ? `${classInfo} ${name}` : name
}
const collaborators = collaboratorsMap.get(schedule.songId) || []
@@ -324,11 +278,8 @@ export default defineEventHandler(async (event) => {
}))
// 获取重播申请信息
const linkedReplayRequestId = schedule.replayRequestId || null
const replayRequestCount = linkedReplayRequestId
? replayRequestCountsMap.get(schedule.songId) || 0
: 0
const replayRequesters = linkedReplayRequestId ? replayRequestersMap.get(schedule.songId) || [] : []
const replayRequestCount = replayRequestCountsMap.get(schedule.songId) || 0
const replayRequesters = replayRequestersMap.get(schedule.songId) || []
const formattedReplayRequesters = replayRequesters.map((r: any) => ({
id: r.id,
name: r.name,
@@ -338,8 +289,8 @@ export default defineEventHandler(async (event) => {
status: r.status
}))
// 只有正式绑定了重播申请的排期才按重播展示;草稿排期不提前暴露申请信息
const isReplaySong = linkedReplayRequestId !== null
// 判断是否为重播:有重播申请(PENDING 或 FULFILLED)
const isReplaySong = replayRequestCount > 0
return {
id: schedule.id,
@@ -347,7 +298,6 @@ export default defineEventHandler(async (event) => {
updatedAt: schedule.updatedAt,
playDate: dateOnly.toISOString().split('T')[0],
sequence: schedule.sequence || 1,
replayRequestId: linkedReplayRequestId,
played: schedule.played || false,
playTimeId: schedule.playTimeId,
// 草稿状态信息
@@ -363,39 +313,27 @@ export default defineEventHandler(async (event) => {
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
}
})()
song: {
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,
musicPlatform: schedule.songMusicPlatform || null,
musicId: schedule.songMusicId || null,
semester: schedule.songSemester || null,
createdAt: schedule.songCreatedAt,
// 重播申请信息
replayRequestCount: isReplaySong ? replayRequestCount : 0,
replayRequesters: isReplaySong ? formattedReplayRequesters : [],
isReplay: isReplaySong // 只有已播放过且有重播申请的才标记为重播
}
}
})
+13 -4
View File
@@ -1,16 +1,16 @@
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { schedules, songs } from '~/drizzle/schema'
import { cacheService } from '~~/server/services/cacheService'
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: '需要歌曲管理员及以上权限'
statusMessage: '需要歌曲管理员及以上权限'
})
}
@@ -47,6 +47,7 @@ export default defineEventHandler(async (event) => {
}
try {
const clientIP = getClientIP(event)
const moveResult = await db.transaction(async (tx) => {
const existingOnToDate = await tx
.select({ id: schedules.id })
@@ -80,7 +81,7 @@ export default defineEventHandler(async (event) => {
}
const scheduleIds = sourceSchedules.map((item) => item.id)
const updateTime = getServerDate()
const updateTime = new Date()
const movedSchedules = await tx
.update(schedules)
.set({
@@ -100,7 +101,7 @@ export default defineEventHandler(async (event) => {
const notificationsToSend = moveResult.movedSongs.map((item) => {
const message = `您投稿的歌曲《${item.songTitle}》原定于 ${fromDate} 播放,已调整至 ${toDate}。`
return createSystemNotification(item.requesterId, '排期调整通知', message)
return createSystemNotification(item.requesterId, '排期调整通知', message, clientIP)
})
if (notificationsToSend.length > 0) {
@@ -109,6 +110,14 @@ export default defineEventHandler(async (event) => {
})
}
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(迁移排期日期)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
fromDate,
+206 -109
View File
@@ -1,10 +1,10 @@
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 { playTimes, schedules, songs, users, votes, songReplayRequests } from '~/drizzle/schema'
import { and, asc, count, eq } from 'drizzle-orm'
import { createSongSelectedNotification } from '~~/server/services/notificationService'
import { cacheService } from '~~/server/services/cacheService'
import { getBeijingTimestamp } from '~/utils/timeUtils'
import { getClientIP } from '~~/server/utils/ip-utils'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
@@ -44,13 +44,11 @@ export default defineEventHandler(async (event) => {
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
requesterId: songs.requesterId
}
})
.from(schedules)
@@ -72,120 +70,219 @@ export default defineEventHandler(async (event) => {
})
}
// 使用事务包装更新排期和重播申请状态操作,保证原子性
const publishResult = await db.transaction(async (tx) => {
// 更新草稿为已发布状态
const publishedAt = new Date(getBeijingTimestamp())
// 更新草稿为已发布状态
const publishedAt = new Date(getBeijingTimestamp())
const publishResult = await tx
.update(schedules)
.set({
isDraft: false,
publishedAt: publishedAt,
updatedAt: publishedAt
const publishResult = await db
.update(schedules)
.set({
isDraft: false,
publishedAt: publishedAt,
updatedAt: publishedAt
})
.where(eq(schedules.id, body.scheduleId))
.returning()
const publishedSchedule = publishResult[0]
if (!publishedSchedule) {
throw createError({
statusCode: 500,
message: '发布排期失败'
})
}
// 获取客户端IP地址
const clientIP = getClientIP(event)
// 发布后发送通知(这是与草稿保存的主要区别)
await createSongSelectedNotification(
draft.song.requesterId,
draft.song.id,
{
title: draft.song.title,
artist: draft.song.artist,
playDate: draft.playDate
},
clientIP
)
// 将该歌曲的所有待处理重播申请标记为已完成
const updatedRequests = await db
.update(songReplayRequests)
.set({
status: 'FULFILLED',
updatedAt: new Date()
})
.where(
and(eq(songReplayRequests.songId, draft.song.id), eq(songReplayRequests.status, 'PENDING'))
)
.returning()
if (updatedRequests.length > 0) {
console.log(`发布排期:将 ${updatedRequests.length} 个重播申请标记为 FULFILLED`)
}
// 清除相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(发布排期)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
// 重新缓存完整的排期列表
try {
// 获取所有已发布的排期数据
const schedulesData = await db
.select({
id: schedules.id,
playDate: schedules.playDate,
sequence: schedules.sequence,
played: schedules.played,
playTimeId: schedules.playTimeId,
isDraft: schedules.isDraft,
publishedAt: schedules.publishedAt,
songId: schedules.songId,
songTitle: songs.title,
songArtist: songs.artist,
songRequesterId: songs.requesterId,
songPlayed: songs.played,
songCover: songs.cover,
songMusicPlatform: songs.musicPlatform,
songMusicId: songs.musicId,
songSemester: songs.semester,
requesterName: users.name,
requesterGrade: users.grade,
requesterClass: users.class,
playTimeName: playTimes.name,
playTimeStartTime: playTimes.startTime,
playTimeEndTime: playTimes.endTime,
playTimeEnabled: playTimes.enabled
})
.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)
.innerJoin(songs, eq(schedules.songId, songs.id))
.innerJoin(users, eq(songs.requesterId, users.id))
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(eq(schedules.isDraft, false)) // 只缓存已发布的排期
.orderBy(asc(schedules.playDate))
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
// 获取所有用户的姓名列表,用于检测同名用户
const allUsers = await db
.select({
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
})
} catch (notificationError) {
notificationSent = false
console.error('排期已发布,但发送歌曲入选通知失败:', notificationError)
}
}
.from(users)
const publishedSchedule = publishResult.schedule
// 创建姓名到用户数组的映射
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 = 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 formattedSchedules = schedulesData.map((schedule) => {
const dateOnly = schedule.playDate
// 处理投稿人姓名,如果是同名用户则添加后缀
let requesterName = schedule.requesterName || '未知用户'
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})`
}
}
}
return {
id: schedule.id,
playDate: dateOnly.toISOString().split('T')[0],
sequence: schedule.sequence || 1,
played: schedule.played || false,
playTimeId: schedule.playTimeId,
isDraft: schedule.isDraft,
publishedAt: schedule.publishedAt,
playTime: schedule.playTimeName
? {
id: schedule.playTimeId,
name: schedule.playTimeName,
startTime: schedule.playTimeStartTime,
endTime: schedule.playTimeEndTime,
enabled: schedule.playTimeEnabled
}
: null,
song: {
id: schedule.songId,
title: schedule.songTitle,
artist: schedule.songArtist,
requester: requesterName,
voteCount: voteCountMap.get(schedule.songId) || 0,
played: schedule.songPlayed || false,
cover: schedule.songCover || null,
musicPlatform: schedule.songMusicPlatform || null,
musicId: schedule.songMusicId || null,
semester: schedule.songSemester || null
}
}
})
const completeSchedules = formattedSchedules
// 重新缓存完整的排期列表
if (completeSchedules && completeSchedules.length > 0) {
await cacheService.setSchedulesList(completeSchedules)
console.log(`[Cache] 已发布排期列表已重新缓存,数量: ${completeSchedules.length}`)
} else {
console.log('[Cache] 没有已发布排期数据需要缓存')
}
} catch (cacheError) {
console.warn('[Cache] 重新缓存完整排期列表失败,但不影响排期发布:', cacheError)
}
return {
...draft,
...publishedSchedule,
song: draft.song,
isDraft: false,
publishedAt: publishedSchedule.publishedAt,
// 绑定在 fulfill 中回写,.returning() 快照早于绑定,需显式覆盖
replayRequestId: publishResult.replayRequestId,
message: publishResult.shouldNotify
? notificationSent
? '排期发布成功,通知已发送'
: '排期发布成功,但通知发送失败'
: '排期发布成功'
publishedAt: publishedAt,
message: '排期发布成功,通知已发送'
}
} catch (error: any) {
console.error('发布排期失败:', error)
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: error.message || '发布排期失败'
+40 -64
View File
@@ -1,7 +1,5 @@
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'
import { db, eq, schedules, songs, songReplayRequests, and } from '~/drizzle/db'
import { cacheService } from '~~/server/services/cacheService'
export default defineEventHandler(async (event) => {
// 验证管理员权限
@@ -9,7 +7,7 @@ export default defineEventHandler(async (event) => {
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要歌曲管理员及以上权限'
statusMessage: '需要歌曲管理员及以上权限'
})
}
@@ -20,7 +18,7 @@ export default defineEventHandler(async (event) => {
if (!scheduleId) {
throw createError({
statusCode: 400,
message: '缺少排期 ID'
statusMessage: '缺少排期ID'
})
}
@@ -33,8 +31,6 @@ export default defineEventHandler(async (event) => {
.select({
id: schedules.id,
songId: schedules.songId,
isDraft: schedules.isDraft,
cardCodeId: songs.cardCodeId,
songTitle: songs.title,
songArtist: songs.artist
})
@@ -54,69 +50,49 @@ export default defineEventHandler(async (event) => {
console.log(`找到排期 ID=${scheduleIdNumber}, 歌曲=${existingSchedule.songTitle || '未知歌曲'}`)
// 使用事务包装删除和重播状态更新操作
const result = await db.transaction(async (tx) => {
// 删除排期
const deletedSchedule = await tx
.delete(schedules)
.where(eq(schedules.id, scheduleIdNumber))
// 删除排期
const deletedSchedule = await db
.delete(schedules)
.where(eq(schedules.id, scheduleIdNumber))
.returning()
console.log(`成功删除排期 ID=${scheduleIdNumber}`)
// 恢复该歌曲的重播申请状态为 PENDING
// 只恢复状态不是 PENDING 的申请(可能是 FULFILLED 或其他状态)
if (existingSchedule.songId) {
const updatedRequests = await db
.update(songReplayRequests)
.set({
status: 'PENDING',
updatedAt: new Date()
})
.where(
and(
eq(songReplayRequests.songId, existingSchedule.songId),
// 不恢复已拒绝的申请
eq(songReplayRequests.status, 'FULFILLED')
)
)
.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(`该歌曲仍有其他正式排期,不恢复重播申请状态`)
}
if (updatedRequests.length > 0) {
console.log(`恢复了 ${updatedRequests.length} 个重播申请状态为 PENDING`)
}
}
return deletedSchedule
})
// 清除相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache() // 清除歌曲列表缓存,确保scheduled状态更新
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(移除排期)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
schedule: result
schedule: deletedSchedule
}
} catch (error: any) {
console.error('移除排期失败:', error)
+22 -32
View File
@@ -1,5 +1,6 @@
import { db, eq } from '~/drizzle/db'
import { schedules } from '~/drizzle/schema'
import { cacheService } from '~~/server/services/cacheService'
export default defineEventHandler(async (event) => {
// 验证用户认证和权限
@@ -7,48 +8,39 @@ export default defineEventHandler(async (event) => {
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '需要歌曲管理员及以上权限'
statusMessage: '需要歌曲管理员及以上权限'
})
}
try {
const body = await readBody(event)
const scheduleUpdates = body.schedules
const { schedules } = body
if (!Array.isArray(scheduleUpdates) || scheduleUpdates.length === 0) {
if (!schedules || !Array.isArray(schedules) || schedules.length === 0) {
throw createError({
statusCode: 400,
message: '缺少排期数据'
statusMessage: '缺少排期数据'
})
}
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 })
)
)
const results = await Promise.all(
schedules.map(async (item) => {
return db
.update(schedules)
.set({ sequence: item.sequence })
.where(eq(schedules.id, Number(item.id)))
})
)
if (updatedRows.some((rows) => rows.length === 0)) {
throw createError({ statusCode: 404, message: '部分排期不存在,请刷新后重试' })
}
return updatedRows
})
// 清除排期相关缓存
try {
await cacheService.clearSchedulesCache()
await cacheService.clearSongsCache()
console.log('[Cache] 排期缓存和歌曲列表缓存已清除(更新排期顺序)')
} catch (cacheError) {
console.error('[Cache] 清除缓存失败:', cacheError)
}
return {
success: true,
@@ -57,11 +49,9 @@ export default defineEventHandler(async (event) => {
} catch (error: any) {
console.error('更新排期顺序失败:', error)
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: error.message || '更新排期顺序失败'
statusMessage: error.message || '更新排期顺序失败'
})
}
})