mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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')
|
||||||
@@ -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 }>(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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表示无限制)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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此操作无法撤销。`,
|
||||||
|
|||||||
@@ -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 获取完整密钥
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) # 最大重试次数
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user