feat(risk-control): add content moderation audit
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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 invitee’s recharges. Disabled by default.',
|
||||
|
||||
@@ -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: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Vendored
+6
@@ -49,6 +49,12 @@ declare module 'vue-router' {
|
||||
*/
|
||||
requiresPayment?: boolean
|
||||
|
||||
/**
|
||||
* 是否要求风控中心功能开关已启用
|
||||
* @default false
|
||||
*/
|
||||
requiresRiskControl?: boolean
|
||||
|
||||
/**
|
||||
* i18n key for the page title
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user