mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 手动代理节点支持、系统默认代理与跨格式流式 usage 提取
代理节点: - 支持手动添加代理节点(HTTP/HTTPS/SOCKS5),含地址、认证信息和区域标签 - 新增手动节点的 CRUD API 和前端管理界面 - 提供商代理配置从 URL 字符串迁移至代理节点选择器(proxy_node_id) - 新增系统默认代理节点设置,未单独配置代理的提供商自动回退使用 - 删除节点时自动清除系统默认代理引用并失效缓存 - 健康检查跳过手动节点(无心跳,始终在线) 流式处理: - CLI handler 跨格式转换时委托基类解析 Provider 原始事件的 usage - StreamProcessor 新增 _extract_usage_from_converted_event 从转换后事件补充提取 usage - 支持 Claude/OpenAI/OpenAI Responses/Gemini 多种 usage 格式
This commit is contained in:
10
aether-proxy/Cargo.lock
generated
10
aether-proxy/Cargo.lock
generated
@@ -851,6 +851,15 @@ version = "0.1.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-src"
|
||||||
|
version = "300.5.5+3.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.111"
|
version = "0.9.111"
|
||||||
@@ -859,6 +868,7 @@ checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
|
"openssl-src",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Antigravity endpoint signature to gemini:chat & add proxy_nodes table
|
"""Antigravity endpoint signature to gemini:chat & add proxy_nodes table (with manual fields)
|
||||||
|
|
||||||
Revision ID: e1b2c3d4f5a6
|
Revision ID: e1b2c3d4f5a6
|
||||||
Revises: b5c6d7e8f9a0
|
Revises: b5c6d7e8f9a0
|
||||||
@@ -83,7 +83,7 @@ def upgrade() -> None:
|
|||||||
"""))
|
"""))
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Part 2: Create proxy_nodes table (idempotent)
|
# Part 2: Create proxy_nodes table with manual proxy fields (idempotent)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
# Create ENUM type (idempotent)
|
# Create ENUM type (idempotent)
|
||||||
@@ -95,13 +95,40 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if table_exists("proxy_nodes"):
|
if table_exists("proxy_nodes"):
|
||||||
|
# Table already exists — ensure manual proxy columns are present
|
||||||
|
inspector = inspect(conn)
|
||||||
|
existing_columns = {c["name"] for c in inspector.get_columns("proxy_nodes")}
|
||||||
|
|
||||||
|
# ip 列扩容:手动节点的 ip 存储 "socks5://hostname" 形式,45 字符可能不够
|
||||||
|
ip_col = next((c for c in inspector.get_columns("proxy_nodes") if c["name"] == "ip"), None)
|
||||||
|
if ip_col and hasattr(ip_col["type"], "length") and (ip_col["type"].length or 0) < 512:
|
||||||
|
op.alter_column("proxy_nodes", "ip", type_=sa.String(512), existing_nullable=False)
|
||||||
|
|
||||||
|
manual_columns = [
|
||||||
|
("is_manual", sa.Boolean(), False, sa.text("false"), "是否为手动添加的代理节点"),
|
||||||
|
("proxy_url", sa.String(500), True, None, "手动节点的完整代理 URL"),
|
||||||
|
("proxy_username", sa.String(255), True, None, "手动节点的代理用户名"),
|
||||||
|
("proxy_password", sa.String(500), True, None, "手动节点的代理密码"),
|
||||||
|
]
|
||||||
|
for col_name, col_type, nullable, default, comment in manual_columns:
|
||||||
|
if col_name not in existing_columns:
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
col_name,
|
||||||
|
col_type, # type: ignore[arg-type]
|
||||||
|
nullable=nullable,
|
||||||
|
server_default=default,
|
||||||
|
comment=comment,
|
||||||
|
),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"proxy_nodes",
|
"proxy_nodes",
|
||||||
sa.Column("id", sa.String(36), primary_key=True),
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
sa.Column("name", sa.String(100), nullable=False),
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
sa.Column("ip", sa.String(45), nullable=False),
|
sa.Column("ip", sa.String(512), nullable=False),
|
||||||
sa.Column("port", sa.Integer(), nullable=False),
|
sa.Column("port", sa.Integer(), nullable=False),
|
||||||
sa.Column("region", sa.String(100), nullable=True),
|
sa.Column("region", sa.String(100), nullable=True),
|
||||||
sa.Column(
|
sa.Column(
|
||||||
@@ -127,6 +154,32 @@ def upgrade() -> None:
|
|||||||
sa.Column("active_connections", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
sa.Column("active_connections", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
sa.Column("total_requests", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
sa.Column("total_requests", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||||
sa.Column("avg_latency_ms", sa.Float(), nullable=True),
|
sa.Column("avg_latency_ms", sa.Float(), nullable=True),
|
||||||
|
# --- Manual proxy node fields ---
|
||||||
|
sa.Column(
|
||||||
|
"is_manual",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
comment="是否为手动添加的代理节点",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"proxy_url",
|
||||||
|
sa.String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="手动节点的完整代理 URL",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"proxy_username",
|
||||||
|
sa.String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="手动节点的代理用户名",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"proxy_password",
|
||||||
|
sa.String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="手动节点的代理密码",
|
||||||
|
),
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"created_at",
|
"created_at",
|
||||||
sa.DateTime(timezone=True),
|
sa.DateTime(timezone=True),
|
||||||
@@ -147,7 +200,7 @@ def downgrade() -> None:
|
|||||||
conn = op.get_bind()
|
conn = op.get_bind()
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Part 2 rollback: Drop proxy_nodes table
|
# Part 2 rollback: Drop proxy_nodes table (and manual columns if present)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
if table_exists("proxy_nodes"):
|
if table_exists("proxy_nodes"):
|
||||||
op.drop_table("proxy_nodes")
|
op.drop_table("proxy_nodes")
|
||||||
|
|||||||
@@ -81,12 +81,16 @@ export function sortApiFormats(formats: string[]): string[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 代理配置类型
|
* 代理配置类型
|
||||||
|
* 支持两种模式:
|
||||||
|
* - 手动配置:设置 url/username/password
|
||||||
|
* - 代理节点:设置 node_id(与 url 互斥)
|
||||||
*/
|
*/
|
||||||
export interface ProxyConfig {
|
export interface ProxyConfig {
|
||||||
url: string
|
url?: string
|
||||||
username?: string
|
username?: string
|
||||||
password?: string
|
password?: string
|
||||||
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
node_id?: string // 代理节点 ID(aether-proxy 注册的节点,与 url 互斥)
|
||||||
|
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface ProxyNode {
|
|||||||
port: number
|
port: number
|
||||||
region: string | null
|
region: string | null
|
||||||
status: 'online' | 'unhealthy' | 'offline'
|
status: 'online' | 'unhealthy' | 'offline'
|
||||||
|
is_manual: boolean
|
||||||
|
// 手动节点专用字段
|
||||||
|
proxy_url?: string
|
||||||
|
proxy_username?: string
|
||||||
|
proxy_password?: string // 脱敏后的密码
|
||||||
registered_by: string | null
|
registered_by: string | null
|
||||||
last_heartbeat_at: string | null
|
last_heartbeat_at: string | null
|
||||||
heartbeat_interval: number
|
heartbeat_interval: number
|
||||||
@@ -24,13 +29,40 @@ export interface ProxyNodeListResponse {
|
|||||||
limit: number
|
limit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManualProxyNodeCreateRequest {
|
||||||
|
name: string
|
||||||
|
proxy_url: string
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
region?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualProxyNodeUpdateRequest {
|
||||||
|
name?: string
|
||||||
|
proxy_url?: string
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
region?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const proxyNodesApi = {
|
export const proxyNodesApi = {
|
||||||
async listProxyNodes(params?: { status?: string; skip?: number; limit?: number }): Promise<ProxyNodeListResponse> {
|
async listProxyNodes(params?: { status?: string; skip?: number; limit?: number }): Promise<ProxyNodeListResponse> {
|
||||||
const response = await apiClient.get<ProxyNodeListResponse>('/api/admin/proxy-nodes', { params })
|
const response = await apiClient.get<ProxyNodeListResponse>('/api/admin/proxy-nodes', { params })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteProxyNode(nodeId: string): Promise<void> {
|
async createManualNode(data: ManualProxyNodeCreateRequest): Promise<{ node_id: string; node: ProxyNode }> {
|
||||||
await apiClient.delete(`/api/admin/proxy-nodes/${nodeId}`)
|
const response = await apiClient.post<{ node_id: string; node: ProxyNode }>('/api/admin/proxy-nodes/manual', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateManualNode(nodeId: string, data: ManualProxyNodeUpdateRequest): Promise<{ node_id: string; node: ProxyNode }> {
|
||||||
|
const response = await apiClient.patch<{ node_id: string; node: ProxyNode }>(`/api/admin/proxy-nodes/${nodeId}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteProxyNode(nodeId: string): Promise<{ message: string; node_id: string; cleared_system_proxy: boolean }> {
|
||||||
|
const response = await apiClient.delete<{ message: string; node_id: string; cleared_system_proxy: boolean }>(`/api/admin/proxy-nodes/${nodeId}`)
|
||||||
|
return response.data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
|
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
|
||||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||||
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
|
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||||
|
|
||||||
export const anyrouterTemplate: AuthTemplate = {
|
export const anyrouterTemplate: AuthTemplate = {
|
||||||
id: 'anyrouter',
|
id: 'anyrouter',
|
||||||
@@ -52,7 +52,7 @@ export const anyrouterTemplate: AuthTemplate = {
|
|||||||
connector: {
|
connector: {
|
||||||
auth_type: 'cookie',
|
auth_type: 'cookie',
|
||||||
config: {
|
config: {
|
||||||
proxy: buildProxyUrl(formData),
|
...buildProxyConfig(formData),
|
||||||
},
|
},
|
||||||
credentials: {
|
credentials: {
|
||||||
session_cookie: formData.session_cookie,
|
session_cookie: formData.session_cookie,
|
||||||
@@ -64,7 +64,7 @@ export const anyrouterTemplate: AuthTemplate = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
parseConfig(config: any): Record<string, any> {
|
parseConfig(config: any): Record<string, any> {
|
||||||
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
|
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||||
return {
|
return {
|
||||||
base_url: config?.base_url || '',
|
base_url: config?.base_url || '',
|
||||||
session_cookie: config?.connector?.credentials?.session_cookie || '',
|
session_cookie: config?.connector?.credentials?.session_cookie || '',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
||||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||||
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
|
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化窗口限额显示(百分比格式)
|
* 格式化窗口限额显示(百分比格式)
|
||||||
@@ -92,7 +92,7 @@ export const cubenceTemplate: AuthTemplate = {
|
|||||||
connector: {
|
connector: {
|
||||||
auth_type: 'cookie',
|
auth_type: 'cookie',
|
||||||
config: {
|
config: {
|
||||||
proxy: buildProxyUrl(formData),
|
...buildProxyConfig(formData),
|
||||||
},
|
},
|
||||||
credentials: {
|
credentials: {
|
||||||
token_cookie: formData.token_cookie,
|
token_cookie: formData.token_cookie,
|
||||||
@@ -104,7 +104,7 @@ export const cubenceTemplate: AuthTemplate = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
parseConfig(config: any): Record<string, any> {
|
parseConfig(config: any): Record<string, any> {
|
||||||
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
|
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||||
return {
|
return {
|
||||||
base_url: config?.base_url || '',
|
base_url: config?.base_url || '',
|
||||||
token_cookie: config?.connector?.credentials?.token_cookie || '',
|
token_cookie: config?.connector?.credentials?.token_cookie || '',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
||||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||||
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
|
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||||
|
|
||||||
export const nekocodeTemplate: AuthTemplate = {
|
export const nekocodeTemplate: AuthTemplate = {
|
||||||
id: 'nekocode',
|
id: 'nekocode',
|
||||||
@@ -51,7 +51,7 @@ export const nekocodeTemplate: AuthTemplate = {
|
|||||||
connector: {
|
connector: {
|
||||||
auth_type: 'cookie',
|
auth_type: 'cookie',
|
||||||
config: {
|
config: {
|
||||||
proxy: buildProxyUrl(formData),
|
...buildProxyConfig(formData),
|
||||||
},
|
},
|
||||||
credentials: {
|
credentials: {
|
||||||
session_cookie: formData.session_cookie,
|
session_cookie: formData.session_cookie,
|
||||||
@@ -63,7 +63,7 @@ export const nekocodeTemplate: AuthTemplate = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
parseConfig(config: any): Record<string, any> {
|
parseConfig(config: any): Record<string, any> {
|
||||||
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
|
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||||
return {
|
return {
|
||||||
base_url: config?.base_url || '',
|
base_url: config?.base_url || '',
|
||||||
session_cookie: config?.connector?.credentials?.session_cookie || '',
|
session_cookie: config?.connector?.credentials?.session_cookie || '',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
|
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
|
||||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||||
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
|
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||||
|
|
||||||
export const newApiTemplate: AuthTemplate = {
|
export const newApiTemplate: AuthTemplate = {
|
||||||
id: 'new_api',
|
id: 'new_api',
|
||||||
@@ -80,7 +80,7 @@ export const newApiTemplate: AuthTemplate = {
|
|||||||
auth_type: 'api_key',
|
auth_type: 'api_key',
|
||||||
config: {
|
config: {
|
||||||
auth_method: 'bearer',
|
auth_method: 'bearer',
|
||||||
proxy: buildProxyUrl(formData),
|
...buildProxyConfig(formData),
|
||||||
},
|
},
|
||||||
credentials: {
|
credentials: {
|
||||||
// 敏感字段始终发送(空字符串会触发后端合并已保存的值)
|
// 敏感字段始终发送(空字符串会触发后端合并已保存的值)
|
||||||
@@ -95,7 +95,7 @@ export const newApiTemplate: AuthTemplate = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
parseConfig(config: any): Record<string, any> {
|
parseConfig(config: any): Record<string, any> {
|
||||||
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
|
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||||
return {
|
return {
|
||||||
base_url: config?.base_url || '',
|
base_url: config?.base_url || '',
|
||||||
api_key: config?.connector?.credentials?.api_key || '',
|
api_key: config?.connector?.credentials?.api_key || '',
|
||||||
|
|||||||
@@ -165,8 +165,30 @@ export interface AuthTemplateRegistry {
|
|||||||
// ==================== 通用字段定义 ====================
|
// ==================== 通用字段定义 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代理地址字段
|
* 代理节点 ID 字段
|
||||||
*/
|
*/
|
||||||
|
export const PROXY_NODE_FIELD: AuthTemplateField = {
|
||||||
|
key: 'proxy_node_id',
|
||||||
|
label: '代理节点',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '选择代理节点...',
|
||||||
|
required: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用代理配置字段组(可折叠,带启用开关)
|
||||||
|
* 由 ProviderAuthDialog 特殊渲染为代理节点选择器
|
||||||
|
*/
|
||||||
|
export const PROXY_FIELD_GROUP: AuthTemplateFieldGroup = {
|
||||||
|
title: '代理配置',
|
||||||
|
fields: [PROXY_NODE_FIELD],
|
||||||
|
collapsible: true,
|
||||||
|
defaultExpanded: false,
|
||||||
|
hasToggle: true,
|
||||||
|
toggleKey: 'proxy_enabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧代码的字段导出
|
||||||
export const PROXY_URL_FIELD: AuthTemplateField = {
|
export const PROXY_URL_FIELD: AuthTemplateField = {
|
||||||
key: 'proxy_url',
|
key: 'proxy_url',
|
||||||
label: '代理地址',
|
label: '代理地址',
|
||||||
@@ -174,10 +196,6 @@ export const PROXY_URL_FIELD: AuthTemplateField = {
|
|||||||
placeholder: 'http://proxy:port 或 socks5://',
|
placeholder: 'http://proxy:port 或 socks5://',
|
||||||
required: false,
|
required: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 代理用户名字段
|
|
||||||
*/
|
|
||||||
export const PROXY_USERNAME_FIELD: AuthTemplateField = {
|
export const PROXY_USERNAME_FIELD: AuthTemplateField = {
|
||||||
key: 'proxy_username',
|
key: 'proxy_username',
|
||||||
label: '用户名',
|
label: '用户名',
|
||||||
@@ -185,10 +203,6 @@ export const PROXY_USERNAME_FIELD: AuthTemplateField = {
|
|||||||
placeholder: '可选',
|
placeholder: '可选',
|
||||||
required: false,
|
required: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 代理密码字段
|
|
||||||
*/
|
|
||||||
export const PROXY_PASSWORD_FIELD: AuthTemplateField = {
|
export const PROXY_PASSWORD_FIELD: AuthTemplateField = {
|
||||||
key: 'proxy_password',
|
key: 'proxy_password',
|
||||||
label: '密码',
|
label: '密码',
|
||||||
@@ -199,96 +213,46 @@ export const PROXY_PASSWORD_FIELD: AuthTemplateField = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用代理配置字段组(可折叠,带启用开关)
|
* 构建代理配置(仅代理节点模式)
|
||||||
*/
|
|
||||||
export const PROXY_FIELD_GROUP: AuthTemplateFieldGroup = {
|
|
||||||
title: '代理配置',
|
|
||||||
fields: [PROXY_URL_FIELD, PROXY_USERNAME_FIELD, PROXY_PASSWORD_FIELD],
|
|
||||||
collapsible: true,
|
|
||||||
defaultExpanded: false,
|
|
||||||
hasToggle: true,
|
|
||||||
toggleKey: 'proxy_enabled',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建代理 URL(包含认证信息)
|
|
||||||
*
|
*
|
||||||
* @param formData 表单数据
|
* @param formData 表单数据
|
||||||
* @returns 完整的代理 URL,或 undefined
|
* @returns 代理配置对象,展开到 connector.config 中
|
||||||
*/
|
*/
|
||||||
export function buildProxyUrl(formData: Record<string, any>): string | undefined {
|
export function buildProxyConfig(formData: Record<string, any>): { proxy_node_id?: string } {
|
||||||
if (!formData.proxy_enabled || !formData.proxy_url) {
|
if (!formData.proxy_enabled || !formData.proxy_node_id) {
|
||||||
return undefined
|
return {}
|
||||||
}
|
|
||||||
|
|
||||||
const proxyUrl = formData.proxy_url.trim()
|
|
||||||
const username = formData.proxy_username?.trim()
|
|
||||||
const password = formData.proxy_password?.trim()
|
|
||||||
|
|
||||||
// 如果没有认证信息,直接返回 URL
|
|
||||||
if (!username) {
|
|
||||||
return proxyUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 URL 并添加认证信息
|
|
||||||
try {
|
|
||||||
const url = new URL(proxyUrl)
|
|
||||||
url.username = username
|
|
||||||
if (password) {
|
|
||||||
url.password = password
|
|
||||||
}
|
|
||||||
return url.toString()
|
|
||||||
} catch {
|
|
||||||
// URL 解析失败,尝试简单拼接
|
|
||||||
const protocol = proxyUrl.includes('://') ? proxyUrl.split('://')[0] : 'http'
|
|
||||||
const host = proxyUrl.includes('://') ? proxyUrl.split('://')[1] : proxyUrl
|
|
||||||
const auth = password ? `${username}:${password}` : username
|
|
||||||
return `${protocol}://${auth}@${host}`
|
|
||||||
}
|
}
|
||||||
|
return { proxy_node_id: formData.proxy_node_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从代理 URL 解析表单数据
|
* 解析代理配置
|
||||||
*
|
*
|
||||||
* @param proxyUrl 代理 URL
|
* @param config connector.config 对象
|
||||||
* @returns 表单数据
|
* @returns 表单数据
|
||||||
*/
|
*/
|
||||||
export function parseProxyUrl(proxyUrl: string | undefined): Record<string, any> {
|
export function parseProxyConfig(config: any): Record<string, any> {
|
||||||
if (!proxyUrl) {
|
// 代理节点模式
|
||||||
|
if (config?.proxy_node_id) {
|
||||||
return {
|
return {
|
||||||
proxy_enabled: false,
|
proxy_enabled: true,
|
||||||
proxy_url: '',
|
proxy_node_id: config.proxy_node_id,
|
||||||
proxy_username: '',
|
|
||||||
proxy_password: '',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 兼容旧数据(手动 URL 模式)- 标记为启用但无节点
|
||||||
const url = new URL(proxyUrl)
|
if (config?.proxy) {
|
||||||
const username = url.username || ''
|
|
||||||
const password = url.password || ''
|
|
||||||
|
|
||||||
// 移除认证信息后的 URL
|
|
||||||
url.username = ''
|
|
||||||
url.password = ''
|
|
||||||
const cleanUrl = url.toString()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
proxy_enabled: true,
|
proxy_enabled: true,
|
||||||
proxy_url: cleanUrl,
|
proxy_node_id: '',
|
||||||
proxy_username: username,
|
|
||||||
proxy_password: password,
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 解析失败,直接使用原始 URL
|
|
||||||
return {
|
|
||||||
proxy_enabled: true,
|
|
||||||
proxy_url: proxyUrl,
|
|
||||||
proxy_username: '',
|
|
||||||
proxy_password: '',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
proxy_enabled: false,
|
||||||
|
proxy_node_id: '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容旧的导出(已废弃,请使用 PROXY_FIELD_GROUP)
|
// 兼容旧的导出
|
||||||
export const PROXY_FIELD: AuthTemplateField = PROXY_URL_FIELD
|
export const PROXY_FIELD: AuthTemplateField = PROXY_URL_FIELD
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
||||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||||
import { PROXY_FIELD_GROUP, buildProxyUrl, parseProxyUrl } from './types'
|
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化限额显示(百分比格式)
|
* 格式化限额显示(百分比格式)
|
||||||
@@ -65,7 +65,7 @@ export const yescodeTemplate: AuthTemplate = {
|
|||||||
connector: {
|
connector: {
|
||||||
auth_type: 'cookie',
|
auth_type: 'cookie',
|
||||||
config: {
|
config: {
|
||||||
proxy: buildProxyUrl(formData),
|
...buildProxyConfig(formData),
|
||||||
},
|
},
|
||||||
credentials: {
|
credentials: {
|
||||||
auth_cookie: formData.auth_cookie,
|
auth_cookie: formData.auth_cookie,
|
||||||
@@ -77,7 +77,7 @@ export const yescodeTemplate: AuthTemplate = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
parseConfig(config: any): Record<string, any> {
|
parseConfig(config: any): Record<string, any> {
|
||||||
const proxyData = parseProxyUrl(config?.connector?.config?.proxy)
|
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||||
return {
|
return {
|
||||||
base_url: config?.base_url || '',
|
base_url: config?.base_url || '',
|
||||||
auth_cookie: config?.connector?.credentials?.auth_cookie || '',
|
auth_cookie: config?.connector?.credentials?.auth_cookie || '',
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
v-for="(group, groupIndex) in fieldGroups"
|
v-for="(group, groupIndex) in fieldGroups"
|
||||||
:key="groupIndex"
|
:key="groupIndex"
|
||||||
>
|
>
|
||||||
<!-- 可折叠的分组(如代理配置) -->
|
<!-- 可折叠的分组(代理配置 - 代理节点选择) -->
|
||||||
<div
|
<div
|
||||||
v-if="group.collapsible && group.hasToggle && group.toggleKey"
|
v-if="group.collapsible && group.hasToggle && group.toggleKey"
|
||||||
class="space-y-2"
|
class="space-y-2"
|
||||||
@@ -65,50 +65,22 @@
|
|||||||
<span class="text-xs text-muted-foreground">启用代理</span>
|
<span class="text-xs text-muted-foreground">启用代理</span>
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="formData[group.toggleKey] || false"
|
:model-value="formData[group.toggleKey] || false"
|
||||||
@update:model-value="formData[group.toggleKey] = $event"
|
@update:model-value="handleProxyToggle(group.toggleKey, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 展开内容(卡片) -->
|
<!-- 展开内容(卡片)- 代理节点选择 -->
|
||||||
<div
|
<div
|
||||||
v-if="formData[group.toggleKey]"
|
v-if="formData[group.toggleKey]"
|
||||||
class="rounded-lg border border-border bg-muted/30 px-4 py-3"
|
class="rounded-lg border border-border bg-muted/30 px-4 py-3"
|
||||||
>
|
>
|
||||||
<!-- 横向排列的字段:代理地址占更多空间 -->
|
<ProxyNodeSelect
|
||||||
<div class="flex gap-3">
|
ref="proxyNodeSelectRef"
|
||||||
<div
|
:model-value="formData.proxy_node_id || ''"
|
||||||
v-for="(field, fieldIndex) in group.fields"
|
trigger-class="h-8"
|
||||||
:key="field.key"
|
@update:model-value="(v: string) => { formData.proxy_node_id = v; handleFieldChange('proxy_node_id', v) }"
|
||||||
class="space-y-1"
|
/>
|
||||||
:class="fieldIndex === 0 ? 'flex-[2]' : 'flex-1'"
|
|
||||||
>
|
|
||||||
<Label class="text-xs text-muted-foreground">
|
|
||||||
{{ field.label }}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<!-- 文本输入 -->
|
|
||||||
<Input
|
|
||||||
v-if="field.type === 'text'"
|
|
||||||
v-model="formData[field.key]"
|
|
||||||
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
|
|
||||||
:masked="field.sensitive"
|
|
||||||
disable-autofill
|
|
||||||
class="h-8 text-sm"
|
|
||||||
@update:model-value="handleFieldChange(field.key, $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 密码/敏感输入 -->
|
|
||||||
<Input
|
|
||||||
v-else-if="field.type === 'password'"
|
|
||||||
v-model="formData[field.key]"
|
|
||||||
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
|
|
||||||
masked
|
|
||||||
class="h-8 text-sm"
|
|
||||||
@update:model-value="handleFieldChange(field.key, $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -304,6 +276,8 @@ import {
|
|||||||
type AuthTemplate,
|
type AuthTemplate,
|
||||||
type AuthTemplateFieldGroup,
|
type AuthTemplateFieldGroup,
|
||||||
} from '../auth-templates'
|
} from '../auth-templates'
|
||||||
|
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||||
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -322,6 +296,16 @@ const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cooki
|
|||||||
|
|
||||||
const { success: showSuccess, error: showError } = useToast()
|
const { success: showSuccess, error: showError } = useToast()
|
||||||
const { confirmDanger } = useConfirm()
|
const { confirmDanger } = useConfirm()
|
||||||
|
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
|
||||||
|
const proxyNodesStore = useProxyNodesStore()
|
||||||
|
|
||||||
|
/** 启用代理时加载节点列表(直接调用 store,避免 ref 未挂载时静默失败) */
|
||||||
|
function handleProxyToggle(toggleKey: string, value: boolean) {
|
||||||
|
formData.value[toggleKey] = value
|
||||||
|
if (value) {
|
||||||
|
proxyNodesStore.ensureLoaded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
|||||||
@@ -238,47 +238,20 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="form.proxy_enabled"
|
:model-value="form.proxy_enabled"
|
||||||
@update:model-value="(v: boolean) => form.proxy_enabled = v"
|
@update:model-value="handleProxyToggle"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-muted-foreground">启用代理</span>
|
<span class="text-sm text-muted-foreground">启用代理</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="form.proxy_enabled"
|
v-if="form.proxy_enabled"
|
||||||
class="grid grid-cols-2 gap-4 p-3 border rounded-lg bg-muted/50"
|
class="space-y-1.5 p-3 border rounded-lg bg-muted/50"
|
||||||
>
|
>
|
||||||
<div class="space-y-1.5">
|
<Label class="text-xs">代理节点 *</Label>
|
||||||
<Label class="text-xs">代理地址 *</Label>
|
<ProxyNodeSelect
|
||||||
<Input
|
ref="proxyNodeSelectRef"
|
||||||
v-model="form.proxy_url"
|
v-model="form.proxy_node_id"
|
||||||
placeholder="http://proxy:port 或 socks5://proxy:port"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label class="text-xs">用户名</Label>
|
|
||||||
<Input
|
|
||||||
v-model="form.proxy_username"
|
|
||||||
placeholder="可选"
|
|
||||||
autocomplete="off"
|
|
||||||
data-form-type="other"
|
|
||||||
data-lpignore="true"
|
|
||||||
data-1p-ignore="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label class="text-xs">密码</Label>
|
|
||||||
<Input
|
|
||||||
v-model="form.proxy_password"
|
|
||||||
type="password"
|
|
||||||
placeholder="可选"
|
|
||||||
autocomplete="new-password"
|
|
||||||
data-form-type="other"
|
|
||||||
data-lpignore="true"
|
|
||||||
data-1p-ignore="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -322,6 +295,8 @@ import { useFormDialog } from '@/composables/useFormDialog'
|
|||||||
import { createProvider, updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
import { createProvider, updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { parseNumberInput } from '@/utils/form'
|
import { parseNumberInput } from '@/utils/form'
|
||||||
|
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||||
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -337,6 +312,16 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
|
||||||
|
const proxyNodesStore = useProxyNodesStore()
|
||||||
|
|
||||||
|
/** 启用代理时懒加载节点列表 */
|
||||||
|
function handleProxyToggle(v: boolean) {
|
||||||
|
form.value.proxy_enabled = v
|
||||||
|
if (v) {
|
||||||
|
proxyNodesStore.ensureLoaded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 内部状态
|
// 内部状态
|
||||||
const internalOpen = computed(() => props.modelValue)
|
const internalOpen = computed(() => props.modelValue)
|
||||||
@@ -372,11 +357,9 @@ const form = ref({
|
|||||||
// 超时配置(秒)
|
// 超时配置(秒)
|
||||||
stream_first_byte_timeout: undefined as number | undefined,
|
stream_first_byte_timeout: undefined as number | undefined,
|
||||||
request_timeout: undefined as number | undefined,
|
request_timeout: undefined as number | undefined,
|
||||||
// 代理配置(扁平化便于表单绑定)
|
// 代理配置
|
||||||
proxy_enabled: false,
|
proxy_enabled: false,
|
||||||
proxy_url: '',
|
proxy_node_id: '',
|
||||||
proxy_username: '',
|
|
||||||
proxy_password: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 重置表单
|
// 重置表单
|
||||||
@@ -403,9 +386,7 @@ function resetForm() {
|
|||||||
request_timeout: undefined,
|
request_timeout: undefined,
|
||||||
// 代理配置
|
// 代理配置
|
||||||
proxy_enabled: false,
|
proxy_enabled: false,
|
||||||
proxy_url: '',
|
proxy_node_id: '',
|
||||||
proxy_username: '',
|
|
||||||
proxy_password: '',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,9 +419,12 @@ function loadProviderData() {
|
|||||||
request_timeout: props.provider.request_timeout ?? undefined,
|
request_timeout: props.provider.request_timeout ?? undefined,
|
||||||
// 代理配置
|
// 代理配置
|
||||||
proxy_enabled: proxy?.enabled ?? false,
|
proxy_enabled: proxy?.enabled ?? false,
|
||||||
proxy_url: proxy?.url || '',
|
proxy_node_id: proxy?.node_id || '',
|
||||||
proxy_username: proxy?.username || '',
|
}
|
||||||
proxy_password: proxy?.password || '',
|
|
||||||
|
// 如果有代理配置,确保加载节点列表(直接调用 store,避免 ref 未挂载时静默失败)
|
||||||
|
if (proxy?.enabled) {
|
||||||
|
proxyNodesStore.ensureLoaded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,19 +446,17 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启用代理时必须填写代理地址
|
// 启用代理时的验证
|
||||||
if (form.value.proxy_enabled && !form.value.proxy_url) {
|
if (form.value.proxy_enabled && !form.value.proxy_node_id) {
|
||||||
showError('启用代理时必须填写代理地址', '验证失败')
|
showError('请选择代理节点', '验证失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// 构建代理配置
|
// 构建代理配置
|
||||||
const proxy = form.value.proxy_enabled ? {
|
const proxy = form.value.proxy_enabled && form.value.proxy_node_id ? {
|
||||||
url: form.value.proxy_url,
|
node_id: form.value.proxy_node_id,
|
||||||
username: form.value.proxy_username || undefined,
|
|
||||||
password: form.value.proxy_password || undefined,
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
} : null
|
} : null
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Select
|
||||||
|
:model-value="modelValue"
|
||||||
|
:disabled="proxyNodesStore.loading || nodeOptions.length === 0"
|
||||||
|
@update:model-value="(v: string) => $emit('update:modelValue', v)"
|
||||||
|
>
|
||||||
|
<SelectTrigger :class="triggerClass">
|
||||||
|
<SelectValue
|
||||||
|
:placeholder="proxyNodesStore.loading
|
||||||
|
? '加载节点列表中...'
|
||||||
|
: nodeOptions.length === 0
|
||||||
|
? '暂无可用节点'
|
||||||
|
: '选择代理节点...'"
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="node in nodeOptions"
|
||||||
|
:key="node.id"
|
||||||
|
:value="node.id"
|
||||||
|
>
|
||||||
|
{{ node.name }}{{ node.region ? ` · ${node.region}` : '' }} ({{ node.ip }}:{{ node.port }})
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p
|
||||||
|
v-if="!proxyNodesStore.loading && nodeOptions.length === 0"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无在线代理节点,请在「代理节点」页面添加
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui'
|
||||||
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
triggerClass?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const proxyNodesStore = useProxyNodesStore()
|
||||||
|
|
||||||
|
/** 在线节点 + 保留当前已选节点(可能已离线) */
|
||||||
|
const nodeOptions = computed(() => {
|
||||||
|
const online = proxyNodesStore.onlineNodes
|
||||||
|
if (props.modelValue) {
|
||||||
|
const found = online.find(n => n.id === props.modelValue)
|
||||||
|
if (!found) {
|
||||||
|
const allNode = proxyNodesStore.nodes.find(n => n.id === props.modelValue)
|
||||||
|
if (allNode) return [allNode, ...online]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return online
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 供父组件调用:启用代理时懒加载节点列表 */
|
||||||
|
function ensureLoaded() {
|
||||||
|
proxyNodesStore.ensureLoaded()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ ensureLoaded })
|
||||||
|
</script>
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { proxyNodesApi, type ProxyNode } from '@/api/proxy-nodes'
|
import { proxyNodesApi, type ProxyNode, type ManualProxyNodeCreateRequest } from '@/api/proxy-nodes'
|
||||||
|
|
||||||
export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
||||||
const nodes = ref<ProxyNode[]>([])
|
const nodes = ref<ProxyNode[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
/** 标记是否已加载过(避免重复请求) */
|
||||||
|
const fetched = ref(false)
|
||||||
|
|
||||||
|
/** 在线节点(可用于代理选择) */
|
||||||
|
const onlineNodes = computed(() =>
|
||||||
|
nodes.value.filter(n => n.status === 'online')
|
||||||
|
)
|
||||||
|
|
||||||
async function fetchNodes(params?: { status?: string }) {
|
async function fetchNodes(params?: { status?: string }) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -16,6 +23,7 @@ export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
|||||||
const data = await proxyNodesApi.listProxyNodes({ ...params, limit: 1000 })
|
const data = await proxyNodesApi.listProxyNodes({ ...params, limit: 1000 })
|
||||||
nodes.value = data.items
|
nodes.value = data.items
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
|
fetched.value = true
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取代理节点列表失败'
|
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取代理节点列表失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -23,6 +31,30 @@ export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确保节点列表已加载(懒加载,不重复请求) */
|
||||||
|
async function ensureLoaded() {
|
||||||
|
if (!fetched.value && !loading.value) {
|
||||||
|
await fetchNodes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createManualNode(data: ManualProxyNodeCreateRequest) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await proxyNodesApi.createManualNode(data)
|
||||||
|
// 重新获取列表以保持排序一致
|
||||||
|
await fetchNodes()
|
||||||
|
return result
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '创建手动代理节点失败'
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteNode(nodeId: string) {
|
async function deleteNode(nodeId: string) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -44,7 +76,11 @@ export const useProxyNodesStore = defineStore('proxy-nodes', () => {
|
|||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
fetched,
|
||||||
|
onlineNodes,
|
||||||
fetchNodes,
|
fetchNodes,
|
||||||
|
ensureLoaded,
|
||||||
|
createManualNode,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,10 +9,20 @@
|
|||||||
<h3 class="text-base font-semibold">
|
<h3 class="text-base font-semibold">
|
||||||
代理节点
|
代理节点
|
||||||
</h3>
|
</h3>
|
||||||
<RefreshButton
|
<div class="flex items-center gap-2">
|
||||||
:loading="store.loading"
|
<Button
|
||||||
@click="refresh"
|
size="sm"
|
||||||
/>
|
class="h-7 text-xs"
|
||||||
|
@click="showAddDialog = true"
|
||||||
|
>
|
||||||
|
<Plus class="w-3 h-3 mr-1" />
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
<RefreshButton
|
||||||
|
:loading="store.loading"
|
||||||
|
@click="refresh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
@@ -66,6 +76,15 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<div class="h-4 w-px bg-border" />
|
<div class="h-4 w-px bg-border" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-xs"
|
||||||
|
@click="showAddDialog = true"
|
||||||
|
>
|
||||||
|
<Plus class="w-3.5 h-3.5 mr-1" />
|
||||||
|
手动添加
|
||||||
|
</Button>
|
||||||
|
<div class="h-4 w-px bg-border" />
|
||||||
<RefreshButton
|
<RefreshButton
|
||||||
:loading="store.loading"
|
:loading="store.loading"
|
||||||
@click="refresh"
|
@click="refresh"
|
||||||
@@ -97,10 +116,19 @@
|
|||||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
<TableCell class="py-4">
|
<TableCell class="py-4">
|
||||||
<span class="text-sm font-semibold">{{ node.name }}</span>
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-sm font-semibold">{{ node.name }}</span>
|
||||||
|
<Badge
|
||||||
|
v-if="node.is_manual"
|
||||||
|
variant="outline"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
手动
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-4">
|
<TableCell class="py-4">
|
||||||
<code class="text-xs text-muted-foreground">{{ node.ip }}:{{ node.port }}</code>
|
<code class="text-xs text-muted-foreground">{{ node.is_manual ? (node.proxy_url || `${node.ip}:${node.port}`) : `${node.ip}:${node.port}` }}</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-4">
|
<TableCell class="py-4">
|
||||||
<span class="text-sm text-muted-foreground">{{ node.region || '-' }}</span>
|
<span class="text-sm text-muted-foreground">{{ node.region || '-' }}</span>
|
||||||
@@ -123,15 +151,27 @@
|
|||||||
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-4 text-center">
|
<TableCell class="py-4 text-center">
|
||||||
<Button
|
<div class="flex items-center justify-center gap-0.5">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
v-if="node.is_manual"
|
||||||
class="h-8 w-8"
|
variant="ghost"
|
||||||
title="删除"
|
size="icon"
|
||||||
@click="handleDelete(node)"
|
class="h-8 w-8"
|
||||||
>
|
title="编辑"
|
||||||
<Trash2 class="h-4 w-4" />
|
@click="handleEdit(node)"
|
||||||
</Button>
|
>
|
||||||
|
<SquarePen class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="删除"
|
||||||
|
@click="handleDelete(node)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow v-if="paginatedNodes.length === 0">
|
<TableRow v-if="paginatedNodes.length === 0">
|
||||||
@@ -152,8 +192,17 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-start justify-between mb-2">
|
<div class="flex items-start justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-sm">{{ node.name }}</div>
|
<div class="flex items-center gap-1.5">
|
||||||
<code class="text-xs text-muted-foreground">{{ node.ip }}:{{ node.port }}</code>
|
<span class="font-semibold text-sm">{{ node.name }}</span>
|
||||||
|
<Badge
|
||||||
|
v-if="node.is_manual"
|
||||||
|
variant="outline"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
手动
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<code class="text-xs text-muted-foreground">{{ node.is_manual ? (node.proxy_url || `${node.ip}:${node.port}`) : `${node.ip}:${node.port}` }}</code>
|
||||||
</div>
|
</div>
|
||||||
<Badge :variant="statusVariant(node.status)" class="text-xs">
|
<Badge :variant="statusVariant(node.status)" class="text-xs">
|
||||||
{{ statusLabel(node.status) }}
|
{{ statusLabel(node.status) }}
|
||||||
@@ -175,15 +224,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
||||||
<Button
|
<div class="flex items-center gap-1">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
v-if="node.is_manual"
|
||||||
class="h-7 px-2 text-xs"
|
variant="ghost"
|
||||||
@click="handleDelete(node)"
|
size="sm"
|
||||||
>
|
class="h-7 px-2 text-xs"
|
||||||
<Trash2 class="h-3 w-3 mr-1" />
|
@click="handleEdit(node)"
|
||||||
删除
|
>
|
||||||
</Button>
|
<SquarePen class="h-3 w-3 mr-1" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
@click="handleDelete(node)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3 w-3 mr-1" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="paginatedNodes.length === 0" class="p-8 text-center text-muted-foreground text-sm">
|
<div v-if="paginatedNodes.length === 0" class="p-8 text-center text-muted-foreground text-sm">
|
||||||
@@ -201,6 +262,82 @@
|
|||||||
@update:page-size="pageSize = $event"
|
@update:page-size="pageSize = $event"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
<!-- 手动添加/编辑代理节点对话框 -->
|
||||||
|
<Dialog
|
||||||
|
:model-value="showAddDialog"
|
||||||
|
:title="editingNode ? '编辑代理节点' : '手动添加代理节点'"
|
||||||
|
:description="editingNode ? '修改手动代理节点的配置' : '手动配置的代理节点,用于无法部署 aether-proxy 的场景'"
|
||||||
|
:icon="editingNode ? SquarePen : Plus"
|
||||||
|
size="md"
|
||||||
|
@update:model-value="handleDialogClose"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="space-y-4"
|
||||||
|
@submit.prevent="handleAddManualNode"
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>名称 *</Label>
|
||||||
|
<Input
|
||||||
|
v-model="addForm.name"
|
||||||
|
placeholder="例如: 美西 VPN 代理"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>代理地址 *</Label>
|
||||||
|
<Input
|
||||||
|
v-model="addForm.proxy_url"
|
||||||
|
placeholder="http://proxy:port 或 socks5://proxy:port"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>用户名</Label>
|
||||||
|
<Input
|
||||||
|
v-model="addForm.username"
|
||||||
|
placeholder="可选"
|
||||||
|
autocomplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>密码</Label>
|
||||||
|
<Input
|
||||||
|
v-model="addForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="可选"
|
||||||
|
autocomplete="new-password"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>区域</Label>
|
||||||
|
<Input
|
||||||
|
v-model="addForm.region"
|
||||||
|
placeholder="可选,例如: US-West"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
@click="handleDialogClose(false)"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:disabled="addingNode || !addForm.name || !addForm.proxy_url"
|
||||||
|
@click="editingNode ? handleUpdateManualNode() : handleAddManualNode()"
|
||||||
|
>
|
||||||
|
{{ addingNode ? (editingNode ? '保存中...' : '添加中...') : (editingNode ? '保存' : '添加') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -209,13 +346,14 @@ import { ref, computed, onMounted, watch } from 'vue'
|
|||||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import type { ProxyNode } from '@/api/proxy-nodes'
|
import { proxyNodesApi, type ProxyNode } from '@/api/proxy-nodes'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Button,
|
Button,
|
||||||
Badge,
|
Badge,
|
||||||
Input,
|
Input,
|
||||||
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
@@ -229,9 +367,10 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
Pagination,
|
Pagination,
|
||||||
RefreshButton,
|
RefreshButton,
|
||||||
|
Dialog,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
|
|
||||||
import { Search, Trash2 } from 'lucide-vue-next'
|
import { Search, Trash2, Plus, SquarePen } from 'lucide-vue-next'
|
||||||
|
|
||||||
const { success, error: toastError } = useToast()
|
const { success, error: toastError } = useToast()
|
||||||
const { confirmDanger } = useConfirm()
|
const { confirmDanger } = useConfirm()
|
||||||
@@ -242,6 +381,18 @@ const filterStatus = ref('all')
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
// 手动添加/编辑对话框
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const addingNode = ref(false)
|
||||||
|
const editingNode = ref<ProxyNode | null>(null)
|
||||||
|
const addForm = ref({
|
||||||
|
name: '',
|
||||||
|
proxy_url: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
region: '',
|
||||||
|
})
|
||||||
|
|
||||||
const filteredNodes = computed(() => {
|
const filteredNodes = computed(() => {
|
||||||
let filtered = [...store.nodes]
|
let filtered = [...store.nodes]
|
||||||
|
|
||||||
@@ -277,6 +428,70 @@ async function refresh() {
|
|||||||
await store.fetchNodes()
|
await store.fetchNodes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEdit(node: ProxyNode) {
|
||||||
|
editingNode.value = node
|
||||||
|
addForm.value = {
|
||||||
|
name: node.name,
|
||||||
|
proxy_url: node.proxy_url || '',
|
||||||
|
username: node.proxy_username || '',
|
||||||
|
password: '', // 不回填密码(已脱敏)
|
||||||
|
region: node.region || '',
|
||||||
|
}
|
||||||
|
showAddDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogClose(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
showAddDialog.value = false
|
||||||
|
editingNode.value = null
|
||||||
|
addForm.value = { name: '', proxy_url: '', username: '', password: '', region: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateManualNode() {
|
||||||
|
if (!editingNode.value || !addForm.value.name || !addForm.value.proxy_url) return
|
||||||
|
|
||||||
|
addingNode.value = true
|
||||||
|
try {
|
||||||
|
await proxyNodesApi.updateManualNode(editingNode.value.id, {
|
||||||
|
name: addForm.value.name,
|
||||||
|
proxy_url: addForm.value.proxy_url,
|
||||||
|
username: addForm.value.username || undefined,
|
||||||
|
// 空密码不发送(保留原值)
|
||||||
|
password: addForm.value.password || undefined,
|
||||||
|
region: addForm.value.region || undefined,
|
||||||
|
})
|
||||||
|
success('代理节点已更新')
|
||||||
|
handleDialogClose(false)
|
||||||
|
await store.fetchNodes()
|
||||||
|
} catch (err: any) {
|
||||||
|
toastError(err.response?.data?.error?.message || err.response?.data?.detail || '更新失败')
|
||||||
|
} finally {
|
||||||
|
addingNode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddManualNode() {
|
||||||
|
if (!addForm.value.name || !addForm.value.proxy_url) return
|
||||||
|
|
||||||
|
addingNode.value = true
|
||||||
|
try {
|
||||||
|
await store.createManualNode({
|
||||||
|
name: addForm.value.name,
|
||||||
|
proxy_url: addForm.value.proxy_url,
|
||||||
|
username: addForm.value.username || undefined,
|
||||||
|
password: addForm.value.password || undefined,
|
||||||
|
region: addForm.value.region || undefined,
|
||||||
|
})
|
||||||
|
success('代理节点已添加')
|
||||||
|
handleDialogClose(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
toastError(err.response?.data?.error?.message || err.response?.data?.detail || '添加失败')
|
||||||
|
} finally {
|
||||||
|
addingNode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(node: ProxyNode) {
|
async function handleDelete(node: ProxyNode) {
|
||||||
const confirmed = await confirmDanger(
|
const confirmed = await confirmDanger(
|
||||||
`确定要删除代理节点 "${node.name}" (${node.ip}:${node.port}) 吗?`,
|
`确定要删除代理节点 "${node.name}" (${node.ip}:${node.port}) 吗?`,
|
||||||
@@ -285,8 +500,14 @@ async function handleDelete(node: ProxyNode) {
|
|||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.deleteNode(node.id)
|
const result = await proxyNodesApi.deleteProxyNode(node.id)
|
||||||
success('代理节点已删除')
|
// 同步更新 store 本地状态
|
||||||
|
store.nodes = store.nodes.filter(n => n.id !== node.id)
|
||||||
|
if (result.cleared_system_proxy) {
|
||||||
|
success('代理节点已删除,系统默认代理已自动清除')
|
||||||
|
} else {
|
||||||
|
success('代理节点已删除')
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toastError(err.response?.data?.error?.message || '删除失败')
|
toastError(err.response?.data?.error?.message || '删除失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
|
<!-- 网络代理 -->
|
||||||
|
<CardSection
|
||||||
|
title="网络代理"
|
||||||
|
description="配置提供商出站请求的默认代理,仅影响大模型 API、余额查询、OAuth 等提供商请求"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="proxyConfigLoading || !hasProxyConfigChanges"
|
||||||
|
@click="saveProxyConfig"
|
||||||
|
>
|
||||||
|
{{ proxyConfigLoading ? '保存中...' : '保存' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<div class="max-w-md">
|
||||||
|
<Label class="block text-sm font-medium mb-1">
|
||||||
|
默认代理节点
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
:model-value="systemConfig.system_proxy_node_id || '__direct__'"
|
||||||
|
@update:model-value="(v: string) => systemConfig.system_proxy_node_id = v === '__direct__' ? null : v"
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="直连(不使用代理)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__direct__">
|
||||||
|
直连(不使用代理)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
v-for="node in proxyNodesStore.onlineNodes"
|
||||||
|
:key="node.id"
|
||||||
|
:value="node.id"
|
||||||
|
>
|
||||||
|
{{ node.name }}{{ node.region ? ` · ${node.region}` : '' }} ({{ node.ip }}:{{ node.port }})
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
对未单独配置代理的提供商生效,覆盖大模型 API 请求、余额查询、OAuth 刷新等。不影响系统内部接口。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardSection>
|
||||||
|
|
||||||
<!-- 基础配置 -->
|
<!-- 基础配置 -->
|
||||||
<CardSection
|
<CardSection
|
||||||
title="基础配置"
|
title="基础配置"
|
||||||
@@ -1010,10 +1054,14 @@ import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
|||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { adminApi, type ConfigExportData, type ConfigImportResponse, type UsersExportData, type UsersImportResponse } from '@/api/admin'
|
import { adminApi, type ConfigExportData, type ConfigImportResponse, type UsersExportData, type UsersImportResponse } from '@/api/admin'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
|
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
|
const proxyNodesStore = useProxyNodesStore()
|
||||||
|
|
||||||
interface SystemConfig {
|
interface SystemConfig {
|
||||||
|
// 网络代理
|
||||||
|
system_proxy_node_id: string | null
|
||||||
// 基础配置
|
// 基础配置
|
||||||
default_user_quota_usd: number
|
default_user_quota_usd: number
|
||||||
rate_limit_per_minute: number
|
rate_limit_per_minute: number
|
||||||
@@ -1044,6 +1092,7 @@ interface SystemConfig {
|
|||||||
enable_oauth_token_refresh: boolean
|
enable_oauth_token_refresh: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const proxyConfigLoading = ref(false)
|
||||||
const basicConfigLoading = ref(false)
|
const basicConfigLoading = ref(false)
|
||||||
const logConfigLoading = ref(false)
|
const logConfigLoading = ref(false)
|
||||||
const cleanupConfigLoading = ref(false)
|
const cleanupConfigLoading = ref(false)
|
||||||
@@ -1074,6 +1123,8 @@ const usersMergeModeSelectOpen = ref(false)
|
|||||||
const systemVersion = ref<string>('')
|
const systemVersion = ref<string>('')
|
||||||
|
|
||||||
const systemConfig = ref<SystemConfig>({
|
const systemConfig = ref<SystemConfig>({
|
||||||
|
// 网络代理
|
||||||
|
system_proxy_node_id: null,
|
||||||
// 基础配置
|
// 基础配置
|
||||||
default_user_quota_usd: 10.0,
|
default_user_quota_usd: 10.0,
|
||||||
rate_limit_per_minute: 0,
|
rate_limit_per_minute: 0,
|
||||||
@@ -1171,6 +1222,7 @@ onMounted(async () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadSystemConfig(),
|
loadSystemConfig(),
|
||||||
loadSystemVersion(),
|
loadSystemVersion(),
|
||||||
|
proxyNodesStore.ensureLoaded(),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1186,6 +1238,8 @@ async function loadSystemVersion() {
|
|||||||
async function loadSystemConfig() {
|
async function loadSystemConfig() {
|
||||||
try {
|
try {
|
||||||
const configs = [
|
const configs = [
|
||||||
|
// 网络代理
|
||||||
|
'system_proxy_node_id',
|
||||||
// 基础配置
|
// 基础配置
|
||||||
'default_user_quota_usd',
|
'default_user_quota_usd',
|
||||||
'rate_limit_per_minute',
|
'rate_limit_per_minute',
|
||||||
@@ -1239,6 +1293,31 @@ async function loadSystemConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasProxyConfigChanges = computed(() => {
|
||||||
|
if (!originalConfig.value) return false
|
||||||
|
return systemConfig.value.system_proxy_node_id !== originalConfig.value.system_proxy_node_id
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveProxyConfig() {
|
||||||
|
proxyConfigLoading.value = true
|
||||||
|
try {
|
||||||
|
await adminApi.updateSystemConfig(
|
||||||
|
'system_proxy_node_id',
|
||||||
|
systemConfig.value.system_proxy_node_id || null,
|
||||||
|
'系统默认代理节点 ID'
|
||||||
|
)
|
||||||
|
if (originalConfig.value) {
|
||||||
|
originalConfig.value.system_proxy_node_id = systemConfig.value.system_proxy_node_id
|
||||||
|
}
|
||||||
|
success('网络代理配置已保存')
|
||||||
|
} catch (err) {
|
||||||
|
error('保存代理配置失败')
|
||||||
|
log.error('保存代理配置失败:', err)
|
||||||
|
} finally {
|
||||||
|
proxyConfigLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveBasicConfig() {
|
async function saveBasicConfig() {
|
||||||
basicConfigLoading.value = true
|
basicConfigLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,20 +20,30 @@ from src.api.base.context import ApiRequestContext
|
|||||||
from src.api.base.pipeline import ApiRequestPipeline
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.database import ProxyNode, ProxyNodeStatus
|
from src.models.database import ProxyNode, ProxyNodeStatus, SystemConfig
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/proxy-nodes", tags=["Admin - Proxy Nodes"])
|
router = APIRouter(prefix="/api/admin/proxy-nodes", tags=["Admin - Proxy Nodes"])
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_password(password: str | None) -> str | None:
|
||||||
|
"""脱敏密码,仅显示前2位和后2位"""
|
||||||
|
if not password:
|
||||||
|
return None
|
||||||
|
if len(password) <= 4:
|
||||||
|
return "****"
|
||||||
|
return password[:2] + "****" + password[-2:]
|
||||||
|
|
||||||
|
|
||||||
def _node_to_dict(node: ProxyNode) -> dict[str, Any]:
|
def _node_to_dict(node: ProxyNode) -> dict[str, Any]:
|
||||||
return {
|
d = {
|
||||||
"id": node.id,
|
"id": node.id,
|
||||||
"name": node.name,
|
"name": node.name,
|
||||||
"ip": node.ip,
|
"ip": node.ip,
|
||||||
"port": node.port,
|
"port": node.port,
|
||||||
"region": node.region,
|
"region": node.region,
|
||||||
"status": node.status.value if node.status else None,
|
"status": node.status.value if node.status else None,
|
||||||
|
"is_manual": bool(node.is_manual),
|
||||||
"registered_by": node.registered_by,
|
"registered_by": node.registered_by,
|
||||||
"last_heartbeat_at": node.last_heartbeat_at,
|
"last_heartbeat_at": node.last_heartbeat_at,
|
||||||
"heartbeat_interval": node.heartbeat_interval,
|
"heartbeat_interval": node.heartbeat_interval,
|
||||||
@@ -43,6 +53,12 @@ def _node_to_dict(node: ProxyNode) -> dict[str, Any]:
|
|||||||
"created_at": node.created_at,
|
"created_at": node.created_at,
|
||||||
"updated_at": node.updated_at,
|
"updated_at": node.updated_at,
|
||||||
}
|
}
|
||||||
|
# 手动节点附带代理配置(密码脱敏)
|
||||||
|
if node.is_manual:
|
||||||
|
d["proxy_url"] = node.proxy_url
|
||||||
|
d["proxy_username"] = node.proxy_username
|
||||||
|
d["proxy_password"] = _mask_password(node.proxy_password)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
class ProxyNodeRegisterRequest(BaseModel):
|
class ProxyNodeRegisterRequest(BaseModel):
|
||||||
@@ -81,6 +97,58 @@ class ProxyNodeUnregisterRequest(BaseModel):
|
|||||||
node_id: str = Field(..., min_length=1, max_length=36, description="节点 ID")
|
node_id: str = Field(..., min_length=1, max_length=36, description="节点 ID")
|
||||||
|
|
||||||
|
|
||||||
|
class ManualProxyNodeCreateRequest(BaseModel):
|
||||||
|
"""手动创建代理节点"""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=100, description="节点名")
|
||||||
|
proxy_url: str = Field(
|
||||||
|
..., min_length=1, max_length=500, description="代理 URL (http/https/socks5)"
|
||||||
|
)
|
||||||
|
username: str | None = Field(None, max_length=255, description="代理用户名")
|
||||||
|
password: str | None = Field(None, max_length=500, description="代理密码")
|
||||||
|
region: str | None = Field(None, max_length=100, description="区域标签")
|
||||||
|
|
||||||
|
@field_validator("proxy_url")
|
||||||
|
@classmethod
|
||||||
|
def validate_proxy_url(cls, v: str) -> str:
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
v = v.strip()
|
||||||
|
if not re.match(r"^(http|https|socks5)://", v, re.IGNORECASE):
|
||||||
|
raise ValueError("代理 URL 必须以 http://, https:// 或 socks5:// 开头")
|
||||||
|
parsed = urlparse(v)
|
||||||
|
if not parsed.hostname:
|
||||||
|
raise ValueError("代理 URL 必须包含有效的 host")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ManualProxyNodeUpdateRequest(BaseModel):
|
||||||
|
"""更新手动代理节点"""
|
||||||
|
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100, description="节点名")
|
||||||
|
proxy_url: str | None = Field(None, min_length=1, max_length=500, description="代理 URL")
|
||||||
|
username: str | None = Field(None, max_length=255, description="代理用户名")
|
||||||
|
password: str | None = Field(None, max_length=500, description="代理密码")
|
||||||
|
region: str | None = Field(None, max_length=100, description="区域标签")
|
||||||
|
|
||||||
|
@field_validator("proxy_url")
|
||||||
|
@classmethod
|
||||||
|
def validate_proxy_url(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
v = v.strip()
|
||||||
|
if not re.match(r"^(http|https|socks5)://", v, re.IGNORECASE):
|
||||||
|
raise ValueError("代理 URL 必须以 http://, https:// 或 socks5:// 开头")
|
||||||
|
parsed = urlparse(v)
|
||||||
|
if not parsed.hostname:
|
||||||
|
raise ValueError("代理 URL 必须包含有效的 host")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
async def register_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
|
async def register_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
adapter = AdminRegisterProxyNodeAdapter()
|
adapter = AdminRegisterProxyNodeAdapter()
|
||||||
@@ -111,6 +179,20 @@ async def list_proxy_nodes(
|
|||||||
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.post("/manual")
|
||||||
|
async def create_manual_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
adapter = AdminCreateManualProxyNodeAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{node_id}")
|
||||||
|
async def update_manual_proxy_node(
|
||||||
|
node_id: str, request: Request, db: Session = Depends(get_db)
|
||||||
|
) -> Any:
|
||||||
|
adapter = AdminUpdateManualProxyNodeAdapter(node_id=node_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{node_id}")
|
@router.delete("/{node_id}")
|
||||||
async def delete_proxy_node(node_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
async def delete_proxy_node(node_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
adapter = AdminDeleteProxyNodeAdapter(node_id=node_id)
|
adapter = AdminDeleteProxyNodeAdapter(node_id=node_id)
|
||||||
@@ -298,7 +380,151 @@ class AdminDeleteProxyNodeAdapter(AdminApiAdapter):
|
|||||||
proxy_node_port=node.port,
|
proxy_node_port=node.port,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 若该节点是系统默认代理,自动清除引用
|
||||||
|
was_system_proxy = False
|
||||||
|
sys_cfg = (
|
||||||
|
context.db.query(SystemConfig)
|
||||||
|
.filter(SystemConfig.key == "system_proxy_node_id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if sys_cfg and sys_cfg.value == self.node_id:
|
||||||
|
sys_cfg.value = None
|
||||||
|
was_system_proxy = True
|
||||||
|
|
||||||
context.db.delete(node)
|
context.db.delete(node)
|
||||||
context.db.commit()
|
context.db.commit()
|
||||||
|
|
||||||
return {"message": "deleted", "node_id": self.node_id}
|
if was_system_proxy:
|
||||||
|
from src.clients.http_client import invalidate_system_proxy_cache
|
||||||
|
|
||||||
|
invalidate_system_proxy_cache()
|
||||||
|
|
||||||
|
msg = "deleted"
|
||||||
|
if was_system_proxy:
|
||||||
|
msg = "deleted, system default proxy cleared"
|
||||||
|
return {"message": msg, "node_id": self.node_id, "cleared_system_proxy": was_system_proxy}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_host_port(proxy_url: str) -> tuple[str, int]:
|
||||||
|
"""从代理 URL 中解析 host 和 port(含协议前缀,避免唯一约束冲突)"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(proxy_url)
|
||||||
|
host = parsed.hostname or "manual"
|
||||||
|
default_ports = {"https": 443, "socks5": 1080}
|
||||||
|
port = parsed.port or default_ports.get((parsed.scheme or "").lower(), 80)
|
||||||
|
# 添加协议前缀区分同 host:port 不同协议的场景
|
||||||
|
scheme = (parsed.scheme or "http").lower()
|
||||||
|
if scheme != "http":
|
||||||
|
host = f"{scheme}://{host}"
|
||||||
|
return host, port
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminCreateManualProxyNodeAdapter(AdminApiAdapter):
|
||||||
|
name: str = "admin_create_manual_proxy_node"
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any:
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
try:
|
||||||
|
req = ManualProxyNodeCreateRequest.model_validate(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
|
||||||
|
|
||||||
|
host, port = _parse_host_port(req.proxy_url)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
node = ProxyNode(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=req.name,
|
||||||
|
ip=host,
|
||||||
|
port=port,
|
||||||
|
region=req.region,
|
||||||
|
is_manual=True,
|
||||||
|
proxy_url=req.proxy_url,
|
||||||
|
proxy_username=req.username,
|
||||||
|
proxy_password=req.password,
|
||||||
|
status=ProxyNodeStatus.ONLINE,
|
||||||
|
registered_by=context.user.id if context.user else None,
|
||||||
|
last_heartbeat_at=None,
|
||||||
|
heartbeat_interval=0,
|
||||||
|
active_connections=0,
|
||||||
|
total_requests=0,
|
||||||
|
avg_latency_ms=None,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
# 检查是否已存在同地址的节点
|
||||||
|
existing = (
|
||||||
|
context.db.query(ProxyNode).filter(ProxyNode.ip == host, ProxyNode.port == port).first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise InvalidRequestException(
|
||||||
|
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
|
||||||
|
)
|
||||||
|
|
||||||
|
context.db.add(node)
|
||||||
|
context.db.commit()
|
||||||
|
context.db.refresh(node)
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="proxy_node_manual_create",
|
||||||
|
proxy_node_id=node.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"node_id": node.id, "node": _node_to_dict(node)}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminUpdateManualProxyNodeAdapter(AdminApiAdapter):
|
||||||
|
name: str = "admin_update_manual_proxy_node"
|
||||||
|
node_id: str = ""
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any:
|
||||||
|
node = context.db.query(ProxyNode).filter(ProxyNode.id == self.node_id).first()
|
||||||
|
if not node:
|
||||||
|
raise NotFoundException(f"ProxyNode {self.node_id} 不存在", "proxy_node")
|
||||||
|
if not node.is_manual:
|
||||||
|
raise InvalidRequestException("只能编辑手动添加的代理节点")
|
||||||
|
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
try:
|
||||||
|
req = ManualProxyNodeUpdateRequest.model_validate(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
|
||||||
|
|
||||||
|
if req.name is not None:
|
||||||
|
node.name = req.name
|
||||||
|
if req.proxy_url is not None:
|
||||||
|
node.proxy_url = req.proxy_url
|
||||||
|
host, port = _parse_host_port(req.proxy_url)
|
||||||
|
# 检查新地址是否与其他节点冲突
|
||||||
|
existing = (
|
||||||
|
context.db.query(ProxyNode)
|
||||||
|
.filter(ProxyNode.ip == host, ProxyNode.port == port, ProxyNode.id != node.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise InvalidRequestException(
|
||||||
|
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
|
||||||
|
)
|
||||||
|
node.ip = host
|
||||||
|
node.port = port
|
||||||
|
if req.username is not None:
|
||||||
|
node.proxy_username = req.username
|
||||||
|
# password: None=不发送(保留原值), ""=清空, 非空=更新
|
||||||
|
if req.password is not None:
|
||||||
|
node.proxy_password = req.password or None
|
||||||
|
if req.region is not None:
|
||||||
|
node.region = req.region
|
||||||
|
|
||||||
|
node.updated_at = datetime.now(timezone.utc)
|
||||||
|
context.db.commit()
|
||||||
|
context.db.refresh(node)
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="proxy_node_manual_update",
|
||||||
|
proxy_node_id=node.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"node_id": node.id, "node": _node_to_dict(node)}
|
||||||
|
|||||||
@@ -276,6 +276,73 @@ class StreamProcessor:
|
|||||||
if finish_reason is not None:
|
if finish_reason is not None:
|
||||||
ctx.has_completion = True
|
ctx.has_completion = True
|
||||||
|
|
||||||
|
def _extract_usage_from_converted_event(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
evt: dict[str, Any],
|
||||||
|
event_type: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
从转换后的事件中提取 usage 信息(补充 Provider 事件解析)。
|
||||||
|
|
||||||
|
支持多种格式:
|
||||||
|
- Claude: message_delta.usage, message_start.message.usage
|
||||||
|
- OpenAI: chunk.usage / response.completed.response.usage
|
||||||
|
- Gemini: usageMetadata
|
||||||
|
"""
|
||||||
|
usage: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
# Claude 格式: message_delta 或 message_start
|
||||||
|
if event_type == "message_delta":
|
||||||
|
usage = evt.get("usage")
|
||||||
|
elif event_type == "message_start":
|
||||||
|
message = evt.get("message", {})
|
||||||
|
if isinstance(message, dict):
|
||||||
|
usage = message.get("usage")
|
||||||
|
# OpenAI Responses API 格式: response.completed 中 usage 嵌套在 response 对象内
|
||||||
|
elif event_type == "response.completed":
|
||||||
|
resp_obj = evt.get("response")
|
||||||
|
if isinstance(resp_obj, dict):
|
||||||
|
usage = resp_obj.get("usage")
|
||||||
|
# 兼容: 部分实现可能在顶层也有 usage
|
||||||
|
if not usage:
|
||||||
|
usage = evt.get("usage")
|
||||||
|
# OpenAI Chat 格式: 直接在 chunk 中
|
||||||
|
elif "usage" in evt:
|
||||||
|
usage = evt.get("usage")
|
||||||
|
# Gemini 格式: usageMetadata
|
||||||
|
elif "usageMetadata" in evt:
|
||||||
|
meta = evt.get("usageMetadata", {})
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
usage = {
|
||||||
|
"input_tokens": meta.get("promptTokenCount", 0),
|
||||||
|
"output_tokens": meta.get("candidatesTokenCount", 0),
|
||||||
|
"cache_read_tokens": meta.get("cachedContentTokenCount", 0),
|
||||||
|
"cache_creation_tokens": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage and isinstance(usage, dict):
|
||||||
|
new_input = usage.get("input_tokens", 0) or 0
|
||||||
|
new_output = usage.get("output_tokens", 0) or 0
|
||||||
|
new_cached = usage.get("cache_read_tokens") or usage.get("cache_read_input_tokens") or 0
|
||||||
|
new_cache_creation = (
|
||||||
|
usage.get("cache_creation_tokens") or usage.get("cache_creation_input_tokens") or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_input > ctx.input_tokens:
|
||||||
|
ctx.input_tokens = new_input
|
||||||
|
logger.debug("[{}] 从转换后事件更新 input_tokens: {}", self.request_id, new_input)
|
||||||
|
if new_output > ctx.output_tokens:
|
||||||
|
ctx.output_tokens = new_output
|
||||||
|
logger.debug("[{}] 从转换后事件更新 output_tokens: {}", self.request_id, new_output)
|
||||||
|
if new_cached > ctx.cached_tokens:
|
||||||
|
ctx.cached_tokens = new_cached
|
||||||
|
if new_cache_creation > ctx.cache_creation_tokens:
|
||||||
|
ctx.cache_creation_tokens = new_cache_creation
|
||||||
|
|
||||||
|
if any([new_input, new_output, new_cached, new_cache_creation]):
|
||||||
|
ctx.final_usage = usage
|
||||||
|
|
||||||
async def prefetch_and_check_error(
|
async def prefetch_and_check_error(
|
||||||
self,
|
self,
|
||||||
byte_iterator: Any,
|
byte_iterator: Any,
|
||||||
@@ -748,6 +815,18 @@ class StreamProcessor:
|
|||||||
ctx.data_count += 1
|
ctx.data_count += 1
|
||||||
if ctx.record_parsed_chunks:
|
if ctx.record_parsed_chunks:
|
||||||
ctx.parsed_chunks.append(evt)
|
ctx.parsed_chunks.append(evt)
|
||||||
|
event_type = evt.get("type", "")
|
||||||
|
if event_type in ("message_stop", "response.completed"):
|
||||||
|
ctx.has_completion = True
|
||||||
|
elif "choices" in evt:
|
||||||
|
choices = evt.get("choices", [])
|
||||||
|
for choice in choices:
|
||||||
|
if isinstance(choice, dict) and choice.get("finish_reason"):
|
||||||
|
ctx.has_completion = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# 从转换后的事件中补充 usage 信息
|
||||||
|
self._extract_usage_from_converted_event(ctx, evt, event_type)
|
||||||
|
|
||||||
# 统一使用 SSE 格式输出(Gemini streamGenerateContent 也使用 SSE)
|
# 统一使用 SSE 格式输出(Gemini streamGenerateContent 也使用 SSE)
|
||||||
# 参考: https://ai.google.dev/api/generate-content
|
# 参考: https://ai.google.dev/api/generate-content
|
||||||
|
|||||||
@@ -87,7 +87,18 @@ class ClaudeCliMessageHandler(CliMessageHandlerBase):
|
|||||||
- content_block_delta: 文本增量
|
- content_block_delta: 文本增量
|
||||||
- message_delta: 消息增量,包含最终 usage
|
- message_delta: 消息增量,包含最终 usage
|
||||||
- message_stop: 消息结束
|
- message_stop: 消息结束
|
||||||
|
|
||||||
|
跨格式转换时(如 provider=openai:cli),原始事件数据是 Provider 格式而非 Claude 格式。
|
||||||
|
此时委托基类方法通过 Provider 格式解析器提取 usage。
|
||||||
"""
|
"""
|
||||||
|
# 跨格式转换时:原始事件是 Provider 格式,
|
||||||
|
# 基类 _process_event_data 会自动选择正确的 Provider 解析器提取 usage/text
|
||||||
|
if ctx.provider_api_format and ctx.provider_api_format != ctx.client_api_format:
|
||||||
|
super()._process_event_data(ctx, event_type, data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 以下是同格式(claude:cli / claude:chat)的处理逻辑
|
||||||
|
|
||||||
# 处理 message_start 事件
|
# 处理 message_start 事件
|
||||||
if event_type == "message_start":
|
if event_type == "message_start":
|
||||||
message = data.get("message", {})
|
message = data.get("message", {})
|
||||||
|
|||||||
@@ -208,7 +208,18 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
|
|||||||
|
|
||||||
注意: Gemini 流解析器会将每个 JSON 对象作为一个"事件"传递
|
注意: Gemini 流解析器会将每个 JSON 对象作为一个"事件"传递
|
||||||
event_type 在这里可能为空或是自定义的标记
|
event_type 在这里可能为空或是自定义的标记
|
||||||
|
|
||||||
|
跨格式转换时(如 provider=claude:chat),原始事件数据是 Provider 格式而非 Gemini 格式。
|
||||||
|
此时委托基类方法通过 Provider 格式解析器提取 usage。
|
||||||
"""
|
"""
|
||||||
|
# 跨格式转换时:原始事件是 Provider 格式,
|
||||||
|
# 基类 _process_event_data 会自动选择正确的 Provider 解析器提取 usage/text
|
||||||
|
if ctx.provider_api_format and ctx.provider_api_format != ctx.client_api_format:
|
||||||
|
super()._process_event_data(ctx, _event_type, data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 以下是同格式(gemini:cli / gemini:chat)的处理逻辑
|
||||||
|
|
||||||
# 提取候选响应
|
# 提取候选响应
|
||||||
candidates = data.get("candidates", [])
|
candidates = data.get("candidates", [])
|
||||||
if candidates:
|
if candidates:
|
||||||
|
|||||||
@@ -84,7 +84,18 @@ class OpenAICliMessageHandler(CliMessageHandlerBase):
|
|||||||
事件类型:
|
事件类型:
|
||||||
- response.output_text.delta: 文本增量
|
- response.output_text.delta: 文本增量
|
||||||
- response.completed: 响应完成(包含 usage)
|
- response.completed: 响应完成(包含 usage)
|
||||||
|
|
||||||
|
跨格式转换时(如 provider=claude:chat),原始事件数据是 Provider 格式而非 OpenAI CLI 格式。
|
||||||
|
此时先调用基类方法通过 Provider 格式解析器提取 usage,再执行 OpenAI CLI 特定的处理逻辑。
|
||||||
"""
|
"""
|
||||||
|
# 跨格式转换时:原始事件是 Provider 格式(如 Claude),
|
||||||
|
# 基类 _process_event_data 会自动选择正确的 Provider 解析器提取 usage/text
|
||||||
|
if ctx.provider_api_format and ctx.provider_api_format != ctx.client_api_format:
|
||||||
|
super()._process_event_data(ctx, event_type, data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 以下是同格式(openai:cli)的处理逻辑
|
||||||
|
|
||||||
# 提取 response_id
|
# 提取 response_id
|
||||||
if not ctx.response_id:
|
if not ctx.response_id:
|
||||||
response_obj = data.get("response")
|
response_obj = data.get("response")
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
|
|||||||
读取 ProxyNode 信息(带内存 TTL 缓存)
|
读取 ProxyNode 信息(带内存 TTL 缓存)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{"ip": str, "port": int} 或 None(不存在/非在线)
|
aether-proxy 节点: {"ip": str, "port": int}
|
||||||
|
手动节点: {"is_manual": True, "proxy_url": str, "username": str|None, "password": str|None}
|
||||||
|
不存在/非在线: None
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = _proxy_node_cache.get(node_id)
|
cached = _proxy_node_cache.get(node_id)
|
||||||
@@ -63,7 +65,16 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
|
|||||||
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_TTL_SECONDS)
|
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_TTL_SECONDS)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
value = {"ip": node.ip, "port": node.port}
|
if node.is_manual:
|
||||||
|
value: dict[str, Any] = {
|
||||||
|
"is_manual": True,
|
||||||
|
"proxy_url": node.proxy_url,
|
||||||
|
"username": node.proxy_username,
|
||||||
|
"password": node.proxy_password,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
value = {"ip": node.ip, "port": node.port}
|
||||||
|
|
||||||
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
|
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
|
||||||
return value
|
return value
|
||||||
finally:
|
finally:
|
||||||
@@ -94,6 +105,90 @@ def _build_hmac_proxy_url(ip: str, port: int, node_id: str) -> str:
|
|||||||
return f"http://hmac:{timestamp}.{signature}@{host}:{int(port)}"
|
return f"http://hmac:{timestamp}.{signature}@{host}:{int(port)}"
|
||||||
|
|
||||||
|
|
||||||
|
# 系统默认代理缓存
|
||||||
|
_system_proxy_cache: tuple[dict[str, Any] | None, float] | None = None
|
||||||
|
_SYSTEM_PROXY_CACHE_TTL = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_system_proxy_cache() -> None:
|
||||||
|
"""手动失效系统代理缓存(在删除节点等操作后调用)"""
|
||||||
|
global _system_proxy_cache
|
||||||
|
_system_proxy_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_proxy_config() -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
获取系统默认代理配置(带 TTL 缓存)
|
||||||
|
|
||||||
|
从 system_configs 表中读取 system_proxy_node_id。
|
||||||
|
返回 {"node_id": "...", "enabled": True} 或 None。
|
||||||
|
"""
|
||||||
|
global _system_proxy_cache
|
||||||
|
now = time.time()
|
||||||
|
if _system_proxy_cache:
|
||||||
|
value, expires_at = _system_proxy_cache
|
||||||
|
if now < expires_at:
|
||||||
|
return value
|
||||||
|
|
||||||
|
from src.database import create_session
|
||||||
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
|
db = create_session()
|
||||||
|
try:
|
||||||
|
node_id = SystemConfigService.get_config(db, "system_proxy_node_id")
|
||||||
|
if node_id and isinstance(node_id, str) and node_id.strip():
|
||||||
|
result: dict[str, Any] | None = {"node_id": node_id.strip(), "enabled": True}
|
||||||
|
else:
|
||||||
|
result = None
|
||||||
|
_system_proxy_cache = (result, now + _SYSTEM_PROXY_CACHE_TTL)
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
_system_proxy_cache = (None, now + _SYSTEM_PROXY_CACHE_TTL)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ops_proxy(connector_config: dict[str, Any] | None) -> str | None:
|
||||||
|
"""
|
||||||
|
从 ops connector.config 中解析代理 URL(含系统默认回退)
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
1. connector_config.proxy_node_id(新格式)
|
||||||
|
2. connector_config.proxy(旧格式 URL 字符串)
|
||||||
|
3. 系统默认代理节点
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connector_config: connector 的 config 字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
代理 URL 字符串,或 None
|
||||||
|
"""
|
||||||
|
if connector_config:
|
||||||
|
# 新格式:proxy_node_id → 通过 build_proxy_url 解析
|
||||||
|
node_id = connector_config.get("proxy_node_id")
|
||||||
|
if isinstance(node_id, str) and node_id.strip():
|
||||||
|
try:
|
||||||
|
return build_proxy_url({"node_id": node_id.strip(), "enabled": True})
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 旧格式:直接返回 proxy URL 字符串
|
||||||
|
proxy = connector_config.get("proxy")
|
||||||
|
if isinstance(proxy, str) and proxy.strip():
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
# 回退:系统默认代理
|
||||||
|
system_proxy = get_system_proxy_config()
|
||||||
|
if system_proxy:
|
||||||
|
try:
|
||||||
|
return build_proxy_url(system_proxy)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
|
def _compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
|
||||||
"""
|
"""
|
||||||
计算代理配置的缓存键
|
计算代理配置的缓存键
|
||||||
@@ -146,13 +241,43 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
|
|||||||
if not proxy_config.get("enabled", True):
|
if not proxy_config.get("enabled", True):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ProxyNode 模式(aether-proxy)
|
# ProxyNode 模式(aether-proxy 或手动节点)
|
||||||
node_id = proxy_config.get("node_id")
|
node_id = proxy_config.get("node_id")
|
||||||
if isinstance(node_id, str) and node_id.strip():
|
if isinstance(node_id, str) and node_id.strip():
|
||||||
node_id = node_id.strip()
|
node_id = node_id.strip()
|
||||||
node_info = _get_proxy_node_info(node_id)
|
node_info = _get_proxy_node_info(node_id)
|
||||||
if not node_info:
|
if not node_info:
|
||||||
raise ProxyNodeUnavailableError(f"代理节点 {node_id} 不可用", node_id=node_id)
|
raise ProxyNodeUnavailableError(f"代理节点 {node_id} 不可用", node_id=node_id)
|
||||||
|
|
||||||
|
# 手动节点:直接使用存储的代理 URL(含认证信息)
|
||||||
|
if node_info.get("is_manual"):
|
||||||
|
manual_url = node_info.get("proxy_url")
|
||||||
|
if not manual_url:
|
||||||
|
raise ProxyNodeUnavailableError(
|
||||||
|
f"手动代理节点 {node_id} 缺少 proxy_url", node_id=node_id
|
||||||
|
)
|
||||||
|
username = node_info.get("username")
|
||||||
|
password = node_info.get("password")
|
||||||
|
if username:
|
||||||
|
parsed = urlparse(manual_url)
|
||||||
|
encoded_username = quote(username, safe="")
|
||||||
|
encoded_password = quote(password, safe="") if password else ""
|
||||||
|
# 使用 hostname+port 而非 netloc,避免 URL 内嵌凭据导致双重认证
|
||||||
|
host_part = parsed.hostname or "localhost"
|
||||||
|
if parsed.port:
|
||||||
|
host_part = f"{host_part}:{parsed.port}"
|
||||||
|
if encoded_password:
|
||||||
|
auth_url = (
|
||||||
|
f"{parsed.scheme}://{encoded_username}:{encoded_password}@{host_part}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
auth_url = f"{parsed.scheme}://{encoded_username}@{host_part}"
|
||||||
|
if parsed.path:
|
||||||
|
auth_url += parsed.path
|
||||||
|
return auth_url
|
||||||
|
return manual_url
|
||||||
|
|
||||||
|
# aether-proxy 节点:使用 HMAC 认证
|
||||||
return _build_hmac_proxy_url(node_info["ip"], node_info["port"], node_id)
|
return _build_hmac_proxy_url(node_info["ip"], node_info["port"], node_id)
|
||||||
|
|
||||||
proxy_url: str | None = proxy_config.get("url")
|
proxy_url: str | None = proxy_config.get("url")
|
||||||
@@ -337,14 +462,19 @@ class HTTPClientPool:
|
|||||||
获取代理客户端(带缓存复用)
|
获取代理客户端(带缓存复用)
|
||||||
|
|
||||||
相同代理配置会复用同一个客户端,大幅减少连接建立开销。
|
相同代理配置会复用同一个客户端,大幅减少连接建立开销。
|
||||||
|
当 proxy_config 为 None 时,自动回退到系统默认代理节点。
|
||||||
注意:返回的客户端使用默认超时配置,如需自定义超时请在请求时传递 timeout 参数。
|
注意:返回的客户端使用默认超时配置,如需自定义超时请在请求时传递 timeout 参数。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
proxy_config: 代理配置字典,包含 url, username, password
|
proxy_config: 代理配置字典,为 None 时使用系统默认代理
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
可复用的 httpx.AsyncClient 实例
|
可复用的 httpx.AsyncClient 实例
|
||||||
"""
|
"""
|
||||||
|
# 无特定代理时,回退到系统默认代理
|
||||||
|
if not proxy_config:
|
||||||
|
proxy_config = get_system_proxy_config()
|
||||||
|
|
||||||
cache_key = _compute_proxy_cache_key(proxy_config)
|
cache_key = _compute_proxy_cache_key(proxy_config)
|
||||||
|
|
||||||
# 无代理时返回默认客户端
|
# 无代理时返回默认客户端
|
||||||
|
|||||||
@@ -801,16 +801,22 @@ class ProxyNodeStatus(PyEnum):
|
|||||||
|
|
||||||
|
|
||||||
class ProxyNode(Base):
|
class ProxyNode(Base):
|
||||||
"""代理节点表(用于 aether-proxy 注册/心跳)"""
|
"""代理节点表(aether-proxy 自动注册 + 手动添加)"""
|
||||||
|
|
||||||
__tablename__ = "proxy_nodes"
|
__tablename__ = "proxy_nodes"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
name = Column(String(100), nullable=False) # 节点名
|
name = Column(String(100), nullable=False) # 节点名
|
||||||
ip = Column(String(45), nullable=False) # 公网 IP(IPv6 最长 39 + 冗余)
|
ip = Column(String(512), nullable=False) # 公网 IP 或手动节点的主机名(含协议前缀)
|
||||||
port = Column(Integer, nullable=False) # 代理端口
|
port = Column(Integer, nullable=False) # 代理端口
|
||||||
region = Column(String(100), nullable=True) # 区域标签
|
region = Column(String(100), nullable=True) # 区域标签
|
||||||
|
|
||||||
|
# 手动节点专用字段
|
||||||
|
is_manual = Column(Boolean, default=False, nullable=False, comment="是否为手动添加的代理节点")
|
||||||
|
proxy_url = Column(String(500), nullable=True, comment="手动节点的完整代理 URL")
|
||||||
|
proxy_username = Column(String(255), nullable=True, comment="手动节点的代理用户名")
|
||||||
|
proxy_password = Column(String(500), nullable=True, comment="手动节点的代理密码")
|
||||||
|
|
||||||
status = Column(
|
status = Column(
|
||||||
Enum(
|
Enum(
|
||||||
ProxyNodeStatus,
|
ProxyNodeStatus,
|
||||||
|
|||||||
@@ -408,8 +408,10 @@ class AnyrouterArchitecture(ProviderArchitecture):
|
|||||||
Returns:
|
Returns:
|
||||||
包含 acw_cookie 的配置
|
包含 acw_cookie 的配置
|
||||||
"""
|
"""
|
||||||
# 从 config 获取代理配置
|
# 从 config 获取代理配置(支持 proxy_node_id 和旧的 proxy URL)
|
||||||
proxy = config.get("proxy")
|
from src.clients.http_client import resolve_ops_proxy
|
||||||
|
|
||||||
|
proxy = resolve_ops_proxy(config)
|
||||||
acw_cookie = await _get_acw_cookie(base_url, proxy=proxy)
|
acw_cookie = await _get_acw_cookie(base_url, proxy=proxy)
|
||||||
if acw_cookie:
|
if acw_cookie:
|
||||||
return {"acw_cookie": acw_cookie}
|
return {"acw_cookie": acw_cookie}
|
||||||
|
|||||||
@@ -50,8 +50,10 @@ class ProviderConnector(ABC):
|
|||||||
self._expires_at: datetime | None = None
|
self._expires_at: datetime | None = None
|
||||||
self._last_error: str | None = None
|
self._last_error: str | None = None
|
||||||
|
|
||||||
# 代理配置
|
# 代理配置(支持 proxy_node_id 和旧的 proxy URL)
|
||||||
self._proxy: str | None = self.config.get("proxy")
|
from src.clients.http_client import resolve_ops_proxy
|
||||||
|
|
||||||
|
self._proxy: str | None = resolve_ops_proxy(self.config)
|
||||||
|
|
||||||
# HTTP 客户端配置
|
# HTTP 客户端配置
|
||||||
self._timeout = self.config.get("timeout", 30)
|
self._timeout = self.config.get("timeout", 30)
|
||||||
|
|||||||
@@ -180,7 +180,9 @@ class NekoCodeArchitecture(ProviderArchitecture):
|
|||||||
"timeout": 10,
|
"timeout": 10,
|
||||||
"verify": get_ssl_context(),
|
"verify": get_ssl_context(),
|
||||||
}
|
}
|
||||||
proxy = config.get("proxy")
|
from src.clients.http_client import resolve_ops_proxy
|
||||||
|
|
||||||
|
proxy = resolve_ops_proxy(config)
|
||||||
if proxy:
|
if proxy:
|
||||||
client_kwargs["proxy"] = proxy
|
client_kwargs["proxy"] = proxy
|
||||||
|
|
||||||
|
|||||||
@@ -193,8 +193,10 @@ class YesCodeArchitecture(ProviderArchitecture):
|
|||||||
|
|
||||||
cookie_header = _build_cookie_header(cookie_input)
|
cookie_header = _build_cookie_header(cookie_input)
|
||||||
|
|
||||||
# 获取代理配置
|
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL)
|
||||||
proxy = config.get("proxy")
|
from src.clients.http_client import resolve_ops_proxy
|
||||||
|
|
||||||
|
proxy = resolve_ops_proxy(config)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 构建 client 参数
|
# 构建 client 参数
|
||||||
|
|||||||
@@ -916,8 +916,10 @@ class ProviderOpsService:
|
|||||||
f"endpoint={verify_endpoint}, headers={list(headers.keys())}"
|
f"endpoint={verify_endpoint}, headers={list(headers.keys())}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取代理配置
|
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL)
|
||||||
proxy = config.get("proxy")
|
from src.clients.http_client import resolve_ops_proxy
|
||||||
|
|
||||||
|
proxy = resolve_ops_proxy(config)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 构建 httpx client 参数
|
# 构建 httpx client 参数
|
||||||
|
|||||||
@@ -55,7 +55,15 @@ class ProxyNodeHealthScheduler:
|
|||||||
db = create_session()
|
db = create_session()
|
||||||
try:
|
try:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
nodes = db.query(ProxyNode).filter(ProxyNode.status != ProxyNodeStatus.OFFLINE).all()
|
# 仅检查非手动节点(手动节点无心跳,始终保持 ONLINE)
|
||||||
|
nodes = (
|
||||||
|
db.query(ProxyNode)
|
||||||
|
.filter(
|
||||||
|
ProxyNode.status != ProxyNodeStatus.OFFLINE,
|
||||||
|
ProxyNode.is_manual == False, # noqa: E712
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
if not nodes:
|
if not nodes:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ class SystemConfigService:
|
|||||||
"value": 30,
|
"value": 30,
|
||||||
"description": "审计日志保留天数,超过此天数的审计日志将被自动清理",
|
"description": "审计日志保留天数,超过此天数的审计日志将被自动清理",
|
||||||
},
|
},
|
||||||
|
# 系统代理
|
||||||
|
"system_proxy_node_id": {
|
||||||
|
"value": None,
|
||||||
|
"description": "系统默认代理节点 ID,为空时直连。仅影响提供商出站请求(大模型API/余额查询/OAuth),不影响系统内部接口",
|
||||||
|
},
|
||||||
# SMTP 邮件配置
|
# SMTP 邮件配置
|
||||||
"smtp_host": {
|
"smtp_host": {
|
||||||
"value": None,
|
"value": None,
|
||||||
|
|||||||
Reference in New Issue
Block a user