feat(risk-control): 内容审计支持按模型生效

This commit is contained in:
wucm667 committed 2026-05-21 21:18:43 +08:00
1 parent 16793d3af0
commit 0d5c6f7cc7
8 files changed
+784 -86

No files matched your search

+8
View File
@@ -2,6 +2,12 @@ import { apiClient } from '../client'
export type ModerationMode = 'off' | 'observe' | 'pre_block'
export type KeywordBlockingMode = 'keyword_only' | 'keyword_and_api' | 'api_only'
export type ContentModerationModelFilterType = 'all' | 'include' | 'exclude'
export interface ContentModerationModelFilter {
type: ContentModerationModelFilterType
models: string[]
}
export interface ContentModerationConfig {
enabled: boolean
@@ -32,6 +38,7 @@ export interface ContentModerationConfig {
pre_hash_check_enabled: boolean
blocked_keywords: string[]
keyword_blocking_mode: KeywordBlockingMode
model_filter: ContentModerationModelFilter
}
export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen'
@@ -105,6 +112,7 @@ export interface UpdateContentModerationConfig {
pre_hash_check_enabled?: boolean
blocked_keywords?: string[]
keyword_blocking_mode?: KeywordBlockingMode
model_filter?: ContentModerationModelFilter
}
export interface ContentModerationRuntimeStatus {
+14
View File
@@ -2532,6 +2532,20 @@ export default {
selectedGroups: 'Selected Groups',
searchGroups: 'Search group name or platform',
noGroups: 'No groups available',
modelFilter: 'Model scope',
modelFilterHint: 'Moderate by the client-requested model name; channel model mappings do not change this match.',
modelFilterAll: 'All models',
modelFilterAllDesc: 'All model requests go through content moderation.',
modelFilterInclude: 'Only selected',
modelFilterIncludeDesc: 'Only listed models go through content moderation.',
modelFilterExclude: 'Exclude selected',
modelFilterExcludeDesc: 'Listed models skip content moderation; other models are moderated.',
modelFilterModels: 'Model list',
modelFilterModelCount: '{count} models configured',
modelFilterModelsRequired: 'This model scope requires at least 1 model',
modelFilterAllSummary: 'Applies to all models',
modelFilterIncludeSummary: 'Applies to {count} models',
modelFilterExcludeSummary: 'Excludes {count} models',
emptyLogs: 'No audit records',
workerStatus: 'Worker Runtime',
workerStatusHint: 'Queue and worker pool status for asynchronous observation tasks.',
+14
View File
@@ -2609,6 +2609,20 @@ export default {
selectedGroups: '指定分组',
searchGroups: '搜索分组名称或平台',
noGroups: '暂无可用分组',
modelFilter: '模型范围',
modelFilterHint: '按客户端请求的模型名决定是否执行内容审计,模型映射后仍以请求模型判断。',
modelFilterAll: '所有模型',
modelFilterAllDesc: '所有模型请求都会进入内容审计。',
modelFilterInclude: '仅指定模型',
modelFilterIncludeDesc: '只有列表中的模型会执行内容审计。',
modelFilterExclude: '排除指定模型',
modelFilterExcludeDesc: '列表中的模型跳过内容审计,其余模型执行审计。',
modelFilterModels: '模型列表',
modelFilterModelCount: '已配置 {count} 个模型',
modelFilterModelsRequired: '当前模型范围至少需要配置 1 个模型',
modelFilterAllSummary: '全部模型生效',
modelFilterIncludeSummary: '仅 {count} 个模型生效',
modelFilterExcludeSummary: '排除 {count} 个模型',
emptyLogs: '暂无审核记录',
workerStatus: 'Worker 运行状态',
workerStatusHint: '异步观察任务的队列和 worker 池状态。',
+165 -1
View File
@@ -145,6 +145,26 @@
</button>
</div>
<div class="flex flex-col gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900/30 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
<Icon name="filter" size="sm" class="flex-shrink-0 text-gray-400" />
<span class="font-medium">{{ t('admin.riskControl.modelFilter') }}</span>
<span class="truncate text-gray-500 dark:text-gray-400">{{ modelFilterSummary }}</span>
</div>
<div v-if="modelFilterPreviewModels.length > 0" class="flex flex-wrap gap-1.5">
<span
v-for="model in modelFilterPreviewModels"
:key="model"
class="inline-flex max-w-[180px] items-center truncate rounded-md bg-white px-2 py-1 font-mono text-xs text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300"
>
{{ model }}
</span>
<span v-if="hiddenModelFilterModelCount > 0" class="inline-flex rounded-md bg-white px-2 py-1 text-xs text-gray-500 shadow-sm dark:bg-dark-800 dark:text-gray-400">
+{{ hiddenModelFilterModelCount }}
</span>
</div>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-6">
<Select v-model="filters.result" :options="resultOptions" @change="reloadLogsFromFirstPage" />
<Select v-model="filters.group_id" :options="groupFilterOptions" @change="reloadLogsFromFirstPage" />
@@ -628,6 +648,52 @@
<p v-if="filteredGroups.length === 0" class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.noGroups') }}</p>
</div>
</div>
<div class="space-y-4 rounded-lg border border-gray-100 p-4 dark:border-dark-700">
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.modelFilter') }}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.modelFilterHint') }}</p>
</div>
<span class="inline-flex w-fit rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:bg-dark-700 dark:text-gray-300">
{{ modelFilterSummary }}
</span>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<button
v-for="option in modelFilterOptions"
:key="option.value"
type="button"
class="rounded-lg border p-3 text-left transition-colors"
:class="configForm.model_filter_type === 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="setModelFilterType(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.model_filter_type === 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 v-if="configForm.model_filter_type !== 'all'" class="space-y-2">
<label class="input-label">{{ t('admin.riskControl.modelFilterModels') }}</label>
<ModelWhitelistSelector v-model="configForm.model_filter_models" />
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.riskControl.modelFilterModelCount', { count: modelFilterModelCount }) }}
</p>
</div>
</div>
</div>
<div v-else-if="activeSettingsTab === 'runtime'" class="grid grid-cols-1 gap-5 lg:grid-cols-2">
@@ -887,11 +953,14 @@ import Icon from '@/components/icons/Icon.vue'
import Select from '@/components/common/Select.vue'
import Toggle from '@/components/common/Toggle.vue'
import Pagination from '@/components/common/Pagination.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { adminAPI } from '@/api/admin'
import type {
ContentModerationAPIKeyStatus,
ContentModerationConfig,
ContentModerationLog,
ContentModerationModelFilter,
ContentModerationModelFilterType,
ContentModerationRuntimeStatus,
ContentModerationTestAuditResult,
KeywordBlockingMode,
@@ -987,6 +1056,8 @@ const configForm = reactive({
pre_hash_check_enabled: false,
blocked_keywords_text: '',
keyword_blocking_mode: 'keyword_and_api' as KeywordBlockingMode,
model_filter_type: 'all' as ContentModerationModelFilterType,
model_filter_models: [] as string[],
})
const pagination = reactive({
@@ -1038,6 +1109,24 @@ const keywordBlockingModeOptions = computed<Array<{ value: KeywordBlockingMode;
},
])
const modelFilterOptions = computed<Array<{ value: ContentModerationModelFilterType; label: string; description: string }>>(() => [
{
value: 'all',
label: t('admin.riskControl.modelFilterAll'),
description: t('admin.riskControl.modelFilterAllDesc'),
},
{
value: 'include',
label: t('admin.riskControl.modelFilterInclude'),
description: t('admin.riskControl.modelFilterIncludeDesc'),
},
{
value: 'exclude',
label: t('admin.riskControl.modelFilterExclude'),
description: t('admin.riskControl.modelFilterExcludeDesc'),
},
])
type KeywordNoticeView = {
title: string
description: string
@@ -1120,6 +1209,22 @@ const groupFilterOptions = computed<SelectOption[]>(() => [
const selectedGroupCount = computed(() => String(configForm.group_ids.length))
const modelFilterModelCount = computed(() => configForm.model_filter_models.length)
const modelFilterSummary = computed(() => {
if (configForm.model_filter_type === 'include') {
return t('admin.riskControl.modelFilterIncludeSummary', { count: modelFilterModelCount.value })
}
if (configForm.model_filter_type === 'exclude') {
return t('admin.riskControl.modelFilterExcludeSummary', { count: modelFilterModelCount.value })
}
return t('admin.riskControl.modelFilterAllSummary')
})
const modelFilterPreviewModels = computed(() => configForm.model_filter_models.slice(0, 6))
const hiddenModelFilterModelCount = computed(() => Math.max(0, configForm.model_filter_models.length - modelFilterPreviewModels.value.length))
const filteredGroups = computed(() => {
const keyword = groupSearch.value.trim().toLowerCase()
if (!keyword) return groups.value
@@ -1238,7 +1343,7 @@ const overviewItems = computed<OverviewItem[]>(() => [
key: 'scope',
label: t('admin.riskControl.overview.groupScope'),
value: configForm.all_groups ? t('admin.riskControl.allGroups') : selectedGroupCount.value,
meta: configForm.all_groups ? t('admin.riskControl.allGroupsHint') : t('admin.riskControl.selectedGroupsHint'),
meta: modelFilterSummary.value,
icon: 'users',
iconClass: 'bg-violet-50 text-violet-600 dark:bg-violet-900/20 dark:text-violet-300',
},
@@ -1342,6 +1447,9 @@ function applyConfig(config: ContentModerationConfig) {
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)
const modelFilter = normalizeModelFilter(config.model_filter)
configForm.model_filter_type = modelFilter.type
configForm.model_filter_models = modelFilter.models
}
async function loadAll() {
@@ -1388,6 +1496,11 @@ async function loadStatus(silent = true) {
async function saveConfig() {
saving.value = true
try {
const modelFilterPayload = buildModelFilterPayload()
if (modelFilterPayload.type !== 'all' && modelFilterPayload.models.length === 0) {
appStore.showError(t('admin.riskControl.modelFilterModelsRequired'))
return
}
const payload: UpdateContentModerationConfig = {
enabled: configForm.enabled,
mode: configForm.mode,
@@ -1413,6 +1526,7 @@ async function saveConfig() {
pre_hash_check_enabled: configForm.pre_hash_check_enabled,
blocked_keywords: blockedKeywordList.value,
keyword_blocking_mode: configForm.keyword_blocking_mode,
model_filter: modelFilterPayload,
}
const keys = parseApiKeys(configForm.api_keys_text)
if (!payload.clear_api_key && configForm.api_keys_mode === 'replace' && keys.length === 0) {
@@ -1568,6 +1682,13 @@ function setAPIKeysMode(mode: APIKeysWriteMode) {
}
}
function setModelFilterType(type: ContentModerationModelFilterType) {
configForm.model_filter_type = type
if (type === 'all') {
configForm.model_filter_models = []
}
}
async function testApiKeys(useInputKeys: boolean) {
const keys = useInputKeys ? parseApiKeys(configForm.api_keys_text) : []
if (useInputKeys && keys.length === 0) {
@@ -1824,6 +1945,49 @@ function normalizeKeywordBlockingMode(value: unknown): KeywordBlockingMode {
return 'keyword_and_api'
}
function normalizeModelFilter(value: unknown): ContentModerationModelFilter {
if (!value || typeof value !== 'object') {
return { type: 'all', models: [] }
}
const raw = value as Partial<ContentModerationModelFilter>
const type = normalizeModelFilterType(raw.type)
const models = type === 'all' ? [] : normalizeModelNames(raw.models)
return { type, models }
}
function normalizeModelFilterType(value: unknown): ContentModerationModelFilterType {
if (value === 'include' || value === 'exclude' || value === 'all') {
return value
}
return 'all'
}
function normalizeModelNames(models: unknown): string[] {
if (!Array.isArray(models)) return []
const seen = new Set<string>()
const out: string[] = []
for (const item of models) {
const model = String(item ?? '').trim()
if (!model) continue
const key = model.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push(model)
}
return out
}
function buildModelFilterPayload(): ContentModerationModelFilter {
const type = normalizeModelFilterType(configForm.model_filter_type)
if (type === 'all') {
return { type: 'all', models: [] }
}
return {
type,
models: normalizeModelNames(configForm.model_filter_models),
}
}
function parseBlockedKeywords(value: string): string[] {
const seen = new Set<string>()
const out: string[] = []
@@ -0,0 +1,227 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
import RiskControlView from '../RiskControlView.vue'
import type { ContentModerationConfig, UpdateContentModerationConfig } from '@/api/admin/riskControl'
const {
getConfig,
updateConfig,
getStatus,
listLogs,
getGroups,
showError,
showSuccess,
} = vi.hoisted(() => ({
getConfig: vi.fn(),
updateConfig: vi.fn(),
getStatus: vi.fn(),
listLogs: vi.fn(),
getGroups: vi.fn(),
showError: vi.fn(),
showSuccess: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
riskControl: {
getConfig,
updateConfig,
getStatus,
listLogs,
testAPIKeys: vi.fn(),
deleteFlaggedHash: vi.fn(),
clearFlaggedHashes: vi.fn(),
unbanUser: vi.fn(),
},
groups: {
getAll: getGroups,
},
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
showSuccess,
}),
}))
vi.mock('@/utils/apiError', () => ({
extractApiErrorMessage: (_err: unknown, fallback: string) => fallback,
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, string | number>) =>
key.replace(/\{(\w+)\}/g, (_, token) => String(params?.[token] ?? `{${token}}`)),
}),
}
})
const baseConfig = (): ContentModerationConfig => ({
enabled: true,
mode: 'pre_block',
base_url: 'https://api.openai.com',
model: 'omni-moderation-latest',
api_key_configured: false,
api_key_masked: '',
api_key_count: 0,
api_key_masks: [],
api_key_statuses: [],
timeout_ms: 3000,
sample_rate: 100,
all_groups: true,
group_ids: [],
record_non_hits: false,
worker_count: 4,
queue_size: 32768,
block_status: 403,
block_message: '内容审计命中风险规则,请调整输入后重试',
email_on_hit: true,
auto_ban_enabled: true,
ban_threshold: 10,
violation_window_hours: 720,
retry_count: 2,
hit_retention_days: 180,
non_hit_retention_days: 3,
pre_hash_check_enabled: false,
blocked_keywords: [],
keyword_blocking_mode: 'keyword_and_api',
model_filter: {
type: 'all',
models: [],
},
})
const runtimeStatus = () => ({
enabled: true,
risk_control_enabled: true,
mode: 'pre_block',
worker_count: 4,
max_workers: 32,
active_workers: 0,
idle_workers: 4,
queue_size: 32768,
queue_length: 0,
queue_usage_percent: 0,
enqueued: 0,
dropped: 0,
processed: 0,
errors: 0,
api_key_statuses: [],
flagged_hash_count: 0,
last_cleanup_deleted_hit: 0,
last_cleanup_deleted_non_hit: 0,
})
const AppLayoutStub = { template: '<div><slot /></div>' }
const BaseDialogStub = defineComponent({
props: {
show: {
type: Boolean,
default: false,
},
},
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
})
const ModelWhitelistSelectorStub = defineComponent({
props: {
modelValue: {
type: Array,
default: () => [],
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const onInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit(
'update:modelValue',
value
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean)
)
}
return () =>
h('input', {
'data-test': 'model-filter-input',
value: (props.modelValue as string[]).join('\n'),
onInput,
})
},
})
function findButtonByText(wrapper: VueWrapper, text: string): DOMWrapper<HTMLButtonElement> {
const button = wrapper.findAll<HTMLButtonElement>('button').find((item) => item.text().includes(text))
if (!button) {
throw new Error(`button not found: ${text}`)
}
return button
}
describe('admin RiskControlView', () => {
beforeEach(() => {
getConfig.mockReset()
updateConfig.mockReset()
getStatus.mockReset()
listLogs.mockReset()
getGroups.mockReset()
showError.mockReset()
showSuccess.mockReset()
getConfig.mockResolvedValue(baseConfig())
getStatus.mockResolvedValue(runtimeStatus())
listLogs.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 1 })
getGroups.mockResolvedValue([])
updateConfig.mockImplementation(async (payload: UpdateContentModerationConfig) => ({
...baseConfig(),
...payload,
model_filter: payload.model_filter ?? baseConfig().model_filter,
api_key_configured: false,
api_key_masked: '',
api_key_count: 0,
api_key_masks: [],
api_key_statuses: [],
}))
})
it('saves the selected model filter mode and models', async () => {
const wrapper = mount(RiskControlView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
BaseDialog: BaseDialogStub,
Icon: true,
Select: true,
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
},
},
})
await flushPromises()
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
await findButtonByText(wrapper, 'admin.riskControl.tabs.scope').trigger('click')
await findButtonByText(wrapper, 'admin.riskControl.modelFilterInclude').trigger('click')
await wrapper.get('[data-test="model-filter-input"]').setValue('gpt-5.5, gpt-5.4')
await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click')
await flushPromises()
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
model_filter: {
type: 'include',
models: ['gpt-5.5', 'gpt-5.4'],
},
}))
expect(showError).not.toHaveBeenCalled()
})
})