Files
BYW-VoiceHub/server/api/songs/[id]/update.put.ts
T
Henry_Du bd8df1f026
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 6m31s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s
feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
- 新增 /api/admin/songs/batch-reject.post 批量驳回接口(事务清排期/投票/扣接纳数/黑名单去重/异步通知)
- requireSongAdmin 三角色校验 + createApiError 统一错误包装
- scheduleDaysBefore/After 四字段排期可见范围限制(开放 API 与公开排期接口三处缓存路径 + DB 条件过滤)
- submissionNoteAutoModeration 基于黑名单 KEYWORD/LANGUAGE/GENRE 自动判定留言 publicStatus,命中自动关闭 submissionNotePublic
- 更新 songs/request.post 与 songs/[id]/update.put 接入自动审查逻辑
2026-09-06 17:03:20 +08:00

312 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db } from '~/drizzle/db'
import {
songs,
users,
songCollaborators,
collaborationLogs,
systemSettings,
songBlacklists
} from '~/drizzle/schema'
import { and, eq, inArray, or } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
import { createSubmissionNoteClearedNotification } from '~~/server/services/notificationService'
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
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
}
if ('submissionNotePublic' in body) {
const wantPublic = updateData.submissionNote ? body.submissionNotePublic !== false : false
updateData.submissionNotePublic = wantPublic
// 自动审查(若 systemSettings 打开 submissionNoteRequiresApproval)
try {
const sysSetRes = await db.select().from(systemSettings).limit(1)
const sysSet = sysSetRes[0]
if (sysSet?.submissionNoteRequiresApproval && wantPublic && updateData.submissionNote) {
const bl = await db
.select()
.from(songBlacklists)
.where(inArray(songBlacklists.type, ['KEYWORD', 'LANGUAGE', 'GENRE']))
const mod = moderateSubmissionNote(
updateData.submissionNote,
wantPublic,
true,
{
title: updateData.title || existingSong.title,
artist: updateData.artist || existingSong.artist
},
bl
)
updateData.submissionNotePublic = mod.notePublic
updateData.submissionNotePublicStatus = mod.publicStatus
} else {
updateData.submissionNotePublicStatus = null
}
} catch (err) {
console.warn('[Update API] 投稿留言自动审查失败,保持用户设置:', err)
}
}
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'
})
}
})