From 0061fc04b722fe9768a7ab9c29f52dc2bbb89786 Mon Sep 17 00:00:00 2001 From: fawney19 Date: Wed, 7 Jan 2026 14:55:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E8=87=B3=200.2.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Management Token(访问令牌)功能,支持创建、更新、删除和管理 - 前端添加访问令牌管理页面,支持普通用户和管理员 - 后端实现完整的令牌生命周期管理 API - 添加数据库迁移脚本创建 management_tokens 表 - Nginx 配置添加 gzip 压缩,优化响应传输 - Dialog 组件添加 persistent 属性,防止意外关闭 - 为管理后台 API 添加详细的中文文档注释 - 简化多处类型注解,统一代码风格 --- Dockerfile.app | 9 + Dockerfile.app.local | 9 + ...d55f1d008b7_add_management_tokens_table.py | 131 +++ frontend/src/api/management-tokens.ts | 203 +++++ frontend/src/components/ui/dialog/Dialog.vue | 12 +- .../features/auth/components/LoginDialog.vue | 6 +- frontend/src/layouts/MainLayout.vue | 3 + frontend/src/router/index.ts | 10 + frontend/src/views/admin/LdapSettings.vue | 69 +- frontend/src/views/user/ManagementTokens.vue | 859 ++++++++++++++++++ src/_version.py | 4 +- src/api/admin/__init__.py | 2 + src/api/admin/api_keys/routes.py | 145 ++- src/api/admin/endpoints/__init__.py | 2 +- src/api/admin/endpoints/concurrency.py | 42 +- src/api/admin/endpoints/health.py | 112 ++- src/api/admin/endpoints/keys.py | 152 +++- src/api/admin/endpoints/routes.py | 121 ++- src/api/admin/ldap.py | 80 +- src/api/admin/management_tokens/__init__.py | 10 + src/api/admin/management_tokens/routes.py | 300 ++++++ src/api/admin/models/__init__.py | 2 +- src/api/admin/models/catalog.py | 16 + src/api/admin/models/external.py | 29 +- src/api/admin/models/global_models.py | 137 ++- src/api/admin/monitoring/audit.py | 112 ++- src/api/admin/monitoring/cache.py | 298 +++++- src/api/admin/monitoring/trace.py | 60 +- src/api/admin/provider_strategy.py | 72 ++ src/api/admin/providers/models.py | 209 ++++- src/api/admin/providers/routes.py | 92 ++ src/api/admin/providers/summary.py | 128 ++- src/api/admin/security/ip_management.py | 82 +- src/api/admin/system.py | 126 ++- src/api/admin/usage/routes.py | 126 ++- src/api/admin/users/routes.py | 112 ++- src/api/announcements/routes.py | 135 ++- src/api/auth/routes.py | 82 +- src/api/base/adapter.py | 1 + src/api/base/context.py | 5 +- src/api/base/pipeline.py | 124 ++- src/api/dashboard/routes.py | 73 ++ src/api/monitoring/user.py | 36 + src/api/public/capabilities.py | 55 +- src/api/public/catalog.py | 158 +++- src/api/public/claude.py | 29 +- src/api/public/gemini.py | 41 +- src/api/public/models.py | 167 +++- src/api/public/openai.py | 25 + src/api/user_me/__init__.py | 2 + src/api/user_me/management_tokens.py | 577 ++++++++++++ src/api/user_me/routes.py | 228 ++++- src/config/settings.py | 22 + src/main.py | 87 +- src/models/database.py | 190 ++++ src/models/gemini.py | 422 +-------- src/services/auth/service.py | 141 ++- src/services/management_token/__init__.py | 15 + src/services/management_token/service.py | 416 +++++++++ 59 files changed, 6265 insertions(+), 648 deletions(-) create mode 100644 alembic/versions/20260106_1524_ad55f1d008b7_add_management_tokens_table.py create mode 100644 frontend/src/api/management-tokens.ts create mode 100644 frontend/src/views/user/ManagementTokens.vue create mode 100644 src/api/admin/management_tokens/__init__.py create mode 100644 src/api/admin/management_tokens/routes.py create mode 100644 src/api/user_me/management_tokens.py create mode 100644 src/services/management_token/__init__.py create mode 100644 src/services/management_token/service.py diff --git a/Dockerfile.app b/Dockerfile.app index 728c519..7b852fe 100644 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -51,6 +51,15 @@ RUN printf '%s\n' \ ' "" $remote_addr;' \ '}' \ '' \ +'# gzip 压缩配置(对 base64 图片等非流式响应有效)' \ +'gzip on;' \ +'gzip_min_length 256;' \ +'gzip_comp_level 5;' \ +'gzip_vary on;' \ +'gzip_proxied any;' \ +'gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \ +'gzip_disable "msie6";' \ +'' \ 'server {' \ ' listen 80;' \ ' server_name _;' \ diff --git a/Dockerfile.app.local b/Dockerfile.app.local index ed48553..b1045e8 100644 --- a/Dockerfile.app.local +++ b/Dockerfile.app.local @@ -52,6 +52,15 @@ RUN printf '%s\n' \ ' "" $remote_addr;' \ '}' \ '' \ +'# gzip 压缩配置(对 base64 图片等非流式响应有效)' \ +'gzip on;' \ +'gzip_min_length 256;' \ +'gzip_comp_level 5;' \ +'gzip_vary on;' \ +'gzip_proxied any;' \ +'gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \ +'gzip_disable "msie6";' \ +'' \ 'server {' \ ' listen 80;' \ ' server_name _;' \ diff --git a/alembic/versions/20260106_1524_ad55f1d008b7_add_management_tokens_table.py b/alembic/versions/20260106_1524_ad55f1d008b7_add_management_tokens_table.py new file mode 100644 index 0000000..fe75c6e --- /dev/null +++ b/alembic/versions/20260106_1524_ad55f1d008b7_add_management_tokens_table.py @@ -0,0 +1,131 @@ +"""add_management_tokens_table + +Revision ID: ad55f1d008b7 +Revises: c3d4e5f6g7h8 +Create Date: 2026-01-06 15:24:10.660394+00:00 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = 'ad55f1d008b7' +down_revision = 'c3d4e5f6g7h8' +branch_labels = None +depends_on = None + + +def table_exists(table_name: str) -> bool: + """检查表是否存在""" + conn = op.get_bind() + inspector = inspect(conn) + return table_name in inspector.get_table_names() + + +def index_exists(table_name: str, index_name: str) -> bool: + """检查索引是否存在""" + conn = op.get_bind() + inspector = inspect(conn) + try: + indexes = inspector.get_indexes(table_name) + return any(idx["name"] == index_name for idx in indexes) + except Exception: + return False + + +def constraint_exists(table_name: str, constraint_name: str) -> bool: + """检查约束是否存在""" + conn = op.get_bind() + inspector = inspect(conn) + try: + constraints = inspector.get_unique_constraints(table_name) + if any(c["name"] == constraint_name for c in constraints): + return True + # 也检查 check 约束 + check_constraints = inspector.get_check_constraints(table_name) + if any(c["name"] == constraint_name for c in check_constraints): + return True + return False + except Exception: + return False + + +def upgrade() -> None: + """应用迁移:创建 management_tokens 表""" + # 幂等性检查 + if table_exists("management_tokens"): + # 表已存在,检查是否需要添加约束 + if not constraint_exists("management_tokens", "uq_management_tokens_user_name"): + op.create_unique_constraint( + "uq_management_tokens_user_name", + "management_tokens", + ["user_id", "name"], + ) + # 添加 IP 白名单非空检查约束 + if not constraint_exists("management_tokens", "check_allowed_ips_not_empty"): + op.create_check_constraint( + "check_allowed_ips_not_empty", + "management_tokens", + "allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0", + ) + return + + op.create_table('management_tokens', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('token_prefix', sa.String(length=12), nullable=True), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('allowed_ips', sa.JSON(), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_used_ip', sa.String(length=45), nullable=True), + sa.Column('usage_count', sa.Integer(), server_default='0', nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_management_tokens_is_active', 'management_tokens', ['is_active'], unique=False) + op.create_index('idx_management_tokens_user_id', 'management_tokens', ['user_id'], unique=False) + op.create_index(op.f('ix_management_tokens_token_hash'), 'management_tokens', ['token_hash'], unique=True) + # 添加用户名称唯一约束 + op.create_unique_constraint( + "uq_management_tokens_user_name", + "management_tokens", + ["user_id", "name"], + ) + # 添加 IP 白名单非空检查约束 + # 注意:JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理 + op.create_check_constraint( + "check_allowed_ips_not_empty", + "management_tokens", + "allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0", + ) + + +def downgrade() -> None: + """回滚迁移:删除 management_tokens 表""" + # 幂等性检查 + if not table_exists("management_tokens"): + return + + # 删除约束 + if constraint_exists("management_tokens", "check_allowed_ips_not_empty"): + op.drop_constraint("check_allowed_ips_not_empty", "management_tokens", type_="check") + if constraint_exists("management_tokens", "uq_management_tokens_user_name"): + op.drop_constraint("uq_management_tokens_user_name", "management_tokens", type_="unique") + + # 删除索引 + if index_exists("management_tokens", "ix_management_tokens_token_hash"): + op.drop_index(op.f('ix_management_tokens_token_hash'), table_name='management_tokens') + if index_exists("management_tokens", "idx_management_tokens_user_id"): + op.drop_index('idx_management_tokens_user_id', table_name='management_tokens') + if index_exists("management_tokens", "idx_management_tokens_is_active"): + op.drop_index('idx_management_tokens_is_active', table_name='management_tokens') + + # 删除表 + op.drop_table('management_tokens') diff --git a/frontend/src/api/management-tokens.ts b/frontend/src/api/management-tokens.ts new file mode 100644 index 0000000..ec6defd --- /dev/null +++ b/frontend/src/api/management-tokens.ts @@ -0,0 +1,203 @@ +/** + * Management Token API + */ + +import apiClient from './client' + +// ============== 类型定义 ============== + +export interface ManagementToken { + id: string + user_id: string + name: string + description?: string + token_display: string + allowed_ips?: string[] | null + expires_at?: string | null + last_used_at?: string | null + last_used_ip?: string | null + usage_count: number + is_active: boolean + created_at: string + updated_at: string + user?: { + id: string + email: string + username: string + role: string + } +} + +export interface CreateManagementTokenRequest { + name: string + description?: string + allowed_ips?: string[] + expires_at?: string | null +} + +export interface CreateManagementTokenResponse { + message: string + token: string + data: ManagementToken +} + +export interface UpdateManagementTokenRequest { + name?: string + description?: string | null + allowed_ips?: string[] | null + expires_at?: string | null + is_active?: boolean +} + +export interface ManagementTokenListResponse { + items: ManagementToken[] + total: number + skip: number + limit: number + quota?: { + used: number + max: number + } +} + +// ============== 用户自助管理 API ============== + +export const managementTokenApi = { + /** + * 列出当前用户的 Management Tokens + */ + async listTokens(params?: { + is_active?: boolean + skip?: number + limit?: number + }): Promise { + const response = await apiClient.get( + '/api/me/management-tokens', + { params } + ) + return response.data + }, + + /** + * 创建 Management Token + */ + async createToken( + data: CreateManagementTokenRequest + ): Promise { + const response = await apiClient.post( + '/api/me/management-tokens', + data + ) + return response.data + }, + + /** + * 获取 Token 详情 + */ + async getToken(tokenId: string): Promise { + const response = await apiClient.get( + `/api/me/management-tokens/${tokenId}` + ) + return response.data + }, + + /** + * 更新 Token + */ + async updateToken( + tokenId: string, + data: UpdateManagementTokenRequest + ): Promise<{ message: string; data: ManagementToken }> { + const response = await apiClient.put<{ message: string; data: ManagementToken }>( + `/api/me/management-tokens/${tokenId}`, + data + ) + return response.data + }, + + /** + * 删除 Token + */ + async deleteToken(tokenId: string): Promise<{ message: string }> { + const response = await apiClient.delete<{ message: string }>( + `/api/me/management-tokens/${tokenId}` + ) + return response.data + }, + + /** + * 切换 Token 状态 + */ + async toggleToken( + tokenId: string + ): Promise<{ message: string; data: ManagementToken }> { + const response = await apiClient.patch<{ message: string; data: ManagementToken }>( + `/api/me/management-tokens/${tokenId}/status` + ) + return response.data + }, + + /** + * 重新生成 Token + */ + async regenerateToken( + tokenId: string + ): Promise<{ token: string; data: ManagementToken }> { + const response = await apiClient.post<{ token: string; data: ManagementToken }>( + `/api/me/management-tokens/${tokenId}/regenerate` + ) + return response.data + } +} + +// ============== 管理员 API ============== + +export const adminManagementTokenApi = { + /** + * 列出所有 Management Tokens(管理员) + */ + async listAllTokens(params?: { + user_id?: string + is_active?: boolean + skip?: number + limit?: number + }): Promise { + const response = await apiClient.get( + '/api/admin/management-tokens', + { params } + ) + return response.data + }, + + /** + * 获取 Token 详情(管理员) + */ + async getToken(tokenId: string): Promise { + const response = await apiClient.get( + `/api/admin/management-tokens/${tokenId}` + ) + return response.data + }, + + /** + * 删除任意 Token(管理员) + */ + async deleteToken(tokenId: string): Promise<{ message: string }> { + const response = await apiClient.delete<{ message: string }>( + `/api/admin/management-tokens/${tokenId}` + ) + return response.data + }, + + /** + * 切换任意 Token 状态(管理员) + */ + async toggleToken( + tokenId: string + ): Promise<{ message: string; data: ManagementToken }> { + const response = await apiClient.patch<{ message: string; data: ManagementToken }>( + `/api/admin/management-tokens/${tokenId}/status` + ) + return response.data + } +} diff --git a/frontend/src/components/ui/dialog/Dialog.vue b/frontend/src/components/ui/dialog/Dialog.vue index a77759a..b35b771 100644 --- a/frontend/src/components/ui/dialog/Dialog.vue +++ b/frontend/src/components/ui/dialog/Dialog.vue @@ -18,7 +18,7 @@ v-if="isOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity pointer-events-auto" :style="{ zIndex: backdropZIndex }" - @click="handleClose" + @click="handleBackdropClick" /> @@ -106,6 +106,7 @@ const props = defineProps<{ iconClass?: string // Custom icon color class zIndex?: number // Custom z-index for nested dialogs (default: 60) noPadding?: boolean // Disable default content padding + persistent?: boolean // Prevent closing on backdrop click }>() // Emits 定义 @@ -138,6 +139,13 @@ function handleClose() { } } +// 处理背景点击 +function handleBackdropClick() { + if (!props.persistent) { + handleClose() + } +} + const maxWidthClass = computed(() => { const sizeValue = props.maxWidth || props.size || 'md' const sizes = { @@ -162,7 +170,7 @@ const contentZIndex = computed(() => (props.zIndex || 60) + 10) // 添加 ESC 键监听 useEscapeKey(() => { - if (isOpen.value) { + if (isOpen.value && !props.persistent) { handleClose() return true // 阻止其他监听器(如父级抽屉的 ESC 监听器) } diff --git a/frontend/src/features/auth/components/LoginDialog.vue b/frontend/src/features/auth/components/LoginDialog.vue index 4d05dfe..2cc6baf 100644 --- a/frontend/src/features/auth/components/LoginDialog.vue +++ b/frontend/src/features/auth/components/LoginDialog.vue @@ -73,14 +73,16 @@ >

@@ -92,7 +121,10 @@

-