feat(risk-control): 内容审计新增关键词拦截

This commit is contained in:
shaw
2026-05-20 11:13:53 +08:00
parent 3d22dd34d3
commit 91da815993
7 changed files with 550 additions and 6 deletions
+5
View File
@@ -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 {
+18
View File
@@ -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',
},
},
+18
View File
@@ -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: '异常',
},
},
+173 -2
View File
@@ -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 })