- 新增 /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 接入自动审查逻辑
151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
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
|
||
}
|
||
}
|