feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
import { db } from '~/drizzle/db'
import { schedules, songs, votes, requestTimes } from '~/drizzle/schema'
import { eq, sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
const body = await readBody(event)
if (!body.songId) {
throw createError({
statusCode: 400,
message: '歌曲ID不能为空'
})
}
try {
// 使用事务确保删除操作的原子性
const result = await db.transaction(async (tx) => {
// 在事务中重新检查歌曲是否存在
const songResult = await tx.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createError({
statusCode: 404,
message: '歌曲不存在或已被删除'
})
}
console.log(`开始删除歌曲: ${song.title} (ID: ${body.songId})`)
// 删除歌曲的所有投票
const deletedVotes = await tx.delete(votes).where(eq(votes.songId, body.songId))
console.log(`删除了投票记录`)
// 删除歌曲的所有排期
const deletedSchedules = await tx.delete(schedules).where(eq(schedules.songId, body.songId))
console.log(`删除了排期记录`)
// 如果有 hitRequestId,减少对应时段的已接纳数量
if (song.hitRequestId) {
try {
await tx
.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}`)
}
}
// 删除歌曲
const deletedSong = await tx.delete(songs).where(eq(songs.id, body.songId)).returning()
const deletedSongData = deletedSong[0]
console.log(`成功删除歌曲: ${deletedSongData?.title}`)
return {
message: '歌曲已成功删除',
songId: body.songId,
deletedVotes: true,
deletedSchedules: true
}
})
return result
} catch (error: any) {
console.error('删除歌曲时发生错误:', error)
// 如果是已经格式化的错误,直接抛出
if (error.statusCode) {
throw error
}
// 处理Prisma特定错误
if (error.code === 'P2025') {
throw createError({
statusCode: 404,
message: '歌曲不存在或已被删除'
})
}
// 其他未知错误
throw createError({
statusCode: 500,
message: '删除歌曲时发生未知错误'
})
}
})
+291
View File
@@ -0,0 +1,291 @@
import { readBody } from 'h3'
import { db } from '~/drizzle/db'
import { songs } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import {
createTxSongDetailBody,
normalizeTxMusicId,
txHeaders
} from '~~/server/utils/native_tx'
import { fetchSongDuration } from '~~/server/utils/songDurationFetcher'
export default defineEventHandler(async (event) => {
requireSongAdmin(event)
const body = await readBody(event)
// 新增歌曲场景:未传 songId 时按平台+音乐ID获取时长,仅返回不写入
if (body?.songId == null) {
const platform = body?.platform
const musicId = body?.musicId
if (!platform || !musicId) {
throw createApiError(
400,
SERVER_ERROR_CODES.SONG_DURATION_PLATFORM_REQUIRED,
'缺少平台或音乐 ID 信息,无法获取时长'
)
}
const durationSeconds = await fetchSongDuration(String(platform), String(musicId))
if (durationSeconds == null) {
return { success: false, songId: null, durationSeconds: null, message: '未获取到有效时长' }
}
return { success: true, songId: null, durationSeconds, message: '时长获取成功' }
}
const songId = body.songId == null ? null : Number(body.songId)
if (!Number.isFinite(songId) || songId < 1) {
throw createApiError(400, SERVER_ERROR_CODES.SONG_INVALID_ID, '歌曲 ID 无效')
}
const song = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
if (song.length === 0) {
throw createApiError(404, SERVER_ERROR_CODES.SONG_NOT_FOUND, '歌曲不存在')
}
const s = song[0]
// 浏览器读取或管理员手动输入时,只由服务端负责校验并保存结果。
if (Object.prototype.hasOwnProperty.call(body || {}, 'durationSeconds')) {
if (body.durationSeconds === null) {
await db.update(songs).set({ durationSeconds: null }).where(eq(songs.id, songId))
return { success: true, songId, durationSeconds: null, message: '时长已清空' }
}
const durationSeconds = Number(body.durationSeconds)
if (!Number.isInteger(durationSeconds) || durationSeconds < 0 || durationSeconds > 7200) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
'时长需在 0 秒至 2 小时之间'
)
}
if (s.durationSeconds !== durationSeconds) {
await db.update(songs).set({ durationSeconds }).where(eq(songs.id, songId))
}
return {
success: true,
songId,
durationSeconds,
message: s.durationSeconds === durationSeconds ? '时长未变化' : '时长更新成功'
}
}
const platform = s.musicPlatform
const musicId = s.musicId
if (!platform || !musicId) {
throw createApiError(
400,
SERVER_ERROR_CODES.SONG_DURATION_PLATFORM_REQUIRED,
'歌曲缺少平台或音乐 ID 信息,无法获取时长'
)
}
let durationSeconds: number | null = null
try {
if (platform === 'netease' || platform === 'netease-podcast') {
const response: any = await $fetch('/api/api-enhanced/netease/song/url/v1', {
params: { id: musicId, level: 'standard' },
timeout: 10000
})
if (response?.code === 200 && Array.isArray(response.data) && response.data.length > 0) {
const timeMs = response.data[0]?.time
if (typeof timeMs === 'number' && timeMs > 0) {
durationSeconds = Math.floor(timeMs / 1000)
}
}
if (!durationSeconds) {
return {
success: false,
songId,
durationSeconds: null,
message: '网易云 API 未返回有效时长'
}
}
} else if (platform === 'tencent') {
const normalized = normalizeTxMusicId(musicId)
const result: any = await $fetch(
'https://u.y.qq.com/cgi-bin/musicu.fcg',
{
method: 'POST',
headers: txHeaders,
body: createTxSongDetailBody(normalized),
responseType: 'json',
timeout: 10000
}
)
if (result?.code === 0 && result?.req?.code === 0) {
const interval = result.req?.data?.track_info?.interval
if (typeof interval === 'number' && interval > 0) {
durationSeconds = Math.floor(interval)
}
}
if (!durationSeconds) {
return {
success: false,
songId,
durationSeconds: null,
message: 'QQ 音乐详情接口未返回有效时长'
}
}
} else if (platform === 'migu') {
const songInfo: any = await $fetch(
`https://app.c.nf.migu.cn/resource/song/by-contentids/v2.0?contentId=${encodeURIComponent(musicId)}`,
{ timeout: 10000 }
)
if (songInfo?.code?.toString() === '000000' && songInfo?.data?.length) {
const duration = songInfo.data[0]?.duration
if (typeof duration === 'number' && duration > 0) {
durationSeconds = Math.floor(duration)
}
}
if (!durationSeconds) {
return {
success: false,
songId,
durationSeconds: null,
message: '咪咕歌曲信息接口未返回有效时长'
}
}
} else if (platform === 'bilibili') {
// musicId 格式为 bvid:cid 或 bvid:cid:page
const parts = musicId.split(':')
const bvid = parts[0]
const page = parts.length > 2 ? Number(parts[2]) : 1
if (parts.length > 2 && (!Number.isInteger(page) || page < 1)) {
return {
success: false,
songId,
durationSeconds: null,
message: `哔哩哔哩视频 ID 格式无效:${musicId}`
}
}
const viewResp: any = await $fetch(
'https://api.bilibili.com/x/web-interface/view',
{
params: { bvid },
headers: {
Cookie: 'buvid3=0',
Referer: 'https://www.bilibili.com/'
},
timeout: 10000
}
)
if (viewResp?.code === 0 && viewResp?.data?.pages?.length) {
const pageIdx = page > 1 ? Math.max(0, page - 1) : 0
const duration = viewResp.data.pages[pageIdx]?.duration
if (typeof duration === 'number' && duration > 0) {
durationSeconds = Math.floor(duration)
}
}
if (!durationSeconds) {
return {
success: false,
songId,
durationSeconds: null,
message: '哔哩哔哩视频详情接口未返回有效时长'
}
}
} else {
return {
success: false,
songId,
durationSeconds: null,
message: `暂不支持的平台:${platform}`
}
}
// 网易云固定返回 30 秒时通常是试听片段,不作为歌曲完整时长。
if ((platform === 'netease' || platform === 'netease-podcast') && durationSeconds === 30) {
return {
success: false,
songId,
durationSeconds: null,
message: '网易云返回试听片段时长,未写入'
}
}
// 校验时长范围(合理的歌曲时长:30秒~1小时)
if (durationSeconds < 30 || durationSeconds > 3600) {
console.warn(`[时长刷新] 歌曲 #${songId} 时长异常:${durationSeconds}s,跳过写入`)
return {
success: false,
songId,
durationSeconds: null,
message: `获取到的时长 ${durationSeconds}s 超出合理范围(30秒~1小时),未写入`
}
}
// 避免无变化写入
if (s.durationSeconds === durationSeconds) {
return {
success: true,
songId,
durationSeconds,
message: '时长未变化'
}
}
// 写入数据库
await db.update(songs).set({ durationSeconds }).where(eq(songs.id, songId))
return {
success: true,
songId,
durationSeconds,
message: '时长获取成功'
}
} catch (err: any) {
const errorMsg = err?.message || '未知错误'
const platformName = platform === 'netease' || platform === 'netease-podcast' ? '网易云' : platform === 'tencent' ? 'QQ音乐' : platform === 'migu' ? '咪咕' : platform === 'bilibili' ? 'Bilibili' : platform
const statusCode = err.statusCode || err.status || (err.response?.status as number | undefined)
// AbortError:请求超时或批量刷新中止
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
return {
success: false,
songId,
durationSeconds: null,
message: `${platformName} 响应超时`
}
}
// 404:平台限流或歌曲不存在
if (statusCode === 404) {
return {
success: false,
songId,
durationSeconds: null,
message: `${platformName} 未找到该歌曲`
}
}
if (statusCode === 403 || statusCode === 429) {
return {
success: false,
songId,
durationSeconds: null,
message: `${platformName} 接口被限制,请稍后重试`
}
}
console.error(`[时长刷新] 歌曲 #${songId} 获取时长失败 (${platformName}):`, errorMsg)
return {
success: false,
songId,
durationSeconds: null,
message: `${platformName} 接口异常,请稍后重试`
}
}
})
+136
View File
@@ -0,0 +1,136 @@
import { db } from '~/drizzle/db'
import { createSongPlayedNotification } from '~~/server/services/notificationService'
import { songs, songReplayRequests } from '~/drizzle/schema'
import { eq, and, inArray } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
import { getClientIP } from '~~/server/utils/ip-utils'
import { restoreReplayRequestsToPending } from '~~/server/utils/scheduleReplayBinding'
import { z } from 'zod'
const markPlayedSchema = z.object({
songId: z.number().optional(),
songIds: z.array(z.number()).max(100, '批量操作单次最多支持 100 首歌曲').optional(),
unmark: z.boolean().optional()
})
export default defineEventHandler(async (event) => {
// 检查用户认证
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '需要登录才能标记歌曲'
})
}
// 检查是否是管理员
if (!['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员可以标记歌曲为已播放'
})
}
const body = await readBody(event)
const validatedDataResult = markPlayedSchema.safeParse(body)
if (!validatedDataResult.success) {
throw createError({
statusCode: 400,
message: validatedDataResult.error.issues[0]?.message || '请求参数无效'
})
}
const validatedData = validatedDataResult.data
const songIds = Array.from(
new Set([
...(validatedData.songId !== undefined ? [validatedData.songId] : []),
...(validatedData.songIds || [])
])
)
if (songIds.length === 0) {
throw createError({
statusCode: 400,
message: '歌曲ID列表不能为空'
})
}
// 撤回已播放
const isUnmark = validatedData.unmark === true
// 过滤需要更新的歌曲
const condition = !isUnmark
? and(inArray(songs.id, songIds), eq(songs.played, false))
: and(inArray(songs.id, songIds), eq(songs.played, true))
const { updatedSongsResult, updatedSongIds } = await db.transaction(async (tx) => {
const updatedSongsResult = await tx
.update(songs)
.set({
played: !isUnmark,
playedAt: isUnmark ? null : getBeijingTime()
})
.where(condition)
.returning({ id: songs.id })
const updatedSongIds = updatedSongsResult.map((s) => s.id)
if (updatedSongIds.length > 0) {
if (!isUnmark) {
// 标记已播放:履行全部待处理的重播申请
const updatedRequests = await tx
.update(songReplayRequests)
.set({
status: 'FULFILLED',
updatedAt: new Date()
})
.where(
and(
inArray(songReplayRequests.songId, updatedSongIds),
eq(songReplayRequests.status, 'PENDING')
)
)
.returning({ id: songReplayRequests.id })
if (updatedRequests.length > 0) {
console.log(`将 ${updatedRequests.length} 个重播申请状态更新为 FULFILLED(歌曲已播放)`)
}
} else {
// 撤回已播放:每人仅恢复最新一条,避免违反部分唯一索引
const restoredCount = await restoreReplayRequestsToPending({
tx,
songIds: updatedSongIds,
at: new Date()
})
if (restoredCount > 0) {
console.log(`将 ${restoredCount} 个重播申请状态恢复为 PENDING(撤回已播放)`)
}
}
}
return { updatedSongsResult, updatedSongIds }
})
// 异步发送通知
if (!isUnmark && updatedSongIds.length > 0) {
event.waitUntil(
Promise.allSettled(
updatedSongIds.map((songId) =>
createSongPlayedNotification(songId).catch((err) => {
console.error(`发送歌曲(${songId})已播放通知失败:`, err)
})
)
)
)
}
return {
message: isUnmark ? '歌曲已成功撤回已播放状态' : '歌曲已成功标记为已播放',
count: updatedSongsResult.length,
updatedSongIds: updatedSongIds
}
})
+147
View File
@@ -0,0 +1,147 @@
import { db } from '~/drizzle/db'
import { schedules, songBlacklists, songs, votes, requestTimes } from '~/drizzle/schema'
import { eq, sql } from 'drizzle-orm'
import { createSongRejectedNotification } from '../../../services/notificationService'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
const body = await readBody(event)
if (!body.songId) {
throw createError({
statusCode: 400,
message: '歌曲ID不能为空'
})
}
if (!body.reason || !body.reason.trim()) {
throw createError({
statusCode: 400,
message: '驳回原因不能为空'
})
}
try {
let songForNotification = null
// 使用事务确保驳回操作的原子性
const result = await db.transaction(async (tx) => {
// 在事务中重新检查歌曲是否存在
const songResult = await tx.select().from(songs).where(eq(songs.id, body.songId)).limit(1)
const song = songResult[0]
if (!song) {
throw createError({
statusCode: 404,
message: '歌曲不存在或已被删除'
})
}
// 保存歌曲信息用于后续发送通知
songForNotification = song
console.log(`开始驳回歌曲: ${song.title} (ID: ${body.songId})`)
// 如果选择加入黑名单,先添加到黑名单
if (body.addToBlacklist) {
try {
await tx.insert(songBlacklists).values({
type: 'SONG',
value: `${song.title} - ${song.artist}`,
reason: `歌曲驳回: ${body.reason.trim()}`,
createdBy: user.id
})
console.log(`歌曲已添加到黑名单`)
} catch (error) {
// 如果黑名单中已存在相同歌曲,忽略错误继续执行
console.log(`黑名单添加失败,可能已存在: ${error.message}`)
}
}
// 删除歌曲的所有投票
await tx.delete(votes).where(eq(votes.songId, body.songId))
console.log(`删除了投票记录`)
// 删除歌曲的所有排期
await tx.delete(schedules).where(eq(schedules.songId, body.songId))
console.log(`删除了排期记录`)
// 如果有 hitRequestId,减少对应时段的已接纳数量
if (song.hitRequestId) {
try {
await tx
.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}`)
}
}
// 删除歌曲
const deletedSong = await tx.delete(songs).where(eq(songs.id, body.songId)).returning()
console.log(`歌曲删除完成`)
return {
success: true,
deletedSong: deletedSong[0],
addedToBlacklist: body.addToBlacklist
}
})
// 事务提交后,发送通知
// 这样避免邮件发送等耗时操作阻塞数据库事务连接
let notificationSent = false
if (songForNotification && songForNotification.requesterId) {
// 异步发送通知,不阻塞响应
createSongRejectedNotification(
songForNotification.requesterId,
{ title: songForNotification.title, artist: songForNotification.artist },
body.reason.trim()
)
.then(() => {
console.log(`已发送驳回通知给用户: ${songForNotification.requesterId}`)
})
.catch((error) => {
console.error(`发送通知失败: ${error.message}`)
})
notificationSent = true
}
console.log(`歌曲驳回操作完成: ${body.songId}`)
return {
success: true,
message: '歌曲驳回成功',
data: {
...result,
notificationSent
}
}
} catch (error) {
console.error('驳回歌曲失败:', error)
// 如果是我们抛出的错误,直接重新抛出
if (error.statusCode) {
throw error
}
// 其他错误
throw createError({
statusCode: 500,
message: '驳回歌曲失败: ' + error.message
})
}
})