469 lines
18 KiB
TypeScript
469 lines
18 KiB
TypeScript
import { createError, defineEventHandler, getQuery } from 'h3'
|
||
import { client } from '~/drizzle/db'
|
||
import { formatDateTime, getBeijingTimeISOString } from '~/utils/timeUtils'
|
||
import { SUBMISSION_NOTE_STATUS } from '~~/server/config/constants'
|
||
import { maskPublicScheduleData,
|
||
stripAnonymousSongIdentifiersFromSchedules,
|
||
type PublicScheduleItem
|
||
} from '../../utils/studentMask'
|
||
import { verifyUserAuth } from '../../utils/auth'
|
||
|
||
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 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 loadBasicSchedules = async (client: any, semester: string, user: any, isAdmin: boolean) => {
|
||
const params: any[] = []
|
||
const conditions: string[] = []
|
||
const scheduleDraftColumn = await client.unsafe(`
|
||
SELECT 1
|
||
FROM information_schema.columns
|
||
WHERE table_schema = current_schema()
|
||
AND table_name = 'Schedule'
|
||
AND column_name = 'isDraft'
|
||
LIMIT 1
|
||
`)
|
||
if (scheduleDraftColumn.length > 0) conditions.push('sch."isDraft" = false')
|
||
if (semester) conditions.push('s.semester = $1')
|
||
if (semester) params.push(semester)
|
||
const rows = await client.unsafe(`
|
||
SELECT
|
||
sch.id,
|
||
to_char(sch."playDate", 'YYYY-MM-DD') AS "playDate",
|
||
sch.sequence,
|
||
sch.played AS "schedulePlayed",
|
||
sch."playTimeId",
|
||
s.id AS "songId",
|
||
s.title,
|
||
s.artist,
|
||
s.played AS "songPlayed",
|
||
s.cover,
|
||
s."musicPlatform",
|
||
s."musicId",
|
||
s.semester,
|
||
s."requesterId",
|
||
s."createdAt",
|
||
u.name AS "requesterName",
|
||
u.grade AS "requesterGrade",
|
||
u.class AS "requesterClass",
|
||
pt.id AS "playTimeRecordId",
|
||
pt.name AS "playTimeName",
|
||
pt."startTime" AS "playTimeStart",
|
||
pt."endTime" AS "playTimeEnd",
|
||
pt.enabled AS "playTimeEnabled",
|
||
COALESCE((SELECT "hideStudentInfo" FROM "SystemSettings" LIMIT 1), true) AS "hideStudentInfo"
|
||
FROM "Schedule" sch
|
||
INNER JOIN "Song" s ON s.id = sch."songId"
|
||
LEFT JOIN "User" u ON u.id = s."requesterId"
|
||
LEFT JOIN "PlayTime" pt ON pt.id = sch."playTimeId"
|
||
${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
|
||
ORDER BY sch."playDate", sch.sequence
|
||
`, params)
|
||
const hideStudentInfo = rows[0]?.hideStudentInfo ?? true
|
||
const schedules = rows.map((row: any) => ({
|
||
id: Number(row.id),
|
||
playDate: row.playDate,
|
||
sequence: Number(row.sequence || 1),
|
||
replayRequestId: null,
|
||
played: row.schedulePlayed === true,
|
||
playTimeId: row.playTimeId ? Number(row.playTimeId) : null,
|
||
playTime: row.playTimeRecordId ? {
|
||
id: Number(row.playTimeRecordId),
|
||
name: row.playTimeName,
|
||
startTime: row.playTimeStart,
|
||
endTime: row.playTimeEnd,
|
||
enabled: row.playTimeEnabled === true
|
||
} : null,
|
||
song: {
|
||
id: Number(row.songId),
|
||
title: row.title,
|
||
artist: row.artist,
|
||
requester: formatDisplayName({ name: row.requesterName, grade: row.requesterGrade, class: row.requesterClass }),
|
||
requesterGrade: row.requesterGrade || null,
|
||
requesterClass: row.requesterClass || null,
|
||
collaborators: [],
|
||
voteCount: 0,
|
||
played: row.songPlayed === true,
|
||
cover: row.cover || null,
|
||
cardCodeId: null,
|
||
usedCardCode: false,
|
||
musicPlatform: row.musicPlatform || null,
|
||
musicId: row.musicId || null,
|
||
durationSeconds: null,
|
||
playUrl: null,
|
||
semester: row.semester || null,
|
||
requestedAt: row.createdAt ? formatDateTime(row.createdAt) : null,
|
||
hasSubmissionNote: false,
|
||
submissionNote: null,
|
||
submissionNotePublic: false,
|
||
preferredPlayTimeId: null,
|
||
requesterId: row.requesterId ? Number(row.requesterId) : null,
|
||
replayRequestCount: 0,
|
||
replayRequesters: [],
|
||
isReplay: false
|
||
}
|
||
})) as PublicScheduleItem[]
|
||
if (hideStudentInfo && !isAdmin) maskPublicScheduleData(schedules)
|
||
if (!user) stripAnonymousSongIdentifiersFromSchedules(schedules)
|
||
return schedules
|
||
}
|
||
|
||
export default defineEventHandler(async (event) => {
|
||
let authenticatedUser: any = null
|
||
let authenticatedIsAdmin = false
|
||
try {
|
||
const query = getQuery(event)
|
||
const semester = String(query.semester || '').trim()
|
||
|
||
const authResult = event.context.authRejected
|
||
? { success: false, user: null }
|
||
: await verifyUserAuth(event)
|
||
const user = authResult.success ? authResult.user : null
|
||
const isAdmin = Boolean(user && ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role))
|
||
authenticatedUser = user
|
||
authenticatedIsAdmin = isAdmin
|
||
|
||
const params: any[] = []
|
||
const semesterCondition = semester ? 'AND s.semester = $1' : ''
|
||
if (semester) params.push(semester)
|
||
|
||
// 排期按 YYYY-MM-DD 存储,范围必须按北京时间自然日计算,不能使用当前时刻加减 24 小时。
|
||
const scheduleRangeResult = await client.unsafe(
|
||
`SELECT "scheduleDaysBeforeEnabled", "scheduleDaysBefore", "scheduleDaysAfterEnabled", "scheduleDaysAfter" FROM "SystemSettings" LIMIT 1`
|
||
)
|
||
const storedDaysBefore = Number(scheduleRangeResult[0]?.scheduleDaysBefore)
|
||
const storedDaysAfter = Number(scheduleRangeResult[0]?.scheduleDaysAfter)
|
||
// 兼容历史脏数据,异常值不能导致范围意外扩大或生成无效日期。
|
||
const daysBefore = Number.isInteger(storedDaysBefore) && storedDaysBefore >= 1 && storedDaysBefore <= 730 ? storedDaysBefore : 1
|
||
const daysAfter = Number.isInteger(storedDaysAfter) && storedDaysAfter >= 1 && storedDaysAfter <= 730 ? storedDaysAfter : 1
|
||
const beforeEnabled = scheduleRangeResult[0]?.scheduleDaysBeforeEnabled === true
|
||
const afterEnabled = scheduleRangeResult[0]?.scheduleDaysAfterEnabled === true
|
||
let dateRangeCondition = ''
|
||
if (!isAdmin && (beforeEnabled || afterEnabled)) {
|
||
const today = new Date(`${getBeijingTimeISOString().slice(0, 10)}T00:00:00.000Z`)
|
||
if (beforeEnabled) {
|
||
const minDate = new Date(today.getTime() - daysBefore * 86400000).toISOString()
|
||
dateRangeCondition += ' AND sch."playDate" >= $' + (params.length + 1)
|
||
params.push(minDate)
|
||
}
|
||
if (afterEnabled) {
|
||
const maxDate = new Date(today.getTime() + (daysAfter + 1) * 86400000).toISOString()
|
||
dateRangeCondition += ' AND sch."playDate" < $' + (params.length + 1)
|
||
params.push(maxDate)
|
||
}
|
||
}
|
||
|
||
const schedulesQuery = `
|
||
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"
|
||
),
|
||
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
|
||
),
|
||
replay_counts AS (
|
||
SELECT song_id, COUNT(DISTINCT user_id)::int AS replay_count
|
||
FROM song_replay_requests
|
||
WHERE status IN ('PENDING', 'FULFILLED')
|
||
GROUP BY song_id
|
||
),
|
||
replay_metadata AS (
|
||
SELECT
|
||
rr.id,
|
||
rr.user_id,
|
||
rr.submission_note,
|
||
rr.submission_note_public,
|
||
rr.submission_note_public_status,
|
||
rr.preferred_play_time_id
|
||
FROM song_replay_requests rr
|
||
),
|
||
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.status,
|
||
rr.created_at,
|
||
ROW_NUMBER() OVER (PARTITION BY rr.song_id ORDER BY rr.created_at DESC) AS position
|
||
FROM (
|
||
-- insert-only 模型下同一用户可能有多条申请,每人只取最新一条
|
||
SELECT DISTINCT ON (song_id, user_id) song_id, user_id, status, created_at
|
||
FROM song_replay_requests
|
||
WHERE status IN ('PENDING', 'FULFILLED')
|
||
ORDER BY song_id, user_id, created_at DESC
|
||
) 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
|
||
),
|
||
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,
|
||
'status', status,
|
||
'createdAt', created_at
|
||
)
|
||
ORDER BY created_at DESC
|
||
) FILTER (WHERE position <= 5) AS requesters
|
||
FROM ranked_replay_requesters
|
||
GROUP BY song_id
|
||
)
|
||
SELECT
|
||
sch.id,
|
||
to_char(sch."playDate", 'YYYY-MM-DD') AS "playDate",
|
||
sch.sequence,
|
||
sch.replay_request_id AS "replayRequestId",
|
||
sch.played AS "schedulePlayed",
|
||
sch."playTimeId",
|
||
s.id AS "songId",
|
||
s.title,
|
||
s.artist,
|
||
s.played AS "songPlayed",
|
||
s.cover,
|
||
s."musicPlatform",
|
||
s."musicId",
|
||
s."durationSeconds",
|
||
s."playUrl",
|
||
s.semester,
|
||
s."requesterId",
|
||
s."cardCodeId" IS NOT NULL AS "usedCardCode",
|
||
s."createdAt",
|
||
s."submissionNote",
|
||
s."submissionNotePublic",
|
||
CASE WHEN rm.id IS NOT NULL THEN rm.submission_note ELSE s."submissionNote" END AS "effectiveSubmissionNote",
|
||
CASE WHEN rm.id IS NOT NULL THEN COALESCE(rm.submission_note_public, false) ELSE s."submissionNotePublic" END AS "effectiveSubmissionNotePublic",
|
||
CASE WHEN rm.id IS NOT NULL THEN rm.submission_note_public_status ELSE s."submissionNotePublicStatus" END AS "effectiveSubmissionNotePublicStatus",
|
||
CASE WHEN rm.id IS NOT NULL THEN rm.preferred_play_time_id ELSE s."preferredPlayTimeId" END AS "effectivePlayTimeId",
|
||
rm.user_id AS "replayRequesterUserId",
|
||
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 "playTimeRecordId",
|
||
pt.name AS "playTimeName",
|
||
pt."startTime" AS "playTimeStart",
|
||
pt."endTime" AS "playTimeEnd",
|
||
pt.enabled AS "playTimeEnabled",
|
||
COALESCE(vc.vote_count, 0) AS "voteCount",
|
||
COALESCE(ac.collaborators, '[]'::jsonb) AS collaborators,
|
||
COALESCE(rc.replay_count, 0) AS "replayRequestCount",
|
||
COALESCE(rr.requesters, '[]'::jsonb) AS "replayRequesters",
|
||
COALESCE(
|
||
(SELECT "hideStudentInfo" FROM "SystemSettings" LIMIT 1),
|
||
true
|
||
) AS "hideStudentInfo"
|
||
FROM "Schedule" sch
|
||
INNER JOIN "Song" s ON s.id = sch."songId"
|
||
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 = sch."playTimeId"
|
||
LEFT JOIN vote_counts vc ON vc."songId" = s.id
|
||
LEFT JOIN accepted_collaborators ac ON ac.song_id = s.id
|
||
LEFT JOIN replay_counts rc ON rc.song_id = s.id
|
||
LEFT JOIN replay_requesters rr ON rr.song_id = s.id
|
||
LEFT JOIN replay_metadata rm ON rm.id = sch.replay_request_id
|
||
WHERE sch."isDraft" = false
|
||
${semesterCondition}
|
||
${dateRangeCondition}
|
||
ORDER BY sch."playDate", sch.sequence
|
||
`
|
||
|
||
const rows = await client.unsafe(schedulesQuery, params)
|
||
const shouldHideStudentInfo = rows[0]?.hideStudentInfo ?? true
|
||
|
||
const formattedSchedules = rows.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,
|
||
status: requester.status
|
||
}))
|
||
: []
|
||
|
||
const linkedReplayRequestId = row.replayRequestId ? Number(row.replayRequestId) : null
|
||
const effectiveSubmissionNote = row.effectiveSubmissionNote
|
||
const effectiveSubmissionNotePublic = row.effectiveSubmissionNotePublic === true
|
||
// 公开留言审核:待审/已拒绝的不对普通用户公开(管理员与投稿人始终可见)
|
||
const effectiveNotePublic =
|
||
effectiveSubmissionNotePublic &&
|
||
row.effectiveSubmissionNotePublicStatus !== SUBMISSION_NOTE_STATUS.PENDING &&
|
||
row.effectiveSubmissionNotePublicStatus !== SUBMISSION_NOTE_STATUS.REJECTED
|
||
const effectivePlayTimeId = row.effectivePlayTimeId ? Number(row.effectivePlayTimeId) : null
|
||
// 备注可见性主体:绑定重播申请时为申请人,否则为歌曲投稿人
|
||
const noteOwnerId =
|
||
linkedReplayRequestId !== null && row.replayRequesterUserId
|
||
? Number(row.replayRequesterUserId)
|
||
: row.requesterId
|
||
? Number(row.requesterId)
|
||
: null
|
||
const isNoteOwner = Boolean(user && noteOwnerId !== null && noteOwnerId === user.id)
|
||
const canViewSubmissionNote =
|
||
Boolean(effectiveSubmissionNote) &&
|
||
(effectiveNotePublic || Boolean(user && (isAdmin || isNoteOwner)))
|
||
const replayRequestCount = Number(row.replayRequestCount || 0)
|
||
|
||
return {
|
||
id: Number(row.id),
|
||
// playDate 已在 SQL 层用 to_char 格式化为 YYYY-MM-DD,避免时区解析偏移
|
||
playDate: row.playDate,
|
||
sequence: Number(row.sequence || 1),
|
||
replayRequestId: linkedReplayRequestId,
|
||
played: row.schedulePlayed === true,
|
||
playTimeId: row.playTimeId ? Number(row.playTimeId) : null,
|
||
playTime: row.playTimeRecordId
|
||
? {
|
||
id: Number(row.playTimeRecordId),
|
||
name: row.playTimeName,
|
||
startTime: row.playTimeStart,
|
||
endTime: row.playTimeEnd,
|
||
enabled: row.playTimeEnabled === true
|
||
}
|
||
: null,
|
||
song: {
|
||
id: Number(row.songId),
|
||
title: row.title,
|
||
artist: row.artist,
|
||
requester: formatDisplayName(
|
||
{
|
||
name: row.requesterName,
|
||
grade: row.requesterGrade,
|
||
class: row.requesterClass
|
||
},
|
||
Number(row.requesterNameCount),
|
||
Number(row.requesterGradeCount)
|
||
),
|
||
requesterGrade: row.requesterGrade || null,
|
||
requesterClass: row.requesterClass || null,
|
||
collaborators,
|
||
voteCount: Number(row.voteCount || 0),
|
||
played: row.songPlayed === true,
|
||
cover: row.cover || null,
|
||
cardCodeId: null,
|
||
usedCardCode: row.usedCardCode === true,
|
||
musicPlatform: row.musicPlatform || null,
|
||
musicId: row.musicId || null,
|
||
durationSeconds: row.durationSeconds || null,
|
||
playUrl: row.playUrl || null,
|
||
semester: row.semester || null,
|
||
requestedAt: row.createdAt ? formatDateTime(row.createdAt) : null,
|
||
hasSubmissionNote: canViewSubmissionNote,
|
||
submissionNote: canViewSubmissionNote ? effectiveSubmissionNote : null,
|
||
submissionNotePublic: canViewSubmissionNote ? effectiveSubmissionNotePublic : false,
|
||
preferredPlayTimeId: effectivePlayTimeId,
|
||
requesterId: row.requesterId ? Number(row.requesterId) : null,
|
||
replayRequestCount,
|
||
replayRequesters,
|
||
isReplay: linkedReplayRequestId !== null
|
||
}
|
||
}
|
||
}) as PublicScheduleItem[]
|
||
|
||
if (shouldHideStudentInfo && !isAdmin) {
|
||
maskPublicScheduleData(formattedSchedules)
|
||
}
|
||
if (!user) {
|
||
stripAnonymousSongIdentifiersFromSchedules(formattedSchedules)
|
||
}
|
||
|
||
return formattedSchedules
|
||
} catch (error: any) {
|
||
console.error('获取公共排期失败:', error)
|
||
if (error.statusCode) throw error
|
||
if (isSchemaCompatibilityError(error)) {
|
||
try {
|
||
const fallbackSemester = String(getQuery(event).semester || '').trim()
|
||
return await loadBasicSchedules(client, fallbackSemester, authenticatedUser, authenticatedIsAdmin)
|
||
} catch (fallbackError) {
|
||
console.error('基础排期兜底查询失败:', fallbackError)
|
||
}
|
||
}
|
||
throw createError({
|
||
statusCode: 500,
|
||
message: error.message || '获取排期数据失败'
|
||
})
|
||
}
|
||
})
|