mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 00:21:34 +00:00
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:
@@ -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: '启用前置哈希比对',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user