feat: 迁移自定义功能至 1.5.9.9 版本
包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, requestTimes } from '~/drizzle/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { cacheService } from '../../../services/cacheService'
|
||||
import { createSongRejectedNotification } from '../../../services/notificationService'
|
||||
|
||||
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)
|
||||
|
||||
if (!body.songId || !['approved', 'rejected'].includes(body.status)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '参数错误'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
let songForNotification = null
|
||||
|
||||
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: '歌曲不存在'
|
||||
})
|
||||
}
|
||||
|
||||
songForNotification = song
|
||||
|
||||
if (body.status === 'rejected') {
|
||||
// 如果有 hitRequestId,减少对应时段的已接纳数量
|
||||
if (song.hitRequestId) {
|
||||
try {
|
||||
await tx
|
||||
.update(requestTimes)
|
||||
.set({
|
||||
accepted: sql`GREATEST(0, accepted - 1)`
|
||||
})
|
||||
.where(eq(requestTimes.id, song.hitRequestId))
|
||||
} catch (error) {
|
||||
console.error(`减少投稿时段接纳数量失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const note = song.submissionNote ? `[审核未通过] ${body.reason || ''}\n${song.submissionNote}` : `[审核未通过] ${body.reason || ''}`
|
||||
|
||||
// 释放排期序号并更新备注
|
||||
const updatedSong = await tx.update(songs).set({
|
||||
expectedPlayDate: null,
|
||||
expectedPlaySequence: null,
|
||||
hitRequestId: null,
|
||||
submissionNote: note
|
||||
}).where(eq(songs.id, body.songId)).returning()
|
||||
|
||||
return { success: true, song: updatedSong[0] }
|
||||
} else {
|
||||
const note = song.submissionNote ? `[审核已通过]\n${song.submissionNote}` : `[审核已通过]`
|
||||
const updatedSong = await tx.update(songs).set({
|
||||
submissionNote: note
|
||||
}).where(eq(songs.id, body.songId)).returning()
|
||||
|
||||
return { success: true, song: updatedSong[0] }
|
||||
}
|
||||
})
|
||||
|
||||
if (body.status === 'rejected' && songForNotification && songForNotification.requesterId) {
|
||||
createSongRejectedNotification(
|
||||
songForNotification.requesterId,
|
||||
{ title: songForNotification.title, artist: songForNotification.artist },
|
||||
body.reason?.trim() || '未过审释放排期',
|
||||
getClientIP(event)
|
||||
).catch(e => console.error(e))
|
||||
}
|
||||
|
||||
await cacheService.clearSongsCache()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: body.status === 'rejected' ? '已驳回并释放排期' : '审核已通过',
|
||||
data: result.song
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('审核歌曲失败:', error)
|
||||
if (error.statusCode) throw error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '审核失败: ' + error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songBlacklists, songs, votes, requestTimes } from '~/drizzle/schema'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { cacheService } from '../../../services/cacheService'
|
||||
import { createSongRejectedNotification } from '../../../services/notificationService'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
|
||||
const MAX_BATCH_REJECT_COUNT = 1000
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireSongAdmin(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
const songIds = Array.isArray(body?.songIds)
|
||||
? [...new Set(body.songIds.filter((id: any) => Number.isInteger(id) && id > 0))]
|
||||
: []
|
||||
const reason = typeof body?.reason === 'string' ? body.reason.trim() : ''
|
||||
const addToBlacklist = body?.addToBlacklist === true
|
||||
|
||||
if (songIds.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.SONG_BATCH_REJECT_IDS_REQUIRED, '歌曲ID列表不能为空')
|
||||
}
|
||||
|
||||
if (songIds.length > MAX_BATCH_REJECT_COUNT) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.SONG_BATCH_REJECT_LIMIT_EXCEEDED,
|
||||
`单次最多驳回 ${MAX_BATCH_REJECT_COUNT} 首歌曲`,
|
||||
{ params: [MAX_BATCH_REJECT_COUNT] }
|
||||
)
|
||||
}
|
||||
|
||||
if (!reason) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.SONG_BATCH_REJECT_REASON_REQUIRED, '驳回原因不能为空')
|
||||
}
|
||||
|
||||
try {
|
||||
let rejectedSongs: any[] = []
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const existingSongs = await tx.select().from(songs).where(inArray(songs.id, songIds))
|
||||
|
||||
if (existingSongs.length === 0) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.SONG_NOT_FOUND, '歌曲不存在或已被删除')
|
||||
}
|
||||
|
||||
const existingIds = existingSongs.map((song) => song.id)
|
||||
console.log(`开始批量驳回歌曲,共 ${existingIds.length} 首`)
|
||||
|
||||
if (addToBlacklist) {
|
||||
const blacklistValues = [...new Set(existingSongs.map((song: any) => `${song.title} - ${song.artist}`))]
|
||||
const existingBlacklist = await tx
|
||||
.select({ value: songBlacklists.value })
|
||||
.from(songBlacklists)
|
||||
.where(and(eq(songBlacklists.type, 'SONG'), inArray(songBlacklists.value, blacklistValues)))
|
||||
const existingSet = new Set(existingBlacklist.map((item) => item.value))
|
||||
const valuesToInsert = blacklistValues.filter((value) => !existingSet.has(value))
|
||||
for (const value of valuesToInsert) {
|
||||
try {
|
||||
await tx.insert(songBlacklists).values({
|
||||
type: 'SONG',
|
||||
value,
|
||||
reason: `歌曲驳回: ${reason}`,
|
||||
createdBy: user.id
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.log(`黑名单添加失败,可能已存在: ${error.message}`)
|
||||
}
|
||||
}
|
||||
console.log(`黑名单添加处理完成`)
|
||||
}
|
||||
|
||||
await tx.delete(votes).where(inArray(votes.songId, existingIds))
|
||||
console.log(`删除了投票记录`)
|
||||
|
||||
await tx.delete(schedules).where(inArray(schedules.songId, existingIds))
|
||||
console.log(`删除了排期记录`)
|
||||
|
||||
const hitRequestIds = [...new Set(existingSongs.map((song: any) => song.hitRequestId).filter(Boolean))]
|
||||
for (const hitRequestId of hitRequestIds) {
|
||||
const count = existingSongs.filter((song: any) => song.hitRequestId === hitRequestId).length
|
||||
try {
|
||||
await tx
|
||||
.update(requestTimes)
|
||||
.set({
|
||||
accepted: sql`GREATEST(0, accepted - ${count})`
|
||||
})
|
||||
.where(eq(requestTimes.id, hitRequestId as any))
|
||||
} catch (error: any) {
|
||||
console.error(`减少投稿时段 ${hitRequestId} 接纳数量失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
rejectedSongs = await tx.delete(songs).where(inArray(songs.id, existingIds)).returning()
|
||||
console.log(`歌曲批量删除完成`)
|
||||
})
|
||||
|
||||
const songsToNotify = rejectedSongs.filter((song) => song.requesterId)
|
||||
for (const song of songsToNotify) {
|
||||
createSongRejectedNotification(
|
||||
song.requesterId,
|
||||
{ title: song.title, artist: song.artist },
|
||||
reason
|
||||
).catch((error: any) => {
|
||||
console.error(`发送驳回通知失败: ${error.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
await cacheService.clearSongsCache()
|
||||
await cacheService.clearSchedulesCache()
|
||||
|
||||
console.log(`批量驳回操作完成,共 ${rejectedSongs.length} 首`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '歌曲批量驳回成功',
|
||||
data: {
|
||||
rejected: rejectedSongs.length,
|
||||
missing: songIds.length - rejectedSongs.length,
|
||||
notificationSent: songsToNotify.length
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('批量驳回歌曲失败:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createApiError(500, SERVER_ERROR_CODES.SONG_OPERATION_FAILED, '批量驳回失败: ' + error.message)
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songs, votes, requestTimes } from '~/drizzle/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { cacheService } from '../../../services/cacheService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户认证和权限
|
||||
@@ -74,6 +75,17 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 清除相关缓存
|
||||
try {
|
||||
await cacheService.clearSongsCache()
|
||||
if (result.deletedSchedules) {
|
||||
await cacheService.clearSchedulesCache()
|
||||
}
|
||||
console.log('[Cache] 歌曲和排期缓存已清除(删除歌曲)')
|
||||
} catch (cacheError) {
|
||||
console.warn('[Cache] 清除缓存失败:', cacheError)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error: any) {
|
||||
console.error('删除歌曲时发生错误:', error)
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { createSongPlayedNotification } from '~~/server/services/notificationService'
|
||||
import { CacheService } from '~~/server/services/cacheService'
|
||||
import { songs, songReplayRequests } from '~/drizzle/schema'
|
||||
import { eq, and, inArray } from 'drizzle-orm'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { restoreReplayRequestsToPending } from '~~/server/utils/scheduleReplayBinding'
|
||||
import { z } from 'zod'
|
||||
|
||||
const markPlayedSchema = z.object({
|
||||
songId: z.number().optional(),
|
||||
songIds: z.array(z.number()).max(100, '批量操作单次最多支持 100 首歌曲').optional(),
|
||||
unmark: z.boolean().optional()
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户认证
|
||||
@@ -25,7 +18,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// 检查是否是管理员
|
||||
if (!['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)) {
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有管理员可以标记歌曲为已播放'
|
||||
@@ -33,24 +26,15 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const validatedDataResult = markPlayedSchema.safeParse(body)
|
||||
|
||||
if (!validatedDataResult.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: validatedDataResult.error.issues[0]?.message || '请求参数无效'
|
||||
})
|
||||
let songIds: number[] = []
|
||||
|
||||
if (body.songId) {
|
||||
songIds = [body.songId]
|
||||
} else if (body.songIds && Array.isArray(body.songIds)) {
|
||||
songIds = body.songIds
|
||||
}
|
||||
|
||||
const validatedData = validatedDataResult.data
|
||||
|
||||
const songIds = Array.from(
|
||||
new Set([
|
||||
...(validatedData.songId !== undefined ? [validatedData.songId] : []),
|
||||
...(validatedData.songIds || [])
|
||||
])
|
||||
)
|
||||
|
||||
if (songIds.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
@@ -58,15 +42,19 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 撤回已播放
|
||||
const isUnmark = validatedData.unmark === true
|
||||
// 检查是否是撤回操作
|
||||
const isUnmark = body.unmark === true
|
||||
|
||||
// 过滤需要更新的歌曲
|
||||
// 构建更新条件:只更新状态不符合预期的歌曲
|
||||
// 如果是标记为已播放(!isUnmark),则只更新 played = false 的歌曲
|
||||
// 如果是撤回(isUnmark),则只更新 played = true 的歌曲
|
||||
const condition = !isUnmark
|
||||
? and(inArray(songs.id, songIds), eq(songs.played, false))
|
||||
: and(inArray(songs.id, songIds), eq(songs.played, true))
|
||||
|
||||
// 使用事务确保数据一致性
|
||||
const { updatedSongsResult, updatedSongIds } = await db.transaction(async (tx) => {
|
||||
// 更新歌曲状态
|
||||
const updatedSongsResult = await tx
|
||||
.update(songs)
|
||||
.set({
|
||||
@@ -74,58 +62,60 @@ export default defineEventHandler(async (event) => {
|
||||
playedAt: isUnmark ? null : getBeijingTime()
|
||||
})
|
||||
.where(condition)
|
||||
.returning({ id: songs.id })
|
||||
.returning()
|
||||
|
||||
const updatedSongIds = updatedSongsResult.map((s) => s.id)
|
||||
// 获取实际更新的歌曲ID列表
|
||||
const updatedSongIds = updatedSongsResult.map(s => s.id)
|
||||
|
||||
if (updatedSongIds.length > 0) {
|
||||
if (!isUnmark) {
|
||||
// 标记已播放:履行全部待处理的重播申请
|
||||
const updatedRequests = await tx
|
||||
.update(songReplayRequests)
|
||||
.set({
|
||||
status: 'FULFILLED',
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(songReplayRequests.songId, updatedSongIds),
|
||||
eq(songReplayRequests.status, 'PENDING')
|
||||
)
|
||||
)
|
||||
.returning({ id: songReplayRequests.id })
|
||||
// 根据操作类型更新重播申请状态
|
||||
const targetStatus = !isUnmark ? 'PENDING' : 'FULFILLED'
|
||||
const newStatus = !isUnmark ? 'FULFILLED' : 'PENDING'
|
||||
|
||||
if (updatedRequests.length > 0) {
|
||||
console.log(`将 ${updatedRequests.length} 个重播申请状态更新为 FULFILLED(歌曲已播放)`)
|
||||
}
|
||||
} else {
|
||||
// 撤回已播放:每人仅恢复最新一条,避免违反部分唯一索引
|
||||
const restoredCount = await restoreReplayRequestsToPending({
|
||||
tx,
|
||||
songIds: updatedSongIds,
|
||||
at: new Date()
|
||||
const updatedRequests = await tx
|
||||
.update(songReplayRequests)
|
||||
.set({
|
||||
status: newStatus,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(inArray(songReplayRequests.songId, updatedSongIds), eq(songReplayRequests.status, targetStatus))
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (restoredCount > 0) {
|
||||
console.log(`将 ${restoredCount} 个重播申请状态恢复为 PENDING(撤回已播放)`)
|
||||
}
|
||||
if (updatedRequests.length > 0) {
|
||||
const logMessage = !isUnmark
|
||||
? `将 ${updatedRequests.length} 个重播申请状态更新为 FULFILLED(歌曲已播放)`
|
||||
: `将 ${updatedRequests.length} 个重播申请状态恢复为 PENDING(撤回已播放)`
|
||||
console.log(logMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return { updatedSongsResult, updatedSongIds }
|
||||
})
|
||||
|
||||
// 异步发送通知
|
||||
// 清除歌曲和排期相关缓存
|
||||
try {
|
||||
const cacheService = CacheService.getInstance()
|
||||
await cacheService.clearSongsCache()
|
||||
await cacheService.clearSchedulesCache()
|
||||
} catch (error) {
|
||||
console.error('清除缓存失败:', error)
|
||||
}
|
||||
|
||||
// 如果是标记为已播放,则异步发送通知(不阻塞响应)
|
||||
if (!isUnmark && updatedSongIds.length > 0) {
|
||||
event.waitUntil(
|
||||
Promise.allSettled(
|
||||
updatedSongIds.map((songId) =>
|
||||
createSongPlayedNotification(songId).catch((err) => {
|
||||
console.error(`发送歌曲(${songId})已播放通知失败:`, err)
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
// 获取客户端IP地址
|
||||
const clientIP = getClientIP(event)
|
||||
|
||||
// 异步发送通知,不等待完成
|
||||
setImmediate(() => {
|
||||
Promise.allSettled(updatedSongIds.map(songId =>
|
||||
createSongPlayedNotification(songId, clientIP).catch((err) => {
|
||||
console.error(`发送歌曲(${songId})已播放通知失败:`, err)
|
||||
})
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songBlacklists, songs, votes, requestTimes } from '~/drizzle/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { cacheService } from '../../../services/cacheService'
|
||||
import { createSongRejectedNotification } from '../../../services/notificationService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -108,7 +109,8 @@ export default defineEventHandler(async (event) => {
|
||||
createSongRejectedNotification(
|
||||
songForNotification.requesterId,
|
||||
{ title: songForNotification.title, artist: songForNotification.artist },
|
||||
body.reason.trim()
|
||||
body.reason.trim(),
|
||||
getClientIP(event)
|
||||
)
|
||||
.then(() => {
|
||||
console.log(`已发送驳回通知给用户: ${songForNotification.requesterId}`)
|
||||
@@ -120,6 +122,10 @@ export default defineEventHandler(async (event) => {
|
||||
notificationSent = true
|
||||
}
|
||||
|
||||
// 清除相关缓存
|
||||
await cacheService.clearSongsCache()
|
||||
await cacheService.clearSchedulesCache()
|
||||
|
||||
console.log(`歌曲驳回操作完成: ${body.songId}`)
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user