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,150 @@
|
||||
import type { SongBlacklist } from '~/drizzle/schema'
|
||||
|
||||
export type BlacklistType = 'KEYWORD' | 'LANGUAGE' | 'GENRE' | 'SONG'
|
||||
|
||||
export interface ModerationContext {
|
||||
title?: string | null
|
||||
artist?: string | null
|
||||
genre?: string | null
|
||||
}
|
||||
|
||||
export interface ModerationResult {
|
||||
notePublic: boolean
|
||||
publicStatus: 'approved' | 'rejected' | null
|
||||
rejectedReasons: string[]
|
||||
matchedRules: { type: BlacklistType; value: string }[]
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function isCJK(text: string): boolean {
|
||||
return /[\u4e00-\u9fa5]/.test(text)
|
||||
}
|
||||
|
||||
function isLatin(text: string): boolean {
|
||||
return /[A-Za-z]/.test(text)
|
||||
}
|
||||
|
||||
function isKana(text: string): boolean {
|
||||
return /[\u3040-\u30ff]/.test(text)
|
||||
}
|
||||
|
||||
function isHangul(text: string): boolean {
|
||||
return /[\uac00-\ud7af]/.test(text)
|
||||
}
|
||||
|
||||
function isCyrillic(text: string): boolean {
|
||||
return /[\u0400-\u04ff]/.test(text)
|
||||
}
|
||||
|
||||
function isArabic(text: string): boolean {
|
||||
return /[\u0600-\u06ff]/.test(text)
|
||||
}
|
||||
|
||||
function detectLanguages(text: string): string[] {
|
||||
const result: string[] = []
|
||||
if (!text) return result
|
||||
const normalized = text.toLowerCase()
|
||||
if (isCJK(text)) result.push('zh', '中文', 'chinese', 'cjk')
|
||||
if (isLatin(text)) result.push('en', '英文', 'english', 'latin')
|
||||
if (isKana(text)) result.push('ja', '日文', 'japanese')
|
||||
if (isHangul(text)) result.push('ko', '韩文', 'korean')
|
||||
if (isCyrillic(text)) result.push('ru', '俄文', 'russian', 'cyrillic')
|
||||
if (isArabic(text)) result.push('ar', '阿拉伯文', 'arabic')
|
||||
return result
|
||||
}
|
||||
|
||||
export function moderateSubmissionNote(
|
||||
note: string | null,
|
||||
wantPublic: boolean,
|
||||
requiresApproval: boolean,
|
||||
song: ModerationContext,
|
||||
blacklistRows: SongBlacklist[]
|
||||
): ModerationResult {
|
||||
if (!note || !wantPublic) {
|
||||
return {
|
||||
notePublic: wantPublic && !!note,
|
||||
publicStatus: null,
|
||||
rejectedReasons: [],
|
||||
matchedRules: []
|
||||
}
|
||||
}
|
||||
|
||||
const noteLower = note.toLowerCase()
|
||||
const noteForKeyword = note.toLowerCase()
|
||||
const title = (song.title || '').toLowerCase()
|
||||
const artist = (song.artist || '').toLowerCase()
|
||||
const genre = (song.genre || '').toLowerCase()
|
||||
|
||||
const rejectedReasons: string[] = []
|
||||
const matchedRules: { type: BlacklistType; value: string }[] = []
|
||||
|
||||
const noteLanguages = detectLanguages(note)
|
||||
const textLanguages = new Set([
|
||||
...noteLanguages,
|
||||
...detectLanguages(song.title || ''),
|
||||
...detectLanguages(song.artist || ''),
|
||||
...detectLanguages(song.genre || '')
|
||||
])
|
||||
|
||||
for (const row of blacklistRows) {
|
||||
const t = row.type as BlacklistType
|
||||
const rawValue = row.value
|
||||
if (!rawValue) continue
|
||||
const value = rawValue.trim()
|
||||
if (!value) continue
|
||||
const valueLower = value.toLowerCase()
|
||||
|
||||
if (t === 'KEYWORD') {
|
||||
// 命中 note / title / artist / genre 的其中一个都算
|
||||
const haystacks = [noteForKeyword, title, artist, genre].filter(Boolean) as string[]
|
||||
const exact = haystacks.some((h) => h.includes(valueLower))
|
||||
const asRegex = (() => {
|
||||
try {
|
||||
const re = new RegExp(escapeRegex(value), 'i')
|
||||
return haystacks.some((h) => re.test(h))
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
if (exact || asRegex) {
|
||||
matchedRules.push({ type: t, value })
|
||||
rejectedReasons.push(`命中敏感词:${value}`)
|
||||
}
|
||||
} else if (t === 'LANGUAGE') {
|
||||
const v = valueLower
|
||||
const checkList = [
|
||||
v,
|
||||
...v.split(/[,,\/|\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||
]
|
||||
const hit = checkList.some((lang) => textLanguages.has(lang))
|
||||
if (hit) {
|
||||
matchedRules.push({ type: t, value })
|
||||
rejectedReasons.push(`命中语言限制:${value}`)
|
||||
}
|
||||
} else if (t === 'GENRE') {
|
||||
// 匹配流派:genre 字段或 note/title/artist 中的流派关键词
|
||||
const combinedForGenre = `${genre} ${noteForKeyword} ${title} ${artist}`.trim()
|
||||
if (!combinedForGenre) continue
|
||||
const tokens = valueLower.split(/[,,\/|\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||
const hit = tokens.some((tok) => combinedForGenre.includes(tok))
|
||||
if (hit) {
|
||||
matchedRules.push({ type: t, value })
|
||||
rejectedReasons.push(`命中流派限制:${value}`)
|
||||
}
|
||||
} else {
|
||||
// SONG 不参与 note 审查
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const rejected = matchedRules.length > 0
|
||||
return {
|
||||
notePublic: rejected ? false : (requiresApproval ? false : wantPublic),
|
||||
publicStatus: requiresApproval || matchedRules.length > 0 ? (rejected ? 'rejected' : 'approved') : null,
|
||||
rejectedReasons,
|
||||
matchedRules
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user