feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 6m31s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s

- 新增 /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:
2026-09-06 17:03:20 +08:00
parent 43e2a05666
commit bd8df1f026
53 changed files with 10486 additions and 245 deletions
@@ -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
}
}