feat(moderation): route content moderation through configurable proxy server

Implements #2646: the risk-control content audit can now send OpenAI
Moderations requests through a proxy from IP Management - Proxy Servers.

Backend:
- ContentModerationConfig gains proxy_id (nil = direct, unchanged default)
- update semantics: null keeps, 0 clears, >0 selects (validated to exist)
- moderation calls build the client via the shared httpclient pool; proxy
  resolution failure surfaces as a moderation error and never silently
  falls back to direct connection
- proxy_id -> URL resolution cached 60s (single-entry, invalidated on
  config save) so the pre-block hot path does not hit the DB per request
- test-key endpoint accepts proxy_id too (null = saved config's proxy,
  0 = force direct), so input-key/saved-key tests exercise the same path
- proxy usage/inactivity logged (content_moderation.proxy_enabled /
  proxy_not_active) without leaking credentials

Frontend:
- ProxySelector in the risk-control basic settings tab, proxy list loaded
  non-blockingly; save and test payloads carry proxy_id; zh/en i18n
This commit is contained in:
shaw
2026-07-31 23:12:22 +08:00
parent 4c80d160dd
commit 948b63c9ca
12 changed files with 512 additions and 41 deletions
+5
View File
@@ -14,6 +14,7 @@ export interface ContentModerationConfig {
mode: ModerationMode
base_url: string
model: string
proxy_id: number | null
api_key_configured: boolean
api_key_masked: string
api_key_count: number
@@ -66,6 +67,8 @@ export interface TestContentModerationAPIKeysPayload {
base_url?: string
model?: string
timeout_ms?: number
// null/undefined 沿用已保存配置的代理;0 强制直连;>0 指定代理
proxy_id?: number
prompt?: string
images?: string[]
}
@@ -90,6 +93,8 @@ export interface UpdateContentModerationConfig {
mode?: ModerationMode
base_url?: string
model?: string
// undefined 不修改;0 清除(直连);>0 指定代理
proxy_id?: number
api_key?: string
api_keys?: string[]
api_keys_mode?: 'append' | 'replace'
@@ -286,6 +286,8 @@ export default {
timeoutMs: 'HTTP Timeout (ms)',
retryCount: 'Retry Count',
sampleRate: 'Sample Rate',
proxy: 'Proxy Server',
proxyHint: 'Send moderation requests through the selected proxy (IP Management - Proxy Servers), useful when the egress IP is not supported by OpenAI. Defaults to direct connection.',
recordNonHits: 'Record Non-Hits',
recordNonHitsHint: 'When enabled, sampled non-hit request summaries are redacted before storage.',
preHashCheck: 'Enable Pre-Hash Check',
@@ -286,6 +286,8 @@ export default {
timeoutMs: 'HTTP 超时 (ms)',
retryCount: '失败重试次数',
sampleRate: '采样率',
proxy: '代理服务器',
proxyHint: '审计请求经指定代理(IP管理-代理服务器)发出,适用于出口 IP 不受 OpenAI 支持的部署;默认直连。',
recordNonHits: '记录未命中输入',
recordNonHitsHint: '开启后会记录抽样但未命中的请求摘要,摘要会先脱敏再入库。',
preHashCheck: '启用前置哈希比对',
+18 -2
View File
@@ -424,6 +424,11 @@
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.riskControl.proxy') }}</label>
<ProxySelector v-model="configForm.proxy_id" :proxies="proxies" />
<p class="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.proxyHint') }}</p>
</div>
</div>
<div class="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm dark:border-dark-700 dark:bg-dark-800">
@@ -1123,6 +1128,7 @@ 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 ProxySelector from '@/components/common/ProxySelector.vue'
import { adminAPI } from '@/api/admin'
import type {
ContentModerationAPIKeyLoad,
@@ -1137,7 +1143,7 @@ import type {
ModerationMode,
UpdateContentModerationConfig,
} from '@/api/admin/riskControl'
import type { AdminGroup, SelectOption } from '@/types'
import type { AdminGroup, Proxy, SelectOption } from '@/types'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { formatDateTime as formatDateTimeValue } from '@/utils/format'
@@ -1205,6 +1211,7 @@ const activeSettingsTab = ref<SettingsTab>('basic')
const groupSearch = ref('')
const flaggedHashInput = ref('')
const groups = ref<AdminGroup[]>([])
const proxies = ref<Proxy[]>([])
const logs = ref<ContentModerationLog[]>([])
const status = ref<ContentModerationRuntimeStatus | null>(null)
const testedApiKeyStatuses = ref<ContentModerationAPIKeyStatus[]>([])
@@ -1221,6 +1228,7 @@ const configForm = reactive({
mode: 'pre_block' as ModerationMode,
base_url: 'https://api.openai.com',
model: 'omni-moderation-latest',
proxy_id: null as number | null,
api_keys_text: '',
api_key_configured: false,
api_key_masked: '',
@@ -1695,6 +1703,7 @@ function applyConfig(config: ContentModerationConfig) {
configForm.mode = config.mode
configForm.base_url = config.base_url || 'https://api.openai.com'
configForm.model = config.model || 'omni-moderation-latest'
configForm.proxy_id = config.proxy_id || null
configForm.api_keys_text = ''
configForm.api_key_configured = config.api_key_configured
configForm.api_key_masked = config.api_key_masked || ''
@@ -1735,14 +1744,17 @@ function applyConfig(config: ContentModerationConfig) {
async function loadAll() {
loading.value = true
try {
const [config, groupItems, runtimeStatus] = await Promise.all([
const [config, groupItems, runtimeStatus, proxyItems] = await Promise.all([
adminAPI.riskControl.getConfig(),
adminAPI.groups.getAll(),
adminAPI.riskControl.getStatus(),
// 代理列表加载失败不阻塞风控页面(仅影响下拉可选项)
adminAPI.proxies.getAll().catch(() => [] as Proxy[]),
])
applyConfig(config)
groups.value = groupItems
status.value = runtimeStatus
proxies.value = proxyItems
if (Array.isArray(runtimeStatus.api_key_statuses)) {
configForm.api_key_statuses = [...runtimeStatus.api_key_statuses]
prunePendingDeleteAPIKeyHashes()
@@ -1786,6 +1798,8 @@ async function saveConfig() {
mode: configForm.mode,
base_url: configForm.base_url,
model: configForm.model,
// 后端语义:0 清除代理(直连),>0 指定代理
proxy_id: configForm.proxy_id ?? 0,
timeout_ms: Number(configForm.timeout_ms) || 3000,
retry_count: Number(configForm.retry_count) || 0,
sample_rate: Number(configForm.sample_rate) || 0,
@@ -1984,6 +1998,8 @@ async function testApiKeys(useInputKeys: boolean) {
base_url: configForm.base_url,
model: configForm.model,
timeout_ms: Number(configForm.timeout_ms) || 3000,
// 与保存语义一致:0 强制直连,>0 指定代理,确保测试与实际审计走同一条链路
proxy_id: configForm.proxy_id ?? 0,
prompt: moderationTestPrompt.value,
images: moderationTestImages.value,
})
@@ -12,6 +12,7 @@ const {
getStatus,
listLogs,
getGroups,
getProxies,
showError,
showSuccess,
} = vi.hoisted(() => ({
@@ -20,6 +21,7 @@ const {
getStatus: vi.fn(),
listLogs: vi.fn(),
getGroups: vi.fn(),
getProxies: vi.fn(),
showError: vi.fn(),
showSuccess: vi.fn(),
}))
@@ -39,6 +41,9 @@ vi.mock('@/api/admin', () => ({
groups: {
getAll: getGroups,
},
proxies: {
getAll: getProxies,
},
},
}))
@@ -73,6 +78,7 @@ const baseConfig = (): ContentModerationConfig => ({
mode: 'pre_block',
base_url: 'https://api.openai.com',
model: 'omni-moderation-latest',
proxy_id: null,
api_key_configured: false,
api_key_masked: '',
api_key_count: 0,
@@ -198,6 +204,7 @@ describe('admin RiskControlView', () => {
getStatus.mockResolvedValue(runtimeStatus())
listLogs.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 1 })
getGroups.mockResolvedValue([])
getProxies.mockResolvedValue([])
updateConfig.mockImplementation(async (payload: UpdateContentModerationConfig) => ({
...baseConfig(),
...payload,
@@ -221,6 +228,7 @@ describe('admin RiskControlView', () => {
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
ProxySelector: true,
},
},
})
@@ -254,6 +262,7 @@ describe('admin RiskControlView', () => {
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
ProxySelector: true,
},
},
})
@@ -294,6 +303,7 @@ describe('admin RiskControlView', () => {
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
ProxySelector: true,
},
},
})
@@ -361,6 +371,7 @@ describe('admin RiskControlView', () => {
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
ProxySelector: true,
},
},
})