feat: 添加 Provider Ops 扩展操作系统,支持余额监控

主要更改:
- 新增 Provider Ops 服务框架,支持通过架构配置执行余额查询等扩展操作
- 后端:添加 provider_ops 服务层和 API 路由
- 前端:添加 ProviderAuthDialog 组件配置认证信息
- 前端:添加 providerOps API 和认证模板系统

UI/组件优化:
- Input 组件:新增 masked 属性,使用 CSS 遮蔽敏感信息,避免触发密码管理器
- Pagination 组件:移除首页/末页/上下页按钮,改为页码跳转输入框
- KeyFormDialog:使用 masked 属性简化 API Key 输入逻辑
- ProviderManagement:重新设计表格布局,显示余额监控数据
This commit is contained in:
fawney19
2026-01-17 19:50:35 +08:00
parent 3cdf471473
commit c71027c466
29 changed files with 4653 additions and 144 deletions

View File

@@ -310,6 +310,7 @@ export interface ProviderWithEndpointsSummary {
unhealthy_endpoints: number
api_formats: string[]
endpoint_health_details: EndpointHealthDetail[]
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
created_at: string
updated_at: string
}

View File

@@ -0,0 +1,353 @@
/**
* Provider 操作 API
*
* 提供 Provider 扩展操作相关的 API
* - 架构管理
* - 连接管理
* - 操作执行(余额查询、签到等)
*/
import client from './client'
// ==================== Types ====================
/** 认证类型 */
export type ConnectorAuthType = 'api_key' | 'session_login' | 'oauth' | 'cookie' | 'none'
/** 操作类型 */
export type ProviderActionType =
| 'query_balance'
| 'checkin'
| 'claim_quota'
| 'refresh_token'
| 'get_usage'
| 'get_models'
| 'custom'
/** 操作状态 */
export type ActionStatus =
| 'success'
| 'auth_failed'
| 'auth_expired'
| 'rate_limited'
| 'network_error'
| 'parse_error'
| 'not_configured'
| 'not_supported'
| 'already_done'
| 'unknown_error'
/** 连接状态 */
export type ConnectorStatus = 'disconnected' | 'connecting' | 'connected' | 'expired' | 'error'
/** 架构信息 */
export interface ArchitectureInfo {
architecture_id: string
display_name: string
description: string
supported_auth_types: Array<{
type: string
display_name: string
}>
supported_actions: Array<{
type: string
display_name: string
description: string
config_schema: Record<string, any>
}>
default_connector: string | null
}
/** 连接状态响应 */
export interface ConnectionStatusResponse {
status: ConnectorStatus
auth_type: ConnectorAuthType
connected_at: string | null
expires_at: string | null
last_error: string | null
}
/** Provider 操作状态响应 */
export interface ProviderOpsStatusResponse {
provider_id: string
is_configured: boolean
architecture_id: string | null
connection_status: ConnectionStatusResponse
enabled_actions: string[]
}
/** 余额信息 */
export interface BalanceInfo {
total_granted: number | null
total_used: number | null
total_available: number | null
expires_at: string | null
currency: string
extra: Record<string, any>
}
/** 签到信息 */
export interface CheckinInfo {
reward: number | null
streak_days: number | null
next_reward: number | null
message: string | null
extra: Record<string, any>
}
/** 操作结果响应 */
export interface ActionResultResponse {
status: ActionStatus
action_type: ProviderActionType
data: BalanceInfo | CheckinInfo | Record<string, unknown> | null
message: string | null
executed_at: string
response_time_ms: number | null
cache_ttl_seconds: number
}
/** 连接器配置请求 */
export interface ConnectorConfigRequest {
auth_type: ConnectorAuthType
config: Record<string, any>
credentials: Record<string, any>
}
/** 操作配置请求 */
export interface ActionConfigRequest {
enabled: boolean
config: Record<string, any>
}
/** 保存配置请求 */
export interface SaveConfigRequest {
architecture_id: string
base_url?: string
connector: ConnectorConfigRequest
actions: Record<string, ActionConfigRequest>
schedule: Record<string, string>
}
/** 连接请求 */
export interface ConnectRequest {
credentials?: Record<string, any>
}
/** 执行操作请求 */
export interface ExecuteActionRequest {
config?: Record<string, any>
}
// ==================== API Functions ====================
const BASE_URL = '/api/admin/provider-ops'
/**
* 获取所有可用的架构
*/
export async function getArchitectures(): Promise<ArchitectureInfo[]> {
const response = await client.get<ArchitectureInfo[]>(`${BASE_URL}/architectures`)
return response.data
}
/**
* 获取指定架构的详情
*/
export async function getArchitecture(architectureId: string): Promise<ArchitectureInfo> {
const response = await client.get<ArchitectureInfo>(
`${BASE_URL}/architectures/${architectureId}`
)
return response.data
}
/**
* 获取 Provider 的操作状态
*/
export async function getProviderOpsStatus(
providerId: string
): Promise<ProviderOpsStatusResponse> {
const response = await client.get<ProviderOpsStatusResponse>(
`${BASE_URL}/providers/${providerId}/status`
)
return response.data
}
/** Provider 操作配置响应(脱敏) */
export interface ProviderOpsConfigResponse {
provider_id: string
is_configured: boolean
architecture_id?: string
base_url?: string
connector?: {
auth_type: string
config: Record<string, any>
credentials: Record<string, any>
}
}
/**
* 获取 Provider 的操作配置(脱敏)
*/
export async function getProviderOpsConfig(
providerId: string
): Promise<ProviderOpsConfigResponse> {
const response = await client.get<ProviderOpsConfigResponse>(
`${BASE_URL}/providers/${providerId}/config`
)
return response.data
}
/**
* 保存 Provider 的操作配置
*/
export async function saveProviderOpsConfig(
providerId: string,
config: SaveConfigRequest
): Promise<{ success: boolean; message: string }> {
const response = await client.put<{ success: boolean; message: string }>(
`${BASE_URL}/providers/${providerId}/config`,
config
)
return response.data
}
/**
* 删除 Provider 的操作配置
*/
export async function deleteProviderOpsConfig(
providerId: string
): Promise<{ success: boolean; message: string }> {
const response = await client.delete<{ success: boolean; message: string }>(
`${BASE_URL}/providers/${providerId}/config`
)
return response.data
}
/**
* 建立与 Provider 的连接
*/
export async function connectProvider(
providerId: string,
request?: ConnectRequest
): Promise<{ success: boolean; message: string }> {
const response = await client.post<{ success: boolean; message: string }>(
`${BASE_URL}/providers/${providerId}/connect`,
request || {}
)
return response.data
}
/**
* 断开与 Provider 的连接
*/
export async function disconnectProvider(
providerId: string
): Promise<{ success: boolean; message: string }> {
const response = await client.post<{ success: boolean; message: string }>(
`${BASE_URL}/providers/${providerId}/disconnect`
)
return response.data
}
/**
* 执行指定操作
*/
export async function executeAction(
providerId: string,
actionType: ProviderActionType,
request?: ExecuteActionRequest
): Promise<ActionResultResponse> {
const response = await client.post<ActionResultResponse>(
`${BASE_URL}/providers/${providerId}/actions/${actionType}`,
request || {}
)
return response.data
}
/**
* 获取余额(优先返回缓存,后台异步刷新)
* @param providerId Provider ID
* @param refresh 是否触发后台刷新(默认 true
*/
export async function getBalance(
providerId: string,
refresh: boolean = true
): Promise<ActionResultResponse> {
const response = await client.get<ActionResultResponse>(
`${BASE_URL}/providers/${providerId}/balance`,
{ params: { refresh } }
)
return response.data
}
/**
* 立即刷新余额(同步等待结果)
*/
export async function refreshBalance(providerId: string): Promise<ActionResultResponse> {
const response = await client.post<ActionResultResponse>(
`${BASE_URL}/providers/${providerId}/balance`
)
return response.data
}
/**
* 签到(快捷方法)
*/
export async function checkin(providerId: string): Promise<ActionResultResponse> {
const response = await client.post<ActionResultResponse>(
`${BASE_URL}/providers/${providerId}/checkin`
)
return response.data
}
/**
* 批量查询余额
*/
export async function batchQueryBalance(
providerIds?: string[]
): Promise<Record<string, ActionResultResponse>> {
const response = await client.post<Record<string, ActionResultResponse>>(
`${BASE_URL}/batch/balance`,
providerIds
)
return response.data
}
/** 验证认证请求 */
export interface VerifyAuthRequest {
architecture_id: string
base_url: string
connector: ConnectorConfigRequest
actions?: Record<string, ActionConfigRequest>
schedule?: Record<string, string>
}
/** 验证认证响应 */
export interface VerifyAuthResponse {
success: boolean
message?: string
data?: {
username?: string
display_name?: string
email?: string
quota?: number
used_quota?: number
request_count?: number
extra?: Record<string, any>
}
}
/**
* 验证 Provider 认证配置
* 在保存前测试认证是否有效
*/
export async function verifyProviderAuth(
providerId: string,
config: VerifyAuthRequest
): Promise<VerifyAuthResponse> {
const response = await client.post<VerifyAuthResponse>(
`${BASE_URL}/providers/${providerId}/verify`,
config
)
return response.data
}

View File

