Files
Henry_Du bd8df1f026
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 6m31s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s
feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
- 新增 /api/admin/songs/batch-reject.post 批量驳回接口(事务清排期/投票/扣接纳数/黑名单去重/异步通知)
- requireSongAdmin 三角色校验 + createApiError 统一错误包装
- scheduleDaysBefore/After 四字段排期可见范围限制(开放 API 与公开排期接口三处缓存路径 + DB 条件过滤)
- submissionNoteAutoModeration 基于黑名单 KEYWORD/LANGUAGE/GENRE 自动判定留言 publicStatus,命中自动关闭 submissionNotePublic
- 更新 songs/request.post 与 songs/[id]/update.put 接入自动审查逻辑
2026-09-06 17:03:20 +08:00

476 lines
17 KiB
TypeScript
Raw Permalink 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, getCookie, getHeader, getQuery } from 'h3'
import jwt from 'jsonwebtoken'
import { db } from '~/drizzle/db'
import {
playTimes,
schedules,
songCollaborators,
songReplayRequests,
songs,
systemSettings,
users,
votes
} from '~/drizzle/schema'
import { and, count, desc, eq, gte, inArray, lt, like, or } from 'drizzle-orm'
import {
resolveScheduleVisibilityRange,
isPlayDateInRange,
type VisibilitySettingsLike
} from '~~/server/utils/schedule-visibility'
import { cacheService } from '~~/server/services/cacheService'
import { executeRedisCommand, isRedisReady } from '../../utils/redis'
import { formatDateTime } from '~/utils/timeUtils'
import { maskPublicScheduleData, PublicScheduleItem } from '../../utils/studentMask'
import { verifyUserAuth } from '../../utils/auth'
export default defineEventHandler(async (event) => {
try {
// 获取查询参数
const query = getQuery(event)
const semester = query.semester as string
const bypassCache = query.bypass_cache === 'true'
// 检查用户是否已登录并获取角色
const authResult = await verifyUserAuth(event)
const isLoggedIn = authResult.success
const isAdmin = isLoggedIn && ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(authResult.user?.role)
// 获取系统设置
const systemSettingsDataResult = await db
.select()
.from(systemSettings)
.limit(1)
const systemSettingsData = systemSettingsDataResult[0] || null
const shouldHideStudentInfo = systemSettingsData?.hideStudentInfo ?? true
const visibilitySettings: VisibilitySettingsLike = {
scheduleDaysBeforeEnabled: systemSettingsData?.scheduleDaysBeforeEnabled,
scheduleDaysBefore: systemSettingsData?.scheduleDaysBefore,
scheduleDaysAfterEnabled: systemSettingsData?.scheduleDaysAfterEnabled,
scheduleDaysAfter: systemSettingsData?.scheduleDaysAfter
}
const visibilityRange = resolveScheduleVisibilityRange(visibilitySettings)
// 初始化缓存服务
// 获取当前日期,使用UTC时间
const now = new Date()
const today = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)
)
// 构建缓存键
const cacheKey = semester ? `public_schedules:${semester}` : 'public_schedules:all'
// 如果不绕过缓存,优先从Redis缓存获取排期数据
if (!bypassCache && isRedisReady()) {
const cachedData = await executeRedisCommand(async () => {
const client = (await import('../../utils/redis')).getRedisClient()
if (!client) return null
const data = await client.get(cacheKey)
if (data) {
const parsedData = JSON.parse(data) as PublicScheduleItem[]
console.log(`[Cache] 公共排期Redis缓存命中: ${cacheKey},数量: ${parsedData.length}`)
// 深拷贝数据以避免修改缓存的原始数据
const resultData = JSON.parse(JSON.stringify(parsedData)) as PublicScheduleItem[]
// 如果需要隐藏学生信息且用户不是管理员,则对排期数据进行脱敏
if (shouldHideStudentInfo && !isAdmin) {
maskPublicScheduleData(resultData)
}
return resultData
}
return null
})
if (cachedData) {
const filtered = visibilityRange.startAt || visibilityRange.endAt
? cachedData.filter((s) => isPlayDateInRange(s.playDate, visibilityRange))
: cachedData
const resultData = JSON.parse(JSON.stringify(filtered)) as PublicScheduleItem[]
if (shouldHideStudentInfo && !isAdmin) {
maskPublicScheduleData(resultData)
}
return resultData
}
}
// 如果不绕过缓存,Redis缓存未命中,尝试从CacheService获取(CacheService缓存所有学期数据)
let cachedSchedules = null
if (!bypassCache) {
cachedSchedules = await cacheService.getSchedulesList()
}
if (cachedSchedules && cachedSchedules.length > 0) {
console.log(`[Cache] CacheService排期缓存命中(所有学期),数量: ${cachedSchedules.length}`)
// 如果指定了学期,过滤数据
let filteredSchedules = cachedSchedules
if (semester) {
filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester)
console.log(`[Cache] 过滤学期 ${semester} 后的数量: ${filteredSchedules.length}`)
}
// 应用排期可见范围过滤
if (visibilityRange.startAt || visibilityRange.endAt) {
filteredSchedules = filteredSchedules.filter((s) =>
isPlayDateInRange(s.playDate, visibilityRange)
)
}
// 将过滤后的数据缓存到Redis(缓存完整数据,不进行模糊化)
if (isRedisReady()) {
await executeRedisCommand(async () => {
const client = (await import('../../utils/redis')).getRedisClient()
if (!client) return
await client.set(cacheKey, JSON.stringify(filteredSchedules))
console.log(
`[Cache] 排期数据设置Redis缓存: ${cacheKey},数量: ${filteredSchedules.length}`
)
})
}
// 深拷贝数据以避免修改缓存的原始数据
const resultData = JSON.parse(JSON.stringify(filteredSchedules)) as PublicScheduleItem[]
// 如果需要隐藏学生信息且用户不是管理员,则对排期数据进行脱敏
if (shouldHideStudentInfo && !isAdmin) {
maskPublicScheduleData(resultData)
}
return resultData
}
// 获取排期的歌曲,包含播放时段信息(查询所有学期的数据)
const scheduleWhere: any[] = [eq(schedules.isDraft, false)]
if (visibilityRange.startAt) {
scheduleWhere.push(gte(schedules.playDate, visibilityRange.startAt)!)
}
if (visibilityRange.endAt) {
scheduleWhere.push(lt(schedules.playDate, visibilityRange.endAt)!)
}
const schedulesData = await db
.select({
id: schedules.id,
playDate: schedules.playDate,
sequence: schedules.sequence,
played: schedules.played,
playTimeId: schedules.playTimeId,
song: {
id: songs.id,
title: songs.title,
artist: songs.artist,
played: songs.played,
cover: songs.cover,
musicPlatform: songs.musicPlatform,
musicId: songs.musicId,
playUrl: songs.playUrl,
semester: songs.semester,
requesterId: songs.requesterId,
createdAt: songs.createdAt
},
requester: {
name: users.name,
grade: users.grade,
class: users.class
},
playTime: {
id: playTimes.id,
name: playTimes.name,
startTime: playTimes.startTime,
endTime: playTimes.endTime,
enabled: playTimes.enabled
}
})
.from(schedules)
.leftJoin(songs, eq(schedules.songId, songs.id))
.leftJoin(users, eq(songs.requesterId, users.id))
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(and(...scheduleWhere)) // 只查询已发布的排期(+可见范围)
.orderBy(schedules.playDate)
const voteCountsQuery = await db
.select({
songId: votes.songId,
count: count(votes.id)
})
.from(votes)
.groupBy(votes.songId)
const voteCounts = new Map(voteCountsQuery.map((v) => [v.songId, v.count]))
// 获取所有用户的姓名列表,用于检测同名用户
const allUsers = await db
.select({
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
})
.from(users)
// 创建姓名到用户数组的映射
const nameToUsers = new Map()
allUsers.forEach((user) => {
if (user.name) {
if (!nameToUsers.has(user.name)) {
nameToUsers.set(user.name, [])
}
nameToUsers.get(user.name).push(user)
}
})
// 获取联合投稿人信息
const songIds = Array.from(new Set(schedulesData.map((s) => s.song?.id).filter(Boolean))) as number[]
const collaboratorsMap = new Map()
if (songIds.length > 0) {
const collaboratorsData = await db
.select({
songId: songCollaborators.songId,
status: songCollaborators.status,
user: {
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
}
})
.from(songCollaborators)
.leftJoin(users, eq(songCollaborators.userId, users.id))
.where(
and(
inArray(songCollaborators.songId, songIds),
eq(songCollaborators.status, 'ACCEPTED') // 只展示已接受的
)
)
collaboratorsData.forEach((c) => {
if (!collaboratorsMap.has(c.songId)) {
collaboratorsMap.set(c.songId, [])
}
if (c.user) {
collaboratorsMap.get(c.songId).push(c.user)
}
})
}
// 获取重播申请信息
const replayRequestCountsMap = new Map()
const replayRequestersMap = new Map()
if (songIds.length > 0) {
// 获取每首歌的重播申请数量(统计 PENDING 和 FULFILLED 状态)
// FULFILLED 表示已经被排期,PENDING 表示等待排期
const replayCountsData = await db
.select({
songId: songReplayRequests.songId,
count: count(songReplayRequests.id)
})
.from(songReplayRequests)
.where(
and(
inArray(songReplayRequests.songId, songIds),
// 查询 PENDING 或 FULFILLED 状态的申请
// PENDING: 等待排期,FULFILLED: 已排期但可能还未播放
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
)
)
.groupBy(songReplayRequests.songId)
replayCountsData.forEach((r) => {
replayRequestCountsMap.set(r.songId, r.count)
})
// 获取重播申请人列表(前5个,包括 PENDING 和 FULFILLED)
const replayRequestersData = await db
.select({
songId: songReplayRequests.songId,
user: {
id: users.id,
name: users.name,
grade: users.grade,
class: users.class
},
status: songReplayRequests.status,
createdAt: songReplayRequests.createdAt
})
.from(songReplayRequests)
.innerJoin(users, eq(songReplayRequests.userId, users.id))
.where(
and(
inArray(songReplayRequests.songId, songIds),
inArray(songReplayRequests.status, ['PENDING', 'FULFILLED'])
)
)
.orderBy(desc(songReplayRequests.createdAt))
replayRequestersData.forEach((r) => {
if (!replayRequestersMap.has(r.songId)) {
replayRequestersMap.set(r.songId, [])
}
// 只保留前5个
if (replayRequestersMap.get(r.songId).length < 5) {
replayRequestersMap.get(r.songId).push({
id: r.user.id,
name: r.user.name || '未知用户',
grade: r.user.grade,
class: r.user.class,
status: r.status
})
}
})
}
const formatClassInfo = (userObj: any) => {
if (!userObj) return ''
const parts: string[] = []
if (userObj.grade) parts.push(String(userObj.grade))
if (userObj.class) parts.push(String(userObj.class))
return parts.join(' ').trim()
}
// 辅助函数:格式化显示名称(班级 + 姓名)
const formatDisplayName = (userObj: any) => {
if (!userObj) return '未知用户'
const name = userObj.name ? String(userObj.name).trim() : ''
const classInfo = formatClassInfo(userObj)
if (!name) return classInfo || '未知用户'
return classInfo ? `${classInfo} ${name}` : name
}
// 转换数据格式
const formattedSchedules = schedulesData.map((schedule) => {
// 如果没有关联歌曲信息,跳过
if (!schedule.song) return null
// 获取原始日期,并确保使用UTC时间
const originalDate = new Date(schedule.playDate)
// 创建一个新的日期对象,保留原始日期的年月日,但使用UTC时间
const dateOnly = new Date(
Date.UTC(
originalDate.getUTCFullYear(),
originalDate.getUTCMonth(),
originalDate.getUTCDate(),
0,
0,
0,
0
)
)
// 处理投稿人姓名
const requesterName = formatDisplayName(schedule.requester)
// 处理联合投稿人
const collaborators = collaboratorsMap.get(schedule.song.id) || []
const formattedCollaborators = collaborators.map((c: any) => ({
id: c.id,
name: c.name,
displayName: formatDisplayName(c),
grade: c.grade,
class: c.class
}))
// 获取重播申请信息
const replayRequestCount = replayRequestCountsMap.get(schedule.song.id) || 0
const replayRequesters = replayRequestersMap.get(schedule.song.id) || []
const formattedReplayRequesters = replayRequesters.map((r: any) => ({
id: r.id,
name: r.name,
displayName: formatDisplayName(r),
grade: r.grade,
class: r.class,
status: r.status
}))
// 判断是否为重播:有重播申请(PENDING 或 FULFILLED)
const isReplaySong = replayRequestCount > 0
// 注意:这里不进行模糊化处理,保持完整数据用于缓存
// 模糊化处理将在最终返回时进行
return {
id: schedule.id,
playDate: dateOnly.toISOString().split('T')[0], // 转换为YYYY-MM-DD格式字符串
sequence: schedule.sequence || 1,
played: schedule.played || false,
playTimeId: schedule.playTimeId, // 添加播放时段ID
playTime: schedule.playTime
? {
id: schedule.playTime.id,
name: schedule.playTime.name,
startTime: schedule.playTime.startTime,
endTime: schedule.playTime.endTime,
enabled: schedule.playTime.enabled
}
: null, // 添加播放时段信息
song: {
id: schedule.song.id,
title: schedule.song.title,
artist: schedule.song.artist,
requester: requesterName,
requesterGrade: schedule.requester?.grade || null,
requesterClass: schedule.requester?.class || null,
collaborators: formattedCollaborators, // 添加联合投稿人
voteCount: voteCounts.get(schedule.song.id) || 0,
played: schedule.song.played || false,
cover: schedule.song.cover || null,
musicPlatform: schedule.song.musicPlatform || null,
musicId: schedule.song.musicId || null,
playUrl: schedule.song.playUrl || null,
semester: schedule.song.semester || null,
requestedAt: schedule.song.createdAt ? formatDateTime(schedule.song.createdAt) : null,
// 重播申请信息
replayRequestCount: isReplaySong ? replayRequestCount : 0,
replayRequesters: isReplaySong ? formattedReplayRequesters : [],
isReplay: isReplaySong // 只有已播放过且有重播申请的才标记为重播
}
}
}).filter(Boolean)
const allSchedulesResult = formattedSchedules
// 始终缓存所有学期的数据到CacheService
if (allSchedulesResult && allSchedulesResult.length > 0) {
await cacheService.setSchedulesList(allSchedulesResult)
console.log(
`[Cache] 公共排期设置CacheService缓存(所有学期),数量: ${allSchedulesResult.length}`
)
}
// 准备要返回和缓存的数据
let finalResult = allSchedulesResult
if (semester && allSchedulesResult) {
finalResult = allSchedulesResult.filter((s) => s.song?.semester === semester)
}
// 缓存结果到Redis(如果可用)- 根据请求参数缓存相应数据
if (finalResult && isRedisReady()) {
await executeRedisCommand(async () => {
const client = (await import('../../utils/redis')).getRedisClient()
if (!client) return
await client.set(cacheKey, JSON.stringify(finalResult))
console.log(`[Cache] 排期数据设置Redis缓存: ${cacheKey},数量: ${finalResult.length}`)
})
}
// 深拷贝数据以避免修改缓存的原始数据
const resultToReturn = JSON.parse(JSON.stringify(finalResult || allSchedulesResult)) as PublicScheduleItem[]
// 如果需要隐藏学生信息且用户不是管理员,则对排期数据进行脱敏
if (shouldHideStudentInfo && !isAdmin && resultToReturn) {
maskPublicScheduleData(resultToReturn)
}
return resultToReturn
} catch (error: any) {
console.error('获取公共排期失败:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || '获取排期数据失败'
})
}
})