Files
BYW-VoiceHub/server/api/songs/index.get.ts
T

512 lines
19 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 { createError, defineEventHandler, getQuery } from 'h3'
import { client } from '~/drizzle/db'
import { formatDateTime } from '~/utils/timeUtils'
import { getServerTimestamp } from '~~/server/utils/serverTime'
import { SUBMISSION_NOTE_STATUS } from '~~/server/config/constants'
import {
maskSongsInfo,
stripAnonymousSongIdentifiers,
type MaskableSong
} from '~~/server/utils/studentMask'
interface SongResponse extends MaskableSong {
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
cardCodeId?: number | null
durationSeconds?: number | 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
submissionNotePublicStatus?: string | null
replayRequestId?: number | null
}
const formatDisplayName = (
user: { name?: string | null; grade?: string | null; class?: string | null },
nameCount = 1,
gradeCount = 1
) => {
if (!user?.name) return '未知用户'
if (nameCount <= 1 || !user.grade) return user.name
if (gradeCount > 1 && user.class) return `${user.name}(${user.grade} ${user.class})`
return `${user.name}(${user.grade})`
}
const calculateReplayCooldown = (status?: string | null, updatedAt?: Date | string | null) => {
if (status !== 'REJECTED' || !updatedAt) return undefined
const cooldownMs = 24 * 60 * 60 * 1000
const remainingMs = cooldownMs - (getServerTimestamp() - new Date(updatedAt).getTime())
return remainingMs > 0 ? Math.ceil(remainingMs / (60 * 60 * 1000)) : 0
}
const isSchemaCompatibilityError = (error: unknown) => {
const value = error as { code?: string; cause?: { code?: string } } | null
const code = String(value?.code || value?.cause?.code || '')
return ['42P01', '42703'].includes(code)
}
const loadBasicSongs = async (
client: any,
semester: string,
grade: string,
search: string,
user: any,
isAdmin: boolean,
options: { scope?: string; sortBy?: string; sortOrder?: string } = {}
) => {
const conditions: string[] = []
const params: any[] = []
const addParam = (value: any) => {
params.push(value)
return `$${params.length}`
}
if (search) {
const value = addParam(`%${search}%`)
conditions.push(`(s.title ILIKE ${value} OR s.artist ILIKE ${value})`)
}
if (semester) conditions.push(`s.semester = ${addParam(semester)}`)
if (grade) conditions.push(`u.grade = ${addParam(grade)}`)
if (options.scope === 'mine' && user) conditions.push(`s."requesterId" = ${addParam(user.id)}`)
const orderColumn =
options.sortBy === 'title'
? 's.title'
: options.sortBy === 'artist'
? 's.artist'
: 's."createdAt"'
const orderDirection = options.sortOrder === 'asc' ? 'ASC' : 'DESC'
// 兜底查询只依赖初始版本就存在的 Song/User 字段。
// 可选迁移(排期、卡密、重播等)缺失时,歌曲列表仍应可用。
const rows = await client.unsafe(`
SELECT s.id, s.title, s.artist, s.played, s."playedAt", s.semester,
s."createdAt", s."updatedAt", s.cover, s."musicPlatform", s."musicId",
u.id AS "requesterId", u.name AS "requesterName", u.grade AS "requesterGrade", u.class AS "requesterClass",
COUNT(*) OVER()::int AS total
FROM "Song" s
LEFT JOIN "User" u ON u.id = s."requesterId"
${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
ORDER BY ${orderColumn} ${orderDirection}
`, params)
const hideStudentInfo = true
const songs = rows.map((row: any) => ({
id: Number(row.id), title: row.title, artist: row.artist,
requester: formatDisplayName({ name: row.requesterName, grade: row.requesterGrade, class: row.requesterClass }),
requesterId: row.requesterId ? Number(row.requesterId) : undefined,
requesterGrade: row.requesterGrade || null, requesterClass: row.requesterClass || null,
collaborators: [], voteCount: 0, played: row.played === true, playedAt: row.playedAt || null,
semester: row.semester || null, createdAt: row.createdAt, updatedAt: row.updatedAt,
requestedAt: formatDateTime(row.createdAt), scheduled: false,
cover: row.cover || null, musicPlatform: row.musicPlatform || null, musicId: row.musicId || null,
cardCodeId: null,
durationSeconds: null, playUrl: null,
replayRequested: false, replayRequestCount: 0, isReplay: false, replayRequesters: [],
hasSubmissionNote: false, submissionNote: null, submissionNotePublic: false,
preferredPlayTimeId: null,
})) as SongResponse[]
if (hideStudentInfo && !isAdmin) maskSongsInfo(songs)
if (!user) songs.forEach(stripAnonymousSongIdentifiers)
return { success: true, data: { songs, total: Number(rows[0]?.total || 0) } }
}
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const search = String(query.search || '').trim()
const semester = String(query.semester || '').trim()
const grade = String(query.grade || '').trim()
const scope = String(query.scope || '').trim()
const sortBy = String(query.sortBy || 'createdAt')
const sortOrder = String(query.sortOrder || 'desc') === 'asc' ? 'asc' : 'desc'
const user = event.context.user || null
const isAdmin = Boolean(user && ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role))
const params: any[] = [user?.id ?? null]
const conditions: string[] = []
const addParam = (value: any) => {
params.push(value)
return `$${params.length}`
}
if (search) {
const searchParam = addParam(`%${search}%`)
conditions.push(`(s.title ILIKE ${searchParam} OR s.artist ILIKE ${searchParam})`)
}
if (semester) conditions.push(`s.semester = ${addParam(semester)}`)
if (grade) conditions.push(`u.grade = ${addParam(grade)}`)
if (scope === 'mine' && user) conditions.push(`s.\"requesterId\" = $1`)
const orderColumn =
sortBy === 'title'
? 's.title'
: sortBy === 'artist'
? 's.artist'
: sortBy === 'votes'
? '\"voteCount\"'
: 's.\"createdAt\"'
const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const orderSql =
sortBy === 'votes'
? `${orderColumn} ${sortOrder}, s.\"createdAt\" ASC`
: `${orderColumn} ${sortOrder}`
const baseQuery = `
WITH
user_name_counts AS (
SELECT name, COUNT(*)::int AS name_count
FROM "User"
WHERE name IS NOT NULL
GROUP BY name
),
user_grade_counts AS (
SELECT name, grade, COUNT(*)::int AS grade_count
FROM "User"
WHERE name IS NOT NULL
GROUP BY name, grade
),
vote_counts AS (
SELECT "songId", COUNT(*)::int AS vote_count
FROM "Vote"
GROUP BY "songId"
),
current_user_votes AS (
SELECT "songId"
FROM "Vote"
WHERE $1::int IS NOT NULL AND "userId" = $1
GROUP BY "songId"
),
published_schedule AS (
SELECT DISTINCT ON ("songId") "songId", "playDate", played
FROM "Schedule"
WHERE "isDraft" = false
ORDER BY "songId", "playDate" DESC
),
replay_counts AS (
SELECT song_id, COUNT(*)::int AS replay_count
FROM song_replay_requests
WHERE status = 'PENDING'
GROUP BY song_id
),
current_user_replay AS (
SELECT DISTINCT ON (song_id) song_id, status, updated_at
FROM song_replay_requests
WHERE $1::int IS NOT NULL AND user_id = $1
ORDER BY song_id, created_at DESC
),
latest_replay AS (
SELECT DISTINCT ON (song_id) song_id, id
FROM song_replay_requests
WHERE status IN ('PENDING', 'FULFILLED')
ORDER BY song_id, created_at DESC
),
accepted_collaborators AS (
SELECT
sc.song_id,
jsonb_agg(
jsonb_build_object(
'id', u.id,
'name', u.name,
'grade', u.grade,
'class', u.class,
'nameCount', COALESCE(unc.name_count, 1),
'gradeCount', COALESCE(ugc.grade_count, 1)
)
ORDER BY sc.created_at
) AS collaborators
FROM song_collaborators sc
INNER JOIN "User" u ON u.id = sc.user_id
LEFT JOIN user_name_counts unc ON unc.name = u.name
LEFT JOIN user_grade_counts ugc
ON ugc.name = u.name AND ugc.grade IS NOT DISTINCT FROM u.grade
WHERE sc.status = 'ACCEPTED'
GROUP BY sc.song_id
),
ranked_replay_requesters AS (
SELECT
rr.song_id,
u.id,
u.name,
u.grade,
u.class,
COALESCE(unc.name_count, 1) AS name_count,
COALESCE(ugc.grade_count, 1) AS grade_count,
rr.created_at,
ROW_NUMBER() OVER (PARTITION BY rr.song_id ORDER BY rr.created_at DESC) AS position
FROM song_replay_requests rr
INNER JOIN "User" u ON u.id = rr.user_id
LEFT JOIN user_name_counts unc ON unc.name = u.name
LEFT JOIN user_grade_counts ugc
ON ugc.name = u.name AND ugc.grade IS NOT DISTINCT FROM u.grade
WHERE rr.status = 'PENDING'
),
replay_requesters AS (
SELECT
song_id,
jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'grade', grade,
'class', class,
'nameCount', name_count,
'gradeCount', grade_count,
'createdAt', created_at
)
ORDER BY created_at DESC
) FILTER (WHERE position <= 3) AS requesters
FROM ranked_replay_requesters
GROUP BY song_id
)
SELECT
s.id,
s.title,
s.artist,
s.played,
s."playedAt",
s.semester,
s."createdAt",
s."updatedAt",
s.cover,
s."musicPlatform",
s."musicId",
s."cardCodeId",
s."durationSeconds",
s."playUrl",
s."submissionNote",
s."submissionNotePublic",
s."submissionNotePublicStatus",
s."preferredPlayTimeId",
u.id AS "requesterId",
u.name AS "requesterName",
u.grade AS "requesterGrade",
u.class AS "requesterClass",
COALESCE(unc.name_count, 1) AS "requesterNameCount",
COALESCE(ugc.grade_count, 1) AS "requesterGradeCount",
pt.id AS "playTimeId",
pt.name AS "playTimeName",
pt."startTime" AS "playTimeStart",
pt."endTime" AS "playTimeEnd",
pt.enabled AS "playTimeEnabled",
COALESCE(vc.vote_count, 0) AS "voteCount",
(cuv."songId" IS NOT NULL) AS voted,
ps."playDate" AS "scheduleDate",
ps.played AS "schedulePlayed",
(ps."songId" IS NOT NULL) AS scheduled,
COALESCE(rc.replay_count, 0) AS "replayRequestCount",
cur.status AS "replayRequestStatus",
cur.updated_at AS "replayRequestUpdatedAt",
(cur.song_id IS NOT NULL) AS "replayRequested",
lr.id AS "replayRequestId",
COALESCE(ac.collaborators, '[]'::jsonb) AS collaborators,
COALESCE(rr.requesters, '[]'::jsonb) AS "replayRequesters",
COALESCE(
(SELECT "hideStudentInfo" FROM "SystemSettings" LIMIT 1),
true
) AS "hideStudentInfo",
COUNT(*) OVER()::int AS total
FROM "Song" s
LEFT JOIN "User" u ON u.id = s."requesterId"
LEFT JOIN user_name_counts unc ON unc.name = u.name
LEFT JOIN user_grade_counts ugc
ON ugc.name = u.name AND ugc.grade IS NOT DISTINCT FROM u.grade
LEFT JOIN "PlayTime" pt ON pt.id = s."preferredPlayTimeId"
LEFT JOIN vote_counts vc ON vc."songId" = s.id
LEFT JOIN current_user_votes cuv ON cuv."songId" = s.id
LEFT JOIN published_schedule ps ON ps."songId" = s.id
LEFT JOIN replay_counts rc ON rc.song_id = s.id
LEFT JOIN current_user_replay cur ON cur.song_id = s.id
LEFT JOIN latest_replay lr ON lr.song_id = s.id
LEFT JOIN accepted_collaborators ac ON ac.song_id = s.id
LEFT JOIN replay_requesters rr ON rr.song_id = s.id
${whereSql}
ORDER BY ${orderSql}
`
const baseRows = await client.unsafe(baseQuery, params)
const shouldHideStudentInfo = baseRows[0]?.hideStudentInfo ?? true
const formattedSongs: SongResponse[] = baseRows.map((row: any) => {
const collaborators = Array.isArray(row.collaborators)
? row.collaborators.map((collaborator: any) => ({
id: collaborator.id,
name: collaborator.name,
displayName: formatDisplayName(
collaborator,
Number(collaborator.nameCount),
Number(collaborator.gradeCount)
),
grade: collaborator.grade,
class: collaborator.class
}))
: []
const replayRequesters = Array.isArray(row.replayRequesters)
? row.replayRequesters.map((requester: any) => ({
id: requester.id,
name: requester.name || '未知用户',
displayName: formatDisplayName(
requester,
Number(requester.nameCount),
Number(requester.gradeCount)
),
grade: requester.grade,
class: requester.class,
createdAt: requester.createdAt
}))
: []
const isRequester = Boolean(user && Number(row.requesterId) === user.id)
// 公开留言审核:待审/已拒绝的不对普通用户公开(管理员与投稿人始终可见完整备注与状态)
const notePublic =
row.submissionNotePublic === true &&
row.submissionNotePublicStatus !== SUBMISSION_NOTE_STATUS.PENDING &&
row.submissionNotePublicStatus !== SUBMISSION_NOTE_STATUS.REJECTED
const canViewSubmissionNote =
Boolean(row.submissionNote) && (notePublic || Boolean(user && (isAdmin || isRequester)))
const replayRequestCount = Number(row.replayRequestCount || 0)
const song: SongResponse = {
id: Number(row.id),
title: row.title,
artist: row.artist,
requester: formatDisplayName(
{
name: row.requesterName,
grade: row.requesterGrade,
class: row.requesterClass
},
Number(row.requesterNameCount),
Number(row.requesterGradeCount)
),
requesterId: row.requesterId ? Number(row.requesterId) : undefined,
requesterGrade: row.requesterGrade || null,
requesterClass: row.requesterClass || null,
collaborators,
voteCount: Number(row.voteCount || 0),
played: row.played === true,
playedAt: row.playedAt || null,
semester: row.semester || null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
requestedAt: formatDateTime(row.createdAt),
scheduled: row.scheduled === true,
cover: row.cover || null,
musicPlatform: row.musicPlatform || null,
musicId: row.musicId || null,
cardCodeId: row.cardCodeId ? Number(row.cardCodeId) : null,
durationSeconds: row.durationSeconds ? Number(row.durationSeconds) : null,
playUrl: row.playUrl || null,
replayRequested: user ? row.replayRequested === true : false,
replayRequestCount,
isReplay: replayRequestCount > 0,
replayRequesters,
hasSubmissionNote: canViewSubmissionNote,
submissionNote: canViewSubmissionNote ? row.submissionNote : null,
submissionNotePublic: canViewSubmissionNote ? row.submissionNotePublic === true : false,
submissionNotePublicStatus: canViewSubmissionNote ? (row.submissionNotePublicStatus || null) : null,
replayRequestId: row.replayRequestId ? Number(row.replayRequestId) : null,
preferredPlayTimeId: row.preferredPlayTimeId ? Number(row.preferredPlayTimeId) : null
}
if (user) {
song.voted = row.voted === true
song.replayRequestStatus = row.replayRequestStatus || undefined
song.replayRequestUpdatedAt = row.replayRequestUpdatedAt || undefined
song.replayRequestCooldownRemaining = calculateReplayCooldown(
row.replayRequestStatus,
row.replayRequestUpdatedAt
)
}
if (row.scheduleDate) {
song.scheduleDate = formatDateTime(row.scheduleDate)
song.schedulePlayed = row.schedulePlayed === true
}
if (row.playTimeId) {
song.preferredPlayTime = {
id: Number(row.playTimeId),
name: row.playTimeName,
startTime: row.playTimeStart,
endTime: row.playTimeEnd,
enabled: row.playTimeEnabled === true
}
}
return song
})
if (shouldHideStudentInfo && !isAdmin) {
maskSongsInfo(formattedSongs)
}
if (!user) {
formattedSongs.forEach(stripAnonymousSongIdentifiers)
}
return {
success: true,
data: {
songs: formattedSongs,
total: baseRows.length > 0 ? Number(baseRows[0]?.total || 0) : 0
}
}
} catch (error: any) {
console.error('[Songs API] 获取歌曲列表失败:', error)
if (error.statusCode) throw error
if (isSchemaCompatibilityError(error)) {
try {
const fallbackQuery = getQuery(event)
const fallbackSearch = String(fallbackQuery.search || '').trim()
const fallbackSemester = String(fallbackQuery.semester || '').trim()
const fallbackGrade = String(fallbackQuery.grade || '').trim()
const fallbackScope = String(fallbackQuery.scope || '').trim()
const fallbackSortBy = String(fallbackQuery.sortBy || 'createdAt')
const fallbackSortOrder = String(fallbackQuery.sortOrder || 'desc')
const fallbackUser = event.context.user || null
const fallbackIsAdmin = Boolean(fallbackUser && ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(fallbackUser.role))
return await loadBasicSongs(client, fallbackSemester, fallbackGrade, fallbackSearch, fallbackUser, fallbackIsAdmin, {
scope: fallbackScope,
sortBy: fallbackSortBy,
sortOrder: fallbackSortOrder
})
} catch (fallbackError) {
console.error('[Songs API] 基础歌曲查询也失败:', fallbackError)
}
}
const isDbError = ['ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT'].some((code) =>
String(error?.code || error?.message || '').includes(code)
)
throw createError({
statusCode: isDbError ? 503 : 500,
message: isDbError ? '数据库连接暂时不可用,请稍后重试' : '获取歌曲列表失败,请稍后重试'
})
}
})