512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
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 ? '数据库连接暂时不可用,请稍后重试' : '获取歌曲列表失败,请稍后重试'
|
||
})
|
||
}
|
||
})
|