feat: 添加 API Key 锁定功能和请求头规则系统

1. API Key 锁定功能
- 管理员可锁定/解锁用户的 API Key
- 锁定后用户无法使用、修改或删除该 Key
- 前端显示锁定状态并禁用相关操作

2. 请求头规则系统
- 将 endpoint.headers 升级为 header_rules
- 支持 set(设置)、drop(删除)、rename(重命名)操作
- 前端提供可视化规则编辑界面
- 包含数据迁移脚本

Close #37 Close #86 Close #88
This commit is contained in:
fawney19
2026-01-16 01:18:54 +08:00
parent ea45918561
commit 8e11f3864a
22 changed files with 852 additions and 56 deletions

View File

@@ -0,0 +1,102 @@
"""add header_rules to provider_endpoints and is_locked to api_keys
Revision ID: 6d579000e511
Revises: e4ebe3233b40
Create Date: 2026-01-15 23:00:00.000000+00:00
变更:
1. provider_endpoints 表: 添加 header_rules 字段,迁移 headers 数据
2. api_keys 表: 添加 is_locked 字段(管理员锁定标志)
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic.
revision = '6d579000e511'
down_revision = 'e4ebe3233b40'
branch_labels = None
depends_on = None
def _column_exists(connection, table: str, column: str) -> bool:
"""检查列是否存在"""
result = connection.execute(
sa.text("""
SELECT 1 FROM information_schema.columns
WHERE table_name = :table AND column_name = :column
"""),
{"table": table, "column": column}
)
return result.fetchone() is not None
def upgrade() -> None:
"""添加 header_rules 字段并迁移现有 headers 数据;添加 is_locked 字段"""
connection = op.get_bind()
# ========== provider_endpoints.header_rules ==========
# 1. 添加 header_rules 列(幂等)
if not _column_exists(connection, 'provider_endpoints', 'header_rules'):
op.add_column('provider_endpoints', sa.Column('header_rules', JSON, nullable=True))
# 2. 批量迁移headers -> header_rules
# 使用纯 SQL 将 {"k1":"v1", "k2":"v2"} 转换为 [{"action":"set","key":"k1","value":"v1"}, ...]
if _column_exists(connection, 'provider_endpoints', 'headers'):
connection.execute(
sa.text("""
UPDATE provider_endpoints
SET header_rules = (
SELECT jsonb_agg(
jsonb_build_object('action', 'set', 'key', key, 'value', value)
)
FROM jsonb_each_text(headers::jsonb)
)
WHERE headers IS NOT NULL
AND headers::text != '{}'
AND header_rules IS NULL
""")
)
# 3. 删除旧列
op.drop_column('provider_endpoints', 'headers')
# ========== api_keys.is_locked ==========
if not _column_exists(connection, 'api_keys', 'is_locked'):
op.add_column(
'api_keys',
sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false')
)
def downgrade() -> None:
"""移除 header_rules 字段,恢复 headers 字段;移除 is_locked 字段"""
connection = op.get_bind()
# ========== api_keys.is_locked ==========
if _column_exists(connection, 'api_keys', 'is_locked'):
op.drop_column('api_keys', 'is_locked')
# ========== provider_endpoints.header_rules ==========
# 1. 添加 headers 列(幂等)
if not _column_exists(connection, 'provider_endpoints', 'headers'):
op.add_column('provider_endpoints', sa.Column('headers', JSON, nullable=True))
# 2. 批量迁移header_rules -> headers仅提取 set 操作)
if _column_exists(connection, 'provider_endpoints', 'header_rules'):
connection.execute(
sa.text("""
UPDATE provider_endpoints
SET headers = (
SELECT jsonb_object_agg(rule->>'key', rule->>'value')
FROM jsonb_array_elements(header_rules::jsonb) AS rule
WHERE rule->>'action' = 'set'
AND rule->>'key' IS NOT NULL
)
WHERE header_rules IS NOT NULL
AND jsonb_array_length(header_rules::jsonb) > 0
""")
)
# 3. 删除 header_rules 列
op.drop_column('provider_endpoints', 'header_rules')

View File

@@ -269,6 +269,7 @@ export interface AdminApiKey {
name?: string name?: string
key_display?: string // 脱敏后的密钥显示 key_display?: string // 脱敏后的密钥显示
is_active: boolean is_active: boolean
is_locked: boolean // 管理员锁定标志
is_standalone: boolean // 是否为独立余额Key is_standalone: boolean // 是否为独立余额Key
balance_used_usd?: number // 已使用余额仅独立Key balance_used_usd?: number // 已使用余额仅独立Key
current_balance_usd?: number | null // 当前余额独立Key预付费模式null表示无限制 current_balance_usd?: number | null // 当前余额独立Key预付费模式null表示无限制
@@ -310,6 +311,12 @@ export interface ApiKeyToggleResponse {
message: string message: string
} }
export interface ApiKeyLockResponse {
id: string // UUID
is_locked: boolean
message: string
}
// 管理员API密钥管理相关API // 管理员API密钥管理相关API
export const adminApi = { export const adminApi = {
// 获取所有独立余额Keys列表 // 获取所有独立余额Keys列表
@@ -358,6 +365,14 @@ export const adminApi = {
return response.data return response.data
}, },
// 切换API密钥锁定状态锁定/解锁)
async toggleLockApiKey(keyId: string): Promise<ApiKeyLockResponse> {
const response = await apiClient.patch<ApiKeyLockResponse>(
`/api/admin/api-keys/${keyId}/lock`
)
return response.data
},
// 为独立余额Key调整余额 // 为独立余额Key调整余额
async addApiKeyBalance(keyId: string, amountUsd: number): Promise<AdminApiKey & { message: string }> { async addApiKeyBalance(keyId: string, amountUsd: number): Promise<AdminApiKey & { message: string }> {
const response = await apiClient.patch<AdminApiKey & { message: string }>( const response = await apiClient.patch<AdminApiKey & { message: string }>(

View File

@@ -1,5 +1,5 @@
import client from '../client' import client from '../client'
import type { ProviderEndpoint, ProxyConfig } from './types' import type { ProviderEndpoint, ProxyConfig, HeaderRule } from './types'
/** /**
* 获取指定 Provider 的所有 Endpoints * 获取指定 Provider 的所有 Endpoints
@@ -27,7 +27,7 @@ export async function createEndpoint(
api_format: string api_format: string
base_url: string base_url: string
custom_path?: string custom_path?: string
headers?: Record<string, string> header_rules?: HeaderRule[]
timeout?: number timeout?: number
max_retries?: number max_retries?: number
is_active?: boolean is_active?: boolean
@@ -47,7 +47,7 @@ export async function updateEndpoint(
data: Partial<{ data: Partial<{
base_url: string base_url: string
custom_path: string | null custom_path: string | null
headers: Record<string, string> header_rules: HeaderRule[]
timeout: number timeout: number
max_retries: number max_retries: number
is_active: boolean is_active: boolean

View File

@@ -62,6 +62,31 @@ export interface ProxyConfig {
enabled?: boolean // 是否启用代理false 时保留配置但不使用) enabled?: boolean // 是否启用代理false 时保留配置但不使用)
} }
/**
* 请求头规则类型
* - set: 设置/覆盖请求头
* - drop: 删除请求头
* - rename: 重命名请求头(保留原值)
*/
export interface HeaderRuleSet {
action: 'set'
key: string
value: string
}
export interface HeaderRuleDrop {
action: 'drop'
key: string
}
export interface HeaderRuleRename {
action: 'rename'
from: string
to: string
}
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
export interface ProviderEndpoint { export interface ProviderEndpoint {
id: string id: string
provider_id: string provider_id: string
@@ -69,7 +94,8 @@ export interface ProviderEndpoint {
api_format: string api_format: string
base_url: string base_url: string
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径) custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
headers?: Record<string, string> // 请求头配置
header_rules?: HeaderRule[] // 请求头规则列表,支持 set/drop/rename 操作
timeout: number timeout: number
max_retries: number max_retries: number
is_active: boolean is_active: boolean

View File

@@ -120,6 +120,7 @@ export interface ApiKey {
key?: string key?: string
key_display: string key_display: string
is_active: boolean is_active: boolean
is_locked: boolean // 管理员锁定标志
last_used_at?: string last_used_at?: string
created_at: string created_at: string
total_requests?: number total_requests?: number

View File

@@ -48,6 +48,7 @@ export interface ApiKey {
last_used_at?: string last_used_at?: string
expires_at?: string // 过期时间 expires_at?: string // 过期时间
is_active: boolean is_active: boolean
is_locked: boolean // 管理员锁定标志
is_standalone: boolean // 是否为独立余额Key is_standalone: boolean // 是否为独立余额Key
balance_used_usd?: number // 已使用余额仅独立Key balance_used_usd?: number // 已使用余额仅独立Key
current_balance_usd?: number | null // 当前余额独立Key预付费模式null表示无限制 current_balance_usd?: number | null // 当前余额独立Key预付费模式null表示无限制

View File

@@ -68,9 +68,139 @@
/> />
</div> </div>
</div> </div>
<!-- 请求头规则配置 -->
<Collapsible v-model:open="rulesExpanded" class="mt-2">
<CollapsibleTrigger as-child>
<button
type="button"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronRight
class="w-3 h-3 transition-transform"
:class="{ 'rotate-90': rulesExpanded }"
/>
<span>请求头规则</span>
<span v-if="editingRules.length > 0" class="text-primary">
({{ editingRules.length }})
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent class="pt-2">
<div class="space-y-2">
<div
v-for="(rule, index) in editingRules"
:key="index"
class="flex items-start gap-2 p-2 rounded bg-muted/50"
>
<!-- 操作类型选择 -->
<Select
:model-value="rule.action"
v-model:open="ruleSelectOpen[index]"
@update:model-value="(v) => updateRuleAction(index, v as 'set' | 'drop' | 'rename')"
>
<SelectTrigger class="w-24 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent :side-offset="4">
<SelectItem value="set">设置</SelectItem>
<SelectItem value="drop">删除</SelectItem>
<SelectItem value="rename">重命名</SelectItem>
</SelectContent>
</Select>
<!-- set: key + value -->
<template v-if="rule.action === 'set'">
<div class="flex-1 space-y-1">
<Input
v-model="rule.key"
placeholder="Header 名称"
class="h-7 text-xs"
/>
<p
v-if="validateRuleKey(rule.key, index)"
class="text-xs text-destructive"
>
{{ validateRuleKey(rule.key, index) }}
</p>
</div>
<Input
v-model="rule.value"
placeholder="Header "
class="flex-1 h-7 text-xs"
/>
</template>
<!-- drop: key only -->
<template v-else-if="rule.action === 'drop'">
<div class="flex-1 space-y-1">
<Input
v-model="rule.key"
placeholder="要删除的 Header 名称"
class="h-7 text-xs"
/>
<p
v-if="validateRuleKey(rule.key, index)"
class="text-xs text-destructive"
>
{{ validateRuleKey(rule.key, index) }}
</p>
</div>
</template>
<!-- rename: from + to -->
<template v-else-if="rule.action === 'rename'">
<div class="flex-1 space-y-1">
<Input
v-model="rule.from"
placeholder=" Header 名称"
class="h-7 text-xs"
/>
<p
v-if="validateRenameFrom(rule.from, index)"
class="text-xs text-destructive"
>
{{ validateRenameFrom(rule.from, index) }}
</p>
</div>
<ArrowRight class="w-4 h-4 shrink-0 text-muted-foreground mt-1.5" />
<div class="flex-1 space-y-1">
<Input
v-model="rule.to"
placeholder=" Header 名称"
class="h-7 text-xs"
/>
<p
v-if="validateRenameTo(rule.to, index)"
class="text-xs text-destructive"
>
{{ validateRenameTo(rule.to, index) }}
</p>
</div>
</template>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@click="removeRule(index)"
>
<X class="w-3 h-3" />
</Button>
</div>
<Button
variant="outline"
size="sm"
class="w-full h-7 text-xs"
@click="addRule"
>
<Plus class="w-3 h-3 mr-1" />
添加规则
</Button>
</div>
</CollapsibleContent>
</Collapsible>
</div> </div>
</template> </template>
<!-- 查看模式 -->
<template v-else> <template v-else>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-24 shrink-0"> <div class="w-24 shrink-0">
@@ -80,6 +210,12 @@
<span class="text-sm text-muted-foreground truncate block"> <span class="text-sm text-muted-foreground truncate block">
{{ endpoint.base_url }}{{ endpoint.custom_path ? endpoint.custom_path : '' }} {{ endpoint.base_url }}{{ endpoint.custom_path ? endpoint.custom_path : '' }}
</span> </span>
<span
v-if="getEndpointRulesCount(endpoint) > 0"
class="text-xs text-muted-foreground/70"
>
{{ getEndpointRulesCount(endpoint) }} 条请求头规则
</span>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<Button <Button
@@ -205,8 +341,11 @@ import {
SelectValue, SelectValue,
SelectContent, SelectContent,
SelectItem, SelectItem,
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from '@/components/ui' } from '@/components/ui'
import { Settings, Edit, Trash2, Check, X, Power } from 'lucide-vue-next' import { Settings, Edit, Trash2, Check, X, Power, ChevronRight, Plus, ArrowRight } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import { import {
@@ -215,10 +354,20 @@ import {
deleteEndpoint, deleteEndpoint,
API_FORMAT_LABELS, API_FORMAT_LABELS,
type ProviderEndpoint, type ProviderEndpoint,
type ProviderWithEndpointsSummary type ProviderWithEndpointsSummary,
type HeaderRule,
} from '@/api/endpoints' } from '@/api/endpoints'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
// 编辑用的规则类型(统一的可编辑结构)
interface EditableRule {
action: 'set' | 'drop' | 'rename'
key: string // set/drop 用
value: string // set 用
from: string // rename 用
to: string // rename 用
}
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
provider: ProviderWithEndpointsSummary | null provider: ProviderWithEndpointsSummary | null
@@ -243,6 +392,21 @@ const deletingEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null) const togglingEndpointId = ref<string | null>(null)
const formatSelectOpen = ref(false) const formatSelectOpen = ref(false)
// 请求头规则编辑状态
const editingRules = ref<EditableRule[]>([])
const rulesExpanded = ref(false)
const ruleSelectOpen = ref<Record<number, boolean>>({}) // 每个规则 Select 的打开状态
// 系统保留的 header 名称(不允许用户设置)
const RESERVED_HEADERS = new Set([
'authorization',
'x-api-key',
'x-goog-api-key',
'content-type',
'content-length',
'host',
])
// 内部状态 // 内部状态
const internalOpen = computed(() => props.modelValue) const internalOpen = computed(() => props.modelValue)
@@ -271,6 +435,147 @@ function getDefaultPath(apiFormat: string): string {
return format?.default_path || '' return format?.default_path || ''
} }
// 将 API 返回的 header_rules 转换为可编辑的规则数组
function loadRulesFromEndpoint(endpoint: ProviderEndpoint): EditableRule[] {
const rules: EditableRule[] = []
if (endpoint.header_rules && endpoint.header_rules.length > 0) {
for (const rule of endpoint.header_rules) {
if (rule.action === 'set') {
rules.push({ action: 'set', key: rule.key, value: rule.value || '', from: '', to: '' })
} else if (rule.action === 'drop') {
rules.push({ action: 'drop', key: rule.key, value: '', from: '', to: '' })
} else if (rule.action === 'rename') {
rules.push({ action: 'rename', key: '', value: '', from: rule.from, to: rule.to })
}
}
}
return rules
}
// 将可编辑规则数组转换为 API 需要的 HeaderRule[]
function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | undefined {
const result: HeaderRule[] = []
for (const rule of rules) {
if (rule.action === 'set' && rule.key.trim()) {
result.push({ action: 'set', key: rule.key.trim(), value: rule.value })
} else if (rule.action === 'drop' && rule.key.trim()) {
result.push({ action: 'drop', key: rule.key.trim() })
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
}
}
return result.length > 0 ? result : undefined
}
// 添加新规则
function addRule() {
editingRules.value.push({ action: 'set', key: '', value: '', from: '', to: '' })
}
// 删除规则
function removeRule(index: number) {
editingRules.value.splice(index, 1)
}
// 更新规则类型时重置字段
function updateRuleAction(index: number, action: 'set' | 'drop' | 'rename') {
const rule = editingRules.value[index]
rule.action = action
// 重置字段
rule.key = ''
rule.value = ''
rule.from = ''
rule.to = ''
}
// 验证 set/drop 的 key
function validateRuleKey(key: string, index: number): string | null {
const trimmedKey = key.trim().toLowerCase()
if (!trimmedKey) return null
// set/drop 操作都不允许操作保留头
if (RESERVED_HEADERS.has(trimmedKey)) {
return `"${key}" 是系统保留的请求头`
}
// 检查重复(在所有规则中检查同类型的 key
const duplicate = editingRules.value.findIndex(
(r, i) => i !== index && (
((r.action === 'set' || r.action === 'drop') && r.key.trim().toLowerCase() === trimmedKey) ||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedKey)
)
)
if (duplicate >= 0) {
return '请求头名称重复'
}
return null
}
// 验证 rename 的 from
function validateRenameFrom(from: string, index: number): string | null {
const trimmedFrom = from.trim().toLowerCase()
if (!trimmedFrom) return null
// 检查是否有其他规则已经修改了这个头
const duplicate = editingRules.value.findIndex(
(r, i) => i !== index &&
((r.action === 'set' && r.key.trim().toLowerCase() === trimmedFrom) ||
(r.action === 'drop' && r.key.trim().toLowerCase() === trimmedFrom) ||
(r.action === 'rename' && r.from.trim().toLowerCase() === trimmedFrom))
)
if (duplicate >= 0) {
return '该请求头已被其他规则处理'
}
return null
}
// 验证 rename 的 to
function validateRenameTo(to: string, index: number): string | null {
const trimmedTo = to.trim().toLowerCase()
if (!trimmedTo) return null
if (RESERVED_HEADERS.has(trimmedTo)) {
return `"${to}" 是系统保留的请求头`
}
// 检查重复
const duplicate = editingRules.value.findIndex(
(r, i) => i !== index &&
((r.action === 'set' && r.key.trim().toLowerCase() === trimmedTo) ||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedTo))
)
if (duplicate >= 0) {
return '请求头名称重复'
}
return null
}
// 检查所有规则是否有效(用于保存前验证)
function hasValidationErrors(): boolean {
for (let i = 0; i < editingRules.value.length; i++) {
const rule = editingRules.value[i]
if (rule.action === 'set' || rule.action === 'drop') {
if (validateRuleKey(rule.key, i)) return true
} else if (rule.action === 'rename') {
if (validateRenameFrom(rule.from, i)) return true
if (validateRenameTo(rule.to, i)) return true
}
}
return false
}
// 获取端点的请求头规则数量(用于查看模式显示)
function getEndpointRulesCount(endpoint: ProviderEndpoint): number {
return endpoint.header_rules?.length || 0
}
// 当前编辑端点的默认路径 // 当前编辑端点的默认路径
const editingDefaultPath = computed(() => { const editingDefaultPath = computed(() => {
const endpoint = localEndpoints.value.find(e => e.id === editingEndpointId.value) const endpoint = localEndpoints.value.find(e => e.id === editingEndpointId.value)
@@ -304,6 +609,8 @@ watch(() => props.modelValue, (open) => {
editingEndpointId.value = null editingEndpointId.value = null
editingUrl.value = '' editingUrl.value = ''
editingPath.value = '' editingPath.value = ''
editingRules.value = []
rulesExpanded.value = false
} else { } else {
// 关闭对话框时完全清空新端点表单 // 关闭对话框时完全清空新端点表单
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' } newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
@@ -321,6 +628,9 @@ function startEdit(endpoint: ProviderEndpoint) {
editingEndpointId.value = endpoint.id editingEndpointId.value = endpoint.id
editingUrl.value = endpoint.base_url editingUrl.value = endpoint.base_url
editingPath.value = endpoint.custom_path || '' editingPath.value = endpoint.custom_path || ''
// 加载规则数据
editingRules.value = loadRulesFromEndpoint(endpoint)
rulesExpanded.value = editingRules.value.length > 0
} }
// 取消编辑 // 取消编辑
@@ -328,17 +638,26 @@ function cancelEdit() {
editingEndpointId.value = null editingEndpointId.value = null
editingUrl.value = '' editingUrl.value = ''
editingPath.value = '' editingPath.value = ''
editingRules.value = []
rulesExpanded.value = false
} }
// 保存端点 // 保存端点
async function saveEndpointUrl(endpoint: ProviderEndpoint) { async function saveEndpointUrl(endpoint: ProviderEndpoint) {
if (!editingUrl.value) return if (!editingUrl.value) return
// 检查规则是否有验证错误
if (hasValidationErrors()) {
showError('请修正请求头规则中的错误')
return
}
savingEndpointId.value = endpoint.id savingEndpointId.value = endpoint.id
try { try {
await updateEndpoint(endpoint.id, { await updateEndpoint(endpoint.id, {
base_url: editingUrl.value, base_url: editingUrl.value,
custom_path: editingPath.value || null, // 空字符串时传 null 清空 custom_path: editingPath.value || null,
header_rules: rulesToHeaderRules(editingRules.value),
}) })
success('端点已更新') success('端点已更新')
emit('endpointUpdated') emit('endpointUpdated')

View File

@@ -252,12 +252,21 @@
</div> </div>
</TableCell> </TableCell>
<TableCell class="py-4 text-center"> <TableCell class="py-4 text-center">
<Badge <div class="flex flex-col items-center gap-1">
:variant="apiKey.is_active ? 'success' : 'destructive'" <Badge
class="font-medium" :variant="apiKey.is_active ? 'success' : 'destructive'"
> class="font-medium"
{{ apiKey.is_active ? '活跃' : '禁用' }} >
</Badge> {{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="secondary"
class="text-xs"
>
已锁定
</Badge>
</div>
</TableCell> </TableCell>
<TableCell class="py-4"> <TableCell class="py-4">
<div class="flex justify-center gap-1"> <div class="flex justify-center gap-1">
@@ -279,6 +288,22 @@
> >
<DollarSign class="h-4 w-4" /> <DollarSign class="h-4 w-4" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="apiKey.is_locked ? '解锁' : '锁定'"
@click="toggleLockApiKey(apiKey)"
>
<Lock
v-if="apiKey.is_locked"
class="h-4 w-4"
/>
<LockOpen
v-else
class="h-4 w-4"
/>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -343,12 +368,21 @@
{{ apiKey.name || '未命名 Key' }} {{ apiKey.name || '未命名 Key' }}
</div> </div>
</div> </div>
<Badge <div class="flex flex-col items-end gap-1">
:variant="apiKey.is_active ? 'success' : 'destructive'" <Badge
class="text-xs flex-shrink-0" :variant="apiKey.is_active ? 'success' : 'destructive'"
> class="text-xs flex-shrink-0"
{{ apiKey.is_active ? '活跃' : '禁用' }} >
</Badge> {{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="secondary"
class="text-xs"
>
已锁定
</Badge>
</div>
</div> </div>
<div class="flex flex-wrap gap-2 text-[11px] text-muted-foreground"> <div class="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
@@ -455,6 +489,21 @@
<DollarSign class="h-3.5 w-3.5 mr-1.5" /> <DollarSign class="h-3.5 w-3.5 mr-1.5" />
调整 调整
</Button> </Button>
<Button
variant="outline"
size="sm"
@click="toggleLockApiKey(apiKey)"
>
<Lock
v-if="apiKey.is_locked"
class="h-3.5 w-3.5 mr-1.5"
/>
<LockOpen
v-else
class="h-3.5 w-3.5 mr-1.5"
/>
{{ apiKey.is_locked ? '解锁' : '锁定' }}
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -466,7 +515,7 @@
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="text-rose-600" class="text-rose-600 col-span-2"
@click="deleteApiKey(apiKey)" @click="deleteApiKey(apiKey)"
> >
<Trash2 class="h-3.5 w-3.5 mr-1.5" /> <Trash2 class="h-3.5 w-3.5 mr-1.5" />
@@ -685,7 +734,9 @@ import {
Copy, Copy,
CheckCircle, CheckCircle,
SquarePen, SquarePen,
Search Search,
Lock,
LockOpen
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys' import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
@@ -830,6 +881,20 @@ async function toggleApiKey(apiKey: AdminApiKey) {
} }
} }
async function toggleLockApiKey(apiKey: AdminApiKey) {
try {
const response = await adminApi.toggleLockApiKey(apiKey.id)
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
if (index !== -1) {
apiKeys.value[index].is_locked = response.is_locked
}
success(response.message)
} catch (err: any) {
log.error('切换密钥锁定状态失败:', err)
error(err.response?.data?.detail || '操作失败')
}
}
async function deleteApiKey(apiKey: AdminApiKey) { async function deleteApiKey(apiKey: AdminApiKey) {
const confirmed = await confirmDanger( const confirmed = await confirmDanger(
`确定要删除这个独立余额 Key 吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`, `确定要删除这个独立余额 Key 吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`,

View File

@@ -556,6 +556,13 @@
> >
{{ apiKey.is_active ? '活跃' : '已禁用' }} {{ apiKey.is_active ? '活跃' : '已禁用' }}
</Badge> </Badge>
<Badge
v-if="apiKey.is_locked"
variant="secondary"
class="text-xs"
>
已锁定
</Badge>
<Badge <Badge
v-if="apiKey.is_standalone" v-if="apiKey.is_standalone"
variant="default" variant="default"
@@ -588,6 +595,22 @@
${{ (apiKey.total_cost_usd || 0).toFixed(4) }} ${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
</div> </div>
</div> </div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="apiKey.is_locked ? '解锁' : '锁定'"
@click="toggleLockApiKey(apiKey)"
>
<Lock
v-if="apiKey.is_locked"
class="h-4 w-4"
/>
<LockOpen
v-else
class="h-4 w-4"
/>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -740,7 +763,9 @@ import {
Trash2, Trash2,
Copy, Copy,
Search, Search,
CheckCircle CheckCircle,
Lock,
LockOpen
} from 'lucide-vue-next' } from 'lucide-vue-next'
// 功能组件 // 功能组件
@@ -1024,6 +1049,21 @@ async function deleteApiKey(apiKey: any) {
} }
} }
async function toggleLockApiKey(apiKey: any) {
try {
const response = await adminApi.toggleLockApiKey(apiKey.id)
// 更新本地状态
const index = userApiKeys.value.findIndex(k => k.id === apiKey.id)
if (index !== -1) {
userApiKeys.value[index].is_locked = response.is_locked
}
success(response.message)
} catch (err: any) {
log.error('切换密钥锁定状态失败:', err)
error(err.response?.data?.error?.message || err.response?.data?.detail || '操作失败', '锁定/解锁失败')
}
}
async function copyFullKey(apiKey: any) { async function copyFullKey(apiKey: any) {
try { try {
// 调用后端 API 获取完整密钥 // 调用后端 API 获取完整密钥

View File

@@ -129,12 +129,14 @@
:key="cap.name" :key="cap.name"
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all" class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all"
:class="[ :class="[
apiKey.is_locked ? 'opacity-50 cursor-not-allowed' : '',
isCapabilityEnabled(apiKey, cap.name) isCapabilityEnabled(apiKey, cap.name)
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground' : 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground'
]" ]"
:title="getCapabilityTooltip(cap, isCapabilityEnabled(apiKey, cap.name))" :title="apiKey.is_locked ? '已锁定' : getCapabilityTooltip(cap, isCapabilityEnabled(apiKey, cap.name))"
@click.stop="toggleCapability(apiKey, cap.name)" :disabled="apiKey.is_locked"
@click.stop="!apiKey.is_locked && toggleCapability(apiKey, cap.name)"
> >
<Check <Check
v-if="isCapabilityEnabled(apiKey, cap.name)" v-if="isCapabilityEnabled(apiKey, cap.name)"
@@ -191,12 +193,21 @@
<!-- 状态 --> <!-- 状态 -->
<TableCell class="py-4 text-center"> <TableCell class="py-4 text-center">
<Badge <div class="flex flex-col items-center gap-1">
:variant="apiKey.is_active ? 'success' : 'secondary'" <Badge
class="font-medium px-3 py-1" :variant="apiKey.is_active ? 'success' : 'secondary'"
> class="font-medium px-3 py-1"
{{ apiKey.is_active ? '活跃' : '禁用' }} >
</Badge> {{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="warning"
class="font-medium text-[10px]"
>
已锁定
</Badge>
</div>
</TableCell> </TableCell>
<!-- 最后使用时间 --> <!-- 最后使用时间 -->
@@ -211,7 +222,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
:title="apiKey.is_active ? '禁用' : '启用'" :title="apiKey.is_locked ? '已锁定' : (apiKey.is_active ? '禁用' : '启用')"
:disabled="apiKey.is_locked"
@click="toggleApiKey(apiKey)" @click="toggleApiKey(apiKey)"
> >
<Power class="h-4 w-4" /> <Power class="h-4 w-4" />
@@ -220,7 +232,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
title="删除" :title="apiKey.is_locked ? '已锁定' : '删除'"
:disabled="apiKey.is_locked"
@click="confirmDelete(apiKey)" @click="confirmDelete(apiKey)"
> >
<Trash2 class="h-4 w-4" /> <Trash2 class="h-4 w-4" />
@@ -256,6 +269,13 @@
> >
{{ apiKey.is_active ? '活跃' : '禁用' }} {{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge> </Badge>
<Badge
v-if="apiKey.is_locked"
variant="warning"
class="text-[10px] px-1.5 py-0"
>
已锁定
</Badge>
</div> </div>
<div class="flex items-center gap-0.5 flex-shrink-0"> <div class="flex items-center gap-0.5 flex-shrink-0">
<Button <Button
@@ -271,7 +291,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
:title="apiKey.is_active ? '禁用' : '启用'" :title="apiKey.is_locked ? '已锁定' : (apiKey.is_active ? '禁用' : '启用')"
:disabled="apiKey.is_locked"
@click="toggleApiKey(apiKey)" @click="toggleApiKey(apiKey)"
> >
<Power class="h-3.5 w-3.5" /> <Power class="h-3.5 w-3.5" />
@@ -280,7 +301,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
title="删除" :title="apiKey.is_locked ? '已锁定' : '删除'"
:disabled="apiKey.is_locked"
@click="confirmDelete(apiKey)" @click="confirmDelete(apiKey)"
> >
<Trash2 class="h-3.5 w-3.5" /> <Trash2 class="h-3.5 w-3.5" />

View File

@@ -210,6 +210,25 @@ async def delete_api_key(key_id: str, request: Request, db: Session = Depends(ge
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{key_id}/lock")
async def toggle_lock_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
"""
切换 API Key 锁定状态
锁定/解锁指定的 API Key。锁定后用户无法使用和操作此密钥。
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `id`: API Key ID
- `is_locked`: 新的锁定状态
- `message`: 提示信息
"""
adapter = AdminToggleLockApiKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{key_id}/balance") @router.patch("/{key_id}/balance")
async def add_balance_to_key( async def add_balance_to_key(
key_id: str, key_id: str,
@@ -346,6 +365,7 @@ class AdminListStandaloneKeysAdapter(AdminApiAdapter):
"name": api_key.name, "name": api_key.name,
"key_display": api_key.get_display_key(), "key_display": api_key.get_display_key(),
"is_active": api_key.is_active, "is_active": api_key.is_active,
"is_locked": api_key.is_locked,
"is_standalone": api_key.is_standalone, "is_standalone": api_key.is_standalone,
"current_balance_usd": api_key.current_balance_usd, "current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": float(api_key.balance_used_usd or 0), "balance_used_usd": float(api_key.balance_used_usd or 0),
@@ -541,6 +561,39 @@ class AdminToggleApiKeyAdapter(AdminApiAdapter):
} }
class AdminToggleLockApiKeyAdapter(AdminApiAdapter):
"""切换API密钥锁定状态"""
def __init__(self, key_id: str):
self.key_id = key_id
async def handle(self, context): # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
api_key.is_locked = not api_key.is_locked
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(api_key)
logger.info(f"管理员切换API密钥锁定状态: Key ID {self.key_id}, 新状态 {'锁定' if api_key.is_locked else '解锁'}")
context.add_audit_metadata(
action="toggle_lock_api_key",
target_key_id=api_key.id,
user_id=api_key.user_id,
new_lock_status="locked" if api_key.is_locked else "unlocked",
)
return {
"id": api_key.id,
"is_locked": api_key.is_locked,
"message": f"API密钥已{'锁定' if api_key.is_locked else '解锁'}",
}
class AdminDeleteApiKeyAdapter(AdminApiAdapter): class AdminDeleteApiKeyAdapter(AdminApiAdapter):
def __init__(self, key_id: str): def __init__(self, key_id: str):
self.key_id = key_id self.key_id = key_id
@@ -663,6 +716,7 @@ class AdminGetKeyDetailAdapter(AdminApiAdapter):
"name": api_key.name, "name": api_key.name,
"key_display": api_key.get_display_key(), "key_display": api_key.get_display_key(),
"is_active": api_key.is_active, "is_active": api_key.is_active,
"is_locked": api_key.is_locked,
"is_standalone": api_key.is_standalone, "is_standalone": api_key.is_standalone,
"current_balance_usd": api_key.current_balance_usd, "current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": float(api_key.balance_used_usd or 0), "balance_used_usd": float(api_key.balance_used_usd or 0),

View File

@@ -102,7 +102,7 @@ async def create_provider_endpoint(
- `api_format`: API 格式(如 claude、openai、gemini 等) - `api_format`: API 格式(如 claude、openai、gemini 等)
- `base_url`: 基础 URL - `base_url`: 基础 URL
- `custom_path`: 自定义路径(可选) - `custom_path`: 自定义路径(可选)
- `headers`: 自定义请求头(可选 - `header_rules`: 请求头规则列表(可选,支持 set/drop/rename 操作
- `timeout`: 超时时间(秒,默认 300 - `timeout`: 超时时间(秒,默认 300
- `max_retries`: 最大重试次数(默认 2 - `max_retries`: 最大重试次数(默认 2
- `config`: 额外配置(可选) - `config`: 额外配置(可选)
@@ -169,7 +169,7 @@ async def update_endpoint(
**请求体字段**(均为可选): **请求体字段**(均为可选):
- `base_url`: 基础 URL - `base_url`: 基础 URL
- `custom_path`: 自定义路径 - `custom_path`: 自定义路径
- `headers`: 自定义请求头 - `header_rules`: 请求头规则列表
- `timeout`: 超时时间(秒) - `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数 - `max_retries`: 最大重试次数
- `is_active`: 是否活跃 - `is_active`: 是否活跃
@@ -298,13 +298,14 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
) )
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
new_endpoint = ProviderEndpoint( new_endpoint = ProviderEndpoint(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
provider_id=self.provider_id, provider_id=self.provider_id,
api_format=self.endpoint_data.api_format, api_format=self.endpoint_data.api_format,
base_url=self.endpoint_data.base_url, base_url=self.endpoint_data.base_url,
custom_path=self.endpoint_data.custom_path, custom_path=self.endpoint_data.custom_path,
headers=self.endpoint_data.headers, header_rules=self.endpoint_data.header_rules,
timeout=self.endpoint_data.timeout, timeout=self.endpoint_data.timeout,
max_retries=self.endpoint_data.max_retries, max_retries=self.endpoint_data.max_retries,
is_active=True, is_active=True,
@@ -398,6 +399,7 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在") raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
update_data = self.endpoint_data.model_dump(exclude_unset=True) update_data = self.endpoint_data.model_dump(exclude_unset=True)
# 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理 # 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
if "proxy" in update_data: if "proxy" in update_data:
if update_data["proxy"] is not None: if update_data["proxy"] is not None:

View File

@@ -15,11 +15,13 @@ from src.api.handlers.base.chat_adapter_base import get_adapter_class
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
from src.config.constants import TimeoutDefaults from src.config.constants import TimeoutDefaults
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.headers import get_extra_headers_from_endpoint
from src.core.logger import logger from src.core.logger import logger
from src.database.database import get_db from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User
from src.utils.auth_utils import get_current_user from src.utils.auth_utils import get_current_user
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"]) router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
@@ -130,7 +132,7 @@ async def query_available_models(
"api_key": api_key_value, "api_key": api_key_value,
"base_url": endpoint.base_url, "base_url": endpoint.base_url,
"api_format": fmt, "api_format": fmt,
"extra_headers": endpoint.headers, "extra_headers": get_extra_headers_from_endpoint(endpoint),
}) })
if not endpoint_configs: if not endpoint_configs:
@@ -160,7 +162,7 @@ async def query_available_models(
"api_key": api_key_value, "api_key": api_key_value,
"base_url": endpoint.base_url, "base_url": endpoint.base_url,
"api_format": endpoint.api_format, "api_format": endpoint.api_format,
"extra_headers": endpoint.headers, "extra_headers": get_extra_headers_from_endpoint(endpoint),
}) })
break # 只取第一个可用的 Key break # 只取第一个可用的 Key
@@ -321,7 +323,7 @@ async def test_model(
"api_key_id": api_key.id, # 添加API Key ID用于用量记录 "api_key_id": api_key.id, # 添加API Key ID用于用量记录
"base_url": endpoint.base_url, "base_url": endpoint.base_url,
"api_format": endpoint.api_format, "api_format": endpoint.api_format,
"extra_headers": endpoint.headers, "extra_headers": get_extra_headers_from_endpoint(endpoint),
"timeout": provider.timeout or TimeoutDefaults.HTTP_REQUEST, "timeout": provider.timeout or TimeoutDefaults.HTTP_REQUEST,
} }

View File

@@ -773,7 +773,7 @@ class AdminExportConfigAdapter(AdminApiAdapter):
{ {
"api_format": ep.api_format, "api_format": ep.api_format,
"base_url": ep.base_url, "base_url": ep.base_url,
"headers": ep.headers, "header_rules": ep.header_rules,
"timeout": ep.timeout, "timeout": ep.timeout,
"max_retries": ep.max_retries, "max_retries": ep.max_retries,
"is_active": ep.is_active, "is_active": ep.is_active,
@@ -1063,7 +1063,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
existing_ep.base_url = ep_data.get( existing_ep.base_url = ep_data.get(
"base_url", existing_ep.base_url "base_url", existing_ep.base_url
) )
existing_ep.headers = ep_data.get("headers") existing_ep.header_rules = ep_data.get("header_rules")
existing_ep.timeout = ep_data.get("timeout", 300) existing_ep.timeout = ep_data.get("timeout", 300)
existing_ep.max_retries = ep_data.get("max_retries", 2) existing_ep.max_retries = ep_data.get("max_retries", 2)
existing_ep.is_active = ep_data.get("is_active", True) existing_ep.is_active = ep_data.get("is_active", True)
@@ -1078,7 +1078,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
provider_id=provider_id, provider_id=provider_id,
api_format=ep_data["api_format"], api_format=ep_data["api_format"],
base_url=ep_data["base_url"], base_url=ep_data["base_url"],
headers=ep_data.get("headers"), header_rules=ep_data.get("header_rules"),
timeout=ep_data.get("timeout", 300), timeout=ep_data.get("timeout", 300),
max_retries=ep_data.get("max_retries", 2), max_retries=ep_data.get("max_retries", 2),
is_active=ep_data.get("is_active", True), is_active=ep_data.get("is_active", True),

View File

@@ -489,6 +489,7 @@ class AdminGetUserKeysAdapter(AdminApiAdapter):
"name": key.name, "name": key.name,
"key_display": key.get_display_key(), "key_display": key.get_display_key(),
"is_active": key.is_active, "is_active": key.is_active,
"is_locked": key.is_locked,
"total_requests": key.total_requests, "total_requests": key.total_requests,
"total_cost_usd": float(key.total_cost_usd or 0), "total_cost_usd": float(key.total_cost_usd or 0),
"rate_limit": key.rate_limit, "rate_limit": key.rate_limit,

View File

@@ -140,16 +140,17 @@ class PassthroughRequestBuilder(RequestBuilder):
builder = HeaderBuilder() builder = HeaderBuilder()
# 2. 透传原始头部(排除敏感头部 - 黑名单模式 # 2. 透传原始头部(排除默认敏感头部)
if original_headers: if original_headers:
for name, value in original_headers.items(): for name, value in original_headers.items():
if name.lower() in SENSITIVE_HEADERS: if name.lower() in SENSITIVE_HEADERS:
continue continue
builder.add(name, value) builder.add(name, value)
# 3. 添加 endpoint 配置的额外头部(不能覆盖认证头/Content-Type # 3. 应用 endpoint 的请求头规则
if endpoint.headers: header_rules = getattr(endpoint, "header_rules", None)
builder.add_protected(endpoint.headers, protected_keys) if header_rules:
builder.apply_rules(header_rules, protected_keys)
# 4. 添加额外头部 # 4. 添加额外头部
if extra_headers: if extra_headers:

View File

@@ -514,6 +514,7 @@ class ListMyApiKeysAdapter(AuthenticatedApiAdapter):
"name": key.name, "name": key.name,
"key_display": key.get_display_key(), "key_display": key.get_display_key(),
"is_active": key.is_active, "is_active": key.is_active,
"is_locked": key.is_locked,
"last_used_at": ( "last_used_at": (
real_stats["last_used_at"].isoformat() real_stats["last_used_at"].isoformat()
if real_stats["last_used_at"] if real_stats["last_used_at"]
@@ -614,6 +615,7 @@ class GetMyApiKeyDetailAdapter(AuthenticatedApiAdapter):
"name": api_key.name, "name": api_key.name,
"key_display": api_key.get_display_key(), "key_display": api_key.get_display_key(),
"is_active": api_key.is_active, "is_active": api_key.is_active,
"is_locked": api_key.is_locked,
"allowed_providers": api_key.allowed_providers, "allowed_providers": api_key.allowed_providers,
"force_capabilities": api_key.force_capabilities, "force_capabilities": api_key.force_capabilities,
"rate_limit": api_key.rate_limit, "rate_limit": api_key.rate_limit,
@@ -637,6 +639,8 @@ class DeleteMyApiKeyAdapter(AuthenticatedApiAdapter):
) )
if not api_key: if not api_key:
raise NotFoundException("API密钥不存在", "api_key") raise NotFoundException("API密钥不存在", "api_key")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法删除")
context.db.delete(api_key) context.db.delete(api_key)
context.db.commit() context.db.commit()
return {"message": "API密钥已删除"} return {"message": "API密钥已删除"}
@@ -656,6 +660,8 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
) )
if not api_key: if not api_key:
raise NotFoundException("API密钥不存在", "api_key") raise NotFoundException("API密钥不存在", "api_key")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改状态")
api_key.is_active = not api_key.is_active api_key.is_active = not api_key.is_active
context.db.commit() context.db.commit()
context.db.refresh(api_key) context.db.refresh(api_key)
@@ -1055,6 +1061,8 @@ class UpdateApiKeyProvidersAdapter(AuthenticatedApiAdapter):
) )
if not api_key: if not api_key:
raise NotFoundException("API密钥不存在") raise NotFoundException("API密钥不存在")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
if request.allowed_providers is not None and len(request.allowed_providers) > 0: if request.allowed_providers is not None and len(request.allowed_providers) > 0:
provider_ids = [cfg.provider_id for cfg in request.allowed_providers] provider_ids = [cfg.provider_id for cfg in request.allowed_providers]
@@ -1101,6 +1109,8 @@ class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter):
) )
if not api_key: if not api_key:
raise NotFoundException("API密钥不存在") raise NotFoundException("API密钥不存在")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
# 保存旧值用于审计 # 保存旧值用于审计
old_capabilities = api_key.force_capabilities old_capabilities = api_key.force_capabilities

View File

@@ -213,7 +213,7 @@ class HeaderBuilder:
""" """
添加头部但保护指定的 key 不被覆盖 添加头部但保护指定的 key 不被覆盖
用于 endpoint.headers 不能覆盖认证头的场景。 用于 endpoint 额外请求头不能覆盖认证头的场景。
""" """
protected_lower = {k.lower() for k in protected_keys} protected_lower = {k.lower() for k in protected_keys}
for k, v in headers.items(): for k, v in headers.items():
@@ -227,6 +227,61 @@ class HeaderBuilder:
self._headers.pop(k.lower(), None) self._headers.pop(k.lower(), None)
return self return self
def rename(self, from_key: str, to_key: str) -> "HeaderBuilder":
"""
重命名头部(保留原值)
如果 from_key 不存在,则不做任何操作。
"""
from_lower = from_key.lower()
if from_lower in self._headers:
_, value = self._headers.pop(from_lower)
self._headers[to_key.lower()] = (to_key, value)
return self
def apply_rules(
self,
rules: list[Dict[str, Any]],
protected_keys: Optional[AbstractSet[str]] = None,
) -> "HeaderBuilder":
"""
应用请求头规则
支持的规则类型:
- set: 设置/覆盖头部 {"action": "set", "key": "X-Custom", "value": "fixed"}
- drop: 删除头部 {"action": "drop", "key": "X-Unwanted"}
- rename: 重命名头部 {"action": "rename", "from": "X-Old", "to": "X-New"}
Args:
rules: 规则列表
protected_keys: 受保护的 key不能被 set/drop/rename 修改)
"""
protected_lower = {k.lower() for k in protected_keys} if protected_keys else set()
for rule in rules:
action = rule.get("action")
if action == "set":
key = rule.get("key", "")
value = rule.get("value", "")
if key and key.lower() not in protected_lower:
self.add(key, value)
elif action == "drop":
key = rule.get("key", "")
if key and key.lower() not in protected_lower:
self._headers.pop(key.lower(), None)
elif action == "rename":
from_key = rule.get("from", "")
to_key = rule.get("to", "")
if from_key and to_key:
# 两个 key 都不能是受保护的
if from_key.lower() not in protected_lower and to_key.lower() not in protected_lower:
self.rename(from_key, to_key)
return self
def build(self) -> Dict[str, str]: def build(self) -> Dict[str, str]:
"""构建最终的头部字典""" """构建最终的头部字典"""
return {original_key: value for original_key, value in self._headers.values()} return {original_key: value for original_key, value in self._headers.values()}
@@ -471,3 +526,53 @@ def get_adapter_protected_keys(api_format: APIFormat) -> tuple[str, ...]:
""" """
return tuple(get_protected_keys(api_format)) return tuple(get_protected_keys(api_format))
# =============================================================================
# Header Rules 工具函数
# =============================================================================
def extract_set_headers_from_rules(
header_rules: Optional[list[Dict[str, Any]]],
) -> Optional[Dict[str, str]]:
"""
从 header_rules 中提取 set 操作生成的头部字典
用于需要构造额外请求头的场景(如模型列表查询、模型测试等)。
注意drop 和 rename 操作在这里不适用,因为它们用于修改已存在的头部。
Args:
header_rules: 请求头规则列表 [{"action": "set", "key": "X-Custom", "value": "val"}, ...]
Returns:
set 操作生成的头部字典,如果没有则返回 None
"""
if not header_rules:
return None
headers: Dict[str, str] = {}
for rule in header_rules:
if rule.get("action") == "set":
key = rule.get("key", "")
value = rule.get("value", "")
if key:
headers[key] = value
return headers if headers else None
def get_extra_headers_from_endpoint(endpoint: Any) -> Optional[Dict[str, str]]:
"""
从 endpoint 提取额外请求头
用于需要构造额外请求头的场景(如模型列表查询、模型测试等)。
Args:
endpoint: ProviderEndpoint 对象
Returns:
额外请求头字典,如果没有则返回 None
"""
header_rules = getattr(endpoint, "header_rules", None)
return extract_set_headers_from_rules(header_rules)

View File

@@ -176,6 +176,7 @@ class ApiKey(Base):
# 状态 # 状态
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
is_locked = Column(Boolean, default=False, nullable=False) # 管理员锁定,用户无法使用/操作
last_used_at = Column(DateTime(timezone=True), nullable=True) last_used_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间 expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间
auto_delete_on_expiry = Column(Boolean, default=False, nullable=False) # 过期后是否自动删除 auto_delete_on_expiry = Column(Boolean, default=False, nullable=False) # 过期后是否自动删除
@@ -602,7 +603,7 @@ class ProviderEndpoint(Base):
base_url = Column(String(500), nullable=False) base_url = Column(String(500), nullable=False)
# 请求配置 # 请求配置
headers = Column(JSON, nullable=True) # 额外请求头 header_rules = Column(JSON, nullable=True) # 请求头规则 [{action, key, value, from, to}]
timeout = Column(Integer, default=300) # 超时(秒) timeout = Column(Integer, default=300) # 超时(秒)
max_retries = Column(Integer, default=2) # 最大重试次数 max_retries = Column(Integer, default=2) # 最大重试次数

View File

@@ -10,6 +10,16 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from src.models.admin_requests import ProxyConfig from src.models.admin_requests import ProxyConfig
# ========== Header Rule 类型定义 ==========
# 请求头规则支持三种操作:
# - set: 设置/覆盖请求头 {"action": "set", "key": "X-Custom", "value": "val"}
# - drop: 删除请求头 {"action": "drop", "key": "X-Unwanted"}
# - rename: 重命名请求头 {"action": "rename", "from": "X-Old", "to": "X-New"}
# 实际验证在 headers.py 的 apply_rules 中处理
HeaderRule = Dict[str, Any]
# ========== ProviderEndpoint CRUD ========== # ========== ProviderEndpoint CRUD ==========
@@ -21,8 +31,12 @@ class ProviderEndpointCreate(BaseModel):
base_url: str = Field(..., min_length=1, max_length=500, description="API 基础 URL") base_url: str = Field(..., min_length=1, max_length=500, description="API 基础 URL")
custom_path: Optional[str] = Field(default=None, max_length=200, description="自定义请求路径") custom_path: Optional[str] = Field(default=None, max_length=200, description="自定义请求路径")
# 请求配置 # 请求配置
headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头") header_rules: Optional[List[HeaderRule]] = Field(
default=None,
description="请求头规则列表,支持 set/drop/rename 操作",
)
timeout: int = Field(default=300, ge=10, le=600, description="超时时间(秒)") timeout: int = Field(default=300, ge=10, le=600, description="超时时间(秒)")
max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数") max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数")
@@ -60,7 +74,13 @@ class ProviderEndpointUpdate(BaseModel):
default=None, min_length=1, max_length=500, description="API 基础 URL" default=None, min_length=1, max_length=500, description="API 基础 URL"
) )
custom_path: Optional[str] = Field(default=None, max_length=200, description="自定义请求路径") custom_path: Optional[str] = Field(default=None, max_length=200, description="自定义请求路径")
headers: Optional[Dict[str, str]] = Field(default=None, description="自定义请求头")
# 请求头配置
header_rules: Optional[List[HeaderRule]] = Field(
default=None,
description="请求头规则列表,支持 set/drop/rename 操作",
)
timeout: Optional[int] = Field(default=None, ge=10, le=600, description="超时时间(秒)") timeout: Optional[int] = Field(default=None, ge=10, le=600, description="超时时间(秒)")
max_retries: Optional[int] = Field(default=None, ge=0, le=10, description="最大重试次数") max_retries: Optional[int] = Field(default=None, ge=0, le=10, description="最大重试次数")
is_active: Optional[bool] = Field(default=None, description="是否启用") is_active: Optional[bool] = Field(default=None, description="是否启用")
@@ -92,8 +112,11 @@ class ProviderEndpointResponse(BaseModel):
base_url: str base_url: str
custom_path: Optional[str] = None custom_path: Optional[str] = None
# 请求配置 # 请求配置
headers: Optional[Dict[str, str]] = None header_rules: Optional[List[HeaderRule]] = Field(
default=None, description="请求头规则列表"
)
timeout: int timeout: int
max_retries: int max_retries: int

View File

@@ -22,6 +22,7 @@ from sqlalchemy.orm import Session, joinedload
from src.config import config from src.config import config
from src.core.logger import logger from src.core.logger import logger
from src.core.enums import AuthSource from src.core.enums import AuthSource
from src.core.exceptions import ForbiddenException
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -385,6 +386,10 @@ class AuthService:
logger.warning("API认证失败 - 密钥已禁用") logger.warning("API认证失败 - 密钥已禁用")
return None return None
if key_record.is_locked:
logger.warning("API认证失败 - 密钥已被管理员锁定")
raise ForbiddenException("该API密钥已被管理员锁定请联系管理员")
# 检查过期时间 # 检查过期时间
if key_record.expires_at: if key_record.expires_at:
# 确保 expires_at 是 aware datetime # 确保 expires_at 是 aware datetime

View File

@@ -19,6 +19,7 @@ import httpx
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.headers import get_extra_headers_from_endpoint
from src.core.logger import logger from src.core.logger import logger
from src.database import create_session from src.database import create_session
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
@@ -268,7 +269,7 @@ class ModelFetchScheduler:
"api_key": api_key_value, "api_key": api_key_value,
"base_url": endpoint.base_url, "base_url": endpoint.base_url,
"api_format": fmt, "api_format": fmt,
"extra_headers": endpoint.headers, "extra_headers": get_extra_headers_from_endpoint(endpoint),
} }
) )