@@ -1,35 +1,151 @@
<template>
<div v-if="masked" class="group relative">
<input
ref="inputRef"
:class="inputClass"
:style="inputStyle"
:value="modelValue"
:type="effectiveType"
:autocomplete="autocompleteAttr"
:data-lpignore="shouldDisableAutofill ? 'true' : undefined"
:data-1p-ignore="shouldDisableAutofill ? 'true' : undefined"
:data-form-type="shouldDisableAutofill ? 'other' : undefined"
:data-protonpass-ignore="shouldDisableAutofill ? 'true' : undefined"
:data-bwignore="shouldDisableAutofill ? 'true' : undefined"
:data-bitwarden-watching="shouldDisableAutofill ? 'false' : undefined"
:name="shouldDisableAutofill ? randomName : undefined"
v-bind="filteredAttrs"
@input="handleInput"
>
<button
v-if="hasValue"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/20 hover:text-muted-foreground/50 transition-colors"
tabindex="-1"
:aria-label="isVisible ? '隐藏内容' : '显示内容'"
@click="toggleVisibility"
>
<EyeOff v-if="isVisible" class="h-4 w-4" />
<Eye v-else class="h-4 w-4" />
</button>
</div>
<input
v-else
ref="inputRef"
:class="inputClass"
:style="inputStyle"
:value="modelValue"
:type="effectiveType"
:autocomplete="autocompleteAttr"
:data-lpignore="disableAutofill ? 'true' : undefined"
:data-1p-ignore="disableAutofill ? 'true' : undefined"
:data-form-type="disableAutofill ? 'other' : undefined"
v-bind="$attrs"
:data-lpignore="shouldDisableAutofill ? 'true' : undefined"
:data-1p-ignore="shouldDisableAutofill ? 'true' : undefined"
:data-form-type="shouldDisableAutofill ? 'other' : undefined"
:data-protonpass-ignore="shouldDisableAutofill ? 'true' : undefined"
:data-bwignore="shouldDisableAutofill ? 'true' : undefined"
:data-bitwarden-watching="shouldDisableAutofill ? 'false' : undefined"
:name="shouldDisableAutofill ? randomName : undefined"
v-bind="filteredAttrs"
@input="handleInput"
>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, useAttrs, ref } from 'vue'
import { Eye, EyeOff } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
// 开发环境警告type="password" 已被弃用
const warnPasswordType = import.meta.env.DEV
? (() => {
let warned = false
return () => {
if (!warned) {
warned = true
console.warn(
'[Input] type="password" 已被弃用,请使用 masked 属性代替。\n' +
'示例:<Input v-model="apiKey" masked />\n' +
'masked 属性使用 CSS 遮蔽而非 password 类型,不会触发浏览器密码管理器。'
)
}
}
})()
: () => {}
interface Props {
modelValue?: string | number
class?: string
autocomplete?: string
/**
* 遮蔽显示内容(用于 API Key 等敏感信息)
* 使用 CSS -webkit-text-security 实现,不会触发浏览器密码管理器
* 同时会显示一个小眼睛按钮用于切换显示/隐藏
* 注意Firefox 不支持 -webkit-text-security会显示明文但仍可通过按钮切换
*/
masked?: boolean
/**
* 禁用浏览器自动填充
* - true: 禁用自动填充
* - false: 允许自动填充(默认)
*/
disableAutofill?: boolean
}
const props = defineProps<Props>()
const attrs = useAttrs()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const inputRef = ref<HTMLInputElement | null>(null)
const isVisible = ref(false)
// 判断是否有值
const hasValue = computed(() => {
return props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== ''
})
function toggleVisibility() {
isVisible.value = !isVisible.value
}
// 计算是否应该禁用自动填充
const shouldDisableAutofill = computed(() => {
// masked 模式默认禁用自动填充
if (props.masked && props.disableAutofill === undefined) {
return true
}
return props.disableAutofill ?? false
})
// 始终使用 text 类型,永远不用 password
const effectiveType = computed(() => {
const attrType = attrs.type as string | undefined
// 如果传入 password强制转为 text配合 masked 使用)
if (attrType === 'password') {
warnPasswordType()
return 'text'
}
return attrType
})
// 过滤掉 type 和 class 属性,因为我们会单独处理
const filteredAttrs = computed(() => {
const { type, class: _, ...rest } = attrs
return rest
})
// 生成一个稳定的随机值(组件实例级别)
const randomSuffix = Math.random().toString(36).substring(2, 8)
const randomName = `field_${randomSuffix}`
const autocompleteAttr = computed(() => {
if (props.disableAutofill) {
return 'one-time-code'
// 如果显式设置了 autocomplete 且不禁用自动填充,使用该值
if (props.autocomplete && !shouldDisableAutofill.value) {
return props.autocomplete
}
// 禁用自动填充时,使用浏览器无法识别的随机值
if (shouldDisableAutofill.value) {
return `off-${randomSuffix}`
}
return props.autocomplete ?? 'off'
})
@@ -37,12 +153,25 @@ const autocompleteAttr = computed(() => {
const inputClass = computed(() =>
cn(
'flex h-11 w-full rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground backdrop-blur transition-all',
props.masked && 'pr-10',
props.class
)
)
// 当 masked 为 true 且未显示时,用 CSS 遮蔽文字
const inputStyle = computed(() => {
if (props.masked && !isVisible.value) {
// 使用 -webkit-text-securityChrome, Safari, Edge 支持)
// Firefox 不支持此属性,会显示明文,但仍可通过小眼睛按钮切换
return { '-webkit-text-security': 'disc' }
}
return undefined
})
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
defineExpose({ inputRef })
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div class="flex flex-col sm:flex-row gap-4 border-t border-border/60 px-6 py-4 bg-muted/20">
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 border-t border-border/60 px-4 sm:px-6 py-3 sm:py-4 bg-muted/20">
<!-- 左侧记录范围和每页数量 -->
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm text-muted-foreground">
<span class="font-medium">
<div class="flex items-center justify-between sm:justify-start gap-3 text-sm text-muted-foreground">
<span class="font-medium whitespace-nowrap">
显示 <span class="text-foreground font-semibold">{{ recordRange.start }}-{{ recordRange.end }}</span> <span class="text-foreground font-semibold">{{ total }}</span>
</span>
<Select
@@ -11,8 +11,10 @@
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger class="w-36 h-9 border-border/60">
<SelectValue />
<SelectTrigger class="w-[120px] h-8 sm:h-9 border-border/60 text-xs sm:text-sm">
<span class="flex-1 text-center">
<SelectValue />
</span>
</SelectTrigger>
<SelectContent>
<SelectItem
@@ -27,26 +29,7 @@
</div>
<!-- 右侧分页按钮 -->
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === 1"
@click="handlePageChange(1)"
>
首页
</Button>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === 1"
@click="handlePageChange(current - 1)"
>
上一页
</Button>
<div class="flex flex-wrap items-center justify-center gap-1.5 sm:gap-2 sm:ml-auto">
<!-- 页码按钮智能省略 -->
<template
v-for="page in pageNumbers"
@@ -68,24 +51,24 @@
>{{ page }}</span>
</template>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === totalPages"
@click="handlePageChange(current + 1)"
<!-- 页码跳转 -->
<div
v-if="totalPages > 7"
class="flex items-center gap-1.5 ml-2 text-sm text-muted-foreground"
>
下一页
</Button>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === totalPages"
@click="handlePageChange(totalPages)"
>
末页
</Button>
<span class="hidden sm:inline">跳至</span>
<input
v-model="jumpPageInput"
type="text"
inputmode="numeric"
pattern="[0-9]*"
class="w-12 h-9 px-2 text-center text-sm border border-border/60 rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60"
@keydown.enter="handleJumpPage"
@blur="handleJumpPage"
@input="filterNumericInput"
>
<span class="hidden sm:inline"></span>
</div>
</div>
</div>
</template>
@@ -116,6 +99,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const pageSizeSelectOpen = ref(false)
const jumpPageInput = ref('')
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
@@ -175,4 +159,18 @@ function handlePageSizeChange(value: string) {
emit('update:current', 1)
}
}
function handleJumpPage() {
const page = parseInt(jumpPageInput.value)
if (!isNaN(page) && page >= 1 && page <= totalPages.value && page !== props.current) {
emit('update:current', page)
}
jumpPageInput.value = ''
}
function filterNumericInput(event: Event) {
const input = event.target as HTMLInputElement
input.value = input.value.replace(/[^0-9]/g, '')
jumpPageInput.value = input.value
}
</script>

View File

@@ -0,0 +1,66 @@
/**
* 提供商认证模板注册表
*
* 集中管理所有认证模板。
*
* ## 添加新模板的步骤
*
* 1. 在 `auth-templates/` 目录下创建新的模板文件(如 `my-api.ts`
* 2. 实现 `AuthTemplate` 接口
* 3. 在本文件中导入并注册到 `templates` 数组
*
* ## 模板需要实现的内容
*
* - `id`: 模板唯一标识(对应后端的 architecture_id
* - `name`: 显示名称
* - `description`: 描述文本
* - `getFields()`: 返回表单字段定义
* - `buildRequest()`: 构建后端 API 请求
* - `parseConfig()`: 从已有配置解析表单数据
* - `validate()`: 验证表单数据
* - `formatQuota()`: (可选)格式化 quota 显示
*/
import type { AuthTemplate, AuthTemplateRegistry } from './types'
import { newApiTemplate } from './new-api'
// ==================== 模板注册 ====================
// 在这里添加新模板
const templates: AuthTemplate[] = [newApiTemplate]
// ==================== 注册表实现 ====================
const templateMap = new Map<string, AuthTemplate>()
// 初始化 Map
templates.forEach((template) => {
templateMap.set(template.id, template)
})
/**
* 认证模板注册表
*/
export const authTemplateRegistry: AuthTemplateRegistry = {
getAll(): AuthTemplate[] {
return templates
},
get(id: string): AuthTemplate | undefined {
return templateMap.get(id)
},
getDefault(): AuthTemplate {
return templates[0]
},
register(template: AuthTemplate): void {
templates.push(template)
templateMap.set(template.id, template)
},
}
// ==================== 导出 ====================
export * from './types'
export { newApiTemplate } from './new-api'

View File

@@ -0,0 +1,97 @@
/**
* New API 认证模板
*
* 适用于 New API 风格的中转站:
* - 使用 Bearer Token 认证
* - 需要 New-Api-User Header 传递用户 ID
* - quota 单位通常是 1/500000 美元
*/
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
import type { SaveConfigRequest } from '@/api/providerOps'
export const newApiTemplate: AuthTemplate = {
id: 'new_api',
name: 'New API',
description: '适用于 New API 风格的中转站,使用 Bearer Token + New-Api-User Header',
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
return [
{
fields: [
{
key: 'base_url',
label: 'API 地址',
type: 'text',
placeholder: providerWebsite || 'https://api.example.com',
helpText: '提供商的 API 基础地址,留空则使用提供商官网',
required: false,
},
{
key: 'api_key',
label: '访问令牌 (API Key)',
type: 'password',
placeholder: 'sk-xxx',
required: true,
sensitive: true,
},
{
key: 'user_id',
label: '用户 ID',
type: 'text',
placeholder: '用户 ID',
required: true,
},
],
},
]
},
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
const baseUrl = formData.base_url || providerWebsite || ''
return {
architecture_id: 'new_api',
base_url: baseUrl,
connector: {
auth_type: 'api_key',
config: {
auth_method: 'bearer',
},
credentials: {
api_key: formData.api_key,
user_id: formData.user_id,
},
},
actions: {},
schedule: {},
}
},
parseConfig(config: any): Record<string, any> {
return {
base_url: config?.base_url || '',
api_key: config?.connector?.credentials?.api_key || '',
user_id: config?.connector?.credentials?.user_id || '',
}
},
validate(formData: Record<string, any>): string | null {
if (!formData.api_key?.trim()) {
return '请填写访问令牌'
}
if (!formData.user_id?.trim()) {
return '请填写用户 ID'
}
return null
},
formatQuota(quota: number): string {
// New API 的 quota 单位是 1/500000 美元
const usd = quota / 500000
if (usd >= 1) {
return `$${usd.toFixed(2)}`
}
return `$${usd.toFixed(4)}`
},
}

View File

@@ -0,0 +1,120 @@
/**
* 提供商认证模板类型定义
*
* 认证模板定义了:
* - 需要收集的表单字段
* - 如何构建后端请求
* - 如何解析已有配置
*/
import type { SaveConfigRequest } from '@/api/providerOps'
/**
* 表单字段类型
*/
export type FieldType = 'text' | 'password' | 'select' | 'textarea'
/**
* 表单字段定义
*/
export interface AuthTemplateField {
/** 字段 key用于表单数据 */
key: string
/** 显示标签 */
label: string
/** 字段类型 */
type: FieldType
/** 占位符 */
placeholder?: string
/** 帮助文本 */
helpText?: string
/** 是否必填 */
required?: boolean
/** 是否为敏感字段(使用 masked 输入) */
sensitive?: boolean
/** select 类型的选项 */
options?: Array<{ value: string; label: string }>
/** 默认值 */
defaultValue?: string
}
/**
* 表单字段分组
*/
export interface AuthTemplateFieldGroup {
/** 分组标题(可选,为空则不显示标题) */
title?: string
/** 分组内的字段 */
fields: AuthTemplateField[]
}
/**
* 验证结果数据
*/
export interface VerifyResultData {
username?: string
display_name?: string
email?: string
quota?: number
used_quota?: number
request_count?: number
extra?: Record<string, any>
}
/**
* 认证模板接口
*/
export interface AuthTemplate {
/** 模板 ID对应后端 architecture_id */
id: string
/** 显示名称 */
name: string
/** 描述 */
description: string
/**
* 获取表单字段定义
* @param providerWebsite 提供商官网(用于设置默认 base_url
*/
getFields(providerWebsite?: string): AuthTemplateFieldGroup[]
/**
* 构建保存请求
* @param formData 表单数据
* @param providerWebsite 提供商官网
*/
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest
/**
* 从已有配置解析表单数据
* @param config 已有配置
*/
parseConfig(config: any): Record<string, any>
/**
* 验证表单数据
* @param formData 表单数据
* @returns 错误消息,无错误返回 null
*/
validate(formData: Record<string, any>): string | null
/**
* 格式化验证结果中的 quota 显示
* @param quota quota 值
*/
formatQuota?(quota: number): string
}
/**
* 认证模板注册表类型
*/
export interface AuthTemplateRegistry {
/** 获取所有模板 */
getAll(): AuthTemplate[]
/** 根据 ID 获取模板 */
get(id: string): AuthTemplate | undefined
/** 获取默认模板 */
getDefault(): AuthTemplate
/** 注册模板 */
register(template: AuthTemplate): void
}

View File

