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:
@@ -0,0 +1,135 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { schedules, songBlacklists, songs, votes, requestTimes } from '~/drizzle/schema'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { cacheService } from '../../../services/cacheService'
|
||||
import { createSongRejectedNotification } from '../../../services/notificationService'
|
||||
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
|
||||
const MAX_BATCH_REJECT_COUNT = 1000
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireSongAdmin(event)
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
const songIds = Array.isArray(body?.songIds)
|
||||
? [...new Set(body.songIds.filter((id: any) => Number.isInteger(id) && id > 0))]
|
||||
: []
|
||||
const reason = typeof body?.reason === 'string' ? body.reason.trim() : ''
|
||||
const addToBlacklist = body?.addToBlacklist === true
|
||||
|
||||
if (songIds.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.SONG_BATCH_REJECT_IDS_REQUIRED, '歌曲ID列表不能为空')
|
||||
}
|
||||
|
||||
if (songIds.length > MAX_BATCH_REJECT_COUNT) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.SONG_BATCH_REJECT_LIMIT_EXCEEDED,
|
||||
`单次最多驳回 ${MAX_BATCH_REJECT_COUNT} 首歌曲`,
|
||||
{ params: [MAX_BATCH_REJECT_COUNT] }
|
||||
)
|
||||
}
|
||||
|
||||
if (!reason) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.SONG_BATCH_REJECT_REASON_REQUIRED, '驳回原因不能为空')
|
||||
}
|
||||
|
||||
try {
|
||||
let rejectedSongs: any[] = []
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const existingSongs = await tx.select().from(songs).where(inArray(songs.id, songIds))
|
||||
|
||||
if (existingSongs.length === 0) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.SONG_NOT_FOUND, '歌曲不存在或已被删除')
|
||||
}
|
||||
|
||||
const existingIds = existingSongs.map((song) => song.id)
|
||||
console.log(`开始批量驳回歌曲,共 ${existingIds.length} 首`)
|
||||
|
||||
if (addToBlacklist) {
|
||||
const blacklistValues = [...new Set(existingSongs.map((song: any) => `${song.title} - ${song.artist}`))]
|
||||
const existingBlacklist = await tx
|
||||
.select({ value: songBlacklists.value })
|
||||
.from(songBlacklists)
|
||||
.where(and(eq(songBlacklists.type, 'SONG'), inArray(songBlacklists.value, blacklistValues)))
|
||||
const existingSet = new Set(existingBlacklist.map((item) => item.value))
|
||||
const valuesToInsert = blacklistValues.filter((value) => !existingSet.has(value))
|
||||
for (const value of valuesToInsert) {
|
||||
try {
|
||||
await tx.insert(songBlacklists).values({
|
||||
type: 'SONG',
|
||||
value,
|
||||
reason: `歌曲驳回: ${reason}`,
|
||||
createdBy: user.id
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.log(`黑名单添加失败,可能已存在: ${error.message}`)
|
||||
}
|
||||
}
|
||||
console.log(`黑名单添加处理完成`)
|
||||
}
|
||||
|
||||
await tx.delete(votes).where(inArray(votes.songId, existingIds))
|
||||
console.log(`删除了投票记录`)
|
||||
|
||||
await tx.delete(schedules).where(inArray(schedules.songId, existingIds))
|
||||
console.log(`删除了排期记录`)
|
||||
|
||||
const hitRequestIds = [...new Set(existingSongs.map((song: any) => song.hitRequestId).filter(Boolean))]
|
||||
for (const hitRequestId of hitRequestIds) {
|
||||
const count = existingSongs.filter((song: any) => song.hitRequestId === hitRequestId).length
|
||||
try {
|
||||
await tx
|
||||
.update(requestTimes)
|
||||
.set({
|
||||
accepted: sql`GREATEST(0, accepted - ${count})`
|
||||
})
|
||||
.where(eq(requestTimes.id, hitRequestId as any))
|
||||
} catch (error: any) {
|
||||
console.error(`减少投稿时段 ${hitRequestId} 接纳数量失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
rejectedSongs = await tx.delete(songs).where(inArray(songs.id, existingIds)).returning()
|
||||
console.log(`歌曲批量删除完成`)
|
||||
})
|
||||
|
||||
const songsToNotify = rejectedSongs.filter((song) => song.requesterId)
|
||||
for (const song of songsToNotify) {
|
||||
createSongRejectedNotification(
|
||||
song.requesterId,
|
||||
{ title: song.title, artist: song.artist },
|
||||
reason
|
||||
).catch((error: any) => {
|
||||
console.error(`发送驳回通知失败: ${error.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
await cacheService.clearSongsCache()
|
||||
await cacheService.clearSchedulesCache()
|
||||
|
||||
console.log(`批量驳回操作完成,共 ${rejectedSongs.length} 首`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '歌曲批量驳回成功',
|
||||
data: {
|
||||
rejected: rejectedSongs.length,
|
||||
missing: songIds.length - rejectedSongs.length,
|
||||
notificationSent: songsToNotify.length
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('批量驳回歌曲失败:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createApiError(500, SERVER_ERROR_CODES.SONG_OPERATION_FAILED, '批量驳回失败: ' + error.message)
|
||||
}
|
||||
})
|
||||
@@ -3,6 +3,7 @@ const { authenticator } = otplib
|
||||
import { db, userIdentities, eq, and, users } from '~/drizzle/db'
|
||||
import { twoFactorCodes } from '~~/server/utils/twoFactorStore'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
|
||||
@@ -100,7 +101,12 @@ export default defineEventHandler(async (event) => {
|
||||
.catch((err) => console.error('Error updating user login info:', err))
|
||||
|
||||
// 生成Token
|
||||
const authToken = JWTEnhanced.generateToken(user.id, user.role)
|
||||
const { token: authToken, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
|
||||
await createAuthSession(event, {
|
||||
userId: user.id,
|
||||
jti,
|
||||
loginMethod: type === 'totp' ? 'totp_2fa' : 'email_2fa'
|
||||
})
|
||||
|
||||
const isSecure =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { parseState, getRedirectUri } from '~~/server/utils/oauth'
|
||||
import { generateBindingToken } from '~~/server/utils/oauth-token'
|
||||
import { db, eq, userIdentities } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
|
||||
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
|
||||
|
||||
@@ -142,7 +143,13 @@ async function handleUserLoginOrBind(
|
||||
}
|
||||
|
||||
// 登录
|
||||
const token = JWTEnhanced.generateToken(existingIdentity.user.id, existingIdentity.user.role)
|
||||
const { token, jti } = JWTEnhanced.generateTokenWithJti(existingIdentity.user.id, existingIdentity.user.role)
|
||||
await createAuthSession(event, {
|
||||
userId: existingIdentity.user.id,
|
||||
jti,
|
||||
loginMethod: 'oauth',
|
||||
oauthProvider: provider
|
||||
})
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users, userIdentities } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
import { verifyBindingToken } from '~~/server/utils/oauth-token'
|
||||
import {
|
||||
isAccountLocked,
|
||||
@@ -86,7 +87,13 @@ export default defineEventHandler(async (event) => {
|
||||
recordLoginSuccess(username, clientIp)
|
||||
|
||||
// 登录
|
||||
const token = JWTEnhanced.generateToken(user.id, user.role)
|
||||
const { token, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
|
||||
await createAuthSession(event, {
|
||||
userId: user.id,
|
||||
jti,
|
||||
loginMethod: 'oauth',
|
||||
oauthProvider: payload.provider
|
||||
})
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users, userIdentities, and } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
import {
|
||||
getAccountLockRemainingTime,
|
||||
getIPBlockRemainingTime,
|
||||
@@ -187,7 +188,8 @@ export default defineEventHandler(async (event) => {
|
||||
.catch((err) => console.error('Error updating user login info:', err))
|
||||
|
||||
// 生成JWT
|
||||
const token = JWTEnhanced.generateToken(user.id, user.role)
|
||||
const { token, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
|
||||
await createAuthSession(event, { userId: user.id, jti, loginMethod: 'password' })
|
||||
|
||||
// 自动判断是否需要secure
|
||||
const isSecure =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db, eq, users } from '~/drizzle/db'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
import { emailVerificationCodes } from '~~/server/api/user/email/send-code.post'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -109,7 +110,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// 签发 JWT Token
|
||||
const token = JWTEnhanced.generateToken(newUser.id, newUser.role)
|
||||
const { token, jti } = JWTEnhanced.generateTokenWithJti(newUser.id, newUser.role)
|
||||
await createAuthSession(event, { userId: newUser.id, jti, loginMethod: 'password' })
|
||||
|
||||
const isSecure =
|
||||
getRequestURL(event).protocol === 'https:' ||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import { executeRedisCommand, isRedisReady } from '../../utils/redis'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { isSessionRevokedByJti, touchAuthSession } from '~~/server/utils/create-auth-session'
|
||||
|
||||
// 用户认证缓存(永久缓存,登出或权限变更时主动失效)
|
||||
|
||||
@@ -19,9 +20,17 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// 验证JWT令牌
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number }
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: number; jti?: string }
|
||||
const userId = decoded.userId
|
||||
|
||||
if (await isSessionRevokedByJti(decoded.jti)) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: '会话已撤销,请重新登录'
|
||||
})
|
||||
}
|
||||
touchAuthSession(decoded.jti).catch(() => undefined)
|
||||
|
||||
// 优先从Redis缓存获取用户认证状态
|
||||
if (isRedisReady()) {
|
||||
const cachedUser = await executeRedisCommand(async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getWebAuthnConfig } from '~~/server/utils/webauthn-config'
|
||||
import { db, eq, and, userIdentities } from '~/drizzle/db'
|
||||
import { createError, defineEventHandler, setCookie } from 'h3'
|
||||
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
|
||||
import { createAuthSession } from '~~/server/utils/create-auth-session'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
@@ -84,7 +85,8 @@ export default defineEventHandler(async (event) => {
|
||||
.where(eq(userIdentities.id, identity.id))
|
||||
|
||||
// 签发登录 Token
|
||||
const token = JWTEnhanced.generateToken(identity.user.id, identity.user.role)
|
||||
const { token, jti } = JWTEnhanced.generateTokenWithJti(identity.user.id, identity.user.role)
|
||||
await createAuthSession(event, { userId: identity.user.id, jti, loginMethod: 'webauthn' })
|
||||
setCookie(event, 'auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { createError, defineEventHandler, getQuery } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { playTimes, schedules, songCollaborators, songs, users } from '~/drizzle/schema'
|
||||
import { playTimes, schedules, songCollaborators, songs, systemSettings, users } from '~/drizzle/schema'
|
||||
import { and, asc, desc, eq, gte, inArray, like, lt, or, sql } from 'drizzle-orm'
|
||||
import { formatDateTime } from '~/utils/timeUtils'
|
||||
import { openApiCache } from '~~/server/utils/open-api-cache'
|
||||
import { CACHE_CONSTANTS } from '~~/server/config/constants'
|
||||
import { cacheService } from '~~/server/services/cacheService'
|
||||
import { executeRedisCommand, isRedisReady } from '~~/server/utils/redis'
|
||||
import {
|
||||
resolveScheduleVisibilityRange,
|
||||
isPlayDateInRange,
|
||||
type VisibilitySettingsLike
|
||||
} from '~~/server/utils/schedule-visibility'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
@@ -36,6 +41,27 @@ export default defineEventHandler(async (event) => {
|
||||
const sortBy = (query.sortBy as string) || 'playDate'
|
||||
const sortOrder = (query.sortOrder as string) || 'asc'
|
||||
|
||||
// 读取系统设置:排期可见范围
|
||||
let visibilityRange = { startAt: null as Date | null, endAt: null as Date | null }
|
||||
try {
|
||||
const systemSettingsDataResult = await db.select().from(systemSettings).limit(1)
|
||||
const systemSettingsData = systemSettingsDataResult[0] || null
|
||||
const visibilitySettings: VisibilitySettingsLike = {
|
||||
scheduleDaysBeforeEnabled: systemSettingsData?.scheduleDaysBeforeEnabled,
|
||||
scheduleDaysBefore: systemSettingsData?.scheduleDaysBefore,
|
||||
scheduleDaysAfterEnabled: systemSettingsData?.scheduleDaysAfterEnabled,
|
||||
scheduleDaysAfter: systemSettingsData?.scheduleDaysAfter
|
||||
}
|
||||
visibilityRange = resolveScheduleVisibilityRange(visibilitySettings)
|
||||
} catch (err) {
|
||||
console.warn('[Schedules API] 读取排期可见范围设置失败,将展示全部:', err)
|
||||
}
|
||||
|
||||
const applyVisibilityFilter = (items: any[]): any[] => {
|
||||
if (!visibilityRange.startAt && !visibilityRange.endAt) return items
|
||||
return items.filter((s) => isPlayDateInRange(s.playDate, visibilityRange))
|
||||
}
|
||||
|
||||
// 优先从普通API缓存获取数据
|
||||
|
||||
// 尝试从CacheService获取排期数据
|
||||
@@ -49,6 +75,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (semester) {
|
||||
filteredSchedules = cachedSchedules.filter((s) => s.song?.semester === semester)
|
||||
}
|
||||
filteredSchedules = applyVisibilityFilter(filteredSchedules)
|
||||
|
||||
// 转换为开放API格式
|
||||
const result = {
|
||||
@@ -105,11 +132,12 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
|
||||
if (redisData) {
|
||||
const filteredRedisData = applyVisibilityFilter(redisData)
|
||||
// 转换为开放API格式
|
||||
const result = {
|
||||
success: true,
|
||||
data: {
|
||||
schedules: redisData.map((schedule: any) => ({
|
||||
schedules: filteredRedisData.map((schedule: any) => ({
|
||||
id: schedule.id,
|
||||
playDate: schedule.playDate,
|
||||
sequence: schedule.sequence,
|
||||
@@ -133,7 +161,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
: null
|
||||
})),
|
||||
total: redisData.length,
|
||||
total: filteredRedisData.length,
|
||||
semester: semester || null
|
||||
}
|
||||
}
|
||||
@@ -175,6 +203,13 @@ export default defineEventHandler(async (event) => {
|
||||
conditions.push(or(like(songs.title, `%${search}%`)!, like(songs.artist, `%${search}%`)!)!)
|
||||
}
|
||||
|
||||
if (visibilityRange.startAt) {
|
||||
conditions.push(gte(schedules.playDate, visibilityRange.startAt)!)
|
||||
}
|
||||
if (visibilityRange.endAt) {
|
||||
conditions.push(lt(schedules.playDate, visibilityRange.endAt)!)
|
||||
}
|
||||
|
||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
// 计算偏移量
|
||||
|
||||
@@ -26,7 +26,13 @@ const publicFields = [
|
||||
'enableSubmissionRemarks',
|
||||
'enableRequestTimeLimitation',
|
||||
'forceBlockAllRequests',
|
||||
'smtpEnabled'
|
||||
'smtpEnabled',
|
||||
'defaultTheme',
|
||||
'enabledThemes',
|
||||
'scheduleDaysBeforeEnabled',
|
||||
'scheduleDaysBefore',
|
||||
'scheduleDaysAfterEnabled',
|
||||
'scheduleDaysAfter'
|
||||
]
|
||||
|
||||
const filterPublicSettings = (data: any) => {
|
||||
@@ -81,7 +87,13 @@ export default defineEventHandler(async (event) => {
|
||||
enableRequestTimeLimitation: false,
|
||||
forceBlockAllRequests: false,
|
||||
enableReplayRequests: false,
|
||||
smtpEnabled: false
|
||||
smtpEnabled: false,
|
||||
defaultTheme: 'System',
|
||||
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]',
|
||||
scheduleDaysBeforeEnabled: false,
|
||||
scheduleDaysBefore: 30,
|
||||
scheduleDaysAfterEnabled: false,
|
||||
scheduleDaysAfter: 14
|
||||
})
|
||||
.returning()
|
||||
|
||||
|
||||
@@ -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