feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 6m31s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s

- 新增 /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 接入自动审查逻辑
This commit is contained in:
2026-09-06 17:03:20 +08:00
parent 43e2a05666
commit bd8df1f026
53 changed files with 10486 additions and 245 deletions
+42 -2
View File
@@ -1,8 +1,16 @@
import { db } from '~/drizzle/db'
import { songs, users, songCollaborators, collaborationLogs } from '~/drizzle/schema'
import { eq, or } from 'drizzle-orm'
import {
songs,
users,
songCollaborators,
collaborationLogs,
systemSettings,
songBlacklists
} from '~/drizzle/schema'
import { and, eq, inArray, or } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
import { createSubmissionNoteClearedNotification } from '~~/server/services/notificationService'
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
export default defineEventHandler(async (event) => {
try {
@@ -122,6 +130,38 @@ export default defineEventHandler(async (event) => {
? body.submissionNote.trim()
: null
}
if ('submissionNotePublic' in body) {
const wantPublic = updateData.submissionNote ? body.submissionNotePublic !== false : false
updateData.submissionNotePublic = wantPublic
// 自动审查(若 systemSettings 打开 submissionNoteRequiresApproval)
try {
const sysSetRes = await db.select().from(systemSettings).limit(1)
const sysSet = sysSetRes[0]
if (sysSet?.submissionNoteRequiresApproval && wantPublic && updateData.submissionNote) {
const bl = await db
.select()
.from(songBlacklists)
.where(inArray(songBlacklists.type, ['KEYWORD', 'LANGUAGE', 'GENRE']))
const mod = moderateSubmissionNote(
updateData.submissionNote,
wantPublic,
true,
{
title: updateData.title || existingSong.title,
artist: updateData.artist || existingSong.artist
},
bl
)
updateData.submissionNotePublic = mod.notePublic
updateData.submissionNotePublicStatus = mod.publicStatus
} else {
updateData.submissionNotePublicStatus = null
}
} catch (err) {
console.warn('[Update API] 投稿留言自动审查失败,保持用户设置:', err)
}
}
const currentRequesterId = updateData.requesterId || existingSong.requesterId
+35 -4
View File
@@ -11,7 +11,12 @@ import {
users,
votes
} from '~/drizzle/schema'
import { and, count, desc, eq, inArray } from 'drizzle-orm'
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'
@@ -38,6 +43,13 @@ export default defineEventHandler(async (event) => {
.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)
// 初始化缓存服务
@@ -73,7 +85,14 @@ export default defineEventHandler(async (event) => {
})
if (cachedData) {
return 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
}
}
@@ -91,6 +110,12 @@ export default defineEventHandler(async (event) => {
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()) {
@@ -116,6 +141,13 @@ export default defineEventHandler(async (event) => {
}
// 获取排期的歌曲,包含播放时段信息(查询所有学期的数据)
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,
@@ -153,10 +185,9 @@ export default defineEventHandler(async (event) => {
.leftJoin(songs, eq(schedules.songId, songs.id))
.leftJoin(users, eq(songs.requesterId, users.id))
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(eq(schedules.isDraft, false)) // 只查询已发布的排期
.where(and(...scheduleWhere)) // 只查询已发布的排期(+可见范围)
.orderBy(schedules.playDate)
// 获取每首歌的投票数
const voteCountsQuery = await db
.select({
songId: votes.songId,
+44 -3
View File
@@ -4,14 +4,16 @@ import {
playTimes,
requestTimes,
semesters,
songBlacklists,
songCollaborators,
songs,
systemSettings
} from '~/drizzle/db'
import { and, eq, gt, gte, lt, lte, sql } from 'drizzle-orm'
import { and, eq, gt, gte, inArray, lt, lte, sql } from 'drizzle-orm'
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
import { isLimitReached } from '~~/server/utils/submissionLimit'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
export default defineEventHandler(async (event) => {
// 检查用户认证
@@ -132,6 +134,18 @@ export default defineEventHandler(async (event) => {
const systemSettingsData = systemSettingsResult[0]
const isAdmin = user.role === 'SUPER_ADMIN' || user.role === 'ADMIN'
// 加载自动审查需要的黑名单(KEYWORD / LANGUAGE / GENRE)
let moderationBlacklists: any[] = []
try {
moderationBlacklists = await db
.select()
.from(songBlacklists)
.where(inArray(songBlacklists.type, ['KEYWORD', 'LANGUAGE', 'GENRE']))
} catch (err) {
console.warn('[Request API] 加载自动审查黑名单失败,将跳过自动审查:', err)
moderationBlacklists = []
}
// 1. 检查强制关闭
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
throw createError({
@@ -243,8 +257,34 @@ export default defineEventHandler(async (event) => {
}
const submissionNote =
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
const submissionNotePublic =
submissionNote !== null ? body.submissionNotePublic !== false : false
const rawSubmissionNotePublic = submissionNote !== null ? body.submissionNotePublic !== false : false
const requiresApproval = Boolean(systemSettingsData?.submissionNoteRequiresApproval)
let finalSubmissionNotePublic = rawSubmissionNotePublic
let submissionNotePublicStatus: string | null = null
if (submissionNote && rawSubmissionNotePublic && requiresApproval) {
const mod = moderateSubmissionNote(
submissionNote,
rawSubmissionNotePublic,
requiresApproval,
{ title: body.title, artist: body.artist, genre: body.genre || null },
moderationBlacklists
)
finalSubmissionNotePublic = mod.notePublic
submissionNotePublicStatus = mod.publicStatus
if (mod.rejectedReasons.length > 0) {
console.log(
`[Request API] 投稿留言自动审查未通过,歌曲=${body.title} - ${body.artist},原因:`,
mod.rejectedReasons
)
}
} else if (submissionNote) {
// 没开自动审查,也记录一下状态
submissionNotePublicStatus = null
}
const submissionNotePublic = finalSubmissionNotePublic
// 准备发送通知的列表
const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = []
@@ -367,6 +407,7 @@ export default defineEventHandler(async (event) => {
playUrl: body.playUrl || null,
submissionNote,
submissionNotePublic,
submissionNotePublicStatus,
hitRequestId: hitRequestTime?.id || null,
expectedPlayDate: body.expectedPlayDate || null,
expectedPlaySequence: body.expectedPlaySequence !== undefined ? Number(body.expectedPlaySequence) : null