@@ -38,19 +38,9 @@
:id="apiKeyInputId"
v-model="form.api_key"
:name="apiKeyFieldName"
:type="apiKeyInputType"
masked
:required="!editingKey"
:placeholder="editingKey ? editingKey.api_key_masked : 'sk-...'"
:class="getApiKeyInputClass()"
autocomplete="new-password"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
@focus="apiKeyFocused = true"
@blur="apiKeyFocused = form.api_key.trim().length > 0"
/>
<p
v-if="apiKeyError"
@@ -329,10 +319,6 @@ const keyNameInputId = computed(() => `key-name-${formNonce.value}`)
const apiKeyInputId = computed(() => `api-key-${formNonce.value}`)
const keyNameFieldName = computed(() => `key-name-field-${formNonce.value}`)
const apiKeyFieldName = computed(() => `api-key-field-${formNonce.value}`)
const apiKeyFocused = ref(false)
const apiKeyInputType = computed(() =>
apiKeyFocused.value || form.value.api_key.trim().length > 0 ? 'password' : 'text'
)
// 可用的能力列表
const availableCapabilities = ref<CapabilityDefinition[]>([])
@@ -397,18 +383,6 @@ function updateRateMultiplier(format: string, value: string | number) {
form.value.rate_multipliers = newMultipliers
}
// API 密钥输入框样式计算
function getApiKeyInputClass(): string {
const classes = []
if (apiKeyError.value) {
classes.push('border-destructive')
}
if (!apiKeyFocused.value && !form.value.api_key) {
classes.push('text-transparent caret-transparent selection:bg-transparent selection:text-transparent')
}
return classes.join(' ')
}
// API 密钥验证错误信息
const apiKeyError = computed(() => {
@@ -433,7 +407,6 @@ const apiKeyError = computed(() => {
// 重置表单
function resetForm() {
formNonce.value = createFieldNonce()
apiKeyFocused.value = false
form.value = {
name: '',
api_key: '',
@@ -453,7 +426,6 @@ function resetForm() {
// 添加成功后清除部分字段以便继续添加
function clearForNextAdd() {
formNonce.value = createFieldNonce()
apiKeyFocused.value = false
form.value.name = ''
form.value.api_key = ''
}
@@ -462,7 +434,6 @@ function clearForNextAdd() {
function loadKeyData() {
if (!props.editingKey) return
formNonce.value = createFieldNonce()
apiKeyFocused.value = false
form.value = {
name: props.editingKey.name,
api_key: '',

View File

@@ -0,0 +1,492 @@
<template>
<Dialog
:open="open"
title="用户认证"
description="配置提供商的用户认证信息,用于余额查询、签到等操作"
:icon="KeyRound"
size="md"
@update:open="$emit('update:open', $event)"
>
<form :name="`provider-auth-${Date.now()}`" autocomplete="off" @submit.prevent>
<!-- 加载状态 -->
<div
v-if="isLoadingConfig"
class="flex items-center justify-center py-8"
>
<div class="text-sm text-muted-foreground">加载配置中...</div>
</div>
<div
v-else
class="space-y-4"
>
<!-- 认证模板选择 -->
<div class="space-y-2">
<Label>认证模板</Label>
<Select
v-model="selectedTemplateId"
v-model:open="templateSelectOpen"
@update:model-value="handleTemplateChange"
>
<SelectTrigger>
<SelectValue placeholder="选择认证模板" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="template in templates"
:key="template.id"
:value="template.id"
>
{{ template.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 动态表单字段 -->
<template v-if="selectedTemplate">
<template
v-for="(group, groupIndex) in fieldGroups"
:key="groupIndex"
>
<!-- 分组标题 -->
<div
v-if="group.title"
class="pt-2 text-sm font-medium text-muted-foreground"
>
{{ group.title }}
</div>
<!-- 字段列表 -->
<div
v-for="field in group.fields"
:key="field.key"
class="space-y-2"
>
<Label>
{{ field.label }}
<span
v-if="field.required"
class="text-muted-foreground/70"
>*</span>
</Label>
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:placeholder="field.placeholder"
disable-autofill
/>
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
/>
<!-- 下拉选择 -->
<Select
v-else-if="field.type === 'select'"
v-model="formData[field.key]"
@update:model-value="handleFieldChange(field.key, $event)"
>
<SelectTrigger>
<SelectValue :placeholder="field.placeholder || '请选择'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in field.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 多行文本 -->
<Textarea
v-else-if="field.type === 'textarea'"
v-model="formData[field.key]"
:placeholder="field.placeholder"
rows="3"
/>
<!-- 帮助文本 -->
<p
v-if="field.helpText"
class="text-xs text-muted-foreground"
>
{{ field.helpText }}
</p>
</div>
</template>
</template>
</div>
</form>
<template #footer>
<Button
variant="outline"
@click="$emit('update:open', false)"
>
取消
</Button>
<Button
:disabled="isSaving || !canSave"
@click="handleSave"
>
{{ isSaving ? '保存中...' : '保存' }}
</Button>
<Button
variant="outline"
:disabled="isVerifying || !canVerify"
@click="handleVerify"
>
{{ isVerifying ? '验证中...' : '验证' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { KeyRound } from 'lucide-vue-next'
import {
Dialog,
Button,
Input,
Label,
Textarea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { saveProviderOpsConfig, verifyProviderAuth, getProviderOpsConfig } from '@/api/providerOps'
import { useToast } from '@/composables/useToast'
import {
authTemplateRegistry,
type AuthTemplate,
type AuthTemplateFieldGroup,
} from '../auth-templates'
// 敏感字段列表(用于验证和加载配置时的特殊处理)
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'cookie_string', 'cookies'] as const
const props = defineProps<{
open: boolean
providerId: string
providerWebsite?: string
currentConfig?: any
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'saved'): void
}>()
const { success: showSuccess, error: showError } = useToast()
// State
const isSaving = ref(false)
const isVerifying = ref(false)
const isLoadingConfig = ref(false)
const verifyStatus = ref<'success' | 'error' | null>(null)
const formChanged = ref(false)
// 敏感字段的 placeholder存储脱敏后的已保存值
const sensitivePlaceholders = ref<Record<string, string>>({})
// 是否有已保存的配置(编辑模式)
const hasExistingConfig = ref(false)
// Select 下拉框状态
const templateSelectOpen = ref(false)
// 模板选择
const selectedTemplateId = ref('new_api')
const formData = ref<Record<string, any>>({})
// 表单是否可以验证(必填字段已填写)
const canVerify = computed(() => {
const template = selectedTemplate.value
if (!template) return false
// 编辑模式下,敏感字段可以为空(使用已保存的值)
if (hasExistingConfig.value) {
// 创建一个临时数据,把空的敏感字段填充为占位值以通过验证
const tempData = { ...formData.value }
for (const field of SENSITIVE_FIELDS) {
if (!tempData[field] && sensitivePlaceholders.value[field]) {
tempData[field] = 'placeholder'
}
}
const error = template.validate(tempData)
if (error) return false
} else {
const error = template.validate(formData.value)
if (error) return false
}
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
return !!effectiveBaseUrl
})
// 保存按钮是否可用:验证成功且表单未变动
const canSave = computed(() => {
return verifyStatus.value === 'success' && !formChanged.value
})
// Computed
const templates = computed(() => authTemplateRegistry.getAll())
const selectedTemplate = computed<AuthTemplate | undefined>(() => {
return authTemplateRegistry.get(selectedTemplateId.value)
})
const fieldGroups = computed<AuthTemplateFieldGroup[]>(() => {
if (!selectedTemplate.value) return []
return selectedTemplate.value.getFields(props.providerWebsite)
})
// Methods
function handleTemplateChange() {
// 重置表单数据
resetFormData()
// 重置验证状态
verifyStatus.value = null
formChanged.value = true
}
function handleFieldChange(_fieldKey: string, _value: any) {
// 标记表单已变动
formChanged.value = true
}
// 监听 formData 变化,验证成功后的修改需要重新验证
watch(
formData,
() => {
// 验证成功后任何修改都需要重新验证
if (verifyStatus.value === 'success') {
formChanged.value = true
}
},
{ deep: true }
)
function resetFormData() {
const template = selectedTemplate.value
if (!template) {
formData.value = {}
return
}
// 初始化表单数据,设置默认值
const data: Record<string, any> = {}
const groups = template.getFields(props.providerWebsite)
for (const group of groups) {
for (const field of group.fields) {
data[field.key] = field.defaultValue ?? ''
}
}
formData.value = data
}
function formatQuota(quota: number): string {
const template = selectedTemplate.value
if (template?.formatQuota) {
return template.formatQuota(quota)
}
// 默认格式化
return quota.toLocaleString()
}
async function handleVerify() {
const template = selectedTemplate.value
if (!template) return
// 验证表单(编辑模式下敏感字段可以为空)
let dataToValidate = formData.value
if (hasExistingConfig.value) {
dataToValidate = { ...formData.value }
for (const field of SENSITIVE_FIELDS) {
if (!dataToValidate[field] && sensitivePlaceholders.value[field]) {
dataToValidate[field] = 'placeholder'
}
}
}
const error = template.validate(dataToValidate)
if (error) {
showError(error)
return
}
// 检查 base_url
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
return
}
isVerifying.value = true
try {
const request = template.buildRequest(formData.value, props.providerWebsite)
// 确保 base_url 是有效字符串,用于 VerifyAuthRequest
const verifyRequest = {
...request,
base_url: request.base_url || effectiveBaseUrl,
}
const result = await verifyProviderAuth(props.providerId, verifyRequest)
if (result.success) {
verifyStatus.value = 'success'
formChanged.value = false // 验证成功后重置表单变动标记
// Toast 提示
const displayName = result.data?.display_name || result.data?.username || '未知'
const quotaStr = result.data?.quota !== undefined ? ` | 余额: ${formatQuota(result.data.quota)}` : ''
showSuccess(`用户: ${displayName}${quotaStr}`, '验证成功')
} else {
verifyStatus.value = 'error'
showError(result.message || '验证失败')
}
} catch (error: any) {
verifyStatus.value = 'error'
const errMsg = error.response?.data?.detail || error.message || '验证失败'
showError(errMsg)
} finally {
isVerifying.value = false
}
}
async function handleSave() {
const template = selectedTemplate.value
if (!template) return
// 验证表单(编辑模式下敏感字段可以为空)
let dataToValidate = formData.value
if (hasExistingConfig.value) {
dataToValidate = { ...formData.value }
for (const field of SENSITIVE_FIELDS) {
if (!dataToValidate[field] && sensitivePlaceholders.value[field]) {
dataToValidate[field] = 'placeholder'
}
}
}
const error = template.validate(dataToValidate)
if (error) {
showError(error)
return
}
// 检查 base_url
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
return
}
isSaving.value = true
try {
const request = template.buildRequest(formData.value, props.providerWebsite)
const result = await saveProviderOpsConfig(props.providerId, request)
if (result.success) {
showSuccess(result.message || '配置已保存', '保存成功')
emit('saved')
emit('update:open', false)
} else {
showError(result.message || '保存失败')
}
} catch (error: any) {
showError(error.response?.data?.detail || error.message, '保存失败')
} finally {
isSaving.value = false
}
}
function loadFromConfig(config: any) {
if (!config?.connector) return
hasExistingConfig.value = true
// 目前只支持 new_api 模板
selectedTemplateId.value = 'new_api'
// 使用模板解析配置
const template = authTemplateRegistry.get(selectedTemplateId.value)
if (template) {
const parsedData = template.parseConfig(config)
// 敏感字段:脱敏值放到 placeholder表单值设为空
sensitivePlaceholders.value = {}
for (const field of SENSITIVE_FIELDS) {
if (parsedData[field]) {
// 保存脱敏值作为 placeholder 提示
sensitivePlaceholders.value[field] = `${parsedData[field]}`
// 表单值设为空
parsedData[field] = ''
}
}
formData.value = parsedData
}
}
// 打开对话框时初始化
watch(
() => props.open,
async (newVal) => {
if (newVal) {
verifyStatus.value = null
formChanged.value = false
// 如果传入了 currentConfig直接使用
if (props.currentConfig?.connector) {
loadFromConfig(props.currentConfig)
return
}
// 否则尝试从后端加载现有配置
if (props.providerId) {
isLoadingConfig.value = true
try {
const config = await getProviderOpsConfig(props.providerId)
if (config.is_configured && config.architecture_id) {
// 构建与 loadFromConfig 兼容的格式
const configData = {
architecture_id: config.architecture_id,
base_url: config.base_url,
connector: config.connector,
}
loadFromConfig(configData)
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
selectedTemplateId.value = 'new_api'
resetFormData()
}
} catch {
// 加载失败,使用默认值
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
selectedTemplateId.value = 'new_api'
resetFormData()
} finally {
isLoadingConfig.value = false
}
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
selectedTemplateId.value = 'new_api'
resetFormData()
}
}
}
)
</script>

View File

@@ -10,3 +10,4 @@ export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
export { default as ProviderAuthDialog } from './ProviderAuthDialog.vue'

View File

@@ -3,7 +3,6 @@
<!-- 提供商表格 -->
<Card
variant="default"
class="overflow-hidden"
>
<!-- 标题和操作栏 -->
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/50">
@@ -99,24 +98,18 @@
<Table>
<TableHeader>
<TableRow class="border-b border-border/40 hover:bg-transparent">
<TableHead class="w-[150px] h-11 font-medium text-foreground/80">
<TableHead class="w-[180px] h-11 font-medium text-foreground/80">
提供商信息
</TableHead>
<TableHead class="w-[100px] h-11 font-medium text-foreground/80">
计费类型
</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80">
官网
<TableHead class="w-[140px] h-11 font-medium text-foreground/80 text-center">
余额监控
</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">
资源统计
</TableHead>
<TableHead class="w-[240px] h-11 font-medium text-foreground/80">
<TableHead class="w-[280px] h-11 font-medium text-foreground/80">
端点健康
</TableHead>
<TableHead class="w-[140px] h-11 font-medium text-foreground/80">
配额/限流
</TableHead>
<TableHead class="w-[80px] h-11 font-medium text-foreground/80 text-center">
状态
</TableHead>
@@ -134,28 +127,49 @@
@click="handleRowClick($event, provider.id)"
>
<TableCell class="py-3.5">
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
<div class="space-y-0.5">
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
<a
v-if="provider.website"
:href="provider.website"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary/80 hover:text-primary hover:underline truncate block max-w-[160px]"
:title="provider.website"
@click.stop
>
{{ formatWebsiteDisplay(provider.website) }}
</a>
</div>
</TableCell>
<TableCell class="py-3.5">
<Badge
variant="outline"
class="text-xs font-normal border-border/50"
<TableCell class="py-3.5 text-center">
<!-- 显示从上游 API 查询的余额 -->
<div
v-if="provider.ops_configured && getProviderBalance(provider.id)"
class="text-xs"
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
</TableCell>
<TableCell class="py-3.5">
<a
v-if="provider.website"
:href="provider.website"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary/80 hover:text-primary hover:underline truncate block max-w-[100px]"
:title="provider.website"
@click.stop
<span class="font-semibold text-foreground/90">
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
</span>
</div>
<!-- 显示本地配置的月度配额 -->
<div
v-else-if="provider.billing_type === 'monthly_quota'"
class="space-y-0.5 text-xs"
>
{{ formatWebsiteDisplay(provider.website) }}
</a>
<Badge
variant="outline"
class="text-[10px] font-normal border-border/50"
>
{{ formatBillingType(provider.billing_type) }}
</Badge>
<div class="text-muted-foreground/70 pt-0.5">
<span
class="font-semibold"
:class="getQuotaUsedColorClass(provider)"
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
</div>
</div>
<span
v-else
class="text-xs text-muted-foreground/50"
@@ -204,25 +218,6 @@
class="text-xs text-muted-foreground/50"
>暂无端点</span>
</TableCell>
<TableCell class="py-3.5">
<div class="space-y-0.5 text-xs">
<div
v-if="provider.billing_type === 'monthly_quota'"
class="text-muted-foreground/70"
>
配额: <span
class="font-semibold"
:class="getQuotaUsedColorClass(provider)"
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
</div>
<div
v-else
class="text-muted-foreground/50"
>
按量付费
</div>
</div>
</TableCell>
<TableCell class="py-3.5 text-center">
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
@@ -254,6 +249,15 @@
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
title="扩展操作配置"
@click="openOpsConfigDialog(provider)"
>
<KeyRound class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -311,10 +315,29 @@
variant="ghost"
size="icon"
class="h-7 w-7"
title="查看详情"
@click="openProviderDrawer(provider.id)"
>
<Eye class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑"
@click="openEditProviderDialog(provider)"
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="扩展操作配置"
@click="openOpsConfigDialog(provider)"
>
<KeyRound class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -334,7 +357,7 @@
</div>
</div>
<!-- 第二行计费类型 + 资源统计 -->
<!-- 第二行计费类型 + 余额/配额 + 资源统计 -->
<div class="flex flex-wrap items-center gap-3 text-xs">
<Badge
variant="outline"
@@ -342,6 +365,23 @@
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
<!-- 余额从上游 API 查询 -->
<span
v-if="provider.ops_configured && getProviderBalance(provider.id)"
class="text-muted-foreground"
>
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
</span>
<!-- 本地配额 -->
<span
v-else-if="provider.billing_type === 'monthly_quota'"
class="text-muted-foreground"
>
配额 <span
class="font-semibold"
:class="getQuotaUsedColorClass(provider)"
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span>/<span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
</span>
<span class="text-muted-foreground">
端点 {{ provider.active_endpoints }}/{{ provider.total_endpoints }}
</span>
@@ -371,19 +411,6 @@
{{ endpoint.api_format }}
</span>
</div>
<!-- 第四行配额 -->
<div
v-if="provider.billing_type === 'monthly_quota'"
class="flex items-center gap-3 text-xs text-muted-foreground"
>
<span>
配额: <span
class="font-semibold"
:class="getQuotaUsedColorClass(provider)"
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / ${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}
</span>
</div>
</div>
</div>
@@ -421,6 +448,13 @@
@toggle-status="toggleProviderStatus"
@refresh="loadProviders"
/>
<ProviderAuthDialog
v-model:open="opsConfigDialogOpen"
:provider-id="opsConfigProviderId"
:provider-website="opsConfigProviderWebsite"
@saved="handleOpsConfigSaved"
/>
</template>
<script setup lang="ts">
@@ -432,7 +466,8 @@ import {
Eye,
Trash2,
ChevronDown,
Power
Power,
KeyRound
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
@@ -446,7 +481,7 @@ import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import Pagination from '@/components/ui/pagination.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { ProviderFormDialog, PriorityManagementDialog } from '@/features/providers/components'
import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components'
import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
@@ -458,6 +493,7 @@ import {
type ProviderWithEndpointsSummary
} from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import { batchQueryBalance, type ActionResultResponse } from '@/api/providerOps'
import { formatBillingType } from '@/utils/format'
const { error: showError, success: showSuccess } = useToast()
@@ -473,6 +509,17 @@ const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false)
const selectedProviderId = ref<string | null>(null)
// 扩展操作配置对话框
const opsConfigDialogOpen = ref(false)
const opsConfigProviderId = ref('')
const opsConfigProviderWebsite = ref('')
// 余额数据缓存 {providerId: ActionResultResponse}
const balanceCache = ref<Record<string, ActionResultResponse>>({})
// 余额加载请求版本计数器(用于防止竞态条件)
// 使用普通变量而非 ref因为不需要响应式仅用于比较请求版本
let balanceLoadVersion = 0
// 搜索
const searchQuery = ref('')
@@ -542,8 +589,12 @@ async function loadPriorityMode() {
// 加载提供商列表
async function loadProviders() {
loading.value = true
// 清空旧的余额缓存,避免数据累积
balanceCache.value = {}
try {
providers.value = await getProvidersSummary()
// 异步加载配置了 ops 的 provider 的余额数据
loadBalances()
} catch (err: any) {
showError(err.response?.data?.detail || '加载提供商列表失败', '错误')
} finally {
@@ -551,6 +602,70 @@ async function loadProviders() {
}
}
// 异步加载余额数据(使用批量接口)
async function loadBalances() {
const currentVersion = ++balanceLoadVersion
try {
const opsProviderIds = providers.value
.filter(p => p.ops_configured)
.map(p => p.id)
if (opsProviderIds.length === 0) return
const results = await batchQueryBalance(opsProviderIds)
// 检查是否有新的请求已经开始,如果有则丢弃当前结果
if (currentVersion !== balanceLoadVersion) return
// 将成功的结果存入缓存
for (const [providerId, result] of Object.entries(results)) {
if (result.status === 'success') {
balanceCache.value[providerId] = result
}
}
} catch (e) {
console.warn('[loadBalances] 加载余额数据失败:', e)
}
}
/**
* 类型守卫:检查是否为 BalanceInfo简化版
* 只检查余额显示所需的字段,完整的 BalanceInfo 还包含 total_granted, total_used, expires_at, extra
*/
function isBalanceInfo(data: unknown): data is { total_available: number | null; currency: string } {
if (typeof data !== 'object' || data === null) return false
if (!('total_available' in data) || !('currency' in data)) return false
const d = data as Record<string, unknown>
// total_available 必须是 number 或 null
if (d.total_available !== null && typeof d.total_available !== 'number') return false
// currency 必须是 string
if (typeof d.currency !== 'string') return false
return true
}
// 获取 provider 的余额显示
function getProviderBalance(providerId: string): { available: number | null; currency: string } | null {
const result = balanceCache.value[providerId]
if (!result || result.status !== 'success' || !result.data) {
return null
}
if (!isBalanceInfo(result.data)) {
return null
}
return {
available: result.data.total_available,
currency: result.data.currency || 'USD'
}
}
// 格式化余额显示
function formatBalanceDisplay(balance: { available: number | null; currency: string } | null): string {
if (!balance || balance.available == null) {
return '-'
}
const symbol = balance.currency === 'USD' ? '$' : balance.currency
return `${symbol}${balance.available.toFixed(2)}`
}
// 格式化官网显示
function formatWebsiteDisplay(url: string): string {
@@ -660,6 +775,19 @@ function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
providerDialogOpen.value = true
}
// 打开扩展操作配置对话框
function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
opsConfigProviderId.value = provider.id
opsConfigProviderWebsite.value = provider.website || ''
opsConfigDialogOpen.value = true
}
// 扩展操作配置保存回调
function handleOpsConfigSaved() {
opsConfigDialogOpen.value = false
loadProviders()
}
// 处理提供商编辑完成
function handleProviderUpdated() {
loadProviders()

View File

@@ -9,6 +9,7 @@ from .management_tokens import router as management_tokens_router
from .modules import router as modules_router
from .models import router as models_router
from .monitoring import router as monitoring_router
from .provider_ops import router as provider_ops_router
from .provider_query import router as provider_query_router
from .provider_strategy import router as provider_strategy_router
from .providers import router as providers_router
@@ -32,6 +33,7 @@ router.include_router(security_router)
router.include_router(provider_query_router)
router.include_router(management_tokens_router)
router.include_router(modules_router)
router.include_router(provider_ops_router)
# 注意ldap_router 已迁移到模块系统,由 ModuleRegistry 动态注册
# 当 LDAP_AVAILABLE=true 时才会注册路由

View File

@@ -0,0 +1,5 @@
"""Provider 操作 API 模块"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,510 @@
"""
Provider 操作 API 路由
提供 Provider 操作相关的 API 端点:
- 架构列表
- 连接管理
- 操作执行(余额查询、签到等)
- 配置管理
"""
from dataclasses import asdict, is_dataclass
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from src.database import get_db
from src.models.database import Provider, User
from src.services.provider_ops import (
ActionStatus,
ConnectorAuthType,
ConnectorStatus,
ProviderActionType,
ProviderOpsConfig,
ProviderOpsService,
get_registry,
)
from src.utils.auth_utils import require_admin
router = APIRouter(prefix="/api/admin/provider-ops", tags=["Provider Operations"])
# ==================== Request/Response Models ====================
class ArchitectureInfo(BaseModel):
"""架构信息"""
architecture_id: str
display_name: str
description: str
supported_auth_types: List[Dict[str, str]]
supported_actions: List[Dict[str, Any]]
default_connector: Optional[str]
class ConnectorConfigRequest(BaseModel):
"""连接器配置请求"""
auth_type: str = Field(..., description="认证类型")
config: Dict[str, Any] = Field(default_factory=dict, description="连接器配置")
credentials: Dict[str, Any] = Field(default_factory=dict, description="凭据信息")
class ActionConfigRequest(BaseModel):
"""操作配置请求"""
enabled: bool = Field(True, description="是否启用")
config: Dict[str, Any] = Field(default_factory=dict, description="操作配置")
class SaveConfigRequest(BaseModel):
"""保存配置请求"""
architecture_id: str = Field("generic_api", description="架构 ID")
base_url: Optional[str] = Field(None, description="API 基础地址")
connector: ConnectorConfigRequest
actions: Dict[str, ActionConfigRequest] = Field(default_factory=dict)
schedule: Dict[str, str] = Field(default_factory=dict, description="定时任务配置")
class ConnectRequest(BaseModel):
"""连接请求"""
credentials: Optional[Dict[str, Any]] = Field(None, description="凭据(可选,使用已保存的)")
class ExecuteActionRequest(BaseModel):
"""执行操作请求"""
config: Optional[Dict[str, Any]] = Field(None, description="操作配置(覆盖默认)")
class ConnectionStatusResponse(BaseModel):
"""连接状态响应"""
status: str
auth_type: str
connected_at: Optional[str]
expires_at: Optional[str]
last_error: Optional[str]
class ActionResultResponse(BaseModel):
"""操作结果响应"""
status: str
action_type: str
data: Optional[Any]
message: Optional[str]
executed_at: str
response_time_ms: Optional[int]
cache_ttl_seconds: int
class ProviderOpsStatusResponse(BaseModel):
"""Provider 操作状态响应"""
provider_id: str
is_configured: bool
architecture_id: Optional[str]
connection_status: ConnectionStatusResponse
enabled_actions: List[str]
class ProviderOpsConfigResponse(BaseModel):
"""Provider 操作配置响应(脱敏)"""
provider_id: str
is_configured: bool
architecture_id: Optional[str] = None
base_url: Optional[str] = None
connector: Optional[Dict[str, Any]] = None # 脱敏后的连接器配置
class VerifyAuthResponse(BaseModel):
"""验证认证响应"""
success: bool
message: Optional[str] = None
data: Optional[Dict[str, Any]] = None
# ==================== Helper Functions ====================
def _serialize_data(data: Any) -> Any:
"""序列化 dataclass 为字典,用于 JSON 响应"""
if data is None:
return None
if is_dataclass(data) and not isinstance(data, type):
return asdict(data)
return data
# ==================== Routes ====================
@router.get("/architectures", response_model=List[ArchitectureInfo])
async def list_architectures(_: User = Depends(require_admin)):
"""获取所有可用的架构"""
registry = get_registry()
return registry.to_dict_list()
@router.get("/architectures/{architecture_id}", response_model=ArchitectureInfo)
async def get_architecture(architecture_id: str, _: User = Depends(require_admin)):
"""获取指定架构的详情"""
registry = get_registry()
arch = registry.get(architecture_id)
if not arch:
raise HTTPException(status_code=404, detail=f"架构 {architecture_id} 不存在")
return arch.to_dict()
@router.get("/providers/{provider_id}/status", response_model=ProviderOpsStatusResponse)
async def get_provider_ops_status(
provider_id: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""获取 Provider 的操作状态"""
service = ProviderOpsService(db)
config = service.get_config(provider_id)
conn_state = service.get_connection_status(provider_id)
enabled_actions = []
if config:
for action_type, action_config in config.actions.items():
if action_config.get("enabled", True):
enabled_actions.append(action_type)
return ProviderOpsStatusResponse(
provider_id=provider_id,
is_configured=config is not None,
architecture_id=config.architecture_id if config else None,
connection_status=ConnectionStatusResponse(
status=conn_state.status.value,
auth_type=conn_state.auth_type.value,
connected_at=conn_state.connected_at.isoformat() if conn_state.connected_at else None,
expires_at=conn_state.expires_at.isoformat() if conn_state.expires_at else None,
last_error=conn_state.last_error,
),
enabled_actions=enabled_actions,
)
@router.get("/providers/{provider_id}/config", response_model=ProviderOpsConfigResponse)
async def get_provider_ops_config(
provider_id: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""
获取 Provider 的操作配置(脱敏)
返回已保存的配置,但敏感字段(如 api_key会被脱敏处理。
"""
service = ProviderOpsService(db)
config = service.get_config(provider_id)
if not config:
return ProviderOpsConfigResponse(
provider_id=provider_id,
is_configured=False,
)
# 获取 base_url
provider = db.query(Provider).filter(Provider.id == provider_id).first()
base_url = None
if provider:
provider_config = provider.config or {}
# base_url 可能存储在 provider_ops 配置中,也可能从 provider 获取
if provider.endpoints:
for endpoint in provider.endpoints:
if endpoint.base_url:
base_url = endpoint.base_url
break
if not base_url:
base_url = provider_config.get("base_url") or provider.website
# 获取脱敏后的凭据
masked_credentials = service.get_masked_credentials(config.connector_credentials)
return ProviderOpsConfigResponse(
provider_id=provider_id,
is_configured=True,
architecture_id=config.architecture_id,
base_url=base_url,
connector={
"auth_type": config.connector_auth_type.value,
"config": config.connector_config,
"credentials": masked_credentials,
},
)
@router.put("/providers/{provider_id}/config")
async def save_provider_ops_config(
provider_id: str,
request: SaveConfigRequest,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""保存 Provider 的操作配置"""
service = ProviderOpsService(db)
# 合并凭据:如果请求中的敏感字段为空,使用已保存的凭据
credentials = service.merge_credentials_with_saved(
provider_id, dict(request.connector.credentials)
)
# 构建配置对象
config = ProviderOpsConfig(
architecture_id=request.architecture_id,
connector_auth_type=ConnectorAuthType(request.connector.auth_type),
connector_config=request.connector.config,
connector_credentials=credentials,
actions={
action_type: {"enabled": action_config.enabled, "config": action_config.config}
for action_type, action_config in request.actions.items()
},
schedule=request.schedule,
)
success = service.save_config(provider_id, config)
if not success:
raise HTTPException(status_code=404, detail="Provider 不存在")
return {"success": True, "message": "配置保存成功"}
@router.post("/providers/{provider_id}/verify", response_model=VerifyAuthResponse)
async def verify_provider_auth(
provider_id: str,
request: SaveConfigRequest,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""
验证 Provider 认证配置
在保存前测试认证是否有效。
如果凭据中的敏感字段为空,会使用已保存的凭据。
"""
service = ProviderOpsService(db)
# 获取 base_url
base_url = request.base_url
if not base_url:
# 尝试从 Provider 获取
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if provider:
# 从 endpoints 或 config 获取
if provider.endpoints:
for endpoint in provider.endpoints:
if endpoint.base_url:
base_url = endpoint.base_url
break
if not base_url and provider.config:
base_url = provider.config.get("base_url")
if not base_url:
return VerifyAuthResponse(
success=False,
message="请提供 API 地址",
)
# 合并凭据:如果请求中的敏感字段为空,使用已保存的凭据
credentials = service.merge_credentials_with_saved(
provider_id, dict(request.connector.credentials)
)
result = await service.verify_auth(
base_url=base_url,
architecture_id=request.architecture_id,
auth_type=ConnectorAuthType(request.connector.auth_type),
config=request.connector.config,
credentials=credentials,
)
return VerifyAuthResponse(
success=result.get("success", False),
message=result.get("message"),
data=result.get("data"),
)
@router.delete("/providers/{provider_id}/config")
async def delete_provider_ops_config(
provider_id: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""删除 Provider 的操作配置"""
service = ProviderOpsService(db)
success = service.delete_config(provider_id)
if not success:
raise HTTPException(status_code=404, detail="Provider 不存在")
return {"success": True, "message": "配置已删除"}
@router.post("/providers/{provider_id}/connect")
async def connect_provider(
provider_id: str,
request: ConnectRequest,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""建立与 Provider 的连接"""
service = ProviderOpsService(db)
success, message = await service.connect(provider_id, request.credentials)
if not success:
raise HTTPException(status_code=400, detail=message)
return {"success": True, "message": message}
@router.post("/providers/{provider_id}/disconnect")
async def disconnect_provider(
provider_id: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""断开与 Provider 的连接"""
service = ProviderOpsService(db)
await service.disconnect(provider_id)
return {"success": True, "message": "已断开连接"}
@router.post(
"/providers/{provider_id}/actions/{action_type}",
response_model=ActionResultResponse,
)
async def execute_action(
provider_id: str,
action_type: str,
request: ExecuteActionRequest,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""执行指定操作"""
service = ProviderOpsService(db)
try:
action_type_enum = ProviderActionType(action_type)
except ValueError:
raise HTTPException(status_code=400, detail=f"无效的操作类型: {action_type}")
result = await service.execute_action(provider_id, action_type_enum, request.config)
return ActionResultResponse(
status=result.status.value,
action_type=result.action_type.value,
data=_serialize_data(result.data),
message=result.message,
executed_at=result.executed_at.isoformat(),
response_time_ms=result.response_time_ms,
cache_ttl_seconds=result.cache_ttl_seconds,
)
@router.get("/providers/{provider_id}/balance", response_model=ActionResultResponse)
async def get_balance(
provider_id: str,
refresh: bool = True,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""
获取余额(优先返回缓存,后台异步刷新)
- refresh=True默认返回缓存并触发后台刷新
- refresh=False仅返回缓存不触发刷新
"""
service = ProviderOpsService(db)
result = await service.query_balance_with_cache(provider_id, trigger_refresh=refresh)
return ActionResultResponse(
status=result.status.value,
action_type=result.action_type.value,
data=_serialize_data(result.data),
message=result.message,
executed_at=result.executed_at.isoformat(),
response_time_ms=result.response_time_ms,
cache_ttl_seconds=result.cache_ttl_seconds,
)
@router.post("/providers/{provider_id}/balance", response_model=ActionResultResponse)
async def refresh_balance(
provider_id: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""立即刷新余额(同步等待结果)"""
service = ProviderOpsService(db)
result = await service.query_balance(provider_id)
return ActionResultResponse(
status=result.status.value,
action_type=result.action_type.value,
data=_serialize_data(result.data),
message=result.message,
executed_at=result.executed_at.isoformat(),
response_time_ms=result.response_time_ms,
cache_ttl_seconds=result.cache_ttl_seconds,
)
@router.post("/providers/{provider_id}/checkin", response_model=ActionResultResponse)
async def checkin(
provider_id: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""签到(快捷方法)"""
service = ProviderOpsService(db)
result = await service.checkin(provider_id)
return ActionResultResponse(
status=result.status.value,
action_type=result.action_type.value,
data=_serialize_data(result.data),
message=result.message,
executed_at=result.executed_at.isoformat(),
response_time_ms=result.response_time_ms,
cache_ttl_seconds=result.cache_ttl_seconds,
)
@router.post("/batch/balance")
async def batch_query_balance(
provider_ids: Optional[List[str]] = None,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
):
"""批量查询余额"""
service = ProviderOpsService(db)
results = await service.batch_query_balance(provider_ids)
return {
provider_id: ActionResultResponse(
status=result.status.value,
action_type=result.action_type.value,
data=_serialize_data(result.data),
message=result.message,
executed_at=result.executed_at.isoformat(),
response_time_ms=result.response_time_ms,
cache_ttl_seconds=result.cache_ttl_seconds,
)
for provider_id, result in results.items()
}

View File

@@ -289,6 +289,9 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
for e in endpoints
]
# 检查是否配置了 Provider Ops余额监控等
ops_configured = bool((provider.config or {}).get("provider_ops"))
return ProviderWithEndpointsSummary(
id=provider.id,
name=provider.name,
@@ -314,6 +317,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
unhealthy_endpoints=unhealthy_endpoints,
api_formats=api_formats,
endpoint_health_details=endpoint_health_details,
ops_configured=ops_configured,
created_at=provider.created_at,
updated_at=provider.updated_at,
)

View File

@@ -639,6 +639,9 @@ class ProviderWithEndpointsSummary(BaseModel):
default=0, description="不健康的端点数量health_score < 0.5"
)
# Provider Ops 配置状态
ops_configured: bool = Field(default=False, description="是否配置了扩展操作(余额监控等)")
# 时间戳
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,39 @@
"""
Provider 操作模块
提供对提供商的扩展操作支持:
- 多种鉴权方式API Key、登录、Cookie
- 可扩展的操作类型(余额查询、签到等)
"""
from src.services.provider_ops.registry import ArchitectureRegistry, get_registry
from src.services.provider_ops.service import ProviderOpsService
from src.services.provider_ops.types import (
ActionResult,
ActionStatus,
BalanceInfo,
CheckinInfo,
ConnectorAuthType,
ConnectorState,
ConnectorStatus,
ProviderActionType,
ProviderOpsConfig,
)
__all__ = [
# 服务
"ProviderOpsService",
# 注册表
"ArchitectureRegistry",
"get_registry",
# 类型
"ActionResult",
"ActionStatus",
"BalanceInfo",
"CheckinInfo",
"ConnectorAuthType",
"ConnectorState",
"ConnectorStatus",
"ProviderActionType",
"ProviderOpsConfig",
]

View File

@@ -0,0 +1,13 @@
"""
Provider 操作模块
"""
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.actions.checkin import CheckinAction
__all__ = [
"ProviderAction",
"BalanceAction",
"CheckinAction",
]

View File

@@ -0,0 +1,231 @@
"""
余额查询操作
"""
import time
from typing import Any, Dict, Optional
import httpx
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.types import (
ActionResult,
ActionStatus,
BalanceInfo,
ProviderActionType,
)
class BalanceAction(ProviderAction):
"""
余额查询操作
支持可配置的 endpoint 和响应字段映射。
"""
action_type = ProviderActionType.QUERY_BALANCE
display_name = "查询余额"
description = "查询账户余额信息"
default_cache_ttl = 86400 # 24 小时
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
"""执行余额查询"""
endpoint = self.config.get("endpoint", "/api/user/balance")
method = self.config.get("method", "GET")
mapping = self.config.get("response_mapping", {})
start_time = time.time()
try:
response = await client.request(method, endpoint)
response_time_ms = int((time.time() - start_time) * 1000)
# 尝试解析 JSON
try:
data = response.json()
except Exception:
return self._make_error_result(
ActionStatus.PARSE_ERROR,
"响应不是有效的 JSON",
)
# 检查 HTTP 状态
if response.status_code != 200:
return self._handle_http_error(response, data)
# 检查业务状态码(如果配置了)
success_field = self.config.get("success_field")
if success_field:
is_success = self._extract_field(data, success_field)
if is_success is False or is_success == 0:
message = self._extract_field(data, self.config.get("message_field", "message"))
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
message or "业务状态码表示失败",
raw_response=data,
)
# 解析余额信息
balance = self._parse_balance(data, mapping)
return self._make_success_result(
data=balance,
response_time_ms=response_time_ms,
raw_response=data,
)
except httpx.TimeoutException:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
"请求超时",
retry_after_seconds=30,
)
except httpx.RequestError as e:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
f"网络错误: {str(e)}",
retry_after_seconds=30,
)
except Exception as e:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"未知错误: {str(e)}",
)
def _parse_balance(self, data: Any, mapping: Dict[str, str]) -> BalanceInfo:
"""解析余额信息"""
# 默认映射(常见字段名)
default_mappings = {
"total_granted": ["data.total_quota", "data.quota", "total_quota", "quota"],
"total_used": ["data.used_quota", "data.used", "used_quota", "used"],
"total_available": [
"data.balance",
"data.remaining",
"data.available",
"balance",
"remaining",
],
}
# 获取 quota 除数(用于将原始值转换为美元,如 New API 的 1/500000
quota_divisor = self.config.get("quota_divisor", 1)
def get_value(field: str, default_paths: list) -> Optional[float]:
# 优先使用用户配置的映射
if field in mapping:
value = self._extract_field(data, mapping[field])
if value is not None:
raw = self._to_float(value)
return raw / quota_divisor if raw is not None else None
# 尝试默认映射
for path in default_paths:
value = self._extract_field(data, path)
if value is not None:
raw = self._to_float(value)
return raw / quota_divisor if raw is not None else None
return None
total_granted = get_value("total_granted", default_mappings["total_granted"])
total_used = get_value("total_used", default_mappings["total_used"])
total_available = get_value("total_available", default_mappings["total_available"])
# 如果只有部分数据,尝试计算
if total_available is None and total_granted is not None and total_used is not None:
total_available = total_granted - total_used
if total_used is None and total_granted is not None and total_available is not None:
total_used = total_granted - total_available
if total_granted is None and total_used is not None and total_available is not None:
total_granted = total_used + total_available
# 提取额外字段
extra = {}
for key, path in mapping.items():
if key not in ["total_granted", "total_used", "total_available", "expires_at"]:
value = self._extract_field(data, path)
if value is not None:
extra[key] = value
return BalanceInfo(
total_granted=total_granted,
total_used=total_used,
total_available=total_available,
currency=self.config.get("currency", "USD"),
extra=extra,
)
def _to_float(self, value: Any) -> Optional[float]:
"""转换为浮点数"""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取操作配置 schema"""
return {
"type": "object",
"properties": {
"endpoint": {
"type": "string",
"title": "API 路径",
"description": "余额查询 API 路径",
"default": "/api/user/balance",
},
"method": {
"type": "string",
"title": "请求方法",
"enum": ["GET", "POST"],
"default": "GET",
},
"quota_divisor": {
"type": "number",
"title": "额度除数",
"description": "将原始额度值转换为美元的除数(如 New API 为 500000",
"default": 1,
},
"success_field": {
"type": "string",
"title": "成功状态字段",
"description": "响应中表示成功的字段路径(如 success, code",
},
"message_field": {
"type": "string",
"title": "消息字段",
"description": "响应中的消息字段路径",
"default": "message",
},
"response_mapping": {
"type": "object",
"title": "响应字段映射",
"description": "响应字段到余额字段的映射",
"properties": {
"total_granted": {
"type": "string",
"title": "总额度字段",
"description": "响应中总额度的字段路径",
},
"total_used": {
"type": "string",
"title": "已用额度字段",
"description": "响应中已用额度的字段路径",
},
"total_available": {
"type": "string",
"title": "可用余额字段",
"description": "响应中可用余额的字段路径",
},
},
},
"currency": {
"type": "string",
"title": "货币单位",
"default": "USD",
},
},
"required": ["endpoint"],
}

View File

@@ -0,0 +1,157 @@
"""
Provider 操作抽象基类
"""
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Any, Dict, Optional
import httpx
from src.services.provider_ops.types import (
ActionResult,
ActionStatus,
ProviderActionType,
)
class ProviderAction(ABC):
"""
提供商操作基类
定义具体的操作逻辑(如查询余额、签到等)。
"""
# 子类需要定义的类属性
action_type: ProviderActionType = ProviderActionType.CUSTOM
display_name: str = "Base Action"
description: str = ""
# 默认缓存时间(秒)
default_cache_ttl: int = 300
def __init__(self, config: Optional[Dict[str, Any]] = None):
"""
初始化操作
Args:
config: 操作配置
"""
self.config = config or {}
@abstractmethod
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
"""
执行操作
Args:
client: 已认证的 HTTP 客户端
Returns:
操作结果
"""
pass
def _extract_field(self, data: Any, path: Optional[str]) -> Any:
"""
从响应数据中提取字段
支持点号分隔的路径,如 "data.user.balance"
Args:
data: 响应数据
path: 字段路径
Returns:
提取的值,如果路径无效则返回 None
"""
if not path:
return None
current = data
for key in path.split("."):
if isinstance(current, dict):
current = current.get(key)
elif isinstance(current, list) and key.isdigit():
index = int(key)
current = current[index] if 0 <= index < len(current) else None
else:
return None
if current is None:
return None
return current
def _make_success_result(
self,
data: Any = None,
message: Optional[str] = None,
response_time_ms: Optional[int] = None,
raw_response: Optional[Dict[str, Any]] = None,
) -> ActionResult:
"""创建成功结果"""
return ActionResult(
status=ActionStatus.SUCCESS,
action_type=self.action_type,
data=data,
message=message,
response_time_ms=response_time_ms,
raw_response=raw_response,
cache_ttl_seconds=self.default_cache_ttl,
)
def _make_error_result(
self,
status: ActionStatus,
message: Optional[str] = None,
retry_after_seconds: Optional[int] = None,
raw_response: Optional[Dict[str, Any]] = None,
) -> ActionResult:
"""创建错误结果"""
return ActionResult(
status=status,
action_type=self.action_type,
message=message,
retry_after_seconds=retry_after_seconds,
raw_response=raw_response,
cache_ttl_seconds=0, # 错误不缓存
)
def _handle_http_error(
self, response: httpx.Response, raw_data: Optional[Dict[str, Any]] = None
) -> ActionResult:
"""处理 HTTP 错误响应"""
status_code = response.status_code
if status_code == 401:
return self._make_error_result(
ActionStatus.AUTH_FAILED, "认证失败", raw_response=raw_data
)
elif status_code == 403:
return self._make_error_result(
ActionStatus.AUTH_FAILED, "无权限访问", raw_response=raw_data
)
elif status_code == 429:
retry_after = response.headers.get("Retry-After")
return self._make_error_result(
ActionStatus.RATE_LIMITED,
"请求频率限制",
retry_after_seconds=int(retry_after) if retry_after else 60,
raw_response=raw_data,
)
else:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"HTTP {status_code}: {response.reason_phrase}",
raw_response=raw_data,
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""
获取操作配置 JSON Schema用于前端表单生成
子类应重写此方法
"""
return {"type": "object", "properties": {}, "required": []}

View File

@@ -0,0 +1,236 @@
"""
签到操作
"""
import time
from typing import Any, Dict
import httpx
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.types import (
ActionResult,
ActionStatus,
CheckinInfo,
ProviderActionType,
)
class CheckinAction(ProviderAction):
"""
签到操作
支持可配置的 endpoint 和响应字段映射。
"""
action_type = ProviderActionType.CHECKIN
display_name = "签到"
description = "每日签到领取额度"
default_cache_ttl = 3600 # 签到结果缓存 1 小时
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
"""执行签到"""
endpoint = self.config.get("endpoint", "/api/user/checkin")
method = self.config.get("method", "POST")
start_time = time.time()
try:
# 构建请求
request_body = self.config.get("request_body", {})
if method == "POST":
response = await client.post(endpoint, json=request_body or None)
else:
response = await client.request(method, endpoint)
response_time_ms = int((time.time() - start_time) * 1000)
# 尝试解析 JSON
try:
data = response.json()
except Exception:
return self._make_error_result(
ActionStatus.PARSE_ERROR,
"响应不是有效的 JSON",
)
# 检查 HTTP 状态
if response.status_code != 200:
return self._handle_http_error(response, data)
# 解析签到结果
checkin_info, status, message = self._parse_checkin_result(data)
if status == ActionStatus.SUCCESS:
return self._make_success_result(
data=checkin_info,
message=message,
response_time_ms=response_time_ms,
raw_response=data,
)
else:
return self._make_error_result(
status,
message,
raw_response=data,
)
except httpx.TimeoutException:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
"请求超时",
retry_after_seconds=30,
)
except httpx.RequestError as e:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
f"网络错误: {str(e)}",
retry_after_seconds=30,
)
except Exception as e:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"未知错误: {str(e)}",
)
def _parse_checkin_result(
self, data: Any
) -> tuple[CheckinInfo, ActionStatus, str | None]:
"""
解析签到结果
Returns:
(CheckinInfo, 状态, 消息)
"""
mapping = self.config.get("response_mapping", {})
# 检查成功状态
success_field = self.config.get("success_field", "success")
is_success = self._extract_field(data, success_field)
# 获取消息
message_field = self.config.get("message_field", "message")
message = self._extract_field(data, message_field)
if message is not None:
message = str(message)
# 检查是否已签到
already_checked_indicators = self.config.get(
"already_checked_indicators", ["already", "已签到", "今日已签", "重复签到"]
)
if message:
for indicator in already_checked_indicators:
if indicator.lower() in message.lower():
return (
CheckinInfo(message=message),
ActionStatus.ALREADY_DONE,
message,
)
# 判断是否成功
if is_success is False or is_success == 0:
return (
CheckinInfo(message=message),
ActionStatus.UNKNOWN_ERROR,
message or "签到失败",
)
# 解析签到信息
reward = None
reward_field = mapping.get("reward") or self.config.get("reward_field")
if reward_field:
reward_value = self._extract_field(data, reward_field)
if reward_value is not None:
try:
reward = float(reward_value)
except (TypeError, ValueError):
pass
streak_days = None
streak_field = mapping.get("streak_days") or self.config.get("streak_field")
if streak_field:
streak_value = self._extract_field(data, streak_field)
if streak_value is not None:
try:
streak_days = int(streak_value)
except (TypeError, ValueError):
pass
# 提取额外字段
extra = {}
for key, path in mapping.items():
if key not in ["reward", "streak_days", "message"]:
value = self._extract_field(data, path)
if value is not None:
extra[key] = value
checkin_info = CheckinInfo(
reward=reward,
streak_days=streak_days,
message=message,
extra=extra,
)
return (checkin_info, ActionStatus.SUCCESS, message or "签到成功")
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取操作配置 schema"""
return {
"type": "object",
"properties": {
"endpoint": {
"type": "string",
"title": "API 路径",
"description": "签到 API 路径",
"default": "/api/user/checkin",
},
"method": {
"type": "string",
"title": "请求方法",
"enum": ["GET", "POST"],
"default": "POST",
},
"request_body": {
"type": "object",
"title": "请求体",
"description": "签到请求的 JSON 体(可选)",
},
"success_field": {
"type": "string",
"title": "成功状态字段",
"description": "响应中表示成功的字段路径",
"default": "success",
},
"message_field": {
"type": "string",
"title": "消息字段",
"description": "响应中的消息字段路径",
"default": "message",
},
"reward_field": {
"type": "string",
"title": "奖励字段",
"description": "响应中奖励额度的字段路径",
},
"streak_field": {
"type": "string",
"title": "连续签到天数字段",
"description": "响应中连续签到天数的字段路径",
},
"already_checked_indicators": {
"type": "array",
"title": "已签到标识",
"description": "消息中表示已签到的关键词",
"items": {"type": "string"},
"default": ["already", "已签到", "今日已签", "重复签到"],
},
"response_mapping": {
"type": "object",
"title": "响应字段映射",
"description": "响应字段到签到信息的映射",
},
},
"required": ["endpoint"],
}

View File

@@ -0,0 +1,21 @@
"""
Provider 架构模块
"""
from src.services.provider_ops.architectures.base import (
ProviderArchitecture,
ProviderConnector,
VerifyResult,
)
from src.services.provider_ops.architectures.generic_api import GenericApiArchitecture
from src.services.provider_ops.architectures.new_api import NewApiArchitecture
from src.services.provider_ops.architectures.one_api import OneApiArchitecture
__all__ = [
"ProviderArchitecture",
"ProviderConnector",
"VerifyResult",
"GenericApiArchitecture",
"NewApiArchitecture",
"OneApiArchitecture",
]

View File

@@ -0,0 +1,529 @@
"""
Provider 架构抽象基类
"""
from abc import ABC, abstractmethod
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, AsyncIterator, Dict, List, Optional, Type
import httpx
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.types import (
ConnectorAuthType,
ConnectorState,
ConnectorStatus,
ProviderActionType,
)
# ==================== 连接器基类 ====================
class ProviderConnector(ABC):
"""
提供商连接器基类
负责建立与提供商的认证连接,管理凭据状态。
每个架构应在自己的文件中实现对应的连接器子类。
"""
# 子类需要定义的类属性
auth_type: ConnectorAuthType = ConnectorAuthType.NONE
display_name: str = "Base Connector"
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
"""
初始化连接器
Args:
base_url: 提供商 API 基础 URL
config: 连接器配置
"""
self.base_url = base_url.rstrip("/")
self.config = config or {}
self._status = ConnectorStatus.DISCONNECTED
self._connected_at: Optional[datetime] = None
self._expires_at: Optional[datetime] = None
self._last_error: Optional[str] = None
# 代理配置
self._proxy: Optional[str] = self.config.get("proxy")
# HTTP 客户端配置
self._timeout = self.config.get("timeout", 30)
self._headers: Dict[str, str] = {}
@abstractmethod
async def connect(self, credentials: Dict[str, Any]) -> bool:
"""
建立认证连接
Args:
credentials: 凭据信息如用户名密码、API Key 等)
Returns:
是否连接成功
"""
pass
@abstractmethod
async def disconnect(self) -> None:
"""断开连接,清理状态"""
pass
@abstractmethod
async def is_authenticated(self) -> bool:
"""检查当前是否已认证"""
pass
@abstractmethod
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""
为请求应用认证信息
Args:
request: 原始请求
Returns:
添加认证信息后的请求
"""
pass
async def refresh_auth(self, credentials: Dict[str, Any]) -> bool:
"""
刷新认证(如 Token 过期)
默认实现:重新连接
Args:
credentials: 凭据信息
Returns:
是否刷新成功
"""
return await self.connect(credentials)
@asynccontextmanager
async def get_client(self) -> AsyncIterator[httpx.AsyncClient]:
"""
获取已认证的 HTTP 客户端
使用 context manager 确保资源正确释放
Yields:
已配置认证信息的 AsyncClient
"""
transport = None
if self._proxy:
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
async with httpx.AsyncClient(
base_url=self.base_url,
timeout=self._timeout,
transport=transport,
event_hooks={"request": [self._auth_hook]},
) as client:
yield client
async def _auth_hook(self, request: httpx.Request) -> None:
"""请求钩子:应用认证信息"""
self._apply_auth(request)
def get_state(self) -> ConnectorState:
"""获取连接器当前状态"""
return ConnectorState(
status=self._status,
auth_type=self.auth_type,
connected_at=self._connected_at,
expires_at=self._expires_at,
last_error=self._last_error,
)
def _set_connected(self, expires_at: Optional[datetime] = None) -> None:
"""设置为已连接状态"""
self._status = ConnectorStatus.CONNECTED
self._connected_at = datetime.now(timezone.utc)
self._expires_at = expires_at
self._last_error = None
def _set_error(self, error: str) -> None:
"""设置错误状态"""
self._status = ConnectorStatus.ERROR
self._last_error = error
def _set_disconnected(self) -> None:
"""设置为断开状态"""
self._status = ConnectorStatus.DISCONNECTED
self._connected_at = None
self._expires_at = None
@classmethod
def get_credentials_schema(cls) -> Dict[str, Any]:
"""
获取凭据配置 JSON Schema用于前端表单生成
子类应重写此方法
"""
return {"type": "object", "properties": {}, "required": []}
# ==================== 验证结果 ====================
@dataclass
class VerifyResult:
"""认证验证结果"""
success: bool
message: Optional[str] = None
username: Optional[str] = None
display_name: Optional[str] = None
email: Optional[str] = None
quota: Optional[float] = None
used_quota: Optional[float] = None
request_count: Optional[int] = None
extra: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
if not self.success:
return {"success": False, "message": self.message}
return {
"success": True,
"data": {
"username": self.username,
"display_name": self.display_name or self.username,
"email": self.email,
"quota": self.quota,
"used_quota": self.used_quota,
"request_count": self.request_count,
"extra": self.extra or {},
},
}
# ==================== 架构基类 ====================
class ProviderArchitecture(ABC):
"""
提供商架构基类
架构 = Connector鉴权方式 + Actions支持的操作
一个架构可以被多个 Provider 复用。
例如generic_api 架构可用于各种中转站。
## 添加新认证模板的步骤
1. 在 architectures/ 目录创建新文件
2. 继承 ProviderArchitecture 和 ProviderConnector
3. 定义类属性architecture_id, display_name, description
4. 实现连接器子类和架构类
5. 重写认证相关方法:
- get_verify_endpoint(): 返回验证端点
- build_verify_headers(): 构建验证请求 headers
- parse_verify_response(): 解析验证响应
6. 在 registry.py 的 _register_builtin_architectures() 中注册
"""
# 子类需要定义的类属性
architecture_id: str = ""
display_name: str = ""
description: str = ""
# 支持的 Connector 类型列表(按优先级排序)
supported_connectors: List[Type[ProviderConnector]] = []
# 支持的 Action 类型列表
supported_actions: List[Type[ProviderAction]] = []
# 默认操作配置
default_action_configs: Dict[ProviderActionType, Dict[str, Any]] = {}
def __init__(self, config: Optional[Dict[str, Any]] = None):
"""
初始化架构
Args:
config: 架构配置
"""
self.config = config or {}
# ==================== 认证验证相关方法 ====================
def get_credentials_schema(self) -> Dict[str, Any]:
"""
获取凭据字段定义JSON Schema 格式)
子类应重写此方法定义需要的凭据字段。
这个 schema 可用于:
1. 前端表单生成(如果需要动态渲染)
2. 凭据验证
3. 文档生成
Returns:
JSON Schema 格式的字段定义
Example:
{
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "访问令牌",
},
"user_id": {
"type": "string",
"title": "用户 ID",
"description": "New API 用户 ID",
},
},
"required": ["api_key", "user_id"],
}
"""
return {
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "访问令牌",
},
},
"required": ["api_key"],
}
def get_verify_endpoint(self) -> str:
"""
获取认证验证端点
子类可重写以自定义验证端点。
Returns:
验证端点路径(如 /api/user/self
"""
return "/api/user/self"
def build_verify_headers(
self,
config: Dict[str, Any],
credentials: Dict[str, Any],
) -> Dict[str, str]:
"""
构建认证验证请求的 Headers
子类可重写以添加特定的 Headers。
Args:
config: 连接器配置
credentials: 凭据信息
Returns:
Headers 字典
"""
headers: Dict[str, str] = {}
# 处理 API Key 认证
api_key = credentials.get("api_key", "")
if api_key:
auth_method = config.get("auth_method", "bearer")
if auth_method == "bearer":
headers["Authorization"] = f"Bearer {api_key}"
elif auth_method == "header":
header_name = config.get("header_name", "X-API-Key")
headers[header_name] = api_key
return headers
def parse_verify_response(
self,
status_code: int,
data: Dict[str, Any],
) -> VerifyResult:
"""
解析认证验证响应
子类可重写以处理特定的响应格式。
Args:
status_code: HTTP 状态码
data: 响应 JSON 数据
Returns:
验证结果
"""
if status_code == 401:
return VerifyResult(success=False, message="认证失败:无效的凭据")
if status_code == 403:
return VerifyResult(success=False, message="认证失败:权限不足")
if status_code != 200:
return VerifyResult(success=False, message=f"验证失败HTTP {status_code}")
# 尝试解析通用响应格式
# 格式1: {"success": true, "data": {...}}
# 格式2: 直接返回用户数据 {...}
if data.get("success") is True and "data" in data:
user_data = data["data"]
elif data.get("success") is False:
message = data.get("message", "验证失败")
return VerifyResult(success=False, message=message)
else:
user_data = data
return VerifyResult(
success=True,
username=user_data.get("username"),
display_name=user_data.get("display_name") or user_data.get("username"),
email=user_data.get("email"),
quota=user_data.get("quota"),
used_quota=user_data.get("used_quota"),
request_count=user_data.get("request_count"),
extra={
k: v
for k, v in user_data.items()
if k
not in (
"username",
"display_name",
"email",
"quota",
"used_quota",
"request_count",
)
},
)
# ==================== 连接器和操作相关方法 ====================
def get_connector(
self,
base_url: str,
auth_type: Optional[ConnectorAuthType] = None,
config: Optional[Dict[str, Any]] = None,
) -> ProviderConnector:
"""
获取连接器实例
Args:
base_url: 提供商 API 基础 URL
auth_type: 指定的认证类型None 则使用默认
config: 连接器配置
Returns:
连接器实例
Raises:
ValueError: 不支持的认证类型
"""
if not self.supported_connectors:
raise ValueError(f"架构 {self.architecture_id} 未配置支持的连接器")
# 查找匹配的连接器
connector_cls: Optional[Type[ProviderConnector]] = None
if auth_type:
for cls in self.supported_connectors:
if cls.auth_type == auth_type:
connector_cls = cls
break
if not connector_cls:
supported = [c.auth_type.value for c in self.supported_connectors]
raise ValueError(
f"架构 {self.architecture_id} 不支持 {auth_type.value} 认证,"
f"支持的类型: {supported}"
)
else:
# 使用第一个(默认)连接器
connector_cls = self.supported_connectors[0]
return connector_cls(base_url, config)
def get_action(
self,
action_type: ProviderActionType,
config: Optional[Dict[str, Any]] = None,
) -> ProviderAction:
"""
获取操作实例
Args:
action_type: 操作类型
config: 操作配置(会与默认配置合并)
Returns:
操作实例
Raises:
ValueError: 不支持的操作类型
"""
action_cls: Optional[Type[ProviderAction]] = None
for cls in self.supported_actions:
if cls.action_type == action_type:
action_cls = cls
break
if not action_cls:
supported = [a.action_type.value for a in self.supported_actions]
raise ValueError(
f"架构 {self.architecture_id} 不支持 {action_type.value} 操作,"
f"支持的操作: {supported}"
)
# 合并默认配置和用户配置
merged_config = dict(self.default_action_configs.get(action_type, {}))
if config:
merged_config.update(config)
return action_cls(merged_config)
def supports_action(self, action_type: ProviderActionType) -> bool:
"""检查是否支持指定操作"""
return any(a.action_type == action_type for a in self.supported_actions)
def supports_auth_type(self, auth_type: ConnectorAuthType) -> bool:
"""检查是否支持指定认证类型"""
return any(c.auth_type == auth_type for c in self.supported_connectors)
def get_supported_auth_types(self) -> List[ConnectorAuthType]:
"""获取支持的认证类型列表"""
return [c.auth_type for c in self.supported_connectors]
def get_supported_action_types(self) -> List[ProviderActionType]:
"""获取支持的操作类型列表"""
return [a.action_type for a in self.supported_actions]
def to_dict(self) -> Dict[str, Any]:
"""转换为字典(用于 API 响应)"""
return {
"architecture_id": self.architecture_id,
"display_name": self.display_name,
"description": self.description,
"credentials_schema": self.get_credentials_schema(),
"verify_endpoint": self.get_verify_endpoint(),
"supported_auth_types": [
{"type": c.auth_type.value, "display_name": c.display_name}
for c in self.supported_connectors
],
"supported_actions": [
{
"type": a.action_type.value,
"display_name": a.display_name,
"description": a.description,
"config_schema": a.get_config_schema(),
}
for a in self.supported_actions
],
"default_connector": (
self.supported_connectors[0].auth_type.value
if self.supported_connectors
else None
),
}

View File

@@ -0,0 +1,155 @@
"""
通用 API 架构
支持各种中转站的可配置架构。
## 添加新认证模板示例
如需添加新的中转站模板(如 MyApi参考以下步骤
1. 在 architectures/ 目录创建新文件,如 my_api.py
from src.services.provider_ops.architectures.base import ProviderArchitecture
from src.services.provider_ops.connectors.base import ProviderConnector
class MyApiConnector(ProviderConnector):
# 实现自己的连接器
pass
class MyApiArchitecture(ProviderArchitecture):
architecture_id = "my_api"
display_name = "My API"
description = "My API 风格中转站"
supported_connectors = [MyApiConnector]
supported_actions = [BalanceAction]
# 如果需要特殊的认证 headers重写此方法
def build_verify_headers(self, config, credentials):
headers = super().build_verify_headers(config, credentials)
if "custom_field" in credentials:
headers["X-Custom-Header"] = credentials["custom_field"]
return headers
2. 在 registry.py 的 _register_builtin_architectures() 中注册:
from .my_api import MyApiArchitecture
builtin = [..., MyApiArchitecture]
3. 在前端 auth-templates/ 添加对应的模板定义
"""
from typing import Any, Dict, List, Optional, Type
import httpx
from src.services.provider_ops.actions import BalanceAction, CheckinAction, ProviderAction
from src.services.provider_ops.architectures.base import ProviderArchitecture, ProviderConnector
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
class GenericApiKeyConnector(ProviderConnector):
"""
通用 API Key 连接器
支持多种 API Key 传递方式:
- Bearer Token (Authorization: Bearer xxx)
- Custom Header (X-API-Key: xxx)
"""
auth_type = ConnectorAuthType.API_KEY
display_name = "API Key"
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
super().__init__(base_url, config)
self._api_key: Optional[str] = None
# 支持配置认证方式
self._auth_method = self.config.get("auth_method", "bearer")
self._header_name = self.config.get("header_name", "Authorization")
async def connect(self, credentials: Dict[str, Any]) -> bool:
"""建立连接"""
api_key = credentials.get("api_key")
if not api_key:
self._set_error("API Key 不能为空")
return False
self._api_key = api_key
self._set_connected()
return True
async def disconnect(self) -> None:
"""断开连接"""
self._api_key = None
self._set_disconnected()
async def is_authenticated(self) -> bool:
"""检查是否已认证"""
return self._api_key is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""为请求应用认证信息"""
if not self._api_key:
return request
if self._auth_method == "bearer":
request.headers["Authorization"] = f"Bearer {self._api_key}"
elif self._auth_method == "header":
request.headers[self._header_name] = self._api_key
return request
@classmethod
def get_credentials_schema(cls) -> Dict[str, Any]:
"""获取凭据配置 schema"""
return {
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "提供商的 API Key",
},
},
"required": ["api_key"],
}
class GenericApiArchitecture(ProviderArchitecture):
"""
通用 API 架构
适用于各种中转站,支持所有认证方式和操作类型。
用户可以完全自定义 endpoint 和响应映射。
这是"自定义"模板对应的后端架构。
"""
architecture_id = "generic_api"
display_name = "通用 API"
description = "可配置的通用 API 架构,适用于各种中转站"
supported_connectors: List[Type[ProviderConnector]] = [
GenericApiKeyConnector,
]
supported_actions: List[Type[ProviderAction]] = [
BalanceAction,
CheckinAction,
]
# 默认操作配置(可被用户配置覆盖)
default_action_configs: Dict[ProviderActionType, Dict[str, Any]] = {
ProviderActionType.QUERY_BALANCE: {
"endpoint": "/api/user/balance",
"method": "GET",
},
ProviderActionType.CHECKIN: {
"endpoint": "/api/user/checkin",
"method": "POST",
},
}
def get_credentials_schema(self) -> Dict[str, Any]:
"""通用架构只需要 api_key"""
return GenericApiKeyConnector.get_credentials_schema()

View File

@@ -0,0 +1,155 @@
"""
New API 架构
针对 New API 风格的中转站优化的预设配置。
"""
from typing import Any, Dict, List, Optional, Type
import httpx
from src.services.provider_ops.actions import BalanceAction, CheckinAction, ProviderAction
from src.services.provider_ops.architectures.base import ProviderArchitecture, ProviderConnector
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
class NewApiConnector(ProviderConnector):
"""
New API 专用连接器
特点:
- 使用 Bearer Token 认证
- 需要 New-Api-User Header 传递用户 ID
"""
auth_type = ConnectorAuthType.API_KEY
display_name = "New API Key"
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
super().__init__(base_url, config)
self._api_key: Optional[str] = None
self._user_id: Optional[str] = None
async def connect(self, credentials: Dict[str, Any]) -> bool:
"""建立连接"""
api_key = credentials.get("api_key")
if not api_key:
self._set_error("API Key 不能为空")
return False
user_id = credentials.get("user_id")
if not user_id:
self._set_error("用户 ID 不能为空")
return False
self._api_key = api_key
self._user_id = str(user_id)
self._set_connected()
return True
async def disconnect(self) -> None:
"""断开连接"""
self._api_key = None
self._user_id = None
self._set_disconnected()
async def is_authenticated(self) -> bool:
"""检查是否已认证"""
return self._api_key is not None and self._user_id is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""为请求应用认证信息"""
if self._api_key:
request.headers["Authorization"] = f"Bearer {self._api_key}"
if self._user_id:
request.headers["New-Api-User"] = self._user_id
return request
@classmethod
def get_credentials_schema(cls) -> Dict[str, Any]:
"""获取凭据配置 schema"""
return {
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "访问令牌 (API Key)",
"description": "New API 的访问令牌",
},
"user_id": {
"type": "string",
"title": "用户 ID",
"description": "New API 用户 ID用于 New-Api-User Header",
},
},
"required": ["api_key", "user_id"],
}
class NewApiArchitecture(ProviderArchitecture):
"""
New API 架构预设
针对 New API 风格的中转站优化的预设配置。
特点:
- 使用 Bearer Token 认证
- 需要 New-Api-User Header 传递用户 ID
- 验证端点: /api/user/self
- quota 单位通常是 1/500000 美元
"""
architecture_id = "new_api"
display_name = "New API"
description = "New API 风格中转站的预设配置"
supported_connectors: List[Type[ProviderConnector]] = [
NewApiConnector,
]
supported_actions: List[Type[ProviderAction]] = [
BalanceAction,
CheckinAction,
]
default_action_configs: Dict[ProviderActionType, Dict[str, Any]] = {
ProviderActionType.QUERY_BALANCE: {
"endpoint": "/api/user/self",
"method": "GET",
"quota_divisor": 500000, # New API 的 quota 单位是 1/500000 美元
"response_mapping": {
"total_granted": "data.quota",
"total_used": "data.used_quota",
"total_available": "data.quota", # New API 通常只返回剩余额度
},
},
ProviderActionType.CHECKIN: {
"endpoint": "/api/user/checkin",
"method": "POST",
"success_field": "success",
"message_field": "message",
},
}
def get_credentials_schema(self) -> Dict[str, Any]:
"""New API 需要 api_key 和 user_id"""
return NewApiConnector.get_credentials_schema()
def build_verify_headers(
self,
config: Dict[str, Any],
credentials: Dict[str, Any],
) -> Dict[str, str]:
"""
构建 New API 的验证请求 Headers
New API 特有:需要 New-Api-User Header 传递用户 ID
"""
headers = super().build_verify_headers(config, credentials)
# New API 特有的 header
user_id = credentials.get("user_id", "")
if user_id:
headers["New-Api-User"] = str(user_id)
return headers

View File

@@ -0,0 +1,111 @@
"""
One API 架构
针对 One API 风格的中转站优化的预设配置。
"""
from typing import Any, Dict, List, Optional, Type
import httpx
from src.services.provider_ops.actions import BalanceAction, ProviderAction
from src.services.provider_ops.architectures.base import ProviderArchitecture, ProviderConnector
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
class OneApiConnector(ProviderConnector):
"""
One API 专用连接器
特点:
- 使用 Bearer Token 认证
- 不需要额外的 Header
"""
auth_type = ConnectorAuthType.API_KEY
display_name = "One API Key"
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
super().__init__(base_url, config)
self._api_key: Optional[str] = None
async def connect(self, credentials: Dict[str, Any]) -> bool:
"""建立连接"""
api_key = credentials.get("api_key")
if not api_key:
self._set_error("API Key 不能为空")
return False
self._api_key = api_key
self._set_connected()
return True
async def disconnect(self) -> None:
"""断开连接"""
self._api_key = None
self._set_disconnected()
async def is_authenticated(self) -> bool:
"""检查是否已认证"""
return self._api_key is not None
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""为请求应用认证信息"""
if self._api_key:
request.headers["Authorization"] = f"Bearer {self._api_key}"
return request
@classmethod
def get_credentials_schema(cls) -> Dict[str, Any]:
"""获取凭据配置 schema"""
return {
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "访问令牌 (API Key)",
"description": "One API 的访问令牌",
},
},
"required": ["api_key"],
}
class OneApiArchitecture(ProviderArchitecture):
"""
One API 架构预设
针对 One API 风格的中转站优化的预设配置。
特点:
- 使用 Bearer Token 认证
- 验证端点: /api/user/self
- 不需要额外的 Header
"""
architecture_id = "one_api"
display_name = "One API"
description = "One API 风格中转站的预设配置"
supported_connectors: List[Type[ProviderConnector]] = [
OneApiConnector,
]
supported_actions: List[Type[ProviderAction]] = [
BalanceAction,
]
default_action_configs: Dict[ProviderActionType, Dict[str, Any]] = {
ProviderActionType.QUERY_BALANCE: {
"endpoint": "/api/user/self",
"method": "GET",
"response_mapping": {
"total_granted": "data.quota",
"total_used": "data.used_quota",
},
},
}
def get_credentials_schema(self) -> Dict[str, Any]:
"""One API 只需要 api_key"""
return OneApiConnector.get_credentials_schema()

View File

@@ -0,0 +1,136 @@
"""
架构注册表
管理所有可用的 Provider 架构。
"""
import threading
from typing import Dict, List, Optional, Type
from src.core.logger import logger
from src.services.provider_ops.architectures import (
GenericApiArchitecture,
NewApiArchitecture,
OneApiArchitecture,
ProviderArchitecture,
)
class ArchitectureRegistry:
"""
架构注册表
单例模式,管理所有可用的 Provider 架构。
"""
_instance: Optional["ArchitectureRegistry"] = None
_lock = threading.Lock()
def __new__(cls) -> "ArchitectureRegistry":
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self) -> None:
if self._initialized:
return
self._architectures: Dict[str, ProviderArchitecture] = {}
self._initialized = True
# 注册内置架构
self._register_builtin_architectures()
def _register_builtin_architectures(self) -> None:
"""注册内置架构"""
builtin = [
GenericApiArchitecture,
NewApiArchitecture,
OneApiArchitecture,
]
for arch_cls in builtin:
self.register(arch_cls())
def register(self, architecture: ProviderArchitecture) -> None:
"""
注册架构
Args:
architecture: 架构实例
"""
if architecture.architecture_id in self._architectures:
logger.warning(f"架构 {architecture.architecture_id} 已存在,将被覆盖")
self._architectures[architecture.architecture_id] = architecture
logger.debug(f"注册架构: {architecture.architecture_id}")
def unregister(self, architecture_id: str) -> bool:
"""
注销架构
Args:
architecture_id: 架构 ID
Returns:
是否成功注销
"""
if architecture_id in self._architectures:
del self._architectures[architecture_id]
return True
return False
def get(self, architecture_id: str) -> Optional[ProviderArchitecture]:
"""
获取架构
Args:
architecture_id: 架构 ID
Returns:
架构实例,不存在则返回 None
"""
return self._architectures.get(architecture_id)
def get_or_default(self, architecture_id: Optional[str] = None) -> ProviderArchitecture:
"""
获取架构,如果不存在则返回默认架构
Args:
architecture_id: 架构 ID
Returns:
架构实例
"""
if architecture_id and architecture_id in self._architectures:
return self._architectures[architecture_id]
# 返回默认架构generic_api
return self._architectures.get("generic_api", GenericApiArchitecture())
def list_all(self) -> List[ProviderArchitecture]:
"""获取所有已注册的架构"""
return list(self._architectures.values())
def list_ids(self) -> List[str]:
"""获取所有已注册的架构 ID"""
return list(self._architectures.keys())
def to_dict_list(self) -> List[Dict]:
"""获取所有架构的字典表示(用于 API 响应)"""
return [arch.to_dict() for arch in self._architectures.values()]
# 全局注册表实例
_registry: Optional[ArchitectureRegistry] = None
def get_registry() -> ArchitectureRegistry:
"""获取全局注册表实例"""
global _registry
if _registry is None:
_registry = ArchitectureRegistry()
return _registry

View File

@@ -0,0 +1,689 @@
"""
Provider 操作服务
提供操作执行、凭据管理、缓存等业务逻辑。
"""
import asyncio
import json
from dataclasses import asdict
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from src.core.cache_service import CacheService
from src.core.crypto import CryptoService
from src.core.logger import logger
from src.database import create_session
from src.models.database import Provider
from src.services.provider_ops.architectures import ProviderArchitecture, ProviderConnector
from src.services.provider_ops.registry import get_registry
from src.services.provider_ops.types import (
ActionResult,
ActionStatus,
BalanceInfo,
ConnectorAuthType,
ConnectorState,
ConnectorStatus,
ProviderActionType,
ProviderOpsConfig,
)
# 余额缓存 TTL24 小时)
BALANCE_CACHE_TTL = 86400
class ProviderOpsService:
"""
Provider 操作服务
提供:
- 凭据管理(加密存储、读取)
- 连接管理(建立、断开、状态检查)
- 操作执行(余额查询、签到等)
"""
# 凭据中需要加密的字段
SENSITIVE_FIELDS = {"api_key", "password", "session_token", "cookie_string", "cookies"}
def __init__(self, db: Session):
self.db = db
self.crypto = CryptoService()
# 连接器缓存 {provider_id: ProviderConnector}
self._connectors: Dict[str, ProviderConnector] = {}
# ==================== 配置管理 ====================
def get_config(self, provider_id: str) -> Optional[ProviderOpsConfig]:
"""
获取 Provider 的操作配置
Args:
provider_id: Provider ID
Returns:
配置对象,未配置则返回 None
"""
provider = self._get_provider(provider_id)
if not provider:
return None
config_data = (provider.config or {}).get("provider_ops")
if not config_data:
return None
return ProviderOpsConfig.from_dict(config_data)
def save_config(
self,
provider_id: str,
config: ProviderOpsConfig,
) -> bool:
"""
保存 Provider 的操作配置
Args:
provider_id: Provider ID
config: 配置对象
Returns:
是否保存成功
"""
provider = self._get_provider(provider_id)
if not provider:
return False
# 加密敏感凭据
encrypted_credentials = self._encrypt_credentials(config.connector_credentials)
logger.debug(
f"加密凭据: provider_id={provider_id}, "
f"input_keys={list(config.connector_credentials.keys())}, "
f"output_keys={list(encrypted_credentials.keys())}, "
f"has_api_key={bool(config.connector_credentials.get('api_key'))}"
)
# 构建配置
config_dict = config.to_dict()
config_dict["connector"]["credentials"] = encrypted_credentials
# 更新 Provider 配置
provider_config = dict(provider.config or {})
provider_config["provider_ops"] = config_dict
provider.config = provider_config
self.db.commit()
# 清除连接器缓存
if provider_id in self._connectors:
del self._connectors[provider_id]
logger.info(f"保存 Provider 操作配置: provider_id={provider_id}")
return True
def delete_config(self, provider_id: str) -> bool:
"""
删除 Provider 的操作配置
Args:
provider_id: Provider ID
Returns:
是否删除成功
"""
provider = self._get_provider(provider_id)
if not provider:
return False
provider_config = dict(provider.config or {})
if "provider_ops" in provider_config:
del provider_config["provider_ops"]
provider.config = provider_config
self.db.commit()
# 清除连接器缓存
if provider_id in self._connectors:
del self._connectors[provider_id]
return True
# ==================== 连接管理 ====================
async def connect(
self,
provider_id: str,
credentials: Optional[Dict[str, Any]] = None,
) -> tuple[bool, str]:
"""
建立与 Provider 的连接
Args:
provider_id: Provider ID
credentials: 凭据(如果为 None 则使用已保存的凭据)
Returns:
(是否成功, 消息)
"""
provider = self._get_provider(provider_id)
if not provider:
return False, "Provider 不存在"
config = self.get_config(provider_id)
if not config:
return False, "未配置操作设置"
# 获取架构
registry = get_registry()
architecture = registry.get_or_default(config.architecture_id)
# 获取 base_url
base_url = self._get_provider_base_url(provider)
if not base_url:
return False, "Provider 未配置 base_url"
# 创建连接器
try:
connector = architecture.get_connector(
base_url=base_url,
auth_type=config.connector_auth_type,
config=config.connector_config,
)
except ValueError as e:
return False, str(e)
# 使用提供的凭据或已保存的凭据
if credentials:
actual_credentials = credentials
else:
actual_credentials = self._decrypt_credentials(config.connector_credentials)
logger.debug(
f"解密凭据: provider_id={provider_id}, "
f"encrypted_keys={list(config.connector_credentials.keys())}, "
f"decrypted_keys={list(actual_credentials.keys())}, "
f"has_api_key={bool(actual_credentials.get('api_key'))}"
)
if not actual_credentials:
return False, "未提供凭据"
# 建立连接
logger.info(
f"尝试连接: provider_id={provider_id}, "
f"credentials_keys={list(actual_credentials.keys())}"
)
success = await connector.connect(actual_credentials)
if success:
self._connectors[provider_id] = connector
return True, "连接成功"
else:
state = connector.get_state()
return False, state.last_error or "连接失败"
async def disconnect(self, provider_id: str) -> bool:
"""
断开与 Provider 的连接
Args:
provider_id: Provider ID
Returns:
是否成功
"""
connector = self._connectors.get(provider_id)
if connector:
await connector.disconnect()
del self._connectors[provider_id]
return True
def get_connection_status(self, provider_id: str) -> ConnectorState:
"""
获取连接状态
Args:
provider_id: Provider ID
Returns:
连接器状态
"""
connector = self._connectors.get(provider_id)
if connector:
return connector.get_state()
# 未连接
config = self.get_config(provider_id)
return ConnectorState(
status=ConnectorStatus.DISCONNECTED,
auth_type=config.connector_auth_type if config else ConnectorAuthType.NONE,
)
# ==================== 操作执行 ====================
async def execute_action(
self,
provider_id: str,
action_type: ProviderActionType,
action_config: Optional[Dict[str, Any]] = None,
) -> ActionResult:
"""
执行操作
Args:
provider_id: Provider ID
action_type: 操作类型
action_config: 操作配置(覆盖默认配置)
Returns:
操作结果
"""
# 检查连接状态
connector = self._connectors.get(provider_id)
if not connector:
# 尝试自动连接
success, message = await self.connect(provider_id)
if not success:
return ActionResult(
status=ActionStatus.AUTH_FAILED,
action_type=action_type,
message=f"连接失败: {message}",
)
connector = self._connectors.get(provider_id)
if not connector or not await connector.is_authenticated():
return ActionResult(
status=ActionStatus.AUTH_EXPIRED,
action_type=action_type,
message="认证已过期,请重新连接",
)
# 获取配置
config = self.get_config(provider_id)
if not config:
return ActionResult(
status=ActionStatus.NOT_CONFIGURED,
action_type=action_type,
message="未配置操作设置",
)
# 获取架构
registry = get_registry()
architecture = registry.get_or_default(config.architecture_id)
# 检查是否支持该操作
if not architecture.supports_action(action_type):
return ActionResult(
status=ActionStatus.NOT_SUPPORTED,
action_type=action_type,
message=f"架构 {architecture.architecture_id} 不支持 {action_type.value} 操作",
)
# 合并操作配置
saved_action_config = config.actions.get(action_type.value, {}).get("config", {})
merged_config = {**saved_action_config, **(action_config or {})}
# 创建操作实例
action = architecture.get_action(action_type, merged_config)
# 执行操作
async with connector.get_client() as client:
result = await action.execute(client)
return result
async def query_balance(
self,
provider_id: str,
config: Optional[Dict[str, Any]] = None,
) -> ActionResult:
"""
查询余额(快捷方法)
Args:
provider_id: Provider ID
config: 操作配置
Returns:
操作结果
"""
result = await self.execute_action(
provider_id, ProviderActionType.QUERY_BALANCE, config
)
# 成功时更新缓存
if result.status == ActionStatus.SUCCESS and result.data:
await self._cache_balance(provider_id, result)
return result
async def query_balance_with_cache(
self,
provider_id: str,
trigger_refresh: bool = True,
) -> ActionResult:
"""
查询余额(优先返回缓存,可触发异步刷新)
Args:
provider_id: Provider ID
trigger_refresh: 是否触发后台异步刷新
Returns:
操作结果(可能是缓存的)
"""
# 尝试从缓存获取
cached = await self._get_cached_balance(provider_id)
if cached:
# 有缓存,可选触发后台刷新
if trigger_refresh:
# 后台任务内部已处理异常并记录日志,无需额外回调
asyncio.create_task(self._refresh_balance_async(provider_id))
return cached
# 没有缓存,同步查询一次(首次访问)
logger.info(f"余额缓存未命中,同步查询: provider_id={provider_id}")
return await self.query_balance(provider_id)
async def _refresh_balance_async(self, provider_id: str) -> None:
"""后台异步刷新余额(使用独立的数据库 session"""
try:
# 后台任务需要创建独立的 session因为原请求的 session 可能已关闭
with create_session() as db:
service = ProviderOpsService(db)
await service.query_balance(provider_id)
except Exception as e:
logger.warning(f"异步刷新余额失败: provider_id={provider_id}, error={e}")
async def _cache_balance(self, provider_id: str, result: ActionResult) -> None:
"""缓存余额结果"""
cache_key = f"provider_ops:balance:{provider_id}"
# 序列化 BalanceInfo
data = result.data
if isinstance(data, BalanceInfo):
data = asdict(data)
cache_data = {
"status": result.status.value,
"data": data,
"executed_at": result.executed_at.isoformat(),
"response_time_ms": result.response_time_ms,
}
await CacheService.set(cache_key, cache_data, BALANCE_CACHE_TTL)
async def _get_cached_balance(self, provider_id: str) -> Optional[ActionResult]:
"""获取缓存的余额"""
cache_key = f"provider_ops:balance:{provider_id}"
cached = await CacheService.get(cache_key)
if not cached:
return None
# 反序列化
try:
data = cached.get("data")
if data and isinstance(data, dict):
# 转回 BalanceInfo
data = BalanceInfo(
total_granted=data.get("total_granted"),
total_used=data.get("total_used"),
total_available=data.get("total_available"),
currency=data.get("currency", "USD"),
extra=data.get("extra", {}),
)
executed_at_str = cached.get("executed_at")
executed_at = (
datetime.fromisoformat(executed_at_str)
if executed_at_str
else datetime.now(timezone.utc)
)
return ActionResult(
status=ActionStatus(cached.get("status", "success")),
action_type=ProviderActionType.QUERY_BALANCE,
data=data,
executed_at=executed_at,
response_time_ms=cached.get("response_time_ms"),
cache_ttl_seconds=BALANCE_CACHE_TTL,
)
except Exception as e:
logger.warning(f"解析缓存余额失败: provider_id={provider_id}, error={e}")
return None
async def checkin(
self,
provider_id: str,
config: Optional[Dict[str, Any]] = None,
) -> ActionResult:
"""
签到(快捷方法)
Args:
provider_id: Provider ID
config: 操作配置
Returns:
操作结果
"""
return await self.execute_action(provider_id, ProviderActionType.CHECKIN, config)
# ==================== 辅助方法 ====================
def _get_provider(self, provider_id: str) -> Optional[Provider]:
"""获取 Provider"""
return self.db.query(Provider).filter(Provider.id == provider_id).first()
def _get_provider_base_url(self, provider: Provider) -> Optional[str]:
"""从 Provider 获取 base_url"""
# 优先从第一个 endpoint 获取
if provider.endpoints:
for endpoint in provider.endpoints:
if endpoint.base_url:
return endpoint.base_url
# 从 config 获取
config = provider.config or {}
if "base_url" in config:
return config["base_url"]
# 从 website 获取
if provider.website:
return provider.website
return None
def _encrypt_credentials(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
"""加密凭据中的敏感字段"""
encrypted = {}
for key, value in credentials.items():
if key in self.SENSITIVE_FIELDS and isinstance(value, str):
if value: # 只加密非空值
encrypted[key] = self.crypto.encrypt(value)
logger.debug(f"加密字段 {key}: 原始长度={len(value)}, 加密后长度={len(encrypted[key])}")
else:
logger.warning(f"跳过空值字段 {key}")
encrypted[key] = value
elif key == "cookies" and isinstance(value, dict):
# cookies 整体加密
encrypted[key] = self.crypto.encrypt(json.dumps(value))
else:
encrypted[key] = value
return encrypted
def _decrypt_credentials(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
"""解密凭据中的敏感字段"""
decrypted = {}
for key, value in credentials.items():
if key in self.SENSITIVE_FIELDS and isinstance(value, str):
try:
decrypted[key] = self.crypto.decrypt(value)
except Exception as e:
logger.warning(f"解密字段 {key} 失败: {e}")
decrypted[key] = value # 解密失败则保持原值
elif key == "cookies" and isinstance(value, str):
try:
decrypted[key] = json.loads(self.crypto.decrypt(value))
except Exception as e:
logger.warning(f"解密 cookies 失败: {e}")
decrypted[key] = value
else:
decrypted[key] = value
return decrypted
def get_masked_credentials(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
"""
获取脱敏后的凭据
解密凭据并对敏感字段进行脱敏处理(显示部分字符)。
Args:
credentials: 加密的凭据
Returns:
脱敏后的凭据
"""
decrypted = self._decrypt_credentials(credentials)
for field in self.SENSITIVE_FIELDS:
if field in decrypted and decrypted[field]:
value = str(decrypted[field])
# 显示前4位和后4位中间固定4个 *(如 sk-x****a12k
if len(value) > 12:
decrypted[field] = value[:4] + "****" + value[-4:]
elif len(value) > 8:
decrypted[field] = value[:2] + "****" + value[-2:]
else:
decrypted[field] = "*" * len(value)
return decrypted
def merge_credentials_with_saved(
self,
provider_id: str,
credentials: Dict[str, Any],
) -> Dict[str, Any]:
"""
合并凭据:如果请求中的敏感字段为空,使用已保存的凭据
用于验证和保存配置时,当用户未重新输入敏感字段时保留原有值。
Args:
provider_id: Provider ID
credentials: 请求中的凭据
Returns:
合并后的凭据
"""
merged = dict(credentials)
saved_config = self.get_config(provider_id)
if saved_config:
saved_credentials = self._decrypt_credentials(saved_config.connector_credentials)
sensitive_fields = ["api_key", "password", "session_token", "cookie_string", "cookies"]
for field in sensitive_fields:
# 如果请求中该字段为空或只包含星号(脱敏值),使用已保存的值
req_value = merged.get(field, "")
if not req_value or (isinstance(req_value, str) and set(req_value) <= {"*"}):
if field in saved_credentials:
merged[field] = saved_credentials[field]
logger.debug(f"合并凭据 - 使用已保存的 {field}")
return merged
# ==================== 批量操作 ====================
async def batch_query_balance(
self, provider_ids: Optional[List[str]] = None
) -> Dict[str, ActionResult]:
"""
批量查询余额
Args:
provider_ids: Provider ID 列表None 表示查询所有已配置的
Returns:
{provider_id: result}
"""
if provider_ids is None:
# 查询所有已配置的 Provider
providers = self.db.query(Provider).filter(Provider.is_active.is_(True)).all()
provider_ids = [
p.id
for p in providers
if p.config and p.config.get("provider_ops")
]
results = {}
for provider_id in provider_ids:
results[provider_id] = await self.query_balance(provider_id)
return results
# ==================== 认证验证 ====================
async def verify_auth(
self,
base_url: str,
architecture_id: str,
auth_type: ConnectorAuthType,
config: Dict[str, Any],
credentials: Dict[str, Any],
) -> Dict[str, Any]:
"""
验证认证配置
在保存前测试认证是否有效。
认证逻辑委托给对应的 Architecture 实现。
Args:
base_url: API 基础地址
architecture_id: 架构 ID
auth_type: 认证类型
config: 连接器配置
credentials: 凭据
Returns:
验证结果
"""
import httpx
# 移除 base_url 末尾的斜杠
base_url = base_url.rstrip("/")
# 获取架构实例
registry = get_registry()
architecture = registry.get_or_default(architecture_id)
# 使用架构的方法构建请求
verify_endpoint = f"{base_url}{architecture.get_verify_endpoint()}"
headers = architecture.build_verify_headers(config, credentials)
logger.debug(
f"验证认证: architecture={architecture_id}, "
f"endpoint={verify_endpoint}, headers={list(headers.keys())}"
)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(verify_endpoint, headers=headers)
# 尝试解析 JSON
try:
data = response.json()
except Exception:
data = {}
# 使用架构的方法解析响应
result = architecture.parse_verify_response(response.status_code, data)
return result.to_dict()
except httpx.TimeoutException:
return {"success": False, "message": "连接超时"}
except httpx.ConnectError as e:
return {"success": False, "message": f"连接失败: {str(e)}"}
except Exception as e:
logger.error(f"验证认证失败: {e}")
return {"success": False, "message": f"验证失败: {str(e)}"}

View File

@@ -0,0 +1,157 @@
"""
Provider 操作模块类型定义
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, List, Optional
class ConnectorAuthType(str, Enum):
"""连接器认证类型"""
API_KEY = "api_key" # API Key 直接认证
SESSION_LOGIN = "session_login" # 用户名密码登录获取 Session
OAUTH = "oauth" # OAuth 流程
COOKIE = "cookie" # 直接使用 Cookie
NONE = "none" # 无需认证
class ProviderActionType(str, Enum):
"""提供商操作类型"""
QUERY_BALANCE = "query_balance" # 查询余额
CHECKIN = "checkin" # 签到
CLAIM_QUOTA = "claim_quota" # 领取额度
REFRESH_TOKEN = "refresh_token" # 刷新 Token
GET_USAGE = "get_usage" # 获取使用记录
GET_MODELS = "get_models" # 获取可用模型列表
CUSTOM = "custom" # 自定义操作
class ActionStatus(str, Enum):
"""操作执行状态"""
SUCCESS = "success" # 成功
AUTH_FAILED = "auth_failed" # 认证失败
AUTH_EXPIRED = "auth_expired" # 认证过期
RATE_LIMITED = "rate_limited" # 频率限制
NETWORK_ERROR = "network_error" # 网络错误
PARSE_ERROR = "parse_error" # 响应解析错误
NOT_CONFIGURED = "not_configured" # 未配置
NOT_SUPPORTED = "not_supported" # 不支持
ALREADY_DONE = "already_done" # 已完成(如今日已签到)
UNKNOWN_ERROR = "unknown_error" # 未知错误
class ConnectorStatus(str, Enum):
"""连接器状态"""
DISCONNECTED = "disconnected" # 未连接
CONNECTING = "connecting" # 连接中
CONNECTED = "connected" # 已连接
EXPIRED = "expired" # 已过期
ERROR = "error" # 错误
@dataclass
class BalanceInfo:
"""余额信息"""
total_granted: Optional[float] = None # 总授予额度
total_used: Optional[float] = None # 已使用额度
total_available: Optional[float] = None # 可用余额
expires_at: Optional[datetime] = None # 过期时间
currency: str = "USD" # 货币单位
extra: Dict[str, Any] = field(default_factory=dict) # 额外信息
@dataclass
class CheckinInfo:
"""签到信息"""
reward: Optional[float] = None # 奖励额度
streak_days: Optional[int] = None # 连续签到天数
next_reward: Optional[float] = None # 下次奖励
message: Optional[str] = None # 签到消息
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ActionResult:
"""操作执行结果"""
status: ActionStatus
action_type: ProviderActionType
data: Optional[Any] = None # 操作返回的数据(如 BalanceInfo, CheckinInfo
message: Optional[str] = None # 消息
executed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
response_time_ms: Optional[int] = None # 响应时间(毫秒)
raw_response: Optional[Dict[str, Any]] = None # 原始响应(调试用)
cache_ttl_seconds: int = 300 # 建议缓存时间
retry_after_seconds: Optional[int] = None # 失败后重试间隔
@property
def is_success(self) -> bool:
return self.status == ActionStatus.SUCCESS
@dataclass
class ConnectorState:
"""连接器状态信息"""
status: ConnectorStatus
auth_type: ConnectorAuthType
connected_at: Optional[datetime] = None
expires_at: Optional[datetime] = None
last_error: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ProviderOpsConfig:
"""Provider 操作配置(存储在 Provider.config['provider_ops'] 中)"""
architecture_id: str = "generic_api"
# 连接器配置
connector_auth_type: ConnectorAuthType = ConnectorAuthType.API_KEY
connector_config: Dict[str, Any] = field(default_factory=dict)
connector_credentials: Dict[str, Any] = field(default_factory=dict) # 加密存储
# 操作配置
actions: Dict[str, Dict[str, Any]] = field(default_factory=dict)
# 定时任务配置
schedule: Dict[str, str] = field(default_factory=dict) # {action_type: cron_expression}
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "ProviderOpsConfig":
"""从字典创建配置"""
if not data:
return cls()
return cls(
architecture_id=data.get("architecture_id", "generic_api"),
connector_auth_type=ConnectorAuthType(
data.get("connector", {}).get("auth_type", "api_key")
),
connector_config=data.get("connector", {}).get("config", {}),
connector_credentials=data.get("connector", {}).get("credentials", {}),
actions=data.get("actions", {}),
schedule=data.get("schedule", {}),
)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典(用于存储)"""
return {
"architecture_id": self.architecture_id,
"connector": {
"auth_type": self.connector_auth_type.value,
"config": self.connector_config,
"credentials": self.connector_credentials,
},
"actions": self.actions,
"schedule": self.schedule,
}