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
+645
View File
@@ -0,0 +1,645 @@
import {
collaborationLogs,
db,
playTimes,
requestTimes,
schedules,
semesters,
cardCodes,
songCollaborators,
songs,
users
} from '~/drizzle/db'
import { and, eq, gte, gt, inArray, lt, lte, or, sql } from 'drizzle-orm'
import { createError } from 'h3'
import { reconcileSongDurationOnSubmit } from '~~/server/services/durationValidationService'
import { createApiError } from '~~/server/utils/apiError'
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
import {
isCardCodeLimitBypassActive,
isLimitReached
} from '~~/server/utils/submissionLimit'
import { getClientIP } from '~~/server/utils/ip-utils'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
import { getServerDate } from '~~/server/utils/serverTime'
import { SERVER_ERROR_CODES, SONG_DURATION_MAX_SECONDS, SONG_DURATION_MIN_SECONDS, SUBMISSION_NOTE_STATUS } from '~~/server/config/constants'
import { normalizeForMatch } from '~~/server/utils/song-name-normalize'
import { resolveSubmissionRestrictionPolicy } from '~~/server/utils/submission-restriction-policy'
import { z } from 'zod'
type SongRequestUser = {
id: number
role: string
}
const songRequestBodySchema = z.object({
title: z.string().trim().min(1, '歌曲名称不能为空').max(200, '歌曲名称不能超过200个字符'),
artist: z.string().trim().min(1, '艺术家不能为空').max(200, '艺术家不能超过200个字符'),
cover: z
.string()
.trim()
.max(1000, '封面地址不能超过1000个字符')
// 封面必须是完整 http(s) 链接,防止音源返回 pic_id 等片段被误存导致封面无法显示
.refine((value) => value === '' || /^https?:\/\//i.test(value), '封面地址必须以 http(s):// 开头')
.optional()
.nullable(),
musicPlatform: z.string().trim().max(50, '音乐平台标识不能超过50个字符').optional().nullable(),
musicId: z.string().trim().max(200, '音乐 ID 不能超过200个字符').optional().nullable(),
bilibiliCid: z.string().trim().max(100, 'Bilibili CID 不能超过100个字符').optional().nullable(),
bilibiliPage: z.union([z.string(), z.number()]).optional().nullable(),
playUrl: z.string().trim().max(2000, '播放链接不能超过2000个字符').optional().nullable(),
durationSeconds: z.number().int().min(SONG_DURATION_MIN_SECONDS, '时长不能为负数').max(SONG_DURATION_MAX_SECONDS, '时长不能超过2小时').optional().nullable(),
submissionNote: z.string().trim().max(300, '备注留言不能超过300个字符').optional().nullable(),
submissionNotePublic: z.boolean().optional(),
preferredPlayTimeId: z.preprocess(
(value) => value === null || value === undefined || value === '' ? null : Number(value),
z.number().int().positive('播出时段 ID 无效').nullable()
).optional(),
cardCode: z.string().trim().max(100, 'CARD_CODE_TOO_LONG').optional().nullable(),
collaborators: z.array(z.union([z.string(), z.number()])).max(20, '联合投稿人不能超过20个').optional()
})
export async function requestSongForUser(event: any, user: SongRequestUser, body: any) {
const parsedBody = songRequestBodySchema.safeParse(body || {})
if (!parsedBody.success) {
const issues = parsedBody.error.issues || []
const cardCodeIssue = issues.find((issue) => String(issue.message).startsWith('CARD_CODE_'))
if (cardCodeIssue) {
throw createApiError(400, cardCodeIssue.message, 'Invalid request card')
}
throw createError({
statusCode: 400,
message: issues.length
? `请求参数验证失败:${issues.map((issue) => issue.message).join(', ')}`
: '请求参数验证失败'
})
}
const requestBody = parsedBody.data
try {
// 标准化后再比较,避免同一首歌因标点或空格差异绕过重复检查。
const normalizedTitle = normalizeForMatch(requestBody.title)
const normalizedArtist = normalizeForMatch(requestBody.artist)
const currentSemester = await getCurrentSemesterName()
const systemSettingsData = await getSystemSettingsCached()
const isAdmin = ['SUPER_ADMIN', 'ADMIN', 'SONG_ADMIN'].includes(user.role)
const restrictionPolicy = resolveSubmissionRestrictionPolicy(systemSettingsData)
const semesterRestrictionOn = restrictionPolicy.mode === 'semester' && !isAdmin
const isBilibili =
requestBody.musicPlatform === 'bilibili' ||
String(requestBody.musicId || '').startsWith('BV') ||
String(requestBody.musicId || '').startsWith('av')
// 仅在重复投稿限制开启且未配置冷却时长时,沿用同一学期内同一首歌只能投稿一次的规则;管理员始终豁免。
if (isBilibili && requestBody.musicId && semesterRestrictionOn) {
let fullMusicId = String(requestBody.musicId)
const bvId = fullMusicId.split(':')[0]
if (requestBody.bilibiliCid) {
const musicIdParts = [bvId, requestBody.bilibiliCid]
if (requestBody.bilibiliPage && Number(requestBody.bilibiliPage) > 1) {
musicIdParts.push(String(requestBody.bilibiliPage))
}
fullMusicId = musicIdParts.join(':')
}
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) {
throw createError({
statusCode: 400,
message: `《${requestBody.title}》已经在列表中,不能重复投稿`
})
}
} else if (semesterRestrictionOn) {
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) {
throw createError({
statusCode: 400,
message: `《${requestBody.title}》已经在列表中,不能重复投稿`
})
}
}
// 重复投稿限制:同一首歌 / 同一歌手在排期后 N 小时内不可再次投稿
if (restrictionPolicy.mode === 'window' && !isAdmin) {
const now = getServerDate()
const { sameSongHours, sameArtistHours, scope } = restrictionPolicy
const maxHours = Math.max(sameSongHours || 0, sameArtistHours || 0)
const cutoff = new Date(now.getTime() - maxHours * 3600000)
// 按 scope 构建 where 子句(显式短路,避免依赖 and(undefined) 的版本行为)
const scopeFilter = scope === 'self' ? eq(songs.requesterId, user.id) : undefined
const whereClause = and(
eq(schedules.isDraft, false),
or(
and(lte(schedules.playDate, now), gte(schedules.playDate, cutoff)),
and(
gt(schedules.playDate, now),
or(gte(schedules.publishedAt, cutoff), gte(schedules.createdAt, cutoff))
)
),
...(scopeFilter ? [scopeFilter] : [])
)
const scheduledSongs = await db
.select({
sPlayDate: schedules.playDate,
sCreatedAt: schedules.createdAt,
sPublishedAt: schedules.publishedAt,
songTitle: songs.title,
songArtist: songs.artist,
songRequesterId: songs.requesterId
})
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.where(whereClause)
for (const scheduled of scheduledSongs) {
const playDate = scheduled.sPlayDate instanceof Date ? scheduled.sPlayDate : new Date(scheduled.sPlayDate)
const createdAt = scheduled.sCreatedAt instanceof Date ? scheduled.sCreatedAt : new Date(scheduled.sCreatedAt)
const publishedAt = scheduled.sPublishedAt
? (scheduled.sPublishedAt instanceof Date ? scheduled.sPublishedAt : new Date(scheduled.sPublishedAt))
: null
// 已播放歌曲:窗口从播放时间起算;未播放歌曲:窗口从排期时间起算
const windowStart = playDate.getTime() <= now.getTime()
? playDate.getTime()
: (publishedAt || createdAt).getTime()
const songWindowMs = (windowStart + (sameSongHours || 0) * 3600000) - now.getTime()
const artistWindowMs = (windowStart + (sameArtistHours || 0) * 3600000) - now.getTime()
const songWindowActive = (sameSongHours || 0) > 0 && songWindowMs > 0
const artistWindowActive = (sameArtistHours || 0) > 0 && artistWindowMs > 0
if (!songWindowActive && !artistWindowActive) continue
const scheduledTitleNorm = normalizeForMatch(scheduled.songTitle || '')
const scheduledArtistNorm = normalizeForMatch(scheduled.songArtist || '')
if (songWindowActive && scheduledTitleNorm === normalizedTitle && scheduledArtistNorm === normalizedArtist) {
throw createApiError(
400,
SERVER_ERROR_CODES.SONG_RESTRICTION_SAME_SONG,
'同一首歌在排期后一段时间内不能重复投稿',
{ params: [sameSongHours, requestBody.title] }
)
}
if (artistWindowActive && scheduledArtistNorm === normalizedArtist) {
throw createApiError(
400,
SERVER_ERROR_CODES.SONG_RESTRICTION_SAME_ARTIST,
'同一歌手在排期后一段时间内不能重复投稿',
{ params: [sameArtistHours, scheduled.songArtist] }
)
}
}
}
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
throw createError({
statusCode: 403,
message: '投稿功能已关闭'
})
}
let hitRequestTime: any = null
if (systemSettingsData?.enableRequestTimeLimitation && !isAdmin) {
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'
}
if (effectiveLimit === 0) {
throw createError({
statusCode: 403,
message: '投稿功能已关闭'
})
}
}
// 强制使用点歌券仅在点歌券功能启用时生效
if (
systemSettingsData?.requireCardCodeForRequests &&
systemSettingsData?.enableCardCodeRequests &&
!isAdmin
) {
const providedCardCode = requestBody.cardCode ? requestBody.cardCode.trim().toUpperCase() : ''
if (!providedCardCode) {
throw createApiError(
403,
'CARD_CODE_REQUIRED_FOR_SITE',
'This site requires a valid request card to submit songs'
)
}
}
const isCardCodeEnabled = systemSettingsData?.enableCardCodeRequests === true
const excludeCardCodeRequestsFromLimit = isCardCodeLimitBypassActive(systemSettingsData)
if (requestBody.cardCode && requestBody.cardCode.trim() && !isCardCodeEnabled && !isAdmin) {
throw createApiError(400, 'CARD_CODE_DISABLED', 'Request card submissions are not enabled')
}
let preferredPlayTime = null
if (requestBody.preferredPlayTimeId) {
if (!systemSettingsData?.enablePlayTimeSelection) {
throw createError({
statusCode: 400,
message: '播出时段选择功能未启用'
})
}
const playTimeResult = await db
.select()
.from(playTimes)
.where(and(eq(playTimes.id, requestBody.preferredPlayTimeId), eq(playTimes.enabled, true)))
.limit(1)
preferredPlayTime = playTimeResult[0]
if (!preferredPlayTime) {
throw createError({
statusCode: 400,
message: '选择的播出时段不存在或未启用'
})
}
}
const rawSubmissionNote = requestBody.submissionNote || ''
const submissionNote =
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
// 公开留言审核:开关开启时投稿不立即公开,进入待审后由管理员通过
const noteRequiresApproval = systemSettingsData?.submissionNoteRequiresApproval === true
const wantsPublic = submissionNote !== null ? requestBody.submissionNotePublic !== false : false
const submissionNotePublic = noteRequiresApproval ? false : wantsPublic
// 仅用户勾选公开的留言进入审核,私密留言只供管理员查看
const submissionNotePublicStatus =
noteRequiresApproval && submissionNote !== null && wantsPublic
? SUBMISSION_NOTE_STATUS.PENDING
: null
const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = []
const song = await db.transaction(async (tx) => {
let providedCardCodeId: number | null = null
const providedCardCode = requestBody.cardCode ? requestBody.cardCode.trim().toUpperCase() : ''
if (providedCardCode) {
const codeRows = await tx
.select()
.from(cardCodes)
.where(eq(cardCodes.code, providedCardCode))
.limit(1)
const found = codeRows[0]
if (!found || found.status !== 'AVAILABLE') {
throw createApiError(
400,
'CARD_CODE_INVALID_OR_USED',
'Request card is invalid or already used'
)
}
const lockResult = await tx
.update(cardCodes)
.set({ status: 'LOCKED', lockedBy: user.id, lockedAt: new Date() })
.where(and(eq(cardCodes.id, found.id), eq(cardCodes.status, 'AVAILABLE')))
.returning()
if (lockResult.length === 0) {
throw createApiError(
400,
'CARD_CODE_LOCKED_OR_UNAVAILABLE',
'Request card is locked or unavailable'
)
}
providedCardCodeId = found.id
}
if (
systemSettingsData?.enableSubmissionLimit &&
!isAdmin &&
!(excludeCardCodeRequestsFromLimit && providedCardCodeId) &&
effectiveLimit &&
effectiveLimit > 0 &&
limitType
) {
// 同一用户的限额检查必须串行,否则并发请求可能读取到相同计数并同时越过上限。
await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, user.id))
.for('update')
if (
await isLimitReached(tx as any, user.id, limitType, effectiveLimit, {
excludeCardCodeRequests: excludeCardCodeRequestsFromLimit
})
) {
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),
latestRequestTime.expected > 0
? lt(requestTimes.accepted, latestRequestTime.expected)
: undefined
)
)
.returning()
if (updateResult.length === 0) {
throw createError({ statusCode: 403, message: '当前时段投稿名额已满' })
}
}
let finalMusicId = requestBody.musicId ? String(requestBody.musicId) : null
if (isBilibili) {
const bvId = finalMusicId?.split(':')[0]
if (bvId) {
const musicIdParts = [bvId]
if (requestBody.bilibiliCid) {
musicIdParts.push(requestBody.bilibiliCid)
if (requestBody.bilibiliPage && Number(requestBody.bilibiliPage) > 1) {
musicIdParts.push(String(requestBody.bilibiliPage))
}
}
finalMusicId = musicIdParts.join(':')
}
}
const songResult = await tx
.insert(songs)
.values({
title: requestBody.title,
artist: requestBody.artist,
requesterId: user.id,
preferredPlayTimeId: preferredPlayTime?.id || null,
semester: currentSemester,
cover: requestBody.cover || null,
musicPlatform: isBilibili ? 'bilibili' : requestBody.musicPlatform || null,
musicId: finalMusicId,
cardCodeId: providedCardCodeId || null,
playUrl: requestBody.playUrl || null,
durationSeconds: requestBody.durationSeconds || null,
submissionNote,
submissionNotePublic,
submissionNotePublicStatus,
hitRequestId: hitRequestTime?.id || null
})
.returning()
const newSong = songResult[0]
if (!newSong) {
throw createError({ statusCode: 500, message: '点歌失败,请稍后重试' })
}
if (
requestBody.collaborators &&
Array.isArray(requestBody.collaborators) &&
requestBody.collaborators.length > 0
) {
const collaboratorIds = requestBody.collaborators.map((id: any) => Number(id)) as number[]
const uniqueCollaboratorIds = [...new Set<number>(collaboratorIds)].filter(
(id) => !isNaN(id) && id !== user.id
)
if (uniqueCollaboratorIds.length > 0) {
const validUsers = await tx
.select({ id: users.id })
.from(users)
.where(inArray(users.id, uniqueCollaboratorIds))
const validUserIds = new Set(validUsers.map((item) => item.id))
for (const collaboratorId of uniqueCollaboratorIds) {
if (!validUserIds.has(collaboratorId)) 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]
if (!collab) continue
await tx.insert(collaborationLogs).values({
collaboratorId: collab.id,
action: 'INVITE',
operatorId: user.id,
ipAddress: getClientIP(event)
})
notificationsToSend.push({
userId: collaboratorId,
songId: newSong.id,
songTitle: newSong.title
})
} catch (err) {
console.error(`邀请用户 ${collaboratorId} 失败:`, err)
}
}
}
}
return newSong
})
// 投稿后立即补齐或校验歌曲时长(不阻塞请求响应)
const submitDuration = song.durationSeconds
const submitPlatform = song.musicPlatform
const submitMusicId = song.musicId
if (submitPlatform && submitMusicId) {
const durationValidationTask = (async () => {
try {
const decision = await reconcileSongDurationOnSubmit(song.id, submitPlatform, submitMusicId, submitDuration)
if (decision.outcome === 'fill' || decision.outcome === 'clear') {
await db.update(songs).set({ durationSeconds: decision.durationSeconds }).where(eq(songs.id, song.id))
if (decision.outcome === 'fill') {
console.log(`[投稿时长] #${song.id} 已补齐时长 ${decision.durationSeconds}s`)
}
}
} catch (err) {
console.error(`[投稿时长] 后台任务 #${song.id} 异常:`, err)
}
})()
if (typeof event.waitUntil === 'function') {
event.waitUntil(durationValidationTask)
} else {
durationValidationTask.catch((err) => console.error(`[投稿时长] 后台任务 #${song.id} 异常:`, err))
}
}
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
}
}