feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
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 || '获取排期数据失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user