mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(gateway): add reversible chat pii redaction
This commit is contained in:
@@ -4,10 +4,14 @@ import type {
|
||||
ClaudeCodeAdvancedConfig,
|
||||
FailoverRulesConfig,
|
||||
PoolAdvancedConfig,
|
||||
ProviderConfig,
|
||||
ProviderWithEndpointsSummary,
|
||||
ProxyConfig,
|
||||
} from './types'
|
||||
import { normalizePoolAdvancedConfig as normalizePoolAdvanced } from './types'
|
||||
import {
|
||||
normalizeChatPiiRedactionProviderConfig as normalizeChatPiiRedactionProvider,
|
||||
normalizePoolAdvancedConfig as normalizePoolAdvanced,
|
||||
} from './types'
|
||||
|
||||
interface ProviderRequestOptions {
|
||||
timeout?: number
|
||||
@@ -42,6 +46,7 @@ function normalizeProviderSummary(
|
||||
): ProviderWithEndpointsSummary {
|
||||
return {
|
||||
...provider,
|
||||
chat_pii_redaction: normalizeChatPiiRedactionProvider(provider.chat_pii_redaction),
|
||||
pool_advanced: normalizePoolAdvanced(provider.pool_advanced),
|
||||
}
|
||||
}
|
||||
@@ -107,6 +112,7 @@ export async function updateProvider(
|
||||
claude_code_advanced: ClaudeCodeAdvancedConfig | null
|
||||
pool_advanced: PoolAdvancedConfig | null
|
||||
failover_rules: FailoverRulesConfig | null
|
||||
config: ProviderConfig | null
|
||||
}>,
|
||||
requestOptions?: ProviderRequestOptions,
|
||||
): Promise<ProviderWithEndpointsSummary> {
|
||||
@@ -138,6 +144,7 @@ export async function createProvider(
|
||||
claude_code_advanced?: ClaudeCodeAdvancedConfig | null
|
||||
pool_advanced?: PoolAdvancedConfig | null
|
||||
failover_rules?: FailoverRulesConfig | null
|
||||
config?: ProviderConfig | null
|
||||
}
|
||||
): Promise<{ id: string; name: string; message?: string }> {
|
||||
const response = await client.post('/api/admin/providers/', data)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizePoolAdvancedConfig } from '@/api/endpoints/types'
|
||||
import { normalizeChatPiiRedactionProviderConfig, normalizePoolAdvancedConfig } from '@/api/endpoints/types'
|
||||
|
||||
describe('normalizePoolAdvancedConfig', () => {
|
||||
it('keeps object payloads, including empty objects', () => {
|
||||
@@ -19,3 +19,17 @@ describe('normalizePoolAdvancedConfig', () => {
|
||||
expect(normalizePoolAdvancedConfig(['lru'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('normalizeChatPiiRedactionProviderConfig', () => {
|
||||
it('defaults unsupported payloads to disabled', () => {
|
||||
expect(normalizeChatPiiRedactionProviderConfig(null)).toEqual({ enabled: false })
|
||||
expect(normalizeChatPiiRedactionProviderConfig({})).toEqual({ enabled: false })
|
||||
expect(normalizeChatPiiRedactionProviderConfig({ enabled: 'yes' })).toEqual({ enabled: false })
|
||||
})
|
||||
|
||||
it('passes through enabled state only', () => {
|
||||
expect(normalizeChatPiiRedactionProviderConfig({ enabled: true })).toEqual({ enabled: true })
|
||||
expect(normalizeChatPiiRedactionProviderConfig({ enabled: false, entities: ['email'] })).toEqual({ enabled: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -176,6 +176,18 @@ export interface FormatAcceptanceConfig {
|
||||
reject_formats?: string[] // 黑名单:拒绝哪些格式(优先级高于白名单)
|
||||
}
|
||||
|
||||
export interface ChatPiiRedactionProviderConfig {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
chat_pii_redaction?: ChatPiiRedactionProviderConfig
|
||||
pool_advanced?: PoolAdvancedConfig
|
||||
failover_rules?: FailoverRulesConfig
|
||||
claude_code_advanced?: ClaudeCodeAdvancedConfig
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ProviderEndpoint {
|
||||
id: string
|
||||
provider_id: string
|
||||
@@ -579,6 +591,13 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function normalizeChatPiiRedactionProviderConfig(value: unknown): ChatPiiRedactionProviderConfig {
|
||||
if (!isPlainObject(value) || typeof value.enabled !== 'boolean') {
|
||||
return { enabled: false }
|
||||
}
|
||||
return { enabled: value.enabled }
|
||||
}
|
||||
|
||||
export function normalizePoolAdvancedConfig(value: unknown): PoolAdvancedConfig | null {
|
||||
if (value == null || value === false) return null
|
||||
if (value === true) return {}
|
||||
@@ -631,6 +650,7 @@ export interface ProviderWithEndpointsSummary {
|
||||
api_formats: string[]
|
||||
endpoint_health_details: EndpointHealthDetail[]
|
||||
claude_code_advanced?: ClaudeCodeAdvancedConfig | null
|
||||
chat_pii_redaction?: ChatPiiRedactionProviderConfig | null
|
||||
pool_advanced?: PoolAdvancedConfig | null
|
||||
failover_rules?: FailoverRulesConfig | null
|
||||
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
||||
|
||||
@@ -23,6 +23,97 @@ export interface AuthModuleInfo {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type ChatPiiRedactionProviderScope = 'all_providers' | 'selected_providers'
|
||||
export type ChatPiiRedactionTtlSeconds = 300 | 3600
|
||||
export type ChatPiiRedactionEntity =
|
||||
| 'email'
|
||||
| 'cn_phone'
|
||||
| 'global_phone'
|
||||
| 'cn_id'
|
||||
| 'payment_card'
|
||||
| 'ipv4'
|
||||
| 'ipv6'
|
||||
| 'api_key'
|
||||
| 'access_token'
|
||||
| 'secret_key'
|
||||
| 'bearer_token'
|
||||
| 'jwt'
|
||||
|
||||
export interface ChatPiiRedactionConfig {
|
||||
enabled: boolean
|
||||
provider_scope: ChatPiiRedactionProviderScope
|
||||
entities: ChatPiiRedactionEntity[]
|
||||
cache_ttl_seconds: ChatPiiRedactionTtlSeconds
|
||||
inject_model_instruction: boolean
|
||||
}
|
||||
|
||||
const CHAT_PII_REDACTION_ENTITIES: ChatPiiRedactionEntity[] = [
|
||||
'email',
|
||||
'cn_phone',
|
||||
'global_phone',
|
||||
'cn_id',
|
||||
'payment_card',
|
||||
'ipv4',
|
||||
'ipv6',
|
||||
'api_key',
|
||||
'access_token',
|
||||
'secret_key',
|
||||
'bearer_token',
|
||||
'jwt',
|
||||
]
|
||||
|
||||
const CHAT_PII_REDACTION_CONFIG_KEYS = {
|
||||
enabled: 'module.chat_pii_redaction.enabled',
|
||||
provider_scope: 'module.chat_pii_redaction.provider_scope',
|
||||
entities: 'module.chat_pii_redaction.entities',
|
||||
cache_ttl_seconds: 'module.chat_pii_redaction.cache_ttl_seconds',
|
||||
inject_model_instruction: 'module.chat_pii_redaction.inject_model_instruction',
|
||||
} as const
|
||||
|
||||
const CHAT_PII_REDACTION_DEFAULT_CONFIG: ChatPiiRedactionConfig = {
|
||||
enabled: false,
|
||||
provider_scope: 'selected_providers',
|
||||
entities: [...CHAT_PII_REDACTION_ENTITIES],
|
||||
cache_ttl_seconds: 300,
|
||||
inject_model_instruction: true,
|
||||
}
|
||||
|
||||
function isChatPiiRedactionEntity(value: unknown): value is ChatPiiRedactionEntity {
|
||||
return typeof value === 'string' && CHAT_PII_REDACTION_ENTITIES.includes(value as ChatPiiRedactionEntity)
|
||||
}
|
||||
|
||||
function normalizeChatPiiRedactionEntities(value: unknown): ChatPiiRedactionEntity[] {
|
||||
if (!Array.isArray(value)) return [...CHAT_PII_REDACTION_DEFAULT_CONFIG.entities]
|
||||
const unique = new Set<ChatPiiRedactionEntity>()
|
||||
for (const item of value) {
|
||||
if (isChatPiiRedactionEntity(item)) unique.add(item)
|
||||
}
|
||||
return CHAT_PII_REDACTION_ENTITIES.filter((item) => unique.has(item))
|
||||
}
|
||||
|
||||
function normalizeChatPiiRedactionConfig(values: Record<keyof ChatPiiRedactionConfig, unknown>): ChatPiiRedactionConfig {
|
||||
return {
|
||||
enabled: values.enabled === true,
|
||||
provider_scope: values.provider_scope === 'all_providers' ? 'all_providers' : 'selected_providers',
|
||||
entities: normalizeChatPiiRedactionEntities(values.entities),
|
||||
cache_ttl_seconds: values.cache_ttl_seconds === 3600 ? 3600 : 300,
|
||||
inject_model_instruction: values.inject_model_instruction !== false,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSystemConfigValue(key: string): Promise<unknown> {
|
||||
const response = await apiClient.get<{ key: string; value: unknown }>(`/api/admin/system/configs/${key}`)
|
||||
return response.data.value
|
||||
}
|
||||
|
||||
async function updateSystemConfigValue(key: string, value: unknown, description: string) {
|
||||
const response = await apiClient.put<{ key: string; value: unknown; description?: string }>(
|
||||
`/api/admin/system/configs/${key}`,
|
||||
{ value, description },
|
||||
)
|
||||
return response.data.value
|
||||
}
|
||||
|
||||
export const modulesApi = {
|
||||
/**
|
||||
* 获取所有模块状态(管理员)
|
||||
@@ -55,6 +146,42 @@ export const modulesApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getChatPiiRedactionConfig(): Promise<ChatPiiRedactionConfig> {
|
||||
const [enabled, providerScope, entities, cacheTtlSeconds, injectModelInstruction] = await Promise.all([
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.provider_scope),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.entities),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.cache_ttl_seconds),
|
||||
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.inject_model_instruction),
|
||||
])
|
||||
|
||||
return normalizeChatPiiRedactionConfig({
|
||||
enabled,
|
||||
provider_scope: providerScope,
|
||||
entities,
|
||||
cache_ttl_seconds: cacheTtlSeconds,
|
||||
inject_model_instruction: injectModelInstruction,
|
||||
})
|
||||
},
|
||||
|
||||
async updateChatPiiRedactionConfig(config: ChatPiiRedactionConfig): Promise<ChatPiiRedactionConfig> {
|
||||
const [enabled, providerScope, entities, cacheTtlSeconds, injectModelInstruction] = await Promise.all([
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled, config.enabled, '敏感信息替换保护总开关'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.provider_scope, config.provider_scope, '敏感信息替换保护启用范围'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.entities, config.entities, '敏感信息替换保护检测类型'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.cache_ttl_seconds, config.cache_ttl_seconds, '敏感信息替换保护缓存 TTL'),
|
||||
updateSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.inject_model_instruction, config.inject_model_instruction, '敏感信息替换保护模型提示说明'),
|
||||
])
|
||||
|
||||
return normalizeChatPiiRedactionConfig({
|
||||
enabled,
|
||||
provider_scope: providerScope,
|
||||
entities,
|
||||
cache_ttl_seconds: cacheTtlSeconds,
|
||||
inject_model_instruction: injectModelInstruction,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取认证模块状态(公开接口,供登录页使用)
|
||||
*/
|
||||
|
||||
@@ -268,6 +268,34 @@
|
||||
@update:model-value="(v: boolean) => form.pool_mode_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 p-3 border rounded-lg bg-muted/50"
|
||||
:class="redactionModuleScope === 'all_providers' ? 'border-primary/30 bg-primary/5' : ''"
|
||||
>
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">敏感信息替换保护</span>
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">
|
||||
{{ redactionHelperText }}
|
||||
</p>
|
||||
<p
|
||||
v-if="!redactionModuleEnabled"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
模块总开关未开启,保存此供应商不会立即生效。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ redactionSwitchLabel }}
|
||||
</span>
|
||||
<Switch
|
||||
:model-value="redactionSwitchValue"
|
||||
:disabled="redactionModuleScope === 'all_providers' || redactionModuleLoading"
|
||||
@update:model-value="(v: boolean) => form.chat_pii_redaction_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -313,9 +341,12 @@ import {
|
||||
updateProvider,
|
||||
type ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints'
|
||||
import { modulesApi, type ChatPiiRedactionProviderScope } from '@/api/modules'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import { dateTimeLocalToRfc3339, formatDateTimeLocalInput } from '@/utils/date'
|
||||
import { getProviderRedactionConfig, withProviderRedactionConfig } from '@/features/providers/utils/providerRedactionPayload'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -331,6 +362,9 @@ const emit = defineEmits<{
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
const redactionModuleLoading = ref(false)
|
||||
const redactionModuleEnabled = ref(false)
|
||||
const redactionModuleScope = ref<ChatPiiRedactionProviderScope>('selected_providers')
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
@@ -368,8 +402,43 @@ const form = ref({
|
||||
request_timeout: undefined as number | undefined,
|
||||
// 号池模式
|
||||
pool_mode_enabled: false,
|
||||
chat_pii_redaction_enabled: false,
|
||||
})
|
||||
|
||||
const redactionSwitchValue = computed(() => {
|
||||
if (redactionModuleScope.value === 'all_providers') {
|
||||
return redactionModuleEnabled.value
|
||||
}
|
||||
return form.value.chat_pii_redaction_enabled
|
||||
})
|
||||
|
||||
const redactionSwitchLabel = computed(() => {
|
||||
if (redactionModuleScope.value === 'all_providers') {
|
||||
return redactionModuleEnabled.value ? '继承开启' : '未生效'
|
||||
}
|
||||
return form.value.chat_pii_redaction_enabled ? '已开启' : '未开启'
|
||||
})
|
||||
|
||||
const redactionHelperText = computed(() => {
|
||||
if (redactionModuleScope.value === 'all_providers') {
|
||||
return '模块管理已设置为“全部供应商”,此供应商会自动执行替换保护。替换类型由模块管理中的“替换类型配置”决定。'
|
||||
}
|
||||
return '仅当“开启敏感信息替换保护”和此供应商开关都开启时生效。替换类型在模块管理的“替换类型配置”中统一选择,适用于所有已开启该功能的供应商。供应商只会看到占位符,客户端响应会自动还原。'
|
||||
})
|
||||
|
||||
async function loadRedactionModuleState() {
|
||||
redactionModuleLoading.value = true
|
||||
try {
|
||||
const config = await modulesApi.getChatPiiRedactionConfig()
|
||||
redactionModuleEnabled.value = config.enabled
|
||||
redactionModuleScope.value = config.provider_scope
|
||||
} catch (err) {
|
||||
log.warn('加载敏感信息替换保护模块配置失败', err)
|
||||
} finally {
|
||||
redactionModuleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
@@ -394,6 +463,7 @@ function resetForm() {
|
||||
request_timeout: undefined,
|
||||
// 号池模式
|
||||
pool_mode_enabled: false,
|
||||
chat_pii_redaction_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +494,7 @@ function loadProviderData() {
|
||||
request_timeout: props.provider.request_timeout ?? undefined,
|
||||
// 号池模式
|
||||
pool_mode_enabled: poolAdvanced !== null,
|
||||
chat_pii_redaction_enabled: getProviderRedactionConfig(props.provider).enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +508,12 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
resetForm,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
loadRedactionModuleState()
|
||||
}
|
||||
})
|
||||
|
||||
// 新建模式下切换 provider_type 时不自动开启号池模式
|
||||
watch(() => form.value.provider_type, () => {
|
||||
if (!isEditMode.value) {
|
||||
@@ -471,7 +548,7 @@ const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const currentPoolAdvanced = normalizePoolAdvancedConfig(props.provider?.pool_advanced)
|
||||
const basePayload = {
|
||||
const basePayload = withProviderRedactionConfig({
|
||||
name: form.value.name,
|
||||
provider_type: form.value.provider_type,
|
||||
description: form.value.description || undefined,
|
||||
@@ -491,7 +568,7 @@ const handleSubmit = async () => {
|
||||
pool_advanced: form.value.pool_mode_enabled
|
||||
? (currentPoolAdvanced ?? {})
|
||||
: null,
|
||||
}
|
||||
}, form.value.chat_pii_redaction_enabled)
|
||||
|
||||
if (isEditMode.value && props.provider) {
|
||||
// 更新提供商
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints/types'
|
||||
import {
|
||||
buildProviderRedactionConfig,
|
||||
getProviderRedactionConfig,
|
||||
withProviderRedactionConfig,
|
||||
} from '../providerRedactionPayload'
|
||||
|
||||
function makeProvider(overrides: Partial<ProviderWithEndpointsSummary> = {}): ProviderWithEndpointsSummary {
|
||||
return {
|
||||
id: 'provider-1',
|
||||
name: 'Provider One',
|
||||
provider_priority: 1,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
is_active: true,
|
||||
total_endpoints: 0,
|
||||
active_endpoints: 0,
|
||||
total_keys: 0,
|
||||
active_keys: 0,
|
||||
total_models: 0,
|
||||
active_models: 0,
|
||||
global_model_ids: [],
|
||||
avg_health_score: 0,
|
||||
unhealthy_endpoints: 0,
|
||||
api_formats: [],
|
||||
endpoint_health_details: [],
|
||||
ops_configured: false,
|
||||
created_at: '2026-05-02T00:00:00Z',
|
||||
updated_at: '2026-05-02T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('provider redaction payload helpers', () => {
|
||||
it('defaults provider redaction to disabled', () => {
|
||||
expect(getProviderRedactionConfig()).toEqual({ enabled: false })
|
||||
expect(getProviderRedactionConfig(makeProvider())).toEqual({ enabled: false })
|
||||
})
|
||||
|
||||
it('loads existing provider redaction config', () => {
|
||||
const provider = makeProvider({ chat_pii_redaction: { enabled: true } })
|
||||
|
||||
expect(getProviderRedactionConfig(provider)).toEqual({ enabled: true })
|
||||
})
|
||||
|
||||
it('builds create and update payloads with provider-level enabled only', () => {
|
||||
expect(buildProviderRedactionConfig(true)).toEqual({
|
||||
chat_pii_redaction: { enabled: true },
|
||||
})
|
||||
|
||||
expect(
|
||||
withProviderRedactionConfig(
|
||||
{
|
||||
name: 'Provider One',
|
||||
config: { pool_advanced: { global_priority: 10 } },
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
name: 'Provider One',
|
||||
config: {
|
||||
pool_advanced: { global_priority: 10 },
|
||||
chat_pii_redaction: { enabled: false },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('does not include provider-level entity or ttl config', () => {
|
||||
const payload = withProviderRedactionConfig({ name: 'Provider One' }, true)
|
||||
|
||||
expect(payload.config.chat_pii_redaction).toEqual({ enabled: true })
|
||||
expect(payload.config.chat_pii_redaction).not.toHaveProperty('entities')
|
||||
expect(payload.config.chat_pii_redaction).not.toHaveProperty('cache_ttl_seconds')
|
||||
expect(payload.config.chat_pii_redaction).not.toHaveProperty('inject_model_instruction')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ProviderConfig, ProviderWithEndpointsSummary } from '@/api/endpoints/types'
|
||||
import { normalizeChatPiiRedactionProviderConfig } from '@/api/endpoints/types'
|
||||
|
||||
export const DEFAULT_PROVIDER_REDACTION_CONFIG = Object.freeze({ enabled: false })
|
||||
|
||||
type ProviderConfigWithRedaction = ProviderConfig & Required<Pick<ProviderConfig, 'chat_pii_redaction'>>
|
||||
|
||||
type ProviderRedactionPayload<TPayload extends object> = Omit<TPayload, 'config'> & {
|
||||
config: ProviderConfigWithRedaction
|
||||
}
|
||||
|
||||
export function getProviderRedactionConfig(provider?: ProviderWithEndpointsSummary | null) {
|
||||
return normalizeChatPiiRedactionProviderConfig(provider?.chat_pii_redaction)
|
||||
}
|
||||
|
||||
export function buildProviderRedactionConfig(enabled: boolean): ProviderConfigWithRedaction {
|
||||
return {
|
||||
chat_pii_redaction: { enabled },
|
||||
}
|
||||
}
|
||||
|
||||
export function withProviderRedactionConfig<TPayload extends object>(
|
||||
payload: TPayload & { config?: ProviderConfig | null },
|
||||
enabled: boolean,
|
||||
): ProviderRedactionPayload<TPayload> {
|
||||
const { config, ...payloadWithoutConfig } = payload
|
||||
|
||||
return {
|
||||
...payloadWithoutConfig,
|
||||
config: {
|
||||
...(config ?? {}),
|
||||
...buildProviderRedactionConfig(enabled),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModelDirectivesManagement.vue')),
|
||||
meta: { module: 'model_directives' }
|
||||
},
|
||||
{
|
||||
path: 'modules/chat-pii-redaction',
|
||||
name: 'ChatPiiRedactionModule',
|
||||
component: () => importWithRetry(() => import('@/views/admin/modules/ChatPiiRedaction.vue')),
|
||||
meta: { module: 'chat_pii_redaction' }
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
|
||||
@@ -133,6 +133,13 @@
|
||||
{{ module.description }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="module.name === 'chat_pii_redaction'"
|
||||
class="mt-3 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ getModuleStatusCopy(module) }}
|
||||
</p>
|
||||
|
||||
<!-- 不可用提示 -->
|
||||
<div
|
||||
v-if="!module.available"
|
||||
@@ -246,6 +253,15 @@ function getCategoryIcon(category: string) {
|
||||
return icons[category] || Puzzle
|
||||
}
|
||||
|
||||
function getModuleStatusCopy(module: { name: string; enabled: boolean; active: boolean; config_validated: boolean; config_error: string | null }) {
|
||||
if (module.name !== 'chat_pii_redaction') {
|
||||
return module.enabled ? '已启用' : '已禁用'
|
||||
}
|
||||
if (!module.config_validated) return '配置异常,替换保护未生效'
|
||||
if (!module.enabled) return '全局替换开关未开启,所有供应商均不会执行替换保护'
|
||||
return '全局替换开关已开启,可在供应商中单独启用'
|
||||
}
|
||||
|
||||
// 所有模块列表(按 admin_menu_order 排序)
|
||||
const allModules = computed(() => {
|
||||
return Object.values(moduleStore.modules)
|
||||
|
||||
355
frontend/src/views/admin/modules/ChatPiiRedaction.vue
Normal file
355
frontend/src/views/admin/modules/ChatPiiRedaction.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="敏感信息替换保护"
|
||||
description="对聊天消息中的手机号、邮箱、证件号、银行卡号、IP、API Key 与令牌进行可逆占位符替换,防止原文发送给上游供应商。"
|
||||
:icon="ShieldCheck"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="loading || saving"
|
||||
@click="loadConfig"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-4 h-4 mr-2"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="loading || saving || !hasChanges"
|
||||
@click="saveConfig"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存配置' }}
|
||||
</Button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<section class="rounded-2xl border border-border bg-card p-5">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full ring-2 ring-offset-2 ring-offset-background"
|
||||
:class="redactionConfig.enabled ? 'bg-primary ring-primary/30' : 'bg-muted ring-muted/60'"
|
||||
/>
|
||||
<p class="text-sm font-semibold text-foreground">
|
||||
{{ statusLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="max-w-3xl text-sm text-muted-foreground">
|
||||
发送给供应商前将聊天消息中的敏感信息替换为占位符,返回客户端前自动还原。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-xl border border-border bg-muted/40 px-4 py-3">
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
开启敏感信息替换保护
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
关闭后所有供应商均不会执行替换
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="redactionConfig.enabled"
|
||||
@update:model-value="(value: boolean) => redactionConfig.enabled = value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CardSection
|
||||
title="启用范围"
|
||||
description="关闭后,所有供应商都不会执行替换;开启后,按下方“启用范围”决定是全部供应商生效还是指定供应商生效。"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<button
|
||||
v-for="option in scopeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition-all duration-200"
|
||||
:class="redactionConfig.provider_scope === option.value
|
||||
? 'border-primary bg-primary/10 text-primary shadow-sm'
|
||||
: 'border-border bg-card/70 text-muted-foreground hover:border-primary/50 hover:text-foreground'"
|
||||
@click="redactionConfig.provider_scope = option.value"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm font-semibold">{{ option.label }}</span>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="redactionConfig.provider_scope === option.value ? 'bg-primary' : 'bg-muted'"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{{ option.helper }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<CardSection
|
||||
title="替换类型配置"
|
||||
description="适用于所有已开启该功能的供应商。"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div
|
||||
v-for="group in entityGroups"
|
||||
:key="group.title"
|
||||
class="rounded-xl border border-border bg-muted/30 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">
|
||||
{{ group.title }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
已选择 {{ selectedCount(group.entities) }} / {{ group.entities.length }} 项
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="setGroupSelection(group.entities, selectedCount(group.entities) !== group.entities.length)"
|
||||
>
|
||||
{{ selectedCount(group.entities) === group.entities.length ? '清除本组' : '选择本组' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label
|
||||
v-for="entity in group.entities"
|
||||
:key="entity.key"
|
||||
class="flex items-start gap-3 rounded-lg border border-border bg-card/70 px-3 py-3 text-sm transition-colors hover:border-primary/40"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="redactionConfig.entities.includes(entity.key)"
|
||||
@update:checked="(checked: boolean) => toggleEntity(entity.key, checked)"
|
||||
/>
|
||||
<span class="leading-tight">
|
||||
<span class="block font-medium text-foreground">{{ entity.label }}</span>
|
||||
<span class="mt-1 block text-xs text-muted-foreground">{{ entity.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card px-4 py-3 text-sm text-muted-foreground">
|
||||
真实姓名、地址、公司名暂不支持自动识别,避免误判影响模型理解。
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<CardSection
|
||||
title="多轮上下文缓存"
|
||||
description="此时间控制“真实值 ↔ 占位符”映射在 Redis 中的缓存窗口。窗口内相同敏感值使用相同占位符,以减少上游 prompt cache 失效;窗口过期后新请求会生成新的占位符。缓存写入 Redis,必须设置 TTL,不写入数据库或日志。"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<button
|
||||
v-for="option in ttlOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition-all duration-200"
|
||||
:class="redactionConfig.cache_ttl_seconds === option.value
|
||||
? 'border-primary bg-primary/10 text-primary shadow-sm'
|
||||
: 'border-border bg-card/70 text-muted-foreground hover:border-primary/50 hover:text-foreground'"
|
||||
@click="redactionConfig.cache_ttl_seconds = option.value"
|
||||
>
|
||||
<span class="text-sm font-semibold">{{ option.label }}</span>
|
||||
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{{ option.helper }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<CardSection title="模型提示说明">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
向模型说明占位符含义
|
||||
</p>
|
||||
<p class="max-w-3xl text-xs leading-relaxed text-muted-foreground">
|
||||
开启后,Aether 会在发往供应商的请求中插入一条简短内部说明,说明 `<AETHER:TYPE:ID>` 是已保护的真实信息占位符,应按对应类型正常理解和处理,不要要求用户重新提供原文。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="redactionConfig.inject_model_instruction"
|
||||
@update:model-value="(value: boolean) => redactionConfig.inject_model_instruction = value"
|
||||
/>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RefreshCw, ShieldCheck } from 'lucide-vue-next'
|
||||
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import { modulesApi, type ChatPiiRedactionConfig, type ChatPiiRedactionEntity, type ChatPiiRedactionProviderScope } from '@/api/modules'
|
||||
import { useModuleStore } from '@/stores/modules'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
interface EntityOption {
|
||||
key: ChatPiiRedactionEntity
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const defaultConfig: ChatPiiRedactionConfig = {
|
||||
enabled: false,
|
||||
provider_scope: 'selected_providers',
|
||||
entities: [
|
||||
'email',
|
||||
'cn_phone',
|
||||
'global_phone',
|
||||
'cn_id',
|
||||
'payment_card',
|
||||
'ipv4',
|
||||
'ipv6',
|
||||
'api_key',
|
||||
'access_token',
|
||||
'secret_key',
|
||||
'bearer_token',
|
||||
'jwt',
|
||||
],
|
||||
cache_ttl_seconds: 300,
|
||||
inject_model_instruction: true,
|
||||
}
|
||||
|
||||
const scopeOptions: Array<{ value: ChatPiiRedactionProviderScope; label: string; helper: string }> = [
|
||||
{
|
||||
value: 'all_providers',
|
||||
label: '全部供应商',
|
||||
helper: '所有供应商都会执行替换保护,无需逐个开启。',
|
||||
},
|
||||
{
|
||||
value: 'selected_providers',
|
||||
label: '指定供应商',
|
||||
helper: '仅对在供应商管理中开启的供应商生效。',
|
||||
},
|
||||
]
|
||||
|
||||
const ttlOptions = [
|
||||
{
|
||||
value: 300 as const,
|
||||
label: '5 分钟(默认)',
|
||||
helper: '更保守,适合短对话或较高隐私偏好;同一敏感信息在 5 分钟内保持相同占位符。',
|
||||
},
|
||||
{
|
||||
value: 3600 as const,
|
||||
label: '1 小时',
|
||||
helper: '更适合长多轮对话,可减少重复上下文检测成本;同一敏感信息在 1 小时内保持相同占位符。',
|
||||
},
|
||||
]
|
||||
|
||||
const entityGroups: Array<{ title: string; entities: EntityOption[] }> = [
|
||||
{
|
||||
title: '个人信息',
|
||||
entities: [
|
||||
{ key: 'email', label: '邮箱', description: '识别常见电子邮箱地址。' },
|
||||
{ key: 'cn_phone', label: '手机号/固话', description: '识别中国大陆手机号和固定电话。' },
|
||||
{ key: 'global_phone', label: '全球电话号码', description: '识别 E.164 风格国际号码。' },
|
||||
{ key: 'cn_id', label: '中国大陆身份证号', description: '识别通过校验的居民身份证号。' },
|
||||
{ key: 'payment_card', label: '银行卡号', description: '识别通过 Luhn 校验的卡号。' },
|
||||
{ key: 'ipv4', label: 'IPv4', description: '识别 IPv4 地址。' },
|
||||
{ key: 'ipv6', label: 'IPv6', description: '识别 IPv6 地址。' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '密钥与令牌',
|
||||
entities: [
|
||||
{ key: 'api_key', label: 'API Key', description: '识别 OpenAI、Anthropic、GitHub、Slack、AWS 等常见密钥。' },
|
||||
{ key: 'access_token', label: 'Access Token', description: '识别访问令牌形态的敏感凭证。' },
|
||||
{ key: 'secret_key', label: 'Secret Key', description: '识别高熵密钥和 secret 字段值。' },
|
||||
{ key: 'bearer_token', label: 'Bearer Token', description: '识别 Authorization Bearer 凭证。' },
|
||||
{ key: 'jwt', label: 'JWT', description: '识别三段式 JWT 令牌。' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const moduleStore = useModuleStore()
|
||||
const { success, error } = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const redactionConfig = ref<ChatPiiRedactionConfig>({ ...defaultConfig })
|
||||
const originalConfig = ref<ChatPiiRedactionConfig>({ ...defaultConfig })
|
||||
|
||||
const hasChanges = computed(() => JSON.stringify(redactionConfig.value) !== JSON.stringify(originalConfig.value))
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const moduleStatus = moduleStore.modules.chat_pii_redaction
|
||||
if (moduleStatus && !moduleStatus.config_validated) return '配置异常,替换保护未生效'
|
||||
if (!redactionConfig.value.enabled) return '全局替换开关未开启,所有供应商均不会执行替换保护'
|
||||
return redactionConfig.value.provider_scope === 'all_providers'
|
||||
? '全局替换开关已开启,全部供应商都会执行替换保护'
|
||||
: '全局替换开关已开启,可在供应商中单独启用'
|
||||
})
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [config] = await Promise.all([
|
||||
modulesApi.getChatPiiRedactionConfig(),
|
||||
moduleStore.fetchModules(),
|
||||
])
|
||||
redactionConfig.value = { ...config }
|
||||
originalConfig.value = { ...config }
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '加载敏感信息替换保护配置失败'))
|
||||
log.error('加载敏感信息替换保护配置失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
const saved = await modulesApi.updateChatPiiRedactionConfig(redactionConfig.value)
|
||||
redactionConfig.value = { ...saved }
|
||||
originalConfig.value = { ...saved }
|
||||
await moduleStore.fetchModules()
|
||||
success('敏感信息替换保护配置已保存')
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '保存敏感信息替换保护配置失败'))
|
||||
log.error('保存敏感信息替换保护配置失败:', err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectedCount(entities: EntityOption[]) {
|
||||
return entities.filter((entity) => redactionConfig.value.entities.includes(entity.key)).length
|
||||
}
|
||||
|
||||
function toggleEntity(entity: ChatPiiRedactionEntity, checked: boolean) {
|
||||
const current = new Set(redactionConfig.value.entities)
|
||||
if (checked) {
|
||||
current.add(entity)
|
||||
} else {
|
||||
current.delete(entity)
|
||||
}
|
||||
redactionConfig.value.entities = defaultConfig.entities.filter((item) => current.has(item))
|
||||
}
|
||||
|
||||
function setGroupSelection(entities: EntityOption[], checked: boolean) {
|
||||
const current = new Set(redactionConfig.value.entities)
|
||||
for (const entity of entities) {
|
||||
if (checked) {
|
||||
current.add(entity.key)
|
||||
} else {
|
||||
current.delete(entity.key)
|
||||
}
|
||||
}
|
||||
redactionConfig.value.entities = defaultConfig.entities.filter((item) => current.has(item))
|
||||
}
|
||||
|
||||
onMounted(loadConfig)
|
||||
</script>
|
||||
Reference in New Issue
Block a user