mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-09 19:22:26 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2395093394 | ||
|
|
28209e1c2a | ||
|
|
00562dd1d4 | ||
|
|
0f78d5cbf3 | ||
|
|
431c6de8d2 | ||
|
|
142e15bbcc | ||
|
|
31acc5c607 | ||
|
|
bfa0a26d41 | ||
|
|
93ab9b6a5e | ||
|
|
35e29d46bd | ||
|
|
3e4309eba3 | ||
|
|
414f45aa71 | ||
|
|
ebdc76346f | ||
|
|
64bfa955f4 | ||
|
|
612992fa1f | ||
|
|
9bfb295238 |
@@ -51,7 +51,7 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
|
|||||||
```bash
|
```bash
|
||||||
# 1. 克隆代码
|
# 1. 克隆代码
|
||||||
git clone https://github.com/fawney19/Aether.git
|
git clone https://github.com/fawney19/Aether.git
|
||||||
cd aether
|
cd Aether
|
||||||
|
|
||||||
# 2. 配置环境变量
|
# 2. 配置环境变量
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -72,7 +72,7 @@ docker compose pull && docker compose up -d && ./migrate.sh
|
|||||||
```bash
|
```bash
|
||||||
# 1. 克隆代码
|
# 1. 克隆代码
|
||||||
git clone https://github.com/fawney19/Aether.git
|
git clone https://github.com/fawney19/Aether.git
|
||||||
cd aether
|
cd Aether
|
||||||
|
|
||||||
# 2. 配置环境变量
|
# 2. 配置环境变量
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""add ldap authentication support
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6g7h8
|
||||||
|
Revises: b2c3d4e5f6g7
|
||||||
|
Create Date: 2026-01-01 14:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c3d4e5f6g7h8'
|
||||||
|
down_revision = 'b2c3d4e5f6g7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _type_exists(conn, type_name: str) -> bool:
|
||||||
|
"""检查 PostgreSQL 类型是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_type WHERE typname = :name"),
|
||||||
|
{"name": type_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||||
|
"""检查列是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = :table AND column_name = :column
|
||||||
|
"""),
|
||||||
|
{"table": table_name, "column": column_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(conn, index_name: str) -> bool:
|
||||||
|
"""检查索引是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||||
|
{"name": index_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, table_name: str) -> bool:
|
||||||
|
"""检查表是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = :name AND table_schema = 'public'
|
||||||
|
"""),
|
||||||
|
{"name": table_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""添加 LDAP 认证支持
|
||||||
|
|
||||||
|
1. 创建 authsource 枚举类型
|
||||||
|
2. 在 users 表添加 auth_source 字段和 LDAP 标识字段
|
||||||
|
3. 创建 ldap_configs 表
|
||||||
|
"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 1. 创建 authsource 枚举类型(幂等)
|
||||||
|
if not _type_exists(conn, 'authsource'):
|
||||||
|
conn.execute(text("CREATE TYPE authsource AS ENUM ('local', 'ldap')"))
|
||||||
|
|
||||||
|
# 2. 在 users 表添加字段(幂等)
|
||||||
|
if not _column_exists(conn, 'users', 'auth_source'):
|
||||||
|
op.add_column('users', sa.Column(
|
||||||
|
'auth_source',
|
||||||
|
sa.Enum('local', 'ldap', name='authsource', create_type=False),
|
||||||
|
nullable=False,
|
||||||
|
server_default='local'
|
||||||
|
))
|
||||||
|
|
||||||
|
if not _column_exists(conn, 'users', 'ldap_dn'):
|
||||||
|
op.add_column('users', sa.Column('ldap_dn', sa.String(length=512), nullable=True))
|
||||||
|
|
||||||
|
if not _column_exists(conn, 'users', 'ldap_username'):
|
||||||
|
op.add_column('users', sa.Column('ldap_username', sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
# 创建索引(幂等)
|
||||||
|
if not _index_exists(conn, 'ix_users_ldap_dn'):
|
||||||
|
op.create_index('ix_users_ldap_dn', 'users', ['ldap_dn'])
|
||||||
|
|
||||||
|
if not _index_exists(conn, 'ix_users_ldap_username'):
|
||||||
|
op.create_index('ix_users_ldap_username', 'users', ['ldap_username'])
|
||||||
|
|
||||||
|
# 3. 创建 ldap_configs 表(幂等)
|
||||||
|
if not _table_exists(conn, 'ldap_configs'):
|
||||||
|
op.create_table(
|
||||||
|
'ldap_configs',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('server_url', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('bind_dn', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('bind_password_encrypted', sa.Text(), nullable=True),
|
||||||
|
sa.Column('base_dn', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('user_search_filter', sa.String(length=500), nullable=False, server_default='(uid={username})'),
|
||||||
|
sa.Column('username_attr', sa.String(length=50), nullable=False, server_default='uid'),
|
||||||
|
sa.Column('email_attr', sa.String(length=50), nullable=False, server_default='mail'),
|
||||||
|
sa.Column('display_name_attr', sa.String(length=50), nullable=False, server_default='cn'),
|
||||||
|
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('is_exclusive', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('use_starttls', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('connect_timeout', sa.Integer(), nullable=False, server_default='10'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚 LDAP 认证支持
|
||||||
|
|
||||||
|
警告:回滚前请确保:
|
||||||
|
1. 已备份数据库
|
||||||
|
2. 没有 LDAP 用户需要保留
|
||||||
|
"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 检查是否存在 LDAP 用户,防止数据丢失
|
||||||
|
if _column_exists(conn, 'users', 'auth_source'):
|
||||||
|
result = conn.execute(text("SELECT COUNT(*) FROM users WHERE auth_source = 'ldap'"))
|
||||||
|
ldap_user_count = result.scalar()
|
||||||
|
if ldap_user_count and ldap_user_count > 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"无法回滚:存在 {ldap_user_count} 个 LDAP 用户。"
|
||||||
|
f"请先删除或转换这些用户,或使用 --force 参数强制回滚(将丢失数据)。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 删除 ldap_configs 表(幂等)
|
||||||
|
if _table_exists(conn, 'ldap_configs'):
|
||||||
|
op.drop_table('ldap_configs')
|
||||||
|
|
||||||
|
# 2. 删除 users 表的 LDAP 相关字段(幂等)
|
||||||
|
if _index_exists(conn, 'ix_users_ldap_username'):
|
||||||
|
op.drop_index('ix_users_ldap_username', table_name='users')
|
||||||
|
|
||||||
|
if _index_exists(conn, 'ix_users_ldap_dn'):
|
||||||
|
op.drop_index('ix_users_ldap_dn', table_name='users')
|
||||||
|
|
||||||
|
if _column_exists(conn, 'users', 'ldap_username'):
|
||||||
|
op.drop_column('users', 'ldap_username')
|
||||||
|
|
||||||
|
if _column_exists(conn, 'users', 'ldap_dn'):
|
||||||
|
op.drop_column('users', 'ldap_dn')
|
||||||
|
|
||||||
|
if _column_exists(conn, 'users', 'auth_source'):
|
||||||
|
op.drop_column('users', 'auth_source')
|
||||||
|
|
||||||
|
# 3. 删除 authsource 枚举类型(幂等)
|
||||||
|
# 注意:不使用 CASCADE,因为此时所有依赖应该已被删除
|
||||||
|
if _type_exists(conn, 'authsource'):
|
||||||
|
conn.execute(text("DROP TYPE authsource"))
|
||||||
@@ -13,6 +13,7 @@ export interface UsersExportData {
|
|||||||
version: string
|
version: string
|
||||||
exported_at: string
|
exported_at: string
|
||||||
users: UserExport[]
|
users: UserExport[]
|
||||||
|
standalone_keys?: StandaloneKeyExport[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserExport {
|
export interface UserExport {
|
||||||
@@ -46,11 +47,15 @@ export interface UserApiKeyExport {
|
|||||||
concurrent_limit?: number | null
|
concurrent_limit?: number | null
|
||||||
force_capabilities?: any
|
force_capabilities?: any
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
expires_at?: string | null
|
||||||
auto_delete_on_expiry?: boolean
|
auto_delete_on_expiry?: boolean
|
||||||
total_requests?: number
|
total_requests?: number
|
||||||
total_cost_usd?: number
|
total_cost_usd?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 独立余额 Key 导出结构(与 UserApiKeyExport 相同,但不包含 is_standalone)
|
||||||
|
export type StandaloneKeyExport = Omit<UserApiKeyExport, 'is_standalone'>
|
||||||
|
|
||||||
export interface GlobalModelExport {
|
export interface GlobalModelExport {
|
||||||
name: string
|
name: string
|
||||||
display_name: string
|
display_name: string
|
||||||
@@ -155,6 +160,44 @@ export interface EmailTemplateResetResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LDAP 配置响应
|
||||||
|
export interface LdapConfigResponse {
|
||||||
|
server_url: string | null
|
||||||
|
bind_dn: string | null
|
||||||
|
base_dn: string | null
|
||||||
|
has_bind_password: boolean
|
||||||
|
user_search_filter: string
|
||||||
|
username_attr: string
|
||||||
|
email_attr: string
|
||||||
|
display_name_attr: string
|
||||||
|
is_enabled: boolean
|
||||||
|
is_exclusive: boolean
|
||||||
|
use_starttls: boolean
|
||||||
|
connect_timeout: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP 配置更新请求
|
||||||
|
export interface LdapConfigUpdateRequest {
|
||||||
|
server_url: string
|
||||||
|
bind_dn: string
|
||||||
|
bind_password?: string
|
||||||
|
base_dn: string
|
||||||
|
user_search_filter?: string
|
||||||
|
username_attr?: string
|
||||||
|
email_attr?: string
|
||||||
|
display_name_attr?: string
|
||||||
|
is_enabled?: boolean
|
||||||
|
is_exclusive?: boolean
|
||||||
|
use_starttls?: boolean
|
||||||
|
connect_timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP 连接测试响应
|
||||||
|
export interface LdapTestResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
// Provider 模型查询响应
|
// Provider 模型查询响应
|
||||||
export interface ProviderModelsQueryResponse {
|
export interface ProviderModelsQueryResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -189,6 +232,7 @@ export interface UsersImportResponse {
|
|||||||
stats: {
|
stats: {
|
||||||
users: { created: number; updated: number; skipped: number }
|
users: { created: number; updated: number; skipped: number }
|
||||||
api_keys: { created: number; skipped: number }
|
api_keys: { created: number; skipped: number }
|
||||||
|
standalone_keys?: { created: number; skipped: number }
|
||||||
errors: string[]
|
errors: string[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -473,5 +517,35 @@ export const adminApi = {
|
|||||||
`/api/admin/system/email/templates/${templateType}/reset`
|
`/api/admin/system/email/templates/${templateType}/reset`
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取系统版本信息
|
||||||
|
async getSystemVersion(): Promise<{ version: string }> {
|
||||||
|
const response = await apiClient.get<{ version: string }>(
|
||||||
|
'/api/admin/system/version'
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// LDAP 配置相关
|
||||||
|
// 获取 LDAP 配置
|
||||||
|
async getLdapConfig(): Promise<LdapConfigResponse> {
|
||||||
|
const response = await apiClient.get<LdapConfigResponse>('/api/admin/ldap/config')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新 LDAP 配置
|
||||||
|
async updateLdapConfig(config: LdapConfigUpdateRequest): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.put<{ message: string }>(
|
||||||
|
'/api/admin/ldap/config',
|
||||||
|
config
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 测试 LDAP 连接
|
||||||
|
async testLdapConnection(config: LdapConfigUpdateRequest): Promise<LdapTestResponse> {
|
||||||
|
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { log } from '@/utils/logger'
|
|||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
|
auth_type?: 'local' | 'ldap'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
@@ -81,6 +82,12 @@ export interface RegistrationSettingsResponse {
|
|||||||
require_email_verification: boolean
|
require_email_verification: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthSettingsResponse {
|
||||||
|
local_enabled: boolean
|
||||||
|
ldap_enabled: boolean
|
||||||
|
ldap_exclusive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
username: string
|
username: string
|
||||||
@@ -173,5 +180,10 @@ export const authApi = {
|
|||||||
{ email }
|
{ email }
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAuthSettings(): Promise<AuthSettingsResponse> {
|
||||||
|
const response = await apiClient.get<AuthSettingsResponse>('/api/auth/settings')
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ export interface UsageRecordDetail {
|
|||||||
cache_creation_price_per_1m?: number
|
cache_creation_price_per_1m?: number
|
||||||
cache_read_price_per_1m?: number
|
cache_read_price_per_1m?: number
|
||||||
price_per_request?: number // 按次计费价格
|
price_per_request?: number // 按次计费价格
|
||||||
|
api_key?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型统计接口
|
// 模型统计接口
|
||||||
@@ -192,6 +197,7 @@ export const meApi = {
|
|||||||
async getUsage(params?: {
|
async getUsage(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
search?: string // 通用搜索:密钥名、模型名
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
}): Promise<UsageResponse> {
|
}): Promise<UsageResponse> {
|
||||||
|
|||||||
@@ -192,10 +192,17 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按 provider 名称和模型名称排序
|
// 按 provider 名称排序,provider 中的模型按 release_date 从近到远排序
|
||||||
items.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
const providerCompare = a.providerName.localeCompare(b.providerName)
|
const providerCompare = a.providerName.localeCompare(b.providerName)
|
||||||
if (providerCompare !== 0) return providerCompare
|
if (providerCompare !== 0) return providerCompare
|
||||||
|
|
||||||
|
// 模型按 release_date 从近到远排序(没有日期的排到最后)
|
||||||
|
const aDate = a.releaseDate ? new Date(a.releaseDate).getTime() : 0
|
||||||
|
const bDate = b.releaseDate ? new Date(b.releaseDate).getTime() : 0
|
||||||
|
if (aDate !== bDate) return bDate - aDate // 降序:新的在前
|
||||||
|
|
||||||
|
// 日期相同或都没有日期时,按模型名称排序
|
||||||
return a.modelName.localeCompare(b.modelName)
|
return a.modelName.localeCompare(b.modelName)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export const usageApi = {
|
|||||||
async getAllUsageRecords(params?: {
|
async getAllUsageRecords(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
user_id?: string // UUID
|
user_id?: string // UUID
|
||||||
username?: string
|
username?: string
|
||||||
model?: string
|
model?: string
|
||||||
|
|||||||
@@ -66,19 +66,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 认证方式切换 -->
|
||||||
|
<div
|
||||||
|
v-if="showAuthTypeTabs"
|
||||||
|
class="auth-type-tabs"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['auth-tab', authType === 'local' && 'active']"
|
||||||
|
@click="authType = 'local'"
|
||||||
|
>
|
||||||
|
本地登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['auth-tab', authType === 'ldap' && 'active']"
|
||||||
|
@click="authType = 'ldap'"
|
||||||
|
>
|
||||||
|
LDAP 登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- 登录表单 -->
|
<!-- 登录表单 -->
|
||||||
<form
|
<form
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
@submit.prevent="handleLogin"
|
@submit.prevent="handleLogin"
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="login-email">邮箱</Label>
|
<div class="flex items-center justify-between">
|
||||||
|
<Label for="login-email">{{ emailLabel }}</Label>
|
||||||
|
<button
|
||||||
|
v-if="ldapExclusive && authType === 'ldap'"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
|
@click="authType = 'local'"
|
||||||
|
>
|
||||||
|
管理员本地登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="ldapExclusive && authType === 'local'"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
|
@click="authType = 'ldap'"
|
||||||
|
>
|
||||||
|
返回 LDAP 登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="login-email"
|
id="login-email"
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
type="email"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="hello@example.com"
|
placeholder="username 或 email"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,6 +220,30 @@ const showRegisterDialog = ref(false)
|
|||||||
const requireEmailVerification = ref(false)
|
const requireEmailVerification = ref(false)
|
||||||
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
||||||
|
|
||||||
|
// LDAP authentication settings
|
||||||
|
const PREFERRED_AUTH_TYPE_KEY = 'aether_preferred_auth_type'
|
||||||
|
function getStoredAuthType(): 'local' | 'ldap' {
|
||||||
|
const stored = localStorage.getItem(PREFERRED_AUTH_TYPE_KEY)
|
||||||
|
return (stored === 'ldap' || stored === 'local') ? stored : 'local'
|
||||||
|
}
|
||||||
|
const authType = ref<'local' | 'ldap'>(getStoredAuthType())
|
||||||
|
const localEnabled = ref(true)
|
||||||
|
const ldapEnabled = ref(false)
|
||||||
|
const ldapExclusive = ref(false)
|
||||||
|
|
||||||
|
// 保存用户的认证类型偏好
|
||||||
|
watch(authType, (newType) => {
|
||||||
|
localStorage.setItem(PREFERRED_AUTH_TYPE_KEY, newType)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showAuthTypeTabs = computed(() => {
|
||||||
|
return localEnabled.value && ldapEnabled.value && !ldapExclusive.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const emailLabel = computed(() => {
|
||||||
|
return '用户名/邮箱'
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => props.modelValue, (val) => {
|
watch(() => props.modelValue, (val) => {
|
||||||
isOpen.value = val
|
isOpen.value = val
|
||||||
// 打开对话框时重置表单
|
// 打开对话框时重置表单
|
||||||
@@ -212,7 +276,7 @@ async function handleLogin() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await authStore.login(form.value.email, form.value.password)
|
const success = await authStore.login(form.value.email, form.value.password, authType.value)
|
||||||
if (success) {
|
if (success) {
|
||||||
showSuccess('登录成功,正在跳转...')
|
showSuccess('登录成功,正在跳转...')
|
||||||
|
|
||||||
@@ -246,16 +310,84 @@ function handleSwitchToLogin() {
|
|||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load registration settings on mount
|
// Load authentication and registration settings on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await authApi.getRegistrationSettings()
|
// Load registration settings
|
||||||
allowRegistration.value = !!settings.enable_registration
|
const regSettings = await authApi.getRegistrationSettings()
|
||||||
requireEmailVerification.value = !!settings.require_email_verification
|
allowRegistration.value = !!regSettings.enable_registration
|
||||||
|
requireEmailVerification.value = !!regSettings.require_email_verification
|
||||||
|
|
||||||
|
// Load authentication settings
|
||||||
|
const authSettings = await authApi.getAuthSettings()
|
||||||
|
localEnabled.value = authSettings.local_enabled
|
||||||
|
ldapEnabled.value = authSettings.ldap_enabled
|
||||||
|
ldapExclusive.value = authSettings.ldap_exclusive
|
||||||
|
// 若仅允许 LDAP 登录,则禁用本地注册入口
|
||||||
|
if (ldapExclusive.value) {
|
||||||
|
allowRegistration.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default auth type based on settings
|
||||||
|
if (authSettings.ldap_exclusive) {
|
||||||
|
authType.value = 'ldap'
|
||||||
|
} else if (!authSettings.local_enabled && authSettings.ldap_enabled) {
|
||||||
|
authType.value = 'ldap'
|
||||||
|
} else {
|
||||||
|
authType.value = 'local'
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证
|
// If获取失败,保持默认:关闭注册 & 关闭邮箱验证 & 使用本地认证
|
||||||
allowRegistration.value = false
|
allowRegistration.value = false
|
||||||
requireEmailVerification.value = false
|
requireEmailVerification.value = false
|
||||||
|
localEnabled.value = true
|
||||||
|
ldapEnabled.value = false
|
||||||
|
ldapExclusive.value = false
|
||||||
|
authType.value = 'local'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-type-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab:hover:not(.active) {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab.active {
|
||||||
|
color: var(--book-cloth);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab.active::after {
|
||||||
|
background: var(--book-cloth);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -32,6 +32,17 @@
|
|||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||||
|
|
||||||
|
<!-- 通用搜索 -->
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
id="usage-records-search"
|
||||||
|
v-model="localSearch"
|
||||||
|
:placeholder="isAdmin ? '搜索用户/密钥/模型/提供商' : '搜索密钥/模型'"
|
||||||
|
class="w-32 sm:w-48 h-8 text-xs border-border/60 pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 用户筛选(仅管理员可见) -->
|
<!-- 用户筛选(仅管理员可见) -->
|
||||||
<Select
|
<Select
|
||||||
v-if="isAdmin && availableUsers.length > 0"
|
v-if="isAdmin && availableUsers.length > 0"
|
||||||
@@ -164,6 +175,12 @@
|
|||||||
>
|
>
|
||||||
用户
|
用户
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
v-if="!isAdmin"
|
||||||
|
class="h-12 font-semibold w-[100px]"
|
||||||
|
>
|
||||||
|
密钥
|
||||||
|
</TableHead>
|
||||||
<TableHead class="h-12 font-semibold w-[140px]">
|
<TableHead class="h-12 font-semibold w-[140px]">
|
||||||
模型
|
模型
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -196,7 +213,7 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-if="records.length === 0">
|
<TableRow v-if="records.length === 0">
|
||||||
<TableCell
|
<TableCell
|
||||||
:colspan="isAdmin ? 9 : 7"
|
:colspan="isAdmin ? 9 : 8"
|
||||||
class="text-center py-12 text-muted-foreground"
|
class="text-center py-12 text-muted-foreground"
|
||||||
>
|
>
|
||||||
暂无请求记录
|
暂无请求记录
|
||||||
@@ -218,7 +235,34 @@
|
|||||||
class="py-4 w-[100px] truncate"
|
class="py-4 w-[100px] truncate"
|
||||||
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
||||||
>
|
>
|
||||||
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
|
<div class="flex flex-col text-xs gap-0.5">
|
||||||
|
<span class="truncate">
|
||||||
|
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="record.api_key?.name"
|
||||||
|
class="text-muted-foreground truncate"
|
||||||
|
:title="record.api_key.name"
|
||||||
|
>
|
||||||
|
{{ record.api_key.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<!-- 用户页面的密钥列 -->
|
||||||
|
<TableCell
|
||||||
|
v-if="!isAdmin"
|
||||||
|
class="py-4 w-[100px]"
|
||||||
|
:title="record.api_key?.name || '-'"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col text-xs gap-0.5">
|
||||||
|
<span class="truncate">{{ record.api_key?.name || '-' }}</span>
|
||||||
|
<span
|
||||||
|
v-if="record.api_key?.display"
|
||||||
|
class="text-muted-foreground truncate"
|
||||||
|
>
|
||||||
|
{{ record.api_key.display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
class="font-medium py-4 w-[140px]"
|
class="font-medium py-4 w-[140px]"
|
||||||
@@ -438,6 +482,7 @@ import {
|
|||||||
TableCard,
|
TableCard,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Input,
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
@@ -451,7 +496,7 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
Pagination,
|
Pagination,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { RefreshCcw } from 'lucide-vue-next'
|
import { RefreshCcw, Search } from 'lucide-vue-next'
|
||||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||||
import { formatDateTime } from '../composables'
|
import { formatDateTime } from '../composables'
|
||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
@@ -471,6 +516,7 @@ const props = defineProps<{
|
|||||||
// 时间段
|
// 时间段
|
||||||
selectedPeriod: string
|
selectedPeriod: string
|
||||||
// 筛选
|
// 筛选
|
||||||
|
filterSearch: string
|
||||||
filterUser: string
|
filterUser: string
|
||||||
filterModel: string
|
filterModel: string
|
||||||
filterProvider: string
|
filterProvider: string
|
||||||
@@ -489,6 +535,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:selectedPeriod': [value: string]
|
'update:selectedPeriod': [value: string]
|
||||||
|
'update:filterSearch': [value: string]
|
||||||
'update:filterUser': [value: string]
|
'update:filterUser': [value: string]
|
||||||
'update:filterModel': [value: string]
|
'update:filterModel': [value: string]
|
||||||
'update:filterProvider': [value: string]
|
'update:filterProvider': [value: string]
|
||||||
@@ -507,6 +554,23 @@ const filterModelSelectOpen = ref(false)
|
|||||||
const filterProviderSelectOpen = ref(false)
|
const filterProviderSelectOpen = ref(false)
|
||||||
const filterStatusSelectOpen = ref(false)
|
const filterStatusSelectOpen = ref(false)
|
||||||
|
|
||||||
|
// 通用搜索(输入防抖)
|
||||||
|
const localSearch = ref(props.filterSearch)
|
||||||
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
watch(() => props.filterSearch, (value) => {
|
||||||
|
if (value !== localSearch.value) {
|
||||||
|
localSearch.value = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(localSearch, (value) => {
|
||||||
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = setTimeout(() => {
|
||||||
|
emit('update:filterSearch', value)
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
// 动态计时器相关
|
// 动态计时器相关
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||||
@@ -574,6 +638,10 @@ function handleRowClick(event: MouseEvent, id: string) {
|
|||||||
// 组件卸载时清理
|
// 组件卸载时清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 格式化 API 格式显示名称
|
// 格式化 API 格式显示名称
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface PaginationParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterParams {
|
export interface FilterParams {
|
||||||
|
search?: string
|
||||||
user_id?: string
|
user_id?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -234,11 +235,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
pagination: PaginationParams,
|
pagination: PaginationParams,
|
||||||
filters?: FilterParams
|
filters?: FilterParams
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!isAdminPage.value) {
|
|
||||||
// 用户页面不需要分页加载,记录已在 loadStats 中获取
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingRecords.value = true
|
isLoadingRecords.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -252,24 +248,34 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加筛选条件
|
// 添加筛选条件
|
||||||
if (filters?.user_id) {
|
if (filters?.search?.trim()) {
|
||||||
params.user_id = filters.user_id
|
params.search = filters.search.trim()
|
||||||
}
|
|
||||||
if (filters?.model) {
|
|
||||||
params.model = filters.model
|
|
||||||
}
|
|
||||||
if (filters?.provider) {
|
|
||||||
params.provider = filters.provider
|
|
||||||
}
|
|
||||||
if (filters?.status) {
|
|
||||||
params.status = filters.status
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await usageApi.getAllUsageRecords(params)
|
if (isAdminPage.value) {
|
||||||
|
// 管理员页面:使用管理员 API
|
||||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
if (filters?.user_id) {
|
||||||
totalRecords.value = response.total || 0
|
params.user_id = filters.user_id
|
||||||
|
}
|
||||||
|
if (filters?.model) {
|
||||||
|
params.model = filters.model
|
||||||
|
}
|
||||||
|
if (filters?.provider) {
|
||||||
|
params.provider = filters.provider
|
||||||
|
}
|
||||||
|
if (filters?.status) {
|
||||||
|
params.status = filters.status
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await usageApi.getAllUsageRecords(params)
|
||||||
|
currentRecords.value = (response.records || []) as UsageRecord[]
|
||||||
|
totalRecords.value = response.total || 0
|
||||||
|
} else {
|
||||||
|
// 用户页面:使用用户 API
|
||||||
|
const userData = await meApi.getUsage(params)
|
||||||
|
currentRecords.value = (userData.records || []) as UsageRecord[]
|
||||||
|
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('加载记录失败:', error)
|
log.error('加载记录失败:', error)
|
||||||
currentRecords.value = []
|
currentRecords.value = []
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export interface UsageRecord {
|
|||||||
user_id?: string
|
user_id?: string
|
||||||
username?: string
|
username?: string
|
||||||
user_email?: string
|
user_email?: string
|
||||||
|
api_key?: {
|
||||||
|
id: string | null
|
||||||
|
name: string | null
|
||||||
|
display: string | null
|
||||||
|
} | null
|
||||||
provider: string
|
provider: string
|
||||||
api_key_name?: string
|
api_key_name?: string
|
||||||
rate_multiplier?: number
|
rate_multiplier?: number
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ const navigation = computed(() => {
|
|||||||
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
|
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
|
||||||
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
|
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
|
||||||
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
||||||
|
{ name: 'LDAP 配置', href: '/admin/ldap', icon: Shield },
|
||||||
{ name: '系统设置', href: '/admin/system', icon: Cog },
|
{ name: '系统设置', href: '/admin/system', icon: Cog },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,6 +367,11 @@ function generateMockUsageRecords(count: number = 100) {
|
|||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
user_email: user.email,
|
user_email: user.email,
|
||||||
|
api_key: {
|
||||||
|
id: `key-${user.id}-${Math.ceil(Math.random() * 2)}`,
|
||||||
|
name: `${user.username} Key ${Math.ceil(Math.random() * 3)}`,
|
||||||
|
display: `sk-ae...${String(1000 + Math.floor(Math.random() * 9000))}`
|
||||||
|
},
|
||||||
provider: model.provider,
|
provider: model.provider,
|
||||||
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
|
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
|
||||||
rate_multiplier: 1.0,
|
rate_multiplier: 1.0,
|
||||||
@@ -835,10 +840,26 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
|||||||
'GET /api/admin/usage/records': async (config) => {
|
'GET /api/admin/usage/records': async (config) => {
|
||||||
await delay()
|
await delay()
|
||||||
requireAdmin()
|
requireAdmin()
|
||||||
const records = getUsageRecords()
|
let records = getUsageRecords()
|
||||||
const params = config.params || {}
|
const params = config.params || {}
|
||||||
const limit = parseInt(params.limit) || 20
|
const limit = parseInt(params.limit) || 20
|
||||||
const offset = parseInt(params.offset) || 0
|
const offset = parseInt(params.offset) || 0
|
||||||
|
|
||||||
|
// 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
|
// 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||||
|
if (typeof params.search === 'string' && params.search.trim()) {
|
||||||
|
const keywords = params.search.trim().toLowerCase().split(/\s+/)
|
||||||
|
records = records.filter(r => {
|
||||||
|
// 每个关键词都要匹配至少一个字段
|
||||||
|
return keywords.every((keyword: string) =>
|
||||||
|
(r.username || '').toLowerCase().includes(keyword) ||
|
||||||
|
(r.api_key?.name || '').toLowerCase().includes(keyword) ||
|
||||||
|
(r.model || '').toLowerCase().includes(keyword) ||
|
||||||
|
(r.provider || '').toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return createMockResponse({
|
return createMockResponse({
|
||||||
records: records.slice(offset, offset + limit),
|
records: records.slice(offset, offset + limit),
|
||||||
total: records.length,
|
total: records.length,
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'EmailSettings',
|
name: 'EmailSettings',
|
||||||
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
|
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ldap',
|
||||||
|
name: 'LdapSettings',
|
||||||
|
component: () => importWithRetry(() => import('@/views/admin/LdapSettings.vue'))
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'audit-logs',
|
path: 'audit-logs',
|
||||||
name: 'AuditLogs',
|
name: 'AuditLogs',
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||||
|
|
||||||
async function login(email: string, password: string) {
|
async function login(email: string, password: string, authType: 'local' | 'ldap' = 'local') {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authApi.login({ email, password })
|
const response = await authApi.login({ email, password, auth_type: authType })
|
||||||
token.value = response.access_token
|
token.value = response.access_token
|
||||||
|
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
|
|||||||
@@ -106,23 +106,23 @@
|
|||||||
type="text"
|
type="text"
|
||||||
:placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'"
|
:placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'"
|
||||||
class="-webkit-text-security-disc"
|
class="-webkit-text-security-disc"
|
||||||
:class="smtpPasswordIsSet ? 'pr-8' : ''"
|
:class="(smtpPasswordIsSet || emailConfig.smtp_password) ? 'pr-10' : ''"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
data-lpignore="true"
|
data-lpignore="true"
|
||||||
data-1p-ignore="true"
|
data-1p-ignore="true"
|
||||||
data-form-type="other"
|
data-form-type="other"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="smtpPasswordIsSet"
|
v-if="smtpPasswordIsSet || emailConfig.smtp_password"
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||||
title="清除已保存的密码"
|
title="清除密码"
|
||||||
@click="handleClearSmtpPassword"
|
@click="handleClearSmtpPassword"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="16"
|
width="14"
|
||||||
height="16"
|
height="14"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -498,6 +498,7 @@ const smtpEncryptionSelectOpen = ref(false)
|
|||||||
const emailSuffixModeSelectOpen = ref(false)
|
const emailSuffixModeSelectOpen = ref(false)
|
||||||
const testSmtpLoading = ref(false)
|
const testSmtpLoading = ref(false)
|
||||||
const smtpPasswordIsSet = ref(false)
|
const smtpPasswordIsSet = ref(false)
|
||||||
|
const clearSmtpPassword = ref(false) // 标记是否要清除密码
|
||||||
|
|
||||||
// 邮件模板相关状态
|
// 邮件模板相关状态
|
||||||
const templateLoading = ref(false)
|
const templateLoading = ref(false)
|
||||||
@@ -710,6 +711,7 @@ async function loadEmailConfig() {
|
|||||||
// 配置不存在时使用默认值,无需处理
|
// 配置不存在时使用默认值,无需处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
clearSmtpPassword.value = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error('加载邮件配置失败')
|
error('加载邮件配置失败')
|
||||||
log.error('加载邮件配置失败:', err)
|
log.error('加载邮件配置失败:', err)
|
||||||
@@ -720,6 +722,12 @@ async function loadEmailConfig() {
|
|||||||
async function saveSmtpConfig() {
|
async function saveSmtpConfig() {
|
||||||
smtpSaveLoading.value = true
|
smtpSaveLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
const passwordAction: 'unchanged' | 'updated' | 'cleared' = emailConfig.value.smtp_password
|
||||||
|
? 'updated'
|
||||||
|
: clearSmtpPassword.value
|
||||||
|
? 'cleared'
|
||||||
|
: 'unchanged'
|
||||||
|
|
||||||
const configItems = [
|
const configItems = [
|
||||||
{
|
{
|
||||||
key: 'smtp_host',
|
key: 'smtp_host',
|
||||||
@@ -737,7 +745,7 @@ async function saveSmtpConfig() {
|
|||||||
description: 'SMTP 用户名'
|
description: 'SMTP 用户名'
|
||||||
},
|
},
|
||||||
// 只有输入了新密码才提交(空值表示保持原密码)
|
// 只有输入了新密码才提交(空值表示保持原密码)
|
||||||
...(emailConfig.value.smtp_password
|
...(passwordAction === 'updated'
|
||||||
? [{
|
? [{
|
||||||
key: 'smtp_password',
|
key: 'smtp_password',
|
||||||
value: emailConfig.value.smtp_password,
|
value: emailConfig.value.smtp_password,
|
||||||
@@ -770,8 +778,23 @@ async function saveSmtpConfig() {
|
|||||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 如果标记了清除密码,删除密码配置
|
||||||
|
if (passwordAction === 'cleared') {
|
||||||
|
promises.push(adminApi.deleteSystemConfig('smtp_password'))
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(promises)
|
await Promise.all(promises)
|
||||||
success('SMTP 配置已保存')
|
success('SMTP 配置已保存')
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
if (passwordAction === 'cleared') {
|
||||||
|
clearSmtpPassword.value = false
|
||||||
|
smtpPasswordIsSet.value = false
|
||||||
|
} else if (passwordAction === 'updated') {
|
||||||
|
clearSmtpPassword.value = false
|
||||||
|
smtpPasswordIsSet.value = true
|
||||||
|
}
|
||||||
|
emailConfig.value.smtp_password = null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error('保存配置失败')
|
error('保存配置失败')
|
||||||
log.error('保存 SMTP 配置失败:', err)
|
log.error('保存 SMTP 配置失败:', err)
|
||||||
@@ -812,15 +835,16 @@ async function saveEmailSuffixConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 清除 SMTP 密码
|
// 清除 SMTP 密码
|
||||||
async function handleClearSmtpPassword() {
|
function handleClearSmtpPassword() {
|
||||||
try {
|
// 如果有输入内容,先清空输入框
|
||||||
await adminApi.deleteSystemConfig('smtp_password')
|
if (emailConfig.value.smtp_password) {
|
||||||
smtpPasswordIsSet.value = false
|
|
||||||
emailConfig.value.smtp_password = null
|
emailConfig.value.smtp_password = null
|
||||||
success('SMTP 密码已清除')
|
return
|
||||||
} catch (err) {
|
}
|
||||||
error('清除密码失败')
|
// 标记要清除服务端密码(保存时生效)
|
||||||
log.error('清除 SMTP 密码失败:', err)
|
if (smtpPasswordIsSet.value) {
|
||||||
|
clearSmtpPassword.value = true
|
||||||
|
smtpPasswordIsSet.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
379
frontend/src/views/admin/LdapSettings.vue
Normal file
379
frontend/src/views/admin/LdapSettings.vue
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
<template>
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="LDAP 配置"
|
||||||
|
description="配置 LDAP 认证服务"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-6">
|
||||||
|
<CardSection
|
||||||
|
title="LDAP 服务器配置"
|
||||||
|
description="配置 LDAP 服务器连接参数"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="testLoading"
|
||||||
|
@click="handleTestConnection"
|
||||||
|
>
|
||||||
|
{{ testLoading ? '测试中...' : '测试连接' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="saveLoading"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
{{ saveLoading ? '保存中...' : '保存' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<Label for="server-url" class="block text-sm font-medium">
|
||||||
|
服务器地址
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="server-url"
|
||||||
|
v-model="ldapConfig.server_url"
|
||||||
|
type="text"
|
||||||
|
placeholder="ldap://ldap.example.com:389"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
格式: ldap://host:389 或 ldaps://host:636
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="bind-dn" class="block text-sm font-medium">
|
||||||
|
绑定 DN
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="bind-dn"
|
||||||
|
v-model="ldapConfig.bind_dn"
|
||||||
|
type="text"
|
||||||
|
placeholder="cn=admin,dc=example,dc=com"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
用于连接 LDAP 服务器的管理员 DN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="bind-password" class="block text-sm font-medium">
|
||||||
|
绑定密码
|
||||||
|
</Label>
|
||||||
|
<div class="relative mt-1">
|
||||||
|
<Input
|
||||||
|
id="bind-password"
|
||||||
|
v-model="ldapConfig.bind_password"
|
||||||
|
type="password"
|
||||||
|
:placeholder="hasPassword ? '已设置(留空保持不变)' : '请输入密码'"
|
||||||
|
:class="(hasPassword || ldapConfig.bind_password) ? 'pr-10' : ''"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="hasPassword || ldapConfig.bind_password"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
@click="handleClearPassword"
|
||||||
|
title="清除密码"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
绑定账号的密码
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="base-dn" class="block text-sm font-medium">
|
||||||
|
基础 DN
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="base-dn"
|
||||||
|
v-model="ldapConfig.base_dn"
|
||||||
|
type="text"
|
||||||
|
placeholder="ou=users,dc=example,dc=com"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
用户搜索的基础 DN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="user-search-filter" class="block text-sm font-medium">
|
||||||
|
用户搜索过滤器
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="user-search-filter"
|
||||||
|
v-model="ldapConfig.user_search_filter"
|
||||||
|
type="text"
|
||||||
|
placeholder="(uid={username})"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{username} 会被替换为登录用户名
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="username-attr" class="block text-sm font-medium">
|
||||||
|
用户名属性
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="username-attr"
|
||||||
|
v-model="ldapConfig.username_attr"
|
||||||
|
type="text"
|
||||||
|
placeholder="uid"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
常用: uid (OpenLDAP), sAMAccountName (AD)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="email-attr" class="block text-sm font-medium">
|
||||||
|
邮箱属性
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email-attr"
|
||||||
|
v-model="ldapConfig.email_attr"
|
||||||
|
type="text"
|
||||||
|
placeholder="mail"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="display-name-attr" class="block text-sm font-medium">
|
||||||
|
显示名称属性
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="display-name-attr"
|
||||||
|
v-model="ldapConfig.display_name_attr"
|
||||||
|
type="text"
|
||||||
|
placeholder="cn"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="connect-timeout" class="block text-sm font-medium">
|
||||||
|
连接超时 (秒)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="connect-timeout"
|
||||||
|
v-model.number="ldapConfig.connect_timeout"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
placeholder="10"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
单次 LDAP 操作超时时间 (1-60秒),跨国网络建议 15-30 秒
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label class="text-sm font-medium">使用 STARTTLS</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
在非 SSL 连接上启用 TLS 加密
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="ldapConfig.use_starttls" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label class="text-sm font-medium">启用 LDAP 认证</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
允许用户使用 LDAP 账号登录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="ldapConfig.is_enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label class="text-sm font-medium">仅允许 LDAP 登录</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
禁用本地账号登录,仅允许 LDAP 认证
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="ldapConfig.is_exclusive" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardSection>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
|
||||||
|
import { Button, Input, Label, Switch } from '@/components/ui'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { adminApi, type LdapConfigUpdateRequest } from '@/api/admin'
|
||||||
|
|
||||||
|
const { success, error } = useToast()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saveLoading = ref(false)
|
||||||
|
const testLoading = ref(false)
|
||||||
|
const hasPassword = ref(false)
|
||||||
|
const clearPassword = ref(false) // 标记是否要清除密码
|
||||||
|
|
||||||
|
const ldapConfig = ref({
|
||||||
|
server_url: '',
|
||||||
|
bind_dn: '',
|
||||||
|
bind_password: '',
|
||||||
|
base_dn: '',
|
||||||
|
user_search_filter: '(uid={username})',
|
||||||
|
username_attr: 'uid',
|
||||||
|
email_attr: 'mail',
|
||||||
|
display_name_attr: 'cn',
|
||||||
|
is_enabled: false,
|
||||||
|
is_exclusive: false,
|
||||||
|
use_starttls: false,
|
||||||
|
connect_timeout: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await adminApi.getLdapConfig()
|
||||||
|
ldapConfig.value = {
|
||||||
|
server_url: response.server_url || '',
|
||||||
|
bind_dn: response.bind_dn || '',
|
||||||
|
bind_password: '',
|
||||||
|
base_dn: response.base_dn || '',
|
||||||
|
user_search_filter: response.user_search_filter || '(uid={username})',
|
||||||
|
username_attr: response.username_attr || 'uid',
|
||||||
|
email_attr: response.email_attr || 'mail',
|
||||||
|
display_name_attr: response.display_name_attr || 'cn',
|
||||||
|
is_enabled: response.is_enabled || false,
|
||||||
|
is_exclusive: response.is_exclusive || false,
|
||||||
|
use_starttls: response.use_starttls || false,
|
||||||
|
connect_timeout: response.connect_timeout || 10,
|
||||||
|
}
|
||||||
|
hasPassword.value = !!response.has_bind_password
|
||||||
|
clearPassword.value = false
|
||||||
|
} catch (err) {
|
||||||
|
error('加载 LDAP 配置失败')
|
||||||
|
console.error('加载 LDAP 配置失败:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
saveLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: LdapConfigUpdateRequest = {
|
||||||
|
server_url: ldapConfig.value.server_url,
|
||||||
|
bind_dn: ldapConfig.value.bind_dn,
|
||||||
|
base_dn: ldapConfig.value.base_dn,
|
||||||
|
user_search_filter: ldapConfig.value.user_search_filter,
|
||||||
|
username_attr: ldapConfig.value.username_attr,
|
||||||
|
email_attr: ldapConfig.value.email_attr,
|
||||||
|
display_name_attr: ldapConfig.value.display_name_attr,
|
||||||
|
is_enabled: ldapConfig.value.is_enabled,
|
||||||
|
is_exclusive: ldapConfig.value.is_exclusive,
|
||||||
|
use_starttls: ldapConfig.value.use_starttls,
|
||||||
|
connect_timeout: ldapConfig.value.connect_timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先使用输入的新密码;否则如果标记清除则发送空字符串
|
||||||
|
let passwordAction: 'unchanged' | 'updated' | 'cleared' = 'unchanged'
|
||||||
|
if (ldapConfig.value.bind_password) {
|
||||||
|
payload.bind_password = ldapConfig.value.bind_password
|
||||||
|
passwordAction = 'updated'
|
||||||
|
} else if (clearPassword.value) {
|
||||||
|
payload.bind_password = ''
|
||||||
|
passwordAction = 'cleared'
|
||||||
|
}
|
||||||
|
|
||||||
|
await adminApi.updateLdapConfig(payload)
|
||||||
|
success('LDAP 配置保存成功')
|
||||||
|
|
||||||
|
if (passwordAction === 'cleared') {
|
||||||
|
hasPassword.value = false
|
||||||
|
clearPassword.value = false
|
||||||
|
} else if (passwordAction === 'updated') {
|
||||||
|
hasPassword.value = true
|
||||||
|
clearPassword.value = false
|
||||||
|
}
|
||||||
|
ldapConfig.value.bind_password = ''
|
||||||
|
} catch (err) {
|
||||||
|
error('保存 LDAP 配置失败')
|
||||||
|
console.error('保存 LDAP 配置失败:', err)
|
||||||
|
} finally {
|
||||||
|
saveLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTestConnection() {
|
||||||
|
if (clearPassword.value && !ldapConfig.value.bind_password) {
|
||||||
|
error('已标记清除绑定密码,请先保存或输入新的绑定密码再测试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
testLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: LdapConfigUpdateRequest = {
|
||||||
|
server_url: ldapConfig.value.server_url,
|
||||||
|
bind_dn: ldapConfig.value.bind_dn,
|
||||||
|
base_dn: ldapConfig.value.base_dn,
|
||||||
|
user_search_filter: ldapConfig.value.user_search_filter,
|
||||||
|
username_attr: ldapConfig.value.username_attr,
|
||||||
|
email_attr: ldapConfig.value.email_attr,
|
||||||
|
display_name_attr: ldapConfig.value.display_name_attr,
|
||||||
|
is_enabled: ldapConfig.value.is_enabled,
|
||||||
|
is_exclusive: ldapConfig.value.is_exclusive,
|
||||||
|
use_starttls: ldapConfig.value.use_starttls,
|
||||||
|
connect_timeout: ldapConfig.value.connect_timeout,
|
||||||
|
...(ldapConfig.value.bind_password && { bind_password: ldapConfig.value.bind_password }),
|
||||||
|
}
|
||||||
|
const response = await adminApi.testLdapConnection(payload)
|
||||||
|
if (response.success) {
|
||||||
|
success('LDAP 连接测试成功')
|
||||||
|
} else {
|
||||||
|
error(`LDAP 连接测试失败: ${response.message}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error('LDAP 连接测试失败')
|
||||||
|
console.error('LDAP 连接测试失败:', err)
|
||||||
|
} finally {
|
||||||
|
testLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearPassword() {
|
||||||
|
// 如果有输入内容,先清空输入框
|
||||||
|
if (ldapConfig.value.bind_password) {
|
||||||
|
ldapConfig.value.bind_password = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 标记要清除服务端密码(保存时生效)
|
||||||
|
if (hasPassword.value) {
|
||||||
|
clearPassword.value = true
|
||||||
|
hasPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -464,6 +464,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
|
<!-- 系统版本信息 -->
|
||||||
|
<CardSection
|
||||||
|
title="系统信息"
|
||||||
|
description="当前系统版本和构建信息"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label class="text-sm font-medium text-muted-foreground">版本:</Label>
|
||||||
|
<span
|
||||||
|
v-if="systemVersion"
|
||||||
|
class="text-sm font-mono"
|
||||||
|
>
|
||||||
|
{{ systemVersion }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
加载中...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 导入配置对话框 -->
|
<!-- 导入配置对话框 -->
|
||||||
@@ -475,7 +499,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div
|
||||||
v-if="importPreview"
|
v-if="importPreview"
|
||||||
class="p-3 bg-muted rounded-lg text-sm"
|
class="text-sm"
|
||||||
>
|
>
|
||||||
<p class="font-medium mb-2">
|
<p class="font-medium mb-2">
|
||||||
配置预览
|
配置预览
|
||||||
@@ -557,7 +581,7 @@
|
|||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
全局模型
|
全局模型
|
||||||
</p>
|
</p>
|
||||||
@@ -567,7 +591,7 @@
|
|||||||
跳过: {{ importResult.stats.global_models.skipped }}
|
跳过: {{ importResult.stats.global_models.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
提供商
|
提供商
|
||||||
</p>
|
</p>
|
||||||
@@ -577,7 +601,7 @@
|
|||||||
跳过: {{ importResult.stats.providers.skipped }}
|
跳过: {{ importResult.stats.providers.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
端点
|
端点
|
||||||
</p>
|
</p>
|
||||||
@@ -587,7 +611,7 @@
|
|||||||
跳过: {{ importResult.stats.endpoints.skipped }}
|
跳过: {{ importResult.stats.endpoints.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
API Keys
|
API Keys
|
||||||
</p>
|
</p>
|
||||||
@@ -596,7 +620,7 @@
|
|||||||
跳过: {{ importResult.stats.keys.skipped }}
|
跳过: {{ importResult.stats.keys.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 bg-muted rounded-lg col-span-2">
|
<div class="col-span-2">
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
模型配置
|
模型配置
|
||||||
</p>
|
</p>
|
||||||
@@ -642,7 +666,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div
|
||||||
v-if="importUsersPreview"
|
v-if="importUsersPreview"
|
||||||
class="p-3 bg-muted rounded-lg text-sm"
|
class="text-sm"
|
||||||
>
|
>
|
||||||
<p class="font-medium mb-2">
|
<p class="font-medium mb-2">
|
||||||
数据预览
|
数据预览
|
||||||
@@ -652,6 +676,9 @@
|
|||||||
<li>
|
<li>
|
||||||
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} 个
|
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }} 个
|
||||||
</li>
|
</li>
|
||||||
|
<li v-if="importUsersPreview.standalone_keys?.length">
|
||||||
|
独立余额 Keys: {{ importUsersPreview.standalone_keys.length }} 个
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -720,7 +747,7 @@
|
|||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
用户
|
用户
|
||||||
</p>
|
</p>
|
||||||
@@ -730,7 +757,7 @@
|
|||||||
跳过: {{ importUsersResult.stats.users.skipped }}
|
跳过: {{ importUsersResult.stats.users.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 bg-muted rounded-lg">
|
<div>
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
API Keys
|
API Keys
|
||||||
</p>
|
</p>
|
||||||
@@ -739,6 +766,18 @@
|
|||||||
跳过: {{ importUsersResult.stats.api_keys.skipped }}
|
跳过: {{ importUsersResult.stats.api_keys.skipped }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="importUsersResult.stats.standalone_keys"
|
||||||
|
class="col-span-2"
|
||||||
|
>
|
||||||
|
<p class="font-medium">
|
||||||
|
独立余额 Keys
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
创建: {{ importUsersResult.stats.standalone_keys.created }},
|
||||||
|
跳过: {{ importUsersResult.stats.standalone_keys.skipped }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -839,6 +878,9 @@ const importUsersResult = ref<UsersImportResponse | null>(null)
|
|||||||
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||||
const usersMergeModeSelectOpen = ref(false)
|
const usersMergeModeSelectOpen = ref(false)
|
||||||
|
|
||||||
|
// 系统版本信息
|
||||||
|
const systemVersion = ref<string>('')
|
||||||
|
|
||||||
const systemConfig = ref<SystemConfig>({
|
const systemConfig = ref<SystemConfig>({
|
||||||
// 基础配置
|
// 基础配置
|
||||||
default_user_quota_usd: 10.0,
|
default_user_quota_usd: 10.0,
|
||||||
@@ -890,9 +932,21 @@ const sensitiveHeadersStr = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSystemConfig()
|
await Promise.all([
|
||||||
|
loadSystemConfig(),
|
||||||
|
loadSystemVersion()
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadSystemVersion() {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getSystemVersion()
|
||||||
|
systemVersion.value = data.version
|
||||||
|
} catch (err) {
|
||||||
|
log.error('加载系统版本失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSystemConfig() {
|
async function loadSystemConfig() {
|
||||||
try {
|
try {
|
||||||
const configs = [
|
const configs = [
|
||||||
@@ -1178,12 +1232,6 @@ function handleUsersFileSelect(event: Event) {
|
|||||||
const content = e.target?.result as string
|
const content = e.target?.result as string
|
||||||
const data = JSON.parse(content) as UsersExportData
|
const data = JSON.parse(content) as UsersExportData
|
||||||
|
|
||||||
// 验证版本
|
|
||||||
if (data.version !== '1.0') {
|
|
||||||
error(`不支持的配置版本: ${data.version}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
importUsersPreview.value = data
|
importUsersPreview.value = data
|
||||||
usersMergeMode.value = 'skip'
|
usersMergeMode.value = 'skip'
|
||||||
importUsersDialogOpen.value = true
|
importUsersDialogOpen.value = true
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
:show-actual-cost="authStore.isAdmin"
|
:show-actual-cost="authStore.isAdmin"
|
||||||
:loading="isLoadingRecords"
|
:loading="isLoadingRecords"
|
||||||
:selected-period="selectedPeriod"
|
:selected-period="selectedPeriod"
|
||||||
|
:filter-search="filterSearch"
|
||||||
:filter-user="filterUser"
|
:filter-user="filterUser"
|
||||||
:filter-model="filterModel"
|
:filter-model="filterModel"
|
||||||
:filter-provider="filterProvider"
|
:filter-provider="filterProvider"
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
:auto-refresh="globalAutoRefresh"
|
:auto-refresh="globalAutoRefresh"
|
||||||
@update:selected-period="handlePeriodChange"
|
@update:selected-period="handlePeriodChange"
|
||||||
|
@update:filter-search="handleFilterSearchChange"
|
||||||
@update:filter-user="handleFilterUserChange"
|
@update:filter-user="handleFilterUserChange"
|
||||||
@update:filter-model="handleFilterModelChange"
|
@update:filter-model="handleFilterModelChange"
|
||||||
@update:filter-provider="handleFilterProviderChange"
|
@update:filter-provider="handleFilterProviderChange"
|
||||||
@@ -133,6 +135,7 @@ const pageSize = ref(20)
|
|||||||
const pageSizeOptions = [10, 20, 50, 100]
|
const pageSizeOptions = [10, 20, 50, 100]
|
||||||
|
|
||||||
// 筛选状态
|
// 筛选状态
|
||||||
|
const filterSearch = ref('')
|
||||||
const filterUser = ref('__all__')
|
const filterUser = ref('__all__')
|
||||||
const filterModel = ref('__all__')
|
const filterModel = ref('__all__')
|
||||||
const filterProvider = ref('__all__')
|
const filterProvider = ref('__all__')
|
||||||
@@ -392,14 +395,17 @@ onMounted(async () => {
|
|||||||
// 热力图加载失败不提示,因为 UI 已显示占位符
|
// 热力图加载失败不提示,因为 UI 已显示占位符
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理员页面加载用户列表和第一页记录
|
// 加载记录和用户列表
|
||||||
if (isAdminPage.value) {
|
if (isAdminPage.value) {
|
||||||
// 并行加载用户列表和记录
|
// 管理员页面:并行加载用户列表和记录
|
||||||
const [users] = await Promise.all([
|
const [users] = await Promise.all([
|
||||||
usersApi.getAllUsers(),
|
usersApi.getAllUsers(),
|
||||||
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
])
|
])
|
||||||
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
||||||
|
} else {
|
||||||
|
// 用户页面:加载记录
|
||||||
|
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -410,34 +416,26 @@ async function handlePeriodChange(value: string) {
|
|||||||
|
|
||||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||||
await loadStats(dateRange)
|
await loadStats(dateRange)
|
||||||
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理分页变化
|
// 处理分页变化
|
||||||
async function handlePageChange(page: number) {
|
async function handlePageChange(page: number) {
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
|
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理每页大小变化
|
// 处理每页大小变化
|
||||||
async function handlePageSizeChange(size: number) {
|
async function handlePageSizeChange(size: number) {
|
||||||
pageSize.value = size
|
pageSize.value = size
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前筛选参数
|
// 获取当前筛选参数
|
||||||
function getCurrentFilters() {
|
function getCurrentFilters() {
|
||||||
return {
|
return {
|
||||||
|
search: filterSearch.value.trim() || undefined,
|
||||||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
||||||
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||||
@@ -446,6 +444,13 @@ function getCurrentFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理筛选变化
|
// 处理筛选变化
|
||||||
|
async function handleFilterSearchChange(value: string) {
|
||||||
|
filterSearch.value = value
|
||||||
|
currentPage.value = 1
|
||||||
|
|
||||||
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFilterUserChange(value: string) {
|
async function handleFilterUserChange(value: string) {
|
||||||
filterUser.value = value
|
filterUser.value = value
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
@@ -486,10 +491,7 @@ async function handleFilterStatusChange(value: string) {
|
|||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||||
await loadStats(dateRange)
|
await loadStats(dateRange)
|
||||||
|
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
if (isAdminPage.value) {
|
|
||||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示请求详情
|
// 显示请求详情
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ dependencies = [
|
|||||||
"redis>=5.0.0",
|
"redis>=5.0.0",
|
||||||
"prometheus-client>=0.20.0",
|
"prometheus-client>=0.20.0",
|
||||||
"apscheduler>=3.10.0",
|
"apscheduler>=3.10.0",
|
||||||
|
"ldap3>=2.9.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|||||||
commit_id: COMMIT_ID
|
commit_id: COMMIT_ID
|
||||||
__commit_id__: COMMIT_ID
|
__commit_id__: COMMIT_ID
|
||||||
|
|
||||||
__version__ = version = '0.1.1.dev0+g393d4d13f.d20251213'
|
__version__ = version = '0.2.3.dev0+g0f78d5cbf.d20260105'
|
||||||
__version_tuple__ = version_tuple = (0, 1, 1, 'dev0', 'g393d4d13f.d20251213')
|
__version_tuple__ = version_tuple = (0, 2, 3, 'dev0', 'g0f78d5cbf.d20260105')
|
||||||
|
|
||||||
__commit_id__ = commit_id = None
|
__commit_id__ = commit_id = None
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter
|
|||||||
from .adaptive import router as adaptive_router
|
from .adaptive import router as adaptive_router
|
||||||
from .api_keys import router as api_keys_router
|
from .api_keys import router as api_keys_router
|
||||||
from .endpoints import router as endpoints_router
|
from .endpoints import router as endpoints_router
|
||||||
|
from .ldap import router as ldap_router
|
||||||
from .models import router as models_router
|
from .models import router as models_router
|
||||||
from .monitoring import router as monitoring_router
|
from .monitoring import router as monitoring_router
|
||||||
from .provider_query import router as provider_query_router
|
from .provider_query import router as provider_query_router
|
||||||
@@ -28,5 +29,6 @@ router.include_router(adaptive_router)
|
|||||||
router.include_router(models_router)
|
router.include_router(models_router)
|
||||||
router.include_router(security_router)
|
router.include_router(security_router)
|
||||||
router.include_router(provider_query_router)
|
router.include_router(provider_query_router)
|
||||||
|
router.include_router(ldap_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
427
src/api/admin/ldap.py
Normal file
427
src/api/admin/ldap.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""LDAP配置管理API端点。"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
from src.core.enums import AuthSource
|
||||||
|
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import AuditEventType, LDAPConfig, User, UserRole
|
||||||
|
from src.services.system.audit import AuditService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/ldap", tags=["Admin - LDAP"])
|
||||||
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
# bcrypt 哈希格式正则:$2a$, $2b$, $2y$ + 2位cost + $ + 53字符(22位salt + 31位hash)
|
||||||
|
BCRYPT_HASH_PATTERN = re.compile(r"^\$2[aby]\$\d{2}\$.{53}$")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Request/Response Models ==========
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfigResponse(BaseModel):
|
||||||
|
"""LDAP配置响应(不返回密码)"""
|
||||||
|
|
||||||
|
server_url: Optional[str] = None
|
||||||
|
bind_dn: Optional[str] = None
|
||||||
|
base_dn: Optional[str] = None
|
||||||
|
has_bind_password: bool = False
|
||||||
|
user_search_filter: str
|
||||||
|
username_attr: str
|
||||||
|
email_attr: str
|
||||||
|
display_name_attr: str
|
||||||
|
is_enabled: bool
|
||||||
|
is_exclusive: bool
|
||||||
|
use_starttls: bool
|
||||||
|
connect_timeout: int
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfigUpdate(BaseModel):
|
||||||
|
"""LDAP配置更新请求"""
|
||||||
|
|
||||||
|
server_url: str = Field(..., min_length=1, max_length=255)
|
||||||
|
bind_dn: str = Field(..., min_length=1, max_length=255)
|
||||||
|
# 允许空字符串表示"清除密码";非空时自动 strip 并校验不能为空
|
||||||
|
bind_password: Optional[str] = Field(None, max_length=1024)
|
||||||
|
base_dn: str = Field(..., min_length=1, max_length=255)
|
||||||
|
user_search_filter: str = Field(default="(uid={username})", max_length=500)
|
||||||
|
username_attr: str = Field(default="uid", max_length=50)
|
||||||
|
email_attr: str = Field(default="mail", max_length=50)
|
||||||
|
display_name_attr: str = Field(default="cn", max_length=50)
|
||||||
|
is_enabled: bool = False
|
||||||
|
is_exclusive: bool = False
|
||||||
|
use_starttls: bool = False
|
||||||
|
connect_timeout: int = Field(default=10, ge=1, le=60) # 单次操作超时,跨国网络建议 15-30 秒
|
||||||
|
|
||||||
|
@field_validator("bind_password")
|
||||||
|
@classmethod
|
||||||
|
def validate_bind_password(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v is None or v == "":
|
||||||
|
return v
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("绑定密码不能为空")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("user_search_filter")
|
||||||
|
@classmethod
|
||||||
|
def validate_search_filter(cls, v: str) -> str:
|
||||||
|
if "{username}" not in v:
|
||||||
|
raise ValueError("搜索过滤器必须包含 {username} 占位符")
|
||||||
|
# 验证括号匹配和嵌套正确性
|
||||||
|
depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth < 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
if depth != 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
# 限制过滤器复杂度,防止构造复杂查询
|
||||||
|
# 检查嵌套层数而非括号总数
|
||||||
|
depth = 0
|
||||||
|
max_depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
max_depth = max(max_depth, depth)
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if max_depth > 5:
|
||||||
|
raise ValueError("搜索过滤器嵌套层数过深(最多5层)")
|
||||||
|
if len(v) > 200:
|
||||||
|
raise ValueError("搜索过滤器过长(最多200字符)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPTestResponse(BaseModel):
|
||||||
|
"""LDAP连接测试响应"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfigTest(BaseModel):
|
||||||
|
"""LDAP配置测试请求(全部可选,用于临时覆盖)"""
|
||||||
|
|
||||||
|
server_url: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
bind_dn: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
bind_password: Optional[str] = Field(None, min_length=1)
|
||||||
|
base_dn: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
user_search_filter: Optional[str] = Field(None, max_length=500)
|
||||||
|
username_attr: Optional[str] = Field(None, max_length=50)
|
||||||
|
email_attr: Optional[str] = Field(None, max_length=50)
|
||||||
|
display_name_attr: Optional[str] = Field(None, max_length=50)
|
||||||
|
is_enabled: Optional[bool] = None
|
||||||
|
is_exclusive: Optional[bool] = None
|
||||||
|
use_starttls: Optional[bool] = None
|
||||||
|
connect_timeout: Optional[int] = Field(None, ge=1, le=60)
|
||||||
|
|
||||||
|
@field_validator("user_search_filter")
|
||||||
|
@classmethod
|
||||||
|
def validate_search_filter(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if "{username}" not in v:
|
||||||
|
raise ValueError("搜索过滤器必须包含 {username} 占位符")
|
||||||
|
# 验证括号匹配和嵌套正确性
|
||||||
|
depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth < 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
if depth != 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
# 限制过滤器复杂度(检查嵌套层数而非括号总数)
|
||||||
|
depth = 0
|
||||||
|
max_depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
max_depth = max(max_depth, depth)
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if max_depth > 5:
|
||||||
|
raise ValueError("搜索过滤器嵌套层数过深(最多5层)")
|
||||||
|
if len(v) > 200:
|
||||||
|
raise ValueError("搜索过滤器过长(最多200字符)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ========== API Endpoints ==========
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""获取LDAP配置(管理员)"""
|
||||||
|
adapter = AdminGetLDAPConfigAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config")
|
||||||
|
async def update_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""更新LDAP配置(管理员)"""
|
||||||
|
adapter = AdminUpdateLDAPConfigAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_ldap_connection(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""测试LDAP连接(管理员)"""
|
||||||
|
adapter = AdminTestLDAPConnectionAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Adapters ==========
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGetLDAPConfigAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context) -> Dict[str, Any]: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
config = db.query(LDAPConfig).first()
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
return LDAPConfigResponse(
|
||||||
|
server_url=None,
|
||||||
|
bind_dn=None,
|
||||||
|
base_dn=None,
|
||||||
|
has_bind_password=False,
|
||||||
|
user_search_filter="(uid={username})",
|
||||||
|
username_attr="uid",
|
||||||
|
email_attr="mail",
|
||||||
|
display_name_attr="cn",
|
||||||
|
is_enabled=False,
|
||||||
|
is_exclusive=False,
|
||||||
|
use_starttls=False,
|
||||||
|
connect_timeout=10,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
return LDAPConfigResponse(
|
||||||
|
server_url=config.server_url,
|
||||||
|
bind_dn=config.bind_dn,
|
||||||
|
base_dn=config.base_dn,
|
||||||
|
has_bind_password=bool(config.bind_password_encrypted),
|
||||||
|
user_search_filter=config.user_search_filter,
|
||||||
|
username_attr=config.username_attr,
|
||||||
|
email_attr=config.email_attr,
|
||||||
|
display_name_attr=config.display_name_attr,
|
||||||
|
is_enabled=config.is_enabled,
|
||||||
|
is_exclusive=config.is_exclusive,
|
||||||
|
use_starttls=config.use_starttls,
|
||||||
|
connect_timeout=config.connect_timeout,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUpdateLDAPConfigAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context) -> Dict[str, str]: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_update = LDAPConfigUpdate.model_validate(payload)
|
||||||
|
except ValidationError as e:
|
||||||
|
errors = e.errors()
|
||||||
|
if errors:
|
||||||
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||||
|
raise InvalidRequestException("请求数据验证失败")
|
||||||
|
|
||||||
|
# 使用行级锁防止并发修改导致的竞态条件
|
||||||
|
config = db.query(LDAPConfig).with_for_update().first()
|
||||||
|
is_new_config = config is None
|
||||||
|
|
||||||
|
if is_new_config:
|
||||||
|
# 首次创建配置时必须提供密码
|
||||||
|
if not config_update.bind_password:
|
||||||
|
raise InvalidRequestException("首次配置 LDAP 时必须设置绑定密码")
|
||||||
|
config = LDAPConfig()
|
||||||
|
db.add(config)
|
||||||
|
|
||||||
|
# 需要启用 LDAP 且未提交新密码时,验证已保存密码可解密(避免开启后不可用)
|
||||||
|
if config_update.is_enabled and config_update.bind_password is None:
|
||||||
|
try:
|
||||||
|
if not config.get_bind_password():
|
||||||
|
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
|
||||||
|
except InvalidRequestException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise InvalidRequestException("绑定密码解密失败,请重新设置绑定密码")
|
||||||
|
|
||||||
|
# 计算更新后的密码状态(用于校验是否可启用/独占)
|
||||||
|
if config_update.bind_password is None:
|
||||||
|
will_have_password = bool(config.bind_password_encrypted)
|
||||||
|
elif config_update.bind_password == "":
|
||||||
|
will_have_password = False
|
||||||
|
else:
|
||||||
|
will_have_password = True
|
||||||
|
|
||||||
|
# 独占模式必须启用 LDAP 且必须有绑定密码(防止误锁定)
|
||||||
|
if config_update.is_exclusive and not config_update.is_enabled:
|
||||||
|
raise InvalidRequestException("仅允许 LDAP 登录 需要先启用 LDAP 认证")
|
||||||
|
if config_update.is_enabled and not will_have_password:
|
||||||
|
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
|
||||||
|
if config_update.is_exclusive and not will_have_password:
|
||||||
|
raise InvalidRequestException("仅允许 LDAP 登录 需要先设置绑定密码")
|
||||||
|
|
||||||
|
config.server_url = config_update.server_url
|
||||||
|
config.bind_dn = config_update.bind_dn
|
||||||
|
config.base_dn = config_update.base_dn
|
||||||
|
config.user_search_filter = config_update.user_search_filter
|
||||||
|
config.username_attr = config_update.username_attr
|
||||||
|
config.email_attr = config_update.email_attr
|
||||||
|
config.display_name_attr = config_update.display_name_attr
|
||||||
|
config.is_enabled = config_update.is_enabled
|
||||||
|
config.is_exclusive = config_update.is_exclusive
|
||||||
|
config.use_starttls = config_update.use_starttls
|
||||||
|
config.connect_timeout = config_update.connect_timeout
|
||||||
|
|
||||||
|
# 启用独占模式前检查是否有足够的本地管理员(防止锁定)
|
||||||
|
# 使用 with_for_update() 阻塞锁防止竞态条件(移除 nowait 确保并发安全)
|
||||||
|
if config_update.is_enabled and config_update.is_exclusive:
|
||||||
|
local_admins = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(
|
||||||
|
User.role == UserRole.ADMIN,
|
||||||
|
User.auth_source == AuthSource.LOCAL,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
User.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# 验证至少有一个管理员有有效的密码哈希(可以登录)
|
||||||
|
# 使用严格的 bcrypt 格式校验:$2a$/$2b$/$2y$ + 2位cost + $ + 53字符
|
||||||
|
valid_admin_count = sum(
|
||||||
|
1
|
||||||
|
for admin in local_admins
|
||||||
|
if admin.password_hash
|
||||||
|
and isinstance(admin.password_hash, str)
|
||||||
|
and BCRYPT_HASH_PATTERN.match(admin.password_hash)
|
||||||
|
)
|
||||||
|
if valid_admin_count < 1:
|
||||||
|
raise InvalidRequestException(
|
||||||
|
"启用 LDAP 独占模式前,必须至少保留 1 个有效的本地管理员账户(含有效密码)作为紧急恢复通道"
|
||||||
|
)
|
||||||
|
|
||||||
|
if config_update.bind_password is not None:
|
||||||
|
if config_update.bind_password == "":
|
||||||
|
# 显式清除密码(设置为 NULL)
|
||||||
|
config.bind_password_encrypted = None
|
||||||
|
password_changed = "cleared"
|
||||||
|
else:
|
||||||
|
config.bind_password_encrypted = crypto_service.encrypt(config_update.bind_password)
|
||||||
|
password_changed = "updated"
|
||||||
|
else:
|
||||||
|
password_changed = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 记录审计日志
|
||||||
|
AuditService.log_event(
|
||||||
|
db=db,
|
||||||
|
event_type=AuditEventType.CONFIG_CHANGED,
|
||||||
|
description=f"LDAP 配置已更新 (enabled={config_update.is_enabled}, exclusive={config_update.is_exclusive})",
|
||||||
|
user_id=str(context.user.id) if context.user else None,
|
||||||
|
metadata={
|
||||||
|
"server_url": config_update.server_url,
|
||||||
|
"is_enabled": config_update.is_enabled,
|
||||||
|
"is_exclusive": config_update.is_exclusive,
|
||||||
|
"password_changed": password_changed,
|
||||||
|
"is_new_config": is_new_config,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "LDAP配置更新成功"}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTestLDAPConnectionAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context) -> Dict[str, Any]: # type: ignore[override]
|
||||||
|
from src.services.auth.ldap import LDAPService
|
||||||
|
|
||||||
|
db = context.db
|
||||||
|
if context.json_body is not None:
|
||||||
|
payload = context.json_body
|
||||||
|
elif not context.raw_body:
|
||||||
|
payload = {}
|
||||||
|
else:
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
|
saved_config = db.query(LDAPConfig).first()
|
||||||
|
|
||||||
|
try:
|
||||||
|
overrides = LDAPConfigTest.model_validate(payload)
|
||||||
|
except ValidationError as e:
|
||||||
|
errors = e.errors()
|
||||||
|
if errors:
|
||||||
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||||
|
raise InvalidRequestException("请求数据验证失败")
|
||||||
|
|
||||||
|
config_data: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
if saved_config:
|
||||||
|
config_data = {
|
||||||
|
"server_url": saved_config.server_url,
|
||||||
|
"bind_dn": saved_config.bind_dn,
|
||||||
|
"base_dn": saved_config.base_dn,
|
||||||
|
"user_search_filter": saved_config.user_search_filter,
|
||||||
|
"username_attr": saved_config.username_attr,
|
||||||
|
"email_attr": saved_config.email_attr,
|
||||||
|
"display_name_attr": saved_config.display_name_attr,
|
||||||
|
"use_starttls": saved_config.use_starttls,
|
||||||
|
"connect_timeout": saved_config.connect_timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 应用前端传入的覆盖值
|
||||||
|
for field in [
|
||||||
|
"server_url",
|
||||||
|
"bind_dn",
|
||||||
|
"base_dn",
|
||||||
|
"user_search_filter",
|
||||||
|
"username_attr",
|
||||||
|
"email_attr",
|
||||||
|
"display_name_attr",
|
||||||
|
"use_starttls",
|
||||||
|
"is_enabled",
|
||||||
|
"is_exclusive",
|
||||||
|
"connect_timeout",
|
||||||
|
]:
|
||||||
|
value = getattr(overrides, field)
|
||||||
|
if value is not None:
|
||||||
|
config_data[field] = value
|
||||||
|
|
||||||
|
# bind_password 优先使用 overrides;否则使用已保存的密码(允许保存密码无法解密时依然用 overrides 测试)
|
||||||
|
if overrides.bind_password is not None:
|
||||||
|
config_data["bind_password"] = overrides.bind_password
|
||||||
|
elif saved_config and saved_config.bind_password_encrypted:
|
||||||
|
try:
|
||||||
|
config_data["bind_password"] = crypto_service.decrypt(
|
||||||
|
saved_config.bind_password_encrypted
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"绑定密码解密失败: {type(e).__name__}: {e}")
|
||||||
|
return LDAPTestResponse(
|
||||||
|
success=False, message="绑定密码解密失败,请检查配置或重新设置密码"
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
# 必填字段检查
|
||||||
|
required_fields = ["server_url", "bind_dn", "base_dn", "bind_password"]
|
||||||
|
missing = [f for f in required_fields if not config_data.get(f)]
|
||||||
|
if missing:
|
||||||
|
return LDAPTestResponse(
|
||||||
|
success=False, message=f"缺少必要字段: {', '.join(missing)}"
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
success, message = LDAPService.test_connection_with_config(config_data)
|
||||||
|
return LDAPTestResponse(success=success, message=message).model_dump()
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""系统设置API端点。"""
|
"""系统设置API端点。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -17,6 +19,46 @@ from src.services.email.email_template import EmailTemplate
|
|||||||
from src.services.system.config import SystemConfigService
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
|
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_version_from_git() -> str | None:
|
||||||
|
"""从 git describe 获取版本号"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "describe", "--tags", "--always"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
version = result.stdout.strip()
|
||||||
|
if version.startswith("v"):
|
||||||
|
version = version[1:]
|
||||||
|
return version
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/version")
|
||||||
|
async def get_system_version():
|
||||||
|
"""获取系统版本信息"""
|
||||||
|
# 优先从 git 获取
|
||||||
|
version = _get_version_from_git()
|
||||||
|
if version:
|
||||||
|
return {"version": version}
|
||||||
|
|
||||||
|
# 回退到静态版本文件
|
||||||
|
try:
|
||||||
|
from src._version import __version__
|
||||||
|
|
||||||
|
return {"version": __version__}
|
||||||
|
except ImportError:
|
||||||
|
return {"version": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
@@ -950,6 +992,31 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
db = context.db
|
db = context.db
|
||||||
|
|
||||||
|
def _serialize_api_key(key: ApiKey, include_is_standalone: bool = False) -> dict:
|
||||||
|
"""序列化 API Key 为导出格式"""
|
||||||
|
data = {
|
||||||
|
"key_hash": key.key_hash,
|
||||||
|
"key_encrypted": key.key_encrypted,
|
||||||
|
"name": key.name,
|
||||||
|
"balance_used_usd": key.balance_used_usd,
|
||||||
|
"current_balance_usd": key.current_balance_usd,
|
||||||
|
"allowed_providers": key.allowed_providers,
|
||||||
|
"allowed_endpoints": key.allowed_endpoints,
|
||||||
|
"allowed_api_formats": key.allowed_api_formats,
|
||||||
|
"allowed_models": key.allowed_models,
|
||||||
|
"rate_limit": key.rate_limit,
|
||||||
|
"concurrent_limit": key.concurrent_limit,
|
||||||
|
"force_capabilities": key.force_capabilities,
|
||||||
|
"is_active": key.is_active,
|
||||||
|
"expires_at": key.expires_at.isoformat() if key.expires_at else None,
|
||||||
|
"auto_delete_on_expiry": key.auto_delete_on_expiry,
|
||||||
|
"total_requests": key.total_requests,
|
||||||
|
"total_cost_usd": key.total_cost_usd,
|
||||||
|
}
|
||||||
|
if include_is_standalone:
|
||||||
|
data["is_standalone"] = key.is_standalone
|
||||||
|
return data
|
||||||
|
|
||||||
# 导出 Users(排除管理员)
|
# 导出 Users(排除管理员)
|
||||||
users = db.query(User).filter(
|
users = db.query(User).filter(
|
||||||
User.is_deleted.is_(False),
|
User.is_deleted.is_(False),
|
||||||
@@ -957,31 +1024,12 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
|||||||
).all()
|
).all()
|
||||||
users_data = []
|
users_data = []
|
||||||
for user in users:
|
for user in users:
|
||||||
# 导出用户的 API Keys(保留加密数据)
|
# 导出用户的 API Keys(排除独立余额Key,独立Key单独导出)
|
||||||
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
|
api_keys = db.query(ApiKey).filter(
|
||||||
api_keys_data = []
|
ApiKey.user_id == user.id,
|
||||||
for key in api_keys:
|
ApiKey.is_standalone.is_(False)
|
||||||
api_keys_data.append(
|
).all()
|
||||||
{
|
api_keys_data = [_serialize_api_key(key, include_is_standalone=True) for key in api_keys]
|
||||||
"key_hash": key.key_hash,
|
|
||||||
"key_encrypted": key.key_encrypted,
|
|
||||||
"name": key.name,
|
|
||||||
"is_standalone": key.is_standalone,
|
|
||||||
"balance_used_usd": key.balance_used_usd,
|
|
||||||
"current_balance_usd": key.current_balance_usd,
|
|
||||||
"allowed_providers": key.allowed_providers,
|
|
||||||
"allowed_endpoints": key.allowed_endpoints,
|
|
||||||
"allowed_api_formats": key.allowed_api_formats,
|
|
||||||
"allowed_models": key.allowed_models,
|
|
||||||
"rate_limit": key.rate_limit,
|
|
||||||
"concurrent_limit": key.concurrent_limit,
|
|
||||||
"force_capabilities": key.force_capabilities,
|
|
||||||
"is_active": key.is_active,
|
|
||||||
"auto_delete_on_expiry": key.auto_delete_on_expiry,
|
|
||||||
"total_requests": key.total_requests,
|
|
||||||
"total_cost_usd": key.total_cost_usd,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
users_data.append(
|
users_data.append(
|
||||||
{
|
{
|
||||||
@@ -1001,10 +1049,15 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 导出独立余额 Keys(管理员创建的,不属于普通用户)
|
||||||
|
standalone_keys = db.query(ApiKey).filter(ApiKey.is_standalone.is_(True)).all()
|
||||||
|
standalone_keys_data = [_serialize_api_key(key) for key in standalone_keys]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": "1.0",
|
"version": "1.1",
|
||||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"users": users_data,
|
"users": users_data,
|
||||||
|
"standalone_keys": standalone_keys_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1024,21 +1077,72 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
|||||||
db = context.db
|
db = context.db
|
||||||
payload = context.ensure_json_body()
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
# 验证配置版本
|
|
||||||
version = payload.get("version")
|
|
||||||
if version != "1.0":
|
|
||||||
raise InvalidRequestException(f"不支持的配置版本: {version}")
|
|
||||||
|
|
||||||
# 获取导入选项
|
# 获取导入选项
|
||||||
merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error
|
merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error
|
||||||
users_data = payload.get("users", [])
|
users_data = payload.get("users", [])
|
||||||
|
standalone_keys_data = payload.get("standalone_keys", [])
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"users": {"created": 0, "updated": 0, "skipped": 0},
|
"users": {"created": 0, "updated": 0, "skipped": 0},
|
||||||
"api_keys": {"created": 0, "skipped": 0},
|
"api_keys": {"created": 0, "skipped": 0},
|
||||||
|
"standalone_keys": {"created": 0, "skipped": 0},
|
||||||
"errors": [],
|
"errors": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _create_api_key_from_data(
|
||||||
|
key_data: dict,
|
||||||
|
owner_id: str,
|
||||||
|
is_standalone: bool = False,
|
||||||
|
) -> tuple[ApiKey | None, str]:
|
||||||
|
"""从导入数据创建 ApiKey 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(ApiKey, "created"): 成功创建
|
||||||
|
(None, "skipped"): key 已存在,跳过
|
||||||
|
(None, "invalid"): 数据无效,跳过
|
||||||
|
"""
|
||||||
|
key_hash = key_data.get("key_hash", "").strip()
|
||||||
|
if not key_hash:
|
||||||
|
return None, "invalid"
|
||||||
|
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first()
|
||||||
|
if existing:
|
||||||
|
return None, "skipped"
|
||||||
|
|
||||||
|
# 解析 expires_at
|
||||||
|
expires_at = None
|
||||||
|
if key_data.get("expires_at"):
|
||||||
|
try:
|
||||||
|
expires_at = datetime.fromisoformat(key_data["expires_at"])
|
||||||
|
except ValueError:
|
||||||
|
stats["errors"].append(
|
||||||
|
f"API Key '{key_data.get('name', key_hash[:8])}' 的 expires_at 格式无效"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ApiKey(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
user_id=owner_id,
|
||||||
|
key_hash=key_hash,
|
||||||
|
key_encrypted=key_data.get("key_encrypted"),
|
||||||
|
name=key_data.get("name"),
|
||||||
|
is_standalone=is_standalone or key_data.get("is_standalone", False),
|
||||||
|
balance_used_usd=key_data.get("balance_used_usd", 0.0),
|
||||||
|
current_balance_usd=key_data.get("current_balance_usd"),
|
||||||
|
allowed_providers=key_data.get("allowed_providers"),
|
||||||
|
allowed_endpoints=key_data.get("allowed_endpoints"),
|
||||||
|
allowed_api_formats=key_data.get("allowed_api_formats"),
|
||||||
|
allowed_models=key_data.get("allowed_models"),
|
||||||
|
rate_limit=key_data.get("rate_limit"),
|
||||||
|
concurrent_limit=key_data.get("concurrent_limit", 5),
|
||||||
|
force_capabilities=key_data.get("force_capabilities"),
|
||||||
|
is_active=key_data.get("is_active", True),
|
||||||
|
expires_at=expires_at,
|
||||||
|
auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False),
|
||||||
|
total_requests=key_data.get("total_requests", 0),
|
||||||
|
total_cost_usd=key_data.get("total_cost_usd", 0.0),
|
||||||
|
), "created"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for user_data in users_data:
|
for user_data in users_data:
|
||||||
# 跳过管理员角色的导入(不区分大小写)
|
# 跳过管理员角色的导入(不区分大小写)
|
||||||
@@ -1109,40 +1213,31 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
# 导入 API Keys
|
# 导入 API Keys
|
||||||
for key_data in user_data.get("api_keys", []):
|
for key_data in user_data.get("api_keys", []):
|
||||||
# 检查是否已存在相同的 key_hash
|
new_key, status = _create_api_key_from_data(key_data, user_id)
|
||||||
if key_data.get("key_hash"):
|
if new_key:
|
||||||
existing_key = (
|
db.add(new_key)
|
||||||
db.query(ApiKey)
|
stats["api_keys"]["created"] += 1
|
||||||
.filter(ApiKey.key_hash == key_data["key_hash"])
|
elif status == "skipped":
|
||||||
.first()
|
stats["api_keys"]["skipped"] += 1
|
||||||
)
|
# invalid 数据不计入统计
|
||||||
if existing_key:
|
|
||||||
stats["api_keys"]["skipped"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_key = ApiKey(
|
# 导入独立余额 Keys(需要找一个管理员用户作为 owner)
|
||||||
id=str(uuid.uuid4()),
|
if standalone_keys_data:
|
||||||
user_id=user_id,
|
# 查找一个管理员用户作为独立Key的owner
|
||||||
key_hash=key_data.get("key_hash", ""),
|
admin_user = db.query(User).filter(User.role == UserRole.ADMIN).first()
|
||||||
key_encrypted=key_data.get("key_encrypted"),
|
if not admin_user:
|
||||||
name=key_data.get("name"),
|
stats["errors"].append("无法导入独立余额Key: 系统中没有管理员用户")
|
||||||
is_standalone=key_data.get("is_standalone", False),
|
else:
|
||||||
balance_used_usd=key_data.get("balance_used_usd", 0.0),
|
for key_data in standalone_keys_data:
|
||||||
current_balance_usd=key_data.get("current_balance_usd"),
|
new_key, status = _create_api_key_from_data(
|
||||||
allowed_providers=key_data.get("allowed_providers"),
|
key_data, admin_user.id, is_standalone=True
|
||||||
allowed_endpoints=key_data.get("allowed_endpoints"),
|
)
|
||||||
allowed_api_formats=key_data.get("allowed_api_formats"),
|
if new_key:
|
||||||
allowed_models=key_data.get("allowed_models"),
|
db.add(new_key)
|
||||||
rate_limit=key_data.get("rate_limit"), # None = 无限制
|
stats["standalone_keys"]["created"] += 1
|
||||||
concurrent_limit=key_data.get("concurrent_limit", 5),
|
elif status == "skipped":
|
||||||
force_capabilities=key_data.get("force_capabilities"),
|
stats["standalone_keys"]["skipped"] += 1
|
||||||
is_active=key_data.get("is_active", True),
|
# invalid 数据不计入统计
|
||||||
auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False),
|
|
||||||
total_requests=key_data.get("total_requests", 0),
|
|
||||||
total_cost_usd=key_data.get("total_cost_usd", 0.0),
|
|
||||||
)
|
|
||||||
db.add(new_key)
|
|
||||||
stats["api_keys"]["created"] += 1
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ async def get_usage_records(
|
|||||||
request: Request,
|
request: Request,
|
||||||
start_date: Optional[datetime] = None,
|
start_date: Optional[datetime] = None,
|
||||||
end_date: Optional[datetime] = None,
|
end_date: Optional[datetime] = None,
|
||||||
|
search: Optional[str] = None, # 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
@@ -104,6 +105,7 @@ async def get_usage_records(
|
|||||||
adapter = AdminUsageRecordsAdapter(
|
adapter = AdminUsageRecordsAdapter(
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
|
search=search,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
username=username,
|
username=username,
|
||||||
model=model,
|
model=model,
|
||||||
@@ -500,6 +502,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
self,
|
self,
|
||||||
start_date: Optional[datetime],
|
start_date: Optional[datetime],
|
||||||
end_date: Optional[datetime],
|
end_date: Optional[datetime],
|
||||||
|
search: Optional[str],
|
||||||
user_id: Optional[str],
|
user_id: Optional[str],
|
||||||
username: Optional[str],
|
username: Optional[str],
|
||||||
model: Optional[str],
|
model: Optional[str],
|
||||||
@@ -510,6 +513,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
):
|
):
|
||||||
self.start_date = start_date
|
self.start_date = start_date
|
||||||
self.end_date = end_date
|
self.end_date = end_date
|
||||||
|
self.search = search
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.username = username
|
self.username = username
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -519,25 +523,54 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
self.offset = offset
|
self.offset = offset
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
|
||||||
|
|
||||||
db = context.db
|
db = context.db
|
||||||
query = (
|
query = (
|
||||||
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey)
|
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey, ApiKey)
|
||||||
.outerjoin(User, Usage.user_id == User.id)
|
.outerjoin(User, Usage.user_id == User.id)
|
||||||
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
||||||
.outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
|
.outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
|
||||||
|
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 如果需要按 Provider 名称搜索/筛选,统一在这里 JOIN
|
||||||
|
if self.search or self.provider:
|
||||||
|
query = query.join(Provider, Usage.provider_id == Provider.id, isouter=True)
|
||||||
|
|
||||||
|
# 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
|
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||||
|
# 限制:最多 10 个关键词,转义后每个关键词最长 100 字符
|
||||||
|
if self.search:
|
||||||
|
keywords = [kw for kw in self.search.strip().split() if kw][:10]
|
||||||
|
for keyword in keywords:
|
||||||
|
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
|
||||||
|
search_pattern = f"%{escaped}%"
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
User.username.ilike(search_pattern, escape="\\"),
|
||||||
|
ApiKey.name.ilike(search_pattern, escape="\\"),
|
||||||
|
Usage.model.ilike(search_pattern, escape="\\"),
|
||||||
|
Provider.name.ilike(search_pattern, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if self.user_id:
|
if self.user_id:
|
||||||
query = query.filter(Usage.user_id == self.user_id)
|
query = query.filter(Usage.user_id == self.user_id)
|
||||||
if self.username:
|
if self.username:
|
||||||
# 支持用户名模糊搜索
|
# 支持用户名模糊搜索
|
||||||
query = query.filter(User.username.ilike(f"%{self.username}%"))
|
escaped = escape_like_pattern(self.username)
|
||||||
|
query = query.filter(User.username.ilike(f"%{escaped}%", escape="\\"))
|
||||||
if self.model:
|
if self.model:
|
||||||
# 支持模型名模糊搜索
|
# 支持模型名模糊搜索
|
||||||
query = query.filter(Usage.model.ilike(f"%{self.model}%"))
|
escaped = escape_like_pattern(self.model)
|
||||||
|
query = query.filter(Usage.model.ilike(f"%{escaped}%", escape="\\"))
|
||||||
if self.provider:
|
if self.provider:
|
||||||
# 支持提供商名称搜索(通过 Provider 表)
|
# 支持提供商名称搜索
|
||||||
query = query.join(Provider, Usage.provider_id == Provider.id, isouter=True)
|
escaped = escape_like_pattern(self.provider)
|
||||||
query = query.filter(Provider.name.ilike(f"%{self.provider}%"))
|
query = query.filter(Provider.name.ilike(f"%{escaped}%", escape="\\"))
|
||||||
if self.status:
|
if self.status:
|
||||||
# 状态筛选
|
# 状态筛选
|
||||||
# 旧的筛选值(基于 is_stream 和 status_code):stream, standard, error
|
# 旧的筛选值(基于 is_stream 和 status_code):stream, standard, error
|
||||||
@@ -575,7 +608,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
||||||
)
|
)
|
||||||
|
|
||||||
request_ids = [usage.request_id for usage, _, _, _ in records if usage.request_id]
|
request_ids = [usage.request_id for usage, _, _, _, _ in records if usage.request_id]
|
||||||
fallback_map = {}
|
fallback_map = {}
|
||||||
if request_ids:
|
if request_ids:
|
||||||
# 只统计实际执行的候选(success 或 failed),不包括 skipped/pending/available
|
# 只统计实际执行的候选(success 或 failed),不包括 skipped/pending/available
|
||||||
@@ -595,6 +628,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
action="usage_records",
|
action="usage_records",
|
||||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||||
|
search=self.search,
|
||||||
user_id=self.user_id,
|
user_id=self.user_id,
|
||||||
username=self.username,
|
username=self.username,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
@@ -606,7 +640,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 构建 provider_id -> Provider 名称的映射,避免 N+1 查询
|
# 构建 provider_id -> Provider 名称的映射,避免 N+1 查询
|
||||||
provider_ids = [usage.provider_id for usage, _, _, _ in records if usage.provider_id]
|
provider_ids = [usage.provider_id for usage, _, _, _, _ in records if usage.provider_id]
|
||||||
provider_map = {}
|
provider_map = {}
|
||||||
if provider_ids:
|
if provider_ids:
|
||||||
providers_data = (
|
providers_data = (
|
||||||
@@ -615,7 +649,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
provider_map = {str(p.id): p.name for p in providers_data}
|
provider_map = {str(p.id): p.name for p in providers_data}
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
for usage, user, endpoint, api_key in records:
|
for usage, user, endpoint, provider_api_key, user_api_key in records:
|
||||||
actual_cost = (
|
actual_cost = (
|
||||||
float(usage.actual_total_cost_usd)
|
float(usage.actual_total_cost_usd)
|
||||||
if usage.actual_total_cost_usd is not None
|
if usage.actual_total_cost_usd is not None
|
||||||
@@ -636,6 +670,15 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
"user_id": user.id if user else None,
|
"user_id": user.id if user else None,
|
||||||
"user_email": user.email if user else "已删除用户",
|
"user_email": user.email if user else "已删除用户",
|
||||||
"username": user.username if user else "已删除用户",
|
"username": user.username if user else "已删除用户",
|
||||||
|
"api_key": (
|
||||||
|
{
|
||||||
|
"id": user_api_key.id,
|
||||||
|
"name": user_api_key.name,
|
||||||
|
"display": user_api_key.get_display_key(),
|
||||||
|
}
|
||||||
|
if user_api_key
|
||||||
|
else None
|
||||||
|
),
|
||||||
"provider": provider_name,
|
"provider": provider_name,
|
||||||
"model": usage.model,
|
"model": usage.model,
|
||||||
"target_model": usage.target_model, # 映射后的目标模型名
|
"target_model": usage.target_model, # 映射后的目标模型名
|
||||||
@@ -661,7 +704,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
"has_fallback": fallback_map.get(usage.request_id, False),
|
"has_fallback": fallback_map.get(usage.request_id, False),
|
||||||
"api_format": usage.api_format
|
"api_format": usage.api_format
|
||||||
or (endpoint.api_format if endpoint and endpoint.api_format else None),
|
or (endpoint.api_format if endpoint and endpoint.api_format else None),
|
||||||
"api_key_name": api_key.name if api_key else None,
|
"api_key_name": provider_api_key.name if provider_api_key else None,
|
||||||
"request_metadata": usage.request_metadata, # Provider 响应元数据
|
"request_metadata": usage.request_metadata, # Provider 响应元数据
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from src.models.api import (
|
|||||||
)
|
)
|
||||||
from src.models.database import AuditEventType, User, UserRole
|
from src.models.database import AuditEventType, User, UserRole
|
||||||
from src.services.auth.service import AuthService
|
from src.services.auth.service import AuthService
|
||||||
|
from src.services.auth.ldap import LDAPService
|
||||||
from src.services.rate_limit.ip_limiter import IPRateLimiter
|
from src.services.rate_limit.ip_limiter import IPRateLimiter
|
||||||
from src.services.system.audit import AuditService
|
from src.services.system.audit import AuditService
|
||||||
from src.services.system.config import SystemConfigService
|
from src.services.system.config import SystemConfigService
|
||||||
@@ -99,6 +100,13 @@ async def registration_settings(request: Request, db: Session = Depends(get_db))
|
|||||||
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.get("/settings")
|
||||||
|
async def auth_settings(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""公开获取认证设置(用于前端判断显示哪些登录选项)"""
|
||||||
|
adapter = AuthSettingsAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(request: Request, db: Session = Depends(get_db)):
|
async def login(request: Request, db: Session = Depends(get_db)):
|
||||||
adapter = AuthLoginAdapter()
|
adapter = AuthLoginAdapter()
|
||||||
@@ -193,7 +201,9 @@ class AuthLoginAdapter(AuthPublicAdapter):
|
|||||||
detail=f"登录请求过于频繁,请在 {reset_after} 秒后重试",
|
detail=f"登录请求过于频繁,请在 {reset_after} 秒后重试",
|
||||||
)
|
)
|
||||||
|
|
||||||
user = await AuthService.authenticate_user(db, login_request.email, login_request.password)
|
user = await AuthService.authenticate_user(
|
||||||
|
db, login_request.email, login_request.password, login_request.auth_type
|
||||||
|
)
|
||||||
if not user:
|
if not user:
|
||||||
AuditService.log_login_attempt(
|
AuditService.log_login_attempt(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -305,6 +315,21 @@ class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
|
|||||||
).model_dump()
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSettingsAdapter(AuthPublicAdapter):
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
"""公开返回认证设置"""
|
||||||
|
db = context.db
|
||||||
|
|
||||||
|
ldap_enabled = LDAPService.is_ldap_enabled(db)
|
||||||
|
ldap_exclusive = LDAPService.is_ldap_exclusive(db)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"local_enabled": not ldap_exclusive,
|
||||||
|
"ldap_enabled": ldap_enabled,
|
||||||
|
"ldap_exclusive": ldap_exclusive,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AuthRegisterAdapter(AuthPublicAdapter):
|
class AuthRegisterAdapter(AuthPublicAdapter):
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
from src.models.database import SystemConfig
|
from src.models.database import SystemConfig
|
||||||
@@ -324,6 +349,12 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
|||||||
detail=f"注册请求过于频繁,请在 {reset_after} 秒后重试",
|
detail=f"注册请求过于频繁,请在 {reset_after} 秒后重试",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 仅允许 LDAP 登录时拒绝本地注册
|
||||||
|
if LDAPService.is_ldap_exclusive(db):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="系统已启用 LDAP 专属登录,禁止本地注册"
|
||||||
|
)
|
||||||
|
|
||||||
allow_registration = db.query(SystemConfig).filter_by(key="enable_registration").first()
|
allow_registration = db.query(SystemConfig).filter_by(key="enable_registration").first()
|
||||||
if allow_registration and not allow_registration.value:
|
if allow_registration and not allow_registration.value:
|
||||||
AuditService.log_event(
|
AuditService.log_event(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.models.database import ApiKey, User
|
from src.models.database import ApiKey, User
|
||||||
|
from src.utils.request_utils import get_client_ip
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ class ApiRequestContext:
|
|||||||
setattr(request.state, "request_id", request_id)
|
setattr(request.state, "request_id", request_id)
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = get_client_ip(request)
|
||||||
user_agent = request.headers.get("user-agent", "unknown")
|
user_agent = request.headers.get("user-agent", "unknown")
|
||||||
|
|
||||||
context = cls(
|
context = cls(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any, Optional, Tuple
|
|||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.config.settings import config
|
||||||
from src.core.exceptions import QuotaExceededException
|
from src.core.exceptions import QuotaExceededException
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.models.database import ApiKey, AuditEventType, User, UserRole
|
from src.models.database import ApiKey, AuditEventType, User, UserRole
|
||||||
@@ -64,13 +65,17 @@ class ApiRequestPipeline:
|
|||||||
try:
|
try:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
# 添加30秒超时防止卡死
|
# 添加超时防止卡死
|
||||||
raw_body = await asyncio.wait_for(http_request.body(), timeout=30.0)
|
raw_body = await asyncio.wait_for(
|
||||||
|
http_request.body(), timeout=config.request_body_timeout
|
||||||
|
)
|
||||||
logger.debug(f"[Pipeline] Raw body读取完成 | size={len(raw_body) if raw_body is not None else 0} bytes")
|
logger.debug(f"[Pipeline] Raw body读取完成 | size={len(raw_body) if raw_body is not None else 0} bytes")
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("读取请求体超时(30s),可能客户端未发送完整请求体")
|
timeout_sec = int(config.request_body_timeout)
|
||||||
|
logger.error(f"读取请求体超时({timeout_sec}s),可能客户端未发送完整请求体")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=408, detail="Request timeout: body not received within 30 seconds"
|
status_code=408,
|
||||||
|
detail=f"Request timeout: body not received within {timeout_sec} seconds",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"[Pipeline] 非写请求跳过读取Body | method={http_request.method}")
|
logger.debug(f"[Pipeline] 非写请求跳过读取Body | method={http_request.method}")
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from src.core.exceptions import (
|
|||||||
UpstreamClientException,
|
UpstreamClientException,
|
||||||
)
|
)
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
from src.services.billing import calculate_request_cost as _calculate_request_cost
|
||||||
from src.services.request.result import RequestResult
|
from src.services.request.result import RequestResult
|
||||||
from src.services.usage.recorder import UsageRecorder
|
from src.services.usage.recorder import UsageRecorder
|
||||||
|
|
||||||
@@ -63,6 +64,9 @@ class ChatAdapterBase(ApiAdapter):
|
|||||||
name: str = "chat.base"
|
name: str = "chat.base"
|
||||||
mode = ApiMode.STANDARD
|
mode = ApiMode.STANDARD
|
||||||
|
|
||||||
|
# 计费模板配置(子类可覆盖,如 "claude", "openai", "gemini")
|
||||||
|
BILLING_TEMPLATE: str = "claude"
|
||||||
|
|
||||||
# 子类可以配置的特殊方法(用于check_endpoint)
|
# 子类可以配置的特殊方法(用于check_endpoint)
|
||||||
@classmethod
|
@classmethod
|
||||||
def build_endpoint_url(cls, base_url: str) -> str:
|
def build_endpoint_url(cls, base_url: str) -> str:
|
||||||
@@ -486,40 +490,6 @@ class ChatAdapterBase(ApiAdapter):
|
|||||||
"""
|
"""
|
||||||
return input_tokens + cache_read_input_tokens
|
return input_tokens + cache_read_input_tokens
|
||||||
|
|
||||||
def get_cache_read_price_for_ttl(
|
|
||||||
self,
|
|
||||||
tier: dict,
|
|
||||||
cache_ttl_minutes: Optional[int] = None,
|
|
||||||
) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
根据缓存 TTL 获取缓存读取价格
|
|
||||||
|
|
||||||
默认实现:检查 cache_ttl_pricing 配置,按 TTL 选择价格
|
|
||||||
子类可覆盖此方法实现不同的 TTL 定价逻辑
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tier: 当前阶梯配置
|
|
||||||
cache_ttl_minutes: 缓存时长(分钟)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
缓存读取价格(每 1M tokens)
|
|
||||||
"""
|
|
||||||
ttl_pricing = tier.get("cache_ttl_pricing")
|
|
||||||
if ttl_pricing and cache_ttl_minutes is not None:
|
|
||||||
matched_price = None
|
|
||||||
for ttl_config in ttl_pricing:
|
|
||||||
ttl_limit = ttl_config.get("ttl_minutes", 0)
|
|
||||||
if cache_ttl_minutes <= ttl_limit:
|
|
||||||
matched_price = ttl_config.get("cache_read_price_per_1m")
|
|
||||||
break
|
|
||||||
if matched_price is not None:
|
|
||||||
return matched_price
|
|
||||||
# 超过所有配置的 TTL,使用最后一个
|
|
||||||
if ttl_pricing:
|
|
||||||
return ttl_pricing[-1].get("cache_read_price_per_1m")
|
|
||||||
|
|
||||||
return tier.get("cache_read_price_per_1m")
|
|
||||||
|
|
||||||
def compute_cost(
|
def compute_cost(
|
||||||
self,
|
self,
|
||||||
input_tokens: int,
|
input_tokens: int,
|
||||||
@@ -537,8 +507,9 @@ class ChatAdapterBase(ApiAdapter):
|
|||||||
"""
|
"""
|
||||||
计算请求成本
|
计算请求成本
|
||||||
|
|
||||||
默认实现:支持固定价格和阶梯计费
|
使用 billing 模块的配置驱动计费。
|
||||||
子类可覆盖此方法实现完全不同的计费逻辑
|
子类可通过设置 BILLING_TEMPLATE 类属性来指定计费模板,
|
||||||
|
或覆盖此方法实现完全自定义的计费逻辑。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_tokens: 输入 token 数
|
input_tokens: 输入 token 数
|
||||||
@@ -566,88 +537,26 @@ class ChatAdapterBase(ApiAdapter):
|
|||||||
"tier_index": Optional[int], # 命中的阶梯索引
|
"tier_index": Optional[int], # 命中的阶梯索引
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
tier_index = None
|
# 计算总输入上下文(使用子类可覆盖的方法)
|
||||||
effective_input_price = input_price_per_1m
|
total_input_context = self.compute_total_input_context(
|
||||||
effective_output_price = output_price_per_1m
|
input_tokens, cache_read_input_tokens, cache_creation_input_tokens
|
||||||
effective_cache_creation_price = cache_creation_price_per_1m
|
)
|
||||||
effective_cache_read_price = cache_read_price_per_1m
|
|
||||||
|
|
||||||
# 检查阶梯计费
|
return _calculate_request_cost(
|
||||||
if tiered_pricing and tiered_pricing.get("tiers"):
|
input_tokens=input_tokens,
|
||||||
total_input_context = self.compute_total_input_context(
|
output_tokens=output_tokens,
|
||||||
input_tokens, cache_read_input_tokens, cache_creation_input_tokens
|
cache_creation_input_tokens=cache_creation_input_tokens,
|
||||||
)
|
cache_read_input_tokens=cache_read_input_tokens,
|
||||||
tier = self._get_tier_for_tokens(tiered_pricing, total_input_context)
|
input_price_per_1m=input_price_per_1m,
|
||||||
|
output_price_per_1m=output_price_per_1m,
|
||||||
if tier:
|
cache_creation_price_per_1m=cache_creation_price_per_1m,
|
||||||
tier_index = tiered_pricing["tiers"].index(tier)
|
cache_read_price_per_1m=cache_read_price_per_1m,
|
||||||
effective_input_price = tier.get("input_price_per_1m", input_price_per_1m)
|
price_per_request=price_per_request,
|
||||||
effective_output_price = tier.get("output_price_per_1m", output_price_per_1m)
|
tiered_pricing=tiered_pricing,
|
||||||
effective_cache_creation_price = tier.get(
|
cache_ttl_minutes=cache_ttl_minutes,
|
||||||
"cache_creation_price_per_1m", cache_creation_price_per_1m
|
total_input_context=total_input_context,
|
||||||
)
|
billing_template=self.BILLING_TEMPLATE,
|
||||||
effective_cache_read_price = self.get_cache_read_price_for_ttl(
|
)
|
||||||
tier, cache_ttl_minutes
|
|
||||||
)
|
|
||||||
if effective_cache_read_price is None:
|
|
||||||
effective_cache_read_price = cache_read_price_per_1m
|
|
||||||
|
|
||||||
# 计算各项成本
|
|
||||||
input_cost = (input_tokens / 1_000_000) * effective_input_price
|
|
||||||
output_cost = (output_tokens / 1_000_000) * effective_output_price
|
|
||||||
|
|
||||||
cache_creation_cost = 0.0
|
|
||||||
cache_read_cost = 0.0
|
|
||||||
if cache_creation_input_tokens > 0 and effective_cache_creation_price is not None:
|
|
||||||
cache_creation_cost = (
|
|
||||||
cache_creation_input_tokens / 1_000_000
|
|
||||||
) * effective_cache_creation_price
|
|
||||||
if cache_read_input_tokens > 0 and effective_cache_read_price is not None:
|
|
||||||
cache_read_cost = (
|
|
||||||
cache_read_input_tokens / 1_000_000
|
|
||||||
) * effective_cache_read_price
|
|
||||||
|
|
||||||
cache_cost = cache_creation_cost + cache_read_cost
|
|
||||||
request_cost = price_per_request if price_per_request else 0.0
|
|
||||||
total_cost = input_cost + output_cost + cache_cost + request_cost
|
|
||||||
|
|
||||||
return {
|
|
||||||
"input_cost": input_cost,
|
|
||||||
"output_cost": output_cost,
|
|
||||||
"cache_creation_cost": cache_creation_cost,
|
|
||||||
"cache_read_cost": cache_read_cost,
|
|
||||||
"cache_cost": cache_cost,
|
|
||||||
"request_cost": request_cost,
|
|
||||||
"total_cost": total_cost,
|
|
||||||
"tier_index": tier_index,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_tier_for_tokens(tiered_pricing: dict, total_input_tokens: int) -> Optional[dict]:
|
|
||||||
"""
|
|
||||||
根据总输入 token 数确定价格阶梯
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tiered_pricing: 阶梯计费配置 {"tiers": [...]}
|
|
||||||
total_input_tokens: 总输入 token 数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
匹配的阶梯配置
|
|
||||||
"""
|
|
||||||
if not tiered_pricing or "tiers" not in tiered_pricing:
|
|
||||||
return None
|
|
||||||
|
|
||||||
tiers = tiered_pricing.get("tiers", [])
|
|
||||||
if not tiers:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for tier in tiers:
|
|
||||||
up_to = tier.get("up_to")
|
|
||||||
if up_to is None or total_input_tokens <= up_to:
|
|
||||||
return tier
|
|
||||||
|
|
||||||
# 如果所有阶梯都有上限且都超过了,返回最后一个阶梯
|
|
||||||
return tiers[-1] if tiers else None
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# 模型列表查询 - 子类应覆盖此方法
|
# 模型列表查询 - 子类应覆盖此方法
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from src.core.exceptions import (
|
|||||||
UpstreamClientException,
|
UpstreamClientException,
|
||||||
)
|
)
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
from src.services.billing import calculate_request_cost as _calculate_request_cost
|
||||||
from src.services.request.result import RequestResult
|
from src.services.request.result import RequestResult
|
||||||
from src.services.usage.recorder import UsageRecorder
|
from src.services.usage.recorder import UsageRecorder
|
||||||
|
|
||||||
@@ -61,6 +62,9 @@ class CliAdapterBase(ApiAdapter):
|
|||||||
name: str = "cli.base"
|
name: str = "cli.base"
|
||||||
mode = ApiMode.PROXY
|
mode = ApiMode.PROXY
|
||||||
|
|
||||||
|
# 计费模板配置(子类可覆盖,如 "claude", "openai", "gemini")
|
||||||
|
BILLING_TEMPLATE: str = "claude"
|
||||||
|
|
||||||
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
|
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
|
||||||
self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID]
|
self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID]
|
||||||
|
|
||||||
@@ -438,40 +442,6 @@ class CliAdapterBase(ApiAdapter):
|
|||||||
"""
|
"""
|
||||||
return input_tokens + cache_read_input_tokens
|
return input_tokens + cache_read_input_tokens
|
||||||
|
|
||||||
def get_cache_read_price_for_ttl(
|
|
||||||
self,
|
|
||||||
tier: dict,
|
|
||||||
cache_ttl_minutes: Optional[int] = None,
|
|
||||||
) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
根据缓存 TTL 获取缓存读取价格
|
|
||||||
|
|
||||||
默认实现:检查 cache_ttl_pricing 配置,按 TTL 选择价格
|
|
||||||
子类可覆盖此方法实现不同的 TTL 定价逻辑
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tier: 当前阶梯配置
|
|
||||||
cache_ttl_minutes: 缓存时长(分钟)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
缓存读取价格(每 1M tokens)
|
|
||||||
"""
|
|
||||||
ttl_pricing = tier.get("cache_ttl_pricing")
|
|
||||||
if ttl_pricing and cache_ttl_minutes is not None:
|
|
||||||
matched_price = None
|
|
||||||
for ttl_config in ttl_pricing:
|
|
||||||
ttl_limit = ttl_config.get("ttl_minutes", 0)
|
|
||||||
if cache_ttl_minutes <= ttl_limit:
|
|
||||||
matched_price = ttl_config.get("cache_read_price_per_1m")
|
|
||||||
break
|
|
||||||
if matched_price is not None:
|
|
||||||
return matched_price
|
|
||||||
# 超过所有配置的 TTL,使用最后一个
|
|
||||||
if ttl_pricing:
|
|
||||||
return ttl_pricing[-1].get("cache_read_price_per_1m")
|
|
||||||
|
|
||||||
return tier.get("cache_read_price_per_1m")
|
|
||||||
|
|
||||||
def compute_cost(
|
def compute_cost(
|
||||||
self,
|
self,
|
||||||
input_tokens: int,
|
input_tokens: int,
|
||||||
@@ -489,8 +459,9 @@ class CliAdapterBase(ApiAdapter):
|
|||||||
"""
|
"""
|
||||||
计算请求成本
|
计算请求成本
|
||||||
|
|
||||||
默认实现:支持固定价格和阶梯计费
|
使用 billing 模块的配置驱动计费。
|
||||||
子类可覆盖此方法实现完全不同的计费逻辑
|
子类可通过设置 BILLING_TEMPLATE 类属性来指定计费模板,
|
||||||
|
或覆盖此方法实现完全自定义的计费逻辑。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_tokens: 输入 token 数
|
input_tokens: 输入 token 数
|
||||||
@@ -508,78 +479,26 @@ class CliAdapterBase(ApiAdapter):
|
|||||||
Returns:
|
Returns:
|
||||||
包含各项成本的字典
|
包含各项成本的字典
|
||||||
"""
|
"""
|
||||||
tier_index = None
|
# 计算总输入上下文(使用子类可覆盖的方法)
|
||||||
effective_input_price = input_price_per_1m
|
total_input_context = self.compute_total_input_context(
|
||||||
effective_output_price = output_price_per_1m
|
input_tokens, cache_read_input_tokens, cache_creation_input_tokens
|
||||||
effective_cache_creation_price = cache_creation_price_per_1m
|
)
|
||||||
effective_cache_read_price = cache_read_price_per_1m
|
|
||||||
|
|
||||||
# 检查阶梯计费
|
return _calculate_request_cost(
|
||||||
if tiered_pricing and tiered_pricing.get("tiers"):
|
input_tokens=input_tokens,
|
||||||
total_input_context = self.compute_total_input_context(
|
output_tokens=output_tokens,
|
||||||
input_tokens, cache_read_input_tokens, cache_creation_input_tokens
|
cache_creation_input_tokens=cache_creation_input_tokens,
|
||||||
)
|
cache_read_input_tokens=cache_read_input_tokens,
|
||||||
tier = self._get_tier_for_tokens(tiered_pricing, total_input_context)
|
input_price_per_1m=input_price_per_1m,
|
||||||
|
output_price_per_1m=output_price_per_1m,
|
||||||
if tier:
|
cache_creation_price_per_1m=cache_creation_price_per_1m,
|
||||||
tier_index = tiered_pricing["tiers"].index(tier)
|
cache_read_price_per_1m=cache_read_price_per_1m,
|
||||||
effective_input_price = tier.get("input_price_per_1m", input_price_per_1m)
|
price_per_request=price_per_request,
|
||||||
effective_output_price = tier.get("output_price_per_1m", output_price_per_1m)
|
tiered_pricing=tiered_pricing,
|
||||||
effective_cache_creation_price = tier.get(
|
cache_ttl_minutes=cache_ttl_minutes,
|
||||||
"cache_creation_price_per_1m", cache_creation_price_per_1m
|
total_input_context=total_input_context,
|
||||||
)
|
billing_template=self.BILLING_TEMPLATE,
|
||||||
effective_cache_read_price = self.get_cache_read_price_for_ttl(
|
)
|
||||||
tier, cache_ttl_minutes
|
|
||||||
)
|
|
||||||
if effective_cache_read_price is None:
|
|
||||||
effective_cache_read_price = cache_read_price_per_1m
|
|
||||||
|
|
||||||
# 计算各项成本
|
|
||||||
input_cost = (input_tokens / 1_000_000) * effective_input_price
|
|
||||||
output_cost = (output_tokens / 1_000_000) * effective_output_price
|
|
||||||
|
|
||||||
cache_creation_cost = 0.0
|
|
||||||
cache_read_cost = 0.0
|
|
||||||
if cache_creation_input_tokens > 0 and effective_cache_creation_price is not None:
|
|
||||||
cache_creation_cost = (
|
|
||||||
cache_creation_input_tokens / 1_000_000
|
|
||||||
) * effective_cache_creation_price
|
|
||||||
if cache_read_input_tokens > 0 and effective_cache_read_price is not None:
|
|
||||||
cache_read_cost = (
|
|
||||||
cache_read_input_tokens / 1_000_000
|
|
||||||
) * effective_cache_read_price
|
|
||||||
|
|
||||||
cache_cost = cache_creation_cost + cache_read_cost
|
|
||||||
request_cost = price_per_request if price_per_request else 0.0
|
|
||||||
total_cost = input_cost + output_cost + cache_cost + request_cost
|
|
||||||
|
|
||||||
return {
|
|
||||||
"input_cost": input_cost,
|
|
||||||
"output_cost": output_cost,
|
|
||||||
"cache_creation_cost": cache_creation_cost,
|
|
||||||
"cache_read_cost": cache_read_cost,
|
|
||||||
"cache_cost": cache_cost,
|
|
||||||
"request_cost": request_cost,
|
|
||||||
"total_cost": total_cost,
|
|
||||||
"tier_index": tier_index,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_tier_for_tokens(tiered_pricing: dict, total_input_tokens: int) -> Optional[dict]:
|
|
||||||
"""根据总输入 token 数确定价格阶梯"""
|
|
||||||
if not tiered_pricing or "tiers" not in tiered_pricing:
|
|
||||||
return None
|
|
||||||
|
|
||||||
tiers = tiered_pricing.get("tiers", [])
|
|
||||||
if not tiers:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for tier in tiers:
|
|
||||||
up_to = tier.get("up_to")
|
|
||||||
if up_to is None or total_input_tokens <= up_to:
|
|
||||||
return tier
|
|
||||||
|
|
||||||
return tiers[-1] if tiers else None
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# 模型列表查询 - 子类应覆盖此方法
|
# 模型列表查询 - 子类应覆盖此方法
|
||||||
|
|||||||
@@ -1497,8 +1497,12 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
retry_after=int(resp.headers.get("retry-after", 0)) or None,
|
retry_after=int(resp.headers.get("retry-after", 0)) or None,
|
||||||
)
|
)
|
||||||
elif resp.status_code >= 500:
|
elif resp.status_code >= 500:
|
||||||
|
error_text = resp.text
|
||||||
raise ProviderNotAvailableException(
|
raise ProviderNotAvailableException(
|
||||||
f"提供商服务不可用: {provider.name}, 状态: {resp.status_code}"
|
f"提供商服务不可用: {provider.name}, 状态: {resp.status_code}",
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
upstream_status=resp.status_code,
|
||||||
|
upstream_response=error_text,
|
||||||
)
|
)
|
||||||
elif 300 <= resp.status_code < 400:
|
elif 300 <= resp.status_code < 400:
|
||||||
redirect_url = resp.headers.get("location", "unknown")
|
redirect_url = resp.headers.get("location", "unknown")
|
||||||
@@ -1508,7 +1512,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
elif resp.status_code != 200:
|
elif resp.status_code != 200:
|
||||||
error_text = resp.text
|
error_text = resp.text
|
||||||
raise ProviderNotAvailableException(
|
raise ProviderNotAvailableException(
|
||||||
f"提供商返回错误: {provider.name}, 状态: {resp.status_code}, 错误: {error_text[:200]}"
|
f"提供商返回错误: {provider.name}, 状态: {resp.status_code}",
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
upstream_status=resp.status_code,
|
||||||
|
upstream_response=error_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 安全解析 JSON 响应,处理可能的编码错误
|
# 安全解析 JSON 响应,处理可能的编码错误
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class ClaudeChatAdapter(ChatAdapterBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
FORMAT_ID = "CLAUDE"
|
FORMAT_ID = "CLAUDE"
|
||||||
|
BILLING_TEMPLATE = "claude" # 使用 Claude 计费模板
|
||||||
name = "claude.chat"
|
name = "claude.chat"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class ClaudeCliAdapter(CliAdapterBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
FORMAT_ID = "CLAUDE_CLI"
|
FORMAT_ID = "CLAUDE_CLI"
|
||||||
|
BILLING_TEMPLATE = "claude" # 使用 Claude 计费模板
|
||||||
name = "claude.cli"
|
name = "claude.cli"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class GeminiChatAdapter(ChatAdapterBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
FORMAT_ID = "GEMINI"
|
FORMAT_ID = "GEMINI"
|
||||||
|
BILLING_TEMPLATE = "gemini" # 使用 Gemini 计费模板
|
||||||
name = "gemini.chat"
|
name = "gemini.chat"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class GeminiCliAdapter(CliAdapterBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
FORMAT_ID = "GEMINI_CLI"
|
FORMAT_ID = "GEMINI_CLI"
|
||||||
|
BILLING_TEMPLATE = "gemini" # 使用 Gemini 计费模板
|
||||||
name = "gemini.cli"
|
name = "gemini.cli"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class OpenAIChatAdapter(ChatAdapterBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
FORMAT_ID = "OPENAI"
|
FORMAT_ID = "OPENAI"
|
||||||
|
BILLING_TEMPLATE = "openai" # 使用 OpenAI 计费模板
|
||||||
name = "openai.chat"
|
name = "openai.chat"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class OpenAICliAdapter(CliAdapterBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
FORMAT_ID = "OPENAI_CLI"
|
FORMAT_ID = "OPENAI_CLI"
|
||||||
|
BILLING_TEMPLATE = "openai" # 使用 OpenAI 计费模板
|
||||||
name = "openai.cli"
|
name = "openai.cli"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -104,11 +104,14 @@ async def get_my_usage(
|
|||||||
request: Request,
|
request: Request,
|
||||||
start_date: Optional[datetime] = None,
|
start_date: Optional[datetime] = None,
|
||||||
end_date: Optional[datetime] = None,
|
end_date: Optional[datetime] = None,
|
||||||
|
search: Optional[str] = None, # 通用搜索:密钥名、模型名
|
||||||
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
|
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
|
||||||
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
|
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
adapter = GetUsageAdapter(start_date=start_date, end_date=end_date, limit=limit, offset=offset)
|
adapter = GetUsageAdapter(
|
||||||
|
start_date=start_date, end_date=end_date, search=search, limit=limit, offset=offset
|
||||||
|
)
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -487,10 +490,15 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
|
|||||||
class GetUsageAdapter(AuthenticatedApiAdapter):
|
class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||||
start_date: Optional[datetime]
|
start_date: Optional[datetime]
|
||||||
end_date: Optional[datetime]
|
end_date: Optional[datetime]
|
||||||
|
search: Optional[str] = None
|
||||||
limit: int = 100
|
limit: int = 100
|
||||||
offset: int = 0
|
offset: int = 0
|
||||||
|
|
||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
|
||||||
|
|
||||||
db = context.db
|
db = context.db
|
||||||
user = context.user
|
user = context.user
|
||||||
summary_list = UsageService.get_usage_summary(
|
summary_list = UsageService.get_usage_summary(
|
||||||
@@ -595,12 +603,30 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
})
|
})
|
||||||
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
|
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
|
||||||
|
|
||||||
query = db.query(Usage).filter(Usage.user_id == user.id)
|
query = (
|
||||||
|
db.query(Usage, ApiKey)
|
||||||
|
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
||||||
|
.filter(Usage.user_id == user.id)
|
||||||
|
)
|
||||||
if self.start_date:
|
if self.start_date:
|
||||||
query = query.filter(Usage.created_at >= self.start_date)
|
query = query.filter(Usage.created_at >= self.start_date)
|
||||||
if self.end_date:
|
if self.end_date:
|
||||||
query = query.filter(Usage.created_at <= self.end_date)
|
query = query.filter(Usage.created_at <= self.end_date)
|
||||||
|
|
||||||
|
# 通用搜索:密钥名、模型名
|
||||||
|
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||||
|
if self.search and self.search.strip():
|
||||||
|
keywords = [kw for kw in self.search.strip().split() if kw][:10]
|
||||||
|
for keyword in keywords:
|
||||||
|
escaped = safe_truncate_escaped(escape_like_pattern(keyword), 100)
|
||||||
|
search_pattern = f"%{escaped}%"
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
ApiKey.name.ilike(search_pattern, escape="\\"),
|
||||||
|
Usage.model.ilike(search_pattern, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 计算总数用于分页
|
# 计算总数用于分页
|
||||||
total_records = query.count()
|
total_records = query.count()
|
||||||
usage_records = query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
usage_records = query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
||||||
@@ -659,8 +685,17 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
"output_price_per_1m": r.output_price_per_1m,
|
"output_price_per_1m": r.output_price_per_1m,
|
||||||
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
|
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
|
||||||
"cache_read_price_per_1m": r.cache_read_price_per_1m,
|
"cache_read_price_per_1m": r.cache_read_price_per_1m,
|
||||||
|
"api_key": (
|
||||||
|
{
|
||||||
|
"id": str(api_key.id),
|
||||||
|
"name": api_key.name,
|
||||||
|
"display": api_key.get_display_key(),
|
||||||
|
}
|
||||||
|
if api_key
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for r in usage_records
|
for r, api_key in usage_records
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,7 +703,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
if user.role == "admin":
|
if user.role == "admin":
|
||||||
response_data["total_actual_cost"] = total_actual_cost
|
response_data["total_actual_cost"] = total_actual_cost
|
||||||
# 为每条记录添加真实成本和倍率信息
|
# 为每条记录添加真实成本和倍率信息
|
||||||
for i, r in enumerate(usage_records):
|
for i, (r, _) in enumerate(usage_records):
|
||||||
# 确保字段有值,避免前端显示 -
|
# 确保字段有值,避免前端显示 -
|
||||||
actual_cost = (
|
actual_cost = (
|
||||||
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
|
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
|
||||||
|
|||||||
@@ -106,13 +106,6 @@ class Config:
|
|||||||
self.llm_api_rate_limit = int(os.getenv("LLM_API_RATE_LIMIT", "100"))
|
self.llm_api_rate_limit = int(os.getenv("LLM_API_RATE_LIMIT", "100"))
|
||||||
self.public_api_rate_limit = int(os.getenv("PUBLIC_API_RATE_LIMIT", "60"))
|
self.public_api_rate_limit = int(os.getenv("PUBLIC_API_RATE_LIMIT", "60"))
|
||||||
|
|
||||||
# 可信代理配置
|
|
||||||
# TRUSTED_PROXY_COUNT: 信任的代理层数(默认 1,即信任最近一层代理)
|
|
||||||
# 设置为 0 表示不信任任何代理头,直接使用连接 IP
|
|
||||||
# 当服务部署在 Nginx/CloudFlare 等反向代理后面时,设置为对应的代理层数
|
|
||||||
# 如果服务直接暴露公网,应设置为 0 以防止 IP 伪造
|
|
||||||
self.trusted_proxy_count = int(os.getenv("TRUSTED_PROXY_COUNT", "1"))
|
|
||||||
|
|
||||||
# 异常处理配置
|
# 异常处理配置
|
||||||
# 设置为 True 时,ProxyException 会传播到路由层以便记录 provider_request_headers
|
# 设置为 True 时,ProxyException 会传播到路由层以便记录 provider_request_headers
|
||||||
# 设置为 False 时,使用全局异常处理器统一处理
|
# 设置为 False 时,使用全局异常处理器统一处理
|
||||||
@@ -161,6 +154,11 @@ class Config:
|
|||||||
self.stream_stats_delay = float(os.getenv("STREAM_STATS_DELAY", "0.1"))
|
self.stream_stats_delay = float(os.getenv("STREAM_STATS_DELAY", "0.1"))
|
||||||
self.stream_first_byte_timeout = self._parse_ttfb_timeout()
|
self.stream_first_byte_timeout = self._parse_ttfb_timeout()
|
||||||
|
|
||||||
|
# 请求体读取超时(秒)
|
||||||
|
# REQUEST_BODY_TIMEOUT: 等待客户端发送完整请求体的超时时间
|
||||||
|
# 默认 60 秒,防止客户端发送不完整请求导致连接卡死
|
||||||
|
self.request_body_timeout = float(os.getenv("REQUEST_BODY_TIMEOUT", "60.0"))
|
||||||
|
|
||||||
# 内部请求 User-Agent 配置(用于查询上游模型列表等)
|
# 内部请求 User-Agent 配置(用于查询上游模型列表等)
|
||||||
# 可通过环境变量覆盖默认值,模拟对应 CLI 客户端
|
# 可通过环境变量覆盖默认值,模拟对应 CLI 客户端
|
||||||
self.internal_user_agent_claude_cli = os.getenv(
|
self.internal_user_agent_claude_cli = os.getenv(
|
||||||
|
|||||||
@@ -30,3 +30,10 @@ class ProviderBillingType(Enum):
|
|||||||
MONTHLY_QUOTA = "monthly_quota" # 月卡额度
|
MONTHLY_QUOTA = "monthly_quota" # 月卡额度
|
||||||
PAY_AS_YOU_GO = "pay_as_you_go" # 按量付费
|
PAY_AS_YOU_GO = "pay_as_you_go" # 按量付费
|
||||||
FREE_TIER = "free_tier" # 免费额度
|
FREE_TIER = "free_tier" # 免费额度
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSource(str, Enum):
|
||||||
|
"""认证来源枚举"""
|
||||||
|
|
||||||
|
LOCAL = "local" # 本地认证
|
||||||
|
LDAP = "ldap" # LDAP 认证
|
||||||
|
|||||||
@@ -203,28 +203,21 @@ class PluginMiddleware:
|
|||||||
"""
|
"""
|
||||||
获取客户端 IP 地址,支持代理头
|
获取客户端 IP 地址,支持代理头
|
||||||
|
|
||||||
注意:此方法信任 X-Forwarded-For 和 X-Real-IP 头,
|
优先级:X-Real-IP > X-Forwarded-For > 直连 IP
|
||||||
仅当服务部署在可信代理(如 Nginx、CloudFlare)后面时才安全。
|
X-Real-IP 由最外层 Nginx 设置,最可靠
|
||||||
如果服务直接暴露公网,攻击者可伪造这些头绕过限流。
|
|
||||||
"""
|
"""
|
||||||
# 从配置获取可信代理层数(默认为 1,即信任最近一层代理)
|
# 优先检查 X-Real-IP(由最外层 Nginx 设置,最可靠)
|
||||||
trusted_proxy_count = getattr(config, "trusted_proxy_count", 1)
|
|
||||||
|
|
||||||
# 优先从代理头获取真实 IP
|
|
||||||
forwarded_for = request.headers.get("x-forwarded-for")
|
|
||||||
if forwarded_for:
|
|
||||||
# X-Forwarded-For 格式: "client, proxy1, proxy2"
|
|
||||||
# 从右往左数 trusted_proxy_count 个,取其左边的第一个
|
|
||||||
ips = [ip.strip() for ip in forwarded_for.split(",")]
|
|
||||||
if len(ips) > trusted_proxy_count:
|
|
||||||
return ips[-(trusted_proxy_count + 1)]
|
|
||||||
elif ips:
|
|
||||||
return ips[0]
|
|
||||||
|
|
||||||
real_ip = request.headers.get("x-real-ip")
|
real_ip = request.headers.get("x-real-ip")
|
||||||
if real_ip:
|
if real_ip:
|
||||||
return real_ip.strip()
|
return real_ip.strip()
|
||||||
|
|
||||||
|
# 检查 X-Forwarded-For,取第一个 IP(原始客户端)
|
||||||
|
forwarded_for = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded_for:
|
||||||
|
ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()]
|
||||||
|
if ips:
|
||||||
|
return ips[0]
|
||||||
|
|
||||||
# 回退到直连 IP
|
# 回退到直连 IP
|
||||||
if request.client:
|
if request.client:
|
||||||
return request.client.host
|
return request.client.host
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ API端点请求/响应模型定义
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
from ..core.enums import UserRole
|
from ..core.enums import UserRole
|
||||||
|
|
||||||
@@ -15,17 +15,9 @@ from ..core.enums import UserRole
|
|||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
"""登录请求"""
|
"""登录请求"""
|
||||||
|
|
||||||
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址")
|
email: str = Field(..., min_length=1, max_length=255, description="邮箱/用户名")
|
||||||
password: str = Field(..., min_length=1, max_length=128, description="密码")
|
password: str = Field(..., min_length=1, max_length=128, description="密码")
|
||||||
|
auth_type: Literal["local", "ldap"] = Field(default="local", description="认证类型")
|
||||||
@classmethod
|
|
||||||
@field_validator("email")
|
|
||||||
def validate_email(cls, v):
|
|
||||||
"""验证邮箱格式"""
|
|
||||||
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
||||||
if not re.match(email_pattern, v):
|
|
||||||
raise ValueError("邮箱格式无效")
|
|
||||||
return v.lower()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@field_validator("password")
|
@field_validator("password")
|
||||||
@@ -36,6 +28,24 @@ class LoginRequest(BaseModel):
|
|||||||
raise ValueError("密码不能为空")
|
raise ValueError("密码不能为空")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_login(self):
|
||||||
|
"""根据认证类型校验并规范化登录标识"""
|
||||||
|
identifier = self.email.strip()
|
||||||
|
|
||||||
|
if not identifier:
|
||||||
|
raise ValueError("用户名/邮箱不能为空")
|
||||||
|
|
||||||
|
# 本地和 LDAP 登录都支持用户名或邮箱
|
||||||
|
# 如果是邮箱格式,转换为小写
|
||||||
|
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||||
|
if re.match(email_pattern, identifier):
|
||||||
|
self.email = identifier.lower()
|
||||||
|
else:
|
||||||
|
self.email = identifier
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
class LoginResponse(BaseModel):
|
||||||
"""登录响应"""
|
"""登录响应"""
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from sqlalchemy.dialects.postgresql import JSONB
|
|||||||
from sqlalchemy.orm import declarative_base, relationship
|
from sqlalchemy.orm import declarative_base, relationship
|
||||||
|
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..core.enums import ProviderBillingType, UserRole
|
from ..core.enums import AuthSource, ProviderBillingType, UserRole
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
@@ -54,6 +54,20 @@ class User(Base):
|
|||||||
default=UserRole.USER,
|
default=UserRole.USER,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
auth_source = Column(
|
||||||
|
Enum(
|
||||||
|
AuthSource,
|
||||||
|
name="authsource",
|
||||||
|
create_type=False,
|
||||||
|
values_callable=lambda x: [e.value for e in x],
|
||||||
|
),
|
||||||
|
default=AuthSource.LOCAL,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# LDAP 标识(仅 auth_source=ldap 时使用,用于在邮箱变更/用户名冲突时稳定关联本地账户)
|
||||||
|
ldap_dn = Column(String(512), nullable=True, index=True)
|
||||||
|
ldap_username = Column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
# 访问限制(NULL 表示不限制,允许访问所有资源)
|
# 访问限制(NULL 表示不限制,允许访问所有资源)
|
||||||
allowed_providers = Column(JSON, nullable=True) # 允许使用的提供商 ID 列表
|
allowed_providers = Column(JSON, nullable=True) # 允许使用的提供商 ID 列表
|
||||||
@@ -428,6 +442,68 @@ class SystemConfig(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfig(Base):
|
||||||
|
"""LDAP认证配置表 - 单行配置"""
|
||||||
|
|
||||||
|
__tablename__ = "ldap_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
server_url = Column(String(255), nullable=False) # ldap://host:389 或 ldaps://host:636
|
||||||
|
bind_dn = Column(String(255), nullable=False) # 绑定账号 DN
|
||||||
|
bind_password_encrypted = Column(Text, nullable=True) # 加密的绑定密码(允许 NULL 表示已清除)
|
||||||
|
base_dn = Column(String(255), nullable=False) # 用户搜索基础 DN
|
||||||
|
user_search_filter = Column(
|
||||||
|
String(500), default="(uid={username})", nullable=False
|
||||||
|
) # 用户搜索过滤器
|
||||||
|
username_attr = Column(String(50), default="uid", nullable=False) # 用户名属性 (uid/sAMAccountName)
|
||||||
|
email_attr = Column(String(50), default="mail", nullable=False) # 邮箱属性
|
||||||
|
display_name_attr = Column(String(50), default="cn", nullable=False) # 显示名称属性
|
||||||
|
is_enabled = Column(Boolean, default=False, nullable=False) # 是否启用 LDAP 认证
|
||||||
|
is_exclusive = Column(
|
||||||
|
Boolean, default=False, nullable=False
|
||||||
|
) # 是否仅允许 LDAP 登录(禁用本地认证)
|
||||||
|
use_starttls = Column(Boolean, default=False, nullable=False) # 是否使用 STARTTLS
|
||||||
|
connect_timeout = Column(Integer, default=10, nullable=False) # 连接超时时间(秒)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_bind_password(self, password: str) -> None:
|
||||||
|
"""
|
||||||
|
设置并加密绑定密码
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: 明文密码
|
||||||
|
"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
|
||||||
|
self.bind_password_encrypted = crypto_service.encrypt(password)
|
||||||
|
|
||||||
|
def get_bind_password(self) -> str:
|
||||||
|
"""
|
||||||
|
获取解密后的绑定密码
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 解密后的明文密码
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DecryptionException: 解密失败时抛出异常
|
||||||
|
"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
|
||||||
|
if not self.bind_password_encrypted:
|
||||||
|
return ""
|
||||||
|
return crypto_service.decrypt(self.bind_password_encrypted)
|
||||||
|
|
||||||
|
|
||||||
class Provider(Base):
|
class Provider(Base):
|
||||||
"""提供商配置表"""
|
"""提供商配置表"""
|
||||||
|
|
||||||
|
|||||||
363
src/services/auth/ldap.py
Normal file
363
src/services/auth/ldap.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
"""LDAP 认证服务"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.models.database import LDAPConfig
|
||||||
|
|
||||||
|
# LDAP 连接默认超时时间(秒)
|
||||||
|
DEFAULT_LDAP_CONNECT_TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ldap_server_url(server_url: str) -> tuple[str, int, bool]:
|
||||||
|
"""
|
||||||
|
解析 LDAP 服务器地址,支持:
|
||||||
|
- ldap://host:389
|
||||||
|
- ldaps://host:636
|
||||||
|
- host:389(无 scheme 时默认 ldap)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(host, port, use_ssl)
|
||||||
|
"""
|
||||||
|
raw = (server_url or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("LDAP server_url is required")
|
||||||
|
|
||||||
|
parsed = urlparse(raw)
|
||||||
|
if parsed.scheme in {"ldap", "ldaps"}:
|
||||||
|
host = parsed.hostname
|
||||||
|
if not host:
|
||||||
|
raise ValueError("Invalid LDAP server_url")
|
||||||
|
use_ssl = parsed.scheme == "ldaps"
|
||||||
|
port = parsed.port or (636 if use_ssl else 389)
|
||||||
|
return host, port, use_ssl
|
||||||
|
|
||||||
|
# 兼容无 scheme:按 ldap:// 解析
|
||||||
|
parsed = urlparse(f"ldap://{raw}")
|
||||||
|
host = parsed.hostname
|
||||||
|
if not host:
|
||||||
|
raise ValueError("Invalid LDAP server_url")
|
||||||
|
port = parsed.port or 389
|
||||||
|
return host, port, False
|
||||||
|
|
||||||
|
|
||||||
|
def escape_ldap_filter(value: str, max_length: int = 128) -> str:
|
||||||
|
"""
|
||||||
|
转义 LDAP 过滤器中的特殊字符,防止 LDAP 注入攻击(RFC 4515)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: 需要转义的字符串
|
||||||
|
max_length: 最大允许长度,默认 128 字符(覆盖大多数企业邮箱用户名)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
转义后的安全字符串
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 输入值过长
|
||||||
|
"""
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
# 先检查原始长度,防止 DoS 攻击
|
||||||
|
# 128 字符足够覆盖大多数企业用户名和邮箱地址
|
||||||
|
if len(value) > max_length:
|
||||||
|
raise ValueError(f"LDAP filter value too long (max {max_length} characters)")
|
||||||
|
|
||||||
|
# Unicode 规范化(使用 NFC 而非 NFKC,避免兼容性字符转换导致安全问题)
|
||||||
|
value = unicodedata.normalize("NFC", value)
|
||||||
|
|
||||||
|
# 再次检查规范化后的长度(防止规范化后长度突增)
|
||||||
|
if len(value) > max_length:
|
||||||
|
raise ValueError(f"LDAP filter value too long after normalization (max {max_length})")
|
||||||
|
|
||||||
|
# LDAP 过滤器特殊字符(RFC 4515 + 扩展)
|
||||||
|
# 使用显式顺序处理,确保反斜杠首先转义
|
||||||
|
value = value.replace("\\", r"\5c") # 反斜杠必须首先转义
|
||||||
|
value = value.replace("*", r"\2a")
|
||||||
|
value = value.replace("(", r"\28")
|
||||||
|
value = value.replace(")", r"\29")
|
||||||
|
value = value.replace("\x00", r"\00") # NUL
|
||||||
|
value = value.replace("&", r"\26")
|
||||||
|
value = value.replace("|", r"\7c")
|
||||||
|
value = value.replace("=", r"\3d")
|
||||||
|
value = value.replace(">", r"\3e")
|
||||||
|
value = value.replace("<", r"\3c")
|
||||||
|
value = value.replace("~", r"\7e")
|
||||||
|
value = value.replace("!", r"\21")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _get_attr_value(entry: Any, attr_name: str, default: str = "") -> str:
|
||||||
|
"""
|
||||||
|
提取 LDAP 条目属性的首个值,避免返回字符串化的列表表示。
|
||||||
|
"""
|
||||||
|
attr = getattr(entry, attr_name, None)
|
||||||
|
if not attr:
|
||||||
|
return default
|
||||||
|
# ldap3 的 EntryAttribute.value 已经是单值或列表,根据类型取首个
|
||||||
|
val = getattr(attr, "value", None)
|
||||||
|
if isinstance(val, list):
|
||||||
|
val = val[0] if val else default
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPService:
|
||||||
|
"""LDAP 认证服务"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_config(db: Session) -> Optional[LDAPConfig]:
|
||||||
|
"""获取 LDAP 配置"""
|
||||||
|
return db.query(LDAPConfig).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_ldap_enabled(db: Session) -> bool:
|
||||||
|
"""检查 LDAP 是否可用(已启用且绑定密码可解密)"""
|
||||||
|
return LDAPService.get_config_data(db) is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_ldap_exclusive(db: Session) -> bool:
|
||||||
|
"""检查是否仅允许 LDAP 登录(仅在 LDAP 可用时生效,避免误锁定)"""
|
||||||
|
config = LDAPService.get_config(db)
|
||||||
|
if not config or config.is_exclusive is not True:
|
||||||
|
return False
|
||||||
|
return LDAPService.get_config_data(db) is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_config_data(db: Session) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
提前获取并解密配置,供线程池使用,避免跨线程共享 Session。
|
||||||
|
"""
|
||||||
|
config = LDAPService.get_config(db)
|
||||||
|
if not config or config.is_enabled is not True:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
bind_password = config.get_bind_password()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LDAP 绑定密码解密失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 绑定密码为空时无法进行 LDAP 认证
|
||||||
|
if not bind_password:
|
||||||
|
logger.warning("LDAP 绑定密码未配置,无法进行 LDAP 认证")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"server_url": config.server_url,
|
||||||
|
"bind_dn": config.bind_dn,
|
||||||
|
"bind_password": bind_password,
|
||||||
|
"base_dn": config.base_dn,
|
||||||
|
"user_search_filter": config.user_search_filter,
|
||||||
|
"username_attr": config.username_attr,
|
||||||
|
"email_attr": config.email_attr,
|
||||||
|
"display_name_attr": config.display_name_attr,
|
||||||
|
"use_starttls": config.use_starttls,
|
||||||
|
"connect_timeout": config.connect_timeout or DEFAULT_LDAP_CONNECT_TIMEOUT,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def authenticate_with_config(config: Dict[str, Any], username: str, password: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
LDAP bind 验证
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: 已解密的 LDAP 配置
|
||||||
|
username: 用户名
|
||||||
|
password: 密码
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
用户属性 dict {username, email, display_name} 或 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import ldap3
|
||||||
|
from ldap3 import Server, Connection, SUBTREE
|
||||||
|
from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError
|
||||||
|
except ImportError:
|
||||||
|
logger.error("ldap3 库未安装")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
logger.warning("LDAP 未配置或未启用")
|
||||||
|
return None
|
||||||
|
|
||||||
|
admin_conn = None
|
||||||
|
user_conn = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 创建服务器连接
|
||||||
|
server_url = config["server_url"]
|
||||||
|
server_host, server_port, use_ssl = parse_ldap_server_url(server_url)
|
||||||
|
timeout = config.get("connect_timeout", DEFAULT_LDAP_CONNECT_TIMEOUT)
|
||||||
|
server = Server(
|
||||||
|
server_host,
|
||||||
|
port=server_port,
|
||||||
|
use_ssl=use_ssl,
|
||||||
|
get_info=ldap3.ALL,
|
||||||
|
connect_timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 使用管理员账号连接
|
||||||
|
bind_password = config["bind_password"]
|
||||||
|
admin_conn = Connection(
|
||||||
|
server,
|
||||||
|
user=config["bind_dn"],
|
||||||
|
password=bind_password,
|
||||||
|
receive_timeout=timeout, # 添加读取超时,避免服务器响应缓慢时阻塞
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.get("use_starttls") and not use_ssl:
|
||||||
|
admin_conn.start_tls()
|
||||||
|
|
||||||
|
if not admin_conn.bind():
|
||||||
|
logger.error(f"LDAP 管理员绑定失败: {admin_conn.result}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 搜索用户(转义用户名防止 LDAP 注入)
|
||||||
|
safe_username = escape_ldap_filter(username)
|
||||||
|
search_filter = config["user_search_filter"].replace("{username}", safe_username)
|
||||||
|
admin_conn.search(
|
||||||
|
search_base=config["base_dn"],
|
||||||
|
search_filter=search_filter,
|
||||||
|
search_scope=SUBTREE,
|
||||||
|
size_limit=2, # 防止过滤器误配导致匹配多用户
|
||||||
|
time_limit=timeout, # 添加搜索超时,防止大型目录搜索阻塞
|
||||||
|
attributes=[
|
||||||
|
config["username_attr"],
|
||||||
|
config["email_attr"],
|
||||||
|
config["display_name_attr"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(admin_conn.entries) != 1:
|
||||||
|
# 统一错误信息,避免泄露用户是否存在;日志仅记录结果数量,不泄露敏感信息
|
||||||
|
logger.warning(
|
||||||
|
f"LDAP 认证失败(用户查找阶段): 搜索返回 {len(admin_conn.entries)} 条结果"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_entry = admin_conn.entries[0]
|
||||||
|
user_dn = user_entry.entry_dn
|
||||||
|
|
||||||
|
# 用户密码验证
|
||||||
|
user_conn = Connection(
|
||||||
|
server,
|
||||||
|
user=user_dn,
|
||||||
|
password=password,
|
||||||
|
receive_timeout=timeout, # 添加读取超时
|
||||||
|
)
|
||||||
|
if config.get("use_starttls") and not use_ssl:
|
||||||
|
user_conn.start_tls()
|
||||||
|
|
||||||
|
if not user_conn.bind():
|
||||||
|
# 统一错误信息,避免泄露密码是否正确;日志仅记录错误码,不泄露用户 DN
|
||||||
|
bind_result = user_conn.result.get("description", "unknown")
|
||||||
|
logger.warning(f"LDAP 认证失败(密码验证阶段): {bind_result}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 提取用户属性(优先用 LDAP 提供的值,不合法则回退默认)
|
||||||
|
ldap_username = _get_attr_value(user_entry, config["username_attr"], username)
|
||||||
|
email = _get_attr_value(
|
||||||
|
user_entry, config["email_attr"], f"{username}@ldap.local"
|
||||||
|
)
|
||||||
|
display_name = _get_attr_value(user_entry, config["display_name_attr"], username)
|
||||||
|
|
||||||
|
logger.info(f"LDAP 认证成功: {username}")
|
||||||
|
return {
|
||||||
|
"username": ldap_username,
|
||||||
|
"ldap_username": ldap_username,
|
||||||
|
"ldap_dn": user_dn,
|
||||||
|
"email": email,
|
||||||
|
"display_name": display_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
except LDAPSocketOpenError as e:
|
||||||
|
logger.error(f"LDAP 服务器连接失败: {e}")
|
||||||
|
return None
|
||||||
|
except LDAPBindError as e:
|
||||||
|
logger.error(f"LDAP 绑定失败: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LDAP 认证异常: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
# 确保连接关闭,避免失败路径泄漏
|
||||||
|
# 使用循环确保即使第一个 unbind 失败,后续连接仍会尝试关闭
|
||||||
|
for conn, name in [(admin_conn, "admin"), (user_conn, "user")]:
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.unbind()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"LDAP {name} 连接关闭失败: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def test_connection_with_config(config: Dict[str, Any]) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
测试 LDAP 连接
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import ldap3
|
||||||
|
from ldap3 import Server, Connection
|
||||||
|
except ImportError:
|
||||||
|
return False, "ldap3 库未安装"
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
return False, "LDAP 配置不存在"
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
server_url = config["server_url"]
|
||||||
|
server_host, server_port, use_ssl = parse_ldap_server_url(server_url)
|
||||||
|
timeout = config.get("connect_timeout", DEFAULT_LDAP_CONNECT_TIMEOUT)
|
||||||
|
server = Server(
|
||||||
|
server_host,
|
||||||
|
port=server_port,
|
||||||
|
use_ssl=use_ssl,
|
||||||
|
get_info=ldap3.ALL,
|
||||||
|
connect_timeout=timeout,
|
||||||
|
)
|
||||||
|
bind_password = config["bind_password"]
|
||||||
|
conn = Connection(
|
||||||
|
server,
|
||||||
|
user=config["bind_dn"],
|
||||||
|
password=bind_password,
|
||||||
|
receive_timeout=timeout, # 添加读取超时
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.get("use_starttls") and not use_ssl:
|
||||||
|
conn.start_tls()
|
||||||
|
|
||||||
|
if not conn.bind():
|
||||||
|
return False, f"绑定失败: {conn.result}"
|
||||||
|
|
||||||
|
return True, "连接成功"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 记录详细错误到日志,但只返回通用信息给前端,避免泄露敏感信息
|
||||||
|
logger.error(f"LDAP 测试连接失败: {type(e).__name__}: {e}")
|
||||||
|
return False, "连接失败,请检查服务器地址、端口和凭据"
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
conn.unbind()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"LDAP 测试连接关闭失败: {e}")
|
||||||
|
|
||||||
|
# 兼容旧接口:如果其他代码直接调用
|
||||||
|
@staticmethod
|
||||||
|
def authenticate(db: Session, username: str, password: str) -> Optional[dict]:
|
||||||
|
config = LDAPService.get_config_data(db)
|
||||||
|
return LDAPService.authenticate_with_config(config, username, password) if config else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def test_connection(db: Session) -> Tuple[bool, str]:
|
||||||
|
config = LDAPService.get_config_data(db)
|
||||||
|
if not config:
|
||||||
|
return False, "LDAP 配置不存在或未启用"
|
||||||
|
return LDAPService.test_connection_with_config(config)
|
||||||
@@ -2,21 +2,25 @@
|
|||||||
认证服务
|
认证服务
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from src.config import config
|
from src.config import config
|
||||||
from src.core.crypto import crypto_service
|
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
from src.core.enums import AuthSource
|
||||||
from src.models.database import ApiKey, User, UserRole
|
from src.models.database import ApiKey, User, UserRole
|
||||||
from src.services.auth.jwt_blacklist import JWTBlacklistService
|
from src.services.auth.jwt_blacklist import JWTBlacklistService
|
||||||
|
from src.services.auth.ldap import LDAPService
|
||||||
from src.services.cache.user_cache import UserCacheService
|
from src.services.cache.user_cache import UserCacheService
|
||||||
from src.services.user.apikey import ApiKeyService
|
from src.services.user.apikey import ApiKeyService
|
||||||
|
|
||||||
@@ -92,15 +96,86 @@ class AuthService:
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的Token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的Token")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
|
async def authenticate_user(
|
||||||
"""用户登录认证"""
|
db: Session, email: str, password: str, auth_type: str = "local"
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""用户登录认证
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
email: 邮箱/用户名
|
||||||
|
password: 密码
|
||||||
|
auth_type: 认证类型 ("local" 或 "ldap")
|
||||||
|
"""
|
||||||
|
if auth_type == "ldap":
|
||||||
|
# LDAP 认证
|
||||||
|
# 预取配置,避免将 Session 传递到线程池
|
||||||
|
config_data = LDAPService.get_config_data(db)
|
||||||
|
if not config_data:
|
||||||
|
logger.warning("登录失败 - LDAP 未启用或配置无效")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 计算总体超时:LDAP 认证包含多次网络操作(连接、管理员绑定、搜索、用户绑定)
|
||||||
|
# 超时策略:
|
||||||
|
# - 单次操作超时(connect_timeout):控制每次网络操作的最大等待时间
|
||||||
|
# - 总体超时:防止异常场景(如服务器响应缓慢但未超时)导致请求堆积
|
||||||
|
# - 公式:单次超时 × 4(覆盖 4 次主要网络操作)+ 10% 缓冲
|
||||||
|
# - 最小 20 秒(保证基本操作),最大 60 秒(避免用户等待过长)
|
||||||
|
single_timeout = config_data.get("connect_timeout", 10)
|
||||||
|
total_timeout = max(20, min(int(single_timeout * 4 * 1.1), 60))
|
||||||
|
|
||||||
|
# 在线程池中执行阻塞的 LDAP 网络请求,避免阻塞事件循环
|
||||||
|
# 添加总体超时保护,防止异常场景下请求堆积
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
try:
|
||||||
|
ldap_user = await asyncio.wait_for(
|
||||||
|
run_in_threadpool(
|
||||||
|
LDAPService.authenticate_with_config, config_data, email, password
|
||||||
|
),
|
||||||
|
timeout=total_timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"LDAP 认证总体超时({total_timeout}秒): {email}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not ldap_user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 获取或创建本地用户
|
||||||
|
user = await AuthService._get_or_create_ldap_user(db, ldap_user)
|
||||||
|
if not user:
|
||||||
|
# 已有本地账号但来源不匹配等情况
|
||||||
|
return None
|
||||||
|
if not user.is_active:
|
||||||
|
logger.warning(f"登录失败 - 用户已禁用: {email}")
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
# 本地认证
|
||||||
# 登录校验必须读取密码哈希,不能使用不包含 password_hash 的缓存对象
|
# 登录校验必须读取密码哈希,不能使用不包含 password_hash 的缓存对象
|
||||||
user = db.query(User).filter(User.email == email).first()
|
# 支持邮箱或用户名登录
|
||||||
|
from sqlalchemy import or_
|
||||||
|
user = db.query(User).filter(
|
||||||
|
or_(User.email == email, User.username == email)
|
||||||
|
).first()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
logger.warning(f"登录失败 - 用户不存在: {email}")
|
logger.warning(f"登录失败 - 用户不存在: {email}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# 检查 LDAP exclusive 模式:仅允许本地管理员登录(紧急恢复通道)
|
||||||
|
if LDAPService.is_ldap_exclusive(db):
|
||||||
|
if user.role != UserRole.ADMIN or user.auth_source != AuthSource.LOCAL:
|
||||||
|
logger.warning(f"登录失败 - 仅允许 LDAP 登录(管理员除外): {email}")
|
||||||
|
return None
|
||||||
|
logger.warning(f"[LDAP-EXCLUSIVE] 紧急恢复通道:本地管理员登录: {email}")
|
||||||
|
|
||||||
|
# 检查用户认证来源
|
||||||
|
if user.auth_source == AuthSource.LDAP:
|
||||||
|
logger.warning(f"登录失败 - 该用户使用 LDAP 认证: {email}")
|
||||||
|
return None
|
||||||
|
|
||||||
if not user.verify_password(password):
|
if not user.verify_password(password):
|
||||||
logger.warning(f"登录失败 - 密码错误: {email}")
|
logger.warning(f"登录失败 - 密码错误: {email}")
|
||||||
return None
|
return None
|
||||||
@@ -118,6 +193,127 @@ class AuthService:
|
|||||||
logger.info(f"用户登录成功: {email} (ID: {user.id})")
|
logger.info(f"用户登录成功: {email} (ID: {user.id})")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _get_or_create_ldap_user(db: Session, ldap_user: dict) -> Optional[User]:
|
||||||
|
"""获取或创建 LDAP 用户
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ldap_user: LDAP 用户信息 {username, email, display_name, ldap_dn, ldap_username}
|
||||||
|
|
||||||
|
注意:使用 with_for_update() 防止并发首次登录创建重复用户
|
||||||
|
"""
|
||||||
|
ldap_dn = (ldap_user.get("ldap_dn") or "").strip() or None
|
||||||
|
ldap_username = (ldap_user.get("ldap_username") or ldap_user.get("username") or "").strip() or None
|
||||||
|
email = ldap_user["email"]
|
||||||
|
|
||||||
|
# 优先用稳定标识查找,避免邮箱变更/用户名冲突导致重复建号
|
||||||
|
# 使用 with_for_update() 锁定行,防止并发创建
|
||||||
|
user: Optional[User] = None
|
||||||
|
if ldap_dn:
|
||||||
|
user = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.auth_source == AuthSource.LDAP, User.ldap_dn == ldap_dn)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not user and ldap_username:
|
||||||
|
user = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.auth_source == AuthSource.LDAP, User.ldap_username == ldap_username)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
# 最后回退按 email 查找:如果存在同邮箱的本地账号,需要拒绝以避免接管
|
||||||
|
user = db.query(User).filter(User.email == email).with_for_update().first()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
if user.auth_source != AuthSource.LDAP:
|
||||||
|
# 避免覆盖已有本地账户(不同来源时拒绝登录)
|
||||||
|
logger.warning(
|
||||||
|
f"LDAP 登录拒绝 - 账户来源不匹配(现有:{user.auth_source}, 请求:LDAP): {email}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 同步邮箱(LDAP 侧邮箱变更时更新;若新邮箱已被占用则拒绝)
|
||||||
|
if user.email != email:
|
||||||
|
email_taken = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.email == email, User.id != user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if email_taken:
|
||||||
|
logger.warning(f"LDAP 登录拒绝 - 新邮箱已被占用: {email}")
|
||||||
|
return None
|
||||||
|
user.email = email
|
||||||
|
|
||||||
|
# 同步 LDAP 标识(首次填充或 LDAP 侧发生变化)
|
||||||
|
if ldap_dn and user.ldap_dn != ldap_dn:
|
||||||
|
user.ldap_dn = ldap_dn
|
||||||
|
if ldap_username and user.ldap_username != ldap_username:
|
||||||
|
user.ldap_username = ldap_username
|
||||||
|
|
||||||
|
user.last_login_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
await UserCacheService.invalidate_user_cache(user.id, user.email)
|
||||||
|
logger.info(f"LDAP 用户登录成功: {ldap_user['email']} (ID: {user.id})")
|
||||||
|
return user
|
||||||
|
|
||||||
|
# 检查 username 是否已被占用,使用时间戳+随机数确保唯一性
|
||||||
|
base_username = ldap_username or ldap_user["username"]
|
||||||
|
username = base_username
|
||||||
|
max_retries = 3
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
# 检查用户名是否已存在
|
||||||
|
existing_user_with_username = db.query(User).filter(User.username == username).first()
|
||||||
|
if existing_user_with_username:
|
||||||
|
# 如果 username 已存在,使用时间戳+随机数确保唯一性
|
||||||
|
username = f"{base_username}_ldap_{int(time.time())}{uuid.uuid4().hex[:4]}"
|
||||||
|
logger.info(f"LDAP 用户名冲突,使用新用户名: {ldap_user['username']} -> {username}")
|
||||||
|
|
||||||
|
# 创建新用户
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
username=username,
|
||||||
|
password_hash="", # LDAP 用户无本地密码
|
||||||
|
auth_source=AuthSource.LDAP,
|
||||||
|
ldap_dn=ldap_dn,
|
||||||
|
ldap_username=ldap_username,
|
||||||
|
role=UserRole.USER,
|
||||||
|
is_active=True,
|
||||||
|
last_login_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
logger.info(f"LDAP 用户创建成功: {ldap_user['email']} (ID: {user.id})")
|
||||||
|
return user
|
||||||
|
except IntegrityError as e:
|
||||||
|
db.rollback()
|
||||||
|
error_str = str(e.orig).lower() if e.orig else str(e).lower()
|
||||||
|
|
||||||
|
# 解析具体冲突类型
|
||||||
|
if "email" in error_str or "ix_users_email" in error_str:
|
||||||
|
# 邮箱冲突不应重试(前面已检查过,说明是并发创建)
|
||||||
|
logger.error(f"LDAP 用户创建失败 - 邮箱并发冲突: {email}")
|
||||||
|
return None
|
||||||
|
elif "username" in error_str or "ix_users_username" in error_str:
|
||||||
|
# 用户名冲突,重试时会生成新用户名
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
logger.error(f"LDAP 用户创建失败(用户名冲突重试耗尽): {username}")
|
||||||
|
return None
|
||||||
|
username = f"{base_username}_ldap_{int(time.time())}{uuid.uuid4().hex[:4]}"
|
||||||
|
logger.warning(f"LDAP 用户创建用户名冲突,重试 ({attempt + 1}/{max_retries}): {username}")
|
||||||
|
else:
|
||||||
|
# 其他约束冲突,不重试
|
||||||
|
logger.error(f"LDAP 用户创建失败 - 未知数据库约束冲突: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def authenticate_api_key(db: Session, api_key: str) -> Optional[tuple[User, ApiKey]]:
|
def authenticate_api_key(db: Session, api_key: str) -> Optional[tuple[User, ApiKey]]:
|
||||||
"""API密钥认证"""
|
"""API密钥认证"""
|
||||||
|
|||||||
51
src/services/billing/__init__.py
Normal file
51
src/services/billing/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
计费模块
|
||||||
|
|
||||||
|
提供配置驱动的计费计算,支持不同厂商的差异化计费模式:
|
||||||
|
- Claude: input + output + cache_creation + cache_read
|
||||||
|
- OpenAI: input + output + cache_read (无缓存创建费用)
|
||||||
|
- 豆包: input + output + cache_read + cache_storage (缓存按时计费)
|
||||||
|
- 按次计费: per_request
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
from src.services.billing import BillingCalculator, UsageMapper, StandardizedUsage
|
||||||
|
|
||||||
|
# 1. 将原始 usage 映射为标准格式
|
||||||
|
usage = UsageMapper.map(raw_usage, api_format="OPENAI")
|
||||||
|
|
||||||
|
# 2. 使用计费计算器计算费用
|
||||||
|
calculator = BillingCalculator(template="openai")
|
||||||
|
result = calculator.calculate(usage, prices)
|
||||||
|
|
||||||
|
# 3. 获取费用明细
|
||||||
|
print(result.total_cost)
|
||||||
|
print(result.costs) # {"input": 0.01, "output": 0.02, ...}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.services.billing.calculator import BillingCalculator, calculate_request_cost
|
||||||
|
from src.services.billing.models import (
|
||||||
|
BillingDimension,
|
||||||
|
BillingUnit,
|
||||||
|
CostBreakdown,
|
||||||
|
StandardizedUsage,
|
||||||
|
)
|
||||||
|
from src.services.billing.templates import BILLING_TEMPLATE_REGISTRY, BillingTemplates
|
||||||
|
from src.services.billing.usage_mapper import UsageMapper, map_usage, map_usage_from_response
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# 数据模型
|
||||||
|
"BillingDimension",
|
||||||
|
"BillingUnit",
|
||||||
|
"CostBreakdown",
|
||||||
|
"StandardizedUsage",
|
||||||
|
# 模板
|
||||||
|
"BillingTemplates",
|
||||||
|
"BILLING_TEMPLATE_REGISTRY",
|
||||||
|
# 计算器
|
||||||
|
"BillingCalculator",
|
||||||
|
"calculate_request_cost",
|
||||||
|
# 映射器
|
||||||
|
"UsageMapper",
|
||||||
|
"map_usage",
|
||||||
|
"map_usage_from_response",
|
||||||
|
]
|
||||||
339
src/services/billing/calculator.py
Normal file
339
src/services/billing/calculator.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
"""
|
||||||
|
计费计算器
|
||||||
|
|
||||||
|
配置驱动的计费计算,支持:
|
||||||
|
- 固定价格计费
|
||||||
|
- 阶梯计费
|
||||||
|
- 多种计费模板
|
||||||
|
- 自定义计费维度
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from src.services.billing.models import (
|
||||||
|
BillingDimension,
|
||||||
|
BillingUnit,
|
||||||
|
CostBreakdown,
|
||||||
|
StandardizedUsage,
|
||||||
|
)
|
||||||
|
from src.services.billing.templates import (
|
||||||
|
BILLING_TEMPLATE_REGISTRY,
|
||||||
|
BillingTemplates,
|
||||||
|
get_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingCalculator:
|
||||||
|
"""
|
||||||
|
配置驱动的计费计算器
|
||||||
|
|
||||||
|
支持多种计费模式:
|
||||||
|
- 使用预定义模板(claude, openai, doubao 等)
|
||||||
|
- 自定义计费维度
|
||||||
|
- 阶梯计费
|
||||||
|
|
||||||
|
示例:
|
||||||
|
# 使用模板
|
||||||
|
calculator = BillingCalculator(template="openai")
|
||||||
|
|
||||||
|
# 自定义维度
|
||||||
|
calculator = BillingCalculator(dimensions=[
|
||||||
|
BillingDimension(name="input", usage_field="input_tokens", price_field="input_price_per_1m"),
|
||||||
|
BillingDimension(name="output", usage_field="output_tokens", price_field="output_price_per_1m"),
|
||||||
|
])
|
||||||
|
|
||||||
|
# 计算费用
|
||||||
|
usage = StandardizedUsage(input_tokens=1000, output_tokens=500)
|
||||||
|
prices = {"input_price_per_1m": 3.0, "output_price_per_1m": 15.0}
|
||||||
|
result = calculator.calculate(usage, prices)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dimensions: Optional[List[BillingDimension]] = None,
|
||||||
|
template: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化计费计算器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dimensions: 自定义计费维度列表(优先级高于模板)
|
||||||
|
template: 使用预定义模板名称 ("claude", "openai", "doubao", "per_request" 等)
|
||||||
|
"""
|
||||||
|
if dimensions:
|
||||||
|
self.dimensions = dimensions
|
||||||
|
elif template:
|
||||||
|
self.dimensions = get_template(template)
|
||||||
|
else:
|
||||||
|
# 默认使用 Claude 模板(向后兼容)
|
||||||
|
self.dimensions = BillingTemplates.CLAUDE_STANDARD
|
||||||
|
|
||||||
|
self.template_name = template
|
||||||
|
|
||||||
|
def calculate(
|
||||||
|
self,
|
||||||
|
usage: StandardizedUsage,
|
||||||
|
prices: Dict[str, float],
|
||||||
|
tiered_pricing: Optional[Dict[str, Any]] = None,
|
||||||
|
cache_ttl_minutes: Optional[int] = None,
|
||||||
|
total_input_context: Optional[int] = None,
|
||||||
|
) -> CostBreakdown:
|
||||||
|
"""
|
||||||
|
计算费用
|
||||||
|
|
||||||
|
Args:
|
||||||
|
usage: 标准化的 usage 数据
|
||||||
|
prices: 价格配置 {"input_price_per_1m": 3.0, "output_price_per_1m": 15.0, ...}
|
||||||
|
tiered_pricing: 阶梯计费配置(可选)
|
||||||
|
cache_ttl_minutes: 缓存 TTL 分钟数(用于 TTL 差异化定价)
|
||||||
|
total_input_context: 总输入上下文(用于阶梯判定,可选)
|
||||||
|
如果提供,将使用该值进行阶梯判定;否则使用默认计算逻辑
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
费用明细 (CostBreakdown)
|
||||||
|
"""
|
||||||
|
result = CostBreakdown()
|
||||||
|
|
||||||
|
# 处理阶梯计费
|
||||||
|
effective_prices = prices.copy()
|
||||||
|
if tiered_pricing and tiered_pricing.get("tiers"):
|
||||||
|
tier, tier_index = self._get_tier(usage, tiered_pricing, total_input_context)
|
||||||
|
if tier:
|
||||||
|
result.tier_index = tier_index
|
||||||
|
# 阶梯价格覆盖默认价格
|
||||||
|
for key, value in tier.items():
|
||||||
|
if key not in ("up_to", "cache_ttl_pricing") and value is not None:
|
||||||
|
effective_prices[key] = value
|
||||||
|
|
||||||
|
# 处理 TTL 差异化定价
|
||||||
|
if cache_ttl_minutes is not None:
|
||||||
|
ttl_price = self._get_cache_read_price_for_ttl(tier, cache_ttl_minutes)
|
||||||
|
if ttl_price is not None:
|
||||||
|
effective_prices["cache_read_price_per_1m"] = ttl_price
|
||||||
|
|
||||||
|
# 记录使用的价格
|
||||||
|
result.effective_prices = effective_prices.copy()
|
||||||
|
|
||||||
|
# 计算各维度费用
|
||||||
|
total = 0.0
|
||||||
|
for dim in self.dimensions:
|
||||||
|
usage_value = usage.get(dim.usage_field, 0)
|
||||||
|
price = effective_prices.get(dim.price_field, dim.default_price)
|
||||||
|
|
||||||
|
if usage_value and price:
|
||||||
|
cost = dim.calculate(usage_value, price)
|
||||||
|
result.costs[dim.name] = cost
|
||||||
|
total += cost
|
||||||
|
|
||||||
|
result.total_cost = total
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _get_tier(
|
||||||
|
self,
|
||||||
|
usage: StandardizedUsage,
|
||||||
|
tiered_pricing: Dict[str, Any],
|
||||||
|
total_input_context: Optional[int] = None,
|
||||||
|
) -> Tuple[Optional[Dict[str, Any]], Optional[int]]:
|
||||||
|
"""
|
||||||
|
确定价格阶梯
|
||||||
|
|
||||||
|
Args:
|
||||||
|
usage: usage 数据
|
||||||
|
tiered_pricing: 阶梯配置 {"tiers": [...]}
|
||||||
|
total_input_context: 预计算的总输入上下文(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(匹配的阶梯配置, 阶梯索引)
|
||||||
|
"""
|
||||||
|
tiers = tiered_pricing.get("tiers", [])
|
||||||
|
if not tiers:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# 使用传入的 total_input_context,或者默认计算
|
||||||
|
if total_input_context is None:
|
||||||
|
total_input_context = self._compute_total_input_context(usage)
|
||||||
|
|
||||||
|
for i, tier in enumerate(tiers):
|
||||||
|
up_to = tier.get("up_to")
|
||||||
|
# up_to 为 None 表示无上限(最后一个阶梯)
|
||||||
|
if up_to is None or total_input_context <= up_to:
|
||||||
|
return tier, i
|
||||||
|
|
||||||
|
# 如果所有阶梯都有上限且都超过了,返回最后一个阶梯
|
||||||
|
return tiers[-1], len(tiers) - 1
|
||||||
|
|
||||||
|
def _compute_total_input_context(self, usage: StandardizedUsage) -> int:
|
||||||
|
"""
|
||||||
|
计算总输入上下文(用于阶梯计费判定)
|
||||||
|
|
||||||
|
默认: input_tokens + cache_read_tokens
|
||||||
|
|
||||||
|
Args:
|
||||||
|
usage: usage 数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
总输入 token 数
|
||||||
|
"""
|
||||||
|
return usage.input_tokens + usage.cache_read_tokens
|
||||||
|
|
||||||
|
def _get_cache_read_price_for_ttl(
|
||||||
|
self,
|
||||||
|
tier: Dict[str, Any],
|
||||||
|
cache_ttl_minutes: int,
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
根据缓存 TTL 获取缓存读取价格
|
||||||
|
|
||||||
|
某些厂商(如 Claude)对不同 TTL 的缓存有不同定价。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tier: 当前阶梯配置
|
||||||
|
cache_ttl_minutes: 缓存时长(分钟)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
缓存读取价格,如果没有 TTL 差异化配置返回 None
|
||||||
|
"""
|
||||||
|
ttl_pricing = tier.get("cache_ttl_pricing")
|
||||||
|
if not ttl_pricing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 找到匹配或最接近的 TTL 价格
|
||||||
|
for ttl_config in ttl_pricing:
|
||||||
|
ttl_limit = ttl_config.get("ttl_minutes", 0)
|
||||||
|
if cache_ttl_minutes <= ttl_limit:
|
||||||
|
price = ttl_config.get("cache_read_price_per_1m")
|
||||||
|
return float(price) if price is not None else None
|
||||||
|
|
||||||
|
# 超过所有配置的 TTL,使用最后一个
|
||||||
|
if ttl_pricing:
|
||||||
|
price = ttl_pricing[-1].get("cache_read_price_per_1m")
|
||||||
|
return float(price) if price is not None else None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, config: Dict[str, Any]) -> "BillingCalculator":
|
||||||
|
"""
|
||||||
|
从配置创建计费计算器
|
||||||
|
|
||||||
|
Config 格式:
|
||||||
|
{
|
||||||
|
"template": "claude", # 或 "openai", "doubao", "per_request"
|
||||||
|
# 或者自定义维度:
|
||||||
|
"dimensions": [
|
||||||
|
{"name": "input", "usage_field": "input_tokens", "price_field": "input_price_per_1m"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: 配置字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BillingCalculator 实例
|
||||||
|
"""
|
||||||
|
if "dimensions" in config:
|
||||||
|
dimensions = [BillingDimension.from_dict(d) for d in config["dimensions"]]
|
||||||
|
return cls(dimensions=dimensions)
|
||||||
|
|
||||||
|
return cls(template=config.get("template", "claude"))
|
||||||
|
|
||||||
|
def get_dimension_names(self) -> List[str]:
|
||||||
|
"""获取所有计费维度名称"""
|
||||||
|
return [dim.name for dim in self.dimensions]
|
||||||
|
|
||||||
|
def get_required_price_fields(self) -> List[str]:
|
||||||
|
"""获取所需的价格字段名称"""
|
||||||
|
return [dim.price_field for dim in self.dimensions]
|
||||||
|
|
||||||
|
def get_required_usage_fields(self) -> List[str]:
|
||||||
|
"""获取所需的 usage 字段名称"""
|
||||||
|
return [dim.usage_field for dim in self.dimensions]
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_request_cost(
|
||||||
|
input_tokens: int,
|
||||||
|
output_tokens: int,
|
||||||
|
cache_creation_input_tokens: int,
|
||||||
|
cache_read_input_tokens: int,
|
||||||
|
input_price_per_1m: float,
|
||||||
|
output_price_per_1m: float,
|
||||||
|
cache_creation_price_per_1m: Optional[float],
|
||||||
|
cache_read_price_per_1m: Optional[float],
|
||||||
|
price_per_request: Optional[float],
|
||||||
|
tiered_pricing: Optional[Dict[str, Any]] = None,
|
||||||
|
cache_ttl_minutes: Optional[int] = None,
|
||||||
|
total_input_context: Optional[int] = None,
|
||||||
|
billing_template: str = "claude",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
计算请求成本的便捷函数
|
||||||
|
|
||||||
|
封装了 BillingCalculator 的调用逻辑,返回兼容旧格式的字典。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_tokens: 输入 token 数
|
||||||
|
output_tokens: 输出 token 数
|
||||||
|
cache_creation_input_tokens: 缓存创建 token 数
|
||||||
|
cache_read_input_tokens: 缓存读取 token 数
|
||||||
|
input_price_per_1m: 输入价格(每 1M tokens)
|
||||||
|
output_price_per_1m: 输出价格(每 1M tokens)
|
||||||
|
cache_creation_price_per_1m: 缓存创建价格(每 1M tokens)
|
||||||
|
cache_read_price_per_1m: 缓存读取价格(每 1M tokens)
|
||||||
|
price_per_request: 按次计费价格
|
||||||
|
tiered_pricing: 阶梯计费配置
|
||||||
|
cache_ttl_minutes: 缓存时长(分钟)
|
||||||
|
total_input_context: 总输入上下文(用于阶梯判定)
|
||||||
|
billing_template: 计费模板名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含各项成本的字典:
|
||||||
|
{
|
||||||
|
"input_cost": float,
|
||||||
|
"output_cost": float,
|
||||||
|
"cache_creation_cost": float,
|
||||||
|
"cache_read_cost": float,
|
||||||
|
"cache_cost": float,
|
||||||
|
"request_cost": float,
|
||||||
|
"total_cost": float,
|
||||||
|
"tier_index": Optional[int],
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 构建标准化 usage
|
||||||
|
usage = StandardizedUsage(
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=output_tokens,
|
||||||
|
cache_creation_tokens=cache_creation_input_tokens,
|
||||||
|
cache_read_tokens=cache_read_input_tokens,
|
||||||
|
request_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建价格配置
|
||||||
|
prices: Dict[str, float] = {
|
||||||
|
"input_price_per_1m": input_price_per_1m,
|
||||||
|
"output_price_per_1m": output_price_per_1m,
|
||||||
|
}
|
||||||
|
if cache_creation_price_per_1m is not None:
|
||||||
|
prices["cache_creation_price_per_1m"] = cache_creation_price_per_1m
|
||||||
|
if cache_read_price_per_1m is not None:
|
||||||
|
prices["cache_read_price_per_1m"] = cache_read_price_per_1m
|
||||||
|
if price_per_request is not None:
|
||||||
|
prices["price_per_request"] = price_per_request
|
||||||
|
|
||||||
|
# 使用 BillingCalculator 计算
|
||||||
|
calculator = BillingCalculator(template=billing_template)
|
||||||
|
result = calculator.calculate(
|
||||||
|
usage, prices, tiered_pricing, cache_ttl_minutes, total_input_context
|
||||||
|
)
|
||||||
|
|
||||||
|
# 返回兼容旧格式的字典
|
||||||
|
return {
|
||||||
|
"input_cost": result.input_cost,
|
||||||
|
"output_cost": result.output_cost,
|
||||||
|
"cache_creation_cost": result.cache_creation_cost,
|
||||||
|
"cache_read_cost": result.cache_read_cost,
|
||||||
|
"cache_cost": result.cache_cost,
|
||||||
|
"request_cost": result.request_cost,
|
||||||
|
"total_cost": result.total_cost,
|
||||||
|
"tier_index": result.tier_index,
|
||||||
|
}
|
||||||
281
src/services/billing/models.py
Normal file
281
src/services/billing/models.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""
|
||||||
|
计费模块数据模型
|
||||||
|
|
||||||
|
定义计费相关的核心数据结构:
|
||||||
|
- BillingUnit: 计费单位枚举
|
||||||
|
- BillingDimension: 计费维度定义
|
||||||
|
- StandardizedUsage: 标准化的 usage 数据
|
||||||
|
- CostBreakdown: 计费明细结果
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class BillingUnit(str, Enum):
|
||||||
|
"""计费单位"""
|
||||||
|
|
||||||
|
PER_1M_TOKENS = "per_1m_tokens" # 每百万 token
|
||||||
|
PER_1M_TOKENS_HOUR = "per_1m_tokens_hour" # 每百万 token 每小时(豆包缓存存储)
|
||||||
|
PER_REQUEST = "per_request" # 每次请求
|
||||||
|
FIXED = "fixed" # 固定费用
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BillingDimension:
|
||||||
|
"""
|
||||||
|
计费维度定义
|
||||||
|
|
||||||
|
每个维度描述一种计费方式,例如:
|
||||||
|
- 输入 token 计费
|
||||||
|
- 输出 token 计费
|
||||||
|
- 缓存读取计费
|
||||||
|
- 按次计费
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str # 维度名称,如 "input", "output", "cache_read"
|
||||||
|
usage_field: str # 从 usage 中取值的字段名
|
||||||
|
price_field: str # 价格配置中的字段名
|
||||||
|
unit: BillingUnit = BillingUnit.PER_1M_TOKENS # 计费单位
|
||||||
|
default_price: float = 0.0 # 默认价格(当价格配置中没有时使用)
|
||||||
|
|
||||||
|
def calculate(self, usage_value: float, price: float) -> float:
|
||||||
|
"""
|
||||||
|
计算该维度的费用
|
||||||
|
|
||||||
|
Args:
|
||||||
|
usage_value: 使用量数值
|
||||||
|
price: 单价
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
计算后的费用
|
||||||
|
"""
|
||||||
|
if usage_value <= 0 or price <= 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if self.unit == BillingUnit.PER_1M_TOKENS:
|
||||||
|
return (usage_value / 1_000_000) * price
|
||||||
|
elif self.unit == BillingUnit.PER_1M_TOKENS_HOUR:
|
||||||
|
# 缓存存储按 token 数 * 小时数计费
|
||||||
|
return (usage_value / 1_000_000) * price
|
||||||
|
elif self.unit == BillingUnit.PER_REQUEST:
|
||||||
|
return usage_value * price
|
||||||
|
elif self.unit == BillingUnit.FIXED:
|
||||||
|
return price
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""转换为字典(用于序列化)"""
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"usage_field": self.usage_field,
|
||||||
|
"price_field": self.price_field,
|
||||||
|
"unit": self.unit.value,
|
||||||
|
"default_price": self.default_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "BillingDimension":
|
||||||
|
"""从字典创建实例"""
|
||||||
|
return cls(
|
||||||
|
name=data["name"],
|
||||||
|
usage_field=data["usage_field"],
|
||||||
|
price_field=data["price_field"],
|
||||||
|
unit=BillingUnit(data.get("unit", "per_1m_tokens")),
|
||||||
|
default_price=data.get("default_price", 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StandardizedUsage:
|
||||||
|
"""
|
||||||
|
标准化的 Usage 数据
|
||||||
|
|
||||||
|
将不同 API 格式的 usage 统一为标准格式,便于计费计算。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 基础 token 计数
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
|
||||||
|
# 缓存相关
|
||||||
|
cache_creation_tokens: int = 0 # Claude: 缓存创建
|
||||||
|
cache_read_tokens: int = 0 # Claude/OpenAI/豆包: 缓存读取/命中
|
||||||
|
|
||||||
|
# 特殊 token 类型
|
||||||
|
reasoning_tokens: int = 0 # o1/豆包: 推理 token(通常包含在 output 中,单独记录用于分析)
|
||||||
|
|
||||||
|
# 时间相关(用于按时计费)
|
||||||
|
cache_storage_token_hours: float = 0.0 # 豆包: 缓存存储 token*小时
|
||||||
|
|
||||||
|
# 请求计数(用于按次计费)
|
||||||
|
request_count: int = 1
|
||||||
|
|
||||||
|
# 扩展字段(未来可能需要的额外维度)
|
||||||
|
extra: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def get(self, field_name: str, default: Any = 0) -> Any:
|
||||||
|
"""
|
||||||
|
通用字段获取
|
||||||
|
|
||||||
|
支持获取标准字段和扩展字段。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_name: 字段名
|
||||||
|
default: 默认值
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
字段值
|
||||||
|
"""
|
||||||
|
if hasattr(self, field_name):
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
# 对于 extra 字段,不直接返回
|
||||||
|
if field_name != "extra":
|
||||||
|
return value
|
||||||
|
return self.extra.get(field_name, default)
|
||||||
|
|
||||||
|
def set(self, field_name: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
通用字段设置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_name: 字段名
|
||||||
|
value: 字段值
|
||||||
|
"""
|
||||||
|
if hasattr(self, field_name) and field_name != "extra":
|
||||||
|
setattr(self, field_name, value)
|
||||||
|
else:
|
||||||
|
self.extra[field_name] = value
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""转换为字典"""
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
"input_tokens": self.input_tokens,
|
||||||
|
"output_tokens": self.output_tokens,
|
||||||
|
"cache_creation_tokens": self.cache_creation_tokens,
|
||||||
|
"cache_read_tokens": self.cache_read_tokens,
|
||||||
|
"reasoning_tokens": self.reasoning_tokens,
|
||||||
|
"cache_storage_token_hours": self.cache_storage_token_hours,
|
||||||
|
"request_count": self.request_count,
|
||||||
|
}
|
||||||
|
if self.extra:
|
||||||
|
result["extra"] = self.extra
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "StandardizedUsage":
|
||||||
|
"""从字典创建实例"""
|
||||||
|
extra = data.pop("extra", {}) if "extra" in data else {}
|
||||||
|
# 只取已知字段
|
||||||
|
known_fields = {
|
||||||
|
"input_tokens",
|
||||||
|
"output_tokens",
|
||||||
|
"cache_creation_tokens",
|
||||||
|
"cache_read_tokens",
|
||||||
|
"reasoning_tokens",
|
||||||
|
"cache_storage_token_hours",
|
||||||
|
"request_count",
|
||||||
|
}
|
||||||
|
filtered = {k: v for k, v in data.items() if k in known_fields}
|
||||||
|
return cls(**filtered, extra=extra)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CostBreakdown:
|
||||||
|
"""
|
||||||
|
计费明细结果
|
||||||
|
|
||||||
|
包含各维度的费用和总费用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 各维度费用 {"input": 0.01, "output": 0.02, "cache_read": 0.001, ...}
|
||||||
|
costs: Dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# 总费用
|
||||||
|
total_cost: float = 0.0
|
||||||
|
|
||||||
|
# 命中的阶梯索引(如果使用阶梯计费)
|
||||||
|
tier_index: Optional[int] = None
|
||||||
|
|
||||||
|
# 货币单位
|
||||||
|
currency: str = "USD"
|
||||||
|
|
||||||
|
# 使用的价格(用于记录和审计)
|
||||||
|
effective_prices: Dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 兼容旧接口的属性(便于渐进式迁移)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_cost(self) -> float:
|
||||||
|
"""输入费用"""
|
||||||
|
return self.costs.get("input", 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def output_cost(self) -> float:
|
||||||
|
"""输出费用"""
|
||||||
|
return self.costs.get("output", 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache_creation_cost(self) -> float:
|
||||||
|
"""缓存创建费用"""
|
||||||
|
return self.costs.get("cache_creation", 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache_read_cost(self) -> float:
|
||||||
|
"""缓存读取费用"""
|
||||||
|
return self.costs.get("cache_read", 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache_cost(self) -> float:
|
||||||
|
"""总缓存费用(创建 + 读取)"""
|
||||||
|
return self.cache_creation_cost + self.cache_read_cost
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_cost(self) -> float:
|
||||||
|
"""按次计费费用"""
|
||||||
|
return self.costs.get("request", 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache_storage_cost(self) -> float:
|
||||||
|
"""缓存存储费用(豆包等)"""
|
||||||
|
return self.costs.get("cache_storage", 0.0)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""转换为字典"""
|
||||||
|
return {
|
||||||
|
"costs": self.costs,
|
||||||
|
"total_cost": self.total_cost,
|
||||||
|
"tier_index": self.tier_index,
|
||||||
|
"currency": self.currency,
|
||||||
|
"effective_prices": self.effective_prices,
|
||||||
|
# 兼容字段
|
||||||
|
"input_cost": self.input_cost,
|
||||||
|
"output_cost": self.output_cost,
|
||||||
|
"cache_creation_cost": self.cache_creation_cost,
|
||||||
|
"cache_read_cost": self.cache_read_cost,
|
||||||
|
"cache_cost": self.cache_cost,
|
||||||
|
"request_cost": self.request_cost,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_legacy_tuple(self) -> tuple:
|
||||||
|
"""
|
||||||
|
转换为旧接口的元组格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(input_cost, output_cost, cache_creation_cost, cache_read_cost,
|
||||||
|
cache_cost, request_cost, total_cost, tier_index)
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.input_cost,
|
||||||
|
self.output_cost,
|
||||||
|
self.cache_creation_cost,
|
||||||
|
self.cache_read_cost,
|
||||||
|
self.cache_cost,
|
||||||
|
self.request_cost,
|
||||||
|
self.total_cost,
|
||||||
|
self.tier_index,
|
||||||
|
)
|
||||||
213
src/services/billing/templates.py
Normal file
213
src/services/billing/templates.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
预定义计费模板
|
||||||
|
|
||||||
|
提供常见厂商的计费配置模板,避免重复配置:
|
||||||
|
- CLAUDE_STANDARD: Claude/Anthropic 标准计费
|
||||||
|
- OPENAI_STANDARD: OpenAI 标准计费
|
||||||
|
- DOUBAO_STANDARD: 豆包计费(含缓存存储)
|
||||||
|
- GEMINI_STANDARD: Gemini 标准计费
|
||||||
|
- PER_REQUEST: 按次计费
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from src.services.billing.models import BillingDimension, BillingUnit
|
||||||
|
|
||||||
|
|
||||||
|
class BillingTemplates:
|
||||||
|
"""预定义的计费模板"""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Claude/Anthropic 标准计费
|
||||||
|
# - 输入 token
|
||||||
|
# - 输出 token
|
||||||
|
# - 缓存创建(创建时收费,约 1.25x 输入价格)
|
||||||
|
# - 缓存读取(约 0.1x 输入价格)
|
||||||
|
# =========================================================================
|
||||||
|
CLAUDE_STANDARD: List[BillingDimension] = [
|
||||||
|
BillingDimension(
|
||||||
|
name="input",
|
||||||
|
usage_field="input_tokens",
|
||||||
|
price_field="input_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="output",
|
||||||
|
usage_field="output_tokens",
|
||||||
|
price_field="output_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="cache_creation",
|
||||||
|
usage_field="cache_creation_tokens",
|
||||||
|
price_field="cache_creation_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="cache_read",
|
||||||
|
usage_field="cache_read_tokens",
|
||||||
|
price_field="cache_read_price_per_1m",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# OpenAI 标准计费
|
||||||
|
# - 输入 token
|
||||||
|
# - 输出 token
|
||||||
|
# - 缓存读取(部分模型支持,无缓存创建费用)
|
||||||
|
# =========================================================================
|
||||||
|
OPENAI_STANDARD: List[BillingDimension] = [
|
||||||
|
BillingDimension(
|
||||||
|
name="input",
|
||||||
|
usage_field="input_tokens",
|
||||||
|
price_field="input_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="output",
|
||||||
|
usage_field="output_tokens",
|
||||||
|
price_field="output_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="cache_read",
|
||||||
|
usage_field="cache_read_tokens",
|
||||||
|
price_field="cache_read_price_per_1m",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 豆包计费
|
||||||
|
# - 推理输入 (input_tokens)
|
||||||
|
# - 推理输出 (output_tokens)
|
||||||
|
# - 缓存命中 (cache_read_tokens) - 类似 Claude 的缓存读取
|
||||||
|
# - 缓存存储 (cache_storage_token_hours) - 按 token 数 * 存储时长计费
|
||||||
|
#
|
||||||
|
# 注意:豆包的缓存创建是免费的,但存储需要按时付费
|
||||||
|
# =========================================================================
|
||||||
|
DOUBAO_STANDARD: List[BillingDimension] = [
|
||||||
|
BillingDimension(
|
||||||
|
name="input",
|
||||||
|
usage_field="input_tokens",
|
||||||
|
price_field="input_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="output",
|
||||||
|
usage_field="output_tokens",
|
||||||
|
price_field="output_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="cache_read",
|
||||||
|
usage_field="cache_read_tokens",
|
||||||
|
price_field="cache_read_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="cache_storage",
|
||||||
|
usage_field="cache_storage_token_hours",
|
||||||
|
price_field="cache_storage_price_per_1m_hour",
|
||||||
|
unit=BillingUnit.PER_1M_TOKENS_HOUR,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Gemini 标准计费
|
||||||
|
# - 输入 token
|
||||||
|
# - 输出 token
|
||||||
|
# - 缓存读取
|
||||||
|
# =========================================================================
|
||||||
|
GEMINI_STANDARD: List[BillingDimension] = [
|
||||||
|
BillingDimension(
|
||||||
|
name="input",
|
||||||
|
usage_field="input_tokens",
|
||||||
|
price_field="input_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="output",
|
||||||
|
usage_field="output_tokens",
|
||||||
|
price_field="output_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="cache_read",
|
||||||
|
usage_field="cache_read_tokens",
|
||||||
|
price_field="cache_read_price_per_1m",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 按次计费
|
||||||
|
# - 适用于某些图片生成模型、特殊 API 等
|
||||||
|
# - 仅按请求次数计费,不按 token 计费
|
||||||
|
# =========================================================================
|
||||||
|
PER_REQUEST: List[BillingDimension] = [
|
||||||
|
BillingDimension(
|
||||||
|
name="request",
|
||||||
|
usage_field="request_count",
|
||||||
|
price_field="price_per_request",
|
||||||
|
unit=BillingUnit.PER_REQUEST,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 混合计费(按次 + 按 token)
|
||||||
|
# - 某些模型既有固定费用又有 token 费用
|
||||||
|
# =========================================================================
|
||||||
|
HYBRID_STANDARD: List[BillingDimension] = [
|
||||||
|
BillingDimension(
|
||||||
|
name="input",
|
||||||
|
usage_field="input_tokens",
|
||||||
|
price_field="input_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="output",
|
||||||
|
usage_field="output_tokens",
|
||||||
|
price_field="output_price_per_1m",
|
||||||
|
),
|
||||||
|
BillingDimension(
|
||||||
|
name="request",
|
||||||
|
usage_field="request_count",
|
||||||
|
price_field="price_per_request",
|
||||||
|
unit=BillingUnit.PER_REQUEST,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 模板注册表
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
BILLING_TEMPLATE_REGISTRY: Dict[str, List[BillingDimension]] = {
|
||||||
|
# 按厂商名称
|
||||||
|
"claude": BillingTemplates.CLAUDE_STANDARD,
|
||||||
|
"anthropic": BillingTemplates.CLAUDE_STANDARD,
|
||||||
|
"openai": BillingTemplates.OPENAI_STANDARD,
|
||||||
|
"doubao": BillingTemplates.DOUBAO_STANDARD,
|
||||||
|
"bytedance": BillingTemplates.DOUBAO_STANDARD,
|
||||||
|
"gemini": BillingTemplates.GEMINI_STANDARD,
|
||||||
|
"google": BillingTemplates.GEMINI_STANDARD,
|
||||||
|
# 按计费模式
|
||||||
|
"per_request": BillingTemplates.PER_REQUEST,
|
||||||
|
"hybrid": BillingTemplates.HYBRID_STANDARD,
|
||||||
|
# 默认
|
||||||
|
"default": BillingTemplates.CLAUDE_STANDARD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_template(name: Optional[str]) -> List[BillingDimension]:
|
||||||
|
"""
|
||||||
|
获取计费模板
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 模板名称(不区分大小写)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
计费维度列表
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return BILLING_TEMPLATE_REGISTRY["default"]
|
||||||
|
|
||||||
|
template = BILLING_TEMPLATE_REGISTRY.get(name.lower())
|
||||||
|
if template is None:
|
||||||
|
available = ", ".join(sorted(BILLING_TEMPLATE_REGISTRY.keys()))
|
||||||
|
raise ValueError(f"Unknown billing template: {name!r}. Available: {available}")
|
||||||
|
|
||||||
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
def list_templates() -> List[str]:
|
||||||
|
"""列出所有可用的模板名称"""
|
||||||
|
return list(BILLING_TEMPLATE_REGISTRY.keys())
|
||||||
267
src/services/billing/usage_mapper.py
Normal file
267
src/services/billing/usage_mapper.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"""
|
||||||
|
Usage 字段映射器
|
||||||
|
|
||||||
|
将不同 API 格式的原始 usage 数据映射为标准化格式。
|
||||||
|
|
||||||
|
支持的格式:
|
||||||
|
- OPENAI / OPENAI_CLI: OpenAI Chat Completions API
|
||||||
|
- CLAUDE / CLAUDE_CLI: Anthropic Messages API
|
||||||
|
- GEMINI / GEMINI_CLI: Google Gemini API
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from src.services.billing.models import StandardizedUsage
|
||||||
|
|
||||||
|
|
||||||
|
class UsageMapper:
|
||||||
|
"""
|
||||||
|
Usage 字段映射器
|
||||||
|
|
||||||
|
将不同 API 格式的 usage 统一映射为 StandardizedUsage。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
# OpenAI 格式
|
||||||
|
raw_usage = {
|
||||||
|
"prompt_tokens": 100,
|
||||||
|
"completion_tokens": 50,
|
||||||
|
"prompt_tokens_details": {"cached_tokens": 20},
|
||||||
|
"completion_tokens_details": {"reasoning_tokens": 10}
|
||||||
|
}
|
||||||
|
usage = UsageMapper.map(raw_usage, "OPENAI")
|
||||||
|
|
||||||
|
# Claude 格式
|
||||||
|
raw_usage = {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"output_tokens": 50,
|
||||||
|
"cache_creation_input_tokens": 30,
|
||||||
|
"cache_read_input_tokens": 20
|
||||||
|
}
|
||||||
|
usage = UsageMapper.map(raw_usage, "CLAUDE")
|
||||||
|
"""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 字段映射配置
|
||||||
|
# 格式: "source_path" -> "target_field"
|
||||||
|
# source_path 支持点号分隔的嵌套路径
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
# OpenAI 格式字段映射
|
||||||
|
OPENAI_MAPPING: Dict[str, str] = {
|
||||||
|
"prompt_tokens": "input_tokens",
|
||||||
|
"completion_tokens": "output_tokens",
|
||||||
|
"prompt_tokens_details.cached_tokens": "cache_read_tokens",
|
||||||
|
"completion_tokens_details.reasoning_tokens": "reasoning_tokens",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Claude 格式字段映射
|
||||||
|
CLAUDE_MAPPING: Dict[str, str] = {
|
||||||
|
"input_tokens": "input_tokens",
|
||||||
|
"output_tokens": "output_tokens",
|
||||||
|
"cache_creation_input_tokens": "cache_creation_tokens",
|
||||||
|
"cache_read_input_tokens": "cache_read_tokens",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gemini 格式字段映射
|
||||||
|
GEMINI_MAPPING: Dict[str, str] = {
|
||||||
|
"promptTokenCount": "input_tokens",
|
||||||
|
"candidatesTokenCount": "output_tokens",
|
||||||
|
"cachedContentTokenCount": "cache_read_tokens",
|
||||||
|
# Gemini 的 usageMetadata 格式
|
||||||
|
"usageMetadata.promptTokenCount": "input_tokens",
|
||||||
|
"usageMetadata.candidatesTokenCount": "output_tokens",
|
||||||
|
"usageMetadata.cachedContentTokenCount": "cache_read_tokens",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 格式名称到映射的对应关系
|
||||||
|
FORMAT_MAPPINGS: Dict[str, Dict[str, str]] = {
|
||||||
|
"OPENAI": OPENAI_MAPPING,
|
||||||
|
"OPENAI_CLI": OPENAI_MAPPING,
|
||||||
|
"CLAUDE": CLAUDE_MAPPING,
|
||||||
|
"CLAUDE_CLI": CLAUDE_MAPPING,
|
||||||
|
"GEMINI": GEMINI_MAPPING,
|
||||||
|
"GEMINI_CLI": GEMINI_MAPPING,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def map(
|
||||||
|
cls,
|
||||||
|
raw_usage: Dict[str, Any],
|
||||||
|
api_format: str,
|
||||||
|
extra_mapping: Optional[Dict[str, str]] = None,
|
||||||
|
) -> StandardizedUsage:
|
||||||
|
"""
|
||||||
|
将原始 usage 映射为标准化格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_usage: 原始 usage 字典
|
||||||
|
api_format: API 格式 ("OPENAI", "CLAUDE", "GEMINI" 等)
|
||||||
|
extra_mapping: 额外的字段映射(用于自定义扩展)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
标准化的 usage 对象
|
||||||
|
"""
|
||||||
|
if not raw_usage:
|
||||||
|
return StandardizedUsage()
|
||||||
|
|
||||||
|
# 获取对应格式的字段映射
|
||||||
|
mapping = cls._get_mapping(api_format)
|
||||||
|
|
||||||
|
# 合并额外映射
|
||||||
|
if extra_mapping:
|
||||||
|
mapping = {**mapping, **extra_mapping}
|
||||||
|
|
||||||
|
result = StandardizedUsage()
|
||||||
|
|
||||||
|
# 执行映射
|
||||||
|
for source_path, target_field in mapping.items():
|
||||||
|
value = cls._get_nested_value(raw_usage, source_path)
|
||||||
|
if value is not None:
|
||||||
|
result.set(target_field, value)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def map_from_response(
|
||||||
|
cls,
|
||||||
|
response: Dict[str, Any],
|
||||||
|
api_format: str,
|
||||||
|
) -> StandardizedUsage:
|
||||||
|
"""
|
||||||
|
从完整响应中提取并映射 usage
|
||||||
|
|
||||||
|
不同 API 格式的 usage 位置可能不同:
|
||||||
|
- OpenAI: response["usage"]
|
||||||
|
- Claude: response["usage"] 或 message_delta 中
|
||||||
|
- Gemini: response["usageMetadata"]
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: 完整的 API 响应
|
||||||
|
api_format: API 格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
标准化的 usage 对象
|
||||||
|
"""
|
||||||
|
format_upper = api_format.upper() if api_format else ""
|
||||||
|
|
||||||
|
# 提取 usage 部分
|
||||||
|
usage_data: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
if format_upper.startswith("GEMINI"):
|
||||||
|
# Gemini: usageMetadata
|
||||||
|
usage_data = response.get("usageMetadata", {})
|
||||||
|
if not usage_data:
|
||||||
|
# 尝试从 candidates 中获取
|
||||||
|
candidates = response.get("candidates", [])
|
||||||
|
if candidates:
|
||||||
|
usage_data = candidates[0].get("usageMetadata", {})
|
||||||
|
else:
|
||||||
|
# OpenAI/Claude: usage
|
||||||
|
usage_data = response.get("usage", {})
|
||||||
|
|
||||||
|
return cls.map(usage_data, api_format)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_mapping(cls, api_format: str) -> Dict[str, str]:
|
||||||
|
"""获取对应格式的字段映射"""
|
||||||
|
if not api_format:
|
||||||
|
return cls.CLAUDE_MAPPING
|
||||||
|
|
||||||
|
format_upper = api_format.upper()
|
||||||
|
|
||||||
|
# 精确匹配
|
||||||
|
if format_upper in cls.FORMAT_MAPPINGS:
|
||||||
|
return cls.FORMAT_MAPPINGS[format_upper]
|
||||||
|
|
||||||
|
# 前缀匹配
|
||||||
|
for key, mapping in cls.FORMAT_MAPPINGS.items():
|
||||||
|
if format_upper.startswith(key.split("_")[0]):
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
# 默认使用 Claude 映射
|
||||||
|
return cls.CLAUDE_MAPPING
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_nested_value(cls, data: Dict[str, Any], path: str) -> Any:
|
||||||
|
"""
|
||||||
|
获取嵌套字段值
|
||||||
|
|
||||||
|
支持点号分隔的路径,如 "prompt_tokens_details.cached_tokens"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 数据字典
|
||||||
|
path: 字段路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
字段值,不存在则返回 None
|
||||||
|
"""
|
||||||
|
if not data or not path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
keys = path.split(".")
|
||||||
|
value: Any = data
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = value.get(key)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register_format(cls, format_name: str, mapping: Dict[str, str]) -> None:
|
||||||
|
"""
|
||||||
|
注册新的格式映射
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_name: 格式名称(会自动转为大写)
|
||||||
|
mapping: 字段映射
|
||||||
|
"""
|
||||||
|
cls.FORMAT_MAPPINGS[format_name.upper()] = mapping
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_supported_formats(cls) -> list:
|
||||||
|
"""获取所有支持的格式"""
|
||||||
|
return list(cls.FORMAT_MAPPINGS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 便捷函数
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def map_usage(
|
||||||
|
raw_usage: Dict[str, Any],
|
||||||
|
api_format: str,
|
||||||
|
) -> StandardizedUsage:
|
||||||
|
"""
|
||||||
|
便捷函数:将原始 usage 映射为标准化格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_usage: 原始 usage 字典
|
||||||
|
api_format: API 格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StandardizedUsage 对象
|
||||||
|
"""
|
||||||
|
return UsageMapper.map(raw_usage, api_format)
|
||||||
|
|
||||||
|
|
||||||
|
def map_usage_from_response(
|
||||||
|
response: Dict[str, Any],
|
||||||
|
api_format: str,
|
||||||
|
) -> StandardizedUsage:
|
||||||
|
"""
|
||||||
|
便捷函数:从响应中提取并映射 usage
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: API 响应
|
||||||
|
api_format: API 格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StandardizedUsage 对象
|
||||||
|
"""
|
||||||
|
return UsageMapper.map_from_response(response, api_format)
|
||||||
@@ -7,6 +7,59 @@ from typing import Any
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
|
||||||
|
def escape_like_pattern(pattern: str) -> str:
|
||||||
|
"""
|
||||||
|
转义 SQL LIKE 语句中的特殊字符(%、_、\\)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: 原始搜索模式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
转义后的模式,可安全用于 LIKE 查询(需配合 escape="\\\\")
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> escape_like_pattern("hello_world%test")
|
||||||
|
'hello\\\\_world\\\\%test'
|
||||||
|
"""
|
||||||
|
return pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_truncate_escaped(escaped: str, max_len: int) -> str:
|
||||||
|
"""
|
||||||
|
安全截断已转义的字符串,避免截断在转义序列中间
|
||||||
|
|
||||||
|
转义后的字符串中,反斜杠总是成对出现(\\\\)或作为转义符(\\%, \\_)。
|
||||||
|
如果在某个位置截断导致末尾有奇数个反斜杠,说明截断发生在转义序列中间,
|
||||||
|
需要去掉最后一个反斜杠以保持转义完整性。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
escaped: 已经过 escape_like_pattern 处理的字符串
|
||||||
|
max_len: 最大长度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
截断后的字符串,保证不会破坏转义序列
|
||||||
|
"""
|
||||||
|
if len(escaped) <= max_len:
|
||||||
|
return escaped
|
||||||
|
|
||||||
|
truncated = escaped[:max_len]
|
||||||
|
|
||||||
|
# 统计末尾连续的反斜杠数量
|
||||||
|
trailing_backslashes = 0
|
||||||
|
for i in range(len(truncated) - 1, -1, -1):
|
||||||
|
if truncated[i] == "\\":
|
||||||
|
trailing_backslashes += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 如果末尾反斜杠数量为奇数,说明截断在转义序列中间
|
||||||
|
# 需要去掉最后一个反斜杠
|
||||||
|
if trailing_backslashes % 2 == 1:
|
||||||
|
truncated = truncated[:-1]
|
||||||
|
|
||||||
|
return truncated
|
||||||
|
|
||||||
|
|
||||||
def date_trunc_portable(dialect_name: str, interval: str, column: Any) -> Any:
|
def date_trunc_portable(dialect_name: str, interval: str, column: Any) -> Any:
|
||||||
"""
|
"""
|
||||||
跨数据库的日期截断函数
|
跨数据库的日期截断函数
|
||||||
|
|||||||
@@ -7,22 +7,20 @@ from typing import Optional
|
|||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from src.config import config
|
|
||||||
|
|
||||||
|
|
||||||
def get_client_ip(request: Request) -> str:
|
def get_client_ip(request: Request) -> str:
|
||||||
"""
|
"""
|
||||||
获取客户端真实IP地址
|
获取客户端真实IP地址
|
||||||
|
|
||||||
按优先级检查:
|
按优先级检查:
|
||||||
1. X-Forwarded-For 头(支持代理链,根据可信代理数量提取)
|
1. X-Real-IP 头(最可靠,由最外层可信 Nginx 直接设置)
|
||||||
2. X-Real-IP 头(Nginx 代理)
|
2. X-Forwarded-For 头的第一个 IP(原始客户端)
|
||||||
3. 直接客户端IP
|
3. 直接客户端IP
|
||||||
|
|
||||||
安全说明:
|
安全说明:
|
||||||
- 此函数根据 TRUSTED_PROXY_COUNT 配置来决定信任的代理层数
|
- X-Real-IP 优先级最高,因为它通常由最外层 Nginx 设置为 $remote_addr,
|
||||||
- 当 TRUSTED_PROXY_COUNT=0 时,不信任任何代理头,直接使用连接 IP
|
Nginx 会直接覆盖这个头,不会传递客户端伪造的值
|
||||||
- 当服务直接暴露公网时,应设置 TRUSTED_PROXY_COUNT=0 以防止 IP 伪造
|
- 只要最外层 Nginx 配置了 proxy_set_header X-Real-IP $remote_addr; 即可正确获取真实 IP
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: FastAPI Request 对象
|
request: FastAPI Request 对象
|
||||||
@@ -30,30 +28,19 @@ def get_client_ip(request: Request) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
str: 客户端IP地址,如果无法获取则返回 "unknown"
|
str: 客户端IP地址,如果无法获取则返回 "unknown"
|
||||||
"""
|
"""
|
||||||
trusted_proxy_count = config.trusted_proxy_count
|
# 优先检查 X-Real-IP 头(由最外层 Nginx 设置,最可靠)
|
||||||
|
|
||||||
# 如果不信任任何代理,直接返回连接 IP
|
|
||||||
if trusted_proxy_count == 0:
|
|
||||||
if request.client and request.client.host:
|
|
||||||
return request.client.host
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
# 优先检查 X-Forwarded-For 头(可能包含代理链)
|
|
||||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
|
||||||
if forwarded_for:
|
|
||||||
# X-Forwarded-For 格式: "client, proxy1, proxy2"
|
|
||||||
# 从右往左数 trusted_proxy_count 个,取其左边的第一个
|
|
||||||
ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()]
|
|
||||||
if len(ips) > trusted_proxy_count:
|
|
||||||
return ips[-(trusted_proxy_count + 1)]
|
|
||||||
elif ips:
|
|
||||||
return ips[0]
|
|
||||||
|
|
||||||
# 检查 X-Real-IP 头(通常由 Nginx 设置)
|
|
||||||
real_ip = request.headers.get("X-Real-IP")
|
real_ip = request.headers.get("X-Real-IP")
|
||||||
if real_ip:
|
if real_ip:
|
||||||
return real_ip.strip()
|
return real_ip.strip()
|
||||||
|
|
||||||
|
# 检查 X-Forwarded-For 头,取第一个 IP(原始客户端)
|
||||||
|
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded_for:
|
||||||
|
# X-Forwarded-For 格式: "client, proxy1, proxy2"
|
||||||
|
ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()]
|
||||||
|
if ips:
|
||||||
|
return ips[0]
|
||||||
|
|
||||||
# 回退到直接客户端IP
|
# 回退到直接客户端IP
|
||||||
if request.client and request.client.host:
|
if request.client and request.client.host:
|
||||||
return request.client.host
|
return request.client.host
|
||||||
@@ -109,36 +96,26 @@ def get_request_metadata(request: Request) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def extract_ip_from_headers(headers: dict, trusted_proxy_count: Optional[int] = None) -> str:
|
def extract_ip_from_headers(headers: dict) -> str:
|
||||||
"""
|
"""
|
||||||
从HTTP头字典中提取IP地址(用于中间件等场景)
|
从HTTP头字典中提取IP地址(用于中间件等场景)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
headers: HTTP头字典
|
headers: HTTP头字典
|
||||||
trusted_proxy_count: 可信代理层数,None 时使用配置值
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: 客户端IP地址
|
str: 客户端IP地址
|
||||||
"""
|
"""
|
||||||
if trusted_proxy_count is None:
|
# 优先检查 X-Real-IP(由最外层 Nginx 设置,最可靠)
|
||||||
trusted_proxy_count = config.trusted_proxy_count
|
|
||||||
|
|
||||||
# 如果不信任任何代理,返回 unknown(调用方需要用其他方式获取连接 IP)
|
|
||||||
if trusted_proxy_count == 0:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
# 检查 X-Forwarded-For
|
|
||||||
forwarded_for = headers.get("x-forwarded-for", "")
|
|
||||||
if forwarded_for:
|
|
||||||
ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()]
|
|
||||||
if len(ips) > trusted_proxy_count:
|
|
||||||
return ips[-(trusted_proxy_count + 1)]
|
|
||||||
elif ips:
|
|
||||||
return ips[0]
|
|
||||||
|
|
||||||
# 检查 X-Real-IP
|
|
||||||
real_ip = headers.get("x-real-ip", "")
|
real_ip = headers.get("x-real-ip", "")
|
||||||
if real_ip:
|
if real_ip:
|
||||||
return real_ip.strip()
|
return real_ip.strip()
|
||||||
|
|
||||||
|
# 检查 X-Forwarded-For,取第一个 IP
|
||||||
|
forwarded_for = headers.get("x-forwarded-for", "")
|
||||||
|
if forwarded_for:
|
||||||
|
ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()]
|
||||||
|
if ips:
|
||||||
|
return ips[0]
|
||||||
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|||||||
0
tests/services/billing/__init__.py
Normal file
0
tests/services/billing/__init__.py
Normal file
440
tests/services/billing/test_billing.py
Normal file
440
tests/services/billing/test_billing.py
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
"""
|
||||||
|
Billing 模块测试
|
||||||
|
|
||||||
|
测试计费模块的核心功能:
|
||||||
|
- BillingCalculator 计费计算
|
||||||
|
- 计费模板
|
||||||
|
- 阶梯计费
|
||||||
|
- calculate_request_cost 便捷函数
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.billing import (
|
||||||
|
BillingCalculator,
|
||||||
|
BillingDimension,
|
||||||
|
BillingTemplates,
|
||||||
|
BillingUnit,
|
||||||
|
CostBreakdown,
|
||||||
|
StandardizedUsage,
|
||||||
|
calculate_request_cost,
|
||||||
|
)
|
||||||
|
from src.services.billing.templates import get_template, list_templates
|
||||||
|
|
||||||
|
|
||||||
|
class TestBillingDimension:
|
||||||
|
"""测试计费维度"""
|
||||||
|
|
||||||
|
def test_calculate_per_1m_tokens(self) -> None:
|
||||||
|
"""测试 per_1m_tokens 计费"""
|
||||||
|
dim = BillingDimension(
|
||||||
|
name="input",
|
||||||
|
usage_field="input_tokens",
|
||||||
|
price_field="input_price_per_1m",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1000 tokens * $3 / 1M = $0.003
|
||||||
|
cost = dim.calculate(1000, 3.0)
|
||||||
|
assert abs(cost - 0.003) < 0.0001
|
||||||
|
|
||||||
|
def test_calculate_per_request(self) -> None:
|
||||||
|
"""测试按次计费"""
|
||||||
|
dim = BillingDimension(
|
||||||
|
name="request",
|
||||||
|
usage_field="request_count",
|
||||||
|
price_field="price_per_request",
|
||||||
|
unit=BillingUnit.PER_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 按次计费:cost = request_count * price
|
||||||
|
cost = dim.calculate(1, 0.05)
|
||||||
|
assert cost == 0.05
|
||||||
|
|
||||||
|
# 多次请求应按次数计费
|
||||||
|
cost = dim.calculate(3, 0.05)
|
||||||
|
assert abs(cost - 0.15) < 0.0001
|
||||||
|
|
||||||
|
def test_calculate_zero_usage(self) -> None:
|
||||||
|
"""测试零用量"""
|
||||||
|
dim = BillingDimension(
|
||||||
|
name="input",
|
||||||
|
usage_field="input_tokens",
|
||||||
|
price_field="input_price_per_1m",
|
||||||
|
)
|
||||||
|
|
||||||
|
cost = dim.calculate(0, 3.0)
|
||||||
|
assert cost == 0.0
|
||||||
|
|
||||||
|
def test_calculate_zero_price(self) -> None:
|
||||||
|
"""测试零价格"""
|
||||||
|
dim = BillingDimension(
|
||||||
|
name="input",
|
||||||
|
usage_field="input_tokens",
|
||||||
|
price_field="input_price_per_1m",
|
||||||
|
)
|
||||||
|
|
||||||
|
cost = dim.calculate(1000, 0.0)
|
||||||
|
assert cost == 0.0
|
||||||
|
|
||||||
|
def test_to_dict_and_from_dict(self) -> None:
|
||||||
|
"""测试序列化和反序列化"""
|
||||||
|
dim = BillingDimension(
|
||||||
|
name="cache_read",
|
||||||
|
usage_field="cache_read_tokens",
|
||||||
|
price_field="cache_read_price_per_1m",
|
||||||
|
unit=BillingUnit.PER_1M_TOKENS,
|
||||||
|
default_price=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
d = dim.to_dict()
|
||||||
|
restored = BillingDimension.from_dict(d)
|
||||||
|
|
||||||
|
assert restored.name == dim.name
|
||||||
|
assert restored.usage_field == dim.usage_field
|
||||||
|
assert restored.price_field == dim.price_field
|
||||||
|
assert restored.unit == dim.unit
|
||||||
|
assert restored.default_price == dim.default_price
|
||||||
|
|
||||||
|
|
||||||
|
class TestStandardizedUsage:
|
||||||
|
"""测试标准化 Usage"""
|
||||||
|
|
||||||
|
def test_basic_usage(self) -> None:
|
||||||
|
"""测试基础 usage"""
|
||||||
|
usage = StandardizedUsage(
|
||||||
|
input_tokens=1000,
|
||||||
|
output_tokens=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert usage.input_tokens == 1000
|
||||||
|
assert usage.output_tokens == 500
|
||||||
|
assert usage.cache_creation_tokens == 0
|
||||||
|
assert usage.cache_read_tokens == 0
|
||||||
|
|
||||||
|
def test_get_field(self) -> None:
|
||||||
|
"""测试字段获取"""
|
||||||
|
usage = StandardizedUsage(
|
||||||
|
input_tokens=1000,
|
||||||
|
output_tokens=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert usage.get("input_tokens") == 1000
|
||||||
|
assert usage.get("nonexistent", 0) == 0
|
||||||
|
|
||||||
|
def test_extra_fields(self) -> None:
|
||||||
|
"""测试扩展字段"""
|
||||||
|
usage = StandardizedUsage(
|
||||||
|
input_tokens=1000,
|
||||||
|
output_tokens=500,
|
||||||
|
extra={"custom_field": 123},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert usage.get("custom_field") == 123
|
||||||
|
|
||||||
|
def test_to_dict(self) -> None:
|
||||||
|
"""测试转换为字典"""
|
||||||
|
usage = StandardizedUsage(
|
||||||
|
input_tokens=1000,
|
||||||
|
output_tokens=500,
|
||||||
|
cache_creation_tokens=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
d = usage.to_dict()
|
||||||
|
assert d["input_tokens"] == 1000
|
||||||
|
assert d["output_tokens"] == 500
|
||||||
|
assert d["cache_creation_tokens"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
class TestCostBreakdown:
|
||||||
|
"""测试费用明细"""
|
||||||
|
|
||||||
|
def test_basic_breakdown(self) -> None:
|
||||||
|
"""测试基础费用明细"""
|
||||||
|
breakdown = CostBreakdown(
|
||||||
|
costs={"input": 0.003, "output": 0.0075},
|
||||||
|
total_cost=0.0105,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert breakdown.input_cost == 0.003
|
||||||
|
assert breakdown.output_cost == 0.0075
|
||||||
|
assert breakdown.total_cost == 0.0105
|
||||||
|
|
||||||
|
def test_cache_cost_calculation(self) -> None:
|
||||||
|
"""测试缓存费用汇总"""
|
||||||
|
breakdown = CostBreakdown(
|
||||||
|
costs={
|
||||||
|
"input": 0.003,
|
||||||
|
"output": 0.0075,
|
||||||
|
"cache_creation": 0.001,
|
||||||
|
"cache_read": 0.0005,
|
||||||
|
},
|
||||||
|
total_cost=0.012,
|
||||||
|
)
|
||||||
|
|
||||||
|
# cache_cost = cache_creation + cache_read
|
||||||
|
assert abs(breakdown.cache_cost - 0.0015) < 0.0001
|
||||||
|
|
||||||
|
def test_to_dict(self) -> None:
|
||||||
|
"""测试转换为字典"""
|
||||||
|
breakdown = CostBreakdown(
|
||||||
|
costs={"input": 0.003, "output": 0.0075},
|
||||||
|
total_cost=0.0105,
|
||||||
|
tier_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
d = breakdown.to_dict()
|
||||||
|
assert d["total_cost"] == 0.0105
|
||||||
|
assert d["tier_index"] == 1
|
||||||
|
assert d["input_cost"] == 0.003
|
||||||
|
|
||||||
|
|
||||||
|
class TestBillingTemplates:
|
||||||
|
"""测试计费模板"""
|
||||||
|
|
||||||
|
def test_claude_template(self) -> None:
|
||||||
|
"""测试 Claude 模板"""
|
||||||
|
template = BillingTemplates.CLAUDE_STANDARD
|
||||||
|
dim_names = [d.name for d in template]
|
||||||
|
|
||||||
|
assert "input" in dim_names
|
||||||
|
assert "output" in dim_names
|
||||||
|
assert "cache_creation" in dim_names
|
||||||
|
assert "cache_read" in dim_names
|
||||||
|
|
||||||
|
def test_openai_template(self) -> None:
|
||||||
|
"""测试 OpenAI 模板"""
|
||||||
|
template = BillingTemplates.OPENAI_STANDARD
|
||||||
|
dim_names = [d.name for d in template]
|
||||||
|
|
||||||
|
assert "input" in dim_names
|
||||||
|
assert "output" in dim_names
|
||||||
|
assert "cache_read" in dim_names
|
||||||
|
# OpenAI 没有缓存创建费用
|
||||||
|
assert "cache_creation" not in dim_names
|
||||||
|
|
||||||
|
def test_gemini_template(self) -> None:
|
||||||
|
"""测试 Gemini 模板"""
|
||||||
|
template = BillingTemplates.GEMINI_STANDARD
|
||||||
|
dim_names = [d.name for d in template]
|
||||||
|
|
||||||
|
assert "input" in dim_names
|
||||||
|
assert "output" in dim_names
|
||||||
|
assert "cache_read" in dim_names
|
||||||
|
|
||||||
|
def test_per_request_template(self) -> None:
|
||||||
|
"""测试按次计费模板"""
|
||||||
|
template = BillingTemplates.PER_REQUEST
|
||||||
|
assert len(template) == 1
|
||||||
|
assert template[0].name == "request"
|
||||||
|
assert template[0].unit == BillingUnit.PER_REQUEST
|
||||||
|
|
||||||
|
def test_get_template(self) -> None:
|
||||||
|
"""测试获取模板"""
|
||||||
|
template = get_template("claude")
|
||||||
|
assert template == BillingTemplates.CLAUDE_STANDARD
|
||||||
|
|
||||||
|
template = get_template("openai")
|
||||||
|
assert template == BillingTemplates.OPENAI_STANDARD
|
||||||
|
|
||||||
|
# 不区分大小写
|
||||||
|
template = get_template("CLAUDE")
|
||||||
|
assert template == BillingTemplates.CLAUDE_STANDARD
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unknown billing template"):
|
||||||
|
get_template("unknown_template")
|
||||||
|
|
||||||
|
def test_list_templates(self) -> None:
|
||||||
|
"""测试列出模板"""
|
||||||
|
templates = list_templates()
|
||||||
|
|
||||||
|
assert "claude" in templates
|
||||||
|
assert "openai" in templates
|
||||||
|
assert "gemini" in templates
|
||||||
|
assert "per_request" in templates
|
||||||
|
|
||||||
|
|
||||||
|
class TestBillingCalculator:
|
||||||
|
"""测试计费计算器"""
|
||||||
|
|
||||||
|
def test_basic_calculation(self) -> None:
|
||||||
|
"""测试基础计费计算"""
|
||||||
|
calculator = BillingCalculator(template="claude")
|
||||||
|
usage = StandardizedUsage(input_tokens=1000, output_tokens=500)
|
||||||
|
prices = {"input_price_per_1m": 3.0, "output_price_per_1m": 15.0}
|
||||||
|
|
||||||
|
result = calculator.calculate(usage, prices)
|
||||||
|
|
||||||
|
# 1000 * 3 / 1M = 0.003
|
||||||
|
assert abs(result.input_cost - 0.003) < 0.0001
|
||||||
|
# 500 * 15 / 1M = 0.0075
|
||||||
|
assert abs(result.output_cost - 0.0075) < 0.0001
|
||||||
|
# Total = 0.0105
|
||||||
|
assert abs(result.total_cost - 0.0105) < 0.0001
|
||||||
|
|
||||||
|
def test_calculation_with_cache(self) -> None:
|
||||||
|
"""测试带缓存的计费计算"""
|
||||||
|
calculator = BillingCalculator(template="claude")
|
||||||
|
usage = StandardizedUsage(
|
||||||
|
input_tokens=1000,
|
||||||
|
output_tokens=500,
|
||||||
|
cache_creation_tokens=200,
|
||||||
|
cache_read_tokens=300,
|
||||||
|
)
|
||||||
|
prices = {
|
||||||
|
"input_price_per_1m": 3.0,
|
||||||
|
"output_price_per_1m": 15.0,
|
||||||
|
"cache_creation_price_per_1m": 3.75,
|
||||||
|
"cache_read_price_per_1m": 0.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = calculator.calculate(usage, prices)
|
||||||
|
|
||||||
|
# cache_creation: 200 * 3.75 / 1M = 0.00075
|
||||||
|
assert abs(result.cache_creation_cost - 0.00075) < 0.0001
|
||||||
|
# cache_read: 300 * 0.3 / 1M = 0.00009
|
||||||
|
assert abs(result.cache_read_cost - 0.00009) < 0.0001
|
||||||
|
|
||||||
|
def test_tiered_pricing(self) -> None:
|
||||||
|
"""测试阶梯计费"""
|
||||||
|
calculator = BillingCalculator(template="claude")
|
||||||
|
usage = StandardizedUsage(input_tokens=250000, output_tokens=10000)
|
||||||
|
|
||||||
|
# 大于 200k 进入第二阶梯
|
||||||
|
tiered_pricing = {
|
||||||
|
"tiers": [
|
||||||
|
{"up_to": 200000, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0},
|
||||||
|
{"up_to": None, "input_price_per_1m": 1.5, "output_price_per_1m": 7.5},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
prices = {"input_price_per_1m": 3.0, "output_price_per_1m": 15.0}
|
||||||
|
|
||||||
|
result = calculator.calculate(usage, prices, tiered_pricing)
|
||||||
|
|
||||||
|
# 应该使用第二阶梯价格
|
||||||
|
assert result.tier_index == 1
|
||||||
|
# 250000 * 1.5 / 1M = 0.375
|
||||||
|
assert abs(result.input_cost - 0.375) < 0.0001
|
||||||
|
|
||||||
|
def test_openai_no_cache_creation(self) -> None:
|
||||||
|
"""测试 OpenAI 模板没有缓存创建费用"""
|
||||||
|
calculator = BillingCalculator(template="openai")
|
||||||
|
usage = StandardizedUsage(
|
||||||
|
input_tokens=1000,
|
||||||
|
output_tokens=500,
|
||||||
|
cache_creation_tokens=200, # 这个不应该计费
|
||||||
|
cache_read_tokens=300,
|
||||||
|
)
|
||||||
|
prices = {
|
||||||
|
"input_price_per_1m": 3.0,
|
||||||
|
"output_price_per_1m": 15.0,
|
||||||
|
"cache_creation_price_per_1m": 3.75,
|
||||||
|
"cache_read_price_per_1m": 0.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = calculator.calculate(usage, prices)
|
||||||
|
|
||||||
|
# OpenAI 模板不包含 cache_creation 维度
|
||||||
|
assert result.cache_creation_cost == 0.0
|
||||||
|
# 但 cache_read 应该计费
|
||||||
|
assert result.cache_read_cost > 0
|
||||||
|
|
||||||
|
def test_from_config(self) -> None:
|
||||||
|
"""测试从配置创建计算器"""
|
||||||
|
config = {"template": "openai"}
|
||||||
|
calculator = BillingCalculator.from_config(config)
|
||||||
|
|
||||||
|
assert calculator.template_name == "openai"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateRequestCost:
|
||||||
|
"""测试便捷函数"""
|
||||||
|
|
||||||
|
def test_basic_usage(self) -> None:
|
||||||
|
"""测试基础用法"""
|
||||||
|
result = calculate_request_cost(
|
||||||
|
input_tokens=1000,
|
||||||
|
output_tokens=500,
|
||||||
|
cache_creation_input_tokens=0,
|
||||||
|
cache_read_input_tokens=0,
|
||||||
|
input_price_per_1m=3.0,
|
||||||
|
output_price_per_1m=15.0,
|
||||||
|
cache_creation_price_per_1m=None,
|
||||||
|
cache_read_price_per_1m=None,
|
||||||
|
price_per_request=None,
|
||||||
|
billing_template="claude",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "input_cost" in result
|
||||||
|
assert "output_cost" in result
|
||||||
|
assert "total_cost" in result
|
||||||
|
assert abs(result["input_cost"] - 0.003) < 0.0001
|
||||||
|
assert abs(result["output_cost"] - 0.0075) < 0.0001
|
||||||
|
|
||||||
|
def test_with_cache(self) -> None:
|
||||||
|
"""测试带缓存"""
|
||||||
|
result = calculate_request_cost(
|
||||||
|
input_tokens=1000,
|
||||||
|
output_tokens=500,
|
||||||
|
cache_creation_input_tokens=200,
|
||||||
|
cache_read_input_tokens=300,
|
||||||
|
input_price_per_1m=3.0,
|
||||||
|
output_price_per_1m=15.0,
|
||||||
|
cache_creation_price_per_1m=3.75,
|
||||||
|
cache_read_price_per_1m=0.3,
|
||||||
|
price_per_request=None,
|
||||||
|
billing_template="claude",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["cache_creation_cost"] > 0
|
||||||
|
assert result["cache_read_cost"] > 0
|
||||||
|
assert result["cache_cost"] == result["cache_creation_cost"] + result["cache_read_cost"]
|
||||||
|
|
||||||
|
def test_different_templates(self) -> None:
|
||||||
|
"""测试不同模板"""
|
||||||
|
prices = {
|
||||||
|
"input_tokens": 1000,
|
||||||
|
"output_tokens": 500,
|
||||||
|
"cache_creation_input_tokens": 200,
|
||||||
|
"cache_read_input_tokens": 300,
|
||||||
|
"input_price_per_1m": 3.0,
|
||||||
|
"output_price_per_1m": 15.0,
|
||||||
|
"cache_creation_price_per_1m": 3.75,
|
||||||
|
"cache_read_price_per_1m": 0.3,
|
||||||
|
"price_per_request": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Claude 模板有 cache_creation
|
||||||
|
result_claude = calculate_request_cost(**prices, billing_template="claude")
|
||||||
|
assert result_claude["cache_creation_cost"] > 0
|
||||||
|
|
||||||
|
# OpenAI 模板没有 cache_creation
|
||||||
|
result_openai = calculate_request_cost(**prices, billing_template="openai")
|
||||||
|
assert result_openai["cache_creation_cost"] == 0
|
||||||
|
|
||||||
|
def test_tiered_pricing_with_total_context(self) -> None:
|
||||||
|
"""测试使用自定义 total_input_context 的阶梯计费"""
|
||||||
|
tiered_pricing = {
|
||||||
|
"tiers": [
|
||||||
|
{"up_to": 200000, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0},
|
||||||
|
{"up_to": None, "input_price_per_1m": 1.5, "output_price_per_1m": 7.5},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 传入预计算的 total_input_context
|
||||||
|
result = calculate_request_cost(
|
||||||
|
input_tokens=1000,
|
||||||
|
output_tokens=500,
|
||||||
|
cache_creation_input_tokens=0,
|
||||||
|
cache_read_input_tokens=0,
|
||||||
|
input_price_per_1m=3.0,
|
||||||
|
output_price_per_1m=15.0,
|
||||||
|
cache_creation_price_per_1m=None,
|
||||||
|
cache_read_price_per_1m=None,
|
||||||
|
price_per_request=None,
|
||||||
|
tiered_pricing=tiered_pricing,
|
||||||
|
total_input_context=250000, # 预计算的值,超过 200k
|
||||||
|
billing_template="claude",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 应该使用第二阶梯价格
|
||||||
|
assert result["tier_index"] == 1
|
||||||
Reference in New Issue
Block a user