初次提交
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, users, songCollaborators, collaborationLogs } from '~/drizzle/schema'
|
||||
import { eq, or } from 'drizzle-orm'
|
||||
import { cacheService } from '~~/server/services/cacheService'
|
||||
import { createSubmissionNoteClearedNotification } from '~~/server/services/notificationService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证请求方法
|
||||
if (event.node.req.method !== 'PUT') {
|
||||
throw createError({
|
||||
statusCode: 405,
|
||||
statusMessage: 'Method Not Allowed'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取已验证的用户信息(由中间件提供)
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: '未授权访问'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (!['ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取歌曲ID
|
||||
const songId = parseInt(getRouterParam(event, 'id'))
|
||||
if (!songId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid song ID'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取请求体
|
||||
const body = await readBody(event)
|
||||
const { title, artist, requester, semester, musicPlatform, musicId, cover, playUrl } = body
|
||||
const ipAddress =
|
||||
(event.node.req.headers['x-forwarded-for'] as string) || event.node.req.socket.remoteAddress
|
||||
|
||||
// 验证必填字段
|
||||
if (!title || !artist) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Title and artist are required'
|
||||
})
|
||||
}
|
||||
|
||||
const existingSongResult = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
|
||||
const existingSong = existingSongResult[0]
|
||||
|
||||
if (!existingSong) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '歌曲不存在'
|
||||
})
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
title: title.trim(),
|
||||
artist: artist.trim(),
|
||||
semester: semester || null,
|
||||
musicPlatform: musicPlatform || null,
|
||||
musicId: musicId || null,
|
||||
cover: cover || null,
|
||||
playUrl: playUrl || null
|
||||
}
|
||||
|
||||
// 处理投稿人
|
||||
if ('requester' in body) {
|
||||
const requester = body.requester
|
||||
|
||||
// 只有在传递有效投稿人信息时才更新
|
||||
if (requester && requester !== '' && requester !== 0) {
|
||||
// 查找投稿人用户
|
||||
const conditions = []
|
||||
if (typeof requester === 'number') {
|
||||
conditions.push(eq(users.id, requester))
|
||||
}
|
||||
if (typeof requester === 'string') {
|
||||
conditions.push(eq(users.username, requester))
|
||||
conditions.push(eq(users.name, requester))
|
||||
}
|
||||
|
||||
const requesterUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(or(...conditions))
|
||||
.limit(1)
|
||||
|
||||
if (requesterUser.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '投稿人用户不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 设置投稿人
|
||||
updateData.requesterId = requesterUser[0].id
|
||||
}
|
||||
// 如果投稿人为空值,则跳过处理,保持原有投稿人不变
|
||||
}
|
||||
|
||||
const shouldClearSubmissionNote = body.clearSubmissionNote === true
|
||||
const submissionNoteClearReason =
|
||||
typeof body.submissionNoteClearReason === 'string' ? body.submissionNoteClearReason.trim() : ''
|
||||
const shouldNotifySubmissionNoteClear = body.notifyOnSubmissionNoteClear === true
|
||||
|
||||
if (shouldClearSubmissionNote) {
|
||||
updateData.submissionNote = null
|
||||
} else if ('submissionNote' in body) {
|
||||
updateData.submissionNote =
|
||||
typeof body.submissionNote === 'string' && body.submissionNote.trim()
|
||||
? body.submissionNote.trim()
|
||||
: null
|
||||
}
|
||||
|
||||
const currentRequesterId = updateData.requesterId || existingSong.requesterId
|
||||
|
||||
// 更新歌曲
|
||||
const updatedSongResult = await db
|
||||
.update(songs)
|
||||
.set(updateData)
|
||||
.where(eq(songs.id, songId))
|
||||
.returning()
|
||||
|
||||
// 处理联合投稿人
|
||||
if ('collaborators' in body && Array.isArray(body.collaborators)) {
|
||||
// 获取现有联合投稿人
|
||||
const existingCollaborators = await db
|
||||
.select()
|
||||
.from(songCollaborators)
|
||||
.where(eq(songCollaborators.songId, songId))
|
||||
|
||||
const existingCollaboratorUserIds = existingCollaborators.map((c) => c.userId)
|
||||
const newCollaboratorIds = [
|
||||
...new Set(
|
||||
body.collaborators
|
||||
.map((id: number | string) => Number(id))
|
||||
.filter((id: number) => Number.isInteger(id) && id > 0 && id !== currentRequesterId)
|
||||
)
|
||||
]
|
||||
|
||||
// 需要添加的
|
||||
const toAdd = newCollaboratorIds.filter(
|
||||
(id: number) => !existingCollaboratorUserIds.includes(id)
|
||||
)
|
||||
// 需要删除的
|
||||
const toDelete = existingCollaborators.filter((c) => !newCollaboratorIds.includes(c.userId))
|
||||
|
||||
// 执行添加
|
||||
for (const userId of toAdd) {
|
||||
// 检查用户是否存在
|
||||
const userExists = await db.select().from(users).where(eq(users.id, userId)).limit(1)
|
||||
if (userExists.length > 0) {
|
||||
const [newCollab] = await db
|
||||
.insert(songCollaborators)
|
||||
.values({
|
||||
songId: songId,
|
||||
userId: userId,
|
||||
status: 'ACCEPTED'
|
||||
})
|
||||
.returning()
|
||||
|
||||
// 记录日志
|
||||
await db.insert(collaborationLogs).values({
|
||||
collaboratorId: newCollab.id,
|
||||
action: 'ADMIN_ADD',
|
||||
operatorId: user.id,
|
||||
ipAddress
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
for (const collab of toDelete) {
|
||||
await db.delete(songCollaborators).where(eq(songCollaborators.id, collab.id))
|
||||
|
||||
// 记录日志
|
||||
await db.insert(collaborationLogs).values({
|
||||
collaboratorId: collab.id,
|
||||
action: 'ADMIN_REMOVE',
|
||||
operatorId: user.id,
|
||||
ipAddress
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldClearSubmissionNote && existingSong.submissionNote && shouldNotifySubmissionNoteClear) {
|
||||
const latestCollaborators = await db
|
||||
.select({
|
||||
userId: songCollaborators.userId
|
||||
})
|
||||
.from(songCollaborators)
|
||||
.where(eq(songCollaborators.songId, songId))
|
||||
|
||||
const notifyUserIds = [
|
||||
currentRequesterId,
|
||||
...latestCollaborators.map((collaborator) => collaborator.userId)
|
||||
].filter((userId): userId is number => Number.isInteger(userId) && userId > 0)
|
||||
|
||||
createSubmissionNoteClearedNotification(
|
||||
notifyUserIds,
|
||||
{ title: updatedSongResult[0].title, artist: updatedSongResult[0].artist },
|
||||
submissionNoteClearReason,
|
||||
ipAddress
|
||||
).catch((error) => {
|
||||
console.error('发送歌曲备注清空通知失败:', error)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取完整的歌曲信息(包含投稿人)
|
||||
const updatedSong = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist,
|
||||
semester: songs.semester,
|
||||
musicPlatform: songs.musicPlatform,
|
||||
musicId: songs.musicId,
|
||||
cover: songs.cover,
|
||||
playUrl: songs.playUrl,
|
||||
submissionNote: songs.submissionNote,
|
||||
submissionNotePublic: songs.submissionNotePublic,
|
||||
requesterId: songs.requesterId,
|
||||
createdAt: songs.createdAt,
|
||||
updatedAt: songs.updatedAt,
|
||||
requester: {
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
}
|
||||
})
|
||||
.from(songs)
|
||||
.leftJoin(users, eq(songs.requesterId, users.id))
|
||||
.where(eq(songs.id, songId))
|
||||
.limit(1)
|
||||
|
||||
// 清除歌曲相关缓存
|
||||
try {
|
||||
await cacheService.clearSongsCache()
|
||||
console.log('[Cache] 歌曲缓存已清除(更新歌曲)')
|
||||
} catch (cacheError) {
|
||||
console.error('[Cache] 清除歌曲缓存失败:', cacheError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
song: updatedSong[0]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update song error:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createError, defineEventHandler, getRouterParam } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, users, votes } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 检查用户认证
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '需要登录才能查看投票人员'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查管理员权限
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有管理员才能查看投票人员列表'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取歌曲ID
|
||||
const songId = parseInt(getRouterParam(event, 'id') || '0')
|
||||
|
||||
if (!songId || isNaN(songId)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的歌曲ID'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查歌曲是否存在
|
||||
const songResult = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
})
|
||||
.from(songs)
|
||||
.where(eq(songs.id, songId))
|
||||
.limit(1)
|
||||
|
||||
const song = songResult[0]
|
||||
|
||||
if (!song) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '歌曲不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取投票人员列表
|
||||
const votesResult = await db
|
||||
.select({
|
||||
userId: votes.userId,
|
||||
createdAt: votes.createdAt,
|
||||
userName: users.name,
|
||||
username: users.username,
|
||||
grade: users.grade,
|
||||
class: users.class
|
||||
})
|
||||
.from(votes)
|
||||
.innerJoin(users, eq(votes.userId, users.id))
|
||||
.where(eq(votes.songId, songId))
|
||||
.orderBy(votes.createdAt)
|
||||
|
||||
// 处理用户名显示逻辑,总是显示年级和班级信息
|
||||
const votersWithDisplayName = votesResult.map((vote) => {
|
||||
let displayName = vote.userName || vote.username
|
||||
|
||||
// 如果有年级信息,添加年级和班级后缀
|
||||
if (vote.grade) {
|
||||
if (vote.class) {
|
||||
displayName = `${displayName}(${vote.grade} ${vote.class})`
|
||||
} else {
|
||||
displayName = `${displayName}(${vote.grade})`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: vote.userId,
|
||||
name: displayName,
|
||||
votedAt: vote.createdAt
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
song: {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist
|
||||
},
|
||||
voters: votersWithDisplayName,
|
||||
totalVotes: votersWithDisplayName.length
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取投票人员列表失败:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '获取投票人员列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, users } from '~/drizzle/schema'
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { cacheService } from '~~/server/services/cacheService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证请求方法
|
||||
if (event.node.req.method !== 'POST') {
|
||||
throw createError({
|
||||
statusCode: 405,
|
||||
statusMessage: 'Method Not Allowed'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取已验证的用户信息(由中间件提供)
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: '未授权访问'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (!['ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取请求体
|
||||
const body = await readBody(event)
|
||||
const { title, artist, requester, semester, musicPlatform, musicId, cover, playUrl } = body
|
||||
|
||||
// 验证必填字段
|
||||
if (!title || !artist) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Title and artist are required'
|
||||
})
|
||||
}
|
||||
|
||||
// 查找投稿人用户
|
||||
let requesterId = null
|
||||
if (requester) {
|
||||
// 检查requester是否为数字ID
|
||||
const parsedId = parseInt(requester)
|
||||
let requesterUser = null
|
||||
|
||||
if (!isNaN(parsedId) && parsedId > 0) {
|
||||
// 如果是有效的数字ID,直接按ID查找
|
||||
const userResult = await db.select().from(users).where(eq(users.id, parsedId)).limit(1)
|
||||
requesterUser = userResult[0]
|
||||
} else {
|
||||
// 如果不是数字ID,按用户名或姓名查找
|
||||
const userResult = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(or(eq(users.username, String(requester)), eq(users.name, String(requester))))
|
||||
.limit(1)
|
||||
requesterUser = userResult[0]
|
||||
}
|
||||
|
||||
requesterId = requesterUser?.id || null
|
||||
}
|
||||
|
||||
// 检查歌曲是否已存在
|
||||
const existingSongResult = await db
|
||||
.select()
|
||||
.from(songs)
|
||||
.where(
|
||||
and(
|
||||
eq(songs.title, title.trim()),
|
||||
eq(songs.artist, artist.trim()),
|
||||
requesterId ? eq(songs.requesterId, requesterId) : eq(songs.requesterId, null)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
const existingSong = existingSongResult[0]
|
||||
|
||||
if (existingSong) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'Song already exists'
|
||||
})
|
||||
}
|
||||
|
||||
// 创建歌曲
|
||||
const newSongResult = await db
|
||||
.insert(songs)
|
||||
.values({
|
||||
title: title.trim(),
|
||||
artist: artist.trim(),
|
||||
requesterId,
|
||||
semester: semester || null,
|
||||
musicPlatform: musicPlatform || null,
|
||||
musicId: musicId || null,
|
||||
cover: cover || null,
|
||||
playUrl: playUrl || null
|
||||
})
|
||||
.returning()
|
||||
|
||||
const newSong = newSongResult[0]
|
||||
|
||||
// 获取投稿人信息
|
||||
let requesterInfo = null
|
||||
if (newSong.requesterId) {
|
||||
const requesterResult = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, newSong.requesterId))
|
||||
.limit(1)
|
||||
requesterInfo = requesterResult[0]
|
||||
}
|
||||
|
||||
// 组合返回数据
|
||||
const songWithRequester = {
|
||||
...newSong,
|
||||
requester: requesterInfo
|
||||
}
|
||||
|
||||
// 清除歌曲相关缓存
|
||||
try {
|
||||
await cacheService.clearSongsCache()
|
||||
console.log('[Cache] 歌曲缓存已清除(添加歌曲)')
|
||||
} catch (error) {
|
||||
console.error('清除歌曲缓存失败:', error)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
song: songWithRequester
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Add song error:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Internal server error'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createError, defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songCollaborators, collaborationLogs, songs } from '~/drizzle/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { createCollaborationResponseNotification } from '~~/server/services/notificationService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: '未登录'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { songId, accept } = body
|
||||
|
||||
if (!songId || typeof accept !== 'boolean') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: '参数错误'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 查找联合投稿记录
|
||||
const collabRecord = await db
|
||||
.select()
|
||||
.from(songCollaborators)
|
||||
.where(
|
||||
and(
|
||||
eq(songCollaborators.songId, songId),
|
||||
eq(songCollaborators.userId, user.id),
|
||||
eq(songCollaborators.status, 'PENDING')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then((res) => res[0])
|
||||
|
||||
if (!collabRecord) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '未找到待处理的邀请'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
const status = accept ? 'ACCEPTED' : 'REJECTED'
|
||||
await db
|
||||
.update(songCollaborators)
|
||||
.set({
|
||||
status,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(songCollaborators.id, collabRecord.id))
|
||||
|
||||
// 记录日志
|
||||
await db.insert(collaborationLogs).values({
|
||||
collaboratorId: collabRecord.id,
|
||||
action: accept ? 'ACCEPT' : 'REJECT',
|
||||
operatorId: user.id,
|
||||
ipAddress:
|
||||
(event.node.req.headers['x-forwarded-for'] as string) || event.node.req.socket.remoteAddress
|
||||
})
|
||||
|
||||
// 获取歌曲信息以发送通知
|
||||
const song = await db
|
||||
.select()
|
||||
.from(songs)
|
||||
.where(eq(songs.id, songId))
|
||||
.limit(1)
|
||||
.then((res) => res[0])
|
||||
|
||||
if (song) {
|
||||
await createCollaborationResponseNotification(user.id, song.requesterId, song.title, accept)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('处理联合投稿邀请失败:', error)
|
||||
if (error.statusCode) throw error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: '处理邀请失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs } from '~/drizzle/schema'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { cache } from '~~/server/utils/cache-helpers'
|
||||
import { isRedisReady } from '~~/server/utils/redis'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
const semester = query.semester as string
|
||||
|
||||
try {
|
||||
// 构建缓存键
|
||||
const cacheKey = semester ? `songs:count:${semester}` : 'songs:count:all'
|
||||
|
||||
// 尝试从缓存获取
|
||||
const cachedCount = await cache.get<{ count: number }>(cacheKey)
|
||||
if (cachedCount !== null) {
|
||||
if (isRedisReady()) {
|
||||
console.log(`[Cache] 歌曲数量缓存命中: ${cacheKey}, 数量: ${cachedCount.count}`)
|
||||
}
|
||||
return cachedCount
|
||||
}
|
||||
|
||||
// 缓存未命中,查询数据库
|
||||
let countQuery = db.select({ count: count() }).from(songs)
|
||||
|
||||
if (semester) {
|
||||
countQuery = countQuery.where(eq(songs.semester, semester))
|
||||
}
|
||||
|
||||
const result = await countQuery
|
||||
const totalCount = result[0]?.count || 0
|
||||
|
||||
const response = {
|
||||
count: totalCount
|
||||
}
|
||||
|
||||
// 缓存结果(5分钟)
|
||||
await cache.set(cacheKey, response)
|
||||
if (isRedisReady()) {
|
||||
console.log(`[Cache] 歌曲数量设置缓存: ${cacheKey}, 数量: ${totalCount}`)
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('获取歌曲数量失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '获取歌曲数量失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { defineEventHandler, createError, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songs, semesters, songBlacklists } from '~/drizzle/schema'
|
||||
import { eq, inArray, and, asc } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '需要登录'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { songIds } = body
|
||||
|
||||
if (!songIds || !Array.isArray(songIds) || songIds.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请选择要导入的歌曲'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前活跃学期
|
||||
const activeSemester = await db
|
||||
.select()
|
||||
.from(semesters)
|
||||
.where(eq(semesters.isActive, true))
|
||||
.limit(1)
|
||||
.then((res) => res[0])
|
||||
|
||||
const currentSemesterName = activeSemester?.name
|
||||
|
||||
if (!currentSemesterName) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '系统未设置当前活跃学期,无法导入歌曲。请联系管理员先设置活跃学期。'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取原始歌曲数据,确保只能获取自己投稿的歌曲,并按创建时间排序
|
||||
const originalSongs = await db
|
||||
.select()
|
||||
.from(songs)
|
||||
.where(
|
||||
and(
|
||||
inArray(songs.id, songIds),
|
||||
eq(songs.requesterId, user.id) // 强制检查所有权
|
||||
)
|
||||
)
|
||||
.orderBy(asc(songs.createdAt)) // 按创建时间正序排列,保持原有顺序
|
||||
|
||||
if (originalSongs.length === 0) {
|
||||
return { success: true, count: 0, results: { total: 0, success: 0, failed: 0, details: [] } }
|
||||
}
|
||||
|
||||
// 获取黑名单
|
||||
const blacklistItems = await db
|
||||
.select()
|
||||
.from(songBlacklists)
|
||||
.where(eq(songBlacklists.isActive, true))
|
||||
|
||||
// 获取当前学期已存在的歌曲,用于排重
|
||||
const existingSongs = await db
|
||||
.select({
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
})
|
||||
.from(songs)
|
||||
.where(eq(songs.semester, currentSemesterName))
|
||||
|
||||
const existingSet = new Set(
|
||||
existingSongs.map((s) => `${s.title.toLowerCase().trim()}|${s.artist.toLowerCase().trim()}`)
|
||||
)
|
||||
|
||||
const results = {
|
||||
total: originalSongs.length,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
details: [] as string[]
|
||||
}
|
||||
|
||||
const songsToInsert: (typeof songs.$inferInsert)[] = []
|
||||
|
||||
for (const song of originalSongs) {
|
||||
const songKey = `${song.title.toLowerCase().trim()}|${song.artist.toLowerCase().trim()}`
|
||||
|
||||
// 1. 检查是否重复
|
||||
if (existingSet.has(songKey)) {
|
||||
results.failed++
|
||||
results.details.push(`《${song.title}》: 当前学期已存在,跳过`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. 检查黑名单
|
||||
const songFullName = `${song.title} - ${song.artist || ''}`.toLowerCase()
|
||||
let isBlocked = false
|
||||
let blockReason = ''
|
||||
|
||||
for (const item of blacklistItems) {
|
||||
if (item.type === 'SONG') {
|
||||
if (songFullName.includes(item.value.toLowerCase())) {
|
||||
isBlocked = true
|
||||
blockReason = item.reason || '黑名单歌曲'
|
||||
break
|
||||
}
|
||||
} else if (item.type === 'KEYWORD') {
|
||||
if (songFullName.includes(item.value.toLowerCase())) {
|
||||
isBlocked = true
|
||||
blockReason = item.reason || '包含违规关键词'
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isBlocked) {
|
||||
results.failed++
|
||||
results.details.push(`《${song.title}》: ${blockReason},跳过`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. 准备插入数据
|
||||
songsToInsert.push({
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
requesterId: user.id, // 强制使用当前登录用户的ID
|
||||
played: false,
|
||||
playedAt: null,
|
||||
semester: currentSemesterName,
|
||||
preferredPlayTimeId: null, // 重置偏好时间
|
||||
cover: song.cover,
|
||||
playUrl: song.playUrl,
|
||||
musicPlatform: song.musicPlatform,
|
||||
musicId: song.musicId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
|
||||
// 将即将插入的歌曲也加入判重集合,防止同一次请求中有重复歌曲
|
||||
existingSet.add(songKey)
|
||||
}
|
||||
|
||||
// 批量插入
|
||||
if (songsToInsert.length > 0) {
|
||||
await db.insert(songs).values(songsToInsert)
|
||||
results.success = songsToInsert.length
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: results.success,
|
||||
results
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,652 @@
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
playTimes,
|
||||
schedules,
|
||||
songCollaborators,
|
||||
songReplayRequests,
|
||||
songs,
|
||||
systemSettings,
|
||||
users,
|
||||
votes
|
||||
} from '~/drizzle/schema'
|
||||
import { and, asc, count, desc, eq, inArray, like, or } from 'drizzle-orm'
|
||||
import { cacheService } from '~~/server/services/cacheService'
|
||||
import { formatDateTime } from '~/utils/timeUtils'
|
||||
import { maskSongsInfo, MaskableSong, MaskableUser } from '~~/server/utils/studentMask'
|
||||
import crypto from 'crypto'
|
||||
|
||||
interface SongResponse {
|
||||
id: number
|
||||
title: string
|
||||
artist: string
|
||||
requester: string
|
||||
requesterId?: number
|
||||
collaborators: any[]
|
||||
voteCount: number
|
||||
played: boolean
|
||||
playedAt: Date | null
|
||||
semester: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
requestedAt: string
|
||||
scheduled: boolean
|
||||
cover: string | null
|
||||
musicPlatform: string | null
|
||||
musicId: string | null
|
||||
playUrl: string | null
|
||||
requesterGrade: string | null
|
||||
requesterClass: string | null
|
||||
replayRequested: boolean
|
||||
replayRequestCount: number
|
||||
isReplay: boolean
|
||||
replayRequesters: any[]
|
||||
voted?: boolean
|
||||
preferredPlayTimeId?: number | null
|
||||
preferredPlayTime?: any
|
||||
scheduleDate?: string
|
||||
schedulePlayed?: boolean
|
||||
replayRequestStatus?: string
|
||||
replayRequestUpdatedAt?: Date | string
|
||||
replayRequestCooldownRemaining?: number
|
||||
hasSubmissionNote?: boolean
|
||||
submissionNote?: string | null
|
||||
submissionNotePublic?: boolean
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const query = getQuery(event)
|
||||
|
||||
const search = (query.search as string) || ''
|
||||
const semester = (query.semester as string) || ''
|
||||
const grade = (query.grade as string) || ''
|
||||
const scope = (query.scope as string) || '' // 'mine' 或为空
|
||||
const sortBy = (query.sortBy as string) || 'createdAt'
|
||||
const sortOrder = (query.sortOrder as string) || 'desc'
|
||||
const bypassCache = query.bypass_cache === 'true'
|
||||
|
||||
// 获取用户身份
|
||||
const user = event.context.user || null
|
||||
const isAdmin = user && ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)
|
||||
console.log('[Songs API] 用户认证状态:', {
|
||||
hasUser: !!user,
|
||||
userId: user?.id,
|
||||
userName: user?.name,
|
||||
userRole: user?.role,
|
||||
isAdmin
|
||||
})
|
||||
|
||||
// 获取系统设置
|
||||
const systemSettingsData = await db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.limit(1)
|
||||
.then((result) => result[0])
|
||||
const shouldHideStudentInfo = systemSettingsData?.hideStudentInfo ?? true
|
||||
|
||||
// 构建缓存键
|
||||
const queryParams = {
|
||||
search: search || '',
|
||||
semester: semester || '',
|
||||
grade: grade || '',
|
||||
scope: scope || '',
|
||||
userId: user?.id, // 登录用户按用户维度隔离缓存,避免私有字段串读
|
||||
sortBy,
|
||||
sortOrder
|
||||
}
|
||||
const queryHash = crypto.createHash('md5').update(JSON.stringify(queryParams)).digest('hex')
|
||||
const cacheKey = `songs:list:${queryHash}`
|
||||
|
||||
// 如果不绕过缓存,尝试从缓存获取基础数据(不包含用户特定的投票状态和动态状态)
|
||||
let cachedData = null
|
||||
if (!bypassCache) {
|
||||
cachedData = await cacheService.getCache<any>(cacheKey)
|
||||
}
|
||||
if (cachedData !== null) {
|
||||
console.log(
|
||||
`[Cache] 歌曲列表缓存命中: ${cacheKey}, 歌曲数: ${cachedData.data?.songs?.length || 0}`
|
||||
)
|
||||
|
||||
// 重新获取动态状态数据(scheduled状态可能已变化)
|
||||
// 只查询已发布的排期,草稿不算作已排期
|
||||
const schedulesQuery = await db
|
||||
.select({
|
||||
songId: schedules.songId
|
||||
})
|
||||
.from(schedules)
|
||||
.where(eq(schedules.isDraft, false))
|
||||
|
||||
const scheduledSongs = new Set(schedulesQuery.map((s) => s.songId))
|
||||
|
||||
// 更新缓存数据中的scheduled状态
|
||||
cachedData.data.songs.forEach((song: any) => {
|
||||
song.scheduled = scheduledSongs.has(song.id)
|
||||
})
|
||||
|
||||
// 如果用户已登录,需要添加用户特定的投票状态
|
||||
if (user) {
|
||||
// 获取用户的投票状态
|
||||
const userVotesQuery = await db
|
||||
.select({
|
||||
songId: votes.songId
|
||||
})
|
||||
.from(votes)
|
||||
.where(eq(votes.userId, user.id))
|
||||
|
||||
const userVotedSongs = new Set(userVotesQuery.map((v) => v.songId))
|
||||
|
||||
// 获取用户的重播申请状态
|
||||
const userReplayRequestsQuery = await db
|
||||
.select({
|
||||
songId: songReplayRequests.songId,
|
||||
status: songReplayRequests.status,
|
||||
updatedAt: songReplayRequests.updatedAt
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.where(eq(songReplayRequests.userId, user.id))
|
||||
|
||||
const userReplayRequestsMap = new Map(userReplayRequestsQuery.map((r) => [r.songId, r]))
|
||||
|
||||
// 为每首歌添加用户特定的状态
|
||||
cachedData.data.songs.forEach((song: any) => {
|
||||
song.voted = userVotedSongs.has(song.id)
|
||||
song.replayRequested = userReplayRequestsMap.has(song.id)
|
||||
|
||||
// 注入重播申请详细状态
|
||||
if (userReplayRequestsMap.has(song.id)) {
|
||||
const replayRequest = userReplayRequestsMap.get(song.id)!
|
||||
song.replayRequestStatus = replayRequest.status
|
||||
|
||||
if (replayRequest.status === 'REJECTED' && replayRequest.updatedAt) {
|
||||
const COOLDOWN_HOURS = 24
|
||||
const cooldownTime = COOLDOWN_HOURS * 60 * 60 * 1000
|
||||
const timeSinceUpdate = Date.now() - new Date(replayRequest.updatedAt).getTime()
|
||||
const remainingTime = cooldownTime - timeSinceUpdate
|
||||
song.replayRequestCooldownRemaining =
|
||||
remainingTime > 0 ? Math.ceil(remainingTime / (60 * 60 * 1000)) : 0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 如果需要隐藏学生信息且用户不是管理员,则对歌曲列表进行脱敏
|
||||
if (shouldHideStudentInfo && !isAdmin && cachedData.data?.songs) {
|
||||
maskSongsInfo(cachedData.data.songs)
|
||||
}
|
||||
|
||||
return cachedData
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Cache] 歌曲列表${bypassCache ? '绕过缓存' : '缓存未命中'},查询数据库: ${cacheKey}`
|
||||
)
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = []
|
||||
|
||||
if (search) {
|
||||
conditions.push(or(like(songs.title, `%${search}%`), like(songs.artist, `%${search}%`)))
|
||||
}
|
||||
|
||||
if (semester) {
|
||||
conditions.push(eq(songs.semester, semester))
|
||||
}
|
||||
|
||||
if (grade) {
|
||||
conditions.push(eq(users.grade, grade))
|
||||
}
|
||||
|
||||
if (scope === 'mine' && user) {
|
||||
conditions.push(eq(songs.requesterId, user.id))
|
||||
}
|
||||
|
||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
// 查询歌曲总数
|
||||
const totalResult = await db
|
||||
.select({ count: count() })
|
||||
.from(songs)
|
||||
.leftJoin(users, eq(songs.requesterId, users.id))
|
||||
.where(whereCondition)
|
||||
const total = totalResult[0].count
|
||||
|
||||
// 获取歌曲数据
|
||||
const songsData = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist,
|
||||
played: songs.played,
|
||||
playedAt: songs.playedAt,
|
||||
semester: songs.semester,
|
||||
createdAt: songs.createdAt,
|
||||
updatedAt: songs.updatedAt,
|
||||
cover: songs.cover,
|
||||
musicPlatform: songs.musicPlatform,
|
||||
musicId: songs.musicId,
|
||||
playUrl: songs.playUrl,
|
||||
submissionNote: songs.submissionNote,
|
||||
submissionNotePublic: songs.submissionNotePublic,
|
||||
preferredPlayTimeId: songs.preferredPlayTimeId,
|
||||
requester: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
grade: users.grade,
|
||||
class: users.class
|
||||
}
|
||||
})
|
||||
.from(songs)
|
||||
.leftJoin(users, eq(songs.requesterId, users.id))
|
||||
.where(whereCondition)
|
||||
.orderBy(
|
||||
sortBy === 'createdAt'
|
||||
? sortOrder === 'desc'
|
||||
? desc(songs.createdAt)
|
||||
: asc(songs.createdAt)
|
||||
: sortBy === 'title'
|
||||
? sortOrder === 'desc'
|
||||
? desc(songs.title)
|
||||
: asc(songs.title)
|
||||
: sortBy === 'artist'
|
||||
? sortOrder === 'desc'
|
||||
? desc(songs.artist)
|
||||
: asc(songs.artist)
|
||||
: desc(songs.createdAt)
|
||||
)
|
||||
|
||||
// 获取每首歌的投票数
|
||||
const voteCountsQuery = await db
|
||||
.select({
|
||||
songId: votes.songId,
|
||||
count: count(votes.id)
|
||||
})
|
||||
.from(votes)
|
||||
.groupBy(votes.songId)
|
||||
|
||||
const voteCounts = new Map(voteCountsQuery.map((v) => [v.songId, v.count]))
|
||||
|
||||
// 获取每首歌的投票详情(用于检查用户是否已投票)
|
||||
const songVotesQuery = await db
|
||||
.select({
|
||||
songId: votes.songId,
|
||||
userId: votes.userId
|
||||
})
|
||||
.from(votes)
|
||||
|
||||
const songVotes = new Map()
|
||||
songVotesQuery.forEach((vote) => {
|
||||
if (!songVotes.has(vote.songId)) {
|
||||
songVotes.set(vote.songId, [])
|
||||
}
|
||||
songVotes.get(vote.songId).push(vote.userId)
|
||||
})
|
||||
|
||||
// 获取每首歌的排期状态和日期
|
||||
// 只查询已发布的排期,草稿不算作已排期
|
||||
const schedulesQuery = await db
|
||||
.select({
|
||||
songId: schedules.songId,
|
||||
playDate: schedules.playDate,
|
||||
played: schedules.played
|
||||
})
|
||||
.from(schedules)
|
||||
.where(eq(schedules.isDraft, false))
|
||||
|
||||
const scheduledSongs = new Set(schedulesQuery.map((s) => s.songId))
|
||||
const scheduleInfo = new Map(
|
||||
schedulesQuery.map((s) => [
|
||||
s.songId,
|
||||
{
|
||||
playDate: s.playDate,
|
||||
played: s.played
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
// 获取每首歌的重播申请状态(全局,不分用户,仅统计 PENDING)
|
||||
const songIds = songsData.map((s) => s.id)
|
||||
let replayRequestCounts = new Map()
|
||||
const replayRequestersMap = new Map()
|
||||
|
||||
if (songIds.length > 0) {
|
||||
const allReplayRequestsQuery = await db
|
||||
.select({
|
||||
songId: songReplayRequests.songId,
|
||||
count: count(songReplayRequests.id)
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.where(
|
||||
and(inArray(songReplayRequests.songId, songIds), eq(songReplayRequests.status, 'PENDING'))
|
||||
)
|
||||
.groupBy(songReplayRequests.songId)
|
||||
|
||||
replayRequestCounts = new Map(allReplayRequestsQuery.map((r) => [r.songId, r.count]))
|
||||
|
||||
// 获取重播申请人信息
|
||||
const replayRequestersData = await db
|
||||
.select({
|
||||
songId: songReplayRequests.songId,
|
||||
user: {
|
||||
id: users.id,
|
||||
name: users.name
|
||||
},
|
||||
createdAt: songReplayRequests.createdAt
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.innerJoin(users, eq(songReplayRequests.userId, users.id))
|
||||
.where(
|
||||
and(inArray(songReplayRequests.songId, songIds), eq(songReplayRequests.status, 'PENDING'))
|
||||
)
|
||||
.orderBy(desc(songReplayRequests.createdAt))
|
||||
|
||||
replayRequestersData.forEach((r) => {
|
||||
if (!replayRequestersMap.has(r.songId)) {
|
||||
replayRequestersMap.set(r.songId, [])
|
||||
}
|
||||
// 只保留前3个
|
||||
if (replayRequestersMap.get(r.songId).length < 3) {
|
||||
replayRequestersMap.get(r.songId).push({
|
||||
id: r.user.id,
|
||||
name: r.user.name || '未知用户',
|
||||
createdAt: r.createdAt
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取期望播放时段信息
|
||||
const playTimesQuery = await db.select().from(playTimes)
|
||||
|
||||
const playTimesMap = new Map(playTimesQuery.map((pt) => [pt.id, pt]))
|
||||
|
||||
// 获取所有用户的姓名列表,用于检测同名用户
|
||||
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((u) => {
|
||||
if (u.name) {
|
||||
if (!nameToUsers.has(u.name)) {
|
||||
nameToUsers.set(u.name, [])
|
||||
}
|
||||
nameToUsers.get(u.name).push(u)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取当前用户的重播申请
|
||||
const userReplayRequestedSongs = new Set()
|
||||
const userReplayRequestsMap = new Map() // 存储详细的重播申请信息
|
||||
if (user) {
|
||||
const userReplayRequestsQuery = await db
|
||||
.select({
|
||||
songId: songReplayRequests.songId,
|
||||
status: songReplayRequests.status,
|
||||
updatedAt: songReplayRequests.updatedAt
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.where(eq(songReplayRequests.userId, user.id))
|
||||
|
||||
userReplayRequestsQuery.forEach((r) => {
|
||||
userReplayRequestedSongs.add(r.songId)
|
||||
userReplayRequestsMap.set(r.songId, {
|
||||
status: r.status,
|
||||
updatedAt: r.updatedAt
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 获取联合投稿人信息
|
||||
// const songIds = songsData.map(s => s.id) // Already defined above
|
||||
const collaboratorsMap = new Map()
|
||||
|
||||
if (songIds.length > 0) {
|
||||
const collaboratorsData = await db
|
||||
.select({
|
||||
songId: songCollaborators.songId,
|
||||
status: songCollaborators.status,
|
||||
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 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 formattedSongs: SongResponse[] = songsData.map((song) => {
|
||||
// 处理投稿人姓名
|
||||
const requesterName = formatDisplayName(song.requester)
|
||||
|
||||
// 处理联合投稿人
|
||||
const collaborators = collaboratorsMap.get(song.id) || []
|
||||
const formattedCollaborators = collaborators.map((c: MaskableUser) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
displayName: formatDisplayName(c),
|
||||
grade: c.grade,
|
||||
class: c.class
|
||||
}))
|
||||
|
||||
// 处理重播申请人
|
||||
const replayRequesters = (replayRequestersMap.get(song.id) || []) as MaskableUser[]
|
||||
const formattedReplayRequesters = replayRequesters.map((r: MaskableUser) => ({
|
||||
...r,
|
||||
displayName: r.name // 这里简化处理,因为 replayRequesters 之前没包含更多信息
|
||||
}))
|
||||
const isRequester = Boolean(user && song.requester?.id === user.id)
|
||||
const canViewSubmissionNote =
|
||||
Boolean(song.submissionNote) &&
|
||||
Boolean(user) &&
|
||||
(Boolean(isAdmin) || isRequester || song.submissionNotePublic)
|
||||
|
||||
// 创建基本歌曲对象
|
||||
const songObject: SongResponse = {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
requester: requesterName,
|
||||
requesterId: song.requester?.id,
|
||||
collaborators: formattedCollaborators, // 添加联合投稿人列表
|
||||
voteCount: voteCounts.get(song.id) || 0,
|
||||
played: song.played,
|
||||
playedAt: song.playedAt,
|
||||
semester: song.semester,
|
||||
createdAt: song.createdAt,
|
||||
updatedAt: song.updatedAt,
|
||||
requestedAt: formatDateTime(song.createdAt), // 添加请求时间的格式化字符串
|
||||
scheduled: scheduledSongs.has(song.id), // 添加是否已排期的标志
|
||||
cover: song.cover || null, // 添加封面字段
|
||||
musicPlatform: song.musicPlatform || null, // 添加音乐平台字段
|
||||
musicId: song.musicId || null, // 添加音乐ID字段
|
||||
playUrl: song.playUrl || null, // 添加播放地址字段
|
||||
requesterGrade: song.requester?.grade || null, // 添加投稿人年级
|
||||
requesterClass: song.requester?.class || null, // 添加投稿人班级
|
||||
replayRequested: userReplayRequestedSongs.has(song.id), // 添加是否已被申请重播的标志
|
||||
replayRequestCount: replayRequestCounts.get(song.id) || 0,
|
||||
isReplay: (replayRequestCounts.get(song.id) || 0) > 0,
|
||||
replayRequesters: formattedReplayRequesters,
|
||||
hasSubmissionNote: canViewSubmissionNote,
|
||||
submissionNote: canViewSubmissionNote ? song.submissionNote : null,
|
||||
submissionNotePublic: canViewSubmissionNote ? song.submissionNotePublic : false
|
||||
}
|
||||
|
||||
// 添加用户的重播申请详细状态
|
||||
if (user && userReplayRequestsMap.has(song.id)) {
|
||||
const replayRequest = userReplayRequestsMap.get(song.id)
|
||||
songObject.replayRequestStatus = replayRequest.status
|
||||
songObject.replayRequestUpdatedAt = replayRequest.updatedAt
|
||||
|
||||
// 如果状态是 REJECTED,计算冷却期剩余时间
|
||||
if (replayRequest.status === 'REJECTED') {
|
||||
const COOLDOWN_HOURS = 24
|
||||
const cooldownTime = COOLDOWN_HOURS * 60 * 60 * 1000
|
||||
const timeSinceUpdate = Date.now() - new Date(replayRequest.updatedAt).getTime()
|
||||
const remainingTime = cooldownTime - timeSinceUpdate
|
||||
|
||||
if (remainingTime > 0) {
|
||||
songObject.replayRequestCooldownRemaining = Math.ceil(remainingTime / (60 * 60 * 1000)) // 剩余小时数
|
||||
} else {
|
||||
songObject.replayRequestCooldownRemaining = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加排期信息
|
||||
const scheduleData = scheduleInfo.get(song.id)
|
||||
if (scheduleData) {
|
||||
songObject.scheduleDate = formatDateTime(scheduleData.playDate) // 排期日期
|
||||
songObject.schedulePlayed = scheduleData.played // 排期中的播放状态
|
||||
}
|
||||
|
||||
// 如果用户已登录,添加投票状态
|
||||
if (user) {
|
||||
const userVotes = songVotes.get(song.id) || []
|
||||
songObject.voted = userVotes.includes(user.id)
|
||||
}
|
||||
|
||||
// 添加期望播放时段相关字段
|
||||
songObject.preferredPlayTimeId = song.preferredPlayTimeId
|
||||
|
||||
// 如果有期望播放时段,添加相关信息
|
||||
if (song.preferredPlayTimeId) {
|
||||
const preferredPlayTime = playTimesMap.get(song.preferredPlayTimeId)
|
||||
if (preferredPlayTime) {
|
||||
songObject.preferredPlayTime = {
|
||||
id: preferredPlayTime.id,
|
||||
name: preferredPlayTime.name,
|
||||
startTime: preferredPlayTime.startTime,
|
||||
endTime: preferredPlayTime.endTime,
|
||||
enabled: preferredPlayTime.enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return songObject
|
||||
})
|
||||
|
||||
// 如果需要隐藏学生信息且用户不是管理员,则对格式化后的歌曲列表进行脱敏
|
||||
if (shouldHideStudentInfo && !isAdmin) {
|
||||
maskSongsInfo(formattedSongs)
|
||||
}
|
||||
|
||||
// 如果按投票数排序,需要重新排序
|
||||
if (sortBy === 'votes') {
|
||||
formattedSongs.sort((a, b) => {
|
||||
const diff = sortOrder === 'desc' ? b.voteCount - a.voteCount : a.voteCount - b.voteCount
|
||||
if (diff === 0) {
|
||||
// 投票数相同时,按提交时间排序(较早提交的优先)
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
}
|
||||
return diff
|
||||
})
|
||||
}
|
||||
|
||||
// 构建返回结果(不包含用户特定的投票状态,以便缓存)
|
||||
const baseResult = {
|
||||
success: true,
|
||||
data: {
|
||||
songs: formattedSongs.map((song) => {
|
||||
const {
|
||||
voted,
|
||||
replayRequested,
|
||||
replayRequestStatus,
|
||||
replayRequestUpdatedAt,
|
||||
replayRequestCooldownRemaining,
|
||||
...baseSong
|
||||
} = song
|
||||
return baseSong
|
||||
}),
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不绕过缓存,缓存基础数据(3分钟,与开放API保持一致)
|
||||
if (!bypassCache) {
|
||||
await cacheService.setCache(cacheKey, baseResult, 180) // 180秒 = 3分钟
|
||||
console.log(`[Cache] 歌曲列表设置缓存: ${cacheKey}, 歌曲数: ${baseResult.data.songs.length}`)
|
||||
}
|
||||
|
||||
// 如果用户已登录,添加投票状态到返回结果
|
||||
const result = {
|
||||
success: true,
|
||||
data: {
|
||||
songs: formattedSongs, // 包含用户投票状态的完整数据
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Songs API] 成功返回 ${result.data.songs.length} 首歌曲,用户类型: ${user ? '登录用户' : '未登录用户'}`
|
||||
)
|
||||
|
||||
return result
|
||||
} catch (error: any) {
|
||||
console.error('[Songs API] 获取歌曲列表失败:', error)
|
||||
|
||||
// 检查是否是数据库连接错误
|
||||
const isDbError =
|
||||
error.message?.includes('ECONNRESET') ||
|
||||
error.message?.includes('ENOTFOUND') ||
|
||||
error.message?.includes('ETIMEDOUT') ||
|
||||
error.message?.includes('Connection terminated') ||
|
||||
error.message?.includes('Connection lost')
|
||||
|
||||
if (isDbError) {
|
||||
console.log('[Songs API] 检测到数据库连接错误')
|
||||
}
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
} else {
|
||||
throw createError({
|
||||
statusCode: isDbError ? 503 : 500,
|
||||
message: isDbError ? '数据库连接暂时不可用,请稍后重试' : '获取歌曲列表失败,请稍后重试'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,443 @@
|
||||
import { createError, defineEventHandler, getCookie, getHeader, getQuery } from 'h3'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
playTimes,
|
||||
schedules,
|
||||
songCollaborators,
|
||||
songReplayRequests,
|
||||
songs,
|
||||
systemSettings,
|
||||
users,
|
||||
votes
|
||||
} from '~/drizzle/schema'
|
||||
import { and, count, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { cacheService } from '~~/server/services/cacheService'
|
||||
import { executeRedisCommand, isRedisReady } from '../../utils/redis'
|
||||
import { formatDateTime } from '~/utils/timeUtils'
|
||||
import { maskPublicScheduleData, PublicScheduleItem } from '../../utils/studentMask'
|
||||
|
||||
import { verifyUserAuth } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 获取查询参数
|
||||
const query = getQuery(event)
|
||||
const semester = query.semester as string
|
||||
const bypassCache = query.bypass_cache === 'true'
|
||||
|
||||
// 检查用户是否已登录并获取角色
|
||||
const authResult = await verifyUserAuth(event)
|
||||
const isLoggedIn = authResult.success
|
||||
const isAdmin = isLoggedIn && ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(authResult.user?.role)
|
||||
|
||||
// 获取系统设置
|
||||
const systemSettingsData = await db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.limit(1)
|
||||
.then((result) => result[0])
|
||||
const shouldHideStudentInfo = systemSettingsData?.hideStudentInfo ?? true
|
||||
|
||||
// 初始化缓存服务
|
||||
|
||||
// 获取当前日期,使用UTC时间
|
||||
const now = new Date()
|
||||
const today = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)
|
||||
)
|
||||
|
||||
// 构建缓存键
|
||||
const cacheKey = semester ? `public_schedules:${semester}` : 'public_schedules:all'
|
||||
|
||||
// 如果不绕过缓存,优先从Redis缓存获取排期数据
|
||||
if (!bypassCache && isRedisReady()) {
|
||||
const cachedData = await executeRedisCommand(async () => {
|
||||
const client = (await import('../../utils/redis')).getRedisClient()
|
||||
if (!client) return null
|
||||
|
||||
const data = await client.get(cacheKey)
|
||||
if (data) {
|
||||
const parsedData = JSON.parse(data) as PublicScheduleItem[]
|
||||
console.log(`[Cache] 公共排期Redis缓存命中: ${cacheKey},数量: ${parsedData.length}`)
|
||||
// 深拷贝数据以避免修改缓存的原始数据
|
||||
const resultData = JSON.parse(JSON.stringify(parsedData)) as PublicScheduleItem[]
|
||||
// 如果需要隐藏学生信息且用户不是管理员,则对排期数据进行脱敏
|
||||
if (shouldHideStudentInfo && !isAdmin) {
|
||||
maskPublicScheduleData(resultData)
|
||||
}
|
||||
return resultData
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
if (cachedData) {
|
||||
return cachedData
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不绕过缓存,Redis缓存未命中,尝试从CacheService获取(CacheService缓存所有学期数据)
|
||||
let cachedSchedules = null
|
||||
if (!bypassCache) {
|
||||
cachedSchedules = await cacheService.getSchedulesList()
|
||||
}
|
||||
|
||||
if (cachedSchedules && cachedSchedules.length > 0) {
|
||||
console.log(`[Cache] CacheService排期缓存命中(所有学期),数量: ${cachedSchedules.length}`)
|
||||
// 如果指定了学期,过滤数据
|
||||
let filteredSchedules = cachedSchedules
|
||||
if (semester) {
|
||||
filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester)
|
||||
console.log(`[Cache] 过滤学期 ${semester} 后的数量: ${filteredSchedules.length}`)
|
||||
}
|
||||
|
||||
// 将过滤后的数据缓存到Redis(缓存完整数据,不进行模糊化)
|
||||
if (isRedisReady()) {
|
||||
await executeRedisCommand(async () => {
|
||||
const client = (await import('../../utils/redis')).getRedisClient()
|
||||
if (!client) return
|
||||
|
||||
await client.set(cacheKey, JSON.stringify(filteredSchedules))
|
||||
console.log(
|
||||
`[Cache] 排期数据设置Redis缓存: ${cacheKey},数量: ${filteredSchedules.length}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 深拷贝数据以避免修改缓存的原始数据
|
||||
const resultData = JSON.parse(JSON.stringify(filteredSchedules)) as PublicScheduleItem[]
|
||||
// 如果需要隐藏学生信息且用户不是管理员,则对排期数据进行脱敏
|
||||
if (shouldHideStudentInfo && !isAdmin) {
|
||||
maskPublicScheduleData(resultData)
|
||||
}
|
||||
|
||||
return resultData
|
||||
}
|
||||
|
||||
// 获取排期的歌曲,包含播放时段信息(查询所有学期的数据)
|
||||
const schedulesData = await db
|
||||
.select({
|
||||
id: schedules.id,
|
||||
playDate: schedules.playDate,
|
||||
sequence: schedules.sequence,
|
||||
played: schedules.played,
|
||||
playTimeId: schedules.playTimeId,
|
||||
song: {
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist,
|
||||
played: songs.played,
|
||||
cover: songs.cover,
|
||||
musicPlatform: songs.musicPlatform,
|
||||
musicId: songs.musicId,
|
||||
playUrl: songs.playUrl,
|
||||
semester: songs.semester,
|
||||
requesterId: songs.requesterId,
|
||||
createdAt: songs.createdAt
|
||||
},
|
||||
requester: {
|
||||
name: users.name,
|
||||
grade: users.grade,
|
||||
class: users.class
|
||||
},
|
||||
playTime: {
|
||||
id: playTimes.id,
|
||||
name: playTimes.name,
|
||||
startTime: playTimes.startTime,
|
||||
endTime: playTimes.endTime,
|
||||
enabled: playTimes.enabled
|
||||
}
|
||||
})
|
||||
.from(schedules)
|
||||
.leftJoin(songs, eq(schedules.songId, songs.id))
|
||||
.leftJoin(users, eq(songs.requesterId, users.id))
|
||||
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
|
||||
.where(eq(schedules.isDraft, false)) // 只查询已发布的排期
|
||||
.orderBy(schedules.playDate)
|
||||
|
||||
// 获取每首歌的投票数
|
||||
const voteCountsQuery = await db
|
||||
.select({
|
||||
songId: votes.songId,
|
||||
count: count(votes.id)
|
||||
})
|
||||
.from(votes)
|
||||
.groupBy(votes.songId)
|
||||
|
||||
const voteCounts = new Map(voteCountsQuery.map((v) => [v.songId, v.count]))
|
||||
|
||||
// 获取所有用户的姓名列表,用于检测同名用户
|
||||
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.song.id)
|
||||
const collaboratorsMap = new Map()
|
||||
|
||||
if (songIds.length > 0) {
|
||||
const collaboratorsData = await db
|
||||
.select({
|
||||
songId: songCollaborators.songId,
|
||||
status: songCollaborators.status,
|
||||
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 状态)
|
||||
// FULFILLED 表示已经被排期,PENDING 表示等待排期
|
||||
const replayCountsData = await db
|
||||
.select({
|
||||
songId: songReplayRequests.songId,
|
||||
count: count(songReplayRequests.id)
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.where(
|
||||
and(
|
||||
inArray(songReplayRequests.songId, songIds),
|
||||
// 查询 PENDING 或 FULFILLED 状态的申请
|
||||
// PENDING: 等待排期,FULFILLED: 已排期但可能还未播放
|
||||
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
|
||||
)
|
||||
)
|
||||
.groupBy(songReplayRequests.songId)
|
||||
|
||||
replayCountsData.forEach((r) => {
|
||||
replayRequestCountsMap.set(r.songId, r.count)
|
||||
})
|
||||
|
||||
// 获取重播申请人列表(前5个,包括 PENDING 和 FULFILLED)
|
||||
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
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.innerJoin(users, eq(songReplayRequests.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(songReplayRequests.songId, songIds),
|
||||
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
|
||||
)
|
||||
)
|
||||
.orderBy(desc(songReplayRequests.createdAt))
|
||||
|
||||
replayRequestersData.forEach((r) => {
|
||||
if (!replayRequestersMap.has(r.songId)) {
|
||||
replayRequestersMap.set(r.songId, [])
|
||||
}
|
||||
// 只保留前5个
|
||||
if (replayRequestersMap.get(r.songId).length < 5) {
|
||||
replayRequestersMap.get(r.songId).push({
|
||||
id: r.user.id,
|
||||
name: r.user.name || '未知用户',
|
||||
grade: r.user.grade,
|
||||
class: r.user.class,
|
||||
status: r.status
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数:格式化显示名称
|
||||
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 formattedSchedules = schedulesData.map((schedule) => {
|
||||
// 获取原始日期,并确保使用UTC时间
|
||||
const originalDate = new Date(schedule.playDate)
|
||||
|
||||
// 创建一个新的日期对象,保留原始日期的年月日,但使用UTC时间
|
||||
const dateOnly = new Date(
|
||||
Date.UTC(
|
||||
originalDate.getUTCFullYear(),
|
||||
originalDate.getUTCMonth(),
|
||||
originalDate.getUTCDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
// 处理投稿人姓名
|
||||
const requesterName = formatDisplayName(schedule.requester)
|
||||
|
||||
// 处理联合投稿人
|
||||
const collaborators = collaboratorsMap.get(schedule.song.id) || []
|
||||
const formattedCollaborators = collaborators.map((c: any) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
displayName: formatDisplayName(c),
|
||||
grade: c.grade,
|
||||
class: c.class
|
||||
}))
|
||||
|
||||
// 获取重播申请信息
|
||||
const replayRequestCount = replayRequestCountsMap.get(schedule.song.id) || 0
|
||||
const replayRequesters = replayRequestersMap.get(schedule.song.id) || []
|
||||
const formattedReplayRequesters = replayRequesters.map((r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
displayName: formatDisplayName(r),
|
||||
grade: r.grade,
|
||||
class: r.class,
|
||||
status: r.status
|
||||
}))
|
||||
|
||||
// 判断是否为重播:有重播申请(PENDING 或 FULFILLED)
|
||||
const isReplaySong = replayRequestCount > 0
|
||||
|
||||
// 注意:这里不进行模糊化处理,保持完整数据用于缓存
|
||||
// 模糊化处理将在最终返回时进行
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
playDate: dateOnly.toISOString().split('T')[0], // 转换为YYYY-MM-DD格式字符串
|
||||
sequence: schedule.sequence || 1,
|
||||
played: schedule.played || false,
|
||||
playTimeId: schedule.playTimeId, // 添加播放时段ID
|
||||
playTime: schedule.playTime
|
||||
? {
|
||||
id: schedule.playTime.id,
|
||||
name: schedule.playTime.name,
|
||||
startTime: schedule.playTime.startTime,
|
||||
endTime: schedule.playTime.endTime,
|
||||
enabled: schedule.playTime.enabled
|
||||
}
|
||||
: null, // 添加播放时段信息
|
||||
song: {
|
||||
id: schedule.song.id,
|
||||
title: schedule.song.title,
|
||||
artist: schedule.song.artist,
|
||||
requester: requesterName,
|
||||
requesterGrade: schedule.requester?.grade || null,
|
||||
requesterClass: schedule.requester?.class || null,
|
||||
collaborators: formattedCollaborators, // 添加联合投稿人
|
||||
voteCount: voteCounts.get(schedule.song.id) || 0,
|
||||
played: schedule.song.played || false,
|
||||
cover: schedule.song.cover || null,
|
||||
musicPlatform: schedule.song.musicPlatform || null,
|
||||
musicId: schedule.song.musicId || null,
|
||||
playUrl: schedule.song.playUrl || null,
|
||||
semester: schedule.song.semester || null,
|
||||
requestedAt: schedule.song.createdAt ? formatDateTime(schedule.song.createdAt) : null,
|
||||
// 重播申请信息
|
||||
replayRequestCount: isReplaySong ? replayRequestCount : 0,
|
||||
replayRequesters: isReplaySong ? formattedReplayRequesters : [],
|
||||
isReplay: isReplaySong // 只有已播放过且有重播申请的才标记为重播
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const allSchedulesResult = formattedSchedules
|
||||
|
||||
// 始终缓存所有学期的数据到CacheService
|
||||
if (allSchedulesResult && allSchedulesResult.length > 0) {
|
||||
await cacheService.setSchedulesList(allSchedulesResult)
|
||||
console.log(
|
||||
`[Cache] 公共排期设置CacheService缓存(所有学期),数量: ${allSchedulesResult.length}`
|
||||
)
|
||||
}
|
||||
|
||||
// 准备要返回和缓存的数据
|
||||
let finalResult = allSchedulesResult
|
||||
if (semester && allSchedulesResult) {
|
||||
finalResult = allSchedulesResult.filter((s) => s.song?.semester === semester)
|
||||
}
|
||||
|
||||
// 缓存结果到Redis(如果可用)- 根据请求参数缓存相应数据
|
||||
if (finalResult && isRedisReady()) {
|
||||
await executeRedisCommand(async () => {
|
||||
const client = (await import('../../utils/redis')).getRedisClient()
|
||||
if (!client) return
|
||||
|
||||
await client.set(cacheKey, JSON.stringify(finalResult))
|
||||
console.log(`[Cache] 排期数据设置Redis缓存: ${cacheKey},数量: ${finalResult.length}`)
|
||||
})
|
||||
}
|
||||
|
||||
// 深拷贝数据以避免修改缓存的原始数据
|
||||
const resultToReturn = JSON.parse(JSON.stringify(finalResult || allSchedulesResult)) as PublicScheduleItem[]
|
||||
|
||||
// 如果需要隐藏学生信息且用户不是管理员,则对排期数据进行脱敏
|
||||
if (shouldHideStudentInfo && !isAdmin && resultToReturn) {
|
||||
maskPublicScheduleData(resultToReturn)
|
||||
}
|
||||
|
||||
return resultToReturn
|
||||
} catch (error: any) {
|
||||
console.error('获取公共排期失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || '获取排期数据失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { and, db, eq, songReplayRequests } from '~/drizzle/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1. 检查用户认证
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '需要登录才能取消重播申请' })
|
||||
}
|
||||
|
||||
// 2. 读取请求体
|
||||
const body = await readBody(event)
|
||||
const { songId } = body
|
||||
|
||||
if (!songId) {
|
||||
throw createError({ statusCode: 400, message: '歌曲ID不能为空' })
|
||||
}
|
||||
|
||||
// 3. 检查申请是否存在且属于该用户
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(songReplayRequests)
|
||||
.where(and(eq(songReplayRequests.songId, songId), eq(songReplayRequests.userId, user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length === 0) {
|
||||
throw createError({ statusCode: 404, message: '重播申请不存在或无权取消' })
|
||||
}
|
||||
|
||||
// 4. 删除申请记录
|
||||
try {
|
||||
await db
|
||||
.delete(songReplayRequests)
|
||||
.where(and(eq(songReplayRequests.songId, songId), eq(songReplayRequests.userId, user.id)))
|
||||
return { success: true, message: '已取消重播申请' }
|
||||
} catch (error: any) {
|
||||
console.error('取消重播申请失败:', error)
|
||||
throw createError({ statusCode: 500, message: '取消重播申请失败,请稍后再试' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { and, db, eq, songs, systemSettings, songReplayRequests, semesters } from '~/drizzle/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1. 检查用户认证
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '需要登录才能申请重播' })
|
||||
}
|
||||
|
||||
// 2. 读取请求体
|
||||
const body = await readBody(event)
|
||||
const { songId } = body
|
||||
|
||||
if (!songId) {
|
||||
throw createError({ statusCode: 400, message: '歌曲ID不能为空' })
|
||||
}
|
||||
|
||||
// 3. 检查系统设置
|
||||
const settingsResult = await db.select().from(systemSettings).limit(1)
|
||||
const settings = settingsResult[0]
|
||||
if (!settings?.enableReplayRequests) {
|
||||
throw createError({ statusCode: 403, message: '重播申请功能未开启' })
|
||||
}
|
||||
|
||||
// 4. 检查歌曲和学期
|
||||
const songResult = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
|
||||
const song = songResult[0]
|
||||
if (!song) {
|
||||
throw createError({ statusCode: 404, message: '歌曲不存在' })
|
||||
}
|
||||
if (!song.played) {
|
||||
throw createError({ statusCode: 400, message: '该歌曲尚未播放,无法申请重播' })
|
||||
}
|
||||
|
||||
// 获取当前学期
|
||||
const currentSemesterResult = await db
|
||||
.select()
|
||||
.from(semesters)
|
||||
.where(eq(semesters.isActive, true))
|
||||
.limit(1)
|
||||
const currentSemester = currentSemesterResult[0]
|
||||
|
||||
// 验证学期
|
||||
if (currentSemester) {
|
||||
if (song.semester !== currentSemester.name) {
|
||||
throw createError({ statusCode: 400, message: '只能申请重播当前学期的歌曲' })
|
||||
}
|
||||
} else {
|
||||
throw createError({ statusCode: 400, message: '当前没有活跃学期,无法申请重播' })
|
||||
}
|
||||
|
||||
// 5. 检查是否重复申请和冷却期
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(songReplayRequests)
|
||||
.where(and(eq(songReplayRequests.songId, songId), eq(songReplayRequests.userId, user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
const existingRequest = existing[0]
|
||||
if (existingRequest.status === 'REJECTED') {
|
||||
// 检查冷却期 (24 小时)
|
||||
const COOLDOWN_HOURS = 24
|
||||
const cooldownTime = COOLDOWN_HOURS * 60 * 60 * 1000
|
||||
const timeSinceUpdate = Date.now() - new Date(existingRequest.updatedAt).getTime()
|
||||
|
||||
if (timeSinceUpdate < cooldownTime) {
|
||||
const remainingHours = Math.ceil((cooldownTime - timeSinceUpdate) / (60 * 60 * 1000))
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: `您的重播申请被拒绝后需要等待 ${remainingHours} 小时才能重新申请`
|
||||
})
|
||||
}
|
||||
|
||||
// 冷却期已过,更新状态为 PENDING
|
||||
await db
|
||||
.update(songReplayRequests)
|
||||
.set({
|
||||
status: 'PENDING',
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
})
|
||||
.where(eq(songReplayRequests.id, existingRequest.id))
|
||||
|
||||
return { success: true, message: '重新申请重播成功' }
|
||||
} else {
|
||||
throw createError({ statusCode: 400, message: '您已经申请过重播该歌曲' })
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 插入申请记录
|
||||
try {
|
||||
await db.insert(songReplayRequests).values({
|
||||
songId,
|
||||
userId: user.id
|
||||
})
|
||||
return { success: true, message: '申请重播成功' }
|
||||
} catch (error: any) {
|
||||
// 处理唯一约束冲突
|
||||
if (error.code === '23505') {
|
||||
throw createError({ statusCode: 400, message: '您已经申请过重播该歌曲' })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,466 @@
|
||||
import {
|
||||
collaborationLogs,
|
||||
db,
|
||||
playTimes,
|
||||
requestTimes,
|
||||
semesters,
|
||||
songCollaborators,
|
||||
songs,
|
||||
systemSettings
|
||||
} from '~/drizzle/db'
|
||||
import { and, eq, gt, gte, lt, lte, sql } from 'drizzle-orm'
|
||||
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
|
||||
import { isLimitReached } from '~~/server/utils/submissionLimit'
|
||||
import { getBeijingTimeISOString } from '~/utils/timeUtils'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户认证
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '需要登录才能点歌'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body.title || !body.artist) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '歌曲名称和艺术家不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 标准化字符串用于精确匹配
|
||||
const normalizeForMatch = (str: string): string => {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
|
||||
.replace(/[&&]/g, 'and')
|
||||
.replace(/[feat\.?|ft\.?]/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const normalizedTitle = normalizeForMatch(body.title)
|
||||
const normalizedArtist = normalizeForMatch(body.artist)
|
||||
|
||||
// 获取当前学期
|
||||
const currentSemester = await getCurrentSemesterName()
|
||||
|
||||
// 哔哩哔哩平台判定逻辑
|
||||
const isBilibili =
|
||||
body.musicPlatform === 'bilibili' ||
|
||||
String(body.musicId || '').startsWith('BV') ||
|
||||
String(body.musicId || '').startsWith('av')
|
||||
|
||||
// 对于哔哩哔哩平台,如果有 musicId,使用 musicId 进行精确匹配
|
||||
if (isBilibili && body.musicId) {
|
||||
// 构建完整的 musicId(包含分P信息)
|
||||
let fullMusicId = String(body.musicId)
|
||||
const bvId = fullMusicId.split(':')[0]
|
||||
|
||||
if (body.bilibiliCid) {
|
||||
const musicIdParts = [bvId, body.bilibiliCid]
|
||||
if (body.bilibiliPage && Number(body.bilibiliPage) > 1) {
|
||||
musicIdParts.push(String(body.bilibiliPage))
|
||||
}
|
||||
fullMusicId = musicIdParts.join(':')
|
||||
}
|
||||
|
||||
// 检查是否已有相同 musicId 的歌曲
|
||||
const existingSongs = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
musicId: songs.musicId,
|
||||
played: songs.played
|
||||
})
|
||||
.from(songs)
|
||||
.where(
|
||||
and(
|
||||
eq(songs.semester, currentSemester),
|
||||
eq(songs.musicPlatform, 'bilibili'),
|
||||
eq(songs.musicId, fullMusicId)
|
||||
)
|
||||
)
|
||||
|
||||
if (existingSongs.length > 0) {
|
||||
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
||||
const hasUnplayedDuplicate = existingSongs.some((s) => !s.played)
|
||||
if (!isSuperAdmin || hasUnplayedDuplicate) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `《${body.title}》已经在列表中,不能重复投稿`
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 对于其他平台,使用标题和艺术家进行匹配
|
||||
const allSongs = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist,
|
||||
semester: songs.semester,
|
||||
played: songs.played
|
||||
})
|
||||
.from(songs)
|
||||
.where(eq(songs.semester, currentSemester))
|
||||
|
||||
const matchingSongs = allSongs.filter((song) => {
|
||||
const songTitle = normalizeForMatch(song.title)
|
||||
const songArtist = normalizeForMatch(song.artist)
|
||||
return songTitle === normalizedTitle && songArtist === normalizedArtist
|
||||
})
|
||||
|
||||
if (matchingSongs.length > 0) {
|
||||
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
||||
const hasUnplayedDuplicate = matchingSongs.some((s) => !s.played)
|
||||
if (!isSuperAdmin || hasUnplayedDuplicate) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `《${body.title}》已经在列表中,不能重复投稿`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查投稿限额(管理员不受限制)
|
||||
const systemSettingsResult = await db.select().from(systemSettings).limit(1)
|
||||
const systemSettingsData = systemSettingsResult[0]
|
||||
const isAdmin = user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
|
||||
|
||||
// 1. 检查强制关闭
|
||||
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '投稿功能已关闭'
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 检查投稿时段限制
|
||||
let hitRequestTime: any = null
|
||||
if (systemSettingsData?.enableRequestTimeLimitation && !isAdmin) {
|
||||
// 使用 ISO 字符串格式进行比较,确保数据库能够正确处理
|
||||
const currentTime = getBeijingTimeISOString()
|
||||
|
||||
const hitRequestTimeResult = await db
|
||||
.select()
|
||||
.from(requestTimes)
|
||||
.where(
|
||||
and(
|
||||
lte(requestTimes.startTime, currentTime),
|
||||
gt(requestTimes.endTime, currentTime),
|
||||
eq(requestTimes.enabled, true)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
hitRequestTime = hitRequestTimeResult[0]
|
||||
|
||||
if (!hitRequestTime) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '当前不在投稿开放时段'
|
||||
})
|
||||
}
|
||||
|
||||
if (hitRequestTime.expected > 0 && hitRequestTime.accepted >= hitRequestTime.expected) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: `当前时段投稿名额已满(${hitRequestTime.accepted}/${hitRequestTime.expected})`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 确定生效的限额类型(三选一逻辑)
|
||||
let effectiveLimit: number | null = null
|
||||
let limitType: 'daily' | 'weekly' | 'monthly' | null = null
|
||||
|
||||
if (systemSettingsData?.enableSubmissionLimit && !isAdmin) {
|
||||
const dailyLimit = systemSettingsData.dailySubmissionLimit
|
||||
const weeklyLimit = systemSettingsData.weeklySubmissionLimit
|
||||
const monthlyLimit = systemSettingsData.monthlySubmissionLimit
|
||||
|
||||
if (dailyLimit !== null && dailyLimit !== undefined) {
|
||||
effectiveLimit = dailyLimit
|
||||
limitType = 'daily'
|
||||
} else if (weeklyLimit !== null && weeklyLimit !== undefined) {
|
||||
effectiveLimit = weeklyLimit
|
||||
limitType = 'weekly'
|
||||
} else if (monthlyLimit !== null && monthlyLimit !== undefined) {
|
||||
effectiveLimit = monthlyLimit
|
||||
limitType = 'monthly'
|
||||
}
|
||||
|
||||
// 如果生效的限额为0,则关闭投稿
|
||||
if (effectiveLimit === 0) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '投稿功能已关闭'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果有生效的限额且大于0,检查使用量
|
||||
// 注意:这里仅作初步检查,严格检查在事务内进行
|
||||
}
|
||||
|
||||
// 检查期望的播出时段是否存在
|
||||
let preferredPlayTime = null
|
||||
if (body.preferredPlayTimeId) {
|
||||
// 检查系统设置是否允许选择播出时段
|
||||
if (!systemSettingsData?.enablePlayTimeSelection) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '播出时段选择功能未启用'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查播出时段是否存在且已启用
|
||||
const playTimeResult = await db
|
||||
.select()
|
||||
.from(playTimes)
|
||||
.where(and(eq(playTimes.id, body.preferredPlayTimeId), eq(playTimes.enabled, true)))
|
||||
.limit(1)
|
||||
preferredPlayTime = playTimeResult[0]
|
||||
|
||||
if (!preferredPlayTime) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '选择的播出时段不存在或未启用'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rawSubmissionNote = typeof body.submissionNote === 'string' ? body.submissionNote.trim() : ''
|
||||
if (rawSubmissionNote.length > 300) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '备注留言不能超过300个字符'
|
||||
})
|
||||
}
|
||||
const submissionNote =
|
||||
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
|
||||
const submissionNotePublic =
|
||||
submissionNote !== null ? body.submissionNotePublic !== false : false
|
||||
|
||||
// 准备发送通知的列表
|
||||
const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = []
|
||||
|
||||
// 创建歌曲和更新状态(使用事务)
|
||||
const song = await db.transaction(async (tx) => {
|
||||
// 检查投稿限额(在事务内进行,防止并发绕过限制)
|
||||
if (
|
||||
systemSettingsData?.enableSubmissionLimit &&
|
||||
!isAdmin &&
|
||||
effectiveLimit &&
|
||||
effectiveLimit > 0 &&
|
||||
limitType
|
||||
) {
|
||||
if (await isLimitReached(tx, user.id, limitType, effectiveLimit)) {
|
||||
const labelMap: Record<string, string> = { daily: '每日', weekly: '每周', monthly: '每月' }
|
||||
const timeMap: Record<string, string> = { daily: '今日', weekly: '本周', monthly: '本月' }
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `${labelMap[limitType]}投稿限额为${effectiveLimit}首,您${timeMap[limitType]}已达到限额`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有时段限制,再次检查并更新已接纳数量
|
||||
if (hitRequestTime) {
|
||||
const latestRequestTimeResult = await tx
|
||||
.select()
|
||||
.from(requestTimes)
|
||||
.where(eq(requestTimes.id, hitRequestTime.id))
|
||||
.limit(1)
|
||||
const latestRequestTime = latestRequestTimeResult[0]
|
||||
|
||||
if (!latestRequestTime || !latestRequestTime.enabled) {
|
||||
throw createError({ statusCode: 403, message: '投稿时段已失效' })
|
||||
}
|
||||
|
||||
// 使用原子更新确保并发安全
|
||||
const updateResult = await tx
|
||||
.update(requestTimes)
|
||||
.set({
|
||||
accepted: sql`${requestTimes.accepted} + 1`
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(requestTimes.id, hitRequestTime.id),
|
||||
// 如果有限额,确保更新时未超限(针对 expected > 0 的情况)
|
||||
latestRequestTime.expected > 0
|
||||
? lt(requestTimes.accepted, latestRequestTime.expected)
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (updateResult.length === 0) {
|
||||
throw createError({ statusCode: 403, message: '当前时段投稿名额已满' })
|
||||
}
|
||||
}
|
||||
|
||||
// 创建歌曲
|
||||
let finalMusicId = body.musicId ? String(body.musicId) : null
|
||||
|
||||
// 如果是 Bilibili 平台,处理 musicId 格式
|
||||
if (isBilibili) {
|
||||
const bvId = finalMusicId?.split(':')[0]
|
||||
if (bvId) {
|
||||
const musicIdParts = [bvId]
|
||||
if (body.bilibiliCid) {
|
||||
musicIdParts.push(body.bilibiliCid)
|
||||
if (body.bilibiliPage && Number(body.bilibiliPage) > 1) {
|
||||
musicIdParts.push(String(body.bilibiliPage))
|
||||
}
|
||||
}
|
||||
finalMusicId = musicIdParts.join(':')
|
||||
}
|
||||
}
|
||||
|
||||
const songResult = await tx
|
||||
.insert(songs)
|
||||
.values({
|
||||
title: body.title,
|
||||
artist: body.artist,
|
||||
requesterId: user.id,
|
||||
preferredPlayTimeId: preferredPlayTime?.id || null,
|
||||
semester: currentSemester, // 使用外部获取的学期名称
|
||||
cover: body.cover || null,
|
||||
musicPlatform: isBilibili ? 'bilibili' : (body.musicPlatform || null),
|
||||
musicId: finalMusicId,
|
||||
playUrl: body.playUrl || null,
|
||||
submissionNote,
|
||||
submissionNotePublic,
|
||||
hitRequestId: hitRequestTime?.id || null
|
||||
})
|
||||
.returning()
|
||||
const newSong = songResult[0]
|
||||
|
||||
// 处理联合投稿人
|
||||
if (
|
||||
body.collaborators &&
|
||||
Array.isArray(body.collaborators) &&
|
||||
body.collaborators.length > 0
|
||||
) {
|
||||
// 去重
|
||||
const uniqueCollaboratorIds = [...new Set(body.collaborators.map((id: any) => Number(id)))]
|
||||
|
||||
for (const collaboratorId of uniqueCollaboratorIds) {
|
||||
// 跳过自己或无效ID
|
||||
if (isNaN(collaboratorId) || collaboratorId === user.id) continue
|
||||
|
||||
try {
|
||||
// 检查是否已经是联合投稿人
|
||||
const existingCollab = await tx
|
||||
.select()
|
||||
.from(songCollaborators)
|
||||
.where(
|
||||
and(
|
||||
eq(songCollaborators.songId, newSong.id),
|
||||
eq(songCollaborators.userId, collaboratorId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingCollab.length > 0) continue
|
||||
|
||||
// 创建联合投稿记录
|
||||
const collabResult = await tx
|
||||
.insert(songCollaborators)
|
||||
.values({
|
||||
songId: newSong.id,
|
||||
userId: collaboratorId,
|
||||
status: 'PENDING'
|
||||
})
|
||||
.returning()
|
||||
|
||||
const collab = collabResult[0]
|
||||
|
||||
// 记录审计日志
|
||||
await tx.insert(collaborationLogs).values({
|
||||
collaboratorId: collab.id,
|
||||
action: 'INVITE',
|
||||
operatorId: user.id,
|
||||
ipAddress:
|
||||
(event.node.req.headers['x-forwarded-for'] as string) ||
|
||||
event.node.req.socket.remoteAddress
|
||||
})
|
||||
|
||||
// 添加到通知列表,事务结束后发送
|
||||
notificationsToSend.push({
|
||||
userId: collaboratorId,
|
||||
songId: newSong.id,
|
||||
songTitle: newSong.title
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`邀请用户 ${collaboratorId} 失败:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newSong
|
||||
})
|
||||
|
||||
// 事务提交成功后,发送通知
|
||||
// 即使通知发送失败,也不影响点歌结果
|
||||
for (const notification of notificationsToSend) {
|
||||
try {
|
||||
await createCollaborationInvitationNotification(
|
||||
user.id,
|
||||
notification.userId,
|
||||
notification.songId,
|
||||
notification.songTitle
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`发送邀请通知给用户 ${notification.userId} 失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return song
|
||||
} catch (error: any) {
|
||||
console.error('点歌失败:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
} else if (error.message === '未设置活跃学期') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '系统未设置当前活跃学期,请联系管理员'
|
||||
})
|
||||
} else {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '点歌失败,请稍后重试'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 获取当前学期名称
|
||||
async function getCurrentSemesterName() {
|
||||
try {
|
||||
// 获取当前活跃的学期
|
||||
const currentSemesterResult = await db
|
||||
.select()
|
||||
.from(semesters)
|
||||
.where(eq(semesters.isActive, true))
|
||||
.limit(1)
|
||||
const currentSemester = currentSemesterResult[0]
|
||||
|
||||
if (currentSemester) {
|
||||
return currentSemester.name
|
||||
}
|
||||
|
||||
// 如果没有活跃学期,抛出错误
|
||||
throw new Error('未设置活跃学期')
|
||||
} catch (error) {
|
||||
console.error('获取当前学期失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { requestTimes, songs, systemSettings } from '~/drizzle/schema'
|
||||
import { and, count, eq, gt, gte, lt, lte } from 'drizzle-orm'
|
||||
import {
|
||||
getBeijingEndOfDay,
|
||||
getBeijingEndOfWeek,
|
||||
getBeijingEndOfMonth,
|
||||
getBeijingStartOfDay,
|
||||
getBeijingStartOfWeek,
|
||||
getBeijingStartOfMonth,
|
||||
getBeijingTimeISOString
|
||||
} from '~/utils/timeUtils'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户认证
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '需要登录才能查看投稿状态'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取系统设置
|
||||
const systemSettingsResult = await db.select().from(systemSettings).limit(1)
|
||||
const systemSettingsData = systemSettingsResult[0]
|
||||
|
||||
// 超级管理员和管理员不受投稿限制
|
||||
const isAdmin = user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
|
||||
|
||||
// 基础返回结构
|
||||
const status: any = {
|
||||
limitEnabled: systemSettingsData?.enableSubmissionLimit || false,
|
||||
dailyLimit: null,
|
||||
weeklyLimit: null,
|
||||
monthlyLimit: null,
|
||||
dailyUsed: 0,
|
||||
weeklyUsed: 0,
|
||||
monthlyUsed: 0,
|
||||
dailyRemaining: null,
|
||||
weeklyRemaining: null,
|
||||
monthlyRemaining: null,
|
||||
submissionClosed: false,
|
||||
timeLimitationEnabled: systemSettingsData?.enableRequestTimeLimitation || false,
|
||||
currentTimePeriod: null
|
||||
}
|
||||
|
||||
// 检查投稿时段限制
|
||||
if (status.timeLimitationEnabled) {
|
||||
const currentTime = getBeijingTimeISOString()
|
||||
|
||||
const activeTimePeriod = await db
|
||||
.select()
|
||||
.from(requestTimes)
|
||||
.where(
|
||||
and(
|
||||
lte(requestTimes.startTime, currentTime),
|
||||
gt(requestTimes.endTime, currentTime),
|
||||
eq(requestTimes.enabled, true)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (activeTimePeriod.length > 0) {
|
||||
status.currentTimePeriod = activeTimePeriod[0]
|
||||
} else if (!isAdmin) {
|
||||
// 如果没有活跃时段且不是管理员,则视为投稿已关闭
|
||||
status.submissionClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!status.limitEnabled) {
|
||||
return status
|
||||
}
|
||||
|
||||
// 确定生效的限额类型(三选一逻辑)
|
||||
const dailyLimit = systemSettingsData.dailySubmissionLimit
|
||||
const weeklyLimit = systemSettingsData.weeklySubmissionLimit
|
||||
const monthlyLimit = systemSettingsData.monthlySubmissionLimit
|
||||
|
||||
let effectiveLimit: number | null = null
|
||||
let limitType: 'daily' | 'weekly' | 'monthly' | null = null
|
||||
|
||||
if (dailyLimit !== null && dailyLimit !== undefined) {
|
||||
effectiveLimit = dailyLimit
|
||||
limitType = 'daily'
|
||||
} else if (weeklyLimit !== null && weeklyLimit !== undefined) {
|
||||
effectiveLimit = weeklyLimit
|
||||
limitType = 'weekly'
|
||||
} else if (monthlyLimit !== null && monthlyLimit !== undefined) {
|
||||
effectiveLimit = monthlyLimit
|
||||
limitType = 'monthly'
|
||||
}
|
||||
|
||||
// 检查是否设置了限额为0(关闭投稿)
|
||||
if (effectiveLimit === 0) {
|
||||
status.dailyLimit = limitType === 'daily' ? effectiveLimit : null
|
||||
status.weeklyLimit = limitType === 'weekly' ? effectiveLimit : null
|
||||
status.monthlyLimit = limitType === 'monthly' ? effectiveLimit : null
|
||||
status.dailyRemaining = 0
|
||||
status.weeklyRemaining = 0
|
||||
status.monthlyRemaining = 0
|
||||
status.submissionClosed = !isAdmin // 管理员不受投稿关闭限制
|
||||
return status
|
||||
}
|
||||
|
||||
// 只计算生效的限额类型的使用量
|
||||
let dailyUsed = 0
|
||||
let weeklyUsed = 0
|
||||
let monthlyUsed = 0
|
||||
|
||||
if (limitType === 'daily' && effectiveLimit && effectiveLimit > 0) {
|
||||
const startOfDay = getBeijingStartOfDay()
|
||||
const endOfDay = getBeijingEndOfDay()
|
||||
|
||||
const dailyUsedResult = await db
|
||||
.select({ count: count() })
|
||||
.from(songs)
|
||||
.where(
|
||||
and(
|
||||
eq(songs.requesterId, user.id),
|
||||
gte(songs.createdAt, startOfDay),
|
||||
lte(songs.createdAt, endOfDay)
|
||||
)
|
||||
)
|
||||
|
||||
dailyUsed = dailyUsedResult[0]?.count || 0
|
||||
} else if (limitType === 'weekly' && effectiveLimit && effectiveLimit > 0) {
|
||||
const startOfWeek = getBeijingStartOfWeek()
|
||||
const endOfWeek = getBeijingEndOfWeek()
|
||||
|
||||
const weeklyUsedResult = await db
|
||||
.select({ count: count() })
|
||||
.from(songs)
|
||||
.where(
|
||||
and(
|
||||
eq(songs.requesterId, user.id),
|
||||
gte(songs.createdAt, startOfWeek),
|
||||
lte(songs.createdAt, endOfWeek)
|
||||
)
|
||||
)
|
||||
|
||||
weeklyUsed = weeklyUsedResult[0]?.count || 0
|
||||
} else if (limitType === 'monthly' && effectiveLimit && effectiveLimit > 0) {
|
||||
const startOfMonth = getBeijingStartOfMonth()
|
||||
const endOfMonth = getBeijingEndOfMonth()
|
||||
|
||||
const monthlyUsedResult = await db
|
||||
.select({ count: count() })
|
||||
.from(songs)
|
||||
.where(
|
||||
and(
|
||||
eq(songs.requesterId, user.id),
|
||||
gte(songs.createdAt, startOfMonth),
|
||||
lte(songs.createdAt, endOfMonth)
|
||||
)
|
||||
)
|
||||
|
||||
monthlyUsed = monthlyUsedResult[0]?.count || 0
|
||||
}
|
||||
|
||||
status.dailyLimit = limitType === 'daily' ? effectiveLimit : null
|
||||
status.weeklyLimit = limitType === 'weekly' ? effectiveLimit : null
|
||||
status.monthlyLimit = limitType === 'monthly' ? effectiveLimit : null
|
||||
status.dailyUsed = dailyUsed
|
||||
status.weeklyUsed = weeklyUsed
|
||||
status.monthlyUsed = monthlyUsed
|
||||
status.dailyRemaining =
|
||||
limitType === 'daily' && effectiveLimit ? Math.max(0, effectiveLimit - dailyUsed) : null
|
||||
status.weeklyRemaining =
|
||||
limitType === 'weekly' && effectiveLimit ? Math.max(0, effectiveLimit - weeklyUsed) : null
|
||||
status.monthlyRemaining =
|
||||
limitType === 'monthly' && effectiveLimit ? Math.max(0, effectiveLimit - monthlyUsed) : null
|
||||
|
||||
return status
|
||||
} catch (error) {
|
||||
console.error('获取投稿状态失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '获取投稿状态失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, semesters, songs, votes } from '~/drizzle/schema'
|
||||
import { and, count, eq } from 'drizzle-orm'
|
||||
import { createSongVotedNotification } from '../../services/notificationService'
|
||||
import {
|
||||
isSongProtected,
|
||||
getSongProtectRemainingSeconds,
|
||||
recordSongVote,
|
||||
recordUserVoteActivity
|
||||
} from '~~/server/services/securityService'
|
||||
import { cacheService } from '~~/server/services/cacheService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户认证
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '需要登录才能投票'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const clientIP = getClientIP(event)
|
||||
|
||||
if (!body.songId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '歌曲ID不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
const isUnvote = body.unvote === true
|
||||
|
||||
try {
|
||||
// 检查歌曲是否存在
|
||||
const songResult = await db.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
|
||||
|
||||
const song = songResult[0]
|
||||
|
||||
if (!song) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '歌曲不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查学期
|
||||
const currentSemesterResult = await db
|
||||
.select()
|
||||
.from(semesters)
|
||||
.where(eq(semesters.isActive, true))
|
||||
.limit(1)
|
||||
const currentSemester = currentSemesterResult[0]
|
||||
|
||||
if (!currentSemester) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '未设置活跃学期,无法进行投票操作'
|
||||
})
|
||||
}
|
||||
|
||||
if (song.semester !== currentSemester.name) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '非活跃学期,无法进行投票操作'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查歌曲是否已播放
|
||||
if (song.played) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '该歌曲已播放,无法进行投票操作'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查歌曲是否已排期(只检查已发布的排期,草稿不算)
|
||||
const schedulesResult = await db
|
||||
.select()
|
||||
.from(schedules)
|
||||
.where(and(eq(schedules.songId, body.songId), eq(schedules.isDraft, false)))
|
||||
.limit(1)
|
||||
|
||||
if (schedulesResult.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '该歌曲已排期,无法进行投票操作'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否是自己的歌曲
|
||||
if (song.requesterId === user.id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '不允许自己给自己投票'
|
||||
})
|
||||
}
|
||||
|
||||
if (isSongProtected(body.songId)) {
|
||||
const remain = getSongProtectRemainingSeconds(body.songId)
|
||||
throw createError({
|
||||
statusCode: 423,
|
||||
message: `该歌曲处于临时保护期,剩余 ${Math.max(remain, 1)} 秒`
|
||||
})
|
||||
}
|
||||
|
||||
// 检查用户是否已经投过票
|
||||
const existingVoteResult = await db
|
||||
.select()
|
||||
.from(votes)
|
||||
.where(and(eq(votes.songId, body.songId), eq(votes.userId, user.id)))
|
||||
.limit(1)
|
||||
|
||||
const existingVote = existingVoteResult[0]
|
||||
|
||||
if (isUnvote) {
|
||||
// 撤销投票逻辑
|
||||
if (!existingVote) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '你尚未为这首歌投票,无法取消'
|
||||
})
|
||||
}
|
||||
|
||||
// 删除投票
|
||||
await db.delete(votes).where(eq(votes.id, existingVote.id))
|
||||
|
||||
// 获取投票总数
|
||||
const voteCountResult = await db
|
||||
.select({ count: count() })
|
||||
.from(votes)
|
||||
.where(eq(votes.songId, body.songId))
|
||||
|
||||
const voteCount = voteCountResult[0].count
|
||||
|
||||
// 清除统计缓存和歌曲缓存
|
||||
try {
|
||||
await cacheService.clearStatsCache()
|
||||
await cacheService.clearSongsCache()
|
||||
console.log('[Cache] 统计缓存和歌曲缓存已清除(取消投票)')
|
||||
} catch (cacheError) {
|
||||
console.error('[Cache] 缓存清除失败(取消投票):', cacheError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '取消投票成功',
|
||||
song: {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
voteCount
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 正常投票逻辑
|
||||
if (existingVote) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '你已经为这首歌投过票了'
|
||||
})
|
||||
}
|
||||
|
||||
// 创建新的投票
|
||||
const newVote = await db
|
||||
.insert(votes)
|
||||
.values({
|
||||
songId: body.songId,
|
||||
userId: user.id
|
||||
})
|
||||
.returning()
|
||||
|
||||
// 获取投票总数
|
||||
const voteCountResult = await db
|
||||
.select({ count: count() })
|
||||
.from(votes)
|
||||
.where(eq(votes.songId, body.songId))
|
||||
|
||||
const voteCount = voteCountResult[0].count
|
||||
|
||||
// 发送通知(异步,不阻塞响应)
|
||||
if (song.requesterId !== user.id) {
|
||||
createSongVotedNotification(body.songId, user.id, clientIP).catch(() => {
|
||||
// 发送通知失败不影响主流程
|
||||
})
|
||||
}
|
||||
|
||||
const protectedTriggered = recordSongVote(body.songId, clientIP, user.id)
|
||||
recordUserVoteActivity(user.id, song.title)
|
||||
|
||||
// 清除统计缓存和歌曲缓存
|
||||
try {
|
||||
await cacheService.clearStatsCache()
|
||||
await cacheService.clearSongsCache()
|
||||
console.log('[Cache] 统计缓存和歌曲缓存已清除(投票)')
|
||||
} catch (cacheError) {
|
||||
console.error('[Cache] 缓存清除失败(投票):', cacheError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '投票成功',
|
||||
song: {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
voteCount
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
} else {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '操作失败,请稍后重试'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { cacheService } from '~~/server/services/cacheService'
|
||||
import {
|
||||
schedules,
|
||||
songs,
|
||||
systemSettings,
|
||||
votes,
|
||||
songCollaborators,
|
||||
collaborationLogs
|
||||
} from '~/drizzle/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户认证
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '需要登录才能撤回歌曲'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body.songId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '歌曲ID不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
// 查找歌曲
|
||||
const songResult = await db.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
|
||||
const song = songResult[0]
|
||||
|
||||
if (!song) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '歌曲不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否是用户自己的投稿或联合投稿
|
||||
const isRequester = song.requesterId === user.id
|
||||
let isCollaborator = false
|
||||
let collaboratorRecord = null
|
||||
|
||||
if (!isRequester) {
|
||||
const collabResult = await db
|
||||
.select()
|
||||
.from(songCollaborators)
|
||||
.where(and(eq(songCollaborators.songId, song.id), eq(songCollaborators.userId, user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (collabResult.length > 0) {
|
||||
isCollaborator = true
|
||||
collaboratorRecord = collabResult[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRequester && !isCollaborator && !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只能撤回自己的投稿或退出联合投稿'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查歌曲是否已经播放
|
||||
if (song.played) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '已播放的歌曲不能撤回'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查歌曲是否已排期(只检查已发布的排期,草稿不算)
|
||||
const scheduleResult = await db
|
||||
.select()
|
||||
.from(schedules)
|
||||
.where(and(eq(schedules.songId, body.songId), eq(schedules.isDraft, false)))
|
||||
.limit(1)
|
||||
const schedule = scheduleResult[0]
|
||||
|
||||
if (schedule) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '已排期的歌曲不能撤回'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是联合投稿人撤回(退出)
|
||||
if (isCollaborator && !isRequester && !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
await db.delete(songCollaborators).where(eq(songCollaborators.id, collaboratorRecord.id))
|
||||
|
||||
// 记录日志
|
||||
await db.insert(collaborationLogs).values({
|
||||
collaboratorId: collaboratorRecord.id,
|
||||
action: 'LEAVE',
|
||||
operatorId: user.id,
|
||||
ipAddress:
|
||||
(event.node.req.headers['x-forwarded-for'] as string) || event.node.req.socket.remoteAddress
|
||||
})
|
||||
|
||||
// 清除歌曲列表缓存
|
||||
await cacheService.clearSongsCache()
|
||||
|
||||
return {
|
||||
message: '已成功退出联合投稿',
|
||||
songId: body.songId,
|
||||
action: 'leave'
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是主投稿人撤回(删除歌曲)
|
||||
|
||||
// 获取系统设置以检查限制类型
|
||||
const settingsResult = await db.select().from(systemSettings).limit(1)
|
||||
const settings = settingsResult[0]
|
||||
const dailyLimit = settings?.dailySubmissionLimit || 0
|
||||
const weeklyLimit = settings?.weeklySubmissionLimit || 0
|
||||
|
||||
// 检查撤销的歌曲是否在当前限制期间内(用于返还配额)
|
||||
let canReturnQuota = false
|
||||
const now = new Date()
|
||||
|
||||
if (dailyLimit > 0) {
|
||||
// 检查是否在同一天
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
|
||||
|
||||
if (song.createdAt >= startOfDay && song.createdAt < endOfDay) {
|
||||
canReturnQuota = true
|
||||
}
|
||||
} else if (weeklyLimit > 0) {
|
||||
// 检查是否在同一周(周一开始)
|
||||
const startOfWeek = new Date(now)
|
||||
const dayOfWeek = now.getDay()
|
||||
const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1 // 周一为一周开始
|
||||
startOfWeek.setDate(now.getDate() - daysToSubtract)
|
||||
startOfWeek.setHours(0, 0, 0, 0)
|
||||
|
||||
const endOfWeek = new Date(startOfWeek.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
if (song.createdAt >= startOfWeek && song.createdAt < endOfWeek) {
|
||||
canReturnQuota = true
|
||||
}
|
||||
}
|
||||
|
||||
// 删除关联的联合投稿记录
|
||||
await db.delete(songCollaborators).where(eq(songCollaborators.songId, body.songId))
|
||||
|
||||
// 删除歌曲的所有投票
|
||||
await db.delete(votes).where(eq(votes.songId, body.songId))
|
||||
|
||||
// 如果有 hitRequestId,减少对应时段的已接纳数量
|
||||
if (song.hitRequestId) {
|
||||
try {
|
||||
// 需要引入 requestTimes 和 sql
|
||||
const { requestTimes } = await import('~/drizzle/schema')
|
||||
const { sql } = await import('drizzle-orm')
|
||||
|
||||
await db
|
||||
.update(requestTimes)
|
||||
.set({
|
||||
accepted: sql`GREATEST(0, accepted - 1)`
|
||||
})
|
||||
.where(eq(requestTimes.id, song.hitRequestId))
|
||||
console.log(`已减少投稿时段 ${song.hitRequestId} 的接纳数量`)
|
||||
} catch (error) {
|
||||
console.error(`减少投稿时段接纳数量失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除歌曲
|
||||
await db.delete(songs).where(eq(songs.id, body.songId))
|
||||
|
||||
// 清除歌曲列表缓存
|
||||
await cacheService.clearSongsCache()
|
||||
console.log('[Cache] 歌曲缓存已清除(撤回歌曲)')
|
||||
|
||||
return {
|
||||
message: canReturnQuota ? '歌曲已成功撤回,投稿配额已返还' : '歌曲已成功撤回',
|
||||
songId: body.songId,
|
||||
quotaReturned: canReturnQuota
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user