Files
BYW-VoiceHub/server/utils/submissionNoteAutoModeration.ts
Henry_Du bd8df1f026
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 6m31s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s
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 接入自动审查逻辑
2026-09-06 17:03:20 +08:00

151 lines
4.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}