feat(risk-control): add content moderation audit

This commit is contained in:
shaw
2026-05-07 09:01:48 +08:00
parent a1106e8167
commit fff4a300c6
54 changed files with 6840 additions and 34 deletions
+6 -2
View File
@@ -30,6 +30,7 @@ import channelMonitorAPI from './channelMonitor'
import channelMonitorTemplateAPI from './channelMonitorTemplate'
import adminPaymentAPI from './payment'
import affiliatesAPI from './affiliates'
import riskControlAPI from './riskControl'
/**
* Unified admin API object for convenient access
@@ -61,7 +62,8 @@ export const adminAPI = {
channelMonitor: channelMonitorAPI,
channelMonitorTemplate: channelMonitorTemplateAPI,
payment: adminPaymentAPI,
affiliates: affiliatesAPI
affiliates: affiliatesAPI,
riskControl: riskControlAPI
}
export {
@@ -91,7 +93,8 @@ export {
channelMonitorAPI,
channelMonitorTemplateAPI,
adminPaymentAPI,
affiliatesAPI
affiliatesAPI,
riskControlAPI
}
export default adminAPI
@@ -101,3 +104,4 @@ export type { BalanceHistoryItem } from './users'
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'
export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile'
export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl'
+251
View File
@@ -0,0 +1,251 @@
import { apiClient } from '../client'
export type ModerationMode = 'off' | 'observe' | 'pre_block'
export interface ContentModerationConfig {
enabled: boolean
mode: ModerationMode
base_url: string
model: string
api_key_configured: boolean
api_key_masked: string
api_key_count: number
api_key_masks: string[]
api_key_statuses: ContentModerationAPIKeyStatus[]
timeout_ms: number
sample_rate: number
all_groups: boolean
group_ids: number[]
record_non_hits: boolean
worker_count: number
queue_size: number
block_status: number
block_message: string
email_on_hit: boolean
auto_ban_enabled: boolean
ban_threshold: number
violation_window_hours: number
retry_count: number
hit_retention_days: number
non_hit_retention_days: number
pre_hash_check_enabled: boolean
}
export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen'
export interface ContentModerationAPIKeyStatus {
index: number
key_hash: string
masked: string
status: ContentModerationAPIKeyStatusValue
failure_count: number
success_count: number
last_error: string
last_checked_at?: string
frozen_until?: string
last_latency_ms: number
last_http_status: number
last_tested: boolean
configured: boolean
}
export interface TestContentModerationAPIKeysPayload {
api_keys?: string[]
base_url?: string
model?: string
timeout_ms?: number
prompt?: string
images?: string[]
}
export interface TestContentModerationAPIKeysResponse {
items: ContentModerationAPIKeyStatus[]
audit_result?: ContentModerationTestAuditResult
image_count: number
}
export interface ContentModerationTestAuditResult {
flagged: boolean
highest_category: string
highest_score: number
composite_score: number
category_scores: Record<string, number>
thresholds: Record<string, number>
}
export interface UpdateContentModerationConfig {
enabled?: boolean
mode?: ModerationMode
base_url?: string
model?: string
api_key?: string
api_keys?: string[]
clear_api_key?: boolean
timeout_ms?: number
sample_rate?: number
all_groups?: boolean
group_ids?: number[]
record_non_hits?: boolean
worker_count?: number
queue_size?: number
block_status?: number
block_message?: string
email_on_hit?: boolean
auto_ban_enabled?: boolean
ban_threshold?: number
violation_window_hours?: number
retry_count?: number
hit_retention_days?: number
non_hit_retention_days?: number
pre_hash_check_enabled?: boolean
}
export interface ContentModerationRuntimeStatus {
enabled: boolean
risk_control_enabled: boolean
mode: ModerationMode
worker_count: number
max_workers: number
active_workers: number
idle_workers: number
queue_size: number
queue_length: number
queue_usage_percent: number
enqueued: number
dropped: number
processed: number
errors: number
api_key_statuses: ContentModerationAPIKeyStatus[]
flagged_hash_count: number
last_cleanup_at?: string
last_cleanup_deleted_hit: number
last_cleanup_deleted_non_hit: number
}
export interface ContentModerationLog {
id: number
request_id: string
user_id: number | null
user_email: string
api_key_id: number | null
api_key_name: string
group_id: number | null
group_name: string
endpoint: string
provider: string
model: string
mode: string
action: string
flagged: boolean
highest_category: string
highest_score: number
category_scores: Record<string, number>
threshold_snapshot: Record<string, number>
input_excerpt: string
upstream_latency_ms: number | null
error: string
violation_count: number
auto_banned: boolean
email_sent: boolean
user_status: string
queue_delay_ms: number | null
created_at: string
}
export interface ListContentModerationLogsParams {
page?: number
page_size?: number
result?: string
group_id?: number
endpoint?: string
search?: string
from?: string
to?: string
}
export interface ContentModerationLogsResponse {
items: ContentModerationLog[]
total: number
page: number
page_size: number
pages: number
}
export interface ContentModerationUnbanUserResponse {
user_id: number
status: string
}
export interface DeleteFlaggedHashResponse {
input_hash: string
deleted: boolean
}
export interface ClearFlaggedHashesResponse {
deleted: number
}
export async function getConfig(): Promise<ContentModerationConfig> {
const { data } = await apiClient.get<ContentModerationConfig>('/admin/risk-control/config')
return data
}
export async function updateConfig(
payload: UpdateContentModerationConfig
): Promise<ContentModerationConfig> {
const { data } = await apiClient.put<ContentModerationConfig>('/admin/risk-control/config', payload)
return data
}
export async function getStatus(): Promise<ContentModerationRuntimeStatus> {
const { data } = await apiClient.get<ContentModerationRuntimeStatus>('/admin/risk-control/status')
return data
}
export async function testAPIKeys(
payload: TestContentModerationAPIKeysPayload = {}
): Promise<TestContentModerationAPIKeysResponse> {
const { data } = await apiClient.post<TestContentModerationAPIKeysResponse>('/admin/risk-control/api-keys/test', payload)
return data
}
export async function listLogs(
params: ListContentModerationLogsParams = {}
): Promise<ContentModerationLogsResponse> {
const { data } = await apiClient.get<ContentModerationLogsResponse>('/admin/risk-control/logs', {
params,
})
return data
}
export async function unbanUser(userID: number): Promise<ContentModerationUnbanUserResponse> {
const { data } = await apiClient.post<ContentModerationUnbanUserResponse>(
`/admin/risk-control/users/${userID}/unban`
)
return data
}
export async function deleteFlaggedHash(inputHash: string): Promise<DeleteFlaggedHashResponse> {
const { data } = await apiClient.delete<DeleteFlaggedHashResponse>('/admin/risk-control/hashes', {
data: { input_hash: inputHash },
})
return data
}
export async function clearFlaggedHashes(): Promise<ClearFlaggedHashesResponse> {
const { data } = await apiClient.delete<ClearFlaggedHashesResponse>('/admin/risk-control/hashes/all')
return data
}
export const riskControlAPI = {
getConfig,
updateConfig,
getStatus,
testAPIKeys,
listLogs,
unbanUser,
deleteFlaggedHash,
clearFlaggedHashes,
}
export default riskControlAPI
+2
View File
@@ -444,6 +444,7 @@ export interface SystemSettings {
// Payment configuration
payment_enabled: boolean;
risk_control_enabled: boolean;
payment_min_amount: number;
payment_max_amount: number;
payment_daily_limit: number;
@@ -613,6 +614,7 @@ export interface UpdateSettingsRequest {
enable_anthropic_cache_ttl_1h_injection?: boolean;
// Payment configuration
payment_enabled?: boolean;
risk_control_enabled?: boolean;
payment_min_amount?: number;
payment_max_amount?: number;
payment_daily_limit?: number;
@@ -593,6 +593,21 @@ const SignalIcon = {
)
}
const ShieldIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z'
})
]
)
}
const PriceTagIcon = {
render: () =>
h(
@@ -635,6 +650,7 @@ const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
const flagRiskControl = makeSidebarFlag(FeatureFlags.riskControl)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
@@ -719,6 +735,7 @@ const adminNavItems = computed((): NavItem[] => {
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/risk-control', label: t('nav.riskControl'), icon: ShieldIcon, hideInSimpleMode: true, featureFlag: flagRiskControl },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
{
+205
View File
@@ -382,6 +382,7 @@ export default {
channelPricing: 'Channel Pricing',
channelMonitor: 'Channel Monitor',
channelStatus: 'Channel Status',
riskControl: 'Risk Control',
},
// Auth
@@ -410,6 +411,9 @@ export default {
passwordRequired: 'Password is required',
passwordMinLength: 'Password must be at least 6 characters',
loginFailed: 'Login failed. Please check your credentials and try again.',
errors: {
USER_NOT_ACTIVE: 'Account has been disabled.',
},
registrationFailed: 'Registration failed. Please try again.',
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
emailSuffixNotAllowedWithAllowed:
@@ -2305,6 +2309,200 @@ export default {
}
},
riskControl: {
title: 'Risk Control',
description: 'Configure content moderation and review audit records',
loadFailed: 'Failed to load risk control',
saveFailed: 'Failed to save content moderation config',
logsFailed: 'Failed to load audit records',
saved: 'Content moderation config saved',
refresh: 'Refresh',
config: 'Content Moderation Config',
configHint: 'Use OpenAI Moderations to score request content and handle threshold hits by mode.',
openSettings: 'Moderation Settings',
settingsTitle: 'Content Moderation Settings',
refreshStatus: 'Refresh Status',
records: 'Audit Records',
recordsHint: 'Shows hits, blocks, errors, and sampled records.',
saveConfig: 'Save Moderation Config',
statusFailed: 'Failed to load runtime status',
enabled: 'Enable Content Moderation',
enabledHint: 'When off, gateway requests are not moderated even if the menu is enabled.',
mode: 'Global Mode',
modePreBlock: 'Pre-Block',
modePreBlockDesc: 'Synchronously reviews the latest user input before every request and rejects hits immediately.',
modeObserve: 'Observe Only',
modeObserveDesc: 'Requests pass through while the latest user input is queued for async review; hits are recorded, notified, and counted.',
modeOff: 'Off',
modeOffDesc: 'Content moderation is disabled and no audit records are written.',
baseUrl: 'OpenAI Base URL',
model: 'Model',
apiKey: 'OpenAI API Key',
apiKeys: 'OpenAI API Keys',
apiKeyCount: '{count} keys',
apiKeyPlaceholder: 'Enter API Key',
apiKeysPlaceholder: 'One API Key per line',
apiKeysPlaceholderKeep: 'Leave empty to keep stored keys; enter values to replace them',
apiKeysHint: '{count} keys are currently stored. Values entered here replace stored keys; leave empty to keep them.',
apiKeyPlaceholderKeep: 'Leave empty to keep current key',
apiKeyWillClear: 'Configured key will be cleared on save',
apiKeyConfigured: 'Configured',
apiKeyTemporary: 'Pending',
inputApiKeyCount: '{count} keys in input',
storedApiKeyCount: '{count} stored keys',
testInputApiKeys: 'Test input keys',
testStoredApiKeys: 'Test stored keys',
testContentWithStoredApiKey: 'Test content with stored key',
testingApiKeys: 'Testing',
apiKeyTestNoInput: 'Enter OpenAI API Keys to test first',
apiKeyTestDone: 'Key test completed for {count} keys',
apiKeyTestFailed: 'Failed to test OpenAI API Keys',
apiKeyHealth: 'Key Availability',
apiKeyFreezeRule: 'Three consecutive failures freeze a key for 1 minute; moderation rotation skips frozen keys.',
apiKeyRows: '{count} keys',
apiKeyHealthEmpty: 'No key status yet',
apiKeyHealthEmptyHint: 'Save keys or test input keys to see availability.',
apiKeyStatusOk: 'Available',
apiKeyStatusError: 'Error',
apiKeyStatusFrozen: 'Frozen',
apiKeyStatusUnknown: 'Untested',
apiKeyFailureCount: '{count} failures',
apiKeyLatency: '{ms} ms',
apiKeyHTTPStatus: 'HTTP {status}',
apiKeyFrozenUntil: 'Frozen until {time}',
apiKeyLastChecked: 'Checked at {time}',
apiKeyNotTested: 'Not tested',
auditTestInput: 'Audit Test Input',
auditTestInputHint: 'Enter a prompt and upload or paste images; images are sent as base64 and are not stored.',
auditTestPromptPlaceholder: 'Enter a user prompt to test; leave empty to only test key availability.',
auditTestImages: 'Test Images',
auditTestImagesHint: 'Upload, drag, or paste images. Up to 4 images, 8MB each.',
addAuditTestImage: 'Add image',
clearAuditTest: 'Clear test',
auditTestImageLimit: 'You can add up to {count} test images',
auditTestImageTooLarge: 'Each test image must be 8MB or smaller',
auditTestImageReadFailed: 'Failed to read test image',
auditTestResult: 'Audit Test Result',
auditTestHighest: 'Top category {category}, score {score}',
auditTestComposite: 'Composite score',
auditTestFlagged: 'Threshold hit',
auditTestPassed: 'Pass',
notConfigured: 'Not configured',
clearApiKey: 'Clear stored key',
keepApiKey: 'Keep stored key',
timeoutMs: 'HTTP Timeout (ms)',
retryCount: 'Retry Count',
sampleRate: 'Sample Rate',
recordNonHits: 'Record Non-Hits',
recordNonHitsHint: 'When enabled, sampled non-hit request summaries are redacted before storage.',
preHashCheck: 'Enable Pre-Hash Check',
preHashCheckHint: 'Hashes from async hits are blocked before moderation; this does not send email or increment ban counters.',
flaggedHashCount: 'Current hash collection size: {count}',
flaggedHashHint: 'Hashes are stored permanently in Redis; paste a full 64-character hash to remove a false block, or clear all stored hashes.',
flaggedHashPlaceholder: 'Paste full 64-character input hash',
deleteFlaggedHash: 'Delete hash',
clearFlaggedHashes: 'Clear all',
clearFlaggedHashesConfirm: 'Clear all risk input hashes? This does not delete audit records, but removes all historical hash blocks.',
flaggedHashDeleted: 'Risk hash deleted',
flaggedHashNotFound: 'Risk hash not found',
flaggedHashDeleteFailed: 'Failed to delete risk hash',
flaggedHashesCleared: 'Cleared {count} risk hashes',
flaggedHashesClearFailed: 'Failed to clear risk hashes',
workerCount: 'Worker Count',
queueSize: 'Async Queue Size',
blockStatus: 'Block HTTP Status',
blockMessage: 'Custom Block Message',
emailOnHit: 'Email on Hit',
emailOnHitHint: 'When enabled, send a risk-control email on every hit; auto-ban notices are always sent.',
autoBan: 'Auto Ban User',
autoBanHint: 'Disable the user, invalidate auth cache, and send a ban notice after the hit threshold is reached.',
banThreshold: 'Ban Threshold',
violationWindowHours: 'Count Window (hours)',
hitRetentionDays: 'Hit Record Retention (days)',
nonHitRetentionDays: 'Non-Hit Record Retention (days, max 3)',
violationCount: '{count} hits',
emailSent: 'Email sent',
emailNotSent: 'No email',
autoBanned: 'Banned',
unbanUser: 'Unban',
unbanSuccess: 'User has been unbanned',
unbanFailed: 'Failed to unban user',
inputDetailTitle: 'Input Summary Detail',
inputDetailContent: 'Full Content',
queueDelay: 'Queued {ms} ms',
allGroups: 'All Groups',
allGroupsHint: 'Auditing all groups',
selectedGroupsHint: 'Auditing selected groups',
groupScope: 'Audit Groups',
groupScopeHint: 'Switch on for all groups, or turn off to choose specific groups.',
selectedGroups: 'Selected Groups',
searchGroups: 'Search group name or platform',
noGroups: 'No groups available',
emptyLogs: 'No audit records',
workerStatus: 'Worker Runtime',
workerStatusHint: 'Queue and worker pool status for asynchronous observation tasks.',
workerPool: 'Worker Pool',
workerPoolMeta: '{active} processing, {idle} idle and ready, {total} total',
queueUsage: 'Queue Usage',
activeWorkers: 'Processing',
idleWorkers: 'Idle Ready',
workerActive: 'Processing an asynchronous audit task',
workerIdle: 'Started, idle and ready',
workerDisabled: 'Risk control or content audit is disabled',
processed: 'Processed',
droppedErrors: 'Dropped / Errors',
autoRefresh: 'Auto refresh every 15s',
lastCleanup: 'Last cleanup: {time}',
cleanupStats: 'Last cleanup deleted {hit} hits and {nonHit} non-hits',
riskSwitchOff: 'System switch off',
tabs: {
basic: 'Basic',
scope: 'Scope',
runtime: 'Runtime',
response: 'Hit Notice',
retention: 'Retention',
},
overview: {
status: 'Status',
enabled: 'Enabled',
disabled: 'Disabled',
apiKey: 'API Key',
groupScope: 'Scope',
logs: 'Audit Records',
currentFilter: 'Current filter',
},
filters: {
search: 'Search user/key/summary',
from: 'From',
to: 'To',
allGroups: 'All Groups',
allEndpoints: 'All Endpoints',
},
table: {
time: 'Time',
group: 'Group',
user: 'User',
apiKey: 'API Key',
endpoint: 'Endpoint',
result: 'Result',
highest: 'Highest',
actionMeta: 'Action',
latency: 'Latency',
input: 'Input Summary',
},
result: {
all: 'All Results',
hit: 'Hit',
blocked: 'Blocked',
pass: 'Pass',
error: 'Error',
},
action: {
block: 'Blocked',
error: 'Error',
},
},
// Channel Monitor
channelMonitor: {
title: 'Channel Monitor',
@@ -4862,6 +5060,13 @@ export default {
enabled: 'Enable Available Channels',
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
},
riskControl: {
title: 'Risk Control',
description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.',
configureLink: 'Configure content moderation in Risk Control',
enabled: 'Enable Risk Control',
enabledHint: 'When off, the admin sidebar entry is hidden and gateway moderation is skipped.',
},
affiliate: {
title: 'Affiliate (Invite Rebate)',
description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitees recharges. Disabled by default.',
+205
View File
@@ -382,6 +382,7 @@ export default {
channelPricing: '渠道定价',
channelMonitor: '渠道监控',
channelStatus: '渠道状态',
riskControl: '风控中心',
},
// Auth
@@ -410,6 +411,9 @@ export default {
passwordRequired: '请输入密码',
passwordMinLength: '密码至少需要 6 个字符',
loginFailed: '登录失败,请检查您的凭据后重试。',
errors: {
USER_NOT_ACTIVE: '账号已被禁用',
},
registrationFailed: '注册失败,请重试。',
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
@@ -2382,6 +2386,200 @@ export default {
}
},
riskControl: {
title: '风控中心',
description: '配置内容审计策略并查看审核记录',
loadFailed: '加载风控中心失败',
saveFailed: '保存内容审计配置失败',
logsFailed: '加载审核记录失败',
saved: '内容审计配置已保存',
refresh: '刷新',
config: '内容审计配置',
configHint: '调用 OpenAI Moderations 进行请求内容评分,命中阈值后按模式处理。',
openSettings: '内容审计设置',
settingsTitle: '内容审计设置',
refreshStatus: '刷新状态',
records: '审核记录',
recordsHint: '展示命中、拦截、异常和已采样记录。',
saveConfig: '保存内容审计配置',
statusFailed: '加载运行状态失败',
enabled: '开启内容审计',
enabledHint: '关闭后即使风控中心菜单启用,也不会审核网关请求。',
mode: '全局模式',
modePreBlock: '前置拦截',
modePreBlockDesc: '每次请求先同步审核最新用户输入,命中后立即拒绝请求。',
modeObserve: '仅观察',
modeObserveDesc: '请求直接放行,最新用户输入进入异步审核队列;命中后只记录、通知和按规则累计。',
modeOff: '关闭',
modeOffDesc: '不执行内容审计,也不会写入审核记录。',
baseUrl: 'OpenAI Base URL',
model: '模型名',
apiKey: 'OpenAI API Key',
apiKeys: 'OpenAI API Keys',
apiKeyCount: '{count} 个 Key',
apiKeyPlaceholder: '请输入 API Key',
apiKeysPlaceholder: '每行一个 API Key',
apiKeysPlaceholderKeep: '留空保持已保存的 Key;填写后将替换为这些 Key',
apiKeysHint: '当前已保存 {count} 个 Key;填写文本框会替换已保存 Key,留空则保持不变。',
apiKeyPlaceholderKeep: '留空保持不变',
apiKeyWillClear: '保存后清除已配置 Key',
apiKeyConfigured: '已配置',
apiKeyTemporary: '待保存',
inputApiKeyCount: '输入区 {count} 个 Key',
storedApiKeyCount: '已保存 {count} 个 Key',
testInputApiKeys: '测试输入区 Key',
testStoredApiKeys: '测试已保存 Key',
testContentWithStoredApiKey: '用已保存 Key 试跑内容',
testingApiKeys: '测试中',
apiKeyTestNoInput: '请先输入需要测试的 OpenAI API Key',
apiKeyTestDone: 'Key 测试完成,共 {count} 个',
apiKeyTestFailed: '测试 OpenAI API Key 失败',
apiKeyHealth: 'Key 可用状态',
apiKeyFreezeRule: '连续 3 次失败会冻结 1 分钟,审计轮询会自动跳过。',
apiKeyRows: '{count} 个',
apiKeyHealthEmpty: '暂无 Key 状态',
apiKeyHealthEmptyHint: '保存 Key 或测试输入区 Key 后会显示可用性。',
apiKeyStatusOk: '可用',
apiKeyStatusError: '异常',
apiKeyStatusFrozen: '冻结',
apiKeyStatusUnknown: '未测试',
apiKeyFailureCount: '失败 {count} 次',
apiKeyLatency: '{ms} ms',
apiKeyHTTPStatus: 'HTTP {status}',
apiKeyFrozenUntil: '冻结至 {time}',
apiKeyLastChecked: '检查于 {time}',
apiKeyNotTested: '尚未测试',
auditTestInput: '审计试跑输入',
auditTestInputHint: '可填写提示词并上传或粘贴图片;图片以 base64 发送,不会保存文件。',
auditTestPromptPlaceholder: '输入要测试的用户提示词;留空时仅测试 Key 可用性。',
auditTestImages: '测试图片',
auditTestImagesHint: '支持上传、拖拽或粘贴图片,最多 4 张,每张不超过 8MB。',
addAuditTestImage: '添加图片',
clearAuditTest: '清空试跑',
auditTestImageLimit: '最多只能添加 {count} 张测试图片',
auditTestImageTooLarge: '单张测试图片不能超过 8MB',
auditTestImageReadFailed: '读取测试图片失败',
auditTestResult: '审计试跑结果',
auditTestHighest: '最高分类 {category},分数 {score}',
auditTestComposite: '综合评分',
auditTestFlagged: '命中阈值',
auditTestPassed: '未命中',
notConfigured: '未配置',
clearApiKey: '清除已保存 Key',
keepApiKey: '保留已保存 Key',
timeoutMs: 'HTTP 超时 (ms)',
retryCount: '失败重试次数',
sampleRate: '采样率',
recordNonHits: '记录未命中输入',
recordNonHitsHint: '开启后会记录抽样但未命中的请求摘要,摘要会先脱敏再入库。',
preHashCheck: '启用前置哈希比对',
preHashCheckHint: '异步审核命中过的输入哈希会被前置拦截;该拦截不发送邮件,也不累计封禁次数。',
flaggedHashCount: '当前哈希集合数量:{count} 个',
flaggedHashHint: '哈希永久保存在 Redis 集合中;可粘贴完整 64 位哈希删除误拦截项,或一键清空全部风险哈希。',
flaggedHashPlaceholder: '粘贴完整 64 位输入哈希',
deleteFlaggedHash: '删除指定哈希',
clearFlaggedHashes: '一键清空',
clearFlaggedHashesConfirm: '确定要清空全部风险输入哈希吗?此操作不会删除审核记录,但会取消所有历史哈希拦截。',
flaggedHashDeleted: '风险哈希已删除',
flaggedHashNotFound: '该风险哈希不存在',
flaggedHashDeleteFailed: '删除风险哈希失败',
flaggedHashesCleared: '已清空 {count} 个风险哈希',
flaggedHashesClearFailed: '清空风险哈希失败',
workerCount: 'Worker 数',
queueSize: '异步队列大小',
blockStatus: '拦截 HTTP 状态码',
blockMessage: '自定义拦截提示',
emailOnHit: '命中后发送邮件',
emailOnHitHint: '开启后每次达到阈值都会向用户发送风控提醒邮件;自动封禁通知始终发送。',
autoBan: '自动封禁用户',
autoBanHint: '命中次数达到阈值后将禁用用户账号、刷新认证缓存并发送封禁通知邮件。',
banThreshold: '封禁触发次数',
violationWindowHours: '累计窗口(小时)',
hitRetentionDays: '命中记录保留(天)',
nonHitRetentionDays: '未命中记录保留(天,最多 3 天)',
violationCount: '{count} 次',
emailSent: '已发邮件',
emailNotSent: '未发邮件',
autoBanned: '已封禁',
unbanUser: '解封',
unbanSuccess: '用户已解封',
unbanFailed: '解封用户失败',
inputDetailTitle: '输入摘要详情',
inputDetailContent: '完整内容',
queueDelay: '排队 {ms} ms',
allGroups: '全部分组',
allGroupsHint: '当前审计全部分组',
selectedGroupsHint: '当前审计指定分组',
groupScope: '审计分组',
groupScopeHint: '开启右侧开关表示全部分组,关闭后选择指定分组。',
selectedGroups: '指定分组',
searchGroups: '搜索分组名称或平台',
noGroups: '暂无可用分组',
emptyLogs: '暂无审核记录',
workerStatus: 'Worker 运行状态',
workerStatusHint: '异步观察任务的队列和 worker 池状态。',
workerPool: 'Worker 池',
workerPoolMeta: '{active} 个处理中,{idle} 个空闲可用,共 {total} 个',
queueUsage: '队列占用',
activeWorkers: '处理中',
idleWorkers: '空闲可用',
workerActive: '正在处理异步审计任务',
workerIdle: '已启动,当前空闲可用',
workerDisabled: '风控或内容审计未启用',
processed: '已处理',
droppedErrors: '丢弃/异常',
autoRefresh: '每 15 秒自动刷新',
lastCleanup: '上次清理:{time}',
cleanupStats: '上次清理删除命中 {hit} 条,未命中 {nonHit} 条',
riskSwitchOff: '系统开关关闭',
tabs: {
basic: '基础',
scope: '审计范围',
runtime: '运行队列',
response: '命中通知',
retention: '日志保留',
},
overview: {
status: '运行状态',
enabled: '已启用',
disabled: '未启用',
apiKey: 'API Key',
groupScope: '审计范围',
logs: '审核记录',
currentFilter: '当前筛选结果',
},
filters: {
search: '按用户/Key/摘要搜索',
from: '开始时间',
to: '结束时间',
allGroups: '全部分组',
allEndpoints: '全部端点',
},
table: {
time: '时间',
group: '分组',
user: '用户',
apiKey: 'API Key',
endpoint: '端点',
result: '结果',
highest: '最高分',
actionMeta: '处置',
latency: '上游耗时',
input: '输入摘要',
},
result: {
all: '全部结果',
hit: '命中',
blocked: '已拦截',
pass: '未命中',
error: '异常',
},
action: {
block: '拦截',
error: '异常',
},
},
// Channel Monitor
channelMonitor: {
title: '渠道监控',
@@ -5025,6 +5223,13 @@ export default {
enabled: '启用可用渠道',
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
},
riskControl: {
title: '风控中心',
description: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
configureLink: '前往 风控中心 配置内容审计',
enabled: '启用风控中心',
enabledHint: '关闭后管理员侧边栏入口隐藏,网关内容审计不会执行。',
},
affiliate: {
title: '邀请返利',
description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
+21
View File
@@ -505,6 +505,19 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.settings.description'
}
},
{
path: '/admin/risk-control',
name: 'AdminRiskControl',
component: () => import('@/views/admin/RiskControlView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Risk Control',
titleKey: 'admin.riskControl.title',
descriptionKey: 'admin.riskControl.description',
requiresRiskControl: true
}
},
{
path: '/admin/usage',
name: 'AdminUsage',
@@ -747,6 +760,14 @@ router.beforeEach((to, _from, next) => {
}
}
if (to.meta.requiresRiskControl) {
const riskControlEnabled = appStore.cachedPublicSettings?.risk_control_enabled === true
if (!riskControlEnabled) {
next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
return
}
}
// 简易模式下限制访问某些页面
if (authStore.isSimpleMode) {
const restrictedPaths = [
+6
View File
@@ -49,6 +49,12 @@ declare module 'vue-router' {
*/
requiresPayment?: boolean
/**
* 是否要求风控中心功能开关已启用
* @default false
*/
requiresRiskControl?: boolean
/**
* i18n key for the page title
*/
+1
View File
@@ -355,6 +355,7 @@ export const useAppStore = defineStore('app', () => {
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
risk_control_enabled: false,
affiliate_enabled: false,
}
}
+1
View File
@@ -197,6 +197,7 @@ export interface PublicSettings {
home_content: string
hide_ccs_import_button: boolean
payment_enabled: boolean
risk_control_enabled: boolean
table_default_page_size: number
table_page_size_options: number[]
custom_menu_items: CustomMenuItem[]
+5
View File
@@ -109,6 +109,11 @@ export const FeatureFlags = {
mode: 'opt-out',
label: 'Payment',
}),
riskControl: defineFlag({
key: 'risk_control_enabled',
mode: 'opt-in',
label: 'Risk Control',
}),
affiliate: defineFlag({
key: 'affiliate_enabled',
mode: 'opt-in',
File diff suppressed because it is too large Load Diff
+35
View File
@@ -4264,6 +4264,39 @@
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.riskControl.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.riskControl.description') }}
</p>
<p class="mt-1.5 text-xs">
<router-link
to="/admin/risk-control"
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
>
{{ t('admin.settings.features.riskControl.configureLink') }}
<span aria-hidden="true"></span>
</router-link>
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.riskControl.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.riskControl.enabledHint') }}
</p>
</div>
<Toggle v-model="form.risk_control_enabled" />
</div>
</div>
</div>
<!-- Affiliate (邀请返利) feature card -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
@@ -5828,6 +5861,7 @@ const form = reactive<SettingsForm>({
backend_mode_enabled: false,
hide_ccs_import_button: false,
payment_enabled: false,
risk_control_enabled: false,
payment_min_amount: 1,
payment_max_amount: 10000,
payment_daily_limit: 50000,
@@ -6863,6 +6897,7 @@ async function saveSettings() {
form.enable_anthropic_cache_ttl_1h_injection,
// Payment configuration
payment_enabled: form.payment_enabled,
risk_control_enabled: form.risk_control_enabled,
payment_min_amount: Number(form.payment_min_amount) || 0,
payment_max_amount: Number(form.payment_max_amount) || 0,
payment_daily_limit: Number(form.payment_daily_limit) || 0,
+2 -10
View File
@@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
import type { TotpLoginResponse } from '@/types'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n()
@@ -369,16 +370,7 @@ async function handleLogin(): Promise<void> {
turnstileToken.value = ''
}
// Handle login error
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = t('auth.loginFailed')
}
errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', t('auth.loginFailed'))
// Also show error toast
appStore.showError(errorMessage.value)