mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 16:09:34 +00:00
feat(risk-control): 内容审计新增关键词拦截
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export type ModerationMode = 'off' | 'observe' | 'pre_block'
|
||||
export type KeywordBlockingMode = 'keyword_only' | 'keyword_and_api' | 'api_only'
|
||||
|
||||
export interface ContentModerationConfig {
|
||||
enabled: boolean
|
||||
@@ -29,6 +30,8 @@ export interface ContentModerationConfig {
|
||||
hit_retention_days: number
|
||||
non_hit_retention_days: number
|
||||
pre_hash_check_enabled: boolean
|
||||
blocked_keywords: string[]
|
||||
keyword_blocking_mode: KeywordBlockingMode
|
||||
}
|
||||
|
||||
export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen'
|
||||
@@ -100,6 +103,8 @@ export interface UpdateContentModerationConfig {
|
||||
hit_retention_days?: number
|
||||
non_hit_retention_days?: number
|
||||
pre_hash_check_enabled?: boolean
|
||||
blocked_keywords?: string[]
|
||||
keyword_blocking_mode?: KeywordBlockingMode
|
||||
}
|
||||
|
||||
export interface ContentModerationRuntimeStatus {
|
||||
|
||||
@@ -2547,8 +2547,25 @@ export default {
|
||||
scope: 'Scope',
|
||||
runtime: 'Runtime',
|
||||
response: 'Hit Notice',
|
||||
keywords: 'Keyword Block',
|
||||
retention: 'Retention',
|
||||
},
|
||||
blockedKeywords: 'Blocked keywords',
|
||||
blockedKeywordsPlaceholder: 'One keyword per line, e.g.:\nbadword1\nbadword2',
|
||||
blockedKeywordsDescription: 'Matching is case-insensitive. Whether the upstream moderation API is invoked after a hit depends on the strategy below.',
|
||||
blockedKeywordsPreBlockHint: 'Keyword blocking only takes effect in "Pre-block" mode.',
|
||||
blockedKeywordsModeWarning: 'Current mode is "{mode}". Keyword blocking will not run until you switch to "Pre-block" mode.',
|
||||
blockedKeywordCount: '{count} keywords configured',
|
||||
blockedKeywordsLimit: 'Up to {max} keywords, each no longer than 200 characters. Duplicates are removed automatically.',
|
||||
keywordBlockingMode: 'Moderation strategy',
|
||||
keywordModeKeywordAndApi: 'Keyword + API',
|
||||
keywordModeKeywordAndApiDesc: 'Block on keyword hit; otherwise fall through to the upstream moderation API.',
|
||||
keywordModeKeywordOnly: 'Keyword only',
|
||||
keywordModeKeywordOnlyDesc: 'Decide using keywords only; misses are allowed without calling the API, saving upstream cost.',
|
||||
keywordModeKeywordOnlyNotice: 'Keyword-only strategy: requests that do not match any keyword are allowed without calling the upstream moderation API.',
|
||||
keywordModeApiOnly: 'API only',
|
||||
keywordModeApiOnlyDesc: 'Use the upstream moderation API only; the keyword list configured here is not consulted.',
|
||||
keywordModeApiOnlyNotice: 'API-only strategy: the keyword list is not consulted; all requests go through the upstream moderation API.',
|
||||
overview: {
|
||||
status: 'Status',
|
||||
enabled: 'Enabled',
|
||||
@@ -2586,6 +2603,7 @@ export default {
|
||||
},
|
||||
action: {
|
||||
block: 'Blocked',
|
||||
keywordBlock: 'Keyword Blocked',
|
||||
error: 'Error',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2624,8 +2624,25 @@ export default {
|
||||
scope: '审计范围',
|
||||
runtime: '运行队列',
|
||||
response: '命中通知',
|
||||
keywords: '关键词拦截',
|
||||
retention: '日志保留',
|
||||
},
|
||||
blockedKeywords: '拦截关键词',
|
||||
blockedKeywordsPlaceholder: '每行输入一个关键词,例如:\n敏感词1\n敏感词2',
|
||||
blockedKeywordsDescription: '匹配忽略大小写;命中后会按下方策略决定是否调用上游审计接口。',
|
||||
blockedKeywordsPreBlockHint: '关键词拦截仅在「前置拦截」模式下生效。',
|
||||
blockedKeywordsModeWarning: '当前为「{mode}」模式,关键词拦截不会生效;请切换到「前置拦截」模式后再保存关键词。',
|
||||
blockedKeywordCount: '已配置 {count} 个关键词',
|
||||
blockedKeywordsLimit: '最多保存 {max} 个关键词,单个长度不超过 200 个字符;重复项会自动去重。',
|
||||
keywordBlockingMode: '审计策略',
|
||||
keywordModeKeywordAndApi: '关键词 + API',
|
||||
keywordModeKeywordAndApiDesc: '命中关键词直接拦截;未命中时再调用上游审计接口。',
|
||||
keywordModeKeywordOnly: '仅关键词',
|
||||
keywordModeKeywordOnlyDesc: '只用关键词判断,未命中即放行,不调用上游审计接口,可显著降低 API 用量。',
|
||||
keywordModeKeywordOnlyNotice: '当前为「仅关键词」策略:未命中关键词的请求将直接放行,不调用上游审计接口。',
|
||||
keywordModeApiOnly: '仅 API',
|
||||
keywordModeApiOnlyDesc: '只调用上游审计接口判断,本页的关键词列表将不会生效。',
|
||||
keywordModeApiOnlyNotice: '当前为「仅 API」策略:关键词列表不会生效,请求会全部交给上游审计接口判断。',
|
||||
overview: {
|
||||
status: '运行状态',
|
||||
enabled: '已启用',
|
||||
@@ -2663,6 +2680,7 @@ export default {
|
||||
},
|
||||
action: {
|
||||
block: '拦截',
|
||||
keywordBlock: '关键词拦截',
|
||||
error: '异常',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -728,6 +728,70 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSettingsTab === 'keywords'" class="space-y-5">
|
||||
<div
|
||||
class="flex items-start gap-3 rounded-lg border p-4"
|
||||
:class="keywordNotice.toneClass"
|
||||
>
|
||||
<Icon
|
||||
:name="keywordNotice.icon"
|
||||
size="md"
|
||||
:class="keywordNotice.iconClass"
|
||||
/>
|
||||
<div class="text-sm leading-6">
|
||||
<p class="font-medium" :class="keywordNotice.titleClass">{{ keywordNotice.title }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ keywordNotice.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="input-label">{{ t('admin.riskControl.keywordBlockingMode') }}</label>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="option in keywordBlockingModeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="rounded-lg border p-3 text-left transition-colors"
|
||||
:class="configForm.keyword_blocking_mode === option.value
|
||||
? 'border-primary-300 bg-primary-50 text-primary-900 shadow-sm dark:border-primary-700 dark:bg-primary-900/20 dark:text-primary-100'
|
||||
: 'border-gray-100 hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-700/60'"
|
||||
@click="configForm.keyword_blocking_mode = option.value"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-semibold">{{ option.label }}</span>
|
||||
<span
|
||||
class="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border"
|
||||
:class="configForm.keyword_blocking_mode === option.value
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-gray-300 text-transparent dark:border-dark-500'"
|
||||
>
|
||||
<Icon name="check" size="xs" :stroke-width="2" />
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ option.description }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.riskControl.blockedKeywords') }}</label>
|
||||
<span class="inline-flex rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-500 dark:bg-dark-700 dark:text-gray-300">
|
||||
{{ t('admin.riskControl.blockedKeywordCount', { count: blockedKeywordCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="configForm.blocked_keywords_text"
|
||||
class="input min-h-52 resize-y font-mono text-sm"
|
||||
:placeholder="t('admin.riskControl.blockedKeywordsPlaceholder')"
|
||||
:disabled="configForm.keyword_blocking_mode === 'api_only'"
|
||||
></textarea>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.riskControl.blockedKeywordsLimit', { max: blockedKeywordMax }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.riskControl.hitRetentionDays') }}</label>
|
||||
@@ -830,6 +894,7 @@ import type {
|
||||
ContentModerationLog,
|
||||
ContentModerationRuntimeStatus,
|
||||
ContentModerationTestAuditResult,
|
||||
KeywordBlockingMode,
|
||||
ModerationMode,
|
||||
UpdateContentModerationConfig,
|
||||
} from '@/api/admin/riskControl'
|
||||
@@ -838,7 +903,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { formatDateTime as formatDateTimeValue } from '@/utils/format'
|
||||
|
||||
type SettingsTab = 'basic' | 'scope' | 'runtime' | 'response' | 'retention'
|
||||
type SettingsTab = 'basic' | 'scope' | 'runtime' | 'response' | 'retention' | 'keywords'
|
||||
type WorkerSlotState = 'active' | 'idle' | 'disabled'
|
||||
type APIKeysWriteMode = 'append' | 'replace'
|
||||
type OverviewIcon = 'shield' | 'key' | 'users' | 'document'
|
||||
@@ -862,6 +927,7 @@ type ModerationScoreRow = {
|
||||
const maxModerationTestImages = 1
|
||||
const maxModerationTestImageSize = 8 * 1024 * 1024
|
||||
const maxVisibleApiKeyRows: number = 3
|
||||
const blockedKeywordMax = 10000
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -919,6 +985,8 @@ const configForm = reactive({
|
||||
hit_retention_days: 180,
|
||||
non_hit_retention_days: 3,
|
||||
pre_hash_check_enabled: false,
|
||||
blocked_keywords_text: '',
|
||||
keyword_blocking_mode: 'keyword_and_api' as KeywordBlockingMode,
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
@@ -942,6 +1010,7 @@ const settingsTabs = computed<Array<{ id: SettingsTab; label: string }>>(() => [
|
||||
{ id: 'scope', label: t('admin.riskControl.tabs.scope') },
|
||||
{ id: 'runtime', label: t('admin.riskControl.tabs.runtime') },
|
||||
{ id: 'response', label: t('admin.riskControl.tabs.response') },
|
||||
{ id: 'keywords', label: t('admin.riskControl.tabs.keywords') },
|
||||
{ id: 'retention', label: t('admin.riskControl.tabs.retention') },
|
||||
])
|
||||
|
||||
@@ -951,6 +1020,78 @@ const modeOptions = computed<SelectOption[]>(() => [
|
||||
{ value: 'off', label: t('admin.riskControl.modeOff') },
|
||||
])
|
||||
|
||||
const keywordBlockingModeOptions = computed<Array<{ value: KeywordBlockingMode; label: string; description: string }>>(() => [
|
||||
{
|
||||
value: 'keyword_and_api',
|
||||
label: t('admin.riskControl.keywordModeKeywordAndApi'),
|
||||
description: t('admin.riskControl.keywordModeKeywordAndApiDesc'),
|
||||
},
|
||||
{
|
||||
value: 'keyword_only',
|
||||
label: t('admin.riskControl.keywordModeKeywordOnly'),
|
||||
description: t('admin.riskControl.keywordModeKeywordOnlyDesc'),
|
||||
},
|
||||
{
|
||||
value: 'api_only',
|
||||
label: t('admin.riskControl.keywordModeApiOnly'),
|
||||
description: t('admin.riskControl.keywordModeApiOnlyDesc'),
|
||||
},
|
||||
])
|
||||
|
||||
type KeywordNoticeView = {
|
||||
title: string
|
||||
description: string
|
||||
icon: 'infoCircle' | 'exclamationTriangle'
|
||||
toneClass: string
|
||||
iconClass: string
|
||||
titleClass: string
|
||||
}
|
||||
|
||||
const keywordNoticeTones = {
|
||||
info: {
|
||||
icon: 'infoCircle' as const,
|
||||
toneClass: 'border-primary-100 bg-primary-50/60 dark:border-primary-900/40 dark:bg-primary-900/10',
|
||||
iconClass: 'mt-0.5 flex-shrink-0 text-primary-500 dark:text-primary-300',
|
||||
titleClass: 'text-primary-700 dark:text-primary-200',
|
||||
},
|
||||
warning: {
|
||||
icon: 'exclamationTriangle' as const,
|
||||
toneClass: 'border-amber-200 bg-amber-50 dark:border-amber-900/40 dark:bg-amber-900/20',
|
||||
iconClass: 'mt-0.5 flex-shrink-0 text-amber-500 dark:text-amber-300',
|
||||
titleClass: 'text-amber-700 dark:text-amber-200',
|
||||
},
|
||||
}
|
||||
|
||||
const keywordNotice = computed<KeywordNoticeView>(() => {
|
||||
const strategy = configForm.keyword_blocking_mode
|
||||
if (strategy === 'api_only') {
|
||||
return {
|
||||
...keywordNoticeTones.info,
|
||||
title: t('admin.riskControl.keywordModeApiOnlyNotice'),
|
||||
description: t('admin.riskControl.keywordModeApiOnlyDesc'),
|
||||
}
|
||||
}
|
||||
if (configForm.mode !== 'pre_block') {
|
||||
return {
|
||||
...keywordNoticeTones.warning,
|
||||
title: t('admin.riskControl.blockedKeywordsModeWarning', { mode: modeLabel(configForm.mode) }),
|
||||
description: t('admin.riskControl.blockedKeywordsDescription'),
|
||||
}
|
||||
}
|
||||
if (strategy === 'keyword_only') {
|
||||
return {
|
||||
...keywordNoticeTones.info,
|
||||
title: t('admin.riskControl.keywordModeKeywordOnlyNotice'),
|
||||
description: t('admin.riskControl.keywordModeKeywordOnlyDesc'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...keywordNoticeTones.info,
|
||||
title: t('admin.riskControl.blockedKeywordsPreBlockHint'),
|
||||
description: t('admin.riskControl.blockedKeywordsDescription'),
|
||||
}
|
||||
})
|
||||
|
||||
const resultOptions = computed<SelectOption[]>(() => [
|
||||
{ value: '', label: t('admin.riskControl.result.all') },
|
||||
{ value: 'hit', label: t('admin.riskControl.result.hit') },
|
||||
@@ -989,6 +1130,10 @@ const filteredGroups = computed(() => {
|
||||
|
||||
const inputApiKeyCount = computed(() => parseApiKeys(configForm.api_keys_text).length)
|
||||
|
||||
const blockedKeywordList = computed(() => parseBlockedKeywords(configForm.blocked_keywords_text))
|
||||
|
||||
const blockedKeywordCount = computed(() => blockedKeywordList.value.length)
|
||||
|
||||
const pendingDeletedApiKeyCount = computed(() => pendingDeleteApiKeyHashes.value.length)
|
||||
|
||||
const effectiveStoredApiKeyCount = computed(() => Math.max(0, configForm.api_key_count - pendingDeletedApiKeyCount.value))
|
||||
@@ -1195,6 +1340,8 @@ function applyConfig(config: ContentModerationConfig) {
|
||||
configForm.hit_retention_days = config.hit_retention_days || 180
|
||||
configForm.non_hit_retention_days = Math.min(Math.max(config.non_hit_retention_days || 3, 1), 3)
|
||||
configForm.pre_hash_check_enabled = config.pre_hash_check_enabled ?? false
|
||||
configForm.blocked_keywords_text = Array.isArray(config.blocked_keywords) ? config.blocked_keywords.join('\n') : ''
|
||||
configForm.keyword_blocking_mode = normalizeKeywordBlockingMode(config.keyword_blocking_mode)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
@@ -1264,6 +1411,8 @@ async function saveConfig() {
|
||||
hit_retention_days: Number(configForm.hit_retention_days) || 180,
|
||||
non_hit_retention_days: Math.min(Math.max(Number(configForm.non_hit_retention_days) || 3, 1), 3),
|
||||
pre_hash_check_enabled: configForm.pre_hash_check_enabled,
|
||||
blocked_keywords: blockedKeywordList.value,
|
||||
keyword_blocking_mode: configForm.keyword_blocking_mode,
|
||||
}
|
||||
const keys = parseApiKeys(configForm.api_keys_text)
|
||||
if (!payload.clear_api_key && configForm.api_keys_mode === 'replace' && keys.length === 0) {
|
||||
@@ -1563,6 +1712,7 @@ function modeDescription(mode: ModerationMode): string {
|
||||
}
|
||||
|
||||
function resultLabel(row: ContentModerationLog): string {
|
||||
if (row.action === 'keyword_block') return t('admin.riskControl.action.keywordBlock')
|
||||
if (row.action === 'block') return t('admin.riskControl.action.block')
|
||||
if (row.action === 'error' || row.error) return t('admin.riskControl.action.error')
|
||||
if (row.flagged) return t('admin.riskControl.result.hit')
|
||||
@@ -1570,7 +1720,7 @@ function resultLabel(row: ContentModerationLog): string {
|
||||
}
|
||||
|
||||
function resultBadgeClass(row: ContentModerationLog): string {
|
||||
if (row.action === 'block') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
if (row.action === 'block' || row.action === 'keyword_block') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
if (row.action === 'error' || row.error) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
if (row.flagged) return 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300'
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
@@ -1667,6 +1817,27 @@ function parseApiKeys(value: string): string[] {
|
||||
.filter((item, index, arr) => item && arr.indexOf(item) === index)
|
||||
}
|
||||
|
||||
function normalizeKeywordBlockingMode(value: unknown): KeywordBlockingMode {
|
||||
if (value === 'keyword_only' || value === 'api_only' || value === 'keyword_and_api') {
|
||||
return value
|
||||
}
|
||||
return 'keyword_and_api'
|
||||
}
|
||||
|
||||
function parseBlockedKeywords(value: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const kw = line.trim()
|
||||
if (!kw) continue
|
||||
const key = kw.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(kw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function violationCountText(row: ContentModerationLog): string {
|
||||
if (!row.flagged) return '-'
|
||||
return t('admin.riskControl.violationCount', { count: row.violation_count || 1 })
|
||||
|
||||
Reference in New Issue
Block a user