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 接入自动审查逻辑
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user